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
View File
@@ -8,6 +8,7 @@ env/
venv/ venv/
ENV/ ENV/
*.egg-info/ *.egg-info/
.coverage
dist/ dist/
build/ build/
.eggs/ .eggs/
@@ -22,6 +23,7 @@ build/
.env .env
.env.local .env.local
.env.*.local .env.*.local
backend/certs/apiclient_key.pem
# Logs # Logs
*.log *.log
+5
View File
@@ -32,6 +32,11 @@ WHATSAPP_WEBHOOK_VERIFY_TOKEN=
# 微信小程序 # 微信小程序
WECHAT_APP_ID= WECHAT_APP_ID=
WECHAT_APP_SECRET= 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(免费层即可) # 汇率 API(免费层即可)
EXCHANGE_RATE_API_KEY= EXCHANGE_RATE_API_KEY=
@@ -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')
+2 -1
View File
@@ -6,5 +6,6 @@ from .spark import SparkProvider
from .sensenova import SensenovaProvider from .sensenova import SensenovaProvider
from .opencode_go import OpencodeGoProvider from .opencode_go import OpencodeGoProvider
from .nvidia import NvidiaProvider 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 typing import Dict, Any, Optional, List
from app.ai.base import AIProvider 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.config import settings
from app.ai.trade_corpus import TradeCorpus from app.ai.trade_corpus import TradeCorpus
import logging import logging
@@ -81,6 +81,16 @@ class AIRouter:
except Exception as e: except Exception as e:
logger.warning(f"Spark init failed: {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: if settings.LOCAL_MODEL_ENABLED:
try: try:
self.providers["local"] = LocalProvider(model_url=settings.LOCAL_MODEL_URL) 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 sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.services.admin import AdminService from app.services.admin import AdminService
from app.services.translation_quota import TranslationQuotaService
from app.api.v1.deps import get_current_user from app.api.v1.deps import get_current_user
router = APIRouter() router = APIRouter()
@@ -173,3 +174,41 @@ async def system_health(
): ):
service = AdminService(db) service = AdminService(db)
return await service.get_system_health() 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 sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional
from app.database import get_db from app.database import get_db
from app.services.payment import PaymentService from app.services.payment import PaymentService
from app.services.wechat_pay import WeChatPayService
from app.api.v1.deps import get_current_user_id from app.api.v1.deps import get_current_user_id
router = APIRouter() router = APIRouter()
@@ -10,6 +12,7 @@ router = APIRouter()
class CreateOrderRequest(BaseModel): class CreateOrderRequest(BaseModel):
plan: str plan: str
pay_type: str = "jsapi"
class PaymentCallbackRequest(BaseModel): class PaymentCallbackRequest(BaseModel):
@@ -40,7 +43,7 @@ async def create_order(
): ):
svc = PaymentService(db) svc = PaymentService(db)
try: 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: except ValueError as e:
raise HTTPException(status_code=400, detail=str(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) success = await svc.handle_payment_callback(data.payment_id, data.success)
if not success: if not success:
raise HTTPException(status_code=404, detail="Order not found") 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_PHONE_NUMBER_ID: Optional[str] = None
WHATSAPP_WEBHOOK_VERIFY_TOKEN: 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_ID: Optional[str] = None
WECHAT_APP_SECRET: Optional[str] = None WECHAT_APP_SECRET: Optional[str] = None
WECHAT_PUSH_TEMPLATE_ID: 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 EXCHANGE_RATE_API_KEY: Optional[str] = None
UPLOAD_DIR: str = "./uploads" UPLOAD_DIR: str = "./uploads"
@@ -71,7 +81,7 @@ class Settings(BaseSettings):
DEBUG: bool = True DEBUG: bool = True
AI_ROUTING: dict = { 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"]}, "reply": {"primary": "opencode_go", "fallback": ["sensenova", "anthropic", "local"]},
"marketing": {"primary": "opencode_go", "fallback": ["sensenova", "openai", "local"]}, "marketing": {"primary": "opencode_go", "fallback": ["sensenova", "openai", "local"]},
"extract": {"primary": "opencode_go", "fallback": ["sensenova", "openai"]}, "extract": {"primary": "opencode_go", "fallback": ["sensenova", "openai"]},
+2
View File
@@ -11,6 +11,7 @@ from .preference import PreferenceAnalysis, MarketingEffect
from .device import Device from .device import Device
from .followup import FollowupStrategy, FollowupLog from .followup import FollowupStrategy, FollowupLog
from .system_config import SystemConfig from .system_config import SystemConfig
from .translation_quota import TranslationQuota
__all__ = [ __all__ = [
"User", "Product", "User", "Product",
@@ -26,4 +27,5 @@ __all__ = [
"Device", "Device",
"FollowupStrategy", "FollowupLog", "FollowupStrategy", "FollowupLog",
"SystemConfig", "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 logging
import hashlib
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from datetime import datetime, timedelta from datetime import datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -9,6 +7,7 @@ from sqlalchemy import select
from app.models.subscription import Subscription from app.models.subscription import Subscription
from app.models.user import User from app.models.user import User
from app.config import settings from app.config import settings
from app.services.wechat_pay import WeChatPayService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -18,10 +17,22 @@ PLANS = {
"enterprise": {"price": 399, "duration_days": 30}, "enterprise": {"price": 399, "duration_days": 30},
} }
PLAN_DESCRIPTIONS = {
"pro": "TradeMate Pro 版会员",
"enterprise": "TradeMate 企业版会员",
}
class PaymentService: class PaymentService:
def __init__(self, db: AsyncSession): def __init__(self, db: AsyncSession):
self.db = db 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]: async def get_plans(self) -> Dict[str, Any]:
return { return {
@@ -85,7 +96,8 @@ class PaymentService:
"auto_renew": sub.auto_renew if sub else False, "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: if plan not in PLANS:
raise ValueError(f"Invalid plan: {plan}") raise ValueError(f"Invalid plan: {plan}")
@@ -98,8 +110,13 @@ class PaymentService:
await self.db.flush() await self.db.flush()
return {"status": "ok", "plan": plan, "amount": 0} 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:]}" 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( sub = Subscription(
user_id=user_id, user_id=user_id,
@@ -111,6 +128,51 @@ class PaymentService:
self.db.add(sub) self.db.add(sub)
await self.db.flush() 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 = { pay_params = {
"appId": settings.WECHAT_APP_ID or "", "appId": settings.WECHAT_APP_ID or "",
"timeStamp": str(int(datetime.utcnow().timestamp())), "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 = "&".join(f"{k}={v}" for k, v in sorted(pay_params.items()))
sign_str += f"&key={settings.SECRET_KEY}" sign_str += f"&key={settings.SECRET_KEY}"
pay_params["paySign"] = hashlib.md5(sign_str.encode()).hexdigest().upper() pay_params["paySign"] = hashlib.md5(sign_str.encode()).hexdigest().upper()
return { return {
"status": "pending", "status": "pending",
"order_id": order_id, "order_id": order_id,
"plan": plan, "plan": plan,
"amount": plan_info["price"], "amount": plan_info["price"],
"currency": "CNY", "currency": "CNY",
"pay_type": pay_type,
"pay_params": pay_params, "pay_params": pay_params,
} }
@@ -155,4 +217,4 @@ class PaymentService:
return True 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")
+25
View File
@@ -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-----
+9
View File
@@ -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-----
+108 -12
View File
@@ -1,17 +1,13 @@
<template> <template>
<view class="admin-container"> <view class="admin-container">
<view class="header-card"> <scroll-view class="tabs" scroll-x>
<text class="title">管理后台</text>
<text class="subtitle">系统管理与监控</text>
</view>
<view class="tabs">
<view class="tab" :class="{ active: tab === 'overview' }" @click="tab = 'overview'">概览</view> <view class="tab" :class="{ active: tab === 'overview' }" @click="tab = 'overview'">概览</view>
<view class="tab" :class="{ active: tab === 'users' }" @click="tab = 'users'">用户</view> <view class="tab" :class="{ active: tab === 'users' }" @click="tab = 'users'">用户</view>
<view class="tab" :class="{ active: tab === 'stats' }" @click="tab = 'stats'">统计</view> <view class="tab" :class="{ active: tab === 'stats' }" @click="tab = 'stats'">统计</view>
<view class="tab" :class="{ active: tab === 'logs' }" @click="tab = 'logs'">日志</view> <view class="tab" :class="{ active: tab === 'logs' }" @click="tab = 'logs'">日志</view>
<view class="tab" :class="{ active: tab === 'config' }" @click="tab = 'config'">配置</view> <view class="tab" :class="{ active: tab === 'config' }" @click="tab = 'config'">配置</view>
</view> <view class="tab" :class="{ active: tab === 'quota' }" @click="tab = 'quota'">翻译配额</view>
</scroll-view>
<!-- 概览 --> <!-- 概览 -->
<view v-if="tab === 'overview'"> <view v-if="tab === 'overview'">
@@ -231,6 +227,45 @@
<text v-if="!configList.length" class="empty-text">暂无配置</text> <text v-if="!configList.length" class="empty-text">暂无配置</text>
</view> </view>
<!-- 翻译配额 -->
<view v-if="tab === 'quota'">
<view class="section">
<view class="section-header">
<text class="section-title">翻译 API 配额管理</text>
<text class="section-count">月配额每月自动重置</text>
</view>
<view class="quota-list" v-if="quotas.length">
<view class="quota-card" v-for="q in quotas" :key="q.version">
<view class="quota-header">
<text class="quota-version">{{ q.version === 'ecommerce' ? '电商版' : '通用版' }}</text>
<text class="quota-desc">{{ q.description }}</text>
</view>
<view class="quota-stat">
<text class="quota-label">已用 ({{ q.current_month }})</text>
<view class="quota-bar-track">
<view class="quota-bar-fill" :style="{ width: quotaPercent(q) }"></view>
</view>
<text class="quota-value">{{ q.used_chars }} / {{ q.monthly_limit }}</text>
</view>
<view class="quota-actions">
<view class="quota-field">
<text class="quota-field-label">月限额</text>
<input class="quota-input" type="number" :value="q.monthly_limit"
@blur="e => onQuotaEdit(q.version, 'monthly_limit', e)" />
</view>
<view class="quota-field">
<text class="quota-field-label">启用</text>
<switch :checked="q.enabled" @change="e => onQuotaEdit(q.version, 'enabled', e)" />
</view>
<text class="quota-reset-btn" @click="resetQuota(q.version)">重置用量</text>
<text class="quota-save-btn" @click="saveQuota(q.version)">保存</text>
</view>
</view>
</view>
<text v-else class="empty-text">暂无配额数据</text>
</view>
</view>
<!-- 用户详情弹窗 --> <!-- 用户详情弹窗 -->
<view class="modal-mask" v-if="userDetail" @click="userDetail = null"> <view class="modal-mask" v-if="userDetail" @click="userDetail = null">
<view class="modal-content" @click.stop> <view class="modal-content" @click.stop>
@@ -287,6 +322,8 @@ const logFilter = ref({ action: '', user_id: '', date_from: '', date_to: '' })
const configList = ref([]) const configList = ref([])
const configEdits = ref({}) const configEdits = ref({})
const quotas = ref([])
const quotaEdits = ref({})
const configLabels = { const configLabels = {
ai_provider_translate: '翻译 AI 模型', ai_provider_translate: '翻译 AI 模型',
@@ -490,20 +527,63 @@ const saveConfig = async (key) => {
} }
} }
const loadQuotas = async () => {
try {
quotas.value = await adminApi.getTranslationQuotas()
} catch (err) {
uni.showToast({ title: err.message || '加载失败', icon: 'none' })
}
}
const quotaPercent = (q) => {
if (!q.monthly_limit) return '0%'
return Math.min((q.used_chars / q.monthly_limit) * 100, 100) + '%'
}
const onQuotaEdit = (version, field, e) => {
if (!quotaEdits.value[version]) quotaEdits.value[version] = {}
const val = e.detail ? e.detail.value : e
quotaEdits.value[version][field] = field === 'monthly_limit' ? Number(val) : !!val
}
const saveQuota = async (version) => {
const edit = quotaEdits.value[version]
if (!edit) {
uni.showToast({ title: '无改动', icon: 'none' })
return
}
try {
await adminApi.updateTranslationQuota(version, edit)
delete quotaEdits.value[version]
uni.showToast({ title: '已保存', icon: 'success' })
loadQuotas()
} catch (err) {
uni.showToast({ title: err.message || '保存失败', icon: 'none' })
}
}
const resetQuota = async (version) => {
try {
await adminApi.resetTranslationQuota(version)
uni.showToast({ title: '已重置', icon: 'success' })
loadQuotas()
} catch (err) {
uni.showToast({ title: err.message || '重置失败', icon: 'none' })
}
}
watch(tab, (val) => { watch(tab, (val) => {
if (val === 'stats') loadUsageStats() if (val === 'stats') loadUsageStats()
else if (val === 'logs') { logPage.value = 1; loadLogs() } else if (val === 'logs') { logPage.value = 1; loadLogs() }
else if (val === 'config') loadConfig() else if (val === 'config') loadConfig()
else if (val === 'quota') loadQuotas()
}) })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.admin-container { min-height: 100vh; background: #f5f5f5; padding: 20rpx; } .admin-container { min-height: 100vh; background: #f5f5f5; padding: 20rpx; }
.header-card { background: linear-gradient(135deg, #667eea, #764ba2); border-radius: 16rpx; padding: 40rpx; margin-bottom: 30rpx; } .tabs { width: 100%; background: #fff; border-radius: 16rpx; margin-bottom: 30rpx; white-space: nowrap; }
.header-card .title { font-size: 40rpx; font-weight: 700; color: #fff; display: block; } .tab { display: inline-block; text-align: center; padding: 20rpx 28rpx; font-size: 26rpx; color: #666; font-weight: 500; }
.header-card .subtitle { font-size: 26rpx; color: rgba(255,255,255,0.8); margin-top: 8rpx; }
.tabs { display: flex; background: #fff; border-radius: 16rpx; overflow: hidden; margin-bottom: 30rpx; }
.tab { flex: 1; text-align: center; padding: 20rpx 0; font-size: 26rpx; color: #666; font-weight: 500; }
.tab.active { color: #667eea; border-bottom: 4rpx solid #667eea; } .tab.active { color: #667eea; border-bottom: 4rpx solid #667eea; }
.stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20rpx; margin-bottom: 30rpx; } .stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20rpx; margin-bottom: 30rpx; }
.stat-card { background: #fff; border-radius: 16rpx; padding: 30rpx; text-align: center; } .stat-card { background: #fff; border-radius: 16rpx; padding: 30rpx; text-align: center; }
@@ -583,4 +663,20 @@ watch(tab, (val) => {
.divider { height: 1rpx; background: #f0f0f0; margin: 16rpx 0; } .divider { height: 1rpx; background: #f0f0f0; margin: 16rpx 0; }
.text-green { color: #52c41a; } .text-green { color: #52c41a; }
.text-red { color: #f5222d; } .text-red { color: #f5222d; }
.quota-list { display: flex; flex-direction: column; gap: 20rpx; }
.quota-card { padding: 24rpx; background: #f9f9f9; border-radius: 12rpx; }
.quota-header { margin-bottom: 16rpx; }
.quota-version { font-size: 28rpx; font-weight: 600; }
.quota-desc { font-size: 22rpx; color: #999; margin-left: 12rpx; }
.quota-stat { display: flex; align-items: center; gap: 12rpx; margin-bottom: 16rpx; }
.quota-label { font-size: 22rpx; color: #666; width: 120rpx; flex-shrink: 0; }
.quota-bar-track { flex: 1; height: 24rpx; background: #f0f0f0; border-radius: 12rpx; overflow: hidden; }
.quota-bar-fill { height: 100%; background: linear-gradient(90deg, #1890ff, #52c41a); border-radius: 12rpx; transition: width 0.3s; }
.quota-value { font-size: 22rpx; color: #333; width: 200rpx; text-align: right; flex-shrink: 0; }
.quota-actions { display: flex; align-items: center; gap: 16rpx; flex-wrap: wrap; }
.quota-field { display: flex; align-items: center; gap: 8rpx; }
.quota-field-label { font-size: 22rpx; color: #666; }
.quota-input { width: 140rpx; height: 56rpx; background: #fff; border: 1rpx solid #e0e0e0; border-radius: 8rpx; padding: 0 12rpx; font-size: 24rpx; text-align: center; }
.quota-reset-btn { font-size: 22rpx; padding: 6rpx 16rpx; background: #fff7e6; color: #fa8c16; border-radius: 6rpx; }
.quota-save-btn { font-size: 22rpx; padding: 6rpx 16rpx; background: #52c41a; color: #fff; border-radius: 6rpx; }
</style> </style>
+2 -2
View File
@@ -427,7 +427,7 @@ const loadData = async () => {
} }
} }
const tabbarPages = [PAGES.INDEX, PAGES.TRANSLATE, PAGES.CUSTOMERS, PAGES.MARKETING, PAGES.QUOTATION] const tabbarPages = [PAGES.INDEX, PAGES.CUSTOMERS, PAGES.MARKETING, PAGES.QUOTATION]
const goToPage = (url) => { const goToPage = (url) => {
if (tabbarPages.includes(url)) { if (tabbarPages.includes(url)) {
@@ -443,7 +443,7 @@ const goToLogin = () => {
content: '请先登录', content: '请先登录',
success: (res) => { success: (res) => {
if (res.confirm) { if (res.confirm) {
uni.reLaunch({ url: PAGES.LOGIN }) uni.navigateTo({ url: PAGES.LOGIN })
} }
}, },
}) })
+11 -2
View File
@@ -125,7 +125,7 @@ const isRegister = ref(false)
const loading = ref(false) const loading = ref(false)
const silentLoading = ref(true) const silentLoading = ref(true)
const error = ref('') const error = ref('')
const showForm = ref(false) const showForm = ref(true)
const isWechatAvailable = ref(false) const isWechatAvailable = ref(false)
const doWechatLogin = async (code) => { const doWechatLogin = async (code) => {
@@ -225,7 +225,7 @@ const handleSubmit = async () => {
uni.setStorageSync(STORAGE_KEYS.USER_INFO, res.user) uni.setStorageSync(STORAGE_KEYS.USER_INFO, res.user)
uni.setStorageSync(STORAGE_KEYS.HAS_LOGIN, true) uni.setStorageSync(STORAGE_KEYS.HAS_LOGIN, true)
uni.setStorageSync(STORAGE_KEYS.IS_GUEST, false) uni.setStorageSync(STORAGE_KEYS.IS_GUEST, false)
uni.reLaunch({ url: PAGES.INDEX }) afterLogin()
} }
} catch (err) { } catch (err) {
console.error('登录失败', err) console.error('登录失败', err)
@@ -238,6 +238,15 @@ const handleSubmit = async () => {
} }
} }
const afterLogin = () => {
const pages = getCurrentPages()
if (pages.length > 1) {
uni.navigateBack()
} else {
uni.switchTab({ url: PAGES.PROFILE })
}
}
const handleWechatLogin = () => { const handleWechatLogin = () => {
uni.login({ uni.login({
provider: 'weixin', provider: 'weixin',
+12 -3
View File
@@ -103,6 +103,7 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { authApi } from '@/utils/api.js' import { authApi } from '@/utils/api.js'
import AiAssistant from '@/components/ai-assistant.vue' import AiAssistant from '@/components/ai-assistant.vue'
import { STORAGE_KEYS, PAGES, TIER_LABELS } from '@/config.js' import { STORAGE_KEYS, PAGES, TIER_LABELS } from '@/config.js'
@@ -121,11 +122,18 @@ const initials = computed(() => {
const tierLabel = computed(() => TIER_LABELS[user.value.tier] || '免费版') const tierLabel = computed(() => TIER_LABELS[user.value.tier] || '免费版')
const loadUser = async () => { const loadUser = async () => {
const token = uni.getStorageSync(STORAGE_KEYS.TOKEN)
if (!token) {
user.value = { tier: 'guest' }
return
}
try { try {
const res = await authApi.getUserInfo() const res = await authApi.getUserInfo()
user.value = res user.value = res
editForm.value = { username: res.username || '', email: res.email || '' } editForm.value = { username: res.username || '', email: res.email || '' }
} catch {} } catch {
user.value = { tier: 'guest' }
}
} }
const saveProfile = async () => { const saveProfile = async () => {
@@ -162,7 +170,7 @@ const changePwd = async () => {
} }
} }
const goLogin = () => uni.reLaunch({ url: PAGES.LOGIN }) const goLogin = () => uni.navigateTo({ url: PAGES.LOGIN })
const goUpgrade = () => uni.navigateTo({ url: PAGES.UPGRADE }) const goUpgrade = () => uni.navigateTo({ url: PAGES.UPGRADE })
const goFeedback = () => uni.navigateTo({ url: PAGES.FEEDBACK }) const goFeedback = () => uni.navigateTo({ url: PAGES.FEEDBACK })
const goAgreement = (type) => uni.navigateTo({ url: `/pages/agreement/${type}` }) const goAgreement = (type) => uni.navigateTo({ url: `/pages/agreement/${type}` })
@@ -175,13 +183,14 @@ const logout = () => {
if (res.confirm) { if (res.confirm) {
uni.removeStorageSync(STORAGE_KEYS.TOKEN) uni.removeStorageSync(STORAGE_KEYS.TOKEN)
uni.removeStorageSync(STORAGE_KEYS.REFRESH_TOKEN) uni.removeStorageSync(STORAGE_KEYS.REFRESH_TOKEN)
uni.reLaunch({ url: PAGES.LOGIN }) user.value = { tier: 'guest' }
} }
}, },
}) })
} }
onMounted(loadUser) onMounted(loadUser)
onShow(loadUser)
</script> </script>
<style scoped> <style scoped>
+32 -2
View File
@@ -33,6 +33,16 @@
> >
{{ loading ? '处理中...' : (selected === currentPlan ? '当前方案' : '立即升级') }} {{ loading ? '处理中...' : (selected === currentPlan ? '当前方案' : '立即升级') }}
</button> </button>
<!-- H5 Native 支付显示二维码 -->
<view class="qr-modal" v-if="showQr">
<view class="qr-box">
<text class="qr-title">请使用微信扫码支付</text>
<image class="qr-img" :src="qrCodeUrl" mode="widthFix" />
<text class="qr-hint">打开微信扫一扫完成支付</text>
<text class="qr-close" @click="showQr = false">关闭</text>
</view>
</view>
</view> </view>
</template> </template>
@@ -45,6 +55,8 @@ const plans = ref([])
const currentPlan = ref('free') const currentPlan = ref('free')
const selected = ref('') const selected = ref('')
const loading = ref(false) const loading = ref(false)
const showQr = ref(false)
const qrCodeUrl = ref('')
onShow(async () => { onShow(async () => {
try { try {
@@ -66,13 +78,21 @@ const handleUpgrade = async () => {
if (!selected.value || selected.value === currentPlan.value) return if (!selected.value || selected.value === currentPlan.value) return
loading.value = true loading.value = true
try { try {
const res = await paymentApi.createOrder(selected.value) // #ifdef MP-WEIXIN
const payType = 'jsapi'
// #endif
// #ifdef H5
const payType = 'native'
// #endif
const res = await paymentApi.createOrder(selected.value, payType)
if (res.amount === 0) { if (res.amount === 0) {
uni.showToast({ title: '已切换为免费版', icon: 'success' }) uni.showToast({ title: '已切换为免费版', icon: 'success' })
currentPlan.value = selected.value currentPlan.value = selected.value
return return
} }
if (res.pay_params) {
if (res.pay_type === 'jsapi' && res.pay_params) {
uni.requestPayment({ uni.requestPayment({
provider: 'wxpay', provider: 'wxpay',
...res.pay_params, ...res.pay_params,
@@ -84,6 +104,9 @@ const handleUpgrade = async () => {
uni.showToast({ title: err.errMsg || '支付失败', icon: 'none' }) uni.showToast({ title: err.errMsg || '支付失败', icon: 'none' })
}, },
}) })
} else if (res.pay_type === 'native' && res.code_url) {
qrCodeUrl.value = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encodeURIComponent(res.code_url)}`
showQr.value = true
} }
} catch (err) { } catch (err) {
uni.showToast({ title: err.message || '操作失败', icon: 'none' }) uni.showToast({ title: err.message || '操作失败', icon: 'none' })
@@ -111,4 +134,11 @@ const handleUpgrade = async () => {
.plan-badge { position: absolute; top: 16rpx; right: 16rpx; font-size: 22rpx; color: #52c41a; background: #f6ffed; padding: 4rpx 12rpx; border-radius: 6rpx; } .plan-badge { position: absolute; top: 16rpx; right: 16rpx; font-size: 22rpx; color: #52c41a; background: #f6ffed; padding: 4rpx 12rpx; border-radius: 6rpx; }
.upgrade-btn { width: 100%; height: 96rpx; background: linear-gradient(135deg, #1890ff, #096dd9); color: #fff; border: none; border-radius: 16rpx; font-size: 32rpx; font-weight: 500; } .upgrade-btn { width: 100%; height: 96rpx; background: linear-gradient(135deg, #1890ff, #096dd9); color: #fff; border: none; border-radius: 16rpx; font-size: 32rpx; font-weight: 500; }
.upgrade-btn[disabled] { background: #a0cfff; } .upgrade-btn[disabled] { background: #a0cfff; }
.qr-modal { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.6); z-index: 999; display: flex; align-items: center; justify-content: center; }
.qr-box { background: #fff; border-radius: 20rpx; padding: 50rpx; text-align: center; width: 500rpx; }
.qr-title { font-size: 30rpx; font-weight: 600; color: #333; margin-bottom: 30rpx; display: block; }
.qr-img { width: 300rpx; height: 300rpx; display: block; margin: 0 auto 30rpx; }
.qr-hint { font-size: 24rpx; color: #999; display: block; margin-bottom: 20rpx; }
.qr-close { font-size: 26rpx; color: #1890ff; display: block; }
</style> </style>
+7 -1
View File
@@ -178,6 +178,11 @@ export const adminApi = {
}, },
getConfig: () => request('/admin/config'), getConfig: () => request('/admin/config'),
updateConfig: (key, value) => request(`/admin/config/${key}`, 'PUT', { value }), updateConfig: (key, value) => request(`/admin/config/${key}`, 'PUT', { value }),
getTranslationQuotas: () => request('/admin/translation-quotas'),
updateTranslationQuota: (version, data) =>
request(`/admin/translation-quotas/${encodeURIComponent(version)}`, 'PUT', data),
resetTranslationQuota: (version) =>
request(`/admin/translation-quotas/${encodeURIComponent(version)}/reset`, 'POST'),
} }
export const aiChatApi = { export const aiChatApi = {
@@ -230,7 +235,8 @@ export const notificationApi = {
export const paymentApi = { export const paymentApi = {
plans: () => request('/payment/plans'), plans: () => request('/payment/plans'),
subscription: () => request('/payment/subscription'), subscription: () => request('/payment/subscription'),
createOrder: (plan) => request('/payment/create-order', 'POST', { plan }), createOrder: (plan, payType = 'jsapi') =>
request('/payment/create-order', 'POST', { plan, pay_type: payType }),
} }
export const feedbackApi = { export const feedbackApi = {