feat: add portal api key management

This commit is contained in:
QiuSW
2026-07-03 11:51:46 +08:00
parent d61a641b7e
commit 35f4c9c494
18 changed files with 323 additions and 29 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 注册 / 登录:用户端可通过 allauth 自助注册、邮箱验证、登录、登出,注册后自动创建 0 点钱包且不写赠点流水。下一步做 T-502 API Key 自助管理页。详见 [`docs/current-state.md`](docs/current-state.md)。 Phase 2 计费核心已完成,Phase 3 对外 API 与充值已完成到 T-306,Phase 4 已完成 T-501 注册 / 登录与 T-502 API Key 自助管理:用户端可通过 allauth 自助注册、邮箱验证、登录、登出,并可生成 / 删除(吊销)API Key;Key 明文只显示一次,库内只保存 hash 和 prefix。下一步做 T-503 个人中心 / 记录页。详见 [`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) 第四节计费时序。
+16
View File
@@ -0,0 +1,16 @@
from django import forms
class ApiKeyCreateForm(forms.Form):
name = forms.CharField(
label="名称",
max_length=80,
required=False,
widget=forms.TextInput(
attrs={
"autocomplete": "off",
"class": "form-control",
"placeholder": "例如:桌面端",
}
),
)
+86
View File
@@ -0,0 +1,86 @@
{% extends "portal/base.html" %}
{% block title %}API Key - cmhub{% endblock %}
{% block content %}
<div class="d-flex flex-column gap-4">
<div>
<h1 class="h3 mb-1">API Key</h1>
<div class="text-secondary">{{ user.email }}</div>
</div>
{% if new_api_key %}
<div class="alert alert-warning mb-0">
<div class="fw-semibold mb-2">新 Key 只显示一次</div>
<input class="form-control font-monospace key-value" type="text" value="{{ new_api_key.raw_key }}" readonly>
<div class="small mt-2">prefix:<code>{{ new_api_key.key_prefix }}</code></div>
</div>
{% endif %}
<section class="cmhub-surface">
<h2 class="h5 mb-3">生成 Key</h2>
<form method="post" class="row g-3 align-items-end">
{% csrf_token %}
<div class="col-md-8">
<label class="form-label" for="{{ form.name.id_for_label }}">{{ form.name.label }}</label>
{{ form.name }}
{% if form.name.errors %}
<div class="text-danger small mt-1">{{ form.name.errors|striptags }}</div>
{% endif %}
</div>
<div class="col-md-4">
<button class="btn btn-primary w-100" type="submit">生成</button>
</div>
</form>
</section>
<section class="cmhub-surface">
<h2 class="h5 mb-3">Key 列表</h2>
{% if api_keys %}
<div class="table-responsive">
<table class="table align-middle mb-0">
<thead>
<tr>
<th scope="col">prefix</th>
<th scope="col">名称</th>
<th scope="col">状态</th>
<th scope="col">最近使用</th>
<th scope="col">创建时间</th>
<th scope="col" class="text-end">操作</th>
</tr>
</thead>
<tbody>
{% for api_key in api_keys %}
<tr>
<td><code>{{ api_key.key_prefix }}</code></td>
<td>{{ api_key.name|default:"-" }}</td>
<td>
{% if api_key.status == "active" %}
<span class="badge text-bg-success">active</span>
{% else %}
<span class="badge text-bg-secondary">revoked</span>
{% endif %}
</td>
<td>{{ api_key.last_used_at|date:"Y-m-d H:i"|default:"-" }}</td>
<td>{{ api_key.created_at|date:"Y-m-d H:i" }}</td>
<td class="text-end">
{% if api_key.status == "active" %}
<form method="post" action="{% url 'portal-apikey-delete' api_key.pk %}">
{% csrf_token %}
<button class="btn btn-sm btn-outline-danger" type="submit">删除</button>
</form>
{% else %}
<span class="text-secondary small">已删除</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-secondary">暂无 API Key</div>
{% endif %}
</section>
</div>
{% endblock %}
+10
View File
@@ -65,6 +65,15 @@
background: #ffffff; background: #ffffff;
padding: 18px; padding: 18px;
} }
.cmhub-surface {
border: 1px solid var(--cmhub-line);
border-radius: 8px;
background: #ffffff;
padding: 20px;
}
.key-value {
word-break: break-all;
}
</style> </style>
</head> </head>
<body> <body>
@@ -74,6 +83,7 @@
<div class="ms-auto d-flex gap-2"> <div class="ms-auto d-flex gap-2">
{% if user.is_authenticated %} {% if user.is_authenticated %}
<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-outline-secondary" href="{% url 'portal-apikeys' %}">API Key</a>
<form method="post" action="{% url 'portal-logout' %}"> <form method="post" action="{% url 'portal-logout' %}">
{% csrf_token %} {% csrf_token %}
<button class="btn btn-sm btn-outline-secondary" type="submit">退出</button> <button class="btn btn-sm btn-outline-secondary" type="submit">退出</button>
@@ -15,6 +15,15 @@
<div class="display-6 fw-semibold">{{ balance.points_balance }}</div> <div class="display-6 fw-semibold">{{ balance.points_balance }}</div>
</div> </div>
</div> </div>
<div class="col-md-4">
<div class="metric d-flex flex-column gap-3">
<div>
<div class="text-secondary small">接口凭证</div>
<div class="h5 mb-0">API Key</div>
</div>
<a class="btn btn-primary align-self-start" href="{% url 'portal-apikeys' %}">管理</a>
</div>
</div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
+100 -1
View File
@@ -5,9 +5,10 @@ from django.contrib.auth import get_user, get_user_model
from django.core import mail from django.core import mail
from django.core.cache import cache from django.core.cache import cache
from django.test import Client, TestCase, override_settings from django.test import Client, TestCase, override_settings
from rest_framework.test import APIClient
from apps.billing.models import PointsLedger from apps.billing.models import PointsLedger
from apps.users.models import UserWallet from apps.users.models import ApiKey, UserWallet
@override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend") @override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend")
@@ -112,3 +113,101 @@ class PortalAccountFlowTests(TestCase):
) )
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
def test_apikeys_requires_session_login(self):
response = self.client.get("/apikeys")
self.assertEqual(response.status_code, 302)
self.assertTrue(response["Location"].startswith("/login?next="))
def test_create_api_key_shows_plaintext_once_and_stores_only_hash(self):
user = self.create_verified_user()
self.client.force_login(user)
response = self.client.post("/apikeys", {"name": "desktop"}, follow=True)
self.assertEqual(response.status_code, 200)
api_key = ApiKey.objects.get(user=user)
new_api_key = response.context["new_api_key"]
raw_key = new_api_key["raw_key"]
self.assertTrue(raw_key.startswith("sk_cmhub_"))
self.assertEqual(api_key.name, "desktop")
self.assertEqual(api_key.key_prefix, raw_key[: ApiKey.KEY_PREFIX_LENGTH])
self.assertNotEqual(api_key.key_hash, raw_key)
self.assertNotIn(raw_key, str(api_key.__dict__))
self.assertTrue(api_key.matches_key(raw_key))
self.assertContains(response, raw_key)
self.assertContains(response, api_key.key_prefix)
self.assertNotContains(response, api_key.key_hash)
second_response = self.client.get("/apikeys")
self.assertEqual(second_response.status_code, 200)
self.assertNotContains(second_response, raw_key)
self.assertContains(second_response, api_key.key_prefix)
self.assertNotContains(second_response, api_key.key_hash)
def test_apikey_list_only_shows_current_user_prefix(self):
user = self.create_verified_user()
other_user = self.create_verified_user()
own_key, own_raw_key = ApiKey.create_for_user(user, name="desktop")
other_key, other_raw_key = ApiKey.create_for_user(other_user, name="other")
self.client.force_login(user)
response = self.client.get("/apikeys")
self.assertEqual(response.status_code, 200)
self.assertContains(response, own_key.key_prefix)
self.assertContains(response, "desktop")
self.assertNotContains(response, own_raw_key)
self.assertNotContains(response, own_key.key_hash)
self.assertNotContains(response, other_key.key_prefix)
self.assertNotContains(response, other_raw_key)
self.assertNotContains(response, other_key.key_hash)
def test_delete_api_key_revokes_it_and_external_api_returns_403(self):
user = self.create_verified_user()
api_key, raw_key = ApiKey.create_for_user(user, name="desktop")
self.client.force_login(user)
response = self.client.post(f"/apikeys/{api_key.pk}/delete")
self.assertEqual(response.status_code, 302)
self.assertEqual(response["Location"], "/apikeys")
api_key.refresh_from_db()
self.assertEqual(api_key.status, ApiKey.Status.REVOKED)
api_client = APIClient()
api_response = api_client.get(
"/api/v1/balance",
HTTP_AUTHORIZATION=f"Bearer {raw_key}",
)
self.assertEqual(api_response.status_code, 403)
self.assertEqual(api_response.data["error"]["code"], "account_disabled")
def test_delete_api_key_does_not_allow_other_users_key(self):
user = self.create_verified_user()
other_user = self.create_verified_user()
other_key, _raw_key = ApiKey.create_for_user(other_user, name="other")
self.client.force_login(user)
response = self.client.post(f"/apikeys/{other_key.pk}/delete")
self.assertEqual(response.status_code, 404)
other_key.refresh_from_db()
self.assertEqual(other_key.status, ApiKey.Status.ACTIVE)
def test_apikey_create_and_delete_posts_are_csrf_protected(self):
user = self.create_verified_user()
api_key, _raw_key = ApiKey.create_for_user(user, name="desktop")
csrf_client = Client(enforce_csrf_checks=True)
csrf_client.force_login(user)
create_response = csrf_client.post("/apikeys", {"name": "new"})
delete_response = csrf_client.post(f"/apikeys/{api_key.pk}/delete")
self.assertEqual(create_response.status_code, 403)
self.assertEqual(delete_response.status_code, 403)
api_key.refresh_from_db()
self.assertEqual(api_key.status, ApiKey.Status.ACTIVE)
+3 -1
View File
@@ -2,7 +2,7 @@ from allauth.account.views import LoginView, LogoutView, SignupView
from django.urls import path from django.urls import path
from django.views.generic import RedirectView from django.views.generic import RedirectView
from .views import DashboardView from .views import ApiKeyDeleteView, ApiKeyListCreateView, DashboardView
urlpatterns = [ urlpatterns = [
path("", RedirectView.as_view(pattern_name="portal-dashboard", permanent=False), name="portal-home"), path("", RedirectView.as_view(pattern_name="portal-dashboard", permanent=False), name="portal-home"),
@@ -10,4 +10,6 @@ urlpatterns = [
path("login", LoginView.as_view(), name="portal-login"), path("login", LoginView.as_view(), name="portal-login"),
path("logout", LogoutView.as_view(), name="portal-logout"), path("logout", LogoutView.as_view(), name="portal-logout"),
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/<int:pk>/delete", ApiKeyDeleteView.as_view(), name="portal-apikey-delete"),
] ]
+47 -1
View File
@@ -1,7 +1,17 @@
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.views.generic import TemplateView from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse_lazy
from django.views import View
from django.views.generic import FormView, TemplateView
from apps.billing.services import get_balance_snapshot from apps.billing.services import get_balance_snapshot
from apps.users.models import ApiKey
from .forms import ApiKeyCreateForm
NEW_API_KEY_SESSION_KEY = "portal_new_api_key"
class DashboardView(LoginRequiredMixin, TemplateView): class DashboardView(LoginRequiredMixin, TemplateView):
@@ -11,3 +21,39 @@ class DashboardView(LoginRequiredMixin, TemplateView):
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
context["balance"] = get_balance_snapshot(self.request.user) context["balance"] = get_balance_snapshot(self.request.user)
return context return context
class ApiKeyListCreateView(LoginRequiredMixin, FormView):
template_name = "portal/apikeys.html"
form_class = ApiKeyCreateForm
success_url = reverse_lazy("portal-apikeys")
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["api_keys"] = ApiKey.objects.filter(user=self.request.user).order_by(
"-created_at",
"-id",
)
context["new_api_key"] = self.request.session.pop(NEW_API_KEY_SESSION_KEY, None)
return context
def form_valid(self, form):
name = form.cleaned_data["name"].strip()
api_key, raw_key = ApiKey.create_for_user(self.request.user, name=name)
self.request.session[NEW_API_KEY_SESSION_KEY] = {
"name": api_key.name,
"key_prefix": api_key.key_prefix,
"raw_key": raw_key,
}
messages.success(self.request, "API Key 已生成")
return super().form_valid(form)
class ApiKeyDeleteView(LoginRequiredMixin, View):
def post(self, request, pk):
api_key = get_object_or_404(ApiKey, pk=pk, user=request.user)
if api_key.status != ApiKey.Status.REVOKED:
api_key.status = ApiKey.Status.REVOKED
api_key.save(update_fields=("status", "updated_at"))
messages.success(request, "API Key 已删除")
return redirect("portal-apikeys")
+2 -2
View File
@@ -38,7 +38,7 @@
## 当前阶段 ## 当前阶段
当前项目处于:**Phase 4 用户端**。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 自助管理页。 当前项目处于:**Phase 4 用户端**。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 个人中心 / 记录页。
优先路径: 优先路径:
@@ -46,7 +46,7 @@
2. Phase 1:最高风险功能原型 —— T-101/T-102/T-103/T-104/T-105 已完成 provider 层、模型配置表、别名解析、配置审计、录制标题/图片 smoke 与审核修补;真实图片同步耗时待配置 Fernet 主密钥、AiModel/ModelAlias 与真实上游后在 T-302/T-403 前补测。 2. Phase 1:最高风险功能原型 —— T-101/T-102/T-103/T-104/T-105 已完成 provider 层、模型配置表、别名解析、配置审计、录制标题/图片 smoke 与审核修补;真实图片同步耗时待配置 Fernet 主密钥、AiModel/ModelAlias 与真实上游后在 T-302/T-403 前补测。
3. Phase 2:计费核心 —— T-201/T-202/T-203 已完成 UserWallet/ApiKey/PointsLedger/CallRecord、计费规则、汇率、计费计算、并发安全扣点与失败退点。 3. Phase 2:计费核心 —— T-201/T-202/T-203 已完成 UserWallet/ApiKey/PointsLedger/CallRecord、计费规则、汇率、计费计算、并发安全扣点与失败退点。
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 管理,然后个人中心/记录页、充值页。 5. Phase 4:用户端(Django 模板 SSR)—— T-501 注册登录与 T-502 API Key 管理已完成,下一步 T-503 个人中心 / 记录页,然后 T-504 充值页。
6. Phase 5:后台与发布 —— 运营后台完善、完整验收、部署 / 运行文档。 6. Phase 5:后台与发布 —— 运营后台完善、完整验收、部署 / 运行文档。
## 领取任务规则 ## 领取任务规则
+3 -3
View File
@@ -11,7 +11,7 @@
| Web 框架 | Django 5.2 LTS | 已定 | 自带 ORM、迁移、admin,适合「API + 运营后台」;选 LTS 维护到 2028,安全更新窗口最长。**禁用已 EOL 的 4.0/4.1**(无安全补丁,资金服务不可用) | | Web 框架 | Django 5.2 LTS | 已定 | 自带 ORM、迁移、admin,适合「API + 运营后台」;选 LTS 维护到 2028,安全更新窗口最长。**禁用已 EOL 的 4.0/4.1**(无安全补丁,资金服务不可用) |
| API 框架 | Django REST Framework (DRF) | 已定 | 鉴权、序列化、参数校验、限流现成 | | API 框架 | Django REST Framework (DRF) | 已定 | 鉴权、序列化、参数校验、限流现成 |
| 运营后台 | django-admin | 已定 | 近零代码即得用户/点数/记录的增删改查与检索,省 80% 后台工作量 | | 运营后台 | django-admin | 已定 | 近零代码即得用户/点数/记录的增删改查与检索,省 80% 后台工作量 |
| 用户端 | Django 模板 SSR + Bootstrap 5 + django-allauth | 已定 | T-501 已落地自助注册/登录/登出、邮箱验证与最小 dashboard;MVP 先用 Django form + Bootstrap 模板,不为简单表单引入 crispy-forms | | 用户端 | Django 模板 SSR + Bootstrap 5 + django-allauth | 已定 | T-501 已落地自助注册/登录/登出、邮箱验证与最小 dashboard;T-502 已落地 API Key 自助生成 / 删除页;MVP 先用 Django form + Bootstrap 模板,不为简单表单引入 crispy-forms |
| 后台美化 | django-unfold 或 simpleui | 待定 | 仅外观,MVP 可先用原生 admin,后期按需引入 | | 后台美化 | django-unfold 或 simpleui | 待定 | 仅外观,MVP 可先用原生 admin,后期按需引入 |
| AI 上游对接 | **Provider 适配器层**(按 `api_type` 注册)+ **能力别名** 映射 + `requests` HTTP 客户端 | 已定 | 对外只暴露 `generate text/image` 两接口与别名;换供应商改后台映射,不动对外契约。移植 `cmbot` 的调用逻辑到各适配器。当前 3 模型机制不同:文本 chat、`nano-banana2` chat 多模态返图、`gpt-image-2` images/edits 改图(详见 `04` 3.1) | | AI 上游对接 | **Provider 适配器层**(按 `api_type` 注册)+ **能力别名** 映射 + `requests` HTTP 客户端 | 已定 | 对外只暴露 `generate text/image` 两接口与别名;换供应商改后台映射,不动对外契约。移植 `cmbot` 的调用逻辑到各适配器。当前 3 模型机制不同:文本 chat、`nano-banana2` chat 多模态返图、`gpt-image-2` images/edits 改图(详见 `04` 3.1) |
| 供应商密钥存储 | 应用层 Fernet 加密(`cryptography`) | 已定 | `AiModel.api_key_encrypted` 加密入库、admin 写入型字段不回显;加密主密钥 `AI_KEY_ENCRYPTION_KEY` 走环境变量,配置清单见 `env.md` | | 供应商密钥存储 | 应用层 Fernet 加密(`cryptography`) | 已定 | `AiModel.api_key_encrypted` 加密入库、admin 写入型字段不回显;加密主密钥 `AI_KEY_ENCRYPTION_KEY` 走环境变量,配置清单见 `env.md` |
@@ -20,7 +20,7 @@
| 数据库 | MySQL 8.4 LTS(cmhub 专用独立实例) | 已定 | 满足 Django 5.2 的 MySQL ≥8.0.11;引擎 InnoDB + 字符集 utf8mb4;行锁 `select_for_update` / 条件更新保并发扣点。**不复用 VPS 已有的 MySQL 5.7**(跑不了 Django 5.2、无 CHECK 约束)。开发亦用 MySQL,勿用 SQLite(不支持 `select_for_update`) | | 数据库 | MySQL 8.4 LTS(cmhub 专用独立实例) | 已定 | 满足 Django 5.2 的 MySQL ≥8.0.11;引擎 InnoDB + 字符集 utf8mb4;行锁 `select_for_update` / 条件更新保并发扣点。**不复用 VPS 已有的 MySQL 5.7**(跑不了 Django 5.2、无 CHECK 约束)。开发亦用 MySQL,勿用 SQLite(不支持 `select_for_update`) |
| MySQL 驱动 | PyMySQL + cryptography | 已定 | PyMySQL 负责 Django 连接 MySQL;MySQL 8 默认 `caching_sha2_password` 认证需要 `cryptography` 支持;客户端连接/读/写超时通过 `MYSQL_CONNECT_TIMEOUT` / `MYSQL_READ_TIMEOUT` / `MYSQL_WRITE_TIMEOUT` 配置 | | MySQL 驱动 | PyMySQL + cryptography | 已定 | PyMySQL 负责 Django 连接 MySQL;MySQL 8 默认 `caching_sha2_password` 认证需要 `cryptography` 支持;客户端连接/读/写超时通过 `MYSQL_CONNECT_TIMEOUT` / `MYSQL_READ_TIMEOUT` / `MYSQL_WRITE_TIMEOUT` 配置 |
| 对外鉴权 | API Key(DRF 自定义 Authentication,哈希存储比对) | 已定 | 用户自助生成 Key;**API 只认 Key、不挂 SessionAuthentication**,防浏览器 cookie 绕过计费 | | 对外鉴权 | API Key(DRF 自定义 Authentication,哈希存储比对) | 已定 | 用户自助生成 Key;**API 只认 Key、不挂 SessionAuthentication**,防浏览器 cookie 绕过计费 |
| 用户端鉴权 | Django Session(+ allauth 注册登录邮箱验证) | 已定 | T-501 已落地 `/signup` `/login` `/logout` 与 `/dashboard`;用户端页面与 `recharge/create` 走 session + CSRF;后台账号也用 Session 登录 | | 用户端鉴权 | Django Session(+ allauth 注册登录邮箱验证) | 已定 | T-501 已落地 `/signup` `/login` `/logout` 与 `/dashboard`;T-502 已落地 `/apikeys`;用户端页面与 `recharge/create` 走 session + CSRF;后台账号也用 Session 登录 |
| 充值对接 | 自助扫码:微信 V3 native + 支付宝当面付;下单取二维码 + 服务端回调(验签 + 幂等) | 已定 | T-304/T-305 已落地回调、扫码下单、状态轮询、HMAC mock 联调与 SDK 模式入口;生产需安装并配置 `wechatpayv3` / `python-alipay-sdk` 与真实商户密钥/证书 | | 充值对接 | 自助扫码:微信 V3 native + 支付宝当面付;下单取二维码 + 服务端回调(验签 + 幂等) | 已定 | T-304/T-305 已落地回调、扫码下单、状态轮询、HMAC mock 联调与 SDK 模式入口;生产需安装并配置 `wechatpayv3` / `python-alipay-sdk` 与真实商户密钥/证书 |
| 生成返回方式 | 同步 HTTP(无任务队列) | 已定 | MVP 简化;图片接口需调大网关/服务超时 | | 生成返回方式 | 同步 HTTP(无任务队列) | 已定 | MVP 简化;图片接口需调大网关/服务超时 |
| 任务队列 | 暂不引入(Celery/RQ) | 待定 | V2 异步化时再评估 | | 任务队列 | 暂不引入(Celery/RQ) | 待定 | V2 异步化时再评估 |
@@ -41,7 +41,7 @@
- **同步可用的前提是「超时链路 + worker 容量」配对**,否则会「小量正常、上量假死」:① 每模型 `timeout_seconds` 设有限值(`ai_models.json` 现为 `0`,迁入须改);② Gunicorn `--timeout` 与网关 `proxy_read_timeout` 按最慢图片放大(别用默认 30s / 60s);③ worker/线程数按**峰值总并发**预留。详见 [架构设计](04-architecture.md) 第五节结论。 - **同步可用的前提是「超时链路 + worker 容量」配对**,否则会「小量正常、上量假死」:① 每模型 `timeout_seconds` 设有限值(`ai_models.json` 现为 `0`,迁入须改);② Gunicorn `--timeout` 与网关 `proxy_read_timeout` 按最慢图片放大(别用默认 30s / 60s);③ worker/线程数按**峰值总并发**预留。详见 [架构设计](04-architecture.md) 第五节结论。
- **何时转 V2 异步**:worker 被长连接占满拖慢快接口/后台、接入方总并发明显上涨、或需要「关窗重连/任务持久化」体验——在此之前保持同步。 - **何时转 V2 异步**:worker 被长连接占满拖慢快接口/后台、接入方总并发明显上涨、或需要「关窗重连/任务持久化」体验——在此之前保持同步。
- **数据库:MySQL 8.4 LTS,cmhub 专用独立实例**:① 部署环境的 VPS 已装 MySQL 5.7 供其他服务用,但 5.7 跑不了 Django 5.2(需 ≥8.0.11)、已 EOL、且不支持 CHECK 约束,故**不复用**它;② 机器内存宽裕(`available` 7.4G),给 cmhub **单开一个 MySQL 8.4 LTS 实例**(独立端口/容器),与已有 5.7 完全隔离、互不影响;③ 选 8.4 LTS 取长维护窗口 + 完整 CHECK 约束(CHECK 需 MySQL ≥8.0.16 才真正生效);④ 强制 InnoDB + utf8mb4(5.7/老配置默认非 utf8mb4,prompt 的 emoji/生僻字会写失败);⑤ MySQL 默认隔离级别 REPEATABLE READ(不同于 PostgreSQL 的 READ COMMITTED),`select_for_update` 扣点仍安全,但计费实现按此语义验证;⑥ 开发环境同用 MySQL,不要用 SQLite——SQLite 会静默忽略 `FOR UPDATE`,并发扣点逻辑测不出来;⑦ 不在代码里写死只适配某一种库的 SQL。 - **数据库:MySQL 8.4 LTS,cmhub 专用独立实例**:① 部署环境的 VPS 已装 MySQL 5.7 供其他服务用,但 5.7 跑不了 Django 5.2(需 ≥8.0.11)、已 EOL、且不支持 CHECK 约束,故**不复用**它;② 机器内存宽裕(`available` 7.4G),给 cmhub **单开一个 MySQL 8.4 LTS 实例**(独立端口/容器),与已有 5.7 完全隔离、互不影响;③ 选 8.4 LTS 取长维护窗口 + 完整 CHECK 约束(CHECK 需 MySQL ≥8.0.16 才真正生效);④ 强制 InnoDB + utf8mb4(5.7/老配置默认非 utf8mb4,prompt 的 emoji/生僻字会写失败);⑤ MySQL 默认隔离级别 REPEATABLE READ(不同于 PostgreSQL 的 READ COMMITTED),`select_for_update` 扣点仍安全,但计费实现按此语义验证;⑥ 开发环境同用 MySQL,不要用 SQLite——SQLite 会静默忽略 `FOR UPDATE`,并发扣点逻辑测不出来;⑦ 不在代码里写死只适配某一种库的 SQL。
- **用户端用 Django 模板 SSR 单体,不引前端框架**:需求含终端用户自助(注册/充值/API Key/记录),选 Django 模板 + Bootstrap + allauth 与后端同工程单体部署,复用 Django auth/session,开发部署最快、最契合单机 MVP;代价是交互不如 SPA,可后续加 HTMX。T-501 已接入 django-allauth 65.18.0,使用邮箱验证、session 与 CSRF;注册成功只创建 0 点 `UserWallet`,不写赠点流水。放弃 Vue/React 前后端分离(两套项目/部署,与单体 MVP 调性冲突)。用户模型:`User`(auth) 持登录态、`UserWallet` 持点数(扣点锁 wallet、与 auth 解耦)、`ApiKey`(User 1:N,哈希存储)。注册不送免费点数。 - **用户端用 Django 模板 SSR 单体,不引前端框架**:需求含终端用户自助(注册/充值/API Key/记录),选 Django 模板 + Bootstrap + allauth 与后端同工程单体部署,复用 Django auth/session,开发部署最快、最契合单机 MVP;代价是交互不如 SPA,可后续加 HTMX。T-501 已接入 django-allauth 65.18.0,使用邮箱验证、session 与 CSRF;注册成功只创建 0 点 `UserWallet`,不写赠点流水。T-502 已用 Django Form + Bootstrap 模板落地 `/apikeys`,生成 Key 后明文只显示一次,删除写为 `revoked`。放弃 Vue/React 前后端分离(两套项目/部署,与单体 MVP 调性冲突)。用户模型:`User`(auth) 持登录态、`UserWallet` 持点数(扣点锁 wallet、与 auth 解耦)、`ApiKey`(User 1:N,哈希存储)。注册不送免费点数。
## 三、构建与运行命令 ## 三、构建与运行命令
+4 -4
View File
@@ -18,7 +18,7 @@
组件落位: 组件落位:
- **用户端层(Django 模板 SSR)**:注册/登录(Django auth / allauth)、个人中心(余额/充值总额/充值记录/消费记录)、API Key 自助管理、发起扫码充值。入口 `apps/portal/`,用 session 鉴权。T-501 已落地 `/signup`、`/login`、`/logout` 与最小 `/dashboard`;注册成功通过 allauth adapter 创建 0 点 `UserWallet`,不写赠点流水。 - **用户端层(Django 模板 SSR)**:注册/登录(Django auth / allauth)、个人中心(余额/充值总额/充值记录/消费记录)、API Key 自助管理、发起扫码充值。入口 `apps/portal/`,用 session 鉴权。T-501 已落地 `/signup`、`/login`、`/logout` 与最小 `/dashboard`;注册成功通过 allauth adapter 创建 0 点 `UserWallet`,不写赠点流水。T-502 已落地 `/apikeys`,用户可自助生成和删除(吊销)自己的 API Key,明文只显示一次,列表只显示 prefix。
- **用户与账号层**:注册用户 `User`、点数钱包 `UserWallet`、`ApiKey`(一用户多把、哈希存储)。入口 `apps/users/`。 - **用户与账号层**:注册用户 `User`、点数钱包 `UserWallet`、`ApiKey`(一用户多把、哈希存储)。入口 `apps/users/`。
- **API 层(DRF)**:对外生成接口、余额查询、支付回调接收、扫码下单。入口 `apps/api/`。生成/余额这类对外业务 API **只认 API Key,不接受 Web session**;充值下单/状态查询属于用户端流程,走 Web session + CSRF;支付回调走平台验签。 - **API 层(DRF)**:对外生成接口、余额查询、支付回调接收、扫码下单。入口 `apps/api/`。生成/余额这类对外业务 API **只认 API Key,不接受 Web session**;充值下单/状态查询属于用户端流程,走 Web session + CSRF;支付回调走平台验签。
- **计费层**:点数计算、原子扣减(锁 `UserWallet` 行)、退点、充值入账、流水记账。入口 `apps/billing/`。 - **计费层**:点数计算、原子扣减(锁 `UserWallet` 行)、退点、充值入账、流水记账。入口 `apps/billing/`。
@@ -272,7 +272,7 @@ CREATE TABLE call_record (
- `payment_user_id`、`payment_txn_no` 为对账预留,字段先建。 - `payment_user_id`、`payment_txn_no` 为对账预留,字段先建。
- `recharge_order.exchange_rate` 与 `points_granted` 在下单时写入,状态为 `pending` 时也必须有值;支付回调金额必须与订单金额一致,入账时不得按新的汇率重算。 - `recharge_order.exchange_rate` 与 `points_granted` 在下单时写入,状态为 `pending` 时也必须有值;支付回调金额必须与订单金额一致,入账时不得按新的汇率重算。
- `call_record.status` 状态机为 `pending -> success / failed`。上游失败退点后仍保持 `failed`,退款流水通过 `points_ledger(change_type=refund, ref_call_id=call_record.id)` 关联,不单独增加 `refunded` 状态,避免调用结果与账务动作混在一个字段里。 - `call_record.status` 状态机为 `pending -> success / failed`。上游失败退点后仍保持 `failed`,退款流水通过 `points_ledger(change_type=refund, ref_call_id=call_record.id)` 关联,不单独增加 `refunded` 状态,避免调用结果与账务动作混在一个字段里。
- T-201 已落地 `UserWallet` / `ApiKey` 于 `apps.users`,`PointsLedger` / `CallRecord` 于 `apps.billing`;T-203 已落地扣点/退点服务;T-304 已落地 `RechargeOrder`、回调幂等入账服务和 `points_ledger(ref_order_id, change_type)` 复合唯一约束,`ref_order_id` 当前仍为数值引用 `RechargeOrder.id`;T-305 已落地 `create_recharge_order()`,负责创建 pending 订单、锁定汇率/点数并回填二维码票据;T-501 已保证 allauth 自助注册路径创建 `UserWallet(points_balance=0)`,且不写 `PointsLedger`,避免把注册初始化误记为赠点或充值。 - T-201 已落地 `UserWallet` / `ApiKey` 于 `apps.users`,`PointsLedger` / `CallRecord` 于 `apps.billing`;T-203 已落地扣点/退点服务;T-304 已落地 `RechargeOrder`、回调幂等入账服务和 `points_ledger(ref_order_id, change_type)` 复合唯一约束,`ref_order_id` 当前仍为数值引用 `RechargeOrder.id`;T-305 已落地 `create_recharge_order()`,负责创建 pending 订单、锁定汇率/点数并回填二维码票据;T-501 已保证 allauth 自助注册路径创建 `UserWallet(points_balance=0)`,且不写 `PointsLedger`,避免把注册初始化误记为赠点或充值;T-502 已把 API Key 自助管理接到 `ApiKey.create_for_user()`,删除动作写为 `revoked` 状态而非物理删除。
## 四、计费时序(核心,务必照此实现) ## 四、计费时序(核心,务必照此实现)
@@ -342,7 +342,7 @@ CREATE TABLE call_record (
| 供应商耦合 | 调用方绑具体 SKU 则换模型要通知所有接入方 | 对外绑能力别名,后台改别名→模型映射即可换供应商 | | 供应商耦合 | 调用方绑具体 SKU 则换模型要通知所有接入方 | 对外绑能力别名,后台改别名→模型映射即可换供应商 |
| 配置热生效 | 后台改模型/密钥后运行时仍用旧值 | 不在进程内长缓存;每次查库或保存时失效缓存 | | 配置热生效 | 后台改模型/密钥后运行时仍用旧值 | 不在进程内长缓存;每次查库或保存时失效缓存 |
| 配置变更审计 | 改密钥/模型/别名映射无痕 | `AiConfigAuditLog` 自动记录后台 create/update/delete;密钥只记录 empty/set 变化,日志只读 | | 配置变更审计 | 改密钥/模型/别名映射无痕 | `AiConfigAuditLog` 自动记录后台 create/update/delete;密钥只记录 empty/set 变化,日志只读 |
| API Key 泄露 | 明文存库一旦泄露全泄 | Key **哈希存储**(sha256),明文只在创建时显示一次,库存 `key_hash`+`key_prefix`,鉴权做哈希比对 | | API Key 泄露 | 明文存库一旦泄露全泄 | Key **哈希存储**(sha256),明文只在创建时显示一次,库内存 `key_hash`+`key_prefix`,鉴权做哈希比对 |
| 注册滥用 | 自助注册被批量刷 | 邮箱验证 + 生成接口限流(DRF throttle);**注册不送点数**,无免费额度可薅 | | 注册滥用 | 自助注册被批量刷 | 邮箱验证 + 生成接口限流(DRF throttle);**注册不送点数**,无免费额度可薅 |
| Web/API 抢 worker | 图片同步长请求占满 worker、拖慢用户端页面 | 单体下按路径把 `/api/generate/*` 与用户端页面**分流到不同 gunicorn/worker 池**(见 5.1) | | Web/API 抢 worker | 图片同步长请求占满 worker、拖慢用户端页面 | 单体下按路径把 `/api/generate/*` 与用户端页面**分流到不同 gunicorn/worker 池**(见 5.1) |
| 充错账户 | 扫码订单未绑定发起用户 | 订单创建即绑定 `user`;回调按 `order_no` 定位订单→其 user 入账 | | 充错账户 | 扫码订单未绑定发起用户 | 订单创建即绑定 `user`;回调按 `order_no` 定位订单→其 user 入账 |
@@ -365,7 +365,7 @@ CREATE TABLE call_record (
2. 移植并跑通一次 AI 调用(标题 / 图片)原型(Phase 1)。 2. 移植并跑通一次 AI 调用(标题 / 图片)原型(Phase 1)。
3. 计费:点数扣减(并发安全)+ 计费规则 + 调用记录(Phase 2,T-201~T-203 已完成)。 3. 计费:点数扣减(并发安全)+ 计费规则 + 调用记录(Phase 2,T-201~T-203 已完成)。
4. 对外 API 鉴权 + 余额查询 + 充值下单/回调 + 安全加固(Phase 3,已完成到 T-306)。 4. 对外 API 鉴权 + 余额查询 + 充值下单/回调 + 安全加固(Phase 3,已完成到 T-306)。
5. 用户端注册登录、API Key 管理、个人中心、充值页(Phase 4;T-501 已完成注册登录,下一步 T-502 API Key 管理)。 5. 用户端注册登录、API Key 管理、个人中心、充值页(Phase 4;T-501 注册登录与 T-502 API Key 管理已完成,下一步 T-503 个人中心 / 记录页)。
6. 运营后台完善、完整验收、部署(Phase 5)。 6. 运营后台完善、完整验收、部署(Phase 5)。
## 七、项目结构建议 ## 七、项目结构建议
+1 -1
View File
@@ -62,7 +62,7 @@
| ID | 任务 | 依赖 | 验收要点 | 状态 | | ID | 任务 | 依赖 | 验收要点 | 状态 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| T-501 | 注册 / 登录(allauth) | T-201 | 自助注册(邮箱验证)、登录、登出;注册后钱包点数为 0(不送点数);session + CSRF | DONE | | T-501 | 注册 / 登录(allauth) | T-201 | 自助注册(邮箱验证)、登录、登出;注册后钱包点数为 0(不送点数);session + CSRF | DONE |
| T-502 | API Key 自助管理页 | T-501, T-301 | 登录用户生成/删除 Key;明文只显示一次、库存哈希;列表只显示 prefix;删除后该 Key 调用 401 | TODO | | T-502 | API Key 自助管理页 | T-501, T-301 | 登录用户生成/删除 Key;明文只显示一次、库内只存哈希;列表只显示 prefix;删除即吊销,吊销后该 Key 调用 403(无效/不存在 Key 仍为 401) | DONE |
| T-503 | 个人中心 / 记录页 | T-501, T-203 | 剩余点数、充值总额、充值记录、消费(调用)记录;数据与流水一致;仅见本人 | TODO | | T-503 | 个人中心 / 记录页 | T-501, T-203 | 剩余点数、充值总额、充值记录、消费(调用)记录;数据与流水一致;仅见本人 | TODO |
| T-504 | 充值页(扫码 + 轮询到账) | T-501, T-305 | 发起充值→展示二维码→轮询订单状态→到账后余额刷新;到账以回调为权威 | TODO | | T-504 | 充值页(扫码 + 轮询到账) | T-501, T-305 | 发起充值→展示二维码→轮询订单状态→到账后余额刷新;到账以回调为权威 | TODO |
+2
View File
@@ -31,6 +31,8 @@ T-306 已实现对外 API 安全加固:`image_url` 下载只允许 `http` / `h
T-501 已实现用户端注册 / 登录基线:`/signup` `/login` `/logout` 走 django-allauth + Django session + CSRF;注册邮箱必须验证,注册成功创建 0 点 `UserWallet`,不创建赠点流水。对外 API 仍只认 API Key,不接受 Web session。 T-501 已实现用户端注册 / 登录基线:`/signup` `/login` `/logout` 走 django-allauth + Django session + CSRF;注册邮箱必须验证,注册成功创建 0 点 `UserWallet`,不创建赠点流水。对外 API 仍只认 API Key,不接受 Web session。
T-502 已实现用户端 API Key 自助管理基线:`/apikeys` 走 Django session + CSRF;登录用户可生成和删除自己的 Key,生成后的明文只在重定向后的首个页面显示一次,库内只保存 `key_hash` 与 `key_prefix`。用户端“删除”落库为 `revoked`,保留历史记录关联;吊销后的 Key 调用生成 / 余额接口返回 `403 account_disabled`,缺失、无效或不存在的 Key 仍返回 `401 unauthorized`。
通用错误响应: 通用错误响应:
```json ```json
+10 -8
View File
@@ -12,10 +12,10 @@
## 当前快照 ## 当前快照
- 日期:2026-07-03 - 日期:2026-07-03
- 阶段:Phase 4 用户端;Phase 3 对外 API 与充值已完成到 T-306,T-501 注册 / 登录(allauth)已完成,下一步 T-502 API Key 自助管理页 - 阶段:Phase 4 用户端;Phase 3 对外 API 与充值已完成到 T-306,T-501 注册 / 登录(allauth)与 T-502 API Key 自助管理页已完成,下一步 T-503 个人中心 / 记录页
- 技术栈:系统 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 落地注册登录;详见 `03-tech-stack.md` - 技术栈:系统 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 落地注册登录;详见 `03-tech-stack.md`
- 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器);T-102 已新增 `AiModel` / `ModelAlias`、Fernet 加密密钥存储、别名解析、admin 配置页、`import_ai_models` 导入命令;T-103 已新增 `AiConfigAuditLog` 审计表、admin 只读页面和后台保存/删除审计 hook;T-104/T-105 已完成录制 title/image smoke 与审核修补;T-201 已新增 `UserWallet` / `ApiKey`、`PointsLedger` / `CallRecord`、对应 admin 与迁移;T-202 已新增 `PricingRule` / `ExchangeRate`、`apps.billing.pricing` 计费计算函数、admin 配置页与迁移;T-203 已新增 `apps.billing.services`,实现并发安全预扣、成功确认与幂等失败退点;T-204 已新增 `billing.0003_pointsledger_unique_ledger_change_type_per_call`,用 MySQL 可落地的 `ref_call + change_type` 复合唯一约束兜底防重复 refund;T-301 已新增 `apps.api.authentication.ApiKeyAuthentication` 与 `ExternalApiView`;T-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 点钱包且不写赠点流水 - 生产代码:已有最小 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-501 已验证:`py -3.12 -m pip install -r requirements.txt` 安装 django-allauth 65.18.0;`py -3.12 manage.py check`、`py -3.12 -m py_compile config\settings.py config\urls.py apps\portal\adapters.py apps\portal\urls.py apps\portal\views.py apps\portal\tests.py`、`py -3.12 manage.py makemigrations --check --dry-run`、`py -3.12 manage.py migrate --noinput`、`py -3.12 -m compileall apps config` 均通过;`py -3.12 manage.py shell -c "from django.test import Client; r=Client().get('/login'); print(r.status_code)"` 返回 200,且命中项目内 portal 模板;`py -3.12 manage.py test apps.portal --noinput --keepdb --verbosity 2` 通过(5 tests OK);`py -3.12 manage.py test apps.users apps.portal --noinput --keepdb --verbosity 2` 通过(7 tests OK);首次完整 `py -3.12 manage.py test --noinput --keepdb --verbosity 2` 在 6 分钟工具超时内无结果,随后设置 `$env:MYSQL_CONNECT_TIMEOUT='90'; $env:MYSQL_READ_TIMEOUT='180'; $env:MYSQL_WRITE_TIMEOUT='180'` 后完整套件通过(95 tests OK,234.743s);修正模板优先级后再次完整重跑通过(95 tests OK,301.722s)。测试/迁移阶段有 allauth `account.EmailAddress` 条件唯一约束在 MySQL 上不可创建的 `models.W036` 警告;本项目用户账本唯一性仍由 `user.email` 唯一约束承担。 - 测试:T-502 已验证:`py -3.12 -m py_compile apps\portal\forms.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` 无变化;`py -3.12 -m compileall apps config` 通过;`py -3.12 manage.py test apps.portal --noinput --keepdb --verbosity 2` 通过(11 tests OK);`py -3.12 manage.py test apps.users apps.portal apps.api --noinput --keepdb --verbosity 2` 通过(52 tests OK);`./init.ps1` 通过;`git diff --check` 通过(仅 Windows CRLF 提示)。测试/迁移阶段仍有 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` 命令加密导入 - 数据: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 用 `./init.ps1`;Unix/WSL 用 `./init.sh`
- 标准验证路径:Windows 用 `py -3.12 manage.py check` / `py -3.12 manage.py test` - 标准验证路径: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` | | `init.sh` / `init.ps1` | 已有 | 启动验证入口,已固定系统 Python 3.12 命令,并校验解释器版本 `>=3.12,<3.14` |
| `requirements.txt` / `pyproject.toml` | 已有 | `requirements.txt` 管运行依赖;`pyproject.toml` 落地 `requires-python`;T-101 新增 `requests`;T-102 使用既有 `cryptography` 做 Fernet 加密;T-501 新增 `django-allauth` | | `requirements.txt` / `pyproject.toml` | 已有 | `requirements.txt` 管运行依赖;`pyproject.toml` 落地 `requires-python`;T-101 新增 `requests`;T-102 使用既有 `cryptography` 做 Fernet 加密;T-501 新增 `django-allauth` |
| `config/`(Django 工程) | 已有 | T-001 创建,含 settings / urls / wsgi / asgi | | `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-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 | | `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-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 创建表单、列表模板与删除(吊销)动作 |
| `manage.py` | 已有 | T-001 创建 | | `manage.py` | 已有 | T-001 创建 |
| `tests/` | 待建 | 随各任务补充 | | `tests/` | 待建 | 随各任务补充 |
@@ -41,10 +41,10 @@
任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史执行记录见 [`../progress.md`](../progress.md)。 任务状态以 [`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-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 自助管理页。
- 正在进行:无。 - 正在进行:无。
- 当前 blocker:远程 MySQL 连接当前不稳定或很慢;支付商户真实密钥/证书与生产 SDK 依赖仍待提供;真实 AI 上游 smoke 仍需配置密钥和模型数据后执行。 - 当前 blocker:远程 MySQL 连接当前不稳定或很慢;支付商户真实密钥/证书与生产 SDK 依赖仍待提供;真实 AI 上游 smoke 仍需配置密钥和模型数据后执行。
- 下一个可领取任务:**T-502 API Key 自助管理页**。 - 下一个可领取任务:**T-503 个人中心 / 记录页**。
## 当前可运行内容 ## 当前可运行内容
@@ -84,15 +84,17 @@ python3.12 manage.py smoke_ai_generation image --recorded
- `GET/POST /login` - `GET/POST /login`
- `GET/POST /logout` - `GET/POST /logout`
- `GET /dashboard` - `GET /dashboard`
- `GET/POST /apikeys`
- `POST /apikeys/<id>/delete`
当前骨架可运行。T-002 已在首次迁移前创建自定义 User,并按 `env.md` 接入 MySQL 8.4 / utf8mb4;远程 MySQL 已完成 Django 初始迁移。T-003 已接通 django-admin,测试可创建/销毁 `test_cmhub` 测试库;当前远程 MySQL 对频繁建库/销库仍可能间歇超时,必要时用 `--keepdb` 且串行跑测试。T-004 已应用 `users.0002_alter_user_email`,`user.email` 已有唯一索引。T-101 的 AI provider 层只做 HTTP 调用与响应解析;T-102 已把 provider 运行配置接到数据库 `AiModel` / `ModelAlias`,`resolve_alias()` 每次查当前 active 配置并按 `text` / `image` 能力校验。T-103 已补 `AiConfigAuditLog`,admin 保存/删除 `AiModel` / `ModelAlias` 时记录 actor、action、target、changed_fields、changes、created_at,密钥只记录 empty/set 状态。T-104/T-105 已用临时回滚配置跑通录制标题和录制图片生成。T-201 已落地钱包、API Key、点数流水和调用记录:API Key 明文只在创建 helper 返回,库内只存 hash/prefix;CallRecord 只存 `result_ref`/`result_summary`,没有 provider raw 字段。T-202 已落地 `PricingRule` / `ExchangeRate`:计费按 `operation_type + alias + resolution` 查 active 规则,优先精确分辨率,再回退默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`;金额换点数按当前 active 汇率向下取整。T-203 已落地 `precharge_call()` / `mark_call_success()` / `refund_call_points()`:预扣锁钱包行,余额不足不写调用/流水;失败退点锁调用记录并幂等写 refund 流水。T-204 已完成复合唯一约束加固,并取得一次完整 `manage.py test` 单次全绿。T-301 已落地 `Authorization: Bearer <API_KEY>` 鉴权:成功后 `request.user` 为所属用户、`request.auth` 为 `ApiKey`,缺失/无效 Key 返回 401,用户或 Key 禁用返回 403,外部 API 不接受 Web session。T-302 已落地生成接口:请求别名解析后按规则计费,预扣成功才调用 Provider,成功确认调用记录,`AiProviderError` / `AiCapabilityError` 等失败路径会退点;图片结果保存到本地 media 并返回 URL。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;`/dashboard` 作为登录落点只展示当前点数,完整个人中心留给 T-503。真实上游生成未执行,原因是当前环境未配置 `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 <API_KEY>` 鉴权:成功后 `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;`/dashboard` 作为登录落点展示当前点数和 API Key 管理入口。T-502 已落地 API Key 自助管理:`/apikeys` 登录访问,生成后完整明文只显示一次,列表只显示 prefix,不显示 hash 或历史明文;删除为吊销 `revoked`,吊销后外部 API 返回 403。完整个人中心与记录页留给 T-503。真实上游生成未执行,原因是当前环境未配置 `AI_KEY_ENCRYPTION_KEY` 且数据库没有 AiModel/ModelAlias;后续配置后可用 `import_ai_models` 导入,再通过接口跑真实标题/图片。
## 开始编码前检查 ## 开始编码前检查
1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。 1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。
2. 读 `docs/00-ai-start-here.md`。 2. 读 `docs/00-ai-start-here.md`。
3. 读 `docs/05-coding-rules.md`(尤其第 8 节资金安全)。 3. 读 `docs/05-coding-rules.md`(尤其第 8 节资金安全)。
4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-502)。 4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-503)。
5. 将该任务状态改为 `DOING`。 5. 将该任务状态改为 `DOING`。
## 维护规则 ## 维护规则
+3 -3
View File
@@ -1,7 +1,7 @@
# cmhub 项目介绍(给管理层) # cmhub 项目介绍(给管理层)
> 面向决策与汇报的项目概览。技术细节见同目录架构与需求文档。 > 面向决策与汇报的项目概览。技术细节见同目录架构与需求文档。
> 日期:2026-07-03 | 阶段:Phase 4 用户端(T-501 已完成,下一步 T-502) > 日期:2026-07-03 | 阶段:Phase 4 用户端(T-501/T-502 已完成,下一步 T-503)
## 一句话概括 ## 一句话概括
@@ -104,8 +104,8 @@
- Phase 1 已完成 T-101~T-104:AI Provider 适配器、AiModel/ModelAlias、Fernet 加密密钥存储、别名解析、配置审计与录制标题生成 smoke 已落地。 - Phase 1 已完成 T-101~T-104:AI Provider 适配器、AiModel/ModelAlias、Fernet 加密密钥存储、别名解析、配置审计与录制标题生成 smoke 已落地。
- Phase 2 计费核心已完成:PricingRule / ExchangeRate、计费计算、并发安全扣点与失败退点已落地,并覆盖并发测试。 - Phase 2 计费核心已完成:PricingRule / ExchangeRate、计费计算、并发安全扣点与失败退点已落地,并覆盖并发测试。
- Phase 3 对外 API 与充值已完成到 T-306:API Key 鉴权、生成/余额接口、扫码充值下单与轮询、支付回调幂等入账、`image_url` SSRF 防护、生成/认证限流和充值金额上限已落地。 - Phase 3 对外 API 与充值已完成到 T-306:API Key 鉴权、生成/余额接口、扫码充值下单与轮询、支付回调幂等入账、`image_url` SSRF 防护、生成/认证限流和充值金额上限已落地。
- Phase 4 已完成 T-501:用户端注册 / 登录(allauth)、邮箱验证、登出、最小 dashboard 与注册后 0 点钱包已落地。 - Phase 4 已完成 T-501/T-502:用户端注册 / 登录(allauth)、邮箱验证、登出、最小 dashboard、注册后 0 点钱包与 API Key 自助管理页已落地;Key 明文只显示一次,库内只保留 hash 和 prefix,删除即吊销。
- 下一步是 T-502:实现 API Key 自助管理页,生成时明文只显示一次,库存只保留哈希和 prefix。 - 下一步是 T-503:实现个人中心 / 记录页,展示剩余点数、充值总额、充值记录和消费记录,并保证仅见本人数据。
--- ---
*更多细节:愿景 `01-vision.md` | 需求与验收 `02-requirements.md` | 架构 `04-architecture.md` | 任务计划 `06-tasks.md`。* *更多细节:愿景 `01-vision.md` | 需求与验收 `02-requirements.md` | 架构 `04-architecture.md` | 任务计划 `06-tasks.md`。*
+1 -1
View File
@@ -40,7 +40,7 @@
## 进度 ## 进度
M1 骨架已完成;M2 已通过录制生成 smoke 验证 AI 调用链路;M3 计费、对外 API、充值闭环与上线前安全加固已完成到 T-306;Phase 4 已完成注册 / 登录(T-501)。下一步做 API Key 管理页,再做个人中心和充值页。里程碑:M1 骨架可跑 · M2 跑通生成 · M3 计费充值闭环 · M4 用户端可用 · M5 验收上线。 M1 骨架已完成;M2 已通过录制生成 smoke 验证 AI 调用链路;M3 计费、对外 API、充值闭环与上线前安全加固已完成到 T-306;Phase 4 已完成注册 / 登录(T-501)与 API Key 管理页(T-502)。下一步做个人中心 / 记录页,再做充值页。里程碑:M1 骨架可跑 · M2 跑通生成 · M3 计费充值闭环 · M4 用户端可用 · M5 验收上线。
--- ---
*详见 `project-brief.md`(完整介绍)。* *详见 `project-brief.md`(完整介绍)。*
+3 -3
View File
@@ -12,9 +12,9 @@
| `/recharge` | GET/POST | 发起充值:选金额→展示支付二维码→轮询到账 | session | | `/recharge` | GET/POST | 发起充值:选金额→展示支付二维码→轮询到账 | session |
| `/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 |
T-501 已落地 `/signup`、`/login`、`/logout` 与最小 `/dashboard`。`/dashboard` 当前只作为登录后的落点展示剩余点数;充值汇总、充值记录与消费记录留给 T-503。 T-501 已落地 `/signup`、`/login`、`/logout` 与最小 `/dashboard`。T-502 已落地 `/apikeys`:登录用户只能管理自己的 Key,生成后明文只显示一次,列表只显示 prefix,删除为吊销 `revoked`。`/dashboard` 当前只作为登录后的落点展示剩余点数与 API Key 管理入口;充值汇总、充值记录与消费记录留给 T-503。
## API 路由(对外,DRF) ## API 路由(对外,DRF)
@@ -51,7 +51,7 @@ T-501 已落地 `/signup`、`/login`、`/logout` 与最小 `/dashboard`。`/dash
### 用户与账号管理 ### 用户与账号管理
- 用户自助注册;API Key 由用户在用户端自助生成(服务端生成、哈希存储、明文只显示一次),运营侧只能查看 prefix / 吊销,不回显明文。 - 用户自助注册;API Key 由用户在用户端自助生成(服务端生成、哈希存储、明文只显示一次),运营侧只能查看 prefix / 吊销,不回显明文。
- 禁用用户或吊销 Key 后,相关调用一律 403 / 401。 - 禁用用户或吊销 Key 后,相关调用返回 403;无效、缺失或不存在的 Key 返回 401。
- 手工调整点数必须经计费层方法(写 `PointsLedger`、锁 `UserWallet`),不允许直接编辑 `points_balance` 字段。 - 手工调整点数必须经计费层方法(写 `PointsLedger`、锁 `UserWallet`),不允许直接编辑 `points_balance` 字段。
### 模型与别名管理 ### 模型与别名管理
+22
View File
@@ -753,3 +753,25 @@
- 阻塞:T-501 功能无阻塞。生产邮箱验证需要配置真实邮件服务;当前默认 console backend 只适合本地开发。allauth 的 `EmailAddress` 条件唯一约束在 MySQL 上有第三方模型警告,但本项目账本唯一性由 `user.email` 唯一约束承担。 - 阻塞:T-501 功能无阻塞。生产邮箱验证需要配置真实邮件服务;当前默认 console backend 只适合本地开发。allauth 的 `EmailAddress` 条件唯一约束在 MySQL 上有第三方模型警告,但本项目账本唯一性由 `user.email` 唯一约束承担。
- 决策:不引入 crispy-forms;T-501 只需要简单 allauth 表单,MVP 先用 Django form + Bootstrap 模板。注册路径只初始化 0 点钱包,不创建 `PointsLedger`,避免被误解为注册赠点。 - 决策:不引入 crispy-forms;T-501 只需要简单 allauth 表单,MVP 先用 Django form + Bootstrap 模板。注册路径只初始化 0 点钱包,不创建 `PointsLedger`,避免被误解为注册赠点。
- 下一步:领取 T-502 API Key 自助管理页。 - 下一步:领取 T-502 API Key 自助管理页。
## 2026-07-03 T-502 API Key 自助管理页
- 状态:DONE
- 变更:
- `apps/portal/forms.py`:新增 `ApiKeyCreateForm`,用于用户端填写 Key 名称。
- `apps/portal/views.py` / `urls.py`:新增 `/apikeys` 列表 + 生成页,以及 `/apikeys/<id>/delete` 删除动作;生成复用 `ApiKey.create_for_user()`,完整明文只放入 session 供重定向后的首个页面显示一次;删除写为 `revoked`,不物理删除。
- `apps/portal/templates/portal/apikeys.html` / `base.html` / `dashboard.html`:新增 API Key 管理页、导航入口和 dashboard 入口;列表只显示 prefix、名称、状态和时间。
- `apps/portal/tests.py`:新增 6 条用户端 API Key 测试,覆盖登录保护、明文只显示一次、库内只存 hash、列表只见本人 prefix、CSRF 保护、删除后外部 API 返回 403。
- 同步更新 `README.md`、`docs/00-ai-start-here.md`、`docs/03-tech-stack.md`、`docs/04-architecture.md`、`docs/api.md`、`docs/routes.md`、`docs/06-tasks.md`、`docs/current-state.md`、`docs/project-brief.md`、`docs/project-onepager.md`。
- 验证:
- `py -3.12 -m py_compile apps\portal\forms.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 test apps.portal --noinput --keepdb --verbosity 2`:通过,11 tests OK。
- `py -3.12 manage.py makemigrations --check --dry-run`:通过,No changes detected。
- `py -3.12 -m compileall apps config`:通过。
- `py -3.12 manage.py test apps.users apps.portal apps.api --noinput --keepdb --verbosity 2`:通过,52 tests OK。
- `./init.ps1`:通过,Python 3.12.3、依赖已满足、`manage.py check` 0 issues。
- `git diff --check`:通过,仅 Windows CRLF 提示。
- 阻塞:无。测试阶段仍有 allauth `account.EmailAddress` 条件唯一约束在 MySQL 上不可创建的 `models.W036` 警告;本项目账本邮箱唯一性由 `user.email` 唯一约束承担。
- 决策:T-502 原任务写“删除后该 Key 调用 401”,但项目架构和 T-301 已明确 `revoked` Key 返回 403、缺失/无效/不存在 Key 才返回 401;本轮将用户端“删除”定义为吊销 `revoked`,保留审计与调用记录关联,并同步修正文档口径。
- 下一步:领取 T-503 个人中心 / 记录页。