1225 lines
42 KiB
Python
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}
|