Files
lixi 8275a04903
CI / ai-gates (push) Successful in 38s
feat(ai): 接入 gpt-image-2 图片生成(文生图 + 图生图)
新增 app/providers/openai/:client 传输(JSON + multipart)、errors 错误映射、
models 厂商 DTO、capabilities 尺寸算术约束、tasks 同步→异步桥、image 图片业务。
装配与配置同步接入,缺 OPENAI_API_KEY 时跳过装配、服务仍可启动。

与火山视频的三处根本差异及其处置:
- 接口同步(无任务 ID、无查询端点)→ SyncTaskRegistry 在本服务内部吸收,
  对外仍是 ADR-023 §4 的两段式提交/轮询。代价:本服务从此持有状态,进程重启
  丢在途任务,且横向扩容前须先解决任务 ID 只在签发进程内有效的问题。
- 产物是 base64 字节而非 URL → 无过期问题,但字节只在内存里。asset_url 放
  data URI 仅为满足领域不变式,绝不能原样写进 media.assets.external_url。
- 图生图走 multipart → 先下载参考图再上传;下载失败与超限按参数错误同步拒绝,
  不变成注定失败的后台任务。

两处需要记住的判断:
- 尺寸上限「3840x2160」文档未说清是逐边还是按像素。逐边理解会禁掉所有高于
  2160 的竖图,而 1024x1536 是文档明示的标准竖图,故必然过严。采用最长边
  ≤3840 且总像素 ≤3840×2160,已在 capabilities.py 与取证页标为推断。
- 两份文档都没有错误码表,故 errors.py 以 HTTP 状态码为主、type/code 为辅,
  顶部标注「未经文档证实」。429 无线索时按限流而非配额耗尽(猜成配额会把可
  重试的限流判死);400 无线索时归服务端错误而非内容被拒(错报会让用户去改
  一个其实没问题的提示词)。

一处死锁已修并回归钉住:SyncTaskRegistry 原用 add_done_callback 记录完成时刻,
而快速完成的任务在注册时已 done,回调便同步地在持有非可重入锁的线程里执行。
改为首次被观察到时惰性记录。

测试 166 → 271(+105)。四道门禁逐条读退出码,全绿。
产物转存仍阻塞于存储决策(ADR-023 §7),图片模型因此未播种进 V7 目录。
2026-09-21 12:10:07 +08:00

124 lines
5.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""内部接口路由。
**本层只做编排,不含任何厂商知识**:解析请求 → 按 `media_kind` 找适配器 → 调用 →
把领域结果序列化。厂商差异全在 `app/providers/<厂商>/` 内。
一处刻意的选择:路径参数是**服务商的任务 ID**,不是我方 `job_id`。本服务不持有
状态,唯一能用来问服务商的就是这个 ID;若用 `job_id` 就得存一份映射,「无状态」
不再成立,且会多出一个「Java 拿到 ID 但查不到任何东西」的中间态。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, Request
from app.domain.generation import MediaKind
from app.providers.base import GenerationProvider
from app.providers.openai.client import OpenAiError
from app.providers.registry import ProviderRegistry
from app.providers.volcengine.client import ArkError
from .auth import verify_service_token
from .errors import ProviderError, ServiceError, ServiceErrorCode
from .schemas import SubmitRequest, SubmitResponse, TaskResponse
log = logging.getLogger(__name__)
router = APIRouter(
prefix="/internal/generation",
dependencies=[Depends(verify_service_token)],
)
def _registry(request: Request) -> ProviderRegistry:
return request.app.state.registry
def _provider_for(
registry: ProviderRegistry, media_kind: MediaKind
) -> GenerationProvider:
"""按 `media_kind` 取适配器。
查不到即 `provider_unavailable`(503)而不是 500:这通常是凭证未配置导致适配器
没装配,属环境问题而非代码缺陷,且对调用方而言是**可重试**的——配好就能用。
"""
for provider in registry.all():
if provider.media_kind is media_kind:
return provider
raise ServiceError(
ServiceErrorCode.PROVIDER_UNAVAILABLE,
f"没有可用的 {media_kind.value} 生成适配器(检查服务商凭证是否已配置)",
)
@router.post("/tasks", response_model=SubmitResponse)
def submit_task(payload: SubmitRequest, request: Request) -> SubmitResponse:
"""提交生成任务,返回服务商任务 ID。
调用方**必须在上游产生任何计费动作之前**把该 ID 落库——火山不支持幂等键,
这是提交超时后唯一能判断「是否已提交」的依据。
"""
provider = _provider_for(_registry(request), payload.media_kind)
try:
provider_request_id = provider.submit(payload.to_domain())
except (ArkError, OpenAiError) as exc:
# 服务商调用失败:原样带上分类,调用方据 retry 决定重试。
raise ProviderError(exc.failure) from exc
except ValueError as exc:
# 本地预校验拦下的参数问题(超出该模型的时长/尺寸/ratio 限制、内容为空、
# 参考图不可下载)。属 invalid_request 而非 provider 错误——请求根本没发出去。
raise ServiceError(ServiceErrorCode.INVALID_REQUEST, str(exc)) from exc
except RuntimeError as exc:
# 同步型适配器的在途任务已达上限。这不是请求的问题而是容量问题,且稍后
# 重试会好,故归 provider_unavailable(503)而非 invalid_request——后者会
# 让调用方判为不可重试并直接失败掉一个本可成功的任务。
raise ServiceError(ServiceErrorCode.PROVIDER_UNAVAILABLE, str(exc)) from exc
log.info(
"已提交生成任务 job_id=%s provider_request_id=%s",
payload.job_id,
provider_request_id,
)
return SubmitResponse(provider_request_id=provider_request_id)
@router.get("/tasks/{provider_request_id}", response_model=TaskResponse)
def get_task(
provider_request_id: str,
request: Request,
media_kind: MediaKind = MediaKind.VIDEO,
) -> TaskResponse:
"""查询任务。
**任务失败也返回 200**:HTTP 调用成功了,失败的是任务,`state=failed` +
`failure` 表达它。做成非 2xx 会让「查不到任务」与「任务失败了」难以区分。
:param media_kind: 决定问哪个适配器。默认 video——当前只有视频适配器;
接入图片后调用方应显式传入。刻意不做「遍历所有适配器试一遍」:那会把
一个任务 ID 拿去问不相干的服务商。
"""
provider = _provider_for(_registry(request), media_kind)
try:
result = provider.fetch(provider_request_id)
except KeyError as exc:
# 同步型适配器的本地注册表里没有这个 ID:从未存在过,或进程重启后丢失
# (两者无法区分)。两种情形下调用方都该**停止轮询**而非退避重试。
raise ServiceError(
ServiceErrorCode.TASK_NOT_FOUND,
f"本服务无此任务(可能因进程重启而丢失):{provider_request_id}",
) from exc
except ArkError as exc:
# 服务商说任务不存在(含超过 7 天保留期)→ 这是 service 类的 task_not_found,
# 而不是 provider 错误:调用方要做的是停止轮询,不是退避重试。
if exc.http_status == 404:
raise ServiceError(
ServiceErrorCode.TASK_NOT_FOUND,
f"服务商无此任务(或已超出其 7 天保留期):{provider_request_id}",
) from exc
raise ProviderError(exc.failure) from exc
except OpenAiError as exc:
raise ProviderError(exc.failure) from exc
return TaskResponse.of(result)