重置项目上下文与规范文档
This commit is contained in:
@@ -1,143 +0,0 @@
|
||||
# 账号管理接口规格
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 统一账号管理路由结构
|
||||
系统 SHALL 提供统一的账号管理路由,按账号类型分组。
|
||||
|
||||
#### Scenario: 平台账号管理路由
|
||||
- **WHEN** 访问 /api/admin/accounts/platform/*
|
||||
- **THEN** 提供平台账号的 CRUD + 角色管理功能
|
||||
|
||||
#### Scenario: 代理账号管理路由
|
||||
- **WHEN** 访问 /api/admin/accounts/shop/*
|
||||
- **THEN** 提供代理账号的 CRUD + 角色管理功能
|
||||
|
||||
#### Scenario: 企业账号管理路由
|
||||
- **WHEN** 访问 /api/admin/accounts/enterprise/*
|
||||
- **THEN** 提供企业账号的 CRUD + 角色管理功能
|
||||
|
||||
### Requirement: 所有账号类型支持完整的CRUD操作
|
||||
系统 SHALL 为所有账号类型提供一致的 CRUD 功能。
|
||||
|
||||
#### Scenario: 创建账号
|
||||
- **WHEN** POST /api/admin/accounts/{type}
|
||||
- **THEN** 验证权限,创建账号,返回账号信息
|
||||
|
||||
#### Scenario: 查询账号列表
|
||||
- **WHEN** GET /api/admin/accounts/{type}
|
||||
- **THEN** 应用数据权限过滤,返回分页列表
|
||||
|
||||
#### Scenario: 查询账号详情
|
||||
- **WHEN** GET /api/admin/accounts/{type}/:id
|
||||
- **THEN** 验证权限,返回账号详情
|
||||
|
||||
#### Scenario: 更新账号
|
||||
- **WHEN** PUT /api/admin/accounts/{type}/:id
|
||||
- **THEN** 验证权限,更新账号,返回更新后信息
|
||||
|
||||
#### Scenario: 删除账号
|
||||
- **WHEN** DELETE /api/admin/accounts/{type}/:id
|
||||
- **THEN** 验证权限,软删除账号,返回成功
|
||||
|
||||
### Requirement: 所有账号类型支持密码和状态管理
|
||||
系统 SHALL 为所有账号类型提供统一的密码和状态管理功能。
|
||||
|
||||
#### Scenario: 修改账号密码
|
||||
- **WHEN** PUT /api/admin/accounts/{type}/:id/password
|
||||
- **THEN** 验证权限,更新密码(bcrypt哈希),返回成功
|
||||
|
||||
#### Scenario: 启用账号
|
||||
- **WHEN** PUT /api/admin/accounts/{type}/:id/status,status=1
|
||||
- **THEN** 验证权限,更新状态为启用,返回成功
|
||||
|
||||
#### Scenario: 禁用账号
|
||||
- **WHEN** PUT /api/admin/accounts/{type}/:id/status,status=0
|
||||
- **THEN** 验证权限,更新状态为禁用,返回成功
|
||||
|
||||
### Requirement: 所有账号类型支持角色管理
|
||||
系统 SHALL 为所有账号类型提供统一的角色管理功能。
|
||||
|
||||
#### Scenario: 分配角色
|
||||
- **WHEN** POST /api/admin/accounts/{type}/:id/roles,body: {role_ids: [1,2]}
|
||||
- **THEN** 验证权限,分配角色,返回成功
|
||||
|
||||
#### Scenario: 查询账号角色
|
||||
- **WHEN** GET /api/admin/accounts/{type}/:id/roles
|
||||
- **THEN** 验证权限,返回账号的所有角色列表
|
||||
|
||||
#### Scenario: 移除角色
|
||||
- **WHEN** DELETE /api/admin/accounts/{type}/:id/roles/:role_id
|
||||
- **THEN** 验证权限,软删除角色关联,返回成功
|
||||
|
||||
#### Scenario: 清空所有角色
|
||||
- **WHEN** POST /api/admin/accounts/{type}/:id/roles,body: {role_ids: []}
|
||||
- **THEN** 验证权限,删除所有角色关联,返回成功
|
||||
|
||||
### Requirement: 删除旧路由避免冲突
|
||||
系统 SHALL 删除旧的账号管理路由,避免与新路由冲突。
|
||||
|
||||
#### Scenario: 旧平台账号路由404
|
||||
- **WHEN** 访问 POST /api/admin/platform-accounts
|
||||
- **THEN** 返回 404 Not Found
|
||||
|
||||
#### Scenario: 旧代理账号路由404
|
||||
- **WHEN** 访问 GET /api/admin/shop-accounts
|
||||
- **THEN** 返回 404 Not Found
|
||||
|
||||
#### Scenario: 旧企业账号路由404
|
||||
- **WHEN** 访问 POST /api/admin/customer-accounts
|
||||
- **THEN** 返回 404 Not Found
|
||||
|
||||
### Requirement: 响应格式保持一致
|
||||
系统 SHALL 为所有账号类型返回一致的响应格式。
|
||||
|
||||
#### Scenario: 创建响应包含完整账号信息
|
||||
- **WHEN** 创建账号成功
|
||||
- **THEN** 返回账号 ID、用户名、手机号、用户类型、状态、创建时间
|
||||
|
||||
#### Scenario: 列表响应包含分页信息
|
||||
- **WHEN** 查询账号列表
|
||||
- **THEN** 返回 {items, total, page, size}
|
||||
|
||||
#### Scenario: 错误响应使用统一格式
|
||||
- **WHEN** 操作失败
|
||||
- **THEN** 返回 {code, message, timestamp}
|
||||
|
||||
### Requirement: 支持按条件筛选账号列表
|
||||
系统 SHALL 支持按多个条件筛选账号列表。
|
||||
|
||||
#### Scenario: 按用户名筛选
|
||||
- **WHEN** GET /api/admin/accounts/{type}?username=张三
|
||||
- **THEN** 返回用户名包含"张三"的账号列表
|
||||
|
||||
#### Scenario: 按手机号筛选
|
||||
- **WHEN** GET /api/admin/accounts/{type}?phone=138
|
||||
- **THEN** 返回手机号包含"138"的账号列表
|
||||
|
||||
#### Scenario: 按状态筛选
|
||||
- **WHEN** GET /api/admin/accounts/{type}?status=1
|
||||
- **THEN** 返回状态为启用的账号列表
|
||||
|
||||
#### Scenario: 按店铺ID筛选(代理账号)
|
||||
- **WHEN** GET /api/admin/accounts/shop?shop_id=100
|
||||
- **THEN** 返回 shop_id=100 的代理账号列表(需权限验证)
|
||||
|
||||
#### Scenario: 按企业ID筛选(企业账号)
|
||||
- **WHEN** GET /api/admin/accounts/enterprise?enterprise_id=50
|
||||
- **THEN** 返回 enterprise_id=50 的企业账号列表(需权限验证)
|
||||
|
||||
### Requirement: 统一Service层实现消除重复
|
||||
系统 SHALL 使用单一 AccountService 处理所有账号类型,消除代码重复。
|
||||
|
||||
#### Scenario: AccountService处理所有账号类型
|
||||
- **WHEN** 调用 AccountService.Create(ctx, req)
|
||||
- **THEN** 根据 req.UserType 创建不同类型账号(平台、代理、企业)
|
||||
|
||||
#### Scenario: 删除ShopAccountService
|
||||
- **WHEN** 系统重构完成
|
||||
- **THEN** ShopAccountService 及相关文件应被删除
|
||||
|
||||
#### Scenario: 删除CustomerAccountService
|
||||
- **WHEN** 系统重构完成
|
||||
- **THEN** CustomerAccountService 及相关文件应被删除
|
||||
@@ -1,105 +0,0 @@
|
||||
# 账号操作审计日志规格
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 记录所有账号管理操作
|
||||
系统 SHALL 记录所有账号管理操作,包括创建、更新、删除、角色分配和移除。
|
||||
|
||||
#### Scenario: 创建账号时记录审计日志
|
||||
- **WHEN** 用户创建账号成功
|
||||
- **THEN** 系统应异步写入审计日志,包含操作人、目标账号、操作类型(create)、变更数据(after_data)
|
||||
|
||||
#### Scenario: 更新账号时记录变更前后数据
|
||||
- **WHEN** 用户更新账号信息(用户名、手机号、状态等)
|
||||
- **THEN** 系统应记录 before_data 和 after_data,包含所有变更字段
|
||||
|
||||
#### Scenario: 删除账号时记录审计日志
|
||||
- **WHEN** 用户软删除账号
|
||||
- **THEN** 系统应记录删除操作,包含被删除账号的完整信息(before_data)
|
||||
|
||||
#### Scenario: 分配角色时记录审计日志
|
||||
- **WHEN** 用户为账号分配角色
|
||||
- **THEN** 系统应记录 operation_type=assign_roles,after_data 包含分配的角色 ID 列表
|
||||
|
||||
#### Scenario: 移除角色时记录审计日志
|
||||
- **WHEN** 用户移除账号的角色
|
||||
- **THEN** 系统应记录 operation_type=remove_role,包含被移除的角色 ID
|
||||
|
||||
### Requirement: 审计日志包含完整的操作上下文
|
||||
系统 SHALL 在审计日志中记录操作人、目标对象、变更内容和请求上下文。
|
||||
|
||||
#### Scenario: 记录操作人信息
|
||||
- **WHEN** 记录审计日志
|
||||
- **THEN** 日志应包含 operator_id、operator_type、operator_name
|
||||
|
||||
#### Scenario: 记录目标账号信息
|
||||
- **WHEN** 记录审计日志
|
||||
- **THEN** 日志应包含 target_account_id、target_username、target_user_type
|
||||
|
||||
#### Scenario: 记录变更数据(JSON格式)
|
||||
- **WHEN** 记录更新操作
|
||||
- **THEN** before_data 和 after_data 应为 JSONB 格式,包含完整的字段信息
|
||||
|
||||
#### Scenario: 记录请求上下文
|
||||
- **WHEN** 记录审计日志
|
||||
- **THEN** 日志应包含 request_id、ip_address、user_agent,可关联访问日志
|
||||
|
||||
### Requirement: 异步写入不阻塞业务流程
|
||||
系统 SHALL 使用 Goroutine 异步写入审计日志,确保业务操作不受审计日志性能影响。
|
||||
|
||||
#### Scenario: 异步写入审计日志
|
||||
- **WHEN** AccountService.Create 创建账号成功
|
||||
- **THEN** 主流程立即返回,审计日志在独立 Goroutine 中异步写入
|
||||
|
||||
#### Scenario: 写入失败只记录错误日志
|
||||
- **WHEN** 审计日志写入数据库失败
|
||||
- **THEN** 记录 Error 级别日志,包含完整审计信息,但不影响业务操作结果
|
||||
|
||||
#### Scenario: 业务响应时间不受影响
|
||||
- **WHEN** 执行账号创建操作
|
||||
- **THEN** API 响应时间不应因审计日志写入而增加(< 1ms)
|
||||
|
||||
### Requirement: 操作描述使用中文
|
||||
系统 SHALL 使用中文描述审计日志的操作类型和内容。
|
||||
|
||||
#### Scenario: 创建操作描述
|
||||
- **WHEN** 记录创建账号操作
|
||||
- **THEN** operation_desc 应为 "创建账号: {username}"
|
||||
|
||||
#### Scenario: 更新操作描述
|
||||
- **WHEN** 记录更新账号操作
|
||||
- **THEN** operation_desc 应为 "更新账号: {username}"
|
||||
|
||||
#### Scenario: 删除操作描述
|
||||
- **WHEN** 记录删除账号操作
|
||||
- **THEN** operation_desc 应为 "删除账号: {username}"
|
||||
|
||||
#### Scenario: 分配角色操作描述
|
||||
- **WHEN** 记录分配角色操作
|
||||
- **THEN** operation_desc 应为 "为账号 {username} 分配角色"
|
||||
|
||||
### Requirement: 支持按多维度查询审计日志
|
||||
系统 SHALL 提供索引支持按操作人、目标账号、时间快速查询审计日志。
|
||||
|
||||
#### Scenario: 按操作人查询日志
|
||||
- **WHEN** 查询特定操作人的所有操作记录
|
||||
- **THEN** 使用 idx_account_log_operator 索引,查询时间 < 50ms
|
||||
|
||||
#### Scenario: 按目标账号查询日志
|
||||
- **WHEN** 查询特定账号的所有操作记录
|
||||
- **THEN** 使用 idx_account_log_target 索引,查询时间 < 50ms
|
||||
|
||||
#### Scenario: 按时间范围查询日志
|
||||
- **WHEN** 查询最近7天的操作记录
|
||||
- **THEN** 使用 idx_account_log_created 索引,支持倒序分页
|
||||
|
||||
### Requirement: 关联访问日志追溯完整请求链路
|
||||
系统 SHALL 通过 request_id 关联审计日志和访问日志,支持完整链路追溯。
|
||||
|
||||
#### Scenario: 通过request_id关联日志
|
||||
- **WHEN** 审计日志中记录 request_id="req-12345"
|
||||
- **THEN** 可以在 access.log 中查询到对应的 HTTP 请求日志
|
||||
|
||||
#### Scenario: 追溯完整请求链路
|
||||
- **WHEN** 运维人员调查某个账号创建操作
|
||||
- **THEN** 通过 request_id 可以查询到:请求参数、权限检查、数据库操作、响应结果
|
||||
@@ -1,127 +0,0 @@
|
||||
# 账号管理权限检查规格
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 三层越权防护架构
|
||||
系统 SHALL 实现三层越权防护机制,确保账号管理操作的安全性。
|
||||
|
||||
#### Scenario: 路由层中间件拦截企业账号
|
||||
- **WHEN** 企业账号(user_type=4)访问账号管理接口(/api/admin/accounts/*)
|
||||
- **THEN** 中间件应返回 403 错误:"无权限访问账号管理功能"
|
||||
|
||||
#### Scenario: Service层权限检查成功
|
||||
- **WHEN** 代理账号创建自己店铺的账号
|
||||
- **THEN** CanManageShop 检查应通过,账号创建成功
|
||||
|
||||
#### Scenario: GORM层自动过滤生效
|
||||
- **WHEN** 代理账号查询账号列表
|
||||
- **THEN** GORM Callback 应自动添加 `shop_id IN (当前店铺+下级店铺)` 过滤条件
|
||||
|
||||
### Requirement: 代理账号只能管理自己店铺及下级店铺的账号
|
||||
系统 SHALL 验证代理账号对目标店铺的管理权限,禁止跨店铺越权操作。
|
||||
|
||||
#### Scenario: 代理创建自己店铺的账号成功
|
||||
- **WHEN** 代理账号(shop_id=100)创建 shop_id=100 的账号
|
||||
- **THEN** 权限检查通过,账号创建成功
|
||||
|
||||
#### Scenario: 代理创建下级店铺的账号成功
|
||||
- **WHEN** 代理账号(shop_id=100,下级:101,102)创建 shop_id=101 的账号
|
||||
- **THEN** GetSubordinateShopIDs 返回 [100,101,102],权限检查通过
|
||||
|
||||
#### Scenario: 代理创建其他店铺的账号失败
|
||||
- **WHEN** 代理账号(shop_id=100)创建 shop_id=200 的账号
|
||||
- **THEN** CanManageShop 返回错误:"无权限管理该店铺的账号",创建失败
|
||||
|
||||
#### Scenario: 代理创建平台账号失败
|
||||
- **WHEN** 代理账号尝试创建 user_type=2 的平台账号
|
||||
- **THEN** Service 层检查返回错误:"无权限创建平台账号",创建失败
|
||||
|
||||
### Requirement: 平台账号和超级管理员可以管理所有账号
|
||||
系统 SHALL 允许平台账号和超级管理员跳过所有权限检查,管理所有账号。
|
||||
|
||||
#### Scenario: 平台账号创建任意类型账号
|
||||
- **WHEN** 平台账号(user_type=2)创建代理账号(user_type=3, shop_id=100)
|
||||
- **THEN** 权限检查跳过,账号创建成功
|
||||
|
||||
#### Scenario: 超级管理员创建任意类型账号
|
||||
- **WHEN** 超级管理员(user_type=1)创建任意类型账号
|
||||
- **THEN** 权限检查跳过,账号创建成功
|
||||
|
||||
#### Scenario: 平台账号查询所有账号
|
||||
- **WHEN** 平台账号调用账号列表接口
|
||||
- **THEN** GORM Callback 跳过过滤,返回所有账号
|
||||
|
||||
### Requirement: 企业账号禁止访问账号管理接口
|
||||
系统 SHALL 禁止企业账号访问所有账号管理接口。
|
||||
|
||||
#### Scenario: 企业账号创建账号失败(路由层拦截)
|
||||
- **WHEN** 企业账号(user_type=4)调用 POST /api/admin/accounts/enterprise
|
||||
- **THEN** 路由层中间件返回 403 错误:"无权限访问账号管理功能"
|
||||
|
||||
#### Scenario: 企业账号更新账号失败(Service层拦截)
|
||||
- **WHEN** 企业账号绕过路由层,直接调用 AccountService.Update
|
||||
- **THEN** Service 层返回 403 错误:"企业账号不允许更新账号"
|
||||
|
||||
### Requirement: 统一错误返回防止信息泄露
|
||||
系统 SHALL 在越权访问时统一返回模糊错误消息,防止攻击者判断资源是否存在。
|
||||
|
||||
#### Scenario: 查询不存在的账号返回模糊错误
|
||||
- **WHEN** 用户查询不存在的账号 ID
|
||||
- **THEN** 返回 403 错误:"无权限操作该资源或资源不存在"
|
||||
|
||||
#### Scenario: 查询越权的账号返回相同错误
|
||||
- **WHEN** 代理账号(shop_id=100)查询 shop_id=200 的账号
|
||||
- **THEN** 返回 403 错误:"无权限操作该资源或资源不存在"(与不存在的错误消息相同)
|
||||
|
||||
### Requirement: CanManageShop 权限检查函数
|
||||
系统 SHALL 提供 CanManageShop 函数验证用户对目标店铺的管理权限。
|
||||
|
||||
#### Scenario: 验证代理对自己店铺的权限
|
||||
- **WHEN** 调用 CanManageShop(ctx, 100, shopStore) 且当前用户 shop_id=100
|
||||
- **THEN** 返回 nil(有权限)
|
||||
|
||||
#### Scenario: 验证代理对下级店铺的权限
|
||||
- **WHEN** 调用 CanManageShop(ctx, 101, shopStore) 且当前用户 shop_id=100,下级包含 101
|
||||
- **THEN** GetSubordinateShopIDs 返回 [100,101,102],返回 nil(有权限)
|
||||
|
||||
#### Scenario: 验证代理对其他店铺的权限失败
|
||||
- **WHEN** 调用 CanManageShop(ctx, 200, shopStore) 且当前用户 shop_id=100
|
||||
- **THEN** 返回错误:"无权限管理该店铺的账号"
|
||||
|
||||
#### Scenario: 验证平台账号自动通过
|
||||
- **WHEN** 调用 CanManageShop(ctx, 200, shopStore) 且当前用户 user_type=2(平台)
|
||||
- **THEN** 不调用 GetSubordinateShopIDs,直接返回 nil(有权限)
|
||||
|
||||
### Requirement: CanManageEnterprise 权限检查函数
|
||||
系统 SHALL 提供 CanManageEnterprise 函数验证用户对目标企业的管理权限。
|
||||
|
||||
#### Scenario: 验证平台账号管理任意企业
|
||||
- **WHEN** 调用 CanManageEnterprise(ctx, 50, enterpriseStore, shopStore) 且当前用户 user_type=2
|
||||
- **THEN** 返回 nil(有权限)
|
||||
|
||||
#### Scenario: 验证代理对归属企业的权限
|
||||
- **WHEN** 调用 CanManageEnterprise(ctx, 50, enterpriseStore, shopStore) 且企业 owner_shop_id=100,当前用户 shop_id=100
|
||||
- **THEN** 返回 nil(有权限)
|
||||
|
||||
#### Scenario: 验证代理对下级店铺企业的权限
|
||||
- **WHEN** 调用 CanManageEnterprise(ctx, 50, enterpriseStore, shopStore) 且企业 owner_shop_id=101,当前用户 shop_id=100,下级包含 101
|
||||
- **THEN** 返回 nil(有权限)
|
||||
|
||||
#### Scenario: 验证代理对其他店铺企业的权限失败
|
||||
- **WHEN** 调用 CanManageEnterprise(ctx, 50, enterpriseStore, shopStore) 且企业 owner_shop_id=200,当前用户 shop_id=100
|
||||
- **THEN** 返回错误:"无权限管理该企业的账号"
|
||||
|
||||
### Requirement: 权限检查性能优化
|
||||
系统 SHALL 使用 Redis 缓存优化权限检查性能,确保 API 响应时间 < 200ms。
|
||||
|
||||
#### Scenario: GetSubordinateShopIDs 命中缓存
|
||||
- **WHEN** 调用 GetSubordinateShopIDs(ctx, 100) 且缓存存在
|
||||
- **THEN** 从 Redis 读取缓存,不查询数据库,耗时 < 5ms
|
||||
|
||||
#### Scenario: GetSubordinateShopIDs 缓存未命中
|
||||
- **WHEN** 调用 GetSubordinateShopIDs(ctx, 100) 且缓存不存在
|
||||
- **THEN** 递归查询数据库,写入 Redis 缓存(30分钟),返回结果
|
||||
|
||||
#### Scenario: 权限检查总耗时 < 10ms
|
||||
- **WHEN** 执行完整权限检查(包含 GetSubordinateShopIDs)
|
||||
- **THEN** 总耗时 < 10ms(缓存命中时 < 5ms)
|
||||
@@ -1,753 +0,0 @@
|
||||
# Spec: 加油包生命周期管理
|
||||
|
||||
## 业务背景
|
||||
|
||||
### 为什么需要加油包生命周期管理
|
||||
|
||||
**现状问题**:
|
||||
- 加油包与主套餐无明确关联,导致主套餐过期后加油包仍可使用(业务逻辑混乱)
|
||||
- 加油包有效期管理不清晰,无法区分"独立有效期"和"跟随主套餐"两种模式
|
||||
- 主套餐切换时,旧加油包是否继承到新主套餐无明确规则
|
||||
- 用户购买加油包时无主套餐检查,可能导致加油包无法使用
|
||||
|
||||
**业务目标**:
|
||||
- 加油包必须依附于主套餐才能购买和使用
|
||||
- 主套餐过期时,其关联的加油包自动失效(级联失效)
|
||||
- 支持两种有效期模式:独立有效期(固定时长)和跟随主套餐(与主套餐同时到期)
|
||||
- 主套餐切换时,旧加油包不继承到新主套餐(用户需重新购买)
|
||||
|
||||
---
|
||||
|
||||
## 业务规则
|
||||
|
||||
### 1. 依附规则
|
||||
|
||||
加油包必须在有主套餐的情况下才能购买:
|
||||
|
||||
```
|
||||
购买加油包前置检查:
|
||||
1. 查询载体当前是否有主套餐(package_type=formal AND status IN (0待生效, 1生效中))
|
||||
2. 如果无主套餐 → 返回错误 400:"必须有主套餐才能购买加油包"
|
||||
3. 如果有主套餐 → 允许购买
|
||||
```
|
||||
|
||||
### 2. 关联规则
|
||||
|
||||
加油包创建时自动关联到当前生效中的主套餐:
|
||||
|
||||
```
|
||||
确定 master_usage_id 的逻辑:
|
||||
1. 查询载体当前生效中的主套餐(package_type=formal AND status=1)
|
||||
2. 如果有生效中主套餐 → master_usage_id = 该主套餐ID
|
||||
3. 如果无生效中主套餐,但有待生效主套餐(status=0)→ master_usage_id = priority 最小的待生效主套餐ID
|
||||
4. 创建 PackageUsage 记录:
|
||||
- package_type = addon
|
||||
- master_usage_id = 上述确定的主套餐ID
|
||||
- status = 0(待生效)
|
||||
- has_independent_expiry = 根据套餐配置
|
||||
```
|
||||
|
||||
### 3. 有效期模式
|
||||
|
||||
加油包支持两种有效期模式:
|
||||
|
||||
| 模式 | has_independent_expiry | 计算规则 | 过期条件 |
|
||||
|------|------------------------|----------|----------|
|
||||
| **独立有效期** | true | `expires_at = activated_at + duration_days` | 自身到期时间到达 |
|
||||
| **跟随主套餐** | false | `expires_at = master套餐.expires_at` | 主套餐到期时间到达 |
|
||||
|
||||
**独立有效期加油包**:
|
||||
- 激活时计算自己的 `expires_at`
|
||||
- 可能在主套餐之前过期
|
||||
- 到期后 `status=3`(已过期)
|
||||
|
||||
**跟随主套餐加油包**:
|
||||
- 激活时 `expires_at = master套餐.expires_at`
|
||||
- 主套餐 `expires_at` 更新时,同步更新所有跟随的加油包
|
||||
- 与主套餐同时到期
|
||||
|
||||
### 4. 级联失效规则
|
||||
|
||||
主套餐过期时,级联失效其所有关联的加油包:
|
||||
|
||||
```
|
||||
主套餐过期触发级联失效:
|
||||
1. 主套餐 status 变为 3(已过期)时触发
|
||||
2. 查询所有 master_usage_id = 主套餐ID 的加油包
|
||||
3. 批量更新这些加油包 status = 4(已失效)
|
||||
4. 不管加油包是否有独立有效期、是否已用完
|
||||
5. 记录级联失效日志
|
||||
```
|
||||
|
||||
**失效状态说明**:
|
||||
- `status=3`(已过期):自身有效期到达
|
||||
- `status=4`(已失效):主套餐过期导致的级联失效
|
||||
|
||||
### 5. 不继承规则
|
||||
|
||||
旧主套餐过期后,其加油包不继承到新主套餐:
|
||||
|
||||
```
|
||||
新主套餐激活时:
|
||||
1. 不更新旧加油包的 master_usage_id
|
||||
2. 旧加油包保持 status=4(已失效)
|
||||
3. 用户需为新主套餐重新购买加油包
|
||||
4. 新加油包 master_usage_id = 新主套餐ID
|
||||
```
|
||||
|
||||
### 6. 订单购买限制
|
||||
|
||||
**同订单禁止混买正式套餐和加油包**:
|
||||
|
||||
```
|
||||
订单创建校验规则:
|
||||
1. 检查订单项中是否同时包含 package_type=formal 和 package_type=addon
|
||||
2. 如果混买 → 返回错误 400:"同订单不能同时购买正式套餐和加油包"
|
||||
3. 原因:加油包依赖主套餐激活,订单处理时序无法保证主套餐先激活
|
||||
4. 解决方案:前端购物车分类展示,提示用户分两单购买
|
||||
```
|
||||
|
||||
**技术实现**:
|
||||
```go
|
||||
// 订单创建时校验
|
||||
func (s *OrderService) ValidateOrderItems(items []*OrderItem) error {
|
||||
hasMainPackage := false
|
||||
hasAddonPackage := false
|
||||
|
||||
for _, item := range items {
|
||||
pkg, err := s.packageStore.GetByID(item.PackageID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if pkg.PackageType == constants.PackageTypeFormal {
|
||||
hasMainPackage = true
|
||||
} else if pkg.PackageType == constants.PackageTypeAddon {
|
||||
hasAddonPackage = true
|
||||
}
|
||||
}
|
||||
|
||||
if hasMainPackage && hasAddonPackage {
|
||||
return errors.New(errors.CodeInvalidParam, "同订单不能同时购买正式套餐和加油包")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 加油包必须依附于主套餐
|
||||
|
||||
系统 SHALL 禁止在无主套餐(无 package_type=formal status=1 或 status=0 的套餐)时购买加油包。
|
||||
|
||||
#### Scenario: 无主套餐时购买加油包失败
|
||||
- **GIVEN** 载体 ICCID=123456,无任何主套餐(无 package_type=formal status IN (0,1))
|
||||
- **WHEN** 用户尝试购买加油包(package_type=addon)
|
||||
- **THEN** 系统返回错误 400,错误码 `ADDON_REQUIRES_MASTER`,错误消息:"必须有主套餐才能购买加油包"
|
||||
|
||||
#### Scenario: 有主套餐时可购买加油包
|
||||
- **GIVEN** 载体有生效中主套餐(ID=123, status=1)
|
||||
- **WHEN** 用户购买加油包(package_id=456)
|
||||
- **THEN** 系统创建订单成功,PackageUsage master_usage_id=123, package_type=addon, status=0
|
||||
|
||||
#### Scenario: 只有待生效主套餐时可购买加油包
|
||||
- **GIVEN** 载体有待生效主套餐(ID=123, status=0, priority=1)
|
||||
- **WHEN** 用户购买加油包
|
||||
- **THEN** 系统创建订单成功,加油包 master_usage_id=123
|
||||
|
||||
### Requirement: 加油包关联主套餐
|
||||
|
||||
系统 SHALL 在创建加油包使用记录时,将其 master_usage_id 设置为当前生效中或最高优先级待生效的主套餐ID。
|
||||
|
||||
#### Scenario: 加油包关联当前生效中主套餐
|
||||
- **GIVEN** 载体有生效中主套餐(ID=123, status=1)
|
||||
- **WHEN** 用户购买加油包
|
||||
- **THEN** 系统创建 PackageUsage:
|
||||
- master_usage_id=123
|
||||
- package_type=addon
|
||||
- status=0
|
||||
|
||||
#### Scenario: 多个主套餐时关联生效中的主套餐
|
||||
- **GIVEN** 载体有:
|
||||
- 生效中主套餐(ID=123, status=1, priority=1)
|
||||
- 待生效主套餐(ID=124, status=0, priority=2)
|
||||
- 待生效主套餐(ID=125, status=0, priority=3)
|
||||
- **WHEN** 用户购买加油包
|
||||
- **THEN** 加油包 master_usage_id=123(优先关联生效中的主套餐)
|
||||
|
||||
#### Scenario: 只有待生效主套餐时关联优先级最高的
|
||||
- **GIVEN** 载体有:
|
||||
- 待生效主套餐(ID=124, status=0, priority=1)
|
||||
- 待生效主套餐(ID=125, status=0, priority=2)
|
||||
- **WHEN** 用户购买加油包
|
||||
- **THEN** 加油包 master_usage_id=124(priority=1 最高)
|
||||
|
||||
### Requirement: 支持独立有效期加油包
|
||||
|
||||
系统 SHALL 支持加油包配置 has_independent_expiry=true,拥有独立的有效期。
|
||||
|
||||
#### Scenario: 独立有效期加油包激活时计算过期时间
|
||||
- **GIVEN** 加油包 has_independent_expiry=true,duration_days=30
|
||||
- **WHEN** 加油包在 2026-02-01 00:00:00 激活
|
||||
- **THEN** 系统计算 expires_at=2026-03-02 23:59:59(+30天)
|
||||
|
||||
#### Scenario: 独立有效期加油包过期
|
||||
- **GIVEN** 加油包 has_independent_expiry=true,expires_at=2026-02-28 23:59:59,data_usage_mb=50(未用完)
|
||||
- **WHEN** 系统时间到达 2026-03-01 00:00:00
|
||||
- **THEN** 定时任务将加油包 status 更新为 3(已过期)
|
||||
|
||||
#### Scenario: 独立有效期加油包在主套餐有效期内过期
|
||||
- **GIVEN** 主套餐有效期到 2026-12-31 23:59:59
|
||||
- **AND** 加油包 has_independent_expiry=true,expires_at=2026-03-31 23:59:59
|
||||
- **WHEN** 系统时间到达 2026-04-01 00:00:00
|
||||
- **THEN** 加油包 status=3(已过期),主套餐仍为 status=1(生效中)
|
||||
|
||||
#### Scenario: 独立有效期加油包在主套餐过期后仍失效
|
||||
- **GIVEN** 加油包 has_independent_expiry=true,expires_at=2026-12-31 23:59:59(未到期)
|
||||
- **AND** 主套餐 expires_at=2026-11-30 23:59:59
|
||||
- **WHEN** 主套餐在 2026-12-01 00:00:00 过期(status=3)
|
||||
- **THEN** 加油包被级联失效(status=4),不管自身 expires_at
|
||||
|
||||
### Requirement: 支持跟随主套餐的加油包
|
||||
|
||||
系统 SHALL 支持加油包配置 has_independent_expiry=false,跟随主套餐有效期。
|
||||
|
||||
#### Scenario: 跟随主套餐的加油包激活时同步到期时间
|
||||
- **GIVEN** 加油包 has_independent_expiry=false,master 主套餐 expires_at=2026-12-31 23:59:59
|
||||
- **WHEN** 加油包在 2026-02-01 00:00:00 激活
|
||||
- **THEN** 系统设置加油包 expires_at=2026-12-31 23:59:59(与主套餐相同)
|
||||
|
||||
#### Scenario: 主套餐更新有效期时同步加油包
|
||||
- **GIVEN** 主套餐 ID=123,expires_at=2026-12-31 23:59:59
|
||||
- **AND** 有3个加油包 master_usage_id=123,has_independent_expiry=false
|
||||
- **WHEN** 主套餐 expires_at 被更新为 2027-01-31 23:59:59
|
||||
- **THEN** 系统批量更新这3个加油包 expires_at=2027-01-31 23:59:59
|
||||
|
||||
#### Scenario: 主套餐有效期更新时不影响独立有效期加油包
|
||||
- **GIVEN** 主套餐 ID=123,expires_at=2026-12-31 23:59:59
|
||||
- **AND** 加油包A:has_independent_expiry=true,expires_at=2026-06-30 23:59:59
|
||||
- **AND** 加油包B:has_independent_expiry=false,expires_at=2026-12-31 23:59:59
|
||||
- **WHEN** 主套餐 expires_at 更新为 2027-01-31 23:59:59
|
||||
- **THEN** 加油包A expires_at 保持 2026-06-30 23:59:59(不变)
|
||||
- **AND** 加油包B expires_at 更新为 2027-01-31 23:59:59
|
||||
|
||||
#### Scenario: 跟随主套餐的加油包与主套餐同时过期
|
||||
- **GIVEN** 主套餐 expires_at=2026-12-31 23:59:59
|
||||
- **AND** 加油包 has_independent_expiry=false,expires_at=2026-12-31 23:59:59
|
||||
- **WHEN** 系统时间到达 2027-01-01 00:00:00
|
||||
- **THEN** 定时任务将主套餐和加油包 status 都更新为 3(已过期)
|
||||
|
||||
### Requirement: 主套餐过期时级联失效加油包
|
||||
|
||||
系统 SHALL 在主套餐过期(status 变为 3)时,将其所有关联加油包的 status 设置为 4(已失效)。
|
||||
|
||||
#### Scenario: 主套餐过期触发加油包失效
|
||||
- **GIVEN** 主套餐 ID=123,expires_at=2026-12-31 23:59:59
|
||||
- **AND** 有3个加油包 master_usage_id=123:
|
||||
- 加油包A:data_usage_mb=50(未用完)
|
||||
- 加油包B:data_usage_mb=200(已用完)
|
||||
- 加油包C:has_independent_expiry=true,expires_at=2027-06-30(未到期)
|
||||
- **WHEN** 系统时间到达 2027-01-01 00:00:00,主套餐 status=3
|
||||
- **THEN** 系统批量更新这3个加油包 status=4(已失效)
|
||||
|
||||
#### Scenario: 独立有效期加油包也会级联失效
|
||||
- **GIVEN** 主套餐 expires_at=2026-11-30 23:59:59
|
||||
- **AND** 加油包 has_independent_expiry=true,expires_at=2026-12-31 23:59:59(晚于主套餐)
|
||||
- **WHEN** 主套餐在 2026-12-01 00:00:00 过期
|
||||
- **THEN** 加油包 status=4(已失效),不管自身还有30天才到期
|
||||
|
||||
#### Scenario: 已过期加油包不重复失效
|
||||
- **GIVEN** 主套餐 expires_at=2026-12-31 23:59:59
|
||||
- **AND** 加油包 has_independent_expiry=true,expires_at=2026-11-30 23:59:59,status=3(已过期)
|
||||
- **WHEN** 主套餐在 2027-01-01 00:00:00 过期
|
||||
- **THEN** 加油包 status 保持 3(已过期),不更新为 4
|
||||
|
||||
#### Scenario: 级联失效记录到审计日志
|
||||
- **GIVEN** 主套餐 ID=123 过期,有5个关联加油包
|
||||
- **WHEN** 系统执行级联失效
|
||||
- **THEN** 系统记录审计日志:
|
||||
- operation_type=cascade_invalidate
|
||||
- operation_desc="主套餐ID=123过期,级联失效5个加油包"
|
||||
- before_data=加油包列表及原状态
|
||||
- after_data=加油包列表及新状态(status=4)
|
||||
|
||||
### Requirement: 加油包不继承到新主套餐
|
||||
|
||||
系统 SHALL 确保旧主套餐过期后,其加油包不会自动关联到新激活的主套餐。
|
||||
|
||||
#### Scenario: 新主套餐激活后加油包不关联
|
||||
- **GIVEN** 主套餐A(ID=123)在 2026-12-31 过期,其加油包已失效(status=4)
|
||||
- **WHEN** 主套餐B(ID=124)在 2027-01-01 激活(priority=2 → status=1)
|
||||
- **THEN** 主套餐A的加油包 master_usage_id 保持 123,status 保持 4
|
||||
- **AND** 主套餐B 无关联加油包
|
||||
|
||||
#### Scenario: 用户需为新主套餐重新购买加油包
|
||||
- **GIVEN** 主套餐B(ID=124)刚激活(status=1)
|
||||
- **WHEN** 用户购买新加油包
|
||||
- **THEN** 新加油包 master_usage_id=124,status=0
|
||||
|
||||
#### Scenario: 旧加油包不可重新激活
|
||||
- **GIVEN** 主套餐A的加油包(ID=999)已失效(status=4)
|
||||
- **WHEN** 用户尝试手动激活这个加油包
|
||||
- **THEN** 系统返回错误 400,错误码 `ADDON_MASTER_EXPIRED`,错误消息:"关联的主套餐已过期,无法激活加油包"
|
||||
|
||||
---
|
||||
|
||||
## 边界条件
|
||||
|
||||
### 1. 主套餐失效但加油包未用完
|
||||
|
||||
- **场景**:主套餐过期时,加油包流量只用了10%
|
||||
- **处理**:仍然级联失效(status=4),剩余流量不可用
|
||||
- **业务规则**:加油包依附于主套餐,主套餐失效则加油包失效
|
||||
|
||||
### 2. 多个主套餐同时存在
|
||||
|
||||
- **场景**:有1个生效中主套餐 + 2个待生效主套餐
|
||||
- **购买加油包时**:关联到生效中的主套餐
|
||||
- **主套餐A过期后**:加油包随A失效,不继承到主套餐B
|
||||
|
||||
### 3. 并发购买加油包
|
||||
|
||||
- **场景**:两个请求同时为同一载体购买加油包
|
||||
- **处理**:
|
||||
- 使用事务 + 行锁:`SELECT * FROM package_usage WHERE carrier_id=? AND package_type=formal AND status IN (0,1) ORDER BY status DESC, priority ASC FOR UPDATE`
|
||||
- 确保两个加油包关联到同一个主套餐
|
||||
|
||||
### 4. 主套餐有效期更新失败
|
||||
|
||||
- **场景**:主套餐 expires_at 更新时,同步跟随加油包失败
|
||||
- **处理**:
|
||||
- 使用事务包裹主套餐更新和加油包批量更新
|
||||
- 更新失败则回滚,返回错误 500
|
||||
- 记录错误日志,包含主套餐ID和失败原因
|
||||
|
||||
### 5. 级联失效失败
|
||||
|
||||
- **场景**:主套餐过期时,批量更新加油包失败(数据库连接断开)
|
||||
- **处理**:
|
||||
- 使用 Asynq 重试机制(最多3次)
|
||||
- 每次重试前检查加油包当前状态,避免重复更新
|
||||
- 3次失败后写入死信队列,发送告警
|
||||
|
||||
---
|
||||
|
||||
## 并发场景
|
||||
|
||||
### Scenario: 并发购买加油包
|
||||
- **GIVEN** 载体有生效中主套餐(ID=123)
|
||||
- **WHEN** 两个请求 req1 和 req2 同时购买加油包
|
||||
- **THEN** 系统使用行锁:
|
||||
```sql
|
||||
SELECT * FROM package_usage
|
||||
WHERE carrier_id=? AND package_type='formal' AND status IN (0,1)
|
||||
ORDER BY status DESC, priority ASC
|
||||
FOR UPDATE
|
||||
```
|
||||
- **AND** req1 和 req2 创建的加油包 master_usage_id 都为 123
|
||||
|
||||
### Scenario: 并发主套餐过期和购买加油包
|
||||
- **GIVEN** 主套餐A(ID=123)即将过期,主套餐B(ID=124)待生效
|
||||
- **WHEN** 时间到达过期时刻:
|
||||
- 请求1:定时任务将主套餐A status=3,触发级联失效
|
||||
- 请求2:用户购买加油包
|
||||
- **THEN** 使用事务隔离:
|
||||
- 如果请求2先获取锁 → 加油包 master_usage_id=123,然后被级联失效(status=4)
|
||||
- 如果请求1先获取锁 → 主套餐A已无生效中,加油包 master_usage_id=124
|
||||
|
||||
### Scenario: 并发更新主套餐有效期和级联失效
|
||||
- **GIVEN** 主套餐 ID=123,有5个跟随的加油包(has_independent_expiry=false)
|
||||
- **WHEN** 同时发生:
|
||||
- 请求1:主套餐 expires_at 更新为 2027-12-31
|
||||
- 请求2:主套餐到期,触发级联失效
|
||||
- **THEN** 使用行锁 `SELECT * FROM package_usage WHERE id=123 FOR UPDATE`
|
||||
- **AND** 先完成的操作生效,后完成的操作基于新状态执行
|
||||
|
||||
---
|
||||
|
||||
## 异常处理
|
||||
|
||||
### 1. 级联失效失败
|
||||
|
||||
- **错误场景**:主套餐过期时,批量更新加油包 SQL 执行失败
|
||||
- **处理流程**:
|
||||
1. 捕获错误,记录 Error 日志(包含主套餐ID、加油包数量、错误信息)
|
||||
2. Asynq 自动重试(最多3次,间隔 10s/30s/60s)
|
||||
3. 重试前检查加油包当前状态(避免重复更新)
|
||||
4. 3次失败后写入死信队列,发送告警通知
|
||||
- **返回错误**:不返回给用户(异步任务),仅记录日志
|
||||
|
||||
### 2. master_usage_id 不存在
|
||||
|
||||
- **错误场景**:加油包的 master_usage_id 指向的主套餐被删除
|
||||
- **处理流程**:
|
||||
1. 加油包激活时检查 `SELECT id FROM package_usage WHERE id=master_usage_id`
|
||||
2. 如果不存在 → 返回错误 500,错误码 `MASTER_NOT_FOUND`
|
||||
3. 记录 Error 日志(包含加油包ID、master_usage_id、载体信息)
|
||||
- **返回错误**:`{"code": "MASTER_NOT_FOUND", "msg": "关联的主套餐不存在,请联系管理员"}`
|
||||
|
||||
### 3. 同步有效期失败
|
||||
|
||||
- **错误场景**:主套餐 expires_at 更新时,批量更新跟随加油包失败
|
||||
- **处理流程**:
|
||||
1. 使用事务包裹主套餐更新和加油包批量更新
|
||||
2. 加油包更新失败 → 事务回滚,主套餐 expires_at 不更新
|
||||
3. 记录 Error 日志(包含主套餐ID、加油包数量、错误信息)
|
||||
4. 返回错误 500,错误码 `SYNC_EXPIRY_FAILED`
|
||||
- **返回错误**:`{"code": "SYNC_EXPIRY_FAILED", "msg": "更新套餐有效期失败,请稍后重试"}`
|
||||
|
||||
### 4. 购买加油包时无主套餐
|
||||
|
||||
- **错误场景**:用户购买加油包时,载体无任何主套餐
|
||||
- **处理流程**:
|
||||
1. 查询载体主套餐:`SELECT id FROM package_usage WHERE carrier_id=? AND package_type='formal' AND status IN (0,1) LIMIT 1`
|
||||
2. 如果无结果 → 返回错误 400,错误码 `ADDON_REQUIRES_MASTER`
|
||||
- **返回错误**:`{"code": "ADDON_REQUIRES_MASTER", "msg": "必须有主套餐才能购买加油包"}`
|
||||
|
||||
---
|
||||
|
||||
## 数据一致性保证
|
||||
|
||||
### 1. 事务边界
|
||||
|
||||
- **主套餐过期 + 级联失效**:使用单个事务,确保原子性
|
||||
- **主套餐更新有效期 + 同步加油包**:使用单个事务,更新失败则回滚
|
||||
- **购买加油包 + 关联主套餐**:使用事务,确保 master_usage_id 正确
|
||||
|
||||
### 2. 行锁机制
|
||||
|
||||
- **查询主套餐时加锁**:`SELECT * FROM package_usage WHERE carrier_id=? AND package_type='formal' AND status IN (0,1) FOR UPDATE`
|
||||
- **更新主套餐有效期时加锁**:`SELECT * FROM package_usage WHERE id=? FOR UPDATE`
|
||||
- **级联失效时加锁**:`SELECT * FROM package_usage WHERE master_usage_id=? FOR UPDATE`
|
||||
|
||||
### 3. 唯一索引
|
||||
|
||||
- 已有索引:`idx_carrier_package_type_priority`(carrier_id + package_type + priority)
|
||||
- 已有索引:`idx_master_usage_id`(master_usage_id)
|
||||
|
||||
### 4. 数据校验
|
||||
|
||||
- **购买加油包前**:校验 has_independent_expiry 与 duration_days 的一致性
|
||||
- **激活加油包时**:校验 master_usage_id 是否存在
|
||||
- **级联失效时**:仅更新 status NOT IN (3, 4) 的加油包(避免重复更新)
|
||||
|
||||
---
|
||||
|
||||
## 性能指标
|
||||
|
||||
| 操作 | 目标响应时间 | 并发要求 | 数据量 |
|
||||
|------|-------------|---------|--------|
|
||||
| 购买加油包(主套餐检查) | < 50ms | 100 QPS | 单载体查询 |
|
||||
| 关联主套餐(查询+插入) | < 100ms | 100 QPS | 单载体查询 + 单条插入 |
|
||||
| 主套餐过期级联失效 | < 500ms | 10 QPS | 批量更新(平均10个加油包) |
|
||||
| 主套餐更新有效期同步 | < 300ms | 50 QPS | 批量更新(平均5个加油包) |
|
||||
|
||||
---
|
||||
|
||||
## 错误码定义
|
||||
|
||||
| 错误码 | HTTP 状态码 | 错误消息 | 场景 |
|
||||
|--------|------------|---------|------|
|
||||
| `ADDON_REQUIRES_MASTER` | 400 | 必须有主套餐才能购买加油包 | 购买加油包时无主套餐 |
|
||||
| `MASTER_NOT_FOUND` | 500 | 关联的主套餐不存在,请联系管理员 | master_usage_id 不存在 |
|
||||
| `ADDON_MASTER_EXPIRED` | 400 | 关联的主套餐已过期,无法激活加油包 | 尝试激活已失效加油包 |
|
||||
| `SYNC_EXPIRY_FAILED` | 500 | 更新套餐有效期失败,请稍后重试 | 同步加油包有效期失败 |
|
||||
| `CASCADE_INVALIDATE_FAILED` | 500 | 级联失效加油包失败,请稍后重试 | 级联失效批量更新失败 |
|
||||
|
||||
---
|
||||
|
||||
## 数据迁移策略
|
||||
|
||||
**激进策略**(开发阶段,保证干净性):
|
||||
|
||||
### 1. ❌ 要删除的字段
|
||||
|
||||
目前 `package_usage` 表中可能存在的冗余字段(需确认后删除):
|
||||
- 如果有 `parent_usage_id` 字段(旧的父级关联) → **删除**
|
||||
- 如果有 `linked_usage_ids` 字段(旧的关联列表) → **删除**
|
||||
- 如果有 `inherit_to_next` 字段(旧的继承标志) → **删除**
|
||||
|
||||
### 2. ✅ 新增的字段
|
||||
|
||||
在 `package_usage` 表中新增:
|
||||
```sql
|
||||
ALTER TABLE package_usage
|
||||
ADD COLUMN master_usage_id BIGINT DEFAULT NULL COMMENT '主套餐ID(加油包专用)',
|
||||
ADD COLUMN has_independent_expiry BOOLEAN DEFAULT false COMMENT '是否有独立有效期(加油包专用)';
|
||||
|
||||
CREATE INDEX idx_master_usage_id ON package_usage(master_usage_id);
|
||||
```
|
||||
|
||||
在 `package` 表中新增:
|
||||
```sql
|
||||
ALTER TABLE package
|
||||
ADD COLUMN has_independent_expiry BOOLEAN DEFAULT false COMMENT '加油包是否有独立有效期(仅 package_type=addon 时有效)';
|
||||
```
|
||||
|
||||
### 3. ❌ 要废弃的逻辑
|
||||
|
||||
- **废弃旧的加油包关联逻辑**:如果代码中存在通过 `parent_usage_id` 或其他字段关联主套餐的逻辑,全部删除
|
||||
- **废弃旧的继承逻辑**:如果代码中存在"主套餐切换时加油包继承到新主套餐"的逻辑,全部删除
|
||||
- **废弃旧的有效期计算逻辑**:如果加油包有效期计算不区分"独立有效期"和"跟随主套餐",全部重构
|
||||
|
||||
### 4. ✅ 历史数据强制转换
|
||||
|
||||
```sql
|
||||
-- Step 1: 历史加油包数据强制关联到当前主套餐
|
||||
UPDATE package_usage pu_addon
|
||||
SET master_usage_id = (
|
||||
SELECT pu_master.id
|
||||
FROM package_usage pu_master
|
||||
WHERE pu_master.carrier_id = pu_addon.carrier_id
|
||||
AND pu_master.package_type = 'formal'
|
||||
AND pu_master.status IN (0, 1)
|
||||
ORDER BY pu_master.status DESC, pu_master.priority ASC
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE pu_addon.package_type = 'addon'
|
||||
AND pu_addon.master_usage_id IS NULL;
|
||||
|
||||
-- Step 2: 无主套餐的历史加油包强制失效
|
||||
UPDATE package_usage
|
||||
SET status = 4,
|
||||
invalidated_at = NOW()
|
||||
WHERE package_type = 'addon'
|
||||
AND master_usage_id IS NULL;
|
||||
|
||||
-- Step 3: 历史加油包默认为独立有效期模式
|
||||
UPDATE package_usage
|
||||
SET has_independent_expiry = true
|
||||
WHERE package_type = 'addon'
|
||||
AND has_independent_expiry IS NULL;
|
||||
|
||||
-- Step 4: 已过期主套餐的加油包全部级联失效
|
||||
UPDATE package_usage pu_addon
|
||||
SET status = 4,
|
||||
invalidated_at = NOW()
|
||||
FROM package_usage pu_master
|
||||
WHERE pu_addon.master_usage_id = pu_master.id
|
||||
AND pu_master.package_type = 'formal'
|
||||
AND pu_master.status = 3 -- 已过期
|
||||
AND pu_addon.status NOT IN (3, 4);
|
||||
```
|
||||
|
||||
### 5. ❌ 删除遗留表/字段(确认后执行)
|
||||
|
||||
```sql
|
||||
-- 如果存在旧的关联表,删除
|
||||
-- DROP TABLE IF EXISTS package_usage_relations;
|
||||
|
||||
-- 如果存在冗余字段,删除
|
||||
-- ALTER TABLE package_usage DROP COLUMN IF EXISTS parent_usage_id;
|
||||
-- ALTER TABLE package_usage DROP COLUMN IF EXISTS linked_usage_ids;
|
||||
-- ALTER TABLE package_usage DROP COLUMN IF EXISTS inherit_to_next;
|
||||
```
|
||||
|
||||
### 6. 验证步骤
|
||||
|
||||
```sql
|
||||
-- 验证1:所有加油包都有 master_usage_id(除了已失效的)
|
||||
SELECT COUNT(*)
|
||||
FROM package_usage
|
||||
WHERE package_type = 'addon'
|
||||
AND status NOT IN (3, 4)
|
||||
AND master_usage_id IS NULL;
|
||||
-- 预期结果:0
|
||||
|
||||
-- 验证2:所有加油包的 master_usage_id 都指向有效的主套餐
|
||||
SELECT COUNT(*)
|
||||
FROM package_usage pu_addon
|
||||
LEFT JOIN package_usage pu_master ON pu_addon.master_usage_id = pu_master.id
|
||||
WHERE pu_addon.package_type = 'addon'
|
||||
AND pu_addon.master_usage_id IS NOT NULL
|
||||
AND pu_master.id IS NULL;
|
||||
-- 预期结果:0
|
||||
|
||||
-- 验证3:已过期主套餐的加油包都已失效
|
||||
SELECT COUNT(*)
|
||||
FROM package_usage pu_addon
|
||||
JOIN package_usage pu_master ON pu_addon.master_usage_id = pu_master.id
|
||||
WHERE pu_master.status = 3
|
||||
AND pu_addon.status NOT IN (3, 4);
|
||||
-- 预期结果:0
|
||||
|
||||
-- 验证4:检查是否还有遗留字段(需根据实际情况调整)
|
||||
-- SELECT column_name FROM information_schema.columns
|
||||
-- WHERE table_name = 'package_usage'
|
||||
-- AND column_name IN ('parent_usage_id', 'linked_usage_ids', 'inherit_to_next');
|
||||
-- 预期结果:0 rows
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试场景矩阵
|
||||
|
||||
| 场景分类 | 测试用例 | 预期结果 |
|
||||
|---------|---------|---------|
|
||||
| **依附检查** | 无主套餐购买加油包 | 返回错误 400:ADDON_REQUIRES_MASTER |
|
||||
| | 有生效中主套餐购买加油包 | 创建成功,master_usage_id=生效中主套餐ID |
|
||||
| | 只有待生效主套餐购买加油包 | 创建成功,master_usage_id=priority最小的待生效主套餐ID |
|
||||
| **关联逻辑** | 多个主套餐时购买加油包 | 优先关联生效中主套餐 |
|
||||
| | 并发购买加油包 | 使用行锁,两个加油包关联到同一主套餐 |
|
||||
| **独立有效期** | 独立有效期加油包激活 | expires_at = activated_at + duration_days |
|
||||
| | 独立有效期加油包到期 | status=3(已过期) |
|
||||
| | 独立有效期加油包未到期但主套餐过期 | status=4(已失效) |
|
||||
| **跟随主套餐** | 跟随主套餐的加油包激活 | expires_at = master套餐.expires_at |
|
||||
| | 主套餐更新有效期 | 跟随加油包同步更新 expires_at |
|
||||
| | 主套餐更新有效期时独立有效期加油包不变 | 独立有效期加油包 expires_at 不变 |
|
||||
| **级联失效** | 主套餐过期触发级联失效 | 所有关联加油包 status=4 |
|
||||
| | 独立有效期加油包未到期但主套餐过期 | status=4(已失效) |
|
||||
| | 已过期加油包不重复失效 | status 保持 3 |
|
||||
| | 级联失效失败重试 | Asynq 重试3次,失败后进入死信队列 |
|
||||
| **不继承** | 新主套餐激活后旧加油包不关联 | 旧加油包 master_usage_id 和 status 保持不变 |
|
||||
| | 为新主套餐购买新加油包 | 新加油包 master_usage_id=新主套餐ID |
|
||||
| | 尝试激活已失效加油包 | 返回错误 400:ADDON_MASTER_EXPIRED |
|
||||
| **并发** | 并发购买加油包 | 使用行锁,确保关联到同一主套餐 |
|
||||
| | 并发主套餐过期和购买加油包 | 事务隔离,先完成的操作生效 |
|
||||
| **异常** | master_usage_id 不存在 | 返回错误 500:MASTER_NOT_FOUND |
|
||||
| | 同步有效期失败 | 事务回滚,返回错误 500:SYNC_EXPIRY_FAILED |
|
||||
| | 级联失效失败 | Asynq 重试,记录日志,发送告警 |
|
||||
|
||||
---
|
||||
|
||||
## 实现参考
|
||||
|
||||
### 购买加油包时的主套餐检查
|
||||
|
||||
```go
|
||||
// Service 层:CheckMasterPackageForAddon
|
||||
func (s *Service) CheckMasterPackageForAddon(ctx context.Context, carrierID uint) (uint, error) {
|
||||
// 查询生效中或待生效的主套餐
|
||||
masterUsage, err := s.store.FindMasterPackage(ctx, carrierID)
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(errors.CodeInternalError, err, "查询主套餐失败")
|
||||
}
|
||||
if masterUsage == nil {
|
||||
return 0, errors.New(errors.CodeInvalidParam, "必须有主套餐才能购买加油包")
|
||||
}
|
||||
return masterUsage.ID, nil
|
||||
}
|
||||
|
||||
// Store 层:FindMasterPackage
|
||||
func (s *Store) FindMasterPackage(ctx context.Context, carrierID uint) (*model.PackageUsage, error) {
|
||||
var usage model.PackageUsage
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("carrier_id = ? AND package_type = ? AND status IN (?, ?)",
|
||||
carrierID, constants.PackageTypeFormal,
|
||||
constants.PackageStatusPending, constants.PackageStatusActive).
|
||||
Order("status DESC, priority ASC"). // 优先生效中,然后按 priority
|
||||
First(&usage).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &usage, nil
|
||||
}
|
||||
```
|
||||
|
||||
### 主套餐过期时级联失效加油包
|
||||
|
||||
```go
|
||||
// Service 层:CascadeInvalidateAddons
|
||||
func (s *Service) CascadeInvalidateAddons(ctx context.Context, masterUsageID uint) error {
|
||||
tx := s.store.BeginTx(ctx)
|
||||
defer tx.Rollback()
|
||||
|
||||
// 批量更新加油包状态
|
||||
count, err := s.store.InvalidateAddonsByMaster(ctx, tx, masterUsageID)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "级联失效加油包失败")
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "提交事务失败")
|
||||
}
|
||||
|
||||
// 记录审计日志(异步)
|
||||
s.auditService.LogOperation(ctx, &model.OperationLog{
|
||||
OperationType: "cascade_invalidate",
|
||||
OperationDesc: fmt.Sprintf("主套餐ID=%d过期,级联失效%d个加油包", masterUsageID, count),
|
||||
TargetID: masterUsageID,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Store 层:InvalidateAddonsByMaster
|
||||
func (s *Store) InvalidateAddonsByMaster(ctx context.Context, tx *gorm.DB, masterUsageID uint) (int64, error) {
|
||||
result := tx.WithContext(ctx).
|
||||
Model(&model.PackageUsage{}).
|
||||
Where("master_usage_id = ? AND status NOT IN (?, ?)",
|
||||
masterUsageID,
|
||||
constants.PackageStatusExpired,
|
||||
constants.PackageStatusInvalidated).
|
||||
Updates(map[string]interface{}{
|
||||
"status": constants.PackageStatusInvalidated,
|
||||
"invalidated_at": time.Now(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return 0, result.Error
|
||||
}
|
||||
return result.RowsAffected, nil
|
||||
}
|
||||
```
|
||||
|
||||
### 主套餐更新有效期时同步跟随加油包
|
||||
|
||||
```go
|
||||
// Service 层:SyncAddonExpiry
|
||||
func (s *Service) SyncAddonExpiry(ctx context.Context, masterUsageID uint, newExpiresAt time.Time) error {
|
||||
tx := s.store.BeginTx(ctx)
|
||||
defer tx.Rollback()
|
||||
|
||||
// 更新主套餐有效期
|
||||
if err := s.store.UpdateExpiry(ctx, tx, masterUsageID, newExpiresAt); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "更新主套餐有效期失败")
|
||||
}
|
||||
|
||||
// 批量更新跟随的加油包
|
||||
count, err := s.store.SyncFollowingAddonExpiry(ctx, tx, masterUsageID, newExpiresAt)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "同步加油包有效期失败")
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "提交事务失败")
|
||||
}
|
||||
|
||||
s.logger.Info("同步加油包有效期成功",
|
||||
zap.Uint("master_usage_id", masterUsageID),
|
||||
zap.Int64("count", count),
|
||||
zap.Time("new_expires_at", newExpiresAt))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Store 层:SyncFollowingAddonExpiry
|
||||
func (s *Store) SyncFollowingAddonExpiry(ctx context.Context, tx *gorm.DB, masterUsageID uint, expiresAt time.Time) (int64, error) {
|
||||
result := tx.WithContext(ctx).
|
||||
Model(&model.PackageUsage{}).
|
||||
Where("master_usage_id = ? AND has_independent_expiry = ?", masterUsageID, false).
|
||||
Update("expires_at", expiresAt)
|
||||
if result.Error != nil {
|
||||
return 0, result.Error
|
||||
}
|
||||
return result.RowsAffected, nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**本 Spec 完成**,包含:
|
||||
- ✅ 业务背景和业务规则
|
||||
- ✅ 详细场景(依附、关联、独立有效期、跟随主套餐、级联失效、不继承)
|
||||
- ✅ 边界条件和并发场景
|
||||
- ✅ 异常处理和数据一致性保证
|
||||
- ✅ 性能指标和错误码定义
|
||||
- ✅ **激进的数据迁移策略**(明确删除字段、废弃逻辑、强制转换)
|
||||
- ✅ 测试场景矩阵和实现参考
|
||||
@@ -1,263 +0,0 @@
|
||||
# Admin Order Creation
|
||||
|
||||
## Purpose
|
||||
|
||||
后台订单创建流程,为代理和平台账号提供订单创建功能。与 H5 端订单创建的核心区别:后台仅支持 wallet/offline 支付方式,且 wallet 支付立即完成扣款和套餐激活(一步到位),不创建待支付订单。
|
||||
|
||||
This capability supports:
|
||||
- 参数验证和支付方式限制
|
||||
- 钱包余额检查和一步扣款
|
||||
- 权限校验(代理、平台、超管)
|
||||
- 错误处理和防御性编程
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 后台订单创建 API 参数验证
|
||||
|
||||
系统 SHALL 在后台订单创建 API 中强制验证请求参数,拒绝非法的支付方式。
|
||||
|
||||
后台订单创建使用独立的 DTO(`CreateAdminOrderRequest`),仅允许 `wallet` 和 `offline` 两种支付方式。Handler 层 MUST 调用 `middleware.ValidateStruct(&req)` 验证参数,确保 DTO 的 `validate:"oneof=wallet offline"` 规则生效。
|
||||
|
||||
#### Scenario: DTO 验证拒绝非法支付方式
|
||||
|
||||
- **WHEN** 后台创建订单请求中 `payment_method` 为 `wechat` 或 `alipay`
|
||||
- **THEN** 系统在 Handler 层验证失败,返回错误"请求参数解析失败"(`CodeInvalidParam`),订单创建失败
|
||||
|
||||
#### Scenario: DTO 验证拒绝空支付方式
|
||||
|
||||
- **WHEN** 后台创建订单请求中缺少 `payment_method` 字段或值为空字符串
|
||||
- **THEN** 系统在 Handler 层验证失败,返回错误"请求参数解析失败"(`CodeInvalidParam`),订单创建失败
|
||||
|
||||
#### Scenario: DTO 验证允许 wallet 支付
|
||||
|
||||
- **WHEN** 后台创建订单请求中 `payment_method` 为 `wallet`
|
||||
- **THEN** 系统通过 DTO 验证,继续后续业务逻辑
|
||||
|
||||
#### Scenario: DTO 验证允许 offline 支付
|
||||
|
||||
- **WHEN** 后台创建订单请求中 `payment_method` 为 `offline`
|
||||
- **THEN** 系统通过 DTO 验证,继续后续业务逻辑
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 后台订单创建权限检查
|
||||
|
||||
系统 SHALL 在后台订单创建时完整检查支付方式权限,所有支付方式(包括非法的)都必须经过权限校验。
|
||||
|
||||
权限规则:
|
||||
- `offline` 支付:仅超管和平台账号可用
|
||||
- `wallet` 支付:代理、平台、超管均可用
|
||||
- 其他支付方式:一律拒绝(兜底检查)
|
||||
|
||||
#### Scenario: 超管可以使用 offline 支付
|
||||
|
||||
- **WHEN** 超管账号创建订单,支付方式为 `offline`
|
||||
- **THEN** 系统通过权限检查,继续创建订单
|
||||
|
||||
#### Scenario: 平台账号可以使用 offline 支付
|
||||
|
||||
- **WHEN** 平台账号创建订单,支付方式为 `offline`
|
||||
- **THEN** 系统通过权限检查,继续创建订单
|
||||
|
||||
#### Scenario: 代理账号不能使用 offline 支付
|
||||
|
||||
- **WHEN** 代理账号创建订单,支付方式为 `offline`
|
||||
- **THEN** 系统返回错误"只有平台可以使用线下支付"(`CodeForbidden`),订单创建失败
|
||||
|
||||
#### Scenario: 代理账号可以使用 wallet 支付
|
||||
|
||||
- **WHEN** 代理账号创建订单,支付方式为 `wallet`,钱包余额充足
|
||||
- **THEN** 系统通过权限检查,继续创建订单
|
||||
|
||||
#### Scenario: 兜底检查拒绝其他支付方式
|
||||
|
||||
- **WHEN** 后台创建订单请求中 `payment_method` 为 `wechat`(虽然 DTO 验证应该已拒绝,但作为防御性编程)
|
||||
- **THEN** 系统在 Handler 层返回错误"后台仅支持钱包支付或线下支付"(`CodeInvalidParam`)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 后台 wallet 订单一步到位
|
||||
|
||||
系统 SHALL 在后台创建 wallet 订单时立即完成余额扣款和套餐激活,不创建待支付订单。订单创建成功后 `payment_status` MUST 为 2(已支付)。
|
||||
|
||||
与 H5 端的核心区别:
|
||||
- **后台**:检查余额 → 扣款 → 创建已支付订单 → 激活套餐(一步完成)
|
||||
- **H5 端**:冻结余额 → 创建待支付订单 → 用户调用支付接口 → 扣款 + 激活(两步流程)
|
||||
|
||||
#### Scenario: 后台 wallet 订单立即扣款
|
||||
|
||||
- **WHEN** 代理在后台创建订单,支付方式为 `wallet`,钱包余额 5000 分,订单金额 3000 分
|
||||
- **THEN** 系统立即扣减钱包余额 3000 分,余额变为 2000 分,创建订单时 `payment_status` = 2,`paid_at` 为当前时间
|
||||
|
||||
#### Scenario: 后台 wallet 订单立即激活套餐
|
||||
|
||||
- **WHEN** 代理在后台创建 wallet 订单成功
|
||||
- **THEN** 系统在同一事务中创建 `PackageUsage` 记录,套餐状态为已激活
|
||||
|
||||
#### Scenario: 后台 wallet 订单不创建待支付状态
|
||||
|
||||
- **WHEN** 代理在后台创建 wallet 订单
|
||||
- **THEN** 系统不创建 `payment_status` = 1(待支付)的订单,订单创建后立即为已支付状态
|
||||
|
||||
#### Scenario: 后台 wallet 订单余额不足直接拒绝
|
||||
|
||||
- **WHEN** 代理在后台创建 wallet 订单,钱包余额 1000 分,订单金额 3000 分
|
||||
- **THEN** 系统在事务外快速检查余额,返回错误"余额不足"(`CodeInsufficientBalance`),订单创建失败
|
||||
|
||||
#### Scenario: 后台 wallet 订单事务保证
|
||||
|
||||
- **WHEN** 代理在后台创建 wallet 订单
|
||||
- **THEN** 订单创建、余额扣减、套餐激活在同一事务中完成,任一步骤失败则全部回滚
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 后台 offline 订单立即激活
|
||||
|
||||
系统 SHALL 在后台创建 offline 订单时立即激活套餐,不扣减钱包余额。订单创建成功后 `payment_status` MUST 为 2(已支付)。
|
||||
|
||||
#### Scenario: 平台创建 offline 订单立即激活
|
||||
|
||||
- **WHEN** 平台账号创建订单,支付方式为 `offline`
|
||||
- **THEN** 系统创建订单时 `payment_status` = 2,`paid_at` 为当前时间,立即激活套餐
|
||||
|
||||
#### Scenario: offline 订单不扣钱包
|
||||
|
||||
- **WHEN** 平台账号创建 offline 订单
|
||||
- **THEN** 系统不扣减任何钱包余额(因为是线下支付)
|
||||
|
||||
#### Scenario: offline 订单不检查余额
|
||||
|
||||
- **WHEN** 平台账号创建 offline 订单,钱包余额为 0
|
||||
- **THEN** 系统仍然创建订单成功(因为线下支付不依赖钱包)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 后台订单创建错误处理
|
||||
|
||||
系统 SHALL 在后台订单创建失败时返回明确的错误信息,不泄露底层细节。
|
||||
|
||||
错误码使用规范:
|
||||
- 参数验证失败:`CodeInvalidParam`(不泄露具体校验错误)
|
||||
- 权限不足:`CodeForbidden`
|
||||
- 余额不足:`CodeInsufficientBalance`
|
||||
- 钱包不存在:`CodeWalletNotFound`
|
||||
- 其他错误:`CodeInternalError`
|
||||
|
||||
#### Scenario: 参数验证失败不泄露细节
|
||||
|
||||
- **WHEN** 后台创建订单请求参数验证失败(如支付方式非法)
|
||||
- **THEN** 系统返回 `CodeInvalidParam` 错误码,错误消息为通用的"请求参数解析失败",不包含具体的 validator 错误信息
|
||||
|
||||
#### Scenario: 钱包余额不足返回明确错误
|
||||
|
||||
- **WHEN** 代理创建 wallet 订单,余额不足
|
||||
- **THEN** 系统返回 `CodeInsufficientBalance` 错误码,错误消息为"余额不足"
|
||||
|
||||
#### Scenario: 钱包不存在返回明确错误
|
||||
|
||||
- **WHEN** 代理创建 wallet 订单,钱包不存在
|
||||
- **THEN** 系统返回 `CodeWalletNotFound` 错误码,错误消息为"钱包不存在"
|
||||
|
||||
#### Scenario: 套餐激活失败回滚并返回错误
|
||||
|
||||
- **WHEN** 后台创建订单时余额扣减成功但套餐激活失败
|
||||
- **THEN** 事务回滚,钱包余额恢复,返回套餐激活失败错误(`CodeInternalError`)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 后台订单创建防重复
|
||||
|
||||
系统 SHALL 使用幂等性检查防止同一订单重复创建和重复扣款。
|
||||
|
||||
幂等性策略:
|
||||
- 使用 Redis 业务键:`order:idempotency:{buyer_type}:{buyer_id}:{order_type}:{carrier_type}:{carrier_id}:{sorted_package_ids}`
|
||||
- TTL:3 分钟
|
||||
- 分布式锁:`order:create:lock:{carrier_type}:{carrier_id}`,TTL 10 秒
|
||||
|
||||
#### Scenario: 重复创建订单返回已创建结果
|
||||
|
||||
- **WHEN** 代理在后台对同一张卡的同一套餐组合在 3 分钟内重复创建订单
|
||||
- **THEN** 系统返回第一次创建的订单信息,不重复扣款
|
||||
|
||||
#### Scenario: 并发创建订单使用分布式锁
|
||||
|
||||
- **WHEN** 两个请求同时为同一张卡创建订单
|
||||
- **THEN** 只有一个请求获取到分布式锁并创建订单,另一个请求返回"操作进行中,请勿重复提交"(`CodeTooManyRequests`)
|
||||
|
||||
#### Scenario: 幂等性 key 超时后可重新创建
|
||||
|
||||
- **WHEN** 订单创建成功 3 分钟后,代理再次创建相同订单
|
||||
- **THEN** 系统创建新订单(因为幂等性 key 已过期)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 后台订单 API 响应格式
|
||||
|
||||
系统 SHALL 在后台订单创建成功后返回完整的订单信息,包含支付状态、实际支付金额、操作者信息等。
|
||||
|
||||
响应字段(`OrderResponse`):
|
||||
- `id`:订单 ID
|
||||
- `order_no`:订单号
|
||||
- `payment_status`:支付状态(后台订单必为 2-已支付)
|
||||
- `payment_method`:支付方式(wallet 或 offline)
|
||||
- `paid_at`:支付时间(不为 NULL)
|
||||
- `total_amount`:订单总金额
|
||||
- `actual_paid_amount`:实际支付金额(仅 wallet 有值)
|
||||
- `operator_id`:操作者 ID
|
||||
- `operator_type`:操作者类型(agent/platform)
|
||||
- `purchase_role`:购买角色(self_purchase/purchase_for_subordinate/purchased_by_platform)
|
||||
|
||||
#### Scenario: wallet 订单响应包含实际支付金额
|
||||
|
||||
- **WHEN** 代理在后台创建 wallet 订单成功
|
||||
- **THEN** 响应包含 `actual_paid_amount` 字段,值为实际扣减的钱包金额
|
||||
|
||||
#### Scenario: offline 订单响应不包含实际支付金额
|
||||
|
||||
- **WHEN** 平台创建 offline 订单成功
|
||||
- **THEN** 响应的 `actual_paid_amount` 字段为 NULL(因为线下支付不扣钱包)
|
||||
|
||||
#### Scenario: 代购订单响应包含操作者信息
|
||||
|
||||
- **WHEN** 上级代理为下级代理购买套餐
|
||||
- **THEN** 响应包含 `operator_id`(上级店铺 ID)、`operator_type` = "agent"、`purchase_role` = "purchase_for_subordinate"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 后台订单创建与 H5 端隔离
|
||||
|
||||
系统 SHALL 使用独立的 Service 方法处理后台订单创建,避免与 H5 端订单创建逻辑混淆。
|
||||
|
||||
架构设计:
|
||||
- 后台:`OrderHandler.Create()` → `OrderService.CreateAdminOrder()`
|
||||
- H5 端:`OrderHandler.Create()` → `OrderService.CreateH5Order()`
|
||||
|
||||
#### Scenario: 后台调用独立的 Service 方法
|
||||
|
||||
- **WHEN** 后台创建订单
|
||||
- **THEN** Handler 层调用 `OrderService.CreateAdminOrder()` 方法,不调用通用的 `Create()` 方法
|
||||
|
||||
#### Scenario: H5 端调用独立的 Service 方法
|
||||
|
||||
- **WHEN** H5 端创建订单
|
||||
- **THEN** Handler 层调用 `OrderService.CreateH5Order()` 方法,不影响后台订单创建逻辑
|
||||
|
||||
#### Scenario: Service 方法命名明确职责
|
||||
|
||||
- **WHEN** 开发人员查看代码
|
||||
- **THEN** 方法命名(`CreateAdminOrder` vs `CreateH5Order`)清楚表明了后台和 H5 端的差异,防止误用
|
||||
|
||||
---
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 后台订单钱包支付权限校验
|
||||
后台创建订单时,若支付方式为钱包支付,Handler 层 SHALL 校验当前操作人为代理账号(`user_type = agent`)。非代理账号使用钱包支付 SHALL 在 Handler 层即被拦截,返回参数错误。
|
||||
|
||||
#### Scenario: 非代理账号使用钱包支付被拦截
|
||||
- **WHEN** 平台用户或企业用户提交订单且 `payment_method = wallet`
|
||||
- **THEN** Handler SHALL 返回 `CodeInvalidParam` 错误,提示"仅代理账号可使用钱包支付",请求不会到达 Service 层
|
||||
|
||||
#### Scenario: 代理账号使用钱包支付正常通过
|
||||
- **WHEN** 代理账号提交订单且 `payment_method = wallet`
|
||||
- **THEN** Handler SHALL 放行,由 Service 层继续处理钱包扣款逻辑
|
||||
@@ -1,70 +0,0 @@
|
||||
# Capability: 代理可售套餐查询
|
||||
|
||||
## Purpose
|
||||
|
||||
本 capability 定义代理用户如何通过统一的套餐管理接口查询可售套餐,系统如何自动过滤并返回代理专属字段(成本价、返佣信息等)。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 代理查询可售套餐列表
|
||||
|
||||
系统 SHALL 通过统一的套餐列表接口(`/api/admin/packages`)为代理用户自动过滤可售套餐。代理用户查询时,系统 MUST 只返回被分配的套餐,响应 MUST 包含成本价、利润空间、返佣信息等代理专属字段。**响应中的 `shelf_status` 字段 MUST 返回代理自己分配记录的值(`allocation.shelf_status`),而非套餐的全局值(`package.shelf_status`)。**
|
||||
|
||||
#### Scenario: 代理查询自动过滤为已分配套餐
|
||||
- **WHEN** 代理用户调用 `GET /api/admin/packages`
|
||||
- **THEN** 系统通过 JOIN `tb_shop_package_allocation` 自动过滤,只返回该代理被分配的套餐
|
||||
|
||||
#### Scenario: 平台用户查询返回所有套餐
|
||||
- **WHEN** 平台用户调用 `GET /api/admin/packages`
|
||||
- **THEN** 系统返回所有套餐(不应用代理权限过滤),shelf_status 返回 `tb_package.shelf_status`
|
||||
|
||||
#### Scenario: 响应包含代理专属字段
|
||||
- **WHEN** 代理用户查询套餐列表
|
||||
- **THEN** 每个套餐包含:cost_price(成本价)、profit_margin(利润空间)、current_commission_rate(当前返佣比例)
|
||||
|
||||
#### Scenario: 响应包含梯度返佣信息
|
||||
- **WHEN** 代理用户查询套餐列表,且该系列启用了梯度返佣
|
||||
- **THEN** 响应包含 tier_info:enabled、current_sales(本周期销量)、current_tier_id(当前档位)、next_threshold(下一档阈值)、next_rate(下一档返佣比例)
|
||||
|
||||
#### Scenario: 按系列筛选
|
||||
- **WHEN** 代理指定套餐系列 ID 筛选
|
||||
- **THEN** 系统只返回该系列下已分配的套餐
|
||||
|
||||
#### Scenario: 代理查询时 shelf_status 返回分配记录的值
|
||||
- **GIVEN** `tb_package.shelf_status=1`(平台上架),代理自己的 `allocation.shelf_status=2`(代理下架)
|
||||
- **WHEN** 代理调用 `GET /api/admin/packages`
|
||||
- **THEN** 响应中该套餐的 `shelf_status=2`(返回代理自己的状态)
|
||||
|
||||
#### Scenario: 代理查询时不按 package.shelf_status 过滤
|
||||
- **GIVEN** `tb_package.shelf_status=2`(平台下架),但代理的 `allocation.shelf_status=1`(代理上架)
|
||||
- **WHEN** 代理调用 `GET /api/admin/packages`
|
||||
- **THEN** 该套餐仍出现在结果中(代理侧状态独立),shelf_status 返回 1
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 代理查询可售套餐详情
|
||||
|
||||
系统 SHALL 通过统一的套餐详情接口(`/api/admin/packages/:id`)为代理用户返回套餐详细信息,包含完整的价格信息。**响应中的 `shelf_status` MUST 返回代理自己分配记录的值。**
|
||||
|
||||
#### Scenario: 代理查询已分配套餐详情
|
||||
- **WHEN** 代理查询一个已被分配的套餐详情
|
||||
- **THEN** 系统返回套餐完整信息,包含:cost_price(成本价)、建议售价、利润空间、价格来源,以及代理自己的 shelf_status
|
||||
|
||||
#### Scenario: 代理查询未分配的套餐
|
||||
- **WHEN** 代理查询一个未被分配的套餐详情
|
||||
- **THEN** 系统返回 404 或权限错误(数据权限过滤生效)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 删除独立的 my-packages 接口
|
||||
|
||||
系统 SHALL 删除以下独立接口及相关代码:
|
||||
- `GET /api/admin/my-packages`
|
||||
- `GET /api/admin/my-packages/:id`
|
||||
- `GET /api/admin/my-series-allocations`
|
||||
|
||||
功能 MUST 通过统一的 `/api/admin/packages` 接口实现,依赖数据权限自动过滤机制。
|
||||
|
||||
#### Scenario: 调用已删除的接口返回404
|
||||
- **WHEN** 代理调用 `GET /api/admin/my-packages`
|
||||
- **THEN** 系统返回 404 Not Found
|
||||
@@ -1,68 +0,0 @@
|
||||
# agent-fund-summary Specification
|
||||
|
||||
## Purpose
|
||||
代理商资金概况列表,聚合展示代理的预充值钱包(主钱包)和佣金钱包余额信息,供平台和代理多层级查看资金概况。
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 代理商资金概况列表
|
||||
|
||||
系统 SHALL 提供 `GET /api/admin/shops/fund-summary` 接口,返回分页的代理商资金概况列表,同时包含预充值钱包(主钱包)和佣金钱包的余额信息。
|
||||
|
||||
**响应字段**(`ShopFundSummaryItem`):
|
||||
- `shop_id`:店铺 ID
|
||||
- `shop_name`:店铺名称
|
||||
- `shop_code`:店铺编码
|
||||
- `username`:主账号用户名
|
||||
- `phone`:主账号手机号
|
||||
- `main_balance`:预充值钱包余额(分)
|
||||
- `main_frozen_balance`:预充值钱包冻结余额(分)
|
||||
- `total_commission`:累计佣金总额(分)
|
||||
- `withdrawn_commission`:已提现佣金(分)
|
||||
- `unwithdraw_commission`:未提现佣金(分)
|
||||
- `frozen_commission`:冻结中佣金(分)
|
||||
- `withdrawing_commission`:提现中佣金(分)
|
||||
- `available_commission`:可提现佣金(分)
|
||||
- `created_at`:店铺创建时间
|
||||
|
||||
**查询参数**(`ShopFundSummaryListReq`):
|
||||
- `page`:页码(默认 1)
|
||||
- `page_size`:每页数量(默认 20,最大 100)
|
||||
- `shop_name`:店铺名称模糊查询
|
||||
- `username`:主账号用户名模糊查询
|
||||
|
||||
**实现要求**:
|
||||
- 主钱包余额通过 `AgentWalletStore.GetShopMainWalletBatch` 批量查询,避免 N+1
|
||||
- 若代理暂无主钱包记录(未充值),`main_balance` 和 `main_frozen_balance` 返回 0
|
||||
- `main_frozen_balance` 字段为未来预留(当前业务无主钱包冻结场景,值恒为 0),前端可暂不展示
|
||||
- 数据权限:列表查询走 `Shop` 表的数据权限过滤(`SubordinateShopIDs`)。平台人员返回全部代理;代理账号返回自己 + 所有下级店铺(而不是只返回自己一条)
|
||||
|
||||
#### Scenario: 平台人员查看所有代理资金概况
|
||||
|
||||
- **WHEN** 平台人员请求 `GET /shops/fund-summary`
|
||||
- **THEN** 系统返回所有代理的分页列表,每条包含 `main_balance` 和佣金钱包字段
|
||||
|
||||
#### Scenario: 无下级代理的账号查看自己的资金概况
|
||||
|
||||
- **WHEN** 无下级代理的代理账号请求 `GET /shops/fund-summary`
|
||||
- **THEN** 系统按数据权限过滤,只返回该代理自己的一条记录
|
||||
|
||||
#### Scenario: 有下级代理的顶级代理查看资金概况
|
||||
|
||||
- **WHEN** 一个拥有多个下级代理的顶级代理账号请求 `GET /shops/fund-summary`
|
||||
- **THEN** 系统返回自己 + 所有下级代理店铺的资金概况列表(按 `SubordinateShopIDs` 过滤)
|
||||
|
||||
#### Scenario: 代理暂无主钱包时返回零值
|
||||
|
||||
- **WHEN** 代理从未充值,`tb_agent_wallet` 中无该店铺的 `wallet_type=main` 记录
|
||||
- **THEN** `main_balance` 和 `main_frozen_balance` 返回 0,其余字段正常返回
|
||||
|
||||
#### Scenario: 按店铺名称过滤
|
||||
|
||||
- **WHEN** 传入 `shop_name=张三`
|
||||
- **THEN** 系统只返回店铺名称包含"张三"的代理记录
|
||||
|
||||
#### Scenario: 企业账号无权访问
|
||||
|
||||
- **WHEN** 企业账号请求此接口
|
||||
- **THEN** 系统返回 403 错误,消息为"企业账号无权访问代理资金功能"
|
||||
67
openspec/specs/agent-funds-commission/spec.md
Normal file
67
openspec/specs/agent-funds-commission/spec.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# 代理资金与佣金当前行为
|
||||
|
||||
## Purpose
|
||||
|
||||
描述代理充值、钱包、佣金、提现及其金额边界的当前可观察行为。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 代理钱包与提现状态门禁
|
||||
|
||||
系统 SHALL 仅允许正常钱包执行资金操作;冻结或关闭钱包拒绝扣款、冻结、释放或提现,提现申请按待审核、已通过、已拒绝、已到账状态推进,重复终态决定不得再次扣减或入账。
|
||||
|
||||
#### Scenario: 非正常钱包资金操作
|
||||
|
||||
- **GIVEN** 代理钱包已冻结或关闭
|
||||
- **WHEN** 请求扣款、冻结、释放或提现
|
||||
- **THEN** 系统返回状态错误且余额与流水不变
|
||||
|
||||
### Requirement: 佣金异常状态可见
|
||||
|
||||
系统 SHALL 将佣金记录保持为已冻结、解冻中、已发放、已失效或待人工修正;链路断裂的记录进入待人工修正而不是静默计入可提现余额。
|
||||
|
||||
#### Scenario: 佣金链路断裂
|
||||
|
||||
- **GIVEN** 佣金记录无法关联完成后续发放所需事实
|
||||
- **WHEN** 系统处理该记录
|
||||
- **THEN** 记录保持待人工修正状态且不增加可提现余额
|
||||
|
||||
### Requirement: 代理在线充值本地支付状态
|
||||
|
||||
系统 SHALL 将代理在线充值的本地支付投影按 0=待支付、1=已支付、2=已失败、3=已退款返回;订单和资产充值使用各自的状态集。
|
||||
|
||||
#### Scenario: 代理在线充值本地支付状态
|
||||
|
||||
- **GIVEN** 代理在线充值的本地支付记录存在
|
||||
- **WHEN** 查询该充值的支付状态
|
||||
- **THEN** 返回数值状态及对应中文名称
|
||||
|
||||
### Requirement: 充值边界
|
||||
|
||||
系统 SHALL 接受资产充值 100 至 10000000 分、线下代理充值 1 至 100000000 分、在线代理充值至少 10000 分。
|
||||
|
||||
#### Scenario: 充值边界
|
||||
|
||||
- **GIVEN** 请求金额位于或越过边界
|
||||
- **WHEN** 创建对应充值
|
||||
- **THEN** 边界内请求进入既有支付流程,越界请求返回参数或业务错误
|
||||
|
||||
## 可达操作索引
|
||||
|
||||
本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。
|
||||
|
||||
### 代理预充值
|
||||
|
||||
`GET /api/admin/agent-recharges`(查询代理充值订单列表);`POST /api/admin/agent-recharges`(创建代理充值订单);`GET /api/admin/agent-recharges/{id}`(查询代理充值订单详情);`POST /api/admin/agent-recharges/{id}/offline-pay`(确认线下充值);`GET /api/admin/agent-recharges/{id}/payment-status`(查询代理充值本地支付与到账状态);`POST /api/admin/agent-recharges/{id}/reject`(驳回代理充值订单);`GET /api/admin/agent-recharges/payment-methods`(查询代理在线充值可用支付方式)。
|
||||
|
||||
### 代理商资金管理
|
||||
|
||||
`POST /api/admin/commission-records/{id}/resolve`(修正待审佣金记录);`PUT /api/admin/shops/{id}/credit-limit`(调整既有店铺实际信用额度);`GET /api/admin/shops/{shop_id}/commission-daily-stats`(代理商每日佣金统计);`GET /api/admin/shops/{shop_id}/commission-records`(代理商佣金明细);`GET /api/admin/shops/{shop_id}/commission-stats`(代理商佣金统计);`GET /api/admin/shops/{shop_id}/main-wallet/transactions`(代理商预充值钱包流水);`GET /api/admin/shops/{shop_id}/withdrawal-requests`(代理商提现记录);`POST /api/admin/shops/{shop_id}/withdrawal-requests`(发起提现申请);`GET /api/admin/shops/fund-summary`(代理商资金概况)。
|
||||
|
||||
### 佣金提现审批
|
||||
|
||||
`GET /api/admin/commission/withdrawal-requests`(提现申请列表);`POST /api/admin/commission/withdrawal-requests/{id}/approve`(审批通过提现申请);`POST /api/admin/commission/withdrawal-requests/{id}/reject`(拒绝提现申请)。
|
||||
|
||||
### 提现配置管理
|
||||
|
||||
`GET /api/admin/commission/withdrawal-settings`(提现配置列表);`POST /api/admin/commission/withdrawal-settings`(新增提现配置);`GET /api/admin/commission/withdrawal-settings/current`(获取当前生效的提现配置)。
|
||||
@@ -1,257 +1,35 @@
|
||||
# agent-open-api Specification
|
||||
# agent-open-api 当前行为
|
||||
|
||||
## Purpose
|
||||
定义代理开放接口的签名认证、数据权限、卡查询、套餐列表、预充值钱包和钱包购买能力,确保第三方代理系统可以在受控权限内对接业务接口。
|
||||
|
||||
描述代理开放接口认证与店铺数据范围的当前行为。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 代理开放接口签名认证
|
||||
### Requirement: 开放接口认证
|
||||
|
||||
系统 SHALL 为 `/api/open/v1` 下所有代理开放接口提供签名认证。调用方 MUST 在请求 Header 中传入 `X-Agent-Account`、`X-Agent-Password`、`X-Agent-Timestamp`、`X-Agent-Nonce`、`X-Agent-Sign`。系统 MUST 校验后台账号存在、账号类型为代理账号、账号状态启用、已绑定启用状态的代理店铺、密码正确、时间戳在允许时间窗内、nonce 未被使用、签名正确。系统 MUST NOT 依赖独立开放接口账号授权表;所有启用状态的代理账号默认允许调用开放接口。
|
||||
系统 SHALL 在代理开放接口执行业务前校验调用方身份与请求签名。
|
||||
|
||||
签名原文 SHALL 使用以下格式:
|
||||
#### Scenario: 开放接口认证
|
||||
|
||||
```text
|
||||
METHOD + "\n" +
|
||||
PATH + "\n" +
|
||||
canonical_query_without_sign + "\n" +
|
||||
SHA256(raw_body) + "\n" +
|
||||
timestamp + "\n" +
|
||||
nonce + "\n" +
|
||||
account
|
||||
```
|
||||
- **GIVEN** 请求缺少、伪造或过期的认证材料
|
||||
- **WHEN** 调用任一代理开放接口
|
||||
- **THEN** 请求被拒绝且不执行资源或资金变化
|
||||
|
||||
签名算法 SHALL 使用 `hex(HMAC-SHA256(password, sign_payload))`。`canonical_query_without_sign` MUST 按参数名升序排列并排除 `sign` 字段。空 body 的 SHA256 MUST 按空字符串计算。
|
||||
### Requirement: 开放接口数据范围
|
||||
|
||||
响应 MUST 使用统一格式 `{code, msg, data, timestamp}`。认证失败错误码 MUST 在 `pkg/errors/` 定义,不得把 bcrypt、HMAC、数据库底层错误暴露给调用方。
|
||||
系统 SHALL 仅允许代理访问其店铺数据范围内的卡、套餐和钱包。
|
||||
|
||||
#### Scenario: 签名认证通过
|
||||
#### Scenario: 开放接口数据范围
|
||||
|
||||
- **WHEN** 启用状态的代理账号携带正确密码、时间戳、nonce、请求参数和签名调用 `/api/open/v1/cards/status`
|
||||
- **THEN** 系统通过认证并将代理账号信息写入请求上下文
|
||||
- **GIVEN** 代理请求其他店铺资源
|
||||
- **WHEN** 查询或写入
|
||||
- **THEN** 请求被统一拒绝且不泄露资源存在性
|
||||
|
||||
#### Scenario: 参数被篡改
|
||||
## 可达操作索引
|
||||
|
||||
- **WHEN** 请求中的 query 或 body 在签名后被修改
|
||||
- **THEN** 系统验签失败并返回签名无效错误
|
||||
本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。
|
||||
|
||||
#### Scenario: 密码错误
|
||||
### 代理开放接口
|
||||
|
||||
- **WHEN** 调用方传入的 `X-Agent-Password` 与后台代理账号密码不匹配
|
||||
- **THEN** 系统返回认证失败错误,不继续执行业务逻辑
|
||||
|
||||
#### Scenario: nonce 重放
|
||||
|
||||
- **WHEN** 同一账号在有效时间窗内重复使用相同 `X-Agent-Nonce`
|
||||
- **THEN** 系统返回重复请求错误,并拒绝执行业务逻辑
|
||||
|
||||
#### Scenario: 代理账号未启用
|
||||
|
||||
- **WHEN** 后台代理账号存在但账号状态为禁用
|
||||
- **THEN** 系统返回认证失败错误,不继续执行业务逻辑
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 开放接口代理上下文与数据权限
|
||||
|
||||
系统 SHALL 将通过签名认证的代理账号映射为内部代理上下文。上下文 MUST 包含 `ContextKeyUserID`、`ContextKeyUserType=UserTypeAgent`、`ContextKeyShopID`、`ContextKeySubordinateShopIDs`。开放接口业务查询 MUST 只允许访问该代理店铺及其下级店铺范围内的卡、套餐、订单和钱包数据。
|
||||
|
||||
#### Scenario: 访问自己店铺的卡
|
||||
|
||||
- **WHEN** 代理开放接口账号查询自己店铺名下卡的流量、状态或实名状态
|
||||
- **THEN** 系统返回该卡数据
|
||||
|
||||
#### Scenario: 访问下级店铺的卡
|
||||
|
||||
- **WHEN** 代理开放接口账号查询下级店铺名下卡的流量、状态或实名状态
|
||||
- **THEN** 系统返回该卡数据
|
||||
|
||||
#### Scenario: 访问无权限卡
|
||||
|
||||
- **WHEN** 代理开放接口账号查询非自己及非下级店铺名下的卡
|
||||
- **THEN** 系统返回 `CodeForbidden`,消息为“无权限操作该资源或资源不存在”
|
||||
|
||||
#### Scenario: 企业账号调用开放接口
|
||||
|
||||
- **WHEN** 企业账号使用正确后台密码调用代理开放接口
|
||||
- **THEN** 系统拒绝调用,因为开放接口仅允许代理账号
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 卡流量查询开放接口
|
||||
|
||||
系统 SHALL 提供 `GET /api/open/v1/cards/traffic` 接口,按 `card_no` 查询单卡流量和套餐信息。`card_no` MUST 支持 ICCID、虚拟号、MSISDN 中任一种可解析为 IoT 卡的标识。接口 MUST 只返回单卡套餐,不返回设备级套餐信息。
|
||||
|
||||
响应 data MUST 至少包含:
|
||||
- `card_no`
|
||||
- `active_remaining_flow_mb`:当前生效套餐剩余流量
|
||||
- `active_total_flow_mb`:当前生效套餐总流量
|
||||
- `active_used_flow_mb`:当前生效套餐已使用流量
|
||||
- `active_packages`:当前生效套餐列表
|
||||
- `pending_packages`:待生效套餐列表
|
||||
- `active_expires_at`:当前生效套餐过期时间
|
||||
|
||||
`active_packages` 每项 MUST 包含:`package_code`、`total_flow_mb`、`expires_at`、`start_at`、`package_name`、`series_name`、`package_type`、`package_type_name`。`pending_packages` 每项 MUST 包含:`package_code`、`total_flow_mb`、`package_name`、`series_name`、`package_type`、`package_type_name`、`valid_days`、`priority`。
|
||||
|
||||
流量口径 MUST 满足:
|
||||
- 总流量使用套餐真总量快照 `data_limit_mb`
|
||||
- 已用流量使用换算后的展示已用量 `min(data_usage_mb * display_gain_ratio_snapshot, data_limit_mb)`
|
||||
- 剩余流量使用 `max(data_limit_mb - used_flow_mb, 0)`
|
||||
- 响应 MUST NOT 包含 `virtual_*`、`ratio`、`threshold`、`reduction`、虚流量、停机阈值等内部字段或文案
|
||||
|
||||
#### Scenario: 查询有生效套餐的卡流量
|
||||
|
||||
- **WHEN** 代理查询自己名下单卡,且该卡存在生效中的正式包和加油包
|
||||
- **THEN** 系统返回当前生效套餐列表、总流量、换算后已用流量、剩余流量和当前过期时间
|
||||
|
||||
#### Scenario: 查询待生效套餐
|
||||
|
||||
- **WHEN** 代理查询单卡流量,且该卡存在待生效套餐
|
||||
- **THEN** 系统在 `pending_packages` 中返回套餐编码、真总流量、套餐名称、系列名称、套餐类型、有效天数和生效优先级
|
||||
|
||||
#### Scenario: 不暴露虚流量
|
||||
|
||||
- **WHEN** 卡的套餐启用了虚流量
|
||||
- **THEN** 响应只返回真总量、换算后已用量和剩余量,不返回任何虚流量字段或内部倍率字段
|
||||
|
||||
#### Scenario: 查询无套餐的卡
|
||||
|
||||
- **WHEN** 代理查询有权限但当前无生效或待生效套餐的卡
|
||||
- **THEN** 系统返回空套餐列表,流量数值为 0
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 卡状态查询开放接口
|
||||
|
||||
系统 SHALL 提供 `GET /api/open/v1/cards/status` 接口,按 `card_no` 查询单卡状态。响应 data MUST 包含 `card_no`、`card_status`、`card_status_name`、`stop_reason`、`stop_reason_name`。卡状态 MUST 按现有网络状态映射为停机/正常:`network_status=0` 返回停机,`network_status=1` 返回正常。
|
||||
|
||||
#### Scenario: 查询正常卡状态
|
||||
|
||||
- **WHEN** 代理查询有权限且 `network_status=1` 的卡
|
||||
- **THEN** 系统返回 `card_status=normal`,`card_status_name=正常`
|
||||
|
||||
#### Scenario: 查询停机卡状态
|
||||
|
||||
- **WHEN** 代理查询有权限且 `network_status=0` 的卡
|
||||
- **THEN** 系统返回 `card_status=stopped`、`card_status_name=停机` 以及停机原因
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 卡实名状态查询开放接口
|
||||
|
||||
系统 SHALL 提供 `GET /api/open/v1/cards/realname-status` 接口,按 `card_no` 查询单卡实名状态。响应 data MUST 包含 `card_no` 和 `is_realnamed`。当 `real_name_status` 为已实名常量时,`is_realnamed` MUST 为 `true`,否则为 `false`。
|
||||
|
||||
#### Scenario: 查询已实名卡
|
||||
|
||||
- **WHEN** 代理查询有权限且 `real_name_status=1` 的卡
|
||||
- **THEN** 系统返回 `is_realnamed=true`
|
||||
|
||||
#### Scenario: 查询未实名卡
|
||||
|
||||
- **WHEN** 代理查询有权限且 `real_name_status=0` 的卡
|
||||
- **THEN** 系统返回 `is_realnamed=false`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 代理套餐列表开放接口
|
||||
|
||||
系统 SHALL 提供 `GET /api/open/v1/packages` 接口,分页返回代理可购买套餐列表。接口 MUST 复用后台代理套餐列表的权限过滤:只返回当前代理已分配且启用的非赠送套餐,并按代理自己的上下架状态过滤。接口 MUST 支持 `page`、`page_size`、`package_type`、`series_id`、`package_name` 查询参数,`page_size` 最大 100。
|
||||
|
||||
响应每项 MUST 包含:`package_id`、`package_code`、`package_name`、`series_name`、`package_type`、`package_type_name`、`cost_price`、`retail_price`、`total_flow_mb`、`valid_days`。`retail_price` MUST 返回代理配置的生效零售价;当代理零售价未配置时,按现有策略回退为成本价。
|
||||
|
||||
#### Scenario: 代理查询套餐列表
|
||||
|
||||
- **WHEN** 代理调用 `GET /api/open/v1/packages?page=1&page_size=20`
|
||||
- **THEN** 系统分页返回该代理可购买套餐,并包含成本价和生效零售价
|
||||
|
||||
#### Scenario: 过滤未分配套餐
|
||||
|
||||
- **WHEN** 套餐未分配给当前代理
|
||||
- **THEN** 该套餐不会出现在开放套餐列表中
|
||||
|
||||
#### Scenario: 生效零售价回退
|
||||
|
||||
- **WHEN** 代理套餐分配记录未配置零售价
|
||||
- **THEN** 响应 `retail_price` 返回成本价
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 代理钱包套餐购买开放接口
|
||||
|
||||
系统 SHALL 提供 `POST /api/open/v1/wallet/package-orders` 接口,允许代理使用预充值主钱包为指定卡购买套餐。请求 body MUST 包含 `card_nos` 和 `package_code`。`package_code` MUST 只允许一个,`card_nos` MUST 至少 1 个,最多 100 个。
|
||||
|
||||
系统 MUST 按卡独立创建后台钱包订单,复用后台代理钱包支付购买逻辑。每张卡购买成功后 MUST 返回订单号;购买失败时 MUST 返回该卡失败原因。任一卡失败 MUST NOT 回滚其他已成功卡的订单。
|
||||
|
||||
响应 data MUST 包含:`batch_no`、`package_code`、`success_count`、`failed_count`、`orders`、`failed_cards`。`orders` 每项 MUST 包含 `card_no`、`order_no`。`failed_cards` 每项 MUST 包含 `card_no`、`code`、`message`。
|
||||
|
||||
当任一卡购买失败时,外层 `code`/`msg` MUST 返回首条失败卡的错误码和原因,`data` MUST 保留批次明细;调用方 MUST 以 `success_count`、`failed_count`、`orders`、`failed_cards` 判断每张卡的处理结果,避免整批重试导致重复下单。
|
||||
|
||||
#### Scenario: 多卡部分成功
|
||||
|
||||
- **WHEN** 代理提交 3 张卡购买同一个套餐编码,其中 2 张成功、1 张失败
|
||||
- **THEN** 系统外层 `code` 和 `msg` 返回首条失败卡的错误码和原因,`data` 返回 `success_count=2`、`failed_count=1`、2 条订单号和 1 条失败卡原因
|
||||
|
||||
#### Scenario: 全部购买失败
|
||||
|
||||
- **WHEN** 代理提交的卡全部购买失败
|
||||
- **THEN** 系统外层 `code` 和 `msg` 返回首条失败卡的错误码和原因,`data.failed_cards` 返回全部失败明细
|
||||
|
||||
#### Scenario: 单套餐编码限制
|
||||
|
||||
- **WHEN** 请求包含多个套餐编码或套餐编码为空
|
||||
- **THEN** 系统返回参数错误,不创建订单
|
||||
|
||||
#### Scenario: 钱包余额不足
|
||||
|
||||
- **WHEN** 某张卡购买套餐时代理主钱包余额不足
|
||||
- **THEN** 该卡返回失败原因“余额不足”,其他卡已成功订单不受影响
|
||||
|
||||
#### Scenario: 复用后台钱包支付
|
||||
|
||||
- **WHEN** 某张卡购买套餐成功
|
||||
- **THEN** 系统创建已支付订单、扣减代理主钱包余额、创建主钱包扣款流水并激活套餐
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 代理预充值钱包余额开放接口
|
||||
|
||||
系统 SHALL 提供 `GET /api/open/v1/wallet/balance` 接口,查询当前代理预充值主钱包余额。响应 data MUST 包含 `balance`、`frozen_balance`、`available_balance`、`currency`。当主钱包不存在时,系统 MUST 返回 0 余额,不报错。
|
||||
|
||||
#### Scenario: 查询主钱包余额
|
||||
|
||||
- **WHEN** 代理主钱包余额为 10000 分、冻结余额为 2000 分
|
||||
- **THEN** 系统返回 `balance=10000`、`frozen_balance=2000`、`available_balance=8000`
|
||||
|
||||
#### Scenario: 主钱包不存在
|
||||
|
||||
- **WHEN** 代理从未充值且主钱包不存在
|
||||
- **THEN** 系统返回 0 余额和币种 CNY
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 代理预充值钱包流水开放接口
|
||||
|
||||
系统 SHALL 提供 `GET /api/open/v1/wallet/transactions` 接口,分页返回当前代理预充值主钱包流水。接口 MUST 与后台 `GET /api/admin/shops/:shop_id/main-wallet/transactions` 的返回字段保持一致,支持 `page`、`page_size`、`transaction_type`、`start_date`、`end_date` 查询参数,结果按 `created_at DESC` 排序。
|
||||
|
||||
响应每项 MUST 包含:`id`、`transaction_type`、`transaction_subtype`、`amount`、`balance_before`、`balance_after`、`remark`、`created_at`。
|
||||
|
||||
#### Scenario: 查询钱包流水
|
||||
|
||||
- **WHEN** 代理调用 `GET /api/open/v1/wallet/transactions?page=1&page_size=20`
|
||||
- **THEN** 系统返回当前代理主钱包流水,字段与后台主钱包流水接口一致
|
||||
|
||||
#### Scenario: 按日期过滤流水
|
||||
|
||||
- **WHEN** 代理传入 `start_date` 和 `end_date`
|
||||
- **THEN** 系统只返回该日期范围内的主钱包流水
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 开放接口文档和路由注册
|
||||
|
||||
系统 SHALL 将代理开放接口注册到路由总入口和 OpenAPI 文档生成器。新增 Handler 后 MUST 同步更新 `cmd/api/docs.go` 和 `cmd/gendocs/main.go` 的 `bootstrap.Handlers` 初始化,确保接口文档中出现 `/api/open/v1` 下所有开放接口。
|
||||
|
||||
#### Scenario: 文档生成器包含开放接口
|
||||
|
||||
- **WHEN** 系统生成 OpenAPI 文档
|
||||
- **THEN** 文档中包含 `/api/open/v1` 下全部代理开放接口
|
||||
`GET /api/open/v1/cards/realname-status`(查询单卡实名状态);`POST /api/open/v1/cards/resume`(机卡分离卡复机);`GET /api/open/v1/cards/status`(查询单卡状态);`GET /api/open/v1/cards/traffic`(查询单卡或设备流量);`POST /api/open/v1/devices/reboot`(重启设备);`POST /api/open/v1/devices/reset`(恢复出厂设置);`POST /api/open/v1/devices/switch-card`(切网(多卡设备切换 ICCID));`GET /api/open/v1/devices/traffic`(查询设备套餐内流量);`GET /api/open/v1/packages`(查询套餐列表);`GET /api/open/v1/wallet/balance`(查询预充值钱包余额);`POST /api/open/v1/wallet/package-orders`(钱包套餐购买);`GET /api/open/v1/wallet/transactions`(查询预充值钱包流水)。
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
# Capability: 订单角色追踪
|
||||
|
||||
## Purpose
|
||||
|
||||
本 capability 定义订单角色追踪能力,记录并区分订单中的操作者、买家、支付者等角色关系,支持多种代购场景的数据查询和业务分析。
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 订单操作者记录
|
||||
|
||||
系统 SHALL 在订单创建时记录操作者信息(谁下的单),区别于买家信息(资源所属者)。
|
||||
|
||||
#### Scenario: 平台创建订单
|
||||
- **WHEN** 平台账号创建订单
|
||||
- **THEN** 订单的 `operator_id` 为 NULL,`operator_type` 为 "platform"
|
||||
|
||||
#### Scenario: 代理创建订单
|
||||
- **WHEN** 代理账号创建订单
|
||||
- **THEN** 订单的 `operator_id` 为代理店铺 ID,`operator_type` 为 "agent"
|
||||
|
||||
#### Scenario: 代理自购
|
||||
- **WHEN** 代理为自己的资源创建订单
|
||||
- **THEN** 订单的 `buyer_id` 等于 `operator_id`
|
||||
|
||||
#### Scenario: 代理代购
|
||||
- **WHEN** 代理为下级代理的资源创建订单
|
||||
- **THEN** 订单的 `buyer_id` 为资源所属店铺 ID,`operator_id` 为操作者店铺 ID,两者不同
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 实际支付金额记录
|
||||
|
||||
系统 SHALL 记录订单的实际支付金额,区别于订单金额(买家视角的价格)。
|
||||
|
||||
#### Scenario: 代理自购订单
|
||||
- **WHEN** 代理为自己的资源创建订单,成本价 80 元
|
||||
- **THEN** 订单的 `total_amount` = 80 元,`actual_paid_amount` = 80 元
|
||||
|
||||
#### Scenario: 代理代购订单
|
||||
- **WHEN** 一级代理(成本价 80 元)为二级代理(成本价 100 元)的资源创建订单
|
||||
- **THEN** 订单的 `total_amount` = 100 元(买家成本价),`actual_paid_amount` = 80 元(操作者实际扣款)
|
||||
|
||||
#### Scenario: 平台代购订单
|
||||
- **WHEN** 平台为代理创建订单
|
||||
- **THEN** 订单的 `total_amount` = 代理成本价,`actual_paid_amount` 为 NULL(平台不扣款)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 订单角色枚举
|
||||
|
||||
系统 SHALL 使用 `purchase_role` 字段标识订单角色关系,支持高效筛选。
|
||||
|
||||
#### Scenario: 自己购买
|
||||
- **WHEN** 代理为自己的资源创建订单
|
||||
- **THEN** 订单的 `purchase_role` = "self_purchase"
|
||||
|
||||
#### Scenario: 上级代理购买
|
||||
- **WHEN** 代理查询作为买家的订单,且 `operator_id` 不为 NULL 且不等于 `buyer_id`
|
||||
- **THEN** 该订单的 `purchase_role` = "purchased_by_parent"(从买家视角)或 "purchase_for_subordinate"(从操作者视角)
|
||||
|
||||
#### Scenario: 平台代购
|
||||
- **WHEN** 平台为代理创建订单
|
||||
- **THEN** 订单的 `purchase_role` = "purchased_by_platform"
|
||||
|
||||
#### Scenario: 给下级购买
|
||||
- **WHEN** 代理为下级代理的资源创建订单
|
||||
- **THEN** 订单的 `purchase_role` = "purchase_for_subordinate"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 订单查询增强
|
||||
|
||||
系统 SHALL 支持代理查询作为买家或操作者的所有订单。
|
||||
|
||||
#### Scenario: 代理查询自己相关的订单
|
||||
- **WHEN** 代理查询订单列表
|
||||
- **THEN** 系统返回 `buyer_id = 代理店铺 ID` 或 `operator_id = 代理店铺 ID` 的所有订单
|
||||
|
||||
#### Scenario: 按订单角色筛选
|
||||
- **WHEN** 代理查询订单列表,指定 `purchase_role = "self_purchase"`
|
||||
- **THEN** 系统只返回自己购买的订单
|
||||
|
||||
#### Scenario: 按订单角色筛选给下级购买的订单
|
||||
- **WHEN** 代理查询订单列表,指定 `purchase_role = "purchase_for_subordinate"`
|
||||
- **THEN** 系统只返回为下级代理购买的订单
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 订单响应包含角色信息
|
||||
|
||||
系统 SHALL 在订单响应中包含操作者和角色信息,支持前端展示。
|
||||
|
||||
#### Scenario: 订单响应包含操作者 ID
|
||||
- **WHEN** 查询订单详情
|
||||
- **THEN** 响应包含 `operator_id`、`operator_type` 字段
|
||||
|
||||
#### Scenario: 订单响应包含操作者名称
|
||||
- **WHEN** 查询订单详情,且 `operator_type = "agent"`
|
||||
- **THEN** 响应包含 `operator_name` 字段(从 Shop 表查询)
|
||||
|
||||
#### Scenario: 订单响应包含角色标识
|
||||
- **WHEN** 查询订单详情
|
||||
- **THEN** 响应包含 `purchase_role`、`is_purchased_by_parent`、`purchase_remark` 字段
|
||||
|
||||
#### Scenario: 上级代购订单的备注
|
||||
- **WHEN** 查询上级代理购买的订单
|
||||
- **THEN** `purchase_remark` 为"由上级代理【XX】购买"
|
||||
|
||||
#### Scenario: 平台代购订单的备注
|
||||
- **WHEN** 查询平台代购的订单
|
||||
- **THEN** `purchase_remark` 为"由平台代购"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 数据权限保持一致
|
||||
|
||||
系统 SHALL 确保订单角色追踪不影响现有数据权限逻辑。
|
||||
|
||||
#### Scenario: 代理只能查询有权限的订单
|
||||
- **WHEN** 代理查询订单列表
|
||||
- **THEN** 系统应用数据权限过滤,只返回 `buyer_id` 或 `operator_id` 在权限范围内的订单
|
||||
|
||||
#### Scenario: 平台可查询所有订单
|
||||
- **WHEN** 平台账号查询订单列表
|
||||
- **THEN** 系统不应用数据权限过滤,返回所有订单
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 订单角色常量定义
|
||||
|
||||
系统 SHALL 在 `internal/model/order.go` 中定义订单角色枚举常量。
|
||||
|
||||
#### Scenario: 订单角色枚举值
|
||||
- **WHEN** 代码中使用订单角色
|
||||
- **THEN** 可用的枚举值包括:
|
||||
- `PurchaseRoleSelfPurchase` = "self_purchase"
|
||||
- `PurchaseRolePurchasedByParent` = "purchased_by_parent"
|
||||
- `PurchaseRolePurchasedByPlatform` = "purchased_by_platform"
|
||||
- `PurchaseRolePurchaseForSubordinate` = "purchase_for_subordinate"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 数据库索引支持
|
||||
|
||||
系统 SHALL 为订单角色追踪字段创建索引,支持高效查询。
|
||||
|
||||
#### Scenario: operator_id 索引
|
||||
- **WHEN** 查询 `operator_id = X` 的订单
|
||||
- **THEN** 数据库使用 `idx_orders_operator_id` 索引
|
||||
|
||||
#### Scenario: purchase_role 索引
|
||||
- **WHEN** 查询 `purchase_role = 'self_purchase'` 的订单
|
||||
- **THEN** 数据库使用 `idx_orders_purchase_role` 索引
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 向后兼容性
|
||||
|
||||
系统 SHALL 确保新增字段不影响现有订单数据和查询。
|
||||
|
||||
#### Scenario: 现有订单字段为 NULL
|
||||
- **WHEN** 查询历史订单
|
||||
- **THEN** `operator_id`、`operator_type`、`actual_paid_amount`、`purchase_role` 字段为 NULL 或空值,不影响查询结果
|
||||
|
||||
#### Scenario: 订单列表查询兼容
|
||||
- **WHEN** 代理查询订单列表,不指定 `purchase_role` 筛选
|
||||
- **THEN** 系统返回所有订单,包括历史订单(role 为 NULL)
|
||||
@@ -1,43 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 创建线下充值订单时必须上传支付凭证
|
||||
|
||||
系统 SHALL 在 `POST /api/admin/agent-recharges` 接口中,当 `payment_method=offline` 时强制要求 `payment_voucher_key` 字段非空。凭证 key 为通过 `/storage/upload-url` 上传至对象存储后获得的文件标识符。当 `payment_method=wechat` 时,`payment_voucher_key` 字段忽略。
|
||||
|
||||
#### Scenario: 线下支付缺少凭证时拒绝创建
|
||||
|
||||
- **WHEN** 调用 `POST /api/admin/agent-recharges`,`payment_method=offline` 且 `payment_voucher_key` 为空或未传
|
||||
- **THEN** 系统返回 400,错误码 `CodeInvalidParam`,消息"线下充值必须上传支付凭证"
|
||||
|
||||
#### Scenario: 线下支付有凭证时创建成功
|
||||
|
||||
- **WHEN** 调用 `POST /api/admin/agent-recharges`,`payment_method=offline` 且 `payment_voucher_key` 非空
|
||||
- **THEN** 系统创建充值记录,凭证 key 写入 `tb_agent_recharge_record.payment_voucher_key`,返回 200
|
||||
|
||||
#### Scenario: 微信支付不需要凭证
|
||||
|
||||
- **WHEN** 调用 `POST /api/admin/agent-recharges`,`payment_method=wechat`,不传 `payment_voucher_key`
|
||||
- **THEN** 系统正常创建充值记录,不做凭证校验,返回 200
|
||||
|
||||
### Requirement: 充值记录存储支付凭证
|
||||
|
||||
系统 SHALL 将 `payment_voucher_key` 持久化到 `tb_agent_recharge_record` 表的 `payment_voucher_key` 列,与充值记录创建在同一个数据库操作内完成。
|
||||
|
||||
#### Scenario: 凭证随记录创建写入
|
||||
|
||||
- **WHEN** `Create` 方法执行创建充值记录
|
||||
- **THEN** `payment_voucher_key` 与其他字段一同写入数据库,不可分割
|
||||
|
||||
### Requirement: 充值记录响应包含凭证信息
|
||||
|
||||
系统 SHALL 在 `AgentRechargeResponse` 中返回 `payment_voucher_key` 字段,允许为空(历史记录及微信支付无凭证)。
|
||||
|
||||
#### Scenario: 已上传凭证的线下充值记录查询
|
||||
|
||||
- **WHEN** 查询已创建的线下充值记录
|
||||
- **THEN** 响应 `payment_voucher_key` 字段返回非空的对象存储 key
|
||||
|
||||
#### Scenario: 微信支付记录无凭证字段
|
||||
|
||||
- **WHEN** 查询微信支付的充值记录
|
||||
- **THEN** 响应 `payment_voucher_key` 字段为空字符串或 omitempty(前端不展示)
|
||||
@@ -1,577 +0,0 @@
|
||||
# 代理充值管理 API 规范
|
||||
|
||||
## Purpose
|
||||
|
||||
定义代理钱包在线扫码自充、平台线下代充、第三方支付确认、异步钱包入账、状态查询、异常恢复与多租户权限控制的统一业务契约,确保充值资金事实可核对、可恢复且不会重复入账。
|
||||
|
||||
## Requirements
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 创建代理充值订单
|
||||
|
||||
系统 SHALL 通过 `POST /api/admin/agent-recharges` 保留同一代理充值资源,并按登录账号类型与 `payment_method` 执行严格分流。
|
||||
|
||||
代理在线请求 MUST 仅接受 `amount`、`payment_method` 与 `request_id`:
|
||||
|
||||
- `payment_method` MUST 为 `wechat` 或 `alipay`。
|
||||
- `amount` MUST 使用分为单位,范围 MUST 为 `10000~100000000`。
|
||||
- 目标店铺与主钱包 MUST 从当前认证上下文确定;请求 MUST NOT 接受或信任 `shop_id`、支付凭证或运营备注。
|
||||
- 每次新的主动提交 MUST 创建新的代理充值单和 `tb_payment` 支付单,不得按金额或待支付记录复用旧单。
|
||||
- 同一提交账号与 `request_id` MUST 形成持久化幂等作用域;相同指纹重放 MUST 返回原业务结果,不同指纹重放 MUST 返回统一冲突错误。
|
||||
|
||||
平台或超级管理员的 `offline` 线下代充 MUST 继续使用现有目标 `shop_id`、付款凭证和企业微信审批契约,在线充值 100 元最低金额 MUST NOT 改变线下代充金额规则。平台、超级管理员和企业账号 MUST NOT 创建代理在线扫码充值单。
|
||||
|
||||
在线创建 MUST 在同一 GORM 事务中保存充值单、支付单及请求幂等事实,再按当前生效支付配置调用选定 Adapter:`provider_type=wechat` 使用微信 v3 H5 并返回 `h5_url`,`provider_type=wechat_v2` 使用微信 v2 MWEB 并返回 `mweb_url`,支付宝复用 C 端 `alipay.trade.wap.pay` 手机网站支付链接能力。成功响应 MUST 使用统一 `{code,msg,data,timestamp}` 格式,并在 `data` 中至少返回 `recharge_id`、`recharge_no`、`payment_no`、`payment_method`、`recharge_source`、`recharge_source_name`、`amount`、`qr_content`、`status` 与 `status_name`。`qr_content` MUST 为支付渠道 HTTPS URL,前端自行渲染二维码;后端不得生成或保存二维码图片。
|
||||
|
||||
充值订单创建、列表、详情和在线支付状态响应 MUST 使用稳定来源枚举区分创建路径:`platform_offline` 表示平台线下代充,`agent_online` 表示代理在线自充。列表 MUST 支持使用 `recharge_source` 筛选;来源可由受控创建方式推导,不要求新增重复数据库字段。
|
||||
|
||||
支付单 MUST 同时保存创建时的收款身份快照:微信记录商户号,支付宝记录应用 ID,并保留支付方式与 `payment_config_id`,供后续导出对账。创建响应 MUST NOT 返回该内部收款身份快照。
|
||||
|
||||
支付链接生成失败时,系统 MUST 将本次支付单标记为失败、充值单标记为已关闭;微信 H5/MWEB 外部下单尝试 MUST 记录 Integration Log,支付宝本地签名生成 WAP URL 不得伪造外部调用日志。系统不得返回缺少有效 `qr_content` 的成功响应。
|
||||
|
||||
#### Scenario: 代理创建微信 H5/MWEB 链接扫码充值
|
||||
- **WHEN** 代理提交 `amount=10000`、`payment_method=wechat` 和新的 `request_id`
|
||||
- **THEN** 系统从登录上下文确定当前店铺主钱包,创建充值单和支付单,按当前配置调用 v3 H5 或 v2 MWEB,并将 `h5_url` 或 `mweb_url` 映射为 `qr_content`
|
||||
- **THEN** 响应不得包含支付配置 ID、商户密钥或其他店铺信息
|
||||
|
||||
#### Scenario: 代理创建支付宝 WAP 链接扫码充值
|
||||
- **WHEN** 代理提交有效金额、`payment_method=alipay` 和新的 `request_id`
|
||||
- **THEN** 系统创建充值单和支付单,使用 `alipay.trade.wap.pay` 生成签名 HTTPS URL,并将 URL 映射为 `qr_content`
|
||||
|
||||
#### Scenario: 区分平台代充与代理自充
|
||||
- **WHEN** 调用方查询充值订单列表、详情或在线支付状态
|
||||
- **THEN** 平台线下代充 MUST 返回 `recharge_source=platform_offline`,代理微信或支付宝在线自充 MUST 返回 `recharge_source=agent_online`
|
||||
|
||||
#### Scenario: 在线充值金额边界
|
||||
- **WHEN** 代理提交的在线充值金额小于 `10000` 分或大于 `100000000` 分
|
||||
- **THEN** 系统 MUST 返回 `CodeInvalidParam`,且不得创建充值单、支付单或调用第三方支付
|
||||
|
||||
#### Scenario: 代理请求携带目标店铺
|
||||
- **WHEN** 代理在线请求携带 `shop_id` 或其他仅线下代充允许的字段
|
||||
- **THEN** 系统 MUST 拒绝请求,不得允许代理选择本店、下级店铺或其他店铺作为受益方
|
||||
|
||||
#### Scenario: 相同请求重放
|
||||
- **WHEN** 同一提交账号使用相同 `request_id` 和相同业务字段重试
|
||||
- **THEN** 系统 MUST 返回首次创建的充值单、支付单和支付链接,不得再次创建业务单或再次调用支付渠道生成链接
|
||||
|
||||
#### Scenario: 幂等请求载荷冲突
|
||||
- **WHEN** 同一提交账号使用已有 `request_id` 但改变金额或支付方式
|
||||
- **THEN** 系统 MUST 返回 `CodeConflict`,不得改变原充值单或创建新单
|
||||
|
||||
#### Scenario: 支付方式不可用
|
||||
- **WHEN** 所选支付方式缺少完整配置、支付链接生成能力或回调验签能力
|
||||
- **THEN** 系统 MUST 返回统一支付配置不可用错误,且不得创建只有本地记录而无法付款的待支付订单
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 线下充值确认
|
||||
|
||||
**接口描述**:平台账号确认线下转账已到账,完成充值并为代理钱包增加余额。
|
||||
|
||||
系统 SHALL 仅允许平台账号对符合条件的存量线下充值记录执行确认,并在校验操作密码后完成一次性钱包入账。
|
||||
|
||||
**HTTP 方法与路径**
|
||||
|
||||
```
|
||||
POST /api/admin/agent-recharges/:id/offline-pay
|
||||
```
|
||||
|
||||
**鉴权**
|
||||
|
||||
- 需要登录态(Bearer Token)
|
||||
- 仅平台账号可调用,其他账号类型返回 `1005 CodeForbidden`
|
||||
|
||||
---
|
||||
|
||||
**请求体示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"operation_password": "Abc123456"
|
||||
}
|
||||
```
|
||||
|
||||
**请求字段说明**
|
||||
|
||||
| 字段名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| operation_password | string | 是 | 操作密码,用于二次身份验证 |
|
||||
|
||||
**路径参数说明**
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | integer | 充值记录 ID |
|
||||
|
||||
**业务规则**
|
||||
|
||||
- 操作密码验证失败返回 `1043 CodeInvalidOldPassword`
|
||||
- 充值记录必须存在且 `payment_method=offline`,否则返回 `1121 CodeRechargeNotFound`
|
||||
- 充值记录状态必须为 `1`(待支付),否则返回 `1050 CodeInvalidStatus`
|
||||
- 确认成功后:
|
||||
1. 充值记录状态更新为 `2`(已完成),记录 `paid_at` 和 `completed_at`
|
||||
2. 代理主钱包余额增加对应金额(使用乐观锁 version 字段防并发)
|
||||
3. 创建钱包流水记录
|
||||
4. 记录审计日志(操作人、操作前后数据)
|
||||
|
||||
---
|
||||
|
||||
**成功响应示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"id": 88,
|
||||
"recharge_no": "ARCH20260316100001",
|
||||
"shop_id": 101,
|
||||
"amount": 200000,
|
||||
"payment_method": "offline",
|
||||
"payment_channel": "offline",
|
||||
"payment_config_id": null,
|
||||
"status": 2,
|
||||
"paid_at": "2026-03-16T11:00:00+08:00",
|
||||
"completed_at": "2026-03-16T11:00:00+08:00",
|
||||
"created_at": "2026-03-16T10:00:00+08:00"
|
||||
},
|
||||
"timestamp": "2026-03-16T11:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**错误响应示例**
|
||||
|
||||
操作密码错误:
|
||||
```json
|
||||
{
|
||||
"code": 1043,
|
||||
"msg": "操作密码错误",
|
||||
"data": null,
|
||||
"timestamp": "2026-03-16T11:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
充值记录不存在:
|
||||
```json
|
||||
{
|
||||
"code": 1121,
|
||||
"msg": "充值记录不存在",
|
||||
"data": null,
|
||||
"timestamp": "2026-03-16T11:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
充值记录状态不允许操作:
|
||||
```json
|
||||
{
|
||||
"code": 1050,
|
||||
"msg": "当前充值记录状态不允许此操作",
|
||||
"data": null,
|
||||
"timestamp": "2026-03-16T11:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
非平台账号调用:
|
||||
```json
|
||||
{
|
||||
"code": 1005,
|
||||
"msg": "只有平台账号可以使用线下充值",
|
||||
"data": null,
|
||||
"timestamp": "2026-03-16T11:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
#### Scenario: 平台确认存量线下充值
|
||||
- **WHEN** 平台账号对待支付的线下充值记录提交正确操作密码
|
||||
- **THEN** 系统 MUST 完成充值记录、主钱包余额和唯一钱包流水更新,重复操作不得重复入账
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 代理充值查询
|
||||
|
||||
系统 SHALL 按当前账号数据范围提供代理充值列表与详情查询,并返回稳定的充值来源、状态和时间字段。
|
||||
|
||||
#### 接口一:充值记录列表
|
||||
|
||||
**接口描述**:分页查询代理充值记录,支持按店铺、状态、充值来源、日期范围过滤。
|
||||
|
||||
**HTTP 方法与路径**
|
||||
|
||||
```
|
||||
GET /api/admin/agent-recharges
|
||||
```
|
||||
|
||||
**鉴权**
|
||||
|
||||
- 需要登录态(Bearer Token)
|
||||
- 代理账号:只能查看自己所属店铺的充值记录
|
||||
- 平台账号:可查看所有店铺的充值记录
|
||||
|
||||
---
|
||||
|
||||
**请求参数(Query String)**
|
||||
|
||||
```
|
||||
GET /api/admin/agent-recharges?page=1&page_size=20&shop_id=101&status=3&recharge_source=agent_online&start_date=2026-03-01&end_date=2026-03-31
|
||||
```
|
||||
|
||||
**请求参数说明**
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| page | integer | 否 | 页码,默认 1 |
|
||||
| page_size | integer | 否 | 每页条数,默认 20,最大 100 |
|
||||
| shop_id | integer | 否 | 按店铺 ID 过滤(平台账号可用) |
|
||||
| status | integer | 否 | 按状态过滤:1=待支付,2=已支付,3=已完成,4=已关闭 |
|
||||
| recharge_source | string | 否 | 按充值来源过滤:`platform_offline`=平台线下代充,`agent_online`=代理在线自充 |
|
||||
| start_date | string | 否 | 创建时间起始日期,格式 `YYYY-MM-DD` |
|
||||
| end_date | string | 否 | 创建时间截止日期,格式 `YYYY-MM-DD` |
|
||||
|
||||
---
|
||||
|
||||
**成功响应示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"total": 56,
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"list": [
|
||||
{
|
||||
"id": 88,
|
||||
"recharge_no": "ARCH20260316100001",
|
||||
"shop_id": 101,
|
||||
"shop_name": "测试店铺A",
|
||||
"amount": 50000,
|
||||
"payment_method": "wechat",
|
||||
"payment_channel": "wechat_direct",
|
||||
"payment_config_id": 3,
|
||||
"recharge_source": "agent_online",
|
||||
"recharge_source_name": "代理在线自充",
|
||||
"status": 3,
|
||||
"paid_at": "2026-03-16T10:05:00+08:00",
|
||||
"completed_at": "2026-03-16T10:05:00+08:00",
|
||||
"created_at": "2026-03-16T10:00:00+08:00"
|
||||
},
|
||||
{
|
||||
"id": 87,
|
||||
"recharge_no": "ARCH20260315090001",
|
||||
"shop_id": 101,
|
||||
"shop_name": "测试店铺A",
|
||||
"amount": 200000,
|
||||
"payment_method": "offline",
|
||||
"payment_channel": "offline",
|
||||
"payment_config_id": null,
|
||||
"recharge_source": "platform_offline",
|
||||
"recharge_source_name": "平台线下代充",
|
||||
"status": 3,
|
||||
"paid_at": "2026-03-15T11:00:00+08:00",
|
||||
"completed_at": "2026-03-15T11:00:00+08:00",
|
||||
"created_at": "2026-03-15T09:00:00+08:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
"timestamp": "2026-03-16T12:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
**列表项字段说明**
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | integer | 充值记录 ID |
|
||||
| recharge_no | string | 充值单号 |
|
||||
| shop_id | integer | 店铺 ID |
|
||||
| shop_name | string | 店铺名称 |
|
||||
| amount | integer | 充值金额(分) |
|
||||
| payment_method | string | 支付方式 |
|
||||
| payment_channel | string | 实际支付通道 |
|
||||
| payment_config_id | integer\|null | 关联支付配置 ID |
|
||||
| recharge_source | string | 充值来源:`platform_offline` 或 `agent_online` |
|
||||
| recharge_source_name | string | 充值来源中文名称 |
|
||||
| status | integer | 状态:1=待支付,2=已支付,3=已完成,4=已关闭 |
|
||||
| paid_at | string\|null | 支付时间 |
|
||||
| completed_at | string\|null | 完成时间 |
|
||||
| created_at | string | 创建时间 |
|
||||
|
||||
---
|
||||
|
||||
**错误响应示例**
|
||||
|
||||
参数错误:
|
||||
```json
|
||||
{
|
||||
"code": 1001,
|
||||
"msg": "参数验证失败",
|
||||
"data": null,
|
||||
"timestamp": "2026-03-16T12:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 接口二:充值记录详情
|
||||
|
||||
**接口描述**:查询单条充值记录的完整详情。
|
||||
|
||||
**HTTP 方法与路径**
|
||||
|
||||
```
|
||||
GET /api/admin/agent-recharges/:id
|
||||
```
|
||||
|
||||
**鉴权**
|
||||
|
||||
- 需要登录态(Bearer Token)
|
||||
- 代理账号:只能查看自己所属店铺的充值记录,否则返回 `1121 CodeRechargeNotFound`
|
||||
- 平台账号:可查看任意充值记录
|
||||
|
||||
---
|
||||
|
||||
**路径参数说明**
|
||||
|
||||
| 参数名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | integer | 充值记录 ID |
|
||||
|
||||
---
|
||||
|
||||
**成功响应示例**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"id": 88,
|
||||
"recharge_no": "ARCH20260316100001",
|
||||
"shop_id": 101,
|
||||
"shop_name": "测试店铺A",
|
||||
"agent_wallet_id": 55,
|
||||
"amount": 50000,
|
||||
"payment_method": "wechat",
|
||||
"payment_channel": "wechat_direct",
|
||||
"payment_config_id": 3,
|
||||
"payment_transaction_id": "wx_txn_20260316_abc123",
|
||||
"recharge_source": "agent_online",
|
||||
"recharge_source_name": "代理在线自充",
|
||||
"status": 3,
|
||||
"paid_at": "2026-03-16T10:05:00+08:00",
|
||||
"completed_at": "2026-03-16T10:05:00+08:00",
|
||||
"created_at": "2026-03-16T10:00:00+08:00",
|
||||
"updated_at": "2026-03-16T10:05:00+08:00"
|
||||
},
|
||||
"timestamp": "2026-03-16T12:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
**详情字段说明**
|
||||
|
||||
| 字段名 | 类型 | 说明 |
|
||||
|--------|------|------|
|
||||
| id | integer | 充值记录 ID |
|
||||
| recharge_no | string | 充值单号 |
|
||||
| shop_id | integer | 店铺 ID |
|
||||
| shop_name | string | 店铺名称 |
|
||||
| agent_wallet_id | integer | 代理钱包 ID |
|
||||
| amount | integer | 充值金额(分) |
|
||||
| payment_method | string | 支付方式 |
|
||||
| payment_channel | string | 实际支付通道 |
|
||||
| payment_config_id | integer\|null | 关联支付配置 ID |
|
||||
| payment_transaction_id | string\|null | 第三方支付流水号 |
|
||||
| recharge_source | string | 充值来源:`platform_offline` 或 `agent_online` |
|
||||
| recharge_source_name | string | 充值来源中文名称 |
|
||||
| status | integer | 状态:1=待支付,2=已支付,3=已完成,4=已关闭 |
|
||||
| paid_at | string\|null | 支付时间 |
|
||||
| completed_at | string\|null | 完成时间 |
|
||||
| created_at | string | 创建时间 |
|
||||
| updated_at | string | 最后更新时间 |
|
||||
|
||||
---
|
||||
|
||||
**错误响应示例**
|
||||
|
||||
充值记录不存在或无权限:
|
||||
```json
|
||||
{
|
||||
"code": 1121,
|
||||
"msg": "充值记录不存在",
|
||||
"data": null,
|
||||
"timestamp": "2026-03-16T12:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
#### Scenario: 按数据范围查询充值记录
|
||||
- **WHEN** 已认证账号查询充值列表或详情
|
||||
- **THEN** 系统 MUST 仅返回其数据范围内的记录,并使用 `recharge_source` 区分平台线下代充与代理在线自充
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 代理充值回调处理
|
||||
|
||||
现有微信和支付宝异步回调入口 MUST 在完成渠道验签后,按 `tb_payment.payment_no` 和 `order_type=agent_recharge` 分发到代理充值支付确认用例,不得仅依赖 `ARCH` 单号前缀判断业务类型。旧代理充值支付单可在迁移期保留受控兼容分支,但新支付单 MUST 走统一支付记录分发。
|
||||
|
||||
支付确认 MUST 校验支付方式、创建时的 `payment_config_id`、回调商户或应用身份、支付单金额、充值单金额、支付单与充值单关联以及第三方交易号唯一性。校验失败 MUST 不改变支付单、充值单或钱包事实,并写入中文安全日志和 Integration Log;日志不得记录密钥或完整敏感正文。
|
||||
|
||||
成功确认 MUST 在同一 GORM 事务中:
|
||||
|
||||
1. 条件更新支付单为已支付并保存第三方交易号与支付时间;
|
||||
2. 条件更新代理充值单从 `1=待支付` 为 `2=已支付`;
|
||||
3. 写入稳定版本的代理充值入账 Outbox。
|
||||
|
||||
支付渠道成功响应 MUST 在上述事务提交后返回。钱包入账不得继续作为支付回调事务中的同步步骤。
|
||||
|
||||
Outbox 消费者 MUST 在独立事务中复用统一代理主钱包入账能力,锁定目标主钱包、增加余额、创建唯一成功流水、将充值单从 `2=已支付` 更新为 `3=已完成`,并写入现有钱包入账事件。消费者 MUST 以充值记录 ID 作为业务幂等引用;重复回调、重复 Outbox 或 Worker 重试不得重复增加余额。
|
||||
|
||||
#### Scenario: 微信支付成功回调
|
||||
- **WHEN** 微信回调验签通过,支付单类型为 `agent_recharge`,且金额、配置、商户身份和业务关联全部一致
|
||||
- **THEN** 系统 MUST 固化支付成功事实和入账 Outbox,并向微信返回渠道成功响应
|
||||
- **THEN** 钱包余额由独立消费者完成,不得在回调事务中同步增加
|
||||
|
||||
#### Scenario: 支付宝支付成功回调
|
||||
- **WHEN** 支付宝通知验签通过,交易状态为成功,支付单类型为 `agent_recharge`,且金额、配置、应用身份和业务关联全部一致
|
||||
- **THEN** 系统 MUST 固化支付成功事实和入账 Outbox,并向支付宝返回 `success`
|
||||
|
||||
#### Scenario: 回调金额或关联不一致
|
||||
- **WHEN** 回调金额与支付单或充值单不一致,或支付单未关联该代理充值记录
|
||||
- **THEN** 系统 MUST 拒绝处理并保留原状态,钱包余额和流水 MUST NOT 改变
|
||||
|
||||
#### Scenario: 重复支付回调
|
||||
- **WHEN** 同一第三方交易号对同一已支付或已完成充值单重复回调
|
||||
- **THEN** 系统 MUST 幂等返回渠道成功响应,不得重复写支付事实、入账 Outbox 或钱包流水
|
||||
|
||||
#### Scenario: 第三方交易号被其他支付单占用
|
||||
- **WHEN** 回调中的第三方交易号已绑定另一张支付单或代理充值单
|
||||
- **THEN** 系统 MUST 返回冲突并记录不含敏感信息的严重错误,任何钱包 MUST NOT 入账
|
||||
|
||||
#### Scenario: 钱包入账暂时失败
|
||||
- **WHEN** 支付事实已提交但钱包入账消费者因锁冲突或暂时性基础设施错误失败
|
||||
- **THEN** 充值单 MUST 保持 `2=已支付`,Outbox/Worker MUST 重试,且支付成功事实不得回滚或要求代理再次付款
|
||||
|
||||
#### Scenario: 重复执行钱包入账
|
||||
- **WHEN** 同一代理充值入账任务被重复消费
|
||||
- **THEN** 数据库唯一流水约束和状态条件更新 MUST 保证余额最多增加一次,并最终将充值单收敛为 `3=已完成`
|
||||
|
||||
### Requirement: 权限控制
|
||||
|
||||
系统 MUST 使用以下权限边界:
|
||||
|
||||
| 操作 | 平台/超级管理员 | 代理账号 | 企业账号 |
|
||||
|------|-----------------|----------|----------|
|
||||
| 创建在线扫码充值 | 禁止 | 仅当前所属店铺主钱包 | 禁止 |
|
||||
| 创建线下代充 | 按现有权限指定目标店铺 | 禁止 | 禁止 |
|
||||
| 线下充值确认 | 允许 | 禁止 | 禁止 |
|
||||
| 查询可用在线支付方式 | 禁止 | 允许 | 禁止 |
|
||||
| 查询充值列表与详情 | 按既有数据范围 | 按既有店铺层级与查看权限 | 禁止 |
|
||||
| 查询在线支付状态 | 按既有数据范围 | 按既有店铺层级与查看权限 | 禁止 |
|
||||
|
||||
路由层 MUST 对企业账号执行粗粒度拦截;Application/Query MUST 执行创建角色、当前店铺、资源归属和查看权限校验;GORM 数据范围过滤保持启用。越权与资源不存在 MUST 统一返回 `CodeForbidden` 和“无权限操作该资源或资源不存在”,不得泄露资源是否存在。
|
||||
|
||||
线下充值确认继续使用既有平台操作密码契约;密码验证失败返回 `CodeInvalidOldPassword`,响应和日志均不得记录密码明文。
|
||||
|
||||
#### Scenario: 代理为当前店铺充值
|
||||
- **WHEN** 代理账号创建在线扫码充值且当前店铺主钱包可用
|
||||
- **THEN** 系统 MUST 以认证上下文中的店铺和主钱包作为唯一受益方
|
||||
|
||||
#### Scenario: 平台尝试创建在线扫码充值
|
||||
- **WHEN** 平台或超级管理员提交 `wechat` 或 `alipay` 代理充值请求
|
||||
- **THEN** 系统 MUST 返回 `CodeForbidden`,并引导其使用既有线下代充审批路径
|
||||
|
||||
#### Scenario: 无权读取支付状态
|
||||
- **WHEN** 登录账号查询其数据范围外充值单的支付状态
|
||||
- **THEN** 系统 MUST 返回统一禁止访问错误,不得返回支付状态、付款内容或钱包余额
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 查询代理在线充值可用支付方式
|
||||
|
||||
系统 SHALL 提供 `GET /api/admin/agent-recharges/payment-methods`,仅根据当前生效支付配置返回真正具备支付链接生成、回调验签和查单能力的在线支付方式。路由 MUST 注册在 `/:id` 动态路由之前。
|
||||
|
||||
成功响应 MUST 使用统一 `{code,msg,data,timestamp}` 格式;`data.methods` MUST 为按 `wechat`、`alipay` 固定顺序排列的字符串数组,并同时返回 `min_amount=10000` 与 `max_amount=100000000`。接口不得返回支付配置 ID、商户号、应用 ID、密钥或具体缺失的敏感配置。
|
||||
|
||||
#### Scenario: 微信与支付宝均可用
|
||||
- **WHEN** 当前支付配置完整支持对应协议的微信 H5/MWEB 和支付宝 WAP 支付
|
||||
- **THEN** 接口 MUST 返回 `methods=["wechat","alipay"]` 及在线金额上下限
|
||||
|
||||
#### Scenario: 没有可用扫码支付方式
|
||||
- **WHEN** 当前配置不支持任何已约定的扫码支付方式
|
||||
- **THEN** 接口 MUST 成功返回空数组,不得伪造可用渠道
|
||||
|
||||
### Requirement: 查询代理在线充值支付状态
|
||||
|
||||
系统 SHALL 提供 `GET /api/admin/agent-recharges/:id/payment-status` 供桌面端轮询本地事实。接口 MUST 仅查询 PostgreSQL 本地支付单、充值单及必要的钱包入账结果,不得在每次轮询时调用第三方支付渠道。
|
||||
|
||||
响应 MUST 使用统一 `{code,msg,data,timestamp}` 格式,并至少返回 `status`、`status_name`、`payment_status`、`payment_status_name`、`paid_at` 与 `completed_at`。`1=待支付` MUST 表示尚未确认收款,`2=已支付` MUST 表示第三方收款已确认但钱包仍在入账,`3=已完成` MUST 表示钱包余额和唯一流水已经提交。
|
||||
|
||||
接口 MUST NOT 返回 `qr_content`、支付密钥、签名参数、内部重试错误或其他店铺余额。第一期 MUST NOT 提供主动取消、支付方式切换、在线退款或本地推算的精确二维码倒计时。
|
||||
|
||||
#### Scenario: 等待扫码支付
|
||||
- **WHEN** 充值单仍为待支付且支付单未确认成功
|
||||
- **THEN** 接口 MUST 返回待支付状态,不得调用第三方查单
|
||||
|
||||
#### Scenario: 已支付等待钱包入账
|
||||
- **WHEN** 支付单已支付且充值单状态为 `2=已支付`
|
||||
- **THEN** 接口 MUST 明确返回支付成功、入账处理中,不得显示支付失败
|
||||
|
||||
#### Scenario: 钱包已经到账
|
||||
- **WHEN** 充值单状态为 `3=已完成` 且唯一钱包流水存在
|
||||
- **THEN** 接口 MUST 返回已完成和完成时间
|
||||
|
||||
### Requirement: 待支付订单受控收敛
|
||||
|
||||
系统 MUST 通过后台受控任务查询长期待支付的代理在线充值支付单,以弥补第三方回调丢失。任务 MUST 使用创建支付单时记录的支付方式和 `payment_config_id` 调用对应查单 Adapter,并为每次外部尝试记录 Integration Log。
|
||||
|
||||
查单确认成功 MUST 复用与回调相同的支付确认用例;查单确认关闭或失效 MUST 条件关闭仍为待支付的支付单与充值单;未知、超时或渠道异常 MUST 保持原业务状态并按任务策略重试。迟到的真实成功通知经完整校验后 MUST 仍能固化支付事实并入账,不得因本地曾判断待支付或关闭而吞掉已收款资金。
|
||||
|
||||
#### Scenario: 回调丢失但查单成功
|
||||
- **WHEN** 待支付收敛任务从第三方查询到交易成功且金额与配置校验通过
|
||||
- **THEN** 系统 MUST 调用统一支付确认用例,写入支付事实和钱包入账 Outbox
|
||||
|
||||
#### Scenario: 第三方明确订单关闭
|
||||
- **WHEN** 查单结果明确表示订单关闭或失效,且本地支付单仍为待支付
|
||||
- **THEN** 系统 MUST 条件更新支付单为失败并将充值单关闭,不得增加钱包余额
|
||||
|
||||
#### Scenario: 查单结果未知
|
||||
- **WHEN** 第三方超时、返回未知状态或暂时不可用
|
||||
- **THEN** 系统 MUST 保持本地待支付状态并重试,不得猜测支付失败
|
||||
|
||||
---
|
||||
|
||||
## 数据模型补充说明
|
||||
|
||||
**tb_agent_recharge_record 新增字段**
|
||||
|
||||
| 字段名 | 类型 | 可空 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| payment_config_id | bigint | 是 | 关联支付配置 ID,线下充值为 NULL,在线充值记录实际使用的支付配置 |
|
||||
|
||||
**充值状态枚举**
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| 1 | 待支付(订单已创建,等待支付) |
|
||||
| 2 | 已支付(第三方收款已确认,钱包入账处理中) |
|
||||
| 3 | 已完成(钱包余额和唯一流水已提交) |
|
||||
| 4 | 已关闭(支付链接生成失败或确认订单已关闭) |
|
||||
|
||||
**支付方式枚举**
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| wechat | 微信 H5/MWEB 支付链接扫码支付 |
|
||||
| alipay | 支付宝 WAP 支付链接扫码支付 |
|
||||
| offline | 线下转账(仅平台账号可用) |
|
||||
|
||||
**支付通道枚举**
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| wechat_direct | 微信直连通道 |
|
||||
| alipay | 支付宝通道 |
|
||||
| offline | 线下转账 |
|
||||
@@ -1,54 +0,0 @@
|
||||
# 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`
|
||||
@@ -1,224 +0,0 @@
|
||||
# Capability: 代理系列授权管理
|
||||
|
||||
## Purpose
|
||||
|
||||
定义"系列授权"(Series Grant)的 CRUD 操作。系列授权将原本割裂的"系列分配"和"套餐分配"合并为一个原子操作,一次请求完成:授权代理可销售某系列下的指定套餐、设定每个套餐的成本价、配置一次性佣金(固定模式:单值天花板;梯度模式:每档位上限金额列表)和代理自设强充(平台未设时)。
|
||||
|
||||
底层仍使用 `tb_shop_series_allocation` 和 `tb_shop_package_allocation` 两张表,对外以 `ShopSeriesAllocation.ID` 作为 grant 主键。
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 创建系列授权(固定模式)
|
||||
|
||||
系统 SHALL 提供 `POST /shop-series-grants` 接口,在一次请求中原子性创建系列分配和套餐分配列表。固定模式下 `one_time_commission_amount` MUST 必填,且不得超过分配者自身的天花板。
|
||||
|
||||
#### Scenario: 代理成功创建固定模式授权
|
||||
- **WHEN** 代理A(自身天花板=80元)为直属下级代理B 创建系列授权,commission_type=fixed,one_time_commission_amount=5000(50元),packages=[{package_id:1, cost_price:3000}, {package_id:2, cost_price:5000}]
|
||||
- **THEN** 系统在事务中创建 1 条 ShopSeriesAllocation(one_time_commission_amount=5000)和 2 条 ShopPackageAllocation,响应返回包含 packages 列表的聚合视图
|
||||
|
||||
#### Scenario: 代理B 已存在此系列授权,重复创建
|
||||
- **WHEN** 代理A 为代理B 创建系列授权,但代理B 在此系列下已有 active 授权记录
|
||||
- **THEN** 系统返回错误"该代理已存在此系列授权"
|
||||
|
||||
#### Scenario: 分配者自身无此系列授权
|
||||
- **WHEN** 代理A 自身未被授权此套餐系列,尝试为代理B 创建此系列授权
|
||||
- **THEN** 系统返回错误"当前账号无此系列授权,无法向下分配"
|
||||
|
||||
#### Scenario: 平台成功创建固定模式授权
|
||||
- **WHEN** 平台管理员为一级代理创建系列授权,commission_type=fixed,one_time_commission_amount=8000(80元),系列总额 commission_amount=10000(100元)
|
||||
- **THEN** 系统创建授权,响应中 allocator_shop_id=0,allocator_shop_name="平台"
|
||||
|
||||
#### Scenario: 固定模式 one_time_commission_amount 为必填
|
||||
- **WHEN** 请求中不包含 one_time_commission_amount
|
||||
- **THEN** 系统返回参数错误"固定模式下一次性佣金额度为必填项"
|
||||
|
||||
#### Scenario: 金额超过代理自身天花板
|
||||
- **WHEN** 代理A(天花板=8000分)为代理B 创建授权,one_time_commission_amount=10000
|
||||
- **THEN** 系统返回错误"一次性佣金额度不能超过上级限额"
|
||||
|
||||
#### Scenario: 平台金额超过系列总额
|
||||
- **WHEN** 平台为代理A 创建授权,one_time_commission_amount=12000,但 PackageSeries.commission_amount=10000
|
||||
- **THEN** 系统返回错误"一次性佣金额度不能超过套餐系列设定的总额"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 创建系列授权(梯度模式)
|
||||
|
||||
系统 SHALL 支持梯度模式的系列授权创建。梯度模式下,`commission_tiers` MUST 为必填,且必须包含与 PackageSeries 完全相同数量和阈值的阶梯(不多不少)。若某档位不希望给下级佣金,应将该档位的 amount 设为 0,不可省略该档位。创建成功后的响应中,`commission_tiers` 每个档位 MUST 包含 `operator`、`dimension`、`stat_scope` 字段(从全局配置合并)。
|
||||
|
||||
#### Scenario: 代理成功创建梯度模式授权
|
||||
- **WHEN** 代理A 的专属阶梯为 `[{operator:">=" , threshold:100, amount:80}, {operator:">=" , threshold:150, amount:120}]`,A 为代理B 创建授权,传入 `commission_tiers=[{threshold:100, amount:50}, {threshold:150, amount:100}]`
|
||||
- **THEN** 系统创建授权,`commission_tiers_json` 存储 `[{threshold:100, amount:50}, {threshold:150, amount:100}]`
|
||||
- **AND** 响应中 `commission_tiers=[{operator:">=" , dimension:"sales_count", stat_scope:"self", threshold:100, amount:50}, ...]`
|
||||
|
||||
#### Scenario: 平台成功创建梯度模式授权
|
||||
- **WHEN** 平台为顶级代理A 创建授权,PackageSeries 阶梯含 `operator`/`dimension`/`stat_scope`
|
||||
- **THEN** 系统创建授权,响应中 `commission_tiers` 包含 PackageSeries 全局 `operator`、`dimension`、`stat_scope`
|
||||
|
||||
#### Scenario: 梯度模式某档位金额超过父级
|
||||
- **WHEN** 代理A 的阶梯第一档 `amount=80`,A 为 B 创建授权时传入第一档 `amount=90`
|
||||
- **THEN** 系统返回错误“某档位佣金金额超过上级天花板”
|
||||
|
||||
#### Scenario: 梯度模式传入了不存在的阈值
|
||||
- **WHEN** PackageSeries 只有 `threshold=100` 和 `150` 两档,请求中传入 `threshold=200`
|
||||
- **THEN** 系统返回错误“梯度阶梯 threshold 与系列配置不匹配”
|
||||
|
||||
#### Scenario: 梯度模式 commission_tiers 为必填
|
||||
- **WHEN** 请求中不包含 `commission_tiers` 或为空数组
|
||||
- **THEN** 系统返回参数错误“梯度模式必须填写阶梯配置”
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 强充配置的平台/代理层级
|
||||
|
||||
创建系列授权时,系统 SHALL 根据 PackageSeries 的触发类型和强充设置决定代理是否可自设强充。
|
||||
|
||||
#### Scenario: 首次充值触发类型,强充不可配置
|
||||
- **WHEN** PackageSeries.trigger_type=first_recharge,代理创建授权时传入任意 enable_force_recharge 值
|
||||
- **THEN** 系统忽略代理的强充设置,响应中 force_recharge_locked=true(首次充值本身即为强充机制,无需额外配置)
|
||||
|
||||
#### Scenario: 累计充值触发类型,平台已设强充,代理配置被忽略
|
||||
- **WHEN** PackageSeries.trigger_type=accumulated_recharge 且 enable_force_recharge=true,代理创建授权时传入 enable_force_recharge=false
|
||||
- **THEN** 系统忽略代理的强充设置,响应中 force_recharge_locked=true
|
||||
|
||||
#### Scenario: 累计充值触发类型,平台未设强充,代理可自设
|
||||
- **WHEN** PackageSeries.trigger_type=accumulated_recharge 且 enable_force_recharge=false,代理创建授权时传入 enable_force_recharge=true,force_recharge_amount=10000
|
||||
- **THEN** 系统保存代理的强充配置,响应中 force_recharge_locked=false,force_recharge_enabled=true,force_recharge_amount=10000
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 查询系列授权详情
|
||||
|
||||
系统 SHALL 提供 `GET /shop-series-grants/:id` 接口,返回包含套餐列表的聚合视图。梯度模式下,`commission_tiers` 中每个档位 MUST 包含 `dimension`(统计维度)和 `stat_scope`(统计范围)字段,这两个字段从 PackageSeries 全局配置按 `threshold` 合并,对代理只读。
|
||||
|
||||
#### Scenario: 固定模式详情
|
||||
- **WHEN** 查询固定模式系列授权详情
|
||||
- **THEN** 响应包含 `commission_type="fixed"`,`one_time_commission_amount=有效值`,`commission_tiers=[]`
|
||||
|
||||
#### Scenario: 梯度模式详情
|
||||
- **WHEN** 查询梯度模式系列授权详情
|
||||
- **THEN** 响应包含 `commission_type="tiered"`,`one_time_commission_amount=0`
|
||||
- **AND** `commission_tiers` 中每个档位包含 `operator`、`dimension`、`stat_scope`、`threshold`、`amount` 五个字段
|
||||
- **AND** `operator`、`dimension`、`stat_scope` 的值来自 PackageSeries 全局配置(对应 threshold 的档位),代理的 `amount` 来自 `ShopSeriesAllocation.commission_tiers_json`
|
||||
|
||||
#### Scenario: 梯度模式 dimension 为销售量
|
||||
- **WHEN** 查询梯度模式授权详情,PackageSeries 阶梯 `dimension = "sales_count"`
|
||||
- **THEN** 响应中对应档位 `dimension = "sales_count"`,前端展示“销售量”条件
|
||||
|
||||
#### Scenario: 梯度模式 dimension 为销售额
|
||||
- **WHEN** 查询梯度模式授权详情,PackageSeries 阶梯 `dimension = "sales_amount"`
|
||||
- **THEN** 响应中对应档位 `dimension = "sales_amount"`,前端展示“销售额”条件
|
||||
|
||||
#### Scenario: 梯度模式 stat_scope 区分
|
||||
- **WHEN** 查询梯度模式授权详情
|
||||
- **THEN** 响应中 `stat_scope` 正确反映 PackageSeries 配置的统计范围(`"self"` 或 `"self_and_sub"`)
|
||||
|
||||
#### Scenario: 查询不存在的授权
|
||||
- **WHEN** 查询不存在的授权 ID
|
||||
- **THEN** 系统返回错误“授权记录不存在”
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 查询系列授权列表
|
||||
|
||||
系统 SHALL 提供 `GET /shop-series-grants` 接口,支持分页和多维度筛选,响应内嵌套餐数量摘要(不含完整套餐列表)。
|
||||
|
||||
#### Scenario: 列表查询支持按店铺和系列筛选
|
||||
- **WHEN** 传入 shop_id、series_id、allocator_shop_id 等筛选条件
|
||||
- **THEN** 仅返回符合条件的授权记录,每条记录包含 package_count
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 更新系列授权配置
|
||||
|
||||
系统 SHALL 提供 `PUT /shop-series-grants/:id` 接口,支持更新一次性佣金配置和强充配置。
|
||||
|
||||
#### Scenario: 固定模式更新佣金额度
|
||||
- **WHEN** 更新 one_time_commission_amount,新值不超过分配者天花板
|
||||
- **THEN** 系统更新成功
|
||||
|
||||
#### Scenario: 梯度模式更新阶梯金额
|
||||
- **WHEN** 更新 commission_tiers,每档位金额不超过分配者同档位上限
|
||||
- **THEN** 系统更新 commission_tiers_json 字段
|
||||
|
||||
#### Scenario: 更新代理自设强充(平台未设时)
|
||||
- **WHEN** 平台未设强充,更新 enable_force_recharge=true,force_recharge_amount=10000
|
||||
- **THEN** 系统更新成功,后续该代理渠道下的客户须满足强充要求
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 管理授权内套餐
|
||||
|
||||
系统 SHALL 提供 `PUT /shop-series-grants/:id/packages` 接口,支持添加套餐、移除套餐、更新成本价,操作在事务中完成,成功后返回 HTTP 200(无需返回完整授权视图)。
|
||||
|
||||
#### Scenario: 向授权中添加新套餐
|
||||
- **WHEN** 请求包含新的 package_id 和 cost_price,且该套餐属于此系列
|
||||
- **THEN** 系统创建新的 ShopPackageAllocation
|
||||
|
||||
#### Scenario: 更新套餐成本价
|
||||
- **WHEN** 请求中套餐的 cost_price 与当前值不同
|
||||
- **THEN** 系统更新 cost_price 并写价格历史记录
|
||||
|
||||
#### Scenario: 移除授权中的套餐
|
||||
- **WHEN** 请求中某套餐标记 remove=true,且该套餐在当前授权中存在
|
||||
- **THEN** 系统软删除对应的 ShopPackageAllocation
|
||||
|
||||
#### Scenario: remove=true 但套餐已不在授权中
|
||||
- **WHEN** 请求中某套餐标记 remove=true,但该套餐已被软删除或从未在此授权中
|
||||
- **THEN** 系统静默忽略该条目,不报错,继续处理其他条目
|
||||
|
||||
#### Scenario: 重新添加曾被移除的套餐
|
||||
- **WHEN** 某套餐曾经被软删除,请求中再次包含该 package_id(无 remove 标志)
|
||||
- **THEN** 系统创建一条新的 ShopPackageAllocation 记录,不恢复旧记录
|
||||
|
||||
#### Scenario: 添加不属于该系列的套餐
|
||||
- **WHEN** 请求中包含不属于该系列的 package_id
|
||||
- **THEN** 系统返回错误"套餐不属于该系列,无法添加到此授权"
|
||||
|
||||
#### Scenario: 添加上级未授权的套餐
|
||||
- **WHEN** 代理A 尝试添加代理A 自己也未获授权的套餐
|
||||
- **THEN** 系统返回错误"无权限分配该套餐"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 删除系列授权
|
||||
|
||||
系统 SHALL 提供 `DELETE /shop-series-grants/:id` 接口,删除时同步软删除所有关联的套餐分配。
|
||||
|
||||
#### Scenario: 成功删除无下级依赖的授权
|
||||
- **WHEN** 删除一个下级代理未基于此授权再分配的记录
|
||||
- **THEN** 系统软删除 ShopSeriesAllocation 和所有关联的 ShopPackageAllocation
|
||||
|
||||
#### Scenario: 有下级依赖时禁止删除
|
||||
- **WHEN** 删除一个已被下级代理用于创建子授权的记录
|
||||
- **THEN** 系统返回错误"存在下级依赖,无法删除,请先删除下级授权"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 系列授权列表强充状态正确反映
|
||||
|
||||
系列授权列表 (`GET /shop-series-grants`) MUST 在每个列表项中返回 `force_recharge_locked`(是否被套餐系列锁定)和 `force_recharge_amount`(强充金额)。`force_recharge_enabled` MUST 反映有效状态:当锁定时为 `true`(无论分配记录自身如何);未锁定时取分配记录的实际设置。
|
||||
|
||||
#### Scenario: 套餐系列锁定强充
|
||||
- **WHEN** 套餐系列配置 `enable_force_recharge=true` 或 `trigger_type=first_recharge`,查询列表
|
||||
- **THEN** 列表项中 `force_recharge_locked=true`,`force_recharge_enabled=true`,`force_recharge_amount`=系列配置的 `force_amount`
|
||||
|
||||
#### Scenario: 代理自身开启强充(未锁定)
|
||||
- **WHEN** 套餐系列未锁定强充,分配记录中 `enable_force_recharge=true`,查询列表
|
||||
- **THEN** 列表项中 `force_recharge_locked=false`,`force_recharge_enabled=true`,`force_recharge_amount`=分配记录的实际金额
|
||||
|
||||
#### Scenario: 代理未开启强充(未锁定)
|
||||
- **WHEN** 套餐系列未锁定强充,分配记录中 `enable_force_recharge=false`,查询列表
|
||||
- **THEN** 列表项中 `force_recharge_locked=false`,`force_recharge_enabled=false`,`force_recharge_amount=0`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 查询系列授权详情强充有效状态
|
||||
|
||||
系列授权详情 (`GET /shop-series-grants/:id`) 中,`force_recharge_enabled` MUST 反映有效状态:当锁定时为 `true`;`force_recharge_amount` 锁定时应返回系列配置的 `force_amount`。
|
||||
|
||||
#### Scenario: 锁定强充时详情响应
|
||||
- **WHEN** 套餐系列锁定强充,查询对应分配记录详情
|
||||
- **THEN** `force_recharge_locked=true`,`force_recharge_enabled=true`,`force_recharge_amount`=系列配置的 `force_amount`
|
||||
@@ -1,339 +0,0 @@
|
||||
# agent-wallet Specification
|
||||
|
||||
## Purpose
|
||||
代理钱包系统,提供店铺级别的主钱包和分佣钱包管理,支持充值、扣款、冻结、提现等操作。与卡钱包完全隔离,独立的数据表和代码实现。
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 代理钱包实体定义
|
||||
|
||||
系统 SHALL 定义代理钱包(AgentWallet)实体,管理店铺级别的钱包,支持主钱包和分佣钱包两种类型。
|
||||
|
||||
**核心概念**:
|
||||
- **主钱包(main)**:店铺的主要资金账户,用于预充值和购买套餐
|
||||
- **分佣钱包(commission)**:店铺的佣金账户,用于接收分佣和提现
|
||||
|
||||
**实体字段**:
|
||||
- `id`:钱包 ID(主键,BIGINT,自增)
|
||||
- `shop_id`:店铺 ID(BIGINT,关联 tb_shop.id,唯一约束之一)
|
||||
- `wallet_type`:钱包类型(VARCHAR(20),枚举值:"main"-主钱包 | "commission"-分佣钱包,唯一约束之一)
|
||||
- `balance`:余额(BIGINT,单位:分,默认 0,≥ 0)
|
||||
- `frozen_balance`:冻结余额(BIGINT,单位:分,默认 0,≥ 0)
|
||||
- `currency`:币种(VARCHAR(10),默认 "CNY")
|
||||
- `status`:钱包状态(INT,1-正常 2-冻结 3-关闭,默认 1)
|
||||
- `version`:版本号(INT,默认 0,乐观锁字段,用于防止并发扣款)
|
||||
- `shop_id_tag`:店铺 ID 标签(BIGINT,多租户过滤用,与 shop_id 相同)
|
||||
- `enterprise_id_tag`:企业 ID 标签(BIGINT,多租户过滤用,可空)
|
||||
- `created_at`:创建时间(TIMESTAMP,自动填充)
|
||||
- `updated_at`:更新时间(TIMESTAMP,自动填充)
|
||||
- `deleted_at`:删除时间(TIMESTAMP,可空,软删除)
|
||||
|
||||
**唯一约束**:`(shop_id, wallet_type)` 在 `deleted_at IS NULL` 条件下唯一
|
||||
|
||||
**可用余额计算**:可用余额 = balance - frozen_balance
|
||||
|
||||
**表名**:`tb_agent_wallet`
|
||||
|
||||
#### Scenario: 创建店铺主钱包
|
||||
|
||||
- **WHEN** 店铺(ID 为 10)首次充值
|
||||
- **THEN** 系统创建代理钱包记录,`shop_id` 为 10,`wallet_type` 为 "main",`balance` 为 0,`status` 为 1(正常),`shop_id_tag` 为 10
|
||||
|
||||
#### Scenario: 创建店铺分佣钱包
|
||||
|
||||
- **WHEN** 店铺(ID 为 10)首次获得佣金
|
||||
- **THEN** 系统创建代理钱包记录,`shop_id` 为 10,`wallet_type` 为 "commission",`balance` 为 0,`status` 为 1(正常)
|
||||
|
||||
#### Scenario: 计算可用余额
|
||||
|
||||
- **WHEN** 代理钱包余额为 100000 分(1000 元),冻结余额为 30000 分(300 元)
|
||||
- **THEN** 系统计算可用余额为 70000 分(700 元)
|
||||
|
||||
#### Scenario: 防止同一店铺创建重复钱包类型
|
||||
|
||||
- **WHEN** 店铺(ID 为 10)已有 wallet_type 为 "main" 的钱包,尝试再次创建 wallet_type 为 "main" 的钱包
|
||||
- **THEN** 系统拒绝创建,返回错误信息"该店铺已存在主钱包"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 代理钱包交易记录
|
||||
|
||||
系统 SHALL 记录所有代理钱包余额变动,包括充值、扣款、退款、分佣、提现等操作,确保完整的审计追踪。
|
||||
|
||||
**实体字段**:
|
||||
- `id`:交易记录 ID(主键,BIGINT,自增)
|
||||
- `agent_wallet_id`:代理钱包 ID(BIGINT,关联 tb_agent_wallet.id)
|
||||
- `shop_id`:店铺 ID(BIGINT,冗余字段,便于按店铺查询)
|
||||
- `user_id`:操作人用户 ID(BIGINT,关联 tb_account.id)
|
||||
- `transaction_type`:交易类型(VARCHAR(20),枚举值:"recharge"-充值 | "deduct"-扣款 | "refund"-退款 | "commission"-分佣 | "withdrawal"-提现)
|
||||
- `amount`:变动金额(BIGINT,单位:分,正数为增加,负数为减少)
|
||||
- `balance_before`:变动前余额(BIGINT,单位:分)
|
||||
- `balance_after`:变动后余额(BIGINT,单位:分)
|
||||
- `status`:交易状态(INT,1-成功 2-失败 3-处理中,默认 1)
|
||||
- `reference_type`:关联业务类型(VARCHAR(50),如 "order" | "commission" | "withdrawal" | "topup",可空)
|
||||
- `reference_id`:关联业务 ID(BIGINT,可空)
|
||||
- `remark`:备注(TEXT,可空)
|
||||
- `metadata`:扩展信息(JSONB,如手续费、支付方式等,可空)
|
||||
- `creator`:创建人 ID(BIGINT)
|
||||
- `shop_id_tag`:店铺 ID 标签(BIGINT,多租户过滤用)
|
||||
- `enterprise_id_tag`:企业 ID 标签(BIGINT,多租户过滤用,可空)
|
||||
- `created_at`:创建时间(TIMESTAMP,自动填充)
|
||||
- `updated_at`:更新时间(TIMESTAMP,自动填充)
|
||||
- `deleted_at`:删除时间(TIMESTAMP,可空,软删除)
|
||||
|
||||
**表名**:`tb_agent_wallet_transaction`
|
||||
|
||||
**索引**:
|
||||
- `idx_agent_tx_wallet (agent_wallet_id, created_at)`:按钱包查询交易历史
|
||||
- `idx_agent_tx_shop (shop_id, created_at)`:按店铺汇总交易
|
||||
- `idx_agent_tx_ref (reference_type, reference_id)`:按关联业务查询
|
||||
- `idx_agent_tx_type (transaction_type, created_at)`:按交易类型统计
|
||||
|
||||
#### Scenario: 充值创建交易记录
|
||||
|
||||
- **WHEN** 店铺(ID 为 10)主钱包充值 100000 分(1000 元)
|
||||
- **THEN** 系统创建代理钱包交易记录,`transaction_type` 为 "recharge",`amount` 为 100000,`balance_before` 为 0,`balance_after` 为 100000,`status` 为 1(成功),`shop_id` 为 10
|
||||
|
||||
#### Scenario: 分佣发放创建交易记录
|
||||
|
||||
- **WHEN** 店铺(ID 为 10)的分佣钱包收到佣金 50000 分(500 元)
|
||||
- **THEN** 系统创建代理钱包交易记录,`transaction_type` 为 "commission",`amount` 为 50000,`balance_before` 为 200000,`balance_after` 为 250000,`reference_type` 为 "commission",`reference_id` 为分佣记录 ID
|
||||
|
||||
#### Scenario: 提现创建交易记录
|
||||
|
||||
- **WHEN** 店铺(ID 为 10)从分佣钱包提现 30000 分(300 元)
|
||||
- **THEN** 系统创建代理钱包交易记录,`transaction_type` 为 "withdrawal",`amount` 为 -30000,`balance_before` 为 250000,`balance_after` 为 220000,`reference_type` 为 "withdrawal",`reference_id` 为提现申请 ID
|
||||
|
||||
#### Scenario: 按店铺查询交易历史
|
||||
|
||||
- **WHEN** 管理员查询店铺(ID 为 10)的所有钱包交易记录,按时间倒序
|
||||
- **THEN** 系统使用索引 `idx_agent_tx_shop` 查询,返回该店铺的主钱包和分佣钱包的所有交易记录,按 `created_at` 降序排序
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 代理充值记录管理
|
||||
|
||||
系统 SHALL 记录所有代理充值操作,包括充值订单号、金额、支付方式、支付状态等信息。
|
||||
|
||||
**实体字段**:
|
||||
- `id`:充值记录 ID(主键,BIGINT,自增)
|
||||
- `user_id`:操作人用户 ID(BIGINT,关联 tb_account.id)
|
||||
- `agent_wallet_id`:代理钱包 ID(BIGINT,关联 tb_agent_wallet.id)
|
||||
- `shop_id`:店铺 ID(BIGINT,冗余字段,便于查询)
|
||||
- `recharge_no`:充值订单号(VARCHAR(50),唯一,格式:ARCH+时间戳+随机数)
|
||||
- `amount`:充值金额(BIGINT,单位:分,≥ 1)
|
||||
- `payment_method`:支付方式(VARCHAR(20),枚举值:"alipay"-支付宝 | "wechat"-微信 | "bank"-银行转账 | "offline"-线下)
|
||||
- `payment_channel`:支付渠道(VARCHAR(50),可空)
|
||||
- `payment_transaction_id`:第三方支付交易号(VARCHAR(100),可空)
|
||||
- `status`:充值状态(INT,1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款,默认 1)
|
||||
- `paid_at`:支付时间(TIMESTAMP,可空)
|
||||
- `completed_at`:完成时间(TIMESTAMP,可空)
|
||||
- `shop_id_tag`:店铺 ID 标签(BIGINT,多租户过滤用)
|
||||
- `enterprise_id_tag`:企业 ID 标签(BIGINT,多租户过滤用,可空)
|
||||
- `created_at`:创建时间(TIMESTAMP,自动填充)
|
||||
- `updated_at`:更新时间(TIMESTAMP,自动填充)
|
||||
- `deleted_at`:删除时间(TIMESTAMP,可空,软删除)
|
||||
|
||||
**表名**:`tb_agent_recharge_record`
|
||||
|
||||
**充值金额限制**:
|
||||
- 最小充值金额:1 分
|
||||
- 最大充值金额:100000000 分(1000000 元)
|
||||
|
||||
**索引**:
|
||||
- `idx_agent_recharge_user (user_id, created_at)`:按用户查询充值记录
|
||||
- `idx_agent_recharge_shop (shop_id, created_at)`:按店铺查询充值记录
|
||||
- `idx_agent_recharge_status (status, created_at)`:按状态过滤充值记录
|
||||
- `idx_agent_recharge_no (recharge_no)`:按订单号查询
|
||||
|
||||
#### Scenario: 创建代理充值订单
|
||||
|
||||
- **WHEN** 店铺(ID 为 10)的管理员发起充值 100000 分(1000 元),选择支付宝支付
|
||||
- **THEN** 系统创建代理充值记录,生成唯一的 `recharge_no`(如 "ARCH20260224123456789012"),`amount` 为 100000,`payment_method` 为 "alipay",`status` 为 1(待支付),`shop_id` 为 10
|
||||
|
||||
#### Scenario: 充值金额低于最小限制
|
||||
|
||||
- **WHEN** 店铺管理员尝试充值 0 分
|
||||
- **THEN** 系统拒绝创建充值订单,返回错误信息"充值金额超出允许范围"
|
||||
|
||||
#### Scenario: 充值支付完成
|
||||
|
||||
- **WHEN** 店铺管理员完成支付宝支付
|
||||
- **THEN** 系统将充值记录状态从 1(待支付)变更为 2(已支付),记录 `paid_at` 时间和 `payment_transaction_id`
|
||||
|
||||
#### Scenario: 充值到账
|
||||
|
||||
- **WHEN** 充值记录状态为 2(已支付),系统处理充值到账
|
||||
- **THEN** 系统将代理钱包余额增加 100000 分,创建代理钱包交易记录,将充值记录状态变更为 3(已完成),记录 `completed_at` 时间
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 代理钱包余额操作
|
||||
|
||||
系统 SHALL 支持代理钱包余额的充值、扣款、退款、冻结、解冻等操作,使用乐观锁防止并发问题。
|
||||
|
||||
**操作类型**:
|
||||
- **充值**:增加钱包余额
|
||||
- **扣款**:减少钱包余额(如购买套餐)
|
||||
- **退款**:增加钱包余额(如订单退款)
|
||||
- **冻结**:将部分余额转为冻结状态(如提现申请中)
|
||||
- **解冻**:将冻结余额转回可用余额(如提现取消)
|
||||
|
||||
**并发控制**:
|
||||
- 使用 `version` 字段实现乐观锁
|
||||
- 每次更新余额时,检查 `version` 是否匹配
|
||||
- 如果 `version` 不匹配,说明有并发更新,操作失败并重试
|
||||
|
||||
**操作约束**:
|
||||
- 扣款时,检查可用余额(balance - frozen_balance)是否充足
|
||||
- 冻结时,检查可用余额是否充足
|
||||
- 所有余额变动必须创建交易记录
|
||||
|
||||
#### Scenario: 代理钱包充值
|
||||
|
||||
- **WHEN** 店铺主钱包当前余额为 100000 分,充值 50000 分
|
||||
- **THEN** 系统将钱包余额更新为 150000 分,`version` 从 1 变更为 2,创建交易记录(`transaction_type` 为 "recharge",`amount` 为 50000)
|
||||
|
||||
#### Scenario: 代理钱包扣款
|
||||
|
||||
- **WHEN** 店铺主钱包当前余额为 150000 分,购买套餐扣款 30000 分
|
||||
- **THEN** 系统检查可用余额(150000 - 0 = 150000)≥ 30000,将钱包余额更新为 120000 分,`version` 从 2 变更为 3,创建交易记录(`transaction_type` 为 "deduct",`amount` 为 -30000)
|
||||
|
||||
#### Scenario: 余额不足扣款失败
|
||||
|
||||
- **WHEN** 店铺主钱包当前余额为 20000 分,购买套餐需要扣款 30000 分
|
||||
- **THEN** 系统检查可用余额(20000 - 0 = 20000)< 30000,拒绝扣款,返回错误信息"余额不足"
|
||||
|
||||
#### Scenario: 并发扣款乐观锁生效
|
||||
|
||||
- **WHEN** 店铺主钱包当前余额为 100000 分,version 为 1,两个并发请求同时扣款 30000 分和 50000 分
|
||||
- **THEN** 第一个请求成功,余额变为 70000 分,version 变为 2;第二个请求因 version 不匹配失败,需重新读取最新余额(70000 分)和 version(2)后重试
|
||||
|
||||
#### Scenario: 冻结余额用于提现
|
||||
|
||||
- **WHEN** 店铺分佣钱包余额为 100000 分,申请提现 30000 分
|
||||
- **THEN** 系统将钱包的 `frozen_balance` 增加 30000 分,可用余额减少 30000 分,`version` 增加 1
|
||||
|
||||
#### Scenario: 解冻余额(提现取消)
|
||||
|
||||
- **WHEN** 店铺分佣钱包冻结余额为 30000 分,用户取消提现申请
|
||||
- **THEN** 系统将钱包的 `frozen_balance` 减少 30000 分,可用余额增加 30000 分,`version` 增加 1
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 代理钱包数据校验
|
||||
|
||||
系统 SHALL 对代理钱包数据进行校验,确保数据完整性和一致性。
|
||||
|
||||
**校验规则**:
|
||||
- `shop_id`:必填,≥ 1,必须是有效的店铺 ID
|
||||
- `wallet_type`:必填,枚举值 "main" | "commission"
|
||||
- `balance`:必填,≥ 0
|
||||
- `frozen_balance`:必填,≥ 0,≤ balance
|
||||
- `currency`:必填,长度 1-10 字符,默认 "CNY"
|
||||
- `status`:必填,枚举值 1-3
|
||||
- `version`:必填,≥ 0
|
||||
|
||||
#### Scenario: 创建钱包时 shop_id 无效
|
||||
|
||||
- **WHEN** 创建代理钱包,`shop_id` 为 0
|
||||
- **THEN** 系统拒绝创建,返回错误信息"店铺 ID 无效,必须 ≥ 1"
|
||||
|
||||
#### Scenario: 创建钱包时 wallet_type 无效
|
||||
|
||||
- **WHEN** 创建代理钱包,`wallet_type` 为 "invalid"
|
||||
- **THEN** 系统拒绝创建,返回错误信息"钱包类型无效,必须是 main 或 commission"
|
||||
|
||||
#### Scenario: 冻结余额超过总余额
|
||||
|
||||
- **WHEN** 代理钱包余额为 100000 分,尝试冻结 150000 分
|
||||
- **THEN** 系统拒绝操作,返回错误信息"冻结余额不能超过总余额"
|
||||
|
||||
#### Scenario: 余额为负数
|
||||
|
||||
- **WHEN** 尝试将代理钱包余额设置为 -10000 分
|
||||
- **THEN** 系统拒绝操作,返回错误信息"余额不能为负数"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 代理钱包归属店铺规则
|
||||
|
||||
系统 SHALL 确保代理钱包归属店铺,不支持转手,店铺的多个员工账号共享钱包。
|
||||
|
||||
**归属规则**:
|
||||
- 代理钱包归属店铺(shop_id),不归属个人用户
|
||||
- 同一店铺的所有员工账号共享该店铺的主钱包和分佣钱包
|
||||
- 店铺钱包不支持转手,归属关系固定
|
||||
|
||||
#### Scenario: 店铺的多个员工账号共享钱包
|
||||
|
||||
- **WHEN** 店铺(ID 为 10)有 3 个员工账号(账号 ID 为 201、202、203),店铺主钱包余额为 500000 分
|
||||
- **THEN** 3 个员工账号登录后查询店铺主钱包,余额都是 500000 分,可以共享使用
|
||||
|
||||
#### Scenario: 员工账号只能访问自己店铺的钱包
|
||||
|
||||
- **WHEN** 员工账号(ID 为 201,归属店铺 10)尝试访问店铺 20 的钱包
|
||||
- **THEN** 系统拒绝访问,返回错误信息"无权限访问该店铺的钱包"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 代理钱包 Redis 缓存策略
|
||||
|
||||
系统 SHALL 使用 Redis 缓存代理钱包余额,提升查询性能,并使用 Redis 分布式锁防止并发操作冲突。
|
||||
|
||||
**缓存 Key 定义**:
|
||||
- 余额缓存:`agent_wallet:balance:{shop_id}:{wallet_type}`
|
||||
- 分布式锁:`agent_wallet:lock:{shop_id}:{wallet_type}`
|
||||
|
||||
**缓存 TTL**:
|
||||
- 余额缓存:300 秒(5 分钟)
|
||||
- 分布式锁:10 秒
|
||||
|
||||
**缓存更新策略**:
|
||||
- 余额变动时,删除缓存(Cache-Aside 模式)
|
||||
- 下次查询时重新加载到缓存
|
||||
|
||||
**常量定义位置**:`pkg/constants/wallet.go`
|
||||
|
||||
```go
|
||||
func RedisAgentWalletBalanceKey(shopID uint, walletType string) string
|
||||
func RedisAgentWalletLockKey(shopID uint, walletType string) string
|
||||
```
|
||||
|
||||
#### Scenario: 查询余额时使用缓存
|
||||
|
||||
- **WHEN** 查询店铺(ID 为 10)主钱包余额,缓存中存在该余额
|
||||
- **THEN** 系统直接从 Redis 返回余额,不查询数据库
|
||||
|
||||
#### Scenario: 余额变动后删除缓存
|
||||
|
||||
- **WHEN** 店铺(ID 为 10)主钱包余额增加 50000 分
|
||||
- **THEN** 系统删除 Redis 缓存 Key `agent_wallet:balance:10:main`,下次查询时重新加载
|
||||
|
||||
#### Scenario: 使用分布式锁防止并发冻结
|
||||
|
||||
- **WHEN** 两个并发请求同时尝试冻结店铺(ID 为 10)主钱包的余额
|
||||
- **THEN** 系统使用 Redis 分布式锁 `agent_wallet:lock:10:main`,第一个请求获得锁,第二个请求等待或失败
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 批量查询店铺主钱包余额
|
||||
|
||||
系统 SHALL 在 `AgentWalletStore` 中提供 `GetShopMainWalletBatch(ctx, shopIDs []uint) map[uint]*AgentWallet` 方法,一次查询多个店铺的主钱包(`wallet_type=main`)记录,返回以 `shop_id` 为 key 的 map。
|
||||
|
||||
**实现要求**:
|
||||
- 使用 `WHERE shop_id IN (?) AND wallet_type = 'main'` 单次查询,不得逐条查询
|
||||
- 不在 map 中的 shop_id 表示该店铺暂无主钱包,调用方按零值处理
|
||||
- 与现有 `GetShopCommissionSummaryBatch` 对称设计
|
||||
|
||||
#### Scenario: 批量查询多个店铺的主钱包
|
||||
|
||||
- **WHEN** 传入 shopIDs `[1, 2, 3]`,其中 shop 3 无主钱包记录
|
||||
- **THEN** 返回 map `{1: &wallet1, 2: &wallet2}`,shop 3 不在 map 中
|
||||
|
||||
#### Scenario: 传入空列表
|
||||
|
||||
- **WHEN** 传入空 `shopIDs`
|
||||
- **THEN** 直接返回空 map,不执行 DB 查询
|
||||
|
||||
---
|
||||
@@ -1,67 +0,0 @@
|
||||
# Capability: 分配配置版本管理
|
||||
|
||||
## Purpose
|
||||
|
||||
本 capability 定义如何管理套餐系列分配的返佣配置版本,确保订单创建时锁定配置,支持配置历史查询和审计。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 返佣配置变更时创建新版本
|
||||
|
||||
系统 SHALL 在代理修改套餐系列分配的返佣配置时,创建新的配置版本记录。旧版本 MUST 被标记为失效(设置 effective_to 时间戳),新版本 MUST 记录生效时间(effective_from)。
|
||||
|
||||
#### Scenario: 修改基础返佣配置时创建新版本
|
||||
- **WHEN** 代理将基础返佣从20%修改为25%
|
||||
- **THEN** 系统失效当前配置版本,创建新版本(version + 1)
|
||||
|
||||
#### Scenario: 修改梯度返佣开关时创建新版本
|
||||
- **WHEN** 代理启用或禁用梯度返佣
|
||||
- **THEN** 系统失效当前配置版本,创建新版本
|
||||
|
||||
#### Scenario: 仅修改非配置字段时不创建新版本
|
||||
- **WHEN** 代理修改分配的状态(启用/禁用),但不修改返佣配置
|
||||
- **THEN** 系统不创建新配置版本
|
||||
|
||||
#### Scenario: 新版本记录正确的生效时间
|
||||
- **WHEN** 代理在2026-01-28 10:00:00修改返佣配置
|
||||
- **THEN** 新版本的 effective_from 为 2026-01-28 10:00:00
|
||||
|
||||
#### Scenario: 旧版本记录正确的失效时间
|
||||
- **WHEN** 代理在2026-01-28 10:00:00修改返佣配置
|
||||
- **THEN** 旧版本的 effective_to 为 2026-01-28 10:00:00
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 订单创建时锁定配置版本
|
||||
|
||||
系统 SHALL 在创建充值订单时,查询当前生效的配置版本并锁定到订单。订单 MUST 记录配置版本ID和配置快照(返佣模式、返佣值)。
|
||||
|
||||
#### Scenario: 订单创建时查询当前生效配置
|
||||
- **WHEN** 下级客户在2026-01-28 10:30:00发起充值
|
||||
- **THEN** 系统查询2026-01-28 10:30:00时生效的配置版本(effective_from <= 10:30:00 AND effective_to IS NULL)
|
||||
|
||||
#### Scenario: 订单锁定配置版本ID
|
||||
- **WHEN** 订单创建时,查询到配置版本ID为123
|
||||
- **THEN** 订单记录 allocation_config_id = 123
|
||||
|
||||
#### Scenario: 订单记录配置快照
|
||||
- **WHEN** 订单创建时,配置为百分比200(20%)
|
||||
- **THEN** 订单记录 locked_commission_mode = "percent", locked_commission_value = 200
|
||||
|
||||
#### Scenario: 配置变更后订单使用锁定的配置
|
||||
- **WHEN** 订单创建后,代理修改了返佣配置
|
||||
- **THEN** 订单仍然按照锁定的配置计算返佣
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 查询历史配置版本
|
||||
|
||||
系统 SHALL 允许代理查询指定分配的所有历史配置版本,按生效时间倒序排列。
|
||||
|
||||
#### Scenario: 查询分配的配置版本历史
|
||||
- **WHEN** 代理查询分配ID为123的配置版本历史
|
||||
- **THEN** 系统返回该分配的所有版本记录,最新版本在最前
|
||||
|
||||
#### Scenario: 历史版本包含完整配置信息
|
||||
- **WHEN** 查询历史配置版本
|
||||
- **THEN** 每个版本包含:版本号、返佣模式、返佣值、梯度开关、生效时间、失效时间
|
||||
@@ -1,59 +0,0 @@
|
||||
# Capability: 分配成本价历史管理
|
||||
|
||||
## Purpose
|
||||
|
||||
本 capability 定义如何记录和查询套餐分配的成本价变更历史,支持审计和纠纷处理,确保历史记录不可篡改。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 成本价调整时记录历史
|
||||
|
||||
系统 SHALL 在代理调整套餐分配的成本价时,创建成本价变更历史记录。历史记录 MUST 包含:旧成本价、新成本价、变更原因、变更人、生效时间。
|
||||
|
||||
#### Scenario: 单个调整时创建历史记录
|
||||
- **WHEN** 代理将套餐A的成本价从10000分调整为11000分,原因为"市场调价"
|
||||
- **THEN** 系统创建历史记录:old = 10000, new = 11000, reason = "市场调价"
|
||||
|
||||
#### Scenario: 批量调整时批量创建历史记录
|
||||
- **WHEN** 代理批量调整100个套餐的成本价
|
||||
- **THEN** 系统创建100条历史记录
|
||||
|
||||
#### Scenario: 历史记录包含变更人信息
|
||||
- **WHEN** 用户ID为456的代理调整成本价
|
||||
- **THEN** 历史记录的 changed_by = 456
|
||||
|
||||
#### Scenario: 历史记录记录生效时间
|
||||
- **WHEN** 代理在2026-01-28 10:00:00调整成本价
|
||||
- **THEN** 历史记录的 effective_from = 2026-01-28 10:00:00
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 查询成本价变更历史
|
||||
|
||||
系统 SHALL 允许代理查询指定套餐分配的成本价变更历史,按生效时间倒序排列。
|
||||
|
||||
#### Scenario: 查询套餐分配的成本价历史
|
||||
- **WHEN** 代理查询分配ID为123的成本价历史
|
||||
- **THEN** 系统返回该分配的所有成本价变更记录,最新变更在最前
|
||||
|
||||
#### Scenario: 历史记录包含完整变更信息
|
||||
- **WHEN** 查询成本价历史
|
||||
- **THEN** 每条记录包含:旧成本价、新成本价、变更原因、变更人、生效时间
|
||||
|
||||
#### Scenario: 支持按时间范围筛选历史
|
||||
- **WHEN** 代理查询2026年1月的成本价变更
|
||||
- **THEN** 系统返回effective_from在2026-01-01至2026-01-31之间的记录
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 支持审计和纠纷处理
|
||||
|
||||
成本价历史记录 SHALL 支持审计和纠纷处理,系统 MUST 保证历史记录不可篡改(只能创建,不能修改或删除)。
|
||||
|
||||
#### Scenario: 历史记录不可修改
|
||||
- **WHEN** 尝试修改已创建的历史记录
|
||||
- **THEN** 系统拒绝操作
|
||||
|
||||
#### Scenario: 历史记录不可删除
|
||||
- **WHEN** 尝试删除已创建的历史记录
|
||||
- **THEN** 系统拒绝操作
|
||||
@@ -1,58 +0,0 @@
|
||||
# Capability: 分配记录独立上下架
|
||||
|
||||
## Purpose
|
||||
|
||||
本 capability 定义代理对自己分配到的套餐的独立上下架能力。代理可以独立控制自己客户侧的套餐可见性,互不影响。同时约束分配记录 status 修改的所有者校验规则。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 分配记录独立上下架
|
||||
|
||||
系统 SHALL 在 `tb_shop_package_allocation` 表维护 `shelf_status` 字段(1-上架, 2-下架),允许代理独立控制自己分配到的套餐在客户侧的可见性,不影响其他代理和平台的同一套餐状态。
|
||||
|
||||
#### Scenario: 新建分配记录默认上架
|
||||
- **WHEN** 平台或上级代理为某店铺创建套餐分配记录
|
||||
- **THEN** `allocation.shelf_status` 默认为 1(上架)
|
||||
|
||||
#### Scenario: 代理下架自己的套餐
|
||||
- **GIVEN** 代理A拥有套餐P的分配记录,shelf_status=1
|
||||
- **WHEN** 代理A调用 `PATCH /api/admin/packages/:id/shelf`,传入 shelf_status=2
|
||||
- **THEN** 系统更新代理A的 `allocation.shelf_status=2`,套餐P在代理A的客户侧不可见
|
||||
- **AND** 代理B的同一套餐分配记录 shelf_status 不受影响
|
||||
- **AND** `tb_package.shelf_status` 不受影响
|
||||
|
||||
#### Scenario: 代理上架自己的套餐
|
||||
- **GIVEN** 代理A的分配记录 shelf_status=2
|
||||
- **WHEN** 代理A调用 `PATCH /api/admin/packages/:id/shelf`,传入 shelf_status=1
|
||||
- **THEN** 系统更新代理A的 `allocation.shelf_status=1`
|
||||
|
||||
#### Scenario: 代理上架已被全局禁用的套餐
|
||||
- **GIVEN** `tb_package.status=2`(套餐全局禁用),代理A的 allocation.shelf_status=2
|
||||
- **WHEN** 代理A尝试将 shelf_status 设置为1(上架)
|
||||
- **THEN** 系统返回错误 "套餐已禁用,无法上架"
|
||||
|
||||
#### Scenario: 调用者无分配记录时无法操作
|
||||
- **GIVEN** 代理A没有套餐P的分配记录
|
||||
- **WHEN** 代理A调用 `PATCH /api/admin/packages/:id/shelf`(套餐ID为P)
|
||||
- **THEN** 系统返回错误 "该套餐未分配给您,无法操作上下架"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 分配记录 status 修改需所有者校验
|
||||
|
||||
系统 MUST 验证调用者是分配记录的创建者(allocator),才允许修改该记录的 `status`(启用/禁用)。
|
||||
|
||||
#### Scenario: 平台用户修改任意分配记录的 status
|
||||
- **GIVEN** 平台用户调用 `PUT /api/admin/shop-package-allocations/:id/status`
|
||||
- **WHEN** 分配记录存在
|
||||
- **THEN** 允许修改,不限制 allocator
|
||||
|
||||
#### Scenario: 代理修改自己创建的分配记录的 status
|
||||
- **GIVEN** 代理A创建了"代理A→代理B"的分配记录(allocator_shop_id = A的shop_id)
|
||||
- **WHEN** 代理A调用修改该记录的 status
|
||||
- **THEN** 允许修改
|
||||
|
||||
#### Scenario: 代理修改别人分配给自己的记录的 status
|
||||
- **GIVEN** 平台或代理A创建了"→代理B"的分配记录(allocator_shop_id != B的shop_id)
|
||||
- **WHEN** 代理B调用修改该记录的 status
|
||||
- **THEN** 系统返回错误 "无权限操作该资源或资源不存在"
|
||||
@@ -1,45 +0,0 @@
|
||||
# API 文档完整性规范
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: OpenAPI 文档 100% 覆盖所有路由接口
|
||||
|
||||
系统的 OpenAPI 文档应包含所有已注册的 HTTP 路由接口,覆盖率达到 100%。
|
||||
|
||||
#### Scenario: 文档注册所有 Handler
|
||||
|
||||
- **WHEN** 系统启动或生成 OpenAPI 文档
|
||||
- **THEN** `cmd/api/docs.go` 中的 `bootstrap.Handlers` 结构体包含全部 48 个 Handler(包括 Account、AdminOrder、AgentRecharge、Asset、AssetWallet 等)
|
||||
- **THEN** 每个 Handler 对应一个已注册的路由组(如 `/api/admin/accounts` → `handlers.Account`)
|
||||
- **THEN** 生成的 OpenAPI 文档包含这 48 个 Handler 对应的全部接口
|
||||
|
||||
#### Scenario: 新增 Handler 时同步文档生成器
|
||||
|
||||
- **WHEN** 开发者在 `internal/router/` 中新增一个 Handler 并注册到路由
|
||||
- **THEN** 开发者必须同时在 `cmd/api/docs.go` 和 `cmd/gendocs/main.go` 中的 `bootstrap.Handlers` 结构体添加该 Handler 字段
|
||||
- **THEN** 若遗漏,代码审查应拒绝合并(检查清单项:**新增 Handler 时是否同步更新 docs.go/gendocs/main.go**)
|
||||
|
||||
#### Scenario: OpenAPI 文档校验完整性
|
||||
|
||||
- **WHEN** 执行 `go run cmd/gendocs/main.go` 生成 OpenAPI 文档
|
||||
- **THEN** 文档应包含所有已注册的接口路由
|
||||
- **THEN** 无"缺失文档"的警告或错误信息
|
||||
|
||||
### Requirement: 文档生成器不遗漏 Handler
|
||||
|
||||
文档生成器的 `bootstrap.Handlers` 结构体应显式列出所有 Handler,避免新增后遗漏。
|
||||
|
||||
#### Scenario: 完整的 Handler 清单
|
||||
|
||||
- **WHEN** 审阅 `cmd/api/docs.go` 的 `bootstrap.Handlers` 结构体定义
|
||||
- **THEN** 该结构体包含以下字段(至少 48 个,按模块分组):
|
||||
```go
|
||||
Account *admin.AccountHandler
|
||||
AdminOrder *admin.OrderHandler
|
||||
AgentRecharge *agent.RechargeHandler
|
||||
Asset *admin.AssetHandler
|
||||
AssetWallet *admin.AssetWalletHandler
|
||||
Authorization *admin.AuthorizationHandler
|
||||
// ... 共 48 个
|
||||
```
|
||||
- **THEN** 注释中标注每个 Handler 对应的路由前缀和功能模块
|
||||
@@ -1,123 +0,0 @@
|
||||
# Asset Allocation Record
|
||||
|
||||
## Purpose
|
||||
|
||||
管理资产(IoT 卡、设备)在平台与代理商之间的流转记录,支持分配和回收操作的完整追溯。
|
||||
## Requirements
|
||||
### Requirement: 资产分配记录查询
|
||||
|
||||
系统 SHALL 提供资产分配记录的查询功能,支持查看卡和设备在平台与代理商之间的流转历史。
|
||||
|
||||
**记录类型**:
|
||||
- `allocate`: 分配记录(上级分配给下级)
|
||||
- `recall`: 回收记录(上级从下级回收)
|
||||
|
||||
**资产类型**:
|
||||
- `iot_card`: 物联网卡(单卡)
|
||||
- `device`: 设备
|
||||
|
||||
**查询条件**:
|
||||
- `allocation_type`(可选): 分配类型,枚举值 "allocate" | "recall"
|
||||
- `asset_type`(可选): 资产类型,枚举值 "iot_card" | "device"
|
||||
- `asset_identifier`(可选): 资产标识符(ICCID 或设备号),模糊匹配
|
||||
- `allocation_no`(可选): 分配单号,精确匹配
|
||||
- `from_shop_id`(可选): 来源店铺 ID
|
||||
- `to_shop_id`(可选): 目标店铺 ID
|
||||
- `operator_id`(可选): 操作人 ID
|
||||
- `created_at_start`(可选): 创建时间起始
|
||||
- `created_at_end`(可选): 创建时间结束
|
||||
|
||||
**分页**:
|
||||
- 默认每页 20 条,最大每页 100 条
|
||||
- 返回总记录数和总页数
|
||||
|
||||
**数据权限**:
|
||||
- 平台用户可查看所有记录
|
||||
- 代理用户只能查看与自己店铺相关的记录(作为来源或目标)
|
||||
|
||||
**API 端点**: `GET /api/admin/asset-allocation-records`
|
||||
|
||||
**响应字段**:
|
||||
- `id`: 记录 ID
|
||||
- `allocation_no`: 分配单号
|
||||
- `allocation_type`: 分配类型
|
||||
- `allocation_type_name`: 分配类型名称(分配/回收)
|
||||
- `asset_type`: 资产类型
|
||||
- `asset_type_name`: 资产类型名称(物联网卡/设备)
|
||||
- `asset_id`: 资产 ID
|
||||
- `asset_identifier`: 资产标识符
|
||||
- `related_device_id`: 关联设备 ID(单卡分配时,如果卡绑定了设备)
|
||||
- `related_card_ids`: 关联卡 ID 列表(设备分配时,包含设备绑定的所有卡 ID)
|
||||
- `from_owner_type`: 来源所有者类型
|
||||
- `from_owner_id`: 来源所有者 ID
|
||||
- `from_owner_name`: 来源所有者名称
|
||||
- `to_owner_type`: 目标所有者类型
|
||||
- `to_owner_id`: 目标所有者 ID
|
||||
- `to_owner_name`: 目标所有者名称
|
||||
- `operator_id`: 操作人 ID
|
||||
- `operator_name`: 操作人名称
|
||||
- `remark`: 备注
|
||||
- `created_at`: 创建时间
|
||||
|
||||
#### Scenario: 查询所有分配记录
|
||||
|
||||
- **WHEN** 平台管理员查询分配记录列表,不带任何筛选条件
|
||||
- **THEN** 系统返回所有分配和回收记录,按创建时间倒序排列
|
||||
|
||||
#### Scenario: 按资产类型筛选记录
|
||||
|
||||
- **WHEN** 管理员查询资产类型为 "iot_card" 的记录
|
||||
- **THEN** 系统只返回物联网卡的分配/回收记录,不包含设备记录
|
||||
|
||||
#### Scenario: 按资产类型筛选设备记录
|
||||
|
||||
- **WHEN** 管理员查询资产类型为 "device" 的记录
|
||||
- **THEN** 系统只返回设备的分配/回收记录,不包含单卡记录
|
||||
|
||||
#### Scenario: 按分配类型筛选记录
|
||||
|
||||
- **WHEN** 管理员查询分配类型为 "allocate" 的记录
|
||||
- **THEN** 系统只返回分配记录,不包含回收记录
|
||||
|
||||
#### Scenario: 按 ICCID 模糊查询
|
||||
|
||||
- **WHEN** 管理员输入 asset_identifier = "8986001"
|
||||
- **THEN** 系统返回 ICCID 包含 "8986001" 的所有分配记录
|
||||
|
||||
#### Scenario: 按设备号模糊查询
|
||||
|
||||
- **WHEN** 管理员输入 asset_identifier = "GPS"
|
||||
- **THEN** 系统返回设备号包含 "GPS" 的所有分配记录
|
||||
|
||||
#### Scenario: 代理查询自己相关的记录
|
||||
|
||||
- **WHEN** 代理用户(店铺 ID=10)查询分配记录
|
||||
- **THEN** 系统只返回 from_owner_id=10 或 to_owner_id=10 的记录
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 资产分配记录详情
|
||||
|
||||
系统 SHALL 提供资产分配记录详情查询功能。
|
||||
|
||||
**API 端点**: `GET /api/admin/asset-allocation-records/:id`
|
||||
|
||||
**响应**:
|
||||
- 包含记录的所有字段
|
||||
- `related_card_ids`: 关联卡 ID 列表(设备分配时,包含设备绑定的所有卡 ID)
|
||||
|
||||
#### Scenario: 查询分配记录详情
|
||||
|
||||
- **WHEN** 管理员查询分配记录详情(ID=1)
|
||||
- **THEN** 系统返回该记录的完整信息,包括来源/目标所有者名称、操作人名称等
|
||||
|
||||
#### Scenario: 查询设备分配记录详情
|
||||
|
||||
- **WHEN** 管理员查询设备分配记录详情
|
||||
- **THEN** 系统返回该记录的完整信息,包括 related_card_ids(设备绑定的所有卡 ID)
|
||||
|
||||
#### Scenario: 查询不存在的记录
|
||||
|
||||
- **WHEN** 管理员查询不存在的分配记录(ID=999)
|
||||
- **THEN** 系统返回 404 错误,提示"分配记录不存在"
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
# asset-audit-readable-content Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change improve-audit-log-readability-and-signal-summary. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: 资产操作审计日志必须补充业务可读字段
|
||||
系统 SHALL 在资产操作审计日志中保留现有内部主键字段,同时 MUST 为关键操作对象补充业务可读字段,确保业务侧无需查库即可理解操作内容。
|
||||
|
||||
#### Scenario: 卡相关日志补充 ICCID
|
||||
- **WHEN** 系统记录针对单卡或多卡的分配、回收、删除、实名、停复机等操作日志
|
||||
- **THEN** `before_data` 或 `after_data` 除内部卡 ID 外,还包含对应的 `iccid` 或 `iccids`
|
||||
|
||||
#### Scenario: 设备相关日志补充设备标识
|
||||
- **WHEN** 系统记录设备绑定、解绑、切卡、远程控制、删除等操作日志
|
||||
- **THEN** `before_data` 或 `after_data` 除内部设备 ID 外,还包含至少一种业务可读设备标识,如虚拟号、IMEI 或 SN
|
||||
|
||||
#### Scenario: 店铺相关日志补充店铺名称
|
||||
- **WHEN** 系统记录资产分配、资产回收或归属变更类日志
|
||||
- **THEN** `before_data` 或 `after_data` 在店铺 ID 之外还包含相应店铺名称,便于直接识别归属变化
|
||||
|
||||
### Requirement: 审计日志可读字段必须遵循兼容新增原则
|
||||
系统 SHALL 以向后兼容方式扩展资产审计日志内容,不得通过删除现有内部字段来换取可读性。
|
||||
|
||||
#### Scenario: 现有内部字段仍然保留
|
||||
- **WHEN** 系统为审计日志补充可读字段
|
||||
- **THEN** 现有 `card_id`、`device_id`、`target_shop_id`、`binding_id` 等内部字段仍然保留,不被移除或重命名
|
||||
|
||||
#### Scenario: 新增字段不影响旧日志读取
|
||||
- **WHEN** 现有日志消费方继续读取资产操作日志
|
||||
- **THEN** 旧字段结构保持可用,新增可读字段仅作为补充信息出现
|
||||
|
||||
### Requirement: 同类审计场景必须使用统一的可读字段命名
|
||||
系统 SHALL 在同类资产审计场景中使用稳定一致的可读字段命名,避免同一语义在不同日志里出现多套字段名。
|
||||
|
||||
#### Scenario: 单卡与批量卡操作字段一致
|
||||
- **WHEN** 系统分别记录单卡操作和批量卡操作日志
|
||||
- **THEN** 单卡场景使用 `iccid`,批量场景使用 `iccids` 或卡对象列表中的 `iccid` 字段,命名保持一致
|
||||
|
||||
#### Scenario: 设备相关可读字段命名稳定
|
||||
- **WHEN** 系统记录多个设备相关操作日志
|
||||
- **THEN** 设备业务标识字段使用统一命名,不在不同接口间随意切换字段语义
|
||||
|
||||
### Requirement: 审计日志补充可读字段不得引入额外破坏性变更
|
||||
系统 SHALL 仅增强资产审计日志的可读性,不得借本次变更调整店铺删除规则、账号过滤规则或其他无关业务行为。
|
||||
|
||||
#### Scenario: 店铺删除规则保持不变
|
||||
- **WHEN** 本次变更上线
|
||||
- **THEN** 店铺删除相关业务规则不因审计日志优化而发生变化
|
||||
|
||||
#### Scenario: 企业账号列表行为保持不变
|
||||
- **WHEN** 本次变更上线
|
||||
- **THEN** 账号列表接口的企业账号展示逻辑不因审计日志优化而发生变化
|
||||
|
||||
55
openspec/specs/asset-device/spec.md
Normal file
55
openspec/specs/asset-device/spec.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# asset-device 当前行为
|
||||
|
||||
## Purpose
|
||||
|
||||
描述卡、设备、资产状态与绑定关系的当前可观察行为。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 资产业务状态
|
||||
|
||||
系统 SHALL 将资产状态按 1=在库、2=已销售、3=已换货、4=已停用返回,并与运营商网络状态分离。
|
||||
|
||||
#### Scenario: 资产业务状态
|
||||
|
||||
- **GIVEN** 资产存在且具有业务与网络状态
|
||||
- **WHEN** 查询资产详情
|
||||
- **THEN** 响应分别返回业务状态与网络状态
|
||||
|
||||
### Requirement: 设备多卡关系
|
||||
|
||||
系统 SHALL 允许设备显式绑定和解绑卡,并在设备资产查询中展示当前卡关系。
|
||||
|
||||
#### Scenario: 设备多卡关系
|
||||
|
||||
- **GIVEN** 设备与卡均存在且操作人有权管理
|
||||
- **WHEN** 执行绑定或解绑
|
||||
- **THEN** 后续设备卡列表反映该关系变化
|
||||
|
||||
## 可达操作索引
|
||||
|
||||
本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。
|
||||
|
||||
### IoT卡管理
|
||||
|
||||
`GET /api/admin/iot-cards/{iccid}/realname-link`(获取实名认证链接);`PUT /api/admin/iot-cards/{iccid}/speed-tier`(设置卡固定限速档位);`POST /api/admin/iot-cards/batch-update-realname-policy`(批量更新卡实名认证策略);`POST /api/admin/iot-cards/import`(批量导入IoT卡(ICCID+MSISDN));`GET /api/admin/iot-cards/import-tasks`(导入任务列表);`GET /api/admin/iot-cards/import-tasks/{id}`(导入任务详情);`PATCH /api/admin/iot-cards/series-binding`(批量设置卡的套餐系列绑定);`GET /api/admin/iot-cards/standalone`(单卡列表(未绑定设备));`POST /api/admin/iot-cards/standalone/allocate`(批量分配单卡);`POST /api/admin/iot-cards/standalone/recall`(批量回收单卡)。
|
||||
|
||||
### 设备管理
|
||||
|
||||
`GET /api/admin/devices`(设备列表);`DELETE /api/admin/devices/{virtual_no}`(删除设备);`GET /api/admin/devices/{virtual_no}/cards`(获取设备绑定的卡列表);`POST /api/admin/devices/{virtual_no}/cards`(绑定卡到设备);`DELETE /api/admin/devices/{virtual_no}/cards/{iccid}`(解绑设备上的卡);`POST /api/admin/devices/allocate`(批量分配设备);`POST /api/admin/devices/batch-update-realname-policy`(批量更新设备实名认证策略);`GET /api/admin/devices/by-identifier/{identifier}/gateway-slots`(查询卡槽信息);`POST /api/admin/devices/by-identifier/{identifier}/reboot`(重启设备);`POST /api/admin/devices/by-identifier/{identifier}/reset`(恢复出厂);`POST /api/admin/devices/by-identifier/{identifier}/switch-card`(切卡);`POST /api/admin/devices/by-identifier/{identifier}/switch-mode`(设置切卡模式);`PUT /api/admin/devices/by-identifier/{identifier}/wifi`(设置 WiFi);`POST /api/admin/devices/import`(批量导入设备);`POST /api/admin/devices/import/allocations`(创建CSV设备批量分配或回收任务);`GET /api/admin/devices/import/tasks`(导入任务列表);`GET /api/admin/devices/import/tasks/{id}`(导入任务详情);`POST /api/admin/devices/recall`(批量回收设备);`PATCH /api/admin/devices/series-binding`(批量设置设备的套餐系列绑定)。
|
||||
|
||||
### 资产管理
|
||||
|
||||
`GET /api/admin/assets/{identifier}/current-package`(当前生效套餐);`PATCH /api/admin/assets/{identifier}/deactivate`(停用资产);`GET /api/admin/assets/{identifier}/operation-logs`(查询平台旧资产操作日志);`GET /api/admin/assets/{identifier}/orders`(资产历史订单);`GET /api/admin/assets/{identifier}/packages`(资产套餐列表);`PATCH /api/admin/assets/{identifier}/packages/{package_usage_id}/expires-at`(修改资产套餐过期时间);`PATCH /api/admin/assets/{identifier}/packages/{package_usage_id}/used-data`(修改资产套餐已用量);`PATCH /api/admin/assets/{identifier}/polling-status`(更新资产轮询状态);`PATCH /api/admin/assets/{identifier}/realname-mode`(更新资产实名认证策略);`PATCH /api/admin/assets/{identifier}/realname-status`(手动更新卡实名状态);`GET /api/admin/assets/{identifier}/realtime-status`(资产实时状态);`POST /api/admin/assets/{identifier}/refresh`(刷新资产状态);`POST /api/admin/assets/{identifier}/start`(复机);`POST /api/admin/assets/{identifier}/stop`(停机);`GET /api/admin/assets/{identifier}/wallet`(资产钱包概况);`GET /api/admin/assets/{identifier}/wallet/transactions`(资产钱包流水列表);`GET /api/admin/assets/resolve/{identifier}`(解析资产);`GET /api/admin/expiring-assets`(查询临期资产列表);`POST /api/admin/expiring-assets/reminder-scan`(手动触发套餐临期提醒扫描)。
|
||||
|
||||
### 资产分配记录
|
||||
|
||||
`GET /api/admin/asset-allocation-records`(分配记录列表);`GET /api/admin/asset-allocation-records/{id}`(分配记录详情)。
|
||||
|
||||
### 企业卡授权
|
||||
|
||||
`POST /api/admin/enterprises/{id}/allocate-cards`(授权卡给企业);`GET /api/admin/enterprises/{id}/cards`(企业卡列表);`POST /api/admin/enterprises/{id}/recall-cards`(回收卡授权)。
|
||||
|
||||
### 企业设备授权
|
||||
|
||||
`POST /api/admin/enterprises/{id}/allocate-devices`(授权设备给企业);`GET /api/admin/enterprises/{id}/devices`(企业设备列表);`POST /api/admin/enterprises/{id}/recall-devices`(撤销设备授权)。
|
||||
@@ -1,59 +0,0 @@
|
||||
# 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` 参与过滤
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
### Requirement: 查询资产本代历史订单
|
||||
|
||||
系统 SHALL 提供接口,让管理员查询某资产本代(当前世代)的全部历史订单,支持分页。
|
||||
|
||||
**API 端点**:`GET /api/admin/assets/:identifier/orders`
|
||||
|
||||
**请求参数**:
|
||||
- `:identifier`(路径):资产标识符(ICCID 或 VirtualNo),必填
|
||||
- `page`(query):页码,默认 1
|
||||
- `page_size`(query):每页数量,默认 20,最大 100
|
||||
- `include_previous`(query):是否包含前代订单,布尔值,默认 false
|
||||
|
||||
**权限规则**:
|
||||
- 代理用户:只能查看数据权限范围内资产的订单
|
||||
- 平台/超管:可查看所有资产订单
|
||||
- 企业账号:不支持此接口(返回 403)
|
||||
|
||||
#### Scenario: 查询本代订单(默认)
|
||||
- **WHEN** 管理员请求 `GET /api/admin/assets/DEV-001/orders`
|
||||
- **THEN** 返回该资产(当前世代)的订单列表,按创建时间倒序,支持分页
|
||||
- **THEN** 响应中每条订单包含 `generation` 字段,值为资产当前世代编号
|
||||
|
||||
#### Scenario: 资产无订单
|
||||
- **WHEN** 管理员查询一个从未购买过套餐的资产
|
||||
- **THEN** 返回 `{ items: [], total: 0, page: 1 }`,不返回错误
|
||||
|
||||
#### Scenario: identifier 不存在
|
||||
- **WHEN** 请求的 identifier 无法解析到任何资产
|
||||
- **THEN** 返回 HTTP 404,错误消息"资产不存在"
|
||||
|
||||
#### Scenario: 代理查询无权限资产
|
||||
- **WHEN** 代理用户请求不属于其数据权限范围的资产订单
|
||||
- **THEN** 返回 HTTP 403,错误消息"无权限操作该资源或资源不存在"
|
||||
|
||||
### Requirement: 查询资产跨代历史订单(含前代)
|
||||
|
||||
当 `include_previous=true` 时,系统 SHALL 通过换货链追溯前代资产,返回前代订单,并在响应中区分世代来源。
|
||||
|
||||
**追溯逻辑**:
|
||||
1. 通过 `ExchangeOrder.new_asset_id` 逆向查找当前资产的换货来源
|
||||
2. 得到前代的 `old_asset_identifier`,查询该标识符的订单
|
||||
3. 递归追溯,最多向前 10 代(安全上限)
|
||||
|
||||
**响应结构(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
|
||||
}
|
||||
```
|
||||
|
||||
**每条订单项(AssetOrderItem)包含**:
|
||||
- `order_no`:订单号
|
||||
- `order_type`:订单类型(single_card / device)
|
||||
- `payment_status`:支付状态
|
||||
- `payment_status_text`:支付状态文本
|
||||
- `total_amount`:订单金额(分)
|
||||
- `payment_method`:支付方式
|
||||
- `paid_at`:支付时间(可空)
|
||||
- `generation`:订单所属资产世代
|
||||
- `items`:套餐明细列表
|
||||
- `created_at`:订单创建时间
|
||||
|
||||
#### Scenario: 查询换货后资产的全代际订单
|
||||
- **WHEN** 管理员请求 `GET /api/admin/assets/DEV-001/orders?include_previous=true`,DEV-001 是换货后的新设备(第2代),原设备为 DEV-OLD-001(第1代)
|
||||
- **THEN** `current_generation` 包含 DEV-001 本代的订单(generation=2)
|
||||
- **THEN** `previous_generations[0]` 包含 DEV-OLD-001 的订单(generation=1),附带换货单号和换货时间
|
||||
- **THEN** `truncated=false`(未超出追溯上限)
|
||||
|
||||
#### Scenario: 资产本身就是第一代(无前代)
|
||||
- **WHEN** 管理员请求带 `include_previous=true`,但该资产从未经过换货
|
||||
- **THEN** `previous_generations` 为空数组 `[]`
|
||||
- **THEN** `current_generation` 正常返回本代订单
|
||||
|
||||
#### Scenario: 换货链超过追溯上限
|
||||
- **WHEN** 换货链深度超过 10 代
|
||||
- **THEN** 追溯在第 10 代截断,`truncated=true`
|
||||
- **THEN** 已追溯到的前代数据正常返回
|
||||
|
||||
#### Scenario: 前代订单分页
|
||||
- **WHEN** 请求带 `include_previous=true`
|
||||
- **THEN** 分页参数(page/page_size)只对 `current_generation` 的订单生效
|
||||
- **THEN** 前代订单每代最多返回 20 条(不支持前代内分页)
|
||||
|
||||
#### Scenario: 无 include_previous 时响应不含前代字段
|
||||
- **WHEN** 管理员请求不带 `include_previous=true`(或传 false)
|
||||
- **THEN** 响应结构中 `previous_generations` 字段为 null 或不返回,节省带宽
|
||||
@@ -1,52 +0,0 @@
|
||||
# Capability: 全局资产标识符注册表
|
||||
|
||||
## Purpose
|
||||
|
||||
维护一张全局唯一的资产标识符注册表(`tb_asset_identifier`),在数据库层保证 IoT 卡的 ICCID/VirtualNo 与设备的 VirtualNo 跨两张表不重复,消除并发写入竞态风险,同时提供高性能的标识符→资产 ID 精确查找能力。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 全局资产标识符注册表
|
||||
|
||||
系统 SHALL 维护一张全局唯一的资产标识符注册表(`tb_asset_identifier`),在数据库层保证 IoT 卡的 ICCID/VirtualNo 与设备的 VirtualNo 跨两张表不重复,消除并发写入竞态风险。
|
||||
|
||||
注册表仅存储**全局唯一标识符**(ICCID、VirtualNo),不存储 IMEI/SN/MSISDN 等非唯一标识符。
|
||||
|
||||
**表字段**:
|
||||
- `id`:自增主键
|
||||
- `identifier`:标识符值(VARCHAR(100),UNIQUE 约束)
|
||||
- `asset_type`:资产类型(`iot_card` 或 `device`)
|
||||
- `asset_id`:对应资产的主键 ID
|
||||
- `created_at`:写入时间
|
||||
|
||||
#### Scenario: 创建设备时注册 VirtualNo
|
||||
- **WHEN** 创建新设备(通过导入或 API),VirtualNo 非空
|
||||
- **THEN** 系统在同一事务内向 `tb_asset_identifier` 写入一条记录(identifier=VirtualNo, asset_type=device, asset_id=新设备ID)
|
||||
- **THEN** 若 VirtualNo 已在注册表中存在,事务回滚,返回错误"虚拟号已被占用"
|
||||
|
||||
#### Scenario: 创建 IoT 卡时注册 ICCID
|
||||
- **WHEN** 创建新 IoT 卡(通过导入),ICCID 非空
|
||||
- **THEN** 系统在同一事务内向注册表写入(identifier=ICCID, asset_type=iot_card, asset_id=新卡ID)
|
||||
- **THEN** 若 ICCID 已在注册表中存在,事务回滚,返回错误"ICCID 已被占用"
|
||||
|
||||
#### Scenario: 创建 IoT 卡时注册 VirtualNo(如有)
|
||||
- **WHEN** 创建新 IoT 卡时 VirtualNo 非空
|
||||
- **THEN** 系统额外向注册表写入(identifier=VirtualNo, asset_type=iot_card, asset_id=新卡ID)
|
||||
- **THEN** 若 VirtualNo 已被其他设备或卡占用,事务回滚,返回错误"虚拟号已被占用"
|
||||
|
||||
#### Scenario: 并发写入同一标识符
|
||||
- **WHEN** 两个并发请求同时尝试注册相同的 VirtualNo(如 "CARD-001")
|
||||
- **THEN** 数据库 UNIQUE 约束保证只有一个写入成功;另一个收到唯一约束冲突错误,事务回滚,返回错误"虚拟号已被占用"
|
||||
|
||||
#### Scenario: 软删除资产时清理注册表
|
||||
- **WHEN** 软删除设备或 IoT 卡
|
||||
- **THEN** 系统在同一事务内删除 `tb_asset_identifier` 中对应的记录(所有 asset_id 匹配的行),允许标识符被后续资产复用
|
||||
|
||||
#### Scenario: 通过标识符精确查找资产
|
||||
- **WHEN** 系统需要根据 identifier(ICCID 或 VirtualNo)定位资产
|
||||
- **THEN** 系统查询 `SELECT * FROM tb_asset_identifier WHERE identifier = ?`,一次查询得到 asset_type 和 asset_id
|
||||
- **THEN** 再按 asset_type 查对应表(`tb_device` 或 `tb_iot_card`)取完整记录
|
||||
|
||||
#### Scenario: 查询不存在的标识符
|
||||
- **WHEN** 查询注册表中不存在的 identifier
|
||||
- **THEN** 返回空结果(not found),调用方可 fallback 到原有查询逻辑
|
||||
@@ -1,86 +0,0 @@
|
||||
# Capability: 资产操作路由标识符标准化
|
||||
|
||||
## Purpose
|
||||
|
||||
定义 B 端资产操作类接口统一使用资产标识符(ICCID 或 VirtualNo)作为路径参数的规范,废弃原有基于数据库主键 ID 的路由,并规定标识符解析的性能要求。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: B 端资产操作接口统一使用标识符路径参数
|
||||
|
||||
B 端所有资产操作类接口 SHALL 使用资产标识符(ICCID 或 VirtualNo)作为路径参数,废弃原有基于数据库主键 ID 的路由。系统内部通过注册表将标识符解析为资产实体,Handler 层无需关心 ID。
|
||||
|
||||
**标识符规则**:
|
||||
- IoT 卡:接受 ICCID 或 VirtualNo(均为全局唯一)
|
||||
- 设备:接受 VirtualNo(全局唯一)
|
||||
- 不接受 IMEI/SN/MSISDN(非唯一,仅 Resolve 的 fallback 路径支持)
|
||||
|
||||
**废弃的旧路由 → 新路由映射**:
|
||||
|
||||
| 旧路由(废弃) | 新路由 |
|
||||
|---|---|
|
||||
| `GET /api/admin/assets/:asset_type/:id/realtime-status` | `GET /api/admin/assets/:identifier/realtime-status` |
|
||||
| `POST /api/admin/assets/:asset_type/:id/refresh` | `POST /api/admin/assets/:identifier/refresh` |
|
||||
| `GET /api/admin/assets/:asset_type/:id/packages` | `GET /api/admin/assets/:identifier/packages` |
|
||||
| `GET /api/admin/assets/:asset_type/:id/current-package` | `GET /api/admin/assets/:identifier/current-package` |
|
||||
| `GET /api/admin/assets/:asset_type/:id/wallet` | `GET /api/admin/assets/:identifier/wallet` |
|
||||
| `GET /api/admin/assets/:asset_type/:id/wallet/transactions` | `GET /api/admin/assets/:identifier/wallet/transactions` |
|
||||
| `PATCH /api/admin/assets/:asset_type/:id/polling-status` | `PATCH /api/admin/assets/:identifier/polling-status` |
|
||||
| `POST /api/admin/assets/device/:device_id/stop` | `POST /api/admin/assets/:identifier/stop` |
|
||||
| `POST /api/admin/assets/device/:device_id/start` | `POST /api/admin/assets/:identifier/start` |
|
||||
| `POST /api/admin/assets/card/:iccid/stop` | `POST /api/admin/assets/:identifier/stop`(合并) |
|
||||
| `POST /api/admin/assets/card/:iccid/start` | `POST /api/admin/assets/:identifier/start`(合并) |
|
||||
| `DELETE /api/admin/devices/:id` | `DELETE /api/admin/devices/:virtual_no` |
|
||||
| `GET /api/admin/devices/:id/cards` | `GET /api/admin/devices/:virtual_no/cards` |
|
||||
| `POST /api/admin/devices/:id/cards` | `POST /api/admin/devices/:virtual_no/cards` |
|
||||
| `DELETE /api/admin/devices/:id/cards/:cardId` | `DELETE /api/admin/devices/:virtual_no/cards/:iccid` |
|
||||
| `PATCH /api/admin/devices/:id/deactivate` | `PATCH /api/admin/assets/:identifier/deactivate` |
|
||||
| `PATCH /api/admin/iot-cards/:id/deactivate` | `PATCH /api/admin/assets/:identifier/deactivate`(合并) |
|
||||
|
||||
#### Scenario: 通过 ICCID 操作 IoT 卡
|
||||
- **WHEN** 管理员请求 `GET /api/admin/assets/898600XXXXXXXX/packages`
|
||||
- **THEN** 系统解析 ICCID,找到对应 IoT 卡,返回该卡的套餐列表
|
||||
|
||||
#### Scenario: 通过 VirtualNo 操作设备
|
||||
- **WHEN** 管理员请求 `POST /api/admin/assets/DEV-001/stop`
|
||||
- **THEN** 系统解析 VirtualNo,找到对应设备,执行批量停机(停该设备下所有已实名卡)
|
||||
|
||||
#### Scenario: 通过 VirtualNo 操作绑定了设备的 IoT 卡(停机)
|
||||
- **WHEN** 管理员请求 `POST /api/admin/assets/CARD-001/stop`,CARD-001 是 IoT 卡的 VirtualNo
|
||||
- **THEN** 系统解析 VirtualNo,找到 IoT 卡,执行单卡停机
|
||||
|
||||
#### Scenario: stop/start 接口对卡和设备行为差异
|
||||
- **WHEN** identifier 解析为 IoT 卡时调用 stop
|
||||
- **THEN** 执行单卡停机
|
||||
- **WHEN** identifier 解析为设备时调用 stop
|
||||
- **THEN** 执行设备停机(批量停机该设备下所有已实名卡)
|
||||
|
||||
#### Scenario: 标识符不存在
|
||||
- **WHEN** 管理员请求的 `:identifier` 在注册表和 fallback 查询中均未找到对应资产
|
||||
- **THEN** 返回 HTTP 404,错误消息"资产不存在"
|
||||
|
||||
#### Scenario: 无权限操作该资产
|
||||
- **WHEN** 代理用户请求的 identifier 对应的资产不属于该代理的数据权限范围
|
||||
- **THEN** 返回 HTTP 403,错误消息"无权限操作该资源或资源不存在"
|
||||
|
||||
#### Scenario: 设备绑卡管理使用设备 VirtualNo
|
||||
- **WHEN** 管理员请求 `GET /api/admin/devices/DEV-001/cards`
|
||||
- **THEN** 系统通过 VirtualNo 找到设备,返回该设备绑定的卡列表
|
||||
|
||||
#### Scenario: 设备解绑卡使用 ICCID
|
||||
- **WHEN** 管理员请求 `DELETE /api/admin/devices/DEV-001/cards/898600XXXXXXXX`
|
||||
- **THEN** 系统通过 VirtualNo 找到设备,通过 ICCID 找到卡,执行解绑
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 新路由下标识符的解析性能
|
||||
|
||||
资产操作接口中标识符解析 SHALL 优先走注册表(单次精确查询),保证解析延迟不超过 10ms(在正常数据库负载下)。
|
||||
|
||||
#### Scenario: 注册表命中路径
|
||||
- **WHEN** 请求携带的 identifier 存在于 `tb_asset_identifier`
|
||||
- **THEN** 系统单次查询注册表得到 asset_type 和 asset_id,无需扫描 tb_device 或 tb_iot_card
|
||||
|
||||
#### Scenario: 注册表未命中(fallback)
|
||||
- **WHEN** 请求携带的 identifier 不在注册表(如 IMEI 或旧数据)
|
||||
- **THEN** 系统 fallback 到原有多字段 OR 查询,同样能定位资产(性能稍低,为次要路径)
|
||||
@@ -1,45 +0,0 @@
|
||||
# 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`
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
# asset-queries Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
提供基于已知资产 ID 的轻量查询接口,包括实时状态查询、手动刷新、套餐历史列表和当前主套餐详情。供前端在 resolve 之后进行快速轮询和详情展示。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 轻量实时状态查询
|
||||
|
||||
系统 SHALL 提供基于持久化数据的轻量状态查询接口,供前端在已知资产 ID 后进行快速轮询。
|
||||
|
||||
**API 端点**: `GET /api/admin/assets/:asset_type/:id/realtime-status`
|
||||
|
||||
**约束**:
|
||||
- `:asset_type` 取值为 `device` 或 `card`
|
||||
- 此接口**不调用网关**,仅读取 DB/Redis 中持久化的最新数据
|
||||
- 不包含套餐流量计算(与 resolve 的区别)
|
||||
- "实时性"依赖轮询系统定期刷新(实名状态约 5 分钟,流量约 10 分钟)
|
||||
|
||||
**card 类型响应字段**:
|
||||
- `network_status`: 网络状态(0-停机 1-开机)
|
||||
- `real_name_status`: 实名状态(0-未实名 1-已实名)
|
||||
- `current_month_usage_mb`: 本月已用流量(持久化缓存值)
|
||||
- `last_sync_at`: 最后与 Gateway 同步时间
|
||||
|
||||
**device 类型响应字段**:
|
||||
- `device_protect_status`: 保护期状态(`"none"` / `"stop"` / `"start"`)
|
||||
- `cards`: 所有绑定卡的状态列表(同 DeviceCardInfo 结构)
|
||||
|
||||
#### Scenario: 查询单卡实时状态
|
||||
|
||||
- **WHEN** 管理员调用 `GET /api/admin/assets/card/123/realtime-status`
|
||||
- **THEN** 系统返回该卡的 network_status、real_name_status、current_month_usage_mb、last_sync_at
|
||||
|
||||
#### Scenario: 查询设备实时状态
|
||||
|
||||
- **WHEN** 管理员调用 `GET /api/admin/assets/device/456/realtime-status`
|
||||
- **THEN** 系统返回设备的保护期状态及所有绑定卡的当前状态列表
|
||||
|
||||
#### Scenario: asset_type 参数非法
|
||||
|
||||
- **WHEN** 管理员调用 `GET /api/admin/assets/unknown-type/123/realtime-status`
|
||||
- **THEN** 系统返回 HTTP 400 参数错误
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 手动刷新接口
|
||||
|
||||
系统 SHALL 提供手动触发网关同步的接口,用于客服主动刷新资产最新状态。
|
||||
|
||||
**API 端点**: `POST /api/admin/assets/:asset_type/:id/refresh`
|
||||
|
||||
**行为规则**:
|
||||
- card 类型:直接调用 `RefreshCardDataFromGateway(iccid)` 同步网络状态、实名状态、本月流量、最后同步时间
|
||||
- device 类型:对该设备所有绑定卡遍历调用 `RefreshCardDataFromGateway`
|
||||
|
||||
**设备类型频率限制**:
|
||||
- 使用 Redis Key `RedisDeviceRefreshCooldownKey(deviceID)` 限频
|
||||
- 同一设备 30 秒冷却期内不允许重复触发
|
||||
- 冷却期内调用返回 HTTP 429
|
||||
|
||||
**响应**:
|
||||
- 刷新完成后返回刷新后的最新状态(与 realtime-status 响应结构相同)
|
||||
|
||||
#### Scenario: 刷新单卡状态
|
||||
|
||||
- **WHEN** 客服调用 `POST /api/admin/assets/card/123/refresh`
|
||||
- **THEN** 系统调用 RefreshCardDataFromGateway,更新 DB 中的卡状态字段,返回刷新后的最新状态
|
||||
|
||||
#### Scenario: 刷新设备状态(首次)
|
||||
|
||||
- **WHEN** 管理员调用 `POST /api/admin/assets/device/456/refresh`,该设备有 3 张绑定卡
|
||||
- **THEN** 系统依次刷新 3 张卡,设置 30 秒冷却期,返回最新状态
|
||||
|
||||
#### Scenario: 设备刷新冷却期内重复触发
|
||||
|
||||
- **WHEN** 管理员在 30 秒冷却期内第二次调用 `POST /api/admin/assets/device/456/refresh`
|
||||
- **THEN** 系统返回 HTTP 429,提示"刷新过于频繁,请稍后再试"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 套餐历史列表查询
|
||||
|
||||
系统 SHALL 提供资产的全量套餐记录查询接口,包含历史和当前生效套餐。
|
||||
|
||||
**API 端点**: `GET /api/admin/assets/:asset_type/:id/packages`
|
||||
|
||||
**排序**: 按 `created_at` 倒序(最新套餐在前)
|
||||
|
||||
**分页**: 不分页,全量返回
|
||||
|
||||
**范围**: 包含所有状态(含 status=4 已失效的历史套餐)
|
||||
|
||||
**按 asset_type 区分查询**:
|
||||
- card:查询 `PackageUsage.iot_card_id = :id`
|
||||
- device:查询 `PackageUsage.device_id = :id`
|
||||
|
||||
**每条记录响应字段**:
|
||||
- `package_usage_id`: 套餐使用记录 ID
|
||||
- `package_name`: 套餐名称
|
||||
- `package_type`: 套餐类型(formal/addon)
|
||||
- `master_usage_id`: 主套餐 ID(加油包时有值,主套餐时为 null)
|
||||
- `real_data_mb`: 真总流量(MB)
|
||||
- `virtual_data_mb`: 虚总流量/停机阈值(MB)
|
||||
- `package_used_mb`: 展示已使用流量(经虚流量换算)
|
||||
- `package_remain_mb`: 展示剩余流量
|
||||
- `activated_at`: 生效时间
|
||||
- `expires_at`: 过期时间
|
||||
- `status`: 套餐状态(0-待生效 1-生效中 2-已用完 3-已过期 4-已失效)
|
||||
- `paid_amount`: 购买时实付金额(分),线下支付或无订单分配时为 null
|
||||
|
||||
#### Scenario: 查询卡的套餐历史
|
||||
|
||||
- **WHEN** 管理员调用 `GET /api/admin/assets/card/123/packages`,该卡有 3 条套餐记录(含 1 条已失效)
|
||||
- **THEN** 系统返回全部 3 条记录,按创建时间倒序排列,每条记录含 `paid_amount` 字段
|
||||
|
||||
#### Scenario: 查询设备的套餐历史
|
||||
|
||||
- **WHEN** 管理员调用 `GET /api/admin/assets/device/456/packages`
|
||||
- **THEN** 系统返回该设备 device_id 下的所有套餐记录,含 `paid_amount` 字段
|
||||
|
||||
#### Scenario: 资产无套餐记录
|
||||
|
||||
- **WHEN** 管理员查询一张从未购买过套餐的卡
|
||||
- **THEN** 系统返回空数组,不报错
|
||||
|
||||
#### Scenario: 线下支付套餐的 paid_amount 为 null
|
||||
|
||||
- **WHEN** 管理员查询一张通过线下支付购买套餐的卡
|
||||
- **THEN** 对应套餐记录的 `paid_amount` 字段缺省(omitempty),不展示
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 当前主套餐详情查询
|
||||
|
||||
系统 SHALL 提供查询资产当前生效主套餐的接口,用于展示套餐详细信息。
|
||||
|
||||
**API 端点**: `GET /api/admin/assets/:asset_type/:id/current-package`
|
||||
|
||||
**查询条件**: `status = 1(生效中)AND master_usage_id IS NULL`
|
||||
|
||||
**多套餐同时生效时**:只返回主套餐(master_usage_id IS NULL),不返回加油包
|
||||
|
||||
**响应字段**:
|
||||
- 完整套餐信息(同套餐历史列表中的单条记录字段)
|
||||
- `paid_amount`: 购买时实付金额(分),线下支付或无订单分配时为 null
|
||||
- 当无生效主套餐时,返回 HTTP 404
|
||||
|
||||
#### Scenario: 返回当前主套餐(含实付金额)
|
||||
|
||||
- **WHEN** 管理员调用 `GET /api/admin/assets/card/123/current-package`,该卡有 1 个生效主套餐和 1 个加油包,主套餐 `paid_amount = 9900`
|
||||
- **THEN** 系统只返回主套餐信息,响应中包含 `paid_amount: 9900`,不包含加油包
|
||||
|
||||
#### Scenario: 无当前生效主套餐
|
||||
|
||||
- **WHEN** 管理员查询没有生效中主套餐的资产
|
||||
- **THEN** 系统返回 HTTP 404
|
||||
|
||||
---
|
||||
|
||||
### Requirement: RefreshCardDataFromGateway 完整同步
|
||||
|
||||
系统 SHALL 提供从 Gateway 完整同步卡数据的方法,替代原 `SyncCardStatusFromGateway`(仅为示例实现)。
|
||||
|
||||
**方法签名**: `RefreshCardDataFromGateway(ctx context.Context, iccid string) error`
|
||||
|
||||
**同步字段**:
|
||||
- `network_status`: 网络状态(从网关卡状态映射)
|
||||
- `real_name_status`: 实名状态(从网关实名接口获取)
|
||||
- `current_month_usage_mb`: 本月已用流量(从网关流量接口获取)
|
||||
- `last_sync_time`: 更新为当前时间
|
||||
|
||||
**错误处理**: 网关调用失败时记录 Error 日志并返回错误,不更新 DB
|
||||
|
||||
#### Scenario: 完整同步卡数据
|
||||
|
||||
- **WHEN** 调用 `RefreshCardDataFromGateway(ctx, "89860123456789012345")`
|
||||
- **THEN** 系统调用网关接口,将 network_status、real_name_status、current_month_usage_mb、last_sync_time 写回 DB
|
||||
|
||||
---
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 资产查询错误处理
|
||||
资产查询 Service 在获取绑定卡信息时,数据库查询错误 SHALL 被记录到日志而非被静默忽略。查询失败 SHALL NOT 中断主查询流程,但 SHALL 记录 Warn 级别日志,包含失败的卡 ID 列表和错误详情。
|
||||
|
||||
#### Scenario: 绑定卡查询失败时记录日志
|
||||
- **WHEN** `iotCardStore.GetByIDs()` 返回错误
|
||||
- **THEN** 系统 SHALL 记录 Warn 日志(含 card_ids 和 error),继续返回已有数据,不返回错误给调用方
|
||||
@@ -1,171 +0,0 @@
|
||||
# Capability: 资产实名策略
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 资产实名策略字段定义
|
||||
|
||||
系统 SHALL 在 `IotCard` 和 `Device` 模型上各新增 `realname_policy` 字段(VARCHAR(20),NOT NULL,DEFAULT 'none'),用于控制该资产的实名认证要求。
|
||||
|
||||
**枚举值**:
|
||||
- `none`:无需实名,充值/购买/实名链接均不受限
|
||||
- `before_order`:先实名后充值/购买,充值/购买前若未实名则拦截并返回 `CodeNeedRealname`
|
||||
- `after_order`:先充值/购买后实名,充值/购买放行;实名链接在无有效充值/订单记录前拦截
|
||||
|
||||
**常量定义**(`pkg/constants/iot.go` 或 `pkg/constants/realname.go`):
|
||||
```go
|
||||
const (
|
||||
RealnmePolicyNone = "none" // 无需实名
|
||||
RealnmePolicyBeforeOrder = "before_order" // 先实名后充值/购买
|
||||
RealnmePolicyAfterOrder = "after_order" // 先充值/购买后实名
|
||||
)
|
||||
```
|
||||
|
||||
#### Scenario: 默认值为 none
|
||||
- **WHEN** 新建 IotCard 或 Device 时未传入 realname_policy
|
||||
- **THEN** 系统自动填充 `realname_policy = "none"`
|
||||
|
||||
#### Scenario: 存量数据迁移后行为不变
|
||||
- **WHEN** 数据库迁移执行后,存量 IotCard 和 Device 的 realname_policy 均为 "none"
|
||||
- **THEN** 充值、购买、实名链接行为与迁移前完全相同(均放行)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 生效策略优先级(设备卡 vs 单卡)
|
||||
|
||||
系统 SHALL 按以下规则确定一张卡的生效实名策略:
|
||||
|
||||
- **单卡**(该卡未绑定任何设备):使用 `IotCard.realname_policy`
|
||||
- **设备卡**(该卡已绑定设备):使用 `Device.realname_policy`,忽略 `IotCard.realname_policy`
|
||||
|
||||
此规则 SHALL 封装为 Service 层公共方法 `GetEffectiveRealnamePolicy`,所有需要策略判断的场景均调用该方法,不得在多处重复实现。
|
||||
|
||||
#### Scenario: 设备卡使用设备策略
|
||||
- **WHEN** IotCard.realname_policy="none" 且该卡绑定了 Device.realname_policy="before_order"
|
||||
- **THEN** 生效策略为 "before_order"(设备策略覆盖卡策略)
|
||||
|
||||
#### Scenario: 单卡使用卡策略
|
||||
- **WHEN** IotCard.realname_policy="before_order" 且该卡未绑定任何设备
|
||||
- **THEN** 生效策略为 "before_order"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: C 端充值接口实名策略拦截
|
||||
|
||||
系统 SHALL 在 C 端充值预检接口(C3 `GET /api/c/v1/wallet/recharge-check`)和充值下单接口(C4 `POST /api/c/v1/wallet/recharge`)中,按生效实名策略执行以下逻辑:
|
||||
|
||||
- `before_order` + `real_name_status=0`(未实名):MUST 拦截,返回 `CodeNeedRealname`(错误码 1187)
|
||||
- `before_order` + `real_name_status=1`(已实名):放行
|
||||
- `after_order`:放行(不检查实名状态)
|
||||
- `none`:放行
|
||||
|
||||
#### Scenario: before_order 模式未实名时充值被拦截
|
||||
- **WHEN** 资产 realname_policy="before_order" 且 real_name_status=0,用户调用充值下单接口
|
||||
- **THEN** 系统返回错误码 1187(CodeNeedRealname)
|
||||
|
||||
#### Scenario: before_order 模式已实名时充值放行
|
||||
- **WHEN** 资产 realname_policy="before_order" 且 real_name_status=1,用户调用充值下单接口
|
||||
- **THEN** 系统正常创建充值订单
|
||||
|
||||
#### Scenario: after_order 模式充值放行
|
||||
- **WHEN** 资产 realname_policy="after_order" 且 real_name_status=0,用户调用充值下单接口
|
||||
- **THEN** 系统正常创建充值订单,不检查实名状态
|
||||
|
||||
---
|
||||
|
||||
### Requirement: C 端购买套餐/订单接口实名策略拦截
|
||||
|
||||
系统 SHALL 在 C 端创建订单接口(D1 `POST /api/c/v1/orders/create`)和支付接口(D4 `POST /api/c/v1/orders/:id/pay`)中,按生效实名策略执行与充值相同的拦截规则。D1 中原 `REALNAME-03` 注释代码 SHALL 被删除,由新策略逻辑替代。
|
||||
|
||||
#### Scenario: before_order 模式未实名时购买套餐被拦截
|
||||
- **WHEN** 资产 realname_policy="before_order" 且 real_name_status=0,用户调用创建订单接口
|
||||
- **THEN** 系统返回错误码 1187(CodeNeedRealname),订单不创建
|
||||
|
||||
#### Scenario: none 模式购买套餐放行
|
||||
- **WHEN** 资产 realname_policy="none",用户调用创建订单接口
|
||||
- **THEN** 系统正常创建订单,不检查实名状态
|
||||
|
||||
---
|
||||
|
||||
### Requirement: C 端实名链接接口策略拦截
|
||||
|
||||
系统 SHALL 在 C 端实名链接接口(E1 `GET /api/c/v1/realname/link`)中,按生效实名策略执行以下逻辑:
|
||||
|
||||
- `none`:放行,正常返回实名链接(用户可自愿实名)
|
||||
- `before_order`:放行,正常返回实名链接(引导用户先实名再充值)
|
||||
- `after_order`:检查该资产当前 generation 内是否存在有效充值(`status=2`)或已支付订单(`payment_status=2`)
|
||||
- 有记录 → 放行
|
||||
- 无记录 → 拦截,返回错误码(新错误码 `CodeRealnameNotAvailable`),消息为"请先完成充值或购买套餐后再进行实名认证"
|
||||
|
||||
#### Scenario: after_order 模式有充值记录时实名链接放行
|
||||
- **WHEN** 资产 realname_policy="after_order" 且当前 generation 内存在 status=2 的充值记录
|
||||
- **THEN** 系统正常返回实名链接
|
||||
|
||||
#### Scenario: after_order 模式无充值/订单记录时实名链接被拦截
|
||||
- **WHEN** 资产 realname_policy="after_order" 且当前 generation 内无任何有效充值或已支付订单
|
||||
- **THEN** 系统返回错误,消息为"请先完成充值或购买套餐后再进行实名认证"
|
||||
|
||||
#### Scenario: before_order 模式正常返回实名链接
|
||||
- **WHEN** 资产 realname_policy="before_order"
|
||||
- **THEN** 系统正常返回实名链接(引导用户完成实名)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 后台管理更新资产实名策略接口
|
||||
|
||||
系统 SHALL 提供 `PATCH /api/admin/assets/:identifier/realname-mode`,仅限后台管理端认证用户访问。接口通过 `assetService.Resolve()` 将标识符(ICCID/虚拟号)解析为具体资产,按 asset_type 分别更新 `IotCard.realname_policy` 或 `Device.realname_policy`。
|
||||
|
||||
**请求体**:
|
||||
```json
|
||||
{ "realname_policy": "none | before_order | after_order" }
|
||||
```
|
||||
|
||||
**响应体**:
|
||||
```json
|
||||
{ "asset_type": "card | device", "asset_id": 123, "realname_policy": "before_order" }
|
||||
```
|
||||
|
||||
#### Scenario: 通过 ICCID 更新单卡策略
|
||||
- **WHEN** 管理员传入 identifier=ICCID,realname_policy="before_order"
|
||||
- **THEN** 系统更新对应 IotCard 的 realname_policy 为 "before_order",返回 asset_type="card"
|
||||
|
||||
#### Scenario: 通过设备号更新设备策略
|
||||
- **WHEN** 管理员传入 identifier=设备虚拟号,realname_policy="after_order"
|
||||
- **THEN** 系统更新对应 Device 的 realname_policy 为 "after_order",返回 asset_type="device"
|
||||
|
||||
#### Scenario: 传入无效枚举值被拒绝
|
||||
- **WHEN** 管理员传入 realname_policy="invalid_value"
|
||||
- **THEN** 系统返回参数校验错误,提示实名策略值无效
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 所有查询接口返回 realname_policy 字段
|
||||
|
||||
以下所有 DTO SHALL 新增 `realname_policy` 字段(string)及对应的 description 标签:
|
||||
|
||||
**后台管理端 DTO**:
|
||||
- `StandaloneIotCardResponse`(IoT 卡详情/列表)
|
||||
- `DeviceResponse`(设备详情/列表)
|
||||
- `AssetResolveResponse`(统一资产解析)
|
||||
- `AssetRealtimeStatusResponse`(资产实时状态)
|
||||
- `DeviceCardBindingResponse`(设备绑卡记录)
|
||||
- `BoundCardInfo`(asset_dto.go 中的通用子结构)
|
||||
- `ImportTaskResponse`(iot 卡导入任务)
|
||||
- `DeviceImportTaskResponse`(设备导入任务)
|
||||
|
||||
**C 端 DTO**:
|
||||
- `AssetInfoResponse`(B1 资产信息)
|
||||
- `BoundCardInfo`(client_asset_dto.go 中的 C 端子结构)
|
||||
- `DeviceCardItem`(F1 设备卡列表项)
|
||||
|
||||
**description 标签统一格式**:
|
||||
```go
|
||||
RealnamePolicy string `json:"realname_policy" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"`
|
||||
```
|
||||
|
||||
#### Scenario: 资产详情接口返回 realname_policy
|
||||
- **WHEN** 后台管理员查询 IoT 卡详情或资产解析
|
||||
- **THEN** 响应中包含 realname_policy 字段
|
||||
|
||||
#### Scenario: C 端资产信息接口返回 realname_policy
|
||||
- **WHEN** C 端用户调用 GET /api/c/v1/asset/info
|
||||
- **THEN** 响应中包含 realname_policy 字段(前端可据此展示提示)
|
||||
@@ -1,150 +0,0 @@
|
||||
# asset-recharge-adaptation Specification
|
||||
|
||||
## Purpose
|
||||
定义资产充值(IoT 卡/设备钱包充值)的完整规范:支付配置关联、充值记录表结构变更、回调验签流程及钱包常量从 Card 前缀统一重命名为 Asset 前缀。
|
||||
## Requirements
|
||||
### Requirement: 资产充值关联支付配置
|
||||
|
||||
系统 SHALL 在创建资产充值订单时记录当前生效的支付配置 ID,用于回调处理时加载正确的配置验签。
|
||||
|
||||
#### Scenario: 创建充值订单时记录支付配置 ID
|
||||
|
||||
- **WHEN** 个人客户创建资产充值订单(IoT 卡钱包或设备钱包充值)
|
||||
|
||||
```
|
||||
POST /api/h5/wallets/recharge
|
||||
Authorization: Bearer {token}
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**请求体(现有接口,字段不变)**
|
||||
|
||||
```json
|
||||
{
|
||||
"resource_type": "iot_card",
|
||||
"resource_id": 101,
|
||||
"amount": 10000,
|
||||
"payment_method": "wechat"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `resource_type` | string | ✅ | 资源类型:`iot_card` / `device` |
|
||||
| `resource_id` | uint | ✅ | 资源 ID(卡 ID 或设备 ID) |
|
||||
| `amount` | int64 | ✅ | 充值金额(分),范围 100~10000000(1 元~10 万元) |
|
||||
| `payment_method` | string | ✅ | 支付方式:`wechat` / `alipay`(支付宝保留但本次不改造) |
|
||||
|
||||
- **THEN** 系统查询当前生效的微信参数配置
|
||||
- **THEN** 将 `payment_config_id` 写入充值记录
|
||||
|
||||
**成功响应 `200 OK`(新增 `payment_config_id` 字段)**
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"id": 1,
|
||||
"recharge_no": "CRCH20260316100000654321",
|
||||
"user_id": 100,
|
||||
"wallet_id": 50,
|
||||
"amount": 10000,
|
||||
"payment_method": "wechat",
|
||||
"payment_config_id": 1,
|
||||
"status": 1,
|
||||
"status_text": "待支付",
|
||||
"created_at": "2026-03-16T10:00:00+08:00",
|
||||
"updated_at": "2026-03-16T10:00:00+08:00"
|
||||
},
|
||||
"msg": "success",
|
||||
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
#### Scenario: 无生效配置时拒绝第三方充值
|
||||
|
||||
- **WHEN** 个人客户创建充值订单(wechat/alipay),但当前无生效的微信参数配置
|
||||
- **THEN** 系统返回错误
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1175,
|
||||
"data": null,
|
||||
"msg": "暂无可用的第三方支付渠道",
|
||||
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 资产充值表结构变更
|
||||
|
||||
系统 MUST 在 `tb_asset_recharge_record` 新增以下字段,用于关联支付配置。
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `payment_config_id` | bigint | ❌ | 创建充值订单时使用的微信参数配置 ID(支付宝支付时为 NULL) |
|
||||
|
||||
#### Scenario: 新建充值记录含 payment_config_id 字段
|
||||
- **WHEN** 个人客户创建微信充值订单
|
||||
- **THEN** 系统 MUST 将当前生效的微信参数配置 ID 写入 `payment_config_id` 字段
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 资产充值回调按配置验签
|
||||
|
||||
系统 MUST 在处理资产充值支付回调时,通过 `payment_config_id` 加载对应配置并使用该配置验签。
|
||||
|
||||
#### Scenario: 收到充值回调按配置验签
|
||||
- **WHEN** 收到支付回调(微信或富友),订单号前缀为 `CRCH`
|
||||
- **THEN** 系统 MUST 查询 `tb_asset_recharge_record`,通过 `payment_config_id` 加载对应配置
|
||||
- **THEN** 系统 MUST 使用该配置的凭证验签
|
||||
- **THEN** 验签通过后调用 `rechargeService.HandlePaymentCallback()`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 常量重命名(Card → Asset)
|
||||
|
||||
系统 MUST 将 `pkg/constants/wallet.go` 中以下常量从 `Card` 前缀重命名为 `Asset` 前缀,旧常量保留为废弃别名。
|
||||
|
||||
| 旧名称 | 新名称 |
|
||||
|--------|--------|
|
||||
| `CardWalletResourceTypeIotCard` | `AssetWalletResourceTypeIotCard` |
|
||||
| `CardWalletResourceTypeDevice` | `AssetWalletResourceTypeDevice` |
|
||||
| `CardWalletStatusNormal` | `AssetWalletStatusNormal` |
|
||||
| `CardWalletStatusFrozen` | `AssetWalletStatusFrozen` |
|
||||
| `CardWalletStatusClosed` | `AssetWalletStatusClosed` |
|
||||
| `CardTransactionTypeRecharge` | `AssetTransactionTypeRecharge` |
|
||||
| `CardTransactionTypeDeduct` | `AssetTransactionTypeDeduct` |
|
||||
| `CardTransactionTypeRefund` | `AssetTransactionTypeRefund` |
|
||||
| `CardRechargeOrderPrefix` | `AssetRechargeOrderPrefix` |
|
||||
| `CardRechargeMinAmount` | `AssetRechargeMinAmount` |
|
||||
| `CardRechargeMaxAmount` | `AssetRechargeMaxAmount` |
|
||||
|
||||
#### 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` 作为关联上下文
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
# asset-resolve Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
提供统一的资产解析入口,通过任意标识符(虚拟号/ICCID/IMEI/SN/MSISDN)定位卡或设备,并返回该资产的中等聚合信息,包含套餐流量、保护期状态和绑定关系。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 统一资产解析入口
|
||||
|
||||
系统 SHALL 提供统一的资产查找接口,通过任意标识符定位卡或设备,并返回该资产的中等聚合信息。
|
||||
|
||||
**API 端点**: `GET /api/admin/assets/resolve/:identifier`
|
||||
|
||||
**查找顺序(更新后)**:
|
||||
1. **主路径**:查 `tb_asset_identifier` WHERE identifier = ? → 命中则得到 asset_type + asset_id,直接查对应表取完整记录
|
||||
2. **Fallback 路径**(注册表未命中时):
|
||||
- 先查 `tb_device`(匹配 `virtual_no = ? OR imei = ? OR sn = ?`)
|
||||
- 未命中则查 `tb_iot_card`(匹配 `virtual_no = ? OR iccid = ? OR msisdn = ?`)
|
||||
3. 两条路径均未命中 → 返回 HTTP 404
|
||||
4. 找到后应用数据权限过滤,无权限 → 返回 HTTP 403
|
||||
|
||||
**数据权限规则**:
|
||||
- 代理用户:只能查看 `shop_id` 在自己及下级店铺范围内的资产
|
||||
- 平台用户(SuperAdmin/Platform):可查看所有资产
|
||||
- 企业账号:暂不支持此接口,调用时返回 HTTP 403
|
||||
|
||||
**响应结构(AssetResolveResponse)**:
|
||||
|
||||
*通用字段(device 和 card 均有)*:
|
||||
- `asset_type`: 资产类型(`"device"` 或 `"card"`)
|
||||
- `asset_id`: 资产主键 ID
|
||||
- `identifier`: 本次查询所用的标识符(原样回传)
|
||||
- `virtual_no`: 虚拟号(设备/卡均使用此字段)
|
||||
- `status`: 资产状态(整型)
|
||||
- `asset_status`: 业务状态(1-在库 2-已销售 3-已换货 4-已停用)
|
||||
- `generation`: 资产世代编号
|
||||
- `batch_no`: 批次号
|
||||
- `shop_id`: 所属店铺 ID(平台库存时为空)
|
||||
- `shop_name`: 所属店铺名称
|
||||
- `series_id`: 套餐系列 ID(未绑定时为空)
|
||||
- `series_name`: 套餐系列名称
|
||||
- `first_commission_paid`: 一次性佣金是否已发放
|
||||
- `accumulated_recharge`: 累计充值金额(分)
|
||||
- `activated_at`: 激活时间(未激活时为空)
|
||||
- `created_at`: 创建时间
|
||||
- `updated_at`: 更新时间
|
||||
- `real_name_at`(`*time.Time`,可为 null):最近一次完成实名的时间
|
||||
- **card 类型**:直接取 `IotCard.first_realname_at`(未实名过则为 null)
|
||||
- **device 类型**:当前所有绑定卡 `first_realname_at` 中的最小值(无已实名卡则为 null)
|
||||
|
||||
*状态与套餐字段(device 和 card 均有)*:
|
||||
- `real_name_status`: 实名状态(整型)
|
||||
- `current_package`: 当前套餐名称(无套餐时返回空字符串)
|
||||
- `package_total_mb`: 真总流量,即 RealDataMB(无套餐时返回 0)
|
||||
- `package_virtual_mb`: 虚总流量/停机阈值,即 VirtualDataMB(无套餐时返回 0)
|
||||
- `package_used_mb`: 客户端展示已使用流量(经虚流量换算,见流量计算规则)
|
||||
- `package_remain_mb`: 客户端展示剩余流量
|
||||
- `device_protect_status`: 保护期状态(`"none"` / `"stop"` / `"start"`);card 类型时若绑定的设备有保护期也返回该设备的保护期状态
|
||||
|
||||
*绑定关系字段*:
|
||||
- `iccid`: 仅 card 类型时有值,供前端调用停复机接口使用
|
||||
- `bound_device_id`: 仅 card 类型且卡绑定了设备时有值
|
||||
- `bound_device_no`: 绑定设备的虚拟号
|
||||
- `bound_device_name`: 绑定设备的名称
|
||||
- `bound_card_count`: 仅 device 类型时有值,绑定卡的总数量
|
||||
- `cards`: 仅 device 类型时有值,所有绑定卡列表(含未实名、已停用)
|
||||
|
||||
*设备专属档案字段(asset_type=device 时有值,card 类型时为空/零值)*:
|
||||
- `device_name`: 设备名称
|
||||
- `imei`: IMEI
|
||||
- `sn`: 序列号
|
||||
- `device_model`: 设备型号
|
||||
- `device_type`: 设备类型
|
||||
- `max_sim_slots`: 最大插槽数
|
||||
- `manufacturer`: 制造商
|
||||
|
||||
*卡专属档案字段(asset_type=card 时有值,device 类型时为空/零值)*:
|
||||
- `carrier_id`: 运营商 ID
|
||||
- `carrier_type`: 运营商类型(CMCC/CUCC/CTCC/CBN)
|
||||
- `carrier_name`: 运营商名称
|
||||
- `msisdn`: 卡接入号
|
||||
- `imsi`: IMSI
|
||||
- `card_category`: 卡业务类型(normal/industry)
|
||||
- `supplier`: 供应商
|
||||
- `activation_status`: 激活状态(0-未激活 1-已激活)
|
||||
- `enable_polling`: 是否参与轮询
|
||||
|
||||
**DeviceCardInfo 结构**:
|
||||
- `iot_card_id`: 卡 ID
|
||||
- `iccid`: ICCID
|
||||
- `virtual_no`: 卡的虚拟号
|
||||
- `real_name_status`: 实名状态
|
||||
- `network_status`: 网络状态
|
||||
- `current_month_usage_mb`: 本月已用流量(来自持久化缓存字段)
|
||||
- `last_sync_at`: 最后与 Gateway 同步时间
|
||||
- `real_name_at`(`*time.Time`,可为 null):该卡最近一次完成实名的时间,取 `IotCard.first_realname_at`
|
||||
|
||||
**流量展示计算规则**:
|
||||
- `package_used_mb = current_month_usage_mb × virtual_ratio`
|
||||
- `package_remain_mb = package_total_mb - package_used_mb`
|
||||
- 当 `enable_virtual_data = false` 时,`virtual_ratio = 1.0`(无换算)
|
||||
- 设备级套餐:`current_month_usage_mb` 为所有绑定卡本月用量之和
|
||||
|
||||
**特殊情况处理**:
|
||||
- 卡绑定的设备已被软删除:视为独立卡,不填充绑定信息
|
||||
- `cards` 列表包含所有状态的绑定卡,不过滤未实名或已停用的卡
|
||||
|
||||
#### Scenario: 通过 ICCID 找到卡
|
||||
|
||||
- **WHEN** 管理员调用 `GET /api/admin/assets/resolve/89860123456789012345`,ICCID 匹配到一张独立卡
|
||||
- **THEN** 系统返回 `asset_type="card"`,包含该卡的虚拟号、状态、套餐流量信息,`bound_device_id` 为空
|
||||
|
||||
#### Scenario: 通过虚拟号找到设备
|
||||
|
||||
- **WHEN** 管理员调用 `GET /api/admin/assets/resolve/GPS-001`,设备表中 `virtual_no = "GPS-001"` 存在
|
||||
- **THEN** 系统返回 `asset_type="device"`,包含该设备的绑定卡列表(DeviceCardInfo 数组),`bound_card_count` 为绑定卡总数
|
||||
|
||||
#### Scenario: 标识符同时命中设备和卡(设备优先)
|
||||
|
||||
- **WHEN** `GPS-001` 在 device 表和 iot_card 表均有匹配(virtual_no 相同)
|
||||
- **THEN** 系统返回设备信息(device 优先),不返回卡信息
|
||||
|
||||
#### Scenario: 标识符未命中任何资产
|
||||
|
||||
- **WHEN** 管理员查询不存在的标识符 `UNKNOWN-999`
|
||||
- **THEN** 系统返回 HTTP 404
|
||||
|
||||
#### Scenario: 代理用户查询无权限的资产
|
||||
|
||||
- **WHEN** 代理用户(shop_id=10)查询属于 shop_id=99(非下级)的设备
|
||||
- **THEN** 系统返回 HTTP 403,明确提示无权限
|
||||
|
||||
#### Scenario: 企业账号调用 resolve
|
||||
|
||||
- **WHEN** 企业账号调用 `GET /api/admin/assets/resolve/:identifier`
|
||||
- **THEN** 系统返回 HTTP 403,提示企业账号暂不支持此接口
|
||||
|
||||
#### Scenario: 卡绑定了有停机保护期的设备
|
||||
|
||||
- **WHEN** 管理员通过 ICCID 查询某张卡,该卡绑定的设备当前有 stop 保护期
|
||||
- **THEN** 响应中 `device_protect_status = "stop"`,反映所属设备的保护期状态
|
||||
|
||||
#### Scenario: 设备无当前生效套餐
|
||||
|
||||
- **WHEN** 管理员查询一台没有购买任何套餐的设备
|
||||
- **THEN** `current_package = ""`,`package_total_mb = 0`,`package_used_mb = 0`,`package_remain_mb = 0`
|
||||
|
||||
#### Scenario: 通过注册表主路径精确解析
|
||||
|
||||
- **WHEN** 管理员输入 identifier 为已存在于 `tb_asset_identifier` 的 VirtualNo 或 ICCID
|
||||
- **THEN** 系统单次查询注册表命中,直接查对应表返回完整资产信息,响应时间 < 50ms
|
||||
|
||||
#### Scenario: Fallback 路径解析 IMEI
|
||||
|
||||
- **WHEN** 管理员输入 identifier 为设备 IMEI(不在注册表中)
|
||||
- **THEN** 注册表未命中,系统 fallback 查 tb_device 的 imei 字段,找到后返回资产信息
|
||||
- **THEN** 响应中 `identifier` 字段原样回传该 IMEI 值
|
||||
|
||||
#### Scenario: Fallback 路径解析 MSISDN
|
||||
|
||||
- **WHEN** 管理员输入 identifier 为 IoT 卡的手机号(MSISDN)
|
||||
- **THEN** 注册表未命中,fallback 查 tb_iot_card 的 msisdn 字段
|
||||
- **THEN** 若存在多张卡的 MSISDN 相同,返回第一条匹配记录(MSISDN 非唯一,存在歧义,记录 warn 日志)
|
||||
|
||||
#### Scenario: 已实名的卡查询实名时间
|
||||
|
||||
- **WHEN** 通过标识符解析一张 `real_name_status = 1` 的卡
|
||||
- **THEN** 响应中 `real_name_at` 返回该卡最近一次 `0→1` 变化时的时间戳
|
||||
|
||||
#### Scenario: 未实名的卡查询实名时间
|
||||
|
||||
- **WHEN** 通过标识符解析一张 `real_name_status = 0` 的卡
|
||||
- **THEN** 响应中 `real_name_at` 返回 null
|
||||
|
||||
#### Scenario: 设备视角查询实名时间(部分卡已实名)
|
||||
|
||||
- **WHEN** 通过标识符解析一台设备,其中绑定了多张卡,部分卡已实名
|
||||
- **THEN** 响应中 `real_name_at` 返回所有已实名绑定卡中 `first_realname_at` 最小的时间戳
|
||||
|
||||
#### Scenario: 设备视角查询实名时间(无已实名卡)
|
||||
|
||||
- **WHEN** 通过标识符解析一台设备,其所有绑定卡均未实名
|
||||
- **THEN** 响应中 `real_name_at` 返回 null
|
||||
|
||||
#### Scenario: 设备绑定卡列表实名时间
|
||||
|
||||
- **WHEN** 通过标识符解析一台设备,绑定卡列表非空
|
||||
- **THEN** `cards` 数组中每个 `DeviceCardInfo` 的 `real_name_at` 返回各卡自己的 `first_realname_at`(未实名则为 null)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 手动刷新路径同步写入实名时间
|
||||
|
||||
系统 SHALL 在手动刷新路径(`RefreshCardDataFromGateway`)检测到实名状态由非已实名变为已实名(`0→1`)时,同步写入 `first_realname_at`,与轮询路径行为一致。
|
||||
|
||||
#### Scenario: 手动刷新触发实名状态变更
|
||||
|
||||
- **WHEN** 调用手动刷新接口,网关返回实名状态为已实名,且卡当前状态为未实名
|
||||
- **THEN** `tb_iot_card.first_realname_at` 被更新为当前时间戳
|
||||
|
||||
#### Scenario: 手动刷新时实名状态无变化
|
||||
|
||||
- **WHEN** 调用手动刷新接口,网关返回实名状态与卡当前状态相同
|
||||
- **THEN** `tb_iot_card.first_realname_at` 不被修改
|
||||
@@ -1,189 +0,0 @@
|
||||
# asset-suspend-resume Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
提供统一的资产停复机接口,包括设备级批量停复机和单卡停复机,含保护期感知逻辑。废弃原分散在各模块的旧停复机接口,统一使用 `/api/admin/assets/` 路径。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 设备停机接口
|
||||
|
||||
系统 SHALL 提供设备停机接口,批量停用设备下所有已实名卡,并建立停机保护期。
|
||||
|
||||
**API 端点**: `POST /api/admin/assets/device/:device_id/stop`
|
||||
|
||||
**执行流程**:
|
||||
1. 验证设备存在(不存在返回 HTTP 404)
|
||||
2. 检查设备是否在保护期(`RedisDeviceProtectKey(deviceID, "stop")` 或 `"start"` 存在则返回 HTTP 403)
|
||||
3. 获取该设备所有已实名(`real_name_status = 1`)的绑定卡
|
||||
4. 遍历调用网关停机接口(未实名卡跳过,永远是停机状态)
|
||||
5. 更新成功停机的卡的 `network_status = 0`,`stopped_at = now()`,`stop_reason = "manual"`
|
||||
6. 在 Redis 中设置停机保护期:`RedisDeviceProtectKey(deviceID, "stop")`,TTL = 1 小时
|
||||
7. 响应:返回成功,附带失败卡列表(如有)
|
||||
|
||||
**保护期说明**:
|
||||
- 保护期时长:1 小时(常量 `DeviceProtectPeriodDuration = 1 * time.Hour`,定义在 `pkg/constants/`)
|
||||
- 停机保护期 key:`protect:device:{device_id}:stop`
|
||||
- 复机保护期 key:`protect:device:{device_id}:start`
|
||||
- 两个 key 互斥:设置 stop 保护期时删除 start 保护期,反之亦然
|
||||
|
||||
**批量部分失败策略**:
|
||||
- 部分卡调网关失败:**仍设置** Redis 保护期(保护期从发起操作时算起)
|
||||
- 已成功停机的卡**不回滚**
|
||||
- 失败的卡记录 Error 日志,响应体中携带失败列表
|
||||
|
||||
#### Scenario: 成功执行设备停机
|
||||
|
||||
- **WHEN** 管理员调用 `POST /api/admin/assets/device/456/stop`,该设备有 3 张已实名卡
|
||||
- **THEN** 系统批量调网关停机,更新 3 张卡 network_status=0,设置 1 小时 stop 保护期,返回成功
|
||||
|
||||
#### Scenario: 设备存在保护期
|
||||
|
||||
- **WHEN** 管理员在设备已有 stop 保护期时再次调用停机接口
|
||||
- **THEN** 系统返回 HTTP 403,提示"设备处于保护期,不允许操作"
|
||||
|
||||
#### Scenario: 设备下无已实名卡
|
||||
|
||||
- **WHEN** 管理员对只有未实名卡的设备执行停机
|
||||
- **THEN** 系统返回成功(0 张卡操作),设置 stop 保护期
|
||||
|
||||
#### Scenario: 设备不存在
|
||||
|
||||
- **WHEN** 管理员调用不存在的设备 ID
|
||||
- **THEN** 系统返回 HTTP 404
|
||||
|
||||
#### Scenario: 部分卡停机失败
|
||||
|
||||
- **WHEN** 设备有 3 张卡,1 张网关调用失败
|
||||
- **THEN** 2 张成功停机,1 张失败记录日志,**仍设置** stop 保护期,响应中包含失败卡信息
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备复机接口
|
||||
|
||||
系统 SHALL 提供设备复机接口,批量恢复设备下所有已实名卡,并建立复机保护期。
|
||||
|
||||
**API 端点**: `POST /api/admin/assets/device/:device_id/start`
|
||||
|
||||
**执行流程**:
|
||||
1. 验证设备存在(不存在返回 HTTP 404)
|
||||
2. 检查设备是否在保护期(stop 或 start 保护期均存在时返回 HTTP 403)
|
||||
3. 获取该设备所有已实名(`real_name_status = 1`)的绑定卡
|
||||
4. 遍历调用网关复机接口
|
||||
5. 更新成功复机的卡的 `network_status = 1`,`resumed_at = now()`
|
||||
6. 设置复机保护期:`RedisDeviceProtectKey(deviceID, "start")`,TTL = 1 小时
|
||||
7. 响应:返回成功
|
||||
|
||||
#### Scenario: 成功执行设备复机
|
||||
|
||||
- **WHEN** 管理员调用 `POST /api/admin/assets/device/456/start`,该设备有 2 张已实名卡
|
||||
- **THEN** 系统批量复机,更新卡状态,设置 1 小时 start 保护期,返回成功
|
||||
|
||||
#### Scenario: 设备在 start 保护期内再次复机
|
||||
|
||||
- **WHEN** 设备已有 start 保护期时再次调用复机接口
|
||||
- **THEN** 系统返回 HTTP 403,提示"设备处于保护期,不允许操作"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 卡停机接口
|
||||
|
||||
系统 SHALL 提供单卡停机接口,含保护期感知逻辑。
|
||||
|
||||
**API 端点**: `POST /api/admin/assets/card/:iccid/stop`
|
||||
|
||||
**执行流程**:
|
||||
1. 通过 ICCID 查找卡(不存在返回 HTTP 404)
|
||||
2. 检查卡是否已实名(`real_name_status = 0` 时返回 HTTP 403:未实名卡不允许停复机)
|
||||
3. 若卡绑定了设备,检查该设备的保护期:
|
||||
- 设备有 **stop 保护期**:允许停机(本已是停机方向,无冲突)
|
||||
- 设备有 **start 保护期**:允许停机(用户可主动停单张卡)
|
||||
- 设备无保护期:正常执行
|
||||
4. 调用网关停机接口
|
||||
5. 更新卡 `network_status = 0`,`stopped_at = now()`,`stop_reason = "manual"`
|
||||
|
||||
#### Scenario: 独立卡(未绑定设备)停机
|
||||
|
||||
- **WHEN** 管理员对一张未绑定设备的已实名卡执行停机
|
||||
- **THEN** 系统正常调网关停机,更新卡状态
|
||||
|
||||
#### Scenario: 绑定设备且设备在 start 保护期内停机
|
||||
|
||||
- **WHEN** 管理员对绑定了设备且设备有 start 保护期的卡执行停机
|
||||
- **THEN** 系统允许执行(用户主动停单张卡不违反 start 保护期),正常停机
|
||||
|
||||
#### Scenario: 对未实名卡执行停机
|
||||
|
||||
- **WHEN** 管理员对 real_name_status=0(未实名)的卡执行停机
|
||||
- **THEN** 系统返回 HTTP 403,提示"未实名卡不允许停复机操作"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 卡复机接口
|
||||
|
||||
系统 SHALL 提供单卡复机接口,含保护期感知逻辑。
|
||||
|
||||
**API 端点**: `POST /api/admin/assets/card/:iccid/start`
|
||||
|
||||
**执行流程**:
|
||||
1. 通过 ICCID 查找卡(不存在返回 HTTP 404)
|
||||
2. 检查卡是否已实名(`real_name_status = 0` 时返回 HTTP 403)
|
||||
3. 若卡绑定了设备,检查该设备的保护期:
|
||||
- 设备有 **stop 保护期**:**不允许**手动复机,返回 HTTP 403(设备处于停机保护期)
|
||||
- 设备有 **start 保护期**:允许复机(本已是复机方向,无冲突)
|
||||
- 设备无保护期:正常执行
|
||||
4. 调用网关复机接口
|
||||
5. 更新卡 `network_status = 1`,`resumed_at = now()`,清空 `stop_reason`
|
||||
|
||||
#### Scenario: 独立卡(未绑定设备)复机
|
||||
|
||||
- **WHEN** 管理员对一张未绑定设备的已实名停机卡执行复机
|
||||
- **THEN** 系统正常调网关复机,更新卡状态
|
||||
|
||||
#### Scenario: 设备处于 stop 保护期时尝试复机
|
||||
|
||||
- **WHEN** 管理员对绑定了设备且设备有 stop 保护期的卡执行复机
|
||||
- **THEN** 系统返回 HTTP 403,提示"设备处于停机保护期,不允许手动复机"
|
||||
|
||||
#### Scenario: 设备在 start 保护期内复机
|
||||
|
||||
- **WHEN** 管理员对绑定了设备且设备有 start 保护期的卡执行复机
|
||||
- **THEN** 系统允许执行(本已是复机方向),正常复机
|
||||
|
||||
#### Scenario: 对未实名卡执行复机
|
||||
|
||||
- **WHEN** 管理员对 real_name_status=0 的卡执行复机
|
||||
- **THEN** 系统返回 HTTP 403,提示"未实名卡不允许停复机操作"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 废弃旧停复机接口
|
||||
|
||||
系统 SHALL 删除以下重复的停复机接口,统一使用新的 `/api/admin/assets/` 路径。
|
||||
|
||||
**待删除接口**:
|
||||
- `POST /api/admin/enterprises/:id/cards/:card_id/suspend`
|
||||
- `POST /api/admin/enterprises/:id/cards/:card_id/resume`
|
||||
- `POST /h5/devices/:device_id/cards/:card_id/suspend`
|
||||
- `POST /h5/devices/:device_id/cards/:card_id/resume`
|
||||
- 旧 Admin 卡停复机接口(`POST /iot-cards/:iccid/suspend|resume`)
|
||||
|
||||
#### Scenario: 调用已删除的旧接口
|
||||
|
||||
- **WHEN** 前端调用 `POST /api/admin/enterprises/:id/cards/:card_id/suspend`
|
||||
- **THEN** 系统返回 HTTP 404(路由已不存在)
|
||||
|
||||
---
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 停复机回调注入
|
||||
系统启动时 SHALL 在 bootstrap 阶段注入停复机回调,确保停机/复机操作完成后自动触发套餐联动逻辑(暂停/恢复套餐计时)。
|
||||
|
||||
#### Scenario: 系统启动后回调已注入
|
||||
- **WHEN** API 或 Worker 服务完成 bootstrap 初始化
|
||||
- **THEN** `usageService.SetStopResumeCallback(stopResumeService)` 和 `activationService.SetResumeCallback(stopResumeService)` SHALL 已被调用
|
||||
|
||||
#### Scenario: 停机触发套餐暂停
|
||||
- **WHEN** 管理员对卡执行停机操作且回调已注入
|
||||
- **THEN** 停机成功后 SHALL 自动触发套餐暂停联动逻辑
|
||||
@@ -1,84 +0,0 @@
|
||||
# asset-wallet-query Specification
|
||||
|
||||
## Purpose
|
||||
Admin 端资产钱包查询,允许平台用户和代理账号查询指定物联网卡或设备的钱包余额概况及收支流水,流水包含可跳转的来源编号(充值单号 / 订单号)。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Admin 端查询资产钱包概况
|
||||
|
||||
系统 SHALL 提供 `GET /api/admin/assets/:asset_type/:id/wallet` 接口,允许平台用户和代理账号查询指定卡或设备的钱包余额概况。
|
||||
|
||||
**接口规格**:
|
||||
- 路径参数 `asset_type`:`card` 或 `device`
|
||||
- 路径参数 `id`:资产数据库 ID(uint)
|
||||
- 无请求体
|
||||
- 返回字段:`wallet_id`、`resource_type`、`resource_id`、`balance`、`frozen_balance`、`available_balance`、`currency`、`status`、`status_text`、`created_at`、`updated_at`
|
||||
|
||||
**权限规则**:
|
||||
- 平台用户/超级管理员:可查询所有资产钱包
|
||||
- 代理账号:只能查询 `shop_id_tag IN (当前店铺及下级店铺)` 的资产钱包(由 `ApplyShopTagFilter` 自动过滤)
|
||||
- 企业账号:Handler 层直接返回 403,禁止访问
|
||||
|
||||
#### Scenario: 平台用户查询卡钱包概况
|
||||
|
||||
- **WHEN** 平台用户请求 `GET /api/admin/assets/card/456/wallet`,该卡存在钱包记录,余额 100 元,冻结 0 元
|
||||
- **THEN** 系统返回 200,`balance=10000`,`frozen_balance=0`,`available_balance=10000`,`status=1`,`status_text="正常"`
|
||||
|
||||
#### Scenario: 代理账号查询下级资产钱包
|
||||
|
||||
- **WHEN** 代理账号(shop_id=10)请求 `GET /api/admin/assets/device/789/wallet`,该设备的 `shop_id_tag` 在该代理的下级店铺范围内
|
||||
- **THEN** 系统返回 200,返回该设备的钱包详情
|
||||
|
||||
#### Scenario: 代理账号查询越权资产钱包
|
||||
|
||||
- **WHEN** 代理账号(shop_id=10)请求 `GET /api/admin/assets/card/999/wallet`,该卡的 `shop_id_tag` 不在该代理的下级店铺范围内
|
||||
- **THEN** 系统返回 404,错误消息为"该资产暂无钱包记录"(不区分"无权"与"不存在")
|
||||
|
||||
#### Scenario: 企业账号请求被拒绝
|
||||
|
||||
- **WHEN** 企业账号请求 `GET /api/admin/assets/card/456/wallet`
|
||||
- **THEN** 系统返回 403,错误消息为"企业账号无权查看钱包信息"
|
||||
|
||||
#### Scenario: 资产无钱包记录
|
||||
|
||||
- **WHEN** 平台用户请求 `GET /api/admin/assets/card/456/wallet`,该卡尚未创建钱包(未充值过)
|
||||
- **THEN** 系统返回 404,错误消息为"该资产暂无钱包记录"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Admin 端查询资产钱包流水列表
|
||||
|
||||
系统 SHALL 提供 `GET /api/admin/assets/:asset_type/:id/wallet/transactions` 接口,允许平台用户和代理账号分页查询指定资产的钱包收支流水,每条流水包含可跳转的来源编号。
|
||||
|
||||
**接口规格**:
|
||||
- 路径参数:同上
|
||||
- 查询参数:`page`(默认 1)、`page_size`(默认 20,最大 100)、`transaction_type`(可选过滤)、`start_time`(可选)、`end_time`(可选)
|
||||
- 流水按 `created_at` 倒序排列
|
||||
- 每条流水返回:`id`、`transaction_type`、`transaction_type_text`、`amount`、`balance_before`、`balance_after`、`reference_type`、`reference_no`、`remark`、`created_at`
|
||||
|
||||
**来源编号跳转规则**:
|
||||
- `reference_type = "recharge"` → `reference_no` 为充值单号(`CRCH…`),前端可跳转至充值单详情
|
||||
- `reference_type = "order"` → `reference_no` 为订单号(`ORD…`),前端可跳转至订单详情
|
||||
|
||||
**权限规则**:与钱包概况接口相同
|
||||
|
||||
#### Scenario: 查询充值和扣款流水
|
||||
|
||||
- **WHEN** 平台用户请求 `GET /api/admin/assets/card/456/wallet/transactions?page=1&page_size=20`,该卡有 1 条充值流水(100 元)和 1 条扣款流水(-30 元)
|
||||
- **THEN** 系统返回 200,`total=2`,按时间倒序返回两条记录,充值流水 `amount=10000`、`reference_type="recharge"`、`reference_no="CRCH20260309001"`;扣款流水 `amount=-3000`、`reference_type="order"`、`reference_no="ORD20260310001"`
|
||||
|
||||
#### Scenario: 按交易类型过滤
|
||||
|
||||
- **WHEN** 平台用户请求 `GET /api/admin/assets/card/456/wallet/transactions?transaction_type=recharge`
|
||||
- **THEN** 系统只返回 `transaction_type="recharge"` 的流水记录
|
||||
|
||||
#### Scenario: 分页超出范围
|
||||
|
||||
- **WHEN** 请求 `page_size=200`(超过最大值 100)
|
||||
- **THEN** 系统返回 400,错误消息为参数验证失败
|
||||
|
||||
#### Scenario: 资产无流水记录
|
||||
|
||||
- **WHEN** 平台用户请求某资产的流水列表,该资产钱包存在但尚无任何流水
|
||||
- **THEN** 系统返回 200,`list=[]`,`total=0`
|
||||
@@ -1,215 +0,0 @@
|
||||
# auth Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change refactor-framework-cleanup. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: Unified Authentication Middleware
|
||||
|
||||
系统 SHALL 提供统一的认证中间件,支持可配置的 Token 提取和验证。
|
||||
|
||||
#### Scenario: Token 验证成功
|
||||
- **WHEN** 请求携带有效的 Token
|
||||
- **THEN** 中间件提取并验证 Token
|
||||
- **AND** 将用户信息同时设置到 Fiber Locals 和 Context
|
||||
- **AND** 请求继续执行
|
||||
|
||||
#### Scenario: Token 缺失
|
||||
- **WHEN** 请求未携带 Token
|
||||
- **AND** 路径不在跳过列表中
|
||||
- **THEN** 返回 AppError(CodeMissingToken)
|
||||
- **AND** 由全局 ErrorHandler 处理错误响应
|
||||
|
||||
#### Scenario: Token 无效
|
||||
- **WHEN** 请求携带的 Token 无效或过期
|
||||
- **THEN** 返回 AppError(CodeUnauthorized)
|
||||
- **AND** 由全局 ErrorHandler 处理错误响应
|
||||
|
||||
#### Scenario: 跳过路径
|
||||
- **WHEN** 请求路径在 SkipPaths 配置中
|
||||
- **THEN** 中间件跳过认证
|
||||
- **AND** 请求直接继续执行
|
||||
|
||||
### Requirement: User Context Management
|
||||
|
||||
认证中间件 SHALL 提供用户上下文管理函数,支持从 Context 获取用户信息。
|
||||
|
||||
#### Scenario: 获取用户 ID
|
||||
- **WHEN** 调用 GetUserIDFromContext(ctx)
|
||||
- **AND** 认证已通过
|
||||
- **THEN** 返回当前用户的 ID
|
||||
|
||||
#### Scenario: 检查 Root 用户
|
||||
- **WHEN** 调用 IsRootUser(ctx)
|
||||
- **THEN** 返回当前用户是否为 Root 用户
|
||||
|
||||
#### Scenario: 设置用户到 Fiber Context
|
||||
- **WHEN** 调用 SetUserToFiberContext(c, userInfo)
|
||||
- **THEN** 用户信息被设置到 Fiber Locals
|
||||
- **AND** 用户信息被设置到请求 Context(供 GORM 等使用)
|
||||
|
||||
### Requirement: Auth Middleware Configuration
|
||||
|
||||
认证中间件 SHALL 支持灵活的配置选项。
|
||||
|
||||
#### Scenario: 自定义 Token 提取
|
||||
- **WHEN** 配置了 TokenExtractor 函数
|
||||
- **THEN** 使用自定义函数从请求中提取 Token
|
||||
|
||||
#### Scenario: 默认 Token 提取
|
||||
- **WHEN** 未配置 TokenExtractor
|
||||
- **THEN** 从 Authorization Header 提取 Bearer Token
|
||||
|
||||
#### Scenario: 自定义验证函数
|
||||
- **WHEN** 配置了 Validator 函数
|
||||
- **THEN** 使用自定义函数验证 Token 并返回用户信息
|
||||
|
||||
### Requirement: 启动时自动初始化默认管理员
|
||||
|
||||
系统在 API 服务启动时 SHALL 检查数据库是否存在超级管理员账号,如果不存在则自动创建默认管理员账号。
|
||||
|
||||
**业务规则**:
|
||||
- 检查条件:`user_type = 1`(超级管理员)且未被软删除的账号
|
||||
- 仅在不存在时创建,存在管理员时跳过
|
||||
- 默认账号信息读取优先级:
|
||||
1. **配置文件优先**:读取 `config.yaml` 的 `default_admin` 配置节
|
||||
2. **代码默认值**:如果配置文件未提供,使用代码内置常量
|
||||
- 代码内置默认值:
|
||||
- 用户名:`admin`
|
||||
- 密码:`Admin@123456`(bcrypt 哈希存储)
|
||||
- 手机号:`13800000000`
|
||||
- 用户类型:`1`(超级管理员)
|
||||
- 状态:`1`(启用)
|
||||
- 初始化失败不中断服务启动(记录错误日志,降级处理)
|
||||
|
||||
#### Scenario: 空数据库首次启动(使用代码默认值)
|
||||
|
||||
- **WHEN** API 服务启动且数据库中不存在任何超级管理员账号
|
||||
- **AND** 配置文件未提供 `default_admin` 配置
|
||||
- **THEN** 系统使用代码内置默认值创建管理员账号
|
||||
- **AND** 用户名为 `admin`,密码为 `Admin@123456`,手机号为 `13800000000`
|
||||
- **AND** 记录日志:"已创建默认管理员账号: admin(使用代码默认值)"
|
||||
- **AND** 创建的账号可以正常使用(密码验证通过)
|
||||
|
||||
#### Scenario: 空数据库首次启动(使用配置文件)
|
||||
|
||||
- **WHEN** API 服务启动且数据库中不存在任何超级管理员账号
|
||||
- **AND** 配置文件提供了 `default_admin` 配置
|
||||
- **THEN** 系统使用配置文件中的值创建管理员账号
|
||||
- **AND** 用户名、密码、手机号均从配置文件读取
|
||||
- **AND** 记录日志:"已创建默认管理员账号: {username}(使用配置文件)"
|
||||
- **AND** 创建的账号可以正常使用(配置的密码验证通过)
|
||||
|
||||
#### Scenario: 已有管理员时启动
|
||||
|
||||
- **WHEN** API 服务启动且数据库中已存在至少一个超级管理员账号
|
||||
- **THEN** 系统跳过创建默认管理员
|
||||
- **AND** 记录日志:"检测到已有管理员账号,跳过初始化"
|
||||
- **AND** 不创建任何新账号
|
||||
|
||||
#### Scenario: 用户名或手机号冲突
|
||||
|
||||
- **WHEN** API 服务启动且尝试创建默认管理员
|
||||
- **AND** 数据库中已存在用户名为 `admin` 或手机号为 `13800000000` 的账号(非超级管理员)
|
||||
- **THEN** 系统创建失败
|
||||
- **AND** 记录错误日志:"创建默认管理员失败: 用户名或手机号已存在"
|
||||
- **AND** 不中断服务启动(降级处理)
|
||||
|
||||
#### Scenario: 初始化执行时机
|
||||
|
||||
- **WHEN** API 服务执行启动流程
|
||||
- **THEN** 管理员初始化在以下时机执行:
|
||||
1. 所有组件(Store、Service、Handler)初始化完成后
|
||||
2. 注册路由前
|
||||
3. 服务器开始监听前
|
||||
- **AND** 确保 AccountStore 可用时才执行初始化
|
||||
|
||||
### Requirement: 默认管理员配置支持
|
||||
|
||||
系统 SHALL 支持通过配置文件自定义默认管理员账号信息,配置文件优先级高于代码默认值。
|
||||
|
||||
**配置格式**:
|
||||
```yaml
|
||||
default_admin:
|
||||
username: "admin" # 可选,默认 "admin"
|
||||
password: "Admin@123456" # 可选,默认 "Admin@123456"
|
||||
phone: "13800000000" # 可选,默认 "13800000000"
|
||||
```
|
||||
|
||||
#### Scenario: 配置文件完整提供
|
||||
|
||||
- **WHEN** `config.yaml` 中配置了 `default_admin` 节
|
||||
- **AND** 提供了 `username`、`password`、`phone` 三个字段
|
||||
- **THEN** 系统读取配置文件的值
|
||||
- **AND** 不使用代码默认值
|
||||
- **AND** 创建管理员账号时使用配置的值
|
||||
|
||||
#### Scenario: 配置文件部分提供
|
||||
|
||||
- **WHEN** `config.yaml` 中配置了 `default_admin` 节
|
||||
- **AND** 只提供了部分字段(如只配置了 `password`)
|
||||
- **THEN** 系统对已提供的字段使用配置值
|
||||
- **AND** 对未提供的字段使用代码默认值
|
||||
- **AND** 例如:配置了 `password: "MySecret123"`,但未配置 `username` 和 `phone`
|
||||
- 使用 `password = "MySecret123"`
|
||||
- 使用 `username = "admin"`(代码默认值)
|
||||
- 使用 `phone = "13800000000"`(代码默认值)
|
||||
|
||||
#### Scenario: 配置文件未提供
|
||||
|
||||
- **WHEN** `config.yaml` 中未配置 `default_admin` 节
|
||||
- **THEN** 系统使用代码内置默认值
|
||||
- **AND** 用户名为 `admin`
|
||||
- **AND** 密码为 `Admin@123456`
|
||||
- **AND** 手机号为 `13800000000`
|
||||
|
||||
#### Scenario: 配置验证
|
||||
|
||||
- **WHEN** 读取 `default_admin` 配置
|
||||
- **THEN** 配置项为可选,不参与 `Validate()` 验证
|
||||
- **AND** 允许配置为空或不存在
|
||||
- **AND** 不阻止服务启动
|
||||
|
||||
### Requirement: 默认管理员安全配置
|
||||
|
||||
系统 SHALL 使用足够复杂的默认密码,并记录管理员创建日志用于安全审计。
|
||||
|
||||
#### Scenario: 默认密码复杂度
|
||||
|
||||
- **WHEN** 创建默认管理员账号
|
||||
- **THEN** 代码内置默认密码 SHALL 满足以下复杂度要求:
|
||||
- 长度 ≥ 12 位
|
||||
- 包含大写字母、小写字母、数字、特殊字符
|
||||
- 示例:`Admin@123456`
|
||||
|
||||
#### Scenario: 审计日志记录
|
||||
|
||||
- **WHEN** 创建或跳过默认管理员账号
|
||||
- **THEN** 系统记录审计日志到 `app.log`
|
||||
- **AND** 日志包含以下信息:
|
||||
- 操作时间
|
||||
- 操作结果(创建成功/跳过/失败)
|
||||
- 创建的用户名(成功时)
|
||||
- 配置来源(配置文件/代码默认值)
|
||||
- 失败原因(失败时)
|
||||
- **AND** 不在日志中记录明文密码
|
||||
|
||||
### Requirement: 系统账号创建内部接口
|
||||
|
||||
Account Service SHALL 提供内部方法用于系统初始化场景创建账号,绕过常规的用户上下文检查。
|
||||
|
||||
#### Scenario: 系统初始化创建账号
|
||||
|
||||
- **WHEN** 系统初始化需要创建内部账号(如默认管理员)
|
||||
- **THEN** 调用 `createSystemAccount(ctx, account)` 方法
|
||||
- **AND** 该方法不检查当前用户 ID(允许 context 中无用户信息)
|
||||
- **AND** 保留用户名和手机号唯一性检查
|
||||
- **AND** 密码使用 bcrypt 哈希存储
|
||||
- **AND** 自动设置 creator 和 updater 为 0(系统创建)
|
||||
|
||||
#### Scenario: 常规 API 请求不使用系统接口
|
||||
|
||||
- **WHEN** 通过 HTTP API 创建账号
|
||||
- **THEN** 使用常规 `Create()` 方法
|
||||
- **AND** 必须有当前用户上下文(user_id > 0)
|
||||
- **AND** 不允许调用 `createSystemAccount()` 方法(内部使用)
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
# authorization-record Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change add-authorization-record-management. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: 授权记录列表查询
|
||||
|
||||
系统 SHALL 提供授权记录列表接口,支持分页和多条件筛选。
|
||||
|
||||
#### Scenario: 平台用户查询所有授权记录
|
||||
- **WHEN** 平台用户请求 `GET /api/admin/authorizations`
|
||||
- **THEN** 系统返回所有授权记录(包含有效和已回收)
|
||||
- **AND** 每条记录包含企业名称、卡信息(ICCID/MSISDN)、授权人名称
|
||||
|
||||
#### Scenario: 代理用户查询授权记录
|
||||
- **WHEN** 代理用户请求 `GET /api/admin/authorizations`
|
||||
- **THEN** 系统只返回该代理店铺下企业的授权记录
|
||||
- **AND** 不包含下级店铺的授权记录
|
||||
|
||||
#### Scenario: 按企业筛选
|
||||
- **WHEN** 请求包含 `enterprise_id` 参数
|
||||
- **THEN** 系统只返回该企业的授权记录
|
||||
|
||||
#### Scenario: 按ICCID模糊查询
|
||||
- **WHEN** 请求包含 `iccid` 参数
|
||||
- **THEN** 系统返回 ICCID 包含该值的授权记录
|
||||
|
||||
#### Scenario: 按授权人类型筛选
|
||||
- **WHEN** 请求包含 `authorizer_type` 参数(2=平台,3=代理)
|
||||
- **THEN** 系统只返回该类型授权人创建的记录
|
||||
|
||||
#### Scenario: 按状态筛选
|
||||
- **WHEN** 请求包含 `status` 参数
|
||||
- **AND** `status=1` 表示有效,`status=0` 表示已回收
|
||||
- **THEN** 系统只返回对应状态的授权记录
|
||||
|
||||
#### Scenario: 按授权时间范围筛选
|
||||
- **WHEN** 请求包含 `start_time` 和/或 `end_time` 参数
|
||||
- **THEN** 系统只返回授权时间在该范围内的记录
|
||||
|
||||
#### Scenario: 分页查询
|
||||
- **WHEN** 请求包含 `page` 和 `page_size` 参数
|
||||
- **THEN** 系统返回对应页的数据
|
||||
- **AND** 响应包含 `total` 总记录数
|
||||
|
||||
### Requirement: 授权记录详情查询
|
||||
|
||||
系统 SHALL 提供授权记录详情接口,返回单条记录的完整信息。
|
||||
|
||||
#### Scenario: 查询存在的授权记录
|
||||
- **WHEN** 请求 `GET /api/admin/authorizations/:id`
|
||||
- **AND** 记录存在且用户有权限查看
|
||||
- **THEN** 系统返回该授权记录的完整信息
|
||||
- **AND** 包含关联的企业名称、卡信息、授权人名称、回收人名称
|
||||
|
||||
#### Scenario: 查询不存在的授权记录
|
||||
- **WHEN** 请求 `GET /api/admin/authorizations/:id`
|
||||
- **AND** 记录不存在
|
||||
- **THEN** 系统返回 404 错误
|
||||
|
||||
#### Scenario: 查询无权限的授权记录
|
||||
- **WHEN** 代理用户请求 `GET /api/admin/authorizations/:id`
|
||||
- **AND** 该记录不属于代理的店铺
|
||||
- **THEN** 系统返回 404 错误(不暴露记录存在)
|
||||
|
||||
### Requirement: 修改授权备注
|
||||
|
||||
系统 SHALL 提供修改授权备注的接口。
|
||||
|
||||
#### Scenario: 平台用户修改任意备注
|
||||
- **WHEN** 平台用户请求 `PUT /api/admin/authorizations/:id/remark`
|
||||
- **AND** 提供新的备注内容
|
||||
- **THEN** 系统更新该授权记录的备注
|
||||
- **AND** 返回更新后的记录
|
||||
|
||||
#### Scenario: 代理用户修改备注
|
||||
- **WHEN** 代理用户请求 `PUT /api/admin/authorizations/:id/remark`
|
||||
- **AND** 该记录属于代理的店铺
|
||||
- **THEN** 系统更新该授权记录的备注
|
||||
|
||||
#### Scenario: 代理用户修改无权限的备注
|
||||
- **WHEN** 代理用户请求 `PUT /api/admin/authorizations/:id/remark`
|
||||
- **AND** 该记录不属于代理的店铺
|
||||
- **THEN** 系统返回 404 错误
|
||||
|
||||
#### Scenario: 备注长度限制
|
||||
- **WHEN** 请求的备注内容超过 500 字符
|
||||
- **THEN** 系统返回 400 错误,提示备注过长
|
||||
|
||||
### Requirement: 授权记录响应格式
|
||||
|
||||
系统 SHALL 使用统一的响应格式返回授权记录。
|
||||
|
||||
#### Scenario: 列表响应格式
|
||||
- **WHEN** 返回授权记录列表
|
||||
- **THEN** 每条记录包含以下字段:
|
||||
- `id`: 记录ID
|
||||
- `enterprise_id`: 企业ID
|
||||
- `enterprise_name`: 企业名称
|
||||
- `card_id`: 卡ID
|
||||
- `iccid`: ICCID
|
||||
- `msisdn`: 手机号
|
||||
- `authorized_by`: 授权人ID
|
||||
- `authorizer_name`: 授权人名称
|
||||
- `authorizer_type`: 授权人类型
|
||||
- `authorized_at`: 授权时间
|
||||
- `revoked_by`: 回收人ID(可空)
|
||||
- `revoker_name`: 回收人名称(可空)
|
||||
- `revoked_at`: 回收时间(可空)
|
||||
- `status`: 状态(1=有效,0=已回收)
|
||||
- `remark`: 备注
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 购买套餐支付成功后自动复机
|
||||
|
||||
系统 SHALL 在支付回调成功激活套餐(`PackageUsage.status` 变为 1)后,对因流量耗尽而停机的卡自动触发复机操作。
|
||||
|
||||
#### Scenario: C 端购买套餐支付成功后停机卡自动复机
|
||||
- **GIVEN** 卡 C1 因流量耗尽停机(`network_status=0`,`stop_reason=traffic_exhausted`),已完成实名认证(`real_name_status=1`)
|
||||
- **WHEN** C 端用户为 C1 购买新套餐并完成微信/支付宝支付,`HandlePaymentCallback` 事务提交成功
|
||||
- **THEN** 系统异步调用 `ResumeCardIfStopped(ctx, "iot_card", cardID)`
|
||||
- **AND** 系统调用 `gateway.StartCard`,更新 `network_status=1`,清空 `stop_reason`
|
||||
|
||||
#### Scenario: 后台购买套餐(钱包支付)后停机卡自动复机
|
||||
- **GIVEN** 卡 C2 因流量耗尽停机,已实名
|
||||
- **WHEN** 后台管理员通过钱包支付为 C2 购买套餐,`HandlePaymentCallback` 事务提交成功,套餐状态为 `status=1`
|
||||
- **THEN** 系统调用复机逻辑,C2 自动复机
|
||||
|
||||
#### Scenario: 购买排队套餐(新套餐为 Pending 状态)时不触发复机
|
||||
- **GIVEN** 卡 C3 已有生效中套餐,用户购买第二个套餐(新套餐创建为 `status=0` Pending)
|
||||
- **WHEN** 支付成功,`HandlePaymentCallback` 完成
|
||||
- **THEN** 系统 SHALL NOT 触发复机调用(仅在套餐变为 `status=1` 时才复机)
|
||||
|
||||
#### Scenario: 未实名卡购买套餐不触发复机
|
||||
- **GIVEN** 卡 C4 停机(`stop_reason=traffic_exhausted`),但 `real_name_status=0`(未实名)
|
||||
- **WHEN** 为 C4 购买新套餐且套餐激活为 `status=1`
|
||||
- **THEN** `ResumeCardIfStopped` 内部检测到未实名,静默跳过,C4 保持停机状态
|
||||
|
||||
#### Scenario: 停机原因非流量耗尽时不触发复机
|
||||
- **GIVEN** 卡 C5 因手动停机(`stop_reason=manual`)
|
||||
- **WHEN** 为 C5 购买新套餐并激活
|
||||
- **THEN** `ResumeCardIfStopped` 检测到 `stop_reason` 不为 `traffic_exhausted`,静默跳过
|
||||
|
||||
#### Scenario: 设备类型载体购买套餐后自动复机
|
||||
- **GIVEN** 设备 D1 绑定了 3 张卡,其中 2 张已实名且因流量耗尽停机,1 张未实名
|
||||
- **WHEN** 为 D1 购买设备级套餐并激活
|
||||
- **THEN** 已实名的 2 张卡自动复机,未实名的 1 张卡保持停机状态
|
||||
@@ -1,37 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 流量周期重置后自动复机
|
||||
|
||||
系统 SHALL 在成功重置套餐流量(`data_usage_mb` 归零,`status` 恢复为 `Active`)后,对因流量耗尽而停机的卡自动触发复机操作。
|
||||
|
||||
#### Scenario: 3 个月套餐第 2 个月耗尽停机,第 3 个月重置后自动复机
|
||||
- **GIVEN** 卡 C1 有一个 3 个月套餐(`data_reset_cycle=monthly`),第 2 个月流量耗尽(`status=2 Depleted`),卡已停机(`network_status=0`,`stop_reason=traffic_exhausted`),已实名
|
||||
- **WHEN** 月度重置任务运行,套餐 `data_usage_mb` 归零,`status` 恢复为 `1 Active`
|
||||
- **THEN** `ResetService` 调用 `ResumeCardIfStopped(ctx, "iot_card", cardID)`
|
||||
- **AND** 卡自动复机(`network_status=1`)
|
||||
|
||||
#### Scenario: 日流量套餐重置后停机卡自动复机
|
||||
- **GIVEN** 卡 C2 有日流量套餐(`data_reset_cycle=daily`),当天流量耗尽停机,已实名
|
||||
- **WHEN** 次日零点日流量重置任务运行
|
||||
- **THEN** 卡自动复机
|
||||
|
||||
#### Scenario: 年流量套餐重置后停机卡自动复机
|
||||
- **GIVEN** 卡 C3 有年流量套餐(`data_reset_cycle=yearly`),年内流量耗尽停机,已实名
|
||||
- **WHEN** 年度重置任务运行
|
||||
- **THEN** 卡自动复机
|
||||
|
||||
#### Scenario: 重置后卡已处于开机状态不重复复机
|
||||
- **GIVEN** 卡 C4 套餐被重置,但 `network_status=1`(已开机,可能已被人工复机)
|
||||
- **WHEN** `ResumeCardIfStopped` 被调用
|
||||
- **THEN** 检测到卡已开机,幂等跳过,不调用 Gateway
|
||||
|
||||
#### Scenario: 重置后未实名卡不复机
|
||||
- **GIVEN** 卡 C5 套餐被重置,`real_name_status=0`(未实名),停机中
|
||||
- **WHEN** `ResumeCardIfStopped` 被调用
|
||||
- **THEN** 检测到未实名,静默跳过,卡保持停机
|
||||
|
||||
#### Scenario: 批量重置中部分套餐的卡已停机
|
||||
- **GIVEN** 月度重置扫描到 100 个套餐,其中 10 个对应的卡处于停机状态且已实名
|
||||
- **WHEN** 月度重置完成
|
||||
- **THEN** 10 张停机卡异步触发复机,其余 90 张正常跳过
|
||||
- **AND** 单个复机失败不影响其他卡的处理
|
||||
@@ -1,482 +0,0 @@
|
||||
# Spec: 自动停复机机制
|
||||
|
||||
## 业务背景
|
||||
|
||||
### 为什么需要自动停复机
|
||||
|
||||
**现状问题**:
|
||||
- 当前系统流量耗尽后手动停机,用户购买加油包后需手动复机
|
||||
- 停复机时机不精确,可能出现流量已耗尽但仍可上网的情况
|
||||
- 用户购买加油包后不知道需要复机,导致流量无法使用
|
||||
|
||||
**业务目标**:
|
||||
- 所有套餐流量耗尽时自动停机,避免超额使用
|
||||
- 购买新套餐(正式/加油包)后自动复机,提升用户体验
|
||||
- 停复机延迟 < 2分钟,确保及时性
|
||||
|
||||
---
|
||||
|
||||
## 业务规则
|
||||
|
||||
### 1. 停机触发条件
|
||||
|
||||
```
|
||||
停机条件 = (所有生效套餐流量 = 0) AND (卡当前状态 = active)
|
||||
```
|
||||
|
||||
**详细逻辑**:
|
||||
```sql
|
||||
-- 检查是否有剩余流量
|
||||
SELECT COUNT(*) FROM tb_package_usage
|
||||
WHERE iot_card_id = ?
|
||||
AND status = 1 -- 生效中
|
||||
AND data_usage_mb < data_limit_mb;
|
||||
|
||||
-- 如果 COUNT = 0,触发停机
|
||||
```
|
||||
|
||||
### 2. 复机触发条件
|
||||
|
||||
```
|
||||
复机条件 = (存在可用流量套餐) AND (卡当前状态 = stopped)
|
||||
```
|
||||
|
||||
**可用流量套餐定义**:
|
||||
```sql
|
||||
status='active' AND remaining_data_amount > 0
|
||||
```
|
||||
|
||||
### 3. 停复机延迟要求
|
||||
|
||||
- **目标延迟**:< 2分钟(从触发条件到完成停复机)
|
||||
- **实现方式**:流量检查后同步调用停复机接口(不走异步队列)
|
||||
|
||||
### 4. 运营商接口容错
|
||||
|
||||
- 停机/复机失败时:
|
||||
- 重试3次(间隔 1s, 2s, 4s)
|
||||
- 仍失败:记录错误日志,人工介入
|
||||
- **不阻塞**套餐激活流程
|
||||
|
||||
---
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 流量耗尽自动停机
|
||||
|
||||
系统 SHALL 在主套餐和所有加油包流量都用完时,调用运营商接口停机。
|
||||
|
||||
#### Scenario: 所有套餐流量耗尽触发停机
|
||||
- **GIVEN** 卡 C1 有主套餐(剩余0MB)和加油包(剩余0MB),卡状态为 active
|
||||
- **WHEN** 轮询系统检查停机条件
|
||||
- **THEN** 系统执行停机操作:
|
||||
1. 调用运营商停机接口
|
||||
2. 更新 IotCard.network_status=0(已停机)
|
||||
3. 记录 stopped_at 时间
|
||||
4. 记录 stop_reason="traffic_exhausted"
|
||||
5. 记录操作日志
|
||||
|
||||
#### Scenario: 有剩余流量时不停机
|
||||
- **GIVEN** 主套餐流量用完,但加油包剩余1GB
|
||||
- **WHEN** 轮询系统检查停机条件
|
||||
- **THEN** 系统查询到有剩余流量,不触发停机
|
||||
|
||||
#### Scenario: 停机接口调用失败重试
|
||||
- **GIVEN** 所有套餐流量用完,需要停机
|
||||
- **WHEN** 调用运营商停机接口失败(网络超时)
|
||||
- **THEN** 系统重试3次(间隔1s/2s/4s)
|
||||
- **AND** 3次都失败后记录 Error 日志,告警通知运维
|
||||
|
||||
#### Scenario: 停机幂等性
|
||||
- **GIVEN** 卡已停机(network_status=0)
|
||||
- **WHEN** 轮询系统再次检测到流量用完
|
||||
- **THEN** 系统检测到已停机,跳过停机调用
|
||||
|
||||
### Requirement: 购买套餐自动复机
|
||||
|
||||
系统 SHALL 在购买新套餐(正式/加油包)激活后,自动调用运营商接口复机。
|
||||
|
||||
#### Scenario: 购买加油包自动复机
|
||||
- **GIVEN** 卡 C1 已停机(network_status=0,stopped_at=2026-02-10 10:00)
|
||||
- **WHEN** 用户购买加油包,激活成功(status=active)
|
||||
- **THEN** 系统执行复机操作:
|
||||
1. 调用运营商复机接口
|
||||
2. 更新 IotCard.network_status=1(正常)
|
||||
3. 记录 resumed_at 时间
|
||||
4. 清空 stopped_at
|
||||
5. 记录操作日志
|
||||
|
||||
#### Scenario: 复机幂等性
|
||||
- **GIVEN** 卡 C1 已停机
|
||||
- **WHEN** 用户快速购买2个加油包
|
||||
- **THEN** 第1个加油包激活 → 触发复机成功
|
||||
- **AND** 第2个加油包激活 → 检测到已是 active 状态,跳过复机
|
||||
- **AND** 运营商复机接口调用仅1次
|
||||
|
||||
#### Scenario: 购买主套餐自动复机
|
||||
- **GIVEN** 卡 C1 已停机,主套餐过期
|
||||
- **WHEN** 用户购买新主套餐,激活成功
|
||||
- **THEN** 系统自动触发复机
|
||||
|
||||
#### Scenario: 复机失败容错
|
||||
- **GIVEN** 卡已停机
|
||||
- **WHEN** 购买加油包激活,但运营商复机接口返回失败
|
||||
- **THEN** 系统重试3次
|
||||
- **AND** 仍失败后:
|
||||
- 套餐激活成功(status=active)
|
||||
- 卡状态仍为 stopped
|
||||
- 错误日志已记录
|
||||
- 告警通知运维
|
||||
|
||||
### Requirement: 复机延迟 < 2分钟
|
||||
|
||||
系统 SHALL 确保从套餐激活到卡复机完成的延迟 < 2分钟。
|
||||
|
||||
#### Scenario: 复机延迟达标
|
||||
- **GIVEN** 加油包在 2026-02-10 10:00:00 激活成功
|
||||
- **WHEN** 系统同步调用复机接口
|
||||
- **THEN** 复机完成时间 < 2026-02-10 10:02:00(延迟 < 2分钟)
|
||||
|
||||
#### Scenario: 复机失败后重试延迟
|
||||
- **GIVEN** 加油包激活,第1次复机调用失败
|
||||
- **WHEN** 系统重试3次(间隔1s/2s/4s)
|
||||
- **THEN** 复机在第3次重试成功,总延迟约7秒
|
||||
|
||||
---
|
||||
|
||||
## 数据模型变更
|
||||
|
||||
### tb_iot_card 新增字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| stopped_at | timestamp | 停机时间,NULL=未停机 |
|
||||
| resumed_at | timestamp | 最近复机时间 |
|
||||
| stop_reason | varchar(50) | 停机原因:`traffic_exhausted`, `manual`, `arrears` |
|
||||
|
||||
**索引**:
|
||||
- 无需索引(非查询字段,仅用于审计)
|
||||
|
||||
---
|
||||
|
||||
## 业务流程
|
||||
|
||||
### 流程1:流量耗尽停机
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[流量上报] --> B{所有套餐流量=0?}
|
||||
B -->|是| C{卡状态=active?}
|
||||
B -->|否| Z[结束]
|
||||
C -->|是| D[调用运营商停机接口]
|
||||
C -->|否| Z
|
||||
D --> E{停机成功?}
|
||||
E -->|是| F[更新卡状态=stopped]
|
||||
E -->|否| G[重试3次]
|
||||
F --> H[记录stopped_at]
|
||||
G --> E
|
||||
```
|
||||
|
||||
### 流程2:购买加油包复机
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[加油包激活成功] --> B{卡状态=stopped?}
|
||||
B -->|是| C[调用运营商复机接口]
|
||||
B -->|否| Z[跳过复机]
|
||||
C --> D{复机成功?}
|
||||
D -->|是| E[更新卡状态=active]
|
||||
D -->|否| F[重试3次]
|
||||
E --> G[清空stopped_at]
|
||||
F --> D
|
||||
F -->|3次失败| H[记录错误日志]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 并发场景
|
||||
|
||||
### Scenario: 并发停复机
|
||||
- **GIVEN** 卡流量刚好用完,同时用户购买加油包
|
||||
- **WHEN** 停机任务和复机任务并发执行
|
||||
- **THEN** 使用数据库行锁:
|
||||
```sql
|
||||
SELECT * FROM iot_card WHERE id=? FOR UPDATE
|
||||
```
|
||||
- **AND** 后执行的操作覆盖前一个操作的状态
|
||||
|
||||
### Scenario: 复机任务重复执行
|
||||
- **GIVEN** 用户购买2个加油包,触发2次复机
|
||||
- **WHEN** 第1次复机成功,卡状态=active
|
||||
- **THEN** 第2次复机检测到卡状态=active,跳过调用
|
||||
|
||||
---
|
||||
|
||||
## 异常处理
|
||||
|
||||
### 1. 停机接口超时
|
||||
|
||||
- **场景**:运营商停机接口响应超时(>5秒)
|
||||
- **处理**:
|
||||
1. 记录 Error 日志(包含卡号、超时时间)
|
||||
2. 重试3次,间隔1s/2s/4s
|
||||
3. 3次都失败:记录到死信队列,告警通知
|
||||
- **用户影响**:卡可能仍可上网(停机未成功)
|
||||
|
||||
### 2. 复机接口失败
|
||||
|
||||
- **场景**:运营商复机接口返回业务错误(如卡状态异常)
|
||||
- **处理**:
|
||||
1. 记录 Error 日志(包含卡号、错误码、错误消息)
|
||||
2. 重试3次
|
||||
3. 3次都失败:套餐激活成功,但卡保持停机状态
|
||||
4. 告警通知运维人工介入
|
||||
- **用户影响**:购买加油包后仍无法上网
|
||||
|
||||
### 3. 停复机状态不一致
|
||||
|
||||
- **场景**:系统记录已停机,但运营商侧仍正常
|
||||
- **处理**:
|
||||
1. 轮询系统定期同步卡状态
|
||||
2. 检测到不一致时记录 Warning 日志
|
||||
3. 自动修正系统状态(以运营商侧为准)
|
||||
- **修正频率**:每小时同步一次
|
||||
|
||||
---
|
||||
|
||||
## 性能指标
|
||||
|
||||
| 操作 | 目标响应时间 | 监控指标 |
|
||||
|------|------------|---------|
|
||||
| 停机接口调用 | < 5秒 | 运营商API耗时 |
|
||||
| 复机接口调用 | < 5秒 | 运营商API耗时 |
|
||||
| 停机条件检查 | < 50ms | SELECT COUNT查询耗时 |
|
||||
| 端到端停机延迟 | < 2分钟 | 流量用完到停机完成 |
|
||||
| 端到端复机延迟 | < 2分钟 | 套餐激活到复机完成 |
|
||||
|
||||
---
|
||||
|
||||
## 错误码定义
|
||||
|
||||
| 错误码 | HTTP 状态码 | 错误消息 | 场景 |
|
||||
|--------|------------|---------|------|
|
||||
| CodeInternal | 500 | 停机操作失败,请重试 | 运营商停机接口失败 |
|
||||
| CodeInternal | 500 | 复机操作失败,请重试 | 运营商复机接口失败 |
|
||||
|
||||
---
|
||||
|
||||
## 测试场景矩阵
|
||||
|
||||
| 维度 | 场景 | 预期结果 |
|
||||
|------|------|---------|
|
||||
| **停机** | 所有套餐流量用完 | 自动停机 |
|
||||
| | 主套餐用完+加油包剩余 | 不停机 |
|
||||
| | 停机接口失败 | 重试3次,失败告警 |
|
||||
| | 已停机重复检测 | 跳过停机 |
|
||||
| **复机** | 购买加油包 | 自动复机 |
|
||||
| | 购买主套餐 | 自动复机 |
|
||||
| | 复机接口失败 | 重试3次,套餐激活成功,卡保持停机 |
|
||||
| | 并发购买2个加油包 | 复机接口调用1次 |
|
||||
| **延迟** | 复机延迟 | < 2分钟 |
|
||||
| | 停机延迟 | < 2分钟 |
|
||||
| **异常** | 停机超时 | 重试后告警 |
|
||||
| | 状态不一致 | 轮询同步修正 |
|
||||
|
||||
---
|
||||
|
||||
## 实现参考
|
||||
|
||||
### Service 层:CheckAndStop
|
||||
|
||||
```go
|
||||
func (s *Service) CheckAndStopCard(ctx context.Context, cardID uint) error {
|
||||
// 1. 查询卡信息
|
||||
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. 检查卡状态
|
||||
if card.NetworkStatus != constants.NetworkStatusActive {
|
||||
return nil // 已停机,跳过
|
||||
}
|
||||
|
||||
// 3. 检查是否有剩余流量
|
||||
hasAvailableData, err := s.packageUsageStore.HasAvailableData(ctx, cardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if hasAvailableData {
|
||||
return nil // 有剩余流量,不停机
|
||||
}
|
||||
|
||||
// 4. 调用运营商停机接口(带重试)
|
||||
err = s.carrierClient.StopCard(ctx, card.ICCID, 3)
|
||||
if err != nil {
|
||||
s.logger.Error("停机失败",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
// 5. 更新卡状态
|
||||
err = s.iotCardStore.UpdateStopStatus(ctx, cardID, time.Now(), "traffic_exhausted")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 6. 记录审计日志
|
||||
s.auditService.LogOperation(ctx, &model.OperationLog{
|
||||
OperationType: "card_stop",
|
||||
OperationDesc: "流量耗尽自动停机",
|
||||
TargetID: cardID,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### Service 层:ResumeCard
|
||||
|
||||
```go
|
||||
func (s *Service) ResumeCardIfStopped(ctx context.Context, cardID uint) error {
|
||||
// 1. 查询卡信息
|
||||
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. 检查卡状态
|
||||
if card.NetworkStatus != constants.NetworkStatusStopped {
|
||||
return nil // 未停机,跳过
|
||||
}
|
||||
|
||||
// 3. 调用运营商复机接口(带重试)
|
||||
err = s.carrierClient.ResumeCard(ctx, card.ICCID, 3)
|
||||
if err != nil {
|
||||
s.logger.Error("复机失败",
|
||||
zap.Uint("card_id", cardID),
|
||||
zap.Error(err))
|
||||
// 复机失败不阻塞套餐激活
|
||||
return nil
|
||||
}
|
||||
|
||||
// 4. 更新卡状态
|
||||
err = s.iotCardStore.UpdateResumeStatus(ctx, cardID, time.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 5. 记录审计日志
|
||||
s.auditService.LogOperation(ctx, &model.OperationLog{
|
||||
OperationType: "card_resume",
|
||||
OperationDesc: "购买套餐自动复机",
|
||||
TargetID: cardID,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**本 Spec 完成**,包含:
|
||||
- ✅ 业务背景和业务规则
|
||||
- ✅ 详细场景(停机、复机、幂等性、容错)
|
||||
- ✅ 数据模型变更
|
||||
- ✅ 业务流程图
|
||||
- ✅ 并发场景和异常处理
|
||||
- ✅ 性能指标和错误码定义
|
||||
- ✅ 测试场景矩阵和实现参考
|
||||
|
||||
---
|
||||
|
||||
## 迭代更新(fix-polling-coverage-gaps)
|
||||
|
||||
### ADDED Requirement: 套餐过期时立即触发绑定卡停机
|
||||
|
||||
当主套餐过期处理完成后,若该套餐的载体(卡或设备)无任何后续生效套餐,系统 SHALL 立即异步触发停机检查,不依赖下一次 carddata 轮询兜底。
|
||||
|
||||
**触发位置**:`PackageActivationHandler.processExpiredPackage` 在执行 `updateCarrierSuspendedStatus` 后,若载体确认无生效套餐,异步调用 `StopResumeCallback.CheckAndStopCard`(iot_card 类型)或遍历绑定卡逐一触发(device 类型)。
|
||||
|
||||
**幂等保护**:`CheckAndStopCard` 已有"卡已停机则跳过"逻辑,重复触发安全。
|
||||
|
||||
**与 carddata 轮询的关系**:此处主动触发为优化项(消除延迟窗口),carddata 轮询的 `checkStopResume` 仍作为兜底保障,两者共存不冲突。
|
||||
|
||||
#### Scenario: 主套餐过期且无后续套餐,载体为 iot_card
|
||||
|
||||
- **WHEN** `HandlePackageActivationCheck` 检测到一张卡的主套餐已过期(`expires_at <= NOW`),执行 `processExpiredPackage` 后确认该卡无待生效或生效中的套餐
|
||||
- **THEN** 系统异步调用 `CheckAndStopCard(cardID)`,将卡停机(`network_status = 0`,`stop_reason = "traffic_exhausted"`),不等待网关响应
|
||||
|
||||
#### Scenario: 主套餐过期且无后续套餐,载体为 device
|
||||
|
||||
- **WHEN** `HandlePackageActivationCheck` 检测到一个设备的主套餐已过期,确认该设备无后续套餐
|
||||
- **THEN** 系统查询设备绑定的所有在线已实名卡,逐一异步触发 `CheckAndStopCard`
|
||||
|
||||
#### Scenario: 主套餐过期但有排队待生效套餐
|
||||
|
||||
- **WHEN** `processExpiredPackage` 处理过期套餐,`activateNextPackage` 成功激活了下一个待生效套餐
|
||||
- **THEN** 系统不触发停机(因有生效套餐),`updateCarrierSuspendedStatus` 检查后确认有生效套餐即返回
|
||||
|
||||
#### Scenario: carddata 轮询兜底仍有效
|
||||
|
||||
- **WHEN** 套餐过期主动停机的异步触发因 gateway 异常失败,卡仍处于在线状态
|
||||
- **THEN** 下一次 `HandleCarddataCheck` 的 `checkStopResume` 检测到在线无套餐,再次尝试停机(兜底保障)
|
||||
|
||||
---
|
||||
|
||||
## 迭代更新(fix-stop-resume-lifecycle-engine)
|
||||
|
||||
### MODIFIED Requirement: 流量耗尽自动复机
|
||||
|
||||
系统 SHALL 在套餐激活或流量重置后,对满足以下全部条件的卡自动调用运营商接口复机:
|
||||
1. `stop_reason = "traffic_exhausted"`(仅限流量耗尽停机,手动停机不自动复机)
|
||||
2. `real_name_status = 1`(已完成实名认证)
|
||||
3. `network_status = 0`(当前处于停机状态)
|
||||
|
||||
系统 SHALL 在执行复机前获取 Redis 分布式锁(Key:`card:resume:lock:{cardID}`,TTL 30s),防止并发重复调用 Gateway。
|
||||
|
||||
#### Scenario: 所有条件满足时自动复机
|
||||
- **GIVEN** 卡 C1 满足:`stop_reason=traffic_exhausted`,`real_name_status=1`,`network_status=0`
|
||||
- **WHEN** `ResumeCardIfStopped(ctx, "iot_card", cardID)` 被调用
|
||||
- **THEN** 系统获取分布式锁,调用 `gateway.StartCard`,更新 `network_status=1`,清空 `stop_reason`,释放锁
|
||||
|
||||
#### Scenario: 未实名卡跳过自动复机
|
||||
- **GIVEN** 卡 C2 `stop_reason=traffic_exhausted`,`real_name_status=0`,`network_status=0`
|
||||
- **WHEN** `ResumeCardIfStopped` 被调用
|
||||
- **THEN** 系统记录 WARN 日志,静默跳过,不调用 Gateway,函数返回 nil
|
||||
|
||||
#### Scenario: 手动停机的卡不被自动复机
|
||||
- **GIVEN** 卡 C3 `stop_reason=manual`,`real_name_status=1`,`network_status=0`
|
||||
- **WHEN** `ResumeCardIfStopped` 被调用
|
||||
- **THEN** 系统检测到 `stop_reason != traffic_exhausted`,静默跳过
|
||||
|
||||
#### Scenario: 并发调用时只有一次复机执行
|
||||
- **GIVEN** 卡 C4 满足复机条件,复机操作正在执行(分布式锁被持有)
|
||||
- **WHEN** 第二个 `ResumeCardIfStopped` 调用同时到达
|
||||
- **THEN** 第二个调用获锁失败,直接返回,不调用 Gateway
|
||||
|
||||
#### Scenario: 设备类型载体复机遍历所有绑定卡
|
||||
- **GIVEN** 设备 D1 绑定 3 张卡(C1 已实名停机、C2 已实名停机、C3 未实名停机)
|
||||
- **WHEN** `ResumeCardIfStopped(ctx, "device", deviceID)` 被调用
|
||||
- **THEN** C1 和 C2 自动复机,C3 因未实名跳过
|
||||
|
||||
### ADDED Requirement: 套餐超额停机必须写入 stop_reason 和 stopped_at
|
||||
|
||||
系统 SHALL 在套餐虚流量超额触发停机时(`stopCards` 函数),写入 `stop_reason=traffic_exhausted` 和 `stopped_at` 到 IoT 卡记录。
|
||||
|
||||
#### Scenario: 虚流量超额停机完整写入 DB
|
||||
- **GIVEN** 卡 C1 当月流量超过套餐 `virtual_data_mb` 上限,`network_status=1`
|
||||
- **WHEN** `HandlePackageCheck` 的 `stopCards` 执行
|
||||
- **THEN** Gateway 调用成功后,DB 更新包含:`network_status=0`,`stop_reason=traffic_exhausted`,`stopped_at=now()`,`updated_at=now()`
|
||||
|
||||
### ADDED Requirement: 保护期停机使用常量 stop_reason
|
||||
|
||||
系统 SHALL 在保护期一致性检查停机时,使用 `constants.StopReasonProtectPeriod` 常量(值:`protect_period`)记录 `stop_reason`。
|
||||
|
||||
#### Scenario: 保护期停机写入正确原因
|
||||
- **GIVEN** 设备处于停机保护期,绑定的卡 C1 `network_status=1`(开机状态不一致)
|
||||
- **WHEN** `HandleProtectConsistencyCheck` 触发停机
|
||||
- **THEN** DB 中 `stop_reason=protect_period`(使用常量,非中文硬编码)
|
||||
- **AND** `ResumeCardIfStopped` 调用时因 `stop_reason != traffic_exhausted` 跳过,不会错误触发自动复机
|
||||
@@ -1,143 +0,0 @@
|
||||
# b-end-auth Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change implement-b-end-auth-system. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: B 端用户登录
|
||||
系统 SHALL 支持后台管理员、代理商和企业用户通过用户名/手机号和密码进行登录认证。
|
||||
|
||||
#### Scenario: 后台管理员登录成功
|
||||
- **WHEN** 用户访问 `POST /api/admin/login` 并提供有效的用户名和密码
|
||||
- **THEN** 系统验证凭据,生成 access token 和 refresh token,返回 token 和用户信息
|
||||
|
||||
#### Scenario: H5 端代理商登录成功
|
||||
- **WHEN** 用户访问 `POST /api/h5/login` 并提供有效的用户名和密码
|
||||
- **THEN** 系统验证凭据,生成 access token 和 refresh token,返回 token 和用户信息
|
||||
|
||||
#### Scenario: 登录失败 - 凭据无效
|
||||
- **WHEN** 用户提供错误的用户名或密码
|
||||
- **THEN** 系统返回 401 错误,错误码 1040,消息"用户名或密码错误"
|
||||
|
||||
#### Scenario: 登录失败 - 账号已禁用
|
||||
- **WHEN** 用户账号状态为禁用
|
||||
- **THEN** 系统返回 403 错误,错误码 1041,消息"账号已被锁定或禁用"
|
||||
|
||||
### Requirement: Token 管理
|
||||
系统 SHALL 使用 Redis 存储的双令牌机制管理用户会话,包括 access token(24小时有效)和 refresh token(7天有效)。
|
||||
|
||||
#### Scenario: 生成 Token 对
|
||||
- **WHEN** 用户登录成功
|
||||
- **THEN** 系统生成随机 UUID 作为 access token 和 refresh token,将用户信息(UserID、UserType、ShopID、EnterpriseID、Username、Device、IP、LoginTime)存储到 Redis,设置相应的 TTL
|
||||
|
||||
#### Scenario: 验证 Access Token
|
||||
- **WHEN** 请求受保护的 API 端点时,在 Authorization 头中提供 Bearer token
|
||||
- **THEN** 系统从 Redis 查询 token 对应的用户信息,验证 token 有效性,将用户信息注入到请求上下文
|
||||
|
||||
#### Scenario: Token 过期
|
||||
- **WHEN** access token 超过 24 小时未使用
|
||||
- **THEN** Redis 自动删除 token,后续验证返回 401 错误,错误码 1002,消息"令牌无效或已过期"
|
||||
|
||||
#### Scenario: Token 不存在
|
||||
- **WHEN** 提供的 token 在 Redis 中不存在
|
||||
- **THEN** 系统返回 401 错误,错误码 1002,消息"令牌无效或已过期"
|
||||
|
||||
### Requirement: 用户登出
|
||||
系统 SHALL 支持用户主动登出,撤销当前使用的 access token 和 refresh token。
|
||||
|
||||
#### Scenario: 成功登出
|
||||
- **WHEN** 用户访问 `POST /api/admin/logout` 或 `POST /api/h5/logout` 并提供有效的 token
|
||||
- **THEN** 系统从 Redis 删除对应的 access token 和 refresh token,并从用户 token 列表中移除,返回成功响应
|
||||
|
||||
#### Scenario: 已登出的 Token 无法再使用
|
||||
- **WHEN** 用户登出后,使用相同的 token 访问受保护端点
|
||||
- **THEN** 系统返回 401 错误,消息"令牌无效或已过期"
|
||||
|
||||
### Requirement: Token 刷新
|
||||
系统 SHALL 支持使用 refresh token 刷新 access token,延长会话有效期而无需重新登录。
|
||||
|
||||
#### Scenario: 成功刷新 Access Token
|
||||
- **WHEN** 用户访问 `POST /api/admin/refresh-token` 或 `POST /api/h5/refresh-token` 并提供有效的 refresh token
|
||||
- **THEN** 系统验证 refresh token,生成新的 access token(保持 refresh token 不变),返回新的 access token
|
||||
|
||||
#### Scenario: Refresh Token 无效
|
||||
- **WHEN** 提供的 refresh token 不存在或已过期
|
||||
- **THEN** 系统返回 401 错误,错误码 1002,消息"刷新令牌无效或已过期"
|
||||
|
||||
### Requirement: 获取当前用户信息
|
||||
系统 SHALL 支持已认证用户查询当前用户的详细信息和权限列表。
|
||||
|
||||
#### Scenario: 成功获取用户信息
|
||||
- **WHEN** 用户访问 `GET /api/admin/me` 或 `GET /api/h5/me` 并提供有效的 access token
|
||||
- **THEN** 系统从 token 解析用户 ID,查询数据库获取用户信息(ID、用户名、手机号、用户类型、店铺 ID、企业 ID)和权限列表,返回完整的用户信息
|
||||
|
||||
#### Scenario: Token 无效时无法获取用户信息
|
||||
- **WHEN** 提供无效或过期的 token
|
||||
- **THEN** 系统在中间件层拦截,返回 401 错误
|
||||
|
||||
### Requirement: 修改密码
|
||||
系统 SHALL 支持已认证用户修改自己的密码,并在密码修改后撤销所有旧 token。
|
||||
|
||||
#### Scenario: 成功修改密码
|
||||
- **WHEN** 用户访问 `PUT /api/admin/password` 或 `PUT /api/h5/password`,提供旧密码和新密码
|
||||
- **THEN** 系统验证旧密码,使用 bcrypt 哈希新密码并更新数据库,撤销用户所有 token(包括当前使用的 token),返回成功响应
|
||||
|
||||
#### Scenario: 旧密码错误
|
||||
- **WHEN** 提供的旧密码不正确
|
||||
- **THEN** 系统返回 400 错误,错误码 1043,消息"旧密码不正确"
|
||||
|
||||
#### Scenario: 密码修改后旧 Token 失效
|
||||
- **WHEN** 用户修改密码后,使用旧的 token 访问任何端点
|
||||
- **THEN** 系统返回 401 错误,消息"令牌无效或已过期"
|
||||
|
||||
### Requirement: 多端认证隔离
|
||||
系统 SHALL 通过认证中间件实现后台和 H5 端的用户类型隔离,确保不同端点只能被对应用户类型访问。
|
||||
|
||||
#### Scenario: 后台端点用户类型验证
|
||||
- **WHEN** 用户访问 `/api/admin/*` 端点
|
||||
- **THEN** 认证中间件验证用户类型必须为 SuperAdmin(1)、Platform(2) 或 Agent(3),否则返回 403 错误
|
||||
|
||||
#### Scenario: H5 端点用户类型验证
|
||||
- **WHEN** 用户访问 `/api/h5/*` 端点
|
||||
- **THEN** 认证中间件验证用户类型必须为 Agent(3) 或 Enterprise(4),否则返回 403 错误
|
||||
|
||||
#### Scenario: 公开端点无需认证
|
||||
- **WHEN** 用户访问 `/api/admin/login`、`/api/admin/refresh-token`、`/api/h5/login` 或 `/api/h5/refresh-token`
|
||||
- **THEN** 中间件跳过认证检查,允许匿名访问
|
||||
|
||||
### Requirement: Token 批量撤销
|
||||
系统 SHALL 支持撤销指定用户的所有 token,用于密码修改或账号禁用场景。
|
||||
|
||||
#### Scenario: 撤销用户所有 Token
|
||||
- **WHEN** 调用 `RevokeAllUserTokens(userID)` 方法(内部使用,密码修改时触发)
|
||||
- **THEN** 系统从 Redis 查询用户 token 列表(`auth:user:{userID}:tokens`),删除所有 access token 和 refresh token 及其对应的用户信息,清空 token 列表
|
||||
|
||||
#### Scenario: 撤销不存在用户的 Token
|
||||
- **WHEN** 调用 `RevokeAllUserTokens` 但用户没有任何活跃 token
|
||||
- **THEN** 系统不报错,直接返回成功
|
||||
|
||||
### Requirement: 并发安全
|
||||
系统 SHALL 保证 Token 管理器在高并发场景下的线程安全和数据一致性。
|
||||
|
||||
#### Scenario: 并发生成 Token
|
||||
- **WHEN** 同一用户在不同设备上同时登录(多个并发请求)
|
||||
- **THEN** 每个请求生成独立的 token 对,所有 token 都有效,互不干扰
|
||||
|
||||
#### Scenario: 并发撤销 Token
|
||||
- **WHEN** 多个请求同时撤销同一 token
|
||||
- **THEN** Redis 操作原子性保证只有一个请求成功删除,其他请求不报错
|
||||
|
||||
### Requirement: 性能要求
|
||||
系统 SHALL 满足以下性能指标。
|
||||
|
||||
#### Scenario: 登录响应时间
|
||||
- **WHEN** 用户发起登录请求
|
||||
- **THEN** API P95 响应时间 < 200ms,P99 响应时间 < 500ms
|
||||
|
||||
#### Scenario: Token 验证响应时间
|
||||
- **WHEN** 请求受保护端点触发 token 验证
|
||||
- **THEN** Redis 查询时间 < 50ms
|
||||
|
||||
#### Scenario: Token 生成唯一性
|
||||
- **WHEN** 系统生成 token
|
||||
- **THEN** 使用 UUID v4 保证全局唯一性,碰撞概率 < 10^-15
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
# bootstrap-init Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change deployment-self-init. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: 集中化目录初始化
|
||||
|
||||
系统 SHALL 在应用启动时通过 `bootstrap.EnsureDirectories()` 函数统一创建所有必需的运行时目录。
|
||||
|
||||
目录列表:
|
||||
- 临时文件目录(从 `config.Storage.TempDir` 读取)
|
||||
- 应用日志目录(从 `config.Logging.AppLog.Filename` 提取目录部分)
|
||||
- 访问日志目录(从 `config.Logging.AccessLog.Filename` 提取目录部分)
|
||||
|
||||
#### Scenario: 成功创建所有目录
|
||||
|
||||
- **WHEN** 应用启动且所有目录路径可写
|
||||
- **THEN** 系统创建所有必需目录,权限为 0755
|
||||
- **AND** 函数返回 nil
|
||||
|
||||
#### Scenario: 目录已存在
|
||||
|
||||
- **WHEN** 应用启动且目录已存在
|
||||
- **THEN** 系统跳过创建,不报错
|
||||
- **AND** 函数返回 nil
|
||||
|
||||
#### Scenario: 配置路径为空
|
||||
|
||||
- **WHEN** 某个目录配置为空字符串
|
||||
- **THEN** 系统跳过该目录的创建
|
||||
- **AND** 不影响其他目录的创建
|
||||
|
||||
### Requirement: 权限降级策略
|
||||
|
||||
系统 SHALL 在目录创建权限不足时自动降级到系统临时目录。
|
||||
|
||||
#### Scenario: 权限不足时降级
|
||||
|
||||
- **WHEN** 创建目录因权限不足失败(os.IsPermission 为 true)
|
||||
- **THEN** 系统使用 `os.TempDir()/junhong/<原目录名>` 作为降级路径
|
||||
- **AND** 记录 WARN 级别日志,包含原路径和降级路径
|
||||
- **AND** 函数返回降级后的路径
|
||||
|
||||
#### Scenario: 非权限错误
|
||||
|
||||
- **WHEN** 创建目录失败且不是权限问题
|
||||
- **THEN** 系统返回错误,应用启动失败
|
||||
- **AND** 错误信息包含目录路径和原始错误
|
||||
|
||||
### Requirement: 初始化顺序
|
||||
|
||||
系统 SHALL 确保目录初始化在所有组件初始化之前完成。
|
||||
|
||||
#### Scenario: 正确的初始化顺序
|
||||
|
||||
- **WHEN** 应用启动
|
||||
- **THEN** 执行顺序为:
|
||||
1. config.Load() 加载配置
|
||||
2. bootstrap.EnsureDirectories() 创建目录
|
||||
3. logger.Init() 初始化日志
|
||||
4. 其他组件初始化
|
||||
|
||||
#### Scenario: 目录初始化失败
|
||||
|
||||
- **WHEN** `bootstrap.EnsureDirectories()` 返回错误
|
||||
- **THEN** 应用立即退出,不继续初始化其他组件
|
||||
- **AND** 错误信息输出到 stderr
|
||||
|
||||
### Requirement: 移除分散的目录创建逻辑
|
||||
|
||||
系统 SHALL 移除各组件中分散的目录创建代码。
|
||||
|
||||
#### Scenario: S3Provider 不再创建目录
|
||||
|
||||
- **WHEN** 初始化 S3Provider
|
||||
- **THEN** 不再调用 `os.MkdirAll` 创建临时目录
|
||||
- **AND** 假设目录已由 bootstrap 创建
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
# 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 表
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 批量设置卡的套餐系列
|
||||
|
||||
系统 SHALL 允许代理批量为 IoT 卡设置套餐系列分配。只能设置当前店铺被分配且启用的套餐系列。
|
||||
|
||||
#### Scenario: 成功批量设置
|
||||
- **WHEN** 代理提交多个 ICCID 和一个有效的 series_allocation_id
|
||||
- **THEN** 系统更新这些卡的 series_allocation_id 字段
|
||||
|
||||
#### Scenario: 系列未分配给店铺
|
||||
- **WHEN** 代理尝试设置一个未分配给卡所属店铺的系列
|
||||
- **THEN** 系统返回错误 "该套餐系列未分配给此店铺"
|
||||
|
||||
#### Scenario: 系列分配已禁用
|
||||
- **WHEN** 代理尝试设置一个已禁用的系列分配
|
||||
- **THEN** 系统返回错误 "该套餐系列分配已禁用"
|
||||
|
||||
#### Scenario: ICCID 不存在
|
||||
- **WHEN** 提交的 ICCID 中有不存在的卡
|
||||
- **THEN** 系统返回错误,列出不存在的 ICCID
|
||||
|
||||
#### Scenario: 卡不属于当前店铺
|
||||
- **WHEN** 代理尝试设置不属于自己店铺的卡
|
||||
- **THEN** 系统返回错误 "部分卡不属于您的店铺"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 清除卡的套餐系列关联
|
||||
|
||||
系统 SHALL 允许代理清除卡的套餐系列关联(将 series_allocation_id 设为 0)。
|
||||
|
||||
#### Scenario: 清除单卡关联
|
||||
- **WHEN** 代理将卡的 series_allocation_id 设为 0
|
||||
- **THEN** 系统清除该卡的套餐系列关联
|
||||
|
||||
#### Scenario: 批量清除关联
|
||||
- **WHEN** 代理批量提交 ICCID 列表,series_allocation_id 为 0
|
||||
- **THEN** 系统清除这些卡的套餐系列关联
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 查询卡的套餐系列信息
|
||||
|
||||
系统 SHALL 在卡详情和列表中返回套餐系列关联信息。
|
||||
|
||||
#### Scenario: 卡详情包含系列信息
|
||||
- **WHEN** 查询卡详情
|
||||
- **THEN** 响应包含 series_allocation_id、关联的系列名称、佣金状态
|
||||
|
||||
#### Scenario: 卡列表支持按系列筛选
|
||||
- **WHEN** 代理按 series_allocation_id 筛选卡列表
|
||||
- **THEN** 系统只返回关联该系列的卡
|
||||
|
||||
---
|
||||
|
||||
### Requirement: IotCard 模型新增字段
|
||||
|
||||
系统 MUST 在 IotCard 模型中新增以下字段:
|
||||
- `series_allocation_id`:套餐系列分配 ID
|
||||
- `first_commission_paid`:一次性佣金是否已发放(默认 false)
|
||||
- `accumulated_recharge`:累计充值金额(默认 0)
|
||||
|
||||
#### Scenario: 新卡默认值
|
||||
- **WHEN** 创建新的 IoT 卡
|
||||
- **THEN** series_allocation_id 为空,first_commission_paid 为 false,accumulated_recharge 为 0
|
||||
|
||||
#### Scenario: 字段在响应中可见
|
||||
- **WHEN** 查询卡信息
|
||||
- **THEN** 响应包含这三个新字段
|
||||
@@ -1,274 +0,0 @@
|
||||
# card-wallet Specification
|
||||
|
||||
## Purpose
|
||||
资产钱包系统,提供物联网卡和设备级别的钱包管理,支持充值、套餐扣费、余额查询等操作。与代理钱包完全隔离,独立的数据表和代码实现。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 资产钱包实体定义
|
||||
|
||||
系统 SHALL 定义资产钱包(AssetWallet)实体,管理物联网卡和设备级别的钱包,支持资源转手场景。原 `CardWallet` / `tb_card_wallet` 全量改名为 `AssetWallet` / `tb_asset_wallet`。
|
||||
|
||||
**核心概念**:
|
||||
- **物联网卡钱包**:归属单张物联网卡,卡转手时钱包跟着卡走
|
||||
- **设备钱包**:归属设备(含1-4张卡),设备的多张卡共享钱包,设备转手时钱包跟着设备走
|
||||
|
||||
**实体字段(与原 CardWallet 完全一致,仅表名改变)**:
|
||||
- `id`:钱包 ID(主键,BIGINT,自增)
|
||||
- `resource_type`:资源类型(VARCHAR(20),枚举值:"iot_card" | "device")
|
||||
- `resource_id`:资源 ID(BIGINT)
|
||||
- `balance`:余额(BIGINT,单位:分,默认 0)
|
||||
- `frozen_balance`:冻结余额(BIGINT,单位:分,默认 0)
|
||||
- `currency`:币种(VARCHAR(10),默认 "CNY")
|
||||
- `status`:钱包状态(INT,1-正常 2-冻结 3-关闭,默认 1)
|
||||
- `version`:版本号(INT,乐观锁)
|
||||
- `shop_id_tag`:店铺 ID 标签(多租户过滤)
|
||||
- `enterprise_id_tag`:企业 ID 标签(可空)
|
||||
- `created_at` / `updated_at` / `deleted_at`
|
||||
|
||||
**表名变更**:`tb_card_wallet` → `tb_asset_wallet`
|
||||
|
||||
**唯一约束**:`(resource_type, resource_id)` 在 `deleted_at IS NULL` 条件下唯一
|
||||
|
||||
**可用余额计算**:可用余额 = balance - frozen_balance
|
||||
|
||||
#### Scenario: 创建物联网卡钱包
|
||||
|
||||
- **WHEN** 个人客户通过 ICCID "8986001234567890" 登录,为该卡充值
|
||||
- **THEN** 系统创建钱包记录写入 `tb_asset_wallet`,`resource_type` 为 "iot_card",`resource_id` 为卡 ID
|
||||
|
||||
#### Scenario: 创建设备钱包
|
||||
|
||||
- **WHEN** 个人客户通过设备号登录,为设备充值
|
||||
- **THEN** 系统创建钱包记录写入 `tb_asset_wallet`,`resource_type` 为 "device",设备的所有卡共享该钱包
|
||||
|
||||
#### Scenario: 计算可用余额
|
||||
|
||||
- **WHEN** 钱包余额 10000 分,冻结余额 3000 分
|
||||
- **THEN** 可用余额 = 7000 分
|
||||
|
||||
#### Scenario: 防止同一资源重复创建钱包
|
||||
|
||||
- **WHEN** 物联网卡(ID=100)已有钱包,尝试再次创建
|
||||
- **THEN** 系统拒绝,返回错误"该资源已存在钱包"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 资产钱包交易记录
|
||||
|
||||
系统 SHALL 记录所有资产钱包余额变动,包括充值、套餐扣费、退款,确保完整收支审计追踪。原 `CardWalletTransaction` / `tb_card_wallet_transaction` 全量改名为 `AssetWalletTransaction` / `tb_asset_wallet_transaction`,同时 `reference_id (bigint)` 字段改为 `reference_no (varchar 50)`。
|
||||
|
||||
**实体字段**:
|
||||
- `id`:交易记录 ID(主键)
|
||||
- `asset_wallet_id`:资产钱包 ID(关联 `tb_asset_wallet.id`,原 `card_wallet_id`)
|
||||
- `resource_type`:资源类型(冗余字段)
|
||||
- `resource_id`:资源 ID(冗余字段)
|
||||
- `user_id`:操作人用户 ID
|
||||
- `transaction_type`:交易类型(`recharge` / `deduct` / `refund`)
|
||||
- `amount`:变动金额(分,充值为正,扣款/退款为负)
|
||||
- `balance_before`:变动前余额(分)
|
||||
- `balance_after`:变动后余额(分)
|
||||
- `status`:交易状态(1-成功 2-失败 3-处理中)
|
||||
- `reference_type`:关联业务类型(`recharge` 或 `order`,可空)
|
||||
- `reference_no`:关联业务编号,存储充值单号(`CRCH…`)或订单号(`ORD…`)(VARCHAR(50),可空)— **原字段 `reference_id (bigint)` 改名并变更类型**
|
||||
- `remark`:备注(TEXT,可空)
|
||||
- `metadata`:扩展信息(JSONB,可空)
|
||||
- `creator`:创建人 ID
|
||||
- `shop_id_tag` / `enterprise_id_tag`:多租户标签
|
||||
|
||||
**表名变更**:`tb_card_wallet_transaction` → `tb_asset_wallet_transaction`
|
||||
|
||||
**字段变更**:`reference_id bigint` → `reference_no varchar(50)`
|
||||
|
||||
#### Scenario: 充值写入流水记录
|
||||
|
||||
- **WHEN** 个人客户完成充值(充值单号 CRCH20260309001,金额 100 元),充值回调成功
|
||||
- **THEN** 系统在 `tb_asset_wallet_transaction` 写入一条记录:`transaction_type="recharge"`,`amount=10000`,`reference_type="recharge"`,`reference_no="CRCH20260309001"`
|
||||
|
||||
#### Scenario: 钱包支付套餐写入扣款流水
|
||||
|
||||
- **WHEN** 个人客户使用钱包支付套餐订单(订单号 ORD20260310001,金额 30 元),`WalletPay` 执行成功
|
||||
- **THEN** 系统在同一事务内向 `tb_asset_wallet_transaction` 写入一条记录:`transaction_type="deduct"`,`amount=-3000`,`reference_type="order"`,`reference_no="ORD20260310001"`,`balance_before` 为扣款前余额,`balance_after` = `balance_before - 3000`
|
||||
|
||||
#### Scenario: 充值流水 reference_no 格式
|
||||
|
||||
- **WHEN** 系统写入充值流水
|
||||
- **THEN** `reference_no` 存储充值单号(格式:`CRCH` + 时间戳 + 随机数),而非数据库主键 ID
|
||||
|
||||
#### Scenario: 扣款流水 reference_no 格式
|
||||
|
||||
- **WHEN** 系统写入扣款流水
|
||||
- **THEN** `reference_no` 存储订单号(格式:`ORD` + 时间戳 + 6位随机数),而非数据库主键 ID
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 充值记录表改名
|
||||
|
||||
系统 SHALL 将原 `tb_card_recharge_record` 表重命名为 `tb_asset_recharge_record`,对应 Go 类型由 `CardRechargeRecord` 改名为 `AssetRechargeRecord`。H5 充值接口 JSON 响应字段 `wallet_id` 不变(保持向后兼容)。
|
||||
|
||||
#### Scenario: H5 充值接口字段不变
|
||||
|
||||
- **WHEN** 前端调用 `GET /api/h5/wallets/recharges/:id`,充值记录关联的钱包 ID 为 123
|
||||
- **THEN** 响应 JSON 中 `wallet_id` 仍为 `123`,JSON 字段名不变(仅 Go 内部字段名从 `CardWalletID` 改为 `AssetWalletID`)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 资产钱包余额操作
|
||||
|
||||
系统 SHALL 支持资产钱包余额的充值、扣款、退款等操作,使用乐观锁防止并发问题。
|
||||
|
||||
**操作类型**:
|
||||
- **充值**:增加钱包余额
|
||||
- **扣款**:减少钱包余额(如购买套餐)
|
||||
- **退款**:增加钱包余额(如订单退款)
|
||||
|
||||
**并发控制**:
|
||||
- 使用 `version` 字段实现乐观锁
|
||||
- 每次更新余额时,检查 `version` 是否匹配
|
||||
- 如果 `version` 不匹配,说明有并发更新,操作失败并重试
|
||||
|
||||
**操作约束**:
|
||||
- 扣款时,检查可用余额(balance - frozen_balance)是否充足
|
||||
- 所有余额变动必须创建交易记录
|
||||
|
||||
#### Scenario: 资产钱包充值
|
||||
|
||||
- **WHEN** 资产钱包当前余额为 10000 分,充值 5000 分
|
||||
- **THEN** 系统将钱包余额更新为 15000 分,`version` 从 1 变更为 2,创建交易记录(`transaction_type` 为 "recharge",`amount` 为 5000)
|
||||
|
||||
#### Scenario: 资产钱包扣款
|
||||
|
||||
- **WHEN** 资产钱包当前余额为 15000 分,购买套餐扣款 3000 分
|
||||
- **THEN** 系统检查可用余额(15000 - 0 = 15000)≥ 3000,将钱包余额更新为 12000 分,`version` 从 2 变更为 3,创建交易记录(`transaction_type` 为 "deduct",`amount` 为 -3000)
|
||||
|
||||
#### Scenario: 余额不足扣款失败
|
||||
|
||||
- **WHEN** 资产钱包当前余额为 2000 分,购买套餐需要扣款 3000 分
|
||||
- **THEN** 系统检查可用余额(2000 - 0 = 2000)< 3000,拒绝扣款,返回错误信息"余额不足"
|
||||
|
||||
#### Scenario: 并发扣款乐观锁生效
|
||||
|
||||
- **WHEN** 资产钱包当前余额为 10000 分,version 为 1,两个并发请求同时扣款 3000 分和 5000 分
|
||||
- **THEN** 第一个请求成功,余额变为 7000 分,version 变为 2;第二个请求因 version 不匹配失败,需重新读取最新余额(7000 分)和 version(2)后重试
|
||||
|
||||
#### Scenario: 订单退款
|
||||
|
||||
- **WHEN** 资产钱包当前余额为 7000 分,订单退款 3000 分
|
||||
- **THEN** 系统将钱包余额更新为 10000 分,`version` 增加 1,创建交易记录(`transaction_type` 为 "refund",`amount` 为 3000)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 资产钱包数据校验
|
||||
|
||||
系统 SHALL 对资产钱包数据进行校验,确保数据完整性和一致性。
|
||||
|
||||
**校验规则**:
|
||||
- `resource_type`:必填,枚举值 "iot_card" | "device"
|
||||
- `resource_id`:必填,≥ 1,必须是有效的资源 ID
|
||||
- `balance`:必填,≥ 0
|
||||
- `frozen_balance`:必填,≥ 0,≤ balance
|
||||
- `currency`:必填,长度 1-10 字符,默认 "CNY"
|
||||
- `status`:必填,枚举值 1-3
|
||||
- `version`:必填,≥ 0
|
||||
|
||||
#### Scenario: 创建钱包时 resource_type 无效
|
||||
|
||||
- **WHEN** 创建资产钱包,`resource_type` 为 "invalid"
|
||||
- **THEN** 系统拒绝创建,返回错误信息"资源类型无效,必须是 iot_card 或 device"
|
||||
|
||||
#### Scenario: 创建钱包时 resource_id 无效
|
||||
|
||||
- **WHEN** 创建资产钱包,`resource_type` 为 "iot_card",`resource_id` 为 0
|
||||
- **THEN** 系统拒绝创建,返回错误信息"资源 ID 无效,必须 ≥ 1"
|
||||
|
||||
#### Scenario: 冻结余额超过总余额
|
||||
|
||||
- **WHEN** 资产钱包余额为 10000 分,尝试冻结 15000 分
|
||||
- **THEN** 系统拒绝操作,返回错误信息"冻结余额不能超过总余额"
|
||||
|
||||
#### Scenario: 余额为负数
|
||||
|
||||
- **WHEN** 尝试将资产钱包余额设置为 -10000 分
|
||||
- **THEN** 系统拒绝操作,返回错误信息"余额不能为负数"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 资产钱包归属资源转手规则
|
||||
|
||||
系统 SHALL 支持资产钱包随资源(物联网卡、设备)转手,新用户登录后可以看到钱包余额。
|
||||
|
||||
**归属规则**:
|
||||
|
||||
| 资源类型 | ResourceType | 适用场景 | 转手规则 |
|
||||
|---------|-------------|---------|---------|
|
||||
| 物联网卡 | iot_card | 个人客户购买单卡 | 钱包归属卡,卡转手时钱包跟着卡走 |
|
||||
| 设备 | device | 个人客户购买设备(含1-4张卡) | 钱包归属设备,设备的多张卡共享钱包,设备转手时钱包跟着设备走 |
|
||||
|
||||
**资源转手场景**:
|
||||
- 物联网卡转手:新用户通过 ICCID 登录后可以看到卡的钱包余额
|
||||
- 设备转手:新用户通过设备号登录后可以看到设备的钱包余额(包含绑定的所有卡)
|
||||
|
||||
#### Scenario: 个人客户购买单卡并充值
|
||||
|
||||
- **WHEN** 个人客户通过 ICCID "8986001234567890" 登录(首次登录),为该卡充值 10000 分
|
||||
- **THEN** 系统创建资产钱包记录,`resource_type` 为 "iot_card",`resource_id` 为卡 ID,`balance` 为 10000
|
||||
|
||||
#### Scenario: 个人客户购买设备并充值
|
||||
|
||||
- **WHEN** 个人客户通过设备号 "DEV-001" 登录(首次登录),该设备绑定 3 张卡,为设备充值 20000 分
|
||||
- **THEN** 系统创建资产钱包记录,`resource_type` 为 "device",`resource_id` 为设备 ID,设备的 3 张卡共享该钱包,`balance` 为 20000
|
||||
|
||||
#### Scenario: 卡转手后新用户查询余额
|
||||
|
||||
- **WHEN** 个人客户 A(微信 OpenID 为 "wx_a")的卡(ICCID 为 "8986001234567890")转手给个人客户 B(微信 OpenID 为 "wx_b"),钱包余额为 5000 分
|
||||
- **THEN** 个人客户 B 通过 ICCID "8986001234567890" 登录后查询钱包,余额为 5000 分,可以继续使用
|
||||
|
||||
#### Scenario: 设备转手后新用户查询余额
|
||||
|
||||
- **WHEN** 个人客户 A 的设备(设备号 "DEV-001",绑定 3 张卡)转手给个人客户 B,设备钱包余额为 15000 分
|
||||
- **THEN** 个人客户 B 通过设备号 "DEV-001" 登录后查询钱包,余额为 15000 分,3 张卡共享该余额
|
||||
|
||||
#### Scenario: 设备的多张卡共享钱包
|
||||
|
||||
- **WHEN** 设备(设备号 "DEV-001")绑定 3 张卡(ICCID 为 "111"、"222"、"333"),设备钱包余额为 20000 分
|
||||
- **THEN** 用户通过任意一张卡的 ICCID 登录,查询钱包余额都是 20000 分(设备级别钱包)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 资产钱包 Redis 缓存策略
|
||||
|
||||
系统 SHALL 使用 Redis 缓存资产钱包余额,提升查询性能,并使用 Redis 分布式锁防止并发操作冲突。
|
||||
|
||||
**缓存 Key 定义**:
|
||||
- 余额缓存:`asset_wallet:balance:{resource_type}:{resource_id}`
|
||||
- 分布式锁:`asset_wallet:lock:{resource_type}:{resource_id}`
|
||||
|
||||
**缓存 TTL**:
|
||||
- 余额缓存:180 秒(3 分钟)
|
||||
- 分布式锁:10 秒
|
||||
|
||||
**缓存更新策略**:
|
||||
- 余额变动时,删除缓存(Cache-Aside 模式)
|
||||
- 下次查询时重新加载到缓存
|
||||
|
||||
**常量定义位置**:`pkg/constants/redis.go`
|
||||
|
||||
```go
|
||||
func RedisAssetWalletBalanceKey(resourceType string, resourceID uint) string
|
||||
func RedisAssetWalletLockKey(resourceType string, resourceID uint) string
|
||||
```
|
||||
|
||||
#### Scenario: 查询余额时使用缓存
|
||||
|
||||
- **WHEN** 查询物联网卡(ICCID "8986001234567890")钱包余额,缓存中存在该余额
|
||||
- **THEN** 系统直接从 Redis 返回余额,不查询数据库
|
||||
|
||||
#### Scenario: 余额变动后删除缓存
|
||||
|
||||
- **WHEN** 物联网卡(ID 为 100)钱包余额增加 5000 分
|
||||
- **THEN** 系统删除 Redis 缓存 Key `asset_wallet:balance:iot_card:100`,下次查询时重新加载
|
||||
|
||||
#### Scenario: 使用分布式锁防止并发扣款
|
||||
|
||||
- **WHEN** 两个并发请求同时尝试从物联网卡(ID 为 100)钱包扣款
|
||||
- **THEN** 系统使用 Redis 分布式锁 `asset_wallet:lock:iot_card:100`,第一个请求获得锁,第二个请求等待或失败
|
||||
@@ -1,48 +0,0 @@
|
||||
# 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 在后续实名跳转实现中按占位符语义进行参数替换
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 运营商上游流量重置日配置
|
||||
`tb_carrier` SHALL 包含 `data_reset_day` 字段(INT, 1-28),表示该运营商每月上游流量重置日(即上游运营商清零网关计数器的日期)。创建/编辑运营商时 SHALL 支持设置此字段。
|
||||
|
||||
注意:此字段与套餐级别的 `data_reset_cycle`(daily/monthly/yearly)是**完全独立的两个维度**。`data_reset_day` 用于检测上游网关值下降是否为正常重置,`data_reset_cycle` 用于我们系统套餐的已用量定时归零。
|
||||
|
||||
#### Scenario: 创建运营商时指定重置日
|
||||
- **WHEN** 管理员创建运营商,指定 `data_reset_day = 27`
|
||||
- **THEN** 该运营商记录的 `data_reset_day` SHALL 为 27
|
||||
|
||||
#### Scenario: 轮询时读取重置日判断上游重置
|
||||
- **WHEN** 轮询系统检测到网关流量值下降(`increment < 0`)
|
||||
- **THEN** 系统 SHALL 读取该卡对应运营商的 `data_reset_day`
|
||||
- **AND** 使用 `isResetWindow(now, resetDay)` 判断是否在重置日窗口内(重置日当天 + 前一天容错)
|
||||
- **AND** 窗口内视为正常上游重置,窗口外记录 Warn 日志并丢弃异常值
|
||||
@@ -1,47 +0,0 @@
|
||||
# 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 额外返回 `current_package`、`current_package_usage_id`、`current_package_activated_at`、`current_package_expires_at` 以及当前套餐流量字段。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`ASSET_NOT_FOUND/资产不存在`。
|
||||
|
||||
#### Scenario: 个人客户查询已绑定资产
|
||||
- **WHEN** 客户携带有效 Token 调用 `GET /api/c/v1/asset/info?identifier=8986xxxx` 且资产已绑定到本人
|
||||
- **THEN** 系统返回 200,包含资产基础信息与当前 generation
|
||||
|
||||
#### Scenario: 当前主套餐返回开始时间和过期时间
|
||||
- **GIVEN** 该资产存在一条生效中的主套餐使用记录
|
||||
- **WHEN** 客户调用 `GET /api/c/v1/asset/info`
|
||||
- **THEN** 响应体中的 `current_package_activated_at` 等于该主套餐的 `activated_at`
|
||||
- **AND** 响应体中的 `current_package_expires_at` 等于该主套餐的 `expires_at`
|
||||
|
||||
---
|
||||
|
||||
### 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** 系统返回频率限制错误并告知剩余冷却时间
|
||||
@@ -1,73 +0,0 @@
|
||||
# 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` string,MUST,资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)
|
||||
- 响应体字段:
|
||||
- `asset_token` string,MUST,5 分钟有效
|
||||
- `expires_in` int,MUST,单位秒
|
||||
- 错误码:
|
||||
- `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`
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
# 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
|
||||
@@ -1,109 +0,0 @@
|
||||
# Capability: 客户端套餐购买
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: D1 创建套餐购买订单接口
|
||||
|
||||
系统 SHALL 提供 `POST /api/c/v1/orders/create`,并且 MUST 要求个人客户认证。**[BREAKING]** 请求体改为使用资产标识符(identifier),废弃 `iot_card_id`/`device_id` 主键字段和 `order_type` 字段。请求体 MUST 包含 `identifier`、`package_ids[]`、`payment_method`。接口流程 MUST 按顺序执行:归属校验 → 套餐校验(含加油包前置)→ **实名策略检查(新增)** → OpenID 查询 → 幂等检查 → 强充检查 → 分流创建。
|
||||
|
||||
**实名策略检查(新增,替代 REALNAME-03 注释代码)**:
|
||||
- 获取生效实名策略(`GetEffectiveRealnamePolicy`)
|
||||
- 生效策略为 `before_order` 且 `real_name_status=0`(未实名):MUST 返回 `CodeNeedRealname`(错误码 1187),消息"该套餐需实名认证后购买",订单不创建
|
||||
- 生效策略为 `after_order` 或 `none`:跳过实名检查,继续后续流程
|
||||
|
||||
原 `REALNAME-03` 注释代码 SHALL 被删除,由此策略检查替代。
|
||||
|
||||
实名不满足时 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`
|
||||
|
||||
**客户端订单创建请求(CreateOrderRequest)**:
|
||||
```json
|
||||
{
|
||||
"identifier": "string(资产标识符,ICCID 或 VirtualNo,必填,1-50字符)",
|
||||
"package_ids": "[uint](套餐 ID 列表,必填,1-10 个)",
|
||||
"payment_method": "string(wallet|wechat|alipay,必填)"
|
||||
}
|
||||
```
|
||||
|
||||
**废弃字段**:
|
||||
- `iot_card_id`(原单卡购买时必填)
|
||||
- `device_id`(原设备购买时必填)
|
||||
- `order_type`(改为系统根据 identifier 解析结果自动填入)
|
||||
|
||||
响应体 MUST 包含前端可直接渲染字段。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`NEED_REALNAME/该套餐需实名认证后购买`、`OPENID_NOT_FOUND/未找到微信授权信息,请先完成授权`、`IDEMPOTENT_CONFLICT/请求处理中,请勿重复提交`、`PACKAGE_NOT_AVAILABLE/套餐不可购买`。
|
||||
|
||||
#### Scenario: 命中强充返回 recharge 结构
|
||||
- **WHEN** 客户购买套餐触发强充要求
|
||||
- **THEN** 系统返回 `order_type="recharge"`,包含充值单与关联套餐信息
|
||||
|
||||
#### Scenario: 个人客户使用 VirtualNo 购买设备套餐
|
||||
- **WHEN** 个人客户发送 `{ identifier: "DEV-001", package_ids: [5], payment_method: "wechat" }`
|
||||
- **THEN** 系统解析 identifier 为设备,创建设备购买订单,微信支付流程正常触发
|
||||
|
||||
#### Scenario: 个人客户使用 ICCID 购买单卡套餐
|
||||
- **WHEN** 个人客户发送 `{ identifier: "898600XXXXX", package_ids: [3], payment_method: "wallet" }`
|
||||
- **THEN** 系统解析为独立 IoT 卡(`is_standalone = true`),创建单卡购买订单
|
||||
|
||||
#### Scenario: 个人客户尝试为绑定设备的卡购买套餐
|
||||
- **WHEN** 个人客户发送 identifier 对应一张 `is_standalone = false` 的卡
|
||||
- **THEN** 系统返回错误"该卡已绑定设备,请前往设备页面购买套餐"
|
||||
|
||||
#### Scenario: 个人客户只能操作自己绑定的资产
|
||||
- **WHEN** 个人客户发送的 identifier 对应的资产不属于该客户
|
||||
- **THEN** 系统返回 HTTP 403"无权限操作该资源"
|
||||
|
||||
#### Scenario: before_order 策略未实名时购买被拦截
|
||||
- **WHEN** 生效策略为 before_order 且 real_name_status=0,用户调用创建订单接口
|
||||
- **THEN** 系统返回 CodeNeedRealname(1187),订单不创建
|
||||
|
||||
#### Scenario: after_order 策略购买放行
|
||||
- **WHEN** 生效策略为 after_order 且 real_name_status=0,用户调用创建订单接口
|
||||
- **THEN** 系统正常创建订单,不检查实名状态
|
||||
|
||||
---
|
||||
|
||||
### 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`
|
||||
|
||||
---
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: C端支付状态枚举统一
|
||||
C端订单查询接口返回的 `payment_status` SHALL 直接使用管理端统一枚举值(1=待支付, 2=已支付, 3=已取消, 4=已退款),不再通过映射函数转换为 0/1/2。
|
||||
|
||||
#### Scenario: C端查询订单返回统一枚举
|
||||
- **WHEN** C端客户查询订单列表或订单详情
|
||||
- **THEN** 返回的 `payment_status` SHALL 为 1/2/3/4 之一,与管理端一致
|
||||
|
||||
## REMOVED Requirements
|
||||
|
||||
### Requirement: C端支付状态映射
|
||||
**Reason**: `orderStatusToClientStatus()` 函数将管理端 1/2/3/4 映射为 C端 0/1/2,造成前后端枚举不一致,增加维护负担。
|
||||
**Migration**: C端前端需更新支付状态枚举解析:1=待支付, 2=已支付, 3=已取消, 4=已退款。
|
||||
@@ -1,96 +0,0 @@
|
||||
# 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` string,MUST,手机号
|
||||
- `scene` string,MUST,业务场景(`bind_phone` / `change_phone_old` / `change_phone_new`)
|
||||
- 响应体字段:
|
||||
- `cooldown_seconds` int,MUST,本次发送后的冷却秒数
|
||||
- 错误码:
|
||||
- `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` string,MUST,新手机号
|
||||
- `code` string,MUST,验证码
|
||||
- 响应体字段:
|
||||
- `phone` string,MUST,已绑定手机号
|
||||
- `bound_at` string,MUST,绑定时间
|
||||
- 错误码:
|
||||
- `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` string,MUST,旧手机号
|
||||
- `old_code` string,MUST,旧手机号验证码
|
||||
- `new_phone` string,MUST,新手机号
|
||||
- `new_code` string,MUST,新手机号验证码
|
||||
- 响应体字段:
|
||||
- `phone` string,MUST,换绑后的手机号
|
||||
- `changed_at` string,MUST,换绑时间
|
||||
- 错误码:
|
||||
- `1001` 缺失认证令牌
|
||||
- `1002` 认证令牌无效
|
||||
- `1006` 参数错误
|
||||
- `1035` 验证码错误或过期
|
||||
- `1037` 新手机号已被绑定
|
||||
- `1039` 旧手机号不匹配
|
||||
|
||||
#### Scenario: 换绑成功
|
||||
- **WHEN** 登录客户提交正确旧/新验证码且新手机号未占用
|
||||
- **THEN** 系统 SHALL 更新绑定手机号为新手机号
|
||||
|
||||
#### Scenario: 旧手机号校验失败
|
||||
- **WHEN** `old_phone` 与当前客户绑定手机号不一致或 `old_code` 错误
|
||||
- **THEN** 系统 MUST 拒绝换绑并返回对应错误码
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
# Capability: 客户端实名跳转自动触发实名检查优先级提升功能
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 客户端实名跳转自动触发实名检查
|
||||
|
||||
系统 SHALL 在用户成功获取实名跳转链接时,自动触发该ICCID的实名状态检查,提高检测优先级。
|
||||
|
||||
#### Scenario: 用户主动获取实名链接时自动触发优先级检查
|
||||
|
||||
- **WHEN** 个人客户调用 `GET /api/c/v1/realname/link` 接口成功获取实名链接
|
||||
- **THEN** 系统自动将该ICCID加入实名检查的高优先级队列,无需等待定时轮询
|
||||
|
||||
#### Scenario: 自动触发失败不影响主流程
|
||||
|
||||
- **WHEN** 个人客户调用实名链接接口,但自动触发实名检查失败
|
||||
- **THEN** 系统仍正常返回实名链接,不因触发失败而阻断用户操作
|
||||
|
||||
#### Scenario: 异步处理不影响响应时间
|
||||
|
||||
- **WHEN** 个人客户调用实名链接接口
|
||||
- **THEN** 系统异步执行实名检查触发,主接口响应时间增加不超过5ms
|
||||
|
||||
### Requirement: 自动触发的权限适配
|
||||
|
||||
系统 SHALL 使用系统用户身份执行自动触发操作,绕过个人客户的权限限制。
|
||||
|
||||
#### Scenario: 个人客户无权限但能触发检查
|
||||
|
||||
- **WHEN** 个人客户获取实名链接(个人客户本身无手动触发权限)
|
||||
- **THEN** 系统使用配置的系统用户身份自动触发,成功将ICCID加入优先级队列
|
||||
|
||||
#### Scenario: 系统用户身份操作记录
|
||||
|
||||
- **WHEN** 系统使用系统用户身份执行自动触发
|
||||
- **THEN** 系统在 `tb_polling_manual_trigger_log` 表中记录操作,`triggered_by` 字段记录系统用户ID
|
||||
|
||||
### Requirement: 自动触发的错误处理和日志
|
||||
|
||||
系统 SHALL 提供完善的错误处理和日志记录机制,确保可观测性。
|
||||
|
||||
#### Scenario: 触发失败时记录详细日志
|
||||
|
||||
- **WHEN** 自动触发实名检查失败(如Redis连接失败、权限错误等)
|
||||
- **THEN** 系统记录包含客户ID、ICCID、错误原因的详细日志,级别为WARN
|
||||
|
||||
#### Scenario: 触发成功时记录操作日志
|
||||
|
||||
- **WHEN** 自动触发实名检查成功
|
||||
- **THEN** 系统记录包含客户ID、ICCID的INFO级别日志,便于运维追踪
|
||||
|
||||
### Requirement: 配置管理和灵活性
|
||||
|
||||
系统 SHALL 支持通过配置管理自动触发功能的关键参数。
|
||||
|
||||
#### Scenario: 系统用户ID配置
|
||||
|
||||
- **WHEN** 系统初始化或配置更新时
|
||||
- **THEN** 系统从环境变量 `JUNHONG_AUTO_TRIGGER_SYSTEM_USER_ID` 读取系统用户ID
|
||||
|
||||
#### Scenario: 自动触发功能开关
|
||||
|
||||
- **WHEN** 需要临时关闭自动触发功能时
|
||||
- **THEN** 系统支持通过环境变量 `JUNHONG_ENABLE_AUTO_TRIGGER` 控制功能开关(默认开启)
|
||||
@@ -1,47 +0,0 @@
|
||||
# Capability: 客户端实名跳转
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: E1 获取实名跳转链接接口
|
||||
|
||||
系统 SHALL 提供 `GET /api/c/v1/realname/link?identifier=xxx&iccid=xxx`,并且 MUST 要求个人客户认证。该接口 MUST 支持两类入口:购买拦截入口与设备卡列表主动入口。目标卡定位 MUST 支持三种路径:
|
||||
|
||||
1. 标识符直达卡:直接使用该卡
|
||||
2. 标识符为设备且传 `iccid`:定位对应设备下卡
|
||||
3. 标识符为设备且未传 `iccid`:定位设备当前活跃卡
|
||||
|
||||
接口在确定目标卡后,MUST 获取生效实名策略(`GetEffectiveRealnamePolicy`),按以下顺序执行前置检查:
|
||||
|
||||
**实名策略检查**(新增,在实名状态检查之前执行):
|
||||
- 生效策略为 `after_order`:检查该资产当前 generation 内是否存在有效充值(`status=2`)或已支付订单(`payment_status=2`)
|
||||
- 无记录 → MUST 返回 `CodeRealnameNotAvailable`,消息"请先完成充值或购买套餐后再进行实名认证"
|
||||
- 有记录 → 继续执行后续检查
|
||||
- 生效策略为 `none` 或 `before_order`:不执行此检查,继续执行后续检查
|
||||
|
||||
**原有检查保留**:
|
||||
- 当 `real_name_status=1` 时 MUST 返回"该卡已完成实名"错误
|
||||
- 运营商实名模式 MUST 支持:`none`(不支持在线实名)、`template`(模板替换)、`gateway`(调用网关)
|
||||
|
||||
响应体 SHALL 至少包含 `realname_mode`(运营商链接类型)、`realname_url`、`card_info{iccid,msisdn,virtual_no}`、`expire_at`(可空)。
|
||||
|
||||
错误码/消息 MUST 至少包含(新增 `REALNAME_NOT_AVAILABLE`):`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`REALNAME_ALREADY_DONE/该卡已完成实名`、`REALNAME_NOT_SUPPORTED/该运营商暂不支持在线实名`、`GATEWAY_ERROR/获取实名链接失败`、`REALNAME_NOT_AVAILABLE/请先完成充值或购买套餐后再进行实名认证`。
|
||||
|
||||
#### Scenario: 设备未传 iccid 自动选活跃卡
|
||||
- **WHEN** 客户传入设备标识符且不传 `iccid`
|
||||
- **THEN** 系统自动选择设备活跃卡并返回实名跳转链接
|
||||
|
||||
#### Scenario: after_order 模式有充值记录时放行
|
||||
- **WHEN** 生效策略为 after_order 且当前 generation 内存在 status=2 的充值记录
|
||||
- **THEN** 系统正常返回实名链接
|
||||
|
||||
#### Scenario: after_order 模式无记录时拦截
|
||||
- **WHEN** 生效策略为 after_order 且当前 generation 内无任何有效充值或已支付订单
|
||||
- **THEN** 系统返回错误码 CodeRealnameNotAvailable,消息"请先完成充值或购买套餐后再进行实名认证"
|
||||
|
||||
#### Scenario: before_order 模式正常返回链接
|
||||
- **WHEN** 生效策略为 before_order 且该卡尚未实名
|
||||
- **THEN** 系统正常返回实名链接(引导用户先实名)
|
||||
|
||||
#### Scenario: none 模式正常返回链接
|
||||
- **WHEN** 生效策略为 none
|
||||
- **THEN** 系统正常返回实名链接(无策略限制,允许用户自愿实名)
|
||||
@@ -1,59 +0,0 @@
|
||||
# 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` uint,MUST
|
||||
- `exp` int64,MUST
|
||||
- Redis Key:`RedisPersonalCustomerTokenKey(customerID)`
|
||||
- Redis Value:当前有效 token(或 token 集合,取决于实现)
|
||||
- TTL:MUST 与 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` bool,MUST
|
||||
- 错误码:
|
||||
- `1001` 缺失认证令牌
|
||||
- `1002` 认证令牌无效
|
||||
|
||||
#### Scenario: 退出登录成功
|
||||
- **WHEN** 登录客户调用 A7
|
||||
- **THEN** 系统 SHALL 删除 `RedisPersonalCustomerTokenKey(customerID)`
|
||||
- **THEN** 系统 SHALL 返回成功
|
||||
|
||||
### Requirement: 服务端主动失效能力
|
||||
|
||||
系统 MUST 支持服务端主动使 token 失效(如封禁/强制下线),且无需等待 JWT 自然过期。
|
||||
|
||||
#### Scenario: 服务端主动踢出
|
||||
- **WHEN** 管理动作触发客户强制下线
|
||||
- **THEN** 系统 SHALL 删除对应 Redis token 状态
|
||||
- **THEN** 该客户后续请求 MUST 被中间件拒绝
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
# 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 在资产解析与归属校验后,**新增实名策略检查**:
|
||||
|
||||
- 获取生效实名策略(`GetEffectiveRealnamePolicy`)
|
||||
- 生效策略为 `before_order` 且 `real_name_status=0`:MUST 返回 `CodeNeedRealname`
|
||||
- 生效策略为 `after_order` 或 `none`:跳过实名检查,继续执行强充规则计算
|
||||
|
||||
通过实名检查后,接口 MUST 复用 `recharge.Service.GetRechargeCheck()` 计算强充规则。响应体 SHALL 包含 `need_force_recharge`、`force_recharge_amount`、`trigger_type`、`min_amount`、`max_amount`、`message`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`NEED_REALNAME/该套餐需实名认证后充值`。
|
||||
|
||||
#### Scenario: 返回强充预检结果
|
||||
- **WHEN** 资产命中强充规则
|
||||
- **THEN** 系统返回 `need_force_recharge=true` 与对应强充金额和触发类型
|
||||
|
||||
#### Scenario: before_order 未实名时充值预检被拦截
|
||||
- **WHEN** 生效策略为 before_order 且 real_name_status=0
|
||||
- **THEN** 系统返回 CodeNeedRealname,不返回强充预检结果
|
||||
|
||||
---
|
||||
|
||||
### Requirement: C4 创建充值订单接口
|
||||
|
||||
系统 SHALL 提供 `POST /api/c/v1/wallet/recharge`,并且 MUST 要求个人客户认证。请求体 MUST 包含:`identifier`、`amount`(100~10000000 分)、`payment_method=wechat`、`app_type`。
|
||||
|
||||
接口 MUST 在归属校验后、OpenID 查询前,**新增实名策略检查**:
|
||||
- 生效策略为 `before_order` 且 `real_name_status=0`:MUST 返回 `CodeNeedRealname`,充值订单不创建
|
||||
- 生效策略为 `after_order` 或 `none`:继续执行后续流程
|
||||
|
||||
接口 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/仅支持微信支付`、`NEED_REALNAME/该套餐需实名认证后充值`。
|
||||
|
||||
#### Scenario: 后端查 OpenID 并返回支付参数
|
||||
- **WHEN** 客户传入合法参数且后端成功查询到 OpenID
|
||||
- **THEN** 系统创建充值单并返回 `recharge + pay_config`
|
||||
|
||||
#### Scenario: before_order 未实名时充值订单创建被拦截
|
||||
- **WHEN** 生效策略为 before_order 且 real_name_status=0,用户调用充值下单接口
|
||||
- **THEN** 系统返回 CodeNeedRealname(1187),充值单不创建
|
||||
|
||||
#### Scenario: after_order 模式充值放行
|
||||
- **WHEN** 生效策略为 after_order 且 real_name_status=0,用户调用充值下单接口
|
||||
- **THEN** 系统正常创建充值单,不检查实名状态
|
||||
|
||||
---
|
||||
|
||||
### 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 对应的充值记录
|
||||
@@ -1,107 +0,0 @@
|
||||
# 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` string,MUST,微信 OAuth 授权码
|
||||
- `asset_token` string,MUST,A1 返回的资产令牌
|
||||
- 响应体字段:
|
||||
- `token` string,MUST,登录 JWT
|
||||
- `need_bind_phone` bool,MUST,是否需要绑定手机号
|
||||
- `is_new_user` bool,MUST,是否新创建用户
|
||||
- 错误码:
|
||||
- `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` string,MUST,小程序登录凭证
|
||||
- `asset_token` string,MUST,A1 返回的资产令牌
|
||||
- 响应体字段:
|
||||
- `token` string,MUST,登录 JWT
|
||||
- `need_bind_phone` bool,MUST
|
||||
- `is_new_user` bool,MUST
|
||||
- 错误码:
|
||||
- `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`
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 订单支付后触发佣金计算
|
||||
|
||||
系统 SHALL 在订单支付成功后自动触发佣金计算。计算通过异步任务执行。代购订单和普通订单的佣金计算逻辑不同。
|
||||
|
||||
#### Scenario: 普通订单支付成功触发计算
|
||||
- **WHEN** 普通订单(is_purchase_on_behalf = false)支付状态变为已支付
|
||||
- **THEN** 系统发送佣金计算异步任务
|
||||
|
||||
#### Scenario: 代购订单支付成功触发计算
|
||||
- **WHEN** 代购订单(is_purchase_on_behalf = true)创建成功(自动已支付)
|
||||
- **THEN** 系统发送佣金计算异步任务
|
||||
|
||||
#### Scenario: 重复支付不重复计算
|
||||
- **WHEN** 订单已计算过佣金(commission_status=2)
|
||||
- **THEN** 系统不重复触发计算
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 成本价差收入计算
|
||||
|
||||
系统 SHALL 为代理链上的每一级代理计算成本价差收入。终端销售代理收入 = 售价 - 成本价;中间层级代理收入 = 下级成本价 - 自己成本价。
|
||||
|
||||
#### Scenario: 单级代理
|
||||
- **WHEN** 一级代理销售套餐,售价 100 元,成本价 80 元
|
||||
- **THEN** 一级代理获得 20 元(100 - 80)成本价差收入
|
||||
|
||||
#### Scenario: 多级代理
|
||||
- **WHEN** 三级代理销售套餐,售价 100 元,各级成本价为:平台 50 → 一级 60 → 二级 70 → 三级 80
|
||||
- **THEN** 三级获得 20 元(100 - 80),二级获得 10 元(80 - 70),一级获得 10 元(70 - 60),平台获得 10 元(60 - 50)
|
||||
|
||||
#### Scenario: 成本价相同
|
||||
- **WHEN** 某级代理成本价等于下级成本价
|
||||
- **THEN** 该级代理成本价差收入为 0,不创建佣金记录
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 佣金直接入账
|
||||
|
||||
成本价差收入 SHALL 直接入账到店铺钱包,无冻结期。
|
||||
|
||||
#### Scenario: 佣金入账
|
||||
- **WHEN** 计算出代理的成本价差收入
|
||||
- **THEN** 系统直接增加店铺钱包余额,创建佣金记录和钱包交易记录
|
||||
|
||||
#### Scenario: 记录入账后余额
|
||||
- **WHEN** 佣金入账
|
||||
- **THEN** CommissionRecord.balance_after 记录入账后的钱包余额
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 更新累计充值金额
|
||||
|
||||
订单支付成功后系统 SHALL 更新卡/设备的累计充值金额,但代购订单除外。
|
||||
|
||||
**关键修复**:每次真实充值(个人客户充值或购买套餐)都必须写回累计充值金额,代购订单不更新。
|
||||
|
||||
#### Scenario: 普通单卡订单更新累计充值
|
||||
- **WHEN** 普通单卡订单(is_purchase_on_behalf = false)支付成功,金额 100 元
|
||||
- **THEN** 系统读取 IotCard.accumulated_recharge 当前值
|
||||
- **AND** 增加 10000 分(100 元 = 10000 分)
|
||||
- **AND** 将新值写回 IotCard.accumulated_recharge
|
||||
- **AND** 使用更新后的累计值判断是否触发一次性佣金
|
||||
|
||||
#### Scenario: 普通设备订单更新累计充值
|
||||
- **WHEN** 普通设备订单(is_purchase_on_behalf = false)支付成功,金额 300 元
|
||||
- **THEN** 系统读取 Device.accumulated_recharge 当前值
|
||||
- **AND** 增加 30000 分(300 元 = 30000 分)
|
||||
- **AND** 将新值写回 Device.accumulated_recharge
|
||||
- **AND** 使用更新后的累计值判断是否触发一次性佣金
|
||||
|
||||
#### Scenario: 代购订单不更新累计充值
|
||||
- **WHEN** 代购订单(is_purchase_on_behalf = true)完成,金额 100 元
|
||||
- **THEN** 系统不更新卡/设备的 accumulated_recharge 字段
|
||||
- **AND** accumulated_recharge 保持原值
|
||||
|
||||
#### Scenario: 累计充值更新使用原子操作
|
||||
- **WHEN** 更新累计充值金额
|
||||
- **THEN** 系统使用 SQL 原子操作(如 `accumulated_recharge = accumulated_recharge + ?`)
|
||||
- **OR** 使用 GORM 乐观锁(version 字段)
|
||||
- **AND** 确保并发场景下累计值不会丢失
|
||||
|
||||
#### Scenario: 更新失败不影响佣金计算
|
||||
- **WHEN** 累计充值金额更新失败(数据库错误、并发冲突等)
|
||||
- **THEN** 系统记录错误日志
|
||||
- **AND** 继续执行后续的佣金计算流程(成本价差、一次性佣金等)
|
||||
- **AND** 不因累计值更新失败而导致整个佣金计算失败
|
||||
|
||||
---
|
||||
|
||||
### Requirement: CommissionRecord 模型简化
|
||||
|
||||
系统 MUST 简化 CommissionRecord 模型,移除冻结相关字段。
|
||||
|
||||
#### Scenario: 新佣金记录字段
|
||||
- **WHEN** 创建佣金记录
|
||||
- **THEN** 包含:shop_id, order_id, iot_card_id, device_id, commission_source, amount, balance_after, status, released_at, remark
|
||||
|
||||
#### Scenario: 佣金来源类型
|
||||
- **WHEN** 创建佣金记录
|
||||
- **THEN** commission_source 为以下之一:cost_diff(成本价差)、one_time(一次性佣金)
|
||||
|
||||
#### Scenario: 不再支持梯度奖励来源
|
||||
- **WHEN** 尝试创建 commission_source = "tier_bonus" 的佣金记录
|
||||
- **THEN** 系统拒绝并返回错误 "不支持的佣金来源类型"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 一次性佣金触发检查
|
||||
|
||||
系统 SHALL 在更新累计充值金额后立即检查是否触发一次性佣金。
|
||||
|
||||
#### Scenario: 累计达到阈值触发佣金
|
||||
|
||||
- **WHEN** 更新累计充值后,累计值 ≥ 配置阈值
|
||||
- **AND** 卡/设备的 first_commission_paid = false
|
||||
- **THEN** 系统发放一次性佣金
|
||||
- **AND** 标记 first_commission_paid = true
|
||||
|
||||
#### Scenario: 累计未达到阈值不触发
|
||||
|
||||
- **WHEN** 更新累计充值后,累计值 < 配置阈值
|
||||
- **THEN** 系统不发放一次性佣金
|
||||
- **AND** first_commission_paid 保持不变
|
||||
|
||||
#### Scenario: 已发放过不重复触发
|
||||
|
||||
- **WHEN** 更新累计充值后,累计值 ≥ 配置阈值
|
||||
- **AND** 卡/设备的 first_commission_paid = true
|
||||
- **THEN** 系统不重复发放一次性佣金
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 累计充值更新日志记录
|
||||
|
||||
系统 SHOULD 记录累计充值金额的更新操作,便于问题排查。
|
||||
|
||||
#### Scenario: 记录更新前后的累计值
|
||||
|
||||
- **WHEN** 更新累计充值金额
|
||||
- **THEN** 系统在日志中记录:订单 ID、资源类型(卡/设备)、资源 ID、更新前累计值、本次充值金额、更新后累计值
|
||||
|
||||
#### Scenario: 记录更新失败原因
|
||||
|
||||
- **WHEN** 累计充值金额更新失败
|
||||
- **THEN** 系统在日志中记录:订单 ID、资源 ID、失败原因(错误信息)、重试次数(如适用)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 代购订单佣金计算规则
|
||||
|
||||
代购订单 SHALL 计算差价佣金,但不触发一次性佣金。
|
||||
|
||||
#### Scenario: 代购订单计算差价佣金
|
||||
- **WHEN** 代购订单(is_purchase_on_behalf = true)完成,买家有上级代理
|
||||
- **THEN** 系统计算差价佣金(买家成本价 - 上级成本价),发放给上级代理链
|
||||
|
||||
#### Scenario: 代购订单不触发一次性佣金
|
||||
- **WHEN** 代购订单完成,佣金计算时检查订单类型
|
||||
- **THEN** 系统跳过一次性佣金判断逻辑,不发放一次性佣金
|
||||
|
||||
#### Scenario: 代购订单示例
|
||||
- **WHEN** 平台为三级代理代购,订单金额 100 元(三级成本价),各级成本价:一级 60 → 二级 70 → 三级 80
|
||||
- **THEN** 二级获得 10 元(80 - 70)差价佣金,一级获得 10 元(70 - 60)差价佣金
|
||||
- **AND** 三级、二级、一级都不获得一次性佣金
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 钱包充值触发一次性佣金
|
||||
|
||||
钱包充值成功后 SHALL 更新累计充值,并检查是否触发一次性佣金。
|
||||
|
||||
#### Scenario: 充值成功更新累计充值
|
||||
- **WHEN** 卡钱包充值 100 元成功,当前累计充值 200 元
|
||||
- **THEN** 系统更新卡的 accumulated_recharge 为 300 元
|
||||
|
||||
#### Scenario: 充值达到首次充值阈值
|
||||
- **WHEN** 卡配置为首次充值触发,阈值 100 元,充值 100 元成功,未发放过佣金
|
||||
- **THEN** 系统触发一次性佣金计算,发放佣金,标记 first_commission_paid = true
|
||||
|
||||
#### Scenario: 充值达到累计充值阈值
|
||||
- **WHEN** 卡配置为累计充值触发,阈值 1000 元,充值后累计达到 1000 元,未发放过佣金
|
||||
- **THEN** 系统触发一次性佣金计算,发放佣金,标记 first_commission_paid = true
|
||||
|
||||
#### Scenario: 充值未达阈值不触发
|
||||
- **WHEN** 充值后累计充值未达到阈值
|
||||
- **THEN** 系统不触发一次性佣金计算
|
||||
|
||||
#### Scenario: 已发放过不重复触发
|
||||
- **WHEN** 卡的一次性佣金已发放过(first_commission_paid = true)
|
||||
- **THEN** 系统不触发一次性佣金计算
|
||||
@@ -1,160 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 迁移接口的越权校验(通用要求)
|
||||
|
||||
本规范下所有 `/shops/:shop_id/...` 迁移/新增接口,Service 层方法入口 SHALL 调用 `middleware.CanManageShop(ctx, shopID)` 做显式越权校验。Handler 层只做参数解析,不承担权限逻辑。该要求同时追溯适用于已有但缺少校验的 `ListShopWithdrawalRequests`、`ListShopCommissionRecords` 两个方法(回归漏洞修复)。
|
||||
|
||||
统一错误返回:`errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")`。
|
||||
|
||||
#### Scenario: 代理传非自己管辖店铺 ID 被 Service 层拦截
|
||||
|
||||
- **WHEN** 代理 A(shopID=10)请求本规范任意一个 `/shops/:shop_id/...` 接口,传入代理 B(shopID=20)的 shopID
|
||||
- **THEN** Service 层入口的 `middleware.CanManageShop(ctx, 20)` 返回 error,接口返回 403,消息 "无权限操作该资源或资源不存在"
|
||||
|
||||
#### Scenario: 平台人员不受 CanManageShop 限制
|
||||
|
||||
- **WHEN** 平台人员请求任意 `/shops/:shop_id/...` 接口
|
||||
- **THEN** `middleware.CanManageShop` 直接放行,接口正常返回数据
|
||||
|
||||
#### Scenario: 已有方法 ListShopCommissionRecords / ListShopWithdrawalRequests 补齐校验
|
||||
|
||||
- **WHEN** 代理账号请求 `/shops/{非自己管辖shopID}/commission-records` 或 `/withdrawal-requests`
|
||||
- **THEN** 实现后返回 403(回归漏洞修复前这两个方法会返回数据)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 查询佣金记录列表
|
||||
|
||||
系统 SHALL 提供佣金记录列表查询,支持按店铺、佣金来源、时间范围、状态筛选。
|
||||
|
||||
#### Scenario: 代理查询自己店铺的佣金
|
||||
- **WHEN** 代理查询佣金记录列表
|
||||
- **THEN** 系统返回该店铺的所有佣金记录
|
||||
|
||||
#### Scenario: 按成本价差筛选
|
||||
- **WHEN** 指定 commission_source 为 cost_diff
|
||||
- **THEN** 系统只返回成本价差类型的佣金记录
|
||||
|
||||
#### Scenario: 按一次性佣金筛选
|
||||
- **WHEN** 指定 commission_source 为 one_time
|
||||
- **THEN** 系统只返回一次性佣金类型的佣金记录
|
||||
|
||||
#### Scenario: 使用已废弃的佣金来源筛选
|
||||
- **WHEN** 指定 commission_source 为 tier_bonus
|
||||
- **THEN** 系统返回空列表或返回错误 "不支持的佣金来源类型"
|
||||
|
||||
#### Scenario: 按时间范围筛选
|
||||
- **WHEN** 指定开始时间和结束时间
|
||||
- **THEN** 系统只返回该时间范围内的佣金记录
|
||||
|
||||
#### Scenario: 响应包含关联信息
|
||||
- **WHEN** 查询佣金记录列表
|
||||
- **THEN** 每条记录包含:
|
||||
- 佣金记录 ID、金额、状态、状态名称
|
||||
- 订单号(order_no)、订单创建时间(order_created_at)
|
||||
- ICCID(当订单类型为单卡时)
|
||||
- 设备虚拟号(当订单类型为设备时)
|
||||
- 销售来源店铺 ID 和名称(seller_shop_id, seller_shop_name)
|
||||
- 佣金入账时间
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 查询佣金记录详情
|
||||
|
||||
系统 SHALL 允许查询单条佣金记录的详细信息。
|
||||
|
||||
#### Scenario: 查询佣金详情
|
||||
- **WHEN** 代理查询指定佣金记录详情
|
||||
- **THEN** 系统返回完整的佣金信息和关联的订单、卡/设备信息
|
||||
|
||||
#### Scenario: 查询他人佣金
|
||||
- **WHEN** 代理尝试查询其他店铺的佣金记录
|
||||
- **THEN** 系统返回 "记录不存在" 错误
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 佣金统计
|
||||
|
||||
系统 SHALL 提供佣金统计功能,包含总收入和各来源占比。
|
||||
|
||||
#### Scenario: 查询总收入
|
||||
- **WHEN** 代理查询佣金统计
|
||||
- **THEN** 系统返回总收入金额(所有已入账佣金之和)
|
||||
|
||||
#### Scenario: 各来源占比
|
||||
- **WHEN** 代理查询佣金统计
|
||||
- **THEN** 系统返回各佣金来源的金额和占比(cost_diff、one_time)
|
||||
|
||||
#### Scenario: 统计响应不包含梯度奖励字段
|
||||
- **WHEN** 代理查询佣金统计
|
||||
- **THEN** 响应中不包含 tier_bonus_amount、tier_bonus_count、tier_bonus_percent 字段
|
||||
|
||||
#### Scenario: 按时间范围统计
|
||||
- **WHEN** 指定时间范围查询统计
|
||||
- **THEN** 系统只统计该时间范围内的佣金
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 每日佣金统计
|
||||
|
||||
系统 SHALL 提供每日佣金统计查询。
|
||||
|
||||
#### Scenario: 查询每日统计
|
||||
- **WHEN** 代理查询指定日期范围的每日统计
|
||||
- **THEN** 系统返回每天的佣金总额和笔数
|
||||
|
||||
#### Scenario: 默认最近30天
|
||||
- **WHEN** 代理查询每日统计不指定日期范围
|
||||
- **THEN** 系统返回最近 30 天的数据
|
||||
|
||||
---
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 佣金状态定义
|
||||
|
||||
**原内容**:
|
||||
|
||||
佣金记录状态为二态:1=已入账,2=已失效
|
||||
|
||||
**修改为**:
|
||||
|
||||
佣金记录状态为四态:
|
||||
- 1 = 已冻结:佣金已计算但暂不可提现
|
||||
- 2 = 解冻中:满足解冻条件,正在等待发放
|
||||
- 3 = 已发放:佣金已入账可提现
|
||||
- 4 = 已失效:佣金核验失败或订单退款导致失效
|
||||
|
||||
#### Scenario: 差价佣金创建时状态
|
||||
- **WHEN** 差价佣金计算完成并创建记录
|
||||
- **THEN** 记录状态为 3(已发放)
|
||||
|
||||
#### Scenario: 链路断裂时状态
|
||||
- **WHEN** 佣金链路断裂(上级代理未分配套餐)
|
||||
- **THEN** 记录状态为 99(待人工修正)
|
||||
|
||||
#### Scenario: 订单退款时状态
|
||||
- **WHEN** 关联订单发生退款
|
||||
- **THEN** 佣金记录状态更新为 4(已失效)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 佣金状态常量一致性
|
||||
|
||||
系统 SHALL 使用统一的状态值和状态名称映射,确保佣金状态显示正确。
|
||||
|
||||
#### Scenario: 已发放状态正确显示
|
||||
- **WHEN** 佣金记录 status = 3
|
||||
- **THEN** 状态名称返回 "已发放"
|
||||
|
||||
#### Scenario: 已冻结状态正确显示
|
||||
- **WHEN** 佣金记录 status = 1
|
||||
- **THEN** 状态名称返回 "已冻结"
|
||||
|
||||
#### Scenario: 解冻中状态正确显示
|
||||
- **WHEN** 佣金记录 status = 2
|
||||
- **THEN** 状态名称返回 "解冻中"
|
||||
|
||||
#### Scenario: 已失效状态正确显示
|
||||
- **WHEN** 佣金记录 status = 4
|
||||
- **THEN** 状态名称返回 "已失效"
|
||||
@@ -1,87 +0,0 @@
|
||||
# Capability: 返佣统计缓存管理
|
||||
|
||||
## Purpose
|
||||
|
||||
本 capability 定义如何使用 Redis 和异步任务管理梯度返佣统计数据,支持高并发场景下的性能优化和数据一致性。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 异步更新梯度统计数据
|
||||
|
||||
系统 SHALL 在充值订单成功后,通过异步任务更新梯度统计数据,而不是实时计算。异步任务 MUST 使用 Asynq 队列系统实现。
|
||||
|
||||
#### Scenario: 充值成功后发送异步任务
|
||||
- **WHEN** 下级客户充值100元成功
|
||||
- **THEN** 系统立即返回成功,并发送异步任务 "commission:stats:update" 到队列
|
||||
|
||||
#### Scenario: 异步任务更新统计数据
|
||||
- **WHEN** 异步任务执行,payload 包含 allocation_id=123, sales_count=1, sales_amount=10000
|
||||
- **THEN** 系统更新 allocation_id=123 当前周期的统计数据
|
||||
|
||||
#### Scenario: 异步任务失败时重试
|
||||
- **WHEN** 异步任务执行失败(如数据库连接超时)
|
||||
- **THEN** 系统自动重试(最多3次)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 使用 Redis 缓存统计数据
|
||||
|
||||
系统 SHALL 使用 Redis 缓存梯度统计数据,key 格式为 `commission:stats:{allocation_id}:{period}`,支持原子递增操作。
|
||||
|
||||
#### Scenario: Redis 原子递增销量
|
||||
- **WHEN** 异步任务更新统计时,allocation_id=123,销量+1
|
||||
- **THEN** 系统执行 HINCRBY commission:stats:123:2026-01 total_count 1
|
||||
|
||||
#### Scenario: Redis 原子递增销售额
|
||||
- **WHEN** 异步任务更新统计时,allocation_id=123,销售额+10000
|
||||
- **THEN** 系统执行 HINCRBY commission:stats:123:2026-01 total_amount 10000
|
||||
|
||||
#### Scenario: Redis key 设置过期时间
|
||||
- **WHEN** 创建 Redis key 时,当前周期结束时间为2026-01-31 23:59:59
|
||||
- **THEN** 系统设置 key 过期时间为 2026-02-07 23:59:59(周期结束后7天)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 定时同步到数据库
|
||||
|
||||
系统 SHALL 每小时执行一次定时任务,将 Redis 中的统计数据同步到数据库表 `tb_shop_series_commission_stats`。
|
||||
|
||||
#### Scenario: 每小时同步 Redis 数据到数据库
|
||||
- **WHEN** 定时任务执行
|
||||
- **THEN** 系统扫描所有 Redis key(pattern: commission:stats:*),批量更新数据库
|
||||
|
||||
#### Scenario: 同步时使用乐观锁避免冲突
|
||||
- **WHEN** 多个任务同时更新同一条统计记录
|
||||
- **THEN** 系统使用 version 字段实现乐观锁,失败时重试
|
||||
|
||||
#### Scenario: 同步后不删除 Redis key
|
||||
- **WHEN** 定时任务同步完成
|
||||
- **THEN** Redis key 保留(用于实时查询),等待过期时间自动清理
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 查询统计数据时优先从 Redis 获取
|
||||
|
||||
系统 SHALL 在查询当前周期的统计数据时,优先从 Redis 获取,Redis 不存在时从数据库获取并回写到 Redis。
|
||||
|
||||
#### Scenario: Redis 存在时直接返回
|
||||
- **WHEN** 查询 allocation_id=123 的当前周期统计
|
||||
- **THEN** 系统从 Redis key `commission:stats:123:2026-01` 获取数据并返回
|
||||
|
||||
#### Scenario: Redis 不存在时从数据库加载
|
||||
- **WHEN** 查询 allocation_id=123 的当前周期统计,Redis key 不存在
|
||||
- **THEN** 系统从数据库查询,并回写到 Redis
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 周期结束后归档统计数据
|
||||
|
||||
系统 SHALL 在每个统计周期结束后,执行归档任务:确保 Redis 数据已同步到数据库,更新统计状态为 "completed",清理 Redis key。
|
||||
|
||||
#### Scenario: 月度周期结束时归档
|
||||
- **WHEN** 2026年1月31日 23:59:59,月度周期结束
|
||||
- **THEN** 系统执行归档任务:同步数据、更新状态为 "completed"、删除 Redis key
|
||||
|
||||
#### Scenario: 归档后统计数据不再更新
|
||||
- **WHEN** 周期已归档(status = "completed")
|
||||
- **THEN** 新的充值订单不再更新该周期的统计数据,而是创建新周期的统计记录
|
||||
@@ -1,124 +0,0 @@
|
||||
# commission-trigger Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change fix-commission-calculation-trigger-and-snapshot. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: 支付成功后自动入队佣金计算任务
|
||||
|
||||
系统 SHALL 在订单首次支付成功时自动 enqueue 佣金计算异步任务(`commission:calculate`),确保佣金及时发放。
|
||||
|
||||
**触发条件**:
|
||||
- 订单从"待支付"变为"已支付"(首次成功支付)
|
||||
- 订单 `commission_status` 为 `pending`(未计算)
|
||||
|
||||
**任务参数**:
|
||||
- 任务类型:`commission:calculate`
|
||||
- Payload:`{"order_id": <订单ID>}`
|
||||
|
||||
#### Scenario: 首次支付成功触发计算
|
||||
|
||||
- **WHEN** 订单支付成功,订单状态从"待支付"变为"已支付"
|
||||
- **THEN** 系统自动 enqueue `commission:calculate` 任务,payload 包含订单 ID
|
||||
- **AND** 订单 `commission_status` 保持为 `pending`(任务执行后才更新为 `calculated`)
|
||||
|
||||
#### Scenario: 重复支付不重复触发
|
||||
|
||||
- **WHEN** 订单已经是"已支付"状态,再次收到支付成功通知(幂等场景)
|
||||
- **THEN** 系统不重复 enqueue 佣金计算任务
|
||||
- **AND** 日志记录"订单已支付,跳过重复入队"
|
||||
|
||||
#### Scenario: 已计算佣金的订单不触发
|
||||
|
||||
- **WHEN** 订单 `commission_status` 为 `calculated`(已计算)
|
||||
- **THEN** 系统跳过入队操作
|
||||
- **AND** 日志记录"订单佣金已计算,跳过入队"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 入队失败不影响支付主链路
|
||||
|
||||
系统 SHALL 确保佣金任务入队失败时不回滚订单支付成功状态,保障主业务链路稳定。
|
||||
|
||||
**失败处理策略**:
|
||||
- 入队失败时记录 ERROR 级别日志(包含订单 ID、失败原因)
|
||||
- 订单状态保持为"已支付",不回滚
|
||||
- 订单 `commission_status` 保持为 `pending`,允许后续补偿
|
||||
|
||||
**补偿机制**:
|
||||
- 后台补偿任务扫描 `commission_status=pending` 且已支付的订单
|
||||
- 人工触发佣金计算(后台接口)
|
||||
- 定时任务重试入队(可选)
|
||||
|
||||
#### Scenario: 入队失败记录日志
|
||||
|
||||
- **WHEN** 佣金任务入队失败(队列服务不可用或网络超时)
|
||||
- **THEN** 系统记录 ERROR 日志,包含订单 ID、失败原因、队列配置信息
|
||||
- **AND** 订单支付状态保持为"已支付",不回滚
|
||||
|
||||
#### Scenario: 失败后允许补偿
|
||||
|
||||
- **WHEN** 后台补偿任务扫描到 `commission_status=pending` 且 `payment_status=paid` 的订单
|
||||
- **THEN** 系统可重新 enqueue 佣金计算任务或直接执行计算
|
||||
- **AND** 避免佣金永久丢失
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 佣金计算任务幂等性
|
||||
|
||||
系统 SHALL 确保佣金计算任务可重复执行,不重复发放佣金。
|
||||
|
||||
**幂等检查**:
|
||||
- 任务执行前检查订单 `commission_status`
|
||||
- 如果已为 `calculated`,跳过计算并返回成功
|
||||
|
||||
**状态更新**:
|
||||
- 计算完成后将订单 `commission_status` 更新为 `calculated`
|
||||
- 状态更新与佣金记录创建在同一事务中
|
||||
|
||||
#### Scenario: 任务重复执行跳过计算
|
||||
|
||||
- **WHEN** 佣金计算任务执行时,订单 `commission_status` 已为 `calculated`
|
||||
- **THEN** 系统跳过佣金计算和钱包入账操作
|
||||
- **AND** 任务返回成功(避免 Asynq 重试)
|
||||
- **AND** 日志记录"订单佣金已计算,跳过执行"
|
||||
|
||||
#### Scenario: 并发任务只有一个成功
|
||||
|
||||
- **WHEN** 同一订单的佣金计算任务被重复入队,两个 worker 并发执行
|
||||
- **THEN** 第一个任务成功完成计算并更新状态为 `calculated`
|
||||
- **AND** 第二个任务检查到状态已为 `calculated`,跳过计算
|
||||
|
||||
#### Scenario: 任务失败可安全重试
|
||||
|
||||
- **WHEN** 佣金计算任务执行失败(数据库异常、钱包服务不可用)
|
||||
- **THEN** Asynq 自动重试任务
|
||||
- **AND** 重试时幂等检查确保不重复发放佣金
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 队列客户端依赖注入
|
||||
|
||||
系统 SHALL 通过依赖注入方式将队列客户端注入到订单服务,遵循现有 bootstrap 架构。
|
||||
|
||||
**注入位置**:
|
||||
- `internal/service/order/service.go` 的 `Service` 结构体
|
||||
- 添加 `queueClient *asynq.Client` 字段
|
||||
|
||||
**注入方式**:
|
||||
- 在 `internal/bootstrap/services.go` 中初始化订单服务时传入队列客户端
|
||||
- 队列客户端在 `bootstrap.Bootstrap()` 中统一创建
|
||||
|
||||
#### Scenario: 订单服务接收队列客户端
|
||||
|
||||
- **WHEN** 系统启动时执行 `bootstrap.Bootstrap()`
|
||||
- **THEN** 订单服务(`order.Service`)通过构造函数接收队列客户端实例
|
||||
- **AND** 队列客户端可在服务内部调用 `Enqueue()` 方法
|
||||
|
||||
#### Scenario: 支付成功时调用队列客户端
|
||||
|
||||
- **WHEN** 订单支付成功,订单服务执行入队操作
|
||||
- **THEN** 系统通过注入的队列客户端调用 `Enqueue("commission:calculate", payload)`
|
||||
- **AND** 不在服务内部直接创建队列客户端(遵循依赖注入原则)
|
||||
|
||||
---
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
# data-permission Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
数据权限过滤机制,通过业务层显式调用实现数据隔离。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Subordinate IDs Caching
|
||||
|
||||
系统 SHALL 缓存用户的下级店铺 ID 列表以提高查询性能。
|
||||
|
||||
#### Scenario: 缓存命中
|
||||
- **WHEN** 获取用户下级店铺 ID 列表
|
||||
- **AND** Redis 缓存存在
|
||||
- **THEN** 直接返回缓存数据
|
||||
|
||||
#### Scenario: 缓存未命中
|
||||
- **WHEN** 获取用户下级店铺 ID 列表
|
||||
- **AND** Redis 缓存不存在
|
||||
- **THEN** 执行递归查询获取下级店铺 ID
|
||||
- **AND** 将结果缓存到 Redis(30 分钟过期)
|
||||
|
||||
#### Scenario: 请求级别复用
|
||||
- **WHEN** 同一请求内多次需要下级店铺 ID 列表
|
||||
- **THEN** 从 Context 中获取预计算的值
|
||||
- **AND** 不重复查询 Redis 或数据库
|
||||
|
||||
### Requirement: Store 层显式数据权限过滤
|
||||
|
||||
系统 SHALL 在 Store 层查询方法中显式调用数据权限过滤函数。
|
||||
|
||||
#### Scenario: 有 shop_id 字段的表
|
||||
- **WHEN** Store 执行列表查询
|
||||
- **AND** 表包含 `shop_id` 字段
|
||||
- **THEN** 显式调用 `ApplyShopFilter(ctx, query)`
|
||||
- **AND** 代理用户只能查询 `shop_id IN (subordinateShopIDs)` 的数据
|
||||
|
||||
#### Scenario: 有 enterprise_id 字段的表
|
||||
- **WHEN** Store 执行列表查询
|
||||
- **AND** 表包含 `enterprise_id` 字段
|
||||
- **AND** 当前用户为企业用户
|
||||
- **THEN** 显式调用 `ApplyEnterpriseFilter(ctx, query)`
|
||||
- **AND** 企业用户只能查询 `enterprise_id = ?` 的数据
|
||||
|
||||
#### Scenario: 有 owner_shop_id 字段的表
|
||||
- **WHEN** Store 执行列表查询
|
||||
- **AND** 表包含 `owner_shop_id` 字段(如 Enterprise 表)
|
||||
- **THEN** 显式调用 `ApplyOwnerShopFilter(ctx, query)`
|
||||
- **AND** 代理用户只能查询 `owner_shop_id IN (subordinateShopIDs)` 的数据
|
||||
|
||||
#### Scenario: NULL shop_id 不可见
|
||||
- **WHEN** 代理用户查询有 `shop_id` 字段的表
|
||||
- **AND** 记录的 `shop_id` 为 NULL(平台库存)
|
||||
- **THEN** 该记录对代理用户不可见
|
||||
|
||||
#### Scenario: 平台用户/超管不过滤
|
||||
- **WHEN** 平台用户或超级管理员执行查询
|
||||
- **THEN** Helper 函数不添加任何过滤条件
|
||||
- **AND** 可查询所有数据
|
||||
@@ -1,130 +0,0 @@
|
||||
# data-scope-middleware Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
数据权限范围中间件,负责在请求入口预计算用户的数据访问范围并注入 Context,供业务层显式使用。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: UserContextInfo 扩展
|
||||
|
||||
系统 SHALL 扩展 `UserContextInfo` 结构体以包含预计算的数据权限范围。
|
||||
|
||||
#### Scenario: 代理用户包含下级店铺 ID 列表
|
||||
- **WHEN** 代理用户登录成功
|
||||
- **AND** 用户有关联的店铺 ID
|
||||
- **THEN** `UserContextInfo.SubordinateShopIDs` 包含自己店铺及所有下级店铺的 ID 列表
|
||||
|
||||
#### Scenario: 平台用户/超管不限制
|
||||
- **WHEN** 平台用户或超级管理员登录成功
|
||||
- **THEN** `UserContextInfo.SubordinateShopIDs` 为 nil
|
||||
- **AND** nil 表示不受数据权限限制
|
||||
|
||||
#### Scenario: 企业用户使用 EnterpriseID
|
||||
- **WHEN** 企业用户登录成功
|
||||
- **THEN** `UserContextInfo.EnterpriseID` 包含用户所属企业 ID
|
||||
- **AND** `UserContextInfo.SubordinateShopIDs` 为 nil
|
||||
|
||||
### Requirement: Auth 中间件预计算
|
||||
|
||||
系统 SHALL 在 Auth 中间件中预计算用户的数据访问范围。
|
||||
|
||||
#### Scenario: 代理用户预计算下级店铺
|
||||
- **WHEN** Auth 中间件验证 token 成功
|
||||
- **AND** 用户类型为代理用户
|
||||
- **AND** 用户有关联的店铺 ID
|
||||
- **THEN** 调用 `GetSubordinateShopIDs` 获取下级店铺 ID 列表
|
||||
- **AND** 将结果设置到 `UserContextInfo.SubordinateShopIDs`
|
||||
|
||||
#### Scenario: 获取下级店铺失败降级处理
|
||||
- **WHEN** 调用 `GetSubordinateShopIDs` 失败
|
||||
- **THEN** `SubordinateShopIDs` 降级为只包含用户自己的店铺 ID
|
||||
- **AND** 记录 Error 日志
|
||||
|
||||
#### Scenario: 非代理用户跳过预计算
|
||||
- **WHEN** Auth 中间件验证 token 成功
|
||||
- **AND** 用户类型不是代理用户
|
||||
- **THEN** 不调用 `GetSubordinateShopIDs`
|
||||
- **AND** `SubordinateShopIDs` 保持为 nil
|
||||
|
||||
### Requirement: Context 数据获取函数
|
||||
|
||||
系统 SHALL 提供从 Context 获取数据权限范围的函数。
|
||||
|
||||
#### Scenario: 获取下级店铺 ID 列表
|
||||
- **WHEN** 调用 `GetSubordinateShopIDs(ctx)`
|
||||
- **AND** Context 包含 `SubordinateShopIDs`
|
||||
- **THEN** 返回下级店铺 ID 列表
|
||||
|
||||
#### Scenario: 获取空列表表示不限制
|
||||
- **WHEN** 调用 `GetSubordinateShopIDs(ctx)`
|
||||
- **AND** Context 中 `SubordinateShopIDs` 为 nil
|
||||
- **THEN** 返回 nil
|
||||
- **AND** 调用方应理解 nil 表示不受数据权限限制
|
||||
|
||||
### Requirement: 查询过滤 Helper 函数
|
||||
|
||||
系统 SHALL 提供查询过滤 Helper 函数,供 Store 层显式调用。
|
||||
|
||||
#### Scenario: ApplyShopFilter 过滤店铺数据
|
||||
- **WHEN** 调用 `ApplyShopFilter(ctx, query)`
|
||||
- **AND** `SubordinateShopIDs` 不为 nil
|
||||
- **THEN** 返回添加了 `WHERE shop_id IN (?)` 条件的查询
|
||||
- **AND** 参数为 `SubordinateShopIDs`
|
||||
|
||||
#### Scenario: ApplyShopFilter 不限制时不添加条件
|
||||
- **WHEN** 调用 `ApplyShopFilter(ctx, query)`
|
||||
- **AND** `SubordinateShopIDs` 为 nil
|
||||
- **THEN** 返回原查询,不添加任何条件
|
||||
|
||||
#### Scenario: ApplyEnterpriseFilter 过滤企业数据
|
||||
- **WHEN** 调用 `ApplyEnterpriseFilter(ctx, query)`
|
||||
- **AND** 用户类型为企业用户
|
||||
- **AND** `EnterpriseID` 大于 0
|
||||
- **THEN** 返回添加了 `WHERE enterprise_id = ?` 条件的查询
|
||||
|
||||
#### Scenario: ApplyEnterpriseFilter 非企业用户不添加条件
|
||||
- **WHEN** 调用 `ApplyEnterpriseFilter(ctx, query)`
|
||||
- **AND** 用户类型不是企业用户
|
||||
- **THEN** 返回原查询,不添加任何条件
|
||||
|
||||
#### Scenario: ApplyOwnerShopFilter 过滤归属店铺数据
|
||||
- **WHEN** 调用 `ApplyOwnerShopFilter(ctx, query)`
|
||||
- **AND** `SubordinateShopIDs` 不为 nil
|
||||
- **THEN** 返回添加了 `WHERE owner_shop_id IN (?)` 条件的查询
|
||||
|
||||
### Requirement: 权限检查函数改造
|
||||
|
||||
系统 SHALL 改造权限检查函数,从 Context 获取数据而非传入 Store。
|
||||
|
||||
#### Scenario: CanManageShop 从 Context 获取数据
|
||||
- **WHEN** 调用 `CanManageShop(ctx, targetShopID)`
|
||||
- **AND** 用户类型为代理用户
|
||||
- **THEN** 从 Context 获取 `SubordinateShopIDs`
|
||||
- **AND** 检查 `targetShopID` 是否在列表中
|
||||
|
||||
#### Scenario: CanManageShop 平台用户自动通过
|
||||
- **WHEN** 调用 `CanManageShop(ctx, targetShopID)`
|
||||
- **AND** `SubordinateShopIDs` 为 nil
|
||||
- **THEN** 返回成功(不受限制)
|
||||
|
||||
#### Scenario: CanManageEnterprise 从 Context 获取数据
|
||||
- **WHEN** 调用 `CanManageEnterprise(ctx, targetEnterpriseID)`
|
||||
- **AND** 用户类型为代理用户
|
||||
- **THEN** 从 Context 获取 `SubordinateShopIDs`
|
||||
- **AND** 查询目标企业的 `owner_shop_id`
|
||||
- **AND** 检查 `owner_shop_id` 是否在列表中
|
||||
|
||||
### Requirement: AuthConfig 扩展
|
||||
|
||||
系统 SHALL 扩展 `AuthConfig` 以支持传入 ShopStore。
|
||||
|
||||
#### Scenario: AuthConfig 包含 ShopStore
|
||||
- **WHEN** 初始化 Auth 中间件
|
||||
- **THEN** `AuthConfig` 可选包含 `ShopStore ShopStoreInterface`
|
||||
- **AND** 用于调用 `GetSubordinateShopIDs`
|
||||
|
||||
#### Scenario: ShopStore 未配置时跳过预计算
|
||||
- **WHEN** `AuthConfig.ShopStore` 为 nil
|
||||
- **THEN** 不预计算 `SubordinateShopIDs`
|
||||
- **AND** 所有用户的 `SubordinateShopIDs` 为 nil
|
||||
@@ -1,56 +0,0 @@
|
||||
# dependency-injection Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change refactor-framework-cleanup. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: Bootstrap Package
|
||||
|
||||
系统 SHALL 提供 bootstrap 包,统一管理所有业务组件的初始化和依赖注入。
|
||||
|
||||
#### Scenario: 初始化所有组件
|
||||
- **WHEN** 调用 Bootstrap(deps)
|
||||
- **THEN** 自动初始化所有 Store、Service 和 Handler
|
||||
- **AND** 返回可直接用于路由注册的 Handlers 结构体
|
||||
|
||||
#### Scenario: 依赖注入
|
||||
- **WHEN** 初始化 Service 时
|
||||
- **THEN** 自动注入所需的 Store 依赖
|
||||
- **AND** 自动注入所需的其他 Service 依赖
|
||||
|
||||
#### Scenario: 添加新业务模块
|
||||
- **WHEN** 需要添加新的业务模块
|
||||
- **THEN** 只需修改 bootstrap 包
|
||||
- **AND** main.go 无需任何修改
|
||||
- **AND** TODO 注释标记扩展点
|
||||
|
||||
### Requirement: Main Function Simplification
|
||||
|
||||
main 函数 SHALL 只负责编排,不包含具体业务组件初始化逻辑。
|
||||
|
||||
#### Scenario: 标准启动流程
|
||||
- **WHEN** 应用启动
|
||||
- **THEN** main 函数执行以下步骤:
|
||||
1. 加载配置
|
||||
2. 初始化基础依赖(DB、Redis、Logger)
|
||||
3. 调用 bootstrap.Bootstrap() 初始化业务组件
|
||||
4. 设置路由和中间件
|
||||
5. 启动服务器
|
||||
|
||||
#### Scenario: 启动失败处理
|
||||
- **WHEN** 任何初始化步骤失败
|
||||
- **THEN** 记录错误日志
|
||||
- **AND** 程序以非零状态码退出
|
||||
|
||||
### Requirement: Dependencies Encapsulation
|
||||
|
||||
系统 SHALL 使用结构体封装基础依赖和业务组件。
|
||||
|
||||
#### Scenario: Dependencies 结构体
|
||||
- **WHEN** 传递基础依赖时
|
||||
- **THEN** 使用 Dependencies 结构体封装 DB、Redis、Logger
|
||||
|
||||
#### Scenario: Handlers 结构体
|
||||
- **WHEN** 返回业务处理器时
|
||||
- **THEN** 使用 Handlers 结构体封装所有 Handler
|
||||
- **AND** 结构体包含 TODO 注释标记未来扩展点
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
# device-import Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change add-device-management. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: 设备批量导入
|
||||
|
||||
系统 SHALL 提供设备批量导入功能,通过 Excel 文件导入设备并自动绑定卡,按固定列位置读取,表头行内容不影响解析结果,仅平台用户可操作。
|
||||
|
||||
**API 端点**: `POST /api/admin/devices/import`
|
||||
|
||||
**请求参数**:
|
||||
- `batch_no`: 批次号(必填)
|
||||
- `file_key`: 对象存储文件路径(必填,通过 /storage/upload-url 获取)
|
||||
|
||||
**Excel 格式**:
|
||||
- **文件格式**: 仅支持 `.xlsx` (Excel 2007+)
|
||||
- **Sheet**: 读取第一个sheet,或优先读取名为"导入数据"的sheet
|
||||
- **表头行**: 第1行固定为表头,永远跳过,内容不限(中文、英文均可)
|
||||
- **列位置(固定,不可变)**:
|
||||
```
|
||||
第1列(索引0): 虚拟号(必填,全局唯一)
|
||||
第2列(索引1): SN(可选)
|
||||
第3列(索引2): 设备名称(可选)
|
||||
第4列(索引3): 设备型号(可选)
|
||||
第5列(索引4): 设备类型(可选)
|
||||
第6列(索引5): IMEI(可选)
|
||||
第7列(索引6): 制造商(可选)
|
||||
第8列(索引7): 最大SIM槽数(可选,默认4,范围1-4)
|
||||
第9列(索引8): 卡1 ICCID(可选,对应槽位1)
|
||||
第10列(索引9): 卡2 ICCID(可选,对应槽位2)
|
||||
第11列(索引10): 卡3 ICCID(可选,对应槽位3)
|
||||
第12列(索引11): 卡4 ICCID(可选,对应槽位4)
|
||||
```
|
||||
- **列格式**: 所有列应设置为文本格式(避免数字被转为科学记数法)
|
||||
|
||||
**失败原因文本**:
|
||||
- VirtualNo 为空:`"设备虚拟号(virtual_no)不能为空"`
|
||||
- MaxSimSlots 越界:`"最大SIM槽数必须在1-4之间"`
|
||||
- ICCID 填写槽位超出最大槽位:`"卡槽填写超出最大SIM槽数"`
|
||||
|
||||
**导入规则**:
|
||||
- 按列索引取值,不识别列名,第一行永远跳过
|
||||
- 若整行目标列皆为空,视为空行跳过,不计入 total,不计入失败
|
||||
- MaxSimSlots 为空或 0 回填默认值 4;非空且不在 [1,4] 记录为失败
|
||||
- `卡1~卡4` 的列位本身就是槽位定义,导入时必须保留原始列位信息,不得按非空 ICCID 顺序重排
|
||||
- 前置槽位允许为空;如果仅填写 `卡2`、`卡3`,则表示设备只在槽位 2、3 上绑定卡
|
||||
- 任一已填写 ICCID 的槽位编号若大于 `max_sim_slots`,则该行导入失败
|
||||
- 导入的设备 shop_id = NULL(平台库存)
|
||||
- 导入的设备 status = 1(在库)
|
||||
- 设备号重复则该行跳过
|
||||
- ICCID 必须已存在于系统中(先导入卡,再导入设备)
|
||||
- ICCID 不存在则该行失败
|
||||
- ICCID 已绑定其他设备则该行失败
|
||||
- 导入通过异步任务处理,立即返回任务 ID
|
||||
|
||||
**权限**: 仅平台用户
|
||||
|
||||
**响应**:
|
||||
- `task_id`: 导入任务 ID
|
||||
- `task_no`: 任务编号
|
||||
- `message`: 提示信息
|
||||
|
||||
#### Scenario: 提交设备导入任务
|
||||
|
||||
- **WHEN** 平台管理员上传 Excel 文件并提交导入请求
|
||||
- **THEN** 系统创建导入任务,返回任务 ID,开始异步处理
|
||||
|
||||
#### Scenario: 中文表头正常导入
|
||||
|
||||
- **GIVEN** Excel 文件第1行表头为 `虚拟号 | SN | 设备名称 | 设备型号 | 设备类型 | IMEI | 制造商 | 最大SIM槽数 | 卡1 | 卡2 | 卡3 | 卡4`
|
||||
- **WHEN** 系统解析该 Excel 文件
|
||||
- **THEN** 系统跳过第1行,从第2行开始按列位置解析数据
|
||||
|
||||
#### Scenario: SN 列按固定顺序导入
|
||||
|
||||
- **GIVEN** 某行第2列(索引1)填写 `SN-001`
|
||||
- **WHEN** 系统解析并导入该行
|
||||
- **THEN** 创建设备记录时 `sn = "SN-001"`
|
||||
|
||||
#### Scenario: 仅填写卡2和卡3时保留原始槽位
|
||||
|
||||
- **GIVEN** 某行 `最大SIM槽数 = 3`
|
||||
- **AND** `卡1` 为空,`卡2 = ICCID_A`,`卡3 = ICCID_B`
|
||||
- **WHEN** 系统解析该行
|
||||
- **THEN** 系统将 `ICCID_A` 识别为槽位2的卡
|
||||
- **AND** 系统将 `ICCID_B` 识别为槽位3的卡
|
||||
- **AND** 系统不得将其重排为槽位1和槽位2
|
||||
|
||||
#### Scenario: 填写槽位超出最大SIM槽数
|
||||
|
||||
- **GIVEN** 某行 `最大SIM槽数 = 2`
|
||||
- **AND** `卡3 = ICCID_C`
|
||||
- **WHEN** 系统解析该行
|
||||
- **THEN** 该行导入失败
|
||||
- **AND** 失败原因为"卡槽填写超出最大SIM槽数"
|
||||
|
||||
#### Scenario: 代理尝试导入设备
|
||||
|
||||
- **WHEN** 代理用户尝试导入设备
|
||||
- **THEN** 系统返回 403 错误,提示"无权限执行此操作"
|
||||
|
||||
#### Scenario: 文件格式错误
|
||||
|
||||
- **WHEN** 平台管理员上传非 Excel 格式(.xlsx)的文件
|
||||
- **THEN** 系统创建任务但处理失败,任务状态为"失败",错误信息为"不支持的文件格式 .csv,请上传Excel文件(.xlsx)"
|
||||
|
||||
#### Scenario: Excel结构错误
|
||||
|
||||
- **WHEN** 平台管理员上传的Excel文件无工作表或无数据行
|
||||
- **THEN** 系统创建任务但处理失败,记录相应错误信息
|
||||
|
||||
#### Scenario: VirtualNo 为空的行记录为失败
|
||||
|
||||
- **WHEN** Excel 中某行第1列(虚拟号)为空
|
||||
- **THEN** 该行计入失败(`fail_count++`),失败原因为"设备虚拟号(virtual_no)不能为空"
|
||||
- **AND** 其他合法行继续导入,不因此行中断
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备导入任务执行
|
||||
|
||||
系统 SHALL 异步执行设备导入任务,逐行处理 Excel 数据。
|
||||
|
||||
**处理规则**:
|
||||
- 打开Excel文件,选择第一个sheet(或优先"导入数据"sheet)
|
||||
- 跳过第1行(表头行),从第2行开始按固定列位置解析数据
|
||||
- 若整行目标列皆为空,视为空行跳过,不计入 total
|
||||
- 逐行解析数据,并保留每个 ICCID 对应的原始槽位编号
|
||||
- 对每行数据执行以下校验:
|
||||
1. 设备号是否已存在(已存在则跳过)
|
||||
2. ICCID 是否存在于系统中(不存在则失败)
|
||||
3. ICCID 是否已绑定其他设备(已绑定则失败)
|
||||
4. 已填写 ICCID 的槽位是否超出该设备 `max_sim_slots`(超出则失败)
|
||||
- 校验通过后:
|
||||
1. 创建设备记录
|
||||
2. 按 ICCID 的原始槽位创建 `slot_position` 一致的设备-卡绑定记录
|
||||
- 记录处理结果(成功/跳过/失败)
|
||||
|
||||
**任务状态**:
|
||||
- 1: 待处理
|
||||
- 2: 处理中
|
||||
- 3: 已完成
|
||||
- 4: 失败
|
||||
|
||||
#### Scenario: 导入成功
|
||||
|
||||
- **WHEN** Excel 中所有设备号不重复且 ICCID 有效
|
||||
- **THEN** 系统创建所有设备和绑定记录,任务状态为"已完成"
|
||||
|
||||
#### Scenario: 前置空槽导入成功
|
||||
|
||||
- **GIVEN** 某行 `卡1` 为空,`卡2 = ICCID_A`,`卡3 = ICCID_B`
|
||||
- **WHEN** 该行校验通过并执行导入
|
||||
- **THEN** 系统创建两条绑定记录
|
||||
- **AND** `ICCID_A` 的 `slot_position = 2`
|
||||
- **AND** `ICCID_B` 的 `slot_position = 3`
|
||||
|
||||
#### Scenario: 部分导入成功
|
||||
|
||||
- **WHEN** Excel 中部分设备号已存在或部分 ICCID 无效
|
||||
- **THEN** 系统只导入有效的行,记录跳过和失败的详情,任务状态为"已完成"
|
||||
|
||||
#### Scenario: ICCID 不存在
|
||||
|
||||
- **WHEN** Excel 中某行的 ICCID 在系统中不存在
|
||||
- **THEN** 该行导入失败,记录失败原因"ICCID 不存在"
|
||||
|
||||
#### Scenario: ICCID 已绑定其他设备
|
||||
|
||||
- **WHEN** Excel 中某行的 ICCID 已绑定到其他设备
|
||||
- **THEN** 该行导入失败,记录失败原因"ICCID 已绑定其他设备"
|
||||
|
||||
#### Scenario: 设备号重复
|
||||
|
||||
- **WHEN** Excel 中某行的设备号在系统中已存在
|
||||
- **THEN** 该行被跳过,记录跳过原因"设备号已存在"
|
||||
|
||||
#### Scenario: 填写槽位超出最大SIM槽数时任务记录失败
|
||||
|
||||
- **WHEN** Excel 中某行声明 `最大SIM槽数 = 2`,但填写了 `卡3` 或 `卡4`
|
||||
- **THEN** 该行计入失败(`fail_count++`)
|
||||
- **AND** 失败明细中的 `reason` 字段为"卡槽填写超出最大SIM槽数"
|
||||
|
||||
#### Scenario: 导入任务结果报告包含 VirtualNo 失败原因
|
||||
|
||||
- **WHEN** 导入任务处理完毕,含有 VirtualNo 为空的行
|
||||
- **THEN** 失败明细列表中,该行的 `reason` 字段为"设备虚拟号(virtual_no)不能为空",`line` 字段为对应行号
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备导入任务列表查询
|
||||
|
||||
系统 SHALL 提供设备导入任务列表查询功能,仅平台用户可操作。
|
||||
|
||||
**API 端点**: `GET /api/admin/devices/import/tasks`
|
||||
|
||||
**查询条件**:
|
||||
- `status`(可选): 任务状态 1-4
|
||||
- `batch_no`(可选): 批次号,模糊匹配
|
||||
- `start_time`(可选): 创建时间起始
|
||||
- `end_time`(可选): 创建时间结束
|
||||
|
||||
**分页**:
|
||||
- 默认每页 20 条,最大每页 100 条
|
||||
|
||||
**响应字段**:
|
||||
- `id`: 任务 ID
|
||||
- `task_no`: 任务编号
|
||||
- `status`: 任务状态
|
||||
- `status_text`: 任务状态文本
|
||||
- `batch_no`: 批次号
|
||||
- `file_name`: 文件名
|
||||
- `total_count`: 总数
|
||||
- `success_count`: 成功数
|
||||
- `skip_count`: 跳过数
|
||||
- `fail_count`: 失败数
|
||||
- `started_at`: 开始时间
|
||||
- `completed_at`: 完成时间
|
||||
- `error_message`: 错误信息
|
||||
- `created_at`: 创建时间
|
||||
|
||||
**权限**: 仅平台用户
|
||||
|
||||
#### Scenario: 查询导入任务列表
|
||||
|
||||
- **WHEN** 平台管理员查询导入任务列表
|
||||
- **THEN** 系统返回所有导入任务,按创建时间倒序排列
|
||||
|
||||
#### Scenario: 按状态筛选任务
|
||||
|
||||
- **WHEN** 平台管理员查询状态为 3(已完成)的任务
|
||||
- **THEN** 系统只返回已完成的任务
|
||||
|
||||
#### Scenario: 代理尝试查询导入任务
|
||||
|
||||
- **WHEN** 代理用户尝试查询导入任务
|
||||
- **THEN** 系统返回 403 错误,提示"无权限执行此操作"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备导入任务详情查询
|
||||
|
||||
系统 SHALL 提供设备导入任务详情查询功能,包含跳过和失败记录的详细信息。
|
||||
|
||||
**API 端点**: `GET /api/admin/devices/import/tasks/:id`
|
||||
|
||||
**响应字段**:
|
||||
- 包含任务列表的所有字段
|
||||
- `skipped_items`: 跳过记录详情列表
|
||||
- `line`: 行号
|
||||
- `device_no`: 设备号
|
||||
- `reason`: 跳过原因
|
||||
- `failed_items`: 失败记录详情列表
|
||||
- `line`: 行号
|
||||
- `device_no`: 设备号
|
||||
- `reason`: 失败原因
|
||||
|
||||
**权限**: 仅平台用户
|
||||
|
||||
#### Scenario: 查询导入任务详情
|
||||
|
||||
- **WHEN** 平台管理员查询导入任务详情(ID=1)
|
||||
- **THEN** 系统返回任务的完整信息,包括跳过和失败记录详情
|
||||
|
||||
#### Scenario: 查询不存在的任务
|
||||
|
||||
- **WHEN** 平台管理员查询不存在的任务(ID=999)
|
||||
- **THEN** 系统返回 404 错误,提示"导入任务不存在"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备导入批次支持实名策略配置
|
||||
|
||||
系统 SHALL 在设备导入请求(`ImportDeviceRequest`)中支持 `realname_policy` 参数,以批次为单位指定导入设备的实名策略,默认为 `none`。
|
||||
|
||||
**请求字段新增**:
|
||||
- `realname_policy`:实名认证策略(枚举:`none` / `before_order` / `after_order`,可选,默认 `none`)
|
||||
|
||||
**任务字段新增**:
|
||||
- `DeviceImportTask` 新增 `realname_policy` 字段(VARCHAR(20) NOT NULL DEFAULT 'none')
|
||||
|
||||
**导入行为**:
|
||||
- 该批次导入的所有设备统一使用 `realname_policy` 的值写入 `Device.realname_policy`,不支持同一批次混用
|
||||
- 导入任务记录该字段用于审计
|
||||
|
||||
#### Scenario: 不传 realname_policy 时默认为 none
|
||||
- **WHEN** 设备导入请求未包含 `realname_policy` 字段
|
||||
- **THEN** 导入的所有设备 `realname_policy` 为 `none`
|
||||
|
||||
#### Scenario: 指定 before_order 导入设备
|
||||
- **WHEN** 设备导入请求中 `realname_policy` = `before_order`
|
||||
- **THEN** 该批次导入的所有设备 `realname_policy` 均为 `before_order`
|
||||
|
||||
#### Scenario: 传入无效 realname_policy 被拒绝
|
||||
- **WHEN** 设备导入请求中 `realname_policy` = `unknown`
|
||||
- **THEN** 系统返回参数校验错误,提示实名策略值无效
|
||||
|
||||
#### Scenario: 设备导入任务响应返回 realname_policy
|
||||
- **WHEN** 管理员查询设备导入任务列表或详情
|
||||
- **THEN** 响应中包含 `realname_policy` 字段
|
||||
@@ -1,61 +0,0 @@
|
||||
# Spec: 设备级套餐耗尽触发绑定卡停机
|
||||
|
||||
## 业务背景
|
||||
|
||||
### 为什么需要设备级套餐耗尽停机
|
||||
|
||||
**现状问题**:
|
||||
- 设备套餐(`carrier_type = "device"`)流量耗尽时,`checkAndTriggerSuspension` 缺少对绑定卡的停机逻辑
|
||||
- 仅有 `iot_card` 类型载体的停机分支,设备类型无对应处理
|
||||
- 导致设备套餐流量耗尽后,绑定卡仍保持在线状态,无法及时停机
|
||||
|
||||
**业务目标**:
|
||||
- 设备套餐流量耗尽时,自动对所有绑定卡触发停机检查
|
||||
- 与 iot_card 类型保持行为一致性
|
||||
- 幂等安全,重复触发不产生副作用
|
||||
|
||||
---
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 设备级套餐耗尽时触发绑定卡停机
|
||||
|
||||
当套餐载体为设备(`carrier_type = "device"`)且所有生效套餐流量耗尽时,系统 SHALL 查询该设备绑定的所有 IoT 卡,并对每张卡异步触发停机检查(`CheckAndStopCard`)。
|
||||
|
||||
**触发位置**:`UsageService.checkAndTriggerSuspension`,在确认无生效套餐后执行。
|
||||
|
||||
**幂等保护**:`CheckAndStopCard` 内部检查卡是否已为停机状态(`network_status = 0`),重复调用不会重复调网关。
|
||||
|
||||
**仅处理绑定卡**:通过 `DeviceSimBindingStore.ListByDeviceID` 获取当前绑定关系(`bind_status = 1`),未绑定或已解绑的卡不受影响。
|
||||
|
||||
#### Scenario: 设备套餐流量耗尽,绑定卡在线
|
||||
|
||||
- **WHEN** `DeductDataUsage` 在 `carrier_type = "device"` 场景下扣减流量后,`checkAndTriggerSuspension` 发现该设备无生效套餐(`status IN (0,1)`)
|
||||
- **THEN** 系统查询该设备的绑定卡列表,对每张在线(`network_status = 1`)且已实名(`real_name_status = 1`)的卡异步调用 `CheckAndStopCard`,将卡停机(`network_status = 0`,`stop_reason = "traffic_exhausted"`)
|
||||
|
||||
#### Scenario: 设备套餐流量耗尽,绑定卡已停机
|
||||
|
||||
- **WHEN** `checkAndTriggerSuspension` 触发设备级停机检查,但绑定卡已经是停机状态(`network_status = 0`)
|
||||
- **THEN** `CheckAndStopCard` 检查到卡已停机,跳过网关调用,不产生重复操作
|
||||
|
||||
#### Scenario: 设备无绑定卡
|
||||
|
||||
- **WHEN** `checkAndTriggerSuspension` 触发设备级停机检查,但该设备当前无有效绑定记录
|
||||
- **THEN** 系统静默跳过,不报错,记录 Debug 日志
|
||||
|
||||
#### Scenario: 套餐载体为 iot_card,不受影响
|
||||
|
||||
- **WHEN** `DeductDataUsage` 在 `carrier_type = "iot_card"` 场景下触发停机检查
|
||||
- **THEN** 仅对该卡本身执行 `CheckAndStopCard`,不查询设备绑定关系(与现有行为一致)
|
||||
|
||||
---
|
||||
|
||||
## 实现说明
|
||||
|
||||
### 依赖注入变更
|
||||
|
||||
`UsageService` 新增 `deviceSimBindingStore *postgres.DeviceSimBindingStore` 字段,通过构造函数注入,`bootstrap` 层负责传入。
|
||||
|
||||
### 异步触发模式
|
||||
|
||||
对每张绑定卡启动独立 goroutine 异步执行 `CheckAndStopCard`,不阻塞当前流量扣减流程。若 `stopResumeCallback == nil`,仅记录 Warn 日志,不报错。
|
||||
@@ -1,84 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 批量设置设备的套餐系列
|
||||
|
||||
系统 SHALL 允许代理批量为设备设置套餐系列分配。只能设置当前店铺被分配且启用的套餐系列。
|
||||
|
||||
#### Scenario: 成功批量设置
|
||||
- **WHEN** 代理提交多个设备 ID 和一个有效的 series_allocation_id
|
||||
- **THEN** 系统更新这些设备的 series_allocation_id 字段
|
||||
|
||||
#### Scenario: 系列未分配给店铺
|
||||
- **WHEN** 代理尝试设置一个未分配给设备所属店铺的系列
|
||||
- **THEN** 系统返回错误 "该套餐系列未分配给此店铺"
|
||||
|
||||
#### Scenario: 系列分配已禁用
|
||||
- **WHEN** 代理尝试设置一个已禁用的系列分配
|
||||
- **THEN** 系统返回错误 "该套餐系列分配已禁用"
|
||||
|
||||
#### Scenario: 设备不存在
|
||||
- **WHEN** 提交的设备 ID 中有不存在的设备
|
||||
- **THEN** 系统返回错误,列出不存在的设备 ID
|
||||
|
||||
#### Scenario: 设备不属于当前店铺
|
||||
- **WHEN** 代理尝试设置不属于自己店铺的设备
|
||||
- **THEN** 系统返回错误 "部分设备不属于您的店铺"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 清除设备的套餐系列关联
|
||||
|
||||
系统 SHALL 允许代理清除设备的套餐系列关联。
|
||||
|
||||
#### Scenario: 清除单设备关联
|
||||
- **WHEN** 代理将设备的 series_allocation_id 设为 0
|
||||
- **THEN** 系统清除该设备的套餐系列关联
|
||||
|
||||
#### Scenario: 批量清除关联
|
||||
- **WHEN** 代理批量提交设备 ID 列表,series_allocation_id 为 0
|
||||
- **THEN** 系统清除这些设备的套餐系列关联
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 查询设备的套餐系列信息
|
||||
|
||||
系统 SHALL 在设备详情和列表中返回套餐系列关联信息。
|
||||
|
||||
#### Scenario: 设备详情包含系列信息
|
||||
- **WHEN** 查询设备详情
|
||||
- **THEN** 响应包含 series_allocation_id、关联的系列名称、佣金状态
|
||||
|
||||
#### Scenario: 设备列表支持按系列筛选
|
||||
- **WHEN** 代理按 series_allocation_id 筛选设备列表
|
||||
- **THEN** 系统只返回关联该系列的设备
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Device 模型新增字段
|
||||
|
||||
系统 MUST 在 Device 模型中新增以下字段:
|
||||
- `series_allocation_id`:套餐系列分配 ID
|
||||
- `first_commission_paid`:一次性佣金是否已发放(默认 false)
|
||||
- `accumulated_recharge`:累计充值金额(默认 0)
|
||||
|
||||
#### Scenario: 新设备默认值
|
||||
- **WHEN** 创建新设备
|
||||
- **THEN** series_allocation_id 为空,first_commission_paid 为 false,accumulated_recharge 为 0
|
||||
|
||||
#### Scenario: 字段在响应中可见
|
||||
- **WHEN** 查询设备信息
|
||||
- **THEN** 响应包含这三个新字段
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备级套餐购买优先级
|
||||
|
||||
设备购买套餐时 MUST 使用 Device.series_allocation_id 确定可购买的套餐系列,而非设备下单卡的 series_allocation_id。
|
||||
|
||||
#### Scenario: 设备有系列关联
|
||||
- **WHEN** 设备有 series_allocation_id,且其下的卡也有各自的 series_allocation_id
|
||||
- **THEN** 设备级套餐购买使用设备的 series_allocation_id
|
||||
|
||||
#### Scenario: 设备无系列关联
|
||||
- **WHEN** 设备的 series_allocation_id 为空
|
||||
- **THEN** 该设备无法购买设备级套餐
|
||||
@@ -1,69 +0,0 @@
|
||||
# device-signal-summary Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change improve-audit-log-readability-and-signal-summary. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: 设备实时状态必须新增信号综合字段
|
||||
系统 SHALL 在设备实时状态响应中保留 `rsrp`、`rsrq`、`rssi`、`sinr` 四个原始字段,并新增 `signal_quality` 与 `signal_bad_reason` 两个综合字段。
|
||||
|
||||
#### Scenario: 后台管理端返回综合字段
|
||||
- **WHEN** 管理员查询设备实时状态且 Gateway 返回了信号相关数据
|
||||
- **THEN** 响应同时包含原始四个信号字段以及 `signal_quality`、`signal_bad_reason`
|
||||
|
||||
#### Scenario: C 端返回综合字段
|
||||
- **WHEN** 个人客户查询设备实时状态且 Gateway 返回了信号相关数据
|
||||
- **THEN** 响应同时包含原始四个信号字段以及 `signal_quality`、`signal_bad_reason`
|
||||
|
||||
### Requirement: 信号好坏字段必须使用面向小白的固定文案
|
||||
系统 SHALL 使用固定中文文案表达信号综合好坏,不得直接要求用户理解通信技术指标。
|
||||
|
||||
#### Scenario: 信号很好
|
||||
- **WHEN** 多项信号指标显示设备当前网络状态较优
|
||||
- **THEN** `signal_quality` 返回 `信号很好`
|
||||
|
||||
#### Scenario: 信号正常
|
||||
- **WHEN** 信号指标处于可接受区间且未出现明显异常
|
||||
- **THEN** `signal_quality` 返回 `信号正常`
|
||||
|
||||
#### Scenario: 信号较弱或很差
|
||||
- **WHEN** 信号指标显示覆盖、质量或抗干扰能力明显下降
|
||||
- **THEN** `signal_quality` 返回 `信号较弱` 或 `信号很差`
|
||||
|
||||
#### Scenario: 数据不足
|
||||
- **WHEN** 四个原始信号字段全部缺失或不足以支撑判断
|
||||
- **THEN** `signal_quality` 返回 `暂无数据`
|
||||
|
||||
### Requirement: 信号怀疑原因字段必须使用怀疑式提示语
|
||||
系统 SHALL 使用单一的、面向非专业用户的“怀疑原因”文案解释信号异常,不得直接输出技术术语结论。
|
||||
|
||||
#### Scenario: 覆盖较弱
|
||||
- **WHEN** 信号指标更接近覆盖不足问题
|
||||
- **THEN** `signal_bad_reason` 返回 `怀疑当前位置信号覆盖较弱`
|
||||
|
||||
#### Scenario: 干扰较多
|
||||
- **WHEN** 信号指标更接近干扰或质量下降问题
|
||||
- **THEN** `signal_bad_reason` 返回 `怀疑周围干扰较多`
|
||||
|
||||
#### Scenario: 遮挡较强
|
||||
- **WHEN** 信号指标更接近设备所处位置存在遮挡的问题
|
||||
- **THEN** `signal_bad_reason` 返回 `怀疑设备所处位置遮挡较强`
|
||||
|
||||
#### Scenario: 网络环境不稳定
|
||||
- **WHEN** 多项指标波动或组合异常但无法归为单一覆盖问题
|
||||
- **THEN** `signal_bad_reason` 返回 `怀疑网络环境不稳定`
|
||||
|
||||
#### Scenario: 暂时无法判断
|
||||
- **WHEN** 原始数据缺失或组合不足以产出可信原因
|
||||
- **THEN** `signal_bad_reason` 返回 `暂时无法判断`
|
||||
|
||||
### Requirement: B 端与 C 端必须复用同一套信号摘要口径
|
||||
系统 SHALL 在后台管理端统一计算信号综合字段,并由 C 端复用同一结果映射,确保两端口径一致。
|
||||
|
||||
#### Scenario: 两端返回一致文案
|
||||
- **WHEN** 同一设备在后台管理端与 C 端分别查询实时状态
|
||||
- **THEN** 两端返回的 `signal_quality` 与 `signal_bad_reason` 含义一致,不出现口径分叉
|
||||
|
||||
#### Scenario: 调整规则只需修改一处
|
||||
- **WHEN** 后续调整信号摘要阈值或文案
|
||||
- **THEN** 系统只需修改统一计算逻辑,即可同步影响后台管理端与 C 端返回结果
|
||||
|
||||
@@ -1,397 +0,0 @@
|
||||
# device Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change add-device-management. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: 设备列表查询
|
||||
|
||||
系统 SHALL 提供设备列表查询功能,支持多维度筛选和分页。
|
||||
|
||||
**查询条件**:
|
||||
- `virtual_no`(可选): 设备虚拟号,支持模糊匹配(原 `device_no` 字段,已全量改名)
|
||||
- `device_name`(可选): 设备名称,支持模糊匹配
|
||||
- `status`(可选): 设备状态,枚举值 1-在库 | 2-已分销 | 3-已激活 | 4-已停用
|
||||
- `shop_id`(可选): 店铺 ID,NULL 表示平台库存
|
||||
- `batch_no`(可选): 批次号,精确匹配
|
||||
- `device_type`(可选): 设备类型
|
||||
- `manufacturer`(可选): 制造商,支持模糊匹配
|
||||
- `created_at_start`(可选): 创建时间起始
|
||||
- `created_at_end`(可选): 创建时间结束
|
||||
|
||||
**分页**:
|
||||
- 默认每页 20 条,最大每页 100 条
|
||||
- 返回总记录数和总页数
|
||||
|
||||
**数据权限**:
|
||||
- 平台用户可查看所有设备
|
||||
- 代理用户只能查看自己店铺及下级店铺的设备
|
||||
|
||||
**API 端点**: `GET /api/admin/devices`
|
||||
|
||||
**响应字段**:
|
||||
- `id`: 设备 ID
|
||||
- `virtual_no`: 设备虚拟号(原 `device_no`,已改名)
|
||||
- `device_name`: 设备名称
|
||||
- `device_model`: 设备型号
|
||||
- `device_type`: 设备类型
|
||||
- `max_sim_slots`: 最大插槽数
|
||||
- `manufacturer`: 制造商
|
||||
- `batch_no`: 批次号
|
||||
- `shop_id`: 店铺 ID
|
||||
- `shop_name`: 店铺名称
|
||||
- `status`: 状态
|
||||
- `status_name`: 状态名称
|
||||
- `bound_card_count`: 已绑定卡数量
|
||||
- `activated_at`: 激活时间
|
||||
- `created_at`: 创建时间
|
||||
- `updated_at`: 更新时间
|
||||
|
||||
#### Scenario: 平台查询所有设备
|
||||
|
||||
- **WHEN** 平台管理员查询设备列表,不带任何筛选条件
|
||||
- **THEN** 系统返回所有设备,按创建时间倒序排列
|
||||
|
||||
#### Scenario: 按虚拟号模糊查询
|
||||
|
||||
- **WHEN** 管理员输入 virtual_no = "GPS"
|
||||
- **THEN** 系统返回虚拟号包含 "GPS" 的所有设备
|
||||
|
||||
#### Scenario: 按状态筛选设备
|
||||
|
||||
- **WHEN** 管理员查询状态为 1(在库)的设备
|
||||
- **THEN** 系统只返回在库状态的设备
|
||||
|
||||
#### Scenario: 代理查询自己店铺的设备
|
||||
|
||||
- **WHEN** 代理用户(店铺 ID=10)查询设备列表
|
||||
- **THEN** 系统只返回 shop_id 为 10 及其下级店铺的设备
|
||||
|
||||
#### Scenario: 查询平台库存设备
|
||||
|
||||
- **WHEN** 平台管理员查询 shop_id 为空的设备
|
||||
- **THEN** 系统返回所有平台库存设备(shop_id = NULL)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备详情查询
|
||||
|
||||
系统 SHALL 提供设备详情查询功能,返回设备的基本信息。
|
||||
|
||||
**API 端点**: `GET /api/admin/devices/:id`
|
||||
|
||||
**响应字段**:
|
||||
- 包含设备的所有基本字段(含 `virtual_no`,不再有 `device_no`)
|
||||
- `shop_name`: 店铺名称(如果有)
|
||||
|
||||
**数据权限**:
|
||||
- 平台用户可查看所有设备
|
||||
- 代理用户只能查看自己店铺及下级店铺的设备
|
||||
|
||||
#### Scenario: 查询设备详情成功
|
||||
|
||||
- **WHEN** 管理员查询设备详情(ID=1)
|
||||
- **THEN** 系统返回该设备的完整基本信息,响应中含 `virtual_no` 字段,不含 `device_no`
|
||||
|
||||
#### Scenario: 查询不存在的设备
|
||||
|
||||
- **WHEN** 管理员查询不存在的设备(ID=999)
|
||||
- **THEN** 系统返回 404 错误,提示"设备不存在"
|
||||
|
||||
#### Scenario: 代理查询无权限的设备
|
||||
|
||||
- **WHEN** 代理用户(店铺 ID=10)查询其他店铺的设备(shop_id=20,非下级)
|
||||
- **THEN** 系统返回 404 错误,提示"设备不存在"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 删除设备
|
||||
|
||||
系统 SHALL 提供删除设备功能,仅平台用户可操作,执行软删除。
|
||||
|
||||
**API 端点**: `DELETE /api/admin/devices/:id`
|
||||
|
||||
**业务规则**:
|
||||
- 仅平台用户可删除设备
|
||||
- 删除设备时自动解绑该设备上的所有卡
|
||||
- 执行软删除(设置 deleted_at)
|
||||
|
||||
**权限**: 仅平台用户
|
||||
|
||||
#### Scenario: 平台删除设备成功
|
||||
|
||||
- **WHEN** 平台管理员删除设备(ID=1)
|
||||
- **THEN** 系统软删除该设备,并解绑设备上的所有卡
|
||||
|
||||
#### Scenario: 代理尝试删除设备
|
||||
|
||||
- **WHEN** 代理用户尝试删除设备
|
||||
- **THEN** 系统返回 403 错误,提示"无权限执行此操作"
|
||||
|
||||
#### Scenario: 删除不存在的设备
|
||||
|
||||
- **WHEN** 平台管理员删除不存在的设备(ID=999)
|
||||
- **THEN** 系统返回 404 错误,提示"设备不存在"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 获取设备绑定的卡列表
|
||||
|
||||
系统 SHALL 提供查询设备绑定的 IoT 卡列表功能。
|
||||
|
||||
**API 端点**: `GET /api/admin/devices/:id/cards`
|
||||
|
||||
**响应字段**:
|
||||
- `bindings`: 绑定列表,每个元素包含:
|
||||
- `id`: 绑定记录 ID
|
||||
- `slot_position`: 插槽位置(1-4)
|
||||
- `iot_card_id`: IoT 卡 ID
|
||||
- `iccid`: ICCID
|
||||
- `msisdn`: 接入号
|
||||
- `carrier_name`: 运营商名称
|
||||
- `status`: 卡状态
|
||||
- `bind_time`: 绑定时间
|
||||
|
||||
#### Scenario: 查询设备绑定的卡
|
||||
|
||||
- **WHEN** 管理员查询设备(ID=1)绑定的卡
|
||||
- **THEN** 系统返回该设备所有已绑定的卡信息,按插槽位置排序
|
||||
|
||||
#### Scenario: 查询无绑定卡的设备
|
||||
|
||||
- **WHEN** 管理员查询没有绑定卡的设备
|
||||
- **THEN** 系统返回空的绑定列表
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 绑定卡到设备
|
||||
|
||||
系统 SHALL 提供将 IoT 卡绑定到设备指定插槽的功能,仅平台用户可操作。
|
||||
|
||||
**API 端点**: `POST /api/admin/devices/:id/cards`
|
||||
|
||||
**请求参数**:
|
||||
- `iot_card_id`: IoT 卡 ID(必填)
|
||||
- `slot_position`: 插槽位置 1-4(必填)
|
||||
|
||||
**业务规则**:
|
||||
- 仅平台用户可操作
|
||||
- 插槽位置不能超过设备的 max_sim_slots
|
||||
- 该插槽必须为空(无已绑定的卡)
|
||||
- 该卡不能已绑定到其他设备
|
||||
- 绑定操作不改变卡的 shop_id
|
||||
|
||||
**权限**: 仅平台用户
|
||||
|
||||
#### Scenario: 绑定卡到设备成功
|
||||
|
||||
- **WHEN** 平台管理员将 IoT 卡(ID=101)绑定到设备(ID=1)的插槽 2
|
||||
- **THEN** 系统创建绑定记录,返回绑定成功信息
|
||||
|
||||
#### Scenario: 绑定到已占用的插槽
|
||||
|
||||
- **WHEN** 平台管理员尝试绑定卡到已有卡的插槽
|
||||
- **THEN** 系统返回错误,提示"该插槽已有绑定的卡"
|
||||
|
||||
#### Scenario: 绑定已被绑定的卡
|
||||
|
||||
- **WHEN** 平台管理员尝试绑定已绑定到其他设备的卡
|
||||
- **THEN** 系统返回错误,提示"该卡已绑定到其他设备"
|
||||
|
||||
#### Scenario: 插槽位置超出范围
|
||||
|
||||
- **WHEN** 平台管理员尝试绑定卡到插槽 5(设备 max_sim_slots=4)
|
||||
- **THEN** 系统返回错误,提示"插槽位置超出设备最大插槽数"
|
||||
|
||||
#### Scenario: 代理尝试绑定卡
|
||||
|
||||
- **WHEN** 代理用户尝试绑定卡到设备
|
||||
- **THEN** 系统返回 403 错误,提示"无权限执行此操作"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 解绑设备上的卡
|
||||
|
||||
系统 SHALL 提供解绑设备上指定卡的功能,仅平台用户可操作。
|
||||
|
||||
**API 端点**: `DELETE /api/admin/devices/:id/cards/:cardId`
|
||||
|
||||
**业务规则**:
|
||||
- 仅平台用户可操作
|
||||
- 更新绑定记录的 bind_status 为 2(已解绑),记录 unbind_time
|
||||
- 解绑操作不改变卡的 shop_id
|
||||
|
||||
**权限**: 仅平台用户
|
||||
|
||||
#### Scenario: 解绑卡成功
|
||||
|
||||
- **WHEN** 平台管理员解绑设备(ID=1)上的卡(ID=101)
|
||||
- **THEN** 系统更新绑定记录状态为已解绑,返回成功信息
|
||||
|
||||
#### Scenario: 解绑不存在的绑定关系
|
||||
|
||||
- **WHEN** 平台管理员尝试解绑不存在的绑定关系
|
||||
- **THEN** 系统返回错误,提示"该卡未绑定到此设备"
|
||||
|
||||
#### Scenario: 代理尝试解绑卡
|
||||
|
||||
- **WHEN** 代理用户尝试解绑设备上的卡
|
||||
- **THEN** 系统返回 403 错误,提示"无权限执行此操作"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 批量分配设备
|
||||
|
||||
系统 SHALL 提供批量分配设备给下级店铺的功能,分配时自动同步绑定卡的归属。
|
||||
|
||||
**API 端点**: `POST /api/admin/devices/allocate`
|
||||
|
||||
**请求参数**:
|
||||
- `target_shop_id`: 目标店铺 ID(必填)
|
||||
- `device_ids`: 设备 ID 列表(必填,最多 100 个)
|
||||
- `remark`: 备注(可选)
|
||||
|
||||
**业务规则**:
|
||||
- 只能分配给直属下级店铺,不可跨级
|
||||
- 平台只能分配 shop_id=NULL 的设备
|
||||
- 代理只能分配自己店铺的设备
|
||||
- 分配后:
|
||||
- 设备的 shop_id 变更为目标店铺 ID
|
||||
- 设备绑定的所有卡的 shop_id 也变更为目标店铺 ID
|
||||
- 设备状态变为「已分销」(2)
|
||||
- 创建资产分配记录(asset_type='device')
|
||||
|
||||
**响应**:
|
||||
- `success_count`: 成功数量
|
||||
- `fail_count`: 失败数量
|
||||
- `failed_items`: 失败详情列表
|
||||
|
||||
#### Scenario: 平台分配设备给一级代理
|
||||
|
||||
- **WHEN** 平台管理员将 5 台设备分配给一级代理店铺(ID=10)
|
||||
- **THEN** 系统更新这 5 台设备及其绑定卡的 shop_id 为 10,创建分配记录,返回成功数量
|
||||
|
||||
#### Scenario: 代理分配设备给下级
|
||||
|
||||
- **WHEN** 代理(店铺 ID=10)将 3 台设备分配给直属下级店铺(ID=101)
|
||||
- **THEN** 系统更新这 3 台设备及其绑定卡的 shop_id 为 101,创建分配记录
|
||||
|
||||
#### Scenario: 分配给非直属下级
|
||||
|
||||
- **WHEN** 代理(店铺 ID=10)尝试分配设备给非直属下级店铺(ID=1011,是 101 的下级)
|
||||
- **THEN** 系统返回错误,提示"只能分配给直属下级店铺"
|
||||
|
||||
#### Scenario: 分配不属于自己的设备
|
||||
|
||||
- **WHEN** 代理(店铺 ID=10)尝试分配其他店铺的设备
|
||||
- **THEN** 系统跳过这些设备,只分配属于自己的设备
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 批量回收设备
|
||||
|
||||
系统 SHALL 提供批量回收已分配设备的功能,回收时自动同步绑定卡的归属。
|
||||
|
||||
**API 端点**: `POST /api/admin/devices/recall`
|
||||
|
||||
**请求参数**:
|
||||
- `device_ids`: 设备 ID 列表(必填,最多 100 个)
|
||||
- `remark`: 备注(可选)
|
||||
|
||||
**业务规则**:
|
||||
- 只能回收直属下级店铺的设备,不可跨级
|
||||
- 平台回收后:设备和绑定卡的 shop_id 变为 NULL
|
||||
- 代理回收后:设备和绑定卡的 shop_id 变为执行回收的店铺 ID
|
||||
- 创建资产回收记录(asset_type='device')
|
||||
|
||||
**响应**:
|
||||
- `success_count`: 成功数量
|
||||
- `fail_count`: 失败数量
|
||||
- `failed_items`: 失败详情列表
|
||||
|
||||
#### Scenario: 平台回收一级代理的设备
|
||||
|
||||
- **WHEN** 平台管理员回收一级代理店铺(ID=10)的 3 台设备
|
||||
- **THEN** 系统更新这 3 台设备及其绑定卡的 shop_id 为 NULL,创建回收记录
|
||||
|
||||
#### Scenario: 代理回收下级的设备
|
||||
|
||||
- **WHEN** 代理(店铺 ID=10)回收下级店铺(ID=101)的 2 台设备
|
||||
- **THEN** 系统更新这 2 台设备及其绑定卡的 shop_id 为 10,创建回收记录
|
||||
|
||||
#### Scenario: 回收非直属下级的设备
|
||||
|
||||
- **WHEN** 代理(店铺 ID=10)尝试回收非直属下级的设备
|
||||
- **THEN** 系统返回错误,提示"只能回收直属下级店铺的设备"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: device_no 全量改名为 virtual_no
|
||||
|
||||
系统 SHALL 将 `tb_device` 表和 `tb_personal_customer_device` 表中的 `device_no` 字段全量改名为 `virtual_no`,确保系统中不再有 `device_no` 的存在。
|
||||
|
||||
**数据库变更**:
|
||||
```sql
|
||||
ALTER TABLE tb_device RENAME COLUMN device_no TO virtual_no;
|
||||
ALTER TABLE tb_personal_customer_device RENAME COLUMN device_no TO virtual_no;
|
||||
```
|
||||
|
||||
**代码影响范围**:
|
||||
- `internal/model/device.go`:`DeviceNo` → `VirtualNo`,column tag 更新
|
||||
- `internal/model/personal_customer_device.go`:`DeviceNo` → `VirtualNo`,column tag 更新
|
||||
- `internal/model/dto/device_dto.go`:`DeviceResponse.DeviceNo` → `VirtualNo`,JSON tag 更新为 `"virtual_no"`
|
||||
- `internal/store/postgres/device_store.go`:`GetByIdentifier` 查询条件中 `device_no` → `virtual_no`
|
||||
- `internal/store/postgres/personal_customer_device_store.go`:所有 `device_no` 引用更新
|
||||
- 所有 Handler、Service 中引用 `DeviceNo` 字段的代码全量替换
|
||||
|
||||
**设备导入模板**:
|
||||
- 导入 Excel 模板中的列头从 `device_no` 更新为 `virtual_no`
|
||||
|
||||
#### Scenario: 改名后查询设备
|
||||
|
||||
- **WHEN** 改名迁移完成后,调用 `GetByIdentifier("GPS-001")`
|
||||
- **THEN** 系统在 `WHERE virtual_no = ? OR imei = ? OR sn = ?` 中正确匹配,与改名前行为一致
|
||||
|
||||
#### Scenario: 响应中字段名已更新
|
||||
|
||||
- **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 使该设备进入新代际并恢复在库可售
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
# Response DTO 规范规范
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Response DTO 必须包含状态文字字段
|
||||
|
||||
项目所有 Response DTO 中,若包含 int 类型状态字段,必须同时包含对应的 `_name` 或 `_text` 文字字段,用于显示该状态的中文描述。
|
||||
|
||||
#### Scenario: 账号列表 Response DTO
|
||||
|
||||
- **WHEN** API 返回账号列表(`GET /api/admin/accounts`)
|
||||
- **THEN** 响应中每个账号对象包含 `status` 字段(int,值:0=禁用,1=启用)
|
||||
- **THEN** 响应中同时包含 `status_name` 字段(string,值:"禁用"或"启用")
|
||||
- **THEN** 前端可直接使用 `status_name` 显示在 UI 上,无需维护单独的状态枚举映射表
|
||||
|
||||
#### Scenario: 资产详情 Response DTO
|
||||
|
||||
- **WHEN** API 返回单个资产信息(`GET /api/admin/assets/:id`)
|
||||
- **THEN** 响应包含 `status`(int)和 `status_name`(string)
|
||||
- **WHEN** 资产包含多个状态字段(如 `network_status`、`activation_status`、`online_status`)
|
||||
- **THEN** 每个状态字段对应一个文字字段:`network_status_name`、`activation_status_name`、`online_status_name`
|
||||
|
||||
#### Scenario: 订单详情中的多重状态
|
||||
|
||||
- **WHEN** API 返回订单详情(`GET /api/admin/orders/:id`)
|
||||
- **THEN** 订单对象包含 `payment_status`(int)和 `payment_status_name`(string)
|
||||
- **THEN** 订单对象包含 `commission_status`(int)和 `commission_status_name`(string)
|
||||
- **THEN** 若订单还有其他 int 类型状态字段,均配有对应的 `_name` 字段
|
||||
|
||||
### Requirement: DTO 文字字段命名规则
|
||||
|
||||
状态文字字段的命名应遵循统一规则:`{状态字段名}_name` 或 `{状态字段名}_text`。
|
||||
|
||||
#### Scenario: 标准命名
|
||||
|
||||
- **WHEN** 定义 DTO 时,状态字段为 `Status`
|
||||
- **THEN** 文字字段命名为 `StatusName`(推荐)或 `StatusText`
|
||||
- **WHEN** 状态字段为 `PaymentStatus`
|
||||
- **THEN** 文字字段命名为 `PaymentStatusName` 或 `PaymentStatusText`
|
||||
|
||||
#### Scenario: JSON 序列化一致性
|
||||
|
||||
- **WHEN** DTO 序列化为 JSON 返回给客户端
|
||||
- **THEN** 字段名使用 snake_case(符合项目 API 规范)
|
||||
- Go 字段 `Status` → JSON `status`
|
||||
- Go 字段 `StatusName` → JSON `status_name`
|
||||
|
||||
### Requirement: Service 层自动赋值 `_name` 字段
|
||||
|
||||
Service 层在构建 Response DTO 时,应自动赋值 `_name`/`_text` 字段,映射状态常量到中文描述。
|
||||
|
||||
#### Scenario: 获取账号详情自动赋值
|
||||
|
||||
- **WHEN** `AccountService.GetAccount(ctx, accountID)` 被调用
|
||||
- **THEN** Service 查询数据库获取账号信息
|
||||
- **THEN** Service 构建 Response DTO,自动设置 `StatusName = constants.GetAccountStatusName(account.Status)`
|
||||
- **THEN** 返回完整的 DTO 给 Handler,Handler 直接序列化响应
|
||||
|
||||
#### Scenario: 列表查询批量赋值
|
||||
|
||||
- **WHEN** `AccountService.ListAccounts(ctx, query)` 被调用
|
||||
- **THEN** Service 查询数据库获取账号列表
|
||||
- **THEN** Service 遍历每个账号,批量赋值 `StatusName` 字段
|
||||
- **THEN** 返回完整列表
|
||||
|
||||
### Requirement: 常量映射函数
|
||||
|
||||
在 `pkg/constants/` 中为每个业务模块定义 `Get{Module}StatusName(status int) string` 函数,用于映射状态值到中文描述。
|
||||
|
||||
#### Scenario: 账号状态映射函数
|
||||
|
||||
- **WHEN** Service 层需要获取账号状态的中文描述
|
||||
- **THEN** 调用 `constants.GetAccountStatusName(status)`
|
||||
- **THEN** 函数返回:
|
||||
- 若 `status == 0`,返回 `"禁用"`
|
||||
- 若 `status == 1`,返回 `"启用"`
|
||||
- 若状态值未知,返回 `"未知"`(不返回空字符串)
|
||||
|
||||
#### Scenario: 订单支付状态映射函数
|
||||
|
||||
- **WHEN** Service 层需要获取订单支付状态描述
|
||||
- **THEN** 调用 `constants.GetOrderPaymentStatusName(status)`
|
||||
- **THEN** 函数返回对应的中文(如 "待支付"、"已支付"、"已完成" 等)
|
||||
@@ -1,157 +0,0 @@
|
||||
# embedded-config Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change deployment-self-init. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: 配置嵌入
|
||||
|
||||
系统 SHALL 使用 Go 的 `go:embed` 指令将默认配置文件嵌入二进制文件。
|
||||
|
||||
嵌入文件位置:`pkg/config/defaults/config.yaml`
|
||||
|
||||
#### Scenario: 加载嵌入配置
|
||||
|
||||
- **WHEN** 调用 `config.Load()`
|
||||
- **THEN** 系统从嵌入的 `defaults/config.yaml` 读取默认配置
|
||||
- **AND** 无需外部配置文件即可启动
|
||||
|
||||
#### Scenario: 嵌入配置包含完整结构
|
||||
|
||||
- **WHEN** 读取嵌入配置
|
||||
- **THEN** 配置包含所有配置节:server、database、redis、storage、logging、queue、jwt、middleware、worker
|
||||
|
||||
### Requirement: 环境变量覆盖
|
||||
|
||||
系统 SHALL 支持通过环境变量覆盖嵌入的默认配置值。
|
||||
|
||||
环境变量格式:`JUNHONG_{SECTION}_{KEY}`
|
||||
|
||||
#### Scenario: 环境变量覆盖配置
|
||||
|
||||
- **WHEN** 设置环境变量 `JUNHONG_DATABASE_HOST=myhost`
|
||||
- **THEN** `config.Database.Host` 的值为 "myhost"
|
||||
- **AND** 覆盖嵌入配置中的默认值
|
||||
|
||||
#### Scenario: 嵌套配置覆盖
|
||||
|
||||
- **WHEN** 设置环境变量 `JUNHONG_LOGGING_LEVEL=debug`
|
||||
- **THEN** `config.Logging.Level` 的值为 "debug"
|
||||
|
||||
#### Scenario: 未设置环境变量
|
||||
|
||||
- **WHEN** 未设置某个配置的环境变量
|
||||
- **THEN** 使用嵌入配置中的默认值
|
||||
|
||||
### Requirement: Worker 运行配置
|
||||
|
||||
系统 SHALL 在嵌入默认配置中提供 `worker` 配置节,并支持通过 `JUNHONG_WORKER_ROLE` 和 `JUNHONG_WORKER_INSTANCE_NAME` 环境变量覆盖。
|
||||
|
||||
默认配置:
|
||||
- `worker.role`: `all`
|
||||
- `worker.instance_name`: 空字符串
|
||||
|
||||
#### Scenario: 默认 Worker 角色
|
||||
- **WHEN** 未设置 `JUNHONG_WORKER_ROLE`
|
||||
- **THEN** `config.Load()` 返回的 `Config.Worker.Role` 为 `all`
|
||||
|
||||
#### Scenario: 环境变量覆盖 Worker 角色
|
||||
- **WHEN** 设置环境变量 `JUNHONG_WORKER_ROLE=consumer`
|
||||
- **THEN** `config.Load()` 返回的 `Config.Worker.Role` 为 `consumer`
|
||||
|
||||
#### Scenario: 环境变量覆盖实例名称
|
||||
- **WHEN** 设置环境变量 `JUNHONG_WORKER_INSTANCE_NAME=worker-consumer-1`
|
||||
- **THEN** `config.Load()` 返回的 `Config.Worker.InstanceName` 为 `worker-consumer-1`
|
||||
|
||||
#### Scenario: Worker 角色参与配置校验
|
||||
- **WHEN** 设置环境变量 `JUNHONG_WORKER_ROLE=invalid`
|
||||
- **THEN** `config.Load()` 返回错误
|
||||
- **AND** 错误信息明确指出 `worker.role` 只允许 `all`、`leader`、`consumer`
|
||||
|
||||
### Requirement: 配置优先级
|
||||
|
||||
系统 SHALL 按以下优先级应用配置(高到低):
|
||||
|
||||
1. 环境变量 (JUNHONG_*)
|
||||
2. 嵌入默认值 (go:embed)
|
||||
|
||||
#### Scenario: 优先级验证
|
||||
|
||||
- **WHEN** 嵌入配置中 `server.address` 为 ":3000"
|
||||
- **AND** 设置环境变量 `JUNHONG_SERVER_ADDRESS=:8080`
|
||||
- **THEN** 最终 `config.Server.Address` 为 ":8080"
|
||||
|
||||
### Requirement: 必填配置验证
|
||||
|
||||
系统 SHALL 在加载配置后验证必填配置项是否已设置。
|
||||
|
||||
必填配置项:
|
||||
- `database.host`
|
||||
- `database.user`
|
||||
- `database.password`
|
||||
- `database.dbname`
|
||||
- `redis.address`
|
||||
- `jwt.secret_key`
|
||||
|
||||
#### Scenario: 必填配置缺失
|
||||
|
||||
- **WHEN** 必填配置项为空且未通过环境变量设置
|
||||
- **THEN** `config.Load()` 返回错误
|
||||
- **AND** 错误信息明确指出缺失的配置项和对应的环境变量名
|
||||
|
||||
#### Scenario: 必填配置通过环境变量提供
|
||||
|
||||
- **WHEN** 所有必填配置通过环境变量设置
|
||||
- **THEN** `config.Load()` 成功返回配置
|
||||
|
||||
### Requirement: 删除外部配置文件支持
|
||||
|
||||
系统 SHALL 移除对外部配置文件的支持。
|
||||
|
||||
#### Scenario: 不读取 configs 目录
|
||||
|
||||
- **WHEN** 应用启动
|
||||
- **THEN** 不读取 `configs/*.yaml` 文件
|
||||
- **AND** 不依赖 `CONFIG_PATH` 或 `CONFIG_ENV` 环境变量
|
||||
|
||||
### Requirement: 删除配置热重载
|
||||
|
||||
系统 SHALL 移除配置热重载功能。
|
||||
|
||||
#### Scenario: 不监听配置文件变化
|
||||
|
||||
- **WHEN** 应用运行中
|
||||
- **THEN** 不使用 fsnotify 监听文件变化
|
||||
- **AND** 删除 `pkg/config/watcher.go`
|
||||
|
||||
#### Scenario: 配置变更需重启
|
||||
|
||||
- **WHEN** 需要更改配置
|
||||
- **THEN** 必须重启应用使新配置生效
|
||||
|
||||
### Requirement: 环境变量前缀
|
||||
|
||||
系统 SHALL 使用 `JUNHONG_` 作为环境变量前缀。
|
||||
|
||||
#### Scenario: 前缀隔离
|
||||
|
||||
- **WHEN** 存在环境变量 `DATABASE_HOST=other`
|
||||
- **AND** 存在环境变量 `JUNHONG_DATABASE_HOST=correct`
|
||||
- **THEN** `config.Database.Host` 为 "correct"
|
||||
- **AND** 忽略无前缀的 `DATABASE_HOST`
|
||||
|
||||
### Requirement: 敏感配置处理
|
||||
|
||||
系统 SHALL 确保敏感配置不嵌入二进制文件。
|
||||
|
||||
敏感配置项(嵌入值为空):
|
||||
- `database.password`
|
||||
- `redis.password`
|
||||
- `jwt.secret_key`
|
||||
- `storage.s3.access_key_id`
|
||||
- `storage.s3.secret_access_key`
|
||||
|
||||
#### Scenario: 敏感配置默认为空
|
||||
|
||||
- **WHEN** 读取嵌入配置
|
||||
- **THEN** 敏感配置项的值为空字符串
|
||||
- **AND** 必须通过环境变量提供实际值
|
||||
@@ -1,180 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 企业单卡授权管理
|
||||
|
||||
系统 SHALL 支持将 IoT 卡授权给企业使用,授权不转移所有权,仅授予使用权限。
|
||||
|
||||
**授权规则**:
|
||||
- 代理只能授权自己的卡(owner_type="agent" 且 owner_id=自己的 shop_id)给自己的企业
|
||||
- 平台可以授权任意卡,但如果是代理的卡,只能授权给该代理的企业
|
||||
- 支持批量授权(最多1000张卡)
|
||||
- **已绑定设备的卡不能通过单卡授权接口授权,MUST 使用设备授权接口**
|
||||
- 只能授权状态为 "已分销(2)" 的卡
|
||||
|
||||
**授权记录存储**:
|
||||
- 使用 `enterprise_card_authorization` 表记录授权关系
|
||||
- 通过单卡授权创建的记录 device_auth_id 为 NULL
|
||||
- 不使用 `asset_allocation_record` 表(该表用于分配,非授权)
|
||||
|
||||
**权限控制**:
|
||||
- 企业用户只能查看被授权的卡
|
||||
- 授权后卡的 shop_id 保持不变(所有权不转移)
|
||||
- 回收授权后企业立即失去访问权限
|
||||
|
||||
#### Scenario: 代理授权自己的卡给自己的企业
|
||||
|
||||
- **WHEN** 代理(shop_id=10)将自己的未绑定设备的卡授权给企业(enterprise_id=5, owner_shop_id=10)
|
||||
- **THEN** 系统创建授权记录(device_auth_id=NULL),企业可以查看和管理该卡
|
||||
|
||||
#### Scenario: 平台授权任意卡给企业
|
||||
|
||||
- **WHEN** 平台管理员将未绑定设备的卡授权给企业
|
||||
- **THEN** 系统创建授权记录(device_auth_id=NULL),企业获得该卡的访问权限
|
||||
|
||||
#### Scenario: 代理无法授权其他代理的卡
|
||||
|
||||
- **WHEN** 代理(shop_id=10)尝试授权其他代理的卡(owner_id=20)给企业
|
||||
- **THEN** 系统拒绝操作,返回权限错误
|
||||
|
||||
#### Scenario: 已绑定设备的卡不能通过单卡授权
|
||||
|
||||
- **WHEN** 用户尝试通过单卡授权接口授权已绑定到设备的卡
|
||||
- **THEN** 系统拒绝操作,返回错误码 CodeCannotAuthorizeBoundCard,提示"该卡已绑定设备,请使用设备授权功能"
|
||||
|
||||
#### Scenario: 只能授权已分销状态的卡
|
||||
|
||||
- **WHEN** 用户尝试授权非"已分销"状态的卡
|
||||
- **THEN** 系统拒绝操作,提示只能授权"已分销"状态的卡
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 企业卡授权数据模型
|
||||
|
||||
系统 SHALL 定义 EnterpriseCardAuthorization 实体,记录企业卡授权关系。
|
||||
|
||||
**实体字段**:
|
||||
- `id`: 主键(BIGINT)
|
||||
- `enterprise_id`: 被授权企业ID(BIGINT,关联 enterprises 表)
|
||||
- `card_id`: IoT卡ID(BIGINT,关联 iot_cards 表)
|
||||
- `authorizer_id`: 授权人账号ID(BIGINT,关联 accounts 表)
|
||||
- `authorizer_type`: 授权人类型(SMALLINT,2=平台用户 3=代理账号)
|
||||
- `authorized_at`: 授权时间(TIMESTAMP)
|
||||
- `revoked_at`: 回收时间(TIMESTAMP,可空)
|
||||
- `revoked_by`: 回收人账号ID(BIGINT,可空)
|
||||
- `remark`: 备注(VARCHAR(500))
|
||||
- **`device_auth_id`: 关联的设备授权ID(BIGINT,可空)**
|
||||
- NULL = 通过单卡授权创建
|
||||
- 有值 = 通过设备授权创建
|
||||
- `created_at`: 创建时间(TIMESTAMP)
|
||||
- `updated_at`: 更新时间(TIMESTAMP)
|
||||
|
||||
**新增索引**:
|
||||
- `idx_eca_device_auth ON tb_enterprise_card_authorization(device_auth_id)`
|
||||
|
||||
#### Scenario: 创建单卡授权记录
|
||||
|
||||
- **WHEN** 通过单卡授权接口授权卡给企业时
|
||||
- **THEN** 系统创建 EnterpriseCardAuthorization 记录,device_auth_id 为 NULL
|
||||
|
||||
#### Scenario: 创建设备关联卡授权记录
|
||||
|
||||
- **WHEN** 通过设备授权创建卡授权记录时
|
||||
- **THEN** 系统创建 EnterpriseCardAuthorization 记录,device_auth_id 指向对应的设备授权ID
|
||||
|
||||
#### Scenario: 回收授权
|
||||
|
||||
- **WHEN** 回收企业的卡授权时
|
||||
- **THEN** 系统更新对应记录的 revoked_at 和 revoked_by 字段,不删除记录(保留历史)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 批量授权接口
|
||||
|
||||
系统 SHALL 提供批量授权接口,支持一次授权多张卡给企业。
|
||||
|
||||
**接口设计**:
|
||||
- 路径:`POST /api/admin/enterprises/:id/allocate-cards`
|
||||
- 请求体:
|
||||
```json
|
||||
{
|
||||
"iccids": ["8986001234567890", "8986001234567891"],
|
||||
"remark": "批量授权"
|
||||
}
|
||||
```
|
||||
- 响应:成功/失败的卡列表及原因
|
||||
|
||||
**处理流程**:
|
||||
1. 验证每张卡的授权权限
|
||||
2. 检查卡状态是否为"已分销"
|
||||
3. **检查卡是否已绑定设备,绑定设备的卡直接拒绝并返回错误**
|
||||
4. 检查是否已授权给该企业
|
||||
5. 创建授权记录(device_auth_id = NULL)
|
||||
6. 返回处理结果
|
||||
|
||||
**移除功能**:
|
||||
- ~~DeviceBundle 预检和确认流程~~(已移除)
|
||||
- ~~confirm_device_bundles 参数~~(已移除)
|
||||
- ~~AllocatedDevices 响应字段~~(已移除)
|
||||
|
||||
#### Scenario: 批量授权成功
|
||||
|
||||
- **WHEN** 代理批量授权 5 张未绑定设备的卡给企业
|
||||
- **THEN** 系统创建 5 条授权记录(device_auth_id 均为 NULL),返回全部成功
|
||||
|
||||
#### Scenario: 批量授权遇到设备卡
|
||||
|
||||
- **WHEN** 代理批量授权 5 张卡,其中 2 张已绑定设备
|
||||
- **THEN** 系统创建 3 条授权记录,返回 3 张成功、2 张失败,失败原因为"该卡已绑定设备,请使用设备授权功能"
|
||||
|
||||
#### Scenario: 批量授权部分成功
|
||||
|
||||
- **WHEN** 代理批量授权 5 张卡,其中 1 张已绑定设备、1 张非已分销状态
|
||||
- **THEN** 系统创建 3 条授权记录,返回 3 张成功、2 张失败及各自失败原因
|
||||
|
||||
---
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 授权记录备注修改权限
|
||||
|
||||
系统 SHALL 对授权记录备注修改操作实施严格的权限控制,确保只有有权限的用户才能修改授权记录的备注信息。
|
||||
|
||||
**权限规则**:
|
||||
- **超级管理员/平台用户**:可以修改任意授权记录的备注
|
||||
- **代理账号**:仅可修改自己创建的授权记录的备注(authorized_by 等于自己的账号 ID)
|
||||
- **企业账号**:禁止修改授权记录备注(即使是授权给自己企业的记录)
|
||||
|
||||
**实施方式**:
|
||||
- Service 层 MUST 在 `UpdateRecordRemark` 方法中校验用户权限和创建者匹配
|
||||
- Store 层 MUST 在更新语句中增加 `authorized_by` 约束条件(对代理用户)
|
||||
- Handler 层 MUST 将权限失败场景返回统一错误码和中文错误消息
|
||||
|
||||
**错误处理**:
|
||||
- 代理尝试修改他人创建的记录:返回错误码 `CodePermissionDenied`(1003),消息"无权修改该授权记录的备注"
|
||||
- 企业用户尝试修改:返回错误码 `CodePermissionDenied`(1003),消息"企业用户无权修改授权记录备注"
|
||||
- 记录不存在或不在可见范围:返回错误码 `CodeRecordNotFound`(2001),消息"授权记录不存在"
|
||||
|
||||
#### Scenario: 平台用户修改任意授权记录备注
|
||||
|
||||
- **WHEN** 平台用户调用备注修改接口,指定任意授权记录 ID 和新备注内容
|
||||
- **THEN** 系统成功更新该授权记录的 `remark` 字段,返回成功响应
|
||||
|
||||
#### Scenario: 代理修改自己创建的授权记录备注
|
||||
|
||||
- **WHEN** 代理账号(account_id=100)调用备注修改接口,修改自己创建的授权记录(authorized_by=100)的备注
|
||||
- **THEN** 系统成功更新该授权记录的 `remark` 字段,返回成功响应
|
||||
|
||||
#### Scenario: 代理尝试修改他人创建的授权记录备注
|
||||
|
||||
- **WHEN** 代理账号(account_id=100)调用备注修改接口,尝试修改其他代理创建的授权记录(authorized_by=200)的备注
|
||||
- **THEN** 系统拒绝操作,返回错误码 `1003`,错误消息"无权修改该授权记录的备注",不执行任何更新
|
||||
|
||||
#### Scenario: 企业用户尝试修改授权记录备注
|
||||
|
||||
- **WHEN** 企业账号调用备注修改接口,尝试修改授权给自己企业的授权记录的备注
|
||||
- **THEN** 系统拒绝操作,返回错误码 `1003`,错误消息"企业用户无权修改授权记录备注",不执行任何更新
|
||||
|
||||
#### Scenario: 代理修改不存在或不可见的授权记录备注
|
||||
|
||||
- **WHEN** 代理账号调用备注修改接口,指定的授权记录 ID 不存在或不在其数据权限范围内
|
||||
- **THEN** 系统返回错误码 `2001`,错误消息"授权记录不存在",不执行任何更新
|
||||
@@ -1,319 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 设备授权企业数据模型
|
||||
|
||||
系统 SHALL 定义 EnterpriseDeviceAuthorization 实体,记录设备与企业的授权关系。
|
||||
|
||||
**实体字段**:
|
||||
- `id`: 主键(BIGSERIAL)
|
||||
- `enterprise_id`: 被授权企业ID(BIGINT,NOT NULL)
|
||||
- `device_id`: 被授权设备ID(BIGINT,NOT NULL)
|
||||
- `authorized_by`: 授权人账号ID(BIGINT,NOT NULL)
|
||||
- `authorized_at`: 授权时间(TIMESTAMP,NOT NULL)
|
||||
- `authorizer_type`: 授权人类型(SMALLINT,2=平台用户 3=代理账号)
|
||||
- `revoked_by`: 回收人账号ID(BIGINT,可空)
|
||||
- `revoked_at`: 回收时间(TIMESTAMP,可空)
|
||||
- `remark`: 备注(VARCHAR(500))
|
||||
- `created_at`, `updated_at`, `deleted_at`: 标准时间字段
|
||||
|
||||
**唯一性约束**:
|
||||
- 一个设备同时只能授权给一个企业:`UNIQUE (device_id) WHERE revoked_at IS NULL AND deleted_at IS NULL`
|
||||
|
||||
**表名**:`tb_enterprise_device_authorization`
|
||||
|
||||
#### Scenario: 创建设备授权记录
|
||||
|
||||
- **WHEN** 授权设备给企业时
|
||||
- **THEN** 系统创建 EnterpriseDeviceAuthorization 记录,authorized_at 设置为当前时间,revoked_at 为 NULL
|
||||
|
||||
#### Scenario: 设备重复授权被拒绝
|
||||
|
||||
- **WHEN** 尝试将已授权给企业A的设备(未回收)再授权给企业B
|
||||
- **THEN** 系统拒绝操作,返回错误"设备已授权给其他企业"
|
||||
|
||||
#### Scenario: 回收后可重新授权
|
||||
|
||||
- **WHEN** 设备授权已被回收后,重新授权给同一企业或其他企业
|
||||
- **THEN** 系统允许创建新的授权记录
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 卡授权记录关联设备授权
|
||||
|
||||
系统 SHALL 在 EnterpriseCardAuthorization 表中添加 device_auth_id 字段,关联设备授权记录。
|
||||
|
||||
**新增字段**:
|
||||
- `device_auth_id`: 关联的设备授权ID(BIGINT,可空)
|
||||
- NULL = 通过单卡授权创建
|
||||
- 有值 = 通过设备授权创建
|
||||
|
||||
**索引**:
|
||||
- `idx_eca_device_auth ON tb_enterprise_card_authorization(device_auth_id)`
|
||||
|
||||
#### Scenario: 设备授权创建关联卡授权
|
||||
|
||||
- **WHEN** 通过设备授权创建卡授权记录时
|
||||
- **THEN** 卡授权记录的 device_auth_id 字段设置为对应的设备授权ID
|
||||
|
||||
#### Scenario: 单卡授权不关联设备
|
||||
|
||||
- **WHEN** 通过单卡授权创建卡授权记录时
|
||||
- **THEN** 卡授权记录的 device_auth_id 字段为 NULL
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备授权管理功能
|
||||
|
||||
系统 SHALL 提供设备授权给企业的功能,支持批量授权和回收。
|
||||
|
||||
**授权规则**:
|
||||
- 代理只能授权自己店铺的设备给自己店铺下的企业
|
||||
- 平台可以授权任意设备给任意企业
|
||||
- 设备 MUST 属于操作者(平台或代理店铺)
|
||||
- 设备 MUST 处于"已分销"状态(status=2)
|
||||
- 设备 MUST 未授权给其他企业(唯一性约束)
|
||||
|
||||
**授权联动**:
|
||||
- 授权设备时,系统 SHALL 自动授权设备下所有已绑定的卡
|
||||
- 卡授权记录的 device_auth_id 指向设备授权记录
|
||||
- 如果设备没有绑定卡,仍然创建设备授权记录(无卡授权)
|
||||
|
||||
#### Scenario: 代理授权设备给自己的企业
|
||||
|
||||
- **WHEN** 代理(shop_id=10)将自己店铺的设备授权给企业(owner_shop_id=10)
|
||||
- **THEN** 系统创建设备授权记录,并为设备下所有已绑定的卡创建卡授权记录
|
||||
|
||||
#### Scenario: 平台授权任意设备
|
||||
|
||||
- **WHEN** 平台管理员授权设备给任意企业
|
||||
- **THEN** 系统创建授权记录,不检查设备和企业的归属关系
|
||||
|
||||
#### Scenario: 代理无法授权其他店铺的设备
|
||||
|
||||
- **WHEN** 代理(shop_id=10)尝试授权其他店铺的设备(shop_id=20)
|
||||
- **THEN** 系统拒绝操作,返回权限错误
|
||||
|
||||
#### Scenario: 设备授权联动卡授权
|
||||
|
||||
- **WHEN** 授权一个绑定了3张卡的设备给企业
|
||||
- **THEN** 系统创建1条设备授权记录和3条卡授权记录,所有卡授权的 device_auth_id 指向该设备授权
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 批量授权设备接口
|
||||
|
||||
系统 SHALL 提供批量授权设备给企业的后台接口。
|
||||
|
||||
**接口设计**:
|
||||
- 路径:`POST /api/admin/enterprises/:id/allocate-devices`
|
||||
- 请求体:
|
||||
```json
|
||||
{
|
||||
"device_nos": ["D001", "D002", "D003"],
|
||||
"remark": "批量授权备注"
|
||||
}
|
||||
```
|
||||
- 响应体:
|
||||
```json
|
||||
{
|
||||
"success_count": 2,
|
||||
"fail_count": 1,
|
||||
"failed_items": [
|
||||
{ "device_no": "D003", "reason": "设备不存在" }
|
||||
],
|
||||
"authorized_devices": [
|
||||
{ "device_id": 1, "device_no": "D001", "card_count": 3 },
|
||||
{ "device_id": 2, "device_no": "D002", "card_count": 2 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**处理流程**:
|
||||
1. 验证企业存在且有权限
|
||||
2. 验证每个设备的授权权限
|
||||
3. 检查设备状态和唯一性约束
|
||||
4. 在事务内创建设备授权和卡授权记录
|
||||
5. 返回处理结果
|
||||
|
||||
#### Scenario: 批量授权成功
|
||||
|
||||
- **WHEN** 平台批量授权3个符合条件的设备给企业
|
||||
- **THEN** 系统创建3条设备授权记录和对应的卡授权记录,返回全部成功
|
||||
|
||||
#### Scenario: 批量授权部分成功
|
||||
|
||||
- **WHEN** 代理批量授权3个设备,其中1个已授权给其他企业
|
||||
- **THEN** 系统创建2条设备授权记录,返回2个成功、1个失败及失败原因
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备授权回收功能
|
||||
|
||||
系统 SHALL 提供回收设备授权的功能,回收时同步回收关联的卡授权。
|
||||
|
||||
**回收规则**:
|
||||
- 代理可以回收自己授权的设备
|
||||
- 平台可以回收任何设备授权
|
||||
- 回收操作在事务内完成
|
||||
|
||||
**回收联动**:
|
||||
- 回收设备授权时,系统 SHALL 同步回收所有 device_auth_id 指向该设备授权的卡授权记录
|
||||
- 更新 revoked_at 和 revoked_by 字段
|
||||
|
||||
**接口设计**:
|
||||
- 路径:`POST /api/admin/enterprises/:id/recall-devices`
|
||||
- 请求体:
|
||||
```json
|
||||
{
|
||||
"device_nos": ["D001", "D002"]
|
||||
}
|
||||
```
|
||||
|
||||
#### Scenario: 回收设备授权联动回收卡授权
|
||||
|
||||
- **WHEN** 回收一个绑定了3张卡的设备的授权
|
||||
- **THEN** 系统更新设备授权的 revoked_at,同时更新3条关联卡授权的 revoked_at
|
||||
|
||||
#### Scenario: 回收后企业无法访问设备和卡
|
||||
|
||||
- **WHEN** 设备授权被回收后,企业用户查询设备或卡
|
||||
- **THEN** 系统不返回该设备和其下的卡
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 后台企业设备列表
|
||||
|
||||
系统 SHALL 提供后台管理查询企业授权设备列表的接口。
|
||||
|
||||
**接口设计**:
|
||||
- 路径:`GET /api/admin/enterprises/:id/devices`
|
||||
- 查询参数:`page`, `page_size`, `device_no`, `status`
|
||||
- 响应:设备列表,包含设备信息和绑定卡数量
|
||||
|
||||
**数据权限**:
|
||||
- 平台用户可查看所有企业的授权设备
|
||||
- 代理用户只能查看自己店铺下企业的授权设备
|
||||
|
||||
#### Scenario: 查询企业授权设备列表
|
||||
|
||||
- **WHEN** 管理员查询企业ID=5的授权设备
|
||||
- **THEN** 系统返回该企业所有授权设备列表,每个设备包含绑定卡数量
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 企业端设备列表
|
||||
|
||||
系统 SHALL 提供企业用户查询自己授权设备列表的 H5 接口。
|
||||
|
||||
**接口设计**:
|
||||
- 路径:`GET /api/h5/enterprise/devices`
|
||||
- 查询参数:`page`, `page_size`, `device_no`
|
||||
- 响应:
|
||||
```json
|
||||
{
|
||||
"list": [
|
||||
{
|
||||
"device_id": 1,
|
||||
"device_no": "D001",
|
||||
"device_name": "GPS追踪器-001",
|
||||
"device_model": "GT-100",
|
||||
"card_count": 3,
|
||||
"authorized_at": "2025-01-29T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 10
|
||||
}
|
||||
```
|
||||
|
||||
**数据权限**:
|
||||
- 企业用户只能看到授权给自己企业的设备
|
||||
- 通过 GORM Callback 自动过滤
|
||||
|
||||
#### Scenario: 企业用户查看设备列表
|
||||
|
||||
- **WHEN** 企业用户查询设备列表
|
||||
- **THEN** 系统返回授权给该企业的所有设备,包含设备信息和卡数量
|
||||
|
||||
#### Scenario: 企业用户无法看到未授权设备
|
||||
|
||||
- **WHEN** 企业用户查询设备列表
|
||||
- **THEN** 系统不返回未授权给该企业的设备
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 企业端设备详情
|
||||
|
||||
系统 SHALL 提供企业用户查询设备详情的 H5 接口,包含设备绑定的卡列表。
|
||||
|
||||
**接口设计**:
|
||||
- 路径:`GET /api/h5/enterprise/devices/:device_id`
|
||||
- 响应:
|
||||
```json
|
||||
{
|
||||
"device": {
|
||||
"device_id": 1,
|
||||
"device_no": "D001",
|
||||
"device_name": "GPS追踪器-001",
|
||||
"device_model": "GT-100",
|
||||
"device_type": "GPS",
|
||||
"authorized_at": "2025-01-29T10:00:00Z"
|
||||
},
|
||||
"cards": [
|
||||
{
|
||||
"card_id": 101,
|
||||
"iccid": "8986001234567890",
|
||||
"msisdn": "1380000001",
|
||||
"carrier_name": "中国联通",
|
||||
"network_status": 1,
|
||||
"network_status_name": "开机"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**可见信息**:
|
||||
- 设备基本信息:设备号、名称、型号、类型
|
||||
- 卡信息:ICCID、MSISDN、运营商、网络状态
|
||||
|
||||
**不可见信息**:
|
||||
- 成本价、分销价、供应商等商业敏感信息
|
||||
|
||||
#### Scenario: 企业用户查看设备详情
|
||||
|
||||
- **WHEN** 企业用户查看授权设备ID=1的详情
|
||||
- **THEN** 系统返回设备信息和该设备绑定的所有卡信息
|
||||
|
||||
#### Scenario: 企业用户无法查看未授权设备
|
||||
|
||||
- **WHEN** 企业用户尝试查看未授权的设备详情
|
||||
- **THEN** 系统返回 404 错误
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 企业端设备卡停机复机
|
||||
|
||||
系统 SHALL 提供企业用户对设备下的卡进行停机/复机操作的 H5 接口。
|
||||
|
||||
**接口设计**:
|
||||
- 停机:`POST /api/h5/enterprise/devices/:device_id/cards/:card_id/suspend`
|
||||
- 复机:`POST /api/h5/enterprise/devices/:device_id/cards/:card_id/resume`
|
||||
|
||||
**权限校验**:
|
||||
- 设备 MUST 授权给当前企业
|
||||
- 卡 MUST 属于该设备(通过 device_sim_binding 验证)
|
||||
- 卡 MUST 通过设备授权(device_auth_id 不为空且有效)
|
||||
|
||||
#### Scenario: 企业用户停机设备下的卡
|
||||
|
||||
- **WHEN** 企业用户对授权设备下的卡执行停机操作
|
||||
- **THEN** 系统更新卡的 network_status 为 0(停机)
|
||||
|
||||
#### Scenario: 企业用户复机设备下的卡
|
||||
|
||||
- **WHEN** 企业用户对授权设备下的卡执行复机操作
|
||||
- **THEN** 系统更新卡的 network_status 为 1(开机)
|
||||
|
||||
#### Scenario: 无法操作未授权设备的卡
|
||||
|
||||
- **WHEN** 企业用户尝试操作未授权设备下的卡
|
||||
- **THEN** 系统返回 403 错误
|
||||
@@ -1,91 +0,0 @@
|
||||
# error-code-validation Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change unify-error-message-source. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: 错误码消息映射完整性校验
|
||||
|
||||
系统 SHALL 在启动时校验所有已注册的错误码都有对应的 `errorMessages` 映射条目。
|
||||
|
||||
如果发现缺失映射,系统 MUST 立即 panic 并输出清晰的错误信息,指明缺失的错误码。
|
||||
|
||||
#### Scenario: 所有错误码都有映射时正常启动
|
||||
|
||||
- **WHEN** 所有 `allErrorCodes` 中的错误码都在 `errorMessages` 映射表中存在
|
||||
- **THEN** 系统正常启动,无错误日志
|
||||
|
||||
#### Scenario: 存在缺失映射时启动失败
|
||||
|
||||
- **WHEN** 某个错误码(如 `CodeNewFeature = 1099`)在 `allErrorCodes` 中注册但 `errorMessages` 中缺失
|
||||
- **THEN** 系统 panic,错误信息包含 "错误码 1099 缺少映射消息"
|
||||
|
||||
### Requirement: 错误码注册表维护
|
||||
|
||||
系统 SHALL 维护一个 `allErrorCodes` 切片,包含所有已定义的错误码常量。
|
||||
|
||||
新增错误码时,开发者 MUST 同时:
|
||||
1. 在 `codes.go` 中定义常量
|
||||
2. 在 `allErrorCodes` 中注册
|
||||
3. 在 `errorMessages` 中添加映射
|
||||
|
||||
#### Scenario: 新增错误码完整注册
|
||||
|
||||
- **WHEN** 开发者新增错误码 `CodeXxx = 1100`
|
||||
- **THEN** 必须同时在 `allErrorCodes` 和 `errorMessages` 中添加对应条目
|
||||
- **THEN** 否则启动时 panic 或测试失败
|
||||
|
||||
### Requirement: errors.New 默认使用映射表消息
|
||||
|
||||
`errors.New()` 函数 SHALL 优先使用 `errorMessages` 映射表中的消息作为默认值。
|
||||
|
||||
当调用者提供自定义消息时,系统 MUST 允许覆盖默认消息。
|
||||
|
||||
#### Scenario: 不传消息参数时使用映射表
|
||||
|
||||
- **WHEN** 调用 `errors.New(errors.CodeNotFound)`
|
||||
- **THEN** 返回的 `AppError.Message` 为 "资源未找到"(映射表中的值)
|
||||
|
||||
#### Scenario: 传空字符串时使用映射表
|
||||
|
||||
- **WHEN** 调用 `errors.New(errors.CodeNotFound, "")`
|
||||
- **THEN** 返回的 `AppError.Message` 为 "资源未找到"(映射表中的值)
|
||||
|
||||
#### Scenario: 传自定义消息时覆盖映射表
|
||||
|
||||
- **WHEN** 调用 `errors.New(errors.CodeNotFound, "提现申请不存在")`
|
||||
- **THEN** 返回的 `AppError.Message` 为 "提现申请不存在"(自定义值)
|
||||
|
||||
### Requirement: errors.Wrap 默认使用映射表消息
|
||||
|
||||
`errors.Wrap()` 函数 SHALL 与 `errors.New()` 保持一致的消息处理逻辑。
|
||||
|
||||
#### Scenario: Wrap 不传消息时使用映射表
|
||||
|
||||
- **WHEN** 调用 `errors.Wrap(errors.CodeDatabaseError, originalErr)`
|
||||
- **THEN** 返回的 `AppError.Message` 为 "数据库错误"(映射表中的值)
|
||||
- **THEN** 返回的 `AppError.Err` 为 `originalErr`
|
||||
|
||||
#### Scenario: Wrap 传自定义消息时覆盖
|
||||
|
||||
- **WHEN** 调用 `errors.Wrap(errors.CodeDatabaseError, "查询用户失败", originalErr)`
|
||||
- **THEN** 返回的 `AppError.Message` 为 "查询用户失败"
|
||||
- **THEN** 返回的 `AppError.Err` 为 `originalErr`
|
||||
|
||||
### Requirement: CI 测试覆盖映射完整性
|
||||
|
||||
系统 SHALL 提供单元测试 `TestAllCodesHaveMessages`,验证所有注册的错误码都有对应的映射。
|
||||
|
||||
此测试 MUST 在 CI 流程中运行,防止映射表腐化。
|
||||
|
||||
#### Scenario: 测试检测到缺失映射
|
||||
|
||||
- **WHEN** 运行 `go test ./pkg/errors/...`
|
||||
- **WHEN** 存在错误码在 `allErrorCodes` 但不在 `errorMessages` 中
|
||||
- **THEN** 测试失败,输出缺失的错误码列表
|
||||
|
||||
#### Scenario: 测试检测到孤立映射
|
||||
|
||||
- **WHEN** 运行 `go test ./pkg/errors/...`
|
||||
- **WHEN** 存在映射条目的错误码不在 `allErrorCodes` 中
|
||||
- **THEN** 测试失败,输出孤立的错误码列表(可选警告)
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
# error-handling Specification
|
||||
|
||||
## Purpose
|
||||
|
||||
定义本项目“错误产生、错误传递、错误返回”的统一规范,确保:
|
||||
|
||||
- 对外响应结构一致(`{code, data, msg, timestamp}`)
|
||||
- 业务语义一致(可预期业务错误返回 4xx,非预期系统错误返回 5xx)
|
||||
- 不泄露内部细节(校验细节、数据库/第三方错误细节仅写日志)
|
||||
- 分层职责明确(Handler 只负责输入/输出,Service 负责业务与结构化错误)
|
||||
|
||||
## Requirements
|
||||
### Requirement: Simplified AppError Structure
|
||||
|
||||
系统 SHALL 简化 AppError 结构,删除冗余的 HTTPStatus 字段。
|
||||
|
||||
#### Scenario: AppError 字段
|
||||
- **WHEN** 创建 AppError
|
||||
- **THEN** 结构体只包含 3 个字段:
|
||||
- Code: 业务错误码
|
||||
- Message: 错误消息
|
||||
- Err: 底层错误(可选)
|
||||
|
||||
#### Scenario: HTTP 状态码获取
|
||||
- **WHEN** ErrorHandler 处理 AppError
|
||||
- **THEN** 通过 GetHTTPStatus(code) 实时获取 HTTP 状态码
|
||||
- **AND** 不从 AppError 字段中读取
|
||||
|
||||
#### Scenario: 禁止手动设置状态码
|
||||
- **WHEN** 创建 AppError
|
||||
- **THEN** 不提供 WithHTTPStatus() 方法
|
||||
- **AND** Code 和 HTTPStatus 始终保持一致
|
||||
|
||||
### Requirement: Unified Error Response Format
|
||||
|
||||
系统 SHALL 使用统一的 JSON 响应格式(错误和成功均使用相同字段)。
|
||||
|
||||
#### Scenario: 响应结构
|
||||
- **WHEN** 返回任何响应时
|
||||
- **THEN** JSON 结构仅包含 4 个字段:
|
||||
- code: 业务错误码(0 表示成功)
|
||||
- msg: 消息(错误消息或 "success")
|
||||
- data: 响应数据(成功时有数据,错误时为 null)
|
||||
- timestamp: ISO 8601 时间戳
|
||||
|
||||
#### Scenario: 不返回 HTTP 状态码字段
|
||||
- **WHEN** 返回响应时
|
||||
- **THEN** JSON 不包含 httpstatus 或 http_status 字段
|
||||
- **AND** HTTP 状态码仅在响应头中体现
|
||||
|
||||
#### Scenario: Handler 返回错误
|
||||
- **WHEN** Handler 函数返回 error
|
||||
- **THEN** 全局 ErrorHandler 拦截错误
|
||||
- **AND** 根据错误类型构造统一格式响应
|
||||
|
||||
### Requirement: Handler Error Return Convention
|
||||
|
||||
所有 Handler 函数 SHALL 通过返回 error 传递错误,由全局 ErrorHandler 统一处理。
|
||||
|
||||
#### Scenario: 业务错误
|
||||
- **WHEN** Handler 遇到业务错误
|
||||
- **THEN** 返回 errors.New(code, message) 创建的 AppError
|
||||
- **AND** 不直接调用 response.Error()
|
||||
|
||||
#### Scenario: 参数验证错误
|
||||
- **WHEN** 请求参数验证失败
|
||||
- **THEN** 返回 errors.New(CodeInvalidParam)
|
||||
- **AND** 不将 validator 的 err.Error() 直接返回给客户端(避免泄露内部字段和规则)
|
||||
- **AND** 详细校验错误 SHALL 记录到日志(用于排查)
|
||||
|
||||
### Requirement: Service Error Output Convention
|
||||
|
||||
Service 层 SHALL 对外输出结构化错误,禁止把普通 error 直接冒泡到 Handler。
|
||||
|
||||
#### Scenario: 预期业务错误
|
||||
- **WHEN** 业务校验失败(例如:验证码错误、资源不存在、状态不允许)
|
||||
- **THEN** 返回 errors.New(<4xx-code>[, message])
|
||||
|
||||
#### Scenario: 非预期系统错误
|
||||
- **WHEN** 发生数据库/缓存/队列/第三方依赖错误
|
||||
- **THEN** 返回 errors.Wrap(<5xx-code>, err, "业务动作失败")
|
||||
- **AND** 客户端 msg 由全局错误映射表提供通用描述
|
||||
|
||||
#### Scenario: 禁止 fmt.Errorf 作为对外错误
|
||||
- **WHEN** Service 需要对外返回错误
|
||||
- **THEN** 不使用 fmt.Errorf(...) 作为返回值
|
||||
- **AND** 必须转换为 AppError(errors.New/Wrap)
|
||||
|
||||
#### Scenario: 成功响应
|
||||
- **WHEN** Handler 执行成功
|
||||
- **THEN** 调用 response.Success(c, data)
|
||||
- **AND** 返回 nil
|
||||
|
||||
### Requirement: Standardized Error Codes
|
||||
|
||||
系统 SHALL 使用标准化的错误码,删除向后兼容的别名。
|
||||
|
||||
#### Scenario: 参数验证错误码
|
||||
- **WHEN** 参数验证失败
|
||||
- **THEN** 使用 CodeInvalidParam
|
||||
- **AND** 不使用 CodeBadRequest(别名已删除)
|
||||
|
||||
#### Scenario: 服务不可用错误码
|
||||
- **WHEN** 服务不可用
|
||||
- **THEN** 使用 CodeServiceUnavailable
|
||||
- **AND** 不使用 CodeAuthServiceUnavailable(别名已删除)
|
||||
|
||||
## 错误报错规范(必须遵守)
|
||||
|
||||
### Handler 层
|
||||
- ❌ **禁止直接返回/拼接底层错误信息给客户端**
|
||||
- 例如:`"参数验证失败: " + err.Error()`、直接返回 `err.Error()`
|
||||
- 原因:泄露内部字段名和校验规则,造成安全风险
|
||||
- ✅ **参数校验失败统一返回** `errors.New(errors.CodeInvalidParam)`
|
||||
- 详细校验错误写日志,对外返回通用消息
|
||||
- ✅ **详细错误信息记录到日志**,用于排查问题
|
||||
- 日志级别:参数错误使用 `WARN` 级别(客户端错误)
|
||||
- 必须包含:`path`、`method`、完整错误信息
|
||||
- 使用结构化日志(`zap.String`、`zap.Error`)
|
||||
|
||||
### Service 层
|
||||
- ❌ **禁止对外返回** `fmt.Errorf(...)`
|
||||
- 原因:未结构化的错误消息会泄露实现细节
|
||||
- ✅ **业务错误使用** `errors.New(code[, msg])`
|
||||
- 适用场景:资源不存在、状态不允许、参数错误等预期错误
|
||||
- ✅ **系统错误使用** `errors.Wrap(code, err[, msg])`
|
||||
- 适用场景:数据库错误、Redis 错误、队列错误等非预期错误
|
||||
|
||||
### 示例对比
|
||||
|
||||
**Handler 层参数校验**:
|
||||
```go
|
||||
// ❌ 错误:泄露校验细节
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "参数解析失败: "+err.Error())
|
||||
}
|
||||
|
||||
// ✅ 正确:通用消息 + 结构化日志
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
logger.GetAppLogger().Warn("参数解析失败",
|
||||
zap.String("path", c.Path()),
|
||||
zap.String("method", c.Method()),
|
||||
zap.Error(err),
|
||||
)
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数格式错误")
|
||||
}
|
||||
|
||||
// ✅ 参数验证失败示例
|
||||
if err := h.validator.Struct(&req); err != nil {
|
||||
logger.GetAppLogger().Warn("参数验证失败",
|
||||
zap.String("path", c.Path()),
|
||||
zap.String("method", c.Method()),
|
||||
zap.Error(err),
|
||||
)
|
||||
return errors.New(errors.CodeInvalidParam) // 使用默认消息
|
||||
}
|
||||
```
|
||||
|
||||
**Service 层错误处理**:
|
||||
```go
|
||||
// ❌ 错误:使用 fmt.Errorf
|
||||
if err := s.store.Create(ctx, data); err != nil {
|
||||
return fmt.Errorf("创建失败: %w", err)
|
||||
}
|
||||
|
||||
// ✅ 正确:使用 errors.Wrap
|
||||
if err := s.store.Create(ctx, data); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "创建失败")
|
||||
}
|
||||
```
|
||||
|
||||
## Service 层错误处理规范
|
||||
|
||||
### 错误分类与映射表
|
||||
|
||||
| 场景分类 | 错误码 | HTTP 状态码 | 使用方式 |
|
||||
|---------|-------|-----------|---------|
|
||||
| 资源不存在 | `CodeNotFound` | 404 | `errors.New(errors.CodeNotFound, "资源不存在")` |
|
||||
| 状态不允许 | `CodeInvalidStatus` | 400 | `errors.New(errors.CodeInvalidStatus, "状态不允许此操作")` |
|
||||
| 参数错误 | `CodeInvalidParam` | 400 | `errors.New(errors.CodeInvalidParam)` |
|
||||
| 重复操作 | `CodeDuplicate` | 409 | `errors.New(errors.CodeDuplicate, "资源已存在")` |
|
||||
| 余额不足 | `CodeInsufficientBalance` | 400 | `errors.New(errors.CodeInsufficientBalance)` |
|
||||
| 额度不足 | `CodeInsufficientQuota` | 400 | `errors.New(errors.CodeInsufficientQuota, "分配额度不足")` |
|
||||
| 超过限制 | `CodeExceedLimit` | 400 | `errors.New(errors.CodeExceedLimit, "超过系统限制")` |
|
||||
| 资源冲突 | `CodeConflict` | 409 | `errors.New(errors.CodeConflict, "资源冲突")` |
|
||||
| 数据库错误 | `CodeInternalError` | 500 | `errors.Wrap(errors.CodeInternalError, err, "操作失败")` |
|
||||
| 队列错误 | `CodeInternalError` | 500 | `errors.Wrap(errors.CodeInternalError, err, "任务提交失败")` |
|
||||
|
||||
### 实际案例
|
||||
|
||||
#### 案例 1:套餐服务(package/service.go)
|
||||
|
||||
**场景:获取套餐**
|
||||
```go
|
||||
// ❌ 错误:使用 fmt.Errorf
|
||||
func (s *Service) Get(ctx context.Context, id uint) (*dto.PackageResponse, error) {
|
||||
pkg, err := s.packageStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "套餐不存在")
|
||||
}
|
||||
return nil, fmt.Errorf("获取套餐失败: %w", err) // ❌ 直接返回系统错误
|
||||
}
|
||||
return s.toResponse(ctx, pkg), nil
|
||||
}
|
||||
|
||||
// ✅ 正确:使用 errors.Wrap
|
||||
func (s *Service) Get(ctx context.Context, id uint) (*dto.PackageResponse, error) {
|
||||
pkg, err := s.packageStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeNotFound, "套餐不存在")
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "获取套餐失败") // ✅
|
||||
}
|
||||
return s.toResponse(ctx, pkg), nil
|
||||
}
|
||||
```
|
||||
|
||||
#### 案例 2:分佣提现(commission_withdrawal/service.go)
|
||||
|
||||
**场景:余额不足**
|
||||
```go
|
||||
// ✅ 业务错误使用 errors.New
|
||||
if wallet.FrozenBalance < amount {
|
||||
return nil, errors.New(errors.CodeInsufficientBalance, "钱包冻结余额不足")
|
||||
}
|
||||
|
||||
// ✅ 事务中的数据库错误使用 errors.Wrap
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := s.walletStore.DeductFrozenBalanceWithTx(ctx, tx, wallet.ID, amount); err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "扣除冻结余额失败")
|
||||
}
|
||||
// ...
|
||||
})
|
||||
```
|
||||
|
||||
#### 案例 3:店铺管理(shop/service.go)
|
||||
|
||||
**场景:层级限制和重复检查**
|
||||
```go
|
||||
// ✅ 业务校验
|
||||
if level > 7 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "店铺层级超过限制")
|
||||
}
|
||||
|
||||
// ✅ 重复检查
|
||||
existing, _ := s.shopStore.GetByCode(ctx, req.ShopCode)
|
||||
if existing != nil {
|
||||
return nil, errors.New(errors.CodeDuplicate, "店铺代码已存在")
|
||||
}
|
||||
|
||||
// ✅ 数据库操作
|
||||
if err := s.shopStore.Create(ctx, shop); err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "创建店铺失败")
|
||||
}
|
||||
```
|
||||
|
||||
### 统一原则
|
||||
|
||||
1. **业务错误(4xx)**:使用 `errors.New(Code4xx, msg)`
|
||||
- 资源不存在、状态不允许、参数错误、重复操作等
|
||||
|
||||
2. **系统错误(5xx)**:使用 `errors.Wrap(Code5xx, err, msg)`
|
||||
- 数据库错误、Redis 错误、队列错误、外部服务错误等
|
||||
|
||||
3. **错误消息保持中文**:便于日志排查和问题定位
|
||||
|
||||
4. **禁止 fmt.Errorf 对外返回**:避免泄露内部实现细节
|
||||
@@ -1,127 +0,0 @@
|
||||
# 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 拒绝并返回"资产当前状态不允许转新"
|
||||
@@ -1,41 +0,0 @@
|
||||
# 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 拒绝填写并返回状态非法错误
|
||||
@@ -1,66 +0,0 @@
|
||||
# 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 仍可在旧代际查询链路中追溯
|
||||
@@ -1,75 +0,0 @@
|
||||
# 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`
|
||||
25
openspec/specs/export-task/spec.md
Normal file
25
openspec/specs/export-task/spec.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# 导出任务当前行为
|
||||
|
||||
## Purpose
|
||||
|
||||
描述导出任务创建、查询、取消和可观察状态的当前行为。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 导出任务终态
|
||||
|
||||
系统 SHALL 将导出任务保持为待处理、处理中、已完成、已失败或已取消;取消只影响尚可取消的任务,重复处理不得把终态任务重新推进为处理中。
|
||||
|
||||
#### Scenario: 终态任务再次执行
|
||||
|
||||
- **GIVEN** 导出任务已完成、失败或取消
|
||||
- **WHEN** Worker 再次收到同一任务
|
||||
- **THEN** 系统保留原终态且不重复生成导出结果
|
||||
|
||||
## 可达操作索引
|
||||
|
||||
本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。
|
||||
|
||||
### 导出任务
|
||||
|
||||
`GET /api/admin/export-tasks`(导出任务列表);`POST /api/admin/export-tasks`(创建导出任务);`GET /api/admin/export-tasks/{id}`(导出任务详情);`POST /api/admin/export-tasks/{id}/cancel`(取消导出任务)。
|
||||
95
openspec/specs/external-integration/spec.md
Normal file
95
openspec/specs/external-integration/spec.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# external-integration 当前行为
|
||||
|
||||
## Purpose
|
||||
|
||||
描述企业微信、支付渠道、Gateway 与运营商回调的当前失败、重试和幂等边界。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 企微审批状态
|
||||
|
||||
系统 SHALL 将审批状态按 0=提交中、1=审批中、2=已通过、3=已拒绝、4=已撤销、5=通过后撤销、6=已删除、7=提交失败、8=提交结果未知返回。
|
||||
|
||||
#### Scenario: 企微审批状态
|
||||
|
||||
- **GIVEN** 审批实例存在
|
||||
- **WHEN** 查询审批
|
||||
- **THEN** 返回数值状态和对应中文名称
|
||||
|
||||
### Requirement: 企微回调与补偿
|
||||
|
||||
系统 SHALL 对企业微信回调执行验签解密,并使回调、轮询和人工同步进入同一权威状态同步语义。
|
||||
|
||||
#### Scenario: 企微回调与补偿
|
||||
|
||||
- **GIVEN** 同一审批变化由多个同步来源到达
|
||||
- **WHEN** 处理同步
|
||||
- **THEN** 审批终态和业务终态至多生效一次
|
||||
|
||||
### Requirement: 外部失败边界
|
||||
|
||||
系统 SHALL 将第三方超时、渠道错误和无效响应转换为当前稳定的系统错误;已接入外部交互日志的渠道同时保留脱敏结果。
|
||||
|
||||
#### Scenario: 外部失败边界
|
||||
|
||||
- **GIVEN** 外部系统超时或返回失败
|
||||
- **WHEN** 调用依赖该系统的操作
|
||||
- **THEN** 客户端收到当前稳定错误;已接入外部交互日志的调用记录脱敏渠道结果
|
||||
|
||||
### Requirement: 外部调用重试边界
|
||||
|
||||
系统 SHALL 仅对 Gateway 客户端超时、连接失败和 DNS 失败自动重试,默认最多重试两次且每次重新签名;HTTP 非 200、响应解析失败、Gateway 业务失败和调用方取消不重试。企业微信审批只有确认尚未调用提交接口的失败可释放后重试,提交结果未知时不得盲目重建审批。
|
||||
|
||||
#### Scenario: 外部写请求结果未知
|
||||
|
||||
- **GIVEN** 企业微信审批提交请求可能已到达渠道但本地未取得确定结果
|
||||
- **WHEN** Worker 处理该失败
|
||||
- **THEN** 系统保留提交结果未知状态且不自动创建第二张审批单
|
||||
|
||||
### Requirement: 富友调用超时兼容行为
|
||||
|
||||
系统 SHALL 保持当前富友预下单客户端未设置独立 HTTP 超时的兼容行为;调用可能持续等待底层连接结束,此行为作为当前缺陷记录而不在基线任务中修复。
|
||||
|
||||
#### Scenario: 富友端点不返回响应
|
||||
|
||||
- **GIVEN** 富友连接建立后持续不返回响应
|
||||
- **WHEN** 系统发起预下单
|
||||
- **THEN** 当前适配器没有自身超时门禁,调用结果保持未确定直到底层请求返回错误或响应
|
||||
|
||||
### Requirement: 运营商回调幂等
|
||||
|
||||
系统 SHALL 以渠道业务标识与标准化载荷生成的幂等键记录运营商实名及网络状态回调;重复回调只应用一次,字段冲突作为独立冲突事实保留且不得覆盖已确认状态。
|
||||
|
||||
#### Scenario: 重复运营商回调
|
||||
|
||||
- **GIVEN** 同一合法运营商回调已经成功应用
|
||||
- **WHEN** 渠道再次发送相同业务事实
|
||||
- **THEN** 系统返回渠道可接受响应且不重复推进卡状态
|
||||
|
||||
## 可达操作索引
|
||||
|
||||
本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。
|
||||
|
||||
### 企业微信审批
|
||||
|
||||
`GET /api/admin/wecom/applications`(查询企业微信应用配置);`POST /api/admin/wecom/applications`(创建或更新企业微信应用配置);`PUT /api/admin/wecom/applications/{id}/default-creator`(保存企业微信应用默认审批发起人);`GET /api/admin/wecom/applications/{id}/members`(分页查询企业微信应用可见成员);`POST /api/admin/wecom/applications/{id}/members/sync`(同步企业微信应用可见成员);`POST /api/admin/wecom/applications/{id}/templates/inspect`(读取企业微信审批模板控件);`POST /api/admin/wecom/applications/{id}/test`(测试企业微信应用连接);`GET /api/admin/wecom/scenes`(分页查询企业微信审批场景配置);`PUT /api/admin/wecom/scenes/{business_type}`(保存并校验企业微信审批场景模板映射);`GET /api/admin/wecom/scenes/{business_type}/fields`(查询企业微信审批场景可映射字段)。账号与企业微信成员的绑定属于账号身份能力。
|
||||
|
||||
### 企业微信审批回调
|
||||
|
||||
`GET /api/callback/wecom/approval/{application_id}`(验证企业微信审批回调地址);`POST /api/callback/wecom/approval/{application_id}`(接收企业微信审批状态变化回调)。
|
||||
|
||||
### 微信支付配置管理
|
||||
|
||||
`GET /api/admin/wechat-configs`(获取支付配置列表);`POST /api/admin/wechat-configs`(创建支付配置);`DELETE /api/admin/wechat-configs/{id}`(删除支付配置);`GET /api/admin/wechat-configs/{id}`(获取支付配置详情);`PUT /api/admin/wechat-configs/{id}`(更新支付配置);`POST /api/admin/wechat-configs/{id}/activate`(激活支付配置);`POST /api/admin/wechat-configs/{id}/deactivate`(停用支付配置);`GET /api/admin/wechat-configs/active`(获取当前生效的支付配置)。
|
||||
|
||||
### 对象存储
|
||||
|
||||
`POST /api/admin/storage/batch-download-urls`(批量获取文件下载预签名 URL);`POST /api/admin/storage/upload-url`(获取文件上传预签名 URL)。
|
||||
|
||||
### 运营商回调
|
||||
|
||||
`POST /api/callback/carriers/cmcc/realname`(移动实名结果回调);`POST /api/callback/carriers/ctcc/realname`(电信实名结果回调);`POST /api/callback/carriers/cucc/realname`(联通实名结果回调);`POST /api/callback/carriers/cucc/realname/remove`(联通解除实名回调)。
|
||||
|
||||
### 运营商管理
|
||||
|
||||
`GET /api/admin/carriers`(运营商列表);`POST /api/admin/carriers`(创建运营商);`DELETE /api/admin/carriers/{id}`(删除运营商);`GET /api/admin/carriers/{id}`(获取运营商详情);`PUT /api/admin/carriers/{id}`(更新运营商);`PUT /api/admin/carriers/{id}/status`(更新运营商状态)。
|
||||
@@ -1,165 +0,0 @@
|
||||
# Capability: 强充预检
|
||||
|
||||
## Purpose
|
||||
|
||||
本 capability 定义强充预检接口,在充值或购买套餐前返回强充要求、允许的充值金额等信息,帮助前端正确引导用户完成支付。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 代理层强充层级判断
|
||||
|
||||
系统 SHALL 在强充预检时按层级判断生效的强充配置:平台在 PackageSeries 中设置的强充具有最高优先级;平台未设强充时,读取客户所属销售代理(`order.SellerShopID`)对应的 ShopSeriesAllocation 强充配置。
|
||||
|
||||
#### Scenario: 平台已设强充,代理自设被忽略
|
||||
- **WHEN** PackageSeries.enable_force_recharge=true(平台层),客户在代理A 的渠道下购买,代理A 的 ShopSeriesAllocation.enable_force_recharge=false
|
||||
- **THEN** 系统使用平台强充规则,need_force_recharge=true,force_recharge_amount=平台设定值
|
||||
|
||||
#### Scenario: 平台未设强充,代理自设生效
|
||||
- **WHEN** PackageSeries.enable_force_recharge=false,客户在代理A 的渠道下购买,代理A 的 ShopSeriesAllocation.enable_force_recharge=true,force_recharge_amount=10000
|
||||
- **THEN** 系统使用代理A 的强充配置,need_force_recharge=true,force_recharge_amount=10000
|
||||
|
||||
#### Scenario: 平台未设强充,代理也未设强充
|
||||
- **WHEN** PackageSeries.enable_force_recharge=false,代理A 的 ShopSeriesAllocation.enable_force_recharge=false
|
||||
- **THEN** 系统返回 need_force_recharge=false
|
||||
|
||||
#### Scenario: 平台未设强充,查询不到销售代理分配
|
||||
- **WHEN** PackageSeries.enable_force_recharge=false,系统查询不到 SellerShop 对应的 ShopSeriesAllocation
|
||||
- **THEN** 系统返回 need_force_recharge=false(降级处理,不影响购买流程)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 钱包充值预检
|
||||
|
||||
系统 SHALL 提供钱包充值预检接口,返回强充要求、允许的充值金额等信息。强充判断 MUST 按代理层级规则执行:优先使用平台强充,平台未设时使用销售代理自设强充。
|
||||
|
||||
#### Scenario: 无强充要求
|
||||
- **WHEN** 客户查询卡钱包充值预检,PackageSeries.enable_force_recharge=false,销售代理 ShopSeriesAllocation.enable_force_recharge=false
|
||||
- **THEN** 系统返回 need_force_recharge=false
|
||||
|
||||
#### Scenario: 首次充值强充(平台层)
|
||||
- **WHEN** 客户查询卡钱包充值预检,PackageSeries 配置为首次充值触发,阈值 10000 分,未发放佣金
|
||||
- **THEN** 系统返回 need_force_recharge=true,force_recharge_amount=10000,trigger_type="single_recharge"
|
||||
|
||||
#### Scenario: 累计充值启用强充(平台层)
|
||||
- **WHEN** 客户查询卡钱包充值预检,PackageSeries.enable_force_recharge=true,force_amount=10000
|
||||
- **THEN** 系统返回 need_force_recharge=true,force_recharge_amount=10000,trigger_type="accumulated_recharge"
|
||||
|
||||
#### Scenario: 代理自设累计充值强充(平台未设)
|
||||
- **WHEN** PackageSeries.enable_force_recharge=false,销售代理的 ShopSeriesAllocation.enable_force_recharge=true,force_recharge_amount=8000
|
||||
- **THEN** 系统返回 need_force_recharge=true,force_recharge_amount=8000
|
||||
|
||||
#### Scenario: 一次性佣金已发放
|
||||
- **WHEN** 客户查询卡钱包充值预检,卡的一次性佣金已发放过
|
||||
- **THEN** 系统返回 need_force_recharge = false(不再强充)
|
||||
|
||||
#### Scenario: 未启用一次性佣金
|
||||
- **WHEN** 客户查询卡钱包充值预检,卡关联系列未启用一次性佣金
|
||||
- **THEN** 系统返回 need_force_recharge = false
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 套餐购买预检
|
||||
|
||||
系统 SHALL 提供套餐购买预检接口,计算实际支付金额、钱包到账金额等信息。强充判断 MUST 按代理层级规则执行。
|
||||
|
||||
#### Scenario: 无强充要求正常购买
|
||||
- **WHEN** 客户购买 90 元套餐,平台和销售代理均未设强充
|
||||
- **THEN** 系统返回 total_package_amount=9000,need_force_recharge=false,actual_payment=9000,wallet_credit=0
|
||||
|
||||
#### Scenario: 代理自设强充,套餐价低于强充金额
|
||||
- **WHEN** 客户购买 50 元套餐,平台未设强充,销售代理设置 force_recharge_amount=10000
|
||||
- **THEN** 系统返回 actual_payment=10000,wallet_credit=5000
|
||||
|
||||
#### Scenario: 首次充值强充(平台层),套餐价低于阈值
|
||||
- **WHEN** 客户购买 90 元套餐,首次充值阈值 100 元(平台层)
|
||||
- **THEN** 系统返回 total_package_amount=9000,need_force_recharge=true,force_recharge_amount=10000,actual_payment=10000,wallet_credit=1000
|
||||
|
||||
#### Scenario: 首次充值强充,套餐价高于阈值
|
||||
- **WHEN** 客户购买 150 元套餐,首次充值阈值 100 元
|
||||
- **THEN** 系统返回 total_package_amount = 15000,need_force_recharge = true,force_recharge_amount = 10000,actual_payment = 15000,wallet_credit = 0,message = "套餐总价150元,无需额外充值"
|
||||
|
||||
#### Scenario: 首次充值强充,套餐价等于阈值
|
||||
- **WHEN** 客户购买 100 元套餐,首次充值阈值 100 元
|
||||
- **THEN** 系统返回 total_package_amount = 10000,need_force_recharge = true,force_recharge_amount = 10000,actual_payment = 10000,wallet_credit = 0
|
||||
|
||||
#### Scenario: 累计充值启用强充,套餐价低于强充金额
|
||||
- **WHEN** 客户购买 50 元套餐,累计充值启用强充,强充金额 100 元
|
||||
- **THEN** 系统返回 actual_payment = 10000,wallet_credit = 5000,message = "需充值100元,购买套餐后余额50元"
|
||||
|
||||
#### Scenario: 累计充值启用强充,套餐价高于强充金额
|
||||
- **WHEN** 客户购买 150 元套餐,累计充值启用强充,强充金额 100 元
|
||||
- **THEN** 系统返回 actual_payment = 15000,wallet_credit = 0,message = "套餐总价150元,无需额外充值"
|
||||
|
||||
#### Scenario: 购买多个套餐
|
||||
- **WHEN** 客户购买 3 个套餐,总价 120 元,首次充值阈值 100 元
|
||||
- **THEN** 系统返回 total_package_amount = 12000,actual_payment = 12000,wallet_credit = 0
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 预检接口响应格式
|
||||
|
||||
预检接口响应 SHALL 包含完整的充值/购买指引信息。
|
||||
|
||||
#### Scenario: 充值预检响应字段
|
||||
- **WHEN** 调用钱包充值预检接口
|
||||
- **THEN** 响应包含:need_force_recharge, force_recharge_amount, trigger_type, min_amount, max_amount, current_accumulated, threshold, message
|
||||
|
||||
#### Scenario: 购买预检响应字段
|
||||
- **WHEN** 调用套餐购买预检接口
|
||||
- **THEN** 响应包含:total_package_amount, need_force_recharge, force_recharge_amount, actual_payment, wallet_credit, message
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 预检接口性能
|
||||
|
||||
预检接口响应时间 MUST 小于 100ms。
|
||||
|
||||
#### Scenario: 快速响应
|
||||
- **WHEN** 调用预检接口
|
||||
- **THEN** 系统在 100ms 内返回结果
|
||||
|
||||
#### Scenario: 缓存系列分配配置
|
||||
- **WHEN** 频繁查询同一卡的预检信息
|
||||
- **THEN** 系统可以缓存系列分配配置,减少数据库查询
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 预检接口错误处理
|
||||
|
||||
预检接口 SHALL 正确处理异常情况。
|
||||
|
||||
#### Scenario: 卡不存在
|
||||
- **WHEN** 查询不存在的卡的充值预检
|
||||
- **THEN** 系统返回错误 "卡不存在"
|
||||
|
||||
#### Scenario: 卡未关联系列
|
||||
- **WHEN** 查询未关联套餐系列的卡的充值预检
|
||||
- **THEN** 系统返回 need_force_recharge = false(无系列分配,无强充要求)
|
||||
|
||||
#### Scenario: 设备不存在
|
||||
- **WHEN** 查询不存在的设备的充值预检
|
||||
- **THEN** 系统返回错误 "设备不存在"
|
||||
|
||||
#### 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`、提示文案可用于前端直接展示
|
||||
@@ -1,181 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 富友支付公众号 JSAPI 下单
|
||||
|
||||
系统 SHALL 支持通过富友支付 `wxPreCreate` 接口发起微信公众号 JSAPI 支付。
|
||||
|
||||
> **本次留桩**:`FuiouPayJSAPI` 方法在 Service 层定义,但实际调用第三方获取支付参数的逻辑暂不实现,返回"富友支付发起暂未实现"错误。`pkg/fuiou/` SDK 包完整实现。
|
||||
|
||||
#### Scenario: 公众号 JSAPI 下单成功
|
||||
|
||||
- **WHEN** 系统调用富友 `wxPreCreate` 接口
|
||||
- `trade_type=JSAPI`
|
||||
- `sub_appid=公众号AppID`(从 `tb_wechat_config.oa_app_id` 读取)
|
||||
- `sub_openid=用户公众号OpenID`
|
||||
- 传入订单号、金额(分)、商品描述、终端 IP、回调地址
|
||||
- **THEN** 富友返回 `result_code=000000`,包含支付参数
|
||||
|
||||
**富友返回支付参数结构**
|
||||
|
||||
```json
|
||||
{
|
||||
"sdk_appid": "wx1234567890abcdef",
|
||||
"sdk_timestamp": "1711411341",
|
||||
"sdk_noncestr": "abc123def456",
|
||||
"sdk_prepayid": "wx26112221580621e9b071c00d9e093b0000",
|
||||
"sdk_package": "Sign=WXPay",
|
||||
"sdk_signtype": "RSA",
|
||||
"sdk_paysign": "..."
|
||||
}
|
||||
```
|
||||
|
||||
- **THEN** 系统将支付参数返回给前端,前端调用 `WeixinJSBridge.invoke('getBrandWCPayRequest', ...)` 拉起支付
|
||||
|
||||
#### Scenario: 公众号 JSAPI 下单失败
|
||||
|
||||
- **WHEN** 富友返回 `result_code` 非 `000000`
|
||||
- **THEN** 系统记录 ERROR 日志(订单号、错误码、错误消息)
|
||||
- **THEN** 系统返回错误
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1173,
|
||||
"data": null,
|
||||
"msg": "支付发起失败,请重试",
|
||||
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 富友支付小程序下单
|
||||
|
||||
系统 SHALL 支持通过富友支付 `wxPreCreate` 接口发起微信小程序支付。
|
||||
|
||||
#### Scenario: 小程序下单成功
|
||||
|
||||
- **WHEN** 系统调用富友 `wxPreCreate` 接口
|
||||
- `trade_type=LETPAY`
|
||||
- `sub_appid=小程序AppID`(从 `tb_wechat_config.miniapp_app_id` 读取)
|
||||
- `sub_openid=用户小程序OpenID`
|
||||
- **THEN** 富友返回 `result_code=000000`,包含支付参数
|
||||
- **THEN** 系统将支付参数返回给前端,前端调用 `wx.requestPayment(...)` 拉起支付
|
||||
|
||||
#### Scenario: 小程序下单缺少 OpenID
|
||||
|
||||
- **WHEN** 系统发起小程序支付但未传入 `sub_openid`
|
||||
- **THEN** 系统返回错误
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 1001,
|
||||
"data": null,
|
||||
"msg": "小程序支付必须提供用户 OpenID",
|
||||
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 富友支付回调处理
|
||||
|
||||
系统 SHALL 接收并处理富友支付成功回调通知,验证签名后更新订单/充值状态。
|
||||
|
||||
#### Scenario: 接收到合法的支付成功回调
|
||||
|
||||
```
|
||||
POST /api/callback/fuiou-pay
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
无需认证
|
||||
```
|
||||
|
||||
**请求体格式**:`req=<双重URL编码的GBK XML>`
|
||||
|
||||
- **THEN** 系统将请求体从 GBK 转换为 UTF-8
|
||||
- **THEN** 系统解析 XML 格式的回调数据
|
||||
- **THEN** 系统根据 `mchnt_order_no` 判断订单类型:
|
||||
- `ORD` 开头 → 套餐订单 → 查询 `tb_order`
|
||||
- `CRCH` 开头 → 资产充值 → 查询 `tb_asset_recharge_record`
|
||||
- `ARCH` 开头 → 代理充值 → 查询 `tb_agent_recharge_record`
|
||||
- **THEN** 通过记录的 `payment_config_id` 加载对应的富友配置
|
||||
- **THEN** 使用该配置的富友公钥验证 RSA 签名
|
||||
- **THEN** 验证 `result_code=000000` 且金额匹配
|
||||
- **THEN** 调用对应 Service 的 HandlePaymentCallback
|
||||
- **THEN** 返回成功 XML 响应(GBK 编码)
|
||||
|
||||
**成功响应**
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="GBK"?>
|
||||
<xml>
|
||||
<result_code>000000</result_code>
|
||||
<result_msg>success</result_msg>
|
||||
</xml>
|
||||
```
|
||||
|
||||
#### Scenario: 回调签名验证失败
|
||||
|
||||
- **WHEN** 富友回调的 RSA 签名与本地计算不匹配
|
||||
- **THEN** 系统记录 ERROR 日志
|
||||
- **THEN** 返回失败 XML 响应
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="GBK"?>
|
||||
<xml>
|
||||
<result_code>999999</result_code>
|
||||
<result_msg>signature verification failed</result_msg>
|
||||
</xml>
|
||||
```
|
||||
|
||||
#### Scenario: 回调订单号不存在
|
||||
|
||||
- **WHEN** `mchnt_order_no` 在系统中不存在
|
||||
- **THEN** 系统记录 ERROR 日志,返回失败 XML 响应
|
||||
|
||||
#### Scenario: 重复回调幂等处理
|
||||
|
||||
- **WHEN** 富友对同一订单多次发送支付成功回调
|
||||
- **THEN** 系统识别已支付,直接返回成功 XML 响应
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 富友 XML 通信协议
|
||||
|
||||
系统 SHALL 正确处理富友支付的 XML + GBK 编码通信协议。
|
||||
|
||||
#### Scenario: 请求编码
|
||||
|
||||
- **WHEN** 系统向富友发送请求
|
||||
- **THEN** 请求体为 XML 格式,GBK 编码声明
|
||||
- **THEN** XML 内容经 GBK 编码后进行两次 URL 编码
|
||||
- **THEN** 以 `req=<encoded_xml>` 的 form 格式发送
|
||||
|
||||
#### Scenario: 响应解码
|
||||
|
||||
- **WHEN** 系统接收富友响应
|
||||
- **THEN** 先进行 URL 解码
|
||||
- **THEN** 将 GBK 内容转换为 UTF-8
|
||||
- **THEN** 替换 XML 声明中的 `encoding="GBK"` 为 `encoding="UTF-8"`
|
||||
- **THEN** 解析 XML 到结构体
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 富友 RSA 签名算法
|
||||
|
||||
系统 SHALL 实现富友支付的 RSA + MD5 签名验签算法。
|
||||
|
||||
#### Scenario: 生成请求签名
|
||||
|
||||
- **WHEN** 系统需要对富友请求签名
|
||||
- **THEN** 提取所有非空字段(排除 `sign` 和 `reserved_` 开头字段)
|
||||
- **THEN** 按字典序排列为 `key=value&key=value` 格式
|
||||
- **THEN** 将签名原文转换为 GBK 编码
|
||||
- **THEN** 计算 MD5 哈希
|
||||
- **THEN** 使用商户私钥对 MD5 哈希进行 RSA PKCS1v15 签名
|
||||
- **THEN** 对签名结果进行 Base64 编码
|
||||
|
||||
#### Scenario: 验证回调签名
|
||||
|
||||
- **WHEN** 系统需要验证富友回调签名
|
||||
- **THEN** 使用相同算法计算签名原文的 MD5 哈希
|
||||
- **THEN** 使用富友公钥对回调中的 `sign` 字段进行 RSA PKCS1v15 验签
|
||||
@@ -1,29 +0,0 @@
|
||||
# fund-summary-primary-account Specification
|
||||
|
||||
## Purpose
|
||||
修复资金概况接口的主账号数据来源问题:确保新建店铺时初始账号被标记为主账号(`is_primary = true`),并通过数据迁移修复历史存量数据,使 `GET /api/admin/shops/fund-summary` 接口能够正确返回每个店铺的主账号用户名和手机号。
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 新建店铺时初始账号标记为主账号
|
||||
|
||||
系统 SHALL 在创建店铺的同时创建初始账号时,将该账号的 `is_primary` 字段设置为 `true`。
|
||||
|
||||
#### Scenario: 创建店铺生成主账号
|
||||
|
||||
- **WHEN** 调用 `POST /api/admin/shops` 创建新店铺
|
||||
- **THEN** 自动创建的初始代理账号 `is_primary = true`,可被 `GetPrimaryAccountsByShopIDs` 查询返回
|
||||
|
||||
### Requirement: 资金概况接口返回正确的用户名和手机号
|
||||
|
||||
系统 SHALL 在 `GET /api/admin/shops/fund-summary` 接口响应中,对每个店铺正确返回主账号的 `username` 和 `phone` 字段(非空)。
|
||||
|
||||
#### Scenario: 有主账号的店铺返回用户信息
|
||||
|
||||
- **WHEN** 调用 `/api/admin/shops/fund-summary`,店铺存在 `is_primary = true` 的账号
|
||||
- **THEN** 响应中每个店铺的 `username` 和 `phone` 为该主账号的真实值,不为空字符串
|
||||
|
||||
#### Scenario: 历史存量数据修复后正确返回
|
||||
|
||||
- **WHEN** 数据迁移执行后,调用 `/api/admin/shops/fund-summary`
|
||||
- **THEN** 现有店铺的账号 `is_primary = true` 已修复,接口返回正确用户名和手机号
|
||||
@@ -1,260 +0,0 @@
|
||||
# Gateway Client Specification
|
||||
|
||||
Gateway API 统一客户端,提供 14 个接口的类型安全封装。
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Gateway 客户端结构
|
||||
|
||||
系统 SHALL 提供 `gateway.Client` 结构体,封装所有 Gateway API 调用。
|
||||
|
||||
客户端字段:
|
||||
- `baseURL string` - Gateway API 基础 URL
|
||||
- `appID string` - 应用 ID
|
||||
- `appSecret string` - 应用密钥
|
||||
- `httpClient *http.Client` - HTTP 客户端(支持连接复用)
|
||||
- `timeout time.Duration` - 请求超时时间
|
||||
- `logger *zap.Logger` - 日志记录器
|
||||
- `maxRetries int` - 最大重试次数
|
||||
|
||||
#### Scenario: 创建 Gateway 客户端
|
||||
|
||||
- **WHEN** 调用 `gateway.NewClient(baseURL, appID, appSecret, logger)`
|
||||
- **THEN** 返回已初始化的 `Client` 实例
|
||||
- **AND** HTTP 客户端配置正确(支持 Keep-Alive)
|
||||
- **AND** 默认最大重试次数为 2
|
||||
|
||||
#### Scenario: 配置超时时间
|
||||
|
||||
- **WHEN** 调用 `client.WithTimeout(30 * time.Second)`
|
||||
- **THEN** 客户端的 `timeout` 字段更新为 30 秒
|
||||
- **AND** 返回客户端自身(支持链式调用)
|
||||
|
||||
### Requirement: 统一请求方法
|
||||
|
||||
系统 SHALL 提供 `doRequest` 方法,统一处理加密、签名、HTTP 请求和响应解析。请求参数 SHALL 直接接收结构体,内部自动序列化并包装为 `{"params": <JSON>}` 格式。
|
||||
|
||||
#### Scenario: 请求参数自动序列化
|
||||
|
||||
- **WHEN** 调用 `doRequest(ctx, "/device/speed-limit", &SpeedLimitReq{CardNo: "xxx", SpeedLimit: 1024})`
|
||||
- **THEN** 请求结构体自动通过 `sonic.Marshal` 序列化
|
||||
- **AND** 序列化结果嵌入 `{"params": <序列化JSON>}` 中进行加密和签名
|
||||
|
||||
#### Scenario: 成功的 API 调用
|
||||
|
||||
- **WHEN** 调用 `doRequest(ctx, "/flow-card/status", req)`
|
||||
- **THEN** 业务数据使用 AES-128-ECB 加密
|
||||
- **AND** 请求使用 MD5 签名
|
||||
- **AND** HTTP POST 发送到 `{baseURL}/flow-card/status`
|
||||
- **AND** 响应中的 `data` 字段返回为 `json.RawMessage`
|
||||
|
||||
#### Scenario: 网络错误
|
||||
|
||||
- **WHEN** HTTP 请求失败(网络中断、DNS 解析失败)
|
||||
- **THEN** 返回 `CodeGatewayError` 错误
|
||||
- **AND** 错误信息包含原始网络错误
|
||||
|
||||
#### Scenario: 请求超时
|
||||
|
||||
- **WHEN** HTTP 请求超过配置的超时时间
|
||||
- **THEN** 返回 `CodeGatewayTimeout` 错误
|
||||
- **AND** Context 超时错误被正确识别
|
||||
|
||||
#### Scenario: 响应格式错误
|
||||
|
||||
- **WHEN** Gateway 响应无法解析为 JSON
|
||||
- **THEN** 返回 `CodeGatewayInvalidResp` 错误
|
||||
- **AND** 错误信息包含原始响应内容
|
||||
|
||||
#### Scenario: Gateway 业务错误
|
||||
|
||||
- **WHEN** Gateway 响应中 `code != 200`
|
||||
- **THEN** 返回 `CodeGatewayError` 错误
|
||||
- **AND** 错误信息包含 Gateway 的 code 和 msg
|
||||
|
||||
### Requirement: 泛型响应解析方法
|
||||
|
||||
系统 SHALL 提供 `doRequestWithResponse[T any]` 泛型方法,自动完成请求发送和响应反序列化。
|
||||
|
||||
#### Scenario: 自动反序列化响应
|
||||
|
||||
- **WHEN** 调用 `doRequestWithResponse[CardStatusResp](ctx, "/flow-card/status", req)`
|
||||
- **THEN** 返回 `*CardStatusResp` 类型的结构体
|
||||
- **AND** 内部调用 `doRequest` 获取 `json.RawMessage` 后自动 unmarshal
|
||||
|
||||
#### Scenario: 反序列化失败
|
||||
|
||||
- **WHEN** Gateway 返回的 JSON 无法匹配目标结构体
|
||||
- **THEN** 返回 `CodeGatewayInvalidResp` 错误
|
||||
- **AND** 错误信息为 "解析 Gateway 响应失败"
|
||||
|
||||
### Requirement: 请求结构体直接序列化
|
||||
|
||||
系统 SHALL 消除手动 `map[string]interface{}` 构建,所有业务方法直接将请求结构体传递给 `doRequest` 或 `doRequestWithResponse`。
|
||||
|
||||
#### Scenario: 设备限速请求
|
||||
|
||||
- **WHEN** 调用 `SetSpeedLimit(ctx, &SpeedLimitReq{CardNo: "xxx", SpeedLimit: 1024})`
|
||||
- **THEN** `SpeedLimitReq` 结构体直接序列化为 JSON
|
||||
- **AND** 不再手动构建 `map[string]interface{}`
|
||||
|
||||
#### Scenario: 流量卡停机请求
|
||||
|
||||
- **WHEN** 调用 `StopCard(ctx, &CardOperationReq{CardNo: "xxx", Extend: "ext"})`
|
||||
- **THEN** `CardOperationReq` 结构体直接序列化
|
||||
- **AND** `Extend` 字段通过 `json:"extend,omitempty"` 标签在为空时自动省略
|
||||
### Requirement: 流量卡 API 封装
|
||||
|
||||
系统 SHALL 提供 7 个流量卡相关的 API 方法。
|
||||
|
||||
#### Scenario: 查询流量卡状态
|
||||
|
||||
- **WHEN** 调用 `client.QueryCardStatus(ctx, &CardStatusReq{CardNo: "898608070422D0010269"})`
|
||||
- **THEN** 返回 `CardStatusResp` 包含 ICCID 和卡状态
|
||||
- **AND** 卡状态为:"准备"、"正常" 或 "停机" 之一
|
||||
|
||||
#### Scenario: 查询流量使用
|
||||
|
||||
- **WHEN** 调用 `client.QueryFlow(ctx, &FlowQueryReq{CardNo: "898608070422D0010269"})`
|
||||
- **THEN** 返回 `FlowUsageResp` 包含已用流量和单位
|
||||
- **AND** 流量单位为 "MB"
|
||||
|
||||
#### Scenario: 查询实名认证状态
|
||||
|
||||
- **WHEN** 调用 `client.QueryRealnameStatus(ctx, &CardStatusReq{CardNo: "898608070422D0010269"})`
|
||||
- **THEN** 返回实名认证状态信息
|
||||
|
||||
#### Scenario: 流量卡停机
|
||||
|
||||
- **WHEN** 调用 `client.StopCard(ctx, &CardOperationReq{CardNo: "898608070422D0010269"})`
|
||||
- **THEN** Gateway 执行停机操作
|
||||
- **AND** 方法返回 nil(成功)或错误
|
||||
|
||||
#### Scenario: 流量卡复机
|
||||
|
||||
- **WHEN** 调用 `client.StartCard(ctx, &CardOperationReq{CardNo: "898608070422D0010269"})`
|
||||
- **THEN** Gateway 执行复机操作
|
||||
- **AND** 方法返回 nil(成功)或错误
|
||||
|
||||
#### Scenario: 获取实名认证链接
|
||||
|
||||
- **WHEN** 调用 `client.GetRealnameLink(ctx, &CardStatusReq{CardNo: "898608070422D0010269"})`
|
||||
- **THEN** 返回实名认证跳转链接
|
||||
- **AND** 链接格式为有效的 HTTPS URL
|
||||
|
||||
#### Scenario: 广电国网扩展参数
|
||||
|
||||
- **WHEN** 停机/复机请求中 `Extend` 字段不为空
|
||||
- **THEN** 请求包含 `extend` 参数
|
||||
- **AND** Gateway 正确处理广电国网特殊逻辑
|
||||
|
||||
### Requirement: 设备 API 封装
|
||||
|
||||
系统 SHALL 提供 7 个设备相关的 API 方法。
|
||||
|
||||
#### Scenario: 查询设备信息
|
||||
|
||||
- **WHEN** 调用 `client.GetDeviceInfo(ctx, &DeviceInfoReq{CardNo: "898608070422D0010269"})`
|
||||
- **THEN** 返回 `DeviceInfoResp` 包含设备详细信息
|
||||
- **AND** 信息包括:IMEI、在线状态、信号强度、WiFi 配置、速率等
|
||||
|
||||
#### Scenario: 通过设备 ID 查询
|
||||
|
||||
- **WHEN** 调用 `client.GetDeviceInfo(ctx, &DeviceInfoReq{DeviceID: "868123456789012"})`
|
||||
- **THEN** 通过设备 IMEI 查询设备信息
|
||||
- **AND** 返回结果与通过卡号查询一致
|
||||
|
||||
#### Scenario: 查询设备卡槽信息
|
||||
|
||||
- **WHEN** 调用 `client.GetSlotInfo(ctx, &DeviceInfoReq{CardNo: "898608070422D0010269"})`
|
||||
- **THEN** 返回设备中已安装的物联网卡信息
|
||||
|
||||
#### Scenario: 设置设备限速
|
||||
|
||||
- **WHEN** 调用 `client.SetSpeedLimit(ctx, &SpeedLimitReq{DeviceID: "868123456789012", UploadSpeed: 1024, DownloadSpeed: 2048})`
|
||||
- **THEN** 设备上下行速率设置为指定值(KB/s)
|
||||
|
||||
#### Scenario: 设置设备 WiFi
|
||||
|
||||
- **WHEN** 调用 `client.SetWiFi(ctx, &WiFiReq{CardNo: "868123456789012", Params: WiFiParams{SSIDName: "MyWiFi", SSIDPassword: "12345678"}})`
|
||||
- **THEN** 设备 WiFi 配置更新
|
||||
- **AND** WiFi 名称和密码正确设置
|
||||
|
||||
#### Scenario: 设备切换卡
|
||||
|
||||
- **WHEN** 调用 `client.SwitchCard(ctx, &SwitchCardReq{DeviceID: "868123456789012", TargetICCID: "898608070422D0010270"})`
|
||||
- **THEN** 多卡设备切换到目标 ICCID
|
||||
|
||||
#### Scenario: 设备恢复出厂设置
|
||||
|
||||
- **WHEN** 调用 `client.ResetDevice(ctx, &DeviceOperationReq{DeviceID: "868123456789012"})`
|
||||
- **THEN** 设备恢复为出厂状态
|
||||
|
||||
#### Scenario: 设备重启
|
||||
|
||||
- **WHEN** 调用 `client.RebootDevice(ctx, &DeviceOperationReq{DeviceID: "868123456789012"})`
|
||||
- **THEN** 设备执行重启操作
|
||||
|
||||
### Requirement: 类型安全的 DTO
|
||||
|
||||
系统 SHALL 为所有请求和响应定义类型安全的结构体。
|
||||
|
||||
#### Scenario: 请求 DTO 包含验证标签
|
||||
|
||||
- **WHEN** 定义 `CardStatusReq` 结构体
|
||||
- **THEN** `CardNo` 字段包含 `validate:"required"` 标签
|
||||
- **AND** 可以使用 Validator 库进行验证
|
||||
|
||||
#### Scenario: 响应 DTO 正确解析
|
||||
|
||||
- **WHEN** Gateway 返回 JSON 响应
|
||||
- **THEN** `CardStatusResp` 结构体正确解析 `iccid`、`cardStatus`、`extend` 字段
|
||||
- **AND** 字段类型与 Gateway 文档一致
|
||||
|
||||
### Requirement: 并发安全
|
||||
|
||||
系统 SHALL 确保 `Client` 结构体可以安全地并发调用。
|
||||
|
||||
#### Scenario: 多个 Goroutine 并发调用
|
||||
|
||||
- **WHEN** 10 个 Goroutine 同时调用 `client.QueryCardStatus`
|
||||
- **THEN** 所有请求都正确执行
|
||||
- **AND** 不发生 race condition
|
||||
|
||||
#### Scenario: HTTP 连接复用
|
||||
|
||||
- **WHEN** 多次调用相同的 Gateway API
|
||||
- **THEN** HTTP 客户端复用 TCP 连接
|
||||
- **AND** 减少连接建立开销
|
||||
|
||||
### Requirement: 错误处理一致性
|
||||
|
||||
系统 SHALL 使用项目统一的错误码系统。
|
||||
|
||||
#### Scenario: Gateway 错误返回统一错误码
|
||||
|
||||
- **WHEN** Gateway API 调用失败
|
||||
- **THEN** 返回 `errors.AppError` 类型
|
||||
- **AND** 错误码为 `CodeGatewayError`、`CodeGatewayTimeout` 等之一
|
||||
|
||||
#### Scenario: 错误包含上下文信息
|
||||
|
||||
- **WHEN** 加密失败
|
||||
- **THEN** 错误信息为 "数据加密失败"
|
||||
- **AND** 包含底层错误的详细信息
|
||||
|
||||
### Requirement: Context 支持
|
||||
|
||||
系统 SHALL 支持通过 Context 控制请求超时和取消。
|
||||
|
||||
#### Scenario: 使用 Context 控制超时
|
||||
|
||||
- **WHEN** 调用 `client.QueryCardStatus(ctx, req)` 且 ctx 设置了 30 秒超时
|
||||
- **THEN** 请求在 30 秒后自动超时
|
||||
- **AND** 返回 `CodeGatewayTimeout` 错误
|
||||
|
||||
#### Scenario: 取消请求
|
||||
|
||||
- **WHEN** 调用 `client.QueryCardStatus(ctx, req)` 且 ctx 被取消
|
||||
- **THEN** 请求立即停止
|
||||
- **AND** 返回 context canceled 错误
|
||||
@@ -1,175 +0,0 @@
|
||||
# Gateway Config Specification
|
||||
|
||||
Gateway API 的配置集成规范,定义配置结构和加载方式。
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Gateway 配置结构
|
||||
|
||||
系统 SHALL 在 `pkg/config/config.go` 中添加 `GatewayConfig` 结构体。
|
||||
|
||||
配置字段:
|
||||
- `BaseURL string` - Gateway API 基础 URL
|
||||
- `AppID string` - 应用 ID
|
||||
- `AppSecret string` - 应用密钥
|
||||
- `Timeout int` - 请求超时时间(秒)
|
||||
|
||||
#### Scenario: 配置结构定义
|
||||
|
||||
- **WHEN** 定义 `GatewayConfig` 结构体
|
||||
- **THEN** 包含 `mapstructure` 标签用于 Viper 解析
|
||||
- **AND** 字段名使用 snake_case(如 `base_url`、`app_id`)
|
||||
|
||||
#### Scenario: 集成到主配置
|
||||
|
||||
- **WHEN** 在 `Config` 结构体中添加 `Gateway GatewayConfig` 字段
|
||||
- **THEN** 使用 `mapstructure:"gateway"` 标签
|
||||
- **AND** 配置可通过 `config.Get().Gateway` 访问
|
||||
|
||||
### Requirement: 默认配置嵌入
|
||||
|
||||
系统 SHALL 在 `pkg/config/defaults/config.yaml` 中添加 Gateway 默认配置。
|
||||
|
||||
#### Scenario: 嵌入默认配置
|
||||
|
||||
- **WHEN** 读取嵌入的默认配置文件
|
||||
- **THEN** 包含 `gateway` 配置节
|
||||
- **AND** 配置包含:
|
||||
```yaml
|
||||
gateway:
|
||||
base_url: "https://lplan.whjhft.com/openapi"
|
||||
app_id: "60bgt1X8i7AvXqkd"
|
||||
app_secret: "BZeQttaZQt0i73moF"
|
||||
timeout: 30
|
||||
```
|
||||
|
||||
### Requirement: 环境变量覆盖
|
||||
|
||||
系统 SHALL 支持通过环境变量覆盖 Gateway 配置。
|
||||
|
||||
环境变量格式:`JUNHONG_GATEWAY_{KEY}`
|
||||
|
||||
#### Scenario: 覆盖 BaseURL
|
||||
|
||||
- **WHEN** 设置环境变量 `JUNHONG_GATEWAY_BASE_URL=https://test.example.com`
|
||||
- **THEN** `config.Gateway.BaseURL` 的值为 "https://test.example.com"
|
||||
- **AND** 覆盖嵌入配置中的默认值
|
||||
|
||||
#### Scenario: 覆盖 AppID
|
||||
|
||||
- **WHEN** 设置环境变量 `JUNHONG_GATEWAY_APP_ID=test_app_id`
|
||||
- **THEN** `config.Gateway.AppID` 的值为 "test_app_id"
|
||||
|
||||
#### Scenario: 覆盖 AppSecret
|
||||
|
||||
- **WHEN** 设置环境变量 `JUNHONG_GATEWAY_APP_SECRET=test_secret`
|
||||
- **THEN** `config.Gateway.AppSecret` 的值为 "test_secret"
|
||||
|
||||
#### Scenario: 覆盖 Timeout
|
||||
|
||||
- **WHEN** 设置环境变量 `JUNHONG_GATEWAY_TIMEOUT=60`
|
||||
- **THEN** `config.Gateway.Timeout` 的值为 60
|
||||
|
||||
### Requirement: 配置验证
|
||||
|
||||
系统 SHALL 在配置加载后验证 Gateway 配置的有效性。
|
||||
|
||||
#### Scenario: 必填字段验证
|
||||
|
||||
- **WHEN** 配置加载完成
|
||||
- **THEN** 验证 `BaseURL`、`AppID`、`AppSecret` 不为空
|
||||
- **AND** 如果为空,返回明确的错误信息
|
||||
|
||||
#### Scenario: BaseURL 格式验证
|
||||
|
||||
- **WHEN** 验证 `BaseURL` 字段
|
||||
- **THEN** 必须以 `http://` 或 `https://` 开头
|
||||
- **AND** 不能以 `/` 结尾
|
||||
|
||||
#### Scenario: Timeout 范围验证
|
||||
|
||||
- **WHEN** 验证 `Timeout` 字段
|
||||
- **THEN** 值必须在 5 到 300 秒之间
|
||||
- **AND** 如果超出范围,返回验证错误
|
||||
|
||||
#### Scenario: AppID 格式验证
|
||||
|
||||
- **WHEN** 验证 `AppID` 字段
|
||||
- **THEN** 长度必须 > 0
|
||||
- **AND** 不包含特殊字符(仅允许字母、数字、下划线)
|
||||
|
||||
### Requirement: 敏感配置处理
|
||||
|
||||
系统 SHALL 确保 `AppSecret` 不记录到日志中。
|
||||
|
||||
#### Scenario: 配置日志脱敏
|
||||
|
||||
- **WHEN** 记录配置加载成功的日志
|
||||
- **THEN** `AppSecret` 字段显示为 "***"
|
||||
- **AND** 实际值不出现在日志中
|
||||
|
||||
#### Scenario: 错误日志脱敏
|
||||
|
||||
- **WHEN** 配置验证失败并记录错误日志
|
||||
- **THEN** `AppSecret` 字段显示为 "***"
|
||||
|
||||
### Requirement: Gateway 客户端初始化
|
||||
|
||||
系统 SHALL 在 `internal/bootstrap/bootstrap.go` 中初始化 Gateway 客户端。
|
||||
|
||||
#### Scenario: Bootstrap 中初始化
|
||||
|
||||
- **WHEN** 调用 `bootstrap.Bootstrap(deps)`
|
||||
- **THEN** 从 `deps.Config.Gateway` 读取配置
|
||||
- **AND** 调用 `gateway.NewClient(baseURL, appID, appSecret).WithTimeout(...)`
|
||||
- **AND** 将客户端赋值给 `deps.GatewayClient`
|
||||
|
||||
#### Scenario: 配置错误时启动失败
|
||||
|
||||
- **WHEN** Gateway 配置验证失败
|
||||
- **THEN** `bootstrap.Bootstrap` 返回错误
|
||||
- **AND** 应用启动失败
|
||||
|
||||
### Requirement: 多环境配置支持
|
||||
|
||||
系统 SHALL 支持通过环境变量切换不同环境的 Gateway 配置。
|
||||
|
||||
#### Scenario: 开发环境配置
|
||||
|
||||
- **WHEN** 使用默认嵌入配置(未设置环境变量)
|
||||
- **THEN** 使用生产环境的 Gateway URL 和凭证
|
||||
|
||||
#### Scenario: 测试环境配置
|
||||
|
||||
- **WHEN** 设置环境变量指向测试 Gateway
|
||||
- **AND** `JUNHONG_GATEWAY_BASE_URL=https://test-gateway.example.com`
|
||||
- **AND** `JUNHONG_GATEWAY_APP_ID=test_app_id`
|
||||
- **THEN** 客户端连接到测试环境
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Config 结构体扩展
|
||||
|
||||
系统 SHALL 在现有的 `Config` 结构体中添加 `Gateway` 字段。
|
||||
|
||||
#### Scenario: 配置结构兼容性
|
||||
|
||||
- **WHEN** 添加 `Gateway GatewayConfig` 字段
|
||||
- **THEN** 不影响现有配置字段的加载
|
||||
- **AND** 现有配置(Server、Database、Redis 等)继续正常工作
|
||||
|
||||
### Requirement: Dependencies 结构体扩展
|
||||
|
||||
系统 SHALL 在 `internal/bootstrap/bootstrap.go` 的 `Dependencies` 结构体中添加 `GatewayClient` 字段。
|
||||
|
||||
#### Scenario: 依赖注入扩展
|
||||
|
||||
- **WHEN** 在 `Dependencies` 中添加 `GatewayClient *gateway.Client` 字段
|
||||
- **THEN** 不影响现有依赖的注入
|
||||
- **AND** Gateway 客户端可以注入到需要的 Service
|
||||
|
||||
#### Scenario: Service 层使用
|
||||
|
||||
- **WHEN** Service 需要调用 Gateway API
|
||||
- **THEN** 在 Service 构造函数中接收 `gatewayClient *gateway.Client` 参数
|
||||
- **AND** 从 Bootstrap 中传递 `deps.GatewayClient`
|
||||
@@ -1,155 +0,0 @@
|
||||
# Gateway Crypto Specification
|
||||
|
||||
Gateway API 的加密和签名工具函数,实现 AES-128-ECB 加密和 MD5 签名机制。
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: AES-128-ECB 加密
|
||||
|
||||
系统 SHALL 提供 `aesEncrypt` 函数,使用 AES-128-ECB 模式加密业务数据。
|
||||
|
||||
加密流程:
|
||||
1. 密钥生成:`MD5(appSecret)` 的原始字节数组(16字节)
|
||||
2. 加密算法:AES-128-ECB
|
||||
3. 填充方式:PKCS5Padding
|
||||
4. 编码输出:Base64
|
||||
|
||||
#### Scenario: 加密业务数据
|
||||
|
||||
- **WHEN** 调用 `aesEncrypt(data, appSecret)`
|
||||
- **AND** `data` 为业务数据的 JSON 字节数组
|
||||
- **THEN** 返回 Base64 编码的加密字符串
|
||||
- **AND** 密钥为 `MD5(appSecret)` 的 16 字节数组
|
||||
|
||||
#### Scenario: PKCS5 填充正确性
|
||||
|
||||
- **WHEN** 业务数据长度不是 AES 块大小(16 字节)的整数倍
|
||||
- **THEN** 使用 PKCS5Padding 进行填充
|
||||
- **AND** 填充字节值等于填充长度
|
||||
|
||||
#### Scenario: 加密输出格式
|
||||
|
||||
- **WHEN** 加密成功
|
||||
- **THEN** 输出为 Base64 字符串
|
||||
- **AND** 字符串不包含换行符
|
||||
|
||||
#### Scenario: 加密失败
|
||||
|
||||
- **WHEN** AES 加密过程失败
|
||||
- **THEN** 返回 `CodeGatewayEncryptError` 错误
|
||||
- **AND** 错误信息包含原始错误
|
||||
|
||||
### Requirement: MD5 签名生成
|
||||
|
||||
系统 SHALL 提供 `generateSign` 函数,生成 MD5 签名。
|
||||
|
||||
签名流程:
|
||||
1. 参数排序:`appId`、`data`、`timestamp` 按字母升序
|
||||
2. 拼接字符串:`appId=xxx&data=xxx×tamp=xxx&key=appSecret`
|
||||
3. MD5 加密
|
||||
4. 转大写十六进制
|
||||
|
||||
#### Scenario: 生成正确的签名
|
||||
|
||||
- **WHEN** 调用 `generateSign(appID, encryptedData, timestamp, appSecret)`
|
||||
- **THEN** 参数按字母序拼接:`appId` → `data` → `timestamp`
|
||||
- **AND** 追加 `&key=appSecret`
|
||||
- **AND** MD5 加密后转大写十六进制
|
||||
|
||||
#### Scenario: 签名输出格式
|
||||
|
||||
- **WHEN** 签名生成成功
|
||||
- **THEN** 输出为 32 位大写十六进制字符串
|
||||
- **AND** 例如:"ABCDEF1234567890ABCDEF1234567890"
|
||||
|
||||
#### Scenario: 签名可重现
|
||||
|
||||
- **WHEN** 使用相同的 `appID`、`encryptedData`、`timestamp`、`appSecret`
|
||||
- **THEN** 多次调用 `generateSign` 生成相同的签名
|
||||
|
||||
#### Scenario: 时间戳格式
|
||||
|
||||
- **WHEN** 签名中使用时间戳
|
||||
- **THEN** 时间戳为 Unix 秒级时间戳(10 位数字)
|
||||
- **AND** 例如:1704067200
|
||||
|
||||
### Requirement: 参数序列化
|
||||
|
||||
系统 SHALL 正确序列化请求参数,确保与 Gateway 期望格式一致。
|
||||
|
||||
#### Scenario: 业务数据序列化
|
||||
|
||||
- **WHEN** 业务数据为 Go 结构体
|
||||
- **THEN** 使用 `sonic.Marshal` 序列化为 JSON 字符串
|
||||
- **AND** JSON 格式与 Gateway 文档一致
|
||||
|
||||
#### Scenario: 空字段处理
|
||||
|
||||
- **WHEN** 请求结构体中某些字段为空(omitempty)
|
||||
- **THEN** 序列化时忽略空字段
|
||||
- **AND** 减少请求体大小
|
||||
|
||||
### Requirement: 加密/签名测试验证
|
||||
|
||||
系统 SHALL 提供加密和签名的单元测试,验证与 Gateway 文档一致性。
|
||||
|
||||
#### Scenario: 加密测试用例
|
||||
|
||||
- **WHEN** 使用已知的业务数据和 appSecret
|
||||
- **THEN** 加密输出与 Gateway 文档示例一致
|
||||
- **AND** 可以被 Gateway 正确解密
|
||||
|
||||
#### Scenario: 签名测试用例
|
||||
|
||||
- **WHEN** 使用已知的参数和 appSecret
|
||||
- **THEN** 签名输出与 Gateway 文档示例一致
|
||||
- **AND** Gateway 验证签名成功
|
||||
|
||||
#### Scenario: 端到端验证
|
||||
|
||||
- **WHEN** 运行集成测试,实际调用 Gateway API
|
||||
- **THEN** 加密和签名被 Gateway 接受
|
||||
- **AND** 响应状态码为 200
|
||||
|
||||
### Requirement: 性能要求
|
||||
|
||||
系统 SHALL 确保加密和签名操作的性能满足要求。
|
||||
|
||||
#### Scenario: 加密性能
|
||||
|
||||
- **WHEN** 加密 1KB 的业务数据
|
||||
- **THEN** 加密时间 < 1ms
|
||||
- **AND** 内存分配最小化
|
||||
|
||||
#### Scenario: 签名性能
|
||||
|
||||
- **WHEN** 生成签名
|
||||
- **THEN** 签名时间 < 0.5ms
|
||||
- **AND** 无不必要的内存分配
|
||||
|
||||
### Requirement: 安全性说明
|
||||
|
||||
系统 SHALL 在文档中说明 AES-ECB 模式的安全性限制。
|
||||
|
||||
#### Scenario: 安全性文档
|
||||
|
||||
- **WHEN** 查看加密函数的文档注释
|
||||
- **THEN** 注释中说明 ECB 模式不推荐用于生产环境
|
||||
- **AND** 说明这是 Gateway 强制要求,无法改变
|
||||
- **AND** 建议使用 HTTPS 加密传输层
|
||||
|
||||
### Requirement: 字符编码一致性
|
||||
|
||||
系统 SHALL 确保所有字符串操作使用 UTF-8 编码。
|
||||
|
||||
#### Scenario: 字符串编码
|
||||
|
||||
- **WHEN** 序列化业务数据
|
||||
- **THEN** 使用 UTF-8 编码
|
||||
- **AND** 中文字符正确处理
|
||||
|
||||
#### Scenario: 签名字符串编码
|
||||
|
||||
- **WHEN** 生成签名的拼接字符串
|
||||
- **THEN** 使用 UTF-8 编码
|
||||
- **AND** 与 Gateway 期望的编码一致
|
||||
@@ -1,39 +0,0 @@
|
||||
# Gateway Request Logging
|
||||
|
||||
## Purpose
|
||||
|
||||
Gateway 请求日志记录,在每次 Gateway API 调用时按结果级别记录请求日志,便于问题排查和运维监控。
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Gateway 请求日志
|
||||
|
||||
系统 SHALL 在每次 Gateway API 调用时记录请求日志,包含请求路径和请求体大小。
|
||||
|
||||
#### Scenario: 正常请求记录 Debug 日志
|
||||
|
||||
- **WHEN** 调用 `doRequest(ctx, "/flow-card/status", req)` 且请求成功
|
||||
- **THEN** 记录 Debug 级别日志
|
||||
- **AND** 日志包含字段:`path`(请求路径)、`duration`(耗时)
|
||||
|
||||
#### Scenario: Gateway 业务错误记录 Warn 日志
|
||||
|
||||
- **WHEN** Gateway 返回 `code != 200` 的业务错误
|
||||
- **THEN** 记录 Warn 级别日志
|
||||
- **AND** 日志包含字段:`path`、`duration`、`gateway_code`(Gateway 状态码)、`gateway_msg`(Gateway 错误信息)
|
||||
|
||||
#### Scenario: 网络错误记录 Error 日志
|
||||
|
||||
- **WHEN** HTTP 请求失败(连接失败、超时、DNS 解析失败等)
|
||||
- **THEN** 记录 Error 级别日志
|
||||
- **AND** 日志包含字段:`path`、`duration`、`error`(错误信息)
|
||||
|
||||
### Requirement: Logger 依赖注入
|
||||
|
||||
系统 SHALL 通过构造函数将 `*zap.Logger` 注入到 `Client` 中。
|
||||
|
||||
#### Scenario: 创建带日志的客户端
|
||||
|
||||
- **WHEN** 调用 `gateway.NewClient(baseURL, appID, appSecret, logger)`
|
||||
- **THEN** 客户端使用传入的 logger 记录日志
|
||||
- **AND** 不使用全局 `zap.L()`
|
||||
@@ -1,57 +0,0 @@
|
||||
# Gateway Retry
|
||||
|
||||
## Purpose
|
||||
|
||||
Gateway 请求自动重试机制,在网络级错误时自动重试,提高 Gateway API 调用的可靠性。
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 网络级错误自动重试
|
||||
|
||||
系统 SHALL 在 Gateway API 调用遇到网络级错误时自动重试。
|
||||
|
||||
#### Scenario: 连接失败自动重试
|
||||
|
||||
- **WHEN** Gateway HTTP 请求因连接失败(TCP 连接拒绝、DNS 解析失败)失败
|
||||
- **THEN** 系统自动重试,最多重试 2 次(共 3 次尝试)
|
||||
- **AND** 重试间隔使用指数退避(100ms → 300ms)
|
||||
|
||||
#### Scenario: Client 超时自动重试
|
||||
|
||||
- **WHEN** Gateway HTTP 请求因 Client 配置的超时时间到期而失败
|
||||
- **THEN** 系统自动重试
|
||||
- **AND** 用户传入的 Context 未被取消
|
||||
|
||||
#### Scenario: Gateway 业务错误不重试
|
||||
|
||||
- **WHEN** Gateway 返回 HTTP 200 但业务状态码 `code != 200`
|
||||
- **THEN** 系统不重试,直接返回业务错误
|
||||
|
||||
#### Scenario: HTTP 状态码错误不重试
|
||||
|
||||
- **WHEN** Gateway 返回 HTTP 4xx 或 5xx 状态码
|
||||
- **THEN** 系统不重试,直接返回错误
|
||||
|
||||
#### Scenario: 用户 Context 取消不重试
|
||||
|
||||
- **WHEN** 用户传入的 Context 被取消
|
||||
- **THEN** 系统立即停止,不重试
|
||||
|
||||
#### Scenario: 加密或序列化错误不重试
|
||||
|
||||
- **WHEN** 请求参数加密或序列化失败
|
||||
- **THEN** 系统不重试,直接返回错误
|
||||
|
||||
### Requirement: 重试配置
|
||||
|
||||
系统 SHALL 支持通过链式方法配置重试参数。
|
||||
|
||||
#### Scenario: 自定义最大重试次数
|
||||
|
||||
- **WHEN** 调用 `client.WithRetry(3)` 后发起 API 请求
|
||||
- **THEN** 网络级错误时最多重试 3 次(共 4 次尝试)
|
||||
|
||||
#### Scenario: 禁用重试
|
||||
|
||||
- **WHEN** 调用 `client.WithRetry(0)` 后发起 API 请求
|
||||
- **THEN** 不进行任何重试
|
||||
@@ -1,56 +0,0 @@
|
||||
### Requirement: 超级管理员可设置和修改全局操作密码
|
||||
|
||||
系统 SHALL 提供 `POST /api/admin/super-admin/operation-password` 接口,仅允许 `user_type=1`(超级管理员)调用,用于设置或修改全局操作密码。密码以 bcrypt 哈希值存储于 Redis。
|
||||
|
||||
#### Scenario: 超级管理员首次设置操作密码
|
||||
|
||||
- **WHEN** 超级管理员调用 `POST /api/admin/super-admin/operation-password`,传入合法的 `password` 和 `confirm_password`(两者一致)
|
||||
- **THEN** 系统将 bcrypt 哈希后的密码写入 Redis,返回 200 成功
|
||||
|
||||
#### Scenario: 超级管理员修改已有操作密码
|
||||
|
||||
- **WHEN** 操作密码已存在,超级管理员重新调用设置接口传入新密码
|
||||
- **THEN** 系统覆盖 Redis 中的旧哈希值,返回 200 成功
|
||||
|
||||
#### Scenario: 非超级管理员调用被拒绝
|
||||
|
||||
- **WHEN** `user_type != 1` 的账号调用 `POST /api/admin/super-admin/operation-password`
|
||||
- **THEN** 系统返回 403,错误消息"仅超级管理员可设置操作密码"
|
||||
|
||||
#### Scenario: 两次密码不一致
|
||||
|
||||
- **WHEN** 超级管理员传入的 `password` 与 `confirm_password` 不一致
|
||||
- **THEN** 系统返回 400,错误消息"两次输入的密码不一致"
|
||||
|
||||
### Requirement: 可查询操作密码设置状态
|
||||
|
||||
系统 SHALL 提供 `GET /api/admin/super-admin/operation-password/status` 接口,返回操作密码是否已设置(布尔值),不返回密码本身,仅超级管理员可调用。
|
||||
|
||||
#### Scenario: 查询已设置状态
|
||||
|
||||
- **WHEN** 操作密码已设置,超级管理员调用状态查询接口
|
||||
- **THEN** 返回 `{"is_set": true}`
|
||||
|
||||
#### Scenario: 查询未设置状态
|
||||
|
||||
- **WHEN** 操作密码未设置(Redis 中无对应 key),超级管理员调用状态查询接口
|
||||
- **THEN** 返回 `{"is_set": false}`
|
||||
|
||||
### Requirement: 操作密码验证使用全局操作密码
|
||||
|
||||
系统 SHALL 将所有需要操作密码验证的接口(目前为 `agent-recharges` 线下充值确认)改为验证全局操作密码,而非当前登录用户的登录密码。
|
||||
|
||||
#### Scenario: 操作密码正确时通过验证
|
||||
|
||||
- **WHEN** 调用需要操作密码的接口,传入正确的全局操作密码
|
||||
- **THEN** 验证通过,接口正常执行后续业务逻辑
|
||||
|
||||
#### Scenario: 操作密码错误时拒绝
|
||||
|
||||
- **WHEN** 调用需要操作密码的接口,传入错误的密码
|
||||
- **THEN** 系统返回 400,错误消息"操作密码错误"
|
||||
|
||||
#### Scenario: 操作密码未设置时拒绝
|
||||
|
||||
- **WHEN** Redis 中无操作密码,调用需要操作密码的接口
|
||||
- **THEN** 系统返回 400,错误消息"操作密码未设置,请联系超级管理员"
|
||||
@@ -1,51 +0,0 @@
|
||||
# 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 旧接口残留导致的编译错误
|
||||
|
||||
59
openspec/specs/identity-access/spec.md
Normal file
59
openspec/specs/identity-access/spec.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# identity-access 当前行为
|
||||
|
||||
## Purpose
|
||||
|
||||
描述后台身份令牌与数据范围拒绝的当前行为。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 令牌生命周期
|
||||
|
||||
系统 SHALL 为登录成功的后台账号签发访问令牌,并支持刷新、登出和当前账号查询。
|
||||
|
||||
#### Scenario: 令牌生命周期
|
||||
|
||||
- **GIVEN** 账号凭证有效
|
||||
- **WHEN** 调用登录接口
|
||||
- **THEN** 返回访问凭证;后续认证接口按该凭证识别账号
|
||||
|
||||
### Requirement: 数据范围拒绝
|
||||
|
||||
系统 SHALL 对无权管理的店铺、企业或资源返回统一拒绝结果,不区分资源不存在与越权。
|
||||
|
||||
#### Scenario: 数据范围拒绝
|
||||
|
||||
- **GIVEN** 操作者不在目标资源的数据范围内
|
||||
- **WHEN** 请求读取或修改目标资源
|
||||
- **THEN** 请求被拒绝且响应不泄露目标是否存在
|
||||
|
||||
## 可达操作索引
|
||||
|
||||
本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。
|
||||
|
||||
### 统一认证
|
||||
|
||||
`POST /api/auth/login`(统一登录(后台+H5));`POST /api/auth/logout`(统一登出);`GET /api/auth/me`(获取用户信息);`PUT /api/auth/password`(修改密码);`POST /api/auth/refresh-token`(刷新 Token)。
|
||||
|
||||
### 账号管理
|
||||
|
||||
`GET /api/admin/accounts`(查询账号列表);`POST /api/admin/accounts`(创建账号);`DELETE /api/admin/accounts/{account_id}/roles/{role_id}`(移除账号角色);`DELETE /api/admin/accounts/{id}`(删除账号);`GET /api/admin/accounts/{id}`(获取账号详情);`PUT /api/admin/accounts/{id}`(更新账号);`PUT /api/admin/accounts/{id}/password`(修改账号密码);`GET /api/admin/accounts/{id}/roles`(获取账号角色);`POST /api/admin/accounts/{id}/roles`(为账号分配角色);`PUT /api/admin/accounts/{id}/status`(修改账号状态);`PUT /api/admin/accounts/{id}/wecom-binding`(绑定账号企业微信成员)。
|
||||
|
||||
### 角色
|
||||
|
||||
`GET /api/admin/roles`(角色列表);`POST /api/admin/roles`(创建角色);`DELETE /api/admin/roles/{id}`(删除角色);`GET /api/admin/roles/{id}`(获取角色详情);`PUT /api/admin/roles/{id}`(更新角色);`PUT /api/admin/roles/{id}/default-credit`(更新客户角色的新建代理默认信用模板);`GET /api/admin/roles/{id}/permissions`(获取角色权限);`POST /api/admin/roles/{id}/permissions`(分配权限);`PUT /api/admin/roles/{id}/status`(更新角色状态);`DELETE /api/admin/roles/{role_id}/permissions`(批量移除权限);`DELETE /api/admin/roles/{role_id}/permissions/{perm_id}`(移除权限)。
|
||||
|
||||
### 权限
|
||||
|
||||
`GET /api/admin/permissions`(权限列表);`POST /api/admin/permissions`(创建权限);`DELETE /api/admin/permissions/{id}`(删除权限);`GET /api/admin/permissions/{id}`(获取权限详情);`PUT /api/admin/permissions/{id}`(更新权限);`GET /api/admin/permissions/tree`(获取权限树)。
|
||||
|
||||
### 店铺管理
|
||||
|
||||
`GET /api/admin/shops`(店铺列表);`POST /api/admin/shops`(创建店铺);`DELETE /api/admin/shops/{id}`(删除店铺);`GET /api/admin/shops/{id}`(查询店铺详情);`PUT /api/admin/shops/{id}`(更新店铺);`GET /api/admin/shops/{shop_id}/roles`(查询店铺默认角色);`POST /api/admin/shops/{shop_id}/roles`(分配店铺默认角色);`DELETE /api/admin/shops/{shop_id}/roles/{role_id}`(删除店铺默认角色);`GET /api/admin/shops/business-owner-candidates`(查询店铺业务员候选);`GET /api/admin/shops/cascade`(店铺联级查询)。
|
||||
|
||||
### 企业客户管理
|
||||
|
||||
`GET /api/admin/enterprises`(查询企业客户列表);`POST /api/admin/enterprises`(新增企业客户);`PUT /api/admin/enterprises/{id}`(编辑企业信息);`PUT /api/admin/enterprises/{id}/password`(修改企业账号密码);`PUT /api/admin/enterprises/{id}/status`(启用/禁用企业)。
|
||||
|
||||
### 授权记录管理
|
||||
|
||||
`GET /api/admin/authorizations`(授权记录列表);`GET /api/admin/authorizations/{id}`(授权记录详情);`PUT /api/admin/authorizations/{id}/remark`(修改授权备注)。
|
||||
@@ -1,345 +0,0 @@
|
||||
# IoT Agent Commission Management
|
||||
|
||||
## Purpose
|
||||
|
||||
Manage commission rules and records for IoT agents, supporting three commission types (one-time, long-term, combined), ladder commissions, commission freeze/unfreeze logic, approval workflows, and multi-level agent commission distribution.
|
||||
|
||||
This capability supports:
|
||||
- Agent hierarchy (tree structure) management
|
||||
- Three commission types: one-time, long-term, combined
|
||||
- Commission rule configuration (series-based for one-time, package-based for long-term)
|
||||
- Combined commission with OR-condition unfreezing (time point OR package cycle)
|
||||
- Ladder commission based on activation/pickup/deposit thresholds
|
||||
- Commission record lifecycle (frozen → unfreezing → released → invalid)
|
||||
- Commission unfreeze conditions (activation + real-name + recharge for normal cards; no real-name required for industry cards)
|
||||
- Commission approval workflow (auto or manual)
|
||||
- Multi-level agent commission distribution
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 代理树形关系
|
||||
|
||||
系统 SHALL 管理代理的树形层级关系,每个代理只有一个上级代理。
|
||||
|
||||
**agent_hierarchies 表**:
|
||||
- `id`: 代理关系 ID(主键,BIGINT)
|
||||
- `agent_id`: 代理用户 ID(BIGINT,唯一)
|
||||
- `parent_agent_id`: 上级代理用户 ID(BIGINT,可空,NULL 表示顶级代理)
|
||||
- `level`: 代理层级(INT,1-顶级代理 2-二级代理 ...)
|
||||
- `path`: 代理路径(VARCHAR(500),如 "1/5/12",用于快速获取整个代理链)
|
||||
- `created_at`: 创建时间(TIMESTAMP,自动填充)
|
||||
- `updated_at`: 更新时间(TIMESTAMP,自动填充)
|
||||
|
||||
#### Scenario: 创建顶级代理
|
||||
|
||||
- **WHEN** 平台创建顶级代理(用户 ID 为 101)
|
||||
- **THEN** 系统创建代理关系记录,`agent_id` 为 101,`parent_agent_id` 为 NULL,`level` 为 1,`path` 为 "101"
|
||||
|
||||
#### Scenario: 创建下级代理
|
||||
|
||||
- **WHEN** 顶级代理(ID 为 101)创建下级代理(用户 ID 为 102)
|
||||
- **THEN** 系统创建代理关系记录,`agent_id` 为 102,`parent_agent_id` 为 101,`level` 为 2,`path` 为 "101/102"
|
||||
|
||||
#### Scenario: 查询代理的整个上级链
|
||||
|
||||
- **WHEN** 查询代理(ID 为 103,路径为 "101/102/103")的上级链
|
||||
- **THEN** 系统解析 `path` 字段,返回代理 101(顶级)、102(父级)、103(当前代理)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 分佣规则配置
|
||||
|
||||
系统 SHALL 支持为代理配置分佣规则,包括一次性分佣、长期分佣和组合分佣。
|
||||
|
||||
**commission_rules 表**:
|
||||
- `id`: 分佣规则 ID(主键,BIGINT)
|
||||
- `agent_id`: 代理用户 ID(BIGINT)
|
||||
- `business_type`: 业务类型(VARCHAR(20),"iot_card"-IoT卡 | "number_card"-号卡)
|
||||
- `commission_type`: 分佣类型(VARCHAR(20),"one_time"-一次性 | "long_term"-长期 | "combined"-组合)
|
||||
- `series_id`: 套餐系列 ID(BIGINT,可空,**仅一次性分佣使用**,关联 package_series 表)
|
||||
- `package_id`: 套餐 ID(BIGINT,可空,**仅长期分佣使用**,关联 packages 表)
|
||||
- `commission_mode`: 分佣模式(VARCHAR(20),"fixed"-固定金额 | "percent"-百分比)
|
||||
- `commission_value`: 分佣值(DECIMAL(10,4),固定金额或百分比值)
|
||||
- `freeze_days`: 冻结天数(INT,分佣冻结天数,默认 7)
|
||||
- `is_ladder`: 是否阶梯分佣(BOOLEAN,默认 false)
|
||||
- `status`: 规则状态(INT,1-有效 2-无效)
|
||||
- `created_at`: 创建时间(TIMESTAMP,自动填充)
|
||||
- `updated_at`: 更新时间(TIMESTAMP,自动填充)
|
||||
|
||||
**字段使用规则**:
|
||||
- **一次性分佣**: 使用 `series_id` 关联套餐系列,`package_id` 为 NULL
|
||||
- **长期分佣**: 使用 `package_id` 关联具体套餐,`series_id` 为 NULL
|
||||
- **组合分佣**: 需要创建两条规则记录,一条一次性(使用 `series_id`),一条长期(使用 `package_id`)
|
||||
- **`series_id` 和 `package_id` 互斥**: 不能同时有值
|
||||
|
||||
#### Scenario: 配置一次性分佣规则
|
||||
|
||||
- **WHEN** 平台为代理(ID 为 123)配置一次性分佣规则,套餐系列 ID 为 1(月套餐系列),固定金额 5.00 元
|
||||
- **THEN** 系统创建分佣规则,`agent_id` 为 123,`commission_type` 为 "one_time",`series_id` 为 1,`package_id` 为 NULL,`commission_mode` 为 "fixed",`commission_value` 为 5.00
|
||||
|
||||
#### Scenario: 配置长期分佣规则
|
||||
|
||||
- **WHEN** 平台为代理(ID 为 123)配置长期分佣规则,套餐 ID 为 3001,百分比 5%
|
||||
- **THEN** 系统创建分佣规则,`agent_id` 为 123,`commission_type` 为 "long_term",`series_id` 为 NULL,`package_id` 为 3001,`commission_mode` 为 "percent",`commission_value` 为 0.05
|
||||
|
||||
#### Scenario: 配置组合分佣规则
|
||||
|
||||
- **WHEN** 平台为代理(ID 为 123)配置组合分佣规则,套餐系列 ID 为 1,先一次性分佣 10.00 元,连续在网 3 个月后开始长期分佣(套餐 ID 为 3001)3.00 元/月
|
||||
- **THEN** 系统创建两条分佣规则:
|
||||
- 一条 `commission_type` 为 "one_time",`series_id` 为 1,`package_id` 为 NULL
|
||||
- 另一条 `commission_type` 为 "long_term",`series_id` 为 NULL,`package_id` 为 3001,且关联组合条件
|
||||
|
||||
#### Scenario: 字段互斥校验
|
||||
|
||||
- **WHEN** 平台尝试创建分佣规则,同时设置 `series_id` 为 1 和 `package_id` 为 3001
|
||||
- **THEN** 系统拒绝创建,返回错误信息"`series_id` 和 `package_id` 不能同时有值"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 组合分佣条件配置
|
||||
|
||||
系统 SHALL 支持为组合分佣配置解冻条件,包括时间点条件和套餐周期条件。
|
||||
|
||||
**commission_combined_conditions 表**:
|
||||
- `id`: 组合条件 ID(主键,BIGINT)
|
||||
- `commission_rule_id`: 关联的分佣规则 ID(BIGINT,必须是 commission_type 为 "long_term" 且属于组合分佣的规则)
|
||||
- `condition_type`: 条件类型(VARCHAR(20),"time_point"-时间点 | "package_cycle"-套餐周期)
|
||||
- `time_months`: 时间月数(INT,可空,仅当 condition_type 为 "time_point" 时有值,表示实名后多少个月)
|
||||
- `package_cycle_threshold`: 套餐周期阈值(INT,可空,仅当 condition_type 为 "package_cycle" 时有值,表示使用多少个套餐周期)
|
||||
- `created_at`: 创建时间(TIMESTAMP,自动填充)
|
||||
- `updated_at`: 更新时间(TIMESTAMP,自动填充)
|
||||
|
||||
**解冻逻辑**: 组合分佣的长期部分,当满足**任一条件**(OR 关系)时开始产生长期分佣。
|
||||
|
||||
#### Scenario: 配置时间点条件
|
||||
|
||||
- **WHEN** 平台为组合分佣规则(ID 为 501)配置时间点条件,实名后 3 个月开始长期分佣
|
||||
- **THEN** 系统创建组合条件记录,`commission_rule_id` 为 501,`condition_type` 为 "time_point",`time_months` 为 3
|
||||
|
||||
#### Scenario: 配置套餐周期条件
|
||||
|
||||
- **WHEN** 平台为组合分佣规则(ID 为 501)配置套餐周期条件,使用 10 个套餐周期后开始长期分佣
|
||||
- **THEN** 系统创建组合条件记录,`commission_rule_id` 为 501,`condition_type` 为 "package_cycle",`package_cycle_threshold` 为 10
|
||||
|
||||
#### Scenario: 同时配置两种条件(OR 关系)
|
||||
|
||||
- **WHEN** 平台为组合分佣规则(ID 为 501)同时配置时间点条件(6 个月)和套餐周期条件(10 个周期)
|
||||
- **THEN** 系统创建两条组合条件记录,长期分佣在任一条件满足时开始
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 阶梯分佣配置
|
||||
|
||||
系统 SHALL 支持阶梯分佣,根据激活量/提货量达到阶梯条件后变更分佣值。
|
||||
|
||||
**commission_ladder 表**:
|
||||
- `id`: 阶梯配置 ID(主键,BIGINT)
|
||||
- `commission_rule_id`: 关联的分佣规则 ID(BIGINT)
|
||||
- `ladder_type`: 阶梯类型(VARCHAR(20),"activation"-激活量 | "pickup"-提货量 | "deposit"-保证金)
|
||||
- `ladder_threshold`: 阶梯阈值(INT,如激活 100 张)
|
||||
- `commission_mode`: 分佣模式(VARCHAR(20),"fixed"-固定金额 | "percent"-百分比)
|
||||
- `commission_value`: 分佣值(DECIMAL(10,4),达到阶梯后的分佣值)
|
||||
- `created_at`: 创建时间(TIMESTAMP,自动填充)
|
||||
- `updated_at`: 更新时间(TIMESTAMP,自动填充)
|
||||
|
||||
#### Scenario: 配置激活量阶梯
|
||||
|
||||
- **WHEN** 平台为代理(ID 为 123)配置阶梯分佣,激活 100 张卡后分佣从 5.00 元提升到 8.00 元
|
||||
- **THEN** 系统创建阶梯配置,`ladder_type` 为 "activation",`ladder_threshold` 为 100,`commission_value` 为 8.00
|
||||
|
||||
#### Scenario: 计算阶梯分佣
|
||||
|
||||
- **WHEN** 代理(ID 为 123)当月激活量达到 100 张
|
||||
- **THEN** 系统根据阶梯配置,从第 101 张卡开始使用新的分佣值 8.00 元
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 分佣记录管理
|
||||
|
||||
系统 SHALL 记录每笔分佣,支持冻结、解冻和发放流程。
|
||||
|
||||
**commission_records 表**:
|
||||
- `id`: 分佣记录 ID(主键,BIGINT)
|
||||
- `agent_id`: 代理用户 ID(BIGINT)
|
||||
- `order_id`: 订单 ID(BIGINT)
|
||||
- `commission_rule_id`: 分佣规则 ID(BIGINT)
|
||||
- `commission_type`: 分佣类型(VARCHAR(20),"one_time" | "long_term" | "combined")
|
||||
- `amount`: 分佣金额(DECIMAL(10,2),元)
|
||||
- `status`: 分佣状态(INT,1-冻结 2-解冻中 3-已发放 4-已失效)
|
||||
- `freeze_until`: 冻结截止时间(TIMESTAMP,可空)
|
||||
- `released_at`: 发放时间(TIMESTAMP,可空)
|
||||
- `created_at`: 创建时间(TIMESTAMP,自动填充)
|
||||
- `updated_at`: 更新时间(TIMESTAMP,自动填充)
|
||||
|
||||
#### Scenario: 创建一次性分佣记录
|
||||
|
||||
- **WHEN** 订单(ID 为 10001)完成,触发代理(ID 为 123)的一次性分佣 5.00 元,冻结 7 天
|
||||
- **THEN** 系统创建分佣记录,`agent_id` 为 123,`order_id` 为 10001,`amount` 为 5.00,状态为 1(冻结),`freeze_until` 为 7 天后
|
||||
|
||||
#### Scenario: 分佣自动解冻
|
||||
|
||||
- **WHEN** 分佣记录(ID 为 1001)的冻结截止时间到达,且满足解冻条件(激活+实名+充值)
|
||||
- **THEN** 系统将分佣状态从 1(冻结) 变更为 2(解冻中),创建分佣解冻审批记录
|
||||
|
||||
#### Scenario: 分佣发放
|
||||
|
||||
- **WHEN** 分佣解冻审批通过
|
||||
- **THEN** 系统将分佣状态从 2(解冻中) 变更为 3(已发放),将分佣金额转入代理钱包,`released_at` 记录发放时间
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 分佣解冻条件
|
||||
|
||||
系统 SHALL 根据分佣类型校验不同的解冻条件。
|
||||
|
||||
**一次性分佣解冻条件**:
|
||||
- 激活(实名状态为已实名;对于行业卡,实名状态可以为未实名)
|
||||
- 达到累计/首次充值金额
|
||||
- 冻结天数到达
|
||||
|
||||
**长期分佣解冻条件**:
|
||||
- 激活(实名状态为已实名;对于行业卡,实名状态可以为未实名)
|
||||
- 达到累计/首次充值金额
|
||||
- 在网状态正常
|
||||
- 三无校验通过(通过 Excel 导入解冻)
|
||||
|
||||
**组合分佣解冻条件**:
|
||||
- **一次性部分**: 立即产生并按一次性分佣条件解冻
|
||||
- **长期部分**: 当满足以下**任一条件**时开始长期分佣(OR 关系):
|
||||
- 达到某个时间点之后(例如:实名后 3 个月)
|
||||
- **OR** 该 IoT 卡的套餐使用周期数达到阈值(例如:10 个周期)
|
||||
- **注意**: 套餐周期阈值是针对单张 IoT 卡的,不是设备级别
|
||||
|
||||
#### Scenario: 一次性分佣满足解冻条件
|
||||
|
||||
- **WHEN** 分佣记录(ID 为 1001)的冻结截止时间到达,用户已实名且已充值
|
||||
- **THEN** 系统将分佣状态变更为 2(解冻中),创建审批记录
|
||||
|
||||
#### Scenario: 长期分佣等待 Excel 导入解冻
|
||||
|
||||
- **WHEN** 长期分佣记录等待三无校验
|
||||
- **THEN** 系统保持分佣状态为 1(冻结),等待平台通过 Excel 导入解冻数据
|
||||
|
||||
#### Scenario: 组合分佣时间点条件满足
|
||||
|
||||
- **WHEN** 组合分佣规则配置为实名后 3 个月开始长期分佣,IoT 卡已实名 3 个月
|
||||
- **THEN** 系统开始为该 IoT 卡创建长期分佣记录,即使套餐周期数未达到阈值
|
||||
|
||||
#### Scenario: 组合分佣套餐周期条件满足
|
||||
|
||||
- **WHEN** 组合分佣规则配置为套餐使用 10 个周期后开始长期分佣,IoT 卡已使用套餐 10 个周期
|
||||
- **THEN** 系统开始为该 IoT 卡创建长期分佣记录,即使未达到时间点要求
|
||||
|
||||
#### Scenario: 组合分佣任一条件满足即开始
|
||||
|
||||
- **WHEN** 组合分佣规则配置为"实名后 6 个月 OR 10 个套餐周期",IoT 卡已使用 10 个周期但只实名 2 个月
|
||||
- **THEN** 系统开始为该 IoT 卡创建长期分佣记录(因为套餐周期条件已满足)
|
||||
|
||||
#### Scenario: 行业卡一次性分佣解冻(无需实名)
|
||||
|
||||
- **WHEN** 行业卡(card_category 为 "industry")的一次性分佣记录冻结期到达,卡已激活且已充值,但实名状态为未实名
|
||||
- **THEN** 系统判定解冻条件满足(行业卡无需实名认证),将分佣状态变更为 2(解冻中),创建审批记录
|
||||
|
||||
#### Scenario: 行业卡长期分佣解冻(无需实名)
|
||||
|
||||
- **WHEN** 行业卡(card_category 为 "industry")的长期分佣记录满足充值金额和在网状态,但实名状态为未实名
|
||||
- **THEN** 系统判定行业卡无需实名认证,等待三无校验通过后可解冻
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 分佣解冻审批
|
||||
|
||||
系统 SHALL 支持分佣解冻审批流程,审批通过后发放分佣。
|
||||
|
||||
**commission_approvals 表**:
|
||||
- `id`: 审批记录 ID(主键,BIGINT)
|
||||
- `commission_record_id`: 分佣记录 ID(BIGINT)
|
||||
- `approval_type`: 审批类型(VARCHAR(20),"auto"-自动 | "manual"-人工)
|
||||
- `status`: 审批状态(INT,1-待审批 2-已通过 3-已拒绝)
|
||||
- `approver_id`: 审批人用户 ID(BIGINT,可空)
|
||||
- `approval_time`: 审批时间(TIMESTAMP,可空)
|
||||
- `approval_note`: 审批备注(TEXT,可空)
|
||||
- `created_at`: 创建时间(TIMESTAMP,自动填充)
|
||||
- `updated_at`: 更新时间(TIMESTAMP,自动填充)
|
||||
|
||||
#### Scenario: 创建审批记录
|
||||
|
||||
- **WHEN** 分佣记录(ID 为 1001)状态变更为 2(解冻中)
|
||||
- **THEN** 系统创建审批记录,`commission_record_id` 为 1001,`approval_type` 为 "auto",状态为 1(待审批)
|
||||
|
||||
#### Scenario: 审批通过
|
||||
|
||||
- **WHEN** 审批人(用户 ID 为 999)审批通过审批记录(ID 为 2001)
|
||||
- **THEN** 系统将审批状态变更为 2(已通过),分佣记录状态变更为 3(已发放),将分佣金额转入代理钱包
|
||||
|
||||
#### Scenario: 审批拒绝
|
||||
|
||||
- **WHEN** 审批人拒绝审批记录(ID 为 2001),备注"用户未满足在网条件"
|
||||
- **THEN** 系统将审批状态变更为 3(已拒绝),分佣记录状态变更为 4(已失效)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 分佣模板
|
||||
|
||||
系统 SHALL 支持创建分佣模板,存储常用的分佣方案,便于快速配置。
|
||||
|
||||
**commission_templates 表**:
|
||||
- `id`: 模板 ID(主键,BIGINT)
|
||||
- `template_name`: 模板名称(VARCHAR(255))
|
||||
- `business_type`: 业务类型(VARCHAR(20),"iot_card"-IoT卡 | "number_card"-号卡)
|
||||
- `commission_type`: 分佣类型(VARCHAR(20),"one_time" | "long_term" | "combined")
|
||||
- `commission_mode`: 分佣模式(VARCHAR(20),"fixed" | "percent")
|
||||
- `commission_value`: 分佣值(DECIMAL(10,4))
|
||||
- `freeze_days`: 冻结天数(INT)
|
||||
- `is_ladder`: 是否阶梯分佣(BOOLEAN)
|
||||
- `created_at`: 创建时间(TIMESTAMP,自动填充)
|
||||
- `updated_at`: 更新时间(TIMESTAMP,自动填充)
|
||||
|
||||
#### Scenario: 创建分佣模板
|
||||
|
||||
- **WHEN** 平台创建分佣模板"标准月套餐分佣",业务类型为 IoT 卡,一次性分佣 5.00 元,冻结 7 天
|
||||
- **THEN** 系统创建模板记录,`template_name` 为 "标准月套餐分佣",`business_type` 为 "iot_card",`commission_type` 为 "one_time",`commission_value` 为 5.00,`freeze_days` 为 7
|
||||
|
||||
#### Scenario: 应用分佣模板
|
||||
|
||||
- **WHEN** 平台为代理(ID 为 123)应用模板(ID 为 501)
|
||||
- **THEN** 系统根据模板配置创建分佣规则,`agent_id` 为 123,其他字段从模板复制
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 多级代理分佣
|
||||
|
||||
系统 SHALL 支持多级代理分佣,根据代理路径计算每一级代理的分佣。
|
||||
|
||||
**多级分佣规则**:
|
||||
- 通过代理路径(`path`)获取整个代理链
|
||||
- 为每一级代理查找对应的分佣规则
|
||||
- 创建多条分佣记录,每条对应一个代理
|
||||
|
||||
#### Scenario: 三级代理分佣
|
||||
|
||||
- **WHEN** 订单(ID 为 10001)的代理路径为 "101/102/103",每级代理配置分佣:101(2.00 元)、102(3.00 元)、103(5.00 元)
|
||||
- **THEN** 系统创建 3 条分佣记录:代理 101 的 2.00 元、代理 102 的 3.00 元、代理 103 的 5.00 元
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 分佣数据校验
|
||||
|
||||
系统 SHALL 对分佣数据进行校验,确保数据完整性和一致性。
|
||||
|
||||
**校验规则**:
|
||||
- 代理 ID(agent_id):必填,≥ 1
|
||||
- 订单 ID(order_id):必填,≥ 1
|
||||
- 分佣金额(amount):必填,≥ 0,最多 2 位小数
|
||||
- 分佣状态(status):必填,枚举值 1-4
|
||||
- 冻结天数(freeze_days):必填,≥ 0
|
||||
|
||||
#### Scenario: 创建分佣记录时金额为负数
|
||||
|
||||
- **WHEN** 创建分佣记录,金额为 -5.00
|
||||
- **THEN** 系统拒绝创建,返回错误信息"分佣金额必须 ≥ 0"
|
||||
|
||||
#### Scenario: 创建分佣规则时分佣值无效
|
||||
|
||||
- **WHEN** 创建分佣规则,分佣模式为百分比,分佣值为 1.5(超过 100%)
|
||||
- **THEN** 系统拒绝创建,返回错误信息"百分比分佣值必须在 0-1 之间"
|
||||
@@ -1,59 +0,0 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: IoT 卡列表展示绑定设备的虚拟号
|
||||
`tb_iot_card` 表 SHALL 新增 `device_virtual_no VARCHAR(100) NOT NULL DEFAULT ''` 列,存储当前绑定设备的虚拟号快照。未绑定设备时该字段为空字符串。列表和详情接口 SHALL 在响应中返回 `device_virtual_no` 字段。
|
||||
|
||||
#### Scenario: 未绑定设备的卡响应 device_virtual_no 为空
|
||||
- **WHEN** 请求 `GET /api/admin/iot-cards/standalone` 列表
|
||||
- **THEN** `is_standalone = true` 的卡响应中 `device_virtual_no` 为空字符串 `""`
|
||||
|
||||
#### Scenario: 已绑定设备的卡响应包含设备虚拟号
|
||||
- **WHEN** 请求 `GET /api/admin/iot-cards/standalone` 列表(不带 `is_standalone` 参数)
|
||||
- **THEN** `is_standalone = false` 的卡也出现在结果中,且 `device_virtual_no` 等于绑定设备的 `virtual_no`
|
||||
|
||||
#### Scenario: 详情接口包含设备虚拟号
|
||||
- **WHEN** 请求 `GET /api/admin/iot-cards/standalone/:iccid` 且该卡绑定了设备
|
||||
- **THEN** 响应中 `device_virtual_no` 等于绑定设备的 `virtual_no`
|
||||
|
||||
### Requirement: `is_standalone` 作为可选查询参数
|
||||
列表接口 `GET /api/admin/iot-cards/standalone` SHALL 接受可选布尔参数 `is_standalone`。不传时 SHALL 返回全部卡(含绑定设备的卡);传 `true` 时仅返回未绑定设备的卡;传 `false` 时仅返回已绑定设备的卡。
|
||||
|
||||
#### Scenario: 不传 is_standalone 返回全部卡
|
||||
- **WHEN** 请求列表且不携带 `is_standalone` 参数
|
||||
- **THEN** 返回结果包含独立卡和绑定设备的卡
|
||||
|
||||
#### Scenario: 传 is_standalone=true 仅返回独立卡
|
||||
- **WHEN** 请求列表携带 `is_standalone=true`
|
||||
- **THEN** 返回结果只包含 `is_standalone = true` 的卡
|
||||
|
||||
#### Scenario: 传 is_standalone=false 仅返回绑定卡
|
||||
- **WHEN** 请求列表携带 `is_standalone=false`
|
||||
- **THEN** 返回结果只包含 `is_standalone = false` 的卡
|
||||
|
||||
### Requirement: 移除列表接口的 virtual_no 查询参数
|
||||
`GET /api/admin/iot-cards/standalone` 请求 SHALL NOT 再接受 `virtual_no` 查询参数。
|
||||
|
||||
#### Scenario: 传入 virtual_no 参数不产生过滤效果
|
||||
- **WHEN** 请求列表携带 `virtual_no=xxx`
|
||||
- **THEN** 参数被忽略,接口正常返回全部卡(不报错,不过滤)
|
||||
|
||||
### Requirement: 绑定设备时快照写入 device_virtual_no
|
||||
执行 `BindCard`(POST `/api/admin/devices/:virtual_no/cards`)成功后,系统 SHALL 将被绑定卡的 `device_virtual_no` 更新为设备的 `virtual_no`。
|
||||
|
||||
#### Scenario: 绑卡后卡的 device_virtual_no 被写入
|
||||
- **WHEN** 成功调用 BindCard 将 IoT 卡绑定到设备
|
||||
- **THEN** `tb_iot_card.device_virtual_no` = 对应设备的 `virtual_no`
|
||||
|
||||
### Requirement: 解绑设备时清空 device_virtual_no
|
||||
执行 `UnbindCard`(DELETE `/api/admin/devices/:virtual_no/cards/:iccid`)成功后,系统 SHALL 将被解绑卡的 `device_virtual_no` 清空为空字符串。
|
||||
|
||||
#### Scenario: 解绑后卡的 device_virtual_no 被清空
|
||||
- **WHEN** 成功调用 UnbindCard 将 IoT 卡从设备解绑
|
||||
- **THEN** `tb_iot_card.device_virtual_no` = `""`
|
||||
|
||||
### Requirement: 设备导入时批量快照 device_virtual_no
|
||||
设备导入任务处理成功绑定关系后,系统 SHALL 批量将被绑定卡的 `device_virtual_no` 更新为该设备的 `virtual_no`。
|
||||
|
||||
#### Scenario: 设备导入时绑定的卡 device_virtual_no 被写入
|
||||
- **WHEN** 设备导入 Excel 中某行包含设备虚拟号和一组 ICCID,导入任务处理成功
|
||||
- **THEN** 该组 ICCID 对应卡的 `device_virtual_no` = 设备的 `virtual_no`
|
||||
@@ -1,370 +0,0 @@
|
||||
# iot-card-import-task Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change iot-card-standalone-management. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: 导入任务实体定义
|
||||
|
||||
系统 SHALL 定义 IoT 卡导入任务(IotCardImportTask)实体,用于跟踪 IoT 卡批量导入的进度和结果。
|
||||
|
||||
**实体字段**:
|
||||
|
||||
**任务信息**:
|
||||
- `id`: 任务 ID(主键,BIGINT)
|
||||
- `task_no`: 任务编号(VARCHAR(50),唯一,格式: IMP-YYYYMMDD-XXXXXX)
|
||||
- `status`: 任务状态(INT,1-待处理 2-处理中 3-已完成 4-失败)
|
||||
|
||||
**导入参数**:
|
||||
- `carrier_id`: 运营商 ID(BIGINT,必填)
|
||||
- `carrier_type`: 运营商类型(VARCHAR(10),CMCC/CUCC/CTCC/CBN)
|
||||
- `batch_no`: 批次号(VARCHAR(100),可选)
|
||||
- `file_name`: 原始文件名(VARCHAR(255),可选)
|
||||
|
||||
**待导入数据**:
|
||||
- `card_list`: 待导入卡列表(JSONB,结构: [{iccid, msisdn}],替代原 iccid_list)
|
||||
|
||||
**进度统计**:
|
||||
- `total_count`: 总数(INT,CSV 文件总行数)
|
||||
- `success_count`: 成功数(INT,成功导入的卡数量)
|
||||
- `skip_count`: 跳过数(INT,因重复等原因跳过的数量)
|
||||
- `fail_count`: 失败数(INT,因格式错误等原因失败的数量)
|
||||
|
||||
**结果详情**:
|
||||
- `skipped_items`: 跳过记录详情(JSONB,结构: [{line, iccid, msisdn, reason}])
|
||||
- `failed_items`: 失败记录详情(JSONB,结构: [{line, iccid, msisdn, reason}])
|
||||
|
||||
**时间和错误**:
|
||||
- `started_at`: 开始处理时间(TIMESTAMP,可空)
|
||||
- `completed_at`: 完成时间(TIMESTAMP,可空)
|
||||
- `error_message`: 任务级错误信息(TEXT,可空,如文件解析失败等)
|
||||
|
||||
**系统字段**:
|
||||
- `shop_id`: 店铺 ID(BIGINT,可空,记录发起导入的店铺)
|
||||
- `created_at`: 创建时间(TIMESTAMP,自动填充)
|
||||
- `updated_at`: 更新时间(TIMESTAMP,自动填充)
|
||||
- `creator`: 创建人 ID(BIGINT)
|
||||
- `updater`: 更新人 ID(BIGINT)
|
||||
|
||||
#### Scenario: 创建导入任务
|
||||
|
||||
- **GIVEN** 管理员上传包含 ICCID 和 MSISDN 两列的 CSV 文件
|
||||
- **WHEN** 系统解析 CSV 并创建导入任务
|
||||
- **THEN** 系统创建导入任务记录,`card_list` 包含 [{iccid, msisdn}] 结构,`status` 为 1(待处理)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 导入任务状态流转
|
||||
|
||||
系统 SHALL 管理导入任务的状态流转,确保状态变更符合业务规则。
|
||||
|
||||
**状态定义**:
|
||||
- **1-待处理**: 任务已创建,等待 Worker 处理
|
||||
- **2-处理中**: Worker 正在处理导入
|
||||
- **3-已完成**: 导入处理完成(可能有部分失败)
|
||||
- **4-失败**: 任务级别错误,导入中断
|
||||
|
||||
**状态流转规则**:
|
||||
- 待处理(1) → 处理中(2): Worker 开始处理
|
||||
- 处理中(2) → 已完成(3): 处理完成
|
||||
- 处理中(2) → 失败(4): 发生严重错误
|
||||
- 待处理(1) → 失败(4): 文件验证失败等
|
||||
|
||||
#### Scenario: 正常状态流转
|
||||
|
||||
- **WHEN** 导入任务经历完整生命周期
|
||||
- **THEN** 状态依次变更: 待处理(1) → 处理中(2) → 已完成(3)
|
||||
|
||||
#### Scenario: 异常状态流转
|
||||
|
||||
- **WHEN** 导入任务处理过程中发生严重错误
|
||||
- **THEN** 状态变更: 待处理(1) → 处理中(2) → 失败(4)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 导入任务创建权限控制
|
||||
|
||||
系统 SHALL 仅允许超级管理员与平台用户创建 IoT 卡导入任务。
|
||||
|
||||
#### Scenario: 平台用户创建导入任务
|
||||
- **WHEN** 平台用户请求创建导入任务
|
||||
- **THEN** 系统创建导入任务并返回任务信息
|
||||
|
||||
#### Scenario: 非平台用户创建导入任务被拒绝
|
||||
- **WHEN** 非平台用户(代理账号/企业账号等)请求创建导入任务
|
||||
- **THEN** 系统返回 403(Forbidden),并返回统一错误码 `CodeForbidden`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 导入任务列表查询
|
||||
|
||||
系统 SHALL 支持查询导入任务列表,用于管理和监控导入任务。
|
||||
|
||||
**查询条件**:
|
||||
- 任务状态(status): 可选,1-待处理 2-处理中 3-已完成 4-失败
|
||||
- 运营商 ID(carrier_id): 可选
|
||||
- 批次号(batch_no): 可选,模糊匹配
|
||||
- 创建时间范围: 可选
|
||||
|
||||
**分页**:
|
||||
- 默认每页 20 条,最大每页 100 条
|
||||
- 默认按创建时间倒序排列
|
||||
|
||||
**权限**:
|
||||
- 仅超级管理员/平台用户可查询导入任务列表
|
||||
|
||||
#### Scenario: 查询所有导入任务
|
||||
|
||||
- **WHEN** 平台管理员查询导入任务列表
|
||||
- **THEN** 系统返回导入任务列表,包含任务编号、状态、运营商、总数、成功数、跳过数、失败数、创建时间
|
||||
|
||||
#### Scenario: 按状态筛选导入任务
|
||||
|
||||
- **WHEN** 平台管理员查询状态为 2(处理中) 的导入任务
|
||||
- **THEN** 系统返回所有正在处理的导入任务列表
|
||||
|
||||
#### Scenario: 非平台用户查询导入任务列表被拒绝
|
||||
|
||||
- **WHEN** 非平台用户(代理账号/企业账号等)查询导入任务列表
|
||||
- **THEN** 系统返回 403(Forbidden),并返回统一错误码 `CodeForbidden`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 导入任务详情查询
|
||||
|
||||
系统 SHALL 支持查询单个导入任务的详细信息,包括跳过/失败记录详情。
|
||||
|
||||
**详情信息**:
|
||||
- 任务基本信息: 任务编号、状态、运营商、批次号、文件名
|
||||
- 进度统计: 总数、成功数、跳过数、失败数
|
||||
- 时间信息: 创建时间、开始时间、完成时间
|
||||
- 跳过记录详情: 行号、ICCID、原因
|
||||
- 失败记录详情: 行号、ICCID、原因
|
||||
- 错误信息: 任务级错误(如有)
|
||||
|
||||
**权限**:
|
||||
- 仅超级管理员/平台用户可查询导入任务详情
|
||||
|
||||
#### Scenario: 查询导入任务详情
|
||||
|
||||
- **WHEN** 平台管理员查询导入任务(ID 为 1)的详情
|
||||
- **THEN** 系统返回任务完整信息,包括跳过和失败记录的详细列表
|
||||
|
||||
#### Scenario: 非平台用户查询导入任务详情被拒绝
|
||||
|
||||
- **WHEN** 非平台用户(代理账号/企业账号等)查询导入任务详情
|
||||
- **THEN** 系统返回 403(Forbidden),并返回统一错误码 `CodeForbidden`
|
||||
|
||||
#### Scenario: 查询导入任务的跳过记录
|
||||
|
||||
- **WHEN** 管理员查询导入任务(ID 为 1)的跳过记录
|
||||
- **THEN** 系统返回跳过记录列表,每条包含: 行号(line)、ICCID、原因(如"ICCID 已存在")
|
||||
|
||||
#### Scenario: 查询导入任务的失败记录
|
||||
|
||||
- **WHEN** 管理员查询导入任务(ID 为 1)的失败记录
|
||||
- **THEN** 系统返回失败记录列表,每条包含: 行号(line)、ICCID、原因(如"电信 ICCID 必须为 19 位")
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 导入任务数据校验
|
||||
|
||||
系统 SHALL 对导入任务数据进行校验,确保数据完整性和一致性。
|
||||
|
||||
**校验规则**:
|
||||
- 任务编号(task_no): 必填,系统自动生成,格式 IMP-YYYYMMDD-XXXXXX,唯一
|
||||
- 任务状态(status): 必填,枚举值 1(待处理) | 2(处理中) | 3(已完成) | 4(失败)
|
||||
- 运营商 ID(carrier_id): 必填,必须是有效的运营商 ID
|
||||
- 总数(total_count): 必填,≥ 0
|
||||
- 成功数(success_count): 必填,≥ 0,≤ total_count
|
||||
- 跳过数(skip_count): 必填,≥ 0,≤ total_count
|
||||
- 失败数(fail_count): 必填,≥ 0,≤ total_count
|
||||
- 数量一致性: success_count + skip_count + fail_count ≤ total_count
|
||||
|
||||
#### Scenario: 创建任务时运营商 ID 无效
|
||||
|
||||
- **WHEN** 创建导入任务时 carrier_id 不存在
|
||||
- **THEN** 系统拒绝创建,返回错误信息"运营商 ID 无效"
|
||||
|
||||
#### Scenario: 更新任务时数量不一致
|
||||
|
||||
- **WHEN** 更新导入任务时 success_count + skip_count + fail_count > total_count
|
||||
- **THEN** 系统拒绝更新,返回错误信息"统计数量不一致"
|
||||
|
||||
### Requirement: Excel 文件格式规范
|
||||
|
||||
系统 SHALL 要求 Excel 文件必须包含 ICCID、MSISDN、虚拟号三列,按固定列位置读取,表头行内容不影响解析结果。
|
||||
|
||||
**文件格式要求**:
|
||||
- **文件格式**: 仅支持 `.xlsx` (Excel 2007+)
|
||||
- **Sheet**: 读取第一个sheet,或优先读取名为"导入数据"的sheet
|
||||
- **表头行**: 第1行固定为表头,永远跳过,内容不限(中文、英文均可)
|
||||
- **列位置(固定,不可变)**:
|
||||
- 第1列(索引0): ICCID
|
||||
- 第2列(索引1): MSISDN
|
||||
- 第3列(索引2): 虚拟号(必填)
|
||||
- **列格式**: 应设置为文本格式(避免长数字被转为科学记数法)
|
||||
|
||||
**解析规则**:
|
||||
- 按列索引取值,不识别列名,第一行永远跳过
|
||||
- 自动去除单元格首尾空格
|
||||
- 若 ICCID、MSISDN、virtual_no 三列皆为空,视为空行跳过,不计入 total,不计入失败
|
||||
- ICCID 为空(但其他列非空)的行记录为失败,失败原因为"ICCID 不能为空"
|
||||
- MSISDN 为空(但其他列非空)的行记录为失败,失败原因为"MSISDN 不能为空"
|
||||
- virtual_no(第3列)为空的行记录为失败,失败原因为"虚拟号(virtual_no)不能为空"
|
||||
|
||||
**virtual_no 导入规则**:
|
||||
- virtual_no 为必填,为空则该行记录为失败
|
||||
- virtual_no 全局唯一(跨卡和设备),重复则失败,原因为"虚拟号已被占用: <值>"
|
||||
|
||||
#### Scenario: 正常导入(ICCID 和 VirtualNo 均有值)
|
||||
|
||||
- **WHEN** Excel 中某行第1列 ICCID="898600XXXXX",第3列 virtual_no="CARD-001",第2列 msisdn="13800000001"
|
||||
- **THEN** 导入成功,卡记录写入数据库,VirtualNo 和 ICCID 同步注册到 `tb_asset_identifier`
|
||||
|
||||
#### Scenario: 中文表头正常导入
|
||||
|
||||
- **GIVEN** Excel 文件第1行表头为 `ICCID | 接入号 | 虚拟号`(任意中文或英文内容)
|
||||
- **WHEN** 系统解析该 Excel 文件
|
||||
- **THEN** 系统跳过第1行,从第2行开始按列位置解析数据
|
||||
|
||||
#### Scenario: VirtualNo 为空的行被拒绝
|
||||
|
||||
- **WHEN** Excel 中某行第3列为空
|
||||
- **THEN** 该行计入失败,失败原因为"虚拟号(virtual_no)不能为空"
|
||||
- **THEN** 其他合法行继续导入,不因此行中断
|
||||
|
||||
#### Scenario: VirtualNo 重复被拒绝
|
||||
|
||||
- **WHEN** Excel 中某行第3列的值与已有卡/设备的 VirtualNo 重复(跨表)
|
||||
- **THEN** 该行计入失败,原因为"虚拟号已被占用: <值>"
|
||||
|
||||
#### Scenario: 三列皆空时跳过不计入 total
|
||||
|
||||
- **WHEN** Excel 中某行 ICCID、MSISDN、virtual_no 三列均为空
|
||||
- **THEN** 该行视为空行跳过,不计入 total,不计入失败
|
||||
|
||||
#### Scenario: 导入任务完成后的结果报告
|
||||
|
||||
- **WHEN** 导入任务处理完毕
|
||||
- **THEN** 结果包含:`success_count`、`fail_count`、`skip_count`、失败明细列表(含行号、原因)
|
||||
- **THEN** VirtualNo 为空的失败行在明细中明确体现行号和原因
|
||||
|
||||
#### Scenario: 拒绝非Excel格式文件
|
||||
|
||||
- **GIVEN** 上传文件扩展名为 .csv
|
||||
- **WHEN** 系统尝试解析该文件
|
||||
- **THEN** 系统返回错误"不支持的文件格式 .csv,请上传Excel文件(.xlsx)"
|
||||
|
||||
#### Scenario: MSISDN 为空的行记录失败
|
||||
|
||||
- **WHEN** Excel 中某行第2列为空
|
||||
- **THEN** 该行标记为失败,原因为"MSISDN 不能为空"
|
||||
|
||||
#### Scenario: 长数字无损解析
|
||||
|
||||
- **GIVEN** Excel 文件中第1列设置为文本格式,包含 20 位数字 "89860012345678901234"
|
||||
- **WHEN** 系统解析该 Excel 文件
|
||||
- **THEN** ICCID 完整保留为 "89860012345678901234",无精度损失,无科学记数法
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 导入时填充 MSISDN 字段
|
||||
|
||||
系统 SHALL 在创建 IoT 卡记录时填充 MSISDN 字段。
|
||||
|
||||
**处理规则**:
|
||||
- 从 `card_list` 中获取 ICCID 和 MSISDN
|
||||
- 创建 `IotCard` 记录时同时设置 `iccid` 和 `msisdn` 字段
|
||||
|
||||
#### Scenario: 创建卡记录时填充 MSISDN
|
||||
|
||||
- **GIVEN** 导入任务包含卡数据 [{iccid: "898600...", msisdn: "13800000001"}]
|
||||
- **WHEN** Worker 处理导入任务创建卡记录
|
||||
- **THEN** 创建的 `IotCard` 记录 `iccid` 为 "898600...",`msisdn` 为 "13800000001"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 导入物联网卡时记录运营商信息
|
||||
|
||||
系统 SHALL 在导入物联网卡时,将运营商的 carrier_type 和 carrier_name 作为冗余字段存储到 IotCard 记录中。这些字段在导入时从 Carrier 表查询并写入,后续不再依赖 Carrier 表。
|
||||
|
||||
#### Scenario: 导入时填充冗余字段
|
||||
- **WHEN** 系统处理物联网卡导入任务
|
||||
- **THEN** 系统根据 carrier_id 查询 Carrier 表,将 carrier_type 和 carrier_name 写入每条 IotCard 记录
|
||||
|
||||
#### Scenario: Carrier 不存在
|
||||
- **WHEN** 导入任务指定的 carrier_id 对应的 Carrier 不存在或已删除
|
||||
- **THEN** 系统拒绝导入,返回错误"运营商不存在"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 导入任务记录运营商名称
|
||||
|
||||
系统 SHALL 在创建导入任务时,将 carrier_name 作为冗余字段存储到 IotCardImportTask 记录中(已有 carrier_type)。
|
||||
|
||||
#### Scenario: 创建导入任务时填充 carrier_name
|
||||
- **WHEN** 管理员创建物联网卡导入任务
|
||||
- **THEN** 系统根据 carrier_id 查询 Carrier 表,将 carrier_name 写入导入任务记录
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 导入批次支持卡业务类型
|
||||
|
||||
系统 SHALL 在 IoT 卡导入请求中支持 `card_category` 参数,以批次为单位指定导入卡的业务类型,默认为普通卡。
|
||||
|
||||
**请求字段**:
|
||||
- `card_category`: 卡业务类型(枚举:`normal` / `industry`,可选,默认 `normal`)
|
||||
|
||||
**任务字段**:
|
||||
- `IotCardImportTask` 新增 `card_category` 字段(VARCHAR(20),默认 `normal`)
|
||||
|
||||
**导入行为**:
|
||||
- 导入任务中所有卡统一使用 `card_category` 的值,不支持同一批次混用
|
||||
|
||||
#### Scenario: 不传 card_category 时默认为普通卡
|
||||
|
||||
- **WHEN** 导入请求未包含 `card_category` 字段
|
||||
- **THEN** 导入的所有卡 `card_category` 为 `normal`
|
||||
|
||||
#### Scenario: 指定行业卡导入
|
||||
|
||||
- **WHEN** 导入请求中 `card_category` = `industry`
|
||||
- **THEN** 该批次导入的所有卡 `card_category` 均为 `industry`
|
||||
|
||||
#### Scenario: 传入无效 card_category
|
||||
|
||||
- **WHEN** 导入请求中 `card_category` = `unknown`(非枚举值)
|
||||
- **THEN** 系统返回参数校验错误,提示卡业务类型无效
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 导入批次支持实名策略配置
|
||||
|
||||
系统 SHALL 在 IoT 卡导入请求(`ImportIotCardRequest`)中支持 `realname_policy` 参数,以批次为单位指定导入卡的实名策略,默认为 `none`。
|
||||
|
||||
**请求字段新增**:
|
||||
- `realname_policy`:实名认证策略(枚举:`none` / `before_order` / `after_order`,可选,默认 `none`)
|
||||
|
||||
**任务字段新增**:
|
||||
- `IotCardImportTask` 新增 `realname_policy` 字段(VARCHAR(20) NOT NULL DEFAULT 'none')
|
||||
|
||||
**导入行为**:
|
||||
- 该批次导入的所有卡统一使用 `realname_policy` 的值写入 `IotCard.realname_policy`,不支持同一批次混用
|
||||
- 导入任务记录该字段用于审计
|
||||
|
||||
#### Scenario: 不传 realname_policy 时默认为 none
|
||||
- **WHEN** 导入请求未包含 `realname_policy` 字段
|
||||
- **THEN** 导入的所有卡 `realname_policy` 为 `none`,导入任务 `realname_policy` 为 `none`
|
||||
|
||||
#### Scenario: 指定 before_order 导入
|
||||
- **WHEN** 导入请求中 `realname_policy` = `before_order`
|
||||
- **THEN** 该批次导入的所有卡 `realname_policy` 均为 `before_order`
|
||||
|
||||
#### Scenario: 传入无效 realname_policy
|
||||
- **WHEN** 导入请求中 `realname_policy` = `unknown`(非枚举值)
|
||||
- **THEN** 系统返回参数校验错误,提示实名策略值无效
|
||||
|
||||
#### Scenario: 导入任务响应返回 realname_policy
|
||||
- **WHEN** 管理员查询导入任务详情或列表
|
||||
- **THEN** 响应中包含 `realname_policy` 字段,与创建时传入值一致
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: IoT 卡网关读数记录
|
||||
`tb_iot_card` SHALL 包含 `last_gateway_reading_mb` 字段(FLOAT, 默认 0),记录上次轮询时网关返回的流量读数,用于计算增量。
|
||||
|
||||
#### Scenario: 轮询更新网关读数
|
||||
- **WHEN** 轮询系统获取到网关流量值
|
||||
- **THEN** 系统 SHALL 将 `last_gateway_reading_mb` 更新为本次网关返回值(无论增量是否 > 0)
|
||||
|
||||
### Requirement: 月流量增量累加
|
||||
`current_month_usage_mb` SHALL 使用增量累加(`+= increment`)而非直接覆盖(`= gatewayValue`)。增量 = 当前网关读数 - 上次网关读数(`last_gateway_reading_mb`)。
|
||||
|
||||
#### Scenario: 正常流量增长
|
||||
- **WHEN** 上次读数 100MB,本次读数 105MB
|
||||
- **THEN** `current_month_usage_mb` SHALL 增加 5MB(而非被覆盖为 105MB)
|
||||
- **AND** `data_usage_mb`(卡生命周期总用量)SHALL 同步增加 5MB
|
||||
|
||||
#### Scenario: 上游自然月重置
|
||||
- **WHEN** 上次读数 500MB,本次读数 10MB,且在运营商重置日窗口内
|
||||
- **THEN** `increment` SHALL 为 10MB(本次原始值即为增量),`current_month_usage_mb += 10`
|
||||
|
||||
#### Scenario: 非重置日异常下降
|
||||
- **WHEN** 上次读数 500MB,本次读数 10MB,但不在重置日窗口内
|
||||
- **THEN** `increment` SHALL 为 0,记录 Warn 日志,`current_month_usage_mb` 不变
|
||||
|
||||
### Requirement: 跨自然月重置
|
||||
当检测到系统跨自然月时,`current_month_usage_mb` SHALL 重置为 0(不再等于 `gatewayFlowMB`),`last_month_total_mb` SHALL 记录上月累计值。
|
||||
|
||||
#### Scenario: 跨月轮询
|
||||
- **WHEN** 上次轮询在 3 月,本次轮询在 4 月
|
||||
- **THEN** `last_month_total_mb` = 原 `current_month_usage_mb`,`current_month_usage_mb` = 0,`current_month_start_date` 更新为本月 1 日
|
||||
|
||||
### Requirement: 新卡首次轮询
|
||||
新入库的卡 `last_gateway_reading_mb` 默认为 0,首次轮询的增量 = 网关返回的全量值。这是预期行为。批量导入已有使用量的卡时,导入脚本应同步设置 `last_gateway_reading_mb`。
|
||||
|
||||
### Requirement: 增量函数合并
|
||||
`calculateFlowUpdates()` 和 `calculateFlowIncrement()` SHALL 合并为一个函数,返回 `(updates map[string]any, increment float64)`。消除两个独立增量计算函数的不一致风险。
|
||||
@@ -1,515 +0,0 @@
|
||||
# IoT Device Management
|
||||
|
||||
## Purpose
|
||||
|
||||
Manage IoT devices and their bindings with IoT cards (SIM cards), supporting device lifecycle management, device-card binding relationships, device-level package purchases, batch allocation, and remote device operations.
|
||||
|
||||
This capability supports:
|
||||
- Device entity definition and lifecycle management
|
||||
- Device-IoT card binding relationships (1-4 cards per device)
|
||||
- Device-level package purchases with shared data pool
|
||||
- Batch device allocation to agents
|
||||
- Remote device operations (reboot, password change, reset)
|
||||
## Requirements
|
||||
### Requirement: 设备实体定义
|
||||
|
||||
系统 SHALL 定义设备(Device)实体,用于管理用户的物联网设备(如 GPS 追踪器、智能传感器等),支持设备与 IoT 卡的绑定关系、设备批量分配和设备操作。
|
||||
|
||||
**核心概念**: 设备不在卡管系统中销售,主要用于:
|
||||
1. 用户设备管理(用户添加自己的设备,绑定 IoT 卡)
|
||||
2. 方便运营人员管理投诉和代理要求(通过设备维度批量查看绑定的所有 IoT 卡)
|
||||
3. 设备操作(重启、修改账号密码、重置等)
|
||||
4. 设备批量分配(运营人员在别的系统报单后发货,把设备和绑定的 IoT 卡一起分配给代理)
|
||||
|
||||
**实体字段**:
|
||||
|
||||
**基本属性**:
|
||||
- `id`: 设备 ID(主键,BIGINT)
|
||||
- `device_no`: 设备编号(唯一,VARCHAR(100))
|
||||
- `device_name`: 设备名称(VARCHAR(255))
|
||||
- `device_model`: 设备型号(VARCHAR(100))
|
||||
- `device_type`: 设备类型(VARCHAR(50),如 "GPS Tracker"、"Camera"、"Sensor")
|
||||
- `max_sim_slots`: 最大 IoT 卡插槽数量(INT,1-4,默认 4)
|
||||
- `manufacturer`: 设备制造商(VARCHAR(255),可选)
|
||||
- `batch_no`: 批次号(VARCHAR(100),用于批量导入追溯)
|
||||
|
||||
**店铺归属和状态**:
|
||||
- `shop_id`: 店铺 ID(BIGINT,可空,NULL 表示平台库存,有值表示店铺所有)
|
||||
- `status`: 设备状态(INT,1-在库 2-已分销 3-已激活 4-已停用)
|
||||
- `activated_at`: 激活时间(TIMESTAMP,可空)
|
||||
|
||||
**设备操作配置**(预留字段,用于后续设备操作功能):
|
||||
- `device_username`: 设备登录账号(VARCHAR(100),可选)
|
||||
- `device_password_encrypted`: 设备登录密码(加密存储,VARCHAR(255),可选)
|
||||
- `device_api_endpoint`: 设备 API 接口地址(VARCHAR(500),可选)
|
||||
|
||||
**系统字段**:
|
||||
- `created_at`: 创建时间(TIMESTAMP,自动填充)
|
||||
- `updated_at`: 更新时间(TIMESTAMP,自动填充)
|
||||
- `creator`: 创建人 ID(BIGINT)
|
||||
- `updater`: 更新人 ID(BIGINT)
|
||||
|
||||
#### Scenario: 用户添加设备
|
||||
|
||||
- **WHEN** 用户添加自己的设备(设备编号为 "GPS-001",设备名称为 "物流车辆追踪器")
|
||||
- **THEN** 系统创建设备记录,根据用户归属设置 `shop_id`,状态为 1(在库)
|
||||
|
||||
#### Scenario: 平台导入设备到库存
|
||||
|
||||
- **WHEN** 平台批量导入设备数据(准备发货给代理)
|
||||
- **THEN** 系统创建设备记录,`shop_id` 为 NULL(平台库存),状态为 1(在库)
|
||||
|
||||
#### Scenario: 运营人员批量分配设备给代理店铺
|
||||
|
||||
- **WHEN** 运营人员将平台库存设备(ID 为 1001)分配给代理店铺(ID 为 10)
|
||||
- **THEN** 系统将设备的 `shop_id` 设置为 10,同时自动将该设备绑定的所有 IoT 卡的 `shop_id` 也设置为 10
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备状态流转
|
||||
|
||||
系统 SHALL 管理设备的状态流转,确保状态变更符合业务规则。
|
||||
|
||||
**状态定义**:
|
||||
- **1-未激活**: 设备尚未激活使用
|
||||
- **2-已激活**: 设备已被用户激活使用
|
||||
- **3-已停用**: 设备已停用,不可使用
|
||||
|
||||
**状态流转规则**:
|
||||
- 未激活(1) → 已激活(2): 用户激活设备
|
||||
- 已激活(2) → 已停用(3): 用户或平台主动停用设备
|
||||
- 已停用(3) → 已激活(2): 用户或平台主动恢复设备(仅在符合业务规则时)
|
||||
|
||||
#### Scenario: 用户激活设备
|
||||
|
||||
- **WHEN** 用户激活自己的设备
|
||||
- **THEN** 系统将设备状态从 1(未激活) 变更为 2(已激活),`activated_at` 记录激活时间
|
||||
|
||||
#### Scenario: 用户停用设备
|
||||
|
||||
- **WHEN** 用户停用已激活的设备
|
||||
- **THEN** 系统将设备状态从 2(已激活) 变更为 3(已停用),同时可选择是否停用该设备绑定的所有 IoT 卡
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备与 IoT 卡绑定关系
|
||||
|
||||
系统 SHALL 管理设备与 IoT 卡的绑定关系,一个设备可以绑定 1-4 张 IoT 卡。
|
||||
|
||||
**绑定规则**:
|
||||
- 一个设备最多绑定 4 张 IoT 卡(由 `max_sim_slots` 字段控制)
|
||||
- 一个 IoT 卡同一时间只能绑定一个设备
|
||||
- 绑定时记录插槽位置(slot_position: 1, 2, 3, 4)
|
||||
- 绑定时记录绑定时间和绑定状态(1-已绑定 2-已解绑)
|
||||
- 绑定/解绑操作不改变 IoT 卡的 shop_id(所有权由分销操作管理,而非绑定操作)
|
||||
- **新增**: 同一设备的同一插槽同一时间只能绑定一张卡(数据库唯一约束)
|
||||
|
||||
**中间表 tb_device_sim_binding**:
|
||||
- `id`: 绑定记录 ID(主键,BIGINT)
|
||||
- `device_id`: 设备 ID(BIGINT)
|
||||
- `iot_card_id`: IoT 卡 ID(BIGINT)
|
||||
- `slot_position`: 插槽位置(INT,1-4)
|
||||
- `bind_status`: 绑定状态(INT,1-已绑定 2-已解绑)
|
||||
- `bind_time`: 绑定时间(TIMESTAMP)
|
||||
- `unbind_time`: 解绑时间(TIMESTAMP,可空)
|
||||
- `created_at`: 创建时间(TIMESTAMP,自动填充)
|
||||
- `updated_at`: 更新时间(TIMESTAMP,自动填充)
|
||||
- `deleted_at`: 软删除时间(TIMESTAMP,可空)
|
||||
- `creator`: 创建人 ID(BIGINT)
|
||||
- `updater`: 更新人 ID(BIGINT)
|
||||
|
||||
**数据库约束**:
|
||||
- `idx_device_sim_bindings_active_card`: 唯一索引 (iot_card_id) WHERE bind_status = 1,防止同一张卡绑定到多个设备
|
||||
- **新增** `idx_active_device_slot`: 唯一索引 (device_id, slot_position) WHERE bind_status = 1 AND deleted_at IS NULL,防止同一插槽绑定多张卡
|
||||
|
||||
**并发安全**:
|
||||
- 系统 SHALL 在数据库层面通过唯一约束防止并发绑定导致的数据不一致
|
||||
- 系统 SHALL 正确处理唯一约束冲突错误,返回友好的用户提示而非通用数据库错误
|
||||
|
||||
#### Scenario: 绑定 IoT 卡到设备
|
||||
|
||||
- **WHEN** 用户将 IoT 卡(ID 为 101)绑定到设备(ID 为 1001)的插槽 1
|
||||
- **THEN** 系统创建绑定记录,`device_id` 为 1001,`iot_card_id` 为 101,`slot_position` 为 1,`bind_status` 为 1(已绑定),`bind_time` 为当前时间
|
||||
|
||||
#### Scenario: 解绑 IoT 卡
|
||||
|
||||
- **WHEN** 用户解绑设备的 IoT 卡(绑定记录 ID 为 10)
|
||||
- **THEN** 系统将绑定记录的 `bind_status` 从 1(已绑定) 变更为 2(已解绑),`unbind_time` 记录解绑时间,IoT 卡的 `shop_id` 保持不变
|
||||
|
||||
#### Scenario: 并发绑定同一张卡到不同设备
|
||||
|
||||
- **WHEN** 两个请求同时尝试将同一张 IoT 卡(ID 为 101)绑定到不同设备
|
||||
- **THEN** 第一个请求成功,第二个请求返回错误"该卡已绑定到其他设备"
|
||||
|
||||
#### Scenario: 并发绑定不同卡到同一设备插槽
|
||||
|
||||
- **WHEN** 两个请求同时尝试将不同 IoT 卡绑定到同一设备(ID 为 1001)的同一插槽(slot_position 为 1)
|
||||
- **THEN** 第一个请求成功,第二个请求返回错误"该插槽已有绑定的卡"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备套餐购买和流量共享
|
||||
|
||||
系统 SHALL 支持用户为设备购买套餐,套餐自动分配到设备绑定的所有 IoT 卡,流量在设备级别共享。
|
||||
|
||||
**设备套餐业务规则**:
|
||||
- 用户为设备购买套餐时,套餐会分配到设备绑定的**所有 IoT 卡**(1-4 张)
|
||||
- 套餐的流量是**设备级别共享的**(例如 3000G/月共享,不管用哪张卡)
|
||||
- 分佣**只计算一次**(不按卡数倍增)
|
||||
- 订单表通过 `device_id` 字段关联设备,通过 `device_sim_bindings` 表查找绑定的所有 IoT 卡
|
||||
|
||||
**套餐分配示例**:
|
||||
- 设备绑定 3 张 IoT 卡
|
||||
- 用户购买套餐:399 元/年,每月 3000G 流量,长期佣金 100 元
|
||||
- 用户支付:399 元
|
||||
- 套餐分配:设备的 3 张 IoT 卡都获得该套餐
|
||||
- 流量使用:3000G/月 在 3 张卡之间共享(不是每张卡 3000G,而是总共 3000G)
|
||||
- 分佣:代理获得 100 元分佣(只分一次,不是 3 × 100 元)
|
||||
|
||||
#### Scenario: 用户为设备购买套餐
|
||||
|
||||
- **WHEN** 用户为设备(ID 为 1001,绑定 3 张 IoT 卡)购买套餐(套餐 ID 为 3001,399 元/年,3000G/月)
|
||||
- **THEN** 系统创建套餐订单,`device_id` 为 1001,`package_id` 为 3001,订单金额为 399 元,将套餐分配到设备绑定的 3 张 IoT 卡,设置流量共享模式为设备级别
|
||||
|
||||
#### Scenario: 设备级流量共享
|
||||
|
||||
- **WHEN** 设备(ID 为 1001)的套餐流量为 3000G/月,设备绑定 3 张 IoT 卡
|
||||
- **THEN** 系统设置流量共享模式,3 张 IoT 卡共享 3000G/月(不是每张卡 3000G),无论使用哪张卡,都从这个流量池扣除
|
||||
|
||||
#### Scenario: 设备套餐分佣
|
||||
|
||||
- **WHEN** 用户为设备购买套餐,订单金额为 399 元,代理的长期分佣规则为 100 元
|
||||
- **THEN** 系统为代理创建一条分佣记录,分佣金额为 100 元(只分一次,不按设备绑定的卡数倍增)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备批量分配
|
||||
|
||||
系统 SHALL 支持运营人员批量分配设备给代理店铺,设备分配时自动分配该设备绑定的所有 IoT 卡。
|
||||
|
||||
**分配规则**:
|
||||
- 只能分配 `shop_id` 为 NULL 的设备(平台库存)
|
||||
- 分配时,设备的 `shop_id` 设置为目标店铺 ID
|
||||
- 分配时,设备绑定的所有 IoT 卡的 `shop_id` 也设置为目标店铺 ID
|
||||
- 分配操作记录到操作日志
|
||||
|
||||
#### Scenario: 运营人员批量分配设备
|
||||
|
||||
- **WHEN** 运营人员将 10 台设备(平台库存)分配给代理店铺(ID 为 10)
|
||||
- **THEN** 系统将这 10 台设备的 `shop_id` 设置为 10,同时将这些设备绑定的所有 IoT 卡的 `shop_id` 也设置为 10
|
||||
|
||||
#### Scenario: 分配已分配的设备
|
||||
|
||||
- **WHEN** 运营人员尝试分配 `shop_id` 不为 NULL 的设备
|
||||
- **THEN** 系统拒绝分配,返回错误信息"该设备已分配给店铺,不能重复分配"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备操作
|
||||
|
||||
系统 SHALL 支持对设备的远程操作(重启、修改账号密码、重置等),用于设备管理和故障排查。
|
||||
|
||||
**设备操作类型**:
|
||||
- **重启设备**: 远程重启设备
|
||||
- **修改账号密码**: 修改设备的登录账号和密码
|
||||
- **重置设备**: 将设备恢复到出厂设置
|
||||
- **查询设备状态**: 查询设备的在线状态、运行状态等
|
||||
- **设备配置更新**: 更新设备的配置参数
|
||||
|
||||
**操作说明**:
|
||||
- 本阶段只设计数据模型字段和接口定义,不实现设备操作的具体代码
|
||||
- 后续 Service 层将调用设备厂商提供的 API 或通过 MQTT/HTTP 协议与设备通信
|
||||
- 设备操作需要记录操作日志(操作类型、操作人、操作时间、操作结果)
|
||||
|
||||
#### Scenario: 重启设备
|
||||
|
||||
- **WHEN** 用户或运营人员请求重启设备(ID 为 1001)
|
||||
- **THEN** 系统调用设备 API 发送重启命令,记录操作日志,返回操作结果
|
||||
|
||||
#### Scenario: 修改设备密码
|
||||
|
||||
- **WHEN** 用户或运营人员修改设备(ID 为 1001)的登录密码
|
||||
- **THEN** 系统更新设备的 `device_password_encrypted` 字段(加密存储),调用设备 API 同步密码修改,记录操作日志
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备批量导入
|
||||
|
||||
系统 SHALL 支持批量导入设备数据,用于平台库存管理。
|
||||
|
||||
**导入字段**:
|
||||
- 设备编号(必填)
|
||||
- 设备名称(可选)
|
||||
- 设备型号(可选)
|
||||
- 设备类型(可选)
|
||||
- 最大插槽数(可选,默认 4)
|
||||
- 设备制造商(可选)
|
||||
- 批次号(可选,由任务自动生成)
|
||||
- **ICCID 1-4**(可选,用于绑定 IoT 卡)
|
||||
|
||||
**导入规则**:
|
||||
- 设备编号必须唯一,重复编号将被跳过
|
||||
- 导入的设备默认 `shop_id` 为 NULL(平台库存),状态为 1(在库)
|
||||
- 导入成功后记录操作日志
|
||||
|
||||
**IoT 卡绑定规则**(新增):
|
||||
- 系统 SHALL 校验 ICCID 对应的卡是否存在
|
||||
- 系统 SHALL 校验卡是否已绑定到其他设备
|
||||
- **新增**: 系统 SHALL 校验卡的归属权,只允许绑定平台库存的卡(shop_id = NULL)
|
||||
- 如果卡已分配给店铺(shop_id != NULL),系统 SHALL 拒绝绑定并记录原因
|
||||
|
||||
**导入结果分类**(新增):
|
||||
- **完全成功**: 设备创建且所有指定的卡都绑定成功
|
||||
- **部分成功**: 设备创建但部分卡绑定失败(新增 warning 状态)
|
||||
- **跳过**: 设备编号已存在
|
||||
- **失败**: 设备创建失败或所有指定的卡都不可用
|
||||
|
||||
**导入任务模型扩展**(新增):
|
||||
- `warning_count`: 警告数量(部分成功的设备数)
|
||||
- `warning_items`: 警告记录详情(JSONB,记录哪些卡绑定失败及原因)
|
||||
|
||||
#### Scenario: 批量导入设备成功
|
||||
|
||||
- **WHEN** 平台上传包含 50 条设备数据的 CSV 文件
|
||||
- **THEN** 系统创建 50 条设备记录,`shop_id` 为 NULL(平台库存),状态为 1(在库),返回导入成功消息
|
||||
|
||||
#### Scenario: 批量导入包含重复编号
|
||||
|
||||
- **WHEN** 平台上传的 CSV 文件中包含已存在的设备编号
|
||||
- **THEN** 系统跳过重复编号的设备,记录到 skipped_items 并列出重复编号,其他有效设备正常导入
|
||||
|
||||
#### Scenario: 导入时绑定平台库存的卡
|
||||
|
||||
- **WHEN** CSV 行指定了 ICCID,且该卡为平台库存(shop_id = NULL)且未绑定其他设备
|
||||
- **THEN** 系统创建设备并绑定该卡,记录为完全成功
|
||||
|
||||
#### Scenario: 导入时尝试绑定已分配给店铺的卡
|
||||
|
||||
- **WHEN** CSV 行指定了 ICCID,但该卡已分配给店铺(shop_id != NULL)
|
||||
- **THEN** 系统创建设备但不绑定该卡,将该设备记录到 warning_items,原因为"ICCID-XXX 已分配给店铺,不能绑定到平台库存设备"
|
||||
|
||||
#### Scenario: 导入时部分卡绑定成功
|
||||
|
||||
- **WHEN** CSV 行指定了 4 张卡,其中 2 张为平台库存且未绑定,1 张已分配给店铺,1 张不存在
|
||||
- **THEN** 系统创建设备并绑定 2 张有效的卡,将该设备记录到 warning_items,原因为"部分卡绑定失败: ICCID-001 已分配给店铺,不能绑定到平台库存设备; ICCID-002 不存在",success_count 和 warning_count 各加 1
|
||||
|
||||
#### Scenario: 导入时所有指定的卡都不可用
|
||||
|
||||
- **WHEN** CSV 行指定了 2 张卡,但都已绑定到其他设备
|
||||
- **THEN** 系统不创建设备,将该行记录到 failed_items,原因为"所有指定的卡都不可用: ICCID-001 已绑定其他设备, ICCID-002 已绑定其他设备"
|
||||
|
||||
### Requirement: 设备查询和筛选
|
||||
|
||||
系统 SHALL 支持多维度查询和筛选设备,包括状态、店铺归属、批次号、设备类型等。
|
||||
|
||||
**查询条件**:
|
||||
- 设备编号(精确匹配或模糊匹配)
|
||||
- 设备名称(模糊匹配)
|
||||
- 设备状态(单选或多选)
|
||||
- 店铺 ID(shop_id): 可选,NULL 表示平台库存
|
||||
- 批次号(精确匹配)
|
||||
- 设备类型(单选或多选)
|
||||
- 设备制造商(模糊匹配)
|
||||
- 激活时间范围(开始时间 - 结束时间)
|
||||
- 创建时间范围(开始时间 - 结束时间)
|
||||
|
||||
**分页**:
|
||||
- 默认每页 20 条,最大每页 100 条
|
||||
- 返回总记录数和总页数
|
||||
|
||||
**数据权限**:
|
||||
- 基于 shop_id 自动应用数据权限过滤
|
||||
- 代理只能看到自己店铺及下级店铺的设备
|
||||
|
||||
#### Scenario: 查询平台库存设备
|
||||
|
||||
- **WHEN** 运营人员查询平台库存设备
|
||||
- **THEN** 系统返回 `shop_id` 为 NULL 的设备列表
|
||||
|
||||
#### Scenario: 代理查询自己店铺的设备
|
||||
|
||||
- **WHEN** 代理店铺(ID 为 10)查询自己的设备
|
||||
- **THEN** 系统返回 `shop_id` 为 10(及其下级店铺)的设备列表
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备数据校验
|
||||
|
||||
系统 SHALL 对设备数据进行校验,确保数据完整性和一致性。
|
||||
|
||||
**校验规则**:
|
||||
- 设备编号(device_no):必填,长度 1-100 字符,唯一
|
||||
- 设备名称(device_name):可选,长度 1-255 字符
|
||||
- 设备型号(device_model):可选,长度 1-100 字符
|
||||
- 设备类型(device_type):可选,长度 1-50 字符
|
||||
- 最大插槽数(max_sim_slots):必填,1-4 之间的整数
|
||||
- 店铺 ID(shop_id):可选,NULL 表示平台库存,有值必须是有效的店铺 ID
|
||||
- 设备状态(status):必填,枚举值 1(在库) | 2(已分销) | 3(已激活) | 4(已停用)
|
||||
|
||||
#### Scenario: 创建设备时插槽数超出范围
|
||||
|
||||
- **WHEN** 用户创建设备,最大插槽数为 5
|
||||
- **THEN** 系统拒绝创建,返回错误信息"最大插槽数必须在 1-4 之间"
|
||||
|
||||
#### Scenario: 创建设备时设备编号重复
|
||||
|
||||
- **WHEN** 用户创建设备,设备编号为已存在的 "DEV-001"
|
||||
- **THEN** 系统拒绝创建,返回错误信息"设备编号已存在"
|
||||
|
||||
### Requirement: DeviceSimBinding 模型组织
|
||||
|
||||
系统 SHALL 将 DeviceSimBinding 模型定义在独立的文件中,遵循项目代码组织规范。
|
||||
|
||||
**文件位置**:
|
||||
- 从: `internal/model/package.go`
|
||||
- 到: `internal/model/device_sim_binding.go`
|
||||
|
||||
**模型内容**:
|
||||
```go
|
||||
// DeviceSimBinding 设备-IoT卡绑定关系模型
|
||||
// 管理设备与 IoT 卡的多对多绑定关系(1 设备绑定 1-4 张 IoT 卡)
|
||||
type DeviceSimBinding struct {
|
||||
gorm.Model
|
||||
BaseModel `gorm:"embedded"`
|
||||
DeviceID uint `gorm:"column:device_id;index:idx_device_slot;not null;comment:设备ID"`
|
||||
IotCardID uint `gorm:"column:iot_card_id;index;not null;comment:IoT卡ID"`
|
||||
SlotPosition int `gorm:"column:slot_position;type:int;index:idx_device_slot;comment:插槽位置(1, 2, 3, 4)"`
|
||||
BindStatus int `gorm:"column:bind_status;type:int;default:1;comment:绑定状态 1-已绑定 2-已解绑"`
|
||||
BindTime *time.Time `gorm:"column:bind_time;comment:绑定时间"`
|
||||
UnbindTime *time.Time `gorm:"column:unbind_time;comment:解绑时间"`
|
||||
}
|
||||
|
||||
func (DeviceSimBinding) TableName() string {
|
||||
return "tb_device_sim_binding"
|
||||
}
|
||||
```
|
||||
|
||||
#### Scenario: 模型文件独立
|
||||
|
||||
- **WHEN** 开发者需要查找或修改 DeviceSimBinding 模型
|
||||
- **THEN** 模型定义位于 `internal/model/device_sim_binding.go` 文件中,而非混杂在 `package.go` 中
|
||||
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Device Handler 分层修复
|
||||
|
||||
Device Handler SHALL 不再直接持有 `gateway.Client` 引用。所有 Gateway API 调用 SHALL 通过 Device Service 层发起。
|
||||
|
||||
#### Scenario: DeviceHandler 不持有 gatewayClient
|
||||
|
||||
- **WHEN** 创建 `DeviceHandler` 实例
|
||||
- **THEN** `NewDeviceHandler` 构造函数不接收 `gateway.Client` 参数
|
||||
- **AND** Handler 结构体不包含 `gatewayClient` 字段
|
||||
|
||||
#### Scenario: 查询设备网关信息通过 Service 调用
|
||||
|
||||
- **WHEN** Handler 的 `GetGatewayInfo` 方法被调用
|
||||
- **THEN** Handler 调用 `service.GetGatewayInfo(ctx, identifier)`
|
||||
|
||||
#### Scenario: 查询设备卡槽信息通过 Service 调用
|
||||
|
||||
- **WHEN** Handler 的 `GetGatewaySlots` 方法被调用
|
||||
- **THEN** Handler 调用 `service.GetGatewaySlots(ctx, identifier)`
|
||||
|
||||
#### Scenario: 设置设备限速通过 Service 调用
|
||||
|
||||
- **WHEN** Handler 的 `SetSpeedLimit` 方法被调用
|
||||
- **THEN** Handler 调用 `service.SetGatewaySpeedLimit(ctx, identifier, speedLimit)`
|
||||
|
||||
#### Scenario: 设置设备 WiFi 通过 Service 调用
|
||||
|
||||
- **WHEN** Handler 的 `SetWiFi` 方法被调用
|
||||
- **THEN** Handler 调用 `service.SetGatewayWiFi(ctx, identifier, req)`
|
||||
|
||||
#### Scenario: 切换设备卡通过 Service 调用
|
||||
|
||||
- **WHEN** Handler 的 `SwitchCard` 方法被调用
|
||||
- **THEN** Handler 调用 `service.GatewaySwitchCard(ctx, identifier, targetICCID)`
|
||||
|
||||
#### Scenario: 重启设备通过 Service 调用
|
||||
|
||||
- **WHEN** Handler 的 `RebootDevice` 方法被调用
|
||||
- **THEN** Handler 调用 `service.GatewayRebootDevice(ctx, identifier)`
|
||||
|
||||
#### Scenario: 恢复出厂设置通过 Service 调用
|
||||
|
||||
- **WHEN** Handler 的 `ResetDevice` 方法被调用
|
||||
- **THEN** Handler 调用 `service.GatewayResetDevice(ctx, identifier)`
|
||||
|
||||
### Requirement: Device Service Gateway 代理方法
|
||||
|
||||
Device Service SHALL 提供 Gateway API 的代理方法,封装设备标识符解析、IMEI 检查和 Gateway 调用。
|
||||
|
||||
#### Scenario: GetGatewayInfo 方法
|
||||
|
||||
- **WHEN** 调用 `service.GetGatewayInfo(ctx, identifier)`
|
||||
- **THEN** 先通过 `GetDeviceByIdentifier` 查找设备并验证权限
|
||||
- **AND** 检查设备 IMEI 不为空
|
||||
- **AND** 调用 `gatewayClient.GetDeviceInfo` 传入设备 IMEI
|
||||
- **AND** 返回 `*gateway.DeviceInfoResp`
|
||||
|
||||
#### Scenario: GetGatewaySlots 方法
|
||||
|
||||
- **WHEN** 调用 `service.GetGatewaySlots(ctx, identifier)`
|
||||
- **THEN** 先查找设备、验证 IMEI 不为空
|
||||
- **AND** 调用 `gatewayClient.GetSlotInfo`
|
||||
- **AND** 返回 `*gateway.SlotInfoResp`
|
||||
|
||||
#### Scenario: SetGatewaySpeedLimit 方法
|
||||
|
||||
- **WHEN** 调用 `service.SetGatewaySpeedLimit(ctx, identifier, speedLimit)`
|
||||
- **THEN** 先查找设备、验证 IMEI
|
||||
- **AND** 调用 `gatewayClient.SetSpeedLimit` 传入设备 IMEI 和限速值
|
||||
|
||||
#### Scenario: SetGatewayWiFi 方法
|
||||
|
||||
- **WHEN** 调用 `service.SetGatewayWiFi(ctx, identifier, cardNo, ssid, password string, enabled bool)`
|
||||
- **THEN** 先查找设备、验证 IMEI
|
||||
- **AND** 调用 `gatewayClient.SetWiFi` 传入设备 IMEI、cardNo(ICCID)、ssid、password、enabled
|
||||
|
||||
#### Scenario: GatewaySwitchCard 方法
|
||||
|
||||
- **WHEN** 调用 `service.GatewaySwitchCard(ctx, identifier, targetICCID)`
|
||||
- **THEN** 先查找设备、验证 IMEI
|
||||
- **AND** 调用 `gatewayClient.SwitchCard` 传入设备 IMEI 作为 cardNo 和目标 ICCID
|
||||
|
||||
#### Scenario: GatewayRebootDevice 方法
|
||||
|
||||
- **WHEN** 调用 `service.GatewayRebootDevice(ctx, identifier)`
|
||||
- **THEN** 先查找设备、验证 IMEI
|
||||
- **AND** 调用 `gatewayClient.RebootDevice` 传入设备 IMEI
|
||||
|
||||
#### Scenario: GatewayResetDevice 方法
|
||||
|
||||
- **WHEN** 调用 `service.GatewayResetDevice(ctx, identifier)`
|
||||
- **THEN** 先查找设备、验证 IMEI
|
||||
- **AND** 调用 `gatewayClient.ResetDevice` 传入设备 IMEI
|
||||
|
||||
#### Scenario: 设备 IMEI 为空
|
||||
|
||||
- **WHEN** 调用任意 Gateway 代理方法且设备的 IMEI 字段为空
|
||||
- **THEN** 返回 `CodeInvalidParam` 错误
|
||||
- **AND** 错误信息说明该设备未配置 IMEI
|
||||
|
||||
#### Scenario: 设备不存在或无权限
|
||||
|
||||
- **WHEN** 调用任意 Gateway 代理方法且标识符无法匹配到设备
|
||||
- **THEN** 返回对应的错误(由 `GetDeviceByIdentifier` 返回)
|
||||
|
||||
### Requirement: Device Service 接收 Gateway Client
|
||||
|
||||
Device Service SHALL 在构造函数中接收 `*gateway.Client` 依赖。
|
||||
|
||||
#### Scenario: Device Service 初始化
|
||||
|
||||
- **WHEN** 创建 Device Service 实例
|
||||
- **THEN** 构造函数接收 `gatewayClient *gateway.Client` 参数
|
||||
- **AND** 存储为 Service 的内部字段
|
||||
- **AND** `gatewayClient` 可以为 nil(Gateway 配置缺失时)
|
||||
|
||||
#### Scenario: Gateway Client 为 nil 时调用 Gateway 方法
|
||||
|
||||
- **WHEN** `gatewayClient` 为 nil 且调用任意 Gateway 代理方法
|
||||
- **THEN** 返回 `CodeGatewayError` 错误
|
||||
- **AND** 错误信息为 "Gateway 客户端未配置"
|
||||
@@ -1,174 +0,0 @@
|
||||
# Number Card Management
|
||||
|
||||
## Purpose
|
||||
|
||||
Manage number cards (virtual products) for carrier order callbacks, supporting carrier order passthrough, agent promotion, commission processing, and carrier settlement tracking.
|
||||
|
||||
This capability supports:
|
||||
- Number card entity definition as virtual product mapping
|
||||
- Carrier order callbacks from Gateway project
|
||||
- Agent promotion via links or offline cards
|
||||
- Commission processing for number card orders
|
||||
- Carrier settlement tracking for financial reconciliation
|
||||
- Integration with existing commission rules (one-time, long-term, combined)
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 号卡实体定义
|
||||
|
||||
系统 SHALL 定义号卡(NumberCard)实体,作为运营商订单回传的映射,支持代理分销和分佣。
|
||||
|
||||
**实体字段**:
|
||||
- `id`: 号卡 ID(主键,BIGINT)
|
||||
- `virtual_product_code`: 虚拟商品编码(VARCHAR(100),唯一,用于对应运营商订单)
|
||||
- `product_name`: 商品名称(VARCHAR(255))
|
||||
- `carrier`: 运营商名称(VARCHAR(100),如 "中国移动"、"中国联通"、"中国电信")
|
||||
- `carrier_product_id`: 运营商商品 ID(VARCHAR(100))
|
||||
- `package_type`: 套餐类型(VARCHAR(50),如 "月套餐"、"流量包")
|
||||
- `data_amount_mb`: 流量额度(BIGINT,MB 为单位,可选)
|
||||
- `voice_minutes`: 语音分钟数(INT,可选)
|
||||
- `sms_count`: 短信条数(INT,可选)
|
||||
- `price`: 固定售价(DECIMAL(10,2),由运营商定价)
|
||||
- `status`: 号卡状态(INT,1-上架 2-下架)
|
||||
- `created_at`: 创建时间(TIMESTAMP,自动填充)
|
||||
- `updated_at`: 更新时间(TIMESTAMP,自动填充)
|
||||
|
||||
#### Scenario: 创建号卡商品
|
||||
|
||||
- **WHEN** 平台创建号卡商品,虚拟商品编码为 "VC-CMCC-001",运营商为"中国移动",固定售价为 30.00 元
|
||||
- **THEN** 系统创建号卡记录,`virtual_product_code` 为 "VC-CMCC-001",`carrier` 为 "中国移动",`price` 为 30.00,状态为 1(上架)
|
||||
|
||||
#### Scenario: 虚拟商品编码唯一性
|
||||
|
||||
- **WHEN** 平台创建号卡商品,虚拟商品编码为已存在的 "VC-CMCC-001"
|
||||
- **THEN** 系统拒绝创建,返回错误信息"虚拟商品编码已存在"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 号卡运营商订单回传
|
||||
|
||||
系统 SHALL 接收 Gateway 项目转换后的运营商订单回传,通过虚拟商品编码匹配号卡,创建订单和分佣记录。
|
||||
|
||||
**订单回传字段**:
|
||||
- `carrier_order_id`: 运营商订单 ID(VARCHAR(255),唯一)
|
||||
- `virtual_product_code`: 虚拟商品编码(VARCHAR(100),用于匹配号卡)
|
||||
- `user_phone`: 用户手机号(VARCHAR(20))
|
||||
- `amount`: 订单金额(DECIMAL(10,2))
|
||||
- `order_time`: 订单时间(TIMESTAMP)
|
||||
- `agent_id`: 代理 ID(BIGINT,可空,如果通过代理推广则有值)
|
||||
- `carrier_order_data`: 运营商订单原始数据(JSONB)
|
||||
|
||||
**回传处理流程**:
|
||||
1. Gateway 接收运营商订单,统一转换为 JSON 格式
|
||||
2. Gateway 通过 HTTP POST 回传给 CMP 系统
|
||||
3. CMP 系统根据 `virtual_product_code` 匹配号卡
|
||||
4. CMP 系统创建订单记录(`order_type` 为 "number_card")
|
||||
5. 如果有 `agent_id`,触发代理分佣流程
|
||||
|
||||
#### Scenario: 接收运营商订单回传
|
||||
|
||||
- **WHEN** Gateway 回传运营商订单,虚拟商品编码为 "VC-CMCC-001",代理 ID 为 123,订单金额为 30.00 元
|
||||
- **THEN** 系统创建订单记录,`order_type` 为 "number_card",`source_id` 为号卡 ID,`agent_id` 为 123,触发分佣计算
|
||||
|
||||
#### Scenario: 虚拟商品编码不存在
|
||||
|
||||
- **WHEN** Gateway 回传运营商订单,虚拟商品编码为不存在的 "VC-UNKNOWN"
|
||||
- **THEN** 系统拒绝创建订单,返回错误信息"虚拟商品编码不存在"并记录到日志
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 号卡代理分销
|
||||
|
||||
系统 SHALL 支持号卡的代理分销,代理通过推广链接或卡板推广号卡给终端用户。
|
||||
|
||||
**分销规则**:
|
||||
- 号卡由运营商定价,平台无权修改价格
|
||||
- 代理通过推广链接或卡板获取用户激活
|
||||
- 用户激活充值后,资金直接支付给运营商,不经过平台
|
||||
- 运营商周期性结算总佣金给平台
|
||||
- 平台根据代理分佣规则分配佣金给代理
|
||||
|
||||
**代理推广方式**:
|
||||
- **推广链接**: 代理生成带有 `agent_id` 的推广链接,用户点击链接激活
|
||||
- **卡板**: 代理线下分发印有二维码的卡板,用户扫码激活
|
||||
|
||||
#### Scenario: 代理生成推广链接
|
||||
|
||||
- **WHEN** 代理商(用户 ID 为 123)为号卡(ID 为 5001)生成推广链接
|
||||
- **THEN** 系统生成带有 `agent_id=123` 和 `product_id=5001` 的推广链接,如 `https://example.com/activate?agent=123&product=5001`
|
||||
|
||||
#### Scenario: 用户通过代理链接激活
|
||||
|
||||
- **WHEN** 用户通过代理推广链接激活号卡并充值 30.00 元
|
||||
- **THEN** 运营商接收用户支付,Gateway 回传订单时包含 `agent_id=123`,系统触发代理分佣流程
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 号卡分佣处理
|
||||
|
||||
系统 SHALL 根据号卡分佣规则计算代理佣金,支持冻结和解冻流程。
|
||||
|
||||
**分佣规则**:
|
||||
- 号卡分佣配置在代理分佣规则表(`commission_rules`)中
|
||||
- 分佣类型:一次性分佣、长期分佣、组合分佣(参考 iot-agent-commission 规范)
|
||||
- 号卡订单的分佣需要满足条件:激活(实名) + 达到充值金额 + 在网状态 + 三无校验
|
||||
- 分佣记录创建时状态为"冻结",满足条件后变为"解冻中",审批通过后变为"已发放"
|
||||
|
||||
#### Scenario: 号卡订单触发分佣
|
||||
|
||||
- **WHEN** 运营商回传订单,代理 ID 为 123,订单金额为 30.00 元,该代理配置了一次性分佣 5.00 元
|
||||
- **THEN** 系统创建分佣记录,金额为 5.00 元,状态为"冻结",等待满足解冻条件
|
||||
|
||||
#### Scenario: 号卡分佣解冻
|
||||
|
||||
- **WHEN** 号卡订单满足解冻条件(激活 + 充值 + 在网 + 三无校验)
|
||||
- **THEN** 系统将分佣记录状态从"冻结"变更为"解冻中",创建分佣解冻审批记录
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 号卡运营商结算
|
||||
|
||||
系统 SHALL 记录运营商周期性结算的佣金总额,用于财务对账和利润计算。
|
||||
|
||||
**结算字段**:
|
||||
- `settlement_id`: 结算记录 ID(主键,BIGINT)
|
||||
- `carrier`: 运营商名称(VARCHAR(100))
|
||||
- `settlement_period`: 结算周期(VARCHAR(50),如 "2025-01")
|
||||
- `total_commission`: 运营商结算的佣金总额(DECIMAL(18,2))
|
||||
- `settlement_time`: 结算时间(TIMESTAMP)
|
||||
- `status`: 结算状态(INT,1-待确认 2-已确认)
|
||||
- `created_at`: 创建时间(TIMESTAMP,自动填充)
|
||||
- `updated_at`: 更新时间(TIMESTAMP,自动填充)
|
||||
|
||||
#### Scenario: 记录运营商结算
|
||||
|
||||
- **WHEN** 运营商"中国移动"结算 2025 年 1 月的佣金总额 50000.00 元
|
||||
- **THEN** 系统创建结算记录,`carrier` 为 "中国移动",`settlement_period` 为 "2025-01",`total_commission` 为 50000.00,状态为 1(待确认)
|
||||
|
||||
#### Scenario: 确认运营商结算
|
||||
|
||||
- **WHEN** 财务确认运营商结算记录(ID 为 1001)
|
||||
- **THEN** 系统将结算记录状态从 1(待确认) 变更为 2(已确认)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 号卡数据校验
|
||||
|
||||
系统 SHALL 对号卡数据进行校验,确保数据完整性和一致性。
|
||||
|
||||
**校验规则**:
|
||||
- 虚拟商品编码(virtual_product_code):必填,长度 1-100 字符,唯一
|
||||
- 商品名称(product_name):必填,长度 1-255 字符
|
||||
- 运营商名称(carrier):必填,长度 1-100 字符
|
||||
- 固定售价(price):必填,≥ 0,最多 2 位小数
|
||||
- 状态(status):必填,枚举值 1(上架) | 2(下架)
|
||||
|
||||
#### Scenario: 创建号卡时虚拟商品编码为空
|
||||
|
||||
- **WHEN** 平台创建号卡,虚拟商品编码为空
|
||||
- **THEN** 系统拒绝创建,返回错误信息"虚拟商品编码不能为空"
|
||||
|
||||
#### Scenario: 创建号卡时固定售价为负数
|
||||
|
||||
- **WHEN** 平台创建号卡,固定售价为 -10.00
|
||||
- **THEN** 系统拒绝创建,返回错误信息"固定售价必须 ≥ 0"
|
||||
@@ -1,312 +0,0 @@
|
||||
# IoT Order Management
|
||||
|
||||
## Purpose
|
||||
|
||||
Manage orders for IoT card packages and number card products, including order creation, payment processing, status tracking, commission triggering, and support for single-card orders, device-level orders, and carrier number card orders.
|
||||
|
||||
This capability supports:
|
||||
- Unified order entity for package orders and number card orders
|
||||
- Order status lifecycle management
|
||||
- Multiple payment methods (wallet, online payment, carrier direct payment)
|
||||
- Commission triggering on order completion
|
||||
- Device-level order commission (counted once regardless of bound card count)
|
||||
- Multi-dimensional order querying and filtering
|
||||
## Requirements
|
||||
### Requirement: 订单实体定义
|
||||
|
||||
系统 SHALL 定义订单(Order)实体,统一管理两种订单类型:套餐订单、号卡订单,并支持混合支付方式(钱包 + 在线支付)。
|
||||
|
||||
**修改说明**:
|
||||
- 增加 `wallet_payment_amount` 字段:钱包支付金额
|
||||
- 增加 `online_payment_amount` 字段:在线支付金额
|
||||
- 支持用户在购买套餐时选择支付方式(全部钱包支付、全部在线支付、混合支付)
|
||||
|
||||
**实体字段**(只列出新增字段):
|
||||
- `wallet_payment_amount`:钱包支付金额(BIGINT,单位:分,默认 0)**【新增】**
|
||||
- `online_payment_amount`:在线支付金额(BIGINT,单位:分,默认 0)**【新增】**
|
||||
|
||||
**支付规则**:
|
||||
- `wallet_payment_amount` + `online_payment_amount` = `amount`(订单总金额)
|
||||
- 当 `payment_method` 为 "wallet" 时,`wallet_payment_amount` = `amount`,`online_payment_amount` = 0
|
||||
- 当 `payment_method` 为 "online" 时,`online_payment_amount` = `amount`,`wallet_payment_amount` = 0
|
||||
- 混合支付时,`payment_method` 为 "mixed",两个字段都 > 0
|
||||
|
||||
#### Scenario: 全额钱包支付
|
||||
|
||||
- **WHEN** 用户购买套餐,订单金额为 30 00 分(30 元),选择钱包支付,钱包余额为 10000 分
|
||||
- **THEN** 系统创建订单,`amount` 为 3000,`payment_method` 为 "wallet",`wallet_payment_amount` 为 3000,`online_payment_amount` 为 0
|
||||
|
||||
#### Scenario: 全额在线支付
|
||||
|
||||
- **WHEN** 用户购买套餐,订单金额为 3000 分(30 元),选择在线支付
|
||||
- **THEN** 系统创建订单,`amount` 为 3000,`payment_method` 为 "online",`wallet_payment_amount` 为 0,`online_payment_amount` 为 3000
|
||||
|
||||
#### Scenario: 混合支付
|
||||
|
||||
- **WHEN** 用户购买套餐,订单金额为 5000 分(50 元),钱包余额为 3000 分,用户选择钱包支付 3000 分 + 在线支付 2000 分
|
||||
- **THEN** 系统创建订单,`amount` 为 5000,`payment_method` 为 "mixed",`wallet_payment_amount` 为 3000,`online_payment_amount` 为 2000
|
||||
|
||||
#### Scenario: 钱包余额不足,部分钱包支付
|
||||
|
||||
- **WHEN** 用户购买套餐,订单金额为 5000 分(50 元),钱包余额为 2000 分,用户选择钱包支付 2000 分 + 在线支付 3000 分
|
||||
- **THEN** 系统先冻结钱包余额 2000 分,创建订单,`wallet_payment_amount` 为 2000,`online_payment_amount` 为 3000,等待用户完成在线支付
|
||||
|
||||
#### Scenario: 钱包余额不足,无法全额钱包支付
|
||||
|
||||
- **WHEN** 用户购买套餐,订单金额为 5000 分(50 元),钱包余额为 3000 分,用户选择钱包支付
|
||||
- **THEN** 系统拒绝创建订单,返回错误信息"钱包余额不足",建议用户选择混合支付或在线支付
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 订单状态流转
|
||||
|
||||
系统 SHALL 管理订单的状态流转,确保状态变更符合业务规则。**新增订单超时自动取消的详细场景。**
|
||||
|
||||
**状态定义**:
|
||||
- **1-待支付**: 订单已创建,等待用户支付
|
||||
- **2-已支付**: 用户已支付,等待系统处理
|
||||
- **3-已完成**: 订单已完成(激活/发货等)
|
||||
- **4-已取消**: 订单已取消
|
||||
- **5-已退款**: 订单已退款
|
||||
|
||||
**状态流转规则**:
|
||||
- 待支付(1) → 已支付(2): 用户完成支付
|
||||
- 待支付(1) → 已取消(4): 用户手动取消订单或订单超时(30 分钟)
|
||||
- 已支付(2) → 已完成(3): 系统完成订单处理(激活/发货)
|
||||
- 已支付(2) → 已退款(5): 用户申请退款且审核通过
|
||||
- 已完成(3) → 已退款(5): 用户申请退款且审核通过(特殊情况)
|
||||
|
||||
#### Scenario: 用户支付订单
|
||||
|
||||
- **WHEN** 用户支付待支付订单(ID 为 10001),支付金额为 30.00 元
|
||||
- **THEN** 系统将订单状态从 1(待支付) 变更为 2(已支付),`paid_at` 记录支付时间
|
||||
|
||||
#### Scenario: 单卡套餐订单完成
|
||||
|
||||
- **WHEN** 系统处理完单卡套餐订单(ID 为 10001),激活 IoT 卡并分配套餐
|
||||
- **THEN** 系统将订单状态从 2(已支付) 变更为 3(已完成),`completed_at` 记录完成时间
|
||||
|
||||
#### Scenario: 设备级套餐订单完成
|
||||
|
||||
- **WHEN** 系统处理完设备级套餐订单(ID 为 10002),为设备绑定的所有 IoT 卡分配套餐
|
||||
- **THEN** 系统将订单状态从 2(已支付) 变更为 3(已完成),`completed_at` 记录完成时间
|
||||
|
||||
#### Scenario: 用户手动取消订单
|
||||
|
||||
- **WHEN** 用户手动取消待支付订单(ID 为 10003)
|
||||
- **THEN** 系统将订单状态从 1(待支付) 变更为 4(已取消),`expires_at` 设置为 NULL,如有钱包预扣则解冻余额
|
||||
|
||||
#### Scenario: 订单超时自动取消
|
||||
|
||||
- **WHEN** 订单创建后 30 分钟未支付,定时任务扫描到该订单
|
||||
- **THEN** 系统自动将订单状态从 1(待支付) 变更为 4(已取消),`expires_at` 设置为 NULL,如有钱包预扣则解冻余额
|
||||
|
||||
#### Scenario: 订单超时自动取消(混合支付)
|
||||
|
||||
- **WHEN** 混合支付订单创建后 30 分钟未完成在线支付,钱包已预扣 2000 分
|
||||
- **THEN** 系统自动取消订单,解冻钱包余额 2000 分
|
||||
|
||||
#### Scenario: 订单超时自动取消(纯在线支付)
|
||||
|
||||
- **WHEN** 纯在线支付订单创建后 30 分钟未支付
|
||||
- **THEN** 系统自动取消订单,无需钱包解冻操作
|
||||
---
|
||||
|
||||
### Requirement: 订单支付方式
|
||||
|
||||
系统 SHALL 支持三种支付方式:钱包支付、在线支付、运营商直付。
|
||||
|
||||
**支付方式**:
|
||||
- **钱包支付(wallet)**: 从用户钱包余额扣款
|
||||
- **在线支付(online)**: 通过第三方支付(微信/支付宝等)
|
||||
- **运营商直付(carrier)**: 用户直接支付给运营商(仅号卡订单)
|
||||
|
||||
**支付规则**:
|
||||
- 一次性分佣订单必须使用钱包支付
|
||||
- 套餐购买订单可以使用钱包或在线支付
|
||||
- 号卡订单必须使用运营商直付
|
||||
|
||||
#### Scenario: 钱包支付订单
|
||||
|
||||
- **WHEN** 用户使用钱包支付订单(金额为 30.00 元),钱包余额为 50.00 元
|
||||
- **THEN** 系统从钱包扣除 30.00 元,订单状态变更为 2(已支付),`payment_method` 为 "wallet"
|
||||
|
||||
#### Scenario: 钱包余额不足
|
||||
|
||||
- **WHEN** 用户使用钱包支付订单(金额为 30.00 元),钱包余额为 20.00 元
|
||||
- **THEN** 系统拒绝支付,返回错误信息"钱包余额不足"
|
||||
|
||||
#### Scenario: 一次性分佣订单强制钱包支付
|
||||
|
||||
- **WHEN** 用户购买配置了一次性分佣的套餐,尝试使用在线支付
|
||||
- **THEN** 系统拒绝支付,返回错误信息"一次性分佣订单必须使用钱包支付"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 订单分佣触发
|
||||
|
||||
系统 SHALL 在订单完成时触发分佣计算,根据代理分佣规则创建分佣记录。
|
||||
|
||||
**触发条件**:
|
||||
- 订单状态变更为 3(已完成)
|
||||
- 订单有 `agent_id`(通过代理销售)
|
||||
- 代理配置了分佣规则
|
||||
|
||||
**分佣计算规则**:
|
||||
- **单卡套餐订单**: 根据 IoT 卡关联的代理分佣规则计算分佣
|
||||
- **设备级套餐订单**: 分佣只计算一次(不按设备绑定的 IoT 卡数量倍增)
|
||||
- **号卡订单**: 下单即冻结分佣,次月通过 Excel 导入解冻
|
||||
|
||||
#### Scenario: 单卡套餐购买订单触发分佣
|
||||
|
||||
- **WHEN** 代理(ID 为 123)的单卡套餐订单(ID 为 10001)完成,订单金额为 30.00 元,代理配置了 5.00 元一次性分佣
|
||||
- **THEN** 系统创建分佣记录,`agent_id` 为 123,`order_id` 为 10001,`amount` 为 5.00,状态为 1(冻结)
|
||||
|
||||
#### Scenario: 设备级套餐订单触发分佣(只计算一次)
|
||||
|
||||
- **WHEN** 代理(ID 为 123)的设备级套餐订单(ID 为 10002)完成,设备绑定 3 张 IoT 卡,订单金额为 399.00 元,代理配置了 100.00 元长期分佣
|
||||
- **THEN** 系统创建一条分佣记录,`agent_id` 为 123,`order_id` 为 10002,`amount` 为 100.00,状态为 1(冻结),不是 3 × 100.00
|
||||
|
||||
#### Scenario: 号卡订单触发分佣
|
||||
|
||||
- **WHEN** 代理(ID 为 123)的号卡订单(ID 为 10003)创建,订单金额为 30.00 元,代理配置了长期分佣
|
||||
- **THEN** 系统创建分佣记录,`agent_id` 为 123,`order_id` 为 10003,状态为 1(冻结),等待次月通过 Excel 导入解冻
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 订单查询和筛选
|
||||
|
||||
系统 SHALL 支持多维度查询和筛选订单。
|
||||
|
||||
**查询条件**:
|
||||
- 订单编号(精确匹配)
|
||||
- 订单类型(1-套餐订单 2-号卡订单)
|
||||
- 订单状态(单选或多选)
|
||||
- IoT 卡 ID(精确匹配)
|
||||
- 设备 ID(精确匹配)
|
||||
- 号卡 ID(精确匹配)
|
||||
- 用户 ID(精确匹配)
|
||||
- 代理 ID(精确匹配)
|
||||
- 支付方式(单选或多选)
|
||||
- 创建时间范围(开始时间 - 结束时间)
|
||||
- 支付时间范围(开始时间 - 结束时间)
|
||||
- 完成时间范围(开始时间 - 结束时间)
|
||||
|
||||
**分页**:
|
||||
- 默认每页 20 条,最大每页 100 条
|
||||
- 返回总记录数和总页数
|
||||
|
||||
#### Scenario: 查询用户的所有订单
|
||||
|
||||
- **WHEN** 用户(ID 为 2001)查询自己的所有订单
|
||||
- **THEN** 系统返回 `user_id` 为 2001 的所有订单列表,按创建时间倒序排列
|
||||
|
||||
#### Scenario: 查询代理的订单
|
||||
|
||||
- **WHEN** 代理(ID 为 123)查询自己的订单,筛选已完成的套餐订单
|
||||
- **THEN** 系统返回 `agent_id` 为 123 且 `order_type` 为 1 且 `status` 为 3(已完成) 的订单列表
|
||||
|
||||
#### Scenario: 查询 IoT 卡的订单历史
|
||||
|
||||
- **WHEN** 运营人员查询 IoT 卡(ID 为 1001)的所有订单
|
||||
- **THEN** 系统返回 `iot_card_id` 为 1001 的所有订单列表,包含套餐购买记录
|
||||
|
||||
#### Scenario: 查询设备的订单历史
|
||||
|
||||
- **WHEN** 运营人员查询设备(ID 为 5001)的所有订单
|
||||
- **THEN** 系统返回 `device_id` 为 5001 的所有设备级套餐订单列表
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 订单数据校验
|
||||
|
||||
系统 SHALL 对订单数据进行校验,确保数据完整性和一致性,特别是支付金额的一致性。
|
||||
|
||||
**新增校验规则**:
|
||||
- `wallet_payment_amount`:必填,≥ 0,最多精确到分
|
||||
- `online_payment_amount`:必填,≥ 0,最多精确到分
|
||||
- `wallet_payment_amount` + `online_payment_amount` = `amount`(订单总金额)
|
||||
- 当 `payment_method` 为 "wallet" 时,`wallet_payment_amount` 必须 = `amount`
|
||||
- 当 `payment_method` 为 "online" 时,`online_payment_amount` 必须 = `amount`
|
||||
- 当 `payment_method` 为 "mixed" 时,两个字段都必须 > 0
|
||||
|
||||
#### Scenario: 支付金额不一致
|
||||
|
||||
- **WHEN** 创建订单,`amount` 为 5000,`wallet_payment_amount` 为 2000,`online_payment_amount` 为 2000
|
||||
- **THEN** 系统拒绝创建,返回错误信息"支付金额总和与订单金额不一致"
|
||||
|
||||
#### Scenario: 钱包支付时在线支付金额不为 0
|
||||
|
||||
- **WHEN** 创建订单,`payment_method` 为 "wallet",`wallet_payment_amount` 为 3000,`online_payment_amount` 为 0(正确),但用户错误地设置 `online_payment_amount` 为 100
|
||||
- **THEN** 系统拒绝创建,返回错误信息"钱包支付时在线支付金额必须为 0"
|
||||
|
||||
#### Scenario: 混合支付时钱包支付金额为 0
|
||||
|
||||
- **WHEN** 创建订单,`payment_method` 为 "mixed",`wallet_payment_amount` 为 0,`online_payment_amount` 为 5000
|
||||
- **THEN** 系统拒绝创建,返回错误信息"混合支付时钱包支付金额和在线支付金额都必须大于 0"
|
||||
|
||||
### Requirement: 订单支付处理
|
||||
|
||||
系统 SHALL 根据支付方式正确处理订单支付,包括钱包扣款、在线支付、混合支付等。
|
||||
|
||||
**钱包支付流程**:
|
||||
1. 检查钱包可用余额是否充足
|
||||
2. 冻结钱包余额(`frozen_balance` 增加)
|
||||
3. 创建订单,状态为"待支付"
|
||||
4. 订单完成后,扣减钱包余额(`balance` 减少,`frozen_balance` 减少),创建钱包明细记录
|
||||
5. 订单取消时,解冻钱包余额(`frozen_balance` 减少)
|
||||
|
||||
**在线支付流程**:
|
||||
1. 创建订单,状态为"待支付"
|
||||
2. 调用第三方支付接口
|
||||
3. 用户完成支付后,订单状态变更为"已支付"
|
||||
4. 订单完成后,订单状态变更为"已完成"
|
||||
|
||||
**混合支付流程**:
|
||||
1. 检查钱包可用余额是否充足(钱包支付部分)
|
||||
2. 冻结钱包余额
|
||||
3. 创建订单,状态为"待支付"
|
||||
4. 调用第三方支付接口(在线支付部分)
|
||||
5. 用户完成在线支付后,扣减钱包余额,订单状态变更为"已支付"
|
||||
6. 订单完成后,订单状态变更为"已完成"
|
||||
|
||||
#### Scenario: 钱包支付订单完成
|
||||
|
||||
- **WHEN** 用户使用钱包支付购买套餐,订单金额为 3000 分
|
||||
- **THEN** 系统:
|
||||
1. 创建订单,状态为"待支付",冻结钱包余额 3000 分
|
||||
2. 订单处理完成后,扣减钱包余额 3000 分,解冻 3000 分,创建钱包明细记录(类型为"扣款"),订单状态变更为"已完成"
|
||||
|
||||
#### Scenario: 混合支付订单完成
|
||||
|
||||
- **WHEN** 用户使用混合支付购买套餐,钱包支付 2000 分 + 在线支付 3000 分
|
||||
- **THEN** 系统:
|
||||
1. 创建订单,状态为"待支付",冻结钱包余额 2000 分
|
||||
2. 用户完成在线支付 3000 分后,扣减钱包余额 2000 分,解冻 2000 分,创建钱包明细记录,订单状态变更为"已支付"
|
||||
3. 订单处理完成后,订单状态变更为"已完成"
|
||||
|
||||
#### Scenario: 订单取消,解冻钱包余额
|
||||
|
||||
- **WHEN** 用户使用钱包支付创建订单,订单金额为 3000 分,然后取消订单
|
||||
- **THEN** 系统解冻钱包余额 3000 分(`frozen_balance` 减少 3000),订单状态变更为"已取消"
|
||||
|
||||
---
|
||||
|
||||
### 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`
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
# IoT Package Management
|
||||
|
||||
## Purpose
|
||||
|
||||
Manage IoT packages (data plans) for IoT cards and devices, including package definitions, real/virtual data coexistence, single-card packages, device-level packages with shared data pools, and agent package allocation.
|
||||
|
||||
This capability supports:
|
||||
- Package entity definition with real and virtual data types
|
||||
- Formal packages and addon packages (data top-ups)
|
||||
- Single-card package purchases
|
||||
- Device-level package purchases with shared data pool across all bound cards
|
||||
- Agent package allocation with retail pricing
|
||||
- Commission calculation (counted once for device-level packages regardless of card count)
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: 套餐实体定义
|
||||
|
||||
系统 SHALL 定义套餐(Package)实体,包含套餐的基本属性、定价、流量配置,以及用于客户端展示流量换算的 `virtual_ratio` 字段。
|
||||
|
||||
**核心概念**: 套餐只适用于 IoT 卡(ICCID),用户可以为单张 IoT 卡购买套餐,也可以为设备购买套餐(套餐分配到设备绑定的所有 IoT 卡,流量设备级共享)。
|
||||
|
||||
**实体字段**:
|
||||
- `id`: 套餐 ID(主键,BIGINT)
|
||||
- `package_code`: 套餐编码(VARCHAR(50),唯一)
|
||||
- `package_name`: 套餐名称(VARCHAR(255))
|
||||
- `series_id`: 套餐系列 ID(BIGINT,关联 package_series 表,用于组织套餐分组和配置一次性分佣)
|
||||
- `package_type`: 套餐类型(VARCHAR(20),"formal"-正式套餐 | "addon"-加油包)
|
||||
- `duration_months`: 套餐时长(INT,月数,1-月套餐 12-年套餐,加油包为 0)
|
||||
- `real_data_mb`: 真流量额度(BIGINT,MB 为单位,套餐标称总流量)
|
||||
- `virtual_data_mb`: 虚流量额度(BIGINT,MB 为单位,停机阈值,始终小于或等于真流量)
|
||||
- `data_amount_mb`: 总流量额度(BIGINT,MB 为单位,real_data_mb + virtual_data_mb)
|
||||
- `virtual_ratio`: 虚流量换算比例(DECIMAL(10,6),套餐创建时计算并存储,用于客户端展示)
|
||||
- `enable_virtual_data`: 是否启用虚流量(BOOLEAN,false 时 virtual_ratio=1.0)
|
||||
- `price`: 套餐价格(DECIMAL(10,2),元)
|
||||
- `status`: 套餐状态(INT,1-上架 2-下架)
|
||||
- `created_at`: 创建时间(TIMESTAMP,自动填充)
|
||||
- `updated_at`: 更新时间(TIMESTAMP,自动填充)
|
||||
|
||||
**virtual_ratio 计算规则**:
|
||||
- `enable_virtual_data = true` 且 `virtual_data_mb > 0`:`virtual_ratio = real_data_mb / virtual_data_mb`
|
||||
- 其他情况(未启用虚流量):`virtual_ratio = 1.0`
|
||||
- 套餐创建或更新时由 Service 层自动计算并存储,不由调用方传入
|
||||
|
||||
**virtual_ratio 使用场景**(展示换算):
|
||||
- `展示已使用 = 真已使用 × virtual_ratio`
|
||||
- `展示剩余 = real_data_mb - 展示已使用`
|
||||
- 目的:当真用量达到停机阈值(virtual_data_mb)时,客户看到的展示用量恰好等于 real_data_mb(100% 已使用)
|
||||
|
||||
**套餐类型说明**:
|
||||
- **正式套餐(formal)**: 每张 IoT 卡只能有一个有效的正式套餐,购买新的正式套餐会替换旧的
|
||||
- **加油包(addon)**: 每张 IoT 卡可以购买多个加油包,与正式套餐共存
|
||||
|
||||
#### Scenario: 创建月套餐(未启用虚流量)
|
||||
|
||||
- **WHEN** 平台创建月套餐,套餐编码为 "PKG-M-001",`enable_virtual_data = false`,`real_data_mb = 10240`
|
||||
- **THEN** 系统创建套餐记录,`virtual_ratio = 1.0`(未启用虚流量时无换算)
|
||||
|
||||
#### Scenario: 创建启用虚流量的套餐
|
||||
|
||||
- **WHEN** 平台创建套餐,`enable_virtual_data = true`,`real_data_mb = 10240`(10G),`virtual_data_mb = 9216`(9G)
|
||||
- **THEN** 系统自动计算并存储 `virtual_ratio = 10240 / 9216 ≈ 1.111111`
|
||||
|
||||
#### Scenario: 展示流量换算正确
|
||||
|
||||
- **WHEN** 客户的卡真已使用 = 9216 MB(已达停机阈值),`real_data_mb = 10240`,`virtual_ratio = 1.111111`
|
||||
- **THEN** 展示已使用 = 9216 × 1.111111 ≈ 10240 MB,展示剩余 = 0 MB,客户看到"已用 10G / 共 10G"
|
||||
|
||||
#### Scenario: 创建年套餐
|
||||
|
||||
- **WHEN** 平台创建年套餐,套餐编码为 "PKG-Y-001",套餐名称为 "年套餐 120GB",套餐系列 ID 为 1,类型为正式套餐,时长为 12 个月,真流量为 122880 MB,虚流量为 0,价格为 300.00 元
|
||||
- **THEN** 系统创建套餐记录,`package_code` 为 "PKG-Y-001",`series_id` 为 1,`package_type` 为 "formal",`duration_months` 为 12,`real_data_mb` 为 122880,`virtual_data_mb` 为 0,`data_amount_mb` 为 122880,`price` 为 300.00,`virtual_ratio` 为 1.0
|
||||
|
||||
#### Scenario: 创建流量加油包
|
||||
|
||||
- **WHEN** 平台创建加油包,套餐编码为 "PKG-ADD-001",套餐名称为 "流量包 5GB",套餐系列 ID 为 2,类型为加油包,时长为 0,真流量为 5120 MB,虚流量为 0,价格为 10.00 元
|
||||
- **THEN** 系统创建套餐记录,`package_code` 为 "PKG-ADD-001",`series_id` 为 2,`package_type` 为 "addon",`duration_months` 为 0,`real_data_mb` 为 5120,`virtual_data_mb` 为 0,`data_amount_mb` 为 5120,`price` 为 10.00,`virtual_ratio` 为 1.0
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 套餐流量类型和真虚流量共存
|
||||
|
||||
系统 SHALL 支持真流量和虚流量两种流量类型,两者可以共存于同一套餐中。
|
||||
|
||||
**流量类型定义**:
|
||||
- **真流量(real_data_mb)**: 实际可用的流量,可在运营商网络中使用
|
||||
- **虚流量(virtual_data_mb)**: 虚拟流量,用于停机判断(虚流量用完后停机,即使真流量还有剩余)
|
||||
- **总流量(data_amount_mb)**: 真流量 + 虚流量的总和
|
||||
|
||||
**重要规则**:
|
||||
- 真流量和虚流量可以同时存在于一个套餐中
|
||||
- 停机判断基于虚流量(虚流量用完后停机)
|
||||
- 套餐可以只有真流量、只有虚流量、或两者都有
|
||||
|
||||
#### Scenario: 创建真虚流量共存的套餐
|
||||
|
||||
- **WHEN** 平台创建套餐,真流量为 8000 MB,虚流量为 2000 MB
|
||||
- **THEN** 系统创建套餐记录,`real_data_mb` 为 8000,`virtual_data_mb` 为 2000,`data_amount_mb` 为 10000
|
||||
|
||||
#### Scenario: 创建纯真流量套餐
|
||||
|
||||
- **WHEN** 平台创建套餐,真流量为 10240 MB,虚流量为 0
|
||||
- **THEN** 系统创建套餐记录,`real_data_mb` 为 10240,`virtual_data_mb` 为 0,`data_amount_mb` 为 10240
|
||||
|
||||
#### Scenario: 创建纯虚流量套餐
|
||||
|
||||
- **WHEN** 平台创建套餐,真流量为 0,虚流量为 10240 MB
|
||||
- **THEN** 系统创建套餐记录,`real_data_mb` 为 0,`virtual_data_mb` 为 10240,`data_amount_mb` 为 10240
|
||||
|
||||
#### Scenario: 虚流量用完停机
|
||||
|
||||
- **WHEN** 套餐的虚流量为 2000 MB,用户已使用 2000 MB 虚流量,但真流量还剩余 5000 MB
|
||||
- **THEN** 系统判断虚流量已用完,触发停机操作,即使真流量还有剩余
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 单卡套餐购买
|
||||
|
||||
系统 SHALL 支持用户为单张 IoT 卡购买套餐。
|
||||
|
||||
**购买规则**:
|
||||
- 每张 IoT 卡只能有一个有效的正式套餐
|
||||
- 购买新的正式套餐会替换旧的正式套餐
|
||||
- 可以同时购买多个加油包
|
||||
- 套餐购买后创建套餐订单记录
|
||||
|
||||
#### Scenario: 为 IoT 卡购买正式套餐
|
||||
|
||||
- **WHEN** 用户为 IoT 卡(ICCID 为 "8986...")购买月套餐(套餐 ID 为 1001),价格为 30.00 元
|
||||
- **THEN** 系统创建套餐订单,`order_type` 为 1(套餐订单),`iot_card_id` 为 IoT 卡 ID,`package_id` 为 1001,`amount` 为 30.00
|
||||
|
||||
#### Scenario: 为 IoT 卡购买加油包
|
||||
|
||||
- **WHEN** 用户为 IoT 卡购买流量加油包(套餐 ID 为 2001),价格为 10.00 元
|
||||
- **THEN** 系统创建套餐订单,IoT 卡的正式套餐保持不变,加油包作为额外套餐生效
|
||||
|
||||
#### Scenario: 购买新正式套餐替换旧套餐
|
||||
|
||||
- **WHEN** 用户为 IoT 卡购买新的月套餐,该 IoT 卡已有月套餐
|
||||
- **THEN** 系统创建新订单,旧的正式套餐失效,新套餐生效
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 设备级套餐购买和流量共享
|
||||
|
||||
系统 SHALL 支持用户为设备购买套餐,套餐分配到设备绑定的所有 IoT 卡,流量设备级共享。
|
||||
|
||||
**设备套餐业务规则**:
|
||||
- 用户为设备购买套餐时,套餐会分配到设备绑定的**所有 IoT 卡**(1-4 张)
|
||||
- 套餐的流量是**设备级别共享的**(例如 3000G/月共享,不管用哪张卡)
|
||||
- 分佣**只计算一次**(不按卡数倍增)
|
||||
- 订单表通过 `device_id` 字段关联设备,通过 `device_sim_bindings` 表查找绑定的所有 IoT 卡
|
||||
- 设备购买的套餐不受单卡套餐限制(设备套餐和单卡套餐独立管理)
|
||||
|
||||
**流量共享机制**:
|
||||
- 设备绑定的所有 IoT 卡共享套餐流量池
|
||||
- 任意一张 IoT 卡使用流量都会从共享池扣除
|
||||
- 流量池耗尽后,所有绑定的 IoT 卡都无法使用
|
||||
|
||||
**订单记录**:
|
||||
- 订单表 `device_id` 字段记录设备 ID(设备级套餐订单)
|
||||
- 订单表 `iot_card_id` 字段为 NULL(不关联具体 IoT 卡)
|
||||
- 通过 `device_sim_bindings` 表查询设备绑定的所有 IoT 卡
|
||||
|
||||
#### Scenario: 为设备购买套餐
|
||||
|
||||
- **WHEN** 用户为设备(ID 为 1001,绑定 3 张 IoT 卡)购买年套餐,价格为 399.00 元,流量为 3000G/月
|
||||
- **THEN** 系统创建套餐订单,`order_type` 为 1(套餐订单),`device_id` 为 1001,`iot_card_id` 为 NULL,`amount` 为 399.00,套餐分配到 3 张绑定的 IoT 卡
|
||||
|
||||
#### Scenario: 设备流量共享
|
||||
|
||||
- **WHEN** 设备(绑定 3 张 IoT 卡)购买套餐 3000G/月,其中一张 IoT 卡使用 1000G 流量
|
||||
- **THEN** 流量池剩余 2000G,其他两张 IoT 卡可以使用剩余的 2000G
|
||||
|
||||
#### Scenario: 设备套餐分佣只计算一次
|
||||
|
||||
- **WHEN** 设备(绑定 3 张 IoT 卡)购买套餐,长期佣金为 100.00 元
|
||||
- **THEN** 系统创建一条分佣记录,金额为 100.00 元(不是 3 × 100.00 元)
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 套餐分配给代理
|
||||
|
||||
系统 SHALL 支持将套餐分配给代理商,代理可以在平台设置的成本价基础上加价销售。
|
||||
|
||||
**分配规则**:
|
||||
- 平台为套餐设置成本价(分配给代理的价格)
|
||||
- 代理可以在成本价基础上加价,但不能超过成本价的 2 倍
|
||||
- 分配记录存储在 `agent_package_allocations` 表
|
||||
|
||||
**agent_package_allocations 表**:
|
||||
- `id`: 分配记录 ID(主键,BIGINT)
|
||||
- `agent_id`: 代理用户 ID(BIGINT)
|
||||
- `package_id`: 套餐 ID(BIGINT)
|
||||
- `cost_price`: 成本价(DECIMAL(10,2),平台给代理的价格)
|
||||
- `retail_price`: 零售价(DECIMAL(10,2),代理设置的终端销售价格)
|
||||
- `status`: 分配状态(INT,1-有效 2-无效)
|
||||
- `created_at`: 创建时间(TIMESTAMP,自动填充)
|
||||
- `updated_at`: 更新时间(TIMESTAMP,自动填充)
|
||||
|
||||
#### Scenario: 平台分配套餐给代理
|
||||
|
||||
- **WHEN** 平台将套餐(ID 为 1001)分配给代理(用户 ID 为 123),成本价为 25.00 元
|
||||
- **THEN** 系统创建分配记录,`agent_id` 为 123,`package_id` 为 1001,`cost_price` 为 25.00,状态为 1(有效)
|
||||
|
||||
#### Scenario: 代理设置零售价
|
||||
|
||||
- **WHEN** 代理(用户 ID 为 123)为套餐(ID 为 1001)设置零售价为 30.00 元
|
||||
- **THEN** 系统更新分配记录,`retail_price` 为 30.00
|
||||
|
||||
#### Scenario: 代理零售价超过 2 倍成本价
|
||||
|
||||
- **WHEN** 代理设置零售价为 60.00 元,成本价为 25.00 元(2 倍为 50.00 元)
|
||||
- **THEN** 系统拒绝设置,返回错误信息"零售价不能超过成本价的 2 倍"
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 套餐数据校验
|
||||
|
||||
系统 SHALL 对套餐数据进行校验,确保数据完整性和一致性。
|
||||
|
||||
**校验规则**:
|
||||
- 套餐编码(package_code):必填,长度 1-50 字符,唯一
|
||||
- 套餐名称(package_name):必填,长度 1-255 字符
|
||||
- 套餐系列 ID(series_id):必填,≥ 1,必须是有效的套餐系列 ID
|
||||
- 套餐类型(package_type):必填,枚举值 "formal" | "addon"
|
||||
- 套餐时长(duration_months):必填,≥ 0(正式套餐 ≥ 1,加油包为 0)
|
||||
- 真流量额度(real_data_mb):可选,≥ 0
|
||||
- 虚流量额度(virtual_data_mb):可选,≥ 0
|
||||
- 总流量额度(data_amount_mb):必填,≥ 0,必须等于 real_data_mb + virtual_data_mb
|
||||
- 套餐价格(price):必填,≥ 0,最多 2 位小数
|
||||
- 状态(status):必填,枚举值 1(上架) | 2(下架)
|
||||
|
||||
#### Scenario: 创建套餐时价格为负数
|
||||
|
||||
- **WHEN** 平台创建套餐,价格为 -10.00
|
||||
- **THEN** 系统拒绝创建,返回错误信息"套餐价格必须 ≥ 0"
|
||||
|
||||
#### Scenario: 创建套餐时套餐编码重复
|
||||
|
||||
- **WHEN** 平台创建套餐,套餐编码为已存在的 "PKG-M-001"
|
||||
- **THEN** 系统拒绝创建,返回错误信息"套餐编码已存在"
|
||||
|
||||
#### Scenario: 创建正式套餐时时长为 0
|
||||
|
||||
- **WHEN** 平台创建正式套餐,套餐类型为 "formal",时长为 0
|
||||
- **THEN** 系统拒绝创建,返回错误信息"正式套餐时长必须 ≥ 1"
|
||||
@@ -1,73 +0,0 @@
|
||||
# legacy-cleanup Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change remove-legacy-rbac-cleanup. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: 基于店铺的数据权限过滤
|
||||
|
||||
系统 SHALL 在 Store 层的 List 方法中自动应用基于店铺的数据权限过滤:代理账号只能查询自己店铺及下级店铺的数据。
|
||||
|
||||
#### Scenario: 代理账号查询数据
|
||||
- **WHEN** 代理账号(user_type=3,shop_id=X)查询业务数据列表
|
||||
- **THEN** 系统自动添加 WHERE 条件:`shop_id IN (X, 及X的所有下级店铺ID)`
|
||||
|
||||
#### Scenario: 企业账号查询数据
|
||||
- **WHEN** 企业账号(user_type=4,enterprise_id=Y)查询业务数据列表
|
||||
- **THEN** 系统自动添加 WHERE 条件:`enterprise_id = Y`
|
||||
|
||||
#### Scenario: 平台用户跳过过滤
|
||||
- **WHEN** 平台用户(user_type=1 或 2)查询业务数据列表
|
||||
- **THEN** 系统不添加任何过滤条件,返回所有数据
|
||||
|
||||
#### Scenario: C端用户跳过过滤
|
||||
- **WHEN** context 中包含 SkipOwnerFilter 标记(C端用户)
|
||||
- **THEN** 系统跳过 shop_id/enterprise_id 过滤,由业务代码自行处理
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 认证中间件适配新用户体系
|
||||
|
||||
系统 SHALL 更新认证中间件以支持新的用户类型和组织关联,在 context 中正确设置用户信息。
|
||||
|
||||
#### Scenario: B端用户认证
|
||||
- **WHEN** B端 Token 验证成功
|
||||
- **THEN** 中间件在 context 中设置:user_id、user_type、shop_id(代理)或 enterprise_id(企业)
|
||||
|
||||
#### Scenario: C端用户认证
|
||||
- **WHEN** C端 Token 验证成功
|
||||
- **THEN** 中间件在 context 中设置:customer_id、SkipOwnerFilter=true
|
||||
|
||||
#### Scenario: Token类型不匹配
|
||||
- **WHEN** C端 Token 访问 /api/v1/ 或 B端 Token 访问 /api/c/
|
||||
- **THEN** 中间件返回 401 Unauthorized
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 权限校验适配新体系
|
||||
|
||||
系统 SHALL 更新权限校验中间件以支持角色类型匹配和权限端口校验。
|
||||
|
||||
#### Scenario: 权限端口校验
|
||||
- **WHEN** 用户访问权限保护的接口
|
||||
- **THEN** 中间件检查用户权限的 platform 字段是否与请求来源匹配
|
||||
|
||||
#### Scenario: 超级管理员跳过权限
|
||||
- **WHEN** 超级管理员(user_type=1)访问任意接口
|
||||
- **THEN** 中间件跳过权限校验,允许访问
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 访问日志记录新字段
|
||||
|
||||
系统 SHALL 在访问日志中记录新的用户体系字段,便于问题排查和数据分析。
|
||||
|
||||
#### Scenario: B端用户访问日志
|
||||
- **WHEN** B端用户发起 HTTP 请求
|
||||
- **THEN** 访问日志包含字段:user_id、user_type、shop_id(或 enterprise_id)
|
||||
|
||||
#### Scenario: C端用户访问日志
|
||||
- **WHEN** C端用户发起 HTTP 请求
|
||||
- **THEN** 访问日志包含字段:customer_id、标记为 C 端用户
|
||||
|
||||
---
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
# Purpose
|
||||
|
||||
本规范定义登录接口返回菜单树和按钮权限的需求。
|
||||
|
||||
登录接口将在响应中返回三个权限相关字段:
|
||||
- `menus`: 菜单树(树形结构,用于渲染侧边栏)
|
||||
- `buttons`: 按钮权限码列表(扁平数组,用于控制按钮显示)
|
||||
- `permissions`: 所有权限码列表(扁平数组,保留向后兼容性)
|
||||
|
||||
这使得前端可以直接使用菜单树渲染侧边栏,无需二次处理,同时保持与现有系统的向后兼容性。
|
||||
|
||||
# Requirements
|
||||
|
||||
## Requirement: 登录响应包含菜单树和按钮权限
|
||||
|
||||
登录接口 SHALL 在响应中返回三个权限相关字段:
|
||||
- `menus`: 菜单树(树形结构,用于渲染侧边栏)
|
||||
- `buttons`: 按钮权限码列表(扁平数组,用于控制按钮显示)
|
||||
- `permissions`: 所有权限码列表(扁平数组,保留向后兼容性)
|
||||
|
||||
适用端点:
|
||||
- `POST /api/admin/login`(后台登录)
|
||||
- `POST /api/h5/login`(H5 端登录)
|
||||
|
||||
### Scenario: 普通用户登录成功
|
||||
|
||||
- **WHEN** 普通用户(非超级管理员)登录成功
|
||||
- **THEN** 响应包含 `menus` 数组(包含用户有权限的菜单树)
|
||||
- **THEN** 响应包含 `buttons` 数组(包含用户有权限的按钮权限码)
|
||||
- **THEN** 响应包含 `permissions` 数组(包含所有权限码)
|
||||
- **THEN** `menus` 数组为树形结构,每个节点包含 `id`, `perm_code`, `name`, `url`, `sort`, `children` 字段
|
||||
|
||||
### Scenario: 用户无任何权限
|
||||
|
||||
- **WHEN** 用户登录成功但未分配任何角色或权限
|
||||
- **THEN** 响应包含空的 `menus` 数组 `[]`
|
||||
- **THEN** 响应包含空的 `buttons` 数组 `[]`
|
||||
- **THEN** 响应包含空的 `permissions` 数组 `[]`
|
||||
|
||||
## Requirement: 菜单权限构建树形结构
|
||||
|
||||
系统 SHALL 基于权限表的 `perm_type` 和 `parent_id` 字段构建菜单树:
|
||||
- 只包含 `perm_type = 1`(菜单权限)的权限记录
|
||||
- 根据 `parent_id` 字段构建父子关系
|
||||
- 根节点为 `parent_id = NULL` 或 `parent_id = 0` 的权限
|
||||
- 子节点追加到父节点的 `children` 数组中
|
||||
|
||||
### Scenario: 构建两级菜单树
|
||||
|
||||
- **WHEN** 用户有以下权限:
|
||||
- ID=1, perm_code="user:menu", perm_type=1, parent_id=NULL(用户管理)
|
||||
- ID=2, perm_code="user:list:menu", perm_type=1, parent_id=1(用户列表)
|
||||
- **THEN** `menus` 数组包含 1 个根节点(用户管理)
|
||||
- **THEN** 根节点的 `children` 数组包含 1 个子节点(用户列表)
|
||||
|
||||
### Scenario: 孤儿节点提升为根节点
|
||||
|
||||
- **WHEN** 用户有子菜单权限(perm_code="user:list:menu", parent_id=1)
|
||||
- **WHEN** 用户没有父菜单权限(ID=1 不在权限列表中)
|
||||
- **THEN** 子菜单提升为根节点,出现在 `menus` 数组的顶层
|
||||
- **THEN** 子菜单的 `children` 数组为空
|
||||
|
||||
## Requirement: 按钮权限提取扁平列表
|
||||
|
||||
系统 SHALL 提取所有 `perm_type = 2`(按钮权限)的权限码作为 `buttons` 数组:
|
||||
- 只包含 `perm_code` 字段值
|
||||
- 不构建树形结构
|
||||
- 按原始顺序返回
|
||||
|
||||
### Scenario: 提取按钮权限码
|
||||
|
||||
- **WHEN** 用户有以下权限:
|
||||
- perm_code="user:create", perm_type=2
|
||||
- perm_code="user:update", perm_type=2
|
||||
- perm_code="user:delete", perm_type=2
|
||||
- **THEN** `buttons` 数组包含 `["user:create", "user:update", "user:delete"]`
|
||||
|
||||
## Requirement: 平台过滤
|
||||
|
||||
系统 SHALL 根据登录请求的 `device` 参数过滤权限的 `platform` 字段:
|
||||
- `platform = "all"` 的权限对所有端口可见
|
||||
- `platform = "web"` 的权限只在 `device = "web"` 时可见
|
||||
- `platform = "h5"` 的权限只在 `device = "h5"` 时可见
|
||||
- 未指定 `device` 参数时默认为 `"web"`
|
||||
|
||||
### Scenario: Web 后台登录过滤 H5 菜单
|
||||
|
||||
- **WHEN** 用户登录时 `device = "web"`
|
||||
- **WHEN** 用户有以下权限:
|
||||
- perm_code="dashboard:menu", perm_type=1, platform="all"
|
||||
- perm_code="user:menu", perm_type=1, platform="web"
|
||||
- perm_code="mobile:menu", perm_type=1, platform="h5"
|
||||
- **THEN** `menus` 数组包含 "dashboard:menu" 和 "user:menu"
|
||||
- **THEN** `menus` 数组不包含 "mobile:menu"(H5 专属菜单被过滤)
|
||||
|
||||
### Scenario: H5 端登录过滤 Web 菜单
|
||||
|
||||
- **WHEN** 用户登录时 `device = "h5"`
|
||||
- **WHEN** 用户有以下权限:
|
||||
- perm_code="mobile:menu", perm_type=1, platform="h5"
|
||||
- perm_code="user:menu", perm_type=1, platform="web"
|
||||
- perm_code="common:menu", perm_type=1, platform="all"
|
||||
- **THEN** `menus` 数组包含 "mobile:menu" 和 "common:menu"
|
||||
- **THEN** `menus` 数组不包含 "user:menu"(Web 专属菜单被过滤)
|
||||
|
||||
## Requirement: 超级管理员获取所有权限
|
||||
|
||||
系统 SHALL 为超级管理员(`user_type = 1`)返回所有菜单和按钮权限:
|
||||
- 查询数据库中所有 `status = 1`(启用)的权限
|
||||
- 仍然应用平台过滤(根据 `device` 参数)
|
||||
- 不查询角色权限关联表
|
||||
|
||||
### Scenario: 超级管理员登录
|
||||
|
||||
- **WHEN** 超级管理员(user_type=1)登录
|
||||
- **WHEN** 数据库包含 100 个启用的权限(50 个菜单 + 50 个按钮)
|
||||
- **WHEN** 登录时 `device = "web"`
|
||||
- **THEN** `menus` 数组包含所有 `platform="all"` 或 `platform="web"` 的菜单权限
|
||||
- **THEN** `buttons` 数组包含所有 `platform="all"` 或 `platform="web"` 的按钮权限
|
||||
- **THEN** 不包含 `platform="h5"` 的权限
|
||||
|
||||
## Requirement: 菜单排序
|
||||
|
||||
菜单树 SHALL 根据权限表的 `sort` 字段排序:
|
||||
- 同级菜单按 `sort` 字段升序排列
|
||||
- 子菜单在其父节点的 `children` 数组中按 `sort` 排序
|
||||
- 递归应用到所有层级
|
||||
|
||||
### Scenario: 菜单按 sort 字段排序
|
||||
|
||||
- **WHEN** 用户有以下权限:
|
||||
- perm_code="order:menu", sort=3
|
||||
- perm_code="user:menu", sort=1
|
||||
- perm_code="dashboard:menu", sort=2
|
||||
- **THEN** `menus` 数组的顺序为 `["user:menu", "dashboard:menu", "order:menu"]`
|
||||
|
||||
### Scenario: 子菜单按 sort 字段排序
|
||||
|
||||
- **WHEN** 父菜单 "user:menu" 有三个子菜单:
|
||||
- "user:list:menu", sort=10
|
||||
- "user:role:menu", sort=5
|
||||
- "user:dept:menu", sort=8
|
||||
- **THEN** 父菜单的 `children` 数组顺序为 `["user:role:menu", "user:dept:menu", "user:list:menu"]`
|
||||
|
||||
## Requirement: GetMe 接口不返回菜单
|
||||
|
||||
`GET /api/admin/me` 和 `GET /api/h5/me` 接口 SHALL NOT 返回 `menus` 和 `buttons` 字段:
|
||||
- 只返回 `user` 和 `permissions` 字段(现有行为保持不变)
|
||||
- 避免频繁查询和构建菜单树
|
||||
|
||||
### Scenario: 调用 GetMe 接口
|
||||
|
||||
- **WHEN** 已登录用户调用 `GET /api/admin/me`
|
||||
- **THEN** 响应包含 `user` 对象
|
||||
- **THEN** 响应包含 `permissions` 数组(权限码列表)
|
||||
- **THEN** 响应不包含 `menus` 字段
|
||||
- **THEN** 响应不包含 `buttons` 字段
|
||||
|
||||
## Requirement: MenuNode 数据结构
|
||||
|
||||
系统 SHALL 定义 `MenuNode` DTO 结构体,包含以下字段:
|
||||
- `id` (uint): 权限 ID
|
||||
- `perm_code` (string): 权限码(如 "user:menu")
|
||||
- `name` (string): 菜单名称(如 "用户管理")
|
||||
- `url` (string): 路由路径(如 "/users")
|
||||
- `sort` (int): 排序值
|
||||
- `children` ([]MenuNode): 子菜单数组(递归结构)
|
||||
|
||||
所有字段 MUST 包含 JSON 标签。
|
||||
|
||||
### Scenario: MenuNode 结构定义
|
||||
|
||||
- **WHEN** 定义 MenuNode 结构体
|
||||
- **THEN** 包含 `id` 字段,类型为 `uint`,JSON 标签为 `"id"`
|
||||
- **THEN** 包含 `perm_code` 字段,类型为 `string`,JSON 标签为 `"perm_code"`
|
||||
- **THEN** 包含 `name` 字段,类型为 `string`,JSON 标签为 `"name"`
|
||||
- **THEN** 包含 `url` 字段,类型为 `string`,JSON 标签为 `"url"`
|
||||
- **THEN** 包含 `sort` 字段,类型为 `int`,JSON 标签为 `"sort"`
|
||||
- **THEN** 包含 `children` 字段,类型为 `[]MenuNode`,JSON 标签为 `"children"`
|
||||
|
||||
## Requirement: 响应格式向后兼容
|
||||
|
||||
系统 SHALL 保留原有 `permissions` 字段,确保向后兼容:
|
||||
- 登录响应同时包含 `permissions`, `menus`, `buttons` 三个字段
|
||||
- 前端可以选择使用新字段或继续使用旧字段
|
||||
- `permissions` 包含所有权限码(菜单 + 按钮)
|
||||
|
||||
### Scenario: 向后兼容性验证
|
||||
|
||||
- **WHEN** 用户登录成功
|
||||
- **WHEN** 用户有 3 个菜单权限和 2 个按钮权限
|
||||
- **THEN** 响应包含 `permissions` 数组,长度为 5
|
||||
- **THEN** 响应包含 `menus` 数组(树形结构)
|
||||
- **THEN** 响应包含 `buttons` 数组,长度为 2
|
||||
- **THEN** 旧版前端仍可使用 `permissions` 字段正常工作
|
||||
|
||||
## Requirement: 性能要求
|
||||
|
||||
菜单树构建逻辑 MUST 满足以下性能要求:
|
||||
- 时间复杂度为 O(n),n 为权限数量
|
||||
- 登录响应时间增加 < 50ms(在权限数量 < 100 的场景下)
|
||||
- 不影响 GetMe 接口性能(未修改)
|
||||
|
||||
### Scenario: 性能基准测试
|
||||
|
||||
- **WHEN** 用户有 50 个权限(30 个菜单 + 20 个按钮)
|
||||
- **WHEN** 菜单最大层级为 3 级
|
||||
- **THEN** 登录接口响应时间增加 < 50ms
|
||||
- **THEN** 菜单树构建时间 < 10ms
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user