All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m3s
266 lines
9.2 KiB
Markdown
266 lines
9.2 KiB
Markdown
# 代理开放接口对接说明
|
||
|
||
## 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`。认证类失败只返回统一错误码和消息。
|