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
AI_KEY_ENCRYPTION_KEY=base64-fernet-key AI_KEY_ENCRYPTION_KEY=base64-fernet-key
AI_IMAGE_UPSTREAM_DEADLINE_SECONDS=180
# API safety # API safety
API_GENERATE_THROTTLE_RATE=60/min 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) 第四节计费时序。 > ⚠️ 涉及资金/点数。改动充值、扣费、退款、对账相关代码前,先读 [`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_image_from_response,
extract_text_from_response, extract_text_from_response,
extract_titles_from_response, extract_titles_from_response,
image_request_timeout,
image_bytes_to_data_url, image_bytes_to_data_url,
normalize_api_url, normalize_api_url,
request_timeout, request_timeout,
@@ -79,6 +80,16 @@ class BaseHttpProvider:
def _read_timeout(self, model: ResolvedModel, resolution: str) -> int: def _read_timeout(self, model: ResolvedModel, resolution: str) -> int:
return self._timeout(model, resolution)[1] 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): class ChatCompletionsProvider(BaseHttpProvider):
def capabilities(self) -> set[str]: def capabilities(self) -> set[str]:
@@ -142,14 +153,14 @@ class ChatCompletionsProvider(BaseHttpProvider):
url, url,
headers=self._headers(model, json=True), headers=self._headers(model, json=True),
json=payload, json=payload,
timeout=self._timeout(model, resolution), timeout=self._image_timeout(model, resolution),
) )
response.raise_for_status() response.raise_for_status()
raw = response.json() raw = response.json()
image_bytes = extract_image_from_response( image_bytes = extract_image_from_response(
raw, raw,
session=self.session, session=self.session,
timeout=self._read_timeout(model, resolution), timeout=self._image_read_timeout(model, resolution),
) )
if not image_bytes: if not image_bytes:
raise AiResponseParseError("AI response did not contain an image") raise AiResponseParseError("AI response did not contain an image")
@@ -217,14 +228,14 @@ class GeminiProvider(ChatCompletionsProvider):
url, url,
headers=self._headers(model, json=True), headers=self._headers(model, json=True),
json=payload, json=payload,
timeout=self._timeout(model, resolution), timeout=self._image_timeout(model, resolution),
) )
response.raise_for_status() response.raise_for_status()
raw = response.json() raw = response.json()
image_bytes = extract_image_from_response( image_bytes = extract_image_from_response(
raw, raw,
session=self.session, session=self.session,
timeout=self._read_timeout(model, resolution), timeout=self._image_read_timeout(model, resolution),
) )
if not image_bytes: if not image_bytes:
raise AiResponseParseError("AI response did not contain an image") raise AiResponseParseError("AI response did not contain an image")
@@ -266,14 +277,14 @@ class ImagesGenerationProvider(BaseHttpProvider):
url, url,
headers=self._headers(model, json=True), headers=self._headers(model, json=True),
json=payload, json=payload,
timeout=self._timeout(model, resolution), timeout=self._image_timeout(model, resolution),
) )
response.raise_for_status() response.raise_for_status()
raw = response.json() raw = response.json()
image_bytes = extract_image_from_response( image_bytes = extract_image_from_response(
raw, raw,
session=self.session, session=self.session,
timeout=self._read_timeout(model, resolution), timeout=self._image_read_timeout(model, resolution),
) )
if not image_bytes: if not image_bytes:
raise AiResponseParseError("AI response did not contain an image") raise AiResponseParseError("AI response did not contain an image")
@@ -317,14 +328,14 @@ class ImagesEditsProvider(BaseHttpProvider):
headers=self._headers(model), headers=self._headers(model),
data=data, data=data,
files=files, files=files,
timeout=self._timeout(model, resolution), timeout=self._image_timeout(model, resolution),
) )
response.raise_for_status() response.raise_for_status()
raw = response.json() raw = response.json()
image_bytes = extract_image_from_response( image_bytes = extract_image_from_response(
raw, raw,
session=self.session, session=self.session,
timeout=self._read_timeout(model, resolution), timeout=self._image_read_timeout(model, resolution),
) )
if not image_bytes: if not image_bytes:
raise AiResponseParseError("AI response did not contain an image") 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 from urllib.parse import urljoin, urlparse
import requests import requests
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from .base import AiProviderConfigError, AiResponseParseError from .base import AiProviderConfigError, AiResponseParseError
@@ -26,6 +28,7 @@ SUPPORTED_API_TYPES = {
} }
RESOLUTION_TIMEOUTS = {"512": 180, "1K": 240, "2K": 360, "4K": 600} RESOLUTION_TIMEOUTS = {"512": 180, "1K": 240, "2K": 360, "4K": 600}
DEFAULT_IMAGE_UPSTREAM_DEADLINE_SECONDS = 180
BASE64_KEYS = {"image_base64", "base64", "b64_json", "data"} BASE64_KEYS = {"image_base64", "base64", "b64_json", "data"}
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"} 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 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: def detect_api_type(url: str, api_type: str = API_AUTO) -> str:
if api_type and api_type != API_AUTO: if api_type and api_type != API_AUTO:
if api_type not in SUPPORTED_API_TYPES: 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.models import AiConfigAuditLog, AiModel, ModelAlias
from apps.ai.providers import AiCapabilityError, ResolvedModel, get_provider, resolve_api_type 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.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") 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("1k"), "1024x1024")
self.assertEqual(resolution_to_size("512px"), "512x512") 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): class ChatCompletionsProviderTests(SimpleTestCase):
def test_generate_text_builds_chat_payload_and_cleans_titles(self): 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.assertEqual(content[0], {"type": "text", "text": "Generate product image"})
self.assertTrue(content[1]["image_url"]["url"].startswith("data:image/jpeg;base64,")) 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): class ImagesEditsProviderTests(SimpleTestCase):
def test_generate_image_builds_multipart_request_and_parses_base64(self): 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: def upstream_error(exc: Exception) -> ApiRequestError:
if isinstance(exc, ApiRequestError): if isinstance(exc, ApiRequestError):
return exc return exc
if isinstance(exc, requests.Timeout):
return ApiRequestError("upstream_timeout", "上游 AI 调用超时,已退回点数", status.HTTP_502_BAD_GATEWAY)
if isinstance(exc, AiProviderError | requests.RequestException | OSError): if isinstance(exc, AiProviderError | requests.RequestException | OSError):
return ApiRequestError("upstream_error", "上游 AI 调用失败,已退回点数", status.HTTP_502_BAD_GATEWAY) return ApiRequestError("upstream_error", "上游 AI 调用失败,已退回点数", status.HTTP_502_BAD_GATEWAY)
return ApiRequestError("upstream_error", "生成失败,已退回点数", 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, 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): def test_provider_capability_error_returns_400_and_refunds_points(self):
self.provider.image_error = AiCapabilityError("input image is required") 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_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") 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_BYTES = env_int("IMAGE_URL_MAX_BYTES", 10 * 1024 * 1024)
IMAGE_URL_MAX_REDIRECTS = env_int("IMAGE_URL_MAX_REDIRECTS", 3) IMAGE_URL_MAX_REDIRECTS = env_int("IMAGE_URL_MAX_REDIRECTS", 3)
IMAGE_URL_CONNECT_TIMEOUT_SECONDS = env_int("IMAGE_URL_CONNECT_TIMEOUT_SECONDS", 10) 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 安全加固已完成。 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 用户端审核优化已完成。 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 任务已收尾。 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`),并发测试覆盖 | | 并发扣点超扣 | 多请求同时扣同一账号 | 行锁 / 原子更新 + DB 约束 `>=0`(MySQL 的 CHECK 需 ≥8.0.16 才生效,不能只靠它;主防线是 `select_for_update` 或 `UPDATE ... WHERE balance>=N`),并发测试覆盖 |
| 充值重复入账 | 回调可能重发 | `order_no` 唯一 + 状态机幂等 | | 充值重复入账 | 回调可能重发 | `order_no` 唯一 + 状态机幂等 |
| 回调伪造 | 不验签会被刷点 | 强制验签,验签失败不入账并留痕 | | 回调伪造 | 不验签会被刷点 | 强制验签,验签失败不入账并留痕 |
| 图片生成耗时长 | 同步等待几十秒到分钟级;后台可换上慢模型拖垮 worker | 每模型 timeout 必须生效并设上限;worker 数/网关超时按最慢模型预留;V2 异步化 | | 图片生成耗时长 | 同步等待几十秒到分钟级;后台可换上慢模型拖垮 worker | T-612 后生图 Provider 读取超时取 `min(AiModel.timeout_seconds 或分辨率默认值, AI_IMAGE_UPSTREAM_DEADLINE_SECONDS)`,撞硬截止走失败退点;worker 数/网关超时按内层硬截止预留;V2 异步化 |
| 上游错误分类 | 区分参数错误与上游故障 | AI 层抛分类异常;上游故障退点 | | 上游错误分类 | 区分参数错误与上游故障 | AI 层抛分类异常;上游故障退点 |
| 凭证安全 | 上游 api_key、支付密钥 | 应用层加密存储,admin 脱敏不回显;其余密钥走环境变量,不入代码与样例 | | 凭证安全 | 上游 api_key、支付密钥 | 应用层加密存储,admin 脱敏不回显;其余密钥走环境变量,不入代码与样例 |
| 抽象泄漏 / 参数越权 | 各供应商入参出参不一致;若调用方 `parameters` 可覆盖 `model`/`n`/`size` 会击穿别名计费 | Provider 适配器 + capabilities 声明 + `parameters` 白名单过滤;核心/计费字段服务端固定,不取交集、不允许覆盖 | | 抽象泄漏 / 参数越权 | 各供应商入参出参不一致;若调用方 `parameters` 可覆盖 `model`/`n`/`size` 会击穿别名计费 | Provider 适配器 + capabilities 声明 + `parameters` 白名单过滤;核心/计费字段服务端固定,不取交集、不允许覆盖 |
@@ -427,7 +427,7 @@ CREATE TABLE call_record (
桌面端 cmbot 可以**不改交互骨架**,继续用同步方式调 cmhub、cmhub 再同步转中转站,正常使用——决定成败的不是同步/异步本身,而是下面两件事有没有配对好;配好就稳,配不好会「小量正常、上量假死」: 桌面端 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,数量 ≥ 预期峰值总并发 + 余量,避免慢图片把快的生文接口和后台挤死。 2. **worker/线程数按峰值总并发预留**:同步下每个在飞请求占住一个 worker 整个生成周期(几十秒~分钟)。用 Gunicorn `gthread`(`--threads`)或多 sync worker,数量 ≥ 预期峰值总并发 + 余量,避免慢图片把快的生文接口和后台挤死。
适用边界:**单接入方、小并发批量**(桌面端 `concurrency` 1~4)完全够用,点数一致性也更简单。当出现「worker 被长连接占满拖慢快接口/后台」「接入方总并发明显上涨」「需要关窗重连/任务持久化」任一信号时,才转 V2 异步(队列),在此之前保持同步;但适配器接口与 `call_record` 的 `pending/success/failed` 三态需为异步预留口子(见 4.1、七、八)。 适用边界:**单接入方、小并发批量**(桌面端 `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 | | `no_pricing_rule` | 未配置对应计费规则 | 400 |
| `model_not_allowed` | 该模型不支持此操作(如图片模型生成标题) | 400 | | `model_not_allowed` | 该模型不支持此操作(如图片模型生成标题) | 400 |
| `content_blocked` | 输入内容未通过安全审核;T-604 中表示 prompt 命中本地敏感词,未扣点 | 400 | | `content_blocked` | 输入内容未通过安全审核;T-604 中表示 prompt 命中本地敏感词,未扣点 | 400 |
| `upstream_timeout` | 上游 AI 调用超时(已退点) | 502 |
| `upstream_error` | 上游 AI 失败(已退点) | 502 | | `upstream_error` | 上游 AI 失败(已退点) | 502 |
| `signature_invalid` | 支付回调验签失败 | 400 | | `signature_invalid` | 支付回调验签失败 | 400 |
| `amount_mismatch` | 支付回调金额与本地订单金额不一致 | 400 | | `amount_mismatch` | 支付回调金额与本地订单金额不一致 | 400 |
@@ -79,7 +80,7 @@ T-607/T-609 已实现 `GET /api/v1/client/releases/latest?platform=windows`,
| `order_not_found` | 充值订单不存在或不属于当前用户 | 404 | | `order_not_found` | 充值订单不存在或不属于当前用户 | 404 |
| `rate_limited` | 请求过于频繁,请稍后再试 | 429 | | `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` ### `GET /api/v1/balance`
@@ -411,7 +412,7 @@ class Provider(Protocol):
- `parameters` 为供应商特有参数的安全子集;适配器负责白名单过滤并把统一入参翻译成各家上游格式,核心字段不可被 `parameters` 或 `extra_body` 覆盖。 - `parameters` 为供应商特有参数的安全子集;适配器负责白名单过滤并把统一入参翻译成各家上游格式,核心字段不可被 `parameters` 或 `extra_body` 覆盖。
- 配置热生效:每次调用读当前 AiModel/ModelAlias,后台改动及时反映(或带缓存失效)。 - 配置热生效:每次调用读当前 AiModel/ModelAlias,后台改动及时反映(或带缓存失效)。
- 配置审计:后台保存/删除 AiModel、ModelAlias 时写 `AiConfigAuditLog`;记录 actor/action/target/changed_fields/changes/created_at,admin 只读查看;密钥变更只记录 empty/set 状态。 - 配置审计:后台保存/删除 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 DJANGO_CACHE_LOCATION=cmhub_cache
AI_KEY_ENCRYPTION_KEY=base64-fernet-key AI_KEY_ENCRYPTION_KEY=base64-fernet-key
AI_IMAGE_UPSTREAM_DEADLINE_SECONDS=180
PAYMENT_CALLBACK_MODE=sdk PAYMENT_CALLBACK_MODE=sdk
WECHAT_PAY_NOTIFY_URL=https://cmhub.example.com/api/v1/recharge/callback/wechat 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。 - `/secure/cmhub/ai_models.json` 不提交到 git。
- `AiModel.timeout_seconds` 不能继续依赖默认 `0` 口径上线;图片模型必须按真实耗时设置读取超时。 - `AiModel.timeout_seconds` 不能继续依赖默认 `0` 口径上线;图片模型必须按真实耗时设置读取超时,并受 `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS` 硬截止保护。
- 运营后台需配置 `PricingRule` 和 `ExchangeRate`,否则生成和充值会因缺规则被拒绝。 - 运营后台需配置 `PricingRule` 和 `ExchangeRate`,否则生成和充值会因缺规则被拒绝。
### AI 上游配置故障处理 ### AI 上游配置故障处理
@@ -257,7 +258,7 @@ gunicorn config.wsgi:application \
--error-logfile - --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 中读取。 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`,并据此回填: 上线前必须记录真实 `image` smoke 的 `elapsed_ms`,并据此回填:
- 图片 `AiModel.timeout_seconds` - 图片 `AiModel.timeout_seconds` 与 `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS`
- Gunicorn 长请求池 `--timeout` - Gunicorn 长请求池 `--timeout`
- Nginx `/api/v1/generate/` 的 `proxy_read_timeout` - Nginx `/api/v1/generate/` 的 `proxy_read_timeout`
- 接入方客户端 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_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())"` 生成。 `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` 由后台或数据迁移维护,不通过环境变量硬编码具体模型。 `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 安全配置 ## 五、对外 API 安全配置
@@ -154,4 +155,4 @@ T-604 只做 prompt 本地敏感词快筛。命中时返回 `content_blocked`,
- MySQL 为 8.4 LTS / InnoDB / `utf8mb4`,不是 SQLite 或已有 MySQL 5.7。 - MySQL 为 8.4 LTS / InnoDB / `utf8mb4`,不是 SQLite 或已有 MySQL 5.7。
- `image_url` 下载上限、生成限流、认证失败限流、单笔充值金额上限已按生产容量调整。 - `image_url` 下载上限、生成限流、认证失败限流、单笔充值金额上限已按生产容量调整。
- 若启用 `MODERATION_ENABLED`,`MODERATION_PROVIDER=keyword`,敏感词词库已配置,且共享 cache 可用于多 worker matcher 版本失效。 - 若启用 `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`、不能覆盖结果、不能再次改账。 - 验收补充迟到 worker 场景:reaper 已判失败并退点后,迟到 worker 返回成功也不能改回 `succeeded`、不能覆盖结果、不能再次改账。
- 验证:仅文档更新;提交前用 `git diff --check`、`Select-String` 复核。 - 验证:仅文档更新;提交前用 `git diff --check`、`Select-String` 复核。
- 下一步:提交本轮文档;后续领取 T-612。 - 下一步:提交本轮文档;后续领取 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 生图异步任务化复用同一套审核、计费、上游和退点逻辑。