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
+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