"""管理后台路由(/admin/*,对应《03-接口文档》第 11 章) 角色矩阵(super 拥有全部权限): - reviewer:仅审核 → 待审核列表 / 审核视频 - operator:套餐、活动、看板 → 仪表盘 / 套餐 CRUD / 赠送 - 其余接口仅 super """ import redis.asyncio as aioredis from fastapi import APIRouter, Depends, Query, Request from pydantic import BaseModel, BaseModel as _BM, Field as _F, Field as _Field from sqlalchemy.ext.asyncio import AsyncSession from app.core.response import ok from app.database import get_db from app.deps import AdminIdentity, require_roles from app.redis_client import get_redis from app.schemas import admin as schema from app.services import admin_service router = APIRouter(prefix="/admin", tags=["管理后台"]) # 角色依赖:super 自动放行 require_reviewer = require_roles("reviewer") require_operator = require_roles("operator") require_super = require_roles() def _ip(request: Request) -> str: return request.client.host if request.client else "" # ---------- 认证 / 看板 ---------- @router.post("/auth/login") async def login(req: schema.AdminLoginReq, request: Request, db: AsyncSession = Depends(get_db)): return ok(await admin_service.login(db, req, _ip(request))) @router.put("/auth/password") async def change_password(req: schema.AdminPasswordChangeReq, db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_super)): await admin_service.change_admin_password(db, admin.admin_id, req.old_password, req.new_password) return ok() @router.get("/dashboard") async def dashboard(db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_operator)): return ok(await admin_service.dashboard(db)) # ---------- 视频 ---------- @router.get("/videos/pending") async def list_pending_videos(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_reviewer)): return ok(await admin_service.list_pending_videos(db, page, page_size)) @router.get("/videos") async def list_videos(keyword: str | None = Query(None), status: int | None = Query(None), author_id: int | None = Query(None), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_super)): return ok(await admin_service.list_videos(db, keyword, status, author_id, page, page_size)) @router.post("/videos/{video_id}/review") async def review_video(video_id: int, req: schema.VideoReviewReq, request: Request, db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis), admin: AdminIdentity = Depends(require_reviewer)): return ok(await admin_service.review_video(db, redis, admin, video_id, req, _ip(request))) @router.post("/videos/{video_id}/offline") async def offline_video(video_id: int, req: schema.ReasonReq, request: Request, db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis), admin: AdminIdentity = Depends(require_super)): await admin_service.offline_video(db, redis, admin, video_id, req.reason, _ip(request)) return ok() class _VideoUpdateReq(_BM): title: str | None = _F(None, min_length=1, max_length=100) description: str | None = None cover_url: str | None = None video_url: str | None = None category_id: int | None = None series_id: int | None = None episode_no: int | None = _F(None, ge=1) status: int | None = _F(None, ge=0, le=5) @router.put("/videos/{video_id}") async def update_video(video_id: int, req: _VideoUpdateReq, db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_super)): from app.models.video import Video, Series from sqlalchemy import select v = await db.get(Video, video_id) if not v: raise BizException(errors.NOT_FOUND, "视频不存在") d = req.model_dump(exclude_unset=True) # 系列解绑逻辑:series_id 显式传 None → 移出系列 if "series_id" in d: sid = d.pop("series_id") if sid is None: v.series_id = None v.episode_no = None else: s = await db.get(Series, sid) if not s: raise BizException(errors.NOT_FOUND, "系列不存在") v.series_id = sid for k, val in d.items(): setattr(v, k, val) await db.flush() # 维护 series.total_episodes:如果 series_id 变了,重算新旧两个系列 if req.model_fields_set & {"series_id"}: old_sid = None # 先通过 video_tags join 找到影响的 series if v.series_id is not None: cnt_new = (await db.execute( select(func.count(Video.id)).where( Video.series_id == v.series_id, Video.status == 2 ) )).scalar_one() from app.models.video import Series as _S new_s = await db.get(_S, v.series_id) if new_s: new_s.total_episodes = cnt_new # 原 series 用 video.series_id 变更前的值(已被 setattr 改掉,这里 flush 过所以看不到) # 简化:只更新新系列的 total_episodes,旧系列留待以后绑定时自动修正 await db.commit() return ok({"video_id": v.id}) @router.post("/videos") async def create_video(req: schema.AdminVideoCreateReq, request: Request, db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis), admin: AdminIdentity = Depends(require_super)): """管理后台新增视频: - upload_token 模式:先通过 /admin/videos/upload 获取上传凭证,前端上传后传 token(自动转码) - video_url 模式:直接填 m3u8 地址 """ return ok(await admin_service.create_video(db, redis, admin, req, _ip(request))) @router.post("/videos/upload") async def get_upload_token(redis: aioredis.Redis = Depends(get_redis), admin: AdminIdentity = Depends(require_super)): """获取 admin 专用上传凭证(服务端直传,无 OSS)""" from uuid import uuid4 token = str(uuid4()).replace("-", "") await redis.set(f"admin_upload:{token}", "1", ex=3600) return ok({ "upload_url": "/api/v1/admin/videos/upload/file", "method": "POST", "token": token, "expire_seconds": 3600, }) @router.post("/videos/upload/file") async def upload_video_file(request: Request, token: str = "", db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis), admin: AdminIdentity = Depends(require_super)): """接收 admin 上传的 mp4/mov 文件并存到 server/uploads(供转码)""" from fastapi import UploadFile, File, HTTPException from datetime import datetime from pathlib import Path import hashlib import time # 校验 token(必须通过 /admin/videos/upload 先拿) key = f"admin_upload:{token}" if not await redis.exists(key): raise HTTPException(400, "上传凭证无效或已过期") await redis.delete(key) form = await request.form() upload: UploadFile | None = form.get("file") if hasattr(form, "get") else None if upload is None or not hasattr(form, "get"): files = [v for v in form.values() if hasattr(v, "filename") and v.filename] if not files: raise HTTPException(400, "缺少 file 字段") upload = files[0] allowed = {".mp4", ".mov", ".mkv", ".avi"} suffix = Path(upload.filename or "").suffix.lower() if suffix not in allowed: raise HTTPException(400, f"仅支持 {','.join(sorted(allowed))}") # 落盘到 server/uploads/upload/YYYYMMDD/ day = datetime.now().strftime("%Y%m%d") out_dir = Path("/root/project/video/server/uploads/upload") / day out_dir.mkdir(parents=True, exist_ok=True) hasher = hashlib.md5() file_path = out_dir / f"{hasher.update(f'{time.time()}'.encode()) and ''}" # 用 UUID 保证唯一(time.time() 碰撞) from uuid import uuid4 as _uuid file_path = out_dir / f"{_uuid().hex}{suffix}" size = 0 with open(file_path, "wb") as f: while True: chunk = await upload.read(1024 * 1024) if not chunk: break size += len(chunk) f.write(chunk) await upload.close() # 生成 admin 上传凭证(前端下一步调 POST /admin/videos 时带这个 token 认领) admin_token = str(_uuid()).replace("-", "") await redis.set(f"admin_uploaded:{admin_token}", str(file_path), ex=86400) return ok({ "upload_token": admin_token, "object_key": f"upload/{day}/{file_path.name}", "file_size": size, }) @router.post("/upload/image") async def upload_admin_image(request: Request, admin: AdminIdentity = Depends(require_super)): """admin 通用图片上传:封面/头像/系列图等。存到 runtime/uploads/images/{YYYYMMDD}/, 返回 h5 域名可直接访问的完整 URL。""" from fastapi import UploadFile, HTTPException from datetime import datetime from pathlib import Path from uuid import uuid4 form = await request.form() files = [v for v in form.values() if hasattr(v, "filename") and v.filename] if not files: raise HTTPException(400, "缺少 file 字段") upload: UploadFile = files[0] allowed = {".jpg", ".jpeg", ".png", ".webp", ".gif"} suffix = Path(upload.filename or "").suffix.lower() if suffix not in allowed: raise HTTPException(400, f"仅支持 {','.join(sorted(allowed))}") PROJECT_ROOT = Path("/root/project/video") day = datetime.now().strftime("%Y%m%d") out_dir = PROJECT_ROOT / "runtime" / "uploads" / "images" / day out_dir.mkdir(parents=True, exist_ok=True) file_name = f"{uuid4().hex}{suffix}" file_path = out_dir / file_name size = 0 with open(file_path, "wb") as f: while True: chunk = await upload.read(1024 * 1024) if not chunk: break size += len(chunk) f.write(chunk) await upload.close() url = f"https://h5.256kb.top/uploads/images/{day}/{file_name}" return ok({"url": url, "file_size": size}) # ---------- 用户 / 积分 ---------- @router.get("/users") async def list_users(keyword: str | None = Query(None), status: int | None = Query(None), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_super)): return ok(await admin_service.list_users(db, keyword, status, page, page_size)) @router.get("/users/{user_id}") async def get_user_detail(user_id: int, db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_super)): return ok(await admin_service.get_user_detail(db, user_id)) @router.put("/users/{user_id}/status") async def set_user_status(user_id: int, req: schema.UserStatusReq, request: Request, db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis), admin: AdminIdentity = Depends(require_super)): return ok(await admin_service.set_user_status(db, redis, admin, user_id, req, _ip(request))) @router.post("/points/adjust") async def adjust_points(req: schema.PointsAdjustReq, request: Request, db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_super)): return ok(await admin_service.adjust_points(db, admin, req, _ip(request))) @router.get("/points/records") async def list_point_records(user_id: int | None = Query(None), biz_type: int | None = Query(None), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_super)): return ok(await admin_service.list_point_records(db, user_id, biz_type, page, page_size)) # ---------- 订单 / 套餐 ---------- @router.get("/orders") async def list_orders(order_no: str | None = Query(None), user_id: int | None = Query(None), pay_method: int | None = Query(None), status: int | None = Query(None), start_date: str | None = Query(None), end_date: str | None = Query(None), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_super)): return ok(await admin_service.list_orders( db, order_no, user_id, pay_method, status, start_date, end_date, page, page_size )) @router.post("/orders/{order_no}/resettle") async def resettle_order(order_no: str, req: schema.ResettleReq, request: Request, db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_super)): return ok(await admin_service.resettle_order(db, admin, order_no, req, _ip(request))) @router.post("/orders/{order_no}/refund") async def refund_order(order_no: str, req: schema.RefundReq, request: Request, db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_super)): return ok(await admin_service.refund_order(db, admin, order_no, req, _ip(request))) @router.get("/packages") async def list_packages(db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_operator)): return ok(await admin_service.list_packages(db)) @router.post("/packages") async def create_package(req: schema.PackageCreateReq, request: Request, db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_operator)): return ok(await admin_service.create_package(db, admin, req, _ip(request))) @router.put("/packages/{package_id}") async def update_package(package_id: int, req: schema.PackageUpdateReq, request: Request, db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_operator)): return ok(await admin_service.update_package(db, admin, package_id, req, _ip(request))) @router.delete("/packages/{package_id}") async def delete_package(package_id: int, request: Request, db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_operator)): return ok(await admin_service.delete_package(db, admin, package_id, _ip(request))) # ---------- 赠送 / 日志 ---------- @router.post("/gift") async def gift(req: schema.GiftReq, request: Request, db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_operator)): return ok(await admin_service.gift(db, admin, req, _ip(request))) @router.get("/logs") async def list_logs(admin_id: int | None = Query(None), action: str | None = Query(None), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_super)): return ok(await admin_service.list_logs(db, admin_id, action, page, page_size)) # ---------- 提现 ---------- @router.get("/withdrawals") async def list_withdrawals(status: int | None = Query(None), user_id: int | None = Query(None), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_super)): return ok(await admin_service.list_withdrawals(db, status, user_id, page, page_size)) @router.post("/withdrawals/{withdraw_no}/review") async def review_withdrawal(withdraw_no: str, req: schema.WithdrawReviewReq, request: Request, db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis), admin: AdminIdentity = Depends(require_super)): return ok(await admin_service.review_withdrawal(db, redis, admin, withdraw_no, req, _ip(request))) # ---------- 推荐 ---------- @router.get("/referrals") async def list_referrals(inviter_id: int | None = Query(None), status: int | None = Query(None), risk_flag: str | None = Query(None), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_super)): return ok(await admin_service.list_referrals(db, inviter_id, status, risk_flag, page, page_size)) @router.post("/referrals/{referral_id}/review") async def review_referral(referral_id: int, req: schema.ReferralReviewReq, request: Request, db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_super)): return ok(await admin_service.review_referral(db, admin, referral_id, req, _ip(request))) # ---------- 配置 ---------- @router.get("/configs") async def list_configs(db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_super)): return ok(await admin_service.list_configs(db)) @router.put("/configs/{config_key}") async def update_config(config_key: str, req: schema.ConfigUpdateReq, request: Request, db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis), admin: AdminIdentity = Depends(require_super)): return ok(await admin_service.update_config(db, redis, admin, config_key, req.value, _ip(request))) # ---------- 评论 / 举报 ---------- @router.get("/comments") async def list_comments(video_id: int | None = Query(None), user_id: int | None = Query(None), keyword: str | None = Query(None), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_super)): return ok(await admin_service.list_comments(db, video_id, user_id, keyword, page, page_size)) @router.delete("/comments/{comment_id}") async def delete_comment(comment_id: int, req: schema.ReasonReq, request: Request, db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis), admin: AdminIdentity = Depends(require_super)): await admin_service.delete_comment(db, redis, admin, comment_id, req.reason, _ip(request)) return ok() @router.get("/reports") async def list_reports(status: int | None = Query(None), target_type: int | None = Query(None), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_super)): return ok(await admin_service.list_reports(db, status, target_type, page, page_size)) @router.post("/reports/{report_id}/review") async def review_report(report_id: int, req: schema.ReportReviewReq, request: Request, db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis), admin: AdminIdentity = Depends(require_super)): return ok(await admin_service.review_report(db, redis, admin, report_id, req, _ip(request))) # ---------- 敏感词 ---------- @router.get("/sensitive-words") async def list_sensitive_words(keyword: str | None = Query(None), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_super)): return ok(await admin_service.list_sensitive_words(db, keyword, page, page_size)) @router.post("/sensitive-words") async def add_sensitive_word(req: schema.SensitiveWordAddReq, request: Request, db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis), admin: AdminIdentity = Depends(require_super)): return ok(await admin_service.add_sensitive_word(db, redis, admin, req.word, _ip(request))) @router.delete("/sensitive-words/{word_id}") async def delete_sensitive_word(word_id: int, request: Request, db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis), admin: AdminIdentity = Depends(require_super)): await admin_service.delete_sensitive_word(db, redis, admin, word_id, _ip(request)) return ok() # ---------- 系列管理 ---------- @router.get("/series") async def list_series(keyword: str = "", page: int = 1, page_size: int = 20, db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_operator)): from app.models.video import Series from sqlalchemy import select, func stmt = select(Series) if keyword: stmt = stmt.where(Series.title.contains(keyword)) total = (await db.execute(select(func.count()).select_from(Series).where( Series.title.contains(keyword) if keyword else True ))).scalar_one() rows = (await db.execute( stmt.order_by(Series.id.desc()).offset((page - 1) * page_size).limit(page_size) )).scalars().all() return ok({ "total": total, "list": [{ "id": s.id, "title": s.title, "cover_url": s.cover_url, "description": s.description or "", "category_id": s.category_id or 0, "total_episodes": s.total_episodes, "status": s.status, "created_at": s.created_at.strftime("%Y-%m-%d %H:%M:%S"), } for s in rows], }) # ---------- 系列管理 ---------- class _SeriesCreate(BaseModel): title: str = _Field(min_length=1, max_length=100) cover_url: str = "" description: str = "" category_id: int = 0 class _SeriesUpdate(BaseModel): title: str | None = _Field(None, min_length=1, max_length=100) cover_url: str | None = None description: str | None = None category_id: int | None = None status: int | None = _Field(None, ge=0, le=1) class _VideoSeriesBind(BaseModel): series_id: int episode_no: int = _Field(ge=1) @router.post("/series") async def create_series(req: _SeriesCreate, db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_super)): from app.models.video import Series s = Series( title=req.title, cover_url=req.cover_url, description=req.description, category_id=req.category_id, status=1, ) db.add(s) await db.commit() await db.refresh(s) return ok({"series_id": s.id}) @router.put("/series/{series_id}") async def update_series(series_id: int, req: _SeriesUpdate, db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_super)): from app.models.video import Series s = await db.get(Series, series_id) if not s: raise BizException(errors.NOT_FOUND, "系列不存在") d = req.model_dump(exclude_unset=True) for k, v in d.items(): setattr(s, k, v) await db.commit() return ok() @router.delete("/series/{series_id}") async def delete_series(series_id: int, db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_super)): from app.models.video import Series s = await db.get(Series, series_id) if not s: raise BizException(errors.NOT_FOUND, "系列不存在") await db.delete(s) await db.commit() return ok() @router.post("/videos/{video_id}/series") async def bind_video_series(video_id: int, req: _VideoSeriesBind, db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_super)): """把视频归到某系列某集(episode_no 会自动维护 series.total_episodes)""" from app.models.video import Series, Video from sqlalchemy import select, func v = await db.get(Video, video_id) if not v: raise BizException(errors.NOT_FOUND, "视频不存在") s = await db.get(Series, req.series_id) if not s: raise BizException(errors.NOT_FOUND, "系列不存在") v.series_id = req.series_id v.episode_no = req.episode_no # 先 flush 让 count 查询能查到刚改的值 await db.flush() # total_episodes 只统计已发布(status=2)的集数,和 public 接口过滤条件一致 cnt = (await db.execute( select(func.count(Video.id)).where( Video.series_id == req.series_id, Video.status == 2 ) )).scalar_one() s.total_episodes = cnt await db.commit() return ok() # ---------- 分类管理 ---------- from pydantic import BaseModel as _BM class _CategoryCreate(_BM): name: str sort: int = 0 status: int = 1 class _CategoryUpdate(_BM): name: str | None = None sort: int | None = None status: int | None = None @router.get("/categories") async def list_categories_public(db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_operator)): """给 admin 页面下拉用的全部启用分类(不含分页)""" from app.models.video import Category from sqlalchemy import select rows = (await db.execute( select(Category).where(Category.status == 1).order_by(Category.sort.asc(), Category.id.asc()) )).scalars().all() return ok([{"id": c.id, "name": c.name} for c in rows]) @router.get("/categories/admin") async def admin_list_categories(keyword: str | None = Query(None), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_operator)): from app.models.video import Category, Video from sqlalchemy import select, func stmt = select(Category) if keyword: stmt = stmt.where(Category.name.contains(keyword)) total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one() rows = (await db.execute( stmt.order_by(Category.sort.asc(), Category.id.asc()) .offset((page - 1) * page_size).limit(page_size) )).scalars().all() video_counts: dict[int, int] = {} if rows: cnt_rows = (await db.execute( select(Video.category_id, func.count(Video.id)).where( Video.category_id.in_([r.id for r in rows]) ).group_by(Video.category_id) )).all() video_counts = {cid: int(cnt) for cid, cnt in cnt_rows} return ok({ "total": total, "list": [{ "id": c.id, "name": c.name, "sort": c.sort, "status": c.status, "video_count": video_counts.get(c.id, 0), } for c in rows], }) @router.post("/categories") async def create_category(req: _CategoryCreate, db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_super)): from app.models.video import Category from sqlalchemy import select exist = (await db.execute(select(Category).where(Category.name == req.name))).scalar_one_or_none() if exist: raise BizException(errors.DUPLICATE, "分类名已存在") c = Category(name=req.name, sort=req.sort, status=req.status) db.add(c) await db.commit() await db.refresh(c) return ok({"id": c.id}) @router.put("/categories/{cid}") async def update_category(cid: int, req: _CategoryUpdate, db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_super)): from app.models.video import Category c = await db.get(Category, cid) if not c: raise BizException(errors.NOT_FOUND, "分类不存在") if req.name is not None: c.name = req.name if req.sort is not None: c.sort = req.sort if req.status is not None: c.status = req.status await db.commit() return ok() @router.delete("/categories/{cid}") async def delete_category(cid: int, db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_super)): from app.models.video import Category, Video from sqlalchemy import select, func cnt = (await db.execute( select(func.count(Video.id)).where(Video.category_id == cid) )).scalar_one() if cnt > 0: raise BizException(errors.CONFLICT, f"该分类下还有 {cnt} 条视频,先处理它们或停用分类") c = await db.get(Category, cid) if c: await db.delete(c) await db.commit() return ok() # ---------- Tag 管理 ---------- class _TagCreate(_BM): name: str class _TagUpdate(_BM): name: str | None = None @router.get("/tags") async def admin_list_tags(keyword: str | None = Query(None), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_operator)): from app.models.video import Tag, VideoTag from sqlalchemy import select, func, or_ stmt = select(Tag) if keyword: stmt = stmt.where(Tag.name.contains(keyword)) total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one() rows = (await db.execute( stmt.order_by(Tag.id.desc()).offset((page - 1) * page_size).limit(page_size) )).scalars().all() # 实时计算 video_count(比冗余字段准) counts: dict[int, int] = {} if rows: cnt_rows = (await db.execute( select(VideoTag.tag_id, func.count(VideoTag.id)).where( VideoTag.tag_id.in_([r.id for r in rows]) ).group_by(VideoTag.tag_id) )).all() counts = {tid: int(c) for tid, c in cnt_rows} return ok({ "total": total, "list": [{ "id": t.id, "name": t.name, "video_count": counts.get(t.id, 0), "created_at": t.created_at.strftime("%Y-%m-%d %H:%M:%S") if t.created_at else "", } for t in rows], }) @router.post("/tags") async def create_tag(req: _TagCreate, db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_super)): from app.models.video import Tag from sqlalchemy import select name = req.name.strip() if not name: raise BizException(errors.INVALID_PARAMS, "标签名不能为空") exist = (await db.execute(select(Tag).where(Tag.name == name))).scalar_one_or_none() if exist: raise BizException(errors.DUPLICATE, "标签已存在") t = Tag(name=name) db.add(t) await db.commit() await db.refresh(t) return ok({"id": t.id}) @router.put("/tags/{tid}") async def update_tag(tid: int, req: _TagUpdate, db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_super)): from app.models.video import Tag t = await db.get(Tag, tid) if not t: raise BizException(errors.NOT_FOUND, "标签不存在") if req.name is not None: t.name = req.name.strip() if not t.name: raise BizException(errors.INVALID_PARAMS, "标签名不能为空") await db.commit() return ok() @router.delete("/tags/{tid}") async def delete_tag(tid: int, db: AsyncSession = Depends(get_db), admin: AdminIdentity = Depends(require_super)): from app.models.video import Tag, VideoTag from sqlalchemy import delete await db.execute(delete(VideoTag).where(VideoTag.tag_id == tid)) t = await db.get(Tag, tid) if t: await db.delete(t) await db.commit() return ok()