diff --git a/video-gen-admin/src/pages/AdminPaymentStats.tsx b/video-gen-admin/src/pages/AdminPaymentStats.tsx
new file mode 100644
index 00000000..c76030e2
--- /dev/null
+++ b/video-gen-admin/src/pages/AdminPaymentStats.tsx
@@ -0,0 +1,193 @@
+import React, { useEffect, useState } from 'react';
+import {
+ Card, Col, Row, Space, Table, Tag, Typography, Statistic, message,
+} from 'antd';
+import {
+ DollarOutlined, CheckCircleOutlined, ClockCircleOutlined, CloseCircleOutlined,
+} from '@ant-design/icons';
+import { getPaymentStats } from '../api';
+import { formatDate } from '../utils/formatDate';
+
+const AdminPaymentStats: React.FC = () => {
+ const [loading, setLoading] = useState(false);
+ const [stats, setStats] = useState
(null);
+
+ const load = async () => {
+ try {
+ setLoading(true);
+ const data = await getPaymentStats();
+ setStats(data);
+ } catch {
+ message.error('加载支付统计失败');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => { load(); }, []);
+
+ const statusConfig: Record = {
+ paid: { color: 'green', label: '已支付', icon: },
+ pending: { color: 'gold', label: '待支付', icon: },
+ cancelled: { color: 'default', label: '已取消', icon: },
+ };
+
+ const methodConfig: Record = {
+ alipay: { color: 'blue', label: '支付宝' },
+ wechat: { color: 'green', label: '微信' },
+ };
+
+ const columns = [
+ { title: '订单号', dataIndex: 'order_no', key: 'order_no', width: 200 },
+ {
+ title: '支付方式', dataIndex: 'payment_method', key: 'payment_method', width: 100,
+ render: (m: string) => {
+ const c = methodConfig[m] || { color: 'default', label: m };
+ return {c.label};
+ },
+ },
+ {
+ title: '金额', dataIndex: 'amount', key: 'amount', width: 100,
+ render: (a: number) => ¥{a.toFixed(2)},
+ },
+ { title: '积分', dataIndex: 'credits', key: 'credits', width: 80 },
+ {
+ title: '状态', dataIndex: 'status', key: 'status', width: 100,
+ render: (s: string) => {
+ const c = statusConfig[s] || { color: 'default', label: s, icon: null };
+ return {c.label};
+ },
+ },
+ { title: '支付宝交易号', dataIndex: 'trade_no', key: 'trade_no', width: 200, render: (v: string) => v || '-' },
+ {
+ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 160,
+ render: (d: string) => {d ? formatDate(d) : '-'},
+ },
+ {
+ title: '支付时间', dataIndex: 'paid_at', key: 'paid_at', width: 160,
+ render: (d: string) => {d ? formatDate(d) : '-'},
+ },
+ ];
+
+ if (!stats) {
+ return 加载中…
;
+ }
+
+ const paidInfo = stats.by_status?.paid || { count: 0, amount: 0 };
+ const pendingInfo = stats.by_status?.pending || { count: 0, amount: 0 };
+ const cancelledInfo = stats.by_status?.cancelled || { count: 0, amount: 0 };
+ const totalOrders = paidInfo.count + pendingInfo.count + cancelledInfo.count;
+
+ return (
+
+ {/* Summary cards */}
+
+
+
+ }
+ suffix="元"
+ valueStyle={{ color: '#10b981', fontWeight: 700 }}
+ />
+
+ {stats.today.paid_count} 笔订单
+
+
+
+
+
+ }
+ suffix="元"
+ valueStyle={{ color: '#6366f1', fontWeight: 700 }}
+ />
+
+ {paidInfo.count} 笔订单
+
+
+
+
+
+ }
+ suffix="笔"
+ valueStyle={{ color: '#f59e0b', fontWeight: 700 }}
+ />
+
+ ¥{pendingInfo.amount.toFixed(2)} 待付
+
+
+
+
+
+ }
+ suffix="笔"
+ valueStyle={{ color: '#94a3b8', fontWeight: 700 }}
+ />
+
+ ¥{cancelledInfo.amount.toFixed(2)} 已取消
+
+
+
+
+
+ {/* Status breakdown */}
+
订单状态分布}>
+
+ {['paid', 'pending', 'cancelled'].map(s => {
+ const info = stats.by_status?.[s] || { count: 0, amount: 0 };
+ const c = statusConfig[s];
+ const pct = totalOrders > 0 ? ((info.count / totalOrders) * 100).toFixed(1) : '0.0';
+ return (
+
+
+
+ {c.label}
+ {pct}%
+
+
+ {info.count} 笔
+
+
+ ¥{info.amount.toFixed(2)}
+
+
+
+ );
+ })}
+
+
+
+ {/* Recent orders table */}
+
最近 50 笔订单}>
+
+
+
+ );
+};
+
+export default AdminPaymentStats;
diff --git a/video-gen-admin/src/types/index.ts b/video-gen-admin/src/types/index.ts
index 5fe99196..e3535391 100644
--- a/video-gen-admin/src/types/index.ts
+++ b/video-gen-admin/src/types/index.ts
@@ -117,6 +117,26 @@ export interface AdminStats {
creditsConsumedToday: number;
}
+export interface PaymentStats {
+ by_status: Record;
+ today: { paid_count: number; paid_amount: number };
+ recent: PaymentOrder[];
+}
+
+export interface PaymentOrder {
+ id: string;
+ order_no: string;
+ user_id: string;
+ user?: { username: string };
+ amount: number;
+ credits: number;
+ payment_method: string;
+ status: string;
+ trade_no?: string;
+ paid_at?: string;
+ created_at: string;
+}
+
export interface ModelConfig {
id: string;
name: string;
diff --git a/video-gen-api/alembic/versions/a1b2c3d4e5f6_add_refund_fields_to_payment_orders.py b/video-gen-api/alembic/versions/a1b2c3d4e5f6_add_refund_fields_to_payment_orders.py
new file mode 100644
index 00000000..69f81b00
--- /dev/null
+++ b/video-gen-api/alembic/versions/a1b2c3d4e5f6_add_refund_fields_to_payment_orders.py
@@ -0,0 +1,29 @@
+"""add_refund_fields_to_payment_orders
+
+Revision ID: a1b2c3d4e5f6
+Revises: ed59aefc83da
+Create Date: 2026-06-10 12:00:00.000000
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = 'a1b2c3d4e5f6'
+down_revision: Union[str, None] = 'ed59aefc83da'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column('payment_orders', sa.Column('refund_trade_no', sa.String(length=128), nullable=True))
+ op.add_column('payment_orders', sa.Column('refunded_at', sa.DateTime(timezone=True), nullable=True))
+ op.add_column('payment_orders', sa.Column('refund_amount', sa.Float(), nullable=True))
+
+
+def downgrade() -> None:
+ op.drop_column('payment_orders', 'refund_amount')
+ op.drop_column('payment_orders', 'refunded_at')
+ op.drop_column('payment_orders', 'refund_trade_no')
diff --git a/video-gen-api/app/api/v1/admin.py b/video-gen-api/app/api/v1/admin.py
index 327cced3..1bd79e7f 100644
--- a/video-gen-api/app/api/v1/admin.py
+++ b/video-gen-api/app/api/v1/admin.py
@@ -440,6 +440,123 @@ async def batch_update_payment_configs(
return {"ok": True}
+@router.get("/payment-stats")
+async def get_payment_stats(
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Return payment statistics for admin dashboard."""
+ from sqlalchemy import func
+
+ # Status breakdown
+ status_result = await db.execute(
+ select(
+ PaymentOrder.status,
+ func.count().label("count"),
+ func.coalesce(func.sum(PaymentOrder.amount), 0).label("amount"),
+ ).group_by(PaymentOrder.status)
+ )
+ by_status = {}
+ for row in status_result.all():
+ by_status[row.status] = {"count": row.count, "amount": float(row.amount)}
+
+ # Today's stats
+ today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
+ today_result = await db.execute(
+ select(
+ func.count().label("paid_count"),
+ func.coalesce(func.sum(PaymentOrder.amount), 0).label("paid_amount"),
+ ).where(
+ PaymentOrder.status == "paid",
+ PaymentOrder.paid_at >= today_start,
+ )
+ )
+ today_row = today_result.one()
+
+ # Recent 50 orders
+ recent_result = await db.execute(
+ select(PaymentOrder)
+ .order_by(PaymentOrder.created_at.desc())
+ .limit(50)
+ )
+ recent = recent_result.scalars().all()
+
+ return {
+ "by_status": by_status,
+ "today": {
+ "paid_count": today_row.paid_count,
+ "paid_amount": float(today_row.paid_amount),
+ },
+ "recent": [
+ {
+ "id": o.id,
+ "order_no": o.order_no,
+ "user_id": o.user_id,
+ "amount": o.amount,
+ "credits": o.credits,
+ "payment_method": o.payment_method,
+ "status": o.status,
+ "trade_no": o.trade_no,
+ "paid_at": o.paid_at.isoformat() if o.paid_at else None,
+ "created_at": o.created_at.isoformat() if o.created_at else None,
+ }
+ for o in recent
+ ],
+ }
+
+
+@router.get("/payment-orders")
+async def get_admin_payment_orders(
+ method: str | None = None,
+ status: str | None = None,
+ page: int = 1,
+ page_size: int = 20,
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Return paginated payment orders for admin."""
+ query = select(PaymentOrder)
+ if method:
+ query = query.where(PaymentOrder.payment_method == method)
+ if status:
+ query = query.where(PaymentOrder.status == status)
+
+ # Count total
+ count_result = await db.execute(
+ select(func.count()).select_from(query.subquery())
+ )
+ total = count_result.scalar() or 0
+
+ # Paginated results
+ result = await db.execute(
+ query.order_by(PaymentOrder.created_at.desc())
+ .offset((page - 1) * page_size)
+ .limit(page_size)
+ )
+ orders = result.scalars().all()
+
+ return {
+ "total": total,
+ "page": page,
+ "page_size": page_size,
+ "items": [
+ {
+ "id": o.id,
+ "order_no": o.order_no,
+ "user_id": o.user_id,
+ "amount": o.amount,
+ "credits": o.credits,
+ "payment_method": o.payment_method,
+ "status": o.status,
+ "trade_no": o.trade_no,
+ "paid_at": o.paid_at.isoformat() if o.paid_at else None,
+ "created_at": o.created_at.isoformat() if o.created_at else None,
+ }
+ for o in orders
+ ],
+ }
+
+
@router.put("/payment-configs/{config_id}")
async def update_payment_config(
config_id: str,
@@ -1250,3 +1367,76 @@ async def admin_generate_video(
await db.flush()
return {"message": "ok", "record_id": record_id}
+
+
+# ── Payment Stats ────────────────────────────────────────
+
+@router.get("/payment-stats")
+async def get_payment_stats(
+ admin: User = Depends(get_admin_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Payment statistics for admin dashboard."""
+ from app.models.payment_order import PaymentOrder
+ from datetime import datetime
+
+ # Count and revenue by status
+ rows = (await db.execute(
+ select(
+ PaymentOrder.status,
+ PaymentOrder.payment_method,
+ func.count(PaymentOrder.id).label("count"),
+ func.coalesce(func.sum(PaymentOrder.amount), 0).label("total_amount"),
+ ).group_by(PaymentOrder.status, PaymentOrder.payment_method)
+ )).all()
+
+ by_status: dict[str, dict] = {}
+ for r in rows:
+ s = r.status
+ if s not in by_status:
+ by_status[s] = {"count": 0, "amount": 0.0}
+ by_status[s]["count"] += r.count
+ by_status[s]["amount"] += float(r.total_amount)
+
+ # Recent orders (last 50)
+ recent = (await db.execute(
+ select(PaymentOrder)
+ .order_by(PaymentOrder.created_at.desc())
+ .limit(50)
+ )).scalars().all()
+
+ # Today stats
+ today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
+ today_paid = (await db.execute(
+ select(
+ func.count(PaymentOrder.id),
+ func.coalesce(func.sum(PaymentOrder.amount), 0),
+ ).where(
+ PaymentOrder.status == "paid",
+ PaymentOrder.paid_at >= today_start,
+ )
+ )).first()
+ today_count, today_amount = (today_paid or (0, 0))
+
+ return {
+ "by_status": by_status,
+ "today": {
+ "paid_count": int(today_count or 0),
+ "paid_amount": float(today_amount or 0),
+ },
+ "recent": [
+ {
+ "id": o.id,
+ "order_no": o.order_no,
+ "user_id": o.user_id,
+ "amount": o.amount,
+ "credits": o.credits,
+ "payment_method": o.payment_method,
+ "status": o.status,
+ "trade_no": o.trade_no,
+ "created_at": _iso(o.created_at),
+ "paid_at": _iso(o.paid_at),
+ }
+ for o in recent
+ ],
+ }
diff --git a/video-gen-api/app/api/v1/payments.py b/video-gen-api/app/api/v1/payments.py
index 4fc6f89a..e6fb4e57 100644
--- a/video-gen-api/app/api/v1/payments.py
+++ b/video-gen-api/app/api/v1/payments.py
@@ -89,7 +89,10 @@ async def wechat_callback(request: Request, db: AsyncSession = Depends(get_db)):
async def alipay_callback(request: Request, db: AsyncSession = Depends(get_db)):
form_data = await request.form()
data = dict(form_data)
- logger.info(f"Alipay callback received: {list(data.keys())}")
+ logger.info(
+ f"ALIPAY_CALLBACK order_no={data.get('out_trade_no')} "
+ f"trade_no={data.get('trade_no', '')} status={data.get('trade_status', '')}"
+ )
# Verify signature first
if not await verify_alipay_callback(data, db):
@@ -114,9 +117,40 @@ async def list_orders(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
+ # Auto-expire stale pending orders before returning
+ from app.services.payment import _check_and_expire_order
result = await db.execute(
select(PaymentOrder)
.where(PaymentOrder.user_id == current_user.id)
.order_by(PaymentOrder.created_at.desc())
)
- return result.scalars().all()
+ orders = result.scalars().all()
+ for o in orders:
+ await _check_and_expire_order(db, o)
+ return orders
+
+
+@router.post("/orders/{order_no}/cancel")
+async def cancel_order(
+ order_no: str,
+ current_user: User = Depends(get_current_user),
+ db: AsyncSession = Depends(get_db),
+):
+ """Cancel a pending order. Only the order owner can cancel, only if still pending."""
+ result = await db.execute(
+ select(PaymentOrder).where(
+ PaymentOrder.order_no == order_no,
+ PaymentOrder.user_id == current_user.id,
+ ).limit(1)
+ )
+ order = result.scalar_one_or_none()
+ if not order:
+ raise HTTPException(status_code=404, detail="订单不存在")
+ if order.status != "pending":
+ raise HTTPException(status_code=400, detail=f"订单状态为{order.status},无法取消")
+ order.status = "cancelled"
+ await db.flush()
+ logger.info(
+ f"ORDER_CANCELLED order_no={order_no} user={current_user.id} amount={order.amount}"
+ )
+ return {"ok": True}
diff --git a/video-gen-api/app/config.py b/video-gen-api/app/config.py
index 3118204d..6ba4706b 100644
--- a/video-gen-api/app/config.py
+++ b/video-gen-api/app/config.py
@@ -57,7 +57,7 @@ class Settings(BaseSettings):
ALIPAY_PRIVATE_KEY: str = ""
ALIPAY_PUBLIC_KEY: str = ""
ALIPAY_NOTIFY_URL: str = ""
- PAYMENT_MOCK: bool = True
+ PAYMENT_MOCK: bool = False # Default off; use admin panel to enable for testing
STORAGE_TYPE: str = "local"
STORAGE_LOCAL_PATH: str = "./storage/generate/videos"
diff --git a/video-gen-api/app/main.py b/video-gen-api/app/main.py
index 1ceeb94a..bc89bb65 100644
--- a/video-gen-api/app/main.py
+++ b/video-gen-api/app/main.py
@@ -21,6 +21,48 @@ from app.services.log_config import decrypt_data
logging.basicConfig(level=logging.INFO if settings.DEBUG else logging.WARNING)
+def _setup_payment_logger():
+ """Configure a dedicated file logger for payment events.
+
+ Logs are written to logs/payment_YYYY-MM-DD.log, rotated daily.
+ 30 days of history are retained.
+ """
+ from logging.handlers import TimedRotatingFileHandler
+ log_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "logs")
+ os.makedirs(log_dir, exist_ok=True)
+ log_file = os.path.join(log_dir, "payment.log")
+
+ payment_logger = logging.getLogger("payment")
+ payment_logger.setLevel(logging.INFO)
+ payment_logger.propagate = False # don't double-log to root
+
+ # Avoid adding duplicate handlers on reload
+ if any(getattr(h, "_payment_file", False) for h in payment_logger.handlers):
+ return
+
+ handler = TimedRotatingFileHandler(
+ log_file,
+ when="midnight",
+ interval=1,
+ backupCount=30,
+ encoding="utf-8",
+ utc=False, # use local time
+ )
+ handler.suffix = "%Y-%m-%d" # files named like payment.log.2026-06-10
+ handler._payment_file = True # type: ignore[attr-defined]
+ handler.setFormatter(logging.Formatter(
+ "%(asctime)s [%(levelname)s] %(message)s",
+ datefmt="%Y-%m-%d %H:%M:%S",
+ ))
+ payment_logger.addHandler(handler)
+ # Mirror to console in DEBUG mode
+ if settings.DEBUG:
+ payment_logger.addHandler(logging.StreamHandler())
+
+
+_setup_payment_logger()
+
+
@asynccontextmanager
async def lifespan(app: FastAPI):
from app.models import async_session
@@ -35,13 +77,31 @@ async def lifespan(app: FastAPI):
from app.services.video_queue import task_queue
await task_queue.recover()
queue_task = asyncio.create_task(task_queue.run())
-
+
+ # Background task: auto-expire pending payment orders
+ async def _order_expiry_loop():
+ from app.services.payment import expire_all_pending_orders
+ from logging import getLogger
+ bg_logger = getLogger("payment")
+ while True:
+ try:
+ async with async_session() as db:
+ n = await expire_all_pending_orders(db)
+ if n > 0:
+ bg_logger.info(f"Auto-expired {n} pending payment order(s)")
+ except Exception as e:
+ bg_logger.error(f"Order expiry loop error: {e}")
+ await asyncio.sleep(60) # check every minute
+
+ expiry_task = asyncio.create_task(_order_expiry_loop())
+
app.state.db_session_factory = async_session
yield
task_queue.stop()
await queue_task
+ expiry_task.cancel()
await close_database()
await close_redis()
diff --git a/video-gen-api/app/models/payment_order.py b/video-gen-api/app/models/payment_order.py
index 0e240d4a..d378d7ee 100644
--- a/video-gen-api/app/models/payment_order.py
+++ b/video-gen-api/app/models/payment_order.py
@@ -22,3 +22,9 @@ class PaymentOrder(Base, TimestampMixin):
DateTime(timezone=True), nullable=True
)
trade_no: Mapped[str | None] = mapped_column(String(128), nullable=True)
+ # Refund fields
+ refund_trade_no: Mapped[str | None] = mapped_column(String(128), nullable=True)
+ refunded_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True), nullable=True
+ )
+ refund_amount: Mapped[float | None] = mapped_column(Float, nullable=True)
diff --git a/video-gen-api/app/services/payment.py b/video-gen-api/app/services/payment.py
index e09d2ba5..2559586f 100644
--- a/video-gen-api/app/services/payment.py
+++ b/video-gen-api/app/services/payment.py
@@ -1,5 +1,7 @@
import logging
-from datetime import datetime
+import os
+from datetime import datetime, timedelta
+from logging.handlers import TimedRotatingFileHandler
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -10,7 +12,32 @@ 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")
+# ---------------------------------------------------------------------------
+# Payment logger → logs/payment/YYYY-MM-DD.log (one file per day, keep 30 days)
+# ---------------------------------------------------------------------------
+logger = logging.getLogger("payment")
+logger.setLevel(logging.INFO)
+
+_log_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "logs", "payment")
+os.makedirs(_log_dir, exist_ok=True)
+
+_file_handler = TimedRotatingFileHandler(
+ os.path.join(_log_dir, "payment.log"),
+ when="midnight",
+ interval=1,
+ backupCount=0,
+ encoding="utf-8",
+ utc=False,
+)
+_file_handler.suffix = "%Y-%m-%d"
+_file_handler.setFormatter(logging.Formatter(
+ "[%(asctime)s] %(levelname)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
+))
+if not logger.handlers:
+ logger.addHandler(_file_handler)
+
+# Orders pending payment for longer than this are auto-cancelled
+ORDER_EXPIRE_MINUTES = 5
# ---------------------------------------------------------------------------
@@ -26,6 +53,46 @@ async def _get_payment_configs(db: AsyncSession) -> dict[str, str]:
return {c.key: c.value for c in result.scalars().all()}
+async def _check_and_expire_order(db: AsyncSession, order: PaymentOrder) -> bool:
+ """If a pending order has passed its expiry, mark it cancelled.
+ Returns True if the order was expired.
+ """
+ if order.status != "pending":
+ return False
+ expiry = order.created_at + timedelta(minutes=ORDER_EXPIRE_MINUTES)
+ if datetime.now(order.created_at.tzinfo) >= expiry:
+ order.status = "cancelled"
+ await db.flush()
+ logger.info(
+ f"ORDER_EXPIRED order_no={order.order_no} user={order.user_id} "
+ f"amount={order.amount} created_at={order.created_at.isoformat()}"
+ )
+ return True
+ return False
+
+
+async def expire_all_pending_orders(db: AsyncSession) -> int:
+ """Background task: mark all expired pending orders as cancelled.
+ Returns the number of orders expired.
+ """
+ threshold = datetime.now() - timedelta(minutes=ORDER_EXPIRE_MINUTES)
+ result = await db.execute(
+ select(PaymentOrder).where(
+ PaymentOrder.status == "pending",
+ PaymentOrder.created_at <= threshold,
+ )
+ )
+ orders = result.scalars().all()
+ for o in orders:
+ o.status = "cancelled"
+ logger.info(
+ f"ORDER_EXPIRED order_no={o.order_no} user={o.user_id} amount={o.amount}"
+ )
+ if orders:
+ await db.flush()
+ return len(orders)
+
+
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", "")
@@ -125,6 +192,10 @@ async def create_recharge_order(
)
db.add(order)
await db.flush()
+ logger.info(
+ f"ORDER_CREATED order_no={order.order_no} user={user_id} "
+ f"amount={price} credits={total_credits} method={method} mock={mock_mode}"
+ )
if mock_mode:
# Mock: immediately complete payment
@@ -371,4 +442,7 @@ async def process_payment_success_by_order_no(db: AsyncSession, order_no: str, t
related_id=order.id,
)
await db.flush()
- logger.info(f"Payment success processed: order_no={order_no}, trade_no={trade_no}")
+ logger.info(
+ f"PAYMENT_SUCCESS order_no={order_no} user={order.user_id} "
+ f"amount={order.amount} credits={order.credits} trade_no={trade_no}"
+ )
diff --git a/video-gen-app/src/api/index.ts b/video-gen-app/src/api/index.ts
index 08635b16..84103f5d 100644
--- a/video-gen-app/src/api/index.ts
+++ b/video-gen-app/src/api/index.ts
@@ -331,6 +331,11 @@ export async function getPaymentOrders(): Promise {
return api.get('/payments/orders');
}
+export async function cancelPaymentOrder(orderNo: string): Promise {
+ return api.post(`/payments/orders/${orderNo}/cancel`);
+}
+}
+
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 29d2e9c9..e4234010 100644
--- a/video-gen-app/src/components/Layout/AppLayout.tsx
+++ b/video-gen-app/src/components/Layout/AppLayout.tsx
@@ -27,7 +27,7 @@ import {
} from '@ant-design/icons';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../store/useAuthStore';
-import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrders, getNotifications, markNotificationRead, getSiteInfo } from '../../api';
+import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrders, cancelPaymentOrder, getNotifications, markNotificationRead, getSiteInfo } from '../../api';
import NotificationPopup from '../NotificationPopup';
interface MenuConfig {
@@ -93,6 +93,7 @@ const AppLayout: React.FC = () => {
const [paymentMethod, setPaymentMethod] = useState('alipay');
const [paying, setPaying] = useState(false);
const pollingTimerRef = useRef | null>(null);
+ const currentOrderNoRef = useRef(null);
const [enabledMethods, setEnabledMethods] = useState<{ alipay: boolean; wechat: boolean }>({ alipay: false, wechat: false });
// 监听预览弹窗状态,关闭浮动按钮
@@ -208,11 +209,16 @@ const AppLayout: React.FC = () => {
if (order && order.status === 'paid') {
clearInterval(timer);
pollingTimerRef.current = null;
+ currentOrderNoRef.current = null;
message.success('支付成功!积分已到账');
useAuthStore.getState().refreshUser();
setQrCodeModalOpen(false);
setCurrentPaymentInfo(null);
setSelectedPlan(null);
+ } else if (order && order.status === 'cancelled') {
+ clearInterval(timer);
+ pollingTimerRef.current = null;
+ currentOrderNoRef.current = null;
}
} catch {
// ignore polling errors
@@ -621,6 +627,7 @@ const AppLayout: React.FC = () => {
});
setRechargeModalOpen(false);
setQrCodeModalOpen(true);
+ currentOrderNoRef.current = order.order_no;
// Start polling for payment status
startPolling(order.order_no);
} else {
@@ -650,7 +657,16 @@ const AppLayout: React.FC = () => {
{/* QR Code Payment Modal */}
{ stopPolling(); setQrCodeModalOpen(false); setCurrentPaymentInfo(null); }}
+ onCancel={async () => {
+ stopPolling();
+ // Mark order as cancelled if it's still pending
+ if (currentOrderNoRef.current) {
+ try { await cancelPaymentOrder(currentOrderNoRef.current); } catch {}
+ currentOrderNoRef.current = null;
+ }
+ setQrCodeModalOpen(false);
+ setCurrentPaymentInfo(null);
+ }}
footer={null}
width={400}
closable={false}
@@ -750,7 +766,16 @@ const AppLayout: React.FC = () => {