feat: add async image task API

This commit is contained in:
QiuSW
2026-07-08 22:08:48 +08:00
parent c98f713762
commit 25a4080177
26 changed files with 1531 additions and 48 deletions
+113 -2
View File
@@ -13,7 +13,7 @@
- 用户或 Key 被禁用/吊销(disabled/revoked):返回 `403`。
- **对外 API 只接受 API Key 认证,不接受 Web session**(浏览器带 cookie 也不能调 API,防绕过计费归属)。
- 例外:客户端下载版本检查接口只返回公开发布元数据,设计为匿名只读接口,不需要 API Key,不读取用户、不扣点。
- 图片生成为**同步**接口,可能耗时较长,调用方与网关需设置足够超时(≥ 300s)。
- 图片生成提供两条并存路径:旧 `POST /api/v1/generate/image` 为同步接口,可能耗时较长;T-614 起新增异步任务接口 `POST /api/v1/generate/image/tasks` + `GET /api/v1/generate/image/tasks/{task_id}`,新版桌面端优先使用异步提交 / 轮询,旧客户端继续走同步接口。
- 用户端注册使用同一个 `User` 账本主体;注册邮箱**必填且唯一**(`ACCOUNT_EMAIL_VERIFICATION="none"`,**不做邮箱验证**、注册即可用),唯一约束避免同邮箱对应多个点数账户。
- T-608 起新用户注册成功一次性赠送 **100 点**试用点数;赠点必须经计费层写入钱包和 `PointsLedger(change_type=signup_bonus)`,不得直接改余额字段。历史用户是否补发不属于默认注册流程。
- API Key 库内只存 `key_hash`(SHA-256)与 `key_prefix`,明文只在创建时返回一次,不在 admin、日志或调用记录中回显。
@@ -21,7 +21,9 @@
T-301 已实现对外 API 鉴权基线:`apps.api.authentication.ApiKeyAuthentication` 只解析 `Authorization: Bearer <API_KEY>`;生成、余额等外部 API 视图应继承 `apps.api.views.ExternalApiView`,不接受 Web session。
T-302 已实现生成接口基线:`POST /api/v1/generate/title` 与 `POST /api/v1/generate/image` 已接入 API Key 鉴权、别名解析、计费规则、预扣点、Provider 调用、成功确认和失败退点;图片结果当前以本地 `MEDIA_ROOT` 保存并返回 `image_url`,后续可替换为对象存储。T-613 起内部实现已抽为生成核心 service,旧同步接口仍保持原字段、状态码和错误语义;后续异步 worker 必须复用同一套预扣、执行、确认和退点阶段,不得复制第二套资金逻辑。
T-302 已实现生成接口基线:`POST /api/v1/generate/title` 与 `POST /api/v1/generate/image` 已接入 API Key 鉴权、别名解析、计费规则、预扣点、Provider 调用、成功确认和失败退点;图片结果当前以本地 `MEDIA_ROOT` 保存并返回 `image_url`,后续可替换为对象存储。T-613 起内部实现已抽为生成核心 service,旧同步接口仍保持原字段、状态码和错误语义。T-614 起新增异步生图任务接口,提交阶段同步审核 prompt 和预扣点,worker 复用同一套已预扣执行 / 成功确认 / 失败退点阶段,不复制第二套资金逻辑。
T-614 已实现生图异步任务化:`POST /api/v1/generate/image/tasks` 返回 `202` 与公开 UUID `task_id`,`GET /api/v1/generate/image/tasks/{task_id}` 轮询任务状态和结果。任务表 `ImageGenerationTask` 关联 `user`、`api_key` 与已预扣 `CallRecord`,支持 `Idempotency-Key` 去重、payload 冲突 `409 idempotency_conflict`、租约 / 心跳 / reaper 处理僵尸 running 任务并幂等退点。异步结果 URL 由 `MEDIA_PUBLIC_BASE_URL` 或 `PUBLIC_BASE_URL` 生成,不透传上游临时链接。
T-303 已实现余额查询基线:`GET /api/v1/balance` 已接入 API Key 鉴权,返回当前 `UserWallet.points_balance`,并保留旧字段同时新增不含邮箱的 `account` 账号展示对象;测试覆盖响应余额与 `PointsLedger.points_delta` 累加值一致的账务场景,并确认 Web session 不能调用该外部接口。
@@ -71,6 +73,9 @@ T-607/T-609 已实现 `GET /api/v1/client/releases/latest?platform=windows`,
| `no_pricing_rule` | 未配置对应计费规则 | 400 |
| `model_not_allowed` | 该模型不支持此操作(如图片模型生成标题) | 400 |
| `content_blocked` | 输入内容未通过安全审核;T-604 中表示 prompt 命中本地敏感词,未扣点 | 400 |
| `idempotency_conflict` | 同一 `Idempotency-Key` 已用于不同请求 payload | 409 |
| `task_not_found` | 图片生成任务不存在或不属于当前 API Key 所属用户 | 404 |
| `task_timeout` | 异步图片任务租约超时,reaper 已判失败并退点 | 200(轮询响应内 `status=failed`) |
| `upstream_timeout` | 上游 AI 调用超时(已退点) | 502 |
| `upstream_error` | 上游 AI 失败(已退点) | 502 |
| `signature_invalid` | 支付回调验签失败 | 400 |
@@ -193,6 +198,112 @@ GET /api/v1/client/releases/latest?platform=windows
要点:改图类模型缺原图返回 `bad_request`,不落 500;上游超时返回 `upstream_timeout`,其他上游失败返回 `upstream_error`,两者都**不最终扣点**(已预扣则退回)。T-604 后 prompt 命中本地敏感词时返回 `content_blocked`,并且不得下载 `image_url` 或解码大图后再拦截。T-612 后生图上游读取超时会受 `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS` 硬截止保护,避免旧同步接口长期占用生成池线程。结果默认存对象存储返回 `image_url`,避免同步响应体过大。请求里的 `image_url` 适用同标题接口相同的 SSRF 防护和大小上限;内网图片请用 `image_base64`。
### `POST /api/v1/generate/image/tasks`
提交异步图片生成任务。该接口与旧同步接口共存,参数与 `POST /api/v1/generate/image` 一致,额外支持请求头 `Idempotency-Key`。
请求:
```http
POST /api/v1/generate/image/tasks
Authorization: Bearer sk_cmhub_xxx
Idempotency-Key: desktop-job-20260708-0001
Content-Type: application/json
```
```json
{
"prompt": "把这件衣服换成模特上身的穿搭图",
"model": "image-hd",
"image_base64": "data:image/png;base64,...",
"resolution": "1K",
"aspect_ratio": "1:1",
"parameters": {}
}
```
成功响应:
```json
{
"task_id": "2bff8217-47a9-44f1-9bd9-82a375e79dc9",
"status": "queued",
"call_id": 12346,
"points_cost": 10,
"points_balance": 88,
"created_at": "2026-07-08T21:30:00+08:00",
"expires_at": "2026-07-09T21:30:00+08:00"
}
```
要点:
- 提交阶段同步执行 prompt 审核;命中敏感词返回 `400 content_blocked`,不建任务、不扣点、不调上游。
- 提交阶段预扣点数;余额不足返回 `402 insufficient_points`,不建任务。
- `Idempotency-Key` 按当前 API Key 去重。同 key + 同 payload 返回同一 `task_id`,不重复扣点;同 key + 不同 payload 返回 `409 idempotency_conflict`。
- 不保存 provider `raw`、上游密钥或 `image_base64` 原文;base64 会解码后作为输入文件引用保存,任务请求快照只保存必要字段和文件引用。
- `image_url` 仍按同步接口的 SSRF 与大小规则处理,失败不扣点。
### `GET /api/v1/generate/image/tasks/{task_id}`
轮询异步图片生成任务。请求:
```http
GET /api/v1/generate/image/tasks/2bff8217-47a9-44f1-9bd9-82a375e79dc9
Authorization: Bearer sk_cmhub_xxx
```
排队或执行中:
```json
{
"task_id": "2bff8217-47a9-44f1-9bd9-82a375e79dc9",
"status": "running",
"call_id": 12346,
"points_cost": 10,
"created_at": "2026-07-08T21:30:00+08:00",
"updated_at": "2026-07-08T21:30:05+08:00",
"expires_at": "2026-07-09T21:30:00+08:00"
}
```
成功:
```json
{
"task_id": "2bff8217-47a9-44f1-9bd9-82a375e79dc9",
"status": "succeeded",
"call_id": 12346,
"points_cost": 10,
"created_at": "2026-07-08T21:30:00+08:00",
"updated_at": "2026-07-08T21:31:40+08:00",
"expires_at": "2026-07-09T21:30:00+08:00",
"result": {
"image_url": "https://cm.833729.com/media/generated/images/2026/07/08/result.png"
}
}
```
失败:
```json
{
"task_id": "2bff8217-47a9-44f1-9bd9-82a375e79dc9",
"status": "failed",
"call_id": 12346,
"points_cost": 10,
"created_at": "2026-07-08T21:30:00+08:00",
"updated_at": "2026-07-08T21:33:00+08:00",
"expires_at": "2026-07-09T21:30:00+08:00",
"error": {
"code": "task_timeout",
"message": "图片生成任务超时,已退回点数"
}
}
```
要点:查询只允许任务所属 `user` 与当前 API Key 所属 `user` 一致,跨用户返回 `404 task_not_found`;`succeeded` 重复查询必须返回同一个 `image_url`;`failed` 表示已退款或未扣款,不需要客户端再请求退款。任务元数据默认保留 `IMAGE_TASK_RETENTION_HOURS`(默认 24h),结果图片保留窗口按 `GENERATED_IMAGE_RETENTION_HOURS`(默认 72h)管理;本任务暂不暴露 cancel 路由。
### `GET /api/v1/balance`
查询当前账号点数余额。成功响应: