修复一些问题,主要是生效套餐
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m16s

This commit is contained in:
2026-05-12 10:32:38 +08:00
parent b7369a9c71
commit 95fc0b0a1b
32 changed files with 5984 additions and 553 deletions

View 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` 下全部代理开放接口

View File

@@ -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: 审批拒绝接口

View File

@@ -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` 和错误信息)

View File

@@ -1,7 +1,7 @@
## ADDED Requirements
### Requirement: 退款申请数据模型
系统 SHALL 提供 `tb_refund_request` 表存储退款申请,包含退款单号(唯一)、关联订单 ID、店铺 ID数据权限过滤、实收金额、申请退款金额、审批退款金额、状态、审批人、审批时间、佣金回扣标记、资产重置标记等字段。
系统 SHALL 提供 `tb_refund_request` 表存储退款申请,包含退款单号(唯一)、关联订单 ID、店铺 ID数据权限过滤、实收金额、申请退款金额、审批退款金额、状态、审批人、审批时间、佣金回扣标记、退款后资产处理标记等字段。
#### Scenario: 创建退款申请
- **WHEN** 平台管理员提交退款申请,填写订单 ID、实收金额、申请退款金额、退款原因