Files
video-gen/video-gen-api/app/api/v1/admin.py
T

1225 lines
42 KiB
Python

from datetime import datetime, timezone, timedelta
import json
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import delete, func, 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.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.schemas.admin import (
CreditAdjustRequest,
ModelConfigCreate,
ModelConfigOut,
SystemConfigUpdate,
SystemConfigOut,
AdminUserOut,
AdminStatsOut,
CreateUserRequest,
UpdateMenusRequest,
ResetPasswordRequest,
OperationLogOut,
)
from app.schemas.industry import IndustryConfigCreate, IndustryConfigOut
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.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.generation_billing_service import (
OWNER_GENERATION_RECORD,
charge_generation_media_by_params,
get_next_credit_attempt_no,
)
from app.services.generation_refund_service import mark_generation_record_failed_and_refund_once
from app.utils.id_gen import generate_id
from app.schemas.generation import GenerationType, ASPECT_RATIOS, RESOLUTIONS
CST = timezone(timedelta(hours=8))
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", response_model=list[AdminUserOut])
async def list_users(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
search: str = Query(""),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
query = select(User).order_by(User.created_at.desc())
if search:
query = query.where(
(User.username.ilike(f"%{search}%"))
| (User.email.ilike(f"%{search}%"))
)
result = await db.execute(query.offset((page - 1) * page_size).limit(page_size))
return result.scalars().all()
@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,
credits=req.credits,
is_admin=(req.user_type == "admin"),
user_type=req.user_type,
allowed_menus=req.allowed_menus,
)
user.credits = round(user.credits, 2)
db.add(user)
await db.flush()
await log_operation(db, admin.id, admin.username, f"创建用户 {username}", "POST", "/admin/users", ip=None)
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")
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="用户不存在")
user.credits = round(user.credits, 2)
return user
@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:
await add_credits(db, user_id, req.amount, f"管理员调整: {req.description}")
else:
await deduct_credits(db, user_id, abs(req.amount), f"管理员调整: {req.description}")
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")
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")
return {"message": "ok"}
@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")
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")
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=100),
user_id: str | None = Query(None),
type: str | None = Query(None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""List all credit transaction records with filters."""
query = select(CreditRecord, User.username).join(
User, CreditRecord.user_id == User.id, isouter=True
).order_by(CreditRecord.created_at.desc())
count_query = select(func.count(CreditRecord.id))
if user_id:
query = query.where(CreditRecord.user_id == user_id)
count_query = count_query.where(CreditRecord.user_id == user_id)
if type:
query = query.where(CreditRecord.type == type)
count_query = count_query.where(CreditRecord.type == type)
total = (await db.execute(count_query)).scalar() or 0
result = await db.execute(query.offset((page - 1) * page_size).limit(page_size))
rows = result.all()
return {
"total": total,
"items": [
{
"id": record.id,
"user_id": record.user_id,
"username": username,
"type": record.type,
"amount": round(record.amount, 2),
"balance_after": round(record.balance_after, 2),
"description": record.description,
"related_id": record.related_id,
"created_at": _iso(record.created_at),
}
for record, username in rows
],
}
# ── Notification Admin ───────────────────────────────────
@router.get("/notifications")
async def list_admin_notifications(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
user_id: str | None = Query(None),
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""List all notifications (including broadcasts) with optional user_id filter."""
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)
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
)
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()
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/{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()
return {
"id": config.id,
"key": config.key,
"value": config.value,
"description": config.description,
}
# ── 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()
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()
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()
return {"message": "ok"}
# ── Video Engine ─────────────────────────────────────────
@router.get("/video-engines", response_model=list[VideoEngineOut])
async def list_video_engines(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(VideoEngine).order_by(VideoEngine.priority.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()
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).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()
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).limit(1)
)
engine = result.scalar_one_or_none()
if not engine:
raise HTTPException(status_code=404, detail="视频引擎不存在")
await db.delete(engine)
await db.flush()
return {"message": "ok"}
# ── Image Engine ──────────────────────────────────────────
@router.get("/image-engines", response_model=list[ImageEngineOut])
async def list_image_engines(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(ImageEngine).order_by(ImageEngine.priority.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()
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).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()
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).limit(1)
)
engine = result.scalar_one_or_none()
if not engine:
raise HTTPException(status_code=404, detail="图片引擎不存在")
await db.delete(engine)
await db.flush()
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).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))
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()
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()
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()
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(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(ModelConfig).order_by(ModelConfig.priority.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),
):
config = ModelConfig(id=generate_id(), **req.model_dump())
db.add(config)
await db.flush()
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),
):
result = await db.execute(select(ModelConfig).where(ModelConfig.id == config_id).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()
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).limit(1))
config = result.scalar_one_or_none()
if not config:
raise HTTPException(status_code=404, detail="配置不存在")
await db.delete(config)
await db.flush()
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.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 = req.value
await db.flush()
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=100),
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
],
}
# ── Stats ────────────────────────────────────────────────
@router.get("/stats", response_model=AdminStatsOut)
async def get_stats(
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
total_users = (await db.execute(
select(func.count(User.id)).where(User.user_type == "frontend")
)).scalar() or 0
total_projects = (await db.execute(select(func.count(Project.id)).where(Project.deleted_at.is_(None)))).scalar() or 0
total_generations = (
await db.execute(select(func.count(GenerationRecord.id)).where(GenerationRecord.deleted_at.is_(None)))
).scalar() or 0
total_revenue = (
await db.execute(
select(func.coalesce(func.sum(PaymentOrder.amount), 0)).where(
PaymentOrder.status == "paid"
)
)
).scalar() or 0
today_start = datetime.now().replace(
hour=0, minute=0, second=0, microsecond=0
)
credits_today = (
await db.execute(
select(func.coalesce(func.sum(func.abs(CreditRecord.amount)), 0)).where(
CreditRecord.type == "consume",
CreditRecord.created_at >= today_start,
)
)
).scalar() or 0
return AdminStatsOut(
total_users=total_users,
total_projects=total_projects,
total_generations=total_generations,
total_revenue=float(total_revenue),
credits_consumed_today=float(credits_today),
)
# ── 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=100),
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),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
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)
.join(User, GenerationRecord.user_id == User.id)
.join(Project, GenerationRecord.project_id == Project.id)
.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)
# Count total
count_query = select(func.count(GenerationRecord.id)).where(GenerationRecord.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)
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()
items = []
for record, username, project_name in rows:
refs = None
if record.media_references:
try:
refs = json.loads(record.media_references)
except (json.JSONDecodeError, TypeError):
refs = None
items.append({
"id": record.id,
"user_id": record.user_id,
"username": username,
"project_id": record.project_id,
"project_name": project_name,
"original_prompt": record.original_prompt,
"optimized_prompt": record.optimized_prompt,
"duration": record.duration,
"aspect_ratio": record.aspect_ratio,
"resolution": record.resolution,
"status": record.status,
"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,
"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}
@router.put("/generation-records/{record_id}/status")
async def admin_update_generation_status(
record_id: str,
body: dict,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Admin update generation record status (e.g., confirm/reject)."""
result = await db.execute(
select(GenerationRecord).where(
GenerationRecord.id == record_id,
GenerationRecord.deleted_at.is_(None),
)
.with_for_update()
.limit(1)
)
record = result.scalar_one_or_none()
if not record:
raise HTTPException(status_code=404, detail="记录不存在")
new_status = body.get("status")
if new_status not in ("prompt_optimized", "generating", "completed", "failed"):
raise HTTPException(status_code=400, detail="无效状态")
if new_status == "failed":
await mark_generation_record_failed_and_refund_once(
db,
record=record,
error_message=body.get("error_message") or record.error_message or "管理员设置为失败",
)
else:
record.status = new_status
if body.get("video_url"):
record.video_url = body["video_url"]
if body.get("video_cover_url"):
record.video_cover_url = body["video_cover_url"]
if body.get("image_url"):
record.image_url = body["image_url"]
if new_status == "completed":
record.generated_at = datetime.now()
await db.flush()
return {"message": "ok"}
@router.post("/generation-records/{record_id}/generate")
async def admin_generate_video(
record_id: str,
body: dict,
admin: User = Depends(get_admin_user),
db: AsyncSession = Depends(get_db),
):
"""Admin trigger video/image generation for a record with specified params."""
from app.models.project import Project
from app.services.video_queue import task_queue
result = await db.execute(
select(GenerationRecord, Project.name)
.join(Project, GenerationRecord.project_id == Project.id)
.where(
GenerationRecord.id == record_id,
GenerationRecord.deleted_at.is_(None),
Project.deleted_at.is_(None),
)
.with_for_update()
)
row = result.first()
if not row:
raise HTTPException(status_code=404, detail="记录不存在")
record, project_name = row
type_str = "视频" if record.gen_type == GenerationType.video else "图片"
if record.status not in ("prompt_optimized", "failed"):
raise HTTPException(status_code=400, detail=f"当前状态不允许生成{type_str}")
attempt_no = await get_next_credit_attempt_no(
db,
owner_type=OWNER_GENERATION_RECORD,
owner_id=record.id,
)
if record.gen_type == GenerationType.video:
# Video Generation
aspect_ratio = body.get("aspect_ratio", "16:9")
resolution = body.get("resolution", "720p")
if aspect_ratio not in ASPECT_RATIOS:
raise HTTPException(status_code=400, detail="不支持的画面比例")
if resolution not in RESOLUTIONS:
raise HTTPException(status_code=400, detail="不支持的分辨率")
duration = record.duration or 5
media_billing = await charge_generation_media_by_params(
db,
user_id=record.user_id,
record_id=record.id,
gen_type="video",
duration=duration,
resolution=resolution,
project_name=project_name,
description_prefix="视频生成(管理后台)",
owner_type=OWNER_GENERATION_RECORD,
attempt_no=attempt_no,
)
record.aspect_ratio = aspect_ratio
record.resolution = resolution
record.credits_cost = round(float(record.credits_cost or 0) + media_billing.total_charged, 2)
record.status = "generating"
record.error_message = None
record.video_url = None
record.video_cover_url = None
record.image_url = None
record.seedance_task_id = None
await db.flush()
try:
from app.services.video_gen import get_active_engine, submit_video_task
engine = await get_active_engine(db)
task_id = await submit_video_task(
db,
engine,
record,
include_media_references=False,
)
record.seedance_task_id = task_id
await db.flush()
await task_queue.enqueue(record_id)
except Exception as e:
await mark_generation_record_failed_and_refund_once(
db,
record=record,
error_message=str(e),
)
await db.flush()
elif record.gen_type == GenerationType.image:
# Image generation
post_image_size = body.get("image_size", "")
image_size = post_image_size or record.image_size or "2K"
media_billing = await charge_generation_media_by_params(
db,
user_id=record.user_id,
record_id=record.id,
gen_type="image",
image_size=image_size,
project_name=project_name,
description_prefix="图片生成(管理后台)",
owner_type=OWNER_GENERATION_RECORD,
attempt_no=attempt_no,
)
record.image_size = image_size
record.credits_cost = round(float(record.credits_cost or 0) + media_billing.total_charged, 2)
record.status = "generating"
record.error_message = None
record.image_url = None
record.video_url = None
record.video_cover_url = None
record.seedance_task_id = None
await db.flush()
try:
await task_queue.enqueue(record_id)
except Exception as e:
await mark_generation_record_failed_and_refund_once(
db,
record=record,
error_message=str(e),
)
await db.flush()
return {"message": "ok", "record_id": record_id}