Files
junhong_cmp_fiber/openspec/specs/card-replacement/spec.md
huang b9733c4913
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m12s
fix: 修正零售价架构错误 + 清理旧微信配置 + 归档提案 + 前端接口文档
1. 修正 retail_price 架构:
   - 删除 batch-pricing 接口的 pricing_target 字段和 retail_price 分支
     (上级只能改下级成本价,不能改零售价)
   - 新增 PATCH /api/admin/packages/:id/retail-price 接口
     (代理自己改自己的零售价,校验 retail_price >= cost_price)

2. 清理旧微信 YAML 配置(已全部迁移到数据库 tb_wechat_config):
   - 删除 config.yaml 中 wechat.official_account 配置节
   - 删除 NewOfficialAccountApp() 旧工厂函数
   - 清理 personal_customer service 中的死代码(旧登录/绑定微信方法)
   - 清理 docker-compose.prod.yml 中旧微信环境变量和证书挂载注释

3. 归档四个已完成提案到 openspec/changes/archive/

4. 新增前端接口变更说明文档(docs/前端接口变更说明.md)

5. 修正归档提案和 specs 中关于 pricing_target 的错误描述
2026-03-19 17:39:43 +08:00

220 lines
8.8 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.
# card-replacement Specification
## Purpose
TBD - created by archiving change add-wallet-transfer-tag-models. Update Purpose after archive.
## Requirements
### Requirement: 换卡记录实体定义
系统 SHALL 定义换卡记录(CardReplacementRecord)实体,记录老卡到新卡的完整转移过程,包括套餐权益、代理关系、所有者信息等。
**核心概念**
- **换卡场景**:老卡损坏、丢失或故障,需要更换新卡
- **权益转移**:老卡的套餐(含剩余流量)、代理关系、所有者信息等全部转移到新卡
- **套餐继续生效**:转移后套餐不作废,剩余流量继续可用
**实体字段**
- `id`:换卡记录 ID主键BIGINT
- `replacement_no`换卡单号VARCHAR(50),唯一)
- `old_card_id`:老卡 IDBIGINT关联 tb_iot_card.id
- `old_iccid`:老卡 ICCIDVARCHAR(50),冗余存储,防止老卡被删除后无法追踪)
- `new_card_id`:新卡 IDBIGINT关联 tb_iot_card.id
- `new_iccid`:新卡 ICCIDVARCHAR(50),冗余存储)
- `old_owner_type`老卡所有者类型VARCHAR(20)
- `old_owner_id`:老卡所有者 IDBIGINT
- `old_agent_id`:老卡代理 IDBIGINT可空
- `new_owner_type`新卡所有者类型VARCHAR(20)
- `new_owner_id`:新卡所有者 IDBIGINT
- `new_agent_id`:新卡代理 IDBIGINT可空
- `package_snapshot`套餐快照JSONB记录转移时的套餐详情
- `replacement_reason`换卡原因VARCHAR(20),枚举值:"damaged"-损坏 | "lost"-丢失 | "malfunction"-故障 | "upgrade"-升级 | "other"-其他)
- `remark`备注TEXT
- `status`换卡状态INT1-待审批 2-已通过 3-已拒绝 4-已完成)
- `approved_by`:审批人 IDBIGINT可空
- `approved_at`审批时间TIMESTAMP可空
- `completed_at`完成时间TIMESTAMP可空
- `creator`:创建人 IDBIGINT
- `updater`:更新人 IDBIGINT
- `created_at`创建时间TIMESTAMP自动填充
- `updated_at`更新时间TIMESTAMP自动填充
- `deleted_at`删除时间TIMESTAMP可空软删除
**套餐快照 JSON 格式示例**
```json
{
"package_id": 3001,
"package_name": "月套餐 10GB",
"package_code": "PKG-M-001",
"data_limit_mb": 10240,
"data_usage_mb": 5120,
"real_data_usage_mb": 4000,
"virtual_data_usage_mb": 1120,
"data_remaining_mb": 5120,
"activated_at": "2026-01-01T00:00:00Z",
"expires_at": "2026-02-01T00:00:00Z",
"remaining_days": 15,
"order_id": 10001
}
```
#### Scenario: 创建换卡记录
- **WHEN** 用户ID 为 2001的老卡ICCID 为 "8986001"损坏需要换新卡ICCID 为 "8986002"
- **THEN** 系统创建换卡记录,`old_card_id` 为老卡 ID`new_card_id` 为新卡 ID`replacement_reason` 为 "damaged"`status` 为 1待审批
#### Scenario: 审批通过换卡
- **WHEN** 运营人员ID 为 999审批通过换卡记录ID 为 5001
- **THEN** 系统将换卡记录状态从 1待审批变更为 2已通过记录 `approved_by` 为 999`approved_at` 为当前时间
#### Scenario: 完成换卡
- **WHEN** 换卡记录ID 为 5001状态为 2已通过系统执行换卡操作
- **THEN** 系统将:
1. 记录老卡和新卡的快照信息(所有者、代理、套餐)
2. 将老卡的套餐权益转移到新卡(套餐使用记录的 `iot_card_id` 更新为新卡 ID
3. 将新卡的 `owner_type``owner_id` 更新为老卡的值
4. 将新卡的代理关系更新为老卡的值(如有)
5. 将换卡记录状态变更为 4已完成记录 `completed_at` 为当前时间
#### Scenario: 拒绝换卡
- **WHEN** 运营人员ID 为 999拒绝换卡记录ID 为 5001原因为"新卡不符合要求"
- **THEN** 系统将换卡记录状态从 1待审批变更为 3已拒绝记录 `approved_by` 为 999`approved_at` 为当前时间,`remark` 为拒绝原因
---
### Requirement: 套餐权益转移
系统 SHALL 在换卡完成后,将老卡的套餐权益(包括剩余流量、过期时间等)转移到新卡,套餐继续生效。
**转移内容**
- 套餐使用记录(`tb_package_usage`
- 剩余流量(`data_limit_mb - data_usage_mb`
- 套餐过期时间(`expires_at`
- 关联的订单信息
**转移规则**
- 老卡的套餐使用记录的 `iot_card_id` 更新为新卡 ID
- 剩余流量完整保留
- 套餐过期时间不变
- 如果老卡有多个套餐(正式套餐 + 加油包),全部转移
#### Scenario: 套餐转移
- **WHEN** 老卡有月套餐(剩余 5120 MB 流量,还有 15 天过期)
- **THEN** 系统将套餐使用记录的 `iot_card_id` 从老卡 ID 更新为新卡 ID流量和过期时间保持不变
#### Scenario: 多套餐转移
- **WHEN** 老卡有正式套餐和 2 个加油包
- **THEN** 系统将所有套餐使用记录的 `iot_card_id` 更新为新卡 ID所有套餐继续生效
---
### Requirement: 代理关系转移
系统 SHALL 在换卡完成后,将老卡的代理关系转移到新卡。
**转移内容**
- 新卡的 `owner_type` 更新为老卡的 `owner_type`
- 新卡的 `owner_id` 更新为老卡的 `owner_id`
- 如果老卡通过代理销售,新卡继承相同的代理关系
#### Scenario: 代理关系转移
- **WHEN** 老卡的 `owner_type` 为 "agent"`owner_id` 为 123
- **THEN** 系统将新卡的 `owner_type` 更新为 "agent"`owner_id` 更新为 123
---
### Requirement: 换卡记录查询
系统 SHALL 支持按老卡 ID、新卡 ID、用户 ID、换卡单号等条件查询换卡记录。
**查询条件**
- 换卡单号(精确匹配)
- 老卡 ID精确匹配
- 新卡 ID精确匹配
- 老卡 ICCID精确匹配或模糊匹配
- 新卡 ICCID精确匹配或模糊匹配
- 换卡状态(单选或多选)
- 换卡原因(单选或多选)
- 创建时间范围
- 完成时间范围
**分页**
- 默认每页 20 条,最大每页 100 条
- 返回总记录数和总页数
#### Scenario: 按老卡 ICCID 查询换卡记录
- **WHEN** 查询老卡 ICCID 为 "8986001" 的换卡记录
- **THEN** 系统返回所有 `old_iccid` 为 "8986001" 的换卡记录列表
#### Scenario: 按状态查询换卡记录
- **WHEN** 查询状态为 1待审批的换卡记录
- **THEN** 系统返回所有 `status` 为 1 的换卡记录列表,按创建时间倒序排列
---
### Requirement: 换卡数据校验
系统 SHALL 对换卡数据进行校验,确保数据完整性和一致性。
**校验规则**
- `old_card_id`:必填,≥ 1必须是有效的 IoT 卡 ID
- `new_card_id`:必填,≥ 1必须是有效的 IoT 卡 ID不能与 `old_card_id` 相同
- `old_iccid`:必填,长度 19-20 字符
- `new_iccid`:必填,长度 19-20 字符,不能与 `old_iccid` 相同
- `replacement_reason`:必填,枚举值 "damaged" | "lost" | "malfunction" | "upgrade" | "other"
- `status`:必填,枚举值 1-4
#### Scenario: 换卡时老卡和新卡相同
- **WHEN** 创建换卡记录,`old_card_id``new_card_id` 都为 1001
- **THEN** 系统拒绝创建,返回错误信息"新卡不能与老卡相同"
#### Scenario: 换卡时新卡 ICCID 无效
- **WHEN** 创建换卡记录,`new_iccid` 长度为 15小于 19
- **THEN** 系统拒绝创建,返回错误信息"ICCID 长度必须为 19-20 字符"
#### Scenario: 换卡时老卡不存在
- **WHEN** 创建换卡记录,`old_card_id` 为 99999不存在的 IoT 卡)
- **THEN** 系统拒绝创建,返回错误信息"老卡不存在"
---
### Requirement: 废弃旧换卡模型能力
系统 MUST 废弃 `CardReplacementRecord` 作为主业务能力,原因是其仅覆盖卡换卡且缺少收货信息、物流信息、设备换货与全量迁移能力,无法满足当前换货闭环需求。
#### Scenario: 新换货流程不再写入旧模型
- **WHEN** 执行任意新换货流程H1~H7、G1~G2
- **THEN** 系统 MUST 仅读写 `ExchangeOrder`,不再创建 `CardReplacementRecord` 新记录
---
### Requirement: 旧表迁移为 legacy 保留查询
系统 SHALL 将 `tb_card_replacement_record` 改名为 `tb_card_replacement_record_legacy`,仅用于历史查询保留。
系统 MUST NOT 将 legacy 数据回灌到 `tb_exchange_order`
#### Scenario: legacy 数据保留但不参与新流程
- **WHEN** 运营查询历史老换卡记录
- **THEN** 系统可从 legacy 表读取历史数据,但新换货流程 SHALL 不依赖该表
---
### Requirement: 旧代码引用替换
系统 MUST 将旧换卡引用替换为 `ExchangeOrder`,包括 `iot_card_store.go``is_replaced` 过滤逻辑。
#### Scenario: is_replaced 基于新换货单判定
- **WHEN** 查询 IoT 卡并使用 `is_replaced=true` 过滤
- **THEN** 系统 MUST 基于 `ExchangeOrder` 状态判定是否已发生换货,而非 legacy 表