Files
junhong_cmp_fiber/docs/agent-open-api/功能总结.md
Break 1f634eb465
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m3s
init
2026-06-16 15:15:50 +08:00

266 lines
9.2 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 代理开放接口对接说明
## 1. 接入说明
代理开放接口挂载在 `/api/open/v1`,面向代理店铺第三方系统调用。接入方使用代理账号登录信息进行签名认证,通过认证后即可调用卡查询、套餐查询、预充值钱包查询和套餐购买接口。
请通过 HTTPS 调用开放接口。接入方不要在客户端日志、浏览器控制台或异常信息中记录账号密码、签名、随机串等敏感信息。
## 2. 认证 Header
每个请求必须携带以下 Header
| Header | 是什么 | 怎么来 | 示例 | 注意事项 |
| --- | --- | --- | --- | --- |
| `X-Agent-Account` | 代理账号标识 | 填写代理登录平台使用的用户名或手机号,由平台开通代理账号时提供 | `agent001``13800000000` | 必须与签名原文最后一行 `account` 完全一致 |
| `X-Agent-Password` | 代理账号当前登录密码 | 由代理账号持有人提供,和登录平台的密码一致 | `Passw0rd123` | 同时作为 HMAC-SHA256 签名密钥;密码变更后必须使用新密码签名;不要写入日志 |
| `X-Agent-Timestamp` | 请求发起时间 | 调用方在每次请求前生成 | `1715400000``1715400000000``2024-05-11T10:00:00+08:00` | 支持 Unix 秒、Unix 毫秒、RFC3339必须与签名原文 `timestamp` 行完全一致;客户端服务器时间需同步 |
| `X-Agent-Nonce` | 请求随机串 | 调用方每次请求自行生成,不需要平台提前分配 | `n-1715400000-001` 或 UUID | 同一账号 5 分钟内不可重复,重复会被判定为重放请求 |
| `X-Agent-Sign` | 请求签名 | 调用方按下方签名规则实时计算 | `5f2d...` | 不是平台分配的固定值;只要 method、path、query、body、timestamp、nonce、account 或 password 任意一个变化,签名都会变化 |
Header 生成顺序建议:
1. 先确定 `X-Agent-Account``X-Agent-Password`
2. 每次请求生成新的 `X-Agent-Timestamp``X-Agent-Nonce`
3. 按下方规则拼出签名原文。
4.`X-Agent-Password` 对签名原文做 HMAC-SHA256得到 `X-Agent-Sign`
5. 带上 5 个 Header 发起请求。
签名原文:
```text
METHOD
PATH
canonical_query
body_sha256
timestamp
nonce
account
```
拼接规则:
- 使用换行符 `\n` 连接 7 行,最后一行后不追加换行。
- `METHOD` 使用大写 HTTP 方法,例如 `GET``POST`
- `PATH` 只取请求路径,例如 `/api/open/v1/cards/status`,不包含域名和 query。
- `canonical_query` 为 query 参数规范化结果:排除 `sign` 字段参数名按升序排列同名多值按值升序排列key 和 value 使用 URL QueryEscape 编码后按 `key=value` 拼接;多个参数用 `&` 连接;无 query 时为空字符串。
- `body_sha256` 为原始请求 body 字节的 SHA256 小写十六进制值。GET 或空 body 使用空字符串的 SHA256`e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`
- POST JSON 请求必须先确定最终发送的 body 字符串,再用同一个字符串计算 `body_sha256` 并发送请求;签名后不要再重新格式化 JSON、调整字段顺序或改变空格。
- `timestamp``nonce``account` 必须与 Header 中的值完全一致。
签名值为:
```text
lowercase_hex(HMAC-SHA256(password, sign_payload))
```
其中 `password``X-Agent-Password` 的原始值,`sign_payload` 为上面 7 行拼接后的字符串。
### 2.1 GET 签名示例
请求:
```text
GET /api/open/v1/cards/status?card_no=89860000000000000001
X-Agent-Account: agent001
X-Agent-Password: Passw0rd123
X-Agent-Timestamp: 1715400000
X-Agent-Nonce: n-1715400000-001
```
签名原文:
```text
GET
/api/open/v1/cards/status
card_no=89860000000000000001
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
1715400000
n-1715400000-001
agent001
```
Node.js 示例:
```javascript
const crypto = require("crypto");
function sha256Hex(input) {
return crypto.createHash("sha256").update(input).digest("hex");
}
function queryEscape(value) {
return new URLSearchParams({ v: String(value) }).toString().slice(2);
}
function canonicalQuery(params) {
return Object.entries(params)
.filter(([key]) => key.toLowerCase() !== "sign")
.flatMap(([key, value]) => {
const values = Array.isArray(value) ? value : [value];
return values.sort().map((item) => [key, item]);
})
.sort(([aKey, aValue], [bKey, bValue]) => {
if (aKey === bKey) return String(aValue).localeCompare(String(bValue));
return aKey.localeCompare(bKey);
})
.map(([key, value]) => `${queryEscape(key)}=${queryEscape(value)}`)
.join("&");
}
function sign({ method, path, query, body, timestamp, nonce, account, password }) {
const payload = [
method.toUpperCase(),
path,
canonicalQuery(query),
sha256Hex(body || ""),
timestamp,
nonce,
account,
].join("\n");
return crypto.createHmac("sha256", password).update(payload).digest("hex");
}
```
完整 GET 调用示例:
```javascript
const https = require("https");
const account = "agent001";
const password = "Passw0rd123";
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = `nonce-${Date.now()}`;
const method = "GET";
const path = "/api/open/v1/cards/status";
const query = { card_no: "89860000000000000001" };
const body = "";
const requestQuery = canonicalQuery(query);
const requestPath = `${path}?${requestQuery}`;
const signature = sign({ method, path, query, body, timestamp, nonce, account, password });
const req = https.request(
{
hostname: "open.example.com",
path: requestPath,
method,
headers: {
"X-Agent-Account": account,
"X-Agent-Password": password,
"X-Agent-Timestamp": timestamp,
"X-Agent-Nonce": nonce,
"X-Agent-Sign": signature,
},
},
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => console.log(data));
}
);
req.end();
```
完整 POST 调用示例:
```javascript
const https = require("https");
const account = "agent001";
const password = "Passw0rd123";
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = `nonce-${Date.now()}`;
const method = "POST";
const path = "/api/open/v1/wallet/package-orders";
const query = {};
const body = JSON.stringify({
card_nos: ["89860000000000000001", "89860000000000000002"],
package_code: "PKG001",
});
const signature = sign({ method, path, query, body, timestamp, nonce, account, password });
const req = https.request(
{
hostname: "open.example.com",
path,
method,
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body),
"X-Agent-Account": account,
"X-Agent-Password": password,
"X-Agent-Timestamp": timestamp,
"X-Agent-Nonce": nonce,
"X-Agent-Sign": signature,
},
},
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => console.log(data));
}
);
req.write(body);
req.end();
```
curl 调用示例:
```bash
curl -G "https://open.example.com/api/open/v1/cards/status" \
--data-urlencode "card_no=89860000000000000001" \
-H "X-Agent-Account: agent001" \
-H "X-Agent-Password: Passw0rd123" \
-H "X-Agent-Timestamp: 1715400000" \
-H "X-Agent-Nonce: n-1715400000-001" \
-H "X-Agent-Sign: 上面代码计算出的签名"
```
## 3. 接口列表
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/api/open/v1/cards/traffic` | 按 `card_no` 查询单卡流量和套餐 |
| `GET` | `/api/open/v1/cards/status` | 按 `card_no` 查询单卡正常/停机状态 |
| `GET` | `/api/open/v1/cards/realname-status` | 按 `card_no` 查询实名状态 |
| `GET` | `/api/open/v1/packages` | 查询可购买套餐列表 |
| `POST` | `/api/open/v1/wallet/package-orders` | 使用预充值钱包给多张卡购买同一个套餐 |
| `GET` | `/api/open/v1/wallet/balance` | 查询预充值钱包余额 |
| `GET` | `/api/open/v1/wallet/transactions` | 查询预充值钱包流水 |
`card_no` 支持 ICCID、虚拟号、MSISDN。开放接口只支持单卡设备标识会返回参数错误。
## 4. 字段口径
卡流量字段:
- `total_flow_mb`:套餐总流量,单位 MB。
- `used_flow_mb`:已用流量,单位 MB。
- `remaining_flow_mb`:剩余流量,单位 MB。
套餐列表字段:
- `cost_price`:套餐结算价,单位分。
- `retail_price`:套餐销售价,单位分。
- `total_flow_mb`:套餐总流量,单位 MB。
- `valid_days`:套餐有效天数。
钱包购买接口一次只允许一个 `package_code``card_nos` 最多 100 张卡。每张卡独立创建钱包订单,允许部分成功,失败卡会返回独立错误码和原因。任一卡购买失败时,外层 `code`/`msg` 返回首条失败卡的错误码和原因,同时 `data.failed_cards` 保留失败明细,`data.orders` 保留已成功订单;接入方应以 `success_count``failed_count``orders``failed_cards` 判断单卡结果,避免整批重试造成重复下单。
## 5. 错误码
| 错误码 | 说明 |
| --- | --- |
| `1048` | 开放接口认证失败 |
| `1049` | 开放接口签名无效 |
| `1190` | 开放接口时间戳无效 |
| `1191` | 重复请求,请勿重放 |
| `1005` | 无权限操作该资源或资源不存在 |
参数校验失败统一返回 `1001`。认证类失败只返回统一错误码和消息。