# 站点自主定价 API

目标：平台一次授权后，站点在自己的管理后台或可信部署脚本中调价。无需反复让平台改代码、改数据库或人工发布；Star Task 继续统一报价与扣点。

## 1. 平台一次授权

在 **Star Admin → 站点管理 → 定价授权** 签发凭证：

- `pricing:read`：读取当前规则、试算草稿、读取本站历史。
- `pricing:write`：发布、恢复继承、回滚。写入凭证同时包含读取权限。
- `min_points`：允许发布的最低正整数点数，1–1000000。图片按每张，其他固定价格按每次。它是平台设定的授权下限，不是自动计算的上游成本；应依据站点结算约定设置。
- 有效期：1–365 天，默认 90 天。到期前签发新凭证，站点替换 Secret 后撤销旧凭证。

凭证只在创建响应显示一次；数据库仅保存 SHA-256 摘要。放在站点 Worker/后端 Secret `SITE_PRICING_TOKEN`，不得进入浏览器、公开环境变量、源码或日志。总后台可随时撤销；撤销不撤销已发布价格。停用站点也会拒绝该站点的凭证。

平台自动化也可用管理员会话调用 Star Admin 主机：

```http
POST /admin/sites/<SITE_ID>/pricing-tokens
Authorization: Bearer <STAR_ADMIN_SESSION_TOKEN>
Content-Type: application/json

{"name":"production-backend","scopes":["pricing:read","pricing:write"],"min_points":1,"expires_in_days":90}
```

返回 `id`、`site_id`、`token`、`scopes`、`min_points`、`expires_at`。时间均为 Unix 毫秒。列表：`GET /admin/sites/<SITE_ID>/pricing-tokens`；撤销：`POST /admin/sites/<SITE_ID>/pricing-tokens/<TOKEN_ID>/revoke`。平台可信服务也可使用 Star Task `/internal/admin/sites/.../pricing-tokens` 同名接口和 `TASK_ADMIN_SECRET`。这些管理凭证不能交给站点。

## 2. 站点调用约定

基地址：`https://task.starhk.uk/site-api/v1/pricing`。

所有请求使用 `Authorization: Bearer <SITE_PRICING_TOKEN>`。该接口独立于普通用户 JWT、普通站点 API Key 与全局管理 API；这些凭证不能替代定价凭证。**不传 `site_id` 或 `X-Site-Id`**，身份由凭证确定。浏览器请求（带 `Origin`）会被拒绝；由站点后端调用。

站点自己的管理入口必须先验证站点管理员身份并实施 CSRF 防护；不得把持有写入凭证的代理接口开放给普通登录用户或匿名访问者。后端固定 Star Task 基地址，不接受客户端传入任意 URL。

先通过公开运行时目录 `GET /v1/models?view=products&site_id=<SITE_ID>` 选择精确的 `modes[].task_model`、`task_type` 和参数。不能把产品显示名猜成模型 ID；不同生成模式对应不同模型 ID 时分别发布。

| 方法与相对路径 | 权限 | 用途 |
| --- | --- | --- |
| `GET /access` | read | 本凭证的站点、权限、下限、到期时间 |
| `GET /models/<TASK_MODEL>` | read | 当前有效规则、来源及 `etag` |
| `POST /models/<TASK_MODEL>/preview` | read | 校验草稿并报价，不写入 |
| `PUT /models/<TASK_MODEL>` | write | 发布或恢复继承 |
| `GET /models/<TASK_MODEL>/history` | read | 最新 50 条本站自主发布记录 |
| `POST /models/<TASK_MODEL>/rollback` | write | 将指定历史版本重新发布 |

响应为顶层 `{ "ok": true, ... }`，不是 `data` 包裹。错误为 `{ "ok": false, "error": { "code", "message" } }`。响应不缓存；JSON 请求上限 32 KiB。

## 3. 简化价格定义

图片分辨率价格：

```json
{
  "type": "image_resolution",
  "field": "resolution",
  "prices": { "1K": 3, "2K": 4, "4K": 6 }
}
```

`field` 用运行时表单实际字段，允许 `resolution` 或 `quality`。档位键使用大写 K，可表达 `0.5K`、`1K`、`2K`、`4K`、`8K`；只配置模型实际支持的档位，并覆盖所有可用档位。未匹配的输入按已配置的最高档点数回退；发布前应逐档预览，不应依赖这个回退代替正确参数。

固定价格：`{"type":"fixed","points":3}`。恢复继承：`{"type":"inherit"}`。

所有价格必须是授权下限至 1000000 之间的整数。图片按每张计费，规则会乘请求中的 `n`（默认 1），例如 2K、`n=2` 为 8 点；其他媒体固定价按每次任务计费。使用其他输出数量字段、文本 Token 计费、按秒/质量复合规则或供应商自行决定扣点的模型，不应套用这个简化接口，应保留平台维护的规则。

每次发布替换该站点该模型的**完整规则**，不合并旧附加费、质量倍率或其他字段。调用者不能提交 `rules_json`、任意公式、`task_type` 或其他站点身份。模型任务类型由服务端解析。

## 4. 预览与发布示例（仅服务端）

下面函数应在已经验证站点管理员身份的后端操作或可信 CLI 中运行。`taskModel` 来自运行时目录，`token` 来自服务端 Secret。不要自动覆盖冲突。

```ts
export async function publishImagePrice(token: string, taskModel: string) {
  const base = 'https://task.starhk.uk/site-api/v1/pricing';
  const modelPath = '/models/' + encodeURIComponent(taskModel);
  async function call(path: string, method = 'GET', body?: unknown) {
    const response = await fetch(base + path, {
      method,
      headers: {
        Authorization: `Bearer ${token}`,
        ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
      },
      ...(body === undefined ? {} : { body: JSON.stringify(body) }),
    });
    const result = await response.json() as any;
    if (!response.ok) {
      if (response.status === 409) throw new Error('价格已被修改，请重新读取并让管理员确认');
      throw new Error(result.error?.message ?? `HTTP ${response.status}`);
    }
    return result;
  }
  const current = await call(modelPath);
  const pricing = {
    type: 'image_resolution', field: 'resolution',
    prices: { '1K': 3, '2K': 4, '4K': 6 },
  };
  const preview = await call(modelPath + '/preview', 'POST', {
    pricing,
    inputs: [
      { resolution: '1K', n: 1 },
      { resolution: '2K', n: 1 },
      { resolution: '4K', n: 1 },
      { resolution: '2K', n: 2 },
    ],
  });
  // 在站点管理页面将 preview.quotes 展示给管理员。
  const expected = [3, 4, 6, 8];
  if (preview.quotes.some((q: any, i: number) => q.cost_points !== expected[i])) {
    throw new Error('试算结果与期望不一致');
  }
  return call(modelPath, 'PUT', { pricing, expected_etag: current.etag });
}
```

交互式后台可将读取/预览和发布拆成两个操作；保存管理员审阅时的 `etag`，不要在点击发布后静默获取新值覆盖它。`preview` 接受 1–20 个参数对象，返回同计价引擎算出的 `quotes[].cost_points` 和 `breakdown`。

`PUT` 成功返回 `revision_id`、新 `etag`、`source_site_id`、`pricing_version`、`rules_json`。未设置本站价格时，恢复继承返回 `unchanged:true`，不新增审计。

## 5. 冲突、恢复、回滚

读取响应的 `etag` 是本站该模型当前状态标识，不是公共目录的最大 `pricing_version`。每次写入必须传入 `expected_etag`；过期或遗漏返回 `409 PRICING_CONFLICT`。更新规则和写入审计在同一事务中完成；并发修改只有匹配原状态的一次成功。

恢复全局价格：

```json
{"expected_etag":"<current etag>","pricing":{"type":"inherit"}}
```

回滚：先读取历史，再 POST `/models/<TASK_MODEL>/rollback`：

```json
{"expected_etag":"<current etag>","revision_id":"<revision to restore>"}
```

回滚把目标记录的已发布配置重新发布，生成新审计记录，仍检查当前凭证的最低点数。只能回滚本站、同模型的自主发布历史；不支持直接回滚总后台或旧 SQL 修改。历史包含凭证 ID（可追溯签发名称）、修改前后规则和时间，不包含凭证明文。站点如多人共用一个凭证，平台只能识别到该凭证，站点需自行记录操作者。

请求超时或连接中断时，先读取当前状态与历史确认是否已成功，再决定是否重新发布；不要盲目自动重试写入。`401` 表示凭证无效/过期/撤销，`403` 表示权限不足、站点停用或浏览器调用，`400` 表示参数或最低价校验失败，`404` 表示模型不存在、停用或未获本站模型权限。

## 6. 生效与验收

发布后无需重新部署前端。前端应主动失效自己的模型/规则缓存，重新获取本站目录，并调用正常的 `POST /v1/pricing/quote`。公开目录可能有短时 HTTP 缓存，不要只依赖全局最大版本号判断本站修改。创建任务响应的 `cost_points` 是实际扣点依据；已创建任务保留原扣点。

验收：1K/2K/4K 报价为 3/4/6；2K 两张为 8；其他站点不变；恢复继承后使用全局价；只读/撤销/过期凭证不能写入；冲突返回 409；浏览器产物不含定价凭证。

## 7. 平台上线顺序

1. 在 Star Task 的 task-db 执行迁移 `0086_site_pricing_access.sql`（`npm run db:migrate:remote:086`）。此迁移只创建凭证与审计表，不修改现有价格。
2. 部署 Star Task（新 API 和文档），再部署 Star Auth（授权入口）。
3. 为需要自主定价的站点签发最小权限凭证，交由其服务端保存。
4. 通过预览和正常报价验收后，站点自行发布价格。

升级不会自动给已有站点增加写权限，也不会自动发布示例中的 3/4/6 价格。旧管理接口和已有前端报价/扣点流程继续可用。

## Opt-in image resolution contract v1

The existing image_resolution request accepts optional `contract_version: 1`, `default_resolution: "1K"`, and `unit: "output" | "request"`. Version 1 requires complete supported resolution coverage, normalizes aliases and rejects unmatched tiers; omitting the version preserves historical behavior. See [image resolution pricing](/docs/image-resolution-pricing/) for full examples and frontend quote protection.
