feat: credit-based billing system

- New DB models: credit_packages, subscription_plans, user_credits, credit_consumptions, credit_purchases
- CreditService: balance, deduct, add_credits, grant_free_trial, history
- User API: /api/v1/credits/* (balance/history/packages/purchase/subscribe)
- Admin API: /api/v1/admin/credit-* (CRUD packages/plans, user credits, consumptions)
- PaymentService.create_credit_order + handle_callback for credit purchases
- Credit deduction on: discovery, translate, marketing, ai_chat, followup
- Free trial 30 credits on registration
- Documentation: docs/CREDIT_SYSTEM.md
This commit is contained in:
TradeMate Dev
2026-06-12 10:39:45 +08:00
parent 5d895ae12c
commit 2a107a42f3
21 changed files with 1528 additions and 33 deletions
+74 -19
View File
@@ -143,6 +143,39 @@ class PaymentService:
**gw_result,
}
async def create_credit_order(self, user_id: str, amount: float,
description: str, pay_type: str = "alipay",
metadata: dict = None) -> Dict[str, Any]:
order_no = gen_order_no(user_id)
gw = get_gateway(pay_type)
meta_remark = {"uid": user_id, "oid": order_no, "type": "credit_purchase"}
if metadata:
meta_remark.update(metadata)
gw_result = await gw.create_order(order_no, int(amount * 100),
description, pay_type=pay_type,
remark=json.dumps(meta_remark, separators=(",", ":")))
txn = PaymentTransaction(
user_id=user_id, order_no=order_no, plan="credit_purchase",
amount=amount, gateway="unified", pay_type=pay_type,
status="pending", description=json.dumps(metadata or {}, ensure_ascii=False),
gateway_order_no=gw_result.get("gateway_order_id", ""),
)
self.db.add(txn)
await self.db.flush()
return {
"status": "pending",
"order_id": order_no,
"amount": amount,
"currency": "CNY",
"gateway": "unified",
"pay_type": pay_type,
"metadata": metadata or {},
**gw_result,
}
async def handle_callback(self, order_no: str, gateway_order_id: str,
gateway_order_no: str, success: bool,
amount: float = 0, notify_raw: str = "") -> bool:
@@ -162,30 +195,52 @@ class PaymentService:
txn.paid_at = datetime.utcnow()
txn.notify_raw = notify_raw
sub_result = await self.db.execute(
select(Subscription).where(Subscription.payment_id == order_no)
)
sub = sub_result.scalar_one_or_none()
if sub:
sub.status = "active"
sub.started_at = datetime.utcnow()
if PLANS[sub.plan]["duration_days"]:
sub.expires_at = datetime.utcnow() + timedelta(days=PLANS[sub.plan]["duration_days"])
if txn.plan == "credit_purchase":
from app.services.credit import CreditService
credit_svc = CreditService(self.db)
user_result = await self.db.execute(select(User).where(User.id == txn.user_id))
user = user_result.scalar_one_or_none()
if user:
user.tier = txn.plan
if txn.description:
try:
meta = json.loads(txn.description)
credits = meta.get("credits", 0)
except (json.JSONDecodeError, TypeError):
credits = 0
else:
credits = 0
if not credits:
credits = max(1, int(txn.amount / 0.79))
await credit_svc.add_credits(
txn.user_id, credits, "package",
f"支付完成 - 获得 {credits} 次信用额度"
)
else:
sub_result = await self.db.execute(
select(Subscription).where(Subscription.payment_id == order_no)
)
sub = sub_result.scalar_one_or_none()
if sub:
sub.status = "active"
sub.started_at = datetime.utcnow()
if PLANS[sub.plan]["duration_days"]:
sub.expires_at = datetime.utcnow() + timedelta(days=PLANS[sub.plan]["duration_days"])
user_result = await self.db.execute(select(User).where(User.id == txn.user_id))
user = user_result.scalar_one_or_none()
if user:
user.tier = txn.plan
else:
txn.status = "failed"
txn.notify_raw = notify_raw
sub_result = await self.db.execute(
select(Subscription).where(Subscription.payment_id == order_no)
)
sub = sub_result.scalar_one_or_none()
if sub:
sub.status = "failed"
if txn.plan != "credit_purchase":
sub_result = await self.db.execute(
select(Subscription).where(Subscription.payment_id == order_no)
)
sub = sub_result.scalar_one_or_none()
if sub:
sub.status = "failed"
await self.db.flush()
return True