## Context 承接《第1批-8-9月迭代缺陷修复技术方案》(下称“方案”)第 3、7、8 节。 - 提交失败:`approval_submission_consumer.go` 在表单构造失败和企微明确拒绝两处调用 `ApprovalContextRepository.MarkFailed`,其内部 `markSubmissionState` 单事务更新企微上下文与审批实例(7),但不通知业务模块。测试库退款、线下充值、核销、扫码注册均有提交失败实例停在待审批。 - 决策分发:六个业务类型已通过 `DecisionDispatcher` 注册决策消费者,终态经 `TerminalEventWriter`(`internal/infrastructure/approval/terminal_event.go`)写入公共 Outbox;各消费者 `switch` 的 `default` 分支返回错误。 - M13:`internal/service/refund/approval_decision.go:227` 关闭分支用 `event.BusinessID` 直接查退款单,而新流程中 `BusinessID` 是审批尝试 ID;通过分支(:48、:92)已用 `refundapproval.ResolveRefundInTx`。 - 恢复:核销、退款、扫码注册三处恢复入口在“实例为 7 且上下文为提交失败”时走 `RecoverSubmissionEvent` 重放。 - 状态约束:五张业务表有 `CHECK (status IN ...)`,佣金提现表无约束。 - 核销列表投影 `ProjectApplication`(`internal/query/employeecollection/application.go:207`)未给审批提交状态字段赋值。 ## Goals / Non-Goals **Goals:** - 提交失败的业务单进入明确终态并释放占用,可在卡管重新发起。 - 退款关闭类终态正确结束退款单。 - 核销列表与详情显示真实提交状态。 **Non-Goals:** - 不改通过、驳回、撤销、通过后撤销的既有处理(M13 只修定位)。 - 不处理提交结果未知(8),仍由恢复任务确认。 - 不支持企微侧重新提交挂接回原单。 - 不新增佣金提现表的状态约束。 ## Decisions 1. **复用既有决策通道,新增决策值而不是新事件类型。** `pkg/constants/approval.go` 增加 `ApprovalDecisionSubmissionFailed = "submission_failed"`;`markSubmissionState` 在状态 7 分支、实例更新成功后,同一事务写入决策投递记录与 `TerminalEventWriter.Append` 事件(见决策 9),事件号为 `approval:{实例ID}:submission_failed`。不新建订阅链路,幂等、重试、死信全部沿用现有机制。备选是业务模块轮询“实例 7 且业务单待审批”,延迟高且要六处各写一套扫描,放弃。 2. **六个消费者同一次交付接入。** `default` 分支返回错误,未接入的业务类型会进入分发失败重试,因此 M8、M14 同批,消费者显式处理新值,不能落入驳回分支。处理前锁审批实例并确认仍为 7。 3. **提交失败为终态,去掉重放。** 决策异步消费,若业务单变终态前有人点“恢复审批”,实例被重放回提交中,会出现“业务单已终态、企微又生成审批”的分裂。三处恢复入口的 replay 分支一律拒绝;“结果未知”“已有单号同步”分支不变。 4. **状态值。** 核销 4、退款 7、扫码注册 3、资料资格 4、线下充值 7、佣金提现 5。 5. **退款状态 7 的门禁与已拒绝(3)一致。** 加入 `RefundResubmittableStatuses()`,不加入 `RefundActiveStatuses()`:既可对原单重新发起,也可为同一订单新建退款单,两张同时生效由 `uk_refund_request_active_order` 兜底。状态 7 与 3、4 同属“钱未动的终态”,且不可恢复,无重复退款风险;状态 6 禁止另建新单是因为渠道退款可能已发起,与 7 不同。2026-10-11 已确认按 3、4 处理。换货校验 `HasUnfinishedByAsset` 不改,新状态自然不阻断换货。 6. **M13 照通过分支写法。** `ResolveRefundInTx` 本身不加锁,解析后再按 `target.ID` 对退款单 `SELECT ... FOR UPDATE`;其余逻辑不变。重新发起沿用 `CreationService.Resubmit`,已允许状态 3。 7. **核销投影一次批量读取。** 按 `latest_approval_instance_id` 批量读 `tb_wecom_approval_context`,状态名称映射与脱敏复用退款已有实现(`internal/service/refund/service.go:1467-1520`)。 8. **迁移只放宽约束。** 一组新的成对迁移:五张表逐表 `DROP CONSTRAINT` 后按新值集合重建、更新列注释;佣金提现只更新列注释。down 恢复原约束(执行前须确认没有新状态数据)。资料资格的 `chk_withdrawal_qualification_invalidated` 与 `WHERE status = 1` 唯一索引不受影响。同一迁移放宽 `tb_approval_decision_delivery.ck_approval_decision_delivery_decision`,允许 `submission_failed`,否则决策 9 写投递记录会违反约束(2026-10-11 实施时发现)。 9. **写决策时同时建投递记录。** 现有终态决策(`internal/application/approval/sync_decision.go`)在同一事务里调用 `DecisionDeliveryWriter.Create` 与 `TerminalEventWriter.Append`;分发器处理前 `Claim` 投递记录(`internal/infrastructure/approval/decision_delivery.go`,要求更新到 1 行),没有记录时返回未认领,分发器直接结束,业务单永远不变且不报错。因此 `markSubmissionState` 必须两者都写,事件号沿用 `terminalDecisionEventID` 的格式 `approval:{实例ID}:submission_failed`。`ApprovalContextRepository`(`internal/infrastructure/wecom`)需注入这两个写入器,同步修改启动装配。 10. **只在消费入口放行新决策值。** `TerminalDecisionConsumer.Consume`(`internal/infrastructure/approval/terminal_event.go:69`)用 `approvaldomain.StatusForDecision` 校验决策值,目前不认 `submission_failed`。该函数也被渠道状态推进 `ApplyDecision` 使用,直接加值会让企微回调可以把实例推到提交失败;因此只在 `Consume` 中对 `submission_failed` 单独放行,`StatusForDecision` 不改。 11. **失败原因由业务模块读取。** `TerminalDecisionEvent` 没有原因字段,不扩展事件结构;各业务消费者处理 `submission_failed` 时按实例读取 `tb_wecom_approval_context.last_error`,脱敏后写入各自的终态原因字段与审计。 ## Risks / Trade-offs - [某个消费者漏接新决策] → 分发失败重试堆积;任务中逐个消费者核对,测试环境对六个业务类型各构造一次提交失败。 - [漏写投递记录或消费入口未放行] → 事件静默结束或被拒;验证时逐个确认投递记录状态为成功、业务单状态已变化。 - [测试库已有“实例 7、业务单待审批”存量] → 代码不自动修正;在测试环境只读统计,需要时写一次性补数据脚本,经确认后执行。 - [佣金提现冻结释放出错影响余额] → 释放与状态更新同事务、条件更新检查 `RowsAffected`,验证时核对钱包余额与流水。 - [退款状态 7 可同时走重发与新建两条路] → 与已拒绝一致,唯一索引保证至多一张活动申请。 ## Migration Plan 测试环境:先执行新的成对迁移(只放宽约束,旧代码照常运行),再部署 API 与 Worker。六个消费者与核心决策同一次部署。 ## Open Questions 无。