up
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user