From c15a07a5c2442dcfc15b382fb2c35084ecad001d Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Fri, 3 Jul 2026 09:07:21 +0800 Subject: [PATCH] feat: add recharge callback processing --- .env.example | 4 + README.md | 2 +- apps/api/tests.py | 186 +++++++++++++++- apps/api/urls.py | 18 +- apps/api/views.py | 81 ++++++- apps/billing/admin.py | 26 ++- .../migrations/0004_rechargeorder_and_more.py | 72 +++++++ apps/billing/models.py | 78 +++++++ apps/billing/payment_gateways.py | 203 ++++++++++++++++++ apps/billing/services.py | 161 +++++++++++++- apps/billing/tests.py | 167 +++++++++++++- config/settings.py | 20 ++ docs/00-ai-start-here.md | 4 +- docs/03-tech-stack.md | 2 +- docs/04-architecture.md | 6 +- docs/06-tasks.md | 2 +- docs/api.md | 5 + docs/current-state.md | 26 +-- docs/env.md | 5 + progress.md | 30 +++ 20 files changed, 1073 insertions(+), 25 deletions(-) create mode 100644 apps/billing/migrations/0004_rechargeorder_and_more.py create mode 100644 apps/billing/payment_gateways.py diff --git a/.env.example b/.env.example index 4aa8c3c..cbe2810 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,10 @@ MYSQL_WRITE_TIMEOUT=120 # AI key encryption AI_KEY_ENCRYPTION_KEY=base64-fernet-key +# Payment callback verification +PAYMENT_CALLBACK_MODE=mock +PAYMENT_MOCK_CALLBACK_SECRET=change-me-mock-callback-secret + # WeChat Pay V3 native WECHAT_PAY_APPID=wx-your-appid WECHAT_PAY_MCHID=your-mchid diff --git a/README.md b/README.md index 9d659a9..ca79681 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Python 3.12 / Django 5.2 LTS + DRF / django-admin / 用户端 Django 模板 SSR ## 当前状态 -Phase 2 计费核心已完成,T-301 API Key 鉴权、T-302 生成标题 / 图片接口与 T-303 余额查询接口已落地:对外 API 可用 `Authorization: Bearer ` 调用生成能力或查询点数余额。下一步是 T-304 充值回调。详见 [`docs/current-state.md`](docs/current-state.md)。 +Phase 2 计费核心已完成,T-301 API Key 鉴权、T-302 生成标题 / 图片接口、T-303 余额查询接口与 T-304 充值回调已落地:对外 API 可用 `Authorization: Bearer ` 调用生成能力或查询点数余额,充值回调按订单幂等入账。下一步是 T-305 扫码充值下单 + 轮询。详见 [`docs/current-state.md`](docs/current-state.md)。 > ⚠️ 涉及资金/点数。改动充值、扣费、退款、对账相关代码前,先读 [`docs/05-coding-rules.md`](docs/05-coding-rules.md) 第 8 节与 [`docs/04-architecture.md`](docs/04-architecture.md) 第四节计费时序。 diff --git a/apps/api/tests.py b/apps/api/tests.py index 417e832..1a88af4 100644 --- a/apps/api/tests.py +++ b/apps/api/tests.py @@ -1,6 +1,8 @@ import uuid import base64 +import json import tempfile +from decimal import Decimal from pathlib import Path from unittest.mock import patch @@ -20,7 +22,11 @@ from apps.ai.providers import ( ImageGenerationResult, TextGenerationResult, ) -from apps.billing.models import CallRecord, PointsLedger, PricingRule +from apps.billing.models import CallRecord, PointsLedger, PricingRule, RechargeOrder +from apps.billing.payment_gateways import ( + build_mock_alipay_signature, + build_mock_body_signature, +) from apps.users.models import ApiKey from apps.users.models import UserWallet @@ -191,6 +197,184 @@ class BalanceApiTests(TestCase): self.assertEqual(response.data["error"]["code"], "unauthorized") +@override_settings( + PAYMENT_CALLBACK_MODE="mock", + PAYMENT_MOCK_CALLBACK_SECRET="test-payment-callback-secret", +) +class RechargeCallbackApiTests(TestCase): + wechat_url = "/api/v1/recharge/callback/wechat" + alipay_url = "/api/v1/recharge/callback/alipay" + + def setUp(self): + suffix = uuid.uuid4().hex[:8] + self.user = get_user_model().objects.create_user( + username=f"recharge-user-{suffix}", + email=f"recharge-user-{suffix}@example.com", + password="password", + ) + self.wallet = UserWallet.objects.create(user=self.user, points_balance=100) + self.client = APIClient(enforce_csrf_checks=True) + + def create_order( + self, + *, + amount="20.00", + points_granted=200, + pay_method=RechargeOrder.PayMethod.WEIXIN, + ) -> RechargeOrder: + return RechargeOrder.objects.create( + user=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, + ) + + def signed_wechat_body(self, order, *, total_cents=2000): + payload = { + "event_type": "TRANSACTION.SUCCESS", + "resource": { + "trade_state": "SUCCESS", + "out_trade_no": order.order_no, + "transaction_id": "wx-txn-001", + "success_time": "2026-07-03T00:00:00+08:00", + "amount": {"total": total_cents}, + }, + } + body = json.dumps(payload, separators=(",", ":")).encode("utf-8") + return body, build_mock_body_signature(body) + + def signed_alipay_payload(self, order, *, total_amount="20.00"): + payload = { + "trade_status": "TRADE_SUCCESS", + "out_trade_no": order.order_no, + "trade_no": "ali-txn-001", + "total_amount": total_amount, + "gmt_payment": "2026-07-03 00:00:00", + } + payload["sign"] = build_mock_alipay_signature(payload) + return payload + + def test_wechat_callback_credits_once_and_is_csrf_exempt(self): + order = self.create_order(amount="20.00", points_granted=200) + body, signature = self.signed_wechat_body(order) + + first = self.client.post( + self.wechat_url, + data=body, + content_type="application/json", + HTTP_WECHATPAY_SIGNATURE=signature, + ) + second = self.client.post( + self.wechat_url, + data=body, + content_type="application/json", + HTTP_WECHATPAY_SIGNATURE=signature, + ) + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 200) + self.assertEqual(first.data["code"], "SUCCESS") + self.wallet.refresh_from_db() + order.refresh_from_db() + self.assertEqual(self.wallet.points_balance, 300) + self.assertEqual(order.status, RechargeOrder.Status.PAID) + self.assertEqual(order.payment_txn_no, "wx-txn-001") + self.assertEqual( + PointsLedger.objects.filter( + user=self.user, + ref_order_id=order.id, + change_type=PointsLedger.ChangeType.RECHARGE, + ).count(), + 1, + ) + + def test_wechat_callback_rejects_bad_signature_without_crediting(self): + order = self.create_order(amount="20.00", points_granted=200) + body, _signature = self.signed_wechat_body(order) + + response = self.client.post( + self.wechat_url, + data=body, + content_type="application/json", + HTTP_WECHATPAY_SIGNATURE="bad-signature", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["error"]["code"], "signature_invalid") + self.wallet.refresh_from_db() + order.refresh_from_db() + self.assertEqual(self.wallet.points_balance, 100) + self.assertEqual(order.status, RechargeOrder.Status.PENDING) + self.assertFalse(PointsLedger.objects.filter(ref_order_id=order.id).exists()) + + def test_wechat_callback_rejects_amount_mismatch_without_crediting(self): + order = self.create_order(amount="20.00", points_granted=200) + body, signature = self.signed_wechat_body(order, total_cents=1999) + + response = self.client.post( + self.wechat_url, + data=body, + content_type="application/json", + HTTP_WECHATPAY_SIGNATURE=signature, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["error"]["code"], "amount_mismatch") + self.wallet.refresh_from_db() + order.refresh_from_db() + self.assertEqual(self.wallet.points_balance, 100) + self.assertEqual(order.status, RechargeOrder.Status.PENDING) + self.assertFalse(PointsLedger.objects.filter(ref_order_id=order.id).exists()) + + def test_alipay_callback_credits_once_returns_success_and_is_csrf_exempt(self): + order = self.create_order( + amount="20.00", + points_granted=200, + pay_method=RechargeOrder.PayMethod.ALIPAY, + ) + payload = self.signed_alipay_payload(order) + + first = self.client.post(self.alipay_url, data=payload) + second = self.client.post(self.alipay_url, data=payload) + + self.assertEqual(first.status_code, 200) + self.assertEqual(second.status_code, 200) + self.assertEqual(first.content, b"success") + self.wallet.refresh_from_db() + order.refresh_from_db() + self.assertEqual(self.wallet.points_balance, 300) + self.assertEqual(order.status, RechargeOrder.Status.PAID) + self.assertEqual(order.payment_txn_no, "ali-txn-001") + self.assertEqual( + PointsLedger.objects.filter( + ref_order_id=order.id, + change_type=PointsLedger.ChangeType.RECHARGE, + ).count(), + 1, + ) + + def test_alipay_callback_rejects_bad_signature_without_crediting(self): + order = self.create_order( + amount="20.00", + points_granted=200, + pay_method=RechargeOrder.PayMethod.ALIPAY, + ) + payload = self.signed_alipay_payload(order) + payload["sign"] = "bad-signature" + + response = self.client.post(self.alipay_url, data=payload) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.content, b"fail") + self.wallet.refresh_from_db() + order.refresh_from_db() + self.assertEqual(self.wallet.points_balance, 100) + self.assertEqual(order.status, RechargeOrder.Status.PENDING) + self.assertFalse(PointsLedger.objects.filter(ref_order_id=order.id).exists()) + + class FakeGenerationProvider: def __init__(self, *, capabilities=None): self._capabilities = set(capabilities or {"text", "image", "vision"}) diff --git a/apps/api/urls.py b/apps/api/urls.py index eb6041e..aaafe6e 100644 --- a/apps/api/urls.py +++ b/apps/api/urls.py @@ -1,9 +1,25 @@ from django.urls import path -from .views import BalanceView, GenerateImageView, GenerateTitleView +from .views import ( + AlipayRechargeCallbackView, + BalanceView, + GenerateImageView, + GenerateTitleView, + WechatRechargeCallbackView, +) 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/callback/wechat", + WechatRechargeCallbackView.as_view(), + name="api-recharge-callback-wechat", + ), + path( + "v1/recharge/callback/alipay", + AlipayRechargeCallbackView.as_view(), + name="api-recharge-callback-alipay", + ), ] diff --git a/apps/api/views.py b/apps/api/views.py index b2f15c7..2dc8643 100644 --- a/apps/api/views.py +++ b/apps/api/views.py @@ -1,3 +1,8 @@ +import logging + +from django.http import HttpResponse +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt from rest_framework.exceptions import AuthenticationFailed from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response @@ -15,7 +20,22 @@ from apps.api.serializers import ( GenerateImageRequestSerializer, GenerateTitleRequestSerializer, ) -from apps.billing.services import get_balance_snapshot +from apps.billing.payment_gateways import ( + PaymentVerificationError, + verify_alipay_callback, + verify_wechat_callback, +) +from apps.billing.services import ( + InvalidRechargeOrderStateError, + RechargeAmountMismatchError, + RechargeCallbackError, + RechargeOrderNotFoundError, + RechargePayMethodMismatchError, + apply_recharge_payment, + get_balance_snapshot, +) + +logger = logging.getLogger(__name__) class ExternalApiView(APIView): @@ -77,3 +97,62 @@ class BalanceView(ExternalApiView): }, status=status.HTTP_200_OK, ) + + +class RechargeCallbackView(APIView): + authentication_classes = () + permission_classes = () + + @staticmethod + def _error_response(exc: Exception): + if isinstance(exc, PaymentVerificationError): + return ( + api_error("signature_invalid", "支付回调验签失败"), + status.HTTP_400_BAD_REQUEST, + ) + if isinstance(exc, RechargeAmountMismatchError): + return ( + api_error("amount_mismatch", "支付回调金额与本地订单金额不一致"), + status.HTTP_400_BAD_REQUEST, + ) + if isinstance(exc, RechargeCallbackError): + return api_error(exc.code, "支付回调处理失败"), status.HTTP_400_BAD_REQUEST + return api_error("bad_request", "支付回调处理失败"), status.HTTP_400_BAD_REQUEST + + +@method_decorator(csrf_exempt, name="dispatch") +class WechatRechargeCallbackView(RechargeCallbackView): + def post(self, request): + try: + payment = verify_wechat_callback(request.headers, request.body) + apply_recharge_payment(payment) + except ( + PaymentVerificationError, + RechargeAmountMismatchError, + InvalidRechargeOrderStateError, + RechargeOrderNotFoundError, + RechargePayMethodMismatchError, + ) as exc: + logger.warning("Rejected WeChat recharge callback: %s", exc.__class__.__name__) + data, http_status = self._error_response(exc) + return Response(data, status=http_status) + return Response({"code": "SUCCESS", "message": "成功"}, status=status.HTTP_200_OK) + + +@method_decorator(csrf_exempt, name="dispatch") +class AlipayRechargeCallbackView(RechargeCallbackView): + def post(self, request): + try: + payload = request.data.dict() if hasattr(request.data, "dict") else dict(request.data) + payment = verify_alipay_callback(payload) + apply_recharge_payment(payment) + except ( + PaymentVerificationError, + RechargeAmountMismatchError, + InvalidRechargeOrderStateError, + RechargeOrderNotFoundError, + RechargePayMethodMismatchError, + ) as exc: + logger.warning("Rejected Alipay recharge callback: %s", exc.__class__.__name__) + return HttpResponse("fail", status=status.HTTP_400_BAD_REQUEST) + return HttpResponse("success", content_type="text/plain", status=status.HTTP_200_OK) diff --git a/apps/billing/admin.py b/apps/billing/admin.py index 114a271..1ed18f5 100644 --- a/apps/billing/admin.py +++ b/apps/billing/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import CallRecord, ExchangeRate, PointsLedger, PricingRule +from .models import CallRecord, ExchangeRate, PointsLedger, PricingRule, RechargeOrder class ReadOnlyLedgerAdmin(admin.ModelAdmin): @@ -68,6 +68,30 @@ class ExchangeRateAdmin(admin.ModelAdmin): readonly_fields = ("created_at", "updated_at") +@admin.register(RechargeOrder) +class RechargeOrderAdmin(ReadOnlyLedgerAdmin): + list_display = ( + "created_at", + "order_no", + "user", + "pay_method", + "status", + "amount_money", + "currency", + "points_granted", + "payment_txn_no", + "paid_at", + ) + list_filter = ("pay_method", "status", "currency", "created_at") + search_fields = ( + "order_no", + "user__username", + "user__email", + "payment_txn_no", + ) + ordering = ("-created_at", "-id") + + @admin.register(CallRecord) class CallRecordAdmin(ReadOnlyLedgerAdmin): list_display = ( diff --git a/apps/billing/migrations/0004_rechargeorder_and_more.py b/apps/billing/migrations/0004_rechargeorder_and_more.py new file mode 100644 index 0000000..e1fe9c1 --- /dev/null +++ b/apps/billing/migrations/0004_rechargeorder_and_more.py @@ -0,0 +1,72 @@ +# Generated by Django 5.2.15 on 2026-07-03 00:45 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('billing', '0003_pointsledger_unique_ledger_change_type_per_call'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='RechargeOrder', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('order_no', models.CharField(max_length=64, unique=True)), + ('amount_money', models.DecimalField(decimal_places=2, max_digits=12)), + ('currency', models.CharField(default='CNY', max_length=10)), + ('pay_method', models.CharField(choices=[('weixin', 'Weixin'), ('alipay', 'Alipay')], max_length=20)), + ('exchange_rate', models.DecimalField(decimal_places=4, max_digits=12)), + ('points_granted', models.BigIntegerField()), + ('status', models.CharField(choices=[('pending', 'Pending'), ('paid', 'Paid'), ('failed', 'Failed'), ('expired', 'Expired')], default='pending', max_length=20)), + ('code_url', models.TextField(blank=True)), + ('expires_at', models.DateTimeField(blank=True, null=True)), + ('payment_txn_no', models.CharField(blank=True, max_length=128)), + ('paid_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'db_table': 'recharge_order', + 'ordering': ('-created_at', '-id'), + }, + ), + migrations.AddConstraint( + model_name='pointsledger', + constraint=models.UniqueConstraint(fields=('ref_order_id', 'change_type'), name='unique_ledger_change_type_per_order'), + ), + migrations.AddField( + model_name='rechargeorder', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='recharge_orders', to=settings.AUTH_USER_MODEL), + ), + migrations.AddIndex( + model_name='rechargeorder', + index=models.Index(fields=['user', 'created_at'], name='recharge_or_user_id_312e87_idx'), + ), + migrations.AddIndex( + model_name='rechargeorder', + index=models.Index(fields=['status', 'created_at'], name='recharge_or_status_139277_idx'), + ), + migrations.AddIndex( + model_name='rechargeorder', + index=models.Index(fields=['pay_method', 'status'], name='recharge_or_pay_met_4c0043_idx'), + ), + migrations.AddConstraint( + model_name='rechargeorder', + constraint=models.CheckConstraint(condition=models.Q(('amount_money__gt', 0)), name='recharge_order_amount_money_positive'), + ), + migrations.AddConstraint( + model_name='rechargeorder', + constraint=models.CheckConstraint(condition=models.Q(('exchange_rate__gt', 0)), name='recharge_order_exchange_rate_positive'), + ), + migrations.AddConstraint( + model_name='rechargeorder', + constraint=models.CheckConstraint(condition=models.Q(('points_granted__gt', 0)), name='recharge_order_points_granted_positive'), + ), + ] diff --git a/apps/billing/models.py b/apps/billing/models.py index 30a9482..4770b65 100644 --- a/apps/billing/models.py +++ b/apps/billing/models.py @@ -154,6 +154,80 @@ class ExchangeRate(models.Model): super().save(*args, **kwargs) +class RechargeOrder(models.Model): + class PayMethod(models.TextChoices): + WEIXIN = "weixin", "Weixin" + ALIPAY = "alipay", "Alipay" + + class Status(models.TextChoices): + PENDING = "pending", "Pending" + PAID = "paid", "Paid" + FAILED = "failed", "Failed" + EXPIRED = "expired", "Expired" + + order_no = models.CharField(max_length=64, unique=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + related_name="recharge_orders", + ) + amount_money = models.DecimalField(max_digits=12, decimal_places=2) + currency = models.CharField(max_length=10, default="CNY") + pay_method = models.CharField(max_length=20, choices=PayMethod.choices) + exchange_rate = models.DecimalField(max_digits=12, decimal_places=4) + points_granted = models.BigIntegerField() + status = models.CharField( + max_length=20, + choices=Status.choices, + default=Status.PENDING, + ) + code_url = models.TextField(blank=True) + expires_at = models.DateTimeField(null=True, blank=True) + payment_txn_no = models.CharField(max_length=128, blank=True) + paid_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "recharge_order" + ordering = ("-created_at", "-id") + constraints = [ + models.CheckConstraint( + condition=Q(amount_money__gt=0), + name="recharge_order_amount_money_positive", + ), + models.CheckConstraint( + condition=Q(exchange_rate__gt=0), + name="recharge_order_exchange_rate_positive", + ), + models.CheckConstraint( + condition=Q(points_granted__gt=0), + name="recharge_order_points_granted_positive", + ), + ] + indexes = [ + models.Index(fields=("user", "created_at")), + models.Index(fields=("status", "created_at")), + models.Index(fields=("pay_method", "status")), + ] + + def __str__(self) -> str: + return f"{self.order_no} {self.status}" + + def clean(self) -> None: + super().clean() + self.order_no = str(self.order_no or "").strip() + self.currency = str(self.currency or "").strip().upper() + if not self.order_no: + raise ValidationError({"order_no": "Order number is required."}) + if not self.currency: + raise ValidationError({"currency": "Currency is required."}) + + def save(self, *args, **kwargs) -> None: + self.full_clean() + super().save(*args, **kwargs) + + class PointsLedger(models.Model): class ChangeType(models.TextChoices): RECHARGE = "recharge", "Recharge" @@ -196,6 +270,10 @@ class PointsLedger(models.Model): fields=("ref_call", "change_type"), name="unique_ledger_change_type_per_call", ), + models.UniqueConstraint( + fields=("ref_order_id", "change_type"), + name="unique_ledger_change_type_per_order", + ), ] indexes = [ models.Index(fields=("user", "created_at")), diff --git a/apps/billing/payment_gateways.py b/apps/billing/payment_gateways.py new file mode 100644 index 0000000..b0f2c7f --- /dev/null +++ b/apps/billing/payment_gateways.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +import hashlib +import hmac +import json +from decimal import Decimal +from decimal import InvalidOperation +from pathlib import Path + +from django.conf import settings +from django.utils import timezone +from django.utils.dateparse import parse_datetime + +from .models import RechargeOrder +from .services import RechargePayment + + +class PaymentVerificationError(RuntimeError): + code = "signature_invalid" + + +class PaymentQueryUnavailableError(RuntimeError): + code = "payment_query_unavailable" + + +def payment_callback_mode() -> str: + return str(getattr(settings, "PAYMENT_CALLBACK_MODE", "") or "").strip().lower() + + +def _mock_secret() -> str: + return str(getattr(settings, "PAYMENT_MOCK_CALLBACK_SECRET", "") or "") + + +def _require_mock_secret() -> bytes: + secret = _mock_secret() + if not secret: + raise PaymentVerificationError("Mock payment callback secret is not configured.") + return secret.encode("utf-8") + + +def build_mock_body_signature(body: bytes) -> str: + return hmac.new(_require_mock_secret(), body, hashlib.sha256).hexdigest() + + +def build_mock_alipay_signature(data: dict) -> str: + canonical = "&".join( + f"{key}={data[key]}" + for key in sorted(data) + if key not in {"sign", "sign_type"} + ) + return hmac.new( + _require_mock_secret(), + canonical.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + +def _parse_paid_at(value: str | None): + if not value: + return timezone.now() + parsed = parse_datetime(str(value)) + if parsed is None: + return timezone.now() + if timezone.is_naive(parsed): + return timezone.make_aware(parsed, timezone.get_current_timezone()) + return parsed + + +def _decimal_money(value) -> Decimal: + try: + return Decimal(str(value)).quantize(Decimal("0.01")) + except (InvalidOperation, TypeError, ValueError) as exc: + raise PaymentVerificationError("Invalid payment amount.") from exc + + +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") + if not provided or not hmac.compare_digest(str(provided), expected): + raise PaymentVerificationError("Invalid mock WeChat callback signature.") + + +def _verify_mock_alipay_signature(data: dict) -> None: + expected = build_mock_alipay_signature(data) + provided = data.get("sign") + if not provided or not hmac.compare_digest(str(provided), expected): + raise PaymentVerificationError("Invalid mock Alipay callback signature.") + + +def verify_wechat_callback(headers, body: bytes) -> RechargePayment: + if payment_callback_mode() == "mock": + _verify_mock_wechat_signature(headers, body) + try: + payload = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise PaymentVerificationError("Invalid mock WeChat callback payload.") from exc + + resource = payload.get("resource") or {} + if payload.get("event_type") != "TRANSACTION.SUCCESS": + raise PaymentVerificationError("Unsupported WeChat callback event.") + if resource.get("trade_state") != "SUCCESS": + raise PaymentVerificationError("WeChat transaction is not successful.") + + try: + total_cents = Decimal(str((resource.get("amount") or {}).get("total"))) + except (InvalidOperation, TypeError, ValueError) as exc: + raise PaymentVerificationError("Invalid WeChat payment amount.") from exc + return RechargePayment( + order_no=str(resource.get("out_trade_no") or ""), + 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")), + ) + + return _verify_wechat_callback_with_sdk(headers, body) + + +def verify_alipay_callback(data: dict) -> RechargePayment: + callback_data = {key: str(value) for key, value in data.items()} + if payment_callback_mode() == "mock": + _verify_mock_alipay_signature(callback_data) + else: + _verify_alipay_callback_with_sdk(callback_data) + + if callback_data.get("trade_status") not in {"TRADE_SUCCESS", "TRADE_FINISHED"}: + raise PaymentVerificationError("Alipay trade is not successful.") + + return RechargePayment( + order_no=str(callback_data.get("out_trade_no") or ""), + pay_method=RechargeOrder.PayMethod.ALIPAY, + amount=_decimal_money(callback_data.get("total_amount")), + transaction_id=str(callback_data.get("trade_no") or ""), + paid_at=_parse_paid_at(callback_data.get("gmt_payment")), + ) + + +def _verify_wechat_callback_with_sdk(headers, body: bytes) -> RechargePayment: + try: + from wechatpayv3 import WeChatPay # type: ignore + except ImportError as exc: + raise PaymentVerificationError("wechatpayv3 is not installed.") from exc + + private_key_path = getattr(settings, "WECHAT_PAY_PRIVATE_KEY_PATH", "") + if not private_key_path: + raise PaymentVerificationError("WeChat Pay private key path is not configured.") + + private_key = Path(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, + ) + resource = client.callback(headers, body) + if resource.get("trade_state") != "SUCCESS": + raise PaymentVerificationError("WeChat transaction is not successful.") + + try: + total_cents = Decimal(str((resource.get("amount") or {}).get("total"))) + except (InvalidOperation, TypeError, ValueError) as exc: + raise PaymentVerificationError("Invalid WeChat payment amount.") from exc + return RechargePayment( + order_no=str(resource.get("out_trade_no") or ""), + 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 _verify_alipay_callback_with_sdk(data: dict) -> None: + try: + from alipay import AliPay # type: ignore + except ImportError as exc: + raise PaymentVerificationError("python-alipay-sdk is not installed.") from exc + + 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" + ) + sign = data.pop("sign", "") + 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, + ) + if not client.verify(data, sign): + raise PaymentVerificationError("Invalid Alipay callback signature.") + + +def query_payment_order(order: RechargeOrder) -> RechargePayment: + raise PaymentQueryUnavailableError( + f"Active payment query for {order.pay_method} is not configured." + ) diff --git a/apps/billing/services.py b/apps/billing/services.py index a886819..367b823 100644 --- a/apps/billing/services.py +++ b/apps/billing/services.py @@ -1,13 +1,16 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import datetime +from decimal import Decimal from django.db import transaction from django.db.models import Sum +from django.utils import timezone from apps.users.models import UserWallet -from .models import CallRecord, PointsLedger, normalize_resolution +from .models import CallRecord, PointsLedger, RechargeOrder, normalize_resolution class BillingOperationError(RuntimeError): @@ -30,6 +33,44 @@ class InvalidCallStateError(BillingOperationError): super().__init__(message) +class RechargeCallbackError(BillingOperationError): + code = "recharge_callback_error" + + +class RechargeOrderNotFoundError(RechargeCallbackError): + code = "order_not_found" + + def __init__(self, order_no: str): + self.order_no = order_no + super().__init__("Recharge order was not found.") + + +class RechargeAmountMismatchError(RechargeCallbackError): + code = "amount_mismatch" + + def __init__(self, *, order_amount: Decimal, callback_amount: Decimal): + self.order_amount = order_amount + self.callback_amount = callback_amount + super().__init__("Payment callback amount does not match local recharge order.") + + +class RechargePayMethodMismatchError(RechargeCallbackError): + code = "bad_request" + + def __init__(self, *, order_pay_method: str, callback_pay_method: str): + self.order_pay_method = order_pay_method + self.callback_pay_method = callback_pay_method + super().__init__("Payment callback method does not match local recharge order.") + + +class InvalidRechargeOrderStateError(RechargeCallbackError): + code = "bad_request" + + def __init__(self, *, status: str): + self.status = status + super().__init__("Recharge order cannot be paid from its current state.") + + @dataclass(frozen=True) class CallCharge: call_record: CallRecord @@ -53,6 +94,24 @@ class BalanceSnapshot: ledger_balance: int +@dataclass(frozen=True) +class RechargePayment: + order_no: str + pay_method: str + amount: Decimal + transaction_id: str + paid_at: datetime | None = None + + +@dataclass(frozen=True) +class RechargeResult: + order: RechargeOrder + ledger_entry: PointsLedger | None + points_granted: int + balance_after: int + applied: bool + + def _validate_positive_points(points: int) -> int: if isinstance(points, bool) or not isinstance(points, int) or points <= 0: raise ValueError("points must be a positive integer") @@ -64,6 +123,14 @@ def _locked_wallet_for_user(user) -> UserWallet: return wallet +def _normalize_money(value) -> Decimal: + return Decimal(str(value)).quantize(Decimal("0.01")) + + +def _normalize_pay_method(value: str) -> str: + return str(value or "").strip().lower() + + def get_balance_snapshot(user) -> BalanceSnapshot: points_balance = ( UserWallet.objects.filter(user=user) @@ -79,6 +146,98 @@ def get_balance_snapshot(user) -> BalanceSnapshot: ) +def apply_recharge_payment(payment: RechargePayment) -> RechargeResult: + order_no = str(payment.order_no or "").strip() + pay_method = _normalize_pay_method(payment.pay_method) + callback_amount = _normalize_money(payment.amount) + paid_at = payment.paid_at or timezone.now() + + with transaction.atomic(): + try: + order = ( + RechargeOrder.objects.select_for_update() + .select_related("user") + .get(order_no=order_no) + ) + except RechargeOrder.DoesNotExist as exc: + raise RechargeOrderNotFoundError(order_no) from exc + + if order.status == RechargeOrder.Status.PAID: + ledger_entry = ( + PointsLedger.objects.filter( + ref_order_id=order.id, + change_type=PointsLedger.ChangeType.RECHARGE, + ) + .order_by("id") + .first() + ) + wallet_balance = ( + UserWallet.objects.filter(user=order.user) + .values_list("points_balance", flat=True) + .first() + ) + return RechargeResult( + order=order, + ledger_entry=ledger_entry, + points_granted=0, + balance_after=int(wallet_balance or 0), + applied=False, + ) + + if order.status != RechargeOrder.Status.PENDING: + raise InvalidRechargeOrderStateError(status=order.status) + + if _normalize_pay_method(order.pay_method) != pay_method: + raise RechargePayMethodMismatchError( + order_pay_method=order.pay_method, + callback_pay_method=pay_method, + ) + + order_amount = _normalize_money(order.amount_money) + if order_amount != callback_amount: + raise RechargeAmountMismatchError( + order_amount=order_amount, + callback_amount=callback_amount, + ) + + points_granted = _validate_positive_points(order.points_granted) + wallet = _locked_wallet_for_user(order.user) + wallet.points_balance += points_granted + wallet.save(update_fields=("points_balance", "updated_at")) + + order.status = RechargeOrder.Status.PAID + order.payment_txn_no = str(payment.transaction_id or "").strip() + order.paid_at = paid_at + order.save(update_fields=("status", "payment_txn_no", "paid_at", "updated_at")) + + ledger_entry = PointsLedger.objects.create( + user=order.user, + change_type=PointsLedger.ChangeType.RECHARGE, + points_delta=points_granted, + balance_after=wallet.points_balance, + ref_order_id=order.id, + reason=f"Recharge paid via {order.pay_method}: {order.order_no}", + ) + + return RechargeResult( + order=order, + ledger_entry=ledger_entry, + points_granted=points_granted, + balance_after=ledger_entry.balance_after, + applied=True, + ) + + +def query_and_apply_recharge_payment(order_no: str, query_func) -> RechargeResult: + normalized_order_no = str(order_no or "").strip() + try: + order = RechargeOrder.objects.get(order_no=normalized_order_no) + except RechargeOrder.DoesNotExist as exc: + raise RechargeOrderNotFoundError(normalized_order_no) from exc + payment = query_func(order) + return apply_recharge_payment(payment) + + def precharge_call( *, user, diff --git a/apps/billing/tests.py b/apps/billing/tests.py index ffea997..018cc50 100644 --- a/apps/billing/tests.py +++ b/apps/billing/tests.py @@ -13,7 +13,13 @@ from django.test import TestCase, TransactionTestCase, override_settings from django.utils import timezone from apps.ai.models import AiModel, ModelAlias -from apps.billing.models import CallRecord, ExchangeRate, PointsLedger, PricingRule +from apps.billing.models import ( + CallRecord, + ExchangeRate, + PointsLedger, + PricingRule, + RechargeOrder, +) from apps.billing.pricing import ( NoPricingRuleError, calculate_points_cost, @@ -24,8 +30,12 @@ from apps.billing.pricing import ( from apps.billing.services import ( InsufficientPointsError, InvalidCallStateError, + RechargeAmountMismatchError, + RechargePayment, + apply_recharge_payment, mark_call_success, precharge_call, + query_and_apply_recharge_payment, refund_call_points, ) from apps.users.models import ApiKey, UserWallet @@ -178,11 +188,55 @@ class BillingCoreModelTests(TestCase): ref_call=call, ) + def test_points_ledger_rejects_duplicate_recharge_for_same_order(self): + order = RechargeOrder.objects.create( + user=self.user, + order_no=f"R{uuid.uuid4().hex[:12]}", + amount_money=Decimal("20.00"), + pay_method=RechargeOrder.PayMethod.WEIXIN, + exchange_rate=Decimal("10.0000"), + points_granted=200, + ) + other_order = RechargeOrder.objects.create( + user=self.user, + order_no=f"R{uuid.uuid4().hex[:12]}", + amount_money=Decimal("30.00"), + pay_method=RechargeOrder.PayMethod.WEIXIN, + exchange_rate=Decimal("10.0000"), + points_granted=300, + ) + + PointsLedger.objects.create( + user=self.user, + change_type=PointsLedger.ChangeType.RECHARGE, + points_delta=200, + balance_after=200, + ref_order_id=order.id, + ) + PointsLedger.objects.create( + user=self.user, + change_type=PointsLedger.ChangeType.RECHARGE, + points_delta=300, + balance_after=500, + ref_order_id=other_order.id, + ) + + with self.assertRaises(IntegrityError): + with transaction.atomic(): + PointsLedger.objects.create( + user=self.user, + change_type=PointsLedger.ChangeType.RECHARGE, + points_delta=200, + balance_after=700, + ref_order_id=order.id, + ) + def test_billing_models_are_registered_in_admin(self): self.assertIn(UserWallet, admin.site._registry) self.assertIn(ApiKey, admin.site._registry) self.assertIn(PointsLedger, admin.site._registry) self.assertIn(CallRecord, admin.site._registry) + self.assertIn(RechargeOrder, admin.site._registry) @override_settings(AI_KEY_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY) @@ -339,6 +393,22 @@ class BillingServiceTests(TestCase): self.wallet = UserWallet.objects.create(user=self.user, points_balance=100) self.api_key, _raw_key = ApiKey.create_for_user(self.user, name="server") + def create_recharge_order( + self, + *, + amount="20.00", + points_granted=200, + pay_method=RechargeOrder.PayMethod.WEIXIN, + ) -> RechargeOrder: + return RechargeOrder.objects.create( + user=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, + ) + def test_precharge_call_debits_wallet_and_writes_pending_call_and_consume_ledger(self): charge = precharge_call( user=self.user, @@ -458,6 +528,101 @@ class BillingServiceTests(TestCase): with self.assertRaises(InvalidCallStateError): refund_call_points(call, error_message="late failure") + def test_apply_recharge_payment_credits_wallet_writes_ledger_and_marks_paid(self): + order = self.create_recharge_order(amount="20.00", points_granted=200) + + result = apply_recharge_payment( + RechargePayment( + order_no=order.order_no, + pay_method=RechargeOrder.PayMethod.WEIXIN, + amount=Decimal("20.00"), + transaction_id="wx-txn-001", + paid_at=timezone.now(), + ) + ) + + self.wallet.refresh_from_db() + order.refresh_from_db() + self.assertTrue(result.applied) + self.assertEqual(result.points_granted, 200) + self.assertEqual(result.balance_after, 300) + self.assertEqual(self.wallet.points_balance, 300) + self.assertEqual(order.status, RechargeOrder.Status.PAID) + self.assertEqual(order.payment_txn_no, "wx-txn-001") + self.assertIsNotNone(order.paid_at) + + ledger = PointsLedger.objects.get(ref_order_id=order.id) + self.assertEqual(ledger.change_type, PointsLedger.ChangeType.RECHARGE) + self.assertEqual(ledger.points_delta, 200) + self.assertEqual(ledger.balance_after, 300) + self.assertEqual(ledger.user, self.user) + + def test_apply_recharge_payment_is_idempotent_for_duplicate_callback(self): + order = self.create_recharge_order(amount="20.00", points_granted=200) + payment = RechargePayment( + order_no=order.order_no, + pay_method=RechargeOrder.PayMethod.WEIXIN, + amount=Decimal("20.00"), + transaction_id="wx-txn-duplicate", + paid_at=timezone.now(), + ) + + first = apply_recharge_payment(payment) + second = apply_recharge_payment(payment) + + self.wallet.refresh_from_db() + self.assertTrue(first.applied) + self.assertFalse(second.applied) + self.assertEqual(self.wallet.points_balance, 300) + self.assertEqual( + PointsLedger.objects.filter( + ref_order_id=order.id, + change_type=PointsLedger.ChangeType.RECHARGE, + ).count(), + 1, + ) + + def test_apply_recharge_payment_rejects_amount_mismatch_without_crediting(self): + order = self.create_recharge_order(amount="20.00", points_granted=200) + + with self.assertRaises(RechargeAmountMismatchError): + apply_recharge_payment( + RechargePayment( + order_no=order.order_no, + pay_method=RechargeOrder.PayMethod.WEIXIN, + amount=Decimal("19.99"), + transaction_id="wx-txn-bad-amount", + paid_at=timezone.now(), + ) + ) + + self.wallet.refresh_from_db() + order.refresh_from_db() + self.assertEqual(self.wallet.points_balance, 100) + self.assertEqual(order.status, RechargeOrder.Status.PENDING) + self.assertFalse(PointsLedger.objects.filter(ref_order_id=order.id).exists()) + + def test_query_and_apply_recharge_payment_uses_same_idempotent_path(self): + order = self.create_recharge_order(amount="30.00", points_granted=300) + + 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(), + ) + + result = query_and_apply_recharge_payment(order.order_no, fake_query) + + self.wallet.refresh_from_db() + order.refresh_from_db() + self.assertTrue(result.applied) + self.assertEqual(self.wallet.points_balance, 400) + self.assertEqual(order.status, RechargeOrder.Status.PAID) + self.assertEqual(order.payment_txn_no, "queried-txn-001") + class ConcurrentDebitTests(TransactionTestCase): def setUp(self): diff --git a/config/settings.py b/config/settings.py index 161719a..f11cac3 100644 --- a/config/settings.py +++ b/config/settings.py @@ -59,6 +59,26 @@ AI_KEY_ENCRYPTION_KEY = os.environ.get("AI_KEY_ENCRYPTION_KEY", "") # SECURITY WARNING: don't run with debug turned on in production! DEBUG = env_bool("DJANGO_DEBUG", True) +PAYMENT_CALLBACK_MODE = os.environ.get( + "PAYMENT_CALLBACK_MODE", + "mock" if DEBUG else "sdk", +).strip().lower() +PAYMENT_MOCK_CALLBACK_SECRET = os.environ.get( + "PAYMENT_MOCK_CALLBACK_SECRET", + "cmhub-dev-mock-callback-secret" if DEBUG else "", +) +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", "") +WECHAT_PAY_CERT_SERIAL_NO = os.environ.get("WECHAT_PAY_CERT_SERIAL_NO", "") +WECHAT_PAY_PRIVATE_KEY_PATH = os.environ.get("WECHAT_PAY_PRIVATE_KEY_PATH", "") +WECHAT_PAY_NOTIFY_URL = os.environ.get("WECHAT_PAY_NOTIFY_URL", "") +ALIPAY_APPID = os.environ.get("ALIPAY_APPID", "") +ALIPAY_APP_PRIVATE_KEY_PATH = os.environ.get("ALIPAY_APP_PRIVATE_KEY_PATH", "") +ALIPAY_PUBLIC_KEY_PATH = os.environ.get("ALIPAY_PUBLIC_KEY_PATH", "") +ALIPAY_NOTIFY_URL = os.environ.get("ALIPAY_NOTIFY_URL", "") +ALIPAY_DEBUG = env_bool("ALIPAY_DEBUG", False) + ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS", "127.0.0.1,localhost,testserver") diff --git a/docs/00-ai-start-here.md b/docs/00-ai-start-here.md index 77b0de7..1e7bb7d 100644 --- a/docs/00-ai-start-here.md +++ b/docs/00-ai-start-here.md @@ -38,14 +38,14 @@ ## 当前阶段 -当前项目处于:**Phase 3 对外 API 与充值起步**。Phase 2 计费核心已完成到 T-204;T-301 API Key 鉴权、T-302 生成标题 / 图片接口与 T-303 余额查询接口已落地。下一步进入 T-304 充值回调。 +当前项目处于:**Phase 3 对外 API 与充值起步**。Phase 2 计费核心已完成到 T-204;T-301 API Key 鉴权、T-302 生成标题 / 图片接口、T-303 余额查询接口与 T-304 充值回调已落地。下一步进入 T-305 扫码充值下单 + 轮询。 优先路径: 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 充值回调,随后扫码下单与轮询。 +4. Phase 3:对外 API 与充值 —— T-301 Key 鉴权、T-302 生成接口、T-303 余额查询、T-304 充值回调已完成;下一步 T-305 扫码下单与轮询。 5. Phase 4:用户端(Django 模板 SSR)—— 注册登录、API Key 管理、个人中心/记录页、充值页。 6. Phase 5:后台与发布 —— 运营后台完善、完整验收、部署 / 运行文档。 diff --git a/docs/03-tech-stack.md b/docs/03-tech-stack.md index 3769b54..0b3225c 100644 --- a/docs/03-tech-stack.md +++ b/docs/03-tech-stack.md @@ -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 + 支付宝当面付;下单取二维码 + 服务端回调(验签 + 幂等) | 已定 | 库 `wechatpayv3` / `python-alipay-sdk`;协议对齐同支付系统 PHP 实现(见 `api.md`);仅商户密钥/证书待提供 | +| 充值对接 | 自助扫码:微信 V3 native + 支付宝当面付;下单取二维码 + 服务端回调(验签 + 幂等) | 已定 | T-304 已落地回调、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) | diff --git a/docs/04-architecture.md b/docs/04-architecture.md index d0458da..7ebfa68 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -42,6 +42,8 @@ T-302 已实现 `/api/v1/generate/title` 与 `/api/v1/generate/image`:API 层 T-303 已实现 `/api/v1/balance`:外部 API 继续只认 API Key,API 层调用 `apps.billing.services.get_balance_snapshot()` 读取当前钱包余额;测试覆盖余额响应与流水累加一致的场景。 +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`。 + **计费层(`apps/billing`)** - 计费规则查询:按「操作类型 + 能力别名(+ 可选分辨率)」算出本次点数 N。**按别名定价,不按具体供应商 SKU 定价**,这样后台换底层模型时计费不变。 @@ -230,7 +232,7 @@ CREATE TABLE points_ledger ( change_type TEXT NOT NULL, -- recharge / consume / adjust / refund points_delta BIGINT NOT NULL, -- 正为加、负为减 balance_after BIGINT NOT NULL, -- 变动后余额,便于对账 - ref_order_id INTEGER, -- T-201 先存数值引用;RechargeOrder 落地后再正式关联 + ref_order_id INTEGER, -- 数值引用 RechargeOrder.id;T-304 已加 ref_order_id+change_type 唯一兜底 ref_call_id INTEGER REFERENCES call_record(id), reason TEXT, -- 运营手工调整必填原因 created_at TEXT NOT NULL @@ -266,7 +268,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 已落地扣点/退点服务;`ref_order_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`。 ## 四、计费时序(核心,务必照此实现) diff --git a/docs/06-tasks.md b/docs/06-tasks.md index c3da716..20cde7a 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -53,7 +53,7 @@ | T-301 | API Key 鉴权(DRF Authentication) | T-201 | 对请求 Key 哈希比对定位 ApiKey→User;**只挂 Key 认证、不挂 Session**;无效/缺失 401;用户或 Key 禁用 403 | DONE | | 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 入账写流水;补主动查单兜底;含幂等测试 | TODO | +| 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 | ## Phase 4 · 用户端(Django 模板 SSR) diff --git a/docs/api.md b/docs/api.md index c093476..21ffadd 100644 --- a/docs/api.md +++ b/docs/api.md @@ -23,6 +23,8 @@ T-302 已实现生成接口基线:`POST /api/v1/generate/title` 与 `POST /api T-303 已实现余额查询基线:`GET /api/v1/balance` 已接入 API Key 鉴权,返回当前 `UserWallet.points_balance`;测试覆盖响应余额与 `PointsLedger.points_delta` 累加值一致的账务场景,并确认 Web session 不能调用该外部接口。 +T-304 已实现充值回调基线:`POST /api/v1/recharge/callback/wechat` 与 `/alipay` 已 `@csrf_exempt`,回调先验签(开发/测试可用明确 HMAC mock,生产 `PAYMENT_CALLBACK_MODE=sdk` 走支付 SDK),再按 `order_no` 锁定 `RechargeOrder` 幂等入账;金额或支付方式不一致不加点,重复回调不重复写充值流水。 + 通用错误响应: ```json @@ -133,6 +135,8 @@ calculate_points_granted(amount, currency: str = "CNY", at=None) -> int 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 +apply_recharge_payment(payment: RechargePayment) -> RechargeResult +query_and_apply_recharge_payment(order_no: str, query_func) -> RechargeResult ``` 要点: @@ -145,6 +149,7 @@ refund_call_points(call_record: CallRecord, ...) -> RefundResult - `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` 流水,重复调用不会重复加点;成功调用不能走失败退点。 +- 已验签支付回调调用 `apply_recharge_payment()`:按 `order_no` 锁定 `RechargeOrder`,校验金额和通道,锁 `UserWallet` 加点并写 `PointsLedger(change_type=recharge, ref_order_id=order.id)`;重复回调直接返回已处理结果,不重复加点。 ## 支付充值(自助扫码:微信 V3 native + 支付宝当面付) diff --git a/docs/current-state.md b/docs/current-state.md index 22dfca1..61cff6a 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -11,17 +11,17 @@ ## 当前快照 -- 日期:2026-07-02 -- 阶段:Phase 3 对外 API 与充值起步;T-303 余额查询接口已完成,下一步 T-304 充值回调 +- 日期:2026-07-03 +- 阶段:Phase 3 对外 API 与充值起步;T-304 充值回调已完成,下一步 T-305 扫码充值下单 + 轮询 - 技术栈:系统 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-303 验证通过:`./init.ps1`、`py_compile`、`manage.py check`、`makemigrations --check`、`manage.py test apps.api --noinput --keepdb --verbosity 2`(19 tests OK)、`py -3.12 manage.py test apps.ai.tests.AiModelEncryptionTests --noinput --keepdb --verbosity 2`(2 tests OK,使用 `MYSQL_CONNECT_TIMEOUT=90` 补跑连接失败类)。完整 `py -3.12 manage.py test --noinput --keepdb --verbosity 2` 多次尝试仍被远程 MySQL `43.128.3.240:3306` 连接超时 / reset 阻断;已通过用例无断言失败,失败点是建连接或测试类 setUp。 +- 生产代码:已有最小 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 连接超时。 - 数据: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`;真实密钥/支付凭证不得写入代码或文档样例。充值订单在创建时锁定汇率与预计点数,回调入账使用订单值,不按新汇率重算 -- 当前 blocker:远程 MySQL 连接当前不稳定,导致完整测试暂未取得 T-303 后的单次全绿;T-303 相关测试已通过。支付商户密钥/证书仍缺真实值,但可先按 T-304 mock / 可替换配置实现;真实 AI 上游 smoke 需要先配置 `AI_KEY_ENCRYPTION_KEY` 并导入 AiModel/ModelAlias。图片同步真实耗时风险仍未退,已登记到 T-403。 +- 配置基线:运行环境变量集中见 `docs/env.md`;真实密钥/支付凭证不得写入代码或文档样例。充值订单在创建时锁定汇率与预计点数,回调入账使用订单值,不按新汇率重算。支付回调由 `PAYMENT_CALLBACK_MODE` 控制:本地/测试可用 HMAC `mock`,生产应为 `sdk` +- 当前 blocker:远程 MySQL 连接当前不稳定,完整测试暂未单次全绿;T-304 相关测试已通过。微信/支付宝真实商户密钥/证书与生产 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 已在 `apps/billing/services.py` 落扣点/退点与余额快照;T-301/T-302/T-303 已在 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 已在 `apps/billing/services.py` 落扣点/退点、余额快照与充值入账;T-304 已新增 `apps/billing/payment_gateways.py`;T-301/T-302/T-303/T-304 已在 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-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 充值回调。 - 正在进行:无。 -- 当前 blocker:远程 MySQL 连接当前不稳定,完整测试暂未单次全绿;支付商户真实密钥/证书仍待提供。 -- 下一个可领取任务:**T-304 充值回调(微信/支付宝验签 + 幂等入账)**。 +- 当前 blocker:远程 MySQL 连接当前不稳定,完整测试暂未单次全绿;支付商户真实密钥/证书与生产 SDK 依赖仍待提供。 +- 下一个可领取任务:**T-305 扫码充值下单 + 轮询(create/status)**。 ## 当前可运行内容 @@ -73,15 +73,17 @@ 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/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 ` 鉴权:成功后 `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`,测试覆盖余额与流水累加一致。真实上游生成未执行,原因是当前环境未配置 `AI_KEY_ENCRYPTION_KEY` 且数据库没有 AiModel/ModelAlias;后续配置后可用 `import_ai_models` 导入,再通过接口跑真实标题/图片。 +当前骨架可运行。T-002 已在首次迁移前创建自定义 User,并按 `env.md` 接入 MySQL 8.4 / utf8mb4;远程 MySQL 已完成 Django 初始迁移。T-003 已接通 django-admin,测试可创建/销毁 `test_cmhub` 测试库;当前远程 MySQL 对频繁建库/销库仍可能间歇超时,必要时用 `--keepdb` 且串行跑测试。T-004 已应用 `users.0002_alter_user_email`,`user.email` 已有唯一索引。T-101 的 AI provider 层只做 HTTP 调用与响应解析;T-102 已把 provider 运行配置接到数据库 `AiModel` / `ModelAlias`,`resolve_alias()` 每次查当前 active 配置并按 `text` / `image` 能力校验。T-103 已补 `AiConfigAuditLog`,admin 保存/删除 `AiModel` / `ModelAlias` 时记录 actor、action、target、changed_fields、changes、created_at,密钥只记录 empty/set 状态。T-104/T-105 已用临时回滚配置跑通录制标题和录制图片生成。T-201 已落地钱包、API Key、点数流水和调用记录:API Key 明文只在创建 helper 返回,库内只存 hash/prefix;CallRecord 只存 `result_ref`/`result_summary`,没有 provider raw 字段。T-202 已落地 `PricingRule` / `ExchangeRate`:计费按 `operation_type + alias + resolution` 查 active 规则,优先精确分辨率,再回退默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`;金额换点数按当前 active 汇率向下取整。T-203 已落地 `precharge_call()` / `mark_call_success()` / `refund_call_points()`:预扣锁钱包行,余额不足不写调用/流水;失败退点锁调用记录并幂等写 refund 流水。T-204 已完成复合唯一约束加固,并取得一次完整 `manage.py test` 单次全绿。T-301 已落地 `Authorization: Bearer ` 鉴权:成功后 `request.user` 为所属用户、`request.auth` 为 `ApiKey`,缺失/无效 Key 返回 401,用户或 Key 禁用返回 403,外部 API 不接受 Web session。T-302 已落地生成接口:请求别名解析后按规则计费,预扣成功才调用 Provider,成功确认调用记录,`AiProviderError` / `AiCapabilityError` 等失败路径会退点;图片结果保存到本地 media 并返回 URL。T-303 已落地余额查询接口:`GET /api/v1/balance` 继承外部 API Key 鉴权,读取 billing 余额快照并返回 `user` 与 `points_balance`,测试覆盖余额与流水累加一致。T-304 已落地充值回调:`RechargeOrder` 保存下单锁定的金额/汇率/点数,微信/支付宝回调先验签再按订单幂等入账,重复回调不重复加点,金额不一致不入账;主动查单兜底可调用 `query_and_apply_recharge_payment(order_no, query_func)` 复用同一入账路径。真实上游生成未执行,原因是当前环境未配置 `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-304)。 +4. 在 `docs/06-tasks.md` 领取第一个 `TODO` 且依赖均 `DONE` 的任务(当前为 T-305)。 5. 将该任务状态改为 `DOING`。 ## 维护规则 diff --git a/docs/env.md b/docs/env.md index 743573b..f19fb9c 100644 --- a/docs/env.md +++ b/docs/env.md @@ -50,6 +50,11 @@ AI 上游连接超时与读取超时不走全局环境变量:连接超时由 ` ## 五、支付配置 +| 变量 | 必填 | 示例 | 说明 | +| --- | --- | --- | --- | +| `PAYMENT_CALLBACK_MODE` | 是 | `mock` / `sdk` | 回调验签模式;本地/测试可用 `mock`,生产必须为 `sdk` | +| `PAYMENT_MOCK_CALLBACK_SECRET` | mock 是 | `change-me` | mock 回调 HMAC 密钥;仅用于本地/测试,不得冒充真实支付验签 | + ### 微信 V3 native | 变量 | 必填 | 示例 | 说明 | diff --git a/progress.md b/progress.md index d45f741..12d7a23 100644 --- a/progress.md +++ b/progress.md @@ -619,3 +619,33 @@ - 阻塞:T-303 功能与相关测试无阻塞;完整测试当前被远程 MySQL `43.128.3.240:3306` 连接稳定性阻塞,后续建议数据库连通稳定后重跑一次 `py -3.12 manage.py test --noinput --keepdb --verbosity 2`。 - 决策:余额接口返回 `UserWallet.points_balance` 作为对外余额;测试用流水累加值校验账务场景一致性。接口只读,不在 API 层创建或修改钱包。 - 下一步:领取 T-304 充值回调(微信/支付宝验签 + 幂等入账)。 + +## 2026-07-03 T-304 充值回调(微信/支付宝验签 + 幂等入账) + +- 状态:DONE +- 变更: + - `apps/billing/models.py`:新增 `RechargeOrder`,保存 `order_no`、用户、金额、通道、下单锁定汇率、预计/实际入账点数、订单状态、支付流水号与支付时间;新增 `points_ledger(ref_order_id, change_type)` 复合唯一约束,作为同一订单重复充值流水的 DB 兜底。 + - `apps/billing/services.py`:新增 `RechargePayment` / `RechargeResult`、`apply_recharge_payment()` 与 `query_and_apply_recharge_payment()`;入账时锁订单,校验 pending、金额、通道,锁 `UserWallet` 加点,写 `PointsLedger(recharge)`,重复回调幂等返回不重复加点。 + - `apps/billing/payment_gateways.py`:新增微信 / 支付宝回调 verifier;`PAYMENT_CALLBACK_MODE=mock` 用 HMAC 模拟验签,`sdk` 模式走 `wechatpayv3` / `python-alipay-sdk` 入口,缺 SDK 或配置时失败不入账。 + - `apps/api/views.py` / `apps/api/urls.py`:新增 `POST /api/v1/recharge/callback/wechat` 与 `/alipay`,两个端点均无登录态、显式 `@csrf_exempt`;微信成功返回 `{"code":"SUCCESS","message":"成功"}`,支付宝成功返回纯文本 `success`。 + - `apps/billing/admin.py`:注册只读 `RechargeOrderAdmin`。 + - `apps/billing/tests.py`:覆盖订单 admin 注册、同一订单重复充值流水 DB 兜底、成功入账、重复回调幂等、金额不一致不入账、主动查单复用同一入账路径。 + - `apps/api/tests.py`:覆盖微信/支付宝回调成功与重复回调幂等、mock 验签失败不入账、金额不一致不入账、开启 CSRF 检查时回调端点仍可被支付网关调用。 + - 新增迁移 `apps/billing/migrations/0004_rechargeorder_and_more.py`,已应用到当前 MySQL。 + - 同步更新 `.env.example`、`README.md`、`docs/00-ai-start-here.md`、`docs/03-tech-stack.md`、`docs/04-architecture.md`、`docs/api.md`、`docs/env.md`、`docs/06-tasks.md`、`docs/current-state.md`。 +- 验证: + - `./init.ps1`:开工前通过,依赖同步与 `manage.py check` 正常。 + - `py -3.12 manage.py makemigrations billing`:生成 `billing.0004_rechargeorder_and_more`。 + - `py -3.12 -m py_compile apps\billing\models.py apps\billing\admin.py apps\billing\services.py apps\billing\payment_gateways.py apps\billing\tests.py apps\api\views.py apps\api\urls.py apps\api\tests.py config\settings.py`:通过。 + - `py -3.12 manage.py makemigrations --check`:通过,No changes detected。 + - `py -3.12 manage.py migrate`:通过,应用 `billing.0004_rechargeorder_and_more`。 + - `py -3.12 manage.py check`:通过,0 issues。 + - `py -3.12 manage.py showmigrations billing`:通过,`billing.0001` ~ `0004` 均为 `[X]`。 + - `py -3.12 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 失败。 + - `$env:MYSQL_CONNECT_TIMEOUT='90'; py -3.12 manage.py test apps.billing.tests.BillingCoreModelTests apps.billing.tests.ConcurrentDebitTests --noinput --keepdb --verbosity 2`:业务用例均跑过并显示 ok;最终测试库 flush 阶段仍遇到远程 MySQL 连接超时,因此命令整体未作为绿灯。 +- 阻塞:T-304 功能与相关测试无阻塞;完整测试仍被远程 MySQL `43.128.3.240:3306` 连接稳定性阻塞。生产真实支付还需要安装/配置微信、支付宝 SDK 与商户密钥/证书;当前 mock 仅用于本地/测试联调。 +- 决策:T-304 只做回调验签与幂等入账;扫码下单、订单状态查询、前端轮询留给 T-305。`PointsLedger.ref_order_id` 本轮仍保持数值引用,配合复合唯一约束兜底防重复充值流水。 +- 下一步:领取 T-305 扫码充值下单 + 轮询(create/status)。