diff --git a/README.md b/README.md
index 3b6f431..e278150 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,7 @@ Python 3.12 / Django 5.2 LTS + DRF / django-admin / 用户端 Django 模板 SSR
## 当前状态
-Phase 2 计费核心已完成,Phase 3 对外 API 与充值已完成到 T-306,Phase 4 已完成 T-501 注册 / 登录、T-502 API Key 自助管理与 T-503 个人中心 / 记录页:用户端可通过 allauth 自助注册、邮箱验证、登录、登出,生成 / 删除(吊销)API Key,并查看余额、充值总额、充值记录与消费记录。下一步做 T-504 充值页。详见 [`docs/current-state.md`](docs/current-state.md)。
+Phase 2 计费核心已完成,Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端已完成 T-501~T-504:用户可通过 allauth 自助注册、邮箱验证、登录、登出,扫码充值并轮询到账,生成 / 删除(吊销)API Key,并查看余额、充值总额、充值记录与消费记录。下一步进入 T-401 运营后台完善。详见 [`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/forms.py b/apps/portal/forms.py
index b989433..9639635 100644
--- a/apps/portal/forms.py
+++ b/apps/portal/forms.py
@@ -1,4 +1,9 @@
+from decimal import Decimal
+
from django import forms
+from django.conf import settings
+
+from apps.billing.models import RechargeOrder
class ApiKeyCreateForm(forms.Form):
@@ -14,3 +19,37 @@ class ApiKeyCreateForm(forms.Form):
}
),
)
+
+
+class RechargeCreateForm(forms.Form):
+ amount = forms.DecimalField(
+ label="充值金额",
+ max_digits=12,
+ decimal_places=2,
+ min_value=Decimal("0.01"),
+ widget=forms.NumberInput(
+ attrs={
+ "class": "form-control",
+ "inputmode": "decimal",
+ "min": "0.01",
+ "step": "0.01",
+ "placeholder": "例如:100.00",
+ }
+ ),
+ )
+ pay_method = forms.ChoiceField(
+ label="支付方式",
+ choices=(
+ (RechargeOrder.PayMethod.WEIXIN, "微信"),
+ (RechargeOrder.PayMethod.ALIPAY, "支付宝"),
+ ),
+ initial=RechargeOrder.PayMethod.WEIXIN,
+ widget=forms.RadioSelect(attrs={"class": "form-check-input"}),
+ )
+
+ def clean_amount(self):
+ amount = self.cleaned_data["amount"]
+ max_amount = Decimal(str(settings.RECHARGE_MAX_AMOUNT_CNY))
+ if amount > max_amount:
+ raise forms.ValidationError(f"单笔充值金额不能超过 {max_amount:.2f} CNY")
+ return amount
diff --git a/apps/portal/templates/portal/base.html b/apps/portal/templates/portal/base.html
index bb2c1f9..940c23a 100644
--- a/apps/portal/templates/portal/base.html
+++ b/apps/portal/templates/portal/base.html
@@ -83,6 +83,7 @@
{% if user.is_authenticated %}
控制台
+
充值
API Key
充值记录
消费记录
diff --git a/apps/portal/templates/portal/dashboard.html b/apps/portal/templates/portal/dashboard.html
index 5648124..d6f8534 100644
--- a/apps/portal/templates/portal/dashboard.html
+++ b/apps/portal/templates/portal/dashboard.html
@@ -54,9 +54,9 @@
diff --git a/apps/portal/templates/portal/recharge.html b/apps/portal/templates/portal/recharge.html
new file mode 100644
index 0000000..c1c1ee3
--- /dev/null
+++ b/apps/portal/templates/portal/recharge.html
@@ -0,0 +1,254 @@
+{% extends "portal/base.html" %}
+
+{% block title %}充值 - cmhub{% endblock %}
+
+{% block content %}
+
+
+
+
充值
+
{{ user.email }}
+
+
充值记录
+
+
+
+
+
+
当前点数
+
{{ balance.points_balance }}
+
+
+
+
+
充值总额
+
{{ recharge_total_amount|floatformat:2 }}
+
+
+
+
+
入账点数
+
{{ recharge_points_total }}
+
+
+
+
+
+
+ {% if current_order %}
+
+
+
+
待支付订单
+
订单号:{{ current_order.order_no }}
+
+
{{ current_order.status }}
+
+
+
+
+
+
+ - 金额
+ - {{ current_order.amount_money|floatformat:2 }} {{ current_order.currency }}
+ - 预计到账
+ - {{ current_order.points_granted }} 点
+ - 支付方式
+ - {{ current_order.pay_method }}
+ - 创建时间
+ - {{ current_order.created_at|date:"Y-m-d H:i" }}
+ - 二维码有效期
+ - {{ current_order.expires_at|date:"Y-m-d H:i"|default:"-" }}
+ - 到账时间
+ - {{ current_order.paid_at|date:"Y-m-d H:i"|default:"-" }}
+
+
+ {% if current_order.status == "pending" %}
+ 正在等待支付结果,页面会自动轮询订单状态。
+ {% elif current_order.status == "paid" %}
+ 订单已到账,余额已刷新。
+ {% else %}
+ 订单当前状态为 {{ current_order.status }}。
+ {% endif %}
+
+
+
+
+ {% endif %}
+
+
+
+ {% if recent_recharge_orders %}
+
+
+
+
+ | 订单号 |
+ 金额 |
+ 点数 |
+ 状态 |
+ 创建时间 |
+
+
+
+ {% for order in recent_recharge_orders %}
+
+ {{ order.order_no }} |
+ {{ order.amount_money|floatformat:2 }} {{ order.currency }} |
+ {{ order.points_granted }} |
+ {{ order.status }} |
+ {{ order.created_at|date:"Y-m-d H:i" }} |
+
+ {% endfor %}
+
+
+
+ {% else %}
+ 暂无充值记录
+ {% endif %}
+
+
+
+
+
+{% endblock %}
diff --git a/apps/portal/tests.py b/apps/portal/tests.py
index 9b72b3e..818db25 100644
--- a/apps/portal/tests.py
+++ b/apps/portal/tests.py
@@ -9,7 +9,7 @@ from django.test import Client, TestCase, override_settings
from django.utils import timezone
from rest_framework.test import APIClient
-from apps.billing.models import CallRecord, PointsLedger, RechargeOrder
+from apps.billing.models import CallRecord, ExchangeRate, PointsLedger, RechargeOrder
from apps.users.models import ApiKey, UserWallet
@@ -434,3 +434,115 @@ class PortalAccountFlowTests(TestCase):
self.assertContains(response, "15")
self.assertNotContains(response, "other-alias")
self.assertNotContains(response, other_key.key_prefix)
+
+ def test_recharge_page_requires_login_and_shows_form(self):
+ user = self.create_verified_user()
+ UserWallet.objects.create(user=user, points_balance=25)
+
+ anonymous_response = self.client.get("/recharge")
+ self.assertEqual(anonymous_response.status_code, 302)
+ self.assertTrue(anonymous_response["Location"].startswith("/login?next="))
+
+ self.client.force_login(user)
+ response = self.client.get("/recharge")
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.context["balance"].points_balance, 25)
+ self.assertContains(response, "充值金额")
+ self.assertContains(response, "创建订单")
+ self.assertContains(response, "qrcode@1.5.4")
+ self.assertContains(response, "QRCode.toCanvas")
+
+ def test_recharge_page_post_creates_pending_order_without_crediting_wallet_or_ledger(self):
+ user = self.create_verified_user()
+ UserWallet.objects.create(user=user, points_balance=5)
+ ExchangeRate.objects.create(
+ currency="CNY",
+ points_per_unit=Decimal("10.0000"),
+ effective_from=timezone.now(),
+ )
+ self.client.force_login(user)
+
+ response = self.client.post(
+ "/recharge",
+ {"amount": "20.00", "pay_method": RechargeOrder.PayMethod.WEIXIN},
+ )
+
+ self.assertEqual(response.status_code, 302)
+ self.assertTrue(response["Location"].startswith("/recharge?order_no="))
+ order = RechargeOrder.objects.get(user=user)
+ self.assertEqual(order.amount_money, Decimal("20.00"))
+ self.assertEqual(order.exchange_rate, Decimal("10.0000"))
+ self.assertEqual(order.points_granted, 200)
+ self.assertEqual(order.status, RechargeOrder.Status.PENDING)
+ self.assertTrue(order.code_url.startswith("weixin://wxpay/cmhub-mock"))
+ self.assertIsNotNone(order.expires_at)
+ wallet = UserWallet.objects.get(user=user)
+ self.assertEqual(wallet.points_balance, 5)
+ self.assertFalse(
+ PointsLedger.objects.filter(
+ user=user,
+ ref_order_id=order.id,
+ ).exists()
+ )
+
+ follow_response = self.client.get(response["Location"])
+
+ self.assertEqual(follow_response.status_code, 200)
+ self.assertEqual(follow_response.context["current_order"], order)
+ self.assertContains(follow_response, order.order_no)
+ self.assertContains(follow_response, "weixin://wxpay/cmhub-mock")
+ self.assertContains(follow_response, 'data-status-url="/api/v1/recharge/status"')
+ self.assertContains(follow_response, "data-recharge-order")
+
+ def test_recharge_page_current_order_only_shows_current_user_order(self):
+ user = self.create_verified_user()
+ other_user = self.create_verified_user()
+ UserWallet.objects.create(user=user, points_balance=0)
+ other_order = self.create_recharge_order(
+ other_user,
+ order_no="R-OTHER-RECHARGE-PAGE-504",
+ amount="50.00",
+ points=500,
+ status=RechargeOrder.Status.PENDING,
+ )
+ self.client.force_login(user)
+
+ response = self.client.get(f"/recharge?order_no={other_order.order_no}")
+
+ self.assertEqual(response.status_code, 200)
+ self.assertIsNone(response.context["current_order"])
+ self.assertNotContains(response, other_order.order_no)
+
+ @override_settings(RECHARGE_MAX_AMOUNT_CNY="100.00")
+ def test_recharge_page_rejects_amount_above_configured_maximum(self):
+ user = self.create_verified_user()
+ UserWallet.objects.create(user=user, points_balance=0)
+ ExchangeRate.objects.create(
+ currency="CNY",
+ points_per_unit=Decimal("10.0000"),
+ effective_from=timezone.now(),
+ )
+ self.client.force_login(user)
+
+ response = self.client.post(
+ "/recharge",
+ {"amount": "100.01", "pay_method": RechargeOrder.PayMethod.ALIPAY},
+ )
+
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "单笔充值金额不能超过 100.00 CNY")
+ self.assertFalse(RechargeOrder.objects.filter(user=user).exists())
+
+ def test_recharge_page_post_is_csrf_protected(self):
+ user = self.create_verified_user()
+ csrf_client = Client(enforce_csrf_checks=True)
+ csrf_client.force_login(user)
+
+ response = csrf_client.post(
+ "/recharge",
+ {"amount": "20.00", "pay_method": RechargeOrder.PayMethod.WEIXIN},
+ )
+
+ self.assertEqual(response.status_code, 403)
+ self.assertFalse(RechargeOrder.objects.filter(user=user).exists())
diff --git a/apps/portal/urls.py b/apps/portal/urls.py
index 5b24aaf..16b0d53 100644
--- a/apps/portal/urls.py
+++ b/apps/portal/urls.py
@@ -6,6 +6,7 @@ from .views import (
ApiKeyDeleteView,
ApiKeyListCreateView,
DashboardView,
+ RechargePageView,
RechargeRecordListView,
UsageRecordListView,
)
@@ -18,6 +19,7 @@ urlpatterns = [
path("dashboard", DashboardView.as_view(), name="portal-dashboard"),
path("apikeys", ApiKeyListCreateView.as_view(), name="portal-apikeys"),
path("apikeys/
/delete", ApiKeyDeleteView.as_view(), name="portal-apikey-delete"),
+ path("recharge", RechargePageView.as_view(), name="portal-recharge"),
path("records/recharge", RechargeRecordListView.as_view(), name="portal-recharge-records"),
path("records/usage", UsageRecordListView.as_view(), name="portal-usage-records"),
]
diff --git a/apps/portal/views.py b/apps/portal/views.py
index a41e0b1..ece6ab4 100644
--- a/apps/portal/views.py
+++ b/apps/portal/views.py
@@ -2,15 +2,21 @@ from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Sum
from django.shortcuts import get_object_or_404, redirect
-from django.urls import reverse_lazy
+from django.urls import reverse, reverse_lazy
from django.views import View
from django.views.generic import FormView, TemplateView
+from apps.billing.payment_gateways import PaymentOrderCreateError
+from apps.billing.pricing import NoExchangeRateError
from apps.billing.models import PointsLedger, RechargeOrder
-from apps.billing.services import get_balance_snapshot
+from apps.billing.services import (
+ RechargeOrderCreateError,
+ create_recharge_order,
+ get_balance_snapshot,
+)
from apps.users.models import ApiKey
-from .forms import ApiKeyCreateForm
+from .forms import ApiKeyCreateForm, RechargeCreateForm
NEW_API_KEY_SESSION_KEY = "portal_new_api_key"
@@ -120,6 +126,49 @@ class ApiKeyDeleteView(LoginRequiredMixin, View):
return redirect("portal-apikeys")
+class RechargePageView(LoginRequiredMixin, FormView):
+ template_name = "portal/recharge.html"
+ form_class = RechargeCreateForm
+
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ order_no = str(self.request.GET.get("order_no") or "").strip()
+ current_order = None
+ if order_no:
+ current_order = RechargeOrder.objects.filter(
+ user=self.request.user,
+ order_no=order_no,
+ ).first()
+ context["balance"] = get_balance_snapshot(self.request.user)
+ context.update(get_portal_account_summary(self.request.user))
+ context["current_order"] = current_order
+ context["recent_recharge_orders"] = get_recharge_orders_for_user(
+ self.request.user
+ )[:5]
+ return context
+
+ def form_valid(self, form):
+ try:
+ order = create_recharge_order(
+ user=self.request.user,
+ amount=form.cleaned_data["amount"],
+ pay_method=form.cleaned_data["pay_method"],
+ )
+ except NoExchangeRateError:
+ form.add_error(None, "未配置当前币种汇率,暂时无法充值。")
+ return self.form_invalid(form)
+ except RechargeOrderCreateError:
+ form.add_error(None, "充值下单参数错误,请检查金额和支付方式。")
+ return self.form_invalid(form)
+ except PaymentOrderCreateError:
+ form.add_error(None, "支付下单失败,请稍后重试。")
+ return self.form_invalid(form)
+
+ messages.success(self.request, "充值订单已创建,请扫码支付。")
+ recharge_url = reverse("portal-recharge")
+ return redirect(f"{recharge_url}?order_no={order.order_no}")
+
+
class RechargeRecordListView(LoginRequiredMixin, TemplateView):
template_name = "portal/recharge_records.html"
diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md
index 0acf734..3bc8150 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 安全加固;Phase 4 已完成 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页与 T-503 个人中心 / 记录页。下一步进入 T-504 充值页。
+当前项目处于:**Phase 5 后台与发布**。Phase 2 计费核心已完成到 T-204;Phase 3 已完成 T-301 API Key 鉴权、T-302 生成标题 / 图片接口、T-303 余额查询接口、T-304 充值回调、T-305 扫码充值下单 + 轮询与 T-306 对外 API 安全加固;Phase 4 已完成 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页与 T-504 充值页。下一步进入 T-401 运营后台完善。
优先路径:
@@ -46,8 +46,8 @@
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 注册登录、T-502 API Key 管理与 T-503 个人中心 / 记录页已完成,下一步 T-504 充值页。
-6. Phase 5:后台与发布 —— 运营后台完善、完整验收、部署 / 运行文档。
+5. Phase 4:用户端(Django 模板 SSR)—— T-501 注册登录、T-502 API Key 管理、T-503 个人中心 / 记录页与 T-504 充值页已完成。
+6. Phase 5:后台与发布 —— 下一步 T-401 运营后台完善,随后做完整验收与部署 / 运行文档。
## 领取任务规则
diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md
index b3cfd22..dcf043c 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 | 已定 | T-501 已落地自助注册/登录/登出、邮箱验证与最小 dashboard;T-502 已落地 API Key 自助生成 / 删除页;T-503 已落地个人中心、充值记录和消费记录页;MVP 先用 Django form + Bootstrap 模板,不为简单表单引入 crispy-forms |
+| 用户端 | Django 模板 SSR + Bootstrap 5 + django-allauth | 已定 | T-501 已落地自助注册/登录/登出、邮箱验证与最小 dashboard;T-502 已落地 API Key 自助生成 / 删除页;T-503 已落地个人中心、充值记录和消费记录页;T-504 已落地充值页(创建订单、展示二维码票据、轮询到账后刷新);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 注册登录邮箱验证) | 已定 | T-501 已落地 `/signup` `/login` `/logout` 与 `/dashboard`;T-502 已落地 `/apikeys`;T-503 已落地 `/records/recharge` 与 `/records/usage`;用户端页面与 `recharge/create` 走 session + CSRF;后台账号也用 Session 登录 |
+| 用户端鉴权 | Django Session(+ allauth 注册登录邮箱验证) | 已定 | T-501 已落地 `/signup` `/login` `/logout` 与 `/dashboard`;T-502 已落地 `/apikeys`;T-503 已落地 `/records/recharge` 与 `/records/usage`;T-504 已落地 `/recharge`;用户端页面与 `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。T-501 已接入 django-allauth 65.18.0,使用邮箱验证、session 与 CSRF;注册成功只创建 0 点 `UserWallet`,不写赠点流水。T-502 已用 Django Form + Bootstrap 模板落地 `/apikeys`,生成 Key 后明文只显示一次,删除写为 `revoked`。T-503 已用只读 Django TemplateView 落地 dashboard 汇总、充值记录和消费记录。放弃 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`。T-503 已用只读 Django TemplateView 落地 dashboard 汇总、充值记录和消费记录。T-504 已用 Django Form + Bootstrap 模板落地 `/recharge`:页面 POST 创建 pending 充值订单,展示支付二维码票据,并用浏览器轮询 `/api/v1/recharge/status`,订单 paid 后刷新余额。放弃 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 82d80a6..8f92a0c 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 鉴权。T-501 已落地 `/signup`、`/login`、`/logout` 与最小 `/dashboard`;注册成功通过 allauth adapter 创建 0 点 `UserWallet`,不写赠点流水。T-502 已落地 `/apikeys`,用户可自助生成和删除(吊销)自己的 API Key,明文只显示一次,列表只显示 prefix。T-503 已扩展 `/dashboard` 并新增 `/records/recharge`、`/records/usage`,只读展示当前用户余额、充值订单与消费流水。
+- **用户端层(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。T-503 已扩展 `/dashboard` 并新增 `/records/recharge`、`/records/usage`,只读展示当前用户余额、充值订单与消费流水。T-504 已落地 `/recharge`,用户可创建 pending 充值订单、查看二维码票据,并轮询订单状态;到账仍以服务端回调或主动查单入账后的本地订单状态为准。
- **用户与账号层**:注册用户 `User`、点数钱包 `UserWallet`、`ApiKey`(一用户多把、哈希存储)。入口 `apps/users/`。
- **API 层(DRF)**:对外生成接口、余额查询、支付回调接收、扫码下单。入口 `apps/api/`。生成/余额这类对外业务 API **只认 API Key,不接受 Web session**;充值下单/状态查询属于用户端流程,走 Web session + CSRF;支付回调走平台验签。
- **计费层**:点数计算、原子扣减(锁 `UserWallet` 行)、退点、充值入账、流水记账。入口 `apps/billing/`。
@@ -46,6 +46,8 @@ T-304 已实现 `/api/v1/recharge/callback/wechat` 与 `/api/v1/recharge/callbac
T-305 已实现 `/api/v1/recharge/create` 与 `/api/v1/recharge/status`:两个端点走 `SessionAuthentication + IsAuthenticated`,属于用户端 session + CSRF 流程,不接受 API Key;创建订单时调用 `apps.billing.services.create_recharge_order()` 锁定当前汇率和预计到账点数,再经 `apps.billing.payment_gateways.create_payment_order()` 获取微信 native `code_url` 或支付宝当面付 `qr_code`;状态查询只允许订单所属用户访问,并在 pending 时尝试 `query_payment_order()` 主动查单补入账,查单不可用时保持 pending 等回调。
+T-504 已实现用户端 `/recharge` 页面:GET 展示当前余额、充值表单、当前订单和最近充值;POST 经 `RechargeCreateForm` 校验金额与支付方式后复用 `create_recharge_order()` 创建 pending 订单并重定向到当前订单页,避免刷新重复下单;页面用 qrcode.js 渲染 `code_url`,同时保留可复制支付票据兜底;浏览器每秒轮询 `/api/v1/recharge/status`,订单 paid 后刷新页面重新读取余额。页面不直接写 `UserWallet.points_balance` 或 `PointsLedger`。
+
T-306 已实现对外 API 安全加固:`download_image_input()` 在请求前校验 `image_url` 协议与解析后的 IP,只允许公网 `http` / `https`,拒绝私有、回环、链路本地、保留、组播、未指定地址;重定向由服务端手动跟随并逐跳重新校验,响应按 `IMAGE_URL_MAX_BYTES` 流式限长读取。`REST_FRAMEWORK` 全局默认认证为空、默认权限为 `IsAuthenticated`,外部 API 和用户端 session API 必须显式声明认证类;生成接口挂 `GenerateRateThrottle`,认证失败挂 IP 限流;充值下单通过 `RECHARGE_MAX_AMOUNT_CNY` 控制单笔上限。
**计费层(`apps/billing`)**
@@ -272,7 +274,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-501 已保证 allauth 自助注册路径创建 `UserWallet(points_balance=0)`,且不写 `PointsLedger`,避免把注册初始化误记为赠点或充值;T-502 已把 API Key 自助管理接到 `ApiKey.create_for_user()`,删除动作写为 `revoked` 状态而非物理删除;T-503 已把个人中心和记录页接到只读查询,余额用 `get_balance_snapshot()`,充值总额按 paid `RechargeOrder` 汇总,入账 / 消费 / 退款按 `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` 状态而非物理删除;T-503 已把个人中心和记录页接到只读查询,余额用 `get_balance_snapshot()`,充值总额按 paid `RechargeOrder` 汇总,入账 / 消费 / 退款按 `PointsLedger` 汇总;T-504 已把 `/recharge` 页面接到 `create_recharge_order()` 与 `/api/v1/recharge/status`。
## 四、计费时序(核心,务必照此实现)
@@ -365,7 +367,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;T-501 注册登录、T-502 API Key 管理与 T-503 个人中心 / 记录页已完成,下一步 T-504 充值页)。
+5. 用户端注册登录、API Key 管理、个人中心、充值页(Phase 4;T-501 注册登录、T-502 API Key 管理、T-503 个人中心 / 记录页与 T-504 充值页已完成)。
6. 运营后台完善、完整验收、部署(Phase 5)。
## 七、项目结构建议
diff --git a/docs/06-tasks.md b/docs/06-tasks.md
index d3ce29b..c8ed21c 100644
--- a/docs/06-tasks.md
+++ b/docs/06-tasks.md
@@ -64,7 +64,7 @@
| T-501 | 注册 / 登录(allauth) | T-201 | 自助注册(邮箱验证)、登录、登出;注册后钱包点数为 0(不送点数);session + CSRF | DONE |
| T-502 | API Key 自助管理页 | T-501, T-301 | 登录用户生成/删除 Key;明文只显示一次、库内只存哈希;列表只显示 prefix;删除即吊销,吊销后该 Key 调用 403(无效/不存在 Key 仍为 401) | DONE |
| T-503 | 个人中心 / 记录页 | T-501, T-203 | 剩余点数、充值总额、充值记录、消费(调用)记录;数据与流水一致;仅见本人 | DONE |
-| T-504 | 充值页(扫码 + 轮询到账) | T-501, T-305 | 发起充值→展示二维码→轮询订单状态→到账后余额刷新;到账以回调为权威 | TODO |
+| T-504 | 充值页(扫码 + 轮询到账) | T-501, T-305 | 发起充值→展示二维码→轮询订单状态→到账后余额刷新;到账以回调为权威 | DONE |
## Phase 5 · 后台与发布
diff --git a/docs/api.md b/docs/api.md
index 2b516a9..d6e6d95 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -35,6 +35,8 @@ T-502 已实现用户端 API Key 自助管理基线:`/apikeys` 走 Django sess
T-503 已实现用户端个人中心 / 记录页基线:`/dashboard` 走 Django session,展示当前用户剩余点数、充值总额、入账点数、净消耗点数和最近记录;`/records/recharge` 只展示当前登录用户的充值订单;`/records/usage` 只展示当前登录用户的 `consume` / `refund` 点数流水并关联调用信息。页面只读,不写 `UserWallet.points_balance`。
+T-504 已实现用户端充值页基线:`/recharge` 走 Django session + CSRF,GET 展示余额、充值表单、当前订单和最近充值;POST 创建 pending 充值订单并展示支付二维码票据;浏览器轮询 `GET /api/v1/recharge/status`,订单 paid 后刷新页面重新读取余额。页面不直接加点,到账仍以支付回调或主动查单入账后的本地订单状态为准。
+
通用错误响应:
```json
@@ -229,6 +231,7 @@ query_and_apply_recharge_payment(order_no: str, query_func) -> RechargeResult
- `exchange_rate` 与 `points_granted` 以订单创建时的配置为准;回调入账使用订单值,不因后台后续改汇率而变化。
- 微信走 `pay/transactions/native` 取 `code_url`;支付宝走 `trade.precreate` 取 `qr_code`;前端用 qrcode.js 渲染。
- 走 **Web session** 鉴权(用户端流程),不同于对外 API Key;订单绑定发起用户,防充错账户。
+- T-504 的 `/recharge` 页面使用同一 `create_recharge_order()` 计费层入口创建订单;JSON API 仍保留给用户端脚本或后续前端调用。
- 开发 / 测试 `PAYMENT_CALLBACK_MODE=mock` 时返回 mock 二维码票据;生产应使用 `sdk` 模式与真实商户配置。
- 单笔金额超过 `RECHARGE_MAX_AMOUNT_CNY` 会返回 `bad_request`,不创建 `RechargeOrder`。
diff --git a/docs/current-state.md b/docs/current-state.md
index bb5f26c..2c5a1cb 100644
--- a/docs/current-state.md
+++ b/docs/current-state.md
@@ -12,10 +12,10 @@
## 当前快照
- 日期:2026-07-03
-- 阶段:Phase 4 用户端;Phase 3 对外 API 与充值已完成到 T-306,T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页与 T-503 个人中心 / 记录页已完成,下一步 T-504 充值页
+- 阶段:Phase 5 后台与发布;Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页与 T-504 充值页已完成,下一步 T-401 运营后台完善
- 技术栈:系统 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-502 已新增 `/apikeys`、API Key 创建表单、列表页和删除(吊销)动作,生成后明文只显示一次,列表只显示 prefix;T-503 已扩展 `/dashboard` 为个人中心汇总,并新增 `/records/recharge` 充值记录与 `/records/usage` 消费记录,只读展示当前用户数据
-- 测试:T-503 已验证:`./init.ps1` 开工前通过;`py -3.12 -m py_compile apps\portal\views.py apps\portal\urls.py apps\portal\tests.py` 通过;`py -3.12 manage.py check` 通过;`py -3.12 manage.py test apps.portal --noinput --keepdb --verbosity 2` 通过(14 tests OK);`py -3.12 manage.py makemigrations --check --dry-run` 无变化;`py -3.12 -m compileall apps config` 通过;`py -3.12 manage.py test apps.users apps.portal apps.api --noinput --keepdb --verbosity 2` 通过(55 tests OK);`./init.ps1` 收尾通过;`git diff --check` 通过(仅 Windows CRLF 提示)。测试/迁移阶段仍有 allauth `account.EmailAddress` 条件唯一约束在 MySQL 上不可创建的 `models.W036` 警告;本项目用户账本唯一性由 `user.email` 唯一约束承担。
+- 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器);T-102 已新增 `AiModel` / `ModelAlias`、Fernet 加密密钥存储、别名解析、admin 配置页、`import_ai_models` 导入命令;T-103 已新增 `AiConfigAuditLog` 审计表、admin 只读页面和后台保存/删除审计 hook;T-104/T-105 已完成录制 title/image smoke 与审核修补;T-201 已新增 `UserWallet` / `ApiKey`、`PointsLedger` / `CallRecord`、对应 admin 与迁移;T-202 已新增 `PricingRule` / `ExchangeRate`、`apps.billing.pricing` 计费计算函数、admin 配置页与迁移;T-203 已新增 `apps.billing.services`,实现并发安全预扣、成功确认与幂等失败退点;T-204 已新增 `billing.0003_pointsledger_unique_ledger_change_type_per_call`,用 MySQL 可落地的 `ref_call + change_type` 复合唯一约束兜底防重复 refund;T-301 已新增 `apps.api.authentication.ApiKeyAuthentication` 与 `ExternalApiView`;T-302 已新增生成接口编排、序列化器、图片本地存储和 `/api/v1/generate/title|image` 路由;T-303 已新增 `apps.billing.services.get_balance_snapshot()` 与 `/api/v1/balance` 余额查询接口;T-304 已新增 `RechargeOrder`、充值回调验签适配器、幂等入账服务、微信/支付宝回调路由与迁移 `billing.0004_rechargeorder_and_more`;T-305 已新增 `create_recharge_order()`、微信/支付宝扫码下单 mock/SDK 入口、`/api/v1/recharge/create` 与 `/api/v1/recharge/status`;T-306 已新增 `apps.api.throttles`、`apps.api.exceptions`、`REST_FRAMEWORK` 安全默认认证、生成/认证失败限流、`image_url` SSRF 防护与响应大小上限、充值单笔金额上限;T-501 已接入 allauth,新增 portal 路由、注册适配器、登录/注册/登出模板和最小 dashboard,注册成功创建 0 点钱包且不写赠点流水;T-502 已新增 `/apikeys`、API Key 创建表单、列表页和删除(吊销)动作,生成后明文只显示一次,列表只显示 prefix;T-503 已扩展 `/dashboard` 为个人中心汇总,并新增 `/records/recharge` 充值记录与 `/records/usage` 消费记录,只读展示当前用户数据;T-504 已新增 `/recharge` 页面、`RechargeCreateForm`、充值导航入口和轮询脚本,页面创建 pending 订单、展示二维码票据、轮询 `/api/v1/recharge/status`,订单 paid 后刷新余额
+- 测试:T-504 已验证:`./init.ps1` 开工前通过;`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` 通过(19 tests OK);`py -3.12 manage.py test apps.users apps.portal apps.api --noinput --keepdb --verbosity 2` 通过(60 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` 命令加密导入
- 标准启动路径:Windows 用 `./init.ps1`;Unix/WSL 用 `./init.sh`
- 标准验证路径:Windows 用 `py -3.12 manage.py check` / `py -3.12 manage.py test`
@@ -33,7 +33,7 @@
| `init.sh` / `init.ps1` | 已有 | 启动验证入口,已固定系统 Python 3.12 命令,并校验解释器版本 `>=3.12,<3.14` |
| `requirements.txt` / `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 错误响应;T-501 已在 portal 落 allauth 注册/登录/登出路由、模板、adapter 与 dashboard;T-502 已在 portal 落 `/apikeys`、API Key 创建表单、列表模板与删除(吊销)动作;T-503 已在 portal 落个人中心汇总、充值记录和消费记录页 |
+| `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 创建表单、列表模板与删除(吊销)动作;T-503 已在 portal 落个人中心汇总、充值记录和消费记录页;T-504 已在 portal 落 `/recharge` 充值页、充值表单、二维码票据展示和状态轮询 |
| `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-501 注册 / 登录(allauth);T-502 API Key 自助管理页;T-503 个人中心 / 记录页。
+- 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-103 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补;T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型;T-202 PricingRule / ExchangeRate 模型 + 计费计算;T-203 并发安全扣点 / 退点;T-204 Phase 2 计费核心审核加固;T-301 API Key 鉴权;T-302 生成标题 / 图片接口;T-303 余额查询接口;T-304 充值回调;T-305 扫码充值下单 + 轮询;T-306 Phase 3 对外 API 安全加固;T-501 注册 / 登录(allauth);T-502 API Key 自助管理页;T-503 个人中心 / 记录页;T-504 充值页(扫码 + 轮询到账)。
- 正在进行:无。
- 当前 blocker:远程 MySQL 连接当前不稳定或很慢;支付商户真实密钥/证书与生产 SDK 依赖仍待提供;真实 AI 上游 smoke 仍需配置密钥和模型数据后执行。
-- 下一个可领取任务:**T-504 充值页(扫码 + 轮询到账)**。
+- 下一个可领取任务:**T-401 运营后台完善**。
## 当前可运行内容
@@ -84,19 +84,20 @@ python3.12 manage.py smoke_ai_generation image --recorded
- `GET/POST /login`
- `GET/POST /logout`
- `GET /dashboard`
+- `GET/POST /recharge`
- `GET/POST /apikeys`
- `POST /apikeys//delete`
- `GET /records/recharge`
- `GET /records/usage`
-当前骨架可运行。T-002 已在首次迁移前创建自定义 User,并按 `env.md` 接入 MySQL 8.4 / utf8mb4;远程 MySQL 已完成 Django 初始迁移。T-003 已接通 django-admin,测试可创建/销毁 `test_cmhub` 测试库;当前远程 MySQL 对频繁建库/销库仍可能间歇超时,必要时用 `--keepdb` 且串行跑测试。T-004 已应用 `users.0002_alter_user_email`,`user.email` 已有唯一索引。T-101 的 AI provider 层只做 HTTP 调用与响应解析;T-102 已把 provider 运行配置接到数据库 `AiModel` / `ModelAlias`,`resolve_alias()` 每次查当前 active 配置并按 `text` / `image` 能力校验。T-103 已补 `AiConfigAuditLog`,admin 保存/删除 `AiModel` / `ModelAlias` 时记录 actor、action、target、changed_fields、changes、created_at,密钥只记录 empty/set 状态。T-104/T-105 已用临时回滚配置跑通录制标题和录制图片生成。T-201 已落地钱包、API Key、点数流水和调用记录:API Key 明文只在创建 helper 返回,库内只存 hash/prefix;CallRecord 只存 `result_ref`/`result_summary`,没有 provider raw 字段。T-202 已落地 `PricingRule` / `ExchangeRate`:计费按 `operation_type + alias + resolution` 查 active 规则,优先精确分辨率,再回退默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`;金额换点数按当前 active 汇率向下取整。T-203 已落地 `precharge_call()` / `mark_call_success()` / `refund_call_points()`:预扣锁钱包行,余额不足不写调用/流水;失败退点锁调用记录并幂等写 refund 流水。T-204 已完成复合唯一约束加固,并取得一次完整 `manage.py test` 单次全绿。T-301 已落地 `Authorization: Bearer ` 鉴权:成功后 `request.user` 为所属用户、`request.auth` 为 `ApiKey`,缺失/无效 Key 返回 401,用户或 Key 禁用返回 403,外部 API 不接受 Web session。T-302 已落地生成接口:请求别名解析后按规则计费,预扣成功才调用 Provider,成功确认调用记录,`AiProviderError` / `AiCapabilityError` 等失败路径会退点;图片结果保存到本地 media 并返回 URL。T-303 已落地余额查询接口:`GET /api/v1/balance` 继承外部 API Key 鉴权,读取 billing 余额快照并返回 `user` 与 `points_balance`,测试覆盖余额与流水累加一致。T-304 已落地充值回调:`RechargeOrder` 保存下单锁定的金额/汇率/点数,微信/支付宝回调先验签再按订单幂等入账,重复回调不重复加点,金额不一致不入账;主动查单兜底可调用 `query_and_apply_recharge_payment(order_no, query_func)` 复用同一入账路径。T-305 已落地扫码下单与轮询:用户端 session 登录后可 `POST /api/v1/recharge/create` 创建 pending 订单并拿到 mock/SDK 二维码票据,`GET /api/v1/recharge/status` 只返回本人订单并在 pending 时尝试主动查单补入账;API Key 不能调用这两个用户端接口。T-306 已落地对外 API 安全加固:`image_url` 下载在扣点前做协议白名单、公网地址校验、重定向逐跳校验和响应大小上限;DRF 全局默认不再隐式启用 Session/Basic;生成接口按 Key 限流,认证失败按 IP 限流;充值下单有单笔金额上限。T-501 已落地 allauth 注册 / 登录:`ACCOUNT_EMAIL_VERIFICATION="mandatory"`,注册成功创建 0 点钱包、不写点数流水;未验证邮箱不能建立登录 session。T-502 已落地 API Key 自助管理:`/apikeys` 登录访问,生成后完整明文只显示一次,列表只显示 prefix,不显示 hash 或历史明文;删除为吊销 `revoked`,吊销后外部 API 返回 403。T-503 已落地个人中心与记录页:`/dashboard` 展示剩余点数、充值总额、入账点数、净消耗点数和最近记录;`/records/recharge` 展示当前用户充值订单;`/records/usage` 展示当前用户 consume/refund 点数流水并关联调用信息;所有页面均只读且只查本人。充值页留给 T-504。真实上游生成未执行,原因是当前环境未配置 `AI_KEY_ENCRYPTION_KEY` 且数据库没有 AiModel/ModelAlias;后续配置后可用 `import_ai_models` 导入,再通过接口跑真实标题/图片。
+当前骨架可运行。T-002 已在首次迁移前创建自定义 User,并按 `env.md` 接入 MySQL 8.4 / utf8mb4;远程 MySQL 已完成 Django 初始迁移。T-003 已接通 django-admin,测试可创建/销毁 `test_cmhub` 测试库;当前远程 MySQL 对频繁建库/销库仍可能间歇超时,必要时用 `--keepdb` 且串行跑测试。T-004 已应用 `users.0002_alter_user_email`,`user.email` 已有唯一索引。T-101 的 AI provider 层只做 HTTP 调用与响应解析;T-102 已把 provider 运行配置接到数据库 `AiModel` / `ModelAlias`,`resolve_alias()` 每次查当前 active 配置并按 `text` / `image` 能力校验。T-103 已补 `AiConfigAuditLog`,admin 保存/删除 `AiModel` / `ModelAlias` 时记录 actor、action、target、changed_fields、changes、created_at,密钥只记录 empty/set 状态。T-104/T-105 已用临时回滚配置跑通录制标题和录制图片生成。T-201 已落地钱包、API Key、点数流水和调用记录:API Key 明文只在创建 helper 返回,库内只存 hash/prefix;CallRecord 只存 `result_ref`/`result_summary`,没有 provider raw 字段。T-202 已落地 `PricingRule` / `ExchangeRate`:计费按 `operation_type + alias + resolution` 查 active 规则,优先精确分辨率,再回退默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`;金额换点数按当前 active 汇率向下取整。T-203 已落地 `precharge_call()` / `mark_call_success()` / `refund_call_points()`:预扣锁钱包行,余额不足不写调用/流水;失败退点锁调用记录并幂等写 refund 流水。T-204 已完成复合唯一约束加固,并取得一次完整 `manage.py test` 单次全绿。T-301 已落地 `Authorization: Bearer ` 鉴权:成功后 `request.user` 为所属用户、`request.auth` 为 `ApiKey`,缺失/无效 Key 返回 401,用户或 Key 禁用返回 403,外部 API 不接受 Web session。T-302 已落地生成接口:请求别名解析后按规则计费,预扣成功才调用 Provider,成功确认调用记录,`AiProviderError` / `AiCapabilityError` 等失败路径会退点;图片结果保存到本地 media 并返回 URL。T-303 已落地余额查询接口:`GET /api/v1/balance` 继承外部 API Key 鉴权,读取 billing 余额快照并返回 `user` 与 `points_balance`,测试覆盖余额与流水累加一致。T-304 已落地充值回调:`RechargeOrder` 保存下单锁定的金额/汇率/点数,微信/支付宝回调先验签再按订单幂等入账,重复回调不重复加点,金额不一致不入账;主动查单兜底可调用 `query_and_apply_recharge_payment(order_no, query_func)` 复用同一入账路径。T-305 已落地扫码下单与轮询:用户端 session 登录后可 `POST /api/v1/recharge/create` 创建 pending 订单并拿到 mock/SDK 二维码票据,`GET /api/v1/recharge/status` 只返回本人订单并在 pending 时尝试主动查单补入账;API Key 不能调用这两个用户端接口。T-306 已落地对外 API 安全加固:`image_url` 下载在扣点前做协议白名单、公网地址校验、重定向逐跳校验和响应大小上限;DRF 全局默认不再隐式启用 Session/Basic;生成接口按 Key 限流,认证失败按 IP 限流;充值下单有单笔金额上限。T-501 已落地 allauth 注册 / 登录:`ACCOUNT_EMAIL_VERIFICATION="mandatory"`,注册成功创建 0 点钱包、不写点数流水;未验证邮箱不能建立登录 session。T-502 已落地 API Key 自助管理:`/apikeys` 登录访问,生成后完整明文只显示一次,列表只显示 prefix,不显示 hash 或历史明文;删除为吊销 `revoked`,吊销后外部 API 返回 403。T-503 已落地个人中心与记录页:`/dashboard` 展示剩余点数、充值总额、入账点数、净消耗点数和最近记录;`/records/recharge` 展示当前用户充值订单;`/records/usage` 展示当前用户 consume/refund 点数流水并关联调用信息;所有页面均只读且只查本人。T-504 已落地充值页:`/recharge` GET 展示余额、充值表单、当前订单和最近充值,POST 创建 pending 订单并展示二维码票据,浏览器轮询 `/api/v1/recharge/status`,paid 后刷新页面重新读取余额;页面不直接写钱包或流水。真实上游生成未执行,原因是当前环境未配置 `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-504)。
+4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-401)。
5. 将该任务状态改为 `DOING`。
## 维护规则
diff --git a/docs/project-brief.md b/docs/project-brief.md
index ff07da5..6e370a7 100644
--- a/docs/project-brief.md
+++ b/docs/project-brief.md
@@ -1,7 +1,7 @@
# cmhub 项目介绍(给管理层)
> 面向决策与汇报的项目概览。技术细节见同目录架构与需求文档。
-> 日期:2026-07-03 | 阶段:Phase 4 用户端(T-501/T-502/T-503 已完成,下一步 T-504)
+> 日期:2026-07-03 | 阶段:Phase 5 后台与发布(Phase 4 用户端已完成到 T-504)
## 一句话概括
@@ -104,8 +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 防护、生成/认证限流和充值金额上限已落地。
-- Phase 4 已完成 T-501/T-502/T-503:用户端注册 / 登录(allauth)、邮箱验证、登出、API Key 自助管理、个人中心汇总、充值记录和消费记录已落地;Key 明文只显示一次,库内只保留 hash 和 prefix,删除即吊销;记录页仅见本人数据。
-- 下一步是 T-504:实现充值页,发起充值后展示二维码并轮询订单状态,到账以服务端回调 / 主动查单后的本地订单状态为准。
+- Phase 4 已完成 T-501~T-504:用户端注册 / 登录(allauth)、邮箱验证、登出、API Key 自助管理、个人中心汇总、充值记录、消费记录和充值页已落地;用户可创建充值订单、查看二维码票据并轮询到账;Key 明文只显示一次,库内只保留 hash 和 prefix,删除即吊销;记录页仅见本人数据。
+- 下一步是 T-401:完善运营后台,补齐用户/钱包/API Key/计费规则/充值订单/流水/调用记录的运营检索与手工调点能力。
---
*更多细节:愿景 `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 1e80fa9..8eae26f 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 5 后台与发布
## 电梯陈述(30 秒)
@@ -40,7 +40,7 @@
## 进度
-M1 骨架已完成;M2 已通过录制生成 smoke 验证 AI 调用链路;M3 计费、对外 API、充值闭环与上线前安全加固已完成到 T-306;Phase 4 已完成注册 / 登录(T-501)、API Key 管理页(T-502)和个人中心 / 记录页(T-503)。下一步做充值页。里程碑:M1 骨架可跑 · M2 跑通生成 · M3 计费充值闭环 · M4 用户端可用 · M5 验收上线。
+M1 骨架已完成;M2 已通过录制生成 smoke 验证 AI 调用链路;M3 计费、对外 API、充值闭环与上线前安全加固已完成到 T-306;M4 用户端已完成注册 / 登录(T-501)、API Key 管理页(T-502)、个人中心 / 记录页(T-503)和充值页(T-504)。下一步做运营后台完善。里程碑:M1 骨架可跑 · M2 跑通生成 · M3 计费充值闭环 · M4 用户端可用 · M5 验收上线。
---
*详见 `project-brief.md`(完整介绍)。*
diff --git a/docs/routes.md b/docs/routes.md
index 3dac329..1fe4f11 100644
--- a/docs/routes.md
+++ b/docs/routes.md
@@ -14,7 +14,7 @@
| `/records/usage` | GET | 点数使用(消费/调用)记录 | session |
| `/apikeys` | GET/POST | API Key 管理:列表 / 生成 / 删除(删除即吊销,明文只显示一次) | session |
-T-501 已落地 `/signup`、`/login`、`/logout` 与最小 `/dashboard`。T-502 已落地 `/apikeys`:登录用户只能管理自己的 Key,生成后明文只显示一次,列表只显示 prefix,删除为吊销 `revoked`。T-503 已扩展 `/dashboard` 为个人中心汇总,并落地 `/records/recharge` 与 `/records/usage`:充值总额按已支付订单统计,入账 / 消费 / 退款点数按 `PointsLedger` 统计,记录页只查询当前登录用户数据。充值发起页留给 T-504。
+T-501 已落地 `/signup`、`/login`、`/logout` 与最小 `/dashboard`。T-502 已落地 `/apikeys`:登录用户只能管理自己的 Key,生成后明文只显示一次,列表只显示 prefix,删除为吊销 `revoked`。T-503 已扩展 `/dashboard` 为个人中心汇总,并落地 `/records/recharge` 与 `/records/usage`:充值总额按已支付订单统计,入账 / 消费 / 退款点数按 `PointsLedger` 统计,记录页只查询当前登录用户数据。T-504 已落地 `/recharge`:登录用户可选择金额和支付方式创建 pending 充值订单,页面展示二维码票据并轮询 `/api/v1/recharge/status`,到账后刷新余额。
## API 路由(对外,DRF)
diff --git a/progress.md b/progress.md
index 29c565e..5ae7fb9 100644
--- a/progress.md
+++ b/progress.md
@@ -801,3 +801,27 @@
- 阻塞:无。测试阶段仍有 allauth `account.EmailAddress` 条件唯一约束在 MySQL 上不可创建的 `models.W036` 警告;本项目账本邮箱唯一性由 `user.email` 唯一约束承担。
- 决策:T-503 页面只读,不写 `UserWallet.points_balance`;余额用 `get_balance_snapshot()`,充值总额按 paid `RechargeOrder.amount_money` 汇总,入账点数按 `PointsLedger(recharge)` 汇总,消费记录直接展示 `PointsLedger(consume/refund)` 并关联 `CallRecord`,保证记录与流水同源。
- 下一步:领取 T-504 充值页(扫码 + 轮询到账)。
+
+## 2026-07-03 T-504 充值页(扫码 + 轮询到账)
+
+- 状态:DONE
+- 变更:
+ - `apps/portal/forms.py`:新增 `RechargeCreateForm`,校验充值金额、支付方式和 `RECHARGE_MAX_AMOUNT_CNY` 单笔上限。
+ - `apps/portal/views.py` / `urls.py`:新增 `/recharge` 用户端页面;GET 展示余额、充值表单、当前订单和最近充值;POST 复用 `create_recharge_order()` 创建 pending 订单并重定向到当前订单页,避免刷新重复下单。
+ - `apps/portal/templates/portal/recharge.html`:新增充值页,展示支付二维码票据,保留可复制票据兜底;浏览器轮询 `/api/v1/recharge/status`,订单 paid 后刷新页面重新读取余额。
+ - `apps/portal/templates/portal/base.html` / `dashboard.html`:新增充值入口。
+ - `apps/portal/tests.py`:新增充值页测试,覆盖登录保护、创建订单不加点不写流水、当前订单仅见本人、金额上限和 CSRF 保护。
+ - 同步更新 `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`。
+- 验证:
+ - `./init.ps1`:开工前通过,Python 3.12.3、依赖已满足、`manage.py check` 0 issues。
+ - `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 makemigrations --check --dry-run`:通过,No changes detected。
+ - `py -3.12 -m compileall apps config`:通过。
+ - `py -3.12 manage.py test apps.portal --noinput --keepdb --verbosity 2`:通过,19 tests OK。
+ - `py -3.12 manage.py test apps.users apps.portal apps.api --noinput --keepdb --verbosity 2`:首次因远程 MySQL 连接中断失败;拉长 MySQL 超时并在网络恢复后复跑通过,60 tests OK。
+ - `./init.ps1`:收尾通过,依赖已满足、`manage.py check` 0 issues。
+ - `git diff --check`:通过,仅 Windows CRLF 提示。
+- 阻塞:T-504 功能无阻塞。测试阶段仍有 allauth `account.EmailAddress` 条件唯一约束在 MySQL 上不可创建的 `models.W036` 警告;本项目账本邮箱唯一性由 `user.email` 唯一约束承担。真实支付仍需微信 / 支付宝商户密钥、证书、生产 SDK 依赖与公网回调地址。
+- 决策:`/recharge` 页面 POST 直接复用 billing 层 `create_recharge_order()` 创建订单,JSON `POST /api/v1/recharge/create` 继续保留给用户端脚本或后续前端调用;页面本身不写钱包和流水,到账只认回调或主动查单入账后的本地订单状态。
+- 下一步:领取 T-401 运营后台完善。