diff --git a/video-gen-admin/src/App.tsx b/video-gen-admin/src/App.tsx index cbf94893..5ee8b867 100644 --- a/video-gen-admin/src/App.tsx +++ b/video-gen-admin/src/App.tsx @@ -11,6 +11,7 @@ import AdminSettings from './pages/AdminSettings'; import AdminNotificationManager from './pages/AdminNotificationManager'; import AdminCreditRecords from './pages/AdminCreditRecords'; import AdminPaymentConfig from './pages/AdminPaymentConfig'; +import AdminPaymentStats from './pages/AdminPaymentStats'; import AdminIndustries from './pages/AdminIndustries'; import AdminVideoEngines from './pages/AdminVideoEngines'; import AdminImageEngines from './pages/AdminImageEngines'; @@ -75,6 +76,7 @@ const App = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/video-gen-admin/src/api/index.ts b/video-gen-admin/src/api/index.ts index cf274fea..6a8b5fa0 100644 --- a/video-gen-admin/src/api/index.ts +++ b/video-gen-admin/src/api/index.ts @@ -211,6 +211,22 @@ export async function batchUpdatePaymentConfigs(configs: Record) await api.put('/admin/payment-configs/batch', configs); } +export async function getPaymentStats(): Promise<{ + by_status: Record; + today: { paid_count: number; paid_amount: number }; + recent: any[]; +}> { + return api.get('/admin/payment-stats'); +} + +export async function getAdminPaymentOrders(params?: { method?: string; status?: string }): Promise<{ items: any[] }> { + const qs = new URLSearchParams(); + if (params?.method) qs.set('method', params.method); + if (params?.status) qs.set('status', params.status); + const suffix = qs.toString() ? `?${qs.toString()}` : ''; + return api.get(`/admin/payment-orders${suffix}`); +} + export async function getAdminNotifications(): Promise<{ total: number; items: any[] }> { return api.get('/admin/notifications'); } diff --git a/video-gen-admin/src/pages/AdminPaymentConfig.tsx b/video-gen-admin/src/pages/AdminPaymentConfig.tsx index 6ca418d8..63b14202 100644 --- a/video-gen-admin/src/pages/AdminPaymentConfig.tsx +++ b/video-gen-admin/src/pages/AdminPaymentConfig.tsx @@ -11,6 +11,7 @@ const AdminPaymentConfig: React.FC = () => { const [saving, setSaving] = useState(false); const [wechatEnabled, setWechatEnabled] = useState(false); const [alipayEnabled, setAlipayEnabled] = useState(false); + const [mockMode, setMockMode] = useState(false); const [form] = Form.useForm(); const load = async () => { @@ -31,6 +32,7 @@ const AdminPaymentConfig: React.FC = () => { }); setWechatEnabled(map['payment_wechat_enabled'] === 'true'); setAlipayEnabled(map['payment_alipay_enabled'] === 'true'); + setMockMode(map['payment_mock'] === 'true'); } catch { message.error('加载支付配置失败'); } @@ -43,6 +45,7 @@ const AdminPaymentConfig: React.FC = () => { const values = await form.validateFields(); setSaving(true); await batchUpdatePaymentConfigs({ + payment_mock: String(mockMode), payment_wechat_enabled: String(wechatEnabled), payment_wechat_mch_id: values.wechat_mch_id || '', payment_wechat_api_key: values.wechat_api_key || '', @@ -66,6 +69,27 @@ const AdminPaymentConfig: React.FC = () => { return (
+ {/* Mock Mode Toggle */} + +
+
+
+
+ 模拟支付模式 + + {mockMode ? '⚠️ 开启后所有充值会直接成功(仅用于测试)' : '关闭 - 使用真实支付渠道'} + +
+
+ +
+
+ {/* WeChat Pay */}
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 = () => {