Files
video-gen/video-gen-api/app/api/v1/admin.py
T
2026-08-11 09:24:18 +08:00

2410 lines
81 KiB
Python

from datetime import datetime, timezone, timedelta
import json
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import delete, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, get_admin_user
from app.models.user import User
from app.models.project import Project
from app.models.generation_record import GenerationRecord
from app.models.chat_generation_task import ChatGenerationTask
from app.models.credit_record import CreditRecord
from app.models.model_config import ModelConfig
from app.models.system_config import SystemConfig
from app.models.payment_order import PaymentOrder
from app.models.token_usage import TokenUsage
from app.models.notification import Notification
from app.models.notification_read import NotificationRead
from app.models.industry_config import IndustryConfig
from app.models.video_engine import VideoEngine
from app.models.image_engine import ImageEngine
from app.models.credit_ratio import CreditRatio
from app.models.operation_log import OperationLog
from app.enums.user import FrontendUserKind, UserType
from app.enums.team import TEAM_UNASSIGNED_VALUE
from app.schemas.admin import (
CreditAdjustRequest,
ModelConfigCreate,
ModelConfigOut,
SystemConfigCreate,
SystemConfigUpdate,
SystemConfigOut,
AdminUserOut,
AdminStatsOut,
DailyCreditOut,
TeamCreditOut,
ModelUsageOut,
VideoParamOut,
CreateUserRequest,
UpdateMenusRequest,
ResetPasswordRequest,
UpdateFrontendUserKindRequest,
)
from app.schemas.team import UpdateUserTeamRequest
from app.schemas.industry import IndustryConfigCreate
from app.schemas.video_engine import VideoEngineCreate, VideoEngineOut
from app.schemas.image_engine import ImageEngineCreate, ImageEngineOut
from app.schemas.credit_ratio import CreditRatioCreate, CreditRatioOut
from app.services.credits import add_credits, deduct_credits
from app.enums.credit_balance import CreditBalanceSourceType, CreditLevel
from app.services.credit.query_service import attach_credit_snapshot, get_available_credits, get_user_credit_map
from app.services.credit.time_policy import add_natural_months
from app.services.credit.utils import utc_now
from app.services.credit_record_meta_service import build_admin_adjust_meta
from app.services.admin_credit_record_service import list_admin_credit_records
from app.services.system_config_cache import invalidate_system_config_cache
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.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls
from app.services.resource_signed_url_service import build_resource_signed_url
from app.services.payment import process_refund
from app.services.resource_capacity_service import batch_get_user_resource_capacity_usage, get_user_resource_capacity_usage
from app.services.team_service import batch_get_team_name_map, set_frontend_user_team
from app.utils.id_gen import generate_id
CST = timezone(timedelta(hours=8))
def _safe_json_object(value: str | None) -> dict | None:
if not value:
return None
try:
parsed = json.loads(value)
except (TypeError, json.JSONDecodeError):
return None
return parsed if isinstance(parsed, dict) else None
def _iso(dt):
"""Serialize datetime as naive ISO string (UTC→CST, strip tzinfo)."""
if dt is None:
return None
try:
d = dt if isinstance(dt, datetime) else datetime.fromisoformat(str(dt))
if d.tzinfo and d.tzinfo.utcoffset(None) == timedelta(0):
d = d.astimezone(CST)
return d.replace(tzinfo=None).isoformat()
except (ValueError, TypeError):
return str(dt)
router = APIRouter(prefix="/admin", tags=["admin"])
# ── User Management ──────────────────────────────────────
@router.get("/users")
async def list_users(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=1000),
search: str = Query(""),
user_type: str | None = Query(None, pattern="^(frontend|admin)$"),
frontend_user_kind: str | None = Query(None, pattern="^(internal|external)$"),
team_id: str | None = Query(None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
query = select(User).order_by(User.created_at.desc())
count_query = select(func.count(User.id))
if search:
like = f"%{search}%"
search_filter = (User.username.ilike(like)) | (User.email.ilike(like)) | (User.phone.ilike(like))
query = query.where(search_filter)
count_query = count_query.where(search_filter)
if user_type:
query = query.where(User.user_type == user_type)
count_query = count_query.where(User.user_type == user_type)
if frontend_user_kind:
query = query.where(User.user_type == UserType.FRONTEND.value, User.frontend_user_kind == frontend_user_kind)
count_query = count_query.where(User.user_type == UserType.FRONTEND.value, User.frontend_user_kind == frontend_user_kind)
if team_id:
team_filter = User.team_id.is_(None) if team_id == TEAM_UNASSIGNED_VALUE else (User.team_id == team_id)
query = query.where(team_filter)
count_query = count_query.where(team_filter)
total = (await db.execute(count_query)).scalar() or 0
result = await db.execute(query.offset((page - 1) * page_size).limit(page_size))
users = list(result.scalars().all())
user_ids = [u.id for u in users]
team_ids = [getattr(u, "team_id", None) for u in users if getattr(u, "team_id", None)]
capacity_map = await batch_get_user_resource_capacity_usage(db, user_ids)
team_name_map = await batch_get_team_name_map(db, team_ids)
credit_map = await get_user_credit_map(db, user_ids)
for item in users:
attach_credit_snapshot(item, credit_map.get(item.id, 0.0))
return {
"items": [
AdminUserOut.model_validate(user)
.model_copy(
update={
"resource_capacity": capacity_map.get(user.id),
"team_name": team_name_map.get(getattr(user, "team_id", None)),
}
)
.model_dump(mode="json")
for user in users
],
"total": total,
}
@router.post("/users", response_model=AdminUserOut)
async def create_user(
req: CreateUserRequest,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
import random
# Frontend users: use phone, auto-generate username
if req.user_type == "frontend":
if not req.phone:
raise HTTPException(status_code=400, detail="前台用户必须填写手机号")
existing_phone = await db.execute(select(User).where(User.phone == req.phone).limit(1))
if existing_phone.scalar_one_or_none():
raise HTTPException(status_code=400, detail="该手机号已注册")
username = f"用户{req.phone[-4:]}"
existing_name = await db.execute(select(User).where(User.username == username).limit(1))
if existing_name.scalar_one_or_none():
username = f"用户{req.phone[-4:]}{random.randint(10, 99)}"
else:
if not req.username:
raise HTTPException(status_code=400, detail="后台用户必须填写用户名")
username = req.username
existing = await db.execute(select(User).where(User.username == username).limit(1))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="用户名已存在")
user = User(
id=generate_id(),
username=username,
hashed_password=hash_password(req.password),
email=req.email,
phone=req.phone,
is_admin=req.is_admin if req.user_type == "admin" else False,
user_type=req.user_type,
frontend_user_kind=req.frontend_user_kind if req.user_type == "frontend" else FrontendUserKind.EXTERNAL.value,
allowed_menus=req.allowed_menus,
private_portrait_asset_limit=req.private_portrait_asset_limit,
)
db.add(user)
await db.flush()
if req.credits > 0:
now = utc_now()
await add_credits(
db, user.id, req.credits, "管理员创建用户初始积分",
record_meta=build_admin_adjust_meta(),
valid_from=now,
expires_at=add_natural_months(now, 1),
credit_level=CreditLevel.GENERAL.value,
source_type=CreditBalanceSourceType.ADMIN_GRANT.value,
source_id=admin.id,
related_id=admin.id,
biz_key=f"admin-create-user-credit:{user.id}",
)
else:
attach_credit_snapshot(user, 0)
await log_operation(
db,
admin.id,
admin.username,
f"创建用户 {username}",
"POST",
"/admin/users",
detail=json.dumps(
{
"user_id": user.id,
"username": username,
"user_type": req.user_type,
"frontend_user_kind": user.frontend_user_kind,
"private_portrait_asset_limit": user.private_portrait_asset_limit,
"credits": user.credits,
"phone": user.phone,
"email": user.email,
},
ensure_ascii=False,
default=str,
),
ip=None,
)
await db.commit()
return user
@router.put("/users/{user_id}/menus")
async def update_user_menus(
user_id: str,
req: UpdateMenusRequest,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.id == user_id).limit(1))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
user.allowed_menus = req.allowed_menus
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"更新菜单权限",
"PUT",
f"/admin/users/{user_id}/menus",
detail=json.dumps(
{
"user_id": user_id,
"allowed_menus": req.allowed_menus,
},
ensure_ascii=False,
),
)
await db.commit()
return {"message": "ok"}
@router.get("/users/{user_id}", response_model=AdminUserOut)
async def get_user(
user_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.id == user_id).limit(1))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
attach_credit_snapshot(user, await get_available_credits(db, user.id))
resource_capacity = await get_user_resource_capacity_usage(db, user.id)
team_name_map = await batch_get_team_name_map(db, [getattr(user, "team_id", None)])
return AdminUserOut.model_validate(user).model_copy(
update={
"resource_capacity": resource_capacity,
"team_name": team_name_map.get(getattr(user, "team_id", None)),
}
)
@router.post("/users/{user_id}/credits")
async def adjust_credits(
user_id: str,
req: CreditAdjustRequest,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
if req.amount > 0:
now = utc_now()
await add_credits(
db, user_id, req.amount, f"管理员调整: {req.description}",
record_meta=build_admin_adjust_meta(),
valid_from=now, expires_at=add_natural_months(now, 1),
credit_level=CreditLevel.GENERAL.value,
source_type=CreditBalanceSourceType.ADMIN_GRANT.value,
source_id=admin.id,
related_id=admin.id,
biz_key=f"admin-adjust-credit:{admin.id}:{generate_id()}",
)
else:
await deduct_credits(db, user_id, abs(req.amount), f"管理员调整: {req.description}", record_meta=build_admin_adjust_meta())
await create_notification(
db, user_id, "积分变动通知",
f"您的积分已{'增加' if req.amount > 0 else '扣除'}{abs(req.amount)}积分。原因:{req.description}",
"credit",
)
await log_operation(
db,
admin.id,
admin.username,
f"调整积分 {'+' if req.amount > 0 else ''}{req.amount}",
"POST",
f"/admin/users/{user_id}/credits",
detail=json.dumps(
{
"user_id": user_id,
"amount": req.amount,
"description": req.description,
},
ensure_ascii=False,
),
)
await db.commit()
return {"message": "ok"}
@router.put("/users/{user_id}/status")
async def update_user_status(
user_id: str,
body: dict,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
is_active = body.get("is_active", True)
await db.execute(
update(User).where(User.id == user_id).values(is_active=is_active)
)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"{'启用' if is_active else '禁用'}用户",
"PUT",
f"/admin/users/{user_id}/status",
detail=json.dumps(
{
"user_id": user_id,
"is_active": is_active,
},
ensure_ascii=False,
),
)
await db.commit()
return {"message": "ok"}
@router.put("/users/{user_id}/admin-status")
async def update_user_admin_status(
user_id: str,
body: dict,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
is_admin = body.get("is_admin", False)
result = await db.execute(select(User).where(User.id == user_id).limit(1))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if user.user_type != "admin":
raise HTTPException(status_code=400, detail="仅后台用户支持设置超级管理员状态")
user.is_admin = is_admin
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"{is_admin and '设为' or '取消'}超级管理员",
"PUT",
f"/admin/users/{user_id}/admin-status",
detail=json.dumps(
{
"user_id": user_id,
"is_admin": is_admin,
},
ensure_ascii=False,
),
)
return {"message": "ok"}
@router.put("/users/{user_id}/frontend-kind", response_model=AdminUserOut)
async def update_user_frontend_kind(
user_id: str,
req: UpdateFrontendUserKindRequest,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.id == user_id).limit(1))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if user.user_type != UserType.FRONTEND.value:
raise HTTPException(status_code=400, detail="仅前台用户支持设置内部/外部归类")
user.frontend_user_kind = req.frontend_user_kind or FrontendUserKind.EXTERNAL.value
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"设置前台用户归类为 {user.frontend_user_kind}",
"PUT",
f"/admin/users/{user_id}/frontend-kind",
detail=json.dumps(
{
"user_id": user_id,
"frontend_user_kind": user.frontend_user_kind,
},
ensure_ascii=False,
),
)
return user
@router.put("/users/{user_id}/team", response_model=AdminUserOut)
async def update_user_team(
user_id: str,
req: UpdateUserTeamRequest,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
user, before, after = await set_frontend_user_team(db, user_id=user_id, team_id=req.team_id)
await log_operation(
db,
admin.id,
admin.username,
f"设置用户团队 {user.username}",
"PUT",
f"/admin/users/{user_id}/team",
detail=json.dumps({"before": before, "after": after}, ensure_ascii=False),
)
resource_capacity = await get_user_resource_capacity_usage(db, user.id)
return AdminUserOut.model_validate(user).model_copy(
update={"resource_capacity": resource_capacity, "team_name": after.get("team_name")}
)
@router.put("/users/{user_id}/reset-password")
async def reset_user_password(
user_id: str,
req: ResetPasswordRequest,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.id == user_id).limit(1))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
user.hashed_password = hash_password(req.new_password)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"重置密码",
"PUT",
f"/admin/users/{user_id}/reset-password",
detail=json.dumps(
{
"user_id": user_id,
"new_password": "***",
},
ensure_ascii=False,
),
)
return {"message": "ok"}
@router.post("/change-password")
async def admin_change_password(
body: dict,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
old_password = body.get("old_password", "")
new_password = body.get("new_password", "")
if not verify_password(old_password, admin.hashed_password):
raise HTTPException(status_code=400, detail="原密码错误")
if len(new_password) < 6:
raise HTTPException(status_code=400, detail="密码至少6位")
admin.hashed_password = hash_password(new_password)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
"修改密码",
"POST",
"/admin/change-password",
detail=json.dumps(
{
"old_password": "***",
"new_password": "***",
},
ensure_ascii=False,
),
)
return {"message": "密码修改成功"}
# ── Credit Records ───────────────────────────────────────
@router.get("/credit-records")
async def list_credit_records(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=1000),
user_id: str | None = Query(None),
user_name: str | None = Query(None),
user_type: str | None = Query(None),
frontend_user_kind: str | None = Query(None),
team_id: str | None = Query(None),
record_type: str | None = Query(None),
type: str | None = Query(None),
credit_subject: str | None = Query(None),
media_type: str | None = Query(None),
charge_kind: str | None = Query(None),
charge_action: str | None = Query(None),
source_module: str | None = Query(None),
source_step_code: str | None = Query(None),
billing_scene: str | None = Query(None),
start_date: str = Query(None),
end_date: str = Query(None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""List all credit transaction records with filters and full summary."""
return await list_admin_credit_records(
db,
page=page,
page_size=page_size,
user_id=user_id,
user_name=user_name,
user_type=user_type,
frontend_user_kind=frontend_user_kind,
team_id=team_id,
record_type=record_type or type,
credit_subject=credit_subject,
media_type=media_type,
charge_kind=charge_kind,
charge_action=charge_action,
source_module=source_module,
source_step_code=source_step_code,
billing_scene=billing_scene,
start_date=start_date,
end_date=end_date,
)
# ── Notification Admin ───────────────────────────────────
@router.get("/notifications")
async def list_admin_notifications(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=500),
user_id: str | None = Query(None),
is_read: bool = Query(None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""List all notifications with optional filters."""
query = select(Notification).order_by(Notification.created_at.desc())
count_query = select(func.count(Notification.id))
if user_id:
query = query.where(Notification.user_id == user_id)
count_query = count_query.where(Notification.user_id == user_id)
if is_read is not None:
query = query.where(Notification.is_read == is_read)
count_query = count_query.where(Notification.is_read == is_read)
total = (await db.execute(count_query)).scalar() or 0
result = await db.execute(query.offset((page - 1) * page_size).limit(page_size))
items = result.scalars().all()
return {
"total": total,
"items": [
{
"id": item.id,
"user_id": item.user_id,
"title": item.title,
"content": item.content,
"type": item.type,
"is_read": item.is_read,
"related_id": item.related_id,
"created_at": _iso(item.created_at),
}
for item in items
],
}
@router.post("/notifications")
async def create_admin_notification(
body: dict,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Create notification. target_user_id=None broadcasts to all users."""
title = body.get("title", "")
content = body.get("content", "")
notif_type = body.get("type", "system")
target_user_id = body.get("target_user_id")
if not title or not content:
raise HTTPException(status_code=400, detail="标题和内容不能为空")
if target_user_id:
# Send to specific user
await create_notification(
db, target_user_id, title, content, notif_type, push_ws=True
)
else:
# Broadcast: create one notification with user_id=NULL
await create_notification(
db, None, title, content, notif_type, push_ws=True
)
await log_operation(
db,
admin.id,
admin.username,
f"{'发送通知给指定用户' if target_user_id else '广播通知'}: {title}",
"POST",
"/admin/notifications",
detail=json.dumps(
{
"title": title,
"target_user_id": target_user_id,
"type": notif_type,
},
ensure_ascii=False,
),
)
return {"message": "ok"}
@router.delete("/notifications/{notification_id}")
async def delete_admin_notification(
notification_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(Notification).where(Notification.id == notification_id).limit(1)
)
notif = result.scalar_one_or_none()
if not notif:
raise HTTPException(status_code=404, detail="通知不存在")
await db.execute(
delete(NotificationRead).where(NotificationRead.notification_id == notification_id)
)
await db.delete(notif)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"删除通知: {notif.title}",
"DELETE",
f"/admin/notifications/{notification_id}",
detail=json.dumps(
{
"notification_id": notification_id,
"title": notif.title,
},
ensure_ascii=False,
),
)
return {"message": "ok"}
@router.get("/notifications/{notification_id}/read-users")
async def get_notification_read_users(
notification_id: str,
page: int = Query(1, ge=1),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Get list of users who have read a specific notification."""
from app.services.notification import get_notification_read_users
items, total = await get_notification_read_users(db, notification_id, page)
return {"total": total, "items": items}
# ── Payment Config ───────────────────────────────────────
@router.get("/payment-configs")
async def list_payment_configs(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Return system configs that start with payment_ prefix."""
result = await db.execute(
select(SystemConfig).where(SystemConfig.key.like("payment_%"))
)
configs = result.scalars().all()
return [
{
"id": c.id,
"key": c.key,
"value": c.value,
"description": c.description,
}
for c in configs
]
@router.put("/payment-configs/batch")
async def batch_update_payment_configs(
req: dict[str, str],
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Batch upsert payment configs. Creates missing keys, updates existing ones."""
from app.utils.id_gen import generate_id
for key, value in req.items():
if not key.startswith("payment_"):
continue
result = await db.execute(
select(SystemConfig).where(SystemConfig.key == key).limit(1)
)
config = result.scalar_one_or_none()
if config:
config.value = value
else:
db.add(SystemConfig(
id=generate_id(),
key=key,
value=value,
))
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
"批量更新支付配置",
"PUT",
"/admin/payment-configs/batch",
detail=json.dumps(list(req.keys()), ensure_ascii=False),
)
return {"ok": True}
@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 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"),
)
.where(*breakdown_filters)
.group_by(PaymentOrder.status)
)
for row in status_result.all():
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 (CST time zone) - independent of filter
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,
PaymentOrder.paid_at < today_end,
)
)
today_row = today_result.one()
# 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()
return {
"by_status": by_status,
"today": {
"paid_count": today_row.paid_count,
"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),
},
}
@router.get("/payment-orders")
async def list_payment_orders(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=500),
payment_method: str | None = Query(None),
status: str | None = Query(None),
phone: str | None = Query(None, description="按用户手机号模糊搜索"),
start_date: str | None = Query(None),
end_date: str | None = Query(None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Return paginated payment orders for admin dashboard."""
query = select(PaymentOrder, User.username, User.phone).join(User, PaymentOrder.user_id == User.id)
count_query = select(func.count(PaymentOrder.id))
filters = []
if payment_method:
filters.append(PaymentOrder.payment_method == payment_method)
if status:
filters.append(PaymentOrder.status == status)
if phone:
filters.append(User.phone.ilike(f"%{phone.strip()}%"))
if start_date:
filters.append(PaymentOrder.created_at >= datetime.fromisoformat(start_date).replace(tzinfo=CST))
if end_date:
filters.append(PaymentOrder.created_at < (datetime.fromisoformat(end_date) + timedelta(days=1)).replace(tzinfo=CST))
for f in filters:
query = query.where(f)
count_query = count_query.where(f)
total = (await db.execute(count_query)).scalar() or 0
result = await db.execute(
query.order_by(PaymentOrder.created_at.desc()).offset((page - 1) * page_size).limit(page_size)
)
rows = result.all()
items = [
{
"id": o.id,
"orderNo": o.order_no,
"order_no": o.order_no,
"userId": o.user_id,
"user_id": o.user_id,
"username": username,
"phone": user_phone,
"amount": round(float(o.amount), 2),
"credits": round(float(o.credits), 2),
"paymentMethod": o.payment_method,
"payment_method": o.payment_method,
"status": o.status,
"tradeNo": o.trade_no,
"trade_no": o.trade_no,
"paidAt": o.paid_at.isoformat() if o.paid_at else None,
"paid_at": o.paid_at.isoformat() if o.paid_at else None,
"createdAt": o.created_at.isoformat() if o.created_at else None,
"created_at": o.created_at.isoformat() if o.created_at else None,
}
for o, username, user_phone in rows
]
return {"items": items, "total": total, "page": page, "page_size": page_size}
@router.put("/payment-configs/{config_id}")
async def update_payment_config(
config_id: str,
req: SystemConfigUpdate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Update a payment system config value."""
result = await db.execute(
select(SystemConfig).where(
SystemConfig.id == config_id,
SystemConfig.key.like("payment_%"),
)
.limit(1)
)
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=404, detail="支付配置不存在")
config.value = req.value
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"更新支付配置: {config.key}",
"PUT",
f"/admin/payment-configs/{config_id}",
detail=json.dumps(
{
"config_id": config_id,
"key": config.key,
"value": req.value,
},
ensure_ascii=False,
),
)
return {
"id": config.id,
"key": config.key,
"value": config.value,
"description": config.description,
}
@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", "退款失败"))
await log_operation(
db,
admin.id,
admin.username,
f"订单退款: {order_no}",
"POST",
f"/admin/payment-orders/{order_no}/refund",
detail=json.dumps(
{
"order_no": order_no,
},
ensure_ascii=False,
),
)
return result
# ── Industry Config ──────────────────────────────────────
def _serialize_industry(ind: IndustryConfig) -> dict:
"""Convert industry config to dict with parsed skills."""
skills = []
if ind.skills:
try:
raw = json.loads(ind.skills)
if isinstance(raw, list):
skills = raw if raw and isinstance(raw[0], dict) else [{"key": s, "label": s} for s in raw]
except (json.JSONDecodeError, TypeError):
skills = []
return {
"id": ind.id,
"key": ind.key,
"label": ind.label,
"icon": ind.icon or "",
"description": ind.description,
"skills": skills,
"is_active": ind.is_active,
"sort_order": ind.sort_order,
"created_at": ind.created_at,
}
@router.get("/industry-configs")
async def list_industry_configs(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(IndustryConfig).order_by(IndustryConfig.sort_order.asc())
)
return [_serialize_industry(ind) for ind in result.scalars().all()]
@router.post("/industry-configs")
async def create_industry_config(
req: IndustryConfigCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
config = IndustryConfig(
id=generate_id(),
key=req.key,
label=req.label,
icon=req.icon or "",
description=req.description,
skills=json.dumps(req.skills, ensure_ascii=False),
is_active=req.is_active,
sort_order=req.sort_order,
)
db.add(config)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"创建行业配置: {req.label}",
"POST",
"/admin/industry-configs",
detail=json.dumps(
{
"config_id": config.id,
"key": req.key,
"label": req.label,
},
ensure_ascii=False,
),
)
return _serialize_industry(config)
@router.put("/industry-configs/{config_id}")
async def update_industry_config(
config_id: str,
req: IndustryConfigCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(IndustryConfig).where(IndustryConfig.id == config_id).limit(1)
)
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=404, detail="行业配置不存在")
config.key = req.key
config.label = req.label
config.icon = req.icon or ""
config.description = req.description
config.skills = json.dumps(req.skills, ensure_ascii=False)
config.is_active = req.is_active
config.sort_order = req.sort_order
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"更新行业配置: {req.label}",
"PUT",
f"/admin/industry-configs/{config_id}",
detail=json.dumps(
{
"config_id": config_id,
"key": req.key,
"label": req.label,
},
ensure_ascii=False,
),
)
return _serialize_industry(config)
@router.delete("/industry-configs/{config_id}")
async def delete_industry_config(
config_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(IndustryConfig).where(IndustryConfig.id == config_id).limit(1)
)
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=404, detail="行业配置不存在")
await db.delete(config)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"删除行业配置: {config.label}",
"DELETE",
f"/admin/industry-configs/{config_id}",
detail=json.dumps(
{
"config_id": config_id,
"key": config.key,
"label": config.label,
},
ensure_ascii=False,
),
)
return {"message": "ok"}
# ── Video Engine ─────────────────────────────────────────
@router.get("/video-engines", response_model=list[VideoEngineOut])
async def list_video_engines(
include_deleted: bool = Query(False),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
query = select(VideoEngine)
if not include_deleted:
query = query.where(VideoEngine.deleted_at.is_(None))
result = await db.execute(
query.order_by(VideoEngine.priority.desc(), VideoEngine.id.desc())
)
return result.scalars().all()
@router.post("/video-engines", response_model=VideoEngineOut)
async def create_video_engine(
req: VideoEngineCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
engine = VideoEngine(id=generate_id(), **req.model_dump())
db.add(engine)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"创建视频引擎: {req.name}",
"POST",
"/admin/video-engines",
detail=json.dumps(
{
"engine_id": engine.id,
"name": req.name,
},
ensure_ascii=False,
),
)
return engine
@router.put("/video-engines/{engine_id}", response_model=VideoEngineOut)
async def update_video_engine(
engine_id: str,
req: VideoEngineCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(VideoEngine).where(VideoEngine.id == engine_id, VideoEngine.deleted_at.is_(None)).limit(1)
)
engine = result.scalar_one_or_none()
if not engine:
raise HTTPException(status_code=404, detail="视频引擎不存在")
for k, v in req.model_dump().items():
setattr(engine, k, v)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"更新视频引擎: {engine.name}",
"PUT",
f"/admin/video-engines/{engine_id}",
detail=json.dumps(
{
"engine_id": engine_id,
"name": engine.name,
},
ensure_ascii=False,
),
)
return engine
@router.delete("/video-engines/{engine_id}")
async def delete_video_engine(
engine_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(VideoEngine).where(VideoEngine.id == engine_id, VideoEngine.deleted_at.is_(None)).limit(1)
)
engine = result.scalar_one_or_none()
if not engine:
raise HTTPException(status_code=404, detail="视频引擎不存在")
engine_name = engine.name
engine.deleted_at = datetime.now(timezone.utc)
engine.is_active = False
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"软删除视频引擎: {engine_name}",
"DELETE",
f"/admin/video-engines/{engine_id}",
detail=json.dumps(
{
"engine_id": engine_id,
"name": engine_name,
},
ensure_ascii=False,
),
)
return {"message": "ok"}
# ── Image Engine ──────────────────────────────────────────
@router.get("/image-engines", response_model=list[ImageEngineOut])
async def list_image_engines(
include_deleted: bool = Query(False),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
query = select(ImageEngine)
if not include_deleted:
query = query.where(ImageEngine.deleted_at.is_(None))
result = await db.execute(
query.order_by(ImageEngine.priority.desc(), ImageEngine.id.desc())
)
return result.scalars().all()
@router.post("/image-engines", response_model=ImageEngineOut)
async def create_image_engine(
req: ImageEngineCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
engine = ImageEngine(id=generate_id(), **req.model_dump())
db.add(engine)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"创建图片引擎: {req.name}",
"POST",
"/admin/image-engines",
detail=json.dumps(
{
"engine_id": engine.id,
"name": req.name,
},
ensure_ascii=False,
),
)
return engine
@router.put("/image-engines/{engine_id}", response_model=ImageEngineOut)
async def update_image_engine(
engine_id: str,
req: ImageEngineCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ImageEngine).where(ImageEngine.id == engine_id, ImageEngine.deleted_at.is_(None)).limit(1)
)
engine = result.scalar_one_or_none()
if not engine:
raise HTTPException(status_code=404, detail="图片引擎不存在")
for k, v in req.model_dump().items():
setattr(engine, k, v)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"更新图片引擎: {engine.name}",
"PUT",
f"/admin/image-engines/{engine_id}",
detail=json.dumps(
{
"engine_id": engine_id,
"name": engine.name,
},
ensure_ascii=False,
),
)
return engine
@router.delete("/image-engines/{engine_id}")
async def delete_image_engine(
engine_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ImageEngine).where(ImageEngine.id == engine_id, ImageEngine.deleted_at.is_(None)).limit(1)
)
engine = result.scalar_one_or_none()
if not engine:
raise HTTPException(status_code=404, detail="图片引擎不存在")
engine_name = engine.name
engine.deleted_at = datetime.now(timezone.utc)
engine.is_active = False
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"软删除图片引擎: {engine_name}",
"DELETE",
f"/admin/image-engines/{engine_id}",
detail=json.dumps(
{
"engine_id": engine_id,
"name": engine_name,
},
ensure_ascii=False,
),
)
return {"message": "ok"}
async def _validate_credit_ratio_engine(db: AsyncSession, req: CreditRatioCreate) -> None:
"""校验积分规则绑定的引擎是否存在。
CreditRatio.model_config_id 为兼容旧字段名,当前实际保存引擎ID:
- gen_type=image 时对应 image_engines.id
- gen_type=video 时对应 video_engines.id
"""
gen_type = (req.gen_type or "").lower().strip()
engine_id = (req.model_config_id or "").strip()
if gen_type not in ("image", "video"):
raise HTTPException(status_code=400, detail="gen_type 仅支持 image 或 video")
if not engine_id:
raise HTTPException(status_code=400, detail="model_config_id 不能为空,当前字段用于保存图片/视频引擎ID")
model = ImageEngine if gen_type == "image" else VideoEngine
result = await db.execute(select(model).where(model.id == engine_id, model.deleted_at.is_(None)).limit(1))
engine = result.scalar_one_or_none()
if not engine:
detail = "图片积分规则绑定的图片引擎不存在" if gen_type == "image" else "视频积分规则绑定的视频引擎不存在"
raise HTTPException(status_code=400, detail=detail)
# ── Credit Ratio ─────────────────────────────────────────
@router.get("/credit-ratios", response_model=list[CreditRatioOut])
async def list_credit_ratios(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(CreditRatio).order_by(CreditRatio.gen_type.desc(), CreditRatio.model_config_id.desc()))
return result.scalars().all()
@router.post("/credit-ratios", response_model=CreditRatioOut)
async def create_credit_ratio(
req: CreditRatioCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
await _validate_credit_ratio_engine(db, req)
data = req.model_dump()
data["gen_type"] = data["gen_type"].lower().strip()
data["model_config_id"] = data["model_config_id"].strip()
ratio = CreditRatio(id=generate_id(), **data)
db.add(ratio)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"创建积分规则: {data.get('gen_type', '')} - {data.get('model_config_id', '')}",
"POST",
"/admin/credit-ratios",
detail=json.dumps(
{
"ratio_id": ratio.id,
"gen_type": data.get("gen_type"),
"model_config_id": data.get("model_config_id"),
},
ensure_ascii=False,
),
)
await db.commit()
return ratio
@router.put("/credit-ratios/{ratio_id}", response_model=CreditRatioOut)
async def update_credit_ratio(
ratio_id: str,
req: CreditRatioCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(CreditRatio).where(CreditRatio.id == ratio_id).limit(1)
)
ratio = result.scalar_one_or_none()
if not ratio:
raise HTTPException(status_code=404, detail="积分比例不存在")
await _validate_credit_ratio_engine(db, req)
data = req.model_dump()
data["gen_type"] = data["gen_type"].lower().strip()
data["model_config_id"] = data["model_config_id"].strip()
for k, v in data.items():
setattr(ratio, k, v)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"更新积分规则: {data.get('gen_type', '')} - {data.get('model_config_id', '')}",
"PUT",
f"/admin/credit-ratios/{ratio_id}",
detail=json.dumps(
{
"ratio_id": ratio_id,
"gen_type": data.get("gen_type"),
"model_config_id": data.get("model_config_id"),
},
ensure_ascii=False,
),
)
await db.commit()
return ratio
@router.delete("/credit-ratios/{ratio_id}")
async def delete_credit_ratio(
ratio_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(CreditRatio).where(CreditRatio.id == ratio_id).limit(1)
)
ratio = result.scalar_one_or_none()
if not ratio:
raise HTTPException(status_code=404, detail="积分比例不存在")
await db.delete(ratio)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"删除积分规则: {ratio.gen_type} - {ratio.model_config_id}",
"DELETE",
f"/admin/credit-ratios/{ratio_id}",
detail=json.dumps(
{
"ratio_id": ratio_id,
"gen_type": ratio.gen_type,
"model_config_id": ratio.model_config_id,
},
ensure_ascii=False,
),
)
await db.commit()
return {"message": "ok"}
@router.get("/credit-ratios/grouped", response_model=dict)
async def list_credit_ratios_grouped(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(CreditRatio))
ratios = result.scalars().all()
grouped = {}
for ratio in ratios:
if ratio.gen_type not in grouped:
grouped[ratio.gen_type] = []
grouped[ratio.gen_type].append(CreditRatioOut.model_validate(ratio))
return grouped
# ── Model Config ─────────────────────────────────────────
@router.get("/model-configs", response_model=list[ModelConfigOut])
async def list_model_configs(
include_deleted: bool = Query(False),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
query = select(ModelConfig)
if not include_deleted:
query = query.where(ModelConfig.deleted_at.is_(None))
result = await db.execute(query.order_by(ModelConfig.priority.desc(), ModelConfig.id.desc()))
return result.scalars().all()
@router.post("/model-configs", response_model=ModelConfigOut)
async def create_model_config(
req: ModelConfigCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
if req.provider == "mock":
raise HTTPException(status_code=400, detail="正式LLM计费链路禁止新增Mock模型")
config = ModelConfig(id=generate_id(), **req.model_dump())
db.add(config)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"创建模型配置: {req.name}",
"POST",
"/admin/model-configs",
detail=json.dumps(
{
"config_id": config.id,
"name": req.name,
},
ensure_ascii=False,
),
)
return config
@router.put("/model-configs/{config_id}", response_model=ModelConfigOut)
async def update_model_config(
config_id: str,
req: ModelConfigCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
if req.provider == "mock":
raise HTTPException(status_code=400, detail="正式LLM计费链路禁止使用Mock模型")
result = await db.execute(select(ModelConfig).where(ModelConfig.id == config_id, ModelConfig.deleted_at.is_(None)).limit(1))
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=404, detail="配置不存在")
for k, v in req.model_dump().items():
setattr(config, k, v)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"更新模型配置: {config.name}",
"PUT",
f"/admin/model-configs/{config_id}",
detail=json.dumps(
{
"config_id": config_id,
"name": config.name,
},
ensure_ascii=False,
),
)
return config
@router.delete("/model-configs/{config_id}")
async def delete_model_config(
config_id: str,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(ModelConfig).where(ModelConfig.id == config_id, ModelConfig.deleted_at.is_(None)).limit(1))
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=404, detail="配置不存在")
config_name = config.name
config.deleted_at = datetime.now(timezone.utc)
config.is_active = False
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"软删除模型配置: {config_name}",
"DELETE",
f"/admin/model-configs/{config_id}",
detail=json.dumps(
{
"config_id": config_id,
"name": config_name,
},
ensure_ascii=False,
),
)
return {"message": "ok"}
# ── System Config ────────────────────────────────────────
@router.get("/system-configs", response_model=list[SystemConfigOut])
async def list_system_configs(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(SystemConfig))
return result.scalars().all()
@router.post("/system-configs", response_model=SystemConfigOut)
async def create_system_config(
req: SystemConfigCreate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
from app.utils.id_gen import generate_id
config = SystemConfig(
id=generate_id(),
key=req.key,
value=str(req.value),
description=req.description or "",
)
db.add(config)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"创建系统配置: {config.key}",
"POST",
"/admin/system-configs",
detail=json.dumps({"key": req.key, "value": req.value}, ensure_ascii=False),
)
await db.commit()
await invalidate_system_config_cache([req.key])
await db.refresh(config)
return config
@router.put("/system-configs/{config_id}", response_model=SystemConfigOut)
async def update_system_config(
config_id: str,
req: SystemConfigUpdate,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(SystemConfig).where(SystemConfig.id == config_id).limit(1))
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=404, detail="配置不存在")
config.value = str(req.value)
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"更新系统配置: {config.key}",
"PUT",
f"/admin/system-configs/{config_id}",
detail=json.dumps(
{
"config_id": config_id,
"key": config.key,
"value": req.value,
},
ensure_ascii=False,
),
)
updated_key = str(config.key)
await db.commit()
await invalidate_system_config_cache([updated_key])
await db.refresh(config)
return config
# ── Operation Logs ──────────────────────────────────────
@router.get("/operation-logs")
async def list_operation_logs(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=500),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
query = select(OperationLog).order_by(OperationLog.created_at.desc())
count_query = select(func.count(OperationLog.id))
total = (await db.execute(count_query)).scalar() or 0
result = await db.execute(query.offset((page - 1) * page_size).limit(page_size))
items = result.scalars().all()
return {
"total": total,
"items": [
{
"id": item.id,
"user_id": item.user_id,
"username": item.username,
"action": item.action,
"method": item.method,
"path": item.path,
"detail": item.detail,
"ip": item.ip,
"created_at": _iso(item.created_at),
}
for item in items
],
}
def _build_param_out(model_map: dict[str, dict[str, int]]) -> list[VideoParamOut]:
"""将 {模型: {标签: 数量}} 转为扁平列表,按模型+数量排序。"""
result: list[VideoParamOut] = []
for model, labels in model_map.items():
for label, count in sorted(labels.items(), key=lambda x: -x[1]):
result.append(VideoParamOut(model=model, label=label, count=count))
return result
# ── Stats ────────────────────────────────────────────────
@router.get("/stats", response_model=AdminStatsOut)
async def get_stats(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
start_date: str = Query(None),
end_date: str = Query(None),
):
today_start = datetime.now(CST).replace(hour=0, minute=0, second=0, microsecond=0)
try:
if start_date:
date_start = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=CST)
else:
date_start = today_start
if end_date:
date_end = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=CST)
date_end = date_end.replace(hour=23, minute=59, second=59, microsecond=999999)
else:
date_end = datetime.now(CST)
except:
date_start = today_start
date_end = datetime.now(CST)
total_users = (await db.execute(
select(func.count(User.id)).where(
User.user_type == "frontend",
User.created_at >= date_start,
User.created_at <= date_end,
)
)).scalar() or 0
total_projects = (await db.execute(
select(func.count(Project.id)).where(
Project.deleted_at.is_(None),
Project.created_at >= date_start,
Project.created_at <= date_end,
)
)).scalar() or 0
total_generations = (await db.execute(
select(func.count(ChatGenerationTask.id)).where(
ChatGenerationTask.created_at >= date_start,
ChatGenerationTask.created_at <= date_end,
)
)).scalar() or 0
total_records = (await db.execute(
select(func.count(GenerationRecord.id)).where(
GenerationRecord.deleted_at.is_(None),
GenerationRecord.created_at >= date_start,
GenerationRecord.created_at <= date_end,
)
)).scalar() or 0
total_revenue = (await db.execute(
select(func.coalesce(func.sum(PaymentOrder.amount), 0)).where(
PaymentOrder.status == "paid",
PaymentOrder.created_at >= date_start,
PaymentOrder.created_at <= date_end,
)
)).scalar() or 0
# 预扣占用不是实际消费;历史流水 charge_action 为空时仍按真实扣费兼容。
real_credit_charge_filter = or_(
CreditRecord.charge_action.is_(None),
CreditRecord.charge_action == "charge",
)
credits_consumed = (await db.execute(
select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where(
CreditRecord.type == "consume",
real_credit_charge_filter,
CreditRecord.created_at >= date_start,
CreditRecord.created_at <= date_end,
)
)).scalar() or 0
alipay_revenue = (await db.execute(
select(func.coalesce(func.sum(PaymentOrder.amount), 0)).where(
PaymentOrder.status == "paid",
PaymentOrder.payment_method == "alipay",
PaymentOrder.created_at >= date_start,
PaymentOrder.created_at <= date_end,
)
)).scalar() or 0
wechat_revenue = (await db.execute(
select(func.coalesce(func.sum(PaymentOrder.amount), 0)).where(
PaymentOrder.status == "paid",
PaymentOrder.payment_method == "wechat",
PaymentOrder.created_at >= date_start,
PaymentOrder.created_at <= date_end,
)
)).scalar() or 0
period_duration = date_end - date_start
last_period_start = date_start - period_duration
last_period_end = date_start
last_period_users = (await db.execute(
select(func.count(User.id)).where(
User.user_type == "frontend",
User.created_at >= last_period_start,
User.created_at <= last_period_end,
)
)).scalar() or 0
last_period_projects = (await db.execute(
select(func.count(Project.id)).where(
Project.deleted_at.is_(None),
Project.created_at >= last_period_start,
Project.created_at <= last_period_end,
)
)).scalar() or 0
last_period_generations = (await db.execute(
select(func.count(ChatGenerationTask.id)).where(
ChatGenerationTask.created_at >= last_period_start,
ChatGenerationTask.created_at <= last_period_end,
)
)).scalar() or 0
last_period_records = (await db.execute(
select(func.count(GenerationRecord.id)).where(
GenerationRecord.deleted_at.is_(None),
GenerationRecord.created_at >= last_period_start,
GenerationRecord.created_at <= last_period_end,
)
)).scalar() or 0
last_period_revenue = (await db.execute(
select(func.coalesce(func.sum(PaymentOrder.amount), 0)).where(
PaymentOrder.status == "paid",
PaymentOrder.created_at >= last_period_start,
PaymentOrder.created_at <= last_period_end,
)
)).scalar() or 0
last_period_credits_consumed = (await db.execute(
select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where(
CreditRecord.type == "consume",
real_credit_charge_filter,
CreditRecord.created_at >= last_period_start,
CreditRecord.created_at <= last_period_end,
)
)).scalar() or 0
# ── 每日各模块积分消耗(始终返回选中日期往前7天,便于图表展示)
from sqlalchemy import Date, cast as sa_cast
_day_expr = sa_cast(CreditRecord.created_at, Date)
# 图表固定展示 [date_end - 6天, date_end] 共7天
_chart_end_dt = date_end
_chart_start_dt = _chart_end_dt - timedelta(days=6)
_inner = (
select(
_day_expr.label('date'),
CreditRecord.source_module.label('module'),
func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0).label('credits'),
)
.where(
CreditRecord.type == "consume",
real_credit_charge_filter,
CreditRecord.created_at >= _chart_start_dt,
CreditRecord.created_at <= _chart_end_dt,
)
.group_by(_day_expr, CreditRecord.source_module)
.subquery()
)
daily_credits_rows = (await db.execute(
select(
_inner.c.date,
func.coalesce(_inner.c.module, 'other').label('module'),
_inner.c.credits,
).order_by(_inner.c.date)
)).all()
daily_credits_by_module = [
DailyCreditOut(date=str(row.date), module=row.module, credits=float(row.credits or 0))
for row in daily_credits_rows
]
# ── 选中周期内各模块积分占比(按 source_module 分组,不拆日期)
_period_inner = (
select(
CreditRecord.source_module.label('module'),
func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0).label('credits'),
)
.where(
CreditRecord.type == "consume",
real_credit_charge_filter,
CreditRecord.created_at >= date_start,
CreditRecord.created_at <= date_end,
)
.group_by(CreditRecord.source_module)
.subquery()
)
period_credits_rows = (await db.execute(
select(
func.coalesce(_period_inner.c.module, 'other').label('module'),
_period_inner.c.credits,
).order_by(_period_inner.c.credits.desc())
)).all()
period_credits_by_module = [
DailyCreditOut(date='', module=row.module, credits=float(row.credits or 0))
for row in period_credits_rows
]
# ── 各团队积分消耗(有团队 vs 无团队,使用流水中的团队快照)
team_credit_rows = (await db.execute(
select(
func.coalesce(CreditRecord.team_name_snapshot, '未分配团队').label('team_name'),
CreditRecord.team_id_snapshot.label('team_id'),
func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0).label('credits'),
)
.where(
CreditRecord.type == "consume",
real_credit_charge_filter,
CreditRecord.created_at >= date_start,
CreditRecord.created_at <= date_end,
)
.group_by(CreditRecord.team_id_snapshot, CreditRecord.team_name_snapshot)
.order_by(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0).desc())
)).all()
credits_by_team = [
TeamCreditOut(team_name=row.team_name, team_id=row.team_id, credits=float(row.credits or 0))
for row in team_credit_rows
]
# ── 各模型使用次数(通过 engine 快照字段统计)
model_usage_rows = (await db.execute(
select(
func.coalesce(CreditRecord.engine_name, '未知').label('model_name'),
func.coalesce(CreditRecord.engine_provider, 'unknown').label('provider'),
func.count(CreditRecord.id).label('count'),
)
.where(
CreditRecord.type == "consume",
real_credit_charge_filter,
CreditRecord.created_at >= date_start,
CreditRecord.created_at <= date_end,
)
.group_by(CreditRecord.engine_name, CreditRecord.engine_provider)
.order_by(func.count(CreditRecord.id).desc())
)).all()
model_usage = [
ModelUsageOut(model_name=row.model_name, provider=row.provider, count=int(row.count or 0))
for row in model_usage_rows
]
# ── 视频分辨率/比例/时长使用分布(按模型分组)
_video_gen_q = (
select(
func.coalesce(CreditRecord.engine_name, '未知').label('model'),
ChatGenerationTask.resolution,
ChatGenerationTask.aspect_ratio,
ChatGenerationTask.duration,
)
.join(CreditRecord, CreditRecord.related_id == ChatGenerationTask.id)
.where(
ChatGenerationTask.gen_type == "video",
CreditRecord.type == "consume",
real_credit_charge_filter,
ChatGenerationTask.created_at >= date_start,
ChatGenerationTask.created_at <= date_end,
ChatGenerationTask.deleted_at.is_(None),
)
)
_video_rows = (await db.execute(_video_gen_q)).all()
_res_map: dict[str, dict[str, int]] = {}
_ratio_map: dict[str, dict[str, int]] = {}
_dur_map: dict[str, dict[str, int]] = {}
for row in _video_rows:
model = row.model or '未知'
if row.resolution:
_res_map.setdefault(model, {})
_res_map[model][row.resolution] = _res_map[model].get(row.resolution, 0) + 1
if row.aspect_ratio:
_ratio_map.setdefault(model, {})
_ratio_map[model][row.aspect_ratio] = _ratio_map[model].get(row.aspect_ratio, 0) + 1
if row.duration:
_k = f"{row.duration}秒"
_dur_map.setdefault(model, {})
_dur_map[model][_k] = _dur_map[model].get(_k, 0) + 1
video_resolution_usage = _build_param_out(_res_map)
video_ratio_usage = _build_param_out(_ratio_map)
video_duration_usage = _build_param_out(_dur_map)
return AdminStatsOut(
total_users=total_users,
total_projects=total_projects,
total_generations=total_generations,
total_records=total_records,
total_revenue=float(total_revenue),
credits_consumed_today=float(credits_consumed),
today_alipay_revenue=float(alipay_revenue),
today_wechat_revenue=float(wechat_revenue),
last_period_users=last_period_users,
last_period_projects=last_period_projects,
last_period_generations=last_period_generations,
last_period_records=last_period_records,
last_period_revenue=float(last_period_revenue),
last_period_credits_consumed=float(last_period_credits_consumed),
daily_credits_by_module=daily_credits_by_module,
period_credits_by_module=period_credits_by_module,
credits_by_team=credits_by_team,
model_usage=model_usage,
video_resolution_usage=video_resolution_usage,
video_ratio_usage=video_ratio_usage,
video_duration_usage=video_duration_usage,
)
# ── Token Usage ─────────────────────────────────────────
@router.get("/token-usage")
async def list_token_usage(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=500),
user_id: str | None = Query(None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""List token usage records with optional user filter."""
query = select(TokenUsage).order_by(TokenUsage.created_at.desc())
if user_id:
query = query.where(TokenUsage.user_id == user_id)
count_query = select(func.count(TokenUsage.id))
if user_id:
count_query = count_query.where(TokenUsage.user_id == user_id)
total = (await db.execute(count_query)).scalar() or 0
result = await db.execute(query.offset((page - 1) * page_size).limit(page_size))
items = result.scalars().all()
return {
"total": total,
"items": [
{
"id": item.id,
"model_config_id": item.model_config_id,
"user_id": item.user_id,
"input_tokens": item.input_tokens,
"output_tokens": item.output_tokens,
"total_tokens": item.total_tokens,
"created_at": _iso(item.created_at),
}
for item in items
],
}
# ── Generation Records (Admin) ─────────────────────────────
@router.get("/generation-records")
async def admin_list_generation_records(
user_id: str | None = Query(None),
status: str | None = Query(None),
engine_id: str | None = Query(None),
include_media_references: bool | None = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=500),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""List all generation records across all users, with optional filters."""
query = (
select(GenerationRecord, User.username, Project.name, Project.industry, IndustryConfig.label)
.join(User, GenerationRecord.user_id == User.id)
.join(Project, GenerationRecord.project_id == Project.id)
.outerjoin(IndustryConfig, Project.industry == IndustryConfig.key)
.where(GenerationRecord.deleted_at.is_(None), Project.deleted_at.is_(None))
.order_by(GenerationRecord.created_at.desc())
)
if user_id:
query = query.where(GenerationRecord.user_id == user_id)
if status:
query = query.where(GenerationRecord.status == status)
if engine_id:
query = query.where(GenerationRecord.engine_id == engine_id)
if include_media_references is not None:
query = query.where(GenerationRecord.include_media_references.is_(include_media_references))
# Count total
count_query = (
select(func.count(GenerationRecord.id))
.join(Project, GenerationRecord.project_id == Project.id)
.where(GenerationRecord.deleted_at.is_(None), Project.deleted_at.is_(None))
)
if user_id:
count_query = count_query.where(GenerationRecord.user_id == user_id)
if status:
count_query = count_query.where(GenerationRecord.status == status)
if engine_id:
count_query = count_query.where(GenerationRecord.engine_id == engine_id)
if include_media_references is not None:
count_query = count_query.where(GenerationRecord.include_media_references.is_(include_media_references))
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Paginate
offset = (page - 1) * page_size
query = query.offset(offset).limit(page_size)
result = await db.execute(query)
rows = result.all()
refs_map = await batch_resolve_private_portrait_reference_display_urls(
db,
{record.id: json.loads(record.media_references) if record.media_references else None for record, _username, _project_name, _industry, _industry_label in rows},
user_id=user_id,
)
items = []
for record, username, project_name, industry, industry_label in rows:
refs = refs_map.get(record.id)
items.append({
"id": record.id,
"user_id": record.user_id,
"username": username,
"project_id": record.project_id,
"project_name": project_name,
"industry": industry_label or industry,
"original_prompt": record.original_prompt,
"optimized_prompt": record.optimized_prompt,
"duration": record.duration,
"aspect_ratio": record.aspect_ratio,
"resolution": record.resolution,
"status": record.status,
"pipeline_stage": record.pipeline_stage,
"video_upscale_enabled": bool(record.video_upscale_enabled_snapshot),
"video_url": build_resource_signed_url(record.video_url) if record.video_url else '',
"video_cover_url": build_resource_signed_url(record.video_cover_url) if record.video_cover_url else '',
"references": refs,
"engine_id": record.engine_id,
"engine_name": (_safe_json_object(record.engine_snapshot_json) or {}).get("name"),
"engine_snapshot": _safe_json_object(record.engine_snapshot_json),
"include_media_references": bool(record.include_media_references),
"credits_cost": record.credits_cost or 0,
"text_credits_cost": record.text_credits_cost or 0,
"text_tokens_used": record.text_tokens_used or 0,
"video_tokens_used": record.video_tokens_used or 0,
"error_message": record.error_message,
"created_at": _iso(record.created_at),
"generated_at": _iso(record.generated_at),
# append img param
"gen_type": record.gen_type,
"image_size": record.image_size or '',
"image_url": build_resource_signed_url(record.image_url) if record.image_url else '',
"image_tokens_used": record.image_tokens_used or 0,
"image_proportion": record.image_proportion or '',
"image_px": record.image_px or '',
})
return {"total": total, "items": items}
# ── File Uploads ─────────────────────────────────────────
import os
from fastapi import File, UploadFile
@router.post("/upload-pdf")
async def upload_pdf(
file: UploadFile = File(...),
config_key: str | None = None,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Upload a PDF file and save URL to system config."""
from app.config import settings
if not file.filename:
raise HTTPException(status_code=400, detail="请选择文件")
if not file.filename.lower().endswith(".pdf"):
raise HTTPException(status_code=400, detail="仅支持PDF格式")
content = await file.read()
if len(content) > 10 * 1024 * 1024:
raise HTTPException(status_code=400, detail="文件大小不能超过10MB")
safe_name = f"pdf_{generate_id()}.pdf"
file_path = os.path.join(settings.UPLOAD_LOCAL_PATH, safe_name)
with open(file_path, "wb") as f:
f.write(content)
url = f"/uploads/{safe_name}"
if config_key:
result = await db.execute(
select(SystemConfig).where(SystemConfig.key == config_key).limit(1)
)
config = result.scalar_one_or_none()
if config:
config.value = url
else:
db.add(SystemConfig(
id=generate_id(),
key=config_key,
value=url,
))
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"上传PDF文件: {file.filename}",
"POST",
"/admin/upload-pdf",
detail=json.dumps(
{
"filename": file.filename,
"config_key": config_key,
"url": url,
},
ensure_ascii=False,
),
)
await db.commit()
return {"url": url}
@router.post("/upload-logo")
async def upload_logo(
file: UploadFile = File(...),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Upload a Logo image file and save URL to system config."""
from app.config import settings
if not file.filename:
raise HTTPException(status_code=400, detail="请选择文件")
allowed_extensions = ('.png', '.jpg', '.jpeg', '.gif', '.webp')
if not file.filename.lower().endswith(allowed_extensions):
raise HTTPException(status_code=400, detail="仅支持 PNG、JPG、GIF、WebP 格式图片")
content = await file.read()
if len(content) > 2 * 1024 * 1024:
raise HTTPException(status_code=400, detail="文件大小不能超过2MB")
safe_name = "site_logo.png"
file_path = os.path.join(settings.UPLOAD_LOCAL_PATH, safe_name)
with open(file_path, "wb") as f:
f.write(content)
url = f"/uploads/{safe_name}"
result = await db.execute(
select(SystemConfig).where(SystemConfig.key == "site_logo").limit(1)
)
config = result.scalar_one_or_none()
if config:
config.value = url
else:
db.add(SystemConfig(
id="cfg_site_logo",
key="site_logo",
value=url,
description="网站Logo图片",
))
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"上传Logo图片: {file.filename}",
"POST",
"/admin/upload-logo",
detail=json.dumps(
{
"filename": file.filename,
"url": url,
},
ensure_ascii=False,
),
)
await db.commit()
return {"url": url}
@router.post("/upload-login-video")
async def upload_login_video(
file: UploadFile = File(...),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""上传登录页背景视频/动图,保存 URL 到 system config login_bg_video。"""
from app.config import settings
if not file.filename:
raise HTTPException(status_code=400, detail="请选择文件")
content = await file.read()
if len(content) > 50 * 1024 * 1024:
raise HTTPException(status_code=400, detail="文件大小不能超过50MB")
ext = os.path.splitext(file.filename)[1].lower()
safe_name = f"login_bg_{generate_id()}{ext}"
file_path = os.path.join(settings.UPLOAD_LOCAL_PATH, safe_name)
with open(file_path, "wb") as f:
f.write(content)
url = f"/uploads/{safe_name}"
result = await db.execute(
select(SystemConfig).where(SystemConfig.key == "login_bg_video").limit(1)
)
config = result.scalar_one_or_none()
if config:
config.value = url
else:
db.add(SystemConfig(
id=generate_id(),
key="login_bg_video",
value=url,
description="登录页背景视频",
))
await db.flush()
await log_operation(
db,
admin.id,
admin.username,
f"上传登录背景视频: {file.filename}",
"POST",
"/admin/upload-login-video",
detail=json.dumps({"filename": file.filename, "url": url}, ensure_ascii=False),
)
await db.commit()
return {"url": url}
# ── Payment Stats ────────────────────────────────────────