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:
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()
|
||||
Reference in new issue
Block a user