修复一些问题,主要是生效套餐
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m16s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m16s
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-11
|
||||
176
openspec/changes/archive/2026-05-11-add-agent-open-api/design.md
Normal file
176
openspec/changes/archive/2026-05-11-add-agent-open-api/design.md
Normal file
@@ -0,0 +1,176 @@
|
||||
## Context
|
||||
|
||||
当前系统已经具备代理套餐列表、后台钱包支付订单、主钱包流水、卡实名/状态和套餐使用记录等能力,但这些能力主要挂在 `/api/admin` 或 C 端接口下,认证方式依赖后台 JWT 或个人客户 JWT。代理商第三方系统对接需要长期稳定的开放接口,并且调用方需要通过请求签名保证参数未被篡改。
|
||||
|
||||
本次设计新增 `/api/open/v1` 代理开放接口域。开放接口不直接暴露后台 JWT,也不改变现有后台接口契约,而是通过独立中间件把校验通过的后台代理账号转换为内部代理上下文,再复用已有 Service 能力。所有业务实现仍遵循 Handler → Service → Store → Model 分层。
|
||||
|
||||
约束:
|
||||
- 复用后台代理账号密码作为当前阶段的对接凭据。
|
||||
- 后台账号密码以 bcrypt 存储,服务端不能反推出明文密码。
|
||||
- 流量展示不能暴露虚流量、倍率、停机阈值等内部语义。
|
||||
- 开放接口暂只考虑单卡查询;套餐购买接口支持多卡输入,但按单卡分别下单。
|
||||
- 不引入新依赖,签名使用 Go 标准库 HMAC-SHA256。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
|
||||
- 提供代理开放接口认证中间件,校验账号、密码、时间戳、随机串和签名。
|
||||
- 提供卡流量查询、卡状态查询、卡实名状态查询、代理套餐列表、钱包套餐购买、钱包余额、钱包流水接口。
|
||||
- 将开放接口请求映射为代理账号上下文,使现有 `middleware.CanManageShop`、套餐分配过滤和钱包扣款逻辑继续生效。
|
||||
- 流量字段只对外展示代理可理解的真实业务口径:总流量、已用流量、剩余流量。
|
||||
- 套餐购买按卡独立处理,允许部分成功并返回失败卡列表。
|
||||
- 保持分页、错误码、响应格式、日志和文档生成的一致性。
|
||||
|
||||
**Non-Goals:**
|
||||
|
||||
- 不提供设备开放接口。
|
||||
- 不支持单次购买多个套餐编码。
|
||||
- 不引入独立 API key / API secret;当前阶段只复用后台代理账号密码。
|
||||
- 不暴露虚流量字段、展示倍率、停机阈值或内部扣减阈值。
|
||||
- 不改造现有后台 `/api/admin` 接口。
|
||||
- 不讨论或新增自动化测试文件。
|
||||
|
||||
## Decisions
|
||||
|
||||
### 决策 1:新增独立开放接口域 `/api/open/v1`
|
||||
|
||||
选择:新增 `internal/routes/open.go` 注册 `/api/open/v1` 路由组,并新增 `internal/handler/openapi` 包承载 Handler。
|
||||
|
||||
理由:
|
||||
- 与后台管理端和 C 端接口隔离,避免第三方系统依赖后台 JWT。
|
||||
- 便于统一挂载开放接口签名中间件、访问日志和限流策略。
|
||||
- 不改变现有后台接口路径和权限语义。
|
||||
|
||||
替代方案:
|
||||
- 直接开放 `/api/admin`:会暴露后台管理语义,且要求三方维护后台 JWT,不适合长期对接。
|
||||
|
||||
### 决策 2:复用后台代理账号密码,不新增开放接口授权表
|
||||
|
||||
选择:开放接口认证直接复用后台账号表和店铺数据权限。只要后台账号存在、账号类型为代理账号、账号状态启用,并且账号已绑定有效代理店铺,即默认允许调用 `/api/open/v1`。不新增 `tb_agent_open_api_account` 或其他开放接口账号授权表。
|
||||
|
||||
理由:
|
||||
- 满足当前“复用后台代理账号密码”的低接入成本。
|
||||
- 业务口径明确为所有代理店铺都能使用开放接口,不需要平台额外维护启用记录。
|
||||
- 继续使用现有账号禁用和店铺状态作为统一入口控制,避免同一代理账号出现后台可用但开放接口不可用的二义性。
|
||||
- 不需要保存明文密码或第二套密钥。
|
||||
|
||||
替代方案:
|
||||
- 新增开放接口授权表:增加配置和运维成本,且与“所有代理店铺都能使用开放接口”的要求冲突。
|
||||
- 新增 API secret:更安全,但当前用户已确认先复用后台账号密码。
|
||||
|
||||
### 决策 3:签名中间件先校验密码,再用本次请求密码重算 HMAC
|
||||
|
||||
选择:客户端在 Header 传入:
|
||||
|
||||
```text
|
||||
X-Agent-Account
|
||||
X-Agent-Password
|
||||
X-Agent-Timestamp
|
||||
X-Agent-Nonce
|
||||
X-Agent-Sign
|
||||
```
|
||||
|
||||
签名原文:
|
||||
|
||||
```text
|
||||
METHOD + "\n" +
|
||||
PATH + "\n" +
|
||||
canonical_query_without_sign + "\n" +
|
||||
SHA256(raw_body) + "\n" +
|
||||
timestamp + "\n" +
|
||||
nonce + "\n" +
|
||||
account
|
||||
```
|
||||
|
||||
签名值:
|
||||
|
||||
```text
|
||||
hex(HMAC-SHA256(password, sign_payload))
|
||||
```
|
||||
|
||||
服务端流程:
|
||||
1. 按账号查询后台账号。
|
||||
2. 校验账号类型必须为代理账号,账号状态必须启用,并且已绑定启用状态的代理店铺。
|
||||
3. 使用 bcrypt 校验 `X-Agent-Password`。
|
||||
4. 校验时间戳在允许时间窗内。
|
||||
5. 使用 Redis `SET NX` 记录 nonce,防止重放。
|
||||
6. 使用本次请求密码重算 HMAC 并进行常量时间比较。
|
||||
7. 注入 `ContextKeyUserID`、`ContextKeyUserType`、`ContextKeyShopID`、`ContextKeySubordinateShopIDs` 等代理上下文。
|
||||
|
||||
理由:
|
||||
- 由于数据库中没有明文密码,服务端只能用请求中的明文密码重算签名。
|
||||
- HMAC 覆盖 method、path、query、body、timestamp、nonce、account,能发现请求参数或 body 被篡改。
|
||||
- Redis nonce 防止有效时间窗内重放。
|
||||
|
||||
替代方案:
|
||||
- `MD5(account + params + password)`:实现简单但抗碰撞和密钥使用方式不如 HMAC。
|
||||
- 服务端保存可逆密码:不符合安全要求。
|
||||
|
||||
安全约束:
|
||||
- 开放接口必须部署在 HTTPS 下;否则后台密码会被链路暴露。
|
||||
- 日志、访问日志和错误日志必须脱敏 `X-Agent-Password`、`X-Agent-Sign`。
|
||||
|
||||
### 决策 4:开放接口业务服务做编排,核心能力复用现有 Service
|
||||
|
||||
选择:新增 `internal/service/agent_open_api.Service` 作为编排层,依赖注入现有服务和 Store:
|
||||
- 资产/卡解析:复用资产解析或 IoT 卡 Store 的标识符查询能力。
|
||||
- 套餐列表:复用 `package.Service.List` 的代理上下文过滤。
|
||||
- 钱包购买:复用 `order.Service.CreateAdminOrder`,为每张卡构造 `CreateAdminOrderRequest`。
|
||||
- 主钱包流水:复用 `ShopCommissionService.ListMainWalletTransactions` 或抽取其查询逻辑。
|
||||
- 主钱包余额:复用 `AgentWalletStore.GetMainWallet`。
|
||||
|
||||
理由:
|
||||
- 保持 Handler 只负责参数解析和响应。
|
||||
- 避免复制后台复杂的代理套餐权限、钱包扣款、套餐激活和分佣规则。
|
||||
- 让开放接口与后台业务规则保持一致。
|
||||
|
||||
替代方案:
|
||||
- 为开放接口重新实现购买逻辑:容易绕过幂等、钱包乐观锁、套餐激活和佣金规则。
|
||||
|
||||
### 决策 5:流量展示只输出真实业务口径,不输出内部虚流量口径
|
||||
|
||||
选择:卡流量查询按 `PackageUsage` 快照计算:
|
||||
- `total_flow_mb = data_limit_mb`
|
||||
- `used_flow_mb = min(data_usage_mb * display_gain_ratio_snapshot, data_limit_mb)`
|
||||
- `remaining_flow_mb = max(total_flow_mb - used_flow_mb, 0)`
|
||||
|
||||
对外字段使用 `total_flow_mb`、`used_flow_mb`、`remaining_flow_mb`,不出现 `virtual_*`、`ratio`、`threshold`、`reduction`。
|
||||
|
||||
理由:
|
||||
- 符合“让代理觉得这就是真正的流量”的业务要求。
|
||||
- 复用现有快照字段,避免套餐配置变更影响历史使用记录。
|
||||
- 不泄露系统内部停机保护阈值。
|
||||
|
||||
替代方案:
|
||||
- 直接返回 `BuildTrafficMetrics` 的所有字段:会暴露虚流量字段,不符合要求。
|
||||
|
||||
### 决策 6:多卡购买按单卡独立下单,允许部分成功
|
||||
|
||||
选择:`POST /api/open/v1/wallet/package-orders` 接收 `card_nos[]` 和单个 `package_code`。Service 按卡逐个解析套餐和创建订单,每张卡独立返回成功订单号或失败原因。
|
||||
|
||||
理由:
|
||||
- 用户已确认允许部分成功。
|
||||
- 现有订单模型以单卡/设备为订单载体,逐卡复用最稳。
|
||||
- 单卡失败不影响其他卡购买,便于代理侧重试失败卡。
|
||||
|
||||
替代方案:
|
||||
- 一个批次事务全部成功或全部失败:一张坏卡会拖住整批,不符合当前要求。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Risk] 复用后台密码要求客户端传明文密码 → Mitigation:强制 HTTPS 部署,日志脱敏,后续可演进为独立 API secret。
|
||||
- [Risk] bcrypt 校验每次请求都有成本 → Mitigation:开放接口调用量初期可接受;后续可用短 TTL Redis 认证缓存,但必须谨慎避免密码变更后缓存过久。
|
||||
- [Risk] 多卡购买部分成功导致代理侧需要处理批次差异 → Mitigation:响应固定返回 `success_count`、`failed_count`、`orders`、`failed_cards`。
|
||||
- [Risk] 流量换算字段若重复手写可能与现有逻辑不一致 → Mitigation:在模型或服务层抽取开放接口专用展示方法,统一复用快照计算。
|
||||
- [Risk] 签名 canonical 规则双方理解不一致 → Mitigation:在接口文档中提供明确排序、空 body、数组参数和 JSON body 签名示例。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. 不新增数据库迁移,直接复用现有 `tb_account`、`tb_shop`、卡、套餐和钱包数据。
|
||||
2. 发布代码后,所有启用状态的代理账号可按签名规则调用开放接口。
|
||||
3. 若需要回滚,禁用 `/api/open/v1` 路由或回滚本次代码;已有后台功能不受影响。
|
||||
|
||||
## Open Questions
|
||||
|
||||
无。当前已确认:所有启用代理店铺均可使用开放接口、不新增开放接口账号授权表、流量不暴露虚流量、多卡部分成功、复用后台代理账号密码、一次只买一个套餐编码、套餐列表返回生效零售价。
|
||||
@@ -0,0 +1,47 @@
|
||||
## Why
|
||||
|
||||
现有代理商需要通过开放接口对接套餐购买、卡状态、流量、实名和预充值钱包数据,但当前能力主要面向后台管理端,依赖后台登录态,不适合直接给第三方系统调用。需要新增一套签名校验的代理开放接口,在不暴露虚流量语义的前提下复用现有代理套餐、钱包支付和卡套餐数据能力。
|
||||
|
||||
功能 ID:feature-20260511-agent-open-api
|
||||
|
||||
## What Changes
|
||||
|
||||
- 新增代理开放接口域,提供账号密码、请求参数、时间戳、随机串和 `sign` 的调用校验。
|
||||
- 新增单卡开放查询接口:卡流量查询、卡状态查询、卡实名状态查询。
|
||||
- 新增代理开放套餐列表接口,分页返回代理可购买套餐,复用后台代理套餐列表的权限过滤,并返回生效零售价。
|
||||
- 新增代理预充值钱包开放接口:余额查询、流水列表查询。
|
||||
- 新增代理钱包套餐购买开放接口:一次只允许一个套餐编码,支持多张卡分开购买,允许部分成功并返回失败卡原因。
|
||||
- 流量展示对外只返回真实业务口径字段:总流量、换算后的已用流量、剩余流量;不暴露虚流量、展示倍率、停机阈值等内部字段。
|
||||
- 新增开放接口 DTO、Handler、Service、Store/查询方法和路由注册,继续遵守 Handler → Service → Store → Model 分层。
|
||||
- 新增接口文档注册,确保 OpenAPI 文档生成器包含开放接口。
|
||||
- 不新增外部依赖,签名使用 Go 标准库 HMAC-SHA256;继续使用 Fiber、GORM、Viper、Zap、Redis。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `agent-open-api`: 定义代理开放接口的认证签名、卡查询、套餐列表、钱包余额/流水和钱包购买套餐能力。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
无。现有后台套餐、订单、钱包和资产查询能力仅作为实现复用点,不改变其既有接口契约。
|
||||
|
||||
## Impact
|
||||
|
||||
- 影响代码范围:
|
||||
- `internal/handler/openapi/`:新增代理开放接口 Handler。
|
||||
- `internal/service/agent_open_api/`:新增开放接口编排服务,复用套餐、订单、钱包、资产解析等现有服务。
|
||||
- `internal/model/dto/`:新增开放接口请求和响应 DTO。
|
||||
- `internal/model/`:复用现有账号、店铺、卡、套餐和钱包模型,不新增开放接口账号授权模型。
|
||||
- `internal/store/postgres/`:复用现有账号和店铺 Store,必要时补充按套餐编码、卡标识、钱包流水的查询方法。
|
||||
- `internal/routes/`:新增 `/api/open/v1` 路由组。
|
||||
- `pkg/constants/`:新增开放接口相关常量、Redis nonce 防重放 key 生成函数。
|
||||
- `pkg/errors/`:补充开放接口认证、签名、时间戳、重放相关错误码。
|
||||
- `cmd/api/docs.go` 与 `cmd/gendocs/main.go`:注册新增 Handler,保证文档生成覆盖。
|
||||
- 数据影响:
|
||||
- 不新增开放接口账号授权表;所有启用状态的代理账号默认可调用开放接口。
|
||||
- Redis 记录开放接口 nonce,防止时间窗内重放。
|
||||
- 性能影响:
|
||||
- 列表接口分页,默认 20,最大 100。
|
||||
- 签名校验只做轻量 HMAC、bcrypt 校验和 Redis nonce 写入。
|
||||
- 流量查询按单卡读取当前/待生效套餐,避免设备级聚合和大范围扫描。
|
||||
@@ -0,0 +1,250 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### 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 使用以下格式:
|
||||
|
||||
```text
|
||||
METHOD + "\n" +
|
||||
PATH + "\n" +
|
||||
canonical_query_without_sign + "\n" +
|
||||
SHA256(raw_body) + "\n" +
|
||||
timestamp + "\n" +
|
||||
nonce + "\n" +
|
||||
account
|
||||
```
|
||||
|
||||
签名算法 SHALL 使用 `hex(HMAC-SHA256(password, sign_payload))`。`canonical_query_without_sign` MUST 按参数名升序排列并排除 `sign` 字段。空 body 的 SHA256 MUST 按空字符串计算。
|
||||
|
||||
响应 MUST 使用统一格式 `{code, msg, data, timestamp}`。认证失败错误码 MUST 在 `pkg/errors/` 定义,不得把 bcrypt、HMAC、数据库底层错误暴露给调用方。
|
||||
|
||||
#### Scenario: 签名认证通过
|
||||
|
||||
- **WHEN** 启用状态的代理账号携带正确密码、时间戳、nonce、请求参数和签名调用 `/api/open/v1/cards/status`
|
||||
- **THEN** 系统通过认证并将代理账号信息写入请求上下文
|
||||
|
||||
#### Scenario: 参数被篡改
|
||||
|
||||
- **WHEN** 请求中的 query 或 body 在签名后被修改
|
||||
- **THEN** 系统验签失败并返回签名无效错误
|
||||
|
||||
#### 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`。
|
||||
|
||||
#### Scenario: 多卡部分成功
|
||||
|
||||
- **WHEN** 代理提交 3 张卡购买同一个套餐编码,其中 2 张成功、1 张失败
|
||||
- **THEN** 系统返回 `success_count=2`、`failed_count=1`、2 条订单号和 1 条失败卡原因
|
||||
|
||||
#### 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** 执行文档生成命令
|
||||
- **THEN** 生成的 OpenAPI 文档包含代理开放接口认证说明和全部 `/api/open/v1` 路由
|
||||
|
||||
#### Scenario: 路由注册覆盖开放接口
|
||||
|
||||
- **WHEN** API 服务启动
|
||||
- **THEN** `/api/open/v1/cards/traffic`、`/api/open/v1/cards/status`、`/api/open/v1/cards/realname-status`、`/api/open/v1/packages`、`/api/open/v1/wallet/package-orders`、`/api/open/v1/wallet/balance`、`/api/open/v1/wallet/transactions` 均已注册
|
||||
@@ -0,0 +1,51 @@
|
||||
## 1. 数据模型、常量和错误码
|
||||
|
||||
- [x] 1.1 不新增开放接口账号授权表;所有启用的代理账号默认可调用开放接口,继续复用 `tb_account` 和店铺数据权限。
|
||||
- [x] 1.2 复用现有 `Account` 与 `Shop` 模型,不新增开放接口授权模型。
|
||||
- [x] 1.3 复用现有 `AccountStore` 和 `ShopStore` 查询账号与下级店铺,不新增开放接口授权 Store。
|
||||
- [x] 1.4 在 `pkg/constants/` 新增开放接口 Header 名称、签名时间窗、批次号前缀、Redis nonce key 生成函数等常量,并为所有常量添加中文注释。
|
||||
- [x] 1.5 在 `pkg/errors/` 新增开放接口认证、签名无效、时间戳无效、nonce 重放等错误码和中文错误消息。
|
||||
- [x] 1.6 验证:运行 `gofmt` 覆盖新增 Go 文件,运行 `go build ./cmd/api` 确认基础模型与常量可编译。
|
||||
|
||||
## 2. 开放接口签名认证中间件
|
||||
|
||||
- [x] 2.1 新增开放接口认证中间件,解析 `X-Agent-Account`、`X-Agent-Password`、`X-Agent-Timestamp`、`X-Agent-Nonce`、`X-Agent-Sign`。
|
||||
- [x] 2.2 实现签名原文构造:方法、路径、排序后的 query、body SHA256、时间戳、nonce、账号,排除 `sign` 字段。
|
||||
- [x] 2.3 使用 bcrypt 校验后台账号密码,使用 HMAC-SHA256 重算签名,并用常量时间比较校验签名。
|
||||
- [x] 2.4 使用 Redis `SET NX` 写入开放接口 nonce,按签名时间窗设置 TTL,拒绝重复 nonce。
|
||||
- [x] 2.5 校验账号类型、账号状态和关联店铺状态;所有启用代理账号默认允许调用开放接口。
|
||||
- [x] 2.6 认证通过后注入代理上下文:用户ID、用户类型、店铺ID、下级店铺ID列表,保证现有权限过滤可复用。
|
||||
- [x] 2.7 对开放接口密码、签名、nonce 等敏感字段做日志脱敏,避免访问日志和错误日志泄露。
|
||||
- [ ] 2.8 验证:使用构造好的签名请求和篡改参数请求,通过 curl 手动确认成功、签名失败、nonce 重放、密码错误、非代理账号、禁用账号等分支。
|
||||
|
||||
## 3. DTO 与开放接口业务服务
|
||||
|
||||
- [x] 3.1 新增开放接口 DTO:卡流量、卡状态、实名状态、套餐列表、钱包购买、钱包余额、钱包流水的请求和响应结构,description 使用中文。
|
||||
- [x] 3.2 新增 `agent_open_api.Service`,通过结构体字段注入账号、店铺、卡、套餐、钱包、订单等现有 Store/Service。
|
||||
- [x] 3.3 实现单卡标识解析逻辑:支持 ICCID、虚拟号、MSISDN,必须解析为 IoT 卡,设备标识返回参数错误。
|
||||
- [x] 3.4 实现卡流量查询:查询生效和待生效 `PackageUsage`,补充套餐编码和系列名称,只返回真实业务口径流量字段,不返回虚流量内部字段。
|
||||
- [x] 3.5 实现卡状态查询:按 `network_status` 映射正常/停机,并返回停机原因。
|
||||
- [x] 3.6 实现卡实名状态查询:按 `real_name_status` 返回 `is_realnamed`。
|
||||
- [x] 3.7 实现代理套餐列表:复用代理套餐过滤和生效零售价逻辑,分页返回套餐ID、编码、名称、系列、类型、成本价、生效零售价、总流量、有效天数。
|
||||
- [x] 3.8 实现预充值钱包余额查询:主钱包不存在时返回 0 余额,存在时返回余额、冻结余额、可用余额和币种。
|
||||
- [x] 3.9 实现预充值钱包流水查询:复用主钱包流水字段和过滤条件,只查询当前代理主钱包。
|
||||
- [x] 3.10 实现钱包套餐购买:按单个套餐编码解析套餐,逐张卡调用后台钱包订单创建逻辑,记录每张卡成功订单号或失败原因,允许部分成功。
|
||||
- [ ] 3.11 验证:使用 PostgreSQL MCP 或 SQL 查询核对套餐、卡、钱包和订单数据;使用 curl 手动核对所有开放接口响应字段和错误分支。
|
||||
|
||||
## 4. Handler、路由和依赖注入
|
||||
|
||||
- [x] 4.1 新增 `internal/handler/openapi` 包,实现开放接口 Handler,Handler 只负责参数解析、校验和统一响应。
|
||||
- [x] 4.2 新增 `internal/routes/open.go`,注册 `/api/open/v1` 路由组和全部 7 个开放接口。
|
||||
- [x] 4.3 更新 `internal/routes/routes.go`,在总路由入口挂载开放接口路由和签名认证中间件。
|
||||
- [x] 4.4 更新 `internal/bootstrap/types.go`、`internal/bootstrap/services.go`、`internal/bootstrap/handlers.go`,完成开放接口 Service、Store、Handler 的结构体字段注入。
|
||||
- [x] 4.5 更新 `cmd/api/docs.go` 和 `cmd/gendocs/main.go` 的 `bootstrap.Handlers` 初始化,确保新增 Handler 进入文档生成器。
|
||||
- [x] 4.6 验证:运行 `go build ./cmd/api` 和 `go build ./cmd/gendocs`,确认路由、依赖注入和文档入口可编译。
|
||||
|
||||
## 5. 文档、索引和最终验证
|
||||
|
||||
- [x] 5.1 新增 `docs/agent-open-api/功能总结.md`,说明签名规则、字段口径、接口列表、错误码和对接示例。
|
||||
- [x] 5.2 更新 README 或接口文档索引,加入代理开放接口文档入口。
|
||||
- [x] 5.3 运行 OpenAPI 文档生成命令,确认 `/api/open/v1` 下全部接口出现在文档中。
|
||||
- [x] 5.4 运行 `openspec validate add-agent-open-api --strict`,修正 OpenSpec 结构问题。
|
||||
- [x] 5.5 运行 `ccc index` 更新代码索引。
|
||||
- [ ] 5.6 最终验证:手动执行签名成功、签名失败、卡流量、卡状态、实名状态、套餐列表、钱包余额、钱包流水、多卡部分成功购买、余额不足购买等场景,并记录验证结果。
|
||||
250
openspec/specs/agent-open-api/spec.md
Normal file
250
openspec/specs/agent-open-api/spec.md
Normal file
@@ -0,0 +1,250 @@
|
||||
# agent-open-api Specification
|
||||
|
||||
## Purpose
|
||||
定义代理开放接口的签名认证、数据权限、卡查询、套餐列表、预充值钱包和钱包购买能力,确保第三方代理系统可以在受控权限内对接业务接口。
|
||||
|
||||
## Requirements
|
||||
|
||||
### 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 使用以下格式:
|
||||
|
||||
```text
|
||||
METHOD + "\n" +
|
||||
PATH + "\n" +
|
||||
canonical_query_without_sign + "\n" +
|
||||
SHA256(raw_body) + "\n" +
|
||||
timestamp + "\n" +
|
||||
nonce + "\n" +
|
||||
account
|
||||
```
|
||||
|
||||
签名算法 SHALL 使用 `hex(HMAC-SHA256(password, sign_payload))`。`canonical_query_without_sign` MUST 按参数名升序排列并排除 `sign` 字段。空 body 的 SHA256 MUST 按空字符串计算。
|
||||
|
||||
响应 MUST 使用统一格式 `{code, msg, data, timestamp}`。认证失败错误码 MUST 在 `pkg/errors/` 定义,不得把 bcrypt、HMAC、数据库底层错误暴露给调用方。
|
||||
|
||||
#### Scenario: 签名认证通过
|
||||
|
||||
- **WHEN** 启用状态的代理账号携带正确密码、时间戳、nonce、请求参数和签名调用 `/api/open/v1/cards/status`
|
||||
- **THEN** 系统通过认证并将代理账号信息写入请求上下文
|
||||
|
||||
#### Scenario: 参数被篡改
|
||||
|
||||
- **WHEN** 请求中的 query 或 body 在签名后被修改
|
||||
- **THEN** 系统验签失败并返回签名无效错误
|
||||
|
||||
#### 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`。
|
||||
|
||||
#### Scenario: 多卡部分成功
|
||||
|
||||
- **WHEN** 代理提交 3 张卡购买同一个套餐编码,其中 2 张成功、1 张失败
|
||||
- **THEN** 系统返回 `success_count=2`、`failed_count=1`、2 条订单号和 1 条失败卡原因
|
||||
|
||||
#### 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` 下全部代理开放接口
|
||||
@@ -23,15 +23,15 @@
|
||||
|
||||
#### Scenario: 查询退款详情
|
||||
- **WHEN** 管理员查询某退款单详情
|
||||
- **THEN** 系统 SHALL 返回退款单完整信息(含 `commission_deducted` 和 `asset_reset` 标记状态)
|
||||
- **THEN** 系统 SHALL 返回退款单完整信息(含 `commission_deducted` 和 `asset_reset` 标记状态,其中 `asset_reset` 表示退款后资产处理是否完成)
|
||||
|
||||
### Requirement: 审批通过接口
|
||||
`POST /api/admin/refunds/:id/approve` SHALL 审批通过退款。请求体可选 `approved_refund_amount`(不填则等于 `requested_refund_amount`)和 `remark`。审批通过后异步触发资产处理和佣金全额回扣。
|
||||
|
||||
#### Scenario: 审批通过并触发副作用
|
||||
- **WHEN** 审批人通过退款申请
|
||||
- **THEN** 退款状态 SHALL 变为已通过,记录审批人和审批时间
|
||||
- **AND** 系统 SHALL 异步执行:失效所有套餐 → 停机 → 世代重置资产 → 更新订单状态为已退款 → 标记 `asset_reset=true`
|
||||
- **THEN** 退款状态 SHALL 变为已通过,记录审批人和审批时间,并同步将订单状态更新为已退款
|
||||
- **AND** 系统 SHALL 异步执行:精准失效本次退款关联套餐 → 尝试接续待生效主套餐 → 必要时停机 → 标记 `asset_reset=true`
|
||||
- **AND** 系统 SHALL 异步执行:全额回扣该订单所有佣金 → 标记 `commission_deducted=true`
|
||||
|
||||
### Requirement: 审批拒绝接口
|
||||
|
||||
@@ -1,49 +1,74 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 退款后套餐失效
|
||||
退款审批通过后,系统 SHALL 查找该订单关联资产(`iot_card_id` 或 `device_id`)的全部套餐使用记录(`status IN (0,1,2)` 待生效/生效中/已用完),批量更新为 `status=4`(已失效)。
|
||||
### Requirement: 退款后套餐精准失效
|
||||
退款审批通过后,系统 SHALL 仅失效本次退款关联订单生成的套餐使用记录。若退款单传入 `package_usage_id`,系统 SHALL 优先失效该套餐使用记录,且该记录必须属于退款订单;若未传入 `package_usage_id`,系统 SHALL 失效该订单生成的全部可失效套餐使用记录。
|
||||
|
||||
#### Scenario: 失效单卡套餐
|
||||
- **WHEN** 退款订单为单卡购买,关联 iot_card_id
|
||||
- **THEN** 系统 SHALL 将该卡所有 `status IN (0,1,2)` 的套餐使用记录更新为 `status=4`
|
||||
#### Scenario: 精准失效单个套餐
|
||||
- **WHEN** 退款单包含 `package_usage_id`
|
||||
- **THEN** 系统 SHALL 将该套餐使用记录从 `status IN (0,1,2)` 更新为 `status=4`
|
||||
- **AND** 系统 SHALL 写入 `refund_id` 与 `refund_no` 快照
|
||||
|
||||
#### Scenario: 失效设备套餐
|
||||
- **WHEN** 退款订单为设备购买,关联 device_id
|
||||
- **THEN** 系统 SHALL 将该设备所有 `status IN (0,1,2)` 的套餐使用记录更新为 `status=4`
|
||||
#### Scenario: 未指定套餐时失效订单套餐
|
||||
- **WHEN** 退款单未包含 `package_usage_id`
|
||||
- **THEN** 系统 SHALL 将该订单生成的所有 `status IN (0,1,2)` 套餐使用记录更新为 `status=4`
|
||||
|
||||
### Requirement: 退款后停机
|
||||
套餐失效后,系统 SHALL 对资产执行停机操作。
|
||||
#### Scenario: 主套餐关联加油包失效
|
||||
- **WHEN** 被失效套餐为主套餐
|
||||
- **THEN** 系统 SHALL 将其关联的可失效加油包一并更新为 `status=4`
|
||||
|
||||
#### Scenario: 单卡停机
|
||||
- **WHEN** 退款订单为单卡购买
|
||||
- **THEN** 系统 SHALL 调用 `StopResumeService.ManualStopCard(iccid)` 停机
|
||||
### Requirement: 退款后待生效套餐接续
|
||||
退款套餐失效后,系统 SHALL 按购买顺序尝试激活该资产队首待生效主套餐。系统 MUST NOT 跳过未满足实名条件的队首套餐。
|
||||
|
||||
#### Scenario: 设备停机(停所有绑定卡)
|
||||
- **WHEN** 退款订单为设备购买
|
||||
- **THEN** 系统 SHALL 调用 `DeviceService.StopDevice(deviceID)`,内部遍历所有绑定卡逐一停机
|
||||
#### Scenario: 立即生效套餐接续
|
||||
- **WHEN** 退款后资产无生效主套餐,且队首待生效主套餐 `pending_realname_activation=false`
|
||||
- **THEN** 系统 SHALL 激活该套餐,并按套餐 `expiry_base` 计算生效时间和过期时间
|
||||
|
||||
### Requirement: 退款后资产世代重置
|
||||
停机后,系统 SHALL 对资产执行世代重置(参照换货模块 `exchange/service.go` 的重置逻辑),使资产回到可重新销售状态,一次性佣金可重新触发。
|
||||
#### Scenario: 实名套餐已满足条件
|
||||
- **WHEN** 队首待生效主套餐 `pending_realname_activation=true`
|
||||
- **AND** 单卡已实名,或设备任意已绑定卡已实名
|
||||
- **THEN** 系统 SHALL 激活该套餐,并清除 `pending_realname_activation`
|
||||
|
||||
#### Scenario: 单卡世代重置
|
||||
- **WHEN** 退款订单为单卡购买
|
||||
- **THEN** 系统 SHALL 更新 IoT 卡:`generation+1`、`asset_status=1(在库)`、`accumulated_recharge=0`、`first_commission_paid=false`、清零系列充值和触发标记,清理个人客户绑定(`tb_personal_customer_device`),删除旧资产钱包并创建新空钱包
|
||||
#### Scenario: 实名套餐未满足条件
|
||||
- **WHEN** 队首待生效主套餐 `pending_realname_activation=true`
|
||||
- **AND** 单卡未实名,或设备没有任何已绑定卡实名
|
||||
- **THEN** 系统 SHALL 保持该套餐待生效
|
||||
- **AND** 系统 MUST NOT 跳过该套餐激活后续待生效套餐
|
||||
|
||||
#### Scenario: 设备世代重置
|
||||
- **WHEN** 退款订单为设备购买
|
||||
- **THEN** 系统 SHALL 更新设备:`generation+1`、`asset_status=1(在库)`、`accumulated_recharge=0`、`first_commission_paid=false`、清零系列充值和触发标记,清理个人客户绑定,删除旧资产钱包并创建新空钱包
|
||||
### Requirement: 退款后必要时停机
|
||||
套餐失效和接续激活完成后,系统 SHALL 检查资产是否仍有生效主套餐。仅当资产没有生效主套餐时,系统 SHALL 执行停机操作。
|
||||
|
||||
### Requirement: 退款后订单状态更新
|
||||
资产处理完成后,系统 SHALL 将订单 `payment_status` 更新为 4(已退款)。
|
||||
#### Scenario: 有接续套餐时不强停
|
||||
- **WHEN** 退款后成功接续待生效主套餐,或资产仍存在其他生效主套餐
|
||||
- **THEN** 系统 SHALL NOT 执行退款停机
|
||||
|
||||
#### Scenario: 订单标记已退款
|
||||
- **WHEN** 资产处理全部成功
|
||||
- **THEN** 订单 `payment_status` SHALL 变为 4(已退款),退款单 `asset_reset` 标记为 `true`
|
||||
#### Scenario: 无可用主套餐时停机
|
||||
- **WHEN** 退款后资产没有任何生效主套餐
|
||||
- **THEN** 单卡订单 SHALL 调用 `StopResumeService.ManualStopCard(iccid)`
|
||||
- **AND** 设备订单 SHALL 调用 `DeviceService.StopDevice(deviceID)`
|
||||
|
||||
### Requirement: 退款不得重置资产世代和钱包
|
||||
套餐退款不是资产退回库存。退款资产处理 MUST NOT 执行资产 `generation+1`、MUST NOT 清理个人客户绑定、MUST NOT 删除或重建资产钱包。资产世代重置和钱包重建仅属于换货/转新流程。
|
||||
|
||||
#### Scenario: 退款后保留资产钱包
|
||||
- **WHEN** 套餐退款审批通过
|
||||
- **THEN** 资产钱包 ID、余额和冻结余额 SHALL 保持不被退款资产处理重建或清零
|
||||
|
||||
#### Scenario: 退款后保留资产世代
|
||||
- **WHEN** 套餐退款审批通过
|
||||
- **THEN** IoT 卡或设备的 `generation` SHALL 不因退款资产处理变化
|
||||
|
||||
### Requirement: 退款后处理标记
|
||||
退款后资产处理成功完成后,系统 SHALL 将退款单 `asset_reset` 标记为 `true`。该字段为兼容字段,语义为“退款后资产处理是否完成”。
|
||||
|
||||
#### Scenario: 资产处理完成
|
||||
- **WHEN** 套餐失效、接续激活检查和必要停机处理完成
|
||||
- **THEN** 退款单 `asset_reset` SHALL 标记为 `true`
|
||||
|
||||
### Requirement: 资产处理失败容错
|
||||
资产处理 SHALL 在独立 Goroutine 中执行,失败不影响审批结果。
|
||||
|
||||
#### Scenario: 资产处理失败
|
||||
- **WHEN** 套餐失效/停机/世代重置过程中发生错误
|
||||
- **THEN** 审批结果 SHALL NOT 受影响(已通过),`asset_reset` 保持 `false`,记录 Error 日志(含 refund_id 和 error)
|
||||
- **AND** 管理员可通过 `asset_reset=false` 筛选需手动处理的退款单
|
||||
- **WHEN** 套餐失效、接续激活或生效套餐查询过程中发生错误
|
||||
- **THEN** 审批结果 SHALL NOT 受影响(已通过)
|
||||
- **AND** `asset_reset` SHALL 保持 `false`
|
||||
- **AND** 系统 SHALL 记录 Error 日志(含 `refund_id` 和错误信息)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 退款申请数据模型
|
||||
系统 SHALL 提供 `tb_refund_request` 表存储退款申请,包含退款单号(唯一)、关联订单 ID、店铺 ID(数据权限过滤)、实收金额、申请退款金额、审批退款金额、状态、审批人、审批时间、佣金回扣标记、资产重置标记等字段。
|
||||
系统 SHALL 提供 `tb_refund_request` 表存储退款申请,包含退款单号(唯一)、关联订单 ID、店铺 ID(数据权限过滤)、实收金额、申请退款金额、审批退款金额、状态、审批人、审批时间、佣金回扣标记、退款后资产处理标记等字段。
|
||||
|
||||
#### Scenario: 创建退款申请
|
||||
- **WHEN** 平台管理员提交退款申请,填写订单 ID、实收金额、申请退款金额、退款原因
|
||||
|
||||
Reference in New Issue
Block a user