From 75d5b3d5def67d4a7f605753f50c63dfa59eca8c Mon Sep 17 00:00:00 2001 From: shunli Date: Mon, 9 Feb 2026 00:02:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20readiness=20=E4=B8=8E=E4=BD=9C?= =?UTF-8?q?=E4=B8=9A=E7=BB=9F=E8=AE=A1=E5=8F=AF=E8=A7=82=E6=B5=8B=E6=80=A7?= =?UTF-8?q?=EF=BC=88E11=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 /ready(DB 探活失败返回 503) - OCR 作业输出 claimed/available/duplicate/failed 统计 - README 补充 /health 与 /ready 说明 --- .../2026-02-08_21-50-09-p4-trialrun-ready.csv | 2 +- server/README.md | 10 +++++++ server/src/app.js | 15 ++++++++++ server/src/jobs/ocrJob.js | 30 +++++++++++++++---- server/src/scripts/run-ocr-once.js | 4 ++- 5 files changed, 54 insertions(+), 7 deletions(-) diff --git a/issues/2026-02-08_21-50-09-p4-trialrun-ready.csv b/issues/2026-02-08_21-50-09-p4-trialrun-ready.csv index d7a3fcf3..f8e96f56 100644 --- a/issues/2026-02-08_21-50-09-p4-trialrun-ready.csv +++ b/issues/2026-02-08_21-50-09-p4-trialrun-ready.csv @@ -9,7 +9,7 @@ E7,委派管理前端页(运营可用),新增委派管理页与API封装支 E8,默认账号与失败体验收敛,补dev seed默认账号文档并让前端对网络失败给出可操作提示(含“忘记密码请联系管理员重置”),登录页后端不可达提示服务未启动且README列出默认账号且SYS_001提示可读,"manual: 停后端访问登录页看到提示并按README默认账号登录成功",manual,DONE,TODO,TODO,client/src/pages/LoginPage.tsx | client/src/api/errorMessages.ts | server/README.md | client/README.md,"[""E1""]",仅写dev seed账号不写生产密码与密钥 E9,DB索引补强(试运行性能),为列表分页与筛选补组合索引以降低试运行慢查询风险,迁移可重复执行且回归全绿,cd server && npm run prisma:migrate:deploy && ./scripts/regression-all.sh,manual,DONE,TODO,TODO,server/prisma/schema.prisma | server/prisma/migrations/*,"[""E1""]",新增组合索引仅增不破回滚成本低 E10,后端安全基线(上线级),增加安全头与关键接口限流并完善上传校验与日志脱敏约定,限流触发行为正确安全头存在上传非法类型被拒绝且不泄露token,"manual: curl压测login并验证429与headers并做一次上传非法类型校验",manual,DONE,TODO,TODO,server/src/app.js | server/src/config.js | server/src/routes/auth.js | server/src/routes/invoicePool.js | server/README.md | server/package.json,"[""E1""]",优先使用@fastify/helmet与@fastify/rate-limit并写回滚说明 -E11,可观测性基线(ready与作业统计),新增/ready进行DB探活并为OCR与委派回收作业输出结构化统计,ready可反映db状态且作业日志输出claimed updated audited等计数便于排障,"manual: 启动服务curl /ready并触发一次job观察日志字段",manual,TODO,TODO,TODO,server/src/app.js | server/src/jobs/delegationRevokeJob.js | server/src/jobs/ocrJob.js | server/README.md,"[""E10""]",不引入外部监控先提供最小可观测字段 +E11,可观测性基线(ready与作业统计),新增/ready进行DB探活并为OCR与委派回收作业输出结构化统计,ready可反映db状态且作业日志输出claimed updated audited等计数便于排障,"manual: 启动服务curl /ready并触发一次job观察日志字段",manual,DONE,TODO,TODO,server/src/app.js | server/src/jobs/delegationRevokeJob.js | server/src/jobs/ocrJob.js | server/README.md,"[""E10""]",不引入外部监控先提供最小可观测字段 E12,单域部署(server托管client静态),支持server托管client dist实现同域cookie并提供开关与SPA回退,开启后通过server根路径访问前端且登录刷新正常,"manual: cd client && npm run build 然后SERVE_CLIENT_DIST=true启动server访问http://127.0.0.1:3000完成登录",manual,TODO,TODO,TODO,server/src/app.js | server/src/config.js | server/README.md | docker-compose.yml,"[""E10""]",优先复用@fastify/static并用正则fallback避开/api E13,部署与运维包(试运行),提供deploy/trialrun含env模板compose示例反向代理示例与启动停止runbook,按runbook可在新机器完成启动升级迁移与回滚演练一次,"manual: 按deploy/trialrun/runbook执行docker compose启动并验证ready与登录与回归入口",manual,TODO,TODO,TODO,deploy/trialrun/* | docker-compose.yml | server/.env.example | server/README.md | client/README.md,"[""E12""]",先覆盖试运行环境后续再细化生产 E14,备份与恢复演练(DB+文件),提供DB(pg_dump)与文件存储(票据上传目录)备份恢复脚本并把演练步骤写入runbook,可生成DB+文件备份并在空库与空存储恢复后通过smoke与登录验证,"manual: 执行备份脚本后restore到新库与新存储目录并跑smoke-test",manual,TODO,TODO,TODO,scripts/backup-db.sh | scripts/restore-db.sh | scripts/backup-storage.sh | scripts/restore-storage.sh | deploy/trialrun/* | server/README.md,"[""E13""]",脚本读取DATABASE_URL与STORAGE_DIR不写死密码并输出可审计日志 diff --git a/server/README.md b/server/README.md index f744e6b2..5431ae2d 100644 --- a/server/README.md +++ b/server/README.md @@ -71,6 +71,16 @@ cd /home/shunli/Projects/hyb-oa/server ./scripts/smoke-test.sh ``` +也可直接探活: +```bash +curl -fsS http://127.0.0.1:3000/health +curl -fsS http://127.0.0.1:3000/ready +``` + +说明: +- `/health`:liveness(不查 DB) +- `/ready`:readiness(`SELECT 1` 探活 DB,失败返回 503) + --- ## 4. 回归入口 diff --git a/server/src/app.js b/server/src/app.js index 937f162c..15ee67a4 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -148,6 +148,21 @@ async function buildApp() { return reply.send({ requestId: request.id, data: { ok: true } }); }); + app.get("/ready", async (request, reply) => { + try { + await app.prisma.$queryRaw`SELECT 1`; + return reply.send({ requestId: request.id, data: { ok: true } }); + } catch (error) { + app.log.error({ err: error, requestId: request.id }, "Readiness check failed"); + throw new AppError({ + code: "SYS_503", + message: "Service Unavailable", + statusCode: 503, + details: { db: "unreachable" }, + }); + } + }); + await app.register(authRoutes, { prefix: "/api/auth" }); await app.register(meRoutes, { prefix: "/api" }); await app.register(reimbursementRoutes, { prefix: "/api" }); diff --git a/server/src/jobs/ocrJob.js b/server/src/jobs/ocrJob.js index b30cead4..b96c29d9 100644 --- a/server/src/jobs/ocrJob.js +++ b/server/src/jobs/ocrJob.js @@ -249,28 +249,39 @@ async function processClaimedInvoice(prisma, config, claimedInvoice, workerId, l }, "OCR invoice marked as duplicate", ); - return; + return { outcome: "duplicate" }; } await markInvoiceAsAvailable(prisma, claimedInvoice.id, normalized, ocrResultId); + return { outcome: "available" }; } catch (error) { const message = trimErrorMessage(error); await markInvoiceAsFailed(prisma, claimedInvoice, message, config); logger.error({ err: error, invoiceId: claimedInvoice.id, workerId }, "OCR processing failed"); + return { outcome: "failed" }; } } async function runOcrOnce(prisma, config, logger, workerId = `ocr:${process.pid}`) { const claimed = await claimPendingInvoices(prisma, config, workerId); if (!claimed.length) { - return { claimed: 0 }; + return { claimed: 0, available: 0, duplicate: 0, failed: 0 }; } + const stats = { claimed: claimed.length, available: 0, duplicate: 0, failed: 0 }; + for (const item of claimed) { - await processClaimedInvoice(prisma, config, item, workerId, logger); + const result = await processClaimedInvoice(prisma, config, item, workerId, logger); + if (result?.outcome === "available") { + stats.available += 1; + } else if (result?.outcome === "duplicate") { + stats.duplicate += 1; + } else if (result?.outcome === "failed") { + stats.failed += 1; + } } - return { claimed: claimed.length }; + return stats; } function startOcrJob(fastify) { @@ -289,7 +300,16 @@ function startOcrJob(fastify) { running = true; try { - await runOcrOnce(fastify.prisma, fastify.config, fastify.log, workerId); + const result = await runOcrOnce(fastify.prisma, fastify.config, fastify.log, workerId); + if (result.claimed > 0) { + fastify.log.info( + { + workerId, + ...result, + }, + "OCR job tick processed", + ); + } } catch (error) { fastify.log.error({ err: error }, "OCR job tick failed"); } finally { diff --git a/server/src/scripts/run-ocr-once.js b/server/src/scripts/run-ocr-once.js index f3a3fdf3..ce5986c8 100644 --- a/server/src/scripts/run-ocr-once.js +++ b/server/src/scripts/run-ocr-once.js @@ -15,7 +15,9 @@ const { runOcrOnce } = require("../jobs/ocrJob"); } const result = await runOcrOnce(prisma, config, console, `ocr-script:${process.pid}`); - process.stdout.write(`claimed=${result.claimed}\n`); + process.stdout.write( + `claimed=${result.claimed} available=${result.available} duplicate=${result.duplicate} failed=${result.failed}\n`, + ); } finally { await prisma.$disconnect(); await pool.end();