feat: add image upstream deadline

This commit is contained in:
QiuSW
2026-07-08 20:14:33 +08:00
parent 2042c1a3e7
commit 12717d09e8
16 changed files with 202 additions and 32 deletions
+1
View File
@@ -34,6 +34,7 @@ MYSQL_WRITE_TIMEOUT=120
# AI key encryption
AI_KEY_ENCRYPTION_KEY=base64-fernet-key
AI_IMAGE_UPSTREAM_DEADLINE_SECONDS=180
# API safety
API_GENERATE_THROTTLE_RATE=60/min
+1 -1
View File
@@ -25,7 +25,7 @@ Python 3.12 / Django 5.2 LTS + DRF / django-admin / 用户端 Django 模板 SSR
## 当前状态
Phase 2 计费核心已完成,Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端已完成 T-501~T-505,Phase 5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档,Phase 6 已完成 T-601 可用别名发现、T-602/T-603 django-admin 中文化、T-604 中文敏感词本地过滤、T-605 免邮箱验证策略落地、T-606 公开首页 + 客户端下载入口、T-607 桌面端最新版本检查接口、T-608 新用户注册赠送 100 点试用点数、T-609 桌面端版本强制更新标记、T-610 首页导入模板下载入口与 T-611 用户端品牌名统一为“虾皮圈”。用户可通过公开首页进入注册、登录、下载客户端和下载导入模板;新用户注册后经计费层自动获得 100 点并写注册赠点流水;登录后可扫码充值并轮询到账,生成 / 删除(吊销)API Key,查看余额、充值总额、分页充值记录、分页点数记录与可用模型;桌面端可匿名请求最新客户端版本 JSON,并读取 `release.force_update` 判断是否必须升级;运营可在 django-admin 检索用户、钱包、API Key、计费规则、汇率、充值订单、点数流水、注册赠点记录、调用记录、客户端发布版本和导入模板,并通过计费层带原因手工调点。下一步已拆为 T-612~T-615,优先处理生图同步接口止血、共享生成 core、异步提交轮询和旧同步接口遥测;生产侧仍需补真实支付回调到账闭环。详见 [`docs/current-state.md`](docs/current-state.md)。
Phase 2 计费核心已完成,Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端已完成 T-501~T-505,Phase 5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档,Phase 6 已完成 T-601 可用别名发现、T-602/T-603 django-admin 中文化、T-604 中文敏感词本地过滤、T-605 免邮箱验证策略落地、T-606 公开首页 + 客户端下载入口、T-607 桌面端最新版本检查接口、T-608 新用户注册赠送 100 点试用点数、T-609 桌面端版本强制更新标记、T-610 首页导入模板下载入口、T-611 用户端品牌名统一为“虾皮圈”与 T-612 生图同步接口止血。用户可通过公开首页进入注册、登录、下载客户端和下载导入模板;新用户注册后经计费层自动获得 100 点并写注册赠点流水;登录后可扫码充值并轮询到账,生成 / 删除(吊销)API Key,查看余额、充值总额、分页充值记录、分页点数记录与可用模型;桌面端可匿名请求最新客户端版本 JSON,并读取 `release.force_update` 判断是否必须升级;运营可在 django-admin 检索用户、钱包、API Key、计费规则、汇率、充值订单、点数流水、注册赠点记录、调用记录、客户端发布版本和导入模板,并通过计费层带原因手工调点。下一步为 T-613 抽生成核心 service,之后继续 T-614 异步提交轮询和 T-615 旧同步接口遥测;生产侧仍需补真实支付回调到账闭环。详见 [`docs/current-state.md`](docs/current-state.md)。
> ⚠️ 涉及资金/点数。改动充值、扣费、退款、对账相关代码前,先读 [`docs/05-coding-rules.md`](docs/05-coding-rules.md) 第 8 节与 [`docs/04-architecture.md`](docs/04-architecture.md) 第四节计费时序。
+19 -8
View File
@@ -20,6 +20,7 @@ from .utils import (
extract_image_from_response,
extract_text_from_response,
extract_titles_from_response,
image_request_timeout,
image_bytes_to_data_url,
normalize_api_url,
request_timeout,
@@ -79,6 +80,16 @@ class BaseHttpProvider:
def _read_timeout(self, model: ResolvedModel, resolution: str) -> int:
return self._timeout(model, resolution)[1]
def _image_timeout(self, model: ResolvedModel, resolution: str) -> tuple[int, int]:
return image_request_timeout(
model.connect_timeout_seconds,
model.timeout_seconds,
resolution,
)
def _image_read_timeout(self, model: ResolvedModel, resolution: str) -> int:
return self._image_timeout(model, resolution)[1]
class ChatCompletionsProvider(BaseHttpProvider):
def capabilities(self) -> set[str]:
@@ -142,14 +153,14 @@ class ChatCompletionsProvider(BaseHttpProvider):
url,
headers=self._headers(model, json=True),
json=payload,
timeout=self._timeout(model, resolution),
timeout=self._image_timeout(model, resolution),
)
response.raise_for_status()
raw = response.json()
image_bytes = extract_image_from_response(
raw,
session=self.session,
timeout=self._read_timeout(model, resolution),
timeout=self._image_read_timeout(model, resolution),
)
if not image_bytes:
raise AiResponseParseError("AI response did not contain an image")
@@ -217,14 +228,14 @@ class GeminiProvider(ChatCompletionsProvider):
url,
headers=self._headers(model, json=True),
json=payload,
timeout=self._timeout(model, resolution),
timeout=self._image_timeout(model, resolution),
)
response.raise_for_status()
raw = response.json()
image_bytes = extract_image_from_response(
raw,
session=self.session,
timeout=self._read_timeout(model, resolution),
timeout=self._image_read_timeout(model, resolution),
)
if not image_bytes:
raise AiResponseParseError("AI response did not contain an image")
@@ -266,14 +277,14 @@ class ImagesGenerationProvider(BaseHttpProvider):
url,
headers=self._headers(model, json=True),
json=payload,
timeout=self._timeout(model, resolution),
timeout=self._image_timeout(model, resolution),
)
response.raise_for_status()
raw = response.json()
image_bytes = extract_image_from_response(
raw,
session=self.session,
timeout=self._read_timeout(model, resolution),
timeout=self._image_read_timeout(model, resolution),
)
if not image_bytes:
raise AiResponseParseError("AI response did not contain an image")
@@ -317,14 +328,14 @@ class ImagesEditsProvider(BaseHttpProvider):
headers=self._headers(model),
data=data,
files=files,
timeout=self._timeout(model, resolution),
timeout=self._image_timeout(model, resolution),
)
response.raise_for_status()
raw = response.json()
image_bytes = extract_image_from_response(
raw,
session=self.session,
timeout=self._read_timeout(model, resolution),
timeout=self._image_read_timeout(model, resolution),
)
if not image_bytes:
raise AiResponseParseError("AI response did not contain an image")
+31
View File
@@ -8,6 +8,8 @@ from typing import Any, Iterable
from urllib.parse import urljoin, urlparse
import requests
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from .base import AiProviderConfigError, AiResponseParseError
@@ -26,6 +28,7 @@ SUPPORTED_API_TYPES = {
}
RESOLUTION_TIMEOUTS = {"512": 180, "1K": 240, "2K": 360, "4K": 600}
DEFAULT_IMAGE_UPSTREAM_DEADLINE_SECONDS = 180
BASE64_KEYS = {"image_base64", "base64", "b64_json", "data"}
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
@@ -44,6 +47,34 @@ def request_timeout(connect_timeout: int, read_timeout: int, resolution: str) ->
return connect_timeout, resolved_read_timeout
def image_request_timeout(connect_timeout: int, read_timeout: int, resolution: str) -> tuple[int, int]:
resolved_connect_timeout, resolved_read_timeout = request_timeout(
connect_timeout,
read_timeout,
resolution,
)
return resolved_connect_timeout, cap_image_read_timeout(resolved_read_timeout)
def cap_image_read_timeout(read_timeout: int) -> int:
deadline = image_upstream_deadline_seconds()
if deadline <= 0:
return read_timeout
return min(read_timeout, deadline)
def image_upstream_deadline_seconds() -> int:
try:
value = getattr(
settings,
"AI_IMAGE_UPSTREAM_DEADLINE_SECONDS",
DEFAULT_IMAGE_UPSTREAM_DEADLINE_SECONDS,
)
except ImproperlyConfigured:
value = DEFAULT_IMAGE_UPSTREAM_DEADLINE_SECONDS
return int(value)
def detect_api_type(url: str, api_type: str = API_AUTO) -> str:
if api_type and api_type != API_AUTO:
if api_type not in SUPPORTED_API_TYPES:
+64 -1
View File
@@ -13,7 +13,7 @@ from apps.ai.importers import import_ai_models_config
from apps.ai.models import AiConfigAuditLog, AiModel, ModelAlias
from apps.ai.providers import AiCapabilityError, ResolvedModel, get_provider, resolve_api_type
from apps.ai.providers.openai_compatible import ChatCompletionsProvider, ImagesEditsProvider
from apps.ai.providers.utils import resolution_to_size
from apps.ai.providers.utils import image_request_timeout, resolution_to_size
TEST_ENCRYPTION_KEY = Fernet.generate_key().decode("ascii")
@@ -60,6 +60,11 @@ class ProviderUtilsTests(SimpleTestCase):
self.assertEqual(resolution_to_size("1k"), "1024x1024")
self.assertEqual(resolution_to_size("512px"), "512x512")
@override_settings(AI_IMAGE_UPSTREAM_DEADLINE_SECONDS=180)
def test_image_request_timeout_caps_read_timeout_to_deadline(self):
self.assertEqual(image_request_timeout(30, 0, "4K"), (30, 180))
self.assertEqual(image_request_timeout(30, 120, "4K"), (30, 120))
class ChatCompletionsProviderTests(SimpleTestCase):
def test_generate_text_builds_chat_payload_and_cleans_titles(self):
@@ -192,6 +197,64 @@ class ChatCompletionsProviderTests(SimpleTestCase):
self.assertEqual(content[0], {"type": "text", "text": "Generate product image"})
self.assertTrue(content[1]["image_url"]["url"].startswith("data:image/jpeg;base64,"))
@override_settings(AI_IMAGE_UPSTREAM_DEADLINE_SECONDS=180)
def test_generate_image_caps_post_and_download_timeouts(self):
session = FakeSession(
FakeResponse(
{
"choices": [
{
"message": {
"content": [
{
"type": "image_url",
"image_url": {"url": "https://cdn.example.com/out.png"},
}
]
}
}
]
}
),
FakeResponse({}, content=b"generated-image"),
)
provider = ChatCompletionsProvider(session=session)
model = ResolvedModel(
name="Slow image",
url="https://api.vectorengine.ai/v1/chat/completions",
model="image-model",
api_key="test-key",
api_type="chat",
timeout_seconds=0,
connect_timeout_seconds=30,
)
result = provider.generate_image("Generate product image", model, resolution="4K")
self.assertEqual(result.image, b"generated-image")
self.assertEqual(session.posts[0]["timeout"], (30, 180))
self.assertEqual(session.gets[0]["timeout"], 180)
@override_settings(AI_IMAGE_UPSTREAM_DEADLINE_SECONDS=180)
def test_generate_text_does_not_use_image_deadline(self):
session = FakeSession(
FakeResponse({"choices": [{"message": {"content": "1. Red Dress"}}]})
)
provider = ChatCompletionsProvider(session=session)
model = ResolvedModel(
name="Slow text",
url="https://api.vectorengine.ai/v1/chat/completions",
model="text-model",
api_key="test-key",
api_type="chat",
timeout_seconds=0,
connect_timeout_seconds=30,
)
provider.generate_text("Generate titles", model, resolution="4K")
self.assertEqual(session.posts[0]["timeout"], (30, 600))
class ImagesEditsProviderTests(SimpleTestCase):
def test_generate_image_builds_multipart_request_and_parses_base64(self):
+2
View File
@@ -290,6 +290,8 @@ def precharge_or_raise(**kwargs):
def upstream_error(exc: Exception) -> ApiRequestError:
if isinstance(exc, ApiRequestError):
return exc
if isinstance(exc, requests.Timeout):
return ApiRequestError("upstream_timeout", "上游 AI 调用超时,已退回点数", status.HTTP_502_BAD_GATEWAY)
if isinstance(exc, AiProviderError | requests.RequestException | OSError):
return ApiRequestError("upstream_error", "上游 AI 调用失败,已退回点数", status.HTTP_502_BAD_GATEWAY)
return ApiRequestError("upstream_error", "生成失败,已退回点数", status.HTTP_502_BAD_GATEWAY)
+31
View File
@@ -1514,6 +1514,37 @@ class GenerateApiTests(TestCase):
1,
)
def test_image_upstream_timeout_refunds_precharged_points_and_marks_call_failed(self):
self.provider.image_error = requests.Timeout("image upstream deadline exceeded")
response = self.post_with_provider(
"/api/v1/generate/image",
{"prompt": "生成图片", "model": self.image_alias, "resolution": "1K"},
)
self.assertEqual(response.status_code, 502)
self.assertEqual(response.data["error"]["code"], "upstream_timeout")
self.wallet.refresh_from_db()
self.assertEqual(self.wallet.points_balance, 100)
call = CallRecord.objects.get(user=self.user)
self.assertEqual(call.status, CallRecord.Status.FAILED)
self.assertIn("image upstream deadline exceeded", call.error_message)
self.assertEqual(
PointsLedger.objects.filter(
ref_call=call,
change_type=PointsLedger.ChangeType.CONSUME,
).count(),
1,
)
self.assertEqual(
PointsLedger.objects.filter(
ref_call=call,
change_type=PointsLedger.ChangeType.REFUND,
).count(),
1,
)
def test_provider_capability_error_returns_400_and_refunds_points(self):
self.provider.image_error = AiCapabilityError("input image is required")
+1
View File
@@ -112,6 +112,7 @@ SECURE_PROXY_SSL_HEADER = (
API_GENERATE_THROTTLE_RATE = os.environ.get("API_GENERATE_THROTTLE_RATE", "60/min")
API_AUTH_FAILURE_THROTTLE_RATE = os.environ.get("API_AUTH_FAILURE_THROTTLE_RATE", "30/min")
AI_IMAGE_UPSTREAM_DEADLINE_SECONDS = env_int("AI_IMAGE_UPSTREAM_DEADLINE_SECONDS", 180)
IMAGE_URL_MAX_BYTES = env_int("IMAGE_URL_MAX_BYTES", 10 * 1024 * 1024)
IMAGE_URL_MAX_REDIRECTS = env_int("IMAGE_URL_MAX_REDIRECTS", 3)
IMAGE_URL_CONNECT_TIMEOUT_SECONDS = env_int("IMAGE_URL_CONNECT_TIMEOUT_SECONDS", 10)
+2 -2
View File
@@ -38,7 +38,7 @@
## 当前阶段
当前项目处于:**Phase 6 增强任务推进期**。Phase 2 计费核心已完成到 T-204;Phase 3 已完成 T-301 API Key 鉴权、T-302 生成标题 / 图片接口、T-303 余额查询接口、T-304 充值回调、T-305 扫码充值下单 + 轮询与 T-306 对外 API 安全加固;Phase 4 已完成 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化;Phase 5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档;Phase 6 已完成 T-601「可用别名发现」、T-602「django-admin 中文化第 1-3 层」、T-603「django-admin 字段级中文化」、T-604「中文敏感词本地过滤」、T-605「免邮箱验证策略落地」、T-606「公开首页 + 客户端下载入口」、T-607「桌面端最新版本检查接口」、T-608「新用户注册赠送 100 点试用点数」、T-609「桌面端版本检查接口增加强制更新标记」、T-610「首页导入模板下载入口」与 T-611「用户端品牌名统一为虾皮圈」。生图慢 / 504 / 客户端超时已拆为 T-612~T-615:先做同步接口止血和上游硬截止,再抽共享生成 core,随后新增异步提交轮询接口并观察旧同步接口用量。生产侧仍需补真实支付回调到账闭环;邮件服务仅用于后续密码找回/通知等邮件能力,不阻塞注册登录。
当前项目处于:**Phase 6 增强任务推进期**。Phase 2 计费核心已完成到 T-204;Phase 3 已完成 T-301 API Key 鉴权、T-302 生成标题 / 图片接口、T-303 余额查询接口、T-304 充值回调、T-305 扫码充值下单 + 轮询与 T-306 对外 API 安全加固;Phase 4 已完成 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化;Phase 5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档;Phase 6 已完成 T-601「可用别名发现」、T-602「django-admin 中文化第 1-3 层」、T-603「django-admin 字段级中文化」、T-604「中文敏感词本地过滤」、T-605「免邮箱验证策略落地」、T-606「公开首页 + 客户端下载入口」、T-607「桌面端最新版本检查接口」、T-608「新用户注册赠送 100 点试用点数」、T-609「桌面端版本检查接口增加强制更新标记」、T-610「首页导入模板下载入口」、T-611「用户端品牌名统一为虾皮圈」与 T-612「生图同步接口止血」。生图慢 / 504 / 客户端超时已拆为 T-612~T-615:T-612 已先做同步接口上游硬截止止血;下一步 T-613 抽共享生成 core,随后 T-614 新增异步提交轮询接口,T-615 观察旧同步接口用量。生产侧仍需补真实支付回调到账闭环;邮件服务仅用于后续密码找回/通知等邮件能力,不阻塞注册登录。
优先路径:
@@ -48,7 +48,7 @@
4. Phase 3:对外 API 与充值 —— T-301 Key 鉴权、T-302 生成接口、T-303 余额查询、T-304 充值回调、T-305 扫码下单与轮询、T-306 安全加固已完成。
5. Phase 4:用户端(Django 模板 SSR)—— T-501 注册登录、T-502 API Key 管理、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化已完成。
6. Phase 5:后台与发布 —— T-401 运营后台完善、T-402 完整验收 MVP、T-403 部署 / 运行文档已完成;计划内 MVP 任务已收尾。
7. Phase 6:增强(MVP 后)—— T-601 可用别名发现已完成,实现 `/api/v1/models` 与 portal 只读「可用模型」页;T-602 已完成 django-admin 分组/表名中文化;T-603 已完成字段级中文标签代码与 no-op 迁移并人工确认 admin 字段中文化;T-604 已完成中文敏感词本地过滤;T-605 已完成免邮箱验证策略落地;T-606 已完成公开首页 + 客户端下载入口;T-607 已完成桌面端最新版本检查接口;T-608 已完成新用户注册赠送 100 点试用点数;T-609 已完成桌面端版本检查接口增加强制更新标记;T-610 已完成首页导入模板下载入口;T-611 已完成用户端品牌名统一为虾皮圈;T-612~T-615 已立项为生图链路治理任务,当前下一个可领取任务为 T-612。
7. Phase 6:增强(MVP 后)—— T-601 可用别名发现已完成,实现 `/api/v1/models` 与 portal 只读「可用模型」页;T-602 已完成 django-admin 分组/表名中文化;T-603 已完成字段级中文标签代码与 no-op 迁移并人工确认 admin 字段中文化;T-604 已完成中文敏感词本地过滤;T-605 已完成免邮箱验证策略落地;T-606 已完成公开首页 + 客户端下载入口;T-607 已完成桌面端最新版本检查接口;T-608 已完成新用户注册赠送 100 点试用点数;T-609 已完成桌面端版本检查接口增加强制更新标记;T-610 已完成首页导入模板下载入口;T-611 已完成用户端品牌名统一为虾皮圈;T-612 已完成生图同步接口止血;当前下一个可领取任务为 T-613。
## 领取任务规则
+2 -2
View File
@@ -408,7 +408,7 @@ CREATE TABLE call_record (
| 并发扣点超扣 | 多请求同时扣同一账号 | 行锁 / 原子更新 + DB 约束 `>=0`(MySQL 的 CHECK 需 ≥8.0.16 才生效,不能只靠它;主防线是 `select_for_update` 或 `UPDATE ... WHERE balance>=N`),并发测试覆盖 |
| 充值重复入账 | 回调可能重发 | `order_no` 唯一 + 状态机幂等 |
| 回调伪造 | 不验签会被刷点 | 强制验签,验签失败不入账并留痕 |
| 图片生成耗时长 | 同步等待几十秒到分钟级;后台可换上慢模型拖垮 worker | 每模型 timeout 必须生效并设上限;worker 数/网关超时按最慢模型预留;V2 异步化 |
| 图片生成耗时长 | 同步等待几十秒到分钟级;后台可换上慢模型拖垮 worker | T-612 后生图 Provider 读取超时取 `min(AiModel.timeout_seconds 或分辨率默认值, AI_IMAGE_UPSTREAM_DEADLINE_SECONDS)`,撞硬截止走失败退点;worker 数/网关超时按内层硬截止预留;V2 异步化 |
| 上游错误分类 | 区分参数错误与上游故障 | AI 层抛分类异常;上游故障退点 |
| 凭证安全 | 上游 api_key、支付密钥 | 应用层加密存储,admin 脱敏不回显;其余密钥走环境变量,不入代码与样例 |
| 抽象泄漏 / 参数越权 | 各供应商入参出参不一致;若调用方 `parameters` 可覆盖 `model`/`n`/`size` 会击穿别名计费 | Provider 适配器 + capabilities 声明 + `parameters` 白名单过滤;核心/计费字段服务端固定,不取交集、不允许覆盖 |
@@ -427,7 +427,7 @@ CREATE TABLE call_record (
桌面端 cmbot 可以**不改交互骨架**,继续用同步方式调 cmhub、cmhub 再同步转中转站,正常使用——决定成败的不是同步/异步本身,而是下面两件事有没有配对好;配好就稳,配不好会「小量正常、上量假死」:
1. **超时链路层层放大且对齐**(外层 ≥ 内层):桌面端读超时 ≥ Gunicorn `--timeout` / 网关 `proxy_read_timeout` ≥ cmhub→中转站读超时。三个默认值是雷:`AiModel.timeout_seconds`(`0` 表示按 Provider 分辨率默认值,而不是无限等待)、Gunicorn 默认 `30s`(会直接杀掉跑图片的 worker)、Nginx 默认 `60s`。图片链路建议统一放到 `300s` 量级,并在 T-302/T-403 前用一次真实图片生成耗时校准。
1. **超时链路层层放大且对齐**(外层 ≥ 内层):桌面端读超时 ≥ Nginx `proxy_read_timeout` ≥ Gunicorn `--timeout` ≥ cmhub→中转站实际读取超时。T-612 后生图读取超时为 `min(AiModel.timeout_seconds 或分辨率默认值, AI_IMAGE_UPSTREAM_DEADLINE_SECONDS)`;默认硬截止约 180s,生产按真实图片 smoke 校准。三个默认值仍是雷:`AiModel.timeout_seconds`(`0` 表示按 Provider 分辨率默认值,而不是无限等待)、Gunicorn 默认 `30s`(会直接杀掉跑图片的 worker)、Nginx 默认 `60s`。
2. **worker/线程数按峰值总并发预留**:同步下每个在飞请求占住一个 worker 整个生成周期(几十秒~分钟)。用 Gunicorn `gthread`(`--threads`)或多 sync worker,数量 ≥ 预期峰值总并发 + 余量,避免慢图片把快的生文接口和后台挤死。
适用边界:**单接入方、小并发批量**(桌面端 `concurrency` 1~4)完全够用,点数一致性也更简单。当出现「worker 被长连接占满拖慢快接口/后台」「接入方总并发明显上涨」「需要关窗重连/任务持久化」任一信号时,才转 V2 异步(队列),在此之前保持同步;但适配器接口与 `call_record` 的 `pending/success/failed` 三态需为异步预留口子(见 4.1、七、八)。
+1 -1
View File
File diff suppressed because one or more lines are too long
+4 -3
View File
@@ -71,6 +71,7 @@ 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 |
| `upstream_timeout` | 上游 AI 调用超时(已退点) | 502 |
| `upstream_error` | 上游 AI 失败(已退点) | 502 |
| `signature_invalid` | 支付回调验签失败 | 400 |
| `amount_mismatch` | 支付回调金额与本地订单金额不一致 | 400 |
@@ -79,7 +80,7 @@ T-607/T-609 已实现 `GET /api/v1/client/releases/latest?platform=windows`,
| `order_not_found` | 充值订单不存在或不属于当前用户 | 404 |
| `rate_limited` | 请求过于频繁,请稍后再试 | 429 |
`upstream_error` 需要按错误消息继续区分:如果 `/api/v1/balance` 成功、`/api/v1/models` 中目标别名存在且 `pricing_status="priced"`,但生成接口返回 `502 upstream_error` 且消息为「上游模型配置不可用」,优先判定为**服务端上游模型运行配置问题**,不是客户端 payload 问题。常见原因是 `AI_KEY_ENCRYPTION_KEY` 与入库时不一致、`AiModel.api_key_encrypted` 无法解密、别名指向的 `AiModel` 缺 `url` / `model` / `api_type` / API Key,或 `api_type` 无可用 Provider。该错误路径不应最终扣点;修复按 [`deployment.md`](deployment.md) 的 AI 模型配置排查步骤执行。
`upstream_timeout` 与 `upstream_error` 都表示本次生成失败且已退点,客户端可按失败 / 重试处理。`upstream_timeout` 通常来自 T-612 的生图上游硬截止或底层 HTTP 超时。`upstream_error` 需要按错误消息继续区分:如果 `/api/v1/balance` 成功、`/api/v1/models` 中目标别名存在且 `pricing_status="priced"`,但生成接口返回 `502 upstream_error` 且消息为「上游模型配置不可用」,优先判定为**服务端上游模型运行配置问题**,不是客户端 payload 问题。常见原因是 `AI_KEY_ENCRYPTION_KEY` 与入库时不一致、`AiModel.api_key_encrypted` 无法解密、别名指向的 `AiModel` 缺 `url` / `model` / `api_type` / API Key,或 `api_type` 无可用 Provider。该错误路径不应最终扣点;修复按 [`deployment.md`](deployment.md) 的 AI 模型配置排查步骤执行。
## 对外接口
@@ -190,7 +191,7 @@ GET /api/v1/client/releases/latest?platform=windows
}
```
要点:改图类模型缺原图返回 `bad_request`,不落 500;上游失败返回 `upstream_error` 且**不扣点**(已预扣则退回)。T-604 后 prompt 命中本地敏感词时返回 `content_blocked`,并且不得下载 `image_url` 或解码大图后再拦截。结果默认存对象存储返回 `image_url`,避免同步响应体过大。请求里的 `image_url` 适用同标题接口相同的 SSRF 防护和大小上限;内网图片请用 `image_base64`。
要点:改图类模型缺原图返回 `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`。
### `GET /api/v1/balance`
@@ -411,7 +412,7 @@ class Provider(Protocol):
- `parameters` 为供应商特有参数的安全子集;适配器负责白名单过滤并把统一入参翻译成各家上游格式,核心字段不可被 `parameters` 或 `extra_body` 覆盖。
- 配置热生效:每次调用读当前 AiModel/ModelAlias,后台改动及时反映(或带缓存失效)。
- 配置审计:后台保存/删除 AiModel、ModelAlias 时写 `AiConfigAuditLog`;记录 actor/action/target/changed_fields/changes/created_at,admin 只读查看;密钥变更只记录 empty/set 状态。
- 区分异常:别名/能力不匹配 → 业务错误(400 类,如 `model_not_allowed`);上游网络/超时/服务错误 → `upstream_error`(502,触发退点)。
- 区分异常:别名/能力不匹配 → 业务错误(400 类,如 `model_not_allowed`);上游超时 → `upstream_timeout`(502,触发退点);其他上游网络/服务错误 → `upstream_error`(502,触发退点)。
- 不在本模块写点数逻辑,只负责解析、调上游与解析返回。
## 待实现时确认
+10 -9
View File
File diff suppressed because one or more lines are too long
+4 -3
View File
@@ -117,6 +117,7 @@ DJANGO_CACHE_BACKEND=django.core.cache.backends.db.DatabaseCache
DJANGO_CACHE_LOCATION=cmhub_cache
AI_KEY_ENCRYPTION_KEY=base64-fernet-key
AI_IMAGE_UPSTREAM_DEADLINE_SECONDS=180
PAYMENT_CALLBACK_MODE=sdk
WECHAT_PAY_NOTIFY_URL=https://cmhub.example.com/api/v1/recharge/callback/wechat
@@ -159,7 +160,7 @@ python3.12 manage.py import_ai_models /secure/cmhub/ai_models.json --create-defa
要求:
- `/secure/cmhub/ai_models.json` 不提交到 git。
- `AiModel.timeout_seconds` 不能继续依赖默认 `0` 口径上线;图片模型必须按真实耗时设置读取超时。
- `AiModel.timeout_seconds` 不能继续依赖默认 `0` 口径上线;图片模型必须按真实耗时设置读取超时,并受 `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS` 硬截止保护。
- 运营后台需配置 `PricingRule` 和 `ExchangeRate`,否则生成和充值会因缺规则被拒绝。
### AI 上游配置故障处理
@@ -257,7 +258,7 @@ gunicorn config.wsgi:application \
--error-logfile -
```
`--timeout` 必须按真实图片生成耗时校准:`Gunicorn timeout` 应大于 `AiModel.timeout_seconds`,Nginx `proxy_read_timeout` 应大于 Gunicorn timeout,调用方 read timeout 应不小于 Nginx。
`--timeout` 必须按真实图片生成耗时校准:生图 Provider 实际读取超时为 `min(AiModel.timeout_seconds 或分辨率默认值, AI_IMAGE_UPSTREAM_DEADLINE_SECONDS)`;`Gunicorn timeout` 应大于这个内层上游硬截止,Nginx `proxy_read_timeout` 应大于 Gunicorn timeout,调用方 read timeout 应不小于 Nginx。
systemd 单元可分别命名为 `cmhub-web.service` 和 `cmhub-generate.service`。服务的 `WorkingDirectory` 指向 `/www/wwwroot/cmhub`,`ExecStart` 使用上面的两条 Gunicorn 命令,环境变量由项目根目录 `.env` 在 Django settings 中读取。
@@ -345,7 +346,7 @@ python3.12 manage.py smoke_ai_generation image
上线前必须记录真实 `image` smoke 的 `elapsed_ms`,并据此回填:
- 图片 `AiModel.timeout_seconds`
- 图片 `AiModel.timeout_seconds` 与 `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS`
- Gunicorn 长请求池 `--timeout`
- Nginx `/api/v1/generate/` 的 `proxy_read_timeout`
- 接入方客户端 read timeout
+3 -2
View File
@@ -51,12 +51,13 @@
| 变量 | 必填 | 示例 | 说明 |
| --- | --- | --- | --- |
| `AI_KEY_ENCRYPTION_KEY` | 是 | `base64-fernet-key` | Fernet 主密钥,用于加密 `AiModel.api_key_encrypted`;生产不可更换,除非完成密钥轮换 |
| `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS` | 否 | `180` | T-612 生图上游读取硬截止秒数;只作用于 `generate_image` 的上游请求和上游返回图片 URL 下载,Provider 实际读取超时取 `min(AiModel.timeout_seconds 或分辨率默认值, 本值)`;生产按真实图片 smoke 耗时校准,外层 Gunicorn / Nginx / 客户端超时必须大于该值 |
`AI_KEY_ENCRYPTION_KEY` 必须是 `cryptography.fernet.Fernet.generate_key()` 生成的 base64 字符串,可用 `py -3.12 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` 生成。
`AiModel.url`、`AiModel.model`、`AiModel.api_type`、`AiModel.capabilities` 与加密后的 `api_key` 由后台或数据迁移维护,不通过环境变量硬编码具体模型。
AI 上游连接超时与读取超时不走全局环境变量:连接超时由 `AiModel.connect_timeout_seconds` 控制,读取超时由 `AiModel.timeout_seconds` 控制;当读取超时为 `0` 时,Provider 按分辨率使用内置默认值。
AI 上游连接超时由 `AiModel.connect_timeout_seconds` 控制。文本读取超时由 `AiModel.timeout_seconds` 控制;当读取超时为 `0` 时,Provider 按分辨率使用内置默认值。生图读取超时在此基础上再套 `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS` 硬截止,避免慢 / 卡死图片上游长期占用生成池线程。
## 五、对外 API 安全配置
@@ -154,4 +155,4 @@ T-604 只做 prompt 本地敏感词快筛。命中时返回 `content_blocked`,
- MySQL 为 8.4 LTS / InnoDB / `utf8mb4`,不是 SQLite 或已有 MySQL 5.7。
- `image_url` 下载上限、生成限流、认证失败限流、单笔充值金额上限已按生产容量调整。
- 若启用 `MODERATION_ENABLED`,`MODERATION_PROVIDER=keyword`,敏感词词库已配置,且共享 cache 可用于多 worker matcher 版本失效。
- 图片同步链路的客户端、Nginx、Gunicorn、上游 read timeout 均按最慢图片模型放大到同一量级。
- 图片同步链路的客户端、Nginx、Gunicorn 超时均大于 `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS`;上游 read timeout 由 `AiModel.timeout_seconds` / 分辨率默认值和该硬截止共同决定。
+26
View File
@@ -1648,3 +1648,29 @@
- 验收补充迟到 worker 场景:reaper 已判失败并退点后,迟到 worker 返回成功也不能改回 `succeeded`、不能覆盖结果、不能再次改账。
- 验证:仅文档更新;提交前用 `git diff --check`、`Select-String` 复核。
- 下一步:提交本轮文档;后续领取 T-612。
## 2026-07-08 实施:T-612 生图同步接口止血
- 状态:DONE。
- 代码变更:
- `config/settings.py` / `.env.example`:新增 `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS`,默认 180 秒。
- `apps/ai/providers/utils.py`:新增生图上游读取硬截止计算,实际读取超时为 `min(AiModel.timeout_seconds 或分辨率默认值, AI_IMAGE_UPSTREAM_DEADLINE_SECONDS)`。
- `apps/ai/providers/openai_compatible.py`:所有 `generate_image` Provider 的上游请求和上游返回图片 URL 下载改用生图硬截止;`generate_text` 继续使用原 `AiModel.timeout_seconds` / 分辨率默认超时,不受 T-612 截断。
- `apps/api/generation.py`:`requests.Timeout` 统一映射为 `upstream_timeout`,HTTP 502,并复用既有失败退点路径。
- `apps/ai/tests.py` / `apps/api/tests.py`:新增生图超时截断、文本不受影响、上游超时后退回预扣点数并标记调用失败的目标测试。
- 文档变更:
- `docs/06-tasks.md`:T-612 标记为 DONE,下一个任务更新为 T-613。
- `docs/env.md`、`docs/deployment.md`、`docs/04-architecture.md`、`docs/api.md`:同步环境变量、部署超时链、API 错误码和退款口径。
- `README.md`、`docs/00-ai-start-here.md`、`docs/current-state.md`:同步当前阶段、最新验证和下一步。
- 验证:
- `py -3.12 -m py_compile config\settings.py apps\ai\providers\utils.py apps\ai\providers\openai_compatible.py apps\api\generation.py apps\ai\tests.py apps\api\tests.py`:通过。
- `py -3.12 manage.py check`:通过,0 issues。
- `py -3.12 manage.py makemigrations --check --dry-run`:通过,No changes detected。
- `py -3.12 manage.py test apps.ai.tests.ProviderUtilsTests.test_image_request_timeout_caps_read_timeout_to_deadline apps.ai.tests.ChatCompletionsProviderTests.test_generate_image_caps_post_and_download_timeouts apps.ai.tests.ChatCompletionsProviderTests.test_generate_text_does_not_use_image_deadline --keepdb --noinput --verbosity 2`:首次发现 text/image 超时方法调用接反,修正后重跑通过,3 tests OK。
- `py -3.12 manage.py test apps.api.tests.GenerateApiTests.test_image_upstream_timeout_refunds_precharged_points_and_marks_call_failed --keepdb --noinput --verbosity 2`:通过。
- `py -3.12 manage.py test apps.api.tests.GenerateApiTests --keepdb --noinput --verbosity 2`:通过,17 tests OK。
- `.\init.ps1`:收尾验证通过(Python 3.12.3,依赖已满足,`manage.py check` 0 issues,打印启动命令)。
- `git diff --check`:通过,仅 Windows CRLF 提示。
- 已知测试环境现象:测试期仍保留 allauth `account.EmailAddress` 条件唯一约束在 MySQL 上不可创建的既有 `models.W036` 警告。
- 决策:T-612 只给旧同步生图接口加硬截止和明确错误码,不引入 gevent,不改变文本生成超时口径;真正解决长耗时与客户端轮询体验仍放到 T-613~T-615。
- 下一步:领取 T-613,抽生成核心 service,为 T-614 生图异步任务化复用同一套审核、计费、上游和退点逻辑。