Compare commits

..

10 Commits

Author SHA1 Message Date
TradeMate Dev 15d172e825 feat: 更新支付模块 (Stripe/PayPal/PingPong) 和 uni-app 配置 2026-06-16 13:32:50 +08:00
TradeMate Dev e5b1e7d588 fix: admin CreditManagement naming conflict (showAdjustDialog -> openAdjustDialog) 2026-06-12 11:26:30 +08:00
TradeMate Dev d8780a716b feat: user frontend i18n (zh-CN/en)
- vue-i18n@9 with locale files for zh-CN and en
- Language switcher in topbar
- Navigation, breadcrumb, credits page translated
- Discovery page i18n keys prepared
- Language persisted in localStorage
- Build verified
2026-06-12 11:21:19 +08:00
TradeMate Dev 79474d8480 feat: quotation credit deduction + production restart
- Quotation generate-from-inquiry deducts 2 credits
- Backend restarted and verified API endpoints work
- Credit seed data committed to database
- All credit APIs returning correct data
2026-06-12 11:07:08 +08:00
TradeMate Dev a95e8b2b73 feat: frontend credit system UI
Admin:
- New CreditManagement.vue (tabs: rates, packages, plans, user credits, consumptions, stats)
- Sidebar menu + router entry
- Full CRUD for credit packages and subscription plans
- User credit balance adjustment
- Consumption log viewer

User:
- Credits.vue replaces Upgrade.vue (package purchase, subscription, history tabs)
- Credit balance display in topbar + dashboard header CTA card
- Navigation restructured: discovery first
- Profile redirects to /credits
- Dashboard upgrade dialog simplified to redirect to /credits
2026-06-12 11:00:22 +08:00
TradeMate Dev 2a107a42f3 feat: credit-based billing system
- New DB models: credit_packages, subscription_plans, user_credits, credit_consumptions, credit_purchases
- CreditService: balance, deduct, add_credits, grant_free_trial, history
- User API: /api/v1/credits/* (balance/history/packages/purchase/subscribe)
- Admin API: /api/v1/admin/credit-* (CRUD packages/plans, user credits, consumptions)
- PaymentService.create_credit_order + handle_callback for credit purchases
- Credit deduction on: discovery, translate, marketing, ai_chat, followup
- Free trial 30 credits on registration
- Documentation: docs/CREDIT_SYSTEM.md
2026-06-12 10:39:45 +08:00
TradeMate Dev 5d895ae12c fix: standardize error response format
- Fix exchange.py: replace {'error':...} with HTTPException(detail=...)
- Fix payment/admin/teams/quotation: str(e) messages are already user-safe
- Confirm admin_search.py test endpoint uses correct probe pattern
- Confirm frontend has no raw alert() calls (already uses ElMessage)
2026-06-11 19:38:05 +08:00
TradeMate Dev 9e9c7ac270 fix: additional code quality and performance improvements
Code quality:
- Remove empty except blocks with proper logging
- Create shared pagination utility function
- Remove duplicate UUID validation code
- Fix dead code in translation.py

Performance:
- Fix N+1 query in followup engine (use join instead of loop)
- Add eager loading for customer health scores
- Create database indexes for common query patterns:
  - customers: (user_id, status), (user_id, last_contact_at)
  - payment_transactions: (user_id, created_at)
  - followup_logs: (user_id, customer_id)
  - notifications: (user_id, is_read)

Configuration:
- Centralize magic numbers in config.py:
  - Payment prices
  - File upload limits
  - Rate limiting settings
  - Pagination defaults
- Update auth.py to use centralized rate limiting config
- Update customer/product imports to use centralized upload limits
- Update import_service.py to use centralized MAX_ROWS
2026-06-11 18:25:08 +08:00
TradeMate Dev 13e3992d4c fix: security and code quality improvements
Security fixes:
- Add file upload size limits (10MB) for customer and product imports
- Add XLSX file validation with row limits and magic byte checking
- Implement password validation (min 6 chars) in registration
- Add rate limiting for guest login (5 per IP per 15 minutes)
- Sanitize error messages to prevent information leakage
- Fix XSS vulnerability by removing unsafe v-html usage
- Enforce WhatsApp webhook signature verification
- Add SSRF protection with URL validation and IP blocking
- Fix marketing endpoints to use proper authentication

Code quality improvements:
- Create shared utility functions for UUID validation and string sanitization
- Remove duplicate UUID validation code from admin modules
- Remove dead code (pass statement in translation.py)
- Fix aliyun SDK import compatibility
2026-06-11 17:54:07 +08:00
TradeMate Dev d2736d1ef6 feat: AI routing DB-driven, payment gateway full integration, WeChat mini-program CI/CD
- AI routing rules now stored in system_configs DB table instead of hardcoded config
- Multi-model support via name|model composite key for same-provider routing
- UnifiedPayService with HMAC-SHA256 gateway integration (alipay/wechat)
- Admin payment panel: list, stats, search, filter, refund
- WeChat mini-program CI/CD via miniprogram-ci (v1.0.9)
- Translation quota extended to LLM provider tier
- SearchService with DB-driven provider config (bing/google_cse/searxng)
- Footer cleanup across admin/workspace/uni-app
- Private key excluded from git tracking
2026-06-09 17:19:45 +08:00
82 changed files with 16538 additions and 384 deletions
+3
View File
@@ -55,3 +55,6 @@ docker-compose.override.yml
# Generated by MCP search server
backend/app/services/_bing_search.js
# WeChat mini-program private key
uni-app/private.key
+3 -2
View File
@@ -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.
- **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`.
- **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)`.
- **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.
- **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.
- **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
+48
View File
@@ -18,6 +18,7 @@
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"playwright": "^1.60.0",
"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": {
"version": "8.5.15",
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz",
+5 -4
View File
@@ -9,16 +9,17 @@
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"element-plus": "^2.9.1",
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.9",
"dayjs": "^1.11.13",
"element-plus": "^2.9.1",
"pinia": "^2.3.0",
"dayjs": "^1.11.13"
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"playwright": "^1.60.0",
"vite": "^6.0.7"
}
}
+22
View File
@@ -58,4 +58,26 @@ export function processInvoice(id, action) {
export function aiChat(message, history = []) { return http.post('/ai/chat', { message, history }) }
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
+16 -39
View File
@@ -26,6 +26,14 @@
<el-icon><Document /></el-icon>
<span>日志</span>
</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-icon><Setting /></el-icon>
<span>配置</span>
@@ -93,37 +101,13 @@
<el-footer class="footer">
<div class="footer-content">
<div class="footer-section">
<div class="footer-brand">TradeMate</div>
<p class="footer-tagline">AI 外贸小助手 · 让外贸更简单</p>
<div class="qrcode-row">
<div class="qrcode-item">
<img src="/images/yzr/yuzhiran.jpg" alt="微信公众号" class="qrcode-img" />
<span>微信公众号</span>
</div>
<div class="qrcode-item">
<img src="/images/yzr/yuzhiran-tech.jpg" alt="微信服务号" class="qrcode-img" />
<span>微信服务号</span>
</div>
<div class="qrcode-item">
<img src="/images/yzr/yuzhiran-yhl.jpg" alt="小程序" class="qrcode-img" />
<span>小程序</span>
</div>
<div class="qrcode-item">
<img src="/images/yzr/kefu.png" alt="客服" class="qrcode-img" />
<span>微信客服</span>
</div>
</div>
</div>
<div class="footer-bottom">
<p>&copy; {{ new Date().getFullYear() }} TradeMate 外贸小助手. 保留所有权利.</p>
<div class="footer-links">
<a href="http://beian.miit.gov.cn/" target="_blank">{{ beianInfo.icp }}</a>
<a v-if="beianInfo.showGongan" :href="beianInfo.gonganLink" target="_blank" rel="noreferrer" class="gongan-link">
<img src="/images/beian/gongan-beian.png" alt="公安备案" class="gongan-icon" />
{{ beianInfo.gongan }}
</a>
</div>
<p>&copy; {{ new Date().getFullYear() }} TradeMate</p>
<div class="footer-links">
<a href="http://beian.miit.gov.cn/" target="_blank">{{ beianInfo.icp }}</a>
<a v-if="beianInfo.showGongan" :href="beianInfo.gonganLink" target="_blank" rel="noreferrer" class="gongan-link">
<img src="/images/beian/gongan-beian.png" alt="公安备案" class="gongan-icon" />
{{ beianInfo.gongan }}
</a>
</div>
</div>
</el-footer>
@@ -172,14 +156,7 @@ const beianInfo = computed(() => {
.user-name { font-size: 14px; color: #333; }
.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-content { padding: 20px 24px 16px; }
.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-content { padding: 8px 24px; display: flex; justify-content: center; align-items: center; gap: 16px; flex-wrap: wrap; }
.footer-links { display: flex; gap: 16px; align-items: center; }
.footer-links a { color: #999; text-decoration: none; }
.footer-links a:hover { color: #1890ff; }
+16
View File
@@ -36,6 +36,22 @@ const routes = [
{ 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',
component: AdminLayout,
+4 -4
View File
@@ -12,14 +12,14 @@
<div class="cfg-group-title">{{ fieldLabelsMap.ai_routing?.[taskKey] || taskKey }}</div>
<div class="cfg-field">
<span class="cfg-label">主选</span>
<el-select v-model="edits[cfg.key][taskKey].primary" size="small" style="width:300px" filterable>
<el-option v-for="p in providers" :key="p.provider_type" :value="p.provider_type" :label="p.name + ' — ' + p.model_name" />
<el-select v-model="edits[cfg.key][taskKey].primary" size="small" style="width:380px" filterable>
<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>
</div>
<div class="cfg-field">
<span class="cfg-label">备用</span>
<el-select v-model="edits[cfg.key][taskKey].fallback" size="small" style="width:400px" 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-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.name + '|' + p.model_name" :value="p.name + '|' + p.model_name" :label="p.name + ' — ' + p.model_name" />
</el-select>
</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>
+180
View File
@@ -0,0 +1,180 @@
<template>
<div>
<el-row :gutter="16" style="margin-bottom:20px">
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-item">
<div class="stat-label">总收入</div>
<div class="stat-value">¥{{ (stats.total_revenue || 0).toFixed(2) }}</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-item">
<div class="stat-label">已支付</div>
<div class="stat-value" style="color:#52c41a">{{ stats.paid_count || 0 }}</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-item">
<div class="stat-label">待支付</div>
<div class="stat-value" style="color:#faad14">{{ stats.pending_count || 0 }}</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<div class="stat-item">
<div class="stat-label">已退款</div>
<div class="stat-value" style="color:#ff4d4f">{{ stats.refunded_count || 0 }}</div>
</div>
</el-card>
</el-col>
</el-row>
<el-card>
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center">
<span>交易记录</span>
<div style="display:flex;gap:8px">
<el-select v-model="filters.status" placeholder="状态" clearable style="width:120px" @change="loadData">
<el-option label="全部" value="" />
<el-option label="待支付" value="pending" />
<el-option label="已支付" value="paid" />
<el-option label="已退款" value="refunded" />
<el-option label="失败" value="failed" />
</el-select>
<el-select v-model="filters.pay_type" placeholder="支付方式" clearable style="width:120px" @change="loadData">
<el-option label="全部" value="" />
<el-option label="支付宝" value="alipay" />
<el-option label="微信" value="wechat" />
</el-select>
<el-input v-model="filters.user_id" placeholder="用户ID" clearable style="width:180px" @clear="loadData" @keyup.enter="loadData" />
<el-button type="primary" @click="loadData">搜索</el-button>
</div>
</div>
</template>
<el-table :data="list" v-loading="loading" stripe style="width:100%">
<el-table-column prop="order_no" label="订单号" width="200" />
<el-table-column prop="user_id" label="用户" min-width="180" />
<el-table-column prop="plan" label="套餐" width="100" />
<el-table-column label="金额" width="100">
<template #default="{ row }">¥{{ (row.amount || 0).toFixed(2) }}</template>
</el-table-column>
<el-table-column label="支付方式" width="80">
<template #default="{ row }">{{ row.pay_type === 'wechat' ? '微信' : '支付宝' }}</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag v-if="row.status === 'paid'" type="success" size="small">已支付</el-tag>
<el-tag v-else-if="row.status === 'pending'" type="warning" size="small">待支付</el-tag>
<el-tag v-else-if="row.status === 'refunded'" type="danger" size="small">已退款</el-tag>
<el-tag v-else type="info" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="时间" width="170">
<template #default="{ row }">{{ row.paid_at || row.created_at }}</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status === 'paid'" type="danger" size="small" @click="handleRefund(row)">退款</el-button>
</template>
</el-table-column>
</el-table>
<div style="margin-top:16px;display:flex;justify-content:flex-end">
<el-pagination
v-model:current-page="page"
v-model:page-size="size"
:total="total"
layout="total, prev, pager, next"
@current-change="loadData"
/>
</div>
</el-card>
<el-dialog v-model="refundDialog.visible" title="确认退款" width="400px">
<p>订单号{{ refundDialog.order_no }}</p>
<p>退款金额¥{{ (refundDialog.amount || 0).toFixed(2) }}</p>
<el-input v-model="refundDialog.reason" type="textarea" placeholder="退款原因(可选)" :rows="3" />
<template #footer>
<el-button @click="refundDialog.visible = false">取消</el-button>
<el-button type="danger" :loading="refundDialog.loading" @click="confirmRefund">确认退款</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { listPayments, getPaymentStats, adminRefund } from '@/api'
import { ElMessage, ElMessageBox } from 'element-plus'
const list = ref([])
const total = ref(0)
const page = ref(1)
const size = ref(20)
const loading = ref(false)
const stats = ref({})
const filters = reactive({ status: '', pay_type: '', user_id: '' })
const refundDialog = reactive({ visible: false, loading: false, order_no: '', amount: 0, reason: '' })
onMounted(() => {
loadStats()
loadData()
})
async function loadStats() {
try {
const res = await getPaymentStats()
stats.value = res.data || res
} catch { /* ignore */ }
}
async function loadData() {
loading.value = true
try {
const params = { page: page.value, size: size.value }
if (filters.status) params.status = filters.status
if (filters.pay_type) params.pay_type = filters.pay_type
if (filters.user_id) params.user_id = filters.user_id
const res = await listPayments(params)
const d = res.data || res
list.value = d.items || []
total.value = d.total || 0
} catch { /* ignore */ }
finally { loading.value = false }
}
function handleRefund(row) {
refundDialog.order_no = row.order_no
refundDialog.amount = row.amount
refundDialog.reason = ''
refundDialog.visible = true
}
async function confirmRefund() {
refundDialog.loading = true
try {
await adminRefund(refundDialog.order_no, refundDialog.reason)
ElMessage.success('退款成功')
refundDialog.visible = false
loadData()
loadStats()
} catch (e) {
ElMessage.error(e?.detail || '退款失败')
} finally {
refundDialog.loading = false
}
}
</script>
<style scoped>
.stat-item { text-align: center; padding: 8px 0; }
.stat-label { font-size: 13px; color: #999; margin-bottom: 4px; }
.stat-value { font-size: 24px; font-weight: 700; color: #1890ff; }
</style>
@@ -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 -1
View File
@@ -1,6 +1,6 @@
from typing import Dict, Any, Optional
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 app.services.translation_quota import TranslationQuotaService
from app.database import AsyncSessionLocal
+9 -10
View File
@@ -8,12 +8,12 @@ import logging
logger = logging.getLogger(__name__)
DEFAULT_ROUTING: Dict[str, dict] = {
"translate": {"primary": "sensenova", "fallback": ["alibaba-mt", "nvidia"]},
"reply": {"primary": "sensenova", "fallback": ["nvidia"]},
"marketing": {"primary": "sensenova", "fallback": ["nvidia"]},
"extract": {"primary": "sensenova", "fallback": ["nvidia"]},
"quotation": {"primary": "sensenova", "fallback": ["nvidia"]},
"chat": {"primary": "sensenova", "fallback": ["nvidia"]},
"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"]},
}
@@ -36,10 +36,9 @@ class AIRouter:
for p in rows:
inst = self._build_provider(p)
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.provider_type] = inst
new_providers[f"{p.name}|{p.model_name}"] = inst
new_providers[str(p.id)] = inst
if new_providers:
self.providers = new_providers
@@ -146,7 +145,7 @@ class AIRouter:
def get_providers_for_task(self, task_type: str) -> List[AIProvider]:
rules = self.routing_rules.get(
task_type,
{"primary": "sensenova", "fallback": ["nvidia"]},
{"primary": "Sensenova (商汤)|deepseek-v4-flash", "fallback": ["NVIDIA|stepfun-ai/step-3.7-flash"]},
)
ordered = []
seen = set()
+33 -5
View File
@@ -41,11 +41,11 @@ async def list_users(
return await service.list_users(page, size, role)
from app.core.utils import validate_uuid
def _validate_uuid(user_id: str):
try:
uuid.UUID(user_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid user ID format")
validate_uuid(user_id)
@router.patch("/users/{target_user_id}/tier")
@@ -283,12 +283,13 @@ async def admin_list_payments(
size: int = Query(20, ge=1, le=100),
gateway: str = Query(default=""),
status: str = Query(default=""),
pay_type: str = Query(default=""),
user_id: str = Query(default=""),
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_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")
@@ -313,3 +314,30 @@ async def admin_refund(
return await svc.admin_refund(order_no, reason)
except ValueError as 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))
+308
View File
@@ -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()
+4 -5
View File
@@ -181,9 +181,8 @@ async def test_provider(
return {"success": False, "error": str(e)}
from app.core.utils import validate_uuid
def _validate_uuid(uuid_str: str):
import uuid
try:
uuid.UUID(uuid_str)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid UUID")
validate_uuid(uuid_str)
+9
View File
@@ -9,6 +9,7 @@ from app.ai.local_faq import match_faq
from app.api.v1.deps import get_current_user_id
from app.models.system_config import SystemConfig
from app.services.admin import AdminService
from app.services.credit import CreditService
import logging
import time
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"
)
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()
ai = get_ai_router()
result = await ai.chat(data.message, data.history or [], system_prompt)
+45 -3
View File
@@ -8,7 +8,7 @@ from app.database import get_db
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.csrf import require_csrf_token
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel, EmailStr, field_validator
from datetime import datetime, timedelta
from app.services.admin import AdminService
from app.models.subscription import Subscription
@@ -40,6 +40,13 @@ class LoginRequest(BaseModel):
phone: 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):
refresh_token: str
@@ -74,6 +81,10 @@ async def register(
)
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:
try:
from app.api.v1.referral import do_claim_referral
@@ -146,6 +157,38 @@ async def login(
@router.post("/login/guest")
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())
access_token = create_access_token(
{"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})
client_ip = request.client.host if request.client else None
await AdminService(db).log_usage(guest_id, "user.login_guest", {}, ip=client_ip)
await AdminService(db).log_usage(guest_id, "user.login_guest", {})
return LoginResponse(
access_token=access_token,
+178
View File
@@ -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": "已取消自动续费,当前订阅到期后不再续费"}
+18 -4
View File
@@ -136,6 +136,11 @@ async def delete_customer(
return {"message": "Customer deleted"}
from app.config import settings
MAX_UPLOAD_SIZE = settings.MAX_UPLOAD_SIZE
@router.post("/import")
async def import_customers(
file: UploadFile = File(...),
@@ -144,8 +149,17 @@ async def import_customers(
):
from app.workers.tasks import process_customer_import
content = await file.read()
filename = file.filename or ""
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
if filename.endswith(".xlsx"):
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")
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)
all_errors = parse_errors + validation_errors
@@ -167,7 +181,7 @@ async def import_customers(
await svc.create_customer(user_id, record)
imported_count += 1
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 {
"imported": imported_count,
+114 -12
View File
@@ -1,7 +1,14 @@
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from typing import Optional, Dict, Any
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.credit import CreditService
from app.api.v1.deps import get_current_user_id
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -16,46 +23,141 @@ class AnalyzeRequest(BaseModel):
product_description: str
class MarketIntelRequest(BaseModel):
product_description: str
target_market: str = "US"
class OutreachRequest(BaseModel):
company: 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")
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():
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:
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:
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")
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():
raise HTTPException(status_code=400, detail="请填写公司网址")
if not req.product_description.strip():
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()
try:
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:
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")
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"):
raise HTTPException(status_code=400, detail="请填写公司名称")
if not req.product.get("name"):
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()
try:
result = await svc.outreach(req.company, req.product)
return {"success": True, "data": result}
result = await svc.generate_outreach(req.company, req.product)
return {"success": True, "data": result, "credits_remaining": balance - 3}
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="生成失败,请稍后重试")
+2 -2
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter
from fastapi import APIRouter, HTTPException
from app.services.exchange import ExchangeRateService
from datetime import datetime
@@ -14,7 +14,7 @@ async def convert_currency(
):
rate = await service.get_rate(from_currency, to_currency)
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 {
"from_currency": from_currency.upper(),
+9
View File
@@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional
from app.database import get_db
from app.services.followup_engine import FollowupEngine
from app.services.credit import CreditService
from app.api.v1.deps import get_current_user_id
router = APIRouter()
@@ -84,6 +85,14 @@ async def trigger_followup_scan(
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, "followup_scan")
if not ok:
raise HTTPException(
status_code=402,
detail=f"次数不足 (剩余 {balance:.1f}, 需要 2)"
)
engine = FollowupEngine(db)
result = await engine.scan_and_followup()
return result
+36 -8
View File
@@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.services.marketing import MarketingService
from app.services.preference import UserPreferenceService
from app.services.credit import CreditService
from app.core.security import decode_token
from app.api.v1.deps import get_current_user_id
from app.config import settings
@@ -45,6 +46,14 @@ async def generate_marketing(
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()
pref_service = UserPreferenceService(db)
pref_context = await pref_service.get_preference_context(user_id, "marketing")
@@ -63,13 +72,23 @@ async def generate_marketing(
"product": data.product_name,
"target": data.target,
"count": len(results),
"credits_remaining": balance - 5,
}
@router.post("/keywords")
async def generate_keywords(data: KeywordsRequest, authorization: str = Header(None)):
if not authorization:
raise HTTPException(status_code=401, detail="Missing token")
async def generate_keywords(
data: KeywordsRequest,
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()
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)
return {"keywords": keywords, "product": data.product_name}
return {"keywords": keywords, "product": data.product_name, "credits_remaining": balance - 5}
@router.post("/competitor-analysis")
async def competitor_analysis(data: CompetitorRequest, authorization: str = Header(None)):
if not authorization:
raise HTTPException(status_code=401, detail="Missing token")
async def competitor_analysis(
data: CompetitorRequest,
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()
product_info = {
@@ -95,4 +123,4 @@ async def competitor_analysis(data: CompetitorRequest, authorization: str = Head
}
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}
+175 -8
View File
@@ -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 import select
from pydantic import BaseModel
from typing import Optional
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.core.csrf import require_csrf_token
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -40,7 +44,6 @@ async def create_order(
data: CreateOrderRequest,
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
_csrf: str = Depends(require_csrf_token),
):
svc = PaymentService(db)
try:
@@ -78,7 +81,6 @@ async def refund(
data: RefundRequest,
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
_csrf: str = Depends(require_csrf_token),
):
svc = PaymentService(db)
try:
@@ -87,10 +89,43 @@ async def refund(
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")
async def unified_webhook(request: Request, db: AsyncSession = Depends(get_db)):
body = await request.body()
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
try:
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", "")
transaction_id = pay_data.get("transaction_id", "")
amount = pay_data.get("amount", 0)
success = event == "recharge.completed"
success = event in ("recharge.completed", "order.refunded")
svc = PaymentService(db)
await svc.handle_callback(
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"}
@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"}
+19 -2
View File
@@ -100,9 +100,26 @@ async def import_products(
user_id: str = Depends(get_current_user_id),
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)
content = await file.read()
filename = file.filename.lower()
filename_lower = filename.lower()
if filename.endswith(".xlsx"):
if not HAS_OPENPYXL:
+9
View File
@@ -6,6 +6,7 @@ from app.database import get_db
from app.services.quotation import QuotationService
from app.services.pdf_generator import pdf_generator
from app.services import export
from app.services.credit import CreditService
from app.api.v1.deps import get_current_user_id
from app.models.quotation import Quotation
from app.models.customer import Customer
@@ -35,6 +36,14 @@ async def generate_from_inquiry(
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, "quotation")
if not ok:
raise HTTPException(
status_code=402,
detail=f"次数不足 (剩余 {balance:.1f}, 需要 2)"
)
service = QuotationService(db)
result = await service.generate_from_inquiry(
user_id=user_id,
+27
View File
@@ -6,6 +6,7 @@ from app.database import get_db
from app.services.translation import TranslationService
from app.services.tts import tts_service
from app.services.preference import UserPreferenceService
from app.services.credit import CreditService
from app.core.security import decode_token
from app.api.v1.deps import get_current_user_id
@@ -35,6 +36,7 @@ class ExtractRequest(BaseModel):
async def translate_text(
data: TranslateRequest,
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
service = TranslationService()
result = await service.translate(
@@ -44,6 +46,13 @@ async def translate_text(
context=data.context,
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
@@ -54,6 +63,15 @@ async def generate_reply(
db: AsyncSession = Depends(get_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")
service = TranslationService()
@@ -71,7 +89,16 @@ async def generate_reply(
async def extract_info(
data: ExtractRequest,
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()
result = await service.extract_info(data.text, data.extract_type)
return {"extracted": result, "type": data.extract_type}
+2
View File
@@ -38,6 +38,8 @@ async def handle_webhook(
if x_hub_signature_256:
if not svc.verify_signature(body, x_hub_signature_256):
raise HTTPException(status_code=403, detail="Invalid signature")
else:
raise HTTPException(status_code=403, detail="Missing signature")
import json
body_json = json.loads(body)
+39
View File
@@ -84,5 +84,44 @@ class Settings(BaseSettings):
PRO_MAX_PRODUCTS: int = 20
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()
+2 -1
View File
@@ -23,8 +23,9 @@ CSRF_PROTECTED_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
# Endpoints that should skip CSRF protection (e.g., webhook endpoints)
CSRF_SKIP_ENDPOINTS = [
"/api/v1/webhook/",
"/api/v1/payment/webhook",
"/api/v1/payment/",
"/api/v1/whatsapp/webhook",
"/api/v1/ai/",
]
+60
View File
@@ -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
View File
@@ -129,7 +129,7 @@ async def health():
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(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(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_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"])
+8
View File
@@ -19,6 +19,10 @@ from .search_provider import SearchProvider
from .discovery_record import DiscoveryRecord
from .ai_provider import AIProvider
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__ = [
"User", "Product",
@@ -37,4 +41,8 @@ __all__ = [
"DiscoveryRecord",
"AIProvider",
"PaymentTransaction",
"CreditPackage", "SubscriptionPlan",
"UserCredit",
"CreditConsumption",
"CreditPurchase",
]
+20
View File
@@ -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)
+37
View File
@@ -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)
+22
View File
@@ -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)
+26
View File
@@ -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
View File
@@ -7,6 +7,7 @@ from app.models.analytics import UsageLog
from app.models.customer import Customer
from app.models.quotation import Quotation
from app.models.system_config import SystemConfig
from app.models.search_provider import SearchProvider
from datetime import datetime, timedelta
import logging
@@ -289,13 +290,13 @@ class AdminService:
async def _seed_default_configs(self):
defaults = [
SystemConfig(key="ai_routing", value={
"translate": {"primary": "sensenova", "fallback": ["alibaba-mt", "nvidia"]},
"reply": {"primary": "sensenova", "fallback": ["nvidia"]},
"marketing": {"primary": "sensenova", "fallback": ["nvidia"]},
"extract": {"primary": "sensenova", "fallback": ["nvidia"]},
"quotation": {"primary": "sensenova", "fallback": ["nvidia"]},
"chat": {"primary": "sensenova", "fallback": ["nvidia"]},
}, description="AI 路由规则:各任务的主选/备用供应商"),
"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 路由规则:各任务的主选/备用供应商(按模型名称)"),
SystemConfig(key="feature_guest_mode", value={"enabled": True}, description="游客模式开关"),
SystemConfig(key="feature_wechat_login", value={"enabled": False}, description="微信登录开关"),
SystemConfig(key="feature_registration", value={"enabled": True}, description="新用户注册开关"),
@@ -334,21 +335,13 @@ class AdminService:
result = await self.db.execute(
select(SystemConfig).where(SystemConfig.key == "ai_routing")
)
if not result.scalar_one_or_none():
self.db.add(SystemConfig(
key="ai_routing",
value={
"translate": {"primary": "sensenova", "fallback": ["alibaba-mt", "nvidia"]},
"reply": {"primary": "sensenova", "fallback": ["nvidia"]},
"marketing": {"primary": "sensenova", "fallback": ["nvidia"]},
"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")
existing = result.scalar_one_or_none()
if not existing:
await self._seed_ai_routing()
else:
await self._migrate_routing_names(existing)
await self._seed_search_providers()
result = await self.db.execute(
select(SystemConfig).order_by(SystemConfig.key)
@@ -364,6 +357,99 @@ class AdminService:
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]]:
result = await self.db.execute(
select(SystemConfig).where(SystemConfig.key == key)
+256
View File
@@ -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,
}
+9 -1
View File
@@ -63,10 +63,18 @@ class CustomerHealthService:
return await self._compute_full_health(user_id, customer)
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(
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()
# Batch process customers instead of individual queries
results = []
for c in customers:
health = await self._compute_full_health(user_id, c)
+73 -2
View File
@@ -1,6 +1,7 @@
import json
import logging
from typing import Dict, Any, Optional, Union
from sqlalchemy.ext.asyncio import AsyncSession
from app.ai.router import get_ai_router
from app.services.search_web import search_companies, fetch_page_text
@@ -29,10 +30,11 @@ ANALYZE_MATCH_PROMPT = """你是外贸客户分析专家。分析目标公司的
class DiscoveryService:
def __init__(self):
def __init__(self, db: Optional[AsyncSession] = None):
ai_router = get_ai_router()
self.ai = ai_router
self._ai_available = len(ai_router.providers) > 0
self.db = db
async def search(self, product_description: str, target_market: str) -> Dict[str, Any]:
queries = self._build_queries(product_description, target_market)
@@ -90,6 +92,61 @@ URL: {company_url}
logger.warning(f"Analysis AI parse failed: {e}")
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]:
if not self._ai_available:
return self._template_outreach(company_info, product_info)
@@ -124,6 +181,18 @@ URL: {company_url}
return self._template_outreach(company_info, product_info)
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:
results = await search_bing_batch(queries[:3], max_per_query=4)
if results:
@@ -131,6 +200,7 @@ URL: {company_url}
except Exception as e:
logger.warning(f"Bing batch search failed: {e}")
# Fallback: Google CSE from env vars
results = await search_companies(queries[0], max_results=10)
if results:
return {"results": results[:15], "provider": "google_cse"}
@@ -250,7 +320,8 @@ URL: {company_url}
try:
return json.loads(text[brace:end+1])
except json.JSONDecodeError:
pass
logger.debug(f"Failed to parse JSON from text: {text[:100]}")
return None
return None
def _suggest_companies(self, product: str, market: str) -> list:
+14 -5
View File
@@ -287,11 +287,20 @@ class FollowupEngine:
total = len(count_result.scalars().all())
items = []
for log in logs:
customer_result = await self.db.execute(
select(Customer).where(Customer.id == log.customer_id)
)
customer = customer_result.scalar_one_or_none()
# Use join to avoid N+1 query problem
query = select(FollowupLog, Customer).join(
Customer, FollowupLog.customer_id == Customer.id, isouter=True
).where(
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({
"id": str(log.id),
"customer_id": str(log.customer_id),
+13 -1
View File
@@ -21,19 +21,31 @@ OPTIONAL_COLUMNS = {
}
from app.config import settings
class ImportService:
MAX_ROWS = settings.MAX_EXCEL_ROWS
@staticmethod
def parse_xlsx(file_bytes: bytes) -> Tuple[List[Dict[str, Any]], List[str]]:
if not HAS_OPENPYXL:
return [], ["openpyxl not installed"]
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
rows = list(ws.iter_rows(values_only=True))
if not rows:
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]]
missing = REQUIRED_COLUMNS - set(headers)
if missing:
@@ -84,11 +84,13 @@ class MCPClientManager:
try:
await self._session.__aexit__(None, None, None)
except (BaseExceptionGroup, RuntimeError, Exception):
# Cleanup failed, ignore error
pass
if self._ctx:
try:
await self._ctx.__aexit__(None, None, None)
except (BaseExceptionGroup, RuntimeError, Exception):
# Cleanup failed, ignore error
pass
+154 -23
View File
@@ -1,3 +1,4 @@
import json
import logging
import hashlib
from typing import Optional, Dict, Any, List
@@ -34,14 +35,29 @@ GATEWAY_MAP: Dict[str, PaymentGateway] = {}
def init_gateways():
if settings.PAY_API_KEY:
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:
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:
raise ValueError("支付网关未配置,请设置 PAY_API_KEY")
raise ValueError("支付网关未配置,请设置 PAY_API_KEY / STRIPE_SECRET_KEY / PAYPAL_CLIENT_ID / PINGPONG 配置")
if not gw.supports(pay_type):
raise ValueError(f"支付方式 {pay_type} 不被支持(仅支持 alipay/wechat")
raise ValueError(f"支付方式 {pay_type} 不被支持")
return gw
@@ -109,9 +125,11 @@ class PaymentService:
order_no = gen_order_no(user_id)
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_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(
user_id=user_id, plan=plan, status="pending",
@@ -140,6 +158,39 @@ class PaymentService:
**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,
gateway_order_no: str, success: bool,
amount: float = 0, notify_raw: str = "") -> bool:
@@ -159,30 +210,52 @@ class PaymentService:
txn.paid_at = datetime.utcnow()
txn.notify_raw = notify_raw
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"])
if txn.plan == "credit_purchase":
from app.services.credit import CreditService
credit_svc = CreditService(self.db)
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
if txn.description:
try:
meta = json.loads(txn.description)
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:
txn.status = "failed"
txn.notify_raw = notify_raw
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 = "failed"
if txn.plan != "credit_purchase":
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 = "failed"
await self.db.flush()
return True
@@ -208,6 +281,45 @@ class PaymentService:
"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,
page: int = 1, size: int = 20) -> Dict[str, Any]:
query = select(PaymentTransaction).where(
@@ -277,7 +389,8 @@ class PaymentService:
async def admin_list_payments(self, page: int = 1, size: int = 20,
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))
count_query = select(PaymentTransaction.id)
if gateway:
@@ -289,6 +402,9 @@ class PaymentService:
if user_id:
query = 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 = len(total_result.scalars().all())
@@ -348,6 +464,21 @@ class PaymentService:
return {"status": "ok", "order_no": order_no, "refund_amount": txn.amount,
"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]:
all_txns = await self.db.execute(select(PaymentTransaction))
rows = all_txns.scalars().all()
+3
View File
@@ -30,6 +30,9 @@ class PaymentGateway(ABC):
def parse_callback(self, body: str, headers: dict) -> Dict[str, Any]:
...
async def close_order(self, order_no: str) -> Dict[str, Any]:
raise NotImplementedError
def supports(self, pay_type: str) -> bool:
return pay_type in self.supported_types
+275
View File
@@ -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"}
+188
View File
@@ -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"}
+34
View File
@@ -41,6 +41,13 @@ class SearchService:
return await searxng_search(provider.api_endpoint, query, limit)
elif pt == "bing":
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:
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
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
+25
View File
@@ -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]:
# 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:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
resp = await client.get(url, headers={"User-Agent": "Mozilla/5.0"})
+123
View File
@@ -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}")
-3
View File
@@ -50,9 +50,6 @@ class TranslationService:
preference_context: Optional[str] = None,
) -> List[Dict[str, Any]]:
similar = await self.corpus.find_similar(inquiry, "reply")
if similar and count > 1:
pass
results = []
tones = self._get_tones(tone, count)
+30
View File
@@ -64,6 +64,7 @@ class UnifiedPayService(PaymentGateway):
payment_method = "wechat"
elif payment_method == "pc":
payment_method = "alipay"
remark = kwargs.get("remark", "")
body = {
"merchant_order_id": order_no,
"amount": amount / 100,
@@ -71,6 +72,8 @@ class UnifiedPayService(PaymentGateway):
"subject": description or "TradeMate 会员充值",
"notify_url": self.webhook_url,
}
if remark:
body["remark"] = remark
result = await self._request("POST", "/v1/pay/orders", body)
out = {
"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}")
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
def parse_callback(self, body: str, headers: dict) -> Dict[str, Any]:
@@ -115,3 +142,6 @@ class UnifiedPayService(PaymentGateway):
"success": event == "recharge.completed",
"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")
+9
View File
@@ -68,6 +68,8 @@ aliyunsdkalimt_request_v20181012.TranslateECommerceRequest = TranslateECommerceR
# Mock AcsClient
aliyunsdkcore = types.ModuleType('aliyunsdkcore')
aliyunsdkcore_client = types.ModuleType('aliyunsdkcore.client')
aliyunsdkcore_auth = types.ModuleType('aliyunsdkcore.auth')
aliyunsdkcore_auth_credentials = types.ModuleType('aliyunsdkcore.auth.credentials')
class AcsClient:
def __init__(self, *args, **kwargs):
@@ -76,9 +78,16 @@ class AcsClient:
def do_action(self, request):
return b'{"TranslateResult": "mock translation"}'
class AccessKeyCredential:
def __init__(self, *args, **kwargs):
pass
aliyunsdkcore_client.AcsClient = AcsClient
aliyunsdkcore_auth_credentials.AccessKeyCredential = AccessKeyCredential
sys.modules['aliyunsdkcore'] = aliyunsdkcore
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.database import Base, get_db
+1 -1
View File
@@ -23,7 +23,7 @@ class TestAuthAPI:
data = response.json()
assert data["phone"] == "13900139001"
assert data["username"] == "newuser"
assert data["tier"] == "free"
assert data["tier"] == "pro"
async def test_register_duplicate_phone(self, client: AsyncClient, test_user):
response = await client.post(
+399
View File
@@ -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(扣减后余额)
- sourcepackage/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 成本 |
+11601 -69
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -6,13 +6,16 @@
"dev:mp-weixin": "uni -p mp-weixin",
"build:mp-weixin": "uni build -p mp-weixin",
"dev:h5": "uni",
"build:h5": "uni build"
"build:h5": "uni build",
"upload": "node scripts/upload.js",
"preview": "node scripts/upload.js preview"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-4010520240507001",
"@dcloudio/uni-components": "3.0.0-4010520240507001",
"@dcloudio/uni-h5": "3.0.0-4010520240507001",
"@dcloudio/uni-mp-weixin": "3.0.0-4010520240507001",
"miniprogram-ci": "^2.1.31",
"vue": "3.4.21"
},
"devDependencies": {
+59
View File
@@ -0,0 +1,59 @@
const ci = require('miniprogram-ci')
const path = require('path')
const APPID = process.env.WX_APPID || 'wxdad62baf4ccd09e3'
const KEY_PATH = process.env.WX_PRIVATE_KEY_PATH || path.resolve(__dirname, '../private.key')
const VERSION = process.env.WX_VERSION || '1.0.6'
// miniprogram-ci 2.x 已知 bug:上传完成后进程残留 setTimeout 无法自动退出
// 设置强制退出计时器,防止进程卡死
const FORCE_EXIT_MS = 600_000
let forceExitTimer = setTimeout(() => {
console.error('Upload timed out, forcing exit')
process.exit(1)
}, FORCE_EXIT_MS)
async function main() {
const project = new ci.Project({
appid: APPID,
type: 'miniProgram',
projectPath: path.resolve(__dirname, '../dist/build/mp-weixin/'),
privateKeyPath: KEY_PATH,
ignores: ['node_modules/**/*'],
})
const action = process.argv[2] || 'upload'
if (action === 'preview') {
const qrcodeDest = path.resolve(__dirname, '../dist/qrcode.jpg')
await ci.preview({
project,
version: VERSION,
desc: process.env.WX_DESC || '自动预览',
setting: { minify: true, es6: true, autoPrefixWXSS: true },
qrcodeFormat: 'image',
qrcodeOutputDest: qrcodeDest,
onProgressUpdate: console.log,
})
console.log('Preview QR:', qrcodeDest)
} else {
console.log(`Uploading v${VERSION} ...`)
const result = await ci.upload({
project,
version: VERSION,
desc: process.env.WX_DESC || '自动构建上传',
setting: { minify: true, es6: true, autoPrefixWXSS: true },
onProgressUpdate: console.log,
})
console.log('Upload done:', JSON.stringify(result))
}
}
main().then(() => {
clearTimeout(forceExitTimer)
process.exit(0)
}).catch(e => {
console.error('Upload failed:', e.message)
clearTimeout(forceExitTimer)
process.exit(1)
})
+4
View File
@@ -6,8 +6,12 @@
</script>
<style>
/* #ifdef H5 */
* { margin: 0; padding: 0; box-sizing: border-box; }
/* #endif */
html, body, #app { height: 100%; width: 100%; }
/* #ifdef H5 */
uni-page { overflow-y: auto !important; }
uni-page-body { overflow-y: auto !important; min-height: 100% !important; }
/* #endif */
</style>
+1
View File
@@ -18,6 +18,7 @@ export const PAGES = {
LOGIN: '/pages/login/login',
PRODUCT: '/pages/product/product',
UPGRADE: '/pages/upgrade/upgrade',
CREDITS: '/pages/credits/credits',
FEEDBACK: '/pages/feedback/feedback',
FOLLOWUP: '/pages/followup/followup',
NOTIFICATION: '/pages/notification/notification',
+1 -1
View File
@@ -25,7 +25,7 @@
},
"quickapp": {},
"mp-weixin": {
"appid": "",
"appid": "wxdad62baf4ccd09e3",
"setting": {
"urlCheck": false,
"es6": true,
+6
View File
@@ -91,6 +91,12 @@
"navigationBarTitleText": "升级会员"
}
},
{
"path": "pages/credits/credits",
"style": {
"navigationBarTitleText": "购买次数"
}
},
{
"path": "pages/followup/followup",
"style": {
+279
View File
@@ -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>
+2 -43
View File
@@ -277,63 +277,22 @@
</view>
<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">
<text class="footer-link" @click="goToPage(PAGES.AGREEMENT_PRIVACY)">隐私政策</text>
<text class="footer-divider">|</text>
<text class="footer-link" @click="goToPage(PAGES.AGREEMENT_TERMS)">用户协议</text>
</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>
<AiAssistant />
</view>
</template>
<script setup>
import { ref, computed, onUnmounted } from 'vue'
import { ref, onUnmounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { authApi, customerApi, analyticsApi, onboardingApi, notificationApi, followupApi, translateApi, BASE_URL } from '@/utils/api.js'
import AiAssistant from '@/components/ai-assistant.vue'
import { STORAGE_KEYS, PAGES, EXTERNAL_URLS, APP_INFO, 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())
import { STORAGE_KEYS, PAGES, EXTRACT_FIELD_LABELS } from '@/config.js'
const showAnnouncement = ref(false)
const currentAnnouncement = ref(0)
+90 -16
View File
@@ -9,6 +9,16 @@
</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-title">账号设置</view>
<view class="menu-item" v-if="user.tier !== 'guest'" @click="showProfileEdit = true">
@@ -21,9 +31,9 @@
<text class="menu-text">修改密码</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="goUpgrade">
<view class="menu-item" @click="goCredits">
<text class="menu-icon"></text>
<text class="menu-text">会员升级</text>
<text class="menu-text">购买次数</text>
<text class="menu-arrow"></text>
</view>
</view>
@@ -49,21 +59,31 @@
<text class="menu-text">意见反馈</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="goAgreement('privacy')">
<text class="menu-icon">📄</text>
<text class="menu-text">隐私政策</text>
<text class="menu-arrow"></text>
</view>
<view class="section">
<view class="section-title">关于我们</view>
<view class="about-item">
<text class="about-label">版本</text>
<text class="about-value">1.0.0</text>
</view>
<view class="menu-item" @click="goAgreement('terms')">
<text class="menu-icon">📋</text>
<text class="menu-text">用户协议</text>
<text class="menu-arrow"></text>
<view class="about-item" @click="goAgreement('privacy')">
<text class="about-label">隐私政策</text>
<text class="about-arrow"></text>
</view>
<view class="menu-item">
<text class="menu-icon"></text>
<text class="menu-text">版本</text>
<text class="menu-value">1.0.0</text>
<view class="about-item" @click="goAgreement('terms')">
<text class="about-label">用户协议</text>
<text class="about-arrow"></text>
</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 class="logout-btn" v-if="user.tier !== 'guest'" @click="logout">退出登录</view>
@@ -118,11 +138,12 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
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 { STORAGE_KEYS, PAGES, TIER_LABELS } from '@/config.js'
const user = ref({})
const creditBalance = ref(0)
const showProfileEdit = ref(false)
const showPassword = ref(false)
const editForm = ref({ username: '', email: '' })
@@ -148,6 +169,12 @@ const loadUser = async () => {
} catch {
user.value = { tier: 'guest' }
}
try {
const cb = await creditApi.balance()
creditBalance.value = cb.balance || 0
} catch {
creditBalance.value = 0
}
}
const saveProfile = async () => {
@@ -185,7 +212,7 @@ const changePwd = async () => {
}
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 goAgreement = (type) => uni.navigateTo({ url: `/pages/agreement/${type}` })
const goCertification = () => uni.navigateTo({ url: PAGES.CERTIFICATION })
@@ -270,6 +297,21 @@ onShow(loadUser)
.tier-badge.enterprise { background: #e3f2fd; color: #1565c0; }
.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 {
background: #fff;
border-radius: 16rpx;
@@ -412,4 +454,36 @@ onShow(loadUser)
background: #1890ff;
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>
+21 -1
View File
@@ -1,6 +1,12 @@
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 token = uni.getStorageSync(STORAGE_KEYS.TOKEN)
@@ -253,6 +259,20 @@ export const paymentApi = {
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 = {
submit: (content, category = 'general', contact = '') =>
request('/feedback', 'POST', { content, category, contact }),
+115 -2
View File
@@ -1,11 +1,11 @@
{
"name": "trademate-admin",
"name": "trademate-user",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trademate-admin",
"name": "trademate-user",
"version": "1.0.0",
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
@@ -14,10 +14,12 @@
"element-plus": "^2.9.1",
"pinia": "^2.3.0",
"vue": "^3.5.13",
"vue-i18n": "^9.14.4",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"playwright": "^1.60.0",
"vite": "^6.0.7"
}
},
@@ -552,6 +554,50 @@
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"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": {
"version": "1.5.5",
"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": {
"version": "8.5.15",
"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": {
"version": "4.6.4",
"resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz",
+6 -4
View File
@@ -9,16 +9,18 @@
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"element-plus": "^2.9.1",
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.9",
"dayjs": "^1.11.13",
"element-plus": "^2.9.1",
"pinia": "^2.3.0",
"dayjs": "^1.11.13"
"vue": "^3.5.13",
"vue-i18n": "^9.14.4",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"playwright": "^1.60.0",
"vite": "^6.0.7"
}
}
+13 -1
View File
@@ -102,7 +102,7 @@ export function markAllRead() { return http.post('/notifications/read-all') }
export function getPlans() { return http.get('/payment/plans') }
export function 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 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 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
+22
View File
@@ -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
+57 -56
View File
@@ -16,16 +16,16 @@
:collapse-transition="false"
@select="showMobileMenu = false"
>
<el-menu-item index="/workspace"><el-icon><Odometer /></el-icon><span>工作台</span></el-menu-item>
<el-menu-item index="/translate"><el-icon><ChatLineSquare /></el-icon><span>智能翻译</span></el-menu-item>
<el-menu-item index="/customers"><el-icon><User /></el-icon><span>客户管理</span></el-menu-item>
<el-menu-item index="/products"><el-icon><Goods /></el-icon><span>产品库</span></el-menu-item>
<el-menu-item index="/quotations"><el-icon><DocumentCopy /></el-icon><span>报价单</span></el-menu-item>
<el-menu-item index="/marketing"><el-icon><Promotion /></el-icon><span>营销素材</span></el-menu-item>
<el-menu-item index="/discovery"><el-icon><Search /></el-icon><span>挖掘新客</span></el-menu-item>
<el-menu-item index="/followup"><el-icon><Message /></el-icon><span>智能跟进</span></el-menu-item>
<el-menu-item index="/analytics"><el-icon><DataAnalysis /></el-icon><span>数据分析</span></el-menu-item>
<el-menu-item index="/team"><el-icon><UserFilled /></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="/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>{{ $t('nav.customers') }}</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>{{ $t('nav.quotations') }}</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="/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>{{ $t('nav.followup') }}</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>{{ $t('nav.team') }}</span></el-menu-item>
</el-menu>
</aside>
@@ -35,10 +35,15 @@
<el-icon :size="20"><Expand /></el-icon>
</el-button>
<el-breadcrumb separator="/" class="breadcrumb">
<el-breadcrumb-item :to="'/workspace'">工作台</el-breadcrumb-item>
<el-breadcrumb-item v-if="route.meta?.title" :to="route.path">{{ route.meta.title }}</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">{{ $t('nav.' + route.name?.toLowerCase()) || route.meta.title }}</el-breadcrumb-item>
</el-breadcrumb>
<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-button text style="font-size:18px" @click="$router.push('/notifications')">
<el-icon><Bell /></el-icon>
@@ -52,9 +57,9 @@
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="$router.push('/profile')">个人中心</el-dropdown-item>
<el-dropdown-item @click="$router.push('/notifications')">通知中心</el-dropdown-item>
<el-dropdown-item divided @click="handleLogout">退出登录</el-dropdown-item>
<el-dropdown-item @click="$router.push('/profile')">{{ $t('nav.profile') }}</el-dropdown-item>
<el-dropdown-item @click="$router.push('/notifications')">{{ $t('nav.notifications') }}</el-dropdown-item>
<el-dropdown-item divided @click="handleLogout">{{ $t('common.confirm') }}退出</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
@@ -67,37 +72,13 @@
<footer class="footer">
<div class="footer-content">
<div class="footer-section">
<div class="footer-brand">TradeMate</div>
<p class="footer-tagline">AI 外贸小助手 · 让外贸更简单</p>
<div class="qrcode-row">
<div class="qrcode-item">
<img src="/images/yzr/yuzhiran.jpg" alt="微信公众号" class="qrcode-img" />
<span>微信公众号</span>
</div>
<div class="qrcode-item">
<img src="/images/yzr/yuzhiran-tech.jpg" alt="微信服务号" class="qrcode-img" />
<span>微信服务号</span>
</div>
<div class="qrcode-item">
<img src="/images/yzr/yuzhiran-yhl.jpg" alt="小程序" class="qrcode-img" />
<span>小程序</span>
</div>
<div class="qrcode-item">
<img src="/images/yzr/kefu.png" alt="客服" class="qrcode-img" />
<span>微信客服</span>
</div>
</div>
</div>
<div class="footer-bottom">
<p>&copy; {{ new Date().getFullYear() }} TradeMate 外贸小助手. 保留所有权利.</p>
<div class="footer-links">
<a href="http://beian.miit.gov.cn/" target="_blank">{{ beianInfo.icp }}</a>
<a v-if="beianInfo.showGongan" :href="beianInfo.gonganLink" target="_blank" rel="noreferrer" class="gongan-link">
<img src="/images/beian/gongan-beian.png" alt="公安备案" class="gongan-icon" />
{{ beianInfo.gongan }}
</a>
</div>
<p>&copy; {{ new Date().getFullYear() }} TradeMate</p>
<div class="footer-links">
<a href="http://beian.miit.gov.cn/" target="_blank">{{ beianInfo.icp }}</a>
<a v-if="beianInfo.showGongan" :href="beianInfo.gonganLink" target="_blank" rel="noreferrer" class="gongan-link">
<img src="/images/beian/gongan-beian.png" alt="公安备案" class="gongan-icon" />
{{ beianInfo.gongan }}
</a>
</div>
</div>
</footer>
@@ -109,16 +90,41 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { getUnreadCount } from '@/api'
import { getUnreadCount, getCreditBalance } from '@/api'
import AiAssistant from '@/components/AiAssistant.vue'
import { switchLang } from '@/i18n'
const route = useRoute()
const router = useRouter()
const { locale } = useI18n()
const auth = useAuthStore()
const collapsed = ref(false)
const showMobileMenu = ref(false)
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 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; }
.notif-badge :deep(.el-badge__content) { top: 8px; right: 4px; }
.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-content { padding: 16px 24px 12px; }
.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 { text-align: center; color: #999; font-size: 12px; border-top: 1px solid #e8e8e8; background: #fff; flex-shrink: 0; padding: 8px 24px; }
.footer-content { display: flex; justify-content: center; align-items: center; gap: 14px; flex-wrap: wrap; }
.footer-links { display: flex; gap: 14px; align-items: center; }
.footer-links a { color: #999; text-decoration: none; }
.footer-links a:hover { color: #1890ff; }
.gongan-link { display: inline-flex; align-items: center; gap: 4px; }
.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) {
.sidebar { position: fixed; left: -220px; top: 0; bottom: 0; z-index: 1000; transition: left 0.3s; }
+79
View File
@@ -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"
}
}
+79
View File
@@ -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": "中文"
}
}
+2
View File
@@ -6,10 +6,12 @@ import zhCn from 'element-plus/es/locale/lang/zh-cn'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
import i18n from './i18n'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(i18n)
app.use(ElementPlus, { locale: zhCn })
for (const [k, v] of Object.entries(ElementPlusIconsVue)) app.component(k, v)
app.mount('#app')
+3 -3
View File
@@ -56,7 +56,7 @@ const routes = [
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
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'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Upgrade', component: () => import('@/views/Upgrade.vue'), meta: { title: '升级会员' } },
{ path: '', name: 'Credits', component: () => import('@/views/Credits.vue'), meta: { title: '购买次数' } },
]
},
{
+227
View File
@@ -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>
+2 -2
View File
@@ -11,8 +11,8 @@
</div>
<el-divider style="margin:8px 0" />
<div class="profile-menu">
<div class="menu-item" @click="$router.push('/upgrade')">
<el-icon><Crown /></el-icon><span>升级会员</span>
<div class="menu-item" @click="$router.push('/credits')">
<el-icon><Coin /></el-icon><span>购买次数</span>
</div>
<div class="menu-item" @click="$router.push('/certification')">
<el-icon><Stamp /></el-icon><span>实名认证</span>
+76 -9
View File
@@ -23,17 +23,51 @@
<div style="text-align:center;margin-top:16px">
<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 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>
</el-card>
</el-col>
</el-row>
<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>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, reactive, onMounted } from 'vue'
import { getPlans, getSubscription, createOrder } from '@/api'
import { ElMessage } from 'element-plus'
@@ -41,6 +75,16 @@ const plans = ref([])
const currentPlan = ref(null)
const loadingId = ref(null)
const payDialog = reactive({
visible: false,
planId: null,
payType: 'alipay',
loading: false,
orderCreated: false,
payUrl: '',
codeUrl: '',
})
onMounted(async () => {
try {
const [plansRes, subRes] = await Promise.all([getPlans(), getSubscription().catch(() => null)])
@@ -53,14 +97,37 @@ onMounted(async () => {
} catch { /* ignore */ }
})
async function upgrade(planId) {
loadingId.value = planId
function showPayDialog(planId) {
payDialog.planId = planId
payDialog.payType = 'alipay'
payDialog.orderCreated = false
payDialog.payUrl = ''
payDialog.codeUrl = ''
payDialog.visible = true
}
async function handleUpgrade() {
payDialog.loading = true
try {
const res = await createOrder(planId, 'native')
ElMessage.success('订单已创建' + (res.pay_url ? ',正在跳转支付...' : ''))
if (res.pay_url) window.open(res.pay_url)
} catch (e) { ElMessage.error(e?.detail || '升级失败') }
finally { loadingId.value = null }
const res = await createOrder(payDialog.planId, payDialog.payType)
payDialog.orderCreated = true
if (res.code_url) {
payDialog.codeUrl = res.code_url
} 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>
+23 -6
View File
@@ -20,10 +20,24 @@
style="margin-bottom:16px"
>
<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>
</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-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)">
@@ -37,7 +51,7 @@
<template #header>
<div class="card-header">
<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>
</template>
<div class="usage-grid">
@@ -129,17 +143,17 @@
<el-table :data="planData" border>
<el-table-column label="功能" prop="feature" width="140" />
<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 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 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>
<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>
</el-dialog>
</div>
@@ -148,6 +162,7 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { getCreditBalance } from '@/api'
import { getAnalyticsOverview, translate, getFollowupPending, getSilentCustomers, getUsageStats } from '@/api'
const auth = useAuthStore()
@@ -160,6 +175,7 @@ const followups = ref([])
const silentCustomers = ref([])
const usageStats = ref({ tier: 'free', limits: {}, usage: {} })
const showUpgrade = ref(false)
const creditBalance = ref(null)
const usageItems = computed(() => {
const u = usageStats.value.usage || {}
@@ -206,6 +222,7 @@ const features = [
onMounted(async () => {
try {
getCreditBalance().then(res => { creditBalance.value = res.balance }).catch(() => {})
const [overview, fup, silent, usage] = await Promise.all([
getAnalyticsOverview().catch(() => null),
getFollowupPending().catch(() => []),