Files
HeXi/hexi/plugins/nonebot_plugin_bf_bot/repository.py
T
sansenhoshiandClaude Code a31aab00e8 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>
2026-10-13 17:53:06 +08:00

314 lines
12 KiB
Python
Raw 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.
"""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()