feat: add generation API endpoints
This commit is contained in:
@@ -25,7 +25,7 @@ Python 3.12 / Django 5.2 LTS + DRF / django-admin / 用户端 Django 模板 SSR
|
||||
|
||||
## 当前状态
|
||||
|
||||
Phase 2 计费核心已完成,T-301 API Key 鉴权也已落地:对外 API 可用 `Authorization: Bearer <API_KEY>` 识别用户,且不接受 Web session。下一步是 T-302 生成标题 / 图片接口。详见 [`docs/current-state.md`](docs/current-state.md)。
|
||||
Phase 2 计费核心已完成,T-301 API Key 鉴权与 T-302 生成标题 / 图片接口已落地:对外 API 可用 `Authorization: Bearer <API_KEY>` 调用并按点数预扣、成功确认、失败退点。下一步是 T-303 余额查询接口。详见 [`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) 第四节计费时序。
|
||||
|
||||
|
||||
+8
-3
@@ -23,8 +23,8 @@ REQUIRED_CAPABILITIES = {
|
||||
}
|
||||
|
||||
|
||||
def resolve_alias(operation_type: str, alias: str | None = None) -> ResolvedModel:
|
||||
"""Resolve an external capability alias to a provider-ready model config."""
|
||||
def resolve_model_alias(operation_type: str, alias: str | None = None) -> ModelAlias:
|
||||
"""Resolve an external capability alias to an active ModelAlias row."""
|
||||
required_capability = REQUIRED_CAPABILITIES.get(operation_type)
|
||||
if required_capability is None:
|
||||
raise AliasResolutionError(f"unsupported operation_type: {operation_type}")
|
||||
@@ -52,4 +52,9 @@ def resolve_alias(operation_type: str, alias: str | None = None) -> ResolvedMode
|
||||
f"alias {model_alias.alias} maps to model {ai_model.name} without "
|
||||
f"{required_capability} capability"
|
||||
)
|
||||
return ai_model.to_resolved_model()
|
||||
return model_alias
|
||||
|
||||
|
||||
def resolve_alias(operation_type: str, alias: str | None = None) -> ResolvedModel:
|
||||
"""Resolve an external capability alias to a provider-ready model config."""
|
||||
return resolve_model_alias(operation_type, alias).ai_model.to_resolved_model()
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
from dataclasses import dataclass
|
||||
from time import perf_counter
|
||||
from typing import Any, Mapping
|
||||
|
||||
import requests
|
||||
from rest_framework import status
|
||||
|
||||
from apps.ai.aliases import (
|
||||
AliasResolutionError,
|
||||
ModelCapabilityError,
|
||||
REQUIRED_CAPABILITIES,
|
||||
resolve_model_alias,
|
||||
)
|
||||
from apps.ai.providers import AiCapabilityError, AiProviderError, get_provider
|
||||
from apps.billing.models import CallRecord, normalize_resolution
|
||||
from apps.billing.pricing import NoPricingRuleError, calculate_points_cost
|
||||
from apps.billing.services import (
|
||||
InsufficientPointsError,
|
||||
precharge_call,
|
||||
refund_call_points,
|
||||
mark_call_success,
|
||||
)
|
||||
|
||||
from .errors import api_error
|
||||
from .storage import save_generated_image
|
||||
|
||||
|
||||
class ApiRequestError(ValueError):
|
||||
def __init__(self, code: str, message: str, http_status: int):
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.http_status = http_status
|
||||
super().__init__(message)
|
||||
|
||||
def as_response_data(self) -> dict:
|
||||
return api_error(self.code, self.message)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImageInput:
|
||||
data: bytes
|
||||
mime_type: str = "image/png"
|
||||
filename: str = "image.png"
|
||||
|
||||
|
||||
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 {})
|
||||
image_input = load_image_input(request_data)
|
||||
|
||||
model_alias = resolve_model_alias_or_raise(CallRecord.OperationType.TITLE, alias)
|
||||
resolved_model = resolved_model_or_raise(model_alias)
|
||||
provider = provider_or_raise(resolved_model)
|
||||
ensure_provider_supports(provider, CallRecord.OperationType.TITLE)
|
||||
points_cost = calculate_points_cost_or_raise(
|
||||
CallRecord.OperationType.TITLE,
|
||||
model_alias.alias,
|
||||
resolution,
|
||||
)
|
||||
|
||||
charge = precharge_or_raise(
|
||||
user=user,
|
||||
api_key=api_key,
|
||||
operation_type=CallRecord.OperationType.TITLE,
|
||||
alias=model_alias.alias,
|
||||
model_used=resolved_model.model,
|
||||
resolution=resolution,
|
||||
prompt=prompt,
|
||||
points_cost=points_cost,
|
||||
)
|
||||
|
||||
try:
|
||||
started = perf_counter()
|
||||
generation = provider.generate_text(
|
||||
prompt,
|
||||
resolved_model,
|
||||
image=image_input.data if image_input else None,
|
||||
image_mime_type=image_input.mime_type if image_input else "image/png",
|
||||
resolution=resolution,
|
||||
parameters=parameters,
|
||||
)
|
||||
latency_ms = elapsed_ms(started)
|
||||
titles = list(generation.titles or ())
|
||||
if not titles and generation.text:
|
||||
titles = [generation.text]
|
||||
result_summary = summarize_titles(titles, generation.text)
|
||||
call_record = mark_call_success(
|
||||
charge.call_record,
|
||||
result_summary=result_summary,
|
||||
upstream_latency_ms=latency_ms,
|
||||
)
|
||||
except AiCapabilityError as exc:
|
||||
refund_call_points(
|
||||
charge.call_record,
|
||||
error_message=str(exc),
|
||||
reason="Provider rejected the title request.",
|
||||
)
|
||||
raise ApiRequestError("bad_request", "请求参数不支持当前模型", status.HTTP_400_BAD_REQUEST) from exc
|
||||
except Exception as exc:
|
||||
refund_call_points(
|
||||
charge.call_record,
|
||||
error_message=str(exc),
|
||||
reason="Upstream title generation failed.",
|
||||
)
|
||||
raise upstream_error(exc) from exc
|
||||
|
||||
return {
|
||||
"titles": titles,
|
||||
"alias": model_alias.alias,
|
||||
"model_used": generation.model_used,
|
||||
"points_cost": points_cost,
|
||||
"points_balance": charge.balance_after,
|
||||
"call_id": call_record.id,
|
||||
}
|
||||
|
||||
|
||||
def generate_image_response(*, user, api_key, request, request_data: Mapping[str, Any]) -> dict:
|
||||
prompt = request_data["prompt"]
|
||||
alias = request_data.get("model") or None
|
||||
resolution = normalize_resolution(request_data.get("resolution") or "1K") or "1K"
|
||||
aspect_ratio = request_data.get("aspect_ratio") or "1:1"
|
||||
parameters = dict(request_data.get("parameters") or {})
|
||||
image_input = load_image_input(request_data)
|
||||
|
||||
model_alias = resolve_model_alias_or_raise(CallRecord.OperationType.IMAGE, alias)
|
||||
resolved_model = resolved_model_or_raise(model_alias)
|
||||
provider = provider_or_raise(resolved_model)
|
||||
ensure_provider_supports(provider, CallRecord.OperationType.IMAGE)
|
||||
points_cost = calculate_points_cost_or_raise(
|
||||
CallRecord.OperationType.IMAGE,
|
||||
model_alias.alias,
|
||||
resolution,
|
||||
)
|
||||
|
||||
charge = precharge_or_raise(
|
||||
user=user,
|
||||
api_key=api_key,
|
||||
operation_type=CallRecord.OperationType.IMAGE,
|
||||
alias=model_alias.alias,
|
||||
model_used=resolved_model.model,
|
||||
resolution=resolution,
|
||||
prompt=prompt,
|
||||
points_cost=points_cost,
|
||||
)
|
||||
|
||||
try:
|
||||
started = perf_counter()
|
||||
generation = provider.generate_image(
|
||||
prompt,
|
||||
resolved_model,
|
||||
image=image_input.data if image_input else None,
|
||||
image_mime_type=image_input.mime_type if image_input else "image/png",
|
||||
image_filename=image_input.filename if image_input else "image.png",
|
||||
resolution=resolution,
|
||||
aspect_ratio=aspect_ratio,
|
||||
parameters=parameters,
|
||||
)
|
||||
latency_ms = elapsed_ms(started)
|
||||
image_url = save_generated_image(generation.image, request=request)
|
||||
call_record = mark_call_success(
|
||||
charge.call_record,
|
||||
result_ref=image_url,
|
||||
result_summary=f"image_bytes={len(generation.image)}",
|
||||
upstream_latency_ms=latency_ms,
|
||||
)
|
||||
except AiCapabilityError as exc:
|
||||
refund_call_points(
|
||||
charge.call_record,
|
||||
error_message=str(exc),
|
||||
reason="Provider rejected the image request.",
|
||||
)
|
||||
raise ApiRequestError("bad_request", "请求参数不支持当前模型", status.HTTP_400_BAD_REQUEST) from exc
|
||||
except Exception as exc:
|
||||
refund_call_points(
|
||||
charge.call_record,
|
||||
error_message=str(exc),
|
||||
reason="Upstream image generation failed.",
|
||||
)
|
||||
raise upstream_error(exc) from exc
|
||||
|
||||
return {
|
||||
"image_url": image_url,
|
||||
"alias": model_alias.alias,
|
||||
"model_used": generation.model_used,
|
||||
"points_cost": points_cost,
|
||||
"points_balance": charge.balance_after,
|
||||
"call_id": call_record.id,
|
||||
}
|
||||
|
||||
|
||||
def resolve_model_alias_or_raise(operation_type: str, alias: str | None):
|
||||
try:
|
||||
return resolve_model_alias(operation_type, alias)
|
||||
except ModelCapabilityError as exc:
|
||||
raise ApiRequestError(
|
||||
"model_not_allowed",
|
||||
"该模型不支持此操作",
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
) from exc
|
||||
except AliasResolutionError as exc:
|
||||
raise ApiRequestError(
|
||||
"model_not_allowed",
|
||||
"模型别名不可用或不支持此操作",
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
) from exc
|
||||
|
||||
|
||||
def ensure_provider_supports(provider, operation_type: str) -> None:
|
||||
required_capability = REQUIRED_CAPABILITIES[operation_type]
|
||||
if required_capability not in provider.capabilities():
|
||||
raise ApiRequestError(
|
||||
"model_not_allowed",
|
||||
"该模型不支持此操作",
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
|
||||
def resolved_model_or_raise(model_alias):
|
||||
try:
|
||||
return model_alias.ai_model.to_resolved_model()
|
||||
except Exception as exc:
|
||||
raise ApiRequestError(
|
||||
"upstream_error",
|
||||
"上游模型配置不可用",
|
||||
status.HTTP_502_BAD_GATEWAY,
|
||||
) from exc
|
||||
|
||||
|
||||
def provider_or_raise(resolved_model):
|
||||
try:
|
||||
return get_provider(resolved_model.api_type, resolved_model.url)
|
||||
except AiProviderError as exc:
|
||||
raise ApiRequestError(
|
||||
"upstream_error",
|
||||
"上游模型配置不可用",
|
||||
status.HTTP_502_BAD_GATEWAY,
|
||||
) from exc
|
||||
|
||||
|
||||
def calculate_points_cost_or_raise(
|
||||
operation_type: str,
|
||||
alias: str,
|
||||
resolution: str,
|
||||
) -> int:
|
||||
try:
|
||||
return calculate_points_cost(operation_type, alias, resolution)
|
||||
except NoPricingRuleError as exc:
|
||||
raise ApiRequestError(
|
||||
"no_pricing_rule",
|
||||
"未配置对应计费规则",
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
) from exc
|
||||
|
||||
|
||||
def precharge_or_raise(**kwargs):
|
||||
try:
|
||||
return precharge_call(**kwargs)
|
||||
except InsufficientPointsError as exc:
|
||||
raise ApiRequestError(
|
||||
"insufficient_points",
|
||||
"点数不足,请先充值",
|
||||
status.HTTP_402_PAYMENT_REQUIRED,
|
||||
) from exc
|
||||
|
||||
|
||||
def upstream_error(exc: Exception) -> ApiRequestError:
|
||||
if isinstance(exc, ApiRequestError):
|
||||
return exc
|
||||
if isinstance(exc, AiProviderError | requests.RequestException | OSError):
|
||||
return ApiRequestError("upstream_error", "上游 AI 调用失败,已退回点数", status.HTTP_502_BAD_GATEWAY)
|
||||
return ApiRequestError("upstream_error", "生成失败,已退回点数", status.HTTP_502_BAD_GATEWAY)
|
||||
|
||||
|
||||
def load_image_input(data: Mapping[str, Any]) -> ImageInput | None:
|
||||
raw_base64 = str(data.get("image_base64") or "").strip()
|
||||
if raw_base64:
|
||||
return decode_image_input(raw_base64)
|
||||
|
||||
image_url = str(data.get("image_url") or "").strip()
|
||||
if image_url:
|
||||
return download_image_input(image_url)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def decode_image_input(value: str) -> ImageInput:
|
||||
mime_type = "image/png"
|
||||
encoded = value
|
||||
if value.startswith("data:"):
|
||||
if ";base64," not in value:
|
||||
raise ApiRequestError("bad_request", "image_base64 格式无效", status.HTTP_400_BAD_REQUEST)
|
||||
prefix, encoded = value.split(",", 1)
|
||||
mime_type = prefix[len("data:") :].split(";", 1)[0] or mime_type
|
||||
try:
|
||||
image = base64.b64decode(encoded, validate=True)
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise ApiRequestError("bad_request", "image_base64 格式无效", status.HTTP_400_BAD_REQUEST) from exc
|
||||
if not image:
|
||||
raise ApiRequestError("bad_request", "image_base64 不能为空", status.HTTP_400_BAD_REQUEST)
|
||||
return ImageInput(data=image, mime_type=mime_type, filename=filename_for_mime(mime_type))
|
||||
|
||||
|
||||
def download_image_input(url: str) -> ImageInput:
|
||||
session = requests.Session()
|
||||
session.trust_env = False
|
||||
try:
|
||||
response = session.get(url, timeout=(10, 60))
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
raise ApiRequestError("bad_request", "无法读取 image_url", status.HTTP_400_BAD_REQUEST) from exc
|
||||
content_type = response.headers.get("Content-Type", "image/png").split(";", 1)[0]
|
||||
if not content_type.startswith("image/"):
|
||||
raise ApiRequestError("bad_request", "image_url 不是图片资源", status.HTTP_400_BAD_REQUEST)
|
||||
if not response.content:
|
||||
raise ApiRequestError("bad_request", "image_url 图片内容为空", status.HTTP_400_BAD_REQUEST)
|
||||
return ImageInput(
|
||||
data=response.content,
|
||||
mime_type=content_type,
|
||||
filename=filename_for_mime(content_type),
|
||||
)
|
||||
|
||||
|
||||
def filename_for_mime(mime_type: str) -> str:
|
||||
extension = {
|
||||
"image/jpeg": "jpg",
|
||||
"image/png": "png",
|
||||
"image/webp": "webp",
|
||||
}.get(mime_type, "png")
|
||||
return f"input.{extension}"
|
||||
|
||||
|
||||
def summarize_titles(titles: list[str], text: str) -> str:
|
||||
summary = " | ".join(titles[:3]) or str(text or "")
|
||||
return summary[:500]
|
||||
|
||||
|
||||
def elapsed_ms(started: float) -> int:
|
||||
return int((perf_counter() - started) * 1000)
|
||||
@@ -0,0 +1,45 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class GenerateTitleRequestSerializer(serializers.Serializer):
|
||||
prompt = serializers.CharField(trim_whitespace=True, allow_blank=False)
|
||||
model = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
trim_whitespace=True,
|
||||
max_length=64,
|
||||
)
|
||||
image_url = serializers.URLField(required=False, allow_blank=True)
|
||||
image_base64 = serializers.CharField(required=False, allow_blank=True)
|
||||
resolution = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
trim_whitespace=True,
|
||||
default="1K",
|
||||
)
|
||||
parameters = serializers.DictField(required=False, default=dict)
|
||||
|
||||
|
||||
class GenerateImageRequestSerializer(serializers.Serializer):
|
||||
prompt = serializers.CharField(trim_whitespace=True, allow_blank=False)
|
||||
model = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
trim_whitespace=True,
|
||||
max_length=64,
|
||||
)
|
||||
image_url = serializers.URLField(required=False, allow_blank=True)
|
||||
image_base64 = serializers.CharField(required=False, allow_blank=True)
|
||||
resolution = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
trim_whitespace=True,
|
||||
default="1K",
|
||||
)
|
||||
aspect_ratio = serializers.CharField(
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
trim_whitespace=True,
|
||||
default="1:1",
|
||||
)
|
||||
parameters = serializers.DictField(required=False, default=dict)
|
||||
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import uuid4
|
||||
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.files.storage import default_storage
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
def save_generated_image(image: bytes, *, request=None) -> str:
|
||||
today = timezone.now()
|
||||
path = (
|
||||
"generated/images/"
|
||||
f"{today:%Y/%m/%d}/"
|
||||
f"{uuid4().hex}.png"
|
||||
)
|
||||
saved_path = default_storage.save(path, ContentFile(image))
|
||||
url = default_storage.url(saved_path)
|
||||
if request is not None and url.startswith("/"):
|
||||
return request.build_absolute_uri(url)
|
||||
return url
|
||||
@@ -1,5 +1,10 @@
|
||||
import uuid
|
||||
import base64
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import path
|
||||
@@ -8,7 +13,19 @@ from rest_framework.test import APIClient
|
||||
|
||||
from apps.api.authentication import ApiKeyAuthentication
|
||||
from apps.api.views import ExternalApiView
|
||||
from apps.ai.models import AiModel, ModelAlias
|
||||
from apps.ai.providers import (
|
||||
AiCapabilityError,
|
||||
AiProviderError,
|
||||
ImageGenerationResult,
|
||||
TextGenerationResult,
|
||||
)
|
||||
from apps.billing.models import CallRecord, PointsLedger, PricingRule
|
||||
from apps.users.models import ApiKey
|
||||
from apps.users.models import UserWallet
|
||||
|
||||
|
||||
TEST_ENCRYPTION_KEY = Fernet.generate_key().decode("ascii")
|
||||
|
||||
|
||||
class AuthenticatedEchoView(ExternalApiView):
|
||||
@@ -101,3 +118,304 @@ class ApiKeyAuthenticationTests(TestCase):
|
||||
|
||||
self.assertEqual(response.status_code, 401)
|
||||
self.assertEqual(response.data["error"]["code"], "unauthorized")
|
||||
|
||||
|
||||
class FakeGenerationProvider:
|
||||
def __init__(self, *, capabilities=None):
|
||||
self._capabilities = set(capabilities or {"text", "image", "vision"})
|
||||
self.text_calls = []
|
||||
self.image_calls = []
|
||||
self.text_error = None
|
||||
self.image_error = None
|
||||
|
||||
def capabilities(self):
|
||||
return set(self._capabilities)
|
||||
|
||||
def generate_text(self, prompt, model, **kwargs):
|
||||
self.text_calls.append({"prompt": prompt, "model": model, **kwargs})
|
||||
if self.text_error is not None:
|
||||
raise self.text_error
|
||||
return TextGenerationResult(
|
||||
text="测试标题一",
|
||||
titles=("测试标题一", "测试标题二"),
|
||||
model_used=model.model,
|
||||
raw={"secret": "SECRET_RAW_SHOULD_NOT_BE_STORED"},
|
||||
)
|
||||
|
||||
def generate_image(self, prompt, model, **kwargs):
|
||||
self.image_calls.append({"prompt": prompt, "model": model, **kwargs})
|
||||
if self.image_error is not None:
|
||||
raise self.image_error
|
||||
return ImageGenerationResult(
|
||||
image=b"generated-image-bytes",
|
||||
model_used=model.model,
|
||||
raw={"b64_json": "SECRET_RAW_SHOULD_NOT_BE_STORED"},
|
||||
)
|
||||
|
||||
|
||||
@override_settings(AI_KEY_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY)
|
||||
class GenerateApiTests(TestCase):
|
||||
def setUp(self):
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
self.media_dir = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.media_dir.cleanup)
|
||||
media_override = override_settings(
|
||||
MEDIA_ROOT=self.media_dir.name,
|
||||
MEDIA_URL="/media/",
|
||||
)
|
||||
media_override.enable()
|
||||
self.addCleanup(media_override.disable)
|
||||
|
||||
self.user = get_user_model().objects.create_user(
|
||||
username=f"generate-user-{suffix}",
|
||||
email=f"generate-user-{suffix}@example.com",
|
||||
password="password",
|
||||
)
|
||||
self.wallet = UserWallet.objects.create(user=self.user, points_balance=100)
|
||||
self.api_key, self.raw_key = ApiKey.create_for_user(self.user, name="generate")
|
||||
self.client = APIClient()
|
||||
self.provider = FakeGenerationProvider()
|
||||
|
||||
self.title_model = self.create_ai_model(
|
||||
name=f"title-model-{suffix}",
|
||||
model=f"gpt-title-{suffix}",
|
||||
capabilities=["text", "vision"],
|
||||
)
|
||||
self.image_model = self.create_ai_model(
|
||||
name=f"image-model-{suffix}",
|
||||
model=f"gpt-image-{suffix}",
|
||||
capabilities=["image", "vision"],
|
||||
)
|
||||
self.title_alias = f"title-standard-{suffix}"
|
||||
self.image_alias = f"image-hd-{suffix}"
|
||||
ModelAlias.objects.create(
|
||||
operation_type=ModelAlias.OperationType.TITLE,
|
||||
alias=self.title_alias,
|
||||
ai_model=self.title_model,
|
||||
is_default=True,
|
||||
)
|
||||
ModelAlias.objects.create(
|
||||
operation_type=ModelAlias.OperationType.IMAGE,
|
||||
alias=self.image_alias,
|
||||
ai_model=self.image_model,
|
||||
is_default=True,
|
||||
)
|
||||
PricingRule.objects.create(
|
||||
operation_type=CallRecord.OperationType.TITLE,
|
||||
alias=self.title_alias,
|
||||
points_cost=2,
|
||||
)
|
||||
PricingRule.objects.create(
|
||||
operation_type=CallRecord.OperationType.IMAGE,
|
||||
alias=self.image_alias,
|
||||
resolution="1K",
|
||||
points_cost=10,
|
||||
)
|
||||
|
||||
def create_ai_model(self, *, name, model, capabilities):
|
||||
ai_model = AiModel(
|
||||
name=name,
|
||||
url="https://api.example.test/v1",
|
||||
model=model,
|
||||
api_type=AiModel.ApiType.CHAT,
|
||||
capabilities=capabilities,
|
||||
)
|
||||
ai_model.set_api_key("sk-test-secret")
|
||||
ai_model.save()
|
||||
return ai_model
|
||||
|
||||
def auth_header(self) -> dict:
|
||||
return {"HTTP_AUTHORIZATION": f"Bearer {self.raw_key}"}
|
||||
|
||||
def post_with_provider(self, path, payload, provider=None):
|
||||
with patch("apps.api.generation.get_provider", return_value=provider or self.provider):
|
||||
return self.client.post(path, payload, format="json", **self.auth_header())
|
||||
|
||||
def test_generate_title_uses_default_alias_charges_points_and_writes_call_record(self):
|
||||
response = self.post_with_provider(
|
||||
"/api/v1/generate/title",
|
||||
{
|
||||
"prompt": "生成标题",
|
||||
"resolution": "1k",
|
||||
"parameters": {"temperature": 0.2, "model": "bad-overridden-model"},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["titles"], ["测试标题一", "测试标题二"])
|
||||
self.assertEqual(response.data["alias"], self.title_alias)
|
||||
self.assertEqual(response.data["model_used"], self.title_model.model)
|
||||
self.assertEqual(response.data["points_cost"], 2)
|
||||
self.assertEqual(response.data["points_balance"], 98)
|
||||
self.assertEqual(len(self.provider.text_calls), 1)
|
||||
self.assertEqual(self.provider.text_calls[0]["model"].model, self.title_model.model)
|
||||
|
||||
self.wallet.refresh_from_db()
|
||||
self.assertEqual(self.wallet.points_balance, 98)
|
||||
call = CallRecord.objects.get(pk=response.data["call_id"])
|
||||
self.assertEqual(call.status, CallRecord.Status.SUCCESS)
|
||||
self.assertEqual(call.api_key, self.api_key)
|
||||
self.assertEqual(call.alias, self.title_alias)
|
||||
self.assertEqual(call.model_used, self.title_model.model)
|
||||
self.assertEqual(call.resolution, "1K")
|
||||
self.assertNotIn("SECRET_RAW", call.result_summary)
|
||||
self.assertEqual(
|
||||
PointsLedger.objects.filter(
|
||||
user=self.user,
|
||||
change_type=PointsLedger.ChangeType.CONSUME,
|
||||
).count(),
|
||||
1,
|
||||
)
|
||||
|
||||
def test_generate_image_stores_file_returns_url_and_does_not_store_raw_base64(self):
|
||||
encoded = base64.b64encode(b"input-image").decode("ascii")
|
||||
|
||||
response = self.post_with_provider(
|
||||
"/api/v1/generate/image",
|
||||
{
|
||||
"prompt": "生成图片",
|
||||
"model": self.image_alias,
|
||||
"image_base64": f"data:image/png;base64,{encoded}",
|
||||
"resolution": "1K",
|
||||
"aspect_ratio": "1:1",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["alias"], self.image_alias)
|
||||
self.assertEqual(response.data["model_used"], self.image_model.model)
|
||||
self.assertEqual(response.data["points_cost"], 10)
|
||||
self.assertEqual(response.data["points_balance"], 90)
|
||||
self.assertTrue(response.data["image_url"].startswith("http://testserver/media/"))
|
||||
self.assertEqual(self.provider.image_calls[0]["image"], b"input-image")
|
||||
|
||||
media_relative_path = response.data["image_url"].split("/media/", 1)[1]
|
||||
self.assertTrue((Path(self.media_dir.name) / media_relative_path).exists())
|
||||
|
||||
call = CallRecord.objects.get(pk=response.data["call_id"])
|
||||
self.assertEqual(call.status, CallRecord.Status.SUCCESS)
|
||||
self.assertEqual(call.result_ref, response.data["image_url"])
|
||||
self.assertEqual(call.result_summary, "image_bytes=21")
|
||||
self.assertNotIn("SECRET_RAW", call.result_ref + call.result_summary)
|
||||
|
||||
def test_insufficient_points_returns_402_without_calling_provider_or_writing_call(self):
|
||||
self.wallet.points_balance = 1
|
||||
self.wallet.save(update_fields=("points_balance", "updated_at"))
|
||||
|
||||
response = self.post_with_provider(
|
||||
"/api/v1/generate/title",
|
||||
{"prompt": "生成标题", "model": self.title_alias},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 402)
|
||||
self.assertEqual(response.data["error"]["code"], "insufficient_points")
|
||||
self.assertEqual(self.provider.text_calls, [])
|
||||
self.assertFalse(CallRecord.objects.filter(user=self.user).exists())
|
||||
self.assertFalse(PointsLedger.objects.filter(user=self.user).exists())
|
||||
self.wallet.refresh_from_db()
|
||||
self.assertEqual(self.wallet.points_balance, 1)
|
||||
|
||||
def test_missing_pricing_rule_returns_400_without_charging(self):
|
||||
PricingRule.objects.filter(alias=self.title_alias).delete()
|
||||
|
||||
response = self.post_with_provider(
|
||||
"/api/v1/generate/title",
|
||||
{"prompt": "生成标题", "model": self.title_alias},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.data["error"]["code"], "no_pricing_rule")
|
||||
self.assertEqual(self.provider.text_calls, [])
|
||||
self.assertFalse(CallRecord.objects.filter(user=self.user).exists())
|
||||
self.wallet.refresh_from_db()
|
||||
self.assertEqual(self.wallet.points_balance, 100)
|
||||
|
||||
def test_alias_capability_mismatch_returns_model_not_allowed_without_charging(self):
|
||||
bad_alias = f"bad-title-{uuid.uuid4().hex[:8]}"
|
||||
ModelAlias.objects.create(
|
||||
operation_type=ModelAlias.OperationType.TITLE,
|
||||
alias=bad_alias,
|
||||
ai_model=self.image_model,
|
||||
)
|
||||
PricingRule.objects.create(
|
||||
operation_type=CallRecord.OperationType.TITLE,
|
||||
alias=bad_alias,
|
||||
points_cost=2,
|
||||
)
|
||||
|
||||
response = self.post_with_provider(
|
||||
"/api/v1/generate/title",
|
||||
{"prompt": "生成标题", "model": bad_alias},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.data["error"]["code"], "model_not_allowed")
|
||||
self.assertEqual(self.provider.text_calls, [])
|
||||
self.assertFalse(CallRecord.objects.filter(alias=bad_alias).exists())
|
||||
|
||||
def test_provider_capability_mismatch_returns_model_not_allowed_before_charging(self):
|
||||
text_only_provider = FakeGenerationProvider(capabilities={"text"})
|
||||
|
||||
response = self.post_with_provider(
|
||||
"/api/v1/generate/image",
|
||||
{"prompt": "生成图片", "model": self.image_alias, "resolution": "1K"},
|
||||
provider=text_only_provider,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.data["error"]["code"], "model_not_allowed")
|
||||
self.assertEqual(text_only_provider.image_calls, [])
|
||||
self.assertFalse(CallRecord.objects.filter(user=self.user).exists())
|
||||
|
||||
def test_upstream_failure_refunds_precharged_points_and_marks_call_failed(self):
|
||||
self.provider.text_error = AiProviderError("provider timeout")
|
||||
|
||||
response = self.post_with_provider(
|
||||
"/api/v1/generate/title",
|
||||
{"prompt": "生成标题", "model": self.title_alias},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 502)
|
||||
self.assertEqual(response.data["error"]["code"], "upstream_error")
|
||||
self.wallet.refresh_from_db()
|
||||
self.assertEqual(self.wallet.points_balance, 100)
|
||||
|
||||
call = CallRecord.objects.get(user=self.user)
|
||||
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_provider_capability_error_returns_400_and_refunds_points(self):
|
||||
self.provider.image_error = AiCapabilityError("input image is required")
|
||||
|
||||
response = self.post_with_provider(
|
||||
"/api/v1/generate/image",
|
||||
{"prompt": "生成图片", "model": self.image_alias, "resolution": "1K"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(response.data["error"]["code"], "bad_request")
|
||||
self.wallet.refresh_from_db()
|
||||
self.assertEqual(self.wallet.points_balance, 100)
|
||||
call = CallRecord.objects.get(user=self.user)
|
||||
self.assertEqual(call.status, CallRecord.Status.FAILED)
|
||||
self.assertEqual(
|
||||
PointsLedger.objects.filter(
|
||||
ref_call=call,
|
||||
change_type=PointsLedger.ChangeType.REFUND,
|
||||
).count(),
|
||||
1,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import GenerateImageView, GenerateTitleView
|
||||
|
||||
urlpatterns = [
|
||||
path("v1/generate/title", GenerateTitleView.as_view(), name="api-generate-title"),
|
||||
path("v1/generate/image", GenerateImageView.as_view(), name="api-generate-image"),
|
||||
]
|
||||
@@ -1,9 +1,20 @@
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.api.authentication import ApiKeyAuthentication
|
||||
from apps.api.errors import api_error
|
||||
from apps.api.generation import (
|
||||
ApiRequestError,
|
||||
generate_image_response,
|
||||
generate_title_response,
|
||||
)
|
||||
from apps.api.serializers import (
|
||||
GenerateImageRequestSerializer,
|
||||
GenerateTitleRequestSerializer,
|
||||
)
|
||||
|
||||
|
||||
class ExternalApiView(APIView):
|
||||
@@ -14,3 +25,42 @@ class ExternalApiView(APIView):
|
||||
if request.authenticators and not request.successful_authenticator:
|
||||
raise AuthenticationFailed(api_error("unauthorized", "缺失或无效 API Key"))
|
||||
super().permission_denied(request, message=message, code=code)
|
||||
|
||||
|
||||
class GenerateTitleView(ExternalApiView):
|
||||
def post(self, request):
|
||||
serializer = GenerateTitleRequestSerializer(data=request.data)
|
||||
if not serializer.is_valid():
|
||||
return Response(
|
||||
api_error("bad_request", "参数错误"),
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
try:
|
||||
data = generate_title_response(
|
||||
user=request.user,
|
||||
api_key=request.auth,
|
||||
request_data=serializer.validated_data,
|
||||
)
|
||||
except ApiRequestError as exc:
|
||||
return Response(exc.as_response_data(), status=exc.http_status)
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class GenerateImageView(ExternalApiView):
|
||||
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:
|
||||
data = generate_image_response(
|
||||
user=request.user,
|
||||
api_key=request.auth,
|
||||
request=request,
|
||||
request_data=serializer.validated_data,
|
||||
)
|
||||
except ApiRequestError as exc:
|
||||
return Response(exc.as_response_data(), status=exc.http_status)
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
@@ -169,6 +169,9 @@ USE_TZ = True
|
||||
|
||||
STATIC_URL = 'static/'
|
||||
|
||||
MEDIA_ROOT = os.environ.get("MEDIA_ROOT", str(BASE_DIR / "media"))
|
||||
MEDIA_URL = os.environ.get("MEDIA_URL", "/media/")
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
|
||||
|
||||
|
||||
+7
-1
@@ -14,9 +14,15 @@ Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.urls import path
|
||||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('api/', include('apps.api.urls')),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
|
||||
@@ -38,14 +38,14 @@
|
||||
|
||||
## 当前阶段
|
||||
|
||||
当前项目处于:**Phase 3 对外 API 与充值起步**。Phase 2 计费核心已完成到 T-204;T-301 API Key 鉴权已落地。下一步进入 T-302 生成标题 / 图片接口。
|
||||
当前项目处于:**Phase 3 对外 API 与充值起步**。Phase 2 计费核心已完成到 T-204;T-301 API Key 鉴权与 T-302 生成标题 / 图片接口已落地。下一步进入 T-303 余额查询接口。
|
||||
|
||||
优先路径:
|
||||
|
||||
1. Phase 0:Django 骨架可运行、**自定义 User 模型在首次迁移前定好**、django-admin 可登录;T-004 审核修补项已完成。
|
||||
2. Phase 1:最高风险功能原型 —— T-101/T-102/T-103/T-104/T-105 已完成 provider 层、模型配置表、别名解析、配置审计、录制标题/图片 smoke 与审核修补;真实图片同步耗时待配置 Fernet 主密钥、AiModel/ModelAlias 与真实上游后在 T-302/T-403 前补测。
|
||||
3. Phase 2:计费核心 —— T-201/T-202/T-203 已完成 UserWallet/ApiKey/PointsLedger/CallRecord、计费规则、汇率、计费计算、并发安全扣点与失败退点。
|
||||
4. Phase 3:对外 API 与充值 —— T-301 Key 鉴权已完成;下一步 T-302 生成接口,随后余额查询、充值回调、扫码下单与轮询。
|
||||
4. Phase 3:对外 API 与充值 —— T-301 Key 鉴权、T-302 生成接口已完成;下一步 T-303 余额查询,随后充值回调、扫码下单与轮询。
|
||||
5. Phase 4:用户端(Django 模板 SSR)—— 注册登录、API Key 管理、个人中心/记录页、充值页。
|
||||
6. Phase 5:后台与发布 —— 运营后台完善、完整验收、部署 / 运行文档。
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
| 后台美化 | 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` |
|
||||
| 图片结果存储 | 对象存储(S3 兼容 / 本地存储)返回 URL | 待定 | 同步响应默认返回 `image_url`,避免大 base64 进响应体 |
|
||||
| 图片结果存储 | 本地存储返回 URL(S3 兼容后续可替换) | 已定(MVP) | T-302 已用 Django `default_storage` + `MEDIA_ROOT` / `MEDIA_URL` 落地本地存储,同步响应返回 `image_url`,避免大 base64 进响应体;生产对象存储后续可替换 |
|
||||
| 配置变更审计 | 自建 `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` 配置 |
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
|
||||
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。
|
||||
|
||||
**计费层(`apps/billing`)**
|
||||
|
||||
- 计费规则查询:按「操作类型 + 能力别名(+ 可选分辨率)」算出本次点数 N。**按别名定价,不按具体供应商 SKU 定价**,这样后台换底层模型时计费不变。
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@
|
||||
| ID | 任务 | 依赖 | 验收要点 | 状态 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| T-301 | API Key 鉴权(DRF Authentication) | T-201 | 对请求 Key 哈希比对定位 ApiKey→User;**只挂 Key 认证、不挂 Session**;无效/缺失 401;用户或 Key 禁用 403 | DONE |
|
||||
| T-302 | 生成标题 / 图片接口 | T-104, T-203, T-301 | 按 `api.md` 实现;请求传**能力别名**+ `parameters`,但 `parameters` 只能经 Provider 白名单透传,核心/计费字段不可被覆盖;编排「别名解析+能力校验→预扣→调上游→成功确认/失败退点→写记录」;评估 `Provider.capabilities()` 与模型声明能力的二次校验;`images_edits` 缺原图 / `AiCapabilityError` 翻译为 400,不落 500;调用记录不保存 provider `raw` / base64;点数不足返回 402;含测试;建议随本任务或 T-403 前跑一次真实图片生成并记录真实耗时 | TODO |
|
||||
| T-302 | 生成标题 / 图片接口 | T-104, T-203, T-301 | 按 `api.md` 实现;请求传**能力别名**+ `parameters`,但 `parameters` 只能经 Provider 白名单透传,核心/计费字段不可被覆盖;编排「别名解析+能力校验→预扣→调上游→成功确认/失败退点→写记录」;评估 `Provider.capabilities()` 与模型声明能力的二次校验;`images_edits` 缺原图 / `AiCapabilityError` 翻译为 400,不落 500;调用记录不保存 provider `raw` / base64;点数不足返回 402;含测试;建议随本任务或 T-403 前跑一次真实图片生成并记录真实耗时 | DONE |
|
||||
| T-303 | 余额查询接口 | T-301 | 返回余额等于流水累加;含测试 | TODO |
|
||||
| T-304 | 充值回调(微信/支付宝验签 + 幂等入账) | T-202 | 两端点 `@csrf_exempt`;微信 SDK 验签解密、支付宝 SDK verify;验签失败不入账;同一 order_no 重复回调只入账一次;校验回调金额与订单金额一致;使用订单创建时锁定的 `points_granted` 锁 wallet 入账写流水;补主动查单兜底;含幂等测试 | TODO |
|
||||
| T-305 | 扫码充值下单 + 轮询(create/status) | T-304 | 支持 weixin(native,金额分)/alipay(precreate,金额元);建 pending 订单绑定 user,并在下单时锁定汇率/预计点数→取 code_url/qr_code→前端渲染 + 轮询 status;缺商户密钥时 mock | TODO |
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
|
||||
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`,后续可替换为对象存储。
|
||||
|
||||
通用错误响应:
|
||||
|
||||
```json
|
||||
|
||||
+15
-10
@@ -12,16 +12,16 @@
|
||||
## 当前快照
|
||||
|
||||
- 日期:2026-07-02
|
||||
- 阶段:Phase 3 对外 API 与充值起步;T-301 API Key 鉴权已完成,下一步 T-302 生成标题 / 图片接口
|
||||
- 阶段:Phase 3 对外 API 与充值起步;T-302 生成标题 / 图片接口已完成,下一步 T-303 余额查询接口
|
||||
- 技术栈:系统 Python 3.12.3 + Django 5.2.15 + DRF 3.16.1 + PyMySQL 1.1.3 + cryptography 46.0.7 + requests 2.34.2 + django-admin;MySQL 8.4 已接入 settings,并支持 `MYSQL_CONNECT_TIMEOUT` / `MYSQL_READ_TIMEOUT` / `MYSQL_WRITE_TIMEOUT`;用户端(模板 SSR/Bootstrap/allauth) 后续任务落地;详见 `03-tech-stack.md`
|
||||
- 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器);T-102 已新增 `AiModel` / `ModelAlias`、Fernet 加密密钥存储、别名解析、admin 配置页、`import_ai_models` 导入命令;T-103 已新增 `AiConfigAuditLog` 审计表、admin 只读页面和后台保存/删除审计 hook;T-104/T-105 已完成录制 title/image smoke 与审核修补;T-201 已新增 `UserWallet` / `ApiKey`、`PointsLedger` / `CallRecord`、对应 admin 与迁移;T-202 已新增 `PricingRule` / `ExchangeRate`、`apps.billing.pricing` 计费计算函数、admin 配置页与迁移;T-203 已新增 `apps.billing.services`,实现并发安全预扣、成功确认与幂等失败退点;T-204 已新增 `billing.0003_pointsledger_unique_ledger_change_type_per_call`,用 MySQL 可落地的 `ref_call + change_type` 复合唯一约束兜底防重复 refund;T-301 已新增 `apps.api.authentication.ApiKeyAuthentication` 与 `ExternalApiView`
|
||||
- 测试:T-301 验证通过:`py_compile`、`manage.py check`、`makemigrations --check`、`manage.py test apps.api --noinput --keepdb --verbosity 2`(8 tests OK)。T-204 已补齐完整全量测试证据:`py -3.12 manage.py test --noinput --keepdb --verbosity 2`(44 tests OK)。远程 MySQL 仍可能偶发链路超时,必要时先确认无残留测试进程,再串行重跑。
|
||||
- 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器);T-102 已新增 `AiModel` / `ModelAlias`、Fernet 加密密钥存储、别名解析、admin 配置页、`import_ai_models` 导入命令;T-103 已新增 `AiConfigAuditLog` 审计表、admin 只读页面和后台保存/删除审计 hook;T-104/T-105 已完成录制 title/image smoke 与审核修补;T-201 已新增 `UserWallet` / `ApiKey`、`PointsLedger` / `CallRecord`、对应 admin 与迁移;T-202 已新增 `PricingRule` / `ExchangeRate`、`apps.billing.pricing` 计费计算函数、admin 配置页与迁移;T-203 已新增 `apps.billing.services`,实现并发安全预扣、成功确认与幂等失败退点;T-204 已新增 `billing.0003_pointsledger_unique_ledger_change_type_per_call`,用 MySQL 可落地的 `ref_call + change_type` 复合唯一约束兜底防重复 refund;T-301 已新增 `apps.api.authentication.ApiKeyAuthentication` 与 `ExternalApiView`;T-302 已新增生成接口编排、序列化器、图片本地存储和 `/api/v1/generate/title|image` 路由
|
||||
- 测试:T-302 验证通过:`py_compile`、`manage.py check`、`makemigrations --check`、`manage.py test apps.api --noinput --keepdb --verbosity 2`(16 tests OK)、`py -3.12 manage.py test --noinput --keepdb --verbosity 2`(60 tests OK)。远程 MySQL 仍可能偶发链路超时,必要时先确认无残留测试进程,再串行重跑。
|
||||
- 数据:AI 上游调用与模型配置参考 `D:\chengma\cmbot`(`src/services/ai_text_service.py`、`ai_image_service.py`、`config/ai_models.json`);真实 `ai_models.json` 不提交,需通过 `import_ai_models` 命令加密导入
|
||||
- 标准启动路径:Windows 用 `./init.ps1`;Unix/WSL 用 `./init.sh`
|
||||
- 标准验证路径:Windows 用 `py -3.12 manage.py check` / `py -3.12 manage.py test`
|
||||
- 设计基线:**自助用户端 + 对外 API + 运营后台**三合一单体;用户模型 `User`(auth)/`UserWallet`(点数,锁 wallet 扣点)/`ApiKey`(1:N,哈希存储);对外两接口 + **能力别名 + Provider 适配器**(可插拔供应商);自助扫码充值;注册不送点数。详见 `04-architecture.md` 与 2026-06-29 / 2026-07-01 的 `progress.md` 决策
|
||||
- 配置基线:运行环境变量集中见 `docs/env.md`;真实密钥/支付凭证不得写入代码或文档样例。充值订单在创建时锁定汇率与预计点数,回调入账使用订单值,不按新汇率重算
|
||||
- 当前 blocker:无阻塞 T-302 的 blocker。支付商户密钥/证书仍缺真实值,但不阻塞 T-302;真实 AI 上游 smoke 需要先配置 `AI_KEY_ENCRYPTION_KEY` 并导入 AiModel/ModelAlias。图片同步真实耗时风险仍未退,已登记到 T-302/T-403。
|
||||
- 当前 blocker:无阻塞 T-303 的 blocker。支付商户密钥/证书仍缺真实值,但不阻塞 T-303;真实 AI 上游 smoke 需要先配置 `AI_KEY_ENCRYPTION_KEY` 并导入 AiModel/ModelAlias。图片同步真实耗时风险仍未退,已登记到 T-403。
|
||||
|
||||
## 当前目录要点
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
| `init.sh` / `init.ps1` | 已有 | 启动验证入口,已固定系统 Python 3.12 命令,并校验解释器版本 `>=3.12,<3.14` |
|
||||
| `requirements.txt` / `pyproject.toml` | 已有 | `requirements.txt` 管运行依赖;`pyproject.toml` 落地 `requires-python`;T-101 新增 `requests`;T-102 使用既有 `cryptography` 做 Fernet 加密 |
|
||||
| `config/`(Django 工程) | 已有 | T-001 创建,含 settings / urls / wsgi / asgi |
|
||||
| `apps/`(users/portal/billing/ai/api) | 已有 | T-002 创建;`apps/users` 已定义自定义 `User`;T-003 已注册 admin 与 admin smoke test;T-004 已给 `User.email` 加唯一约束;T-101 已新增 `apps/ai/providers`;T-102 已新增 `apps/ai/security.py`、`aliases.py`、`importers.py`、management command 与 `ai.0001_initial` 迁移;T-103 已新增 `apps/ai/audit.py` 与 `ai.0002_aiconfigauditlog` 迁移;T-104/T-105 已新增 `smoke_ai_generation` 录制 title/image smoke 命令;T-201 已在 users 落 `UserWallet` / `ApiKey`,在 billing 落 `PointsLedger` / `CallRecord`;T-202 已在 billing 落 `PricingRule` / `ExchangeRate` 与 `pricing.py`;T-203 已新增 `apps/billing/services.py` |
|
||||
| `apps/`(users/portal/billing/ai/api) | 已有 | T-002 创建;`apps/users` 已定义自定义 `User`;T-003 已注册 admin 与 admin smoke test;T-004 已给 `User.email` 加唯一约束;T-101 已新增 `apps/ai/providers`;T-102 已新增 `apps/ai/security.py`、`aliases.py`、`importers.py`、management command 与 `ai.0001_initial` 迁移;T-103 已新增 `apps/ai/audit.py` 与 `ai.0002_aiconfigauditlog` 迁移;T-104/T-105 已新增 `smoke_ai_generation` 录制 title/image smoke 命令;T-201 已在 users 落 `UserWallet` / `ApiKey`,在 billing 落 `PointsLedger` / `CallRecord`;T-202 已在 billing 落 `PricingRule` / `ExchangeRate` 与 `pricing.py`;T-203 已新增 `apps/billing/services.py`;T-301/T-302 已在 api 落鉴权、生成接口编排、序列化器、图片存储与路由 |
|
||||
| `manage.py` | 已有 | T-001 创建 |
|
||||
| `tests/` | 待建 | 随各任务补充 |
|
||||
|
||||
@@ -41,10 +41,10 @@
|
||||
|
||||
任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史执行记录见 [`../progress.md`](../progress.md)。
|
||||
|
||||
- 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-103 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补;T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型;T-202 PricingRule / ExchangeRate 模型 + 计费计算;T-203 并发安全扣点 / 退点;T-204 Phase 2 计费核心审核加固;T-301 API Key 鉴权。
|
||||
- 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-103 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补;T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型;T-202 PricingRule / ExchangeRate 模型 + 计费计算;T-203 并发安全扣点 / 退点;T-204 Phase 2 计费核心审核加固;T-301 API Key 鉴权;T-302 生成标题 / 图片接口。
|
||||
- 正在进行:无。
|
||||
- 当前 blocker:无阻塞 T-302 的 blocker。
|
||||
- 下一个可领取任务:**T-302 生成标题 / 图片接口**。
|
||||
- 当前 blocker:无阻塞 T-303 的 blocker。
|
||||
- 下一个可领取任务:**T-303 余额查询接口**。
|
||||
|
||||
## 当前可运行内容
|
||||
|
||||
@@ -68,14 +68,19 @@ python3.12 manage.py smoke_ai_generation title --recorded
|
||||
python3.12 manage.py smoke_ai_generation image --recorded
|
||||
```
|
||||
|
||||
当前骨架可运行。T-002 已在首次迁移前创建自定义 User,并按 `env.md` 接入 MySQL 8.4 / utf8mb4;远程 MySQL 已完成 Django 初始迁移。T-003 已接通 django-admin,测试可创建/销毁 `test_cmhub` 测试库;当前远程 MySQL 对频繁建库/销库仍可能间歇超时,必要时用 `--keepdb` 且串行跑测试。T-004 已应用 `users.0002_alter_user_email`,`user.email` 已有唯一索引。T-101 的 AI provider 层只做 HTTP 调用与响应解析;T-102 已把 provider 运行配置接到数据库 `AiModel` / `ModelAlias`,`resolve_alias()` 每次查当前 active 配置并按 `text` / `image` 能力校验。T-103 已补 `AiConfigAuditLog`,admin 保存/删除 `AiModel` / `ModelAlias` 时记录 actor、action、target、changed_fields、changes、created_at,密钥只记录 empty/set 状态。T-104/T-105 已用临时回滚配置跑通录制标题和录制图片生成。T-201 已落地钱包、API Key、点数流水和调用记录:API Key 明文只在创建 helper 返回,库内只存 hash/prefix;CallRecord 只存 `result_ref`/`result_summary`,没有 provider raw 字段。T-202 已落地 `PricingRule` / `ExchangeRate`:计费按 `operation_type + alias + resolution` 查 active 规则,优先精确分辨率,再回退默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`;金额换点数按当前 active 汇率向下取整。T-203 已落地 `precharge_call()` / `mark_call_success()` / `refund_call_points()`:预扣锁钱包行,余额不足不写调用/流水;失败退点锁调用记录并幂等写 refund 流水。T-204 已完成复合唯一约束加固,并取得一次完整 `manage.py test` 单次全绿。T-301 已落地 `Authorization: Bearer <API_KEY>` 鉴权:成功后 `request.user` 为所属用户、`request.auth` 为 `ApiKey`,缺失/无效 Key 返回 401,用户或 Key 禁用返回 403,外部 API 不接受 Web session。真实上游生成未执行,原因是当前环境未配置 `AI_KEY_ENCRYPTION_KEY` 且数据库没有 AiModel/ModelAlias;后续配置后可用 `import_ai_models` 导入,再用同一 smoke 命令去掉 `--recorded` 跑真实标题/图片。
|
||||
当前对外生成接口:
|
||||
|
||||
- `POST /api/v1/generate/title`
|
||||
- `POST /api/v1/generate/image`
|
||||
|
||||
当前骨架可运行。T-002 已在首次迁移前创建自定义 User,并按 `env.md` 接入 MySQL 8.4 / utf8mb4;远程 MySQL 已完成 Django 初始迁移。T-003 已接通 django-admin,测试可创建/销毁 `test_cmhub` 测试库;当前远程 MySQL 对频繁建库/销库仍可能间歇超时,必要时用 `--keepdb` 且串行跑测试。T-004 已应用 `users.0002_alter_user_email`,`user.email` 已有唯一索引。T-101 的 AI provider 层只做 HTTP 调用与响应解析;T-102 已把 provider 运行配置接到数据库 `AiModel` / `ModelAlias`,`resolve_alias()` 每次查当前 active 配置并按 `text` / `image` 能力校验。T-103 已补 `AiConfigAuditLog`,admin 保存/删除 `AiModel` / `ModelAlias` 时记录 actor、action、target、changed_fields、changes、created_at,密钥只记录 empty/set 状态。T-104/T-105 已用临时回滚配置跑通录制标题和录制图片生成。T-201 已落地钱包、API Key、点数流水和调用记录:API Key 明文只在创建 helper 返回,库内只存 hash/prefix;CallRecord 只存 `result_ref`/`result_summary`,没有 provider raw 字段。T-202 已落地 `PricingRule` / `ExchangeRate`:计费按 `operation_type + alias + resolution` 查 active 规则,优先精确分辨率,再回退默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`;金额换点数按当前 active 汇率向下取整。T-203 已落地 `precharge_call()` / `mark_call_success()` / `refund_call_points()`:预扣锁钱包行,余额不足不写调用/流水;失败退点锁调用记录并幂等写 refund 流水。T-204 已完成复合唯一约束加固,并取得一次完整 `manage.py test` 单次全绿。T-301 已落地 `Authorization: Bearer <API_KEY>` 鉴权:成功后 `request.user` 为所属用户、`request.auth` 为 `ApiKey`,缺失/无效 Key 返回 401,用户或 Key 禁用返回 403,外部 API 不接受 Web session。T-302 已落地生成接口:请求别名解析后按规则计费,预扣成功才调用 Provider,成功确认调用记录,`AiProviderError` / `AiCapabilityError` 等失败路径会退点;图片结果保存到本地 media 并返回 URL。真实上游生成未执行,原因是当前环境未配置 `AI_KEY_ENCRYPTION_KEY` 且数据库没有 AiModel/ModelAlias;后续配置后可用 `import_ai_models` 导入,再通过接口跑真实标题/图片。
|
||||
|
||||
## 开始编码前检查
|
||||
|
||||
1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。
|
||||
2. 读 `docs/00-ai-start-here.md`。
|
||||
3. 读 `docs/05-coding-rules.md`(尤其第 8 节资金安全)。
|
||||
4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-302)。
|
||||
4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-303)。
|
||||
5. 将该任务状态改为 `DOING`。
|
||||
|
||||
## 维护规则
|
||||
|
||||
+23
@@ -572,3 +572,26 @@
|
||||
- 阻塞:无。
|
||||
- 决策:生成/余额等对外 API 后续统一继承 `ExternalApiView`;充值下单/状态查询仍按用户端 session 流程另行实现,不使用该外部 API 基类。
|
||||
- 下一步:领取 T-302 生成标题 / 图片接口。
|
||||
|
||||
## 2026-07-02 T-302 生成标题 / 图片接口
|
||||
|
||||
- 状态:DONE
|
||||
- 变更:
|
||||
- 新增 `apps/api/generation.py`:编排「别名解析 -> Provider 能力二次校验 -> 计费 -> 预扣 -> 调 Provider -> 成功确认 / 失败退点」,统一翻译 `insufficient_points`、`no_pricing_rule`、`model_not_allowed`、`bad_request`、`upstream_error`。
|
||||
- 新增 `apps/api/serializers.py`:校验 `POST /api/v1/generate/title` 与 `POST /api/v1/generate/image` 请求参数,仅把 `parameters` 字段传给 Provider,不把整包请求透传。
|
||||
- 新增 `apps/api/storage.py`:图片结果用 Django `default_storage` 存到 `MEDIA_ROOT/generated/images/...`,响应返回 `image_url`,调用记录只写 URL / 摘要,不写 provider raw 或 base64。
|
||||
- 新增 `apps/api/urls.py` 并接入 `config/urls.py`:开放 `/api/v1/generate/title` 与 `/api/v1/generate/image`。
|
||||
- `config/settings.py`:补 `MEDIA_ROOT` / `MEDIA_URL`。
|
||||
- `apps/ai/aliases.py`:新增 `resolve_model_alias()`,用于默认别名场景拿到真实 alias 参与计费和调用记录;原 `resolve_alias()` 保持兼容。
|
||||
- 扩展 `apps/api/tests.py`:覆盖标题成功扣点、图片存储返回 URL、余额不足不调上游、缺计费规则不扣点、别名能力不匹配、Provider 能力二次校验、上游失败退点、`AiCapabilityError` 返回 400 且退点。
|
||||
- 同步更新 `README.md`、`docs/00-ai-start-here.md`、`docs/03-tech-stack.md`、`docs/04-architecture.md`、`docs/api.md`、`docs/06-tasks.md`、`docs/current-state.md`。
|
||||
- 验证:
|
||||
- `./init.ps1`:开工前通过。
|
||||
- `py -3.12 -m py_compile apps\api\generation.py apps\api\serializers.py apps\api\storage.py apps\api\urls.py apps\api\views.py apps\api\tests.py apps\ai\aliases.py config\settings.py config\urls.py`:通过。
|
||||
- `py -3.12 manage.py check`:通过,0 issues。
|
||||
- `py -3.12 manage.py makemigrations --check`:通过,No changes detected。
|
||||
- `py -3.12 manage.py test apps.api --noinput --keepdb --verbosity 2`:通过,16 tests OK。
|
||||
- `py -3.12 manage.py test --noinput --keepdb --verbosity 2`:通过,60 tests OK,耗时 133.953s。
|
||||
- 阻塞:无。真实上游标题/图片接口未跑,原因是当前环境未配置可用 `AI_KEY_ENCRYPTION_KEY` + AiModel/ModelAlias + 真实上游 key;图片同步真实耗时风险仍保留到 T-403 部署前校准。
|
||||
- 决策:MVP 图片结果先用本地 media 存储返回 URL,不引入 S3 依赖;后续生产对象存储可替换 `default_storage`。
|
||||
- 下一步:领取 T-303 余额查询接口。
|
||||
|
||||
Reference in New Issue
Block a user