# 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`。

当任一卡购买失败时，外层 `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` 下全部代理开放接口
