fix: 修正零售价架构错误 + 清理旧微信配置 + 归档提案 + 前端接口文档
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 的错误描述
This commit is contained in:
2026-03-19 17:39:43 +08:00
parent 9bd55a1695
commit b9733c4913
98 changed files with 3665 additions and 571 deletions

View File

@@ -0,0 +1,54 @@
# agent-retail-price Specification
## Purpose
TBD - created by archiving change client-api-data-model-fixes. Update Purpose after archive.
## Requirements
### Requirement: 分配零售价字段定义
系统 MUST 在 `ShopPackageAllocation` 新增 `retail_price bigint NOT NULL DEFAULT 0` 字段。
#### Scenario: 新字段存在且非空
- **WHEN** 执行分配记录建表或迁移
- **THEN** `retail_price` MUST 为非空整型字段,默认值为 `0`
---
### Requirement: 分配创建默认零售价规则
系统 MUST 在创建分配记录时将 `retail_price` 自动设置为对应 `Package.SuggestedRetailPrice`
#### Scenario: 创建分配自动带出建议零售价
- **WHEN** 平台给代理创建套餐分配记录
- **THEN** 新记录的 `retail_price` MUST 等于该套餐的 `suggested_retail_price`
---
### Requirement: 零售价约束规则
系统 MUST 强制校验:`retail_price >= cost_price`
#### Scenario: 零售价低于成本价
- **WHEN** 代理设置 `retail_price < cost_price`
- **THEN** 系统 MUST 拒绝保存并返回价格约束错误
### Requirement: 成本价分配锁定规则
当某分配存在下级分配记录时,系统 MUST 禁止修改该分配的 `cost_price`
#### Scenario: 存在下级分配时修改成本价
- **WHEN** 上级分配记录已被继续分配到下级店铺
- **THEN** 系统 MUST 拒绝对该记录的 `cost_price` 修改
---
### Requirement: 代理零售价可调与存量迁移
系统 MUST 提供独立接口 `PATCH /api/admin/packages/:id/retail-price` 供代理修改自己分配记录的 `retail_price`(在约束范围内);系统 MUST 对存量数据执行迁移:将 `retail_price` 批量更新为对应套餐的 `SuggestedRetailPrice`
#### Scenario: 代理调整自己的零售价
- **WHEN** 代理修改自己分配记录的 `retail_price` 且满足价格约束
- **THEN** 系统 MUST 允许更新
#### Scenario: 存量数据回填零售价
- **WHEN** 执行本次数据迁移
- **THEN** 系统 MUST 将历史 `ShopPackageAllocation.retail_price` 批量更新为对应套餐的 `SuggestedRetailPrice`

View File

@@ -0,0 +1,59 @@
# asset-generation Specification
## Purpose
TBD - created by archiving change client-api-data-model-fixes. Update Purpose after archive.
## 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` 过滤。
本提案阶段 MUST 仅新增字段定义,具体过滤逻辑在后续提案实现。
#### Scenario: 客户端按代际查看历史
- **WHEN** 客户端请求携带指定 `generation`
- **THEN** 系统 MUST 仅返回该代际的数据(在后续提案中实现)
#### Scenario: 后台查询不按代际裁剪
- **WHEN** 管理端查询订单或充值记录且未显式指定 `generation`
- **THEN** 系统 MUST 返回全部代际数据
---
### Requirement: 钱包流水不引入代际字段
系统 MUST NOT 在钱包流水相关表新增 `generation` 字段,因为钱包流水已通过 `wallet_id` 天然隔离。
#### Scenario: 钱包流水按钱包隔离
- **WHEN** 查询某资产钱包流水
- **THEN** 系统 MUST 仅依赖 `wallet_id` 完成数据隔离,不新增 `generation` 参与过滤

View File

@@ -0,0 +1,45 @@
# asset-lifecycle-status Specification
## Purpose
TBD - created by archiving change client-api-data-model-fixes. Update Purpose after archive.
## 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 仅新增字段与常量定义,状态流转逻辑(导入→在库、首次绑定/分配→已销售、换货完成→已换货、转新→在库且代际+1、手动停用→已停用在后续提案实现。
#### Scenario: 网络状态变化不影响资产状态
- **WHEN** Gateway 同步将 `network_status` 从开机改为停机
- **THEN** 系统 MUST 保持 `asset_status` 不变
#### Scenario: 资产状态变化不强制修改网络状态
- **WHEN** 管理端将资产手动停用(`asset_status=4`
- **THEN** 系统 MUST 不自动改写 `network_status`

View File

@@ -1,5 +1,8 @@
## MODIFIED Requirements
# asset-recharge-adaptation Specification
## Purpose
定义资产充值IoT 卡/设备钱包充值)的完整规范:支付配置关联、充值记录表结构变更、回调验签流程及钱包常量从 Card 前缀统一重命名为 Asset 前缀。
## Requirements
### Requirement: 资产充值关联支付配置
系统 SHALL 在创建资产充值订单时记录当前生效的支付配置 ID用于回调处理时加载正确的配置验签。
@@ -76,28 +79,33 @@ Content-Type: application/json
### Requirement: 资产充值表结构变更
`tb_asset_recharge_record` 新增字段:
系统 MUST 在 `tb_asset_recharge_record` 新增以下字段,用于关联支付配置。
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `payment_config_id` | bigint | ❌ | 创建充值订单时使用的微信参数配置 ID支付宝支付时为 NULL |
#### Scenario: 新建充值记录含 payment_config_id 字段
- **WHEN** 个人客户创建微信充值订单
- **THEN** 系统 MUST 将当前生效的微信参数配置 ID 写入 `payment_config_id` 字段
---
### Requirement: 资产充值回调按配置验签
- **WHEN** 收到支付回调(微信或富友),订单号前缀为 `CRCH`
- **THEN** 系统查询 `tb_asset_recharge_record`,通过 `payment_config_id` 加载对应配置
- **THEN** 使用该配置的凭证验签
- **THEN** 验签通过后调用 `rechargeService.HandlePaymentCallback()`
系统 MUST 在处理资产充值支付回调时,通过 `payment_config_id` 加载对应配置并使用该配置验签。
> **注意**:当前代码中 `callback/payment.go` 使用废弃的 `RechargeOrderPrefix = "RCH"` 进行前缀匹配,需修复为 `AssetRechargeOrderPrefix = "CRCH"`。
#### Scenario: 收到充值回调按配置验签
- **WHEN** 收到支付回调(微信或富友),订单号前缀为 `CRCH`
- **THEN** 系统 MUST 查询 `tb_asset_recharge_record`,通过 `payment_config_id` 加载对应配置
- **THEN** 系统 MUST 使用该配置的凭证验签
- **THEN** 验签通过后调用 `rechargeService.HandlePaymentCallback()`
---
### Requirement: 常量重命名Card → Asset
`pkg/constants/wallet.go` 中以下常量从 `Card` 前缀重命名为 `Asset` 前缀
系统 MUST 将 `pkg/constants/wallet.go` 中以下常量从 `Card` 前缀重命名为 `Asset` 前缀,旧常量保留为废弃别名。
| 旧名称 | 新名称 |
|--------|--------|
@@ -113,4 +121,30 @@ Content-Type: application/json
| `CardRechargeMinAmount` | `AssetRechargeMinAmount` |
| `CardRechargeMaxAmount` | `AssetRechargeMaxAmount` |
`Card*` 常量保留为废弃别名,添加 `Deprecated` 注释。段落标题 `卡钱包常量``资产钱包常量`
#### Scenario: 新代码使用 Asset 前缀常量
- **WHEN** 业务代码引用钱包资源类型或充值相关常量
- **THEN** 系统 MUST 使用 `Asset*` 前缀常量,`Card*` 常量标注 `Deprecated`
### Requirement: 资产充值记录扩展字段(操作人与代际)
系统 MUST 在 `tb_asset_recharge_record` 新增以下字段:
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `operator_type` | varchar(20) | ✅ | 操作人类型,枚举 `admin_user` / `personal_customer`,默认 `admin_user` |
| `generation` | int | ✅ | 资产代际,默认 `1` |
| `linked_package_ids` | jsonb | ❌ | 关联套餐 ID 列表,默认 `'[]'` |
| `linked_order_type` | varchar(20) | ❌ | 关联订单类型 |
| `linked_carrier_type` | varchar(20) | ❌ | 关联载体类型(如 iot_card/device |
| `linked_carrier_id` | bigint | ❌ | 关联载体 ID |
#### Scenario: 新建充值记录默认字段值
- **WHEN** 系统创建新的资产充值记录且未显式传入新增字段
- **THEN** `operator_type` MUST 默认为 `admin_user`
- **THEN** `generation` MUST 默认为 `1`
- **THEN** `linked_package_ids` MUST 默认为空数组 `[]`
#### Scenario: 写入关联上下文信息
- **WHEN** 充值记录由订单或套餐联动产生
- **THEN** 系统 MUST 可写入 `linked_order_type``linked_carrier_type``linked_carrier_id` 作为关联上下文

View File

@@ -185,3 +185,35 @@ TBD - created by archiving change add-wallet-transfer-tag-models. Update Purpose
- **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 表

View File

@@ -0,0 +1,48 @@
# carrier-realname-config Specification
## Purpose
TBD - created by archiving change client-api-data-model-fixes. Update Purpose after archive.
## Requirements
### Requirement: 运营商实名链接配置字段定义
系统 MUST 在 Carrier 模型新增以下字段:
- `realname_link_type varchar(20) NOT NULL DEFAULT 'none'`
- `realname_link_template varchar(500) DEFAULT ''`
#### Scenario: 默认配置为不支持在线实名
- **WHEN** 创建新的运营商记录且未显式设置实名链接配置
- **THEN** 系统 MUST 将 `realname_link_type` 设为 `none``realname_link_template` 设为空字符串
---
### Requirement: 实名链接三种模式
系统 MUST 支持并仅支持以下实名链接模式:
- `none`:不支持在线实名
- `template`:使用模板 URL 生成实名链接
- `gateway`:通过 Gateway 接口动态获取实名链接
#### Scenario: none 模式
- **WHEN** `realname_link_type=none`
- **THEN** 系统 MUST 视为不支持在线实名跳转
#### Scenario: template 模式
- **WHEN** `realname_link_type=template`
- **THEN** 系统 MUST 使用 `realname_link_template` 作为实名链接模板
#### Scenario: gateway 模式
- **WHEN** `realname_link_type=gateway`
- **THEN** 系统 MUST 通过 Gateway 能力获取实名链接
---
### Requirement: 模板占位符规则
`realname_link_type=template` 时,系统 MUST 支持模板中的占位符 `{iccid}``{msisdn}``{virtual_no}`
本提案阶段 MUST 仅新增字段,不实现实名跳转接口逻辑。
#### Scenario: 模板占位符可被解析
- **WHEN** 模板 URL 包含 `{iccid}``{msisdn}``{virtual_no}`
- **THEN** 系统 MUST 在后续实名跳转实现中按占位符语义进行参数替换

View File

@@ -0,0 +1,41 @@
# Capability: 客户端资产信息
## ADDED Requirements
### Requirement: B1 资产基本信息查询接口
系统 SHALL 提供 `GET /api/c/v1/asset/info?identifier=xxx`,并且 MUST 要求个人客户认证C 端 Token。接口 MUST 复用 `asset.Service.Resolve()` 解析标识符,并在调用时使用 `gorm.SkipDataPermission(ctx)` 以绕过 shop_id 数据权限过滤。请求参数 MUST 包含 `identifier`ICCID、虚拟号、设备号之一。响应体 SHALL 返回 `asset_type``asset_id``identifier``virtual_no``status``real_name_status``carrier``generation``wallet_balance`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ASSET_NOT_FOUND/资产不存在`
#### Scenario: 个人客户查询已绑定资产
- **WHEN** 客户携带有效 Token 调用 `GET /api/c/v1/asset/info?identifier=8986xxxx` 且资产已绑定到本人
- **THEN** 系统返回 200包含资产基础信息与当前 generation
---
### Requirement: B2 可购买套餐列表接口
系统 SHALL 提供 `GET /api/c/v1/asset/packages?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 在归属校验通过后返回可购买套餐列表。价格规则 MUST 为:代理渠道取 `allocation.retail_price`,平台渠道取 `Package.SuggestedRetailPrice`。过滤规则 MUST 同时满足:`Package.status=1``shelf_status` 可售、加油包前置主套餐条件成立、`retail_price >= cost_price`。结果 MUST 按展示价格升序。响应体 SHALL 包含 `packages[]`,每项至少含 `package_id``package_name``package_type``retail_price``cost_price``validity``is_addon`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``PACKAGE_NOT_AVAILABLE/当前无可购买套餐`
#### Scenario: 代理渠道价格与过滤生效
- **WHEN** 客户查询可购套餐且其销售链路为代理渠道,部分套餐存在 `retail_price < cost_price`
- **THEN** 系统仅返回可售且满足价格约束的套餐,并按价格升序输出
---
### Requirement: B3 历史套餐列表接口
系统 SHALL 提供 `GET /api/c/v1/asset/package-history?identifier=xxx&page=1&page_size=20`,并且 MUST 要求个人客户认证。接口 MUST 基于标识符解析资产并进行归属校验。查询条件 MUST 自动追加 `generation = 资产当前generation`。请求参数 SHALL 支持 `page``page_size`(默认 20最大 100。响应体 SHALL 返回 `list[]``total``page``page_size`,列表项复用 `dto.AssetPackageResponse` 结构。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在`
#### Scenario: 转手后历史隔离
- **WHEN** 资产已发生转手且存在历史套餐记录
- **THEN** 系统只返回当前 generation 的记录,不返回旧 generation 数据
---
### Requirement: B4 手动刷新接口
系统 SHALL 提供 `POST /api/c/v1/asset/refresh`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier`。当资产为卡时 MUST 调用 Gateway 刷新卡信息;当资产为设备时 MUST 先检查 Redis 冷却窗口,再对设备下卡执行批量刷新。响应体 SHALL 返回 `refresh_type``card`/`device`)、`accepted``cooldown_seconds`(设备场景)。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``TOO_MANY_REQUESTS/刷新过于频繁,请稍后重试``GATEWAY_ERROR/网关调用失败`
#### Scenario: 设备刷新冷却拦截
- **WHEN** 客户在冷却时间内重复调用设备刷新
- **THEN** 系统返回频率限制错误并告知剩余冷却时间

View File

@@ -0,0 +1,73 @@
# client-asset-token Specification
## Purpose
TBD - created by archiving change client-auth-system. Update Purpose after archive.
## Requirements
### Requirement: A1 资产标识符验证接口
系统 MUST 提供无认证资产验证接口 `POST /api/c/v1/auth/verify-asset`,用于将外部资产标识符兑换为短时效 `asset_token`
- HTTP Method + Path: `POST /api/c/v1/auth/verify-asset`
- 请求体字段:
- `identifier` stringMUST资产标识符SN/IMEI/虚拟号/ICCID/MSISDN
- 响应体字段:
- `asset_token` stringMUST5 分钟有效
- `expires_in` intMUST单位秒
- 错误码:
- `1006` 参数错误(标识符为空或格式非法)
- `1404` 资产不存在
- `1003` 请求过于频繁
#### Scenario: 资产验证成功并返回 asset_token
- **WHEN** 客户端提交合法且存在的资产标识符
- **THEN** 系统 SHALL 解析并定位资产
- **THEN** 系统 SHALL 签发 5 分钟有效的 `asset_token`
- **THEN** 系统 SHALL 返回 `{asset_token, expires_in}`
#### Scenario: 输入参数非法
- **WHEN** 客户端提交空字符串或不支持格式的标识符
- **THEN** 系统 MUST 返回参数错误码 `1006`
### Requirement: A1 输入校验与安全约束
系统 SHALL 对标识符进行白名单校验,并在 A1 响应中禁止暴露内部 `asset_id`
- 输入校验规则:
- MUST 去除前后空格并做长度限制
- MUST 仅允许预定义字符集(数字、字母、必要分隔符)
- MUST 拒绝 SQL 片段/控制字符
- 输出安全规则:
- MUST NOT 返回 `asset_id`
- MUST NOT 返回内部表名/字段名
#### Scenario: 防止内部主键泄露
- **WHEN** A1 接口返回成功响应
- **THEN** 返回体 MUST 只包含 `asset_token` 与有效期信息
- **THEN** 返回体 MUST NOT 包含 `asset_id`
### Requirement: A1 资产令牌签发规范
`asset_token` SHALL 使用独立签名密钥签发,且 payload 仅包含 `asset_type``asset_id`
- JWT 约束:
- `exp` = 当前时间 + 5 分钟
- payload MUST 包含 `asset_type``asset_id`
- payload MUST NOT 包含手机号、OpenID 等敏感信息
#### Scenario: token 结构与时效符合规范
- **WHEN** 服务端签发 `asset_token`
- **THEN** token MUST 使用资产令牌专用签名密钥
- **THEN** token MUST 在 5 分钟后过期
### Requirement: A1 IP 级限频
系统 SHALL 对 A1 实施 IP 维度限频:`30 次/分钟`
#### Scenario: 限频内请求通过
- **WHEN** 同一 IP 在 1 分钟内请求次数不超过 30 次
- **THEN** 系统 SHALL 正常处理请求
#### Scenario: 超过限频阈值
- **WHEN** 同一 IP 在 1 分钟内请求次数超过 30 次
- **THEN** 系统 MUST 返回错误码 `1003`

View File

@@ -0,0 +1,51 @@
# Capability: 客户端设备能力
## ADDED Requirements
### Requirement: F1 设备卡列表接口
系统 SHALL 提供 `GET /api/c/v1/device/cards?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 仅允许设备类型资产调用,且设备 MUST 具备 IMEI。响应体 SHALL 返回 `cards[]`,每项至少包含:`card_id``iccid``msisdn``carrier_name``network_status``real_name_status``slot_position``is_active`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ASSET_TYPE_INVALID/仅设备资产支持该操作``DEVICE_IMEI_REQUIRED/设备IMEI缺失`
#### Scenario: 返回设备绑定卡列表
- **WHEN** 客户查询已绑定设备卡列表
- **THEN** 系统返回设备下全部卡及活跃标记
---
### Requirement: F2 设备重启接口
系统 SHALL 提供 `POST /api/c/v1/device/reboot`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier`。接口 MUST 仅允许设备类型且 IMEI 存在,并调用 `gateway.RebootDevice(imei)`。响应体 SHALL 返回 `accepted=true``request_id`(如网关返回)。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ASSET_TYPE_INVALID/仅设备资产支持该操作``DEVICE_IMEI_REQUIRED/设备IMEI缺失``GATEWAY_ERROR/网关调用失败`
#### Scenario: 设备重启成功受理
- **WHEN** 客户对合法设备发起重启
- **THEN** 系统调用网关成功并返回受理结果
---
### Requirement: F3 设备恢复出厂接口
系统 SHALL 提供 `POST /api/c/v1/device/factory-reset`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier`。接口 MUST 仅允许设备类型且 IMEI 存在,并调用 `gateway.ResetDevice(imei)`。响应体 SHALL 返回 `accepted=true`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ASSET_TYPE_INVALID/仅设备资产支持该操作``DEVICE_IMEI_REQUIRED/设备IMEI缺失``GATEWAY_ERROR/网关调用失败`
#### Scenario: 恢复出厂失败返回网关错误
- **WHEN** 网关返回失败
- **THEN** 系统返回网关调用失败错误
---
### Requirement: F4 设备 WiFi 设置接口
系统 SHALL 提供 `POST /api/c/v1/device/wifi`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier``ssid``password``enabled`。接口 MUST 仅允许设备类型且 IMEI 存在,并调用 `gateway.SetWiFi(imei, ssid, password, enabled)`。实现 MUST 将 Gateway 的 `WiFiReq.cardNo` 填充为设备 IMEI。响应体 SHALL 返回 `accepted=true`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ASSET_TYPE_INVALID/仅设备资产支持该操作``DEVICE_IMEI_REQUIRED/设备IMEI缺失``GATEWAY_ERROR/网关调用失败`
#### Scenario: WiFi 请求 cardNo 使用 IMEI
- **WHEN** 客户调用设备 WiFi 设置
- **THEN** 系统向网关发送的 `cardNo` 字段值为设备 IMEI
---
### Requirement: F5 设备切卡接口
系统 SHALL 提供 `POST /api/c/v1/device/switch-card`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier``target_iccid`。接口 MUST 仅允许设备类型且 IMEI 存在,并调用 `gateway.SwitchCard(imei, target_iccid)`。响应体 SHALL 返回 `accepted=true``target_iccid`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ASSET_TYPE_INVALID/仅设备资产支持该操作``DEVICE_IMEI_REQUIRED/设备IMEI缺失``GATEWAY_ERROR/网关调用失败`
#### Scenario: 切卡成功返回目标卡号
- **WHEN** 客户请求切换到目标 ICCID 且网关执行成功
- **THEN** 系统返回 `accepted=true` 与目标 ICCID

View File

@@ -0,0 +1,46 @@
# Capability: 客户端套餐购买
## ADDED Requirements
### Requirement: D1 创建套餐购买订单接口
系统 SHALL 提供 `POST /api/c/v1/orders/create`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier``package_ids[]``app_type`。接口流程 MUST 按顺序执行:归属校验 → 套餐校验(含加油包前置)→ 实名校验 → OpenID 查询 → 幂等检查 → 强充检查 → 分流创建。实名不满足时 MUST 返回 `NEED_REALNAME`。OpenID 缺失时 MUST 返回 `OPENID_NOT_FOUND`。幂等 MUST 使用 Redis 业务键 + 分布式锁。分流规则 MUST 为:
- 无强充:创建套餐订单并返回 `order_type="package"``order``pay_config`
- 需强充:创建充值单并返回 `order_type="recharge"``recharge``pay_config``linked_package_info`
响应体 MUST 包含前端可直接渲染字段。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``NEED_REALNAME/该套餐需实名认证后购买``OPENID_NOT_FOUND/未找到微信授权信息,请先完成授权``IDEMPOTENT_CONFLICT/请求处理中,请勿重复提交``PACKAGE_NOT_AVAILABLE/套餐不可购买`
#### Scenario: 命中强充返回 recharge 结构
- **WHEN** 客户购买套餐触发强充要求
- **THEN** 系统返回 `order_type="recharge"`,包含充值单与关联套餐信息
---
### Requirement: D2 套餐订单列表接口
系统 SHALL 提供 `GET /api/c/v1/orders?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 做归属校验并按资产当前 generation 过滤订单。请求参数 SHALL 支持 `payment_status``page``page_size`。响应体 SHALL 返回 `list[]``total``page``page_size`,列表项至少含 `order_id``order_no``total_amount``payment_status``created_at`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在`
#### Scenario: 支持支付状态筛选
- **WHEN** 客户带 `payment_status=paid` 查询订单
- **THEN** 系统仅返回当前 generation 且支付状态匹配的订单
---
### Requirement: D3 套餐订单详情接口
系统 SHALL 提供 `GET /api/c/v1/orders/:id`,并且 MUST 要求个人客户认证。接口 MUST 基于订单关联资产执行归属校验(通过资产虚拟号匹配 `PersonalCustomerDevice`)。响应体 SHALL 返回订单详情、套餐明细、支付信息、状态流转时间。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``ORDER_NOT_FOUND/订单不存在`
#### Scenario: 查询他人订单被拦截
- **WHEN** 客户请求不属于本人资产的订单详情
- **THEN** 系统返回 403错误消息为无权限操作该资产或资源不存在
---
### Requirement: AutoPurchaseAfterRecharge 异步任务
系统 SHALL 增加 `AutoPurchaseAfterRecharge` Asynq 任务处理强充二阶段。任务输入 MUST 包含 `recharge_record_id`。处理流程 MUST 为:从钱包扣款(`payment_method=wallet`)→ 创建套餐订单(`source="client"`、写入当前 generation→ 激活套餐。任务失败 MUST 自动重试,最大 3 次。全部失败后 MUST 将 `auto_purchase_status` 标记为 `failed`,并保留钱包余额供用户手动购买。成功时 MUST 标记为 `success`
#### Scenario: 异步任务连续失败
- **WHEN** AutoPurchaseAfterRecharge 连续执行失败且达到最大重试次数
- **THEN** 系统将充值记录 `auto_purchase_status` 更新为 `failed`

View File

@@ -0,0 +1,96 @@
# client-phone-binding Specification
## Purpose
TBD - created by archiving change client-auth-system. Update Purpose after archive.
## Requirements
### Requirement: A4 发送验证码接口
系统 MUST 提供无认证验证码接口 `POST /api/c/v1/auth/send-code`,并复用现有验证码服务。
- HTTP Method + Path: `POST /api/c/v1/auth/send-code`
- 请求体字段:
- `phone` stringMUST手机号
- `scene` stringMUST业务场景`bind_phone` / `change_phone_old` / `change_phone_new`
- 响应体字段:
- `cooldown_seconds` intMUST本次发送后的冷却秒数
- 错误码:
- `1006` 参数错误
- `1003` 请求过于频繁(触发任一限流)
- `1050` 短信发送失败
#### Scenario: 发送成功
- **WHEN** 手机号格式合法且未触发限流
- **THEN** 系统 SHALL 发送验证码并返回冷却时间
### Requirement: A4 限频规则
系统 SHALL 对 A4 实施三层限频:手机号 60 秒冷却、同 IP 每小时 20 次、同手机号每日 10 次。
#### Scenario: 60 秒内重复发送
- **WHEN** 同一手机号在 60 秒冷却内再次请求
- **THEN** 系统 MUST 返回 `1003`
#### Scenario: 同 IP 超过小时阈值
- **WHEN** 同一 IP 在 1 小时内发送次数超过 20
- **THEN** 系统 MUST 返回 `1003`
#### Scenario: 同手机号超过日阈值
- **WHEN** 同一手机号在当日发送次数超过 10
- **THEN** 系统 MUST 返回 `1003`
### Requirement: A5 首次绑定手机号接口
系统 MUST 提供需认证接口 `POST /api/c/v1/auth/bind-phone`,仅允许首次绑定。
- HTTP Method + Path: `POST /api/c/v1/auth/bind-phone`
- 请求体字段:
- `phone` stringMUST新手机号
- `code` stringMUST验证码
- 响应体字段:
- `phone` stringMUST已绑定手机号
- `bound_at` stringMUST绑定时间
- 错误码:
- `1001` 缺失认证令牌
- `1002` 认证令牌无效
- `1006` 参数错误
- `1035` 验证码错误或过期
- `1037` 手机号已被绑定
- `1038` 已绑定手机号不可重复绑定
#### Scenario: 首次绑定成功
- **WHEN** 客户已登录、验证码正确且手机号未被占用
- **THEN** 系统 SHALL 完成手机号首次绑定并返回绑定信息
#### Scenario: 已绑定用户再次调用绑定
- **WHEN** 当前客户已存在绑定手机号
- **THEN** 系统 MUST 返回 `1038`
### Requirement: A6 换绑手机号接口
系统 MUST 提供需认证接口 `POST /api/c/v1/auth/change-phone`,并执行旧手机号与新手机号双验证码校验。
- HTTP Method + Path: `POST /api/c/v1/auth/change-phone`
- 请求体字段:
- `old_phone` stringMUST旧手机号
- `old_code` stringMUST旧手机号验证码
- `new_phone` stringMUST新手机号
- `new_code` stringMUST新手机号验证码
- 响应体字段:
- `phone` stringMUST换绑后的手机号
- `changed_at` stringMUST换绑时间
- 错误码:
- `1001` 缺失认证令牌
- `1002` 认证令牌无效
- `1006` 参数错误
- `1035` 验证码错误或过期
- `1037` 新手机号已被绑定
- `1039` 旧手机号不匹配
#### Scenario: 换绑成功
- **WHEN** 登录客户提交正确旧/新验证码且新手机号未占用
- **THEN** 系统 SHALL 更新绑定手机号为新手机号
#### Scenario: 旧手机号校验失败
- **WHEN** `old_phone` 与当前客户绑定手机号不一致或 `old_code` 错误
- **THEN** 系统 MUST 拒绝换绑并返回对应错误码

View File

@@ -0,0 +1,23 @@
# Capability: 客户端实名跳转
## ADDED Requirements
### Requirement: E1 获取实名跳转链接接口
系统 SHALL 提供 `GET /api/c/v1/realname/link?identifier=xxx&iccid=xxx`,并且 MUST 要求个人客户认证。该接口 MUST 支持两类入口:购买拦截入口与设备卡列表主动入口。目标卡定位 MUST 支持三种路径:
1. 标识符直达卡:直接使用该卡
2. 标识符为设备且传 `iccid`:定位对应设备下卡
3. 标识符为设备且未传 `iccid`:定位设备当前活跃卡
`real_name_status=1` 时 MUST 返回“该卡已完成实名”错误。运营商实名模式 MUST 支持:
- `none`:不支持在线实名,直接报错
- `template`:按模板替换占位符 `{iccid}` `{msisdn}` `{virtual_no}` 返回 URL
- `gateway`:调用网关获取实名链接
响应体 SHALL 至少包含 `realname_mode``realname_url``card_info{iccid,msisdn,virtual_no}``expire_at`(可空)。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在``REALNAME_ALREADY_DONE/该卡已完成实名``REALNAME_NOT_SUPPORTED/该运营商暂不支持在线实名``GATEWAY_ERROR/获取实名链接失败`
#### Scenario: 设备未传 iccid 自动选活跃卡
- **WHEN** 客户传入设备标识符且不传 `iccid`
- **THEN** 系统自动选择设备活跃卡并返回实名跳转链接

View File

@@ -0,0 +1,59 @@
# client-token-management Specification
## Purpose
TBD - created by archiving change client-auth-system. Update Purpose after archive.
## Requirements
### Requirement: 登录 JWT 签发与 Redis 状态存储
系统 MUST 在 A2/A3 登录成功后签发个人客户 JWT并将 token 状态写入 Redis。
- JWT payload 字段:
- `customer_id` uintMUST
- `exp` int64MUST
- Redis Key`RedisPersonalCustomerTokenKey(customerID)`
- Redis Value当前有效 token或 token 集合,取决于实现)
- TTLMUST 与 JWT 过期时间一致
#### Scenario: 登录成功写入 Redis
- **WHEN** 客户完成微信登录
- **THEN** 系统 SHALL 签发 JWT
- **THEN** 系统 SHALL 将 token 写入 Redis 并设置 TTL
### Requirement: PersonalAuthMiddleware 双重校验
系统 SHALL 在个人客户认证中间件执行双重校验JWT 解析校验 + Redis 状态校验。
#### Scenario: JWT 与 Redis 均有效
- **WHEN** 请求携带有效 JWT 且 Redis 中存在有效状态
- **THEN** 中间件 SHALL 放行并写入 `customer_id` 到上下文
#### Scenario: JWT 有效但 Redis 不存在
- **WHEN** JWT 仍在有效期但 Redis 中不存在该客户 token 状态
- **THEN** 中间件 MUST 返回未认证错误 `1002`
### Requirement: A7 退出登录接口
系统 MUST 提供需认证接口 `POST /api/c/v1/auth/logout`,用于删除 Redis token 状态。
- HTTP Method + Path: `POST /api/c/v1/auth/logout`
- 请求体字段:无
- 响应体字段:
- `success` boolMUST
- 错误码:
- `1001` 缺失认证令牌
- `1002` 认证令牌无效
#### Scenario: 退出登录成功
- **WHEN** 登录客户调用 A7
- **THEN** 系统 SHALL 删除 `RedisPersonalCustomerTokenKey(customerID)`
- **THEN** 系统 SHALL 返回成功
### Requirement: 服务端主动失效能力
系统 MUST 支持服务端主动使 token 失效(如封禁/强制下线),且无需等待 JWT 自然过期。
#### Scenario: 服务端主动踢出
- **WHEN** 管理动作触发客户强制下线
- **THEN** 系统 SHALL 删除对应 Redis token 状态
- **THEN** 该客户后续请求 MUST 被中间件拒绝

View File

@@ -0,0 +1,51 @@
# Capability: 客户端钱包与充值
## ADDED Requirements
### Requirement: C1 钱包详情接口
系统 SHALL 提供 `GET /api/c/v1/wallet/detail?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 先完成资产解析与归属校验;钱包不存在时 MUST 自动创建空钱包。响应体 SHALL 包含 `wallet_id``resource_type``resource_id``balance``frozen_balance``updated_at`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在`
#### Scenario: 首次访问自动建钱包
- **WHEN** 客户查询资产钱包详情且钱包记录不存在
- **THEN** 系统自动创建钱包并返回余额 0
---
### Requirement: C2 钱包流水列表接口
系统 SHALL 提供 `GET /api/c/v1/wallet/transactions?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 通过归属校验解析出唯一 `wallet_id` 后查询流水,实现天然隔离。请求参数 SHALL 支持 `transaction_type``start_time``end_time``page``page_size`。响应体 SHALL 包含 `list[]``total``page``page_size`,每条记录至少含 `transaction_id``type``amount``balance_after``created_at``remark`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在`
#### Scenario: wallet_id 隔离生效
- **WHEN** 客户查询某资产流水
- **THEN** 系统仅返回该资产钱包对应流水,不返回其他钱包数据
---
### Requirement: C3 充值预检接口
系统 SHALL 提供 `GET /api/c/v1/wallet/recharge-check?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 复用 `recharge.Service.GetRechargeCheck()` 计算强充规则。响应体 SHALL 包含 `need_force_recharge``force_recharge_amount``trigger_type``min_amount``max_amount``message`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在`
#### Scenario: 返回强充预检结果
- **WHEN** 资产命中强充规则
- **THEN** 系统返回 `need_force_recharge=true` 与对应强充金额和触发类型
---
### Requirement: C4 创建充值订单接口
系统 SHALL 提供 `POST /api/c/v1/wallet/recharge`,并且 MUST 要求个人客户认证。请求体 MUST 包含:`identifier``amount`100~10000000 分)、`payment_method=wechat``app_type`。接口 MUST 禁止客户端传入 OpenID并由后端按 `customer_id + app_type` 查询 OpenID。订单创建时 MUST 写入:`operator_type=personal_customer` 与资产当前 `generation` 快照。响应体 SHALL 返回 `recharge``pay_config`,其中 `recharge` 至少含 `recharge_id``recharge_no``amount``status``pay_config` 为微信 JSAPI 拉起参数。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``OPENID_NOT_FOUND/未找到微信授权信息,请先完成授权``FORBIDDEN/无权限操作该资产或资源不存在``PAYMENT_NOT_SUPPORTED/仅支持微信支付`
#### Scenario: 后端查 OpenID 并返回支付参数
- **WHEN** 客户传入合法参数且后端成功查询到 OpenID
- **THEN** 系统创建充值单并返回 `recharge + pay_config`
---
### Requirement: C5 充值订单列表接口
系统 SHALL 提供 `GET /api/c/v1/wallet/recharges?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 在归属校验后按资产当前 generation 过滤充值记录。请求参数 SHALL 支持 `status``page``page_size`。响应体 SHALL 返回 `list[]``total``page``page_size`,每项至少含 `recharge_id``recharge_no``amount``status``payment_method``created_at`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误``FORBIDDEN/无权限操作该资产或资源不存在`
#### Scenario: generation 过滤充值历史
- **WHEN** 资产存在多代充值记录
- **THEN** 系统仅返回当前 generation 对应的充值记录

View File

@@ -0,0 +1,107 @@
# client-wechat-login Specification
## Purpose
TBD - created by archiving change client-auth-system. Update Purpose after archive.
## Requirements
### Requirement: A2 微信公众号登录接口
系统 MUST 提供 `POST /api/c/v1/auth/wechat-login`,使用公众号 OAuth code + `asset_token` 完成登录。
- HTTP Method + Path: `POST /api/c/v1/auth/wechat-login`
- 请求体字段:
- `code` stringMUST微信 OAuth 授权码
- `asset_token` stringMUSTA1 返回的资产令牌
- 响应体字段:
- `token` stringMUST登录 JWT
- `need_bind_phone` boolMUST是否需要绑定手机号
- `is_new_user` boolMUST是否新创建用户
- 错误码:
- `1002` token 无效或过期asset_token/JWT
- `1040` 微信授权失败
- `1006` 参数错误
#### Scenario: 公众号登录成功
- **WHEN** 客户端提交有效 `code` 与有效 `asset_token`
- **THEN** 系统 SHALL 调用公众号 OAuth 获取 `openid` 与可选 `unionid`
- **THEN** 系统 SHALL 执行客户查找/创建/合并逻辑
- **THEN** 系统 SHALL 绑定资产并签发登录 token
### Requirement: A3 微信小程序登录接口
系统 MUST 提供 `POST /api/c/v1/auth/miniapp-login`,使用小程序 `jscode2session` + `asset_token` 完成登录。
- HTTP Method + Path: `POST /api/c/v1/auth/miniapp-login`
- 请求体字段:
- `code` stringMUST小程序登录凭证
- `asset_token` stringMUSTA1 返回的资产令牌
- 响应体字段:
- `token` stringMUST登录 JWT
- `need_bind_phone` boolMUST
- `is_new_user` boolMUST
- 错误码:
- `1002` token 无效或过期
- `1040` 微信授权失败
- `1006` 参数错误
#### Scenario: 小程序登录成功
- **WHEN** 客户端提交有效小程序 `code` 与有效 `asset_token`
- **THEN** 系统 SHALL 调用 `jscode2session` 获取 `openid` 与可选 `unionid`
- **THEN** 系统 SHALL 执行与 A2 一致的客户查找/创建/合并、资产绑定与签发逻辑
### Requirement: asset_token 校验与资产解析
系统 SHALL 在 A2/A3 登录前强制校验 `asset_token`,并解析出 `asset_type` + `asset_id`
#### Scenario: asset_token 无效
- **WHEN** `asset_token` 签名不合法或已过期
- **THEN** 系统 MUST 拒绝登录并返回 `1002`
#### Scenario: asset_token 有效
- **WHEN** `asset_token` 可被成功解析
- **THEN** 系统 SHALL 使用解析出的资产信息继续登录流程
### Requirement: 客户查找/创建/合并逻辑
系统 MUST 按以下顺序处理客户归属:
1. 先查 `PersonalCustomerOpenID``(app_id, open_id)`
2. 未命中且存在 `unionid` 时按 `unionid` 回查并复用客户;
3. 仍未命中时创建新 `PersonalCustomer` 与 OpenID 记录。
#### Scenario: openid 命中既有客户
- **WHEN** `(app_id, open_id)` 已存在
- **THEN** 系统 SHALL 直接复用对应 `customer_id`
#### Scenario: openid 未命中但 unionid 命中
- **WHEN** `(app_id, open_id)` 不存在且 `unionid` 命中历史记录
- **THEN** 系统 SHALL 复用已存在客户
- **THEN** 系统 SHALL 新增当前 `app_id + open_id` 记录
#### Scenario: openid/unionid 均未命中
- **WHEN** 无任何匹配记录
- **THEN** 系统 SHALL 创建新客户并写入 OpenID 记录
### Requirement: 登录后资产绑定
系统 SHALL 在 A2/A3 每次登录时创建一条 `PersonalCustomerDevice` 绑定记录,且 MUST 允许同一资产被多个客户绑定。
#### Scenario: 已有绑定时再次登录
- **WHEN** 同一客户再次登录同一资产
- **THEN** 系统 SHALL 记录本次登录绑定关系(按实现可去重或追加历史)
#### Scenario: 不同客户绑定同一资产
- **WHEN** 资产已被其他客户绑定
- **THEN** 系统 MUST 允许新增绑定,不得覆盖已有客户绑定关系
### Requirement: 登录响应与手机号绑定开关
系统 MUST 在登录响应中返回 `need_bind_phone`,该值由 `client.require_phone_binding` 与客户手机号绑定状态共同决定。
#### Scenario: 要求手机号绑定且未绑定
- **WHEN** 配置 `client.require_phone_binding=true` 且客户未绑定手机号
- **THEN** 登录响应 MUST 返回 `need_bind_phone=true`
#### Scenario: 已绑定手机号或配置关闭
- **WHEN** 客户已绑定手机号或 `client.require_phone_binding=false`
- **THEN** 登录响应 MUST 返回 `need_bind_phone=false`

View File

@@ -356,3 +356,42 @@ ALTER TABLE tb_personal_customer_device RENAME COLUMN device_no TO virtual_no;
- **WHEN** 前端调用设备列表或详情接口
- **THEN** 响应 JSON 中 key 为 `virtual_no`,不再有 `device_no`
### Requirement: 设备实体定义
系统 SHALL 在 `Device` 模型新增以下字段:
- `asset_status int NOT NULL DEFAULT 1`
- `generation int NOT NULL DEFAULT 1`
#### Scenario: 新建设备默认资产状态
- **WHEN** 创建新的设备记录
- **THEN** `asset_status` MUST 默认为 `1`(在库)
#### Scenario: 新建设备默认代际
- **WHEN** 创建新的设备记录
- **THEN** `generation` MUST 默认为 `1`
---
### Requirement: 设备换货状态语义扩展
系统 SHALL 将 `asset_status=3` 定义为"已换货",用于标记已被换出的旧设备资产。
#### Scenario: 换货完成后旧设备标记
- **WHEN** H5 确认完成且旧资产为设备
- **THEN** 系统 MUST 将旧设备 `asset_status` 更新为 `3`
---
### Requirement: 设备转新重置规则
系统 SHALL 在 H7 转新时对设备执行以下重置:
- `generation = generation + 1`
- `asset_status = 1`(在库)
- 清空累计充值与首充触发相关状态
- 清除个人客户绑定关系
- 创建新空钱包并与新代际设备关联
#### Scenario: 转新后设备可重新销售
- **WHEN** 对已换货设备执行转新
- **THEN** 系统 MUST 使该设备进入新代际并恢复在库可售

View File

@@ -0,0 +1,127 @@
# exchange-admin-management Specification
## Purpose
提供后台换货单管理能力,涵盖换货单的发起、列表查询、详情查看、发货、确认完成、取消及旧资产转新等完整生命周期管理。
## Requirements
### Requirement: H1 发起换货单
系统 SHALL 提供 `POST /api/admin/exchanges`(需后台认证 `Auth=true`),用于发起换货单。
请求体 MUST 包含:`old_asset_type``old_identifier``exchange_reason`,可选 `remark`
系统 MUST 校验:
- 旧资产存在且当前用户有权限
- 同一资产不存在进行中的换货单(`status IN (1,2,3)`
成功响应 SHALL 返回新建换货单信息(含 `id``exchange_no``status=1`)。
错误响应 MUST 至少包含:参数错误、资产不存在或无权限、存在进行中换货单。
#### Scenario: 资产已有进行中换货单
- **WHEN** 后台为同一资产重复发起换货
- **THEN** 系统 MUST 拒绝创建并返回"存在进行中的换货单"
---
### Requirement: H2 换货单列表
系统 SHALL 提供 `GET /api/admin/exchanges``Auth=true`),支持分页与条件查询。
查询条件 SHOULD 支持:`status``identifier`(资产标识搜索)、`created_at_start``created_at_end`、分页参数。
响应 SHALL 返回列表与分页元数据。
#### Scenario: 按状态查询待发货单
- **WHEN** 运营查询 `status=2`
- **THEN** 系统返回所有待发货换货单并按创建时间倒序
---
### Requirement: H3 换货单详情
系统 SHALL 提供 `GET /api/admin/exchanges/:id``Auth=true`)查询换货单详情。
响应 MUST 返回旧/新资产信息、收货信息、物流信息、迁移状态信息。
错误响应 MUST 至少包含:换货单不存在或无权限。
#### Scenario: 查询不存在换货单
- **WHEN** 查询不存在的换货单 ID
- **THEN** 系统 MUST 返回"资源不存在或无权限"
---
### Requirement: H4 发货
系统 SHALL 提供 `POST /api/admin/exchanges/:id/ship``Auth=true`)。
请求体 MUST 包含:`express_company``express_no``new_identifier``migrate_data`
系统 MUST 校验:
- 当前状态必须为 `2`
- 新旧资产类型必须一致(卡换卡/设备换设备)
- 新资产必须 `asset_status=1`(在库)
成功后 SHALL 更新新资产信息、物流信息并将状态改为 `3`
错误响应 MUST 至少包含:非法状态、资产类型不匹配、新资产非在库、资产不存在或无权限。
#### Scenario: 新资产类型不一致
- **WHEN** 旧资产为 iot_card 且新资产为 device
- **THEN** 系统 MUST 拒绝发货并返回"换货资产类型必须一致"
---
### Requirement: H5 确认完成
系统 SHALL 提供 `POST /api/admin/exchanges/:id/complete``Auth=true`)。
系统 MUST 校验当前状态为 `3`。当 `migrate_data=true` 时,系统 MUST 执行全量迁移事务(见 `exchange-data-migration` 能力)。
成功后 SHALL
- `migration_completed=true`(若执行迁移)
- 换货单状态更新为 `4`
错误响应 MUST 至少包含:非法状态、迁移失败、换货单不存在或无权限。
#### Scenario: 需要迁移并完成
- **WHEN** 状态为 `3``migrate_data=true`
- **THEN** 系统 MUST 在事务成功后将状态变为 `4` 并记录迁移结果
---
### Requirement: H6 取消换货
系统 SHALL 提供 `POST /api/admin/exchanges/:id/cancel``Auth=true`)。
系统 MUST 仅允许在 `status IN (1,2)` 时取消,成功后状态更新为 `5`
系统 MUST 禁止已发货单取消(`status=3`)。
#### Scenario: 已发货单取消失败
- **WHEN** 换货单状态为 `3` 发起取消
- **THEN** 系统 MUST 返回状态非法错误
---
### Requirement: H7 旧资产转新
系统 SHALL 提供 `POST /api/admin/exchanges/:id/renew``Auth=true`)。
系统 MUST 校验旧资产当前 `asset_status=3`(已换货),并执行:
- `generation + 1`
- `asset_status -> 1`
- 清除累计充值/首充相关状态
- 清除个人客户绑定
- 创建新空钱包
系统 MUST 保留历史数据,不执行历史删除。
错误响应 MUST 至少包含:资产状态不满足转新条件、换货单不存在或无权限。
#### Scenario: 旧资产未处于已换货状态
- **WHEN** 旧资产 `asset_status != 3` 发起转新
- **THEN** 系统 MUST 拒绝并返回"资产当前状态不允许转新"

View File

@@ -0,0 +1,41 @@
# exchange-client-notification Specification
## Purpose
提供个人客户端换货通知与收货信息填写能力,支持客户查询进行中的换货单状态并提交收货地址。
## Requirements
### Requirement: G1 查询进行中换货通知
系统 SHALL 提供 `GET /api/c/v1/exchange/pending?identifier=xxx`(需个人客户认证 `Auth=true`)。
系统 MUST 根据资产标识查询当前客户可见的进行中换货单,仅返回 `status IN (1,2,3)` 的记录。
响应 SHALL 至少包含:换货单 ID、单号、状态、换货原因、创建时间。
错误响应 MUST 至少包含:参数错误、资产不存在或无权限。
#### Scenario: 命中进行中换货单
- **WHEN** 客户按资产标识查询且存在状态为 2 的换货单
- **THEN** 系统返回该换货单并标识当前状态为待发货
---
### Requirement: G2 填写收货信息
系统 SHALL 提供 `POST /api/c/v1/exchange/:id/shipping-info`(需个人客户认证 `Auth=true`)。
请求体 MUST 包含:`recipient_name``recipient_phone``recipient_address`
系统 MUST 校验:
- 换货单存在且当前客户有权限
- 当前状态必须为 `1`
成功后 SHALL 写入收货信息并将状态更新为 `2`
错误响应 MUST 至少包含:参数错误、状态非法、换货单不存在或无权限。
#### Scenario: 非待填写状态禁止更新收货信息
- **WHEN** 换货单当前状态为 `2``3`
- **THEN** 系统 MUST 拒绝填写并返回状态非法错误

View File

@@ -0,0 +1,66 @@
# exchange-data-migration Specification
## Purpose
定义换货全量迁移事务规则,包括 11 张表的迁移策略、设备换设备特殊规则及旧资产转新的代际隔离策略。
## Requirements
### Requirement: 全量迁移事务边界
系统 MUST 在 H5 确认完成且 `migrate_data=true` 时,使用**单一数据库事务**执行全量迁移。
该事务 SHALL 覆盖资产钱包、套餐、标签、客户绑定及资产状态更新等所有步骤;任一步骤失败 MUST 回滚。
#### Scenario: 迁移中途失败回滚
- **WHEN** 迁移第 N 步发生数据库错误
- **THEN** 系统 MUST 回滚整个事务,换货单状态保持未完成
---
### Requirement: 11 张表迁移规则
系统 SHALL 按以下规则处理 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`:将绑定记录中的 `virtual_no` 更新为新资产虚拟号。
11. `tb_iot_card`/`tb_device`:迁移累计充值与首充状态到新资产,并将旧资产 `asset_status -> 3`
#### Scenario: 钱包余额转移并记录流水
- **WHEN** 旧资产钱包余额为 5000 分
- **THEN** 新资产钱包余额增加 5000 分,旧钱包余额按迁移策略清零,并写入迁移流水
---
### Requirement: 设备换设备特殊规则
设备换设备流程 MUST NOT 迁移 `DeviceSimBinding`
系统 SHALL 视新设备为新硬件交付,新设备卡绑定由其自身体系决定,旧设备绑定关系保留历史。
#### Scenario: 设备换设备不复制绑定卡
- **WHEN** 执行设备换设备全量迁移
- **THEN** 系统 MUST 不创建或复制任何 `DeviceSimBinding` 记录到新设备
---
### Requirement: 转新规则
系统 SHALL 在 H7 转新时执行代际隔离策略:
- 资产 `generation + 1`
- 创建新空钱包(新 `wallet_id`
- 清除累计充值状态与首充触发状态
- 清除 `PersonalCustomerDevice` 绑定
- 不删除历史业务数据
#### Scenario: 转新后历史数据保留
- **WHEN** 资产转新完成
- **THEN** 历史订单、充值、分佣、流量数据 MUST 仍可在旧代际查询链路中追溯

View File

@@ -0,0 +1,75 @@
# exchange-order-model Specification
## Purpose
定义换货单ExchangeOrder数据模型、状态常量、状态机流转规则及换货单号生成规则作为换货系统的核心数据基础。
## Requirements
### Requirement: ExchangeOrder 换货单模型定义
系统 SHALL 定义 `ExchangeOrder` 模型并映射到 `tb_exchange_order`,用于承载客户端换货完整生命周期。
模型字段 MUST 至少包含:
- 基础:`id``created_at``updated_at``deleted_at``creator``updater`
- 单号:`exchange_no`
- 旧资产:`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`
- 业务:`exchange_reason``remark``status`
- 多租户:`shop_id`
`ExchangeOrder` SHALL 嵌入 `BaseModel` 并实现 `TableName() string`,返回 `tb_exchange_order`
#### Scenario: 创建换货单模型实例
- **WHEN** 系统创建新的换货单记录
- **THEN** 记录 MUST 同时包含旧资产快照、收货信息占位、迁移状态字段和多租户字段
---
### Requirement: 换货状态常量定义
系统 MUST 使用 int 常量定义换货状态:
- `1` 待填写信息
- `2` 待发货
- `3` 已发货待确认
- `4` 已完成
- `5` 已取消
#### Scenario: 状态常量一致性
- **WHEN** Service、Store、Handler 读取或更新换货状态
- **THEN** 各层 MUST 使用统一常量值,禁止硬编码散落魔法数字
---
### Requirement: 换货状态机流转规则
系统 SHALL 执行以下状态机:
- 创建换货单后:`1`
- 客户填写收货信息后:`1 -> 2`
- 后台发货后:`2 -> 3`
- 后台确认完成后:`3 -> 4`
- 取消:仅允许 `1/2 -> 5`
系统 MUST 禁止非法流转(如 `3 -> 5``4 -> 2`)。
#### Scenario: 已发货不可取消
- **WHEN** 换货单状态为 `3` 且请求取消
- **THEN** 系统 MUST 拒绝并返回状态流转非法错误
---
### Requirement: 换货单号生成规则
系统 MUST 为每个换货单生成全局可追踪单号,格式为:`EXC + 时间戳片段 + 随机数片段`
生成规则 SHALL 满足:
- 前缀固定为 `EXC`
- 包含日期/时间信息用于人工排查
- 包含随机片段降低并发冲突概率
#### Scenario: 生成换货单号
- **WHEN** 后台发起换货并创建新单
- **THEN** 系统 MUST 生成形如 `EXC20260319XXXXXX` 的单号并写入 `exchange_no`

View File

@@ -143,3 +143,23 @@
#### Scenario: 套餐不存在
- **WHEN** 套餐购买预检时,套餐 ID 不存在
- **THEN** 系统返回错误 "套餐不存在"
---
### Requirement: 强充检查结果对客户端透出
系统 MUST 将强充检查结果输出给客户端接口(充值预检与购买预检),用于前端明确展示支付拆分。输出字段 SHALL 至少包含:`need_force_recharge``force_recharge_amount``trigger_type``total_package_amount``actual_payment``wallet_credit``message`。若无强充,`need_force_recharge=false``actual_payment=total_package_amount`
#### Scenario: 客户端购买预检命中强充
- **WHEN** 客户端调用购买预检且命中强充规则
- **THEN** 系统返回强充金额、实际支付金额和钱包入账金额
---
### Requirement: 前端展示套餐价与强充金额拆分
系统 SHALL 在强充场景提供可直接渲染的拆分语义套餐总价、需支付金额、充值入钱包金额并给出中文提示文案。当前端调用客户端下单接口D1若命中强充 MUST 返回 `order_type="recharge"``linked_package_info`,以便前端保持与预检展示一致。
#### Scenario: 套餐价低于强充金额
- **WHEN** 套餐总价 5000 分,强充金额 10000 分
- **THEN** 预检返回 `actual_payment=10000``wallet_credit=5000`、提示文案可用于前端直接展示

View File

@@ -0,0 +1,51 @@
# h5-legacy-cleanup Specification
## Purpose
TBD - created by archiving change client-api-data-model-fixes. Update Purpose after archive.
## Requirements
### Requirement: 旧 H5 接口文件删除清单
系统 MUST 完整删除以下旧 H5 文件:
- `internal/handler/h5/auth.go`
- `internal/handler/h5/order.go`
- `internal/handler/h5/recharge.go`
- `internal/handler/h5/package_usage.go`
- `internal/handler/h5/enterprise_device.go`
- `internal/routes/h5.go`
- `internal/routes/h5_enterprise_device.go`
- `internal/routes/h5_package_usage.go`
#### Scenario: 旧 H5 文件不存在
- **WHEN** 执行本提案改造完成后检查仓库
- **THEN** 上述文件 MUST 全部不存在
---
### Requirement: 旧 H5 与旧登录引用清理清单
系统 MUST 清理以下代码引用:
- bootstrap`handlers.go``H5Auth``EnterpriseDeviceH5``H5PackageUsage``H5Order``H5Recharge`
- bootstrap`types.go` 对应字段
- bootstrap`middlewares.go``createH5AuthMiddleware`
- 路由:`routes.go``/api/h5` 挂载
- 路由:`order.go``registerH5OrderRoutes`
- 路由:`recharge.go``registerH5RechargeRoutes`
- 文档:`pkg/openapi/handlers.go` 中 H5 Handler 构造
- 限流:`cmd/api/main.go``/api/h5` 限流配置
- 旧登录方法:`internal/handler/app/personal_customer.go``Login``SendCode``WechatOAuthLogin``BindWechat`
- 旧登录路由:`internal/routes/personal.go` 中指向已删除方法的路由
#### Scenario: 编译期无已删除符号引用
- **WHEN** 清理完成后执行编译
- **THEN** 系统 MUST 不再出现对上述已删除 Handler、路由或方法的引用
---
### Requirement: 清理后编译通过
系统 MUST 在完成文件删除与引用清理后保持工程可编译。
#### Scenario: 全量编译验证通过
- **WHEN** 执行构建命令
- **THEN** 工程 MUST 编译通过且无 H5 旧接口残留导致的编译错误

View File

@@ -624,7 +624,6 @@ This capability supports:
- **WHEN** 系统时间到达每月1号 00:00:00
- **THEN** 系统重置所有 data_reset_cycle=monthly 的套餐 data_usage_mb=0
---
### Requirement: IotCard Handler 分层修复
@@ -749,3 +748,42 @@ IotCard Service SHALL 提供 Gateway API 的代理方法,封装权限检查和
- **WHEN** 系统中有历史导入的卡,没有 virtual_no
- **THEN** 这些卡的 virtual_no = NULL不影响唯一索引部分索引跳过 NULL 值)
### Requirement: IoT 卡资产生命周期字段
系统 SHALL 在 `IotCard` 模型新增以下资产生命周期追踪字段:
- `asset_status int NOT NULL DEFAULT 1`
- `generation int NOT NULL DEFAULT 1`
#### Scenario: 新建 IoT 卡默认资产状态
- **WHEN** 创建新的 IoT 卡记录
- **THEN** `asset_status` MUST 默认为 `1`(在库)
#### Scenario: 新建 IoT 卡默认代际
- **WHEN** 创建新的 IoT 卡记录
- **THEN** `generation` MUST 默认为 `1`
---
### Requirement: IoT 卡换货状态语义扩展
系统 SHALL 将 `asset_status=3` 定义为"已换货",用于标记已被换出、不可继续作为当前代际在售资产的 IoT 卡。
#### Scenario: 换货完成后旧卡标记为已换货
- **WHEN** H5 确认完成且旧资产为 IoT 卡
- **THEN** 系统 MUST 将旧卡 `asset_status` 更新为 `3`
---
### Requirement: IoT 卡转新重置规则
系统 SHALL 在 H7 转新时对 IoT 卡执行以下重置:
- `generation = generation + 1`
- `asset_status = 1`(在库)
- 清空累计充值与首充触发相关状态(含 `AccumulatedRecharge``FirstCommissionPaid`、系列首充/累计字段)
- 清除个人客户绑定关系
#### Scenario: 转新后进入新代际
- **WHEN** 对旧卡执行转新
- **THEN** 系统 MUST 使该卡进入新代际并以在库状态重新销售

View File

@@ -292,3 +292,21 @@ This capability supports:
---
### Requirement: 订单来源与代际字段
系统 SHALL 在订单Order实体新增来源与代际字段
- `source varchar(20) NOT NULL DEFAULT 'admin'`,取值 `admin/client`
- `generation int NOT NULL DEFAULT 1`
#### Scenario: 新建订单默认后台来源
- **WHEN** 系统创建订单且未显式指定来源
- **THEN** `source` MUST 默认为 `admin`
#### Scenario: 客户端下单写入客户端来源
- **WHEN** 客户端入口创建订单
- **THEN** `source` MUST 写入为 `client`
#### Scenario: 新建订单默认代际为 1
- **WHEN** 系统创建订单且未显式指定代际
- **THEN** `generation` MUST 默认为 `1`

View File

@@ -1,5 +1,8 @@
## ADDED Requirements
# one-time-commission-trigger Specification
## Purpose
一次性佣金触发机制 - 定义单次充值和累计充值两种触发条件、佣金发放规则、配置获取和幂等性保障。
## Requirements
### Requirement: 一次性充值触发佣金
系统 SHALL 支持"一次性充值"触发条件:当单笔订单金额 ≥ 配置阈值时触发一次性佣金。
@@ -138,3 +141,22 @@
- **WHEN** 提供的梯度档位缺少必填字段threshold_value、commission_mode、commission_value
- **THEN** 系统返回错误:梯度佣金档位配置无效(错误码 40105
### Requirement: 一次性佣金触发条件
系统 SHALL 在满足一次性佣金阈值规则的前提下,仅对客户端订单触发一次性佣金。
完整触发判断 MUST 为:`!order.IsPurchaseOnBehalf && order.Source == "client"`
#### Scenario: 客户端自购订单触发
- **WHEN** 订单满足阈值条件,且 `order.IsPurchaseOnBehalf=false``order.Source="client"`
- **THEN** 系统 SHALL 触发一次性佣金计算
#### Scenario: 代购订单不触发
- **WHEN** 订单满足阈值条件,但 `order.IsPurchaseOnBehalf=true`
- **THEN** 系统 SHALL 不触发一次性佣金
#### Scenario: 后台订单不触发
- **WHEN** 订单满足阈值条件,且 `order.Source="admin"`
- **THEN** 系统 SHALL 不触发一次性佣金

View File

@@ -1,5 +1,8 @@
## ADDED Requirements
# package-purchase-validation Specification
## Purpose
套餐购买验证 - 定义客户端购买套餐前的权限、状态、价格及设备卡验证规则。
## Requirements
### Requirement: 验证卡/设备的套餐购买权限
创建订单前系统 MUST 验证卡/设备是否有权购买指定套餐。
@@ -83,3 +86,34 @@
#### Scenario: 设备无系列关联
- **WHEN** 设备的 series_allocation_id 为空
- **THEN** 验证失败,返回 "该设备未关联套餐系列"
### Requirement: 代理渠道购买价格规则
系统 MUST 根据购买渠道返回正确的购买价格:代理渠道使用 `allocation.retail_price`,平台渠道使用 `Package.SuggestedRetailPrice`
#### Scenario: 代理渠道使用分配零售价
- **WHEN** 客户通过代理渠道购买套餐
- **THEN** 系统 MUST 使用 `allocation.retail_price` 作为支付金额
#### Scenario: 平台渠道使用套餐建议零售价
- **WHEN** 客户通过平台自营渠道购买套餐
- **THEN** 系统 MUST 使用 `Package.SuggestedRetailPrice` 作为支付金额
---
### Requirement: validatePackages 价格累加与展示校验
系统 MUST 在 `validatePackages()` 中按渠道来源使用一致的价格来源进行累加计算,并在代理渠道增加价格展示可见性校验。
#### Scenario: 代理渠道累加使用 retail_price
- **WHEN** `validatePackages()` 处理代理渠道的多套餐下单
- **THEN** 总价累加 MUST 基于各套餐的 `allocation.retail_price`
#### Scenario: 平台渠道累加使用 SuggestedRetailPrice
- **WHEN** `validatePackages()` 处理平台渠道的多套餐下单
- **THEN** 总价累加 MUST 基于各套餐的 `Package.SuggestedRetailPrice`
#### Scenario: 代理渠道过滤异常零售价
- **WHEN** 代理渠道某套餐存在 `retail_price < cost_price`
- **THEN** 系统 MUST 不展示该套餐,且不允许该套餐进入下单校验

View File

@@ -0,0 +1,39 @@
# personal-customer-openid Specification
## Purpose
TBD - created by archiving change client-auth-system. Update Purpose after archive.
## Requirements
### Requirement: PersonalCustomerOpenID 模型定义
系统 MUST 新增 `PersonalCustomerOpenID` 模型与数据表 `tb_personal_customer_openid`,用于保存客户在不同 AppID 下的 OpenID 记录。
- 关键字段:
- `id` uint主键
- `customer_id` uintMUST关联个人客户 ID
- `app_id` stringMUST微信应用标识
- `open_id` stringMUST当前应用下 OpenID
- `union_id` string可选开放平台统一标识
- `created_at`/`updated_at`/`deleted_at`
- 索引约束:
- MUST 存在唯一索引 `UNIQUE(app_id, open_id)`(软删条件下唯一)
#### Scenario: 新增 OpenID 记录成功
- **WHEN** 登录流程创建新 OpenID 关系
- **THEN** 系统 SHALL 插入一条包含 `customer_id/app_id/open_id` 的记录
#### Scenario: 重复 app_id + open_id 被拒绝
- **WHEN** 试图插入已存在的 `(app_id, open_id)` 组合
- **THEN** 系统 MUST 触发唯一约束并拒绝写入
### Requirement: 与 PersonalCustomer 的关系约束
系统 SHALL 通过 `customer_id``PersonalCustomer` 建立逻辑关联(不使用数据库外键约束)。
#### Scenario: 根据 customer_id 查询 OpenID 列表
- **WHEN** 业务根据 `customer_id` 查询 OpenID
- **THEN** 系统 SHALL 返回该客户在多 AppID 下的全部有效记录
#### Scenario: 软删除客户后的记录处理
- **WHEN** 客户逻辑删除或状态失效
- **THEN** 系统 MUST 支持按业务策略同步停用或软删除 OpenID 记录

View File

@@ -363,3 +363,86 @@ sms:
---
### Requirement: 微信标识索引策略
系统 MUST 将 `tb_personal_customer.wx_open_id` 的索引从唯一索引调整为普通索引:删除 `uniqueIndex`,改为 `index`。
#### Scenario: 多条记录允许相同 wx_open_id
- **WHEN** 数据库中写入两条具有相同 `wx_open_id` 的个人客户记录
- **THEN** 数据库层 MUST 不再因唯一约束报错
#### Scenario: 查询性能仍受索引保障
- **WHEN** 按 `wx_open_id` 执行查询
- **THEN** 系统 MUST 继续命中普通索引以保障查询性能
### Requirement: 个人客户登录主流程改为微信授权
系统 SHALL 将个人客户登录主流程从“手机号 + 验证码登录”调整为“资产验证 + 微信授权登录”。
- 新登录入口:
- `POST /api/c/v1/auth/verify-asset`A1无认证
- `POST /api/c/v1/auth/wechat-login`A2无认证
- `POST /api/c/v1/auth/miniapp-login`A3无认证
- 请求与响应要点:
- A2/A3 请求体 MUST 包含 `code` 与 `asset_token`
- A2/A3 响应体 MUST 包含 `token`、`need_bind_phone`、`is_new_user`
- 错误码:
- `1006` 参数错误
- `1002` token 无效或过期
- `1040` 微信授权失败
#### Scenario: 通过微信授权完成登录
- **WHEN** 用户先完成 A1再提交 A2 或 A3
- **THEN** 系统 SHALL 完成客户识别/创建、资产绑定并返回登录 token
#### Scenario: 不再支持旧手机号直登入口
- **WHEN** 客户端调用旧手机号登录路径(如 `/api/c/v1/login`
- **THEN** 系统 MUST 按新路由规范拒绝或迁移提示,不再作为主登录路径
### Requirement: 手机号从“登录凭据”调整为“登录后补充资料”
系统 MUST 将手机号能力调整为登录后绑定/换绑,而非登录入口。
- 相关接口:
- `POST /api/c/v1/auth/send-code`A4无认证
- `POST /api/c/v1/auth/bind-phone`A5需认证
- `POST /api/c/v1/auth/change-phone`A6需认证
- 响应字段:
- A5/A6 MUST 返回绑定后的 `phone`
#### Scenario: 首次登录后要求绑定手机号
- **WHEN** `client.require_phone_binding=true` 且用户未绑定手机号
- **THEN** 登录响应 MUST 返回 `need_bind_phone=true`
- **THEN** 用户通过 A4+A5 完成绑定后进入业务页面
### Requirement: 微信身份字段迁移到 OpenID 关联能力
系统 SHALL 保留 `PersonalCustomer.wx_open_id` 与 `wx_union_id` 字段的兼容性,但新登录链路 MUST 以 `PersonalCustomerOpenID` 为主。
#### Scenario: 读取用户微信身份
- **WHEN** 登录流程需要按微信身份识别客户
- **THEN** 系统 MUST 优先查询 `PersonalCustomerOpenID`
- **THEN** 不再依赖 `PersonalCustomer` 单字段承载多 AppID 场景
---
### Requirement: 换货迁移时更新个人客户资产绑定
系统 SHALL 在 H5 全量迁移成功后,更新 `PersonalCustomerDevice` 的资产标识绑定关系:
- 若旧资产存在客户绑定,绑定中的 `virtual_no` MUST 更新为新资产 `virtual_no`
- 更新后客户对资产访问连续,不需重新登录即可看到新资产
#### Scenario: 迁移后客户绑定跟随新资产
- **WHEN** 旧资产存在个人客户绑定且执行了 `migrate_data=true`
- **THEN** 系统 MUST 将绑定记录的 `virtual_no` 更新为新资产虚拟号
---
### Requirement: 转新时清除个人客户绑定
系统 SHALL 在 H7 转新时清除该资产在 `PersonalCustomerDevice` 中的绑定关系,避免旧客户继续访问新代际资产。
#### Scenario: 转新后旧客户需重新绑定
- **WHEN** 资产转新完成
- **THEN** 系统 MUST 删除或失效对应客户绑定,使旧客户再次访问时触发重新绑定流程

View File

@@ -3,9 +3,7 @@
## Purpose
本 capability 定义钱包充值功能,允许个人客户为卡/设备钱包充值,支持强充验证、第三方支付和充值后的累计充值更新与一次性佣金触发。
## Requirements
### Requirement: 创建钱包充值订单
系统 SHALL 允许个人客户创建钱包充值订单。创建前 MUST 验证强充要求,强充场景下充值金额必须等于要求的强充金额。
@@ -186,3 +184,59 @@
#### Scenario: 充值金额过大
- **WHEN** 客户尝试充值 200000 元
- **THEN** 系统返回错误 "单次充值金额不能超过100000元"
### Requirement: 充值回调事务一致性
`HandlePaymentCallback` 内的 `UpdateStatusWithOptimisticLock``UpdatePaymentInfo` MUST 使用同一个事务内 `tx` 执行,保证充值状态与支付信息的原子性。
#### Scenario: 回调处理中状态更新与支付信息更新同事务
- **WHEN** 收到支付成功回调并进入 `HandlePaymentCallback`
- **THEN** 系统 MUST 在同一事务 `tx` 内执行 `UpdateStatusWithOptimisticLock`
- **THEN** 系统 MUST 在同一事务 `tx` 内执行 `UpdatePaymentInfo`
#### Scenario: 事务失败整体回滚
- **WHEN** 回调处理中任一步骤失败
- **THEN** 系统 MUST 回滚该事务,保证订单状态与支付信息不出现部分成功
---
### Requirement: Store 方法签名支持事务参数
系统 MUST 调整充值相关 Store 方法签名,支持显式传入 `*gorm.DB tx` 参数,以保证事务边界可控。
#### Scenario: Service 传入事务句柄
- **WHEN** Service 在事务上下文调用 Store 更新充值记录
- **THEN** Store 方法 MUST 接收并使用传入的 `tx` 执行数据库操作
---
### Requirement: 充值回调采用两阶段处理
系统 MUST 将强充场景的充值回调改为两阶段:第一阶段同步事务内完成入账与状态更新,第二阶段异步执行自动购买。第一阶段 SHALL 包含:更新充值状态、钱包加款、累计充值更新、首充佣金判断。第二阶段 SHALL 通过 Asynq 任务执行钱包扣款、创建套餐订单、激活套餐。该改造适用于客户端触发的强充路径,且不影响非强充充值主流程。
#### Scenario: 强充回调同步入账成功并触发异步任务
- **WHEN** 强充充值支付回调验签成功
- **THEN** 系统在事务内完成钱包入账与充值单状态更新
- **AND** 入队 `AutoPurchaseAfterRecharge` 异步任务
---
### Requirement: 充值记录新增 auto_purchase_status 状态追踪
系统 MUST 在 `AssetRechargeRecord` 增加 `auto_purchase_status` 字段,用于追踪强充后二阶段自动购买状态。状态集 SHALL 至少包括:`pending``success``failed`。创建强充充值单时 MUST 初始化为 `pending`;异步购买成功后 MUST 更新为 `success`;重试耗尽后 MUST 更新为 `failed`
#### Scenario: 强充充值单创建时默认 pending
- **WHEN** 系统创建与套餐联动的强充充值单
- **THEN** 充值记录 `auto_purchase_status` 初始化为 `pending`
---
### Requirement: 异步自动购买失败处理规范
系统 SHALL 对 `AutoPurchaseAfterRecharge` 失败场景执行统一处理:任务 MUST 自动重试(最多 3 次);全部失败后 MUST 记录错误日志并将 `auto_purchase_status` 置为 `failed`;用户资金 SHALL 保留在钱包中,允许后续手动购买,不得回滚已成功的充值入账。
#### Scenario: 异步任务最终失败
- **WHEN** 自动购买任务连续失败并达到最大重试次数
- **THEN** 系统将 `auto_purchase_status` 标记为 `failed`
- **AND** 钱包余额保持可用,用户可手动下单

View File

@@ -1,7 +1,8 @@
# 微信公众号能力规格说明
## ADDED Requirements
# wechat-official-account Specification
## Purpose
微信公众号能力规范,定义微信 OAuth 2.0 授权登录、账号绑定、OpenID/UnionID 查询、Access Token 中控及配置管理。
## Requirements
### Requirement: 系统必须支持微信 OAuth 2.0 授权登录
系统 SHALL 实现微信公众号 OAuth 2.0 授权流程,允许个人客户通过微信授权获取用户身份信息。
@@ -145,3 +146,48 @@
- **WHEN** 必填配置项AppID、AppSecret缺失
- **THEN** 系统记录 FATAL 级别日志
- **THEN** 系统启动失败并退出
### Requirement: 微信配置源从 YAML 改为数据库动态读取
系统 MUST 将公众号/小程序授权配置源从 YAML 静态配置切换为数据库 `tb_wechat_config` 动态读取(`is_active=true`)。
- 配置读取规则:
- 公众号登录A2使用 `app_id` + `app_secret`
- 小程序登录A3使用 `miniapp_app_id` + `miniapp_app_secret`
- 适配接口:
- `POST /api/c/v1/auth/wechat-login`
- `POST /api/c/v1/auth/miniapp-login`
#### Scenario: 公众号登录读取数据库配置
- **WHEN** 调用 A2 执行 OAuth code 换取 OpenID
- **THEN** 系统 SHALL 从 `tb_wechat_config` 读取当前激活公众号配置
#### Scenario: 小程序登录读取数据库配置
- **WHEN** 调用 A3 执行 jscode2session
- **THEN** 系统 SHALL 从 `tb_wechat_config` 读取当前激活小程序配置
### Requirement: 配置缺失或无激活记录时失败
系统 MUST 在缺少有效数据库配置时拒绝微信登录请求,并返回统一错误。
- 错误码:
- `1041` 微信配置不可用
- `1040` 微信授权失败(第三方调用失败)
#### Scenario: 无激活配置
- **WHEN** `tb_wechat_config` 中不存在 `is_active=true` 记录
- **THEN** 系统 MUST 返回 `1041`
#### Scenario: 配置存在但第三方调用失败
- **WHEN** 已获取数据库配置但调用微信接口失败
- **THEN** 系统 MUST 返回 `1040`
### Requirement: 旧 YAML 配置不再作为登录凭据来源
系统 SHALL 停止在登录链路中使用 `wechat.official_account.*` 静态配置作为 AppID/AppSecret 来源。
#### Scenario: 配置切换后行为一致
- **WHEN** 运维在数据库中更新激活配置
- **THEN** 后续登录请求 SHALL 使用新配置生效
- **THEN** 无需重启服务加载 YAML