83 lines
7.1 KiB
Markdown
83 lines
7.1 KiB
Markdown
## 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. 回滚时停止开放恢复入口并回退二进制;已恢复的原提交事件继续按既有可靠投递语义处理,不删除审批、申请或审计事实。
|