diff --git a/.env.example b/.env.example index 33ad8a1..706e0ec 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,12 @@ MYSQL_WRITE_TIMEOUT=120 # AI key encryption AI_KEY_ENCRYPTION_KEY=base64-fernet-key AI_IMAGE_UPSTREAM_DEADLINE_SECONDS=180 +PUBLIC_BASE_URL= +MEDIA_PUBLIC_BASE_URL= +IMAGE_TASK_RETENTION_HOURS=24 +GENERATED_IMAGE_RETENTION_HOURS=72 +IMAGE_TASK_REAPER_INTERVAL_SECONDS=60 +IMAGE_TASK_LEASE_SECONDS=600 # API safety API_GENERATE_THROTTLE_RATE=60/min diff --git a/README.md b/README.md index dd1d4f1..a5953f1 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ## 它做什么 - 用户端(自助):公开首页、注册/登录、扫码充值、查看充值记录/剩余点数/消费记录、查看可用模型、生成与删除 API Key、下载桌面端(新用户注册成功赠送 100 点试用点数)。 -- 对外提供 HTTP API:生成标题、生成图片(同步返回)、查询点数余额、查询可用能力别名。 +- 对外提供 HTTP API:生成标题、生成图片(旧同步接口 + 新异步提交/轮询接口)、查询点数余额、查询可用能力别名。 - 按「操作类型 + 能力别名(+ 可选分辨率)」计费,调用消耗不同点数;余额不足返回「点数不足,请先充值」。 - **预付费点数模型**:用户在外部支付系统充值,付款成功由支付系统回调本服务,按汇率把金额转成点数存在本地;之后调用直接扣本地点数,扣费与上游解耦、低延迟。 - django-admin 运营后台:管理注册用户、点数余额、计费规则、充值订单、点数流水、调用记录。 @@ -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 生图同步接口止血与 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)。 +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 与 T-614 生图异步任务化接口。用户可通过公开首页进入注册、登录、下载客户端和下载导入模板;新用户注册后经计费层自动获得 100 点并写注册赠点流水;登录后可扫码充值并轮询到账,生成 / 删除(吊销)API Key,查看余额、充值总额、分页充值记录、分页点数记录与可用模型;桌面端可匿名请求最新客户端版本 JSON,并读取 `release.force_update` 判断是否必须升级;新版桌面端可用异步生图提交 / 轮询接口,旧同步生图接口继续兼容;运营可在 django-admin 检索用户、钱包、API Key、计费规则、汇率、充值订单、点数流水、注册赠点记录、调用记录、图片生成任务、客户端发布版本和导入模板,并通过计费层带原因手工调点。下一步为 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/admin.py b/apps/api/admin.py index 8c38f3f..144a630 100644 --- a/apps/api/admin.py +++ b/apps/api/admin.py @@ -1,3 +1,47 @@ from django.contrib import admin -# Register your models here. +from .models import ImageGenerationTask + + +@admin.register(ImageGenerationTask) +class ImageGenerationTaskAdmin(admin.ModelAdmin): + list_display = ( + "task_id", + "user", + "status", + "attempt_count", + "points_balance_after_charge", + "created_at", + "finished_at", + ) + list_filter = ("status", "created_at", "finished_at") + search_fields = ( + "task_id", + "user__username", + "api_key__key_prefix", + "call_record__id", + "idempotency_key", + "error_code", + ) + readonly_fields = ( + "task_id", + "user", + "api_key", + "call_record", + "idempotency_key_hash", + "request_hash", + "request_payload", + "input_image", + "result_url", + "points_balance_after_charge", + "started_at", + "finished_at", + "expires_at", + "locked_at", + "lease_expires_at", + "heartbeat_at", + "worker_id", + "attempt_count", + "created_at", + "updated_at", + ) diff --git a/apps/api/image_tasks.py b/apps/api/image_tasks.py new file mode 100644 index 0000000..f38887c --- /dev/null +++ b/apps/api/image_tasks.py @@ -0,0 +1,581 @@ +from __future__ import annotations + +import hashlib +import json +import socket +from dataclasses import replace +from datetime import timedelta +from typing import Any, Mapping + +from django.conf import settings +from django.core.files.base import ContentFile +from django.db import IntegrityError, connection, transaction +from django.utils import timezone +from rest_framework import status + +from apps.billing.models import CallRecord +from apps.billing.services import InvalidCallStateError, refund_call_points + +from .generation import ( + ApiRequestError, + GenerationInput, + ImageInput, + PrechargedGeneration, + execute_precharged_generation, + prepare_generation, + precharge_generation, +) +from .models import ImageGenerationTask + + +IDEMPOTENCY_KEY_MAX_LENGTH = 128 +TASK_FAILURE_MESSAGE = "图片生成失败,已退回点数" +TASK_TIMEOUT_MESSAGE = "图片生成任务超时,已退回点数" + + +def create_image_generation_task( + *, + user, + api_key, + request_data: Mapping[str, Any], + idempotency_key: str = "", +) -> tuple[ImageGenerationTask, bool]: + normalized_key = normalize_idempotency_key(idempotency_key) + idempotency_key_hash = hash_text(normalized_key) if normalized_key else None + request_hash = request_hash_for_image_request(request_data) + + existing = find_idempotent_task(api_key, idempotency_key_hash) + if existing is not None: + return idempotent_task_or_raise(existing, request_hash), False + + prepared = prepare_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", + ) + ) + + try: + with transaction.atomic(): + existing = find_idempotent_task( + api_key, + idempotency_key_hash, + for_update=True, + ) + if existing is not None: + return idempotent_task_or_raise(existing, request_hash), False + + precharged = precharge_generation(prepared) + now = timezone.now() + task = ImageGenerationTask.objects.create( + user=user, + api_key=api_key, + call_record=precharged.call_record, + idempotency_key=normalized_key, + idempotency_key_hash=idempotency_key_hash, + request_hash=request_hash, + request_payload=task_request_payload( + request_data=request_data, + image_input=prepared.image_input, + ), + points_balance_after_charge=precharged.points_balance_after_charge, + expires_at=now + timedelta(hours=image_task_retention_hours()), + ) + store_task_input_image(task, prepared.image_input) + return task, True + except IntegrityError: + if idempotency_key_hash: + existing = find_idempotent_task(api_key, idempotency_key_hash) + if existing is not None: + return idempotent_task_or_raise(existing, request_hash), False + raise + + +def find_idempotent_task(api_key, idempotency_key_hash: str | None, *, for_update=False): + if not idempotency_key_hash: + return None + queryset = ImageGenerationTask.objects.select_related("call_record").filter( + api_key=api_key, + idempotency_key_hash=idempotency_key_hash, + ) + if for_update: + queryset = queryset.select_for_update() + return queryset.order_by("id").first() + + +def idempotent_task_or_raise( + task: ImageGenerationTask, + request_hash: str, +) -> ImageGenerationTask: + if task.request_hash != request_hash: + raise ApiRequestError( + "idempotency_conflict", + "Idempotency-Key 已用于不同请求", + status.HTTP_409_CONFLICT, + ) + return task + + +def normalize_idempotency_key(value: str) -> str: + normalized = str(value or "").strip() + if len(normalized) > IDEMPOTENCY_KEY_MAX_LENGTH: + raise ApiRequestError( + "bad_request", + "Idempotency-Key 过长", + status.HTTP_400_BAD_REQUEST, + ) + return normalized + + +def request_hash_for_image_request(request_data: Mapping[str, Any]) -> str: + image_base64 = str(request_data.get("image_base64") or "") + payload = { + "prompt": str(request_data.get("prompt") or ""), + "model": str(request_data.get("model") or ""), + "resolution": str(request_data.get("resolution") or "1K"), + "aspect_ratio": str(request_data.get("aspect_ratio") or "1:1"), + "parameters": dict(request_data.get("parameters") or {}), + "image_url": str(request_data.get("image_url") or ""), + "image_base64_sha256": hash_text(image_base64) if image_base64 else "", + } + return hash_json(payload) + + +def task_request_payload( + *, + request_data: Mapping[str, Any], + image_input: ImageInput | None, +) -> dict[str, Any]: + image_source = "none" + if request_data.get("image_base64"): + image_source = "base64_stored" + elif request_data.get("image_url"): + image_source = "url_stored" + + payload = { + "prompt": str(request_data.get("prompt") or ""), + "model": str(request_data.get("model") or ""), + "resolution": str(request_data.get("resolution") or "1K"), + "aspect_ratio": str(request_data.get("aspect_ratio") or "1:1"), + "parameters": dict(request_data.get("parameters") or {}), + "image_url": str(request_data.get("image_url") or ""), + "image_input": None, + } + if image_input is not None: + payload["image_input"] = { + "source": image_source, + "mime_type": image_input.mime_type, + "filename": image_input.filename, + "storage_path": "", + } + return payload + + +def store_task_input_image( + task: ImageGenerationTask, + image_input: ImageInput | None, +) -> None: + if image_input is None: + return + task.input_image.save( + image_input.filename, + ContentFile(image_input.data), + save=True, + ) + payload = dict(task.request_payload or {}) + image_info = dict(payload.get("image_input") or {}) + image_info["storage_path"] = task.input_image.name + payload["image_input"] = image_info + task.request_payload = payload + task.save(update_fields=("request_payload", "updated_at")) + + +def claim_next_image_task(worker_id: str | None = None) -> ImageGenerationTask | None: + normalized_worker_id = normalize_worker_id(worker_id) + now = timezone.now() + lease_expires_at = now + timedelta(seconds=image_task_lease_seconds()) + + with transaction.atomic(): + queryset = ( + ImageGenerationTask.objects.select_related("user", "api_key", "call_record") + .filter(status=ImageGenerationTask.Status.QUEUED) + .order_by("created_at", "id") + ) + if connection.features.has_select_for_update_skip_locked: + queryset = queryset.select_for_update(skip_locked=True) + else: + queryset = queryset.select_for_update() + + task = queryset.first() + if task is None: + return None + + task.status = ImageGenerationTask.Status.RUNNING + task.worker_id = normalized_worker_id + task.locked_at = now + task.lease_expires_at = lease_expires_at + task.heartbeat_at = now + task.started_at = task.started_at or now + task.attempt_count += 1 + task.save( + update_fields=( + "status", + "worker_id", + "locked_at", + "lease_expires_at", + "heartbeat_at", + "started_at", + "attempt_count", + "updated_at", + ) + ) + return task + + +def run_one_image_task(worker_id: str | None = None) -> ImageGenerationTask | None: + task = claim_next_image_task(worker_id) + if task is None: + return None + return run_image_generation_task(task, worker_id=worker_id) + + +def run_image_generation_task( + task: ImageGenerationTask, + *, + worker_id: str | None = None, +) -> ImageGenerationTask: + task = refresh_task(task) + if task.status != ImageGenerationTask.Status.RUNNING: + return task + + if worker_id: + heartbeat_image_task(task.pk, worker_id) + + try: + precharged = precharged_generation_for_task(task) + result = execute_precharged_generation( + precharged, + image_url_builder=media_public_url_builder, + ) + except ApiRequestError as exc: + refund_task_call(task, exc.message) + return mark_task_failed_if_running(task.pk, exc.code, exc.message) + except Exception as exc: + refund_task_call(task, str(exc)) + return mark_task_failed_if_running( + task.pk, + "upstream_error", + TASK_FAILURE_MESSAGE, + ) + + return mark_task_succeeded_if_running(task.pk, result.image_url) + + +def precharged_generation_for_task(task: ImageGenerationTask) -> PrechargedGeneration: + payload = dict(task.request_payload or {}) + prepared = prepare_generation( + GenerationInput( + user=task.user, + api_key=task.api_key, + operation_type=CallRecord.OperationType.IMAGE, + prompt=str(payload.get("prompt") or ""), + alias=str(payload.get("model") or "") or None, + resolution=str(payload.get("resolution") or "1K"), + parameters=dict(payload.get("parameters") or {}), + aspect_ratio=str(payload.get("aspect_ratio") or "1:1"), + ) + ) + image_input = stored_image_input_for_task(task) + if image_input is not None: + prepared = replace(prepared, image_input=image_input) + + return PrechargedGeneration( + prepared=prepared, + call_record=task.call_record, + points_cost=task.call_record.points_cost, + points_balance_after_charge=task.points_balance_after_charge, + ) + + +def stored_image_input_for_task(task: ImageGenerationTask) -> ImageInput | None: + if not task.input_image: + return None + payload = dict(task.request_payload or {}) + image_info = dict(payload.get("image_input") or {}) + with task.input_image.open("rb") as image_file: + image = image_file.read() + return ImageInput( + data=image, + mime_type=str(image_info.get("mime_type") or "image/png"), + filename=str(image_info.get("filename") or "input.png"), + ) + + +def heartbeat_image_task(task_pk: int, worker_id: str | None = None) -> None: + now = timezone.now() + ImageGenerationTask.objects.filter( + pk=task_pk, + status=ImageGenerationTask.Status.RUNNING, + ).update( + heartbeat_at=now, + lease_expires_at=now + timedelta(seconds=image_task_lease_seconds()), + worker_id=normalize_worker_id(worker_id), + ) + + +def mark_task_succeeded_if_running( + task_pk: int, + result_url: str, +) -> ImageGenerationTask: + now = timezone.now() + with transaction.atomic(): + task = ImageGenerationTask.objects.select_for_update().get(pk=task_pk) + if task.status != ImageGenerationTask.Status.RUNNING: + return task + task.status = ImageGenerationTask.Status.SUCCEEDED + task.result_url = str(result_url or "") + task.error_code = "" + task.error_message = "" + task.finished_at = now + task.heartbeat_at = now + task.save( + update_fields=( + "status", + "result_url", + "error_code", + "error_message", + "finished_at", + "heartbeat_at", + "updated_at", + ) + ) + return task + + +def mark_task_failed_if_running( + task_pk: int, + error_code: str, + error_message: str, +) -> ImageGenerationTask: + now = timezone.now() + with transaction.atomic(): + task = ImageGenerationTask.objects.select_for_update().get(pk=task_pk) + if task.status != ImageGenerationTask.Status.RUNNING: + return task + task.status = ImageGenerationTask.Status.FAILED + task.error_code = str(error_code or "upstream_error") + task.error_message = str(error_message or TASK_FAILURE_MESSAGE) + task.finished_at = now + task.heartbeat_at = now + task.save( + update_fields=( + "status", + "error_code", + "error_message", + "finished_at", + "heartbeat_at", + "updated_at", + ) + ) + return task + + +def reap_stale_image_tasks( + *, + now=None, + limit: int = 100, +) -> int: + current_time = now or timezone.now() + stale_ids = list( + ImageGenerationTask.objects.filter(status=ImageGenerationTask.Status.RUNNING) + .filter(stale_task_filter(current_time)) + .order_by("lease_expires_at", "id") + .values_list("id", flat=True)[:limit] + ) + reaped = 0 + for task_pk in stale_ids: + if reap_stale_image_task(task_pk, current_time): + reaped += 1 + return reaped + + +def reap_stale_image_task(task_pk: int, now) -> bool: + with transaction.atomic(): + task = ( + ImageGenerationTask.objects.select_for_update() + .select_related("call_record") + .get(pk=task_pk) + ) + if task.status != ImageGenerationTask.Status.RUNNING or not task_is_stale(task, now): + return False + + if task.call_record.status == CallRecord.Status.SUCCESS: + task.status = ImageGenerationTask.Status.SUCCEEDED + task.result_url = task.call_record.result_ref + task.error_code = "" + task.error_message = "" + task.finished_at = now + task.save( + update_fields=( + "status", + "result_url", + "error_code", + "error_message", + "finished_at", + "updated_at", + ) + ) + return True + + try: + refund_call_points( + task.call_record, + error_message=TASK_TIMEOUT_MESSAGE, + reason="Image generation task lease expired; refund precharged points.", + ) + except InvalidCallStateError: + task.call_record.refresh_from_db() + if task.call_record.status == CallRecord.Status.SUCCESS: + task.status = ImageGenerationTask.Status.SUCCEEDED + task.result_url = task.call_record.result_ref + task.finished_at = now + task.save(update_fields=("status", "result_url", "finished_at", "updated_at")) + return True + raise + + task.status = ImageGenerationTask.Status.FAILED + task.error_code = "task_timeout" + task.error_message = TASK_TIMEOUT_MESSAGE + task.finished_at = now + task.heartbeat_at = now + task.save( + update_fields=( + "status", + "error_code", + "error_message", + "finished_at", + "heartbeat_at", + "updated_at", + ) + ) + return True + + +def stale_task_filter(now): + stale_heartbeat_before = now - timedelta(seconds=image_task_lease_seconds()) + return ( + models_q("lease_expires_at__lte", now) + | models_q("heartbeat_at__lte", stale_heartbeat_before) + ) + + +def task_is_stale(task: ImageGenerationTask, now) -> bool: + stale_heartbeat_before = now - timedelta(seconds=image_task_lease_seconds()) + if task.lease_expires_at and task.lease_expires_at <= now: + return True + return bool(task.heartbeat_at and task.heartbeat_at <= stale_heartbeat_before) + + +def refund_task_call(task: ImageGenerationTask, error_message: str) -> None: + try: + refund_call_points( + task.call_record, + error_message=error_message, + reason="Image generation task failed; refund precharged points.", + ) + except InvalidCallStateError: + return + + +def refresh_task(task: ImageGenerationTask) -> ImageGenerationTask: + return ( + ImageGenerationTask.objects.select_related("user", "api_key", "call_record") + .get(pk=task.pk) + ) + + +def task_submit_response(task: ImageGenerationTask) -> dict[str, Any]: + call_record = task.call_record + return { + "task_id": str(task.task_id), + "status": task.status, + "call_id": call_record.id, + "points_cost": call_record.points_cost, + "points_balance": task.points_balance_after_charge, + "created_at": task.created_at.isoformat(), + "expires_at": task.expires_at.isoformat() if task.expires_at else None, + } + + +def task_detail_response(task: ImageGenerationTask) -> dict[str, Any]: + call_record = task.call_record + data: dict[str, Any] = { + "task_id": str(task.task_id), + "status": task.status, + "call_id": call_record.id, + "points_cost": call_record.points_cost, + "created_at": task.created_at.isoformat(), + "updated_at": task.updated_at.isoformat(), + "expires_at": task.expires_at.isoformat() if task.expires_at else None, + } + if task.status == ImageGenerationTask.Status.SUCCEEDED: + data["result"] = {"image_url": task.result_url} + elif task.status in {ImageGenerationTask.Status.FAILED, ImageGenerationTask.Status.EXPIRED}: + data["error"] = { + "code": task.error_code or "upstream_error", + "message": task.error_message or TASK_FAILURE_MESSAGE, + } + return data + + +def media_public_url_builder(url: str) -> str: + if url.startswith(("http://", "https://")): + return url + base_url = str(getattr(settings, "MEDIA_PUBLIC_BASE_URL", "") or "").rstrip("/") + if base_url and url.startswith("/"): + return f"{base_url}{url}" + return url + + +def image_task_retention_hours() -> int: + return max(1, int(getattr(settings, "IMAGE_TASK_RETENTION_HOURS", 24))) + + +def image_task_lease_seconds() -> int: + return max(1, int(getattr(settings, "IMAGE_TASK_LEASE_SECONDS", 600))) + + +def normalize_worker_id(worker_id: str | None) -> str: + normalized = str(worker_id or "").strip() + if normalized: + return normalized[:128] + return f"{socket.gethostname()}:{hash_text(str(timezone.now().timestamp()))[:8]}" + + +def hash_text(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def hash_json(value: Mapping[str, Any]) -> str: + payload = json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=False, + default=str, + ) + return hash_text(payload) + + +def models_q(key: str, value): + from django.db.models import Q + + return Q(**{key: value}) diff --git a/apps/api/management/__init__.py b/apps/api/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/api/management/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/api/management/commands/__init__.py b/apps/api/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/api/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/api/management/commands/run_image_tasks.py b/apps/api/management/commands/run_image_tasks.py new file mode 100644 index 0000000..af485a7 --- /dev/null +++ b/apps/api/management/commands/run_image_tasks.py @@ -0,0 +1,66 @@ +import time +import uuid + +from django.conf import settings +from django.core.management.base import BaseCommand + +from apps.api.image_tasks import reap_stale_image_tasks, run_one_image_task + + +class Command(BaseCommand): + help = "Run asynchronous image generation tasks." + + def add_arguments(self, parser): + parser.add_argument( + "--once", + action="store_true", + help="Process at most one queued task and exit.", + ) + parser.add_argument( + "--worker-id", + default="", + help="Stable worker identifier recorded on claimed tasks.", + ) + parser.add_argument( + "--sleep-seconds", + type=float, + default=1.0, + help="Sleep interval when no queued task is available.", + ) + parser.add_argument( + "--reap-only", + action="store_true", + help="Only reap stale running tasks and exit.", + ) + + def handle(self, *args, **options): + worker_id = options["worker_id"] or f"image-worker-{uuid.uuid4().hex[:8]}" + once = bool(options["once"]) + reap_only = bool(options["reap_only"]) + sleep_seconds = max(0.1, float(options["sleep_seconds"])) + reaper_interval = max( + 1, + int(getattr(settings, "IMAGE_TASK_REAPER_INTERVAL_SECONDS", 60)), + ) + last_reap_at = 0.0 + + while True: + now = time.monotonic() + if reap_only or now - last_reap_at >= reaper_interval: + reaped = reap_stale_image_tasks() + if reaped: + self.stdout.write(f"reaped={reaped}") + last_reap_at = now + if reap_only: + return + + task = run_one_image_task(worker_id=worker_id) + if task is not None: + self.stdout.write(f"task={task.task_id} status={task.status}") + if once: + return + continue + + if once: + return + time.sleep(sleep_seconds) diff --git a/apps/api/migrations/0001_initial.py b/apps/api/migrations/0001_initial.py new file mode 100644 index 0000000..f37d072 --- /dev/null +++ b/apps/api/migrations/0001_initial.py @@ -0,0 +1,58 @@ +# Generated by Django 5.2.15 on 2026-07-08 13:44 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('billing', '0007_alter_pointsledger_change_type_signupbonusgrant'), + ('users', '0005_alter_apikey_created_at_alter_apikey_key_hash_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ImageGenerationTask', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('task_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='任务 ID')), + ('status', models.CharField(choices=[('queued', '排队中'), ('running', '处理中'), ('succeeded', '成功'), ('failed', '失败'), ('expired', '已过期')], default='queued', max_length=20, verbose_name='状态')), + ('idempotency_key', models.CharField(blank=True, max_length=128, verbose_name='幂等键')), + ('idempotency_key_hash', models.CharField(blank=True, editable=False, max_length=64, null=True, verbose_name='幂等键哈希')), + ('request_hash', models.CharField(max_length=64, verbose_name='请求哈希')), + ('request_payload', models.JSONField(blank=True, default=dict, verbose_name='请求快照')), + ('input_image', models.FileField(blank=True, upload_to='generated/task_inputs/%Y/%m/%d/', verbose_name='输入图片')), + ('result_url', models.TextField(blank=True, verbose_name='结果 URL')), + ('error_code', models.CharField(blank=True, max_length=64, verbose_name='错误码')), + ('error_message', models.TextField(blank=True, verbose_name='错误信息')), + ('points_balance_after_charge', models.BigIntegerField(default=0, verbose_name='扣费后余额')), + ('started_at', models.DateTimeField(blank=True, null=True, verbose_name='开始时间')), + ('finished_at', models.DateTimeField(blank=True, null=True, verbose_name='完成时间')), + ('expires_at', models.DateTimeField(blank=True, null=True, verbose_name='任务元数据过期时间')), + ('locked_at', models.DateTimeField(blank=True, null=True, verbose_name='锁定时间')), + ('lease_expires_at', models.DateTimeField(blank=True, null=True, verbose_name='租约过期时间')), + ('heartbeat_at', models.DateTimeField(blank=True, null=True, verbose_name='心跳时间')), + ('worker_id', models.CharField(blank=True, max_length=128, verbose_name='Worker ID')), + ('attempt_count', models.PositiveIntegerField(default=0, verbose_name='尝试次数')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ('api_key', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='image_generation_tasks', to='users.apikey', verbose_name='API 密钥')), + ('call_record', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='image_generation_task', to='billing.callrecord', verbose_name='调用记录')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='image_generation_tasks', to=settings.AUTH_USER_MODEL, verbose_name='用户')), + ], + options={ + 'verbose_name': '图片生成任务', + 'verbose_name_plural': '图片生成任务', + 'db_table': 'image_generation_task', + 'ordering': ('-created_at', '-id'), + 'indexes': [models.Index(fields=['user', 'created_at'], name='image_gener_user_id_1e7164_idx'), models.Index(fields=['api_key', 'created_at'], name='image_gener_api_key_30d9ad_idx'), models.Index(fields=['status', 'created_at'], name='image_gener_status_727db2_idx'), models.Index(fields=['status', 'lease_expires_at'], name='image_gener_status_7a7bd1_idx'), models.Index(fields=['worker_id', 'status'], name='image_gener_worker__f9e534_idx'), models.Index(fields=['expires_at'], name='image_gener_expires_545a78_idx')], + 'constraints': [models.UniqueConstraint(fields=('api_key', 'idempotency_key_hash'), name='unique_image_task_idempotency_hash_per_api_key'), models.CheckConstraint(condition=models.Q(('points_balance_after_charge__gte', 0)), name='image_task_balance_after_charge_non_negative')], + }, + ), + ] diff --git a/apps/api/models.py b/apps/api/models.py index 71a8362..ffeb27f 100644 --- a/apps/api/models.py +++ b/apps/api/models.py @@ -1,3 +1,98 @@ -from django.db import models +from __future__ import annotations -# Create your models here. +import uuid + +from django.conf import settings +from django.db import models +from django.db.models import Q + + +class ImageGenerationTask(models.Model): + class Status(models.TextChoices): + QUEUED = "queued", "排队中" + RUNNING = "running", "处理中" + SUCCEEDED = "succeeded", "成功" + FAILED = "failed", "失败" + EXPIRED = "expired", "已过期" + + task_id = models.UUIDField("任务 ID", default=uuid.uuid4, unique=True, editable=False) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + verbose_name="用户", + on_delete=models.PROTECT, + related_name="image_generation_tasks", + ) + api_key = models.ForeignKey( + "users.ApiKey", + verbose_name="API 密钥", + on_delete=models.PROTECT, + related_name="image_generation_tasks", + ) + call_record = models.OneToOneField( + "billing.CallRecord", + verbose_name="调用记录", + on_delete=models.PROTECT, + related_name="image_generation_task", + ) + status = models.CharField( + "状态", + max_length=20, + choices=Status.choices, + default=Status.QUEUED, + ) + idempotency_key = models.CharField("幂等键", max_length=128, blank=True) + idempotency_key_hash = models.CharField( + "幂等键哈希", + max_length=64, + null=True, + blank=True, + editable=False, + ) + request_hash = models.CharField("请求哈希", max_length=64) + request_payload = models.JSONField("请求快照", default=dict, blank=True) + input_image = models.FileField( + "输入图片", + upload_to="generated/task_inputs/%Y/%m/%d/", + blank=True, + ) + result_url = models.TextField("结果 URL", blank=True) + error_code = models.CharField("错误码", max_length=64, blank=True) + error_message = models.TextField("错误信息", blank=True) + points_balance_after_charge = models.BigIntegerField("扣费后余额", default=0) + started_at = models.DateTimeField("开始时间", null=True, blank=True) + finished_at = models.DateTimeField("完成时间", null=True, blank=True) + expires_at = models.DateTimeField("任务元数据过期时间", null=True, blank=True) + locked_at = models.DateTimeField("锁定时间", null=True, blank=True) + lease_expires_at = models.DateTimeField("租约过期时间", null=True, blank=True) + heartbeat_at = models.DateTimeField("心跳时间", null=True, blank=True) + worker_id = models.CharField("Worker ID", max_length=128, blank=True) + attempt_count = models.PositiveIntegerField("尝试次数", default=0) + created_at = models.DateTimeField("创建时间", auto_now_add=True) + updated_at = models.DateTimeField("更新时间", auto_now=True) + + class Meta: + db_table = "image_generation_task" + verbose_name = "图片生成任务" + verbose_name_plural = "图片生成任务" + ordering = ("-created_at", "-id") + constraints = [ + models.UniqueConstraint( + fields=("api_key", "idempotency_key_hash"), + name="unique_image_task_idempotency_hash_per_api_key", + ), + models.CheckConstraint( + condition=Q(points_balance_after_charge__gte=0), + name="image_task_balance_after_charge_non_negative", + ), + ] + indexes = [ + models.Index(fields=("user", "created_at")), + models.Index(fields=("api_key", "created_at")), + models.Index(fields=("status", "created_at")), + models.Index(fields=("status", "lease_expires_at")), + models.Index(fields=("worker_id", "status")), + models.Index(fields=("expires_at",)), + ] + + def __str__(self) -> str: + return f"{self.task_id} {self.status}" diff --git a/apps/api/tests.py b/apps/api/tests.py index a78c448..4bd3f4a 100644 --- a/apps/api/tests.py +++ b/apps/api/tests.py @@ -2,6 +2,7 @@ import uuid import base64 import json import tempfile +from datetime import timedelta from decimal import Decimal from pathlib import Path from unittest.mock import patch @@ -28,6 +29,12 @@ from apps.api.generation import ( prepare_generation, run_synchronous_generation, ) +from apps.api.image_tasks import ( + claim_next_image_task, + reap_stale_image_tasks, + run_image_generation_task, +) +from apps.api.models import ImageGenerationTask from apps.api.throttles import GenerateRateThrottle from apps.api.views import ClientLatestReleaseView, ExternalApiView, ModelsView from apps.ai.models import AiModel, ModelAlias @@ -1157,9 +1164,9 @@ class GenerateApiTests(TestCase): def auth_header(self) -> dict: return {"HTTP_AUTHORIZATION": f"Bearer {self.raw_key}"} - def post_with_provider(self, path, payload, provider=None): + def post_with_provider(self, path, payload, provider=None, **extra): with patch("apps.api.generation.get_provider", return_value=provider or self.provider): - return self.client.post(path, payload, format="json", **self.auth_header()) + return self.client.post(path, payload, format="json", **self.auth_header(), **extra) def assert_generation_not_charged(self): self.wallet.refresh_from_db() @@ -1281,6 +1288,273 @@ class GenerateApiTests(TestCase): self.assertEqual(call.result_summary, "image_bytes=21") self.assertNotIn("SECRET_RAW", call.result_ref + call.result_summary) + @override_settings( + MODERATION_ENABLED=True, + MODERATION_PROVIDER="keyword", + MODERATION_CACHE_VERSION_KEY="test:api:moderation:sensitive_words:version", + ) + def test_async_image_blocked_prompt_creates_no_task_or_charge(self): + SensitiveWord.objects.create(word="敏感词", category="policy") + + with ( + patch("apps.api.generation.socket.getaddrinfo") as dns_lookup, + patch("apps.api.generation.requests.Session.get") as image_get, + ): + response = self.post_with_provider( + "/api/v1/generate/image/tasks", + { + "prompt": "请生成敏-感\u200b 词图片", + "model": self.image_alias, + "image_url": "https://safe.example.com/input.jpg", + "resolution": "1K", + "aspect_ratio": "1:1", + }, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["error"]["code"], "content_blocked") + dns_lookup.assert_not_called() + image_get.assert_not_called() + self.assertFalse(ImageGenerationTask.objects.exists()) + self.assert_generation_not_charged() + + def test_async_image_insufficient_points_returns_402_without_task(self): + self.wallet.points_balance = 1 + self.wallet.save(update_fields=("points_balance", "updated_at")) + + response = self.post_with_provider( + "/api/v1/generate/image/tasks", + {"prompt": "生成图片", "model": self.image_alias, "resolution": "1K"}, + ) + + self.assertEqual(response.status_code, 402) + self.assertEqual(response.data["error"]["code"], "insufficient_points") + self.assertFalse(ImageGenerationTask.objects.exists()) + self.assertEqual(self.provider.image_calls, []) + self.wallet.refresh_from_db() + self.assertEqual(self.wallet.points_balance, 1) + self.assertFalse(CallRecord.objects.filter(user=self.user).exists()) + + def test_async_image_idempotency_reuses_task_and_rejects_conflict(self): + payload = {"prompt": "生成图片", "model": self.image_alias, "resolution": "1K"} + + first = self.post_with_provider( + "/api/v1/generate/image/tasks", + payload, + HTTP_IDEMPOTENCY_KEY="image-job-001", + ) + second = self.post_with_provider( + "/api/v1/generate/image/tasks", + payload, + HTTP_IDEMPOTENCY_KEY="image-job-001", + ) + conflict = self.post_with_provider( + "/api/v1/generate/image/tasks", + {**payload, "prompt": "生成另一张图片"}, + HTTP_IDEMPOTENCY_KEY="image-job-001", + ) + + self.assertEqual(first.status_code, 202) + self.assertEqual(second.status_code, 202) + self.assertEqual(first.data["task_id"], second.data["task_id"]) + self.assertEqual(conflict.status_code, 409) + self.assertEqual(conflict.data["error"]["code"], "idempotency_conflict") + self.assertEqual(ImageGenerationTask.objects.count(), 1) + self.assertEqual(CallRecord.objects.filter(user=self.user).count(), 1) + self.assertEqual( + PointsLedger.objects.filter( + user=self.user, + change_type=PointsLedger.ChangeType.CONSUME, + ).count(), + 1, + ) + self.wallet.refresh_from_db() + self.assertEqual(self.wallet.points_balance, 90) + self.assertEqual(self.provider.image_calls, []) + + @override_settings(MEDIA_PUBLIC_BASE_URL="https://cm.example.test") + def test_async_image_worker_success_and_poll_are_idempotent(self): + response = self.post_with_provider( + "/api/v1/generate/image/tasks", + {"prompt": "生成图片", "model": self.image_alias, "resolution": "1K"}, + ) + + self.assertEqual(response.status_code, 202) + self.assertEqual(response.data["status"], ImageGenerationTask.Status.QUEUED) + self.assertEqual(response.data["points_balance"], 90) + self.assertEqual(self.provider.image_calls, []) + + with patch("apps.api.generation.get_provider", return_value=self.provider): + claimed = claim_next_image_task("worker-a") + self.assertIsNotNone(claimed) + task = run_image_generation_task(claimed, worker_id="worker-a") + + self.assertEqual(task.status, ImageGenerationTask.Status.SUCCEEDED) + self.assertTrue(task.result_url.startswith("https://cm.example.test/media/")) + self.assertEqual(len(self.provider.image_calls), 1) + + poll = self.client.get( + f"/api/v1/generate/image/tasks/{response.data['task_id']}", + **self.auth_header(), + ) + repeat = self.client.get( + f"/api/v1/generate/image/tasks/{response.data['task_id']}", + **self.auth_header(), + ) + + self.assertEqual(poll.status_code, 200) + self.assertEqual(poll.data["status"], ImageGenerationTask.Status.SUCCEEDED) + self.assertEqual(poll.data["result"]["image_url"], task.result_url) + self.assertEqual(repeat.data["result"]["image_url"], task.result_url) + + def test_async_image_poll_rejects_cross_user_access(self): + response = self.post_with_provider( + "/api/v1/generate/image/tasks", + {"prompt": "生成图片", "model": self.image_alias, "resolution": "1K"}, + ) + other_user = get_user_model().objects.create_user( + username=f"other-{uuid.uuid4().hex[:8]}", + email=f"other-{uuid.uuid4().hex[:8]}@example.com", + password="password", + ) + _other_key, other_raw_key = ApiKey.create_for_user(other_user, name="other") + + denied = self.client.get( + f"/api/v1/generate/image/tasks/{response.data['task_id']}", + HTTP_AUTHORIZATION=f"Bearer {other_raw_key}", + ) + + self.assertEqual(denied.status_code, 404) + self.assertEqual(denied.data["error"]["code"], "task_not_found") + + def test_async_image_worker_failure_refunds_precharged_points(self): + self.provider.image_error = requests.Timeout("image upstream deadline exceeded") + response = self.post_with_provider( + "/api/v1/generate/image/tasks", + {"prompt": "生成图片", "model": self.image_alias, "resolution": "1K"}, + ) + + with patch("apps.api.generation.get_provider", return_value=self.provider): + task = run_image_generation_task( + claim_next_image_task("worker-failure"), + worker_id="worker-failure", + ) + + self.assertEqual(task.status, ImageGenerationTask.Status.FAILED) + self.assertEqual(task.error_code, "upstream_timeout") + self.wallet.refresh_from_db() + self.assertEqual(self.wallet.points_balance, 100) + + call = CallRecord.objects.get(pk=response.data["call_id"]) + self.assertEqual(call.status, CallRecord.Status.FAILED) + self.assertEqual( + PointsLedger.objects.filter( + ref_call=call, + change_type=PointsLedger.ChangeType.REFUND, + ).count(), + 1, + ) + + poll = self.client.get( + f"/api/v1/generate/image/tasks/{response.data['task_id']}", + **self.auth_header(), + ) + self.assertEqual(poll.data["status"], ImageGenerationTask.Status.FAILED) + self.assertEqual(poll.data["error"]["code"], "upstream_timeout") + + def test_async_image_reaper_fails_stale_running_task_and_refunds(self): + response = self.post_with_provider( + "/api/v1/generate/image/tasks", + {"prompt": "生成图片", "model": self.image_alias, "resolution": "1K"}, + ) + claimed = claim_next_image_task("worker-crash") + stale_at = timezone.now() - timedelta(seconds=5) + ImageGenerationTask.objects.filter(pk=claimed.pk).update( + lease_expires_at=stale_at, + heartbeat_at=stale_at, + ) + + reaped = reap_stale_image_tasks(now=timezone.now()) + task = ImageGenerationTask.objects.get(pk=claimed.pk) + + self.assertEqual(reaped, 1) + self.assertEqual(task.status, ImageGenerationTask.Status.FAILED) + self.assertEqual(task.error_code, "task_timeout") + self.wallet.refresh_from_db() + self.assertEqual(self.wallet.points_balance, 100) + call = CallRecord.objects.get(pk=response.data["call_id"]) + self.assertEqual( + PointsLedger.objects.filter( + ref_call=call, + change_type=PointsLedger.ChangeType.REFUND, + ).count(), + 1, + ) + + @override_settings(MEDIA_PUBLIC_BASE_URL="https://cm.example.test") + def test_async_image_duplicate_worker_does_not_double_charge_or_refund(self): + response = self.post_with_provider( + "/api/v1/generate/image/tasks", + {"prompt": "生成图片", "model": self.image_alias, "resolution": "1K"}, + ) + + with patch("apps.api.generation.get_provider", return_value=self.provider): + task = run_image_generation_task( + claim_next_image_task("worker-a"), + worker_id="worker-a", + ) + duplicate = run_image_generation_task(task, worker_id="worker-b") + + self.assertEqual(duplicate.status, ImageGenerationTask.Status.SUCCEEDED) + self.assertEqual(duplicate.result_url, task.result_url) + self.assertEqual(len(self.provider.image_calls), 1) + call = CallRecord.objects.get(pk=response.data["call_id"]) + 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(), + 0, + ) + + def test_async_image_late_worker_after_reaper_cannot_flip_failed_task(self): + response = self.post_with_provider( + "/api/v1/generate/image/tasks", + {"prompt": "生成图片", "model": self.image_alias, "resolution": "1K"}, + ) + claimed = claim_next_image_task("worker-late") + stale_at = timezone.now() - timedelta(seconds=5) + ImageGenerationTask.objects.filter(pk=claimed.pk).update( + lease_expires_at=stale_at, + heartbeat_at=stale_at, + ) + reap_stale_image_tasks(now=timezone.now()) + + with patch("apps.api.generation.get_provider", return_value=self.provider): + late = run_image_generation_task(claimed, worker_id="worker-late") + + self.assertEqual(late.status, ImageGenerationTask.Status.FAILED) + self.assertEqual(late.result_url, "") + self.assertEqual(self.provider.image_calls, []) + self.wallet.refresh_from_db() + self.assertEqual(self.wallet.points_balance, 100) + call = CallRecord.objects.get(pk=response.data["call_id"]) + self.assertEqual(call.status, CallRecord.Status.FAILED) + self.assertEqual( + PointsLedger.objects.filter( + ref_call=call, + change_type=PointsLedger.ChangeType.REFUND, + ).count(), + 1, + ) + def test_generation_core_saves_image_with_url_builder_without_request(self): encoded = base64.b64encode(b"input-image").decode("ascii") diff --git a/apps/api/urls.py b/apps/api/urls.py index dd413d5..6ad6c47 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -4,6 +4,8 @@ from .views import ( AlipayRechargeCallbackView, BalanceView, ClientLatestReleaseView, + GenerateImageTaskDetailView, + GenerateImageTaskSubmitView, GenerateImageView, GenerateTitleView, ModelsView, @@ -22,6 +24,16 @@ urlpatterns = [ ), path("v1/generate/title", GenerateTitleView.as_view(), name="api-generate-title"), path("v1/generate/image", GenerateImageView.as_view(), name="api-generate-image"), + path( + "v1/generate/image/tasks", + GenerateImageTaskSubmitView.as_view(), + name="api-generate-image-task-submit", + ), + path( + "v1/generate/image/tasks/", + GenerateImageTaskDetailView.as_view(), + name="api-generate-image-task-detail", + ), path("v1/recharge/create", RechargeCreateView.as_view(), name="api-recharge-create"), path("v1/recharge/status", RechargeStatusView.as_view(), name="api-recharge-status"), path( diff --git a/apps/api/views.py b/apps/api/views.py index 3742397..b840ae4 100644 --- a/apps/api/views.py +++ b/apps/api/views.py @@ -18,6 +18,12 @@ from apps.api.generation import ( generate_image_response, generate_title_response, ) +from apps.api.image_tasks import ( + create_image_generation_task, + task_detail_response, + task_submit_response, +) +from apps.api.models import ImageGenerationTask from apps.api.serializers import ( GenerateImageRequestSerializer, GenerateTitleRequestSerializer, @@ -107,6 +113,43 @@ class GenerateImageView(ExternalApiView): return Response(data, status=status.HTTP_200_OK) +class GenerateImageTaskSubmitView(ExternalApiView): + throttle_classes = (GenerateRateThrottle,) + + def post(self, request): + serializer = GenerateImageRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + api_error("bad_request", "参数错误"), + status=status.HTTP_400_BAD_REQUEST, + ) + try: + task, _created = create_image_generation_task( + user=request.user, + api_key=request.auth, + request_data=serializer.validated_data, + idempotency_key=request.headers.get("Idempotency-Key", ""), + ) + except ApiRequestError as exc: + return Response(exc.as_response_data(), status=exc.http_status) + return Response(task_submit_response(task), status=status.HTTP_202_ACCEPTED) + + +class GenerateImageTaskDetailView(ExternalApiView): + def get(self, request, task_id): + task = ( + ImageGenerationTask.objects.select_related("call_record") + .filter(task_id=task_id, user=request.user) + .first() + ) + if task is None: + return Response( + api_error("task_not_found", "图片生成任务不存在"), + status=status.HTTP_404_NOT_FOUND, + ) + return Response(task_detail_response(task), status=status.HTTP_200_OK) + + class BalanceView(ExternalApiView): def get(self, request): balance = get_balance_snapshot(request.user) diff --git a/config/settings.py b/config/settings.py index ef6cd92..9e8deb1 100644 --- a/config/settings.py +++ b/config/settings.py @@ -117,6 +117,12 @@ IMAGE_URL_MAX_BYTES = env_int("IMAGE_URL_MAX_BYTES", 10 * 1024 * 1024) IMAGE_URL_MAX_REDIRECTS = env_int("IMAGE_URL_MAX_REDIRECTS", 3) IMAGE_URL_CONNECT_TIMEOUT_SECONDS = env_int("IMAGE_URL_CONNECT_TIMEOUT_SECONDS", 10) IMAGE_URL_READ_TIMEOUT_SECONDS = env_int("IMAGE_URL_READ_TIMEOUT_SECONDS", 60) +PUBLIC_BASE_URL = os.environ.get("PUBLIC_BASE_URL", "").rstrip("/") +MEDIA_PUBLIC_BASE_URL = os.environ.get("MEDIA_PUBLIC_BASE_URL", PUBLIC_BASE_URL).rstrip("/") +IMAGE_TASK_RETENTION_HOURS = env_int("IMAGE_TASK_RETENTION_HOURS", 24) +GENERATED_IMAGE_RETENTION_HOURS = env_int("GENERATED_IMAGE_RETENTION_HOURS", 72) +IMAGE_TASK_REAPER_INTERVAL_SECONDS = env_int("IMAGE_TASK_REAPER_INTERVAL_SECONDS", 60) +IMAGE_TASK_LEASE_SECONDS = env_int("IMAGE_TASK_LEASE_SECONDS", 600) RECHARGE_MAX_AMOUNT_CNY = env_decimal("RECHARGE_MAX_AMOUNT_CNY", "100000.00") MODERATION_ENABLED = env_bool("MODERATION_ENABLED", False) MODERATION_PROVIDER = os.environ.get("MODERATION_PROVIDER", "keyword").strip().lower() diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 4b20b8d..0f892e2 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「生图同步接口止血」与 T-613「抽生成核心 service」。生图慢 / 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」与 T-614「生图异步任务化接口」。生图慢 / 504 / 客户端超时已拆为 T-612~T-615:T-612 已先做同步接口上游硬截止止血,T-613 已抽共享生成 core,T-614 已新增异步提交 / 轮询接口与 DB worker;下一步 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 已完成抽生成核心 service;当前下一个可领取任务为 T-614。 +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 已完成生图异步任务化接口;当前下一个可领取任务为 T-615。 ## 领取任务规则 @@ -69,7 +69,7 @@ MVP 只做: - **用户端(Django 模板 SSR + Bootstrap)**:自助注册/登录、注册成功一次性赠送 100 点试用点数、扫码充值、查看充值记录/充值总额/剩余点数/消费记录、自助生成与删除 API Key。 -- 对外两个生成接口:`POST /api/v1/generate/title`、`POST /api/v1/generate/image`(同步返回结果)。 +- 对外生成接口:`POST /api/v1/generate/title`(同步返回标题)、`POST /api/v1/generate/image`(旧同步生图,保留兼容)、`POST /api/v1/generate/image/tasks` + `GET /api/v1/generate/image/tasks/{task_id}`(异步生图提交 / 轮询)。 - API Key 鉴权,识别所属注册用户(API 只认 Key,不认 Web session)。 - 点数计费:按「操作类型 + 能力别名(+ 可选分辨率)」查计费规则得点数;本地余额原子扣减;不足报错。 - 支付充值回调:支付系统服务端回调 → 验签 + 幂等 → 按汇率把金额转点数入账。 @@ -78,7 +78,7 @@ MVP 只做: MVP 不做: -- 异步任务队列(图片生成第一版走**同步等待**返回,不引入 Celery)。 +- Celery/RQ 等外部任务队列(T-614 已用 DB 任务表 + management command worker 实现生图异步提交 / 轮询;本期不做 cancel、回调通知或专业队列)。 - 自建支付/收银台(充值走外部支付系统,本服务发起扫码下单并接收回调,不碰资金清算)。 - 面向终端消费者的「内容生成产品页」(用户端只做账户自助,生成结果仍只经 API 输出)。 - 复杂活动赠点、邀请奖励或历史用户自动补发;除 T-608 明确定义的新用户一次性 100 点注册试用额度外,不做其他免费点数活动。 diff --git a/docs/02-requirements.md b/docs/02-requirements.md index b831783..3d21c99 100644 --- a/docs/02-requirements.md +++ b/docs/02-requirements.md @@ -30,7 +30,7 @@ | 扫码充值 | 用户端发起充值→展示支付二维码→扫码付款→回调到账;金额按汇率转点数 | P0 | | 个人中心 | 查看剩余点数、充值总额、充值记录、点数使用(消费)记录 | P0 | | 生成标题 API | 带 API Key + prompt(+可选商品图)调用,拿到标题文字 | P0 | -| 生成图片 API | 带 API Key + prompt + 图 + 模型/分辨率调用,**同步**拿到生成图片 | P0 | +| 生成图片 API | 带 API Key + prompt + 图 + 模型/分辨率调用;旧同步接口可直接返回图片 URL,新版客户端可用异步提交 / 轮询拿结果 | P0 | | 点数计费与扣减 | 按「操作类型+能力别名+分辨率」算点数,余额足够则扣点放行,不足则报错 | P0 | | 余额查询 API | 查询当前用户点数余额 | P0 | | 调用记录 | 每次调用落库:用户、Key、时间、操作、模型、消耗点数、成功/失败、错误 | P0 | @@ -45,7 +45,7 @@ | 功能 | 描述 | 阶段 | | --- | --- | --- | -| 异步生成 | 图片生成改任务队列 + 轮询/回调,提升并发 | V2 | +| 旧同步生图接口用量遥测与弃用 | 观察旧同步生图调用量、错误率和客户端版本,满足条件后再单独任务下线旧同步接口 | V2 | | 用量统计报表 | 按用户/时间/模型聚合消耗与成本 | V2 | | 复杂活动赠点 / 邀请奖励 | 注册 100 点之外的活动额度、邀请返利、历史用户补发等,需单独风控与审批 | V2 | | API Key 轮换 / 授权 | Key 到期轮换、按 Key 限授权别名 | V2 | @@ -56,7 +56,7 @@ 1. 作为新用户,我在用户端自助注册登录后先获得 100 点试用点数;后续扫码充值一笔钱,看到对应点数按汇率到账。 2. 作为已登录用户,我自助生成一把 API Key(明文只显示一次),也能删除不用的 Key。 3. 作为接入方,我带着 API Key 调用生成标题接口,传入 prompt,能拿到生成的标题。 -4. 作为接入方,我调用生成图片接口,等待若干秒后在同一个响应里拿到图片结果。 +4. 作为接入方,我可以调用旧同步生成图片接口在同一个响应里拿到图片结果;也可以调用异步图片任务接口先拿到 `task_id`,再轮询直到成功或失败。 5. 当我的点数不足以支付本次调用时,系统拒绝调用并返回「点数不足,请先充值」,且不调用上游、不扣点。 6. 作为已登录用户,我在个人中心看到剩余点数、充值总额、充值记录和消费(调用)记录。 7. 作为运营,我在后台能管理用户与点数(手工调点带原因),配置某操作的点数单价,查看某用户的调用记录和点数流水。 @@ -66,7 +66,7 @@ 每条 P0 功能对应可验证的判据: - **生成标题 API**:携带有效 API Key + 合法 prompt 调用,返回 200 且响应含标题文本;无效/缺失 Key 返回 401。 -- **生成图片 API**:携带有效 Key 调用,在超时上限内返回 200 且含图片(URL 或 base64);上游失败时返回明确错误码,且**不扣点**(已扣则冲正)。 +- **生成图片 API**:旧同步接口携带有效 Key 调用,在超时上限内返回 200 且含图片 URL;异步接口提交后返回 `task_id`,轮询成功后返回稳定图片 URL。上游失败、超时或异步任务僵死时返回明确错误码,且**不最终扣点**(已扣则冲正)。 - **点数计费与扣减**:调用前按规则算出点数 N;余额 ≥ N 才放行并精确扣 N;余额 < N 返回点数不足错误码且不调上游。并发同时多次调用,最终扣点总额正确,**不出现超扣或扣成负数**。 - **余额查询 API**:返回的余额等于该账号点数流水累加结果。 - **充值转点数**:模拟一次支付系统回调(含订单号、金额、签名),验签通过后按汇率加点;**同一订单号重复回调只入账一次**(幂等);验签失败不入账。 @@ -86,11 +86,11 @@ | 是否需要账号 | 是。终端用户**自助注册**(Django auth / allauth)→ 自助生成 API Key;运营用 Django 后台账号 | | 计费模型 | **预付费点数**:充值时按汇率把金额转点数存本地,调用直接扣本地点数 | | 余额是否实时查支付系统 | 否。本地点数余额为唯一权威账本,调用链路不查支付系统 | -| 生成返回方式 | **同步**等待返回结果(图片接口需把网关/服务超时调到 ≥ 300s) | +| 生成返回方式 | 标题仍同步返回;图片旧同步接口保留兼容,新版客户端优先使用异步提交 / 轮询 | | 模型选择方式 | 对外用**能力别名**(如 `image-hd`),不暴露具体模型名;后台配置别名→模型映射 | | 充值入口 | 用户端自助发起扫码充值(本服务向支付系统下单取二维码),付款成功由支付系统**服务端回调**入账 | | 注册是否送点数 | 是。新用户注册成功一次性赠送 100 点试用点数;只对功能上线后的新注册用户自动发放,历史用户是否补发需另行审批和单独任务 | -| 暂不支持 | 异步队列、复杂活动赠点 / 邀请奖励、多币种、分账、自动退款对账 | +| 暂不支持 | 复杂活动赠点 / 邀请奖励、多币种、分账、自动退款对账;异步生图本期只做提交 / 轮询,不做 cancel、回调通知或 Celery/RQ | ## 七、待确认 / 风险点 @@ -100,6 +100,6 @@ - **注册赠点风险**:注册送点虽然不来自支付回调,但仍是可消费点数,必须防重复发放、防并发重试重复入账,并配套注册限流 / 图形验证码等防刷手段。 - **凭证风险**:上游 AI 的 api_key、支付系统密钥属敏感信息,只能放环境变量/后台配置,不入代码与文档样例。 - **第三方平台风险**:上游 vectorengine 可能限流、超时、变更;需处理超时、错误透传与失败不扣点。 -- **耗时风险**:图片同步生成耗时长,需确认网关/反向代理/客户端超时上限,避免连接中断导致已扣点但结果丢失。 +- **耗时风险**:图片旧同步生成耗时长,需确认网关/反向代理/客户端超时上限;新版客户端应优先使用异步任务接口,避免连接中断导致结果丢失。异步任务必须通过租约 / reaper 处理 worker 崩溃,避免点数已扣但任务永远 running。 - **退款 / 纠纷**:外部退款后本地点数如何冲正?MVP 不自动化,但数据结构需保留可冲正能力。 - **待确认决策**:上述由谁、何时决策需明确,尤其商户配置交付时间与计费规则。 diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index 343bd2f..abbecc5 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -15,15 +15,15 @@ | 后台美化 | django-unfold 或 simpleui | 待定 | 仅外观,MVP 可先用原生 admin,后期按需引入 | | AI 上游对接 | **Provider 适配器层**(按 `api_type` 注册)+ **能力别名** 映射 + `requests` HTTP 客户端 | 已定 | 对外只暴露 `generate text/image` 两接口与别名;换供应商改后台映射,不动对外契约。移植 `cmbot` 的调用逻辑到各适配器。当前 3 模型机制不同:文本 chat、`nano-banana2` chat 多模态返图、`gpt-image-2` images/edits 改图(详见 `04` 3.1) | | 供应商密钥存储 | 应用层 Fernet 加密(`cryptography`) | 已定 | `AiModel.api_key_encrypted` 加密入库、admin 写入型字段不回显;加密主密钥 `AI_KEY_ENCRYPTION_KEY` 走环境变量,配置清单见 `env.md` | -| 图片结果存储 | 本地存储返回 URL(S3 兼容后续可替换) | 已定(MVP) | T-302 已用 Django `default_storage` + `MEDIA_ROOT` / `MEDIA_URL` 落地本地存储,同步响应返回 `image_url`,避免大 base64 进响应体;生产对象存储后续可替换 | +| 图片结果存储 | 本地存储返回 URL(S3 兼容后续可替换) | 已定 | T-302 已用 Django `default_storage` + `MEDIA_ROOT` / `MEDIA_URL` 落地本地存储,同步响应返回 `image_url`;T-614 异步 worker 通过 `MEDIA_PUBLIC_BASE_URL` / `PUBLIC_BASE_URL` 生成绝对结果 URL;生产对象存储后续可替换 | | 配置变更审计 | 自建 `AiConfigAuditLog` 审计表 + django-admin 只读查看 | 已定 | 记录 AiModel / ModelAlias / api_key 变更的 actor、时间、目标、动作和字段差异;密钥只记录 empty/set 状态,不记录明文或密文 | | 数据库 | MySQL 8.4 LTS(cmhub 专用独立实例) | 已定 | 满足 Django 5.2 的 MySQL ≥8.0.11;引擎 InnoDB + 字符集 utf8mb4;行锁 `select_for_update` / 条件更新保并发扣点。**不复用 VPS 已有的 MySQL 5.7**(跑不了 Django 5.2、无 CHECK 约束)。开发亦用 MySQL,勿用 SQLite(不支持 `select_for_update`) | | MySQL 驱动 | PyMySQL + cryptography | 已定 | PyMySQL 负责 Django 连接 MySQL;MySQL 8 默认 `caching_sha2_password` 认证需要 `cryptography` 支持;客户端连接/读/写超时通过 `MYSQL_CONNECT_TIMEOUT` / `MYSQL_READ_TIMEOUT` / `MYSQL_WRITE_TIMEOUT` 配置 | | 对外鉴权 | API Key(DRF 自定义 Authentication,哈希存储比对) | 已定 | 用户自助生成 Key;**API 只认 Key、不挂 SessionAuthentication**,防浏览器 cookie 绕过计费 | | 用户端鉴权 | Django Session(+ allauth 注册登录,免邮箱验证) | 已定 | T-501 已落地 `/signup` `/login` `/logout` 与 `/dashboard`;T-605 固定 `ACCOUNT_EMAIL_VERIFICATION="none"`,新用户注册后可直接使用;T-608 显式配置注册限流 `ACCOUNT_SIGNUP_RATE_LIMIT`(默认 `20/m/ip`,由 allauth signup rate limit 执行);T-502 已落地 `/apikeys`;T-503 已落地 `/records/recharge` 与 `/records/usage`;T-504 已落地 `/recharge`;用户端页面与 `recharge/create` 走 session + CSRF;后台账号也用 Session 登录 | | 充值对接 | 自助扫码:微信 V3 native + 支付宝当面付;下单取二维码 + 服务端回调(验签 + 幂等) | 已定 | T-304/T-305 已落地回调、扫码下单、状态轮询、HMAC mock 联调与 SDK 模式入口;生产需安装并配置 `wechatpayv3` / `python-alipay-sdk` 与真实商户密钥/证书 | -| 生成返回方式 | 同步 HTTP(无任务队列) | 已定 | MVP 简化;图片接口需调大网关/服务超时 | -| 任务队列 | 暂不引入(Celery/RQ) | 待定 | V2 异步化时再评估 | +| 生成返回方式 | 旧同步 HTTP + 生图异步提交/轮询并存 | 已定 | T-614 起 `POST /api/v1/generate/image/tasks` 提交任务、`GET /api/v1/generate/image/tasks/{task_id}` 轮询;旧 `/api/v1/generate/image` 同步接口保留给老客户端 | +| 任务队列 | DB 任务表 + management command worker;暂不引入 Celery/RQ | 已定(Phase 6) | T-614 使用 `ImageGenerationTask` + MySQL `select_for_update(skip_locked)` + `run_image_tasks` worker;Celery/Redis 仅在 DB worker 不够时再评估 | | 部署方式 | VPS / 宝塔 + Nginx + Gunicorn(gthread) + systemd,Django 单体 | 已定(MVP) | 生产按 `deployment.md` 执行;Nginx 按路径把 `/api/v1/generate/*`(图片长请求)分流到独立 Gunicorn 池,用户端 / admin / 余额 / 充值走普通池;`/static/` 托管 `STATIC_ROOT`,`/media/` 托管本地媒体或后续换对象存储;DRF 限流生产必须使用共享 Django cache | | 测试 | Django 自带 `manage.py test`(unittest)/ 可选 pytest-django | 已定 | 先用内置 test runner,重点覆盖计费与回调;涉及 `select_for_update` 的并发扣点测试必须在 MySQL 上跑,SQLite 会忽略行锁导致假绿 | | 依赖管理 | 系统 Python 3.12 + pip + `requirements.txt` + `pyproject.toml` | 已定 | 不使用虚拟环境;Windows 用 `py -3.12`,Unix/WSL 用 `python3.12`;运行依赖仍由 `requirements.txt` 管理,`pyproject.toml` 只落地 `requires-python` 元数据;init 会显式校验解释器版本在 `>=3.12,<3.14` | @@ -36,10 +36,10 @@ - **稳定接口 + 可插拔供应商**:对外只 `generate text/image` 两接口 + 能力别名;具体模型在后台配置并经适配器调用。收益是换供应商对调用方零改动、计费按别名稳定、可加授权与故障转移;代价是需维护适配器层与别名映射。**调用方不绑具体模型 SKU**。 - **供应商密钥存储采用 Fernet 应用层加密**:T-102 已落地 `AiModel.api_key_encrypted`,密文带 `fernet:` 前缀;明文只在 admin 表单提交或调用 `resolve_alias()` 后进入内存,不入库、不回显。主密钥来自 `AI_KEY_ENCRYPTION_KEY`,生产不可随意更换,除非后续做密钥轮换。 - **配置变更审计采用专表而非只依赖 admin LogEntry**:T-103 起后台保存/删除 `AiModel`、`ModelAlias` 时写 `AiConfigAuditLog`,记录谁、何时、对哪个配置做了 create/update/delete、哪些字段发生变化;`api_key` 变更只记 empty/set,不保存明文或 Fernet 密文。 -- **同步生成而非任务队列**:MVP 不引入 Celery/Redis,降低复杂度;图片耗时长,靠调大超时支撑,V2 再异步化。 - - **桌面端可不改、全同步接入**:cmbot 现有批处理引擎(后台线程池 + 进度/心跳/重试/停止)只需把 service 层 URL/密钥从直连中转站换成 cmhub 的 `base_url + API Key`,即可正常使用;`桌面端 → cmhub → 中转站` 多一跳不影响可用性,点数一致性反而更简单(一次请求闭环:预扣→同步调→成功/失败退点)。 - - **同步可用的前提是「超时链路 + worker 容量」配对**,否则会「小量正常、上量假死」:① 每模型 `timeout_seconds` 设有限值(`ai_models.json` 现为 `0`,迁入须改);② Gunicorn `--timeout` 与网关 `proxy_read_timeout` 按最慢图片放大(别用默认 30s / 60s);③ worker/线程数按**峰值总并发**预留。详见 [架构设计](04-architecture.md) 第五节结论。 - - **何时转 V2 异步**:worker 被长连接占满拖慢快接口/后台、接入方总并发明显上涨、或需要「关窗重连/任务持久化」体验——在此之前保持同步。 +- **生成返回方式演进**:MVP 先用同步 HTTP 降低复杂度;T-612 给旧同步生图加上游硬截止止血;T-613 抽共享生成 core;T-614 已新增生图异步提交 / 轮询路径,但不删除旧同步接口。 + - **旧同步接口继续兼容**:老桌面端仍可调用 `/api/v1/generate/image`,依赖「超时链路 + worker 容量」配对。每模型 `timeout_seconds`、`AI_IMAGE_UPSTREAM_DEADLINE_SECONDS`、Gunicorn `--timeout`、Nginx `proxy_read_timeout` 和客户端 read timeout 必须按真实图片耗时配置。 + - **新异步接口优先给新版客户端**:新版桌面端提交 `/api/v1/generate/image/tasks` 获得 `task_id`,再轮询 `/api/v1/generate/image/tasks/{task_id}`,避免 HTTP 长连接占住生成池,也支持客户端重启后继续查结果。 + - **任务队列选择**:T-614 采用数据库任务表 + management command worker,不引入 Celery/RQ/Redis;后续只有在 DB worker 吞吐或运维能力不够时再评估专业队列。 - **数据库:MySQL 8.4 LTS,cmhub 专用独立实例**:① 部署环境的 VPS 已装 MySQL 5.7 供其他服务用,但 5.7 跑不了 Django 5.2(需 ≥8.0.11)、已 EOL、且不支持 CHECK 约束,故**不复用**它;② 机器内存宽裕(`available` 7.4G),给 cmhub **单开一个 MySQL 8.4 LTS 实例**(独立端口/容器),与已有 5.7 完全隔离、互不影响;③ 选 8.4 LTS 取长维护窗口 + 完整 CHECK 约束(CHECK 需 MySQL ≥8.0.16 才真正生效);④ 强制 InnoDB + utf8mb4(5.7/老配置默认非 utf8mb4,prompt 的 emoji/生僻字会写失败);⑤ MySQL 默认隔离级别 REPEATABLE READ(不同于 PostgreSQL 的 READ COMMITTED),`select_for_update` 扣点仍安全,但计费实现按此语义验证;⑥ 开发环境同用 MySQL,不要用 SQLite——SQLite 会静默忽略 `FOR UPDATE`,并发扣点逻辑测不出来;⑦ 不在代码里写死只适配某一种库的 SQL。 - **用户端用 Django 模板 SSR 单体,不引前端框架**:需求含终端用户自助(注册/充值/API Key/记录),选 Django 模板 + Bootstrap + allauth 与后端同工程单体部署,复用 Django auth/session,开发部署最快、最契合单机 MVP;代价是交互不如 SPA,可后续加 HTMX。T-501 已接入 django-allauth 65.18.0,使用 session 与 CSRF;T-605 起注册策略固定为 `ACCOUNT_EMAIL_VERIFICATION="none"`,免邮箱验证、注册即可用,邮箱仍必填且唯一;T-608 已把注册成功后的初始化改为经 `apps.billing` 发放 100 点试用点数并写 `signup_bonus` 流水,且显式配置 `ACCOUNT_SIGNUP_RATE_LIMIT` 做基础注册限流。T-502 已用 Django Form + Bootstrap 模板落地 `/apikeys`,生成 Key 后明文只显示一次,删除写为 `revoked`。T-503 已用只读 Django TemplateView 落地 dashboard 汇总、充值记录和点数记录。T-504 已用 Django Form + Bootstrap 模板落地 `/recharge`:页面 POST 创建 pending 充值订单,展示支付二维码票据,并用浏览器轮询 `/api/v1/recharge/status`,订单 paid 后刷新余额。T-505 已把 Bootstrap 5 CSS 与 qrcode.js vendoring 到 `apps/portal/static/portal/vendor/`,页面不再依赖 jsdelivr,充值/点数记录页改为 Django `Paginator` 分页。放弃 Vue/React 前后端分离(两套项目/部署,与单体 MVP 调性冲突)。用户模型:`User`(auth) 持登录态、`UserWallet` 持点数(扣点锁 wallet、与 auth 解耦)、`ApiKey`(User 1:N,哈希存储)。新注册用户一次性赠送 100 点,必须走计费层和点数流水。 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 809b812..e03fe7a 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -20,7 +20,7 @@ - **用户端层(Django 模板 SSR)**:公开首页、注册/登录(Django auth / allauth)、个人中心(余额/充值总额/充值记录/点数记录)、API Key 自助管理、发起扫码充值、模型目录与客户端下载入口。入口 `apps/portal/`,公开首页匿名可访问,其他自助页面用 session 鉴权。T-501/T-608 已落地 `/signup`、`/login`、`/logout` 与 `/dashboard`;注册成功后经计费层一次性发放 100 点试用点数,并写 `signup_bonus` 点数流水。T-502 已落地 `/apikeys`,用户可自助生成和删除(吊销)自己的 API Key,明文只显示一次,列表只显示 prefix。T-503/T-608 已扩展 `/dashboard` 并新增 `/records/recharge`、`/records/usage`,只读展示当前用户余额、充值订单、注册赠点与消费/退款流水。T-504 已落地 `/recharge`,用户可创建 pending 充值订单、查看二维码票据,并轮询订单状态;到账仍以服务端回调或主动查单入账后的本地订单状态为准。T-505 已把 Bootstrap/qrcode.js 改成本地 static 自托管,并把充值/点数记录页从固定切片改为分页。T-606 已把 `/` 改为公开首页,并新增 `DownloadRelease` 下载版本配置用于展示 Windows 客户端版本、下载地址、SHA256 与发布说明;T-607/T-609 已新增公开 JSON 版本检查接口给桌面端自动更新使用,并返回强制更新标记。T-610 已在公开首页下载区增加导入模板下载入口,由后台 `ImportTemplate` 配置当前模板。 - **用户与账号层**:注册用户 `User`、点数钱包 `UserWallet`、`ApiKey`(一用户多把、哈希存储)。入口 `apps/users/`。 -- **API 层(DRF)**:对外生成接口、余额查询、支付回调接收、扫码下单、公开版本检查接口。入口 `apps/api/`。生成/余额/模型目录这类对外业务 API **只认 API Key,不接受 Web session**;充值下单/状态查询属于用户端流程,走 Web session + CSRF;支付回调走平台验签;T-607/T-609 的客户端下载版本检查接口为公开只读例外,不需要 API Key,不读取用户、不扣点,`release.force_update` 仅表示当前发布版本是否强制升级。 +- **API 层(DRF)**:对外生成接口、异步图片任务接口、余额查询、支付回调接收、扫码下单、公开版本检查接口。入口 `apps/api/`。生成/余额/模型目录这类对外业务 API **只认 API Key,不接受 Web session**;充值下单/状态查询属于用户端流程,走 Web session + CSRF;支付回调走平台验签;T-607/T-609 的客户端下载版本检查接口为公开只读例外,不需要 API Key,不读取用户、不扣点,`release.force_update` 仅表示当前发布版本是否强制升级。 - **计费层**:点数计算、原子扣减(锁 `UserWallet` 行)、退点、充值入账、流水记账。入口 `apps/billing/`。 - **AI 调用层(Provider Adapter 架构)**:对外只暴露稳定能力,内部用「能力别名 → 具体供应商适配器」解耦。入口 `apps/ai/`,适配器在 `apps/ai/providers/`。 - **内容安全层(本地敏感词 / 后续云审核)**:入口 `apps/moderation/`。T-604 只做 prompt 文本本地敏感词快筛,命中在扣点和调上游前返回 `content_blocked`;云内容安全、图片审核和输出审核保留扩展点,不在 T-604 范围。 @@ -41,6 +41,8 @@ T-301 已实现 `ApiKeyAuthentication` 与 `ExternalApiView`:外部 API 使用 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-614 已实现 `/api/v1/generate/image/tasks` 与 `/api/v1/generate/image/tasks/{task_id}`:提交接口同步审核 prompt、解析图片输入和预扣点,创建 `ImageGenerationTask(status=queued)` 后立即返回公开 UUID `task_id`;后台 worker 通过 `select_for_update(skip_locked)` 抢任务,复用 T-613 `execute_precharged_generation()` 对已预扣 `CallRecord` 调上游、保存结果、成功确认或失败退点。任务表记录 worker 租约、心跳和尝试次数;reaper 识别僵尸 `running` 任务后默认判失败并幂等退点,不默认重排队。异步 worker 没有 DRF request,结果 URL 由 `MEDIA_PUBLIC_BASE_URL` / `PUBLIC_BASE_URL` 生成。 + T-303 已实现 `/api/v1/balance`:外部 API 继续只认 API Key,API 层调用 `apps.billing.services.get_balance_snapshot()` 读取当前钱包余额;测试覆盖余额响应与流水累加一致的场景。 T-304 已实现 `/api/v1/recharge/callback/wechat` 与 `/api/v1/recharge/callback/alipay`:两个回调端点均 `@csrf_exempt` 且不挂登录态;回调验签后调用 `apps.billing.services.apply_recharge_payment()`,按 `order_no` 锁定 `RechargeOrder` 幂等入账,金额或通道不一致不加点。缺真实商户配置时仅允许使用明确的 HMAC mock 模式联调,生产应切换 `PAYMENT_CALLBACK_MODE=sdk`。 @@ -314,18 +316,48 @@ CREATE TABLE call_record ( created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); + +-- 异步图片生成任务(对外只暴露 task_id,不暴露自增 id) +CREATE TABLE image_generation_task ( + id INTEGER PRIMARY KEY, + task_id CHAR(32) UNIQUE NOT NULL, -- UUID + user_id INTEGER NOT NULL REFERENCES "user"(id), + api_key_id INTEGER NOT NULL REFERENCES api_key(id), + call_record_id INTEGER UNIQUE NOT NULL REFERENCES call_record(id), + status TEXT NOT NULL, -- queued / running / succeeded / failed / expired + idempotency_key VARCHAR(128) NOT NULL DEFAULT '', + idempotency_key_hash CHAR(64), -- api_key + hash 唯一;NULL 允许无幂等键任务重复存在 + request_hash CHAR(64) NOT NULL, + request_payload JSON NOT NULL, -- prompt/model/resolution/parameters/输入文件引用,不存 base64 原文 + input_image VARCHAR(100) NOT NULL DEFAULT '', + result_url TEXT NOT NULL DEFAULT '', + error_code VARCHAR(64) NOT NULL DEFAULT '', + error_message TEXT NOT NULL DEFAULT '', + points_balance_after_charge BIGINT NOT NULL DEFAULT 0, + started_at DATETIME(6), + finished_at DATETIME(6), + expires_at DATETIME(6), + locked_at DATETIME(6), + lease_expires_at DATETIME(6), + heartbeat_at DATETIME(6), + worker_id VARCHAR(128) NOT NULL DEFAULT '', + attempt_count INTEGER NOT NULL DEFAULT 0, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + UNIQUE(api_key_id, idempotency_key_hash) +); ``` 需要说明: - 主键自增;`api_key.key_hash`、`pricing_rule(operation_type, alias, resolution)`、`recharge_order.order_no`、`user.username` 唯一。 -- 重要索引:`call_record(user_id, created_at)`、`points_ledger(user_id, created_at)`、`recharge_order(order_no)`、`api_key(key_hash)`、`signup_bonus_grant(user_id)`。 +- 重要索引:`call_record(user_id, created_at)`、`points_ledger(user_id, created_at)`、`recharge_order(order_no)`、`api_key(key_hash)`、`signup_bonus_grant(user_id)`、`image_generation_task(status, created_at)`、`image_generation_task(status, lease_expires_at)`、`image_generation_task(user_id, created_at)`。 - 不软删除业务流水;用户/账号可标记 `disabled` 而非物理删;API Key 用 `revoked` 状态而非物理删。 - 服务端生成字段:`api_key.key_hash`/`key_prefix`、`points_balance`、`balance_after`、各 `created_at` / `updated_at`。 - `payment_user_id`、`payment_txn_no` 为对账预留,字段先建。 - `recharge_order.exchange_rate` 与 `points_granted` 在下单时写入,状态为 `pending` 时也必须有值;支付回调金额必须与订单金额一致,入账时不得按新的汇率重算。 - `call_record.status` 状态机为 `pending -> success / failed`。上游失败退点后仍保持 `failed`,退款流水通过 `points_ledger(change_type=refund, ref_call_id=call_record.id)` 关联,不单独增加 `refunded` 状态,避免调用结果与账务动作混在一个字段里。 -- T-201 已落地 `UserWallet` / `ApiKey` 于 `apps.users`,`PointsLedger` / `CallRecord` 于 `apps.billing`;T-203 已落地扣点/退点服务;T-304 已落地 `RechargeOrder`、回调幂等入账服务和 `points_ledger(ref_order_id, change_type)` 复合唯一约束,`ref_order_id` 当前仍为数值引用 `RechargeOrder.id`;T-305 已落地 `create_recharge_order()`,负责创建 pending 订单、锁定汇率/点数并回填二维码票据;T-401 已落地 `adjust_wallet_points()`,手工调整点数必须带原因并写 `adjust` 流水,后台钱包余额字段只读;T-608 已新增 `signup_bonus` 流水类型和 MySQL 兼容的注册赠点幂等标记 `SignupBonusGrant(user UNIQUE)`,并把 allauth 自助注册路径改为调用 `grant_signup_bonus()` 发放 100 点;T-502 已把 API Key 自助管理接到 `ApiKey.create_for_user()`,删除动作写为 `revoked` 状态而非物理删除;T-503/T-608 已把个人中心和记录页接到只读查询,余额用 `get_balance_snapshot()`,充值总额按 paid `RechargeOrder` 汇总,充值 / 注册赠点 / 消费 / 退款按 `PointsLedger` 汇总;T-504 已把 `/recharge` 页面接到 `create_recharge_order()` 与 `/api/v1/recharge/status`。 +- T-201 已落地 `UserWallet` / `ApiKey` 于 `apps.users`,`PointsLedger` / `CallRecord` 于 `apps.billing`;T-203 已落地扣点/退点服务;T-304 已落地 `RechargeOrder`、回调幂等入账服务和 `points_ledger(ref_order_id, change_type)` 复合唯一约束,`ref_order_id` 当前仍为数值引用 `RechargeOrder.id`;T-305 已落地 `create_recharge_order()`,负责创建 pending 订单、锁定汇率/点数并回填二维码票据;T-401 已落地 `adjust_wallet_points()`,手工调整点数必须带原因并写 `adjust` 流水,后台钱包余额字段只读;T-608 已新增 `signup_bonus` 流水类型和 MySQL 兼容的注册赠点幂等标记 `SignupBonusGrant(user UNIQUE)`,并把 allauth 自助注册路径改为调用 `grant_signup_bonus()` 发放 100 点;T-614 已在 `apps.api` 落地 `ImageGenerationTask` 与 `api.0001_initial` 迁移,使用 MySQL 兼容的 `(api_key, idempotency_key_hash)` 唯一约束处理幂等键,不使用条件唯一约束;T-502 已把 API Key 自助管理接到 `ApiKey.create_for_user()`,删除动作写为 `revoked` 状态而非物理删除;T-503/T-608 已把个人中心和记录页接到只读查询,余额用 `get_balance_snapshot()`,充值总额按 paid `RechargeOrder` 汇总,充值 / 注册赠点 / 消费 / 退款按 `PointsLedger` 汇总;T-504 已把 `/recharge` 页面接到 `create_recharge_order()` 与 `/api/v1/recharge/status`。 ## 四、计费时序(核心,务必照此实现) @@ -352,6 +384,31 @@ CREATE TABLE call_record ( - **先扣后调、失败必退**:保证不会“调用成功但没扣到”或“失败还扣钱”。 - 余额不足在调上游**之前**拦截。 +### 4.1.1 调用扣点(异步图片任务) + +```text +1. API Key 鉴权 + serializer 参数校验。 +2. prompt 内容安全审核;命中 content_blocked → 400,不建任务、不扣点、不调上游。 +3. 图片输入校验 / 解码:image_base64 解码后保存为输入文件引用,任务快照不保存 base64 原文;image_url 执行公网/协议/大小校验。 +4. 别名、Provider 能力、PricingRule 校验;缺规则或能力不符 → 不扣点。 +5. 事务:锁钱包预扣点,写 CallRecord(pending) + PointsLedger(consume),创建 ImageGenerationTask(queued, call_record=已预扣调用)。 +6. 立即返回 202 + task_id。 +7. worker 抢 queued → running,写 worker_id / locked_at / lease_expires_at / heartbeat_at / attempt_count。 +8. worker 复用已预扣 CallRecord 调上游: + 成功 → mark_call_success,任务置 succeeded,写稳定 result_url。 + 失败 / 超时 → refund_call_points 幂等退点,任务置 failed。 +9. reaper 扫描 lease/heartbeat 过期的 running: + 默认置 failed + refund_call_points;不默认重排队。 + 若 call_record 已 success,则只把任务补成 succeeded,不退款。 +``` + +要点: + +- 提交即预扣,避免余额只够 1 张却排入大量任务;余额不足仍返回 `402 insufficient_points`。 +- `Idempotency-Key` 按当前 API Key 去重,同 key 同 payload 返回同一任务且不重复扣点;同 key 不同 payload 返回 `409 idempotency_conflict`。 +- worker 是至少一次执行模型,但账务终态和结果终态必须 exactly-once:不得重复扣/退,不得覆盖已成功结果,也不得把 reaper 已失败退款的任务改回成功。 +- 查询接口只按 `task_id` + 当前 API Key 所属用户查任务,防止 IDOR;成功任务重复查询返回同一 `result_url`。 + ### 4.2 充值入账(自助扫码 + 支付回调) ```text @@ -408,7 +465,8 @@ CREATE TABLE call_record ( | 并发扣点超扣 | 多请求同时扣同一账号 | 行锁 / 原子更新 + DB 约束 `>=0`(MySQL 的 CHECK 需 ≥8.0.16 才生效,不能只靠它;主防线是 `select_for_update` 或 `UPDATE ... WHERE balance>=N`),并发测试覆盖 | | 充值重复入账 | 回调可能重发 | `order_no` 唯一 + 状态机幂等 | | 回调伪造 | 不验签会被刷点 | 强制验签,验签失败不入账并留痕 | -| 图片生成耗时长 | 同步等待几十秒到分钟级;后台可换上慢模型拖垮 worker | T-612 后生图 Provider 读取超时取 `min(AiModel.timeout_seconds 或分辨率默认值, AI_IMAGE_UPSTREAM_DEADLINE_SECONDS)`,撞硬截止走失败退点;worker 数/网关超时按内层硬截止预留;V2 异步化 | +| 图片生成耗时长 | 同步等待几十秒到分钟级;后台可换上慢模型拖垮 worker | T-612 后生图 Provider 读取超时取 `min(AiModel.timeout_seconds 或分辨率默认值, AI_IMAGE_UPSTREAM_DEADLINE_SECONDS)`,撞硬截止走失败退点;T-614 已提供异步提交 / 轮询路径,新客户端优先使用异步任务,旧同步接口保留兼容 | +| 异步任务僵死 / 迟到 worker | worker 抢到任务后进程崩溃会留下 running;迟到 worker 可能在 reaper 退款后返回成功 | `ImageGenerationTask` 记录租约和心跳;reaper 按 `lease_expires_at` / `heartbeat_at` 幂等失败退款;成功/失败终态写入前重新锁任务,禁止覆盖已成功或已退款失败的任务 | | 上游错误分类 | 区分参数错误与上游故障 | AI 层抛分类异常;上游故障退点 | | 凭证安全 | 上游 api_key、支付密钥 | 应用层加密存储,admin 脱敏不回显;其余密钥走环境变量,不入代码与样例 | | 抽象泄漏 / 参数越权 | 各供应商入参出参不一致;若调用方 `parameters` 可覆盖 `model`/`n`/`size` 会击穿别名计费 | Provider 适配器 + capabilities 声明 + `parameters` 白名单过滤;核心/计费字段服务端固定,不取交集、不允许覆盖 | @@ -430,7 +488,7 @@ CREATE TABLE call_record ( 1. **超时链路层层放大且对齐**(外层 ≥ 内层):桌面端读超时 ≥ Nginx `proxy_read_timeout` ≥ Gunicorn `--timeout` ≥ cmhub→中转站实际读取超时。T-612 后生图读取超时为 `min(AiModel.timeout_seconds 或分辨率默认值, AI_IMAGE_UPSTREAM_DEADLINE_SECONDS)`;默认硬截止约 180s,生产按真实图片 smoke 校准。三个默认值仍是雷:`AiModel.timeout_seconds`(`0` 表示按 Provider 分辨率默认值,而不是无限等待)、Gunicorn 默认 `30s`(会直接杀掉跑图片的 worker)、Nginx 默认 `60s`。 2. **worker/线程数按峰值总并发预留**:同步下每个在飞请求占住一个 worker 整个生成周期(几十秒~分钟)。用 Gunicorn `gthread`(`--threads`)或多 sync worker,数量 ≥ 预期峰值总并发 + 余量,避免慢图片把快的生文接口和后台挤死。 -适用边界:**单接入方、小并发批量**(桌面端 `concurrency` 1~4)完全够用,点数一致性也更简单。当出现「worker 被长连接占满拖慢快接口/后台」「接入方总并发明显上涨」「需要关窗重连/任务持久化」任一信号时,才转 V2 异步(队列),在此之前保持同步;但适配器接口与 `call_record` 的 `pending/success/failed` 三态需为异步预留口子(见 4.1、七、八)。 +适用边界:**旧客户端 / 小并发批量**(桌面端 `concurrency` 1~4)仍可继续同步使用,点数一致性简单。T-614 起新版客户端优先走异步任务接口:提交后拿 `task_id`,后台 worker 慢慢生成,客户端轮询状态和结果,适合图片耗时长、窗口重启后继续查询、以及避免 HTTP 长连接占满生成池的场景。旧同步接口的用量观察和弃用条件放到 T-615。 ## 六、推荐开发顺序 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index bf4eb04..89c4e07 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -92,7 +92,7 @@ | 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` 留证据 | 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-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` 留证据 | DONE | | 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 eb1fa11..96a9a7a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -13,7 +13,7 @@ - 用户或 Key 被禁用/吊销(disabled/revoked):返回 `403`。 - **对外 API 只接受 API Key 认证,不接受 Web session**(浏览器带 cookie 也不能调 API,防绕过计费归属)。 - 例外:客户端下载版本检查接口只返回公开发布元数据,设计为匿名只读接口,不需要 API Key,不读取用户、不扣点。 -- 图片生成为**同步**接口,可能耗时较长,调用方与网关需设置足够超时(≥ 300s)。 +- 图片生成提供两条并存路径:旧 `POST /api/v1/generate/image` 为同步接口,可能耗时较长;T-614 起新增异步任务接口 `POST /api/v1/generate/image/tasks` + `GET /api/v1/generate/image/tasks/{task_id}`,新版桌面端优先使用异步提交 / 轮询,旧客户端继续走同步接口。 - 用户端注册使用同一个 `User` 账本主体;注册邮箱**必填且唯一**(`ACCOUNT_EMAIL_VERIFICATION="none"`,**不做邮箱验证**、注册即可用),唯一约束避免同邮箱对应多个点数账户。 - T-608 起新用户注册成功一次性赠送 **100 点**试用点数;赠点必须经计费层写入钱包和 `PointsLedger(change_type=signup_bonus)`,不得直接改余额字段。历史用户是否补发不属于默认注册流程。 - API Key 库内只存 `key_hash`(SHA-256)与 `key_prefix`,明文只在创建时返回一次,不在 admin、日志或调用记录中回显。 @@ -21,7 +21,9 @@ T-301 已实现对外 API 鉴权基线:`apps.api.authentication.ApiKeyAuthentication` 只解析 `Authorization: Bearer `;生成、余额等外部 API 视图应继承 `apps.api.views.ExternalApiView`,不接受 Web session。 -T-302 已实现生成接口基线:`POST /api/v1/generate/title` 与 `POST /api/v1/generate/image` 已接入 API Key 鉴权、别名解析、计费规则、预扣点、Provider 调用、成功确认和失败退点;图片结果当前以本地 `MEDIA_ROOT` 保存并返回 `image_url`,后续可替换为对象存储。T-613 起内部实现已抽为生成核心 service,旧同步接口仍保持原字段、状态码和错误语义;后续异步 worker 必须复用同一套预扣、执行、确认和退点阶段,不得复制第二套资金逻辑。 +T-302 已实现生成接口基线:`POST /api/v1/generate/title` 与 `POST /api/v1/generate/image` 已接入 API Key 鉴权、别名解析、计费规则、预扣点、Provider 调用、成功确认和失败退点;图片结果当前以本地 `MEDIA_ROOT` 保存并返回 `image_url`,后续可替换为对象存储。T-613 起内部实现已抽为生成核心 service,旧同步接口仍保持原字段、状态码和错误语义。T-614 起新增异步生图任务接口,提交阶段同步审核 prompt 和预扣点,worker 复用同一套已预扣执行 / 成功确认 / 失败退点阶段,不复制第二套资金逻辑。 + +T-614 已实现生图异步任务化:`POST /api/v1/generate/image/tasks` 返回 `202` 与公开 UUID `task_id`,`GET /api/v1/generate/image/tasks/{task_id}` 轮询任务状态和结果。任务表 `ImageGenerationTask` 关联 `user`、`api_key` 与已预扣 `CallRecord`,支持 `Idempotency-Key` 去重、payload 冲突 `409 idempotency_conflict`、租约 / 心跳 / reaper 处理僵尸 running 任务并幂等退点。异步结果 URL 由 `MEDIA_PUBLIC_BASE_URL` 或 `PUBLIC_BASE_URL` 生成,不透传上游临时链接。 T-303 已实现余额查询基线:`GET /api/v1/balance` 已接入 API Key 鉴权,返回当前 `UserWallet.points_balance`,并保留旧字段同时新增不含邮箱的 `account` 账号展示对象;测试覆盖响应余额与 `PointsLedger.points_delta` 累加值一致的账务场景,并确认 Web session 不能调用该外部接口。 @@ -71,6 +73,9 @@ T-607/T-609 已实现 `GET /api/v1/client/releases/latest?platform=windows`, | `no_pricing_rule` | 未配置对应计费规则 | 400 | | `model_not_allowed` | 该模型不支持此操作(如图片模型生成标题) | 400 | | `content_blocked` | 输入内容未通过安全审核;T-604 中表示 prompt 命中本地敏感词,未扣点 | 400 | +| `idempotency_conflict` | 同一 `Idempotency-Key` 已用于不同请求 payload | 409 | +| `task_not_found` | 图片生成任务不存在或不属于当前 API Key 所属用户 | 404 | +| `task_timeout` | 异步图片任务租约超时,reaper 已判失败并退点 | 200(轮询响应内 `status=failed`) | | `upstream_timeout` | 上游 AI 调用超时(已退点) | 502 | | `upstream_error` | 上游 AI 失败(已退点) | 502 | | `signature_invalid` | 支付回调验签失败 | 400 | @@ -193,6 +198,112 @@ GET /api/v1/client/releases/latest?platform=windows 要点:改图类模型缺原图返回 `bad_request`,不落 500;上游超时返回 `upstream_timeout`,其他上游失败返回 `upstream_error`,两者都**不最终扣点**(已预扣则退回)。T-604 后 prompt 命中本地敏感词时返回 `content_blocked`,并且不得下载 `image_url` 或解码大图后再拦截。T-612 后生图上游读取超时会受 `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS` 硬截止保护,避免旧同步接口长期占用生成池线程。结果默认存对象存储返回 `image_url`,避免同步响应体过大。请求里的 `image_url` 适用同标题接口相同的 SSRF 防护和大小上限;内网图片请用 `image_base64`。 +### `POST /api/v1/generate/image/tasks` + +提交异步图片生成任务。该接口与旧同步接口共存,参数与 `POST /api/v1/generate/image` 一致,额外支持请求头 `Idempotency-Key`。 + +请求: + +```http +POST /api/v1/generate/image/tasks +Authorization: Bearer sk_cmhub_xxx +Idempotency-Key: desktop-job-20260708-0001 +Content-Type: application/json +``` + +```json +{ + "prompt": "把这件衣服换成模特上身的穿搭图", + "model": "image-hd", + "image_base64": "data:image/png;base64,...", + "resolution": "1K", + "aspect_ratio": "1:1", + "parameters": {} +} +``` + +成功响应: + +```json +{ + "task_id": "2bff8217-47a9-44f1-9bd9-82a375e79dc9", + "status": "queued", + "call_id": 12346, + "points_cost": 10, + "points_balance": 88, + "created_at": "2026-07-08T21:30:00+08:00", + "expires_at": "2026-07-09T21:30:00+08:00" +} +``` + +要点: + +- 提交阶段同步执行 prompt 审核;命中敏感词返回 `400 content_blocked`,不建任务、不扣点、不调上游。 +- 提交阶段预扣点数;余额不足返回 `402 insufficient_points`,不建任务。 +- `Idempotency-Key` 按当前 API Key 去重。同 key + 同 payload 返回同一 `task_id`,不重复扣点;同 key + 不同 payload 返回 `409 idempotency_conflict`。 +- 不保存 provider `raw`、上游密钥或 `image_base64` 原文;base64 会解码后作为输入文件引用保存,任务请求快照只保存必要字段和文件引用。 +- `image_url` 仍按同步接口的 SSRF 与大小规则处理,失败不扣点。 + +### `GET /api/v1/generate/image/tasks/{task_id}` + +轮询异步图片生成任务。请求: + +```http +GET /api/v1/generate/image/tasks/2bff8217-47a9-44f1-9bd9-82a375e79dc9 +Authorization: Bearer sk_cmhub_xxx +``` + +排队或执行中: + +```json +{ + "task_id": "2bff8217-47a9-44f1-9bd9-82a375e79dc9", + "status": "running", + "call_id": 12346, + "points_cost": 10, + "created_at": "2026-07-08T21:30:00+08:00", + "updated_at": "2026-07-08T21:30:05+08:00", + "expires_at": "2026-07-09T21:30:00+08:00" +} +``` + +成功: + +```json +{ + "task_id": "2bff8217-47a9-44f1-9bd9-82a375e79dc9", + "status": "succeeded", + "call_id": 12346, + "points_cost": 10, + "created_at": "2026-07-08T21:30:00+08:00", + "updated_at": "2026-07-08T21:31:40+08:00", + "expires_at": "2026-07-09T21:30:00+08:00", + "result": { + "image_url": "https://cm.833729.com/media/generated/images/2026/07/08/result.png" + } +} +``` + +失败: + +```json +{ + "task_id": "2bff8217-47a9-44f1-9bd9-82a375e79dc9", + "status": "failed", + "call_id": 12346, + "points_cost": 10, + "created_at": "2026-07-08T21:30:00+08:00", + "updated_at": "2026-07-08T21:33:00+08:00", + "expires_at": "2026-07-09T21:30:00+08:00", + "error": { + "code": "task_timeout", + "message": "图片生成任务超时,已退回点数" + } +} +``` + +要点:查询只允许任务所属 `user` 与当前 API Key 所属 `user` 一致,跨用户返回 `404 task_not_found`;`succeeded` 重复查询必须返回同一个 `image_url`;`failed` 表示已退款或未扣款,不需要客户端再请求退款。任务元数据默认保留 `IMAGE_TASK_RETENTION_HOURS`(默认 24h),结果图片保留窗口按 `GENERATED_IMAGE_RETENTION_HOURS`(默认 72h)管理;本任务暂不暴露 cancel 路由。 + ### `GET /api/v1/balance` 查询当前账号点数余额。成功响应: diff --git a/docs/current-state.md b/docs/current-state.md index 33146fd..c61d3eb 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -12,10 +12,12 @@ ## 当前快照 - 日期: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 抽生成核心 service 已完成;后续仍需处理真实支付回调到账闭环、客户端下载包发布和 T-614~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` 并走既有失败退点路径;T-613 已把旧同步生成链路抽成 `GenerationInput`、`prepare_generation()`、`precharge_generation()`、`execute_precharged_generation()` 与 `GenerationResult`,旧 view 只负责 serializer 和异常转 HTTP,后续异步 worker 可复用已预扣执行 / 确认 / 退点阶段。 +- T-614 生产代码补充:已新增 `ImageGenerationTask` 与 `api.0001_initial`,新增 `POST /api/v1/generate/image/tasks`、`GET /api/v1/generate/image/tasks/{task_id}`、`apps.api.image_tasks` 任务服务、`run_image_tasks` management command 和只读 admin;异步提交支持 `Idempotency-Key` 去重 / 冲突检测,worker 使用 DB 任务表、租约、心跳与 reaper,成功返回 cmhub 托管 URL,失败 / 超时 / 僵任务走计费层幂等退款。 - 用户端导航:顶部导航 active 状态已修复,`portal/base.html` 基于 `request.resolver_match.url_name` 高亮当前页面入口,并用 `aria-current="page"` 标记;「充值」不再在非充值页固定深色高亮。 +- 最新验证:T-614 生图异步任务化接口已验证 `py -3.12 manage.py makemigrations api` 生成 `api.0001_initial`;`py -3.12 manage.py test apps.api.tests.GenerateApiTests -v 2 --keepdb` 通过,28 tests OK,覆盖敏感词拦截不建任务/不扣点、余额不足 402、Idempotency-Key 去重和冲突、worker 成功轮询同一 URL、跨用户拒绝、失败/超时退款、reaper 僵任务退款、重复 worker 幂等和 reaper 退款后的迟到 worker 不可改回成功。首次不带 `--keepdb` 运行时因已有 `test_cmhub` 触发交互式删除确认导致 EOF 中断;重跑 `--keepdb` 通过。测试期仍保留 allauth 在 MySQL 条件唯一约束上的既有 `models.W036` 警告。 - 最新验证: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` 警告。 @@ -35,8 +37,8 @@ - 标准启动路径:Windows 用 `./init.ps1`;Unix/WSL 用 `./init.sh` - 标准验证路径: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-614~T-615 继续推进。 +- 配置基线:运行环境变量集中见 `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` 上游读取硬截止。T-614 起异步生图配置包含 `PUBLIC_BASE_URL`、`MEDIA_PUBLIC_BASE_URL`、`IMAGE_TASK_RETENTION_HOURS`、`GENERATED_IMAGE_RETENTION_HOURS`、`IMAGE_TASK_REAPER_INTERVAL_SECONDS`、`IMAGE_TASK_LEASE_SECONDS`;生产必须运行 `run_image_tasks` worker。 +- 当前 blocker:微信正式下单已能返回二维码,但线上微信回调曾出现 `PaymentVerificationError`,仍需单独修复并完成“付款后自动入账”闭环验收;支付宝恢复依赖开放平台把 `43.128.3.240` 加入可信 IP。线上真实标题生成已跑通并验证扣点;图片同步旧接口已由 T-612 加上游硬截止止血,T-614 已提供异步任务化路径,下一步按 T-615 补旧同步接口遥测与弃用口径。 ## 当前目录要点 @@ -49,6 +51,7 @@ | `requirements.txt` / `requirements-production.txt` / `pyproject.toml` | 已有 | `requirements.txt` 管本地/基础运行依赖;`requirements-production.txt` 追加 Linux 生产 Gunicorn;`pyproject.toml` 落地 `requires-python`;T-101 新增 `requests`;T-102 使用既有 `cryptography` 做 Fernet 加密;T-501 新增 `django-allauth` | | `config/`(Django 工程) | 已有 | T-001 创建,含 settings / urls / wsgi / asgi | | `apps/`(users/portal/billing/ai/api/moderation) | 已有 | T-002 创建;`apps/users` 已定义自定义 `User`;T-003 已注册 admin 与 admin smoke test;T-004 已给 `User.email` 加唯一约束;T-101 已新增 `apps/ai/providers`;T-102 已新增 `apps/ai/security.py`、`aliases.py`、`importers.py`、management command 与 `ai.0001_initial` 迁移;T-103 已新增 `apps/ai/audit.py` 与 `ai.0002_aiconfigauditlog` 迁移;T-104/T-105 已新增 `smoke_ai_generation` 录制 title/image smoke 命令;T-201 已在 users 落 `UserWallet` / `ApiKey`,在 billing 落 `PointsLedger` / `CallRecord`;T-202 已在 billing 落 `PricingRule` / `ExchangeRate` 与 `pricing.py`;T-203/T-303/T-304/T-305 已在 `apps/billing/services.py` 落扣点/退点、余额快照、充值入账与充值下单;T-401 已在 `apps/billing/services.py` 落 `adjust_wallet_points()`,在 `apps/users/admin.py` 与 `apps/users/templates/admin/users/userwallet/adjust_points.html` 落钱包手工调点入口;T-608 已在 billing 落 `SignupBonusGrant`、`grant_signup_bonus()`、`signup_bonus` 流水类型、admin 检索和迁移;T-304/T-305 已在 `apps/billing/payment_gateways.py` 落回调验签、mock 下单与 SDK 入口;T-301~T-306 已在 api 落鉴权、生成接口编排、序列化器、图片存储、余额查询、充值回调、充值下单/状态查询、`image_url` SSRF 防护、生成/认证限流与统一 429 错误响应;T-501/T-608 已在 portal 落 allauth 注册/登录/登出路由、模板、adapter 与 dashboard,注册成功自动赠送 100 点;T-502 已在 portal 落 `/apikeys`、API Key 创建表单、列表模板与删除(吊销)动作;T-503/T-608 已在 portal 落个人中心汇总、充值记录和点数记录页;T-504 已在 portal 落 `/recharge` 充值页、充值表单、二维码票据展示和状态轮询;T-505 已在 portal 落本地 vendor static 与记录分页;T-601 已在 `apps/ai/catalog.py` 落公开模型目录,在 api 落 `/api/v1/models`,在 portal 落 `/models` 页面和导航入口;T-604 已在 moderation 落 `SensitiveWord`、keyword matcher、归一化、共享 cache 版本失效与 admin;T-606 已在 portal 落公开首页、`DownloadRelease`、下载版本 admin、`brand.css` 与首页模板;T-607/T-609 已在 api 落 `/api/v1/client/releases/latest` 公开版本检查接口,并在 portal `DownloadRelease` 落 `force_update` 字段与 admin 展示 | +| `apps/api` 异步生图 | 已有 | T-614 已落 `ImageGenerationTask`、`api.0001_initial`、异步提交/轮询路由、任务 service、worker lease / heartbeat / reaper、`run_image_tasks` 命令和 admin 只读排障;旧同步生图接口保留 | | `manage.py` | 已有 | T-001 创建 | | `tests/` | 待建 | 随各任务补充 | @@ -56,11 +59,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-613 抽生成核心 service(计费+审核+上游共享 core)。 +- 已完成: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-614 生图异步任务化接口(提交+轮询,新增不动旧接口)。 - 正在进行:无。 -- 待开始:T-614 生图异步任务化接口、T-615 旧同步生图接口用量遥测 + 弃用口径。 -- 当前 blocker:支付商户真实密钥/证书与生产 SDK 依赖仍待提供;微信回调到账闭环仍需真实支付验收;真实 AI 标题生成已在线上跑通,图片生成慢 / 504 / 客户端超时风险已拆为 T-612~T-615 分阶段处理。 -- 下一个可领取任务:T-614 生图异步任务化接口(提交+轮询,新增不动旧接口)。 +- 待开始:T-615 旧同步生图接口用量遥测 + 弃用口径。 +- 当前 blocker:支付商户真实密钥/证书与生产 SDK 依赖仍待提供;微信回调到账闭环仍需真实支付验收;真实 AI 标题生成已在线上跑通,图片生成慢 / 504 / 客户端超时风险已拆为 T-612~T-615 分阶段处理,T-614 已提供异步任务路径。 +- 下一个可领取任务:T-615 旧同步生图接口用量遥测 + 弃用口径。 ## 当前可运行内容 @@ -90,12 +93,15 @@ python3.12 manage.py createcachetable cmhub_cache python3.12 manage.py collectstatic --noinput gunicorn config.wsgi:application --bind 127.0.0.1:8001 --workers 2 --worker-class gthread --threads 4 --timeout 120 gunicorn config.wsgi:application --bind 127.0.0.1:8002 --workers 1 --worker-class gthread --threads 4 --timeout 360 +python3.12 manage.py run_image_tasks --worker-id cmhub-image-worker-1 --sleep-seconds 1 ``` 当前对外 API: - `POST /api/v1/generate/title` - `POST /api/v1/generate/image` +- `POST /api/v1/generate/image/tasks` +- `GET /api/v1/generate/image/tasks/{task_id}` - `GET /api/v1/balance` - `GET /api/v1/models` - `GET /api/v1/client/releases/latest` @@ -119,14 +125,15 @@ gunicorn config.wsgi:application --bind 127.0.0.1:8002 --workers 1 --worker-clas - `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/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`。真实标题上游生成已在线上跑通并验证扣点;图片生成真实耗时仍需补测。 +T-614 已落地异步生图任务化:提交接口预扣后返回 `task_id`,轮询接口返回 queued/running/succeeded/failed,worker 和 reaper 复用同一套成功确认 / 失败退点路径,旧同步接口继续兼容。 ## 开始编码前检查 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-614。 -5. 下一步优先处理 T-614,新增生图异步提交 / 轮询接口,并复用 T-613 的审核 / 计费 / 上游 / 退点核心阶段;真实支付回调到账闭环和客户端下载包发布继续作为后续业务事项排期。 +4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务;当前为 T-615。 +5. 下一步优先处理 T-615,给旧同步生图接口补结构化遥测和弃用口径;真实支付回调到账闭环和客户端下载包发布继续作为后续业务事项排期。 ## 维护规则 diff --git a/docs/deployment.md b/docs/deployment.md index 518fc7a..5f3b339 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -11,9 +11,11 @@ -> 宝塔 / Nginx /static/ -> STATIC_ROOT /media/ -> MEDIA_ROOT(MVP 本地媒体;后续可换对象存储) - /api/v1/generate/* -> Gunicorn 长请求池(图片同步,超时更长) + /api/v1/generate/image/tasks* -> Gunicorn 普通请求池(异步提交/轮询,短请求) + /api/v1/generate/* -> Gunicorn 长请求池(旧同步生成,超时更长) 其他路径 -> Gunicorn 普通请求池(用户端 / admin / 余额 / 充值) -> cmhub Django 单体 + -> cmhub image-task worker(management command,后台处理异步生图) -> MySQL 8.4 -> Django shared cache(MVP 可用 MySQL DatabaseCache;高并发换 Redis/Memcached) ``` @@ -112,6 +114,12 @@ STATIC_URL=/static/ STATIC_ROOT=/www/wwwroot/cmhub/staticfiles MEDIA_ROOT=/www/wwwroot/cmhub/media MEDIA_URL=/media/ +PUBLIC_BASE_URL=https://cmhub.example.com +MEDIA_PUBLIC_BASE_URL=https://cmhub.example.com +IMAGE_TASK_RETENTION_HOURS=24 +GENERATED_IMAGE_RETENTION_HOURS=72 +IMAGE_TASK_REAPER_INTERVAL_SECONDS=60 +IMAGE_TASK_LEASE_SECONDS=600 DJANGO_CACHE_BACKEND=django.core.cache.backends.db.DatabaseCache DJANGO_CACHE_LOCATION=cmhub_cache @@ -136,6 +144,7 @@ python3.12 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key - 当前注册策略为免邮箱验证,注册登录不依赖邮件服务;T-608 后注册成功会赠送 100 点,生产必须保留或收紧 `ACCOUNT_SIGNUP_RATE_LIMIT`,并使用共享 Django cache 承载限流计数;若后续启用密码找回、通知或恢复邮箱验证,再把 `DJANGO_EMAIL_BACKEND` 改为真实 SMTP / 邮件服务并配置 `DJANGO_DEFAULT_FROM_EMAIL`。 - `PAYMENT_CALLBACK_MODE=sdk` 必须配齐微信 / 支付宝商户配置;未配齐时先保持 `mock`。 - 宝塔 / Nginx 已强制 HTTPS 时,`DJANGO_SECURE_SSL_REDIRECT=false` 即可;全站 HTTPS 稳定后再把 `DJANGO_SECURE_HSTS_SECONDS` 调大,避免 HSTS 误锁域名。 +- T-614 异步生图 worker 没有 request 对象,生产必须配置 `MEDIA_PUBLIC_BASE_URL` 或 `PUBLIC_BASE_URL` 为公开 HTTPS 域名,否则异步轮询成功时可能返回相对 `/media/...` URL。 ## 五、初始化数据库与静态文件 @@ -262,6 +271,42 @@ gunicorn config.wsgi:application \ systemd 单元可分别命名为 `cmhub-web.service` 和 `cmhub-generate.service`。服务的 `WorkingDirectory` 指向 `/www/wwwroot/cmhub`,`ExecStart` 使用上面的两条 Gunicorn 命令,环境变量由项目根目录 `.env` 在 Django settings 中读取。 +异步生图 worker 示例: + +```bash +python3.12 manage.py run_image_tasks \ + --worker-id cmhub-image-worker-1 \ + --sleep-seconds 1 +``` + +建议单独托管为 `cmhub-image-worker.service`。该 worker 从数据库 `image_generation_task` 表抢 `queued` 任务,使用 MySQL `select_for_update(skip_locked)` 标记 `running`,执行成功后写 `succeeded` 和稳定 `result_url`;失败或上游超时会调用计费层退点并写 `failed`。worker 循环会按 `IMAGE_TASK_REAPER_INTERVAL_SECONDS` 扫描租约或心跳过期的 `running` 任务,默认判失败并幂等退点,不默认重排队。 + +systemd 单元示例: + +```ini +[Unit] +Description=cmhub image task worker +After=network.target mysql.service + +[Service] +Type=simple +WorkingDirectory=/www/wwwroot/cmhub +ExecStart=/usr/bin/python3.12 manage.py run_image_tasks --worker-id cmhub-image-worker-1 --sleep-seconds 1 +Restart=always +RestartSec=5 +User=www +Group=www + +[Install] +WantedBy=multi-user.target +``` + +部署异步生图时至少需要同时运行: + +- `cmhub-web.service`:用户端、admin、余额、模型目录、异步提交 / 轮询等短请求。 +- `cmhub-generate.service`:旧同步生成接口,保留给老客户端。 +- `cmhub-image-worker.service`:新异步任务真正调上游生成图片。 + ## 八、宝塔 / Nginx 配置 宝塔中新建站点并绑定域名和 SSL 后,在站点 Nginx 配置中加入或调整: @@ -285,6 +330,28 @@ server { add_header Cache-Control "public"; } + location = /api/v1/generate/image/tasks { + proxy_pass http://127.0.0.1:8001; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_connect_timeout 30s; + proxy_send_timeout 120s; + proxy_read_timeout 120s; + } + + location ^~ /api/v1/generate/image/tasks/ { + proxy_pass http://127.0.0.1:8001; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_connect_timeout 30s; + proxy_send_timeout 120s; + proxy_read_timeout 120s; + } + location /api/v1/generate/ { proxy_pass http://127.0.0.1:8002; proxy_set_header Host $host; @@ -313,6 +380,7 @@ server { - `/static/` 必须指向 `STATIC_ROOT`,否则 Bootstrap/qrcode 本地资源在 `DEBUG=False` 下 404。 - `/media/` 是 MVP 本地图片结果访问路径;如果改对象存储,应同步更新 `MEDIA_URL` 和存储配置。 +- `/api/v1/generate/image/tasks` 与 `/api/v1/generate/image/tasks/{task_id}` 必须写在 `/api/v1/generate/` 前面,确保异步提交 / 轮询走普通 web 池;旧同步生成接口继续走长请求池。 - 宝塔若已在外层配置 HTTP 到 HTTPS 跳转,`DJANGO_SECURE_SSL_REDIRECT=false` 即可;如果由 Django 负责跳转,再设为 `true`。 ## 九、上线前验证 @@ -335,6 +403,13 @@ python3.12 manage.py test apps.api --noinput --keepdb --verbosity 2 python3.12 manage.py test apps.portal --noinput --keepdb --verbosity 2 ``` +异步生图 worker 单次验证: + +```bash +python3.12 manage.py run_image_tasks --once --worker-id smoke-worker +python3.12 manage.py run_image_tasks --reap-only +``` + AI smoke: ```bash @@ -362,6 +437,8 @@ python3.12 manage.py smoke_ai_generation image - HTTPS 已由宝塔 / Nginx 强制跳转;如由 Django 强制跳转则设置 `DJANGO_SECURE_SSL_REDIRECT=true`。HSTS 只在确认全站 HTTPS 后启用。 - `STATIC_ROOT` 已 `collectstatic`,Nginx 可访问 `/static/portal/vendor/bootstrap/bootstrap.min.css` 和 `/static/portal/vendor/qrcode/qrcode.js`。 - `MEDIA_ROOT` 或对象存储可访问生成图片 URL。 +- `MEDIA_PUBLIC_BASE_URL` / `PUBLIC_BASE_URL` 已配置为客户端可访问的 HTTPS 域名;异步生图成功返回的 `result.image_url` 可公网下载。 +- `cmhub-image-worker.service` 已启动并设置开机自启;`journalctl -u cmhub-image-worker.service` 无持续异常。 - `DJANGO_CACHE_BACKEND` 为共享 cache,不是 `LocMemCache`。 - MySQL 是 8.4 / InnoDB / `utf8mb4`。 - 真实支付 SDK 与商户配置齐全后,`PAYMENT_CALLBACK_MODE=sdk`。 diff --git a/docs/env.md b/docs/env.md index c4fcff4..4322b94 100644 --- a/docs/env.md +++ b/docs/env.md @@ -52,6 +52,12 @@ | --- | --- | --- | --- | | `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 / 客户端超时必须大于该值 | +| `PUBLIC_BASE_URL` | 否 | `https://cm.833729.com` | 站点公开基础 URL;异步 worker 没有 request 时可用它生成绝对媒体 URL | +| `MEDIA_PUBLIC_BASE_URL` | 否 | `https://cm.833729.com` | 媒体文件公开基础 URL;优先于 `PUBLIC_BASE_URL`,用于 T-614 异步生图 worker 返回 `result.image_url` | +| `IMAGE_TASK_RETENTION_HOURS` | 否 | `24` | 异步生图任务元数据保留窗口,默认至少 24 小时,覆盖客户端重启后继续轮询 | +| `GENERATED_IMAGE_RETENTION_HOURS` | 否 | `72` | 生成图片文件保留窗口口径,默认 72 小时;实际清理任务后续单独实现时按此值执行 | +| `IMAGE_TASK_REAPER_INTERVAL_SECONDS` | 否 | `60` | 异步生图 worker 循环中扫描僵尸 running 任务的间隔秒数 | +| `IMAGE_TASK_LEASE_SECONDS` | 否 | `600` | 异步生图任务租约秒数;worker 认领任务后写 `lease_expires_at` / `heartbeat_at`,超时由 reaper 判失败并退点 | `AI_KEY_ENCRYPTION_KEY` 必须是 `cryptography.fernet.Fernet.generate_key()` 生成的 base64 字符串,可用 `py -3.12 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` 生成。 @@ -59,6 +65,8 @@ AI 上游连接超时由 `AiModel.connect_timeout_seconds` 控制。文本读取超时由 `AiModel.timeout_seconds` 控制;当读取超时为 `0` 时,Provider 按分辨率使用内置默认值。生图读取超时在此基础上再套 `AI_IMAGE_UPSTREAM_DEADLINE_SECONDS` 硬截止,避免慢 / 卡死图片上游长期占用生成池线程。 +T-614 起新增异步生图任务接口:提交任务仍同步审核 prompt 和预扣点;worker 成功后返回 cmhub 托管媒体 URL。生产必须配置 `MEDIA_PUBLIC_BASE_URL` 或 `PUBLIC_BASE_URL` 为 HTTPS 域名,否则 worker 只能返回相对 `/media/...` URL,不利于桌面端直接下载。 + ## 五、对外 API 安全配置 | 变量 | 必填 | 示例 | 说明 | diff --git a/docs/project-brief.md b/docs/project-brief.md index b1e9095..140f68e 100644 --- a/docs/project-brief.md +++ b/docs/project-brief.md @@ -61,7 +61,7 @@ **第一版做**: - 用户端:自助注册登录、注册成功赠送 100 点试用点数、扫码充值、查看点数/充值/消费记录、自助生成与删除 API Key。 -- 生成标题、生成图片两个接口(同步返回结果)。 +- 生成标题、生成图片接口;图片保留旧同步接口,并新增异步提交 / 轮询接口给新版客户端。 - 点数计费、扣点、余额查询。 - 充值到账(对接已有支付系统)。 - 调用记录与点数流水。 @@ -69,7 +69,7 @@ **第一版先不做**(留待后续迭代): -- 高并发异步处理、用量统计报表、自动退款对账、复杂活动赠点 / 邀请奖励等(自助注册/扫码充值/API Key 管理与注册送 100 点已纳入第一版口径)。 +- 用量统计报表、自动退款对账、复杂活动赠点 / 邀请奖励等(自助注册/扫码充值/API Key 管理、注册送 100 点与生图异步提交 / 轮询已纳入当前口径)。 > 取舍原则:先把「充值 → 调用 → 计费 → 记录」最小闭环做稳,再扩展。 @@ -109,7 +109,7 @@ - Phase 5 已完成 T-402:MVP P0 验收通过,注册/充值/API Key/调用/余额/记录/后台/别名映射均有测试证据,详见 `mvp-acceptance.md`。 - Phase 5 已完成 T-403:已补部署 / 运行文档,明确宝塔/Nginx/Gunicorn、生产静态与媒体文件、共享缓存限流、图片同步超时、真实商户配置和上线检查;当前免邮箱验证,邮件服务仅作为后续密码找回/通知等邮件能力配置项,详见 `deployment.md`。 - Phase 6 已完成 T-601 可用别名发现、T-602 后台表名/分组中文化、T-603 字段级中文化、T-604 中文敏感词本地过滤、T-605 免邮箱验证策略落地、T-606 公开首页 + 客户端下载入口、T-607 桌面端最新版本检查接口、T-608 新用户注册赠送 100 点试用点数与 T-609 桌面端版本强制更新标记;线上真实标题生成已恢复并验证扣点。 -- 下一步可继续补跑真实支付回调到账闭环、配置并发布客户端下载包和真实图片耗时验证。 +- 下一步可继续补跑真实支付回调到账闭环、配置并发布客户端下载包,并按 T-615 观察旧同步生图接口用量与弃用条件。 --- *更多细节:愿景 `01-vision.md` | 需求与验收 `02-requirements.md` | 架构 `04-architecture.md` | 任务计划 `06-tasks.md`。* diff --git a/docs/project-onepager.md b/docs/project-onepager.md index 547f025..63b647b 100644 --- a/docs/project-onepager.md +++ b/docs/project-onepager.md @@ -30,7 +30,7 @@ ## 第一版范围 做:自助用户端(注册注册送 100 点/扫码充值/API Key 管理/记录)、两个生成接口、点数计费、充值到账、调用记录、运营后台。 -先不做:异步高并发、报表、复杂活动赠点 / 邀请奖励(后续迭代;自助注册/充值/API Key 已在第一版)。 +先不做:报表、复杂活动赠点 / 邀请奖励、自动退款对账(后续迭代;自助注册/充值/API Key、注册送 100 点和生图异步提交 / 轮询已在当前版本)。 ## 需要拍板 @@ -40,7 +40,7 @@ ## 进度 -M1 骨架已完成;M2 已通过录制生成 smoke 验证 AI 调用链路;M3 计费、对外 API、充值闭环与上线前安全加固已完成到 T-306;M4 用户端已完成注册 / 登录、API Key 管理、个人中心 / 记录页、充值页和审核优化;M5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档;M7 注册试用额度已完成,注册后经账本发放 100 点并留流水。下一步是在 VPS 上按部署文档接真实邮件、支付、AI 模型并补真实图片耗时验证。里程碑:M1 骨架可跑 · M2 跑通生成 · M3 计费充值闭环 · M4 用户端可用 · M5 验收上线 · M7 试用额度安全落地。 +M1 骨架已完成;M2 已通过录制生成 smoke 验证 AI 调用链路;M3 计费、对外 API、充值闭环与上线前安全加固已完成到 T-306;M4 用户端已完成注册 / 登录、API Key 管理、个人中心 / 记录页、充值页和审核优化;M5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档;M7 注册试用额度已完成,注册后经账本发放 100 点并留流水;M11 生图异步任务化已完成到 T-614,旧同步接口继续兼容。下一步是在 VPS 上按部署文档接真实支付回调闭环,并按 T-615 观察旧同步生图用量。里程碑:M1 骨架可跑 · M2 跑通生成 · M3 计费充值闭环 · M4 用户端可用 · M5 验收上线 · M7 试用额度安全落地 · M11 生图异步任务化。 --- *详见 `project-brief.md`(完整介绍)。* diff --git a/docs/routes.md b/docs/routes.md index 2952210..6498ab6 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -26,6 +26,8 @@ T-606 已落地 `/` 公开首页:匿名访问返回 200,不再重定向到 ` | --- | --- | --- | --- | | `/api/v1/generate/title` | POST | 生成标题 | API Key | | `/api/v1/generate/image` | POST | 生成图片(同步) | API Key | +| `/api/v1/generate/image/tasks` | POST | 提交异步图片生成任务,返回 `task_id` | API Key | +| `/api/v1/generate/image/tasks/{task_id}` | GET | 轮询异步图片生成任务状态和结果 | API Key | | `/api/v1/balance` | GET | 查询点数余额 | API Key | | `/api/v1/models` | GET | 查询可调用能力别名、能力和点数单价 | API Key | | `/api/v1/client/releases/latest` | GET | 桌面端检查最新客户端版本(公开发布元数据) | 公开 | @@ -35,6 +37,7 @@ T-606 已落地 `/` 公开首页:匿名访问返回 200,不再重定向到 ` | `/api/v1/recharge/callback/alipay` | POST | 支付宝异步回调 | 验签(`@csrf_exempt`) | T-607/T-609 已落地 `/api/v1/client/releases/latest`:公开匿名可访问,不需要 API Key,不读取用户账本,只返回当前 `DownloadRelease` 的版本、下载 URL、SHA256、发布说明、强制更新标记和发布时间;无当前版本返回 `release:null`。 +T-614 已落地 `/api/v1/generate/image/tasks` 与 `/api/v1/generate/image/tasks/{task_id}`:新版桌面端可先提交异步生图任务,再轮询状态;旧 `/api/v1/generate/image` 同步接口继续保留。异步查询只允许同一用户访问自己的任务,跨用户按不存在处理。 ## 运营后台(django-admin,`/admin/`) @@ -50,6 +53,7 @@ T-607/T-609 已落地 `/api/v1/client/releases/latest`:公开匿名可访问 | 充值订单 | RechargeOrder | 检索订单、查看状态/金额/入账点数/支付流水号(只读) | | 点数流水 | PointsLedger | 按账号、类型、时间检索充值/消费/调整/冲正流水(只读,对账用) | | 调用记录 | CallRecord | 按账号、API Key、时间、状态检索调用,查看别名/实际模型/消耗/错误(只读) | +| 图片生成任务 | ImageGenerationTask | 查看异步生图任务状态、公开 task_id、关联调用记录、worker 租约、结果 URL 与错误信息(只读排障) | | 模型配置 | AiModel | 维护上游模型(url/model/api_key[加密脱敏]/api_type/capabilities/timeout) | | 能力别名 | ModelAlias | 维护对外别名 → 具体模型的映射;换供应商在此改指向 | | 配置审计 | AiConfigAuditLog | 只读查看 AiModel / ModelAlias / 密钥变更:谁、何时、改了什么 | diff --git a/progress.md b/progress.md index 522f5f9..d8cdada 100644 --- a/progress.md +++ b/progress.md @@ -1703,3 +1703,34 @@ - 测试期仍保留 allauth `account.EmailAddress` 条件唯一约束在 MySQL 上不可创建的既有 `models.W036` 警告。 - 决策:T-613 不改旧同步接口成功 / 失败响应契约,不新增异步任务表或新路由;只把生成 pipeline 拆成后续 T-614 可复用的准备、预扣、已预扣执行三个阶段。 - 下一步:领取 T-614,新增生图异步提交 / 轮询接口,并复用 T-613 的核心阶段。 + +## 2026-07-08 实施:T-614 生图异步任务化接口 + +- 状态:DONE。 +- 代码变更: + - `apps/api/models.py` / `apps/api/migrations/0001_initial.py`:新增 `ImageGenerationTask`,公开 UUID `task_id`,关联 `user`、`api_key`、已预扣 `CallRecord`,记录状态、请求哈希、输入文件引用、结果 URL、错误、租约、心跳、worker 和尝试次数;幂等键使用 `(api_key, idempotency_key_hash)` 唯一约束,兼容 MySQL,不使用条件唯一约束。 + - `apps/api/image_tasks.py`:新增异步任务 service,提交阶段同步审核 prompt、处理图片输入、预扣点并建 queued 任务;支持 `Idempotency-Key` 去重和 payload 冲突;worker 抢任务后复用 T-613 的 `execute_precharged_generation()`,成功写稳定 URL,失败 / 超时走计费层幂等退款;reaper 处理租约或心跳过期的 running 任务。 + - `apps/api/views.py` / `apps/api/urls.py`:新增 `POST /api/v1/generate/image/tasks` 与 `GET /api/v1/generate/image/tasks/{task_id}`;旧 `POST /api/v1/generate/image` 保留。 + - `apps/api/management/commands/run_image_tasks.py`:新增 DB worker 命令,支持循环处理、`--once` 和 `--reap-only`。 + - `config/settings.py` / `.env.example`:新增 `PUBLIC_BASE_URL`、`MEDIA_PUBLIC_BASE_URL`、`IMAGE_TASK_RETENTION_HOURS`、`GENERATED_IMAGE_RETENTION_HOURS`、`IMAGE_TASK_REAPER_INTERVAL_SECONDS`、`IMAGE_TASK_LEASE_SECONDS`。 + - `apps/api/admin.py`:注册图片生成任务只读排障 admin。 + - `apps/api/tests.py`:新增异步生图目标测试,覆盖审核拦截、余额不足、幂等、worker 成功、跨用户拒绝、失败退款、reaper 和迟到 worker 幂等。 +- 文档变更: + - `docs/api.md`:新增异步提交 / 轮询接口合约、响应示例、错误码和幂等说明。 + - `docs/routes.md`:新增两条异步生图 API 路由和 admin 管理项。 + - `docs/04-architecture.md`:同步 `ImageGenerationTask` schema、异步计费时序、worker/reaper 语义和 exactly-once 账务终态。 + - `docs/deployment.md`:新增 worker 运行 / systemd 示例、Nginx 对 `/api/v1/generate/image/tasks*` 的优先短请求分流、上线验证和检查项。 + - `docs/env.md`、`docs/03-tech-stack.md`、`docs/02-requirements.md`:同步异步生图配置与“旧同步 + 新异步并存”口径。 + - `README.md`、`docs/00-ai-start-here.md`、`docs/current-state.md`、`docs/project-brief.md`、`docs/project-onepager.md`:同步当前阶段和下一步为 T-615。 + - `docs/06-tasks.md`:T-614 标记为 DONE。 +- 验证: + - `py -3.12 manage.py makemigrations api`:通过,生成 `apps/api/migrations/0001_initial.py`。 + - `py -3.12 manage.py test apps.api.tests.GenerateApiTests -v 2 --keepdb`:通过,28 tests OK。 +- 测试环境现象: + - 首次运行 `py -3.12 manage.py test apps.api.tests.GenerateApiTests -v 2` 时,已有测试库 `test_cmhub` 触发交互式删除确认,非交互环境 EOF 中断;改用 `--keepdb` 后迁移并通过。 + - 测试期仍保留 allauth `account.EmailAddress` 条件唯一约束在 MySQL 上不可创建的既有 `models.W036` 警告。 +- 决策: + - T-614 不引入 Celery / Redis;第一版异步用 MySQL 任务表 + management command worker + systemd。 + - 本期不暴露 cancel 路由;后续如需取消,仅允许取消 queued 并单独拆任务。 + - `image_base64` 不进 DB 原文,提交阶段解码为输入文件引用;异步结果不透传上游临时链接,只返回 cmhub 托管 URL。 +- 下一步:领取 T-615,给旧同步生图接口和新异步路径补结构化遥测,并形成旧同步接口弃用条件。