feat: add recharge callback processing

This commit is contained in:
QiuSW
2026-07-03 09:07:21 +08:00
parent 8931960b35
commit c15a07a5c2
20 changed files with 1073 additions and 25 deletions
+4
View File
@@ -18,6 +18,10 @@ MYSQL_WRITE_TIMEOUT=120
# AI key encryption # AI key encryption
AI_KEY_ENCRYPTION_KEY=base64-fernet-key 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 V3 native
WECHAT_PAY_APPID=wx-your-appid WECHAT_PAY_APPID=wx-your-appid
WECHAT_PAY_MCHID=your-mchid WECHAT_PAY_MCHID=your-mchid
+1 -1
View File
@@ -25,7 +25,7 @@ Python 3.12 / Django 5.2 LTS + DRF / django-admin / 用户端 Django 模板 SSR
## 当前状态 ## 当前状态
Phase 2 计费核心已完成,T-301 API Key 鉴权、T-302 生成标题 / 图片接口与 T-303 余额查询接口已落地:对外 API 可用 `Authorization: Bearer <API_KEY>` 调用生成能力或查询点数余额。下一步是 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 <API_KEY>` 调用生成能力或查询点数余额,充值回调按订单幂等入账。下一步是 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) 第四节计费时序。 > ⚠️ 涉及资金/点数。改动充值、扣费、退款、对账相关代码前,先读 [`docs/05-coding-rules.md`](docs/05-coding-rules.md) 第 8 节与 [`docs/04-architecture.md`](docs/04-architecture.md) 第四节计费时序。
+185 -1
View File
@@ -1,6 +1,8 @@
import uuid import uuid
import base64 import base64
import json
import tempfile import tempfile
from decimal import Decimal
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
@@ -20,7 +22,11 @@ from apps.ai.providers import (
ImageGenerationResult, ImageGenerationResult,
TextGenerationResult, 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 ApiKey
from apps.users.models import UserWallet from apps.users.models import UserWallet
@@ -191,6 +197,184 @@ class BalanceApiTests(TestCase):
self.assertEqual(response.data["error"]["code"], "unauthorized") 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: class FakeGenerationProvider:
def __init__(self, *, capabilities=None): def __init__(self, *, capabilities=None):
self._capabilities = set(capabilities or {"text", "image", "vision"}) self._capabilities = set(capabilities or {"text", "image", "vision"})
+17 -1
View File
@@ -1,9 +1,25 @@
from django.urls import path from django.urls import path
from .views import BalanceView, GenerateImageView, GenerateTitleView from .views import (
AlipayRechargeCallbackView,
BalanceView,
GenerateImageView,
GenerateTitleView,
WechatRechargeCallbackView,
)
urlpatterns = [ urlpatterns = [
path("v1/balance", BalanceView.as_view(), name="api-balance"), path("v1/balance", BalanceView.as_view(), name="api-balance"),
path("v1/generate/title", GenerateTitleView.as_view(), name="api-generate-title"), path("v1/generate/title", GenerateTitleView.as_view(), name="api-generate-title"),
path("v1/generate/image", GenerateImageView.as_view(), name="api-generate-image"), 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",
),
] ]
+80 -1
View File
@@ -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.exceptions import AuthenticationFailed
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
@@ -15,7 +20,22 @@ from apps.api.serializers import (
GenerateImageRequestSerializer, GenerateImageRequestSerializer,
GenerateTitleRequestSerializer, 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): class ExternalApiView(APIView):
@@ -77,3 +97,62 @@ class BalanceView(ExternalApiView):
}, },
status=status.HTTP_200_OK, 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)
+25 -1
View File
@@ -1,6 +1,6 @@
from django.contrib import admin from django.contrib import admin
from .models import CallRecord, ExchangeRate, PointsLedger, PricingRule from .models import CallRecord, ExchangeRate, PointsLedger, PricingRule, RechargeOrder
class ReadOnlyLedgerAdmin(admin.ModelAdmin): class ReadOnlyLedgerAdmin(admin.ModelAdmin):
@@ -68,6 +68,30 @@ class ExchangeRateAdmin(admin.ModelAdmin):
readonly_fields = ("created_at", "updated_at") 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) @admin.register(CallRecord)
class CallRecordAdmin(ReadOnlyLedgerAdmin): class CallRecordAdmin(ReadOnlyLedgerAdmin):
list_display = ( list_display = (
@@ -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'),
),
]
+78
View File
@@ -154,6 +154,80 @@ class ExchangeRate(models.Model):
super().save(*args, **kwargs) 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 PointsLedger(models.Model):
class ChangeType(models.TextChoices): class ChangeType(models.TextChoices):
RECHARGE = "recharge", "Recharge" RECHARGE = "recharge", "Recharge"
@@ -196,6 +270,10 @@ class PointsLedger(models.Model):
fields=("ref_call", "change_type"), fields=("ref_call", "change_type"),
name="unique_ledger_change_type_per_call", name="unique_ledger_change_type_per_call",
), ),
models.UniqueConstraint(
fields=("ref_order_id", "change_type"),
name="unique_ledger_change_type_per_order",
),
] ]
indexes = [ indexes = [
models.Index(fields=("user", "created_at")), models.Index(fields=("user", "created_at")),
+203
View File
@@ -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."
)
+160 -1
View File
@@ -1,13 +1,16 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from django.db import transaction from django.db import transaction
from django.db.models import Sum from django.db.models import Sum
from django.utils import timezone
from apps.users.models import UserWallet from apps.users.models import UserWallet
from .models import CallRecord, PointsLedger, normalize_resolution from .models import CallRecord, PointsLedger, RechargeOrder, normalize_resolution
class BillingOperationError(RuntimeError): class BillingOperationError(RuntimeError):
@@ -30,6 +33,44 @@ class InvalidCallStateError(BillingOperationError):
super().__init__(message) 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) @dataclass(frozen=True)
class CallCharge: class CallCharge:
call_record: CallRecord call_record: CallRecord
@@ -53,6 +94,24 @@ class BalanceSnapshot:
ledger_balance: int 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: def _validate_positive_points(points: int) -> int:
if isinstance(points, bool) or not isinstance(points, int) or points <= 0: if isinstance(points, bool) or not isinstance(points, int) or points <= 0:
raise ValueError("points must be a positive integer") raise ValueError("points must be a positive integer")
@@ -64,6 +123,14 @@ def _locked_wallet_for_user(user) -> UserWallet:
return wallet 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: def get_balance_snapshot(user) -> BalanceSnapshot:
points_balance = ( points_balance = (
UserWallet.objects.filter(user=user) 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( def precharge_call(
*, *,
user, user,
+166 -1
View File
@@ -13,7 +13,13 @@ from django.test import TestCase, TransactionTestCase, override_settings
from django.utils import timezone from django.utils import timezone
from apps.ai.models import AiModel, ModelAlias 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 ( from apps.billing.pricing import (
NoPricingRuleError, NoPricingRuleError,
calculate_points_cost, calculate_points_cost,
@@ -24,8 +30,12 @@ from apps.billing.pricing import (
from apps.billing.services import ( from apps.billing.services import (
InsufficientPointsError, InsufficientPointsError,
InvalidCallStateError, InvalidCallStateError,
RechargeAmountMismatchError,
RechargePayment,
apply_recharge_payment,
mark_call_success, mark_call_success,
precharge_call, precharge_call,
query_and_apply_recharge_payment,
refund_call_points, refund_call_points,
) )
from apps.users.models import ApiKey, UserWallet from apps.users.models import ApiKey, UserWallet
@@ -178,11 +188,55 @@ class BillingCoreModelTests(TestCase):
ref_call=call, 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): def test_billing_models_are_registered_in_admin(self):
self.assertIn(UserWallet, admin.site._registry) self.assertIn(UserWallet, admin.site._registry)
self.assertIn(ApiKey, admin.site._registry) self.assertIn(ApiKey, admin.site._registry)
self.assertIn(PointsLedger, admin.site._registry) self.assertIn(PointsLedger, admin.site._registry)
self.assertIn(CallRecord, admin.site._registry) self.assertIn(CallRecord, admin.site._registry)
self.assertIn(RechargeOrder, admin.site._registry)
@override_settings(AI_KEY_ENCRYPTION_KEY=TEST_ENCRYPTION_KEY) @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.wallet = UserWallet.objects.create(user=self.user, points_balance=100)
self.api_key, _raw_key = ApiKey.create_for_user(self.user, name="server") 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): def test_precharge_call_debits_wallet_and_writes_pending_call_and_consume_ledger(self):
charge = precharge_call( charge = precharge_call(
user=self.user, user=self.user,
@@ -458,6 +528,101 @@ class BillingServiceTests(TestCase):
with self.assertRaises(InvalidCallStateError): with self.assertRaises(InvalidCallStateError):
refund_call_points(call, error_message="late failure") 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): class ConcurrentDebitTests(TransactionTestCase):
def setUp(self): def setUp(self):
+20
View File
@@ -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! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = env_bool("DJANGO_DEBUG", True) 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") ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS", "127.0.0.1,localhost,testserver")
+2 -2
View File
@@ -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 审核修补项已完成。 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 前补测。 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、计费规则、汇率、计费计算、并发安全扣点与失败退点。 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 管理、个人中心/记录页、充值页。 5. Phase 4:用户端(Django 模板 SSR)—— 注册登录、API Key 管理、个人中心/记录页、充值页。
6. Phase 5:后台与发布 —— 运营后台完善、完整验收、部署 / 运行文档。 6. Phase 5:后台与发布 —— 运营后台完善、完整验收、部署 / 运行文档。
+1 -1
View File
@@ -21,7 +21,7 @@
| MySQL 驱动 | PyMySQL + cryptography | 已定 | PyMySQL 负责 Django 连接 MySQL;MySQL 8 默认 `caching_sha2_password` 认证需要 `cryptography` 支持;客户端连接/读/写超时通过 `MYSQL_CONNECT_TIMEOUT` / `MYSQL_READ_TIMEOUT` / `MYSQL_WRITE_TIMEOUT` 配置 | | 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 绕过计费 | | 对外鉴权 | API Key(DRF 自定义 Authentication,哈希存储比对) | 已定 | 用户自助生成 Key;**API 只认 Key、不挂 SessionAuthentication**,防浏览器 cookie 绕过计费 |
| 用户端鉴权 | Django Session(+ allauth 注册登录邮箱验证) | 已定 | 用户端页面与 `recharge/create` 走 session + CSRF;后台账号也用 Session 登录 | | 用户端鉴权 | 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 简化;图片接口需调大网关/服务超时 | | 生成返回方式 | 同步 HTTP(无任务队列) | 已定 | MVP 简化;图片接口需调大网关/服务超时 |
| 任务队列 | 暂不引入(Celery/RQ) | 待定 | V2 异步化时再评估 | | 任务队列 | 暂不引入(Celery/RQ) | 待定 | V2 异步化时再评估 |
| 部署方式 | Docker + Gunicorn(gthread) + Nginx,单体 | 待定 | MVP 先 `runserver`;生产 Nginx 按路径把 `/api/generate/*`(图片长请求)与用户端页面**分流到不同 gunicorn/worker 池**,避免图片阻塞拖慢页面(见 `04-architecture.md` 5.1) | | 部署方式 | Docker + Gunicorn(gthread) + Nginx,单体 | 待定 | MVP 先 `runserver`;生产 Nginx 按路径把 `/api/generate/*`(图片长请求)与用户端页面**分流到不同 gunicorn/worker 池**,避免图片阻塞拖慢页面(见 `04-architecture.md` 5.1) |
+4 -2
View File
@@ -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-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`)** **计费层(`apps/billing`)**
- 计费规则查询:按「操作类型 + 能力别名(+ 可选分辨率)」算出本次点数 N。**按别名定价,不按具体供应商 SKU 定价**,这样后台换底层模型时计费不变。 - 计费规则查询:按「操作类型 + 能力别名(+ 可选分辨率)」算出本次点数 N。**按别名定价,不按具体供应商 SKU 定价**,这样后台换底层模型时计费不变。
@@ -230,7 +232,7 @@ CREATE TABLE points_ledger (
change_type TEXT NOT NULL, -- recharge / consume / adjust / refund change_type TEXT NOT NULL, -- recharge / consume / adjust / refund
points_delta BIGINT NOT NULL, -- 正为加、负为减 points_delta BIGINT NOT NULL, -- 正为加、负为减
balance_after 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), ref_call_id INTEGER REFERENCES call_record(id),
reason TEXT, -- 运营手工调整必填原因 reason TEXT, -- 运营手工调整必填原因
created_at TEXT NOT NULL created_at TEXT NOT NULL
@@ -266,7 +268,7 @@ CREATE TABLE call_record (
- `payment_user_id`、`payment_txn_no` 为对账预留,字段先建。 - `payment_user_id`、`payment_txn_no` 为对账预留,字段先建。
- `recharge_order.exchange_rate` 与 `points_granted` 在下单时写入,状态为 `pending` 时也必须有值;支付回调金额必须与订单金额一致,入账时不得按新的汇率重算。 - `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` 状态,避免调用结果与账务动作混在一个字段里。 - `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`。
## 四、计费时序(核心,务必照此实现) ## 四、计费时序(核心,务必照此实现)
+1 -1
View File
@@ -53,7 +53,7 @@
| T-301 | API Key 鉴权(DRF Authentication) | T-201 | 对请求 Key 哈希比对定位 ApiKey→User;**只挂 Key 认证、不挂 Session**;无效/缺失 401;用户或 Key 禁用 403 | DONE | | 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-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-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 | | T-305 | 扫码充值下单 + 轮询(create/status) | T-304 | 支持 weixin(native,金额分)/alipay(precreate,金额元);建 pending 订单绑定 user,并在下单时锁定汇率/预计点数→取 code_url/qr_code→前端渲染 + 轮询 status;缺商户密钥时 mock | TODO |
## Phase 4 · 用户端(Django 模板 SSR) ## Phase 4 · 用户端(Django 模板 SSR)
+5
View File
@@ -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-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 ```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 precharge_call(..., user, points_cost: int, operation_type: str, alias: str, ...) -> CallCharge
mark_call_success(call_record: CallRecord, ...) -> CallRecord mark_call_success(call_record: CallRecord, ...) -> CallRecord
refund_call_points(call_record: CallRecord, ...) -> RefundResult 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`、不调上游。 - `precharge_call()` 使用事务 + `select_for_update()` 锁 `UserWallet` 行;余额不足抛 `InsufficientPointsError(code="insufficient_points")`,不创建 `CallRecord`、不写 `PointsLedger`、不调上游。
- 预扣成功后写 `CallRecord(status=pending)` 与 `PointsLedger(change_type=consume, points_delta=-N)`;上游成功只更新调用记录,余额不再变化。 - 预扣成功后写 `CallRecord(status=pending)` 与 `PointsLedger(change_type=consume, points_delta=-N)`;上游成功只更新调用记录,余额不再变化。
- 上游失败调用 `refund_call_points()`:同一 `CallRecord` 只写一条 `refund` 流水,重复调用不会重复加点;成功调用不能走失败退点。 - 上游失败调用 `refund_call_points()`:同一 `CallRecord` 只写一条 `refund` 流水,重复调用不会重复加点;成功调用不能走失败退点。
- 已验签支付回调调用 `apply_recharge_payment()`:按 `order_no` 锁定 `RechargeOrder`,校验金额和通道,锁 `UserWallet` 加点并写 `PointsLedger(change_type=recharge, ref_order_id=order.id)`;重复回调直接返回已处理结果,不重复加点。
## 支付充值(自助扫码:微信 V3 native + 支付宝当面付) ## 支付充值(自助扫码:微信 V3 native + 支付宝当面付)
+14 -12
View File
@@ -11,17 +11,17 @@
## 当前快照 ## 当前快照
- 日期:2026-07-02 - 日期:2026-07-03
- 阶段:Phase 3 对外 API 与充值起步;T-303 余额查询接口已完成,下一步 T-304 充值回调 - 阶段: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` - 技术栈:系统 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` 余额查询接口 - 生产代码:已有最小 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-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。 - 测试: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` 命令加密导入 - 数据: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 用 `./init.ps1`;Unix/WSL 用 `./init.sh`
- 标准验证路径:Windows 用 `py -3.12 manage.py check` / `py -3.12 manage.py test` - 标准验证路径: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` 决策 - 设计基线:**自助用户端 + 对外 API + 运营后台**三合一单体;用户模型 `User`(auth)/`UserWallet`(点数,锁 wallet 扣点)/`ApiKey`(1:N,哈希存储);对外两接口 + **能力别名 + Provider 适配器**(可插拔供应商);自助扫码充值;注册不送点数。详见 `04-architecture.md` 与 2026-06-29 / 2026-07-01 的 `progress.md` 决策
- 配置基线:运行环境变量集中见 `docs/env.md`;真实密钥/支付凭证不得写入代码或文档样例。充值订单在创建时锁定汇率与预计点数,回调入账使用订单值,不按新汇率重算 - 配置基线:运行环境变量集中见 `docs/env.md`;真实密钥/支付凭证不得写入代码或文档样例。充值订单在创建时锁定汇率与预计点数,回调入账使用订单值,不按新汇率重算。支付回调由 `PAYMENT_CALLBACK_MODE` 控制:本地/测试可用 HMAC `mock`,生产应为 `sdk`
- 当前 blocker:远程 MySQL 连接当前不稳定,导致完整测试暂未取得 T-303 后的单次全绿;T-303 相关测试已通过。支付商户密钥/证书仍缺真实值,但可先按 T-304 mock / 可替换配置实现;真实 AI 上游 smoke 需要先配置 `AI_KEY_ENCRYPTION_KEY` 并导入 AiModel/ModelAlias。图片同步真实耗时风险仍未退,已登记到 T-403。 - 当前 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` | | `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 加密 | | `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 | | `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 创建 | | `manage.py` | 已有 | T-001 创建 |
| `tests/` | 待建 | 随各任务补充 | | `tests/` | 待建 | 随各任务补充 |
@@ -41,10 +41,10 @@
任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史执行记录见 [`../progress.md`](../progress.md)。 任务状态以 [`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 连接当前不稳定,完整测试暂未单次全绿;支付商户真实密钥/证书仍待提供。 - 当前 blocker:远程 MySQL 连接当前不稳定,完整测试暂未单次全绿;支付商户真实密钥/证书与生产 SDK 依赖仍待提供。
- 下一个可领取任务:**T-304 充值回调(微信/支付宝验签 + 幂等入账)**。 - 下一个可领取任务:**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/title`
- `POST /api/v1/generate/image` - `POST /api/v1/generate/image`
- `GET /api/v1/balance` - `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 <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`,测试覆盖余额与流水累加一致。真实上游生成未执行,原因是当前环境未配置 `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)` 复用同一入账路径。真实上游生成未执行,原因是当前环境未配置 `AI_KEY_ENCRYPTION_KEY` 且数据库没有 AiModel/ModelAlias;后续配置后可用 `import_ai_models` 导入,再通过接口跑真实标题/图片。
## 开始编码前检查 ## 开始编码前检查
1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。 1. 读仓库级 `AGENTS.md` / `CLAUDE.md`。
2. 读 `docs/00-ai-start-here.md`。 2. 读 `docs/00-ai-start-here.md`。
3. 读 `docs/05-coding-rules.md`(尤其第 8 节资金安全)。 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`。 5. 将该任务状态改为 `DOING`。
## 维护规则 ## 维护规则
+5
View File
@@ -50,6 +50,11 @@ AI 上游连接超时与读取超时不走全局环境变量:连接超时由 `
## 五、支付配置 ## 五、支付配置
| 变量 | 必填 | 示例 | 说明 |
| --- | --- | --- | --- |
| `PAYMENT_CALLBACK_MODE` | 是 | `mock` / `sdk` | 回调验签模式;本地/测试可用 `mock`,生产必须为 `sdk` |
| `PAYMENT_MOCK_CALLBACK_SECRET` | mock 是 | `change-me` | mock 回调 HMAC 密钥;仅用于本地/测试,不得冒充真实支付验签 |
### 微信 V3 native ### 微信 V3 native
| 变量 | 必填 | 示例 | 说明 | | 变量 | 必填 | 示例 | 说明 |
+30
View File
@@ -619,3 +619,33 @@
- 阻塞:T-303 功能与相关测试无阻塞;完整测试当前被远程 MySQL `43.128.3.240:3306` 连接稳定性阻塞,后续建议数据库连通稳定后重跑一次 `py -3.12 manage.py test --noinput --keepdb --verbosity 2`。 - 阻塞:T-303 功能与相关测试无阻塞;完整测试当前被远程 MySQL `43.128.3.240:3306` 连接稳定性阻塞,后续建议数据库连通稳定后重跑一次 `py -3.12 manage.py test --noinput --keepdb --verbosity 2`。
- 决策:余额接口返回 `UserWallet.points_balance` 作为对外余额;测试用流水累加值校验账务场景一致性。接口只读,不在 API 层创建或修改钱包。 - 决策:余额接口返回 `UserWallet.points_balance` 作为对外余额;测试用流水累加值校验账务场景一致性。接口只读,不在 API 层创建或修改钱包。
- 下一步:领取 T-304 充值回调(微信/支付宝验签 + 幂等入账)。 - 下一步:领取 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)。