up
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user