diff --git a/README.md b/README.md index e016e3f..dd1d4f1 100644 --- a/README.md +++ b/README.md @@ -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 用户端品牌名统一为“虾皮圈”与 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)。 +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 生图同步接口止血与 T-613 抽生成核心 service。用户可通过公开首页进入注册、登录、下载客户端和下载导入模板;新用户注册后经计费层自动获得 100 点并写注册赠点流水;登录后可扫码充值并轮询到账,生成 / 删除(吊销)API Key,查看余额、充值总额、分页充值记录、分页点数记录与可用模型;桌面端可匿名请求最新客户端版本 JSON,并读取 `release.force_update` 判断是否必须升级;运营可在 django-admin 检索用户、钱包、API Key、计费规则、汇率、充值订单、点数流水、注册赠点记录、调用记录、客户端发布版本和导入模板,并通过计费层带原因手工调点。下一步为 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) 第四节计费时序。 diff --git a/apps/api/generation.py b/apps/api/generation.py index 84323aa..6c49535 100644 --- a/apps/api/generation.py +++ b/apps/api/generation.py @@ -4,9 +4,9 @@ import base64 import binascii import ipaddress import socket -from dataclasses import dataclass +from dataclasses import dataclass, field from time import perf_counter -from typing import Any, Mapping +from typing import Any, Callable, Mapping from urllib.parse import urljoin, urlsplit import requests @@ -53,157 +53,316 @@ class ImageInput: filename: str = "image.png" +ImageUrlBuilder = Callable[[str], str] + + +@dataclass(frozen=True) +class GenerationInput: + user: Any + api_key: Any + operation_type: str + prompt: str + alias: str | None = None + resolution: str = "1K" + parameters: Mapping[str, Any] = field(default_factory=dict) + image_url: str = "" + image_base64: str = "" + aspect_ratio: str = "1:1" + + +@dataclass(frozen=True) +class PreparedGeneration: + user: Any + api_key: Any + operation_type: str + prompt: str + alias: str + resolution: str + parameters: dict[str, Any] + image_input: ImageInput | None + model_alias: Any + resolved_model: Any + provider: Any + points_cost: int + aspect_ratio: str = "1:1" + + +@dataclass(frozen=True) +class PrechargedGeneration: + prepared: PreparedGeneration + call_record: CallRecord + points_cost: int + points_balance_after_charge: int + + +@dataclass(frozen=True) +class GenerationResult: + operation_type: str + alias: str + model_used: str + points_cost: int + points_balance: int + call_record: CallRecord + titles: tuple[str, ...] = field(default_factory=tuple) + image_url: str = "" + + def as_response_data(self) -> dict[str, Any]: + common = { + "alias": self.alias, + "model_used": self.model_used, + "points_cost": self.points_cost, + "points_balance": self.points_balance, + "call_id": self.call_record.id, + } + if self.operation_type == CallRecord.OperationType.TITLE: + return {"titles": list(self.titles), **common} + return {"image_url": self.image_url, **common} + + IMAGE_URL_ALLOWED_SCHEMES = {"http", "https"} IMAGE_URL_CHUNK_SIZE = 64 * 1024 def generate_title_response(*, user, api_key, request_data: Mapping[str, Any]) -> dict: - prompt = request_data["prompt"] - alias = request_data.get("model") or None - resolution = normalize_resolution(request_data.get("resolution") or "1K") or "1K" - parameters = dict(request_data.get("parameters") or {}) - moderate_prompt_or_raise(user=user, api_key=api_key, prompt=prompt) - image_input = load_image_input(request_data) + result = run_synchronous_generation( + GenerationInput( + user=user, + api_key=api_key, + operation_type=CallRecord.OperationType.TITLE, + prompt=request_data["prompt"], + alias=request_data.get("model") or None, + resolution=request_data.get("resolution") or "1K", + parameters=dict(request_data.get("parameters") or {}), + image_url=str(request_data.get("image_url") or ""), + image_base64=str(request_data.get("image_base64") or ""), + ) + ) + return result.as_response_data() - model_alias = resolve_model_alias_or_raise(CallRecord.OperationType.TITLE, alias) + +def generate_image_response( + *, + user, + api_key, + request_data: Mapping[str, Any], + image_url_builder: ImageUrlBuilder | None = None, +) -> dict: + result = run_synchronous_generation( + GenerationInput( + user=user, + api_key=api_key, + operation_type=CallRecord.OperationType.IMAGE, + prompt=request_data["prompt"], + alias=request_data.get("model") or None, + resolution=request_data.get("resolution") or "1K", + parameters=dict(request_data.get("parameters") or {}), + image_url=str(request_data.get("image_url") or ""), + image_base64=str(request_data.get("image_base64") or ""), + aspect_ratio=request_data.get("aspect_ratio") or "1:1", + ), + image_url_builder=image_url_builder, + ) + return result.as_response_data() + + +def run_synchronous_generation( + generation_input: GenerationInput, + *, + image_url_builder: ImageUrlBuilder | None = None, +) -> GenerationResult: + prepared = prepare_generation(generation_input) + precharged = precharge_generation(prepared) + return execute_precharged_generation( + precharged, + image_url_builder=image_url_builder, + ) + + +def prepare_generation(generation_input: GenerationInput) -> PreparedGeneration: + operation_type = normalize_operation_type(generation_input.operation_type) + resolution = normalize_resolution(generation_input.resolution or "1K") or "1K" + parameters = dict(generation_input.parameters or {}) + prompt = str(generation_input.prompt or "") + + moderate_prompt_or_raise( + user=generation_input.user, + api_key=generation_input.api_key, + prompt=prompt, + ) + image_input = load_image_input( + { + "image_base64": generation_input.image_base64, + "image_url": generation_input.image_url, + } + ) + + model_alias = resolve_model_alias_or_raise(operation_type, generation_input.alias) resolved_model = resolved_model_or_raise(model_alias) provider = provider_or_raise(resolved_model) - ensure_provider_supports(provider, CallRecord.OperationType.TITLE) + ensure_provider_supports(provider, operation_type) points_cost = calculate_points_cost_or_raise( + operation_type, + model_alias.alias, + resolution, + ) + + return PreparedGeneration( + user=generation_input.user, + api_key=generation_input.api_key, + operation_type=operation_type, + prompt=prompt, + alias=model_alias.alias, + resolution=resolution, + parameters=parameters, + image_input=image_input, + model_alias=model_alias, + resolved_model=resolved_model, + provider=provider, + points_cost=points_cost, + aspect_ratio=generation_input.aspect_ratio or "1:1", + ) + + +def precharge_generation(prepared: PreparedGeneration) -> PrechargedGeneration: + charge = precharge_or_raise( + user=prepared.user, + api_key=prepared.api_key, + operation_type=prepared.operation_type, + alias=prepared.alias, + model_used=prepared.resolved_model.model, + resolution=prepared.resolution, + prompt=prepared.prompt, + points_cost=prepared.points_cost, + ) + return PrechargedGeneration( + prepared=prepared, + call_record=charge.call_record, + points_cost=charge.points_cost, + points_balance_after_charge=charge.balance_after, + ) + + +def execute_precharged_generation( + precharged: PrechargedGeneration, + *, + image_url_builder: ImageUrlBuilder | None = None, +) -> GenerationResult: + prepared = precharged.prepared + try: + started = perf_counter() + if prepared.operation_type == CallRecord.OperationType.TITLE: + result = execute_title_generation(precharged, started) + else: + result = execute_image_generation( + precharged, + started, + image_url_builder=image_url_builder, + ) + except AiCapabilityError as exc: + refund_call_points( + precharged.call_record, + error_message=str(exc), + reason=f"Provider rejected the {prepared.operation_type} request.", + ) + raise ApiRequestError( + "bad_request", + "请求参数不支持当前模型", + status.HTTP_400_BAD_REQUEST, + ) from exc + except Exception as exc: + refund_call_points( + precharged.call_record, + error_message=str(exc), + reason=f"Upstream {prepared.operation_type} generation failed.", + ) + raise upstream_error(exc) from exc + + return result + + +def execute_title_generation( + precharged: PrechargedGeneration, + started: float, +) -> GenerationResult: + prepared = precharged.prepared + image_input = prepared.image_input + generation = prepared.provider.generate_text( + prepared.prompt, + prepared.resolved_model, + image=image_input.data if image_input else None, + image_mime_type=image_input.mime_type if image_input else "image/png", + resolution=prepared.resolution, + parameters=prepared.parameters, + ) + latency_ms = elapsed_ms(started) + titles = list(generation.titles or ()) + if not titles and generation.text: + titles = [generation.text] + result_summary = summarize_titles(titles, generation.text) + call_record = mark_call_success( + precharged.call_record, + result_summary=result_summary, + upstream_latency_ms=latency_ms, + ) + return GenerationResult( + operation_type=prepared.operation_type, + alias=prepared.alias, + model_used=generation.model_used, + points_cost=precharged.points_cost, + points_balance=precharged.points_balance_after_charge, + call_record=call_record, + titles=tuple(titles), + ) + + +def execute_image_generation( + precharged: PrechargedGeneration, + started: float, + *, + image_url_builder: ImageUrlBuilder | None = None, +) -> GenerationResult: + prepared = precharged.prepared + image_input = prepared.image_input + generation = prepared.provider.generate_image( + prepared.prompt, + prepared.resolved_model, + image=image_input.data if image_input else None, + image_mime_type=image_input.mime_type if image_input else "image/png", + image_filename=image_input.filename if image_input else "image.png", + resolution=prepared.resolution, + aspect_ratio=prepared.aspect_ratio, + parameters=prepared.parameters, + ) + latency_ms = elapsed_ms(started) + image_url = save_generated_image( + generation.image, + url_builder=image_url_builder, + ) + call_record = mark_call_success( + precharged.call_record, + result_ref=image_url, + result_summary=f"image_bytes={len(generation.image)}", + upstream_latency_ms=latency_ms, + ) + return GenerationResult( + operation_type=prepared.operation_type, + alias=prepared.alias, + model_used=generation.model_used, + points_cost=precharged.points_cost, + points_balance=precharged.points_balance_after_charge, + call_record=call_record, + image_url=image_url, + ) + + +def normalize_operation_type(operation_type: str) -> str: + normalized = str(operation_type or "").strip() + if normalized not in { CallRecord.OperationType.TITLE, - model_alias.alias, - resolution, - ) - - charge = precharge_or_raise( - user=user, - api_key=api_key, - operation_type=CallRecord.OperationType.TITLE, - alias=model_alias.alias, - model_used=resolved_model.model, - resolution=resolution, - prompt=prompt, - points_cost=points_cost, - ) - - try: - started = perf_counter() - generation = provider.generate_text( - prompt, - resolved_model, - image=image_input.data if image_input else None, - image_mime_type=image_input.mime_type if image_input else "image/png", - resolution=resolution, - parameters=parameters, - ) - latency_ms = elapsed_ms(started) - titles = list(generation.titles or ()) - if not titles and generation.text: - titles = [generation.text] - result_summary = summarize_titles(titles, generation.text) - call_record = mark_call_success( - charge.call_record, - result_summary=result_summary, - upstream_latency_ms=latency_ms, - ) - except AiCapabilityError as exc: - refund_call_points( - charge.call_record, - error_message=str(exc), - reason="Provider rejected the title request.", - ) - raise ApiRequestError("bad_request", "请求参数不支持当前模型", status.HTTP_400_BAD_REQUEST) from exc - except Exception as exc: - refund_call_points( - charge.call_record, - error_message=str(exc), - reason="Upstream title generation failed.", - ) - raise upstream_error(exc) from exc - - return { - "titles": titles, - "alias": model_alias.alias, - "model_used": generation.model_used, - "points_cost": points_cost, - "points_balance": charge.balance_after, - "call_id": call_record.id, - } - - -def generate_image_response(*, user, api_key, request, request_data: Mapping[str, Any]) -> dict: - prompt = request_data["prompt"] - alias = request_data.get("model") or None - resolution = normalize_resolution(request_data.get("resolution") or "1K") or "1K" - aspect_ratio = request_data.get("aspect_ratio") or "1:1" - parameters = dict(request_data.get("parameters") or {}) - moderate_prompt_or_raise(user=user, api_key=api_key, prompt=prompt) - image_input = load_image_input(request_data) - - model_alias = resolve_model_alias_or_raise(CallRecord.OperationType.IMAGE, alias) - resolved_model = resolved_model_or_raise(model_alias) - provider = provider_or_raise(resolved_model) - ensure_provider_supports(provider, CallRecord.OperationType.IMAGE) - points_cost = calculate_points_cost_or_raise( CallRecord.OperationType.IMAGE, - model_alias.alias, - resolution, - ) - - charge = precharge_or_raise( - user=user, - api_key=api_key, - operation_type=CallRecord.OperationType.IMAGE, - alias=model_alias.alias, - model_used=resolved_model.model, - resolution=resolution, - prompt=prompt, - points_cost=points_cost, - ) - - try: - started = perf_counter() - generation = provider.generate_image( - prompt, - resolved_model, - image=image_input.data if image_input else None, - image_mime_type=image_input.mime_type if image_input else "image/png", - image_filename=image_input.filename if image_input else "image.png", - resolution=resolution, - aspect_ratio=aspect_ratio, - parameters=parameters, - ) - latency_ms = elapsed_ms(started) - image_url = save_generated_image(generation.image, request=request) - call_record = mark_call_success( - charge.call_record, - result_ref=image_url, - result_summary=f"image_bytes={len(generation.image)}", - upstream_latency_ms=latency_ms, - ) - except AiCapabilityError as exc: - refund_call_points( - charge.call_record, - error_message=str(exc), - reason="Provider rejected the image request.", - ) - raise ApiRequestError("bad_request", "请求参数不支持当前模型", status.HTTP_400_BAD_REQUEST) from exc - except Exception as exc: - refund_call_points( - charge.call_record, - error_message=str(exc), - reason="Upstream image generation failed.", - ) - raise upstream_error(exc) from exc - - return { - "image_url": image_url, - "alias": model_alias.alias, - "model_used": generation.model_used, - "points_cost": points_cost, - "points_balance": charge.balance_after, - "call_id": call_record.id, - } + }: + raise ValueError(f"Unsupported generation operation type: {operation_type}") + return normalized def moderate_prompt_or_raise(*, user, api_key, prompt: str) -> None: diff --git a/apps/api/storage.py b/apps/api/storage.py index a60ef2a..20d1005 100644 --- a/apps/api/storage.py +++ b/apps/api/storage.py @@ -1,5 +1,6 @@ from __future__ import annotations +from typing import Callable from uuid import uuid4 from django.core.files.base import ContentFile @@ -7,7 +8,12 @@ from django.core.files.storage import default_storage from django.utils import timezone -def save_generated_image(image: bytes, *, request=None) -> str: +def save_generated_image( + image: bytes, + *, + url_builder: Callable[[str], str] | None = None, + request=None, +) -> str: today = timezone.now() path = ( "generated/images/" @@ -16,6 +22,8 @@ def save_generated_image(image: bytes, *, request=None) -> str: ) saved_path = default_storage.save(path, ContentFile(image)) url = default_storage.url(saved_path) + if url_builder is not None and url.startswith("/"): + return url_builder(url) if request is not None and url.startswith("/"): return request.build_absolute_uri(url) return url diff --git a/apps/api/tests.py b/apps/api/tests.py index befc2fb..a78c448 100644 --- a/apps/api/tests.py +++ b/apps/api/tests.py @@ -20,6 +20,14 @@ from rest_framework.test import APIClient from rest_framework.views import APIView from apps.api.authentication import ApiKeyAuthentication +from apps.api.generation import ( + ApiRequestError, + GenerationInput, + execute_precharged_generation, + precharge_generation, + prepare_generation, + run_synchronous_generation, +) from apps.api.throttles import GenerateRateThrottle from apps.api.views import ClientLatestReleaseView, ExternalApiView, ModelsView from apps.ai.models import AiModel, ModelAlias @@ -1273,6 +1281,34 @@ class GenerateApiTests(TestCase): self.assertEqual(call.result_summary, "image_bytes=21") self.assertNotIn("SECRET_RAW", call.result_ref + call.result_summary) + def test_generation_core_saves_image_with_url_builder_without_request(self): + encoded = base64.b64encode(b"input-image").decode("ascii") + + with patch("apps.api.generation.get_provider", return_value=self.provider): + result = run_synchronous_generation( + GenerationInput( + user=self.user, + api_key=self.api_key, + operation_type=CallRecord.OperationType.IMAGE, + prompt="生成图片", + alias=self.image_alias, + resolution="1K", + image_base64=f"data:image/png;base64,{encoded}", + ), + image_url_builder=lambda url: f"https://cdn.example.test{url}", + ) + + self.assertEqual(result.operation_type, CallRecord.OperationType.IMAGE) + self.assertTrue(result.image_url.startswith("https://cdn.example.test/media/")) + self.assertEqual(result.as_response_data()["image_url"], result.image_url) + self.assertEqual(self.provider.image_calls[0]["image"], b"input-image") + + call = CallRecord.objects.get(pk=result.call_record.id) + self.assertEqual(call.status, CallRecord.Status.SUCCESS) + self.assertEqual(call.result_ref, result.image_url) + self.wallet.refresh_from_db() + self.assertEqual(self.wallet.points_balance, 90) + def test_generate_image_downloads_safe_image_url(self): response = FakeImageUrlResponse( headers={"Content-Type": "image/jpeg"}, @@ -1514,6 +1550,51 @@ class GenerateApiTests(TestCase): 1, ) + def test_precharged_generation_stage_refunds_on_upstream_failure(self): + self.provider.text_error = AiProviderError("provider timeout") + + with patch("apps.api.generation.get_provider", return_value=self.provider): + prepared = prepare_generation( + GenerationInput( + user=self.user, + api_key=self.api_key, + operation_type=CallRecord.OperationType.TITLE, + prompt="生成标题", + alias=self.title_alias, + resolution="1K", + ) + ) + precharged = precharge_generation(prepared) + + self.wallet.refresh_from_db() + self.assertEqual(self.wallet.points_balance, 98) + self.assertEqual(precharged.call_record.status, CallRecord.Status.PENDING) + + with self.assertRaises(ApiRequestError) as captured: + execute_precharged_generation(precharged) + + self.assertEqual(captured.exception.code, "upstream_error") + self.wallet.refresh_from_db() + self.assertEqual(self.wallet.points_balance, 100) + + call = CallRecord.objects.get(pk=precharged.call_record.id) + self.assertEqual(call.status, CallRecord.Status.FAILED) + self.assertIn("provider timeout", 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_image_upstream_timeout_refunds_precharged_points_and_marks_call_failed(self): self.provider.image_error = requests.Timeout("image upstream deadline exceeded") diff --git a/apps/api/views.py b/apps/api/views.py index 5e4970d..3742397 100644 --- a/apps/api/views.py +++ b/apps/api/views.py @@ -99,8 +99,8 @@ class GenerateImageView(ExternalApiView): data = generate_image_response( user=request.user, api_key=request.auth, - request=request, request_data=serializer.validated_data, + image_url_builder=request.build_absolute_uri, ) except ApiRequestError as exc: return Response(exc.as_response_data(), status=exc.http_status) diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 9a292b3..4b20b8d 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -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「用户端品牌名统一为虾皮圈」与 T-612「生图同步接口止血」。生图慢 / 504 / 客户端超时已拆为 T-612~T-615:T-612 已先做同步接口上游硬截止止血;下一步 T-613 抽共享生成 core,随后 T-614 新增异步提交轮询接口,T-615 观察旧同步接口用量。生产侧仍需补真实支付回调到账闭环;邮件服务仅用于后续密码找回/通知等邮件能力,不阻塞注册登录。 +当前项目处于:**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「生图同步接口止血」与 T-613「抽生成核心 service」。生图慢 / 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-613。 +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 已完成抽生成核心 service;当前下一个可领取任务为 T-614。 ## 领取任务规则 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index e85fe1c..809b812 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -34,12 +34,12 @@ - API Key 鉴权(自定义 DRF Authentication,哈希比对),把请求绑定到 Key 所属的注册用户。 - 参数校验、请求/响应序列化、错误码统一。 -- 编排单次调用:调用计费层预扣 → 调 AI 层 → 成功确认 / 失败退点 → 写调用记录。 +- 编排单次调用:通过生成核心 service 组织「审核 → 图片输入处理 → 别名 / 计费 → 预扣 → 调 AI 层 → 成功确认 / 失败退点 → 写调用记录」。 - 不直接写点数余额字段,必须走计费层提供的方法。 T-301 已实现 `ApiKeyAuthentication` 与 `ExternalApiView`:外部 API 使用 `Authorization: Bearer `,通过 SHA-256 hash 定位 `ApiKey -> User`,成功后 `request.user` 为所属用户、`request.auth` 为本次 API Key;缺失/无效 Key 返回 `401 unauthorized`,用户或 Key 禁用返回 `403 account_disabled`。生成/余额等外部 API 应继承 `ExternalApiView`,不要挂 `SessionAuthentication`。 -T-302 已实现 `/api/v1/generate/title` 与 `/api/v1/generate/image`:API 层只做鉴权、参数校验和编排;别名解析、Provider 选择、计费计算、预扣、成功确认、失败退点分别调用 `apps.ai` / `apps.billing` 既有模块。图片结果 MVP 先用本地 `default_storage` 保存到 `MEDIA_ROOT/generated/images/...` 并返回 `image_url`;`CallRecord` 只写 URL / 摘要,不保存 provider `raw` 或 base64。 +T-302 已实现 `/api/v1/generate/title` 与 `/api/v1/generate/image`:API 层只做鉴权、参数校验和编排;别名解析、Provider 选择、计费计算、预扣、成功确认、失败退点分别调用 `apps.ai` / `apps.billing` 既有模块。T-613 已把生成链路抽为 `apps.api.generation` 的核心阶段:`prepare_generation()` 负责审核、图片输入、别名、Provider 与计费准备;`precharge_generation()` 只调用 billing 预扣;`execute_precharged_generation()` 复用已预扣 `CallRecord` 调上游并成功确认或失败退点,供旧同步接口和后续异步 worker 共用。图片结果 MVP 先用本地 `default_storage` 保存到 `MEDIA_ROOT/generated/images/...` 并返回 `image_url`;核心阶段通过 URL 构建器生成外部 URL,不依赖 DRF `Request`;`CallRecord` 只写 URL / 摘要,不保存 provider `raw` 或 base64。 T-303 已实现 `/api/v1/balance`:外部 API 继续只认 API Key,API 层调用 `apps.billing.services.get_balance_snapshot()` 读取当前钱包余额;测试覆盖余额响应与流水累加一致的场景。 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 65daf53..bf4eb04 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -91,7 +91,7 @@ | T-610 | 首页导入模板下载入口 | T-606 | 在公开首页“下载客户端”按钮右侧新增“下载导入模板”链接,让用户下载桌面端导入商品数据使用的 Excel 模板。**产品口径**:模板下载是公开资源,匿名可见;不需要登录、不需要 API Key、不扣点、不占用生成接口限流。**后台配置**:新增独立 `ImportTemplate`(不要复用 `DownloadRelease`,两者生命周期不同),字段建议包含 `name`、`file`(FileField,`MEDIA_ROOT/import_templates/`)、`external_url`(可选,有则优先,后续切 CDN/对象存储)、`sha256`(可选)、`is_current`、`notes`、`created_at`、`updated_at`;admin 可上传/更新模板并标记当前模板;每次保存 current 时把其他 current 置为 false,避免使用 MySQL 不支持的条件唯一约束。**首页行为**:`HomeView` 同时读取 Windows 当前 `DownloadRelease` 和当前 `ImportTemplate`;有当前模板且有可下载地址时,在“下载客户端”右侧展示“下载导入模板”链接;无模板时不显示该链接或显示不可用提示,不能影响客户端下载入口和首页 200。**托管策略**:本地上传模板复用生产 Nginx `/media/` 静态托管,不经 Django/Gunicorn 传大文件;`external_url` 优先于 `file.url`,响应/页面不得暴露本地 `MEDIA_ROOT` 或服务器文件路径。**UI**:保持 T-606 下载区现有布局,桌面端按钮为主按钮,导入模板为次级链接/按钮;移动端需换行不重叠。**文档**:同步 `02-requirements.md`、`04-architecture.md`、`routes.md`、`api.md`、`current-state.md`。**测试**:覆盖首页有模板时展示链接且 href 为绝对/可访问 media URL,`external_url` 优先,本地路径不泄露,无模板时首页正常且不显示错误;admin 字段可见;`makemigrations` / `migrate` / `check` / 目标测试通过并在 `../progress.md` 留证据 | DONE | | T-611 | 用户端品牌名统一为“虾皮圈” | T-606 | 把用户端页面上对外展示的项目/产品名从 `cmhub` 统一改为“虾皮圈”。**范围**:只改终端用户可见的 portal 页面文案和浏览器标题,包括公开首页、顶部导航品牌、注册/登录/登出页、控制台、充值、API Key、可用模型、充值记录、点数记录等模板中作为品牌/产品名出现的 `cmhub`;用户端文案里的“cmhub API Key”应改为“虾皮圈 API Key”。**不改**:仓库名、Python 包名、Django app 名、数据库表名、环境变量、API 路径、域名、对外接口字段、后台内部模型名和技术文档里指代服务代号的 `cmhub`。**文档**:同步 `02-requirements.md`、`00-ai-start-here.md`、`current-state.md` 与 `../progress.md`。**测试**:更新 portal 相关断言,覆盖首页展示“虾皮圈”且不再展示旧标题 `cmhub AI 电商生成台`;关键用户端页面渲染不含作为品牌展示的 `cmhub`;`check`、`makemigrations --check --dry-run`、目标 portal 测试通过并在 `../progress.md` 留证据 | DONE | | T-612 | 生图同步接口止血(上游硬截止 + 长请求池校准) | T-403 | **先行止血,不改成功响应契约**,保护存量老客户端的旧同步路。**真实现状**:线上已按路径分流到独立 Gunicorn 长请求池,当前是 `gthread` 而非 sync worker;P0 风险是同步生图长请求仍会占用生成池线程,上游慢 / 卡死时导致生成池排队、客户端写入或读取超时,并可能拖慢同池生文接口。**硬截止优先**:新增可配置上游生图硬截止(建议 `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS`,默认 180s 左右,生产按真实 smoke 校准),Provider 实际读取超时取 `min(AiModel.timeout_seconds 或分辨率默认值, AI_IMAGE_UPSTREAM_DEADLINE_SECONDS)`;撞截止必须走现有失败退点路径,保证不超扣、不扣成负数。**Worker 策略**:默认继续使用已验证的 `gthread` 长请求池并校准 `workers/threads/timeout`;只有在本地和生产预演证明 `requests`、PyMySQL、Django cache、支付 SDK 在 monkey-patch 下无问题时,才允许把 `gevent` 作为替代方案写入部署文档,不能把 gevent 当成默认第一步。**接口兼容**:`POST /api/v1/generate/image` 的成功响应字段保持不变;失败响应外层结构保持 `{error:{code,message}}`,如新增 `upstream_timeout` 错误码必须同步 `api.md` 并保持旧客户端可按失败处理。**文档**:同步 `deployment.md`(硬截止、`AiModel.timeout_seconds`、Gunicorn `--timeout`、Nginx `proxy_read_timeout`、客户端 read timeout 的外层 >= 内层关系)、`04-architecture.md` 和 `env.md`。**验收**:模拟上游超时能退点并返回结构化错误;压测下卡死上游不会拖垮 `/balance`、`/models`,生文排队受控;线上配置仍保留 web 池和 generate 池分离;`check`/目标测试/`init` 通过并在 `../progress.md` 留证据 | DONE | -| T-613 | 抽生成核心 service(计费+审核+上游共享 core) | T-612, T-302, T-604 | 为异步化铺路,**重构现有 `apps/api/generation.py`,不是另写第二套生成逻辑**。把「别名解析+能力校验 → 审核(T-604) → 图片输入处理 → 计费计算 → 预扣(precharge) → 调上游 → 保存结果 → 成功确认/失败退点」整理为不依赖 DRF `Request` / `Response` 的核心 service,并为 T-614 拆出可复用的阶段:同步旧接口可一口气执行完整 pipeline,异步 worker 可复用“已预扣 call_record 的执行与确认 / 退点”阶段,**严禁复制第二套扣点/退点逻辑**。**HTTP 解耦**:核心 service 不直接构造 DRF `Response`,错误用领域异常表达;图片保存不能强依赖 `request.build_absolute_uri()`,需通过 URL 构建器或公开基础 URL 生成结果 URL。**兼容约束**:旧 `POST /api/v1/generate/title|image` 的字段、HTTP 状态码和错误语义保持不变;不要求 JSON 字段顺序逐字节一致。**验收**:T-302/T-604 既有断言不降低标准即通过;覆盖旧同步接口成功、上游失败退点、敏感词拦截不扣点、图片 URL 保存;`check`/目标测试/`init` 通过并在 `../progress.md` 留证据 | TODO | +| T-613 | 抽生成核心 service(计费+审核+上游共享 core) | T-612, T-302, T-604 | 为异步化铺路,**重构现有 `apps/api/generation.py`,不是另写第二套生成逻辑**。把「别名解析+能力校验 → 审核(T-604) → 图片输入处理 → 计费计算 → 预扣(precharge) → 调上游 → 保存结果 → 成功确认/失败退点」整理为不依赖 DRF `Request` / `Response` 的核心 service,并为 T-614 拆出可复用的阶段:同步旧接口可一口气执行完整 pipeline,异步 worker 可复用“已预扣 call_record 的执行与确认 / 退点”阶段,**严禁复制第二套扣点/退点逻辑**。**HTTP 解耦**:核心 service 不直接构造 DRF `Response`,错误用领域异常表达;图片保存不能强依赖 `request.build_absolute_uri()`,需通过 URL 构建器或公开基础 URL 生成结果 URL。**兼容约束**:旧 `POST /api/v1/generate/title|image` 的字段、HTTP 状态码和错误语义保持不变;不要求 JSON 字段顺序逐字节一致。**验收**:T-302/T-604 既有断言不降低标准即通过;覆盖旧同步接口成功、上游失败退点、敏感词拦截不扣点、图片 URL 保存;`check`/目标测试/`init` 通过并在 `../progress.md` 留证据 | DONE | | T-614 | 生图异步任务化接口(提交+轮询,新增不动旧接口) | T-613, T-203, T-301 | 新增任务化接口与旧同步接口**共存**(expand-contract 并行变更),新版桌面端走新路、老版本零感知。跨仓契约参考 cmshopee 侧设计(Obsidian「生图接口异步任务化-提交轮询方案」评审修订 v2),**落地口径以本任务 + `api.md` 为准**。**新增路由**:`POST /api/v1/generate/image/tasks`(提交后返回 `202` + `task_id`)、`GET /api/v1/generate/image/tasks/{task_id}`(轮询取状态和结果);本期不暴露 cancel 路由,若后续要做取消单独拆任务且只允许取消 `queued`。**任务模型**:新增 `ImageGenerationTask`,公开 `task_id` 用 UUID,不暴露自增 ID;字段至少包含 `user`、`api_key`、`call_record`、`status(queued/running/succeeded/failed/expired)`、`idempotency_key`、`request_hash`、必要的请求快照 / 输入引用、`result_url`、`error_code`、`error_message`、`started_at`、`finished_at`、`expires_at`、`locked_at`、`lease_expires_at`、`heartbeat_at`、`worker_id`、`attempt_count`。不得把 provider raw、密钥或超大 base64 原文长期存 DB;`image_base64` 应先解码后落临时文件 / 存储引用,`image_url` submit 阶段至少做协议与公网地址校验,实际下载可在 worker 内执行,失败走退点。**提交段**:审核(T-604)同步执行,命中 BLOCK 直接 `400 content_blocked`,不建 task、不扣点、不调上游;submit 时预扣(沿用现有 `precharge`),余额不足 `402`,堵住「余额只够 1 张却提交 100 个 task」;建任务后立即返回 `202`。**幂等**:支持 `Idempotency-Key`,按 `api_key + operation + key` 去重;同 key 同 payload 返回同一 `task_id` 且不重复预扣,同 key 不同 payload 返回 `409 idempotency_conflict`。**后台 worker**:优先用 DB 任务表 + management command worker + systemd 托管,MySQL 8.4 可用 `select_for_update(skip_locked)` 抢任务;暂不引入 Celery/Redis,`django-q`/`huey` 只有在 DB worker 不够时再单独评估。worker 必须复用 T-613 共享 core 和已预扣的 `CallRecord`,成功保留扣点并返回 cmhub 托管 URL,失败/撞硬截止必须退点。**结果 URL**:异步 worker 没有 request,必须新增 `PUBLIC_BASE_URL` 或 `MEDIA_PUBLIC_BASE_URL` 等配置来生成绝对 URL;不得透传上游临时链接。**查询段**:只读、短超时,必须校验 `task.user == api_key.user`(防 IDOR,跨用户返回 404 或 403);`succeeded` 幂等重取返回同一 URL;过期任务 / 图片 GC 口径写入文档。**保留窗口**:task_id 持久化的目的是扛客户端重启,窗口须覆盖桌面端现实停机(如关一晚),**任务元数据保留 ≥24h、结果图保留更久(如 24–72h,可配,别硬编码 6h)**,避免「点已扣、图被 GC」。**租约与僵任务回收(reaper,必做)**:worker 抢任务时写 `worker_id`、`locked_at`、`lease_expires_at`,运行中周期性更新 `heartbeat_at`;如果 worker 用 `select_for_update(skip_locked)` 抢任务后崩溃,行锁随连接释放但 `status` 会永远停在 `running`——**点数已预扣却永不退、客户端轮询到自己超时**。reaper 按 `lease_expires_at` / `heartbeat_at`(兜底 `started_at`)识别僵任务,默认把超时 `running` 判 `failed` + **退点(幂等)**,不默认重排队;只有能证明任务尚未调上游(如明确 `stage=not_started`)时才允许后续任务设计重排队。**worker 至少一次执行,账务和结果 exactly-once**:worker 可能重复执行同一任务,但「上游调用确认 / 退点 / 写结果」这些终态动作必须幂等;重复执行不得重复扣/退,不得覆盖已 `succeeded` 结果,也不得把 reaper 已判 `failed` 且已退点的任务改回 `succeeded`。**配置**:新增或同步 `IMAGE_TASK_RETENTION_HOURS`、`GENERATED_IMAGE_RETENTION_HOURS`、`IMAGE_TASK_REAPER_INTERVAL_SECONDS`、`IMAGE_TASK_LEASE_SECONDS` 等环境变量口径。**文档**:同步 `api.md`(新契约、状态机、错误码、幂等)、`routes.md`、`04-architecture.md`(异步计费时序)、`deployment.md`(worker 进程托管)、`env.md`。**验收**:审核命中不建 task/不扣点/不调上游;预扣余额不足 402;同 Idempotency-Key 去重且 payload 冲突 409;worker 成功后重复 GET 同一 URL;跨用户 GET 被拒;失败/超时退点不超扣;**模拟 worker 崩溃留下 `running` 僵任务 → reaper 判失败并退点、不超扣、客户端下次 GET 得到 `failed`**;**worker 重复执行同一任务幂等(不重复扣/退、不覆盖已 `succeeded` 结果)**;**reaper 已把任务判 `failed` 并退点后,迟到 worker 返回成功也不能改回 `succeeded`、不能覆盖结果、不能再次改账**;旧生文与旧同步生图不回归;`check`/目标测试/`init` 通过并在 `../progress.md` 留证据 | TODO | | T-615 | 旧同步生图接口用量遥测 + 弃用口径 | T-614 | 给旧同步接口装可观测、定弃用退出条件,避免永久双维护。**最小遥测**:旧 `POST /api/v1/generate/image` 和新 `/tasks` 路径都写结构化日志 / 计数,至少含 route_type(sync/async)、api_key 前缀或 ID、user_id、client version(若请求头提供,如 `X-Client-Version`)、alias、status、latency_ms、error_code;日志不得包含 API Key 明文、prompt 全文、图片 base64 或 provider raw。**查看方式**:先用日志查询即可;若要 admin 报表需单独评估数据量和索引。**弃用口径**:在 `deployment.md`/`04-architecture.md` 记录迁移计划:新版桌面端默认走异步接口 → 观察旧路调用量和错误率 → 旧路调用归零或低于阈值一段时间 → 宣布 deprecate → 另立任务下线;旧同步接口下线前必须保留成功响应兼容。**验收**:能按 client version / api_key 看到旧路与新路用量;弃用条件成文;不泄露敏感数据;`check`/目标测试/`init` 通过并在 `../progress.md` 留证据 | TODO | diff --git a/docs/api.md b/docs/api.md index f303ea4..eb1fa11 100644 --- a/docs/api.md +++ b/docs/api.md @@ -21,7 +21,7 @@ T-301 已实现对外 API 鉴权基线:`apps.api.authentication.ApiKeyAuthentication` 只解析 `Authorization: Bearer `;生成、余额等外部 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-302 已实现生成接口基线:`POST /api/v1/generate/title` 与 `POST /api/v1/generate/image` 已接入 API Key 鉴权、别名解析、计费规则、预扣点、Provider 调用、成功确认和失败退点;图片结果当前以本地 `MEDIA_ROOT` 保存并返回 `image_url`,后续可替换为对象存储。T-613 起内部实现已抽为生成核心 service,旧同步接口仍保持原字段、状态码和错误语义;后续异步 worker 必须复用同一套预扣、执行、确认和退点阶段,不得复制第二套资金逻辑。 T-303 已实现余额查询基线:`GET /api/v1/balance` 已接入 API Key 鉴权,返回当前 `UserWallet.points_balance`,并保留旧字段同时新增不含邮箱的 `account` 账号展示对象;测试覆盖响应余额与 `PointsLedger.points_delta` 累加值一致的账务场景,并确认 Web session 不能调用该外部接口。 diff --git a/docs/current-state.md b/docs/current-state.md index 30e2415..33146fd 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -12,10 +12,11 @@ ## 当前快照 - 日期:2026-07-08 -- 阶段:Phase 6 增强(MVP 后);Phase 3 对外 API 与充值已完成到 T-306,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 生图同步接口止血已完成;后续仍需处理真实支付回调到账闭环、客户端下载包发布和 T-613~T-615 生图异步化后续任务 +- 阶段:Phase 6 增强(MVP 后);Phase 3 对外 API 与充值已完成到 T-306,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 生图同步接口止血与 T-613 抽生成核心 service 已完成;后续仍需处理真实支付回调到账闭环、客户端下载包发布和 T-614~T-615 生图异步化后续任务 - 技术栈:系统 Python 3.12.3 + Django 5.2.15 + DRF 3.16.1 + django-allauth 65.18.0 + PyMySQL 1.1.3 + cryptography 49.0.0 + requests 2.34.2 + ahocorapy 1.6.2 + wechatpayv3 2.0.2 + python-alipay-sdk 3.4.0 + django-admin;MySQL 8.4 已接入 settings,并支持 `MYSQL_CONNECT_TIMEOUT` / `MYSQL_READ_TIMEOUT` / `MYSQL_WRITE_TIMEOUT`;用户端已用 Django 模板 SSR + Bootstrap + allauth 落地注册登录;生产部署口径为 VPS / 宝塔 + Nginx + Gunicorn(gthread) + systemd;详见 `03-tech-stack.md` 与 `deployment.md` -- 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器);T-102 已新增 `AiModel` / `ModelAlias`、Fernet 加密密钥存储、别名解析、admin 配置页、`import_ai_models` 导入命令;T-103 已新增 `AiConfigAuditLog` 审计表、admin 只读页面和后台保存/删除审计 hook;T-104/T-105 已完成录制 title/image smoke 与审核修补;T-201 已新增 `UserWallet` / `ApiKey`、`PointsLedger` / `CallRecord`、对应 admin 与迁移;T-202 已新增 `PricingRule` / `ExchangeRate`、`apps.billing.pricing` 计费计算函数、admin 配置页与迁移;T-203 已新增 `apps.billing.services`,实现并发安全预扣、成功确认与幂等失败退点;T-204 已新增 `billing.0003_pointsledger_unique_ledger_change_type_per_call`,用 MySQL 可落地的 `ref_call + change_type` 复合唯一约束兜底防重复 refund;T-301 已新增 `apps.api.authentication.ApiKeyAuthentication` 与 `ExternalApiView`;T-302 已新增生成接口编排、序列化器、图片本地存储和 `/api/v1/generate/title|image` 路由;T-303 已新增 `apps.billing.services.get_balance_snapshot()` 与 `/api/v1/balance` 余额查询接口;T-304 已新增 `RechargeOrder`、充值回调验签适配器、幂等入账服务、微信/支付宝回调路由与迁移 `billing.0004_rechargeorder_and_more`;T-305 已新增 `create_recharge_order()`、微信/支付宝扫码下单 mock/SDK 入口、`/api/v1/recharge/create` 与 `/api/v1/recharge/status`;T-306 已新增 `apps.api.throttles`、`apps.api.exceptions`、`REST_FRAMEWORK` 安全默认认证、生成/认证失败限流、`image_url` SSRF 防护与响应大小上限、充值单笔金额上限;T-501/T-608 已接入 allauth 注册登录路径,注册成功后 adapter 调用 `grant_signup_bonus()` 经 billing 一次性发放 100 点并写 `signup_bonus` 流水,新增 `SignupBonusGrant(user UNIQUE)` 幂等标记、admin 只读检索和 `billing.0007` 迁移;T-502 已新增 `/apikeys`、API Key 创建表单、列表页和删除(吊销)动作,生成后明文只显示一次,列表只显示 prefix;T-503/T-608 已扩展 `/dashboard` 为个人中心汇总,并新增 `/records/recharge` 充值记录与 `/records/usage` 点数记录,只读展示当前用户数据和注册赠点 / 消费 / 退款流水;T-504 已新增 `/recharge` 页面、`RechargeCreateForm`、充值导航入口和轮询脚本,页面创建 pending 订单、展示二维码票据、轮询 `/api/v1/recharge/status`,订单 paid 后刷新余额;T-505 已把 Bootstrap 5 CSS 与 qrcode.js vendoring 到 `apps/portal/static/portal/vendor/`,页面不再依赖 jsdelivr,并把充值记录 / 点数记录改为 Django `Paginator` 分页;T-401 已新增 `adjust_wallet_points()` 手工调点服务、钱包 admin 专用调点表单与模板,后台可管理/检索用户、钱包、API Key(脱敏)、计费规则、汇率、充值订单、点数流水、注册赠点记录和调用记录,流水/订单/调用记录保持只读;T-402 已新增 `docs/mvp-acceptance.md`,按 P0 验收矩阵记录 MVP 完整验收结论、测试证据和已知限制;T-403 已新增 `docs/deployment.md` 与 `requirements-production.txt`,并在 settings 中补齐 `STATIC_ROOT`、`CSRF_TRUSTED_ORIGINS`、共享 `CACHES`、HTTPS cookie、proxy SSL、HSTS 环境变量与 `ACCOUNT_SIGNUP_RATE_LIMIT` 注册限流配置;T-601 已新增 `apps.ai.catalog.get_public_model_catalog()`、`GET /api/v1/models` 与 portal `/models` 只读页面,只展示 active 可调用别名、能力、是否需要原图和点数单价,不解密 provider key,不暴露底层 SKU / URL / key / `extra_body`;T-604 已新增 `apps.moderation`、`SensitiveWord` 模型/admin/迁移、keyword provider、归一化管线和共享 cache 版本失效,生成接口已改为 prompt 先审再读取图片/计费/扣点/调上游;T-606 已新增公开首页 `/`、`DownloadRelease` 模型/admin/迁移、首页 SSR 模板、共享 `portal/brand.css`,并把现有 portal 页面套入同一套品牌 token;T-607/T-609 已新增 `ClientLatestReleaseView` 与 `/api/v1/client/releases/latest`,公开匿名返回当前客户端版本 JSON,`release.force_update` 表示该版本是否强制升级;`portal.0002_downloadrelease_force_update` 已给 `DownloadRelease` 增加 `force_update` 字段,admin 可编辑和筛选;T-610 已新增 `ImportTemplate` 模型/admin/迁移 `portal.0003_importtemplate`,首页读取当前模板并在“下载客户端”旁展示“下载导入模板”,本地文件 URL 转为当前站点绝对 URL,`external_url` 优先;T-611 已把用户端 portal 可见品牌名统一为“虾皮圈”,包括页面标题、顶部导航、首页 H1、用户端“虾皮圈 API Key”文案和 allauth 邮件模板;T-612 已新增 `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS`,生图 Provider 上游请求和上游返回图片 URL 下载会按 `min(AiModel.timeout_seconds 或分辨率默认值, 硬截止)` 控制读取超时,超时返回 `upstream_timeout` 并走既有失败退点路径。 +- 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器);T-102 已新增 `AiModel` / `ModelAlias`、Fernet 加密密钥存储、别名解析、admin 配置页、`import_ai_models` 导入命令;T-103 已新增 `AiConfigAuditLog` 审计表、admin 只读页面和后台保存/删除审计 hook;T-104/T-105 已完成录制 title/image smoke 与审核修补;T-201 已新增 `UserWallet` / `ApiKey`、`PointsLedger` / `CallRecord`、对应 admin 与迁移;T-202 已新增 `PricingRule` / `ExchangeRate`、`apps.billing.pricing` 计费计算函数、admin 配置页与迁移;T-203 已新增 `apps.billing.services`,实现并发安全预扣、成功确认与幂等失败退点;T-204 已新增 `billing.0003_pointsledger_unique_ledger_change_type_per_call`,用 MySQL 可落地的 `ref_call + change_type` 复合唯一约束兜底防重复 refund;T-301 已新增 `apps.api.authentication.ApiKeyAuthentication` 与 `ExternalApiView`;T-302 已新增生成接口编排、序列化器、图片本地存储和 `/api/v1/generate/title|image` 路由;T-303 已新增 `apps.billing.services.get_balance_snapshot()` 与 `/api/v1/balance` 余额查询接口;T-304 已新增 `RechargeOrder`、充值回调验签适配器、幂等入账服务、微信/支付宝回调路由与迁移 `billing.0004_rechargeorder_and_more`;T-305 已新增 `create_recharge_order()`、微信/支付宝扫码下单 mock/SDK 入口、`/api/v1/recharge/create` 与 `/api/v1/recharge/status`;T-306 已新增 `apps.api.throttles`、`apps.api.exceptions`、`REST_FRAMEWORK` 安全默认认证、生成/认证失败限流、`image_url` SSRF 防护与响应大小上限、充值单笔金额上限;T-501/T-608 已接入 allauth 注册登录路径,注册成功后 adapter 调用 `grant_signup_bonus()` 经 billing 一次性发放 100 点并写 `signup_bonus` 流水,新增 `SignupBonusGrant(user UNIQUE)` 幂等标记、admin 只读检索和 `billing.0007` 迁移;T-502 已新增 `/apikeys`、API Key 创建表单、列表页和删除(吊销)动作,生成后明文只显示一次,列表只显示 prefix;T-503/T-608 已扩展 `/dashboard` 为个人中心汇总,并新增 `/records/recharge` 充值记录与 `/records/usage` 点数记录,只读展示当前用户数据和注册赠点 / 消费 / 退款流水;T-504 已新增 `/recharge` 页面、`RechargeCreateForm`、充值导航入口和轮询脚本,页面创建 pending 订单、展示二维码票据、轮询 `/api/v1/recharge/status`,订单 paid 后刷新余额;T-505 已把 Bootstrap 5 CSS 与 qrcode.js vendoring 到 `apps/portal/static/portal/vendor/`,页面不再依赖 jsdelivr,并把充值记录 / 点数记录改为 Django `Paginator` 分页;T-401 已新增 `adjust_wallet_points()` 手工调点服务、钱包 admin 专用调点表单与模板,后台可管理/检索用户、钱包、API Key(脱敏)、计费规则、汇率、充值订单、点数流水、注册赠点记录和调用记录,流水/订单/调用记录保持只读;T-402 已新增 `docs/mvp-acceptance.md`,按 P0 验收矩阵记录 MVP 完整验收结论、测试证据和已知限制;T-403 已新增 `docs/deployment.md` 与 `requirements-production.txt`,并在 settings 中补齐 `STATIC_ROOT`、`CSRF_TRUSTED_ORIGINS`、共享 `CACHES`、HTTPS cookie、proxy SSL、HSTS 环境变量与 `ACCOUNT_SIGNUP_RATE_LIMIT` 注册限流配置;T-601 已新增 `apps.ai.catalog.get_public_model_catalog()`、`GET /api/v1/models` 与 portal `/models` 只读页面,只展示 active 可调用别名、能力、是否需要原图和点数单价,不解密 provider key,不暴露底层 SKU / URL / key / `extra_body`;T-604 已新增 `apps.moderation`、`SensitiveWord` 模型/admin/迁移、keyword provider、归一化管线和共享 cache 版本失效,生成接口已改为 prompt 先审再读取图片/计费/扣点/调上游;T-606 已新增公开首页 `/`、`DownloadRelease` 模型/admin/迁移、首页 SSR 模板、共享 `portal/brand.css`,并把现有 portal 页面套入同一套品牌 token;T-607/T-609 已新增 `ClientLatestReleaseView` 与 `/api/v1/client/releases/latest`,公开匿名返回当前客户端版本 JSON,`release.force_update` 表示该版本是否强制升级;`portal.0002_downloadrelease_force_update` 已给 `DownloadRelease` 增加 `force_update` 字段,admin 可编辑和筛选;T-610 已新增 `ImportTemplate` 模型/admin/迁移 `portal.0003_importtemplate`,首页读取当前模板并在“下载客户端”旁展示“下载导入模板”,本地文件 URL 转为当前站点绝对 URL,`external_url` 优先;T-611 已把用户端 portal 可见品牌名统一为“虾皮圈”,包括页面标题、顶部导航、首页 H1、用户端“虾皮圈 API Key”文案和 allauth 邮件模板;T-612 已新增 `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS`,生图 Provider 上游请求和上游返回图片 URL 下载会按 `min(AiModel.timeout_seconds 或分辨率默认值, 硬截止)` 控制读取超时,超时返回 `upstream_timeout` 并走既有失败退点路径;T-613 已把旧同步生成链路抽成 `GenerationInput`、`prepare_generation()`、`precharge_generation()`、`execute_precharged_generation()` 与 `GenerationResult`,旧 view 只负责 serializer 和异常转 HTTP,后续异步 worker 可复用已预扣执行 / 确认 / 退点阶段。 - 用户端导航:顶部导航 active 状态已修复,`portal/base.html` 基于 `request.resolver_match.url_name` 高亮当前页面入口,并用 `aria-current="page"` 标记;「充值」不再在非充值页固定深色高亮。 +- 最新验证:T-613 抽生成核心 service 已验证 `.\init.ps1` 开工前通过;`py -3.12 -m py_compile apps\api\generation.py apps\api\storage.py apps\api\views.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;T-613 新增核心 service 目标测试 2 tests OK;`py -3.12 manage.py test apps.api.tests.GenerateApiTests --keepdb --noinput --verbosity 1` 通过,19 tests OK;`.\init.ps1` 收尾通过;`git diff --check` 通过,仅 Windows CRLF 提示。曾尝试 `py -3.12 manage.py test apps.api --keepdb --noinput --verbosity 1`,命令 484 秒超时无断言结果;拆跑非生成 API 测试类时在测试库 setup 阶段出现远程 MySQL `43.128.3.240` 连接超时 `OperationalError(2003)`,期间 `Test-NetConnection 43.128.3.240 -Port 3306` 显示端口可达。测试期仍保留 allauth 在 MySQL 条件唯一约束上的既有 `models.W036` 警告。 - 最新验证:T-612 生图同步接口止血已验证 `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;生图 Provider 目标测试 3 tests OK,确认生图上游请求与返回图片 URL 下载会被 `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS` 截断,文本生成不受该硬截止影响;`GenerateApiTests.test_image_upstream_timeout_refunds_precharged_points_and_marks_call_failed` 通过;`GenerateApiTests` 整组 17 tests OK;`.\init.ps1` 收尾通过;`git diff --check` 通过,仅 Windows CRLF 提示。测试期仍保留 allauth 在 MySQL 条件唯一约束上的既有 `models.W036` 警告。 - 最新验证:T-611 用户端品牌名统一为虾皮圈已验证 `py -3.12 manage.py check` 通过,0 issues;`py -3.12 manage.py makemigrations --check --dry-run` 通过,No changes detected;`git diff --check` 通过,仅 Windows CRLF 提示;`py -3.12 manage.py test apps.portal.tests.PortalAccountFlowTests.test_homepage_is_public_and_shows_anonymous_onboarding_without_release apps.portal.tests.PortalAccountFlowTests.test_homepage_shows_current_download_release apps.portal.tests.PortalAccountFlowTests.test_portal_pages_use_shopee_circle_branding --keepdb --noinput --verbosity 2` 通过,3 tests OK。测试期仍保留 allauth 在 MySQL 条件唯一约束上的既有 `models.W036` 警告。 - 测试:T-601 已验证:`.\init.ps1` 通过(Python 3.12.3,依赖已满足,`manage.py check` 0 issues,打印启动命令);`py -3.12 -m py_compile apps\ai\catalog.py apps\api\views.py apps\api\urls.py apps\api\tests.py apps\portal\views.py apps\portal\urls.py apps\portal\tests.py` 通过;`py -3.12 manage.py check` 通过;`py -3.12 manage.py makemigrations --check --dry-run` 无变化;`git diff --check` 通过;`py -3.12 manage.py test apps.api.tests.ModelsCatalogApiTests apps.portal.tests.PortalAccountFlowTests.test_models_page_requires_session_login apps.portal.tests.PortalAccountFlowTests.test_models_page_lists_public_aliases_prices_and_unpriced_state --keepdb --noinput --verbosity 2` 首次运行时 3 条 API 测试已通过,portal `setUpClass` 阶段因远程 MySQL 连接 43.128.3.240 超时中断;随后 `Test-NetConnection 43.128.3.240 -Port 3306` 显示 `TcpTestSucceeded=True`,单独重跑两条 portal 测试通过,2 tests OK。T-403 已验证:`./init.ps1` 开工前通过;`py -3.12 manage.py check` 通过;`py -3.12 manage.py makemigrations --check --dry-run` 无变化;`py -3.12 manage.py findstatic portal/vendor/bootstrap/bootstrap.min.css portal/vendor/qrcode/qrcode.js --verbosity 1` 找到两个本地 static 文件;`py -3.12 manage.py check --deploy` 在当前开发 `.env` 下仅报预期安全配置警告,临时注入生产型安全环境变量(含 HSTS includeSubDomains/preload)后通过,0 issues;`py -3.12 manage.py collectstatic --dry-run --noinput` 通过,预期收集 169 个 static 文件;`py -3.12 manage.py createcachetable --dry-run cmhub_cache` 通过,输出 MySQL cache 表 DDL;`py -3.12 -m compileall config` 通过;尝试 `py -3.12 manage.py test apps.api --noinput --keepdb --verbosity 2` 时 25 条用例已通过,`GenerateApiTests` 14 条因远程 MySQL 连接重置/事务中断被记 ERROR,随后 `Test-NetConnection 43.128.3.240 -Port 3306` 端口可达,单独重跑 `py -3.12 manage.py test apps.api.tests.GenerateApiTests --noinput --keepdb --verbosity 2` 通过,14 tests OK。测试/迁移阶段仍有 allauth `account.EmailAddress` 条件唯一约束在 MySQL 上不可创建的 `models.W036` 警告;本项目用户账本邮箱唯一性由 `user.email` 唯一约束承担。 @@ -35,7 +36,7 @@ - 标准验证路径:Windows 用 `py -3.12 manage.py check` / `py -3.12 manage.py test` - 设计基线:**自助用户端 + 对外 API + 运营后台**三合一单体;用户模型 `User`(auth)/`UserWallet`(点数,锁 wallet 扣点)/`ApiKey`(1:N,哈希存储);对外两接口 + **能力别名 + Provider 适配器**(可插拔供应商);自助扫码充值;新用户注册成功一次性赠送 100 点试用点数,必须经 billing 写 `signup_bonus` 流水,且通过 `SignupBonusGrant(user UNIQUE)` 防重复发放。详见 `04-architecture.md` 与 2026-07-08 的 `progress.md` 决策 - 配置基线:运行环境变量集中见 `docs/env.md`;真实密钥/支付凭证不得写入代码或文档样例。用户端注册策略固定为 `ACCOUNT_EMAIL_VERIFICATION="none"`:免邮箱验证、注册即可用、邮箱仍必填且唯一;`DJANGO_EMAIL_BACKEND` / `DJANGO_DEFAULT_FROM_EMAIL` 仅用于后续密码找回、通知或恢复邮箱验证等邮件能力,不作为当前注册登录前置条件。充值订单在创建时锁定汇率与预计点数,回调入账使用订单值,不按新汇率重算。支付回调与下单由 `PAYMENT_CALLBACK_MODE` 控制:本地/测试可用 HMAC `mock`,生产应为 `sdk`;二维码本地有效期提示由 `PAYMENT_QR_EXPIRES_MINUTES` 控制。T-306 起对外 API 安全配置包含 `API_GENERATE_THROTTLE_RATE`、`API_AUTH_FAILURE_THROTTLE_RATE`、`IMAGE_URL_MAX_BYTES`、`IMAGE_URL_MAX_REDIRECTS`、`IMAGE_URL_CONNECT_TIMEOUT_SECONDS`、`IMAGE_URL_READ_TIMEOUT_SECONDS`、`RECHARGE_MAX_AMOUNT_CNY`。T-403 起生产静态、共享 cache 与 HTTPS 安全配置包含 `STATIC_URL`、`STATIC_ROOT`、`DJANGO_CACHE_BACKEND`、`DJANGO_CACHE_LOCATION`、`DJANGO_CSRF_TRUSTED_ORIGINS`、`DJANGO_SESSION_COOKIE_SECURE`、`DJANGO_CSRF_COOKIE_SECURE`、`DJANGO_SECURE_SSL_REDIRECT`、`DJANGO_SECURE_PROXY_SSL_HEADER`、`DJANGO_SECURE_HSTS_SECONDS`。T-604 起内容安全配置包含 `MODERATION_ENABLED`、`MODERATION_PROVIDER=keyword`、`MODERATION_FAIL_CLOSED`、`MODERATION_BLOCK_ON_REVIEW`、`MODERATION_CACHE_VERSION_KEY`;生产多 worker 下必须使用共享 cache 承载敏感词版本号。T-612 起生图同步接口止血配置包含 `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS`,只作用于 `generate_image` 上游读取硬截止。 -- 当前 blocker:微信正式下单已能返回二维码,但线上微信回调曾出现 `PaymentVerificationError`,仍需单独修复并完成“付款后自动入账”闭环验收;支付宝恢复依赖开放平台把 `43.128.3.240` 加入可信 IP。线上真实标题生成已跑通并验证扣点;图片同步旧接口已由 T-612 加上游硬截止止血,但真实图片耗时和异步任务化仍需按 T-613~T-615 继续推进。 +- 当前 blocker:微信正式下单已能返回二维码,但线上微信回调曾出现 `PaymentVerificationError`,仍需单独修复并完成“付款后自动入账”闭环验收;支付宝恢复依赖开放平台把 `43.128.3.240` 加入可信 IP。线上真实标题生成已跑通并验证扣点;图片同步旧接口已由 T-612 加上游硬截止止血,生成核心已由 T-613 抽出,真实图片耗时和异步任务化仍需按 T-614~T-615 继续推进。 ## 当前目录要点 @@ -55,11 +56,11 @@ 任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史执行记录见 [`../progress.md`](../progress.md)。 -- 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-103 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补;T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型;T-202 PricingRule / ExchangeRate 模型 + 计费计算;T-203 并发安全扣点 / 退点;T-204 Phase 2 计费核心审核加固;T-301 API Key 鉴权;T-302 生成标题 / 图片接口;T-303 余额查询接口;T-304 充值回调;T-305 扫码充值下单 + 轮询;T-306 Phase 3 对外 API 安全加固;T-501 注册 / 登录(allauth);T-502 API Key 自助管理页;T-503 个人中心 / 记录页;T-504 充值页(扫码 + 轮询到账);T-505 Phase 4 用户端审核优化;T-401 运营后台完善;T-402 完整验收 MVP;T-403 部署 / 运行文档;T-601 可用别名发现;T-602 django-admin 中文化(第 1-3 层);T-603 django-admin 中文化(第 4 层·字段级);T-604 中文敏感词本地过滤;T-605 免邮箱验证策略落地;T-606 公开首页 + 客户端下载入口;T-607 桌面端最新版本检查接口;T-608 新用户注册赠送 100 点试用点数;T-609 桌面端版本检查接口增加强制更新标记;T-610 首页导入模板下载入口;T-611 用户端品牌名统一为虾皮圈;T-612 生图同步接口止血(上游硬截止 + 长请求池校准)。 +- 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-103 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补;T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型;T-202 PricingRule / ExchangeRate 模型 + 计费计算;T-203 并发安全扣点 / 退点;T-204 Phase 2 计费核心审核加固;T-301 API Key 鉴权;T-302 生成标题 / 图片接口;T-303 余额查询接口;T-304 充值回调;T-305 扫码充值下单 + 轮询;T-306 Phase 3 对外 API 安全加固;T-501 注册 / 登录(allauth);T-502 API Key 自助管理页;T-503 个人中心 / 记录页;T-504 充值页(扫码 + 轮询到账);T-505 Phase 4 用户端审核优化;T-401 运营后台完善;T-402 完整验收 MVP;T-403 部署 / 运行文档;T-601 可用别名发现;T-602 django-admin 中文化(第 1-3 层);T-603 django-admin 中文化(第 4 层·字段级);T-604 中文敏感词本地过滤;T-605 免邮箱验证策略落地;T-606 公开首页 + 客户端下载入口;T-607 桌面端最新版本检查接口;T-608 新用户注册赠送 100 点试用点数;T-609 桌面端版本检查接口增加强制更新标记;T-610 首页导入模板下载入口;T-611 用户端品牌名统一为虾皮圈;T-612 生图同步接口止血(上游硬截止 + 长请求池校准);T-613 抽生成核心 service(计费+审核+上游共享 core)。 - 正在进行:无。 -- 待开始:T-613 抽生成核心 service、T-614 生图异步任务化接口、T-615 旧同步生图接口用量遥测 + 弃用口径。 +- 待开始:T-614 生图异步任务化接口、T-615 旧同步生图接口用量遥测 + 弃用口径。 - 当前 blocker:支付商户真实密钥/证书与生产 SDK 依赖仍待提供;微信回调到账闭环仍需真实支付验收;真实 AI 标题生成已在线上跑通,图片生成慢 / 504 / 客户端超时风险已拆为 T-612~T-615 分阶段处理。 -- 下一个可领取任务:T-613 抽生成核心 service(计费+审核+上游共享 core)。 +- 下一个可领取任务:T-614 生图异步任务化接口(提交+轮询,新增不动旧接口)。 ## 当前可运行内容 @@ -117,15 +118,15 @@ gunicorn config.wsgi:application --bind 127.0.0.1:8002 --workers 1 --worker-clas - `GET /records/recharge` - `GET /records/usage` -当前骨架可运行。T-002 已在首次迁移前创建自定义 User,并按 `env.md` 接入 MySQL 8.4 / utf8mb4;远程 MySQL 已完成 Django 初始迁移。T-003 已接通 django-admin,测试可创建/销毁 `test_cmhub` 测试库;当前远程 MySQL 对频繁建库/销库仍可能间歇超时,必要时用 `--keepdb` 且串行跑测试。T-004 已应用 `users.0002_alter_user_email`,`user.email` 已有唯一索引。T-101 的 AI provider 层只做 HTTP 调用与响应解析;T-102 已把 provider 运行配置接到数据库 `AiModel` / `ModelAlias`,`resolve_alias()` 每次查当前 active 配置并按 `text` / `image` 能力校验。T-103 已补 `AiConfigAuditLog`,admin 保存/删除 `AiModel` / `ModelAlias` 时记录 actor、action、target、changed_fields、changes、created_at,密钥只记录 empty/set 状态。T-104/T-105 已用临时回滚配置跑通录制标题和录制图片生成。T-201 已落地钱包、API Key、点数流水和调用记录:API Key 明文只在创建 helper 返回,库内只存 hash/prefix;CallRecord 只存 `result_ref`/`result_summary`,没有 provider raw 字段。T-202 已落地 `PricingRule` / `ExchangeRate`:计费按 `operation_type + alias + resolution` 查 active 规则,优先精确分辨率,再回退默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`;金额换点数按当前 active 汇率向下取整。T-203 已落地 `precharge_call()` / `mark_call_success()` / `refund_call_points()`:预扣锁钱包行,余额不足不写调用/流水;失败退点锁调用记录并幂等写 refund 流水。T-204 已完成复合唯一约束加固,并取得一次完整 `manage.py test` 单次全绿。T-301 已落地 `Authorization: Bearer ` 鉴权:成功后 `request.user` 为所属用户、`request.auth` 为 `ApiKey`,缺失/无效 Key 返回 401,用户或 Key 禁用返回 403,外部 API 不接受 Web session。T-302 已落地生成接口:请求别名解析后按规则计费,预扣成功才调用 Provider,成功确认调用记录,`AiProviderError` / `AiCapabilityError` 等失败路径会退点;图片结果保存到本地 media 并返回 URL。T-303 已落地余额查询接口:`GET /api/v1/balance` 继承外部 API Key 鉴权,读取 billing 余额快照并返回兼容字段 `user` / `points_balance`,以及不含邮箱的 `account.username` / `account.display_name`,测试覆盖余额与流水累加一致。T-304 已落地充值回调:`RechargeOrder` 保存下单锁定的金额/汇率/点数,微信/支付宝回调先验签再按订单幂等入账,重复回调不重复加点,金额不一致不入账;主动查单兜底可调用 `query_and_apply_recharge_payment(order_no, query_func)` 复用同一入账路径。T-305 已落地扫码下单与轮询:用户端 session 登录后可 `POST /api/v1/recharge/create` 创建 pending 订单并拿到 mock/SDK 二维码票据,`GET /api/v1/recharge/status` 只返回本人订单并在 pending 时尝试主动查单补入账;API Key 不能调用这两个用户端接口。T-306 已落地对外 API 安全加固:`image_url` 下载在扣点前做协议白名单、公网地址校验、重定向逐跳校验和响应大小上限;DRF 全局默认不再隐式启用 Session/Basic;生成接口按 Key 限流,认证失败按 IP 限流;充值下单有单笔金额上限。T-501/T-605/T-608 已落地 allauth 注册 / 登录:`ACCOUNT_EMAIL_VERIFICATION="none"`,免邮箱验证、注册即可用,邮箱仍必填且唯一;注册成功经 billing 发放 100 点并写 `signup_bonus` 流水,重复调用或并发触发由 `SignupBonusGrant` 幂等标记兜底,注册限流由 allauth signup rate limit 执行。T-502 已落地 API Key 自助管理:`/apikeys` 登录访问,生成后完整明文只显示一次,列表只显示 prefix,不显示 hash 或历史明文;删除为吊销 `revoked`,吊销后外部 API 返回 403。T-503/T-608 已落地个人中心与记录页:`/dashboard` 展示剩余点数、充值总额、获得点数、净消耗点数和最近记录;`/records/recharge` 展示当前用户充值订单;`/records/usage` 展示当前用户 signup_bonus/consume/refund 点数流水并关联调用信息;所有页面均只读且只查本人。T-504 已落地充值页:`/recharge` GET 展示余额、充值表单、当前订单和最近充值,POST 创建 pending 订单并展示二维码票据,浏览器轮询 `/api/v1/recharge/status`,paid 后刷新页面重新读取余额;页面不直接写钱包或流水。T-401 已落地运营后台完善:用户列表显示钱包余额,钱包余额只读且通过专用表单手工调点,调点必须填原因、非 0、不得扣成负数,并经 `adjust_wallet_points()` 锁钱包写 `PointsLedger(adjust)`;API Key admin 只展示 prefix 和 hash 摘要,不回显明文或完整 hash;订单、流水、注册赠点记录、调用记录继续只读并增强检索。T-402 已完成 MVP P0 验收并新增 `docs/mvp-acceptance.md`。T-403 已完成部署 / 运行文档,生产按 `deployment.md` 执行,并已补 settings 对生产静态目录、共享 cache、CSRF trusted origins、HTTPS cookie/proxy/HSTS 与注册限流的环境变量支持。T-601 已落地可用别名发现:`GET /api/v1/models` 用 API Key 鉴权返回公开别名目录,`/models` 用 session 展示只读「可用模型」页;两者均不解密 provider key,不输出底层 SKU、URL、key 或 `extra_body`。T-603 已完成 django-admin 字段级中文化并人工确认字段标签中文。T-604 已落地本地 prompt 敏感词过滤:`MODERATION_ENABLED=false` 默认 no-op,启用 `keyword` 后命中返回 `content_blocked`,并在下载 `image_url`、解析别名、计费、预扣点和上游调用前拦截。T-606 已落地公开首页:`GET /` 匿名 200、不跳登录;下载区读取 Windows 当前 `DownloadRelease`,优先 `external_url`,无当前版本显示「暂未发布」;现有 portal 页面已共用 `brand.css` 品牌 token。T-607/T-609 已落地公开版本检查接口:`GET /api/v1/client/releases/latest` 匿名 200,无需 API Key;支持 `windows`/`macos`/`linux`,返回当前版本 JSON 或 `release:null`,当前版本响应包含 `release.force_update`。真实标题上游生成已在线上跑通并验证扣点;图片生成真实耗时仍需补测。 +当前骨架可运行。T-002 已在首次迁移前创建自定义 User,并按 `env.md` 接入 MySQL 8.4 / utf8mb4;远程 MySQL 已完成 Django 初始迁移。T-003 已接通 django-admin,测试可创建/销毁 `test_cmhub` 测试库;当前远程 MySQL 对频繁建库/销库仍可能间歇超时,必要时用 `--keepdb` 且串行跑测试。T-004 已应用 `users.0002_alter_user_email`,`user.email` 已有唯一索引。T-101 的 AI provider 层只做 HTTP 调用与响应解析;T-102 已把 provider 运行配置接到数据库 `AiModel` / `ModelAlias`,`resolve_alias()` 每次查当前 active 配置并按 `text` / `image` 能力校验。T-103 已补 `AiConfigAuditLog`,admin 保存/删除 `AiModel` / `ModelAlias` 时记录 actor、action、target、changed_fields、changes、created_at,密钥只记录 empty/set 状态。T-104/T-105 已用临时回滚配置跑通录制标题和录制图片生成。T-201 已落地钱包、API Key、点数流水和调用记录:API Key 明文只在创建 helper 返回,库内只存 hash/prefix;CallRecord 只存 `result_ref`/`result_summary`,没有 provider raw 字段。T-202 已落地 `PricingRule` / `ExchangeRate`:计费按 `operation_type + alias + resolution` 查 active 规则,优先精确分辨率,再回退默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`;金额换点数按当前 active 汇率向下取整。T-203 已落地 `precharge_call()` / `mark_call_success()` / `refund_call_points()`:预扣锁钱包行,余额不足不写调用/流水;失败退点锁调用记录并幂等写 refund 流水。T-204 已完成复合唯一约束加固,并取得一次完整 `manage.py test` 单次全绿。T-301 已落地 `Authorization: Bearer ` 鉴权:成功后 `request.user` 为所属用户、`request.auth` 为 `ApiKey`,缺失/无效 Key 返回 401,用户或 Key 禁用返回 403,外部 API 不接受 Web session。T-302/T-613 已落地生成接口与共享核心:旧同步接口仍保持原响应契约;`prepare_generation()` 执行 prompt 审核、图片输入处理、别名解析、Provider 选择和计费计算;`precharge_generation()` 调 billing 预扣;`execute_precharged_generation()` 复用已预扣 `CallRecord` 调上游并成功确认或失败退点;图片结果保存到本地 media 并通过 URL 构建器返回外部 URL。T-303 已落地余额查询接口:`GET /api/v1/balance` 继承外部 API Key 鉴权,读取 billing 余额快照并返回兼容字段 `user` / `points_balance`,以及不含邮箱的 `account.username` / `account.display_name`,测试覆盖余额与流水累加一致。T-304 已落地充值回调:`RechargeOrder` 保存下单锁定的金额/汇率/点数,微信/支付宝回调先验签再按订单幂等入账,重复回调不重复加点,金额不一致不入账;主动查单兜底可调用 `query_and_apply_recharge_payment(order_no, query_func)` 复用同一入账路径。T-305 已落地扫码下单与轮询:用户端 session 登录后可 `POST /api/v1/recharge/create` 创建 pending 订单并拿到 mock/SDK 二维码票据,`GET /api/v1/recharge/status` 只返回本人订单并在 pending 时尝试主动查单补入账;API Key 不能调用这两个用户端接口。T-306 已落地对外 API 安全加固:`image_url` 下载在扣点前做协议白名单、公网地址校验、重定向逐跳校验和响应大小上限;DRF 全局默认不再隐式启用 Session/Basic;生成接口按 Key 限流,认证失败按 IP 限流;充值下单有单笔金额上限。T-501/T-605/T-608 已落地 allauth 注册 / 登录:`ACCOUNT_EMAIL_VERIFICATION="none"`,免邮箱验证、注册即可用,邮箱仍必填且唯一;注册成功经 billing 发放 100 点并写 `signup_bonus` 流水,重复调用或并发触发由 `SignupBonusGrant` 幂等标记兜底,注册限流由 allauth signup rate limit 执行。T-502 已落地 API Key 自助管理:`/apikeys` 登录访问,生成后完整明文只显示一次,列表只显示 prefix,不显示 hash 或历史明文;删除为吊销 `revoked`,吊销后外部 API 返回 403。T-503/T-608 已落地个人中心与记录页:`/dashboard` 展示剩余点数、充值总额、获得点数、净消耗点数和最近记录;`/records/recharge` 展示当前用户充值订单;`/records/usage` 展示当前用户 signup_bonus/consume/refund 点数流水并关联调用信息;所有页面均只读且只查本人。T-504 已落地充值页:`/recharge` GET 展示余额、充值表单、当前订单和最近充值,POST 创建 pending 订单并展示二维码票据,浏览器轮询 `/api/v1/recharge/status`,paid 后刷新页面重新读取余额;页面不直接写钱包或流水。T-401 已落地运营后台完善:用户列表显示钱包余额,钱包余额只读且通过专用表单手工调点,调点必须填原因、非 0、不得扣成负数,并经 `adjust_wallet_points()` 锁钱包写 `PointsLedger(adjust)`;API Key admin 只展示 prefix 和 hash 摘要,不回显明文或完整 hash;订单、流水、注册赠点记录、调用记录继续只读并增强检索。T-402 已完成 MVP P0 验收并新增 `docs/mvp-acceptance.md`。T-403 已完成部署 / 运行文档,生产按 `deployment.md` 执行,并已补 settings 对生产静态目录、共享 cache、CSRF trusted origins、HTTPS cookie/proxy/HSTS 与注册限流的环境变量支持。T-601 已落地可用别名发现:`GET /api/v1/models` 用 API Key 鉴权返回公开别名目录,`/models` 用 session 展示只读「可用模型」页;两者均不解密 provider key,不输出底层 SKU、URL、key 或 `extra_body`。T-603 已完成 django-admin 字段级中文化并人工确认字段标签中文。T-604 已落地本地 prompt 敏感词过滤:`MODERATION_ENABLED=false` 默认 no-op,启用 `keyword` 后命中返回 `content_blocked`,并在下载 `image_url`、解析别名、计费、预扣点和上游调用前拦截。T-606 已落地公开首页:`GET /` 匿名 200、不跳登录;下载区读取 Windows 当前 `DownloadRelease`,优先 `external_url`,无当前版本显示「暂未发布」;现有 portal 页面已共用 `brand.css` 品牌 token。T-607/T-609 已落地公开版本检查接口:`GET /api/v1/client/releases/latest` 匿名 200,无需 API Key;支持 `windows`/`macos`/`linux`,返回当前版本 JSON 或 `release:null`,当前版本响应包含 `release.force_update`。真实标题上游生成已在线上跑通并验证扣点;图片生成真实耗时仍需补测。 ## 开始编码前检查 1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。 2. 读 `docs/00-ai-start-here.md`。 3. 读 `docs/05-coding-rules.md`(尤其第 8 节资金安全)。 -4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务;当前为 T-613。 -5. 下一步优先处理 T-613,抽生成核心 service,为 T-614 生图异步任务化复用同一套审核 / 计费 / 上游 / 退点逻辑;真实支付回调到账闭环和客户端下载包发布继续作为后续业务事项排期。 +4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务;当前为 T-614。 +5. 下一步优先处理 T-614,新增生图异步提交 / 轮询接口,并复用 T-613 的审核 / 计费 / 上游 / 退点核心阶段;真实支付回调到账闭环和客户端下载包发布继续作为后续业务事项排期。 ## 维护规则 diff --git a/progress.md b/progress.md index b3adb2e..522f5f9 100644 --- a/progress.md +++ b/progress.md @@ -1674,3 +1674,32 @@ - 已知测试环境现象:测试期仍保留 allauth `account.EmailAddress` 条件唯一约束在 MySQL 上不可创建的既有 `models.W036` 警告。 - 决策:T-612 只给旧同步生图接口加硬截止和明确错误码,不引入 gevent,不改变文本生成超时口径;真正解决长耗时与客户端轮询体验仍放到 T-613~T-615。 - 下一步:领取 T-613,抽生成核心 service,为 T-614 生图异步任务化复用同一套审核、计费、上游和退点逻辑。 + +## 2026-07-08 实施:T-613 抽生成核心 service + +- 状态:DONE。 +- 代码变更: + - `apps/api/generation.py`:把旧同步生成编排抽成 `GenerationInput`、`PreparedGeneration`、`PrechargedGeneration`、`GenerationResult` 与三段核心函数:`prepare_generation()`、`precharge_generation()`、`execute_precharged_generation()`;旧 `generate_title_response()` / `generate_image_response()` 保留为兼容薄包装。 + - `apps/api/generation.py`:`prepare_generation()` 负责 prompt 审核、图片输入处理、别名解析、Provider 选择和计费计算;`precharge_generation()` 只调用 billing 的 `precharge_call()`;`execute_precharged_generation()` 复用已预扣 `CallRecord` 调上游,并通过既有 `mark_call_success()` / `refund_call_points()` 完成成功确认或失败退点。 + - `apps/api/storage.py`:`save_generated_image()` 新增 `url_builder` 参数,核心 service 可生成外部 URL 而不依赖 DRF `Request`;保留旧 `request` 参数兼容。 + - `apps/api/views.py`:`GenerateImageView` 只把 `request.build_absolute_uri` 作为 URL 构建器传给核心 service,不再把整个 request 传入生成核心。 + - `apps/api/tests.py`:新增核心 service 目标测试,覆盖无 DRF request 构建图片 URL,以及已预扣执行阶段上游失败后退点。 +- 文档变更: + - `docs/06-tasks.md`:T-613 标记为 DONE,下一个任务为 T-614。 + - `docs/04-architecture.md`、`docs/api.md`:同步生成核心 service 的内部阶段与“后续异步 worker 必须复用同一套资金逻辑”的口径。 + - `README.md`、`docs/00-ai-start-here.md`、`docs/current-state.md`:同步 Phase 6 当前状态、验证证据和下一步。 +- 验证: + - `.\init.ps1`:开工前通过。 + - `py -3.12 -m py_compile apps\api\generation.py apps\api\storage.py apps\api\views.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.api.tests.GenerateApiTests.test_generation_core_saves_image_with_url_builder_without_request apps.api.tests.GenerateApiTests.test_precharged_generation_stage_refunds_on_upstream_failure --keepdb --noinput --verbosity 2`:通过,2 tests OK。 + - `py -3.12 manage.py test apps.api.tests.GenerateApiTests --keepdb --noinput --verbosity 1`:通过,19 tests OK。 + - `.\init.ps1`:收尾验证通过(Python 3.12.3,依赖已满足,`manage.py check` 0 issues,打印启动命令)。 + - `git diff --check`:通过,仅 Windows CRLF 提示。 +- 测试环境现象: + - 首次运行新增目标测试时,测试库 setup 阶段远程 MySQL `43.128.3.240` 连接超时;`Test-NetConnection 43.128.3.240 -Port 3306` 随后显示 `TcpTestSucceeded=True`,重跑通过。 + - 曾尝试 `py -3.12 manage.py test apps.api --keepdb --noinput --verbosity 1`,命令 484 秒超时无断言结果;拆跑非生成 API 测试类时仍在测试库 setup 阶段出现 `OperationalError(2003)`。本轮直接影响的 `GenerateApiTests` 已完整通过。 + - 测试期仍保留 allauth `account.EmailAddress` 条件唯一约束在 MySQL 上不可创建的既有 `models.W036` 警告。 +- 决策:T-613 不改旧同步接口成功 / 失败响应契约,不新增异步任务表或新路由;只把生成 pipeline 拆成后续 T-614 可复用的准备、预扣、已预扣执行三个阶段。 +- 下一步:领取 T-614,新增生图异步提交 / 轮询接口,并复用 T-613 的核心阶段。