对外 API v1 · 开发文档
这份文档写给下游对接方:分销商、客户自己的脚本、第三方系统。
读完它你可以自己写程序,把商品、价格、服务、余额、开关机、开通和续费都接进去。
本版本一共 15 项能力:5 个只读接口、7 个服务操作、3 个任务接口。
每一项的请求参数、应答字段、专属错误码都在下面各自的卡片里。
全部能力都走同一个入口,用同一把 API 密钥,回同一种 JSON 信封。
接口入口
https://www.xxisp.com/api.php
每个接口都拼在它后面,例如商品列表就是
https://www.xxisp.com/api.php/v1/products。
如果这样调不通(服务器返回 404),改用不依赖服务器配置的写法:
https://www.xxisp.com/api.php?path=/v1/products。
这一页怎么用
左栏是所有接口的索引,按方法打了徽章,点一下跳到右边对应的卡片。
每一块代码右上角都能一键复制;多语言的示例用上面的页签切换。
窄屏时左栏会收起来,点「展开接口索引」再选。
一、这个接口能做什么
下面是一张总览。每一条都能在左边索引里点进去看细节。
只读(查):
- 商品列表和单个商品,带你这个账号每个周期的价
- 你的云服务清单和单台服务(含 IP、规格、到期日)
- 你这个账号的余额
写(真的会动机器、会动钱):
- 同步信息:让本地记录跟上机房
- 开机 / 关机 / 重启
- 开通:按商品和周期下订单,并从账户余额扣款,成功后真正开机器
- 续费:为某台服务开续费账单并扣账户余额,成功后顺延到期日
轮询:
- 开机 / 关机 / 重启 / 同步是异步的,先回一个任务号,再去查任务结果
- 还有两个接口用来列任务和查"我这一族能做哪些操作"
明确不提供的(很重要,别按它们写代码):删除机器、重装系统、重置密码、
强制关机、暂停 / 解除暂停、锁定 / 解锁。这些要么不可逆、要么会改掉客户手上的
凭据、要么是我们催费和封停的手段。它们不在操作表里,路由都匹配不上。
二、接口入口(三种写法)
本站的接口只有一个入口文件:站点根目录下的 api.php。
把下面的 <站点> 换成本站的 API 域名(以本站「会员中心 → 代理分销 → API 管理」
页显示的「API 接口地址」为准)。
三种写法同时有效,选一种用就行。它们的区别只在"依赖不依赖服务器配置"。
写法一:`/api.php/v1/...`(主用法)
#GET https://<站点>/api.php/v1/productsapi.php 是一个真实存在的文件,服务器会把它交给 PHP 执行;
后面的 /v1/products 由 PHP 自己的 PATH_INFO 接住。
这一种不需要站长改任何服务器配置,所以它是主用法。
写法二:`?path=/v1/...`(最保险,一定有效)
#GET https://<站点>/api.php?path=/v1/products为什么要有这一种:有的服务器在 PHP 的 location 里加了try_files $uri =404,或者 if (!-f $request_filename) { return 404; }。
那种配置下,写法一里的 /api.php/v1/products 不是一个真实文件,
会被服务器直接判成 404 —— 你会以为是接口没做,其实是服务器配置那一层。
写法二不依赖任何服务器行为,只要 api.php 能执行就行。
所以排障的第一条命令就是:把写法一换成写法二。
写法三:裸路径 `/v1/...`(站长把 API 做成了独立域名时用)
#GET https://<站点>/v1/products这一种要站长把 API 域名做成一个独立站点,并在那里加一条 rewrite:
location ^~ /v1/ {
rewrite ^/v1/(.*)$ /api.php?path=/v1/$1 last;
}加了之后三种写法同时都能用,不会互相打架。没加的话裸路径是 404。
一个容易写错的细节
#写法二里的 path 只取路径部分。即使你顺手把查询串写进去,例如?path=/v1/products?cycle=monthly,后面的 cycle=monthly 也会被丢掉。
正确写法是把查询参数放在外层:
GET https://<站点>/api.php?path=/v1/products&cycle=monthly支持的方法
#只读接口收 GET 和 HEAD(HEAD 只回响应头、不回 JSON 正文)。
写接口收 POST。
用错方法不会 404,会拿到 405 加 method_not_allowed,并且文案里写明
这条路由收什么方法。这一点是有意做的:一个全局的"只许 GET"和一张有 POST
的路由表不可能同时成立,所以方法跟着每一条路由走。
三、身份:一把 API 密钥
密钥长什么样
#idc_ 开头,后面 40 个十六进制字符,一共 44 个字符。
下面这个是一眼能看出是假的例子,不是真密钥:
idc_0123456789abcdef0123456789abcdef01234567怎么拿到密钥
#- 登录本站会员中心。
- 打开「代理分销 → API 管理」,地址是
/profile-api.php。 - 在那一页创建一把 API 密钥。
创建、重命名、轮换、吊销这四个动作,每一个都要当场用绑定手机号
收一条短信验证码(每个操作验一次,没有"验一次管一段时间")。
- 密钥明文只在创建或轮换的那一刻显示一次。本站数据库里只存它的
sha256 摘要,之后没有任何办法再看一遍。请当场抄进你的密码管理器
或者配置中心。忘了就"重新生成"(轮换)一次,旧密钥当场失效。
怎么把密钥发过来
#按下面的优先级。代码就是按这个顺序取,取到第一个非空的为止:
| 顺序 | 写法 | 说明 |
|---|---|---|
| 1 | Authorization: Bearer <你的密钥> | 推荐用法,不会进访问日志 |
| 2 | X-Api-Key: <你的密钥> | 有些代理会吃掉 Authorization 头,那时用它 |
| 3 | ?apikey=<你的密钥> | 不推荐,见下面的警告 |
?apikey= 这种写法请不要用。URL 会进服务器的访问日志、进浏览器历史、
进 Referer 头,等于把你的密钥到处撒。它存在只是为了照顾确实设不了
请求头的旧客户端。
一把密钥只能读写一个账号的数据
#密钥属于创建它的那一个客户。接口只返回这个账号自己的数据。
地址里加任何参数都读不到别的账号的数据。就算你手写别人的编号,
拿到的也是 404,而不是 403 —— 本站不会告诉你"这个编号存在,只是不是你的"。
这条规则对只读接口和写接口一视同仁:拿自己的密钥去操作别人的机器,
也是 404,而且响应里没有一个字节属于那个账号。
密钥被吊销,或者账号本身不是正常状态之后,所有调用都会收到 401 加invalid_api_key。"密钥不存在"、"密钥已吊销"、"账号被停用"这三种情况
返回完全一样的应答,这是故意的:否则别人可以拿它反推哪些编号存在。
请求示例代码
#同一个请求,四种语言。四种都能直接跑(填上你的域名和密钥)。
API="https://<站点>/api.php"
KEY="你的API密钥"
curl -s -H "Authorization: Bearer $KEY" "$API/v1/services"<?php
$api = 'https://<站点>/api.php';
$key = '你的API密钥';
$ch = curl_init($api . '/v1/services');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key],
CURLOPT_TIMEOUT => 20,
]);
$body = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$j = json_decode((string) $body, true);
if (!is_array($j)) {
throw new RuntimeException('不是 JSON,HTTP ' . $code);
}
if (empty($j['ok'])) {
// 认 code,不要认 message
throw new RuntimeException('接口报错 ' . $j['error']['code'] . ' HTTP ' . $code);
}
foreach ($j['data']['services'] as $s) {
echo $s['name'], ' ', $s['status_label'], ' 到期 ', (string) $s['next_due_date'], "\n";
}import json
import urllib.request
API = "https://<站点>/api.php"
KEY = "你的API密钥"
req = urllib.request.Request(API + "/v1/services",
headers={"Authorization": "Bearer " + KEY})
with urllib.request.urlopen(req, timeout=20) as resp:
data = json.loads(resp.read().decode("utf-8"))
if not data.get("ok"):
# 认 code,不要认 message
raise RuntimeError("接口报错 %s" % data["error"]["code"])
for s in data["data"]["services"]:
print("%s %s 到期 %s" % (s["name"], s["status_label"], s["next_due_date"]))const API = 'https://<站点>/api.php';
const KEY = '你的API密钥';
const res = await fetch(API + '/v1/services', {
headers: { Authorization: 'Bearer ' + KEY },
});
const body = await res.json(); // 4xx / 5xx 也是 JSON 信封
if (!body.ok) {
// 认 code,不要认 message
throw new Error('接口报错 ' + body.error.code + ' HTTP ' + res.status);
}
for (const s of body.data.services) {
console.log(`${s.name} ${s.status_label} 到期 ${s.next_due_date}`);
}四、应答格式(信封)
成功
#{
"ok": true,
"api": "idcb-public-api",
"version": "v1",
"request_id": "6701dfb649b5364c",
"data": { }
}失败
#{
"ok": false,
"api": "idcb-public-api",
"version": "v1",
"request_id": "8f606307b32b21b2",
"error": {
"code": "invalid_api_key",
"message": "密钥无效。"
}
}五条固定规则
#ok永远在最外层。先判它,再决定读data还是读error。- 成功的应答里没有
error键;失败的应答里没有data键。
不是 "error": null,是根本没有这个键。所以可以一句
if (r.error) 判死,不用管 null 和缺键两种形状。
error.code是给机器看的:英文 snake_case,稳定,不会被翻译。
error.message 是给人看的:简体中文,文案会改。
请只把 message 打进日志,绝不要用程序去匹配它。
request_id每次请求都不同,是一个 16 位的十六进制字符串。
报障时把它提供给我们,我们就能对到那一条请求记录。
- 有一个例外要多看一眼:
error.detail只在真的有细节时才出现。
目前只有 409 那一族会带它(里面是"在途那一笔"的任务号)。
响应头
#| 头 | 值 | 说明 |
|---|---|---|
Content-Type | application/json; charset=utf-8 | 永远是 JSON |
Cache-Control | no-store, no-cache, must-revalidate | 不允许任何中间层缓存 |
Pragma | no-cache | 同上,兼容老客户端 |
X-Content-Type-Options | nosniff | 防浏览器把 JSON 当页面渲染 |
X-Api-Version | v1 | 当前版本 |
Retry-After | 60 | 只在 429 时出现,单位是秒 |
接口不发 Set-Cookie,也不会 302 / 303 跳转。任何一次调用拿到的
都是 JSON 加状态码。
成功不止 200:还有一个 202
#| HTTP | 什么时候 |
|---|---|
| 200 | 只读接口;以及当场就有结果的写操作(VNC / 续费 / 开通) |
| 202 | 异步写操作已受理(同步信息 / 开机 / 关机 / 重启) |
202 的语义是"我收下了、还没做完"。拿到 202 就去轮询任务,
不要以为操作已经完成。202 的应答里 state 一定是 queued 或 running,async 一定是 true。
拿不到 202 的环境(服务器不支持提前结束请求,而且起不了后台工作进程)
会退回 200 加 async: false,那次请求会等到机房返回为止。
所以请据 async 分辨,不要据状态码分辨。
五、错误码总表
这是下游密钥可能遇到的全部错误码。error.code 稳定,error.message 会变。
| HTTP | error.code | 什么时候会出现 |
|---|---|---|
| 400 | bad_request | 请求体不合法(例如 op 不认识、product_id 没带、selection 结构不对) |
| 400 | bad_cycle | cycle 不是 monthly / quarterly / semiannually / annually 之一 |
| 400 | service_not_enabled | 这台服务不能下发:独立产品(没绑机房)、没绑机器、或者机房信息不完整 |
| 401 | unauthenticated | 一个凭据都没带 |
| 401 | invalid_api_key | 密钥无效。不存在 / 已吊销 / 格式不对 / 账号不是正常状态,全都归这一个 code |
| 402 | insufficient_balance | 账户余额不足,这一笔花不起。error.detail 里带 balance / required / shortage / currency。这种情况下订单 / 账单 / 余额一个都不会产生 |
| 403 | operation_not_allowed | 这个操作不对客户密钥开放(目前只有 VNC) |
| 404 | not_found | 商品、服务或任务不存在,或者存在但不属于这个账号 |
| 404 | endpoint_not_found | 路径不在本版本的接口表里。/v1 本身也是这个 |
| 405 | method_not_allowed | 这条路由不收这个 HTTP 方法 |
| 409 | operation_busy | 同一台服务的同一个操作正在处理中。响应里带 error.detail.op_id |
| 429 | rate_limited | 触发限流,响应头带 Retry-After |
| 500 | internal_error | 本站内部错误。请把 request_id 提供给我们 |
| 503 | api_disabled | 本站的接口整体关闭中。默认就是关闭的 |
| 503 | not_enabled | 本站的密钥功能还没启用(数据库迁移没执行) |
| 503 | operation_store_unavailable | 写操作功能还没启用(任务表那个迁移没执行)。只读接口不受影响 |
同一个 code 对应的 HTTP 状态码是固定的。
反过来,一个 503 可能是三个不同的 code,所以判断时请认 error.code,
不要只认状态码。
还有一类"错误码"不在 `error` 里,在 `operation.outcome` 里
#开通和续费是同步操作:它们先落任务行再干活。所以有两层失败:
- 请求本身写错了 / 钱不够 ✗ 那是一条同步的 4xx,
走上面的 error(例如 402 insufficient_balance)✓
这种情况下什么都没发生,也不会留下任务行 ✓
- 请求没问题,但业务没做成 ✗ HTTP 是 200,
ok 是 true,而 data.operation.state 是 failed ✓
这时稳定错误码在 data.operation.outcome 的最前面,
用全角竖线隔开,形如 provision_failed|订单已创建… ✓
后者可能出现的码:
| 码 | 什么时候 |
|---|---|
payment_failed | 扣款那一步失败了(账单不属于这个账号、或者余额支付的其它失败) |
insufficient_balance | 扣款那一步判余额不足(并发下前面查够、这里不够) |
provision_failed | 钱已经扣了、账单已付清,但机器没开出来。result.needs_operator 是 true |
renew_failed | 钱已经扣了、到期日已顺延,但机房那一段没完成。result.needs_operator 是 true |
upstream_failed | 机房/业务类拒绝,而且没给出更具体的码(兜底) |
所以判"到底成没成"请两步都看:先 ok / HTTP,再看operation.state 和 operation.outcome 的前缀。
这里没有列出的 code 不会由下游密钥触发。如果你收到的 error.code
不在这张表里,请把 request_id 提供给我们。
两个真实例子:
{
"ok": false,
"api": "idcb-public-api",
"version": "v1",
"request_id": "8f606307b32b21b2",
"error": {
"code": "invalid_api_key",
"message": "密钥无效。"
}
}{
"ok": false,
"api": "idcb-public-api",
"version": "v1",
"request_id": "b0255c41667232df",
"error": {
"code": "api_disabled",
"message": "本站的 API 接口当前未开放。请联系服务商。"
}
}六、钱:价格与折扣
这一节最容易搞错,请读完。
报出来的价就是下单会收的价
#prices[].price 不是参考价。本站的接口和收银台调的是同一段代码,
同一个客户、同一个商品、同一个周期算出来的数逐分相同。
所以你可以放心拿它给自己的客户报价。
| 字段 | 含义 |
|---|---|
list_price | 折前价 |
price | 实际要收的价 |
discount_percent | 这次实际施加的减免百分比,15.00 表示减 15% |
saving | 减免了多少钱,等于 list_price 减 price |
op=order 下订单时,账单金额和订单金额也是同一次计算的结果,
不是两处各算一遍碰巧相等。
金额是字符串,不是数字
#所有金额都是两位小数的字符串:"85.00"、"123.45"、"0.00"。
不是数字类型,也不是浮点数。请不要转成浮点去累加,要算就按定点小数
或者换算成整数分来处理。金额为 null 只出现在 priced 为假时。
编号、月份、端口、库存、天数这些是数字类型。
布尔字段(auto_renew、auto_provision、required、is_default、has_password、priced、dispatched、async)是真假值。
影子模式:报的是标准价
#本站的客户等级折扣有一个开关,有三个状态:关、影子、开。
影子模式下,接口报的是标准价,也就是 price 等于 list_price、discount_percent 为 0.00。
原因是影子模式的定义就是"算给你看,但一分钱都不动"。
收银台在影子模式下收的就是标准价。
如果接口报一个"开关打开后会是"的价格,那它报的就是一个客户不会被收的价,
你拿它去对账,只会对出一笔不存在的差额。
所以不要以为影子模式等于"我可以省一点"或者"我能在报价里预览折扣"。
影子模式下折扣不生效,报的价就是标准价,收的钱也是标准价。
怎么知道这个客户现在有没有折扣
#看数字,不要看开关。接口不暴露本站折扣开关的状态。
判断方法只有一个:
discount_percent大于0.00,说明这个客户在这个商品上正在享受折扣,
price 就是折后价。
discount_percent等于0.00,说明现在没有任何折扣被施加,
price 等于 list_price。
你不需要、也没有办法从接口区分"开关关着"和"开关在影子模式",
因为这两种情况对你要做的事完全一样:要收的就是 price。
需要知道开关到底是哪一种、命中了哪条规则,那是本站运营侧的试算工具
要做的事,请联系本站管理员,不要写进你的对接程序。
开单和续费会**从账户余额扣款**
#这一点必须说清楚,免得你按"调一下只是开一张单"去写程序:
op=order(开通):按商品与周期下订单,并从账户余额扣款。
余额不足会直接拒绝,不会下单、不会开通。
扣款成功后由本站既有的开通流程真正开机器。
op=renew(续费):为那台服务开续费账单并从账户余额扣款,
金额取本站为该服务设定的续费价。余额不足会直接拒绝,
不会开单、不会续期。扣款成功后由既有的续费流程顺延到期日并同步机房。
扣款用的就是客户在账单页点「用余额支付」的同一个函数,
所以"接口扣的钱"和"客户自己点一下扣的钱"是同一笔账、同一种记法。
余额不足那一笔什么都不会发生:不会下单、不会开单、不会扣款、
订单和账单一张都不会产生。这是刻意的(先查余额,再落单,最后才扣款)。
⚠️ 这个结果可能以两种形状出现,你的程序两种都要认:
- 402 加
error.code = insufficient_balance,
error.detail 里带 balance / required / shortage / currency 四个数。
这种情况下连任务行都没有。
- 200 加
ok: true,而operation.state是failed,
operation.outcome 的最前面是 upstream_failed|账户余额不足:…
(那句中文原话里就带着"需要多少 / 当前多少 / 还差多少"三个数)。
所以判"到底成没成"一律要两步都看:先看 HTTP 和 ok,
再看 operation.state 与 operation.outcome 的前缀。
只认 HTTP 状态码的程序会漏掉第二种。
钱扣了但机器没开出来,怎么办
#这种结果一定会被说出来,不会静默成功:
- 任务变成
failed,outcome的最前面是稳定错误码,形如
provision_failed|… 或 renew_failed|…(后面接机房的原话)。
result.needs_operator是true,一眼看得出"要找客服"。- 钱不退、账单保持已付。 这和"客户自己支付时撞上同样情况"的处置
一模一样:订单 / 账单 / 服务那几行都留着,由人工在后台处理。
重复提交解决不了问题(那会让账单被扣两次的担心变成真的),
请把 op_id / request_id 提供给我们。
⚠️ 反过来的顺序绝不采用:先开机器再扣钱,成功了却扣不到钱 =
白送一台机器。那是本站最不能出的结果,所以顺序永远是
先查余额 → 再落单 → 再原子扣款结算。
余额只动一次
#同一个 Idempotency-Key 重放,命中的是上一次那一笔,
扣款函数不会被调第二次。所以"网络超时后重试"请务重用同一个键。
并发上,真正防止超支的是扣款那一步的条件更新
(余额 >= 金额 写在 SQL 里,靠数据库行锁排队),
不是接口这一层"查一下够不够"。所以两个人同时抢同一笔余额时,
只有一个人能扣到,另一个人会拿到余额不足。
配置项:报的是默认配置的价
#prices[] 里的价是按该商品的默认配置算出来的:
每个可配置项取标记为默认的那一项,没有标记就取第一项。
应答里的 selection 恒为 default,就是在告诉你这一点。
op=order 时你可以用 selection 指定配置({"选项编号": 取值编号})。
那时账单按你选的配置算。可选值和它们的 price_diff 在GET /v1/products/{id} 的 options 里。
七、写操作的幂等:两把锁
写操作真的会动机器。所以有两层保护,请都用上。
第一层:`Idempotency-Key` 请求头(防重放)
#同一个 Idempotency-Key 再打一次,回的是上一次那一笔任务,dispatched 是 false,绝不会重复下发。
Idempotency-Key: 你生成的一个唯一串- 键的原文不落库,本站只存它的 sha256 前 32 位,和存密钥一个待遇。
- 同一个键 + 同一个
op才算同一笔。换个键就是新的一笔
(所以正常的下一次关机不会被吞掉)。
- 不带这个头就不做这一层。 把"没带"当成"所有请求互为重复"会把
客户正常的下一次操作吞掉,那是错的。
- 网络超时之后重试,请用同一个键。这就是它存在的意义。
第二层:重复提交保护(防双击)
#不管带没带幂等键,同一台服务的同一个操作:
- 正在处理中(有
queued/running的任务)→ **409
operation_busy**,响应里 error.detail.op_id 告诉你那一笔的编号,
去轮询它,不要重复提交。
- 刚刚已经做成(60 秒内
done过)→ 200,state是done,
dispatched 是 false,outcome 里明说"刚刚已经执行过,本次没有重复下发"。
为什么第二种不报错:把"已经关过机了"报成错误,会让你的脚本以为操作失败了,
而事实是目标状态已经达成。那是成功。
- 同步操作(开通 / 续费)不查这两条,因为它们自己就是幂等的:
已有未付续费账单会复用,订单有库存闸和独立订单号。
真要防重复下单,请带 Idempotency-Key。
并发下的上限(说清楚,不装作没有)
#那个"在途"判断是"先查后插",两个请求在同一毫秒进来时可能都查不到对方,
于是落两行任务。兜底是幂等键的唯一约束、机房自己的防重复、以及
"开机 / 关机"这个动作本身就幂等。真正会因为并发重复而多花钱的只有开通,
它的兜底是订单号而不是接口这层。所以:要严格防重复,请带幂等键。
八、限流:读一组,写一组
读和写是两组完全独立的桶。 这一点很重要:一次全量对账(几十次读)
不会把你的写额度打光,反过来也一样。
| 组 | 桶 | 默认额度 | 主体 |
|---|---|---|---|
| 读 | 密钥 | 60 次 / 分钟 | 每一把 API 密钥 |
| 读 | 来源 IP | 120 次 / 分钟 | 每一个来源 IP |
| 写 | 密钥 | 6 次 / 分钟 | 每一把 API 密钥 |
| 写 | 来源 IP | 30 次 / 分钟 | 每一个来源 IP |
- 为什么写给得这么少:一次写就是一次真的动机器(甚至真的开单)。
"一分钟六次关机"已经比任何真人操作都密。
- 窗口是 60 秒的滚动窗口:数最近 60 秒里已经发生过的调用次数。
- 两道闸(主体 + IP)同时生效。撞上哪一道都是 429 加
error.code = rate_limited,响应头里有 Retry-After(单位是秒)。
写操作的 429 文案和读的略有不同,但 code 一样。
- 撞上了请按
Retry-After退避,不要在窗口里一直重试。 - 额度按密钥分开算:一把密钥被限流,不影响同一个账号的另一把密钥,
也不影响别的账号。
- 具体额度可以由本站管理员调整。所以请不要把 60 或 6 写成常量,
也不要以"每分钟固定 N 次"来设计;按 429 和 Retry-After 自适应。
九、接口:商品
在售商品列表,带你这个账号每个周期的价。
参数:
| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
Authorization | header | string | 是 | Bearer <你的密钥>。另外两种写法见第三节 |
cycle | query | string | 否 | monthly / quarterly / semiannually / annually 之一。给了不在这四个里的值,当场 400 加 bad_cycle,不会静默退回月付 |
注意:cycle 只决定应答里 default_cycle 回显哪一个周期。
每一个商品返回的是它所有有定价的周期的价,不是一个周期的价。
一次全报出来才好对账。
请求示例:
curl -s -H "Authorization: Bearer 你的API密钥" \
"https://<站点>/api.php/v1/products?cycle=annually"应答(data 里):
| 字段 | 类型 | 说明 |
|---|---|---|
caller | 对象 | 这次是谁在调,见第十一节 |
default_cycle | 字符串 | 你传的 cycle,没传就是 monthly |
count | 数字 | products 里有多少个商品 |
products | 数组 | 商品对象,见下 |
query_count | 数字 | 诊断计数,见第十五节 |
商品对象:
| 字段 | 类型 | 说明 |
|---|---|---|
id | 数字 | 商品编号 |
name | 字符串 | 商品名 |
description | 字符串 | 商品说明,最多 2000 字 |
category | 对象 | { "id": 数字, "name": 字符串 } 商品分组 |
kind | 字符串 | cloud(云产品,自动开通)或 manual(独立产品,人工开通) |
kind_label | 字符串 | 上面那个的中文,例如 云产品(自动开通) |
status | 字符串 | on_sale 或 off_sale |
stock | 数字 | 库存,-1 表示不限 |
auto_provision | 布尔 | 付款后是否自动开通 |
pricing_cycles | 数组 | 这个商品真的有定价的周期,固定按 月 / 季 / 半年 / 年 排 |
prices | 数组 | 每个周期一项,见下 |
options | 数组 | 可配置项,见下 |
商品列表只列在售的商品。单个商品接口才查得到已下架的。
prices 数组里每一项:
| 字段 | 类型 | 说明 |
|---|---|---|
cycle | 字符串 | 实际算价用的周期 |
cycle_label | 字符串 | 月付 / 季付 / 半年付 / 年付 |
priced | 布尔 | 有没有算出价 |
pricing_note | 字符串 | priced 为假时,这里是用中文说明原因(例如"商品未定价") |
price | 字符串或 null | 实际要收的价,两位小数 |
list_price | 字符串或 null | 折前价 |
discount_percent | 字符串或 null | 这次实际施加的减免百分比 |
saving | 字符串或 null | 减免了多少钱 |
selection | 字符串 | 恒为 default,表示这一项价是按默认配置算的 |
duration_id | 数字 | 本站内部的周期编号,下单时用 |
months | 数字 | 这个周期是几个月 |
currency | 字符串 | 三位大写币种代码 |
priced 为假时,price / list_price / discount_percent / saving
都是 null,并且 selection / duration_id / months / currency
里只有 currency 会出现。请按"可能缺键"来解析。
options 数组里每一项:
| 字段 | 类型 | 说明 |
|---|---|---|
id | 数字 | 可配置项编号 |
name | 字符串 | 名称,例如 内存 |
required | 布尔 | 是不是必选 |
values | 数组 | 可选值,每项 { "id", "label", "price_diff", "is_default" } |
price_diff 是这个选项相对基准价的加价(字符串金额)。本站不返回成本。
应答示例(products 只留第 1 个商品,prices 只留第 1 个周期;
真实应答里会有 count 那么多项,字段名与取值类型一字未改):
{
"ok": true,
"api": "idcb-public-api",
"version": "v1",
"request_id": "f5d40aed86f34abb",
"data": {
"caller": {
"type": "downstream",
"type_label": "下游对接方(客户密钥)",
"client_id": 12,
"client_name": "张三",
"scope": "client",
"key_prefix": "idc_live_aaa",
"key_label": "张三的下游脚本"
},
"default_cycle": "monthly",
"count": 2,
"products": [
{
"id": 11,
"name": "香港云服务器 1核1G",
"description": "测试用商品",
"category": { "id": 4, "name": "云服务器" },
"kind": "cloud",
"kind_label": "云产品(自动开通)",
"status": "on_sale",
"stock": -1,
"auto_provision": true,
"pricing_cycles": ["monthly", "annually"],
"prices": [
{
"cycle": "monthly",
"cycle_label": "月付",
"priced": true,
"pricing_note": "",
"price": "85.00",
"list_price": "100.00",
"discount_percent": "15.00",
"saving": "15.00",
"selection": "default",
"duration_id": 69,
"months": 1,
"currency": "USD"
}
],
"options": [
{
"id": 21,
"name": "内存",
"required": true,
"values": [
{ "id": 31, "label": "1G", "price_diff": "0.00", "is_default": true },
{ "id": 32, "label": "2G", "price_diff": "20.00", "is_default": false }
]
}
]
}
],
"query_count": 30
}
}应答示例(失败):cycle 给了不在白名单里的值。
{
"ok": false,
"api": "idcb-public-api",
"version": "v1",
"request_id": "ce2d9cd1eb58a942",
"error": {
"code": "bad_cycle",
"message": "cycle 只能是 monthly、quarterly、semiannually、annually 之一。"
}
}本接口专属的错误:
| HTTP | code | 什么时候 |
|---|---|---|
| 400 | bad_cycle | cycle 不在那四个值里 |
一个商品,包含已下架的商品(按编号问就答)。字段和列表接口里的商品对象一样。
参数:
| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
Authorization | header | string | 是 | Bearer <你的密钥> |
{id} | path | 数字 | 是 | 商品编号,十进制数字,1 到 18 位 |
cycle | query | string | 否 | 取值同上一节。合法值决定应答里的 default_cycle;这个商品没有那个周期的定价时退回 monthly,而且从 default_cycle 看得出来。非法值照样 400 加 bad_cycle |
{id} 只接受十进制数字。别的写法(负数、带字母、超长)会落到 404endpoint_not_found,不会有"参数错误"这种提示。
应答(data 里)有三个键:caller、default_cycle、product
(一个商品对象,字段与列表接口完全一样)、query_count。
没有 count。
请求示例:
curl -s -H "Authorization: Bearer 你的API密钥" \
"https://<站点>/api.php/v1/products/11?cycle=annually"应答示例(只留第 1 个周期):
{
"ok": true,
"api": "idcb-public-api",
"version": "v1",
"request_id": "ef2ad845d93f89bf",
"data": {
"caller": {
"type": "downstream",
"type_label": "下游对接方(客户密钥)",
"client_id": 12,
"client_name": "张三",
"scope": "client",
"key_prefix": "idc_live_aaa",
"key_label": "张三的下游脚本"
},
"default_cycle": "monthly",
"product": {
"id": 11,
"name": "香港云服务器 1核1G",
"description": "测试用商品",
"category": { "id": 4, "name": "云服务器" },
"kind": "cloud",
"kind_label": "云产品(自动开通)",
"status": "on_sale",
"stock": -1,
"auto_provision": true,
"pricing_cycles": ["monthly", "annually"],
"prices": [
{
"cycle": "monthly",
"cycle_label": "月付",
"priced": true,
"pricing_note": "",
"price": "85.00",
"list_price": "100.00",
"discount_percent": "15.00",
"saving": "15.00",
"selection": "default",
"duration_id": 69,
"months": 1,
"currency": "USD"
}
],
"options": []
},
"query_count": 27
}
}应答示例(失败):编号不存在。
{
"ok": false,
"api": "idcb-public-api",
"version": "v1",
"request_id": "24aeaa954beff0cf",
"error": {
"code": "not_found",
"message": "没有这个商品。"
}
}本接口专属的错误:
| HTTP | code | 什么时候 |
|---|---|---|
| 404 | not_found | 没有这个编号的商品 |
| 400 | bad_cycle | cycle 不在那四个值里 |
十、接口:服务
你的云服务清单,按服务编号从大到小排。
参数:
| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
Authorization | header | string | 是 | Bearer <你的密钥> |
这个接口不收任何查询参数。
请求示例:
curl -s -H "Authorization: Bearer 你的API密钥" \
"https://<站点>/api.php/v1/services"应答(data 里):
| 字段 | 类型 | 说明 |
|---|---|---|
caller | 对象 | 见第十一节 |
count | 数字 | services 里有多少台 |
manual_count | 数字 | 这个账号还有几台独立产品(人工开通那种)不在本接口里 |
manual_count_note | 字符串 | 上面那个数字的中文说明 |
services | 数组 | 服务对象,见下 |
query_count | 数字 | 诊断计数 |
服务对象:
| 字段 | 类型 | 说明 |
|---|---|---|
id | 数字 | 服务编号 |
name | 字符串 | 服务名,一般是主机名或域名 |
product | 对象 | { "id": 数字, "name": 字符串 } |
status | 字符串 | 本站数据库里的原始状态值,例如 Active |
status_label | 字符串 | 上面那个的中文,例如 正常 |
kind | 字符串 | 本接口只回云产品,所以恒为 cloud |
billing_cycle | 字符串 | 计费周期代码 |
billing_cycle_label | 字符串 | 计费周期中文 |
amount | 字符串 | 这台服务的金额,两位小数 |
currency | 字符串 | 币种代码 |
reg_date | 字符串或 null | 开通日期,Y-m-d |
next_due_date | 字符串或 null | 下次到期日,Y-m-d |
days_to_due | 数字或 null | 还有几天到期,按本站服务器当天算,已过期是负数 |
auto_renew | 布尔 | 是否自动续费 |
upstream | 字符串 | 这台机器挂在哪个上游,例如 v10 |
upstream_host_id | 数字或 null | 上游那边的机器编号。它和 upstream 一起决定这台机器能不能做写操作,见第十二节 |
ip | 字符串 | 主 IP,没有就是空串 |
ipv6 | 字符串 | IPv6,没有就是空串 |
assigned_ips | 字符串 | 附加 IP,多个用逗号分隔,没有就是空串 |
username | 字符串 | 登录用户名,没有就是空串 |
ssh_port | 数字 | 连接端口 |
has_password | 布尔 | 有没有设过密码。本站不返回密码本身 |
login_block | 字符串 | 连接信息那几行纯文本,见下 |
spec | 对象 | 规格,键是中文名(例如 内存),值是字符串。没有就是空对象 |
dc | 字符串 | 机房 |
os | 字符串 | 操作系统 |
notes | 字符串 | 备注,最多 2000 字 |
caution | 字符串 | 注意事项,最多 2000 字 |
pending_renew_invoice | 对象或 null | 未付的续费账单,见下 |
pending_renew_invoice 不是 null 时:
| 字段 | 类型 | 说明 |
|---|---|---|
id | 数字 | 账单编号 |
amount | 字符串 | 金额 |
currency | 字符串 | 币种 |
due_date | 字符串或 null | 到期日 Y-m-d |
一台服务可能有多张未付续费账单,本站只给最新那一张,
这样列表和详情两个接口的值永远一致。
login_block 是给人看的多行纯文本,和你在服务详情页点"复制全部"
拿到的那几行是同一份(行与行之间是 \n)。它不含密码。
应答示例(只留第 1 台):
{
"ok": true,
"api": "idcb-public-api",
"version": "v1",
"request_id": "22796eee88c4ada6",
"data": {
"caller": {
"type": "downstream",
"type_label": "下游对接方(客户密钥)",
"client_id": 12,
"client_name": "张三",
"scope": "client",
"key_prefix": "idc_live_aaa",
"key_label": "张三的下游脚本"
},
"count": 3,
"manual_count": 1,
"manual_count_note": "这个客户还有 1 台独立产品(人工开通),不在本接口里,请在会员中心「独立产品」页查看。",
"services": [
{
"id": 2003,
"name": "over.example.com",
"product": { "id": 12, "name": "独立服务器托管" },
"status": "Suspended",
"status_label": "已暂停",
"kind": "cloud",
"billing_cycle": "monthly",
"billing_cycle_label": "月付",
"amount": "50.00",
"currency": "USD",
"reg_date": "2026-08-01",
"next_due_date": "2026-10-07",
"days_to_due": -3,
"auto_renew": false,
"upstream": "v10",
"upstream_host_id": 9003,
"ip": "5.6.7.8",
"ipv6": "",
"assigned_ips": "5.6.7.9",
"username": "root",
"ssh_port": 22,
"has_password": false,
"login_block": "IP: 5.6.7.8\n用户名: root\n端口: 22\n附加 IP: 5.6.7.9",
"spec": { "访问地址": "5.6.7.8" },
"dc": "",
"os": "",
"notes": "",
"caution": "注意备份",
"pending_renew_invoice": null
}
],
"query_count": 16
}
}本接口专属的错误:
| HTTP | code | 什么时候 |
|---|---|---|
| 404 | not_found | 没有这个账号(正常情况下不会出现) |
一台服务。字段与上一节的服务对象完全一样,
只是 data 里是 service 一个对象,另外带 caller 和 query_count。
参数:
| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
Authorization | header | string | 是 | Bearer <你的密钥> |
{id} | path | 数字 | 是 | 服务编号,十进制数字,1 到 18 位 |
请求示例:
curl -s -H "Authorization: Bearer 你的API密钥" \
"https://<站点>/api.php/v1/services/2001"应答示例(片段,完整字段同上表):
{
"ok": true,
"api": "idcb-public-api",
"version": "v1",
"request_id": "dda501e01e357a23",
"data": {
"caller": {
"type": "downstream",
"type_label": "下游对接方(客户密钥)",
"client_id": 12,
"client_name": "张三",
"scope": "client",
"key_prefix": "idc_live_aaa",
"key_label": "张三的下游脚本"
},
"service": {
"id": 2001,
"name": "hk-01.example.com",
"product": { "id": 11, "name": "香港云服务器 1核1G" },
"status": "Active",
"status_label": "正常",
"kind": "cloud",
"billing_cycle": "monthly",
"billing_cycle_label": "月付",
"amount": "85.00",
"currency": "USD",
"reg_date": "2026-09-01",
"next_due_date": "2026-11-09",
"days_to_due": 30,
"auto_renew": false,
"upstream": "v10",
"upstream_host_id": 9001,
"ip": "1.2.3.4",
"ipv6": "",
"assigned_ips": "",
"username": "root",
"ssh_port": 22022,
"has_password": true,
"login_block": "IP: 1.2.3.4\n用户名: root\n端口: 22022",
"spec": {
"内存": "1G",
"CPU": "1核",
"访问地址": "1.2.3.4",
"操作系统": "CentOS 8",
"机房": "HK"
},
"dc": "HK",
"os": "CentOS-8-Stream-x64",
"notes": "测试机",
"caution": "",
"pending_renew_invoice": null
},
"query_count": 15
}
}应答示例(失败):这台服务不存在,或者它不属于你这个账号。
{
"ok": false,
"api": "idcb-public-api",
"version": "v1",
"request_id": "b57dea55082cace5",
"error": {
"code": "not_found",
"message": "没有这个服务,或者它不属于这个账号。"
}
}本接口专属的错误:
| HTTP | code | 什么时候 |
|---|---|---|
| 404 | not_found | 这台服务不存在,或者它不属于你这个账号(两种情况同一个应答) |
十一、接口:账户
余额。余额的唯一权威是账号上的那个余额值,本站不会拿历史账单再汇总一遍。
参数:
| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
Authorization | header | string | 是 | Bearer <你的密钥> |
请求示例:
curl -s -H "Authorization: Bearer 你的API密钥" \
"https://<站点>/api.php/v1/balance"应答(data 里):
| 字段 | 类型 | 说明 |
|---|---|---|
caller | 对象 | 见下 |
balance | 对象 | 见下 |
query_count | 数字 | 诊断计数 |
balance:
| 字段 | 类型 | 说明 |
|---|---|---|
client_id | 数字 | 账号编号 |
amount | 字符串 | 余额,两位小数 |
currency | 字符串 | 币种代码 |
display_currency | 字符串 | 页面上会用什么币种显示。这不是换算后的金额 |
应答示例:
{
"ok": true,
"api": "idcb-public-api",
"version": "v1",
"request_id": "6701dfb649b5364c",
"data": {
"caller": {
"type": "downstream",
"type_label": "下游对接方(客户密钥)",
"client_id": 12,
"client_name": "张三",
"scope": "client",
"key_prefix": "idc_live_aaa",
"key_label": "张三的下游脚本"
},
"balance": {
"client_id": 12,
"amount": "123.45",
"currency": "USD",
"display_currency": "usd"
},
"query_count": 13
}
}display_currency 只是"页面上会怎么显示",不是换算后的金额。
换算汇率会变,接口回原值加币种,换算请你自己做。
本接口专属的错误:
| HTTP | code | 什么时候 |
|---|---|---|
| 404 | not_found | 没有这个账号(正常情况下不会出现) |
caller 对象
#每个成功应答里都有 caller,告诉你这次是谁在调。
用密钥调用时是这些字段:
| 字段 | 类型 | 说明 |
|---|---|---|
type | 字符串 | 使用密钥时是 downstream |
type_label | 字符串 | 上面那个的中文 |
client_id | 数字 | 你这把密钥所属的账号 |
client_name | 字符串 | 账号名(没填名字时是邮箱) |
scope | 字符串 | 使用密钥时是 client,意思是"范围就是这一个账号" |
key_prefix | 字符串 | 密钥的前 12 个字符,用来让你确认"是哪一把" |
key_label | 字符串 | 创建这把密钥时填的名称 |
这里永远只有前缀,不会有密钥原文,也不会有完整哈希。
十二、接口:服务操作(写)
这一族只有一个路径,用请求体里的 op 区分要做什么。
这样加一个操作不需要你改 URL。
参数:
| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
Authorization | header | string | 是 | Bearer <你的密钥> |
Idempotency-Key | header | string | 否 | 幂等键,见第七节。强烈建议写 |
service_id | path | 数字 | 是 | 服务编号,十进制数字,1 到 18 位。必须是你自己账号的 |
op | body | string | 是 | 要做什么。取值见下面那张表 |
product_id | body | 数字 | op=order 时必填 | 商品编号 |
cycle | body | string | 否 | 计费周期,默认 monthly,取值同商品接口 |
selection | body | 对象 | 否 | {"选项编号": 取值编号},只给 op=order 用 |
idempotency_key | body | string | 否 | 幂等键也可以写在请求体里(不推荐,见第七节) |
请求体是 JSON 对象。上限 64 KB。JSON 解不出来时会退一步按表单编码解析。
请求示例(四种语言,都能直接跑;写操作比读多一个幂等键头):
curl -s -X POST \
-H "Authorization: Bearer 你的API密钥" \
-H "Idempotency-Key: demo-0001" \
-H "Content-Type: application/json" \
-d '{"op":"power_off"}' \
"https://<站点>/api.php/v1/services/2001/ops"<?php
$api = 'https://<站点>/api.php';
$key = '你的API密钥';
$ch = curl_init($api . '/v1/services/2001/ops');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $key,
'Content-Type: application/json',
'Idempotency-Key: demo-0001',
],
CURLOPT_POSTFIELDS => json_encode(['op' => 'power_off']),
CURLOPT_TIMEOUT => 30,
]);
$body = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$j = json_decode((string) $body, true);
if (empty($j['ok'])) {
throw new RuntimeException('接口报错 ' . $j['error']['code'] . ' HTTP ' . $code);
}
// 202 = 已受理,去轮询;200 且 async=false = 已经出结果
echo $j['data']['operation']['state'], ' ', $j['data']['poll'], "\n";import json
import urllib.request
API = "https://<站点>/api.php"
KEY = "你的API密钥"
req = urllib.request.Request(
API + "/v1/services/2001/ops",
data=json.dumps({"op": "power_off"}).encode("utf-8"),
headers={
"Authorization": "Bearer " + KEY,
"Content-Type": "application/json",
"Idempotency-Key": "demo-0001",
},
method="POST",
)
with urllib.request.urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode("utf-8"))
op = data["data"]["operation"]
print(op["state"], op["state_label"], data["data"]["poll"])const API = 'https://<站点>/api.php';
const KEY = '你的API密钥';
const res = await fetch(API + '/v1/services/2001/ops', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + KEY,
'Content-Type': 'application/json',
'Idempotency-Key': 'demo-0001',
},
body: JSON.stringify({ op: 'power_off' }),
});
const body = await res.json();
if (!body.ok) {
throw new Error('接口报错 ' + body.error.code + ' HTTP ' + res.status);
}
const op = body.data.operation;
console.log(res.status, op.state, op.state_label, body.data.poll);应答形状(所有操作共用一种形状):
| 字段 | 类型 | 说明 |
|---|---|---|
caller | 对象 | 见第十一节 |
operation | 对象 | 这一笔任务,字段见下 |
dispatched | 布尔 | 这次真的下发了吗。false = 命中幂等或去重保护,没有重复下发 |
async | 布尔 | 是不是"先回 202,再在后台跑完" |
poll | 字符串 | 轮询这一笔的路径(不含域名,跟着你的入口形状走) |
note | 字符串 | 这个操作的说明 |
result | 对象 | 业务结果。只有真的产生了东西时才有这个键(账单 / 订单 / 服务) |
operation 对象:
| 字段 | 类型 | 说明 |
|---|---|---|
op_id | 字符串 | 任务编号,op_ 加 32 位十六进制,128 位随机数,猜不出来 |
operation | 字符串 | 就是 op 的取值 |
label | 字符串 | 这个操作的中文名 |
kind | 字符串 | service 或 order |
state | 字符串 | queued / running / done / failed |
state_label | 字符串 | 上面那个的中文 |
caller | 字符串 | downstream |
service_id | 数字 | 服务编号(订单类操作是 0) |
order_id | 数字 | 订单编号(服务类操作是 0) |
submitted_at | 字符串或 null | 受理时间,Y-m-d H:i:s |
started_at | 字符串或 null | 开始下发的时间 |
finished_at | 字符串或 null | 结束时间 |
outcome | 字符串 | 人能看懂的一句话:失败了就是机房的原话。失败时最前面是稳定错误码 + 全角竖线,形如 provision_failed|… |
want_state | 字符串 | 想要到的机器状态:on / off / 空 |
upstream_state | 字符串 | 回读到的机器状态原值,例如 Running |
settled | 布尔或 null | 机器到没到目标状态。null = 这个操作没有可回读的目标状态 |
settled_note | 字符串 | 上面那个字段的中文说明 |
state 的语义请务必看准:
queued/running= 已开始,还没结果。继续轮询。done= 机房受理了。它不等于机器已经到了目标状态,
所以还要看 settled。
failed= 机房拒绝了 / 抛异常了 / 超时了。outcome里是机房的原话,
而且最前面带稳定错误码。
settled 的意义:上游的电源接口是"同步返回受理结果"的,它回成功只说明
收下了这条指令。本站会再回读一次机器状态放进 upstream_state,
并用 settled 回答"到没到"。刚下发时 settled 常常是 false,
过几秒再轮询一次就有了。所以"到底关掉没有"要看这里,不要看 HTTP 状态码。
result 里可能出现的业务结果:
result 里的键 | 什么时候有 | 内容 |
|---|---|---|
invoice | 开通 / 续费 | id / status / amount / currency / due_date |
balance | 开通 / 续费 | charged(这一笔实际扣了多少,按余额差算)、before、after |
paid_by | 开通 / 续费 | paid(这次扣的)、already_paid(并发下已被别的请求付掉)、already_renewed(这张续费账单早就处理过,本次没扣) |
payments_gateway | 开通 / 续费 | 恒为 credit,表示走的是账户余额 |
pay_note | 开通 / 续费 | 一句中文说明 |
order | 开通 | id(订单号)、total、service_id(开出来的服务编号,没开出来就是 0) |
provisioning | 开通 | ok(机器开出来了吗)、msg、note |
renew | 续费 | upstream_done、upstream_skipped、upstream_msg、note |
reused_existing | 续费 | 这张续费账单是不是复用了已有的那张未付账单 |
needs_operator | 扣款成功但机器那一段没做成 | 恒为 true。看到它就别再重复提交,把 op_id 给我们 |
⚠️ balance 那一项是按余额差算出来的,不是把账单金额再算一遍。
两处推导同一个数正是本项目最贵的 bug 来源,所以这里刻意只认余额的前后差。
应答示例(成功、异步受理,HTTP 202):
{
"ok": true,
"api": "idcb-public-api",
"version": "v1",
"request_id": "7d21b0c9a4e35f18",
"data": {
"caller": {
"type": "downstream",
"type_label": "下游对接方(客户密钥)",
"client_id": 12,
"client_name": "张三",
"scope": "client",
"key_prefix": "idc_live_aaa",
"key_label": "张三的下游脚本"
},
"operation": {
"op_id": "op_9f3c1a7e5b2d8064c1e0a9f4b7d2c385",
"operation": "power_on",
"label": "开机",
"kind": "service",
"state": "queued",
"state_label": "已受理,等待下发",
"caller": "downstream",
"service_id": 2001,
"order_id": 0,
"submitted_at": "2026-10-10 14:20:31",
"started_at": null,
"finished_at": null,
"outcome": "已受理,正在下发到机房。",
"want_state": "on",
"upstream_state": "",
"settled": null,
"settled_note": "机房还没受理完成,请继续轮询。"
},
"dispatched": true,
"async": true,
"poll": "/v1/operations/op_9f3c1a7e5b2d8064c1e0a9f4b7d2c385",
"note": "把机器开机。上游受理不等于已经开机,请轮询任务看机器状态。"
}
}应答示例(失败):同一台服务的同一个操作正在处理中。
{
"ok": false,
"api": "idcb-public-api",
"version": "v1",
"request_id": "1a7f4c9e2b6d8035",
"error": {
"code": "operation_busy",
"message": "这台服务的「关机」正在处理中,请用返回的 op_id 轮询结果,不要重复提交。",
"detail": {
"op_id": "op_4b8e1d0f6a2c9375e4d81f0b7a3c692e",
"state": "queued"
}
}
}本接口专属的错误:
| HTTP | code | 什么时候 |
|---|---|---|
| 400 | bad_request | op 不认识或没带;op=order 没带 product_id;selection 结构不对 |
| 400 | bad_cycle | cycle 不在那四个值里 |
| 400 | service_not_enabled | 这台服务是独立产品(没绑机房)、没绑机器、或者机房信息不完整 |
| 402 | insufficient_balance | 账户余额不足(只可能由 op=order / op=renew 触发)。error.detail 里带 balance / required / shortage / currency |
| 403 | operation_not_allowed | 这个操作不对客户密钥开放 |
| 404 | not_found | 这台服务不存在,或者不属于你这个账号 |
| 409 | operation_busy | 同一台服务的同一个操作正在处理中 |
| 503 | operation_store_unavailable | 写操作功能还没启用 |
⚠️ 还有一类失败不在这里:请求本身没问题、但业务没做成
(扣款失败 / 机器没开出来 / 机房那一段没完成 ✓)。
那时 HTTP 是 200、ok 是 true,而 operation.state 是 failed,
稳定错误码在 operation.outcome 的最前面。见第五节末尾那张表。
| op | 名称 | 权限 | 是否异步 | 说明 |
|---|---|---|---|---|
sync | 同步信息 | 下游密钥也可以用 | 异步(先回 202,再轮询) | 让本地服务记录跟上机房的最新状态。只读操作,不会动机器。 |
power_on | 开机 | 下游密钥也可以用 | 异步(先回 202,再轮询) | 把机器开机。上游受理不等于已经开机,请轮询任务看机器状态。 |
power_off | 关机 | 下游密钥也可以用 | 异步(先回 202,再轮询) | 把机器关机。上游受理不等于已经关机,请轮询任务看机器状态。 |
power_reboot | 重启 | 下游密钥也可以用 | 异步(先回 202,再轮询) | 重启机器。重启期间状态不变,所以只能确认机房已受理。 |
vnc | VNC 控制台地址 | 仅本站内部系统 | 同步(当场就有结果) | 取一次性的控制台地址。只对本站内部系统开放,不对下游密钥开放,且响应里不含任何密码。 |
order | 开通(下订单并用余额支付) | 下游密钥也可以用 | 同步(当场就有结果) | 按商品与周期下订单,并从账户余额扣款。余额不足会直接拒绝,不会下单、不会开通。扣款成功后由本站既有的开通流程真正开机器。 |
renew | 续费(扣余额) | 下游密钥也可以用 | 同步(当场就有结果) | 为这台服务开续费账单并从账户余额扣款,金额取本站为该服务设定的续费价。余额不足会直接拒绝,不会开单、不会续期。 |
让本地服务记录跟上机房的最新状态。只读操作,不会动机器。
请求体:
{"op": "sync"}同步(async 是 true),先回 202,去轮询任务。
把机器开机。上游受理不等于已经开机,请轮询任务看机器状态。
请求体:
{"op": "power_on"}want_state 是 on,所以任务里会给出 settled。异步,先回 202。
把机器关机。上游受理不等于已经关机,请轮询任务看机器状态。
请求体:
{"op": "power_off"}want_state 是 off,所以任务里会给出 settled。异步,先回 202。
重启机器。重启期间状态不变,所以只能确认机房已受理。
请求体:
{"op": "power_reboot"}want_state 是空的,所以 settled 是 null,settled_note 会说明
"这个操作没有可回读的目标状态"。异步,先回 202。
按商品与周期下订单,并从账户余额扣款。余额不足会直接拒绝,
不会下单、不会开通。扣款成功后由本站既有的开通流程真正开机器。
请求体:
{
"op": "order",
"product_id": 11,
"cycle": "monthly",
"selection": { "21": 31, "22": 33 }
}product_id必填。商品编号从GET /v1/products拿。cycle默认monthly。写错当场 400 加bad_cycle。selection是{"选项编号": 取值编号}的扁平对象,键和值都必须是数字。
不给就用该商品的默认配置。可选值从 GET /v1/products/{id} 的
options 里拿(默认配置的编号也印在那个应答的 values[].is_default 上)。
- 这个操作不要求这台服务绑了机器,所以独立产品(人工开通)也能下单。
- 钱不够就什么都别想发生:余额不足时当场 402
insufficient_balance,订单、账单、余额一个都不会动。
- 同步:当场就有结果(HTTP 200)。
result里有订单号、已付账单、
这一笔扣了多少、以及机器那一步成没成。
应答示例(成功片段,机器也开出来了):
{
"ok": true,
"api": "idcb-public-api",
"version": "v1",
"request_id": "3c9b2a17e5d4086f",
"data": {
"operation": {
"op_id": "op_5e1a9c3d7b0f2486a1d95c3f8b2e704d",
"operation": "order",
"label": "开通(下订单并用余额支付)",
"kind": "order",
"state": "done",
"state_label": "机房已受理",
"caller": "downstream",
"service_id": 2600,
"order_id": 1,
"submitted_at": "2026-10-10 14:31:02",
"started_at": "2026-10-10 14:31:02",
"finished_at": "2026-10-10 14:31:05",
"outcome": "已用余额支付 85.00,开通完成。",
"want_state": "",
"upstream_state": "",
"settled": null,
"settled_note": "机房已受理。这个操作没有可回读的目标状态,请到会员中心核对。"
},
"dispatched": true,
"async": false,
"poll": "/v1/operations/op_5e1a9c3d7b0f2486a1d95c3f8b2e704d",
"note": "按商品与周期下订单,并从账户余额扣款。余额不足会直接拒绝,不会下单、不会开通。扣款成功后由本站既有的开通流程真正开机器。",
"result": {
"invoice": {
"id": 7001,
"status": "paid",
"amount": "85.00",
"currency": "USD",
"due_date": "2026-10-17"
},
"balance": { "charged": "85.00", "before": "123.45", "after": "38.45" },
"paid_by": "paid",
"payments_gateway": "credit",
"pay_note": "这一笔已经从账户余额扣款,账单已付清。余额不足时本站会直接拒绝,不会下单、不会开通、不会扣款。",
"order": { "id": 1, "total": "85.00", "service_id": 2009 },
"provisioning": {
"ok": true,
"msg": "",
"note": "本站既有的开通流程已经处理完这一步。"
}
}
}
}应答示例(失败一:余额不足,什么都没发生):
{
"ok": true,
"api": "idcb-public-api",
"version": "v1",
"request_id": "d8b09e7157eb15d7",
"data": {
"operation": {
"op_id": "op_97256094a6d98b35fdb990c31b24f243",
"operation": "order",
"label": "开通(下订单并用余额支付)",
"kind": "order",
"state": "failed",
"state_label": "失败",
"caller": "downstream",
"service_id": 2600,
"order_id": 0,
"submitted_at": "2026-10-10 15:19:16",
"started_at": "2026-10-10 15:19:16",
"finished_at": "2026-10-10 15:19:16",
"outcome": "upstream_failed|账户余额不足:这一笔需要 $850.00,当前余额 $123.45,还差 $726.55。请先充值。",
"want_state": "",
"upstream_state": "",
"settled": null,
"settled_note": "操作失败,机器状态没有改变。"
},
"dispatched": true,
"async": false,
"poll": "/v1/operations/op_97256094a6d98b35fdb990c31b24f243",
"note": "按商品与周期下订单,并从账户余额扣款。余额不足会直接拒绝,不会下单、不会开通。扣款成功后由本站既有的开通流程真正开机器。"
}
}⚠️ 同一种情况也可能以一个同步的 402 出现:error.code = insufficient_balance,error.detail 带balance / required / shortage / currency。
两种形状都要认(见第六节)。
应答示例(失败二:钱扣了但机器没开出来):
{
"ok": true,
"api": "idcb-public-api",
"version": "v1",
"request_id": "f3495ac670544ea9",
"data": {
"operation": {
"op_id": "op_90eb8f225c2fbd50fb34c3cdd1bd789b",
"operation": "order",
"label": "开通(下订单并用余额支付)",
"kind": "order",
"state": "failed",
"state_label": "失败",
"caller": "downstream",
"service_id": 2600,
"order_id": 1,
"submitted_at": "2026-10-10 15:20:21",
"started_at": "2026-10-10 15:20:21",
"finished_at": "2026-10-10 15:20:21",
"outcome": "provision_failed|已用余额支付 $85.00,剩余余额 $38.45。 但机器没有开出来(开通流程没有返回服务号)。账单已按余额扣款、订单和服务记录都已保留,请到后台核对,不要重复提交。",
"want_state": "",
"upstream_state": "",
"settled": null,
"settled_note": "操作失败,机器状态没有改变。"
},
"dispatched": true,
"async": false,
"poll": "/v1/operations/op_90eb8f225c2fbd50fb34c3cdd1bd789b",
"note": "按商品与周期下订单,并从账户余额扣款。余额不足会直接拒绝,不会下单、不会开通。扣款成功后由本站既有的开通流程真正开机器。",
"result": {
"invoice": {
"id": 7004,
"status": "paid",
"amount": "85.00",
"currency": "USD",
"due_date": "2026-10-13"
},
"balance": { "charged": "85.00", "before": "123.45", "after": "38.45" },
"paid_by": "paid",
"payments_gateway": "credit",
"pay_note": "这一笔已经从账户余额扣款,账单已付清。余额不足时本站会直接拒绝,不会下单、不会开通、不会扣款。",
"order": { "id": 1, "total": "85.00", "service_id": 0 },
"provisioning": {
"ok": false,
"msg": "",
"note": "钱已经按账单收了、账单已付清,但机器没开出来。订单和服务记录都留着,请到后台核对(不要重复提交)。"
},
"needs_operator": true
}
}
}⚠️ 最后这一种情况钱不退、账单保持已付。请把 op_id 提供给我们,
不要重复提交 —— 重复提交解决不了它,只会多一层对账的麻烦。
应答示例(失败三:product_id 没带,请求本身就不对):
{
"ok": false,
"api": "idcb-public-api",
"version": "v1",
"request_id": "8e2d5b91c4a73f60",
"error": {
"code": "bad_request",
"message": "缺少 product_id(商品编号)。请在请求体里带上它。"
}
}为这台服务开续费账单并从账户余额扣款,金额取本站为该服务设定的续费价。
余额不足会直接拒绝,不会开单、不会续期。扣款成功后由既有的续费流程
顺延到期日并同步机房。
请求体:
{"op": "renew"}cycle可以不写。写了就必须等于这台服务当前的计费周期,
换周期要拿定价重算一笔价,那是第三处算价,本版本不做。
要换周期请到会员中心或联系客服。
- 这个操作不要求这台服务绑了机器(付了钱还没开出来那种正好最需要续费)。
- 账号邮箱还没验证时不能续费(和客户中心同一条前置条件),会 400。
- 钱不够时当场 402
insufficient_balance,不会留下任何账单。 - 那张续费账单如果早就处理过(客户自己在会员中心付掉了),
本次不会重复扣款,paid_by 是 already_renewed、balance.charged 是 0.00。
- 机房那一段按设置被跳过时,本地到期日照样顺延,
result.renew.upstream_skipped 是 true。
- 同步:当场就有结果(HTTP 200)。
应答示例(成功片段,机房也同步了):
{
"ok": true,
"api": "idcb-public-api",
"version": "v1",
"request_id": "b41e7c0a95d3286f",
"data": {
"operation": {
"op_id": "op_1c7d0b4a9e2f8563d0a71c5b8e3f9642",
"operation": "renew",
"label": "续费(扣余额)",
"kind": "service",
"state": "done",
"state_label": "机房已受理",
"caller": "downstream",
"service_id": 2001,
"order_id": 0,
"submitted_at": "2026-10-10 14:33:40",
"started_at": "2026-10-10 14:33:40",
"finished_at": "2026-10-10 14:33:44",
"outcome": "已生成续费账单 已用余额支付 85.00,续期成功,到期日 2026-11-09 → 2026-12-09。机房:已同步。",
"want_state": "",
"upstream_state": "",
"settled": null,
"settled_note": "机房已受理。这个操作没有可回读的目标状态,请到会员中心核对。"
},
"dispatched": true,
"async": false,
"poll": "/v1/operations/op_1c7d0b4a9e2f8563d0a71c5b8e3f9642",
"note": "为这台服务开续费账单并从账户余额扣款,金额取本站为该服务设定的续费价。余额不足会直接拒绝,不会开单、不会续期。",
"result": {
"invoice": {
"id": 7004,
"status": "paid",
"amount": "85.00",
"currency": "USD",
"due_date": "2026-11-09"
},
"balance": { "charged": "85.00", "before": "123.45", "after": "38.45" },
"paid_by": "paid",
"payments_gateway": "credit",
"pay_note": "这一笔已经从账户余额扣款,账单已付清。余额不足时本站会直接拒绝,不会下单、不会开通、不会扣款。",
"reused_existing": false,
"renew": {
"upstream_done": true,
"upstream_skipped": false,
"upstream_msg": "",
"note": "本地到期日已经顺延,机房也已同步。"
}
}
}
}应答示例(失败:钱扣了、本地也续上了,但机房那一段没完成):
{
"ok": true,
"api": "idcb-public-api",
"version": "v1",
"request_id": "9f4c2a70b1e5d386",
"data": {
"operation": {
"op_id": "op_3a8f1c60d9b2e475a0c3f81b6d2e9507",
"operation": "renew",
"label": "续费(扣余额)",
"kind": "service",
"state": "failed",
"state_label": "失败",
"caller": "downstream",
"service_id": 2001,
"order_id": 0,
"submitted_at": "2026-10-10 14:40:02",
"started_at": "2026-10-10 14:40:02",
"finished_at": "2026-10-10 14:40:09",
"outcome": "renew_failed|已用余额支付 85.00,续期成功,到期日 2026-11-09 → 2026-12-09。机房侧尚未完成。 到期日已经顺延、余额已按账单扣款,不需要重复提交;机房那一段需要人工跟进。",
"want_state": "",
"upstream_state": "",
"settled": null,
"settled_note": "操作失败,机器状态没有改变。"
},
"dispatched": true,
"async": false,
"poll": "/v1/operations/op_3a8f1c60d9b2e475a0c3f81b6d2e9507",
"result": {
"renew": {
"upstream_done": false,
"upstream_skipped": false,
"upstream_msg": "机房侧尚未完成",
"note": "本地到期日已经顺延;机房那一段还没完成,需要人工跟进。"
},
"needs_operator": true
}
}
}本接口(op=order / op=renew)专属的错误:
| HTTP | code | 什么时候 |
|---|---|---|
| 402 | insufficient_balance | 账户余额不足。什么都没发生,不会下单、不会开单、不会扣款 |
| 400 | bad_request | 缺 product_id;selection 结构不对;cycle 和当前周期不一致;邮箱未验证 |
| 400 | bad_cycle | cycle 不在那四个值里 |
| 404 | not_found | 这台服务不存在,或者不属于你这个账号 |
| 200 | payment_failed(在 operation.outcome 里) | 扣款那一步失败 |
| 200 | provision_failed(在 operation.outcome 里) | 钱扣了、账单已付清,但机器没开出来。result.needs_operator 是 true |
| 200 | renew_failed(在 operation.outcome 里) | 钱扣了、到期日已顺延,但机房那一段没完成。result.needs_operator 是 true |
VNC 控制台(`op=vnc`):不对下游开放
#op=vnc 在本站的操作表里,但它只对本站内部系统开放。
用你的密钥调它,会拿到 403 加 operation_not_allowed,
响应里没有任何地址、密码或令牌。
原因是 VNC 不是"一次操作"而是一张进门条:它把看得见屏幕的权限交出去,
一般还能进 BIOS、改启动项。那比关机大得多,不适合做成一个脚本里的一步。
需要控制台请到会员中心的「服务详情 → 配置与操作」打开,
那里是登录之后的一对一页面,有会话、有审计。
十三、接口:任务(轮询)
异步写操作回一个任务号,你拿它来查结果。
你这一个账号最近的任务,最多 50 笔,按编号从大到小排。
参数:
| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
Authorization | header | string | 是 | Bearer <你的密钥> |
请求示例:
curl -s -H "Authorization: Bearer 你的API密钥" \
"https://<站点>/api.php/v1/operations"应答(data 里):
| 字段 | 类型 | 说明 |
|---|---|---|
caller | 对象 | 见第十一节 |
count | 数字 | operations 里有多少笔 |
note | 字符串 | 一句说明(只列最近 50 笔、单笔怎么查) |
operations | 数组 | 任务对象数组,字段与单笔查询里的 operation 完全一样 |
只列你自己账号的任务。别人的任务你查不到,也不会串进来。
本接口专属的错误:
| HTTP | code | 什么时候 |
|---|---|---|
| 503 | operation_store_unavailable | 写操作功能还没启用 |
拿 POST /v1/services/{service_id}/ops 应答里的 poll(或者 op_id)来查。
参数:
| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
Authorization | header | string | 是 | Bearer <你的密钥> |
op_id | path | string | 是 | 任务编号,4 到 40 个字母数字下划线 |
请求示例:
curl -s -H "Authorization: Bearer 你的API密钥" \
"https://<站点>/api.php/v1/operations/op_9f3c1a7e5b2d8064c1e0a9f4b7d2c385"应答形状和发起操作时一样(caller / operation / dispatched / async /poll / note),只是 dispatched 是 false(轮询本身不下发任何东西)。
应答示例(成功片段,异步操作跑完之后):
{
"ok": true,
"api": "idcb-public-api",
"version": "v1",
"request_id": "5f8a1c3e70b9d246",
"data": {
"operation": {
"op_id": "op_9f3c1a7e5b2d8064c1e0a9f4b7d2c385",
"operation": "power_off",
"label": "关机",
"kind": "service",
"state": "done",
"state_label": "机房已受理",
"caller": "downstream",
"service_id": 2001,
"order_id": 0,
"submitted_at": "2026-10-10 14:20:31",
"started_at": "2026-10-10 14:20:32",
"finished_at": "2026-10-10 14:20:36",
"outcome": "指令已下发",
"want_state": "off",
"upstream_state": "Stopped",
"settled": true,
"settled_note": "机器已经到目标状态。"
},
"dispatched": false,
"async": false,
"poll": "/v1/operations/op_9f3c1a7e5b2d8064c1e0a9f4b7d2c385",
"note": "把机器关机。上游受理不等于已经关机,请轮询任务看机器状态。"
}
}settled 还是 false 的时候长这样(很常见,过几秒再查一次):
{
"state": "done",
"state_label": "机房已受理",
"upstream_state": "Running",
"settled": false,
"settled_note": "机房已受理,但机器还没到目标状态(off)。刚下发时很常见,请过几秒再轮询一次。"
}应答示例(失败):编号不存在,或者不属于你这个账号。
{
"ok": false,
"api": "idcb-public-api",
"version": "v1",
"request_id": "2d9e6b40f1a83c57",
"error": {
"code": "not_found",
"message": "没有这个操作编号,或者它不属于这个账号。"
}
}本接口专属的错误:
| HTTP | code | 什么时候 |
|---|---|---|
| 404 | not_found | 没有这个操作编号,或者它不属于这个账号(同一个应答) |
| 503 | operation_store_unavailable | 写操作功能还没启用 |
还有一个兜底要知道:进程中途结束的任务会卡在 running,
本站每次受理和轮询时会顺手把超过 10 分钟没动静的判成 failed,outcome 写的是"操作超时,结果未知,请到会员中心或联系客服核对机器状态"。
所以轮询到 failed 时请到会员中心核对机器真实状态,不要直接当成没发生。
这份清单是从代码的操作表现读的,所以它永远和你实际能调的东西一致。
建议启动时拉一次,用它决定界面上给不给某个按钮。
参数:
| 名称 | 位置 | 类型 | 必填 | 说明 |
|---|---|---|---|---|
Authorization | header | string | 是 | Bearer <你的密钥> |
请求示例:
curl -s -H "Authorization: Bearer 你的API密钥" \
"https://<站点>/api.php/v1/operations/catalog"应答(data 里):
| 字段 | 类型 | 说明 |
|---|---|---|
caller | 对象 | 见第十一节 |
note | 字符串 | 一句说明(op 取值就是 operation 这一列) |
operations | 数组 | 每一项见下 |
operations 数组里每一项:
| 字段 | 类型 | 说明 |
|---|---|---|
operation | 字符串 | op 的取值 |
label | 字符串 | 中文名 |
method | 字符串 | 恒为 POST |
path | 字符串 | 恒为 /v1/services/{service_id}/ops |
allowed | 布尔 | 你这把密钥能不能调它。vnc 在下游密钥下是 false |
dangerous | 布尔 | 会不会真的动机器或动钱 |
poll | 字符串 | 去哪里轮询。同步操作是空串 |
description | 字符串 | 一句话说明 |
应答示例(operations 只留第 1 项):
{
"ok": true,
"api": "idcb-public-api",
"version": "v1",
"request_id": "a3f7d120c8b64e95",
"data": {
"note": "op 取值就是 operation 这一列。发起操作用 POST /v1/services/{service_id}/ops,请求体 {\"op\":\"...\"}。",
"operations": [
{
"operation": "sync",
"label": "同步信息",
"method": "POST",
"path": "/v1/services/{service_id}/ops",
"allowed": true,
"dangerous": false,
"poll": "GET /v1/operations/{op_id}",
"description": "让本地服务记录跟上机房的最新状态。只读操作,不会动机器。"
}
]
}
}本接口专属的错误:
| HTTP | code | 什么时候 |
|---|---|---|
| 503 | operation_store_unavailable | 写操作功能还没启用 |
十四、完整可跑的例子
把 <站点> 换成本站的 API 域名,把 你的API密钥 换成你创建的那把密钥。
下面的代码都是完整可跑的,只依赖语言自带的库。
curl
## 入口:两种写法都可以,第二种在服务器有 try_files 时更保险
API="https://<站点>/api.php"
BASE="https://<站点>/api.php"
KEY="你的API密钥"
# 1) 商品列表,带你这个账号每个周期的价
curl -s -H "Authorization: Bearer $KEY" "$API/v1/products"
# 2) 只看年付(cycle 写错会 400 bad_cycle)
curl -s -H "Authorization: Bearer $KEY" "$API/v1/products?cycle=annually"
# 3) 一个商品
curl -s -H "Authorization: Bearer $KEY" "$API/v1/products/11"
# 4) 服务清单
curl -s -H "Authorization: Bearer $KEY" "$API/v1/services"
# 5) 一台服务
curl -s -H "Authorization: Bearer $KEY" "$API/v1/services/2001"
# 6) 余额
curl -s -H "Authorization: Bearer $KEY" "$API/v1/balance"
# 7) 排障:连状态码和响应头一起看
curl -s -D - -o /dev/null -H "Authorization: Bearer $KEY" "$API/v1/balance"
# 8) 服务器把 /api.php/v1/... 判成 404 时换这种形状(不依赖服务器配置)
curl -s -H "Authorization: Bearer $KEY" "$BASE?path=/v1/products"
# 9) 关机:写操作先回 202,拿 poll 去轮询(-i 能看到状态码)
curl -s -i -X POST -H "Authorization: Bearer $KEY" \
-H "Idempotency-Key: demo-0001" -H "Content-Type: application/json" \
-d '{"op":"power_off"}' "$API/v1/services/2001/ops"
# 10) 轮询那一笔(op_id 换成上一步回给你的那个)
curl -s -H "Authorization: Bearer $KEY" "$API/v1/operations/op_00000000000000000000000000000000"
# 11) 能力清单:我这一族能做哪些操作,哪些对下游开放
curl -s -H "Authorization: Bearer $KEY" "$API/v1/operations/catalog"
# 12) 开通:下订单并从账户余额扣款(余额不足会 402,什么都别想发生)
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"op":"order","product_id":11,"cycle":"monthly"}' "$API/v1/services/2600/ops"
# 13) 续费:开续费账单并扣余额(金额取服务自己的续费价)
curl -s -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"op":"renew"}' "$API/v1/services/2001/ops"注意不要用 ?apikey= 那种写法把密钥放进 URL。
PHP
#<?php
$api = 'https://<站点>/api.php';
$key = '你的API密钥';
function api_get($url, $key)
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $key],
CURLOPT_TIMEOUT => 20,
]);
$body = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$j = json_decode((string) $body, true);
if (!is_array($j)) {
throw new RuntimeException('不是 JSON,HTTP ' . $code);
}
if (empty($j['ok'])) {
// 认 code,不要认 message
throw new RuntimeException('接口报错 ' . $j['error']['code'] . ' HTTP ' . $code);
}
return $j;
}
function api_post($url, $key, array $payload, $idem = '')
{
$h = ['Authorization: Bearer ' . $key, 'Content-Type: application/json'];
if ($idem !== '') { $h[] = 'Idempotency-Key: ' . $idem; }
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => $h,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_TIMEOUT => 30,
]);
$body = curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$j = json_decode((string) $body, true);
if (!is_array($j)) {
throw new RuntimeException('不是 JSON,HTTP ' . $code);
}
if (empty($j['ok'])) {
throw new RuntimeException('接口报错 ' . $j['error']['code'] . ' HTTP ' . $code);
}
return $j;
}
// 1) 商品列表:每个商品每个周期的价
foreach (api_get($api . '/v1/products', $key)['data']['products'] as $p) {
foreach ($p['prices'] as $pr) {
if (empty($pr['priced'])) { continue; }
echo $p['name'], ' ', $pr['cycle_label'], ' 收 ', $pr['price'],
'(折前 ', $pr['list_price'], ' 减免 ', $pr['discount_percent'], "%)\n";
}
}
// 2) 服务清单
$svc = api_get($api . '/v1/services', $key)['data'];
echo '云服务 ', $svc['count'], ' 台,另有独立产品 ', $svc['manual_count'], " 台\n";
// 3) 一台服务
$one = api_get($api . '/v1/services/2001', $key)['data']['service'];
echo $one['name'], ' 状态 ', $one['status_label'],
' 到期 ', (string) $one['next_due_date'], "\n";
// 4) 余额(金额是字符串,别用浮点累加)
$bal = api_get($api . '/v1/balance', $key)['data']['balance'];
echo '余额 ', $bal['amount'], ' ', $bal['currency'], "\n";
// 5) 关机:202 = 已受理,去轮询任务
$r = api_post($api . '/v1/services/2001/ops', $key, ['op' => 'power_off'], 'demo-0001');
$op = $r['data']['operation'];
echo '任务 ', $op['op_id'], ' 状态 ', $op['state_label'], "\n";
// 6) 轮询到终态
$opId = $op['op_id'];
for ($i = 0; $i < 10; $i++) {
sleep(2);
$t = api_get($api . '/v1/operations/' . rawurlencode($opId), $key)['data']['operation'];
if ($t['state'] === 'done' || $t['state'] === 'failed') {
echo $t['state_label'], ' / 机器状态 ', $t['upstream_state'],
' / 到位 ', var_export($t['settled'], true), "\n";
break;
}
}Python
#import json
import time
import urllib.request
import urllib.error
API = "https://<站点>/api.php"
KEY = "你的API密钥"
def call(path, payload=None, idem="", method=None):
url = API + path
headers = {"Authorization": "Bearer " + KEY}
data = None
if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"
if idem:
headers["Idempotency-Key"] = idem
req = urllib.request.Request(url, data=data, headers=headers,
method=method or ("POST" if data else "GET"))
try:
with urllib.request.urlopen(req, timeout=30) as resp:
body = resp.read().decode("utf-8")
status = resp.status
except urllib.error.HTTPError as e: # 4xx / 5xx 也带 JSON 信封
body = e.read().decode("utf-8")
status = e.code
data = json.loads(body)
if not data.get("ok"):
# 认 code,不要认 message
raise RuntimeError("接口报错 %s HTTP %s" % (data["error"]["code"], status))
return status, data
# 1) 商品列表
for p in call("/v1/products")[1]["data"]["products"]:
for pr in p["prices"]:
if not pr.get("priced"):
continue
print("%s %s 收 %s(折前 %s 减免 %s%%)" % (
p["name"], pr["cycle_label"], pr["price"],
pr["list_price"], pr["discount_percent"]))
# 2) 服务清单
svc = call("/v1/services")[1]["data"]
print("云服务 %d 台,另有独立产品 %d 台 · %s" % (
svc["count"], svc["manual_count"], svc["manual_count_note"]))
# 3) 一台服务
one = call("/v1/services/2001")[1]["data"]["service"]
print("%s 状态 %s 到期 %s" % (one["name"], one["status_label"], one["next_due_date"]))
# 4) 余额(金额是字符串,别用浮点累加)
bal = call("/v1/balance")[1]["data"]["balance"]
print("余额 %s %s" % (bal["amount"], bal["currency"]))
# 5) 关机:202 = 已受理,去轮询任务
status, r = call("/v1/services/2001/ops", {"op": "power_off"}, idem="demo-0001")
op = r["data"]["operation"]
print(status, "任务 %s 状态 %s" % (op["op_id"], op["state_label"]))
# 6) 轮询到终态
for _ in range(10):
time.sleep(2)
t = call("/v1/operations/" + op["op_id"])[1]["data"]["operation"]
if t["state"] in ("done", "failed"):
print("%s / 机器状态 %s / 到位 %s" % (
t["state_label"], t["upstream_state"], t["settled"]))
break
# 7) 能力清单:哪些操作对下游开放
for op in call("/v1/operations/catalog")[1]["data"]["operations"]:
print("%-14s %-8s 开放=%s 有风险=%s" % (
op["operation"], op["label"], op["allowed"], op["dangerous"]))Node.js
#需要 Node 18 以上(用内置的 fetch)。
const API = 'https://<站点>/api.php';
const KEY = '你的API密钥';
async function call(path, payload, idem) {
const init = { headers: { Authorization: 'Bearer ' + KEY } };
if (payload) {
init.method = 'POST';
init.headers['Content-Type'] = 'application/json';
if (idem) init.headers['Idempotency-Key'] = idem;
init.body = JSON.stringify(payload);
}
const res = await fetch(API + path, init);
const body = await res.json(); // 4xx / 5xx 也是 JSON 信封
if (!body.ok) {
// 认 code,不要认 message
throw new Error('接口报错 ' + body.error.code + ' HTTP ' + res.status);
}
return { status: res.status, body };
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// 1) 商品列表
const list = (await call('/v1/products')).body.data;
for (const p of list.products) {
for (const pr of p.prices) {
if (!pr.priced) continue;
console.log(`${p.name} ${pr.cycle_label} 收 ${pr.price}` +
`(折前 ${pr.list_price} 减免 ${pr.discount_percent}%)`);
}
}
// 2) 服务清单
const svc = (await call('/v1/services')).body.data;
console.log(`云服务 ${svc.count} 台,另有独立产品 ${svc.manual_count} 台`);
// 3) 一台服务
const one = (await call('/v1/services/2001')).body.data.service;
console.log(`${one.name} 状态 ${one.status_label} 到期 ${one.next_due_date}`);
// 4) 余额(金额是字符串,别用浮点累加)
const bal = (await call('/v1/balance')).body.data.balance;
console.log(`余额 ${bal.amount} ${bal.currency}`);
// 5) 关机:202 = 已受理,去轮询任务
const accepted = await call('/v1/services/2001/ops', { op: 'power_off' }, 'demo-0001');
const op = accepted.body.data.operation;
console.log(accepted.status, `任务 ${op.op_id} 状态 ${op.state_label}`);
// 6) 轮询到终态
for (let i = 0; i < 10; i++) {
await sleep(2000);
const t = (await call('/v1/operations/' + op.op_id)).body.data.operation;
if (t.state === 'done' || t.state === 'failed') {
console.log(`${t.state_label} / 机器状态 ${t.upstream_state} / 到位 ${t.settled}`);
break;
}
}
// 7) 开通:下订单并从账户余额扣款(余额不足会直接拒,什么都别想发生)
const made = await call('/v1/services/2600/ops',
{ op: 'order', product_id: 11, cycle: 'monthly' }, 'demo-0002');
console.log('订单', made.body.data.result.order.id,
'未付账单', made.body.data.result.invoice.amount);十五、常见问题(排障)
问:第一次调就返 503,error.code 是 api_disabled。是我的密钥没生效吗?
不是。这是本站整体把接口关着(默认就是关的)。密钥本身没问题。
去问本站管理员什么时候开放;开放之后同一把密钥直接能用,不用重新申请。
程序上请把 503 当成"稍后重试并且退避"。
问:503 加 not_enabled 又是什么?
本站的密钥功能还没建起来(数据库迁移还没执行)。
这和你的密钥、你的调用方式都无关,找本站管理员。
问:503 加 operation_store_unavailable 呢?
写操作那一族的功能还没建起来。只读接口不受影响,照常能调。
问:401 加 invalid_api_key。是我密钥打错了吗?
invalid_api_key 一个 code 覆盖好几种原因:密钥不存在、密钥已经被吊销
或者轮换过、密钥格式不对(比如复制时少了字符)、密钥所属账号已被停用。
本站故意不告诉你到底是哪一种,否则等于给扫号的人反馈。
自检顺序:
- 密钥是不是 44 个字符、
idc_开头、中间没有空格或换行? - 是不是在会员中心点过"重新生成"(轮换)?轮换之后旧密钥立刻失效,
要用新的那一把。
- 账号本身是不是正常状态?
- 都不对,带
request_id找本站管理员。
问:我读一台服务,返回 404,但那台服务明明存在。
如果它不是你这个账号的,本站就返回 404,而不是 403。
403 等于承认"这个编号存在,只是不是你的",那样别人就能拿编号从 1 数上去,
把本站有多少台机器、某个编号属于谁都摸出来。
所以"不存在"和"不是你的"必须是同一个应答。这不是 bug。
写操作也是同一条口径。
问:我调关机,返回 202,是不是已经关好了?
不是。202 只说明"我收下了,还没做完"。请拿应答里的 poll 去轮询,
看 state 和 settled。state 变成 done 也只说明机房受理了,settled 才是"机器到没到目标状态"。刚下发时 settled 常常是 false,
过几秒再查一次。
问:409 operation_busy 是什么意思?
同一台服务的同一个操作正在处理中。响应里 error.detail.op_id 就是
在途那一笔的编号,去轮询它,不要重复提交。
问:我重复提交了两次,会不会开两次机?
不会。带了同一个 Idempotency-Key 就是回上一次那一笔(dispatched 是false);没带键时,同一台服务同一操作在途会被 409 挡住,
60 秒内刚做成过会回 200 加"本次没有重复下发"。
问:429 了,等多久?
看响应头 Retry-After(单位是秒,当前实现是 60)。等够再试,
不要在这个窗口里一直重试。也请检查是不是把限流额度写死在代码里了。
注意读和写是两组独立的额度:你把读的额度打满,写照样能调。
问:为什么金额是字符串 "85.00" 而不是数字?
这是故意的,浮点数算钱会掉精度。请按定点小数处理,
不要转成浮点去累加。
问:下订单/续费会不会扣我账号的余额?
会。 这两个操作都会从账户余额扣款,走的是客户在账单页点
「用余额支付」的同一个函数,所以账目和客户自己点一下完全一致。
余额不够时当场拒绝,而且什么都没发生:不会下单、不会开单、不会扣款。
⚠️ 这个"拒绝"可能是一个 402 加 error.code = insufficient_balance,
也可能是一条 200 + operation.state = failed、outcome 以upstream_failed|账户余额不足:… 开头的任务(见第六节)。
两种都要认,别只认 HTTP 状态码。
如果钱扣了但机器没开出来(机房拒绝、超时、上游余额不够),
那一刻的答案是明确的:钱不退、账单保持已付,
任务判 failed 并在 outcome 最前面给出 provision_failed /renew_failed,result.needs_operator 是 true。
订单和服务记录都留着,由本站人工处理。这和"客户自己支付时撞上同样情况"
的处置一模一样。请把 op_id / request_id 提供给我们,
不要重复提交。
问:接口会不会突然少一个字段?
不会。字段是稳定契约,error.code 也是。
新增字段是可能的,所以请让你的解析器忽略不认识的字段,
不要因为多了一个字段就报错。
问:query_count 是什么?
本站内部用的诊断计数,表示这一次请求执行了多少条数据库语句。
它随时可能变化,不要依赖它做任何判断。
问:响应会被缓存吗?
不会。本站给每个应答都加了 Cache-Control: no-store 和Pragma: no-cache。你自己的程序也请不要长期缓存余额。
问:时间是什么时区?
日期一律是 Y-m-d 字符串,例如 "2026-11-09",时间戳字段是Y-m-d H:i:s,例如 "2026-10-10 14:20:31"。
原样返回本站数据库里的值,没有做任何时区换算,也不会给你 Unix 时间戳。days_to_due 是拿本站服务器当天算出来的天数,已经过期就是负数。
所以不要假设它是某个固定时区。
十六、这个版本明确不提供的东西
- 看不到别人的任何数据。密钥只属于一个账号,只读和写都一样。
- 不返回任何密码。服务器 root 密码、控制面板密码都不返回。
应答里只有 has_password(有没有设过密码)和 login_block
(连接信息那几行,不含密码)。要密码请登录会员中心,
到「服务详情 → 连接信息」看。
- 不含独立产品。
/v1/services只列云产品;独立产品(人工开通那种)
的台数会在 manual_count 里给你一个数字和一句说明,清单请在会员中心看。
(独立产品可以下单和续费,但不能开关机 —— 它没有机房机器可操作。)
- 不能删除机器、不能重装系统、不能重置密码、不能强制关机、
不能暂停 / 解除暂停、不能锁定 / 解锁。这些操作要么不可逆、
要么会改掉客户手上的凭据、要么是我们催费和封停的手段。
它们不在操作表里,路由都匹配不上,调了就是 400 或者 404。
- VNC 控制台不对下游开放(见第十二节末尾)。
- 不接受指定配置报价以外的算价。价格恒按你给的
selection(或默认配置)算,
由本站同一段代码算出来,接口不自己算价。
- 不返回成本价。可配置项只报加价
price_diff,不报成本。 - 续费不支持换周期。换周期要拿定价重算一笔价,那是第三处算价,
本版本不做;要换周期请到会员中心或联系客服。
十七、创建密钥和查看本账号接口清单
这些需要登录本站会员中心:创建、轮换、吊销、重命名 API 密钥,
以及查看本账号的「API 状态」和「API 接口地址」。
位置是:会员中心 → 代理分销 → API 管理,地址 /profile-api.php。
本账号的接口清单摘要也印在那一页上。
十八、本站自有系统
本站自己的跨站同步、监控、财务、客服工具使用另一套凭据,
不对下游开放,也不属于这份文档的范围。
需要对接本站自有系统,请联系本站管理员。