313 lines
12 KiB
Python
313 lines
12 KiB
Python
"""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()
|