This commit is contained in:
root
2026-10-16 13:51:00 +08:00
parent 6bf949949c
commit 8fc899d0a9
3830 changed files with 11228 additions and 188377 deletions
+250
View File
@@ -35,6 +35,14 @@ async def login(req: schema.AdminLoginReq, request: Request, db: AsyncSession =
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)):
@@ -76,6 +84,96 @@ async def offline_video(video_id: int, req: schema.ReasonReq, request: Request,
return ok()
@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.get("/users")
@@ -297,3 +395,155 @@ async def delete_sensitive_word(word_id: int, request: Request, db: AsyncSession
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,
"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)
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
status: int | None = _Field(None, ge=0, le=1)
class _VideoSeriesBind(BaseModel):
series_id: int
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)):
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()