fix: 第1批 M8、M13、M14 审批提交失败终态与退款关闭类终态定位

- 审批核心在实例进入提交失败的同一事务内写 submission_failed 决策投递与终态事件,消费入口单独放行
- 核销(4)、退款(7)、扫码注册(3)、资料资格(4)、线下充值(7)、佣金提现(5)新增“审批提交失败”终态并释放预占/冻结
- 三处恢复入口不再重放提交失败;退款关闭类终态按审批尝试定位退款单(M13)
- 核销列表与详情批量返回审批提交状态;筛选、字段说明、状态名称、退款导出加入新状态
- 迁移 000239 放宽五张业务表状态约束与决策投递取值约束(测试库已执行 up/down/up)
- OpenSpec Change fix-approval-submission-failure-terminal:18/26,测试部署后端到端验证与存量补数据待完成
This commit is contained in:
邹进
2026-10-12 00:03:46 +09:00
parent 25c081fe60
commit 6298a9061f
42 changed files with 810 additions and 87 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-11

View File

@@ -0,0 +1,53 @@
## 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
无。

View File

@@ -0,0 +1,33 @@
## Why
企微明确拒绝提交审批时,审批核心只把审批实例改为“提交失败”,不通知业务模块,业务单永远停在待审批:核销申请的账单预占、佣金提现的冻结金额一直不释放。另外退款审批在企微被拒绝、撤销、删除时,关闭分支把审批尝试 ID 当成退款单 ID,退款单停在待审批(测试库退款单 511 已复现)。核销申请列表与详情的审批提交状态字段也从未赋值。依据为《第1批-8-9月迭代缺陷修复技术方案》第 3、7、8 节(M8、M13、M14)。
## What Changes
- 审批核心在实例变为“提交失败”的同一事务内,发出新的终态决策 `submission_failed`,带脱敏失败摘要;提交结果未知不发。六个业务决策消费者必须同时接入该决策。
- 核销申请新增状态 4“审批提交失败”(终态),收到决策后释放全部预占;列表与详情一次批量返回审批提交状态(M8)。
- 退款新增状态 7“审批提交失败”(终态);扫码注册代理新增 3、提现资料资格新增 4、线下充值新增 7、佣金提现新增 5,各自为终态并释放占用;佣金提现释放冻结金额(M14)。
- 以上状态均不再提供“恢复审批”;核销、退款、扫码注册代理三处恢复入口对“提交失败”的重放分支改为拒绝。要再办理须在卡管重新发起,生成新的审批尝试与新的企微审批。
- 退款审批在企微被拒绝、撤销、删除后,按审批尝试正确定位退款单并置为“已拒绝”(终态);企微侧重新提交产生的新单号不关联(M13)。
- **BREAKING** 上述业务列表、筛选、导出出现新状态值;新增一组成对迁移,放宽五张表的状态 `CHECK` 约束。
## Capabilities
### New Capabilities
无。
### Modified Capabilities
- `external-integration`:审批提交失败作为终态决策通知业务模块。
- `employee-collection-bill`:核销申请新增“审批提交失败”终态;列表与详情返回审批提交状态。
- `order-refund-exchange`:退款新增“审批提交失败”终态;企微关闭类终态按审批尝试定位退款单;企微侧重新提交不关联;恢复入口不再重放提交失败。
- `agent-distribution-withdrawal`:扫码注册代理、佣金提现、提现资料资格新增“审批提交失败”终态;注册恢复入口不再重放提交失败。
- `agent-funds-commission`:线下代理充值新增“审批提交失败”终态。
## Impact
- 代码:`pkg/constants/approval.go`、`internal/infrastructure/wecom/approval_context_repository.go`、`internal/infrastructure/approval/terminal_event.go`;六个决策消费者(`internal/application/employeecollection/approval_decision.go`、`internal/service/refund/approval_decision.go`、`internal/application/agentrecharge/approval_decision.go`、`internal/application/distributionwithdrawal/{registration,withdrawal,qualification}_approval.go`);三处恢复入口;各业务状态常量、列表筛选、状态名称、导出格式化;`internal/query/employeecollection/application.go`。
- 数据库:新增一组成对迁移(编号排在 238 之后),放宽 `tb_employee_collection_application`、`tb_refund_request`、`tb_agent_distribution_registration`、`tb_withdrawal_qualification`、`tb_agent_recharge_record` 的状态约束,更新列注释(含无约束的 `tb_commission_withdrawal_request`);放宽 `tb_approval_decision_delivery` 的决策取值约束,允许 `submission_failed`。
- 接口:列表筛选与状态说明新增值;重新生成接口文档。
- 运营:这些单据提交企微失败后需在卡管重新发起,不再“恢复审批”。

View File

@@ -0,0 +1,50 @@
## MODIFIED Requirements
### Requirement: 代理注册原审批可受控恢复
系统 SHALL 提供 `POST /api/admin/agent-distribution-registrations/{id}/recover-approval`,允许有权平台账号及申请直接上级代理恢复仍待审批的注册申请。恢复 MUST 校验原审批实例及业务关联、上级店铺启用状态和调用方范围,复用原实例与稳定提交事实;不得新建注册记录、审批实例、企业微信审批单、店铺、账号或钱包。结果未知时 MUST 优先确认原提交,已有审批单号时 MUST 同步原审批;审批实例为提交失败时 MUST 返回“审批提交失败为终态,请重新发起”,MUST NOT 重放原提交事件。终态申请(含审批提交失败)或停用上级 MUST 返回稳定冲突;恢复 MUST 写入脱敏审计。
#### Scenario: 提交失败不可恢复
- **WHEN** 注册申请的原审批实例已提交失败时请求恢复
- **THEN** 系统返回“审批提交失败为终态,请重新发起”,不重放提交事件、不新建注册记录或业务实体
#### Scenario: 结果未知优先确认
- **WHEN** 待审批申请的原提交结果未知
- **THEN** 系统进入原实例确认流程,不直接创建新的企业微信审批
#### Scenario: 终态或停用上级拒绝恢复
- **WHEN** 注册申请已终态或其直接上级店铺已停用时请求恢复
- **THEN** 系统返回稳定冲突且不改变注册、审批或实体事实
## ADDED Requirements
### Requirement: 扫码注册代理审批提交失败
审批核心对注册申请的审批实例发出 `submission_failed` 决策时,系统 SHALL 确认审批实例仍为提交失败,将注册记录从待审批(0)条件更新为审批提交失败(3),记录脱敏失败原因并写审计,按事件幂等;MUST NOT 创建店铺、账号、钱包或层级。审批提交失败为终态,MUST NOT 阻止同一手机号、用户名或店铺编号再次提交;客户重新扫码提交形成新的注册记录与新的审批实例。
#### Scenario: 注册审批提交失败
- **WHEN** 待审批注册申请的审批实例提交企微失败
- **THEN** 注册记录变为审批提交失败,不创建任何业务实体,同一手机号可重新扫码提交
### Requirement: 佣金提现审批提交失败
审批核心对提现申请最新审批尝试发出 `submission_failed` 决策时,系统 SHALL 确认审批实例仍为提交失败,在同一事务内仅一次释放该次尝试的冻结余额并记录释放时间,将提现申请从待审核条件更新为审批提交失败(5),记录脱敏失败原因并写审计,按事件幂等。审批提交失败的申请 MUST 允许代理修改金额、收款信息和本次发票后重新发起,重新发起时按新金额重新冻结、新增审批尝试与审批实例,资料资格保持有效。
#### Scenario: 提现审批提交失败释放冻结
- **WHEN** 待审核提现申请的审批实例提交企微失败
- **THEN** 申请变为审批提交失败,冻结金额仅一次释放,钱包余额与流水一致
#### Scenario: 提交失败后重新发起
- **WHEN** 代理对审批提交失败的提现申请修改后重新发起
- **THEN** 系统按新金额重新冻结并创建新的审批尝试与审批实例,历史尝试不被覆盖
### Requirement: 提现资料资格审批提交失败
审批核心对资料版本审批实例发出 `submission_failed` 决策时,系统 SHALL 确认审批实例仍为提交失败,将资料版本从等待终审(0)条件更新为审批提交失败(4),记录脱敏失败原因并写审计,按事件幂等。审批提交失败为终态,MUST NOT 阻止代理提交新的资料版本,MUST NOT 影响已通过的有效版本。
#### Scenario: 资料资格审批提交失败
- **WHEN** 等待终审的资料版本审批实例提交企微失败
- **THEN** 该版本变为审批提交失败,代理可提交新的资料版本,已通过的有效版本不受影响

View File

@@ -0,0 +1,13 @@
## ADDED Requirements
### Requirement: 线下代理充值审批提交失败
审批核心对线下代理充值申请的审批实例发出 `submission_failed` 决策时,系统 SHALL 确认审批实例仍为提交失败,将充值记录从待审批条件更新为审批提交失败(7),记录脱敏失败原因并写审计,按事件幂等;MUST NOT 入账或改变代理钱包余额。审批提交失败为终态,代理充值列表、筛选、状态名称与导出 MUST 显示该状态;要再充值由申请人重新发起新的充值申请。
#### Scenario: 线下充值审批提交失败
- **WHEN** 待审批线下代理充值申请的审批实例提交企微失败
- **THEN** 充值记录变为审批提交失败,钱包余额不变,列表与筛选可见该状态
#### Scenario: 重复投递
- **WHEN** 同一 `submission_failed` 决策被重复投递
- **THEN** 充值记录状态与审计只生效一次

View File

@@ -0,0 +1,46 @@
## MODIFIED Requirements
### Requirement: 核销申请审批、重提与幂等
核销申请状态 SHALL 为 `审批中`、`已通过`、`已驳回`、`已撤销/已关闭`、`审批提交失败`,且不得以申请状态覆盖账单核销状态。提交或重提时系统 MUST 新增一条不可变审批尝试记录(冻结当次收款方式、外部付款、附件、备注、账单分摊及审批材料快照)并为其创建新的企业微信审批实例;申请只保存最新审批实例 ID 用于展示。企业微信业务类型 MUST 为 `employee_collection_approval`,其业务标识 MUST 取审批尝试记录主键,使同一申请的多次提交各自持有独立审批实例,且 MUST NOT 修改既有审批实例的共享唯一约束或既有 `refund_approval`、`offline_recharge_approval` 场景语义。
企业微信最终通过时,系统 MUST 幂等地将申请标记为已通过、将各分摊写入账单已核销金额并释放其预占;最终驳回时 MUST 标记申请已驳回、释放全部预占且保留审批意见。已驳回或审批提交失败的申请可修改全部申请内容后重提,重提生成新的审批尝试与新的企业微信审批,历史审批实例、材料和结果不得覆盖;已通过分摊不可修改。
审批核心对最新审批尝试发出 `submission_failed` 决策时,系统 MUST 确认审批实例仍为提交失败,并在同一事务内释放该次尝试的全部预占分摊与账单预占、将申请从审批中条件更新为审批提交失败、以脱敏失败摘要写入终态原因并写审计;非最新尝试或申请已不是审批中时幂等结束。审批提交失败为终态,MUST NOT 再通过“恢复审批”重放原提交,恢复入口对该情形 MUST 返回“审批提交失败为终态,请重新发起”,`recoverable` MUST 返回 false。回调延迟或提交结果未知时申请保持在途,系统 MUST 使用既有查询/恢复机制确认渠道结果,且不得由本地人工通过或拒绝绕过企业微信。企业微信对已通过申请撤销时,系统 MUST NOT 回滚已核销金额,MUST 将申请转为异常终态、保留审计与账单详情提示,并禁止自动重提。同一撤销结果在本地申请仍为审批中时(回调乱序或通过结果未被消费),系统 MUST 释放该次审批尝试的全部审批中预占、将申请转为异常终态并记录终态原因、保留审计,MUST NOT 自动重提。
#### Scenario: 企业微信通过核销申请
- **WHEN** 企业微信对含多笔分摊的核销申请返回最终通过,且该结果首次被消费
- **THEN** 系统仅一次更新申请、各账单已核销金额和状态,并保留审批实例及冻结快照
#### Scenario: 企业微信驳回后重提
- **WHEN** 企业微信最终驳回核销申请
- **THEN** 系统释放预占、保留驳回实例和意见;员工或有代办权限的超级管理员修改申请后重提时新增审批尝试记录并创建新的审批实例,历史实例与材料不被覆盖
#### Scenario: 企业微信通过后撤销
- **WHEN** 已通过的核销申请收到企业微信撤销结果
- **THEN** 系统不回滚已核销金额,将申请标记为异常终态、保留审计并在账单详情提示,且不允许自动重提
#### Scenario: 审批中收到通过后撤销
- **WHEN** 企业微信对仍处于审批中的核销申请返回“通过后撤销”(回调乱序或本地未消费到通过结果)
- **THEN** 系统释放该申请全部审批中预占、将申请转为异常终态并记录终态原因、保留审计,且不回滚任何已核销金额;该申请禁止自动重提,账单可重新发起新的核销申请
#### Scenario: 审批提交失败释放预占
- **WHEN** 核销申请的最新审批实例因缺必填控件映射或企微明确拒绝而提交失败
- **THEN** 申请变为审批提交失败、全部预占释放、账单可再次核销、审计写入,且对该申请“恢复审批”被拒
#### Scenario: 审批提交失败后重提
- **WHEN** 员工修改审批提交失败的申请后重提
- **THEN** 系统新增审批尝试记录并创建新的企业微信审批,申请回到审批中
#### Scenario: 提交结果未知保持在途
- **WHEN** 核销申请的审批实例提交结果未知
- **THEN** 申请保持审批中,预占不释放
## ADDED Requirements
### Requirement: 核销申请返回审批提交状态
核销申请列表与详情 SHALL 返回审批提交状态、状态名称、脱敏失败摘要、可恢复标记、最近一次恢复结果与时间,按申请的最新审批实例一次批量读取企微审批上下文,MUST NOT 逐行查询;状态名称映射与失败摘要脱敏 MUST 与退款审批投影一致。无审批上下文的申请 MUST 返回默认值,MUST NOT 报错或丢行。
#### Scenario: 列表返回提交状态
- **WHEN** 查询一页核销申请,其中部分有审批上下文、部分没有
- **THEN** 有上下文的申请返回与测试库企微审批上下文一致的提交状态,没有的返回默认值,整页无逐行查询

View File

@@ -0,0 +1,29 @@
## ADDED Requirements
### Requirement: 审批提交失败通知业务模块
审批实例因企微明确拒绝提交或审批表单无法构造(如缺必填控件映射)而变为“提交失败”(7)时,系统 SHALL 在同一事务内经既有终态决策通道发出决策 `submission_failed`:MUST 同时写入标准决策 Outbox 事件与决策投递记录(与其他终态决策相同,缺投递记录时业务消费者不会处理);事件标识 MUST 沿用既有格式 `approval:{审批实例标识}:submission_failed`,重复写入不产生第二个事件。决策事件结构不新增字段,失败原因由业务模块从该实例的企微审批上下文读取并脱敏后保存。决策消费入口 MUST 接受 `submission_failed`;渠道回调与轮询的审批状态推进 MUST NOT 因此接受该决策值。提交结果未知(8)MUST NOT 发出该决策,仍由既有恢复机制确认。所有已注册的审批业务类型(核销、退款、扫码注册代理、线下充值、佣金提现、提现资料资格)MUST 显式处理该决策,MUST NOT 将其按驳回处理;各业务处理前 MUST 确认审批实例仍为提交失败,否则幂等结束。
#### Scenario: 企微明确拒绝提交
- **WHEN** 某业务单的审批实例提交企微被明确拒绝
- **THEN** 审批实例变为提交失败,同一事务写入一条 `submission_failed` 决策事件,对应业务模块随后将业务单置为“审批提交失败”
#### Scenario: 审批表单构造失败
- **WHEN** 某审批实例因缺少必填控件映射无法构造表单
- **THEN** 系统按提交失败处理并发出 `submission_failed` 决策
#### Scenario: 提交结果未知不发决策
- **WHEN** 审批实例提交结果未知
- **THEN** 系统不发出 `submission_failed` 决策,业务单保持审批中
#### Scenario: 业务模块确实收到决策
- **WHEN** 审批实例变为提交失败并写入决策事件
- **THEN** 该事件存在对应的决策投递记录,业务消费者能认领并处理,业务单状态发生变化,而不是被当作已处理直接结束
#### Scenario: 渠道回调不能推进到提交失败
- **WHEN** 企微回调或轮询带来的决策值为 `submission_failed`
- **THEN** 审批状态推进按不受支持的决策拒绝,审批实例状态不变
#### Scenario: 决策重复投递
- **WHEN** 同一 `submission_failed` 决策被重复投递
- **THEN** 业务单状态、释放的占用与审计均只生效一次

View File

@@ -0,0 +1,97 @@
## MODIFIED Requirements
### Requirement: 订单、退款与换货状态门禁
系统 SHALL 仅允许待支付订单取消;退款申请按待审批、已通过、已拒绝、已退回、原路退款处理中、原路退款失败、审批提交失败流转,仅已拒绝、已退回、原路退款失败和审批提交失败申请可修改并重新提交;换货按待填写信息、待发货、已发货、已完成或已取消流转,并拒绝与当前状态或流程类型不匹配的操作。处于待审批、原路退款处理中或原路退款失败的退款申请 MUST 阻止同一订单创建新的退款申请,且 MUST NOT 被再次推进为新的审批实例或第二次渠道退款。已拒绝与审批提交失败的退款申请为终态,MUST NOT 阻止同一订单创建新的退款申请,也 MUST NOT 阻止该资产换货。
#### Scenario: 重复推进终态
- **GIVEN** 退款或换货已进入不允许当前操作的状态
- **WHEN** 再次审批、发货、完成、取消或重新提交
- **THEN** 系统返回状态冲突且不重复改变资产、余额或业务状态
#### Scenario: 原路退款失败后重提
- **WHEN** 退款申请处于原路退款失败状态且调用方在其数据范围内修改材料后重新提交
- **THEN** 系统新增一条审批尝试记录与一个新的企业微信审批实例并使申请回到待审批,历史尝试与已提交的渠道退款不被覆盖
#### Scenario: 企业微信通过后撤销的申请被再次推进
- **GIVEN** 退款申请已记录企业微信通过后撤销的审批异常
- **WHEN** 调用者再次提交或重提该申请
- **THEN** 系统返回状态冲突,不恢复套餐权益、不取消已提交的渠道退款且不自动重提
#### Scenario: 审批提交失败的退款不阻断换货
- **GIVEN** 资产的退款申请处于审批提交失败
- **WHEN** 调用方为该资产发起换货
- **THEN** 系统不因该退款申请拒绝换货
### Requirement: 企业微信唯一终审与审批尝试重提
退款申请 SHALL 保存退款原因、冻结实收金额、唯一关联套餐及其使用情况、方式、金额和当次材料快照。超级管理员、平台用户和代理可在各自订单数据范围内创建、修改并重提未成功申请;企业微信是唯一终审,本地 MUST NOT 人工通过、拒绝或退回已关联审批实例的申请。每次创建或重提 MUST 新增一条不可变的审批尝试记录,并以该尝试记录作为通用审批业务标识;同一申请每次提交各自持有独立审批实例,历史尝试材料与审批结果不被覆盖。退款单 SHALL 仅保存最新尝试与最新审批实例引用用于展示。
同一订单同时至多存在一张待审批、原路退款处理中或原路退款失败的退款申请。企业微信驳回、撤销或删除待审批申请后,系统 MUST 将退款申请置为已拒绝(终态),拒绝原因分别为“企业微信审批已拒绝”“企业微信审批已撤销”“企业微信审批已删除”,并写审计;要再退款须在卡管对该申请重新发起,生成新的审批尝试与新的企业微信审批。企业微信侧对已驳回或已撤销审批重新提交产生的新审批单号,系统 MUST NOT 关联到任何退款申请,只按既有“未关联本系统业务,已忽略”留痕。原路退款明确失败后可修改未成功申请的金额、原因、方式、收款信息和附件并重提;每次重提必须新建审批实例及快照。
审批核心对最新审批实例发出 `submission_failed` 决策时,系统 MUST 确认审批实例仍为提交失败,将退款申请从待审批条件更新为审批提交失败(终态),记录脱敏失败原因并写审计,按事件幂等;该申请 MUST 可在卡管重新发起,MUST NOT 再通过恢复入口重放原提交。审批结果未知时申请保持在途,使用既有查询与恢复闭环,不得另建或重提。企业微信通过后撤销时不回滚套餐失效或已启动退款,标记审批异常并禁止自动重提。
审批终态(含通过、驳回、撤销、删除、通过后撤销与提交失败)MUST 以审批实例标识判别业务归属:先按审批尝试记录标识与审批实例匹配,未命中时按退款申请标识与审批实例匹配以兼容尚未切换到尝试模式的存量申请,两者均不匹配时返回稳定冲突错误,不得回落到任一候选业务单。定位后 MUST 对退款申请加行锁再推进状态。
#### Scenario: 审批通过前结果未知
- **WHEN** 企业微信提交成功性或最终结果暂时未知
- **THEN** 申请保持在途且订单不得创建第二张活动申请,系统通过既有恢复机制确认结果
#### Scenario: 未成功申请重提新建实例
- **GIVEN** 退款申请处于已拒绝、已退回、原路退款失败或审批提交失败状态且无审批异常
- **WHEN** 调用者修改材料后重新提交
- **THEN** 系统在同一事务新增审批尝试记录并创建新的企业微信审批实例,原尝试记录与历史审批结果保持不变
#### Scenario: 尝试模式申请被企业微信撤销
- **GIVEN** 退款申请以审批尝试记录作为审批业务标识,且处于待审批
- **WHEN** 企业微信对该审批返回撤销、拒绝或删除
- **THEN** 系统按审批尝试定位到该退款申请,将其置为已拒绝并记录对应原因与审计,该资产随后可换货
#### Scenario: 企业微信侧重新提交不关联
- **WHEN** 企业微信回调一个卡管未创建的审批单号(企微侧对已驳回或已撤销审批的重新提交)
- **THEN** 系统不关联任何退款申请,只留痕“未关联本系统业务,已忽略”
#### Scenario: 审批提交失败
- **WHEN** 待审批退款申请的最新审批实例提交企微失败
- **THEN** 退款申请变为审批提交失败并写审计,列表、筛选、导出显示该状态;重复投递结果不变
#### Scenario: 存量申请审批终态仍被消费
- **GIVEN** 退款申请由本能力上线前创建,其审批实例的业务标识指向退款申请本身
- **WHEN** 该审批实例到达通过或关闭终态
- **THEN** 系统按审批实例标识匹配到该退款申请并幂等推进,不按尝试模式误判为不存在
#### Scenario: 已关联实例的本地人工终审
- **GIVEN** 退款申请已关联审批实例
- **WHEN** 后台账号调用本地通过、拒绝或退回入口
- **THEN** 系统拒绝该操作且不改变退款申请与订单状态
#### Scenario: 存量无实例待审批申请仍可终结
- **GIVEN** 退款申请未关联任何审批实例且处于待审批状态
- **WHEN** 后台账号在既有开关开放时调用本地通过、拒绝或退回入口
- **THEN** 系统按既有语义终结该申请,不要求该申请先接入企业微信审批
### Requirement: 退款原审批实例可受控恢复
系统 SHALL 提供 `POST /api/admin/refunds/{id}/recover-approval`,允许既有退款管理范围内的超级管理员、平台用户和代理恢复仍处于待审批的退款最新审批实例;企业账号拒绝,越权与不存在不可区分。恢复 MUST 复用原审批实例及 `approval:{instance_id}:submission` 稳定提交事实,不得新增审批尝试/实例、修改冻结退款材料或触发退款、订单、套餐与资金动作。已有 `sp_no` 只查询同步,结果未知只确认;审批实例为提交失败时 MUST 返回“审批提交失败为终态,请重新发起”,MUST NOT 重放原提交事件。审批终态、审批提交失败、原路退款处理中或渠道退款失败 MUST 返回状态冲突。审批提交失败的退款申请 `recoverable` MUST 为 false。历史快照缺失新增键时,只能从关联不可变审批尝试补齐缺失值并写脱敏审计。
#### Scenario: 恢复入口只处理原审批事实
- **WHEN** 授权账号请求恢复待审批退款
- **THEN** 系统按最新尝试与实例关联选择恢复分支,列表/详情返回提交状态、可恢复标记、安全失败摘要和最近恢复时间,重复或并发请求至多一次改变提交事实。
#### Scenario: 提交失败不可恢复
- **WHEN** 授权账号对审批实例已提交失败的退款申请请求恢复
- **THEN** 系统返回“审批提交失败为终态,请重新发起”,不重放提交事件、不改变审批实例

View File

@@ -0,0 +1,43 @@
## 1. 迁移与常量
- [x] 1.1 新增成对迁移(编号排在 238 之后):放宽核销 `IN (0..4)`、退款 `IN (1..7)`、扫码注册 `IN (0..3)`、资料资格 `IN (0..4)`、线下充值 `IN (1..7)` 的状态约束并更新列注释;佣金提现只更新列注释;同时放宽 `tb_approval_decision_delivery` 决策取值约束允许 `submission_failed`;down 恢复原约束。
- [x] 1.2 新增状态常量与中文名“审批提交失败”:核销 4、退款 7、扫码注册 3、资料资格 4、线下充值 7、佣金提现 5,同步模型注释与状态名称函数。
- [x] 1.3 `pkg/constants/approval.go` 新增 `ApprovalDecisionSubmissionFailed = "submission_failed"`。
## 2. 审批核心发出提交失败决策
- [x] 2.1 `markSubmissionState` 在状态 7 分支、实例更新成功后,同一事务内依次调用 `DecisionDeliveryWriter.Create` 与 `TerminalEventWriter.Append` 写 `submission_failed`(事件号 `approval:{实例ID}:submission_failed`,事件结构不加字段);状态 8 不发。`ApprovalContextRepository` 注入两个写入器并修改启动装配。
- [x] 2.2 核对 `approval_submission_consumer.go` 表单构造失败与企微明确拒绝两处均经该分支发出决策。
- [x] 2.3 `TerminalDecisionConsumer.Consume` 对 `submission_failed` 单独放行,`StatusForDecision` 与 `ApplyDecision` 不改,渠道回调仍拒绝该值。
- [x] 2.4 六个消费者处理 `submission_failed` 时按实例读取企微审批上下文 `last_error`,脱敏后写入终态原因与审计。
## 3. M13 退款关闭类终态定位
- [x] 3.1 `internal/service/refund/approval_decision.go` 的 `applyClosedDecision` 改为 `ResolveRefundInTx` 定位,再按 `target.ID` 对退款单加行锁;其余逻辑不变。
## 4. 六个业务消费者接入(同一次交付)
- [x] 4.1 核销:照 `applyClosed` 实现 `applySubmitFailed`(确认实例为 7、只处理最新尝试、释放全部预占、0→4 条件更新、终态原因、审计);`ValidateApplicationResubmit` 允许状态 4 重提。
- [x] 4.2 退款:新增提交失败分支,用 `ResolveRefundInTx` 定位,待审批→7 条件更新、记录原因、审计;状态 7 加入 `RefundResubmittableStatuses()`,不加入 `RefundActiveStatuses()`。
- [x] 4.3 扫码注册代理:0→3,不创建实体,审计。
- [x] 4.4 线下充值:待审批→7,不入账,审计。
- [x] 4.5 佣金提现:同事务释放冻结并记录释放时间,待审核→5,审计;`Resubmit` 扩展为允许状态 5,按新金额重新冻结。
- [x] 4.6 提现资料资格:0→4,审计;确认可提交新资料版本、不影响已通过版本。
## 5. 恢复入口与投影
- [x] 5.1 核销、退款、扫码注册三处恢复入口的提交失败 replay 分支改为拒绝“审批提交失败为终态,请重新发起”;`recoverable` 对新状态返回 false。
- [x] 5.2 核销列表与详情按最新审批实例批量读取企微上下文,填充提交状态、名称、脱敏摘要、可恢复、最近恢复结果与时间。
- [x] 5.3 六个业务的列表筛选、接口字段说明、导出状态格式化(含 `internal/exporter/refund_scene.go`)加入新状态;实现时 `grep` 各状态常量逐处核对。
## 6. 文档与测试环境验证
- [x] 6.1 `gofmt`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`。
- [x] 6.2 在 `junhong_cmp_test` 执行新迁移(up、down、再 up)并确认旧数据不受影响。
- [ ] 6.3 按 ENG-TEST-001 用缺必填控件映射对六个业务类型各构造一次提交失败:业务单变为“审批提交失败”、占用释放(核销预占、提现冻结与钱包流水)、审计写入、列表筛选导出可见;恢复被拒;重新发起生成新尝试与新审批;同一事件重复投递结果不变。
- [ ] 6.4 构造提交结果未知的实例,业务单保持审批中。
- [ ] 6.5 核对每个提交失败事件都有决策投递记录且最终为成功;模拟渠道回调带 `submission_failed` 被拒、实例状态不变。
- [ ] 6.6 M13:对尝试模式退款分别用模拟企微撤销、拒绝、删除终态事件触发,退款单变为已拒绝、原因与审计正确、资产可换货;对其重新发起生成新审批;模拟卡管未知的企微单号回调只留痕;“通过”“通过后撤销”与早期 `business_id = 退款单 ID` 的数据回归不变。
- [ ] 6.7 核销列表与详情提交状态与测试库一致,无上下文申请正常返回,无逐行查询。
- [ ] 6.8 只读统计测试库“实例 7 或已终态、业务单仍审批中”的存量,需要时写一次性补数据脚本,经确认后执行。
- [ ] 6.9 运行 `openspec validate --all` 与 `./scripts/context-health.sh`。