"""视频解析 Web API 子应用(挂载到 /api/video_analysis)。 群策略(data/list.json v3,见 policy.py)的唯一 Web 读写入口, 鉴权走 hexi.web_hub.web_auth(OAuth2 + SQLite),与统一管理台 /hub 共用登录态。 前端页面:hexi/web/src/plugins/video_analysis/index.tsx。 写入全部落在 `policy.PolicyStore` 上(加锁 + 原子替换 + 归一化), 所以这里不需要再做字段校验,只要校验群号形态。 另有一组「链接解析 + 预览」接口(/parse、/jobs、/jobs/{id}/refresh): 粘链接 → 起任务 → 出下载链接 + 页面内预览,实现在 services/web_jobs.py。 **每条路由都要自带 `dependencies=[auth]`** —— mount 层没有兜底, 漏一条就是匿名可访问(包括这条"让服务器去抓任意 URL"的接口)。 """ from __future__ import annotations from fastapi import FastAPI from fastapi.responses import JSONResponse from nonebot import get_adapter from nonebot.adapters.onebot.v11 import Adapter from hexi.web_hub.web_auth import require_admin from .policy import PLANS, PLATFORMS, STORE, Policy from .services.web_jobs import JOBS, extract_urls API = require_admin def _ok(data=None, msg: str = "ok") -> JSONResponse: return JSONResponse({"status": 0, "msg": msg, "data": data}) def _fail(msg: str, status: int = 400) -> JSONResponse: return JSONResponse({"status": status, "msg": msg}) async def _group_names() -> dict[str, str]: """群号 → 群名;拿不到 bot(未连接)时返回空表,前端只显示群号。""" try: bots = get_adapter(Adapter).bots bot = next(iter(bots.values()), None) if bot is None: return {} return { str(g["group_id"]): g.get("group_name") or "" for g in await bot.get_group_list() } except Exception: # noqa: BLE001 — 未连接/适配器未加载都按拿不到处理 return {} def _payload(policy: Policy) -> dict: """策略的 JSON 视图;sends_link 是"实际会不会发链接"(供前端置灰)。""" data = policy.to_dict() data["sends_link"] = policy.sends_link return data def build_admin_app() -> FastAPI | None: """构建群策略管理 API 子应用(挂载到 /api/video_analysis)。""" app = FastAPI(title="Video Analysis API") auth = require_admin @app.get("/overview", response_class=JSONResponse, dependencies=[auth]) async def overview(): """一次拿全:群策略列表 + 默认节 + 黑名单 + 平台/方案选项。""" names = await _group_names() groups = [ { "group_id": gid, "group_name": names.get(gid, ""), "online": gid in names, "policy": _payload(policy), } for gid, policy in sorted(STORE.all_groups().items(), key=_sort_key) ] return _ok( { "groups": groups, "default": _payload(STORE.default_policy()), "blacklist": STORE.blacklist(), "platforms": list(PLATFORMS), "plans": list(PLANS), "online": bool(names), } ) @app.post("/group", response_class=JSONResponse, dependencies=[auth]) async def save_group(data: dict): """新增/覆盖单个群的策略(群不存在则加入白名单)。""" gid = str(data.get("group_id", "")).strip() if not gid.isdigit(): return _fail("群号必须是纯数字") policy = await STORE.set_group(gid, Policy.from_dict(data.get("policy"))) return _ok(_payload(policy), f"群 {gid} 策略已保存") @app.delete("/group/{group_id}", response_class=JSONResponse, dependencies=[auth]) async def remove_group(group_id: str): """移出白名单(该群不再解析)。""" if not await STORE.remove_group(group_id): return _fail(f"群 {group_id} 不在白名单中") return _ok({"group_id": group_id}, f"群 {group_id} 已移出白名单") @app.post("/default", response_class=JSONResponse, dependencies=[auth]) async def save_default(data: dict): """保存默认策略(私聊与未配置群使用)。""" policy = await STORE.set_default(Policy.from_dict(data.get("policy", data))) return _ok(_payload(policy), "默认策略已保存") @app.post("/blacklist", response_class=JSONResponse, dependencies=[auth]) async def save_blacklist(data: dict): """整体替换全局黑名单(QQ 列表)。""" raw = data.get("blacklist", []) if not isinstance(raw, list): return _fail("blacklist 必须是列表") values = [str(x).strip() for x in raw if str(x).strip()] invalid = [v for v in values if not v.isdigit()] if invalid: return _fail(f"黑名单只能是 QQ 号:{'、'.join(invalid)}") stored = await STORE.set_blacklist(values) return _ok({"blacklist": stored}, "黑名单已保存") # ── 链接解析 + 预览(services/web_jobs.py) ────────────── # 与群策略无关:解析结果按默认策略(default 节)的 plan / upload_public 存储。 @app.post("/parse", response_class=JSONResponse, dependencies=[auth]) async def parse(data: dict): """整段文本里挑链接 → 逐个起任务;同 URL 已有在跑的任务时直接复用。""" urls = extract_urls(str(data.get("text") or "")) if not urls: return _fail("没找到链接") jobs = await JOBS.submit(urls, force=bool(data.get("force"))) return _ok( {"jobs": [j.to_dict() for j in jobs], "urls": urls}, f"已提交 {len(jobs)} 个任务", ) @app.get("/jobs", response_class=JSONResponse, dependencies=[auth]) async def list_jobs(): """全部任务,新的在前(前端按 1.5s 轮询这一个接口)。""" return _ok({"jobs": [j.to_dict() for j in JOBS.list_jobs()]}) @app.post( "/jobs/{job_id}/refresh", response_class=JSONResponse, dependencies=[auth] ) async def refresh_job(job_id: str): """重传已落盘文件换一批新链接(预签名链接 1 小时过期)。""" job = await JOBS.refresh(job_id) if job is None: return _fail("任务不存在,或还没有可刷新的文件") return _ok({"job": job.to_dict()}, "链接已刷新") @app.delete("/jobs/{job_id}", response_class=JSONResponse, dependencies=[auth]) async def remove_job(job_id: str): """删掉一条任务(还在跑的一并取消);只清任务表,temp 里的文件交给清理任务。""" if not JOBS.remove(job_id): return _fail("任务不存在") return _ok({"job_id": job_id}, "任务已清理") @app.post("/jobs/clear", response_class=JSONResponse, dependencies=[auth]) async def clear_jobs(): """清掉所有已完成/失败的任务(排队、运行中的不动)。""" removed = JOBS.clear_finished() return _ok({"removed": removed}, f"已清理 {removed} 条任务") return app def _sort_key(item: tuple[str, Policy]) -> tuple[int, int, str]: """群号按数值排序(非数字的排最后,不参与数值比较)。""" gid = item[0] return (1, 0, gid) if not gid.isdigit() else (0, int(gid), "")