Files
junhong_cmp_fiber/openspec/changes/add-direct-exchange-flow/design.md
Break 27fba9160c
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 56s
提案
2026-06-04 09:39:20 +08:00

304 lines
15 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
当前换货实现已经具备后台建单、客户端填写地址、后台发货、后台完成、旧资产转新的主链路,但实现上仍然是单一路径,且关键业务动作没有被统一到稳定的事务边界内。
当前实现存在四个直接问题:
1. `ExchangeOrder` 模型没有 `flow_type`,无法表达“走物流”与“直转新资产”两种流程,只能依赖状态字段隐式区分。
2. `Complete()` 不是单事务。当前 `migrate_data=true` 时,`executeMigration()` 会先独立提交一次事务,随后 `Complete()` 再单独把换货单状态改为已完成,存在“迁移已生效但单据仍停在待完成”的不一致窗口。
3. 旧资产 `asset_status -> 3` 只在迁移逻辑里执行。也就是说,`migrate_data=false` 时单据虽然可以完成,但旧资产不会被系统视为“已换货”,后续 `Renew()` 会直接失败。
4. 个人客户绑定切换当前只在迁移逻辑中执行。这会导致“换货成功但客户仍绑定旧资产”的脏状态,尤其在新增 `direct + migrate_data=false` 场景下会被明显放大。
本次变更横跨模型、DTO、后台/客户端接口、换货服务、迁移事务、资产追溯和生命周期语义,是一个典型的跨模块行为修正型变更。
## Goals / Non-Goals
**Goals:**
- 在现有换货系统内新增 `direct` 流程,同时保留 `shipping` 原流程不变。
- 将换货流程类型与状态机拆开:`flow_type` 表达业务路径,`status` 继续表达处理阶段。
- 定义一个统一、可回滚的“完成换货”单事务,覆盖必做切换与可选迁移。
- 保证 `migrate_data=false` 仍然能形成业务闭环:旧资产被换出、新资产成为当前资产、客户绑定切到新资产、单据已完成。
- 明确旧资产/新资产/世代/追溯链的状态变化,使后续实现无需再靠推测还原业务语义。
- 保证 `iot_card``device` 两类资产都支持 `direct` 流程。
**Non-Goals:**
- 不新增第三种换货流程类型。
- 不改变现有 `renew` 的业务定位,仍然由运营后台显式触发,而不是在换货完成时自动执行。
- 不新增自动消息推送或物流轨迹查询能力。
- 不在本次变更中回填历史换货单数据,也不把 legacy 记录回灌为新语义数据。
## Decisions
### 决策 1新增 `flow_type`,保留现有状态值不变
换货流程存在两条业务路径:
- `shipping`:建单后等待客户填写收货地址,再由后台发货、后台确认完成。
- `direct`:建单时已经确定新资产,无需客户填写地址,也无需物流,建单即可完成。
如果继续只用 `status` 表达两种路径,会导致以下问题:
- 客户端待处理查询无法区分“真的待客户处理”和“已跳过客户节点”。
- 后台列表中 `status=4` 的已完成单据看不出是走物流还是直转。
- 取消、发货、填写地址等接口无法自然约束适用范围。
因此本次设计明确:
- `flow_type` 使用字符串枚举:`shipping` / `direct`
- 为保持旧后台调用兼容,创建接口未传 `flow_type` 时按 `shipping` 处理
- 创建接口传入非空且不属于 `shipping/direct``flow_type` 时必须返回参数错误
- `status` 继续保留现有 int 常量:`1/2/3/4/5`
- `shipping` 流程状态机保持不变
- `direct` 流程允许“创建后立即处于 `status=4`
选择原因:
- 对原流程兼容性最好,不需要重命名现有状态常量。
- 后台与客户端都能用 `flow_type + status` 明确判断当前单据语义。
- 查询、追溯和审计都更稳定。
备选方案与拒绝理由:
- 方案 A新增 `status=6 直转已完成`
- 拒绝原因:状态值被混入路径语义,客户端和后台会被迫理解“特殊完成态”,长期维护成本更高。
- 方案 B新增第二张 `direct_exchange_order`
- 拒绝原因:核心事实被拆散,追溯链和列表逻辑会重复实现。
### 决策 2完成换货拆为“必做切换 + 可选迁移”,并统一纳入单事务
完成换货后,系统至少要落定以下事实:
- 本次换货单已经结束。
- 旧资产不能再继续作为当前客户资产使用。
- 新资产已经成为客户当前资产。
- 若客户原先已绑定旧资产,绑定关系必须切到新资产。
这些动作并不是“可选迁移”,而是换货成功本身的最小闭环。因此本次设计把完成换货分为两层:
1. 必做切换:
- 校验新资产存在、类型一致、当前在库
- 写入 `new_asset_type/new_asset_id/new_asset_identifier`
- 旧资产 `asset_status -> 3`
- 新资产 `asset_status -> 2`
- 若旧资产存在 `PersonalCustomerDevice` 绑定,则绑定切换到新资产资产绑定键
-`completed_at`
- 换货单 `status -> 4`
2. 可选迁移,仅 `migrate_data=true` 时执行:
- 钱包余额迁移
- 迁移流水记录
- 套餐使用记录迁移
- 套餐日明细连续性保留
- 标签复制
- 累计充值/首充触发状态复制
上述两层必须在同一个数据库事务中完成。事务任一步失败,整个完成动作回滚,换货单保持未完成。
选择原因:
- 避免 `migrate_data=false` 出现“单据完成但旧资产仍可被系统当成正常资产”的错误状态。
- 避免迁移提交成功但换货单状态未完成的分裂状态。
-`direct``shipping` 共用同一个“完成换货”内核,只是入口不同。
备选方案与拒绝理由:
- 方案 A继续保留 `executeMigration()` 自己开事务,`Complete()` 外层只做单据状态更新
- 拒绝原因:状态不一致风险已经存在,不能带到新流程。
- 方案 B`migrate_data=false` 时只更新单据状态,不更新资产和绑定
- 拒绝原因:这不是换货成功,只是“记录一张单子”。
### 决策 3`direct` 在创建接口里完成“建单 + 完成换货”
`direct` 不是后台先建一个待处理单再调用 `complete`,而是后台在创建时就已经给出:
- 旧资产
- 新资产
- 是否迁移;`migrate_data` 未传时按 `false` 处理,传入时必须是布尔值
- 换货原因
因此 `POST /api/admin/exchanges``flow_type=direct` 时应直接执行:
1. 校验旧资产存在、无进行中的换货单
2. 校验旧资产处于 `asset_status=2` 已销售
3. 校验新资产存在、类型一致、在库、归属店铺一致、未被有效客户绑定占用
4. 创建换货单并写入 `old_*` / `new_*` / `flow_type` / `migrate_data`
5. 在同一事务内执行“完成换货必做切换”
6.`migrate_data=true`,继续执行可选迁移
7. 直接返回 `status=4`
事务语义:
- `direct` 创建、完成切换、可选迁移必须共用同一个数据库事务
- 任一步失败时整个事务回滚,不保留 `status=1/2/3` 或其他半成品 `direct` 单据
- 成功返回时,数据库中已存在且只能存在 `status=4``direct` 单据
这样做的好处:
- 不产生半成品 `direct` 单据
- 不需要为 `direct` 再暴露一个“后台补完成”的额外接口
- 事务边界清晰:创建成功即代表换货成功
### 决策 4`shipping` 的发货与完成职责分离,但完成时仍重用同一事务内核
`shipping` 流程保留现有操作感知:
- 创建单据:`status=1`
- 客户填写地址:`1 -> 2`
- 后台发货:`2 -> 3`
- 后台确认完成:`3 -> 4`
其中:
- `ship` 只负责落新资产快照、物流信息、`migrate_data``shipped_at`,并把状态推进到 `3`
- `complete` 负责执行“完成换货单事务”
`shipping` 发货后的新资产占用规则:
- `ship` 选择新资产时必须使用条件更新或行锁确认新资产仍为 `asset_status=1`
- `ship` 成功后新资产已被该换货单占用,其他换货单或销售绑定不得再使用该新资产
- 若实现不新增独立“占用中”状态,则必须通过换货单 `new_asset_type/new_asset_id + status IN (3)` 的唯一性/查询校验阻止重复占用
- `complete` 时仍需再次校验新资产未被外部写入破坏;若不满足条件,完成失败并保持单据未完成
这样处理可以保留现有后台操作习惯,同时让 `shipping``direct` 的最终完成动作完全一致。
### 决策 5客户绑定切换属于必做动作且需要可承接性前置校验
如果旧资产存在 `PersonalCustomerDevice` 绑定,则完成换货后客户必须继续指向新资产,否则业务上等于“客户仍在使用旧资产”。
因此本次设计将绑定切换定义为必做动作,并增加一个硬前置规则:
- 若旧资产存在客户绑定,则新资产必须具备可写入绑定的资产绑定键
- IoT 卡使用 `virtual_no` 作为绑定键;设备优先使用 `virtual_no`,为空时可按现有登录绑定规则使用 `imei`
- 新资产绑定键不得已存在 `status=1``PersonalCustomerDevice` 绑定记录,避免多个客户当前态混入同一资产
- 若新资产无法承接绑定,换货完成动作必须失败并回滚
这里不要求系统为缺失资产绑定键的新资产自动生成标识,因为那会扩大本次变更边界,也会影响其他资产管理能力。
### 决策 6新资产在换货完成后必须从“在库”切为“已销售”
当前 `ship``complete` 流程都要求新资产必须先是 `asset_status=1` 在库,但完成换货后,系统实际上已经把该资产交付给了当前客户。如果不更新新资产状态,会出现“库存资产已被客户使用”的脏状态。
因此本次设计明确:
- 完成换货成功后,新资产 `asset_status -> 2`
- 此规则同时适用于 `shipping``direct`
- 旧资产必须在完成前为 `asset_status=2` 已销售;已换货、在库或已停用资产不得发起/完成换货
- 新资产必须在选择和完成时都满足 `asset_status=1` 在库
- 旧资产 `generation` 不变;新资产 `generation` 也不变
这与 `renew` 不冲突。`renew` 处理的是旧资产重新回炉,不是新资产接棒。
### 决策 7补充 `shipped_at` 和 `completed_at`,不再复用 `updated_at` 表达关键业务时间
当前资产历史订单追溯中的 `exchanged_at` 实际取的是换货单 `updated_at`,这会混淆以下行为:
- 后台补备注
- 重新发货修正
- 迁移状态更新
本次设计要求:
- `shipped_at`:仅在 `shipping` 发货成功时写入
- `completed_at`:仅在换货成功完成时写入,`shipping``direct` 共用
- 资产追溯里的 `exchanged_at` 改取 `completed_at`
- 历史已完成换货单没有 `completed_at` 时,追溯接口必须兼容回退到 `updated_at`
这样可以避免追溯链把“最后更新时间”错当成“换货完成时间”。
### 决策 8`generation` 只在 `renew` 阶段递增
换货完成时的本质是“当前客户从旧资产切到新资产”,而不是“旧资产重新变成新货”。
因此:
- 换货完成阶段:
- 旧资产 `generation` 不变
- 新资产 `generation` 不变
- `renew` 阶段:
- 旧资产 `generation + 1`
- 旧资产 `asset_status -> 1`
- 清空累计充值/首充状态
- 清理个人客户绑定
- 删除旧钱包并重建新空钱包
这样可以保证:
- 换货链由 `ExchangeOrder.old_* / new_*` 表达
- 世代链由 `generation` 表达
- 两条语义链不互相污染
### 决策 9钱包冻结余额阻止可选迁移
`migrate_data=true` 时,旧资产钱包余额迁移会改写旧钱包余额并给新钱包入账。如果旧钱包存在冻结余额,说明仍有未完成扣款、退款、支付或其他资金占用。
因此:
- 旧资产钱包 `frozen_balance > 0` 时必须拒绝迁移并回滚整个完成事务
- 不迁移可用余额、不迁移冻结余额,也不做部分迁移
- 后台需要先处理冻结业务,再重新执行换货完成或 direct 创建
这样避免把仍在占用中的资金静默搬到新资产,导致后续支付/退款按旧钱包执行失败。
### 决策 10多租户归属必须保持一致
换货不是跨店铺调拨能力。本次不扩大资产归属变更范围。
因此:
- 旧资产和新资产必须都在当前操作者权限范围内
- 新旧资产 `shop_id` 必须一致,含二者同为平台库存 `NULL`
- 换货单 `shop_id` 取旧资产 `shop_id`
- 如业务未来需要跨店铺换入,必须另起提案处理调拨、审计和权限边界
## Risks / Trade-offs
- [风险] `direct` 创建接口职责变重,参数和校验分支增加
- 缓解:在 DTO 和 Service 层明确按 `flow_type` 分支校验,避免 handler 拼接复杂条件。
- [风险] 完成换货被收敛为大事务后,单次事务执行时间会增长
- 缓解:换货属于低频后台操作,可接受;同时将“必做切换”和“可选迁移”分层,避免未来继续在事务里堆无关动作。
- [风险] 新资产可承接绑定的约束可能暴露出历史库存数据不完整问题
- 缓解:把“无法承接绑定则完成失败”写成明确规则,避免系统默默产出脏数据;实际实现时通过错误码和后台提示引导运营修正库存数据。
- [风险] 主线 spec 与 archived spec 对 `personal-customer` 的换货 requirement 已有历史沉淀,修改时容易遗漏一致性
- 缓解:本次 change 直接修改主线 capability不再依赖 archived 文档语义。
- [风险] 新增 `completed_at` 后,旧数据没有该字段值
- 缓解:本次不做历史回填;追溯逻辑对历史已完成单据统一回退到 `updated_at`,新完成单据必须写入并优先使用 `completed_at`
## Migration Plan
1. 数据库变更
-`tb_exchange_order` 增加 `flow_type``shipped_at``completed_at`
-`flow_type` 设置默认值 `shipping`
- 视查询需要补充 `flow_type + status``new_asset_type + new_asset_id` 等索引
2. 模型与 DTO 变更
- 更新 `ExchangeOrder` 模型
- 更新创建、发货、详情、列表、客户端待处理等 DTO
3. 服务层重构
- 提炼统一的“完成换货事务”内部方法
-`shipping complete``direct create` 共用同一完成内核
- 将现有 `executeMigration()` 重构为可注入外部事务的内部步骤,而不是自开事务
4. 接口兼容与文档更新
- 后台创建接口支持 `flow_type`
- 保持原 `shipping` 路径向后兼容
- 更新 OpenAPI 文档生成器注册与中文描述
5. 手工验证
- 执行 `go build ./...`
- 使用 PostgreSQL MCP 核对字段、状态、时间字段和索引
- 手工验证 `shipping/direct``migrate_data=true/false``renew``include_previous=true` 等关键场景
6. 回滚策略
- 若实现尚未上线,可回滚代码并回滚新增字段
- 若已产生 `direct` 单据数据,则不能只回滚代码而不处理数据,必须连带考虑接口兼容和字段保留
## Open Questions
- 无。当前边界按兼容优先、失败回滚、不引入新流程状态处理。