# SD2.0 下游商户 API 接入指南（v2.0.0）

这份文件是给后端开发者、自动化代理和 Codex 直接执行的契约。它覆盖除管理员后台外的全部商户能力：素材登记/上传、音频转黑屏视频、人脸处理开关、异步生成、HitPaw 去水印、SoulLens 1080p/2K 超分、封面、任务还原、取消、删除、额度和 Webhook。

## 0. 固定约定

- 生产地址：`https://sd2.kontony.asia`
- OpenAPI：`GET /merchant/openapi.json`
- 交互文档：`GET /merchant/docs`
- AI/Codex 版：`GET /merchant/ai-guide.md`
- 所有时间是 Unix 秒；香港服务器热资源保留 7200 秒（2 小时）。接近到期时，最终视频会流式转存到 Filebin 临时视频床，校验成功后继续保留 259200 秒（72 小时）；72 小时后本站自动停止提供链接。
- Key ID 和 Secret 只放在商户服务端。浏览器调用自己的后端，由自己的后端调用 SD2；不要把 Secret、签名逻辑或管理员接口发到浏览器。
- 每个任务使用 `request_id` 做幂等键。网络超时后必须用同一个 `request_id` 重试，不能生成新订单号。

## 1. 签名：逐字节一致

每个请求都带四个头：`X-SD2-Key`、`X-SD2-Timestamp`、`X-SD2-Nonce`、`X-SD2-Signature`。服务端允许时间戳与当前时间相差 ±300 秒；Nonce 为 8–120 个字符，同一 Key 十分钟内不得重用。

签名原文只有五行，行尾不要额外换行：

```text
BODY_HASH = hex_lower(SHA256(原始请求体字节))
CANONICAL = METHOD_UPPER + "\n" + PATH_WITH_QUERY + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + BODY_HASH
SIGNATURE = hex_lower(HMAC-SHA256(SECRET_UTF8, CANONICAL_UTF8))
```

`PATH_WITH_QUERY` 是 URL 的原始路径加原始查询串，例如 `/merchant/v1/jobs?limit=50&before=0`；不要改顺序、不要 URL 解码、不要补域名。GET/DELETE 没有请求体时，`BODY_HASH` 固定为 SHA-256 空字节：`e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`。JSON 必须先用 UTF-8、无 BOM、紧凑分隔符序列化成一份 bytes，再用同一份 bytes 发送和计算哈希。

### Python 签名函数

```python
import hashlib, hmac, secrets, time

def sd2_headers(method, path_with_query, body: bytes, key_id, secret):
    timestamp = str(int(time.time()))
    nonce = secrets.token_urlsafe(18)
    body_hash = hashlib.sha256(body).hexdigest()
    canonical = "\n".join([method.upper(), path_with_query, timestamp, nonce, body_hash])
    signature = hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest()
    return {"X-SD2-Key": key_id, "X-SD2-Timestamp": timestamp,
            "X-SD2-Nonce": nonce, "X-SD2-Signature": signature}
```

### Node.js 签名函数

```js
import crypto from "node:crypto";
export function sd2Headers(method, pathWithQuery, body, keyId, secret) {
  const bytes = Buffer.isBuffer(body) ? body : Buffer.from(body ?? "", "utf8");
  const timestamp = String(Math.floor(Date.now() / 1000));
  const nonce = crypto.randomBytes(18).toString("base64url");
  const bodyHash = crypto.createHash("sha256").update(bytes).digest("hex");
  const canonical = [method.toUpperCase(), pathWithQuery, timestamp, nonce, bodyHash].join("\n");
  const signature = crypto.createHmac("sha256", secret).update(canonical, "utf8").digest("hex");
  return {"X-SD2-Key": keyId, "X-SD2-Timestamp": timestamp,
          "X-SD2-Nonce": nonce, "X-SD2-Signature": signature};
}
```

## 2. 素材

有两种方式，二选一：

1. `POST /merchant/v1/media` 登记一个你自己的 HTTPS 地址。服务端在生产时读取该地址；地址必须是 HTTPS 且属于服务端允许的外部域名。
2. `POST /merchant/v1/uploads` 直接上传二进制。单文件上限 200 MB，上传后返回 `media.id` 和临时 URL，保留 2 小时。请求头还必须有 `X-File-Name` 和 `X-SD2-Content-SHA256`（原始文件 SHA-256）。音频会在上传阶段异步受控转换为 1280×720、带原音频的黑屏 MP4，返回的 media 类型为 `video/mp4`；调用方等待该上传响应后再创建任务。

一个任务最多 12 个素材，`mediaIds` 不可重复，且必须属于当前商户。素材删除前不得被任何任务引用。

## 3. 创建任务

`POST /merchant/v1/jobs` 的最小请求：

```json
{"request_id":"order_20260816_0001","prompt":"产品在自然光下缓慢旋转展示"}
```

完整字段：`duration` 4–15 秒，`ratio` 为 `16:9`、`9:16`、`1:1`、`4:3`、`3:4`、`21:9`；`mediaIds` 最多 12 个；`pencil_faces`（默认 false）和彩铅人脸处理、`grid_faces`（默认 false）人脸网格；`watermark`（默认 true）进入 HitPaw 去水印；`super_resolution` 可为 `""`、`"1080p"`、`"2k"`；`callback_url` 可覆盖商户默认 HTTPS 回调。

超分强制顺序：**视频生成 → 去水印 → SoulLens 超分**。任务成功返回的 `resultUrl` 永远是最后一步的最终视频。

生产端会在独立提示词前自动补充固定协议：按所选比例和秒数生成；音频已转换为黑屏视频并与提示词关联；首次审核不通过立即停止，不改提示词、不换素材、不重试，并反馈具体原因；成功视频必须可见。商户只提交自己的独立提示词，不要重复拼接这段协议。

创建成功会立即预占额度：视频成本 +（1080p 或 2K 成本）。任何生成、去水印或超分失败都会释放预占额度，失败任务不扣费。

## 4. 状态机、轮询和回调

`status` 终态只有 `success`、`failed`；其余状态都表示处理中，常见值为 `queued`、`claimed`、`uploading`、`generating`、`recovering`、`processing`。`stage` 为 `generation`、`watermark`、`super_resolution`、`complete`、`failed`。客户端应使用指数退避轮询：2、4、8、15、30 秒，之后每 30 秒一次；收到终态立即停止。不要并行轮询同一任务。

成功：检查 `resultUrl` 和 `resultExpires`，在过期前由用户下载或由后端流式转发。失败：展示 `message` 和 `pipeline` 中失败节点，保留 `input` 供“编辑并还原”，不要自动重试同一订单；如要重新生产，生成新的 `request_id`。

如果创建时传 `callback_url`，终态会 POST 完整任务快照。配置了回调 Secret 时，验签：`hex_lower(HMAC-SHA256(CALLBACK_SECRET_UTF8, 原始响应体 UTF8 bytes))`，对比头 `X-SD2-Webhook-Signature`。回调必须返回 2xx；非 2xx 会保留 `callbackSent=false`，客户端可继续轮询。

## 5. 任务生命周期接口

| 方法 | 路径 | 用途 |
|---|---|---|
| GET | `/merchant/v1/jobs/{id}` | 单任务快照，字段完整 |
| GET | `/merchant/v1/jobs?limit=50&before=0` | 分页列表，下一页使用 `nextBefore` |
| GET | `/merchant/v1/jobs/{id}/restore` | 取回原始 prompt、比例、秒数、开关和素材，重建编辑页 |
| GET | `/merchant/v1/jobs/{id}/poster` | 取首帧 JPEG；需带 SD2 签名 |
| POST | `/merchant/v1/jobs/{id}/cancel` | 仅 queued 可取消，立即释放预占额度 |
| DELETE | `/merchant/v1/jobs/{id}` | 仅已结算的 success/failed 可删除，同时清理结果文件 |
| GET | `/merchant/v1/quota` | 当前额度、预占、可用、已消费和单价 |
| GET | `/merchant/v1/quota/ledger` | 额度流水分页 |
| GET | `/merchant/v1/health` | 版本、能力、限制和当前商户并发配置 |

## 6. 错误和处理策略

错误响应统一为 `{ "detail": "稳定错误码" }`。`400` 修正参数；`401` 重新生成时间戳/Nonce并检查 Secret；`402` 引导充值；`409` 不重放同一 Nonce，或等待任务终态；`413` 压缩/拆分素材；`429` 遵守 `Retry-After`（若有）并降低并发；`507` 等待临时空间清理后重试上传。稳定错误码包括：`request_id_required`、`prompt_required`、`prompt_too_long`、`ratio_invalid`、`media_count_invalid`、`media_not_owned`、`media_url_not_allowed`、`x-sd2-content-sha256_required`、`merchant_upload_hash_mismatch`、`merchant_upload_too_large`、`merchant_audio_too_large`、`audio_conversion_queue_busy`、`audio_conversion_failed`、`merchant_quota_insufficient`、`merchant_concurrency_limit`、`merchant_job_not_found`、`merchant_job_already_claimed`、`merchant_job_not_terminal`、`poster_not_ready`、`task_media_missing`、`merchant_media_in_use`、`temporary_storage_capacity_reached`。

## 7. “等价 SD2 工作台”实现顺序

1. 商户后端保存 Key/Secret，提供自己的登录和权限；浏览器只调用商户后端。
2. 上传素材并展示缩略图；等待音频上传响应，标记“音频已转黑屏视频”。
3. 组装创建请求，生成唯一 `request_id`，服务端签名并发送。
4. 立即展示队列卡片、首帧占位、输入快照、`quotaReserved`；列表每个用户只查询自己的商户任务。最终视频先由香港服务器提供，接近 2 小时到期后自动切换为 Filebin 地址，前端仍使用原任务接口。
5. 轮询快照，按 `stage` 显示生成/去水印/超分进度；success 显示首帧和播放按钮，下载直接跟随 `resultUrl`。
6. failed 显示具体 message 和失败节点，允许 restore；不扣额度。
7. 结果过期或用户主动删除后清理本地缓存，不把大视频长期落盘。

## 8. 可运行的最小 Node.js 调用

```js
const base = "https://sd2.kontony.asia";
const keyId = process.env.SD2_KEY, secret = process.env.SD2_SECRET;
async function call(method, path, value) {
  const body = value === undefined ? Buffer.alloc(0) : Buffer.from(JSON.stringify(value));
  const headers = {"Content-Type":"application/json", ...sd2Headers(method, path, body, keyId, secret)};
  const r = await fetch(base + path, {method, headers, body: body.length ? body : undefined});
  const text = await r.text(); if (!r.ok) throw new Error(`${r.status} ${text}`); return text ? JSON.parse(text) : null;
}
const created = await call("POST", "/merchant/v1/jobs", {request_id:`web_${Date.now()}`, prompt:"一只纸飞机在夕阳中飞过城市", ratio:"9:16", duration:8, watermark:true, super_resolution:"1080p"});
let job = created; while (!["success","failed"].includes(job.status)) { await new Promise(r=>setTimeout(r,5000)); job = await call("GET", `/merchant/v1/jobs/${created.id}`); }
if (job.status === "success") console.log("download:", job.resultUrl); else console.error(job.message);
```

字段、响应 schema、示例和机器校验规则以 `/merchant/openapi.json` 为最终契约；当文档文字与 schema 冲突时，以 schema 和服务器实际响应为准，并联系平台升级版本。
