Qwen 说让我再改点东西所以改了,强化了数据库相关的事情

This commit is contained in:
2025-11-19 00:44:44 +08:00
parent 0d540eea4c
commit f6fadb7226
9 changed files with 1338 additions and 24 deletions

View File

@ -98,4 +98,4 @@ poetry run python bot.py
## 数据库模块 ## 数据库模块
本项目的数据库模块已更新为异步实现,使用连接池来提高性能,并支持现代的`pathlib.Path`参数类型。详细使用方法请参考`konabot/common/database/__init__.py`文件中的实现 本项目的数据库模块已更新为异步实现,使用连接池来提高性能,并支持现代的`pathlib.Path`参数类型。详细使用方法请参考[数据库使用文档](/docs/database.md)

8
bot.py
View File

@ -10,6 +10,7 @@ from nonebot.adapters.onebot.v11 import Adapter as OnebotAdapter
from konabot.common.log import init_logger from konabot.common.log import init_logger
from konabot.common.nb.exc import BotExceptionMessage from konabot.common.nb.exc import BotExceptionMessage
from konabot.common.path import LOG_PATH from konabot.common.path import LOG_PATH
from konabot.common.database import get_global_db_manager
dotenv.load_dotenv() dotenv.load_dotenv()
@ -49,6 +50,13 @@ def main():
nonebot.load_plugins("konabot/plugins") nonebot.load_plugins("konabot/plugins")
nonebot.load_plugin("nonebot_plugin_analysis_bilibili") nonebot.load_plugin("nonebot_plugin_analysis_bilibili")
# 注册关闭钩子
@driver.on_shutdown
async def shutdown_handler():
# 关闭全局数据库管理器
db_manager = get_global_db_manager()
await db_manager.close_all_connections()
nonebot.run() nonebot.run()
if __name__ == "__main__": if __name__ == "__main__":

848
commit_f21da65.diff Normal file
View File

@ -0,0 +1,848 @@
commit f21da657dbc79c2d139265a69696e5ad213f5c53
Author: MixBadGun <1059129006@qq.com>
Date: Tue Nov 18 19:36:05 2025 +0800
database 接入
diff --git a/.env.example b/.env.example
index 7fde1d8..488632c 100644
--- a/.env.example
+++ b/.env.example
@@ -1,4 +1,4 @@
ENVIRONMENT=dev
PORT=21333
-
+DATABASE_PATH="./data/database.db"
ENABLE_CONSOLE=true
diff --git a/.gitignore b/.gitignore
index 9f2daec..8337d30 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,6 @@
/.env
/data
-__pycache__
\ No newline at end of file
+__pycache__
+
+*.db
\ No newline at end of file
diff --git a/bot.py b/bot.py
index 782c870..e4c56ca 100644
--- a/bot.py
+++ b/bot.py
@@ -10,6 +10,7 @@ from nonebot.adapters.onebot.v11 import Adapter as OnebotAdapter
from konabot.common.log import init_logger
from konabot.common.nb.exc import BotExceptionMessage
from konabot.common.path import LOG_PATH
+from konabot.core.preinit import preinit
dotenv.load_dotenv()
env = os.environ.get("ENVIRONMENT", "prod")
@@ -48,6 +49,9 @@ def main():
nonebot.load_plugins("konabot/plugins")
nonebot.load_plugin("nonebot_plugin_analysis_bilibili")
+ # 预加载
+ preinit("konabot/plugins")
+
nonebot.run()
if __name__ == "__main__":
diff --git a/konabot/common/database/__init__.py b/konabot/common/database/__init__.py
new file mode 100644
index 0000000..2a44469
--- /dev/null
+++ b/konabot/common/database/__init__.py
@@ -0,0 +1,64 @@
+import os
+import sqlite3
+from typing import List, Dict, Any, Optional
+
+class DatabaseManager:
+ """超级无敌神奇的数据库!"""
+
+ @classmethod
+ def query(cls, query: str, params: Optional[tuple] = None) -> List[Dict[str, Any]]:
+ """执行查询语句并返回结果"""
+ conn = sqlite3.connect(os.environ.get('DATABASE_PATH', './data/database.db'))
+ cursor = conn.cursor()
+ cursor.execute(query, params or ())
+ columns = [description[0] for description in cursor.description]
+ results = [dict(zip(columns, row)) for row in cursor.fetchall()]
+ cursor.close()
+ conn.close()
+ return results
+
+ @classmethod
+ def query_by_sql_file(cls, file_path: str, params: Optional[tuple] = None) -> List[Dict[str, Any]]:
+ """从 SQL 文件中读取查询语句并执行"""
+ with open(file_path, 'r', encoding='utf-8') as f:
+ query = f.read()
+ return cls.query(query, params)
+
+ @classmethod
+ def execute(cls, command: str, params: Optional[tuple] = None) -> None:
+ """执行非查询语句"""
+ conn = sqlite3.connect(os.environ.get('DATABASE_PATH', './data/database.db'))
+ cursor = conn.cursor()
+ cursor.execute(command, params or ())
+ conn.commit()
+ cursor.close()
+ conn.close()
+
+ @classmethod
+ def execute_by_sql_file(cls, file_path: str, params: Optional[tuple] = None) -> None:
+ """从 SQL 文件中读取非查询语句并执行"""
+ with open(file_path, 'r', encoding='utf-8') as f:
+ command = f.read()
+ # 按照需要执行多条语句
+ commands = command.split(';')
+ for cmd in commands:
+ cmd = cmd.strip()
+ if cmd:
+ cls.execute(cmd, params)
+
+ @classmethod
+ def execute_many(cls, command: str, seq_of_params: List[tuple]) -> None:
+ """执行多条非查询语句"""
+ conn = sqlite3.connect(os.environ.get('DATABASE_PATH', './data/database.db'))
+ cursor = conn.cursor()
+ cursor.executemany(command, seq_of_params)
+ conn.commit()
+ cursor.close()
+ conn.close()
+
+ @classmethod
+ def execute_many_values_by_sql_file(cls, file_path: str, seq_of_params: List[tuple]) -> None:
+ """从 SQL 文件中读取一条语句,但是被不同值同时执行"""
+ with open(file_path, 'r', encoding='utf-8') as f:
+ command = f.read()
+ cls.execute_many(command, seq_of_params)
\ No newline at end of file
diff --git a/konabot/core/preinit.py b/konabot/core/preinit.py
new file mode 100644
index 0000000..ccfd3f7
--- /dev/null
+++ b/konabot/core/preinit.py
@@ -0,0 +1,15 @@
+from pathlib import Path
+
+from nonebot import logger
+
+def preinit(path: str):
+ # 执行预初始化,递归找到位于对应路径内文件名为 __preinit__.py 的所有文件都会被执行
+ dir_path = Path(path)
+ for item in dir_path.iterdir():
+ if item.is_dir():
+ preinit(item)
+ elif item.is_file() and item.name == "__preinit__.py":
+ # 动态导入该文件以执行预初始化代码
+ module_path = str(item.with_suffix("")).replace("/", ".").replace("\\", ".")
+ __import__(module_path)
+ logger.info(f"Preinitialized module: {module_path}")
\ No newline at end of file
diff --git a/konabot/plugins/air_conditioner/__init__.py b/konabot/plugins/air_conditioner/__init__.py
index 4f921fe..e148954 100644
--- a/konabot/plugins/air_conditioner/__init__.py
+++ b/konabot/plugins/air_conditioner/__init__.py
@@ -7,16 +7,19 @@ from nonebot.adapters.discord.event import MessageEvent as DiscordMessageEvent
from nonebot_plugin_alconna import Alconna, AlconnaMatcher, Args, UniMessage, on_alconna
from PIL import Image
import numpy as np
+from konabot.common.database import DatabaseManager
from konabot.common.longtask import DepLongTaskTarget
from konabot.common.path import ASSETS_PATH
from konabot.common.web_render import WebRenderer
from konabot.plugins.air_conditioner.ac import AirConditioner, CrashType, generate_ac_image, wiggle_transform
-
+from pathlib import Path
import random
import math
+ROOT_PATH = Path(__file__).resolve().parent
+
def get_ac(id: str) -> AirConditioner:
- ac = AirConditioner.air_conditioners.get(id)
+ ac = AirConditioner.get_ac(id)
if ac is None:
ac = AirConditioner(id)
return ac
@@ -61,7 +64,7 @@ evt = on_alconna(Alconna(
async def _(event: BaseEvent, target: DepLongTaskTarget):
id = target.channel_id
ac = get_ac(id)
- ac.on = True
+ ac.update_ac(state=True)
await send_ac_image(evt, ac)
evt = on_alconna(Alconna(
@@ -72,7 +75,7 @@ evt = on_alconna(Alconna(
async def _(event: BaseEvent, target: DepLongTaskTarget):
id = target.channel_id
ac = get_ac(id)
- ac.on = False
+ ac.update_ac(state=False)
await send_ac_image(evt, ac)
evt = on_alconna(Alconna(
@@ -82,6 +85,8 @@ evt = on_alconna(Alconna(
@evt.handle()
async def _(event: BaseEvent, target: DepLongTaskTarget, temp: Optional[Union[int, float]] = 1):
+ if temp is None:
+ temp = 1
if temp <= 0:
return
id = target.channel_id
@@ -89,7 +94,7 @@ async def _(event: BaseEvent, target: DepLongTaskTarget, temp: Optional[Union[in
if not ac.on or ac.burnt == True or ac.frozen == True:
await send_ac_image(evt, ac)
return
- ac.temperature += temp
+ ac.update_ac(temperature_delta=temp)
if ac.temperature > 40:
# 根据温度随机出是否爆炸40度开始呈指数增长
possibility = -math.e ** ((40-ac.temperature) / 50) + 1
@@ -115,6 +120,8 @@ evt = on_alconna(Alconna(
@evt.handle()
async def _(event: BaseEvent, target: DepLongTaskTarget, temp: Optional[Union[int, float]] = 1):
+ if temp is None:
+ temp = 1
if temp <= 0:
return
id = target.channel_id
@@ -122,7 +129,7 @@ async def _(event: BaseEvent, target: DepLongTaskTarget, temp: Optional[Union[in
if not ac.on or ac.burnt == True or ac.frozen == True:
await send_ac_image(evt, ac)
return
- ac.temperature -= temp
+ ac.update_ac(temperature_delta=-temp)
if ac.temperature < 0:
# 根据温度随机出是否冻结0度开始呈指数增长
possibility = -math.e ** (ac.temperature / 50) + 1
@@ -141,6 +148,16 @@ async def _(event: BaseEvent, target: DepLongTaskTarget):
ac.change_ac()
await send_ac_image(evt, ac)
+def query_number_ranking(id: str) -> tuple[int, int]:
+ result = DatabaseManager.query_by_sql_file(
+ ROOT_PATH / "sql" / "query_crash_and_rank.sql",
+ (id,id)
+ )
+ if len(result) == 0:
+ return 0, 0
+ else:
+ return result[0].values()
+
evt = on_alconna(Alconna(
"空调炸炸排行榜",
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
@@ -148,8 +165,9 @@ evt = on_alconna(Alconna(
@evt.handle()
async def _(event: BaseEvent, target: DepLongTaskTarget):
id = target.channel_id
- ac = get_ac(id)
- number, ranking = ac.get_crashes_and_ranking()
+ # ac = get_ac(id)
+ # number, ranking = ac.get_crashes_and_ranking()
+ number, ranking = query_number_ranking(id)
params = {
"number": number,
"ranking": ranking
diff --git a/konabot/plugins/air_conditioner/__preinit__.py b/konabot/plugins/air_conditioner/__preinit__.py
new file mode 100644
index 0000000..67054a0
--- /dev/null
+++ b/konabot/plugins/air_conditioner/__preinit__.py
@@ -0,0 +1,9 @@
+# 预初始化,只要是导入本插件包就会执行这里的代码
+from pathlib import Path
+
+from konabot.common.database import DatabaseManager
+
+# 初始化数据库表
+DatabaseManager.execute_by_sql_file(
+ Path(__file__).resolve().parent / "sql" / "create_table.sql"
+)
diff --git a/konabot/plugins/air_conditioner/ac.py b/konabot/plugins/air_conditioner/ac.py
index 6614784..9be7619 100644
--- a/konabot/plugins/air_conditioner/ac.py
+++ b/konabot/plugins/air_conditioner/ac.py
@@ -1,66 +1,112 @@
from enum import Enum
from io import BytesIO
+from pathlib import Path
import cv2
import numpy as np
from PIL import Image, ImageDraw, ImageFont
+from konabot.common.database import DatabaseManager
from konabot.common.path import ASSETS_PATH, FONTS_PATH
from konabot.common.path import DATA_PATH
import json
+ROOT_PATH = Path(__file__).resolve().parent
+
class CrashType(Enum):
BURNT = 0
FROZEN = 1
class AirConditioner:
- air_conditioners: dict[str, "AirConditioner"] = {}
+ @classmethod
+ def get_ac(cls, id: str) -> 'AirConditioner':
+ result = DatabaseManager.query_by_sql_file(ROOT_PATH / "sql" / "query_ac.sql", (id,))
+ if len(result) == 0:
+ ac = cls.create_ac(id)
+ return ac
+ ac_data = result[0]
+ ac = AirConditioner(id)
+ ac.on = bool(ac_data["on"])
+ ac.temperature = float(ac_data["temperature"])
+ ac.burnt = bool(ac_data["burnt"])
+ ac.frozen = bool(ac_data["frozen"])
+ return ac
- def __init__(self, id: str) -> None:
- self.id = id
+ @classmethod
+ def create_ac(cls, id: str) -> 'AirConditioner':
+ ac = AirConditioner(id)
+ DatabaseManager.execute_by_sql_file(
+ ROOT_PATH / "sql" / "insert_ac.sql",
+ (id, ac.on, ac.temperature, ac.burnt, ac.frozen)
+ )
+ return ac
+
+ def update_ac(self, state: bool = None, temperature_delta: float = None, burnt: bool = None, frozen: bool = None) -> 'AirConditioner':
+ if state is not None:
+ self.on = state
+ if temperature_delta is not None:
+ self.temperature += temperature_delta
+ if burnt is not None:
+ self.burnt = burnt
+ if frozen is not None:
+ self.frozen = frozen
+ DatabaseManager.execute_by_sql_file(
+ ROOT_PATH / "sql" / "update_ac.sql",
+ (self.on, self.temperature, self.burnt, self.frozen, self.id)
+ )
+ return self
+
+ def change_ac(self) -> 'AirConditioner':
self.on = False
- self.temperature = 24 # 默认温度
+ self.temperature = 24
self.burnt = False
self.frozen = False
- AirConditioner.air_conditioners[id] = self
+ DatabaseManager.execute_by_sql_file(
+ ROOT_PATH / "sql" / "update_ac.sql",
+ (self.on, self.temperature, self.burnt, self.frozen, self.id)
+ )
+ return self
- def change_ac(self):
+ def __init__(self, id: str) -> None:
+ self.id = id
+ self.on = False
+ self.temperature = 24 # 默认温度
self.burnt = False
self.frozen = False
- self.on = False
- self.temperature = 24 # 重置为默认温度
def broke_ac(self, crash_type: CrashType):
'''
- 让空调坏掉,并保存数据
-
+ 让空调坏掉
:param crash_type: CrashType 枚举,表示空调坏掉的类型
'''
match crash_type:
case CrashType.BURNT:
- self.burnt = True
+ self.update_ac(burnt=True)
case CrashType.FROZEN:
- self.frozen = True
- self.save_crash_data(crash_type)
+ self.update_ac(frozen=True)
+ DatabaseManager.execute_by_sql_file(
+ ROOT_PATH / "sql" / "insert_crash.sql",
+ (self.id, crash_type.value)
+ )
- def save_crash_data(self, crash_type: CrashType):
- '''
- 如果空调爆炸了,就往本地的 ac_crash_data.json 里该 id 的记录加一
- '''
- data_file = DATA_PATH / "ac_crash_data.json"
- crash_data = {}
- if data_file.exists():
- with open(data_file, "r", encoding="utf-8") as f:
- crash_data = json.load(f)
- if self.id not in crash_data:
- crash_data[self.id] = {"burnt": 0, "frozen": 0}
- match crash_type:
- case CrashType.BURNT:
- crash_data[self.id]["burnt"] += 1
- case CrashType.FROZEN:
- crash_data[self.id]["frozen"] += 1
- with open(data_file, "w", encoding="utf-8") as f:
- json.dump(crash_data, f, ensure_ascii=False, indent=4)
+ # def save_crash_data(self, crash_type: CrashType):
+ # '''
+ # 如果空调爆炸了,就往本地的 ac_crash_data.json 里该 id 的记录加一
+ # '''
+ # data_file = DATA_PATH / "ac_crash_data.json"
+ # crash_data = {}
+ # if data_file.exists():
+ # with open(data_file, "r", encoding="utf-8") as f:
+ # crash_data = json.load(f)
+ # if self.id not in crash_data:
+ # crash_data[self.id] = {"burnt": 0, "frozen": 0}
+ # match crash_type:
+ # case CrashType.BURNT:
+ # crash_data[self.id]["burnt"] += 1
+ # case CrashType.FROZEN:
+ # crash_data[self.id]["frozen"] += 1
+ # with open(data_file, "w", encoding="utf-8") as f:
+ # json.dump(crash_data, f, ensure_ascii=False, indent=4)
def get_crashes_and_ranking(self) -> tuple[int, int]:
'''
diff --git a/konabot/plugins/air_conditioner/sql/create_table.sql b/konabot/plugins/air_conditioner/sql/create_table.sql
new file mode 100644
index 0000000..5203e23
--- /dev/null
+++ b/konabot/plugins/air_conditioner/sql/create_table.sql
@@ -0,0 +1,15 @@
+-- 创建所有表
+CREATE TABLE IF NOT EXISTS air_conditioner (
+ id VARCHAR(128) PRIMARY KEY,
+ 'on' BOOLEAN NOT NULL,
+ temperature REAL NOT NULL,
+ burnt BOOLEAN NOT NULL,
+ frozen BOOLEAN NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS air_conditioner_crash_log (
+ id VARCHAR(128) NOT NULL,
+ crash_type INT NOT NULL,
+ timestamp DATETIME NOT NULL,
+ FOREIGN KEY (id) REFERENCES air_conditioner(id)
+);
\ No newline at end of file
diff --git a/konabot/plugins/air_conditioner/sql/insert_ac.sql b/konabot/plugins/air_conditioner/sql/insert_ac.sql
new file mode 100644
index 0000000..3fb1c76
--- /dev/null
+++ b/konabot/plugins/air_conditioner/sql/insert_ac.sql
@@ -0,0 +1,3 @@
+-- 插入一台新空调
+INSERT INTO air_conditioner (id, 'on', temperature, burnt, frozen)
+VALUES (?, ?, ?, ?, ?);
\ No newline at end of file
diff --git a/konabot/plugins/air_conditioner/sql/insert_crash.sql b/konabot/plugins/air_conditioner/sql/insert_crash.sql
new file mode 100644
index 0000000..aae3898
--- /dev/null
+++ b/konabot/plugins/air_conditioner/sql/insert_crash.sql
@@ -0,0 +1,3 @@
+-- 插入一条空调爆炸记录
+INSERT INTO air_conditioner_crash_log (id, crash_type, timestamp)
+VALUES (?, ?, CURRENT_TIMESTAMP);
\ No newline at end of file
diff --git a/konabot/plugins/air_conditioner/sql/query_ac.sql b/konabot/plugins/air_conditioner/sql/query_ac.sql
new file mode 100644
index 0000000..db957d3
--- /dev/null
+++ b/konabot/plugins/air_conditioner/sql/query_ac.sql
@@ -0,0 +1,4 @@
+-- 查询空调状态,如果没有就插入一条新的记录
+SELECT *
+FROM air_conditioner
+WHERE id = ?;
\ No newline at end of file
diff --git a/konabot/plugins/air_conditioner/sql/query_crash_and_rank.sql b/konabot/plugins/air_conditioner/sql/query_crash_and_rank.sql
new file mode 100644
index 0000000..c180638
--- /dev/null
+++ b/konabot/plugins/air_conditioner/sql/query_crash_and_rank.sql
@@ -0,0 +1,23 @@
+-- 从 air_conditioner_crash_log 表中获取指定 id 损坏的次数以及损坏次数的排名
+SELECT crash_count, crash_rank
+FROM (
+ SELECT id,
+ COUNT(*) AS crash_count,
+ RANK() OVER (ORDER BY COUNT(*) DESC) AS crash_rank
+ FROM air_conditioner_crash_log
+ GROUP BY id
+) AS ranked_data
+WHERE id = ?
+-- 如果该 id 没有损坏记录,则返回 0 次损坏和对应的最后一名
+UNION
+SELECT 0 AS crash_count,
+ (SELECT COUNT(DISTINCT id) + 1 FROM air_conditioner_crash_log) AS crash_rank
+FROM (
+ SELECT DISTINCT id
+ FROM air_conditioner_crash_log
+) AS ranked_data
+WHERE NOT EXISTS (
+ SELECT 1
+ FROM air_conditioner_crash_log
+ WHERE id = ?
+);
\ No newline at end of file
diff --git a/konabot/plugins/air_conditioner/sql/update_ac.sql b/konabot/plugins/air_conditioner/sql/update_ac.sql
new file mode 100644
index 0000000..df9145e
--- /dev/null
+++ b/konabot/plugins/air_conditioner/sql/update_ac.sql
@@ -0,0 +1,4 @@
+-- 更新空调状态
+UPDATE air_conditioner
+SET 'on' = ?, temperature = ?, burnt = ?, frozen = ?
+WHERE id = ?;
\ No newline at end of file
diff --git a/konabot/plugins/idiomgame/__init__.py b/konabot/plugins/idiomgame/__init__.py
index ee4e26c..36710aa 100644
--- a/konabot/plugins/idiomgame/__init__.py
+++ b/konabot/plugins/idiomgame/__init__.py
@@ -18,11 +18,14 @@ from nonebot_plugin_alconna import (
on_alconna,
)
+from konabot.common.database import DatabaseManager
from konabot.common.longtask import DepLongTaskTarget
from konabot.common.path import ASSETS_PATH
from konabot.common.llm import get_llm
+ROOT_PATH = Path(__file__).resolve().parent
+
DATA_DIR = Path(__file__).parent.parent.parent.parent / "data"
DATA_FILE_PATH = (
@@ -94,18 +97,19 @@ class IdiomGameLLM:
@classmethod
async def storage_idiom(cls, idiom: str):
- # 将 idiom 存入本地文件以备后续分析
- with open(DATA_DIR / "idiom_llm_storage.txt", "a", encoding="utf-8") as f:
- f.write(idiom + "\n")
- IdiomGame.append_into_word_list(idiom)
+ # 将 idiom 存入数据库
+ DatabaseManager.execute_by_sql_file(
+ ROOT_PATH / "sql" / "insert_custom_word.sql",
+ (idiom,)
+ )
class IdiomGame:
- ALL_WORDS = [] # 所有四字词语
- ALL_IDIOMS = [] # 所有成语
+ # ALL_WORDS = [] # 所有四字词语
+ # ALL_IDIOMS = [] # 所有成语
INSTANCE_LIST: dict[str, "IdiomGame"] = {} # 群号对应的游戏实例
- IDIOM_FIRST_CHAR = {} # 所有成语包括词语的首字字典
- AVALIABLE_IDIOM_FIRST_CHAR = {} # 真正有效的成语首字字典
+ # IDIOM_FIRST_CHAR = {} # 所有成语包括词语的首字字典
+ # AVALIABLE_IDIOM_FIRST_CHAR = {} # 真正有效的成语首字字典
__inited = False
@@ -130,11 +134,10 @@ class IdiomGame:
'''
将一个新词加入到词语列表中
'''
- if word not in cls.ALL_WORDS:
- cls.ALL_WORDS.append(word)
- if word[0] not in cls.IDIOM_FIRST_CHAR:
- cls.IDIOM_FIRST_CHAR[word[0]] = []
- cls.IDIOM_FIRST_CHAR[word[0]].append(word)
+ DatabaseManager.execute_by_sql_file(
+ ROOT_PATH / "sql" / "insert_custom_word.sql",
+ (word,)
+ )
def be_able_to_play(self) -> bool:
if self.last_play_date != datetime.date.today():
@@ -145,11 +148,17 @@ class IdiomGame:
return True
return False
+ @staticmethod
+ def random_idiom() -> str:
+ return DatabaseManager.query_by_sql_file(
+ ROOT_PATH / "sql" / "random_choose_idiom.sql"
+ )[0]["idiom"]
+
def choose_start_idiom(self) -> str:
"""
随机选择一个成语作为起始成语
"""
- self.last_idiom = secrets.choice(IdiomGame.ALL_IDIOMS)
+ self.last_idiom = IdiomGame.random_idiom()
self.last_char = self.last_idiom[-1]
if not self.is_nextable(self.last_char):
self.choose_start_idiom()
@@ -208,7 +217,7 @@ class IdiomGame:
return self.last_idiom
def _skip_idiom_async(self) -> str:
- self.last_idiom = secrets.choice(IdiomGame.ALL_IDIOMS)
+ self.last_idiom = IdiomGame.random_idiom()
self.last_char = self.last_idiom[-1]
if not self.is_nextable(self.last_char):
self._skip_idiom_async()
@@ -228,8 +237,11 @@ class IdiomGame:
"""
判断是否有成语可以接
"""
- return last_char in IdiomGame.AVALIABLE_IDIOM_FIRST_CHAR
-
+ return DatabaseManager.query_by_sql_file(
+ ROOT_PATH / "sql" / "is_nextable.sql",
+ (last_char,)
+ )[0]["DEED"] == 1
+
def add_already_idiom(self, idiom: str):
if idiom in self.already_idioms:
self.already_idioms[idiom] += 1
@@ -259,7 +271,12 @@ class IdiomGame:
if idiom[0] != self.last_char:
state.append(TryVerifyState.WRONG_FIRST_CHAR)
return state
- if idiom not in IdiomGame.ALL_IDIOMS and idiom not in IdiomGame.ALL_WORDS:
+ # 成语是否存在
+ result = DatabaseManager.query_by_sql_file(
+ ROOT_PATH / "sql" / "query_idiom.sql",
+ (idiom, idiom, idiom)
+ )[0]["status"]
+ if result == -1:
logger.info(f"用户 {user_id} 发送了未知词语 {idiom},正在使用 LLM 进行验证")
try:
if not await IdiomGameLLM.verify_idiom_with_llm(idiom):
@@ -281,7 +298,7 @@ class IdiomGame:
self.last_idiom = idiom
self.last_char = idiom[-1]
self.add_score(user_id, 1 * score_k) # 先加 1 分
- if idiom in IdiomGame.ALL_IDIOMS:
+ if result == 1:
state.append(TryVerifyState.VERIFIED_AND_REAL)
self.add_score(user_id, 4 * score_k) # 再加 4 分
self.remain_rounds -= 1
@@ -319,14 +336,21 @@ class IdiomGame:
@classmethod
def random_idiom_starting_with(cls, first_char: str) -> Optional[str]:
cls.init_lexicon()
- if first_char not in cls.AVALIABLE_IDIOM_FIRST_CHAR:
+ result = DatabaseManager.query_by_sql_file(
+ ROOT_PATH / "sql" / "query_idiom_start_with.sql",
+ (first_char,)
+ )
+ if len(result) == 0:
return None
- return secrets.choice(cls.AVALIABLE_IDIOM_FIRST_CHAR[first_char])
+ return result[0]["idiom"]
@classmethod
def init_lexicon(cls):
if cls.__inited:
return
+ DatabaseManager.execute_by_sql_file(
+ ROOT_PATH / "sql" / "create_table.sql"
+ ) # 确保数据库初始化
cls.__inited = True
# 成语大表
@@ -334,11 +358,12 @@ class IdiomGame:
ALL_IDIOMS_INFOS = json.load(f)
# 词语大表
+ ALL_WORDS = []
with open(ASSETS_PATH / "lexicon" / "ci.json", "r", encoding="utf-8") as f:
jsonData = json.load(f)
- cls.ALL_WORDS = [item["ci"] for item in jsonData]
- logger.debug(f"Loaded {len(cls.ALL_WORDS)} words from ci.json")
- logger.debug(f"Sample words: {cls.ALL_WORDS[:5]}")
+ ALL_WORDS = [item["ci"] for item in jsonData]
+ logger.debug(f"Loaded {len(ALL_WORDS)} words from ci.json")
+ logger.debug(f"Sample words: {ALL_WORDS[:5]}")
COMMON_WORDS = []
# 读取 COMMON 词语大表
@@ -389,29 +414,44 @@ class IdiomGame:
logger.debug(f"Loaded additional {len(LOCAL_LLM_WORDS)} words from idiom_llm_storage.txt")
# 只有成语的大表
- cls.ALL_IDIOMS = [idiom["word"] for idiom in ALL_IDIOMS_INFOS] + THUOCL_IDIOMS
- cls.ALL_IDIOMS = list(set(cls.ALL_IDIOMS)) # 去重
+ ALL_IDIOMS = [idiom["word"] for idiom in ALL_IDIOMS_INFOS] + THUOCL_IDIOMS
+ ALL_IDIOMS = list(set(ALL_IDIOMS)) # 去重
+ # 批量插入数据库
+ DatabaseManager.execute_many_values_by_sql_file(
+ ROOT_PATH / "sql" / "insert_idiom.sql",
+ [(idiom,) for idiom in ALL_IDIOMS]
+ )
+
# 其他四字词语表,仅表示可以有这个词
- cls.ALL_WORDS = (
- [word for word in cls.ALL_WORDS if len(word) == 4]
+ ALL_WORDS = (
+ [word for word in ALL_WORDS if len(word) == 4]
+ THUOCL_WORDS
+ COMMON_WORDS
- + LOCAL_LLM_WORDS
)
- cls.ALL_WORDS = list(set(cls.ALL_WORDS)) # 去重
+ # 插入数据库
+ DatabaseManager.execute_many_values_by_sql_file(
+ ROOT_PATH / "sql" / "insert_word.sql",
+ [(word,) for word in ALL_WORDS]
+ )
+
+ # 自定义词语 LOCAL_LLM_WORDS 插入数据库,兼容用
+ DatabaseManager.execute_many_values_by_sql_file(
+ ROOT_PATH / "sql" / "insert_custom_word.sql",
+ [(word,) for word in LOCAL_LLM_WORDS]
+ )
- # 根据成语大表,划分出成语首字字典
- for idiom in cls.ALL_IDIOMS + cls.ALL_WORDS:
- if idiom[0] not in cls.IDIOM_FIRST_CHAR:
- cls.IDIOM_FIRST_CHAR[idiom[0]] = []
- cls.IDIOM_FIRST_CHAR[idiom[0]].append(idiom)
+ # # 根据成语大表,划分出成语首字字典
+ # for idiom in cls.ALL_IDIOMS + cls.ALL_WORDS:
+ # if idiom[0] not in cls.IDIOM_FIRST_CHAR:
+ # cls.IDIOM_FIRST_CHAR[idiom[0]] = []
+ # cls.IDIOM_FIRST_CHAR[idiom[0]].append(idiom)
- # 根据真正的成语大表,划分出有效成语首字字典
- for idiom in cls.ALL_IDIOMS:
- if idiom[0] not in cls.AVALIABLE_IDIOM_FIRST_CHAR:
- cls.AVALIABLE_IDIOM_FIRST_CHAR[idiom[0]] = []
- cls.AVALIABLE_IDIOM_FIRST_CHAR[idiom[0]].append(idiom)
+ # # 根据真正的成语大表,划分出有效成语首字字典
+ # for idiom in cls.ALL_IDIOMS:
+ # if idiom[0] not in cls.AVALIABLE_IDIOM_FIRST_CHAR:
+ # cls.AVALIABLE_IDIOM_FIRST_CHAR[idiom[0]] = []
+ # cls.AVALIABLE_IDIOM_FIRST_CHAR[idiom[0]].append(idiom)
evt = on_alconna(
@@ -514,7 +554,9 @@ async def end_game(event: BaseEvent, group_id: str):
for line in history_lines:
result_text += line + "\n"
await evt.send(await result_text.export())
- instance.clear_score_board()
+ # instance.clear_score_board()
+ # 将实例删除
+ del IdiomGame.INSTANCE_LIST[group_id]
evt = on_alconna(
diff --git a/konabot/plugins/idiomgame/sql/create_table.sql b/konabot/plugins/idiomgame/sql/create_table.sql
new file mode 100644
index 0000000..5d38580
--- /dev/null
+++ b/konabot/plugins/idiomgame/sql/create_table.sql
@@ -0,0 +1,15 @@
+-- 创建成语大表
+CREATE TABLE IF NOT EXISTS all_idioms (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ idiom VARCHAR(128) NOT NULL UNIQUE
+);
+
+CREATE TABLE IF NOT EXISTS all_words (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ word VARCHAR(128) NOT NULL UNIQUE
+);
+
+CREATE TABLE IF NOT EXISTS custom_words (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ word VARCHAR(128) NOT NULL UNIQUE
+);
\ No newline at end of file
diff --git a/konabot/plugins/idiomgame/sql/insert_custom_word.sql b/konabot/plugins/idiomgame/sql/insert_custom_word.sql
new file mode 100644
index 0000000..212c8a2
--- /dev/null
+++ b/konabot/plugins/idiomgame/sql/insert_custom_word.sql
@@ -0,0 +1,3 @@
+-- 插入自定义词
+INSERT OR IGNORE INTO custom_words (word)
+VALUES (?);
\ No newline at end of file
diff --git a/konabot/plugins/idiomgame/sql/insert_idiom.sql b/konabot/plugins/idiomgame/sql/insert_idiom.sql
new file mode 100644
index 0000000..eaedae8
--- /dev/null
+++ b/konabot/plugins/idiomgame/sql/insert_idiom.sql
@@ -0,0 +1,3 @@
+-- 插入成语大表,避免重复插入
+INSERT OR IGNORE INTO all_idioms (idiom)
+VALUES (?);
\ No newline at end of file
diff --git a/konabot/plugins/idiomgame/sql/insert_word.sql b/konabot/plugins/idiomgame/sql/insert_word.sql
new file mode 100644
index 0000000..b085aab
--- /dev/null
+++ b/konabot/plugins/idiomgame/sql/insert_word.sql
@@ -0,0 +1,3 @@
+-- 插入词
+INSERT OR IGNORE INTO all_words (word)
+VALUES (?);
\ No newline at end of file
diff --git a/konabot/plugins/idiomgame/sql/is_nextable.sql b/konabot/plugins/idiomgame/sql/is_nextable.sql
new file mode 100644
index 0000000..a7bbeb1
--- /dev/null
+++ b/konabot/plugins/idiomgame/sql/is_nextable.sql
@@ -0,0 +1,5 @@
+-- 查询是否有以 xx 开头的成语,有则返回真,否则假
+SELECT EXISTS(
+ SELECT 1 FROM all_idioms
+ WHERE idiom LIKE ? || '%'
+) AS DEED;
diff --git a/konabot/plugins/idiomgame/sql/query_idiom.sql b/konabot/plugins/idiomgame/sql/query_idiom.sql
new file mode 100644
index 0000000..fa3bf93
--- /dev/null
+++ b/konabot/plugins/idiomgame/sql/query_idiom.sql
@@ -0,0 +1,7 @@
+-- 查询成语是否在 all_idioms 中,如果存在则返回 1否则再判断是否在 custom_words 或 all_words 中,存在则返回 0否则返回 -1
+SELECT
+ CASE
+ WHEN EXISTS (SELECT 1 FROM all_idioms WHERE idiom = ?) THEN 1
+ WHEN EXISTS (SELECT 1 FROM custom_words WHERE word = ?) OR EXISTS (SELECT 1 FROM all_words WHERE word = ?) THEN 0
+ ELSE -1
+ END AS status;
\ No newline at end of file
diff --git a/konabot/plugins/idiomgame/sql/query_idiom_start_with.sql b/konabot/plugins/idiomgame/sql/query_idiom_start_with.sql
new file mode 100644
index 0000000..a6e8fc6
--- /dev/null
+++ b/konabot/plugins/idiomgame/sql/query_idiom_start_with.sql
@@ -0,0 +1,4 @@
+-- 查询以 xx 开头的成语,随机打乱后只取第一个
+SELECT idiom FROM all_idioms
+WHERE idiom LIKE ? || '%'
+ORDER BY RANDOM() LIMIT 1;
\ No newline at end of file
diff --git a/konabot/plugins/idiomgame/sql/random_choose_idiom.sql b/konabot/plugins/idiomgame/sql/random_choose_idiom.sql
new file mode 100644
index 0000000..f706092
--- /dev/null
+++ b/konabot/plugins/idiomgame/sql/random_choose_idiom.sql
@@ -0,0 +1,2 @@
+-- 随机从 all_idioms 表中选择一个成语
+SELECT idiom FROM all_idioms ORDER BY RANDOM() LIMIT 1;
\ No newline at end of file

223
docs/database.md Normal file
View File

@ -0,0 +1,223 @@
# 数据库系统使用文档
本文档详细介绍了本项目中使用的异步数据库系统,包括其架构设计、使用方法和最佳实践。
## 系统概述
本项目的数据库系统基于 `aiosqlite` 库构建,提供了异步的 SQLite 数据库访问接口。系统主要特性包括:
1. **异步操作**:完全支持异步/await模式适配NoneBot2框架
2. **连接池**:内置连接池机制,提高数据库访问性能
3. **参数化查询**支持安全的参数化查询防止SQL注入
4. **SQL文件支持**可以直接执行SQL文件中的脚本
5. **类型支持**:支持 `pathlib.Path``str` 类型的路径参数
## 核心类和方法
### DatabaseManager 类
`DatabaseManager` 是数据库操作的核心类,提供了以下主要方法:
#### 初始化
```python
from konabot.common.database import DatabaseManager
from pathlib import Path
# 使用默认数据库路径
db = DatabaseManager()
# 指定了义数据库路径
db = DatabaseManager("./data/myapp.db")
db = DatabaseManager(Path("./data/myapp.db"))
```
#### 查询操作
```python
# 执行查询语句并返回结果
results = await db.query("SELECT * FROM users WHERE age > ?", (18,))
# 从SQL文件执行查询
results = await db.query_by_sql_file("./sql/get_users.sql", (18,))
```
#### 执行操作
```python
# 执行非查询语句
await db.execute("INSERT INTO users (name, email) VALUES (?, ?)", ("张三", "zhangsan@example.com"))
# 执行SQL脚本不带参数
await db.execute_script("""
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE
);
INSERT INTO users (name, email) VALUES ('测试用户', 'test@example.com');
""")
# 从SQL文件执行非查询语句
await db.execute_by_sql_file("./sql/create_tables.sql")
# 带参数执行SQL文件
await db.execute_by_sql_file("./sql/insert_user.sql", ("张三", "zhangsan@example.com"))
# 执行多条语句(每条语句使用相同参数)
await db.execute_many("INSERT INTO users (name, email) VALUES (?, ?)", [
("张三", "zhangsan@example.com"),
("李四", "lisi@example.com"),
("王五", "wangwu@example.com")
])
# 从SQL文件执行多条语句每条语句使用相同参数
await db.execute_many_values_by_sql_file("./sql/batch_insert.sql", [
("张三", "zhangsan@example.com"),
("李四", "lisi@example.com")
])
```
## SQL文件处理机制
### 单语句SQL文件
```sql
-- insert_user.sql
INSERT INTO users (name, email) VALUES (?, ?);
```
```python
# 使用方式
await db.execute_by_sql_file("./sql/insert_user.sql", ("张三", "zhangsan@example.com"))
```
### 多语句SQL文件
```sql
-- setup.sql
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE
);
CREATE TABLE IF NOT EXISTS profiles (
user_id INTEGER,
age INTEGER,
FOREIGN KEY (user_id) REFERENCES users(id)
);
```
```python
# 使用方式
await db.execute_by_sql_file("./sql/setup.sql")
```
### 多语句带不同参数的SQL文件
```sql
-- batch_operations.sql
INSERT INTO users (name, email) VALUES (?, ?);
INSERT INTO profiles (user_id, age) VALUES (?, ?);
```
```python
# 使用方式
await db.execute_by_sql_file("./sql/batch_operations.sql", [
("张三", "zhangsan@example.com"), # 第一条语句的参数
(1, 25) # 第二条语句的参数
])
```
## 最佳实践
### 1. 数据库表设计
```sql
-- 推荐的表设计实践
CREATE TABLE IF NOT EXISTS example_table (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
### 2. SQL文件组织
建议按照功能模块组织SQL文件
```
plugin/
├── sql/
│ ├── create_tables.sql
│ ├── insert_data.sql
│ ├── update_data.sql
│ └── query_data.sql
└── __init__.py
```
### 3. 错误处理
```python
try:
results = await db.query("SELECT * FROM users WHERE id = ?", (user_id,))
except Exception as e:
logger.error(f"数据库查询失败: {e}")
# 处理错误情况
```
### 4. 连接管理
```python
# 在应用启动时初始化
db_manager = DatabaseManager()
# 在应用关闭时清理连接
async def shutdown():
await db_manager.close_all_connections()
```
## 高级特性
### 连接池配置
```python
class DatabaseManager:
def __init__(self, db_path: Optional[Union[str, Path]] = None):
# 连接池大小配置
self._pool_size = 5 # 可根据需要调整
```
### 事务支持
```python
# 通过execute方法的自动提交机制支持事务
await db.execute("BEGIN TRANSACTION")
try:
await db.execute("INSERT INTO users (name) VALUES (?)", ("张三",))
await db.execute("INSERT INTO profiles (user_id, age) VALUES (?, ?)", (1, 25))
await db.execute("COMMIT")
except Exception:
await db.execute("ROLLBACK")
raise
```
## 注意事项
1. **异步环境**:所有数据库操作都必须在异步环境中执行
2. **参数安全**始终使用参数化查询避免SQL注入
3. **资源管理**:确保在应用关闭时调用 `close_all_connections()`
4. **SQL解析**:使用 `sqlparse` 库准确解析SQL语句正确处理包含分号的字符串和注释
5. **错误处理**:适当处理数据库操作可能抛出的异常
## 常见问题
### Q: 如何处理数据库约束错误?
A: 确保SQL语句中的字段名正确引用特别是保留字需要使用双引号包围
```sql
CREATE TABLE air_conditioner (
id VARCHAR(128) PRIMARY KEY,
"on" BOOLEAN NOT NULL, -- 使用双引号包围保留字
temperature REAL NOT NULL
);
```
### Q: 如何处理多个语句和参数的匹配?
A: 当SQL文件包含多个语句时参数应该是参数列表每个语句对应一个参数元组
```python
await db.execute_by_sql_file("./sql/batch.sql", [
("参数1", "参数2"), # 第一个语句的参数
("参数3", "参数4") # 第二个语句的参数
])
```
通过遵循这些指南和最佳实践,您可以充分利用本项目的异步数据库系统,构建高性能、安全的数据库应用。

View File

@ -1,20 +1,43 @@
import os import os
import asyncio import asyncio
import sqlparse
from pathlib import Path from pathlib import Path
from typing import List, Dict, Any, Optional, Union from typing import List, Dict, Any, Optional, Union, TYPE_CHECKING
import aiosqlite import aiosqlite
if TYPE_CHECKING:
from . import DatabaseManager
# 全局数据库管理器实例
_global_db_manager: Optional['DatabaseManager'] = None
def get_global_db_manager() -> 'DatabaseManager':
"""获取全局数据库管理器实例"""
global _global_db_manager
if _global_db_manager is None:
from . import DatabaseManager
_global_db_manager = DatabaseManager()
return _global_db_manager
def close_global_db_manager() -> None:
"""关闭全局数据库管理器实例"""
global _global_db_manager
if _global_db_manager is not None:
# 注意这个函数应该在async环境中调用close_all_connections
_global_db_manager = None
class DatabaseManager: class DatabaseManager:
"""异步数据库管理器""" """异步数据库管理器"""
def __init__(self, db_path: Optional[Union[str, Path]] = None): def __init__(self, db_path: Optional[Union[str, Path]] = None, pool_size: int = 5):
""" """
初始化数据库管理器 初始化数据库管理器
Args: Args:
db_path: 数据库文件路径支持str和Path类型 db_path: 数据库文件路径支持str和Path类型
pool_size: 连接池大小
""" """
if db_path is None: if db_path is None:
self.db_path = os.environ.get("DATABASE_PATH", "./data/database.db") self.db_path = os.environ.get("DATABASE_PATH", "./data/database.db")
@ -23,27 +46,46 @@ class DatabaseManager:
# 连接池 # 连接池
self._connection_pool = [] self._connection_pool = []
self._pool_size = 5 self._pool_size = pool_size
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self._in_use = set() # 跟踪正在使用的连接
async def _get_connection(self) -> aiosqlite.Connection: async def _get_connection(self) -> aiosqlite.Connection:
"""从连接池获取连接""" """从连接池获取连接"""
async with self._lock: async with self._lock:
if self._connection_pool: # 尝试从池中获取现有连接
return self._connection_pool.pop() while self._connection_pool:
conn = self._connection_pool.pop()
# 检查连接是否仍然有效
try:
await conn.execute("SELECT 1")
self._in_use.add(conn)
return conn
except:
# 连接已失效,关闭它
try:
await conn.close()
except:
pass
# 如果连接池为空,创建新连接 # 如果连接池为空,创建新连接
conn = await aiosqlite.connect(self.db_path) conn = await aiosqlite.connect(self.db_path)
await conn.execute("PRAGMA foreign_keys = ON") await conn.execute("PRAGMA foreign_keys = ON")
return conn self._in_use.add(conn)
return conn
async def _return_connection(self, conn: aiosqlite.Connection) -> None: async def _return_connection(self, conn: aiosqlite.Connection) -> None:
"""将连接返回到连接池""" """将连接返回到连接池"""
async with self._lock: async with self._lock:
self._in_use.discard(conn)
if len(self._connection_pool) < self._pool_size: if len(self._connection_pool) < self._pool_size:
self._connection_pool.append(conn) self._connection_pool.append(conn)
else: else:
await conn.close() # 池已满,直接关闭连接
try:
await conn.close()
except:
pass
async def query( async def query(
self, query: str, params: Optional[tuple] = None self, query: str, params: Optional[tuple] = None
@ -57,6 +99,9 @@ class DatabaseManager:
results = [dict(zip(columns, row)) for row in rows] results = [dict(zip(columns, row)) for row in rows]
await cursor.close() await cursor.close()
return results return results
except Exception as e:
# 记录错误但重新抛出,让调用者处理
raise Exception(f"数据库查询失败: {str(e)}") from e
finally: finally:
await self._return_connection(conn) await self._return_connection(conn)
@ -75,6 +120,9 @@ class DatabaseManager:
try: try:
await conn.execute(command, params or ()) await conn.execute(command, params or ())
await conn.commit() await conn.commit()
except Exception as e:
# 记录错误但重新抛出,让调用者处理
raise Exception(f"数据库执行失败: {str(e)}") from e
finally: finally:
await self._return_connection(conn) await self._return_connection(conn)
@ -84,19 +132,47 @@ class DatabaseManager:
try: try:
await conn.executescript(script) await conn.executescript(script)
await conn.commit() await conn.commit()
except Exception as e:
# 记录错误但重新抛出,让调用者处理
raise Exception(f"数据库脚本执行失败: {str(e)}") from e
finally: finally:
await self._return_connection(conn) await self._return_connection(conn)
def _parse_sql_statements(self, script: str) -> List[str]:
"""解析SQL脚本分割成独立的语句"""
# 使用sqlparse库更准确地分割SQL语句
parsed = sqlparse.split(script)
statements = []
for statement in parsed:
statement = statement.strip()
if statement:
statements.append(statement)
return statements
async def execute_by_sql_file( async def execute_by_sql_file(
self, file_path: Union[str, Path], params: Optional[tuple] = None self, file_path: Union[str, Path], params: Optional[Union[tuple, List[tuple]]] = None
) -> None: ) -> None:
"""从 SQL 文件中读取非查询语句并执行""" """从 SQL 文件中读取非查询语句并执行"""
path = str(file_path) if isinstance(file_path, Path) else file_path path = str(file_path) if isinstance(file_path, Path) else file_path
with open(path, "r", encoding="utf-8") as f: with open(path, "r", encoding="utf-8") as f:
script = f.read() script = f.read()
# 如果有参数使用execute方法而不是execute_script
if params: # 如果有参数且是元组使用execute执行整个脚本
if params is not None and isinstance(params, tuple):
await self.execute(script, params) await self.execute(script, params)
# 如果有参数且是列表,分别执行每个语句
elif params is not None and isinstance(params, list):
# 使用sqlparse准确分割SQL语句
statements = self._parse_sql_statements(script)
if len(statements) != len(params):
raise ValueError(f"语句数量({len(statements)})与参数组数量({len(params)})不匹配")
for statement, stmt_params in zip(statements, params):
if statement:
await self.execute(statement, stmt_params)
# 如果无参数使用executescript
else: else:
await self.execute_script(script) await self.execute_script(script)
@ -106,6 +182,9 @@ class DatabaseManager:
try: try:
await conn.executemany(command, seq_of_params) await conn.executemany(command, seq_of_params)
await conn.commit() await conn.commit()
except Exception as e:
# 记录错误但重新抛出,让调用者处理
raise Exception(f"数据库批量执行失败: {str(e)}") from e
finally: finally:
await self._return_connection(conn) await self._return_connection(conn)
@ -121,7 +200,19 @@ class DatabaseManager:
async def close_all_connections(self) -> None: async def close_all_connections(self) -> None:
"""关闭所有连接""" """关闭所有连接"""
async with self._lock: async with self._lock:
# 关闭池中的连接
for conn in self._connection_pool: for conn in self._connection_pool:
await conn.close() try:
await conn.close()
except:
pass
self._connection_pool.clear() self._connection_pool.clear()
# 关闭正在使用的连接
for conn in self._in_use.copy():
try:
await conn.close()
except:
pass
self._in_use.clear()

View File

@ -62,6 +62,12 @@ async def register_startup_hook():
Path(__file__).resolve().parent / "sql" / "create_table.sql" Path(__file__).resolve().parent / "sql" / "create_table.sql"
) )
@driver.on_shutdown
async def register_shutdown_hook():
"""注册关闭时需要执行的函数"""
# 关闭所有数据库连接
await db_manager.close_all_connections()
evt = on_alconna(Alconna( evt = on_alconna(Alconna(
"群空调" "群空调"
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True) ), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
@ -135,7 +141,7 @@ evt = on_alconna(Alconna(
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True) ), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
@evt.handle() @evt.handle()
async def _(event: BaseEvent, target: DepLongTaskTarget, temp: Optional[Union[int, float]] = 1): async def _(target: DepLongTaskTarget, temp: Optional[Union[int, float]] = 1):
if temp is None: if temp is None:
temp = 1 temp = 1
if temp <= 0: if temp <= 0:
@ -158,7 +164,7 @@ evt = on_alconna(Alconna(
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True) ), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
@evt.handle() @evt.handle()
async def _(event: BaseEvent, target: DepLongTaskTarget): async def _(target: DepLongTaskTarget):
id = target.channel_id id = target.channel_id
ac = await get_ac(id) ac = await get_ac(id)
await ac.change_ac() await ac.change_ac()
@ -181,7 +187,7 @@ evt = on_alconna(Alconna(
), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True) ), use_cmd_start=True, use_cmd_sep=False, skip_for_unmatch=True)
@evt.handle() @evt.handle()
async def _(event: BaseEvent, target: DepLongTaskTarget): async def _(target: DepLongTaskTarget):
id = target.channel_id id = target.channel_id
# ac = get_ac(id) # ac = get_ac(id)
# number, ranking = ac.get_crashes_and_ranking() # number, ranking = ac.get_crashes_and_ranking()

View File

@ -73,6 +73,12 @@ async def register_startup_hook():
"""注册启动时需要执行的函数""" """注册启动时需要执行的函数"""
await IdiomGame.init_lexicon() await IdiomGame.init_lexicon()
@driver.on_shutdown
async def register_shutdown_hook():
"""注册关闭时需要执行的函数"""
# 关闭所有数据库连接
await db_manager.close_all_connections()
class TryStartState(Enum): class TryStartState(Enum):
STARTED = 0 STARTED = 0

139
poetry.lock generated
View File

@ -970,12 +970,12 @@ version = "0.4.6"
description = "Cross-platform colored terminal text." description = "Cross-platform colored terminal text."
optional = false optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["main"] groups = ["main", "dev"]
markers = "sys_platform == \"win32\" or platform_system == \"Windows\""
files = [ files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
] ]
markers = {main = "sys_platform == \"win32\" or platform_system == \"Windows\"", dev = "sys_platform == \"win32\""}
[package.source] [package.source]
type = "legacy" type = "legacy"
@ -1592,6 +1592,23 @@ type = "legacy"
url = "https://pypi.tuna.tsinghua.edu.cn/simple" url = "https://pypi.tuna.tsinghua.edu.cn/simple"
reference = "mirrors" reference = "mirrors"
[[package]]
name = "iniconfig"
version = "2.3.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
]
[package.source]
type = "legacy"
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
reference = "mirrors"
[[package]] [[package]]
name = "jiter" name = "jiter"
version = "0.11.1" version = "0.11.1"
@ -2703,6 +2720,23 @@ type = "legacy"
url = "https://pypi.tuna.tsinghua.edu.cn/simple" url = "https://pypi.tuna.tsinghua.edu.cn/simple"
reference = "mirrors" reference = "mirrors"
[[package]]
name = "packaging"
version = "25.0"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
]
[package.source]
type = "legacy"
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
reference = "mirrors"
[[package]] [[package]]
name = "pillow" name = "pillow"
version = "11.3.0" version = "11.3.0"
@ -2882,6 +2916,27 @@ type = "legacy"
url = "https://pypi.tuna.tsinghua.edu.cn/simple" url = "https://pypi.tuna.tsinghua.edu.cn/simple"
reference = "mirrors" reference = "mirrors"
[[package]]
name = "pluggy"
version = "1.6.0"
description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
]
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["coverage", "pytest", "pytest-benchmark"]
[package.source]
type = "legacy"
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
reference = "mirrors"
[[package]] [[package]]
name = "propcache" name = "propcache"
version = "0.4.1" version = "0.4.1"
@ -3368,7 +3423,7 @@ version = "2.19.2"
description = "Pygments is a syntax highlighting package written in Python." description = "Pygments is a syntax highlighting package written in Python."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"] groups = ["main", "dev"]
files = [ files = [
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
@ -3399,6 +3454,58 @@ type = "legacy"
url = "https://pypi.tuna.tsinghua.edu.cn/simple" url = "https://pypi.tuna.tsinghua.edu.cn/simple"
reference = "mirrors" reference = "mirrors"
[[package]]
name = "pytest"
version = "9.0.1"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad"},
{file = "pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8"},
]
[package.dependencies]
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
iniconfig = ">=1.0.1"
packaging = ">=22"
pluggy = ">=1.5,<2"
pygments = ">=2.7.2"
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
[package.source]
type = "legacy"
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
reference = "mirrors"
[[package]]
name = "pytest-asyncio"
version = "1.3.0"
description = "Pytest support for asyncio"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5"},
{file = "pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5"},
]
[package.dependencies]
pytest = ">=8.2,<10"
typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""}
[package.extras]
docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"]
testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
[package.source]
type = "legacy"
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
reference = "mirrors"
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.1" version = "1.2.1"
@ -3723,6 +3830,27 @@ type = "legacy"
url = "https://pypi.tuna.tsinghua.edu.cn/simple" url = "https://pypi.tuna.tsinghua.edu.cn/simple"
reference = "mirrors" reference = "mirrors"
[[package]]
name = "sqlparse"
version = "0.5.3"
description = "A non-validating SQL parser."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca"},
{file = "sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272"},
]
[package.extras]
dev = ["build", "hatch"]
doc = ["sphinx"]
[package.source]
type = "legacy"
url = "https://pypi.tuna.tsinghua.edu.cn/simple"
reference = "mirrors"
[[package]] [[package]]
name = "starlette" name = "starlette"
version = "0.49.3" version = "0.49.3"
@ -3926,11 +4054,12 @@ version = "4.15.0"
description = "Backported and Experimental Type Hints for Python 3.9+" description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["main", "dev"]
files = [ files = [
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
] ]
markers = {dev = "python_version == \"3.12\""}
[package.source] [package.source]
type = "legacy" type = "legacy"
@ -4552,4 +4681,4 @@ reference = "mirrors"
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.12,<4.0" python-versions = ">=3.12,<4.0"
content-hash = "5597aa165095a11fa08e4b6e1a1f4d3396711b684ed363ae0ced2dd59a09ec5d" content-hash = "2c341fdc0d5b29ad3b24516c46e036b2eff4c11e244047d114971039255c2ac4"

View File

@ -28,6 +28,7 @@ dependencies = [
"openai (>=2.7.1,<3.0.0)", "openai (>=2.7.1,<3.0.0)",
"imageio (>=2.37.2,<3.0.0)", "imageio (>=2.37.2,<3.0.0)",
"aiosqlite (>=0.20.0,<1.0.0)", "aiosqlite (>=0.20.0,<1.0.0)",
"sqlparse (>=0.5.0,<1.0.0)",
] ]
[tool.poetry] [tool.poetry]
@ -47,5 +48,7 @@ priority = "primary"
[dependency-groups] [dependency-groups]
dev = [ dev = [
"rust-just (>=1.43.0,<2.0.0)" "rust-just (>=1.43.0,<2.0.0)",
"pytest (>=9.0.1,<10.0.0)",
"pytest-asyncio (>=1.3.0,<2.0.0)"
] ]