All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m12s
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 的错误描述
220 lines
8.8 KiB
Markdown
220 lines
8.8 KiB
Markdown
# 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`:老卡 ID(BIGINT,关联 tb_iot_card.id)
|
||
- `old_iccid`:老卡 ICCID(VARCHAR(50),冗余存储,防止老卡被删除后无法追踪)
|
||
- `new_card_id`:新卡 ID(BIGINT,关联 tb_iot_card.id)
|
||
- `new_iccid`:新卡 ICCID(VARCHAR(50),冗余存储)
|
||
- `old_owner_type`:老卡所有者类型(VARCHAR(20))
|
||
- `old_owner_id`:老卡所有者 ID(BIGINT)
|
||
- `old_agent_id`:老卡代理 ID(BIGINT,可空)
|
||
- `new_owner_type`:新卡所有者类型(VARCHAR(20))
|
||
- `new_owner_id`:新卡所有者 ID(BIGINT)
|
||
- `new_agent_id`:新卡代理 ID(BIGINT,可空)
|
||
- `package_snapshot`:套餐快照(JSONB,记录转移时的套餐详情)
|
||
- `replacement_reason`:换卡原因(VARCHAR(20),枚举值:"damaged"-损坏 | "lost"-丢失 | "malfunction"-故障 | "upgrade"-升级 | "other"-其他)
|
||
- `remark`:备注(TEXT)
|
||
- `status`:换卡状态(INT,1-待审批 2-已通过 3-已拒绝 4-已完成)
|
||
- `approved_by`:审批人 ID(BIGINT,可空)
|
||
- `approved_at`:审批时间(TIMESTAMP,可空)
|
||
- `completed_at`:完成时间(TIMESTAMP,可空)
|
||
- `creator`:创建人 ID(BIGINT)
|
||
- `updater`:更新人 ID(BIGINT)
|
||
- `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 表
|
||
|