Files
sansenhoshiandClaude Code 4badcfcf32 feat(video-analysis): 群策略 v3 / 群文件投递通道 / Web 管理页
- policy.py:per-group 正交策略(自动解析 / 自动策略 / 禁用策略 / 存储 A·B·C /
  公网 / 链接 / 群文件 + 平台限定),list.json v1/v2 → v3 自动迁移,
  写入统一走 PolicyStore(加锁 + .tmp 原子替换 + 字段归一)
- 群文件并行通道 group_file.py:打包 zip(可选 pyzipper AES-256)后优先走 S3 预签名、
  本地直传兜底;设了密码但 pyzipper 不可用就放弃上传,不退化成明文
- list_proc.py 收敛到「视频策略」统一入口,权限判定改走 policy
- Web 管理页 /hub/video_analysis(群策略 + 链接解析面板)与 services/web_jobs.py
  (只复用纯函数层,Web 上下文不发消息;内存任务表 + 并发闸门 + 超时)
- 媒体命名统一到 utils.py({作者}_{作者id}/{作品名}[_短码]),cleanup 回收空目录
- 测试:policy / 命名 / 群文件 / web_jobs 四组

顺带 pyproject 的 pytest 加 testpaths=tests(避免收进 debug/ 下的调试脚本)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 14:23:32 +08:00

177 lines
7.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""视频解析 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), "")