This commit is contained in:
2026-06-11 17:55:42 +08:00
39 changed files with 1921 additions and 2743 deletions
+111 -89
View File
@@ -43,6 +43,7 @@ from app.services.notification import create_notification
from app.services.auth import hash_password, verify_password
from app.services.operation_log import log_operation
from app.services.resource_signed_url_service import build_resource_signed_url
from app.services.payment import sync_pending_orders, process_refund
from app.services.generation_billing_service import (
OWNER_GENERATION_RECORD,
@@ -442,26 +443,70 @@ async def batch_update_payment_configs(
@router.get("/payment-stats")
async def get_payment_stats(
payment_method: str | None = Query(None),
status: str | None = Query(None),
start_date: str | None = Query(None),
end_date: str | None = Query(None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Return payment statistics for admin dashboard."""
"""Return payment statistics for admin dashboard with filters."""
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},
"refunded": {"count": 0, "amount": 0.0},
}
# Parse dates and build base query filters
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)
# Default to today if no date range provided
query_start = today_start
query_end = today_end
if start_date:
query_start = datetime.fromisoformat(start_date).replace(tzinfo=CST)
if end_date:
query_end = (datetime.fromisoformat(end_date) + timedelta(days=1)).replace(tzinfo=CST)
# Build filter list for status breakdown
breakdown_filters = []
if payment_method:
breakdown_filters.append(PaymentOrder.payment_method == payment_method)
if status:
breakdown_filters.append(PaymentOrder.status == status)
# Always apply date range to breakdown
breakdown_filters.append(PaymentOrder.created_at >= query_start)
breakdown_filters.append(PaymentOrder.created_at < query_end)
# 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)
)
.where(*breakdown_filters)
.group_by(PaymentOrder.status)
)
by_status = {}
for row in status_result.all():
by_status[row.status] = {"count": row.count, "amount": float(row.amount)}
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) - independent of filter
today_result = await db.execute(
select(
func.count().label("paid_count"),
@@ -469,38 +514,70 @@ async def get_payment_stats(
).where(
PaymentOrder.status == "paid",
PaymentOrder.paid_at >= today_start,
PaymentOrder.paid_at < today_end,
)
)
today_row = today_result.one()
# Recent 50 orders
# Monthly cumulative stats
month_start = now_cst.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
month_end = (month_start + timedelta(days=32)).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
month_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 >= month_start,
PaymentOrder.paid_at < month_end,
)
)
month_row = month_result.one()
# Recent orders with filters
recent_filters = []
if payment_method:
recent_filters.append(PaymentOrder.payment_method == payment_method)
if status:
recent_filters.append(PaymentOrder.status == status)
recent_filters.append(PaymentOrder.created_at >= query_start)
recent_filters.append(PaymentOrder.created_at < query_end)
recent_result = await db.execute(
select(PaymentOrder)
select(PaymentOrder, User)
.join(User, PaymentOrder.user_id == User.id)
.where(*recent_filters)
.order_by(PaymentOrder.created_at.desc())
.limit(50)
)
recent = recent_result.scalars().all()
recent_data = recent_result.all()
return {
"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),
},
"month": {
"paid_count": month_row.paid_count,
"paid_amount": round(float(month_row.paid_amount), 2),
},
"recent": [
{
"id": o.id,
"order_no": o.order_no,
"user_id": o.user_id,
"amount": o.amount,
"credits": o.credits,
"username": u.username,
"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", "refunded") else "cancelled",
"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
for o, u in recent_data
],
}
@@ -544,13 +621,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,
"status": o.status if o.status in ("pending", "paid", "cancelled", "refunded") else "cancelled",
"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
],
@@ -585,6 +662,19 @@ async def update_payment_config(
}
@router.post("/payment-orders/{order_no}/refund")
async def refund_payment_order(
order_no: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Refund a paid payment order."""
result = await process_refund(db, order_no)
if not result.get("success"):
raise HTTPException(status_code=400, detail=result.get("message", "退款失败"))
return result
# ── Industry Config ──────────────────────────────────────
def _serialize_industry(ind: IndustryConfig) -> dict:
@@ -1371,72 +1461,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
],
}
+47 -4
View File
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger("videogen")
logger = logging.getLogger("payment")
from app.dependencies import get_db, get_current_user
from app.models.user import User
@@ -16,13 +16,20 @@ from app.services.payment import (
verify_wechat_callback,
verify_alipay_callback,
process_payment_success_by_order_no,
process_refund,
_get_payment_configs,
_close_alipay_order,
_get_order_expire_seconds,
)
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)
@@ -89,9 +96,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 order_no={data.get('out_trade_no')} "
f"trade_no={data.get('trade_no', '')} status={data.get('trade_status', '')}"
f"data={data}"
)
# Verify signature first
@@ -106,8 +114,11 @@ async def alipay_callback(request: Request, db: AsyncSession = Depends(get_db)):
order_no = data.get("out_trade_no")
trade_no = data.get("trade_no", "")
total_amount_str = data.get("total_amount", "")
total_amount = float(total_amount_str) if total_amount_str else None
if order_no:
await process_payment_success_by_order_no(db, order_no, trade_no)
await process_payment_success_by_order_no(db, order_no, trade_no, total_amount)
return "success"
@@ -130,6 +141,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,
@@ -148,6 +182,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(
+2 -2
View File
@@ -41,7 +41,7 @@ async def create_app(
db: AsyncSession = Depends(get_db),
):
try:
app = await create_user_oauth_app(db, req.app_id, req.secret, req.open_type, admin.id)
app = await create_user_oauth_app(db, req.app_id, req.secret, req.open_type, admin.id, req.count, req.auth_url, req.company)
return UserOAuthAppOut.model_validate(app)
except ValueError as e:
raise HTTPException(
@@ -72,7 +72,7 @@ async def update_app(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
app = await update_user_oauth_app(db, id, req.secret, req.open_type, req.status, admin.id)
app = await update_user_oauth_app(db, id, req.secret, req.open_type, req.status, req.count, req.auth_url, req.company, admin.id)
if not app:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,