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
This commit is contained in:
TradeMate Dev
2026-05-20 18:30:12 +08:00
parent a60aac4638
commit c397740748
22 changed files with 828 additions and 35 deletions
+69 -7
View File
@@ -1,7 +1,5 @@
import hmac
import hashlib
import json
import logging
import hashlib
from typing import Optional, Dict, Any
from datetime import datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession
@@ -9,6 +7,7 @@ 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__)
@@ -18,10 +17,22 @@ PLANS = {
"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 {
@@ -85,7 +96,8 @@ class PaymentService:
"auto_renew": sub.auto_renew if sub else False,
}
async def create_order(self, user_id: str, plan: str) -> Dict[str, Any]:
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}")
@@ -98,8 +110,13 @@ class PaymentService:
await self.db.flush()
return {"status": "ok", "plan": plan, "amount": 0}
from app.config import settings
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,
@@ -111,6 +128,51 @@ class PaymentService:
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())),
@@ -121,13 +183,13 @@ class PaymentService:
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,
}
@@ -155,4 +217,4 @@ class PaymentService:
return True
payment_service = PaymentService
payment_service = PaymentService
+113
View File
@@ -0,0 +1,113 @@
from typing import Dict, Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.translation_quota import TranslationQuota
from datetime import datetime
import logging
logger = logging.getLogger(__name__)
class TranslationQuotaService:
def __init__(self, db: AsyncSession):
self.db = db
async def _get_or_create(self, version: str) -> TranslationQuota:
result = await self.db.execute(
select(TranslationQuota).where(TranslationQuota.version == version)
)
quota = result.scalar_one_or_none()
if not quota:
now = datetime.utcnow()
quota = TranslationQuota(
version=version,
monthly_limit=1000000,
used_chars=0,
current_month=now.strftime("%Y-%m"),
enabled=True,
description=f"阿里云翻译{version}",
)
self.db.add(quota)
await self.db.flush()
return quota
async def check_quota(self, version: str) -> bool:
quota = await self._get_or_create(version)
now = datetime.utcnow()
current = now.strftime("%Y-%m")
if quota.current_month != current:
quota.current_month = current
quota.used_chars = 0
await self.db.flush()
return quota.enabled and quota.used_chars < quota.monthly_limit
async def consume(self, version: str, chars: int):
quota = await self._get_or_create(version)
if not quota.enabled:
raise ValueError(f"Translation API [{version}] is disabled")
now = datetime.utcnow()
current = now.strftime("%Y-%m")
if quota.current_month != current:
quota.current_month = current
quota.used_chars = 0
quota.used_chars += chars
await self.db.flush()
remaining = max(0, quota.monthly_limit - quota.used_chars)
logger.info(f"Quota [{version}] consumed {chars} chars, remaining {remaining} this month")
return remaining
async def get_all_quotas(self) -> list:
default_versions = ["ecommerce", "general"]
for v in default_versions:
await self._get_or_create(v)
result = await self.db.execute(select(TranslationQuota).order_by(TranslationQuota.version))
quotas = result.scalars().all()
rows = []
for q in quotas:
now = datetime.utcnow()
current = now.strftime("%Y-%m")
if q.current_month != current:
q.current_month = current
q.used_chars = 0
await self.db.flush()
rows.append({
"version": q.version,
"monthly_limit": q.monthly_limit,
"used_chars": q.used_chars,
"current_month": q.current_month,
"enabled": q.enabled,
"description": q.description,
})
return rows
async def update_quota(self, version: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
quota = await self._get_or_create(version)
if "monthly_limit" in data:
quota.monthly_limit = int(data["monthly_limit"])
if "enabled" in data:
quota.enabled = bool(data["enabled"])
if "description" in data:
quota.description = str(data["description"])
await self.db.flush()
return {
"version": quota.version,
"monthly_limit": quota.monthly_limit,
"used_chars": quota.used_chars,
"current_month": quota.current_month,
"enabled": quota.enabled,
"description": quota.description,
}
async def reset_usage(self, version: str) -> Optional[Dict[str, Any]]:
quota = await self._get_or_create(version)
quota.used_chars = 0
quota.current_month = datetime.utcnow().strftime("%Y-%m")
await self.db.flush()
return {
"version": quota.version,
"monthly_limit": quota.monthly_limit,
"used_chars": quota.used_chars,
"current_month": quota.current_month,
"enabled": quota.enabled,
}
+181
View File
@@ -0,0 +1,181 @@
import json
import time
import logging
import uuid
import base64
from typing import Optional, Dict, Any
from pathlib import Path
import httpx
from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.backends import default_backend
from app.config import settings
logger = logging.getLogger(__name__)
class WeChatPayService:
def __init__(self):
self.mch_id = settings.WECHAT_PAY_MCH_ID
self.api_key = settings.WECHAT_PAY_API_KEY
self.serial_no = settings.WECHAT_PAY_SERIAL_NO
self.app_id = settings.WECHAT_APP_ID
self.api_base = settings.WECHAT_PAY_API_BASE
self.notify_url = settings.WECHAT_PAY_NOTIFY_URL
self._private_key = None
def _load_private_key(self) -> bytes:
if self._private_key:
return self._private_key
cert_dir = Path(settings.WECHAT_PAY_CERT_DIR)
key_path = cert_dir / "apiclient_key.pem"
if not key_path.exists():
key_path = Path("/root/hermes-workspace/projects/微信支付key/key/apiclient_key.pem")
with open(key_path, "rb") as f:
self._private_key = f.read()
return self._private_key
def _sign_rsa(self, sign_str: str) -> str:
private_key_data = self._load_private_key()
key = serialization.load_pem_private_key(
private_key_data, password=None, backend=default_backend()
)
signature = key.sign(
sign_str.encode("utf-8"),
padding.PKCS1v15(),
hashes.SHA256(),
)
return base64.b64encode(signature).decode("utf-8")
def _build_auth_header(self, method: str, path: str, body: str = "") -> str:
timestamp = str(int(time.time()))
nonce = uuid.uuid4().hex[:16]
sign_str = f"{method}\n{path}\n{timestamp}\n{nonce}\n{body}\n"
signature = self._sign_rsa(sign_str)
return (
f'WECHATPAY2-SHA256-RSA2048 '
f'mchid="{self.mch_id}",'
f'nonce_str="{nonce}",'
f'timestamp="{timestamp}",'
f'serial_no="{self.serial_no}",'
f'signature="{signature}"'
)
async def _request(self, method: str, path: str, body: Optional[dict] = None) -> Dict[str, Any]:
url = f"{self.api_base}{path}"
body_str = json.dumps(body, ensure_ascii=False, separators=(",", ":")) if body else ""
auth = self._build_auth_header(method, path, body_str)
async with httpx.AsyncClient() as client:
resp = await client.request(
method=method,
url=url,
content=body_str if body else None,
headers={
"Authorization": auth,
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "TradeMate/1.0",
},
)
data = resp.json() if resp.text else {}
if resp.status_code >= 400:
logger.error(f"WeChat Pay API error: {resp.status_code} {data}")
raise Exception(f"WeChat Pay error: {data.get('message', resp.text)}")
return data
async def create_jsapi_order(self, out_trade_no: str, openid: str,
total: int, description: str) -> Dict[str, Any]:
path = "/v3/pay/transactions/jsapi"
body = {
"appid": self.app_id,
"mchid": self.mch_id,
"description": description,
"out_trade_no": out_trade_no,
"notify_url": self.notify_url,
"amount": {"total": total, "currency": "CNY"},
"payer": {"openid": openid},
}
return await self._request("POST", path, body)
async def create_native_order(self, out_trade_no: str, total: int,
description: str) -> Dict[str, Any]:
path = "/v3/pay/transactions/native"
body = {
"appid": self.app_id,
"mchid": self.mch_id,
"description": description,
"out_trade_no": out_trade_no,
"notify_url": self.notify_url,
"amount": {"total": total, "currency": "CNY"},
}
return await self._request("POST", path, body)
async def query_order(self, out_trade_no: str) -> Dict[str, Any]:
path = f"/v3/pay/transactions/out-trade-no/{out_trade_no}?mchid={self.mch_id}"
return await self._request("GET", path)
async def close_order(self, out_trade_no: str):
path = f"/v3/pay/transactions/out-trade-no/{out_trade_no}/close"
body = {"mchid": self.mch_id}
await self._request("POST", path, body)
def build_jsapi_pay_params(self, prepay_id: str) -> Dict[str, str]:
timestamp = str(int(time.time()))
nonce = uuid.uuid4().hex[:16]
package = f"prepay_id={prepay_id}"
sign_str = f"{self.app_id}\n{timestamp}\n{nonce}\n{package}\n"
pay_sign = self._sign_rsa(sign_str)
return {
"appId": self.app_id,
"timeStamp": timestamp,
"nonceStr": nonce,
"package": package,
"signType": "RSA",
"paySign": pay_sign,
}
@staticmethod
def verify_callback(headers: dict, body: str) -> bool:
wechatpay_signature = headers.get("wechatpay-signature", "")
wechatpay_timestamp = headers.get("wechatpay-timestamp", "")
wechatpay_nonce = headers.get("wechatpay-nonce", "")
wechatpay_serial = headers.get("wechatpay-serial", "")
if not all([wechatpay_signature, wechatpay_timestamp, wechatpay_nonce, wechatpay_serial]):
logger.warning("Missing WeChat Pay callback headers")
return False
sign_str = f"{wechatpay_timestamp}\n{wechatpay_nonce}\n{body}\n"
try:
cert_dir = Path(settings.WECHAT_PAY_CERT_DIR)
cert_path = cert_dir / "pub_key.pem"
if not cert_path.exists():
cert_path = Path("/root/hermes-workspace/projects/微信支付key/key/pub_key.pem")
with open(cert_path, "rb") as f:
cert_data = f.read()
public_key = serialization.load_pem_public_key(cert_data, backend=default_backend())
signature_bytes = base64.b64decode(wechatpay_signature)
public_key.verify(
signature_bytes,
sign_str.encode("utf-8"),
padding.PKCS1v15(),
hashes.SHA256(),
)
return True
except Exception as e:
logger.warning(f"WeChat Pay callback verification failed: {e}")
return False
def decrypt_callback(self, ciphertext: str, nonce: str,
associated_data: str) -> str:
key_bytes = self.api_key.encode("utf-8")
nonce_bytes = base64.b64decode(nonce) if nonce else b""
associated_bytes = associated_data.encode("utf-8")
ciphertext_bytes = base64.b64decode(ciphertext)
aesgcm = AESGCM(key_bytes)
plaintext = aesgcm.decrypt(nonce_bytes, ciphertext_bytes, associated_bytes)
return plaintext.decode("utf-8")