fix: stabilize production payment flow

This commit is contained in:
QiuSW
2026-07-06 08:56:30 +08:00
parent 0d6b97f44d
commit e4565de0c4
14 changed files with 270 additions and 13 deletions
+2
View File
@@ -3,6 +3,8 @@
.env.*
!.env.example
ai_models.json
.secrets/
secrets/
# Python / Django local artifacts
__pycache__/
+21
View File
@@ -742,6 +742,27 @@ class RechargeCreateStatusApiTests(TestCase):
1,
)
def test_recharge_status_keeps_pending_when_active_query_raises_unexpected_error(self):
order = self.create_order(amount="20.00", points_granted=200)
self.client.force_login(self.user)
with patch("apps.api.views.query_payment_order", side_effect=RuntimeError("gateway down")):
response = self.client.get(self.status_url, {"order_no": order.order_no})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["order_no"], order.order_no)
self.assertEqual(response.data["status"], RechargeOrder.Status.PENDING)
self.wallet.refresh_from_db()
order.refresh_from_db()
self.assertEqual(self.wallet.points_balance, 100)
self.assertEqual(order.status, RechargeOrder.Status.PENDING)
self.assertFalse(
PointsLedger.objects.filter(
ref_order_id=order.id,
change_type=PointsLedger.ChangeType.RECHARGE,
).exists()
)
class FakeGenerationProvider:
def __init__(self, *, capabilities=None):
+7
View File
@@ -222,6 +222,13 @@ class RechargeStatusView(PortalSessionApiView):
api_error(exc.code, "支付查单结果与本地订单不一致"),
status=status.HTTP_400_BAD_REQUEST,
)
except Exception as exc: # pragma: no cover - external gateway/runtime.
logger.warning(
"Active recharge query failed for %s: %s",
order.order_no,
exc.__class__.__name__,
)
order.refresh_from_db()
return Response(_recharge_order_response(order), status=status.HTTP_200_OK)
+27 -3
View File
@@ -94,6 +94,25 @@ def _wechat_amount_cents(value) -> int:
return int((_decimal_money(value) * Decimal("100")).to_integral_value())
def _wechat_response_payload(response) -> dict:
if isinstance(response, dict):
return response
if isinstance(response, tuple) and len(response) >= 2:
payload = response[1]
if isinstance(payload, dict):
return payload
if isinstance(payload, bytes):
payload = payload.decode("utf-8")
if isinstance(payload, str):
try:
parsed = json.loads(payload)
except json.JSONDecodeError as exc:
raise ValueError("WeChat API response is not valid JSON.") from exc
if isinstance(parsed, dict):
return parsed
raise ValueError("WeChat API response has an unsupported format.")
def _default_expires_at():
minutes = int(getattr(settings, "PAYMENT_QR_EXPIRES_MINUTES", 10) or 10)
return timezone.now() + timedelta(minutes=minutes)
@@ -198,7 +217,7 @@ def _create_mock_payment_order(order: RechargeOrder) -> PaymentOrderCode:
def _create_wechat_payment_order_with_sdk(order: RechargeOrder) -> PaymentOrderCode:
try:
from wechatpayv3 import WeChatPay # type: ignore
from wechatpayv3 import WeChatPay, WeChatPayType # type: ignore
except ImportError as exc:
raise PaymentOrderCreateError("wechatpayv3 is not installed.") from exc
@@ -231,11 +250,16 @@ def _create_wechat_payment_order_with_sdk(order: RechargeOrder) -> PaymentOrderC
"total": _wechat_amount_cents(order.amount_money),
"currency": order.currency,
},
pay_type=WeChatPayType.NATIVE,
)
except Exception as exc: # pragma: no cover - depends on merchant SDK/runtime.
raise PaymentOrderCreateError("WeChat native order creation failed.") from exc
code_url = response.get("code_url") if isinstance(response, dict) else None
try:
payload = _wechat_response_payload(response)
except ValueError as exc:
raise PaymentOrderCreateError("WeChat native order response was invalid.") from exc
code_url = payload.get("code_url")
if not code_url:
raise PaymentOrderCreateError("WeChat native order did not return code_url.")
return PaymentOrderCode(code_url=str(code_url), expires_at=_default_expires_at())
@@ -398,7 +422,7 @@ def _query_wechat_payment_order_with_sdk(order: RechargeOrder) -> RechargePaymen
"WeChat active payment query SDK binding is not configured."
)
try:
resource = query(out_trade_no=order.order_no)
resource = _wechat_response_payload(query(out_trade_no=order.order_no))
except Exception as exc: # pragma: no cover - depends on merchant SDK/runtime.
raise PaymentQueryUnavailableError("WeChat trade query failed.") from exc
if resource.get("trade_state") != "SUCCESS":
+96
View File
@@ -1,8 +1,12 @@
import threading
import time
import uuid
import sys
import tempfile
from datetime import timedelta
from decimal import Decimal
from types import SimpleNamespace
from unittest.mock import patch
from cryptography.fernet import Fernet
from django.contrib import admin
@@ -28,6 +32,7 @@ from apps.billing.pricing import (
get_pricing_rule,
quote_recharge_points,
)
from apps.billing import payment_gateways
from apps.billing.payment_gateways import PaymentOrderCode
from apps.billing.services import (
InsufficientPointsError,
@@ -497,6 +502,97 @@ class BillingServiceTests(TestCase):
self.assertEqual(order.status, RechargeOrder.Status.FAILED)
self.assertEqual(order.code_url, "")
def test_wechat_sdk_native_order_passes_explicit_pay_type(self):
order = self.create_recharge_order(amount="1.00", points_granted=10)
class FakeWeChatPayType:
NATIVE = object()
class FakeWeChatPay:
pay_kwargs = None
def __init__(self, **_kwargs):
pass
def pay(self, **kwargs):
FakeWeChatPay.pay_kwargs = kwargs
return (
200,
'{"code_url":"weixin://wxpay/bizpayurl?pr=test-ticket"}',
)
fake_module = SimpleNamespace(
WeChatPay=FakeWeChatPay,
WeChatPayType=FakeWeChatPayType,
)
with tempfile.TemporaryDirectory() as tmpdir:
private_key_path = f"{tmpdir}/apiclient_key.pem"
with open(private_key_path, "w", encoding="utf-8") as private_key:
private_key.write("test-private-key")
with (
patch.dict(sys.modules, {"wechatpayv3": fake_module}),
override_settings(
WECHAT_PAY_APPID="wx-test-appid",
WECHAT_PAY_MCHID="1900000001",
WECHAT_PAY_API_V3_KEY="a" * 32,
WECHAT_PAY_CERT_SERIAL_NO="ABC123",
WECHAT_PAY_PRIVATE_KEY_PATH=private_key_path,
WECHAT_PAY_NOTIFY_URL="https://cm.example.test/api/v1/recharge/callback/wechat",
),
):
payment_order = payment_gateways._create_wechat_payment_order_with_sdk(order)
self.assertEqual(
payment_order.code_url,
"weixin://wxpay/bizpayurl?pr=test-ticket",
)
self.assertIs(FakeWeChatPay.pay_kwargs["pay_type"], FakeWeChatPayType.NATIVE)
def test_wechat_sdk_query_parses_tuple_json_response(self):
order = self.create_recharge_order(amount="1.00", points_granted=10)
class FakeWeChatPay:
def __init__(self, **_kwargs):
pass
def query(self, **_kwargs):
return (
200,
(
'{"trade_state":"SUCCESS",'
f'"out_trade_no":"{order.order_no}",'
'"amount":{"total":100},'
'"transaction_id":"wx-transaction-001",'
'"success_time":"2026-07-04T17:40:00+08:00"}'
),
)
fake_module = SimpleNamespace(WeChatPay=FakeWeChatPay)
with tempfile.TemporaryDirectory() as tmpdir:
private_key_path = f"{tmpdir}/apiclient_key.pem"
with open(private_key_path, "w", encoding="utf-8") as private_key:
private_key.write("test-private-key")
with (
patch.dict(sys.modules, {"wechatpayv3": fake_module}),
override_settings(
WECHAT_PAY_APPID="wx-test-appid",
WECHAT_PAY_MCHID="1900000001",
WECHAT_PAY_API_V3_KEY="a" * 32,
WECHAT_PAY_CERT_SERIAL_NO="ABC123",
WECHAT_PAY_PRIVATE_KEY_PATH=private_key_path,
WECHAT_PAY_NOTIFY_URL="https://cm.example.test/api/v1/recharge/callback/wechat",
),
):
payment = payment_gateways._query_wechat_payment_order_with_sdk(order)
self.assertEqual(payment.order_no, order.order_no)
self.assertEqual(payment.amount, Decimal("1.00"))
self.assertEqual(payment.transaction_id, "wx-transaction-001")
def test_precharge_call_debits_wallet_and_writes_pending_call_and_consume_ledger(self):
charge = precharge_call(
user=self.user,
-1
View File
@@ -41,7 +41,6 @@ class RechargeCreateForm(forms.Form):
label="支付方式",
choices=(
(RechargeOrder.PayMethod.WEIXIN, "微信"),
(RechargeOrder.PayMethod.ALIPAY, "支付宝"),
),
initial=RechargeOrder.PayMethod.WEIXIN,
widget=forms.RadioSelect(attrs={"class": "form-check-input"}),
@@ -87,6 +87,12 @@
<span class="badge text-bg-secondary" id="recharge-status-badge">{{ current_order.status }}</span>
</div>
{% if is_mock_payment_mode %}
<div class="alert alert-warning" role="alert">
当前为支付测试模式。下方二维码只用于联调订单创建、页面渲染和状态轮询,不能使用微信或支付宝真实付款。
</div>
{% endif %}
<div class="row g-4 align-items-start">
<div class="col-md-5">
<canvas id="recharge-qr" class="border rounded bg-white p-2" width="240" height="240"></canvas>
+49 -1
View File
@@ -666,6 +666,8 @@ class PortalAccountFlowTests(TestCase):
self.assertEqual(response.context["balance"].points_balance, 25)
self.assertContains(response, "充值金额")
self.assertContains(response, "创建订单")
self.assertContains(response, "微信")
self.assertNotContains(response, "支付宝")
self.assertContains(response, "portal/vendor/bootstrap/bootstrap.min.css")
self.assertContains(response, "portal/vendor/qrcode/qrcode.js")
self.assertContains(response, "QRCode.toCanvas")
@@ -675,6 +677,7 @@ class PortalAccountFlowTests(TestCase):
)
self.assertIsNotNone(finders.find("portal/vendor/qrcode/qrcode.js"))
@override_settings(PAYMENT_CALLBACK_MODE="mock")
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)
@@ -714,9 +717,35 @@ class PortalAccountFlowTests(TestCase):
self.assertEqual(follow_response.context["current_order"], order)
self.assertContains(follow_response, order.order_no)
self.assertContains(follow_response, "weixin://wxpay/cmhub-mock")
self.assertTrue(follow_response.context["is_mock_payment_mode"])
self.assertContains(follow_response, "当前为支付测试模式")
self.assertContains(follow_response, "不能使用微信或支付宝真实付款")
self.assertContains(follow_response, 'data-status-url="/api/v1/recharge/status"')
self.assertContains(follow_response, "data-recharge-order")
@override_settings(PAYMENT_CALLBACK_MODE="sdk")
def test_recharge_page_does_not_show_mock_notice_in_sdk_mode(self):
user = self.create_verified_user()
UserWallet.objects.create(user=user, points_balance=0)
order = self.create_recharge_order(
user,
order_no="R-SDK-RECHARGE-PAGE-504",
amount="20.00",
points=200,
status=RechargeOrder.Status.PENDING,
)
order.code_url = "weixin://wxpay/bizpayurl?pr=real-native-ticket"
order.save(update_fields=("code_url", "updated_at"))
self.client.force_login(user)
response = self.client.get(f"/recharge?order_no={order.order_no}")
self.assertEqual(response.status_code, 200)
self.assertFalse(response.context["is_mock_payment_mode"])
self.assertContains(response, "weixin://wxpay/bizpayurl")
self.assertNotContains(response, "当前为支付测试模式")
self.assertNotContains(response, "不能使用微信或支付宝真实付款")
def test_recharge_page_current_order_only_shows_current_user_order(self):
user = self.create_verified_user()
other_user = self.create_verified_user()
@@ -749,13 +778,32 @@ class PortalAccountFlowTests(TestCase):
response = self.client.post(
"/recharge",
{"amount": "100.01", "pay_method": RechargeOrder.PayMethod.ALIPAY},
{"amount": "100.01", "pay_method": RechargeOrder.PayMethod.WEIXIN},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "单笔充值金额不能超过 100.00 CNY")
self.assertFalse(RechargeOrder.objects.filter(user=user).exists())
def test_recharge_page_rejects_hidden_alipay_submit(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": "20.00", "pay_method": RechargeOrder.PayMethod.ALIPAY},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "选择一个有效的选项")
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)
+4 -1
View File
@@ -8,7 +8,7 @@ from django.views import View
from django.views.generic import FormView, TemplateView
from apps.ai.catalog import get_public_model_catalog
from apps.billing.payment_gateways import PaymentOrderCreateError
from apps.billing.payment_gateways import PaymentOrderCreateError, payment_callback_mode
from apps.billing.pricing import NoExchangeRateError
from apps.billing.models import PointsLedger, RechargeOrder
from apps.billing.services import (
@@ -164,7 +164,10 @@ class RechargePageView(LoginRequiredMixin, FormView):
).first()
context["balance"] = get_balance_snapshot(self.request.user)
context.update(get_portal_account_summary(self.request.user))
current_payment_mode = payment_callback_mode()
context["current_order"] = current_order
context["payment_callback_mode"] = current_payment_mode
context["is_mock_payment_mode"] = current_payment_mode == "mock"
context["recent_recharge_orders"] = get_recharge_orders_for_user(
self.request.user
)[:5]
+1 -1
View File
@@ -46,7 +46,7 @@ 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 订单并重定向到当前订单页,避免刷新重复下单;页面用本地 static 自托管的 qrcode.js 渲染 `code_url`,同时保留可复制支付票据兜底;浏览器每秒轮询 `/api/v1/recharge/status`,订单 paid 后刷新页面重新读取余额。页面不直接写 `UserWallet.points_balance` 或 `PointsLedger`。
T-504 已实现用户端 `/recharge` 页面:GET 展示当前余额、充值表单、当前订单和最近充值;POST 经 `RechargeCreateForm` 校验金额与支付方式后复用 `create_recharge_order()` 创建 pending 订单并重定向到当前订单页,避免刷新重复下单;页面用本地 static 自托管的 qrcode.js 渲染 `code_url`,同时保留可复制支付票据兜底;浏览器每秒轮询 `/api/v1/recharge/status`,订单 paid 后刷新页面重新读取余额。页面不直接写 `UserWallet.points_balance` 或 `PointsLedger`。当 `PAYMENT_CALLBACK_MODE=mock` 时,页面必须醒目提示二维码为测试票据,不能用于微信/支付宝真实支付。当前因支付宝可信 IP 未配置,用户端表单暂只开放微信支付,底层支付宝 API / 回调 / SDK 路径保留。
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` 控制单笔上限。
+2 -2
View File
@@ -37,7 +37,7 @@ T-502 已实现用户端 API Key 自助管理基线:`/apikeys` 走 Django sess
T-503/T-505 已实现用户端个人中心 / 记录页基线:`/dashboard` 走 Django session,展示当前用户剩余点数、充值总额、入账点数、净消耗点数和最近记录;`/records/recharge` 分页展示当前登录用户的充值订单;`/records/usage` 分页展示当前登录用户的 `consume` / `refund` 点数流水并关联调用信息。页面只读,不写 `UserWallet.points_balance`。
T-504/T-505 已实现用户端充值页基线:`/recharge` 走 Django session + CSRF,GET 展示余额、充值表单、当前订单和最近充值;POST 创建 pending 充值订单并用本地 static 自托管的 qrcode.js 展示支付二维码票据;浏览器轮询 `GET /api/v1/recharge/status`,订单 paid 后刷新页面重新读取余额。页面不直接加点,到账仍以支付回调或主动查单入账后的本地订单状态为准。
T-504/T-505 已实现用户端充值页基线:`/recharge` 走 Django session + CSRF,GET 展示余额、充值表单、当前订单和最近充值;POST 创建 pending 充值订单并用本地 static 自托管的 qrcode.js 展示支付二维码票据;浏览器轮询 `GET /api/v1/recharge/status`,订单 paid 后刷新页面重新读取余额。页面不直接加点,到账仍以支付回调或主动查单入账后的本地订单状态为准。若当前 `PAYMENT_CALLBACK_MODE=mock`,页面必须明确提示二维码为测试票据、不能用微信/支付宝真实支付,避免用户把 mock 二维码当成生产收款码。当前因支付宝可信 IP 未配置,用户端 `/recharge` 表单暂只开放微信支付,底层 JSON API / 回调 / SDK 路径仍保留支付宝。
通用错误响应:
@@ -265,7 +265,7 @@ query_and_apply_recharge_payment(order_no: str, query_func) -> RechargeResult
- 微信走 `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` 模式与真实商户配置。
- 开发 / 测试 `PAYMENT_CALLBACK_MODE=mock` 时返回 mock 二维码票据(如 `weixin://wxpay/cmhub-mock...` / `https://qr.alipay.com/cmhub-mock...`),只用于联调订单创建、页面渲染和轮询,不会跳转到真实微信/支付宝收银台;用户端充值页应展示醒目 mock 提示。生产应使用 `sdk` 模式与真实商户配置。
- 单笔金额超过 `RECHARGE_MAX_AMOUNT_CNY` 会返回 `bad_request`,不创建 `RechargeOrder`。
### `GET /api/v1/recharge/status?order_no=...`
+6 -3
View File
@@ -11,20 +11,23 @@
## 当前快照
- 日期:2026-07-04
- 日期:2026-07-06
- 阶段:Phase 6 增强(MVP 后);Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化已完成,Phase 5 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档已完成,Phase 6 T-601 可用别名发现、T-602 django-admin 中文化第 1-3 层已完成;T-603 django-admin 字段级中文化代码与迁移已完成,但完整测试被工作区既有邮箱验证配置改动阻塞,暂记 `BLOCKED`
- 技术栈:系统 Python 3.12.3 + Django 5.2.15 + DRF 3.16.1 + django-allauth 65.18.0 + PyMySQL 1.1.3 + cryptography 46.0.7 + requests 2.34.2 + django-admin;MySQL 8.4 已接入 settings,并支持 `MYSQL_CONNECT_TIMEOUT` / `MYSQL_READ_TIMEOUT` / `MYSQL_WRITE_TIMEOUT`;用户端已用 Django 模板 SSR + Bootstrap + allauth 落地注册登录;生产部署口径为 VPS / 宝塔 + Nginx + Gunicorn(gthread) + systemd;详见 `03-tech-stack.md` 与 `deployment.md`
- 技术栈:系统 Python 3.12.3 + Django 5.2.15 + DRF 3.16.1 + django-allauth 65.18.0 + PyMySQL 1.1.3 + cryptography 49.0.0 + requests 2.34.2 + wechatpayv3 2.0.2 + python-alipay-sdk 3.4.0 + django-admin;MySQL 8.4 已接入 settings,并支持 `MYSQL_CONNECT_TIMEOUT` / `MYSQL_READ_TIMEOUT` / `MYSQL_WRITE_TIMEOUT`;用户端已用 Django 模板 SSR + Bootstrap + allauth 落地注册登录;生产部署口径为 VPS / 宝塔 + Nginx + Gunicorn(gthread) + systemd;详见 `03-tech-stack.md` 与 `deployment.md`
- 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器);T-102 已新增 `AiModel` / `ModelAlias`、Fernet 加密密钥存储、别名解析、admin 配置页、`import_ai_models` 导入命令;T-103 已新增 `AiConfigAuditLog` 审计表、admin 只读页面和后台保存/删除审计 hook;T-104/T-105 已完成录制 title/image smoke 与审核修补;T-201 已新增 `UserWallet` / `ApiKey`、`PointsLedger` / `CallRecord`、对应 admin 与迁移;T-202 已新增 `PricingRule` / `ExchangeRate`、`apps.billing.pricing` 计费计算函数、admin 配置页与迁移;T-203 已新增 `apps.billing.services`,实现并发安全预扣、成功确认与幂等失败退点;T-204 已新增 `billing.0003_pointsledger_unique_ledger_change_type_per_call`,用 MySQL 可落地的 `ref_call + change_type` 复合唯一约束兜底防重复 refund;T-301 已新增 `apps.api.authentication.ApiKeyAuthentication` 与 `ExternalApiView`;T-302 已新增生成接口编排、序列化器、图片本地存储和 `/api/v1/generate/title|image` 路由;T-303 已新增 `apps.billing.services.get_balance_snapshot()` 与 `/api/v1/balance` 余额查询接口;T-304 已新增 `RechargeOrder`、充值回调验签适配器、幂等入账服务、微信/支付宝回调路由与迁移 `billing.0004_rechargeorder_and_more`;T-305 已新增 `create_recharge_order()`、微信/支付宝扫码下单 mock/SDK 入口、`/api/v1/recharge/create` 与 `/api/v1/recharge/status`;T-306 已新增 `apps.api.throttles`、`apps.api.exceptions`、`REST_FRAMEWORK` 安全默认认证、生成/认证失败限流、`image_url` SSRF 防护与响应大小上限、充值单笔金额上限;T-501 已接入 allauth,新增 portal 路由、注册适配器、登录/注册/登出模板和最小 dashboard,注册成功创建 0 点钱包且不写赠点流水;T-502 已新增 `/apikeys`、API Key 创建表单、列表页和删除(吊销)动作,生成后明文只显示一次,列表只显示 prefix;T-503 已扩展 `/dashboard` 为个人中心汇总,并新增 `/records/recharge` 充值记录与 `/records/usage` 消费记录,只读展示当前用户数据;T-504 已新增 `/recharge` 页面、`RechargeCreateForm`、充值导航入口和轮询脚本,页面创建 pending 订单、展示二维码票据、轮询 `/api/v1/recharge/status`,订单 paid 后刷新余额;T-505 已把 Bootstrap 5 CSS 与 qrcode.js vendoring 到 `apps/portal/static/portal/vendor/`,页面不再依赖 jsdelivr,并把充值记录 / 消费记录改为 Django `Paginator` 分页;T-401 已新增 `adjust_wallet_points()` 手工调点服务、钱包 admin 专用调点表单与模板,后台可管理/检索用户、钱包、API Key(脱敏)、计费规则、汇率、充值订单、点数流水和调用记录,流水/订单/调用记录保持只读;T-402 已新增 `docs/mvp-acceptance.md`,按 P0 验收矩阵记录 MVP 完整验收结论、测试证据和已知限制;T-403 已新增 `docs/deployment.md` 与 `requirements-production.txt`,并在 settings 中补齐 `STATIC_ROOT`、`CSRF_TRUSTED_ORIGINS`、共享 `CACHES`、HTTPS cookie、proxy SSL 与 HSTS 环境变量支持;T-601 已新增 `apps.ai.catalog.get_public_model_catalog()`、`GET /api/v1/models` 与 portal `/models` 只读页面,只展示 active 可调用别名、能力、是否需要原图和点数单价,不解密 provider key,不暴露底层 SKU / URL / key / `extra_body`
- 用户端导航:顶部导航 active 状态已修复,`portal/base.html` 基于 `request.resolver_match.url_name` 高亮当前页面入口,并用 `aria-current="page"` 标记;「充值」不再在非充值页固定深色高亮。
- 测试:T-601 已验证:`.\init.ps1` 通过(Python 3.12.3,依赖已满足,`manage.py check` 0 issues,打印启动命令);`py -3.12 -m py_compile apps\ai\catalog.py apps\api\views.py apps\api\urls.py apps\api\tests.py apps\portal\views.py apps\portal\urls.py apps\portal\tests.py` 通过;`py -3.12 manage.py check` 通过;`py -3.12 manage.py makemigrations --check --dry-run` 无变化;`git diff --check` 通过;`py -3.12 manage.py test apps.api.tests.ModelsCatalogApiTests apps.portal.tests.PortalAccountFlowTests.test_models_page_requires_session_login apps.portal.tests.PortalAccountFlowTests.test_models_page_lists_public_aliases_prices_and_unpriced_state --keepdb --noinput --verbosity 2` 首次运行时 3 条 API 测试已通过,portal `setUpClass` 阶段因远程 MySQL 连接 43.128.3.240 超时中断;随后 `Test-NetConnection 43.128.3.240 -Port 3306` 显示 `TcpTestSucceeded=True`,单独重跑两条 portal 测试通过,2 tests OK。T-403 已验证:`./init.ps1` 开工前通过;`py -3.12 manage.py check` 通过;`py -3.12 manage.py makemigrations --check --dry-run` 无变化;`py -3.12 manage.py findstatic portal/vendor/bootstrap/bootstrap.min.css portal/vendor/qrcode/qrcode.js --verbosity 1` 找到两个本地 static 文件;`py -3.12 manage.py check --deploy` 在当前开发 `.env` 下仅报预期安全配置警告,临时注入生产型安全环境变量(含 HSTS includeSubDomains/preload)后通过,0 issues;`py -3.12 manage.py collectstatic --dry-run --noinput` 通过,预期收集 169 个 static 文件;`py -3.12 manage.py createcachetable --dry-run cmhub_cache` 通过,输出 MySQL cache 表 DDL;`py -3.12 -m compileall config` 通过;尝试 `py -3.12 manage.py test apps.api --noinput --keepdb --verbosity 2` 时 25 条用例已通过,`GenerateApiTests` 14 条因远程 MySQL 连接重置/事务中断被记 ERROR,随后 `Test-NetConnection 43.128.3.240 -Port 3306` 端口可达,单独重跑 `py -3.12 manage.py test apps.api.tests.GenerateApiTests --noinput --keepdb --verbosity 2` 通过,14 tests OK。测试/迁移阶段仍有 allauth `account.EmailAddress` 条件唯一约束在 MySQL 上不可创建的 `models.W036` 警告;本项目用户账本邮箱唯一性由 `user.email` 唯一约束承担。
- 最新验证:T-603 字段级中文化已验证 `py -3.12 -m py_compile apps\users\models.py apps\users\admin.py apps\billing\models.py apps\billing\admin.py apps\ai\models.py apps\ai\admin.py` 通过;`py -3.12 manage.py makemigrations` 仅生成 `users.0005` / `billing.0006` / `ai.0004` 三个 `AlterField` 迁移;`py -3.12 manage.py sqlmigrate users 0005`、`billing 0006`、`ai 0004` 均为 `(no-op)`;`py -3.12 manage.py migrate` 应用成功,仅出现既有 allauth MySQL `models.W036` 警告;`py -3.12 manage.py makemigrations --check --dry-run` 通过,No changes detected;`py -3.12 manage.py check` 通过;`git diff --check` 通过,仅 Windows CRLF 提示;`py -3.12 manage.py test --keepdb --noinput --verbosity 2` 跑 125 条,123 条通过,2 条失败,失败原因是工作区既有 `config/settings.py` 把 `ACCOUNT_EMAIL_VERIFICATION` 改为 `none`,导致 `test_signup_creates_unverified_user_wallet_with_zero_points_and_no_ledger` 收不到验证邮件、`test_unverified_email_cannot_establish_login_session` 未验证用户仍建立登录 session。
- 线上热修验证:`/admin/billing/exchangerate/` 500 根因是 `ExchangeRateAdmin.date_hierarchy` 触发 MySQL `CONVERT_TZ`,而生产 MySQL 未加载时区表,Django 抛 `ValueError: Database returned an invalid datetime value`;已移除 `apps.billing.admin` 中 4 个 DateTime `date_hierarchy`,保留 `list_filter` 日期筛选。验证 `py -3.12 -m py_compile apps\billing\admin.py apps\billing\tests.py`、`py -3.12 manage.py check`、`py -3.12 manage.py test apps.billing.tests.BillingAdminTests.test_exchange_rate_changelist_renders_when_rates_exist --keepdb --noinput --verbosity 2` 均通过。
- Mock 支付提示:`PAYMENT_CALLBACK_MODE=mock` 时,用户端 `/recharge` 的待支付订单区域会显示醒目测试模式提示,说明二维码只用于联调订单创建、页面渲染和状态轮询,不能使用微信或支付宝真实付款;`sdk` 模式不显示该提示。验证 `py -3.12 -m py_compile apps\portal\views.py apps\portal\tests.py`、`py -3.12 manage.py check`、`py -3.12 manage.py test apps.portal.tests.PortalAccountFlowTests.test_recharge_page_post_creates_pending_order_without_crediting_wallet_or_ledger apps.portal.tests.PortalAccountFlowTests.test_recharge_page_does_not_show_mock_notice_in_sdk_mode --keepdb --noinput --verbosity 2` 均通过。
- 微信正式下单热修:线上微信 Native 下单失败根因是 `wechatpayv3` SDK 需要显式 `pay_type=WeChatPayType.NATIVE`,且成功响应为 `(status_code, json_string)` tuple 而非 dict;已兼容 tuple/json 响应并覆盖下单与主动查单。线上用修复后的 `_create_wechat_payment_order_with_sdk()` 创建 1 分诊断预支付请求,确认返回 `weixin://wxpay/bizpayurl` 票据。另:pending 订单主动查单异常不再让 `/api/v1/recharge/status` 返回 500,而是保持当前订单状态返回 200。
- 支付宝临时下线:线上支付宝预下单返回 `isv.illegal-client-ip`(当前调用 IP 不在支付宝可信名单中),需在支付宝开放平台把 VPS 出口 IP `43.128.3.240` 加入可信 IP 后再恢复。因此用户端 `/recharge` 表单暂时只展示并接受微信支付;底层 API、模型枚举、回调与支付宝 SDK 路径保留不删。
- 数据: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`;真实密钥/支付凭证不得写入代码或文档样例。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`。T-403 起生产静态、共享 cache 与 HTTPS 安全配置包含 `STATIC_URL`、`STATIC_ROOT`、`DJANGO_CACHE_BACKEND`、`DJANGO_CACHE_LOCATION`、`DJANGO_CSRF_TRUSTED_ORIGINS`、`DJANGO_SESSION_COOKIE_SECURE`、`DJANGO_CSRF_COOKIE_SECURE`、`DJANGO_SECURE_SSL_REDIRECT`、`DJANGO_SECURE_PROXY_SSL_HEADER`、`DJANGO_SECURE_HSTS_SECONDS`。
- 当前 blocker:T-603 完整测试当前被工作区既有 `config/settings.py` 改动阻塞:`ACCOUNT_EMAIL_VERIFICATION` 从 `mandatory` 改为 `none`,导致注册不发验证邮件、未验证用户也能登录,2 条 portal 邮箱验证测试失败;需恢复 `mandatory` 后重跑失败用例和完整测试,再把 T-603 标为 `DONE`。另:远程 MySQL 连接仍可能很慢或间歇超时,完整测试需预留较长时间并优先用 `--keepdb` 串行跑,必要时改用更稳定的测试库。微信/支付宝真实商户密钥/证书与生产 SDK 依赖仍待提供/安装;真实 AI 上游 smoke 需要先配置 `AI_KEY_ENCRYPTION_KEY` 并导入 AiModel/ModelAlias。图片同步真实耗时风险仍未退,T-403 已在 `deployment.md` 明确上线前必须记录真实图片 smoke 耗时;未跑通前只能按保守超时内测,不能声称已验证。
- 当前 blocker:T-603 完整测试当前被工作区既有 `config/settings.py` 改动阻塞:`ACCOUNT_EMAIL_VERIFICATION` 从 `mandatory` 改为 `none`,导致注册不发验证邮件、未验证用户也能登录,2 条 portal 邮箱验证测试失败;需恢复 `mandatory` 后重跑失败用例和完整测试,再把 T-603 标为 `DONE`。另:远程 MySQL 连接仍可能很慢或间歇超时,完整测试需预留较长时间并优先用 `--keepdb` 串行跑,必要时改用更稳定的测试库。微信正式下单已能返回二维码,但线上微信回调曾出现 `PaymentVerificationError`,仍需单独修复并完成“付款后自动入账”闭环验收;支付宝恢复依赖开放平台把 `43.128.3.240` 加入可信 IP。真实 AI 上游 smoke 需要先配置 `AI_KEY_ENCRYPTION_KEY` 并导入 AiModel/ModelAlias。图片同步真实耗时风险仍未退,T-403 已在 `deployment.md` 明确上线前必须记录真实图片 smoke 耗时;未跑通前只能按保守超时内测,不能声称已验证。
## 当前目录要点
+46
View File
@@ -1103,3 +1103,49 @@
- `py -3.12 manage.py check`:通过,0 issues。
- `py -3.12 manage.py test apps.billing.tests.BillingAdminTests.test_exchange_rate_changelist_renders_when_rates_exist --keepdb --noinput --verbosity 2`:通过,1 test OK;测试阶段仍有既有 allauth MySQL `models.W036` 警告。
- 下一步:提交并部署后,在 VPS 上重启 `cmhub-web.service`,用 Django test client 或浏览器确认 `/admin/billing/exchangerate/` 已恢复 200。
## 2026-07-04 Mock 支付二维码提示
- 状态:DONE
- 背景:线上 `PAYMENT_CALLBACK_MODE=mock` 时生成的微信二维码内容为 `weixin://wxpay/cmhub-mock...`,该票据只用于联调,不会进入微信真实支付页;用户用微信扫码可能看到空白,容易误认为生产支付异常。
- 文档先行:
- `docs/api.md`:补充 mock 模式返回的是测试二维码票据,不能跳转真实微信/支付宝收银台;用户端充值页应展示醒目 mock 提示。
- `docs/04-architecture.md`:补充 `/recharge` 页面在 `PAYMENT_CALLBACK_MODE=mock` 时必须提示测试票据不可真实支付。
- 代码:
- `apps/portal/views.py`:向充值页模板传入 `payment_callback_mode` 与 `is_mock_payment_mode`。
- `apps/portal/templates/portal/recharge.html`:待支付订单区域在 mock 模式下显示 warning 提示。
- `apps/portal/tests.py`:覆盖 mock 模式显示提示、sdk 模式不显示提示。
- 验证:
- `py -3.12 -m py_compile apps\portal\views.py apps\portal\tests.py`:通过。
- `py -3.12 manage.py check`:通过,0 issues。
- `py -3.12 manage.py test apps.portal.tests.PortalAccountFlowTests.test_recharge_page_post_creates_pending_order_without_crediting_wallet_or_ledger apps.portal.tests.PortalAccountFlowTests.test_recharge_page_does_not_show_mock_notice_in_sdk_mode --keepdb --noinput --verbosity 2`:通过,2 tests OK;测试阶段仍有既有 allauth MySQL `models.W036` 警告。
## 2026-07-04 临时隐藏用户端支付宝充值入口
- 状态:DONE
- 背景:线上支付宝 `trade.precreate` 返回 `isv.illegal-client-ip` / `当前调用IP不在可信名单中`,VPS 出口 IP 已确认为 `43.128.3.240`;在支付宝开放平台可信 IP 未配置前,用户端展示支付宝会持续创建失败订单。
- 变更:
- `apps/portal/forms.py`:`RechargeCreateForm.pay_method` 暂时只保留微信选项,页面不再渲染支付宝 radio,手工提交 `pay_method=alipay` 也会被表单拒绝。
- `apps/portal/tests.py`:覆盖充值页不显示支付宝、超过上限仍按微信校验、隐藏支付宝提交不创建订单。
- `docs/current-state.md`:记录支付宝临时下线原因和恢复条件;底层 API / 回调 / SDK 路径保留。
- 恢复条件:支付宝开放平台把 `43.128.3.240` 加入可信 IP 白名单并通过真实预下单 smoke 后,再把 `RechargeCreateForm` 的支付宝 choice 加回。
## 2026-07-06 支付正式模式热修收敛
- 状态:DONE
- 背景:生产已切到 `PAYMENT_CALLBACK_MODE=sdk` 并配置微信 / 支付宝真实参数后,微信 1 元下单先后暴露两个 SDK 兼容问题;支付宝预下单返回业务错误 `isv.illegal-client-ip`。
- 变更:
- `requirements.txt`:新增 `wechatpayv3` / `python-alipay-sdk`,并把 `cryptography` 上限放宽到 `<50`,对齐真实支付 SDK 依赖。
- `.gitignore`:忽略 `.secrets/` / `secrets/`,避免支付证书和私钥误入库。
- `apps/billing/payment_gateways.py`:微信 Native 下单显式传 `WeChatPayType.NATIVE`;兼容 `wechatpayv3` 返回 `(status_code, json_string)` 的 tuple 响应,统一解析 `code_url` 与查单结果。
- `apps/api/views.py`:pending 充值订单主动查单遇到支付网关运行时异常时不再让状态接口 500,而是记录 warning 并保持当前订单状态返回 200。
- `apps/portal/forms.py`:支付宝可信 IP 未配置前,用户端充值表单只保留微信;手工提交 `pay_method=alipay` 会被表单拒绝。
- `docs/api.md` / `docs/04-architecture.md` / `docs/current-state.md`:同步 mock 提示、微信正式下单热修、支付宝临时下线和恢复条件。
- 验证:
- `py -3.12 -m py_compile apps\billing\payment_gateways.py apps\api\views.py apps\billing\tests.py apps\api\tests.py`:通过。
- `py -3.12 manage.py check`:通过,0 issues。
- `py -3.12 manage.py test apps.billing.tests.BillingServiceTests.test_wechat_sdk_native_order_passes_explicit_pay_type apps.billing.tests.BillingServiceTests.test_wechat_sdk_query_parses_tuple_json_response apps.api.tests.RechargeCreateStatusApiTests.test_recharge_status_keeps_pending_when_active_query_raises_unexpected_error --keepdb --noinput --verbosity 2`:通过,3 tests OK。
- `py -3.12 -m py_compile apps\portal\forms.py apps\portal\tests.py`:通过。
- `py -3.12 manage.py test apps.portal.tests.PortalAccountFlowTests.test_recharge_page_requires_login_and_shows_form apps.portal.tests.PortalAccountFlowTests.test_recharge_page_rejects_amount_above_configured_maximum apps.portal.tests.PortalAccountFlowTests.test_recharge_page_rejects_hidden_alipay_submit --keepdb --noinput --verbosity 2`:通过,3 tests OK。
- 线上:同步热修后 `python3.12 manage.py check` 通过,`cmhub-web` / `cmhub-generate` 重启后均为 `active`;修复后的微信 1 分诊断预支付请求返回 `weixin://wxpay/bizpayurl` 票据;用户端表单 choices 仅剩 `微信`,支付宝提交无效。
- 后续:微信下单已通,但线上微信回调曾出现 `PaymentVerificationError`,仍需修复并做真实支付到账闭环;支付宝需在开放平台把 VPS 出口 IP `43.128.3.240` 加入可信 IP 后再恢复页面选项。
+3 -1
View File
@@ -2,5 +2,7 @@ Django>=5.2,<5.3
djangorestframework>=3.16,<3.17
django-allauth>=65.18,<66
PyMySQL>=1.1,<1.2
cryptography>=42,<47
cryptography>=42,<50
requests>=2.32,<3
wechatpayv3>=2.0,<3
python-alipay-sdk>=3.4,<4