Files
junhong_cmp_fiber/openspec/changes/archive/2026-09-14-add-exchange-data-migration-status/design.md
break 93e072e1e2
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 11m42s
feat(换货): AUG26-005 换货业务数据迁移状态与失败恢复
- 新增成对迁移 000222:tb_exchange_order 增加非空 migration_status 与
  migration_failure_reason,按既有 migrate_data/migration_completed 回填历史,
  并加四值 CHECK 约束,不新增索引
- 模型与常量定义四种迁移状态及中文名称,保留既有布尔字段兼容语义
- 物流换货创建恒 not_migrated,发货按请求落 pending/not_migrated,
  完成成功写 migrated/not_migrated 并清空失败原因、同步兼容字段
- 直接换货创建即完成,任一步失败整体回滚,不持久化换货单、不产生 failed
- 迁移失败回滚全部业务修改后,在独立短事务内条件更新 failed 与安全失败原因
  并写失败审计,RowsAffected 为 0 时跳过状态写入但仍写审计
- failed 物流单重试仅限超级管理员或平台用户,授权以锁内 FOR UPDATE 判定为准,
  重试从钱包余额起整表重跑;非 failed 单沿用既有完成门禁
- 列表与详情返回迁移状态与中文名称,仅 failed 返回失败原因;既有三字段保持兼容
- 换货导出在「状态」列后新增中文「迁移状态」列,不导出失败原因
- 同步 order-refund-exchange 主 spec 与验证证据,归档本 Change
- 登记 KNOWN-ISSUE-001:既有标签复制 OnConflict 未声明部分索引谓词(42P10),
  旧资产带标签时迁移最后一步失败,待另立变更修复
2026-09-14 18:32:26 +08:00

89 lines
9.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Context
`proposal.md`。现有换货单以 `migrate_data``migration_completed` 两个布尔字段记录意图和成功结果;`Service.Complete` 在同一数据库事务中完成资产归属、客户绑定、资产状态和可选业务数据迁移。迁移报错会回滚整个事务,现有失败审计不保存可供列表查询的迁移失败状态。
现有迁移函数已在同一事务中处理钱包余额、套餐使用记录、累计字段和资产标签。物流换货的发货后状态允许再次确认完成;直接换货创建即在同一事务完成,创建失败时不持久化换货单。
## Goals / Non-Goals
**Goals:**
- 为已持久化换货单提供稳定、可查询的迁移状态和安全失败原因。
- 保持业务数据迁移和换货完成的原子性,并让物流换货的失败可由超级管理员或平台用户重试。
- 兼容既有响应字段和历史换货数据。
**Non-Goals:**
- 不改变直接换货创建失败即整体回滚、无换货单留存的现有行为。
- 不修改迁移项目、增加迁移明细表、迁移手机号—资产关联,或改变资产归属和个人客户—资产绑定的既有换货动作。
- 不新增列表筛选或路由;换货导出仅新增一列迁移状态,不导出失败原因。
- 不提供迁移失败换货单的放弃或取消出口:`failed` 单只能通过重试成功离开,如需放弃能力须另立需求。
## Decisions
### 1. 使用状态字段取代布尔字段推断
`tb_exchange_order` 新增非空 `migration_status` 和非空 `migration_failure_reason`。状态使用字符串 `not_migrated``pending``migrated``failed`,中文名称仅在应用层投影;失败原因最长 500 字符且为空表示无失败原因。
新字段表达完整结果,保留 `migrate_data``migration_completed``migration_balance` 供兼容客户端及既有业务使用。物流换货创建即写 `not_migrated`(创建阶段不写 `migrate_data`);发货时按请求 `migrate_data` 写入 `pending``not_migrated`;成功完成写入 `migrated`、清空失败原因,并同步既有 `migration_completed``migration_balance`
新增列带默认值迁移按「ADD COLUMN带默认值→ 以既有字段回填 → 添加 `chk_exchange_order_migration_status` 约束校验四值」顺序执行;数据库层白名单与 `pkg/constants` 取值一致。
备选方案是在现有两个布尔字段上叠加前端规则。放弃原因是无法表达失败和失败原因,且容易把待迁移与失败混淆。
### 2. 主事务回滚后以短事务落失败状态与审计
业务数据迁移仍与换货完成共享原有 GORM 事务。任何一步失败均回滚资产状态、归属、客户绑定、钱包、套餐和标签的本次修改,确保重试从完整且未部分迁移的事实开始。
外层识别到迁移失败后,另开短事务,在同一个短事务内写入失败状态与失败审计;该短事务不得与已回滚的主事务共用连接或事务。条件更新为「换货单仍处于可确认完成状态」:`RowsAffected` 为 0 时跳过状态写入但仍写失败审计,避免并发确认下把已成功完成的换货单改回 `failed`
失败原因按固定规则安全化:只拼接 AppError 链上的中文 `Message`,丢弃非 AppError 的底层 `cause`,按 rune 截断至不超过 500 字符;数据库等基础设施错误降级为固定安全摘要;业务类原因(例如旧资产钱包存在冻结余额)保留可见。禁止把数据库、外部服务或敏感载荷原文写入该字段。
备选方案是让迁移失败提交部分换货结果。放弃原因是会产生无法可靠补偿的钱包和套餐事实,且违背本期整套迁移原子执行的产品边界。
### 3. 只为失败的物流换货增加受限重试
保持既有确认完成入口。换货单为物流流程、业务状态仍为已发货待确认且迁移状态为 `failed` 时,仅超级管理员或平台用户可再次确认。授权以锁内判定为准:用例在 `FOR UPDATE` 锁定换货单后读取 `migration_status`;事务外预读只用于快速拒绝,不作为授权依据。授权通过后从钱包余额开始重新执行全部迁移。未失败的换货沿用现有可确认权限和状态门禁,代理既有完成权限不被削弱。
直接换货继续创建即完成;其迁移失败会回滚整笔创建,不留换货单或失败状态,避免为单一失败路径引入新的直接换货中间状态与重试接口。
### 4. 一次成对迁移完成历史映射
新增一对当前根迁移,不修改历史迁移。迁移新增列后,以既有字段回填:`migrate_data=false` 映射为 `not_migrated``migrate_data=true AND migration_completed=true` 映射为 `migrated`;其余 `migrate_data=true` 映射为 `pending`。历史记录的失败原因置空。
不增加索引:本期没有迁移状态筛选或后台批处理查询,现有列表分页读取已直接投影换货单字段。
## 行为与数据契约
### 数据投影与历史映射
- `tb_exchange_order` 新增 `migration_status varchar(20) NOT NULL DEFAULT 'not_migrated'``migration_failure_reason varchar(500) NOT NULL DEFAULT ''`,并以 `chk_exchange_order_migration_status` 约束四值DTO 列表与详情新增 `migration_status``migration_status_name`,并仅在状态为 `failed` 时返回 `migration_failure_reason`
- 上线迁移将 `migrate_data=false` 映射 `not_migrated``migrate_data=true AND migration_completed=true` 映射 `migrated`,其余已存在 `migrate_data=true` 映射 `pending`;不推断历史失败原因。
### 创建、发货与确认完成
- `POST /exchanges`:沿用现有创建入参和权限。物流单创建时恒为 `not_migrated`(创建阶段不写 `migrate_data`,迁移意图在发货时确定);直接换货仍在同一创建事务内完成,任一步失败则整个创建回滚,不持久化换货单、不产生 `failed` 状态。
- `POST /exchanges/:id/ship`:沿用既有物流状态机和发货字段;按请求 `migrate_data` 写入 `pending``not_migrated`,不改变既有物流状态机。
- `POST /exchanges/:id/complete`:先在同一事务锁定换货单并验证既有“已发货待确认”状态和数据范围。`not_migrated` 只执行固有资产归属及个人客户绑定;`pending` 执行钱包余额、有效套餐使用、累计充值、资产标签的完整迁移及固有动作。成功时写 `migrated`、清空失败原因、同步 `migration_completed``migration_balance`,并写完成时间和既有成功审计。
### 失败与受限重试
-`pending` 迁移任一步失败时,主事务必须回滚资产归属、个人客户绑定、钱包、套餐、累计字段、标签和完成状态;外层另开短事务,在同一短事务内写 `failed`、按上述规则安全化的失败原因与失败审计,条件为换货单仍是可确认完成状态;`RowsAffected` 为 0 时跳过状态写入但仍写审计。该短事务不与已回滚的主事务共用连接或事务,符合 `ENG-TX-001` 的例外条件(回滚后的 failed/denied 事实与审计使用独立短事务,且以业务单仍处于允许该失败事实的状态为条件更新)。
-`migration_status=failed` 的物流单,`POST /exchanges/:id/complete` 仅超级管理员或平台用户可重试;授权以 `FOR UPDATE` 锁定后读到的 `migration_status` 为准,事务外预读只做快速拒绝。授权通过后从钱包余额开始重跑全部迁移,禁止仅重试某一子项。非超级管理员、非平台账号返回无权,非失败单沿用既有完成状态门禁、不削弱代理既有完成权限,不将完成接口变成通用重复执行入口。
- 手机号—资产关联永不在上述动作中读取、复制或删除;新资产后续按自身 H5 手机号绑定规则处理。
### 读取行为
- `GET /exchanges``GET /exchanges/:id` 沿用既有换货数据范围,返回新状态字段;旧 `migrate_data``migration_completed``migration_balance` 保持原响应兼容,但调用方不得再以其组合判断迁移结果。
- 换货导出沿用既有导出数据范围,在「状态」列之后新增一列迁移状态并使用中文名称;导出不输出失败原因,导出列不参与任何迁移、完成或数据范围规则。
## Risks / Trade-offs
- [失败原因可能包含底层敏感或不稳定信息] → 只拼接 AppError 链上的中文 `Message`、丢弃非 AppError 的 `cause` 并按 rune 截断至 500 字符;数据库等基础设施错误降级为固定安全摘要,业务类原因保留可见,禁止写入数据库、外部服务或敏感载荷原文。
- [失败状态写入与失败审计的短事务二次异常] → 状态与审计同属一个回滚后短事务,`RowsAffected` 为 0 时跳过状态写入仍写审计;短事务异常复用既有换货失败审计的次级故障记录方式,并返回原失败、保留诊断。
- [并发确认造成重复迁移] → 重用换货单 `FOR UPDATE` 锁与预期业务状态更新;只有仍为 `failed` 的失败重试可以进入受限路径。
- [旧客户端只读取布尔字段] → 保持原字段及其成功语义,新字段只增不删。
## Migration Plan
1. 在隔离数据库执行新迁移ADD COLUMN 带默认值 → 按既有字段回填 → 添加 `chk_exchange_order_migration_status` 约束),核对历史映射、非空与四值约束以及 down 后 Schema。
2. 发布同时包含迁移、写侧状态转换、列表/详情 DTO 投影、换货导出新列和审计更新的版本。
3. 发生应用回滚时,先回滚应用至仍兼容新增列的版本;仅在确认没有依赖新状态的数据或功能后执行 down 迁移。