认证与访问令牌
使用 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_id 和 client_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 授权遵循以下规则:
orders:write不包含orders:read。查询订单必须单独授予orders:read;权限不足时返回 HTTP 403 和insufficient_scope。- 访问令牌的最终 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_id 或 client_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、完整访问令牌或包含顾客资料的请求正文。
下一步
- 幂等、重试与错误处理:区分安全重试、编号冲突和两类限流。
- Darkroom API v2 Reference:完整的请求 / 响应 schema。
