上游 / 下游接口对接说明

本文覆盖两条链路:


一、整体架构

下游系统 ──HTTPS(HMAC签名)──► api/v1.php ──► 鉴权(签名/时效/防重放/白名单/限流)
                                            └─► YxApiGateway 动作分发
                                                  └─► 订单/业务/财务
                                                        └─► 任务队列(yx_task)
                                                              └─► 上游驱动(HTTP/Mock) ──► 供应商接口
                                                              └─► 事件回调 ──► 下游 notify_url

关键设计:所有上游调用都走异步任务队列,由 tools/cron.php 驱动。原因:


二、上游对接

2.1 配置

后台 →「业务管理 → 上游对接」:

  1. 新增供应商,选择驱动: | 驱动 | 说明 | |---|---| | 本地模拟(mock) | 不依赖真实上游,用于打通全流程与联调 | | 通用 HTTP/JSON(http) | 对接任意标准上游接口,需填接口地址、appid、密钥 |
  1. 点「连通性测试」验证可达(结果写入列表)
  2. 点「拉取产品」查看上游可售产品与编码
  3. 在「产品映射」中把本地产品绑定到上游产品编码,并填写规格 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=..&params=<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

actionparams期望返回
ping无code=0 即连通
products无data.list: [{code,name,price,spec}]
createproduct_code、规格字段、service_sn、hostname、cycle、osremote_id、ip、username、password、status
statusremote_idstatus(running/stopped/…)、ip
renewremote_id、cyclecode=0
powerremote_id、action(start/stop/reboot)code=0
reinstallremote_id、oscode=0
destroyremote_idcode=0

2.4 对接异构协议(可配置驱动)

第三方商业系统的接口约定各不相同(请求方式、签名算法、字段名、响应结构都可能不一样)。 为避免「每接一家就要改一次代码」,本项目提供 自定义 HTTP 驱动:把对方协议写进后台配置即可完成对接,无需改代码。

选择方式:后台「业务管理 → 上游对接 → 新增供应商 → 对接驱动」选 自定义 HTTP(字段/签名/响应全可配),然后在「接口协议配置」里填 JSON(点「填入默认模板」会给出完整骨架)。

可配置项

分组配置项说明
传输methodPOST_JSON / POST_FORM / GET
headers附加请求头,支持占位符 {appid} {secret} {ts} {nonce},例如 {"X-Api-Key":"{secret}","X-Merchant":"{appid}"}
签名authappend_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.formatjson(默认)/ 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=1XML,成功码 code=0
字段名user / timestamp / host_id / out_trade_nomerchant / 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 项,就能直接落成配置:

  1. 接口形态:是单个入口(动作名放参数里)还是每个动作一个路径?请求方式是 GET / POST 表单 / POST JSON?参数是平铺还是包在某个字段里?
  2. 认证方式:请求体签名,还是放在请求头(如 X-Api-Key),还是 HTTP Basic?
  3. 鉴权参数名:商户号/应用 ID、时间戳、随机串、动作名分别叫什么(不发的就置空)
  4. 签名算法:参与签名的字段范围、排序规则、是否排除空值、密钥拼接位置、摘要算法(md5 / hmac-sha1 / hmac-sha256)、大小写
  5. 动作名/路径:连通性、产品列表、开通、查状态、续费、开关机、重装、销毁分别是动作名还是路径
  6. 关键业务字段名:产品编码、我方单号、主机名、周期、系统模板、对端实例 ID、电源操作
  7. 响应结构:JSON 还是 XML?成功码字段与成功值、错误信息字段、数据根路径
  8. 数据字段名:对端实例 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?

返回:order_sn、total、status 等

order.pay

参数:order_sn、method

支付后会:生成业务实例 → 若产品绑定上游则入队开通 → 触发 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 或后台「同步上游状态」

六、安全要点