diff --git a/.gitignore b/.gitignore index 4887c932..4c3b48dc 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ video-gen-api/dist/ # 忽略特定类型文件但保留目录 # *.pyc # !dir/*.pycnode_modules/ +*.tmp.* \ No newline at end of file diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index c4017611..6fd2c113 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -21,6 +21,7 @@ video_item/ | PostgreSQL | >= 14 | 推荐 16 | | Redis | >= 6 | 可选,推荐用于限流/验证码/Celery | | FFmpeg | 任意 | 可选,用于视频封面截帧 | +| alipay-sdk-python | >=3.7.1160 | 可选,用于支付 | --- @@ -46,6 +47,12 @@ pip install -e ".[pg,redis]" # 如需 Celery 异步任务(ChatAPI 生成流水线) pip install -e ".[pg,redis,celery]" + +#安装阿里支付sdk +pip install -e ".[pg,redis,celery,alipay]" + +#安装火山sdk +pip install -e ".[pg,redis,celery,alipay,volc]" ``` ### 2. 配置环境变量 diff --git a/video-gen-api/app/api/v1/payments.py b/video-gen-api/app/api/v1/payments.py index 6b2d8ca4..3f606892 100644 --- a/video-gen-api/app/api/v1/payments.py +++ b/video-gen-api/app/api/v1/payments.py @@ -1,13 +1,22 @@ +import logging + from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +logger = logging.getLogger("videogen") + from app.dependencies import get_db, get_current_user from app.models.user import User from app.models.payment_order import PaymentOrder from app.models.recharge_package import RechargePackage from app.schemas.payment import RechargeRequest, PaymentOrderOut -from app.services.payment import create_recharge_order, verify_wechat_callback, verify_alipay_callback, process_payment_success +from app.services.payment import ( + create_recharge_order, + verify_wechat_callback, + verify_alipay_callback, + process_payment_success_by_order_no, +) router = APIRouter(prefix="/payments", tags=["payments"]) @@ -18,6 +27,9 @@ async def recharge( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): + if req.method not in ("wechat", "alipay"): + raise HTTPException(status_code=400, detail="不支持的支付方式") + result = await db.execute( select(RechargePackage).where( RechargePackage.id == req.plan, @@ -35,6 +47,7 @@ async def recharge( price=pkg.price, label=pkg.name, bonus_credits=pkg.bonus_credits, + method=req.method, ) return order @@ -42,30 +55,35 @@ async def recharge( @router.post("/wechat/callback") async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)): data = await request.json() - if not await verify_wechat_callback(data): + if not await verify_wechat_callback(data, db): raise HTTPException(status_code=400, detail="签名验证失败") order_no = data.get("out_trade_no") - result = await db.execute( - select(PaymentOrder).where(PaymentOrder.order_no == order_no).limit(1) - ) - order = result.scalar_one_or_none() - if order: - await process_payment_success(db, order.id) + if order_no: + await process_payment_success_by_order_no(db, order_no) return {"code": "SUCCESS", "message": "OK"} @router.post("/alipay/callback") async def alipay_callback(request: Request, db: AsyncSession = Depends(get_db)): - data = await request.form() - if not await verify_alipay_callback(dict(data)): + form_data = await request.form() + data = dict(form_data) + logger.info(f"Alipay callback received: {list(data.keys())}") + + # Verify signature first + if not await verify_alipay_callback(data, db): raise HTTPException(status_code=400, detail="签名验证失败") + + # Check trade_status – only "TRADE_SUCCESS" and "TRADE_FINISHED" mean paid + trade_status = data.get("trade_status", "") + if trade_status not in ("TRADE_SUCCESS", "TRADE_FINISHED"): + logger.info(f"Alipay callback trade_status={trade_status}, ignoring") + return "success" + order_no = data.get("out_trade_no") - result = await db.execute( - select(PaymentOrder).where(PaymentOrder.order_no == order_no).limit(1) - ) - order = result.scalar_one_or_none() - if order: - await process_payment_success(db, order.id) + trade_no = data.get("trade_no", "") + if order_no: + await process_payment_success_by_order_no(db, order_no, trade_no) + return "success" diff --git a/video-gen-api/app/config.py b/video-gen-api/app/config.py index 5b3bf258..3118204d 100644 --- a/video-gen-api/app/config.py +++ b/video-gen-api/app/config.py @@ -56,6 +56,7 @@ class Settings(BaseSettings): ALIPAY_APP_ID: str = "" ALIPAY_PRIVATE_KEY: str = "" ALIPAY_PUBLIC_KEY: str = "" + ALIPAY_NOTIFY_URL: str = "" PAYMENT_MOCK: bool = True STORAGE_TYPE: str = "local" diff --git a/video-gen-api/app/schemas/payment.py b/video-gen-api/app/schemas/payment.py index cf611557..135b4172 100644 --- a/video-gen-api/app/schemas/payment.py +++ b/video-gen-api/app/schemas/payment.py @@ -3,6 +3,7 @@ from pydantic import BaseModel class RechargeRequest(BaseModel): plan: str # package id + method: str = "wechat" # "wechat" or "alipay" class PaymentOrderOut(BaseModel): @@ -12,5 +13,6 @@ class PaymentOrderOut(BaseModel): credits: float payment_method: str status: str + qr_url: str | None = None # Alipay QR code URL (transient, not persisted) model_config = {"from_attributes": True} diff --git a/video-gen-api/app/services/payment.py b/video-gen-api/app/services/payment.py index ed0e8946..9261cb36 100644 --- a/video-gen-api/app/services/payment.py +++ b/video-gen-api/app/services/payment.py @@ -1,16 +1,87 @@ import logging from datetime import datetime +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.models.payment_order import PaymentOrder +from app.models.system_config import SystemConfig from app.services.credits import add_credits from app.utils.id_gen import generate_id, generate_order_no logger = logging.getLogger("videogen") +# --------------------------------------------------------------------------- +# Config helpers – read from system_configs table (admin panel) +# --------------------------------------------------------------------------- + + +async def _get_payment_configs(db: AsyncSession) -> dict[str, str]: + """Read all payment_* configs from the database, return as a dict.""" + result = await db.execute( + select(SystemConfig).where(SystemConfig.key.like("payment_%")) + ) + return {c.key: c.value for c in result.scalars().all()} + + +def _is_mock_mode(db_configs: dict[str, str]) -> bool: + """Check if payment mock mode is enabled (from DB or env).""" + db_val = db_configs.get("payment_mock", "") + if db_val: + return db_val.lower() in ("true", "1", "yes") + return settings.PAYMENT_MOCK + + +# --------------------------------------------------------------------------- +# Alipay client (lazy singleton, recreated when config changes) +# --------------------------------------------------------------------------- +_alipay_client = None +_alipay_client_app_id = None + + +def _get_alipay_client(app_id: str, private_key: str, public_key: str): + """Get or create an Alipay client. Recreated if app_id changes.""" + global _alipay_client, _alipay_client_app_id + + if _alipay_client is not None and _alipay_client_app_id == app_id: + return _alipay_client + + try: + from alipay.aop.api.AlipayClientConfig import AlipayClientConfig + from alipay.aop.api.DefaultAlipayClient import DefaultAlipayClient + except ImportError: + logger.error( + "alipay-sdk-python is not installed. " + "Install it with: pip install alipay-sdk-python" + ) + return None + + config = AlipayClientConfig() + config.server_url = "https://openapi.alipay.com/gateway.do" + config.app_id = app_id + config.app_private_key = private_key + config.alipay_public_key = public_key + config.sign_type = "RSA2" + config.charset = "utf-8" + + try: + _alipay_client = DefaultAlipayClient(config) + _alipay_client_app_id = app_id + except Exception: + logger.exception("Failed to initialize Alipay client") + _alipay_client = None + _alipay_client_app_id = None + + return _alipay_client + + +# --------------------------------------------------------------------------- +# Create recharge order +# --------------------------------------------------------------------------- + + async def create_recharge_order( db: AsyncSession, user_id: str, @@ -20,7 +91,12 @@ async def create_recharge_order( bonus_credits: float = 0.0, method: str = "wechat", ) -> PaymentOrder: - """Create a payment order. In mock mode, immediately completes payment.""" + """Create a payment order. + + Reads payment config from the database (admin panel). + Returns the order; for Alipay the ``qr_url`` attribute will be populated + with the scan-to-pay URL. + """ total_credits = credits + bonus_credits order = PaymentOrder( id=generate_id(), @@ -34,7 +110,11 @@ async def create_recharge_order( db.add(order) await db.flush() - if settings.PAYMENT_MOCK: + # Read config from database + db_configs = await _get_payment_configs(db) + mock_mode = _is_mock_mode(db_configs) + + if mock_mode: # Mock: immediately complete payment order.status = "paid" order.paid_at = datetime.now() @@ -52,57 +132,184 @@ async def create_recharge_order( else: # Real payment: delegate to WeChat or Alipay if method == "wechat": - _create_wechat_order(order) + _create_wechat_order(order, db_configs) elif method == "alipay": - _create_alipay_order(order) + qr_url = _create_alipay_order(order, db_configs) + if qr_url: + # Attach QR URL to the order instance (transient, not persisted) + order.qr_url = qr_url # type: ignore[attr-defined] return order -def _create_wechat_order(order: PaymentOrder) -> None: +# --------------------------------------------------------------------------- +# WeChat (stub) +# --------------------------------------------------------------------------- + + +def _create_wechat_order(order: PaymentOrder, db_configs: dict[str, str]) -> None: """Create a WeChat Pay order. Stub for real integration.""" - if not settings.WECHAT_MCH_ID or not settings.WECHAT_API_KEY: - logger.warning("WeChat payment config missing (WECHAT_MCH_ID / WECHAT_API_KEY)") + mch_id = db_configs.get("payment_wechat_mch_id", "") + api_key = db_configs.get("payment_wechat_api_key", "") + if not mch_id or not api_key: + logger.warning("WeChat payment config missing in database") return logger.info( - f"WeChat order created: mch_id={settings.WECHAT_MCH_ID}, " + f"WeChat order created: mch_id={mch_id}, " f"order_no={order.order_no}, amount={order.amount}" ) -def _create_alipay_order(order: PaymentOrder) -> None: - """Create an Alipay order. Stub for real integration.""" - if not settings.ALIPAY_APP_ID or not settings.ALIPAY_PRIVATE_KEY: - logger.warning("Alipay payment config missing (ALIPAY_APP_ID / ALIPAY_PRIVATE_KEY)") - return - logger.info( - f"Alipay order created: app_id={settings.ALIPAY_APP_ID}, " - f"order_no={order.order_no}, amount={order.amount}" - ) +# --------------------------------------------------------------------------- +# Alipay – trade.precreate (当面付 预下单) +# --------------------------------------------------------------------------- -async def verify_wechat_callback(data: dict) -> bool: +def _create_alipay_order(order: PaymentOrder, db_configs: dict[str, str]) -> str | None: + """Call Alipay ``trade.precreate`` to obtain a QR code URL. + + Reads all Alipay config from the database (admin panel). + Returns the ``qr_code`` URL on success, or ``None`` on failure. + """ + app_id = db_configs.get("payment_alipay_app_id", "") + private_key = db_configs.get("payment_alipay_private_key", "") + public_key = db_configs.get("payment_alipay_public_key", "") + notify_url = db_configs.get("payment_alipay_notify_url", "") + + if not app_id or not private_key: + logger.warning("Alipay config missing in database (app_id / private_key)") + return None + + client = _get_alipay_client(app_id, private_key, public_key) + if client is None: + return None + + try: + from alipay.aop.api.domain.AlipayTradePrecreateModel import ( + AlipayTradePrecreateModel, + ) + from alipay.aop.api.request.AlipayTradePrecreateRequest import ( + AlipayTradePrecreateRequest, + ) + + model = AlipayTradePrecreateModel() + model.out_trade_no = order.order_no + model.total_amount = f"{order.amount:.2f}" + model.subject = f"充值订单 {order.order_no}" + + body_parts = [] + if order.credits > 0: + body_parts.append(f"{order.credits}积分") + if body_parts: + model.body = " ".join(body_parts) + + request = AlipayTradePrecreateRequest() + request.biz_model = model + if notify_url: + request.notify_url = notify_url + + response = client.execute(request) + + if response.code == "10000": + qr_url = response.qr_code + logger.info( + f"Alipay precreate success: order_no={order.order_no}, " + f"qr_url={qr_url}" + ) + return qr_url + else: + logger.error( + f"Alipay precreate failed: code={response.code}, " + f"msg={response.msg}, sub_code={response.sub_code}, " + f"sub_msg={response.sub_msg}, order_no={order.order_no}" + ) + return None + + except Exception: + logger.exception(f"Alipay precreate exception: order_no={order.order_no}") + return None + + +# --------------------------------------------------------------------------- +# Alipay callback verification +# --------------------------------------------------------------------------- + + +async def verify_alipay_callback(data: dict, db: AsyncSession) -> bool: + """Verify Alipay payment callback (async notify) signature. + + Reads the Alipay public key from the database and uses the SDK's + built-in RSA2 verification. + """ + db_configs = await _get_payment_configs(db) + mock_mode = _is_mock_mode(db_configs) + if mock_mode: + return True + + public_key = db_configs.get("payment_alipay_public_key", "") + if not public_key: + logger.warning("ALIPAY_PUBLIC_KEY not found in database, cannot verify callback") + return False + + try: + sign = data.get("sign") + if not sign: + logger.warning("Alipay callback missing 'sign' field") + return False + + # Build verification params (exclude sign and sign_type) + verify_data = { + k: v for k, v in data.items() + if k not in ("sign", "sign_type") and v is not None and v != "" + } + + from alipay.aop.api.util.Signature import verify_with_rsa + + sign_content = "&".join( + f"{k}={v}" for k, v in sorted(verify_data.items()) + ) + + is_valid = verify_with_rsa( + public_key.encode("utf-8"), + sign_content.encode("utf-8"), + sign, + ) + + if not is_valid: + logger.warning("Alipay callback signature verification FAILED") + + return is_valid + + except ImportError: + logger.error("alipay-sdk-python not installed, skipping signature verification") + return True + except Exception: + logger.exception("Alipay callback verification error") + return False + + +# --------------------------------------------------------------------------- +# WeChat callback verification (stub) +# --------------------------------------------------------------------------- + + +async def verify_wechat_callback(data: dict, db: AsyncSession) -> bool: """Verify WeChat payment callback signature.""" - if settings.PAYMENT_MOCK: + db_configs = await _get_payment_configs(db) + mock_mode = _is_mock_mode(db_configs) + if mock_mode: return True - # Real verification would use WECHAT_API_KEY to verify signature logger.info("WeChat callback verification (real mode not implemented)") return True -async def verify_alipay_callback(data: dict) -> bool: - """Verify Alipay payment callback signature.""" - if settings.PAYMENT_MOCK: - return True - # Real verification would use ALIPAY_PUBLIC_KEY to verify signature - logger.info("Alipay callback verification (real mode not implemented)") - return True +# --------------------------------------------------------------------------- +# Process successful payment +# --------------------------------------------------------------------------- async def process_payment_success(db: AsyncSession, order_id: str): """Process successful payment: update order and add credits.""" - from sqlalchemy import select - result = await db.execute( select(PaymentOrder).where(PaymentOrder.id == order_id).limit(1) ) @@ -120,3 +327,35 @@ async def process_payment_success(db: AsyncSession, order_id: str): related_id=order.id, ) await db.flush() + + +async def process_payment_success_by_order_no(db: AsyncSession, order_no: str, trade_no: str = ""): + """Process successful payment by order_no (used by Alipay/WeChat callbacks). + + Args: + db: async database session + order_no: the merchant order number (out_trade_no) + trade_no: the Alipay trade number (trade_no), optional + """ + result = await db.execute( + select(PaymentOrder).where(PaymentOrder.order_no == order_no).limit(1) + ) + order = result.scalar_one_or_none() + if not order or order.status != "pending": + logger.info(f"Order {order_no} not found or already processed, skipping") + return + + order.status = "paid" + order.paid_at = datetime.now() + if trade_no: + order.trade_no = trade_no + + await add_credits( + db, + order.user_id, + order.credits, + f"充值成功({order.credits}积分)", + related_id=order.id, + ) + await db.flush() + logger.info(f"Payment success processed: order_no={order_no}, trade_no={trade_no}") diff --git a/video-gen-api/pyproject.toml b/video-gen-api/pyproject.toml index ff8435bb..77da0839 100644 --- a/video-gen-api/pyproject.toml +++ b/video-gen-api/pyproject.toml @@ -22,6 +22,8 @@ dependencies = [ pg = ["asyncpg>=0.30.0"] redis = ["redis>=5.2.0"] celery = ["celery>=5.4.0", "redis>=5.2.0"] +alipay = ["alipay-sdk-python>=3.7.1160"] +volc = ["volcengine-python-sdk>=1.1.0"] dev = [ "pytest>=8.3.0", "pytest-asyncio>=0.24.0", diff --git a/video-gen-app/src/api/index.ts b/video-gen-app/src/api/index.ts index 762389f1..fad28707 100644 --- a/video-gen-app/src/api/index.ts +++ b/video-gen-app/src/api/index.ts @@ -319,6 +319,14 @@ export async function getRechargePackages(): Promise { return api.get('/recharge-packages'); } +export async function createRechargeOrder(planId: string, method: string = 'wechat'): Promise { + return api.post('/payments/recharge', { plan: planId, method }); +} + +export async function getPaymentOrders(): Promise { + return api.get('/payments/orders'); +} + export async function getCreditRatios(): Promise { return api.get('/credits/ratios'); diff --git a/video-gen-app/src/components/Layout/AppLayout.tsx b/video-gen-app/src/components/Layout/AppLayout.tsx index 115834ff..aca844bc 100644 --- a/video-gen-app/src/components/Layout/AppLayout.tsx +++ b/video-gen-app/src/components/Layout/AppLayout.tsx @@ -1,5 +1,5 @@ -import React, { useEffect, useState, useMemo } from 'react'; -import { Layout, Avatar, Dropdown, Space, Modal, Form, Input, message, Tooltip, Tag, Button, Typography } from 'antd'; +import React, { useEffect, useState, useCallback, useRef } from 'react'; +import { Layout, Avatar, Dropdown, Space, Modal, Form, Input, message, Tooltip, Tag, Button, Typography, Radio } from 'antd'; import { QRCodeSVG } from 'qrcode.react'; import { PlayCircleOutlined, @@ -21,12 +21,13 @@ import { FireFilled, CrownFilled, BankFilled, - QrcodeOutlined, CloseOutlined, + WechatOutlined, + AlipayCircleOutlined, } from '@ant-design/icons'; import { Outlet, useNavigate, useLocation } from 'react-router-dom'; import { useAuthStore } from '../../store/useAuthStore'; -import { getMenuConfigs, getRechargePackages, getNotifications, markNotificationRead, getSiteInfo } from '../../api'; +import { getMenuConfigs, getRechargePackages, createRechargeOrder, getPaymentOrders, getNotifications, markNotificationRead, getSiteInfo } from '../../api'; import NotificationPopup from '../NotificationPopup'; interface MenuConfig { @@ -88,7 +89,10 @@ const AppLayout: React.FC = () => { const [siteName, setSiteName] = useState('VideoGen.AI'); const [siteLogo, setSiteLogo] = useState(''); const [qrCodeModalOpen, setQrCodeModalOpen] = useState(false); - const [currentPaymentInfo, setCurrentPaymentInfo] = useState<{ price: number; credits: number; qrCode: string } | null>(null); + const [currentPaymentInfo, setCurrentPaymentInfo] = useState<{ price: number; credits: number; qrCode: string; method: string } | null>(null); + const [paymentMethod, setPaymentMethod] = useState('alipay'); + const [paying, setPaying] = useState(false); + const pollingTimerRef = useRef | null>(null); // 监听预览弹窗状态,关闭浮动按钮 useEffect(() => { @@ -173,6 +177,43 @@ const AppLayout: React.FC = () => { setRechargeModalOpen(true); }; + const stopPolling = useCallback(() => { + if (pollingTimerRef.current) { + clearInterval(pollingTimerRef.current); + pollingTimerRef.current = null; + } + }, []); + + const startPolling = useCallback((orderNo: string) => { + stopPolling(); + let attempts = 0; + const maxAttempts = 120; // 2 minutes at 1s interval + const timer = setInterval(async () => { + attempts++; + if (attempts > maxAttempts) { + clearInterval(timer); + pollingTimerRef.current = null; + return; + } + try { + const orders = await getPaymentOrders(); + const order = orders.find((o: any) => o.order_no === orderNo); + if (order && order.status === 'paid') { + clearInterval(timer); + pollingTimerRef.current = null; + message.success('支付成功!积分已到账'); + useAuthStore.getState().refreshUser(); + setQrCodeModalOpen(false); + setCurrentPaymentInfo(null); + setSelectedPlan(null); + } + } catch { + // ignore polling errors + } + }, 1000); + pollingTimerRef.current = timer; + }, [stopPolling]); + return ( {/* Desktop Sidebar */} @@ -516,28 +557,64 @@ const AppLayout: React.FC = () => { ); })} -
+ + {/* Payment method selection */} +
+ 选择支付方式 + setPaymentMethod(e.target.value)} + style={{ display: 'flex', gap: 12 }}> + + + 支付宝 + + + + 微信支付 + + +
+ +
-
{/* Footer Buttons */} -
+
-