refactor(bf-bot): 插件重构为 handlers/services/utils 分层,资源归入 res/

- 旧平铺模块(data/data_utils/database_op/image_builder*/get_bf6_data/
  gametools_bf6/bf6_data/img_utils)拆分为 handlers(绑定/查询)、
  services(fetchers/processing/query)、utils(渲染/参数)分层;
  新增 repository.py / config.py / paths.py
- 字体/图片/模板资源迁入 res/,新增 BF2042 卡面素材(bg/object_icon 等)
- browser_ext/:BF2042 扩展捕获桥(WS 直连兜底通道)源码入库
- 新增 hexi/web/src/plugins/bf_bot 前端管理页
- 新增 tests:test_bf_bot_helpers / repository / tracker_api

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
sansenhoshiandClaude Code committed 2026-10-13 17:53:06 +08:00
1 parent 614e1d7e00
commit a31aab00e8
752 files changed
+6241 -2506

No files matched your search

@@ -0,0 +1,313 @@
"""Model 层:绑定库 / 标签库的唯一读写入口(仅标准库)。
- 库文件固定为 `hexi/data/nonebot_plugin_bf_bot/user_data.db`(paths.DB_PATH),
不再写进插件源码目录。
- 本模块顶层不 import nonebot(logger 到函数内延迟导入),保证脱离 NoneBot
运行时也能被单测加载。
- 表结构:users(QQ↔EA 绑定,EA 三字段 UNIQUE)、designation、medals、
query_record(历史遗留,未用)。
"""
from __future__ import annotations
import sqlite3
from typing import Any
from .paths import DB_PATH
def _logger():
from nonebot import logger
return logger
class TableManager:
"""所有表管理器的基类:每次操作一条短连接 + 即时提交。"""
def __init__(self, db_path: str | None = None):
self.db_path = str(db_path or DB_PATH)
def _execute(self, sql: str, params: tuple = ()) -> sqlite3.Cursor:
"""执行 SQL 并返回游标(调用方自行 fetch)。"""
with sqlite3.connect(self.db_path) as conn:
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
cursor.execute(sql, params)
conn.commit()
return cursor
class UserManager(TableManager):
"""users 表:QQ ↔ EAID 绑定。"""
def add_user(
self,
qq_id: str,
ea_player_name: str,
ea_player_id: str,
ea_user_id: str,
designation: str,
medals: str,
) -> int:
"""新增绑定,返回新行 id。撞 EA UNIQUE 约束会抛 sqlite3.IntegrityError,
绑定入口需先用 get_user_by_ea_id 预检(见 services/binding.py)。"""
cursor = self._execute(
"INSERT INTO users (qq_id, ea_player_name, ea_player_id, ea_user_id,"
" designation, medals) VALUES (?, ?, ?, ?, ?, ?)",
(qq_id, ea_player_name, ea_player_id, ea_user_id, designation, medals),
)
return int(cursor.lastrowid or 0)
def update_user(
self,
qq_id: str,
ea_player_name: str,
ea_player_id: str,
ea_user_id: str,
designation: str,
medals: str,
) -> int:
"""换绑:按 qq_id 更新 EA 信息,返回受影响行数。"""
cursor = self._execute(
"UPDATE users SET ea_player_name = ?, ea_player_id = ?, ea_user_id = ?"
" WHERE qq_id = ?",
(ea_player_name, ea_player_id, ea_user_id, qq_id),
)
return cursor.rowcount
def get_user_by_qq(self, qq_id: str) -> dict[str, Any] | None:
cursor = self._execute("SELECT * FROM users WHERE qq_id = ?", (qq_id,))
row = cursor.fetchone()
return dict(row) if row else None
def get_user_by_ea_id(self, ea_user_id: str) -> dict[str, Any] | None:
cursor = self._execute(
"SELECT * FROM users WHERE ea_user_id = ?", (ea_user_id,)
)
row = cursor.fetchone()
return dict(row) if row else None
def delete_user_by_qq(self, qq_id: str) -> int:
cursor = self._execute("DELETE FROM users WHERE qq_id = ?", (qq_id,))
return cursor.rowcount
def list_users(
self, limit: int | None = None, offset: int = 0
) -> list[dict[str, Any]]:
"""Web 绑定管理页用:绑定记录(按绑定时间倒序)。
limit=None 取全量;给 limit/offset 即分页(Web 页每页 10 条)。
"""
sql = "SELECT * FROM users ORDER BY create_time DESC, id DESC"
params: tuple = ()
if limit is not None:
sql += " LIMIT ? OFFSET ?"
params = (int(limit), int(offset))
cursor = self._execute(sql, params)
return [dict(row) for row in cursor.fetchall()]
def count_users(self) -> int:
"""绑定总数(Web 分页用)。"""
cursor = self._execute("SELECT COUNT(*) FROM users")
row = cursor.fetchone()
return int(row[0]) if row else 0
class DesignationManager(TableManager):
"""designation 表:玩家标签(装饰图标)定义。"""
def create_designation(self, name: str, icon_path: str) -> int:
cursor = self._execute(
"INSERT INTO designation (name, icon_path) VALUES (?, ?)", (name, icon_path)
)
return int(cursor.lastrowid or 0)
def get_all_designation(self) -> list[dict[str, Any]]:
cursor = self._execute("SELECT * FROM designation")
return [dict(row) for row in cursor.fetchall()]
def get_designation_by_id_list(self, ids: list[int]) -> list[dict[str, Any]]:
if not ids:
return []
placeholders = ",".join(["?"] * len(ids))
cursor = self._execute(
f"SELECT * FROM designation WHERE id IN ({placeholders})", tuple(ids)
)
return [dict(row) for row in cursor.fetchall()]
class Bf2042SettingsManager(TableManager):
"""bf2042_settings 表:BF2042 卡片个人设置。
- support:旧 project 的"支持者"标记(1=可用个人背景图库 bg/user/{qq}/);
- bg_mode:预留(common/user),当前渲染只认 support + 个人目录是否有图。
"""
def get_settings(self, qq_id: str) -> dict[str, Any] | None:
cursor = self._execute(
"SELECT * FROM bf2042_settings WHERE qq_id = ?", (str(qq_id),)
)
row = cursor.fetchone()
return dict(row) if row else None
def is_support(self, qq_id: str) -> bool:
"""无记录/查询失败一律按普通用户处理(不让装饰性功能拖垮查询)。"""
try:
row = self.get_settings(qq_id)
return bool(row and int(row.get("support") or 0) == 1)
except Exception: # noqa: BLE001
return False
def set_support(self, qq_id: str, support: bool) -> None:
self._execute(
"""
INSERT INTO bf2042_settings (qq_id, support) VALUES (?, ?)
ON CONFLICT(qq_id) DO UPDATE SET support = excluded.support
""",
(str(qq_id), 1 if support else 0),
)
def upsert_settings(self, qq_id: str, **fields: Any) -> None:
"""按列 upsert(bg_mode 等扩展字段留以后用)。"""
allowed = {"support", "bg_mode"}
cols = {k: v for k, v in fields.items() if k in allowed}
if not cols:
return
qq = str(qq_id)
existing = self.get_settings(qq)
if existing is None:
names = ", ".join(cols)
marks = ", ".join(["?"] * len(cols))
self._execute(
f"INSERT INTO bf2042_settings (qq_id, {names}) VALUES (?, {marks})",
(qq, *cols.values()),
)
else:
sets = ", ".join(f"{k} = ?" for k in cols)
self._execute(
f"UPDATE bf2042_settings SET {sets}, update_time = CURRENT_TIMESTAMP "
"WHERE qq_id = ?",
(*cols.values(), qq),
)
class DatabaseManager:
"""建表 / 校验表结构(幂等)。"""
def __init__(self, db_path: str | None = None):
# 默认与 TableManager 指向同一个库文件;
# 绝对路径,避免受启动目录影响
self.db_path = str(db_path or DB_PATH)
self.expected_tables = {
"users": [
("id", "INTEGER PRIMARY KEY AUTOINCREMENT"),
("qq_id", "TEXT NOT NULL"),
("ea_player_name", "TEXT UNIQUE"),
("ea_player_id", "TEXT UNIQUE"),
("ea_user_id", "TEXT UNIQUE"),
("designation", "TEXT NOT NULL"),
("medals", "TEXT NOT NULL"),
("create_time", "TIMESTAMP DEFAULT CURRENT_TIMESTAMP"),
],
"designation": [
("id", "INTEGER PRIMARY KEY"),
("name", "TEXT NOT NULL"),
("icon_path", "TEXT NOT NULL"),
("create_time", "TIMESTAMP DEFAULT CURRENT_TIMESTAMP"),
],
"medals": [
("id", "INTEGER PRIMARY KEY"),
("name", "TEXT NOT NULL"),
("icon_path", "TEXT NOT NULL"),
("create_time", "TIMESTAMP DEFAULT CURRENT_TIMESTAMP"),
],
"query_record": [
("id", "INTEGER PRIMARY KEY"),
("user_id", "TEXT NOT NULL"),
("ea_player_name", "TEXT NOT NULL"),
("ea_player_id", "TEXT NOT NULL"),
("ea_user_id", "TEXT NOT NULL"),
("status", "TEXT NOT NULL"),
("create_time", "TIMESTAMP DEFAULT CURRENT_TIMESTAMP"),
],
# BF2042 卡片个人设置 + 特权标记(旧 project user_bind.support 迁移而来)
"bf2042_settings": [
("qq_id", "TEXT PRIMARY KEY"),
("support", "INTEGER DEFAULT 0"),
("bg_mode", "TEXT DEFAULT 'common'"),
("create_time", "TIMESTAMP DEFAULT CURRENT_TIMESTAMP"),
("update_time", "TIMESTAMP DEFAULT CURRENT_TIMESTAMP"),
],
}
def initialize_database(self) -> bool:
"""初始化数据库并创建表结构(CREATE TABLE IF NOT EXISTS,幂等)。"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
for table_name, columns in self.expected_tables.items():
columns_sql = ", ".join(f"{col} {typ}" for col, typ in columns)
cursor.execute(
f"CREATE TABLE IF NOT EXISTS {table_name} ({columns_sql})"
)
conn.commit()
return True
except Exception as e: # noqa: BLE001
# 不动库文件:建表失败很可能是文件被占用一类的临时问题,
# 直接删库会连带丢掉用户的绑定数据,这里只报告失败
_logger().error(f"Database initialization failed: {str(e)}")
return False
def _verify_database_integrity(self, conn: sqlite3.Connection) -> bool:
"""校验表齐全、列结构一致、SQLite 完整性(供排查用,不参与启动流程)。"""
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
# 忽略 sqlite_sequence 等 SQLite 内部表,它们随 AUTOINCREMENT 自动出现
existing_tables = {
row[0] for row in cursor.fetchall() if not row[0].startswith("sqlite_")
}
missing_tables = set(self.expected_tables.keys()) - existing_tables
if missing_tables:
_logger().warning(f"Missing tables: {missing_tables}")
return False
for table_name, expected_columns in self.expected_tables.items():
cursor.execute(f"PRAGMA table_info({table_name})")
actual_columns = [(row[1], row[2]) for row in cursor.fetchall()]
if not self._compare_columns(actual_columns, expected_columns):
_logger().warning(f"Table {table_name} structure mismatch")
return False
cursor.execute("PRAGMA integrity_check")
integrity_result = cursor.fetchone()
if integrity_result[0] != "ok":
_logger().warning(f"Integrity check failed: {integrity_result}")
return False
return True
@staticmethod
def _type_head(declaration: str) -> str:
"""取类型声明的首个词,用于忽略约束差异。"""
parts = (declaration or "").split()
return parts[0].upper() if parts else ""
def _compare_columns(self, actual: list[tuple], expected: list[tuple]) -> bool:
"""比较实际列与预期列是否匹配。"""
if len(actual) != len(expected):
return False
for (act_col, act_type), (exp_col, exp_type) in zip(actual, expected):
if act_col.lower() != exp_col.lower():
return False
# PRAGMA table_info 只返回类型名,不带 PRIMARY KEY/AUTOINCREMENT 等约束,
# 所以只比对类型首词,否则 INTEGER 永远匹配不上 "INTEGER PRIMARY KEY ..."
if self._type_head(act_type) != self._type_head(exp_type):
return False
return True
def init_database() -> bool:
"""建表(幂等);插件入口导入期调用,兼容新环境与热重载。"""
return DatabaseManager().initialize_database()