跳到内容
API 文档
Esc
切换打开⌘J预览

认证与访问令牌

使用 OAuth 2.0 client_credentials 获取 1 小时有效的 Bearer token,并正确缓存和更新访问令牌。

Darkroom API v2 使用 OAuth 2.0 client_credentials。整个流程只有两种凭据:

凭据 生命周期 出现在哪里
client_secret 长期(默认 90 天,可选 30/90/180 天或永不过期) 仅在获取访问令牌时发送到 /oauth/token
access_token 1 小时 每个业务请求的 Authorization

client_credentials 流程不返回 refresh_token。客户端可使用现有的 client_idclient_secret 获取新的访问令牌;该行为符合 RFC 6749 §4.4.3。

两步接入

获取访问令牌

使用长期凭据向 /oauth/token 发送 application/x-www-form-urlencoded 请求。

调用业务端点

在每个业务请求中发送 Authorization: Bearer <access_token>,请求正文使用 UTF-8 JSON。

第一步:获取访问令牌

curl -X POST https://sandbox-api.darkroom.net/v2/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "client_id=dr_test_ci_8f2a1c9d4b7e" \
  -d "client_secret=dr_test_cs_3e6b0a5f9c21" \
  -d "scope=orders:write orders:read"
{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "orders:write orders:read"
}

scope 参数可省略,省略时授予该凭据已授权的全部 scope。

第二步:调用业务端点

curl -X POST https://sandbox-api.darkroom.net/v2/orders \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "order_code": "crm-20260727-K7Q2XM",
    "scheduled_date": "2026-08-18",
    "scheduled_start_time": "10:30:00",
    "store_code": "st_bj_chaoyang",
    "services": [
      { "service_code": "family-basic", "unit_price_amount": "699.00", "quantity": 1 }
    ],
    "customer": {
      "name": "陈女士",
      "country_calling_code": "+86",
      "mobile": "13800000000"
    }
  }'

缓存和更新访问令牌

以下示例演示如何缓存访问令牌,并在到期前获取新令牌:

const TOKEN_URL = "https://sandbox-api.darkroom.net/v2/oauth/token";
const API_BASE_URL = "https://sandbox-api.darkroom.net/v2";
// 提前 60 秒更新令牌,避免业务请求在令牌到期时失败
const TOKEN_REFRESH_SKEW_SECONDS = 60;

let cachedAccessToken = null;
let cachedAccessTokenExpiresAt = 0;

async function getAccessToken() {
  if (cachedAccessToken && Date.now() < cachedAccessTokenExpiresAt) {
    return cachedAccessToken;
  }

  const response = await fetch(TOKEN_URL, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "client_credentials",
      client_id: process.env.DARKROOM_CLIENT_ID,
      client_secret: process.env.DARKROOM_CLIENT_SECRET,
    }),
  });

  if (!response.ok) {
    throw new Error(`Token request failed: ${response.status}`);
  }

  const token = await response.json();
  cachedAccessToken = token.access_token;
  cachedAccessTokenExpiresAt =
    Date.now() + (token.expires_in - TOKEN_REFRESH_SKEW_SECONDS) * 1000;

  return cachedAccessToken;
}

export async function createOrder(orderPayload) {
  const response = await fetch(`${API_BASE_URL}/orders`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${await getAccessToken()}`,
      "Content-Type": "application/json",
      Accept: "application/json",
    },
    body: JSON.stringify(orderPayload),
  });

  return { status: response.status, body: await response.json() };
}
import os
import time

import requests

TOKEN_URL = "https://sandbox-api.darkroom.net/v2/oauth/token"
API_BASE_URL = "https://sandbox-api.darkroom.net/v2"
TOKEN_REFRESH_SKEW_SECONDS = 60

_cached_access_token = None
_cached_access_token_expires_at = 0.0


def get_access_token() -> str:
    global _cached_access_token, _cached_access_token_expires_at

    if _cached_access_token and time.time() < _cached_access_token_expires_at:
        return _cached_access_token

    response = requests.post(
        TOKEN_URL,
        data={
            "grant_type": "client_credentials",
            "client_id": os.environ["DARKROOM_CLIENT_ID"],
            "client_secret": os.environ["DARKROOM_CLIENT_SECRET"],
        },
        timeout=15,
    )
    response.raise_for_status()
    token = response.json()

    _cached_access_token = token["access_token"]
    _cached_access_token_expires_at = (
        time.time() + token["expires_in"] - TOKEN_REFRESH_SKEW_SECONDS
    )
    return _cached_access_token


def create_order(order_payload: dict) -> tuple[int, dict]:
    response = requests.post(
        f"{API_BASE_URL}/orders",
        json=order_payload,
        headers={
            "Authorization": f"Bearer {get_access_token()}",
            "Accept": "application/json",
        },
        timeout=15,
    )
    return response.status_code, response.json()

Scope

Scope 命名规则是 资源:动作,由商家在管理后台勾选授权。

Scope 含义
orders:write 创建订单
orders:read 查询订单

Scope 授权遵循以下规则:

  1. orders:write 不包含 orders:read。查询订单必须单独授予 orders:read;权限不足时返回 HTTP 403 和 insufficient_scope
  2. 访问令牌的最终 scope 是凭据已授权 scope 与本次请求 scope 的交集。请求未授权的 scope 时,服务端返回 invalid_scope,不会自动缩减权限后继续签发。

例如,订单来源系统可以仅授予 orders:write,使其只能创建订单;分析系统可以仅授予 orders:read,使其只能查询订单。

凭据保管

措施 说明
前缀识别 dr_live_ci_ / dr_live_cs_ / dr_test_ci_ / dr_test_cs_,便于密钥扫描工具识别可能的泄露
一次性显示 client_secret 仅创建时显示一次,之后服务端只存 hash
平滑轮换 同一凭据支持同时保留两个有效 secret,便于在不中断服务的情况下完成轮换
有效期 30 / 90 / 180 天或永不过期,默认 90 天;到期前 7 天提醒
审计 记录 last_used_at 与调用方 IP,可选 IP 白名单

client_secret 必须保存在服务端密钥管理系统或环境变量中,不得嵌入网页、移动端应用、代码仓库、工单或截图。

认证失败怎么排查

状态 error.code 含义与处置
401 invalid_client client_idclient_secret 无效。请确认凭据内容及其所属环境
401 credential_expired 凭据已过有效期。请在管理后台续期或创建新凭据,无需重试原请求
400 invalid_scope 请求包含未授权的 scope。请调整请求,或由商家管理员补充授权
400 unsupported_grant_type 只支持 client_credentials
401 invalid_token / missing_token token 已过期、被撤销或未发送。获取新令牌后可重试一次;如果仍返回 401,请检查凭据状态并停止自动重试
403 insufficient_scope token 的 scope 不含本端点所需权限(注意 write 不含 read
403 credential_disabled 凭据已被停用,联系商家管理员

联系支持团队时,请提供 request_id、HTTP 状态码、error.code 和发生时间。不得发送 client_secret、完整访问令牌或包含顾客资料的请求正文。

下一步