This commit is contained in:
lz_db
2026-10-17 19:04:05 +08:00
parent 28b11b4365
commit 1253c159f5
43 changed files with 2652 additions and 114 deletions
+327
View File
@@ -5,6 +5,8 @@
- operator:套餐、活动、看板 → 仪表盘 / 套餐 CRUD / 赠送
- 其余接口仅 super
"""
import logging
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel, BaseModel as _BM, Field as _F, Field as _Field
@@ -19,6 +21,8 @@ from app.services import admin_service
router = APIRouter(prefix="/admin", tags=["管理后台"])
logger = logging.getLogger(__name__)
# 角色依赖:super 自动放行
require_reviewer = require_roles("reviewer")
require_operator = require_roles("operator")
@@ -341,6 +345,15 @@ async def set_user_status(user_id: int, req: schema.UserStatusReq, request: Requ
return ok(await admin_service.set_user_status(db, redis, admin, user_id, req, _ip(request)))
@router.put("/users/{user_id}/password")
async def reset_user_password(user_id: int, req: schema.UserPasswordResetReq, request: Request,
db: AsyncSession = Depends(get_db),
redis: aioredis.Redis = Depends(get_redis),
admin: AdminIdentity = Depends(require_super)):
await admin_service.reset_user_password(db, redis, admin, user_id, req, _ip(request))
return ok()
@router.post("/points/adjust")
async def adjust_points(req: schema.PointsAdjustReq, request: Request,
db: AsyncSession = Depends(get_db),
@@ -558,6 +571,19 @@ async def list_series(keyword: str = "", page: int = 1, page_size: int = 20,
rows = (await db.execute(
stmt.order_by(Series.id.desc()).offset((page - 1) * page_size).limit(page_size)
)).scalars().all()
# 关联采集记录(标记采集来源,供"同步更新"按钮使用)
from app.models.video import CollectionRecord
series_ids = [s.id for s in rows]
source_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}
return ok({
"total": total,
"list": [{
@@ -565,6 +591,7 @@ async def list_series(keyword: str = "", page: int = 1, page_size: int = 20,
"description": s.description or "",
"category_id": s.category_id or 0,
"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],
})
@@ -664,6 +691,306 @@ async def bind_video_series(video_id: int, req: _VideoSeriesBind,
return ok()
# ---------- 短剧采集 ----------
_collect_sync_running = False
@router.post("/collection/sync/{series_id}")
async def collect_sync_one(series_id: int, request: Request, db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
"""手动同步单部采集短剧(补充源站新出的集数)"""
from app.models.admin import AdminLog
from app.models.video import CollectionRecord
from app.services import collection_service
from sqlalchemy import select
source_id = (await db.execute(
select(CollectionRecord.source_id).where(
CollectionRecord.source_key == collection_service.SOURCE_KEY,
CollectionRecord.series_id == series_id,
)
)).scalar_one_or_none()
if not source_id:
raise BizException(errors.NOT_FOUND, "该系列不是采集内容")
stat = await collection_service.sync_drama_by_id(db, source_id)
db.add(AdminLog(admin_id=admin.admin_id, action="collection_sync",
target=f"series:{series_id}", detail=str(stat), ip=_ip(request)))
await db.commit()
return ok(stat)
class _CollectSyncAllReq(BaseModel):
pages: int = _Field(2, ge=1, le=20) # 增量同步的列表页数
@router.post("/collection/sync-all")
async def collect_sync_all(req: _CollectSyncAllReq, request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
"""触发增量同步(后台异步执行,立即返回)"""
import asyncio
from app.database import SessionFactory
from app.models.admin import AdminLog
from app.services import collection_service
global _collect_sync_running
if _collect_sync_running:
raise BizException(errors.RATE_LIMITED, "已有同步任务进行中")
db.add(AdminLog(admin_id=admin.admin_id, action="collection_sync_all",
target="", detail=f"pages={req.pages}", ip=_ip(request)))
await db.commit()
async def _run() -> None:
global _collect_sync_running
_collect_sync_running = True
try:
async with SessionFactory() as s:
await collection_service.sync_short_dramas(s, max_pages=req.pages)
finally:
_collect_sync_running = False
asyncio.create_task(_run())
return ok({"started": True, "pages": req.pages})
# ---------- 采集源管理(单视频类,自助添加)----------
class _SourceCreate(BaseModel):
name: str = _Field(min_length=1, max_length=50)
list_url: str = _Field(min_length=10, max_length=500)
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)
status: int | None = _Field(None, ge=0, le=1)
def _source_dict(s) -> dict:
return {
"id": s.id, "name": s.name, "list_url": s.list_url,
"category_id": s.category_id, "status": s.status,
"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"),
}
async def _resolve_category(db: AsyncSession, name: str) -> int:
"""按名称找分类,不存在则创建"""
from app.models.video import Category
from sqlalchemy import func, select
cid = (await db.execute(select(Category.id).where(Category.name == name))).scalar_one_or_none()
if cid:
return cid
max_sort = (await db.execute(select(func.coalesce(func.max(Category.sort), 0)))).scalar_one()
c = Category(name=name, sort=max_sort + 1, status=1)
db.add(c)
await db.flush()
return c.id
@router.get("/collection/sources")
async def list_sources(db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
from app.models.video import CollectionSource
from sqlalchemy import select
rows = (await db.execute(select(CollectionSource).order_by(CollectionSource.id.desc()))).scalars().all()
return ok({"list": [_source_dict(s) for s in rows]})
@router.post("/collection/sources")
async def create_source(req: _SourceCreate, request: Request, db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
"""添加采集源:自动按名称建/找分类"""
from app.models.admin import AdminLog
from app.models.video import CollectionSource
if "{{page}}" not in req.list_url:
raise BizException(errors.PARAM_ERROR, "URL 需包含 {{page}} 页码占位符")
cid = await _resolve_category(db, req.name.strip())
s = CollectionSource(name=req.name.strip(), list_url=req.list_url.strip(), category_id=cid)
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)))
await db.commit()
await db.refresh(s)
return ok(_source_dict(s))
@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)):
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"]:
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()
for k, v in d.items():
setattr(s, k, v)
await db.commit()
await db.refresh(s)
return ok(_source_dict(s))
@router.delete("/collection/sources/{source_id}")
async def delete_source(source_id: int, db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_super)):
from app.models.video import CollectionSource
s = await db.get(CollectionSource, source_id)
if not s:
raise BizException(errors.NOT_FOUND, "采集源不存在")
await db.delete(s)
await db.commit()
return ok()
class _SourceBatchReq(BaseModel):
text: str = _Field(min_length=1, max_length=20000)
@router.post("/collection/sources/batch")
async def batch_create_sources(req: _SourceBatchReq, request: Request, db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
"""批量添加采集源:每行一条,格式 "分类名 URL"(URL 可带反引号),成功后自动触发采集"""
import re
from app.models.admin import AdminLog
from app.models.video import CollectionSource
from sqlalchemy import select
line_re = re.compile(r"^\s*(?P<name>.+?)[\s]+[`'\"]*(?P<url>https?://[^\s`'\"]+)[`'\"]*\s*$")
created, skipped, failed = [], [], []
for raw in req.text.splitlines():
line = raw.strip()
if not line:
continue
m = line_re.match(line)
if not m or "{{page}}" not in m.group("url"):
failed.append({"line": line[:80], "reason": "格式错误或缺少 {{page}} 占位"})
continue
name, url = m.group("name").strip().strip("`'\" "), m.group("url")
if not name or len(name) > 50:
failed.append({"line": line[:80], "reason": "分类名为空或过长"})
continue
exists = (await db.execute(
select(CollectionSource.id).where(CollectionSource.list_url == url)
)).scalar_one_or_none()
if exists:
skipped.append({"name": name, "reason": "URL 已存在"})
continue
cid = await _resolve_category(db, name)
s = CollectionSource(name=name, list_url=url, category_id=cid)
db.add(s)
await db.flush()
created.append({"id": s.id, "name": name})
db.add(AdminLog(admin_id=admin.admin_id, action="collection_source_batch",
target="", detail=f"created={len(created)} skipped={len(skipped)} failed={len(failed)}",
ip=_ip(request)))
await db.commit()
# 新建的源自动触发后台采集
if created:
import asyncio
for c in created:
if c["id"] not in _sync_running_sources:
_sync_running_sources.add(c["id"])
asyncio.create_task(_run_source_sync(c["id"], 0))
return ok({"created": created, "skipped": skipped, "failed": failed,
"sync_started": [c["id"] for c in created]})
_sync_running_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
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)
except Exception:
logger.exception("采集源 %s 同步失败", source_id)
finally:
_sync_running_sources.discard(source_id)
class _SourceSyncReq(BaseModel):
max_pages: int = _Field(0, ge=0, le=200) # 0=不限(增量会自动提前停止)
@router.post("/collection/sources/{source_id}/sync")
async def sync_one_source(source_id: int, req: _SourceSyncReq, request: Request,
db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
"""同步单个采集源(后台异步,立即返回)"""
import asyncio
from app.models.admin import AdminLog
from app.models.video import CollectionSource
s = await db.get(CollectionSource, source_id)
if not s:
raise BizException(errors.NOT_FOUND, "采集源不存在")
if source_id in _sync_running_sources:
raise BizException(errors.RATE_LIMITED, "该源正在同步中")
db.add(AdminLog(admin_id=admin.admin_id, action="collection_source_sync",
target=f"source:{source_id}", detail=f"max_pages={req.max_pages}", ip=_ip(request)))
await db.commit()
_sync_running_sources.add(source_id)
asyncio.create_task(_run_source_sync(source_id, req.max_pages))
return ok({"started": True, "source_id": source_id})
@router.post("/collection/sources/sync-all")
async def sync_all_sources(request: Request, db: AsyncSession = Depends(get_db),
admin: AdminIdentity = Depends(require_operator)):
"""同步全部启用的采集源(后台异步)"""
import asyncio
from app.models.admin import AdminLog
from app.models.video import CollectionSource
from sqlalchemy import select
ids = (await db.execute(
select(CollectionSource.id).where(CollectionSource.status == 1)
)).scalars().all()
started = [i for i in ids if i not in _sync_running_sources]
for i in started:
_sync_running_sources.add(i)
asyncio.create_task(_run_source_sync(i, 0))
db.add(AdminLog(admin_id=admin.admin_id, action="collection_sources_sync_all",
target="", detail=f"sources={started}", ip=_ip(request)))
await db.commit()
return ok({"started": started, "skipped_running": [i for i in ids if i not in started]})
# ---------- 分类管理 ----------
from pydantic import BaseModel as _BM