# B2B 批量优惠券发放规格

## 1. 决策与范围

**要做的决定：**是否按本规格实现一个租户最多 300 行的异步发放流程，使用户能确认不可变的导入版本、看到逐行终态，并在取消后保留已提交结果且不再提交余额变更。批准的决策主体、可查看范围和客户范围以现有授权为准；本规格不新增审批角色或连接器。[M1]

**v1 价值：**对已存在且属于当前租户的客户发放优惠券；跨批次以 `(tenant, external_id)` 保证业务效果至多一次；同键同载荷复用已记录结果，同键异载荷明确冲突。金额以整数分处理。队列至少一次投递不是经济效果恰好一次的保证；后者由数据库事务和永久业务唯一键保证。[M1；PS2]

**不包含：**生产实现、真实代发、自动撤销已提交发放、新审批、外部连接器、新队列基础设施、超过 300 行的单批导入，以及以批次 UUID 代替业务键。[M1]

本文中 API 路径和表字段是**建议的实现契约**，不是对现有系统的描述。复用现有数据库事务、唯一约束和队列；派发重试使用现有调度/派发进程，必要时扩展该进程读取发件箱，不另建基础设施。[M1]

## 2. 状态和持久化契约

### 批次状态

| 状态 | 含义及允许转移 |
|---|---|
| `draft` | 尚未确认，可编辑。`draft → queued`：确认事务成功；`draft → cancelled`：如产品允许草稿取消，仅能取消未确认批次。确认与草稿取消通过锁定同一批次行串行化。 |
| `queued` | 冻结版本、全部行和派发意图已持久化，等待队列工作。`queued → running`：工作进程开始处理；`queued → cancelled`：确认后的取消事务先取得批次锁。 |
| `running` | 至少有工作已开始，尚有待处理行。`running → completed`：没有待处理行且最终检查完成；`running → cancelled`：取消事务先取得批次锁并完成取消结算。 |
| `completed` | 所有行均为非取消终态；不可再处理或取消。 |
| `cancelled` | 已取消未完成工作；此前已提交的行结果保留，剩余待处理行已在取消事务中置为 `cancelled`。不可再处理。 |

确认只对 `draft` 有效。重复确认同一冻结版本返回同一批次及其当前状态；已冻结内容不可修改。对已完成或已取消批次再次取消，返回当前终态而不改变结果。草稿取消是否对用户开放是产品决策；若开放，只能按上表转移，且不产生队列意图。[M1；PS2]

### 行状态

所有行从 `ready` 开始。允许的唯一终态转移如下；不存在持久化的 `processing` 或 claim 状态：

| 转移 | 终态含义 |
|---|---|
| `ready → credited` | 本事务首次记录业务键并完成余额变更。 |
| `ready → already_applied` | 在本批处理前，数据库中已有同租户、同业务键、同客户和金额的成功发放；本行不再次变更余额。 |
| `ready → duplicate_applied` | 本批另一行是相同键、相同客户和金额的规范行；本行引用规范行结果，不重复发放。规范行可能是 `credited` 或 `already_applied`。 |
| `ready → invalid` | 格式、金额或客户验证失败；不保留成功发放业务键。 |
| `ready → conflict` | 同批同键载荷不一致，或永久业务键已存在但客户/金额不同；所有可识别的相关行均标为冲突，不择一发放。 |
| `ready → cancelled` | 取消事务将尚未处理的行结清为取消；不覆盖任何既有终态。 |

`external_id` 相同的同批行按文件行号最小者作为规范行。若同键行的 `customer_id` 或 `cents` 不同，**该批次内该键的所有行**均为 `conflict`。若载荷一致，只发一次；规范行记录真实结果，跟随行记录 `duplicate_applied` 及规范行引用。已证明此前成功的同键同载荷行显示 `already_applied`，而不是待应用或冲突。[M1]

### 建议数据/API 契约

- **导入与授权：**每批最多 300 行；只接受 `external_id`、`customer_id`、`cents`。行号用于稳定排序和解释结果。`cents` 必须是正整数，解析时拒绝小数、溢出和非整数表示，不经浮点转换。只允许已认证用户访问其获授权租户，以及该租户已有客户；不得通过批次 ID、导出或结果查询跨租户读取。[M1]
- **持久化：**批次保存租户、状态、冻结版本标识/内容摘要、请求幂等键和时间；冻结行保存原始值、行号、终态、错误码和必要的规范行引用。发放记录以永久唯一约束 `(tenant, external_id)` 保存成功效果的客户、金额和结果；同一数据库事务内写入发放记录、余额变更及所有受影响行结果。仅成功发放占用该业务键；验证失败不预留键。[M1]
- **确认：**建议 `POST /batches/{id}/confirm` 携带幂等键和当前草稿版本。一个数据库事务锁定批次、校验版本和权限、冻结内容、建立全部行和发件箱派发意图，并将批次置为 `queued`。**仅在该事务提交后**回复确认成功（例如 `202` 和批次 ID）。同一键、同一版本重试返回既有批次及当前进度；相同请求键配不同版本/载荷返回冲突，不覆盖已冻结内容。丢失响应后客户端可用相同键重试或查询批次。[M1；PS2]
- **派发恢复：**确认事务同时写入持久化 outbox 意图。复用的现有派发进程反复读取未发送意图、向现有队列 enqueue，并在 enqueue 后标记已发送。enqueue 成功而标记前崩溃会重复 enqueue；重复消息必须安全。若现有代码没有可复用的重试进程，实现范围须扩展该现有进程来轮询 outbox；不得把“数据库提交后直接 enqueue”当作可靠方案。[M1；PS1]
- **处理与原子性：**工作消息携带批次 ID。工作进程逐组处理至批次所有行终态，而非只处理首行。每个规范键组在一个数据库事务中锁定批次行，检查状态，验证客户，解析该业务键，并在一次事务内完成业务效果及规范行/同键跟随行结果。相同键的数据库唯一冲突后，读取已提交记录并比较完整业务载荷：相同载荷复用结果，不同载荷记冲突；不得静默覆盖。跨批次并发争同一键由永久唯一约束裁决；失败的插入不得重复加余额。余额变更、发放记录和对应行结果同成同败。[M1；PS1；PS2]
- **无待处理行与消息确认：**没有 `ready` 行时，工作进程在批次锁内再次检查：若所有行终态则置 `completed`；若已取消则保持 `cancelled`。只有看到批次已为 `completed` 或 `cancelled` 后才确认队列消息。提交后、ack 前崩溃导致重投时，终态检查使处理成为无副作用空操作，再确认消息。若某事务崩溃未提交，数据库回滚，重投可重新处理；不遗留需人工解锁的 claim。[M1；PS1]
- **进度与导出：**查询/刷新从数据库重建批次和逐行状态，不依赖浏览器内存或队列消息。结果至少返回行号、状态、稳定错误码及可解释文本；跟随行返回规范行引用。仅按现有租户授权返回结果和导出，导出保留整数 `cents` 及行状态，不泄露其他租户数据。保留期限沿用现有业务/合规策略，不在本规格臆定期限。[M1]

## 3. 取消的线性化与并发语义

**建议同步规则：**确认、取消、每个发放事务及批次最终检查都锁定同一批次记录；锁的持有与事务提交决定顺序。取消事务取得锁后，将 `queued` 或 `running` 批次置为 `cancelled`，并在同一事务把所有剩余 `ready` 行置为 `cancelled`。**取消事务提交是取消线性化点**；接口只有在该提交后返回“取消已结清”。数据库锁等待期间，取消请求尚未结清，不能承诺此时不会再有提交。

若某发放事务先取得批次锁并提交，该行结果保留；取消随后取得锁并取消其余 `ready` 行。若取消先提交，后续工作看到 `cancelled`，不得提交任何新余额变更。数据库事务提交失败则取消未生效，调用方可重试。并发工作不假设只有一个；它们按批次锁串行化同批提交，跨批次由业务唯一约束保护。已确认但尚未派发的批次也可按同一规则取消；残留或迟到的队列消息只会观察到终态并安全 ack。该规则限制的是取消事务提交之后的新提交，不承诺固定数量的“取消后”提交。[M1；PS1]

## 4. 验收场景（Given / When / Then）

以下场景是业务级验收；示例业务记录来自材料 M1，不代表其他客户事实。[M1]

### 场景 A：确认冻结、响应丢失后重试

**Given** 租户有未确认草稿版本 V，行内容已通过权限检查。  
**When** 用户用幂等键 K 确认，服务提交冻结行、批次 `queued` 和 outbox 意图，但响应丢失；随后用户以 K 和 V 重试。  
**Then** 重试返回相同批次和当前状态，不创建第二个批次或发放；确认后编辑 V 被拒绝。相同 K 携带不同版本返回冲突。确认成功以首次数据库提交为准，而非 enqueue 或客户端收到响应为准。[M1；PS2]

### 场景 B：指定案例的确定行结果

**Given** 已存在成功记录 `E0/C1/500`；新批次包含 `E0/C1/500`、两行 `E1` 分别为 `C2/300` 和 `C3/300`、`E2/空客户/任意正整数`、`E3/C2/1.5`，以及两行相同载荷 `E4/C2/200`。  
**When** 批次被确认并处理到终态。  
**Then** `E0` 为 `already_applied`，不再加余额；两条 `E1` 均为 `conflict`，不择一；`E2` 为 `invalid`（客户缺失/不合规）；`E3` 为 `invalid`（金额不是正整数）；两条 `E4` 中行号较小者为 `credited`，另一条为 `duplicate_applied` 并引用规范行。批次为 `completed`，所有行都有终态，余额仅增加一次 200 分。[M1]

### 场景 C：跨批次同键并发及异值冲突

**Given** 两个已确认批次由不同 worker 同时处理同租户同 `external_id`；其首次业务键记录尚不存在。  
**When** 两个事务分别尝试写入相同客户/金额。  
**Then** 数据库唯一约束只允许一个事务建立发放并加余额；另一个事务读取已提交记录，写 `already_applied`，不得再次加余额。若第二个载荷客户或金额不同，则其行写 `conflict`。每批行结果与其事务中实际效果一致，不用批次 UUID 代替业务键。[M1]

### 场景 D：取消与在途发放竞争

**Given** `running` 批次中某 worker 与取消请求并发，且另有 `ready` 行。  
**When** worker 先取得批次锁并完成一个原子发放事务，随后取消事务取得锁并提交。  
**Then** 已提交行保持原终态，其他 `ready` 行在取消事务内转为 `cancelled`，批次转为 `cancelled`；从取消提交起，任何 worker 都不能再提交余额变更。若取消事务先提交，worker 不产生余额变化。调用方收到取消成功时，行状态已可从数据库完整刷新。[M1；PS1]

### 场景 E：提交后崩溃、重复消息和无工作收尾

**Given** worker 已原子提交一组规范行及跟随行的结果，但在 ack 前崩溃；队列再次投递同一消息。  
**When** 重投 worker 处理批次，或第一次处理前批次已无 `ready` 行。  
**Then** 重投不重复变更余额；所有行终态时批次被确认/保持 `completed`，无待处理行时不留下悬空批次；仅在批次 `completed` 或 `cancelled` 后 ack。若先前事务实际未提交，则其行结果和余额均回滚，重投可正常完成。[M1；PS1]

### 场景 F：派发间隙恢复

**Given** 确认事务已提交 `queued` 批次和 outbox 意图，派发进程在 enqueue 前崩溃；或在 enqueue 后、标记 outbox 已发送前崩溃。  
**When** 复用的派发进程重试未确认意图。  
**Then** 前一种情况最终产生队列消息；后一种情况允许重复消息，但 worker 按批次/业务键幂等处理。成功确认不会因数据库提交与 enqueue 非原子而永久丢失工作。[M1；PS1]

## 5. 明确待定事项与交付边界

实现前由产品/平台负责人确认两点：

1. 草稿是否允许用户取消；若允许，启用状态表中 `draft → cancelled`，否则草稿只可按现有草稿生命周期处理。
2. 现有进程中由哪个具体调度/派发进程负责 outbox 重试，以及现有结果导出/数据保留策略的适用配置。此确认不得削弱 outbox 可恢复性、租户隔离或本文终态语义。

**验收完成定义：**API、持久化约束、派发恢复、worker 事务、取消串行化、结果刷新和上述场景均按同一状态契约实现；没有待终结的行状态、不可恢复 claim，或仅存在于内存的进度。[M1；PS1；PS2]