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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+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()
+88
View File
@@ -115,3 +115,91 @@ async def report_complete(video_id: int, req: WatchCompleteReq, db: AsyncSession
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(get_identity)):
return ok(await video_service.report_complete(db, redis, identity, video_id, req.watch_duration))
# ---------- 系列(Series)----------
@router.get("/series/{series_id}")
async def get_series(series_id: int, db: AsyncSession = Depends(get_db)):
"""系列详情 + 全部集数列表(按 episode_no 排序)"""
from app.models.video import Series, Video
from sqlalchemy import select
series = await db.get(Series, series_id)
if not series or series.status != 1:
raise BizException(errors.NOT_FOUND, "系列不存在或已隐藏")
# 关联视频(已发布)
stmt = select(Video).where(
Video.series_id == series_id, Video.status == 2
).order_by(Video.episode_no.asc(), Video.id.asc())
eps = (await db.execute(stmt)).scalars().all()
return ok({
"series": {
"id": series.id,
"title": series.title,
"cover_url": series.cover_url,
"description": series.description,
"total_episodes": len(eps),
},
"episodes": [
{
"video_id": v.id,
"episode_no": v.episode_no,
"title": v.title,
"cover_url": v.cover_url,
"duration": v.duration,
"video_url": v.video_url,
}
for v in eps
],
})
@router.get("/{video_id}/series")
async def get_video_series(video_id: int, db: AsyncSession = Depends(get_db)):
"""当前视频所属系列 + 上下集(用于 PlayerScreen 抽屉入口)"""
from app.models.video import Series, Video
from sqlalchemy import select
video = await db.get(Video, video_id)
if not video or not video.series_id or video.status != 2:
return ok({"has_series": False})
series = await db.get(Series, video.series_id)
if not series or series.status != 1:
return ok({"has_series": False})
stmt = select(Video).where(
Video.series_id == series.id, Video.status == 2
).order_by(Video.episode_no.asc(), Video.id.asc())
all_eps = (await db.execute(stmt)).scalars().all()
# 找当前集索引
current_idx = next((i for i, v in enumerate(all_eps) if v.id == video_id), -1)
prev_ep = all_eps[current_idx - 1] if current_idx > 0 else None
next_ep = all_eps[current_idx + 1] if 0 <= current_idx < len(all_eps) - 1 else None
return ok({
"has_series": True,
"current_episode_no": video.episode_no,
"series": {
"id": series.id,
"title": series.title,
"cover_url": series.cover_url,
"total_episodes": len(all_eps),
},
"episodes": [
{
"video_id": v.id,
"episode_no": v.episode_no,
"title": v.title,
"cover_url": v.cover_url,
"duration": v.duration,
}
for v in all_eps
],
"prev_video_id": prev_ep.id if prev_ep else None,
"next_video_id": next_ep.id if next_ep else None,
})