feat(server): readiness 与作业统计可观测性(E11)

- 新增 /ready(DB 探活失败返回 503)
- OCR 作业输出 claimed/available/duplicate/failed 统计
- README 补充 /health 与 /ready 说明
这个提交包含在:
2026-02-09 00:02:49 +08:00
父节点 479565f737
当前提交 75d5b3d5de
共修改 5 个文件,包含 54 行新增和 7 行删除
+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. 回归入口
+15
查看文件
@@ -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 {
+3 -1
查看文件
@@ -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();