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

15 KiB
Raw Blame History

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_carddevice 两类资产都支持 direct 流程。

Non-Goals:

  • 不新增第三种换货流程类型。
  • 不改变现有 renew 的业务定位,仍然由运营后台显式触发,而不是在换货完成时自动执行。
  • 不新增自动消息推送或物流轨迹查询能力。
  • 不在本次变更中回填历史换货单数据,也不把 legacy 记录回灌为新语义数据。

Decisions

决策 1新增 flow_type,保留现有状态值不变

换货流程存在两条业务路径:

  • shipping:建单后等待客户填写收货地址,再由后台发货、后台确认完成。
  • direct:建单时已经确定新资产,无需客户填写地址,也无需物流,建单即可完成。

如果继续只用 status 表达两种路径,会导致以下问题:

  • 客户端待处理查询无法区分“真的待客户处理”和“已跳过客户节点”。
  • 后台列表中 status=4 的已完成单据看不出是走物流还是直转。
  • 取消、发货、填写地址等接口无法自然约束适用范围。

因此本次设计明确:

  • flow_type 使用字符串枚举:shipping / direct
  • 为保持旧后台调用兼容,创建接口未传 flow_type 时按 shipping 处理
  • 创建接口传入非空且不属于 shipping/directflow_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 出现“单据完成但旧资产仍可被系统当成正常资产”的错误状态。
  • 避免迁移提交成功但换货单状态未完成的分裂状态。
  • directshipping 共用同一个“完成换货”内核,只是入口不同。

备选方案与拒绝理由:

  • 方案 A继续保留 executeMigration() 自己开事务,Complete() 外层只做单据状态更新
    • 拒绝原因:状态不一致风险已经存在,不能带到新流程。
  • 方案 Bmigrate_data=false 时只更新单据状态,不更新资产和绑定
    • 拒绝原因:这不是换货成功,只是“记录一张单子”。

决策 3direct 在创建接口里完成“建单 + 完成换货”

direct 不是后台先建一个待处理单再调用 complete,而是后台在创建时就已经给出:

  • 旧资产
  • 新资产
  • 是否迁移;migrate_data 未传时按 false 处理,传入时必须是布尔值
  • 换货原因

因此 POST /api/admin/exchangesflow_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=4direct 单据

这样做的好处:

  • 不产生半成品 direct 单据
  • 不需要为 direct 再暴露一个“后台补完成”的额外接口
  • 事务边界清晰:创建成功即代表换货成功

决策 4shipping 的发货与完成职责分离,但完成时仍重用同一事务内核

shipping 流程保留现有操作感知:

  • 创建单据:status=1
  • 客户填写地址:1 -> 2
  • 后台发货:2 -> 3
  • 后台确认完成:3 -> 4

其中:

  • ship 只负责落新资产快照、物流信息、migrate_datashipped_at,并把状态推进到 3
  • complete 负责执行“完成换货单事务”

shipping 发货后的新资产占用规则:

  • ship 选择新资产时必须使用条件更新或行锁确认新资产仍为 asset_status=1
  • ship 成功后新资产已被该换货单占用,其他换货单或销售绑定不得再使用该新资产
  • 若实现不新增独立“占用中”状态,则必须通过换货单 new_asset_type/new_asset_id + status IN (3) 的唯一性/查询校验阻止重复占用
  • complete 时仍需再次校验新资产未被外部写入破坏;若不满足条件,完成失败并保持单据未完成

这样处理可以保留现有后台操作习惯,同时让 shippingdirect 的最终完成动作完全一致。

决策 5客户绑定切换属于必做动作且需要可承接性前置校验

如果旧资产存在 PersonalCustomerDevice 绑定,则完成换货后客户必须继续指向新资产,否则业务上等于“客户仍在使用旧资产”。

因此本次设计将绑定切换定义为必做动作,并增加一个硬前置规则:

  • 若旧资产存在客户绑定,则新资产必须具备可写入绑定的资产绑定键
  • IoT 卡使用 virtual_no 作为绑定键;设备优先使用 virtual_no,为空时可按现有登录绑定规则使用 imei
  • 新资产绑定键不得已存在 status=1PersonalCustomerDevice 绑定记录,避免多个客户当前态混入同一资产
  • 若新资产无法承接绑定,换货完成动作必须失败并回滚

这里不要求系统为缺失资产绑定键的新资产自动生成标识,因为那会扩大本次变更边界,也会影响其他资产管理能力。

决策 6新资产在换货完成后必须从“在库”切为“已销售”

当前 shipcomplete 流程都要求新资产必须先是 asset_status=1 在库,但完成换货后,系统实际上已经把该资产交付给了当前客户。如果不更新新资产状态,会出现“库存资产已被客户使用”的脏状态。

因此本次设计明确:

  • 完成换货成功后,新资产 asset_status -> 2
  • 此规则同时适用于 shippingdirect
  • 旧资产必须在完成前为 asset_status=2 已销售;已换货、在库或已停用资产不得发起/完成换货
  • 新资产必须在选择和完成时都满足 asset_status=1 在库
  • 旧资产 generation 不变;新资产 generation 也不变

这与 renew 不冲突。renew 处理的是旧资产重新回炉,不是新资产接棒。

决策 7补充 shipped_atcompleted_at,不再复用 updated_at 表达关键业务时间

当前资产历史订单追溯中的 exchanged_at 实际取的是换货单 updated_at,这会混淆以下行为:

  • 后台补备注
  • 重新发货修正
  • 迁移状态更新

本次设计要求:

  • shipped_at:仅在 shipping 发货成功时写入
  • completed_at:仅在换货成功完成时写入,shippingdirect 共用
  • 资产追溯里的 exchanged_at 改取 completed_at
  • 历史已完成换货单没有 completed_at 时,追溯接口必须兼容回退到 updated_at

这样可以避免追溯链把“最后更新时间”错当成“换货完成时间”。

决策 8generation 只在 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_typeshipped_atcompleted_at
    • flow_type 设置默认值 shipping
    • 视查询需要补充 flow_type + statusnew_asset_type + new_asset_id 等索引
  2. 模型与 DTO 变更

    • 更新 ExchangeOrder 模型
    • 更新创建、发货、详情、列表、客户端待处理等 DTO
  3. 服务层重构

    • 提炼统一的“完成换货事务”内部方法
    • shipping completedirect create 共用同一完成内核
    • 将现有 executeMigration() 重构为可注入外部事务的内部步骤,而不是自开事务
  4. 接口兼容与文档更新

    • 后台创建接口支持 flow_type
    • 保持原 shipping 路径向后兼容
    • 更新 OpenAPI 文档生成器注册与中文描述
  5. 手工验证

    • 执行 go build ./...
    • 使用 PostgreSQL MCP 核对字段、状态、时间字段和索引
    • 手工验证 shipping/directmigrate_data=true/falserenewinclude_previous=true 等关键场景
  6. 回滚策略

    • 若实现尚未上线,可回滚代码并回滚新增字段
    • 若已产生 direct 单据数据,则不能只回滚代码而不处理数据,必须连带考虑接口兼容和字段保留

Open Questions

  • 无。当前边界按兼容优先、失败回滚、不引入新流程状态处理。