上游 / 下游接口对接说明
本文覆盖两条链路:
- 上游对接:我们 → 资源供应商(自动开通、续费、开关机、重装、销毁、状态同步)
- 下游开放接口:下游代理 / 分销系统 → 我们(下单、支付、开通、续费、查询、回调)
一、整体架构
下游系统 ──HTTPS(HMAC签名)──► api/v1.php ──► 鉴权(签名/时效/防重放/白名单/限流)
└─► YxApiGateway 动作分发
└─► 订单/业务/财务
└─► 任务队列(yx_task)
└─► 上游驱动(HTTP/Mock) ──► 供应商接口
└─► 事件回调 ──► 下游 notify_url
关键设计:所有上游调用都走异步任务队列,由 tools/cron.php 驱动。原因:
- 上游接口耗时不可控,不能阻塞客户下单与支付
- 上游可能失败,需要退避重试而不是让客户看到 500
- 需要可观测、可重试、可人工干预
二、上游对接
2.1 配置
后台 →「业务管理 → 上游对接」:
- 新增供应商,选择驱动: | 驱动 | 说明 | |---|---| |
本地模拟(mock)| 不依赖真实上游,用于打通全流程与联调 | |通用 HTTP/JSON(http)| 对接任意标准上游接口,需填接口地址、appid、密钥 |
- 点「连通性测试」验证可达(结果写入列表)
- 点「拉取产品」查看上游可售产品与编码
- 在「产品映射」中把本地产品绑定到上游产品编码,并填写规格 JSON(会随开通请求原样提交给上游)
2.2 异步开通流程
客户下单支付
└─► 若产品已绑定上游 → 业务状态置 pending,入队 upstream.create
└─► cron 执行 → 调用上游 create
├─ 成功 → 回写 remote_id / IP / 登录账号密码,业务置 active
└─ 失败 → 退避重试(1/5/15/60/120 分钟),超过上限标记 failed
未绑定上游的产品不受影响,仍走「后台人工开通」。
2.3 通用 HTTP 驱动协议(我方 → 上游)
请求:POST {endpoint},Content-Type: application/json
{
"action": "create",
"appid": "上游分配的商户号",
"ts": 1730000000,
"nonce": "a1b2c3d4e5f6a7b8",
"params": { "product_code": "hk-2c2g", "cpu": 2, "mem": 2048, "region": "hk" },
"sign": "…"
}
签名:
canonical = "action=..&appid=..&nonce=..¶ms=<JSON原文>&ts=.." (按 key 升序)
sign = HMAC-SHA256(canonical, 上游密钥) 小写十六进制
响应:
{ "code": 0, "msg": "ok",
"data": { "remote_id": "VM-123", "ip": "1.2.3.4", "username": "root",
"password": "xxxx", "status": "running", "expire_at": "2027-01-01" } }
code != 0 视为失败,msg 会写入任务错误详情并在后台展示。
动作(action)与 params
| action | params | 期望返回 |
|---|---|---|
ping | 无 | code=0 即连通 |
products | 无 | data.list: [{code,name,price,spec}] |
create | product_code、规格字段、service_sn、hostname、cycle、os | remote_id、ip、username、password、status |
status | remote_id | status(running/stopped/…)、ip |
renew | remote_id、cycle | code=0 |
power | remote_id、action(start/stop/reboot) | code=0 |
reinstall | remote_id、os | code=0 |
destroy | remote_id | code=0 |
2.4 对接异构协议(可配置驱动)
第三方商业系统的接口约定各不相同(请求方式、签名算法、字段名、响应结构都可能不一样)。 为避免「每接一家就要改一次代码」,本项目提供 自定义 HTTP 驱动:把对方协议写进后台配置即可完成对接,无需改代码。
选择方式:后台「业务管理 → 上游对接 → 新增供应商 → 对接驱动」选 自定义 HTTP(字段/签名/响应全可配),然后在「接口协议配置」里填 JSON(点「填入默认模板」会给出完整骨架)。
可配置项
| 分组 | 配置项 | 说明 |
|---|---|---|
| 传输 | method | POST_JSON / POST_FORM / GET |
headers | 附加请求头,支持占位符 {appid} {secret} {ts} {nonce},例如 {"X-Api-Key":"{secret}","X-Merchant":"{appid}"} | |
| 签名 | auth | append_secret = md5(串+密钥);append_secret_upper = 大写;secret_prefix = md5(密钥+串);hmac_sha256;hmac_md5;basic(HTTP Basic,走 Authorization 头,不写请求体签名);none |
sign_field | 签名参数名,默认 sign | |
drop_empty | 签名时是否排除空值(默认 true) | |
params_json_unescaped | 数组参数序列化为签名串时是否用 UNESCAPED_UNICODE(对方用 PHP 默认 json_encode 时要设为 false) | |
| 公共字段 | appid_field / ts_field / nonce_field / action_field | 对方用的参数名;置空字符串表示不发该字段(多路径接口通常把 action_field 置空) |
params_wrapper | 业务参数外层包名;空字符串 = 平铺在顶层 | |
| 动作 | actions | 我方动作 → 对方动作。值可以是动作名(单入口接口)或路径/完整 URL(多路径接口),例如 "create": "/api/host/create" |
| 字段 | fields | 我方字段 → 对方字段:product_code/service_sn/hostname/cycle/os/remote_id/power_action |
| 响应 | response.format | json(默认)/ xml |
response.code_path / success_value | 用点号路径取状态码,并指定哪个值代表成功(如 status=1、code=0、ret=true) | |
response.msg_path | 错误信息路径 | |
response.data_path | 业务数据根路径("" 表示根就是数据) | |
response.map | 我方字段 → 对方字段:remote_id/ip/username/password/status/expire_at | |
response.list_path | 产品列表路径(支持点号路径,如 data.list.item) | |
response.item_map | 列表项字段映射:code/name/price/spec(spec 需指向一个对象) |
已实测的两种异构协议
| 协议 A | 协议 B | |
|---|---|---|
| 传输 | POST 表单 | POST JSON(参数包在 params) |
| 认证 | 请求体签名 md5(键值串+密钥) | 请求头 X-Api-Key / X-Merchant,无签名 |
| 入口 | 单入口,动作名在 act 字段 | 多路径 /api/host/create 等 |
| 响应 | JSON,成功码 status=1 | XML,成功码 code=0 |
| 字段名 | user / timestamp / host_id / out_trade_no | merchant / ts / host_id / out_trade_no |
| 实测结果 | 27/27 通过 | 26/26 通过 |
两者都在未修改任何代码的前提下跑通了「连通性测试 → 拉取产品 → 下单 → 支付 → 自动开通 → 回写实例编号/IP/账号密码/状态 → 续费 → 重启 → 重装 → 销毁」全链路。
注意:多路径接口下,未在 actions 中列出的动作会回落到「接口地址」那一栏配置的地址。 若对端返回「未知接口」,先检查是不是动作漏配了映射。
XML 接口的一个已知坑:若列表节点下只有一个子元素,SimpleXML转数组后可能变成对象而非数组, 导致列表解析异常。此时请把list_path指向重复节点的父级并确认对端至少返回两条,或优先使用 JSON 接口。
配置示例(表单 + MD5 追加密钥 + 平铺字段 + status=1 响应)
{
"method": "POST_FORM",
"auth": "append_secret",
"appid_field": "user",
"ts_field": "timestamp",
"nonce_field": "",
"action_field": "act",
"params_wrapper": "",
"actions": {
"test": "ping", "products": "product_list", "create": "host_create",
"status": "host_info", "renew": "host_renew", "power": "host_power",
"reinstall": "host_rebuild", "destroy": "host_delete"
},
"fields": {
"product_code": "product_code", "service_sn": "out_trade_no",
"hostname": "hostname", "cycle": "billing_cycle", "os": "os",
"remote_id": "host_id", "power_action": "op"
},
"response": {
"code_path": "status", "success_value": "1", "msg_path": "msg",
"data_path": "data", "list_path": "data.list",
"map": { "remote_id": "host_id", "ip": "ip", "username": "user",
"password": "pass", "status": "state", "expire_at": "due_date" },
"item_map": { "code": "code", "name": "name", "price": "price", "spec": "config" }
}
}
上面这套配置已通过端到端验证:目标服务端按自己的规则用 md5(键值串+密钥) 验签、返回 {"status":1,...}, 我方在未修改任何代码的前提下完成了「连通性测试 → 拉取产品 → 下单 → 支付 → 自动开通 → 回写实例编号/IP/账号密码 → 续费 → 重启 → 重装 → 销毁」全链路。
对接前需要从对方文档确认的信息
对接任何第三方系统(含商业 IDC 计费系统),只要先确认下面 8 项,就能直接落成配置:
- 接口形态:是单个入口(动作名放参数里)还是每个动作一个路径?请求方式是 GET / POST 表单 / POST JSON?参数是平铺还是包在某个字段里?
- 认证方式:请求体签名,还是放在请求头(如
X-Api-Key),还是 HTTP Basic? - 鉴权参数名:商户号/应用 ID、时间戳、随机串、动作名分别叫什么(不发的就置空)
- 签名算法:参与签名的字段范围、排序规则、是否排除空值、密钥拼接位置、摘要算法(md5 / hmac-sha1 / hmac-sha256)、大小写
- 动作名/路径:连通性、产品列表、开通、查状态、续费、开关机、重装、销毁分别是动作名还是路径
- 关键业务字段名:产品编码、我方单号、主机名、周期、系统模板、对端实例 ID、电源操作
- 响应结构:JSON 还是 XML?成功码字段与成功值、错误信息字段、数据根路径
- 数据字段名:对端实例 ID、IP、登录账号、密码、运行状态、到期时间
拿到这 8 项后:填配置 → 点「连通性测试」→ 点「拉取产品」确认字段映射正确 → 在产品映射里绑定本地产品 → 下一笔订单即可自动开通。
若对方协议包含本项目配置项覆盖不到的特殊约定(如签名为多段拼接、需要 RSA 签名、需要先取 token), 则新增一个驱动类实现 YxUpstreamDriver 接口的 8 个方法即可,业务层无需改动。
2.5 扩展新上游厂商(实现驱动类)
实现接口并登记即可,业务层零改动:
// includes/upstream.php
class YxUpstreamMyCloud extends YxUpstreamBase
{
public function code(): string { return 'mycloud'; }
public function test(): array { /* ... */ }
public function products(): array { /* ... */ }
public function create(array $params): array { /* ... */ }
public function status(string $remoteId): array { /* ... */ }
public function renew(string $remoteId, string $cycle, array $opt = []): array { /* ... */ }
public function power(string $remoteId, string $action): array { /* ... */ }
public function reinstall(string $remoteId, array $opt = []): array { /* ... */ }
public function destroy(string $remoteId): array { /* ... */ }
}
同时在 YxUpstream::drivers() 与 YxUpstream::driver() 中登记。
三、下游开放接口
3.1 接入准备
后台 →「应用与授权 → 下游接口」→ 新建应用,得到 AppID 与 AppSecret(密钥仅创建/重置时完整显示一次)。
可配置:
| 配置 | 说明 |
|---|---|
| 归属类型 | 客户自用(以某客户身份操作)/渠道/代理商(可为名下终端用户自动建档)/平台级(可操作全部客户) |
| 事件回调地址 | 我方主动推送事件的 URL |
| IP 白名单 | 支持精确 IP、1.2.3.* 前缀通配、10.0.0.0/8 网段;留空不限制 |
| 每分钟调用上限 | 超过返回 429 |
| 动作授权 | 勾选后仅允许指定动作;不勾选表示全部允许 |
3.2 公共参数与签名
入口:POST https://你的域名/api/v1.php(也支持 GET,便于排查)
| 参数 | 必填 | 说明 |
|---|---|---|
| appid | 是 | 应用标识 |
| action | 是 | 动作名 |
| ts | 是 | Unix 时间戳(±300 秒) |
| nonce | 是 | 随机串,8-64 位,同应用内一次性(防重放) |
| sign | 是 | 见下 |
| 其它 | 视动作 | 业务参数,仅支持一级标量(不接受嵌套数组) |
签名算法
1. 取所有请求参数(除 sign),过滤掉空值
2. 按参数名 ASCII 升序排序
3. 拼成 k1=v1&k2=v2&k3=v3
4. sign = HMAC-SHA256(拼接串, AppSecret),输出小写十六进制
PHP 示例
function yx_sign(array $p, string $secret): string {
unset($p['sign']);
$p = array_filter($p, fn($v) => $v !== '' && $v !== null);
ksort($p);
$pairs = [];
foreach ($p as $k => $v) { $pairs[] = $k . '=' . $v; }
return hash_hmac('sha256', implode('&', $pairs), $secret);
}
$params = [
'appid' => 'yxappidxxxx',
'action' => 'order.create',
'ts' => time(),
'nonce' => bin2hex(random_bytes(8)),
'product_id' => 1,
'cycle' => 'month',
'external_user_id' => 'U10086',
'external_sn' => 'ORDER-20261013-001',
];
$params['sign'] = yx_sign($params, '你的AppSecret');
$ch = curl_init('https://你的域名/api/v1.php');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($params),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
]);
$resp = json_decode(curl_exec($ch), true);
Python 示例
import time, hmac, hashlib, json, secrets, requests
def yx_sign(p, secret):
p = {k: v for k, v in p.items() if k != 'sign' and v not in ('', None)}
s = '&'.join(f'{k}={p[k]}' for k in sorted(p))
return hmac.new(secret.encode(), s.encode(), hashlib.sha256).hexdigest()
params = {
'appid': 'yxappidxxxx', 'action': 'product.list',
'ts': str(int(time.time())), 'nonce': secrets.token_hex(8),
}
params['sign'] = yx_sign(params, SECRET)
r = requests.post('https://你的域名/api/v1.php', json=params, timeout=15)
print(r.json())
3.3 响应与错误码
{ "code": 0, "msg": "ok", "data": { }, "ts": 1730000000 }
| code | 含义 |
|---|---|
| 0 | 成功 |
| 400 | 参数错误(缺参数、未知动作、非法取值) |
| 401 | 验签失败(签名错、ts 过期、nonce 重放、appid 不存在) |
| 403 | 无权限(应用被禁用、IP 不在白名单、动作未授权、余额不足) |
| 404 | 对象不存在或不属于该应用 |
| 429 | 超出每分钟调用上限 |
| 500 | 服务端异常 |
3.4 动作清单
ping
连通性测试。返回服务端名称、版本、版本能力、服务器时间。
product.list
参数:group_id?、keyword? 返回:total、list[](product_id、code、name、group、type、cycle、price、setup_fee、stock、auto_setup、edition_min、description)、groups
product.detail
参数:product_id(或 product_code) 返回:产品基础信息 + cycles[](月/季/半年/年及对应折后价)+ upstream(是否已绑定上游,可自动开通)
order.create
参数:product_id、cycle、external_user_id、external_sn?、email?、phone?、company?
external_user_id是你系统里的用户标识;我方按appid + external_user_id自动建档并建立映射,后续所有接口都可用它定位external_sn为你的订单号,相同的 external_sn 会幂等返回同一订单,请务必传入以避免重复下单
返回:order_sn、total、status 等
order.pay
参数:order_sn、method
method=balance:扣我方账户余额(客户自用型应用适用)method=manual:你已自行收款,我方仅标记支付并交付(分销场景常用)
支付后会:生成业务实例 → 若产品绑定上游则入队开通 → 触发 order.paid 回调
order.query / order.list
order.query:order_sn order.list:external_user_id?、status?、page?、limit?
service.list / service.detail
service.detail 会返回 login_user / login_pass(密码在我方为 AES 加密存储,仅对授权应用解密返回)
service.renew
参数:service_sn、cycle?。本地到期日顺延;若有上游实例,同步入队通知上游。
service.operate
参数:service_sn、op
| op | 含义 | 是否改变计费状态 |
|---|---|---|
start | 上游开机(异步) | 否 |
stop | 上游关机(异步) | 否 |
reboot | 上游重启(异步) | 否 |
reinstall | 重装系统(异步,可带 os) | 否 |
suspend | 业务暂停(改状态 + 上游关机) | 是 |
unsuspend | 业务恢复(改状态 + 上游开机) | 是 |
destroy | 业务终止(改状态 + 上游销毁) | 是 |
sync | 同步上游状态 | 否 |
异步操作返回 {"queued": true, "task_id": 12}。
balance.query
参数:external_user_id?(客户自用型应用可省略,返回自身余额)
license.query
参数:domain。返回 authorized、status、message、edition、expire_at
3.5 事件回调(我方 → 下游)
配置了回调地址后,下列事件会推送(经任务队列,失败自动重试):
| 事件 | 触发时机 | data 关键字段 |
|---|---|---|
order.paid | 订单支付完成 | order_sn、total、service_sn、service_status、remote_id、external_sn、paid_at |
请求体
{ "appid": "yxappidxxxx", "event": "order.paid", "ts": 1730000000,
"nonce": "…", "data": { … }, "sign": "…" }
验签:canonical = "appid=..&data=<JSON原文>&event=..&nonce=..&ts=.."(key 升序),sign = HMAC-SHA256(canonical, AppSecret)
应答:HTTP 2xx 视为送达;否则按退避策略重试。
四、任务队列
后台 →「业务管理 → 任务队列」可查看所有异步任务:类型、对象、状态、尝试次数、下次重试时间、错误原因,并支持立即执行 / 手动重试 / 取消 / 清理历史。
| 状态 | 含义 |
|---|---|
| pending | 待执行(含等待下次重试) |
| running | 执行中(超过 10 分钟未结束会被自动释放重跑) |
| done | 已完成 |
| failed | 已达重试上限 |
| cancelled | 已取消 |
退避策略:1 → 5 → 15 → 60 → 120 分钟,默认最多 5 次。 去重:同一类型 + 同一对象默认只保留一个排队/执行中的任务,避免重复开通(事件通知类任务不去重)。
依赖定时任务:请确保宝塔「计划任务」已配置(建议每 5-10 分钟):
php /www/wwwroot/你的站点目录/tools/cron.php --token=后台生成的任务令牌
cron 每次会执行:到期标记 → 自动续费 → 任务队列 → 清理过期 nonce/日志/历史任务 → 授权巡检。
五、排查手册
| 现象 | 排查方向 |
|---|---|
| 401 验签失败 | 是否对空值也参与签名;是否对 param 做了 URL 编码后再签名(应使用原始值);AppSecret 是否被重置 |
| 401 请求已过期 | 服务器时间未同步(ntpdate) |
| 401 nonce 已被使用 | 每次请求必须用新的随机串 |
| 403 IP 不在白名单 | 后台「下游接口 → 编辑」核对白名单与出口 IP |
| 429 | 调低并发或提高每分钟上限 |
| 下单成功但业务一直 pending | 看「任务队列」:上游是否失败;确认计划任务是否在跑 |
| 业务一直未开通 | 产品是否绑定上游;上游连通性测试是否通过 |
| 回调未到达 | 下游地址是否公网可达;看任务队列中 downstream.notify 的错误详情 |
| 上游开通成功但状态不对 | 用 service.operate op=sync 或后台「同步上游状态」 |
六、安全要点
- 全链路 HTTPS;AppSecret 仅用于签名,不要下发到客户端浏览器
- nonce 一次性 + ±300 秒时效,杜绝重放
- IP 白名单、动作最小授权、每分钟限流三重约束
- 数据范围严格隔离:不同应用之间无法互相查看订单与业务(返回 404 而非 403,避免泄露存在性)
- 全部调用留痕(appid、动作、结果、耗时、IP、脱敏参数),保留 30 天
- 上游密钥与 AppSecret 均以 AES-256-CBC 加密存储,不会明文落库