Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 15d172e825 | |||
| e5b1e7d588 | |||
| d8780a716b | |||
| 79474d8480 | |||
| a95e8b2b73 | |||
| 2a107a42f3 | |||
| 5d895ae12c | |||
| 9e9c7ac270 | |||
| 13e3992d4c | |||
| d2736d1ef6 |
@@ -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
|
||||||
@@ -94,14 +94,15 @@ alembic revision --autogenerate -m "desc"
|
|||||||
- **AI Router reload**: After modifying AI providers via admin API, call `POST /api/v1/admin/ai/reload` to refresh in-memory providers.
|
- **AI Router reload**: After modifying AI providers via admin API, call `POST /api/v1/admin/ai/reload` to refresh in-memory providers.
|
||||||
- **Payment**: Uses unified `pay-api` gateway (`UnifiedPayService`). NOT direct WeChat/Alipay integration. Credentials: `PAY_API_KEY`/`PAY_API_SECRET` from `.env`. HMAC-SHA256 auth. Webhook at `POST /api/v1/payment/webhook` skips CSRF.
|
- **Payment**: Uses unified `pay-api` gateway (`UnifiedPayService`). NOT direct WeChat/Alipay integration. Credentials: `PAY_API_KEY`/`PAY_API_SECRET` from `.env`. HMAC-SHA256 auth. Webhook at `POST /api/v1/payment/webhook` skips CSRF.
|
||||||
- **Payment gateway config**: `PAY_API_KEY`, `PAY_API_SECRET`, `PAY_API_BASE_URL`, `PAY_WEBHOOK_URL` in `config.py`. `pay_type` param: `"alipay"` (returns `pay_url`) or `"wechat"` (returns `code_url`). `UnifiedPayService` normalizes legacy `native`/`jsapi`/`pc` to `wechat`/`alipay`.
|
- **Payment gateway config**: `PAY_API_KEY`, `PAY_API_SECRET`, `PAY_API_BASE_URL`, `PAY_WEBHOOK_URL` in `config.py`. `pay_type` param: `"alipay"` (returns `pay_url`) or `"wechat"` (returns `code_url`). `UnifiedPayService` normalizes legacy `native`/`jsapi`/`pc` to `wechat`/`alipay`.
|
||||||
|
- **Stripe**: `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET` in `.env`. `StripePaymentService` via Checkout Sessions. Selected when `pay_type` is `card`/`stripe`. Webhook `POST /api/v1/payment/stripe-webhook`.
|
||||||
|
- **PayPal**: `PAYPAL_CLIENT_ID`, `PAYPAL_CLIENT_SECRET`, `PAYPAL_WEBHOOK_ID`, `PAYPAL_SANDBOX=True` in `.env`. `PayPalPaymentService` via Orders v2 API. Selected when `pay_type` is `paypal`. Webhook `POST /api/v1/payment/paypal-webhook`.
|
||||||
|
- **Credit purchase**: `POST /api/v1/credits/stripe-purchase` with `gateway: "stripe"|"paypal"` for overseas payments (USD), returns `session_url` for redirect. Gateway-agnostic: `gateway` param selects the provider.
|
||||||
- **Manual auth on some endpoints**: `keywords` and `competitor-analysis` endpoints use `authorization: str = Header(None)` instead of `Depends(get_current_user_id)`.
|
- **Manual auth on some endpoints**: `keywords` and `competitor-analysis` endpoints use `authorization: str = Header(None)` instead of `Depends(get_current_user_id)`.
|
||||||
- **MarketingService fallback**: When no AI providers initialized, returns template content instead of crashing.
|
- **MarketingService fallback**: When no AI providers initialized, returns template content instead of crashing.
|
||||||
- **Onboarding service**: calls `mkt.generate(product_info={"name": ..., ...})`, not keyword args. Check `onboarding.py` for the exact dict shape.
|
- **Onboarding service**: calls `mkt.generate(product_info={"name": ..., ...})`, not keyword args. Check `onboarding.py` for the exact dict shape.
|
||||||
- **CustomerHealthService**: `get_health_overview` endpoint must use `CustomerHealthService(db)` not `CustomerService(db)`.
|
- **CustomerHealthService**: `get_health_overview` endpoint must use `CustomerHealthService(db)` not `CustomerService(db)`.
|
||||||
- **CSRF**: Sensitive endpoints (auth/payment/profile) require `X-CSRF-Token` header. Token available via `csrf_token` cookie / `X-CSRF-Token` response header.
|
- **CSRF**: Sensitive endpoints (auth/payment/profile) require `X-CSRF-Token` header. Token available via `csrf_token` cookie / `X-CSRF-Token` response header.
|
||||||
- **AI Router reload**: After modifying AI providers via admin API, call `POST /api/v1/admin/ai/reload` to refresh in-memory providers.
|
- **AI Router reload**: After modifying AI providers via admin API, call `POST /api/v1/admin/ai/reload` to refresh in-memory providers.
|
||||||
- **Payment**: Uses unified `pay-api` gateway (`UnifiedPayService`). NOT direct WeChat/Alipay integration. Credentials: `PAY_API_KEY`/`PAY_API_SECRET` from `.env`. HMAC-SHA256 auth. Webhook at `POST /api/v1/payment/webhook` skips CSRF.
|
|
||||||
- **Payment gateway config**: `PAY_API_KEY`, `PAY_API_SECRET`, `PAY_API_BASE_URL`, `PAY_WEBHOOK_URL` in `config.py`. `pay_type` param: `"alipay"` (returns `pay_url`) or `"wechat"` (returns `code_url`). `UnifiedPayService` normalizes legacy `native`/`jsapi`/`pc` to `wechat`/`alipay`.
|
|
||||||
|
|
||||||
## Project Conventions
|
## Project Conventions
|
||||||
|
|
||||||
|
|||||||
Generated
+48
@@ -18,6 +18,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"playwright": "^1.60.0",
|
||||||
"vite": "^6.0.7"
|
"vite": "^6.0.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1706,6 +1707,53 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.60.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.15",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz",
|
||||||
|
|||||||
@@ -9,16 +9,17 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.5.13",
|
|
||||||
"vue-router": "^4.5.0",
|
|
||||||
"element-plus": "^2.9.1",
|
|
||||||
"@element-plus/icons-vue": "^2.3.1",
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"element-plus": "^2.9.1",
|
||||||
"pinia": "^2.3.0",
|
"pinia": "^2.3.0",
|
||||||
"dayjs": "^1.11.13"
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"playwright": "^1.60.0",
|
||||||
"vite": "^6.0.7"
|
"vite": "^6.0.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,4 +58,26 @@ 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 function listCreditPackages() { return http.get('/admin/credit-packages') }
|
||||||
|
export function createCreditPackage(data) { return http.post('/admin/credit-packages', data) }
|
||||||
|
export function updateCreditPackage(id, data) { return http.put(`/admin/credit-packages/${id}`, data) }
|
||||||
|
export function deleteCreditPackage(id) { return http.delete(`/admin/credit-packages/${id}`) }
|
||||||
|
|
||||||
|
export function listSubscriptionPlans() { return http.get('/admin/subscription-plans') }
|
||||||
|
export function createSubscriptionPlan(data) { return http.post('/admin/subscription-plans', data) }
|
||||||
|
export function updateSubscriptionPlan(id, data) { return http.put(`/admin/subscription-plans/${id}`, data) }
|
||||||
|
export function deleteSubscriptionPlan(id) { return http.delete(`/admin/subscription-plans/${id}`) }
|
||||||
|
|
||||||
|
export function listUserCredits(page = 1, size = 20) { return http.get('/admin/user-credits', { params: { page, size } }) }
|
||||||
|
export function adjustUserCredits(userId, credits, reason = '') {
|
||||||
|
return http.post('/admin/user-credits/adjust', { user_id: userId, credits, reason })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listCreditConsumptions(params) { return http.get('/admin/credit-consumptions', { params }) }
|
||||||
|
export function getCreditStats() { return http.get('/admin/credit-stats') }
|
||||||
|
|
||||||
export default http
|
export default http
|
||||||
|
|||||||
@@ -26,6 +26,14 @@
|
|||||||
<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="/credits">
|
||||||
|
<el-icon><Coin /></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 +101,13 @@
|
|||||||
|
|
||||||
<el-footer class="footer">
|
<el-footer class="footer">
|
||||||
<div class="footer-content">
|
<div class="footer-content">
|
||||||
<div class="footer-section">
|
<p>© {{ 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>© {{ 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 +156,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; }
|
||||||
|
|||||||
@@ -36,6 +36,22 @@ 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: '/credits',
|
||||||
|
component: AdminLayout,
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
children: [
|
||||||
|
{ path: '', name: 'Credits', component: () => import('@/views/CreditManagement.vue'), meta: { title: '信用管理' } },
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/config',
|
path: '/config',
|
||||||
component: AdminLayout,
|
component: AdminLayout,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,395 @@
|
|||||||
|
<template>
|
||||||
|
<div class="credit-management">
|
||||||
|
<el-tabs v-model="activeTab">
|
||||||
|
<el-tab-pane label="消费速率" name="rates">
|
||||||
|
<el-card>
|
||||||
|
<el-alert title="各功能信用消耗速率" type="info" :closable="false" show-icon style="margin-bottom:16px" />
|
||||||
|
<el-table :data="rates" border stripe>
|
||||||
|
<el-table-column prop="feature" label="功能" width="200" />
|
||||||
|
<el-table-column prop="credits" label="消耗次数" width="120" />
|
||||||
|
<el-table-column prop="description" label="说明" />
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="次数包管理" name="packages">
|
||||||
|
<el-card>
|
||||||
|
<div style="margin-bottom:12px">
|
||||||
|
<el-button type="primary" @click="showPackageDialog = true">添加次数包</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="packages" border stripe v-loading="loading.packages">
|
||||||
|
<el-table-column prop="name" label="名称" width="120" />
|
||||||
|
<el-table-column prop="name_en" label="英文名" width="120" />
|
||||||
|
<el-table-column prop="credits" label="次数" width="80" />
|
||||||
|
<el-table-column prop="price" label="价格(¥)" width="100" />
|
||||||
|
<el-table-column prop="price_usd" label="价格($)" width="100" />
|
||||||
|
<el-table-column prop="original_price" label="原价(¥)" width="100" />
|
||||||
|
<el-table-column prop="is_active" label="启用" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.is_active ? 'success' : 'info'">{{ row.is_active ? '是' : '否' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" @click="editPackage(row)">编辑</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="deletePackage(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="订阅套餐" name="plans">
|
||||||
|
<el-card>
|
||||||
|
<div style="margin-bottom:12px">
|
||||||
|
<el-button type="primary" @click="showPlanDialog = true">添加套餐</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="plans" border stripe v-loading="loading.plans">
|
||||||
|
<el-table-column prop="name" label="名称" width="120" />
|
||||||
|
<el-table-column prop="name_en" label="英文名" width="120" />
|
||||||
|
<el-table-column prop="credits_per_month" label="月次数" width="100" />
|
||||||
|
<el-table-column prop="price" label="月费(¥)" width="100" />
|
||||||
|
<el-table-column prop="price_usd" label="月费($)" width="100" />
|
||||||
|
<el-table-column prop="is_active" label="启用" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.is_active ? 'success' : 'info'">{{ row.is_active ? '是' : '否' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" @click="editPlan(row)">编辑</el-button>
|
||||||
|
<el-button size="small" type="danger" @click="deletePlan(row)">删除</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="用户余额" name="userCredits">
|
||||||
|
<el-card>
|
||||||
|
<div style="margin-bottom:12px;display:flex;gap:12px;align-items:center">
|
||||||
|
<el-input v-model="searchUserId" placeholder="搜索用户ID" style="width:300px" clearable />
|
||||||
|
<el-button type="primary" @click="loadUserCredits">查询</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="userCredits" border stripe v-loading="loading.userCredits">
|
||||||
|
<el-table-column prop="username" label="用户名" width="120" />
|
||||||
|
<el-table-column prop="user_id" label="用户ID" width="240" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="balance" label="余额" width="80" />
|
||||||
|
<el-table-column prop="total_purchased" label="总购买" width="80" />
|
||||||
|
<el-table-column prop="total_used" label="总消耗" width="80" />
|
||||||
|
<el-table-column prop="free_trial_used" label="试用已领" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.free_trial_used ? 'success' : 'info'">{{ row.free_trial_used ? '是' : '否' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="160" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button size="small" @click="openAdjustDialog(row)">调整</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-pagination
|
||||||
|
v-if="userCreditTotal > userCreditSize"
|
||||||
|
v-model:current-page="userCreditPage"
|
||||||
|
:page-size="userCreditSize"
|
||||||
|
:total="userCreditTotal"
|
||||||
|
layout="prev, pager, next"
|
||||||
|
style="margin-top:16px;justify-content:center"
|
||||||
|
@current-change="loadUserCredits"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="消费流水" name="consumptions">
|
||||||
|
<el-card>
|
||||||
|
<div style="margin-bottom:12px;display:flex;gap:12px">
|
||||||
|
<el-input v-model="consumptionUserId" placeholder="用户ID" style="width:200px" clearable />
|
||||||
|
<el-select v-model="consumptionType" placeholder="功能类型" clearable style="width:150px">
|
||||||
|
<el-option v-for="r in rateOptions" :key="r.feature" :label="r.feature" :value="r.feature" />
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" @click="loadConsumptions(1)">查询</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="consumptions" border stripe v-loading="loading.consumptions">
|
||||||
|
<el-table-column prop="user_id" label="用户ID" width="220" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="result_type" label="功能" width="120" />
|
||||||
|
<el-table-column prop="credits_change" label="变化" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :style="{ color: row.credits_change < 0 ? '#f56c6c' : '#67c23a' }">
|
||||||
|
{{ row.credits_change > 0 ? '+' : '' }}{{ row.credits_change }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="balance_after" label="余额" width="80" />
|
||||||
|
<el-table-column prop="source" label="来源" width="120" />
|
||||||
|
<el-table-column prop="description" label="说明" min-width="200" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="created_at" label="时间" width="180" />
|
||||||
|
</el-table>
|
||||||
|
<el-pagination
|
||||||
|
v-if="consumptionTotal > consumptionSize"
|
||||||
|
v-model:current-page="consumptionPage"
|
||||||
|
:page-size="consumptionSize"
|
||||||
|
:total="consumptionTotal"
|
||||||
|
layout="prev, pager, next"
|
||||||
|
style="margin-top:16px;justify-content:center"
|
||||||
|
@current-change="loadConsumptions"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="统计概览" name="stats">
|
||||||
|
<el-card>
|
||||||
|
<el-row :gutter="20">
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-statistic title="总购买次数" :value="stats.total_purchased" />
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-statistic title="总消耗次数" :value="stats.total_consumed" />
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-statistic title="当前总余额" :value="stats.total_balance" />
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="6">
|
||||||
|
<el-statistic title="活跃用户" :value="stats.total_users_with_credits" />
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</el-card>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
|
||||||
|
<el-dialog v-model="showPackageDialog" :title="editingPackage ? '编辑次数包' : '添加次数包'" width="500px">
|
||||||
|
<el-form :model="packageForm" label-width="100px">
|
||||||
|
<el-form-item label="名称"><el-input v-model="packageForm.name" /></el-form-item>
|
||||||
|
<el-form-item label="英文名"><el-input v-model="packageForm.name_en" /></el-form-item>
|
||||||
|
<el-form-item label="次数"><el-input-number v-model="packageForm.credits" :min="1" /></el-form-item>
|
||||||
|
<el-form-item label="价格(¥)"><el-input-number v-model="packageForm.price" :min="0" :precision="2" /></el-form-item>
|
||||||
|
<el-form-item label="价格($)"><el-input-number v-model="packageForm.price_usd" :min="0" :precision="2" /></el-form-item>
|
||||||
|
<el-form-item label="原价(¥)"><el-input-number v-model="packageForm.original_price" :min="0" :precision="2" /></el-form-item>
|
||||||
|
<el-form-item label="启用">
|
||||||
|
<el-switch v-model="packageForm.is_active" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="排序"><el-input-number v-model="packageForm.sort_order" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showPackageDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="savePackage">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="showPlanDialog" :title="editingPlan ? '编辑套餐' : '添加套餐'" width="500px">
|
||||||
|
<el-form :model="planForm" label-width="120px">
|
||||||
|
<el-form-item label="名称"><el-input v-model="planForm.name" /></el-form-item>
|
||||||
|
<el-form-item label="英文名"><el-input v-model="planForm.name_en" /></el-form-item>
|
||||||
|
<el-form-item label="月次数"><el-input-number v-model="planForm.credits_per_month" :min="1" /></el-form-item>
|
||||||
|
<el-form-item label="月费(¥)"><el-input-number v-model="planForm.price" :min="0" :precision="2" /></el-form-item>
|
||||||
|
<el-form-item label="月费($)"><el-input-number v-model="planForm.price_usd" :min="0" :precision="2" /></el-form-item>
|
||||||
|
<el-form-item label="天数"><el-input-number v-model="planForm.duration_days" :min="1" /></el-form-item>
|
||||||
|
<el-form-item label="启用"><el-switch v-model="planForm.is_active" /></el-form-item>
|
||||||
|
<el-form-item label="排序"><el-input-number v-model="planForm.sort_order" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showPlanDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="savePlan">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="showAdjustDialog" title="调整用户余额" width="400px">
|
||||||
|
<p>用户ID: {{ adjustTarget?.user_id }}</p>
|
||||||
|
<p>当前余额: {{ adjustTarget?.balance }}</p>
|
||||||
|
<el-form label-width="80px" style="margin-top:12px">
|
||||||
|
<el-form-item label="调整次数">
|
||||||
|
<el-input-number v-model="adjustCredits" :min="-99999" :precision="1" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="原因">
|
||||||
|
<el-input v-model="adjustReason" type="textarea" :rows="2" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showAdjustDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="confirmAdjust">确认调整</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
listCreditPackages, createCreditPackage, updateCreditPackage, deleteCreditPackage,
|
||||||
|
listSubscriptionPlans, createSubscriptionPlan, updateSubscriptionPlan, deleteSubscriptionPlan,
|
||||||
|
listUserCredits, adjustUserCredits,
|
||||||
|
listCreditConsumptions, getCreditStats,
|
||||||
|
} from '@/api'
|
||||||
|
|
||||||
|
const activeTab = ref('stats')
|
||||||
|
|
||||||
|
const rateOptions = [
|
||||||
|
{ feature: 'lead_search', credits: 10, description: '客户搜索 (15条线索+AI匹配)' },
|
||||||
|
{ feature: 'company_analysis', credits: 5, description: '公司深度分析' },
|
||||||
|
{ feature: 'market_intel', credits: 20, description: '市场情报报告' },
|
||||||
|
{ feature: 'translate_per_1000chars', credits: 1, description: '翻译 (每1000字符)' },
|
||||||
|
{ feature: 'reply_suggest', credits: 2, description: '回复建议生成' },
|
||||||
|
{ feature: 'outreach', credits: 3, description: '开发信生成' },
|
||||||
|
{ feature: 'marketing_content', credits: 5, description: '营销素材生成' },
|
||||||
|
{ feature: 'competitor_analysis', credits: 10, description: '竞品分析' },
|
||||||
|
{ feature: 'ai_chat', credits: 1, description: 'AI助手对话' },
|
||||||
|
{ feature: 'info_extract', credits: 1, description: '信息提取' },
|
||||||
|
{ feature: 'followup_scan', credits: 2, description: '跟进扫描' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const rates = ref(rateOptions)
|
||||||
|
const packages = ref([])
|
||||||
|
const plans = ref([])
|
||||||
|
const userCredits = ref([])
|
||||||
|
const consumptions = ref([])
|
||||||
|
const stats = ref({ total_purchased: 0, total_consumed: 0, total_balance: 0, total_users_with_credits: 0 })
|
||||||
|
|
||||||
|
const loading = reactive({ packages: false, plans: false, userCredits: false, consumptions: false })
|
||||||
|
|
||||||
|
const showPackageDialog = ref(false)
|
||||||
|
const showPlanDialog = ref(false)
|
||||||
|
const showAdjustDialog = ref(false)
|
||||||
|
const editingPackage = ref(null)
|
||||||
|
const editingPlan = ref(null)
|
||||||
|
const adjustTarget = ref(null)
|
||||||
|
const adjustCredits = ref(0)
|
||||||
|
const adjustReason = ref('')
|
||||||
|
|
||||||
|
const defaultPackageForm = () => ({ name: '', name_en: '', credits: 100, price: 79, price_usd: null, original_price: null, is_active: true, sort_order: 0 })
|
||||||
|
const defaultPlanForm = () => ({ name: '', name_en: '', credits_per_month: 100, price: 69, price_usd: null, duration_days: 30, is_active: true, sort_order: 0 })
|
||||||
|
|
||||||
|
const packageForm = ref(defaultPackageForm())
|
||||||
|
const planForm = ref(defaultPlanForm())
|
||||||
|
|
||||||
|
const searchUserId = ref('')
|
||||||
|
const userCreditPage = ref(1)
|
||||||
|
const userCreditSize = ref(20)
|
||||||
|
const userCreditTotal = ref(0)
|
||||||
|
|
||||||
|
const consumptionUserId = ref('')
|
||||||
|
const consumptionType = ref('')
|
||||||
|
const consumptionPage = ref(1)
|
||||||
|
const consumptionSize = ref(20)
|
||||||
|
const consumptionTotal = ref(0)
|
||||||
|
|
||||||
|
async function loadPackages() {
|
||||||
|
loading.packages = true
|
||||||
|
try { packages.value = await listCreditPackages() } catch (e) { ElMessage.error('加载次数包失败') }
|
||||||
|
loading.packages = false
|
||||||
|
}
|
||||||
|
async function loadPlans() {
|
||||||
|
loading.plans = true
|
||||||
|
try { plans.value = await listSubscriptionPlans() } catch (e) { ElMessage.error('加载订阅套餐失败') }
|
||||||
|
loading.plans = false
|
||||||
|
}
|
||||||
|
async function loadUserCredits() {
|
||||||
|
loading.userCredits = true
|
||||||
|
try {
|
||||||
|
const res = await listUserCredits(userCreditPage.value, userCreditSize.value)
|
||||||
|
userCredits.value = res.items
|
||||||
|
userCreditTotal.value = res.total
|
||||||
|
} catch (e) { ElMessage.error('加载用户余额失败') }
|
||||||
|
loading.userCredits = false
|
||||||
|
}
|
||||||
|
async function loadConsumptions(page) {
|
||||||
|
if (page) consumptionPage.value = page
|
||||||
|
loading.consumptions = true
|
||||||
|
try {
|
||||||
|
const params = { page: consumptionPage.value, size: consumptionSize.value }
|
||||||
|
if (consumptionUserId.value) params.user_id = consumptionUserId.value
|
||||||
|
if (consumptionType.value) params.result_type = consumptionType.value
|
||||||
|
const res = await listCreditConsumptions(params)
|
||||||
|
consumptions.value = res.items
|
||||||
|
consumptionTotal.value = res.total
|
||||||
|
} catch (e) { ElMessage.error('加载消费流水失败') }
|
||||||
|
loading.consumptions = false
|
||||||
|
}
|
||||||
|
async function loadStats() {
|
||||||
|
try { stats.value = await getCreditStats() } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function editPackage(row) {
|
||||||
|
editingPackage.value = row
|
||||||
|
packageForm.value = { ...row }
|
||||||
|
showPackageDialog.value = true
|
||||||
|
}
|
||||||
|
async function savePackage() {
|
||||||
|
try {
|
||||||
|
if (editingPackage.value) {
|
||||||
|
await updateCreditPackage(editingPackage.value.id, packageForm.value)
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
} else {
|
||||||
|
await createCreditPackage(packageForm.value)
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
}
|
||||||
|
showPackageDialog.value = false
|
||||||
|
editingPackage.value = null
|
||||||
|
packageForm.value = defaultPackageForm()
|
||||||
|
await loadPackages()
|
||||||
|
} catch (e) { ElMessage.error('保存失败') }
|
||||||
|
}
|
||||||
|
async function deletePackage(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确认删除?')
|
||||||
|
await deleteCreditPackage(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
await loadPackages()
|
||||||
|
} catch (e) { /* cancelled or error */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function editPlan(row) {
|
||||||
|
editingPlan.value = row
|
||||||
|
planForm.value = { ...row }
|
||||||
|
showPlanDialog.value = true
|
||||||
|
}
|
||||||
|
async function savePlan() {
|
||||||
|
try {
|
||||||
|
if (editingPlan.value) {
|
||||||
|
await updateSubscriptionPlan(editingPlan.value.id, planForm.value)
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
} else {
|
||||||
|
await createSubscriptionPlan(planForm.value)
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
}
|
||||||
|
showPlanDialog.value = false
|
||||||
|
editingPlan.value = null
|
||||||
|
planForm.value = defaultPlanForm()
|
||||||
|
await loadPlans()
|
||||||
|
} catch (e) { ElMessage.error('保存失败') }
|
||||||
|
}
|
||||||
|
async function deletePlan(row) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确认删除?')
|
||||||
|
await deleteSubscriptionPlan(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
await loadPlans()
|
||||||
|
} catch (e) { /* cancelled */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAdjustDialog(row) {
|
||||||
|
adjustTarget.value = row
|
||||||
|
adjustCredits.value = 0
|
||||||
|
adjustReason.value = ''
|
||||||
|
showAdjustDialog.value = true
|
||||||
|
}
|
||||||
|
async function confirmAdjust() {
|
||||||
|
if (!adjustCredits.value) {
|
||||||
|
ElMessage.warning('请输入调整次数')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await adjustUserCredits(adjustTarget.value.user_id, adjustCredits.value, adjustReason.value)
|
||||||
|
ElMessage.success('调整成功')
|
||||||
|
showAdjustDialog.value = false
|
||||||
|
await loadUserCredits()
|
||||||
|
} catch (e) { ElMessage.error('调整失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadPackages()
|
||||||
|
loadPlans()
|
||||||
|
loadUserCredits()
|
||||||
|
loadConsumptions(1)
|
||||||
|
loadStats()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""add credit system tables (packages, plans, user credits, consumptions, purchases)
|
||||||
|
|
||||||
|
Revision ID: add_credit_system
|
||||||
|
Revises: add_perf_indexes
|
||||||
|
Create Date: 2026-06-12
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
|
|
||||||
|
revision = "add_credit_system"
|
||||||
|
down_revision = "add_perf_indexes"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
op.create_table(
|
||||||
|
"credit_packages",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(100), nullable=False),
|
||||||
|
sa.Column("name_en", sa.String(100), nullable=False),
|
||||||
|
sa.Column("credits", sa.Integer, nullable=False),
|
||||||
|
sa.Column("price", sa.Float, nullable=False),
|
||||||
|
sa.Column("price_usd", sa.Float, nullable=True),
|
||||||
|
sa.Column("original_price", sa.Float, nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean, default=True),
|
||||||
|
sa.Column("sort_order", sa.Integer, default=0),
|
||||||
|
sa.Column("created_at", sa.DateTime, default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime, default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"subscription_plans",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(100), nullable=False),
|
||||||
|
sa.Column("name_en", sa.String(100), nullable=False),
|
||||||
|
sa.Column("credits_per_month", sa.Integer, nullable=False),
|
||||||
|
sa.Column("price", sa.Float, nullable=False),
|
||||||
|
sa.Column("price_usd", sa.Float, nullable=True),
|
||||||
|
sa.Column("duration_days", sa.Integer, default=30),
|
||||||
|
sa.Column("is_active", sa.Boolean, default=True),
|
||||||
|
sa.Column("sort_order", sa.Integer, default=0),
|
||||||
|
sa.Column("created_at", sa.DateTime, default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime, default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"user_credits",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False, unique=True, index=True),
|
||||||
|
sa.Column("balance", sa.Float, default=0),
|
||||||
|
sa.Column("total_purchased", sa.Float, default=0),
|
||||||
|
sa.Column("total_used", sa.Float, default=0),
|
||||||
|
sa.Column("subscription_plan_id", UUID(as_uuid=True), sa.ForeignKey("subscription_plans.id"), nullable=True),
|
||||||
|
sa.Column("subscription_expires_at", sa.DateTime, nullable=True),
|
||||||
|
sa.Column("subscription_auto_renew", sa.Boolean, default=False),
|
||||||
|
sa.Column("free_trial_used", sa.Boolean, default=False),
|
||||||
|
sa.Column("daily_translate_chars", sa.Integer, default=0),
|
||||||
|
sa.Column("daily_translate_date", sa.Date, nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime, default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime, default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"credit_consumptions",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False, index=True),
|
||||||
|
sa.Column("result_type", sa.String(50), nullable=False),
|
||||||
|
sa.Column("reference_id", UUID(as_uuid=True), nullable=True),
|
||||||
|
sa.Column("credits_change", sa.Float, nullable=False),
|
||||||
|
sa.Column("balance_after", sa.Float, nullable=False),
|
||||||
|
sa.Column("source", sa.String(30), nullable=False),
|
||||||
|
sa.Column("description", sa.String(500), nullable=True),
|
||||||
|
sa.Column("metadata", JSONB, nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime, default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("idx_credit_consumptions_user", "credit_consumptions", ["user_id", sa.text("created_at DESC")])
|
||||||
|
op.create_index("idx_credit_consumptions_type", "credit_consumptions", ["result_type"])
|
||||||
|
op.create_table(
|
||||||
|
"credit_purchases",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False, index=True),
|
||||||
|
sa.Column("package_id", UUID(as_uuid=True), sa.ForeignKey("credit_packages.id"), nullable=True),
|
||||||
|
sa.Column("subscription_plan_id", UUID(as_uuid=True), sa.ForeignKey("subscription_plans.id"), nullable=True),
|
||||||
|
sa.Column("credits", sa.Integer, nullable=False),
|
||||||
|
sa.Column("amount", sa.Float, nullable=False),
|
||||||
|
sa.Column("currency", sa.String(3), default="CNY"),
|
||||||
|
sa.Column("payment_method", sa.String(20), nullable=True),
|
||||||
|
sa.Column("status", sa.String(20), default="pending"),
|
||||||
|
sa.Column("payment_transaction_id", UUID(as_uuid=True), sa.ForeignKey("payment_transactions.id"), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime, default=sa.func.now()),
|
||||||
|
sa.Column("paid_at", sa.DateTime, nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("idx_credit_purchases_user", "credit_purchases", ["user_id"])
|
||||||
|
op.create_index("idx_credit_purchases_status", "credit_purchases", ["status"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_table("credit_purchases")
|
||||||
|
op.drop_table("credit_consumptions")
|
||||||
|
op.drop_table("user_credits")
|
||||||
|
op.drop_table("subscription_plans")
|
||||||
|
op.drop_table("credit_packages")
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""add performance indexes
|
||||||
|
|
||||||
|
Revision ID: add_perf_indexes
|
||||||
|
Revises: add_payment_transactions_table
|
||||||
|
Create Date: 2026-06-11
|
||||||
|
|
||||||
|
Add indexes for common query patterns to improve performance.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'add_perf_indexes'
|
||||||
|
down_revision = 'add_payment_transactions'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Customer indexes
|
||||||
|
op.create_index('ix_customer_user_status', 'customers', ['user_id', 'status'])
|
||||||
|
op.create_index('ix_customer_user_last_contact', 'customers', ['user_id', 'last_contact_at'])
|
||||||
|
|
||||||
|
# Payment transaction indexes
|
||||||
|
op.create_index('ix_payment_user_created', 'payment_transactions', ['user_id', 'created_at'])
|
||||||
|
|
||||||
|
# Followup log indexes
|
||||||
|
op.create_index('ix_followup_user_customer', 'followup_logs', ['user_id', 'customer_id'])
|
||||||
|
|
||||||
|
# Notification indexes
|
||||||
|
op.create_index('ix_notification_user_read', 'notifications', ['user_id', 'is_read'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Remove indexes
|
||||||
|
op.drop_index('ix_notification_user_read', 'notifications')
|
||||||
|
op.drop_index('ix_followup_user_customer', 'followup_logs')
|
||||||
|
op.drop_index('ix_payment_user_created', 'payment_transactions')
|
||||||
|
op.drop_index('ix_customer_user_last_contact', 'customers')
|
||||||
|
op.drop_index('ix_customer_user_status', 'customers')
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
from aliyunsdkcore.client import AcsClient
|
from aliyunsdkcore.client import AcsClient
|
||||||
from aliyunsdkcore.auth.credentials import StsTokenCredential
|
from aliyunsdkcore.auth.credentials import AccessKeyCredential
|
||||||
from aliyunsdkalimt.request.v20181012 import TranslateGeneralRequest, TranslateECommerceRequest
|
from aliyunsdkalimt.request.v20181012 import TranslateGeneralRequest, TranslateECommerceRequest
|
||||||
from app.services.translation_quota import TranslationQuotaService
|
from app.services.translation_quota import TranslationQuotaService
|
||||||
from app.database import AsyncSessionLocal
|
from app.database import AsyncSessionLocal
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -41,11 +41,11 @@ async def list_users(
|
|||||||
return await service.list_users(page, size, role)
|
return await service.list_users(page, size, role)
|
||||||
|
|
||||||
|
|
||||||
|
from app.core.utils import validate_uuid
|
||||||
|
|
||||||
|
|
||||||
def _validate_uuid(user_id: str):
|
def _validate_uuid(user_id: str):
|
||||||
try:
|
validate_uuid(user_id)
|
||||||
uuid.UUID(user_id)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid user ID format")
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/users/{target_user_id}/tier")
|
@router.patch("/users/{target_user_id}/tier")
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.database import get_db
|
||||||
|
from app.api.v1.admin import require_admin
|
||||||
|
from app.services.credit import CreditService
|
||||||
|
from app.models.credit_package import CreditPackage, SubscriptionPlan
|
||||||
|
from app.models.user_credit import UserCredit
|
||||||
|
from app.models.user import User
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class PackageForm(BaseModel):
|
||||||
|
name: str
|
||||||
|
name_en: str
|
||||||
|
credits: int
|
||||||
|
price: float
|
||||||
|
price_usd: Optional[float] = None
|
||||||
|
original_price: Optional[float] = None
|
||||||
|
is_active: bool = True
|
||||||
|
sort_order: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class PlanForm(BaseModel):
|
||||||
|
name: str
|
||||||
|
name_en: str
|
||||||
|
credits_per_month: int
|
||||||
|
price: float
|
||||||
|
price_usd: Optional[float] = None
|
||||||
|
duration_days: int = 30
|
||||||
|
is_active: bool = True
|
||||||
|
sort_order: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class AdjustCreditsForm(BaseModel):
|
||||||
|
user_id: str
|
||||||
|
credits: float
|
||||||
|
reason: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/credit-packages")
|
||||||
|
async def list_packages(
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(CreditPackage).order_by(CreditPackage.sort_order)
|
||||||
|
)
|
||||||
|
return [{
|
||||||
|
"id": str(p.id),
|
||||||
|
"name": p.name,
|
||||||
|
"name_en": p.name_en,
|
||||||
|
"credits": p.credits,
|
||||||
|
"price": p.price,
|
||||||
|
"price_usd": p.price_usd,
|
||||||
|
"original_price": p.original_price,
|
||||||
|
"is_active": p.is_active,
|
||||||
|
"sort_order": p.sort_order,
|
||||||
|
} for p in result.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/credit-packages")
|
||||||
|
async def create_package(
|
||||||
|
data: PackageForm,
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
pkg = CreditPackage(**data.model_dump())
|
||||||
|
db.add(pkg)
|
||||||
|
await db.flush()
|
||||||
|
return {"id": str(pkg.id), "status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/credit-packages/{pkg_id}")
|
||||||
|
async def update_package(
|
||||||
|
pkg_id: str,
|
||||||
|
data: PackageForm,
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
uid = uuid.UUID(pkg_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="无效ID")
|
||||||
|
result = await db.execute(select(CreditPackage).where(CreditPackage.id == uid))
|
||||||
|
pkg = result.scalar_one_or_none()
|
||||||
|
if not pkg:
|
||||||
|
raise HTTPException(status_code=404, detail="次数包不存在")
|
||||||
|
for k, v in data.model_dump().items():
|
||||||
|
setattr(pkg, k, v)
|
||||||
|
await db.flush()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/credit-packages/{pkg_id}")
|
||||||
|
async def delete_package(
|
||||||
|
pkg_id: str,
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
uid = uuid.UUID(pkg_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="无效ID")
|
||||||
|
result = await db.execute(select(CreditPackage).where(CreditPackage.id == uid))
|
||||||
|
pkg = result.scalar_one_or_none()
|
||||||
|
if not pkg:
|
||||||
|
raise HTTPException(status_code=404, detail="次数包不存在")
|
||||||
|
await db.delete(pkg)
|
||||||
|
await db.flush()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/subscription-plans")
|
||||||
|
async def list_plans(
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(SubscriptionPlan).order_by(SubscriptionPlan.sort_order)
|
||||||
|
)
|
||||||
|
return [{
|
||||||
|
"id": str(p.id),
|
||||||
|
"name": p.name,
|
||||||
|
"name_en": p.name_en,
|
||||||
|
"credits_per_month": p.credits_per_month,
|
||||||
|
"price": p.price,
|
||||||
|
"price_usd": p.price_usd,
|
||||||
|
"duration_days": p.duration_days,
|
||||||
|
"is_active": p.is_active,
|
||||||
|
"sort_order": p.sort_order,
|
||||||
|
} for p in result.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/subscription-plans")
|
||||||
|
async def create_plan(
|
||||||
|
data: PlanForm,
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
plan = SubscriptionPlan(**data.model_dump())
|
||||||
|
db.add(plan)
|
||||||
|
await db.flush()
|
||||||
|
return {"id": str(plan.id), "status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/subscription-plans/{plan_id}")
|
||||||
|
async def update_plan(
|
||||||
|
plan_id: str,
|
||||||
|
data: PlanForm,
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
uid = uuid.UUID(plan_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="无效ID")
|
||||||
|
result = await db.execute(select(SubscriptionPlan).where(SubscriptionPlan.id == uid))
|
||||||
|
plan = result.scalar_one_or_none()
|
||||||
|
if not plan:
|
||||||
|
raise HTTPException(status_code=404, detail="订阅套餐不存在")
|
||||||
|
for k, v in data.model_dump().items():
|
||||||
|
setattr(plan, k, v)
|
||||||
|
await db.flush()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/subscription-plans/{plan_id}")
|
||||||
|
async def delete_plan(
|
||||||
|
plan_id: str,
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
uid = uuid.UUID(plan_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="无效ID")
|
||||||
|
result = await db.execute(select(SubscriptionPlan).where(SubscriptionPlan.id == uid))
|
||||||
|
plan = result.scalar_one_or_none()
|
||||||
|
if not plan:
|
||||||
|
raise HTTPException(status_code=404, detail="订阅套餐不存在")
|
||||||
|
await db.delete(plan)
|
||||||
|
await db.flush()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/user-credits")
|
||||||
|
async def list_user_credits(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(20, ge=1, le=100),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
offset = (page - 1) * size
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserCredit).order_by(UserCredit.updated_at.desc()).offset(offset).limit(size)
|
||||||
|
)
|
||||||
|
items = result.scalars().all()
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
count_result = await db.execute(select(func.count(UserCredit.id)))
|
||||||
|
total = count_result.scalar() or 0
|
||||||
|
|
||||||
|
enriched = []
|
||||||
|
for uc in items:
|
||||||
|
user_result = await db.execute(select(User).where(User.id == uc.user_id))
|
||||||
|
user = user_result.scalar_one_or_none()
|
||||||
|
enriched.append({
|
||||||
|
"id": str(uc.id),
|
||||||
|
"user_id": str(uc.user_id),
|
||||||
|
"username": user.username if user else "N/A",
|
||||||
|
"balance": uc.balance,
|
||||||
|
"total_purchased": uc.total_purchased,
|
||||||
|
"total_used": uc.total_used,
|
||||||
|
"subscription_plan_id": str(uc.subscription_plan_id) if uc.subscription_plan_id else None,
|
||||||
|
"subscription_expires_at": uc.subscription_expires_at.isoformat() if uc.subscription_expires_at else None,
|
||||||
|
"free_trial_used": uc.free_trial_used,
|
||||||
|
"updated_at": uc.updated_at.isoformat() if uc.updated_at else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"items": enriched, "total": total, "page": page, "size": size}
|
||||||
|
|
||||||
|
|
||||||
|
class AdjustForm(BaseModel):
|
||||||
|
user_id: str
|
||||||
|
credits: float
|
||||||
|
reason: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/user-credits/adjust")
|
||||||
|
async def adjust_credits(
|
||||||
|
data: AdjustForm,
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = CreditService(db)
|
||||||
|
try:
|
||||||
|
uid = uuid.UUID(data.user_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="无效用户ID")
|
||||||
|
balance = await svc.add_credits(
|
||||||
|
user_id=uid,
|
||||||
|
credits=data.credits,
|
||||||
|
source="admin_grant",
|
||||||
|
description=data.reason or f"管理员调整: {data.credits:+.1f} 次",
|
||||||
|
)
|
||||||
|
return {"status": "ok", "balance": balance}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/credit-consumptions")
|
||||||
|
async def list_consumptions(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(20, ge=1, le=200),
|
||||||
|
user_id: str = Query(None),
|
||||||
|
result_type: str = Query(None),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
from app.models.credit_consumption import CreditConsumption
|
||||||
|
from sqlalchemy import select, func, desc
|
||||||
|
|
||||||
|
conditions = []
|
||||||
|
if user_id:
|
||||||
|
try:
|
||||||
|
conditions.append(CreditConsumption.user_id == uuid.UUID(user_id))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if result_type:
|
||||||
|
conditions.append(CreditConsumption.result_type == result_type)
|
||||||
|
|
||||||
|
stmt = select(CreditConsumption).where(*conditions).order_by(
|
||||||
|
desc(CreditConsumption.created_at)
|
||||||
|
).offset((page - 1) * size).limit(size)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
items = result.scalars().all()
|
||||||
|
|
||||||
|
count_stmt = select(func.count(CreditConsumption.id)).where(*conditions)
|
||||||
|
count_result = await db.execute(count_stmt)
|
||||||
|
total = count_result.scalar() or 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": [{
|
||||||
|
"id": str(c.id),
|
||||||
|
"user_id": str(c.user_id),
|
||||||
|
"result_type": c.result_type,
|
||||||
|
"credits_change": c.credits_change,
|
||||||
|
"balance_after": c.balance_after,
|
||||||
|
"source": c.source,
|
||||||
|
"description": c.description,
|
||||||
|
"created_at": c.created_at.isoformat() if c.created_at else None,
|
||||||
|
} for c in items],
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"size": size,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/credit-stats")
|
||||||
|
async def credit_stats(
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = CreditService(db)
|
||||||
|
return await svc.get_stats()
|
||||||
@@ -181,9 +181,8 @@ async def test_provider(
|
|||||||
return {"success": False, "error": str(e)}
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
from app.core.utils import validate_uuid
|
||||||
|
|
||||||
|
|
||||||
def _validate_uuid(uuid_str: str):
|
def _validate_uuid(uuid_str: str):
|
||||||
import uuid
|
validate_uuid(uuid_str)
|
||||||
try:
|
|
||||||
uuid.UUID(uuid_str)
|
|
||||||
except ValueError:
|
|
||||||
raise HTTPException(status_code=400, detail="Invalid UUID")
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from app.ai.local_faq import match_faq
|
|||||||
from app.api.v1.deps import get_current_user_id
|
from app.api.v1.deps import get_current_user_id
|
||||||
from app.models.system_config import SystemConfig
|
from app.models.system_config import SystemConfig
|
||||||
from app.services.admin import AdminService
|
from app.services.admin import AdminService
|
||||||
|
from app.services.credit import CreditService
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import re
|
import re
|
||||||
@@ -108,6 +109,14 @@ async def chat(
|
|||||||
f"db={t1-t0:.2f}s orm={t2-t1:.2f}s total={t4-t_start:.2f}s"
|
f"db={t1-t0:.2f}s orm={t2-t1:.2f}s total={t4-t_start:.2f}s"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
credit_svc = CreditService(db)
|
||||||
|
ok, balance = await credit_svc.deduct(user_id, "ai_chat")
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=402,
|
||||||
|
detail=f"次数不足 (剩余 {balance:.1f})"
|
||||||
|
)
|
||||||
|
|
||||||
t3 = time.time()
|
t3 = time.time()
|
||||||
ai = get_ai_router()
|
ai = get_ai_router()
|
||||||
result = await ai.chat(data.message, data.history or [], system_prompt)
|
result = await ai.chat(data.message, data.history or [], system_prompt)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from app.database import get_db
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.core.security import hash_password, verify_password, create_access_token, create_refresh_token, decode_token
|
from app.core.security import hash_password, verify_password, create_access_token, create_refresh_token, decode_token
|
||||||
from app.core.csrf import require_csrf_token
|
from app.core.csrf import require_csrf_token
|
||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, EmailStr, field_validator
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from app.services.admin import AdminService
|
from app.services.admin import AdminService
|
||||||
from app.models.subscription import Subscription
|
from app.models.subscription import Subscription
|
||||||
@@ -40,6 +40,13 @@ class LoginRequest(BaseModel):
|
|||||||
phone: str = ""
|
phone: str = ""
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
@field_validator('password')
|
||||||
|
@classmethod
|
||||||
|
def validate_password(cls, v: str) -> str:
|
||||||
|
if len(v) < 6:
|
||||||
|
raise ValueError('Password must be at least 6 characters')
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
class RefreshRequest(BaseModel):
|
class RefreshRequest(BaseModel):
|
||||||
refresh_token: str
|
refresh_token: str
|
||||||
@@ -74,6 +81,10 @@ async def register(
|
|||||||
)
|
)
|
||||||
db.add(sub)
|
db.add(sub)
|
||||||
|
|
||||||
|
from app.services.credit import CreditService
|
||||||
|
credit_svc = CreditService(db)
|
||||||
|
await credit_svc.grant_free_trial(user.id)
|
||||||
|
|
||||||
if data.ref_code:
|
if data.ref_code:
|
||||||
try:
|
try:
|
||||||
from app.api.v1.referral import do_claim_referral
|
from app.api.v1.referral import do_claim_referral
|
||||||
@@ -146,6 +157,38 @@ async def login(
|
|||||||
|
|
||||||
@router.post("/login/guest")
|
@router.post("/login/guest")
|
||||||
async def guest_login(request: Request, db: AsyncSession = Depends(get_db)):
|
async def guest_login(request: Request, db: AsyncSession = Depends(get_db)):
|
||||||
|
# Rate limiting: max 5 guest logins per IP per 15 minutes
|
||||||
|
from app.core.redis import get_redis
|
||||||
|
|
||||||
|
client_ip = request.client.host if request.client else "unknown"
|
||||||
|
cache_key = f"guest_login:{client_ip}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
redis_client = await get_redis()
|
||||||
|
now = int(time.time())
|
||||||
|
window = settings.GUEST_LOGIN_WINDOW # 15 minutes
|
||||||
|
limit = settings.GUEST_LOGIN_LIMIT
|
||||||
|
|
||||||
|
# Get count of logins in current window
|
||||||
|
count = await redis_client.get(cache_key)
|
||||||
|
if count and int(count) >= limit:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=429,
|
||||||
|
detail="Too many guest login attempts. Please try again later or register an account."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Increment counter
|
||||||
|
pipe = redis_client.pipeline()
|
||||||
|
pipe.incr(cache_key)
|
||||||
|
pipe.expire(cache_key, window)
|
||||||
|
await pipe.execute()
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
# If Redis is down, proceed without rate limiting
|
||||||
|
pass
|
||||||
|
|
||||||
guest_id = str(uuid.uuid4())
|
guest_id = str(uuid.uuid4())
|
||||||
access_token = create_access_token(
|
access_token = create_access_token(
|
||||||
{"sub": guest_id, "tier": "guest", "role": "guest", "is_guest": True},
|
{"sub": guest_id, "tier": "guest", "role": "guest", "is_guest": True},
|
||||||
@@ -153,8 +196,7 @@ async def guest_login(request: Request, db: AsyncSession = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
refresh_token = create_refresh_token({"sub": guest_id, "is_guest": True})
|
refresh_token = create_refresh_token({"sub": guest_id, "is_guest": True})
|
||||||
|
|
||||||
client_ip = request.client.host if request.client else None
|
await AdminService(db).log_usage(guest_id, "user.login_guest", {})
|
||||||
await AdminService(db).log_usage(guest_id, "user.login_guest", {}, ip=client_ip)
|
|
||||||
|
|
||||||
return LoginResponse(
|
return LoginResponse(
|
||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import json
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from app.database import get_db
|
||||||
|
from app.api.v1.deps import get_current_user_id
|
||||||
|
from app.services.credit import CreditService
|
||||||
|
from app.services.payment import PaymentService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseRequest(BaseModel):
|
||||||
|
package_id: str
|
||||||
|
pay_type: str = "alipay"
|
||||||
|
|
||||||
|
|
||||||
|
class StripePurchaseRequest(BaseModel):
|
||||||
|
package_id: str
|
||||||
|
gateway: str = "stripe"
|
||||||
|
success_url: str = "https://trade.yuzhiran.com/workspace/credits?pay=success"
|
||||||
|
cancel_url: str = "https://trade.yuzhiran.com/workspace/credits?pay=cancel"
|
||||||
|
|
||||||
|
|
||||||
|
class SubscribeRequest(BaseModel):
|
||||||
|
plan_id: str
|
||||||
|
pay_type: str = "alipay"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/balance")
|
||||||
|
async def get_balance(
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = CreditService(db)
|
||||||
|
return await svc.get_balance(user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history")
|
||||||
|
async def get_history(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(20, ge=1, le=100),
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = CreditService(db)
|
||||||
|
return await svc.get_history(user_id, page, size)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/packages")
|
||||||
|
async def list_packages(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = CreditService(db)
|
||||||
|
return await svc.get_packages()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/subscription-plans")
|
||||||
|
async def list_subscription_plans(
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = CreditService(db)
|
||||||
|
return await svc.get_subscription_plans()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/purchase")
|
||||||
|
async def purchase_package(
|
||||||
|
req: PurchaseRequest,
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = CreditService(db)
|
||||||
|
packages = await svc.get_packages()
|
||||||
|
pkg = next((p for p in packages if p["id"] == req.package_id), None)
|
||||||
|
if not pkg:
|
||||||
|
raise HTTPException(status_code=404, detail="次数包不存在")
|
||||||
|
|
||||||
|
pay_svc = PaymentService(db)
|
||||||
|
order = await pay_svc.create_credit_order(
|
||||||
|
user_id=user_id,
|
||||||
|
amount=pkg["price"],
|
||||||
|
description=f"购买 {pkg['name']} ({pkg['credits']}次)",
|
||||||
|
pay_type=req.pay_type,
|
||||||
|
metadata={"credit_package_id": req.package_id, "credits": pkg["credits"]},
|
||||||
|
)
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/subscribe")
|
||||||
|
async def subscribe_plan(
|
||||||
|
req: SubscribeRequest,
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = CreditService(db)
|
||||||
|
plans = await svc.get_subscription_plans()
|
||||||
|
plan = next((p for p in plans if p["id"] == req.plan_id), None)
|
||||||
|
if not plan:
|
||||||
|
raise HTTPException(status_code=404, detail="订阅套餐不存在")
|
||||||
|
|
||||||
|
pay_svc = PaymentService(db)
|
||||||
|
order = await pay_svc.create_credit_order(
|
||||||
|
user_id=user_id,
|
||||||
|
amount=plan["price"],
|
||||||
|
description=f"开通 {plan['name']} (每月{plan['credits_per_month']}次)",
|
||||||
|
pay_type=req.pay_type,
|
||||||
|
metadata={"subscription_plan_id": req.plan_id, "credits_per_month": plan["credits_per_month"]},
|
||||||
|
)
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/stripe-purchase")
|
||||||
|
async def stripe_purchase(
|
||||||
|
req: StripePurchaseRequest,
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = CreditService(db)
|
||||||
|
packages = await svc.get_packages()
|
||||||
|
pkg = next((p for p in packages if p["id"] == req.package_id), None)
|
||||||
|
if not pkg:
|
||||||
|
raise HTTPException(status_code=404, detail="次数包不存在")
|
||||||
|
|
||||||
|
from app.services.payment import get_gateway, gen_order_no
|
||||||
|
|
||||||
|
price_usd = pkg.get("price_usd") or round(pkg["price"] / 7, 2)
|
||||||
|
amount_cents = int(price_usd * 100)
|
||||||
|
|
||||||
|
order_no = gen_order_no(user_id)
|
||||||
|
gw = get_gateway(req.gateway)
|
||||||
|
sep = '&' if '?' in req.success_url else '?'
|
||||||
|
success_url = f"{req.success_url}{sep}order_id={order_no}"
|
||||||
|
gw_result = await gw.create_order(
|
||||||
|
order_no, amount_cents, f"{pkg['name_en']} ({pkg['credits']} credits)",
|
||||||
|
pay_type=req.gateway,
|
||||||
|
success_url=success_url,
|
||||||
|
cancel_url=req.cancel_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.models.payment_transaction import PaymentTransaction
|
||||||
|
txn = PaymentTransaction(
|
||||||
|
user_id=user_id, order_no=order_no, plan="credit_purchase",
|
||||||
|
amount=price_usd, gateway=req.gateway, pay_type=req.gateway,
|
||||||
|
status="pending", description=json.dumps({"credits": pkg["credits"]}),
|
||||||
|
gateway_order_no=gw_result.get("session_id", ""),
|
||||||
|
)
|
||||||
|
db.add(txn)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "pending",
|
||||||
|
"order_id": order_no,
|
||||||
|
"session_url": gw_result.get("session_url"),
|
||||||
|
"session_id": gw_result.get("session_id"),
|
||||||
|
"amount": price_usd,
|
||||||
|
"currency": "USD",
|
||||||
|
"gateway": req.gateway,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/cancel-subscription")
|
||||||
|
async def cancel_subscription(
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
from app.models.user_credit import UserCredit
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
result = await db.execute(select(UserCredit).where(UserCredit.user_id == user_id))
|
||||||
|
uc = result.scalar_one_or_none()
|
||||||
|
if not uc or not uc.subscription_plan_id:
|
||||||
|
raise HTTPException(status_code=400, detail="没有有效的订阅")
|
||||||
|
|
||||||
|
uc.subscription_auto_renew = False
|
||||||
|
await db.flush()
|
||||||
|
return {"success": True, "message": "已取消自动续费,当前订阅到期后不再续费"}
|
||||||
@@ -136,6 +136,11 @@ async def delete_customer(
|
|||||||
return {"message": "Customer deleted"}
|
return {"message": "Customer deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
MAX_UPLOAD_SIZE = settings.MAX_UPLOAD_SIZE
|
||||||
|
|
||||||
@router.post("/import")
|
@router.post("/import")
|
||||||
async def import_customers(
|
async def import_customers(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
@@ -144,8 +149,17 @@ async def import_customers(
|
|||||||
):
|
):
|
||||||
from app.workers.tasks import process_customer_import
|
from app.workers.tasks import process_customer_import
|
||||||
|
|
||||||
content = await file.read()
|
filename = file.filename or "unknown"
|
||||||
filename = file.filename or ""
|
file_size = 0
|
||||||
|
content = b""
|
||||||
|
while True:
|
||||||
|
chunk = await file.read(8192)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
file_size += len(chunk)
|
||||||
|
if file_size > MAX_UPLOAD_SIZE:
|
||||||
|
raise HTTPException(status_code=413, detail=f"File too large. Max {MAX_UPLOAD_SIZE // (1024*1024)}MB")
|
||||||
|
content += chunk
|
||||||
|
|
||||||
if filename.endswith(".xlsx"):
|
if filename.endswith(".xlsx"):
|
||||||
records, parse_errors = import_service.parse_xlsx(content)
|
records, parse_errors = import_service.parse_xlsx(content)
|
||||||
@@ -155,7 +169,7 @@ async def import_customers(
|
|||||||
raise HTTPException(status_code=400, detail="Unsupported file format. Use .xlsx or .csv")
|
raise HTTPException(status_code=400, detail="Unsupported file format. Use .xlsx or .csv")
|
||||||
|
|
||||||
if parse_errors and not records:
|
if parse_errors and not records:
|
||||||
raise HTTPException(status_code=400, detail=f"Parse failed: {'; '.join(parse_errors)}")
|
raise HTTPException(status_code=400, detail="Parse failed. Check file format.")
|
||||||
|
|
||||||
valid, validation_errors = import_service.validate_records(records)
|
valid, validation_errors = import_service.validate_records(records)
|
||||||
all_errors = parse_errors + validation_errors
|
all_errors = parse_errors + validation_errors
|
||||||
@@ -167,7 +181,7 @@ async def import_customers(
|
|||||||
await svc.create_customer(user_id, record)
|
await svc.create_customer(user_id, record)
|
||||||
imported_count += 1
|
imported_count += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
all_errors.append(f"Import failed for {record.get('name', 'unknown')}: {str(e)}")
|
all_errors.append(f"Import failed for row: {str(e)}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"imported": imported_count,
|
"imported": imported_count,
|
||||||
|
|||||||
+114
-12
@@ -1,7 +1,14 @@
|
|||||||
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
|
||||||
|
from app.services.credit import CreditService
|
||||||
|
from app.api.v1.deps import get_current_user_id
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -16,46 +23,141 @@ class AnalyzeRequest(BaseModel):
|
|||||||
product_description: str
|
product_description: str
|
||||||
|
|
||||||
|
|
||||||
|
class MarketIntelRequest(BaseModel):
|
||||||
|
product_description: str
|
||||||
|
target_market: str = "US"
|
||||||
|
|
||||||
|
|
||||||
class OutreachRequest(BaseModel):
|
class OutreachRequest(BaseModel):
|
||||||
company: Dict[str, Any]
|
company: Dict[str, Any]
|
||||||
product: Dict[str, Any]
|
product: Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
CREDIT_COST = {
|
||||||
|
"search": 10,
|
||||||
|
"analyze": 5,
|
||||||
|
"outreach": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _deduct_credits(user_id: str, result_type: str, db: AsyncSession):
|
||||||
|
svc = CreditService(db)
|
||||||
|
ok, balance = await svc.deduct(user_id, result_type)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=402,
|
||||||
|
detail=f"次数不足 (剩余 {balance:.1f}, 需要 {CREDIT_COST.get(result_type, 1)})"
|
||||||
|
)
|
||||||
|
return balance
|
||||||
|
|
||||||
|
|
||||||
@router.post("/search")
|
@router.post("/search")
|
||||||
async def search_leads(req: SearchRequest):
|
async def search_leads(
|
||||||
|
req: SearchRequest,
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
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()
|
|
||||||
|
credit_svc = CreditService(db)
|
||||||
|
ok, balance = await credit_svc.deduct(user_id, "lead_search")
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=402,
|
||||||
|
detail=f"次数不足 (剩余 {balance:.1f}, 需要 10)"
|
||||||
|
)
|
||||||
|
|
||||||
|
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, "credits_remaining": balance - 10}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"搜索失败: {str(e)}")
|
await credit_svc.add_credits(user_id, 10, "refund", "搜索失败退回次数")
|
||||||
|
logger.error(f"Search failed: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="搜索失败,请稍后重试")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/analyze")
|
@router.post("/analyze")
|
||||||
async def analyze_company(req: AnalyzeRequest):
|
async def analyze_company(
|
||||||
|
req: AnalyzeRequest,
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
if not req.company_url.strip():
|
if not req.company_url.strip():
|
||||||
raise HTTPException(status_code=400, detail="请填写公司网址")
|
raise HTTPException(status_code=400, detail="请填写公司网址")
|
||||||
if not req.product_description.strip():
|
if not req.product_description.strip():
|
||||||
raise HTTPException(status_code=400, detail="请填写产品描述")
|
raise HTTPException(status_code=400, detail="请填写产品描述")
|
||||||
|
|
||||||
|
credit_svc = CreditService(db)
|
||||||
|
ok, balance = await credit_svc.deduct(user_id, "company_analysis")
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=402,
|
||||||
|
detail=f"次数不足 (剩余 {balance:.1f}, 需要 5)"
|
||||||
|
)
|
||||||
|
|
||||||
svc = DiscoveryService()
|
svc = DiscoveryService()
|
||||||
try:
|
try:
|
||||||
result = await svc.analyze(req.company_url, req.product_description)
|
result = await svc.analyze(req.company_url, req.product_description)
|
||||||
return {"success": True, "data": result}
|
return {"success": True, "data": result, "credits_remaining": balance - 5}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"分析失败: {str(e)}")
|
await credit_svc.add_credits(user_id, 5, "refund", "分析失败退回次数")
|
||||||
|
logger.error(f"Analysis failed: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="分析失败,请稍后重试")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/market-intel")
|
||||||
|
async def market_intel(
|
||||||
|
req: MarketIntelRequest,
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
if not req.product_description.strip():
|
||||||
|
raise HTTPException(status_code=400, detail="请填写产品描述")
|
||||||
|
|
||||||
|
credit_svc = CreditService(db)
|
||||||
|
ok, balance = await credit_svc.deduct(user_id, "market_intel")
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=402,
|
||||||
|
detail=f"次数不足 (剩余 {balance:.1f}, 需要 20)"
|
||||||
|
)
|
||||||
|
|
||||||
|
svc = DiscoveryService(db=db)
|
||||||
|
try:
|
||||||
|
result = await svc.market_intel(req.product_description, req.target_market)
|
||||||
|
return {"success": True, "data": result, "credits_remaining": balance - 20}
|
||||||
|
except Exception as e:
|
||||||
|
await credit_svc.add_credits(user_id, 20, "refund", "市场分析失败退回次数")
|
||||||
|
logger.error(f"Market intel failed: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="分析失败,请稍后重试")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/outreach")
|
@router.post("/outreach")
|
||||||
async def generate_outreach(req: OutreachRequest):
|
async def generate_outreach(
|
||||||
|
req: OutreachRequest,
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
if not req.company.get("name"):
|
if not req.company.get("name"):
|
||||||
raise HTTPException(status_code=400, detail="请填写公司名称")
|
raise HTTPException(status_code=400, detail="请填写公司名称")
|
||||||
if not req.product.get("name"):
|
if not req.product.get("name"):
|
||||||
raise HTTPException(status_code=400, detail="请填写产品名称")
|
raise HTTPException(status_code=400, detail="请填写产品名称")
|
||||||
|
|
||||||
|
credit_svc = CreditService(db)
|
||||||
|
ok, balance = await credit_svc.deduct(user_id, "outreach")
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=402,
|
||||||
|
detail=f"次数不足 (剩余 {balance:.1f}, 需要 3)"
|
||||||
|
)
|
||||||
|
|
||||||
svc = DiscoveryService()
|
svc = DiscoveryService()
|
||||||
try:
|
try:
|
||||||
result = await svc.outreach(req.company, req.product)
|
result = await svc.generate_outreach(req.company, req.product)
|
||||||
return {"success": True, "data": result}
|
return {"success": True, "data": result, "credits_remaining": balance - 3}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
|
await credit_svc.add_credits(user_id, 3, "refund", "生成失败退回次数")
|
||||||
|
logger.error(f"Outreach generation failed: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="生成失败,请稍后重试")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from fastapi import APIRouter
|
from fastapi import APIRouter, HTTPException
|
||||||
from app.services.exchange import ExchangeRateService
|
from app.services.exchange import ExchangeRateService
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ async def convert_currency(
|
|||||||
):
|
):
|
||||||
rate = await service.get_rate(from_currency, to_currency)
|
rate = await service.get_rate(from_currency, to_currency)
|
||||||
if rate is None:
|
if rate is None:
|
||||||
return {"error": f"No rate available for {from_currency} -> {to_currency}"}
|
raise HTTPException(status_code=404, detail=f"汇率不可用: {from_currency} → {to_currency}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"from_currency": from_currency.upper(),
|
"from_currency": from_currency.upper(),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.services.followup_engine import FollowupEngine
|
from app.services.followup_engine import FollowupEngine
|
||||||
|
from app.services.credit import CreditService
|
||||||
from app.api.v1.deps import get_current_user_id
|
from app.api.v1.deps import get_current_user_id
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -84,6 +85,14 @@ async def trigger_followup_scan(
|
|||||||
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),
|
||||||
):
|
):
|
||||||
|
credit_svc = CreditService(db)
|
||||||
|
ok, balance = await credit_svc.deduct(user_id, "followup_scan")
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=402,
|
||||||
|
detail=f"次数不足 (剩余 {balance:.1f}, 需要 2)"
|
||||||
|
)
|
||||||
|
|
||||||
engine = FollowupEngine(db)
|
engine = FollowupEngine(db)
|
||||||
result = await engine.scan_and_followup()
|
result = await engine.scan_and_followup()
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.services.marketing import MarketingService
|
from app.services.marketing import MarketingService
|
||||||
from app.services.preference import UserPreferenceService
|
from app.services.preference import UserPreferenceService
|
||||||
|
from app.services.credit import CreditService
|
||||||
from app.core.security import decode_token
|
from app.core.security import decode_token
|
||||||
from app.api.v1.deps import get_current_user_id
|
from app.api.v1.deps import get_current_user_id
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -45,6 +46,14 @@ async def generate_marketing(
|
|||||||
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),
|
||||||
):
|
):
|
||||||
|
credit_svc = CreditService(db)
|
||||||
|
ok, balance = await credit_svc.deduct(user_id, "marketing_content")
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=402,
|
||||||
|
detail=f"次数不足 (剩余 {balance:.1f}, 需要 5)"
|
||||||
|
)
|
||||||
|
|
||||||
service = MarketingService()
|
service = MarketingService()
|
||||||
pref_service = UserPreferenceService(db)
|
pref_service = UserPreferenceService(db)
|
||||||
pref_context = await pref_service.get_preference_context(user_id, "marketing")
|
pref_context = await pref_service.get_preference_context(user_id, "marketing")
|
||||||
@@ -63,13 +72,23 @@ async def generate_marketing(
|
|||||||
"product": data.product_name,
|
"product": data.product_name,
|
||||||
"target": data.target,
|
"target": data.target,
|
||||||
"count": len(results),
|
"count": len(results),
|
||||||
|
"credits_remaining": balance - 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/keywords")
|
@router.post("/keywords")
|
||||||
async def generate_keywords(data: KeywordsRequest, authorization: str = Header(None)):
|
async def generate_keywords(
|
||||||
if not authorization:
|
data: KeywordsRequest,
|
||||||
raise HTTPException(status_code=401, detail="Missing token")
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
credit_svc = CreditService(db)
|
||||||
|
ok, balance = await credit_svc.deduct(user_id, "marketing_content")
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=402,
|
||||||
|
detail=f"次数不足 (剩余 {balance:.1f}, 需要 5)"
|
||||||
|
)
|
||||||
|
|
||||||
service = MarketingService()
|
service = MarketingService()
|
||||||
product_info = {
|
product_info = {
|
||||||
@@ -79,13 +98,22 @@ async def generate_keywords(data: KeywordsRequest, authorization: str = Header(N
|
|||||||
}
|
}
|
||||||
keywords = await service.generate_keywords(product_info, data.language, data.count)
|
keywords = await service.generate_keywords(product_info, data.language, data.count)
|
||||||
|
|
||||||
return {"keywords": keywords, "product": data.product_name}
|
return {"keywords": keywords, "product": data.product_name, "credits_remaining": balance - 5}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/competitor-analysis")
|
@router.post("/competitor-analysis")
|
||||||
async def competitor_analysis(data: CompetitorRequest, authorization: str = Header(None)):
|
async def competitor_analysis(
|
||||||
if not authorization:
|
data: CompetitorRequest,
|
||||||
raise HTTPException(status_code=401, detail="Missing token")
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
credit_svc = CreditService(db)
|
||||||
|
ok, balance = await credit_svc.deduct(user_id, "competitor_analysis")
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=402,
|
||||||
|
detail=f"次数不足 (剩余 {balance:.1f}, 需要 10)"
|
||||||
|
)
|
||||||
|
|
||||||
service = MarketingService()
|
service = MarketingService()
|
||||||
product_info = {
|
product_info = {
|
||||||
@@ -95,4 +123,4 @@ async def competitor_analysis(data: CompetitorRequest, authorization: str = Head
|
|||||||
}
|
}
|
||||||
analysis = await service.analyze_competitors(product_info, data.market)
|
analysis = await service.analyze_competitors(product_info, data.market)
|
||||||
|
|
||||||
return {"analysis": analysis, "product": data.product_name, "market": data.market}
|
return {"analysis": analysis, "product": data.product_name, "market": data.market, "credits_remaining": balance - 10}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Query
|
import json
|
||||||
|
import logging
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Query, Header
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
from pydantic import BaseModel
|
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, GATEWAY_MAP
|
||||||
|
from app.services.unified_pay import UnifiedPayService
|
||||||
|
from app.models.payment_transaction import PaymentTransaction
|
||||||
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
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@@ -40,7 +44,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 +81,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 +89,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 +138,143 @@ 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"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/stripe-webhook")
|
||||||
|
async def stripe_webhook(
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
body = await request.body()
|
||||||
|
body_str = body.decode("utf-8")
|
||||||
|
|
||||||
|
stripe_gw = GATEWAY_MAP.get("stripe")
|
||||||
|
if not stripe_gw:
|
||||||
|
raise HTTPException(status_code=501, detail="Stripe 未配置")
|
||||||
|
|
||||||
|
if not stripe_gw.verify_callback(dict(request.headers), body_str):
|
||||||
|
raise HTTPException(status_code=403, detail="Stripe 签名验证失败")
|
||||||
|
|
||||||
|
parsed = stripe_gw.parse_callback(body_str, dict(request.headers))
|
||||||
|
if parsed.get("success"):
|
||||||
|
svc = PaymentService(db)
|
||||||
|
await svc.handle_callback(
|
||||||
|
parsed["order_no"],
|
||||||
|
parsed["gateway_order_id"],
|
||||||
|
parsed["gateway_order_no"],
|
||||||
|
True,
|
||||||
|
parsed["amount"],
|
||||||
|
body_str,
|
||||||
|
)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/paypal-capture")
|
||||||
|
async def paypal_capture(
|
||||||
|
request: Request,
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
body = await request.json()
|
||||||
|
order_no = body.get("order_no", "")
|
||||||
|
token = body.get("token", "")
|
||||||
|
|
||||||
|
if not order_no or not token:
|
||||||
|
raise HTTPException(status_code=400, detail="缺少参数")
|
||||||
|
|
||||||
|
txn_result = await db.execute(
|
||||||
|
select(PaymentTransaction).where(
|
||||||
|
PaymentTransaction.order_no == order_no,
|
||||||
|
PaymentTransaction.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
txn = txn_result.scalar_one_or_none()
|
||||||
|
if not txn:
|
||||||
|
raise HTTPException(status_code=404, detail="订单不存在")
|
||||||
|
if txn.status != "pending":
|
||||||
|
return {"status": "ok", "message": "已处理"}
|
||||||
|
|
||||||
|
paypal_gw = GATEWAY_MAP.get("paypal")
|
||||||
|
if not paypal_gw:
|
||||||
|
raise HTTPException(status_code=501, detail="PayPal 未配置")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await paypal_gw.capture_order(token)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
if result.get("completed"):
|
||||||
|
capture_id = result.get("capture_id", token)
|
||||||
|
svc = PaymentService(db)
|
||||||
|
await svc.handle_callback(
|
||||||
|
order_no, token, capture_id, True, txn.amount, json.dumps(result)
|
||||||
|
)
|
||||||
|
return {"status": "completed", "order_no": order_no}
|
||||||
|
raise HTTPException(status_code=400, detail=f"PayPal capture failed: {result.get('status')}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/paypal-webhook")
|
||||||
|
async def paypal_webhook(
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
body = await request.body()
|
||||||
|
body_str = body.decode("utf-8")
|
||||||
|
|
||||||
|
paypal_gw = GATEWAY_MAP.get("paypal")
|
||||||
|
if not paypal_gw:
|
||||||
|
raise HTTPException(status_code=501, detail="PayPal 未配置")
|
||||||
|
|
||||||
|
if not paypal_gw.verify_callback(dict(request.headers), body_str):
|
||||||
|
raise HTTPException(status_code=403, detail="PayPal 签名验证失败")
|
||||||
|
|
||||||
|
parsed = paypal_gw.parse_callback(body_str, dict(request.headers))
|
||||||
|
if parsed.get("success"):
|
||||||
|
svc = PaymentService(db)
|
||||||
|
await svc.handle_callback(
|
||||||
|
parsed["order_no"],
|
||||||
|
parsed["gateway_order_id"],
|
||||||
|
parsed["gateway_order_no"],
|
||||||
|
True,
|
||||||
|
parsed["amount"],
|
||||||
|
body_str,
|
||||||
|
)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/pingpong-webhook")
|
||||||
|
async def pingpong_webhook(
|
||||||
|
request: Request,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
body = await request.body()
|
||||||
|
body_str = body.decode("utf-8")
|
||||||
|
|
||||||
|
pp_gw = GATEWAY_MAP.get("pingpong")
|
||||||
|
if not pp_gw:
|
||||||
|
raise HTTPException(status_code=501, detail="PingPong 未配置")
|
||||||
|
|
||||||
|
if not pp_gw.verify_callback(dict(request.headers), body_str):
|
||||||
|
raise HTTPException(status_code=403, detail="PingPong 签名验证失败")
|
||||||
|
|
||||||
|
parsed = pp_gw.parse_callback(body_str, dict(request.headers))
|
||||||
|
if parsed.get("success"):
|
||||||
|
svc = PaymentService(db)
|
||||||
|
await svc.handle_callback(
|
||||||
|
parsed["order_no"],
|
||||||
|
parsed["gateway_order_id"],
|
||||||
|
parsed["gateway_order_no"],
|
||||||
|
True,
|
||||||
|
parsed["amount"],
|
||||||
|
body_str,
|
||||||
|
)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|||||||
@@ -100,9 +100,26 @@ async def import_products(
|
|||||||
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),
|
||||||
):
|
):
|
||||||
|
from app.services.product import ProductService
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
MAX_UPLOAD_SIZE = settings.MAX_UPLOAD_SIZE
|
||||||
|
|
||||||
|
filename = file.filename or "unknown"
|
||||||
|
file_size = 0
|
||||||
|
content = b""
|
||||||
|
while True:
|
||||||
|
chunk = await file.read(8192)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
file_size += len(chunk)
|
||||||
|
if file_size > MAX_UPLOAD_SIZE:
|
||||||
|
raise HTTPException(status_code=413, detail=f"File too large. Max {MAX_UPLOAD_SIZE // (1024*1024)}MB")
|
||||||
|
content += chunk
|
||||||
|
|
||||||
service = ProductService(db)
|
service = ProductService(db)
|
||||||
content = await file.read()
|
filename_lower = filename.lower()
|
||||||
filename = file.filename.lower()
|
|
||||||
|
|
||||||
if filename.endswith(".xlsx"):
|
if filename.endswith(".xlsx"):
|
||||||
if not HAS_OPENPYXL:
|
if not HAS_OPENPYXL:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from app.database import get_db
|
|||||||
from app.services.quotation import QuotationService
|
from app.services.quotation import QuotationService
|
||||||
from app.services.pdf_generator import pdf_generator
|
from app.services.pdf_generator import pdf_generator
|
||||||
from app.services import export
|
from app.services import export
|
||||||
|
from app.services.credit import CreditService
|
||||||
from app.api.v1.deps import get_current_user_id
|
from app.api.v1.deps import get_current_user_id
|
||||||
from app.models.quotation import Quotation
|
from app.models.quotation import Quotation
|
||||||
from app.models.customer import Customer
|
from app.models.customer import Customer
|
||||||
@@ -35,6 +36,14 @@ async def generate_from_inquiry(
|
|||||||
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),
|
||||||
):
|
):
|
||||||
|
credit_svc = CreditService(db)
|
||||||
|
ok, balance = await credit_svc.deduct(user_id, "quotation")
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=402,
|
||||||
|
detail=f"次数不足 (剩余 {balance:.1f}, 需要 2)"
|
||||||
|
)
|
||||||
|
|
||||||
service = QuotationService(db)
|
service = QuotationService(db)
|
||||||
result = await service.generate_from_inquiry(
|
result = await service.generate_from_inquiry(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from app.database import get_db
|
|||||||
from app.services.translation import TranslationService
|
from app.services.translation import TranslationService
|
||||||
from app.services.tts import tts_service
|
from app.services.tts import tts_service
|
||||||
from app.services.preference import UserPreferenceService
|
from app.services.preference import UserPreferenceService
|
||||||
|
from app.services.credit import CreditService
|
||||||
from app.core.security import decode_token
|
from app.core.security import decode_token
|
||||||
from app.api.v1.deps import get_current_user_id
|
from app.api.v1.deps import get_current_user_id
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ class ExtractRequest(BaseModel):
|
|||||||
async def translate_text(
|
async def translate_text(
|
||||||
data: TranslateRequest,
|
data: TranslateRequest,
|
||||||
user_id: str = Depends(get_current_user_id),
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
service = TranslationService()
|
service = TranslationService()
|
||||||
result = await service.translate(
|
result = await service.translate(
|
||||||
@@ -44,6 +46,13 @@ async def translate_text(
|
|||||||
context=data.context,
|
context=data.context,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
credit_svc = CreditService(db)
|
||||||
|
char_count = len(data.text)
|
||||||
|
await credit_svc.deduct(
|
||||||
|
user_id, "translate",
|
||||||
|
metadata={"chars": char_count, "target_lang": data.target_lang},
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -54,6 +63,15 @@ async def generate_reply(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
pref_service = UserPreferenceService(db)
|
pref_service = UserPreferenceService(db)
|
||||||
|
|
||||||
|
credit_svc = CreditService(db)
|
||||||
|
ok, balance = await credit_svc.deduct(user_id, "reply_suggest")
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=402,
|
||||||
|
detail=f"次数不足 (剩余 {balance:.1f}, 需要 2)"
|
||||||
|
)
|
||||||
|
|
||||||
pref_context = await pref_service.get_preference_context(user_id, "reply")
|
pref_context = await pref_service.get_preference_context(user_id, "reply")
|
||||||
|
|
||||||
service = TranslationService()
|
service = TranslationService()
|
||||||
@@ -71,7 +89,16 @@ async def generate_reply(
|
|||||||
async def extract_info(
|
async def extract_info(
|
||||||
data: ExtractRequest,
|
data: ExtractRequest,
|
||||||
user_id: str = Depends(get_current_user_id),
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
credit_svc = CreditService(db)
|
||||||
|
ok, balance = await credit_svc.deduct(user_id, "info_extract")
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=402,
|
||||||
|
detail=f"次数不足 (剩余 {balance:.1f}, 需要 1)"
|
||||||
|
)
|
||||||
|
|
||||||
service = TranslationService()
|
service = TranslationService()
|
||||||
result = await service.extract_info(data.text, data.extract_type)
|
result = await service.extract_info(data.text, data.extract_type)
|
||||||
return {"extracted": result, "type": data.extract_type}
|
return {"extracted": result, "type": data.extract_type}
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ async def handle_webhook(
|
|||||||
if x_hub_signature_256:
|
if x_hub_signature_256:
|
||||||
if not svc.verify_signature(body, x_hub_signature_256):
|
if not svc.verify_signature(body, x_hub_signature_256):
|
||||||
raise HTTPException(status_code=403, detail="Invalid signature")
|
raise HTTPException(status_code=403, detail="Invalid signature")
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=403, detail="Missing signature")
|
||||||
|
|
||||||
import json
|
import json
|
||||||
body_json = json.loads(body)
|
body_json = json.loads(body)
|
||||||
|
|||||||
@@ -84,5 +84,44 @@ class Settings(BaseSettings):
|
|||||||
PRO_MAX_PRODUCTS: int = 20
|
PRO_MAX_PRODUCTS: int = 20
|
||||||
PRO_DAILY_QUOTATIONS: int = 30
|
PRO_DAILY_QUOTATIONS: int = 30
|
||||||
|
|
||||||
|
# Stripe
|
||||||
|
STRIPE_SECRET_KEY: Optional[str] = None
|
||||||
|
STRIPE_WEBHOOK_SECRET: Optional[str] = None
|
||||||
|
STRIPE_PRICE_ID_20: Optional[str] = None
|
||||||
|
STRIPE_PRICE_ID_100: Optional[str] = None
|
||||||
|
STRIPE_PRICE_ID_500: Optional[str] = None
|
||||||
|
STRIPE_PRICE_ID_2000: Optional[str] = None
|
||||||
|
|
||||||
|
# PayPal
|
||||||
|
PAYPAL_CLIENT_ID: Optional[str] = None
|
||||||
|
PAYPAL_CLIENT_SECRET: Optional[str] = None
|
||||||
|
PAYPAL_WEBHOOK_ID: Optional[str] = None
|
||||||
|
PAYPAL_SANDBOX: bool = True
|
||||||
|
|
||||||
|
# PingPong
|
||||||
|
PINGPONG_CLIENT_ID: Optional[str] = None
|
||||||
|
PINGPONG_ACC_ID: Optional[str] = None
|
||||||
|
PINGPONG_SECRET_KEY: Optional[str] = None
|
||||||
|
PINGPONG_SANDBOX: bool = True
|
||||||
|
PINGPONG_REGION: str = "EU"
|
||||||
|
|
||||||
|
# Payment prices
|
||||||
|
PRO_MONTHLY_PRICE: int = 99
|
||||||
|
PRO_YEARLY_PRICE: int = 999
|
||||||
|
ENTERPRISE_MONTHLY_PRICE: int = 399
|
||||||
|
ENTERPRISE_YEARLY_PRICE: int = 3999
|
||||||
|
|
||||||
|
# File upload limits
|
||||||
|
MAX_UPLOAD_SIZE: int = 10 * 1024 * 1024 # 10MB
|
||||||
|
MAX_EXCEL_ROWS: int = 10000
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
GUEST_LOGIN_LIMIT: int = 5
|
||||||
|
GUEST_LOGIN_WINDOW: int = 900 # 15 minutes
|
||||||
|
|
||||||
|
# Pagination defaults
|
||||||
|
DEFAULT_PAGE_SIZE: int = 20
|
||||||
|
MAX_PAGE_SIZE: int = 100
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
@@ -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/",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Shared utility functions"""
|
||||||
|
import uuid
|
||||||
|
from typing import Any, Optional
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
|
def validate_uuid(value: str) -> str:
|
||||||
|
"""Validate UUID format and return the value"""
|
||||||
|
try:
|
||||||
|
uuid.UUID(value)
|
||||||
|
return value
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(f"Invalid UUID format: {value}")
|
||||||
|
|
||||||
|
|
||||||
|
def truncate_string(value: str, max_length: int = 100) -> str:
|
||||||
|
"""Truncate string to specified length"""
|
||||||
|
if len(value) <= max_length:
|
||||||
|
return value
|
||||||
|
return value[:max_length]
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_for_logging(value: str) -> str:
|
||||||
|
"""Sanitize string for logging (remove sensitive info)"""
|
||||||
|
# Remove common sensitive patterns
|
||||||
|
import re
|
||||||
|
value = re.sub(r'[^a-zA-Z0-9\s\-_.,:;!?\'"]', '', value)
|
||||||
|
return value[:200] # Limit length for log safety
|
||||||
|
|
||||||
|
|
||||||
|
def paginate_query(query, page: int = 1, size: int = 20) -> dict:
|
||||||
|
"""
|
||||||
|
Paginate a SQLAlchemy query and return results with metadata.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Base SQLAlchemy query
|
||||||
|
page: Page number (1-indexed)
|
||||||
|
size: Items per page
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with items, total, page, size, pages
|
||||||
|
"""
|
||||||
|
from math import ceil
|
||||||
|
|
||||||
|
if page < 1:
|
||||||
|
page = 1
|
||||||
|
if size < 1 or size > 100:
|
||||||
|
size = 20
|
||||||
|
|
||||||
|
offset = (page - 1) * size
|
||||||
|
total_query = select(func.count()).select_from(query.subquery())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": query.offset(offset).limit(size).all(),
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"size": size,
|
||||||
|
"pages": ceil(total / size) if total > 0 else 0,
|
||||||
|
}
|
||||||
+3
-1
@@ -129,7 +129,7 @@ async def health():
|
|||||||
return {"status": "ok", "app": settings.APP_NAME, "version": "1.0.0"}
|
return {"status": "ok", "app": settings.APP_NAME, "version": "1.0.0"}
|
||||||
|
|
||||||
|
|
||||||
from app.api.v1 import auth, marketing, translate, customer, quotation, whatsapp, product, exchange, push, admin, analytics, teams, onboarding, notification, feedback, payment, interaction, silent_pattern, training, followup, ai_assistant, discovery, discovery_record, certification, invoice, usage, referral, admin_search, search, admin_ai
|
from app.api.v1 import auth, marketing, translate, customer, quotation, whatsapp, product, exchange, push, admin, analytics, teams, onboarding, notification, feedback, payment, interaction, silent_pattern, training, followup, ai_assistant, discovery, discovery_record, certification, invoice, usage, referral, admin_search, search, admin_ai, credits, admin_credits
|
||||||
|
|
||||||
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
||||||
app.include_router(marketing.router, prefix="/api/v1/marketing", tags=["marketing"])
|
app.include_router(marketing.router, prefix="/api/v1/marketing", tags=["marketing"])
|
||||||
@@ -161,6 +161,8 @@ app.include_router(usage.router, prefix="/api/v1/usage", tags=["usage"])
|
|||||||
app.include_router(referral.router, prefix="/api/v1/referral", tags=["referral"])
|
app.include_router(referral.router, prefix="/api/v1/referral", tags=["referral"])
|
||||||
app.include_router(admin_search.router, prefix="/api/v1/admin", tags=["admin"])
|
app.include_router(admin_search.router, prefix="/api/v1/admin", tags=["admin"])
|
||||||
app.include_router(admin_ai.router, prefix="/api/v1/admin", tags=["admin"])
|
app.include_router(admin_ai.router, prefix="/api/v1/admin", tags=["admin"])
|
||||||
|
app.include_router(admin_credits.router, prefix="/api/v1/admin", tags=["admin"])
|
||||||
|
app.include_router(credits.router, prefix="/api/v1/credits", tags=["credits"])
|
||||||
app.include_router(search.router, prefix="/api/v1/search", tags=["search"])
|
app.include_router(search.router, prefix="/api/v1/search", tags=["search"])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ from .search_provider import SearchProvider
|
|||||||
from .discovery_record import DiscoveryRecord
|
from .discovery_record import DiscoveryRecord
|
||||||
from .ai_provider import AIProvider
|
from .ai_provider import AIProvider
|
||||||
from .payment_transaction import PaymentTransaction
|
from .payment_transaction import PaymentTransaction
|
||||||
|
from .credit_package import CreditPackage, SubscriptionPlan
|
||||||
|
from .user_credit import UserCredit
|
||||||
|
from .credit_consumption import CreditConsumption
|
||||||
|
from .credit_purchase import CreditPurchase
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User", "Product",
|
"User", "Product",
|
||||||
@@ -37,4 +41,8 @@ __all__ = [
|
|||||||
"DiscoveryRecord",
|
"DiscoveryRecord",
|
||||||
"AIProvider",
|
"AIProvider",
|
||||||
"PaymentTransaction",
|
"PaymentTransaction",
|
||||||
|
"CreditPackage", "SubscriptionPlan",
|
||||||
|
"UserCredit",
|
||||||
|
"CreditConsumption",
|
||||||
|
"CreditPurchase",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from sqlalchemy import Column, String, Float, DateTime, ForeignKey, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
|
from datetime import datetime
|
||||||
|
from app.database import Base
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class CreditConsumption(Base):
|
||||||
|
__tablename__ = "credit_consumptions"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
result_type = Column(String(50), nullable=False)
|
||||||
|
reference_id = Column(UUID(as_uuid=True), nullable=True)
|
||||||
|
credits_change = Column(Float, nullable=False)
|
||||||
|
balance_after = Column(Float, nullable=False)
|
||||||
|
source = Column(String(30), nullable=False)
|
||||||
|
description = Column(String(500))
|
||||||
|
metadata_ = Column("metadata", JSONB)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from sqlalchemy import Column, String, Integer, Float, Boolean, DateTime
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
from app.database import Base
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class CreditPackage(Base):
|
||||||
|
__tablename__ = "credit_packages"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
name_en = Column(String(100), nullable=False)
|
||||||
|
credits = Column(Integer, nullable=False)
|
||||||
|
price = Column(Float, nullable=False)
|
||||||
|
price_usd = Column(Float)
|
||||||
|
original_price = Column(Float)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
sort_order = Column(Integer, default=0)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionPlan(Base):
|
||||||
|
__tablename__ = "subscription_plans"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
name_en = Column(String(100), nullable=False)
|
||||||
|
credits_per_month = Column(Integer, nullable=False)
|
||||||
|
price = Column(Float, nullable=False)
|
||||||
|
price_usd = Column(Float)
|
||||||
|
duration_days = Column(Integer, default=30)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
sort_order = Column(Integer, default=0)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from sqlalchemy import Column, String, Integer, Float, DateTime, ForeignKey
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
from app.database import Base
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class CreditPurchase(Base):
|
||||||
|
__tablename__ = "credit_purchases"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
package_id = Column(UUID(as_uuid=True), ForeignKey("credit_packages.id"), nullable=True)
|
||||||
|
subscription_plan_id = Column(UUID(as_uuid=True), ForeignKey("subscription_plans.id"), nullable=True)
|
||||||
|
credits = Column(Integer, nullable=False)
|
||||||
|
amount = Column(Float, nullable=False)
|
||||||
|
currency = Column(String(3), default="CNY")
|
||||||
|
payment_method = Column(String(20))
|
||||||
|
status = Column(String(20), default="pending")
|
||||||
|
payment_transaction_id = Column(UUID(as_uuid=True), ForeignKey("payment_transactions.id"), nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
paid_at = Column(DateTime, nullable=True)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from sqlalchemy import Column, String, Float, Boolean, DateTime, ForeignKey, Integer, Date
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
from app.database import Base
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class UserCredit(Base):
|
||||||
|
__tablename__ = "user_credits"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, unique=True, index=True)
|
||||||
|
balance = Column(Float, default=0)
|
||||||
|
total_purchased = Column(Float, default=0)
|
||||||
|
total_used = Column(Float, default=0)
|
||||||
|
|
||||||
|
subscription_plan_id = Column(UUID(as_uuid=True), ForeignKey("subscription_plans.id"), nullable=True)
|
||||||
|
subscription_expires_at = Column(DateTime, nullable=True)
|
||||||
|
subscription_auto_renew = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
free_trial_used = Column(Boolean, default=False)
|
||||||
|
daily_translate_chars = Column(Integer, default=0)
|
||||||
|
daily_translate_date = Column(Date, nullable=True)
|
||||||
|
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
+108
-22
@@ -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)
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func, desc
|
||||||
|
from datetime import datetime, date
|
||||||
|
from decimal import Decimal
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.models import UserCredit, CreditConsumption, CreditPackage, SubscriptionPlan, CreditPurchase
|
||||||
|
from app.models.system_config import SystemConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_CONSUMPTION_RATES = {
|
||||||
|
"lead_search": 10,
|
||||||
|
"company_analysis": 5,
|
||||||
|
"market_intel": 20,
|
||||||
|
"translate_per_1000chars": 1,
|
||||||
|
"reply_suggest": 2,
|
||||||
|
"outreach": 3,
|
||||||
|
"marketing_content": 5,
|
||||||
|
"competitor_analysis": 10,
|
||||||
|
"ai_chat_per_10msg": 1,
|
||||||
|
"info_extract": 1,
|
||||||
|
"quotation": 2,
|
||||||
|
"followup_scan": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
FREE_TRIAL_CREDITS = 30
|
||||||
|
DAILY_FREE_TRANSLATE_CHARS = 1000
|
||||||
|
|
||||||
|
|
||||||
|
class CreditService:
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def _ensure_credit(self, user_id: str) -> UserCredit:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(UserCredit).where(UserCredit.user_id == user_id)
|
||||||
|
)
|
||||||
|
uc = result.scalar_one_or_none()
|
||||||
|
if not uc:
|
||||||
|
uc = UserCredit(user_id=user_id, balance=0)
|
||||||
|
self.db.add(uc)
|
||||||
|
await self.db.flush()
|
||||||
|
return uc
|
||||||
|
|
||||||
|
async def get_balance(self, user_id: str) -> dict:
|
||||||
|
uc = await self._ensure_credit(user_id)
|
||||||
|
rates = await self._get_rates()
|
||||||
|
return {
|
||||||
|
"balance": uc.balance,
|
||||||
|
"total_purchased": uc.total_purchased,
|
||||||
|
"total_used": uc.total_used,
|
||||||
|
"subscription": {
|
||||||
|
"plan_id": str(uc.subscription_plan_id) if uc.subscription_plan_id else None,
|
||||||
|
"expires_at": uc.subscription_expires_at.isoformat() if uc.subscription_expires_at else None,
|
||||||
|
"auto_renew": uc.subscription_auto_renew,
|
||||||
|
} if uc.subscription_plan_id else None,
|
||||||
|
"free_trial_used": uc.free_trial_used,
|
||||||
|
"daily_free_translate_chars_left": max(0, DAILY_FREE_TRANSLATE_CHARS - await self._daily_translate_chars(uc)),
|
||||||
|
"rates": rates,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def deduct(self, user_id: str, result_type: str, reference_id: str = None, amount: float = None, metadata: dict = None) -> tuple[bool, float]:
|
||||||
|
rates = await self._get_rates()
|
||||||
|
cost = amount or rates.get(result_type, 1)
|
||||||
|
|
||||||
|
uc = await self._ensure_credit(user_id)
|
||||||
|
|
||||||
|
if result_type == "translate":
|
||||||
|
char_count = (metadata or {}).get("chars", 0)
|
||||||
|
if char_count > 0:
|
||||||
|
daily_free = await self._daily_translate_chars(uc)
|
||||||
|
free_remaining = max(0, DAILY_FREE_TRANSLATE_CHARS - daily_free)
|
||||||
|
free_used = min(free_remaining, char_count)
|
||||||
|
paid_chars = char_count - free_used
|
||||||
|
cost = (paid_chars / 1000) * rates.get("translate_per_1000chars", 1)
|
||||||
|
if free_used > 0:
|
||||||
|
today = date.today()
|
||||||
|
if uc.daily_translate_date != today:
|
||||||
|
uc.daily_translate_date = today
|
||||||
|
uc.daily_translate_chars = 0
|
||||||
|
uc.daily_translate_chars += free_used
|
||||||
|
await self.db.flush()
|
||||||
|
|
||||||
|
if cost <= 0:
|
||||||
|
await self._log(user_id, result_type, reference_id, 0, uc.balance, "daily_free", metadata)
|
||||||
|
return True, uc.balance
|
||||||
|
|
||||||
|
if uc.balance < cost:
|
||||||
|
return False, uc.balance
|
||||||
|
|
||||||
|
uc.balance -= cost
|
||||||
|
uc.total_used += cost
|
||||||
|
balance_after = uc.balance
|
||||||
|
|
||||||
|
await self._log(user_id, result_type, reference_id, -cost, balance_after, "credit", metadata)
|
||||||
|
await self.db.flush()
|
||||||
|
return True, balance_after
|
||||||
|
|
||||||
|
async def add_credits(self, user_id: str, credits: float, source: str, description: str = None) -> float:
|
||||||
|
uc = await self._ensure_credit(user_id)
|
||||||
|
uc.balance += credits
|
||||||
|
if credits > 0:
|
||||||
|
uc.total_purchased += credits
|
||||||
|
balance_after = uc.balance
|
||||||
|
await self._log(user_id, "topup", None, credits, balance_after, source, {"description": description})
|
||||||
|
await self.db.flush()
|
||||||
|
return balance_after
|
||||||
|
|
||||||
|
async def grant_free_trial(self, user_id: str) -> float:
|
||||||
|
uc = await self._ensure_credit(user_id)
|
||||||
|
if uc.free_trial_used:
|
||||||
|
return uc.balance
|
||||||
|
return await self.add_credits(
|
||||||
|
user_id, FREE_TRIAL_CREDITS, "free_trial",
|
||||||
|
f"新用户注册赠送 {FREE_TRIAL_CREDITS} 次"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def consume_for_subscription(self, user_id: str, plan_id: str) -> tuple[bool, str]:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(SubscriptionPlan).where(SubscriptionPlan.id == plan_id, SubscriptionPlan.is_active == True)
|
||||||
|
)
|
||||||
|
plan = result.scalar_one_or_none()
|
||||||
|
if not plan:
|
||||||
|
return False, "套餐不存在"
|
||||||
|
|
||||||
|
uc = await self._ensure_credit(user_id)
|
||||||
|
amount = plan.price
|
||||||
|
|
||||||
|
return True, "ok"
|
||||||
|
|
||||||
|
async def _log(self, user_id: str, result_type: str, reference_id: str,
|
||||||
|
credits_change: float, balance_after: float, source: str, metadata: dict = None):
|
||||||
|
log = CreditConsumption(
|
||||||
|
user_id=user_id,
|
||||||
|
result_type=result_type,
|
||||||
|
reference_id=reference_id,
|
||||||
|
credits_change=credits_change,
|
||||||
|
balance_after=balance_after,
|
||||||
|
source=source,
|
||||||
|
metadata_=metadata or {},
|
||||||
|
)
|
||||||
|
self.db.add(log)
|
||||||
|
|
||||||
|
async def get_history(self, user_id: str, page: int = 1, size: int = 20) -> dict:
|
||||||
|
offset = (page - 1) * size
|
||||||
|
stmt = select(CreditConsumption).where(
|
||||||
|
CreditConsumption.user_id == user_id
|
||||||
|
).order_by(desc(CreditConsumption.created_at)).offset(offset).limit(size)
|
||||||
|
result = await self.db.execute(stmt)
|
||||||
|
items = result.scalars().all()
|
||||||
|
|
||||||
|
count_stmt = select(func.count()).where(CreditConsumption.user_id == user_id)
|
||||||
|
count_result = await self.db.execute(count_stmt)
|
||||||
|
total = count_result.scalar() or 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": [{
|
||||||
|
"id": str(item.id),
|
||||||
|
"result_type": item.result_type,
|
||||||
|
"credits_change": item.credits_change,
|
||||||
|
"balance_after": item.balance_after,
|
||||||
|
"source": item.source,
|
||||||
|
"description": item.description,
|
||||||
|
"created_at": item.created_at.isoformat() if item.created_at else None,
|
||||||
|
} for item in items],
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"size": size,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _get_rates(self) -> dict:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(SystemConfig).where(SystemConfig.key == "credit_consumption_rates")
|
||||||
|
)
|
||||||
|
row = result.scalar_one_or_none()
|
||||||
|
if row and row.value:
|
||||||
|
return {**DEFAULT_CONSUMPTION_RATES, **row.value}
|
||||||
|
return dict(DEFAULT_CONSUMPTION_RATES)
|
||||||
|
|
||||||
|
async def _daily_translate_chars(self, uc: UserCredit) -> int:
|
||||||
|
today = date.today()
|
||||||
|
if uc.daily_translate_date != today:
|
||||||
|
return 0
|
||||||
|
return uc.daily_translate_chars or 0
|
||||||
|
|
||||||
|
async def get_packages(self) -> list:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(CreditPackage).where(CreditPackage.is_active == True).order_by(CreditPackage.sort_order)
|
||||||
|
)
|
||||||
|
return [{
|
||||||
|
"id": str(p.id),
|
||||||
|
"name": p.name,
|
||||||
|
"name_en": p.name_en,
|
||||||
|
"credits": p.credits,
|
||||||
|
"price": p.price,
|
||||||
|
"price_usd": p.price_usd,
|
||||||
|
"original_price": p.original_price,
|
||||||
|
} for p in result.scalars().all()]
|
||||||
|
|
||||||
|
async def get_subscription_plans(self) -> list:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(SubscriptionPlan).where(SubscriptionPlan.is_active == True).order_by(SubscriptionPlan.sort_order)
|
||||||
|
)
|
||||||
|
return [{
|
||||||
|
"id": str(p.id),
|
||||||
|
"name": p.name,
|
||||||
|
"name_en": p.name_en,
|
||||||
|
"credits_per_month": p.credits_per_month,
|
||||||
|
"price": p.price,
|
||||||
|
"price_usd": p.price_usd,
|
||||||
|
"duration_days": p.duration_days,
|
||||||
|
} for p in result.scalars().all()]
|
||||||
|
|
||||||
|
async def get_stats(self) -> dict:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(func.coalesce(func.sum(UserCredit.total_purchased), 0))
|
||||||
|
)
|
||||||
|
total_purchased = result.scalar()
|
||||||
|
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(func.coalesce(func.sum(UserCredit.balance), 0))
|
||||||
|
)
|
||||||
|
total_balance = result.scalar()
|
||||||
|
|
||||||
|
result = await self.db.execute(select(func.count(UserCredit.id)))
|
||||||
|
total_users = result.scalar()
|
||||||
|
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(func.coalesce(func.sum(CreditConsumption.credits_change), 0)).where(
|
||||||
|
CreditConsumption.credits_change < 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
total_consumed = abs(result.scalar() or 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_purchased": total_purchased,
|
||||||
|
"total_balance": total_balance,
|
||||||
|
"total_consumed": total_consumed,
|
||||||
|
"total_users_with_credits": total_users,
|
||||||
|
}
|
||||||
|
|
||||||
|
CREDIT_CONSUMPTION = {
|
||||||
|
"lead_search": 10,
|
||||||
|
"company_analysis": 5,
|
||||||
|
"market_intel": 20,
|
||||||
|
"translate_per_1000chars": 1,
|
||||||
|
"reply_suggest": 2,
|
||||||
|
"outreach": 3,
|
||||||
|
"marketing_content": 5,
|
||||||
|
"competitor_analysis": 10,
|
||||||
|
"ai_chat": 1,
|
||||||
|
"info_extract": 1,
|
||||||
|
"quotation": 2,
|
||||||
|
"followup_scan": 2,
|
||||||
|
}
|
||||||
@@ -63,10 +63,18 @@ class CustomerHealthService:
|
|||||||
return await self._compute_full_health(user_id, customer)
|
return await self._compute_full_health(user_id, customer)
|
||||||
|
|
||||||
async def get_all_health_scores(self, user_id: str) -> List[Dict[str, Any]]:
|
async def get_all_health_scores(self, user_id: str) -> List[Dict[str, Any]]:
|
||||||
|
# Use eager loading to avoid N+1 query problem
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
customers_result = await self.db.execute(
|
customers_result = await self.db.execute(
|
||||||
select(Customer).where(Customer.user_id == user_id).order_by(Customer.updated_at.desc())
|
select(Customer)
|
||||||
|
.options(selectinload(Customer.conversations))
|
||||||
|
.where(Customer.user_id == user_id)
|
||||||
|
.order_by(Customer.updated_at.desc())
|
||||||
)
|
)
|
||||||
customers = customers_result.scalars().all()
|
customers = customers_result.scalars().all()
|
||||||
|
|
||||||
|
# Batch process customers instead of individual queries
|
||||||
results = []
|
results = []
|
||||||
for c in customers:
|
for c in customers:
|
||||||
health = await self._compute_full_health(user_id, c)
|
health = await self._compute_full_health(user_id, c)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -90,6 +92,61 @@ URL: {company_url}
|
|||||||
logger.warning(f"Analysis AI parse failed: {e}")
|
logger.warning(f"Analysis AI parse failed: {e}")
|
||||||
return self._template_analysis(company_url)
|
return self._template_analysis(company_url)
|
||||||
|
|
||||||
|
async def market_intel(self, product_description: str, target_market: str) -> Dict[str, Any]:
|
||||||
|
queries = self._build_queries(product_description, target_market)
|
||||||
|
search_results = await self._web_search_all(queries[:3])
|
||||||
|
companies = search_results.get("results", [])[:10] if search_results else []
|
||||||
|
|
||||||
|
if not self._ai_available:
|
||||||
|
return {
|
||||||
|
"market": target_market,
|
||||||
|
"product": product_description,
|
||||||
|
"trends": [],
|
||||||
|
"competitors": [],
|
||||||
|
"opportunities": "找到 {} 家潜在客户".format(len(companies)),
|
||||||
|
}
|
||||||
|
|
||||||
|
company_summary = "\n".join(
|
||||||
|
f"- {c.get('title','')}: {c.get('snippet','')[:200]}"
|
||||||
|
for c in companies[:5]
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = f"""产品: {product_description}
|
||||||
|
目标市场: {target_market}
|
||||||
|
|
||||||
|
搜索到的相关公司:
|
||||||
|
{company_summary}
|
||||||
|
|
||||||
|
请提供该市场的详细分析报告,以 JSON 格式返回:
|
||||||
|
{{
|
||||||
|
"market_size": "市场规模评估",
|
||||||
|
"trends": ["趋势1", "趋势2", "趋势3"],
|
||||||
|
"competitors": [{{"name": "竞品名", "strength": "优势", "weakness": "劣势"}}],
|
||||||
|
"opportunities": ["机会点1", "机会点2"],
|
||||||
|
"challenges": ["挑战1", "挑战2"],
|
||||||
|
"entry_strategy": "进入市场建议",
|
||||||
|
"price_range": "价格区间参考",
|
||||||
|
"regulatory_notes": "法规注意事项"
|
||||||
|
}}"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await self.ai.chat(prompt,
|
||||||
|
system_prompt="你是资深国际贸易市场分析师。只返回 JSON,不要其他内容。")
|
||||||
|
content = result.get("reply", "")
|
||||||
|
parsed = self._extract_json(content)
|
||||||
|
if not parsed:
|
||||||
|
raise ValueError("Failed to parse AI response")
|
||||||
|
return {**parsed, "market": target_market, "product": product_description}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Market intel failed: {e}")
|
||||||
|
return {
|
||||||
|
"market": target_market,
|
||||||
|
"product": product_description,
|
||||||
|
"trends": ["分析暂时不可用"],
|
||||||
|
"competitors": [],
|
||||||
|
"opportunities": f"找到 {len(companies)} 家相关公司",
|
||||||
|
}
|
||||||
|
|
||||||
async def outreach(self, company_info: Dict[str, Any], product_info: Dict[str, Any]) -> Dict[str, Any]:
|
async def outreach(self, company_info: Dict[str, Any], product_info: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
if not self._ai_available:
|
if not self._ai_available:
|
||||||
return self._template_outreach(company_info, product_info)
|
return self._template_outreach(company_info, product_info)
|
||||||
@@ -124,6 +181,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 +200,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"}
|
||||||
@@ -250,7 +320,8 @@ URL: {company_url}
|
|||||||
try:
|
try:
|
||||||
return json.loads(text[brace:end+1])
|
return json.loads(text[brace:end+1])
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
logger.debug(f"Failed to parse JSON from text: {text[:100]}")
|
||||||
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _suggest_companies(self, product: str, market: str) -> list:
|
def _suggest_companies(self, product: str, market: str) -> list:
|
||||||
|
|||||||
@@ -287,11 +287,20 @@ class FollowupEngine:
|
|||||||
total = len(count_result.scalars().all())
|
total = len(count_result.scalars().all())
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for log in logs:
|
# Use join to avoid N+1 query problem
|
||||||
customer_result = await self.db.execute(
|
query = select(FollowupLog, Customer).join(
|
||||||
select(Customer).where(Customer.id == log.customer_id)
|
Customer, FollowupLog.customer_id == Customer.id, isouter=True
|
||||||
)
|
).where(
|
||||||
customer = customer_result.scalar_one_or_none()
|
FollowupLog.user_id == user_id
|
||||||
|
).order_by(
|
||||||
|
FollowupLog.created_at.desc()
|
||||||
|
).offset((page - 1) * size).limit(size)
|
||||||
|
|
||||||
|
result = await self.db.execute(query)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for log, customer in rows:
|
||||||
items.append({
|
items.append({
|
||||||
"id": str(log.id),
|
"id": str(log.id),
|
||||||
"customer_id": str(log.customer_id),
|
"customer_id": str(log.customer_id),
|
||||||
|
|||||||
@@ -21,19 +21,31 @@ OPTIONAL_COLUMNS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
|
||||||
class ImportService:
|
class ImportService:
|
||||||
|
MAX_ROWS = settings.MAX_EXCEL_ROWS
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse_xlsx(file_bytes: bytes) -> Tuple[List[Dict[str, Any]], List[str]]:
|
def parse_xlsx(file_bytes: bytes) -> Tuple[List[Dict[str, Any]], List[str]]:
|
||||||
if not HAS_OPENPYXL:
|
if not HAS_OPENPYXL:
|
||||||
return [], ["openpyxl not installed"]
|
return [], ["openpyxl not installed"]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
wb = openpyxl.load_workbook(io.BytesIO(file_bytes), read_only=True)
|
# Validate magic bytes for XLSX
|
||||||
|
if len(file_bytes) < 4 or file_bytes[:4] != b'PK\x03\x04':
|
||||||
|
return [], ["Invalid XLSX file format"]
|
||||||
|
|
||||||
|
wb = openpyxl.load_workbook(io.BytesIO(file_bytes), read_only=True, data_only=True)
|
||||||
ws = wb.active
|
ws = wb.active
|
||||||
rows = list(ws.iter_rows(values_only=True))
|
rows = list(ws.iter_rows(values_only=True))
|
||||||
if not rows:
|
if not rows:
|
||||||
return [], ["Empty file"]
|
return [], ["Empty file"]
|
||||||
|
|
||||||
|
if len(rows) > ImportService.MAX_ROWS + 1:
|
||||||
|
return [], [f"File too large. Max {ImportService.MAX_ROWS} data rows"]
|
||||||
|
|
||||||
headers = [str(h).strip().lower() if h else "" for h in rows[0]]
|
headers = [str(h).strip().lower() if h else "" for h in rows[0]]
|
||||||
missing = REQUIRED_COLUMNS - set(headers)
|
missing = REQUIRED_COLUMNS - set(headers)
|
||||||
if missing:
|
if missing:
|
||||||
|
|||||||
@@ -84,11 +84,13 @@ class MCPClientManager:
|
|||||||
try:
|
try:
|
||||||
await self._session.__aexit__(None, None, None)
|
await self._session.__aexit__(None, None, None)
|
||||||
except (BaseExceptionGroup, RuntimeError, Exception):
|
except (BaseExceptionGroup, RuntimeError, Exception):
|
||||||
|
# Cleanup failed, ignore error
|
||||||
pass
|
pass
|
||||||
if self._ctx:
|
if self._ctx:
|
||||||
try:
|
try:
|
||||||
await self._ctx.__aexit__(None, None, None)
|
await self._ctx.__aexit__(None, None, None)
|
||||||
except (BaseExceptionGroup, RuntimeError, Exception):
|
except (BaseExceptionGroup, RuntimeError, Exception):
|
||||||
|
# Cleanup failed, ignore error
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+154
-23
@@ -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
|
||||||
@@ -34,14 +35,29 @@ GATEWAY_MAP: Dict[str, PaymentGateway] = {}
|
|||||||
def init_gateways():
|
def init_gateways():
|
||||||
if settings.PAY_API_KEY:
|
if settings.PAY_API_KEY:
|
||||||
GATEWAY_MAP["unified"] = UnifiedPayService()
|
GATEWAY_MAP["unified"] = UnifiedPayService()
|
||||||
|
if settings.STRIPE_SECRET_KEY:
|
||||||
|
from app.services.stripe_pay import StripePaymentService
|
||||||
|
GATEWAY_MAP["stripe"] = StripePaymentService()
|
||||||
|
if settings.PAYPAL_CLIENT_ID and settings.PAYPAL_CLIENT_SECRET:
|
||||||
|
from app.services.paypal_pay import PayPalPaymentService
|
||||||
|
GATEWAY_MAP["paypal"] = PayPalPaymentService()
|
||||||
|
if settings.PINGPONG_CLIENT_ID and settings.PINGPONG_ACC_ID and settings.PINGPONG_SECRET_KEY:
|
||||||
|
from app.services.pingpong_pay import PingPongCheckoutService
|
||||||
|
GATEWAY_MAP["pingpong"] = PingPongCheckoutService()
|
||||||
|
|
||||||
|
|
||||||
def get_gateway(pay_type: str) -> PaymentGateway:
|
def get_gateway(pay_type: str) -> PaymentGateway:
|
||||||
gw = GATEWAY_MAP.get("unified")
|
gw = GATEWAY_MAP.get("unified")
|
||||||
|
if pay_type in ("card", "stripe", "alipay_stripe", "wechat_stripe"):
|
||||||
|
gw = GATEWAY_MAP.get("stripe") or gw
|
||||||
|
if pay_type in ("paypal",):
|
||||||
|
gw = GATEWAY_MAP.get("paypal") or gw
|
||||||
|
if pay_type in ("pingpong",):
|
||||||
|
gw = GATEWAY_MAP.get("pingpong") or gw
|
||||||
if not gw:
|
if not gw:
|
||||||
raise ValueError("支付网关未配置,请设置 PAY_API_KEY")
|
raise ValueError("支付网关未配置,请设置 PAY_API_KEY / STRIPE_SECRET_KEY / PAYPAL_CLIENT_ID / PINGPONG 配置")
|
||||||
if not gw.supports(pay_type):
|
if not gw.supports(pay_type):
|
||||||
raise ValueError(f"支付方式 {pay_type} 不被支持(仅支持 alipay/wechat)")
|
raise ValueError(f"支付方式 {pay_type} 不被支持")
|
||||||
return gw
|
return gw
|
||||||
|
|
||||||
|
|
||||||
@@ -109,9 +125,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",
|
||||||
@@ -140,6 +158,39 @@ class PaymentService:
|
|||||||
**gw_result,
|
**gw_result,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def create_credit_order(self, user_id: str, amount: float,
|
||||||
|
description: str, pay_type: str = "alipay",
|
||||||
|
metadata: dict = None) -> Dict[str, Any]:
|
||||||
|
order_no = gen_order_no(user_id)
|
||||||
|
gw = get_gateway(pay_type)
|
||||||
|
|
||||||
|
meta_remark = {"uid": user_id, "oid": order_no, "type": "credit_purchase"}
|
||||||
|
if metadata:
|
||||||
|
meta_remark.update(metadata)
|
||||||
|
|
||||||
|
gw_result = await gw.create_order(order_no, int(amount * 100),
|
||||||
|
description, pay_type=pay_type,
|
||||||
|
remark=json.dumps(meta_remark, separators=(",", ":")))
|
||||||
|
|
||||||
|
txn = PaymentTransaction(
|
||||||
|
user_id=user_id, order_no=order_no, plan="credit_purchase",
|
||||||
|
amount=amount, gateway="unified", pay_type=pay_type,
|
||||||
|
status="pending", description=json.dumps(metadata or {}, ensure_ascii=False),
|
||||||
|
gateway_order_no=gw_result.get("gateway_order_id", ""),
|
||||||
|
)
|
||||||
|
self.db.add(txn)
|
||||||
|
await self.db.flush()
|
||||||
|
return {
|
||||||
|
"status": "pending",
|
||||||
|
"order_id": order_no,
|
||||||
|
"amount": amount,
|
||||||
|
"currency": "CNY",
|
||||||
|
"gateway": "unified",
|
||||||
|
"pay_type": pay_type,
|
||||||
|
"metadata": metadata or {},
|
||||||
|
**gw_result,
|
||||||
|
}
|
||||||
|
|
||||||
async def handle_callback(self, order_no: str, gateway_order_id: str,
|
async def handle_callback(self, order_no: str, gateway_order_id: str,
|
||||||
gateway_order_no: str, success: bool,
|
gateway_order_no: str, success: bool,
|
||||||
amount: float = 0, notify_raw: str = "") -> bool:
|
amount: float = 0, notify_raw: str = "") -> bool:
|
||||||
@@ -159,30 +210,52 @@ class PaymentService:
|
|||||||
txn.paid_at = datetime.utcnow()
|
txn.paid_at = datetime.utcnow()
|
||||||
txn.notify_raw = notify_raw
|
txn.notify_raw = notify_raw
|
||||||
|
|
||||||
sub_result = await self.db.execute(
|
if txn.plan == "credit_purchase":
|
||||||
select(Subscription).where(Subscription.payment_id == order_no)
|
from app.services.credit import CreditService
|
||||||
)
|
credit_svc = CreditService(self.db)
|
||||||
sub = sub_result.scalar_one_or_none()
|
|
||||||
if sub:
|
|
||||||
sub.status = "active"
|
|
||||||
sub.started_at = datetime.utcnow()
|
|
||||||
if PLANS[sub.plan]["duration_days"]:
|
|
||||||
sub.expires_at = datetime.utcnow() + timedelta(days=PLANS[sub.plan]["duration_days"])
|
|
||||||
|
|
||||||
user_result = await self.db.execute(select(User).where(User.id == txn.user_id))
|
if txn.description:
|
||||||
user = user_result.scalar_one_or_none()
|
try:
|
||||||
if user:
|
meta = json.loads(txn.description)
|
||||||
user.tier = txn.plan
|
credits = meta.get("credits", 0)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
credits = 0
|
||||||
|
else:
|
||||||
|
credits = 0
|
||||||
|
|
||||||
|
if not credits:
|
||||||
|
credits = max(1, int(txn.amount / 0.79))
|
||||||
|
|
||||||
|
await credit_svc.add_credits(
|
||||||
|
txn.user_id, credits, "package",
|
||||||
|
f"支付完成 - 获得 {credits} 次信用额度"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
sub_result = await self.db.execute(
|
||||||
|
select(Subscription).where(Subscription.payment_id == order_no)
|
||||||
|
)
|
||||||
|
sub = sub_result.scalar_one_or_none()
|
||||||
|
if sub:
|
||||||
|
sub.status = "active"
|
||||||
|
sub.started_at = datetime.utcnow()
|
||||||
|
if PLANS[sub.plan]["duration_days"]:
|
||||||
|
sub.expires_at = datetime.utcnow() + timedelta(days=PLANS[sub.plan]["duration_days"])
|
||||||
|
|
||||||
|
user_result = await self.db.execute(select(User).where(User.id == txn.user_id))
|
||||||
|
user = user_result.scalar_one_or_none()
|
||||||
|
if user:
|
||||||
|
user.tier = txn.plan
|
||||||
else:
|
else:
|
||||||
txn.status = "failed"
|
txn.status = "failed"
|
||||||
txn.notify_raw = notify_raw
|
txn.notify_raw = notify_raw
|
||||||
|
|
||||||
sub_result = await self.db.execute(
|
if txn.plan != "credit_purchase":
|
||||||
select(Subscription).where(Subscription.payment_id == order_no)
|
sub_result = await self.db.execute(
|
||||||
)
|
select(Subscription).where(Subscription.payment_id == order_no)
|
||||||
sub = sub_result.scalar_one_or_none()
|
)
|
||||||
if sub:
|
sub = sub_result.scalar_one_or_none()
|
||||||
sub.status = "failed"
|
if sub:
|
||||||
|
sub.status = "failed"
|
||||||
|
|
||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
return True
|
return True
|
||||||
@@ -208,6 +281,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 +389,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 +402,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 +464,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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import base64
|
||||||
|
import httpx
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
from app.config import settings
|
||||||
|
from app.services.payment_gateway import PaymentGateway
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PAYPAL_API_BASE = "https://api-m.paypal.com"
|
||||||
|
PAYPAL_API_SANDBOX = "https://api-m.sandbox.paypal.com"
|
||||||
|
|
||||||
|
|
||||||
|
class PayPalPaymentService(PaymentGateway):
|
||||||
|
name = "paypal"
|
||||||
|
supported_types = ["paypal", "card"]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.client_id = settings.PAYPAL_CLIENT_ID or ""
|
||||||
|
self.client_secret = settings.PAYPAL_CLIENT_SECRET or ""
|
||||||
|
self.webhook_id = settings.PAYPAL_WEBHOOK_ID or ""
|
||||||
|
self.sandbox = settings.PAYPAL_SANDBOX
|
||||||
|
self._base_url = PAYPAL_API_SANDBOX if self.sandbox else PAYPAL_API_BASE
|
||||||
|
self._access_token = None
|
||||||
|
self._token_expires = 0
|
||||||
|
|
||||||
|
def _is_configured(self) -> bool:
|
||||||
|
return bool(self.client_id and self.client_secret)
|
||||||
|
|
||||||
|
async def _get_access_token(self) -> str:
|
||||||
|
if self._access_token and datetime.utcnow().timestamp() < self._token_expires - 60:
|
||||||
|
return self._access_token
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{self._base_url}/v1/oauth2/token",
|
||||||
|
auth=(self.client_id, self.client_secret),
|
||||||
|
data={"grant_type": "client_credentials"},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise ValueError(f"PayPal OAuth failed: {resp.text}")
|
||||||
|
data = resp.json()
|
||||||
|
self._access_token = data["access_token"]
|
||||||
|
self._token_expires = datetime.utcnow().timestamp() + data.get("expires_in", 32400)
|
||||||
|
return self._access_token
|
||||||
|
|
||||||
|
async def create_order(self, order_no: str, amount: int, description: str,
|
||||||
|
**kwargs) -> Dict[str, Any]:
|
||||||
|
if not self._is_configured():
|
||||||
|
raise ValueError("PayPal 未配置")
|
||||||
|
|
||||||
|
token = await self._get_access_token()
|
||||||
|
pay_type = kwargs.get("pay_type", "paypal")
|
||||||
|
success_url = kwargs.get("success_url", "https://trade.yuzhiran.com/workspace/credits?paypal=success")
|
||||||
|
cancel_url = kwargs.get("cancel_url", "https://trade.yuzhiran.com/workspace/credits?paypal=cancel")
|
||||||
|
|
||||||
|
usd_amount = round(amount / 100, 2)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"intent": "CAPTURE",
|
||||||
|
"purchase_units": [{
|
||||||
|
"reference_id": order_no,
|
||||||
|
"description": description[:127],
|
||||||
|
"amount": {
|
||||||
|
"currency_code": "USD",
|
||||||
|
"value": str(usd_amount),
|
||||||
|
"breakdown": {
|
||||||
|
"item_total": {
|
||||||
|
"currency_code": "USD",
|
||||||
|
"value": str(usd_amount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"items": [{
|
||||||
|
"name": description[:127],
|
||||||
|
"unit_amount": {
|
||||||
|
"currency_code": "USD",
|
||||||
|
"value": str(usd_amount)
|
||||||
|
},
|
||||||
|
"quantity": "1",
|
||||||
|
"category": "DIGITAL_GOODS"
|
||||||
|
}]
|
||||||
|
}],
|
||||||
|
"payment_source": {
|
||||||
|
"paypal": {
|
||||||
|
"experience_context": {
|
||||||
|
"payment_method_preference": "IMMEDIATE_PAYMENT_REQUIRED",
|
||||||
|
"landing_page": "LOGIN",
|
||||||
|
"user_action": "PAY_NOW",
|
||||||
|
"return_url": success_url,
|
||||||
|
"cancel_url": cancel_url,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{self._base_url}/v2/checkout/orders",
|
||||||
|
json=payload,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"PayPal-Request-Id": order_no,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if resp.status_code not in (200, 201):
|
||||||
|
raise ValueError(f"PayPal create order failed: {resp.text}")
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
approval_url = ""
|
||||||
|
for link in data.get("links", []):
|
||||||
|
if link["rel"] == "payer-action":
|
||||||
|
approval_url = link["href"]
|
||||||
|
break
|
||||||
|
|
||||||
|
return {
|
||||||
|
"gateway_order_id": data["id"],
|
||||||
|
"merchant_order_id": order_no,
|
||||||
|
"session_url": approval_url,
|
||||||
|
"session_id": data["id"],
|
||||||
|
"amount": usd_amount,
|
||||||
|
"status": data["status"],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def query_order(self, order_no: str) -> Dict[str, Any]:
|
||||||
|
if not self._is_configured():
|
||||||
|
return {"status": "unknown"}
|
||||||
|
token = await self._get_access_token()
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{self._base_url}/v2/checkout/orders/{order_no}",
|
||||||
|
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return {"status": "unknown"}
|
||||||
|
data = resp.json()
|
||||||
|
return {
|
||||||
|
"status": data.get("status", "unknown"),
|
||||||
|
"payment_status": "completed" if data.get("status") == "COMPLETED" else data.get("status", ""),
|
||||||
|
"amount": float(data.get("purchase_units", [{}])[0].get("amount", {}).get("value", 0)),
|
||||||
|
"currency": data.get("purchase_units", [{}])[0].get("amount", {}).get("currency_code", "USD"),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def refund(self, order_no: str, amount: int, reason: str = "") -> Dict[str, Any]:
|
||||||
|
token = await self._get_access_token()
|
||||||
|
usd_amount = round(amount / 100, 2)
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{self._base_url}/v2/payments/captures/{order_no}/refund",
|
||||||
|
json={
|
||||||
|
"amount": {"currency_code": "USD", "value": str(usd_amount)},
|
||||||
|
"note_to_payer": reason or "Refund",
|
||||||
|
},
|
||||||
|
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
if resp.status_code not in (200, 201):
|
||||||
|
raise ValueError(f"PayPal refund failed: {resp.text}")
|
||||||
|
data = resp.json()
|
||||||
|
return {"status": data.get("status", "COMPLETED"), "refund_id": data.get("id", "")}
|
||||||
|
|
||||||
|
async def query_refund(self, order_no: str) -> Dict[str, Any]:
|
||||||
|
return {"status": "not_implemented"}
|
||||||
|
|
||||||
|
def verify_callback(self, headers: dict, body: str) -> bool:
|
||||||
|
if not self.webhook_id:
|
||||||
|
logger.warning("PayPal webhook ID not configured")
|
||||||
|
return False
|
||||||
|
|
||||||
|
transmission_id = headers.get("paypal-transmission-id", "")
|
||||||
|
transmission_time = headers.get("paypal-transmission-time", "")
|
||||||
|
cert_url = headers.get("paypal-cert-url", "")
|
||||||
|
actual_sig = headers.get("paypal-transmission-sig", "")
|
||||||
|
auth_algo = headers.get("paypal-auth-algo", "")
|
||||||
|
|
||||||
|
if not all([transmission_id, transmission_time, cert_url, actual_sig, auth_algo]):
|
||||||
|
logger.warning("PayPal webhook missing required headers")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
token_resp = httpx.post(
|
||||||
|
f"{self._base_url}/v1/oauth2/token",
|
||||||
|
auth=(self.client_id, self.client_secret),
|
||||||
|
data={"grant_type": "client_credentials"},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if token_resp.status_code != 200:
|
||||||
|
return False
|
||||||
|
token = token_resp.json()["access_token"]
|
||||||
|
|
||||||
|
resp = httpx.post(
|
||||||
|
f"{self._base_url}/v1/notifications/verify-webhook-signature",
|
||||||
|
json={
|
||||||
|
"auth_algo": auth_algo,
|
||||||
|
"cert_url": cert_url,
|
||||||
|
"transmission_id": transmission_id,
|
||||||
|
"transmission_sig": actual_sig,
|
||||||
|
"transmission_time": transmission_time,
|
||||||
|
"webhook_id": self.webhook_id,
|
||||||
|
"webhook_event": json.loads(body),
|
||||||
|
},
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
result = resp.json()
|
||||||
|
return result.get("verification_status") == "SUCCESS"
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"PayPal webhook verification failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def parse_callback(self, body: str, headers: dict) -> Dict[str, Any]:
|
||||||
|
event = json.loads(body)
|
||||||
|
event_type = event.get("event_type", "")
|
||||||
|
resource = event.get("resource", {})
|
||||||
|
|
||||||
|
order_id = ""
|
||||||
|
amount = 0
|
||||||
|
if resource:
|
||||||
|
order_id = resource.get("id", "")
|
||||||
|
amt_field = resource.get("amount", {})
|
||||||
|
if isinstance(amt_field, dict) and (amt_field.get("value") or amt_field.get("total")):
|
||||||
|
amount = float(amt_field.get("value", amt_field.get("total", 0)))
|
||||||
|
else:
|
||||||
|
units = resource.get("purchase_units", [])
|
||||||
|
amount = float(units[0]["amount"]["value"]) if units and units[0].get("amount") else 0
|
||||||
|
|
||||||
|
custom_id = ""
|
||||||
|
purchase_units = resource.get("purchase_units", [])
|
||||||
|
if purchase_units:
|
||||||
|
custom_id = purchase_units[0].get("reference_id", "")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"event": event_type,
|
||||||
|
"order_no": custom_id,
|
||||||
|
"gateway_order_id": order_id,
|
||||||
|
"gateway_order_no": resource.get("payments", {}).get("captures", [{}])[0].get("id", order_id) if resource else order_id,
|
||||||
|
"amount": amount,
|
||||||
|
"success": event_type in ("CHECKOUT.ORDER.APPROVED", "PAYMENT.CAPTURE.COMPLETED", "CHECKOUT.ORDER.COMPLETED"),
|
||||||
|
"raw": resource,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def capture_order(self, order_id: str) -> Dict[str, Any]:
|
||||||
|
token = await self._get_access_token()
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{self._base_url}/v2/checkout/orders/{order_id}/capture",
|
||||||
|
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||||
|
)
|
||||||
|
if resp.status_code not in (200, 201):
|
||||||
|
raise ValueError(f"PayPal capture failed: {resp.text}")
|
||||||
|
data = resp.json()
|
||||||
|
status = data.get("status", "")
|
||||||
|
capture_id = ""
|
||||||
|
for pu in data.get("purchase_units", []):
|
||||||
|
for cap in pu.get("payments", {}).get("captures", []):
|
||||||
|
if cap.get("status") == "COMPLETED":
|
||||||
|
capture_id = cap["id"]
|
||||||
|
break
|
||||||
|
return {
|
||||||
|
"status": status,
|
||||||
|
"capture_id": capture_id,
|
||||||
|
"completed": status == "COMPLETED",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def close_order(self, order_no: str) -> Dict[str, Any]:
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import hashlib
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.services.payment_gateway import PaymentGateway
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
PINGPONG_HOST_SANDBOX = "https://sandbox-acquirer-payment.pingpongx.com"
|
||||||
|
PINGPONG_HOST_PROD_EU = "https://acquirer-payment.pingpongx.com"
|
||||||
|
PINGPONG_HOST_PROD_US = "https://acquirer-payment-checkout-us.pingpongx.com"
|
||||||
|
|
||||||
|
|
||||||
|
class PingPongCheckoutService(PaymentGateway):
|
||||||
|
name = "pingpong"
|
||||||
|
supported_types = ["pingpong", "card"]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.client_id = settings.PINGPONG_CLIENT_ID or ""
|
||||||
|
self.acc_id = settings.PINGPONG_ACC_ID or ""
|
||||||
|
self.secret_key = settings.PINGPONG_SECRET_KEY or ""
|
||||||
|
self.sandbox = settings.PINGPONG_SANDBOX
|
||||||
|
if self.sandbox:
|
||||||
|
self._base_url = PINGPONG_HOST_SANDBOX
|
||||||
|
else:
|
||||||
|
region = (settings.PINGPONG_REGION or "EU").upper()
|
||||||
|
if region == "US":
|
||||||
|
self._base_url = PINGPONG_HOST_PROD_US
|
||||||
|
else:
|
||||||
|
self._base_url = PINGPONG_HOST_PROD_EU
|
||||||
|
|
||||||
|
def _is_configured(self) -> bool:
|
||||||
|
return bool(self.client_id and self.acc_id and self.secret_key)
|
||||||
|
|
||||||
|
def _sign(self, params: dict) -> str:
|
||||||
|
sorted_keys = sorted(k for k in params if k != "sign")
|
||||||
|
sign_str = "&".join(f"{k}={params[k]}" for k in sorted_keys)
|
||||||
|
raw = sign_str + self.secret_key
|
||||||
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest().upper()
|
||||||
|
|
||||||
|
def _verify_sign(self, params: dict) -> bool:
|
||||||
|
if "sign" not in params:
|
||||||
|
return False
|
||||||
|
expected = self._sign(params)
|
||||||
|
return expected == params["sign"].upper()
|
||||||
|
|
||||||
|
async def _request(self, path: str, biz_data: dict) -> dict:
|
||||||
|
biz_json = json.dumps(biz_data, separators=(",", ":"))
|
||||||
|
body = {
|
||||||
|
"accId": self.acc_id,
|
||||||
|
"clientId": self.client_id,
|
||||||
|
"signType": "SHA256",
|
||||||
|
"version": "1.0",
|
||||||
|
"bizContent": biz_json,
|
||||||
|
}
|
||||||
|
body["sign"] = self._sign(body)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{self._base_url}{path}",
|
||||||
|
json=body,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise ValueError(f"PingPong request failed: {resp.status_code} {resp.text}")
|
||||||
|
data = resp.json()
|
||||||
|
if data.get("code") != "000000":
|
||||||
|
raise ValueError(f"PingPong error: {data.get('code')} - {data.get('description', '')}")
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def create_order(self, order_no: str, amount: int, description: str,
|
||||||
|
**kwargs) -> Dict[str, Any]:
|
||||||
|
if not self._is_configured():
|
||||||
|
raise ValueError("PingPong 未配置")
|
||||||
|
|
||||||
|
usd_amount = round(amount / 100, 2)
|
||||||
|
usd_str = f"{usd_amount:.2f}"
|
||||||
|
success_url = kwargs.get("success_url", "https://trade.yuzhiran.com/workspace/credits?pingpong=success")
|
||||||
|
cancel_url = kwargs.get("cancel_url", "https://trade.yuzhiran.com/workspace/credits?pingpong=cancel")
|
||||||
|
notification_url = kwargs.get("notification_url", "https://trade.yuzhiran.com/api/v1/payment/pingpong-webhook")
|
||||||
|
|
||||||
|
shopper_ip = kwargs.get("shopper_ip", "127.0.0.1")
|
||||||
|
|
||||||
|
biz_data = {
|
||||||
|
"merchantTransactionId": order_no,
|
||||||
|
"amount": usd_str,
|
||||||
|
"currency": "USD",
|
||||||
|
"paymentType": "SALE",
|
||||||
|
"shopperIP": shopper_ip,
|
||||||
|
"notificationUrl": notification_url,
|
||||||
|
"payResultUrl": success_url,
|
||||||
|
"cancelUrl": cancel_url,
|
||||||
|
"goods": [{
|
||||||
|
"name": description[:127] or "Credit Package",
|
||||||
|
"unitPrice": usd_str,
|
||||||
|
"number": "1",
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await self._request("/v4/payment/prePay", biz_data)
|
||||||
|
bc = result.get("bizContent", {})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"gateway_order_id": bc.get("transactionId", ""),
|
||||||
|
"merchant_order_id": order_no,
|
||||||
|
"session_url": bc.get("paymentUrl", ""),
|
||||||
|
"session_id": bc.get("token", ""),
|
||||||
|
"amount": usd_amount,
|
||||||
|
"status": "pending",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def query_order(self, order_no: str) -> Dict[str, Any]:
|
||||||
|
if not self._is_configured():
|
||||||
|
return {"status": "unknown"}
|
||||||
|
biz_data = {"merchantTransactionId": order_no}
|
||||||
|
try:
|
||||||
|
result = await self._request("/v4/payment/query", biz_data)
|
||||||
|
bc = result.get("bizContent", {})
|
||||||
|
return {
|
||||||
|
"status": bc.get("status", "unknown"),
|
||||||
|
"payment_status": "completed" if bc.get("status") == "SUCCESS" else bc.get("status", ""),
|
||||||
|
"amount": float(bc.get("amount", 0)),
|
||||||
|
"currency": bc.get("currency", "USD"),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"PingPong query failed: {e}")
|
||||||
|
return {"status": "unknown"}
|
||||||
|
|
||||||
|
async def refund(self, order_no: str, amount: int, reason: str = "") -> Dict[str, Any]:
|
||||||
|
usd_amount = round(amount / 100, 2)
|
||||||
|
biz_data = {
|
||||||
|
"merchantTransactionId": order_no,
|
||||||
|
"amount": f"{usd_amount:.2f}",
|
||||||
|
"currency": "USD",
|
||||||
|
"reason": reason or "Refund",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
result = await self._request("/v4/refund", biz_data)
|
||||||
|
return {"status": "COMPLETED", "refund_id": result.get("bizContent", {}).get("refundId", "")}
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(f"PingPong refund failed: {e}")
|
||||||
|
|
||||||
|
async def query_refund(self, order_no: str) -> Dict[str, Any]:
|
||||||
|
biz_data = {"merchantTransactionId": order_no}
|
||||||
|
try:
|
||||||
|
result = await self._request("/v4/refund/query", biz_data)
|
||||||
|
return {"status": result.get("bizContent", {}).get("status", "unknown")}
|
||||||
|
except Exception:
|
||||||
|
return {"status": "not_implemented"}
|
||||||
|
|
||||||
|
def verify_callback(self, headers: dict, body: str) -> bool:
|
||||||
|
try:
|
||||||
|
data = json.loads(body)
|
||||||
|
return self._verify_sign(data)
|
||||||
|
except (json.JSONDecodeError, KeyError) as e:
|
||||||
|
logger.error(f"PingPong callback verify failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def parse_callback(self, body: str, headers: dict) -> Dict[str, Any]:
|
||||||
|
data = json.loads(body)
|
||||||
|
bc_raw = data.get("bizContent", "{}")
|
||||||
|
if isinstance(bc_raw, str):
|
||||||
|
try:
|
||||||
|
bc = json.loads(bc_raw)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
bc = {}
|
||||||
|
else:
|
||||||
|
bc = bc_raw
|
||||||
|
|
||||||
|
status = bc.get("status", "")
|
||||||
|
return {
|
||||||
|
"event": status,
|
||||||
|
"order_no": bc.get("merchantTransactionId", ""),
|
||||||
|
"gateway_order_id": bc.get("transactionId", ""),
|
||||||
|
"gateway_order_no": bc.get("transactionId", ""),
|
||||||
|
"amount": float(bc.get("amount", 0)),
|
||||||
|
"success": status == "SUCCESS",
|
||||||
|
"raw": bc,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def close_order(self, order_no: str) -> Dict[str, Any]:
|
||||||
|
return {"status": "ok"}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,31 @@ async def _google_cse(query: str, max_results: int, api_key: str, cse_id: str) -
|
|||||||
|
|
||||||
|
|
||||||
async def fetch_page_text(url: str) -> Optional[str]:
|
async def fetch_page_text(url: str) -> Optional[str]:
|
||||||
|
# Validate URL to prevent SSRF
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
import ipaddress
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in ('http', 'https'):
|
||||||
|
logger.warning(f"Invalid URL scheme: {url}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check if hostname is an IP address and block private/reserved ranges
|
||||||
|
hostname = parsed.hostname
|
||||||
|
if hostname:
|
||||||
|
try:
|
||||||
|
ip = ipaddress.ip_address(hostname)
|
||||||
|
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
|
||||||
|
logger.warning(f"Blocked private/reserved IP: {url}")
|
||||||
|
return None
|
||||||
|
except ValueError:
|
||||||
|
# Not an IP address, it's a hostname - proceed normally
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"URL validation failed for {url}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
|
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
|
||||||
resp = await client.get(url, headers={"User-Agent": "Mozilla/5.0"})
|
resp = await client.get(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import logging
|
||||||
|
import stripe
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from app.config import settings
|
||||||
|
from app.services.payment_gateway import PaymentGateway
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class StripePaymentService(PaymentGateway):
|
||||||
|
name = "stripe"
|
||||||
|
supported_types = ["card", "alipay", "wechat"]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.secret_key = settings.STRIPE_SECRET_KEY
|
||||||
|
self.webhook_secret = settings.STRIPE_WEBHOOK_SECRET
|
||||||
|
if self.secret_key:
|
||||||
|
stripe.api_key = self.secret_key
|
||||||
|
|
||||||
|
async def create_order(self, order_no: str, amount: int, description: str,
|
||||||
|
**kwargs) -> Dict[str, Any]:
|
||||||
|
if not self.secret_key:
|
||||||
|
raise ValueError("Stripe 未配置")
|
||||||
|
|
||||||
|
pay_type = kwargs.get("pay_type", "card")
|
||||||
|
success_url = kwargs.get("success_url", "")
|
||||||
|
cancel_url = kwargs.get("cancel_url", "")
|
||||||
|
|
||||||
|
payment_method_types = ["card"]
|
||||||
|
if pay_type == "alipay":
|
||||||
|
payment_method_types = ["alipay"]
|
||||||
|
elif pay_type == "wechat":
|
||||||
|
payment_method_types = ["wechat_pay"]
|
||||||
|
|
||||||
|
session = stripe.checkout.Session.create(
|
||||||
|
payment_method_types=payment_method_types,
|
||||||
|
line_items=[{
|
||||||
|
"price_data": {
|
||||||
|
"currency": "usd",
|
||||||
|
"product_data": {"name": description},
|
||||||
|
"unit_amount": amount,
|
||||||
|
},
|
||||||
|
"quantity": 1,
|
||||||
|
}],
|
||||||
|
mode="payment",
|
||||||
|
success_url=success_url or "https://trade.yuzhiran.com/workspace/credits?stripe=success",
|
||||||
|
cancel_url=cancel_url or "https://trade.yuzhiran.com/workspace/credits?stripe=cancel",
|
||||||
|
metadata={"order_no": order_no},
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"gateway_order_id": session.id,
|
||||||
|
"merchant_order_id": order_no,
|
||||||
|
"session_url": session.url,
|
||||||
|
"session_id": session.id,
|
||||||
|
"amount": amount / 100,
|
||||||
|
"status": "pending",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def query_order(self, order_no: str) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
session = stripe.checkout.Session.retrieve(order_no)
|
||||||
|
return {
|
||||||
|
"status": session.status,
|
||||||
|
"payment_status": session.payment_status,
|
||||||
|
"amount": session.amount_total / 100 if session.amount_total else 0,
|
||||||
|
"currency": session.currency or "usd",
|
||||||
|
"customer_email": session.customer_details.email if session.customer_details else None,
|
||||||
|
}
|
||||||
|
except stripe.error.StripeError as e:
|
||||||
|
logger.error(f"Stripe query failed: {e}")
|
||||||
|
return {"status": "unknown"}
|
||||||
|
|
||||||
|
async def refund(self, order_no: str, amount: int, reason: str = "") -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
payment_intents = stripe.checkout.Session.list(
|
||||||
|
payment_intent=True
|
||||||
|
)
|
||||||
|
refund = stripe.Refund.create(
|
||||||
|
payment_intent=order_no,
|
||||||
|
amount=amount,
|
||||||
|
reason="requested_by_customer",
|
||||||
|
)
|
||||||
|
return {"status": refund.status, "refund_id": refund.id}
|
||||||
|
except stripe.error.StripeError as e:
|
||||||
|
logger.error(f"Stripe refund failed: {e}")
|
||||||
|
raise ValueError(f"退款失败: {e}")
|
||||||
|
|
||||||
|
async def query_refund(self, order_no: str) -> Dict[str, Any]:
|
||||||
|
return {"status": "not_implemented"}
|
||||||
|
|
||||||
|
def verify_callback(self, headers: dict, body: str) -> bool:
|
||||||
|
if not self.webhook_secret:
|
||||||
|
logger.warning("Stripe webhook secret not configured")
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
sig_header = headers.get("stripe-signature", "")
|
||||||
|
stripe.Webhook.construct_event(body, sig_header, self.webhook_secret)
|
||||||
|
return True
|
||||||
|
except stripe.error.SignatureVerificationError as e:
|
||||||
|
logger.warning(f"Stripe webhook signature invalid: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def parse_callback(self, body: str, headers: dict) -> Dict[str, Any]:
|
||||||
|
event = stripe.Webhook.construct_event(body, headers.get("stripe-signature", ""), self.webhook_secret)
|
||||||
|
session = event.data.object
|
||||||
|
return {
|
||||||
|
"event": event.type,
|
||||||
|
"order_no": session.get("metadata", {}).get("order_no", ""),
|
||||||
|
"gateway_order_id": session.get("id", ""),
|
||||||
|
"gateway_order_no": session.get("payment_intent", ""),
|
||||||
|
"amount": (session.get("amount_total", 0) or 0) / 100,
|
||||||
|
"success": event.type == "checkout.session.completed",
|
||||||
|
"raw": session,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def close_order(self, order_no: str) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
stripe.checkout.Session.expire(order_no)
|
||||||
|
return {"status": "expired"}
|
||||||
|
except stripe.error.StripeError as e:
|
||||||
|
logger.error(f"Stripe close order failed: {e}")
|
||||||
|
raise ValueError(f"关闭订单失败: {e}")
|
||||||
@@ -50,9 +50,6 @@ class TranslationService:
|
|||||||
preference_context: Optional[str] = None,
|
preference_context: Optional[str] = None,
|
||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
similar = await self.corpus.find_similar(inquiry, "reply")
|
similar = await self.corpus.find_similar(inquiry, "reply")
|
||||||
if similar and count > 1:
|
|
||||||
pass
|
|
||||||
|
|
||||||
results = []
|
results = []
|
||||||
tones = self._get_tones(tone, count)
|
tones = self._get_tones(tone, count)
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ aliyunsdkalimt_request_v20181012.TranslateECommerceRequest = TranslateECommerceR
|
|||||||
# Mock AcsClient
|
# Mock AcsClient
|
||||||
aliyunsdkcore = types.ModuleType('aliyunsdkcore')
|
aliyunsdkcore = types.ModuleType('aliyunsdkcore')
|
||||||
aliyunsdkcore_client = types.ModuleType('aliyunsdkcore.client')
|
aliyunsdkcore_client = types.ModuleType('aliyunsdkcore.client')
|
||||||
|
aliyunsdkcore_auth = types.ModuleType('aliyunsdkcore.auth')
|
||||||
|
aliyunsdkcore_auth_credentials = types.ModuleType('aliyunsdkcore.auth.credentials')
|
||||||
|
|
||||||
class AcsClient:
|
class AcsClient:
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -76,9 +78,16 @@ class AcsClient:
|
|||||||
def do_action(self, request):
|
def do_action(self, request):
|
||||||
return b'{"TranslateResult": "mock translation"}'
|
return b'{"TranslateResult": "mock translation"}'
|
||||||
|
|
||||||
|
class AccessKeyCredential:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
aliyunsdkcore_client.AcsClient = AcsClient
|
aliyunsdkcore_client.AcsClient = AcsClient
|
||||||
|
aliyunsdkcore_auth_credentials.AccessKeyCredential = AccessKeyCredential
|
||||||
sys.modules['aliyunsdkcore'] = aliyunsdkcore
|
sys.modules['aliyunsdkcore'] = aliyunsdkcore
|
||||||
sys.modules['aliyunsdkcore.client'] = aliyunsdkcore_client
|
sys.modules['aliyunsdkcore.client'] = aliyunsdkcore_client
|
||||||
|
sys.modules['aliyunsdkcore.auth'] = aliyunsdkcore_auth
|
||||||
|
sys.modules['aliyunsdkcore.auth.credentials'] = aliyunsdkcore_auth_credentials
|
||||||
|
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from app.database import Base, get_db
|
from app.database import Base, get_db
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class TestAuthAPI:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["phone"] == "13900139001"
|
assert data["phone"] == "13900139001"
|
||||||
assert data["username"] == "newuser"
|
assert data["username"] == "newuser"
|
||||||
assert data["tier"] == "free"
|
assert data["tier"] == "pro"
|
||||||
|
|
||||||
async def test_register_duplicate_phone(self, client: AsyncClient, test_user):
|
async def test_register_duplicate_phone(self, client: AsyncClient, test_user):
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
|
|||||||
@@ -0,0 +1,399 @@
|
|||||||
|
# TradeMate Credit System — 信用计费系统设计方案
|
||||||
|
|
||||||
|
## 1. 业务模式变更
|
||||||
|
|
||||||
|
### 1.1 从工具订阅 → 结果付费
|
||||||
|
|
||||||
|
- **旧模式**:按月订阅解锁功能(Free/Pro/Enterprise)
|
||||||
|
- **新模式**:按次付费购买"结果",所有 AI 功能统一消耗信用额度(Credits)
|
||||||
|
- **核心**:用户不为功能付费,为**产生的价值**(客户线索、分析报告、翻译字符)付费
|
||||||
|
|
||||||
|
### 1.2 目标用户群
|
||||||
|
|
||||||
|
- **国内**:中国外贸 SOHO/小团队 → 中文界面,¥ 计价
|
||||||
|
- **海外**:全球跨境小B → 英文界面,$ 计价
|
||||||
|
- 两端统一产品逻辑,分语言/货币展示
|
||||||
|
|
||||||
|
### 1.3 产品定位
|
||||||
|
|
||||||
|
| 维度 | 旧 | 新 |
|
||||||
|
|------|-----|-----|
|
||||||
|
| 定位 | 外贸工具套装 | 外贸客户发现引擎 |
|
||||||
|
| Slogan | 外贸小助手 | Customer Discovery Engine / 外贸获客引擎 |
|
||||||
|
| 核心价值 | 多功能的工具箱 | 输入产品,找到买家 |
|
||||||
|
| 免费策略 | 限功能免费版 | 注册送 N 次,用完即购 |
|
||||||
|
| 收费锚点 | 按月付费解锁 | 按产出结果扣次 |
|
||||||
|
|
||||||
|
## 2. 信用额度系统(Credits)
|
||||||
|
|
||||||
|
### 2.1 定义
|
||||||
|
|
||||||
|
**1 Credit = 1 次消费单位。** 所有 AI 调用均以 Credits 计价。
|
||||||
|
|
||||||
|
用户持有的 Credits 通过两种方式获得:
|
||||||
|
- **一次性购买(Credit Pack)**:永不过期,用不完的余额留存
|
||||||
|
- **月订阅(Subscription)**:每月自动到账,月底未用完清零
|
||||||
|
|
||||||
|
两种方式的 Credits 统一存放在 `user_credits.balance` 中,消费时按 `source` 记录来源。
|
||||||
|
|
||||||
|
### 2.2 消费价格表
|
||||||
|
|
||||||
|
所有 AI 相关功能均消耗 Credits,覆盖 API 调用成本:
|
||||||
|
|
||||||
|
| 功能 | Credits | 说明 |
|
||||||
|
|------|---------|------|
|
||||||
|
| **客户搜索** (lead_search) | 10 | 多源搜索 + AI 匹配 + 联系方式提取 |
|
||||||
|
| **公司深度分析** (company_analysis) | 5 | 单家公司 AI 分析报告 |
|
||||||
|
| **市场情报报告** (market_intel) | 20 | 多维度市场研究 |
|
||||||
|
| **翻译** (translate, 5000 chars) | 5 | 按字符计费,不足按比例折算 |
|
||||||
|
| **回复建议** (reply_suggest) | 2 | AI 生成 3 条回复建议 |
|
||||||
|
| **开发信生成** (outreach) | 3 | 单封定制开发信 |
|
||||||
|
| **营销素材** (marketing_content) | 5 | 多风格营销文案 |
|
||||||
|
| **竞品分析** (competitor_analysis) | 10 | 多个竞品对比分析 |
|
||||||
|
| **AI 助手对话** (ai_chat, 10条消息) | 1 | 普通 AI 对话 |
|
||||||
|
| **信息提取** (info_extract) | 1 | 询盘/邮件信息提取 |
|
||||||
|
| **报价单生成** (quotation) | 2 | AI 辅助报价 |
|
||||||
|
| **跟进策略** (followup_scan) | 2 | AI 扫描跟进建议 |
|
||||||
|
|
||||||
|
### 2.3 计费规则
|
||||||
|
|
||||||
|
- **按次扣减**:功能调用成功后扣减,失败不扣
|
||||||
|
- **余额不足**:返回 `402 Payment Required` + 当前余额 + 所需额度
|
||||||
|
- **不足扣减**:如余额不足单次消费,允许扣至负数(欠费模式),欠费部分下次充值自动抵扣
|
||||||
|
- **日免费额度**:翻译每日免费 1000 字符(防流失),超出部分扣 Credits
|
||||||
|
- **免费试用**:新用户注册自动赠送 30 Credits(≈ 3 次搜索)
|
||||||
|
|
||||||
|
### 2.4 消费记录
|
||||||
|
|
||||||
|
每次扣减记录在 `credit_consumptions` 表,包含:
|
||||||
|
- user_id, result_type(功能类型)
|
||||||
|
- reference_id(关联业务记录 ID)
|
||||||
|
- credits_change(正数 = 充值,负数 = 消费)
|
||||||
|
- balance_after(扣减后余额)
|
||||||
|
- source(package/subscription/admin_grant)
|
||||||
|
- metadata(Jsonb,保存上下文,如搜索关键词、翻译字符数)
|
||||||
|
|
||||||
|
## 3. 定价体系
|
||||||
|
|
||||||
|
### 3.1 次数包(一次性,永不过期)
|
||||||
|
|
||||||
|
| 次数 | 价格(¥) | 次均 | 价格($) |
|
||||||
|
|------|---------|------|---------|
|
||||||
|
| 20 | ¥19 | ¥0.95 | $3 |
|
||||||
|
| 100 | ¥79 | ¥0.79 | $12 |
|
||||||
|
| 500 | ¥299 | ¥0.60 | $45 |
|
||||||
|
| 2000 | ¥899 | ¥0.45 | $138 |
|
||||||
|
|
||||||
|
### 3.2 订阅套餐(每月重置)
|
||||||
|
|
||||||
|
| 套餐 | 月次数 | 月费(¥) | 月费($) | 次均 |
|
||||||
|
|------|-------|---------|---------|------|
|
||||||
|
| Starter | 100 | ¥69 | $10 | ¥0.69 |
|
||||||
|
| Pro | 500 | ¥269 | $40 | ¥0.54 |
|
||||||
|
| Enterprise | 2000 | ¥699 | $105 | ¥0.35 |
|
||||||
|
|
||||||
|
### 3.3 新旧过渡方案
|
||||||
|
|
||||||
|
- 现有有效订阅用户 → 按剩余天数折算为 Credits 存入余额
|
||||||
|
- Pro 剩余 15 天 ≈ 100 × 0.5 ≈ 50 Credits 一次性赠送
|
||||||
|
- 同时保留原订阅到期日
|
||||||
|
- 新用户 → 走新信用系统
|
||||||
|
- 过渡期两种支付方式并存
|
||||||
|
|
||||||
|
## 4. 数据库设计
|
||||||
|
|
||||||
|
### 4.1 新增表
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 次数包定义(一次性购买)
|
||||||
|
CREATE TABLE credit_packages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(100) NOT NULL, -- 中文名
|
||||||
|
name_en VARCHAR(100) NOT NULL, -- 英文名
|
||||||
|
credits INTEGER NOT NULL, -- 额定量
|
||||||
|
price NUMERIC(10,2) NOT NULL, -- 人民币价格
|
||||||
|
price_usd NUMERIC(10,2), -- 美元价格
|
||||||
|
original_price NUMERIC(10,2), -- 原价(划线价)
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 订阅套餐定义(周期性)
|
||||||
|
CREATE TABLE subscription_plans (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
name_en VARCHAR(100) NOT NULL,
|
||||||
|
credits_per_month INTEGER NOT NULL,
|
||||||
|
price NUMERIC(10,2) NOT NULL,
|
||||||
|
price_usd NUMERIC(10,2),
|
||||||
|
duration_days INTEGER NOT NULL DEFAULT 30,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 用户信用余额
|
||||||
|
CREATE TABLE user_credits (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL UNIQUE REFERENCES users(id),
|
||||||
|
balance NUMERIC(12,1) NOT NULL DEFAULT 0, -- 当前余额(支持小数)
|
||||||
|
total_purchased NUMERIC(12,1) NOT NULL DEFAULT 0,
|
||||||
|
total_used NUMERIC(12,1) NOT NULL DEFAULT 0,
|
||||||
|
-- 订阅关联
|
||||||
|
subscription_plan_id UUID REFERENCES subscription_plans(id),
|
||||||
|
subscription_expires_at TIMESTAMP,
|
||||||
|
subscription_auto_renew BOOLEAN DEFAULT FALSE,
|
||||||
|
-- 免费赠送标记
|
||||||
|
free_trial_used BOOLEAN DEFAULT FALSE,
|
||||||
|
daily_translate_chars INTEGER DEFAULT 0,
|
||||||
|
daily_translate_date DATE,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 信用消费日志
|
||||||
|
CREATE TABLE credit_consumptions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
result_type VARCHAR(50) NOT NULL, -- lead_search / translate / outreach / ...
|
||||||
|
reference_id UUID, -- 关联业务记录
|
||||||
|
credits_change NUMERIC(10,1) NOT NULL, -- 负=消费,正=充值
|
||||||
|
balance_after NUMERIC(12,1) NOT NULL,
|
||||||
|
source VARCHAR(30) NOT NULL, -- package / subscription / admin_grant / free_trial / daily_free
|
||||||
|
description VARCHAR(500),
|
||||||
|
metadata JSONB,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 次数包购买记录
|
||||||
|
CREATE TABLE credit_purchases (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
package_id UUID REFERENCES credit_packages(id),
|
||||||
|
subscription_plan_id UUID REFERENCES subscription_plans(id),
|
||||||
|
credits INTEGER NOT NULL,
|
||||||
|
amount NUMERIC(10,2) NOT NULL,
|
||||||
|
currency VARCHAR(3) DEFAULT 'CNY',
|
||||||
|
payment_method VARCHAR(20), -- alipay / wechat / stripe
|
||||||
|
status VARCHAR(20) DEFAULT 'pending', -- pending / paid / refunded
|
||||||
|
payment_transaction_id UUID REFERENCES payment_transactions(id),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
paid_at TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 索引
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE INDEX idx_credit_consumptions_user ON credit_consumptions(user_id, created_at DESC);
|
||||||
|
CREATE INDEX idx_credit_consumptions_type ON credit_consumptions(result_type);
|
||||||
|
CREATE INDEX idx_credit_purchases_user ON credit_purchases(user_id);
|
||||||
|
CREATE INDEX idx_credit_purchases_status ON credit_purchases(status);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 配置项
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"free_trial_credits": {"value": 30, "desc": "新用户免费赠送次数"},
|
||||||
|
"daily_free_translate_chars": {"value": 1000, "desc": "每日免费翻译字符数"},
|
||||||
|
"credit_consumption_rates": {
|
||||||
|
"value": {
|
||||||
|
"lead_search": 10,
|
||||||
|
"company_analysis": 5,
|
||||||
|
"market_intel": 20,
|
||||||
|
"translate_per_1000chars": 1,
|
||||||
|
"reply_suggest": 2,
|
||||||
|
"outreach": 3,
|
||||||
|
"marketing_content": 5,
|
||||||
|
"competitor_analysis": 10,
|
||||||
|
"ai_chat_per_10msg": 1,
|
||||||
|
"info_extract": 1,
|
||||||
|
"quotation": 2,
|
||||||
|
"followup_scan": 2
|
||||||
|
},
|
||||||
|
"desc": "各功能信用消耗速率"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. API 设计
|
||||||
|
|
||||||
|
### 5.1 信用系统
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/credits/balance → 余额+订阅信息
|
||||||
|
GET /api/v1/credits/history?page=&size= → 消费历史
|
||||||
|
POST /api/v1/credits/packages → 购买次数包(走支付网关)
|
||||||
|
POST /api/v1/credits/subscribe → 开通订阅
|
||||||
|
POST /api/v1/credits/cancel-subscription → 取消订阅
|
||||||
|
GET /api/v1/credits/packages → 次数包列表
|
||||||
|
GET /api/v1/credits/subscription-plans → 订阅套餐列表
|
||||||
|
GET /api/v1/credits/rates → 各功能消耗速率
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 现有功能增加额度扣减
|
||||||
|
|
||||||
|
所有 AI 功能在返回结果后调用 `CreditService.deduct()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 在每个 AI 功能服务层中(discovery/discovery.py, translate/translation.py, etc.)
|
||||||
|
from app.services.credit import CreditService
|
||||||
|
|
||||||
|
svc = CreditService(db)
|
||||||
|
success, balance = await svc.deduct(user_id, "lead_search", reference_id=record_id)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=402, detail="次数不足")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 管理端
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/admin/credit-packages → CRUD
|
||||||
|
POST /api/v1/admin/credit-packages
|
||||||
|
PUT /api/v1/admin/credit-packages/{id}
|
||||||
|
DELETE /api/v1/admin/credit-packages/{id}
|
||||||
|
GET /api/v1/admin/subscription-plans → CRUD
|
||||||
|
POST /api/v1/admin/subscription-plans
|
||||||
|
PUT /api/v1/admin/subscription-plans/{id}
|
||||||
|
GET /api/v1/admin/user-credits → 用户余额列表
|
||||||
|
POST /api/v1/admin/user-credits/adjust → 手动调整余额
|
||||||
|
GET /api/v1/admin/credit-consumptions → 消费流水
|
||||||
|
GET /api/v1/admin/credit-stats → 统计数据
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 前端改造
|
||||||
|
|
||||||
|
### 6.1 导航栏重构
|
||||||
|
|
||||||
|
```
|
||||||
|
新用户侧边栏(按优先级):
|
||||||
|
|
||||||
|
【核心】
|
||||||
|
★ 发现客户 /discovery ← 首页
|
||||||
|
市场情报 /market-intel ← 新增
|
||||||
|
我的线索 /my-leads ← 新增(收藏/保存的线索)
|
||||||
|
|
||||||
|
【工具(增值)】
|
||||||
|
客户管理 /customers
|
||||||
|
产品库 /products
|
||||||
|
报价单 /quotations
|
||||||
|
智能翻译 /translate
|
||||||
|
开发信 /outreach
|
||||||
|
营销素材 /marketing
|
||||||
|
|
||||||
|
【查看】
|
||||||
|
工作台 /workspace ← 仪表盘(用量统计/最近操作)
|
||||||
|
团队协作 /team
|
||||||
|
个人中心 /profile
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Topbar 改造
|
||||||
|
|
||||||
|
顶部始终显示 `余额: X.X 次` 按钮,点击跳转购买页。
|
||||||
|
|
||||||
|
```html
|
||||||
|
<el-button class="credit-balance" @click="$router.push('/credits')">
|
||||||
|
<el-icon><Coin /></el-icon>
|
||||||
|
{{ balance }} 次
|
||||||
|
</el-button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 购买页面
|
||||||
|
|
||||||
|
替代现有 `/upgrade` 路由为 `/credits`:
|
||||||
|
|
||||||
|
- Tab1: **次数包** — 多卡片展示(20/100/500/2000次),支付弹窗
|
||||||
|
- Tab2: **订阅套餐** — Starter/Pro/Enterprise,月付/年付
|
||||||
|
- Tab3: **消费明细** — 近期消费流水
|
||||||
|
|
||||||
|
### 6.4 余额不足引导
|
||||||
|
|
||||||
|
所有 AI 功能检测到余额不足时,前端弹窗:
|
||||||
|
```
|
||||||
|
"次数不足,当前剩余 X 次,本次操作需要 Y 次
|
||||||
|
[去购买] [取消]"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 免费额度提示
|
||||||
|
|
||||||
|
新用户注册后首页显示:
|
||||||
|
```
|
||||||
|
"欢迎使用 TradeMate!您有 30 次免费搜索额度,用完即止。"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 国际化
|
||||||
|
|
||||||
|
### 7.1 第一阶段(核心页面)
|
||||||
|
|
||||||
|
| 页面 | 优先级 |
|
||||||
|
|------|--------|
|
||||||
|
| 登录/注册 | P0 |
|
||||||
|
| 导航栏 | P0 |
|
||||||
|
| 发现客户 | P0 |
|
||||||
|
| 购买页面 | P0 |
|
||||||
|
| 工作台 | P1 |
|
||||||
|
| 翻译 | P1 |
|
||||||
|
| 个人中心 | P1 |
|
||||||
|
|
||||||
|
### 7.2 技术方案
|
||||||
|
|
||||||
|
- 使用 vue-i18n + JSON locale files
|
||||||
|
- `zh-CN.json`(默认)+ `en.json`
|
||||||
|
- 通过 URL query `?lang=en` 或 Cookie 切换
|
||||||
|
- 管理后台跟随用户语言偏好
|
||||||
|
|
||||||
|
```json
|
||||||
|
// en.json
|
||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"discovery": "Find Buyers",
|
||||||
|
"translate": "Translate",
|
||||||
|
"customers": "Customers",
|
||||||
|
"credits": "Buy Credits"
|
||||||
|
},
|
||||||
|
"discovery": {
|
||||||
|
"search_placeholder": "e.g. LED lighting, solar panels...",
|
||||||
|
"search_button": "Search Buyers",
|
||||||
|
"credits_cost": "This search costs 10 credits"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 实施计划
|
||||||
|
|
||||||
|
### Phase 1 — 信用系统后端(当前)
|
||||||
|
1. 数据库模型 + migration
|
||||||
|
2. CreditService
|
||||||
|
3. 信用系统 API
|
||||||
|
4. 管理端 CRUD
|
||||||
|
|
||||||
|
### Phase 2 — 功能接入扣次
|
||||||
|
1. Discovery 接入(搜索/分析/市场报告)
|
||||||
|
2. Translate 接入
|
||||||
|
3. Marketing 接入
|
||||||
|
4. AI Chat / Outreach 接入
|
||||||
|
|
||||||
|
### Phase 3 — 前端改造
|
||||||
|
1. 新导航 + 余额显示
|
||||||
|
2. 购买/订阅页面
|
||||||
|
3. 余额不足引导
|
||||||
|
4. 工作台改仪表盘
|
||||||
|
|
||||||
|
### Phase 4 — 国际化 + 定价上线
|
||||||
|
1. 中英文切换
|
||||||
|
2. 海外支付(Stripe)
|
||||||
|
3. 定价正式发布
|
||||||
|
4. 老用户数据迁移
|
||||||
|
|
||||||
|
## 9. 关键风险
|
||||||
|
|
||||||
|
| 风险 | 缓解 |
|
||||||
|
|------|------|
|
||||||
|
| 用户觉得按次付费贵 | 免费 30 次让用户先体验价值;套餐价需低于用户感知价值 |
|
||||||
|
| 搜索结果质量不稳 | 多搜索源回退(DB → Bing → Google CSE → AI),持续优化 |
|
||||||
|
| 老用户不满过渡方案 | 老用户按剩余天数高折抵换算,并保留原有套餐期限内权益 |
|
||||||
|
| 翻译用量大成本高 | 每日免费额兜底 + Credits 定价覆盖 API 成本 |
|
||||||
Generated
+11601
-69
File diff suppressed because it is too large
Load Diff
@@ -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": {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const PAGES = {
|
|||||||
LOGIN: '/pages/login/login',
|
LOGIN: '/pages/login/login',
|
||||||
PRODUCT: '/pages/product/product',
|
PRODUCT: '/pages/product/product',
|
||||||
UPGRADE: '/pages/upgrade/upgrade',
|
UPGRADE: '/pages/upgrade/upgrade',
|
||||||
|
CREDITS: '/pages/credits/credits',
|
||||||
FEEDBACK: '/pages/feedback/feedback',
|
FEEDBACK: '/pages/feedback/feedback',
|
||||||
FOLLOWUP: '/pages/followup/followup',
|
FOLLOWUP: '/pages/followup/followup',
|
||||||
NOTIFICATION: '/pages/notification/notification',
|
NOTIFICATION: '/pages/notification/notification',
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
},
|
},
|
||||||
"quickapp": {},
|
"quickapp": {},
|
||||||
"mp-weixin": {
|
"mp-weixin": {
|
||||||
"appid": "",
|
"appid": "wxdad62baf4ccd09e3",
|
||||||
"setting": {
|
"setting": {
|
||||||
"urlCheck": false,
|
"urlCheck": false,
|
||||||
"es6": true,
|
"es6": true,
|
||||||
|
|||||||
@@ -91,6 +91,12 @@
|
|||||||
"navigationBarTitleText": "升级会员"
|
"navigationBarTitleText": "升级会员"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/credits/credits",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "购买次数"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/followup/followup",
|
"path": "pages/followup/followup",
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view class="balance-card">
|
||||||
|
<text class="balance-label">可用次数</text>
|
||||||
|
<text class="balance-value">{{ balance }}</text>
|
||||||
|
<text class="balance-tip" v-if="subscription">含订阅 {{ subCredits }}/月</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section">
|
||||||
|
<view class="tabs">
|
||||||
|
<text class="tab" :class="{ active: tab === 'packages' }" @click="tab = 'packages'">次数包</text>
|
||||||
|
<text class="tab" :class="{ active: tab === 'plans' }" @click="tab = 'plans'">订阅方案</text>
|
||||||
|
<text class="tab" :class="{ active: tab === 'history' }" @click="tab = 'history'">消费记录</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="tab === 'packages'">
|
||||||
|
<view class="item-card" v-for="pkg in packages" :key="pkg.id">
|
||||||
|
<view class="item-info">
|
||||||
|
<text class="item-name">{{ pkg.name }}</text>
|
||||||
|
<text class="item-credits">{{ pkg.credits }} 次</text>
|
||||||
|
</view>
|
||||||
|
<view class="item-action">
|
||||||
|
<text class="item-price">¥{{ pkg.price }}</text>
|
||||||
|
<view class="buy-btns">
|
||||||
|
<text class="buy-btn" @click="purchase(pkg.id)">微信</text>
|
||||||
|
<text class="buy-btn overseas" @click="overseasPurchase(pkg.id, 'stripe')">Card</text>
|
||||||
|
<text class="buy-btn overseas" @click="overseasPurchase(pkg.id, 'paypal')">PayPal</text>
|
||||||
|
<text class="buy-btn pingpong" @click="overseasPurchase(pkg.id, 'pingpong')">Card</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="tab === 'plans'">
|
||||||
|
<view class="item-card" v-for="plan in subPlans" :key="plan.id"
|
||||||
|
:class="{ active: plan.id === currentSubId }">
|
||||||
|
<view class="item-info">
|
||||||
|
<text class="item-name">{{ plan.name }}</text>
|
||||||
|
<text class="item-credits">{{ plan.credits_per_month }} 次/月</text>
|
||||||
|
</view>
|
||||||
|
<view class="item-action">
|
||||||
|
<text class="item-price">¥{{ plan.price }}/月</text>
|
||||||
|
<text class="buy-btn" v-if="plan.id === currentSubId" @click="cancelSub">取消订阅</text>
|
||||||
|
<text class="buy-btn" v-else @click="subscribe(plan.id)">订阅</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="tab === 'history'">
|
||||||
|
<view class="history-item" v-for="item in history" :key="item.id">
|
||||||
|
<text class="hist-desc">{{ item.description || item.action }}</text>
|
||||||
|
<text class="hist-amount" :class="{ deduct: item.amount < 0 }">
|
||||||
|
{{ item.amount > 0 ? '+' : '' }}{{ item.amount }}
|
||||||
|
</text>
|
||||||
|
<text class="hist-date">{{ formatDate(item.created_at) }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="empty" v-if="history.length === 0">暂无记录</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { creditApi, paymentApi } from '@/utils/api.js'
|
||||||
|
|
||||||
|
const tab = ref('packages')
|
||||||
|
const balance = ref(0)
|
||||||
|
const packages = ref([])
|
||||||
|
const subPlans = ref([])
|
||||||
|
const history = ref([])
|
||||||
|
const subscription = ref(null)
|
||||||
|
const currentSubId = ref('')
|
||||||
|
const subCredits = ref(0)
|
||||||
|
|
||||||
|
const handlePayPalRedirect = async () => {
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
const gateway = params.get('gateway')
|
||||||
|
const token = params.get('token')
|
||||||
|
const orderId = params.get('order_id')
|
||||||
|
const result = params.get('result')
|
||||||
|
if (gateway === 'paypal' && result === 'success' && token && orderId) {
|
||||||
|
try {
|
||||||
|
await creditApi.paypalCapture(orderId, token)
|
||||||
|
uni.showToast({ title: '支付成功', icon: 'success' })
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: e.message || '支付处理失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
url.search = ''
|
||||||
|
window.history.replaceState({}, '', url)
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
const cb = await creditApi.balance()
|
||||||
|
balance.value = cb.balance || 0
|
||||||
|
subscription.value = cb.subscription || null
|
||||||
|
if (subscription.value) {
|
||||||
|
currentSubId.value = subscription.value.plan_id
|
||||||
|
subCredits.value = subscription.value.credits_per_month || 0
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
const pkgs = await creditApi.packages()
|
||||||
|
packages.value = pkgs || []
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
const plans = await creditApi.subscriptionPlans()
|
||||||
|
subPlans.value = plans || []
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
const h = await creditApi.history()
|
||||||
|
history.value = h.items || h || []
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const purchase = async (packageId) => {
|
||||||
|
uni.showLoading({ title: '创建订单...' })
|
||||||
|
try {
|
||||||
|
const res = await creditApi.purchase(packageId, 'jsapi')
|
||||||
|
if (res.pay_params) {
|
||||||
|
uni.requestPayment({
|
||||||
|
provider: 'wxpay',
|
||||||
|
timeStamp: res.pay_params.timeStamp,
|
||||||
|
nonceStr: res.pay_params.nonceStr,
|
||||||
|
package: res.pay_params.package,
|
||||||
|
signType: res.pay_params.signType,
|
||||||
|
paySign: res.pay_params.paySign,
|
||||||
|
success: () => {
|
||||||
|
uni.showToast({ title: '购买成功', icon: 'success' })
|
||||||
|
loadData()
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
uni.showToast({ title: '支付取消', icon: 'none' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else if (res.pay_url) {
|
||||||
|
window.location.href = res.pay_url
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: '订单创建成功', icon: 'success' })
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: e.message || '购买失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
uni.hideLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const overseasPurchase = async (packageId, gateway) => {
|
||||||
|
uni.showLoading({ title: '跳转支付...' })
|
||||||
|
try {
|
||||||
|
const baseUrl = 'https://trade.yuzhiran.com/workspace/credits'
|
||||||
|
const res = await creditApi.stripePurchase(packageId, gateway, `${baseUrl}?gateway=${gateway}&result=success`, `${baseUrl}?gateway=${gateway}&result=cancel`)
|
||||||
|
if (res.session_url) {
|
||||||
|
window.location.href = res.session_url
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: '创建订单失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: e.message || '下单失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
uni.hideLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const subscribe = async (planId) => {
|
||||||
|
uni.showLoading({ title: '处理中...' })
|
||||||
|
try {
|
||||||
|
const res = await paymentApi.createOrder(planId, 'jsapi')
|
||||||
|
if (res.pay_params) {
|
||||||
|
uni.requestPayment({
|
||||||
|
provider: 'wxpay',
|
||||||
|
timeStamp: res.pay_params.timeStamp,
|
||||||
|
nonceStr: res.pay_params.nonceStr,
|
||||||
|
package: res.pay_params.package,
|
||||||
|
signType: res.pay_params.signType,
|
||||||
|
paySign: res.pay_params.paySign,
|
||||||
|
success: () => {
|
||||||
|
uni.showToast({ title: '订阅成功', icon: 'success' })
|
||||||
|
loadData()
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
uni.showToast({ title: '支付取消', icon: 'none' })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} else if (res.pay_url) {
|
||||||
|
window.location.href = res.pay_url
|
||||||
|
} else {
|
||||||
|
uni.showToast({ title: '订阅成功', icon: 'success' })
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: e.message || '订阅失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
uni.hideLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelSub = async () => {
|
||||||
|
uni.showModal({
|
||||||
|
title: '提示',
|
||||||
|
content: '确定取消订阅?',
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
try {
|
||||||
|
await creditApi.cancelSubscription()
|
||||||
|
uni.showToast({ title: '已取消', icon: 'success' })
|
||||||
|
loadData()
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: e.message || '取消失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (d) => {
|
||||||
|
if (!d) return ''
|
||||||
|
const date = new Date(d)
|
||||||
|
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadData)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page { padding: 20rpx; background: #f5f5f5; min-height: 100vh; }
|
||||||
|
.balance-card {
|
||||||
|
background: linear-gradient(135deg, #1890ff, #096dd9);
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 40rpx;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
.balance-label { font-size: 28rpx; color: rgba(255,255,255,0.8); display: block; }
|
||||||
|
.balance-value { font-size: 72rpx; color: #fff; font-weight: bold; display: block; margin: 10rpx 0; }
|
||||||
|
.balance-tip { font-size: 24rpx; color: rgba(255,255,255,0.7); display: block; }
|
||||||
|
.section { background: #fff; border-radius: 16rpx; overflow: hidden; }
|
||||||
|
.tabs { display: flex; border-bottom: 2rpx solid #f0f0f0; }
|
||||||
|
.tab {
|
||||||
|
flex: 1; text-align: center; padding: 24rpx 0;
|
||||||
|
font-size: 28rpx; color: #666; position: relative;
|
||||||
|
}
|
||||||
|
.tab.active { color: #1890ff; font-weight: 600; }
|
||||||
|
.tab.active::after {
|
||||||
|
content: ''; position: absolute; bottom: 0; left: 20%; right: 20%;
|
||||||
|
height: 4rpx; background: #1890ff; border-radius: 2rpx;
|
||||||
|
}
|
||||||
|
.item-card {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 28rpx 30rpx; border-bottom: 2rpx solid #f5f5f5;
|
||||||
|
}
|
||||||
|
.item-card.active { background: #f0f9ff; }
|
||||||
|
.item-info { display: flex; flex-direction: column; }
|
||||||
|
.item-name { font-size: 30rpx; font-weight: 500; color: #333; }
|
||||||
|
.item-credits { font-size: 24rpx; color: #999; margin-top: 6rpx; }
|
||||||
|
.item-action { display: flex; flex-direction: column; align-items: flex-end; }
|
||||||
|
.item-price { font-size: 32rpx; color: #f5222d; font-weight: bold; }
|
||||||
|
.buy-btn {
|
||||||
|
font-size: 24rpx; color: #1890ff; margin-top: 8rpx; padding: 4rpx 16rpx;
|
||||||
|
border: 2rpx solid #1890ff; border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
.buy-btns { display: flex; gap: 8rpx; margin-top: 8rpx; }
|
||||||
|
.buy-btn.overseas { color: #52c41a; border-color: #52c41a; }
|
||||||
|
.buy-btn.pingpong { color: #722ed1; border-color: #722ed1; }
|
||||||
|
.history-item {
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
padding: 24rpx 30rpx; border-bottom: 2rpx solid #f5f5f5;
|
||||||
|
}
|
||||||
|
.hist-desc { flex: 1; font-size: 26rpx; color: #333; }
|
||||||
|
.hist-amount { font-size: 28rpx; font-weight: bold; color: #52c41a; margin-left: 16rpx; }
|
||||||
|
.hist-amount.deduct { color: #f5222d; }
|
||||||
|
.hist-date { font-size: 22rpx; color: #999; margin-left: 16rpx; min-width: 100rpx; text-align: right; }
|
||||||
|
.empty { text-align: center; padding: 60rpx; color: #999; font-size: 28rpx; }
|
||||||
|
</style>
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -9,6 +9,16 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view class="credit-card" v-if="user.tier !== 'guest'" @click="goCredits">
|
||||||
|
<view class="credit-left">
|
||||||
|
<text class="credit-label">可用次数</text>
|
||||||
|
<text class="credit-value">{{ creditBalance }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="credit-right">
|
||||||
|
<text class="credit-buy">购买次数 ›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="section">
|
<view class="section">
|
||||||
<view class="section-title">账号设置</view>
|
<view class="section-title">账号设置</view>
|
||||||
<view class="menu-item" v-if="user.tier !== 'guest'" @click="showProfileEdit = true">
|
<view class="menu-item" v-if="user.tier !== 'guest'" @click="showProfileEdit = true">
|
||||||
@@ -21,9 +31,9 @@
|
|||||||
<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="goUpgrade">
|
<view class="menu-item" @click="goCredits">
|
||||||
<text class="menu-icon">⭐</text>
|
<text class="menu-icon">⭐</text>
|
||||||
<text class="menu-text">会员升级</text>
|
<text class="menu-text">购买次数</text>
|
||||||
<text class="menu-arrow">›</text>
|
<text class="menu-arrow">›</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -49,21 +59,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>
|
||||||
@@ -118,11 +138,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { onShow } from '@dcloudio/uni-app'
|
import { onShow } from '@dcloudio/uni-app'
|
||||||
import { authApi } from '@/utils/api.js'
|
import { authApi, creditApi } 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'
|
||||||
|
|
||||||
const user = ref({})
|
const user = ref({})
|
||||||
|
const creditBalance = ref(0)
|
||||||
const showProfileEdit = ref(false)
|
const showProfileEdit = ref(false)
|
||||||
const showPassword = ref(false)
|
const showPassword = ref(false)
|
||||||
const editForm = ref({ username: '', email: '' })
|
const editForm = ref({ username: '', email: '' })
|
||||||
@@ -148,6 +169,12 @@ const loadUser = async () => {
|
|||||||
} catch {
|
} catch {
|
||||||
user.value = { tier: 'guest' }
|
user.value = { tier: 'guest' }
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
const cb = await creditApi.balance()
|
||||||
|
creditBalance.value = cb.balance || 0
|
||||||
|
} catch {
|
||||||
|
creditBalance.value = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const saveProfile = async () => {
|
const saveProfile = async () => {
|
||||||
@@ -185,7 +212,7 @@ const changePwd = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const goLogin = () => uni.navigateTo({ url: PAGES.LOGIN })
|
const goLogin = () => uni.navigateTo({ url: PAGES.LOGIN })
|
||||||
const goUpgrade = () => uni.navigateTo({ url: PAGES.UPGRADE })
|
const goCredits = () => uni.navigateTo({ url: PAGES.CREDITS })
|
||||||
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}` })
|
||||||
const goCertification = () => uni.navigateTo({ url: PAGES.CERTIFICATION })
|
const goCertification = () => uni.navigateTo({ url: PAGES.CERTIFICATION })
|
||||||
@@ -270,6 +297,21 @@ onShow(loadUser)
|
|||||||
.tier-badge.enterprise { background: #e3f2fd; color: #1565c0; }
|
.tier-badge.enterprise { background: #e3f2fd; color: #1565c0; }
|
||||||
.tier-badge.guest { background: #fce4ec; color: #c62828; }
|
.tier-badge.guest { background: #fce4ec; color: #c62828; }
|
||||||
|
|
||||||
|
.credit-card {
|
||||||
|
background: linear-gradient(135deg, #f97316, #ea580c);
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 28rpx 32rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.credit-left { display: flex; flex-direction: column; }
|
||||||
|
.credit-label { font-size: 24rpx; color: rgba(255,255,255,0.8); }
|
||||||
|
.credit-value { font-size: 48rpx; color: #fff; font-weight: bold; }
|
||||||
|
.credit-right { }
|
||||||
|
.credit-buy { font-size: 28rpx; color: #fff; font-weight: 500; }
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 16rpx;
|
border-radius: 16rpx;
|
||||||
@@ -412,4 +454,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>
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -253,6 +259,20 @@ export const paymentApi = {
|
|||||||
request('/payment/create-order', 'POST', { plan, pay_type: payType }),
|
request('/payment/create-order', 'POST', { plan, pay_type: payType }),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const creditApi = {
|
||||||
|
balance: () => request('/credits/balance'),
|
||||||
|
history: (page = 1, size = 20) => request(`/credits/history?page=${page}&size=${size}`),
|
||||||
|
packages: () => request('/credits/packages'),
|
||||||
|
subscriptionPlans: () => request('/credits/subscription-plans'),
|
||||||
|
purchase: (packageId, payType = 'alipay') =>
|
||||||
|
request('/credits/purchase', 'POST', { package_id: packageId, pay_type: payType }),
|
||||||
|
stripePurchase: (packageId, gateway = 'stripe', successUrl, cancelUrl) =>
|
||||||
|
request('/credits/stripe-purchase', 'POST', { package_id: packageId, gateway: gateway, success_url: successUrl, cancel_url: cancelUrl }),
|
||||||
|
cancelSubscription: () => request('/credits/cancel-subscription', 'POST'),
|
||||||
|
paypalCapture: (orderNo, token) =>
|
||||||
|
request('/payment/paypal-capture', 'POST', { order_no: orderNo, token: token }),
|
||||||
|
}
|
||||||
|
|
||||||
export const feedbackApi = {
|
export const feedbackApi = {
|
||||||
submit: (content, category = 'general', contact = '') =>
|
submit: (content, category = 'general', contact = '') =>
|
||||||
request('/feedback', 'POST', { content, category, contact }),
|
request('/feedback', 'POST', { content, category, contact }),
|
||||||
|
|||||||
Generated
+115
-2
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "trademate-admin",
|
"name": "trademate-user",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "trademate-admin",
|
"name": "trademate-user",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@element-plus/icons-vue": "^2.3.1",
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
@@ -14,10 +14,12 @@
|
|||||||
"element-plus": "^2.9.1",
|
"element-plus": "^2.9.1",
|
||||||
"pinia": "^2.3.0",
|
"pinia": "^2.3.0",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
|
"vue-i18n": "^9.14.4",
|
||||||
"vue-router": "^4.5.0"
|
"vue-router": "^4.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"playwright": "^1.60.0",
|
||||||
"vite": "^6.0.7"
|
"vite": "^6.0.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -552,6 +554,50 @@
|
|||||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@intlify/core-base": {
|
||||||
|
"version": "9.14.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@intlify/core-base/-/core-base-9.14.4.tgz",
|
||||||
|
"integrity": "sha512-vtZCt7NqWhKEtHa3SD/322DlgP5uR9MqWxnE0y8Q0tjDs9H5Lxhss+b5wv8rmuXRoHKLESNgw9d+EN9ybBbj9g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/message-compiler": "9.14.4",
|
||||||
|
"@intlify/shared": "9.14.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@intlify/message-compiler": {
|
||||||
|
"version": "9.14.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@intlify/message-compiler/-/message-compiler-9.14.4.tgz",
|
||||||
|
"integrity": "sha512-vcyCLiVRN628U38c3PbahrhbbXrckrM9zpy0KZVlDk2Z0OnGwv8uQNNXP3twwGtfLsCf4gu3ci6FMIZnPaqZsw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/shared": "9.14.4",
|
||||||
|
"source-map-js": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@intlify/shared": {
|
||||||
|
"version": "9.14.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@intlify/shared/-/shared-9.14.4.tgz",
|
||||||
|
"integrity": "sha512-P9zv6i1WvMc9qDBWvIgKkymjY2ptIiQ065PjDv7z7fDqH3J/HBRBN5IoiR46r/ujRcU7hCuSIZWvCAFCyuOYZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@jridgewell/sourcemap-codec": {
|
"node_modules/@jridgewell/sourcemap-codec": {
|
||||||
"version": "1.5.5",
|
"version": "1.5.5",
|
||||||
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
"resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
|
||||||
@@ -1706,6 +1752,53 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.60.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.60.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||||
|
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.15",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz",
|
||||||
@@ -1942,6 +2035,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-i18n": {
|
||||||
|
"version": "9.14.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-9.14.4.tgz",
|
||||||
|
"integrity": "sha512-B934C8yUyWLT0EMud3DySrwSUJI7ZNiWYsEEz2gknTthqKiG4dzWE/WSa8AzCuSQzwBEv4HtG1jZDhgzPfWSKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@intlify/core-base": "9.14.4",
|
||||||
|
"@intlify/shared": "9.14.4",
|
||||||
|
"@vue/devtools-api": "^6.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/kazupon"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vue-router": {
|
"node_modules/vue-router": {
|
||||||
"version": "4.6.4",
|
"version": "4.6.4",
|
||||||
"resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz",
|
"resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz",
|
||||||
|
|||||||
@@ -9,16 +9,18 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"vue": "^3.5.13",
|
|
||||||
"vue-router": "^4.5.0",
|
|
||||||
"element-plus": "^2.9.1",
|
|
||||||
"@element-plus/icons-vue": "^2.3.1",
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"element-plus": "^2.9.1",
|
||||||
"pinia": "^2.3.0",
|
"pinia": "^2.3.0",
|
||||||
"dayjs": "^1.11.13"
|
"vue": "^3.5.13",
|
||||||
|
"vue-i18n": "^9.14.4",
|
||||||
|
"vue-router": "^4.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^5.2.1",
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"playwright": "^1.60.0",
|
||||||
"vite": "^6.0.7"
|
"vite": "^6.0.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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') }
|
||||||
@@ -119,4 +119,16 @@ export function getUsageStats() { return http.get('/usage/stats') }
|
|||||||
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 getCreditBalance() { return http.get('/credits/balance') }
|
||||||
|
export function getCreditHistory(params) { return http.get('/credits/history', { params }) }
|
||||||
|
export function getCreditPackages() { return http.get('/credits/packages') }
|
||||||
|
export function getSubscriptionPlans() { return http.get('/credits/subscription-plans') }
|
||||||
|
export function purchaseCreditPackage(packageId, payType = 'alipay') {
|
||||||
|
return http.post('/credits/purchase', { package_id: packageId, pay_type: payType })
|
||||||
|
}
|
||||||
|
export function subscribeCreditPlan(planId, payType = 'alipay') {
|
||||||
|
return http.post('/credits/subscribe', { plan_id: planId, pay_type: payType })
|
||||||
|
}
|
||||||
|
export function cancelCreditSubscription() { return http.post('/credits/cancel-subscription') }
|
||||||
|
|
||||||
export default http
|
export default http
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
import zhCN from './locales/zh-CN.json'
|
||||||
|
import en from './locales/en.json'
|
||||||
|
|
||||||
|
const savedLang = localStorage.getItem('lang') || 'zh-CN'
|
||||||
|
|
||||||
|
const i18n = createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: savedLang,
|
||||||
|
fallbackLocale: 'zh-CN',
|
||||||
|
messages: {
|
||||||
|
'zh-CN': zhCN,
|
||||||
|
'en': en,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function switchLang(lang) {
|
||||||
|
i18n.global.locale.value = lang
|
||||||
|
localStorage.setItem('lang', lang)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default i18n
|
||||||
@@ -16,16 +16,16 @@
|
|||||||
:collapse-transition="false"
|
:collapse-transition="false"
|
||||||
@select="showMobileMenu = false"
|
@select="showMobileMenu = false"
|
||||||
>
|
>
|
||||||
<el-menu-item index="/workspace"><el-icon><Odometer /></el-icon><span>工作台</span></el-menu-item>
|
<el-menu-item index="/discovery"><el-icon><Search /></el-icon><span>{{ $t('nav.discovery') }}</span></el-menu-item>
|
||||||
<el-menu-item index="/translate"><el-icon><ChatLineSquare /></el-icon><span>智能翻译</span></el-menu-item>
|
<el-menu-item index="/workspace"><el-icon><Odometer /></el-icon><span>{{ $t('nav.workspace') }}</span></el-menu-item>
|
||||||
<el-menu-item index="/customers"><el-icon><User /></el-icon><span>客户管理</span></el-menu-item>
|
<el-menu-item index="/customers"><el-icon><User /></el-icon><span>{{ $t('nav.customers') }}</span></el-menu-item>
|
||||||
<el-menu-item index="/products"><el-icon><Goods /></el-icon><span>产品库</span></el-menu-item>
|
<el-menu-item index="/products"><el-icon><Goods /></el-icon><span>{{ $t('nav.products') }}</span></el-menu-item>
|
||||||
<el-menu-item index="/quotations"><el-icon><DocumentCopy /></el-icon><span>报价单</span></el-menu-item>
|
<el-menu-item index="/quotations"><el-icon><DocumentCopy /></el-icon><span>{{ $t('nav.quotations') }}</span></el-menu-item>
|
||||||
<el-menu-item index="/marketing"><el-icon><Promotion /></el-icon><span>营销素材</span></el-menu-item>
|
<el-menu-item index="/translate"><el-icon><ChatLineSquare /></el-icon><span>{{ $t('nav.translate') }}</span></el-menu-item>
|
||||||
<el-menu-item index="/discovery"><el-icon><Search /></el-icon><span>挖掘新客</span></el-menu-item>
|
<el-menu-item index="/marketing"><el-icon><Promotion /></el-icon><span>{{ $t('nav.marketing') }}</span></el-menu-item>
|
||||||
<el-menu-item index="/followup"><el-icon><Message /></el-icon><span>智能跟进</span></el-menu-item>
|
<el-menu-item index="/followup"><el-icon><Message /></el-icon><span>{{ $t('nav.followup') }}</span></el-menu-item>
|
||||||
<el-menu-item index="/analytics"><el-icon><DataAnalysis /></el-icon><span>数据分析</span></el-menu-item>
|
<el-menu-item index="/analytics"><el-icon><DataAnalysis /></el-icon><span>{{ $t('nav.analytics') }}</span></el-menu-item>
|
||||||
<el-menu-item index="/team"><el-icon><UserFilled /></el-icon><span>团队协作</span></el-menu-item>
|
<el-menu-item index="/team"><el-icon><UserFilled /></el-icon><span>{{ $t('nav.team') }}</span></el-menu-item>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -35,10 +35,15 @@
|
|||||||
<el-icon :size="20"><Expand /></el-icon>
|
<el-icon :size="20"><Expand /></el-icon>
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-breadcrumb separator="/" class="breadcrumb">
|
<el-breadcrumb separator="/" class="breadcrumb">
|
||||||
<el-breadcrumb-item :to="'/workspace'">工作台</el-breadcrumb-item>
|
<el-breadcrumb-item :to="'/workspace'">{{ $t('nav.workspace') }}</el-breadcrumb-item>
|
||||||
<el-breadcrumb-item v-if="route.meta?.title" :to="route.path">{{ route.meta.title }}</el-breadcrumb-item>
|
<el-breadcrumb-item v-if="route.meta?.title" :to="route.path">{{ $t('nav.' + route.name?.toLowerCase()) || route.meta.title }}</el-breadcrumb-item>
|
||||||
</el-breadcrumb>
|
</el-breadcrumb>
|
||||||
<div class="topbar-right">
|
<div class="topbar-right">
|
||||||
|
<el-button text style="font-size:13px;color:#999" @click="toggleLang">{{ currentLang }}</el-button>
|
||||||
|
<el-button v-if="creditBalance !== null" text class="credit-btn" @click="$router.push('/credits')">
|
||||||
|
<el-icon><Coin /></el-icon>
|
||||||
|
<span class="credit-text">{{ creditBalance }} {{ $t('topbar.credits') }}</span>
|
||||||
|
</el-button>
|
||||||
<el-badge :value="unread" :hidden="!unread" class="notif-badge">
|
<el-badge :value="unread" :hidden="!unread" class="notif-badge">
|
||||||
<el-button text style="font-size:18px" @click="$router.push('/notifications')">
|
<el-button text style="font-size:18px" @click="$router.push('/notifications')">
|
||||||
<el-icon><Bell /></el-icon>
|
<el-icon><Bell /></el-icon>
|
||||||
@@ -52,9 +57,9 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
<el-dropdown-menu>
|
<el-dropdown-menu>
|
||||||
<el-dropdown-item @click="$router.push('/profile')">个人中心</el-dropdown-item>
|
<el-dropdown-item @click="$router.push('/profile')">{{ $t('nav.profile') }}</el-dropdown-item>
|
||||||
<el-dropdown-item @click="$router.push('/notifications')">通知中心</el-dropdown-item>
|
<el-dropdown-item @click="$router.push('/notifications')">{{ $t('nav.notifications') }}</el-dropdown-item>
|
||||||
<el-dropdown-item divided @click="handleLogout">退出登录</el-dropdown-item>
|
<el-dropdown-item divided @click="handleLogout">{{ $t('common.confirm') }}退出</el-dropdown-item>
|
||||||
</el-dropdown-menu>
|
</el-dropdown-menu>
|
||||||
</template>
|
</template>
|
||||||
</el-dropdown>
|
</el-dropdown>
|
||||||
@@ -67,37 +72,13 @@
|
|||||||
|
|
||||||
<footer class="footer">
|
<footer class="footer">
|
||||||
<div class="footer-content">
|
<div class="footer-content">
|
||||||
<div class="footer-section">
|
<p>© {{ 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>© {{ 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>
|
||||||
@@ -109,16 +90,41 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { getUnreadCount } from '@/api'
|
import { getUnreadCount, getCreditBalance } from '@/api'
|
||||||
import AiAssistant from '@/components/AiAssistant.vue'
|
import AiAssistant from '@/components/AiAssistant.vue'
|
||||||
|
import { switchLang } from '@/i18n'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { locale } = useI18n()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const collapsed = ref(false)
|
const collapsed = ref(false)
|
||||||
const showMobileMenu = ref(false)
|
const showMobileMenu = ref(false)
|
||||||
const unread = ref(0)
|
const unread = ref(0)
|
||||||
|
const creditBalance = ref(null)
|
||||||
|
|
||||||
|
const currentLang = computed(() => locale.value === 'en' ? 'English' : '中文')
|
||||||
|
function toggleLang() {
|
||||||
|
const next = locale.value === 'en' ? 'zh-CN' : 'en'
|
||||||
|
switchLang(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCreditBalance() {
|
||||||
|
try {
|
||||||
|
const res = await getCreditBalance()
|
||||||
|
creditBalance.value = res.balance
|
||||||
|
} catch (e) { creditBalance.value = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const res = await getUnreadCount()
|
||||||
|
unread.value = res.count || res || 0
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
loadCreditBalance()
|
||||||
|
})
|
||||||
|
|
||||||
const beianInfo = computed(() => {
|
const beianInfo = computed(() => {
|
||||||
const hostname = window.location.hostname
|
const hostname = window.location.hostname
|
||||||
@@ -160,20 +166,15 @@ 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; }
|
||||||
.gongan-link { display: inline-flex; align-items: center; gap: 4px; }
|
.gongan-link { display: inline-flex; align-items: center; gap: 4px; }
|
||||||
.gongan-icon { height: 14px; vertical-align: middle; }
|
.gongan-icon { height: 14px; vertical-align: middle; }
|
||||||
|
.credit-btn { display: flex; align-items: center; gap: 4px; color: #e6a23c !important; font-weight: 600; }
|
||||||
|
.credit-text { font-size: 13px; }
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.sidebar { position: fixed; left: -220px; top: 0; bottom: 0; z-index: 1000; transition: left 0.3s; }
|
.sidebar { position: fixed; left: -220px; top: 0; bottom: 0; z-index: 1000; transition: left 0.3s; }
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"discovery": "Find Buyers",
|
||||||
|
"workspace": "Dashboard",
|
||||||
|
"customers": "Customers",
|
||||||
|
"products": "Products",
|
||||||
|
"quotations": "Quotations",
|
||||||
|
"translate": "Translate",
|
||||||
|
"marketing": "Marketing",
|
||||||
|
"followup": "Follow-ups",
|
||||||
|
"analytics": "Analytics",
|
||||||
|
"team": "Team",
|
||||||
|
"notifications": "Notifications",
|
||||||
|
"profile": "Profile",
|
||||||
|
"credits": "Buy Credits",
|
||||||
|
"settings": "Settings"
|
||||||
|
},
|
||||||
|
"topbar": {
|
||||||
|
"credits": "credits",
|
||||||
|
"purchase": "Buy Credits",
|
||||||
|
"notifications": "Notifications"
|
||||||
|
},
|
||||||
|
"discovery": {
|
||||||
|
"title": "Discover New Customers",
|
||||||
|
"subtitle": "Enter a product description to find potential buyers worldwide",
|
||||||
|
"product_placeholder": "e.g. LED lighting, solar panels, auto parts",
|
||||||
|
"market_placeholder": "Target market (e.g. US, Germany, leave empty for global)",
|
||||||
|
"search": "Search Buyers",
|
||||||
|
"searching": "Searching...",
|
||||||
|
"results": "Search Results",
|
||||||
|
"no_results": "No results found. Try different keywords",
|
||||||
|
"credits_cost": "Each search costs 10 credits",
|
||||||
|
"analyze": "Deep Analysis",
|
||||||
|
"outreach": "Generate Outreach",
|
||||||
|
"add_customer": "Add as Customer",
|
||||||
|
"visit_website": "Visit Website",
|
||||||
|
"match_score": "Match Score",
|
||||||
|
"contact": "Contact Info"
|
||||||
|
},
|
||||||
|
"credits": {
|
||||||
|
"title": "Credits",
|
||||||
|
"balance": "Current Balance",
|
||||||
|
"packages": "Buy Credits",
|
||||||
|
"subscription": "Subscription",
|
||||||
|
"history": "Transaction History",
|
||||||
|
"purchase": "Buy Now",
|
||||||
|
"subscribe": "Subscribe",
|
||||||
|
"per_month": "/month",
|
||||||
|
"per_credit": "per credit",
|
||||||
|
"pay_alipay": "Alipay",
|
||||||
|
"pay_wechat": "WeChat Pay",
|
||||||
|
"confirm_pay": "Confirm Payment",
|
||||||
|
"scan_pay": "Scan to Pay",
|
||||||
|
"insufficient": "Insufficient credits",
|
||||||
|
"buy_more": "Buy More",
|
||||||
|
"total_purchased": "Total Purchased",
|
||||||
|
"total_used": "Total Used"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "Loading...",
|
||||||
|
"error": "Something went wrong",
|
||||||
|
"retry": "Retry",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"save": "Save",
|
||||||
|
"delete": "Delete",
|
||||||
|
"edit": "Edit",
|
||||||
|
"back": "Back",
|
||||||
|
"no_data": "No data",
|
||||||
|
"search": "Search",
|
||||||
|
"all": "All",
|
||||||
|
"status": "Status",
|
||||||
|
"time": "Time"
|
||||||
|
},
|
||||||
|
"lang": {
|
||||||
|
"switch_to": "中文",
|
||||||
|
"current": "English"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"discovery": "发现客户",
|
||||||
|
"workspace": "工作台",
|
||||||
|
"customers": "客户管理",
|
||||||
|
"products": "产品库",
|
||||||
|
"quotations": "报价单",
|
||||||
|
"translate": "智能翻译",
|
||||||
|
"marketing": "营销素材",
|
||||||
|
"followup": "跟进提醒",
|
||||||
|
"analytics": "数据分析",
|
||||||
|
"team": "团队协作",
|
||||||
|
"notifications": "通知中心",
|
||||||
|
"profile": "个人中心",
|
||||||
|
"credits": "购买次数",
|
||||||
|
"settings": "设置"
|
||||||
|
},
|
||||||
|
"topbar": {
|
||||||
|
"credits": "次",
|
||||||
|
"purchase": "购买次数",
|
||||||
|
"notifications": "通知"
|
||||||
|
},
|
||||||
|
"discovery": {
|
||||||
|
"title": "发现新客户",
|
||||||
|
"subtitle": "输入产品关键词,找到全球潜在买家",
|
||||||
|
"product_placeholder": "例如:LED lighting, solar panels, auto parts",
|
||||||
|
"market_placeholder": "目标市场(如 US, Germany, 留空为全球)",
|
||||||
|
"search": "搜索买家",
|
||||||
|
"searching": "正在搜索...",
|
||||||
|
"results": "搜索结果",
|
||||||
|
"no_results": "暂无结果,请尝试其他关键词",
|
||||||
|
"credits_cost": "每次搜索消耗 10 次",
|
||||||
|
"analyze": "深度分析",
|
||||||
|
"outreach": "生成开发信",
|
||||||
|
"add_customer": "添加为客户",
|
||||||
|
"visit_website": "访问网站",
|
||||||
|
"match_score": "匹配度",
|
||||||
|
"contact": "联系方式"
|
||||||
|
},
|
||||||
|
"credits": {
|
||||||
|
"title": "信用次数",
|
||||||
|
"balance": "当前余额",
|
||||||
|
"packages": "购买次数",
|
||||||
|
"subscription": "订阅套餐",
|
||||||
|
"history": "消费记录",
|
||||||
|
"purchase": "立即购买",
|
||||||
|
"subscribe": "开通订阅",
|
||||||
|
"per_month": "次/月",
|
||||||
|
"per_credit": "次均",
|
||||||
|
"pay_alipay": "支付宝",
|
||||||
|
"pay_wechat": "微信支付",
|
||||||
|
"confirm_pay": "确认支付",
|
||||||
|
"scan_pay": "扫码支付",
|
||||||
|
"insufficient": "次数不足",
|
||||||
|
"buy_more": "去购买",
|
||||||
|
"total_purchased": "已购买",
|
||||||
|
"total_used": "已使用"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "加载中...",
|
||||||
|
"error": "出错了",
|
||||||
|
"retry": "重试",
|
||||||
|
"cancel": "取消",
|
||||||
|
"confirm": "确认",
|
||||||
|
"save": "保存",
|
||||||
|
"delete": "删除",
|
||||||
|
"edit": "编辑",
|
||||||
|
"back": "返回",
|
||||||
|
"no_data": "暂无数据",
|
||||||
|
"search": "搜索",
|
||||||
|
"all": "全部",
|
||||||
|
"status": "状态",
|
||||||
|
"time": "时间"
|
||||||
|
},
|
||||||
|
"lang": {
|
||||||
|
"switch_to": "English",
|
||||||
|
"current": "中文"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,10 +6,12 @@ import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
|||||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
import i18n from './i18n'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
app.use(createPinia())
|
app.use(createPinia())
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
app.use(i18n)
|
||||||
app.use(ElementPlus, { locale: zhCn })
|
app.use(ElementPlus, { locale: zhCn })
|
||||||
for (const [k, v] of Object.entries(ElementPlusIconsVue)) app.component(k, v)
|
for (const [k, v] of Object.entries(ElementPlusIconsVue)) app.component(k, v)
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ const routes = [
|
|||||||
component: () => import('@/layouts/UserLayout.vue'),
|
component: () => import('@/layouts/UserLayout.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
children: [
|
children: [
|
||||||
{ path: '', name: 'Discovery', component: () => import('@/views/Discovery.vue'), meta: { title: '挖掘新客' } },
|
{ path: '', name: 'Discovery', component: () => import('@/views/Discovery.vue'), meta: { title: '发现客户' } },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -100,11 +100,11 @@ const routes = [
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/upgrade',
|
path: '/credits',
|
||||||
component: () => import('@/layouts/UserLayout.vue'),
|
component: () => import('@/layouts/UserLayout.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
children: [
|
children: [
|
||||||
{ path: '', name: 'Upgrade', component: () => import('@/views/Upgrade.vue'), meta: { title: '升级会员' } },
|
{ path: '', name: 'Credits', component: () => import('@/views/Credits.vue'), meta: { title: '购买次数' } },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<template>
|
||||||
|
<div class="credits-page">
|
||||||
|
<div class="balance-card" v-if="balance">
|
||||||
|
<el-card>
|
||||||
|
<div class="balance-header">
|
||||||
|
<div>
|
||||||
|
<div class="balance-label">当前余额</div>
|
||||||
|
<div class="balance-amount">{{ balance.balance }} <small>次</small></div>
|
||||||
|
</div>
|
||||||
|
<div class="balance-sub">
|
||||||
|
<span>已购买 {{ balance.total_purchased }} 次</span>
|
||||||
|
<span>已使用 {{ balance.total_used }} 次</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="balance.subscription" class="subscription-info">
|
||||||
|
<el-tag type="success" v-if="balance.subscription.auto_renew">订阅中</el-tag>
|
||||||
|
<span v-if="balance.subscription.expires_at">到期 {{ balance.subscription.expires_at?.split('T')[0] }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-tabs v-model="activeTab">
|
||||||
|
<el-tab-pane label="购买次数" name="packages">
|
||||||
|
<div class="package-grid">
|
||||||
|
<div v-for="pkg in packages" :key="pkg.id" class="package-card" @click="buyPackage(pkg)">
|
||||||
|
<div class="pkg-name">{{ pkg.name }}</div>
|
||||||
|
<div class="pkg-name-en">{{ pkg.name_en }}</div>
|
||||||
|
<div class="pkg-credits">{{ pkg.credits }} <small>次</small></div>
|
||||||
|
<div class="pkg-price">
|
||||||
|
<span class="current">¥{{ pkg.price }}</span>
|
||||||
|
<span v-if="pkg.original_price" class="original">¥{{ pkg.original_price }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pkg-unit">≈ ¥{{ (pkg.price / pkg.credits).toFixed(2) }}/次</div>
|
||||||
|
<el-button type="primary" class="pkg-btn">立即购买</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="订阅套餐" name="subscription">
|
||||||
|
<div class="plan-grid">
|
||||||
|
<div v-for="plan in subscriptionPlans" :key="plan.id" class="plan-card">
|
||||||
|
<div class="plan-name">{{ plan.name }}</div>
|
||||||
|
<div class="plan-name-en">{{ plan.name_en }}</div>
|
||||||
|
<div class="plan-credits">{{ plan.credits_per_month }} <small>次/月</small></div>
|
||||||
|
<div class="plan-price">¥{{ plan.price }}<small>/月</small></div>
|
||||||
|
<div class="plan-unit">≈ ¥{{ (plan.price / plan.credits_per_month).toFixed(2) }}/次</div>
|
||||||
|
<el-button type="warning" class="plan-btn" @click="subscribePlan(plan)">开通订阅</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-card v-if="balance?.subscription" style="margin-top:16px">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between">
|
||||||
|
<span>当前订阅状态: <el-tag type="success">已订阅</el-tag></span>
|
||||||
|
<el-button size="small" @click="cancelSubscription">取消自动续费</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="消费记录" name="history">
|
||||||
|
<el-table :data="history" border stripe v-loading="historyLoading">
|
||||||
|
<el-table-column prop="created_at" label="时间" width="160" />
|
||||||
|
<el-table-column prop="result_type" label="功能" width="140" />
|
||||||
|
<el-table-column prop="credits_change" label="变化" width="80">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span :style="{ color: row.credits_change < 0 ? '#f56c6c' : '#67c23a', fontWeight: 'bold' }">
|
||||||
|
{{ row.credits_change > 0 ? '+' : '' }}{{ row.credits_change }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="balance_after" label="余额" width="70" />
|
||||||
|
<el-table-column prop="source" label="来源" width="120" />
|
||||||
|
<el-table-column prop="description" label="说明" />
|
||||||
|
</el-table>
|
||||||
|
<el-pagination
|
||||||
|
v-if="historyTotal > historySize"
|
||||||
|
v-model:current-page="historyPage"
|
||||||
|
:page-size="historySize"
|
||||||
|
:total="historyTotal"
|
||||||
|
layout="prev, pager, next"
|
||||||
|
style="margin-top:16px;justify-content:center"
|
||||||
|
@current-change="loadHistory"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
|
||||||
|
<el-dialog v-model="showPayDialog" title="选择支付方式" width="360px">
|
||||||
|
<p style="margin-bottom:12px">购买 <strong>{{ selectedPkg?.name }}</strong> ({{ selectedPkg?.credits }}次)</p>
|
||||||
|
<p style="font-size:20px;font-weight:bold;color:#e6a23c;margin-bottom:16px">¥{{ selectedPkg?.price }}</p>
|
||||||
|
<el-radio-group v-model="payType" style="display:flex;gap:16px;justify-content:center;margin-bottom:16px">
|
||||||
|
<el-radio-button value="alipay">支付宝</el-radio-button>
|
||||||
|
<el-radio-button value="wechat">微信支付</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="showPayDialog = false">取消</el-button>
|
||||||
|
<el-button type="primary" @click="confirmPurchase" :loading="paying">确认支付</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="showQrDialog" title="扫码支付" width="320px">
|
||||||
|
<div style="text-align:center">
|
||||||
|
<img v-if="qrCodeUrl" :src="qrCodeUrl" style="width:240px;height:240px" />
|
||||||
|
<p v-else>加载中...</p>
|
||||||
|
<p style="color:#999;margin-top:12px">请使用{{ payType === 'alipay' ? '支付宝' : '微信' }}扫码支付</p>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import {
|
||||||
|
getCreditBalance, getCreditHistory, getCreditPackages, getSubscriptionPlans,
|
||||||
|
purchaseCreditPackage, subscribeCreditPlan, cancelCreditSubscription,
|
||||||
|
} from '@/api'
|
||||||
|
|
||||||
|
const activeTab = ref('packages')
|
||||||
|
const balance = ref(null)
|
||||||
|
const packages = ref([])
|
||||||
|
const subscriptionPlans = ref([])
|
||||||
|
const history = ref([])
|
||||||
|
const historyLoading = ref(false)
|
||||||
|
const historyPage = ref(1)
|
||||||
|
const historySize = ref(10)
|
||||||
|
const historyTotal = ref(0)
|
||||||
|
|
||||||
|
const showPayDialog = ref(false)
|
||||||
|
const selectedPkg = ref(null)
|
||||||
|
const payType = ref('alipay')
|
||||||
|
const paying = ref(false)
|
||||||
|
const showQrDialog = ref(false)
|
||||||
|
const qrCodeUrl = ref('')
|
||||||
|
|
||||||
|
async function loadBalance() {
|
||||||
|
try { balance.value = await getCreditBalance() } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
async function loadPackages() {
|
||||||
|
try { packages.value = await getCreditPackages() } catch (e) { ElMessage.error('加载失败') }
|
||||||
|
}
|
||||||
|
async function loadPlans() {
|
||||||
|
try { subscriptionPlans.value = await getSubscriptionPlans() } catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
async function loadHistory(page) {
|
||||||
|
if (page) historyPage.value = page
|
||||||
|
historyLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getCreditHistory({ page: historyPage.value, size: historySize.value })
|
||||||
|
history.value = res.items
|
||||||
|
historyTotal.value = res.total
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
historyLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function buyPackage(pkg) {
|
||||||
|
selectedPkg.value = pkg
|
||||||
|
showPayDialog.value = true
|
||||||
|
}
|
||||||
|
async function confirmPurchase() {
|
||||||
|
if (!selectedPkg.value) return
|
||||||
|
paying.value = true
|
||||||
|
try {
|
||||||
|
const res = await purchaseCreditPackage(selectedPkg.value.id, payType.value)
|
||||||
|
if (res.code_url) {
|
||||||
|
qrCodeUrl.value = res.code_url
|
||||||
|
showPayDialog.value = false
|
||||||
|
showQrDialog.value = true
|
||||||
|
} else if (res.pay_url) {
|
||||||
|
window.open(res.pay_url, '_blank')
|
||||||
|
showPayDialog.value = false
|
||||||
|
} else {
|
||||||
|
ElMessage.success('购买成功')
|
||||||
|
showPayDialog.value = false
|
||||||
|
await loadBalance()
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e?.detail || '支付失败')
|
||||||
|
}
|
||||||
|
paying.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function subscribePlan(plan) {
|
||||||
|
try {
|
||||||
|
await ElMessage.success('订阅功能开发中,即将开放')
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
async function cancelSubscription() {
|
||||||
|
try {
|
||||||
|
await cancelCreditSubscription()
|
||||||
|
ElMessage.success('已取消自动续费')
|
||||||
|
await loadBalance()
|
||||||
|
} catch (e) { ElMessage.error(e?.detail || '取消失败') }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadBalance()
|
||||||
|
loadPackages()
|
||||||
|
loadPlans()
|
||||||
|
loadHistory(1)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.credits-page { max-width: 960px; margin: 0 auto; }
|
||||||
|
.balance-card { margin-bottom: 24px; }
|
||||||
|
.balance-header { display: flex; align-items: center; gap: 24px; }
|
||||||
|
.balance-label { font-size: 14px; color: #999; }
|
||||||
|
.balance-amount { font-size: 36px; font-weight: 700; color: #e6a23c; }
|
||||||
|
.balance-amount small { font-size: 16px; }
|
||||||
|
.balance-sub { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: #999; }
|
||||||
|
.subscription-info { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #666; }
|
||||||
|
.package-grid, .plan-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }
|
||||||
|
.package-card, .plan-card {
|
||||||
|
background: #fff; border: 1px solid #e8e8e8; border-radius: 12px;
|
||||||
|
padding: 24px; text-align: center; cursor: pointer; transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.package-card:hover, .plan-card:hover { border-color: #1890ff; box-shadow: 0 4px 12px rgba(24,144,255,0.1); transform: translateY(-2px); }
|
||||||
|
.pkg-name, .plan-name { font-size: 18px; font-weight: 600; }
|
||||||
|
.pkg-name-en, .plan-name-en { font-size: 12px; color: #999; margin-bottom: 8px; }
|
||||||
|
.pkg-credits, .plan-credits { font-size: 32px; font-weight: 700; color: #1890ff; }
|
||||||
|
.pkg-credits small, .plan-credits small { font-size: 14px; }
|
||||||
|
.pkg-price { margin: 8px 0; }
|
||||||
|
.pkg-price .current { font-size: 22px; font-weight: 700; color: #e6a23c; }
|
||||||
|
.pkg-price .original { font-size: 14px; color: #999; text-decoration: line-through; margin-left: 8px; }
|
||||||
|
.plan-price { font-size: 24px; font-weight: 700; color: #e6a23c; margin: 8px 0; }
|
||||||
|
.plan-price small { font-size: 14px; }
|
||||||
|
.pkg-unit, .plan-unit { font-size: 12px; color: #999; margin-bottom: 12px; }
|
||||||
|
.pkg-btn, .plan-btn { width: 100%; }
|
||||||
|
</style>
|
||||||
@@ -11,8 +11,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<el-divider style="margin:8px 0" />
|
<el-divider style="margin:8px 0" />
|
||||||
<div class="profile-menu">
|
<div class="profile-menu">
|
||||||
<div class="menu-item" @click="$router.push('/upgrade')">
|
<div class="menu-item" @click="$router.push('/credits')">
|
||||||
<el-icon><Crown /></el-icon><span>升级会员</span>
|
<el-icon><Coin /></el-icon><span>购买次数</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="menu-item" @click="$router.push('/certification')">
|
<div class="menu-item" @click="$router.push('/certification')">
|
||||||
<el-icon><Stamp /></el-icon><span>实名认证</span>
|
<el-icon><Stamp /></el-icon><span>实名认证</span>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -20,10 +20,24 @@
|
|||||||
style="margin-bottom:16px"
|
style="margin-bottom:16px"
|
||||||
>
|
>
|
||||||
<template #default>
|
<template #default>
|
||||||
<span>试用结束后将自动恢复为免费版。 <el-button text type="primary" size="small" @click="showUpgrade = true">立即升级正式版</el-button></span>
|
<span>试用结束后将自动恢复为免费版。 <el-button text type="primary" size="small" @click="$router.push('/credits')">购买更多次数</el-button></span>
|
||||||
</template>
|
</template>
|
||||||
</el-alert>
|
</el-alert>
|
||||||
|
|
||||||
|
<el-card shadow="never" style="margin-bottom:16px;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);color:#fff">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 0">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:14px;opacity:0.9">信用余额</div>
|
||||||
|
<div style="font-size:28px;font-weight:700">{{ creditBalance ?? '...' }} 次</div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:right">
|
||||||
|
<el-button type="primary" @click="$router.push('/discovery')" style="margin-bottom:4px">发现新客户</el-button>
|
||||||
|
<br>
|
||||||
|
<el-button text style="color:#fff;opacity:0.8" @click="$router.push('/credits')">购买次数 →</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
<el-row :gutter="20">
|
<el-row :gutter="20">
|
||||||
<el-col :xs="12" :sm="6" v-for="item in stats" :key="item.label">
|
<el-col :xs="12" :sm="6" v-for="item in stats" :key="item.label">
|
||||||
<el-card shadow="hover" class="stat-card" @click="item.route && $router.push(item.route)">
|
<el-card shadow="hover" class="stat-card" @click="item.route && $router.push(item.route)">
|
||||||
@@ -37,7 +51,7 @@
|
|||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="section-title">本月用量</span>
|
<span class="section-title">本月用量</span>
|
||||||
<el-button v-if="usageStats.tier !== 'enterprise'" text type="primary" size="small" @click="showUpgrade = true">升级以获取更多额度</el-button>
|
<el-button v-if="usageStats.tier !== 'enterprise'" text type="primary" size="small" @click="$router.push('/credits')">购买更多次数</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="usage-grid">
|
<div class="usage-grid">
|
||||||
@@ -129,17 +143,17 @@
|
|||||||
<el-table :data="planData" border>
|
<el-table :data="planData" border>
|
||||||
<el-table-column label="功能" prop="feature" width="140" />
|
<el-table-column label="功能" prop="feature" width="140" />
|
||||||
<el-table-column label="免费版" width="160">
|
<el-table-column label="免费版" width="160">
|
||||||
<template #default="{ row }"><span v-html="row.free" /></template>
|
<template #default="{ row }"><span>{{ row.free }}</span></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="Pro ¥99/月" width="160">
|
<el-table-column label="Pro ¥99/月" width="160">
|
||||||
<template #default="{ row }"><span v-html="row.pro" /></template>
|
<template #default="{ row }"><span>{{ row.pro }}</span></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="企业 ¥399/月" width="160">
|
<el-table-column label="企业 ¥399/月" width="160">
|
||||||
<template #default="{ row }"><span v-html="row.enterprise" /></template>
|
<template #default="{ row }"><span>{{ row.enterprise }}</span></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
<div style="text-align:center;margin-top:20px">
|
<div style="text-align:center;margin-top:20px">
|
||||||
<el-button type="primary" size="large" @click="$router.push('/upgrade')">立即升级</el-button>
|
<el-button type="primary" size="large" @click="$router.push('/credits')">购买次数</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,6 +162,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { getCreditBalance } from '@/api'
|
||||||
import { getAnalyticsOverview, translate, getFollowupPending, getSilentCustomers, getUsageStats } from '@/api'
|
import { getAnalyticsOverview, translate, getFollowupPending, getSilentCustomers, getUsageStats } from '@/api'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
@@ -160,6 +175,7 @@ const followups = ref([])
|
|||||||
const silentCustomers = ref([])
|
const silentCustomers = ref([])
|
||||||
const usageStats = ref({ tier: 'free', limits: {}, usage: {} })
|
const usageStats = ref({ tier: 'free', limits: {}, usage: {} })
|
||||||
const showUpgrade = ref(false)
|
const showUpgrade = ref(false)
|
||||||
|
const creditBalance = ref(null)
|
||||||
|
|
||||||
const usageItems = computed(() => {
|
const usageItems = computed(() => {
|
||||||
const u = usageStats.value.usage || {}
|
const u = usageStats.value.usage || {}
|
||||||
@@ -206,6 +222,7 @@ const features = [
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
|
getCreditBalance().then(res => { creditBalance.value = res.balance }).catch(() => {})
|
||||||
const [overview, fup, silent, usage] = await Promise.all([
|
const [overview, fup, silent, usage] = await Promise.all([
|
||||||
getAnalyticsOverview().catch(() => null),
|
getAnalyticsOverview().catch(() => null),
|
||||||
getFollowupPending().catch(() => []),
|
getFollowupPending().catch(() => []),
|
||||||
|
|||||||
Reference in New Issue
Block a user