This commit is contained in:
lz_db
2026-10-25 11:46:44 +08:00
parent 893dfed0ed
commit 494f667719
63 changed files with 5213 additions and 239 deletions
Binary file not shown.
+377 -24
View File
@@ -66,11 +66,11 @@ async def list_pending_videos(page: int = Query(1, ge=1), page_size: int = Query
@router.get("/videos")
async def list_videos(keyword: str | None = Query(None), status: int | None = Query(None),
author_id: int | None = Query(None),
author_id: int | None = Query(None), source_id: int | 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_super)):
return ok(await admin_service.list_videos(db, keyword, status, author_id, page, page_size))
return ok(await admin_service.list_videos(db, keyword, status, author_id, page, page_size, source_id))
@router.post("/videos/{video_id}/review")
@@ -90,17 +90,90 @@ async def offline_video(video_id: int, req: schema.ReasonReq, request: Request,
return ok()
class _VideoBatchOffline(_BM):
video_ids: list[int] = _Field(min_length=1, max_length=500)
reason: str = _Field(default="管理员批量下架", max_length=200)
# 批量路由须在 /videos/{video_id} 之前注册,否则 "batch-offline" 会被当成 video_id
@router.post("/videos/batch-offline")
async def batch_offline_videos(req: _VideoBatchOffline, request: Request,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
admin: AdminIdentity = Depends(require_super)):
"""批量下架:复用单条下架逻辑(通知作者、写日志、清详情缓存),跳过非已发布"""
success = 0
skipped: list[int] = []
for vid in req.video_ids:
try:
await admin_service.offline_video(
db, redis, admin, vid, req.reason, _ip(request))
success += 1
except BizException:
await db.rollback()
skipped.append(vid)
return ok({"success": success, "skipped": skipped})
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
source_id: int | None = _F(None, ge=0)
series_id: int | None = None
episode_no: int | None = _F(None, ge=1)
status: int | None = _F(None, ge=0, le=5)
class _VideoBatchUpdate(BaseModel):
video_ids: list[int] = _Field(min_length=1, max_length=500)
source_id: int | None = _Field(None, ge=0)
status: int | None = _Field(None, ge=0, le=5)
category_id: int | None = _Field(None, ge=0)
# 批量路由须在 /videos/{video_id} 之前注册,否则 "batch" 会被当成 video_id
@router.put("/videos/batch")
async def batch_update_videos(req: _VideoBatchUpdate, request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
"""批量编辑视频属性(来源 / 状态 / 分类)"""
from app.models.admin import AdminLog
from app.models.video import Video
from sqlalchemy import select
if req.source_id is None and req.status is None and req.category_id is None:
raise BizException(errors.PARAM_ERROR, "至少要传一个要修改的字段")
rows = (await db.execute(select(Video).where(Video.id.in_(req.video_ids)))).scalars().all()
affected = 0
for v in rows:
changed = False
if req.source_id is not None and v.source_id != req.source_id:
v.source_id = req.source_id
changed = True
if req.status is not None and v.status != req.status:
v.status = req.status
changed = True
if req.category_id is not None and v.category_id != req.category_id:
v.category_id = req.category_id
changed = True
if changed:
affected += 1
db.add(AdminLog(
admin_id=admin.admin_id, action="video_batch_update",
target=f"videos:{len(req.video_ids)}",
detail=f"source_id={req.source_id} status={req.status} "
f"category_id={req.category_id} affected={affected}",
ip=_ip(request),
))
await db.commit()
return ok({"affected": affected})
@router.put("/videos/{video_id}")
async def update_video(video_id: int, req: _VideoUpdateReq,
db: AsyncSession = Depends(get_db),
@@ -332,6 +405,64 @@ async def list_users(keyword: str | None = Query(None), status: int | None = Que
return ok(await admin_service.list_users(db, keyword, status, page, page_size))
@router.get("/user-action-logs")
async def list_user_action_logs(user_id: int | None = Query(None),
action: str | None = Query(None),
keyword: str | None = Query(None),
start: str | None = Query(None),
end: 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_super)):
"""用户操作日志查询(仅 super 可见)。用于排查特定用户 bug 时间线"""
from app.models.user_action_log import UserActionLog
from sqlalchemy import select, func, desc
conds = []
if user_id:
conds.append(UserActionLog.user_id == user_id)
if action:
conds.append(UserActionLog.action == action)
if keyword:
conds.append(UserActionLog.detail.like(f"%{keyword}%"))
if start:
conds.append(UserActionLog.created_at >= start)
if end:
conds.append(UserActionLog.created_at <= end + " 23:59:59")
total = (await db.execute(
select(func.count()).select_from(UserActionLog).where(*conds)
)).scalar() or 0
rows = (await db.execute(
select(UserActionLog)
.where(*conds)
.order_by(desc(UserActionLog.id))
.offset((page - 1) * page_size)
.limit(page_size)
)).scalars().all()
return ok({
"total": total,
"page": page,
"page_size": page_size,
"items": [
{
"id": r.id,
"user_id": r.user_id,
"action": r.action,
"page": r.page,
"detail": r.detail,
"device_info": r.device_info,
"app_version": r.app_version,
"created_at": r.created_at.strftime("%Y-%m-%d %H:%M:%S") if r.created_at else "",
}
for r in rows
],
})
@router.get("/users/{user_id}")
async def get_user_detail(user_id: int, db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
@@ -557,18 +688,23 @@ async def delete_sensitive_word(word_id: int, request: Request, db: AsyncSession
# ---------- 系列管理 ----------
@router.get("/series")
async def list_series(keyword: str = "", page: int = 1, page_size: int = 20,
async def list_series(keyword: str = "", source_id: int | None = None,
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)
conds = []
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()
conds.append(Series.title.contains(keyword))
if source_id is not None:
conds.append(Series.source_id == source_id)
stmt = select(Series).where(*conds)
total = (await db.execute(
select(func.count()).select_from(Series).where(*conds)
)).scalar_one()
rows = (await db.execute(
stmt.order_by(Series.id.desc()).offset((page - 1) * page_size).limit(page_size)
)).scalars().all()
@@ -576,14 +712,16 @@ async def list_series(keyword: str = "", page: int = 1, page_size: int = 20,
# 关联采集记录(标记采集来源,供"同步更新"按钮使用)
from app.models.video import CollectionRecord
series_ids = [s.id for s in rows]
source_map: dict[int, int] = {}
collect_map: dict[int, int] = {}
if series_ids:
recs = (await db.execute(
select(CollectionRecord.series_id, CollectionRecord.source_id).where(
CollectionRecord.series_id.in_(series_ids)
)
)).all()
source_map = {r.series_id: r.source_id for r in recs}
collect_map = {r.series_id: r.source_id for r in recs}
names = await admin_service.source_names_map(db, {s.source_id for s in rows})
return ok({
"total": total,
@@ -591,13 +729,138 @@ async def list_series(keyword: str = "", page: int = 1, page_size: int = 20,
"id": s.id, "title": s.title, "cover_url": s.cover_url,
"description": s.description or "",
"category_id": s.category_id or 0,
"source_id": s.source_id or 0,
"source_name": names.get(s.source_id, ""),
"collect_source_id": collect_map.get(s.id),
"total_episodes": s.total_episodes, "status": s.status,
"source_id": source_map.get(s.id),
"created_at": s.created_at.strftime("%Y-%m-%d %H:%M:%S"),
} for s in rows],
})
# ---------- 内容来源管理(麻豆 / 91 / ...)----------
class _ContentSourceCreate(BaseModel):
name: str = _Field(min_length=1, max_length=50)
code: str = ""
sort: int = 0
status: int = _Field(1, ge=0, le=1)
class _ContentSourceUpdate(BaseModel):
name: str | None = _Field(None, min_length=1, max_length=50)
code: str | None = None
sort: int | None = None
status: int | None = _Field(None, ge=0, le=1)
async def _content_source_counts(db: AsyncSession, ids: list[int]) -> tuple[dict[int, int], dict[int, int]]:
"""批量统计各来源下的视频数/系列数"""
from app.models.video import Series, Video
from sqlalchemy import func, select
if not ids:
return {}, {}
v_rows = (await db.execute(
select(Video.source_id, func.count(Video.id))
.where(Video.source_id.in_(ids)).group_by(Video.source_id)
)).all()
s_rows = (await db.execute(
select(Series.source_id, func.count(Series.id))
.where(Series.source_id.in_(ids)).group_by(Series.source_id)
)).all()
return {r[0]: r[1] for r in v_rows}, {r[0]: r[1] for r in s_rows}
@router.get("/sources")
async def list_content_sources(db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
from app.models.video import Source
from sqlalchemy import select
rows = (await db.execute(select(Source).order_by(Source.sort, Source.id))).scalars().all()
v_cnt, s_cnt = await _content_source_counts(db, [s.id for s in rows])
return ok({"list": [{
"id": s.id, "name": s.name, "code": s.code, "sort": s.sort, "status": s.status,
"video_count": v_cnt.get(s.id, 0), "series_count": s_cnt.get(s.id, 0),
} for s in rows]})
@router.post("/sources")
async def create_content_source(req: _ContentSourceCreate, request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
from app.models.admin import AdminLog
from app.models.video import Source
from sqlalchemy import select
name = req.name.strip()
exists = (await db.execute(select(Source.id).where(Source.name == name))).scalar_one_or_none()
if exists:
raise BizException(errors.PARAM_ERROR, "来源名称已存在")
s = Source(name=name, code=req.code.strip(), sort=req.sort, status=req.status)
db.add(s)
await db.flush()
db.add(AdminLog(admin_id=admin.admin_id, action="source_create",
target=f"source:{s.id}", detail=name, ip=_ip(request)))
await db.commit()
await db.refresh(s)
return ok({"id": s.id, "name": s.name, "code": s.code, "sort": s.sort, "status": s.status,
"video_count": 0, "series_count": 0})
@router.put("/sources/{source_id}")
async def update_content_source(source_id: int, req: _ContentSourceUpdate, request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
from app.models.admin import AdminLog
from app.models.video import Source
from sqlalchemy import select
s = await db.get(Source, source_id)
if not s:
raise BizException(errors.NOT_FOUND, "来源不存在")
d = req.model_dump(exclude_unset=True)
if "name" in d:
d["name"] = d["name"].strip()
dup = (await db.execute(
select(Source.id).where(Source.name == d["name"], Source.id != source_id)
)).scalar_one_or_none()
if dup:
raise BizException(errors.PARAM_ERROR, "来源名称已存在")
for k, v in d.items():
setattr(s, k, v)
db.add(AdminLog(admin_id=admin.admin_id, action="source_update",
target=f"source:{source_id}", detail=str(d), ip=_ip(request)))
await db.commit()
await db.refresh(s)
return ok({"id": s.id, "name": s.name, "code": s.code, "sort": s.sort, "status": s.status})
@router.delete("/sources/{source_id}")
async def delete_content_source(source_id: int, request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
from app.models.admin import AdminLog
from app.models.video import CollectionSource, Source
from sqlalchemy import update
s = await db.get(Source, source_id)
if not s:
raise BizException(errors.NOT_FOUND, "来源不存在")
v_cnt, s_cnt = await _content_source_counts(db, [source_id])
if v_cnt.get(source_id, 0) or s_cnt.get(source_id, 0):
raise BizException(errors.PARAM_ERROR, "该来源下仍有视频或系列,不能删除(可改为停用)")
# 采集源解除绑定
await db.execute(update(CollectionSource).where(CollectionSource.source_id == source_id)
.values(source_id=0))
await db.delete(s)
db.add(AdminLog(admin_id=admin.admin_id, action="source_delete",
target=f"source:{source_id}", detail=s.name, ip=_ip(request)))
await db.commit()
return ok()
# ---------- 系列管理 ----------
@@ -606,6 +869,7 @@ class _SeriesCreate(BaseModel):
cover_url: str = ""
description: str = ""
category_id: int = 0
source_id: int = 0
class _SeriesUpdate(BaseModel):
@@ -613,6 +877,7 @@ class _SeriesUpdate(BaseModel):
cover_url: str | None = None
description: str | None = None
category_id: int | None = None
source_id: int | None = None
status: int | None = _Field(None, ge=0, le=1)
@@ -654,18 +919,19 @@ class _SeriesBatchUpdate(BaseModel):
series_ids: list[int] = _Field(min_length=1, max_length=200)
status: int | None = _Field(None, ge=0, le=1)
category_id: int | None = _Field(None, ge=0)
source_id: int | None = _Field(None, ge=0)
@router.put("/series/batch")
async def batch_update_series(req: _SeriesBatchUpdate, request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
"""批量编辑系列属性(当前支持 status / category_id)"""
"""批量编辑系列属性(当前支持 status / category_id / source_id)"""
from app.models.admin import AdminLog
from app.models.video import Series
from sqlalchemy import select
if req.status is None and req.category_id is None:
if req.status is None and req.category_id is None and req.source_id is None:
raise BizException(errors.PARAM_ERROR, "至少要传一个要修改的字段")
stmt = select(Series).where(Series.id.in_(req.series_ids))
@@ -679,13 +945,17 @@ async def batch_update_series(req: _SeriesBatchUpdate, request: Request,
if req.category_id is not None and s.category_id != req.category_id:
s.category_id = req.category_id
changed = True
if req.source_id is not None and s.source_id != req.source_id:
s.source_id = req.source_id
changed = True
if changed:
affected += 1
db.add(AdminLog(
admin_id=admin.admin_id, action="series_batch_update",
target=f"series:{','.join(str(i) for i in req.series_ids)}",
detail=f"status={req.status} category_id={req.category_id} affected={affected}",
detail=f"status={req.status} category_id={req.category_id} "
f"source_id={req.source_id} affected={affected}",
ip=_ip(request),
))
await db.commit()
@@ -698,7 +968,7 @@ async def create_series(req: _SeriesCreate, db: AsyncSession = Depends(get_db),
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,
category_id=req.category_id, source_id=req.source_id, status=1,
)
db.add(s)
await db.commit()
@@ -834,19 +1104,40 @@ async def collect_sync_all(req: _CollectSyncAllReq, request: Request,
class _SourceCreate(BaseModel):
name: str = _Field(min_length=1, max_length=50)
kind: str = _Field("url", pattern="^(url|aidrama91|aidrama91_vlog)$")
list_url: str = _Field(min_length=10, max_length=500)
source_id: int = 0
account: str = "" # aidrama91:源站登录账号
password: str = "" # aidrama91:源站登录密码
category_ids: list[int] = _Field(default_factory=lambda: [4]) # aidrama91:源站分类
page_size: int = _Field(20, ge=1, le=20)
class _SourceUpdate(BaseModel):
name: str | None = _Field(None, min_length=1, max_length=50)
list_url: str | None = _Field(None, min_length=10, max_length=500)
source_id: int | None = None
status: int | None = _Field(None, ge=0, le=1)
account: str | None = None
password: str | None = None
category_ids: list[int] | None = None
page_size: int | None = _Field(None, ge=1, le=20)
def _source_dict(s) -> dict:
import json as _json
try:
cfg = _json.loads(s.config or "{}")
except Exception:
cfg = {}
return {
"id": s.id, "name": s.name, "list_url": s.list_url,
"category_id": s.category_id, "status": s.status,
"id": s.id, "name": s.name, "kind": s.kind or "url", "list_url": s.list_url,
"category_id": s.category_id, "source_id": s.source_id or 0, "status": s.status,
"account": cfg.get("account", ""),
"category_ids": cfg.get("category_ids") or [],
"page_size": cfg.get("page_size") or 20,
"has_token": bool(cfg.get("token")),
"last_sync_at": s.last_sync_at.strftime("%Y-%m-%d %H:%M:%S") if s.last_sync_at else "",
"created_at": s.created_at.strftime("%Y-%m-%d %H:%M:%S"),
}
@@ -881,18 +1172,34 @@ async def list_sources(db: AsyncSession = Depends(get_db),
async def create_source(req: _SourceCreate, request: Request, db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
"""添加采集源:自动按名称建/找分类"""
import json as _json
from app.models.admin import AdminLog
from app.models.video import CollectionSource
from app.services import aidrama91_service
if "{{page}}" not in req.list_url:
if req.kind == "url" and "{{page}}" not in req.list_url:
raise BizException(errors.PARAM_ERROR, "URL 需包含 {{page}} 页码占位符")
if req.kind in ("aidrama91", "aidrama91_vlog") and not (req.account and req.password):
raise BizException(errors.PARAM_ERROR, "91 源需填写源站账号和密码")
cid = await _resolve_category(db, req.name.strip())
s = CollectionSource(name=req.name.strip(), list_url=req.list_url.strip(), category_id=cid)
cfg = {}
if req.kind in ("aidrama91", "aidrama91_vlog"):
cfg = {"account": req.account, "password": req.password,
"category_ids": req.category_ids or [4], "page_size": req.page_size}
s = CollectionSource(name=req.name.strip(), kind=req.kind, list_url=req.list_url.strip(),
config=_json.dumps(cfg, ensure_ascii=False),
category_id=cid, source_id=req.source_id)
if req.kind in ("aidrama91", "aidrama91_vlog"):
s.source_id = await aidrama91_service.refresh_source_id(db, s)
if req.kind == "aidrama91_vlog":
s.category_id = await aidrama91_service.ensure_local_category(
db, aidrama91_service.VLOG_CATEGORY_NAME)
db.add(s)
await db.flush()
db.add(AdminLog(admin_id=admin.admin_id, action="collection_source_create",
target=f"source:{s.id}", detail=f"{s.name} {s.list_url}", ip=_ip(request)))
target=f"source:{s.id}", detail=f"{s.name} {s.kind} {s.list_url}", ip=_ip(request)))
await db.commit()
await db.refresh(s)
return ok(_source_dict(s))
@@ -901,17 +1208,28 @@ async def create_source(req: _SourceCreate, request: Request, db: AsyncSession =
@router.put("/collection/sources/{source_id}")
async def update_source(source_id: int, req: _SourceUpdate, db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
import json as _json
from app.models.video import CollectionSource
s = await db.get(CollectionSource, source_id)
if not s:
raise BizException(errors.NOT_FOUND, "采集源不存在")
d = req.model_dump(exclude_unset=True)
if "list_url" in d and "{{page}}" not in d["list_url"]:
if "list_url" in d and (s.kind or "url") == "url" and "{{page}}" not in d["list_url"]:
raise BizException(errors.PARAM_ERROR, "URL 需包含 {{page}} 页码占位符")
if "name" in d:
d["category_id"] = await _resolve_category(db, d["name"].strip())
d["name"] = d["name"].strip()
# 接口类源的账号/密码/分类等写入 config
cfg_keys = ("account", "password", "category_ids", "page_size")
cfg = _json.loads(s.config or "{}")
for k in cfg_keys:
if k in d:
cfg[k] = d.pop(k)
s.config = _json.dumps(cfg, ensure_ascii=False)
for k, v in d.items():
setattr(s, k, v)
await db.commit()
@@ -991,23 +1309,42 @@ async def batch_create_sources(req: _SourceBatchReq, request: Request, db: Async
_sync_running_sources: set[int] = set()
_sync_cancelled_sources: set[int] = set()
async def _run_source_sync(source_id: int, max_pages: int) -> None:
from app.database import SessionFactory
from app.models.video import CollectionSource
from app.services import collection_service
from app.services import aidrama91_service, collection_service
try:
async with SessionFactory() as s:
source = await s.get(CollectionSource, source_id)
if source:
stat = await collection_service.sync_source(s, source, max_pages=max_pages)
logger.info("采集源 %s 同步完成: %s", source_id, stat)
# 传入 should_cancel 回调,循环里每页/每条检查一次
def _cancelled() -> bool:
return source_id in _sync_cancelled_sources
if source.kind == aidrama91_service.KIND:
await aidrama91_service.refresh_source_id(s, source)
stat = await aidrama91_service.sync_source(
s, source, max_pages=max_pages, should_cancel=_cancelled)
elif source.kind == aidrama91_service.VLOG_KIND:
await aidrama91_service.refresh_source_id(s, source)
stat = await aidrama91_service.sync_vlog_source(
s, source, max_pages=max_pages, should_cancel=_cancelled)
else:
stat = await collection_service.sync_source(
s, source, max_pages=max_pages, should_cancel=_cancelled)
if source_id in _sync_cancelled_sources:
logger.info("采集源 %s 同步被取消: %s", source_id, stat)
else:
logger.info("采集源 %s 同步完成: %s", source_id, stat)
except Exception:
logger.exception("采集源 %s 同步失败", source_id)
finally:
_sync_running_sources.discard(source_id)
_sync_cancelled_sources.discard(source_id)
class _SourceSyncReq(BaseModel):
@@ -1063,6 +1400,22 @@ async def sync_all_sources(request: Request, db: AsyncSession = Depends(get_db),
return ok({"started": started, "skipped_running": [i for i in ids if i not in started]})
@router.post("/collection/sources/{source_id}/sync/cancel")
async def cancel_source_sync(source_id: int, request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
"""请求取消某个采集源的后台同步:仅打个标记,正在执行的任务在下个分页边界自然退出"""
from app.models.admin import AdminLog
if source_id not in _sync_running_sources:
raise BizException(errors.NOT_FOUND, "该采集源当前没有正在运行的同步任务")
_sync_cancelled_sources.add(source_id)
db.add(AdminLog(admin_id=admin.admin_id, action="collection_source_sync_cancel",
target=f"source:{source_id}", detail="", ip=_ip(request)))
await db.commit()
return ok({"cancel_requested": True, "source_id": source_id})
# ---------- 分类管理 ----------
from pydantic import BaseModel as _BM
+74
View File
@@ -0,0 +1,74 @@
"""用户操作日志上报:仅特定账号(如 256kb@gmail.com)开启,用于排查 bug"""
import logging
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.deps import Identity, require_user
from app.core import errors
from app.core.response import BizException, ok
from app.models.user import User
from app.models.user_action_log import UserActionLog
logger = logging.getLogger(__name__)
router = APIRouter()
# 仅这些邮箱的用户允许上报操作日志(白名单,避免给所有用户上传)
ALLOWED_EMAILS = {"256kb@gmail.com"}
# 单条 detail 最大长度(防御性截断)
MAX_DETAIL_LEN = 800
# 单次批量最大条数
MAX_BATCH = 100
class ActionLogItem(BaseModel):
action: str = Field(..., max_length=50)
page: str = Field("", max_length=100)
detail: str = Field("", max_length=MAX_DETAIL_LEN)
device_info: str = Field("", max_length=200)
app_version: str = Field("", max_length=20)
# 客户端本地时间戳(毫秒),用于在服务端时钟漂移时仍能排序
client_ts: int = 0
class ActionLogBatch(BaseModel):
items: list[ActionLogItem] = Field(..., max_length=MAX_BATCH)
@router.post("/logs/action")
async def report_action_log(
body: ActionLogBatch,
request: Request,
db: AsyncSession = Depends(get_db),
identity: Identity = Depends(require_user),
):
"""接收 App 上报的用户操作日志。仅白名单邮箱可上报,其他账号返回 403 不上报"""
user = (await db.execute(
select(User).where(User.id == identity.user_id)
)).scalar_one_or_none()
if not user or user.email not in ALLOWED_EMAILS:
# 静默拒绝:不报错,不写入,避免在普通用户端暴露此通道存在
raise BizException(errors.FORBIDDEN, "forbidden")
rows = [
UserActionLog(
user_id=user.id,
action=item.action[:50],
page=item.page[:100],
detail=item.detail[:MAX_DETAIL_LEN],
device_info=item.device_info[:200],
app_version=item.app_version[:20],
)
for item in body.items
]
db.add_all(rows)
await db.commit()
logger.info(
"user_action_log user=%s count=%s ip=%s",
user.id, len(rows), request.client.host if request.client else "-",
)
return ok({"received": len(rows)})
+106 -2
View File
@@ -1,14 +1,25 @@
"""媒体代理:m3u8 播放列表转发(源站 playlist 内 TS 链接带时效签名,每次回源拉取新鲜版本)"""
"""媒体代理
- /m3u8/proxy :源站路径转发(playlist 内 TS 链接带时效签名,每次回源拉新鲜版本)
- /m3u8/aidrama :91 短剧单集播放列表(上游 m3u8 地址 4 小时过期,播放时现取)
- /img/aidrama :91 封面图解密代理(CDN 返回 AES 加密图片字节,代理解密后输出)
"""
import logging
import re
import httpx
from fastapi import APIRouter, Query, Response
from fastapi import APIRouter, Depends, Query, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.errors import NOT_FOUND, PARAM_ERROR
from app.core.response import BizException
from app.database import get_db
from app.services.collection_service import HEADERS, UPSTREAM
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/m3u8", tags=["媒体"])
img_router = APIRouter(prefix="/img", tags=["媒体"])
# 只允许相对资源路径,防 SSRF
_PATH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9/._-]*$")
@@ -32,3 +43,96 @@ async def m3u8_proxy(path: str = Query(min_length=1, max_length=300)):
media_type="application/vnd.apple.mpegurl",
headers={"Cache-Control": "no-cache", "Access-Control-Allow-Origin": "*"},
)
@router.get("/aidrama")
async def aidrama_playlist(
d: int = Query(gt=0, description="源站短剧 id"),
e: int = Query(gt=0, description="源站集 id"),
n: int | None = Query(None, ge=1, description="集号(用于定位分页)"),
db: AsyncSession = Depends(get_db),
):
"""91 短剧播放列表:上游每次播放重新签发 TS 地址,避免存下来的签名过期"""
from app.services import aidrama91_service
try:
source = await aidrama91_service.get_collection_source(db)
text = await aidrama91_service.fetch_playlist(source, d, e, n)
except Exception:
logger.exception("91 播放列表获取失败 drama=%s ep=%s", d, e)
raise BizException(NOT_FOUND, "播放列表获取失败")
return Response(
content=text,
media_type="application/vnd.apple.mpegurl",
headers={"Cache-Control": "no-cache", "Access-Control-Allow-Origin": "*"},
)
@router.get("/vlog")
async def vlog_playlist(
vid: int = Query(gt=0, description="源站短视频 id"),
db: AsyncSession = Depends(get_db),
):
"""91 短视频播放列表:playUrl 带时效签名,播放时现取"""
from app.services import aidrama91_service
try:
source = await aidrama91_service.get_vlog_source(db)
text = await aidrama91_service.fetch_vlog_playlist(source, vid)
except Exception:
logger.exception("91 vlog 播放列表获取失败 vid=%s", vid)
raise BizException(NOT_FOUND, "播放列表获取失败")
return Response(
content=text,
media_type="application/vnd.apple.mpegurl",
headers={"Cache-Control": "no-cache", "Access-Control-Allow-Origin": "*"},
)
@img_router.get("/aidrama", include_in_schema=False)
async def aidrama_image(u: str = Query(min_length=10, max_length=600)):
"""91 封面解密代理:CDN 图为 AES-128-CBC 加密字节,解密后输出真实图片"""
from urllib.parse import urlparse
from app.services import aidrama91_service
parsed = urlparse(u)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise BizException(PARAM_ERROR, "非法图片地址")
host = parsed.hostname.lower()
if not (host == aidrama91_service.IMG_HOST_SUFFIX.lstrip(".")
or host.endswith(aidrama91_service.IMG_HOST_SUFFIX)):
raise BizException(PARAM_ERROR, "图片域名不在白名单")
async with httpx.AsyncClient(follow_redirects=True) as client:
resp = await client.get(u, headers={
"User-Agent": "Mozilla/5.0 (Linux; Android 10; SM-G973F) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
}, timeout=20)
if resp.status_code != 200 or not resp.content:
raise BizException(NOT_FOUND, "图片获取失败")
try:
data = aidrama91_service.decrypt_image(resp.content)
except Exception:
logger.exception("91 封面解密失败 url=%s", u[:120])
raise BizException(NOT_FOUND, "图片解密失败")
# 识别真实格式(密文无 padding,尾部可能有 1 字节冗余,浏览器可正常渲染)
if data[:3] == b"\xff\xd8\xff":
mime = "image/jpeg"
elif data[:8] == b"\x89PNG\r\n\x1a\n":
mime = "image/png"
elif data[:6] in (b"GIF87a", b"GIF89a"):
mime = "image/gif"
else:
raise BizException(NOT_FOUND, "图片解密异常")
return Response(
content=data,
media_type=mime,
headers={"Cache-Control": "public, max-age=86400",
"Access-Control-Allow-Origin": "*"},
)
+295
View File
@@ -0,0 +1,295 @@
"""系统维护:数据库备份 / 恢复(仅 super 角色)
实现要点:
- 备份:mysqldump 全库导出 → gzip 压缩 → 存到 runtime/backups/
- 恢复:上传 .sql.gz → gunzip → mysql 导入。**高危操作**,调用前需前端二次确认
- 所有文件操作限定在 backups/ 目录内,文件名做白名单校验防路径穿越
- 下载/恢复端点均验证 admin 身份,避免泄露数据库内容
"""
import asyncio
import gzip
import logging
import re
import shutil
from datetime import datetime
from pathlib import Path
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, UploadFile, File
from fastapi.responses import FileResponse
from app.config import get_settings
from app.core import errors
from app.core.response import ok, BizException
from app.deps import AdminIdentity, require_roles
router = APIRouter(prefix="/admin/system", tags=["管理后台-系统"])
logger = logging.getLogger(__name__)
require_super = require_roles()
# 备份目录(项目 runtime/backups/,与 uploads/videos 平级)
BACKUP_DIR = Path("/root/project/video/runtime/backups")
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
# 备份文件名白名单:video_backup_YYYYMMDD_HHMMSS.sql.gz
_FILENAME_RE = re.compile(r"^video_backup_\d{8}_\d{6}\.sql\.gz$")
def _parse_db_url() -> dict:
"""从 settings.database_url 解析 mysqldump 需要的连接参数"""
url = get_settings().database_url
# mysql+aiomysql://user:pass@host:port/dbname?charset=utf8mb4
p = urlparse(url)
return {
"host": p.hostname or "127.0.0.1",
"port": p.port or 3306,
"user": p.username or "root",
"password": p.password or "",
"database": p.path.lstrip("/").split("?")[0],
}
def _validate_filename(filename: str) -> Path:
"""校验文件名合法性,返回完整路径。任何非白名单字符直接拒绝"""
if not _FILENAME_RE.match(filename):
raise BizException(errors.INVALID_PARAMS, "非法的备份文件名")
path = BACKUP_DIR / filename
# resolve 后必须仍在 backups 目录内(防符号链接逃逸)
if not str(path.resolve()).startswith(str(BACKUP_DIR.resolve())):
raise BizException(errors.INVALID_PARAMS, "非法的备份文件路径")
return path
@router.get("/backups")
async def list_backups(admin: AdminIdentity = Depends(require_super)):
"""列出所有备份文件(按修改时间倒序)"""
items = []
for f in BACKUP_DIR.glob("video_backup_*.sql.gz"):
if not _FILENAME_RE.match(f.name):
continue
stat = f.stat()
items.append({
"filename": f.name,
"size": stat.st_size,
"created_at": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
})
items.sort(key=lambda x: x["filename"], reverse=True)
return ok({"items": items, "total": len(items)})
@router.post("/backup")
async def create_backup(admin: AdminIdentity = Depends(require_super)):
"""触发一次备份:mysqldump → gzip。同步等待完成(一般几秒到几十秒)"""
db = _parse_db_url()
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"video_backup_{ts}.sql.gz"
target = BACKUP_DIR / filename
# mysqldump 通过 stdin 传密码避免命令行泄露(MYSQL_PWD 环境变量也可,但子进程隔离更稳)
cmd = [
"mysqldump",
f"--host={db['host']}",
f"--port={db['port']}",
f"--user={db['user']}",
"--single-transaction", # InnoDB 一致性快照,不锁表
"--quick", # 大表逐行读取,避免内存撑爆
"--default-character-set=utf8mb4",
"--routines", "--triggers",
db["database"],
]
try:
proc_dump = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env={"MYSQL_PWD": db["password"], "PATH": "/usr/bin:/bin"},
)
# 用 gzip 流式压缩写入
with open(target, "wb") as fout:
with gzip.GzipFile(fileobj=fout, mode="wb", compresslevel=6) as gz:
while True:
chunk = await proc_dump.stdout.read(1 << 20) # 1MB
if not chunk:
break
gz.write(chunk)
stderr = await proc_dump.stderr.read()
await proc_dump.wait()
if proc_dump.returncode != 0:
target.unlink(missing_ok=True)
err_msg = stderr.decode("utf-8", errors="ignore")[:500]
logger.error(f"mysqldump failed: {err_msg}")
raise BizException(errors.SERVER_ERROR, f"备份失败:{err_msg}")
size = target.stat().st_size
if size < 100: # 异常小文件视为失败
target.unlink(missing_ok=True)
raise BizException(errors.SERVER_ERROR, "备份文件异常(过小)")
logger.info(f"DB backup created: {filename} ({size} bytes) by admin#{admin.admin_id}")
return ok({"filename": filename, "size": size})
except BizException:
raise
except Exception as e:
target.unlink(missing_ok=True)
logger.exception("backup failed")
raise BizException(errors.SERVER_ERROR, f"备份异常:{e}")
@router.get("/backups/{filename}/download")
async def download_backup(filename: str, admin: AdminIdentity = Depends(require_super)):
"""下载备份文件"""
path = _validate_filename(filename)
if not path.exists():
raise BizException(errors.NOT_FOUND, "备份文件不存在")
return FileResponse(
path,
media_type="application/gzip",
filename=filename,
)
@router.delete("/backups/{filename}")
async def delete_backup(filename: str, admin: AdminIdentity = Depends(require_super)):
"""删除指定备份"""
path = _validate_filename(filename)
if not path.exists():
raise BizException(errors.NOT_FOUND, "备份文件不存在")
path.unlink()
logger.info(f"DB backup deleted: {filename} by admin#{admin.admin_id}")
return ok()
@router.post("/restore")
async def restore_backup(
file: UploadFile = File(...),
admin: AdminIdentity = Depends(require_super),
):
"""上传 .sql.gz 并导入数据库(覆盖现有数据,高危操作)。
流程:
1. 接收上传文件,暂存到 backups/_restore_tmp.sql.gz
2. gunzip 解压 + mysql 导入(事务由 dump 内的 SET 语句保证)
3. 无论成败清理临时文件
"""
if not file.filename or not file.filename.endswith(".sql.gz"):
raise BizException(errors.INVALID_PARAMS, "仅支持 .sql.gz 格式")
db = _parse_db_url()
tmp_path = BACKUP_DIR / "_restore_tmp.sql.gz"
try:
# 限制 500MB
size = 0
with open(tmp_path, "wb") as f:
while True:
chunk = await file.read(1 << 20)
if not chunk:
break
size += len(chunk)
if size > 500 * 1024 * 1024:
raise BizException(errors.INVALID_PARAMS, "备份文件过大(>500MB)")
f.write(chunk)
# 解压 + 导入(用 zcat 管道到 mysql,避免中间落地大 SQL 文件)
# gunzip -c file.sql.gz | mysql -h.. -u.. dbname
zcat_cmd = ["gunzip", "-c", str(tmp_path)]
mysql_cmd = [
"mysql",
f"--host={db['host']}",
f"--port={db['port']}",
f"--user={db['user']}",
"--default-character-set=utf8mb4",
db["database"],
]
proc_zcat = await asyncio.create_subprocess_exec(
*zcat_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
proc_mysql = await asyncio.create_subprocess_exec(
*mysql_cmd,
stdin=proc_zcat.stdout,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env={"MYSQL_PWD": db["password"], "PATH": "/usr/bin:/bin"},
)
# zcat.stdout 已被 mysql 消费,避免 zcat 因管道堵塞而无法退出
if proc_zcat.stdout is not None:
proc_zcat.stdout = None # 让 asyncio 不要等待它
_, zcat_err = await proc_zcat.wait(), b""
mysql_out, mysql_err = await proc_mysql.communicate()
if proc_mysql.returncode != 0:
err_msg = mysql_err.decode("utf-8", errors="ignore")[:500]
logger.error(f"mysql restore failed: {err_msg}")
raise BizException(errors.SERVER_ERROR, f"恢复失败:{err_msg}")
logger.warning(
f"DB restored from upload ({file.filename}, {size} bytes) by admin#{admin.admin_id}"
)
return ok({"restored": True, "size": size})
except BizException:
raise
except Exception as e:
logger.exception("restore failed")
raise BizException(errors.SERVER_ERROR, f"恢复异常:{e}")
finally:
tmp_path.unlink(missing_ok=True)
@router.post("/backups/{filename}/restore")
async def restore_from_existing(
filename: str,
admin: AdminIdentity = Depends(require_super),
):
"""从服务器已有备份恢复(等价于先下载再上传恢复,但省去传输)"""
path = _validate_filename(filename)
if not path.exists():
raise BizException(errors.NOT_FOUND, "备份文件不存在")
db = _parse_db_url()
try:
zcat_cmd = ["gunzip", "-c", str(path)]
mysql_cmd = [
"mysql",
f"--host={db['host']}",
f"--port={db['port']}",
f"--user={db['user']}",
"--default-character-set=utf8mb4",
db["database"],
]
proc_zcat = await asyncio.create_subprocess_exec(
*zcat_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
proc_mysql = await asyncio.create_subprocess_exec(
*mysql_cmd,
stdin=proc_zcat.stdout,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env={"MYSQL_PWD": db["password"], "PATH": "/usr/bin:/bin"},
)
if proc_zcat.stdout is not None:
proc_zcat.stdout = None
await proc_zcat.wait()
_, mysql_err = await proc_mysql.communicate()
if proc_mysql.returncode != 0:
err_msg = mysql_err.decode("utf-8", errors="ignore")[:500]
logger.error(f"mysql restore failed: {err_msg}")
raise BizException(errors.SERVER_ERROR, f"恢复失败:{err_msg}")
logger.warning(f"DB restored from {filename} by admin#{admin.admin_id}")
return ok({"restored": True, "filename": filename})
except BizException:
raise
except Exception as e:
logger.exception("restore from existing failed")
raise BizException(errors.SERVER_ERROR, f"恢复异常:{e}")
+54 -2
View File
@@ -24,11 +24,13 @@ UPLOAD_RATE_LIMIT = 5
# 注意:/videos/feed、/videos/categories、/videos/search、/videos/upload/* 须先于 /videos/{video_id} 注册
@router.get("/feed")
async def get_feed(cursor: str | None = Query(None), limit: int = Query(10, ge=1, le=20),
category_id: int | None = Query(None),
category_id: int | None = Query(None), source_id: int | None = Query(None),
type: str = Query("recommend", pattern="^(recommend|latest|hot|liked|viewed)$"),
group: str | None = Query(None, pattern="^(series)$"),
db: AsyncSession = Depends(get_db), redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(get_identity)):
return ok(await video_service.get_feed(db, redis, identity, type, cursor, limit, category_id))
return ok(await video_service.get_feed(
db, redis, identity, type, cursor, limit, category_id, source_id, group))
@router.get("/categories")
@@ -36,6 +38,12 @@ async def get_categories(db: AsyncSession = Depends(get_db)):
return ok(await video_service.list_categories(db))
@router.get("/sources")
async def get_sources(db: AsyncSession = Depends(get_db)):
"""内容来源列表(麻豆/91...),供 App 分类页筛选"""
return ok(await video_service.list_sources(db))
@router.get("/search")
async def search_videos(request: Request, keyword: str = Query(min_length=1, max_length=30),
sort: str = Query("hot", pattern="^(hot|latest)$"),
@@ -103,6 +111,38 @@ async def delete_video(video_id: int, db: AsyncSession = Depends(get_db),
return ok()
# 站点管理员(按注册邮箱判定):可在 App 长按弹窗里下架任意视频
SITE_ADMIN_EMAIL = "256kb@gmail.com"
@router.post("/{video_id}/offline")
async def offline_video_app(
video_id: int,
reason: str = Form(default="管理员长按下架"),
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
identity: Identity = Depends(require_user),
):
"""App 端长按下架(仅限指定管理员邮箱);与后台 /admin/videos/{id}/offline 复用同一业务"""
from sqlalchemy import select
from app.core import errors
from app.core.response import BizException
from app.deps import AdminIdentity
from app.models.user import User
from app.services import admin_service
user = (await db.execute(
select(User).where(User.id == identity.user_id)
)).scalars().first()
if user is None or (user.email or "").lower() != SITE_ADMIN_EMAIL:
raise BizException(errors.FORBIDDEN, "无权限")
admin = AdminIdentity(admin_id=user.id, role="super")
await admin_service.offline_video(db, redis, admin, video_id, reason, ip="app")
return ok()
@router.post("/{video_id}/watch")
async def report_watch(video_id: int, req: WatchReq, db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
@@ -141,6 +181,7 @@ async def get_series(series_id: int, db: AsyncSession = Depends(get_db)):
"title": series.title,
"cover_url": series.cover_url,
"description": series.description,
"source_id": series.source_id or 0,
"total_episodes": len(eps),
},
"episodes": [
@@ -181,6 +222,15 @@ async def get_video_series(video_id: int, db: AsyncSession = Depends(get_db)):
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
# 系列所属来源名称
from app.models.video import Source
source_name = ""
if series.source_id:
source_name = (await db.execute(
select(Source.name).where(Source.id == series.source_id)
)).scalar_one_or_none() or ""
return ok({
"has_series": True,
"current_episode_no": video.episode_no,
@@ -188,6 +238,8 @@ async def get_video_series(video_id: int, db: AsyncSession = Depends(get_db)):
"id": series.id,
"title": series.title,
"cover_url": series.cover_url,
"source_id": series.source_id or 0,
"source_name": source_name,
"total_episodes": len(all_eps),
},
"episodes": [