# -*- coding: utf-8 -*- """群聊人设卡 Web API 子应用(挂载到 /api/galgame_card)。 统一鉴权走 hexi.web_auth(OAuth2 + SQLite),与统一管理台 /hub 共用登录态。 旧版独立后台 /galgame_card(JWT 自鉴权)仍保留可用,前端由 hub 渲染。 """ from __future__ import annotations from typing import Optional from fastapi import FastAPI from fastapi.responses import JSONResponse from hexi.web_auth import require_admin from . import repository from .config import config API = require_admin 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(): try: from nonebot import get_adapter from nonebot.adapters.onebot.v11 import Adapter bots = get_adapter(Adapter).bots return next(iter(bots.values()), None) except Exception: # noqa: BLE001 return None def build_admin_app() -> FastAPI | None: """构建群聊人设卡管理 API 子应用(挂载到 /api/galgame_card)。 在 hub 统一登录态下可用;未启用 Web 时返回 None。 """ if not config.web_enable: return None app = FastAPI(title="Galgame Card API") @app.get("/groups", dependencies=[API]) async def groups(): 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("/groups/discover", dependencies=[API]) async def discover_groups(): 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("/groups", dependencies=[API]) 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("/groups/{group_id}/enabled", dependencies=[API]) 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("/groups/{group_id}/participants", dependencies=[API]) 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("/groups/{group_id}/participants", dependencies=[API]) 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("/groups/{group_id}/participants/{user_id}", dependencies=[API]) async def remove_participant(group_id: int, user_id: int): await repository.leave(user_id, group_id) return _ok(msg="已移除") @app.get("/groups/{group_id}/logs", dependencies=[API]) 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("/groups/{group_id}/impressions", dependencies=[API]) 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("/groups/{group_id}/summaries", dependencies=[API]) 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("/groups/{group_id}/logs", dependencies=[API]) 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("/groups/{group_id}/data", dependencies=[API]) async def clear_group(group_id: int): await repository.clear_group_data(group_id) return _ok(msg="已清空该群全部数据") @app.delete("/groups/{group_id}", dependencies=[API]) async def remove_group(group_id: int): await repository.remove_group(group_id) return _ok(msg="已移出管理列表(数据保留,重新加入可续上)") @app.get("/settings", dependencies=[API]) 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, }, } ) return app