API Integration Guide

密钥验证 API 对接指南

适用版本:v1.6.0 及以上。发布日期:2026-06-01。验证接口只接受独立接入服务密钥协议,签名、时间戳、nonce、设备绑定、签名许可证和限流均由服务端强制校验。

验证地址
POST /api/validate
认证方式
X-Access-Key + HMAC
签名算法
HMAC-SHA256
响应时区
Asia/Shanghai

1. 接入概述

v1.5.0 起,验证接口的设备绑定数量(device_limit)由服务端接入服务配置决定,客户端传入的 device_limit 值将被忽略。v1.4.0 起,POST /api/validate 只接受”接入服务密钥”协议。旧版全局 API_SECRET 验签方式已废弃,不存在回退、兼容或双轨验签逻辑。
v1.6.0 起,系统级 Ed25519 签名许可证启用后,成功响应会追加 signed_license。旧客户端可忽略该新增字段,新客户端可用系统公钥本地验签,防止本地授权状态、过期时间和设备绑定信息被篡改。

生产环境必须通过 HTTPS 暴露接口。签名密钥 secret_key 不应写入可逆向客户端,推荐由客户自己的服务端代理调用验证接口。

当前 Docker 镜像默认以单 worker 运行,因为验证限流器是进程内实现。多 worker、多容器或横向扩容部署前,需要先把限流迁移到 Redis 等集中式实现。

HTTPS 服务端代理推荐 单 worker 默认部署 旧 API_SECRET 已废弃

2. 接入前准备

管理员在后台“接入服务密钥”页面创建接入服务后,会得到两类凭证。

凭证说明
access_key公开标识,放入请求头 X-Access-Key
secret_key签名密钥,只在创建或轮换时展示一次。
如果 secret_key 泄露,应立即在后台禁用对应接入服务;确认接入方完成更新后再轮换密钥。轮换后旧 secret_key 立即失效。

3. 请求格式

请求体必须是 JSON object。签名只覆盖 JSON 请求体,不包含 Header。

HTTP 请求示例
POST /api/validate
Content-Type: application/json
X-Access-Key: ak_xxxxxxxxxxxxxxxxx
X-Api-Signature: <hmac-sha256>

{
  "key": "LICENSE_KEY",
  "machine_id": "DEVICE-001",
  "device_limit": 5,
  "timestamp": 1779123456,
  "nonce": "b5f2b0b7b2564bb9a48b7f6a3d3d83a1"
}
字段位置必填规则
X-Access-KeyHeader接入服务公开标识,缺失返回 MISSING_ACCESS_KEY
X-Api-SignatureHeader使用接入服务 secret_key 计算的 HMAC-SHA256。
keyBody许可证密钥。
machine_idBody设备指纹。服务端只在绑定表中保存 SHA256 摘要。
device_limitBodyv1.5.0 起由服务端接入服务配置决定,客户端传入值将被忽略。保留此字段仅为签名兼容。
timestampBodyUnix 秒级时间戳,默认允许与服务器时间偏差 300 秒。
nonceBody每次请求唯一随机字符串。

4. 签名算法

服务端签名算法与 app/core/security.py 保持一致。

Python 签名函数
import hashlib
import hmac
import json

def generate_signature(secret_key: str, body: dict) -> str:
    payload = json.dumps(body, sort_keys=True, separators=(",", ":"))
    return hmac.new(
        secret_key.encode("utf-8"),
        payload.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()
排序规则sort_keys=True,必须按字段名排序。
分隔符separators=(",", ":"),JSON 中不保留空格。
编码请求体字符串使用 UTF-8 编码。
输出HMAC-SHA256 十六进制小写字符串。
参与签名的 body 必须与实际发送的 JSON 字段和值一致。重试时必须重新生成 timestampnonce 和签名。

5. 调用示例

Python

requests 调用示例
import hashlib
import hmac
import json
import secrets
import time

import requests

ACCESS_KEY = "ak_xxxxxxxxxxxxxxxxx"
SECRET_KEY = "创建或轮换时展示的一次性secret_key"
API_ENDPOINT = "https://your-domain.com/api/validate"


def generate_signature(secret_key: str, body: dict) -> str:
    payload = json.dumps(body, sort_keys=True, separators=(",", ":"))
    return hmac.new(
        secret_key.encode("utf-8"),
        payload.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()


body = {
    "key": "LICENSE_KEY",
    "machine_id": "DEVICE-001",
    "device_limit": 5,
    "timestamp": int(time.time()),
    "nonce": secrets.token_hex(16),
}

response = requests.post(
    API_ENDPOINT,
    json=body,
    headers={
        "X-Access-Key": ACCESS_KEY,
        "X-Api-Signature": generate_signature(SECRET_KEY, body),
    },
    timeout=10,
)

print(response.status_code)
print(response.json())

Node.js

fetch 调用示例
const crypto = require('crypto');

const ACCESS_KEY = 'ak_xxxxxxxxxxxxxxxxx';
const SECRET_KEY = '创建或轮换时展示的一次性secret_key';
const API_ENDPOINT = 'https://your-domain.com/api/validate';

function stableStringify(value) {
  const keys = Object.keys(value).sort();
  const ordered = {};
  for (const key of keys) ordered[key] = value[key];
  return JSON.stringify(ordered);
}

function generateSignature(secretKey, body) {
  return crypto
    .createHmac('sha256', Buffer.from(secretKey, 'utf8'))
    .update(Buffer.from(stableStringify(body), 'utf8'))
    .digest('hex');
}

async function validateLicense() {
  const body = {
    key: 'LICENSE_KEY',
    machine_id: 'DEVICE-001',
    device_limit: 5,
    timestamp: Math.floor(Date.now() / 1000),
    nonce: crypto.randomBytes(16).toString('hex'),
  };

  const response = await fetch(API_ENDPOINT, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Access-Key': ACCESS_KEY,
      'X-Api-Signature': generateSignature(SECRET_KEY, body),
    },
    body: JSON.stringify(body),
  });

  console.log(response.status, await response.json());
}

validateLicense().catch(console.error);

6. 成功响应

首次验证未激活许可证时,服务端会激活许可证并返回激活信息。

首次激活响应
{
  "valid": true,
  "status": "active",
  "duration_type": "1个月",
  "activated_at": "2026-05-20T18:30:00+08:00",
  "expire_at": "2026-06-19T18:30:00+08:00",
  "message": "许可证已成功激活",
  "device_binding": {
    "action": "bound_new",
    "message": "设备已完成绑定",
    "machine_id_hash": "b7d8f0f2a4c9e1d6...",
    "bound_device_count": 1,
    "device_limit": 3
  },
  "signed_license": {
    "alg": "Ed25519",
    "kid": "sm-license-ed25519-signing-key",
    "payload": "base64url-json-payload",
    "signature": "base64url-signature"
  }
}

已激活许可证验证成功时,服务端返回剩余天数。

已激活验证响应
{
  "valid": true,
  "status": "active",
  "duration_type": "1个月",
  "activated_at": "2026-05-20T18:30:00+08:00",
  "expire_at": "2026-06-19T18:30:00+08:00",
  "remaining_days": 29,
  "device_binding": {
    "action": "verified_existing",
    "message": "设备已绑定,本次为正常复核",
    "machine_id_hash": "b7d8f0f2a4c9e1d6...",
    "bound_device_count": 1,
    "device_limit": 3
  },
  "signed_license": {
    "alg": "Ed25519",
    "kid": "sm-license-ed25519-signing-key",
    "payload": "base64url-json-payload",
    "signature": "base64url-signature"
  }
}
终身许可证的 expire_atnullremaining_daysnull

device_binding 字段用于告诉接入端本次联网验证后的设备绑定状态。

字段说明
actionbound_new 表示当前设备本次新绑定成功;verified_existing 表示当前设备已绑定,本次为正常复核。
message设备绑定状态说明,可用于日志或提示。
machine_id_hash服务端保存的设备摘要,不返回原始 machine_id
bound_device_count当前许可证已绑定设备数量。
device_limit当前许可证固化后的最大可绑定设备数。

signed_license 只在系统级签名许可证配置为“已启用”时返回。

字段说明
alg固定为 Ed25519
kid系统级公钥 ID,默认 sm-license-ed25519-signing-key
payloadbase64url 编码后的许可证 JSON。
signaturepayload 字符串本身签名后的 base64url 值。
后台“密钥详情”中的“离线宽限期(天)”和“下次验证间隔(小时)”会在下一次验证成功时写入 grace_untilnext_verify_at。终身许可证的 expire_atgrace_until 均为 null

7. 时间戳与时区处理

timestamp 是请求防重放字段,使用 Unix 秒级时间戳,不需要转换为北京时间。响应中的 activated_atexpire_at 是许可证业务时间,系统已改为返回带显式时区偏移 +08:00 的 ISO 8601 字符串(例如 2026-05-20T18:30:00+08:00),无需额外假设时区。

v1.4.0 起,响应时间字段统一携带 +08:00 时区偏移。接入端可直接使用标准 ISO 8601 解析器处理,无需手动追加时区后缀。
字段格式处理要求
timestampUnix 秒用于签名和 300 秒时间窗口校验,直接使用当前系统 epoch 秒。
activated_atISO 8601 字符串(含 +08:00 偏移)直接解析即可,时间已携带时区信息。
expire_atISO 8601 字符串(含 +08:00 偏移)或 null非空时直接解析判断过期;null 表示终身许可证。
存储建议保存服务端原始字符串(已含时区偏移),可直接解析为本地时间戳用于排序和过期比较。
显示建议前端可使用标准 ISO 8601 解析后格式化显示,无需手动追加时区后缀。
Node.js 时间解析
// v1.4.0 起响应时间已携带 +08:00 时区偏移,可直接解析
function parseLicenseTime(value) {
  if (!value) return null;
  return new Date(value);
}

function formatShanghaiTime(value) {
  const date = parseLicenseTime(value);
  if (!date) return '永久有效';
  return new Intl.DateTimeFormat('zh-CN', {
    timeZone: 'Asia/Shanghai',
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit',
    second: '2-digit',
    hour12: false,
  }).format(date);
}
Flutter/Dart 时间解析
// v1.4.0 起响应时间已携带 +08:00 时区偏移,可直接解析
DateTime? parseLicenseTime(String? value) {
  if (value == null || value.isEmpty) return null;
  return DateTime.parse(value);
}

8. 错误响应

验证接口使用 FastAPI HTTPException 返回错误。实际 JSON 响应外层包含 detail

错误响应结构
{
  "detail": {
    "valid": false,
    "code": "DEVICE_LIMIT_EXCEEDED",
    "message": "设备绑定数量已达上限"
  }
}

客户端应优先读取 response.detail.code;如遇到通用错误,也可能返回字符串型 detail

错误码HTTP 状态码触发条件
INVALID_REQUEST400请求体不是合法 JSON。
MISSING_ACCESS_KEY401缺少 X-Access-Key
ACCESS_KEY_INVALID401接入密钥不存在或已软删除。
ACCESS_KEY_DISABLED403接入服务已禁用。
INVALID_SIGNATURE401缺少签名或签名错误。
MISSING_KEY400缺少许可证密钥 key
MISSING_MACHINE_ID400缺少 machine_id
INVALID_DEVICE_LIMIT400device_limit 不在 1-10 范围内,或大于已固化上限。
TIMESTAMP_EXPIRED401timestamp 缺失、格式无效或超出时间窗口。
MISSING_NONCE401缺少 nonce
NONCE_REPLAYED401同一接入服务下 nonce 重复使用。
RATE_LIMITED429触发 IP、接入服务、许可证或全局限流。
KEY_INVALID404许可证密钥不存在。
LICENSE_REVOKED403许可证已撤销。
LICENSE_EXPIRED410许可证已过期。
DEVICE_LIMIT_EXCEEDED403绑定设备数量已达上限。
INTERNAL_ERROR500服务端内部错误。

9. 设备绑定规则

设备绑定由 license_devices 表记录,服务端对 machine_id 计算 SHA256 后保存摘要。后台详情页展示的是设备摘要,不再展示原始设备指纹。

  1. 所有验证请求必须传入 machine_id
  2. device_limit 由服务端接入服务配置决定(1-10,默认10),管理员在创建或编辑接入服务时设置。客户端传入的 device_limit 值将被忽略,服务端强制使用接入服务配置的值。
  3. 许可证第一次激活时,服务端会把接入服务配置的 device_limit 固化到许可证。
  4. 后续验证中,服务端使用接入服务配置的 device_limit 与已固化值校验。
  5. 同一 machine_id 重复验证不会增加绑定数量。
  6. machine_id 在未达到固化上限时自动绑定。
  7. 达到上限后,新设备验证返回 DEVICE_LIMIT_EXCEEDED
  8. 管理员在后台删除某个绑定设备后,该设备下一次联网验证会按新设备重新校验:未超限则返回 device_binding.action=bound_new 并重新绑定;已超限则返回 DEVICE_LIMIT_EXCEEDED
示例:管理员在接入服务中配置 device_limit=3,则通过该接入服务激活的许可证最多绑定 3 个设备。管理员后续修改接入服务的 device_limit 不影响已固化的许可证。
存量兼容:对于 v1.4.0 及之前已接入的载体服务,升级到 v1.5.0 后,验证时服务端将使用接入服务的 device_limit 配置(默认10),不再使用客户端传入的值。
客户端载体收到 DEVICE_LIMIT_EXCEEDEDLICENSE_REVOKEDLICENSE_EXPIREDKEY_INVALID 时,应立即停用本地授权;收到 device_binding.action=bound_new 时,应更新本地授权绑定状态。

10. 防重放规则

服务端先校验签名和时间戳,再检查并写入 nonce。nonce 按接入服务维度唯一。

必须重新生成每次请求都必须使用新的 nonce
写入时机请求通过签名和时间戳校验后,nonce 会被记录。
失败不可复用如果请求在 nonce 写入后因限流、许可证状态或设备上限失败,该 nonce 也不能再次使用。
清理窗口过期 nonce 由定时任务清理,清理窗口为时间戳窗口的 2 倍。

11. 限流规则

验证接口包含 4 个维度的限流,默认值如下。

维度默认值环境变量
IP60 次/分钟VALIDATION_IP_PER_MINUTE=60
接入服务300 次/分钟VALIDATION_ACCESS_KEY_PER_MINUTE=300
许可证摘要30 次/分钟VALIDATION_LICENSE_PER_MINUTE=30
全局3000 次/分钟VALIDATION_GLOBAL_PER_MINUTE=3000
当前实现为进程内限流,Dockerfile 默认单 worker。多 worker 或多实例部署前必须改为集中式限流。

12. 审计日志与隐私

每次验证成功或失败都会写入接入服务日志。日志记录接入服务 ID、接入密钥快照、载体名称快照、许可证密钥 SHA256 前 16 位摘要、machine_id SHA256 前 16 位摘要、IP、User-Agent、验证结果、错误码、HTTP 状态码和响应耗时。

日志不保存完整许可证密钥,不保存签名密钥,不保存完整请求体。

13. 客户端处理与常见排查

HTTP 状态码客户端处理建议
401检查接入服务密钥、签名、时间戳、nonce。
403检查接入服务是否禁用、许可证是否撤销、设备数是否超限。
404许可证不存在。
410许可证已过期。
429请求过于频繁,按 retry_after 或固定退避重试。
签名错误确认使用接入服务的 secret_key,不是旧全局 API_SECRET;确认参与签名的 JSON 与实际发送 body 完全一致。
时间戳错误确认使用 Unix 秒级时间戳,并保持客户端和服务器时间同步;响应业务时间已携带 +08:00 时区偏移,可直接解析。
设备超限查询后台密钥详情中的绑定设备摘要数量,确认接入服务中配置的 device_limit 是否符合预期。v1.5.0 起,设备绑定数量由接入服务的服务端配置决定,不再由客户端控制。
nonce 重复每次请求重新生成随机 nonce;失败重试也必须重新签名,不能复用原请求。