feat: add public model catalog discovery
This commit is contained in:
@@ -25,7 +25,7 @@ Python 3.12 / Django 5.2 LTS + DRF / django-admin / 用户端 Django 模板 SSR
|
||||
|
||||
## 当前状态
|
||||
|
||||
Phase 2 计费核心已完成,Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端已完成 T-501~T-505,Phase 5 已完成 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档:用户可通过 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) 第四节计费时序。
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,)
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
<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-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-usage-records' %}">消费记录</a>
|
||||
<form method="post" action="{% url 'portal-logout' %}">
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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/<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("records/recharge", RechargeRecordListView.as_view(), name="portal-recharge-records"),
|
||||
path("records/usage", UsageRecordListView.as_view(), name="portal-usage-records"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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。
|
||||
|
||||
## 领取任务规则
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+33
@@ -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 层,供后续生成接口和充值下单调用:
|
||||
|
||||
+13
-10
File diff suppressed because one or more lines are too long
@@ -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`) |
|
||||
|
||||
+46
@@ -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,不需要修补任务。
|
||||
|
||||
Reference in New Issue
Block a user