1
This commit is contained in:
Binary file not shown.
+278
-31
@@ -7,6 +7,7 @@
|
||||
"""
|
||||
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
|
||||
@@ -84,6 +85,68 @@ async def offline_video(video_id: int, req: schema.ReasonReq, request: 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),
|
||||
@@ -461,18 +524,16 @@ async def list_series(keyword: str = "", page: int = 1, page_size: int = 20,
|
||||
"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],
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
# ---------- 系列管理 ----------
|
||||
|
||||
from pydantic import BaseModel, Field as _Field
|
||||
|
||||
|
||||
class _SeriesCreate(BaseModel):
|
||||
title: str = _Field(min_length=1, max_length=100)
|
||||
@@ -485,6 +546,7 @@ 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)
|
||||
|
||||
|
||||
@@ -493,33 +555,6 @@ class _VideoSeriesBind(BaseModel):
|
||||
episode_no: int = _Field(ge=1)
|
||||
|
||||
|
||||
@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))
|
||||
count_stmt = select(func.count()).select_from(Series)
|
||||
if keyword:
|
||||
count_stmt = count_stmt.where(Series.title.contains(keyword))
|
||||
total = (await db.execute(count_stmt)).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,
|
||||
"total_episodes": s.total_episodes, "status": s.status,
|
||||
"created_at": s.created_at.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
} for s in rows],
|
||||
})
|
||||
|
||||
|
||||
@router.post("/series")
|
||||
async def create_series(req: _SeriesCreate, db: AsyncSession = Depends(get_db),
|
||||
admin: AdminIdentity = Depends(require_super)):
|
||||
@@ -589,3 +624,215 @@ async def bind_video_series(video_id: int, req: _VideoSeriesBind,
|
||||
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()
|
||||
|
||||
Binary file not shown.
@@ -163,6 +163,9 @@ def _video_item(video: Video, author: User | None) -> dict:
|
||||
"cover_url": video.cover_url,
|
||||
"video_url": video.video_url,
|
||||
"duration": video.duration,
|
||||
"category_id": video.category_id or 0,
|
||||
"series_id": video.series_id,
|
||||
"episode_no": video.episode_no,
|
||||
"status": video.status,
|
||||
"status_text": video_service.STATUS_TEXT.get(video.status, ""),
|
||||
"reject_reason": video.reject_reason,
|
||||
|
||||
Reference in New Issue
Block a user