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