"""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()