feat(server): 完成 F19~F19d pg-boss 作业系统迁移\n\n- 引入 pg-boss(JOB_DRIVER=pgboss)并保留 cron 回滚\n- OCR/委派回收/超期归档改由 pg-boss schedule + work 执行\n- OCR_PROVIDER=off 自动 unschedule ocr.tick\n- 更新 trialrun env/compose/runbook 与 README,并推进 CSV 状态为 DONE
这个提交包含在:
+12
@@ -80,6 +80,18 @@ npm run seed:trialrun
|
||||
- `EMAIL_FROM`(发件人地址)
|
||||
- `EMAIL_BASE_URL`(可选,邮件内“查看详情”链接的站点前缀;为空则尝试使用 `APP_ORIGIN` 的第一个值)
|
||||
|
||||
### 2.3.3 作业系统(cron vs pg-boss)(F19~F19d)
|
||||
- 默认:`JOB_DRIVER=cron`(同进程 node-cron 调度)
|
||||
- 可选升级:`JOB_DRIVER=pgboss`(Postgres 队列,适配单机/多进程;避免引入 Redis)
|
||||
- `PG_BOSS_SCHEMA`(默认 `pgboss`)
|
||||
- `JOBS_ENABLED=false` 可整体关闭作业(仅用于排障/紧急回滚)
|
||||
- 目前迁移的作业:
|
||||
- `ocr.tick`(受 `OCR_PROVIDER` 控制,off 时不调度)
|
||||
- `delegation.revoke-expired`
|
||||
- `invoice.archive-unused`
|
||||
- 回滚策略:
|
||||
- 将 `JOB_DRIVER` 切回 `cron`(无需 schema 迁移回滚,风险低)
|
||||
|
||||
### 2.4 启动服务
|
||||
```bash
|
||||
cd /home/shunli/Projects/hyb-oa/server
|
||||
|
||||
+63
@@ -26,6 +26,7 @@
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^6.10.1",
|
||||
"pg": "^8.18.0",
|
||||
"pg-boss": "^10.3.0",
|
||||
"prisma": "^7.3.0",
|
||||
"tencentcloud-sdk-nodejs": "^4.1.14",
|
||||
"zod": "^4.3.6",
|
||||
@@ -1253,6 +1254,18 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/cron-parser": {
|
||||
"version": "4.9.0",
|
||||
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
|
||||
"integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"luxon": "^3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -2459,6 +2472,15 @@
|
||||
"url": "https://github.com/sponsors/wellwelwel"
|
||||
}
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "3.7.2",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -2784,6 +2806,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-boss": {
|
||||
"version": "10.4.2",
|
||||
"resolved": "https://registry.npmjs.org/pg-boss/-/pg-boss-10.4.2.tgz",
|
||||
"integrity": "sha512-AttEWOtSzn53av8OnCMWEanwRBvjkZCE1y5nLrZnwvkkMnlZ5XpWDpZ7sKI/BYjvi2OVieMX37arD2ACgJ750w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cron-parser": "^4.9.0",
|
||||
"pg": "^8.16.3",
|
||||
"serialize-error": "^8.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
|
||||
@@ -3358,6 +3394,21 @@
|
||||
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
|
||||
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q=="
|
||||
},
|
||||
"node_modules/serialize-error": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz",
|
||||
"integrity": "sha512-3NnuWfM6vBYoy5gZFvHiYsVbafvI9vZv/+jlIigFn4oP4zjNPK3LhcY0xSCgeb1a5L8jO71Mit9LlNoi2UfDDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"type-fest": "^0.20.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
@@ -3578,6 +3629,18 @@
|
||||
"integrity": "sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "0.20.2",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
|
||||
"integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
|
||||
"license": "(MIT OR CC0-1.0)",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/unzipper": {
|
||||
"version": "0.10.14",
|
||||
"resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz",
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"fastify-plugin": "^5.1.0",
|
||||
"nodemailer": "^6.10.1",
|
||||
"node-cron": "^4.2.1",
|
||||
"pg-boss": "^10.3.0",
|
||||
"pg": "^8.18.0",
|
||||
"prisma": "^7.3.0",
|
||||
"tencentcloud-sdk-nodejs": "^4.1.14",
|
||||
|
||||
+2
-6
@@ -29,9 +29,7 @@ const configReadRoutes = require("./routes/configRead");
|
||||
const approvalRulesRoutes = require("./routes/approvalRules");
|
||||
const notificationsRoutes = require("./routes/notifications");
|
||||
const exportsRoutes = require("./routes/exports");
|
||||
const { startDelegationRevokeJob } = require("./jobs/delegationRevokeJob");
|
||||
const { startOcrJob } = require("./jobs/ocrJob");
|
||||
const { startInvoiceArchiveJob } = require("./jobs/invoiceArchiveJob");
|
||||
const { startJobs } = require("./jobs/jobSystem");
|
||||
const { AppError } = require("./utils/AppError");
|
||||
|
||||
async function buildApp() {
|
||||
@@ -207,9 +205,7 @@ async function buildApp() {
|
||||
await app.register(notificationsRoutes, { prefix: "/api" });
|
||||
await app.register(exportsRoutes, { prefix: "/api" });
|
||||
|
||||
startDelegationRevokeJob(app);
|
||||
startOcrJob(app);
|
||||
startInvoiceArchiveJob(app);
|
||||
await startJobs(app);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -62,6 +62,10 @@ function getConfig() {
|
||||
|
||||
delegationRevokeCron: process.env.DELEGATION_REVOKE_CRON ?? "0 * * * *",
|
||||
|
||||
jobDriver: process.env.JOB_DRIVER ?? "cron",
|
||||
jobsEnabled: process.env.JOBS_ENABLED !== "false",
|
||||
pgBossSchema: process.env.PG_BOSS_SCHEMA ?? "pgboss",
|
||||
|
||||
ocrProvider: process.env.OCR_PROVIDER ?? "off",
|
||||
ocrBatchSize: Number(process.env.OCR_BATCH_SIZE ?? 10),
|
||||
ocrLockTtlSeconds: Number(process.env.OCR_LOCK_TTL_SECONDS ?? 300),
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
const { startDelegationRevokeJob } = require("./delegationRevokeJob");
|
||||
const { startInvoiceArchiveJob } = require("./invoiceArchiveJob");
|
||||
const { startOcrJob } = require("./ocrJob");
|
||||
const { startPgBossJobs } = require("./pgBossJobs");
|
||||
|
||||
async function startJobs(fastify) {
|
||||
if (!fastify.config.jobsEnabled) {
|
||||
fastify.log.info("Jobs disabled");
|
||||
return { driver: "disabled" };
|
||||
}
|
||||
|
||||
if (fastify.config.jobDriver === "pgboss") {
|
||||
await startPgBossJobs(fastify);
|
||||
return { driver: "pgboss" };
|
||||
}
|
||||
|
||||
startDelegationRevokeJob(fastify);
|
||||
startOcrJob(fastify);
|
||||
startInvoiceArchiveJob(fastify);
|
||||
return { driver: "cron" };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
startJobs,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
const PgBoss = require("pg-boss");
|
||||
|
||||
const { revokeExpiredDelegations } = require("./delegationRevokeJob");
|
||||
const { archiveUnusedInvoices } = require("./invoiceArchiveJob");
|
||||
const { runOcrOnce } = require("./ocrJob");
|
||||
|
||||
const JOB_OCR_TICK = "ocr.tick";
|
||||
const JOB_DELEGATION_REVOKE = "delegation.revoke-expired";
|
||||
const JOB_INVOICE_ARCHIVE = "invoice.archive-unused";
|
||||
|
||||
function resolveDatabaseConnectionString() {
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error("Missing DATABASE_URL");
|
||||
}
|
||||
return connectionString;
|
||||
}
|
||||
|
||||
async function startPgBossJobs(fastify) {
|
||||
const connectionString = resolveDatabaseConnectionString();
|
||||
|
||||
const boss = new PgBoss({
|
||||
connectionString,
|
||||
schema: fastify.config.pgBossSchema,
|
||||
});
|
||||
|
||||
await boss.start();
|
||||
|
||||
fastify.decorate("boss", boss);
|
||||
|
||||
fastify.addHook("onClose", async () => {
|
||||
await boss.stop({ graceful: true, timeout: 10_000 });
|
||||
});
|
||||
|
||||
await boss.createQueue(JOB_OCR_TICK);
|
||||
await boss.createQueue(JOB_DELEGATION_REVOKE);
|
||||
await boss.createQueue(JOB_INVOICE_ARCHIVE);
|
||||
|
||||
await boss.work(JOB_DELEGATION_REVOKE, { batchSize: 1 }, async (jobs) => {
|
||||
const job = jobs?.[0];
|
||||
const revoked = await revokeExpiredDelegations(fastify.prisma, fastify.log);
|
||||
if (revoked > 0) {
|
||||
fastify.log.info(
|
||||
{ jobId: job?.id, revoked },
|
||||
"pg-boss delegation revoke tick processed",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await boss.work(JOB_INVOICE_ARCHIVE, { batchSize: 1 }, async (jobs) => {
|
||||
const job = jobs?.[0];
|
||||
const archived = await archiveUnusedInvoices(fastify.prisma, fastify.log, {
|
||||
batchSize: fastify.config.invoiceArchiveBatchSize,
|
||||
unusedDays: fastify.config.invoiceArchiveUnusedDays,
|
||||
});
|
||||
if (archived > 0) {
|
||||
fastify.log.info(
|
||||
{ jobId: job?.id, archived, unusedDays: fastify.config.invoiceArchiveUnusedDays },
|
||||
"pg-boss invoice archive tick processed",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await boss.work(JOB_OCR_TICK, { batchSize: 1 }, async (jobs) => {
|
||||
if (!["tencent", "mock"].includes(fastify.config.ocrProvider)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const job = jobs?.[0];
|
||||
const workerId = `pgboss:ocr:${process.pid}`;
|
||||
const stats = await runOcrOnce(fastify.prisma, fastify.config, fastify.log, workerId);
|
||||
if (stats.claimed > 0) {
|
||||
fastify.log.info(
|
||||
{ jobId: job?.id, workerId, ...stats },
|
||||
"pg-boss OCR tick processed",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await boss.schedule(JOB_DELEGATION_REVOKE, fastify.config.delegationRevokeCron, null, {
|
||||
tz: fastify.config.appTimezone,
|
||||
singletonKey: JOB_DELEGATION_REVOKE,
|
||||
singletonMinutes: 60,
|
||||
});
|
||||
|
||||
await boss.schedule(JOB_INVOICE_ARCHIVE, fastify.config.invoiceArchiveCron, null, {
|
||||
tz: fastify.config.appTimezone,
|
||||
singletonKey: JOB_INVOICE_ARCHIVE,
|
||||
singletonHours: 24,
|
||||
});
|
||||
|
||||
if (["tencent", "mock"].includes(fastify.config.ocrProvider)) {
|
||||
await boss.schedule(JOB_OCR_TICK, fastify.config.ocrPollCron, null, {
|
||||
tz: fastify.config.appTimezone,
|
||||
singletonKey: JOB_OCR_TICK,
|
||||
singletonSeconds: 55,
|
||||
});
|
||||
} else {
|
||||
await boss.unschedule(JOB_OCR_TICK).catch(() => {});
|
||||
}
|
||||
|
||||
fastify.log.info(
|
||||
{
|
||||
schema: fastify.config.pgBossSchema,
|
||||
ocrProvider: fastify.config.ocrProvider,
|
||||
ocrCron: fastify.config.ocrPollCron,
|
||||
delegationCron: fastify.config.delegationRevokeCron,
|
||||
invoiceArchiveCron: fastify.config.invoiceArchiveCron,
|
||||
},
|
||||
"pg-boss job system started",
|
||||
);
|
||||
|
||||
return boss;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
JOB_OCR_TICK,
|
||||
JOB_DELEGATION_REVOKE,
|
||||
JOB_INVOICE_ARCHIVE,
|
||||
startPgBossJobs,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户