feat: AI routing DB-driven, payment gateway full integration, WeChat mini-program CI/CD

- AI routing rules now stored in system_configs DB table instead of hardcoded config
- Multi-model support via name|model composite key for same-provider routing
- UnifiedPayService with HMAC-SHA256 gateway integration (alipay/wechat)
- Admin payment panel: list, stats, search, filter, refund
- WeChat mini-program CI/CD via miniprogram-ci (v1.0.9)
- Translation quota extended to LLM provider tier
- SearchService with DB-driven provider config (bing/google_cse/searxng)
- Footer cleanup across admin/workspace/uni-app
- Private key excluded from git tracking
This commit is contained in:
TradeMate Dev
2026-06-09 17:19:45 +08:00
parent f17a6ccbac
commit d2736d1ef6
28 changed files with 12368 additions and 267 deletions
+3
View File
@@ -55,3 +55,6 @@ docker-compose.override.yml
# Generated by MCP search server # Generated by MCP search server
backend/app/services/_bing_search.js backend/app/services/_bing_search.js
# WeChat mini-program private key
uni-app/private.key
+4
View File
@@ -58,4 +58,8 @@ export function processInvoice(id, action) {
export function aiChat(message, history = []) { return http.post('/ai/chat', { message, history }) } export function aiChat(message, history = []) { return http.post('/ai/chat', { message, history }) }
export function aiQuickQuestions() { return http.get('/ai/quick-questions') } export function aiQuickQuestions() { return http.get('/ai/quick-questions') }
export function listPayments(params) { return http.get('/admin/payments', { params }) }
export function getPaymentStats() { return http.get('/admin/payments/stats') }
export function adminRefund(order_no, reason = '') { return http.post('/admin/payments/refund', { order_no, reason }) }
export default http export default http
+12 -39
View File
@@ -26,6 +26,10 @@
<el-icon><Document /></el-icon> <el-icon><Document /></el-icon>
<span>日志</span> <span>日志</span>
</el-menu-item> </el-menu-item>
<el-menu-item index="/payments">
<el-icon><Wallet /></el-icon>
<span>支付管理</span>
</el-menu-item>
<el-menu-item index="/config"> <el-menu-item index="/config">
<el-icon><Setting /></el-icon> <el-icon><Setting /></el-icon>
<span>配置</span> <span>配置</span>
@@ -93,37 +97,13 @@
<el-footer class="footer"> <el-footer class="footer">
<div class="footer-content"> <div class="footer-content">
<div class="footer-section"> <p>&copy; {{ new Date().getFullYear() }} TradeMate</p>
<div class="footer-brand">TradeMate</div> <div class="footer-links">
<p class="footer-tagline">AI 外贸小助手 · 让外贸更简单</p> <a href="http://beian.miit.gov.cn/" target="_blank">{{ beianInfo.icp }}</a>
<div class="qrcode-row"> <a v-if="beianInfo.showGongan" :href="beianInfo.gonganLink" target="_blank" rel="noreferrer" class="gongan-link">
<div class="qrcode-item"> <img src="/images/beian/gongan-beian.png" alt="公安备案" class="gongan-icon" />
<img src="/images/yzr/yuzhiran.jpg" alt="微信公众号" class="qrcode-img" /> {{ beianInfo.gongan }}
<span>微信公众号</span> </a>
</div>
<div class="qrcode-item">
<img src="/images/yzr/yuzhiran-tech.jpg" alt="微信服务号" class="qrcode-img" />
<span>微信服务号</span>
</div>
<div class="qrcode-item">
<img src="/images/yzr/yuzhiran-yhl.jpg" alt="小程序" class="qrcode-img" />
<span>小程序</span>
</div>
<div class="qrcode-item">
<img src="/images/yzr/kefu.png" alt="客服" class="qrcode-img" />
<span>微信客服</span>
</div>
</div>
</div>
<div class="footer-bottom">
<p>&copy; {{ new Date().getFullYear() }} TradeMate 外贸小助手. 保留所有权利.</p>
<div class="footer-links">
<a href="http://beian.miit.gov.cn/" target="_blank">{{ beianInfo.icp }}</a>
<a v-if="beianInfo.showGongan" :href="beianInfo.gonganLink" target="_blank" rel="noreferrer" class="gongan-link">
<img src="/images/beian/gongan-beian.png" alt="公安备案" class="gongan-icon" />
{{ beianInfo.gongan }}
</a>
</div>
</div> </div>
</div> </div>
</el-footer> </el-footer>
@@ -172,14 +152,7 @@ const beianInfo = computed(() => {
.user-name { font-size: 14px; color: #333; } .user-name { font-size: 14px; color: #333; }
.main-content { background: #f5f5f5; padding: 20px; overflow-y: auto; } .main-content { background: #f5f5f5; padding: 20px; overflow-y: auto; }
.footer { padding: 0; background: #fff; border-top: 1px solid #e8e8e8; color: #666; font-size: 12px; } .footer { padding: 0; background: #fff; border-top: 1px solid #e8e8e8; color: #666; font-size: 12px; }
.footer-content { padding: 20px 24px 16px; } .footer-content { padding: 8px 24px; display: flex; justify-content: center; align-items: center; gap: 16px; flex-wrap: wrap; }
.footer-section { margin-bottom: 16px; }
.footer-brand { font-size: 15px; font-weight: 700; color: #1890ff; margin-bottom: 4px; }
.footer-tagline { color: #999; font-size: 12px; margin-bottom: 12px; }
.qrcode-row { display: flex; gap: 16px; flex-wrap: wrap; }
.qrcode-item { display: flex; flex-direction: column; align-items: center; gap: 4px; color: #999; font-size: 11px; }
.qrcode-img { width: 48px; height: 48px; border-radius: 6px; }
.footer-bottom { border-top: 1px solid #eee; padding-top: 12px; display: flex; justify-content: center; align-items: center; gap: 16px; flex-wrap: wrap; }
.footer-links { display: flex; gap: 16px; align-items: center; } .footer-links { display: flex; gap: 16px; align-items: center; }
.footer-links a { color: #999; text-decoration: none; } .footer-links a { color: #999; text-decoration: none; }
.footer-links a:hover { color: #1890ff; } .footer-links a:hover { color: #1890ff; }
+8
View File
@@ -36,6 +36,14 @@ const routes = [
{ path: '', name: 'Logs', component: () => import('@/views/Logs.vue'), meta: { title: '日志' } }, { path: '', name: 'Logs', component: () => import('@/views/Logs.vue'), meta: { title: '日志' } },
] ]
}, },
{
path: '/payments',
component: AdminLayout,
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Payments', component: () => import('@/views/Payments.vue'), meta: { title: '支付管理' } },
]
},
{ {
path: '/config', path: '/config',
component: AdminLayout, component: AdminLayout,
+4 -4
View File
@@ -12,14 +12,14 @@
<div class="cfg-group-title">{{ fieldLabelsMap.ai_routing?.[taskKey] || taskKey }}</div> <div class="cfg-group-title">{{ fieldLabelsMap.ai_routing?.[taskKey] || taskKey }}</div>
<div class="cfg-field"> <div class="cfg-field">
<span class="cfg-label">主选</span> <span class="cfg-label">主选</span>
<el-select v-model="edits[cfg.key][taskKey].primary" size="small" style="width:300px" filterable> <el-select v-model="edits[cfg.key][taskKey].primary" size="small" style="width:380px" filterable>
<el-option v-for="p in providers" :key="p.provider_type" :value="p.provider_type" :label="p.name + ' — ' + p.model_name" /> <el-option v-for="p in providers" :key="p.name + '|' + p.model_name" :value="p.name + '|' + p.model_name" :label="p.name + ' — ' + p.model_name" />
</el-select> </el-select>
</div> </div>
<div class="cfg-field"> <div class="cfg-field">
<span class="cfg-label">备用</span> <span class="cfg-label">备用</span>
<el-select v-model="edits[cfg.key][taskKey].fallback" size="small" style="width:400px" multiple filterable collapse-tags> <el-select v-model="edits[cfg.key][taskKey].fallback" size="small" style="width:500px" multiple filterable collapse-tags>
<el-option v-for="p in providers" :key="p.provider_type" :value="p.provider_type" :label="p.name + ' — ' + p.model_name" /> <el-option v-for="p in providers" :key="p.name + '|' + p.model_name" :value="p.name + '|' + p.model_name" :label="p.name + ' — ' + p.model_name" />
</el-select> </el-select>
</div> </div>
</div> </div>
+180
View File
@@ -0,0 +1,180 @@
<template>
<div>
<el-row :gutter="16" style="margin-bottom:20px">
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-item">
<div class="stat-label">总收入</div>
<div class="stat-value">¥{{ (stats.total_revenue || 0).toFixed(2) }}</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-item">
<div class="stat-label">已支付</div>
<div class="stat-value" style="color:#52c41a">{{ stats.paid_count || 0 }}</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-item">
<div class="stat-label">待支付</div>
<div class="stat-value" style="color:#faad14">{{ stats.pending_count || 0 }}</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-item">
<div class="stat-label">已退款</div>
<div class="stat-value" style="color:#ff4d4f">{{ stats.refunded_count || 0 }}</div>
</div>
</el-card>
</el-col>
</el-row>
<el-card>
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center">
<span>交易记录</span>
<div style="display:flex;gap:8px">
<el-select v-model="filters.status" placeholder="状态" clearable style="width:120px" @change="loadData">
<el-option label="全部" value="" />
<el-option label="待支付" value="pending" />
<el-option label="已支付" value="paid" />
<el-option label="已退款" value="refunded" />
<el-option label="失败" value="failed" />
</el-select>
<el-select v-model="filters.pay_type" placeholder="支付方式" clearable style="width:120px" @change="loadData">
<el-option label="全部" value="" />
<el-option label="支付宝" value="alipay" />
<el-option label="微信" value="wechat" />
</el-select>
<el-input v-model="filters.user_id" placeholder="用户ID" clearable style="width:180px" @clear="loadData" @keyup.enter="loadData" />
<el-button type="primary" @click="loadData">搜索</el-button>
</div>
</div>
</template>
<el-table :data="list" v-loading="loading" stripe style="width:100%">
<el-table-column prop="order_no" label="订单号" width="200" />
<el-table-column prop="user_id" label="用户" min-width="180" />
<el-table-column prop="plan" label="套餐" width="100" />
<el-table-column label="金额" width="100">
<template #default="{ row }">¥{{ (row.amount || 0).toFixed(2) }}</template>
</el-table-column>
<el-table-column label="支付方式" width="80">
<template #default="{ row }">{{ row.pay_type === 'wechat' ? '微信' : '支付宝' }}</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag v-if="row.status === 'paid'" type="success" size="small">已支付</el-tag>
<el-tag v-else-if="row.status === 'pending'" type="warning" size="small">待支付</el-tag>
<el-tag v-else-if="row.status === 'refunded'" type="danger" size="small">已退款</el-tag>
<el-tag v-else type="info" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="时间" width="170">
<template #default="{ row }">{{ row.paid_at || row.created_at }}</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status === 'paid'" type="danger" size="small" @click="handleRefund(row)">退款</el-button>
</template>
</el-table-column>
</el-table>
<div style="margin-top:16px;display:flex;justify-content:flex-end">
<el-pagination
v-model:current-page="page"
v-model:page-size="size"
:total="total"
layout="total, prev, pager, next"
@current-change="loadData"
/>
</div>
</el-card>
<el-dialog v-model="refundDialog.visible" title="确认退款" width="400px">
<p>订单号{{ refundDialog.order_no }}</p>
<p>退款金额¥{{ (refundDialog.amount || 0).toFixed(2) }}</p>
<el-input v-model="refundDialog.reason" type="textarea" placeholder="退款原因(可选)" :rows="3" />
<template #footer>
<el-button @click="refundDialog.visible = false">取消</el-button>
<el-button type="danger" :loading="refundDialog.loading" @click="confirmRefund">确认退款</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { listPayments, getPaymentStats, adminRefund } from '@/api'
import { ElMessage, ElMessageBox } from 'element-plus'
const list = ref([])
const total = ref(0)
const page = ref(1)
const size = ref(20)
const loading = ref(false)
const stats = ref({})
const filters = reactive({ status: '', pay_type: '', user_id: '' })
const refundDialog = reactive({ visible: false, loading: false, order_no: '', amount: 0, reason: '' })
onMounted(() => {
loadStats()
loadData()
})
async function loadStats() {
try {
const res = await getPaymentStats()
stats.value = res.data || res
} catch { /* ignore */ }
}
async function loadData() {
loading.value = true
try {
const params = { page: page.value, size: size.value }
if (filters.status) params.status = filters.status
if (filters.pay_type) params.pay_type = filters.pay_type
if (filters.user_id) params.user_id = filters.user_id
const res = await listPayments(params)
const d = res.data || res
list.value = d.items || []
total.value = d.total || 0
} catch { /* ignore */ }
finally { loading.value = false }
}
function handleRefund(row) {
refundDialog.order_no = row.order_no
refundDialog.amount = row.amount
refundDialog.reason = ''
refundDialog.visible = true
}
async function confirmRefund() {
refundDialog.loading = true
try {
await adminRefund(refundDialog.order_no, refundDialog.reason)
ElMessage.success('退款成功')
refundDialog.visible = false
loadData()
loadStats()
} catch (e) {
ElMessage.error(e?.detail || '退款失败')
} finally {
refundDialog.loading = false
}
}
</script>
<style scoped>
.stat-item { text-align: center; padding: 8px 0; }
.stat-label { font-size: 13px; color: #999; margin-bottom: 4px; }
.stat-value { font-size: 24px; font-weight: 700; color: #1890ff; }
</style>
+9 -10
View File
@@ -8,12 +8,12 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_ROUTING: Dict[str, dict] = { DEFAULT_ROUTING: Dict[str, dict] = {
"translate": {"primary": "sensenova", "fallback": ["alibaba-mt", "nvidia"]}, "translate": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["阿里翻译|alibaba-mt", "NVIDIA|stepfun-ai/step-3.7-flash"]},
"reply": {"primary": "sensenova", "fallback": ["nvidia"]}, "reply": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
"marketing": {"primary": "sensenova", "fallback": ["nvidia"]}, "marketing": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
"extract": {"primary": "sensenova", "fallback": ["nvidia"]}, "extract": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
"quotation": {"primary": "sensenova", "fallback": ["nvidia"]}, "quotation": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
"chat": {"primary": "sensenova", "fallback": ["nvidia"]}, "chat": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
} }
@@ -36,10 +36,9 @@ class AIRouter:
for p in rows: for p in rows:
inst = self._build_provider(p) inst = self._build_provider(p)
if inst: if inst:
key = p.id.hex if hasattr(p.id, 'hex') else str(p.id)
new_providers[key] = inst
new_providers[p.name] = inst new_providers[p.name] = inst
new_providers[p.provider_type] = inst new_providers[f"{p.name}|{p.model_name}"] = inst
new_providers[str(p.id)] = inst
if new_providers: if new_providers:
self.providers = new_providers self.providers = new_providers
@@ -146,7 +145,7 @@ class AIRouter:
def get_providers_for_task(self, task_type: str) -> List[AIProvider]: def get_providers_for_task(self, task_type: str) -> List[AIProvider]:
rules = self.routing_rules.get( rules = self.routing_rules.get(
task_type, task_type,
{"primary": "sensenova", "fallback": ["nvidia"]}, {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
) )
ordered = [] ordered = []
seen = set() seen = set()
+29 -1
View File
@@ -283,12 +283,13 @@ async def admin_list_payments(
size: int = Query(20, ge=1, le=100), size: int = Query(20, ge=1, le=100),
gateway: str = Query(default=""), gateway: str = Query(default=""),
status: str = Query(default=""), status: str = Query(default=""),
pay_type: str = Query(default=""),
user_id: str = Query(default=""), user_id: str = Query(default=""),
_: dict = Depends(require_admin), _: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
svc = PaymentService(db) svc = PaymentService(db)
return await svc.admin_list_payments(page, size, gateway, status, user_id) return await svc.admin_list_payments(page, size, gateway, status, user_id, pay_type)
@router.get("/payments/stats") @router.get("/payments/stats")
@@ -313,3 +314,30 @@ async def admin_refund(
return await svc.admin_refund(order_no, reason) return await svc.admin_refund(order_no, reason)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@router.post("/payments/close")
async def admin_close_order(
data: dict,
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
order_no = data.get("order_no", "")
svc = PaymentService(db)
try:
return await svc.admin_close_order(order_no)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/payments/query-refund/{order_no}")
async def admin_query_refund(
order_no: str,
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
svc = PaymentService(db)
try:
return await svc.query_refund(order_no)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
+5 -3
View File
@@ -1,6 +1,8 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, Depends, HTTPException
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services.discovery import DiscoveryService from app.services.discovery import DiscoveryService
router = APIRouter() router = APIRouter()
@@ -22,10 +24,10 @@ class OutreachRequest(BaseModel):
@router.post("/search") @router.post("/search")
async def search_leads(req: SearchRequest): async def search_leads(req: SearchRequest, db: AsyncSession = Depends(get_db)):
if not req.product_description.strip(): if not req.product_description.strip():
raise HTTPException(status_code=400, detail="请填写产品描述") raise HTTPException(status_code=400, detail="请填写产品描述")
svc = DiscoveryService() svc = DiscoveryService(db=db)
try: try:
result = await svc.search(req.product_description, req.target_market) result = await svc.search(req.product_description, req.target_market)
return {"success": True, "data": result} return {"success": True, "data": result}
+39 -6
View File
@@ -4,9 +4,10 @@ from pydantic import BaseModel
from typing import Optional 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.unified_pay import UnifiedPayService
from app.api.v1.deps import get_current_user_id from app.api.v1.deps import get_current_user_id
from app.core.csrf import require_csrf_token import logging
logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@@ -40,7 +41,6 @@ async def create_order(
data: CreateOrderRequest, data: CreateOrderRequest,
user_id: str = Depends(get_current_user_id), user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_csrf: str = Depends(require_csrf_token),
): ):
svc = PaymentService(db) svc = PaymentService(db)
try: try:
@@ -78,7 +78,6 @@ async def refund(
data: RefundRequest, data: RefundRequest,
user_id: str = Depends(get_current_user_id), user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_csrf: str = Depends(require_csrf_token),
): ):
svc = PaymentService(db) svc = PaymentService(db)
try: try:
@@ -87,10 +86,43 @@ async def refund(
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@router.post("/close-order")
async def close_order(
data: dict,
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
order_no = data.get("order_no", "")
svc = PaymentService(db)
try:
return await svc.close_order(user_id, order_no)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("/query-refund/{order_no}")
async def query_refund(
order_no: str,
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
svc = PaymentService(db)
try:
return await svc.query_refund(order_no, user_id=user_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.post("/webhook") @router.post("/webhook")
async def unified_webhook(request: Request, db: AsyncSession = Depends(get_db)): async def unified_webhook(request: Request, db: AsyncSession = Depends(get_db)):
body = await request.body() body = await request.body()
body_str = body.decode("utf-8") body_str = body.decode("utf-8")
gw = UnifiedPayService()
if not gw.verify_callback(dict(request.headers), body_str):
logger.warning("Webhook verification failed")
raise HTTPException(status_code=403, detail="签名验证失败")
import json import json
try: try:
data = json.loads(body_str) data = json.loads(body_str)
@@ -103,11 +135,12 @@ async def unified_webhook(request: Request, db: AsyncSession = Depends(get_db)):
order_id = pay_data.get("order_id", "") order_id = pay_data.get("order_id", "")
transaction_id = pay_data.get("transaction_id", "") transaction_id = pay_data.get("transaction_id", "")
amount = pay_data.get("amount", 0) amount = pay_data.get("amount", 0)
success = event == "recharge.completed" success = event in ("recharge.completed", "order.refunded")
svc = PaymentService(db) svc = PaymentService(db)
await svc.handle_callback( await svc.handle_callback(
merchant_order_id, order_id, transaction_id, merchant_order_id, order_id, transaction_id,
success, amount, body_str, success if event == "recharge.completed" else True,
amount, body_str,
) )
return {"code": 0, "message": "OK"} return {"code": 0, "message": "OK"}
+2 -1
View File
@@ -23,8 +23,9 @@ CSRF_PROTECTED_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
# Endpoints that should skip CSRF protection (e.g., webhook endpoints) # Endpoints that should skip CSRF protection (e.g., webhook endpoints)
CSRF_SKIP_ENDPOINTS = [ CSRF_SKIP_ENDPOINTS = [
"/api/v1/webhook/", "/api/v1/webhook/",
"/api/v1/payment/webhook", "/api/v1/payment/",
"/api/v1/whatsapp/webhook", "/api/v1/whatsapp/webhook",
"/api/v1/ai/",
] ]
+108 -22
View File
@@ -7,6 +7,7 @@ from app.models.analytics import UsageLog
from app.models.customer import Customer from app.models.customer import Customer
from app.models.quotation import Quotation from app.models.quotation import Quotation
from app.models.system_config import SystemConfig from app.models.system_config import SystemConfig
from app.models.search_provider import SearchProvider
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
@@ -289,13 +290,13 @@ class AdminService:
async def _seed_default_configs(self): async def _seed_default_configs(self):
defaults = [ defaults = [
SystemConfig(key="ai_routing", value={ SystemConfig(key="ai_routing", value={
"translate": {"primary": "sensenova", "fallback": ["alibaba-mt", "nvidia"]}, "translate": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["阿里翻译|alibaba-mt", "NVIDIA|stepfun-ai/step-3.7-flash"]},
"reply": {"primary": "sensenova", "fallback": ["nvidia"]}, "reply": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
"marketing": {"primary": "sensenova", "fallback": ["nvidia"]}, "marketing": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
"extract": {"primary": "sensenova", "fallback": ["nvidia"]}, "extract": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
"quotation": {"primary": "sensenova", "fallback": ["nvidia"]}, "quotation": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
"chat": {"primary": "sensenova", "fallback": ["nvidia"]}, "chat": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
}, description="AI 路由规则:各任务的主选/备用供应商"), }, description="AI 路由规则:各任务的主选/备用供应商(按模型名称)"),
SystemConfig(key="feature_guest_mode", value={"enabled": True}, description="游客模式开关"), SystemConfig(key="feature_guest_mode", value={"enabled": True}, description="游客模式开关"),
SystemConfig(key="feature_wechat_login", value={"enabled": False}, description="微信登录开关"), SystemConfig(key="feature_wechat_login", value={"enabled": False}, description="微信登录开关"),
SystemConfig(key="feature_registration", value={"enabled": True}, description="新用户注册开关"), SystemConfig(key="feature_registration", value={"enabled": True}, description="新用户注册开关"),
@@ -334,21 +335,13 @@ class AdminService:
result = await self.db.execute( result = await self.db.execute(
select(SystemConfig).where(SystemConfig.key == "ai_routing") select(SystemConfig).where(SystemConfig.key == "ai_routing")
) )
if not result.scalar_one_or_none(): existing = result.scalar_one_or_none()
self.db.add(SystemConfig( if not existing:
key="ai_routing", await self._seed_ai_routing()
value={ else:
"translate": {"primary": "sensenova", "fallback": ["alibaba-mt", "nvidia"]}, await self._migrate_routing_names(existing)
"reply": {"primary": "sensenova", "fallback": ["nvidia"]},
"marketing": {"primary": "sensenova", "fallback": ["nvidia"]}, await self._seed_search_providers()
"extract": {"primary": "sensenova", "fallback": ["nvidia"]},
"quotation": {"primary": "sensenova", "fallback": ["nvidia"]},
"chat": {"primary": "sensenova", "fallback": ["nvidia"]},
},
description="AI 路由规则:各任务的主选/备用供应商",
))
await self.db.flush()
logger.info("Seeded ai_routing config")
result = await self.db.execute( result = await self.db.execute(
select(SystemConfig).order_by(SystemConfig.key) select(SystemConfig).order_by(SystemConfig.key)
@@ -364,6 +357,99 @@ class AdminService:
for c in configs for c in configs
] ]
async def _seed_ai_routing(self):
self.db.add(SystemConfig(
key="ai_routing",
value={
"translate": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["阿里翻译|alibaba-mt", "NVIDIA|stepfun-ai/step-3.7-flash"]},
"reply": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
"marketing": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
"extract": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
"quotation": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
"chat": {"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
},
description="AI 路由规则:各任务的主选/备用供应商(按模型名称)",
))
await self.db.flush()
logger.info("Seeded ai_routing config")
async def _migrate_routing_names(self, cfg):
"""Migrate routing rules from provider_type to provider name, and from name-only to name|model composite."""
type_to_name = {"sensenova": "Sensenova (商汤)", "nvidia": "NVIDIA",
"alibaba-mt": "阿里翻译", "opencode_go": "Sensenova (商汤)",
"spark": "NVIDIA", "openai": "NVIDIA",
"anthropic": "NVIDIA", "local": "NVIDIA"}
# Build name→model lookup from DB
result = await self.db.execute(
select(SearchProvider.id).limit(1) # dummy check — actually AIProvider
)
from app.models.ai_provider import AIProvider
prov_result = await self.db.execute(
select(AIProvider).where(AIProvider.enabled == True).order_by(AIProvider.priority)
)
name_to_model = {}
for p in prov_result.scalars().all():
key = p.name
if key not in name_to_model:
name_to_model[key] = p.model_name
updated = False
for task, rules in cfg.value.items():
if not isinstance(rules, dict):
continue
primary = rules.get("primary", "")
# Step 1: type → name
if primary in type_to_name:
primary = type_to_name[primary]
updated = True
# Step 2: name → name|model
if "|" not in primary and primary in name_to_model:
primary = f"{primary}|{name_to_model[primary]}"
updated = True
rules["primary"] = primary
fallback = rules.get("fallback", [])
new_fb = []
for fb in fallback:
# Step 1: type → name
if fb in type_to_name:
fb = type_to_name[fb]
updated = True
# Step 2: name → name|model
if "|" not in fb and fb in name_to_model:
fb = f"{fb}|{name_to_model[fb]}"
updated = True
new_fb.append(fb)
rules["fallback"] = new_fb
if updated:
cfg.value = dict(cfg.value)
cfg.updated_at = datetime.utcnow()
await self.db.flush()
logger.info("Migrated ai_routing to composite name|model keys")
async def _seed_search_providers(self):
result = await self.db.execute(
select(func.count(SearchProvider.id))
)
if result.scalar() > 0:
return
import uuid
defaults = [
SearchProvider(id=uuid.uuid4(), name="Bing Search", provider_type="bing",
api_key="", api_endpoint=None, extra_config={},
priority=0, enabled=True),
SearchProvider(id=uuid.uuid4(), name="Google CSE", provider_type="google_cse",
api_key="", api_endpoint=None,
extra_config={"cx": ""},
priority=1, enabled=False),
]
for p in defaults:
self.db.add(p)
await self.db.flush()
logger.info("Seeded %d default search providers", len(defaults))
async def update_config(self, key: str, value: Any) -> Optional[Dict[str, Any]]: async def update_config(self, key: str, value: Any) -> Optional[Dict[str, Any]]:
result = await self.db.execute( result = await self.db.execute(
select(SystemConfig).where(SystemConfig.key == key) select(SystemConfig).where(SystemConfig.key == key)
+16 -1
View File
@@ -1,6 +1,7 @@
import json import json
import logging import logging
from typing import Dict, Any, Optional, Union from typing import Dict, Any, Optional, Union
from sqlalchemy.ext.asyncio import AsyncSession
from app.ai.router import get_ai_router from app.ai.router import get_ai_router
from app.services.search_web import search_companies, fetch_page_text from app.services.search_web import search_companies, fetch_page_text
@@ -29,10 +30,11 @@ ANALYZE_MATCH_PROMPT = """你是外贸客户分析专家。分析目标公司的
class DiscoveryService: class DiscoveryService:
def __init__(self): def __init__(self, db: Optional[AsyncSession] = None):
ai_router = get_ai_router() ai_router = get_ai_router()
self.ai = ai_router self.ai = ai_router
self._ai_available = len(ai_router.providers) > 0 self._ai_available = len(ai_router.providers) > 0
self.db = db
async def search(self, product_description: str, target_market: str) -> Dict[str, Any]: async def search(self, product_description: str, target_market: str) -> Dict[str, Any]:
queries = self._build_queries(product_description, target_market) queries = self._build_queries(product_description, target_market)
@@ -124,6 +126,18 @@ URL: {company_url}
return self._template_outreach(company_info, product_info) return self._template_outreach(company_info, product_info)
async def _web_search_all(self, queries: list) -> dict: async def _web_search_all(self, queries: list) -> dict:
# Try DB-managed search providers first
if self.db:
try:
from app.services.search import SearchService
svc = SearchService(self.db)
db_results = await svc.search(queries[0], limit=15)
if db_results:
return {"results": self._dedup_and_filter(db_results)[:15], "provider": "db_search"}
except Exception as e:
logger.warning(f"DB search failed: {e}")
# Fallback: hardcoded Bing + 360 scraper
try: try:
results = await search_bing_batch(queries[:3], max_per_query=4) results = await search_bing_batch(queries[:3], max_per_query=4)
if results: if results:
@@ -131,6 +145,7 @@ URL: {company_url}
except Exception as e: except Exception as e:
logger.warning(f"Bing batch search failed: {e}") logger.warning(f"Bing batch search failed: {e}")
# Fallback: Google CSE from env vars
results = await search_companies(queries[0], max_results=10) results = await search_companies(queries[0], max_results=10)
if results: if results:
return {"results": results[:15], "provider": "google_cse"} return {"results": results[:15], "provider": "google_cse"}
+63 -2
View File
@@ -1,3 +1,4 @@
import json
import logging import logging
import hashlib import hashlib
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
@@ -109,9 +110,11 @@ class PaymentService:
order_no = gen_order_no(user_id) order_no = gen_order_no(user_id)
description = PLAN_DESCRIPTIONS.get(plan, f"TradeMate {plan}") description = PLAN_DESCRIPTIONS.get(plan, f"TradeMate {plan}")
remark = json.dumps({"uid": user_id, "oid": order_no}, ensure_ascii=False, separators=(",", ":"))
gw = get_gateway(pay_type) gw = get_gateway(pay_type)
gw_result = await gw.create_order(order_no, int(plan_info["price"] * 100), gw_result = await gw.create_order(order_no, int(plan_info["price"] * 100),
description, pay_type=pay_type) description, pay_type=pay_type, remark=remark)
sub = Subscription( sub = Subscription(
user_id=user_id, plan=plan, status="pending", user_id=user_id, plan=plan, status="pending",
@@ -208,6 +211,45 @@ class PaymentService:
"created_at": txn.created_at.isoformat(), "created_at": txn.created_at.isoformat(),
} }
async def close_order(self, user_id: str, order_no: str) -> Dict[str, Any]:
result = await self.db.execute(
select(PaymentTransaction).where(
PaymentTransaction.order_no == order_no,
PaymentTransaction.user_id == user_id,
)
)
txn = result.scalar_one_or_none()
if not txn:
raise ValueError("订单不存在")
if txn.status != "pending":
raise ValueError("只有待支付订单可关闭")
gw = get_gateway(txn.pay_type)
await gw.close_order(order_no)
txn.status = "closed"
await self.db.flush()
return {"status": "ok", "order_no": order_no}
async def query_refund(self, order_no: str, user_id: str = "") -> Dict[str, Any]:
query = select(PaymentTransaction).where(PaymentTransaction.order_no == order_no)
if user_id:
query = query.where(PaymentTransaction.user_id == user_id)
result = await self.db.execute(query)
txn = result.scalar_one_or_none()
if not txn:
raise ValueError("订单不存在")
if txn.status != "refunded":
raise ValueError("该订单未退款")
gw = get_gateway(txn.pay_type)
gw_result = await gw.query_refund(order_no)
return {
"order_no": order_no,
"status": txn.status,
"refund_amount": txn.refund_amount,
"refund_reason": txn.refund_reason,
"refunded_at": txn.refunded_at.isoformat() if txn.refunded_at else None,
"gateway": gw_result,
}
async def list_transactions(self, user_id: str, async def list_transactions(self, user_id: str,
page: int = 1, size: int = 20) -> Dict[str, Any]: page: int = 1, size: int = 20) -> Dict[str, Any]:
query = select(PaymentTransaction).where( query = select(PaymentTransaction).where(
@@ -277,7 +319,8 @@ class PaymentService:
async def admin_list_payments(self, page: int = 1, size: int = 20, async def admin_list_payments(self, page: int = 1, size: int = 20,
gateway: str = "", status: str = "", gateway: str = "", status: str = "",
user_id: str = "") -> Dict[str, Any]: user_id: str = "",
pay_type: str = "") -> Dict[str, Any]:
query = select(PaymentTransaction).order_by(desc(PaymentTransaction.created_at)) query = select(PaymentTransaction).order_by(desc(PaymentTransaction.created_at))
count_query = select(PaymentTransaction.id) count_query = select(PaymentTransaction.id)
if gateway: if gateway:
@@ -289,6 +332,9 @@ class PaymentService:
if user_id: if user_id:
query = query.where(PaymentTransaction.user_id == user_id) query = query.where(PaymentTransaction.user_id == user_id)
count_query = count_query.where(PaymentTransaction.user_id == user_id) count_query = count_query.where(PaymentTransaction.user_id == user_id)
if pay_type:
query = query.where(PaymentTransaction.pay_type == pay_type)
count_query = count_query.where(PaymentTransaction.pay_type == pay_type)
total_result = await self.db.execute(count_query) total_result = await self.db.execute(count_query)
total = len(total_result.scalars().all()) total = len(total_result.scalars().all())
@@ -348,6 +394,21 @@ class PaymentService:
return {"status": "ok", "order_no": order_no, "refund_amount": txn.amount, return {"status": "ok", "order_no": order_no, "refund_amount": txn.amount,
"user_id": str(txn.user_id)} "user_id": str(txn.user_id)}
async def admin_close_order(self, order_no: str) -> Dict[str, Any]:
result = await self.db.execute(
select(PaymentTransaction).where(PaymentTransaction.order_no == order_no)
)
txn = result.scalar_one_or_none()
if not txn:
raise ValueError("订单不存在")
if txn.status != "pending":
raise ValueError("只有待支付订单可关闭")
gw = get_gateway(txn.pay_type)
await gw.close_order(order_no)
txn.status = "closed"
await self.db.flush()
return {"status": "ok", "order_no": order_no}
async def admin_payment_stats(self) -> Dict[str, Any]: async def admin_payment_stats(self) -> Dict[str, Any]:
all_txns = await self.db.execute(select(PaymentTransaction)) all_txns = await self.db.execute(select(PaymentTransaction))
rows = all_txns.scalars().all() rows = all_txns.scalars().all()
+3
View File
@@ -30,6 +30,9 @@ class PaymentGateway(ABC):
def parse_callback(self, body: str, headers: dict) -> Dict[str, Any]: def parse_callback(self, body: str, headers: dict) -> Dict[str, Any]:
... ...
async def close_order(self, order_no: str) -> Dict[str, Any]:
raise NotImplementedError
def supports(self, pay_type: str) -> bool: def supports(self, pay_type: str) -> bool:
return pay_type in self.supported_types return pay_type in self.supported_types
+34
View File
@@ -41,6 +41,13 @@ class SearchService:
return await searxng_search(provider.api_endpoint, query, limit) return await searxng_search(provider.api_endpoint, query, limit)
elif pt == "bing": elif pt == "bing":
return await bing_search(provider.api_key, query, limit) return await bing_search(provider.api_key, query, limit)
elif pt == "google_cse":
return await google_cse_search(
api_key=provider.api_key,
cx=provider.extra_config.get("cx", "") if provider.extra_config else "",
query=query,
limit=limit,
)
else: else:
raise ValueError(f"Unknown provider type: {pt}") raise ValueError(f"Unknown provider type: {pt}")
@@ -100,3 +107,30 @@ async def bing_search(api_key: Optional[str], query: str, limit: int) -> List[Di
break break
return results return results
async def google_cse_search(api_key: Optional[str], cx: Optional[str], query: str, limit: int) -> List[Dict[str, str]]:
if not api_key or not cx:
raise ValueError("Google CSE API key or CX not configured")
import httpx
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(
"https://www.googleapis.com/customsearch/v1",
params={"key": api_key, "cx": cx, "q": query, "num": min(limit, 10), "lr": "lang_en"},
)
if resp.status_code != 200:
raise ValueError(f"Google CSE returned {resp.status_code}")
data = resp.json()
results = []
for item in data.get("items", []):
url = item.get("link", "")
if any(d in url for d in IGNORE_DOMAINS):
continue
results.append({
"title": (item.get("title") or url)[:100],
"url": url.rstrip("/"),
"snippet": (item.get("snippet") or "")[:200],
})
if len(results) >= limit:
break
return results
+30
View File
@@ -64,6 +64,7 @@ class UnifiedPayService(PaymentGateway):
payment_method = "wechat" payment_method = "wechat"
elif payment_method == "pc": elif payment_method == "pc":
payment_method = "alipay" payment_method = "alipay"
remark = kwargs.get("remark", "")
body = { body = {
"merchant_order_id": order_no, "merchant_order_id": order_no,
"amount": amount / 100, "amount": amount / 100,
@@ -71,6 +72,8 @@ class UnifiedPayService(PaymentGateway):
"subject": description or "TradeMate 会员充值", "subject": description or "TradeMate 会员充值",
"notify_url": self.webhook_url, "notify_url": self.webhook_url,
} }
if remark:
body["remark"] = remark
result = await self._request("POST", "/v1/pay/orders", body) result = await self._request("POST", "/v1/pay/orders", body)
out = { out = {
"gateway_order_id": result.get("gateway_order_id", ""), "gateway_order_id": result.get("gateway_order_id", ""),
@@ -100,6 +103,30 @@ class UnifiedPayService(PaymentGateway):
return await self._request("GET", f"/v1/pay/refunds/{order_no}") return await self._request("GET", f"/v1/pay/refunds/{order_no}")
def verify_callback(self, headers: dict, body: str) -> bool: def verify_callback(self, headers: dict, body: str) -> bool:
auth = headers.get("authorization", headers.get("Authorization", ""))
if not auth.startswith("PAY "):
logger.warning("Webhook missing PAY Authorization header")
return False
parts = auth[4:].strip().split(":")
if len(parts) != 3:
logger.warning("Webhook invalid Authorization format")
return False
api_key, timestamp, signature = parts
if api_key != self.api_key:
logger.warning("Webhook API key mismatch")
return False
now = int(time.time())
if abs(now - int(timestamp)) > 300:
logger.warning("Webhook timestamp expired")
return False
body_sha256 = hashlib.sha256(body.encode()).hexdigest()
sign_str = f"POST\n/api/v1/payment/webhook\n{timestamp}\n{body_sha256}"
expected = hmac.new(
self.api_secret.encode(), sign_str.encode(), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, signature):
logger.warning("Webhook signature mismatch")
return False
return True return True
def parse_callback(self, body: str, headers: dict) -> Dict[str, Any]: def parse_callback(self, body: str, headers: dict) -> Dict[str, Any]:
@@ -115,3 +142,6 @@ class UnifiedPayService(PaymentGateway):
"success": event == "recharge.completed", "success": event == "recharge.completed",
"raw": payload, "raw": payload,
} }
async def close_order(self, order_no: str) -> Dict[str, Any]:
return await self._request("POST", f"/v1/pay/orders/{order_no}/close")
+11601 -69
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -6,13 +6,16 @@
"dev:mp-weixin": "uni -p mp-weixin", "dev:mp-weixin": "uni -p mp-weixin",
"build:mp-weixin": "uni build -p mp-weixin", "build:mp-weixin": "uni build -p mp-weixin",
"dev:h5": "uni", "dev:h5": "uni",
"build:h5": "uni build" "build:h5": "uni build",
"upload": "node scripts/upload.js",
"preview": "node scripts/upload.js preview"
}, },
"dependencies": { "dependencies": {
"@dcloudio/uni-app": "3.0.0-4010520240507001", "@dcloudio/uni-app": "3.0.0-4010520240507001",
"@dcloudio/uni-components": "3.0.0-4010520240507001", "@dcloudio/uni-components": "3.0.0-4010520240507001",
"@dcloudio/uni-h5": "3.0.0-4010520240507001", "@dcloudio/uni-h5": "3.0.0-4010520240507001",
"@dcloudio/uni-mp-weixin": "3.0.0-4010520240507001", "@dcloudio/uni-mp-weixin": "3.0.0-4010520240507001",
"miniprogram-ci": "^2.1.31",
"vue": "3.4.21" "vue": "3.4.21"
}, },
"devDependencies": { "devDependencies": {
+59
View File
@@ -0,0 +1,59 @@
const ci = require('miniprogram-ci')
const path = require('path')
const APPID = process.env.WX_APPID || 'wxdad62baf4ccd09e3'
const KEY_PATH = process.env.WX_PRIVATE_KEY_PATH || path.resolve(__dirname, '../private.key')
const VERSION = process.env.WX_VERSION || '1.0.6'
// miniprogram-ci 2.x 已知 bug:上传完成后进程残留 setTimeout 无法自动退出
// 设置强制退出计时器,防止进程卡死
const FORCE_EXIT_MS = 600_000
let forceExitTimer = setTimeout(() => {
console.error('Upload timed out, forcing exit')
process.exit(1)
}, FORCE_EXIT_MS)
async function main() {
const project = new ci.Project({
appid: APPID,
type: 'miniProgram',
projectPath: path.resolve(__dirname, '../dist/build/mp-weixin/'),
privateKeyPath: KEY_PATH,
ignores: ['node_modules/**/*'],
})
const action = process.argv[2] || 'upload'
if (action === 'preview') {
const qrcodeDest = path.resolve(__dirname, '../dist/qrcode.jpg')
await ci.preview({
project,
version: VERSION,
desc: process.env.WX_DESC || '自动预览',
setting: { minify: true, es6: true, autoPrefixWXSS: true },
qrcodeFormat: 'image',
qrcodeOutputDest: qrcodeDest,
onProgressUpdate: console.log,
})
console.log('Preview QR:', qrcodeDest)
} else {
console.log(`Uploading v${VERSION} ...`)
const result = await ci.upload({
project,
version: VERSION,
desc: process.env.WX_DESC || '自动构建上传',
setting: { minify: true, es6: true, autoPrefixWXSS: true },
onProgressUpdate: console.log,
})
console.log('Upload done:', JSON.stringify(result))
}
}
main().then(() => {
clearTimeout(forceExitTimer)
process.exit(0)
}).catch(e => {
console.error('Upload failed:', e.message)
clearTimeout(forceExitTimer)
process.exit(1)
})
+4
View File
@@ -6,8 +6,12 @@
</script> </script>
<style> <style>
/* #ifdef H5 */
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
/* #endif */
html, body, #app { height: 100%; width: 100%; } html, body, #app { height: 100%; width: 100%; }
/* #ifdef H5 */
uni-page { overflow-y: auto !important; } uni-page { overflow-y: auto !important; }
uni-page-body { overflow-y: auto !important; min-height: 100% !important; } uni-page-body { overflow-y: auto !important; min-height: 100% !important; }
/* #endif */
</style> </style>
+1 -1
View File
@@ -25,7 +25,7 @@
}, },
"quickapp": {}, "quickapp": {},
"mp-weixin": { "mp-weixin": {
"appid": "", "appid": "wxdad62baf4ccd09e3",
"setting": { "setting": {
"urlCheck": false, "urlCheck": false,
"es6": true, "es6": true,
+2 -43
View File
@@ -277,63 +277,22 @@
</view> </view>
<view class="footer"> <view class="footer">
<view class="footer-qrcode">
<view class="qrcode-item">
<image src="/static/images/yzr/yuzhiran.jpg" class="qrcode-img" mode="aspectFill" />
<text class="qrcode-label">公众号</text>
</view>
<view class="qrcode-item">
<image src="/static/images/yzr/yuzhiran-tech.jpg" class="qrcode-img" mode="aspectFill" />
<text class="qrcode-label">服务号</text>
</view>
<view class="qrcode-item">
<image src="/static/images/yzr/yuzhiran-yhl.jpg" class="qrcode-img" mode="aspectFill" />
<text class="qrcode-label">小程序</text>
</view>
<view class="qrcode-item">
<image src="/static/images/yzr/kefu.png" class="qrcode-img" mode="aspectFill" />
<text class="qrcode-label">客服</text>
</view>
</view>
<view class="footer-links"> <view class="footer-links">
<text class="footer-link" @click="goToPage(PAGES.AGREEMENT_PRIVACY)">隐私政策</text> <text class="footer-link" @click="goToPage(PAGES.AGREEMENT_PRIVACY)">隐私政策</text>
<text class="footer-divider">|</text> <text class="footer-divider">|</text>
<text class="footer-link" @click="goToPage(PAGES.AGREEMENT_TERMS)">用户协议</text> <text class="footer-link" @click="goToPage(PAGES.AGREEMENT_TERMS)">用户协议</text>
</view> </view>
<view class="footer-beian">
<a class="footer-beian-link" :href="beianUrl" target="_blank">{{ beianIcp }}</a>
<text class="footer-divider">|</text>
<a class="footer-beian-link" :href="beianPsbUrl" target="_blank">{{ beianPsb }}</a>
</view>
<text class="footer-copyright">© {{ copyrightYear }} 北京宇之然科技中心. 保留所有权利.</text>
</view> </view>
<AiAssistant /> <AiAssistant />
</view> </view>
</template> </template>
<script setup> <script setup>
import { ref, computed, onUnmounted } from 'vue' import { ref, onUnmounted } from 'vue'
import { onShow } from '@dcloudio/uni-app' import { onShow } from '@dcloudio/uni-app'
import { authApi, customerApi, analyticsApi, onboardingApi, notificationApi, followupApi, translateApi, BASE_URL } from '@/utils/api.js' import { authApi, customerApi, analyticsApi, onboardingApi, notificationApi, followupApi, translateApi, BASE_URL } from '@/utils/api.js'
import AiAssistant from '@/components/ai-assistant.vue' import AiAssistant from '@/components/ai-assistant.vue'
import { STORAGE_KEYS, PAGES, EXTERNAL_URLS, APP_INFO, EXTRACT_FIELD_LABELS } from '@/config.js' import { STORAGE_KEYS, PAGES, EXTRACT_FIELD_LABELS } from '@/config.js'
const beianInfo = computed(() => {
let hostname = ''
try { hostname = window.location.hostname } catch {}
if (hostname === 'yuzhiran.com' || hostname === 'www.yuzhiran.com') {
return { icp: '京ICP备2026007249号-1', psb: '京公网安备11011502039545号', psbUrl: 'https://beian.mps.gov.cn/#/query/webSearch?code=11011502039545' }
}
if (hostname === 'yuzhiran.com.cn' || hostname === 'www.yuzhiran.com.cn') {
return { icp: '京ICP备2026007249号-2', psb: '京公网安备11011502039622号', psbUrl: 'https://beian.mps.gov.cn/#/query/webSearch?code=11011502039622' }
}
return { icp: APP_INFO.ICP, psb: APP_INFO.PSB, psbUrl: EXTERNAL_URLS.BEIAN_PSB }
})
const beianUrl = computed(() => EXTERNAL_URLS.BEIAN)
const beianIcp = computed(() => beianInfo.value.icp)
const beianPsb = computed(() => beianInfo.value.psb)
const beianPsbUrl = computed(() => beianInfo.value.psbUrl)
const copyrightYear = computed(() => new Date().getFullYear())
const showAnnouncement = ref(false) const showAnnouncement = ref(false)
const currentAnnouncement = ref(0) const currentAnnouncement = ref(0)
+54 -12
View File
@@ -49,21 +49,31 @@
<text class="menu-text">意见反馈</text> <text class="menu-text">意见反馈</text>
<text class="menu-arrow"></text> <text class="menu-arrow"></text>
</view> </view>
<view class="menu-item" @click="goAgreement('privacy')"> </view>
<text class="menu-icon">📄</text>
<text class="menu-text">隐私政策</text> <view class="section">
<text class="menu-arrow"></text> <view class="section-title">关于我们</view>
<view class="about-item">
<text class="about-label">版本</text>
<text class="about-value">1.0.0</text>
</view> </view>
<view class="menu-item" @click="goAgreement('terms')"> <view class="about-item" @click="goAgreement('privacy')">
<text class="menu-icon">📋</text> <text class="about-label">隐私政策</text>
<text class="menu-text">用户协议</text> <text class="about-arrow"></text>
<text class="menu-arrow"></text>
</view> </view>
<view class="menu-item"> <view class="about-item" @click="goAgreement('terms')">
<text class="menu-icon"></text> <text class="about-label">用户协议</text>
<text class="menu-text">版本</text> <text class="about-arrow"></text>
<text class="menu-value">1.0.0</text>
</view> </view>
<view class="about-item">
<text class="about-label">ICP 备案</text>
<text class="about-value">京ICP备2026007249号-1</text>
</view>
<view class="about-item">
<text class="about-label">公安备案</text>
<text class="about-value">京公网安备11011502039545号</text>
</view>
<view class="about-copyright">© 2026 北京宇之然科技中心. 保留所有权利.</view>
</view> </view>
<view class="logout-btn" v-if="user.tier !== 'guest'" @click="logout">退出登录</view> <view class="logout-btn" v-if="user.tier !== 'guest'" @click="logout">退出登录</view>
@@ -412,4 +422,36 @@ onShow(loadUser)
background: #1890ff; background: #1890ff;
color: #fff; color: #fff;
} }
.about-item {
display: flex;
align-items: center;
padding: 24rpx 30rpx;
border-bottom: 2rpx solid #f0f0f0;
}
.about-item:last-child { border-bottom: none; }
.about-label {
flex: 1;
font-size: 26rpx;
color: #666;
}
.about-value {
font-size: 24rpx;
color: #999;
}
.about-arrow {
font-size: 32rpx;
color: #ccc;
}
.about-copyright {
text-align: center;
padding: 24rpx 30rpx;
font-size: 22rpx;
color: #bbb;
}
</style> </style>
+7 -1
View File
@@ -1,6 +1,12 @@
import { STORAGE_KEYS, PAGES } from '@/config.js' import { STORAGE_KEYS, PAGES } from '@/config.js'
export const BASE_URL = '/api/v1' // #ifdef MP-WEIXIN
const API_HOST = 'https://trade.yuzhiran.com'
// #endif
// #ifndef MP-WEIXIN
const API_HOST = ''
// #endif
export const BASE_URL = `${API_HOST}/api/v1`
const getAuthHeader = () => { const getAuthHeader = () => {
const token = uni.getStorageSync(STORAGE_KEYS.TOKEN) const token = uni.getStorageSync(STORAGE_KEYS.TOKEN)
+1 -1
View File
@@ -102,7 +102,7 @@ export function markAllRead() { return http.post('/notifications/read-all') }
export function getPlans() { return http.get('/payment/plans') } export function getPlans() { return http.get('/payment/plans') }
export function getSubscription() { return http.get('/payment/subscription') } export function getSubscription() { return http.get('/payment/subscription') }
export function createOrder(planId) { return http.post('/payment/create-order', { plan_id: planId }) } export function createOrder(plan, payType = 'alipay') { return http.post('/payment/create-order', { plan, pay_type: payType }) }
export function submitCertification(data) { return http.post('/certification/submit', data) } export function submitCertification(data) { return http.post('/certification/submit', data) }
export function getCertificationStatus() { return http.get('/certification/status') } export function getCertificationStatus() { return http.get('/certification/status') }
+9 -40
View File
@@ -67,37 +67,13 @@
<footer class="footer"> <footer class="footer">
<div class="footer-content"> <div class="footer-content">
<div class="footer-section"> <p>&copy; {{ new Date().getFullYear() }} TradeMate</p>
<div class="footer-brand">TradeMate</div> <div class="footer-links">
<p class="footer-tagline">AI 外贸小助手 · 让外贸更简单</p> <a href="http://beian.miit.gov.cn/" target="_blank">{{ beianInfo.icp }}</a>
<div class="qrcode-row"> <a v-if="beianInfo.showGongan" :href="beianInfo.gonganLink" target="_blank" rel="noreferrer" class="gongan-link">
<div class="qrcode-item"> <img src="/images/beian/gongan-beian.png" alt="公安备案" class="gongan-icon" />
<img src="/images/yzr/yuzhiran.jpg" alt="微信公众号" class="qrcode-img" /> {{ beianInfo.gongan }}
<span>微信公众号</span> </a>
</div>
<div class="qrcode-item">
<img src="/images/yzr/yuzhiran-tech.jpg" alt="微信服务号" class="qrcode-img" />
<span>微信服务号</span>
</div>
<div class="qrcode-item">
<img src="/images/yzr/yuzhiran-yhl.jpg" alt="小程序" class="qrcode-img" />
<span>小程序</span>
</div>
<div class="qrcode-item">
<img src="/images/yzr/kefu.png" alt="客服" class="qrcode-img" />
<span>微信客服</span>
</div>
</div>
</div>
<div class="footer-bottom">
<p>&copy; {{ new Date().getFullYear() }} TradeMate 外贸小助手. 保留所有权利.</p>
<div class="footer-links">
<a href="http://beian.miit.gov.cn/" target="_blank">{{ beianInfo.icp }}</a>
<a v-if="beianInfo.showGongan" :href="beianInfo.gonganLink" target="_blank" rel="noreferrer" class="gongan-link">
<img src="/images/beian/gongan-beian.png" alt="公安备案" class="gongan-icon" />
{{ beianInfo.gongan }}
</a>
</div>
</div> </div>
</div> </div>
</footer> </footer>
@@ -160,15 +136,8 @@ function handleLogout() {
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; flex-shrink: 0; } .topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.notif-badge :deep(.el-badge__content) { top: 8px; right: 4px; } .notif-badge :deep(.el-badge__content) { top: 8px; right: 4px; }
.content { flex: 1; padding: 24px; overflow-y: auto; background: #f5f5f5; } .content { flex: 1; padding: 24px; overflow-y: auto; background: #f5f5f5; }
.footer { text-align: center; color: #999; font-size: 12px; border-top: 1px solid #e8e8e8; background: #fff; flex-shrink: 0; } .footer { text-align: center; color: #999; font-size: 12px; border-top: 1px solid #e8e8e8; background: #fff; flex-shrink: 0; padding: 8px 24px; }
.footer-content { padding: 16px 24px 12px; } .footer-content { display: flex; justify-content: center; align-items: center; gap: 14px; flex-wrap: wrap; }
.footer-section { margin-bottom: 12px; }
.footer-brand { font-size: 14px; font-weight: 700; color: #1890ff; margin-bottom: 2px; }
.footer-tagline { color: #999; font-size: 11px; margin-bottom: 10px; }
.qrcode-row { display: flex; gap: 14px; justify-content: center; flex-wrap: wrap; }
.qrcode-item { display: flex; flex-direction: column; align-items: center; gap: 3px; color: #999; font-size: 10px; }
.qrcode-img { width: 44px; height: 44px; border-radius: 6px; }
.footer-bottom { border-top: 1px solid #eee; padding-top: 10px; display: flex; justify-content: center; align-items: center; gap: 14px; flex-wrap: wrap; }
.footer-links { display: flex; gap: 14px; align-items: center; } .footer-links { display: flex; gap: 14px; align-items: center; }
.footer-links a { color: #999; text-decoration: none; } .footer-links a { color: #999; text-decoration: none; }
.footer-links a:hover { color: #1890ff; } .footer-links a:hover { color: #1890ff; }
+76 -9
View File
@@ -23,17 +23,51 @@
<div style="text-align:center;margin-top:16px"> <div style="text-align:center;margin-top:16px">
<el-button v-if="p.id === currentPlan" type="default" disabled>当前套餐</el-button> <el-button v-if="p.id === currentPlan" type="default" disabled>当前套餐</el-button>
<el-button v-else-if="p.id === 'free'" @click="handleFree">当前套餐</el-button> <el-button v-else-if="p.id === 'free'" @click="handleFree">当前套餐</el-button>
<el-button v-else type="primary" :loading="loadingId === p.id" @click="upgrade(p.id)">{{ p.price === 0 ? '当前套餐' : '升级' }}</el-button> <el-button v-else type="primary" :loading="loadingId === p.id" @click="showPayDialog(p.id)">{{ p.price === 0 ? '当前套餐' : '升级' }}</el-button>
</div> </div>
</el-card> </el-card>
</el-col> </el-col>
</el-row> </el-row>
<el-empty v-if="!plans.length" description="暂无套餐信息" /> <el-empty v-if="!plans.length" description="暂无套餐信息" />
<el-dialog v-model="payDialog.visible" title="选择支付方式" width="400px" :close-on-click-modal="false">
<div style="text-align:center;padding:20px 0" v-if="!payDialog.orderCreated">
<el-radio-group v-model="payDialog.payType" style="margin-bottom:24px">
<el-radio-button value="alipay">
<span style="display:flex;align-items:center;gap:6px;padding:0 20px">
<svg viewBox="0 0 24 24" width="20" height="20" fill="#1677ff"><path d="M21.422 15.358c-3.22-1.386-6.847-2.408-10.564-2.828 1.102-2.279 2.38-4.49 3.735-6.59H9.878c-.185-.413-.262-.912-.04-1.436.454-1.072 1.92-1.348 1.92-1.348s.162-.09.026-.207c-.137-.117-1.866-.313-2.666-.363-2.348-.155-4.99.22-5.733 1.181-1.14 1.48.067 2.925.401 3.337.337.412 1.256.498 1.256.498s-1.466.536-1.992 1.2c-.525.665-.264 1.383.13 1.664.394.281.756.388 1.07.482.707.21 1.818.431 2.795.555 1.454.184 2.957.1 4.312-.184 1.408-2.06 2.83-4.017 4.285-5.907l3.192 1.558c.289.142.66.028.827-.256a.63.63 0 0 0-.086-.74L15.734 7.56c.7-.878 1.426-1.727 2.18-2.537 1.938-2.083 4.298-3.876 6.377-4.707a12.29 12.29 0 0 0-6.648-1.99c-6.427 0-11.66 4.996-11.66 11.116 0 1.49.294 2.913.825 4.215-.374.314-.707.674-.99 1.075-2.316 3.277-.477 6.101 1.046 7.247 1.518 1.144 4.464 1.772 7.155.875 2.798-.93 5.256-3.103 6.822-5.531 1.654-2.563 2.549-5.435 2.549-8.367a12.9 12.9 0 0 0-.316-2.81c-1.178-.022-3.226.306-5.354 1.522z"/></svg>
支付宝
</span>
</el-radio-button>
<el-radio-button value="wechat">
<span style="display:flex;align-items:center;gap:6px;padding:0 20px">
<svg viewBox="0 0 24 24" width="20" height="20" fill="#07c160"><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.271.271 0 0 0 .14.045c.134 0 .24-.11.24-.245 0-.06-.024-.12-.04-.178l-.325-1.233a.49.49 0 0 1 .178-.553C23.028 18.125 24 16.539 24 14.711c0-3.396-3.637-6.02-7.062-5.853zm-2.06 1.964c.535 0 .968.44.968.982a.975.975 0 0 1-.968.983.975.975 0 0 1-.969-.983c0-.542.434-.982.969-.982zm4.844 0c.535 0 .969.44.969.982a.975.975 0 0 1-.969.983.975.975 0 0 1-.968-.983c0-.542.433-.982.968-.982z"/></svg>
微信支付
</span>
</el-radio-button>
</el-radio-group>
<div>
<el-button type="primary" size="large" :loading="payDialog.loading" @click="handleUpgrade">立即支付</el-button>
</div>
</div>
<div style="text-align:center;padding:20px 0" v-else>
<div v-if="payDialog.codeUrl">
<p style="margin-bottom:16px;color:#666">请使用微信扫描下方二维码支付</p>
<img :src="payDialog.codeUrl" style="width:200px;height:200px;border:1px solid #eee;border-radius:8px" />
<p style="margin-top:12px;font-size:12px;color:#999">支付成功后自动生效</p>
</div>
<div v-else-if="payDialog.payUrl">
<p style="margin-bottom:16px;color:#666">正在跳转支付宝...</p>
<el-button type="primary" @click="openPayUrl">前往支付</el-button>
</div>
<el-button style="margin-top:16px" @click="payDialog.visible = false">关闭</el-button>
</div>
</el-dialog>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { getPlans, getSubscription, createOrder } from '@/api' import { getPlans, getSubscription, createOrder } from '@/api'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
@@ -41,6 +75,16 @@ const plans = ref([])
const currentPlan = ref(null) const currentPlan = ref(null)
const loadingId = ref(null) const loadingId = ref(null)
const payDialog = reactive({
visible: false,
planId: null,
payType: 'alipay',
loading: false,
orderCreated: false,
payUrl: '',
codeUrl: '',
})
onMounted(async () => { onMounted(async () => {
try { try {
const [plansRes, subRes] = await Promise.all([getPlans(), getSubscription().catch(() => null)]) const [plansRes, subRes] = await Promise.all([getPlans(), getSubscription().catch(() => null)])
@@ -53,14 +97,37 @@ onMounted(async () => {
} catch { /* ignore */ } } catch { /* ignore */ }
}) })
async function upgrade(planId) { function showPayDialog(planId) {
loadingId.value = planId payDialog.planId = planId
payDialog.payType = 'alipay'
payDialog.orderCreated = false
payDialog.payUrl = ''
payDialog.codeUrl = ''
payDialog.visible = true
}
async function handleUpgrade() {
payDialog.loading = true
try { try {
const res = await createOrder(planId, 'native') const res = await createOrder(payDialog.planId, payDialog.payType)
ElMessage.success('订单已创建' + (res.pay_url ? ',正在跳转支付...' : '')) payDialog.orderCreated = true
if (res.pay_url) window.open(res.pay_url) if (res.code_url) {
} catch (e) { ElMessage.error(e?.detail || '升级失败') } payDialog.codeUrl = res.code_url
finally { loadingId.value = null } } else if (res.pay_url) {
payDialog.payUrl = res.pay_url
window.open(res.pay_url)
} else {
ElMessage.success('订单已创建,请稍后查看')
}
} catch (e) {
ElMessage.error(e?.detail || '下单失败')
} finally {
payDialog.loading = false
}
}
function openPayUrl() {
if (payDialog.payUrl) window.open(payDialog.payUrl)
} }
</script> </script>