diff --git a/.env.example b/.env.example index adba1fb..2d71901 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,8 @@ DJANGO_SECRET_KEY=change-me-generate-a-random-secret DJANGO_DEBUG=true DJANGO_ALLOWED_HOSTS=127.0.0.1,localhost,testserver DJANGO_TIME_ZONE=Asia/Shanghai +DJANGO_EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend +DJANGO_DEFAULT_FROM_EMAIL=noreply@cmhub.local # MySQL 8.4 MYSQL_HOST=127.0.0.1 diff --git a/README.md b/README.md index b031086..b36171b 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:对外 API 可用 `Authorization: Bearer ` 调用生成能力或查询点数余额;用户端 session 流程可创建充值订单、获取二维码票据并轮询订单状态,充值回调按订单幂等入账;`image_url` 已做 SSRF 防护,生成/认证失败有限流,充值下单有单笔金额上限。下一步进入 Phase 4 用户端,先做 T-501 注册 / 登录(allauth)。详见 [`docs/current-state.md`](docs/current-state.md)。 +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)。 > ⚠️ 涉及资金/点数。改动充值、扣费、退款、对账相关代码前,先读 [`docs/05-coding-rules.md`](docs/05-coding-rules.md) 第 8 节与 [`docs/04-architecture.md`](docs/04-architecture.md) 第四节计费时序。 diff --git a/apps/portal/adapters.py b/apps/portal/adapters.py new file mode 100644 index 0000000..a77226e --- /dev/null +++ b/apps/portal/adapters.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from django.db import transaction + +from allauth.account.adapter import DefaultAccountAdapter + +from apps.users.models import UserWallet + + +class CmhubAccountAdapter(DefaultAccountAdapter): + def save_user(self, request, user, form, commit=True): + with transaction.atomic(): + saved_user = super().save_user(request, user, form, commit=commit) + if commit: + UserWallet.objects.get_or_create( + user=saved_user, + defaults={"points_balance": 0}, + ) + return saved_user diff --git a/apps/portal/templates/account/email/email_confirmation_message.txt b/apps/portal/templates/account/email/email_confirmation_message.txt new file mode 100644 index 0000000..ac46adf --- /dev/null +++ b/apps/portal/templates/account/email/email_confirmation_message.txt @@ -0,0 +1,7 @@ +你好, + +请打开下面的链接完成 cmhub 邮箱验证: + +{{ activate_url }} + +如果这不是你的操作,可以忽略本邮件。 diff --git a/apps/portal/templates/account/email/email_confirmation_subject.txt b/apps/portal/templates/account/email/email_confirmation_subject.txt new file mode 100644 index 0000000..40f4907 --- /dev/null +++ b/apps/portal/templates/account/email/email_confirmation_subject.txt @@ -0,0 +1 @@ +[cmhub] 验证你的邮箱 diff --git a/apps/portal/templates/account/login.html b/apps/portal/templates/account/login.html new file mode 100644 index 0000000..eec9b54 --- /dev/null +++ b/apps/portal/templates/account/login.html @@ -0,0 +1,21 @@ +{% extends "portal/base.html" %} + +{% block title %}登录 - cmhub{% endblock %} + +{% block content %} +
+

登录

+
+ {% csrf_token %} + {{ form.non_field_errors }} + {{ form.as_p }} + {% if redirect_field_value %} + + {% endif %} + +
+
+ 没有账号?注册 +
+
+{% endblock %} diff --git a/apps/portal/templates/account/logout.html b/apps/portal/templates/account/logout.html new file mode 100644 index 0000000..90d1214 --- /dev/null +++ b/apps/portal/templates/account/logout.html @@ -0,0 +1,13 @@ +{% extends "portal/base.html" %} + +{% block title %}退出 - cmhub{% endblock %} + +{% block content %} +
+

退出登录

+
+ {% csrf_token %} + +
+
+{% endblock %} diff --git a/apps/portal/templates/account/signup.html b/apps/portal/templates/account/signup.html new file mode 100644 index 0000000..c8d5609 --- /dev/null +++ b/apps/portal/templates/account/signup.html @@ -0,0 +1,21 @@ +{% extends "portal/base.html" %} + +{% block title %}注册 - cmhub{% endblock %} + +{% block content %} +
+

注册

+
+ {% csrf_token %} + {{ form.non_field_errors }} + {{ form.as_p }} + {% if redirect_field_value %} + + {% endif %} + +
+
+ 已有账号?登录 +
+
+{% endblock %} diff --git a/apps/portal/templates/account/verification_sent.html b/apps/portal/templates/account/verification_sent.html new file mode 100644 index 0000000..4f3c130 --- /dev/null +++ b/apps/portal/templates/account/verification_sent.html @@ -0,0 +1,10 @@ +{% extends "portal/base.html" %} + +{% block title %}验证邮箱 - cmhub{% endblock %} + +{% block content %} +
+

验证邮箱

+

验证邮件已发送,请先完成邮箱验证再登录。

+
+{% endblock %} diff --git a/apps/portal/templates/portal/base.html b/apps/portal/templates/portal/base.html new file mode 100644 index 0000000..6c1d5f2 --- /dev/null +++ b/apps/portal/templates/portal/base.html @@ -0,0 +1,99 @@ + + + + + + {% block title %}cmhub{% endblock %} + + + + + +
+ {% if messages %} +
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% block content %}{% endblock %} +
+ + diff --git a/apps/portal/templates/portal/dashboard.html b/apps/portal/templates/portal/dashboard.html new file mode 100644 index 0000000..2c34f37 --- /dev/null +++ b/apps/portal/templates/portal/dashboard.html @@ -0,0 +1,20 @@ +{% extends "portal/base.html" %} + +{% block title %}控制台 - cmhub{% endblock %} + +{% block content %} +
+
+

控制台

+
{{ user.email }}
+
+
+
+
+
剩余点数
+
{{ balance.points_balance }}
+
+
+
+
+{% endblock %} diff --git a/apps/portal/tests.py b/apps/portal/tests.py index 7ce503c..4d65cdc 100644 --- a/apps/portal/tests.py +++ b/apps/portal/tests.py @@ -1,3 +1,114 @@ -from django.test import TestCase +import uuid -# Create your tests here. +from allauth.account.models import EmailAddress +from django.contrib.auth import get_user, get_user_model +from django.core import mail +from django.core.cache import cache +from django.test import Client, TestCase, override_settings + +from apps.billing.models import PointsLedger +from apps.users.models import UserWallet + + +@override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend") +class PortalAccountFlowTests(TestCase): + password = "Strong-pass-123" + + def setUp(self): + cache.clear() + + def create_verified_user(self): + suffix = uuid.uuid4().hex[:8] + user = get_user_model().objects.create_user( + username=f"portal-user-{suffix}", + email=f"portal-user-{suffix}@example.com", + password=self.password, + ) + EmailAddress.objects.create( + user=user, + email=user.email, + verified=True, + primary=True, + ) + return user + + 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" + + response = self.client.post( + "/signup", + { + "username": f"signup-{suffix}", + "email": email, + "password1": self.password, + "password2": self.password, + }, + ) + + self.assertEqual(response.status_code, 302) + user = get_user_model().objects.get(email=email) + email_address = EmailAddress.objects.get(user=user, email=email) + wallet = UserWallet.objects.get(user=user) + self.assertFalse(email_address.verified) + self.assertTrue(email_address.primary) + self.assertEqual(wallet.points_balance, 0) + self.assertFalse(PointsLedger.objects.filter(user=user).exists()) + self.assertEqual(len(mail.outbox), 1) + + def test_unverified_email_cannot_establish_login_session(self): + suffix = uuid.uuid4().hex[:8] + user = get_user_model().objects.create_user( + username=f"unverified-{suffix}", + email=f"unverified-{suffix}@example.com", + password=self.password, + ) + EmailAddress.objects.create( + user=user, + email=user.email, + verified=False, + primary=True, + ) + + self.client.post("/login", {"login": user.email, "password": self.password}) + + self.assertFalse(get_user(self.client).is_authenticated) + + def test_verified_user_can_login_view_dashboard_and_logout(self): + user = self.create_verified_user() + UserWallet.objects.create(user=user, points_balance=42) + + login_response = self.client.post( + "/login", + {"login": user.email, "password": self.password}, + ) + + self.assertEqual(login_response.status_code, 302) + self.assertEqual(login_response["Location"], "/dashboard") + self.assertTrue(get_user(self.client).is_authenticated) + + dashboard_response = self.client.get("/dashboard") + self.assertEqual(dashboard_response.status_code, 200) + self.assertContains(dashboard_response, user.email) + self.assertContains(dashboard_response, "42") + + logout_response = self.client.post("/logout") + self.assertEqual(logout_response.status_code, 302) + self.assertEqual(logout_response["Location"], "/login") + self.assertFalse(get_user(self.client).is_authenticated) + + def test_dashboard_requires_session_login(self): + response = self.client.get("/dashboard") + + self.assertEqual(response.status_code, 302) + self.assertTrue(response["Location"].startswith("/login?next=")) + + def test_login_post_is_csrf_protected(self): + csrf_client = Client(enforce_csrf_checks=True) + + response = csrf_client.post( + "/login", + {"login": "nobody@example.com", "password": "password"}, + ) + + self.assertEqual(response.status_code, 403) diff --git a/apps/portal/urls.py b/apps/portal/urls.py new file mode 100644 index 0000000..e85147d --- /dev/null +++ b/apps/portal/urls.py @@ -0,0 +1,13 @@ +from allauth.account.views import LoginView, LogoutView, SignupView +from django.urls import path +from django.views.generic import RedirectView + +from .views import DashboardView + +urlpatterns = [ + path("", RedirectView.as_view(pattern_name="portal-dashboard", permanent=False), name="portal-home"), + path("signup", SignupView.as_view(), name="portal-signup"), + path("login", LoginView.as_view(), name="portal-login"), + path("logout", LogoutView.as_view(), name="portal-logout"), + path("dashboard", DashboardView.as_view(), name="portal-dashboard"), +] diff --git a/apps/portal/views.py b/apps/portal/views.py index 91ea44a..b53bdf6 100644 --- a/apps/portal/views.py +++ b/apps/portal/views.py @@ -1,3 +1,13 @@ -from django.shortcuts import render +from django.contrib.auth.mixins import LoginRequiredMixin +from django.views.generic import TemplateView -# Create your views here. +from apps.billing.services import get_balance_snapshot + + +class DashboardView(LoginRequiredMixin, TemplateView): + template_name = "portal/dashboard.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["balance"] = get_balance_snapshot(self.request.user) + return context diff --git a/config/settings.py b/config/settings.py index c5f2ac1..68495a4 100644 --- a/config/settings.py +++ b/config/settings.py @@ -76,6 +76,11 @@ PAYMENT_MOCK_CALLBACK_SECRET = os.environ.get( "cmhub-dev-mock-callback-secret" if DEBUG else "", ) PAYMENT_QR_EXPIRES_MINUTES = env_int("PAYMENT_QR_EXPIRES_MINUTES", 10) +EMAIL_BACKEND = os.environ.get( + "DJANGO_EMAIL_BACKEND", + "django.core.mail.backends.console.EmailBackend", +) +DEFAULT_FROM_EMAIL = os.environ.get("DJANGO_DEFAULT_FROM_EMAIL", "noreply@cmhub.local") WECHAT_PAY_APPID = os.environ.get("WECHAT_PAY_APPID", "") WECHAT_PAY_MCHID = os.environ.get("WECHAT_PAY_MCHID", "") WECHAT_PAY_API_V3_KEY = os.environ.get("WECHAT_PAY_API_V3_KEY", "") @@ -108,6 +113,8 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'allauth', + 'allauth.account', 'rest_framework', 'apps.users', 'apps.portal', @@ -135,6 +142,7 @@ MIDDLEWARE = [ 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', + 'allauth.account.middleware.AccountMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] @@ -143,7 +151,7 @@ ROOT_URLCONF = 'config.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], + 'DIRS': [BASE_DIR / 'apps' / 'portal' / 'templates'], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ @@ -158,6 +166,24 @@ TEMPLATES = [ WSGI_APPLICATION = 'config.wsgi.application' AUTH_USER_MODEL = 'users.User' +AUTHENTICATION_BACKENDS = [ + 'django.contrib.auth.backends.ModelBackend', + 'allauth.account.auth_backends.AuthenticationBackend', +] + +LOGIN_URL = "/login" +LOGIN_REDIRECT_URL = "/dashboard" +LOGOUT_REDIRECT_URL = "/login" + +ACCOUNT_ADAPTER = "apps.portal.adapters.CmhubAccountAdapter" +ACCOUNT_EMAIL_VERIFICATION = "mandatory" +ACCOUNT_UNIQUE_EMAIL = True +ACCOUNT_LOGIN_METHODS = {"email", "username"} +ACCOUNT_SIGNUP_FIELDS = ["username*", "email*", "password1*", "password2*"] +ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = True +ACCOUNT_EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL = "/login" +ACCOUNT_EMAIL_CONFIRMATION_AUTHENTICATED_REDIRECT_URL = "/dashboard" +ACCOUNT_EMAIL_SUBJECT_PREFIX = "[cmhub] " # Database diff --git a/config/urls.py b/config/urls.py index bf74410..43b597d 100644 --- a/config/urls.py +++ b/config/urls.py @@ -20,6 +20,8 @@ from django.contrib import admin from django.urls import include, path urlpatterns = [ + path('', include('apps.portal.urls')), + path('accounts/', include('allauth.urls')), path('admin/', admin.site.urls), path('api/', include('apps.api.urls')), ] diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 61b253b..89605c7 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -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 安全加固。下一步进入 T-501 注册 / 登录(allauth)。 +当前项目处于:**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 自助管理页。 优先路径: @@ -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 前补测。 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 安全加固已完成。 -5. Phase 4:用户端(Django 模板 SSR)—— 下一步 T-501 注册登录,然后 API Key 管理、个人中心/记录页、充值页。 +5. Phase 4:用户端(Django 模板 SSR)—— T-501 注册登录已完成,下一步 T-502 API Key 管理,然后个人中心/记录页、充值页。 6. Phase 5:后台与发布 —— 运营后台完善、完整验收、部署 / 运行文档。 ## 领取任务规则 diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index 3d0af6c..4c4bab8 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -11,7 +11,7 @@ | Web 框架 | Django 5.2 LTS | 已定 | 自带 ORM、迁移、admin,适合「API + 运营后台」;选 LTS 维护到 2028,安全更新窗口最长。**禁用已 EOL 的 4.0/4.1**(无安全补丁,资金服务不可用) | | API 框架 | Django REST Framework (DRF) | 已定 | 鉴权、序列化、参数校验、限流现成 | | 运营后台 | django-admin | 已定 | 近零代码即得用户/点数/记录的增删改查与检索,省 80% 后台工作量 | -| 用户端 | Django 模板 SSR + Bootstrap 5 + django-allauth + crispy-forms | 已定 | 自助注册/登录/充值/API Key 管理/记录页;allauth 出注册登录邮箱验证,crispy + 现成 Bootstrap 模板出页面,单体不引前端框架 | +| 用户端 | Django 模板 SSR + Bootstrap 5 + django-allauth | 已定 | T-501 已落地自助注册/登录/登出、邮箱验证与最小 dashboard;MVP 先用 Django form + Bootstrap 模板,不为简单表单引入 crispy-forms | | 后台美化 | django-unfold 或 simpleui | 待定 | 仅外观,MVP 可先用原生 admin,后期按需引入 | | AI 上游对接 | **Provider 适配器层**(按 `api_type` 注册)+ **能力别名** 映射 + `requests` HTTP 客户端 | 已定 | 对外只暴露 `generate text/image` 两接口与别名;换供应商改后台映射,不动对外契约。移植 `cmbot` 的调用逻辑到各适配器。当前 3 模型机制不同:文本 chat、`nano-banana2` chat 多模态返图、`gpt-image-2` images/edits 改图(详见 `04` 3.1) | | 供应商密钥存储 | 应用层 Fernet 加密(`cryptography`) | 已定 | `AiModel.api_key_encrypted` 加密入库、admin 写入型字段不回显;加密主密钥 `AI_KEY_ENCRYPTION_KEY` 走环境变量,配置清单见 `env.md` | @@ -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 驱动 | 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 绕过计费 | -| 用户端鉴权 | Django Session(+ allauth 注册登录邮箱验证) | 已定 | 用户端页面与 `recharge/create` 走 session + CSRF;后台账号也用 Session 登录 | +| 用户端鉴权 | Django Session(+ allauth 注册登录邮箱验证) | 已定 | T-501 已落地 `/signup` `/login` `/logout` 与 `/dashboard`;用户端页面与 `recharge/create` 走 session + CSRF;后台账号也用 Session 登录 | | 充值对接 | 自助扫码:微信 V3 native + 支付宝当面付;下单取二维码 + 服务端回调(验签 + 幂等) | 已定 | T-304/T-305 已落地回调、扫码下单、状态轮询、HMAC mock 联调与 SDK 模式入口;生产需安装并配置 `wechatpayv3` / `python-alipay-sdk` 与真实商户密钥/证书 | | 生成返回方式 | 同步 HTTP(无任务队列) | 已定 | MVP 简化;图片接口需调大网关/服务超时 | | 任务队列 | 暂不引入(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) 第五节结论。 - **何时转 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。 -- **用户端用 Django 模板 SSR 单体,不引前端框架**:需求含终端用户自助(注册/充值/API Key/记录),选 Django 模板 + Bootstrap + allauth 与后端同工程单体部署,复用 Django auth/session,开发部署最快、最契合单机 MVP;代价是交互不如 SPA,可后续加 HTMX。放弃 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`,不写赠点流水。放弃 Vue/React 前后端分离(两套项目/部署,与单体 MVP 调性冲突)。用户模型:`User`(auth) 持登录态、`UserWallet` 持点数(扣点锁 wallet、与 auth 解耦)、`ApiKey`(User 1:N,哈希存储)。注册不送免费点数。 ## 三、构建与运行命令 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index ff227ba..1755833 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -18,7 +18,7 @@ 组件落位: -- **用户端层(Django 模板 SSR)**:注册/登录(Django auth / allauth)、个人中心(余额/充值总额/充值记录/消费记录)、API Key 自助管理、发起扫码充值。入口 `apps/portal/`,用 session 鉴权。 +- **用户端层(Django 模板 SSR)**:注册/登录(Django auth / allauth)、个人中心(余额/充值总额/充值记录/消费记录)、API Key 自助管理、发起扫码充值。入口 `apps/portal/`,用 session 鉴权。T-501 已落地 `/signup`、`/login`、`/logout` 与最小 `/dashboard`;注册成功通过 allauth adapter 创建 0 点 `UserWallet`,不写赠点流水。 - **用户与账号层**:注册用户 `User`、点数钱包 `UserWallet`、`ApiKey`(一用户多把、哈希存储)。入口 `apps/users/`。 - **API 层(DRF)**:对外生成接口、余额查询、支付回调接收、扫码下单。入口 `apps/api/`。生成/余额这类对外业务 API **只认 API Key,不接受 Web session**;充值下单/状态查询属于用户端流程,走 Web session + CSRF;支付回调走平台验签。 - **计费层**:点数计算、原子扣减(锁 `UserWallet` 行)、退点、充值入账、流水记账。入口 `apps/billing/`。 @@ -272,7 +272,7 @@ CREATE TABLE call_record ( - `payment_user_id`、`payment_txn_no` 为对账预留,字段先建。 - `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` 状态,避免调用结果与账务动作混在一个字段里。 -- 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-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`,避免把注册初始化误记为赠点或充值。 ## 四、计费时序(核心,务必照此实现) @@ -365,7 +365,7 @@ CREATE TABLE call_record ( 2. 移植并跑通一次 AI 调用(标题 / 图片)原型(Phase 1)。 3. 计费:点数扣减(并发安全)+ 计费规则 + 调用记录(Phase 2,T-201~T-203 已完成)。 4. 对外 API 鉴权 + 余额查询 + 充值下单/回调 + 安全加固(Phase 3,已完成到 T-306)。 -5. 用户端注册登录、API Key 管理、个人中心、充值页(Phase 4)。 +5. 用户端注册登录、API Key 管理、个人中心、充值页(Phase 4;T-501 已完成注册登录,下一步 T-502 API Key 管理)。 6. 运营后台完善、完整验收、部署(Phase 5)。 ## 七、项目结构建议 diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 2cccfdd..1c46108 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -61,7 +61,7 @@ | ID | 任务 | 依赖 | 验收要点 | 状态 | | --- | --- | --- | --- | --- | -| T-501 | 注册 / 登录(allauth) | T-201 | 自助注册(邮箱验证)、登录、登出;注册后钱包点数为 0(不送点数);session + CSRF | TODO | +| T-501 | 注册 / 登录(allauth) | T-201 | 自助注册(邮箱验证)、登录、登出;注册后钱包点数为 0(不送点数);session + CSRF | DONE | | T-502 | API Key 自助管理页 | T-501, T-301 | 登录用户生成/删除 Key;明文只显示一次、库存哈希;列表只显示 prefix;删除后该 Key 调用 401 | TODO | | T-503 | 个人中心 / 记录页 | T-501, T-203 | 剩余点数、充值总额、充值记录、消费(调用)记录;数据与流水一致;仅见本人 | TODO | | T-504 | 充值页(扫码 + 轮询到账) | T-501, T-305 | 发起充值→展示二维码→轮询订单状态→到账后余额刷新;到账以回调为权威 | TODO | diff --git a/docs/api.md b/docs/api.md index b178328..264814c 100644 --- a/docs/api.md +++ b/docs/api.md @@ -29,6 +29,8 @@ T-305 已实现扫码充值下单与轮询基线:`POST /api/v1/recharge/create T-306 已实现对外 API 安全加固:`image_url` 下载只允许 `http` / `https` 公网地址,拒绝私有/回环/链路本地/保留等地址,重定向后重新校验并限制响应大小;全局 DRF 默认认证为空且默认权限为 `IsAuthenticated`,外部 API 与用户端 API 必须显式 opt-in 认证类;生成接口按 API Key / 用户限流,认证失败按 IP 限流;充值创建有 `RECHARGE_MAX_AMOUNT_CNY` 单笔上限。 +T-501 已实现用户端注册 / 登录基线:`/signup` `/login` `/logout` 走 django-allauth + Django session + CSRF;注册邮箱必须验证,注册成功创建 0 点 `UserWallet`,不创建赠点流水。对外 API 仍只认 API Key,不接受 Web session。 + 通用错误响应: ```json diff --git a/docs/current-state.md b/docs/current-state.md index 5b36437..c687288 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -12,15 +12,15 @@ ## 当前快照 - 日期:2026-07-03 -- 阶段:Phase 4 用户端起步;Phase 3 对外 API 与充值已完成到 T-306,下一步 T-501 注册 / 登录(allauth) -- 技术栈:系统 Python 3.12.3 + Django 5.2.15 + DRF 3.16.1 + PyMySQL 1.1.3 + cryptography 46.0.7 + requests 2.34.2 + django-admin;MySQL 8.4 已接入 settings,并支持 `MYSQL_CONNECT_TIMEOUT` / `MYSQL_READ_TIMEOUT` / `MYSQL_WRITE_TIMEOUT`;用户端(模板 SSR/Bootstrap/allauth) 后续任务落地;详见 `03-tech-stack.md` -- 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器);T-102 已新增 `AiModel` / `ModelAlias`、Fernet 加密密钥存储、别名解析、admin 配置页、`import_ai_models` 导入命令;T-103 已新增 `AiConfigAuditLog` 审计表、admin 只读页面和后台保存/删除审计 hook;T-104/T-105 已完成录制 title/image smoke 与审核修补;T-201 已新增 `UserWallet` / `ApiKey`、`PointsLedger` / `CallRecord`、对应 admin 与迁移;T-202 已新增 `PricingRule` / `ExchangeRate`、`apps.billing.pricing` 计费计算函数、admin 配置页与迁移;T-203 已新增 `apps.billing.services`,实现并发安全预扣、成功确认与幂等失败退点;T-204 已新增 `billing.0003_pointsledger_unique_ledger_change_type_per_call`,用 MySQL 可落地的 `ref_call + change_type` 复合唯一约束兜底防重复 refund;T-301 已新增 `apps.api.authentication.ApiKeyAuthentication` 与 `ExternalApiView`;T-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-306 已验证:`py -3.12 -m py_compile config/settings.py apps/api/exceptions.py apps/api/generation.py apps/api/authentication.py apps/api/views.py apps/api/serializers.py apps/api/throttles.py apps/api/tests.py`、`py -3.12 manage.py check`、`py -3.12 manage.py makemigrations --check --dry-run`、`py -3.12 -m compileall apps config`、`./init.ps1` 均通过;`py -3.12 manage.py test apps.api --noinput --keepdb --verbosity 2` 通过(39 tests OK,含 SSRF、限流、充值上限);认证/生成限流单点补测通过(2 tests OK)。`py -3.12 manage.py test apps.billing apps.api --noinput --keepdb --verbosity 2` 未作为绿灯:62 tests 跑到末尾,功能用例输出均为 ok,最后远程 MySQL 在并发扣点测试的 flush/重连阶段 reset 连接;随后 `$env:MYSQL_CONNECT_TIMEOUT='90'; py -3.12 manage.py test apps.billing.tests.ConcurrentDebitTests --noinput --keepdb --verbosity 2` 通过(1 test OK)。 +- 阶段:Phase 4 用户端;Phase 3 对外 API 与充值已完成到 T-306,T-501 注册 / 登录(allauth)已完成,下一步 T-502 API Key 自助管理页 +- 技术栈:系统 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 点钱包且不写赠点流水 +- 测试: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` 唯一约束承担。 - 数据:AI 上游调用与模型配置参考 `D:\chengma\cmbot`(`src/services/ai_text_service.py`、`ai_image_service.py`、`config/ai_models.json`);真实 `ai_models.json` 不提交,需通过 `import_ai_models` 命令加密导入 - 标准启动路径:Windows 用 `./init.ps1`;Unix/WSL 用 `./init.sh` - 标准验证路径:Windows 用 `py -3.12 manage.py check` / `py -3.12 manage.py test` - 设计基线:**自助用户端 + 对外 API + 运营后台**三合一单体;用户模型 `User`(auth)/`UserWallet`(点数,锁 wallet 扣点)/`ApiKey`(1:N,哈希存储);对外两接口 + **能力别名 + Provider 适配器**(可插拔供应商);自助扫码充值;注册不送点数。详见 `04-architecture.md` 与 2026-06-29 / 2026-07-01 的 `progress.md` 决策 -- 配置基线:运行环境变量集中见 `docs/env.md`;真实密钥/支付凭证不得写入代码或文档样例。充值订单在创建时锁定汇率与预计点数,回调入账使用订单值,不按新汇率重算。支付回调与下单由 `PAYMENT_CALLBACK_MODE` 控制:本地/测试可用 HMAC `mock`,生产应为 `sdk`;二维码本地有效期提示由 `PAYMENT_QR_EXPIRES_MINUTES` 控制。T-306 起对外 API 安全配置包含 `API_GENERATE_THROTTLE_RATE`、`API_AUTH_FAILURE_THROTTLE_RATE`、`IMAGE_URL_MAX_BYTES`、`IMAGE_URL_MAX_REDIRECTS`、`IMAGE_URL_CONNECT_TIMEOUT_SECONDS`、`IMAGE_URL_READ_TIMEOUT_SECONDS`、`RECHARGE_MAX_AMOUNT_CNY`。 +- 配置基线:运行环境变量集中见 `docs/env.md`;真实密钥/支付凭证不得写入代码或文档样例。allauth 邮箱验证发信由 `DJANGO_EMAIL_BACKEND` / `DJANGO_DEFAULT_FROM_EMAIL` 控制,本地默认 console backend,生产需配置真实邮件服务。充值订单在创建时锁定汇率与预计点数,回调入账使用订单值,不按新汇率重算。支付回调与下单由 `PAYMENT_CALLBACK_MODE` 控制:本地/测试可用 HMAC `mock`,生产应为 `sdk`;二维码本地有效期提示由 `PAYMENT_QR_EXPIRES_MINUTES` 控制。T-306 起对外 API 安全配置包含 `API_GENERATE_THROTTLE_RATE`、`API_AUTH_FAILURE_THROTTLE_RATE`、`IMAGE_URL_MAX_BYTES`、`IMAGE_URL_MAX_REDIRECTS`、`IMAGE_URL_CONNECT_TIMEOUT_SECONDS`、`IMAGE_URL_READ_TIMEOUT_SECONDS`、`RECHARGE_MAX_AMOUNT_CNY`。 - 当前 blocker:T-306 上线前 SSRF 风险已修复;远程 MySQL 连接仍可能很慢或间歇超时,完整测试需预留较长时间并优先用 `--keepdb` 串行跑。微信/支付宝真实商户密钥/证书与生产 SDK 依赖仍待提供/安装;真实 AI 上游 smoke 需要先配置 `AI_KEY_ENCRYPTION_KEY` 并导入 AiModel/ModelAlias。图片同步真实耗时风险仍未退,已登记到 T-403。 ## 当前目录要点 @@ -31,9 +31,9 @@ | `AGENTS.md` / `CLAUDE.md` | 已有 | 仓库级入口 | | `progress.md` | 已有 | 执行流水,已记录多轮文档决策;后续任务继续追加 | | `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 加密 | +| `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 | -| `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 错误响应 | +| `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 | | `manage.py` | 已有 | T-001 创建 | | `tests/` | 待建 | 随各任务补充 | @@ -41,10 +41,10 @@ 任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史执行记录见 [`../progress.md`](../progress.md)。 -- 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-103 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补;T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型;T-202 PricingRule / ExchangeRate 模型 + 计费计算;T-203 并发安全扣点 / 退点;T-204 Phase 2 计费核心审核加固;T-301 API Key 鉴权;T-302 生成标题 / 图片接口;T-303 余额查询接口;T-304 充值回调;T-305 扫码充值下单 + 轮询;T-306 Phase 3 对外 API 安全加固。 +- 已完成: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)。 - 正在进行:无。 - 当前 blocker:远程 MySQL 连接当前不稳定或很慢;支付商户真实密钥/证书与生产 SDK 依赖仍待提供;真实 AI 上游 smoke 仍需配置密钥和模型数据后执行。 -- 下一个可领取任务:**T-501 注册 / 登录(allauth)**。 +- 下一个可领取任务:**T-502 API Key 自助管理页**。 ## 当前可运行内容 @@ -78,14 +78,21 @@ python3.12 manage.py smoke_ai_generation image --recorded - `POST /api/v1/recharge/callback/wechat` - `POST /api/v1/recharge/callback/alipay` -当前骨架可运行。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 限流;充值下单有单笔金额上限。真实上游生成未执行,原因是当前环境未配置 `AI_KEY_ENCRYPTION_KEY` 且数据库没有 AiModel/ModelAlias;后续配置后可用 `import_ai_models` 导入,再通过接口跑真实标题/图片。 +当前用户端页面: + +- `GET/POST /signup` +- `GET/POST /login` +- `GET/POST /logout` +- `GET /dashboard` + +当前骨架可运行。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;`/dashboard` 作为登录落点只展示当前点数,完整个人中心留给 T-503。真实上游生成未执行,原因是当前环境未配置 `AI_KEY_ENCRYPTION_KEY` 且数据库没有 AiModel/ModelAlias;后续配置后可用 `import_ai_models` 导入,再通过接口跑真实标题/图片。 ## 开始编码前检查 1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。 2. 读 `docs/00-ai-start-here.md`。 3. 读 `docs/05-coding-rules.md`(尤其第 8 节资金安全)。 -4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-501)。 +4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-502)。 5. 将该任务状态改为 `DOING`。 ## 维护规则 diff --git a/docs/env.md b/docs/env.md index 87716a7..5507a15 100644 --- a/docs/env.md +++ b/docs/env.md @@ -19,6 +19,8 @@ | `DJANGO_ALLOWED_HOSTS` | 是 | `cmhub.example.com,127.0.0.1` | 逗号分隔 | | `DJANGO_CSRF_TRUSTED_ORIGINS` | 生产是 | `https://cmhub.example.com` | 用户端表单、admin、充值页需要 | | `DJANGO_TIME_ZONE` | 否 | `Asia/Shanghai` | 默认按中国业务时区 | +| `DJANGO_EMAIL_BACKEND` | 否 | `django.core.mail.backends.console.EmailBackend` | allauth 注册邮箱验证发信后端;生产应改为真实 SMTP / 邮件服务 | +| `DJANGO_DEFAULT_FROM_EMAIL` | 生产是 | `noreply@cmhub.example.com` | allauth 邮件默认发件人 | ## 三、数据库配置 diff --git a/docs/phase-2-review.md b/docs/phase-2-review.md index 1fdc844..d09a097 100644 --- a/docs/phase-2-review.md +++ b/docs/phase-2-review.md @@ -60,7 +60,7 @@ ### P3 · 登记 / 后续任务(不在 T-204 硬性范围) - **手工调点服务未实现**(§8「运营手工调整必须填原因」):模型层已备好(`PointsLedger` ADJUST 类型的 `clean()` 强制要求 `reason`),wallet admin 已锁死不可直接改余额(安全的中间态);但还缺一个 billing 层 `adjust_points()`(锁 wallet + 写 adjust 流水 + reason)供后台用。→ 登记 **T-401**。 -- **wallet 在扣点热路径 `get_or_create`**:`precharge_call` 用 `select_for_update().get_or_create(user=...)`,会在「钱包缺失」时静默建零钱包再报余额不足,掩盖异常。应保证注册(T-501)时建钱包;billing 层遇缺失可考虑显式报错。→ 登记 **T-501**。 +- **wallet 在扣点热路径 `get_or_create`**:`precharge_call` 用 `select_for_update().get_or_create(user=...)`,会在「钱包缺失」时静默建零钱包再报余额不足,掩盖异常。T-501 已保证 allauth 自助注册路径创建 0 点钱包;billing 层遇缺失是否改为显式报错仍可在后续计费加固中评估。 - **锁顺序约定**:`refund_call_points` 锁序为 (CallRecord → wallet),`precharge_call` 只锁 wallet。当前无跨序死锁;但将来若新增「先锁 wallet 再锁 CallRecord」路径需警惕死锁。→ 作为约定登记。 - **只追加靠 admin + 无 UPDATE 代码保证**:`PointsLedger`/`CallRecord` 的不可改目前由 admin 只读 + 服务层不写 update 保证,DB 层无触发器阻止 UPDATE/DELETE。Django 层已足够,登记备忘即可。 - **`OperationType` 双份枚举**:`apps.ai.ModelAlias.OperationType` 与 `apps.billing.CallRecord.OperationType` 各定义一份 title/image,语义重复。极小的 DRY 隐患,后续可抽公共枚举。 diff --git a/docs/project-brief.md b/docs/project-brief.md index 833c254..544ea24 100644 --- a/docs/project-brief.md +++ b/docs/project-brief.md @@ -1,7 +1,7 @@ # cmhub 项目介绍(给管理层) > 面向决策与汇报的项目概览。技术细节见同目录架构与需求文档。 -> 日期:2026-07-03 | 阶段:Phase 4 起步(T-501 注册 / 登录) +> 日期:2026-07-03 | 阶段:Phase 4 用户端(T-501 已完成,下一步 T-502) ## 一句话概括 @@ -104,7 +104,8 @@ - Phase 1 已完成 T-101~T-104:AI Provider 适配器、AiModel/ModelAlias、Fernet 加密密钥存储、别名解析、配置审计与录制标题生成 smoke 已落地。 - Phase 2 计费核心已完成:PricingRule / ExchangeRate、计费计算、并发安全扣点与失败退点已落地,并覆盖并发测试。 - Phase 3 对外 API 与充值已完成到 T-306:API Key 鉴权、生成/余额接口、扫码充值下单与轮询、支付回调幂等入账、`image_url` SSRF 防护、生成/认证限流和充值金额上限已落地。 -- 下一步是 T-501:实现用户端注册 / 登录(allauth),注册后钱包点数为 0,不送免费点数。 +- Phase 4 已完成 T-501:用户端注册 / 登录(allauth)、邮箱验证、登出、最小 dashboard 与注册后 0 点钱包已落地。 +- 下一步是 T-502:实现 API Key 自助管理页,生成时明文只显示一次,库存只保留哈希和 prefix。 --- *更多细节:愿景 `01-vision.md` | 需求与验收 `02-requirements.md` | 架构 `04-architecture.md` | 任务计划 `06-tasks.md`。* diff --git a/docs/project-onepager.md b/docs/project-onepager.md index 90c6286..1c42b63 100644 --- a/docs/project-onepager.md +++ b/docs/project-onepager.md @@ -1,6 +1,6 @@ # cmhub · 一页汇报版 -> 自助用户端 + 计费型 AI 能力网关 + 运营后台 | 2026-07-03 | Phase 4 起步 +> 自助用户端 + 计费型 AI 能力网关 + 运营后台 | 2026-07-03 | Phase 4 用户端 ## 电梯陈述(30 秒) @@ -40,7 +40,7 @@ ## 进度 -M1 骨架已完成;M2 已通过录制生成 smoke 验证 AI 调用链路;M3 计费、对外 API、充值闭环与上线前安全加固已完成到 T-306。下一步进入 Phase 4 用户端,先做注册 / 登录,再做 API Key 管理、个人中心和充值页。里程碑:M1 骨架可跑 · M2 跑通生成 · M3 计费充值闭环 · M4 用户端可用 · M5 验收上线。 +M1 骨架已完成;M2 已通过录制生成 smoke 验证 AI 调用链路;M3 计费、对外 API、充值闭环与上线前安全加固已完成到 T-306;Phase 4 已完成注册 / 登录(T-501)。下一步做 API Key 管理页,再做个人中心和充值页。里程碑:M1 骨架可跑 · M2 跑通生成 · M3 计费充值闭环 · M4 用户端可用 · M5 验收上线。 --- *详见 `project-brief.md`(完整介绍)。* diff --git a/docs/routes.md b/docs/routes.md index 8f841fa..8b5a6c5 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -14,6 +14,8 @@ | `/records/usage` | GET | 点数使用(消费/调用)记录 | session | | `/apikeys` | GET/POST | API Key 管理:列表 / 生成 / 删除(明文只显示一次) | session | +T-501 已落地 `/signup`、`/login`、`/logout` 与最小 `/dashboard`。`/dashboard` 当前只作为登录后的落点展示剩余点数;充值汇总、充值记录与消费记录留给 T-503。 + ## API 路由(对外,DRF) | 路由 | 方法 | 职责 | 鉴权 | diff --git a/progress.md b/progress.md index 4de48c7..c74b36c 100644 --- a/progress.md +++ b/progress.md @@ -728,3 +728,28 @@ - `git diff --check`:通过,仅 Windows CRLF 提示。 - 阻塞:T-306 功能无阻塞;完整大套件仍受远程 MySQL 连接稳定性影响。生产真实支付仍需微信 / 支付宝商户密钥、证书与 SDK 配置;真实 AI 上游 smoke 仍需配置 `AI_KEY_ENCRYPTION_KEY` 并导入 AiModel/ModelAlias。 - 下一步:领取 T-501 注册 / 登录(allauth)。 + +## 2026-07-03 T-501 注册 / 登录(allauth) + +- 状态:DONE +- 变更: + - `requirements.txt`:新增 `django-allauth>=65.18,<66`,本机安装到 65.18.0。 + - `config/settings.py` / `config/urls.py`:接入 `allauth` / `allauth.account`、allauth authentication backend、`AccountMiddleware`、邮箱验证 mandatory、登录/登出跳转、邮件 backend 配置、项目模板优先级、`accounts/` 路由与 portal 页面路由。 + - `apps/portal/`:新增 `CmhubAccountAdapter`,allauth 注册成功后创建 `UserWallet(points_balance=0)`,不写赠点流水;新增 `/signup`、`/login`、`/logout`、`/dashboard` 路由,最小 dashboard 只读显示点数余额;新增 Bootstrap 模板与中文验证邮件模板。 + - `apps/portal/tests.py`:覆盖注册创建未验证邮箱与 0 点钱包、无赠点流水、未验证邮箱不能登录、已验证用户登录/dashboard/登出、dashboard session 保护、登录 POST CSRF 保护。 + - 同步更新 `.env.example`、`README.md`、`docs/00-ai-start-here.md`、`docs/03-tech-stack.md`、`docs/04-architecture.md`、`docs/api.md`、`docs/routes.md`、`docs/env.md`、`docs/06-tasks.md`、`docs/current-state.md`、`docs/project-brief.md`、`docs/project-onepager.md`、`docs/phase-2-review.md`。 +- 验证: + - `./init.ps1`:开工前通过,依赖同步与 `manage.py check` 正常。 + - `py -3.12 -m pip install -r requirements.txt`:通过,安装 `django-allauth 65.18.0`。 + - `py -3.12 manage.py check`:通过,0 issues。 + - `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 test apps.portal --noinput --keepdb --verbosity 2`:通过,5 tests OK。 + - `py -3.12 manage.py makemigrations --check --dry-run`:通过,No changes detected。 + - `py -3.12 manage.py migrate --noinput`:通过,应用 `account.0001` ~ `account.0009`;MySQL 对 allauth `account.EmailAddress` 条件唯一约束提示 `models.W036`,第三方表的条件约束不会创建。 + - `py -3.12 manage.py test apps.users apps.portal --noinput --keepdb --verbosity 2`:通过,7 tests OK。 + - `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); print(r.content[:120])"`:通过,`/login` 返回 200;修正模板优先级后确认返回项目内 `portal/base.html` 模板。 + - `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。 +- 阻塞:T-501 功能无阻塞。生产邮箱验证需要配置真实邮件服务;当前默认 console backend 只适合本地开发。allauth 的 `EmailAddress` 条件唯一约束在 MySQL 上有第三方模型警告,但本项目账本唯一性由 `user.email` 唯一约束承担。 +- 决策:不引入 crispy-forms;T-501 只需要简单 allauth 表单,MVP 先用 Django form + Bootstrap 模板。注册路径只初始化 0 点钱包,不创建 `PointsLedger`,避免被误解为注册赠点。 +- 下一步:领取 T-502 API Key 自助管理页。 diff --git a/requirements.txt b/requirements.txt index 80dc906..82fb033 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ Django>=5.2,<5.3 djangorestframework>=3.16,<3.17 +django-allauth>=65.18,<66 PyMySQL>=1.1,<1.2 cryptography>=42,<47 requests>=2.32,<3