feat(ssa): Complete Phase V-A editable analysis plan variables

Features:
- Add editable variable selection in workflow plan (SingleVarSelect + MultiVarTags)
- Implement 3-layer flexible interception (warning bar + icon + blocking dialog)
- Add tool_param_constraints.json for 12 statistical tools parameter validation
- Add PATCH /workflow/:id/params API with Zod structural validation
- Implement synchronous parameter sync before execution (Promise chaining)
- Fix LLM hallucination by strict system prompt constraints
- Fix DynamicReport object-based rows compatibility (R baseline_table)
- Fix Word export row.map error with same normalization logic
- Restore inferGroupingVar for smart default variable selection
- Add ReactMarkdown rendering in SSAChatPane
- Update SSA module status document to v3.5

Modified files:
- backend: workflow.routes, ChatHandlerService, SystemPromptService, FlowTemplateService
- frontend: WorkflowTimeline, SSAWorkspacePane, DynamicReport, SSAChatPane, ssaStore, ssa.css
- config: tool_param_constraints.json (new)
- docs: SSA status doc, team review reports

Tested: Cohort study end-to-end execution + report export verified
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-24 13:08:29 +08:00
parent dc6b292308
commit 85fda830c2
27 changed files with 2732 additions and 154 deletions

View File

@@ -0,0 +1,250 @@
# **工具 3 批量提取技术架构设计:散装派发与轮询收口模式**
**文档版本:** V2.0 (Startup Agile Edition)
**核心架构:** 散装派发 (Scatter) \+ 独立单兵 Worker \+ 定时轮询聚合 (Polling Aggregator)
**业务目标:** 支撑工具 3百篇文献并发提取告别行锁争用与死锁实现最高开发效率与多节点并发性能。
## **💡 一、 为什么选择这套架构?(The Philosophy)**
在处理“1 个任务包含 100 篇文献提取”的场景时,我们放弃了传统的“父子任务 Fan-out”强一致性模型转而采用一种\*\*“最终一致性”\*\*的松耦合架构:
1. **极简的写入逻辑 (无并发冲突)** 100 个 Worker 抢到任务后各干各的,**只更新属于自己的那 1 行 Result 记录**。绝对不去触碰父任务Task 表),彻底消灭了多进程对同一行的行锁竞争 (Row-Lock Contention)。
2. **读写分离的进度感知:** 前端查询进度时API 实时去数据库做 COUNT(Result) 聚合,读操作极快且不阻塞写操作。
3. **单线程结账 (无死锁)** 用一个每 10 秒跑一次的全局定时任务Aggregator充当“包工头”扫描所有任务发现哪个任务下面的子项全做完了就给它打上 Completed 标签。
## **🏗️ 二、 核心数据流转图 (Data Flow)**
\[ 前端 Client \]
│ 1\. POST /tasks (勾选了 100 篇文献)
\[ Node.js API (Controller) \]
│ 2\. 创建 1 个 Task 记录
│ 3\. 批量创建 100 个 Result 记录 (status: pending)
│ 4\. 🚀 散装派发for 循环 100 次 \`pgBoss.send('asl\_extract\_single', ...)\`
└─\> 返回 TaskID 给前端 (耗时 \< 0.1秒)
\======================== 异步处理域 (多 SAE 实例并发) \========================
\[ pg-boss 队列 (Postgres) \] \<── 存放着 100 个单篇提取任务
\[ Pod A \] \[ Pod B \] \[ Pod C \]
Worker 抢单 Worker 抢单 Worker 抢单
│ │ │
├─ 提取 文献 1 ├─ 提取 文献 2 ├─ 提取 文献 3
│ │ │
└─ UPDATE Result 1 └─ UPDATE Result 2 └─ UPDATE Result 3
(status: completed) (status: error) (status: completed)
※ 各干各的,互不干扰,不碰 Task 表!
\======================== 全局收口域 (单线程定时器) \========================
\[ pg-boss 调度器 (10秒触发一次) \]
\[ Task Aggregator (全局唯一包工头) \]
│ 1\. 查出所有 status='processing' 的 Task
│ 2\. GROUP BY 统计其下 Result 的状态
│ 3\. 如果 pending=0 且 extracting=0
└─\> UPDATE Task SET status='completed' (终点收口!)
## **🗄️ 三、 数据库设计微调 (Prisma Schema)**
采用该模式后AslExtractionTask 表不再需要频繁更新,成为一个极其稳定的元数据表。
model AslExtractionTask {
id String @id @default(uuid())
projectId String
templateId String
totalCount Int // 总文献数 (前端传入,创建后不再改变)
// 核心状态:'processing' (进行中), 'completed' (已完成)
// 此字段仅由 API 创建时设为 processing由 Aggregator 统一改为 completed
status String @default("processing")
// 弃用:不再需要 successCount / failedCount 字段,改由实时 COUNT 聚合得出!
createdAt DateTime @default(now())
completedAt DateTime?
results AslExtractionResult\[\]
@@schema("asl\_schema")
}
model AslExtractionResult {
id String @id @default(uuid())
taskId String
pkbDocumentId String
// 子任务状态: 'pending' (排队中), 'extracting' (提取中), 'completed' (成功), 'error' (失败)
status String @default("pending")
extractedData Json? // 最终提取的 JSON 结果
errorMessage String?
task AslExtractionTask @relation(fields: \[taskId\], references: \[id\])
// 添加索引:极大提升 Aggregator 聚合统计的速度
@@index(\[taskId, status\])
@@schema("asl\_schema")
}
## **💻 四、 核心代码落地指南 (Show me the code)**
### **1\. API 层:极速散装派发**
**文件:** ExtractionController.ts
无需编写 Manager Worker直接在 API 接口中进行 for 循环派发。
async function createTask(req: Request, reply: FastifyReply) {
const { projectId, templateId, documentIds } \= req.body;
if (documentIds.length \=== 0\) throw new Error("未选择文献");
// 1\. 批量创建记录
const task \= await prisma.aslExtractionTask.create({
data: { projectId, templateId, totalCount: documentIds.length, status: 'processing' }
});
const resultsData \= documentIds.map(docId \=\> ({
taskId: task.id, pkbDocumentId: docId, status: 'pending'
}));
await prisma.aslExtractionResult.createMany({ data: resultsData });
// 查询出刚创建的 Result IDs
const createdResults \= await prisma.aslExtractionResult.findMany({ where: { taskId: task.id } });
// 2\. 🚀 散装派发 (Scatter) \- 直接压入单篇队列
// 即使 100 次循环,得益于 pg-boss 内部的批量插入优化,耗时极短
const jobs \= createdResults.map(result \=\> ({
name: 'asl\_extract\_single',
data: { resultId: result.id, pkbDocumentId: result.pkbDocumentId },
options: { retryLimit: 3, retryBackoff: true, expireInMinutes: 30 } // 单篇重试机制
}));
await jobQueue.insert(jobs); // 假设你们底层封装了批量 insert 方法
return reply.send({ success: true, taskId: task.id });
}
### **2\. Worker 层:无脑单兵作战**
**文件:** ExtractionSingleWorker.ts
这里是真正调 MinerU 和 LLM 的地方。**没有任何并发锁,没有任何父任务更新。**
// 限制单机并发,防 OOM 和 API 熔断
jobQueue.work('asl\_extract\_single', { teamConcurrency: 10 }, async (job) \=\> {
const { resultId, pkbDocumentId } \= job.data;
// 1\. 更改自身状态为 extracting (不碰父任务!)
await prisma.aslExtractionResult.update({
where: { id: resultId }, data: { status: 'extracting' }
});
try {
// 2\. 执行漫长且脆弱的业务逻辑 (MinerU \+ DeepSeek-V3)
const data \= await extractLogic(pkbDocumentId);
// 3\. 成功:只更新自身!(绝对安全)
await prisma.aslExtractionResult.update({
where: { id: resultId },
data: { status: 'completed', extractedData: data }
});
} catch (error) {
// 错误分级判断
if (isPermanentError(error)) {
// 致命错误:更新自身为 error打断重试
await prisma.aslExtractionResult.update({
where: { id: resultId }, data: { status: 'error', errorMessage: error.message }
});
return { success: false, note: 'Permanent Error' };
} else {
// 临时错误 (如网络波动):让出状态,抛出给 pg-boss 重试
await prisma.aslExtractionResult.update({
where: { id: resultId }, data: { status: 'pending' }
});
throw error;
}
}
});
### **3\. Aggregator 层:全局包工头轮询收口**
**文件:** ExtractionAggregator.ts
**触发机制:** 使用 pg-boss 定时器,保证多 Pod 环境下同一时间只有 1 个机器执行此检查。
// 在后端启动时注册:每 10 秒跑一次
await jobQueue.schedule('asl\_extraction\_aggregator', '\*/10 \* \* \* \* \*');
jobQueue.work('asl\_extraction\_aggregator', async () \=\> {
// 1\. 找到所有还没结束的父任务
const activeTasks \= await prisma.aslExtractionTask.findMany({
where: { status: 'processing' }
});
for (const task of activeTasks) {
// 2\. 分组统计其子任务状态 (聚合查询极快)
const stats \= await prisma.aslExtractionResult.groupBy({
by: \['status'\],
where: { taskId: task.id },
\_count: true
});
const pendingCount \= stats.find(s \=\> s.status \=== 'pending')?.\_count || 0;
const extractingCount \= stats.find(s \=\> s.status \=== 'extracting')?.\_count || 0;
// 3\. 收口逻辑:没有任何人在排队或干活了,说明这批活彻底干完了(不论成功还是失败)
if (pendingCount \=== 0 && extractingCount \=== 0\) {
await prisma.aslExtractionTask.update({
where: { id: task.id },
data: { status: 'completed', completedAt: new Date() }
});
// 可选:在这里触发全量完成的业务动作 (如发送企业微信通知)
logger.info(\`Task ${task.id} completely finished via Aggregator\!\`);
}
}
});
### **4\. 前端查询 API读写分离的进度感知**
**文件:** TaskStatusController.ts
由于父任务表没有 successCount前端轮询调用 /tasks/:taskId/status 时,我们**实时读取计算进度**。
async function getTaskStatus(req, reply) {
const { taskId } \= req.params;
const task \= await prisma.aslExtractionTask.findUnique({ where: { id: taskId }});
// 实时动态 COUNT取代维护冗余字段 (100条数据的 count 耗时 \< 1ms完全无感)
const successCount \= await prisma.aslExtractionResult.count({
where: { taskId, status: 'completed' }
});
const failedCount \= await prisma.aslExtractionResult.count({
where: { taskId, status: 'error' }
});
return reply.send({
status: task.status, // processing 或 completed
progress: {
total: task.totalCount,
success: successCount,
failed: failedCount,
percent: Math.round(((successCount \+ failedCount) / task.totalCount) \* 100\)
}
});
}
## **🛡️ 五、 方案优势与降维打击总结**
采用这套“散装派发 \+ 轮询聚合”模式后,您的团队获得了如下战略优势:
1. **彻底告别死锁 (No Deadlocks)** 不再有恶心的乐观锁和竞争态,研发人员只需要专注写“解析 PDF、调大模型、更新一条数据”的纯粹业务逻辑。
2. **自带清道夫免疫 (Sweeper-free)** 如果某个 Node.js 进程在提取中途“猝死”OOM该篇文献的状态会一直卡在 extracting。pg-boss 发现它超时后会重新拉起变为 pending。只要它还在 pending/extractingAggregator 就不会关闭父任务。这天然规避了此前“硬崩溃导致永远卡死”的顶级漏洞。
3. **开发提速 200%** 架构理解成本降至最低。新人一听就懂:“打散分发,各个击破,定时结账”。
4. **性能拉满 (Max Scale-out)** 多 SAE 实例部署时100 个任务均匀分布在所有机器上。数据库没有任何行锁竞争CPU 和 IO 利用率达到最完美的线性扩展。
**恭喜团队做出了最符合创业公司发展阶段的高可用架构决策!您可以直接将本设计文档交由后端研发开展 Sprint 1 的开发!**

View File

@@ -3,7 +3,7 @@
> **所属:** 工具 3 全文智能提取工作台 V2.0
> **架构总纲:** `08-工具3-全文智能提取工作台V2.0开发计划.md`
> **代码手册:** `08d-工具3-代码模式与技术规范.md`(所有代码模式均在此手册中,开发时按需查阅)
> **建议时间:** Week 15-6 天)
> **建议时间:** Week 15.5-6.5 天,含 v1.6 Sweeper 清道夫 0.5 天)
> **核心目标:** 证明 "PKB 拿数据 → Fan-out 分发 → LLM 盲提 → 数据落库 → 前端看到 completed" 这条管线是通的。
---
@@ -69,13 +69,17 @@
- `PkbBridgeService.ts`:调用 `PkbExportService`,代理所有 PKB 数据访问
**Step C — Fan-out Manager + Child Worker1 天)⚠️ 核心战役:**
- `ExtractionManagerWorker.ts`:读取任务 → ⚠️ v1.5 批量快照 PKB 元数据(`snapshotStorageKey` + `snapshotFilename`)冻结到 `AslExtractionResult` → 为每篇文献 `pgBoss.send('asl_extraction_child', ...)` → 退出Fire-and-forget
- `ExtractionManagerWorker.ts`:读取任务 → 🆕 **v1.6 空集合守卫**`results.length === 0` → 直接 completed ⚠️ v1.5 批量快照 PKB 元数据(`snapshotStorageKey` + `snapshotFilename`)冻结到 `AslExtractionResult` → 为每篇文献 `pgBoss.send('asl_extraction_child', ...)` → 退出Fire-and-forget
- `ExtractionChildWorker.ts` 完整逻辑:
1. **乐观锁抢占**`updateMany({ where: { status: 'pending' }, data: { status: 'extracting' } })`
2. **纯文本降级提取**:从 PKB 读 `extractedText` + 写死 RCT Schema → 调用 DeepSeek
3. **原子递增**:事务内 `update Result + increment Task counts`
4. **Last Child Wins**`successCount + failedCount >= totalCount` → 翻转 `status = completed`
5. **错误分级路由**:致命错误 return / 临时错误 throw
5. **错误分级路由**:致命错误 return / 🆕 **v1.6 临时错误 throw 前释放乐观锁(回退 status → pending**
**Step D — 🆕 Sweeper 清道夫注册0.5 天v1.6 新增):**
- `asl_extraction_sweeper`pg-boss 定时任务,每 10 分钟扫描 `processing``updatedAt > 2h` 的任务,强制标记 `failed`
- 使用 `updatedAt`(最后活跃时间)判断卡死,禁止用 `startedAt`(防误杀健康的超大批量任务)
**Worker 注册(遵守队列命名规范):**
```
@@ -94,9 +98,14 @@ jobQueue.work('asl_extraction_child', { teamConcurrency: 10 }, handler)
- [ ] Last Child Wins最后一个 Child 翻转 Task status = completed
- [ ] 致命错误PKB 文档不存在)→ 该篇标 error + 不重试 + 不阻塞其他篇
- [ ] 临时错误429→ pg-boss 指数退避重试
- [ ] 🆕 临时错误 throw 前回退 `status → pending`:模拟 429 重试后乐观锁仍能抢占成功v1.6 乐观锁释放验证)
- [ ] 🆕 Manager 空集合守卫:`results.length === 0` 时 Task 直接标记 `completed`v1.6 边界验证)
- [ ] 🆕 Sweeper 清道夫已注册:`asl_extraction_sweeper` 定时任务在 pg-boss 中可查到v1.6
- [ ] 🆕 Sweeper 判定条件为 `updatedAt > 2h`,而非 `startedAt`v1.6 防误杀验证)
> 📖 Fan-out 架构图、Worker 代码模式、研发红线见架构总纲 Task 2.3
> 📖 ACL 防腐层设计见架构总纲 Task 3.3b
> 📖 ACL 防腐层设计见架构总纲 Task 3.3b
> 📖 Sweeper、乐观锁释放、空集合守卫代码见 08d §4.2 / §4.3 / §4.6
---
@@ -150,6 +159,9 @@ jobQueue.work('asl_extraction_child', { teamConcurrency: 10 }, handler)
| 5 | Job Payload 仅传 ID< 200 bytes禁止塞 PDF 正文 | pg-boss 阻塞 |
| 6 | ACL 防腐层ASL 不 import PKB 内部类型 | 模块耦合蔓延 |
| 7 | Manager 必须快照 `snapshotStorageKey` + `snapshotFilename`Child 禁止运行时回查 PKB 获取 storageKeyv1.5 | 提取中 PKB 删文档 → 批量崩溃 |
| 8 | 🆕 临时错误 `throw` 前必须 `update({ status: 'pending' })` 释放乐观锁v1.6 | 重试时被"幂等跳过"计数永远缺一票Task 永久卡死 |
| 9 | 🆕 Manager 必须检查 `results.length === 0` 并直接 completedv1.6 | 空文献 → 无 Child → Last Child Wins 死锁 |
| 10 | 🆕 必须注册 `asl_extraction_sweeper` 清道夫(`updatedAt > 2h`,禁止用 `startedAt`v1.6 | 进程 OOM/SIGKILL 后 Task 永久挂起 |
---
@@ -160,6 +172,8 @@ jobQueue.work('asl_extraction_child', { teamConcurrency: 10 }, handler)
✅ PKB ACL 防腐层 → PkbExportService + PkbBridgeService
✅ Fan-out 全链路Manager → N × Child → Last Child Wins → completed
✅ 乐观锁 + 原子递增 + 错误分级路由 — 所有并发 Bug 已验证
✅ 🆕 Sweeper 清道夫注册v1.6 防 OOM/SIGKILL 卡死)
✅ 🆕 乐观锁释放 + 空集合守卫 + pg_notify 参数化v1.6 全量代码级同步)
✅ 前端三步走:选模板/选文献 → 轮询进度 → 极简结果列表
❌ 无 MinerU纯文本降级
❌ 无 SSE 日志流

View File

@@ -137,13 +137,11 @@ class PdfProcessingPipeline {
### 3.2 PKB 复用感知日志
```typescript
// 🚨 v1.6:使用 broadcastLog 跨 Pod 广播(替代 sseEmitter.emit
if (pkbExtractedText) {
this.sseEmitter.emit(taskId, {
type: 'log',
data: {
source: 'system',
message: `⚡ [Fast-path] Reused full-text from PKB (saved ~10s pymupdf4llm): ${filename}`,
}
await broadcastLog(taskId, {
source: 'system',
message: `⚡ [Fast-path] Reused full-text from PKB (saved ~10s pymupdf4llm): ${filename}`,
});
}
```
@@ -189,6 +187,21 @@ class ExtractionManagerWorker {
const task = await prisma.aslExtractionTask.findUnique({ where: { id: job.data.taskId } });
const results = await prisma.aslExtractionResult.findMany({ where: { taskId: task.id } });
// ═══════════════════════════════════════════════════════════
// 🚨 v1.6 空集合边界守卫
// 如果文献被全部删除或过滤后 results 为空,无 Child 被派发,
// Last Child Wins 永远不触发Task 永远卡在 processing。
// Manager 必须自己充当"收口人"直接完成任务。
// ═══════════════════════════════════════════════════════════
if (results.length === 0) {
await prisma.aslExtractionTask.update({
where: { id: task.id },
data: { status: 'completed', completedAt: new Date() },
});
await broadcastLog(task.id, { source: 'system', message: '⚠️ No documents to extract, task auto-completed.' });
return;
}
// ═══════════════════════════════════════════════════════════
// ⚠️ v1.5 PKB 数据一致性快照
// 提取任务可能持续 50 分钟,期间用户可能在 PKB 删除/修改文档。
@@ -284,11 +297,8 @@ class ExtractionChildWorker {
}),
]);
// SSE 推送日志
this.sseEmitter.emit(taskId, {
type: 'log',
data: { source: 'system', message: `${extractResult.filename} extracted` }
});
// 🚨 v1.6SSE 推送日志(跨 Pod 广播,替代原 sseEmitter.emit
await broadcastLog(taskId, { source: 'system', message: `${extractResult.filename} extracted` });
// ═══════════════════════════════════════════════════════════
// ⚠️ v1.4.2 补丁 1"Last Child Wins" 终止器
@@ -300,7 +310,7 @@ class ExtractionChildWorker {
where: { id: taskId },
data: { status: 'completed', completedAt: new Date() },
});
this.sseEmitter.emit(taskId, { type: 'complete' });
await broadcastLog(taskId, { source: 'system', type: 'complete', message: '🎉 All documents extracted.' });
}
} catch (error) {
@@ -324,12 +334,23 @@ class ExtractionChildWorker {
where: { id: taskId },
data: { status: 'completed', completedAt: new Date() },
});
this.sseEmitter.emit(taskId, { type: 'complete' });
await broadcastLog(taskId, { source: 'system', type: 'complete', message: '🎉 All documents extracted.' });
}
return { success: false, reason: 'Permanent failure, aborted retry.' };
}
// 临时错误 (429/网络抖动):直接 throw让 pg-boss 自动指数退避重试
// ═══════════════════════════════════════════════════════════
// 🚨 v1.6 补丁:临时错误 throw 前必须释放乐观锁!
// 原因:上方 updateMany 已将 status 改为 'extracting'。
// 如果裸 throwpg-boss 重试时乐观锁 where: { status: 'pending' }
// 返回 count=0 → 误判"幂等跳过" → 计数永远少一票 → Last Child Wins 永远不触发。
// ═══════════════════════════════════════════════════════════
await prisma.aslExtractionResult.update({
where: { id: resultId },
data: { status: 'pending' },
});
// 临时错误 (429/网络抖动)throw → pg-boss 自动指数退避重试
throw error;
}
}
@@ -388,6 +409,50 @@ class ExtractionChildWorker {
| **死信处理** | 超过 retryLimit 的 Job 进入 DLQ | pg-boss 内置 `onFail` handler 标记该篇为 `error` |
| **进度追踪** | 不在 Job data 中存大量进度 | 进度统一走 `CheckpointService`Job data 仅含 ID 引用 |
### 🆕 4.6 Sweeper 清道夫 — 进程硬崩溃兜底v1.6
> **Fan-out 指南 v1.2 强制要求:** 单兵 Worker 无法处理自身猝死OOM/SIGKILL
> 必须有系统级外部定时任务兜底。否则父任务可能永远卡在 `processing`。
```typescript
// ===== 工具 3 专属清道夫(模块启动时注册) =====
async function aslExtractionSweeper() {
const stuckTasks = await prisma.aslExtractionTask.findMany({
where: {
status: 'processing',
// 🚨 使用 updatedAt最后活跃时间而非 startedAt
// 500 篇文献正常排队可能需要 3+ 小时,用 startedAt 会误杀健康任务。
// 只要 Child 还在完成并递增计数updatedAt 就会持续刷新。
updatedAt: { lt: new Date(Date.now() - 2 * 60 * 60 * 1000) },
},
});
for (const task of stuckTasks) {
await prisma.aslExtractionTask.update({
where: { id: task.id },
data: {
status: 'failed',
errorMessage: '[Sweeper] No progress for 2h — likely Child Worker OOM/SIGKILL. Force-closed.',
completedAt: new Date(),
},
});
// 广播失败事件,确保前端 SSE 能感知
await broadcastLog(task.id, {
source: 'system',
type: 'complete',
message: '❌ [Sweeper] Task force-closed after 2h inactivity.',
});
logger.warn(`[Sweeper] Force-closed stuck task ${task.id} (no progress for 2h)`);
}
}
// 注册为 pg-boss 定时任务(每 10 分钟扫描一次)
await jobQueue.schedule('asl_extraction_sweeper', '*/10 * * * *');
await jobQueue.work('asl_extraction_sweeper', aslExtractionSweeper);
```
> **关键:** Sweeper 判断"卡死"基于 `updatedAt` 而非 `startedAt`,避免误杀正在排队的超大批量任务。
---
## 5. fuzzyQuoteMatch 验证算法
@@ -624,24 +689,29 @@ function ExtractionProgress({ taskId }: { taskId: string }) {
```typescript
// ===== Worker 发送端ExtractionChildWorker 内部) =====
// 替代原有的 this.sseEmitter.emit(),改用 NOTIFY 广播
// 🚨 v1.6 修正:使用 pg_notify() + Prisma 参数化绑定(免疫 SQL 注入)
// 替代原有的 this.sseEmitter.emit() 和 $executeRawUnsafe 字符串拼接
async function broadcastLog(taskId: string, logEntry: LogEntry) {
const payload = JSON.stringify({
const payloadStr = JSON.stringify({
taskId,
type: 'log',
type: logEntry.type ?? 'log',
data: logEntry,
});
// NOTIFY payload 上限 8000 bytes日志消息绰绰有余
await prisma.$executeRawUnsafe(
`NOTIFY asl_sse_channel, '${payload.replace(/'/g, "''")}'`
);
// 🚨 NOTIFY payload 物理上限 ~8000 bytesLLM 错误堆栈可能超限
const safePayload = payloadStr.length > 7000
? payloadStr.substring(0, 7000) + '..."}'
: payloadStr;
// 参数化绑定:$executeRaw Tagged Template + pg_notify()
// 彻底免疫 SQL 注入,无需手动 .replace 转义
await prisma.$executeRaw`SELECT pg_notify('asl_sse_channel', ${safePayload})`;
}
// 使用方式(替代 this.sseEmitter.emit
// 使用方式(全面替代 this.sseEmitter.emit
await broadcastLog(taskId, {
source: 'system',
message: `${filename} extracted`,
timestamp: new Date().toISOString(),
});
```
@@ -684,7 +754,8 @@ class SseNotifyBridge {
```
**关键约束:**
- NOTIFY payload 上限 **8000 bytes**(日志消息远小于此限制
- NOTIFY payload 物理上限 **~8000 bytes** → 发送前必须截断至 **7000 bytes**v1.6 强制规范
- **禁止 `$executeRawUnsafe` + 字符串拼接!** 必须使用 `$executeRaw` Tagged Template + `pg_notify()`v1.6 强制规范)
- LISTEN 连接必须**独立于 Prisma 连接池**PgClient 单独创建)
- NOTIFY 是 fire-and-forget无持久化完美匹配 v1.4 双轨制定位
- `complete` 事件仍走 NOTIFY 广播,确保"Last Child Wins"翻转状态后所有 Pod 的 SSE 客户端都能收到