## Context 现有员工核销提交在同一事务内创建不可变审批尝试、通用审批实例、企业微信上下文和稳定的审批提交 Outbox,申请保存最新尝试与最新实例用于展示。主 Spec 已要求提交失败、回调延迟或结果未知时保持在途并使用查询/恢复机制确认,但当前没有面向员工或管理员的恢复入口,也没有可供页面稳定判断的恢复投影。 通用审批提交事件键为 `approval:{instance_id}:submission`。恢复若绕过该事实直接创建新审批,会在“企微已受理、本地结果未知”时产生重复审批单;若释放账单预占再重建申请,则会改变业务材料与并发余额事实。因此恢复必须落在既有审批实例和可靠投递边界内。 ## Goals / Non-Goals **Goals:** - 为员工核销建立统一、幂等的原审批恢复用例,覆盖明确失败、结果未知和已有企微单号待同步三类状态。 - 让原申请员工与有权管理员共享同一恢复规则,同时保持原申请人为企微发起人。 - 把可恢复性和安全失败摘要作为读侧投影返回,避免前端复制审批状态机。 - 保持审批尝试、申请材料、账单预占和审计事实可追溯。 **Non-Goals:** - 不创建新的审批尝试或审批实例,不提供“强制重新提交”或本地人工终审。 - 不允许通过恢复入口修改核销材料、附件、分摊或收款方式,也不释放账单预占。 - 不改变企业微信提交、查询、回调和终态消费的既有渠道契约。 - 不为退款、代理充值或提现同时新增同类入口;通用恢复接缝可复用,但本 Change 只开放员工核销。 ## Decisions ### 统一恢复用例只接受核销申请标识 新增 `POST /api/admin/employee-collection-applications/{id}/recover-approval`。Handler 只绑定申请 ID,Application 用例在事务内锁定申请和最新审批尝试,并校验申请仍为审批中、最新实例引用一致及调用方权限。请求不接受审批实例 ID、提交人或材料,避免调用方替换恢复目标。 选择单一业务动作而不是分别暴露“重新提交”和“查询状态”:调用方无法可靠知道企业微信是否已受理,分裂入口会把结果未知判定泄漏给前端并放大重复提交风险。 ### 恢复决策以原审批实例的渠道事实为准 用例读取通用审批实例、企业微信上下文及稳定提交事件,按固定优先级决策: 1. 已有企业微信审批单号:只请求查询同步原审批单。 2. 无审批单号且提交结果未知:恢复或触发既有结果确认流程,禁止直接创建提交。 3. 无审批单号且明确失败,并能确认未受理:把同一稳定事件恢复为可投递状态。 4. 事件正在等待、重试或处理:不写重复事件,返回正在恢复。 5. 申请、实例或企微审批已终态:返回状态冲突。 恢复实际外部动作继续由 Worker 执行,HTTP 请求只原子恢复可靠事实或触发查询事实,不同步调用企微创建接口。这样 HTTP 超时不会造成调用方误认为失败后再次提交。 备选是在接口内同步查询或提交;放弃,因为请求超时和进程中断会重新引入结果未知窗口。 ### 稳定事件原位恢复而非新增补偿事件 继续使用 `approval:{instance_id}:submission` 作为唯一提交业务键。对终态失败或耗尽重试的事件执行带当前状态谓词的条件更新,恢复其投递资格和必要的租约字段;对活动事件返回幂等结果。查询同步复用原审批实例的稳定查询/恢复事实,不生成另一个“人工恢复提交”事件键。 并发员工和管理员操作通过审批实例行锁与事件条件更新收敛,最多一个请求改变状态。恢复审计可每次记录触发动作,但业务提交事实保持唯一。 ### 权限与目标实例使用确定规则 原申请员工可恢复本人申请;超级管理员和平台用户可代为恢复;代理、企业账号和其他账号拒绝。越权与不存在保持不可区分。管理员仅作为实际操作者写审计,审批实例中的提交人和企业微信发起身份不变。 恢复目标固定为申请最新尝试与最新实例,并校验业务类型为 `employee_collection_approval`、业务标识等于最新尝试标识、尝试关联实例一致。任一引用不一致都返回状态冲突,不按申请主表或其它候选实例猜测恢复目标。 ### 可恢复性由服务端投影 核销申请列表和详情增加提交状态、可恢复标记、安全失败摘要及最近恢复时间。投影由申请状态、最新实例、企微上下文和提交事件批量计算;列表不得逐条查询放大。失败摘要使用稳定分类和脱敏信息,不返回附件、完整流水、企微原文或内部队列键。 现有企业微信审批上下文已保存 `last_recovery_at`,可直接作为最近渠道恢复时间;人工触发时间、操作者和动作结果由新增核销恢复审计表达,因此不新增业务列或数据库迁移,也不得使用通用 `updated_at` 冒充恢复时间。 ### 恢复失败不改变业务申请与预占 身份、场景、模板映射或渠道暂不可用时,恢复返回明确可处置原因并保留原申请、审批实例和账单预占。配置修复后可再次恢复同一事实。数据库或事件状态更新失败时事务回滚,不形成“已记录恢复但事件未恢复”的分裂状态。 每次人工恢复记录申请、审批实例、操作者、触发前提交状态、选择的恢复动作与结果;审计禁止保存支付凭证、完整外部交易流水号和企微响应原文。 ## Risks / Trade-offs - [平台用户代办可能扩大既有核销写权限] → 恢复入口是独立受控动作,仅允许平台用户恢复原审批事实;不得据此放宽列表、详情、创建、重提或本地终审等其它核销权限。 - [Outbox 终态失败与结果未知目前使用相同状态] → 以企业微信外部单号、提交认领事实和集成结果联合判定;无法证明未受理时一律走查询确认,绝不直接重提。 - [列表投影联查增加查询成本] → 按页批量读取最新审批实例、企微上下文和事件状态,禁止逐条查询。 - [恢复事件后配置仍不可用导致反复失败] → 返回稳定失败分类并保留可再次恢复资格;恢复不增加审批单或账单预占。 - [人工恢复与 Worker 正常重试竞争] → 条件更新和租约状态确保只有一个执行获得处理权,其余返回当前状态。 ## Migration Plan 1. 复用企业微信审批上下文既有 `last_recovery_at` 与审计事件时间表达恢复事实,不执行数据库迁移。 2. 发布支持恢复接缝、员工核销恢复用例、读侧投影和新路由的 API/Worker 二进制。 3. 在维护者指定测试环境验证明确失败、结果未知、已有企微单号、重复与并发恢复,核对企微审批单数量、尝试数量和账单预占均不增加。 4. 回滚时停止开放恢复入口并回退二进制;已恢复的原提交事件继续按既有可靠投递语义处理,不删除审批、申请或审计事实。