文件
hyb-oa/.trellis/tasks/archive/2026-10/09-19-budget-pool-concurrency/design.md
T
wangsli和Claude Opus 5.5 b85ff6a9bf 归档 11 个已上线 Trellis 任务并补收口记录(09-17-company-loan-principal-repayment 任务整理)
- 报销一页式审批工作台父任务及 B-2a/B-2b/只读一致性子任务(AC11 依用户 2026-09-27 确认)
- 报销详情材料预览修复及其部署子任务(由候选 59dc5f6 发布覆盖,原步骤未执行,记录保留)
- 资产事前批准纠偏、Trellis 流程对齐、ERP 遗留精简、预算池并发、采购提交超时
- 各任务 prd 顶部新增收口记录,实现均包含于生产 4a5cd8f2;原未勾清单保留不补勾
- B 组与有真实待办的任务保持进行中

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 10:19:21 +08:00

7.3 KiB

预算池并发修复设计(2026-09-19 用户已批准)

已核事实

三个模块共有四处用量写入:报销 submit;purchaseBudget reserve/release;travelPayments delta。它们先普通读取 used_amount 再写绝对值,没有针对 budget_pools 的显式行锁。四处计算/更新与 HEAD 旧实现相同;两个 service 周边错误码已有其他差异,不能宣称整文件相同。

报销虽在事务内重新 precheck,但预算池未锁;采购名为 authoritativePrecheck 的结果在写事务外获取,并在事务内继续使用。因此最终更新原子化不足以证明 block 决策可靠。示例仅为合成推演:额度100、用量0,两张60的申请同时读0,都通过并写60;数据库最终可能少记60。规划时尚未复现;现已获得原实现红例及修后绿例(见 implement.md),没有观察生产是否发生过。

技术方案

  1. 在既有 budgetPool.js 增加仅接受事务客户端的池级写保护入口。优先使用参数化 SELECT ... FROM budget_pools ... ORDER BY id FOR NO KEY UPDATE:该锁与其他用量/额度更新冲突,又与外键 KEY SHARE 兼容;不修改主键,不用全局锁或进程内 mutex。具体返回字段以现有 Decimal 和映射为准,不输出业务值。
  2. 固定顺序:沿入口已有实体/工作流锁,确定所需池 ID;多池去重、按同一稳定顺序一次获取;锁后重读金额、额度、状态和适用范围,再进行现行预算决定、用量/流水及后续业务写入。新 helper 不反向获取实体或工作流锁;锁序盘点发现反向依赖时先调整调用者,不能只改 helper。不要先拿一个池再发现另一个较小 ID 后逆序加锁。
  3. 普通预览保留非锁定读取;提交的最终判定与实际占用使用同一池和锁后状态。自动候选的初次选择只用于定位,等待锁后重新验证适用性;候选失效/变化按现行错误合同停止,不能拿旧结果继续写或静默占另一个未锁的池。
  4. 报销保留 P9 的一次实体读取及同事务金额重算/审批/审计;预算保护必须先于权威预算读取。采购增加事务内最新预算/输入判定,事务外结果只可作预览,不能抵扣提交判定;差旅 submit 同步该约定。显式指定池与自动池的实际占用对象必须与校验对象一致。
  5. reserve/release、authorize/reverse 同样先按协议取池,再处理用量与流水;保持零金额、池不存在、释放归零、金额两位、重复调用的原语义。全部写入仍在调用者原事务内,失败回滚;不提高超时、增设重试或移出审计来掩盖锁等待。
  6. 保留管理端正常额度/状态更新;其 PostgreSQL 行更新与上述池锁自然互斥。开发 seed 为排除项,不运行它修改真实或既有库。没有 schema 变更,不顺手重构预算模型、历史流水或报销撤回的既有计量口径。

预计影响范围

归属 拟改/检查位置 边界
共享保护 server/src/services/budgetPool.js 增加事务级锁入口,普通预览仍无写锁
核心写入 server/src/routes/reimbursements.js;server/src/services/purchaseBudget.js;server/src/services/travelPayments.js 四处计数写入口一并处理
最终预算判定 server/src/services/feeControl.js;server/src/routes/purchaseRequests.js;server/src/routes/travelRequests.js 必需的锁后重验与同池保证,保留 P9 合并读取
调用者锁序 server/src/routes/workflow.js、travelAuthorizations.js、travelTransactions.js 及采购/差旅撤回 先检查,只有遵守统一保护协议所必需时调整;冻结功能不启用
验证 已有 budgetPool/purchaseBudget/travelPayments、报销/采购预检及审批测试;最小合成 PG 并发补件 不改原断言和验收预算,不把 mock 当真实 PG

验证矩阵

  • 红绿:原代码在同池两笔增量/两笔 block 提交下复现失败;保留旧失败,修后同一输入通过。SQL 微实验只能说明底层竞争,最终必须测试真实 service/route 的调用路径。
  • 合成 PG:同池并发占用;报销与采购共享池竞争;占用/释放交错;同实体重复占用/释放;跨多个池反向输入但统一锁序;不同池不无谓互斥。释放有归零时按合法串行顺序判定,不能把所有增减简单相加当唯一预期。
  • 预算决策:额度边界、刚好用尽、block/warn/off、旧/新开关、显式/自动池、等待期间池停用/额度修改;锁后重验应阻止陈旧放行。
  • 原子性:在流水、用量、工作流及原事务审计步骤注入失败,核无部分提交;同实体幂等和不存在/失效池保持既有返回。
  • 隔离:独占合成 PG、无外联、无真实环境配置/密钥/备份、可证明 owner 与精确清理;使用现有 runner/本地依赖,不安装或启动远端资源。无法满足时报告缺项,不借线上证明。
  • 门禁:针对性通过后,将新代码候选交 09-06 从 static 开始普通完整重验,并独立审查新增锁序/事务补丁。完整本地通过、独立审阅、干净候选、部署和12项公网结果分别记账。

保留与回退

每轮从当前 dirty 树取增量快照,不以 HEAD reset/stash 覆盖旧修复;本轮证据单独保存,旧 r6、冻结差旅和恢复材料不改。回退只恢复本任务精确变更;不读取生产用量、不重放流水、不轮换密钥、不提交推送部署。K0 文档澄清由 08-23 任务持有,与预算代码修复分开。

实施落点与锁序核验

  • 新增锁函数仅接受事务客户端;Prisma 7.8.0 支持嵌套事务,不能用存在 $transaction 判断根客户端,使用根客户端 $connect 边界。
  • 公务卡退款原本先改预算再更新授权快照,与授权导入实体→预算的顺序相反;现于 upsert/预算前先锁已有 card_authorizations 记录,其余同步语义保留。真实 PG 并发验证该顺序。
  • legacy block 金额判断改用 Prisma.Decimal,两位金额边界避免二进制加法误拒绝;未改变精度或额度策略。
  • 提交锁后同一预算快照经 budgetPoolForWrite 传给纯预检,null 表示已判无池,undefined 才进行普通读取;避免锁外陈旧结果和重复预算查询。

验证实施收敛

本补丁客户端只有错误原因镜像,未改页面布局/交互;实际接口与事务验证承担新增并发行为的证明。用户对重复完整浏览器的成本提出异议后,中止本轮未完成浏览器阶段,保留全部原始回执;不追加无明确缺口的浏览器检查。发布候选完整门禁留待准备发布时统一执行,不降低或删除原发布验收要求。

独立复核修正:恢复既有选择口径

差旅 submit 原池解析仅传 legalEntityId、costCenterId、effectiveDate;本轮误加 departmentId 会让 departmentLegacy 池优先于 global,超出“保持原选择”的合同。删除新增 departmentId,保持原成本中心→全局匹配;不借并发修复改预算归属,不迁移或回填历史记录。上下文精确断言及部门/全局并存用例防止再次漂移。

原因码固定夹具除验证夹具→服务端/客户端文本外,还须反向要求所有 audience=user 注册码在夹具中有自有条目;期望中文仍为独立固定值。