diff --git a/.gitignore b/.gitignore
index e61ae78..463e1bc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,8 @@
.env.*
!.env.example
ai_models.json
+.secrets/
+secrets/
# Python / Django local artifacts
__pycache__/
diff --git a/apps/api/tests.py b/apps/api/tests.py
index 5c0d619..9895e93 100644
--- a/apps/api/tests.py
+++ b/apps/api/tests.py
@@ -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):
diff --git a/apps/api/views.py b/apps/api/views.py
index bad60cd..1ea5b3d 100644
--- a/apps/api/views.py
+++ b/apps/api/views.py
@@ -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)
diff --git a/apps/billing/payment_gateways.py b/apps/billing/payment_gateways.py
index 68b8532..614e612 100644
--- a/apps/billing/payment_gateways.py
+++ b/apps/billing/payment_gateways.py
@@ -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":
diff --git a/apps/billing/tests.py b/apps/billing/tests.py
index e8d2211..5404b3c 100644
--- a/apps/billing/tests.py
+++ b/apps/billing/tests.py
@@ -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,
diff --git a/apps/portal/forms.py b/apps/portal/forms.py
index 9639635..1eb48a2 100644
--- a/apps/portal/forms.py
+++ b/apps/portal/forms.py
@@ -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"}),
diff --git a/apps/portal/templates/portal/recharge.html b/apps/portal/templates/portal/recharge.html
index 517a656..aea8bee 100644
--- a/apps/portal/templates/portal/recharge.html
+++ b/apps/portal/templates/portal/recharge.html
@@ -87,6 +87,12 @@
{{ current_order.status }}
+ {% if is_mock_payment_mode %}
+
+ 当前为支付测试模式。下方二维码只用于联调订单创建、页面渲染和状态轮询,不能使用微信或支付宝真实付款。
+
+ {% endif %}
+
diff --git a/apps/portal/tests.py b/apps/portal/tests.py
index d5ea390..80f120c 100644
--- a/apps/portal/tests.py
+++ b/apps/portal/tests.py
@@ -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)
diff --git a/apps/portal/views.py b/apps/portal/views.py
index 694d109..1524476 100644
--- a/apps/portal/views.py
+++ b/apps/portal/views.py
@@ -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]
diff --git a/docs/04-architecture.md b/docs/04-architecture.md
index 0a179b9..a1ae1dc 100644
--- a/docs/04-architecture.md
+++ b/docs/04-architecture.md
@@ -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` 控制单笔上限。
diff --git a/docs/api.md b/docs/api.md
index 397af72..6c277f4 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -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=...`
diff --git a/docs/current-state.md b/docs/current-state.md
index db21a14..efc7f03 100644
--- a/docs/current-state.md
+++ b/docs/current-state.md
@@ -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 耗时;未跑通前只能按保守超时内测,不能声称已验证。
## 当前目录要点
diff --git a/progress.md b/progress.md
index c08bc98..62ba5b5 100644
--- a/progress.md
+++ b/progress.md
@@ -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 后再恢复页面选项。
diff --git a/requirements.txt b/requirements.txt
index 82fb033..d8d23ed 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -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