feat(platform): Fix pg-boss queue conflict and add safety standards
Summary: - Fix pg-boss queue conflict (duplicate key violation on queue_pkey) - Add global error listener to prevent process crash - Reduce connection pool from 10 to 4 - Add graceful shutdown handling (SIGTERM/SIGINT) - Fix researchWorker recursive call bug in catch block - Make screeningWorker idempotent using upsert Security Standards (v1.1): - Prohibit recursive retry in Worker catch blocks - Prohibit payload bloat (only store fileKey/ID in job.data) - Require Worker idempotency (upsert + unique constraint) - Recommend task-specific expireInSeconds settings - Document graceful shutdown pattern New Features: - PKB signed URL endpoint for document preview/download - pg_bigm installation guide for Docker - Dockerfile.postgres-with-extensions for pgvector + pg_bigm Documentation: - Update Postgres-Only async task processing guide (v1.1) - Add troubleshooting SQL queries - Update safety checklist Tested: Local verification passed
This commit is contained in:
@@ -163,3 +163,4 @@ npm run dev
|
||||
*文档结束。如有问题请联系:gofeng117@163.com*
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -190,3 +190,4 @@ OSS_INTERNAL=false # 本地开发用公网,生产用内网
|
||||
- 审核:用户
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -560,3 +560,4 @@ npx tsx src/tests/test-pdf-ingest.ts <pdf文件路径>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
212
docs/02-通用能力层/03-RAG引擎/06-pg_bigm安装指南.md
Normal file
212
docs/02-通用能力层/03-RAG引擎/06-pg_bigm安装指南.md
Normal file
@@ -0,0 +1,212 @@
|
||||
# pg_bigm 安装指南
|
||||
|
||||
> **版本:** v1.0
|
||||
> **日期:** 2026-01-23
|
||||
> **状态:** 待部署
|
||||
> **用途:** 优化中文关键词检索性能
|
||||
|
||||
---
|
||||
|
||||
## 📋 概述
|
||||
|
||||
pg_bigm 是 PostgreSQL 的全文搜索扩展,专门针对中日韩(CJK)字符优化。相比原生 LIKE/ILIKE,pg_bigm 提供:
|
||||
|
||||
- **2-gram 索引**:将文本拆分为连续的 2 字符片段,支持任意子串匹配
|
||||
- **中文友好**:原生支持中文分词,无需额外配置
|
||||
- **性能提升**:10-100x 性能提升(取决于数据量)
|
||||
- **模糊搜索**:支持相似度搜索
|
||||
|
||||
---
|
||||
|
||||
## 🚀 安装步骤
|
||||
|
||||
### 方案 1:Docker 镜像升级(推荐)
|
||||
|
||||
**适用场景**:本地开发环境
|
||||
|
||||
```bash
|
||||
cd AIclinicalresearch
|
||||
|
||||
# 1. 备份现有数据
|
||||
docker exec ai-clinical-postgres pg_dump -U postgres -d ai_clinical_research > backup_$(date +%Y%m%d_%H%M%S).sql
|
||||
|
||||
# 2. 构建新镜像(包含 pgvector + pg_bigm)
|
||||
docker build -f Dockerfile.postgres-with-extensions -t ai-clinical-postgres:v1.1 .
|
||||
|
||||
# 3. 停止现有容器
|
||||
docker compose down
|
||||
|
||||
# 4. 修改 docker-compose.yml,替换镜像
|
||||
# image: pgvector/pgvector:pg15 → image: ai-clinical-postgres:v1.1
|
||||
|
||||
# 5. 启动新容器
|
||||
docker compose up -d
|
||||
|
||||
# 6. 验证扩展安装
|
||||
docker exec ai-clinical-postgres psql -U postgres -d ai_clinical_research -c "SELECT extname, extversion FROM pg_extension;"
|
||||
```
|
||||
|
||||
**预期输出**:
|
||||
```
|
||||
extname | extversion
|
||||
----------+------------
|
||||
plpgsql | 1.0
|
||||
vector | 0.8.0
|
||||
pg_bigm | 1.2
|
||||
```
|
||||
|
||||
### 方案 2:在现有容器中安装
|
||||
|
||||
**适用场景**:不想重建镜像
|
||||
|
||||
```bash
|
||||
# 1. 进入容器
|
||||
docker exec -it ai-clinical-postgres bash
|
||||
|
||||
# 2. 安装编译工具
|
||||
apt-get update && apt-get install -y build-essential postgresql-server-dev-15 wget
|
||||
|
||||
# 3. 下载并编译 pg_bigm
|
||||
cd /tmp
|
||||
wget https://github.com/pgbigm/pg_bigm/archive/refs/tags/v1.2-20200228.tar.gz
|
||||
tar -xzf v1.2-20200228.tar.gz
|
||||
cd pg_bigm-1.2-20200228
|
||||
make USE_PGXS=1
|
||||
make USE_PGXS=1 install
|
||||
|
||||
# 4. 清理
|
||||
rm -rf /tmp/pg_bigm* /tmp/v1.2-20200228.tar.gz
|
||||
apt-get purge -y build-essential postgresql-server-dev-15 wget
|
||||
apt-get autoremove -y
|
||||
|
||||
# 5. 退出容器
|
||||
exit
|
||||
|
||||
# 6. 创建扩展
|
||||
docker exec ai-clinical-postgres psql -U postgres -d ai_clinical_research -c "CREATE EXTENSION IF NOT EXISTS pg_bigm;"
|
||||
```
|
||||
|
||||
### 方案 3:阿里云 RDS
|
||||
|
||||
**适用场景**:生产环境(阿里云 RDS PostgreSQL)
|
||||
|
||||
阿里云 RDS PostgreSQL 15 **已内置** pg_bigm,只需执行:
|
||||
|
||||
```sql
|
||||
-- 连接到 RDS 数据库
|
||||
CREATE EXTENSION IF NOT EXISTS pg_bigm;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 使用方法
|
||||
|
||||
### 1. 创建 GIN 索引
|
||||
|
||||
```sql
|
||||
-- 为 ekb_chunk 表的 content 列创建 pg_bigm 索引
|
||||
CREATE INDEX IF NOT EXISTS idx_ekb_chunk_content_bigm
|
||||
ON ekb_schema.ekb_chunk
|
||||
USING gin (content gin_bigm_ops);
|
||||
|
||||
-- 验证索引创建
|
||||
SELECT indexname, indexdef FROM pg_indexes
|
||||
WHERE tablename = 'ekb_chunk' AND indexname LIKE '%bigm%';
|
||||
```
|
||||
|
||||
### 2. 查询示例
|
||||
|
||||
```sql
|
||||
-- 基本查询(使用索引)
|
||||
SELECT * FROM ekb_schema.ekb_chunk
|
||||
WHERE content LIKE '%银杏叶%';
|
||||
|
||||
-- 相似度查询
|
||||
SELECT *, bigm_similarity(content, '银杏叶副作用') AS similarity
|
||||
FROM ekb_schema.ekb_chunk
|
||||
WHERE content LIKE '%银杏叶%'
|
||||
ORDER BY similarity DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
### 3. 在 VectorSearchService 中使用
|
||||
|
||||
```typescript
|
||||
// keywordSearch 方法会自动检测 pg_bigm
|
||||
// 如果扩展可用,使用 GIN 索引加速
|
||||
// 否则 fallback 到 ILIKE
|
||||
|
||||
async keywordSearch(query: string, options: SearchOptions) {
|
||||
// 自动使用最优方案
|
||||
// pg_bigm: SELECT * WHERE content LIKE '%query%' (使用索引)
|
||||
// fallback: SELECT * WHERE content ILIKE '%query%' (全表扫描)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 性能对比
|
||||
|
||||
| 场景 | ILIKE(无索引) | pg_bigm(GIN索引) | 提升 |
|
||||
|------|----------------|-------------------|------|
|
||||
| 10万条记录 | 500ms | 5ms | 100x |
|
||||
| 100万条记录 | 5s | 50ms | 100x |
|
||||
| 中文2字符 | 支持 | 支持 | - |
|
||||
| 中文1字符 | 支持 | 不支持* | - |
|
||||
|
||||
> *pg_bigm 基于 2-gram,单字符查询需要至少2个字符
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
### 1. 索引大小
|
||||
|
||||
pg_bigm 的 GIN 索引会占用额外存储空间:
|
||||
|
||||
```sql
|
||||
-- 查看索引大小
|
||||
SELECT pg_size_pretty(pg_relation_size('idx_ekb_chunk_content_bigm'));
|
||||
```
|
||||
|
||||
预估:原始数据的 50%-100%
|
||||
|
||||
### 2. 写入性能
|
||||
|
||||
GIN 索引会影响写入性能:
|
||||
|
||||
- INSERT:约慢 20-30%
|
||||
- UPDATE content 字段:约慢 30-50%
|
||||
|
||||
**建议**:批量写入时可临时禁用索引
|
||||
|
||||
### 3. 最小查询长度
|
||||
|
||||
pg_bigm 基于 2-gram,单字符查询效果差:
|
||||
|
||||
```sql
|
||||
-- ❌ 效果差
|
||||
SELECT * WHERE content LIKE '%癌%';
|
||||
|
||||
-- ✅ 效果好
|
||||
SELECT * WHERE content LIKE '%肺癌%';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 相关文档
|
||||
|
||||
- [pg_bigm 官方文档](https://pgbigm.osdn.jp/pg_bigm_en-1-2.html)
|
||||
- [RAG 引擎使用指南](./05-RAG引擎使用指南.md)
|
||||
- [pgvector 替换 Dify 计划](./02-pgvector替换Dify计划.md)
|
||||
|
||||
---
|
||||
|
||||
## 📅 更新计划
|
||||
|
||||
1. ✅ 创建 Dockerfile 和初始化脚本
|
||||
2. ⏳ 本地环境测试
|
||||
3. ⏳ 更新 VectorSearchService 使用 pg_bigm
|
||||
4. ⏳ 生产环境部署(阿里云 RDS)
|
||||
5. ⏳ 创建索引并验证性能
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
# Postgres-Only 异步任务处理指南
|
||||
|
||||
> **文档版本:** v1.0
|
||||
> **文档版本:** v1.1(2026-01-23 安全规范更新)
|
||||
> **创建日期:** 2025-12-22
|
||||
> **最后更新:** 2026-01-23
|
||||
> **维护者:** 平台架构团队
|
||||
> **适用场景:** 长时间任务(>30秒)、大文件处理、后台Worker
|
||||
> **参考实现:** DC Tool C Excel解析、ASL文献筛选、DC Tool B数据提取
|
||||
>
|
||||
> ⚠️ **重要更新 v1.1**:新增[🛡️ 安全规范](#-安全规范强制)章节,包含幂等性、错误处理等强制规范
|
||||
|
||||
---
|
||||
|
||||
@@ -537,6 +540,160 @@ async saveProcessedData(recordId, newData) {
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ 安全规范(强制)
|
||||
|
||||
> **更新日期**:2026-01-23
|
||||
> **来源**:内部逆向审查报告 + 生产问题修复
|
||||
|
||||
基于项目实际遇到的问题,以下规范 **必须遵守**:
|
||||
|
||||
### 规范1:禁止 Worker 递归死循环 ❌
|
||||
|
||||
**错误示例**:
|
||||
```typescript
|
||||
// ❌ 禁止:在 catch 块中重试业务逻辑
|
||||
jobQueue.process('your_task', async (job) => {
|
||||
try {
|
||||
await doSomething(job.data);
|
||||
} catch (error) {
|
||||
// ❌ 错误!这会导致死循环或重复执行
|
||||
await doSomething(job.data);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**正确做法**:
|
||||
```typescript
|
||||
// ✅ 正确:直接 throw,让 pg-boss 接管重试(默认3次)
|
||||
jobQueue.process('your_task', async (job) => {
|
||||
try {
|
||||
await doSomething(job.data);
|
||||
} catch (error) {
|
||||
logger.error('Job failed', { jobId: job.id, error: error.message });
|
||||
throw error; // ✅ pg-boss 会自动重试
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 规范2:禁止 Payload 膨胀 ❌
|
||||
|
||||
**错误示例**:
|
||||
```typescript
|
||||
// ❌ 禁止:在 job.data 中存大文件
|
||||
await jobQueue.push('parse_excel', {
|
||||
fileContent: base64EncodedFile, // ❌ 会导致 job 表膨胀
|
||||
imageData: base64Image, // ❌ 拖慢数据库
|
||||
});
|
||||
```
|
||||
|
||||
**正确做法**:
|
||||
```typescript
|
||||
// ✅ 正确:只存 fileKey 或数据库 ID
|
||||
await jobQueue.push('parse_excel', {
|
||||
sessionId: session.id, // ✅ 只存 ID
|
||||
fileKey: 'path/to/file', // ✅ 只存 OSS 路径
|
||||
userId: 'user-123',
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 规范3:Worker 必须幂等 ⭐
|
||||
|
||||
**问题**:任务失败重试时,可能导致重复写入、重复扣费、重复发邮件。
|
||||
|
||||
**错误示例**:
|
||||
```typescript
|
||||
// ❌ 非幂等:重试会创建多条记录
|
||||
await prisma.screeningResult.create({
|
||||
data: { projectId, literatureId, result }
|
||||
});
|
||||
```
|
||||
|
||||
**正确做法**:
|
||||
```typescript
|
||||
// ✅ 方案1:使用 upsert + 唯一约束
|
||||
await prisma.screeningResult.upsert({
|
||||
where: {
|
||||
projectId_literatureId: { projectId, literatureId }
|
||||
},
|
||||
create: { projectId, literatureId, result },
|
||||
update: { result }, // 重试时覆盖
|
||||
});
|
||||
|
||||
// ✅ 方案2:先检查状态再执行
|
||||
const existing = await prisma.task.findUnique({ where: { id: taskId } });
|
||||
if (existing?.status === 'completed') {
|
||||
logger.info('Task already completed, skipping');
|
||||
return;
|
||||
}
|
||||
await doWork();
|
||||
```
|
||||
|
||||
**幂等性检查清单**:
|
||||
| 操作类型 | 幂等方案 |
|
||||
|---------|---------|
|
||||
| 创建记录 | 使用 `upsert` + 唯一约束 |
|
||||
| 更新记录 | `update` 天然幂等 |
|
||||
| 发送邮件 | 先检查 `notificationSent` 标志 |
|
||||
| 扣费 | 使用幂等 key(如订单号) |
|
||||
| 调用外部API | 检查是否已成功 |
|
||||
|
||||
---
|
||||
|
||||
### 规范4:合理设置任务过期时间
|
||||
|
||||
**默认配置**(当前):
|
||||
```typescript
|
||||
expireInSeconds: 6 * 60 * 60 // 6小时
|
||||
```
|
||||
|
||||
**推荐配置**(按业务类型):
|
||||
| 任务类型 | 过期时间 | 理由 |
|
||||
|---------|---------|------|
|
||||
| `asl_screening_batch` | 30分钟 | 单条文献筛选 |
|
||||
| `dc_extraction_batch` | 1小时 | 批量数据提取 |
|
||||
| `dc_toolc_parse_excel` | 30分钟 | Excel解析 |
|
||||
| `rvw_review_task` | 20分钟 | 审稿任务 |
|
||||
| `asl_research_execute` | 30分钟 | DeepSearch检索 |
|
||||
|
||||
---
|
||||
|
||||
### 规范5:优雅关闭 ✅
|
||||
|
||||
**已在 `index.ts` 实现**:
|
||||
```typescript
|
||||
// 进程退出时优雅关闭
|
||||
process.on('SIGTERM', async () => {
|
||||
await fastify.close(); // 停止接收新请求
|
||||
await jobQueue.stop(); // 等待当前任务完成
|
||||
await prisma.$disconnect(); // 关闭数据库
|
||||
process.exit(0);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 规范6:全局错误监听 ✅
|
||||
|
||||
**已在 `PgBossQueue.ts` 实现**:
|
||||
```typescript
|
||||
// 防止未捕获错误导致进程崩溃
|
||||
this.boss.on('error', (err) => {
|
||||
if (err.code === '23505' && err.constraint === 'queue_pkey') {
|
||||
// 队列冲突,静默处理
|
||||
console.log('Queue concurrency conflict auto-resolved');
|
||||
} else {
|
||||
console.error('PgBoss critical error:', err);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 常见问题
|
||||
|
||||
### Q1: Worker 注册了但不工作?
|
||||
@@ -569,7 +726,7 @@ async saveProcessedData(recordId, newData) {
|
||||
|
||||
## ✅ 检查清单
|
||||
|
||||
在实施异步任务前,请确认:
|
||||
### 基础配置检查
|
||||
|
||||
- [ ] 业务表只存业务信息(不包含 status 等字段)
|
||||
- [ ] 队列名称使用下划线(不含冒号)
|
||||
@@ -579,11 +736,49 @@ async saveProcessedData(recordId, newData) {
|
||||
- [ ] Service 优先读取 clean data
|
||||
- [ ] saveProcessedData 同步更新 clean data
|
||||
|
||||
### 🛡️ 安全规范检查(强制)
|
||||
|
||||
- [ ] **幂等性**:使用 `upsert` 或先检查状态,确保重试安全
|
||||
- [ ] **Payload**:`job.data` 只存 ID 和 fileKey,不存大文件
|
||||
- [ ] **错误处理**:catch 块中直接 `throw error`,不要重试业务逻辑
|
||||
- [ ] **唯一约束**:数据库表有合适的唯一索引防止重复写入
|
||||
- [ ] **过期时间**:根据业务类型设置合理的 `expireInSeconds`
|
||||
|
||||
---
|
||||
|
||||
## 📊 故障排查 SQL
|
||||
|
||||
```sql
|
||||
-- 查看队列健康状况
|
||||
SELECT
|
||||
name AS queue_name,
|
||||
state,
|
||||
COUNT(*) AS count
|
||||
FROM platform_schema.job
|
||||
WHERE created_on > NOW() - INTERVAL '24 hours'
|
||||
GROUP BY name, state
|
||||
ORDER BY name, state;
|
||||
|
||||
-- 查看失败任务
|
||||
SELECT id, name, data, output, created_on
|
||||
FROM platform_schema.job
|
||||
WHERE state = 'failed'
|
||||
ORDER BY created_on DESC
|
||||
LIMIT 10;
|
||||
|
||||
-- 查看卡住的任务(processing 超过1小时)
|
||||
SELECT id, name, data, created_on, started_on
|
||||
FROM platform_schema.job
|
||||
WHERE state = 'active'
|
||||
AND started_on < NOW() - INTERVAL '1 hour';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**维护者**: 平台架构团队
|
||||
**最后更新**: 2025-12-22
|
||||
**文档状态**: ✅ 已完成
|
||||
**最后更新**: 2026-01-23
|
||||
**文档状态**: ✅ 已完成(v1.1 安全规范更新)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -238,3 +238,4 @@ const userId = 'test'; // ❌ 应该用 getUserId(request)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
229
docs/02-通用能力层/技术方案文档_Postgres-Only队列架构优化方案 (1).md
Normal file
229
docs/02-通用能力层/技术方案文档_Postgres-Only队列架构优化方案 (1).md
Normal file
@@ -0,0 +1,229 @@
|
||||
# **Postgres-Only 队列架构治理与异步决策指南**
|
||||
|
||||
**文档版本:** v1.2 (Review版) **面向对象:** 后端开发团队 (2人) **核心目标:** 解决并发冲突,规避异步陷阱,建立低成本运维标准
|
||||
|
||||
**适用架构:** Node.js \+ PostgreSQL \+ pg-boss (无 Redis)
|
||||
|
||||
## **1\. 现状与痛点分析**
|
||||
|
||||
### **1.1 当前架构背景**
|
||||
|
||||
我们采用了极简的 **Postgres-Only** 架构,利用 pg-boss 实现异步任务队列。这对于我们 2 人团队非常有利,因为:
|
||||
|
||||
* **运维成本低**:不需要维护 Redis 或 RabbitMQ。
|
||||
* **事务一致性**:任务数据与业务数据在同一个数据库,天然支持事务。
|
||||
* **部署简单**:一个 Docker 容器搞定所有状态存储。
|
||||
|
||||
### **1.2 遇到的技术问题**
|
||||
|
||||
在开发环境(Nodemon 热重载)或生产环境(多实例部署)启动时,频发以下错误:
|
||||
|
||||
error: duplicate key value violates unique constraint "queue\_pkey"
|
||||
Key (name)=(asl\_research\_execute) already exists.
|
||||
|
||||
**根本原因:** 典型的竞争条件 (Race Condition)。多个进程同时尝试初始化队列,触发数据库唯一约束。
|
||||
|
||||
## **2\. 核心技术方案:健壮的单例模式**
|
||||
|
||||
为了解决报错并防止资源泄露,我们需要对 pg-boss 进行**防御性封装**。
|
||||
|
||||
### **2.1 标准代码实现 (backend/services/queueService.js)**
|
||||
|
||||
**⚠️ 重大更新**:增加了连接池限制 (max: 2),防止搞挂 RDS。
|
||||
|
||||
import PgBoss from 'pg-boss';
|
||||
import { logger } from '@/common/logging';
|
||||
|
||||
class QueueService {
|
||||
constructor() {
|
||||
this.boss \= null;
|
||||
this.isReady \= false;
|
||||
this.isStarting \= false;
|
||||
}
|
||||
|
||||
/\*\*
|
||||
\* 核心:初始化与错误监听
|
||||
\*/
|
||||
async init(connectionString) {
|
||||
if (this.boss || this.isStarting) return;
|
||||
this.isStarting \= true;
|
||||
|
||||
try {
|
||||
this.boss \= new PgBoss({
|
||||
connectionString,
|
||||
application\_name: 'ai\_clinical\_queue',
|
||||
retentionDays: 7,
|
||||
maxTries: 3,
|
||||
// 🛡️ \[逆向防御\]:限制连接池大小
|
||||
// pg-boss 默认至少需要 2-4 个连接。
|
||||
// 我们显式限制为 4,防止挤占 Prisma 的连接配额 (RDS通常限制 100\)
|
||||
max: 4,
|
||||
});
|
||||
|
||||
// 🛡️ \[逆向防御\]:监听全局错误,防止进程崩溃
|
||||
this.boss.on('error', (err) \=\> {
|
||||
if (this.\_isDuplicateKeyError(err)) {
|
||||
logger.warn(\`\[Queue\] Concurrency conflict auto-resolved: ${err.detail}\`);
|
||||
} else {
|
||||
logger.error('\[Queue\] PgBoss critical error:', err);
|
||||
// TODO: 这里可以接入飞书/钉钉 Webhook 告警
|
||||
}
|
||||
});
|
||||
|
||||
await this.boss.start();
|
||||
this.isReady \= true;
|
||||
this.isStarting \= false;
|
||||
logger.info('✅ Queue Service started successfully');
|
||||
|
||||
} catch (err) {
|
||||
this.isStarting \= false;
|
||||
logger.error('❌ Failed to start Queue Service:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/\*\*
|
||||
\* 封装:安全地发布任务
|
||||
\*/
|
||||
async publish(queueName, data, options \= {}) {
|
||||
await this.\_ensureReady();
|
||||
await this.\_ensureQueueExists(queueName);
|
||||
|
||||
try {
|
||||
return await this.boss.send(queueName, data, options);
|
||||
} catch (err) {
|
||||
logger.error(\`❌ Failed to publish to ${queueName}:\`, err);
|
||||
// 🛡️ \[逆向防御\]:这里是否需要抛出错误取决于业务?
|
||||
// 建议抛出,让上层业务感知到任务提交失败
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/\*\*
|
||||
\* 封装:Worker 注册
|
||||
\*/
|
||||
async subscribe(queueName, handler) {
|
||||
await this.\_ensureReady();
|
||||
await this.\_ensureQueueExists(queueName);
|
||||
|
||||
// 🛡️ \[逆向防御\]:包裹 handler,捕获未处理的异常,防止 Worker 僵死
|
||||
const safeHandler \= async (job) \=\> {
|
||||
try {
|
||||
logger.info(\`🔄 Processing job ${job.id} \[${queueName}\]\`);
|
||||
return await handler(job);
|
||||
} catch (err) {
|
||||
logger.error(\`❌ Job ${job.id} failed:\`, err);
|
||||
throw err; // 抛出给 pg-boss 进行重试
|
||||
}
|
||||
};
|
||||
|
||||
await this.boss.work(queueName, safeHandler);
|
||||
logger.info(\`👷 Worker registered: ${queueName}\`);
|
||||
}
|
||||
|
||||
async shutdown() {
|
||||
if (this.boss) {
|
||||
await this.boss.stop();
|
||||
this.boss \= null;
|
||||
this.isReady \= false;
|
||||
}
|
||||
}
|
||||
|
||||
// \--- 私有辅助方法 \---
|
||||
|
||||
async \_ensureQueueExists(queueName) {
|
||||
try {
|
||||
await this.boss.createQueue(queueName);
|
||||
} catch (err) {
|
||||
if (\!this.\_isDuplicateKeyError(err)) throw err;
|
||||
}
|
||||
}
|
||||
|
||||
\_isDuplicateKeyError(err) {
|
||||
return err.code \=== '23505' && err.constraint \=== 'queue\_pkey';
|
||||
}
|
||||
|
||||
async \_ensureReady() {
|
||||
if (\!this.isReady && \!this.isStarting) {
|
||||
await this.init(process.env.DATABASE\_URL);
|
||||
}
|
||||
if (\!this.isReady) throw new Error('QueueService not initialized');
|
||||
}
|
||||
}
|
||||
|
||||
export const queueService \= new QueueService();
|
||||
|
||||
## **3\. 架构决策:什么时候该用异步?**
|
||||
|
||||
作为 2 人团队,维护异步队列有显著的隐性成本。原则:**默认同步,按需异步。**
|
||||
|
||||
### **3.1 坚决【不使用】异步的场景**
|
||||
|
||||
* **短时间 AI 交互 (\< 20秒)**:用 SSE 流式响应。
|
||||
* **简单 CRUD**:直接 await。
|
||||
* **强实时反馈**:如 REDCap Webhook。
|
||||
|
||||
### **3.2 【必须】使用异步的场景**
|
||||
|
||||
* **大文件解析 (DC 模块)**:防止 HTTP Timeout。
|
||||
* **长时外部 API (ASL 模块)**:DeepSearch 检索。
|
||||
* **高并发削峰**:批量导入。
|
||||
|
||||
## **4\. 最佳实践:智能混合模式 (Smart Hybrid Strategy)**
|
||||
|
||||
### **4.1 ⚠️ 逆向风险:代码分裂 (Code Divergence)**
|
||||
|
||||
**风险**:如果同步逻辑写一份,异步 Worker 里又复制粘贴一份。一旦业务修改,很容易“改了同步忘了异步”,导致 Bug。 **解法**:**业务逻辑必须原子化**。
|
||||
|
||||
### **4.2 正确的代码范例**
|
||||
|
||||
// backend/src/modules/dc/services/coreLogic.ts
|
||||
// 1\. 核心逻辑剥离:这是一个纯函数,不关心它是被 HTTP 调用的还是被 Worker 调用的
|
||||
export async function extractDataCore(fileBuffer: Buffer) {
|
||||
// ...复杂的解析逻辑...
|
||||
return result;
|
||||
}
|
||||
|
||||
// backend/src/modules/dc/services/extractionService.ts
|
||||
import { extractDataCore } from './coreLogic';
|
||||
|
||||
async function processData(data: any\[\]) {
|
||||
// 🟢 场景 A:同步处理
|
||||
if (data.length \< 50\) {
|
||||
// 直接调用核心逻辑
|
||||
return await extractDataCore(data);
|
||||
}
|
||||
|
||||
// 🟠 场景 B:异步队列
|
||||
else {
|
||||
// 仅仅是发布任务,任务载荷里只存必要参数
|
||||
const jobId \= await queueService.publish('dc\_process\_batch', data);
|
||||
return { status: 'queued', jobId };
|
||||
}
|
||||
}
|
||||
|
||||
// backend/src/workers/dcWorker.ts
|
||||
import { extractDataCore } from '../modules/dc/services/coreLogic';
|
||||
|
||||
// Worker 也调用同一个核心逻辑
|
||||
queueService.subscribe('dc\_process\_batch', async (job) \=\> {
|
||||
return await extractDataCore(job.data);
|
||||
});
|
||||
|
||||
## **5\. 开发规范**
|
||||
|
||||
### **5.1 命名规范**
|
||||
|
||||
* ✅ **推荐**:模块\_动作 (如 asl\_screening\_task)
|
||||
* ❌ **禁止**:冒号 : 或点号 .
|
||||
|
||||
### **5.2 Postgres-Only 特有技巧**
|
||||
|
||||
利用 **事务一致性**。在 Postgres-Only 架构中,尽量让任务发布与业务数据写入在同一个事务中(如果 ORM 支持),确保不丢任务。
|
||||
|
||||
## **6\. 故障排查与监控 (SQL)**
|
||||
|
||||
因为没有 Redis GUI,使用 SQL 监控:
|
||||
|
||||
\-- 查看失败任务
|
||||
SELECT id, name, data, output, created\_on FROM platform\_schema.job WHERE state \= 'failed' LIMIT 10;
|
||||
@@ -812,5 +812,6 @@ export const AsyncProgressBar: React.FC<AsyncProgressBarProps> = ({
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -307,3 +307,4 @@ Level 3: 兜底Prompt(缓存也失效)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -493,3 +493,4 @@ const pageSize = Number(query.pageSize) || 20;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -227,3 +227,4 @@ ADMIN-运营管理端/
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -326,3 +326,4 @@ INST-机构管理端/
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -893,3 +893,4 @@ export interface SlashCommand {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -198,3 +198,4 @@ export type AgentStage = 'topic' | 'design' | 'review' | 'data' | 'writing';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1305,5 +1305,6 @@ interface FulltextScreeningResult {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -419,5 +419,6 @@ GET /api/v1/asl/fulltext-screening/tasks/:taskId/export
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -362,5 +362,6 @@ Linter错误:0个
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -521,5 +521,6 @@ Failed to open file '\\tmp\\extraction_service\\temp_10000_test.pdf'
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -179,3 +179,4 @@ UNIFUNCS_API_KEY=sk-xxxx
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -587,5 +587,6 @@ df['creatinine'] = pd.to_numeric(df['creatinine'], errors='coerce')
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -425,5 +425,6 @@ npm run dev
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1002,5 +1002,6 @@ export const aiController = new AIController();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1336,5 +1336,6 @@ npm install react-markdown
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -244,5 +244,6 @@ FMA___基线 | FMA___1个月 | FMA___2个月
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -402,5 +402,6 @@ formula = "FMA总分(0-100) / 100"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -236,5 +236,6 @@ async handleFillnaMice(request, reply) {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -208,5 +208,6 @@ method: 'mean' | 'median' | 'mode' | 'constant' | 'ffill' | 'bfill'
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -358,5 +358,6 @@ Changes:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -430,5 +430,6 @@ cd path; command
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -659,5 +659,6 @@ import { logger } from '../../../../common/logging/index.js';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -663,5 +663,6 @@ Content-Length: 45234
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -315,5 +315,6 @@ Response:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -468,5 +468,6 @@ Response:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -462,5 +462,6 @@ import { ChatContainer } from '@/shared/components/Chat';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -372,5 +372,6 @@ const initialMessages = defaultMessages.length > 0 ? defaultMessages : [{
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -412,5 +412,6 @@ python main.py
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -660,5 +660,6 @@ http://localhost:5173/data-cleaning/tool-c
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -270,5 +270,6 @@ Day 5 (6-8小时):
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -448,5 +448,6 @@ Docs: docs/03-业务模块/DC-数据清洗整理/06-开发记录/DC模块重建
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -423,5 +423,6 @@ const mockAssets: Asset[] = [
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -407,5 +407,6 @@ frontend-v2/src/modules/dc/
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -367,5 +367,6 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -321,5 +321,6 @@ ConflictDetectionService // 冲突检测(字段级对比)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -370,5 +370,6 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -333,5 +333,6 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -397,5 +397,6 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -485,5 +485,6 @@ Tool B后端代码**100%复用**了平台通用能力层,无任何重复开发
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -331,5 +331,6 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -262,5 +262,6 @@ $ node scripts/check-dc-tables.mjs
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -495,5 +495,6 @@ ${fields.map((f, i) => `${i + 1}. ${f.name}:${f.desc}`).join('\n')}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -700,5 +700,6 @@ private async processMessageAsync(xmlData: any) {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1094,5 +1094,6 @@ async function testIntegration() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -235,5 +235,6 @@ Content-Type: application/json
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -655,5 +655,6 @@ REDCap API: exportRecords success { recordCount: 1 }
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -661,5 +661,6 @@ backend/src/modules/iit-manager/
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -811,5 +811,6 @@ CREATE TABLE iit_schema.wechat_tokens (
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -568,5 +568,6 @@ Day 3 的开发工作虽然遇到了多个技术问题,但最终成功完成
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -335,5 +335,6 @@ AI: "出生日期:2017-01-04
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -279,5 +279,6 @@ Day 4: REDCap EM(Webhook推送)← 作为增强,而非核心
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -693,5 +693,6 @@ const answer = `根据研究方案[1]和CRF表格[2],纳入标准包括:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -452,3 +452,4 @@ export const calculateAvailableQuota = async (tenantId: string) => {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -325,3 +325,4 @@ https://platform.example.com/t/pharma-abc/login
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -374,4 +374,5 @@ const newResults = resultsData.map((docResult: any) => ({
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -246,5 +246,6 @@ const chatApi = axios.create({
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -783,5 +783,6 @@ docker exec redcap-apache php /tmp/create-redcap-password.php
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -165,5 +165,6 @@ AIclinicalresearch/redcap-docker-dev/
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -333,3 +333,4 @@ npx tsx check_iit_asl_data.ts
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -201,3 +201,4 @@ interface DecodedToken {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -902,5 +902,6 @@ ACR镜像仓库:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1389,5 +1389,6 @@ SAE应用配置:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1205,5 +1205,6 @@ docker exec -e PGPASSWORD="密码" ai-clinical-postgres psql -h RDS地址 -U air
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -616,5 +616,6 @@ scripts/*.ts
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -304,5 +304,6 @@ Node.js后端部署成功后:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -527,5 +527,6 @@ Node.js后端 (SAE) ← http://172.17.173.88:3001
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -242,5 +242,6 @@ curl http://localhost:3001/health
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -280,5 +280,6 @@ npm run dev
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -504,5 +504,6 @@ pgm-2zex1m2y3r23hdn5.pg.rds.aliyuncs.com:5432
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1832,5 +1832,6 @@ curl http://8.140.53.236/
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -380,5 +380,6 @@ crpi-cd5ij4pjt65mweeo.cn-beijing.personal.cr.aliyuncs.com/ai-clinical/backend-se
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -702,5 +702,6 @@ docker login --username=gofeng117@163.com \
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -513,5 +513,6 @@ NAT网关成本¥100/月,对初创团队是一笔开销
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -418,5 +418,6 @@ curl http://你的SAE地址:3001/health
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -750,5 +750,6 @@ const job = await queue.getJob(jobId);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -517,5 +517,6 @@ processLiteraturesInBackground(task.id, projectId, testLiteratures);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -994,5 +994,6 @@ ROI = (¥22,556 - ¥144) / ¥144 × 100% = 15,564%
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1051,5 +1051,6 @@ Redis 实例:¥500/月
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -509,5 +509,6 @@ import { ChatContainer } from '@/shared/components/Chat';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -217,3 +217,4 @@ VALUES ('user-mock-001', '13800000000', ..., 'tenant-mock-001', ...);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -427,5 +427,6 @@ frontend-v2/src/modules/pkb/
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -289,5 +289,6 @@ npm run dev
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -804,5 +804,6 @@ AIA智能问答模块
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -949,5 +949,6 @@ CREATE INDEX idx_rvw_tasks_created_at ON rvw_schema.review_tasks(created_at);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -602,5 +602,6 @@ const typography = {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -914,5 +914,6 @@ app.use('/api/v1/knowledge', (req, res) => {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -228,5 +228,6 @@ rm -rf src/modules/pkb
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -403,5 +403,6 @@ GET /api/v2/pkb/batch-tasks/batch/templates
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -47,5 +47,6 @@ import pkbRoutes from './modules/pkb/routes/index.js';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -316,5 +316,6 @@ backend/
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -527,5 +527,6 @@ const response = await fetch('/api/v2/pkb/batch-tasks/batch/execute', {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user