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

这个提交包含在:
2026-02-09 20:05:01 +08:00
父节点 f233a0d5bc
当前提交 dff1aabc26
共修改 11 个文件,包含 249 行新增和 12 行删除
+5 -1
查看文件
@@ -52,9 +52,13 @@ TENCENT_OCR_REGION="ap-beijing"
# ===== 委派回收 ===== # ===== 委派回收 =====
DELEGATION_REVOKE_CRON="0 * * * *" DELEGATION_REVOKE_CRON="0 * * * *"
# ===== 作业系统(pg-boss,可回滚到 cron)=====
JOB_DRIVER="pgboss" # cron|pgboss
JOBS_ENABLED="true" # false 时整体关闭作业(排障/回滚)
PG_BOSS_SCHEMA="pgboss" # pg-boss 在 PG 内的 schema
# ===== 试运行引导管理员(E20)===== # ===== 试运行引导管理员(E20)=====
TRIALRUN_ADMIN_EMAIL="[email protected]" TRIALRUN_ADMIN_EMAIL="[email protected]"
TRIALRUN_ADMIN_PHONE="13800000000" TRIALRUN_ADMIN_PHONE="13800000000"
# 留空则首次 seed 自动生成并打印一次性口令 # 留空则首次 seed 自动生成并打印一次性口令
TRIALRUN_ADMIN_PASSWORD="" TRIALRUN_ADMIN_PASSWORD=""
+7 -1
查看文件
@@ -55,6 +55,10 @@ services:
TENCENT_OCR_REGION: ${TENCENT_OCR_REGION} TENCENT_OCR_REGION: ${TENCENT_OCR_REGION}
DELEGATION_REVOKE_CRON: ${DELEGATION_REVOKE_CRON} DELEGATION_REVOKE_CRON: ${DELEGATION_REVOKE_CRON}
JOB_DRIVER: ${JOB_DRIVER}
JOBS_ENABLED: ${JOBS_ENABLED}
PG_BOSS_SCHEMA: ${PG_BOSS_SCHEMA}
ports: ports:
- "3000:3000" - "3000:3000"
volumes: volumes:
@@ -86,6 +90,9 @@ services:
TENCENT_SECRET_KEY: ${TENCENT_SECRET_KEY} TENCENT_SECRET_KEY: ${TENCENT_SECRET_KEY}
TENCENT_OCR_REGION: ${TENCENT_OCR_REGION} TENCENT_OCR_REGION: ${TENCENT_OCR_REGION}
DELEGATION_REVOKE_CRON: ${DELEGATION_REVOKE_CRON} DELEGATION_REVOKE_CRON: ${DELEGATION_REVOKE_CRON}
JOB_DRIVER: ${JOB_DRIVER}
JOBS_ENABLED: ${JOBS_ENABLED}
PG_BOSS_SCHEMA: ${PG_BOSS_SCHEMA}
TRIALRUN_ADMIN_EMAIL: ${TRIALRUN_ADMIN_EMAIL} TRIALRUN_ADMIN_EMAIL: ${TRIALRUN_ADMIN_EMAIL}
TRIALRUN_ADMIN_PHONE: ${TRIALRUN_ADMIN_PHONE} TRIALRUN_ADMIN_PHONE: ${TRIALRUN_ADMIN_PHONE}
@@ -99,4 +106,3 @@ services:
&& npm run seed:config && npm run seed:config
&& npm run seed:trialrun" && npm run seed:trialrun"
restart: "no" restart: "no"
+4
查看文件
@@ -18,6 +18,10 @@ cp deploy/trialrun/.env.example deploy/trialrun/.env
- `JWT_SECRET` - `JWT_SECRET`
- `APP_ORIGIN`(建议与对外域名一致,例如 `https://oa.example.com`) - `APP_ORIGIN`(建议与对外域名一致,例如 `https://oa.example.com`)
作业系统建议:
- 试运行默认 `JOB_DRIVER=pgboss`(基于 Postgres 的队列调度,单机/多进程语义更稳)
- 如需快速回滚作业调度:将 `JOB_DRIVER` 改回 `cron`
### 1.2 启动数据库与服务 ### 1.2 启动数据库与服务
```bash ```bash
cd /path/to/hyb-oa cd /path/to/hyb-oa
@@ -17,10 +17,10 @@ F15,邮件通知(SMTP/Nodemailer),"引入 nodemailer 发送邮件:新待
F16,前端通知入口(Bell),"新增通知列表页/弹层与未读角标;支持标记已读","四角色均可见自己的通知;读写后角标正确;e2e 覆盖核心路径","cd client && npm run test:e2e",manual,DONE,DONE,DONE,"client/src/components/navigation/Topbar.tsx | client/src/pages/NotificationsPage.tsx | client/src/api/notifications.ts","[""F14""]","保持 UI 轻量,不做复杂订阅推送" F16,前端通知入口(Bell),"新增通知列表页/弹层与未读角标;支持标记已读","四角色均可见自己的通知;读写后角标正确;e2e 覆盖核心路径","cd client && npm run test:e2e",manual,DONE,DONE,DONE,"client/src/components/navigation/Topbar.tsx | client/src/pages/NotificationsPage.tsx | client/src/api/notifications.ts","[""F14""]","保持 UI 轻量,不做复杂订阅推送"
F17,报表导出(CSV/Excel),"新增报销/票据/审计的导出接口(权限控制),前端提供下载入口","导出字段与权限正确;大数据量时可分页/流式输出;审计记录导出操作","manual: 导出后核对字段;并在 audit_logs 中检索 EXPORT 动作",manual,DONE,DONE,DONE,"server/src/routes/exports.js | server/src/app.js | server/package.json | server/package-lock.json | client/src/api/exports.ts | client/src/api/http.ts | client/src/lib/download.ts | client/src/pages/ReimbursementsPage.tsx | client/src/pages/InvoicePoolPage.tsx | client/src/pages/AuditLogsPage.tsx","[""F6"",""F16""]","选型:exceljs(XLSX),CSV 采用流式生成;回滚:删除 exportsRoutes 注册与依赖即可" F17,报表导出(CSV/Excel),"新增报销/票据/审计的导出接口(权限控制),前端提供下载入口","导出字段与权限正确;大数据量时可分页/流式输出;审计记录导出操作","manual: 导出后核对字段;并在 audit_logs 中检索 EXPORT 动作",manual,DONE,DONE,DONE,"server/src/routes/exports.js | server/src/app.js | server/package.json | server/package-lock.json | client/src/api/exports.ts | client/src/api/http.ts | client/src/lib/download.ts | client/src/pages/ReimbursementsPage.tsx | client/src/pages/InvoicePoolPage.tsx | client/src/pages/AuditLogsPage.tsx","[""F6"",""F16""]","选型:exceljs(XLSX),CSV 采用流式生成;回滚:删除 exportsRoutes 注册与依赖即可"
F18,审批规则维护(system.settings),"补齐 approval_rules 维护 API 与最小前端页(finance_mgr/admin),支持启停与优先级/金额段维护","规则变更可立即影响新 submit;历史 instance 不回写;审计记录变更","cd server && ./scripts/regression-workflow-actions.sh",manual,DONE,DONE,DONE,"server/src/routes/approvalRules.js | server/src/app.js | client/src/pages/ApprovalRulesPage.tsx | client/src/api/approvalRules.ts | client/src/router/index.tsx | client/src/components/navigation/NavLinks.tsx","[""F5""]","只做最小可运营;复杂可视化后置" F18,审批规则维护(system.settings),"补齐 approval_rules 维护 API 与最小前端页(finance_mgr/admin),支持启停与优先级/金额段维护","规则变更可立即影响新 submit;历史 instance 不回写;审计记录变更","cd server && ./scripts/regression-workflow-actions.sh",manual,DONE,DONE,DONE,"server/src/routes/approvalRules.js | server/src/app.js | client/src/pages/ApprovalRulesPage.tsx | client/src/api/approvalRules.ts | client/src/router/index.tsx | client/src/components/navigation/NavLinks.tsx","[""F5""]","只做最小可运营;复杂可视化后置"
F19,作业系统升级(pg-boss 基础设施),"引入 pg-boss(Postgres 队列)作为作业基础设施:提供统一注册/执行框架与开关;保持 node-cron 作业可回滚;并文档化单机用法","pg-boss 可在单机/多进程下可靠运行;最小示例 job 可跑通;不影响现有回归","cd server && ./scripts/regression-all.sh",manual,TODO,TODO,TODO,"server/src/jobs/* | server/src/app.js | server/package.json | deploy/trialrun/* | server/README.md","[""F1""]","选择 pg-boss(成熟/无 Redis);回滚:关闭开关并切回 node-cron" F19,作业系统升级(pg-boss 基础设施),"引入 pg-boss(Postgres 队列)作为作业基础设施:提供统一注册/执行框架与开关;保持 node-cron 作业可回滚;并文档化单机用法","pg-boss 可在单机/多进程下可靠运行;最小示例 job 可跑通;不影响现有回归","cd server && ./scripts/regression-all.sh",manual,DONE,DONE,DONE,"server/src/jobs/jobSystem.js | server/src/jobs/pgBossJobs.js | server/src/app.js | server/src/config.js | server/package.json | server/package-lock.json | server/README.md | deploy/trialrun/.env.example | deploy/trialrun/docker-compose.yml | deploy/trialrun/runbook.md","[""F1""]","选择 pg-boss(成熟/无 Redis);回滚:JOB_DRIVER=cron 或 JOBS_ENABLED=false"
F19b,作业迁移(OCR -> pg-boss),"将 OCR 异步处理迁移到 pg-boss:保持并发锁语义、幂等与回归脚本不变","mock/tencent 两种 provider 下 OCR 仍可跑;并发下单条仅处理一次;回归全绿","cd server && ./scripts/regression-ocr.sh && ./scripts/regression-all.sh",manual,TODO,TODO,TODO,"server/src/jobs/ocrJob.js | server/src/services/ocr/* | server/README.md","[""F19""]","迁移后移除/停用对应 node-cron tick(保留回滚路径)" F19b,作业迁移(OCR -> pg-boss),"将 OCR 异步处理迁移到 pg-boss:保持并发锁语义、幂等与回归脚本不变","mock/tencent 两种 provider 下 OCR 仍可跑;并发下单条仅处理一次;回归全绿","cd server && ./scripts/regression-ocr.sh && ./scripts/regression-all.sh",manual,DONE,DONE,DONE,"server/src/jobs/pgBossJobs.js | server/src/jobs/ocrJob.js | server/src/jobs/jobSystem.js | server/src/config.js | server/README.md","[""F19""]","pg-boss schedule 支持秒级 cron;OCR_PROVIDER=off 时自动 unschedule ocr.tick"
F19c,作业迁移(委派回收 -> pg-boss),"将委派过期回收迁移到 pg-boss:保持 FOR UPDATE SKIP LOCKED + UPDATE RETURNING 原子认领语义","多进程并发下审计不重复写;回收逻辑不变;回归全绿","cd server && RUN_DELEGATION_CONCURRENCY_CHECK=1 ./scripts/regression-workflow-actions.sh",manual,TODO,TODO,TODO,"server/src/jobs/delegationRevokeJob.js | server/README.md","[""F19""]","仍需保留原子认领+审计只基于 RETURNING 结果集" F19c,作业迁移(委派回收 -> pg-boss),"将委派过期回收迁移到 pg-boss:保持 FOR UPDATE SKIP LOCKED + UPDATE RETURNING 原子认领语义","多进程并发下审计不重复写;回收逻辑不变;回归全绿","cd server && RUN_DELEGATION_CONCURRENCY_CHECK=1 ./scripts/regression-workflow-actions.sh",manual,DONE,DONE,DONE,"server/src/jobs/pgBossJobs.js | server/src/jobs/delegationRevokeJob.js | server/src/jobs/jobSystem.js | server/README.md","[""F19""]","仍保留原子认领+审计只基于 RETURNING 结果集"
F19d,作业迁移(超期归档 -> pg-boss),"将 90 天游离归档迁移到 pg-boss(替代 node-cron),统一重试/并发/可观测字段","作业可重复执行幂等;多进程不重复处理;回归全绿","cd server && ./scripts/regression-ocr.sh && ./scripts/regression-all.sh",manual,TODO,TODO,TODO,"server/src/jobs/invoiceArchiveJob.js | server/README.md","[""F19"",""F12""]","迁移后在 README 标注调度频率与开关" F19d,作业迁移(超期归档 -> pg-boss),"将 90 天游离归档迁移到 pg-boss(替代 node-cron),统一重试/并发/可观测字段","作业可重复执行幂等;多进程不重复处理;回归全绿","cd server && ./scripts/regression-ocr.sh && ./scripts/regression-all.sh",manual,DONE,DONE,DONE,"server/src/jobs/pgBossJobs.js | server/src/jobs/invoiceArchiveJob.js | server/src/jobs/jobSystem.js | server/README.md","[""F19"",""F12""]","invoice.archive-unused 默认按 INVOICE_ARCHIVE_CRON 调度;回滚:JOB_DRIVER=cron"
F20,Worker进程拆分(可选),"提供 API 进程与 Worker 进程拆分的部署方式(compose 开关),保持单机也可跑","开启后:API 不跑作业;Worker 只跑作业;两者共享 DATABASE_URL;功能不回归","manual: docker compose 起两个进程并跑回归脚本",manual,TODO,TODO,TODO,"deploy/trialrun/docker-compose.yml | server/src/index.js | server/README.md","[""F19""]","仍保持单机;为未来横向扩展铺路" F20,Worker进程拆分(可选),"提供 API 进程与 Worker 进程拆分的部署方式(compose 开关),保持单机也可跑","开启后:API 不跑作业;Worker 只跑作业;两者共享 DATABASE_URL;功能不回归","manual: docker compose 起两个进程并跑回归脚本",manual,TODO,TODO,TODO,"deploy/trialrun/docker-compose.yml | server/src/index.js | server/README.md","[""F19""]","仍保持单机;为未来横向扩展铺路"
F21,对象存储(MinIO/S3兼容),"存储从本地目录演进到 S3 兼容(先 MinIO),保持 signed-url 契约不变或提供兼容层","上传/预览/签名URL 在 MinIO 下可用;回归与 E2E 通过;迁移路径清晰可回滚","manual: trialrun 环境启用 MinIO 并完成一次上传预览",manual,TODO,TODO,TODO,"server/src/services/storage/* | deploy/trialrun/* | server/README.md","[""F20""]","优先用 @aws-sdk/client-s3;单机优先 MinIO" F21,对象存储(MinIO/S3兼容),"存储从本地目录演进到 S3 兼容(先 MinIO),保持 signed-url 契约不变或提供兼容层","上传/预览/签名URL 在 MinIO 下可用;回归与 E2E 通过;迁移路径清晰可回滚","manual: trialrun 环境启用 MinIO 并完成一次上传预览",manual,TODO,TODO,TODO,"server/src/services/storage/* | deploy/trialrun/* | server/README.md","[""F20""]","优先用 @aws-sdk/client-s3;单机优先 MinIO"
F22,可观测性增强(metrics/日志),"新增 /metrics(Prometheus)与更完整的结构化日志字段规范(不落敏感)","metrics 可抓取关键指标(HTTP/作业/DB);日志具备 requestId/actorId/targetId 等字段","manual: curl /metrics 并核对字段;触发作业观察指标变化",manual,TODO,TODO,TODO,"server/src/app.js | server/src/observability/* | deploy/trialrun/*","[""F19""]","优先用 prom-client;OTel 放更后" F22,可观测性增强(metrics/日志),"新增 /metrics(Prometheus)与更完整的结构化日志字段规范(不落敏感)","metrics 可抓取关键指标(HTTP/作业/DB);日志具备 requestId/actorId/targetId 等字段","manual: curl /metrics 并核对字段;触发作业观察指标变化",manual,TODO,TODO,TODO,"server/src/app.js | server/src/observability/* | deploy/trialrun/*","[""F19""]","优先用 prom-client;OTel 放更后"
1 ID Title Description Acceptance Test_Method Tools Dev_Status Review1_Status Regression_Status Files Dependencies Notes
17 F16 前端通知入口(Bell) 新增通知列表页/弹层与未读角标;支持标记已读 四角色均可见自己的通知;读写后角标正确;e2e 覆盖核心路径 cd client && npm run test:e2e manual DONE DONE DONE client/src/components/navigation/Topbar.tsx | client/src/pages/NotificationsPage.tsx | client/src/api/notifications.ts ["F14"] 保持 UI 轻量,不做复杂订阅推送
18 F17 报表导出(CSV/Excel) 新增报销/票据/审计的导出接口(权限控制),前端提供下载入口 导出字段与权限正确;大数据量时可分页/流式输出;审计记录导出操作 manual: 导出后核对字段;并在 audit_logs 中检索 EXPORT 动作 manual DONE DONE DONE server/src/routes/exports.js | server/src/app.js | server/package.json | server/package-lock.json | client/src/api/exports.ts | client/src/api/http.ts | client/src/lib/download.ts | client/src/pages/ReimbursementsPage.tsx | client/src/pages/InvoicePoolPage.tsx | client/src/pages/AuditLogsPage.tsx ["F6","F16"] 选型:exceljs(XLSX),CSV 采用流式生成;回滚:删除 exportsRoutes 注册与依赖即可
19 F18 审批规则维护(system.settings) 补齐 approval_rules 维护 API 与最小前端页(finance_mgr/admin),支持启停与优先级/金额段维护 规则变更可立即影响新 submit;历史 instance 不回写;审计记录变更 cd server && ./scripts/regression-workflow-actions.sh manual DONE DONE DONE server/src/routes/approvalRules.js | server/src/app.js | client/src/pages/ApprovalRulesPage.tsx | client/src/api/approvalRules.ts | client/src/router/index.tsx | client/src/components/navigation/NavLinks.tsx ["F5"] 只做最小可运营;复杂可视化后置
20 F19 作业系统升级(pg-boss 基础设施) 引入 pg-boss(Postgres 队列)作为作业基础设施:提供统一注册/执行框架与开关;保持 node-cron 作业可回滚;并文档化单机用法 pg-boss 可在单机/多进程下可靠运行;最小示例 job 可跑通;不影响现有回归 cd server && ./scripts/regression-all.sh manual TODO DONE TODO DONE TODO DONE server/src/jobs/* | server/src/app.js | server/package.json | deploy/trialrun/* | server/README.md server/src/jobs/jobSystem.js | server/src/jobs/pgBossJobs.js | server/src/app.js | server/src/config.js | server/package.json | server/package-lock.json | server/README.md | deploy/trialrun/.env.example | deploy/trialrun/docker-compose.yml | deploy/trialrun/runbook.md ["F1"] 选择 pg-boss(成熟/无 Redis);回滚:关闭开关并切回 node-cron 选择 pg-boss(成熟/无 Redis);回滚:JOB_DRIVER=cron 或 JOBS_ENABLED=false
21 F19b 作业迁移(OCR -> pg-boss) 将 OCR 异步处理迁移到 pg-boss:保持并发锁语义、幂等与回归脚本不变 mock/tencent 两种 provider 下 OCR 仍可跑;并发下单条仅处理一次;回归全绿 cd server && ./scripts/regression-ocr.sh && ./scripts/regression-all.sh manual TODO DONE TODO DONE TODO DONE server/src/jobs/ocrJob.js | server/src/services/ocr/* | server/README.md server/src/jobs/pgBossJobs.js | server/src/jobs/ocrJob.js | server/src/jobs/jobSystem.js | server/src/config.js | server/README.md ["F19"] 迁移后移除/停用对应 node-cron tick(保留回滚路径) pg-boss schedule 支持秒级 cron;OCR_PROVIDER=off 时自动 unschedule ocr.tick
22 F19c 作业迁移(委派回收 -> pg-boss) 将委派过期回收迁移到 pg-boss:保持 FOR UPDATE SKIP LOCKED + UPDATE RETURNING 原子认领语义 多进程并发下审计不重复写;回收逻辑不变;回归全绿 cd server && RUN_DELEGATION_CONCURRENCY_CHECK=1 ./scripts/regression-workflow-actions.sh manual TODO DONE TODO DONE TODO DONE server/src/jobs/delegationRevokeJob.js | server/README.md server/src/jobs/pgBossJobs.js | server/src/jobs/delegationRevokeJob.js | server/src/jobs/jobSystem.js | server/README.md ["F19"] 仍需保留原子认领+审计只基于 RETURNING 结果集 仍保留原子认领+审计只基于 RETURNING 结果集
23 F19d 作业迁移(超期归档 -> pg-boss) 将 90 天游离归档迁移到 pg-boss(替代 node-cron),统一重试/并发/可观测字段 作业可重复执行幂等;多进程不重复处理;回归全绿 cd server && ./scripts/regression-ocr.sh && ./scripts/regression-all.sh manual TODO DONE TODO DONE TODO DONE server/src/jobs/invoiceArchiveJob.js | server/README.md server/src/jobs/pgBossJobs.js | server/src/jobs/invoiceArchiveJob.js | server/src/jobs/jobSystem.js | server/README.md ["F19","F12"] 迁移后在 README 标注调度频率与开关 invoice.archive-unused 默认按 INVOICE_ARCHIVE_CRON 调度;回滚:JOB_DRIVER=cron
24 F20 Worker进程拆分(可选) 提供 API 进程与 Worker 进程拆分的部署方式(compose 开关),保持单机也可跑 开启后:API 不跑作业;Worker 只跑作业;两者共享 DATABASE_URL;功能不回归 manual: docker compose 起两个进程并跑回归脚本 manual TODO TODO TODO deploy/trialrun/docker-compose.yml | server/src/index.js | server/README.md ["F19"] 仍保持单机;为未来横向扩展铺路
25 F21 对象存储(MinIO/S3兼容) 存储从本地目录演进到 S3 兼容(先 MinIO),保持 signed-url 契约不变或提供兼容层 上传/预览/签名URL 在 MinIO 下可用;回归与 E2E 通过;迁移路径清晰可回滚 manual: trialrun 环境启用 MinIO 并完成一次上传预览 manual TODO TODO TODO server/src/services/storage/* | deploy/trialrun/* | server/README.md ["F20"] 优先用 @aws-sdk/client-s3;单机优先 MinIO
26 F22 可观测性增强(metrics/日志) 新增 /metrics(Prometheus)与更完整的结构化日志字段规范(不落敏感) metrics 可抓取关键指标(HTTP/作业/DB);日志具备 requestId/actorId/targetId 等字段 manual: curl /metrics 并核对字段;触发作业观察指标变化 manual TODO TODO TODO server/src/app.js | server/src/observability/* | deploy/trialrun/* ["F19"] 优先用 prom-client;OTel 放更后
+12
查看文件
@@ -80,6 +80,18 @@ npm run seed:trialrun
- `EMAIL_FROM`(发件人地址) - `EMAIL_FROM`(发件人地址)
- `EMAIL_BASE_URL`(可选,邮件内“查看详情”链接的站点前缀;为空则尝试使用 `APP_ORIGIN` 的第一个值) - `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 启动服务 ### 2.4 启动服务
```bash ```bash
cd /home/shunli/Projects/hyb-oa/server cd /home/shunli/Projects/hyb-oa/server
+63
查看文件
@@ -26,6 +26,7 @@
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"nodemailer": "^6.10.1", "nodemailer": "^6.10.1",
"pg": "^8.18.0", "pg": "^8.18.0",
"pg-boss": "^10.3.0",
"prisma": "^7.3.0", "prisma": "^7.3.0",
"tencentcloud-sdk-nodejs": "^4.1.14", "tencentcloud-sdk-nodejs": "^4.1.14",
"zod": "^4.3.6", "zod": "^4.3.6",
@@ -1253,6 +1254,18 @@
"node": ">= 10" "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": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2459,6 +2472,15 @@
"url": "https://github.com/sponsors/wellwelwel" "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": { "node_modules/math-intrinsics": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "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": { "node_modules/pg-cloudflare": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", "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", "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
"integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" "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": { "node_modules/set-cookie-parser": {
"version": "2.7.2", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", "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==", "integrity": "sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==",
"license": "0BSD" "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": { "node_modules/unzipper": {
"version": "0.10.14", "version": "0.10.14",
"resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz",
+1
查看文件
@@ -40,6 +40,7 @@
"fastify-plugin": "^5.1.0", "fastify-plugin": "^5.1.0",
"nodemailer": "^6.10.1", "nodemailer": "^6.10.1",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"pg-boss": "^10.3.0",
"pg": "^8.18.0", "pg": "^8.18.0",
"prisma": "^7.3.0", "prisma": "^7.3.0",
"tencentcloud-sdk-nodejs": "^4.1.14", "tencentcloud-sdk-nodejs": "^4.1.14",
+2 -6
查看文件
@@ -29,9 +29,7 @@ const configReadRoutes = require("./routes/configRead");
const approvalRulesRoutes = require("./routes/approvalRules"); const approvalRulesRoutes = require("./routes/approvalRules");
const notificationsRoutes = require("./routes/notifications"); const notificationsRoutes = require("./routes/notifications");
const exportsRoutes = require("./routes/exports"); const exportsRoutes = require("./routes/exports");
const { startDelegationRevokeJob } = require("./jobs/delegationRevokeJob"); const { startJobs } = require("./jobs/jobSystem");
const { startOcrJob } = require("./jobs/ocrJob");
const { startInvoiceArchiveJob } = require("./jobs/invoiceArchiveJob");
const { AppError } = require("./utils/AppError"); const { AppError } = require("./utils/AppError");
async function buildApp() { async function buildApp() {
@@ -207,9 +205,7 @@ async function buildApp() {
await app.register(notificationsRoutes, { prefix: "/api" }); await app.register(notificationsRoutes, { prefix: "/api" });
await app.register(exportsRoutes, { prefix: "/api" }); await app.register(exportsRoutes, { prefix: "/api" });
startDelegationRevokeJob(app); await startJobs(app);
startOcrJob(app);
startInvoiceArchiveJob(app);
return app; return app;
} }
+4
查看文件
@@ -62,6 +62,10 @@ function getConfig() {
delegationRevokeCron: process.env.DELEGATION_REVOKE_CRON ?? "0 * * * *", 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", ocrProvider: process.env.OCR_PROVIDER ?? "off",
ocrBatchSize: Number(process.env.OCR_BATCH_SIZE ?? 10), ocrBatchSize: Number(process.env.OCR_BATCH_SIZE ?? 10),
ocrLockTtlSeconds: Number(process.env.OCR_LOCK_TTL_SECONDS ?? 300), ocrLockTtlSeconds: Number(process.env.OCR_LOCK_TTL_SECONDS ?? 300),
+26
查看文件
@@ -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,
};
+121
查看文件
@@ -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,
};