feat: add recharge create and status APIs
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) 第四节计费时序。
|
||||
|
||||
|
||||
@@ -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
@@ -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"})
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 = ()
|
||||
|
||||
@@ -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")),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", "")
|
||||
|
||||
@@ -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:后台与发布 —— 运营后台完善、完整验收、部署 / 运行文档。
|
||||
|
||||
## 领取任务规则
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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`。
|
||||
|
||||
## 维护规则
|
||||
|
||||
@@ -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
@@ -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)。
|
||||
|
||||
Reference in New Issue
Block a user