refactor: extract generation core service
This commit is contained in:
@@ -25,7 +25,7 @@ Python 3.12 / Django 5.2 LTS + DRF / django-admin / 用户端 Django 模板 SSR
|
||||
|
||||
## 当前状态
|
||||
|
||||
Phase 2 计费核心已完成,Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端已完成 T-501~T-505,Phase 5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档,Phase 6 已完成 T-601 可用别名发现、T-602/T-603 django-admin 中文化、T-604 中文敏感词本地过滤、T-605 免邮箱验证策略落地、T-606 公开首页 + 客户端下载入口、T-607 桌面端最新版本检查接口、T-608 新用户注册赠送 100 点试用点数、T-609 桌面端版本强制更新标记、T-610 首页导入模板下载入口、T-611 用户端品牌名统一为“虾皮圈”与 T-612 生图同步接口止血。用户可通过公开首页进入注册、登录、下载客户端和下载导入模板;新用户注册后经计费层自动获得 100 点并写注册赠点流水;登录后可扫码充值并轮询到账,生成 / 删除(吊销)API Key,查看余额、充值总额、分页充值记录、分页点数记录与可用模型;桌面端可匿名请求最新客户端版本 JSON,并读取 `release.force_update` 判断是否必须升级;运营可在 django-admin 检索用户、钱包、API Key、计费规则、汇率、充值订单、点数流水、注册赠点记录、调用记录、客户端发布版本和导入模板,并通过计费层带原因手工调点。下一步为 T-613 抽生成核心 service,之后继续 T-614 异步提交轮询和 T-615 旧同步接口遥测;生产侧仍需补真实支付回调到账闭环。详见 [`docs/current-state.md`](docs/current-state.md)。
|
||||
Phase 2 计费核心已完成,Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端已完成 T-501~T-505,Phase 5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档,Phase 6 已完成 T-601 可用别名发现、T-602/T-603 django-admin 中文化、T-604 中文敏感词本地过滤、T-605 免邮箱验证策略落地、T-606 公开首页 + 客户端下载入口、T-607 桌面端最新版本检查接口、T-608 新用户注册赠送 100 点试用点数、T-609 桌面端版本强制更新标记、T-610 首页导入模板下载入口、T-611 用户端品牌名统一为“虾皮圈”、T-612 生图同步接口止血与 T-613 抽生成核心 service。用户可通过公开首页进入注册、登录、下载客户端和下载导入模板;新用户注册后经计费层自动获得 100 点并写注册赠点流水;登录后可扫码充值并轮询到账,生成 / 删除(吊销)API Key,查看余额、充值总额、分页充值记录、分页点数记录与可用模型;桌面端可匿名请求最新客户端版本 JSON,并读取 `release.force_update` 判断是否必须升级;运营可在 django-admin 检索用户、钱包、API Key、计费规则、汇率、充值订单、点数流水、注册赠点记录、调用记录、客户端发布版本和导入模板,并通过计费层带原因手工调点。下一步为 T-614 生图异步提交轮询接口,之后继续 T-615 旧同步接口遥测;生产侧仍需补真实支付回调到账闭环。详见 [`docs/current-state.md`](docs/current-state.md)。
|
||||
|
||||
> ⚠️ 涉及资金/点数。改动充值、扣费、退款、对账相关代码前,先读 [`docs/05-coding-rules.md`](docs/05-coding-rules.md) 第 8 节与 [`docs/04-architecture.md`](docs/04-architecture.md) 第四节计费时序。
|
||||
|
||||
|
||||
+271
-112
@@ -4,9 +4,9 @@ import base64
|
||||
import binascii
|
||||
import ipaddress
|
||||
import socket
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from time import perf_counter
|
||||
from typing import Any, Mapping
|
||||
from typing import Any, Callable, Mapping
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
|
||||
import requests
|
||||
@@ -53,48 +53,246 @@ class ImageInput:
|
||||
filename: str = "image.png"
|
||||
|
||||
|
||||
ImageUrlBuilder = Callable[[str], str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GenerationInput:
|
||||
user: Any
|
||||
api_key: Any
|
||||
operation_type: str
|
||||
prompt: str
|
||||
alias: str | None = None
|
||||
resolution: str = "1K"
|
||||
parameters: Mapping[str, Any] = field(default_factory=dict)
|
||||
image_url: str = ""
|
||||
image_base64: str = ""
|
||||
aspect_ratio: str = "1:1"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedGeneration:
|
||||
user: Any
|
||||
api_key: Any
|
||||
operation_type: str
|
||||
prompt: str
|
||||
alias: str
|
||||
resolution: str
|
||||
parameters: dict[str, Any]
|
||||
image_input: ImageInput | None
|
||||
model_alias: Any
|
||||
resolved_model: Any
|
||||
provider: Any
|
||||
points_cost: int
|
||||
aspect_ratio: str = "1:1"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PrechargedGeneration:
|
||||
prepared: PreparedGeneration
|
||||
call_record: CallRecord
|
||||
points_cost: int
|
||||
points_balance_after_charge: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GenerationResult:
|
||||
operation_type: str
|
||||
alias: str
|
||||
model_used: str
|
||||
points_cost: int
|
||||
points_balance: int
|
||||
call_record: CallRecord
|
||||
titles: tuple[str, ...] = field(default_factory=tuple)
|
||||
image_url: str = ""
|
||||
|
||||
def as_response_data(self) -> dict[str, Any]:
|
||||
common = {
|
||||
"alias": self.alias,
|
||||
"model_used": self.model_used,
|
||||
"points_cost": self.points_cost,
|
||||
"points_balance": self.points_balance,
|
||||
"call_id": self.call_record.id,
|
||||
}
|
||||
if self.operation_type == CallRecord.OperationType.TITLE:
|
||||
return {"titles": list(self.titles), **common}
|
||||
return {"image_url": self.image_url, **common}
|
||||
|
||||
|
||||
IMAGE_URL_ALLOWED_SCHEMES = {"http", "https"}
|
||||
IMAGE_URL_CHUNK_SIZE = 64 * 1024
|
||||
|
||||
|
||||
def generate_title_response(*, user, api_key, request_data: Mapping[str, Any]) -> dict:
|
||||
prompt = request_data["prompt"]
|
||||
alias = request_data.get("model") or None
|
||||
resolution = normalize_resolution(request_data.get("resolution") or "1K") or "1K"
|
||||
parameters = dict(request_data.get("parameters") or {})
|
||||
moderate_prompt_or_raise(user=user, api_key=api_key, prompt=prompt)
|
||||
image_input = load_image_input(request_data)
|
||||
result = run_synchronous_generation(
|
||||
GenerationInput(
|
||||
user=user,
|
||||
api_key=api_key,
|
||||
operation_type=CallRecord.OperationType.TITLE,
|
||||
prompt=request_data["prompt"],
|
||||
alias=request_data.get("model") or None,
|
||||
resolution=request_data.get("resolution") or "1K",
|
||||
parameters=dict(request_data.get("parameters") or {}),
|
||||
image_url=str(request_data.get("image_url") or ""),
|
||||
image_base64=str(request_data.get("image_base64") or ""),
|
||||
)
|
||||
)
|
||||
return result.as_response_data()
|
||||
|
||||
model_alias = resolve_model_alias_or_raise(CallRecord.OperationType.TITLE, alias)
|
||||
|
||||
def generate_image_response(
|
||||
*,
|
||||
user,
|
||||
api_key,
|
||||
request_data: Mapping[str, Any],
|
||||
image_url_builder: ImageUrlBuilder | None = None,
|
||||
) -> dict:
|
||||
result = run_synchronous_generation(
|
||||
GenerationInput(
|
||||
user=user,
|
||||
api_key=api_key,
|
||||
operation_type=CallRecord.OperationType.IMAGE,
|
||||
prompt=request_data["prompt"],
|
||||
alias=request_data.get("model") or None,
|
||||
resolution=request_data.get("resolution") or "1K",
|
||||
parameters=dict(request_data.get("parameters") or {}),
|
||||
image_url=str(request_data.get("image_url") or ""),
|
||||
image_base64=str(request_data.get("image_base64") or ""),
|
||||
aspect_ratio=request_data.get("aspect_ratio") or "1:1",
|
||||
),
|
||||
image_url_builder=image_url_builder,
|
||||
)
|
||||
return result.as_response_data()
|
||||
|
||||
|
||||
def run_synchronous_generation(
|
||||
generation_input: GenerationInput,
|
||||
*,
|
||||
image_url_builder: ImageUrlBuilder | None = None,
|
||||
) -> GenerationResult:
|
||||
prepared = prepare_generation(generation_input)
|
||||
precharged = precharge_generation(prepared)
|
||||
return execute_precharged_generation(
|
||||
precharged,
|
||||
image_url_builder=image_url_builder,
|
||||
)
|
||||
|
||||
|
||||
def prepare_generation(generation_input: GenerationInput) -> PreparedGeneration:
|
||||
operation_type = normalize_operation_type(generation_input.operation_type)
|
||||
resolution = normalize_resolution(generation_input.resolution or "1K") or "1K"
|
||||
parameters = dict(generation_input.parameters or {})
|
||||
prompt = str(generation_input.prompt or "")
|
||||
|
||||
moderate_prompt_or_raise(
|
||||
user=generation_input.user,
|
||||
api_key=generation_input.api_key,
|
||||
prompt=prompt,
|
||||
)
|
||||
image_input = load_image_input(
|
||||
{
|
||||
"image_base64": generation_input.image_base64,
|
||||
"image_url": generation_input.image_url,
|
||||
}
|
||||
)
|
||||
|
||||
model_alias = resolve_model_alias_or_raise(operation_type, generation_input.alias)
|
||||
resolved_model = resolved_model_or_raise(model_alias)
|
||||
provider = provider_or_raise(resolved_model)
|
||||
ensure_provider_supports(provider, CallRecord.OperationType.TITLE)
|
||||
ensure_provider_supports(provider, operation_type)
|
||||
points_cost = calculate_points_cost_or_raise(
|
||||
CallRecord.OperationType.TITLE,
|
||||
operation_type,
|
||||
model_alias.alias,
|
||||
resolution,
|
||||
)
|
||||
|
||||
charge = precharge_or_raise(
|
||||
user=user,
|
||||
api_key=api_key,
|
||||
operation_type=CallRecord.OperationType.TITLE,
|
||||
alias=model_alias.alias,
|
||||
model_used=resolved_model.model,
|
||||
resolution=resolution,
|
||||
return PreparedGeneration(
|
||||
user=generation_input.user,
|
||||
api_key=generation_input.api_key,
|
||||
operation_type=operation_type,
|
||||
prompt=prompt,
|
||||
points_cost=points_cost,
|
||||
)
|
||||
|
||||
try:
|
||||
started = perf_counter()
|
||||
generation = provider.generate_text(
|
||||
prompt,
|
||||
resolved_model,
|
||||
image=image_input.data if image_input else None,
|
||||
image_mime_type=image_input.mime_type if image_input else "image/png",
|
||||
alias=model_alias.alias,
|
||||
resolution=resolution,
|
||||
parameters=parameters,
|
||||
image_input=image_input,
|
||||
model_alias=model_alias,
|
||||
resolved_model=resolved_model,
|
||||
provider=provider,
|
||||
points_cost=points_cost,
|
||||
aspect_ratio=generation_input.aspect_ratio or "1:1",
|
||||
)
|
||||
|
||||
|
||||
def precharge_generation(prepared: PreparedGeneration) -> PrechargedGeneration:
|
||||
charge = precharge_or_raise(
|
||||
user=prepared.user,
|
||||
api_key=prepared.api_key,
|
||||
operation_type=prepared.operation_type,
|
||||
alias=prepared.alias,
|
||||
model_used=prepared.resolved_model.model,
|
||||
resolution=prepared.resolution,
|
||||
prompt=prepared.prompt,
|
||||
points_cost=prepared.points_cost,
|
||||
)
|
||||
return PrechargedGeneration(
|
||||
prepared=prepared,
|
||||
call_record=charge.call_record,
|
||||
points_cost=charge.points_cost,
|
||||
points_balance_after_charge=charge.balance_after,
|
||||
)
|
||||
|
||||
|
||||
def execute_precharged_generation(
|
||||
precharged: PrechargedGeneration,
|
||||
*,
|
||||
image_url_builder: ImageUrlBuilder | None = None,
|
||||
) -> GenerationResult:
|
||||
prepared = precharged.prepared
|
||||
try:
|
||||
started = perf_counter()
|
||||
if prepared.operation_type == CallRecord.OperationType.TITLE:
|
||||
result = execute_title_generation(precharged, started)
|
||||
else:
|
||||
result = execute_image_generation(
|
||||
precharged,
|
||||
started,
|
||||
image_url_builder=image_url_builder,
|
||||
)
|
||||
except AiCapabilityError as exc:
|
||||
refund_call_points(
|
||||
precharged.call_record,
|
||||
error_message=str(exc),
|
||||
reason=f"Provider rejected the {prepared.operation_type} request.",
|
||||
)
|
||||
raise ApiRequestError(
|
||||
"bad_request",
|
||||
"请求参数不支持当前模型",
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
refund_call_points(
|
||||
precharged.call_record,
|
||||
error_message=str(exc),
|
||||
reason=f"Upstream {prepared.operation_type} generation failed.",
|
||||
)
|
||||
raise upstream_error(exc) from exc
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def execute_title_generation(
|
||||
precharged: PrechargedGeneration,
|
||||
started: float,
|
||||
) -> GenerationResult:
|
||||
prepared = precharged.prepared
|
||||
image_input = prepared.image_input
|
||||
generation = prepared.provider.generate_text(
|
||||
prepared.prompt,
|
||||
prepared.resolved_model,
|
||||
image=image_input.data if image_input else None,
|
||||
image_mime_type=image_input.mime_type if image_input else "image/png",
|
||||
resolution=prepared.resolution,
|
||||
parameters=prepared.parameters,
|
||||
)
|
||||
latency_ms = elapsed_ms(started)
|
||||
titles = list(generation.titles or ())
|
||||
@@ -102,108 +300,69 @@ def generate_title_response(*, user, api_key, request_data: Mapping[str, Any]) -
|
||||
titles = [generation.text]
|
||||
result_summary = summarize_titles(titles, generation.text)
|
||||
call_record = mark_call_success(
|
||||
charge.call_record,
|
||||
precharged.call_record,
|
||||
result_summary=result_summary,
|
||||
upstream_latency_ms=latency_ms,
|
||||
)
|
||||
except AiCapabilityError as exc:
|
||||
refund_call_points(
|
||||
charge.call_record,
|
||||
error_message=str(exc),
|
||||
reason="Provider rejected the title request.",
|
||||
)
|
||||
raise ApiRequestError("bad_request", "请求参数不支持当前模型", status.HTTP_400_BAD_REQUEST) from exc
|
||||
except Exception as exc:
|
||||
refund_call_points(
|
||||
charge.call_record,
|
||||
error_message=str(exc),
|
||||
reason="Upstream title generation failed.",
|
||||
)
|
||||
raise upstream_error(exc) from exc
|
||||
|
||||
return {
|
||||
"titles": titles,
|
||||
"alias": model_alias.alias,
|
||||
"model_used": generation.model_used,
|
||||
"points_cost": points_cost,
|
||||
"points_balance": charge.balance_after,
|
||||
"call_id": call_record.id,
|
||||
}
|
||||
|
||||
|
||||
def generate_image_response(*, user, api_key, request, request_data: Mapping[str, Any]) -> dict:
|
||||
prompt = request_data["prompt"]
|
||||
alias = request_data.get("model") or None
|
||||
resolution = normalize_resolution(request_data.get("resolution") or "1K") or "1K"
|
||||
aspect_ratio = request_data.get("aspect_ratio") or "1:1"
|
||||
parameters = dict(request_data.get("parameters") or {})
|
||||
moderate_prompt_or_raise(user=user, api_key=api_key, prompt=prompt)
|
||||
image_input = load_image_input(request_data)
|
||||
|
||||
model_alias = resolve_model_alias_or_raise(CallRecord.OperationType.IMAGE, alias)
|
||||
resolved_model = resolved_model_or_raise(model_alias)
|
||||
provider = provider_or_raise(resolved_model)
|
||||
ensure_provider_supports(provider, CallRecord.OperationType.IMAGE)
|
||||
points_cost = calculate_points_cost_or_raise(
|
||||
CallRecord.OperationType.IMAGE,
|
||||
model_alias.alias,
|
||||
resolution,
|
||||
return GenerationResult(
|
||||
operation_type=prepared.operation_type,
|
||||
alias=prepared.alias,
|
||||
model_used=generation.model_used,
|
||||
points_cost=precharged.points_cost,
|
||||
points_balance=precharged.points_balance_after_charge,
|
||||
call_record=call_record,
|
||||
titles=tuple(titles),
|
||||
)
|
||||
|
||||
charge = precharge_or_raise(
|
||||
user=user,
|
||||
api_key=api_key,
|
||||
operation_type=CallRecord.OperationType.IMAGE,
|
||||
alias=model_alias.alias,
|
||||
model_used=resolved_model.model,
|
||||
resolution=resolution,
|
||||
prompt=prompt,
|
||||
points_cost=points_cost,
|
||||
)
|
||||
|
||||
try:
|
||||
started = perf_counter()
|
||||
generation = provider.generate_image(
|
||||
prompt,
|
||||
resolved_model,
|
||||
def execute_image_generation(
|
||||
precharged: PrechargedGeneration,
|
||||
started: float,
|
||||
*,
|
||||
image_url_builder: ImageUrlBuilder | None = None,
|
||||
) -> GenerationResult:
|
||||
prepared = precharged.prepared
|
||||
image_input = prepared.image_input
|
||||
generation = prepared.provider.generate_image(
|
||||
prepared.prompt,
|
||||
prepared.resolved_model,
|
||||
image=image_input.data if image_input else None,
|
||||
image_mime_type=image_input.mime_type if image_input else "image/png",
|
||||
image_filename=image_input.filename if image_input else "image.png",
|
||||
resolution=resolution,
|
||||
aspect_ratio=aspect_ratio,
|
||||
parameters=parameters,
|
||||
resolution=prepared.resolution,
|
||||
aspect_ratio=prepared.aspect_ratio,
|
||||
parameters=prepared.parameters,
|
||||
)
|
||||
latency_ms = elapsed_ms(started)
|
||||
image_url = save_generated_image(generation.image, request=request)
|
||||
image_url = save_generated_image(
|
||||
generation.image,
|
||||
url_builder=image_url_builder,
|
||||
)
|
||||
call_record = mark_call_success(
|
||||
charge.call_record,
|
||||
precharged.call_record,
|
||||
result_ref=image_url,
|
||||
result_summary=f"image_bytes={len(generation.image)}",
|
||||
upstream_latency_ms=latency_ms,
|
||||
)
|
||||
except AiCapabilityError as exc:
|
||||
refund_call_points(
|
||||
charge.call_record,
|
||||
error_message=str(exc),
|
||||
reason="Provider rejected the image request.",
|
||||
return GenerationResult(
|
||||
operation_type=prepared.operation_type,
|
||||
alias=prepared.alias,
|
||||
model_used=generation.model_used,
|
||||
points_cost=precharged.points_cost,
|
||||
points_balance=precharged.points_balance_after_charge,
|
||||
call_record=call_record,
|
||||
image_url=image_url,
|
||||
)
|
||||
raise ApiRequestError("bad_request", "请求参数不支持当前模型", status.HTTP_400_BAD_REQUEST) from exc
|
||||
except Exception as exc:
|
||||
refund_call_points(
|
||||
charge.call_record,
|
||||
error_message=str(exc),
|
||||
reason="Upstream image generation failed.",
|
||||
)
|
||||
raise upstream_error(exc) from exc
|
||||
|
||||
return {
|
||||
"image_url": image_url,
|
||||
"alias": model_alias.alias,
|
||||
"model_used": generation.model_used,
|
||||
"points_cost": points_cost,
|
||||
"points_balance": charge.balance_after,
|
||||
"call_id": call_record.id,
|
||||
}
|
||||
|
||||
def normalize_operation_type(operation_type: str) -> str:
|
||||
normalized = str(operation_type or "").strip()
|
||||
if normalized not in {
|
||||
CallRecord.OperationType.TITLE,
|
||||
CallRecord.OperationType.IMAGE,
|
||||
}:
|
||||
raise ValueError(f"Unsupported generation operation type: {operation_type}")
|
||||
return normalized
|
||||
|
||||
|
||||
def moderate_prompt_or_raise(*, user, api_key, prompt: str) -> None:
|
||||
|
||||
+9
-1
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
from uuid import uuid4
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
@@ -7,7 +8,12 @@ from django.core.files.storage import default_storage
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
def save_generated_image(image: bytes, *, request=None) -> str:
|
||||
def save_generated_image(
|
||||
image: bytes,
|
||||
*,
|
||||
url_builder: Callable[[str], str] | None = None,
|
||||
request=None,
|
||||
) -> str:
|
||||
today = timezone.now()
|
||||
path = (
|
||||
"generated/images/"
|
||||
@@ -16,6 +22,8 @@ def save_generated_image(image: bytes, *, request=None) -> str:
|
||||
)
|
||||
saved_path = default_storage.save(path, ContentFile(image))
|
||||
url = default_storage.url(saved_path)
|
||||
if url_builder is not None and url.startswith("/"):
|
||||
return url_builder(url)
|
||||
if request is not None and url.startswith("/"):
|
||||
return request.build_absolute_uri(url)
|
||||
return url
|
||||
|
||||
@@ -20,6 +20,14 @@ from rest_framework.test import APIClient
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.api.authentication import ApiKeyAuthentication
|
||||
from apps.api.generation import (
|
||||
ApiRequestError,
|
||||
GenerationInput,
|
||||
execute_precharged_generation,
|
||||
precharge_generation,
|
||||
prepare_generation,
|
||||
run_synchronous_generation,
|
||||
)
|
||||
from apps.api.throttles import GenerateRateThrottle
|
||||
from apps.api.views import ClientLatestReleaseView, ExternalApiView, ModelsView
|
||||
from apps.ai.models import AiModel, ModelAlias
|
||||
@@ -1273,6 +1281,34 @@ class GenerateApiTests(TestCase):
|
||||
self.assertEqual(call.result_summary, "image_bytes=21")
|
||||
self.assertNotIn("SECRET_RAW", call.result_ref + call.result_summary)
|
||||
|
||||
def test_generation_core_saves_image_with_url_builder_without_request(self):
|
||||
encoded = base64.b64encode(b"input-image").decode("ascii")
|
||||
|
||||
with patch("apps.api.generation.get_provider", return_value=self.provider):
|
||||
result = run_synchronous_generation(
|
||||
GenerationInput(
|
||||
user=self.user,
|
||||
api_key=self.api_key,
|
||||
operation_type=CallRecord.OperationType.IMAGE,
|
||||
prompt="生成图片",
|
||||
alias=self.image_alias,
|
||||
resolution="1K",
|
||||
image_base64=f"data:image/png;base64,{encoded}",
|
||||
),
|
||||
image_url_builder=lambda url: f"https://cdn.example.test{url}",
|
||||
)
|
||||
|
||||
self.assertEqual(result.operation_type, CallRecord.OperationType.IMAGE)
|
||||
self.assertTrue(result.image_url.startswith("https://cdn.example.test/media/"))
|
||||
self.assertEqual(result.as_response_data()["image_url"], result.image_url)
|
||||
self.assertEqual(self.provider.image_calls[0]["image"], b"input-image")
|
||||
|
||||
call = CallRecord.objects.get(pk=result.call_record.id)
|
||||
self.assertEqual(call.status, CallRecord.Status.SUCCESS)
|
||||
self.assertEqual(call.result_ref, result.image_url)
|
||||
self.wallet.refresh_from_db()
|
||||
self.assertEqual(self.wallet.points_balance, 90)
|
||||
|
||||
def test_generate_image_downloads_safe_image_url(self):
|
||||
response = FakeImageUrlResponse(
|
||||
headers={"Content-Type": "image/jpeg"},
|
||||
@@ -1514,6 +1550,51 @@ class GenerateApiTests(TestCase):
|
||||
1,
|
||||
)
|
||||
|
||||
def test_precharged_generation_stage_refunds_on_upstream_failure(self):
|
||||
self.provider.text_error = AiProviderError("provider timeout")
|
||||
|
||||
with patch("apps.api.generation.get_provider", return_value=self.provider):
|
||||
prepared = prepare_generation(
|
||||
GenerationInput(
|
||||
user=self.user,
|
||||
api_key=self.api_key,
|
||||
operation_type=CallRecord.OperationType.TITLE,
|
||||
prompt="生成标题",
|
||||
alias=self.title_alias,
|
||||
resolution="1K",
|
||||
)
|
||||
)
|
||||
precharged = precharge_generation(prepared)
|
||||
|
||||
self.wallet.refresh_from_db()
|
||||
self.assertEqual(self.wallet.points_balance, 98)
|
||||
self.assertEqual(precharged.call_record.status, CallRecord.Status.PENDING)
|
||||
|
||||
with self.assertRaises(ApiRequestError) as captured:
|
||||
execute_precharged_generation(precharged)
|
||||
|
||||
self.assertEqual(captured.exception.code, "upstream_error")
|
||||
self.wallet.refresh_from_db()
|
||||
self.assertEqual(self.wallet.points_balance, 100)
|
||||
|
||||
call = CallRecord.objects.get(pk=precharged.call_record.id)
|
||||
self.assertEqual(call.status, CallRecord.Status.FAILED)
|
||||
self.assertIn("provider timeout", call.error_message)
|
||||
self.assertEqual(
|
||||
PointsLedger.objects.filter(
|
||||
ref_call=call,
|
||||
change_type=PointsLedger.ChangeType.CONSUME,
|
||||
).count(),
|
||||
1,
|
||||
)
|
||||
self.assertEqual(
|
||||
PointsLedger.objects.filter(
|
||||
ref_call=call,
|
||||
change_type=PointsLedger.ChangeType.REFUND,
|
||||
).count(),
|
||||
1,
|
||||
)
|
||||
|
||||
def test_image_upstream_timeout_refunds_precharged_points_and_marks_call_failed(self):
|
||||
self.provider.image_error = requests.Timeout("image upstream deadline exceeded")
|
||||
|
||||
|
||||
+1
-1
@@ -99,8 +99,8 @@ class GenerateImageView(ExternalApiView):
|
||||
data = generate_image_response(
|
||||
user=request.user,
|
||||
api_key=request.auth,
|
||||
request=request,
|
||||
request_data=serializer.validated_data,
|
||||
image_url_builder=request.build_absolute_uri,
|
||||
)
|
||||
except ApiRequestError as exc:
|
||||
return Response(exc.as_response_data(), status=exc.http_status)
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
|
||||
## 当前阶段
|
||||
|
||||
当前项目处于:**Phase 6 增强任务推进期**。Phase 2 计费核心已完成到 T-204;Phase 3 已完成 T-301 API Key 鉴权、T-302 生成标题 / 图片接口、T-303 余额查询接口、T-304 充值回调、T-305 扫码充值下单 + 轮询与 T-306 对外 API 安全加固;Phase 4 已完成 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化;Phase 5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档;Phase 6 已完成 T-601「可用别名发现」、T-602「django-admin 中文化第 1-3 层」、T-603「django-admin 字段级中文化」、T-604「中文敏感词本地过滤」、T-605「免邮箱验证策略落地」、T-606「公开首页 + 客户端下载入口」、T-607「桌面端最新版本检查接口」、T-608「新用户注册赠送 100 点试用点数」、T-609「桌面端版本检查接口增加强制更新标记」、T-610「首页导入模板下载入口」、T-611「用户端品牌名统一为虾皮圈」与 T-612「生图同步接口止血」。生图慢 / 504 / 客户端超时已拆为 T-612~T-615:T-612 已先做同步接口上游硬截止止血;下一步 T-613 抽共享生成 core,随后 T-614 新增异步提交轮询接口,T-615 观察旧同步接口用量。生产侧仍需补真实支付回调到账闭环;邮件服务仅用于后续密码找回/通知等邮件能力,不阻塞注册登录。
|
||||
当前项目处于:**Phase 6 增强任务推进期**。Phase 2 计费核心已完成到 T-204;Phase 3 已完成 T-301 API Key 鉴权、T-302 生成标题 / 图片接口、T-303 余额查询接口、T-304 充值回调、T-305 扫码充值下单 + 轮询与 T-306 对外 API 安全加固;Phase 4 已完成 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化;Phase 5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档;Phase 6 已完成 T-601「可用别名发现」、T-602「django-admin 中文化第 1-3 层」、T-603「django-admin 字段级中文化」、T-604「中文敏感词本地过滤」、T-605「免邮箱验证策略落地」、T-606「公开首页 + 客户端下载入口」、T-607「桌面端最新版本检查接口」、T-608「新用户注册赠送 100 点试用点数」、T-609「桌面端版本检查接口增加强制更新标记」、T-610「首页导入模板下载入口」、T-611「用户端品牌名统一为虾皮圈」、T-612「生图同步接口止血」与 T-613「抽生成核心 service」。生图慢 / 504 / 客户端超时已拆为 T-612~T-615:T-612 已先做同步接口上游硬截止止血,T-613 已抽共享生成 core;下一步 T-614 新增异步提交轮询接口,随后 T-615 观察旧同步接口用量。生产侧仍需补真实支付回调到账闭环;邮件服务仅用于后续密码找回/通知等邮件能力,不阻塞注册登录。
|
||||
|
||||
优先路径:
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
4. Phase 3:对外 API 与充值 —— T-301 Key 鉴权、T-302 生成接口、T-303 余额查询、T-304 充值回调、T-305 扫码下单与轮询、T-306 安全加固已完成。
|
||||
5. Phase 4:用户端(Django 模板 SSR)—— T-501 注册登录、T-502 API Key 管理、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化已完成。
|
||||
6. Phase 5:后台与发布 —— T-401 运营后台完善、T-402 完整验收 MVP、T-403 部署 / 运行文档已完成;计划内 MVP 任务已收尾。
|
||||
7. Phase 6:增强(MVP 后)—— T-601 可用别名发现已完成,实现 `/api/v1/models` 与 portal 只读「可用模型」页;T-602 已完成 django-admin 分组/表名中文化;T-603 已完成字段级中文标签代码与 no-op 迁移并人工确认 admin 字段中文化;T-604 已完成中文敏感词本地过滤;T-605 已完成免邮箱验证策略落地;T-606 已完成公开首页 + 客户端下载入口;T-607 已完成桌面端最新版本检查接口;T-608 已完成新用户注册赠送 100 点试用点数;T-609 已完成桌面端版本检查接口增加强制更新标记;T-610 已完成首页导入模板下载入口;T-611 已完成用户端品牌名统一为虾皮圈;T-612 已完成生图同步接口止血;当前下一个可领取任务为 T-613。
|
||||
7. Phase 6:增强(MVP 后)—— T-601 可用别名发现已完成,实现 `/api/v1/models` 与 portal 只读「可用模型」页;T-602 已完成 django-admin 分组/表名中文化;T-603 已完成字段级中文标签代码与 no-op 迁移并人工确认 admin 字段中文化;T-604 已完成中文敏感词本地过滤;T-605 已完成免邮箱验证策略落地;T-606 已完成公开首页 + 客户端下载入口;T-607 已完成桌面端最新版本检查接口;T-608 已完成新用户注册赠送 100 点试用点数;T-609 已完成桌面端版本检查接口增加强制更新标记;T-610 已完成首页导入模板下载入口;T-611 已完成用户端品牌名统一为虾皮圈;T-612 已完成生图同步接口止血;T-613 已完成抽生成核心 service;当前下一个可领取任务为 T-614。
|
||||
|
||||
## 领取任务规则
|
||||
|
||||
|
||||
@@ -34,12 +34,12 @@
|
||||
|
||||
- API Key 鉴权(自定义 DRF Authentication,哈希比对),把请求绑定到 Key 所属的注册用户。
|
||||
- 参数校验、请求/响应序列化、错误码统一。
|
||||
- 编排单次调用:调用计费层预扣 → 调 AI 层 → 成功确认 / 失败退点 → 写调用记录。
|
||||
- 编排单次调用:通过生成核心 service 组织「审核 → 图片输入处理 → 别名 / 计费 → 预扣 → 调 AI 层 → 成功确认 / 失败退点 → 写调用记录」。
|
||||
- 不直接写点数余额字段,必须走计费层提供的方法。
|
||||
|
||||
T-301 已实现 `ApiKeyAuthentication` 与 `ExternalApiView`:外部 API 使用 `Authorization: Bearer <API_KEY>`,通过 SHA-256 hash 定位 `ApiKey -> User`,成功后 `request.user` 为所属用户、`request.auth` 为本次 API Key;缺失/无效 Key 返回 `401 unauthorized`,用户或 Key 禁用返回 `403 account_disabled`。生成/余额等外部 API 应继承 `ExternalApiView`,不要挂 `SessionAuthentication`。
|
||||
|
||||
T-302 已实现 `/api/v1/generate/title` 与 `/api/v1/generate/image`:API 层只做鉴权、参数校验和编排;别名解析、Provider 选择、计费计算、预扣、成功确认、失败退点分别调用 `apps.ai` / `apps.billing` 既有模块。图片结果 MVP 先用本地 `default_storage` 保存到 `MEDIA_ROOT/generated/images/...` 并返回 `image_url`;`CallRecord` 只写 URL / 摘要,不保存 provider `raw` 或 base64。
|
||||
T-302 已实现 `/api/v1/generate/title` 与 `/api/v1/generate/image`:API 层只做鉴权、参数校验和编排;别名解析、Provider 选择、计费计算、预扣、成功确认、失败退点分别调用 `apps.ai` / `apps.billing` 既有模块。T-613 已把生成链路抽为 `apps.api.generation` 的核心阶段:`prepare_generation()` 负责审核、图片输入、别名、Provider 与计费准备;`precharge_generation()` 只调用 billing 预扣;`execute_precharged_generation()` 复用已预扣 `CallRecord` 调上游并成功确认或失败退点,供旧同步接口和后续异步 worker 共用。图片结果 MVP 先用本地 `default_storage` 保存到 `MEDIA_ROOT/generated/images/...` 并返回 `image_url`;核心阶段通过 URL 构建器生成外部 URL,不依赖 DRF `Request`;`CallRecord` 只写 URL / 摘要,不保存 provider `raw` 或 base64。
|
||||
|
||||
T-303 已实现 `/api/v1/balance`:外部 API 继续只认 API Key,API 层调用 `apps.billing.services.get_balance_snapshot()` 读取当前钱包余额;测试覆盖余额响应与流水累加一致的场景。
|
||||
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -21,7 +21,7 @@
|
||||
|
||||
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-302 已实现生成接口基线:`POST /api/v1/generate/title` 与 `POST /api/v1/generate/image` 已接入 API Key 鉴权、别名解析、计费规则、预扣点、Provider 调用、成功确认和失败退点;图片结果当前以本地 `MEDIA_ROOT` 保存并返回 `image_url`,后续可替换为对象存储。T-613 起内部实现已抽为生成核心 service,旧同步接口仍保持原字段、状态码和错误语义;后续异步 worker 必须复用同一套预扣、执行、确认和退点阶段,不得复制第二套资金逻辑。
|
||||
|
||||
T-303 已实现余额查询基线:`GET /api/v1/balance` 已接入 API Key 鉴权,返回当前 `UserWallet.points_balance`,并保留旧字段同时新增不含邮箱的 `account` 账号展示对象;测试覆盖响应余额与 `PointsLedger.points_delta` 累加值一致的账务场景,并确认 Web session 不能调用该外部接口。
|
||||
|
||||
|
||||
+10
-9
File diff suppressed because one or more lines are too long
+29
@@ -1674,3 +1674,32 @@
|
||||
- 已知测试环境现象:测试期仍保留 allauth `account.EmailAddress` 条件唯一约束在 MySQL 上不可创建的既有 `models.W036` 警告。
|
||||
- 决策:T-612 只给旧同步生图接口加硬截止和明确错误码,不引入 gevent,不改变文本生成超时口径;真正解决长耗时与客户端轮询体验仍放到 T-613~T-615。
|
||||
- 下一步:领取 T-613,抽生成核心 service,为 T-614 生图异步任务化复用同一套审核、计费、上游和退点逻辑。
|
||||
|
||||
## 2026-07-08 实施:T-613 抽生成核心 service
|
||||
|
||||
- 状态:DONE。
|
||||
- 代码变更:
|
||||
- `apps/api/generation.py`:把旧同步生成编排抽成 `GenerationInput`、`PreparedGeneration`、`PrechargedGeneration`、`GenerationResult` 与三段核心函数:`prepare_generation()`、`precharge_generation()`、`execute_precharged_generation()`;旧 `generate_title_response()` / `generate_image_response()` 保留为兼容薄包装。
|
||||
- `apps/api/generation.py`:`prepare_generation()` 负责 prompt 审核、图片输入处理、别名解析、Provider 选择和计费计算;`precharge_generation()` 只调用 billing 的 `precharge_call()`;`execute_precharged_generation()` 复用已预扣 `CallRecord` 调上游,并通过既有 `mark_call_success()` / `refund_call_points()` 完成成功确认或失败退点。
|
||||
- `apps/api/storage.py`:`save_generated_image()` 新增 `url_builder` 参数,核心 service 可生成外部 URL 而不依赖 DRF `Request`;保留旧 `request` 参数兼容。
|
||||
- `apps/api/views.py`:`GenerateImageView` 只把 `request.build_absolute_uri` 作为 URL 构建器传给核心 service,不再把整个 request 传入生成核心。
|
||||
- `apps/api/tests.py`:新增核心 service 目标测试,覆盖无 DRF request 构建图片 URL,以及已预扣执行阶段上游失败后退点。
|
||||
- 文档变更:
|
||||
- `docs/06-tasks.md`:T-613 标记为 DONE,下一个任务为 T-614。
|
||||
- `docs/04-architecture.md`、`docs/api.md`:同步生成核心 service 的内部阶段与“后续异步 worker 必须复用同一套资金逻辑”的口径。
|
||||
- `README.md`、`docs/00-ai-start-here.md`、`docs/current-state.md`:同步 Phase 6 当前状态、验证证据和下一步。
|
||||
- 验证:
|
||||
- `.\init.ps1`:开工前通过。
|
||||
- `py -3.12 -m py_compile apps\api\generation.py apps\api\storage.py apps\api\views.py apps\api\tests.py`:通过。
|
||||
- `py -3.12 manage.py check`:通过,0 issues。
|
||||
- `py -3.12 manage.py makemigrations --check --dry-run`:通过,No changes detected。
|
||||
- `py -3.12 manage.py test apps.api.tests.GenerateApiTests.test_generation_core_saves_image_with_url_builder_without_request apps.api.tests.GenerateApiTests.test_precharged_generation_stage_refunds_on_upstream_failure --keepdb --noinput --verbosity 2`:通过,2 tests OK。
|
||||
- `py -3.12 manage.py test apps.api.tests.GenerateApiTests --keepdb --noinput --verbosity 1`:通过,19 tests OK。
|
||||
- `.\init.ps1`:收尾验证通过(Python 3.12.3,依赖已满足,`manage.py check` 0 issues,打印启动命令)。
|
||||
- `git diff --check`:通过,仅 Windows CRLF 提示。
|
||||
- 测试环境现象:
|
||||
- 首次运行新增目标测试时,测试库 setup 阶段远程 MySQL `43.128.3.240` 连接超时;`Test-NetConnection 43.128.3.240 -Port 3306` 随后显示 `TcpTestSucceeded=True`,重跑通过。
|
||||
- 曾尝试 `py -3.12 manage.py test apps.api --keepdb --noinput --verbosity 1`,命令 484 秒超时无断言结果;拆跑非生成 API 测试类时仍在测试库 setup 阶段出现 `OperationalError(2003)`。本轮直接影响的 `GenerateApiTests` 已完整通过。
|
||||
- 测试期仍保留 allauth `account.EmailAddress` 条件唯一约束在 MySQL 上不可创建的既有 `models.W036` 警告。
|
||||
- 决策:T-613 不改旧同步接口成功 / 失败响应契约,不新增异步任务表或新路由;只把生成 pipeline 拆成后续 T-614 可复用的准备、预扣、已预扣执行三个阶段。
|
||||
- 下一步:领取 T-614,新增生图异步提交 / 轮询接口,并复用 T-613 的核心阶段。
|
||||
|
||||
Reference in New Issue
Block a user