diff --git a/AGENTS.md b/AGENTS.md
index 0212238..6ccd2ad 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -78,6 +78,8 @@ alembic revision --autogenerate -m "desc"
- **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
diff --git a/admin-frontend/public/images/beian/gongan-beian.png b/admin-frontend/public/images/beian/gongan-beian.png
new file mode 100755
index 0000000..2a13ba2
Binary files /dev/null and b/admin-frontend/public/images/beian/gongan-beian.png differ
diff --git a/admin-frontend/public/images/yzr/kefu.png b/admin-frontend/public/images/yzr/kefu.png
new file mode 100755
index 0000000..05317bf
Binary files /dev/null and b/admin-frontend/public/images/yzr/kefu.png differ
diff --git a/admin-frontend/public/images/yzr/yuzhiran-tech.jpg b/admin-frontend/public/images/yzr/yuzhiran-tech.jpg
new file mode 100755
index 0000000..404bb48
Binary files /dev/null and b/admin-frontend/public/images/yzr/yuzhiran-tech.jpg differ
diff --git a/admin-frontend/public/images/yzr/yuzhiran-yhl.jpg b/admin-frontend/public/images/yzr/yuzhiran-yhl.jpg
new file mode 100755
index 0000000..f7130d2
Binary files /dev/null and b/admin-frontend/public/images/yzr/yuzhiran-yhl.jpg differ
diff --git a/admin-frontend/public/images/yzr/yuzhiran.jpg b/admin-frontend/public/images/yzr/yuzhiran.jpg
new file mode 100755
index 0000000..cc61e6f
Binary files /dev/null and b/admin-frontend/public/images/yzr/yuzhiran.jpg differ
diff --git a/admin-frontend/src/layouts/AdminLayout.vue b/admin-frontend/src/layouts/AdminLayout.vue
index f6dd15c..74bce65 100644
--- a/admin-frontend/src/layouts/AdminLayout.vue
+++ b/admin-frontend/src/layouts/AdminLayout.vue
@@ -92,14 +92,47 @@
diff --git a/backend/.env.example b/backend/.env.example
index ad7efb4..87bb8ac 100644
--- a/backend/.env.example
+++ b/backend/.env.example
@@ -57,13 +57,11 @@ WECHAT_APP_ID=
WECHAT_APP_SECRET=
WECHAT_PUSH_TEMPLATE_ID=
-# 微信支付
-WECHAT_PAY_MCH_ID=
-WECHAT_PAY_API_KEY=
-WECHAT_PAY_SERIAL_NO=
-WECHAT_PAY_CERT_DIR=./certs
-WECHAT_PAY_NOTIFY_URL=https://your-domain.com/api/v1/payment/notify
-WECHAT_PAY_API_BASE=https://api.mch.weixin.qq.com
+# 统一支付网关(宇之然 pay-api,支持支付宝/微信)
+PAY_API_KEY=pay_98c86e0d2eba4379bfe722c8
+PAY_API_SECRET=cc392f42daf94719b9b157f3e7ad6c9472ae20a33ba14323
+PAY_API_BASE_URL=https://www.yzrcloud.cn/api/gateway
+PAY_WEBHOOK_URL=https://your-domain.com/api/v1/payment/webhook
# 汇率 API(免费层即可)
EXCHANGE_RATE_API_KEY=
diff --git a/backend/alembic/versions/add_payment_transactions_table.py b/backend/alembic/versions/add_payment_transactions_table.py
new file mode 100644
index 0000000..342acfe
--- /dev/null
+++ b/backend/alembic/versions/add_payment_transactions_table.py
@@ -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")
diff --git a/backend/app/api/v1/admin.py b/backend/app/api/v1/admin.py
index 94cecf1..29d34e2 100644
--- a/backend/app/api/v1/admin.py
+++ b/backend/app/api/v1/admin.py
@@ -8,6 +8,7 @@ from app.services.admin import AdminService
from app.services.translation_quota import TranslationQuotaService
from app.services.certification import CertificationService
from app.services.invoice import InvoiceService
+from app.services.payment import PaymentService
from app.api.v1.deps import get_current_user
router = APIRouter()
@@ -274,3 +275,41 @@ async def admin_process_invoice(
if not result:
raise HTTPException(status_code=404, detail="Invoice not found")
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))
diff --git a/backend/app/api/v1/payment.py b/backend/app/api/v1/payment.py
index 0153a6c..ceb20f2 100644
--- a/backend/app/api/v1/payment.py
+++ b/backend/app/api/v1/payment.py
@@ -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 pydantic import BaseModel
from typing import Optional
from app.database import get_db
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.core.csrf import require_csrf_token
@@ -13,12 +12,12 @@ router = APIRouter()
class CreateOrderRequest(BaseModel):
plan: str
- pay_type: str = "jsapi"
+ pay_type: str = "alipay"
-class PaymentCallbackRequest(BaseModel):
- payment_id: str
- success: bool
+class RefundRequest(BaseModel):
+ order_no: str
+ reason: str = ""
@router.get("/plans")
@@ -50,42 +49,65 @@ async def create_order(
raise HTTPException(status_code=400, detail=str(e))
-@router.post("/callback")
-async def payment_callback(
- data: PaymentCallbackRequest,
+@router.get("/query/{order_no}")
+async def query_payment(
+ 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),
_csrf: str = Depends(require_csrf_token),
):
svc = PaymentService(db)
- success = await svc.handle_payment_callback(data.payment_id, data.success)
- if not success:
- raise HTTPException(status_code=404, detail="Order not found")
- return {"status": "ok"}
+ try:
+ return await svc.refund(user_id, data.order_no, data.reason)
+ except ValueError as e:
+ raise HTTPException(status_code=400, detail=str(e))
-@router.post("/notify")
-async def wechat_pay_notify(request: Request, db: AsyncSession = Depends(get_db)):
+@router.post("/webhook")
+async def unified_webhook(request: Request, db: AsyncSession = Depends(get_db)):
body = await request.body()
body_str = body.decode("utf-8")
- headers = dict(request.headers)
-
- wxpay = WeChatPayService()
- if not wxpay.verify_callback(headers, body_str):
- raise HTTPException(status_code=401, detail="签名验证失败")
-
import json
- data = json.loads(body_str)
- resource = data.get("resource", {})
- ciphertext = resource.get("ciphertext", "")
- nonce = resource.get("nonce", "")
- associated_data = resource.get("associated_data", "")
+ try:
+ data = json.loads(body_str)
+ except json.JSONDecodeError:
+ raise HTTPException(status_code=400, detail="无效的 JSON")
- plaintext = wxpay.decrypt_callback(ciphertext, nonce, associated_data)
- pay_data = json.loads(plaintext)
- out_trade_no = pay_data.get("out_trade_no", "")
- trade_state = pay_data.get("trade_state", "")
+ event = data.get("event", "")
+ pay_data = data.get("data", {})
+ merchant_order_id = pay_data.get("merchant_order_id", "")
+ 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)
- await svc.handle_payment_callback(out_trade_no, success)
- return {"code": "SUCCESS", "message": "OK"}
+ await svc.handle_callback(
+ merchant_order_id, order_id, transaction_id,
+ success, amount, body_str,
+ )
+ return {"code": 0, "message": "OK"}
diff --git a/backend/app/config.py b/backend/app/config.py
index e0ac648..de70305 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -55,12 +55,10 @@ class Settings(BaseSettings):
WECHAT_APP_SECRET: Optional[str] = None
WECHAT_PUSH_TEMPLATE_ID: Optional[str] = None
- WECHAT_PAY_MCH_ID: Optional[str] = None
- WECHAT_PAY_API_KEY: Optional[str] = None
- WECHAT_PAY_SERIAL_NO: Optional[str] = None
- WECHAT_PAY_CERT_DIR: str = "./certs"
- WECHAT_PAY_NOTIFY_URL: str = "https://example.com/api/v1/payment/notify"
- WECHAT_PAY_API_BASE: str = "https://api.mch.weixin.qq.com"
+ PAY_API_KEY: Optional[str] = None
+ PAY_API_SECRET: Optional[str] = None
+ PAY_API_BASE_URL: str = "https://www.yzrcloud.cn/api/gateway"
+ PAY_WEBHOOK_URL: str = "https://example.com/api/v1/payment/webhook"
EXCHANGE_RATE_API_KEY: Optional[str] = None
diff --git a/backend/app/core/csrf.py b/backend/app/core/csrf.py
index e1b4486..1fd81de 100644
--- a/backend/app/core/csrf.py
+++ b/backend/app/core/csrf.py
@@ -23,7 +23,7 @@ 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/notify",
+ "/api/v1/payment/webhook",
"/api/v1/whatsapp/webhook",
]
diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py
index 2878a31..2aa231c 100644
--- a/backend/app/models/__init__.py
+++ b/backend/app/models/__init__.py
@@ -18,26 +18,23 @@ from .referral import ReferralCode, Referral
from .search_provider import SearchProvider
from .discovery_record import DiscoveryRecord
from .ai_provider import AIProvider
+from .payment_transaction import PaymentTransaction
__all__ = [
"User", "Product",
"Customer", "Conversation", "Message",
"Quotation", "QuotationItem",
- "CorpusEntry",
- "Team", "TeamMember",
- "UsageLog",
- "Notification",
- "Feedback",
"Subscription",
+ "CorpusEntry",
+ "Notification",
+ "Team", "TeamMember",
+ "Feedback",
"PreferenceAnalysis", "MarketingEffect",
- "Device",
"FollowupStrategy", "FollowupLog",
- "SystemConfig",
- "TranslationQuota",
- "Certification", "CertType", "CertStatus",
- "Invoice", "InvoiceType", "InvoiceStatus",
+ "Certification", "Invoice", "InvoiceType", "InvoiceStatus",
"ReferralCode", "Referral",
"SearchProvider",
"DiscoveryRecord",
"AIProvider",
+ "PaymentTransaction",
]
diff --git a/backend/app/models/payment_transaction.py b/backend/app/models/payment_transaction.py
new file mode 100644
index 0000000..17bd93e
--- /dev/null
+++ b/backend/app/models/payment_transaction.py
@@ -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)
diff --git a/backend/app/services/payment.py b/backend/app/services/payment.py
index 47dee9e..02a0d06 100644
--- a/backend/app/services/payment.py
+++ b/backend/app/services/payment.py
@@ -1,13 +1,15 @@
import logging
import hashlib
-from typing import Optional, Dict, Any
+from typing import Optional, Dict, Any, List
from datetime import datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select
+from sqlalchemy import select, desc
from app.models.subscription import Subscription
+from app.models.payment_transaction import PaymentTransaction
from app.models.user import User
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__)
@@ -26,92 +28,50 @@ PLAN_DESCRIPTIONS = {
"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:
def __init__(self, db: AsyncSession):
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]:
return {
"plans": [
- {
- "id": "free",
- "name": "免费版",
- "price": 0,
- "period": "month",
- "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,
- "features": [
- "10 个产品",
- "无限翻译",
- "50 个客户",
- "跟进提醒",
- "报价单生成",
- "省 ¥189",
- ],
- },
- {
- "id": "enterprise",
- "name": "企业版",
- "price": 399,
- "period": "month",
- "features": [
- "无限产品/客户",
- "团队协作",
- "品牌报价单",
- "专属语料训练",
- "API 接入",
- "优先支持",
- ],
- },
- {
- "id": "enterprise_yearly",
- "name": "企业版(年付)",
- "price": 3999,
- "period": "year",
- "original_price": 4788,
- "features": [
- "无限产品/客户",
- "团队协作",
- "品牌报价单",
- "专属语料训练",
- "API 接入",
- "优先支持",
- "省 ¥789",
- ],
- },
+ {"id": "free", "name": "免费版", "price": 0, "period": "month",
+ "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,
+ "features": ["10 个产品", "无限翻译", "50 个客户", "跟进提醒", "报价单生成", "省 ¥189"]},
+ {"id": "enterprise", "name": "企业版", "price": 399, "period": "month",
+ "features": ["无限产品/客户", "团队协作", "品牌报价单", "专属语料训练", "API 接入", "优先支持"]},
+ {"id": "enterprise_yearly", "name": "企业版(年付)", "price": 3999, "period": "year",
+ "original_price": 4788,
+ "features": ["无限产品/客户", "团队协作", "品牌报价单", "专属语料训练", "API 接入", "优先支持", "省 ¥789"]},
],
+ "gateways": list(GATEWAY_MAP.keys()) or ["unified"],
}
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)
)
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()
-
return {
"plan": user.tier if user else "free",
"status": sub.status if sub else "active",
@@ -136,124 +92,281 @@ class PaymentService:
}
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:
- raise ValueError(f"Invalid plan: {plan}")
-
+ raise ValueError(f"无效套餐: {plan}")
plan_info = PLANS[plan]
- if plan_info["price"] == 0:
- result = await self.db.execute(select(User).where(User.id == user_id))
- user = result.scalar_one_or_none()
- if user:
- user.tier = plan
- await self.db.flush()
- return {"status": "ok", "plan": plan, "amount": 0}
-
result = await self.db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
- raise ValueError("User not found")
+ raise ValueError("用户不存在")
- order_id = f"ORD{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{user_id[-6:]}"
+ if plan_info["price"] == 0:
+ user.tier = plan
+ await self.db.flush()
+ return {"status": "ok", "plan": plan, "amount": 0}
+
+ order_no = gen_order_no(user_id)
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(
- user_id=user_id,
- plan=plan,
- status="pending",
- amount=plan_info["price"],
- payment_id=order_id,
+ user_id=user_id, plan=plan, status="pending",
+ amount=plan_info["price"], payment_id=order_no,
+ payment_provider="unified",
)
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()
- wxpay_available = self.wxpay is not None and settings.WECHAT_PAY_NOTIFY_URL not in (
- "", "https://example.com/api/v1/payment/notify"
- )
-
- if wxpay_available:
- try:
- if pay_type == "jsapi":
- openid = user.wechat_openid
- if not openid:
- raise ValueError("用户未绑定微信,请在微信小程序中登录后支付")
-
- wx_result = await self.wxpay.create_jsapi_order(
- order_id, openid, int(plan_info["price"] * 100), description
- )
- prepay_id = wx_result.get("prepay_id", "")
- pay_params = self.wxpay.build_jsapi_pay_params(prepay_id)
- return {
- "status": "pending",
- "order_id": order_id,
- "plan": plan,
- "amount": plan_info["price"],
- "currency": "CNY",
- "pay_type": "jsapi",
- "pay_params": pay_params,
- }
-
- elif pay_type == "native":
- wx_result = await self.wxpay.create_native_order(
- order_id, int(plan_info["price"] * 100), description
- )
- code_url = wx_result.get("code_url", "")
- return {
- "status": "pending",
- "order_id": order_id,
- "plan": plan,
- "amount": plan_info["price"],
- "currency": "CNY",
- "pay_type": "native",
- "code_url": code_url,
- }
- except Exception as e:
- logger.error(f"WeChat Pay order failed: {e}")
- raise ValueError(f"支付创建失败: {str(e)}")
-
- # 开发环境回退:生成模拟支付参数
- pay_params = {
- "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,
+ "order_id": order_no,
"plan": plan,
"amount": plan_info["price"],
"currency": "CNY",
+ "gateway": "unified",
"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(
- select(Subscription).where(Subscription.payment_id == payment_id)
+ select(PaymentTransaction).where(PaymentTransaction.order_no == order_no)
)
- sub = result.scalar_one_or_none()
- if not sub:
+ txn = result.scalar_one_or_none()
+ if not txn:
return False
+ if txn.status != "pending":
+ return True
if success:
- sub.status = "active"
- sub.started_at = datetime.utcnow()
- sub.expires_at = datetime.utcnow() + timedelta(days=PLANS[sub.plan]["duration_days"])
+ 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
- user_result = await self.db.execute(select(User).where(User.id == sub.user_id))
+ 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 = sub.plan
+ user.tier = txn.plan
else:
- sub.status = "failed"
+ 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"
await self.db.flush()
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()
diff --git a/backend/app/services/payment_gateway.py b/backend/app/services/payment_gateway.py
new file mode 100644
index 0000000..1abc885
--- /dev/null
+++ b/backend/app/services/payment_gateway.py
@@ -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 = []
diff --git a/backend/app/services/unified_pay.py b/backend/app/services/unified_pay.py
new file mode 100644
index 0000000..54250f4
--- /dev/null
+++ b/backend/app/services/unified_pay.py
@@ -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,
+ }
diff --git a/backend/app/services/wechat_pay.py b/backend/app/services/wechat_pay.py
deleted file mode 100644
index 3fe0955..0000000
--- a/backend/app/services/wechat_pay.py
+++ /dev/null
@@ -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")
diff --git a/backend/pytest.ini b/backend/pytest.ini
index 7a1818e..61302ba 100644
--- a/backend/pytest.ini
+++ b/backend/pytest.ini
@@ -4,6 +4,7 @@ python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --tb=short --cov=app --cov-report=term-missing
+asyncio_mode = auto
filterwarnings =
ignore::DeprecationWarning
ignore::PendingDeprecationWarning
\ No newline at end of file
diff --git a/backend/tests/test_payment.py b/backend/tests/test_payment.py
new file mode 100644
index 0000000..31163ae
--- /dev/null
+++ b/backend/tests/test_payment.py
@@ -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
diff --git a/uni-app/src/pages/index/index.vue b/uni-app/src/pages/index/index.vue
index 3b8c967..3803c64 100644
--- a/uni-app/src/pages/index/index.vue
+++ b/uni-app/src/pages/index/index.vue
@@ -277,28 +277,64 @@