给渠道方与合作方:三个 SKU 的下单、查单与交付物获取。
| Base URL | http://127.0.0.1:8080 |
|---|---|
| 协议 | HTTP/1.1,Content-Type: application/json; charset=utf-8 |
| 编码 | 请求与响应体一律 UTF-8;中文不转义 |
| 金额单位 | 整数分,字段名以 _cents 结尾(3900 = 39.00 元) |
| 时间格式 | ISO 8601 带时区偏移,例 2026-11-08T09:30:00+08:00;不带偏移的串会被拒绝 |
| 追踪 | 请求可带 X-Request-Id;响应必回 meta.request_id |
_cents 结尾,禁止浮点、禁止字符串元。
例:39 元 = 3900;599 元 = 59900;3999 元 = 399900。
三类凭证,互不通用。服务端到服务端用 API Key;客户自助查单用订单 Token。
两种写法等价,任选其一。密钥格式 sfk_<32 位 urlsafe base64>。
# 写法 A
Authorization: Bearer sfk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# 写法 B
X-API-Key: sfk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
| 存储 | 服务端 data/api_keys.csv |
|---|---|
| 比对方式 | 常数时间比较(hmac.compare_digest),防时序侧信道 |
| 失效 | 表中「启用」列非启用即拒绝;可随时置为禁用以吊销 |
| 错误 | 缺失 → AUTH_TOKEN_MISSING(401);无效/禁用 → AUTH_TOKEN_INVALID(401) |
| 日志 | 凭证不得写入日志,只记录指纹 |
X-Order-Token: <token>
下单成功时服务端签发,token 绑定到具体订单号。持 token 只能访问该订单,不能越权看别人的单。
| 精确匹配 | /api/health /api/skus /api/quote / |
|---|---|
| 前缀匹配 | /api/skus/<sku> |
POST /api/order 在未提供凭证时返回 401;
仅当服务端设置环境变量 SF_ALLOW_PUBLIC_ORDER=1 时才允许匿名下单(该开关默认关闭)。
{
"ok": true,
"data": { "...": "业务载荷" }
}
{
"ok": false,
"error": {
"code": "MISSING_REQUIRED_FIELD",
"message": "缺少必填字段:name",
"details": { "field": "name" },
"retryable": false
}
}
| 字段 | 说明 |
|---|---|
ok | 布尔。成功恒为 true,失败恒为 false —— 先看这个 |
data | 成功时的业务载荷。删除类操作可为 null |
error.code | 业务错误码。按 code 分支,不要解析 message |
error.message | 人类可读说明,可直接展示给客户;文案不保证稳定 |
error.details | 字段级明细,便于定位到具体字段 |
error.retryable | 布尔。true = 退避后可安全重试;false = 重试无意义,须先改请求或状态 |
ok;失败时按 error.code 分支,
error.retryable === true 才自动重试(并遵守 Retry-After)。
不要用 HTTP 状态码替代 error.code 做业务分支。
幂等列:是 = 副作用接口,必须带 Idempotency-Key;否 = 只读。
| 方法 | 路径 | 认证 | 幂等 | 说明 |
|---|---|---|---|---|
| GET | /api/health | 公开 | 否 | 健康检查:可用 SKU 与服务器时间 |
| GET | /api/skus | 公开 | 否 | SKU 目录:规格、档位价格、交付周期、边界 |
| GET | /api/skus/<sku> | 公开 | 否 | 单个 SKU 详情 |
| POST | /api/quote | 公开 | 否 | 报价:按 SKU + 规格算档位与价格 |
| POST | /api/order | API Key | 是 | 建单:写台账,返回订单号与价格 |
| POST | /api/preview | API Key | 否 | 预览:007 出图预览;016/033 返回文本摘要 |
| GET | /api/orders | API Key | 否 | 订单列表 |
| GET | /api/portal/order | 订单 Token | 否 | 客户自助查单:状态、进度、交付物、边界、返工规则 |
| GET | /api/portal/download/<order_id>/<file> | 订单 Token | 否 | 下载交付物 |
| GET | /api/portal/status | 公开 | 否 | 服务状态:各 SKU 健康度与最近故障 |
| GET | /deliveries/<path> | API Key | 否 | 交付物目录下载 |
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| sku | string | 是 | SKU 编号,007 / 016 / 033 |
| spec | object | 是 | 规格对象,字段随 SKU 变化(见 GET /api/skus 的 required_inputs) |
| name | string | 是 | 客户称呼,不能为空 |
| phone | string | 是 | 11 位手机号,必须匹配 ^\d{11}$ |
| note | string | 否 | 备注,默认空串 |
| channel | string | 否 | 来源渠道,默认 web |
spec 各 SKU 的字段:
| SKU | spec 字段 |
|---|---|
| 007 | sizes[](id/one/two/passport)、backgrounds[](white/blue/red)、color(bool)、image_base64 |
| 016 | brand、product、audience、tone、platform(douyin/xiaohongshu/shipinhao/bilibili/other)、count(1–5)、seconds |
| 033 | title、activity_type(general/workshop/meetup/exam)、time_place、host、quota、need_review(bool) |
{
"ok": true,
"data": {
"order_id": "ORD-20261108-1A2B",
"sku": "016",
"tier": "script_pack",
"tier_label": "脚本包",
"price": 329,
"includes": ["脚本正文", "字幕文件", "拍摄清单"],
"not_in_scope": ["不含视频成片", "不含代运营发布"],
"pay_note": "转账请备注订单号 ORD-20261108-1A2B",
"delivery_hours": 48,
"rework_free_limit": 2
}
}
GET /api/portal/order 返回的返工规则。
所有产生副作用的请求(建单、退款、返工等)都必须带 Idempotency-Key。
Idempotency-Key: cust_9f2:order:1
# 也接受放在请求体里(字段名 idempotency_key),此时优先级低于请求头
{ "idempotency_key": "cust_9f2:order:1", "...": "..." }
| 生成方 | 调用方生成,建议 UUID v4 或 ULID |
|---|---|
| 命名建议 | {actor}:{op}:{业务实体ID}:{客户端自增序号} |
| 命中行为 | 返回首次成功响应,状态码与响应体完全一致,响应头带 X-Idempotent-Replay: true |
| 保留期 | 24 小时(TTL),过期记录自动清理 |
| 缺失 | 副作用接口未带该头 → 400 IDEMPOTENCY_KEY_REQUIRED |
| 同键不同体 | → 409 IDEMPOTENCY_CONFLICT,须换新键或核对原请求 |
| 同键处理中 | → 409 IDEMPOTENT_REQUEST_IN_PROGRESS,2 秒后以同键重试 |
| 并发保证 | 同一 key 的并发请求只有一个拿到执行权,其余要么重放要么拿 409 |
滑动窗口(sliding window)计数,窗口 60 秒,非固定窗口。超限返回 429。
| 端点 | 配额 | 说明 |
|---|---|---|
| /api/order* | 10 次/分钟 | 下单是低频高价值动作,配额更严 |
| 其它端点 | 60 次/分钟 | 默认配额 |
Retry-After: 42
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1762562295
| 超限响应 | 429 | error.code = RATE_LIMITED |
|---|---|---|
| 退避策略 | 按 Retry-After 秒数等待后重试;不要立即重试,也不要自己算退避窗口 | |
| 自动重试 | 服务端不自动重试;error.retryable 为 true 时才允许客户端退避重试 | |
| error.details | 限流时含 retry_after(秒)、limit、window_seconds、bucket | |
共 69 条,覆盖 400 / 401 / 403 / 404 / 409 / 422 / 429 / 500 / 503。
| code | 含义 | 客户端处理 | 可重试 |
|---|---|---|---|
| INVALID_ARGUMENT | 请求体或查询参数不符合规范 | 按 details 修正字段后重发 | 否 |
| MISSING_REQUIRED_FIELD | 缺少必填字段 | 补齐 details 中的字段 | 否 |
| INVALID_TIMESTAMP | 时间串格式或时区不合法 | 改用 ISO 8601 带偏移格式 | 否 |
| INVALID_AMOUNT_FORMAT | 金额非整数分或超出范围 | 改为 _cents 整数 | 否 |
| INVALID_CURSOR | 分页游标非法或已过期 | 重新从首页查询 | 否 |
| INVALID_ENUM_VALUE | 枚举值不在允许集合 | 按 details 中的 allowed 修正 | 否 |
| IDEMPOTENCY_KEY_REQUIRED | 副作用接口缺少幂等键 | 补齐后重发 | 否 |
| IDEMPOTENCY_KEY_CONFLICT | 同幂等键对应不同请求体(契约码) | 换新键或核对原请求 | 否 |
| UNSUPPORTED_MEDIA_TYPE | Content-Type 不支持 | 改用 application/json | 否 |
| PAYLOAD_TOO_LARGE | 请求体或文件超过限制 | 改用预签名直传 | 否 |
| code | HTTP | 含义 | 可重试 |
|---|---|---|---|
| AUTH_TOKEN_MISSING | 401 | 未携带凭证 | 否 |
| AUTH_TOKEN_INVALID | 401 | 凭证无效或已吊销 | 否 |
| AUTH_TOKEN_EXPIRED | 401 | 令牌已过期 | 否 |
| AUTH_CREDENTIALS_INVALID | 401 | 账号或密码错误 | 否 |
| AUTH_CSRF_INVALID | 403 | 管理端 CSRF 校验失败 | 否 |
| AUTH_IP_NOT_ALLOWED | 403 | 来源 IP 不在白名单 | 否 |
| PERMISSION_DENIED | 403 | 已认证但无该操作权限 | 否 |
| RESOURCE_NOT_OWNED | 403 | 资源不属于当前主体 | 否 |
| SUBSCRIPTION_FEATURE_LOCKED | 403 | 当前订阅档位不含该能力 | 否 |
| COMPLIANCE_ACCOUNT_RESTRICTED | 403 | 账户因合规复核被限制 | 否 |
| code | 含义 |
|---|---|
| SKU_NOT_FOUND | SKU 不存在或已下架 |
| ORDER_NOT_FOUND | 订单不存在 |
| WORKCART_NOT_FOUND | 作业卡不存在 |
| SUBSCRIPTION_NOT_FOUND | 订阅不存在 |
| PAYMENT_NOT_FOUND | 支付记录不存在 |
| ARTIFACT_NOT_FOUND | 交付物不存在 |
| UPLOAD_NOT_FOUND | 上传会话不存在或已过期 |
| PRODUCT_NOT_FOUND | 工作台产品不存在 |
| ENDPOINT_NOT_FOUND | 路径不存在 |
| METHOD_NOT_ALLOWED | 路径存在但方法不支持 |
| code | 含义 | 可重试 |
|---|---|---|
| RESOURCE_VERSION_CONFLICT | 乐观锁版本号不一致 | 是 |
| STATE_TRANSITION_NOT_ALLOWED | 当前状态不允许该迁移 | 否 |
| IDEMPOTENCY_CONFLICT | 同幂等键对应不同请求体(网关实际返回) | 否 |
| IDEMPOTENT_REQUEST_IN_PROGRESS | 同幂等键请求处理中(2 秒后同键重试) | 是 |
| QUOTE_EXPIRED | 报价已过期 | 否 |
| DUPLICATE_ORDER_IN_PROGRESS | 同一客户与 SKU 有进行中订单 | 否 |
| PAYMENT_AMOUNT_MISMATCH | 回调金额与订单应付不符 | 否 |
| REFUND_ALREADY_PROCESSED | 该笔已退款 | 否 |
| WORKCART_ALREADY_DELIVERED | 作业卡已交付 | 否 |
| SUBSCRIPTION_ALREADY_CANCELED | 订阅已取消 | 否 |
| PRICE_CHANGE_REQUIRES_SECOND_APPROVAL | 改价需第二审批角色 | 否 |
| code | 含义 | 可重试 |
|---|---|---|
| VALIDATION_FAILED | 字段级业务校验失败 | 否 |
| REQUIREMENTS_INQUIRY_INCOMPLETE | 需求五问未答全 | 否 |
| QUOTE_TIER_NOT_ALLOWED | SKU 不支持所选档位 | 否 |
| PRICE_BAND_VIOLATION | 价格超出该档位允许区间 | 否 |
| CONSIGNMENT_WINDOW_EXCEEDED | 交付窗口超出 SKU 承诺周期 | 否 |
| WORKCART_STEP_PRECONDITION_FAILED | 步骤提交前置条件不满足 | 否 |
| REWORK_RULE_VIOLATION | 返工请求超出作业卡返工规则 | 否 |
| REWORK_LIMIT_EXCEEDED | 单笔返工次数已达上限 | 否 |
| COMPLIANCE_RECORD_MISSING | 缺少合规记录(授权书/监护人同意/AI 标识) | 否 |
| CONSENT_RECORD_INVALID | 授权书或同意书缺签署人/日期/授权范围 | 否 |
| MINOR_SUBJECT_UNCONFIRMED | 素材含疑似未成年人但无监护人同意 | 否 |
| AI_DISCLOSURE_ACKNOWLEDGEMENT_MISSING | 未确认 AI 生成内容标识声明 | 否 |
| COMPLIANCE_REVIEW_REQUIRED | 该类目需合规审查后推进 | 否 |
| PARTNER_QUALIFICATION_MISSING | 方案档缺少合作方资质 | 否 |
| PROMISE_FORBIDDEN_CLAIM | 文案含禁止承诺 | 否 |
| REDLINE_POLICY_VIOLATION | 触碰四条红线,接口直接拒绝 | 否 |
| QUOTA_EXCEEDED | 账户配额用尽 | 否 |
| MATERIAL_RIGHTS_UNCONFIRMED | 素材权属未确认 | 否 |
| code | HTTP | 含义 | 可重试 |
|---|---|---|---|
| RATE_LIMITED | 429 | 速率超限(网关实际返回,带 Retry-After) | 是 |
| RATE_LIMIT_EXCEEDED | 429 | 速率超限(契约正式码) | 是 |
| MODEL_QUOTA_EXCEEDED | 429 | 模型调用配额用尽 | 是 |
| CONCURRENT_EXECUTION_LIMIT | 429 | 同一作业卡并发执行超限 | 是 |
| ORDER_QUOTA_EXCEEDED | 429 | 账户同时进行中订单数超限 | 否 |
| INTERNAL_ERROR | 500 | 未归类服务端异常 | 是 |
| AGENT_EXECUTION_FAILED | 500 | 数字员工执行失败 | 是 |
| PIPELINE_TIMEOUT | 500 | 流水线执行超时 | 是 |
| ARTIFACT_GENERATION_FAILED | 500 | 成品生成失败 | 是 |
| DATA_INTEGRITY_ERROR | 500 | 数据一致性校验失败 | 否 |
| SERVICE_UNAVAILABLE | 503 | 服务暂时不可用 | 是 |
| UPSTREAM_TIMEOUT | 503 | 依赖的模型/支付/对象存储超时 | 是 |
网关内部有少量集成别名,与 14 号契约的正式码一一对应。对外返回哪个码,以「实际返回码」列为准;两列都列出来,是为了让你在日志与文档之间不会对不上。
| 实际返回码 | 契约正式码 | HTTP | 说明 |
|---|---|---|---|
| UNAUTHORIZED | AUTH_TOKEN_INVALID | 401 | 凭证无效或已吊销 |
| IDEMPOTENCY_CONFLICT | IDEMPOTENCY_KEY_CONFLICT | 409 | 同幂等键不同请求体 |
| RATE_LIMITED | RATE_LIMIT_EXCEEDED | 429 | 触发限流 |
RATE_LIMITED 与 RATE_LIMIT_EXCEEDED 走同一套退避逻辑),
避免网关调整后客户端突然失配。
BASE=http://127.0.0.1:8080
KEY=sfk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# 1. 看 SKU 目录(公开)
curl -s $BASE/api/skus | head -c 400
# 2. 报价(公开)
curl -s -X POST $BASE/api/quote \
-H 'Content-Type: application/json' \
-d '{"sku":"016","spec":{"brand":"青山茶铺","product":"冷萃乌龙茶",
"audience":"一二线城市白领","platform":"douyin","count":5}}'
# 3. 建单(需 API Key + 幂等键)
curl -s -X POST $BASE/api/order \
-H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: channel_a:order:1' \
-d '{"sku":"016","spec":{"brand":"青山茶铺","product":"冷萃乌龙茶",
"audience":"一二线城市白领","platform":"douyin","count":5},
"name":"青山茶铺","phone":"13812345678","channel":"xhs"}'
# 4. 客户查单(订单 Token)
curl -s "$BASE/api/portal/order?order_id=ORD-20261108-1A2B" \
-H "X-Order-Token: $TOKEN"
# 5. 下载交付物
curl -s -o out.zip "$BASE/api/portal/download/ORD-20261108-1A2B/脚本包.zip" \
-H "X-Order-Token: $TOKEN"
import os, time, uuid, json, urllib.request, urllib.error
BASE = os.environ.get("SF_BASE", "http://127.0.0.1:8080")
KEY = os.environ.get("SF_API_KEY", "")
def call(method, path, body=None, headers=None, idem=None):
h = {"Accept": "application/json"}
if body is not None:
h["Content-Type"] = "application/json"
if KEY:
h["Authorization"] = "Bearer " + KEY
if idem:
h["Idempotency-Key"] = idem
h.update(headers or {})
data = json.dumps(body).encode("utf-8") if body is not None else None
req = urllib.request.Request(BASE + path, data=data, headers=h, method=method)
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, json.loads(r.read().decode("utf-8"))
except urllib.error.HTTPError as e:
payload = json.loads(e.read().decode("utf-8") or "{}")
return e.code, payload
def is_retryable(payload):
return bool(payload.get("error", {}).get("retryable"))
def create_order(sku, spec, name, phone, channel="web"):
# 幂等键在整个重试过程中保持不变
key = "channel_a:order:" + str(uuid.uuid4())
for attempt in range(3):
status, payload = call("POST", "/api/order",
{"sku": sku, "spec": spec, "name": name,
"phone": phone, "channel": channel}, idem=key)
if status < 300:
return payload["data"]
if not is_retryable(payload):
raise RuntimeError(payload["error"])
time.sleep(2 ** attempt) # 退避
raise RuntimeError("重试 3 次仍失败")
data = create_order(
"016",
{"brand": "青山茶铺", "product": "冷萃乌龙茶",
"audience": "一二线城市白领", "platform": "douyin", "count": 5},
"青山茶铺", "13812345678", channel="xhs",
)
print("订单号:", data["order_id"], "金额:", data["price"], "元")
print("付款备注:", data["pay_note"])
const BASE = "http://127.0.0.1:8080";
const KEY = "sfk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
async function call(method, path, body, { idempotencyKey, headers = {} } = {}) {
const h = { Accept: "application/json", ...headers };
if (KEY) h["Authorization"] = `Bearer ${KEY}`;
if (body !== undefined) h["Content-Type"] = "application/json";
if (idempotencyKey) h["Idempotency-Key"] = idempotencyKey;
const res = await fetch(BASE + path, {
method,
headers: h,
body: body === undefined ? undefined : JSON.stringify(body),
});
const payload = await res.json().catch(() => ({}));
if (!payload.ok) {
const err = new Error(payload.error?.message || "请求失败");
err.code = payload.error?.code;
err.retryable = !!payload.error?.retryable;
err.status = res.status;
if (res.status === 429) err.retryAfter = Number(res.headers.get("Retry-After") || 5);
throw err;
}
return payload.data;
}
async function createOrder(sku, spec, name, phone, channel = "web") {
const key = `channel_a:order:${crypto.randomUUID()}`;
for (let i = 0; i < 3; i++) {
try {
return await call("POST", "/api/order",
{ sku, spec, name, phone, channel }, { idempotencyKey: key });
} catch (e) {
if (!e.retryable) throw e; // 只重试可重试错误
const wait = e.retryAfter ?? 2 ** i; // 限流按 Retry-After
await new Promise(r => setTimeout(r, wait * 1000));
}
}
throw new Error("重试 3 次仍失败");
}
const data = await createOrder("016", {
brand: "青山茶铺", product: "冷萃乌龙茶",
audience: "一二线城市白领", platform: "douyin", count: 5,
}, "青山茶铺", "13812345678", "xhs");
console.log("订单号:", data.order_id, "金额:", data.price, "元");
console.log("付款备注:", data.pay_note);
Idempotency-Key,且重试时沿用同一个 keyok 再读 data;失败按 error.code 分支error.retryable === true 的错误做退避重试,且尊重 Retry-After_cents,不做浮点运算order_id 与 pay_note 一起展示给客户X-Order-Token 交给客户用于查单与下载,不经其他渠道外泄error.message,但分支逻辑只用 error.codeX-Request-Id 串联调用链,便于排查