diff --git a/README.md b/README.md index ef9efcf..467d81f 100644 --- a/README.md +++ b/README.md @@ -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) 第四节计费时序。 diff --git a/apps/ai/catalog.py b/apps/ai/catalog.py new file mode 100644 index 0000000..af19203 --- /dev/null +++ b/apps/ai/catalog.py @@ -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 diff --git a/apps/api/tests.py b/apps/api/tests.py index d4bfcbe..5c0d619 100644 --- a/apps/api/tests.py +++ b/apps/api/tests.py @@ -18,7 +18,8 @@ from rest_framework.test import APIClient from rest_framework.views import APIView 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.providers import ( AiCapabilityError, @@ -239,6 +240,155 @@ class BalanceApiTests(TestCase): 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( PAYMENT_CALLBACK_MODE="mock", PAYMENT_MOCK_CALLBACK_SECRET="test-payment-callback-secret", diff --git a/apps/api/urls.py b/apps/api/urls.py index a7288d1..7d985cf 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -5,6 +5,7 @@ from .views import ( BalanceView, GenerateImageView, GenerateTitleView, + ModelsView, RechargeCreateView, RechargeStatusView, WechatRechargeCallbackView, @@ -12,6 +13,7 @@ from .views import ( urlpatterns = [ 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/image", GenerateImageView.as_view(), name="api-generate-image"), path("v1/recharge/create", RechargeCreateView.as_view(), name="api-recharge-create"), diff --git a/apps/api/views.py b/apps/api/views.py index b913677..bad60cd 100644 --- a/apps/api/views.py +++ b/apps/api/views.py @@ -25,6 +25,7 @@ from apps.api.serializers import ( RechargeStatusRequestSerializer, ) 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.payment_gateways import ( 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): authentication_classes = (SessionAuthentication,) permission_classes = (IsAuthenticated,) diff --git a/apps/portal/templates/portal/base.html b/apps/portal/templates/portal/base.html index 7872dce..01130e3 100644 --- a/apps/portal/templates/portal/base.html +++ b/apps/portal/templates/portal/base.html @@ -86,6 +86,7 @@ 控制台 充值 API Key + 可用模型 充值记录 消费记录
diff --git a/apps/portal/templates/portal/models.html b/apps/portal/templates/portal/models.html new file mode 100644 index 0000000..3fcbe0a --- /dev/null +++ b/apps/portal/templates/portal/models.html @@ -0,0 +1,73 @@ +{% extends "portal/base.html" %} + +{% block title %}可用模型 - cmhub{% endblock %} + +{% block content %} +
+

可用模型

+ API Key +
+ +
+ {% if models %} +
+ + + + + + + + + + + + {% for item in models %} + + + + + + + + {% endfor %} + +
别名类型能力原图价格
{{ item.alias }} + {% if item.operation_type == "title" %} + 生成标题 + {% elif item.operation_type == "image" %} + 生成图片 + {% else %} + {{ item.operation_type }} + {% endif %} + + {% for capability in item.capabilities %} + {{ capability }} + {% empty %} + 无 + {% endfor %} + + {% if item.requires_image %} + 需要 + {% else %} + 不需要 + {% endif %} + + {% if item.prices %} +
+ {% for price in item.prices %} + + {% if price.resolution == "default" %}默认{% else %}{{ price.resolution }}{% endif %}:{{ price.points_cost }} 点 + + {% endfor %} +
+ {% else %} + 暂未定价 + {% endif %} +
+
+ {% else %} +

暂无可用模型

+ {% endif %} +
+{% endblock %} diff --git a/apps/portal/tests.py b/apps/portal/tests.py index fcb2b58..d0d2843 100644 --- a/apps/portal/tests.py +++ b/apps/portal/tests.py @@ -10,7 +10,14 @@ from django.test import Client, TestCase, override_settings from django.utils import timezone 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 @@ -77,6 +84,32 @@ class PortalAccountFlowTests(TestCase): 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): suffix = uuid.uuid4().hex[:8] email = f"signup-{suffix}@example.com" @@ -164,6 +197,59 @@ class PortalAccountFlowTests(TestCase): self.assertEqual(response.status_code, 302) 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): user = self.create_verified_user() self.client.force_login(user) diff --git a/apps/portal/urls.py b/apps/portal/urls.py index 16b0d53..d347caa 100644 --- a/apps/portal/urls.py +++ b/apps/portal/urls.py @@ -6,6 +6,7 @@ from .views import ( ApiKeyDeleteView, ApiKeyListCreateView, DashboardView, + ModelCatalogView, RechargePageView, RechargeRecordListView, UsageRecordListView, @@ -19,6 +20,7 @@ urlpatterns = [ path("dashboard", DashboardView.as_view(), name="portal-dashboard"), path("apikeys", ApiKeyListCreateView.as_view(), name="portal-apikeys"), path("apikeys//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("records/recharge", RechargeRecordListView.as_view(), name="portal-recharge-records"), path("records/usage", UsageRecordListView.as_view(), name="portal-usage-records"), diff --git a/apps/portal/views.py b/apps/portal/views.py index ab56f78..694d109 100644 --- a/apps/portal/views.py +++ b/apps/portal/views.py @@ -7,6 +7,7 @@ from django.urls import reverse, reverse_lazy from django.views import View 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.pricing import NoExchangeRateError from apps.billing.models import PointsLedger, RechargeOrder @@ -139,6 +140,15 @@ class ApiKeyDeleteView(LoginRequiredMixin, View): 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): template_name = "portal/recharge.html" form_class = RechargeCreateForm diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 4e8b642..95bf258 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -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 安全加固已完成。 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 只读「可用模型」页;只展示能力别名、能力、是否需要原图和价格,不解密 provider key、不暴露具体 SKU / URL / key。 ## 领取任务规则 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index fa0b86e..a0470f2 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -75,6 +75,12 @@ | 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 | +## 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)。 @@ -82,6 +88,7 @@ - M3:计费 + 对外接口 + 充值闭环 + 上线前安全加固(T-306)。 - M4:用户端(注册/充值/API Key/记录)可用(T-504)。 - M5:运营后台 + MVP 验收 + 可部署(T-403)。 +- M6:可用别名发现(T-601)。 ## 待办池(Backlog) diff --git a/docs/api.md b/docs/api.md index b83725f..397af72 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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-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-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`) T-202 后,计费计算已有独立模块;T-203 后,扣点/退点也收敛到 billing 层,供后续生成接口和充值下单调用: diff --git a/docs/current-state.md b/docs/current-state.md index cccd693..5846035 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -11,11 +11,11 @@ ## 当前快照 -- 日期:2026-07-03 -- 阶段:Phase 5 后台与发布;Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化已完成,Phase 5 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档已完成;计划内 MVP 任务暂无下一个 `TODO` +- 日期:2026-07-04 +- 阶段:Phase 6 增强(MVP 后);Phase 3 对外 API 与充值已完成到 T-306,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` 任务 - 技术栈:系统 Python 3.12.3 + Django 5.2.15 + DRF 3.16.1 + django-allauth 65.18.0 + 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`;用户端已用 Django 模板 SSR + Bootstrap + allauth 落地注册登录;生产部署口径为 VPS / 宝塔 + Nginx + Gunicorn(gthread) + systemd;详见 `03-tech-stack.md` 与 `deployment.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-302 已新增生成接口编排、序列化器、图片本地存储和 `/api/v1/generate/title|image` 路由;T-303 已新增 `apps.billing.services.get_balance_snapshot()` 与 `/api/v1/balance` 余额查询接口;T-304 已新增 `RechargeOrder`、充值回调验签适配器、幂等入账服务、微信/支付宝回调路由与迁移 `billing.0004_rechargeorder_and_more`;T-305 已新增 `create_recharge_order()`、微信/支付宝扫码下单 mock/SDK 入口、`/api/v1/recharge/create` 与 `/api/v1/recharge/status`;T-306 已新增 `apps.api.throttles`、`apps.api.exceptions`、`REST_FRAMEWORK` 安全默认认证、生成/认证失败限流、`image_url` SSRF 防护与响应大小上限、充值单笔金额上限;T-501 已接入 allauth,新增 portal 路由、注册适配器、登录/注册/登出模板和最小 dashboard,注册成功创建 0 点钱包且不写赠点流水;T-502 已新增 `/apikeys`、API Key 创建表单、列表页和删除(吊销)动作,生成后明文只显示一次,列表只显示 prefix;T-503 已扩展 `/dashboard` 为个人中心汇总,并新增 `/records/recharge` 充值记录与 `/records/usage` 消费记录,只读展示当前用户数据;T-504 已新增 `/recharge` 页面、`RechargeCreateForm`、充值导航入口和轮询脚本,页面创建 pending 订单、展示二维码票据、轮询 `/api/v1/recharge/status`,订单 paid 后刷新余额;T-505 已把 Bootstrap 5 CSS 与 qrcode.js vendoring 到 `apps/portal/static/portal/vendor/`,页面不再依赖 jsdelivr,并把充值记录 / 消费记录改为 Django `Paginator` 分页;T-401 已新增 `adjust_wallet_points()` 手工调点服务、钱包 admin 专用调点表单与模板,后台可管理/检索用户、钱包、API Key(脱敏)、计费规则、汇率、充值订单、点数流水和调用记录,流水/订单/调用记录保持只读;T-402 已新增 `docs/mvp-acceptance.md`,按 P0 验收矩阵记录 MVP 完整验收结论、测试证据和已知限制;T-403 已新增 `docs/deployment.md` 与 `requirements-production.txt`,并在 settings 中补齐 `STATIC_ROOT`、`CSRF_TRUSTED_ORIGINS`、共享 `CACHES`、HTTPS cookie、proxy SSL 与 HSTS 环境变量支持 -- 测试:T-403 已验证:`./init.ps1` 开工前通过;`py -3.12 manage.py check` 通过;`py -3.12 manage.py makemigrations --check --dry-run` 无变化;`py -3.12 manage.py findstatic portal/vendor/bootstrap/bootstrap.min.css portal/vendor/qrcode/qrcode.js --verbosity 1` 找到两个本地 static 文件;`py -3.12 manage.py check --deploy` 在当前开发 `.env` 下仅报预期安全配置警告,临时注入生产型安全环境变量(含 HSTS includeSubDomains/preload)后通过,0 issues;`py -3.12 manage.py collectstatic --dry-run --noinput` 通过,预期收集 169 个 static 文件;`py -3.12 manage.py createcachetable --dry-run cmhub_cache` 通过,输出 MySQL cache 表 DDL;`py -3.12 -m compileall config` 通过;尝试 `py -3.12 manage.py test apps.api --noinput --keepdb --verbosity 2` 时 25 条用例已通过,`GenerateApiTests` 14 条因远程 MySQL 连接重置/事务中断被记 ERROR,随后 `Test-NetConnection 43.128.3.240 -Port 3306` 端口可达,单独重跑 `py -3.12 manage.py test apps.api.tests.GenerateApiTests --noinput --keepdb --verbosity 2` 通过,14 tests OK。测试/迁移阶段仍有 allauth `account.EmailAddress` 条件唯一约束在 MySQL 上不可创建的 `models.W036` 警告;本项目用户账本邮箱唯一性由 `user.email` 唯一约束承担。 +- 生产代码:已有最小 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-303 已新增 `apps.billing.services.get_balance_snapshot()` 与 `/api/v1/balance` 余额查询接口;T-304 已新增 `RechargeOrder`、充值回调验签适配器、幂等入账服务、微信/支付宝回调路由与迁移 `billing.0004_rechargeorder_and_more`;T-305 已新增 `create_recharge_order()`、微信/支付宝扫码下单 mock/SDK 入口、`/api/v1/recharge/create` 与 `/api/v1/recharge/status`;T-306 已新增 `apps.api.throttles`、`apps.api.exceptions`、`REST_FRAMEWORK` 安全默认认证、生成/认证失败限流、`image_url` SSRF 防护与响应大小上限、充值单笔金额上限;T-501 已接入 allauth,新增 portal 路由、注册适配器、登录/注册/登出模板和最小 dashboard,注册成功创建 0 点钱包且不写赠点流水;T-502 已新增 `/apikeys`、API Key 创建表单、列表页和删除(吊销)动作,生成后明文只显示一次,列表只显示 prefix;T-503 已扩展 `/dashboard` 为个人中心汇总,并新增 `/records/recharge` 充值记录与 `/records/usage` 消费记录,只读展示当前用户数据;T-504 已新增 `/recharge` 页面、`RechargeCreateForm`、充值导航入口和轮询脚本,页面创建 pending 订单、展示二维码票据、轮询 `/api/v1/recharge/status`,订单 paid 后刷新余额;T-505 已把 Bootstrap 5 CSS 与 qrcode.js vendoring 到 `apps/portal/static/portal/vendor/`,页面不再依赖 jsdelivr,并把充值记录 / 消费记录改为 Django `Paginator` 分页;T-401 已新增 `adjust_wallet_points()` 手工调点服务、钱包 admin 专用调点表单与模板,后台可管理/检索用户、钱包、API Key(脱敏)、计费规则、汇率、充值订单、点数流水和调用记录,流水/订单/调用记录保持只读;T-402 已新增 `docs/mvp-acceptance.md`,按 P0 验收矩阵记录 MVP 完整验收结论、测试证据和已知限制;T-403 已新增 `docs/deployment.md` 与 `requirements-production.txt`,并在 settings 中补齐 `STATIC_ROOT`、`CSRF_TRUSTED_ORIGINS`、共享 `CACHES`、HTTPS cookie、proxy SSL 与 HSTS 环境变量支持;T-601 已新增 `apps.ai.catalog.get_public_model_catalog()`、`GET /api/v1/models` 与 portal `/models` 只读页面,只展示 active 可调用别名、能力、是否需要原图和点数单价,不解密 provider key,不暴露底层 SKU / URL / key / `extra_body` +- 测试:T-601 已验证:`.\init.ps1` 通过(Python 3.12.3,依赖已满足,`manage.py check` 0 issues,打印启动命令);`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` 通过;`py -3.12 manage.py makemigrations --check --dry-run` 无变化;`git diff --check` 通过;`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` 首次运行时 3 条 API 测试已通过,portal `setUpClass` 阶段因远程 MySQL 连接 43.128.3.240 超时中断;随后 `Test-NetConnection 43.128.3.240 -Port 3306` 显示 `TcpTestSucceeded=True`,单独重跑两条 portal 测试通过,2 tests OK。T-403 已验证:`./init.ps1` 开工前通过;`py -3.12 manage.py check` 通过;`py -3.12 manage.py makemigrations --check --dry-run` 无变化;`py -3.12 manage.py findstatic portal/vendor/bootstrap/bootstrap.min.css portal/vendor/qrcode/qrcode.js --verbosity 1` 找到两个本地 static 文件;`py -3.12 manage.py check --deploy` 在当前开发 `.env` 下仅报预期安全配置警告,临时注入生产型安全环境变量(含 HSTS includeSubDomains/preload)后通过,0 issues;`py -3.12 manage.py collectstatic --dry-run --noinput` 通过,预期收集 169 个 static 文件;`py -3.12 manage.py createcachetable --dry-run cmhub_cache` 通过,输出 MySQL cache 表 DDL;`py -3.12 -m compileall config` 通过;尝试 `py -3.12 manage.py test apps.api --noinput --keepdb --verbosity 2` 时 25 条用例已通过,`GenerateApiTests` 14 条因远程 MySQL 连接重置/事务中断被记 ERROR,随后 `Test-NetConnection 43.128.3.240 -Port 3306` 端口可达,单独重跑 `py -3.12 manage.py test apps.api.tests.GenerateApiTests --noinput --keepdb --verbosity 2` 通过,14 tests OK。测试/迁移阶段仍有 allauth `account.EmailAddress` 条件唯一约束在 MySQL 上不可创建的 `models.W036` 警告;本项目用户账本邮箱唯一性由 `user.email` 唯一约束承担。 - 数据: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` @@ -33,7 +33,7 @@ | `init.sh` / `init.ps1` | 已有 | 启动验证入口,已固定系统 Python 3.12 命令,并校验解释器版本 `>=3.12,<3.14` | | `requirements.txt` / `requirements-production.txt` / `pyproject.toml` | 已有 | `requirements.txt` 管本地/基础运行依赖;`requirements-production.txt` 追加 Linux 生产 Gunicorn;`pyproject.toml` 落地 `requires-python`;T-101 新增 `requests`;T-102 使用既有 `cryptography` 做 Fernet 加密;T-501 新增 `django-allauth` | | `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/T-303/T-304/T-305 已在 `apps/billing/services.py` 落扣点/退点、余额快照、充值入账与充值下单;T-401 已在 `apps/billing/services.py` 落 `adjust_wallet_points()`,在 `apps/users/admin.py` 与 `apps/users/templates/admin/users/userwallet/adjust_points.html` 落钱包手工调点入口;T-304/T-305 已在 `apps/billing/payment_gateways.py` 落回调验签、mock 下单与 SDK 入口;T-301~T-306 已在 api 落鉴权、生成接口编排、序列化器、图片存储、余额查询、充值回调、充值下单/状态查询、`image_url` SSRF 防护、生成/认证限流与统一 429 错误响应;T-501 已在 portal 落 allauth 注册/登录/登出路由、模板、adapter 与 dashboard;T-502 已在 portal 落 `/apikeys`、API Key 创建表单、列表模板与删除(吊销)动作;T-503 已在 portal 落个人中心汇总、充值记录和消费记录页;T-504 已在 portal 落 `/recharge` 充值页、充值表单、二维码票据展示和状态轮询;T-505 已在 portal 落本地 vendor static 与记录分页 | +| `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/T-303/T-304/T-305 已在 `apps/billing/services.py` 落扣点/退点、余额快照、充值入账与充值下单;T-401 已在 `apps/billing/services.py` 落 `adjust_wallet_points()`,在 `apps/users/admin.py` 与 `apps/users/templates/admin/users/userwallet/adjust_points.html` 落钱包手工调点入口;T-304/T-305 已在 `apps/billing/payment_gateways.py` 落回调验签、mock 下单与 SDK 入口;T-301~T-306 已在 api 落鉴权、生成接口编排、序列化器、图片存储、余额查询、充值回调、充值下单/状态查询、`image_url` SSRF 防护、生成/认证限流与统一 429 错误响应;T-501 已在 portal 落 allauth 注册/登录/登出路由、模板、adapter 与 dashboard;T-502 已在 portal 落 `/apikeys`、API Key 创建表单、列表模板与删除(吊销)动作;T-503 已在 portal 落个人中心汇总、充值记录和消费记录页;T-504 已在 portal 落 `/recharge` 充值页、充值表单、二维码票据展示和状态轮询;T-505 已在 portal 落本地 vendor static 与记录分页;T-601 已在 `apps/ai/catalog.py` 落公开模型目录,在 api 落 `/api/v1/models`,在 portal 落 `/models` 页面和导航入口 | | `manage.py` | 已有 | T-001 创建 | | `tests/` | 待建 | 随各任务补充 | @@ -41,10 +41,11 @@ 任务状态以 [`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-302 生成标题 / 图片接口;T-303 余额查询接口;T-304 充值回调;T-305 扫码充值下单 + 轮询;T-306 Phase 3 对外 API 安全加固;T-501 注册 / 登录(allauth);T-502 API Key 自助管理页;T-503 个人中心 / 记录页;T-504 充值页(扫码 + 轮询到账);T-505 Phase 4 用户端审核优化;T-401 运营后台完善;T-402 完整验收 MVP;T-403 部署 / 运行文档。 +- 已完成: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 生成标题 / 图片接口;T-303 余额查询接口;T-304 充值回调;T-305 扫码充值下单 + 轮询;T-306 Phase 3 对外 API 安全加固;T-501 注册 / 登录(allauth);T-502 API Key 自助管理页;T-503 个人中心 / 记录页;T-504 充值页(扫码 + 轮询到账);T-505 Phase 4 用户端审核优化;T-401 运营后台完善;T-402 完整验收 MVP;T-403 部署 / 运行文档;T-601 可用别名发现。 - 正在进行:无。 +- 待开始:无已拆任务;后续从 Backlog 或生产配置待办拆新任务。 - 当前 blocker:远程 MySQL 连接当前不稳定或很慢,完整测试单次全绿可能需要稳定测试库;支付商户真实密钥/证书与生产 SDK 依赖仍待提供;真实 AI 上游 smoke 仍需配置密钥和模型数据后执行。 -- 下一个可领取任务:计划内 MVP 任务暂无 `TODO`;后续需按 `deployment.md` 实际部署,或从 Backlog 重新拆任务。 +- 下一个可领取任务:暂无已拆 `TODO`;建议先处理真实邮件、支付、AI 模型导入与真实图片耗时验证,或从 Backlog 拆新任务。 ## 当前可运行内容 @@ -81,6 +82,7 @@ gunicorn config.wsgi:application --bind 127.0.0.1:8002 --workers 1 --worker-clas - `POST /api/v1/generate/title` - `POST /api/v1/generate/image` - `GET /api/v1/balance` +- `GET /api/v1/models` - `POST /api/v1/recharge/create`(用户端 session + CSRF) - `GET /api/v1/recharge/status`(用户端 session) - `POST /api/v1/recharge/callback/wechat` @@ -95,18 +97,19 @@ gunicorn config.wsgi:application --bind 127.0.0.1:8002 --workers 1 --worker-clas - `GET/POST /recharge` - `GET/POST /apikeys` - `POST /apikeys//delete` +- `GET /models` - `GET /records/recharge` - `GET /records/usage` -当前骨架可运行。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 ` 鉴权:成功后 `request.user` 为所属用户、`request.auth` 为 `ApiKey`,缺失/无效 Key 返回 401,用户或 Key 禁用返回 403,外部 API 不接受 Web session。T-302 已落地生成接口:请求别名解析后按规则计费,预扣成功才调用 Provider,成功确认调用记录,`AiProviderError` / `AiCapabilityError` 等失败路径会退点;图片结果保存到本地 media 并返回 URL。T-303 已落地余额查询接口:`GET /api/v1/balance` 继承外部 API Key 鉴权,读取 billing 余额快照并返回 `user` 与 `points_balance`,测试覆盖余额与流水累加一致。T-304 已落地充值回调:`RechargeOrder` 保存下单锁定的金额/汇率/点数,微信/支付宝回调先验签再按订单幂等入账,重复回调不重复加点,金额不一致不入账;主动查单兜底可调用 `query_and_apply_recharge_payment(order_no, query_func)` 复用同一入账路径。T-305 已落地扫码下单与轮询:用户端 session 登录后可 `POST /api/v1/recharge/create` 创建 pending 订单并拿到 mock/SDK 二维码票据,`GET /api/v1/recharge/status` 只返回本人订单并在 pending 时尝试主动查单补入账;API Key 不能调用这两个用户端接口。T-306 已落地对外 API 安全加固:`image_url` 下载在扣点前做协议白名单、公网地址校验、重定向逐跳校验和响应大小上限;DRF 全局默认不再隐式启用 Session/Basic;生成接口按 Key 限流,认证失败按 IP 限流;充值下单有单笔金额上限。T-501 已落地 allauth 注册 / 登录:`ACCOUNT_EMAIL_VERIFICATION="mandatory"`,注册成功创建 0 点钱包、不写点数流水;未验证邮箱不能建立登录 session。T-502 已落地 API Key 自助管理:`/apikeys` 登录访问,生成后完整明文只显示一次,列表只显示 prefix,不显示 hash 或历史明文;删除为吊销 `revoked`,吊销后外部 API 返回 403。T-503 已落地个人中心与记录页:`/dashboard` 展示剩余点数、充值总额、入账点数、净消耗点数和最近记录;`/records/recharge` 展示当前用户充值订单;`/records/usage` 展示当前用户 consume/refund 点数流水并关联调用信息;所有页面均只读且只查本人。T-504 已落地充值页:`/recharge` GET 展示余额、充值表单、当前订单和最近充值,POST 创建 pending 订单并展示二维码票据,浏览器轮询 `/api/v1/recharge/status`,paid 后刷新页面重新读取余额;页面不直接写钱包或流水。T-401 已落地运营后台完善:用户列表显示钱包余额,钱包余额只读且通过专用表单手工调点,调点必须填原因、非 0、不得扣成负数,并经 `adjust_wallet_points()` 锁钱包写 `PointsLedger(adjust)`;API Key admin 只展示 prefix 和 hash 摘要,不回显明文或完整 hash;订单、流水、调用记录继续只读并增强检索。T-402 已完成 MVP P0 验收并新增 `docs/mvp-acceptance.md`。T-403 已完成部署 / 运行文档,生产按 `deployment.md` 执行,并已补 settings 对生产静态目录、共享 cache、CSRF trusted origins、HTTPS cookie/proxy/HSTS 的环境变量支持。真实上游生成未执行,原因是当前环境未配置 `AI_KEY_ENCRYPTION_KEY` 且数据库没有 AiModel/ModelAlias;后续配置后可用 `import_ai_models` 导入,再通过接口跑真实标题/图片并记录图片耗时。 +当前骨架可运行。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 ` 鉴权:成功后 `request.user` 为所属用户、`request.auth` 为 `ApiKey`,缺失/无效 Key 返回 401,用户或 Key 禁用返回 403,外部 API 不接受 Web session。T-302 已落地生成接口:请求别名解析后按规则计费,预扣成功才调用 Provider,成功确认调用记录,`AiProviderError` / `AiCapabilityError` 等失败路径会退点;图片结果保存到本地 media 并返回 URL。T-303 已落地余额查询接口:`GET /api/v1/balance` 继承外部 API Key 鉴权,读取 billing 余额快照并返回 `user` 与 `points_balance`,测试覆盖余额与流水累加一致。T-304 已落地充值回调:`RechargeOrder` 保存下单锁定的金额/汇率/点数,微信/支付宝回调先验签再按订单幂等入账,重复回调不重复加点,金额不一致不入账;主动查单兜底可调用 `query_and_apply_recharge_payment(order_no, query_func)` 复用同一入账路径。T-305 已落地扫码下单与轮询:用户端 session 登录后可 `POST /api/v1/recharge/create` 创建 pending 订单并拿到 mock/SDK 二维码票据,`GET /api/v1/recharge/status` 只返回本人订单并在 pending 时尝试主动查单补入账;API Key 不能调用这两个用户端接口。T-306 已落地对外 API 安全加固:`image_url` 下载在扣点前做协议白名单、公网地址校验、重定向逐跳校验和响应大小上限;DRF 全局默认不再隐式启用 Session/Basic;生成接口按 Key 限流,认证失败按 IP 限流;充值下单有单笔金额上限。T-501 已落地 allauth 注册 / 登录:`ACCOUNT_EMAIL_VERIFICATION="mandatory"`,注册成功创建 0 点钱包、不写点数流水;未验证邮箱不能建立登录 session。T-502 已落地 API Key 自助管理:`/apikeys` 登录访问,生成后完整明文只显示一次,列表只显示 prefix,不显示 hash 或历史明文;删除为吊销 `revoked`,吊销后外部 API 返回 403。T-503 已落地个人中心与记录页:`/dashboard` 展示剩余点数、充值总额、入账点数、净消耗点数和最近记录;`/records/recharge` 展示当前用户充值订单;`/records/usage` 展示当前用户 consume/refund 点数流水并关联调用信息;所有页面均只读且只查本人。T-504 已落地充值页:`/recharge` GET 展示余额、充值表单、当前订单和最近充值,POST 创建 pending 订单并展示二维码票据,浏览器轮询 `/api/v1/recharge/status`,paid 后刷新页面重新读取余额;页面不直接写钱包或流水。T-401 已落地运营后台完善:用户列表显示钱包余额,钱包余额只读且通过专用表单手工调点,调点必须填原因、非 0、不得扣成负数,并经 `adjust_wallet_points()` 锁钱包写 `PointsLedger(adjust)`;API Key admin 只展示 prefix 和 hash 摘要,不回显明文或完整 hash;订单、流水、调用记录继续只读并增强检索。T-402 已完成 MVP P0 验收并新增 `docs/mvp-acceptance.md`。T-403 已完成部署 / 运行文档,生产按 `deployment.md` 执行,并已补 settings 对生产静态目录、共享 cache、CSRF trusted origins、HTTPS cookie/proxy/HSTS 的环境变量支持。T-601 已落地可用别名发现:`GET /api/v1/models` 用 API Key 鉴权返回公开别名目录,`/models` 用 session 展示只读「可用模型」页;两者均不解密 provider key,不输出底层 SKU、URL、key 或 `extra_body`。真实上游生成未执行,原因是当前环境未配置 `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` 的任务(当前计划内 MVP 任务暂无 `TODO`;继续开发前需先从 Backlog 拆新任务)。 -5. 将该任务状态改为 `DOING`。 +4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务;当前暂无已拆 `TODO`,需要先从 Backlog 或生产配置待办拆新任务。 +5. 将领取的任务状态改为 `DOING`。 ## 维护规则 diff --git a/docs/routes.md b/docs/routes.md index 7442202..6cfe816 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -13,8 +13,10 @@ | `/records/recharge` | GET | 充值记录 | session | | `/records/usage` | GET | 点数使用(消费/调用)记录 | 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-601 已落地 `/models`:登录用户可查看当前公开可调用别名、能力、是否需要原图和点数单价;页面不展示底层 SKU、模型 URL、provider key、`api_key_encrypted` 或 `extra_body`。 ## API 路由(对外,DRF) @@ -23,6 +25,7 @@ T-501 已落地 `/signup`、`/login`、`/logout` 与最小 `/dashboard`。T-502 | `/api/v1/generate/title` | POST | 生成标题 | API Key | | `/api/v1/generate/image` | POST | 生成图片(同步) | 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/status` | GET | 轮询订单状态(前端每秒) | Session(用户端) | | `/api/v1/recharge/callback/wechat` | POST | 微信 V3 异步回调 | 验签(`@csrf_exempt`) | diff --git a/progress.md b/progress.md index 040e6c5..73e1f28 100644 --- a/progress.md +++ b/progress.md @@ -968,3 +968,49 @@ - 已知限制:真实 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。 - 下一步:计划内 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,不需要修补任务。