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
+2 -1
View File
@@ -6,5 +6,6 @@ from .spark import SparkProvider
from .sensenova import SensenovaProvider
from .opencode_go import OpencodeGoProvider
from .nvidia import NvidiaProvider
from .alibaba import AlibabaMTProvider
__all__ = ["OpenAIProvider", "ClaudeProvider", "DeepLProvider", "LocalProvider", "SparkProvider", "SensenovaProvider", "OpencodeGoProvider", "NvidiaProvider"]
__all__ = ["OpenAIProvider", "ClaudeProvider", "DeepLProvider", "LocalProvider", "SparkProvider", "SensenovaProvider", "OpencodeGoProvider", "NvidiaProvider", "AlibabaMTProvider"]
+98
View File
@@ -0,0 +1,98 @@
from typing import Dict, Any, Optional
from aliyunsdkcore.client import AcsClient
from aliyunsdkalimt.request.v20181012 import TranslateGeneralRequest, TranslateECommerceRequest
from app.services.translation_quota import TranslationQuotaService
from app.database import AsyncSessionLocal
import asyncio
import json
import logging
logger = logging.getLogger(__name__)
ALIBABA_LANG_MAP = {
"zh": "zh", "en": "en", "ja": "ja", "ko": "ko",
"fr": "fr", "de": "de", "es": "es", "pt": "pt",
"ru": "ru", "ar": "ar", "th": "th", "vi": "vi",
"id": "id", "ms": "ms", "tl": "tl", "hi": "hi",
}
class AlibabaMTProvider:
def __init__(self, access_key_id: str, access_key_secret: str,
region_id: str = "cn-hangzhou"):
self.client = AcsClient(access_key_id, access_key_secret, region_id)
self._name = "alibaba-mt"
async def translate(self, text: str, source_lang: Optional[str],
target_lang: str, context: Optional[str] = None) -> Dict[str, Any]:
src = source_lang if source_lang and source_lang != "auto" else "auto"
tgt = ALIBABA_LANG_MAP.get(target_lang[:2].lower(), "en")
async with AsyncSessionLocal() as db:
quota_svc = TranslationQuotaService(db)
for version in ("ecommerce", "general"):
if not await quota_svc.check_quota(version):
logger.info(f"Quota [{version}] exhausted or disabled, trying next")
continue
result = await self._do_translate(version, text, src, tgt)
if result and result.get("translated_text"):
await quota_svc.consume(version, len(text))
await db.commit()
result["provider"] = f"{self.name}-{version}"
return result
raise Exception("Alibaba MT: both versions quota exhausted or API failed")
async def _do_translate(self, version: str, text: str, src: str,
tgt: str) -> Optional[Dict[str, Any]]:
try:
if version == "ecommerce":
req = TranslateECommerceRequest.TranslateECommerceRequest()
else:
req = TranslateGeneralRequest.TranslateGeneralRequest()
req.set_FormatType("text")
req.set_Scene(version)
req.set_SourceLanguage(src)
req.set_TargetLanguage(tgt)
req.set_SourceText(text)
loop = asyncio.get_event_loop()
body = await loop.run_in_executor(None, self.client.do_action_with_exception, req)
resp = json.loads(body)
data = resp.get("Data", {})
translated = data.get("Translated", "")
detected = data.get("DetectedLanguage", src)
if translated:
logger.info(f"Alibaba MT [{version}] ok: {text[:20]}... -> {translated[:20]}...")
return {
"translated_text": translated,
"provider": f"{self.name}-{version}",
"detected_source_lang": detected,
"char_count": len(text),
"cost": 0,
}
except Exception as e:
logger.warning(f"Alibaba MT [{version}] failed: {e}")
return None
async def reply(self, *args, **kwargs) -> Dict[str, Any]:
raise NotImplementedError("Alibaba MT does not support reply generation")
async def generate_marketing(self, *args, **kwargs) -> Dict[str, Any]:
raise NotImplementedError("Alibaba MT does not support marketing generation")
async def extract_info(self, text: str, schema: Dict[str, Any]) -> Dict[str, Any]:
raise NotImplementedError("Alibaba MT does not support info extraction")
@property
def name(self) -> str:
return self._name
@property
def cost_per_1k_tokens(self) -> float:
return 0
+11 -1
View File
@@ -1,6 +1,6 @@
from typing import Dict, Any, Optional, List
from app.ai.base import AIProvider
from app.ai.providers import OpenAIProvider, ClaudeProvider, DeepLProvider, LocalProvider, SparkProvider, SensenovaProvider, OpencodeGoProvider, NvidiaProvider
from app.ai.providers import OpenAIProvider, ClaudeProvider, DeepLProvider, LocalProvider, SparkProvider, SensenovaProvider, OpencodeGoProvider, NvidiaProvider, AlibabaMTProvider
from app.config import settings
from app.ai.trade_corpus import TradeCorpus
import logging
@@ -81,6 +81,16 @@ class AIRouter:
except Exception as e:
logger.warning(f"Spark init failed: {e}")
if settings.ALIBABA_ACCESS_KEY_ID and settings.ALIBABA_ACCESS_KEY_SECRET:
try:
self.providers["alibaba-mt"] = AlibabaMTProvider(
access_key_id=settings.ALIBABA_ACCESS_KEY_ID,
access_key_secret=settings.ALIBABA_ACCESS_KEY_SECRET,
)
logger.info("Alibaba MT provider ready")
except Exception as e:
logger.warning(f"Alibaba MT init failed: {e}")
if settings.LOCAL_MODEL_ENABLED:
try:
self.providers["local"] = LocalProvider(model_url=settings.LOCAL_MODEL_URL)
+39
View File
@@ -5,6 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services.admin import AdminService
from app.services.translation_quota import TranslationQuotaService
from app.api.v1.deps import get_current_user
router = APIRouter()
@@ -173,3 +174,41 @@ async def system_health(
):
service = AdminService(db)
return await service.get_system_health()
@router.get("/translation-quotas")
async def list_translation_quotas(
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
service = TranslationQuotaService(db)
return await service.get_all_quotas()
@router.put("/translation-quotas/{version}")
async def update_translation_quota(
version: str,
data: dict,
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
allowed = {"monthly_limit", "enabled", "description"}
filtered = {k: v for k, v in data.items() if k in allowed}
service = TranslationQuotaService(db)
result = await service.update_quota(version, filtered)
if not result:
raise HTTPException(status_code=404, detail="Quota not found")
return result
@router.post("/translation-quotas/{version}/reset")
async def reset_translation_quota(
version: str,
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
service = TranslationQuotaService(db)
result = await service.reset_usage(version)
if not result:
raise HTTPException(status_code=404, detail="Quota not found")
return result
+34 -3
View File
@@ -1,8 +1,10 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from typing import Optional
from app.database import get_db
from app.services.payment import PaymentService
from app.services.wechat_pay import WeChatPayService
from app.api.v1.deps import get_current_user_id
router = APIRouter()
@@ -10,6 +12,7 @@ router = APIRouter()
class CreateOrderRequest(BaseModel):
plan: str
pay_type: str = "jsapi"
class PaymentCallbackRequest(BaseModel):
@@ -40,7 +43,7 @@ async def create_order(
):
svc = PaymentService(db)
try:
return await svc.create_order(user_id, data.plan)
return await svc.create_order(user_id, data.plan, data.pay_type)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@@ -54,4 +57,32 @@ async def payment_callback(
success = await svc.handle_payment_callback(data.payment_id, data.success)
if not success:
raise HTTPException(status_code=404, detail="Order not found")
return {"status": "ok"}
return {"status": "ok"}
@router.post("/notify")
async def wechat_pay_notify(request: Request, db: AsyncSession = Depends(get_db)):
body = await request.body()
body_str = body.decode("utf-8")
headers = dict(request.headers)
wxpay = WeChatPayService()
if not wxpay.verify_callback(headers, body_str):
raise HTTPException(status_code=401, detail="签名验证失败")
import json
data = json.loads(body_str)
resource = data.get("resource", {})
ciphertext = resource.get("ciphertext", "")
nonce = resource.get("nonce", "")
associated_data = resource.get("associated_data", "")
plaintext = wxpay.decrypt_callback(ciphertext, nonce, associated_data)
pay_data = json.loads(plaintext)
out_trade_no = pay_data.get("out_trade_no", "")
trade_state = pay_data.get("trade_state", "")
success = trade_state == "SUCCESS"
svc = PaymentService(db)
await svc.handle_payment_callback(out_trade_no, success)
return {"code": "SUCCESS", "message": "OK"}
+11 -1
View File
@@ -55,10 +55,20 @@ class Settings(BaseSettings):
WHATSAPP_PHONE_NUMBER_ID: Optional[str] = None
WHATSAPP_WEBHOOK_VERIFY_TOKEN: Optional[str] = None
ALIBABA_ACCESS_KEY_ID: Optional[str] = None
ALIBABA_ACCESS_KEY_SECRET: Optional[str] = None
WECHAT_APP_ID: Optional[str] = None
WECHAT_APP_SECRET: Optional[str] = None
WECHAT_PUSH_TEMPLATE_ID: Optional[str] = None
WECHAT_PAY_MCH_ID: Optional[str] = None
WECHAT_PAY_API_KEY: Optional[str] = None
WECHAT_PAY_SERIAL_NO: Optional[str] = None
WECHAT_PAY_CERT_DIR: str = "./certs"
WECHAT_PAY_NOTIFY_URL: str = "https://example.com/api/v1/payment/notify"
WECHAT_PAY_API_BASE: str = "https://api.mch.weixin.qq.com"
EXCHANGE_RATE_API_KEY: Optional[str] = None
UPLOAD_DIR: str = "./uploads"
@@ -71,7 +81,7 @@ class Settings(BaseSettings):
DEBUG: bool = True
AI_ROUTING: dict = {
"translate": {"primary": "opencode_go", "fallback": ["sensenova", "openai", "local"]},
"translate": {"primary": "alibaba-mt", "fallback": ["opencode_go", "sensenova", "openai", "local"]},
"reply": {"primary": "opencode_go", "fallback": ["sensenova", "anthropic", "local"]},
"marketing": {"primary": "opencode_go", "fallback": ["sensenova", "openai", "local"]},
"extract": {"primary": "opencode_go", "fallback": ["sensenova", "openai"]},
+2
View File
@@ -11,6 +11,7 @@ from .preference import PreferenceAnalysis, MarketingEffect
from .device import Device
from .followup import FollowupStrategy, FollowupLog
from .system_config import SystemConfig
from .translation_quota import TranslationQuota
__all__ = [
"User", "Product",
@@ -26,4 +27,5 @@ __all__ = [
"Device",
"FollowupStrategy", "FollowupLog",
"SystemConfig",
"TranslationQuota",
]
+18
View File
@@ -0,0 +1,18 @@
from sqlalchemy import Column, String, Boolean, Integer, DateTime, Text
from sqlalchemy.dialects.postgresql import UUID
from datetime import datetime
from app.database import Base
import uuid
class TranslationQuota(Base):
__tablename__ = "translation_quotas"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
version = Column(String(50), unique=True, nullable=False, index=True)
monthly_limit = Column(Integer, nullable=False, default=1000000)
used_chars = Column(Integer, nullable=False, default=0)
current_month = Column(String(7), nullable=False)
enabled = Column(Boolean, nullable=False, default=True)
description = Column(Text, default="")
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+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")