diff --git a/video-gen-api/app/api/v1/admin.py b/video-gen-api/app/api/v1/admin.py
index 1bd79e7f..c71df8da 100644
--- a/video-gen-api/app/api/v1/admin.py
+++ b/video-gen-api/app/api/v1/admin.py
@@ -458,7 +458,7 @@ async def get_payment_stats(
)
by_status = {}
for row in status_result.all():
- by_status[row.status] = {"count": row.count, "amount": float(row.amount)}
+ by_status[row.status] = {"count": row.count, "amount": round(float(row.amount), 2)}
# Today's stats
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
@@ -485,20 +485,20 @@ async def get_payment_stats(
"by_status": by_status,
"today": {
"paid_count": today_row.paid_count,
- "paid_amount": float(today_row.paid_amount),
+ "paid_amount": round(float(today_row.paid_amount), 2),
},
"recent": [
{
"id": o.id,
"order_no": o.order_no,
"user_id": o.user_id,
- "amount": o.amount,
- "credits": o.credits,
+ "amount": round(o.amount, 2),
+ "credits": round(o.credits, 2),
"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,
+ "paid_at": _iso(o.paid_at),
+ "created_at": _iso(o.created_at),
}
for o in recent
],
@@ -544,13 +544,13 @@ async def get_admin_payment_orders(
"id": o.id,
"order_no": o.order_no,
"user_id": o.user_id,
- "amount": o.amount,
- "credits": o.credits,
+ "amount": round(o.amount, 2),
+ "credits": round(o.credits, 2),
"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,
+ "paid_at": _iso(o.paid_at),
+ "created_at": _iso(o.created_at),
}
for o in orders
],
@@ -1371,72 +1371,4 @@ async def admin_generate_video(
# ── 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 273c76cb..aba271db 100644
--- a/video-gen-api/app/api/v1/payments.py
+++ b/video-gen-api/app/api/v1/payments.py
@@ -16,6 +16,9 @@ from app.services.payment import (
verify_wechat_callback,
verify_alipay_callback,
process_payment_success_by_order_no,
+ _get_payment_configs,
+ _close_alipay_order,
+ _get_order_expire_seconds,
)
router = APIRouter(prefix="/payments", tags=["payments"])
@@ -148,6 +151,15 @@ async def cancel_order(
raise HTTPException(status_code=404, detail="订单不存在")
if order.status != "pending":
raise HTTPException(status_code=400, detail=f"订单状态为{order.status},无法取消")
+
+ # If it's an Alipay order, call close API first
+ if order.payment_method == "alipay":
+ db_configs = await _get_payment_configs(db)
+ try:
+ await _close_alipay_order(db, order, db_configs)
+ except Exception as e:
+ logger.exception(f"Failed to close Alipay order {order_no}: {e}")
+
order.status = "cancelled"
await db.flush()
logger.info(
diff --git a/video-gen-api/app/main.py b/video-gen-api/app/main.py
index 41a085f5..a9049055 100644
--- a/video-gen-api/app/main.py
+++ b/video-gen-api/app/main.py
@@ -36,14 +36,20 @@ async def lifespan(app: FastAPI):
await task_queue.recover()
queue_task = asyncio.create_task(task_queue.run())
- # Background task: auto-expire pending payment orders
+ # Background task: auto-expire pending payment orders and sync status
async def _order_expiry_loop():
- from app.services.payment import expire_all_pending_orders
+ from app.services.payment import expire_all_pending_orders, sync_pending_orders
from logging import getLogger
bg_logger = getLogger("payment")
while True:
try:
async with async_session() as db:
+ # 同步待支付订单状态(检查支付宝实际支付状态
+ sync_count = await sync_pending_orders(db)
+ if sync_count > 0:
+ bg_logger.info(f"Synced {sync_count} pending payment order(s)")
+
+ # 自动过期订单
n = await expire_all_pending_orders(db)
if n > 0:
bg_logger.info(f"Auto-expired {n} pending payment order(s)")
@@ -52,6 +58,22 @@ async def lifespan(app: FastAPI):
await asyncio.sleep(60) # check every minute
expiry_task = asyncio.create_task(_order_expiry_loop())
+
+ # 启动时立即同步一次未支付订单
+ asyncio.create_task(asyncio.sleep(5)) # 等待5秒后再同步,让系统完全启动
+ async def startup_sync():
+ await asyncio.sleep(5)
+ from app.services.payment import sync_pending_orders
+ from logging import getLogger
+ bg_logger = getLogger("payment")
+ try:
+ async with async_session() as db:
+ sync_count = await sync_pending_orders(db)
+ if sync_count > 0:
+ bg_logger.info(f"Startup: Synced {sync_count} pending payment order(s)")
+ except Exception as e:
+ bg_logger.error(f"Startup sync error: {e}")
+ asyncio.create_task(startup_sync())
app.state.db_session_factory = async_session
diff --git a/video-gen-api/app/services/payment.py b/video-gen-api/app/services/payment.py
index 03824350..a7fe5249 100644
--- a/video-gen-api/app/services/payment.py
+++ b/video-gen-api/app/services/payment.py
@@ -70,8 +70,17 @@ _handler.setFormatter(logging.Formatter(
if not logger.handlers:
logger.addHandler(_handler)
-# Orders pending payment for longer than this are auto-cancelled
-ORDER_EXPIRE_MINUTES = 5
+# Order expire time in seconds (configurable via payment_order_timeout setting, default 180 seconds)
+DEFAULT_ORDER_EXPIRE_SECONDS = 180
+
+
+def _get_order_expire_seconds(db_configs: dict[str, str]) -> int:
+ """Get order expire time in seconds from config, with fallback to 180."""
+ try:
+ val = db_configs.get("payment_order_timeout", str(DEFAULT_ORDER_EXPIRE_SECONDS))
+ return int(val) if val.strip() else DEFAULT_ORDER_EXPIRE_SECONDS
+ except ValueError:
+ return DEFAULT_ORDER_EXPIRE_SECONDS
# ---------------------------------------------------------------------------
@@ -126,7 +135,9 @@ async def _check_and_expire_order(db: AsyncSession, order: PaymentOrder) -> bool
"""
if order.status != "pending":
return False
- expiry = order.created_at + timedelta(minutes=ORDER_EXPIRE_MINUTES)
+ db_configs = await _get_payment_configs(db)
+ expire_seconds = _get_order_expire_seconds(db_configs)
+ expiry = order.created_at + timedelta(seconds=expire_seconds)
if datetime.now(order.created_at.tzinfo) >= expiry:
order.status = "cancelled"
await db.flush()
@@ -134,6 +145,12 @@ async def _check_and_expire_order(db: AsyncSession, order: PaymentOrder) -> bool
f"ORDER_EXPIRED order_no={order.order_no} user={order.user_id} "
f"amount={order.amount} created_at={order.created_at.isoformat()}"
)
+ # Also call Alipay close API if it was an Alipay order
+ if order.payment_method == "alipay":
+ try:
+ await _close_alipay_order(db, order, db_configs)
+ except Exception as e:
+ logger.exception(f"Failed to close Alipay order {order.order_no}: {e}")
return True
return False
@@ -142,7 +159,9 @@ 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)
+ db_configs = await _get_payment_configs(db)
+ expire_seconds = _get_order_expire_seconds(db_configs)
+ threshold = datetime.now() - timedelta(seconds=expire_seconds)
result = await db.execute(
select(PaymentOrder).where(
PaymentOrder.status == "pending",
@@ -150,14 +169,22 @@ async def expire_all_pending_orders(db: AsyncSession) -> int:
)
)
orders = result.scalars().all()
+ expired_count = 0
for o in orders:
o.status = "cancelled"
+ expired_count += 1
logger.info(
f"ORDER_EXPIRED order_no={o.order_no} user={o.user_id} amount={o.amount}"
)
+ # Also call Alipay close API if it was an Alipay order
+ if o.payment_method == "alipay":
+ try:
+ await _close_alipay_order(db, o, db_configs)
+ except Exception as e:
+ logger.exception(f"Failed to close Alipay order {o.order_no}: {e}")
if orders:
await db.flush()
- return len(orders)
+ return expired_count
def _is_mock_mode(db_configs: dict[str, str]) -> bool:
@@ -417,6 +444,175 @@ def _create_alipay_order(order: PaymentOrder, db_configs: dict[str, str]) -> str
return None
+# ---------------------------------------------------------------------------
+# Alipay order close
+# ---------------------------------------------------------------------------
+
+
+async def _close_alipay_order(db: AsyncSession, order: PaymentOrder, db_configs: dict[str, str]) -> bool:
+ """Call Alipay trade.close API to close an unpaid order.
+ Returns True if the order was closed successfully.
+ """
+ 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", "")
+ gateway = db_configs.get("payment_alipay_gateway", "")
+
+ client = _get_alipay_client(app_id, private_key, public_key, gateway)
+ if client is None:
+ return False
+
+ mock_mode = _is_mock_mode(db_configs)
+ if mock_mode:
+ logger.info(f"Mock mode: skipping close_alipay_order for {order.order_no}")
+ return True
+
+ try:
+ from alipay.aop.api.domain.AlipayTradeCloseModel import AlipayTradeCloseModel
+ from alipay.aop.api.request.AlipayTradeCloseRequest import AlipayTradeCloseRequest
+ from alipay.aop.api.response.AlipayTradeCloseResponse import AlipayTradeCloseResponse
+
+ model = AlipayTradeCloseModel()
+ model.out_trade_no = order.order_no
+
+ request = AlipayTradeCloseRequest(biz_model=model)
+
+ response_content = client.execute(request)
+ if not response_content:
+ logger.error(f"Alipay close failed: empty response, order_no={order.order_no}")
+ return False
+
+ response = AlipayTradeCloseResponse()
+ response.parse_response_content(response_content)
+
+ if response.is_success():
+ logger.info(f"Alipay order closed: order_no={order.order_no}")
+ return True
+ else:
+ logger.error(
+ f"Alipay close 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 False
+
+ except Exception as e:
+ if "TypeError" in str(e) and ("bytes" in str(e) or "str" in str(e)):
+ logger.error(
+ f"Alipay SDK TypeError (bytes/str issue) during close: order_no={order.order_no}, "
+ f"error={str(e)}"
+ )
+ logger.exception(f"Alipay close exception: order_no={order.order_no}")
+ return False
+
+
+# ---------------------------------------------------------------------------
+# Alipay order query
+# ---------------------------------------------------------------------------
+
+
+async def _query_alipay_order(db: AsyncSession, order: PaymentOrder, db_configs: dict[str, str]) -> dict | None:
+ """Call Alipay trade.query API to check order status.
+ Returns the response data if successful, None otherwise.
+ """
+ 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", "")
+ gateway = db_configs.get("payment_alipay_gateway", "")
+
+ client = _get_alipay_client(app_id, private_key, public_key, gateway)
+ if client is None:
+ return None
+
+ mock_mode = _is_mock_mode(db_configs)
+ if mock_mode:
+ logger.info(f"Mock mode: skipping query_alipay_order for {order.order_no}")
+ return {"trade_status": "TRADE_FINISHED"}
+
+ try:
+ from alipay.aop.api.domain.AlipayTradeQueryModel import AlipayTradeQueryModel
+ from alipay.aop.api.request.AlipayTradeQueryRequest import AlipayTradeQueryRequest
+ from alipay.aop.api.response.AlipayTradeQueryResponse import AlipayTradeQueryResponse
+
+ model = AlipayTradeQueryModel()
+ model.out_trade_no = order.order_no
+
+ request = AlipayTradeQueryRequest(biz_model=model)
+
+ response_content = client.execute(request)
+ if not response_content:
+ logger.error(f"Alipay query failed: empty response, order_no={order.order_no}")
+ return None
+
+ response = AlipayTradeQueryResponse()
+ response.parse_response_content(response_content)
+
+ if response.is_success():
+ logger.info(f"Alipay query succeeded: order_no={order.order_no}, trade_status={response.trade_status}")
+ return {
+ "trade_no": response.trade_no,
+ "trade_status": response.trade_status,
+ "total_amount": response.total_amount,
+ "receipt_amount": response.receipt_amount,
+ }
+ else:
+ logger.error(
+ f"Alipay query 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 as e:
+ if "TypeError" in str(e) and ("bytes" in str(e) or "str" in str(e)):
+ logger.error(
+ f"Alipay SDK TypeError (bytes/str issue) during query: order_no={order.order_no}, "
+ f"error={str(e)}"
+ )
+ logger.exception(f"Alipay query exception: order_no={order.order_no}")
+ return None
+
+
+async def sync_pending_orders(db: AsyncSession) -> int:
+ """Check pending orders via Alipay query and update status.
+ Returns the number of orders updated.
+ """
+ result = await db.execute(
+ select(PaymentOrder).where(
+ PaymentOrder.status == "pending",
+ )
+ )
+ orders = result.scalars().all()
+ updated_count = 0
+
+ db_configs = await _get_payment_configs(db)
+
+ for order in orders:
+ if order.payment_method != "alipay":
+ continue
+
+ try:
+ data = await _query_alipay_order(db, order, db_configs)
+ if data:
+ trade_status = data.get("trade_status")
+ if trade_status in ("TRADE_SUCCESS", "TRADE_FINISHED"):
+ # Order was paid but we missed the callback
+ trade_no = data.get("trade_no", "")
+ await process_payment_success_by_order_no(db, order.order_no, trade_no)
+ updated_count += 1
+ elif trade_status in ("TRADE_CLOSED", "TRADE_CANCELLED"):
+ # Order was closed on Alipay side
+ order.status = "cancelled"
+ await db.flush()
+ updated_count += 1
+ except Exception as e:
+ logger.exception(f"Failed to sync order {order.order_no}: {e}")
+
+ if updated_count > 0:
+ await db.flush()
+ return updated_count
+
+
# ---------------------------------------------------------------------------
# Alipay callback verification
# ---------------------------------------------------------------------------
@@ -457,8 +653,8 @@ async def verify_alipay_callback(data: dict, db: AsyncSession) -> bool:
f"{k}={v}" for k, v in sorted(verify_data.items())
)
- logger.info(f"Verifying Alipay callback sign_content: {sign_content[:100]}...")
- logger.info(f"Sign type: {sign_type}")
+ # logger.info(f"Verifying Alipay callback sign_content: {sign_content[:100]}...")
+ # logger.info(f"Sign type: {sign_type}")
# 实现 RSA2 签名验证
is_valid = _verify_alipay_sign(public_key, sign_content, sign, sign_type)
diff --git a/video-gen-app/src/components/Layout/AppLayout.tsx b/video-gen-app/src/components/Layout/AppLayout.tsx
index 5c32218e..eb0f58f9 100644
--- a/video-gen-app/src/components/Layout/AppLayout.tsx
+++ b/video-gen-app/src/components/Layout/AppLayout.tsx
@@ -92,7 +92,9 @@ const AppLayout: React.FC = () => {
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 [countdown, setCountdown] = useState(180); // 默认180秒超时
const pollingTimerRef = useRef | null>(null);
+ const countdownTimerRef = useRef | null>(null);
const currentOrderNoRef = useRef(null);
const [enabledMethods, setEnabledMethods] = useState<{ alipay: boolean; wechat: boolean }>({ alipay: false, wechat: false });
@@ -190,25 +192,23 @@ const AppLayout: React.FC = () => {
clearInterval(pollingTimerRef.current);
pollingTimerRef.current = null;
}
+ if (countdownTimerRef.current) {
+ clearInterval(countdownTimerRef.current);
+ countdownTimerRef.current = null;
+ }
}, []);
- const startPolling = useCallback((orderNo: string) => {
+ const startPolling = useCallback((orderNo: string, timeoutSeconds: number = 180) => {
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;
- }
+ setCountdown(timeoutSeconds);
+
+ // 订单状态轮询(每2秒查询一次,减少请求频率
+ const pollingTimer = setInterval(async () => {
try {
const orders = await getPaymentOrders();
const order = orders.find((o: any) => o.orderNo === orderNo);
if (order && order.status === 'paid') {
- clearInterval(timer);
- pollingTimerRef.current = null;
+ stopPolling();
currentOrderNoRef.current = null;
message.success('支付成功!积分已到账');
useAuthStore.getState().refreshUser();
@@ -216,15 +216,35 @@ const AppLayout: React.FC = () => {
setCurrentPaymentInfo(null);
setSelectedPlan(null);
} else if (order && order.status === 'cancelled') {
- clearInterval(timer);
- pollingTimerRef.current = null;
+ stopPolling();
currentOrderNoRef.current = null;
}
} catch {
// ignore polling errors
}
+ }, 2000);
+ pollingTimerRef.current = pollingTimer;
+
+ // 倒计时
+ const countdownTimer = setInterval(() => {
+ setCountdown(prev => {
+ if (prev <= 1) {
+ // 超时自动取消
+ stopPolling();
+ if (currentOrderNoRef.current) {
+ cancelPaymentOrder(currentOrderNoRef.current).catch(() => {});
+ currentOrderNoRef.current = null;
+ }
+ message.warning('订单已超时,请重新充值');
+ setQrCodeModalOpen(false);
+ setCurrentPaymentInfo(null);
+ setSelectedPlan(null);
+ return 0;
+ }
+ return prev - 1;
+ });
}, 1000);
- pollingTimerRef.current = timer;
+ countdownTimerRef.current = countdownTimer;
}, [stopPolling]);
return (
@@ -744,6 +764,23 @@ const AppLayout: React.FC = () => {
}}>
购买 {currentPaymentInfo?.credits || 0} 积分
+ {/* 倒计时显示 */}
+
+
+ 订单将在 {countdown} 秒后关闭
+
+