feat: add async image task API
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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})
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 点注册试用额度外,不做其他免费点数活动。
|
||||
|
||||
@@ -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 不自动化,但数据结构需保留可冲正能力。
|
||||
- **待确认决策**:上述由谁、何时决策需明确,尤其商户配置交付时间与计费规则。
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
+113
-2
@@ -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
File diff suppressed because one or more lines are too long
+78
-1
@@ -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`。
|
||||
|
||||
@@ -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 安全配置
|
||||
|
||||
| 变量 | 必填 | 示例 | 说明 |
|
||||
|
||||
@@ -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`。*
|
||||
|
||||
@@ -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`(完整介绍)。*
|
||||
|
||||
@@ -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
@@ -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,给旧同步生图接口和新异步路径补结构化遥测,并形成旧同步接口弃用条件。
|
||||
|
||||
Reference in New Issue
Block a user