diff --git a/video-gen-api/app/api/v1/admin.py b/video-gen-api/app/api/v1/admin.py index c71df8da..958fda78 100644 --- a/video-gen-api/app/api/v1/admin.py +++ b/video-gen-api/app/api/v1/admin.py @@ -448,6 +448,13 @@ async def get_payment_stats( """Return payment statistics for admin dashboard.""" from sqlalchemy import func + # Ensure by_status has all expected statuses with defaults + by_status = { + "pending": {"count": 0, "amount": 0.0}, + "paid": {"count": 0, "amount": 0.0}, + "cancelled": {"count": 0, "amount": 0.0}, + } + # Status breakdown status_result = await db.execute( select( @@ -456,12 +463,22 @@ async def get_payment_stats( 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": round(float(row.amount), 2)} + if row.status in by_status: + by_status[row.status] = { + "count": row.count, + "amount": round(float(row.amount), 2) + } + else: + # Map any unexpected status to cancelled + by_status["cancelled"]["count"] += row.count + by_status["cancelled"]["amount"] += round(float(row.amount), 2) - # Today's stats - today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + # Today's stats (CST time zone) + now_cst = datetime.now(CST) + today_start = now_cst.replace(hour=0, minute=0, second=0, microsecond=0) + today_end = today_start + timedelta(days=1) + today_result = await db.execute( select( func.count().label("paid_count"), @@ -469,6 +486,7 @@ async def get_payment_stats( ).where( PaymentOrder.status == "paid", PaymentOrder.paid_at >= today_start, + PaymentOrder.paid_at < today_end, ) ) today_row = today_result.one() @@ -495,7 +513,7 @@ async def get_payment_stats( "amount": round(o.amount, 2), "credits": round(o.credits, 2), "payment_method": o.payment_method, - "status": o.status, + "status": o.status if o.status in ("pending", "paid", "cancelled") else "cancelled", "trade_no": o.trade_no, "paid_at": _iso(o.paid_at), "created_at": _iso(o.created_at), @@ -547,7 +565,7 @@ async def get_admin_payment_orders( "amount": round(o.amount, 2), "credits": round(o.credits, 2), "payment_method": o.payment_method, - "status": o.status, + "status": o.status if o.status in ("pending", "paid", "cancelled") else "cancelled", "trade_no": o.trade_no, "paid_at": _iso(o.paid_at), "created_at": _iso(o.created_at), diff --git a/video-gen-api/app/api/v1/payments.py b/video-gen-api/app/api/v1/payments.py index aba271db..970c9cfe 100644 --- a/video-gen-api/app/api/v1/payments.py +++ b/video-gen-api/app/api/v1/payments.py @@ -25,7 +25,10 @@ router = APIRouter(prefix="/payments", tags=["payments"]) @router.get("/methods") -async def get_payment_methods(db: AsyncSession = Depends(get_db)): +async def get_payment_methods( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): """Return which payment methods are enabled (from admin config).""" from app.services.payment import _get_payment_configs configs = await _get_payment_configs(db) @@ -133,6 +136,29 @@ async def list_orders( return orders +@router.get("/orders/{order_no}", response_model=PaymentOrderOut) +async def get_order( + order_no: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + from app.services.payment import _check_and_expire_order + 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="订单不存在") + # Auto-expire if needed + await _check_and_expire_order(db, order) + return order + + @router.post("/orders/{order_no}/cancel") async def cancel_order( order_no: str, diff --git a/video-gen-app/src/api/index.ts b/video-gen-app/src/api/index.ts index d4f956bc..48133ccf 100644 --- a/video-gen-app/src/api/index.ts +++ b/video-gen-app/src/api/index.ts @@ -331,6 +331,10 @@ export async function getPaymentOrders(): Promise { return api.get('/payments/orders'); } +export async function getPaymentOrder(orderNo: string): Promise { + return api.get(`/payments/orders/${orderNo}`); +} + export async function cancelPaymentOrder(orderNo: string): Promise { return api.post(`/payments/orders/${orderNo}/cancel`); } diff --git a/video-gen-app/src/components/Layout/AppLayout.tsx b/video-gen-app/src/components/Layout/AppLayout.tsx index eb0f58f9..c19e1424 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, cancelPaymentOrder, getNotifications, markNotificationRead, getSiteInfo } from '../../api'; +import { getMenuConfigs, getRechargePackages, getPaymentMethods, createRechargeOrder, getPaymentOrder, cancelPaymentOrder, getNotifications, markNotificationRead, getSiteInfo } from '../../api'; import NotificationPopup from '../NotificationPopup'; interface MenuConfig { @@ -97,6 +97,9 @@ const AppLayout: React.FC = () => { const countdownTimerRef = useRef | null>(null); const currentOrderNoRef = useRef(null); const [enabledMethods, setEnabledMethods] = useState<{ alipay: boolean; wechat: boolean }>({ alipay: false, wechat: false }); + + // LocalStorage keys + const PENDING_ORDER_KEY = 'pending_payment_order'; // 监听预览弹窗状态,关闭浮动按钮 useEffect(() => { @@ -122,6 +125,57 @@ const AppLayout: React.FC = () => { }).catch(() => {}); }; + // 检查并恢复待处理的支付订单 + useEffect(() => { + const checkPendingOrder = async () => { + const savedOrderStr = localStorage.getItem(PENDING_ORDER_KEY); + if (savedOrderStr) { + try { + const savedOrder = JSON.parse(savedOrderStr); + // 查询订单状态 + const order = await getPaymentOrder(savedOrder.orderNo); + if (order.status === 'pending') { + // 订单仍然待支付,恢复弹窗 + setCurrentPaymentInfo({ + price: savedOrder.price, + credits: savedOrder.credits, + qrCode: savedOrder.qrCode, + method: savedOrder.method, + }); + currentOrderNoRef.current = savedOrder.orderNo; + // 计算剩余时间 + const now = Date.now(); + const createdAt = new Date(savedOrder.createdAt).getTime(); + const timeoutSeconds = savedOrder.timeoutSeconds || 180; + const elapsedSeconds = Math.floor((now - createdAt) / 1000); + const remainingSeconds = Math.max(0, timeoutSeconds - elapsedSeconds); + + if (remainingSeconds > 0) { + setQrCodeModalOpen(true); + startPolling(savedOrder.orderNo, remainingSeconds); + } else { + // 已超时,清除 + localStorage.removeItem(PENDING_ORDER_KEY); + } + } else if (order.status === 'paid') { + // 已支付 + message.success('支付成功!积分已到账'); + useAuthStore.getState().refreshUser(); + localStorage.removeItem(PENDING_ORDER_KEY); + } else { + // 订单已取消或其他状态,清除 + localStorage.removeItem(PENDING_ORDER_KEY); + } + } catch { + // 查询失败,清除 + localStorage.removeItem(PENDING_ORDER_KEY); + } + } + }; + + checkPendingOrder(); + }, []); + useEffect(() => { getMenuConfigs().then(data => { let items = data.filter((m: any) => m.is_active !== false && m.isActive !== false); @@ -202,22 +256,23 @@ const AppLayout: React.FC = () => { stopPolling(); setCountdown(timeoutSeconds); - // 订单状态轮询(每2秒查询一次,减少请求频率 + // 订单状态轮询(每2秒查询一次,只查询当前订单 const pollingTimer = setInterval(async () => { try { - const orders = await getPaymentOrders(); - const order = orders.find((o: any) => o.orderNo === orderNo); - if (order && order.status === 'paid') { + const order = await getPaymentOrder(orderNo); + if (order.status === 'paid') { stopPolling(); currentOrderNoRef.current = null; + localStorage.removeItem(PENDING_ORDER_KEY); message.success('支付成功!积分已到账'); useAuthStore.getState().refreshUser(); setQrCodeModalOpen(false); setCurrentPaymentInfo(null); setSelectedPlan(null); - } else if (order && order.status === 'cancelled') { + } else if (order.status === 'cancelled') { stopPolling(); currentOrderNoRef.current = null; + localStorage.removeItem(PENDING_ORDER_KEY); } } catch { // ignore polling errors @@ -235,6 +290,7 @@ const AppLayout: React.FC = () => { cancelPaymentOrder(currentOrderNoRef.current).catch(() => {}); currentOrderNoRef.current = null; } + localStorage.removeItem(PENDING_ORDER_KEY); message.warning('订单已超时,请重新充值'); setQrCodeModalOpen(false); setCurrentPaymentInfo(null); @@ -639,15 +695,28 @@ const AppLayout: React.FC = () => { const order = await createRechargeOrder(plan.id, paymentMethod); if (order.paymentMethod === 'alipay' && order.qrUrl) { // Alipay: show the real QR code URL from the backend - setCurrentPaymentInfo({ + const paymentInfo = { price: plan.price, credits: totalCredits, qrCode: order.qrUrl, method: 'alipay', - }); + }; + setCurrentPaymentInfo(paymentInfo); setRechargeModalOpen(false); setQrCodeModalOpen(true); currentOrderNoRef.current = order.orderNo; + + // 保存到 localStorage + localStorage.setItem(PENDING_ORDER_KEY, JSON.stringify({ + orderNo: order.orderNo, + price: plan.price, + credits: totalCredits, + qrCode: order.qrUrl, + method: 'alipay', + createdAt: order.createdAt || new Date().toISOString(), + timeoutSeconds: 180, + })); + // Start polling for payment status startPolling(order.orderNo); } else { @@ -684,6 +753,7 @@ const AppLayout: React.FC = () => { try { await cancelPaymentOrder(currentOrderNoRef.current); } catch {} currentOrderNoRef.current = null; } + localStorage.removeItem(PENDING_ORDER_KEY); setQrCodeModalOpen(false); setCurrentPaymentInfo(null); }} @@ -809,6 +879,7 @@ const AppLayout: React.FC = () => { try { await cancelPaymentOrder(currentOrderNoRef.current); } catch {} currentOrderNoRef.current = null; } + localStorage.removeItem(PENDING_ORDER_KEY); setQrCodeModalOpen(false); setCurrentPaymentInfo(null); setSelectedPlan(null);