1
This commit is contained in:
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""媒体代理:m3u8 播放列表转发(源站 playlist 内 TS 链接带时效签名,每次回源拉取新鲜版本)"""
|
||||
import re
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Query, Response
|
||||
|
||||
from app.core.errors import NOT_FOUND, PARAM_ERROR
|
||||
from app.core.response import BizException
|
||||
from app.services.collection_service import HEADERS, UPSTREAM
|
||||
|
||||
router = APIRouter(prefix="/m3u8", tags=["媒体"])
|
||||
|
||||
# 只允许相对资源路径,防 SSRF
|
||||
_PATH_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9/._-]*$")
|
||||
|
||||
|
||||
@router.get("/proxy")
|
||||
async def m3u8_proxy(path: str = Query(min_length=1, max_length=300)):
|
||||
if ".." in path or not _PATH_RE.match(path):
|
||||
raise BizException(PARAM_ERROR, "非法路径")
|
||||
|
||||
url = f"{UPSTREAM}/api/v1/m3u8/proxy"
|
||||
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||
resp = await client.get(url, params={"path": path}, headers=HEADERS, timeout=20)
|
||||
|
||||
text = resp.text
|
||||
if resp.status_code != 200 or not text.lstrip().startswith("#EXTM3U"):
|
||||
raise BizException(NOT_FOUND, "播放列表获取失败")
|
||||
|
||||
return Response(
|
||||
content=text,
|
||||
media_type="application/vnd.apple.mpegurl",
|
||||
headers={"Cache-Control": "no-cache", "Access-Control-Allow-Origin": "*"},
|
||||
)
|
||||
@@ -25,7 +25,7 @@ UPLOAD_RATE_LIMIT = 5
|
||||
@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),
|
||||
type: str = Query("recommend", pattern="^(recommend|latest|hot)$"),
|
||||
type: str = Query("recommend", pattern="^(recommend|latest|hot|liked|viewed)$"),
|
||||
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))
|
||||
|
||||
Reference in New Issue
Block a user