feat: add recharge create and status APIs

This commit is contained in:
QiuSW
2026-07-03 09:34:07 +08:00
parent c15a07a5c2
commit fa87359a2c
18 changed files with 808 additions and 22 deletions
+1
View File
@@ -21,6 +21,7 @@ AI_KEY_ENCRYPTION_KEY=base64-fernet-key
# Payment callback verification
PAYMENT_CALLBACK_MODE=mock
PAYMENT_MOCK_CALLBACK_SECRET=change-me-mock-callback-secret
PAYMENT_QR_EXPIRES_MINUTES=10
# WeChat Pay V3 native
WECHAT_PAY_APPID=wx-your-appid
+1 -1
View File
@@ -25,7 +25,7 @@ Python 3.12 / Django 5.2 LTS + DRF / django-admin / 用户端 Django 模板 SSR
## 当前状态
Phase 2 计费核心已完成,T-301 API Key 鉴权、T-302 生成标题 / 图片接口、T-303 余额查询接口与 T-304 充值回调已落地:对外 API 可用 `Authorization: Bearer <API_KEY>` 调用生成能力或查询点数余额,充值回调按订单幂等入账。下一步是 T-305 扫码充值下单 + 轮询。详见 [`docs/current-state.md`](docs/current-state.md)。
Phase 2 计费核心已完成,T-301 API Key 鉴权、T-302 生成标题 / 图片接口、T-303 余额查询接口、T-304 充值回调与 T-305 扫码充值下单 + 轮询已落地:对外 API 可用 `Authorization: Bearer <API_KEY>` 调用生成能力或查询点数余额;用户端 session 流程可创建充值订单、获取二维码票据并轮询订单状态,充值回调按订单幂等入账。下一步进入 Phase 4 用户端注册 / 登录。详见 [`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) 第四节计费时序。
+21
View File
@@ -1,5 +1,9 @@
from decimal import Decimal
from rest_framework import serializers
from apps.billing.models import RechargeOrder
class GenerateTitleRequestSerializer(serializers.Serializer):
prompt = serializers.CharField(trim_whitespace=True, allow_blank=False)
@@ -43,3 +47,20 @@ class GenerateImageRequestSerializer(serializers.Serializer):
default="1:1",
)
parameters = serializers.DictField(required=False, default=dict)
class RechargeCreateRequestSerializer(serializers.Serializer):
amount = serializers.DecimalField(
max_digits=12,
decimal_places=2,
min_value=Decimal("0.01"),
)
pay_method = serializers.ChoiceField(choices=RechargeOrder.PayMethod.values)
class RechargeStatusRequestSerializer(serializers.Serializer):
order_no = serializers.CharField(
trim_whitespace=True,
allow_blank=False,
max_length=64,
)
+171 -1
View File
@@ -10,6 +10,7 @@ from cryptography.fernet import Fernet
from django.contrib.auth import get_user_model
from django.test import TestCase, override_settings
from django.urls import path
from django.utils import timezone
from rest_framework.response import Response
from rest_framework.test import APIClient
@@ -22,11 +23,18 @@ from apps.ai.providers import (
ImageGenerationResult,
TextGenerationResult,
)
from apps.billing.models import CallRecord, PointsLedger, PricingRule, RechargeOrder
from apps.billing.models import (
CallRecord,
ExchangeRate,
PointsLedger,
PricingRule,
RechargeOrder,
)
from apps.billing.payment_gateways import (
build_mock_alipay_signature,
build_mock_body_signature,
)
from apps.billing.services import RechargePayment
from apps.users.models import ApiKey
from apps.users.models import UserWallet
@@ -375,6 +383,168 @@ class RechargeCallbackApiTests(TestCase):
self.assertFalse(PointsLedger.objects.filter(ref_order_id=order.id).exists())
@override_settings(
PAYMENT_CALLBACK_MODE="mock",
PAYMENT_MOCK_CALLBACK_SECRET="test-payment-callback-secret",
PAYMENT_QR_EXPIRES_MINUTES=15,
)
class RechargeCreateStatusApiTests(TestCase):
create_url = "/api/v1/recharge/create"
status_url = "/api/v1/recharge/status"
def setUp(self):
suffix = uuid.uuid4().hex[:8]
self.user = get_user_model().objects.create_user(
username=f"recharge-create-{suffix}",
email=f"recharge-create-{suffix}@example.com",
password="password",
)
self.other_user = get_user_model().objects.create_user(
username=f"recharge-other-{suffix}",
email=f"recharge-other-{suffix}@example.com",
password="password",
)
self.wallet = UserWallet.objects.create(user=self.user, points_balance=100)
self.api_key, self.raw_key = ApiKey.create_for_user(self.user, name="recharge")
self.exchange_rate = ExchangeRate.objects.create(
currency="CNY",
points_per_unit=Decimal("10.0000"),
effective_from=timezone.now(),
)
self.client = APIClient()
def create_order(
self,
*,
user=None,
amount="20.00",
points_granted=200,
pay_method=RechargeOrder.PayMethod.WEIXIN,
):
return RechargeOrder.objects.create(
user=user or self.user,
order_no=f"R{uuid.uuid4().hex[:12]}",
amount_money=Decimal(amount),
pay_method=pay_method,
exchange_rate=Decimal("10.0000"),
points_granted=points_granted,
code_url=f"mockpay://{pay_method}/existing",
)
def test_recharge_create_requires_web_session_not_api_key(self):
response = self.client.post(
self.create_url,
{"amount": "20.00", "pay_method": "weixin"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {self.raw_key}",
)
self.assertEqual(response.status_code, 403)
self.assertFalse(RechargeOrder.objects.filter(user=self.user).exists())
def test_recharge_create_is_session_authenticated_and_locks_quote(self):
self.client.force_login(self.user)
response = self.client.post(
self.create_url,
{"amount": "20.00", "pay_method": "weixin"},
format="json",
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["amount"], "20.00")
self.assertEqual(response.data["exchange_rate"], "10.0000")
self.assertEqual(response.data["points_granted"], 200)
self.assertEqual(response.data["pay_method"], RechargeOrder.PayMethod.WEIXIN)
self.assertEqual(response.data["status"], RechargeOrder.Status.PENDING)
self.assertTrue(response.data["code_url"].startswith("weixin://wxpay/cmhub-mock"))
self.assertIsNotNone(response.data["expires_at"])
order = RechargeOrder.objects.get(order_no=response.data["order_no"])
self.assertEqual(order.user, self.user)
self.assertEqual(order.exchange_rate, Decimal("10.0000"))
self.assertEqual(order.points_granted, 200)
self.wallet.refresh_from_db()
self.assertEqual(self.wallet.points_balance, 100)
self.assertFalse(PointsLedger.objects.filter(ref_order_id=order.id).exists())
def test_recharge_create_supports_alipay_mock_qr_code(self):
self.client.force_login(self.user)
response = self.client.post(
self.create_url,
{"amount": "30.00", "pay_method": "alipay"},
format="json",
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["pay_method"], RechargeOrder.PayMethod.ALIPAY)
self.assertTrue(response.data["code_url"].startswith("https://qr.alipay.com/cmhub-mock"))
def test_recharge_create_enforces_csrf_for_real_session_clients(self):
csrf_client = APIClient(enforce_csrf_checks=True)
csrf_client.force_login(self.user)
response = csrf_client.post(
self.create_url,
{"amount": "20.00", "pay_method": "weixin"},
format="json",
)
self.assertEqual(response.status_code, 403)
self.assertFalse(RechargeOrder.objects.filter(user=self.user).exists())
def test_recharge_status_returns_pending_order_for_owner_only(self):
order = self.create_order()
self.client.force_login(self.user)
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.client.force_login(self.other_user)
denied = self.client.get(self.status_url, {"order_no": order.order_no})
self.assertEqual(denied.status_code, 404)
self.assertEqual(denied.data["error"]["code"], "order_not_found")
def test_recharge_status_active_query_can_apply_paid_order_once(self):
order = self.create_order(amount="20.00", points_granted=200)
self.client.force_login(self.user)
def fake_query(queried_order):
return RechargePayment(
order_no=queried_order.order_no,
pay_method=queried_order.pay_method,
amount=queried_order.amount_money,
transaction_id="queried-txn-001",
paid_at=timezone.now(),
)
with patch("apps.api.views.query_payment_order", side_effect=fake_query) as query:
first = self.client.get(self.status_url, {"order_no": order.order_no})
second = self.client.get(self.status_url, {"order_no": order.order_no})
self.assertEqual(first.status_code, 200)
self.assertEqual(second.status_code, 200)
self.assertEqual(first.data["status"], RechargeOrder.Status.PAID)
self.assertEqual(second.data["status"], RechargeOrder.Status.PAID)
self.assertEqual(query.call_count, 1)
self.wallet.refresh_from_db()
order.refresh_from_db()
self.assertEqual(self.wallet.points_balance, 300)
self.assertEqual(order.payment_txn_no, "queried-txn-001")
self.assertEqual(
PointsLedger.objects.filter(
ref_order_id=order.id,
change_type=PointsLedger.ChangeType.RECHARGE,
).count(),
1,
)
class FakeGenerationProvider:
def __init__(self, *, capabilities=None):
self._capabilities = set(capabilities or {"text", "image", "vision"})
+4
View File
@@ -5,6 +5,8 @@ from .views import (
BalanceView,
GenerateImageView,
GenerateTitleView,
RechargeCreateView,
RechargeStatusView,
WechatRechargeCallbackView,
)
@@ -12,6 +14,8 @@ urlpatterns = [
path("v1/balance", BalanceView.as_view(), name="api-balance"),
path("v1/generate/title", GenerateTitleView.as_view(), name="api-generate-title"),
path("v1/generate/image", GenerateImageView.as_view(), name="api-generate-image"),
path("v1/recharge/create", RechargeCreateView.as_view(), name="api-recharge-create"),
path("v1/recharge/status", RechargeStatusView.as_view(), name="api-recharge-status"),
path(
"v1/recharge/callback/wechat",
WechatRechargeCallbackView.as_view(),
+112
View File
@@ -1,8 +1,10 @@
import logging
from django.http import HttpResponse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from rest_framework.authentication import SessionAuthentication
from rest_framework.exceptions import AuthenticationFailed
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
@@ -19,20 +21,30 @@ from apps.api.generation import (
from apps.api.serializers import (
GenerateImageRequestSerializer,
GenerateTitleRequestSerializer,
RechargeCreateRequestSerializer,
RechargeStatusRequestSerializer,
)
from apps.billing.models import RechargeOrder
from apps.billing.payment_gateways import (
PaymentOrderCreateError,
PaymentQueryUnavailableError,
PaymentVerificationError,
query_payment_order,
verify_alipay_callback,
verify_wechat_callback,
)
from apps.billing.pricing import NoExchangeRateError
from apps.billing.services import (
InvalidRechargeOrderStateError,
RechargeAmountMismatchError,
RechargeCallbackError,
RechargeOrderCreateError,
RechargeOrderNotFoundError,
RechargePayMethodMismatchError,
apply_recharge_payment,
create_recharge_order,
get_balance_snapshot,
query_and_apply_recharge_payment,
)
logger = logging.getLogger(__name__)
@@ -99,6 +111,106 @@ class BalanceView(ExternalApiView):
)
class PortalSessionApiView(APIView):
authentication_classes = (SessionAuthentication,)
permission_classes = (IsAuthenticated,)
def _recharge_order_response(order: RechargeOrder) -> dict:
is_expired = bool(
order.status == RechargeOrder.Status.PENDING
and order.expires_at is not None
and order.expires_at <= timezone.now()
)
return {
"order_no": order.order_no,
"amount": f"{order.amount_money:.2f}",
"currency": order.currency,
"exchange_rate": f"{order.exchange_rate:.4f}",
"points_granted": order.points_granted,
"pay_method": order.pay_method,
"status": order.status,
"code_url": order.code_url,
"expires_at": order.expires_at.isoformat() if order.expires_at else None,
"paid_at": order.paid_at.isoformat() if order.paid_at else None,
"is_expired": is_expired,
}
class RechargeCreateView(PortalSessionApiView):
def post(self, request):
serializer = RechargeCreateRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(
api_error("bad_request", "参数错误"),
status=status.HTTP_400_BAD_REQUEST,
)
try:
order = create_recharge_order(
user=request.user,
amount=serializer.validated_data["amount"],
pay_method=serializer.validated_data["pay_method"],
)
except NoExchangeRateError:
return Response(
api_error("no_exchange_rate", "未配置当前币种汇率"),
status=status.HTTP_400_BAD_REQUEST,
)
except RechargeOrderCreateError:
return Response(
api_error("bad_request", "充值下单参数错误"),
status=status.HTTP_400_BAD_REQUEST,
)
except PaymentOrderCreateError:
return Response(
api_error("payment_order_create_failed", "支付下单失败"),
status=status.HTTP_502_BAD_GATEWAY,
)
return Response(_recharge_order_response(order), status=status.HTTP_201_CREATED)
class RechargeStatusView(PortalSessionApiView):
def get(self, request):
serializer = RechargeStatusRequestSerializer(data=request.query_params)
if not serializer.is_valid():
return Response(
api_error("bad_request", "参数错误"),
status=status.HTTP_400_BAD_REQUEST,
)
order_no = serializer.validated_data["order_no"]
order = RechargeOrder.objects.filter(
user=request.user,
order_no=order_no,
).first()
if order is None:
return Response(
api_error("order_not_found", "充值订单不存在"),
status=status.HTTP_404_NOT_FOUND,
)
if order.status == RechargeOrder.Status.PENDING:
try:
result = query_and_apply_recharge_payment(order.order_no, query_payment_order)
order = result.order
except (PaymentQueryUnavailableError, PaymentOrderCreateError):
order.refresh_from_db()
except RechargeCallbackError as exc:
logger.warning(
"Rejected active recharge query result for %s: %s",
order.order_no,
exc.__class__.__name__,
)
return Response(
api_error(exc.code, "支付查单结果与本地订单不一致"),
status=status.HTTP_400_BAD_REQUEST,
)
return Response(_recharge_order_response(order), status=status.HTTP_200_OK)
class RechargeCallbackView(APIView):
authentication_classes = ()
permission_classes = ()
+259
View File
@@ -3,9 +3,12 @@ from __future__ import annotations
import hashlib
import hmac
import json
from dataclasses import dataclass
from datetime import timedelta
from decimal import Decimal
from decimal import InvalidOperation
from pathlib import Path
from urllib.parse import quote
from django.conf import settings
from django.utils import timezone
@@ -23,6 +26,16 @@ class PaymentQueryUnavailableError(RuntimeError):
code = "payment_query_unavailable"
class PaymentOrderCreateError(RuntimeError):
code = "payment_order_create_failed"
@dataclass(frozen=True)
class PaymentOrderCode:
code_url: str
expires_at: object
def payment_callback_mode() -> str:
return str(getattr(settings, "PAYMENT_CALLBACK_MODE", "") or "").strip().lower()
@@ -73,6 +86,27 @@ def _decimal_money(value) -> Decimal:
raise PaymentVerificationError("Invalid payment amount.") from exc
def _format_money(value) -> str:
return f"{_decimal_money(value):.2f}"
def _wechat_amount_cents(value) -> int:
return int((_decimal_money(value) * Decimal("100")).to_integral_value())
def _default_expires_at():
minutes = int(getattr(settings, "PAYMENT_QR_EXPIRES_MINUTES", 10) or 10)
return timezone.now() + timedelta(minutes=minutes)
def _require_payment_config(values: dict[str, str], gateway: str) -> None:
missing = [name for name, value in values.items() if not str(value or "").strip()]
if missing:
raise PaymentOrderCreateError(
f"{gateway} payment config is incomplete: {', '.join(missing)}"
)
def _verify_mock_wechat_signature(headers, body: bytes) -> None:
expected = build_mock_body_signature(body)
provided = headers.get("Wechatpay-Signature") or headers.get("X-Cmhub-Mock-Signature")
@@ -135,6 +169,123 @@ def verify_alipay_callback(data: dict) -> RechargePayment:
)
def create_payment_order(order: RechargeOrder) -> PaymentOrderCode:
if payment_callback_mode() == "mock":
return _create_mock_payment_order(order)
if order.pay_method == RechargeOrder.PayMethod.WEIXIN:
return _create_wechat_payment_order_with_sdk(order)
if order.pay_method == RechargeOrder.PayMethod.ALIPAY:
return _create_alipay_payment_order_with_sdk(order)
raise PaymentOrderCreateError("Unsupported payment method.")
def _create_mock_payment_order(order: RechargeOrder) -> PaymentOrderCode:
escaped_order_no = quote(order.order_no, safe="")
if order.pay_method == RechargeOrder.PayMethod.WEIXIN:
code_url = (
"weixin://wxpay/cmhub-mock"
f"?out_trade_no={escaped_order_no}&total={_wechat_amount_cents(order.amount_money)}"
)
elif order.pay_method == RechargeOrder.PayMethod.ALIPAY:
code_url = (
"https://qr.alipay.com/cmhub-mock"
f"?out_trade_no={escaped_order_no}&total_amount={quote(_format_money(order.amount_money), safe='')}"
)
else:
raise PaymentOrderCreateError("Unsupported payment method.")
return PaymentOrderCode(code_url=code_url, expires_at=_default_expires_at())
def _create_wechat_payment_order_with_sdk(order: RechargeOrder) -> PaymentOrderCode:
try:
from wechatpayv3 import WeChatPay # type: ignore
except ImportError as exc:
raise PaymentOrderCreateError("wechatpayv3 is not installed.") from exc
_require_payment_config(
{
"WECHAT_PAY_APPID": settings.WECHAT_PAY_APPID,
"WECHAT_PAY_MCHID": settings.WECHAT_PAY_MCHID,
"WECHAT_PAY_API_V3_KEY": settings.WECHAT_PAY_API_V3_KEY,
"WECHAT_PAY_CERT_SERIAL_NO": settings.WECHAT_PAY_CERT_SERIAL_NO,
"WECHAT_PAY_PRIVATE_KEY_PATH": settings.WECHAT_PAY_PRIVATE_KEY_PATH,
"WECHAT_PAY_NOTIFY_URL": settings.WECHAT_PAY_NOTIFY_URL,
},
"WeChat",
)
private_key = Path(settings.WECHAT_PAY_PRIVATE_KEY_PATH).read_text(encoding="utf-8")
client = WeChatPay(
wechatpay_type="NATIVE",
mchid=settings.WECHAT_PAY_MCHID,
private_key=private_key,
cert_serial_no=settings.WECHAT_PAY_CERT_SERIAL_NO,
apiv3_key=settings.WECHAT_PAY_API_V3_KEY,
appid=settings.WECHAT_PAY_APPID,
notify_url=settings.WECHAT_PAY_NOTIFY_URL,
)
try:
response = client.pay(
description="cmhub points recharge",
out_trade_no=order.order_no,
amount={
"total": _wechat_amount_cents(order.amount_money),
"currency": order.currency,
},
)
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
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())
def _create_alipay_payment_order_with_sdk(order: RechargeOrder) -> PaymentOrderCode:
try:
from alipay import AliPay # type: ignore
except ImportError as exc:
raise PaymentOrderCreateError("python-alipay-sdk is not installed.") from exc
_require_payment_config(
{
"ALIPAY_APPID": settings.ALIPAY_APPID,
"ALIPAY_APP_PRIVATE_KEY_PATH": settings.ALIPAY_APP_PRIVATE_KEY_PATH,
"ALIPAY_PUBLIC_KEY_PATH": settings.ALIPAY_PUBLIC_KEY_PATH,
"ALIPAY_NOTIFY_URL": settings.ALIPAY_NOTIFY_URL,
},
"Alipay",
)
app_private_key = Path(settings.ALIPAY_APP_PRIVATE_KEY_PATH).read_text(
encoding="utf-8"
)
alipay_public_key = Path(settings.ALIPAY_PUBLIC_KEY_PATH).read_text(
encoding="utf-8"
)
client = AliPay(
appid=settings.ALIPAY_APPID,
app_notify_url=settings.ALIPAY_NOTIFY_URL,
app_private_key_string=app_private_key,
alipay_public_key_string=alipay_public_key,
sign_type="RSA2",
debug=settings.ALIPAY_DEBUG,
)
try:
response = client.api_alipay_trade_precreate(
subject="cmhub points recharge",
out_trade_no=order.order_no,
total_amount=_format_money(order.amount_money),
notify_url=settings.ALIPAY_NOTIFY_URL,
)
except Exception as exc: # pragma: no cover - depends on merchant SDK/runtime.
raise PaymentOrderCreateError("Alipay precreate order failed.") from exc
qr_code = response.get("qr_code") if isinstance(response, dict) else None
if not qr_code:
raise PaymentOrderCreateError("Alipay precreate order did not return qr_code.")
return PaymentOrderCode(code_url=str(qr_code), expires_at=_default_expires_at())
def _verify_wechat_callback_with_sdk(headers, body: bytes) -> RechargePayment:
try:
from wechatpayv3 import WeChatPay # type: ignore
@@ -198,6 +349,114 @@ def _verify_alipay_callback_with_sdk(data: dict) -> None:
def query_payment_order(order: RechargeOrder) -> RechargePayment:
if payment_callback_mode() == "mock":
raise PaymentQueryUnavailableError(
f"Mock payment query for {order.order_no} is not configured."
)
if order.pay_method == RechargeOrder.PayMethod.WEIXIN:
return _query_wechat_payment_order_with_sdk(order)
if order.pay_method == RechargeOrder.PayMethod.ALIPAY:
return _query_alipay_payment_order_with_sdk(order)
raise PaymentQueryUnavailableError(
f"Active payment query for {order.pay_method} is not configured."
)
def _query_wechat_payment_order_with_sdk(order: RechargeOrder) -> RechargePayment:
try:
from wechatpayv3 import WeChatPay # type: ignore
except ImportError as exc:
raise PaymentQueryUnavailableError("wechatpayv3 is not installed.") from exc
try:
_require_payment_config(
{
"WECHAT_PAY_APPID": settings.WECHAT_PAY_APPID,
"WECHAT_PAY_MCHID": settings.WECHAT_PAY_MCHID,
"WECHAT_PAY_API_V3_KEY": settings.WECHAT_PAY_API_V3_KEY,
"WECHAT_PAY_CERT_SERIAL_NO": settings.WECHAT_PAY_CERT_SERIAL_NO,
"WECHAT_PAY_PRIVATE_KEY_PATH": settings.WECHAT_PAY_PRIVATE_KEY_PATH,
"WECHAT_PAY_NOTIFY_URL": settings.WECHAT_PAY_NOTIFY_URL,
},
"WeChat",
)
except PaymentOrderCreateError as exc:
raise PaymentQueryUnavailableError(str(exc)) from exc
private_key = Path(settings.WECHAT_PAY_PRIVATE_KEY_PATH).read_text(encoding="utf-8")
client = WeChatPay(
wechatpay_type="NATIVE",
mchid=settings.WECHAT_PAY_MCHID,
private_key=private_key,
cert_serial_no=settings.WECHAT_PAY_CERT_SERIAL_NO,
apiv3_key=settings.WECHAT_PAY_API_V3_KEY,
appid=settings.WECHAT_PAY_APPID,
notify_url=settings.WECHAT_PAY_NOTIFY_URL,
)
query = getattr(client, "query", None)
if query is None:
raise PaymentQueryUnavailableError(
"WeChat active payment query SDK binding is not configured."
)
try:
resource = 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":
raise PaymentQueryUnavailableError("WeChat trade is not paid yet.")
try:
total_cents = Decimal(str((resource.get("amount") or {}).get("total")))
except (InvalidOperation, TypeError, ValueError) as exc:
raise PaymentQueryUnavailableError("Invalid WeChat payment amount.") from exc
return RechargePayment(
order_no=str(resource.get("out_trade_no") or order.order_no),
pay_method=RechargeOrder.PayMethod.WEIXIN,
amount=(total_cents / Decimal("100")).quantize(Decimal("0.01")),
transaction_id=str(resource.get("transaction_id") or ""),
paid_at=_parse_paid_at(resource.get("success_time")),
)
def _query_alipay_payment_order_with_sdk(order: RechargeOrder) -> RechargePayment:
try:
from alipay import AliPay # type: ignore
except ImportError as exc:
raise PaymentQueryUnavailableError("python-alipay-sdk is not installed.") from exc
_require_payment_config(
{
"ALIPAY_APPID": settings.ALIPAY_APPID,
"ALIPAY_APP_PRIVATE_KEY_PATH": settings.ALIPAY_APP_PRIVATE_KEY_PATH,
"ALIPAY_PUBLIC_KEY_PATH": settings.ALIPAY_PUBLIC_KEY_PATH,
},
"Alipay",
)
app_private_key = Path(settings.ALIPAY_APP_PRIVATE_KEY_PATH).read_text(
encoding="utf-8"
)
alipay_public_key = Path(settings.ALIPAY_PUBLIC_KEY_PATH).read_text(
encoding="utf-8"
)
client = AliPay(
appid=settings.ALIPAY_APPID,
app_notify_url=settings.ALIPAY_NOTIFY_URL,
app_private_key_string=app_private_key,
alipay_public_key_string=alipay_public_key,
sign_type="RSA2",
debug=settings.ALIPAY_DEBUG,
)
try:
response = client.api_alipay_trade_query(out_trade_no=order.order_no)
except Exception as exc: # pragma: no cover - depends on merchant SDK/runtime.
raise PaymentQueryUnavailableError("Alipay trade query failed.") from exc
trade_status = response.get("trade_status") if isinstance(response, dict) else None
if trade_status not in {"TRADE_SUCCESS", "TRADE_FINISHED"}:
raise PaymentQueryUnavailableError("Alipay trade is not paid yet.")
return RechargePayment(
order_no=str(response.get("out_trade_no") or order.order_no),
pay_method=RechargeOrder.PayMethod.ALIPAY,
amount=_decimal_money(response.get("total_amount")),
transaction_id=str(response.get("trade_no") or ""),
paid_at=_parse_paid_at(response.get("send_pay_date") or response.get("gmt_payment")),
)
+90 -2
View File
@@ -1,8 +1,9 @@
from __future__ import annotations
import secrets
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from decimal import Decimal, InvalidOperation
from django.db import transaction
from django.db.models import Sum
@@ -11,6 +12,7 @@ from django.utils import timezone
from apps.users.models import UserWallet
from .models import CallRecord, PointsLedger, RechargeOrder, normalize_resolution
from .pricing import quote_recharge_points
class BillingOperationError(RuntimeError):
@@ -71,6 +73,18 @@ class InvalidRechargeOrderStateError(RechargeCallbackError):
super().__init__("Recharge order cannot be paid from its current state.")
class RechargeOrderCreateError(BillingOperationError):
code = "bad_request"
class InvalidRechargePayMethodError(RechargeOrderCreateError):
code = "bad_request"
def __init__(self, pay_method: str):
self.pay_method = pay_method
super().__init__("Unsupported recharge payment method.")
@dataclass(frozen=True)
class CallCharge:
call_record: CallRecord
@@ -124,13 +138,87 @@ def _locked_wallet_for_user(user) -> UserWallet:
def _normalize_money(value) -> Decimal:
return Decimal(str(value)).quantize(Decimal("0.01"))
try:
return Decimal(str(value)).quantize(Decimal("0.01"))
except (InvalidOperation, TypeError, ValueError) as exc:
raise RechargeOrderCreateError("Invalid recharge amount.") from exc
def _normalize_pay_method(value: str) -> str:
return str(value or "").strip().lower()
def _validate_recharge_pay_method(value: str) -> str:
pay_method = _normalize_pay_method(value)
if pay_method not in {
RechargeOrder.PayMethod.WEIXIN,
RechargeOrder.PayMethod.ALIPAY,
}:
raise InvalidRechargePayMethodError(pay_method)
return pay_method
def _generate_recharge_order_no() -> str:
for _attempt in range(10):
timestamp = timezone.now().strftime("%Y%m%d%H%M%S")
order_no = f"R{timestamp}{secrets.token_hex(4).upper()}"
if not RechargeOrder.objects.filter(order_no=order_no).exists():
return order_no
raise RechargeOrderCreateError("Could not generate unique recharge order number.")
def create_recharge_order(
*,
user,
amount,
pay_method: str,
currency: str = "CNY",
payment_order_func=None,
) -> RechargeOrder:
normalized_amount = _normalize_money(amount)
if normalized_amount <= 0:
raise RechargeOrderCreateError("Recharge amount must be greater than zero.")
normalized_pay_method = _validate_recharge_pay_method(pay_method)
quote = quote_recharge_points(normalized_amount, currency=currency)
if quote.points_granted <= 0:
raise RechargeOrderCreateError(
"Recharge amount is too small for the current exchange rate."
)
order = RechargeOrder.objects.create(
user=user,
order_no=_generate_recharge_order_no(),
amount_money=quote.amount,
currency=quote.currency,
pay_method=normalized_pay_method,
exchange_rate=quote.points_per_unit,
points_granted=quote.points_granted,
status=RechargeOrder.Status.PENDING,
)
if payment_order_func is None:
from .payment_gateways import create_payment_order
payment_order_func = create_payment_order
try:
payment_order = payment_order_func(order)
code_url = str(getattr(payment_order, "code_url", "") or "").strip()
expires_at = getattr(payment_order, "expires_at", None)
if not code_url:
raise RechargeOrderCreateError("Payment gateway did not return a QR code URL.")
except Exception:
order.status = RechargeOrder.Status.FAILED
order.save(update_fields=("status", "updated_at"))
raise
order.code_url = code_url
order.expires_at = expires_at
order.save(update_fields=("code_url", "expires_at", "updated_at"))
return order
def get_balance_snapshot(user) -> BalanceSnapshot:
points_balance = (
UserWallet.objects.filter(user=user)
+60
View File
@@ -27,12 +27,15 @@ from apps.billing.pricing import (
get_pricing_rule,
quote_recharge_points,
)
from apps.billing.payment_gateways import PaymentOrderCode
from apps.billing.services import (
InsufficientPointsError,
InvalidCallStateError,
RechargeAmountMismatchError,
RechargeOrderCreateError,
RechargePayment,
apply_recharge_payment,
create_recharge_order,
mark_call_success,
precharge_call,
query_and_apply_recharge_payment,
@@ -409,6 +412,63 @@ class BillingServiceTests(TestCase):
points_granted=points_granted,
)
def create_exchange_rate(self, *, points_per_unit="10.0000", effective_from=None):
return ExchangeRate.objects.create(
currency="CNY",
points_per_unit=Decimal(points_per_unit),
effective_from=effective_from or timezone.now() - timedelta(minutes=1),
)
def test_create_recharge_order_locks_exchange_rate_points_and_qr_code(self):
self.create_exchange_rate(points_per_unit="12.5000")
expires_at = timezone.now() + timedelta(minutes=10)
def fake_payment_order(order):
return PaymentOrderCode(
code_url=f"mockpay://{order.pay_method}/{order.order_no}",
expires_at=expires_at,
)
order = create_recharge_order(
user=self.user,
amount=Decimal("8.88"),
pay_method=RechargeOrder.PayMethod.WEIXIN,
payment_order_func=fake_payment_order,
)
self.assertEqual(order.user, self.user)
self.assertEqual(order.status, RechargeOrder.Status.PENDING)
self.assertEqual(order.amount_money, Decimal("8.88"))
self.assertEqual(order.currency, "CNY")
self.assertEqual(order.pay_method, RechargeOrder.PayMethod.WEIXIN)
self.assertEqual(order.exchange_rate, Decimal("12.5000"))
self.assertEqual(order.points_granted, 111)
self.assertTrue(order.code_url.startswith("mockpay://weixin/"))
self.assertEqual(order.expires_at, expires_at)
self.create_exchange_rate(points_per_unit="99.0000")
order.refresh_from_db()
self.assertEqual(order.exchange_rate, Decimal("12.5000"))
self.assertEqual(order.points_granted, 111)
def test_create_recharge_order_marks_order_failed_when_gateway_fails(self):
self.create_exchange_rate(points_per_unit="10.0000")
def failing_payment_order(_order):
raise RechargeOrderCreateError("gateway failed")
with self.assertRaises(RechargeOrderCreateError):
create_recharge_order(
user=self.user,
amount=Decimal("20.00"),
pay_method=RechargeOrder.PayMethod.ALIPAY,
payment_order_func=failing_payment_order,
)
order = RechargeOrder.objects.get(user=self.user, amount_money=Decimal("20.00"))
self.assertEqual(order.status, RechargeOrder.Status.FAILED)
self.assertEqual(order.code_url, "")
def test_precharge_call_debits_wallet_and_writes_pending_call_and_consume_ledger(self):
charge = precharge_call(
user=self.user,
+1
View File
@@ -67,6 +67,7 @@ PAYMENT_MOCK_CALLBACK_SECRET = os.environ.get(
"PAYMENT_MOCK_CALLBACK_SECRET",
"cmhub-dev-mock-callback-secret" if DEBUG else "",
)
PAYMENT_QR_EXPIRES_MINUTES = env_int("PAYMENT_QR_EXPIRES_MINUTES", 10)
WECHAT_PAY_APPID = os.environ.get("WECHAT_PAY_APPID", "")
WECHAT_PAY_MCHID = os.environ.get("WECHAT_PAY_MCHID", "")
WECHAT_PAY_API_V3_KEY = os.environ.get("WECHAT_PAY_API_V3_KEY", "")
+3 -3
View File
@@ -38,15 +38,15 @@
## 当前阶段
当前项目处于:**Phase 3 对外 API 与充值起步**。Phase 2 计费核心已完成到 T-204;T-301 API Key 鉴权、T-302 生成标题 / 图片接口、T-303 余额查询接口与 T-304 充值回调已落地。下一步进入 T-305 扫码充值下单 + 轮询。
当前项目处于:**Phase 4 用户端起步**。Phase 2 计费核心已完成到 T-204;Phase 3 已完成 T-301 API Key 鉴权、T-302 生成标题 / 图片接口、T-303 余额查询接口、T-304 充值回调与 T-305 扫码充值下单 + 轮询。下一步进入 T-501 注册 / 登录(allauth)。
优先路径:
1. Phase 0:Django 骨架可运行、**自定义 User 模型在首次迁移前定好**、django-admin 可登录;T-004 审核修补项已完成。
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 扫码下单与轮询。
5. Phase 4:用户端(Django 模板 SSR)—— 注册登录、API Key 管理、个人中心/记录页、充值页。
4. Phase 3:对外 API 与充值 —— T-301 Key 鉴权、T-302 生成接口、T-303 余额查询、T-304 充值回调、T-305 扫码下单与轮询已完成。
5. Phase 4:用户端(Django 模板 SSR)—— 下一步 T-501 注册登录,然后 API Key 管理、个人中心/记录页、充值页。
6. Phase 5:后台与发布 —— 运营后台完善、完整验收、部署 / 运行文档。
## 领取任务规则
+1 -1
View File
@@ -21,7 +21,7 @@
| MySQL 驱动 | PyMySQL + cryptography | 已定 | PyMySQL 负责 Django 连接 MySQL;MySQL 8 默认 `caching_sha2_password` 认证需要 `cryptography` 支持;客户端连接/读/写超时通过 `MYSQL_CONNECT_TIMEOUT` / `MYSQL_READ_TIMEOUT` / `MYSQL_WRITE_TIMEOUT` 配置 |
| 对外鉴权 | API Key(DRF 自定义 Authentication,哈希存储比对) | 已定 | 用户自助生成 Key;**API 只认 Key、不挂 SessionAuthentication**,防浏览器 cookie 绕过计费 |
| 用户端鉴权 | Django Session(+ allauth 注册登录邮箱验证) | 已定 | 用户端页面与 `recharge/create` 走 session + CSRF;后台账号也用 Session 登录 |
| 充值对接 | 自助扫码:微信 V3 native + 支付宝当面付;下单取二维码 + 服务端回调(验签 + 幂等) | 已定 | T-304 已落地回调、HMAC mock 联调与 SDK 模式入口;生产需安装并配置 `wechatpayv3` / `python-alipay-sdk` 与真实商户密钥/证书 |
| 充值对接 | 自助扫码:微信 V3 native + 支付宝当面付;下单取二维码 + 服务端回调(验签 + 幂等) | 已定 | T-304/T-305 已落地回调、扫码下单、状态轮询、HMAC mock 联调与 SDK 模式入口;生产需安装并配置 `wechatpayv3` / `python-alipay-sdk` 与真实商户密钥/证书 |
| 生成返回方式 | 同步 HTTP(无任务队列) | 已定 | MVP 简化;图片接口需调大网关/服务超时 |
| 任务队列 | 暂不引入(Celery/RQ) | 待定 | V2 异步化时再评估 |
| 部署方式 | Docker + Gunicorn(gthread) + Nginx,单体 | 待定 | MVP 先 `runserver`;生产 Nginx 按路径把 `/api/generate/*`(图片长请求)与用户端页面**分流到不同 gunicorn/worker 池**,避免图片阻塞拖慢页面(见 `04-architecture.md` 5.1) |
+3 -1
View File
@@ -44,6 +44,8 @@ T-303 已实现 `/api/v1/balance`:外部 API 继续只认 API Key,API 层调
T-304 已实现 `/api/v1/recharge/callback/wechat` 与 `/api/v1/recharge/callback/alipay`:两个回调端点均 `@csrf_exempt` 且不挂登录态;回调验签后调用 `apps.billing.services.apply_recharge_payment()`,按 `order_no` 锁定 `RechargeOrder` 幂等入账,金额或通道不一致不加点。缺真实商户配置时仅允许使用明确的 HMAC mock 模式联调,生产应切换 `PAYMENT_CALLBACK_MODE=sdk`。
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 等回调。
**计费层(`apps/billing`)**
- 计费规则查询:按「操作类型 + 能力别名(+ 可选分辨率)」算出本次点数 N。**按别名定价,不按具体供应商 SKU 定价**,这样后台换底层模型时计费不变。
@@ -268,7 +270,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-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 订单、锁定汇率/点数并回填二维码票据。
## 四、计费时序(核心,务必照此实现)
+1 -1
View File
@@ -54,7 +54,7 @@
| T-302 | 生成标题 / 图片接口 | T-104, T-203, T-301 | 按 `api.md` 实现;请求传**能力别名**+ `parameters`,但 `parameters` 只能经 Provider 白名单透传,核心/计费字段不可被覆盖;编排「别名解析+能力校验→预扣→调上游→成功确认/失败退点→写记录」;评估 `Provider.capabilities()` 与模型声明能力的二次校验;`images_edits` 缺原图 / `AiCapabilityError` 翻译为 400,不落 500;调用记录不保存 provider `raw` / base64;点数不足返回 402;含测试;建议随本任务或 T-403 前跑一次真实图片生成并记录真实耗时 | DONE |
| T-303 | 余额查询接口 | T-301 | 返回余额等于流水累加;含测试 | DONE |
| T-304 | 充值回调(微信/支付宝验签 + 幂等入账) | T-202 | 两端点 `@csrf_exempt`;微信 SDK 验签解密、支付宝 SDK verify;验签失败不入账;同一 order_no 重复回调只入账一次;校验回调金额与订单金额一致;使用订单创建时锁定的 `points_granted` 锁 wallet 入账写流水;补主动查单兜底;含幂等测试 | DONE |
| T-305 | 扫码充值下单 + 轮询(create/status) | T-304 | 支持 weixin(native,金额分)/alipay(precreate,金额元);建 pending 订单绑定 user,并在下单时锁定汇率/预计点数→取 code_url/qr_code→前端渲染 + 轮询 status;缺商户密钥时 mock | TODO |
| T-305 | 扫码充值下单 + 轮询(create/status) | T-304 | 支持 weixin(native,金额分)/alipay(precreate,金额元);建 pending 订单绑定 user,并在下单时锁定汇率/预计点数→取 code_url/qr_code→前端渲染 + 轮询 status;缺商户密钥时 mock | DONE |
## Phase 4 · 用户端(Django 模板 SSR)
+39 -2
View File
@@ -25,6 +25,8 @@ T-303 已实现余额查询基线:`GET /api/v1/balance` 已接入 API Key 鉴
T-304 已实现充值回调基线:`POST /api/v1/recharge/callback/wechat` 与 `/alipay` 已 `@csrf_exempt`,回调先验签(开发/测试可用明确 HMAC mock,生产 `PAYMENT_CALLBACK_MODE=sdk` 走支付 SDK),再按 `order_no` 锁定 `RechargeOrder` 幂等入账;金额或支付方式不一致不加点,重复回调不重复写充值流水。
T-305 已实现扫码充值下单与轮询基线:`POST /api/v1/recharge/create` 与 `GET /api/v1/recharge/status` 走用户端 `SessionAuthentication + CSRF`,不接受 API Key;下单创建 pending 订单并锁定汇率/点数,再返回微信 `code_url` 或支付宝 `qr_code`;状态查询只允许订单所属用户访问,并在 pending 时尝试主动查单补入账,查单不可用时保持 pending 等回调。
通用错误响应:
```json
@@ -49,6 +51,9 @@ T-304 已实现充值回调基线:`POST /api/v1/recharge/callback/wechat` 与
| `upstream_error` | 上游 AI 失败(已退点) | 502 |
| `signature_invalid` | 支付回调验签失败 | 400 |
| `amount_mismatch` | 支付回调金额与本地订单金额不一致 | 400 |
| `no_exchange_rate` | 未配置当前币种汇率,无法创建充值订单 | 400 |
| `payment_order_create_failed` | 支付平台下单失败 | 502 |
| `order_not_found` | 充值订单不存在或不属于当前用户 | 404 |
## 对外接口
@@ -132,6 +137,7 @@ T-202 后,计费计算已有独立模块;T-203 后,扣点/退点也收敛
calculate_points_cost(operation_type: str, alias: str, resolution: str | None = None) -> int
quote_recharge_points(amount, currency: str = "CNY", at=None) -> RechargeQuote
calculate_points_granted(amount, currency: str = "CNY", at=None) -> int
create_recharge_order(..., user, amount, pay_method: str, currency: str = "CNY") -> RechargeOrder
precharge_call(..., user, points_cost: int, operation_type: str, alias: str, ...) -> CallCharge
mark_call_success(call_record: CallRecord, ...) -> CallRecord
refund_call_points(call_record: CallRecord, ...) -> RefundResult
@@ -146,6 +152,7 @@ query_and_apply_recharge_payment(order_no: str, query_func) -> RechargeResult
- 缺计费规则抛 `NoPricingRuleError(code="no_pricing_rule")`,API 层应翻译为上方同名错误码。
- `ExchangeRate` 按 `currency + effective_from` 取当前 active 汇率;充值下单时应锁定当时的 `exchange_rate` / `points_granted` 到订单,回调入账不得按新汇率重算。
- 金额换点数采用 `floor(amount * points_per_unit)`,点数为整数。
- `create_recharge_order()` 创建 `RechargeOrder(status=pending)` 并绑定发起用户;写入订单创建时的金额、币种、汇率、预计点数,再调用支付网关下单取二维码票据并回填 `code_url` / `expires_at`;支付平台下单失败时订单标记 `failed`。
- `precharge_call()` 使用事务 + `select_for_update()` 锁 `UserWallet` 行;余额不足抛 `InsufficientPointsError(code="insufficient_points")`,不创建 `CallRecord`、不写 `PointsLedger`、不调上游。
- 预扣成功后写 `CallRecord(status=pending)` 与 `PointsLedger(change_type=consume, points_delta=-N)`;上游成功只更新调用记录,余额不再变化。
- 上游失败调用 `refund_call_points()`:同一 `CallRecord` 只写一条 `refund` 流水,重复调用不会重复加点;成功调用不能走失败退点。
@@ -197,11 +204,15 @@ query_and_apply_recharge_payment(order_no: str, query_func) -> RechargeResult
{
"order_no": "T202606291230001234",
"amount": "100.00",
"exchange_rate": "10.00",
"currency": "CNY",
"exchange_rate": "10.0000",
"points_granted": 1000,
"pay_method": "weixin",
"status": "pending",
"code_url": "weixin://wxpay/bizpayurl?pr=abc123", // 微信 native 返回;支付宝为 qr_code(https://qr.alipay.com/...)
"expires_at": "2026-06-29T12:10:00Z" // 二维码有效期
"expires_at": "2026-06-29T12:10:00Z", // 二维码有效期
"paid_at": null,
"is_expired": false
}
```
@@ -209,6 +220,32 @@ 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;订单绑定发起用户,防充错账户。
- 开发 / 测试 `PAYMENT_CALLBACK_MODE=mock` 时返回 mock 二维码票据;生产应使用 `sdk` 模式与真实商户配置。
### `GET /api/v1/recharge/status?order_no=...`
由**已登录用户**轮询充值订单状态。只返回当前登录用户自己的订单;不属于当前用户的订单按不存在处理。
成功响应:
```json
{
"order_no": "T202606291230001234",
"amount": "100.00",
"currency": "CNY",
"exchange_rate": "10.0000",
"points_granted": 1000,
"pay_method": "weixin",
"status": "paid",
"code_url": "weixin://wxpay/bizpayurl?pr=abc123",
"expires_at": "2026-06-29T12:10:00Z",
"paid_at": "2026-06-29T12:05:00Z",
"is_expired": false
}
```
- 若订单仍为 `pending`,服务端会尝试主动查单并复用 `query_and_apply_recharge_payment()` 补入账;查单不可用或尚未支付时仍返回当前本地状态。
- 到账以服务端回调或主动查单后的本地订单状态为准;`is_expired` 仅是二维码本地有效期提示,不自动阻断延迟到达的真实支付回调。
## AI 调用模块合约(`apps/ai`)
+12 -10
View File
@@ -12,16 +12,16 @@
## 当前快照
- 日期:2026-07-03
- 阶段:Phase 3 对外 API 与充值起步;T-304 充值回调已完成,下一步 T-305 扫码充值下单 + 轮询
- 阶段:Phase 4 用户端起步;Phase 3 已完成到 T-305 扫码充值下单 + 轮询,下一步 T-501 注册 / 登录(allauth)
- 技术栈:系统 Python 3.12.3 + Django 5.2.15 + DRF 3.16.1 + PyMySQL 1.1.3 + cryptography 46.0.7 + requests 2.34.2 + django-admin;MySQL 8.4 已接入 settings,并支持 `MYSQL_CONNECT_TIMEOUT` / `MYSQL_READ_TIMEOUT` / `MYSQL_WRITE_TIMEOUT`;用户端(模板 SSR/Bootstrap/allauth) 后续任务落地;详见 `03-tech-stack.md`
- 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器);T-102 已新增 `AiModel` / `ModelAlias`、Fernet 加密密钥存储、别名解析、admin 配置页、`import_ai_models` 导入命令;T-103 已新增 `AiConfigAuditLog` 审计表、admin 只读页面和后台保存/删除审计 hook;T-104/T-105 已完成录制 title/image smoke 与审核修补;T-201 已新增 `UserWallet` / `ApiKey`、`PointsLedger` / `CallRecord`、对应 admin 与迁移;T-202 已新增 `PricingRule` / `ExchangeRate`、`apps.billing.pricing` 计费计算函数、admin 配置页与迁移;T-203 已新增 `apps.billing.services`,实现并发安全预扣、成功确认与幂等失败退点;T-204 已新增 `billing.0003_pointsledger_unique_ledger_change_type_per_call`,用 MySQL 可落地的 `ref_call + change_type` 复合唯一约束兜底防重复 refund;T-301 已新增 `apps.api.authentication.ApiKeyAuthentication` 与 `ExternalApiView`;T-302 已新增生成接口编排、序列化器、图片本地存储和 `/api/v1/generate/title|image` 路由;T-303 已新增 `apps.billing.services.get_balance_snapshot()` 与 `/api/v1/balance` 余额查询接口;T-304 已新增 `RechargeOrder`、充值回调验签适配器、幂等入账服务、微信/支付宝回调路由与迁移 `billing.0004_rechargeorder_and_more`
- 测试:T-304 验证通过:`./init.ps1`、`py_compile`、`manage.py check`、`makemigrations --check`、`manage.py migrate`(应用 `billing.0004`)、`showmigrations billing`(0004 已 `[X]`)、`manage.py test apps.billing apps.api --noinput --keepdb --verbosity 2`(45 tests OK)、`py -3.12 -m compileall apps config`、`git diff --check`(仅 Windows CRLF 提示)。完整 `py -3.12 manage.py test --noinput --keepdb --verbosity 2` 本轮未作为绿灯:跑到 65/73 后远程 MySQL 连接超时导致 `BillingCoreModelTests.setUpClass` 失败,并引发并发测试 barrier 失败;补跑失败类时业务用例均通过,最终 flush 阶段仍遇到远程 MySQL 连接超时。
- 生产代码:已有最小 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-305 验证通过:`./init.ps1`、`py_compile`、`manage.py check`、`makemigrations --check`、`py -3.12 -m compileall apps config`、`py -3.12 manage.py test apps.billing apps.api --noinput --keepdb --verbosity 2`(53 tests OK),并用 `MYSQL_CONNECT_TIMEOUT=90` 补跑全量失败类 `BillingCoreModelTests` / `AdminSmokeTests`(10 tests OK)和最终下单服务补测(2 tests OK)。完整 `py -3.12 manage.py test --noinput --keepdb --verbosity 2` 本轮未作为绿灯:发现 81 tests,跑过 71 tests,其中已运行用例均无断言失败;远程 MySQL `43.128.3.240:3306` 连接超时导致 `BillingCoreModelTests.setUpClass` 与 `AdminSmokeTests.setUpClass` 失败。
- 数据:AI 上游调用与模型配置参考 `D:\chengma\cmbot`(`src/services/ai_text_service.py`、`ai_image_service.py`、`config/ai_models.json`);真实 `ai_models.json` 不提交,需通过 `import_ai_models` 命令加密导入
- 标准启动路径:Windows 用 `./init.ps1`;Unix/WSL 用 `./init.sh`
- 标准验证路径:Windows 用 `py -3.12 manage.py check` / `py -3.12 manage.py test`
- 设计基线:**自助用户端 + 对外 API + 运营后台**三合一单体;用户模型 `User`(auth)/`UserWallet`(点数,锁 wallet 扣点)/`ApiKey`(1:N,哈希存储);对外两接口 + **能力别名 + Provider 适配器**(可插拔供应商);自助扫码充值;注册不送点数。详见 `04-architecture.md` 与 2026-06-29 / 2026-07-01 的 `progress.md` 决策
- 配置基线:运行环境变量集中见 `docs/env.md`;真实密钥/支付凭证不得写入代码或文档样例。充值订单在创建时锁定汇率与预计点数,回调入账使用订单值,不按新汇率重算。支付回调由 `PAYMENT_CALLBACK_MODE` 控制:本地/测试可用 HMAC `mock`,生产应为 `sdk`
- 当前 blocker:远程 MySQL 连接当前不稳定,完整测试暂未单次全绿;T-304 相关测试已通过。微信/支付宝真实商户密钥/证书与生产 SDK 依赖仍待提供/安装;真实 AI 上游 smoke 需要先配置 `AI_KEY_ENCRYPTION_KEY` 并导入 AiModel/ModelAlias。图片同步真实耗时风险仍未退,已登记到 T-403。
- 配置基线:运行环境变量集中见 `docs/env.md`;真实密钥/支付凭证不得写入代码或文档样例。充值订单在创建时锁定汇率与预计点数,回调入账使用订单值,不按新汇率重算。支付回调与下单由 `PAYMENT_CALLBACK_MODE` 控制:本地/测试可用 HMAC `mock`,生产应为 `sdk`;二维码本地有效期提示由 `PAYMENT_QR_EXPIRES_MINUTES` 控制。
- 当前 blocker:远程 MySQL 连接当前不稳定,完整测试暂未单次全绿;T-305 相关测试已通过。微信/支付宝真实商户密钥/证书与生产 SDK 依赖仍待提供/安装;真实 AI 上游 smoke 需要先配置 `AI_KEY_ENCRYPTION_KEY` 并导入 AiModel/ModelAlias。图片同步真实耗时风险仍未退,已登记到 T-403。
## 当前目录要点
@@ -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 加密 |
| `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 已在 `apps/billing/services.py` 落扣点/退点、余额快照与充值入账;T-304 已新增 `apps/billing/payment_gateways.py`;T-301/T-302/T-303/T-304 已在 api 落鉴权、生成接口编排、序列化器、图片存储、余额查询、充值回调与路由 |
| `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-302/T-303/T-304/T-305 已在 api 落鉴权、生成接口编排、序列化器、图片存储、余额查询、充值回调、充值下单/状态查询与路由 |
| `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-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 扫码充值下单 + 轮询。
- 正在进行:无。
- 当前 blocker:远程 MySQL 连接当前不稳定,完整测试暂未单次全绿;支付商户真实密钥/证书与生产 SDK 依赖仍待提供。
- 下一个可领取任务:**T-305 扫码充值下单 + 轮询(create/status)**。
- 下一个可领取任务:**T-501 注册 / 登录(allauth)**。
## 当前可运行内容
@@ -73,17 +73,19 @@ python3.12 manage.py smoke_ai_generation image --recorded
- `POST /api/v1/generate/title`
- `POST /api/v1/generate/image`
- `GET /api/v1/balance`
- `POST /api/v1/recharge/create`(用户端 session + CSRF)
- `GET /api/v1/recharge/status`(用户端 session)
- `POST /api/v1/recharge/callback/wechat`
- `POST /api/v1/recharge/callback/alipay`
当前骨架可运行。T-002 已在首次迁移前创建自定义 User,并按 `env.md` 接入 MySQL 8.4 / utf8mb4;远程 MySQL 已完成 Django 初始迁移。T-003 已接通 django-admin,测试可创建/销毁 `test_cmhub` 测试库;当前远程 MySQL 对频繁建库/销库仍可能间歇超时,必要时用 `--keepdb` 且串行跑测试。T-004 已应用 `users.0002_alter_user_email`,`user.email` 已有唯一索引。T-101 的 AI provider 层只做 HTTP 调用与响应解析;T-102 已把 provider 运行配置接到数据库 `AiModel` / `ModelAlias`,`resolve_alias()` 每次查当前 active 配置并按 `text` / `image` 能力校验。T-103 已补 `AiConfigAuditLog`,admin 保存/删除 `AiModel` / `ModelAlias` 时记录 actor、action、target、changed_fields、changes、created_at,密钥只记录 empty/set 状态。T-104/T-105 已用临时回滚配置跑通录制标题和录制图片生成。T-201 已落地钱包、API Key、点数流水和调用记录:API Key 明文只在创建 helper 返回,库内只存 hash/prefix;CallRecord 只存 `result_ref`/`result_summary`,没有 provider raw 字段。T-202 已落地 `PricingRule` / `ExchangeRate`:计费按 `operation_type + alias + resolution` 查 active 规则,优先精确分辨率,再回退默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`;金额换点数按当前 active 汇率向下取整。T-203 已落地 `precharge_call()` / `mark_call_success()` / `refund_call_points()`:预扣锁钱包行,余额不足不写调用/流水;失败退点锁调用记录并幂等写 refund 流水。T-204 已完成复合唯一约束加固,并取得一次完整 `manage.py test` 单次全绿。T-301 已落地 `Authorization: Bearer <API_KEY>` 鉴权:成功后 `request.user` 为所属用户、`request.auth` 为 `ApiKey`,缺失/无效 Key 返回 401,用户或 Key 禁用返回 403,外部 API 不接受 Web session。T-302 已落地生成接口:请求别名解析后按规则计费,预扣成功才调用 Provider,成功确认调用记录,`AiProviderError` / `AiCapabilityError` 等失败路径会退点;图片结果保存到本地 media 并返回 URL。T-303 已落地余额查询接口:`GET /api/v1/balance` 继承外部 API Key 鉴权,读取 billing 余额快照并返回 `user` 与 `points_balance`,测试覆盖余额与流水累加一致。T-304 已落地充值回调:`RechargeOrder` 保存下单锁定的金额/汇率/点数,微信/支付宝回调先验签再按订单幂等入账,重复回调不重复加点,金额不一致不入账;主动查单兜底可调用 `query_and_apply_recharge_payment(order_no, query_func)` 复用同一入账路径。真实上游生成未执行,原因是当前环境未配置 `AI_KEY_ENCRYPTION_KEY` 且数据库没有 AiModel/ModelAlias;后续配置后可用 `import_ai_models` 导入,再通过接口跑真实标题/图片。
当前骨架可运行。T-002 已在首次迁移前创建自定义 User,并按 `env.md` 接入 MySQL 8.4 / utf8mb4;远程 MySQL 已完成 Django 初始迁移。T-003 已接通 django-admin,测试可创建/销毁 `test_cmhub` 测试库;当前远程 MySQL 对频繁建库/销库仍可能间歇超时,必要时用 `--keepdb` 且串行跑测试。T-004 已应用 `users.0002_alter_user_email`,`user.email` 已有唯一索引。T-101 的 AI provider 层只做 HTTP 调用与响应解析;T-102 已把 provider 运行配置接到数据库 `AiModel` / `ModelAlias`,`resolve_alias()` 每次查当前 active 配置并按 `text` / `image` 能力校验。T-103 已补 `AiConfigAuditLog`,admin 保存/删除 `AiModel` / `ModelAlias` 时记录 actor、action、target、changed_fields、changes、created_at,密钥只记录 empty/set 状态。T-104/T-105 已用临时回滚配置跑通录制标题和录制图片生成。T-201 已落地钱包、API Key、点数流水和调用记录:API Key 明文只在创建 helper 返回,库内只存 hash/prefix;CallRecord 只存 `result_ref`/`result_summary`,没有 provider raw 字段。T-202 已落地 `PricingRule` / `ExchangeRate`:计费按 `operation_type + alias + resolution` 查 active 规则,优先精确分辨率,再回退默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`;金额换点数按当前 active 汇率向下取整。T-203 已落地 `precharge_call()` / `mark_call_success()` / `refund_call_points()`:预扣锁钱包行,余额不足不写调用/流水;失败退点锁调用记录并幂等写 refund 流水。T-204 已完成复合唯一约束加固,并取得一次完整 `manage.py test` 单次全绿。T-301 已落地 `Authorization: Bearer <API_KEY>` 鉴权:成功后 `request.user` 为所属用户、`request.auth` 为 `ApiKey`,缺失/无效 Key 返回 401,用户或 Key 禁用返回 403,外部 API 不接受 Web session。T-302 已落地生成接口:请求别名解析后按规则计费,预扣成功才调用 Provider,成功确认调用记录,`AiProviderError` / `AiCapabilityError` 等失败路径会退点;图片结果保存到本地 media 并返回 URL。T-303 已落地余额查询接口:`GET /api/v1/balance` 继承外部 API Key 鉴权,读取 billing 余额快照并返回 `user` 与 `points_balance`,测试覆盖余额与流水累加一致。T-304 已落地充值回调:`RechargeOrder` 保存下单锁定的金额/汇率/点数,微信/支付宝回调先验签再按订单幂等入账,重复回调不重复加点,金额不一致不入账;主动查单兜底可调用 `query_and_apply_recharge_payment(order_no, query_func)` 复用同一入账路径。T-305 已落地扫码下单与轮询:用户端 session 登录后可 `POST /api/v1/recharge/create` 创建 pending 订单并拿到 mock/SDK 二维码票据,`GET /api/v1/recharge/status` 只返回本人订单并在 pending 时尝试主动查单补入账;API Key 不能调用这两个用户端接口。真实上游生成未执行,原因是当前环境未配置 `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-305)。
4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-501)。
5. 将该任务状态改为 `DOING`。
## 维护规则
+1
View File
@@ -54,6 +54,7 @@ AI 上游连接超时与读取超时不走全局环境变量:连接超时由 `
| --- | --- | --- | --- |
| `PAYMENT_CALLBACK_MODE` | 是 | `mock` / `sdk` | 回调验签模式;本地/测试可用 `mock`,生产必须为 `sdk` |
| `PAYMENT_MOCK_CALLBACK_SECRET` | mock 是 | `change-me` | mock 回调 HMAC 密钥;仅用于本地/测试,不得冒充真实支付验签 |
| `PAYMENT_QR_EXPIRES_MINUTES` | 否 | `10` | 扫码下单返回的二维码本地有效期提示;不作为自动阻断延迟回调的依据 |
### 微信 V3 native
+28
View File
@@ -649,3 +649,31 @@
- 阻塞:T-304 功能与相关测试无阻塞;完整测试仍被远程 MySQL `43.128.3.240:3306` 连接稳定性阻塞。生产真实支付还需要安装/配置微信、支付宝 SDK 与商户密钥/证书;当前 mock 仅用于本地/测试联调。
- 决策:T-304 只做回调验签与幂等入账;扫码下单、订单状态查询、前端轮询留给 T-305。`PointsLedger.ref_order_id` 本轮仍保持数值引用,配合复合唯一约束兜底防重复充值流水。
- 下一步:领取 T-305 扫码充值下单 + 轮询(create/status)。
## 2026-07-03 T-305 扫码充值下单 + 轮询(create/status)
- 状态:DONE
- 变更:
- `apps/billing/services.py`:新增 `create_recharge_order()`,创建 pending 订单并绑定发起用户;下单时锁定当前 `ExchangeRate.points_per_unit` 与 `points_granted`,再调用支付网关下单取二维码票据;支付平台下单失败时订单标记 `failed`。
- `apps/billing/payment_gateways.py`:新增 `PaymentOrderCode`、`create_payment_order()`、微信/支付宝 mock 二维码生成、微信金额分转换、支付宝金额元格式化,以及 SDK 模式下单/查单入口;mock 查单明确返回不可用,等待回调或测试注入主动查单结果。
- `apps/api/views.py` / `apps/api/serializers.py` / `apps/api/urls.py`:新增 `POST /api/v1/recharge/create` 与 `GET /api/v1/recharge/status`;两个端点走 `SessionAuthentication + IsAuthenticated`,不继承外部 API Key 视图;create 受 CSRF 保护,status 只允许订单所属用户访问,并在 pending 时尝试主动查单补入账。
- `config/settings.py` / `.env.example` / `docs/env.md`:新增 `PAYMENT_QR_EXPIRES_MINUTES`,仅作为二维码本地有效期提示,不自动把 pending 订单改 expired。
- `apps/billing/tests.py`:覆盖创建充值订单锁定汇率/点数/二维码票据、网关下单失败时订单标记 failed。
- `apps/api/tests.py`:覆盖 session 下单、API Key 不能调用充值下单、真实 session 客户端无 CSRF 被拒、支付宝 mock 二维码、状态查询本人可见/他人 404、主动查单补入账且不重复入账。
- 同步更新 `README.md`、`docs/00-ai-start-here.md`、`docs/03-tech-stack.md`、`docs/04-architecture.md`、`docs/api.md`、`docs/06-tasks.md`、`docs/current-state.md`。
- 验证:
- `./init.ps1`:开工前通过,依赖同步与基础检查正常。
- `py -3.12 -m py_compile apps\billing\payment_gateways.py apps\billing\services.py apps\billing\tests.py apps\api\serializers.py apps\api\views.py apps\api\urls.py apps\api\tests.py config\settings.py`:通过。
- `py -3.12 manage.py test apps.billing apps.api --noinput --keepdb --verbosity 2`:通过,53 tests OK。
- `py -3.12 manage.py check`:通过,0 issues。
- `py -3.12 manage.py makemigrations --check`:通过,No changes detected。
- `py -3.12 -m compileall apps config`:通过。
- `git diff --check`:通过,仅 Windows CRLF 提示。
- `py -3.12 manage.py test --noinput --keepdb --verbosity 2`:未作为绿灯;发现 81 tests,跑过 71 tests,已运行用例均无断言失败;远程 MySQL `43.128.3.240:3306` 连接超时导致 `BillingCoreModelTests.setUpClass` 与 `AdminSmokeTests.setUpClass` 失败。
- `$env:MYSQL_CONNECT_TIMEOUT='90'; py -3.12 manage.py test apps.billing.tests.BillingCoreModelTests apps.users.tests.AdminSmokeTests --noinput --keepdb --verbosity 2`:通过,10 tests OK。
- 补微信主动查单 SDK 入口后,`py -3.12 -m py_compile apps\billing\payment_gateways.py apps\billing\services.py apps\api\views.py apps\api\tests.py`:通过;`py -3.12 manage.py check`:通过。
- `py -3.12 manage.py test apps.api.tests.RechargeCreateStatusApiTests apps.billing.tests.BillingServiceTests.test_create_recharge_order_locks_exchange_rate_points_and_qr_code apps.billing.tests.BillingServiceTests.test_create_recharge_order_marks_order_failed_when_gateway_fails --noinput --keepdb --verbosity 2`:未作为绿灯;API 新增 6 tests OK 后,进入 billing 测试类时远程 MySQL 连接超时。
- `$env:MYSQL_CONNECT_TIMEOUT='90'; py -3.12 manage.py test apps.billing.tests.BillingServiceTests.test_create_recharge_order_locks_exchange_rate_points_and_qr_code apps.billing.tests.BillingServiceTests.test_create_recharge_order_marks_order_failed_when_gateway_fails --noinput --keepdb --verbosity 2`:通过,2 tests OK。
- 阻塞:T-305 功能与相关测试无阻塞;完整测试仍被远程 MySQL 连接稳定性阻塞。真实支付生产下单 / 查单仍需要安装 SDK 并提供微信、支付宝商户密钥/证书与公网 notify_url;当前 mock 用于本地/测试联调。
- 决策:状态轮询不因本地 `expires_at` 自动把 pending 订单改 expired,避免误挡延迟到达的真实支付回调;二维码过期只作为前端提示,支付结果仍以回调或主动查单入账为准。T-305 只提供服务端 create/status,实际用户端充值页面留给 T-504。
- 下一步:领取 T-501 注册 / 登录(allauth)。