feat: add public model catalog discovery

This commit is contained in:
QiuSW
2026-07-04 10:14:16 +08:00
parent 3a661afaa5
commit 898329e047
16 changed files with 533 additions and 14 deletions
+1 -1
View File
@@ -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 部署 / 运行文档:用户可通过 allauth 自助注册、邮箱验证、登录、登出,扫码充值并轮询到账,生成 / 删除(吊销)API Key,查看余额、充值总额、分页充值记录与分页消费记录;运营可在 django-admin 检索用户、钱包、API Key、计费规则、汇率、充值订单、点数流水和调用记录,并通过计费层带原因手工调点;生产部署按 `docs/deployment.md` 执行。计划内 MVP 任务已完成,下一步是按部署文档上 VPS 配置真实邮件、支付、AI 模型和图片真实耗时验证。详见 [`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 可用别名发现(`GET /api/v1/models` + portal 只读「可用模型」页):用户可通过 allauth 自助注册、邮箱验证、登录、登出,扫码充值并轮询到账,生成 / 删除(吊销)API Key,查看余额、充值总额、分页充值记录、分页消费记录与可用模型;运营可在 django-admin 检索用户、钱包、API Key、计费规则、汇率、充值订单、点数流水和调用记录,并通过计费层带原因手工调点;生产部署按 `docs/deployment.md` 执行。计划内 MVP 任务和 T-601 增强任务已完成,同时生产侧仍需配置真实邮件、支付、AI 模型和图片真实耗时验证。详见 [`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) 第四节计费时序。 > ⚠️ 涉及资金/点数。改动充值、扣费、退款、对账相关代码前,先读 [`docs/05-coding-rules.md`](docs/05-coding-rules.md) 第 8 节与 [`docs/04-architecture.md`](docs/04-architecture.md) 第四节计费时序。
+93
View File
@@ -0,0 +1,93 @@
from __future__ import annotations
from collections import defaultdict
from apps.ai.aliases import REQUIRED_CAPABILITIES
from apps.ai.models import AiModel, ModelAlias
from apps.ai.providers import AiProviderConfigError, resolve_api_type
from apps.billing.models import PricingRule
PRICING_STATUS_PRICED = "priced"
PRICING_STATUS_UNPRICED = "unpriced"
PUBLIC_DEFAULT_RESOLUTION = "default"
def _has_required_capability(model_alias: ModelAlias) -> bool:
required_capability = REQUIRED_CAPABILITIES.get(model_alias.operation_type)
if required_capability is None:
return False
return required_capability in model_alias.ai_model.capabilities_set()
def _requires_image_input(ai_model: AiModel, operation_type: str) -> bool:
if operation_type != ModelAlias.OperationType.IMAGE:
return False
try:
resolved_api_type = resolve_api_type(ai_model.api_type, ai_model.url)
except AiProviderConfigError:
return False
return resolved_api_type == AiModel.ApiType.IMAGES_EDITS
def _pricing_rule_to_public_price(rule: PricingRule) -> dict:
return {
"resolution": rule.resolution or PUBLIC_DEFAULT_RESOLUTION,
"points_cost": rule.points_cost,
}
def _sort_public_prices(prices: list[dict]) -> list[dict]:
return sorted(
prices,
key=lambda price: (
price["resolution"] != PUBLIC_DEFAULT_RESOLUTION,
price["resolution"],
),
)
def get_public_model_catalog() -> list[dict]:
aliases = [
model_alias
for model_alias in ModelAlias.objects.select_related("ai_model")
.filter(is_active=True, ai_model__is_active=True)
.order_by("operation_type", "alias", "id")
if _has_required_capability(model_alias)
]
if not aliases:
return []
alias_keys = {(item.operation_type, item.alias) for item in aliases}
prices_by_alias = defaultdict(list)
pricing_rules = PricingRule.objects.filter(
is_active=True,
operation_type__in={operation for operation, _alias in alias_keys},
alias__in={alias for _operation, alias in alias_keys},
).order_by("operation_type", "alias", "resolution", "id")
for rule in pricing_rules:
key = (rule.operation_type, rule.alias)
if key in alias_keys:
prices_by_alias[key].append(_pricing_rule_to_public_price(rule))
catalog = []
for model_alias in aliases:
key = (model_alias.operation_type, model_alias.alias)
prices = _sort_public_prices(prices_by_alias.get(key, []))
catalog.append(
{
"alias": model_alias.alias,
"operation_type": model_alias.operation_type,
"capabilities": sorted(model_alias.ai_model.capabilities_set()),
"requires_image": _requires_image_input(
model_alias.ai_model,
model_alias.operation_type,
),
"pricing_status": (
PRICING_STATUS_PRICED if prices else PRICING_STATUS_UNPRICED
),
"prices": prices,
}
)
return catalog
+151 -1
View File
@@ -18,7 +18,8 @@ from rest_framework.test import APIClient
from rest_framework.views import APIView from rest_framework.views import APIView
from apps.api.authentication import ApiKeyAuthentication from apps.api.authentication import ApiKeyAuthentication
from apps.api.views import ExternalApiView from apps.api.throttles import GenerateRateThrottle
from apps.api.views import ExternalApiView, ModelsView
from apps.ai.models import AiModel, ModelAlias from apps.ai.models import AiModel, ModelAlias
from apps.ai.providers import ( from apps.ai.providers import (
AiCapabilityError, AiCapabilityError,
@@ -239,6 +240,155 @@ class BalanceApiTests(TestCase):
self.assertEqual(response.data["error"]["code"], "unauthorized") self.assertEqual(response.data["error"]["code"], "unauthorized")
class ModelsCatalogApiTests(TestCase):
url = "/api/v1/models"
def setUp(self):
cache.clear()
suffix = uuid.uuid4().hex[:8]
self.user = get_user_model().objects.create_user(
username=f"models-user-{suffix}",
email=f"models-user-{suffix}@example.com",
password="password",
)
self.api_key, self.raw_key = ApiKey.create_for_user(self.user, name="models")
self.client = APIClient()
def auth_header(self, raw_key: str | None = None) -> dict:
return {"HTTP_AUTHORIZATION": f"Bearer {raw_key or self.raw_key}"}
def create_alias(
self,
*,
alias: str,
operation_type: str = ModelAlias.OperationType.TITLE,
capabilities: list[str] | None = None,
api_type: str = AiModel.ApiType.CHAT,
url: str = "https://provider-secret.example/v1/chat/completions",
model_sku: str = "secret-sku-gpt-5.5",
model_active: bool = True,
alias_active: bool = True,
) -> ModelAlias:
ai_model = AiModel.objects.create(
name=f"{alias}-{uuid.uuid4().hex[:8]}",
url=url,
model=model_sku,
api_type=api_type,
api_key_encrypted="encrypted-provider-key",
capabilities=capabilities if capabilities is not None else ["text"],
extra_body={"internal": "provider-extra-secret"},
is_active=model_active,
)
return ModelAlias.objects.create(
alias=alias,
operation_type=operation_type,
ai_model=ai_model,
is_active=alias_active,
)
def test_models_returns_public_alias_catalog_without_internal_fields(self):
title_alias = self.create_alias(alias="title-standard", capabilities=["text"])
image_alias = self.create_alias(
alias="image-edit",
operation_type=ModelAlias.OperationType.IMAGE,
capabilities=["image", "vision"],
api_type=AiModel.ApiType.IMAGES_EDITS,
url="https://provider-secret.example/v1/images/edits",
model_sku="secret-sku-image-2",
)
PricingRule.objects.create(
operation_type=title_alias.operation_type,
alias=title_alias.alias,
resolution="",
points_cost=2,
)
PricingRule.objects.create(
operation_type=image_alias.operation_type,
alias=image_alias.alias,
resolution="",
points_cost=10,
)
PricingRule.objects.create(
operation_type=image_alias.operation_type,
alias=image_alias.alias,
resolution="1k",
points_cost=12,
)
response = self.client.get(self.url, **self.auth_header())
self.assertEqual(response.status_code, 200)
self.assertNotIn(GenerateRateThrottle, ModelsView.throttle_classes)
models = {item["alias"]: item for item in response.data["models"]}
self.assertEqual(set(models), {"title-standard", "image-edit"})
self.assertEqual(
set(models["title-standard"]),
{
"alias",
"operation_type",
"capabilities",
"requires_image",
"pricing_status",
"prices",
},
)
self.assertEqual(models["title-standard"]["operation_type"], "title")
self.assertEqual(models["title-standard"]["capabilities"], ["text"])
self.assertFalse(models["title-standard"]["requires_image"])
self.assertEqual(models["title-standard"]["pricing_status"], "priced")
self.assertEqual(
models["title-standard"]["prices"],
[{"resolution": "default", "points_cost": 2}],
)
self.assertEqual(models["image-edit"]["capabilities"], ["image", "vision"])
self.assertTrue(models["image-edit"]["requires_image"])
self.assertEqual(
models["image-edit"]["prices"],
[
{"resolution": "default", "points_cost": 10},
{"resolution": "1K", "points_cost": 12},
],
)
response_body = json.dumps(response.data, ensure_ascii=False)
self.assertNotIn("secret-sku", response_body)
self.assertNotIn("provider-secret.example", response_body)
self.assertNotIn("encrypted-provider-key", response_body)
self.assertNotIn("provider-extra-secret", response_body)
self.assertNotIn("api_key", response_body)
self.assertNotIn("api_key_encrypted", response_body)
self.assertNotIn("extra_body", response_body)
self.assertNotIn("url", response_body)
self.assertNotIn("model_used", response_body)
def test_models_rejects_missing_invalid_and_session_only_authentication(self):
missing = self.client.get(self.url)
invalid = self.client.get(self.url, **self.auth_header("sk_cmhub_invalid"))
self.client.force_login(self.user)
session_only = self.client.get(self.url)
self.assertEqual(missing.status_code, 401)
self.assertEqual(missing.data["error"]["code"], "unauthorized")
self.assertEqual(invalid.status_code, 401)
self.assertEqual(invalid.data["error"]["code"], "unauthorized")
self.assertEqual(session_only.status_code, 401)
self.assertEqual(session_only.data["error"]["code"], "unauthorized")
def test_models_only_lists_callable_active_aliases_and_allows_unpriced_alias(self):
self.create_alias(alias="title-unpriced", capabilities=["text"])
self.create_alias(alias="title-inactive-alias", alias_active=False)
self.create_alias(alias="title-inactive-model", model_active=False)
self.create_alias(alias="title-wrong-capability", capabilities=["image"])
response = self.client.get(self.url, **self.auth_header())
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data["models"]), 1)
item = response.data["models"][0]
self.assertEqual(item["alias"], "title-unpriced")
self.assertEqual(item["pricing_status"], "unpriced")
self.assertEqual(item["prices"], [])
@override_settings( @override_settings(
PAYMENT_CALLBACK_MODE="mock", PAYMENT_CALLBACK_MODE="mock",
PAYMENT_MOCK_CALLBACK_SECRET="test-payment-callback-secret", PAYMENT_MOCK_CALLBACK_SECRET="test-payment-callback-secret",
+2
View File
@@ -5,6 +5,7 @@ from .views import (
BalanceView, BalanceView,
GenerateImageView, GenerateImageView,
GenerateTitleView, GenerateTitleView,
ModelsView,
RechargeCreateView, RechargeCreateView,
RechargeStatusView, RechargeStatusView,
WechatRechargeCallbackView, WechatRechargeCallbackView,
@@ -12,6 +13,7 @@ from .views import (
urlpatterns = [ urlpatterns = [
path("v1/balance", BalanceView.as_view(), name="api-balance"), path("v1/balance", BalanceView.as_view(), name="api-balance"),
path("v1/models", ModelsView.as_view(), name="api-models"),
path("v1/generate/title", GenerateTitleView.as_view(), name="api-generate-title"), 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", GenerateImageView.as_view(), name="api-generate-image"),
path("v1/recharge/create", RechargeCreateView.as_view(), name="api-recharge-create"), path("v1/recharge/create", RechargeCreateView.as_view(), name="api-recharge-create"),
+9
View File
@@ -25,6 +25,7 @@ from apps.api.serializers import (
RechargeStatusRequestSerializer, RechargeStatusRequestSerializer,
) )
from apps.api.throttles import GenerateRateThrottle, throttle_api_auth_failure from apps.api.throttles import GenerateRateThrottle, throttle_api_auth_failure
from apps.ai.catalog import get_public_model_catalog
from apps.billing.models import RechargeOrder from apps.billing.models import RechargeOrder
from apps.billing.payment_gateways import ( from apps.billing.payment_gateways import (
PaymentOrderCreateError, PaymentOrderCreateError,
@@ -117,6 +118,14 @@ class BalanceView(ExternalApiView):
) )
class ModelsView(ExternalApiView):
def get(self, request):
return Response(
{"models": get_public_model_catalog()},
status=status.HTTP_200_OK,
)
class PortalSessionApiView(APIView): class PortalSessionApiView(APIView):
authentication_classes = (SessionAuthentication,) authentication_classes = (SessionAuthentication,)
permission_classes = (IsAuthenticated,) permission_classes = (IsAuthenticated,)
+1
View File
@@ -86,6 +86,7 @@
<a class="btn btn-sm btn-outline-secondary" href="{% url 'portal-dashboard' %}">控制台</a> <a class="btn btn-sm btn-outline-secondary" href="{% url 'portal-dashboard' %}">控制台</a>
<a class="btn btn-sm btn-primary" href="{% url 'portal-recharge' %}">充值</a> <a class="btn btn-sm btn-primary" href="{% url 'portal-recharge' %}">充值</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'portal-apikeys' %}">API Key</a> <a class="btn btn-sm btn-outline-secondary" href="{% url 'portal-apikeys' %}">API Key</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'portal-models' %}">可用模型</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'portal-recharge-records' %}">充值记录</a> <a class="btn btn-sm btn-outline-secondary" href="{% url 'portal-recharge-records' %}">充值记录</a>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'portal-usage-records' %}">消费记录</a> <a class="btn btn-sm btn-outline-secondary" href="{% url 'portal-usage-records' %}">消费记录</a>
<form method="post" action="{% url 'portal-logout' %}"> <form method="post" action="{% url 'portal-logout' %}">
+73
View File
@@ -0,0 +1,73 @@
{% extends "portal/base.html" %}
{% block title %}可用模型 - cmhub{% endblock %}
{% block content %}
<div class="d-flex align-items-center justify-content-between gap-3 mb-4">
<h1 class="h3 mb-0">可用模型</h1>
<a class="btn btn-sm btn-outline-secondary" href="{% url 'portal-apikeys' %}">API Key</a>
</div>
<div class="cmhub-surface">
{% if models %}
<div class="table-responsive">
<table class="table align-middle mb-0">
<thead>
<tr>
<th scope="col">别名</th>
<th scope="col">类型</th>
<th scope="col">能力</th>
<th scope="col">原图</th>
<th scope="col">价格</th>
</tr>
</thead>
<tbody>
{% for item in models %}
<tr>
<td><code>{{ item.alias }}</code></td>
<td>
{% if item.operation_type == "title" %}
生成标题
{% elif item.operation_type == "image" %}
生成图片
{% else %}
{{ item.operation_type }}
{% endif %}
</td>
<td>
{% for capability in item.capabilities %}
<span class="badge text-bg-light border">{{ capability }}</span>
{% empty %}
<span class="text-muted">无</span>
{% endfor %}
</td>
<td>
{% if item.requires_image %}
<span class="badge text-bg-warning">需要</span>
{% else %}
<span class="badge text-bg-light border">不需要</span>
{% endif %}
</td>
<td>
{% if item.prices %}
<div class="d-flex flex-wrap gap-2">
{% for price in item.prices %}
<span class="badge text-bg-light border">
{% if price.resolution == "default" %}默认{% else %}{{ price.resolution }}{% endif %}:{{ price.points_cost }} 点
</span>
{% endfor %}
</div>
{% else %}
<span class="badge text-bg-secondary">暂未定价</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">暂无可用模型</p>
{% endif %}
</div>
{% endblock %}
+87 -1
View File
@@ -10,7 +10,14 @@ from django.test import Client, TestCase, override_settings
from django.utils import timezone from django.utils import timezone
from rest_framework.test import APIClient from rest_framework.test import APIClient
from apps.billing.models import CallRecord, ExchangeRate, PointsLedger, RechargeOrder from apps.ai.models import AiModel, ModelAlias
from apps.billing.models import (
CallRecord,
ExchangeRate,
PointsLedger,
PricingRule,
RechargeOrder,
)
from apps.users.models import ApiKey, UserWallet from apps.users.models import ApiKey, UserWallet
@@ -77,6 +84,32 @@ class PortalAccountFlowTests(TestCase):
status=status, status=status,
) )
def create_model_alias(
self,
*,
alias: str,
operation_type: str = ModelAlias.OperationType.TITLE,
capabilities: list[str] | None = None,
api_type: str = AiModel.ApiType.CHAT,
url: str = "https://provider-secret.example/v1/chat/completions",
model_sku: str = "secret-sku-gpt-5.5",
) -> ModelAlias:
ai_model = AiModel.objects.create(
name=f"{alias}-{uuid.uuid4().hex[:8]}",
url=url,
model=model_sku,
api_type=api_type,
api_key_encrypted="encrypted-provider-key",
capabilities=capabilities if capabilities is not None else ["text"],
extra_body={"internal": "provider-extra-secret"},
is_active=True,
)
return ModelAlias.objects.create(
alias=alias,
operation_type=operation_type,
ai_model=ai_model,
)
def test_signup_creates_unverified_user_wallet_with_zero_points_and_no_ledger(self): def test_signup_creates_unverified_user_wallet_with_zero_points_and_no_ledger(self):
suffix = uuid.uuid4().hex[:8] suffix = uuid.uuid4().hex[:8]
email = f"signup-{suffix}@example.com" email = f"signup-{suffix}@example.com"
@@ -164,6 +197,59 @@ class PortalAccountFlowTests(TestCase):
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertTrue(response["Location"].startswith("/login?next=")) self.assertTrue(response["Location"].startswith("/login?next="))
def test_models_page_requires_session_login(self):
response = self.client.get("/models")
self.assertEqual(response.status_code, 302)
self.assertTrue(response["Location"].startswith("/login?next="))
def test_models_page_lists_public_aliases_prices_and_unpriced_state(self):
user = self.create_verified_user()
title_alias = self.create_model_alias(alias="title-standard", capabilities=["text"])
image_alias = self.create_model_alias(
alias="image-edit",
operation_type=ModelAlias.OperationType.IMAGE,
capabilities=["image", "vision"],
api_type=AiModel.ApiType.IMAGES_EDITS,
url="https://provider-secret.example/v1/images/edits",
model_sku="secret-sku-image-2",
)
PricingRule.objects.create(
operation_type=title_alias.operation_type,
alias=title_alias.alias,
resolution="",
points_cost=2,
)
PricingRule.objects.create(
operation_type=image_alias.operation_type,
alias=image_alias.alias,
resolution="1k",
points_cost=12,
is_active=False,
)
self.client.force_login(user)
response = self.client.get("/models")
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.context["models"]), 2)
self.assertContains(response, "可用模型")
self.assertContains(response, "title-standard")
self.assertContains(response, "生成标题")
self.assertContains(response, "默认")
self.assertContains(response, "2 点")
self.assertContains(response, "image-edit")
self.assertContains(response, "生成图片")
self.assertContains(response, "需要")
self.assertContains(response, "暂未定价")
self.assertNotContains(response, "secret-sku")
self.assertNotContains(response, "provider-secret.example")
self.assertNotContains(response, "encrypted-provider-key")
self.assertNotContains(response, "provider-extra-secret")
self.assertNotContains(response, "api_key")
self.assertNotContains(response, "api_key_encrypted")
self.assertNotContains(response, "extra_body")
def test_create_api_key_shows_plaintext_once_and_stores_only_hash(self): def test_create_api_key_shows_plaintext_once_and_stores_only_hash(self):
user = self.create_verified_user() user = self.create_verified_user()
self.client.force_login(user) self.client.force_login(user)
+2
View File
@@ -6,6 +6,7 @@ from .views import (
ApiKeyDeleteView, ApiKeyDeleteView,
ApiKeyListCreateView, ApiKeyListCreateView,
DashboardView, DashboardView,
ModelCatalogView,
RechargePageView, RechargePageView,
RechargeRecordListView, RechargeRecordListView,
UsageRecordListView, UsageRecordListView,
@@ -19,6 +20,7 @@ urlpatterns = [
path("dashboard", DashboardView.as_view(), name="portal-dashboard"), path("dashboard", DashboardView.as_view(), name="portal-dashboard"),
path("apikeys", ApiKeyListCreateView.as_view(), name="portal-apikeys"), path("apikeys", ApiKeyListCreateView.as_view(), name="portal-apikeys"),
path("apikeys/<int:pk>/delete", ApiKeyDeleteView.as_view(), name="portal-apikey-delete"), path("apikeys/<int:pk>/delete", ApiKeyDeleteView.as_view(), name="portal-apikey-delete"),
path("models", ModelCatalogView.as_view(), name="portal-models"),
path("recharge", RechargePageView.as_view(), name="portal-recharge"), path("recharge", RechargePageView.as_view(), name="portal-recharge"),
path("records/recharge", RechargeRecordListView.as_view(), name="portal-recharge-records"), path("records/recharge", RechargeRecordListView.as_view(), name="portal-recharge-records"),
path("records/usage", UsageRecordListView.as_view(), name="portal-usage-records"), path("records/usage", UsageRecordListView.as_view(), name="portal-usage-records"),
+10
View File
@@ -7,6 +7,7 @@ from django.urls import reverse, reverse_lazy
from django.views import View from django.views import View
from django.views.generic import FormView, TemplateView from django.views.generic import FormView, TemplateView
from apps.ai.catalog import get_public_model_catalog
from apps.billing.payment_gateways import PaymentOrderCreateError from apps.billing.payment_gateways import PaymentOrderCreateError
from apps.billing.pricing import NoExchangeRateError from apps.billing.pricing import NoExchangeRateError
from apps.billing.models import PointsLedger, RechargeOrder from apps.billing.models import PointsLedger, RechargeOrder
@@ -139,6 +140,15 @@ class ApiKeyDeleteView(LoginRequiredMixin, View):
return redirect("portal-apikeys") return redirect("portal-apikeys")
class ModelCatalogView(LoginRequiredMixin, TemplateView):
template_name = "portal/models.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["models"] = get_public_model_catalog()
return context
class RechargePageView(LoginRequiredMixin, FormView): class RechargePageView(LoginRequiredMixin, FormView):
template_name = "portal/recharge.html" template_name = "portal/recharge.html"
form_class = RechargeCreateForm form_class = RechargeCreateForm
+2 -1
View File
@@ -38,7 +38,7 @@
## 当前阶段 ## 当前阶段
当前项目处于:**Phase 5 后台与发布已完成计划内任务**。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 部署 / 运行文档。下一步是按 `deployment.md` 在 VPS 上配置真实邮件、支付、AI 模型和图片真实耗时验证,或从 Backlog 重新规划后续任务。 当前项目处于:**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「可用别名发现」。当前暂无新的已拆 `TODO` 任务,同时生产侧仍需按 `deployment.md` 配置真实邮件、支付、AI 模型和图片真实耗时验证。
优先路径: 优先路径:
@@ -48,6 +48,7 @@
4. Phase 3:对外 API 与充值 —— T-301 Key 鉴权、T-302 生成接口、T-303 余额查询、T-304 充值回调、T-305 扫码下单与轮询、T-306 安全加固已完成。 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 用户端审核优化已完成。 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 任务已收尾。 6. Phase 5:后台与发布 —— T-401 运营后台完善、T-402 完整验收 MVP、T-403 部署 / 运行文档已完成;计划内 MVP 任务已收尾。
7. Phase 6:增强(MVP 后)—— T-601 可用别名发现已完成,实现 `/api/v1/models` 与 portal 只读「可用模型」页;只展示能力别名、能力、是否需要原图和价格,不解密 provider key、不暴露具体 SKU / URL / key。
## 领取任务规则 ## 领取任务规则
+7
View File
@@ -75,6 +75,12 @@
| T-402 | 完整验收 MVP | T-401, T-505 | `02-requirements.md` 的 P0 验收全部通过(含用户端注册/充值/API Key/记录) | DONE | | T-402 | 完整验收 MVP | T-401, T-505 | `02-requirements.md` 的 P0 验收全部通过(含用户端注册/充值/API Key/记录) | DONE |
| T-403 | 部署 / 运行文档 | T-402 | 新环境可按文档运行;明确图片接口超时配置;上线前必须引用一次真实图片生成耗时来设置 Gunicorn `--timeout`、Nginx `proxy_read_timeout`、客户端 read timeout;若尚无真实耗时,部署文档必须显式标注图片同步风险未退,不得声称已验证;生产必须配置真实 `DJANGO_EMAIL_BACKEND` / `DJANGO_DEFAULT_FROM_EMAIL`,否则 allauth 邮箱验证无法发信、用户无法完成登录;**生产静态文件 serving**:T-505 已把 Bootstrap/qrcode 自托管到 `apps/portal/static/`,但 `settings` 目前只有 `STATIC_URL`,`DEBUG=False` 下 Django 不发静态文件——必须配置 `STATIC_ROOT` + `collectstatic` + WhiteNoise 或 Nginx 托管 `/static/`,否则用户端 CSS 错版、充值二维码 404(P2-1 的国内可用性目标在生产失效);**DRF 限流依赖共享缓存**:多 Gunicorn worker 下默认 `LocMemCache` 是每进程的,会让 `generate`/`api_auth_failure` 限流按 worker 各算一份(实际速率≈worker 数×配置值),部署必须配置 Redis/Memcached 等共享 `CACHES` 后端并在文档说明,否则限流形同虚设 | DONE | | T-403 | 部署 / 运行文档 | T-402 | 新环境可按文档运行;明确图片接口超时配置;上线前必须引用一次真实图片生成耗时来设置 Gunicorn `--timeout`、Nginx `proxy_read_timeout`、客户端 read timeout;若尚无真实耗时,部署文档必须显式标注图片同步风险未退,不得声称已验证;生产必须配置真实 `DJANGO_EMAIL_BACKEND` / `DJANGO_DEFAULT_FROM_EMAIL`,否则 allauth 邮箱验证无法发信、用户无法完成登录;**生产静态文件 serving**:T-505 已把 Bootstrap/qrcode 自托管到 `apps/portal/static/`,但 `settings` 目前只有 `STATIC_URL`,`DEBUG=False` 下 Django 不发静态文件——必须配置 `STATIC_ROOT` + `collectstatic` + WhiteNoise 或 Nginx 托管 `/static/`,否则用户端 CSS 错版、充值二维码 404(P2-1 的国内可用性目标在生产失效);**DRF 限流依赖共享缓存**:多 Gunicorn worker 下默认 `LocMemCache` 是每进程的,会让 `generate`/`api_auth_failure` 限流按 worker 各算一份(实际速率≈worker 数×配置值),部署必须配置 Redis/Memcached 等共享 `CACHES` 后端并在文档说明,否则限流形同虚设 | DONE |
## Phase 6 · 增强(MVP 后)
| ID | 任务 | 依赖 | 验收要点 | 状态 |
| --- | --- | --- | --- | --- |
| T-601 | 可用别名发现(portal 别名页 + `GET /api/v1/models`) | T-202, T-301, T-503 | 让调用方能自助发现「能填哪些 `model` 能力别名」。① 新增 `GET /api/v1/models`(**API Key 鉴权,继承 `ExternalApiView`,只复用认证失败限流;不要占用生成接口的 `GenerateRateThrottle` 额度**),仅返回 `ModelAlias.is_active=True` 且 `ai_model.is_active=True` 的可调用别名;② 响应每项只含对外安全字段:`alias`、`operation_type`、`capabilities`、`requires_image`(按 `api_type`/provider 规则推导,如 `images_edits=true`)、`pricing_status`、`prices[{resolution, points_cost}]`,缺 `PricingRule` 时标「暂未定价」而非报错;③ **实现时不得调用 `AiModel.to_resolved_model()` 或任何会解密 provider key 的路径**,列表接口不依赖 `AI_KEY_ENCRYPTION_KEY`,也不读取/输出 `api_key_encrypted`;④ **不暴露具体 SKU/模型名/url/key/provider 原始配置**,测试必须断言响应不含 `ai_model.model`、`ai_model.url`、`api_key`、`api_key_encrypted`、`extra_body` 等内部字段;⑤ portal 加只读「可用模型」页(session 鉴权),同字段、人类可读、单价以点数展示,并在导航中加入入口;⑥ 含测试:API Key 成功返回结构、缺/无效 Key 拒绝、Web session 不能调用外部 API、portal 未登录跳转、页面不泄露 SKU/URL/Key、缺定价别名显示暂未定价。**标注**:未来接 `account_alias_permission`(见 Backlog)后,本清单改为「只列该用户被授权的别名」,接口与页面均按 `user` 过滤 | DONE |
## 里程碑 ## 里程碑
- M1:Django + admin 骨架可运行、自定义 User 就位(T-003)。 - M1:Django + admin 骨架可运行、自定义 User 就位(T-003)。
@@ -82,6 +88,7 @@
- M3:计费 + 对外接口 + 充值闭环 + 上线前安全加固(T-306)。 - M3:计费 + 对外接口 + 充值闭环 + 上线前安全加固(T-306)。
- M4:用户端(注册/充值/API Key/记录)可用(T-504)。 - M4:用户端(注册/充值/API Key/记录)可用(T-504)。
- M5:运营后台 + MVP 验收 + 可部署(T-403)。 - M5:运营后台 + MVP 验收 + 可部署(T-403)。
- M6:可用别名发现(T-601)。
## 待办池(Backlog) ## 待办池(Backlog)
+33
View File
@@ -23,6 +23,8 @@ T-302 已实现生成接口基线:`POST /api/v1/generate/title` 与 `POST /api
T-303 已实现余额查询基线:`GET /api/v1/balance` 已接入 API Key 鉴权,返回当前 `UserWallet.points_balance`;测试覆盖响应余额与 `PointsLedger.points_delta` 累加值一致的账务场景,并确认 Web session 不能调用该外部接口。 T-303 已实现余额查询基线:`GET /api/v1/balance` 已接入 API Key 鉴权,返回当前 `UserWallet.points_balance`;测试覆盖响应余额与 `PointsLedger.points_delta` 累加值一致的账务场景,并确认 Web session 不能调用该外部接口。
T-601 已实现可用别名发现:`GET /api/v1/models` 已接入 API Key 鉴权,只返回当前 active 且具备对应能力的公开别名、能力、是否需要原图和点数单价;接口不调用 `AiModel.to_resolved_model()`,不解密 provider key,不返回底层 SKU、模型 URL、`api_key_encrypted`、`extra_body` 等内部配置,且成功请求不占用生成接口限流额度。
T-304 已实现充值回调基线:`POST /api/v1/recharge/callback/wechat` 与 `/alipay` 已 `@csrf_exempt`,回调先验签(开发/测试可用明确 HMAC mock,生产 `PAYMENT_CALLBACK_MODE=sdk` 走支付 SDK),再按 `order_no` 锁定 `RechargeOrder` 幂等入账;金额或支付方式不一致不加点,重复回调不重复写充值流水。 T-304 已实现充值回调基线:`POST /api/v1/recharge/callback/wechat` 与 `/alipay` 已 `@csrf_exempt`,回调先验签(开发/测试可用明确 HMAC mock,生产 `PAYMENT_CALLBACK_MODE=sdk` 走支付 SDK),再按 `order_no` 锁定 `RechargeOrder` 幂等入账;金额或支付方式不一致不加点,重复回调不重复写充值流水。
T-305 已实现扫码充值下单与轮询基线:`POST /api/v1/recharge/create` 与 `GET /api/v1/recharge/status` 走用户端 `SessionAuthentication + CSRF`,不接受 API Key;下单创建 pending 订单并锁定汇率/点数,再返回微信 `code_url` 或支付宝 `qr_code`;状态查询只允许订单所属用户访问,并在 pending 时尝试主动查单补入账,查单不可用时保持 pending 等回调。 T-305 已实现扫码充值下单与轮询基线:`POST /api/v1/recharge/create` 与 `GET /api/v1/recharge/status` 走用户端 `SessionAuthentication + CSRF`,不接受 API Key;下单创建 pending 订单并锁定汇率/点数,再返回微信 `code_url` 或支付宝 `qr_code`;状态查询只允许订单所属用户访问,并在 pending 时尝试主动查单补入账,查单不可用时保持 pending 等回调。
@@ -140,6 +142,37 @@ T-504/T-505 已实现用户端充值页基线:`/recharge` 走 Django session +
} }
``` ```
### `GET /api/v1/models`
查询当前可调用的能力别名。成功响应:
```json
{
"models": [
{
"alias": "title-standard",
"operation_type": "title",
"capabilities": ["text"],
"requires_image": false,
"pricing_status": "priced",
"prices": [
{"resolution": "default", "points_cost": 2}
]
},
{
"alias": "image-edit",
"operation_type": "image",
"capabilities": ["image", "vision"],
"requires_image": true,
"pricing_status": "unpriced",
"prices": []
}
]
}
```
要点:只列 `ModelAlias.is_active=True` 且 `ai_model.is_active=True`,并按操作类型校验模型声明能力。缺定价规则不报错,`pricing_status="unpriced"` 且 `prices=[]`,用户端页面显示「暂未定价」。该接口返回的是公开能力别名,不返回底层供应商模型名、模型 URL、provider key、`api_key_encrypted`、`extra_body` 或其他原始配置;实现不得依赖 `AI_KEY_ENCRYPTION_KEY`。未来接入 `account_alias_permission` 后,本接口应只列当前 API Key 所属用户被授权的别名。
## 计费模块合约(`apps.billing`) ## 计费模块合约(`apps.billing`)
T-202 后,计费计算已有独立模块;T-203 后,扣点/退点也收敛到 billing 层,供后续生成接口和充值下单调用: T-202 后,计费计算已有独立模块;T-203 后,扣点/退点也收敛到 billing 层,供后续生成接口和充值下单调用:
+13 -10
View File
File diff suppressed because one or more lines are too long
+3
View File
@@ -13,8 +13,10 @@
| `/records/recharge` | GET | 充值记录 | session | | `/records/recharge` | GET | 充值记录 | session |
| `/records/usage` | GET | 点数使用(消费/调用)记录 | session | | `/records/usage` | GET | 点数使用(消费/调用)记录 | session |
| `/apikeys` | GET/POST | API Key 管理:列表 / 生成 / 删除(删除即吊销,明文只显示一次) | session | | `/apikeys` | GET/POST | API Key 管理:列表 / 生成 / 删除(删除即吊销,明文只显示一次) | session |
| `/models` | GET | 可用模型:只读展示可调用能力别名、能力、是否需要原图和点数单价 | session |
T-501 已落地 `/signup`、`/login`、`/logout` 与最小 `/dashboard`。T-502 已落地 `/apikeys`:登录用户只能管理自己的 Key,生成后明文只显示一次,列表只显示 prefix,删除为吊销 `revoked`。T-503/T-505 已扩展 `/dashboard` 为个人中心汇总,并落地 `/records/recharge` 与 `/records/usage`:充值总额按已支付订单统计,入账 / 消费 / 退款点数按 `PointsLedger` 统计,记录页只查询当前登录用户数据并分页展示。T-504/T-505 已落地 `/recharge`:登录用户可选择金额和支付方式创建 pending 充值订单,页面用本地 static 自托管 qrcode.js 展示二维码票据并轮询 `/api/v1/recharge/status`,到账后刷新余额。 T-501 已落地 `/signup`、`/login`、`/logout` 与最小 `/dashboard`。T-502 已落地 `/apikeys`:登录用户只能管理自己的 Key,生成后明文只显示一次,列表只显示 prefix,删除为吊销 `revoked`。T-503/T-505 已扩展 `/dashboard` 为个人中心汇总,并落地 `/records/recharge` 与 `/records/usage`:充值总额按已支付订单统计,入账 / 消费 / 退款点数按 `PointsLedger` 统计,记录页只查询当前登录用户数据并分页展示。T-504/T-505 已落地 `/recharge`:登录用户可选择金额和支付方式创建 pending 充值订单,页面用本地 static 自托管 qrcode.js 展示二维码票据并轮询 `/api/v1/recharge/status`,到账后刷新余额。
T-601 已落地 `/models`:登录用户可查看当前公开可调用别名、能力、是否需要原图和点数单价;页面不展示底层 SKU、模型 URL、provider key、`api_key_encrypted` 或 `extra_body`。
## API 路由(对外,DRF) ## API 路由(对外,DRF)
@@ -23,6 +25,7 @@ T-501 已落地 `/signup`、`/login`、`/logout` 与最小 `/dashboard`。T-502
| `/api/v1/generate/title` | POST | 生成标题 | API Key | | `/api/v1/generate/title` | POST | 生成标题 | API Key |
| `/api/v1/generate/image` | POST | 生成图片(同步) | API Key | | `/api/v1/generate/image` | POST | 生成图片(同步) | API Key |
| `/api/v1/balance` | GET | 查询点数余额 | API Key | | `/api/v1/balance` | GET | 查询点数余额 | API Key |
| `/api/v1/models` | GET | 查询可调用能力别名、能力和点数单价 | API Key |
| `/api/v1/recharge/create` | POST | 用户端发起充值(weixin/alipay),下单取二维码 | Session(用户端) | | `/api/v1/recharge/create` | POST | 用户端发起充值(weixin/alipay),下单取二维码 | Session(用户端) |
| `/api/v1/recharge/status` | GET | 轮询订单状态(前端每秒) | Session(用户端) | | `/api/v1/recharge/status` | GET | 轮询订单状态(前端每秒) | Session(用户端) |
| `/api/v1/recharge/callback/wechat` | POST | 微信 V3 异步回调 | 验签(`@csrf_exempt`) | | `/api/v1/recharge/callback/wechat` | POST | 微信 V3 异步回调 | 验签(`@csrf_exempt`) |
+46
View File
@@ -968,3 +968,49 @@
- 已知限制:真实 AI 上游和真实图片耗时仍未验证,因当前环境未配置 `AI_KEY_ENCRYPTION_KEY` 且数据库无真实 AiModel/ModelAlias;真实微信/支付宝商户配置和 SDK 仍待生产环境提供。`deployment.md` 已明确未跑通真实图片 smoke 前不能声称生产图片链路已验证。 - 已知限制:真实 AI 上游和真实图片耗时仍未验证,因当前环境未配置 `AI_KEY_ENCRYPTION_KEY` 且数据库无真实 AiModel/ModelAlias;真实微信/支付宝商户配置和 SDK 仍待生产环境提供。`deployment.md` 已明确未跑通真实图片 smoke 前不能声称生产图片链路已验证。
- 决策:生产限流共享 cache 的 MVP 推荐路径先用 Django `DatabaseCache` + MySQL cache 表,不引入 Redis 作为当前代码依赖;高并发后可切换 Redis/Memcached 并安装对应 backend。生产静态资源采用 `STATIC_ROOT + collectstatic + Nginx/宝塔托管`,不在应用内引入 WhiteNoise。 - 决策:生产限流共享 cache 的 MVP 推荐路径先用 Django `DatabaseCache` + MySQL cache 表,不引入 Redis 作为当前代码依赖;高并发后可切换 Redis/Memcached 并安装对应 backend。生产静态资源采用 `STATIC_ROOT + collectstatic + Nginx/宝塔托管`,不在应用内引入 WhiteNoise。
- 下一步:计划内 MVP 任务已完成;进入 VPS 实际部署、真实邮件/支付/AI 配置和真实图片耗时验证,或从 Backlog 重新拆后续任务。 - 下一步:计划内 MVP 任务已完成;进入 VPS 实际部署、真实邮件/支付/AI 配置和真实图片耗时验证,或从 Backlog 重新拆后续任务。
## 2026-07-04 Phase 6 T-601 可行性复核与文档口径修正(非任务)
- 状态:DONE
- 变更:复核 Claude Code 新增的 Phase 6 / T-601「可用别名发现」后,更新 `docs/06-tasks.md`、`docs/00-ai-start-here.md`、`docs/current-state.md`、`README.md`。T-601 路由统一为 `GET /api/v1/models`;验收要点收紧为只列 `ModelAlias.is_active=True` 且 `ai_model.is_active=True` 的别名;实现不得调用 `AiModel.to_resolved_model()` 或任何会解密 provider key 的路径;响应与 portal 页面不得暴露具体 SKU、模型 URL、provider key、`api_key_encrypted`、`extra_body` 等内部字段;模型列表成功请求不占用生成接口 `GenerateRateThrottle` 额度,只复用认证失败限流。
- 验证:文档修改;未运行代码测试。
- 决策:T-601 可行,属于 MVP 后低风险只读增强,不新增数据表;当前下一个可领取任务改为 T-601。真实邮件、支付、AI 模型和图片真实耗时验证仍是生产配置待办,不因 T-601 取消。
- 下一步:领取 T-601 可用别名发现。
## 2026-07-04 T-601 可用别名发现
- 状态:DONE
- 变更:
- 新增 `apps/ai/catalog.py`:集中生成公开模型目录,只读取 `ModelAlias`、`AiModel` 的公开能力字段和 `PricingRule`,只列 active 且具备对应能力的别名;按 `api_type` / URL 推导 `images_edits` 是否需要原图;缺定价返回 `pricing_status=unpriced` 与空价格列表。
- 新增 `GET /api/v1/models`:继承 `ExternalApiView`,使用 API Key 鉴权;不挂 `GenerateRateThrottle`,成功查询不占用生成接口额度;响应只含 `alias`、`operation_type`、`capabilities`、`requires_image`、`pricing_status`、`prices`。
- 新增 portal `/models` 只读「可用模型」页,并在导航加入入口;页面显示别名、操作类型、能力、是否需要原图和点数单价,缺定价显示「暂未定价」。
- 扩展 `apps/api/tests.py` 与 `apps/portal/tests.py`:覆盖 API Key 成功结构、缺失/无效 Key 拒绝、Web session 不能调用外部 API、portal 未登录跳转、页面不泄露底层 SKU / URL / key / `extra_body`、缺定价别名显示暂未定价。
- 同步更新 `docs/api.md`、`docs/routes.md`、`docs/06-tasks.md`、`docs/current-state.md`、`docs/00-ai-start-here.md` 与 `README.md`;T-601 标记 DONE,当前暂无新的已拆 `TODO`。
- 验证:
- `.\init.ps1`:通过;Python 3.12.3,依赖均已满足,`manage.py check` 0 issues,并打印启动命令 `py -3.12 manage.py runserver`。
- `py -3.12 -m py_compile apps\ai\catalog.py apps\api\views.py apps\api\urls.py apps\api\tests.py apps\portal\views.py apps\portal\urls.py apps\portal\tests.py`:通过。
- `py -3.12 manage.py check`:通过,0 issues。
- `py -3.12 manage.py makemigrations --check --dry-run`:通过,No changes detected。
- `git diff --check`:通过,仅 Windows CRLF 提示。
- `py -3.12 manage.py test apps.api.tests.ModelsCatalogApiTests apps.portal.tests.PortalAccountFlowTests.test_models_page_requires_session_login apps.portal.tests.PortalAccountFlowTests.test_models_page_lists_public_aliases_prices_and_unpriced_state --keepdb --noinput --verbosity 2`:5 条目标用例中 3 条 API 测试已通过;portal 测试未跑到断言,因远程 MySQL 连接 `43.128.3.240` 超时中断。
- `Test-NetConnection 43.128.3.240 -Port 3306`:`TcpTestSucceeded=True`。
- `py -3.12 manage.py test apps.portal.tests.PortalAccountFlowTests.test_models_page_requires_session_login apps.portal.tests.PortalAccountFlowTests.test_models_page_lists_public_aliases_prices_and_unpriced_state --keepdb --noinput --verbosity 2`:通过,2 tests OK。
- 阻塞:T-601 功能无阻塞;完整套件未单次重跑,远程 MySQL 仍存在间歇超时风险。测试/迁移阶段仍有 allauth `account.EmailAddress` 条件唯一约束在 MySQL 上不可创建的 `models.W036` 既有警告。
- 决策:模型目录接口是只读发现能力,不调用 `AiModel.to_resolved_model()`,不依赖 `AI_KEY_ENCRYPTION_KEY`,不解密或输出 provider key;未来接 `account_alias_permission` 时在同一 catalog 查询层按用户过滤。
- 下一步:暂无已拆 `TODO`;建议先处理真实邮件、支付、AI 模型导入与真实图片耗时验证,或从 Backlog 拆新任务。
## 2026-07-04 T-601 复核(Claude Code review,非任务)
- 状态:DONE(复核完成,结论:达标,质量高)
- 对象:codex 完成的 T-601「可用别名发现」(`GET /api/v1/models` + portal 只读「可用模型」页)。
- 逐条核对全过:
- ① `ModelsView(ExternalApiView)` API Key 鉴权,未挂 `GenerateRateThrottle`(不占用生成限流额度,仅认证失败限流),`filter(is_active=True, ai_model__is_active=True)`。
- ② 响应字段 alias/operation_type/capabilities/requires_image/pricing_status/prices[{resolution,points_cost}];缺价 → pricing_status=unpriced + prices=[],不报错,有专测。
- ③ **不走解密路径**:`apps/ai/catalog.py` 只读 capabilities/api_type/url(url 仅用于 `resolve_api_type` 推导 requires_image,不输出),全项目 `to_resolved_model()` 仅出现在 generation.py;清单不依赖 `AI_KEY_ENCRYPTION_KEY`、不碰 `api_key_encrypted`。
- ④ 不暴露 SKU/url/key/extra_body:测试用哨兵值(secret-sku / provider-secret.example / encrypted-provider-key / provider-extra-secret)断言响应 JSON 一个都搜不到,另断言字段名 api_key/api_key_encrypted/extra_body/url/model_used 缺席——比「断言不含字段」更强,能抓从别路径的泄漏。
- ⑤ portal `ModelCatalogView`(session)+ models.html(单价「X 点」/暂未定价/需原图)+ base.html「可用模型」导航入口。
- ⑥ 测试:API Key 成功结构、缺/无效/仅 session 拒绝、portal 未登录跳转、portal 页不泄露内部字段、unpriced 显示暂未定价——均覆盖。
- 加分:catalog 额外做能力校验过滤(别名指向模型无对应能力则不列,与 resolve_alias 口径一致);查询无 N+1(select_related + 批量 PricingRule);api.md 已同步 /v1/models 契约(补上上轮登记的缺口)。
- 小点(不用改):`prices` 按分辨率字符串排序,`512` 排在 `4K` 之后,纯展示层顺序,无功能影响。
- 保留:本轮完整套件未单次全绿(远程 MySQL 间歇超时),T-601 相关测试本身通过,非断言失败。
- 结论:达标,无 P1/P2,不需要修补任务。