From c3977407486483c3287ec6ba94f56fbb1829df39 Mon Sep 17 00:00:00 2001 From: TradeMate Dev Date: Wed, 20 May 2026 18:30:12 +0800 Subject: [PATCH] feat: WeChat Pay integration, translation quota management, login UX fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .gitignore | 2 + backend/.env.example | 5 + ...3a81b22bd80_add_translation_quota_model.py | 37 ++++ backend/app/ai/providers/__init__.py | 3 +- backend/app/ai/providers/alibaba.py | 98 ++++++++++ backend/app/ai/router.py | 12 +- backend/app/api/v1/admin.py | 39 ++++ backend/app/api/v1/payment.py | 37 +++- backend/app/config.py | 12 +- backend/app/models/__init__.py | 2 + backend/app/models/translation_quota.py | 18 ++ backend/app/services/payment.py | 76 +++++++- backend/app/services/translation_quota.py | 113 +++++++++++ backend/app/services/wechat_pay.py | 181 ++++++++++++++++++ backend/certs/apiclient_cert.pem | 25 +++ backend/certs/pub_key.pem | 9 + uni-app/src/pages/admin/admin.vue | 120 ++++++++++-- uni-app/src/pages/index/index.vue | 4 +- uni-app/src/pages/login/login.vue | 13 +- uni-app/src/pages/profile/profile.vue | 15 +- uni-app/src/pages/upgrade/upgrade.vue | 34 +++- uni-app/src/utils/api.js | 8 +- 22 files changed, 828 insertions(+), 35 deletions(-) create mode 100644 backend/alembic/versions/93a81b22bd80_add_translation_quota_model.py create mode 100644 backend/app/ai/providers/alibaba.py create mode 100644 backend/app/models/translation_quota.py create mode 100644 backend/app/services/translation_quota.py create mode 100644 backend/app/services/wechat_pay.py create mode 100755 backend/certs/apiclient_cert.pem create mode 100755 backend/certs/pub_key.pem diff --git a/.gitignore b/.gitignore index db040a0..0dd45e2 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ env/ venv/ ENV/ *.egg-info/ +.coverage dist/ build/ .eggs/ @@ -22,6 +23,7 @@ build/ .env .env.local .env.*.local +backend/certs/apiclient_key.pem # Logs *.log diff --git a/backend/.env.example b/backend/.env.example index 4b53392..db73853 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -32,6 +32,11 @@ WHATSAPP_WEBHOOK_VERIFY_TOKEN= # 微信小程序 WECHAT_APP_ID= WECHAT_APP_SECRET= +WECHAT_PAY_MCH_ID= +WECHAT_PAY_API_KEY= +WECHAT_PAY_SERIAL_NO= +WECHAT_PAY_CERT_DIR=./certs +WECHAT_PAY_NOTIFY_URL=https://your-domain.com/api/v1/payment/notify # 汇率 API(免费层即可) EXCHANGE_RATE_API_KEY= diff --git a/backend/alembic/versions/93a81b22bd80_add_translation_quota_model.py b/backend/alembic/versions/93a81b22bd80_add_translation_quota_model.py new file mode 100644 index 0000000..2f54623 --- /dev/null +++ b/backend/alembic/versions/93a81b22bd80_add_translation_quota_model.py @@ -0,0 +1,37 @@ +"""add translation_quota model + +Revision ID: 93a81b22bd80 +Revises: eee1d35c9b88 +Create Date: 2026-05-20 15:43:47.299785 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = '93a81b22bd80' +down_revision: Union[str, None] = 'eee1d35c9b88' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table('translation_quotas', + sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('version', sa.String(length=50), nullable=False), + sa.Column('monthly_limit', sa.Integer(), nullable=False), + sa.Column('used_chars', sa.Integer(), nullable=False), + sa.Column('current_month', sa.String(length=7), nullable=False), + sa.Column('enabled', sa.Boolean(), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_translation_quotas_version'), 'translation_quotas', ['version'], unique=True) + + +def downgrade() -> None: + op.drop_index(op.f('ix_translation_quotas_version'), table_name='translation_quotas') + op.drop_table('translation_quotas') diff --git a/backend/app/ai/providers/__init__.py b/backend/app/ai/providers/__init__.py index 0baba13..83efbc9 100644 --- a/backend/app/ai/providers/__init__.py +++ b/backend/app/ai/providers/__init__.py @@ -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"] diff --git a/backend/app/ai/providers/alibaba.py b/backend/app/ai/providers/alibaba.py new file mode 100644 index 0000000..01a3be1 --- /dev/null +++ b/backend/app/ai/providers/alibaba.py @@ -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 diff --git a/backend/app/ai/router.py b/backend/app/ai/router.py index 1769f78..8cc66db 100644 --- a/backend/app/ai/router.py +++ b/backend/app/ai/router.py @@ -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) diff --git a/backend/app/api/v1/admin.py b/backend/app/api/v1/admin.py index 16018b1..e1f38e6 100644 --- a/backend/app/api/v1/admin.py +++ b/backend/app/api/v1/admin.py @@ -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 diff --git a/backend/app/api/v1/payment.py b/backend/app/api/v1/payment.py index b0dc63e..e6ca093 100644 --- a/backend/app/api/v1/payment.py +++ b/backend/app/api/v1/payment.py @@ -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"} \ No newline at end of file + 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"} diff --git a/backend/app/config.py b/backend/app/config.py index 045ffc2..0c7f5b7 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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"]}, diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index c96fddc..fbcc1e8 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/translation_quota.py b/backend/app/models/translation_quota.py new file mode 100644 index 0000000..442609e --- /dev/null +++ b/backend/app/models/translation_quota.py @@ -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) diff --git a/backend/app/services/payment.py b/backend/app/services/payment.py index be34d46..3ae1abf 100644 --- a/backend/app/services/payment.py +++ b/backend/app/services/payment.py @@ -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 \ No newline at end of file +payment_service = PaymentService diff --git a/backend/app/services/translation_quota.py b/backend/app/services/translation_quota.py new file mode 100644 index 0000000..f173847 --- /dev/null +++ b/backend/app/services/translation_quota.py @@ -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, + } diff --git a/backend/app/services/wechat_pay.py b/backend/app/services/wechat_pay.py new file mode 100644 index 0000000..3fe0955 --- /dev/null +++ b/backend/app/services/wechat_pay.py @@ -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") diff --git a/backend/certs/apiclient_cert.pem b/backend/certs/apiclient_cert.pem new file mode 100755 index 0000000..388b89b --- /dev/null +++ b/backend/certs/apiclient_cert.pem @@ -0,0 +1,25 @@ +-----BEGIN CERTIFICATE----- +MIIENDCCAxygAwIBAgIUYUzuDMy950xrnuZnUV3V4gBjtKQwDQYJKoZIhvcNAQEL +BQAwXjELMAkGA1UEBhMCQ04xEzARBgNVBAoTClRlbnBheS5jb20xHTAbBgNVBAsT +FFRlbnBheS5jb20gQ0EgQ2VudGVyMRswGQYDVQQDExJUZW5wYXkuY29tIFJvb3Qg +Q0EwHhcNMjYwMzI0MDYzNTUxWhcNMzEwMzIzMDYzNTUxWjCBjTETMBEGA1UEAwwK +MTEwODk0NTk5MzEbMBkGA1UECgwS5b6u5L+h5ZWG5oi357O757ufMTkwNwYDVQQL +DDDljJfkuqzlrofkuYvnhLbnp5HmioDkuK3lv4PvvIjkuKrkvZPlt6XllYbmiLfv +vIkxCzAJBgNVBAYTAkNOMREwDwYDVQQHDAhTaGVuWmhlbjCCASIwDQYJKoZIhvcN +AQEBBQADggEPADCCAQoCggEBANKG2DnPUqF4kP/IErd0z7MejTXhfRT/9shGgNUm +SFQmK5Vb7owQyFORj5Y22r5Xa+g7t0Wjwmc8jjIFiIIUickf9IHedDzpclK6nPcf +whgEQ5YolA0yRSujlBdpDKxiZED/OdiF+oRNW3Orl4dC2eb/+yCvjr9IgXRwnn4l +ODP8DQ4/xW6JnQhyy+yQ4vydhkK1G1aaqS3kQAyQopgwcGif1e9Wo6JI1c+1MF98 +4KNLEVABg9sH2yRMYSsspyvJfdu+FA9oZT5LXGjSrdyaq7t8cESqRzhQMLvRcZ3o +QSbY9EE9Xy0dt9URS7ZdK53MwoRpnQsUSwpWT8r07f9sM1ECAwEAAaOBuTCBtjAJ +BgNVHRMEAjAAMAsGA1UdDwQEAwID+DCBmwYDVR0fBIGTMIGQMIGNoIGKoIGHhoGE +aHR0cDovL2V2Y2EuaXRydXMuY29tLmNuL3B1YmxpYy9pdHJ1c2NybD9DQT0xQkQ0 +MjIwRTUwREJDMDRCMDZBRDM5NzU0OTg0NkMwMUMzRThFQkQyJnNnPUhBQ0M0NzFC +NjU0MjJFMTJCMjdBOUQzM0E4N0FEMUNERjU5MjZFMTQwMzcxMA0GCSqGSIb3DQEB +CwUAA4IBAQCXQjHcCkJh/Wr3qvi5q3yBXA/zUchZOtBh220dGdEvR+L+9s9i7aGf +legNT0+E1AakD/W6TC25pL7oK3QmyjdW+JGdNtfVh5v+uUNu333mRPre5Bj94cfJ +9W7EHgfzEi3MJ3HXBclh7pCyxl6FRXXXj7STeBetVH0njvYRV6nOl7wobW77lE5N +JkUYPVPPd5pbS3BSpykBS4zxtaBK2MtzhhELonAbzib2z0edlwlGoJD0BFAr7BGT +00mM95Hz5I3W4UgHvv+mDlOMjBAwCtaDSSCoJDhiaxs/WEru4jSqY4aiKEsVrtAQ +zcR9dNa21f+uzO6/oziswYstXOkzeVoc +-----END CERTIFICATE----- diff --git a/backend/certs/pub_key.pem b/backend/certs/pub_key.pem new file mode 100755 index 0000000..0c84ba6 --- /dev/null +++ b/backend/certs/pub_key.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzK+YibPERKTi8CaO+Yq +meTdJmaTqHTS/XBbnRmZgQmudwPIfybp1E+wPKixcBrw/qaj6Ewfoo+6eh++aUVw +ImiQcmYrF+4IgpaTJMfYwYgyeig8XlGlwNNngWPZ7g+7Q7FZUjMYJ4ITq2IHCyWR +IhQfPY9auZdBAndtcX9flDdUAzur2EDYVNYMlFMxt2wGnBGfXitsZefWdvRq5IA1 +N8zCxfCRvN9GFBjtjQXKeUmPwxLmcqKuA3fRz/THjq0oeggQK9PNQmtZgUD8nKE7 +cils+CFpkLK+a34iFIA2QHygL6itReyV7O47MD4gYYW+aCmp/L0FUcdISJtaCZ2T +9QIDAQAB +-----END PUBLIC KEY----- diff --git a/uni-app/src/pages/admin/admin.vue b/uni-app/src/pages/admin/admin.vue index 993dc4f..10ba9f9 100644 --- a/uni-app/src/pages/admin/admin.vue +++ b/uni-app/src/pages/admin/admin.vue @@ -1,17 +1,13 @@