From 27fba9160c27fc9158995ae45e4948f13315dcf2 Mon Sep 17 00:00:00 2001 From: Break Date: Thu, 4 Jun 2026 09:39:20 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../add-direct-exchange-flow/.openspec.yaml | 2 + .../add-direct-exchange-flow/design.md | 303 ++++++++++++++++++ .../add-direct-exchange-flow/proposal.md | 56 ++++ .../specs/asset-generation/spec.md | 73 +++++ .../specs/asset-historical-orders/spec.md | 81 +++++ .../specs/asset-lifecycle-status/spec.md | 68 ++++ .../specs/device/spec.md | 52 +++ .../specs/exchange-admin-management/spec.md | 215 +++++++++++++ .../exchange-client-notification/spec.md | 51 +++ .../specs/exchange-data-migration/spec.md | 132 ++++++++ .../specs/exchange-order-model/spec.md | 135 ++++++++ .../specs/iot-card/spec.md | 52 +++ .../specs/personal-customer/spec.md | 56 ++++ .../changes/add-direct-exchange-flow/tasks.md | 65 ++++ 14 files changed, 1341 insertions(+) create mode 100644 openspec/changes/add-direct-exchange-flow/.openspec.yaml create mode 100644 openspec/changes/add-direct-exchange-flow/design.md create mode 100644 openspec/changes/add-direct-exchange-flow/proposal.md create mode 100644 openspec/changes/add-direct-exchange-flow/specs/asset-generation/spec.md create mode 100644 openspec/changes/add-direct-exchange-flow/specs/asset-historical-orders/spec.md create mode 100644 openspec/changes/add-direct-exchange-flow/specs/asset-lifecycle-status/spec.md create mode 100644 openspec/changes/add-direct-exchange-flow/specs/device/spec.md create mode 100644 openspec/changes/add-direct-exchange-flow/specs/exchange-admin-management/spec.md create mode 100644 openspec/changes/add-direct-exchange-flow/specs/exchange-client-notification/spec.md create mode 100644 openspec/changes/add-direct-exchange-flow/specs/exchange-data-migration/spec.md create mode 100644 openspec/changes/add-direct-exchange-flow/specs/exchange-order-model/spec.md create mode 100644 openspec/changes/add-direct-exchange-flow/specs/iot-card/spec.md create mode 100644 openspec/changes/add-direct-exchange-flow/specs/personal-customer/spec.md create mode 100644 openspec/changes/add-direct-exchange-flow/tasks.md diff --git a/openspec/changes/add-direct-exchange-flow/.openspec.yaml b/openspec/changes/add-direct-exchange-flow/.openspec.yaml new file mode 100644 index 0000000..0ba725f --- /dev/null +++ b/openspec/changes/add-direct-exchange-flow/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-03 diff --git a/openspec/changes/add-direct-exchange-flow/design.md b/openspec/changes/add-direct-exchange-flow/design.md new file mode 100644 index 0000000..72cad7d --- /dev/null +++ b/openspec/changes/add-direct-exchange-flow/design.md @@ -0,0 +1,303 @@ +## 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 + +- 无。当前边界按兼容优先、失败回滚、不引入新流程状态处理。 diff --git a/openspec/changes/add-direct-exchange-flow/proposal.md b/openspec/changes/add-direct-exchange-flow/proposal.md new file mode 100644 index 0000000..01fae9f --- /dev/null +++ b/openspec/changes/add-direct-exchange-flow/proposal.md @@ -0,0 +1,56 @@ +## Why + +功能 ID:`feature-direct-exchange-flow` + +现有换货系统只支持“后台建单 → 客户填写收货地址 → 后台发货 → 后台确认完成”的单一路径,无法覆盖“直接换到新卡/新设备、无需发货”的业务场景。更关键的是,当前“换货单完成”“旧资产已换货标记”“客户绑定切换”“迁移事务提交”并不在同一事务边界内,已经存在完成语义和资产真实状态不一致的风险。 + +本次变更需要在保留原 `shipping` 流程的前提下,补充 `direct` 流程,并把换货完成的核心语义收敛为可审计、可追溯、可直接落地实现的单事务闭环。 + +## What Changes + +- 为换货单引入 `flow_type`,区分 `shipping` 与 `direct` 两种流程类型,而不是复用状态字段硬编码不同分支。 +- `flow_type` 对旧客户端保持兼容:后台创建接口未传时按 `shipping` 处理;传入非法值必须拒绝。 +- 扩展换货单模型与接口契约,补充 `shipped_at`、`completed_at` 等真实业务时间字段,并在创建接口中支持 `direct` 流程所需的 `new_identifier`、`migrate_data`。 +- 保留原 `shipping` 状态机:`1 待填写信息 -> 2 待发货 -> 3 已发货待确认 -> 4 已完成`,且 `1/2 -> 5 已取消`。 +- 新增 `direct` 流程:后台创建换货单时即可完成,直接进入 `status=4`,不经过客户端填写地址和后台发货。 +- `direct` 创建必须是原子动作:任一校验、必做切换或可选迁移失败时,整笔事务回滚且不保留半成品换货单。 +- 将“完成换货”拆分为两个层次并统一纳入单事务: + - 必做切换:写入新资产快照、旧资产 `asset_status -> 3`、新资产 `asset_status -> 2`、客户绑定切到新资产、换货单写完成时间并置为 `4`。 + - 可选迁移:仅在 `migrate_data=true` 时执行钱包余额、流水、套餐、标签、累计充值/首充状态等数据迁移。 +- 明确“客户绑定切换到新资产”属于换货完成必做动作,而不是仅在 `migrate_data=true` 时才执行;若旧资产存在客户绑定但新资产无法承接绑定,整个完成动作必须失败并回滚。 +- 明确资产准入边界:旧资产必须是 `asset_status=2` 已销售;新资产必须是 `asset_status=1` 在库、同类型、同归属店铺且未被有效客户绑定占用。 +- 明确钱包迁移边界:旧资产钱包存在冻结余额时不得迁移,必须拒绝完成并回滚,避免未结业务资金被静默搬迁。 +- 修正换货链追溯语义:资产历史订单中的 `exchanged_at` 必须取真实 `completed_at`,且追溯逻辑同时覆盖 `shipping` 与 `direct` 两种已完成单据。 +- 历史已完成单据没有 `completed_at` 时,追溯接口按兼容策略回退到 `updated_at`;新完成单据必须写入 `completed_at`。 +- 明确旧资产“转新”规则:仅在 `renew` 时执行 `generation + 1` 和重新入库;换货完成本身不改变 `generation`。 + +## Capabilities + +### New Capabilities + +- 无 + +### Modified Capabilities + +- `exchange-order-model`:换货单模型新增流程类型与真实业务时间字段,并扩展状态机以同时表达 `shipping` 与 `direct`。 +- `exchange-admin-management`:后台发起、发货、确认完成、取消、转新的接口契约与行为规则需要调整,以支持 `direct` 流程和完成换货单事务。 +- `exchange-client-notification`:客户端待处理查询与填写收货地址逻辑仅适用于 `shipping` 流程。 +- `exchange-data-migration`:将“完成换货必做切换”和“可选迁移”拆分,并统一到单事务边界。 +- `personal-customer`:换货完成时的个人客户资产绑定切换规则需要调整,补充绑定无法承接时的失败语义。 +- `asset-historical-orders`:换货链追溯的完成时间来源与追溯范围需要调整。 +- `asset-lifecycle-status`:补充换货完成与转新对旧资产、新资产生命周期状态的业务流转规范。 +- `asset-generation`:补充 `generation` 只在 `renew` 阶段递增、不在换货完成阶段变化的约束。 +- `iot-card`:明确 IoT 卡在换货换出、换入、转新场景下的生命周期行为。 +- `device`:明确设备在换货换出、换入、转新场景下的生命周期行为。 + +## Impact + +- 受影响模型与 DTO:`internal/model/exchange_order.go`、`internal/model/dto/exchange_dto.go`,以及与 `IotCard`、`Device`、`PersonalCustomerDevice`、`AssetWallet` 相关的换货协作模型。 +- 受影响服务与事务边界:`internal/service/exchange/service.go`、`internal/service/exchange/migration.go`、`internal/service/asset/service.go`。 +- 受影响存储层:`internal/store/postgres/exchange_order_store.go` 及相关资产查询/绑定/钱包读写逻辑。 +- 受影响接口: + - 后台:`POST /api/admin/exchanges`、`GET /api/admin/exchanges`、`GET /api/admin/exchanges/:id`、`POST /api/admin/exchanges/:id/ship`、`POST /api/admin/exchanges/:id/complete`、`POST /api/admin/exchanges/:id/cancel`、`POST /api/admin/exchanges/:id/renew` + - 客户端:`GET /api/c/v1/exchange/pending`、`POST /api/c/v1/exchange/:id/shipping-info` +- 受影响常量与状态语义:`pkg/constants/constants.go`、`pkg/constants/asset_status.go` 及状态文字映射。 +- 受影响数据库结构:`tb_exchange_order` 需要新增 `flow_type`、`shipped_at`、`completed_at` 等字段,并调整相关索引与查询语义。 +- 受影响文档与文档生成器:新增或修改 handler 后,必须同步更新 `cmd/api/docs.go` 与 `cmd/gendocs/main.go`。 diff --git a/openspec/changes/add-direct-exchange-flow/specs/asset-generation/spec.md b/openspec/changes/add-direct-exchange-flow/specs/asset-generation/spec.md new file mode 100644 index 0000000..12f17e4 --- /dev/null +++ b/openspec/changes/add-direct-exchange-flow/specs/asset-generation/spec.md @@ -0,0 +1,73 @@ +## MODIFIED Requirements + +### Requirement: 资产表新增代际字段 + +系统 MUST 在资产主表新增 `generation int NOT NULL DEFAULT 1` 字段,覆盖 `IotCard` 与 `Device`。 + +#### Scenario: 新资产默认代际为 1 +- **WHEN** 创建新的 IoT 卡或设备 +- **THEN** 系统 MUST 将 `generation` 初始化为 `1` + +--- + +### Requirement: 关联业务表新增代际字段 + +系统 MUST 在以下关联业务表新增 `generation int NOT NULL DEFAULT 1` 字段:`Order`、`PackageUsage`、`AssetRechargeRecord`。 + +#### Scenario: 新关联记录默认代际为 1 +- **WHEN** 创建订单、套餐使用记录或资产充值记录 +- **THEN** 系统 MUST 将记录的 `generation` 默认为 `1` + +--- + +### Requirement: 写时快照代际规则 + +系统 MUST 在创建关联记录时执行代际写时快照:从当前资产(IoT 卡/设备)的 `generation` 复制到新建的 `Order`、`PackageUsage`、`AssetRechargeRecord` 记录。 + +#### Scenario: 创建订单时复制资产代际 +- **WHEN** 某资产当前 `generation=3`,并基于该资产创建订单 +- **THEN** 该订单记录的 `generation` MUST 写入为 `3` + +--- + +### Requirement: 查询过滤规则 + +系统 MUST 支持客户端按 `generation` 过滤历史数据;后台管理侧 MUST 不默认按 `generation` 过滤。 + +#### Scenario: 客户端按代际查看历史 +- **WHEN** 客户端请求携带指定 `generation` +- **THEN** 系统 MUST 仅返回该代际的数据(在后续提案中实现) + +#### Scenario: 后台查询不按代际裁剪 +- **WHEN** 管理端查询订单或充值记录且未显式指定 `generation` +- **THEN** 系统 MUST 返回全部代际数据 + +--- + +### Requirement: 钱包流水不引入代际字段 + +系统 MUST NOT 在钱包流水相关表新增 `generation` 字段,因为钱包流水已通过 `wallet_id` 天然隔离。 + +#### Scenario: 钱包流水按钱包隔离 +- **WHEN** 查询某资产钱包流水 +- **THEN** 系统 MUST 仅依赖 `wallet_id` 完成数据隔离,不新增 `generation` 参与过滤 + +--- + +### Requirement: 换货与转新的代际边界 + +系统 SHALL 将换货链与代际链视为两条不同的业务语义链。 + +系统 MUST 满足: +- 换货完成时,旧资产 `generation` MUST NOT 变化 +- 换货完成时,新资产 `generation` MUST NOT 变化 +- 仅在 `renew` 时,旧资产 `generation` 才允许递增 +- 新资产来源追溯 MUST 通过 `ExchangeOrder.old_* / new_*` 表达,而不是通过 `generation` 推导 + +#### Scenario: 换货完成不改变旧资产世代 +- **WHEN** 后台完成一次换货 +- **THEN** 旧资产 `generation` MUST 保持原值不变 + +#### Scenario: 转新时世代递增 +- **WHEN** 后台执行 `renew` +- **THEN** 系统 MUST 将旧资产 `generation + 1` diff --git a/openspec/changes/add-direct-exchange-flow/specs/asset-historical-orders/spec.md b/openspec/changes/add-direct-exchange-flow/specs/asset-historical-orders/spec.md new file mode 100644 index 0000000..a96235a --- /dev/null +++ b/openspec/changes/add-direct-exchange-flow/specs/asset-historical-orders/spec.md @@ -0,0 +1,81 @@ +## MODIFIED Requirements + +### Requirement: 查询资产跨代历史订单(含前代) + +当 `include_previous=true` 时,系统 SHALL 通过换货链追溯前代资产,返回前代订单,并在响应中区分世代来源。 + +**追溯逻辑**: +1. 通过 `ExchangeOrder.new_asset_id` 逆向查找当前资产的换货来源 +2. 得到前代的 `old_asset_identifier`,查询该标识符的订单 +3. 递归追溯,最多向前 10 代(安全上限) +4. 追溯范围 MUST 同时覆盖 `flow_type=shipping` 与 `flow_type=direct` 的已完成换货单 +5. 追溯 MUST 仅使用 `status=4` 的已完成换货单,禁止把发货中、取消或半成品记录纳入链路 +6. 历史记录若 `flow_type` 为空或缺失,系统 MUST 按 `shipping` 兼容处理 + +**响应结构(AssetOrdersResponse)**: +```json +{ + "current_generation": { + "generation": 2, + "identifier": "DEV-001", + "asset_type": "device", + "total": 5, + "page": 1, + "page_size": 20, + "items": [ ...订单列表... ] + }, + "previous_generations": [ + { + "generation": 1, + "identifier": "DEV-OLD-001", + "asset_type": "device", + "exchange_no": "EXC20260101XXXXXX", + "exchanged_at": "2026-01-01T00:00:00Z", + "total": 3, + "items": [ ...前代订单列表(最近20条)... ] + } + ], + "truncated": false +} +``` + +`previous_generations[].exchanged_at` MUST 优先取换货单 `completed_at`,不得在 `completed_at` 存在时直接将 `updated_at` 视为真实换货完成时间。 + +若历史单据尚无 `completed_at`,系统 MUST 采用 `updated_at` 兼容回退策略;新完成单据 MUST 优先使用 `completed_at`。 + +#### Scenario: 查询 direct 换货后的全代际订单 +- **WHEN** 管理员请求 `GET /api/admin/assets/DEV-NEW-001/orders?include_previous=true`,且当前资产来自一次 `flow_type=direct` 的已完成换货 +- **THEN** `previous_generations` MUST 返回来源旧资产的订单链 +- **AND** `exchanged_at` MUST 返回该换货单的 `completed_at` + +#### Scenario: 查询 shipping 换货后的全代际订单 +- **WHEN** 管理员请求 `GET /api/admin/assets/DEV-001/orders?include_previous=true`,DEV-001 来自一次 `flow_type=shipping` 的已完成换货 +- **THEN** `current_generation` 包含 DEV-001 本代的订单 +- **AND** `previous_generations[0]` 包含来源旧资产的订单,并附带换货单号和真实完成时间 + +#### Scenario: 未完成换货单不进入追溯链 +- **WHEN** 当前资产只存在 `status=3` 的发货待确认换货单来源记录 +- **THEN** `include_previous=true` MUST NOT 将该换货单作为前代来源 + +#### Scenario: 历史完成单据回退完成时间 +- **WHEN** 前代来源换货单 `status=4` 但 `completed_at` 为空 +- **THEN** `previous_generations[].exchanged_at` MUST 使用该换货单 `updated_at` 兼容回退 + +#### Scenario: 资产本身就是第一代(无前代) +- **WHEN** 管理员请求带 `include_previous=true`,但该资产从未经过换货 +- **THEN** `previous_generations` 为空数组 `[]` +- **AND** `current_generation` 正常返回本代订单 + +#### Scenario: 换货链超过追溯上限 +- **WHEN** 换货链深度超过 10 代 +- **THEN** 追溯在第 10 代截断,`truncated=true` +- **AND** 已追溯到的前代数据正常返回 + +#### Scenario: 前代订单分页 +- **WHEN** 请求带 `include_previous=true` +- **THEN** 分页参数(page/page_size)只对 `current_generation` 的订单生效 +- **AND** 前代订单每代最多返回 20 条(不支持前代内分页) + +#### Scenario: 无 include_previous 时响应不含前代字段 +- **WHEN** 管理员请求不带 `include_previous=true`(或传 false) +- **THEN** 响应结构中 `previous_generations` 字段为 null 或不返回,节省带宽 diff --git a/openspec/changes/add-direct-exchange-flow/specs/asset-lifecycle-status/spec.md b/openspec/changes/add-direct-exchange-flow/specs/asset-lifecycle-status/spec.md new file mode 100644 index 0000000..76d11fd --- /dev/null +++ b/openspec/changes/add-direct-exchange-flow/specs/asset-lifecycle-status/spec.md @@ -0,0 +1,68 @@ +## MODIFIED Requirements + +### Requirement: 资产生命周期状态字段定义 + +系统 MUST 在 `IotCard` 与 `Device` 数据模型中新增 `asset_status int NOT NULL DEFAULT 1` 字段,用于表达资产生命周期状态。 + +状态值域 MUST 固定为:`1-在库`、`2-已销售`、`3-已换货`、`4-已停用`。 + +#### Scenario: 新建资产默认在库 +- **WHEN** 系统创建新的 IoT 卡或设备记录 +- **THEN** `asset_status` MUST 默认为 `1`(在库) + +#### Scenario: 非法状态值被拒绝 +- **WHEN** 写入 `asset_status` 为 `0`、`5` 或其他非约定值 +- **THEN** 系统 MUST 拒绝该写入并提示状态值不合法 + +--- + +### Requirement: 资产生命周期状态常量定义 + +系统 MUST 在 `pkg/constants/` 中定义资产生命周期状态常量,并统一由业务层引用,禁止在业务代码中硬编码状态值。 + +#### Scenario: 业务代码引用常量 +- **WHEN** Service 层执行资产状态判断或赋值 +- **THEN** 代码 MUST 使用 `pkg/constants/` 中定义的资产状态常量而不是硬编码数字 + +--- + +### Requirement: 资产状态与网络状态独立 + +系统 MUST 保证 `asset_status` 与运营商侧 `network_status` 完全独立,二者不互相推导、不互相覆盖。 + +状态流转逻辑 MUST 至少包括: +- 导入/建档后:`asset_status=1`(在库) +- 首次绑定/交付客户后:`asset_status=2`(已销售) +- 换货完成时: + - 旧资产完成前必须为 `asset_status=2`(已销售) + - 新资产完成前必须为 `asset_status=1`(在库) + - 旧资产 `asset_status -> 3`(已换货) + - 新资产 `asset_status -> 2`(已销售) +- 转新时: + - 旧资产 `generation + 1` + - 旧资产 `asset_status -> 1`(在库) +- 手动停用时:`asset_status -> 4`(已停用) + +#### Scenario: 网络状态变化不影响资产状态 +- **WHEN** Gateway 同步将 `network_status` 从开机改为停机 +- **THEN** 系统 MUST 保持 `asset_status` 不变 + +#### Scenario: 资产状态变化不强制修改网络状态 +- **WHEN** 管理端将资产手动停用(`asset_status=4`) +- **THEN** 系统 MUST 不自动改写 `network_status` + +#### Scenario: 换货完成后旧资产标记为已换货 +- **WHEN** 任一换货流程完成成功 +- **THEN** 系统 MUST 将旧资产 `asset_status` 更新为 `3` + +#### Scenario: 换货完成后新资产标记为已销售 +- **WHEN** 任一换货流程完成成功 +- **THEN** 系统 MUST 将新资产 `asset_status` 更新为 `2` + +#### Scenario: 旧资产非已销售禁止完成换货 +- **WHEN** 旧资产 `asset_status != 2` +- **THEN** 系统 MUST 拒绝换货完成 + +#### Scenario: 新资产非在库禁止完成换货 +- **WHEN** 新资产 `asset_status != 1` +- **THEN** 系统 MUST 拒绝换货完成 diff --git a/openspec/changes/add-direct-exchange-flow/specs/device/spec.md b/openspec/changes/add-direct-exchange-flow/specs/device/spec.md new file mode 100644 index 0000000..521b1c8 --- /dev/null +++ b/openspec/changes/add-direct-exchange-flow/specs/device/spec.md @@ -0,0 +1,52 @@ +## ADDED Requirements + +### Requirement: 设备换货换出行为 + +系统 SHALL 在设备作为旧资产完成换货后,将其视为“已被换出、不可继续作为当前客户资产使用”的资产。 + +系统 MUST 满足: +- 换货完成前旧设备必须 `asset_status=2`(已销售) +- 换货完成时旧设备 `asset_status -> 3` +- 旧设备 `generation` 在换货完成时保持不变 +- 若后续执行 `renew`,才允许旧设备重新进入新一代库存 + +#### Scenario: shipping 完成后旧设备标记为已换货 +- **WHEN** 一台设备作为旧资产完成 `shipping` 换货 +- **THEN** 系统 MUST 将该设备 `asset_status` 更新为 `3` + +#### Scenario: direct 完成后旧设备标记为已换货 +- **WHEN** 一台设备作为旧资产完成 `direct` 换货 +- **THEN** 系统 MUST 将该设备 `asset_status` 更新为 `3` + +--- + +### Requirement: 设备换货换入行为 + +系统 SHALL 在设备作为新资产完成换货后,将其视为当前客户正在使用的资产。 + +系统 MUST 满足: +- 换货完成前新设备必须 `asset_status=1`(在库) +- 换货完成前新设备必须与旧设备 `shop_id` 一致,含二者同为平台库存 `NULL` +- 换货完成前新设备不得存在有效客户绑定或被其他进行中换货单占用 +- 换货完成后新设备 `asset_status -> 2` +- 新设备 `generation` 在换货完成时保持不变 +- 新设备来源旧设备关系 MUST 通过 `ExchangeOrder` 可追溯 + +#### Scenario: 新设备完成换货后切为已销售 +- **WHEN** 一台设备作为新资产完成换货 +- **THEN** 系统 MUST 将该设备 `asset_status` 更新为 `2` + +--- + +### Requirement: 设备转新重置规则 + +系统 SHALL 在 H7 转新时对设备执行以下重置: +- `generation = generation + 1` +- `asset_status = 1`(在库) +- 清空累计充值与首充触发相关状态(含系列累计/首充字段) +- 清除个人客户绑定关系 +- 删除旧钱包并创建新空钱包 + +#### Scenario: 转新后进入新代际 +- **WHEN** 对旧设备执行转新 +- **THEN** 系统 MUST 使该设备进入新代际并以在库状态重新销售 diff --git a/openspec/changes/add-direct-exchange-flow/specs/exchange-admin-management/spec.md b/openspec/changes/add-direct-exchange-flow/specs/exchange-admin-management/spec.md new file mode 100644 index 0000000..f6a5565 --- /dev/null +++ b/openspec/changes/add-direct-exchange-flow/specs/exchange-admin-management/spec.md @@ -0,0 +1,215 @@ +## MODIFIED Requirements + +### Requirement: H1 发起换货单 + +系统 SHALL 提供 `POST /api/admin/exchanges`(需后台认证 `Auth=true`),用于发起换货单。 + +请求体 MUST 包含:`old_asset_type`、`old_identifier`、`exchange_reason`,可选 `flow_type`、`remark`。 + +`flow_type` 未传或为空时,系统 MUST 按 `shipping` 处理,以兼容旧后台调用;传入非空且不属于 `shipping/direct` 时,系统 MUST 返回参数错误。 + +当 `flow_type=shipping` 时: +- 请求体 MUST NOT 要求 `new_identifier` +- 请求体 MUST NOT 要求 `migrate_data` +- 系统创建成功后 SHALL 返回新建换货单信息(含 `id`、`exchange_no`、`flow_type=shipping`、`status=1`) + +当 `flow_type=direct` 时: +- 请求体 MUST 额外包含 `new_identifier` +- 请求体 MAY 包含 `migrate_data`;未传时 MUST 按 `false` 处理 +- 系统 MUST 在创建接口内完成新资产校验与换货完成事务 +- 创建成功后 SHALL 返回新建换货单信息(含 `id`、`exchange_no`、`flow_type=direct`、`status=4`、`completed_at`) +- 任一步失败时 MUST 回滚整个事务,且 MUST NOT 保留半成品 `direct` 换货单 + +系统 MUST 校验: +- 旧资产存在且当前用户有权限 +- 旧资产当前 `asset_status=2`(已销售) +- 同一资产不存在进行中的 `shipping` 换货单(`status IN (1,2,3)`) +- `direct` 场景下新资产存在 +- `direct` 场景下新资产当前用户有权限 +- `direct` 场景下新旧资产类型必须一致(卡换卡/设备换设备) +- `direct` 场景下新资产必须 `asset_status=1`(在库) +- `direct` 场景下新旧资产 `shop_id` 必须一致(含二者同为平台库存 `NULL`) +- 若旧资产存在客户绑定,则 `direct` 场景下新资产必须可承接绑定关系 +- `direct` 场景下新资产不得存在 `status=1` 的有效客户绑定 +- `direct` 场景下新资产不得已被其他 `shipping + status=3` 换货单占用 + +错误响应 MUST 至少包含:参数错误、资产不存在或无权限、旧资产状态不允许换货、存在进行中换货单、新资产不存在、资产类型不匹配、新资产非在库、新旧资产归属不一致、新资产已被占用、绑定无法承接、钱包冻结余额未处理、迁移失败。 + +#### Scenario: shipping 正常创建 +- **WHEN** 后台以 `flow_type=shipping` 或未传 `flow_type` 发起换货,且旧资产为已销售并无进行中单据 +- **THEN** 系统 MUST 创建 `status=1` 的换货单 + +#### Scenario: direct 创建即完成 +- **WHEN** 后台以 `flow_type=direct` 发起换货且新旧资产校验通过 +- **THEN** 系统 MUST 在同一事务内创建换货单并完成换货 +- **AND** 返回结果 MUST 为 `status=4` + +#### Scenario: direct 缺少新资产标识 +- **WHEN** 后台以 `flow_type=direct` 发起换货但未传 `new_identifier` +- **THEN** 系统 MUST 拒绝创建并返回参数错误 + +#### Scenario: direct 迁移标记未传 +- **WHEN** 后台以 `flow_type=direct` 发起换货且未传 `migrate_data` +- **THEN** 系统 MUST 按 `migrate_data=false` 创建并完成换货 + +#### Scenario: direct 完成失败不留半成品单据 +- **WHEN** 后台以 `flow_type=direct` 发起换货,但完成事务中的绑定承接或迁移步骤失败 +- **THEN** 系统 MUST 回滚整笔事务 +- **AND** MUST NOT 查询到本次请求创建的半成品 direct 换货单 + +#### Scenario: 资产已有进行中 shipping 换货单 +- **WHEN** 后台为同一资产重复发起 `shipping` 换货 +- **THEN** 系统 MUST 拒绝创建并返回“存在进行中的换货单” + +#### Scenario: 旧资产非已销售禁止换货 +- **WHEN** 旧资产 `asset_status != 2` +- **THEN** 系统 MUST 拒绝创建并返回资产状态不允许换货 + +--- + +### Requirement: H2 换货单列表 + +系统 SHALL 提供 `GET /api/admin/exchanges`(`Auth=true`),支持分页与条件查询。 + +查询条件 SHOULD 支持:`status`、`flow_type`、`identifier`(资产标识搜索)、`created_at_start`、`created_at_end`、分页参数。 + +响应 SHALL 返回列表与分页元数据。 +响应项 MUST 返回:旧/新资产标识、`flow_type`、`status`、`shipped_at`、`completed_at`。 + +#### Scenario: 按流程类型查询 direct 已完成单 +- **WHEN** 运营查询 `flow_type=direct` 且 `status=4` +- **THEN** 系统返回所有 direct 已完成换货单并按创建时间倒序 + +--- + +### Requirement: H3 换货单详情 + +系统 SHALL 提供 `GET /api/admin/exchanges/:id`(`Auth=true`)查询换货单详情。 + +响应 MUST 返回旧/新资产信息、流程类型、收货信息、物流信息、迁移状态信息、`shipped_at`、`completed_at`。 + +错误响应 MUST 至少包含:换货单不存在或无权限。 + +#### Scenario: 查询 direct 换货单详情 +- **WHEN** 查询一张 `flow_type=direct` 的已完成换货单 +- **THEN** 响应 MUST 返回 `flow_type=direct` +- **AND** 收货信息与物流信息可以为空 +- **AND** `completed_at` 必须存在 + +--- + +### Requirement: H4 发货 + +系统 SHALL 提供 `POST /api/admin/exchanges/:id/ship`(`Auth=true`)。 + +请求体 MUST 包含:`express_company`、`express_no`、`new_identifier`、`migrate_data`。 + +系统 MUST 校验: +- 换货单 `flow_type` 必须为 `shipping` +- 当前状态必须为 `2` +- 旧资产当前必须仍为 `asset_status=2`(已销售) +- 新旧资产类型必须一致(卡换卡/设备换设备) +- 新资产必须 `asset_status=1`(在库) +- 新资产当前用户有权限 +- 新旧资产 `shop_id` 必须一致(含二者同为平台库存 `NULL`) +- 新资产不得存在 `status=1` 的有效客户绑定 +- 新资产不得已被其他 `shipping + status=3` 换货单占用 +- 系统 MUST 通过条件更新、行锁或等效机制确保发货成功时新资产仍满足在库且未被占用 + +成功后 SHALL: +- 更新新资产信息 +- 更新物流信息 +- 写入 `migrate_data` +- 记录 `shipped_at` +- 将状态改为 `3` +- 将新资产视为被当前换货单占用;在确认完成前不改变新资产 `asset_status` + +错误响应 MUST 至少包含:非法状态、流程类型不支持发货、旧资产状态不允许换货、资产类型不匹配、新资产非在库、新旧资产归属不一致、新资产已被占用、资产不存在或无权限。 + +#### Scenario: direct 单据禁止发货 +- **WHEN** `flow_type=direct` 的换货单调用发货接口 +- **THEN** 系统 MUST 拒绝并返回流程类型不支持该操作的错误 + +#### Scenario: 新资产类型不一致 +- **WHEN** 旧资产为 `iot_card` 且新资产为 `device` +- **THEN** 系统 MUST 拒绝发货并返回“换货资产类型必须一致” + +#### Scenario: 新资产已被其他换货单占用 +- **WHEN** 新资产已经作为其他 `shipping + status=3` 换货单的新资产 +- **THEN** 系统 MUST 拒绝发货并返回新资产已被占用 + +--- + +### Requirement: H5 确认完成 + +系统 SHALL 提供 `POST /api/admin/exchanges/:id/complete`(`Auth=true`)。 + +系统 MUST 校验: +- 换货单 `flow_type` 必须为 `shipping` +- 当前状态必须为 `3` + +系统 MUST 在**单一数据库事务**中执行完成换货动作。该事务至少包括: +- 校验新资产快照完整且新资产仍满足换货条件 +- 校验旧资产仍为 `asset_status=2`(已销售) +- 校验新资产仍为 `asset_status=1`(在库),且未被当前换货单之外的有效记录占用 +- 旧资产 `asset_status -> 3` +- 新资产 `asset_status -> 2` +- 若旧资产存在 `PersonalCustomerDevice` 绑定,则绑定切换到新资产资产绑定键 +- 若 `migrate_data=true`,执行全量迁移事务(见 `exchange-data-migration` 能力) +- 写入 `completed_at` +- 换货单状态更新为 `4` + +成功后 SHALL: +- `migration_completed=true`(若执行迁移) +- 换货单状态更新为 `4` + +错误响应 MUST 至少包含:非法状态、流程类型不支持确认完成、旧资产状态不允许换货、新资产状态不允许换货、迁移失败、绑定无法承接、钱包冻结余额未处理、换货单不存在或无权限。 + +#### Scenario: 需要迁移并完成 +- **WHEN** `shipping` 换货单状态为 `3` 且 `migrate_data=true` +- **THEN** 系统 MUST 在同一事务成功后将状态变为 `4` 并记录迁移结果 + +#### Scenario: 不迁移也必须完成切换 +- **WHEN** `shipping` 换货单状态为 `3` 且 `migrate_data=false` +- **THEN** 系统 MUST 仍然在事务内完成旧资产状态切换、新资产状态切换、绑定切换和单据完成 + +--- + +### Requirement: H6 取消换货 + +系统 SHALL 提供 `POST /api/admin/exchanges/:id/cancel`(`Auth=true`)。 + +系统 MUST 仅允许 `flow_type=shipping` 且 `status IN (1,2)` 时取消,成功后状态更新为 `5`。 + +系统 MUST 禁止已发货单取消(`status=3`)。 +系统 MUST 禁止 `direct` 单据进入取消分支。 + +#### Scenario: 已发货单取消失败 +- **WHEN** `shipping` 换货单状态为 `3` 发起取消 +- **THEN** 系统 MUST 返回状态非法错误 + +#### Scenario: direct 单据取消失败 +- **WHEN** `direct` 换货单发起取消 +- **THEN** 系统 MUST 返回流程类型不支持该操作的错误 + +--- + +### Requirement: H7 旧资产转新 + +系统 SHALL 提供 `POST /api/admin/exchanges/:id/renew`(`Auth=true`)。 + +系统 MUST 校验旧资产当前 `asset_status=3`(已换货),并执行: +- `generation + 1` +- `asset_status -> 1` +- 清除累计充值/首充相关状态 +- 清除个人客户绑定 +- 创建新空钱包 + +系统 MUST 保留历史数据,不执行历史删除。 +系统 MUST NOT 在换货完成阶段修改旧资产 `generation`;`generation` 仅在 `renew` 阶段递增。 + +错误响应 MUST 至少包含:资产状态不满足转新条件、换货单不存在或无权限。 + +#### Scenario: 旧资产未处于已换货状态 +- **WHEN** 旧资产 `asset_status != 3` 发起转新 +- **THEN** 系统 MUST 拒绝并返回“资产当前状态不允许转新” diff --git a/openspec/changes/add-direct-exchange-flow/specs/exchange-client-notification/spec.md b/openspec/changes/add-direct-exchange-flow/specs/exchange-client-notification/spec.md new file mode 100644 index 0000000..3e0c269 --- /dev/null +++ b/openspec/changes/add-direct-exchange-flow/specs/exchange-client-notification/spec.md @@ -0,0 +1,51 @@ +## MODIFIED Requirements + +### Requirement: G1 查询进行中换货通知 + +系统 SHALL 提供 `GET /api/c/v1/exchange/pending?identifier=xxx`(需个人客户认证 `Auth=true`)。 + +系统 MUST 根据资产标识查询当前客户可见的进行中换货单。 + +查询规则 MUST 满足: +- 仅返回 `flow_type=shipping` +- 仅返回 `status IN (1,2,3)` 的记录 +- `direct` 单据无论状态如何都 MUST NOT 出现在该接口结果中 +- 历史记录若 `flow_type` 为空或缺失,系统 MUST 按 `shipping` 兼容处理 + +响应 SHALL 至少包含:换货单 ID、单号、流程类型、状态、换货原因、创建时间。 + +错误响应 MUST 至少包含:参数错误、资产不存在或无权限。 + +#### Scenario: 命中 shipping 进行中换货单 +- **WHEN** 客户按资产标识查询且存在 `flow_type=shipping` 且状态为 `2` 的换货单 +- **THEN** 系统返回该换货单并标识当前状态为待发货 + +#### Scenario: direct 已完成单据不进入待处理 +- **WHEN** 客户按资产标识查询,但该资产最近一次换货为 `flow_type=direct` 且已完成 +- **THEN** 系统 MUST 返回空结果,不将该单据视为待处理通知 + +--- + +### Requirement: G2 填写收货信息 + +系统 SHALL 提供 `POST /api/c/v1/exchange/:id/shipping-info`(需个人客户认证 `Auth=true`)。 + +请求体 MUST 包含:`recipient_name`、`recipient_phone`、`recipient_address`。 + +系统 MUST 校验: +- 换货单存在且当前客户有权限 +- `flow_type` 必须为 `shipping` +- 当前状态必须为 `1` +- 历史记录若 `flow_type` 为空或缺失,系统 MUST 按 `shipping` 兼容处理 + +成功后 SHALL 写入收货信息并将状态更新为 `2`。 + +错误响应 MUST 至少包含:参数错误、状态非法、流程类型不支持该操作、换货单不存在或无权限。 + +#### Scenario: 非待填写状态禁止更新收货信息 +- **WHEN** `shipping` 换货单当前状态为 `2` 或 `3` +- **THEN** 系统 MUST 拒绝填写并返回状态非法错误 + +#### Scenario: direct 单据禁止填写收货信息 +- **WHEN** `direct` 换货单调用填写收货信息接口 +- **THEN** 系统 MUST 拒绝并返回流程类型不支持该操作的错误 diff --git a/openspec/changes/add-direct-exchange-flow/specs/exchange-data-migration/spec.md b/openspec/changes/add-direct-exchange-flow/specs/exchange-data-migration/spec.md new file mode 100644 index 0000000..81de374 --- /dev/null +++ b/openspec/changes/add-direct-exchange-flow/specs/exchange-data-migration/spec.md @@ -0,0 +1,132 @@ +## MODIFIED Requirements + +### Requirement: 完成换货事务边界 + +系统 MUST 在换货完成时使用**单一数据库事务**执行“完成换货必做切换”,并在 `migrate_data=true` 时把全量迁移纳入同一事务。 + +该事务 SHALL 覆盖: +- 旧资产合法性校验 +- 新资产合法性校验 +- 新旧资产归属一致性校验 +- 旧资产 `asset_status -> 3` +- 新资产 `asset_status -> 2` +- 个人客户绑定切换 +- `migrate_data=true` 时的钱包、套餐、标签、累计状态迁移 +- 换货单 `migration_completed`、`migration_balance`、`completed_at`、`status=4` 更新 + +任一步骤失败 MUST 回滚。`shipping` 确认完成失败时,换货单状态保持未完成;`direct` 创建即完成失败时,系统 MUST 回滚整笔创建事务且不保留半成品 `direct` 换货单。 + +该事务适用范围 MUST 包括: +- `shipping` 流程的 H5 确认完成 +- `direct` 流程的创建即完成 + +#### Scenario: 迁移中途失败回滚 +- **WHEN** 完成换货事务第 N 步发生数据库错误 +- **THEN** 系统 MUST 回滚整个事务,换货单状态保持未完成 + +#### Scenario: direct 完成事务失败不落单 +- **WHEN** `direct` 创建即完成事务第 N 步失败 +- **THEN** 系统 MUST 回滚整个事务 +- **AND** MUST NOT 保留本次创建的换货单 + +#### Scenario: 不迁移也必须使用完成事务 +- **WHEN** `migrate_data=false` 且执行 `shipping` 完成或 `direct` 创建即完成 +- **THEN** 系统 MUST 仍然在事务中执行旧资产状态切换、新资产状态切换、绑定切换和单据完成 + +--- + +### Requirement: 完成换货必做切换规则 + +系统 SHALL 将以下动作定义为“完成换货必做切换”,无论 `migrate_data` 为真或假都必须执行: + +1. 校验新资产存在且与旧资产同类型。 +2. 校验旧资产当前 `asset_status=2`(已销售)。 +3. 校验新资产当前 `asset_status=1`(在库)。 +4. 校验新旧资产 `shop_id` 一致,含二者同为平台库存 `NULL`。 +5. 校验新资产未被当前换货单之外的有效客户绑定或进行中换货单占用。 +6. 将旧资产 `asset_status` 更新为 `3`(已换货)。 +7. 将新资产 `asset_status` 更新为 `2`(已销售)。 +8. 若旧资产存在 `PersonalCustomerDevice` 绑定,则将绑定记录中的资产标识字段更新为新资产资产绑定键。 +9. 记录换货单 `completed_at`。 +10. 将换货单状态更新为 `4`。 + +若旧资产存在客户绑定但新资产无法承接绑定,系统 MUST 视为完成换货失败并回滚。 + +#### Scenario: 不迁移但完成换货 +- **WHEN** 后台执行换货完成且 `migrate_data=false` +- **THEN** 系统 MUST 仍然将旧资产标记为已换货 +- **AND** MUST 将新资产标记为已销售 +- **AND** MUST 更新客户绑定关系 + +#### Scenario: 新资产无法承接客户绑定 +- **WHEN** 旧资产存在个人客户绑定,但新资产缺少可承接的资产绑定键 +- **THEN** 系统 MUST 拒绝完成换货并回滚 + +#### Scenario: 新资产被其他进行中换货占用 +- **WHEN** 新资产已经作为其他 `shipping + status=3` 换货单的新资产 +- **THEN** 系统 MUST 拒绝完成换货并回滚 + +--- + +### Requirement: 11 张表迁移规则 + +系统 SHALL 在 `migrate_data=true` 时按以下规则处理 11 张表: + +1. `tb_asset_wallet`:将旧资产钱包余额转移到新资产钱包。 +2. `tb_asset_wallet_transaction`:生成一条迁移流水记录(明确来源钱包、目标钱包、金额、业务类型)。 +3. `tb_asset_recharge_record`:历史充值记录保留,不做更新。 +4. `tb_package_usage`:将生效套餐关联到新资产(更新 `iot_card_id` 或 `device_id`)。 +5. `tb_package_usage_daily_record`:随 `tb_package_usage` 关系迁移(保持套餐日明细连续性)。 +6. `tb_order`:历史订单保留,不做更新。 +7. `tb_commission`:历史分佣记录保留,不做更新。 +8. `tb_data_usage_record`:历史流量记录保留,不做更新。 +9. `tb_resource_tag`:复制旧资产标签到新资产。 +10. `tb_personal_customer_device`:若旧资产存在绑定,绑定记录中的资产标识字段更新为新资产资产绑定键。 +11. `tb_iot_card`/`tb_device`:复制累计充值与首充状态到新资产。 + +旧资产 `asset_status -> 3` 与新资产 `asset_status -> 2` 属于“完成换货必做切换”,不再视为仅在迁移开启时执行的动作。 + +钱包迁移 MUST 满足: +- 旧资产钱包存在 `frozen_balance > 0` 时,系统 MUST 拒绝迁移并回滚整个完成事务 +- 系统 MUST NOT 对冻结余额做部分迁移或静默清零 +- 旧资产没有钱包时,迁移余额按 `0` 处理 +- 新资产没有钱包时,系统 MAY 在同一事务内创建新钱包 +- 写入迁移流水时 MUST 使用能表达“换货迁移”的业务类型或备注,不能伪装为普通充值、退款或消费 + +#### Scenario: 钱包余额转移并记录流水 +- **WHEN** 旧资产钱包余额为 5000 分 +- **THEN** 新资产钱包余额增加 5000 分,旧钱包余额按迁移策略清零,并写入迁移流水 + +#### Scenario: 旧钱包存在冻结余额 +- **WHEN** `migrate_data=true` 且旧资产钱包 `frozen_balance > 0` +- **THEN** 系统 MUST 拒绝完成换货并回滚 +- **AND** MUST 返回钱包冻结余额未处理的错误语义 + +--- + +### Requirement: 设备换设备特殊规则 + +设备换设备流程 MUST NOT 迁移 `DeviceSimBinding`。 + +系统 SHALL 视新设备为新硬件交付,新设备卡绑定由其自身体系决定,旧设备绑定关系保留历史。 + +#### Scenario: 设备换设备不复制绑定卡 +- **WHEN** 执行设备换设备全量迁移 +- **THEN** 系统 MUST 不创建或复制任何 `DeviceSimBinding` 记录到新设备 + +--- + +### Requirement: 转新规则 + +系统 SHALL 在 H7 转新时执行代际隔离策略: +- 资产 `generation + 1` +- 创建新空钱包(新 `wallet_id`) +- 清除累计充值状态与首充触发状态 +- 清除 `PersonalCustomerDevice` 绑定 +- 不删除历史业务数据 + +系统 MUST NOT 在换货完成阶段变更 `generation`。 + +#### Scenario: 转新后历史数据保留 +- **WHEN** 资产转新完成 +- **THEN** 历史订单、充值、分佣、流量数据 MUST 仍可在旧代际查询链路中追溯 diff --git a/openspec/changes/add-direct-exchange-flow/specs/exchange-order-model/spec.md b/openspec/changes/add-direct-exchange-flow/specs/exchange-order-model/spec.md new file mode 100644 index 0000000..65a8696 --- /dev/null +++ b/openspec/changes/add-direct-exchange-flow/specs/exchange-order-model/spec.md @@ -0,0 +1,135 @@ +## MODIFIED Requirements + +### Requirement: ExchangeOrder 换货单模型定义 + +系统 SHALL 定义 `ExchangeOrder` 模型并映射到 `tb_exchange_order`,用于承载客户端换货完整生命周期。 + +模型字段 MUST 至少包含: +- 基础:`id`、`created_at`、`updated_at`、`deleted_at`、`creator`、`updater` +- 单号:`exchange_no` +- 流程:`flow_type` +- 旧资产:`old_asset_type`、`old_asset_id`、`old_asset_identifier` +- 新资产:`new_asset_type`、`new_asset_id`、`new_asset_identifier` +- 收货:`recipient_name`、`recipient_phone`、`recipient_address` +- 物流:`express_company`、`express_no` +- 迁移:`migrate_data`、`migration_completed`、`migration_balance` +- 时间:`shipped_at`、`completed_at` +- 业务:`exchange_reason`、`remark`、`status` +- 多租户:`shop_id` + +`flow_type` MUST 使用字符串枚举,至少支持: +- `shipping`:需要客户填写收货地址、后台发货、后台确认完成 +- `direct`:创建时直接完成,不经过客户填写地址和后台发货 + +`flow_type` MUST 在数据库层设置默认值 `shipping`,用于兼容历史记录与旧创建请求。 + +`ExchangeOrder` SHALL 嵌入 `BaseModel` 并实现 `TableName() string`,返回 `tb_exchange_order`。 + +#### Scenario: 创建 shipping 换货单模型实例 +- **WHEN** 系统创建新的 `shipping` 换货单记录 +- **THEN** 记录 MUST 包含旧资产快照、流程类型 `flow_type=shipping`、收货信息占位、迁移状态字段和多租户字段 + +#### Scenario: 创建 direct 换货单模型实例 +- **WHEN** 系统创建新的 `direct` 换货单记录 +- **THEN** 记录 MUST 包含旧资产快照、新资产快照、流程类型 `flow_type=direct` +- **AND** 记录在创建成功时即可具备 `completed_at` + +--- + +### Requirement: 换货状态常量定义 + +系统 MUST 使用 int 常量定义换货状态: +- `1` 待填写信息 +- `2` 待发货 +- `3` 已发货待确认 +- `4` 已完成 +- `5` 已取消 + +系统 MUST 使用独立的字符串常量定义换货流程类型: +- `shipping` +- `direct` + +#### Scenario: 状态与流程常量一致性 +- **WHEN** Service、Store、Handler 读取或更新换货状态与流程类型 +- **THEN** 各层 MUST 使用统一常量值,禁止硬编码散落魔法数字和字符串 + +--- + +### Requirement: 换货状态机流转规则 + +系统 SHALL 执行以下状态机: + +- `shipping`: + - 创建换货单后:`status=1` + - 客户填写收货信息后:`1 -> 2` + - 后台发货后:`2 -> 3` + - 后台确认完成后:`3 -> 4` + - 取消:仅允许 `1/2 -> 5` + +- `direct`: + - 创建换货单并完成后:`status=4` + - 不经过 `1/2/3` + - 不走客户端填写收货地址 + - 不走后台发货 + - 不进入取消状态机 + - 任一步失败时不保留半成品 `direct` 单据 + +系统 MUST 禁止非法流转(如 `3 -> 5`、`4 -> 2`、`direct 进入 2`)。 + +#### Scenario: shipping 已发货不可取消 +- **WHEN** `shipping` 换货单状态为 `3` 且请求取消 +- **THEN** 系统 MUST 拒绝并返回状态流转非法错误 + +#### Scenario: direct 创建即完成 +- **WHEN** 后台以 `flow_type=direct` 创建换货单且所有校验通过 +- **THEN** 系统 MUST 直接创建 `status=4` 的换货单 +- **AND** MUST 写入 `completed_at` + +#### Scenario: direct 失败不落半成品状态 +- **WHEN** 后台以 `flow_type=direct` 创建换货单但完成事务失败 +- **THEN** 系统 MUST 回滚创建 +- **AND** MUST NOT 产生 `flow_type=direct AND status IN (1,2,3)` 的换货单 + +#### Scenario: direct 不允许进入发货阶段 +- **WHEN** `direct` 换货单请求执行发货或填写收货地址 +- **THEN** 系统 MUST 拒绝并返回流程类型不支持该操作的错误 + +--- + +### Requirement: 换货单号生成规则 + +系统 MUST 为每个换货单生成全局可追踪单号,格式为:`EXC + 时间戳片段 + 随机数片段`。 + +生成规则 SHALL 满足: +- 前缀固定为 `EXC` +- 包含日期/时间信息用于人工排查 +- 包含随机片段降低并发冲突概率 + +#### Scenario: 生成换货单号 +- **WHEN** 后台发起换货并创建新单 +- **THEN** 系统 MUST 生成形如 `EXC20260319XXXXXX` 的单号并写入 `exchange_no` + +--- + +### Requirement: 换货关键业务时间字段语义 + +系统 SHALL 使用独立的业务时间字段表达发货完成和换货完成,而不是复用 `updated_at`。 + +字段语义 MUST 满足: +- `shipped_at`:仅在 `shipping` 流程发货成功后写入 +- `completed_at`:仅在换货完成成功后写入,`shipping` 与 `direct` 共用 +- `direct` 流程 MUST 保持 `shipped_at` 为空 +- 历史已完成单据若 `completed_at` 为空,查询与追溯层 MUST 回退使用 `updated_at` + +#### Scenario: shipping 写入发货时间 +- **WHEN** `shipping` 换货单执行后台发货成功 +- **THEN** 系统 MUST 记录 `shipped_at=当前时间` + +#### Scenario: direct 不写发货时间 +- **WHEN** `direct` 换货单创建并完成成功 +- **THEN** 系统 MUST 保持 `shipped_at` 为空 +- **AND** MUST 写入 `completed_at=当前时间` + +#### Scenario: 历史单据完成时间兼容 +- **WHEN** 查询历史已完成换货单且 `completed_at` 为空 +- **THEN** 系统 MUST 在追溯展示中回退使用 `updated_at` 作为兼容完成时间 diff --git a/openspec/changes/add-direct-exchange-flow/specs/iot-card/spec.md b/openspec/changes/add-direct-exchange-flow/specs/iot-card/spec.md new file mode 100644 index 0000000..a05ce7d --- /dev/null +++ b/openspec/changes/add-direct-exchange-flow/specs/iot-card/spec.md @@ -0,0 +1,52 @@ +## ADDED Requirements + +### Requirement: IoT 卡换货换出行为 + +系统 SHALL 在 IoT 卡作为旧资产完成换货后,将其视为“已被换出、不可继续作为当前客户资产使用”的资产。 + +系统 MUST 满足: +- 换货完成前旧卡必须 `asset_status=2`(已销售) +- 换货完成时旧卡 `asset_status -> 3` +- 旧卡 `generation` 在换货完成时保持不变 +- 若后续执行 `renew`,才允许旧卡重新进入新一代库存 + +#### Scenario: shipping 完成后旧卡标记为已换货 +- **WHEN** 一张 IoT 卡作为旧资产完成 `shipping` 换货 +- **THEN** 系统 MUST 将该卡 `asset_status` 更新为 `3` + +#### Scenario: direct 完成后旧卡标记为已换货 +- **WHEN** 一张 IoT 卡作为旧资产完成 `direct` 换货 +- **THEN** 系统 MUST 将该卡 `asset_status` 更新为 `3` + +--- + +### Requirement: IoT 卡换货换入行为 + +系统 SHALL 在 IoT 卡作为新资产完成换货后,将其视为当前客户正在使用的资产。 + +系统 MUST 满足: +- 换货完成前新卡必须 `asset_status=1`(在库) +- 换货完成前新卡必须与旧卡 `shop_id` 一致,含二者同为平台库存 `NULL` +- 换货完成前新卡不得存在有效客户绑定或被其他进行中换货单占用 +- 换货完成后新卡 `asset_status -> 2` +- 新卡 `generation` 在换货完成时保持不变 +- 新卡来源旧卡关系 MUST 通过 `ExchangeOrder` 可追溯 + +#### Scenario: 新卡完成换货后切为已销售 +- **WHEN** 一张 IoT 卡作为新资产完成换货 +- **THEN** 系统 MUST 将该卡 `asset_status` 更新为 `2` + +--- + +### Requirement: IoT 卡转新重置规则 + +系统 SHALL 在 H7 转新时对 IoT 卡执行以下重置: +- `generation = generation + 1` +- `asset_status = 1`(在库) +- 清空累计充值与首充触发相关状态(含 `AccumulatedRecharge`、系列累计/首充字段) +- 清除个人客户绑定关系 +- 删除旧钱包并创建新空钱包 + +#### Scenario: 转新后进入新代际 +- **WHEN** 对旧卡执行转新 +- **THEN** 系统 MUST 使该卡进入新代际并以在库状态重新销售 diff --git a/openspec/changes/add-direct-exchange-flow/specs/personal-customer/spec.md b/openspec/changes/add-direct-exchange-flow/specs/personal-customer/spec.md new file mode 100644 index 0000000..2cbe810 --- /dev/null +++ b/openspec/changes/add-direct-exchange-flow/specs/personal-customer/spec.md @@ -0,0 +1,56 @@ +## MODIFIED Requirements + +### Requirement: 换货迁移时更新个人客户资产绑定 + +系统 SHALL 在换货完成成功后,更新 `PersonalCustomerDevice` 的资产标识绑定关系: +- 若旧资产存在客户绑定,绑定中的 `virtual_no` MUST 更新为新资产资产绑定键 +- 更新后客户对资产访问连续,不需重新登录即可看到新资产 + +该规则 MUST 同时适用于: +- `shipping` 流程完成换货 +- `direct` 流程创建即完成 + +该规则 MUST NOT 仅依赖 `migrate_data=true` 才执行。 + +#### Scenario: 不迁移也要切换客户绑定 +- **WHEN** 旧资产存在个人客户绑定且执行了 `migrate_data=false` 的换货完成 +- **THEN** 系统 MUST 仍然将绑定记录的资产标识字段更新为新资产资产绑定键 + +#### Scenario: 迁移后客户绑定跟随新资产 +- **WHEN** 旧资产存在个人客户绑定且执行了 `migrate_data=true` +- **THEN** 系统 MUST 将绑定记录的资产标识字段更新为新资产资产绑定键 + +--- + +### Requirement: 换货完成前的绑定承接校验 + +系统 SHALL 在换货完成前校验新资产是否可以承接现有个人客户绑定。 + +若旧资产存在 `PersonalCustomerDevice` 绑定,则系统 MUST 满足: +- 新资产存在可写入绑定的资产绑定键 +- IoT 卡资产绑定键 MUST 使用新卡 `virtual_no` +- 设备资产绑定键 MUST 优先使用新设备 `virtual_no`,为空时 MAY 使用新设备 `imei`,与现有登录绑定规则保持一致 +- 新资产绑定键当前不得存在 `status=1` 的 `PersonalCustomerDevice` 绑定记录 +- 已禁用或软删除绑定记录不视为当前绑定冲突 + +若任一条件不满足,系统 MUST 拒绝完成换货并回滚整个事务。 + +#### Scenario: 新资产无法承接绑定时回滚 +- **WHEN** 旧资产存在客户绑定,但新资产缺少可承接的资产绑定键 +- **THEN** 系统 MUST 拒绝完成换货 +- **AND** MUST 保持换货单未完成 + +#### Scenario: 新资产已有有效客户绑定时回滚 +- **WHEN** 旧资产存在客户绑定,但新资产绑定键已经存在 `status=1` 的客户绑定记录 +- **THEN** 系统 MUST 拒绝完成换货 +- **AND** MUST 回滚整个完成事务 + +--- + +### Requirement: 转新时清除个人客户绑定 + +系统 SHALL 在 H7 转新时清除该资产在 `PersonalCustomerDevice` 中的绑定关系,避免旧客户继续访问新代际资产。 + +#### Scenario: 转新后旧客户需重新绑定 +- **WHEN** 资产转新完成 +- **THEN** 系统 MUST 删除或失效对应客户绑定,使旧客户再次访问时触发重新绑定流程 diff --git a/openspec/changes/add-direct-exchange-flow/tasks.md b/openspec/changes/add-direct-exchange-flow/tasks.md new file mode 100644 index 0000000..a864987 --- /dev/null +++ b/openspec/changes/add-direct-exchange-flow/tasks.md @@ -0,0 +1,65 @@ +## 1. 数据模型与迁移 + +- [x] 1.1 为 `tb_exchange_order` 设计并创建迁移文件,新增 `flow_type`、`shipped_at`、`completed_at` 字段,并补充与新查询语义匹配的索引与默认值。 +- [x] 1.2 更新 `internal/model/exchange_order.go`,让模型字段、中文注释、表注释和数据库迁移保持一致。 +- [x] 1.3 更新换货相关常量与状态文本映射,新增流程类型常量,禁止在 Service/Handler 中硬编码 `shipping`、`direct`、状态值和资产状态值。 +- [x] 1.4 更新 `internal/model/dto/exchange_dto.go`,为后台创建、发货、详情、列表、客户端待处理等 DTO 增加 `flow_type`、`shipped_at`、`completed_at` 以及 `direct` 所需入参约束。 + +## 2. 后台接口契约调整 + +- [x] 2.1 改造 `POST /api/admin/exchanges` 的 DTO、Handler、Service 入参校验,使 `flow_type=shipping` 与 `flow_type=direct` 的必填参数和错误语义明确分支。 +- [x] 2.2 改造 `GET /api/admin/exchanges` 与 `GET /api/admin/exchanges/:id` 的响应结构,补充 `flow_type`、`shipped_at`、`completed_at`,确保后台能区分 `shipping` 与 `direct` 已完成单据。 +- [x] 2.3 限定 `POST /api/admin/exchanges/:id/ship` 仅适用于 `shipping` 流程,并在响应与错误语义中体现“不适用于 direct 单据”。 +- [x] 2.4 改造 `POST /api/admin/exchanges/:id/complete`,让其仅处理 `shipping` 且 `status=3` 的单据,并显式复用统一完成事务。 +- [x] 2.5 保持 `POST /api/admin/exchanges/:id/cancel` 仅对 `shipping` 的 `status=1/2` 生效,明确 `direct` 单据不进入取消分支。 +- [x] 2.6 明确创建接口兼容策略:未传 `flow_type` 按 `shipping` 处理,非法 `flow_type` 返回参数错误;`direct` 未传 `migrate_data` 按 `false` 处理。 + +## 3. 完成换货事务重构 + +- [x] 3.1 在 `internal/service/exchange/service.go` 中提炼统一的“完成换货”内部事务方法,覆盖旧资产状态切换、新资产状态切换、绑定切换、完成时间写入和单据状态更新。 +- [x] 3.2 重构 `internal/service/exchange/migration.go`,将现有自开事务的迁移逻辑改造成可复用的事务内步骤,由外层事务统一提交或回滚。 +- [x] 3.3 在完成换货事务中拆分“必做切换”和“可选迁移”,确保 `migrate_data=false` 时仍然会执行旧资产 `asset_status -> 3`、新资产 `asset_status -> 2`、绑定切换和单据完成。 +- [x] 3.4 为完成换货事务增加客户绑定承接校验:若旧资产存在 `PersonalCustomerDevice` 绑定,而新资产无法承接 `virtual_no`,则整单失败回滚。 +- [x] 3.5 在完成换货事务中补充新资产有效性校验,至少覆盖资产存在、类型一致、在库状态、未被脏绑定占用等前置条件。 +- [x] 3.6 在完成换货事务中补充旧资产 `asset_status=2`、新旧资产 `shop_id` 一致、新资产未被其他换货单占用、旧钱包无冻结余额等边界校验。 + +## 4. `direct` 流程建单即完成 + +- [x] 4.1 在换货创建服务中新增 `flow_type=direct` 分支,使后台创建时即可携带 `new_identifier`、`migrate_data` 并直接进入完成事务。 +- [x] 4.2 确保 `direct` 流程对 `iot_card` 与 `device` 都按相同规则校验新旧资产类型一致、库存状态正确、绑定可承接。 +- [x] 4.3 保证 `direct` 创建成功后返回的换货单已经具备 `status=4`、`completed_at`、新资产快照和可选迁移结果,不产生半成品处理中单据。 +- [x] 4.4 补充 `direct` 流程的中文错误语义,覆盖缺少 `new_identifier`、新资产不存在、类型不一致、绑定不可承接、迁移失败等关键场景。 +- [x] 4.5 保证 `direct` 创建即完成使用单一事务,任一步失败时整单不落库,不产生 `flow_type=direct AND status IN (1,2,3)` 的记录。 + +## 5. `shipping` 流程兼容改造 + +- [x] 5.1 保持 `shipping` 创建仍然默认创建 `status=1` 单据,并将 `flow_type` 明确写为 `shipping`。 +- [x] 5.2 让客户端填写收货地址仍然只处理 `shipping + status=1`,并保证状态从 `1 -> 2` 的条件更新语义不变。 +- [x] 5.3 改造后台发货逻辑,使其在 `shipping + status=2` 时写入 `new_*`、`express_*`、`migrate_data`、`shipped_at` 并推进到 `status=3`。 +- [x] 5.4 改造后台完成逻辑,使其在 `shipping + status=3` 时复用统一完成事务,而不是继续沿用“迁移成功后再单独更新单据状态”的旧实现。 +- [x] 5.5 为 `shipping` 发货后的新资产占用增加校验或约束,避免同一新资产被多个进行中换货单占用。 + +## 6. 个人客户绑定与资产生命周期 + +- [x] 6.1 改造换货完成时的 `PersonalCustomerDevice` 处理逻辑,将“绑定切换到新资产”从可选迁移提升为完成换货必做动作。 +- [x] 6.2 保持 `renew` 仅在旧资产已换货且换货单已完成时执行,并明确 `generation + 1`、重新入库、清理绑定、重建空钱包的事务边界。 +- [x] 6.3 更新 `IotCard` 与 `Device` 在换货换出、换入、转新三类场景下的状态更新逻辑,确保旧资产进入 `3 已换货`、新资产进入 `2 已销售`、转新后旧资产回到 `1 在库`。 +- [x] 6.4 确保 `generation` 只在 `renew` 流程中递增,不在 `shipping/direct` 完成换货阶段发生变化。 +- [x] 6.5 统一个人客户绑定键规则:IoT 卡使用 `virtual_no`,设备优先 `virtual_no`、为空时使用 `imei`,并禁止新资产存在有效绑定冲突。 + +## 7. 查询、追溯与文档生成 + +- [x] 7.1 改造客户端待处理查询,使 `GET /api/c/v1/exchange/pending` 仅返回 `shipping` 且仍处于处理中状态的单据,`direct` 单据不进入待处理视图。 +- [x] 7.2 改造资产历史订单追溯逻辑,使 `include_previous=true` 同时覆盖 `shipping` 与 `direct` 的已完成换货链,并优先使用 `completed_at` 作为 `exchanged_at`。 +- [x] 7.3 改造换货单列表、详情和追溯链展示字段,保证后台与追溯接口都能看出流程类型、发货时间、完成时间和来源旧资产链。 +- [x] 7.4 更新换货相关 handler 注册与文档生成器接入,确保 `cmd/api/docs.go`、`cmd/gendocs/main.go` 与最新接口契约保持一致。 +- [x] 7.5 为历史记录补兼容逻辑:`flow_type` 为空按 `shipping`,历史已完成单据 `completed_at` 为空时追溯回退 `updated_at`。 + +## 8. 手工验证与交付检查 + +- [x] 8.1 执行 `go build ./...`,确认换货相关模型、DTO、Service、Handler、路由和文档生成器改动可正常编译。 +- [x] 8.2 使用 PostgreSQL MCP 核对 `tb_exchange_order` 字段、默认值、索引和时间字段是否与提案一致。 +- [ ] 8.3 手工验证 `shipping` 流程:创建、客户端填地址、后台发货、后台完成、取消限制、`migrate_data=true/false`、客户绑定切换、旧资产状态、新资产状态。 +- [ ] 8.4 手工验证 `direct` 流程:创建即完成、`iot_card/device` 两类资产、`migrate_data=true/false`、绑定不可承接时回滚、已完成单据不出现在客户端待处理。 +- [ ] 8.5 手工验证 `renew` 与资产追溯:旧资产可转新、`generation` 仅在 `renew` 递增、`include_previous=true` 能正确返回旧资产链及真实 `completed_at`。 +- [ ] 8.6 手工验证新增边界:旧资产非已销售拒绝、新旧资产 `shop_id` 不一致拒绝、新资产已绑定/已占用拒绝、旧钱包存在冻结余额时迁移拒绝、direct 失败不落半成品单据。