feat: add async image task API

This commit is contained in:
QiuSW
2026-07-08 22:08:48 +08:00
parent c98f713762
commit 25a4080177
26 changed files with 1531 additions and 48 deletions
+6
View File
@@ -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
+2 -2
View File
@@ -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) 第四节计费时序。
+45 -1
View File
@@ -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",
)
+581
View File
@@ -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})
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
@@ -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)
+58
View File
@@ -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')],
},
),
]
+97 -2
View File
@@ -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}"
+276 -2
View File
@@ -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")
+12
View File
@@ -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/<uuid:task_id>",
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(
+43
View File
@@ -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)
+6
View File
@@ -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()
+4 -4
View File
@@ -38,7 +38,7 @@
## 当前阶段
当前项目处于:**Phase 6 增强任务推进期**。Phase 2 计费核心已完成到 T-204;Phase 3 已完成 T-301 API Key 鉴权、T-302 生成标题 / 图片接口、T-303 余额查询接口、T-304 充值回调、T-305 扫码充值下单 + 轮询与 T-306 对外 API 安全加固;Phase 4 已完成 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化;Phase 5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档;Phase 6 已完成 T-601「可用别名发现」、T-602「django-admin 中文化第 1-3 层」、T-603「django-admin 字段级中文化」、T-604「中文敏感词本地过滤」、T-605「免邮箱验证策略落地」、T-606「公开首页 + 客户端下载入口」、T-607「桌面端最新版本检查接口」、T-608「新用户注册赠送 100 点试用点数」、T-609「桌面端版本检查接口增加强制更新标记」、T-610「首页导入模板下载入口」、T-611「用户端品牌名统一为虾皮圈」、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 点注册试用额度外,不做其他免费点数活动。
+7 -7
View File
@@ -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 不自动化,但数据结构需保留可冲正能力。
- **待确认决策**:上述由谁、何时决策需明确,尤其商户配置交付时间与计费规则。
+7 -7
View File
@@ -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 点,必须走计费层和点数流水。
+63 -5
View File
@@ -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。
## 六、推荐开发顺序
+1 -1
View File
File diff suppressed because one or more lines are too long
+113 -2
View File
@@ -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_KEY>`;生成、余额等外部 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`
查询当前账号点数余额。成功响应:
+16 -9
View File
File diff suppressed because one or more lines are too long
+78 -1
View File
@@ -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`。
+8
View File
@@ -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 安全配置
| 变量 | 必填 | 示例 | 说明 |
+3 -3
View File
@@ -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`。*
+2 -2
View File
@@ -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`(完整介绍)。*
+4
View File
@@ -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 / 密钥变更:谁、何时、改了什么 |
+31
View File
@@ -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,给旧同步生图接口和新异步路径补结构化遥测,并形成旧同步接口弃用条件。