feat(server): readiness 与作业统计可观测性(E11)
- 新增 /ready(DB 探活失败返回 503) - OCR 作业输出 claimed/available/duplicate/failed 统计 - README 补充 /health 与 /ready 说明
这个提交包含在:
@@ -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不写死密码并输出可审计日志
|
||||
|
||||
|
+10
@@ -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. 回归入口
|
||||
|
||||
@@ -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" });
|
||||
|
||||
+25
-5
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
在新工单中引用
屏蔽一个用户