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:
2026-01-23 22:07:26 +08:00
parent 9c96f75c52
commit 61cdc97eeb
297 changed files with 1147 additions and 21 deletions

View File

@@ -163,5 +163,6 @@ https://iit.xunzhengyixue.com/api/v1/iit/health

View File

@@ -64,5 +64,6 @@

View File

@@ -324,5 +324,6 @@ npx tsx src/modules/iit-manager/test-patient-wechat-url-verify.ts

View File

@@ -186,5 +186,6 @@ npm run dev

View File

@@ -65,3 +65,4 @@ main()

View File

@@ -59,3 +59,4 @@ main()

View File

@@ -54,3 +54,4 @@ main()

View File

@@ -86,3 +86,4 @@ main()

View File

@@ -49,3 +49,4 @@ main()

View File

@@ -90,3 +90,4 @@ main()

View File

@@ -37,3 +37,4 @@ main()

View File

@@ -125,3 +125,4 @@ main()

View File

@@ -96,3 +96,4 @@ main()

View File

@@ -82,3 +82,4 @@ main()

View File

@@ -124,3 +124,4 @@ main()

View File

@@ -35,3 +35,4 @@ ON CONFLICT (id) DO NOTHING;

View File

@@ -67,3 +67,4 @@ ON CONFLICT (id) DO NOTHING;

View File

@@ -77,3 +77,4 @@ OSS_INTERNAL=true # 🔴 生产必须用内网
OSS_SIGNED_URL_EXPIRES=3600
```

View File

@@ -83,5 +83,6 @@ WHERE table_schema = 'dc_schema'

View File

@@ -121,5 +121,6 @@ ORDER BY ordinal_position;

View File

@@ -134,5 +134,6 @@ runMigration()

View File

@@ -68,5 +68,6 @@ COMMENT ON COLUMN "dc_schema"."dc_tool_c_sessions"."column_mapping" IS '列名

View File

@@ -95,5 +95,6 @@ COMMENT ON COLUMN dc_schema.dc_tool_c_sessions.expires_at IS '过期时间(创

View File

@@ -65,3 +65,4 @@ USING gin (metadata jsonb_path_ops);

View File

@@ -32,3 +32,4 @@ USING gin (tags);

View File

@@ -135,5 +135,6 @@ Write-Host ""

View File

@@ -245,5 +245,6 @@ function extractCodeBlocks(obj, blocks = []) {

View File

@@ -44,3 +44,4 @@ CREATE TABLE IF NOT EXISTS platform_schema.job_common (

View File

@@ -118,3 +118,4 @@ CREATE OR REPLACE FUNCTION platform_schema.delete_queue(queue_name text) RETURNS

View File

@@ -264,5 +264,6 @@ checkDCTables();

View File

@@ -19,3 +19,4 @@ CREATE SCHEMA IF NOT EXISTS capability_schema;

View File

@@ -216,5 +216,6 @@ createAiHistoryTable()

View File

@@ -203,5 +203,6 @@ createToolCTable()

View File

@@ -200,5 +200,6 @@ createToolCTable()

View File

@@ -322,3 +322,4 @@ main()

View File

@@ -129,3 +129,4 @@ main()

View File

@@ -347,5 +347,6 @@ runTests().catch(error => {

View File

@@ -95,3 +95,4 @@ testAPI().catch(console.error);

View File

@@ -125,3 +125,4 @@ testDeepSearch().catch(console.error);

View File

@@ -312,5 +312,6 @@ verifySchemas()

View File

@@ -202,3 +202,4 @@ export const jwtService = new JWTService();

View File

@@ -50,7 +50,7 @@ export class PgBossQueue implements JobQueue {
this.boss = new PgBoss({
connectionString,
schema, // 使用platform_schema
max: 10, // 最大连接数
max: 4, // 🛡️ 限制连接数,避免挤占 Prisma 连接配额RDS 限制 100
application_name: 'aiclinical-queue',
// 调度配置
@@ -61,6 +61,17 @@ export class PgBossQueue implements JobQueue {
maintenanceIntervalSeconds: 300, // 每5分钟运行维护任务
})
// 🛡️ 全局错误监听:防止未捕获错误导致进程崩溃
this.boss.on('error', (err: any) => {
// 静默处理 duplicate key 错误(队列并发初始化时的正常现象)
if (err.code === '23505' && err.constraint === 'queue_pkey') {
console.log(`[PgBossQueue] Queue concurrency conflict auto-resolved: ${err.detail}`);
} else {
console.error('[PgBossQueue] ❌ Critical error:', err);
// 记录到日志但不崩溃进程
}
});
console.log('[PgBossQueue] Initialized with schema:', schema)
}
@@ -192,13 +203,22 @@ export class PgBossQueue implements JobQueue {
console.log(`[PgBossQueue] 🔧 开始注册 Handler: ${type}`);
try {
// pg-boss 9.x 需要显式创建队列
await this.boss.createQueue(type, {
retryLimit: 3,
retryDelay: 60,
expireInSeconds: 6 * 60 * 60 // 6小时
});
console.log(`[PgBossQueue] ✅ Queue created: ${type}`);
// pg-boss 9.x 需要显式创建队列(幂等操作)
try {
await this.boss.createQueue(type, {
retryLimit: 3,
retryDelay: 60,
expireInSeconds: 6 * 60 * 60 // 6小时
});
console.log(`[PgBossQueue] ✅ Queue created: ${type}`);
} catch (createError: any) {
// 队列已存在时会报 duplicate key 错误,忽略
if (createError.code === '23505' || createError.message?.includes('already exists')) {
console.log(`[PgBossQueue] Queue already exists: ${type}`);
} else {
throw createError;
}
}
await this.boss.work<Record<string, any>>(type, {
batchSize: 1, // 每次处理1个任务

View File

@@ -332,5 +332,6 @@ export function getBatchItems<T>(

View File

@@ -85,3 +85,4 @@ export interface VariableValidation {

View File

@@ -355,3 +355,4 @@ export default ChunkService;

View File

@@ -51,3 +51,4 @@ export const DifyClient = DeprecatedDifyClient;

View File

@@ -206,3 +206,4 @@ export function createOpenAIStreamAdapter(

View File

@@ -212,3 +212,4 @@ export async function streamChat(

View File

@@ -30,3 +30,4 @@ export { THINKING_TAGS } from './types';

View File

@@ -105,3 +105,4 @@ export type SSEEventType =

View File

@@ -280,3 +280,33 @@ const start = async () => {
};
start();
// ============================================
// 🛡️ 优雅关闭处理Graceful Shutdown
// ============================================
const gracefulShutdown = async (signal: string) => {
console.log(`\n⚠ 收到 ${signal} 信号,开始优雅关闭...`);
try {
// 1. 停止接收新请求
await fastify.close();
console.log('✅ HTTP 服务已停止');
// 2. 停止队列(等待当前任务完成)
await jobQueue.stop();
console.log('✅ 任务队列已停止');
// 3. 关闭数据库连接
await prisma.$disconnect();
console.log('✅ 数据库连接已关闭');
console.log('👋 优雅关闭完成,再见!');
process.exit(0);
} catch (error) {
console.error('❌ 优雅关闭失败:', error);
process.exit(1);
}
};
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

View File

@@ -91,3 +91,4 @@ export async function moduleRoutes(fastify: FastifyInstance) {

View File

@@ -121,3 +121,4 @@ export interface PaginatedResponse<T> {

View File

@@ -168,3 +168,4 @@ export const ROLE_DISPLAY_NAMES: Record<UserRole, string> = {

View File

@@ -243,3 +243,4 @@ async function matchIntent(query: string): Promise<{

View File

@@ -97,3 +97,4 @@ export async function uploadAttachment(

View File

@@ -26,3 +26,4 @@ export { aiaRoutes };

View File

@@ -368,5 +368,6 @@ runTests().catch((error) => {

View File

@@ -347,5 +347,6 @@ Content-Type: application/json

View File

@@ -257,7 +257,7 @@ async function processScreeningBatchWithCheckpoint(
literature.publicationYear ? Number(literature.publicationYear) : undefined
);
// 保存结果(使用 screeningService.ts 相同的映射方式
// 保存结果(使用 upsert 保证幂等性,任务重试时覆盖而不是重复创建
const dbResult = {
projectId,
literatureId: literature.id,
@@ -294,8 +294,16 @@ async function processScreeningBatchWithCheckpoint(
finalDecision: screeningResult.finalDecision,
};
await prisma.aslScreeningResult.create({
data: dbResult,
// 🛡️ 使用 upsert 实现幂等性(利用 unique_project_literature 约束)
await prisma.aslScreeningResult.upsert({
where: {
projectId_literatureId: {
projectId,
literatureId: literature.id,
},
},
create: dbResult,
update: dbResult,
});
successCount++;

View File

@@ -67,12 +67,8 @@ export function registerResearchWorker() {
error: error.message,
});
// 更新任务状态为失败
try {
await researchService.executeSearch(taskId, query);
} catch {
// 忽略
}
// ❌ 已移除错误的 executeSearch 调用
// 任务失败后 pg-boss 会自动重试最多3次
return {
success: false,

View File

@@ -283,5 +283,6 @@ export const conflictDetectionService = new ConflictDetectionService();

View File

@@ -233,5 +233,6 @@ curl -X POST http://localhost:3000/api/v1/dc/tool-c/test/execute \

View File

@@ -287,5 +287,6 @@ export const streamAIController = new StreamAIController();

View File

@@ -196,5 +196,6 @@ logger.info('[SessionMemory] 会话记忆管理器已启动', {

View File

@@ -130,5 +130,6 @@ checkTableStructure();

View File

@@ -117,5 +117,6 @@ checkProjectConfig().catch(console.error);

View File

@@ -99,5 +99,6 @@ main();

View File

@@ -556,5 +556,6 @@ URL: https://iit.xunzhengyixue.com/api/v1/iit/patient-wechat/callback

View File

@@ -191,5 +191,6 @@ console.log('');

View File

@@ -508,5 +508,6 @@ export const patientWechatService = new PatientWechatService();

View File

@@ -153,5 +153,6 @@ testDifyIntegration().catch(error => {

View File

@@ -182,5 +182,6 @@ testIitDatabase()

View File

@@ -168,5 +168,6 @@ if (hasError) {

View File

@@ -194,5 +194,6 @@ async function testUrlVerification() {

View File

@@ -275,5 +275,6 @@ main().catch((error) => {

View File

@@ -159,5 +159,6 @@ Write-Host ""

View File

@@ -252,5 +252,6 @@ export interface CachedProtocolRules {

View File

@@ -1,8 +1,10 @@
import type { FastifyRequest, FastifyReply } from 'fastify';
import * as documentService from '../services/documentService.js';
import { storage } from '../../../common/storage/index.js';
import { OSSAdapter } from '../../../common/storage/OSSAdapter.js';
import { randomUUID } from 'crypto';
import path from 'path';
import { logger } from '../../../common/logging/index.js';
/**
* 获取用户ID从JWT Token中获取
@@ -374,4 +376,93 @@ export async function getDocumentFullText(
}
}
/**
* 获取文档签名URL用于前端预览/下载)
*
* @description
* 生成一个带有过期时间的签名URL前端可以直接使用该URL
* - 在浏览器中预览 PDF
* - 下载文件(会恢复原始文件名)
*/
export async function getDocumentSignedUrl(
request: FastifyRequest<{
Params: {
id: string;
};
Querystring: {
/** 过期时间默认3600秒 */
expires?: string;
/** 是否作为附件下载(添加 Content-Disposition默认 false */
download?: string;
};
}>,
reply: FastifyReply
) {
try {
const { id } = request.params;
const expires = parseInt(request.query.expires || '3600', 10);
const download = request.query.download === 'true';
const userId = getUserId(request);
// 获取文档信息
const document = await documentService.getDocumentById(userId, id);
// 检查是否有存储路径
if (!document.storageKey) {
logger.warn('[PKB] 文档没有存储路径,可能是旧数据', { documentId: id });
return reply.status(404).send({
success: false,
message: '文档文件不可用,请重新上传',
});
}
// 生成签名URL
let signedUrl: string;
// 检查存储适配器类型
if (storage instanceof OSSAdapter) {
// OSS: 使用带原始文件名的签名URL
signedUrl = download
? storage.getSignedUrl(document.storageKey, expires, document.filename)
: storage.getSignedUrl(document.storageKey, expires);
} else {
// 本地存储: 使用 getUrl
signedUrl = storage.getUrl(document.storageKey);
}
logger.info('[PKB] 生成签名URL', {
documentId: id,
filename: document.filename,
expires,
download
});
return reply.send({
success: true,
data: {
documentId: document.id,
filename: document.filename,
fileType: document.fileType,
url: signedUrl,
expiresIn: expires,
},
});
} catch (error: any) {
logger.error('[PKB] 获取签名URL失败', { error: error.message });
if (error.message.includes('not found') || error.message.includes('access denied')) {
return reply.status(404).send({
success: false,
message: error.message,
});
}
return reply.status(500).send({
success: false,
message: error.message || 'Failed to get signed URL',
});
}
}

View File

@@ -65,5 +65,6 @@ export default async function healthRoutes(fastify: FastifyInstance) {

View File

@@ -44,6 +44,10 @@ export default async function knowledgeBaseRoutes(fastify: FastifyInstance) {
// Phase 2: 获取文档全文
fastify.get('/documents/:id/full-text', { preHandler: [authenticate, requireModule('PKB')] }, documentController.getDocumentFullText);
// 获取文档签名URL用于预览/下载)
// Query: ?expires=3600&download=true
fastify.get('/documents/:id/signed-url', { preHandler: [authenticate, requireModule('PKB')] }, documentController.getDocumentSignedUrl);
// 删除文档
fastify.delete('/documents/:id', { preHandler: [authenticate, requireModule('PKB')] }, documentController.deleteDocument);

View File

@@ -143,5 +143,6 @@ Content-Type: application/json

View File

@@ -128,5 +128,6 @@ Write-Host " - 删除任务: DELETE $BaseUrl/api/v1/rvw/tasks/{taskId}" -Foregr

View File

@@ -42,5 +42,6 @@ export * from './services/utils.js';

View File

@@ -133,5 +133,6 @@ export function validateAgentSelection(agents: string[]): void {

View File

@@ -433,5 +433,6 @@ SET session_replication_role = 'origin';

View File

@@ -113,3 +113,4 @@ testCrossLanguageSearch();

View File

@@ -175,3 +175,4 @@ testQueryRewrite();

View File

@@ -121,3 +121,4 @@ testRerank();

View File

@@ -135,5 +135,6 @@ WHERE key = 'verify_test';

View File

@@ -278,5 +278,6 @@ verifyDatabase()

View File

@@ -68,5 +68,6 @@ export {}

View File

@@ -91,5 +91,6 @@ Write-Host "✅ 完成!" -ForegroundColor Green

View File

@@ -18,3 +18,4 @@ SELECT schema_name FROM information_schema.schemata WHERE schema_name NOT IN ('p

View File

@@ -179,5 +179,6 @@ DELETE {{baseUrl}}/api/v1/pkb/knowledge/knowledge-bases/{{testKbId}}

View File

@@ -378,5 +378,6 @@ runAdvancedTests().catch(error => {

View File

@@ -444,5 +444,6 @@ runAllTests()

View File

@@ -402,5 +402,6 @@ runAllTests()

View File

@@ -38,3 +38,4 @@ main()

Some files were not shown because too many files have changed in this diff Show More