bugfix:修复notice事件扩散问题 (#2132)

* bugfix:修复notice事件扩散问题

* 优化并发调度

* bugfix:修复签到样式

* bugfix:功能调用统计修复

* bugfix:修复私聊时功能调用统计显示已退群问题

* 提高插件适配兼容性

* 优化发送队列

* 修改权限检查设计

* 继续修改权限检查设计

* 完善权限检查设计

* 优化sqlite配置

* 优化数据库初始化

* 代码整理,无用代码清理

* bugfix:修复启动时数据库校验问题

* bugfix:修复预算裁剪过于激进问题
This commit is contained in:
Copaan
2026-05-28 22:57:28 +08:00
committed by GitHub
parent 12fc5663fb
commit 5596497947
57 changed files with 7557 additions and 1522 deletions
+120 -14
View File
@@ -1,8 +1,10 @@
import asyncio
import hashlib
import json
import os
from pathlib import Path
import re
from typing import Literal
from urllib.parse import urlparse
import aiofiles
@@ -27,8 +29,12 @@ from .config import (
prompt,
)
from .exceptions import DbConnectError, DbUrlIsNode
from .schema_guard import repair_safe_schema_drift
from .schema_ops import SchemaOpRisk, normalize_schema_ops
from .utils import with_db_timeout
Dialect = Literal["sqlite", "postgres", "mysql", "unknown"]
MODELS = db_model.models
SCRIPT_METHOD = db_model.script_method
@@ -47,7 +53,66 @@ __all__ = [
driver = nonebot.get_driver()
_SCRIPT_HASH_FILE = Path() / "data" / ".db_script_hash"
_SCRIPT_HASH_DIR = Path() / "data" / ".db_script_hashes"
_TRUE_VALUES = {"1", "true", "yes", "on"}
def _connection_dialect() -> Dialect:
try:
connection = Tortoise.get_connection("default")
capabilities = getattr(connection, "capabilities", None)
raw = str(getattr(capabilities, "dialect", "") or "").lower()
if raw.startswith("sqlite"):
return "sqlite"
if raw.startswith("postgres"):
return "postgres"
if raw.startswith("mysql"):
return "mysql"
except Exception:
pass
return "unknown"
def _allow_guarded_schema_ops() -> bool:
"""Whether startup may run guarded SchemaOp migrations.
Safe SchemaOps are limited to non-destructive changes such as adding nullable
columns and non-unique indexes. Guarded operations may rename, drop, or alter
columns, so keep them opt-in to avoid damaging existing databases during
normal startup.
"""
return os.getenv("DB_SCHEMA_RUN_GUARDED_OPS", "").strip().lower() in _TRUE_VALUES
def _extract_alter_table_name(sql: str) -> str | None:
match = re.match(r"ALTER\s+TABLE\s+[`\"]?(\w+)[`\"]?", sql, re.IGNORECASE)
return match.group(1) if match else None
def _extract_create_index_table_name(sql: str) -> str | None:
match = re.search(r"\bON\s+[`\"]?(\w+)[`\"]?\s*\(", sql, re.IGNORECASE)
return match.group(1) if match else None
def _db_script_hash_file(script_fingerprint: str) -> Path:
parsed = urlparse(BotConfig.db_url or "")
dialect = parsed.scheme or "unknown"
if dialect == "sqlite":
db_identity = str(Path(parsed.path).resolve())
else:
db_identity = f"{parsed.hostname or ''}:{parsed.port or ''}{parsed.path}"
db_hash = hashlib.md5(
json.dumps(
{
"dialect": dialect,
"db": db_identity,
"script": script_fingerprint,
},
ensure_ascii=False,
sort_keys=True,
).encode()
).hexdigest()
return _SCRIPT_HASH_DIR / f"{db_hash}.json"
def get_config() -> dict:
@@ -131,20 +196,39 @@ async def init():
f"<u><y>{len(db_model.script_method)}</y></u> 个..."
)
sql_list = []
allow_guarded_ops = _allow_guarded_schema_ops()
for module, func in db_model.script_method:
try:
sql = await func() if is_coroutine_callable(func) else func()
if sql:
sql_list += sql
items = await func() if is_coroutine_callable(func) else func()
if not items:
continue
for item in items:
if not isinstance(item, str):
if item.risk == SchemaOpRisk.MANUAL:
logger.debug(f"{module} 跳过手动迁移动作: {item}")
continue
if (
item.risk == SchemaOpRisk.GUARDED
and not allow_guarded_ops
):
logger.debug(f"{module} 跳过受保护迁移动作: {item}")
continue
if item.risk != SchemaOpRisk.SAFE and not allow_guarded_ops:
logger.debug(f"{module} 跳过未知风险迁移动作: {item}")
continue
sql_list += normalize_schema_ops([item], _connection_dialect())
except Exception as e:
logger.debug(f"{module} 执行SCRIPT_METHOD方法出错...", e=e)
if sql_list:
fingerprint = hashlib.md5(
json.dumps(sorted(sql_list), ensure_ascii=False).encode()
).hexdigest()
script_hash_file = _db_script_hash_file(fingerprint)
need_run = not (
_SCRIPT_HASH_FILE.exists()
and _SCRIPT_HASH_FILE.read_text(encoding="utf-8").strip()
script_hash_file.exists()
and json.loads(script_hash_file.read_text(encoding="utf-8")).get(
"script_fingerprint"
)
== fingerprint
)
if need_run:
@@ -186,12 +270,16 @@ async def init():
for sql in sql_list:
# 对于 ALTER TABLE 操作,先检查表是否存在
if sql.strip().upper().startswith("ALTER TABLE"):
match = re.match(
r"ALTER\s+TABLE\s+(\w+)", sql, re.IGNORECASE
)
if match:
table_name = match.group(1)
sql_upper = sql.strip().upper()
if sql_upper.startswith("ALTER TABLE"):
table_name = _extract_alter_table_name(sql)
if table_name:
if not await table_exists(table_name):
logger.debug(f"跳过SQL(表不存在): {sql}")
continue
elif sql_upper.startswith("CREATE INDEX"):
table_name = _extract_create_index_table_name(sql)
if table_name:
if not await table_exists(table_name):
logger.debug(f"跳过SQL(表不存在): {sql}")
continue
@@ -236,13 +324,31 @@ async def init():
except Exception as e:
logger.debug(f"执行SQL: {sql} 错误...", e=e)
logger.debug("SCRIPT_METHOD方法执行完毕!")
_SCRIPT_HASH_FILE.parent.mkdir(parents=True, exist_ok=True)
_SCRIPT_HASH_FILE.write_text(fingerprint, encoding="utf-8")
script_hash_file.parent.mkdir(parents=True, exist_ok=True)
script_hash_file.write_text(
json.dumps(
{
"dialect": urlparse(BotConfig.db_url or "").scheme,
"db_url_hash": hashlib.md5(
(BotConfig.db_url or "").encode()
).hexdigest(),
"script_fingerprint": fingerprint,
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
else:
logger.debug("迁移脚本无变化,跳过执行")
# Tortoise may emit column comments/index SQL during generate_schemas().
# On existing databases with newly added nullable fields, PostgreSQL can
# fail before the post-generate SchemaGuard gets a chance to repair drift.
await repair_safe_schema_drift()
logger.debug("开始生成数据库表结构...")
await Tortoise.generate_schemas()
logger.debug("数据库表结构生成完毕!")
await repair_safe_schema_drift()
logger.info("Database loaded successfully!")
except Exception as e:
raise DbConnectError(f"数据库连接错误... e:{e}") from e