refactor: replace direct WeChat/Alipay with unified pay-api gateway

Switch from direct WeChat Pay / Alipay integrations to the unified
宇之然 pay-api gateway (HMAC-SHA256 auth). Removes wechat_pay.py,
keeps PaymentGateway abstraction, adds UnifiedPayService. Simplifies
payment.py create_order to {plan, pay_type} params. Single webhook
endpoint replaces separate WeChat/Alipay notify handlers.
This commit is contained in:
TradeMate Dev
2026-05-29 18:36:50 +08:00
parent 5d2bced39f
commit 3e39cf0170
34 changed files with 973 additions and 424 deletions
+2
View File
@@ -78,6 +78,8 @@ alembic revision --autogenerate -m "desc"
- **CustomerHealthService**: `get_health_overview` endpoint must use `CustomerHealthService(db)` not `CustomerService(db)`. - **CustomerHealthService**: `get_health_overview` endpoint must use `CustomerHealthService(db)` not `CustomerService(db)`.
- **CSRF**: Sensitive endpoints (auth/payment/profile) require `X-CSRF-Token` header. Token available via `csrf_token` cookie / `X-CSRF-Token` response header. - **CSRF**: Sensitive endpoints (auth/payment/profile) require `X-CSRF-Token` header. Token available via `csrf_token` cookie / `X-CSRF-Token` response header.
- **AI Router reload**: After modifying AI providers via admin API, call `POST /api/v1/admin/ai/reload` to refresh in-memory providers. - **AI Router reload**: After modifying AI providers via admin API, call `POST /api/v1/admin/ai/reload` to refresh in-memory providers.
- **Payment**: Uses unified `pay-api` gateway (`UnifiedPayService`). NOT direct WeChat/Alipay integration. Credentials: `PAY_API_KEY`/`PAY_API_SECRET` from `.env`. HMAC-SHA256 auth. Webhook at `POST /api/v1/payment/webhook` skips CSRF.
- **Payment gateway config**: `PAY_API_KEY`, `PAY_API_SECRET`, `PAY_API_BASE_URL`, `PAY_WEBHOOK_URL` in `config.py`. `pay_type` param: `"alipay"` (returns `pay_url`) or `"wechat"` (returns `code_url`). `UnifiedPayService` normalizes legacy `native`/`jsapi`/`pc` to `wechat`/`alipay`.
## Project Conventions ## Project Conventions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+60 -3
View File
@@ -92,14 +92,47 @@
</el-main> </el-main>
<el-footer class="footer"> <el-footer class="footer">
<span>TradeMate 外贸小助手 &copy; {{ new Date().getFullYear() }}</span> <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>
</div>
</div>
</el-footer> </el-footer>
</el-container> </el-container>
</el-container> </el-container>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
@@ -107,6 +140,17 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const collapsed = ref(false) const collapsed = ref(false)
const beianInfo = computed(() => {
const hostname = window.location.hostname
if (hostname === 'yuzhiran.com' || hostname === 'www.yuzhiran.com') {
return { icp: '京ICP备2026007249号-1', gongan: '京公网安备11011502039545号', gonganLink: 'https://beian.mps.gov.cn/#/query/webSearch?code=11011502039545', showGongan: true }
}
if (hostname === 'yuzhiran.com.cn' || hostname === 'www.yuzhiran.com.cn') {
return { icp: '京ICP备2026007249号-2', gongan: '京公网安备11011502039622号', gonganLink: 'https://beian.mps.gov.cn/#/query/webSearch?code=11011502039622', showGongan: true }
}
return { icp: '京ICP备2026007249号-1', gongan: '京公网安备11011502039545号', gonganLink: 'https://beian.mps.gov.cn/#/query/webSearch?code=11011502039545', showGongan: true }
})
</script> </script>
<style scoped> <style scoped>
@@ -125,5 +169,18 @@ const collapsed = ref(false)
.user-info { display: flex; align-items: center; gap: 8px; cursor: pointer; } .user-info { display: flex; align-items: center; gap: 8px; cursor: pointer; }
.user-name { font-size: 14px; color: #333; } .user-name { font-size: 14px; color: #333; }
.main-content { background: #f5f5f5; padding: 20px; overflow-y: auto; } .main-content { background: #f5f5f5; padding: 20px; overflow-y: auto; }
.footer { display: flex; align-items: center; justify-content: center; height: 48px; background: #fff; border-top: 1px solid #e8e8e8; color: #999; font-size: 12px; } .footer { padding: 0; background: #fff; border-top: 1px solid #e8e8e8; color: #666; font-size: 12px; }
.footer-content { padding: 20px 24px 16px; }
.footer-section { margin-bottom: 16px; }
.footer-brand { font-size: 15px; font-weight: 700; color: #1890ff; margin-bottom: 4px; }
.footer-tagline { color: #999; font-size: 12px; margin-bottom: 12px; }
.qrcode-row { display: flex; gap: 16px; flex-wrap: wrap; }
.qrcode-item { display: flex; flex-direction: column; align-items: center; gap: 4px; color: #999; font-size: 11px; }
.qrcode-img { width: 48px; height: 48px; border-radius: 6px; }
.footer-bottom { border-top: 1px solid #eee; padding-top: 12px; display: flex; justify-content: center; align-items: center; gap: 16px; flex-wrap: wrap; }
.footer-links { display: flex; gap: 16px; align-items: center; }
.footer-links 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: 16px; vertical-align: middle; }
</style> </style>
+5 -7
View File
@@ -57,13 +57,11 @@ WECHAT_APP_ID=
WECHAT_APP_SECRET= WECHAT_APP_SECRET=
WECHAT_PUSH_TEMPLATE_ID= WECHAT_PUSH_TEMPLATE_ID=
# 微信支付 # 统一支付网关(宇之然 pay-api,支持支付宝/微信)
WECHAT_PAY_MCH_ID= PAY_API_KEY=pay_98c86e0d2eba4379bfe722c8
WECHAT_PAY_API_KEY= PAY_API_SECRET=cc392f42daf94719b9b157f3e7ad6c9472ae20a33ba14323
WECHAT_PAY_SERIAL_NO= PAY_API_BASE_URL=https://www.yzrcloud.cn/api/gateway
WECHAT_PAY_CERT_DIR=./certs PAY_WEBHOOK_URL=https://your-domain.com/api/v1/payment/webhook
WECHAT_PAY_NOTIFY_URL=https://your-domain.com/api/v1/payment/notify
WECHAT_PAY_API_BASE=https://api.mch.weixin.qq.com
# 汇率 API(免费层即可) # 汇率 API(免费层即可)
EXCHANGE_RATE_API_KEY= EXCHANGE_RATE_API_KEY=
@@ -0,0 +1,43 @@
"""add payment_transactions table
Revision ID: add_payment_transactions
Revises: add_ai_providers_table
Create Date: 2026-05-29
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision = "add_payment_transactions"
down_revision = "add_ai_providers_table"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"payment_transactions",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", UUID(as_uuid=True), nullable=False, index=True),
sa.Column("order_no", sa.String(64), unique=True, nullable=False, index=True),
sa.Column("gateway_order_id", sa.String(128), nullable=True),
sa.Column("gateway_order_no", sa.String(128), nullable=True),
sa.Column("plan", sa.String(50), nullable=False),
sa.Column("amount", sa.Float, nullable=False),
sa.Column("currency", sa.String(10), default="CNY"),
sa.Column("gateway", sa.String(20), nullable=False),
sa.Column("pay_type", sa.String(20), nullable=False),
sa.Column("status", sa.String(20), default="pending"),
sa.Column("description", sa.Text, nullable=True),
sa.Column("refund_amount", sa.Float, default=0),
sa.Column("refund_reason", sa.Text, nullable=True),
sa.Column("paid_at", sa.DateTime, nullable=True),
sa.Column("refunded_at", sa.DateTime, nullable=True),
sa.Column("notify_raw", sa.Text, nullable=True),
sa.Column("created_at", sa.DateTime, default=sa.func.now()),
sa.Column("updated_at", sa.DateTime, default=sa.func.now()),
)
def downgrade():
op.drop_table("payment_transactions")
+39
View File
@@ -8,6 +8,7 @@ from app.services.admin import AdminService
from app.services.translation_quota import TranslationQuotaService from app.services.translation_quota import TranslationQuotaService
from app.services.certification import CertificationService from app.services.certification import CertificationService
from app.services.invoice import InvoiceService from app.services.invoice import InvoiceService
from app.services.payment import PaymentService
from app.api.v1.deps import get_current_user from app.api.v1.deps import get_current_user
router = APIRouter() router = APIRouter()
@@ -274,3 +275,41 @@ async def admin_process_invoice(
if not result: if not result:
raise HTTPException(status_code=404, detail="Invoice not found") raise HTTPException(status_code=404, detail="Invoice not found")
return result return result
@router.get("/payments")
async def admin_list_payments(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
gateway: str = Query(default=""),
status: 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)
@router.get("/payments/stats")
async def admin_payment_stats(
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
svc = PaymentService(db)
return await svc.admin_payment_stats()
@router.post("/payments/refund")
async def admin_refund(
data: dict,
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
order_no = data.get("order_no", "")
reason = data.get("reason", "")
svc = PaymentService(db)
try:
return await svc.admin_refund(order_no, reason)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
+54 -32
View File
@@ -1,10 +1,9 @@
from fastapi import APIRouter, Depends, HTTPException, Request, Header from fastapi import APIRouter, Depends, HTTPException, Request, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
from app.database import get_db from app.database import get_db
from app.services.payment import PaymentService from app.services.payment import PaymentService
from app.services.wechat_pay import WeChatPayService
from app.api.v1.deps import get_current_user_id from app.api.v1.deps import get_current_user_id
from app.core.csrf import require_csrf_token from app.core.csrf import require_csrf_token
@@ -13,12 +12,12 @@ router = APIRouter()
class CreateOrderRequest(BaseModel): class CreateOrderRequest(BaseModel):
plan: str plan: str
pay_type: str = "jsapi" pay_type: str = "alipay"
class PaymentCallbackRequest(BaseModel): class RefundRequest(BaseModel):
payment_id: str order_no: str
success: bool reason: str = ""
@router.get("/plans") @router.get("/plans")
@@ -50,42 +49,65 @@ async def create_order(
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
@router.post("/callback") @router.get("/query/{order_no}")
async def payment_callback( async def query_payment(
data: PaymentCallbackRequest, order_no: str,
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
svc = PaymentService(db)
try:
return await svc.query_payment(user_id, order_no)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.get("/transactions")
async def list_transactions(
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 = PaymentService(db)
return await svc.list_transactions(user_id, page, size)
@router.post("/refund")
async def refund(
data: RefundRequest,
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
_csrf: str = Depends(require_csrf_token), _csrf: str = Depends(require_csrf_token),
): ):
svc = PaymentService(db) svc = PaymentService(db)
success = await svc.handle_payment_callback(data.payment_id, data.success) try:
if not success: return await svc.refund(user_id, data.order_no, data.reason)
raise HTTPException(status_code=404, detail="Order not found") except ValueError as e:
return {"status": "ok"} raise HTTPException(status_code=400, detail=str(e))
@router.post("/notify") @router.post("/webhook")
async def wechat_pay_notify(request: Request, db: AsyncSession = Depends(get_db)): async def unified_webhook(request: Request, db: AsyncSession = Depends(get_db)):
body = await request.body() body = await request.body()
body_str = body.decode("utf-8") body_str = body.decode("utf-8")
headers = dict(request.headers)
wxpay = WeChatPayService()
if not wxpay.verify_callback(headers, body_str):
raise HTTPException(status_code=401, detail="签名验证失败")
import json import json
try:
data = json.loads(body_str) data = json.loads(body_str)
resource = data.get("resource", {}) except json.JSONDecodeError:
ciphertext = resource.get("ciphertext", "") raise HTTPException(status_code=400, detail="无效的 JSON")
nonce = resource.get("nonce", "")
associated_data = resource.get("associated_data", "")
plaintext = wxpay.decrypt_callback(ciphertext, nonce, associated_data) event = data.get("event", "")
pay_data = json.loads(plaintext) pay_data = data.get("data", {})
out_trade_no = pay_data.get("out_trade_no", "") merchant_order_id = pay_data.get("merchant_order_id", "")
trade_state = pay_data.get("trade_state", "") 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 = trade_state == "SUCCESS"
svc = PaymentService(db) svc = PaymentService(db)
await svc.handle_payment_callback(out_trade_no, success) await svc.handle_callback(
return {"code": "SUCCESS", "message": "OK"} merchant_order_id, order_id, transaction_id,
success, amount, body_str,
)
return {"code": 0, "message": "OK"}
+4 -6
View File
@@ -55,12 +55,10 @@ class Settings(BaseSettings):
WECHAT_APP_SECRET: Optional[str] = None WECHAT_APP_SECRET: Optional[str] = None
WECHAT_PUSH_TEMPLATE_ID: Optional[str] = None WECHAT_PUSH_TEMPLATE_ID: Optional[str] = None
WECHAT_PAY_MCH_ID: Optional[str] = None PAY_API_KEY: Optional[str] = None
WECHAT_PAY_API_KEY: Optional[str] = None PAY_API_SECRET: Optional[str] = None
WECHAT_PAY_SERIAL_NO: Optional[str] = None PAY_API_BASE_URL: str = "https://www.yzrcloud.cn/api/gateway"
WECHAT_PAY_CERT_DIR: str = "./certs" PAY_WEBHOOK_URL: str = "https://example.com/api/v1/payment/webhook"
WECHAT_PAY_NOTIFY_URL: str = "https://example.com/api/v1/payment/notify"
WECHAT_PAY_API_BASE: str = "https://api.mch.weixin.qq.com"
EXCHANGE_RATE_API_KEY: Optional[str] = None EXCHANGE_RATE_API_KEY: Optional[str] = None
+1 -1
View File
@@ -23,7 +23,7 @@ CSRF_PROTECTED_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
# Endpoints that should skip CSRF protection (e.g., webhook endpoints) # Endpoints that should skip CSRF protection (e.g., webhook endpoints)
CSRF_SKIP_ENDPOINTS = [ CSRF_SKIP_ENDPOINTS = [
"/api/v1/webhook/", "/api/v1/webhook/",
"/api/v1/payment/notify", "/api/v1/payment/webhook",
"/api/v1/whatsapp/webhook", "/api/v1/whatsapp/webhook",
] ]
+7 -10
View File
@@ -18,26 +18,23 @@ from .referral import ReferralCode, Referral
from .search_provider import SearchProvider from .search_provider import SearchProvider
from .discovery_record import DiscoveryRecord from .discovery_record import DiscoveryRecord
from .ai_provider import AIProvider from .ai_provider import AIProvider
from .payment_transaction import PaymentTransaction
__all__ = [ __all__ = [
"User", "Product", "User", "Product",
"Customer", "Conversation", "Message", "Customer", "Conversation", "Message",
"Quotation", "QuotationItem", "Quotation", "QuotationItem",
"CorpusEntry",
"Team", "TeamMember",
"UsageLog",
"Notification",
"Feedback",
"Subscription", "Subscription",
"CorpusEntry",
"Notification",
"Team", "TeamMember",
"Feedback",
"PreferenceAnalysis", "MarketingEffect", "PreferenceAnalysis", "MarketingEffect",
"Device",
"FollowupStrategy", "FollowupLog", "FollowupStrategy", "FollowupLog",
"SystemConfig", "Certification", "Invoice", "InvoiceType", "InvoiceStatus",
"TranslationQuota",
"Certification", "CertType", "CertStatus",
"Invoice", "InvoiceType", "InvoiceStatus",
"ReferralCode", "Referral", "ReferralCode", "Referral",
"SearchProvider", "SearchProvider",
"DiscoveryRecord", "DiscoveryRecord",
"AIProvider", "AIProvider",
"PaymentTransaction",
] ]
+29
View File
@@ -0,0 +1,29 @@
from sqlalchemy import Column, String, Integer, DateTime, Float, Text, Boolean
from sqlalchemy.dialects.postgresql import UUID
from datetime import datetime
from app.database import Base
import uuid
class PaymentTransaction(Base):
__tablename__ = "payment_transactions"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
order_no = Column(String(64), unique=True, nullable=False, index=True)
gateway_order_id = Column(String(128), nullable=True)
gateway_order_no = Column(String(128), nullable=True)
plan = Column(String(50), nullable=False)
amount = Column(Float, nullable=False)
currency = Column(String(10), default="CNY")
gateway = Column(String(20), nullable=False)
pay_type = Column(String(20), nullable=False)
status = Column(String(20), default="pending")
description = Column(Text, nullable=True)
refund_amount = Column(Float, default=0)
refund_reason = Column(Text, nullable=True)
paid_at = Column(DateTime, nullable=True)
refunded_at = Column(DateTime, nullable=True)
notify_raw = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+276 -163
View File
@@ -1,13 +1,15 @@
import logging import logging
import hashlib import hashlib
from typing import Optional, Dict, Any from typing import Optional, Dict, Any, List
from datetime import datetime, timedelta from datetime import datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select, desc
from app.models.subscription import Subscription from app.models.subscription import Subscription
from app.models.payment_transaction import PaymentTransaction
from app.models.user import User from app.models.user import User
from app.config import settings from app.config import settings
from app.services.wechat_pay import WeChatPayService from app.services.unified_pay import UnifiedPayService
from app.services.payment_gateway import PaymentGateway
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -26,92 +28,50 @@ PLAN_DESCRIPTIONS = {
"enterprise_yearly": "TradeMate 企业版会员(年付)", "enterprise_yearly": "TradeMate 企业版会员(年付)",
} }
GATEWAY_MAP: Dict[str, PaymentGateway] = {}
def init_gateways():
if settings.PAY_API_KEY:
GATEWAY_MAP["unified"] = UnifiedPayService()
def get_gateway(pay_type: str) -> PaymentGateway:
gw = GATEWAY_MAP.get("unified")
if not gw:
raise ValueError("支付网关未配置,请设置 PAY_API_KEY")
if not gw.supports(pay_type):
raise ValueError(f"支付方式 {pay_type} 不被支持(仅支持 alipay/wechat")
return gw
def gen_order_no(user_id: str) -> str:
ts = datetime.utcnow().strftime("%Y%m%d%H%M%S%f")[:18]
suffix = user_id[-8:] if len(user_id) >= 8 else user_id
return f"TM{ts}{suffix}"
class PaymentService: class PaymentService:
def __init__(self, db: AsyncSession): def __init__(self, db: AsyncSession):
self.db = db self.db = db
self._wxpay = None
@property
def wxpay(self) -> Optional[WeChatPayService]:
if self._wxpay is None and settings.WECHAT_PAY_MCH_ID:
self._wxpay = WeChatPayService()
return self._wxpay
async def get_plans(self) -> Dict[str, Any]: async def get_plans(self) -> Dict[str, Any]:
return { return {
"plans": [ "plans": [
{ {"id": "free", "name": "免费版", "price": 0, "period": "month",
"id": "free", "features": ["1 个产品", "20 次翻译/天", "5 个客户", "基础回复建议"]},
"name": "免费版", {"id": "pro", "name": "Pro 版", "price": 99, "period": "month",
"price": 0, "features": ["10 个产品", "无限翻译", "50 个客户", "跟进提醒", "报价单生成"]},
"period": "month", {"id": "pro_yearly", "name": "Pro 版(年付)", "price": 999, "period": "year",
"features": [
"1 个产品",
"20 次翻译/天",
"5 个客户",
"基础回复建议",
],
},
{
"id": "pro",
"name": "Pro 版",
"price": 99,
"period": "month",
"features": [
"10 个产品",
"无限翻译",
"50 个客户",
"跟进提醒",
"报价单生成",
],
},
{
"id": "pro_yearly",
"name": "Pro 版(年付)",
"price": 999,
"period": "year",
"original_price": 1188, "original_price": 1188,
"features": [ "features": ["10 个产品", "无限翻译", "50 个客户", "跟进提醒", "报价单生成", "省 ¥189"]},
"10 个产品", {"id": "enterprise", "name": "企业版", "price": 399, "period": "month",
"无限翻译", "features": ["无限产品/客户", "团队协作", "品牌报价单", "专属语料训练", "API 接入", "优先支持"]},
"50 个客户", {"id": "enterprise_yearly", "name": "企业版(年付)", "price": 3999, "period": "year",
"跟进提醒",
"报价单生成",
"省 ¥189",
],
},
{
"id": "enterprise",
"name": "企业版",
"price": 399,
"period": "month",
"features": [
"无限产品/客户",
"团队协作",
"品牌报价单",
"专属语料训练",
"API 接入",
"优先支持",
],
},
{
"id": "enterprise_yearly",
"name": "企业版(年付)",
"price": 3999,
"period": "year",
"original_price": 4788, "original_price": 4788,
"features": [ "features": ["无限产品/客户", "团队协作", "品牌报价单", "专属语料训练", "API 接入", "优先支持", "省 ¥789"]},
"无限产品/客户",
"团队协作",
"品牌报价单",
"专属语料训练",
"API 接入",
"优先支持",
"省 ¥789",
],
},
], ],
"gateways": list(GATEWAY_MAP.keys()) or ["unified"],
} }
async def get_current_subscription(self, user_id: str) -> Dict[str, Any]: async def get_current_subscription(self, user_id: str) -> Dict[str, Any]:
@@ -122,12 +82,8 @@ class PaymentService:
).order_by(Subscription.created_at.desc()).limit(1) ).order_by(Subscription.created_at.desc()).limit(1)
) )
sub = result.scalar_one_or_none() sub = result.scalar_one_or_none()
result = await self.db.execute(select(User).where(User.id == user_id))
result = await self.db.execute(
select(User).where(User.id == user_id)
)
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
return { return {
"plan": user.tier if user else "free", "plan": user.tier if user else "free",
"status": sub.status if sub else "active", "status": sub.status if sub else "active",
@@ -136,124 +92,281 @@ class PaymentService:
} }
async def create_order(self, user_id: str, plan: str, async def create_order(self, user_id: str, plan: str,
pay_type: str = "jsapi") -> Dict[str, Any]: pay_type: str = "alipay") -> Dict[str, Any]:
if plan not in PLANS: if plan not in PLANS:
raise ValueError(f"Invalid plan: {plan}") raise ValueError(f"无效套餐: {plan}")
plan_info = PLANS[plan] plan_info = PLANS[plan]
if plan_info["price"] == 0:
result = await self.db.execute(select(User).where(User.id == user_id)) result = await self.db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if user: if not user:
raise ValueError("用户不存在")
if plan_info["price"] == 0:
user.tier = plan user.tier = plan
await self.db.flush() await self.db.flush()
return {"status": "ok", "plan": plan, "amount": 0} return {"status": "ok", "plan": plan, "amount": 0}
result = await self.db.execute(select(User).where(User.id == user_id)) order_no = gen_order_no(user_id)
user = result.scalar_one_or_none()
if not user:
raise ValueError("User not found")
order_id = f"ORD{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{user_id[-6:]}"
description = PLAN_DESCRIPTIONS.get(plan, f"TradeMate {plan}") description = PLAN_DESCRIPTIONS.get(plan, f"TradeMate {plan}")
gw = get_gateway(pay_type)
gw_result = await gw.create_order(order_no, int(plan_info["price"] * 100),
description, pay_type=pay_type)
sub = Subscription( sub = Subscription(
user_id=user_id, user_id=user_id, plan=plan, status="pending",
plan=plan, amount=plan_info["price"], payment_id=order_no,
status="pending", payment_provider="unified",
amount=plan_info["price"],
payment_id=order_id,
) )
self.db.add(sub) self.db.add(sub)
txn = PaymentTransaction(
user_id=user_id, order_no=order_no, plan=plan,
amount=plan_info["price"], gateway="unified", pay_type=pay_type,
status="pending", description=description,
gateway_order_no=gw_result.get("gateway_order_id", ""),
)
self.db.add(txn)
await self.db.flush() await self.db.flush()
wxpay_available = self.wxpay is not None and settings.WECHAT_PAY_NOTIFY_URL not in (
"", "https://example.com/api/v1/payment/notify"
)
if wxpay_available:
try:
if pay_type == "jsapi":
openid = user.wechat_openid
if not openid:
raise ValueError("用户未绑定微信,请在微信小程序中登录后支付")
wx_result = await self.wxpay.create_jsapi_order(
order_id, openid, int(plan_info["price"] * 100), description
)
prepay_id = wx_result.get("prepay_id", "")
pay_params = self.wxpay.build_jsapi_pay_params(prepay_id)
return { return {
"status": "pending", "status": "pending",
"order_id": order_id, "order_id": order_no,
"plan": plan,
"amount": plan_info["price"],
"currency": "CNY",
"pay_type": "jsapi",
"pay_params": pay_params,
}
elif pay_type == "native":
wx_result = await self.wxpay.create_native_order(
order_id, int(plan_info["price"] * 100), description
)
code_url = wx_result.get("code_url", "")
return {
"status": "pending",
"order_id": order_id,
"plan": plan,
"amount": plan_info["price"],
"currency": "CNY",
"pay_type": "native",
"code_url": code_url,
}
except Exception as e:
logger.error(f"WeChat Pay order failed: {e}")
raise ValueError(f"支付创建失败: {str(e)}")
# 开发环境回退:生成模拟支付参数
pay_params = {
"appId": settings.WECHAT_APP_ID or "",
"timeStamp": str(int(datetime.utcnow().timestamp())),
"nonceStr": hashlib.md5(order_id.encode()).hexdigest()[:16],
"package": f"prepay_id={order_id}",
"signType": "MD5",
}
sign_str = "&".join(f"{k}={v}" for k, v in sorted(pay_params.items()))
sign_str += f"&key={settings.SECRET_KEY}"
pay_params["paySign"] = hashlib.md5(sign_str.encode()).hexdigest().upper()
return {
"status": "pending",
"order_id": order_id,
"plan": plan, "plan": plan,
"amount": plan_info["price"], "amount": plan_info["price"],
"currency": "CNY", "currency": "CNY",
"gateway": "unified",
"pay_type": pay_type, "pay_type": pay_type,
"pay_params": pay_params, **gw_result,
} }
async def handle_payment_callback(self, payment_id: str, success: bool) -> bool: 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:
result = await self.db.execute( result = await self.db.execute(
select(Subscription).where(Subscription.payment_id == payment_id) select(PaymentTransaction).where(PaymentTransaction.order_no == order_no)
) )
sub = result.scalar_one_or_none() txn = result.scalar_one_or_none()
if not sub: if not txn:
return False return False
if txn.status != "pending":
return True
if success: if success:
txn.status = "paid"
txn.gateway_order_id = gateway_order_id
txn.gateway_order_no = gateway_order_no
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.status = "active"
sub.started_at = datetime.utcnow() sub.started_at = datetime.utcnow()
if PLANS[sub.plan]["duration_days"]:
sub.expires_at = datetime.utcnow() + timedelta(days=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 == sub.user_id)) user_result = await self.db.execute(select(User).where(User.id == txn.user_id))
user = user_result.scalar_one_or_none() user = user_result.scalar_one_or_none()
if user: if user:
user.tier = sub.plan user.tier = txn.plan
else: 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" sub.status = "failed"
await self.db.flush() await self.db.flush()
return True return True
async def query_payment(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("订单不存在")
return {
"order_no": txn.order_no, "plan": txn.plan,
"amount": txn.amount, "currency": txn.currency,
"gateway": txn.gateway, "pay_type": txn.pay_type,
"status": txn.status,
"gateway_order_no": txn.gateway_order_no,
"paid_at": txn.paid_at.isoformat() if txn.paid_at else None,
"refund_amount": txn.refund_amount,
"created_at": txn.created_at.isoformat(),
}
payment_service = PaymentService async def list_transactions(self, user_id: str,
page: int = 1, size: int = 20) -> Dict[str, Any]:
query = select(PaymentTransaction).where(
PaymentTransaction.user_id == user_id
).order_by(desc(PaymentTransaction.created_at))
total_q = select(PaymentTransaction.id).where(
PaymentTransaction.user_id == user_id
)
total_result = await self.db.execute(total_q)
total = len(total_result.scalars().all())
result = await self.db.execute(query.offset((page - 1) * size).limit(size))
items = result.scalars().all()
return {
"items": [{
"order_no": t.order_no, "plan": t.plan,
"amount": t.amount, "gateway": t.gateway,
"pay_type": t.pay_type, "status": t.status,
"created_at": t.created_at.isoformat(),
"paid_at": t.paid_at.isoformat() if t.paid_at else None,
} for t in items],
"total": total, "page": page, "size": size,
}
async def refund(self, user_id: str, order_no: str,
reason: 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 != "paid":
raise ValueError("只有已支付订单可退款")
if txn.refund_amount >= txn.amount:
raise ValueError("该订单已全额退款")
gw = get_gateway(txn.pay_type)
remaining = int((txn.amount - txn.refund_amount) * 100)
try:
gw_result = await gw.refund(txn.order_no, remaining, reason)
logger.info(f"Refund {txn.order_no}: {gw_result}")
except Exception as e:
raise ValueError(f"退款请求失败: {e}")
txn.status = "refunded"
txn.refund_amount = txn.amount
txn.refund_reason = reason
txn.refunded_at = datetime.utcnow()
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 = "expired"
user_result = await self.db.execute(select(User).where(User.id == txn.user_id))
user = user_result.scalar_one_or_none()
if user and user.tier == txn.plan:
user.tier = "free"
await self.db.flush()
return {"status": "ok", "order_no": order_no, "refund_amount": txn.amount}
async def admin_list_payments(self, page: int = 1, size: int = 20,
gateway: str = "", status: str = "",
user_id: str = "") -> Dict[str, Any]:
query = select(PaymentTransaction).order_by(desc(PaymentTransaction.created_at))
count_query = select(PaymentTransaction.id)
if gateway:
query = query.where(PaymentTransaction.gateway == gateway)
count_query = count_query.where(PaymentTransaction.gateway == gateway)
if status:
query = query.where(PaymentTransaction.status == status)
count_query = count_query.where(PaymentTransaction.status == status)
if user_id:
query = query.where(PaymentTransaction.user_id == user_id)
count_query = count_query.where(PaymentTransaction.user_id == user_id)
total_result = await self.db.execute(count_query)
total = len(total_result.scalars().all())
result = await self.db.execute(query.offset((page - 1) * size).limit(size))
items = result.scalars().all()
return {
"items": [{
"id": str(t.id), "user_id": str(t.user_id),
"order_no": t.order_no, "plan": t.plan,
"amount": t.amount, "gateway": t.gateway,
"pay_type": t.pay_type, "status": t.status,
"gateway_order_no": t.gateway_order_no,
"refund_amount": t.refund_amount,
"created_at": t.created_at.isoformat(),
"paid_at": t.paid_at.isoformat() if t.paid_at else None,
"refunded_at": t.refunded_at.isoformat() if t.refunded_at else None,
} for t in items],
"total": total, "page": page, "size": size,
}
async def admin_refund(self, order_no: str, reason: 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 != "paid":
raise ValueError("只有已支付订单可退款")
gw = get_gateway(txn.pay_type)
remaining = int((txn.amount - txn.refund_amount) * 100)
try:
gw_result = await gw.refund(txn.order_no, remaining, reason)
logger.info(f"Admin refund {txn.order_no}: {gw_result}")
except Exception as e:
raise ValueError(f"退款请求失败: {e}")
txn.status = "refunded"
txn.refund_amount = txn.amount
txn.refund_reason = reason
txn.refunded_at = datetime.utcnow()
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 = "expired"
user_result = await self.db.execute(select(User).where(User.id == txn.user_id))
user = user_result.scalar_one_or_none()
if user and user.tier == txn.plan:
user.tier = "free"
await self.db.flush()
return {"status": "ok", "order_no": order_no, "refund_amount": txn.amount,
"user_id": str(txn.user_id)}
async def admin_payment_stats(self) -> Dict[str, Any]:
all_txns = await self.db.execute(select(PaymentTransaction))
rows = all_txns.scalars().all()
total_count = len(rows)
total_revenue = sum(r.amount for r in rows if r.status == "paid")
total_refund = sum(r.refund_amount for r in rows)
paid_count = sum(1 for r in rows if r.status == "paid")
pending_count = sum(1 for r in rows if r.status == "pending")
refunded_count = sum(1 for r in rows if r.status == "refunded")
failed_count = sum(1 for r in rows if r.status == "failed")
wechat_count = sum(1 for r in rows if r.gateway == "unified" and r.pay_type == "wechat")
alipay_count = sum(1 for r in rows if r.gateway == "unified" and r.pay_type == "alipay")
return {
"total_count": total_count, "total_revenue": total_revenue,
"total_refund": total_refund, "paid_count": paid_count,
"pending_count": pending_count, "refunded_count": refunded_count,
"failed_count": failed_count, "wechat_count": wechat_count,
"alipay_count": alipay_count,
}
init_gateways()
+36
View File
@@ -0,0 +1,36 @@
from abc import ABC, abstractmethod
from typing import Optional, Dict, Any
class PaymentGateway(ABC):
name: str = ""
@abstractmethod
async def create_order(self, order_no: str, amount: int, description: str,
**kwargs) -> Dict[str, Any]:
...
@abstractmethod
async def query_order(self, order_no: str) -> Dict[str, Any]:
...
@abstractmethod
async def refund(self, order_no: str, amount: int, reason: str = "") -> Dict[str, Any]:
...
@abstractmethod
async def query_refund(self, order_no: str) -> Dict[str, Any]:
...
@abstractmethod
def verify_callback(self, headers: dict, body: str) -> bool:
...
@abstractmethod
def parse_callback(self, body: str, headers: dict) -> Dict[str, Any]:
...
def supports(self, pay_type: str) -> bool:
return pay_type in self.supported_types
supported_types: list = []
+117
View File
@@ -0,0 +1,117 @@
import hashlib
import hmac
import json
import time
import logging
from typing import Optional, Dict, Any
import httpx
from app.config import settings
from app.services.payment_gateway import PaymentGateway
logger = logging.getLogger(__name__)
EMPTY_SHA256 = hashlib.sha256(b"").hexdigest()
def _hmac_sign(method: str, path: str, body: dict, api_secret: str) -> str:
timestamp = str(int(time.time()))
body_sha256 = hashlib.sha256(
json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode()
).hexdigest()
sign_str = f"{method}\n{path}\n{timestamp}\n{body_sha256}"
signature = hmac.new(
api_secret.encode(), sign_str.encode(), hashlib.sha256
).hexdigest()
return f"{timestamp}:{signature}"
def _auth_header(api_key: str, api_secret: str, method: str, path: str, body: dict) -> str:
ts_sig = _hmac_sign(method, path, body, api_secret)
return f"PAY {api_key}:{ts_sig}"
class UnifiedPayService(PaymentGateway):
name = "unified"
supported_types = ["alipay", "wechat"]
def __init__(self):
self.api_key = settings.PAY_API_KEY or ""
self.api_secret = settings.PAY_API_SECRET or ""
self.base_url = settings.PAY_API_BASE_URL
self.webhook_url = settings.PAY_WEBHOOK_URL
def _headers(self, method: str, path: str, body: dict) -> dict:
auth = _auth_header(self.api_key, self.api_secret, method, path, body)
return {"Authorization": auth, "Content-Type": "application/json"}
async def _request(self, method: str, path: str, body: dict = None) -> Dict[str, Any]:
body = body or {}
url = f"{self.base_url}{path}"
headers = self._headers(method, path, body)
async with httpx.AsyncClient() as client:
resp = await client.request(method=method, url=url, json=body, headers=headers)
result = resp.json()
if result.get("code") != 0:
raise ValueError(f"支付网关错误: {result.get('message', 'unknown')}")
return result.get("data", {})
async def create_order(self, order_no: str, amount: int, description: str,
**kwargs) -> Dict[str, Any]:
payment_method = kwargs.get("pay_type", "alipay")
if payment_method == "native":
payment_method = "wechat"
elif payment_method == "jsapi":
payment_method = "wechat"
elif payment_method == "pc":
payment_method = "alipay"
body = {
"merchant_order_id": order_no,
"amount": amount / 100,
"payment_method": payment_method,
"subject": description or "TradeMate 会员充值",
"notify_url": self.webhook_url,
}
result = await self._request("POST", "/v1/pay/orders", body)
out = {
"gateway_order_id": result.get("gateway_order_id", ""),
"merchant_order_id": result.get("merchant_order_id", order_no),
"amount": result.get("amount", amount / 100),
"payment_method": payment_method,
"status": result.get("status", "pending"),
}
if payment_method == "alipay":
out["pay_url"] = result.get("pay_url", "")
else:
out["code_url"] = result.get("qrcode", "")
return out
async def query_order(self, order_no: str) -> Dict[str, Any]:
return await self._request("GET", f"/v1/pay/orders/{order_no}")
async def refund(self, order_no: str, amount: int, reason: str = "") -> Dict[str, Any]:
body = {
"merchant_order_id": order_no,
"amount": amount / 100,
"reason": reason or "用户申请退款",
}
return await self._request("POST", "/v1/pay/refunds", body)
async def query_refund(self, order_no: str) -> Dict[str, Any]:
return await self._request("GET", f"/v1/pay/refunds/{order_no}")
def verify_callback(self, headers: dict, body: str) -> bool:
return True
def parse_callback(self, body: str, headers: dict) -> Dict[str, Any]:
data = json.loads(body)
event = data.get("event", "")
payload = data.get("data", {})
return {
"event": event,
"order_no": payload.get("merchant_order_id", ""),
"gateway_order_id": payload.get("order_id", ""),
"gateway_order_no": payload.get("transaction_id", ""),
"amount": payload.get("amount", 0),
"success": event == "recharge.completed",
"raw": payload,
}
-181
View File
@@ -1,181 +0,0 @@
import json
import time
import logging
import uuid
import base64
from typing import Optional, Dict, Any
from pathlib import Path
import httpx
from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.backends import default_backend
from app.config import settings
logger = logging.getLogger(__name__)
class WeChatPayService:
def __init__(self):
self.mch_id = settings.WECHAT_PAY_MCH_ID
self.api_key = settings.WECHAT_PAY_API_KEY
self.serial_no = settings.WECHAT_PAY_SERIAL_NO
self.app_id = settings.WECHAT_APP_ID
self.api_base = settings.WECHAT_PAY_API_BASE
self.notify_url = settings.WECHAT_PAY_NOTIFY_URL
self._private_key = None
def _load_private_key(self) -> bytes:
if self._private_key:
return self._private_key
cert_dir = Path(settings.WECHAT_PAY_CERT_DIR)
key_path = cert_dir / "apiclient_key.pem"
if not key_path.exists():
key_path = Path("/root/hermes-workspace/projects/微信支付key/key/apiclient_key.pem")
with open(key_path, "rb") as f:
self._private_key = f.read()
return self._private_key
def _sign_rsa(self, sign_str: str) -> str:
private_key_data = self._load_private_key()
key = serialization.load_pem_private_key(
private_key_data, password=None, backend=default_backend()
)
signature = key.sign(
sign_str.encode("utf-8"),
padding.PKCS1v15(),
hashes.SHA256(),
)
return base64.b64encode(signature).decode("utf-8")
def _build_auth_header(self, method: str, path: str, body: str = "") -> str:
timestamp = str(int(time.time()))
nonce = uuid.uuid4().hex[:16]
sign_str = f"{method}\n{path}\n{timestamp}\n{nonce}\n{body}\n"
signature = self._sign_rsa(sign_str)
return (
f'WECHATPAY2-SHA256-RSA2048 '
f'mchid="{self.mch_id}",'
f'nonce_str="{nonce}",'
f'timestamp="{timestamp}",'
f'serial_no="{self.serial_no}",'
f'signature="{signature}"'
)
async def _request(self, method: str, path: str, body: Optional[dict] = None) -> Dict[str, Any]:
url = f"{self.api_base}{path}"
body_str = json.dumps(body, ensure_ascii=False, separators=(",", ":")) if body else ""
auth = self._build_auth_header(method, path, body_str)
async with httpx.AsyncClient() as client:
resp = await client.request(
method=method,
url=url,
content=body_str if body else None,
headers={
"Authorization": auth,
"Content-Type": "application/json",
"Accept": "application/json",
"User-Agent": "TradeMate/1.0",
},
)
data = resp.json() if resp.text else {}
if resp.status_code >= 400:
logger.error(f"WeChat Pay API error: {resp.status_code} {data}")
raise Exception(f"WeChat Pay error: {data.get('message', resp.text)}")
return data
async def create_jsapi_order(self, out_trade_no: str, openid: str,
total: int, description: str) -> Dict[str, Any]:
path = "/v3/pay/transactions/jsapi"
body = {
"appid": self.app_id,
"mchid": self.mch_id,
"description": description,
"out_trade_no": out_trade_no,
"notify_url": self.notify_url,
"amount": {"total": total, "currency": "CNY"},
"payer": {"openid": openid},
}
return await self._request("POST", path, body)
async def create_native_order(self, out_trade_no: str, total: int,
description: str) -> Dict[str, Any]:
path = "/v3/pay/transactions/native"
body = {
"appid": self.app_id,
"mchid": self.mch_id,
"description": description,
"out_trade_no": out_trade_no,
"notify_url": self.notify_url,
"amount": {"total": total, "currency": "CNY"},
}
return await self._request("POST", path, body)
async def query_order(self, out_trade_no: str) -> Dict[str, Any]:
path = f"/v3/pay/transactions/out-trade-no/{out_trade_no}?mchid={self.mch_id}"
return await self._request("GET", path)
async def close_order(self, out_trade_no: str):
path = f"/v3/pay/transactions/out-trade-no/{out_trade_no}/close"
body = {"mchid": self.mch_id}
await self._request("POST", path, body)
def build_jsapi_pay_params(self, prepay_id: str) -> Dict[str, str]:
timestamp = str(int(time.time()))
nonce = uuid.uuid4().hex[:16]
package = f"prepay_id={prepay_id}"
sign_str = f"{self.app_id}\n{timestamp}\n{nonce}\n{package}\n"
pay_sign = self._sign_rsa(sign_str)
return {
"appId": self.app_id,
"timeStamp": timestamp,
"nonceStr": nonce,
"package": package,
"signType": "RSA",
"paySign": pay_sign,
}
@staticmethod
def verify_callback(headers: dict, body: str) -> bool:
wechatpay_signature = headers.get("wechatpay-signature", "")
wechatpay_timestamp = headers.get("wechatpay-timestamp", "")
wechatpay_nonce = headers.get("wechatpay-nonce", "")
wechatpay_serial = headers.get("wechatpay-serial", "")
if not all([wechatpay_signature, wechatpay_timestamp, wechatpay_nonce, wechatpay_serial]):
logger.warning("Missing WeChat Pay callback headers")
return False
sign_str = f"{wechatpay_timestamp}\n{wechatpay_nonce}\n{body}\n"
try:
cert_dir = Path(settings.WECHAT_PAY_CERT_DIR)
cert_path = cert_dir / "pub_key.pem"
if not cert_path.exists():
cert_path = Path("/root/hermes-workspace/projects/微信支付key/key/pub_key.pem")
with open(cert_path, "rb") as f:
cert_data = f.read()
public_key = serialization.load_pem_public_key(cert_data, backend=default_backend())
signature_bytes = base64.b64decode(wechatpay_signature)
public_key.verify(
signature_bytes,
sign_str.encode("utf-8"),
padding.PKCS1v15(),
hashes.SHA256(),
)
return True
except Exception as e:
logger.warning(f"WeChat Pay callback verification failed: {e}")
return False
def decrypt_callback(self, ciphertext: str, nonce: str,
associated_data: str) -> str:
key_bytes = self.api_key.encode("utf-8")
nonce_bytes = base64.b64decode(nonce) if nonce else b""
associated_bytes = associated_data.encode("utf-8")
ciphertext_bytes = base64.b64decode(ciphertext)
aesgcm = AESGCM(key_bytes)
plaintext = aesgcm.decrypt(nonce_bytes, ciphertext_bytes, associated_bytes)
return plaintext.decode("utf-8")
+1
View File
@@ -4,6 +4,7 @@ python_files = test_*.py
python_classes = Test* python_classes = Test*
python_functions = test_* python_functions = test_*
addopts = -v --tb=short --cov=app --cov-report=term-missing addopts = -v --tb=short --cov=app --cov-report=term-missing
asyncio_mode = auto
filterwarnings = filterwarnings =
ignore::DeprecationWarning ignore::DeprecationWarning
ignore::PendingDeprecationWarning ignore::PendingDeprecationWarning
+82
View File
@@ -0,0 +1,82 @@
import pytest
from httpx import AsyncClient
async def _setup_csrf(client: AsyncClient, auth_headers: dict) -> dict:
resp = await client.get("/api/v1/payment/plans", headers=auth_headers)
csrf = resp.headers.get("X-CSRF-Token", "")
return {**auth_headers, "X-CSRF-Token": csrf}
class TestPaymentAPI:
async def test_get_plans(self, client: AsyncClient):
response = await client.get("/api/v1/payment/plans")
assert response.status_code == 200
data = response.json()
assert "plans" in data
assert "gateways" in data
plan_ids = [p["id"] for p in data["plans"]]
assert "free" in plan_ids
assert "pro" in plan_ids
assert "enterprise" in plan_ids
async def test_get_subscription_no_auth(self, client: AsyncClient):
response = await client.get("/api/v1/payment/subscription")
assert response.status_code == 401
async def test_get_subscription_authenticated(self, client: AsyncClient,
auth_headers: dict):
response = await client.get(
"/api/v1/payment/subscription",
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert "plan" in data
assert "status" in data
async def test_create_free_plan_order(self, client: AsyncClient,
auth_headers: dict):
csrf_headers = await _setup_csrf(client, auth_headers)
response = await client.post(
"/api/v1/payment/create-order",
json={"plan": "free", "pay_type": "alipay"},
headers=csrf_headers,
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert data["plan"] == "free"
assert data["amount"] == 0
async def test_query_order_not_found(self, client: AsyncClient,
auth_headers: dict):
response = await client.get(
"/api/v1/payment/query/NONEXISTENT",
headers=auth_headers,
)
assert response.status_code == 404
async def test_list_transactions(self, client: AsyncClient,
auth_headers: dict):
response = await client.get(
"/api/v1/payment/transactions",
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert "items" in data
assert "total" in data
async def test_admin_list_payments_no_auth(self, client: AsyncClient):
response = await client.get("/api/v1/admin/payments")
assert response.status_code == 401
async def test_admin_payment_stats_no_auth(self, client: AsyncClient):
response = await client.get("/api/v1/admin/payments/stats")
assert response.status_code == 401
async def test_refund_no_auth(self, client: AsyncClient):
response = await client.post("/api/v1/payment/refund",
json={"order_no": "test", "reason": ""})
assert response.status_code == 401
+86 -7
View File
@@ -277,28 +277,64 @@
</view> </view>
<view class="footer"> <view class="footer">
<view class="footer-qrcode">
<view class="qrcode-item">
<image src="/static/images/yzr/yuzhiran.jpg" class="qrcode-img" mode="aspectFill" />
<text class="qrcode-label">公众号</text>
</view>
<view class="qrcode-item">
<image src="/static/images/yzr/yuzhiran-tech.jpg" class="qrcode-img" mode="aspectFill" />
<text class="qrcode-label">服务号</text>
</view>
<view class="qrcode-item">
<image src="/static/images/yzr/yuzhiran-yhl.jpg" class="qrcode-img" mode="aspectFill" />
<text class="qrcode-label">小程序</text>
</view>
<view class="qrcode-item">
<image src="/static/images/yzr/kefu.png" class="qrcode-img" mode="aspectFill" />
<text class="qrcode-label">客服</text>
</view>
</view>
<view class="footer-links"> <view class="footer-links">
<text class="footer-link" @click="goToPage(PAGES.AGREEMENT_PRIVACY)">隐私政策</text> <text class="footer-link" @click="goToPage(PAGES.AGREEMENT_PRIVACY)">隐私政策</text>
<text class="footer-divider">|</text> <text class="footer-divider">|</text>
<text class="footer-link" @click="goToPage(PAGES.AGREEMENT_TERMS)">用户协议</text> <text class="footer-link" @click="goToPage(PAGES.AGREEMENT_TERMS)">用户协议</text>
</view> </view>
<view class="footer-beian"> <view class="footer-beian">
<a class="footer-beian-link" :href="EXTERNAL_URLS.BEIAN" target="_blank">{{ APP_INFO.ICP }}</a> <a class="footer-beian-link" :href="beianUrl" target="_blank">{{ beianIcp }}</a>
<a class="footer-beian-link" :href="EXTERNAL_URLS.BEIAN_PSB" target="_blank">{{ APP_INFO.PSB }}</a> <text class="footer-divider">|</text>
<a class="footer-beian-link" :href="beianPsbUrl" target="_blank">{{ beianPsb }}</a>
</view> </view>
<text class="footer-copyright">© {{ APP_INFO.COPYRIGHT }}. 保留所有权利.</text> <text class="footer-copyright">© {{ copyrightYear }} 北京宇之然科技中心. 保留所有权利.</text>
</view> </view>
<AiAssistant /> <AiAssistant />
</view> </view>
</template> </template>
<script setup> <script setup>
import { ref, onUnmounted } from 'vue' import { ref, computed, onUnmounted } from 'vue'
import { onShow } from '@dcloudio/uni-app' import { onShow } from '@dcloudio/uni-app'
import { authApi, customerApi, analyticsApi, onboardingApi, notificationApi, followupApi, translateApi, BASE_URL } from '@/utils/api.js' import { authApi, customerApi, analyticsApi, onboardingApi, notificationApi, followupApi, translateApi, BASE_URL } from '@/utils/api.js'
import AiAssistant from '@/components/ai-assistant.vue' import AiAssistant from '@/components/ai-assistant.vue'
import { STORAGE_KEYS, PAGES, EXTERNAL_URLS, APP_INFO, EXTRACT_FIELD_LABELS } from '@/config.js' import { STORAGE_KEYS, PAGES, 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())
const showAnnouncement = ref(false) const showAnnouncement = ref(false)
const currentAnnouncement = ref(0) const currentAnnouncement = ref(0)
const announcements = [ const announcements = [
@@ -1239,10 +1275,53 @@ const playTryResult = () => {
} }
.footer { .footer {
margin-top: 40rpx; padding: 30rpx 20rpx 20rpx;
padding: 40rpx 20rpx 30rpx; background: #f8f8f8;
text-align: center; text-align: center;
border-top: 2rpx solid #e8e8e8; }
.footer-qrcode {
display: flex;
justify-content: center;
gap: 24rpx;
margin-bottom: 20rpx;
flex-wrap: wrap;
}
.qrcode-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6rpx;
}
.qrcode-img {
width: 80rpx;
height: 80rpx;
border-radius: 10rpx;
}
.qrcode-label {
font-size: 20rpx;
color: #999;
}
.footer-links {
display: flex;
justify-content: center;
align-items: center;
gap: 16rpx;
margin-bottom: 12rpx;
}
.footer-link {
font-size: 24rpx;
color: #666;
}
.footer-divider {
font-size: 24rpx;
color: #d9d9d9;
} }
.footer-links { .footer-links {
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+60 -3
View File
@@ -66,14 +66,47 @@
</main> </main>
<footer class="footer"> <footer class="footer">
<span>TradeMate &copy; {{ new Date().getFullYear() }}</span> <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>
</div>
</div>
</footer> </footer>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { getUnreadCount } from '@/api' import { getUnreadCount } from '@/api'
@@ -85,6 +118,17 @@ const collapsed = ref(false)
const showMobileMenu = ref(false) const showMobileMenu = ref(false)
const unread = ref(0) const unread = ref(0)
const beianInfo = computed(() => {
const hostname = window.location.hostname
if (hostname === 'yuzhiran.com' || hostname === 'www.yuzhiran.com') {
return { icp: '京ICP备2026007249号-1', gongan: '京公网安备11011502039545号', gonganLink: 'https://beian.mps.gov.cn/#/query/webSearch?code=11011502039545', showGongan: true }
}
if (hostname === 'yuzhiran.com.cn' || hostname === 'www.yuzhiran.com.cn') {
return { icp: '京ICP备2026007249号-2', gongan: '京公网安备11011502039622号', gonganLink: 'https://beian.mps.gov.cn/#/query/webSearch?code=11011502039622', showGongan: true }
}
return { icp: '京ICP备2026007249号-1', gongan: '京公网安备11011502039545号', gonganLink: 'https://beian.mps.gov.cn/#/query/webSearch?code=11011502039545', showGongan: true }
})
onMounted(async () => { onMounted(async () => {
try { try {
const res = await getUnreadCount() const res = await getUnreadCount()
@@ -114,7 +158,20 @@ function handleLogout() {
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; flex-shrink: 0; } .topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.notif-badge :deep(.el-badge__content) { top: 8px; right: 4px; } .notif-badge :deep(.el-badge__content) { top: 8px; right: 4px; }
.content { flex: 1; padding: 24px; overflow-y: auto; background: #f5f5f5; } .content { flex: 1; padding: 24px; overflow-y: auto; background: #f5f5f5; }
.footer { text-align: center; padding: 12px; color: #999; font-size: 12px; border-top: 1px solid #e8e8e8; background: #fff; flex-shrink: 0; } .footer { text-align: center; color: #999; font-size: 12px; border-top: 1px solid #e8e8e8; background: #fff; flex-shrink: 0; }
.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-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; }
@media (max-width: 768px) { @media (max-width: 768px) {
.sidebar { position: fixed; left: -220px; top: 0; bottom: 0; z-index: 1000; transition: left 0.3s; } .sidebar { position: fixed; left: -220px; top: 0; bottom: 0; z-index: 1000; transition: left 0.3s; }
+62 -2
View File
@@ -75,7 +75,42 @@
</section> </section>
<footer class="landing-footer"> <footer class="landing-footer">
<span>TradeMate 外贸小助手 &copy; {{ new Date().getFullYear() }}</span> <div class="footer-inner">
<div class="footer-top">
<div class="footer-info">
<div class="footer-brand">TradeMate</div>
<p class="footer-desc">AI 外贸小助手 · 让外贸更简单</p>
</div>
<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>
</div>
</div>
</footer> </footer>
</div> </div>
</template> </template>
@@ -87,6 +122,17 @@ import { useAuthStore } from '@/stores/auth'
import { register as registerApi } from '@/api' import { register as registerApi } from '@/api'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
const beianInfo = computed(() => {
const hostname = window.location.hostname
if (hostname === 'yuzhiran.com' || hostname === 'www.yuzhiran.com') {
return { icp: '京ICP备2026007249号-1', gongan: '京公网安备11011502039545号', gonganLink: 'https://beian.mps.gov.cn/#/query/webSearch?code=11011502039545', showGongan: true }
}
if (hostname === 'yuzhiran.com.cn' || hostname === 'www.yuzhiran.com.cn') {
return { icp: '京ICP备2026007249号-2', gongan: '京公网安备11011502039622号', gonganLink: 'https://beian.mps.gov.cn/#/query/webSearch?code=11011502039622', showGongan: true }
}
return { icp: '京ICP备2026007249号-1', gongan: '京公网安备11011502039545号', gonganLink: 'https://beian.mps.gov.cn/#/query/webSearch?code=11011502039545', showGongan: true }
})
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const auth = useAuthStore() const auth = useAuthStore()
@@ -195,7 +241,21 @@ function goWorkspace() { router.push('/workspace') }
.feature-icon { width: 52px; height: 52px; border-radius: 12px; display: flex; align-items: center; justify-content: center; margin-bottom: 16px; } .feature-icon { width: 52px; height: 52px; border-radius: 12px; display: flex; align-items: center; justify-content: center; margin-bottom: 16px; }
.feature-card h3 { font-size: 16px; margin-bottom: 8px; color: #333; } .feature-card h3 { font-size: 16px; margin-bottom: 8px; color: #333; }
.feature-card p { font-size: 13px; color: #999; line-height: 1.5; } .feature-card p { font-size: 13px; color: #999; line-height: 1.5; }
.landing-footer { text-align: center; padding: 24px; color: #999; font-size: 12px; margin-top: auto; border-top: 1px solid #e8e8e8; background: #fff; } .landing-footer { text-align: center; color: #999; font-size: 12px; margin-top: auto; border-top: 1px solid #e8e8e8; background: #fff; padding: 24px 20px 16px; }
.footer-inner { max-width: 1200px; margin: 0 auto; }
.footer-top { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 20px; margin-bottom: 16px; }
.footer-info { text-align: left; }
.footer-brand { font-size: 18px; font-weight: 700; color: #1890ff; margin-bottom: 4px; }
.footer-desc { color: #999; font-size: 13px; }
.qrcode-row { display: flex; gap: 20px; flex-wrap: wrap; justify-content: center; }
.qrcode-item { display: flex; flex-direction: column; align-items: center; gap: 4px; color: #999; font-size: 11px; }
.qrcode-img { width: 60px; height: 60px; border-radius: 8px; }
.footer-bottom { border-top: 1px solid #eee; padding-top: 14px; 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; }
.gongan-link { display: inline-flex; align-items: center; gap: 4px; }
.gongan-icon { height: 16px; vertical-align: middle; }
@media (max-width: 768px) { @media (max-width: 768px) {
.hero-inner { flex-direction: column; padding: 40px 20px; } .hero-inner { flex-direction: column; padding: 40px 20px; }