开发接入文档
把你的系统接入中控:一次登记、之后只靠一个心跳接口,就能拿到授权状态、可售套餐、插件目录、售后回信与版本更新。全部是 HTTP + JSON + HMAC 签名,无 SDK 依赖,任何语言都能对接——签名算法为此专门做了 v2 逐字节版本,PHP / Node / Python / Go 算出的结果完全一致。
概览
中控是一个拉取式(pull)的授权 / 计费 / 售后中枢。中控从不主动访问你的站点——所有交互都由你的站点主动发起 HTTP 请求;下发数据(授权、套餐、回信、更新……)统统「搭心跳的车」随响应返回。因此你只需要能出站访问中控域名即可,无需开放任何入站端口。
- 零配置自动登记:首次心跳不带签名,中控按「产品 + 域名」建档并回发
site_secret,之后签名通信。 - 一个后台管多产品 / 多站点:一类系统 = 一个产品(
product_key),一次部署(一个域名)= 一个站点。 - 闭环能力:续费下单、插件加购、工单留言、更新推送、客户门户自助认领,全部经同一套签名协议。
- 面向开发者的计费:平台向开发者(产品作者)收费,模式三选一——只包月(按活跃站点数月度出账)、只按次(每次调用实时扣信用余额)、包月 + 按次(默认)。所有会打到中控的端点开箱即带单价与免费额度,且无论哪种模式,每一次调用都逐条留痕,开发者门户「接口与计费」可查。付款方不是站长,你交付的站点侧逻辑与费用无关,详见计费与账单。
所有通信都由站点主动发起心跳,中控从不反向连接你的服务器。是否强制对接、断连后如何处置,属于你产品侧的授权策略;参考实现(导航站)默认「强制对接 + 到期/断连软停 + 离线宽限」,详见授权状态机。
通信约定:所有接口均为 POST,请求体与响应体都是 UTF-8 编码的 JSON;生产环境走 https://。下文示例中的中控地址 https://www.dlam.cc 与产品密钥 f6b364026d671e44 即为当前生效值,直接照抄即可。签名版本:中控按请求里的版本标记自适应——带 sign_version:2 或请求头 X-Sign-Version: 2 走 v2,否则回落 v1。两版永久共存(存量站点无需改造),新接入请用 v2。
核心概念
接入前务必分清下面几个标识——它们决定了「谁在跟中控说话」以及「用什么密钥签名」。
| 名称 | 谁持有 | 作用 |
|---|---|---|
product_key | 站点配置 | 产品引导密钥,在中控「产品与套餐」页生成。所有接口都携带;首次登记时作为共享引导(不校验签名)。仅服务端持有,切勿下发到浏览器。 |
domain | 站点 | 站点身份标识(如 https://shop-a.example.com)。登记按 (product, domain) 唯一,每次必须保持一致才能复用同一条记录。 |
site_id | 中控分配 | 中控为该域名分配的主键。首次登记时由心跳响应下发,站点须持久化,之后每个请求都带上。0 表示「尚未登记」。 |
site_secret | 站点保管 | 站点签名密钥(48 位 hex),登记时下发。此后所有「站点 → 中控」请求都用它做 HMAC 签名。泄露即等同冒名。 |
center_secret | 站点可选保管 | 中控回签密钥,产品创建时生成。用于校验「中控 → 站点」响应 sign 的真实性(可选,见回签与验证)。 |
developer | 中控账号 | 产品的归属开发者,也是唯一的计费主体:包月与按次都从它的信用余额(credit)扣款。站长与客户不向中控付费。 |
ts / nonce | 站点生成 | Unix 秒级时间戳与一次性随机串,用于时效校验(±5 分钟)与防重放(10 分钟内不可重复)。 |
核心签名算法
协议有两版签名并存:中控按请求里的版本标记自动识别,两版永久接受——存量站点无需改造,新接入请用 v2。两者都是 hash_hmac('sha256', …, site_secret);中控回签用 center_secret。
| 版本 | 签什么 | 如何声明 | 适用 |
|---|---|---|---|
| v2 · 推荐 | 请求体的原始字节 + 方法 + 路径 + 时间戳 | 请求头 X-Sign-Version: 2,或体内 sign_version:2 / sv:2 | 新接入、非 PHP 语言。逐字节复现,零歧义。 |
| v1 · 兼容 | 把负载规范化成 k=v& 串后再签 | 不带上述标记即视为 v1 | 存量 PHP 站点,行为与过去完全一致。 |
v2 · 对原始字节签名(推荐)
v1 依赖「把 JSON 重新规范化成 k=v& 串」,非 PHP 语言在键序、unicode、数字格式化、嵌套展开上极易与 PHP 算出不同的结果。v2 改为对真正上传的那段字节签名,PHP / Node / Python / Go 逐字节一致。
1. 待签字符串 string_to_sign
string_to_sign =
upper(METHOD) // "POST"(本平台全部为 POST)
+ "\n" + PATH // "/api/heartbeat.php",不含域名、端口与查询串
+ "\n" + to_int(ts) // 裸十进制整数,与 body 里的 ts 同一个值
+ "\n" + hex(sha256(signed_bytes)) // signed_bytes 见下面的空白占位规则
sign = hex(hmac_sha256(site_secret, string_to_sign))
2. 空白占位规则:signed_bytes 怎么来
sign 本身就在请求体里,不能对「含 sign 的字节」自指签名。于是约定先置空、再替换:
sign 的值设为空字符串 ""(键必须存在,建议放在最后)。JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)。这段字节就是 signed_bytes。sha256 → 拼 string_to_sign → HMAC → 得到 sig。"sign":"" 替换为 "sign":"<sig>",其余字节一个不动,直接发出。中控做反向还原:把收到的 "sign":"<sig>" 换回 "sign":""(只替换第一处),再对得到的字节重算一遍,因此两端逐字节一致。硬约束:第 2 步序列化出的字节必须与真正上网的请求体完全相同——替换签名之后不得再序列化一次,也不得让 HTTP 库重排 / 美化 JSON。
<?php
// 待签串 = METHOD \n PATH \n ts \n sha256(带空 sign 的请求体字节)
function center_sign_v2(string $path, int $ts, string $body, string $secret): string {
$s2s = "POST\n" . $path . "\n" . $ts . "\n" . hash('sha256', $body);
return hash_hmac('sha256', $s2s, $secret);
}
// 四步走一遍:$p 里不含 sign
$p['sign'] = ''; // ① 置空,键放最后
$body = json_encode($p, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); // ② 紧凑序列化
$sign = center_sign_v2('/api/heartbeat.php', (int) $p['ts'], $body, $site_secret); // ③ 对这段字节签名
$body = str_replace('"sign":""', '"sign":"' . $sign . '"', $body); // ④ 只回填这一处
// $body 现在就是请求体原文:直接当 content 发出,之后不得再经过任何编码
$ctx = stream_context_create(['http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\n"
. "X-Sign-Version: 2\r\n" // 也可改成体内写 sign_version:2
. "User-Agent: mysite-center/1.0\r\n",
'content' => $body,
'timeout' => 6,
]]);
版本标记二选一即可:用请求头(本页示例与下面的参考实现)时 body 里不必有 sign_version;若把它写进体内,它就变成了被签名字节的一部分——两种写法算出的签名不同,选定一种别混用。
3. 一个可复现的完整例子
设 site_secret 为 8f3c1a55d2b7406e9c1f23ab4de7777755aa112233445566778899aabbccdd,请求 POST /api/heartbeat.php。第 1–2 步得到的字节为:
{"product_key":"cab165d05fcd74f9","site_id":0,"domain":"https://key.dlam.cc","version":"1.0.0","ts":1790000000,"nonce":"a1b2c3d4e5f60718","stats":{"today_pv":128,"order_count":7},"meta":{"php":"7.3.33"},"sign":""}
拼出的 string_to_sign(下面用 \n 表示真实的换行符):
POST\n/api/heartbeat.php\n1790000000\n2d3d769b9a1a2cd699f1ef027f8a769a57df2f88a875f45c2d2e09560b20174c
于是 sig = a58c7518c6e2082a98e30ccb056dd5a1a011c165d930cc7954f308a828a6ad7d,回填后实际发出的请求体(注意只有那一个空串被替换):
{"product_key":"cab165d05fcd74f9","site_id":0,"domain":"https://key.dlam.cc","version":"1.0.0","ts":1790000000,"nonce":"a1b2c3d4e5f60718","stats":{"today_pv":128,"order_count":7},"meta":{"php":"7.3.33"},"sign":"a58c7518c6e2082a98e30ccb056dd5a1a011c165d930cc7954f308a828a6ad7d"}
① PATH 不一致(最常见):中控取的是 REQUEST_URI 去掉查询串的部分,域名端口不参与,但部署在子目录时前缀必须一起签。② 序列化 flags:多一个空格、把 / 转成 \/、把中文转成 \u4e2d,字节就变了。③ 替换后又编码:回填签名必须是纯字符串替换,不能再来一次 json_encode / JSON.stringify。④ 待签串第三行必须是裸整数(中控按 (int)ts 还原),即使 body 里把 ts 写成了字符串。
集成包里附带 _conformance/vectors.json(上面这组数据即取自其中)与 PHP / Node / Python / Go 四个自测脚本。先把你自己语言的输出跑到与向量逐字节一致,再谈联调——这是唯一的验收门槛。
v1 · 规范化串签名(历史兼容)
存量 PHP 站点沿用此版,行为不变:签名分两步,规范化(canonical)→ HMAC-SHA256。
1. 规范化 canonical(payload)
- 先移除
sign字段本身(它不参与签名)。 - 剩余键按字典序升序排序(
ksort)。 - 每个键拼成
k=v;值为数组时先json_encode(flags:JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)。 - 用
&连接所有片段。
2. 签名 sign = hash_hmac('sha256', canonical, secret)
<?php
function center_canonical(array $d): string {
ksort($d);
$p = [];
foreach ($d as $k => $v) {
if (is_array($v)) {
$v = json_encode($v, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$p[] = $k . '=' . $v;
}
return implode('&', $p);
}
// 传入完整 payload(含或不含 sign 均可),返回签名
function center_sign(array $payload, string $secret): string {
unset($payload['sign']); // sign 自身不参与签名
return hash_hmac('sha256', center_canonical($payload), $secret);
}
一个具体例子
对下面这个心跳请求签名(site_secret = "s3cr3t..."):
{
"product_key": "f6b364026d671e44",
"site_id": 12,
"domain": "https://shop-a.example.com",
"version": "1.4.0",
"ts": 1760000000,
"nonce": "3f9a1c7b2e8d4a55"
}
键排序后拼出的 canonical 串为:
domain=https://shop-a.example.com&nonce=3f9a1c7b2e8d4a55&product_key=f6b364026d671e44&site_id=12&ts=1760000000&version=1.4.0
sign = hash_hmac('sha256', 上串, site_secret),把结果作为 sign 字段一起放进最终 JSON 发送。
① 参与签名的字段与实际发送的 JSON 不一致(多/少字段、值不同);② site_id / plan_id 等被当成字符串发送,canonical 里 int 12 → "12" 无引号,务必按整数发送;③ 数组字段(stats / meta)用了不同的 json_encode flags。中控比较签名用 hash_equals(常量时间)。这三类坑正是 v2 存在的原因——新接入请直接用 v2。
接入流程
product_key(和 center_secret)。可选:配好续费套餐、收款说明 pay_note。CENTER_URL 与产品密钥 CENTER_KEY(= product_key)。交付型产品建议写死为常量、后台只读以防买家改库绕过;自用 / 需换域名迁移的场景可存库、后台可改。site_id=0、sign 留空的心跳。中控按 (产品, 域名) 建档,返回 site_id + site_secret + 默认到期(+30 天)。把 site_secret 持久化。site_id 并用 site_secret 签名(新接入用 v2)。把整个响应存成缓存,续费页 / 插件页 / 工单页都从缓存读,避免每页都打中控。自愈:若签名心跳返回 bad signature / site not found(密钥被重置或站点记录丢失),清空本地 site_id/site_secret 再走一次免签登记即可换回新密钥。心跳建议至少间隔 5 分钟(用本地 last_sync 时间戳节流)。到期状态、回信、更新都靠心跳刷新,无需更频繁。
接口清单
六个接口,路径都在中控根下的 /api/。除登记外,其余都必须用 site_secret 签名。每个端点开箱都带单价与免费额度,超额部分按次从开发者的信用余额扣;你的站点侧代码不因定价而改变,被拦截时按普通错误提示即可,见端点如何定价。
| 接口 | 方法 | 签名 | 用途 | 默认单价 / 月免费额度 |
|---|---|---|---|---|
/api/heartbeat.php | POST | 首次登记免签 / 之后需签 | 上报状态、拉取全部下发数据 | ¥0.001 / 次 · 2000 |
/api/renew.php | POST | 需要 | 创建续费「待支付」订单 | ¥0.02 / 次 · 100 |
/api/message.php | POST | 需要 | 向服务商发起工单 / 留言 | ¥0.02 / 次 · 100 |
/api/plugin_order.php | POST | 需要 | 加购插件(免费/试用即开,付费下单) | ¥0.02 / 次 · 100 |
/api/plugin_download.php | POST | 需要 | 下载已授权插件的安装包(.zip 字节流) | ¥0.05 / 次 · 100 |
/api/wallet.php | POST | 需要 | 站点余额钱包:充值申请 / 查余额 / 流水(站长在站点后台用) | ¥0.005 / 次 · 300 |
公共签名请求体(renew / message / plugin_order 通用):
| 字段 | 类型 | 说明 | |
|---|---|---|---|
product_key | string | 必填 | 产品引导密钥 |
site_id | int | 必填 | 登记后返回的站点主键,须 > 0 |
domain | string | 必填 | 与登记时完全一致的站点标识 |
ts | int | 必填 | Unix 秒级时间戳,|now−ts| ≤ 300 |
nonce | string | 必填 | 一次性随机串(如 16 位 hex) |
sign_version | int | 选填 | 填 2 走 v2 逐字节签名;不带此字段时可用请求头 X-Sign-Version: 2;两者都没有即按 v1 校验 |
sign | string | 必填 | 用 site_secret 算出的 HMAC,算法见签名算法 |
心跳 / 登记 POST/api/heartbeat.php
唯一一个「一接口两用」的端点:site_id=0 时是登记(免签名,靠 product_key 引导);site_id>0 时是已授权心跳(必须签名)。响应会把授权、套餐、回信、更新、插件目录、绑定码一次性带回。
请求字段
| 字段 | 类型 | 说明 | |
|---|---|---|---|
product_key | string | 必填 | — |
domain | string | 必填 | 站点标识;登记按此复用记录 |
site_id | int | 选填 | 0 或缺省 = 登记;>0 = 心跳(需签名) |
version | string | 选填 | 当前系统版本号,用于比对是否有更新 |
ts / nonce / sign | — | 心跳必填 | 仅 site_id>0 时校验;版本标记 sign_version / X-Sign-Version 同签名算法 |
stats | object | 选填 | {today_pv, order_count},展示用 |
meta | object | 选填 | 任意键值(如 site_name),原样存 JSON |
响应字段
| 字段 | 类型 | 说明 |
|---|---|---|
ok | bool | 成功为 true;失败见 error |
site_id | int | 站点主键(登记后即返回,需持久化) |
site_secret | string | 签名密钥;登记时下发,心跳时也回,便于站点自愈 |
license | object | {status, expire_at, days_left, allow_disable, grace_days, reason},见授权状态机。reason="arrears" 表示该站点因开发者账号欠费超宽限被下发停用(与站长无关,见计费与账单);reason="developer_disabled" 表示被应用开发者在开发者后台停用,需由开发者恢复;其余情况为空串。 |
plans | array | 可售套餐 [{id,name,months,price}],供续费页展示 |
pay_note | string | 收款说明(服务商在后台配置) |
replies | array | 待送达的总控回信 [{id,title,body,created_at}];下发即标记已送达 |
update | object|null | 仅当有比 version 更新的已发布版本时返回 {version,url,notes,created_at} |
store | array | 该产品可见的插件目录(可浏览加购)。每项含 has_pkg(是否存在可下载安装包,决定卡片是否给「安装」)。 |
plugins | array | 本站已授权且在售的插件,站点据此启用功能。每项含 plugin_id 及包信息 has_pkg / pkg_version / pkg_hash / pkg_size(取自不可变的已发布版本;无包时 pkg_version="")。 |
bind_code | string | 8 位绑定码,客户在门户自助认领本站时输入 |
server_notice | string | 全站公告文本(服务商配置,可空) |
center_time | string | 中控当前时间(ISO8601),可校时 |
sign | string | 中控回签,见回签与验证 |
示例 · 首次登记(curl)
curl -sS https://www.dlam.cc/api/heartbeat.php \
-H 'Content-Type: application/json' \
-d '{
"product_key": "f6b364026d671e44",
"site_id": 0,
"domain": "https://shop-a.example.com",
"version": "1.0.0",
"ts": 0,
"nonce": "",
"sign": ""
}'
示例 · 响应(节选)
{
"ok": true,
"site_id": 12,
"site_secret": "8f3c1a...e1",
"license": {
"status": "正常",
"expire_at": "2026-11-10",
"days_left": 30,
"allow_disable": 0,
"grace_days": 7,
"reason": ""
},
"plans": [ { "id": 3, "name": "年付", "months": 12, "price": "299.00" } ],
"pay_note": "转账至 xxx,备注订单号",
"replies": [],
"update": null,
"store": [ { "id": 2, "slug": "stats", "name": "访问统计增强", "price": "0.00", "trial_days": 0 } ],
"plugins": [],
"bind_code": "K7QM2XRD",
"server_notice": "",
"center_time": "2026-10-11T09:12:00+08:00",
"sign": "..."
}
续费下单 POST/api/renew.php
在公共签名请求体基础上,额外带 plan_id(必填,须属于该产品)。中控创建一个「待支付」续费订单并返回订单号与收款说明。
- 幂等:同一站点 + 同一套餐若已有待支付订单,直接复用,不会重复下单。
- 到账即延期:订单由服务商在中控后台确认到账(若启用 SPay 回调则自动),确认后
expire_at自动延长months个月。
| 响应字段 | 类型 | 说明 |
|---|---|---|
order_no | string | 续费订单号(RN 前缀) |
plan_name / months / amount | — | 套餐名 / 延长月数 / 金额 |
status | string | 初始为「待支付」 |
pay_note | string | 收款说明,展示给客户 |
<?php
$payload = [
'product_key' => $st['product_key'],
'site_id' => (int) $st['site_id'],
'domain' => $domain,
'plan_id' => 3,
'ts' => time(),
'nonce' => bin2hex(random_bytes(8)),
];
// v2:PATH 参数须与请求路径逐字一致;center_body / center_post 见「客户端参考实现」
$body = center_body('/api/renew.php', $payload, $st['site_secret']);
$r = center_post('/api/renew.php', $body);
// $r = ['ok'=>true,'order_no'=>'RN2026...','amount'=>299,'pay_note'=>'...','sign'=>'...']
工单留言 POST/api/message.php
公共签名请求体 + message(必填,留言正文)+ title(选填,默认「站点留言」,最长 120 字)。中控插入一条 direction=in、状态「待回复」的工单。
响应返回 message_id。服务商在中控回复后,回复以 direction=out 暂存,随下一次心跳的 replies[] 下发并标记「已送达」——所以站点无需轮询,回复会自动到达。
插件加购 POST/api/plugin_order.php
公共签名请求体 + plugin_id(必填)。按插件定价走三条分支:
| 插件类型 | 结果 | 关键响应 |
|---|---|---|
| 免费(price ≤ 0) | 立即开通,长期有效 | granted:true, status:"已开通", expire_at:null |
| 有试用(trial_days > 0) | 立即开通试用,到期自动失效 | granted:true, status:"试用中", expire_at:+N天 |
| 付费 | 创建「待支付」订单(幂等复用) | granted:false, order_no:"PL...", amount, billing, pay_note |
若本站已拥有该插件且未过期,返回 {ok:false, error:"你已拥有该插件"}。付费订单确认后由中控写入授权,随下一次心跳的 plugins[] 下发,站点据此启用功能。
插件安装包下载 POST/api/plugin_download.php
公共签名请求体 + plugin_id(必填)+ version(选填,留空则下发「最新已发布版本」)。中控校验:站点签名有效、插件 status=上架、本站有生效授权(已开通/试用中 且未过期),三重通过才放行。
| 结果 | 返回 |
|---|---|
| 成功 | HTTP 200,Content-Type: application/zip 二进制流;响应头带 X-Pkg-Hash(sha256)、X-Pkg-Size、X-Pkg-Version。站点须逐字节核对 hash('sha256', bytes) === pkg_hash 后再落盘。 |
| 失败 | JSON {ok:false,error}:bad signature(403) / no entitlement(403) / plugin not on sale(403) / package not published(404) / plugin file missing(404) |
下发的字节一律取自 plugin_versions(不可变发布历史,仅审核通过 / 官方提交后写入),绝不是开发者刚重传、尚在审核的新包;site_plugins 授权与 下架 状态实时约束可下载性。服务端永不解压 / 执行 zip,文件存于 uploads/plugins/ 且该目录已拒绝任何直接 HTTP 访问。
站点侧「下载 → 验签 → 安全解压安装 → 启停」的运行时,随中控提供 sdk/plugin_client.php(产品无关纯函数库:plugin_install / plugin_activate / plugin_deactivate / plugin_list / plugin_current_version 等)及 sdk/PLUGIN_README.md 集成说明。
站点余额钱包 POST/api/wallet.php
公共签名请求体 + act(必填)。这是站长余额(在服务商处预存的款项,用于在插件商场直接扣款买插件),与开发者计费无关。表单式与 JSON 式调用都接受。
act | 额外字段 | 返回 |
|---|---|---|
topup | amount(≥1,≤100000) | {order_no:"top_…", amount, pay_note, status:"pending"}:创建待支付的余额充值单,到账确认后入站点余额 |
balance | — | {balance, logs:[{remark, delta, created_at}]}:余额与最近 50 条流水 |
sync | — | {balance}:把最新余额写回中控侧站点缓存 |
缺 act 返回 missing act,无法识别返回 unknown act;缺 product_key / site_id / sign 返回 invalid request params。
授权状态机
心跳响应里的 license.status 由中控按到期日与产品开关算出,共 4 态:
| 状态 | 触发条件 | 站点建议动作 |
|---|---|---|
| 正常 | 未过期,或 expire_at 为空(长期) | 无提示 |
| 将到期 | 剩余 ≤ 7 天 | 后台顶部黄条,引导续费 |
| 已过期 | days_left < 0(且产品未开强制停用) | 红条提醒续费,功能仍可用 |
| 已停用 | ① 中控后台人工停用该站点(最高优先级,无视到期与开关);② 该应用开发者在开发者后台停用该站点,此时 reason="developer_disabled";③ 已过期 且 产品 allow_disable=1;④ 该产品的开发者账号欠费超宽限期,此时 reason="arrears"(见计费与账单) | 前台立即停用(见下);到期类原因续费后自动恢复,人工停用需由对应操作方恢复,补缴后自动恢复 |
三个「宽限」别混淆:产品级 grace_days 与 allow_disable 决定到期后是否进入「已停用」(默认关闭=到期只提醒不断服);站点侧还有一个离线宽限期(参考实现常量 CENTER_OFFLINE_GRACE_DAYS,默认 7 天)决定连不上中控时能撑多久,见下;开发者侧的 billing_grace_days(默认 7 天)决定欠费多久后才停服,见计费与账单。
中控确证下发 已停用 → 立即锁前台(站长主动切断,不给缓冲)。心跳持续失败(中控失联)时,从最后一次成功同步(本地 last_sync)起给一个离线宽限期(默认 7 天):期内照常运行不误伤,超期仍连不上才锁,恢复连接或续费到账自动解除。后台续费入口永不拦,避免进不去的死锁。
回签与验证
中控的每个成功响应都可以验源——用来确认「下发给我的授权 / 套餐 / 更新」确实来自你的中控,而不是中间人伪造。有两种载体,按签名版本各自配套:
- 响应头
X-Center-Sign(v2,推荐):请求按 v2 签名时,中控对最终响应的那段字节原文用产品center_secret做 HMAC 写进X-Center-Sign,同时回X-Sign-Version: 2。站点拿收到的原文逐字节比对即可,不需要重新序列化 JSON,因此没有规范化歧义。 - 响应体
sign字段(v1,兼容):对「响应去掉sign后」的规范化串做 HMAC,随每个成功响应返回。
<?php
// 关键:先留原文,绝不能 json_decode 后再序列化一次去比对
$raw = @file_get_contents(CENTER_URL . $path, false, $ctx);
if ($raw === false) { /* 连不上中控:记 sync_error,走离线宽限 */ }
$got = '';
foreach ($http_response_header as $h) {
if (stripos($h, 'X-Center-Sign:') === 0) {
$got = trim(substr($h, strlen('X-Center-Sign:')));
}
}
$verified = $got !== ''
&& hash_equals(hash_hmac('sha256', $raw, CENTER_SECRET), $got);
// $verified 为 false 时按你的策略处理:强制对接场景可视为一次同步失败(进离线宽限),不必立即锁站
$j = json_decode($raw, true);
// 这段已内建在「客户端参考实现」的 center_post() 里,单列于此只为说明原理
v1 的响应体验签照旧可用:
<?php
function center_verify(array $resp, string $center_secret): bool {
$sign = $resp['sign'] ?? '';
unset($resp['sign']);
return hash_equals(
hash_hmac('sha256', center_canonical($resp), $center_secret),
(string) $sign
);
}
回签是「锦上添花」,全程走 HTTPS 时跳过它也完全可用。验响应体 sign(v1)要求你的规范化与中控逐字节一致——把 license/plans/store 等嵌套数组按同样的 json_encode flags 序列化,稍有出入即失败,这正是 v2 改用响应头的原因。两个注意点:① 验签失败的响应(如 bad signature)与尚未登记身份时不会有 X-Center-Sign 头;② 插件安装包是二进制流、不在此回签范围内,用响应头 X-Pkg-Hash 逐字节校验。
计费与账单
中控向开发者(产品作者)收费,付款方不是站长,更不是站长的客户。可收费的两条腿:包月的站点接入服务费 + 按次的端点调用费,都从开发者的信用余额扣,全部走同一套签名协议,无需额外接口。平台在「计费管理」里三选一决定用哪条腿(甚至只用一条),而无论选哪种,用量都逐条记录。
三种计费模式(后台「计费参数 → 计费模式」随时切换)
| 模式 | 包月出账 | 按次扣费 | 欠费停站 | 用量记录 |
|---|---|---|---|---|
| 包月 + 按次 默认 | 每月按活跃站点数出账 | 超额部分实时扣 | 会(超宽限后) | 逐条 |
| 只包月 | 每月按活跃站点数出账 | 不扣,端点只记量 | 会(超宽限后) | 逐条 |
| 只按次 | 不出账 | 每次调用实时扣 | 不触发,余额不足当场拒请求 | 逐条 |
模式只决定扣不扣、怎么扣,不决定记不记——开发者门户「接口与计费」在三种模式下都能看到同样的端点用量、逐条调用记录与 14 日趋势。
两个口袋,互不打扰
| 口袋 | 来源 | 用途 | 能否被扣 |
|---|---|---|---|
信用余额 credit | 充值申请经平台核对后入账 | 付包月账单、付按次调用 | 会,可扣到 0,余额不足即按 fail-closed 处理 |
分成收益 earnings | 插件在商场卖出的分成 | 提现 | 不会,计费永不挪用收益 |
包月 · 站点接入服务费
- 口径:某自然月内有过心跳的站点数 × 站点单价(
billing_site_price,默认 ¥5 / 站点 / 月)。没跑起来的站点不计费。 - 出账:按上一自然月为每个开发者生成一张账单,
(开发者, 期次)唯一——重复出账只会刷新仍处于「待付」的那张,已入账 / 已免的账不重开。 - 自动结算:出账后立即尝试从信用余额扣款。够 → 状态已入账(按
INV{期次}D{开发者}幂等,绝不重复扣);不够 → 状态欠费,进入宽限判定。 - 合并计算:同一开发者名下多个产品 / 多个站点合在一张账单里,按
developer_id分组统计站点数。 - 只在模式含包月时生效:切到「只按次」后不再出账,「按月出账」卡片也会提示无需操作;心跳、授权、工单、插件下发这些调用改由上面的按次口径结算。
按次 · 端点调用费
- 开箱全部计价:心跳、钱包、续费下单、工单、插件下单、插件包下发、开放 API 网关这七个端点在首次启动时就被登记了单价与每月免费额度(见上表)。端点身份就是它的 URL 路径(
/api/xxx.php,与 v2 签名里的PATH同一个值);单价填0或把该行停用,该端点就退回「只记量、不扣钱」。 - 免费额度
free_quota:按开发者、按自然月、按端点累计(跨其名下所有站点合计),额度内的调用cost=0但照样记录;超出后每次扣unit_price。 - 精度:单次最低 ¥0.0001(防止长期累计被四舍五入成 0),所有金额按四位小数记账。
- 逐条留痕:每一次调用——包括被拒的——都写进
dev_usage_logs(开发者 / 站点 / 端点 / 本次扣费 / 成功或被拒 / 时间),同时累加dev_usage_daily的按天聚合。明细按billing_log_days(默认 30 天)滚动清理,按天聚合永久保留;开发者门户「接口与计费」可按端点、站点、结果筛选并分页查看。 - fail-closed:计价端点扣款失败(余额不足)时直接拒绝本次调用并返回
developer_credit_insufficient,该次请求以「拒绝」计入用量、不计扣费。单价为 0 / 已停用的端点照常服务;平台自营产品(developer_id=0)永不扣费,只记量。
欠费会不会停掉我的站点?
停服条件是三件事同时成立,缺一不发生:
billing_grace_days(默认 7 天)。期内一律照常运行。三条都满足时,该开发者名下站点的心跳会把 license.status 下发为已停用、license.reason="arrears"。补缴或充值入账后账单自动结清、状态自动恢复,无需人工干预;模式不含包月(「只按次」)、平台自营产品(不归属任何开发者)与计费总开关关闭时永不触发——只按次模式下欠费的表现是当场拒绝单条请求,而不是整站停用。
开发者门户「费用与账单」页填金额 + 转账交易号提交充值申请,平台核对后入账——入账瞬间会自动尝试结清该开发者的待付 / 欠费账单。在线支付(自动回调入账)是后续接入点:届时只是把「人工核对」换成「回调触发同一笔入账」,账务口径、幂等规则与本页描述不变。
| 配置项 | 含义 | 默认 |
|---|---|---|
billing_mode | 计费模式:包月 / 按次 / 包月+按次,决定出不出包月账、扣不扣按次费(用量照记) | 包月+按次 |
billing_enabled | 计费总开关。关闭时只记用量:既不扣按次、也不出包月账、更不因欠费停服——先用它观察量级,再决定正式收费。 | 开启 |
billing_site_price | 站点包月单价(元 / 站点 / 月),模式含包月时生效 | ¥5 |
billing_grace_days | 欠费宽限天数,超期才停服(模式含包月时才可能触发) | 7 |
billing_log_days | 逐条调用明细的滚动保留天数(1–365),按天聚合的用量不受影响、永久保留 | 30 |
billable_endpoints 每行 | 某个端点路径的 name / unit_price / free_quota / status(启用 / 停用),单价 0 即免费 | 七个中控端点开箱带价 |
端点如何定价(改价与新增费端点的登记法)
这一节解释价格怎么改、新的增值能力怎样变成收入,以及为什么它对已接入的站点是零改造。
- 端点身份 = URL 路径,与 v2 签名里的
PATH是同一个值。平台在「计费管理 → 端点按次定价」里新增一行:路径(以/开头,如/api/ai_fill.php)、显示名、单价(> 0 才计价,填 0 等于免费)、每开发者每月免费额度、状态。路径重复不允许,直接编辑原条目即可改价——改价立刻生效,无需发版。想退回出厂价,同一页点「恢复默认定价」即可(只补齐缺失的七个中控端点,你改过的价格不会被覆盖)。 - 接入侧要做的只有一件事:新增端点在鉴权通过后调用一次
center_meter($product, $site_id)(不传第三个参数时自动取当前请求路径)。它负责记用量、判超额、扣余额、余额不足时 fail-closed。所有已上线端点(含开放 API 网关)都已内建,用量在开发者看板自动出现。 - 站点(你交付出去的程序)不需要改任何代码:定价发生在中控侧,站端只管照常调用;被拦截时收到的是标准
{ok:false, error:"developer_credit_insufficient", message:"…"},按普通错误提示即可。 - 撤价同样无侵入:把该行改为「停用」或删除,或把单价填 0,该端点立刻退回「只记用量、不扣钱」;历史用量与已出账单都保留,可随时导出对账。
额度是按开发者、按自然月、按端点累计的(跨他名下所有站点合计),不是每站一份。以默认每 5 分钟一次的心跳算:单站约 288 次 / 天、8600 次 / 月,所以 2000 次免费额度大约够 7 个站点跑满一个月,再多出的部分按 ¥0.001 / 次扣(每站每月约 ¥6.6);钱包 300 次、续费 / 工单 / 插件下单 / 插件下发各 100 次、开放网关 1000 次,同理超额才计。真正要单独盯的是你自己也要为每次调用掏钱的能力(第三方算力、短信、数据源、大模型接口等):另起一行登记单价,至少覆盖成本,再配一段免费额度兜住低频用户。觉得某项该整月免费,把单价改成 0 或该行停用即可,历史用量仍照常记录。
客户端参考实现
下面是一份可直接改造的最小实现:配置、v2 签名、出站(含响应验源)、心跳(含节流与缓存)。核心思路——把整个心跳响应存成一行缓存,续费 / 插件 / 工单页都读缓存,不反复打中控。
<?php
define('CENTER_URL', 'https://www.dlam.cc'); // 交付时写死为常量、后台只读,防买家改库绕过
define('CENTER_KEY', 'f6b364026d671e44'); // = product_key
define('CENTER_SECRET', ''); // = center_secret,只用于验响应;留空即跳过验源
define('CENTER_THROTTLE', 300); // 心跳节流(秒)
define('CENTER_OFFLINE_GRACE_DAYS', 7); // 失联宽限期:超过才 fail-closed 锁前台
// 单行状态表:既存连接配置,也存中控回传的授权缓存
// center_state(id=1, center_url, product_key, site_id, site_secret,
// status, expire_at, cache TEXT, sync_error, last_sync)
// 出站:$body 必须是「已算好签名的那段字节」,本函数内不再做任何编码
function center_post(string $path, string $body): array {
$ctx = stream_context_create(['http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\nX-Sign-Version: 2\r\nUser-Agent: mysite-center/1.0\r\n",
'content' => $body,
'timeout' => 6,
]]);
$raw = @file_get_contents(CENTER_URL . $path, false, $ctx);
if ($raw === false) return ['error' => '无法连接中控'];
if (CENTER_SECRET !== '') { // 逐字节验源(可选,留空即跳过)
$got = '';
foreach ($http_response_header as $h) {
if (stripos($h, 'X-Center-Sign:') === 0) {
$got = trim(substr($h, strlen('X-Center-Sign:')));
}
}
if ($got !== '' && !hash_equals(hash_hmac('sha256', $raw, CENTER_SECRET), $got)) {
return ['error' => '中控响应验签失败'];
}
}
$j = json_decode($raw, true);
if (!is_array($j)) return ['error' => '中控返回异常'];
if (empty($j['ok'])) return ['error' => (string) ($j['error'] ?? '中控返回异常')];
return $j;
}
<?php
// 置空 sign → 紧凑序列化 → 对这段字节签名 → 回填。返回可直接发出的请求体。
// $path 必须与请求实际路径一致,它就是待签串里的 PATH。
function center_body(string $path, array $p, string $secret): string {
$p['sign'] = ''; // 键必须存在,且放在最后
$body = json_encode($p, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$s2s = "POST\n" . $path . "\n" . (int) $p['ts'] . "\n" . hash('sha256', $body);
return str_replace('"sign":""', '"sign":"' . hash_hmac('sha256', $s2s, $secret) . '"', $body);
}
// 免签登记(site_id=0)用的请求体:同样紧凑序列化,只是不必算签名
function center_body_open(array $p): string {
$p['sign'] = '';
return json_encode($p, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
<?php
function center_sync(array $st, bool $force = false): array {
// 1) 节流:未到间隔直接返回旧状态
if (!$force && !empty($st['last_sync'])
&& (time() - strtotime($st['last_sync'])) < CENTER_THROTTLE) {
return $st;
}
// 2) 组装心跳
$payload = [
'product_key' => $st['product_key'],
'site_id' => (int) $st['site_id'],
'domain' => 'https://shop-a.example.com', // 固定,且与首登一致
'version' => '1.4.0',
'ts' => time(),
'nonce' => bin2hex(random_bytes(8)),
'stats' => ['today_pv' => 128, 'order_count' => 7],
'meta' => ['site_name' => '我的站点'],
];
// 3) 已登记才签名;首次登记免签,靠 product_key 引导
$path = '/api/heartbeat.php';
$signed = ((int) $payload['site_id'] > 0 && (string) $st['site_secret'] !== '');
$body = $signed ? center_body($path, $payload, $st['site_secret'])
: center_body_open($payload);
$resp = center_post($path, $body);
if (isset($resp['error'])) {
// 自愈:身份不符时清空本地 site_id/site_secret,重跑一次走免签登记换回新密钥
if ($signed && preg_match('/bad signature|not found/i', $resp['error'])) {
// UPDATE center_state SET site_id=0, site_secret='' ...; return center_sync($st, true);
}
// 否则记录 sync_error,供离线宽限期判定(未超 CENTER_OFFLINE_GRACE_DAYS 不误伤)
return $st;
}
// 4) 落库:site_id / site_secret(首登时拿到)+ 整个响应作缓存
// UPDATE center_state SET site_id=?, site_secret=?, status=?,
// expire_at=?, cache=?, sync_error='', last_sync=NOW() WHERE id=1
return $resp;
}
续费 / 工单 / 插件加购 / 余额钱包的调用与续费示例同构:组请求体 → center_body('/api/renew.php', $p, $site_secret) → center_post('/api/renew.php', $body)。路径参数必须与你实际请求的路径逐字一致——它同时是 v2 待签串里的 PATH。套餐、插件目录、回信、更新、绑定码都从 cache 里读,无需额外接口。
集成包里的后台界面是纯静态的,它不认语言只认 JSON——换后端只需做三件事:① 逐字节复现 v2 签名;② 按 docs/CONTRACT.md 实现 17 个 /local/* 端点(登记状态与心跳缓存、授权与续费、插件商场、余额钱包);③ 实现静态路由与授权闸门(/admin/*、/plugin/<slug>/<path>、/biz/*,以及前台 /、/site/*)。参考实现是 server/php/。包内附带 _conformance/vectors.json 与 PHP / Node / Python / Go 四个自测脚本——先让自己语言的自测通过,再联调中控,这是唯一验收门槛。
安全模型
- 时效:
|now − ts| ≤ 300 秒,超窗直接拒绝。 - 防重放:
nonce在 10 分钟内不可重复(中控记录 nonces 表)。 - 签名:比较用
hash_equals(常量时间),排除sign自身。 - v2 逐字节:签的是真正上传的字节(方法 + 路径 + 时间戳 + 请求体摘要),跨语言零歧义;v1 / v2 只影响校验路径,安全强度同级,可长期共存。
- 响应验源:v2 请求会额外收到
X-Center-Sign响应头(对响应字节用center_secret做的 HMAC),站点可逐字节确认下发数据未被伪造。 - 计费与身份隔离:欠费只会让已计价的端点在超额时被拒、以及超宽限后把授权下发为已停用,不改变签名协议、不影响身份校验;单价为 0 / 已停用的端点不受欠费影响,永远照常服务。
- 登记引导:首次心跳免签,仅凭
product_key建档——所以product_key只能放在服务端,泄露会被冒登新站点。 - 绑定码:
bind_code只在你能进自己站点后台时才看得到,客户在门户自助认领因此不失守。
交付明码 PHP + 买家自部署,无法做到 100% 强制——对方理论上仍能删掉校验段或改库。参考实现把门槛抬到最高:CENTER_URL/CENTER_KEY 写死为常量、后台只读(改库无效),配合「首次激活锁 + 到期/断连 fail-closed(带离线宽限)」。这些对非技术买家(闲鱼主力)足够有效;真要防铁了心的技术买家,只剩代部署托管或核心加密分档两条路。请据此设定预期。
错误与排查
所有失败都是 { "ok": false, "error": "..." },计费拦截还会多带一句可直接展示的 message。常见 error 与含义:
| error | 含义 / 处理 |
|---|---|
missing product_key/domain | 心跳缺 product_key 或 domain |
unknown product | product_key 不存在,或产品未在中控创建 |
missing site_id | 签名接口要求 site_id > 0;先完成一次登记 |
site not found | site_id 与该 product 不匹配,或站点被删除 |
bad signature | 签名不符 → 用 v2 见四条排查(PATH 不一致最常见),用 v1 见常见错因(多半是字段/类型不一致) |
ts out of window | 时间戳超 ±5 分钟 → 校时,或对齐 center_time |
replay | nonce 重复 → 每次请求生成新随机串 |
plan not found / plugin not found | 套餐/插件不存在或不属于该产品/已下架 |
你已拥有该插件 | 已开通未过期,无需重复加购 |
empty message | 工单正文为空 |
invalid request params | 钱包接口缺 product_key / site_id / sign |
missing act / unknown act | 钱包接口缺 act,或 act 不是 topup / balance / sync |
developer_credit_insufficient | 计费拦截:开发者的信用余额不足以支付本次按次调用(免费额度已用尽),响应还会带一句 message(中文,可直接展示)。只可能出现在单价 > 0 且启用的端点;单价为 0 / 已停用的端点与平台自营产品永不返回此错误。这一条请求仍会以「拒绝」计入用量,开发者充值入账后下一次调用即恢复。处理:提示开发者在门户「费用与账单」充值,或按普通失败降级重试 |
常见问题
- 老站点用着 v1,需要改成 v2 吗? 不需要。中控按请求里的版本标记分流,v1 永久接受,两种版本可以在同一套代码里共存。只有当你换语言或发现 v1 反复对不上签名时,才值得切到 v2——加一个
X-Sign-Version: 2头、按空白占位规则组 body 即可。 - 接入中控要花钱吗?谁付? 向开发者(产品作者)收,站长和你的客户都不向中控付费。付法由平台在后台三选一:只包月(默认 ¥5 / 站点 / 月,只计当月有过心跳的站点)、只按次(七个端点各自的单价,先扣月免费额度)、包月 + 按次(默认)。无论哪种模式,每一次调用都会逐条记进开发者的「接口与计费」看板。详见计费与账单。
- 欠费会不会把我的站直接打死? 不会立刻。余额不足时只有单价 > 0 且已用尽免费额度的端点会被 fail-closed 拒掉;单价为 0 / 已停用的端点与平台自营产品照常服务。整站「已停用」(模式含包月时)需要「有欠费账单 + 余额不够抵 + 超过 7 天宽限」三条同时成立,充值入账后自动恢复。心跳被拒时站点会进入离线宽限期(见授权状态机),不会一断网就误伤。
- 不接中控能用吗? 交付版已把连接信息内置为常量、默认强制对接,站点会自动登记,你无需也无法在后台改。只有明确要卖「纯独立版」时,才在源码里关掉强制开关(
CENTER_REQUIRE_ACTIVATION=false)。 - 中控宕机会不会误伤正常客户? 不会硬伤。心跳拉取式、失联时先进入离线宽限期(默认 7 天)照常运行,只有连续超期连不上才停;中控恢复或续费到账后自动解除,后台续费入口始终可进。
- 一个 product_key 能登记多个站点吗? 能,按
domain区分,每个域名一条独立记录、独立到期。 - 换域名怎么办? 视作新站点重新登记一次;旧记录在中控保留,可手动停用。
- 到期后客户数据会丢吗? 不会。到期只是状态变化,续费后自动恢复,数据始终在客户自己库里。
- 心跳要多久一次? 建议 ≥ 5 分钟。授权、回信、更新都靠它刷新。
- 必须用 PHP 吗? 不必。协议就是 JSON + HMAC-SHA256,Node / Python / Go 等按 v2 签名算法逐字节实现即可,集成包附带各语言的签名自测向量。
接入遇到问题?
把 error 报文和你的实现片段发给服务商,加微信更快定位。