267 lines
10 KiB
Python
267 lines
10 KiB
Python
"""Web 管理后台:群开关 / 参与者 / 数据浏览(挂在 bot 端口 /galgame_card 路径)
|
|||
|
|
|
||
|
|
仿 nonebot_plugin_learning_chat 的网页模式:JWT 登录 + /galgame_card/api/* 路由 + 静态页。
|
||
|
|
"""
|
||
|
|
|
||
|
|
import datetime
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Optional
|
||
|
|
|
||
|
|
from fastapi import Depends, Header, HTTPException
|
||
|
|
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
||
|
|
from fastapi.staticfiles import StaticFiles
|
||
|
|
from jose import jwt
|
||
|
|
from nonebot import get_adapter, get_app, get_driver, logger
|
||
|
|
from nonebot.adapters.onebot.v11 import Adapter, Bot
|
||
|
|
from pydantic import BaseModel
|
||
|
|
|
||
|
|
from . import repository
|
||
|
|
from .config import config
|
||
|
|
|
||
|
|
API_PREFIX = "/galgame_card/api"
|
||
|
|
WEB_DIR = Path(__file__).parent / "web"
|
||
|
|
|
||
|
|
driver = get_driver()
|
||
|
|
|
||
|
|
|
||
|
|
def authentication():
|
||
|
|
def inner(token: Optional[str] = Header(None, alias="Authorization")):
|
||
|
|
try:
|
||
|
|
payload = jwt.decode(token, config.web_secret_key, algorithms="HS256")
|
||
|
|
if not payload.get("username") or payload["username"] != config.web_username:
|
||
|
|
raise HTTPException(status_code=400, detail="登录验证失败或已失效,请重新登录")
|
||
|
|
except (jwt.JWTError, jwt.ExpiredSignatureError, AttributeError):
|
||
|
|
raise HTTPException(status_code=400, detail="登录验证失败或已失效,请重新登录")
|
||
|
|
|
||
|
|
return Depends(inner)
|
||
|
|
|
||
|
|
|
||
|
|
class LoginModel(BaseModel):
|
||
|
|
username: str
|
||
|
|
password: str
|
||
|
|
|
||
|
|
|
||
|
|
def _ok(data=None, msg: str = "ok") -> JSONResponse:
|
||
|
|
return JSONResponse({"status": 0, "msg": msg, "data": data})
|
||
|
|
|
||
|
|
|
||
|
|
def _err(code: int, msg: str) -> JSONResponse:
|
||
|
|
return JSONResponse({"status": code, "msg": msg})
|
||
|
|
|
||
|
|
|
||
|
|
async def _first_bot() -> Optional[Bot]:
|
||
|
|
bots = get_adapter(Adapter).bots
|
||
|
|
return next(iter(bots.values()), None)
|
||
|
|
|
||
|
|
|
||
|
|
@driver.on_startup
|
||
|
|
async def init_web():
|
||
|
|
if not config.web_enable:
|
||
|
|
return
|
||
|
|
app = get_app()
|
||
|
|
|
||
|
|
@app.post(f"{API_PREFIX}/login")
|
||
|
|
async def login(user: LoginModel):
|
||
|
|
if user.username != config.web_username or user.password != config.web_password:
|
||
|
|
return _err(-100, "登录失败,请确认账号密码无误")
|
||
|
|
token = jwt.encode(
|
||
|
|
{
|
||
|
|
"username": user.username,
|
||
|
|
"exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=12),
|
||
|
|
},
|
||
|
|
config.web_secret_key,
|
||
|
|
algorithm="HS256",
|
||
|
|
)
|
||
|
|
return _ok({"token": token}, "登录成功")
|
||
|
|
|
||
|
|
@app.get(f"{API_PREFIX}/groups", dependencies=[authentication()])
|
||
|
|
async def groups():
|
||
|
|
"""管理中的群:数据库直读,不依赖 OneBot"""
|
||
|
|
rows = await repository.list_groups()
|
||
|
|
corpus = await repository.corpus_counts_by_group()
|
||
|
|
impressions = await repository.impression_counts_by_group()
|
||
|
|
summaries = await repository.summary_counts_by_group()
|
||
|
|
participants = await repository.participant_counts_by_group()
|
||
|
|
result = []
|
||
|
|
for row in rows:
|
||
|
|
gid = row.group_id
|
||
|
|
result.append(
|
||
|
|
{
|
||
|
|
"group_id": gid,
|
||
|
|
"group_name": row.group_name,
|
||
|
|
"enabled": row.enabled,
|
||
|
|
"participants": participants.get(gid, 0),
|
||
|
|
"corpus_count": corpus.get(gid, 0),
|
||
|
|
"impression_count": impressions.get(gid, 0),
|
||
|
|
"summary_count": summaries.get(gid, 0),
|
||
|
|
}
|
||
|
|
)
|
||
|
|
return _ok(result)
|
||
|
|
|
||
|
|
@app.get(f"{API_PREFIX}/groups/discover", dependencies=[authentication()])
|
||
|
|
async def discover_groups():
|
||
|
|
"""OneBot 全部群("添加群"选择器用),顺带回填已管理群名称"""
|
||
|
|
bot = await _first_bot()
|
||
|
|
if bot is None:
|
||
|
|
return _err(-100, "未连接 OneBot,无法获取群列表")
|
||
|
|
group_list = await bot.get_group_list()
|
||
|
|
managed_ids = {row.group_id for row in await repository.list_groups()}
|
||
|
|
result = [
|
||
|
|
{
|
||
|
|
"group_id": int(g["group_id"]),
|
||
|
|
"group_name": g.get("group_name", ""),
|
||
|
|
"managed": int(g["group_id"]) in managed_ids,
|
||
|
|
}
|
||
|
|
for g in group_list
|
||
|
|
]
|
||
|
|
await repository.backfill_group_names(
|
||
|
|
{int(g["group_id"]): g.get("group_name", "") for g in group_list}
|
||
|
|
)
|
||
|
|
return _ok(result)
|
||
|
|
|
||
|
|
@app.post(f"{API_PREFIX}/groups", dependencies=[authentication()])
|
||
|
|
async def add_group(data: dict):
|
||
|
|
gid = int(data.get("group_id") or 0)
|
||
|
|
if gid <= 0:
|
||
|
|
return _err(400, "group_id 不合法")
|
||
|
|
await repository.add_group(gid, str(data.get("group_name") or "").strip())
|
||
|
|
return _ok(msg="已加入管理列表")
|
||
|
|
|
||
|
|
@app.post(f"{API_PREFIX}/groups/{{group_id}}/enabled", dependencies=[authentication()])
|
||
|
|
async def set_group_enabled(group_id: int, data: dict):
|
||
|
|
await repository.set_group_enabled(group_id, bool(data.get("enabled")))
|
||
|
|
return _ok(msg="已开启采集" if data.get("enabled") else "已关闭采集")
|
||
|
|
|
||
|
|
@app.get(f"{API_PREFIX}/groups/{{group_id}}/participants", dependencies=[authentication()])
|
||
|
|
async def participants(group_id: int):
|
||
|
|
members = await repository.joined_users(group_id)
|
||
|
|
stats = dict(await repository.chat_log_stats_by_user(group_id))
|
||
|
|
result = [
|
||
|
|
{"user_id": uid, "corpus_count": stats.get(uid, 0)} for uid in members
|
||
|
|
]
|
||
|
|
return _ok(result)
|
||
|
|
|
||
|
|
@app.post(f"{API_PREFIX}/groups/{{group_id}}/participants", dependencies=[authentication()])
|
||
|
|
async def add_participant(group_id: int, data: dict):
|
||
|
|
user_id = int(data.get("user_id") or 0)
|
||
|
|
if user_id <= 0:
|
||
|
|
return _err(400, "user_id 不合法")
|
||
|
|
if await repository.is_joined(user_id, group_id):
|
||
|
|
return _err(400, "已在人设名单中")
|
||
|
|
await repository.join(user_id, group_id)
|
||
|
|
return _ok(msg="已加入")
|
||
|
|
|
||
|
|
@app.delete(f"{API_PREFIX}/groups/{{group_id}}/participants/{{user_id}}", dependencies=[authentication()])
|
||
|
|
async def remove_participant(group_id: int, user_id: int):
|
||
|
|
await repository.leave(user_id, group_id)
|
||
|
|
return _ok(msg="已移除")
|
||
|
|
|
||
|
|
@app.get(f"{API_PREFIX}/groups/{{group_id}}/logs", dependencies=[authentication()])
|
||
|
|
async def logs(group_id: int, user_id: Optional[int] = None, page: int = 1, per_page: int = 50):
|
||
|
|
offset = max(0, (page - 1) * per_page)
|
||
|
|
items = await repository.list_logs(group_id, user_id, offset, per_page)
|
||
|
|
total = await repository.count_logs_filtered(group_id, user_id)
|
||
|
|
result = [
|
||
|
|
{
|
||
|
|
"id": row.id,
|
||
|
|
"user_id": row.user_id,
|
||
|
|
"nickname": row.nickname,
|
||
|
|
"content": row.content,
|
||
|
|
"target_user_id": row.target_user_id,
|
||
|
|
"target_inherited": row.target_inherited,
|
||
|
|
"follows_id": row.follows_id,
|
||
|
|
"created_at": row.created_at.strftime("%Y-%m-%d %H:%M:%S"),
|
||
|
|
}
|
||
|
|
for row in items
|
||
|
|
]
|
||
|
|
return _ok({"items": result, "total": total})
|
||
|
|
|
||
|
|
@app.get(f"{API_PREFIX}/groups/{{group_id}}/impressions", dependencies=[authentication()])
|
||
|
|
async def impressions(group_id: int, page: int = 1, per_page: int = 50):
|
||
|
|
offset = max(0, (page - 1) * per_page)
|
||
|
|
items = await repository.list_impressions_group(group_id, offset, per_page)
|
||
|
|
result = [
|
||
|
|
{
|
||
|
|
"id": row.id,
|
||
|
|
"user_id": row.user_id,
|
||
|
|
"content": row.content,
|
||
|
|
"cover_from_id": row.cover_from_id,
|
||
|
|
"cover_to_id": row.cover_to_id,
|
||
|
|
"created_at": row.created_at.strftime("%Y-%m-%d %H:%M:%S"),
|
||
|
|
}
|
||
|
|
for row in items
|
||
|
|
]
|
||
|
|
return _ok({"items": result, "total": await repository.count_impressions_group(group_id)})
|
||
|
|
|
||
|
|
@app.get(f"{API_PREFIX}/groups/{{group_id}}/summaries", dependencies=[authentication()])
|
||
|
|
async def summaries(group_id: int):
|
||
|
|
items = await repository.list_summaries_group(group_id)
|
||
|
|
result = [
|
||
|
|
{
|
||
|
|
"version": row.version,
|
||
|
|
"card_text": row.card_text,
|
||
|
|
"corpus_count": row.corpus_count,
|
||
|
|
"impression_count": row.impression_count,
|
||
|
|
"model": row.model,
|
||
|
|
"created_at": row.created_at.strftime("%Y-%m-%d %H:%M:%S"),
|
||
|
|
}
|
||
|
|
for row in items
|
||
|
|
]
|
||
|
|
return _ok(result)
|
||
|
|
|
||
|
|
@app.delete(f"{API_PREFIX}/groups/{{group_id}}/logs", dependencies=[authentication()])
|
||
|
|
async def delete_logs(group_id: int, user_id: Optional[int] = None):
|
||
|
|
count = await repository.delete_logs(group_id, user_id)
|
||
|
|
return _ok(msg=f"已删除 {count} 条语料")
|
||
|
|
|
||
|
|
@app.delete(f"{API_PREFIX}/groups/{{group_id}}/data", dependencies=[authentication()])
|
||
|
|
async def clear_group(group_id: int):
|
||
|
|
await repository.clear_group_data(group_id)
|
||
|
|
return _ok(msg="已清空该群全部数据")
|
||
|
|
|
||
|
|
@app.delete(f"{API_PREFIX}/groups/{{group_id}}", dependencies=[authentication()])
|
||
|
|
async def remove_group(group_id: int):
|
||
|
|
await repository.remove_group(group_id)
|
||
|
|
return _ok(msg="已移出管理列表(数据保留,重新加入可续上)")
|
||
|
|
|
||
|
|
@app.get(f"{API_PREFIX}/settings", dependencies=[authentication()])
|
||
|
|
async def settings():
|
||
|
|
from . import processor
|
||
|
|
|
||
|
|
return _ok(
|
||
|
|
{
|
||
|
|
"web_username": config.web_username,
|
||
|
|
"web_enable": config.web_enable,
|
||
|
|
"thresholds": {
|
||
|
|
"max_content_len": processor.MAX_CONTENT_LEN,
|
||
|
|
"repeat_window": processor.REPEAT_WINDOW,
|
||
|
|
"throttle_seconds": processor.THROTTLE_SECONDS,
|
||
|
|
"rolling_window": repository.ROLLING_WINDOW,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
)
|
||
|
|
|
||
|
|
# React 管理后台(web/persona-admin 构建产物)
|
||
|
|
dist_dir = WEB_DIR / "persona-admin" / "dist"
|
||
|
|
if (dist_dir / "index.html").exists():
|
||
|
|
app.mount(
|
||
|
|
"/galgame_card/assets",
|
||
|
|
StaticFiles(directory=dist_dir / "assets"),
|
||
|
|
name="galgame_card_assets",
|
||
|
|
)
|
||
|
|
|
||
|
|
@app.get("/galgame_card")
|
||
|
|
@app.get("/galgame_card/")
|
||
|
|
async def admin_page():
|
||
|
|
return FileResponse(dist_dir / "index.html")
|
||
|
|
|
||
|
|
else:
|
||
|
|
# 构建产物缺失时回退到旧版单文件页面
|
||
|
|
@app.get("/galgame_card")
|
||
|
|
@app.get("/galgame_card/")
|
||
|
|
async def admin_page():
|
||
|
|
html = (WEB_DIR / "index.html").read_text(encoding="utf-8")
|
||
|
|
return HTMLResponse(html)
|
||
|
|
|
||
|
|
logger.info("群聊人设卡 | Web 管理后台已挂载: /galgame_card")
|