Files
trade-assistant/backend/app/services/payment.py
T
TradeMate Dev c397740748 feat: WeChat Pay integration, translation quota management, login UX fixes
- WeChat Pay APIv3 integration (JSAPI + Native) with cert-based auth
- TranslationQuota model + admin management UI (配额 tab)
- Alibaba MT provider now checks quota before translation
- Fix: admin tabs scrollable on mobile, remove header-card
- Fix: profile/login navigation - logout stays on profile, login returns to profile
- Fix: login form now visible by default (no extra click to show)
- Fix: home page translate link uses navigateTo (was switchTab to non-tabBar page)
- Add .coverage and apiclient_key.pem to gitignore
2026-05-20 18:30:12 +08:00

221 lines
7.7 KiB
Python

import logging
import hashlib
from typing import Optional, Dict, Any
from datetime import datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.subscription import Subscription
from app.models.user import User
from app.config import settings
from app.services.wechat_pay import WeChatPayService
logger = logging.getLogger(__name__)
PLANS = {
"free": {"price": 0, "duration_days": None},
"pro": {"price": 99, "duration_days": 30},
"enterprise": {"price": 399, "duration_days": 30},
}
PLAN_DESCRIPTIONS = {
"pro": "TradeMate Pro 版会员",
"enterprise": "TradeMate 企业版会员",
}
class PaymentService:
def __init__(self, db: AsyncSession):
self.db = db
self._wxpay = None
@property
def wxpay(self) -> Optional[WeChatPayService]:
if self._wxpay is None and settings.WECHAT_PAY_MCH_ID:
self._wxpay = WeChatPayService()
return self._wxpay
async def get_plans(self) -> Dict[str, Any]:
return {
"plans": [
{
"id": "free",
"name": "免费版",
"price": 0,
"features": [
"1 个产品",
"20 次翻译/天",
"5 个客户",
"基础回复建议",
],
},
{
"id": "pro",
"name": "Pro 版",
"price": 99,
"features": [
"10 个产品",
"无限翻译",
"50 个客户",
"跟进提醒",
"报价单生成",
],
},
{
"id": "enterprise",
"name": "企业版",
"price": 399,
"features": [
"无限产品",
"多人协作",
"品牌报价单",
"专属语料训练",
"API 接入",
],
},
]
}
async def get_current_subscription(self, user_id: str) -> Dict[str, Any]:
result = await self.db.execute(
select(Subscription).where(
Subscription.user_id == user_id,
Subscription.status == "active",
).order_by(Subscription.created_at.desc()).limit(1)
)
sub = result.scalar_one_or_none()
result = await self.db.execute(
select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none()
return {
"plan": user.tier if user else "free",
"status": sub.status if sub else "active",
"expires_at": sub.expires_at.isoformat() if sub and sub.expires_at else None,
"auto_renew": sub.auto_renew if sub else False,
}
async def create_order(self, user_id: str, plan: str,
pay_type: str = "jsapi") -> Dict[str, Any]:
if plan not in PLANS:
raise ValueError(f"Invalid plan: {plan}")
plan_info = PLANS[plan]
if plan_info["price"] == 0:
result = await self.db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user:
user.tier = plan
await self.db.flush()
return {"status": "ok", "plan": plan, "amount": 0}
result = await self.db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise ValueError("User not found")
order_id = f"ORD{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{user_id[-6:]}"
description = PLAN_DESCRIPTIONS.get(plan, f"TradeMate {plan}")
sub = Subscription(
user_id=user_id,
plan=plan,
status="pending",
amount=plan_info["price"],
payment_id=order_id,
)
self.db.add(sub)
await self.db.flush()
wxpay_available = self.wxpay is not None and settings.WECHAT_PAY_NOTIFY_URL not in (
"", "https://example.com/api/v1/payment/notify"
)
if wxpay_available:
try:
if pay_type == "jsapi":
openid = user.wechat_openid
if not openid:
raise ValueError("用户未绑定微信,请在微信小程序中登录后支付")
wx_result = await self.wxpay.create_jsapi_order(
order_id, openid, int(plan_info["price"] * 100), description
)
prepay_id = wx_result.get("prepay_id", "")
pay_params = self.wxpay.build_jsapi_pay_params(prepay_id)
return {
"status": "pending",
"order_id": order_id,
"plan": plan,
"amount": plan_info["price"],
"currency": "CNY",
"pay_type": "jsapi",
"pay_params": pay_params,
}
elif pay_type == "native":
wx_result = await self.wxpay.create_native_order(
order_id, int(plan_info["price"] * 100), description
)
code_url = wx_result.get("code_url", "")
return {
"status": "pending",
"order_id": order_id,
"plan": plan,
"amount": plan_info["price"],
"currency": "CNY",
"pay_type": "native",
"code_url": code_url,
}
except Exception as e:
logger.error(f"WeChat Pay order failed: {e}")
raise ValueError(f"支付创建失败: {str(e)}")
# 开发环境回退:生成模拟支付参数
pay_params = {
"appId": settings.WECHAT_APP_ID or "",
"timeStamp": str(int(datetime.utcnow().timestamp())),
"nonceStr": hashlib.md5(order_id.encode()).hexdigest()[:16],
"package": f"prepay_id={order_id}",
"signType": "MD5",
}
sign_str = "&".join(f"{k}={v}" for k, v in sorted(pay_params.items()))
sign_str += f"&key={settings.SECRET_KEY}"
pay_params["paySign"] = hashlib.md5(sign_str.encode()).hexdigest().upper()
return {
"status": "pending",
"order_id": order_id,
"plan": plan,
"amount": plan_info["price"],
"currency": "CNY",
"pay_type": pay_type,
"pay_params": pay_params,
}
async def handle_payment_callback(self, payment_id: str, success: bool) -> bool:
result = await self.db.execute(
select(Subscription).where(Subscription.payment_id == payment_id)
)
sub = result.scalar_one_or_none()
if not sub:
return False
if success:
sub.status = "active"
sub.started_at = datetime.utcnow()
sub.expires_at = datetime.utcnow() + timedelta(days=PLANS[sub.plan]["duration_days"])
user_result = await self.db.execute(select(User).where(User.id == sub.user_id))
user = user_result.scalar_one_or_none()
if user:
user.tier = sub.plan
else:
sub.status = "failed"
await self.db.flush()
return True
payment_service = PaymentService