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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user