mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2026-10-12 23:40:03 +08:00
bugfix:修复notice事件扩散问题 (#2132)
* bugfix:修复notice事件扩散问题 * 优化并发调度 * bugfix:修复签到样式 * bugfix:功能调用统计修复 * bugfix:修复私聊时功能调用统计显示已退群问题 * 提高插件适配兼容性 * 优化发送队列 * 修改权限检查设计 * 继续修改权限检查设计 * 完善权限检查设计 * 优化sqlite配置 * 优化数据库初始化 * 代码整理,无用代码清理 * bugfix:修复启动时数据库校验问题 * bugfix:修复预算裁剪过于激进问题
This commit is contained in:
+6
-2
@@ -1,4 +1,4 @@
|
|||||||
SUPERUSERS=[""]
|
SUPERUSERS=[""]
|
||||||
|
|
||||||
COMMAND_START=[""]
|
COMMAND_START=[""]
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ QBOT_ID_DATA = '{
|
|||||||
DB_URL = ""
|
DB_URL = ""
|
||||||
|
|
||||||
# NONE: 不使用缓存, MEMORY: 使用内存缓存, REDIS: 使用Redis缓存
|
# NONE: 不使用缓存, MEMORY: 使用内存缓存, REDIS: 使用Redis缓存
|
||||||
CACHE_MODE = NONE
|
CACHE_MODE = MEMORY
|
||||||
|
|
||||||
# REDIS配置,使用REDIS替换Cache内存缓存
|
# REDIS配置,使用REDIS替换Cache内存缓存
|
||||||
# REDIS地址
|
# REDIS地址
|
||||||
@@ -64,6 +64,9 @@ PORT = 8080
|
|||||||
# 第三方插件路径,如果多个目录用, 隔开
|
# 第三方插件路径,如果多个目录用, 隔开
|
||||||
# EXT_PATH=[""]
|
# EXT_PATH=[""]
|
||||||
|
|
||||||
|
# qq adapter load = True
|
||||||
|
QQ_ADAPTER_LOAD=False
|
||||||
|
|
||||||
# kook adapter toekn
|
# kook adapter toekn
|
||||||
# kaiheila_bots =[{"token": ""}]
|
# kaiheila_bots =[{"token": ""}]
|
||||||
|
|
||||||
@@ -94,3 +97,4 @@ PORT = 8080
|
|||||||
|
|
||||||
# application_commands的{"*": ["*"]}代表将全部应用命令注册为全局应用命令
|
# application_commands的{"*": ["*"]}代表将全部应用命令注册为全局应用命令
|
||||||
# {"admin": ["123", "456"]}则代表将admin命令注册为id是123、456服务器的局部命令,其余命令不注册
|
# {"admin": ["123", "456"]}则代表将admin命令注册为id是123、456服务器的局部命令,其余命令不注册
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _load_json(path: Path) -> dict[str, Any]:
|
||||||
|
return json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _num(value: Any) -> float:
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_div(left: float, right: float) -> float:
|
||||||
|
return round(left / right, 4) if right else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _extract(path: Path) -> dict[str, Any]:
|
||||||
|
payload = _load_json(path)
|
||||||
|
summary = payload.get("summary") or {}
|
||||||
|
trace = summary.get("db_trace") or {}
|
||||||
|
events = _num(summary.get("events_sent_total"))
|
||||||
|
commands = _num(summary.get("commands_sent_total"))
|
||||||
|
return {
|
||||||
|
"path": str(path),
|
||||||
|
"status": payload.get("status"),
|
||||||
|
"elapsed_seconds": payload.get("elapsed_seconds"),
|
||||||
|
"events": int(events),
|
||||||
|
"commands": int(commands),
|
||||||
|
"throughput_eps": summary.get("throughput_events_per_sec"),
|
||||||
|
"command_success_rate": summary.get("command_success_rate"),
|
||||||
|
"latency_avg_ms": summary.get("latency_avg_ms"),
|
||||||
|
"latency_p50_ms": summary.get("latency_p50_ms"),
|
||||||
|
"latency_p95_ms": summary.get("latency_p95_ms"),
|
||||||
|
"latency_p99_ms": summary.get("latency_p99_ms"),
|
||||||
|
"db_timeouts": summary.get("db_timeouts"),
|
||||||
|
"db_slow_queries": summary.get("db_slow_queries"),
|
||||||
|
"chat_history_failures": summary.get("chat_history_failures"),
|
||||||
|
"statistics_flush_failures": summary.get("statistics_flush_failures"),
|
||||||
|
"db_calls": int(_num(trace.get("calls"))),
|
||||||
|
"db_reads": int(_num(trace.get("reads"))),
|
||||||
|
"db_writes": int(_num(trace.get("writes"))),
|
||||||
|
"db_scripts": int(_num(trace.get("scripts"))),
|
||||||
|
"db_calls_per_event": _safe_div(_num(trace.get("calls")), events),
|
||||||
|
"db_reads_per_event": _safe_div(_num(trace.get("reads")), events),
|
||||||
|
"db_writes_per_event": _safe_div(_num(trace.get("writes")), events),
|
||||||
|
"db_calls_per_command": _safe_div(_num(trace.get("calls")), commands),
|
||||||
|
"db_writes_per_command": _safe_div(_num(trace.get("writes")), commands),
|
||||||
|
"db_avg_elapsed_ms": trace.get("avg_elapsed_ms"),
|
||||||
|
"db_avg_wait_ms": trace.get("avg_wait_ms"),
|
||||||
|
"db_max_elapsed_ms": trace.get("max_elapsed_ms"),
|
||||||
|
"db_max_wait_ms": trace.get("max_wait_ms"),
|
||||||
|
"db_max_active": trace.get("max_active"),
|
||||||
|
"db_max_waiting": trace.get("max_waiting"),
|
||||||
|
"db_connection_creates": trace.get("connection_creates"),
|
||||||
|
"db_top_tables": trace.get("top_tables", [])[:12],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("reports", nargs="+")
|
||||||
|
parser.add_argument("--output")
|
||||||
|
args = parser.parse_args()
|
||||||
|
rows = [_extract(Path(item).resolve()) for item in args.reports]
|
||||||
|
payload = {"reports": rows}
|
||||||
|
text = json.dumps(payload, ensure_ascii=False, indent=2)
|
||||||
|
if args.output:
|
||||||
|
output = Path(args.output).resolve()
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output.write_text(text, encoding="utf-8")
|
||||||
|
sys.stdout.write(text + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -9,6 +9,10 @@ from zhenxun.configs.config import Config
|
|||||||
from zhenxun.models.group_console import GroupConsole
|
from zhenxun.models.group_console import GroupConsole
|
||||||
from zhenxun.models.group_member_info import GroupInfoUser
|
from zhenxun.models.group_member_info import GroupInfoUser
|
||||||
from zhenxun.models.level_user import LevelUser
|
from zhenxun.models.level_user import LevelUser
|
||||||
|
from zhenxun.services.hot_query_cache import (
|
||||||
|
invalidate_group_members,
|
||||||
|
invalidate_member_names,
|
||||||
|
)
|
||||||
from zhenxun.services.log import logger
|
from zhenxun.services.log import logger
|
||||||
from zhenxun.utils.platform import PlatformUtils
|
from zhenxun.utils.platform import PlatformUtils
|
||||||
|
|
||||||
@@ -181,4 +185,12 @@ class MemberUpdateManage:
|
|||||||
group_id=group_id,
|
group_id=group_id,
|
||||||
platform="qq",
|
platform="qq",
|
||||||
)
|
)
|
||||||
|
changed_user_ids = (
|
||||||
|
{user.user_id for user in data_list[0]}
|
||||||
|
| {user.user_id for user in data_list[1]}
|
||||||
|
| delete_member_ids
|
||||||
|
)
|
||||||
|
if data_list[0] or data_list[1] or data_list[2] or delete_member_ids:
|
||||||
|
await invalidate_group_members(group_id, changed_user_ids)
|
||||||
|
await invalidate_member_names(changed_user_ids)
|
||||||
return "群组成员信息更新完成!"
|
return "群组成员信息更新完成!"
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ from zhenxun import ui
|
|||||||
from zhenxun.configs.config import Config
|
from zhenxun.configs.config import Config
|
||||||
from zhenxun.configs.utils import Command, PluginExtraData, RegisterConfig
|
from zhenxun.configs.utils import Command, PluginExtraData, RegisterConfig
|
||||||
from zhenxun.models.chat_history import ChatHistory
|
from zhenxun.models.chat_history import ChatHistory
|
||||||
from zhenxun.models.group_member_info import GroupInfoUser
|
from zhenxun.models.friend_user import FriendUser
|
||||||
from zhenxun.services import avatar_service
|
from zhenxun.services import avatar_service
|
||||||
|
from zhenxun.services.hot_query_cache import get_group_member_map, get_member_names
|
||||||
from zhenxun.services.log import logger
|
from zhenxun.services.log import logger
|
||||||
from zhenxun.ui.models import ImageCell, TextCell
|
from zhenxun.ui.models import ImageCell, TextCell
|
||||||
from zhenxun.utils.enum import PluginType
|
from zhenxun.utils.enum import PluginType
|
||||||
@@ -118,7 +119,8 @@ async def _(
|
|||||||
show_quit_member = Config.get_config("chat_history", "SHOW_QUIT_MEMBER", True)
|
show_quit_member = Config.get_config("chat_history", "SHOW_QUIT_MEMBER", True)
|
||||||
|
|
||||||
fetch_count = count.result
|
fetch_count = count.result
|
||||||
if not show_quit_member:
|
has_group_context = bool(group_id)
|
||||||
|
if has_group_context and not show_quit_member:
|
||||||
fetch_count = count.result * 2
|
fetch_count = count.result * 2
|
||||||
|
|
||||||
raw_rank_data = await ChatHistory.get_group_msg_rank(
|
raw_rank_data = await ChatHistory.get_group_msg_rank(
|
||||||
@@ -128,27 +130,37 @@ async def _(
|
|||||||
if raw_rank_data:
|
if raw_rank_data:
|
||||||
rank_data = cast(list[tuple[str, int]], raw_rank_data)
|
rank_data = cast(list[tuple[str, int]], raw_rank_data)
|
||||||
rows_data = []
|
rows_data = []
|
||||||
platform = "qq"
|
platform = getattr(session, "platform", None) or "qq"
|
||||||
|
|
||||||
user_ids_in_rank = [str(uid) for uid, _ in rank_data]
|
user_ids_in_rank = [str(uid) for uid, _ in rank_data]
|
||||||
users_in_group_query = GroupInfoUser.filter(
|
users_in_group = {}
|
||||||
user_id__in=user_ids_in_rank, group_id=group_id
|
user_names: dict[str, str] = {}
|
||||||
)
|
if has_group_context:
|
||||||
users_in_group = {u.user_id: u for u in await users_in_group_query}
|
users_in_group = await get_group_member_map(group_id, user_ids_in_rank)
|
||||||
|
else:
|
||||||
|
friend_users = await FriendUser.filter(
|
||||||
|
user_id__in=user_ids_in_rank
|
||||||
|
).values_list("user_id", "user_name")
|
||||||
|
user_names.update(dict(friend_users))
|
||||||
|
group_user_names = await get_member_names(user_ids_in_rank)
|
||||||
|
for user_id, user_name in group_user_names.items():
|
||||||
|
if user_name and user_id not in user_names:
|
||||||
|
user_names[user_id] = user_name
|
||||||
|
|
||||||
for idx, (uid, num) in enumerate(rank_data):
|
for idx, (uid, num) in enumerate(rank_data):
|
||||||
if len(rows_data) >= count.result:
|
if len(rows_data) >= count.result:
|
||||||
break
|
break
|
||||||
|
|
||||||
uid_str = str(uid)
|
uid_str = str(uid)
|
||||||
user_in_group = users_in_group.get(uid_str)
|
if has_group_context:
|
||||||
|
user_in_group = users_in_group.get(uid_str)
|
||||||
if not user_in_group and not show_quit_member:
|
if not user_in_group and not show_quit_member:
|
||||||
continue
|
continue
|
||||||
|
user_name = (
|
||||||
user_name = (
|
user_in_group.user_name if user_in_group else f"{uid_str}(已退群)"
|
||||||
user_in_group.user_name if user_in_group else f"{uid_str}(已退群)"
|
)
|
||||||
)
|
else:
|
||||||
|
user_name = user_names.get(uid_str) or uid_str
|
||||||
|
|
||||||
avatar_path = await avatar_service.get_avatar_path(platform, uid_str)
|
avatar_path = await avatar_service.get_avatar_path(platform, uid_str)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
import time
|
import time
|
||||||
from typing import Any, ClassVar
|
from typing import Any, ClassVar
|
||||||
|
|
||||||
@@ -46,6 +48,26 @@ class Limit(BaseModel):
|
|||||||
arbitrary_types_allowed = True
|
arbitrary_types_allowed = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class LimitReservation:
|
||||||
|
module: str
|
||||||
|
releases: list[Callable[[], None]] = field(default_factory=list)
|
||||||
|
should_auto_unblock: bool = False
|
||||||
|
active: bool = True
|
||||||
|
|
||||||
|
def commit(self) -> None:
|
||||||
|
self.active = False
|
||||||
|
self.releases.clear()
|
||||||
|
|
||||||
|
def release(self) -> None:
|
||||||
|
if not self.active:
|
||||||
|
return
|
||||||
|
for release in reversed(self.releases):
|
||||||
|
release()
|
||||||
|
self.active = False
|
||||||
|
self.releases.clear()
|
||||||
|
|
||||||
|
|
||||||
def _limit_notice_key(
|
def _limit_notice_key(
|
||||||
limit: PluginLimit | PluginLimitSnapshot,
|
limit: PluginLimit | PluginLimitSnapshot,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
@@ -247,14 +269,9 @@ class LimitManager:
|
|||||||
for limit in limits:
|
for limit in limits:
|
||||||
cls.add_limit(limit)
|
cls.add_limit(limit)
|
||||||
|
|
||||||
# 检查各种限制
|
|
||||||
try:
|
try:
|
||||||
if limit_model := cls.cd_limit.get(module):
|
reservation = await cls.reserve(module, user_id, group_id, channel_id)
|
||||||
await cls.__check(limit_model, user_id, group_id, channel_id)
|
reservation.commit()
|
||||||
if limit_model := cls.block_limit.get(module):
|
|
||||||
await cls.__check(limit_model, user_id, group_id, channel_id)
|
|
||||||
if limit_model := cls.count_limit.get(module):
|
|
||||||
await cls.__check(limit_model, user_id, group_id, channel_id)
|
|
||||||
finally:
|
finally:
|
||||||
# 记录总执行时间
|
# 记录总执行时间
|
||||||
elapsed = time.time() - start_time
|
elapsed = time.time() - start_time
|
||||||
@@ -267,13 +284,53 @@ class LimitManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def __check(
|
async def reserve(
|
||||||
|
cls,
|
||||||
|
module: str,
|
||||||
|
user_id: str,
|
||||||
|
group_id: str | None,
|
||||||
|
channel_id: str | None,
|
||||||
|
) -> LimitReservation:
|
||||||
|
"""检查并预留限制状态;调用方失败时可 release 回滚内存限制。"""
|
||||||
|
if (
|
||||||
|
time.time() - cls.last_update_time > cls.update_interval
|
||||||
|
and not cls.is_updating
|
||||||
|
):
|
||||||
|
asyncio.create_task(cls.update_limits()) # noqa: RUF006
|
||||||
|
|
||||||
|
if module not in cls.add_module:
|
||||||
|
limits = await cls.get_module_limits(module)
|
||||||
|
for limit in limits:
|
||||||
|
cls.add_limit(limit)
|
||||||
|
|
||||||
|
reservation = LimitReservation(module=module)
|
||||||
|
try:
|
||||||
|
if limit_model := cls.cd_limit.get(module):
|
||||||
|
reservation.releases.append(
|
||||||
|
await cls.__reserve(limit_model, user_id, group_id, channel_id)
|
||||||
|
)
|
||||||
|
if limit_model := cls.block_limit.get(module):
|
||||||
|
reservation.should_auto_unblock = True
|
||||||
|
reservation.releases.append(
|
||||||
|
await cls.__reserve(limit_model, user_id, group_id, channel_id)
|
||||||
|
)
|
||||||
|
if limit_model := cls.count_limit.get(module):
|
||||||
|
reservation.releases.append(
|
||||||
|
await cls.__reserve(limit_model, user_id, group_id, channel_id)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
reservation.release()
|
||||||
|
raise
|
||||||
|
return reservation
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def __reserve(
|
||||||
cls,
|
cls,
|
||||||
limit_model: Limit | None,
|
limit_model: Limit | None,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
group_id: str | None,
|
group_id: str | None,
|
||||||
channel_id: str | None,
|
channel_id: str | None,
|
||||||
):
|
) -> Callable[[], None]:
|
||||||
"""检测限制
|
"""检测限制
|
||||||
|
|
||||||
参数:
|
参数:
|
||||||
@@ -286,7 +343,7 @@ class LimitManager:
|
|||||||
IgnoredException: IgnoredException
|
IgnoredException: IgnoredException
|
||||||
"""
|
"""
|
||||||
if not limit_model:
|
if not limit_model:
|
||||||
return
|
return lambda: None
|
||||||
limit = limit_model.limit
|
limit = limit_model.limit
|
||||||
limiter = limit_model.limiter
|
limiter = limit_model.limiter
|
||||||
is_limit = (
|
is_limit = (
|
||||||
@@ -317,12 +374,40 @@ class LimitManager:
|
|||||||
group_id=group_id,
|
group_id=group_id,
|
||||||
)
|
)
|
||||||
if isinstance(limiter, FreqLimiter):
|
if isinstance(limiter, FreqLimiter):
|
||||||
|
had_next_time = key_type in limiter.next_time
|
||||||
|
old_next_time = limiter.next_time.get(key_type, 0.0)
|
||||||
limiter.start_cd(key_type)
|
limiter.start_cd(key_type)
|
||||||
|
|
||||||
|
def release_freq() -> None:
|
||||||
|
if had_next_time:
|
||||||
|
limiter.next_time[key_type] = old_next_time
|
||||||
|
else:
|
||||||
|
limiter.next_time.pop(key_type, None)
|
||||||
|
|
||||||
|
return release_freq
|
||||||
if isinstance(limiter, UserBlockLimiter):
|
if isinstance(limiter, UserBlockLimiter):
|
||||||
|
old_flag = limiter.flag_data.get(key_type, False)
|
||||||
|
old_time = limiter.time.get(key_type, 0.0)
|
||||||
limiter.set_true(key_type)
|
limiter.set_true(key_type)
|
||||||
|
|
||||||
|
def release_block() -> None:
|
||||||
|
limiter.flag_data[key_type] = old_flag
|
||||||
|
if old_time:
|
||||||
|
limiter.time[key_type] = old_time
|
||||||
|
else:
|
||||||
|
limiter.time.pop(key_type, None)
|
||||||
|
|
||||||
|
return release_block
|
||||||
if isinstance(limiter, CountLimiter):
|
if isinstance(limiter, CountLimiter):
|
||||||
|
old_count = limiter.count.get(key_type, 0)
|
||||||
limiter.increase(key_type)
|
limiter.increase(key_type)
|
||||||
|
|
||||||
|
def release_count() -> None:
|
||||||
|
limiter.count[key_type] = old_count
|
||||||
|
|
||||||
|
return release_count
|
||||||
|
return lambda: None
|
||||||
|
|
||||||
|
|
||||||
async def auth_limit(
|
async def auth_limit(
|
||||||
plugin: PluginInfo,
|
plugin: PluginInfo,
|
||||||
@@ -343,11 +428,42 @@ async def auth_limit(
|
|||||||
entity = get_entity_ids(session)
|
entity = get_entity_ids(session)
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(
|
await asyncio.wait_for(
|
||||||
LimitManager.check(
|
_reserve_and_commit_limit(plugin.module, entity),
|
||||||
plugin.module, entity.user_id, entity.group_id, entity.channel_id
|
|
||||||
),
|
|
||||||
timeout=DB_TIMEOUT_SECONDS * 2, # 给予更长的超时时间
|
timeout=DB_TIMEOUT_SECONDS * 2, # 给予更长的超时时间
|
||||||
)
|
)
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.error(f"检查插件限制超时: {plugin.module}", LOGGER_COMMAND)
|
logger.error(f"检查插件限制超时: {plugin.module}", LOGGER_COMMAND)
|
||||||
# 超时时不抛出异常,允许继续执行
|
# 超时时不抛出异常,允许继续执行
|
||||||
|
|
||||||
|
|
||||||
|
async def reserve_auth_limit(
|
||||||
|
plugin: PluginInfo,
|
||||||
|
session: Uninfo,
|
||||||
|
*,
|
||||||
|
context: PermissionContext | None = None,
|
||||||
|
entity: EntityIDs | None = None,
|
||||||
|
) -> LimitReservation:
|
||||||
|
del session
|
||||||
|
if context is not None:
|
||||||
|
entity = context.entity
|
||||||
|
if entity is None:
|
||||||
|
raise RuntimeError("reserve_auth_limit requires entity or context")
|
||||||
|
return await LimitManager.reserve(
|
||||||
|
plugin.module,
|
||||||
|
entity.user_id,
|
||||||
|
entity.group_id,
|
||||||
|
entity.channel_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _reserve_and_commit_limit(
|
||||||
|
module: str,
|
||||||
|
entity: EntityIDs,
|
||||||
|
) -> None:
|
||||||
|
reservation = await LimitManager.reserve(
|
||||||
|
module,
|
||||||
|
entity.user_id,
|
||||||
|
entity.group_id,
|
||||||
|
entity.channel_id,
|
||||||
|
)
|
||||||
|
reservation.commit()
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from nonebot.adapters import Bot, Event
|
from nonebot.adapters import Bot, Event
|
||||||
from nonebot_plugin_alconna import UniMsg
|
from nonebot_plugin_alconna import UniMsg
|
||||||
@@ -31,6 +31,9 @@ EVENT_CACHE = (
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from zhenxun.builtin_plugins.hooks.auth_side_effect import SideEffectCommit
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class EventContext:
|
class EventContext:
|
||||||
@@ -62,6 +65,7 @@ class EventContext:
|
|||||||
class PermissionSideEffectCache:
|
class PermissionSideEffectCache:
|
||||||
auth_results: dict[str, tuple[bool, str | None]] = field(default_factory=dict)
|
auth_results: dict[str, tuple[bool, str | None]] = field(default_factory=dict)
|
||||||
module_locks: dict[str, asyncio.Lock] = field(default_factory=dict)
|
module_locks: dict[str, asyncio.Lock] = field(default_factory=dict)
|
||||||
|
commits: dict[str, "SideEffectCommit"] = field(default_factory=dict)
|
||||||
|
|
||||||
def lock_for(self, module: str) -> asyncio.Lock:
|
def lock_for(self, module: str) -> asyncio.Lock:
|
||||||
lock = self.module_locks.get(module)
|
lock = self.module_locks.get(module)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,271 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
import contextlib
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import importlib
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from nonebot.adapters import Bot, Event
|
||||||
|
from nonebot.matcher import Matcher
|
||||||
|
import nonebot.message as nb_message
|
||||||
|
|
||||||
|
from zhenxun.services.log import logger
|
||||||
|
from zhenxun.services.message_load import signal_overload
|
||||||
|
|
||||||
|
from .auth.config import LOGGER_COMMAND
|
||||||
|
from .auth_activation import HandlerActivationIndex
|
||||||
|
from .auth_patch_guard import validate_handle_event_patch
|
||||||
|
from .auth_types import EventDispatchContext
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class HandleEventSelectorDependencies:
|
||||||
|
activation_index: HandlerActivationIndex
|
||||||
|
overload_selected_threshold: int
|
||||||
|
prepare_handle_event_state: Callable[[Event, dict], None]
|
||||||
|
build_dispatch_context: Callable[
|
||||||
|
[Event, dict | None],
|
||||||
|
Awaitable[EventDispatchContext],
|
||||||
|
]
|
||||||
|
activation_context_from_dispatch: Callable[[EventDispatchContext, Event], Any]
|
||||||
|
new_dispatch_budget: Callable[[], dict[str, int]]
|
||||||
|
dispatch_lane_for_matcher: Callable[[type[Matcher], EventDispatchContext], str]
|
||||||
|
record_activation_result: Callable[[Any], None]
|
||||||
|
debug_activation_shadow: Callable[..., None]
|
||||||
|
merge_dispatch_budget: Callable[[dict[str, int], dict[str, int]], None]
|
||||||
|
build_matcher_state: Callable[[dict], dict]
|
||||||
|
run_selected_matcher: Callable[..., Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
|
_HANDLE_EVENT_PATCHED = False
|
||||||
|
_ORIGINAL_HANDLE_EVENT: Callable[..., Awaitable[None]] | None = None
|
||||||
|
_ORIGINAL_ADAPTER_HANDLE_EVENTS: dict[object, object] = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def patched_handle_event(
|
||||||
|
bot: Bot,
|
||||||
|
event: Event,
|
||||||
|
deps: HandleEventSelectorDependencies,
|
||||||
|
) -> None:
|
||||||
|
show_log = True
|
||||||
|
escape_tag = getattr(nb_message, "escape_tag")
|
||||||
|
logger_ = getattr(nb_message, "logger")
|
||||||
|
no_log_exception = getattr(nb_message, "NoLogException")
|
||||||
|
|
||||||
|
log_msg = f"<m>{escape_tag(bot.type)} {escape_tag(bot.self_id)}</m> | "
|
||||||
|
try:
|
||||||
|
log_msg += event.get_log_string()
|
||||||
|
except no_log_exception:
|
||||||
|
show_log = False
|
||||||
|
if show_log:
|
||||||
|
logger_.opt(colors=True).success(log_msg)
|
||||||
|
|
||||||
|
state = {}
|
||||||
|
dependency_cache = {}
|
||||||
|
async_exit_stack = getattr(nb_message, "AsyncExitStack")
|
||||||
|
apply_event_preprocessors = getattr(nb_message, "_apply_event_preprocessors")
|
||||||
|
apply_event_postprocessors = getattr(nb_message, "_apply_event_postprocessors")
|
||||||
|
trie_rule = getattr(nb_message, "TrieRule")
|
||||||
|
matchers = getattr(nb_message, "matchers")
|
||||||
|
catch = getattr(nb_message, "catch")
|
||||||
|
stop_propagation = getattr(nb_message, "StopPropagation")
|
||||||
|
handle_exception = getattr(nb_message, "_handle_exception")
|
||||||
|
anyio_mod = getattr(nb_message, "anyio")
|
||||||
|
run_coro_with_shield = getattr(nb_message, "run_coro_with_shield")
|
||||||
|
|
||||||
|
async with async_exit_stack() as stack:
|
||||||
|
if not await apply_event_preprocessors(
|
||||||
|
bot=bot,
|
||||||
|
event=event,
|
||||||
|
state=state,
|
||||||
|
stack=stack,
|
||||||
|
dependency_cache=dependency_cache,
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
trie_rule.get_value(bot, event, state)
|
||||||
|
except Exception as e:
|
||||||
|
logger_.opt(colors=True, exception=e).warning(
|
||||||
|
"Error while parsing command for event"
|
||||||
|
)
|
||||||
|
deps.prepare_handle_event_state(event, state)
|
||||||
|
dispatch_context = await deps.build_dispatch_context(event, state)
|
||||||
|
activation_context = deps.activation_context_from_dispatch(
|
||||||
|
dispatch_context,
|
||||||
|
event,
|
||||||
|
)
|
||||||
|
activation_available = True
|
||||||
|
try:
|
||||||
|
deps.activation_index.ensure_fresh(matchers)
|
||||||
|
except Exception as exc:
|
||||||
|
activation_available = False
|
||||||
|
logger.warning(
|
||||||
|
"HandlerActivationIndex 构建失败,回退到旧 matcher 选择逻辑",
|
||||||
|
LOGGER_COMMAND,
|
||||||
|
e=exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
break_flag = False
|
||||||
|
|
||||||
|
def _handle_stop_propagation(_exc_group) -> None:
|
||||||
|
nonlocal break_flag
|
||||||
|
break_flag = True
|
||||||
|
logger_.debug("Stop event propagation")
|
||||||
|
|
||||||
|
for priority in sorted(matchers.keys()):
|
||||||
|
if break_flag:
|
||||||
|
break
|
||||||
|
|
||||||
|
if show_log:
|
||||||
|
logger_.debug(f"Checking for matchers in priority {priority}...")
|
||||||
|
|
||||||
|
if not (priority_matchers := matchers[priority]):
|
||||||
|
continue
|
||||||
|
|
||||||
|
with catch(
|
||||||
|
{
|
||||||
|
stop_propagation: _handle_stop_propagation,
|
||||||
|
Exception: handle_exception(
|
||||||
|
"<r><bg #f8bbd0>Error when checking Matcher.</bg #f8bbd0></r>"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
):
|
||||||
|
priority_budget = deps.new_dispatch_budget()
|
||||||
|
if activation_available:
|
||||||
|
try:
|
||||||
|
activation_result = deps.activation_index.select_priority(
|
||||||
|
priority,
|
||||||
|
priority_matchers,
|
||||||
|
activation_context,
|
||||||
|
priority_budget,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"HandlerActivationIndex 选择失败,当前 priority 回退",
|
||||||
|
LOGGER_COMMAND,
|
||||||
|
e=exc,
|
||||||
|
)
|
||||||
|
activation_result = None
|
||||||
|
else:
|
||||||
|
activation_result = None
|
||||||
|
|
||||||
|
if activation_result is not None:
|
||||||
|
selected_matchers = activation_result.selected
|
||||||
|
deps.record_activation_result(activation_result)
|
||||||
|
deps.debug_activation_shadow(
|
||||||
|
priority=priority,
|
||||||
|
activation_result=activation_result,
|
||||||
|
context=dispatch_context,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
activation_result.candidate_count
|
||||||
|
> deps.overload_selected_threshold
|
||||||
|
):
|
||||||
|
signal_overload(3.0)
|
||||||
|
else:
|
||||||
|
selected_matchers = priority_matchers
|
||||||
|
|
||||||
|
async with anyio_mod.create_task_group() as tg:
|
||||||
|
for matcher in selected_matchers:
|
||||||
|
lane = deps.dispatch_lane_for_matcher(matcher, dispatch_context)
|
||||||
|
if activation_result is None:
|
||||||
|
descriptor = deps.activation_index.descriptor_for(matcher)
|
||||||
|
if descriptor is not None:
|
||||||
|
single_budget = dict(priority_budget)
|
||||||
|
try:
|
||||||
|
single_result = (
|
||||||
|
deps.activation_index.select_priority(
|
||||||
|
priority,
|
||||||
|
[matcher],
|
||||||
|
activation_context,
|
||||||
|
single_budget,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
single_result = None
|
||||||
|
if single_result is not None:
|
||||||
|
deps.record_activation_result(single_result)
|
||||||
|
deps.debug_activation_shadow(
|
||||||
|
priority=priority,
|
||||||
|
activation_result=single_result,
|
||||||
|
context=dispatch_context,
|
||||||
|
)
|
||||||
|
deps.merge_dispatch_budget(
|
||||||
|
priority_budget,
|
||||||
|
single_budget,
|
||||||
|
)
|
||||||
|
if not single_result.selected:
|
||||||
|
continue
|
||||||
|
matcher_state = deps.build_matcher_state(state)
|
||||||
|
tg.start_soon(
|
||||||
|
run_coro_with_shield,
|
||||||
|
deps.run_selected_matcher(
|
||||||
|
matcher,
|
||||||
|
bot,
|
||||||
|
event,
|
||||||
|
matcher_state,
|
||||||
|
stack,
|
||||||
|
dependency_cache,
|
||||||
|
lane,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if show_log:
|
||||||
|
logger_.debug("Checking for matchers completed")
|
||||||
|
|
||||||
|
await apply_event_postprocessors(bot, event, state, stack, dependency_cache)
|
||||||
|
|
||||||
|
|
||||||
|
def install_handle_event_selector(deps: HandleEventSelectorDependencies) -> None:
|
||||||
|
global _HANDLE_EVENT_PATCHED, _ORIGINAL_HANDLE_EVENT
|
||||||
|
if _HANDLE_EVENT_PATCHED:
|
||||||
|
return
|
||||||
|
guard = validate_handle_event_patch()
|
||||||
|
if not guard.ok:
|
||||||
|
logger.warning(
|
||||||
|
f"权限事件分发选择器 patch 未安装,回退 NoneBot 原生分发: {guard.reason}",
|
||||||
|
LOGGER_COMMAND,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
_ORIGINAL_HANDLE_EVENT = nb_message.handle_event
|
||||||
|
|
||||||
|
async def _patched(bot: Bot, event: Event) -> None:
|
||||||
|
await patched_handle_event(bot, event, deps)
|
||||||
|
|
||||||
|
nb_message.handle_event = _patched # type: ignore[assignment]
|
||||||
|
for module_name in (
|
||||||
|
"nonebot.adapters.onebot.v11.bot",
|
||||||
|
"nonebot.adapters.onebot.v12.bot",
|
||||||
|
"onebug.mixin.process",
|
||||||
|
):
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
module = importlib.import_module(module_name)
|
||||||
|
current = getattr(module, "handle_event", None)
|
||||||
|
if current is not None:
|
||||||
|
_ORIGINAL_ADAPTER_HANDLE_EVENTS[module] = current
|
||||||
|
setattr(module, "handle_event", _patched)
|
||||||
|
_HANDLE_EVENT_PATCHED = True
|
||||||
|
|
||||||
|
|
||||||
|
def uninstall_handle_event_selector() -> None:
|
||||||
|
global _HANDLE_EVENT_PATCHED, _ORIGINAL_HANDLE_EVENT
|
||||||
|
if not _HANDLE_EVENT_PATCHED:
|
||||||
|
return
|
||||||
|
if _ORIGINAL_HANDLE_EVENT is not None:
|
||||||
|
nb_message.handle_event = _ORIGINAL_HANDLE_EVENT # type: ignore[assignment]
|
||||||
|
for module, original in list(_ORIGINAL_ADAPTER_HANDLE_EVENTS.items()):
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
setattr(module, "handle_event", original)
|
||||||
|
_ORIGINAL_ADAPTER_HANDLE_EVENTS.clear()
|
||||||
|
_HANDLE_EVENT_PATCHED = False
|
||||||
|
_ORIGINAL_HANDLE_EVENT = None
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"HandleEventSelectorDependencies",
|
||||||
|
"install_handle_event_selector",
|
||||||
|
"patched_handle_event",
|
||||||
|
"uninstall_handle_event_selector",
|
||||||
|
]
|
||||||
@@ -18,6 +18,7 @@ from .auth.config import LOGGER_COMMAND
|
|||||||
from .auth.context import (
|
from .auth.context import (
|
||||||
get_event_context,
|
get_event_context,
|
||||||
get_or_create_event_context,
|
get_or_create_event_context,
|
||||||
|
get_permission_side_effect_cache,
|
||||||
resolve_actor_user_id,
|
resolve_actor_user_id,
|
||||||
resolve_event_channel_id,
|
resolve_event_channel_id,
|
||||||
resolve_event_group_id,
|
resolve_event_group_id,
|
||||||
@@ -25,12 +26,13 @@ from .auth.context import (
|
|||||||
)
|
)
|
||||||
from .auth_checker import (
|
from .auth_checker import (
|
||||||
LimitManager,
|
LimitManager,
|
||||||
|
_get_auth_route_precheck_deps,
|
||||||
_get_route_context,
|
_get_route_context,
|
||||||
auth,
|
auth,
|
||||||
route_precheck,
|
|
||||||
start_auth_runtime_tasks,
|
start_auth_runtime_tasks,
|
||||||
stop_auth_runtime_tasks,
|
stop_auth_runtime_tasks,
|
||||||
)
|
)
|
||||||
|
from .auth_route import route_precheck
|
||||||
|
|
||||||
_SKIP_AUTH_PLUGINS = {"chat_history", "chat_message"}
|
_SKIP_AUTH_PLUGINS = {"chat_history", "chat_message"}
|
||||||
_BOT_CONNECT_TS: float | None = None
|
_BOT_CONNECT_TS: float | None = None
|
||||||
@@ -111,7 +113,7 @@ async def _auth_preprocessor(
|
|||||||
)
|
)
|
||||||
set_route_modules(state, event_context, route_modules)
|
set_route_modules(state, event_context, route_modules)
|
||||||
|
|
||||||
if await route_precheck(matcher, event_context):
|
if await route_precheck(matcher, event_context, **_get_auth_route_precheck_deps()):
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -146,6 +148,7 @@ async def _unblock_after_matcher(
|
|||||||
session: Uninfo,
|
session: Uninfo,
|
||||||
event: Event,
|
event: Event,
|
||||||
state: T_State,
|
state: T_State,
|
||||||
|
exception: Exception | None = None,
|
||||||
):
|
):
|
||||||
context = get_event_context(state)
|
context = get_event_context(state)
|
||||||
if context is not None:
|
if context is not None:
|
||||||
@@ -164,4 +167,37 @@ async def _unblock_after_matcher(
|
|||||||
group_id = session.group.id
|
group_id = session.group.id
|
||||||
if user_id and matcher.plugin:
|
if user_id and matcher.plugin:
|
||||||
module = matcher.plugin.name
|
module = matcher.plugin.name
|
||||||
LimitManager.unblock(module, user_id, group_id, channel_id)
|
side_effects = get_permission_side_effect_cache(
|
||||||
|
state=state,
|
||||||
|
event_cache=context.event_cache if context is not None else None,
|
||||||
|
)
|
||||||
|
commit = side_effects.commits.get(module)
|
||||||
|
if (
|
||||||
|
commit is not None
|
||||||
|
and not commit.committed
|
||||||
|
and commit.owner_matcher_id == id(matcher)
|
||||||
|
):
|
||||||
|
side_effects.commits.pop(module, None)
|
||||||
|
if exception is None:
|
||||||
|
try:
|
||||||
|
await commit.commit_all()
|
||||||
|
side_effects.auth_results[module] = (True, None)
|
||||||
|
except Exception as exc:
|
||||||
|
await commit.rollback_all("commit_failed")
|
||||||
|
logger.error(
|
||||||
|
"auth side effect commit failed",
|
||||||
|
LOGGER_COMMAND,
|
||||||
|
e=exc,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await commit.rollback_all("matcher_exception")
|
||||||
|
if commit.limit_should_auto_unblock:
|
||||||
|
limit_entity = commit.limit_entity
|
||||||
|
LimitManager.unblock(
|
||||||
|
module,
|
||||||
|
limit_entity.user_id if limit_entity else user_id,
|
||||||
|
limit_entity.group_id if limit_entity else group_id,
|
||||||
|
limit_entity.channel_id if limit_entity else channel_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
LimitManager.unblock(module, user_id, group_id, channel_id)
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from nonebot.adapters import Event
|
||||||
|
from nonebot_plugin_uninfo import Uninfo
|
||||||
|
|
||||||
|
from .auth.auth_admin import auth_admin
|
||||||
|
from .auth.auth_bot import auth_bot
|
||||||
|
from .auth.auth_group import auth_group
|
||||||
|
from .auth.auth_plugin import auth_plugin
|
||||||
|
from .auth_types import AuthPreparation
|
||||||
|
|
||||||
|
|
||||||
|
async def legacy_pure_auth_fallback(
|
||||||
|
*,
|
||||||
|
prep: AuthPreparation,
|
||||||
|
event: Event,
|
||||||
|
session: Uninfo,
|
||||||
|
text: str,
|
||||||
|
) -> None:
|
||||||
|
"""Compatibility fallback for cache-deferred pure permission checks."""
|
||||||
|
|
||||||
|
await auth_bot(
|
||||||
|
prep.plugin,
|
||||||
|
prep.snapshot.context.bot_id,
|
||||||
|
prep.snapshot.bot_data,
|
||||||
|
skip_fetch=prep.snapshot.bot_data is not None,
|
||||||
|
allow_sleep_bypass=prep.policy_context.allow_sleep_bypass,
|
||||||
|
context=prep.permission_context,
|
||||||
|
)
|
||||||
|
await auth_group(
|
||||||
|
prep.plugin,
|
||||||
|
prep.snapshot.group,
|
||||||
|
text,
|
||||||
|
prep.snapshot.group_id,
|
||||||
|
context=prep.permission_context,
|
||||||
|
)
|
||||||
|
await auth_plugin(
|
||||||
|
prep.plugin,
|
||||||
|
prep.snapshot.group,
|
||||||
|
session,
|
||||||
|
event,
|
||||||
|
context=prep.permission_context,
|
||||||
|
user_id=prep.snapshot.user_id,
|
||||||
|
)
|
||||||
|
await auth_admin(
|
||||||
|
prep.plugin,
|
||||||
|
session,
|
||||||
|
cached_levels=prep.snapshot.admin_levels,
|
||||||
|
context=prep.permission_context,
|
||||||
|
entity=prep.snapshot.context.entity,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["legacy_pure_auth_fallback"]
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import inspect
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import nonebot.message as nb_message
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AuthPatchGuardResult:
|
||||||
|
ok: bool
|
||||||
|
reason: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
_HANDLE_EVENT_PARAMS = {"bot", "event"}
|
||||||
|
_HANDLE_EVENT_REQUIRED_ATTRS = (
|
||||||
|
"escape_tag",
|
||||||
|
"logger",
|
||||||
|
"NoLogException",
|
||||||
|
"AsyncExitStack",
|
||||||
|
"_apply_event_preprocessors",
|
||||||
|
"_apply_event_postprocessors",
|
||||||
|
"TrieRule",
|
||||||
|
"matchers",
|
||||||
|
"catch",
|
||||||
|
"StopPropagation",
|
||||||
|
"_handle_exception",
|
||||||
|
"anyio",
|
||||||
|
"run_coro_with_shield",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _signature_param_names(func: Callable[..., Any]) -> set[str]:
|
||||||
|
return set(inspect.signature(func).parameters)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_handle_event_patch() -> AuthPatchGuardResult:
|
||||||
|
target = getattr(nb_message, "handle_event", None)
|
||||||
|
if target is None:
|
||||||
|
return AuthPatchGuardResult(False, "missing nonebot.message.handle_event")
|
||||||
|
try:
|
||||||
|
params = _signature_param_names(target)
|
||||||
|
except Exception as exc:
|
||||||
|
return AuthPatchGuardResult(False, f"inspect signature failed: {exc}")
|
||||||
|
missing_params = sorted(_HANDLE_EVENT_PARAMS - params)
|
||||||
|
if missing_params:
|
||||||
|
return AuthPatchGuardResult(
|
||||||
|
False,
|
||||||
|
"handle_event signature missing params: " + ", ".join(missing_params),
|
||||||
|
)
|
||||||
|
missing_attrs = [
|
||||||
|
attr for attr in _HANDLE_EVENT_REQUIRED_ATTRS if not hasattr(nb_message, attr)
|
||||||
|
]
|
||||||
|
if missing_attrs:
|
||||||
|
return AuthPatchGuardResult(
|
||||||
|
False,
|
||||||
|
"nonebot.message missing attrs: " + ", ".join(missing_attrs),
|
||||||
|
)
|
||||||
|
return AuthPatchGuardResult(True)
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from nonebot.adapters import Bot, Event
|
||||||
|
from nonebot.matcher import Matcher
|
||||||
|
from nonebot_plugin_uninfo import Uninfo
|
||||||
|
|
||||||
|
from zhenxun.services.message_load import is_overloaded
|
||||||
|
from zhenxun.utils.utils import EntityIDs
|
||||||
|
|
||||||
|
from .auth.context import (
|
||||||
|
EventContext,
|
||||||
|
PermissionSideEffectCache,
|
||||||
|
set_route_modules,
|
||||||
|
)
|
||||||
|
from .auth.exception import PermissionExemption, SkipPluginException
|
||||||
|
from .auth_policy import (
|
||||||
|
action_from_snapshot,
|
||||||
|
principal_from_snapshot,
|
||||||
|
raise_for_policy,
|
||||||
|
resource_from_snapshot,
|
||||||
|
)
|
||||||
|
from .auth_types import AuthLaneContext, AuthPolicyFlags, AuthPreparation
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .auth_side_effect import SideEffectCommit
|
||||||
|
from .auth_trace import HookTraceRecorder
|
||||||
|
|
||||||
|
|
||||||
|
def _require(value: Any, name: str):
|
||||||
|
if value is None:
|
||||||
|
raise RuntimeError(f"AuthPipelineContext.{name} is required")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _prep(ctx: AuthPipelineContext) -> AuthPreparation:
|
||||||
|
return _require(ctx.prep, "prep")
|
||||||
|
|
||||||
|
|
||||||
|
def _recorder(ctx: AuthPipelineContext) -> "HookTraceRecorder":
|
||||||
|
return _require(ctx.hook_recorder, "hook_recorder")
|
||||||
|
|
||||||
|
|
||||||
|
def _side_effect_commit(ctx: AuthPipelineContext) -> "SideEffectCommit":
|
||||||
|
return _require(ctx.side_effect_commit, "side_effect_commit")
|
||||||
|
|
||||||
|
|
||||||
|
def _side_effect_cache(ctx: AuthPipelineContext) -> PermissionSideEffectCache:
|
||||||
|
return _require(ctx.side_effect_cache, "side_effect_cache")
|
||||||
|
|
||||||
|
|
||||||
|
def _entity(ctx: AuthPipelineContext) -> EntityIDs:
|
||||||
|
return _require(ctx.entity, "entity")
|
||||||
|
|
||||||
|
|
||||||
|
def _lane_context(ctx: AuthPipelineContext) -> AuthLaneContext:
|
||||||
|
return _require(ctx.lane_context, "lane_context")
|
||||||
|
|
||||||
|
|
||||||
|
PipelineHandler = Callable[["AuthPipelineContext"], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class AuthPipelineStage:
|
||||||
|
name: str
|
||||||
|
handler: PipelineHandler
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class AuthPipelineContext:
|
||||||
|
matcher: Matcher
|
||||||
|
event: Event
|
||||||
|
bot: Bot
|
||||||
|
session: Uninfo
|
||||||
|
event_context: EventContext
|
||||||
|
skip_ban: bool = False
|
||||||
|
state: dict | None = None
|
||||||
|
start_time: float = field(default_factory=time.time)
|
||||||
|
module: str = ""
|
||||||
|
entity: EntityIDs | None = None
|
||||||
|
event_cache: dict | None = None
|
||||||
|
text: str = ""
|
||||||
|
route_modules: set[str] | None = None
|
||||||
|
route_skip_checks: bool = False
|
||||||
|
is_command_matcher: bool = False
|
||||||
|
lane_context: AuthLaneContext | None = None
|
||||||
|
side_effect_cache: PermissionSideEffectCache | None = None
|
||||||
|
side_effect_commit: "SideEffectCommit | None" = None
|
||||||
|
side_effect_lock: asyncio.Lock | None = None
|
||||||
|
entered_side_effect_lock: bool = False
|
||||||
|
auth_result_cache: dict | None = None
|
||||||
|
hook_recorder: "HookTraceRecorder | None" = None
|
||||||
|
prep: AuthPreparation | None = None
|
||||||
|
flags: AuthPolicyFlags | None = None
|
||||||
|
cost_gold: int = 0
|
||||||
|
hooks_time: float = 0.0
|
||||||
|
ignore_flag: bool = False
|
||||||
|
auth_allowed: bool | None = None
|
||||||
|
decision_effect: str | None = None
|
||||||
|
decision_reason: str | None = None
|
||||||
|
stopped: bool = False
|
||||||
|
stage_timings: dict[str, float] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def stop(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
allowed: bool,
|
||||||
|
effect: str,
|
||||||
|
reason: str,
|
||||||
|
) -> None:
|
||||||
|
self.auth_allowed = allowed
|
||||||
|
self.decision_effect = effect
|
||||||
|
self.decision_reason = reason
|
||||||
|
self.stopped = True
|
||||||
|
|
||||||
|
|
||||||
|
class AuthPipeline:
|
||||||
|
def __init__(self, stages: list[AuthPipelineStage]) -> None:
|
||||||
|
self._stages = tuple(stages)
|
||||||
|
|
||||||
|
async def run(self, context: AuthPipelineContext) -> None:
|
||||||
|
for stage in self._stages:
|
||||||
|
started = time.perf_counter()
|
||||||
|
await stage.handler(context)
|
||||||
|
context.stage_timings[stage.name] = (time.perf_counter() - started) * 1000
|
||||||
|
if context.stopped:
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class AuthPipelineDependencies:
|
||||||
|
route_modules_with_commands: set[str]
|
||||||
|
get_route_context: Callable[[str, dict | None], Awaitable[set[str]]]
|
||||||
|
is_hidden_plugin: Callable[[Matcher], bool]
|
||||||
|
is_command_matcher_class: Callable[[type[Matcher]], bool]
|
||||||
|
matcher_has_alconna_shortcuts: Callable[[type[Matcher]], bool]
|
||||||
|
prepare_auth_state_with_fallback: Callable[..., Awaitable[Any]]
|
||||||
|
prepare_auth_state: Callable[..., Awaitable[Any]]
|
||||||
|
policy_decision_point: Any
|
||||||
|
policy_skip_message: Callable[[str], str]
|
||||||
|
legacy_pure_auth_fallback: Callable[..., Awaitable[None]]
|
||||||
|
check_ban_from_snapshot: Callable[..., Awaitable[None]]
|
||||||
|
resolve_cost_gold: Callable[..., Awaitable[int]]
|
||||||
|
run_auth_hooks: Callable[..., Awaitable[float]]
|
||||||
|
bot_filter: Callable[..., None]
|
||||||
|
reserve_gold: Callable[..., Awaitable[Any]]
|
||||||
|
append_auth_decision_log: Callable[..., Awaitable[None]]
|
||||||
|
insufficient_gold_error: type[Exception]
|
||||||
|
logger: Any
|
||||||
|
log_command: str
|
||||||
|
|
||||||
|
|
||||||
|
def apply_policy_precheck(
|
||||||
|
ctx: AuthPipelineContext,
|
||||||
|
deps: AuthPipelineDependencies,
|
||||||
|
) -> AuthPolicyFlags:
|
||||||
|
prep = _prep(ctx)
|
||||||
|
hook_recorder = _recorder(ctx)
|
||||||
|
flags = AuthPolicyFlags()
|
||||||
|
snapshot = prep.snapshot
|
||||||
|
decision = deps.policy_decision_point.decide(
|
||||||
|
principal_from_snapshot(snapshot),
|
||||||
|
action_from_snapshot(snapshot),
|
||||||
|
resource_from_snapshot(snapshot),
|
||||||
|
prep.policy_context,
|
||||||
|
)
|
||||||
|
if decision.deferred:
|
||||||
|
hook_recorder.set("auth_core", f"policy:{decision.reason}")
|
||||||
|
if decision.denied:
|
||||||
|
raise_for_policy(decision, deps.policy_skip_message(decision.reason))
|
||||||
|
if decision.allowed and decision.reason in {
|
||||||
|
"hidden_plugin_skip_auth",
|
||||||
|
"route_miss_skip_checks",
|
||||||
|
}:
|
||||||
|
flags.should_return_allowed = True
|
||||||
|
return flags
|
||||||
|
|
||||||
|
bot_decision = deps.policy_decision_point.decide_bot(prep.policy_context)
|
||||||
|
if bot_decision.allowed:
|
||||||
|
hook_recorder.set("auth_bot", "policy")
|
||||||
|
elif bot_decision.denied:
|
||||||
|
raise_for_policy(bot_decision, deps.policy_skip_message(bot_decision.reason))
|
||||||
|
elif bot_decision.deferred:
|
||||||
|
raise PermissionExemption(f"auth_bot deferred: {bot_decision.reason}")
|
||||||
|
|
||||||
|
group_decision = deps.policy_decision_point.decide_group(prep.policy_context)
|
||||||
|
if group_decision.allowed or group_decision.skipped:
|
||||||
|
hook_recorder.set("auth_group", f"policy:{group_decision.reason}")
|
||||||
|
elif group_decision.denied:
|
||||||
|
raise_for_policy(
|
||||||
|
group_decision,
|
||||||
|
deps.policy_skip_message(group_decision.reason),
|
||||||
|
)
|
||||||
|
elif group_decision.deferred:
|
||||||
|
raise PermissionExemption(f"auth_group deferred: {group_decision.reason}")
|
||||||
|
|
||||||
|
plugin_decision = deps.policy_decision_point.decide_plugin(prep.policy_context)
|
||||||
|
if plugin_decision.allowed or plugin_decision.skipped:
|
||||||
|
hook_recorder.set("auth_plugin", f"policy:{plugin_decision.reason}")
|
||||||
|
elif plugin_decision.denied:
|
||||||
|
raise_for_policy(
|
||||||
|
plugin_decision,
|
||||||
|
deps.policy_skip_message(plugin_decision.reason),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise PermissionExemption(f"auth_plugin deferred: {plugin_decision.reason}")
|
||||||
|
|
||||||
|
admin_decision = deps.policy_decision_point.decide_admin(prep.policy_context)
|
||||||
|
if admin_decision.allowed or admin_decision.skipped:
|
||||||
|
hook_recorder.set("auth_admin", f"policy:{admin_decision.reason}")
|
||||||
|
elif admin_decision.denied:
|
||||||
|
raise_for_policy(
|
||||||
|
admin_decision,
|
||||||
|
deps.policy_skip_message(admin_decision.reason),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise PermissionExemption(f"auth_admin deferred: {admin_decision.reason}")
|
||||||
|
|
||||||
|
return flags
|
||||||
|
|
||||||
|
|
||||||
|
async def route_gate_stage(
|
||||||
|
ctx: AuthPipelineContext,
|
||||||
|
deps: AuthPipelineDependencies,
|
||||||
|
) -> None:
|
||||||
|
if not ctx.module:
|
||||||
|
ctx.stop(allowed=True, effect="allow", reason="empty_module")
|
||||||
|
return
|
||||||
|
|
||||||
|
side_effect_cache = _side_effect_cache(ctx)
|
||||||
|
ctx.side_effect_lock = side_effect_cache.lock_for(ctx.module)
|
||||||
|
await ctx.side_effect_lock.acquire()
|
||||||
|
ctx.entered_side_effect_lock = True
|
||||||
|
|
||||||
|
auth_result_cache = side_effect_cache.auth_results
|
||||||
|
ctx.auth_result_cache = auth_result_cache
|
||||||
|
cached_result = auth_result_cache.get(ctx.module)
|
||||||
|
if cached_result is not None:
|
||||||
|
allowed, reason = cached_result
|
||||||
|
if not allowed:
|
||||||
|
ctx.decision_effect = "skip"
|
||||||
|
ctx.decision_reason = reason or "auth_cached_skip"
|
||||||
|
raise SkipPluginException(reason or "auth cached skip")
|
||||||
|
ctx.stop(allowed=True, effect="allow", reason="auth_cached_allow")
|
||||||
|
return
|
||||||
|
|
||||||
|
if deps.is_hidden_plugin(ctx.matcher):
|
||||||
|
ctx.stop(allowed=True, effect="allow", reason="hidden_plugin")
|
||||||
|
return
|
||||||
|
if ctx.event_cache is not None and ctx.event_cache.get("ban_state") is True:
|
||||||
|
ctx.decision_effect = "skip"
|
||||||
|
ctx.decision_reason = "ban_cached"
|
||||||
|
raise SkipPluginException("user or group banned (cached)")
|
||||||
|
|
||||||
|
if ctx.route_modules is None:
|
||||||
|
ctx.route_modules = await deps.get_route_context(ctx.text, ctx.event_cache)
|
||||||
|
set_route_modules(ctx.state, ctx.event_context, ctx.route_modules)
|
||||||
|
ctx.route_skip_checks = (
|
||||||
|
ctx.is_command_matcher
|
||||||
|
and ctx.module in deps.route_modules_with_commands
|
||||||
|
and ctx.module not in ctx.route_modules
|
||||||
|
and not deps.matcher_has_alconna_shortcuts(type(ctx.matcher))
|
||||||
|
)
|
||||||
|
if ctx.route_skip_checks:
|
||||||
|
if ctx.event_cache is not None:
|
||||||
|
ctx.event_cache["route_skip"] = True
|
||||||
|
_recorder(ctx).set("route", "miss")
|
||||||
|
ctx.stop(allowed=True, effect="allow", reason="route_miss_skip_checks")
|
||||||
|
|
||||||
|
|
||||||
|
async def prepare_snapshot_stage(
|
||||||
|
ctx: AuthPipelineContext,
|
||||||
|
deps: AuthPipelineDependencies,
|
||||||
|
) -> None:
|
||||||
|
ctx.prep = await deps.prepare_auth_state_with_fallback(
|
||||||
|
module=ctx.module,
|
||||||
|
context=ctx.event_context,
|
||||||
|
bot=ctx.bot,
|
||||||
|
event_cache=ctx.event_cache,
|
||||||
|
route_skip_checks=ctx.route_skip_checks,
|
||||||
|
skip_ban=ctx.skip_ban,
|
||||||
|
hook_recorder=ctx.hook_recorder,
|
||||||
|
state=ctx.state,
|
||||||
|
session=ctx.session,
|
||||||
|
)
|
||||||
|
if ctx.prep is None:
|
||||||
|
ctx.stop(allowed=True, effect="allow", reason="prepare_timeout_allow")
|
||||||
|
|
||||||
|
|
||||||
|
async def policy_precheck_stage(
|
||||||
|
ctx: AuthPipelineContext,
|
||||||
|
deps: AuthPipelineDependencies,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
ctx.flags = apply_policy_precheck(ctx, deps)
|
||||||
|
except PermissionExemption as exc:
|
||||||
|
_recorder(ctx).set("policy_fallback", str(exc))
|
||||||
|
ctx.prep = await deps.prepare_auth_state(
|
||||||
|
module=ctx.module,
|
||||||
|
context=ctx.event_context,
|
||||||
|
bot=ctx.bot,
|
||||||
|
event_cache=ctx.event_cache,
|
||||||
|
route_skip_checks=ctx.route_skip_checks,
|
||||||
|
skip_ban=ctx.skip_ban,
|
||||||
|
hook_recorder=ctx.hook_recorder,
|
||||||
|
state=ctx.state,
|
||||||
|
session=ctx.session,
|
||||||
|
allow_cache_load=True,
|
||||||
|
)
|
||||||
|
if ctx.prep is None:
|
||||||
|
ctx.stop(allowed=True, effect="allow", reason="policy_fallback_timeout")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
ctx.flags = apply_policy_precheck(ctx, deps)
|
||||||
|
except PermissionExemption as fallback_exc:
|
||||||
|
_recorder(ctx).set("legacy_pure_auth", str(fallback_exc))
|
||||||
|
await deps.legacy_pure_auth_fallback(
|
||||||
|
prep=ctx.prep,
|
||||||
|
event=ctx.event,
|
||||||
|
session=ctx.session,
|
||||||
|
text=ctx.text,
|
||||||
|
)
|
||||||
|
ctx.flags = AuthPolicyFlags()
|
||||||
|
flags = _require(ctx.flags, "flags")
|
||||||
|
if flags.should_return_allowed:
|
||||||
|
ctx.stop(allowed=True, effect="allow", reason="policy_precheck_allow")
|
||||||
|
return
|
||||||
|
await deps.check_ban_from_snapshot(
|
||||||
|
prep=ctx.prep,
|
||||||
|
matcher=ctx.matcher,
|
||||||
|
event_cache=ctx.event_cache,
|
||||||
|
skip_ban=ctx.skip_ban,
|
||||||
|
hook_recorder=ctx.hook_recorder,
|
||||||
|
session=ctx.session,
|
||||||
|
)
|
||||||
|
ctx.cost_gold = await deps.resolve_cost_gold(
|
||||||
|
prep=ctx.prep,
|
||||||
|
route_skip_checks=ctx.route_skip_checks,
|
||||||
|
hook_recorder=ctx.hook_recorder,
|
||||||
|
session=ctx.session,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def legacy_hook_adapter_stage(
|
||||||
|
ctx: AuthPipelineContext,
|
||||||
|
deps: AuthPipelineDependencies,
|
||||||
|
) -> None:
|
||||||
|
prep = _prep(ctx)
|
||||||
|
deps.bot_filter(ctx.session, context=prep.permission_context)
|
||||||
|
ctx.hooks_time = await deps.run_auth_hooks(
|
||||||
|
prep=prep,
|
||||||
|
session=ctx.session,
|
||||||
|
event_cache=ctx.event_cache,
|
||||||
|
route_skip_checks=ctx.route_skip_checks,
|
||||||
|
lane_context=_lane_context(ctx),
|
||||||
|
hook_recorder=_recorder(ctx),
|
||||||
|
side_effect_commit=_side_effect_commit(ctx),
|
||||||
|
)
|
||||||
|
ctx.auth_allowed = True
|
||||||
|
ctx.decision_effect = "allow"
|
||||||
|
ctx.decision_reason = "auth_passed"
|
||||||
|
|
||||||
|
|
||||||
|
async def side_effect_commit_stage(
|
||||||
|
ctx: AuthPipelineContext,
|
||||||
|
deps: AuthPipelineDependencies,
|
||||||
|
) -> None:
|
||||||
|
commit = _side_effect_commit(ctx)
|
||||||
|
side_effect_cache = _side_effect_cache(ctx)
|
||||||
|
if ctx.ignore_flag:
|
||||||
|
await commit.rollback_all("auth_ignored")
|
||||||
|
return
|
||||||
|
if ctx.cost_gold <= 0:
|
||||||
|
if commit.has_pending:
|
||||||
|
side_effect_cache.commits[ctx.module] = commit
|
||||||
|
return
|
||||||
|
gold_start = time.time()
|
||||||
|
try:
|
||||||
|
reservation = await deps.reserve_gold(
|
||||||
|
_entity(ctx).user_id,
|
||||||
|
ctx.module,
|
||||||
|
ctx.cost_gold,
|
||||||
|
ctx.session,
|
||||||
|
)
|
||||||
|
await commit.reserve_gold(
|
||||||
|
reservation,
|
||||||
|
amount=ctx.cost_gold,
|
||||||
|
metadata={"module": ctx.module},
|
||||||
|
)
|
||||||
|
_recorder(ctx).set("reserve_gold", f"{time.time() - gold_start:.3f}s")
|
||||||
|
except deps.insufficient_gold_error:
|
||||||
|
deps.logger.debug(
|
||||||
|
f"预扣金币失败,金币不足: {ctx.module}",
|
||||||
|
deps.log_command,
|
||||||
|
session=ctx.session,
|
||||||
|
)
|
||||||
|
raise SkipPluginException(f"{ctx.module} 金币不足,已取消执行...") from None
|
||||||
|
except TimeoutError:
|
||||||
|
deps.logger.error(
|
||||||
|
f"预扣金币超时,模块: {ctx.module}",
|
||||||
|
deps.log_command,
|
||||||
|
session=ctx.session,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
side_effect_cache.commits[ctx.module] = commit
|
||||||
|
|
||||||
|
|
||||||
|
async def decision_log_stage(
|
||||||
|
ctx: AuthPipelineContext,
|
||||||
|
deps: AuthPipelineDependencies,
|
||||||
|
) -> None:
|
||||||
|
commit = ctx.side_effect_commit
|
||||||
|
has_deferred_commit = commit is not None and commit.has_pending
|
||||||
|
if (
|
||||||
|
ctx.auth_result_cache is not None
|
||||||
|
and ctx.auth_allowed is not None
|
||||||
|
and not has_deferred_commit
|
||||||
|
):
|
||||||
|
ctx.auth_result_cache[ctx.module] = (
|
||||||
|
ctx.auth_allowed,
|
||||||
|
None if ctx.auth_allowed else ctx.decision_reason,
|
||||||
|
)
|
||||||
|
side_effect_state = commit.snapshot() if commit is not None else None
|
||||||
|
shadow_effect = None
|
||||||
|
shadow_reason = None
|
||||||
|
if has_deferred_commit:
|
||||||
|
shadow_effect = "defer"
|
||||||
|
shadow_reason = "side_effect_pending:" + ",".join(
|
||||||
|
commit.pending_kinds if commit is not None else ()
|
||||||
|
)
|
||||||
|
if ctx.entered_side_effect_lock and ctx.side_effect_lock is not None:
|
||||||
|
try:
|
||||||
|
ctx.side_effect_lock.release()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
ctx.entered_side_effect_lock = False
|
||||||
|
latency_ms = (time.time() - ctx.start_time) * 1000
|
||||||
|
await deps.append_auth_decision_log(
|
||||||
|
bot_id=ctx.event_context.bot_id,
|
||||||
|
platform=ctx.event_context.platform,
|
||||||
|
group_id=_entity(ctx).group_id,
|
||||||
|
user_id=_entity(ctx).user_id,
|
||||||
|
module=ctx.module,
|
||||||
|
effect=ctx.decision_effect or "error",
|
||||||
|
reason=ctx.decision_reason,
|
||||||
|
shadow_effect=shadow_effect,
|
||||||
|
shadow_reason=shadow_reason,
|
||||||
|
side_effect_state=side_effect_state,
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
overloaded=is_overloaded(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_auth_pipeline(deps: AuthPipelineDependencies) -> AuthPipeline:
|
||||||
|
return AuthPipeline(
|
||||||
|
[
|
||||||
|
AuthPipelineStage("route_gate", lambda ctx: route_gate_stage(ctx, deps)),
|
||||||
|
AuthPipelineStage(
|
||||||
|
"prepare_snapshot",
|
||||||
|
lambda ctx: prepare_snapshot_stage(ctx, deps),
|
||||||
|
),
|
||||||
|
AuthPipelineStage(
|
||||||
|
"policy_precheck",
|
||||||
|
lambda ctx: policy_precheck_stage(ctx, deps),
|
||||||
|
),
|
||||||
|
AuthPipelineStage(
|
||||||
|
"legacy_hook_adapter",
|
||||||
|
lambda ctx: legacy_hook_adapter_stage(ctx, deps),
|
||||||
|
),
|
||||||
|
AuthPipelineStage(
|
||||||
|
"side_effect_commit",
|
||||||
|
lambda ctx: side_effect_commit_stage(ctx, deps),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from zhenxun.services.cache.runtime_cache import _parse_block_modules
|
||||||
|
from zhenxun.utils.common_utils import CommonUtils
|
||||||
|
from zhenxun.utils.enum import BlockType, PluginType
|
||||||
|
|
||||||
|
from .auth.exception import IsSuperuserException, SkipPluginException
|
||||||
|
from .auth_profile import PluginAuthProfile
|
||||||
|
from .auth_snapshot import AuthSnapshot
|
||||||
|
|
||||||
|
PolicyEffect = Literal["allow", "deny", "skip", "defer"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PolicyDecision:
|
||||||
|
effect: PolicyEffect
|
||||||
|
reason: str = ""
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def allowed(self) -> bool:
|
||||||
|
return self.effect == "allow"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def denied(self) -> bool:
|
||||||
|
return self.effect == "deny"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def skipped(self) -> bool:
|
||||||
|
return self.effect == "skip"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def deferred(self) -> bool:
|
||||||
|
return self.effect == "defer"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PolicyPrincipal:
|
||||||
|
user_id: str
|
||||||
|
group_id: str | None = None
|
||||||
|
channel_id: str | None = None
|
||||||
|
is_superuser: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PolicyAction:
|
||||||
|
name: str
|
||||||
|
module: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PolicyResource:
|
||||||
|
plugin: object
|
||||||
|
profile: PluginAuthProfile
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PolicyContext:
|
||||||
|
snapshot: AuthSnapshot
|
||||||
|
route_skip_checks: bool = False
|
||||||
|
allow_sleep_bypass: bool = False
|
||||||
|
allow_group_sleep_bypass: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class PolicyDecisionPoint:
|
||||||
|
"""Structured permission decision helpers.
|
||||||
|
|
||||||
|
This layer mirrors existing auth semantics and deliberately does not add a
|
||||||
|
new policy table. Side-effecting checks such as limit counters remain
|
||||||
|
deferred to the old hooks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _missing(snapshot: AuthSnapshot, name: str) -> bool:
|
||||||
|
return name in snapshot.cache_misses
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _private_disabled(profile: PluginAuthProfile) -> bool:
|
||||||
|
return profile.block_type == BlockType.PRIVATE
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _group_disabled(profile: PluginAuthProfile) -> bool:
|
||||||
|
return profile.block_type == BlockType.GROUP
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _globally_disabled(profile: PluginAuthProfile) -> bool:
|
||||||
|
return profile.block_type == BlockType.ALL and not profile.status
|
||||||
|
|
||||||
|
def decide(
|
||||||
|
self,
|
||||||
|
principal: PolicyPrincipal,
|
||||||
|
action: PolicyAction,
|
||||||
|
resource: PolicyResource,
|
||||||
|
context: PolicyContext,
|
||||||
|
) -> PolicyDecision:
|
||||||
|
del action
|
||||||
|
snapshot = context.snapshot
|
||||||
|
profile = resource.profile
|
||||||
|
if profile.hidden:
|
||||||
|
return PolicyDecision("allow", "hidden_plugin_skip_auth")
|
||||||
|
if context.route_skip_checks:
|
||||||
|
return PolicyDecision("allow", "route_miss_skip_checks")
|
||||||
|
if snapshot.ban_state is True and not principal.is_superuser:
|
||||||
|
return PolicyDecision("deny", "user_or_group_banned")
|
||||||
|
if profile.superuser_only and not principal.is_superuser:
|
||||||
|
return PolicyDecision("deny", "superuser_required")
|
||||||
|
return PolicyDecision("defer", "needs_legacy_hooks")
|
||||||
|
|
||||||
|
def decide_bot(self, context: PolicyContext) -> PolicyDecision:
|
||||||
|
snapshot = context.snapshot
|
||||||
|
bot_data = snapshot.bot_data
|
||||||
|
if bot_data is None:
|
||||||
|
if self._missing(snapshot, "bot"):
|
||||||
|
return PolicyDecision("defer", "bot_cache_unavailable")
|
||||||
|
return PolicyDecision("deny", "bot_not_found")
|
||||||
|
if not bot_data.status and not context.allow_sleep_bypass:
|
||||||
|
return PolicyDecision("deny", "bot_sleeping")
|
||||||
|
module = snapshot.profile.module
|
||||||
|
if module and self._module_in_block_string(module, bot_data.block_plugins):
|
||||||
|
return PolicyDecision("deny", "bot_plugin_blocked")
|
||||||
|
return PolicyDecision("allow", "bot_allowed")
|
||||||
|
|
||||||
|
def decide_group(self, context: PolicyContext) -> PolicyDecision:
|
||||||
|
snapshot = context.snapshot
|
||||||
|
if not snapshot.group_id:
|
||||||
|
return PolicyDecision("skip", "not_group_event")
|
||||||
|
group = snapshot.group
|
||||||
|
profile = snapshot.profile
|
||||||
|
if group is None:
|
||||||
|
if self._missing(snapshot, "group"):
|
||||||
|
return PolicyDecision("defer", "group_cache_unavailable")
|
||||||
|
return PolicyDecision("deny", "group_not_found")
|
||||||
|
if group.level < 0:
|
||||||
|
return PolicyDecision("deny", "group_blacklisted")
|
||||||
|
if (
|
||||||
|
not group.status
|
||||||
|
and not context.allow_group_sleep_bypass
|
||||||
|
and not snapshot.is_superuser
|
||||||
|
):
|
||||||
|
return PolicyDecision("deny", "group_sleeping")
|
||||||
|
if profile.level > group.level:
|
||||||
|
return PolicyDecision("deny", "group_level_low")
|
||||||
|
return PolicyDecision("allow", "group_allowed")
|
||||||
|
|
||||||
|
def decide_admin(self, context: PolicyContext) -> PolicyDecision:
|
||||||
|
snapshot = context.snapshot
|
||||||
|
profile = snapshot.profile
|
||||||
|
if not profile.need_admin:
|
||||||
|
return PolicyDecision("skip", "admin_not_required")
|
||||||
|
if profile.plugin_type in {PluginType.SUPERUSER, PluginType.SUPER_AND_ADMIN}:
|
||||||
|
if snapshot.is_superuser:
|
||||||
|
return PolicyDecision("allow", "superuser")
|
||||||
|
if profile.plugin_type == PluginType.SUPERUSER:
|
||||||
|
return PolicyDecision("deny", "superuser_required")
|
||||||
|
if not profile.admin_level:
|
||||||
|
return PolicyDecision("skip", "admin_level_empty")
|
||||||
|
if snapshot.admin_levels is None:
|
||||||
|
return PolicyDecision("defer", "admin_levels_unavailable")
|
||||||
|
global_user, group_user = snapshot.admin_levels
|
||||||
|
user_level = global_user.user_level if global_user else 0
|
||||||
|
if snapshot.group_id and group_user:
|
||||||
|
user_level = max(user_level, group_user.user_level)
|
||||||
|
if user_level < profile.admin_level:
|
||||||
|
return PolicyDecision("deny", "admin_level_low")
|
||||||
|
return PolicyDecision("allow", "admin_allowed")
|
||||||
|
|
||||||
|
def decide_plugin(self, context: PolicyContext) -> PolicyDecision:
|
||||||
|
snapshot = context.snapshot
|
||||||
|
profile = snapshot.profile
|
||||||
|
group = snapshot.group
|
||||||
|
if snapshot.is_superuser:
|
||||||
|
return PolicyDecision("allow", "superuser")
|
||||||
|
if snapshot.group_id:
|
||||||
|
if group is None:
|
||||||
|
if self._missing(snapshot, "group"):
|
||||||
|
return PolicyDecision("defer", "group_cache_unavailable")
|
||||||
|
return PolicyDecision("deny", "group_not_found")
|
||||||
|
if profile.status and not self._group_disabled(profile):
|
||||||
|
block_set, super_block_set = self._group_block_sets(group)
|
||||||
|
if not block_set and not super_block_set:
|
||||||
|
return PolicyDecision("allow", "plugin_group_fast_allow")
|
||||||
|
block_set, super_block_set = self._group_block_sets(group)
|
||||||
|
if profile.module in super_block_set:
|
||||||
|
return PolicyDecision("deny", "plugin_superuser_blocked_in_group")
|
||||||
|
if profile.module in block_set:
|
||||||
|
return PolicyDecision("deny", "plugin_blocked_in_group")
|
||||||
|
if self._group_disabled(profile):
|
||||||
|
return PolicyDecision("deny", "plugin_disabled_in_group")
|
||||||
|
elif self._private_disabled(profile):
|
||||||
|
return PolicyDecision("deny", "plugin_disabled_in_private")
|
||||||
|
if self._globally_disabled(profile):
|
||||||
|
if group is not None and getattr(group, "is_super", False):
|
||||||
|
return PolicyDecision("allow", "super_group_bypass")
|
||||||
|
return PolicyDecision("deny", "plugin_global_disabled")
|
||||||
|
return PolicyDecision("allow", "plugin_allowed")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _group_block_sets(group: object) -> tuple[frozenset[str], frozenset[str]]:
|
||||||
|
block_set = getattr(group, "block_plugin_set", None)
|
||||||
|
super_block_set = getattr(group, "superuser_block_plugin_set", None)
|
||||||
|
if block_set is None:
|
||||||
|
block_set = _parse_block_modules(getattr(group, "block_plugin", "") or "")
|
||||||
|
setattr(group, "block_plugin_set", block_set)
|
||||||
|
if super_block_set is None:
|
||||||
|
super_block_set = _parse_block_modules(
|
||||||
|
getattr(group, "superuser_block_plugin", "") or ""
|
||||||
|
)
|
||||||
|
setattr(group, "superuser_block_plugin_set", super_block_set)
|
||||||
|
return block_set, super_block_set
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _module_in_block_string(module: str, value: str | None) -> bool:
|
||||||
|
if not value:
|
||||||
|
return False
|
||||||
|
return CommonUtils.format(module) in value or module in _parse_block_modules(
|
||||||
|
value
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def principal_from_snapshot(snapshot: AuthSnapshot) -> PolicyPrincipal:
|
||||||
|
return PolicyPrincipal(
|
||||||
|
user_id=snapshot.user_id,
|
||||||
|
group_id=snapshot.group_id,
|
||||||
|
channel_id=snapshot.channel_id,
|
||||||
|
is_superuser=snapshot.is_superuser,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def action_from_snapshot(snapshot: AuthSnapshot) -> PolicyAction:
|
||||||
|
return PolicyAction(name="invoke_plugin", module=snapshot.module)
|
||||||
|
|
||||||
|
|
||||||
|
def resource_from_snapshot(snapshot: AuthSnapshot) -> PolicyResource:
|
||||||
|
return PolicyResource(plugin=snapshot.plugin, profile=snapshot.profile)
|
||||||
|
|
||||||
|
|
||||||
|
def raise_for_policy(decision: PolicyDecision, message: str | None = None) -> None:
|
||||||
|
if decision.denied:
|
||||||
|
raise SkipPluginException(message or decision.reason)
|
||||||
|
if decision.allowed and decision.reason == "super_group_bypass":
|
||||||
|
raise IsSuperuserException()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PolicyAction",
|
||||||
|
"PolicyContext",
|
||||||
|
"PolicyDecision",
|
||||||
|
"PolicyDecisionPoint",
|
||||||
|
"PolicyPrincipal",
|
||||||
|
"PolicyResource",
|
||||||
|
"action_from_snapshot",
|
||||||
|
"principal_from_snapshot",
|
||||||
|
"raise_for_policy",
|
||||||
|
"resource_from_snapshot",
|
||||||
|
]
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from zhenxun.services.cache.runtime_cache import (
|
||||||
|
PluginLimitMemoryCache,
|
||||||
|
PluginLimitSnapshot,
|
||||||
|
)
|
||||||
|
from zhenxun.utils.enum import BlockType, PluginType
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PluginAuthProfile:
|
||||||
|
module: str
|
||||||
|
name: str
|
||||||
|
hidden: bool = False
|
||||||
|
status: bool = True
|
||||||
|
block_type: BlockType | None = None
|
||||||
|
plugin_type: PluginType | None = None
|
||||||
|
need_admin: bool = False
|
||||||
|
need_group_check: bool = False
|
||||||
|
has_limit: bool = False
|
||||||
|
cost_gold: int = 0
|
||||||
|
admin_level: int = 0
|
||||||
|
limit_superuser: bool = False
|
||||||
|
level: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def superuser_only(self) -> bool:
|
||||||
|
return self.plugin_type == PluginType.SUPERUSER
|
||||||
|
|
||||||
|
@property
|
||||||
|
def superuser_or_admin(self) -> bool:
|
||||||
|
return self.plugin_type == PluginType.SUPER_AND_ADMIN
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_admin_level(plugin) -> int:
|
||||||
|
try:
|
||||||
|
return int(getattr(plugin, "admin_level", 0) or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_cost_gold(plugin) -> int:
|
||||||
|
try:
|
||||||
|
return int(getattr(plugin, "cost_gold", 0) or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def build_plugin_auth_profile(plugin, *, has_limit: bool = False) -> PluginAuthProfile:
|
||||||
|
plugin_type = getattr(plugin, "plugin_type", None)
|
||||||
|
admin_level = _plugin_admin_level(plugin)
|
||||||
|
block_type = getattr(plugin, "block_type", None)
|
||||||
|
module = str(getattr(plugin, "module", "") or "")
|
||||||
|
need_admin = bool(admin_level > 0) or plugin_type in {
|
||||||
|
PluginType.ADMIN,
|
||||||
|
PluginType.SUPERUSER,
|
||||||
|
PluginType.SUPER_AND_ADMIN,
|
||||||
|
}
|
||||||
|
return PluginAuthProfile(
|
||||||
|
module=module,
|
||||||
|
name=str(getattr(plugin, "name", "") or module),
|
||||||
|
hidden=plugin_type == PluginType.HIDDEN,
|
||||||
|
status=bool(getattr(plugin, "status", True)),
|
||||||
|
block_type=block_type,
|
||||||
|
plugin_type=plugin_type,
|
||||||
|
need_admin=need_admin,
|
||||||
|
need_group_check=block_type
|
||||||
|
in {BlockType.ALL, BlockType.GROUP, BlockType.PRIVATE},
|
||||||
|
has_limit=bool(has_limit),
|
||||||
|
cost_gold=_plugin_cost_gold(plugin),
|
||||||
|
admin_level=admin_level,
|
||||||
|
limit_superuser=bool(getattr(plugin, "limit_superuser", False)),
|
||||||
|
level=int(getattr(plugin, "level", 0) or 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_plugin_auth_profile(
|
||||||
|
plugin,
|
||||||
|
*,
|
||||||
|
event_cache: dict | None = None,
|
||||||
|
allow_cache_load: bool = True,
|
||||||
|
) -> PluginAuthProfile:
|
||||||
|
module = str(getattr(plugin, "module", "") or "")
|
||||||
|
profile_cache: dict[str, PluginAuthProfile] = {}
|
||||||
|
if event_cache is not None:
|
||||||
|
profile_cache = event_cache.setdefault("plugin_auth_profiles", {})
|
||||||
|
cached = profile_cache.get(module)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
limits: list[PluginLimitSnapshot] | None = None
|
||||||
|
limits_ready = False
|
||||||
|
if event_cache is not None:
|
||||||
|
limit_cache = event_cache.setdefault("module_limit_entries", {})
|
||||||
|
if module in limit_cache:
|
||||||
|
limits = limit_cache[module]
|
||||||
|
limits_ready = True
|
||||||
|
if limits is None:
|
||||||
|
limits = PluginLimitMemoryCache.get_limits_if_ready(module)
|
||||||
|
limits_ready = limits is not None
|
||||||
|
if limits is None and allow_cache_load:
|
||||||
|
limits = await PluginLimitMemoryCache.get_limits(module)
|
||||||
|
limits_ready = True
|
||||||
|
if limits is None:
|
||||||
|
limits = []
|
||||||
|
profile = build_plugin_auth_profile(plugin, has_limit=bool(limits))
|
||||||
|
if event_cache is not None:
|
||||||
|
profile_cache[module] = profile
|
||||||
|
event_cache.setdefault("module_limits", {})[module] = profile.has_limit
|
||||||
|
event_cache.setdefault("module_limits_ready", {})[module] = limits_ready
|
||||||
|
if limits_ready:
|
||||||
|
event_cache.setdefault("module_limit_entries", {})[module] = limits
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"PluginAuthProfile",
|
||||||
|
"build_plugin_auth_profile",
|
||||||
|
"get_plugin_auth_profile",
|
||||||
|
]
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
|
from nonebot.matcher import Matcher
|
||||||
|
|
||||||
|
from zhenxun.utils.enum import PluginType
|
||||||
|
|
||||||
|
from .auth.context import EventContext, set_route_modules
|
||||||
|
|
||||||
|
RouteContextGetter = Callable[[str, dict | None], Awaitable[set[str]]]
|
||||||
|
CommandMatcherChecker = Callable[[type[Matcher]], bool]
|
||||||
|
AlconnaShortcutChecker = Callable[[type[Matcher]], bool]
|
||||||
|
|
||||||
|
|
||||||
|
async def route_precheck(
|
||||||
|
matcher: Matcher,
|
||||||
|
context: EventContext,
|
||||||
|
*,
|
||||||
|
route_modules_with_commands: set[str],
|
||||||
|
get_route_context: RouteContextGetter,
|
||||||
|
is_command_matcher_class: CommandMatcherChecker,
|
||||||
|
matcher_has_alconna_shortcuts: AlconnaShortcutChecker,
|
||||||
|
) -> bool:
|
||||||
|
"""Skip expensive auth checks for command matchers proven to be off-route."""
|
||||||
|
|
||||||
|
module = matcher.plugin_name or ""
|
||||||
|
if not module:
|
||||||
|
return False
|
||||||
|
if _is_hidden_plugin(matcher):
|
||||||
|
return False
|
||||||
|
if not is_command_matcher_class(type(matcher)):
|
||||||
|
return False
|
||||||
|
|
||||||
|
route_modules = context.route_modules if context.route_modules_loaded else None
|
||||||
|
if route_modules is None:
|
||||||
|
route_modules = await get_route_context(
|
||||||
|
context.plain_text,
|
||||||
|
context.event_cache,
|
||||||
|
)
|
||||||
|
set_route_modules(None, context, route_modules)
|
||||||
|
|
||||||
|
if module in route_modules_with_commands and module not in route_modules:
|
||||||
|
if matcher_has_alconna_shortcuts(type(matcher)):
|
||||||
|
return False
|
||||||
|
if context.event_cache is not None:
|
||||||
|
context.event_cache["route_skip"] = True
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_hidden_plugin(matcher: Matcher) -> bool:
|
||||||
|
plugin = matcher.plugin
|
||||||
|
if not plugin or not plugin.metadata:
|
||||||
|
return False
|
||||||
|
extra = plugin.metadata.extra or {}
|
||||||
|
return extra.get("plugin_type") == PluginType.HIDDEN
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["route_precheck"]
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, fields
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AuthDispatchRuntimeConfig:
|
||||||
|
hooks_concurrency_limit: int = 5
|
||||||
|
db_concurrency_limit: int = 6
|
||||||
|
command_exact_limit: int = 96
|
||||||
|
command_shortcut_limit: int = 32
|
||||||
|
command_regex_limit: int = 8
|
||||||
|
system_limit: int = 64
|
||||||
|
passive_light_limit: int = 12
|
||||||
|
passive_db_limit: int = 4
|
||||||
|
passive_http_limit: int = 4
|
||||||
|
passive_ai_limit: int = 2
|
||||||
|
passive_render_limit: int = 2
|
||||||
|
overload_selected_threshold: int = 48
|
||||||
|
overload_lane_wait_ms: float = 200.0
|
||||||
|
timeout_seconds: float = 5.0
|
||||||
|
circuit_reset_time: int = 300
|
||||||
|
matcher_route_prefilter_ttl: int = 2
|
||||||
|
prefilter_stats_log_interval: float = 10.0
|
||||||
|
cache_sweep_interval: float = 1.0
|
||||||
|
dispatch_stats_log_interval: float = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AuthObservabilityRuntimeConfig:
|
||||||
|
buffer_max_retain: int = 20_000
|
||||||
|
flush_trigger_size: int = 256
|
||||||
|
flush_batch_size: int = 500
|
||||||
|
flush_interval_seconds: float = 30.0
|
||||||
|
drop_log_interval_seconds: float = 10.0
|
||||||
|
allow_sample_rate: float = 0.005
|
||||||
|
overloaded_allow_sample_rate: float = 0.02
|
||||||
|
non_allow_sample_rate: float = 1.0
|
||||||
|
backpressure_sample_rate: float = 0.2
|
||||||
|
backpressure_severe_active_threshold: int = 5
|
||||||
|
|
||||||
|
|
||||||
|
_WARNED_ENV_KEYS: set[str] = set()
|
||||||
|
_ENV_ALIASES: dict[str, tuple[str, ...]] = {
|
||||||
|
"hooks_concurrency_limit": ("ZX_AUTH_HOOKS_CONCURRENCY_LIMIT",),
|
||||||
|
"db_concurrency_limit": ("ZX_AUTH_DB_CONCURRENCY_LIMIT",),
|
||||||
|
"command_exact_limit": ("ZX_AUTH_DISPATCH_COMMAND_EXACT_LIMIT",),
|
||||||
|
"command_shortcut_limit": ("ZX_AUTH_DISPATCH_COMMAND_SHORTCUT_LIMIT",),
|
||||||
|
"command_regex_limit": ("ZX_AUTH_DISPATCH_COMMAND_REGEX_LIMIT",),
|
||||||
|
"system_limit": ("ZX_AUTH_DISPATCH_SYSTEM_LIMIT",),
|
||||||
|
"passive_light_limit": ("ZX_AUTH_DISPATCH_PASSIVE_LIGHT_LIMIT",),
|
||||||
|
"passive_db_limit": ("ZX_AUTH_DISPATCH_PASSIVE_DB_LIMIT",),
|
||||||
|
"passive_http_limit": ("ZX_AUTH_DISPATCH_PASSIVE_HTTP_LIMIT",),
|
||||||
|
"passive_ai_limit": ("ZX_AUTH_DISPATCH_PASSIVE_AI_LIMIT",),
|
||||||
|
"passive_render_limit": ("ZX_AUTH_DISPATCH_PASSIVE_RENDER_LIMIT",),
|
||||||
|
"overload_selected_threshold": ("ZX_AUTH_OVERLOAD_SELECTED_THRESHOLD",),
|
||||||
|
"overload_lane_wait_ms": ("ZX_AUTH_OVERLOAD_LANE_WAIT_MS",),
|
||||||
|
"timeout_seconds": ("ZX_AUTH_TIMEOUT_SECONDS",),
|
||||||
|
"circuit_reset_time": ("ZX_AUTH_CIRCUIT_RESET_TIME",),
|
||||||
|
"matcher_route_prefilter_ttl": ("ZX_AUTH_MATCHER_ROUTE_PREFILTER_TTL",),
|
||||||
|
"prefilter_stats_log_interval": ("ZX_AUTH_PREFILTER_STATS_LOG_INTERVAL",),
|
||||||
|
"cache_sweep_interval": ("ZX_AUTH_CACHE_SWEEP_INTERVAL",),
|
||||||
|
"dispatch_stats_log_interval": ("ZX_AUTH_DISPATCH_STATS_LOG_INTERVAL",),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _env_name(prefix: str, field_name: str) -> str:
|
||||||
|
return f"{prefix}_{field_name.upper()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _env_names(prefix: str, field_name: str) -> tuple[str, ...]:
|
||||||
|
generated = _env_name(prefix, field_name)
|
||||||
|
aliases = _ENV_ALIASES.get(field_name, ())
|
||||||
|
return (*aliases, generated)
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_env_value(raw: str, default: object) -> object:
|
||||||
|
if isinstance(default, bool):
|
||||||
|
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
if isinstance(default, int) and not isinstance(default, bool):
|
||||||
|
return int(raw)
|
||||||
|
if isinstance(default, float):
|
||||||
|
return float(raw)
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def _warn_invalid_env(env_name: str, raw: str, exc: Exception) -> None:
|
||||||
|
if env_name in _WARNED_ENV_KEYS:
|
||||||
|
return
|
||||||
|
_WARNED_ENV_KEYS.add(env_name)
|
||||||
|
try:
|
||||||
|
from zhenxun.services.log import logger
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
f"{env_name}={raw!r} 解析失败,使用默认值: {exc}",
|
||||||
|
"AuthRuntimeConfig",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# Config is imported early on the auth hot path; logging must be optional.
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _load_config(cls: type, prefix: str):
|
||||||
|
values = {}
|
||||||
|
default_obj = cls()
|
||||||
|
for item in fields(default_obj):
|
||||||
|
default = getattr(default_obj, item.name)
|
||||||
|
env_name = ""
|
||||||
|
raw = None
|
||||||
|
for candidate in _env_names(prefix, item.name):
|
||||||
|
candidate_value = os.getenv(candidate)
|
||||||
|
if candidate_value is not None and candidate_value.strip():
|
||||||
|
env_name = candidate
|
||||||
|
raw = candidate_value
|
||||||
|
break
|
||||||
|
if raw is None or not raw.strip():
|
||||||
|
values[item.name] = default
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
values[item.name] = _coerce_env_value(raw, default)
|
||||||
|
except Exception as exc:
|
||||||
|
_warn_invalid_env(env_name, raw, exc)
|
||||||
|
values[item.name] = default
|
||||||
|
return cls(**values)
|
||||||
|
|
||||||
|
|
||||||
|
AUTH_DISPATCH_RUNTIME_CONFIG = _load_config(
|
||||||
|
AuthDispatchRuntimeConfig,
|
||||||
|
"ZX_AUTH",
|
||||||
|
)
|
||||||
|
AUTH_OBSERVABILITY_RUNTIME_CONFIG = _load_config(
|
||||||
|
AuthObservabilityRuntimeConfig,
|
||||||
|
"ZX_AUTH_OBSERVABILITY",
|
||||||
|
)
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Awaitable, Callable, Sequence
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
import time
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from nonebot_plugin_uninfo import Uninfo
|
||||||
|
|
||||||
|
from zhenxun.services.log import logger
|
||||||
|
from zhenxun.utils.utils import EntityIDs
|
||||||
|
|
||||||
|
from .auth.config import LOGGER_COMMAND
|
||||||
|
from .auth.utils import send_message
|
||||||
|
|
||||||
|
AsyncAction = Callable[[], Awaitable[None]]
|
||||||
|
|
||||||
|
|
||||||
|
class SyncReservation(Protocol):
|
||||||
|
def commit(self) -> None: ...
|
||||||
|
|
||||||
|
def release(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class AsyncReservation(Protocol):
|
||||||
|
async def commit(self) -> None: ...
|
||||||
|
|
||||||
|
async def release(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
ReservationLike = AsyncAction | SyncReservation | AsyncReservation
|
||||||
|
SideEffectKind = str
|
||||||
|
SideEffectState = str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class SideEffectReservation:
|
||||||
|
kind: SideEffectKind
|
||||||
|
reservation: ReservationLike
|
||||||
|
amount: int = 0
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
state: SideEffectState = "reserved"
|
||||||
|
reserved_at: float = field(default_factory=time.monotonic)
|
||||||
|
committed_at: float | None = None
|
||||||
|
released_at: float | None = None
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def should_auto_unblock(self) -> bool:
|
||||||
|
return bool(getattr(self.reservation, "should_auto_unblock", False))
|
||||||
|
|
||||||
|
|
||||||
|
async def _maybe_await(value: Any) -> None:
|
||||||
|
if hasattr(value, "__await__"):
|
||||||
|
await value
|
||||||
|
|
||||||
|
|
||||||
|
async def _commit_reservation(reservation: ReservationLike) -> None:
|
||||||
|
commit = getattr(reservation, "commit", None)
|
||||||
|
if callable(commit):
|
||||||
|
await _maybe_await(commit())
|
||||||
|
return
|
||||||
|
if callable(reservation):
|
||||||
|
await reservation()
|
||||||
|
|
||||||
|
|
||||||
|
async def _release_reservation(reservation: ReservationLike) -> None:
|
||||||
|
release = getattr(reservation, "release", None)
|
||||||
|
if callable(release):
|
||||||
|
await _maybe_await(release())
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class SideEffectCommit:
|
||||||
|
"""权限链副作用提交器。
|
||||||
|
|
||||||
|
第一阶段只封装既有调用点,不改变扣金币、限流提交、权限提示发送时机。
|
||||||
|
"""
|
||||||
|
|
||||||
|
session: Uninfo
|
||||||
|
module: str
|
||||||
|
owner_matcher_id: int | None = None
|
||||||
|
limit_entity: EntityIDs | None = None
|
||||||
|
_reservations: dict[SideEffectKind, SideEffectReservation] = field(
|
||||||
|
default_factory=dict
|
||||||
|
)
|
||||||
|
committed: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def limit_should_auto_unblock(self) -> bool:
|
||||||
|
record = self._reservations.get("limit")
|
||||||
|
return bool(record and record.should_auto_unblock)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_pending(self) -> bool:
|
||||||
|
return any(record.state == "reserved" for record in self._reservations.values())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pending_kinds(self) -> tuple[str, ...]:
|
||||||
|
return tuple(
|
||||||
|
kind
|
||||||
|
for kind, record in self._reservations.items()
|
||||||
|
if record.state == "reserved"
|
||||||
|
)
|
||||||
|
|
||||||
|
def snapshot(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"module": self.module,
|
||||||
|
"committed": self.committed,
|
||||||
|
"pending": list(self.pending_kinds),
|
||||||
|
"reservations": {
|
||||||
|
kind: {
|
||||||
|
"state": record.state,
|
||||||
|
"amount": record.amount,
|
||||||
|
"metadata": record.metadata,
|
||||||
|
"reason": record.reason,
|
||||||
|
}
|
||||||
|
for kind, record in self._reservations.items()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def send_permission_tip(
|
||||||
|
self,
|
||||||
|
message: list | str,
|
||||||
|
check_tag: str | None = None,
|
||||||
|
*,
|
||||||
|
background: bool = False,
|
||||||
|
timeout: float | None = None,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
tip_coro = send_message(
|
||||||
|
self.session,
|
||||||
|
message,
|
||||||
|
check_tag,
|
||||||
|
background=background,
|
||||||
|
)
|
||||||
|
if timeout and not background:
|
||||||
|
await asyncio.wait_for(tip_coro, timeout=timeout)
|
||||||
|
else:
|
||||||
|
await tip_coro
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.error("发送权限提示超时", LOGGER_COMMAND, session=self.session)
|
||||||
|
|
||||||
|
async def reduce_gold(
|
||||||
|
self,
|
||||||
|
func: ReservationLike,
|
||||||
|
) -> None:
|
||||||
|
await self.reserve_gold(func)
|
||||||
|
await self.commit_gold()
|
||||||
|
|
||||||
|
async def reserve(
|
||||||
|
self,
|
||||||
|
kind: SideEffectKind,
|
||||||
|
reservation: ReservationLike,
|
||||||
|
*,
|
||||||
|
amount: int = 0,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
await self.release(kind, f"replace_{kind}_reservation")
|
||||||
|
self._reservations[kind] = SideEffectReservation(
|
||||||
|
kind=kind,
|
||||||
|
reservation=reservation,
|
||||||
|
amount=amount,
|
||||||
|
metadata=metadata or {},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def commit(self, kind: SideEffectKind) -> None:
|
||||||
|
record = self._reservations.get(kind)
|
||||||
|
if record is None or record.state != "reserved":
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await _commit_reservation(record.reservation)
|
||||||
|
except Exception:
|
||||||
|
record.reason = "commit_failed"
|
||||||
|
raise
|
||||||
|
record.state = "committed"
|
||||||
|
record.committed_at = time.monotonic()
|
||||||
|
|
||||||
|
async def release(
|
||||||
|
self,
|
||||||
|
kind: SideEffectKind,
|
||||||
|
reason: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
record = self._reservations.get(kind)
|
||||||
|
if record is None or record.state != "reserved":
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await _release_reservation(record.reservation)
|
||||||
|
finally:
|
||||||
|
record.state = "released"
|
||||||
|
record.released_at = time.monotonic()
|
||||||
|
record.reason = reason
|
||||||
|
|
||||||
|
async def reserve_limit(self, reservation: ReservationLike) -> None:
|
||||||
|
await self.reserve("limit", reservation)
|
||||||
|
|
||||||
|
async def commit_limit(
|
||||||
|
self,
|
||||||
|
reservation: ReservationLike | None = None,
|
||||||
|
) -> None:
|
||||||
|
if reservation is not None:
|
||||||
|
await self.reserve_limit(reservation)
|
||||||
|
await self.commit("limit")
|
||||||
|
|
||||||
|
async def release_limit(self, reason: str | None = None) -> None:
|
||||||
|
await self.release("limit", reason)
|
||||||
|
|
||||||
|
async def reserve_gold(
|
||||||
|
self,
|
||||||
|
reservation: ReservationLike,
|
||||||
|
*,
|
||||||
|
amount: int = 0,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
await self.reserve(
|
||||||
|
"gold",
|
||||||
|
reservation,
|
||||||
|
amount=amount,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def commit_gold(self) -> None:
|
||||||
|
await self.commit("gold")
|
||||||
|
|
||||||
|
async def rollback_gold(self, reason: str | None = None) -> None:
|
||||||
|
await self.release("gold", reason)
|
||||||
|
|
||||||
|
async def rollback_all(self, reason: str | None = None) -> None:
|
||||||
|
for kind in list(self._reservations):
|
||||||
|
await self.release(kind, reason)
|
||||||
|
|
||||||
|
async def commit_all(self, *, order: Sequence[str] = ("gold", "limit")) -> None:
|
||||||
|
for name in order:
|
||||||
|
await self.commit(name)
|
||||||
|
self.committed = True
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from zhenxun.services.cache.runtime_cache import (
|
||||||
|
BanMemoryCache,
|
||||||
|
BotMemoryCache,
|
||||||
|
BotSnapshot,
|
||||||
|
GroupMemoryCache,
|
||||||
|
GroupSnapshot,
|
||||||
|
LevelUserMemoryCache,
|
||||||
|
LevelUserSnapshot,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .auth.context import EventContext
|
||||||
|
from .auth_profile import PluginAuthProfile
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nonebot.adapters import Bot
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class AuthSnapshot:
|
||||||
|
context: EventContext
|
||||||
|
plugin: object
|
||||||
|
profile: PluginAuthProfile
|
||||||
|
bot_data: BotSnapshot | None = None
|
||||||
|
group: GroupSnapshot | None = None
|
||||||
|
admin_levels: tuple[LevelUserSnapshot | None, LevelUserSnapshot | None] | None = (
|
||||||
|
None
|
||||||
|
)
|
||||||
|
ban_state: bool | None = None
|
||||||
|
user_balance_loaded: bool = False
|
||||||
|
user_balance: int | None = None
|
||||||
|
cache_misses: frozenset[str] = field(default_factory=frozenset)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def module(self) -> str:
|
||||||
|
return self.profile.module
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_superuser(self) -> bool:
|
||||||
|
return self.context.is_superuser
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user_id(self) -> str:
|
||||||
|
return self.context.user_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def group_id(self) -> str | None:
|
||||||
|
return self.context.group_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def channel_id(self) -> str | None:
|
||||||
|
return self.context.channel_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_ban_cache(self) -> bool:
|
||||||
|
return self.ban_state is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cache_ready(self) -> bool:
|
||||||
|
return not self.cache_misses
|
||||||
|
|
||||||
|
|
||||||
|
async def build_auth_snapshot(
|
||||||
|
*,
|
||||||
|
context: EventContext,
|
||||||
|
plugin: object,
|
||||||
|
profile: PluginAuthProfile,
|
||||||
|
bot: "Bot",
|
||||||
|
skip_ban: bool = False,
|
||||||
|
allow_cache_load: bool = False,
|
||||||
|
) -> AuthSnapshot:
|
||||||
|
event_cache = context.event_cache
|
||||||
|
entity = context.entity
|
||||||
|
cache_misses: set[str] = set()
|
||||||
|
|
||||||
|
bot_data: BotSnapshot | None = None
|
||||||
|
if (
|
||||||
|
event_cache is not None
|
||||||
|
and "bot_data" in event_cache
|
||||||
|
and (event_cache.get("bot_cache_ready") or not allow_cache_load)
|
||||||
|
):
|
||||||
|
bot_data = event_cache.get("bot_data")
|
||||||
|
else:
|
||||||
|
bot_data = BotMemoryCache.get_if_ready(bot.self_id)
|
||||||
|
if bot_data is None:
|
||||||
|
if allow_cache_load:
|
||||||
|
bot_data = await BotMemoryCache.get(bot.self_id)
|
||||||
|
elif not BotMemoryCache.is_loaded():
|
||||||
|
cache_misses.add("bot")
|
||||||
|
if event_cache is not None:
|
||||||
|
event_cache["bot_data"] = bot_data
|
||||||
|
event_cache["bot_cache_ready"] = BotMemoryCache.is_loaded()
|
||||||
|
|
||||||
|
group = None
|
||||||
|
if entity.group_id:
|
||||||
|
if (
|
||||||
|
event_cache is not None
|
||||||
|
and "group" in event_cache
|
||||||
|
and (event_cache.get("group_cache_ready") or not allow_cache_load)
|
||||||
|
):
|
||||||
|
group = event_cache.get("group")
|
||||||
|
else:
|
||||||
|
group = GroupMemoryCache.get_if_ready(entity.group_id, entity.channel_id)
|
||||||
|
if group is None and not GroupMemoryCache.is_loaded():
|
||||||
|
cache_misses.add("group")
|
||||||
|
elif group is None and allow_cache_load:
|
||||||
|
group = await GroupMemoryCache.get(entity.group_id, entity.channel_id)
|
||||||
|
if event_cache is not None:
|
||||||
|
event_cache["group"] = group
|
||||||
|
event_cache["group_cache_ready"] = GroupMemoryCache.is_loaded()
|
||||||
|
|
||||||
|
admin_levels = None
|
||||||
|
if profile.need_admin:
|
||||||
|
if (
|
||||||
|
event_cache is not None
|
||||||
|
and "admin_levels" in event_cache
|
||||||
|
and (event_cache.get("admin_cache_ready") or not allow_cache_load)
|
||||||
|
):
|
||||||
|
admin_levels = event_cache.get("admin_levels")
|
||||||
|
else:
|
||||||
|
admin_levels = LevelUserMemoryCache.get_levels_if_ready(
|
||||||
|
entity.user_id,
|
||||||
|
entity.group_id,
|
||||||
|
)
|
||||||
|
if admin_levels is None:
|
||||||
|
if allow_cache_load:
|
||||||
|
admin_levels = await LevelUserMemoryCache.get_levels(
|
||||||
|
entity.user_id,
|
||||||
|
entity.group_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cache_misses.add("admin_levels")
|
||||||
|
if event_cache is not None:
|
||||||
|
event_cache["admin_levels"] = admin_levels
|
||||||
|
event_cache["admin_cache_ready"] = LevelUserMemoryCache.is_loaded()
|
||||||
|
|
||||||
|
ban_state = None
|
||||||
|
if not skip_ban:
|
||||||
|
if event_cache is not None and "ban_state" in event_cache:
|
||||||
|
ban_state = event_cache.get("ban_state")
|
||||||
|
elif BanMemoryCache.is_loaded():
|
||||||
|
ban_state = BanMemoryCache.is_banned(entity.user_id, entity.group_id)
|
||||||
|
if event_cache is not None:
|
||||||
|
event_cache["ban_state"] = ban_state
|
||||||
|
elif allow_cache_load:
|
||||||
|
await BanMemoryCache.ensure_loaded()
|
||||||
|
ban_state = BanMemoryCache.is_banned(entity.user_id, entity.group_id)
|
||||||
|
if event_cache is not None:
|
||||||
|
event_cache["ban_state"] = ban_state
|
||||||
|
else:
|
||||||
|
cache_misses.add("ban")
|
||||||
|
|
||||||
|
return AuthSnapshot(
|
||||||
|
context=context,
|
||||||
|
plugin=plugin,
|
||||||
|
profile=profile,
|
||||||
|
bot_data=bot_data,
|
||||||
|
group=group,
|
||||||
|
admin_levels=admin_levels,
|
||||||
|
ban_state=ban_state,
|
||||||
|
cache_misses=frozenset(cache_misses),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_or_build_auth_snapshot(
|
||||||
|
*,
|
||||||
|
context: EventContext,
|
||||||
|
plugin: object,
|
||||||
|
profile: PluginAuthProfile,
|
||||||
|
bot: "Bot",
|
||||||
|
skip_ban: bool = False,
|
||||||
|
allow_cache_load: bool = False,
|
||||||
|
) -> AuthSnapshot:
|
||||||
|
event_cache = context.event_cache
|
||||||
|
module = profile.module
|
||||||
|
if event_cache is not None:
|
||||||
|
snapshot_cache = event_cache.setdefault("auth_snapshots", {})
|
||||||
|
cached = snapshot_cache.get(module)
|
||||||
|
if isinstance(cached, AuthSnapshot):
|
||||||
|
if not (allow_cache_load and cached.cache_misses):
|
||||||
|
return cached
|
||||||
|
snapshot = await build_auth_snapshot(
|
||||||
|
context=context,
|
||||||
|
plugin=plugin,
|
||||||
|
profile=profile,
|
||||||
|
bot=bot,
|
||||||
|
skip_ban=skip_ban,
|
||||||
|
allow_cache_load=allow_cache_load,
|
||||||
|
)
|
||||||
|
if event_cache is not None:
|
||||||
|
event_cache.setdefault("auth_snapshots", {})[module] = snapshot
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["AuthSnapshot", "build_auth_snapshot", "get_or_build_auth_snapshot"]
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from .auth.config import WARNING_THRESHOLD
|
||||||
|
|
||||||
|
|
||||||
|
class HookTraceRecorder:
|
||||||
|
def __init__(self, start_time: float) -> None:
|
||||||
|
self._start_time = start_time
|
||||||
|
self._enabled = False
|
||||||
|
self._data: dict[str, str] = {}
|
||||||
|
|
||||||
|
def _ensure_enabled(self) -> bool:
|
||||||
|
if self._enabled:
|
||||||
|
return True
|
||||||
|
if time.time() - self._start_time <= WARNING_THRESHOLD:
|
||||||
|
return False
|
||||||
|
self._enabled = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
def set(self, key: str, value: str) -> None:
|
||||||
|
if self._ensure_enabled():
|
||||||
|
self._data[key] = value
|
||||||
|
|
||||||
|
def setdefault(self, key: str, value: str) -> None:
|
||||||
|
if self._ensure_enabled():
|
||||||
|
self._data.setdefault(key, value)
|
||||||
|
|
||||||
|
def contains(self, key: str) -> bool:
|
||||||
|
return key in self._data
|
||||||
|
|
||||||
|
def snapshot(self) -> dict[str, str]:
|
||||||
|
return self._data if self._enabled else {}
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["HookTraceRecorder"]
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from zhenxun.models.plugin_info import PluginInfo
|
||||||
|
from zhenxun.models.user_console import UserConsole
|
||||||
|
|
||||||
|
from .auth.context import PermissionContext
|
||||||
|
from .auth_policy import PolicyContext
|
||||||
|
from .auth_profile import PluginAuthProfile
|
||||||
|
from .auth_snapshot import AuthSnapshot
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class AuthPreparation:
|
||||||
|
plugin: PluginInfo
|
||||||
|
user: UserConsole | None
|
||||||
|
profile: PluginAuthProfile
|
||||||
|
snapshot: AuthSnapshot
|
||||||
|
permission_context: PermissionContext
|
||||||
|
policy_context: PolicyContext
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class AuthPolicyFlags:
|
||||||
|
should_return_allowed: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class AuthLaneContext:
|
||||||
|
lane: str = "passive_light"
|
||||||
|
scope_key: str = ""
|
||||||
|
queue_size: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_guaranteed(self) -> bool:
|
||||||
|
return self.lane.startswith("command_") or self.lane == "system"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class EventDispatchContext:
|
||||||
|
event_type: str
|
||||||
|
plain_text: str = ""
|
||||||
|
raw_text: str = ""
|
||||||
|
trie_command_text: str = ""
|
||||||
|
trie_raw_command: str = ""
|
||||||
|
text_candidates: tuple[str, ...] = ()
|
||||||
|
to_me: bool = False
|
||||||
|
has_url: bool = False
|
||||||
|
has_image: bool = False
|
||||||
|
is_command_like: bool = False
|
||||||
|
route_modules: set[str] = field(default_factory=set)
|
||||||
|
ai_route_modules: set[str] = field(default_factory=set)
|
||||||
|
ai_route_heads: set[str] = field(default_factory=set)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AuthLaneContext",
|
||||||
|
"AuthPolicyFlags",
|
||||||
|
"AuthPreparation",
|
||||||
|
"EventDispatchContext",
|
||||||
|
]
|
||||||
@@ -65,7 +65,7 @@ async def handle_api_result(
|
|||||||
if not Config.get_config("hook", "RECORD_BOT_SENT_MESSAGES"):
|
if not Config.get_config("hook", "RECORD_BOT_SENT_MESSAGES"):
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
await BotMessageStore.create(
|
await BotMessageStore.append_buffered(
|
||||||
bot_id=bot.self_id,
|
bot_id=bot.self_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
group_id=group_id,
|
group_id=group_id,
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ from zhenxun.models.group_console import GroupConsole
|
|||||||
from zhenxun.models.group_member_info import GroupInfoUser
|
from zhenxun.models.group_member_info import GroupInfoUser
|
||||||
from zhenxun.models.level_user import LevelUser
|
from zhenxun.models.level_user import LevelUser
|
||||||
from zhenxun.models.plugin_info import PluginInfo
|
from zhenxun.models.plugin_info import PluginInfo
|
||||||
|
from zhenxun.services.hot_query_cache import (
|
||||||
|
invalidate_group_members,
|
||||||
|
invalidate_member_names,
|
||||||
|
)
|
||||||
from zhenxun.services.log import logger
|
from zhenxun.services.log import logger
|
||||||
from zhenxun.utils.common_utils import CommonUtils
|
from zhenxun.utils.common_utils import CommonUtils
|
||||||
from zhenxun.utils.enum import RequestHandleType
|
from zhenxun.utils.enum import RequestHandleType
|
||||||
@@ -82,6 +86,8 @@ async def _refresh_member_info_async(
|
|||||||
"platform": platform,
|
"platform": platform,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
await invalidate_group_members(group_id, [user_id])
|
||||||
|
await invalidate_member_names([user_id])
|
||||||
|
|
||||||
|
|
||||||
class GroupManager:
|
class GroupManager:
|
||||||
@@ -333,6 +339,8 @@ class GroupManager:
|
|||||||
"platform": platform,
|
"platform": platform,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
await invalidate_group_members(group_id, [user_id])
|
||||||
|
await invalidate_member_names([user_id])
|
||||||
task = asyncio.create_task(
|
task = asyncio.create_task(
|
||||||
_refresh_member_info_async(
|
_refresh_member_info_async(
|
||||||
bot, str(group_id), str(user_id), _normalize_platform(platform)
|
bot, str(group_id), str(user_id), _normalize_platform(platform)
|
||||||
@@ -400,6 +408,8 @@ class GroupManager:
|
|||||||
user_name = f"{user_id}"
|
user_name = f"{user_id}"
|
||||||
if user:
|
if user:
|
||||||
await user.delete()
|
await user.delete()
|
||||||
|
await invalidate_group_members(group_id, [user_id])
|
||||||
|
await invalidate_member_names([user_id])
|
||||||
logger.info(
|
logger.info(
|
||||||
f"名称: {user_name} 退出群聊",
|
f"名称: {user_name} 退出群聊",
|
||||||
"group_decrease_handle",
|
"group_decrease_handle",
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ from nonebot_plugin_uninfo import Uninfo
|
|||||||
from zhenxun.models.friend_user import FriendUser
|
from zhenxun.models.friend_user import FriendUser
|
||||||
from zhenxun.models.group_console import GroupConsole
|
from zhenxun.models.group_console import GroupConsole
|
||||||
from zhenxun.models.group_member_info import GroupInfoUser
|
from zhenxun.models.group_member_info import GroupInfoUser
|
||||||
|
from zhenxun.services.hot_query_cache import (
|
||||||
|
invalidate_group_members,
|
||||||
|
invalidate_member_names,
|
||||||
|
)
|
||||||
from zhenxun.services.log import logger
|
from zhenxun.services.log import logger
|
||||||
from zhenxun.utils.platform import PlatformUtils
|
from zhenxun.utils.platform import PlatformUtils
|
||||||
|
|
||||||
@@ -27,6 +31,8 @@ async def _(session: Uninfo):
|
|||||||
group_id=session.group.id,
|
group_id=session.group.id,
|
||||||
platform=PlatformUtils.get_platform(session),
|
platform=PlatformUtils.get_platform(session),
|
||||||
)
|
)
|
||||||
|
await invalidate_group_members(session.group.id, [session.user.id])
|
||||||
|
await invalidate_member_names([session.user.id])
|
||||||
elif not await FriendUser.exists(user_id=session.user.id, platform=platform):
|
elif not await FriendUser.exists(user_id=session.user.id, platform=platform):
|
||||||
await FriendUser.create(
|
await FriendUser.create(
|
||||||
user_id=session.user.id, platform=PlatformUtils.get_platform(session)
|
user_id=session.user.id, platform=PlatformUtils.get_platform(session)
|
||||||
|
|||||||
@@ -17,11 +17,11 @@ from zhenxun import ui
|
|||||||
from zhenxun.configs.config import BotConfig
|
from zhenxun.configs.config import BotConfig
|
||||||
from zhenxun.models.friend_user import FriendUser
|
from zhenxun.models.friend_user import FriendUser
|
||||||
from zhenxun.models.goods_info import GoodsInfo
|
from zhenxun.models.goods_info import GoodsInfo
|
||||||
from zhenxun.models.group_member_info import GroupInfoUser
|
|
||||||
from zhenxun.models.user_console import UserConsole
|
from zhenxun.models.user_console import UserConsole
|
||||||
from zhenxun.models.user_props_log import UserPropsLog
|
from zhenxun.models.user_props_log import UserPropsLog
|
||||||
from zhenxun.services import avatar_service
|
from zhenxun.services import avatar_service
|
||||||
from zhenxun.services.buffered_writers import append_user_gold_log
|
from zhenxun.services.buffered_writers import append_user_gold_log
|
||||||
|
from zhenxun.services.hot_query_cache import get_group_user_ids, get_member_names
|
||||||
from zhenxun.services.log import logger
|
from zhenxun.services.log import logger
|
||||||
from zhenxun.ui.models import ImageCell, TextCell
|
from zhenxun.ui.models import ImageCell, TextCell
|
||||||
from zhenxun.utils.enum import GoldHandle, PropHandle
|
from zhenxun.utils.enum import GoldHandle, PropHandle
|
||||||
@@ -96,9 +96,7 @@ class ShopParam(BaseModel):
|
|||||||
async def gold_rank(session: Uninfo, group_id: str | None, num: int) -> bytes | str:
|
async def gold_rank(session: Uninfo, group_id: str | None, num: int) -> bytes | str:
|
||||||
query = UserConsole
|
query = UserConsole
|
||||||
if group_id:
|
if group_id:
|
||||||
uid_list = await GroupInfoUser.filter(group_id=group_id).values_list(
|
uid_list = await get_group_user_ids(group_id)
|
||||||
"user_id", flat=True
|
|
||||||
)
|
|
||||||
if uid_list:
|
if uid_list:
|
||||||
query = query.filter(user_id__in=uid_list)
|
query = query.filter(user_id__in=uid_list)
|
||||||
user_list = await query.annotate().order_by("-gold").values_list("user_id", "gold")
|
user_list = await query.annotate().order_by("-gold").values_list("user_id", "gold")
|
||||||
@@ -115,11 +113,7 @@ async def gold_rank(session: Uninfo, group_id: str | None, num: int) -> bytes |
|
|||||||
)
|
)
|
||||||
uid2name = {user[0]: user[1] for user in friend_user}
|
uid2name = {user[0]: user[1] for user in friend_user}
|
||||||
if diff_id := set(user_id_list).difference(set(uid2name.keys())):
|
if diff_id := set(user_id_list).difference(set(uid2name.keys())):
|
||||||
group_user = await GroupInfoUser.filter(user_id__in=diff_id).values_list(
|
uid2name.update(await get_member_names(diff_id))
|
||||||
"user_id", "user_name"
|
|
||||||
)
|
|
||||||
for g in group_user:
|
|
||||||
uid2name[g[0]] = g[1]
|
|
||||||
column_name = ["排名", "-", "名称", "金币", "平台"]
|
column_name = ["排名", "-", "名称", "金币", "平台"]
|
||||||
data_list = []
|
data_list = []
|
||||||
platform = PlatformUtils.get_platform(session)
|
platform = PlatformUtils.get_platform(session)
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ from zhenxun import ui
|
|||||||
from zhenxun.configs.path_config import IMAGE_PATH
|
from zhenxun.configs.path_config import IMAGE_PATH
|
||||||
from zhenxun.models.friend_user import FriendUser
|
from zhenxun.models.friend_user import FriendUser
|
||||||
from zhenxun.models.goods_info import GoodsInfo
|
from zhenxun.models.goods_info import GoodsInfo
|
||||||
from zhenxun.models.group_member_info import GroupInfoUser
|
|
||||||
from zhenxun.models.sign_log import SignLog
|
from zhenxun.models.sign_log import SignLog
|
||||||
from zhenxun.models.sign_user import SignUser
|
from zhenxun.models.sign_user import SignUser
|
||||||
from zhenxun.models.user_console import UserConsole
|
from zhenxun.models.user_console import UserConsole
|
||||||
from zhenxun.services.avatar_service import avatar_service
|
from zhenxun.services.avatar_service import avatar_service
|
||||||
from zhenxun.services.buffered_writers import append_user_gold_log
|
from zhenxun.services.buffered_writers import append_user_gold_log
|
||||||
|
from zhenxun.services.hot_query_cache import get_group_user_ids, get_member_names
|
||||||
from zhenxun.services.log import logger
|
from zhenxun.services.log import logger
|
||||||
from zhenxun.ui.models import ImageCell, TextCell
|
from zhenxun.ui.models import ImageCell, TextCell
|
||||||
from zhenxun.utils.enum import GoldHandle
|
from zhenxun.utils.enum import GoldHandle
|
||||||
@@ -52,9 +52,7 @@ class SignManage:
|
|||||||
"""
|
"""
|
||||||
query = SignUser
|
query = SignUser
|
||||||
if group_id:
|
if group_id:
|
||||||
user_list = await GroupInfoUser.filter(group_id=group_id).values_list(
|
user_list = await get_group_user_ids(group_id)
|
||||||
"user_id", flat=True
|
|
||||||
)
|
|
||||||
if user_list:
|
if user_list:
|
||||||
query = query.filter(user_id__in=user_list)
|
query = query.filter(user_id__in=user_list)
|
||||||
user_list = (
|
user_list = (
|
||||||
@@ -76,11 +74,7 @@ class SignManage:
|
|||||||
)
|
)
|
||||||
uid2name = {f[0]: f[1] for f in friend_list}
|
uid2name = {f[0]: f[1] for f in friend_list}
|
||||||
if diff_id := set(user_id_list).difference(set(uid2name.keys())):
|
if diff_id := set(user_id_list).difference(set(uid2name.keys())):
|
||||||
group_user = await GroupInfoUser.filter(user_id__in=diff_id).values_list(
|
uid2name.update(await get_member_names(diff_id))
|
||||||
"user_id", "user_name"
|
|
||||||
)
|
|
||||||
for g in group_user:
|
|
||||||
uid2name[g[0]] = g[1]
|
|
||||||
data_list = []
|
data_list = []
|
||||||
platform = PlatformUtils.get_platform(session)
|
platform = PlatformUtils.get_platform(session)
|
||||||
for i, user in enumerate(user_list):
|
for i, user in enumerate(user_list):
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ async def _generate_html_card(
|
|||||||
"pages/builtin/sign",
|
"pages/builtin/sign",
|
||||||
data=card_data,
|
data=card_data,
|
||||||
clip_selector=".wrapper",
|
clip_selector=".wrapper",
|
||||||
clip_padding=8,
|
clip_padding=0,
|
||||||
disable_animations=True,
|
disable_animations=True,
|
||||||
screenshot_scale="css",
|
screenshot_scale="css",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,15 +1,61 @@
|
|||||||
from tortoise.functions import Count
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from zhenxun.models.group_console import GroupConsole
|
from zhenxun.models.group_console import GroupConsole
|
||||||
from zhenxun.models.group_member_info import GroupInfoUser
|
|
||||||
from zhenxun.models.plugin_info import PluginInfo
|
from zhenxun.models.plugin_info import PluginInfo
|
||||||
from zhenxun.models.statistics import Statistics
|
from zhenxun.services.hot_query_cache import (
|
||||||
|
get_member_name,
|
||||||
|
get_statistics_plugin_counts_cached,
|
||||||
|
)
|
||||||
from zhenxun.utils.echart_utils import ChartUtils
|
from zhenxun.utils.echart_utils import ChartUtils
|
||||||
from zhenxun.utils.echart_utils.models import Barh
|
from zhenxun.utils.echart_utils.models import Barh
|
||||||
from zhenxun.utils.enum import PluginType
|
from zhenxun.utils.enum import PluginType
|
||||||
from zhenxun.utils.time_utils import TimeUtils
|
from zhenxun.utils.time_utils import TimeUtils
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _StatisticsPeriod:
|
||||||
|
title: str
|
||||||
|
start_time: datetime | None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_statistics_period(search_type: str | None) -> _StatisticsPeriod:
|
||||||
|
if search_type == "day":
|
||||||
|
return _StatisticsPeriod("日(1天)", TimeUtils.get_day_start())
|
||||||
|
if search_type == "week":
|
||||||
|
return _StatisticsPeriod(
|
||||||
|
"周(7天)",
|
||||||
|
TimeUtils.get_day_start(
|
||||||
|
datetime.now(TimeUtils.DEFAULT_TIMEZONE) - timedelta(days=6)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if search_type == "month":
|
||||||
|
return _StatisticsPeriod(
|
||||||
|
"月(30天)",
|
||||||
|
TimeUtils.get_day_start(
|
||||||
|
datetime.now(TimeUtils.DEFAULT_TIMEZONE) - timedelta(days=29)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return _StatisticsPeriod("", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_statistics_title(
|
||||||
|
*,
|
||||||
|
target_name: str | None,
|
||||||
|
is_global: bool,
|
||||||
|
period_title: str,
|
||||||
|
) -> str:
|
||||||
|
title = f"{period_title}功能调用统计" if period_title else "功能调用统计"
|
||||||
|
prefixes: list[str] = []
|
||||||
|
if target_name:
|
||||||
|
prefixes.append(target_name)
|
||||||
|
if is_global:
|
||||||
|
prefixes.append("全局")
|
||||||
|
if prefixes:
|
||||||
|
return f"{' '.join(prefixes)} {title}"
|
||||||
|
return title
|
||||||
|
|
||||||
|
|
||||||
class StatisticsManage:
|
class StatisticsManage:
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_statistics(
|
async def get_statistics(
|
||||||
@@ -20,55 +66,49 @@ class StatisticsManage:
|
|||||||
user_id: str | None = None,
|
user_id: str | None = None,
|
||||||
group_id: str | None = None,
|
group_id: str | None = None,
|
||||||
):
|
):
|
||||||
day = None
|
period = _get_statistics_period(search_type)
|
||||||
day_type = ""
|
|
||||||
if search_type == "day":
|
|
||||||
day = 1
|
|
||||||
day_type = "日"
|
|
||||||
elif search_type == "month":
|
|
||||||
day = 30
|
|
||||||
day_type = "月"
|
|
||||||
elif search_type == "week":
|
|
||||||
day = 7
|
|
||||||
day_type = "周"
|
|
||||||
if day_type:
|
|
||||||
day_type += f"({day}天)"
|
|
||||||
title = ""
|
|
||||||
if user_id:
|
if user_id:
|
||||||
"""查用户"""
|
"""查用户"""
|
||||||
query = GroupInfoUser.filter(user_id=user_id)
|
user_name = await get_member_name(user_id, group_id)
|
||||||
if group_id:
|
title = _build_statistics_title(
|
||||||
query = query.filter(group_id=group_id)
|
target_name=user_name or user_id,
|
||||||
user = await query.first()
|
is_global=is_global and not group_id,
|
||||||
title = f"{user.user_name if user else user_id} {day_type}功能调用统计"
|
period_title=period.title,
|
||||||
|
)
|
||||||
elif group_id:
|
elif group_id:
|
||||||
"""查群组"""
|
"""查群组"""
|
||||||
group = await GroupConsole.get_group(group_id=group_id)
|
group = await GroupConsole.get_group(group_id=group_id)
|
||||||
title = f"{group.group_name if group else group_id} {day_type}功能调用统计"
|
title = _build_statistics_title(
|
||||||
|
target_name=group.group_name if group else group_id,
|
||||||
|
is_global=False,
|
||||||
|
period_title=period.title,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
title = "功能调用统计"
|
title = _build_statistics_title(
|
||||||
|
target_name=None,
|
||||||
|
is_global=is_global,
|
||||||
|
period_title=period.title,
|
||||||
|
)
|
||||||
if is_global and not user_id:
|
if is_global and not user_id:
|
||||||
title = f"全局 {title}"
|
return await cls.get_global_statistics(
|
||||||
return await cls.get_global_statistics(plugin_name, day, title)
|
plugin_name, period.start_time, title
|
||||||
|
)
|
||||||
if user_id:
|
if user_id:
|
||||||
return await cls.get_my_statistics(user_id, group_id, day, title)
|
return await cls.get_my_statistics(
|
||||||
|
user_id, group_id, period.start_time, title
|
||||||
|
)
|
||||||
if group_id:
|
if group_id:
|
||||||
return await cls.get_group_statistics(group_id, day, title)
|
return await cls.get_group_statistics(group_id, period.start_time, title)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_global_statistics(
|
async def get_global_statistics(
|
||||||
cls, plugin_name: str | None, day: int | None, title: str
|
cls, plugin_name: str | None, start_time: datetime | None, title: str
|
||||||
) -> bytes | str:
|
) -> bytes | str:
|
||||||
query = Statistics
|
data_list = await get_statistics_plugin_counts_cached(
|
||||||
if plugin_name:
|
"global",
|
||||||
query = query.filter(plugin_name=plugin_name)
|
plugin_name=plugin_name,
|
||||||
if day:
|
start_time=start_time,
|
||||||
query = query.filter(create_time__gte=TimeUtils.get_day_start())
|
|
||||||
data_list = (
|
|
||||||
await query.annotate(count=Count("id"))
|
|
||||||
.group_by("plugin_name")
|
|
||||||
.values_list("plugin_name", "count")
|
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
await cls.__build_image(data_list, title)
|
await cls.__build_image(data_list, title)
|
||||||
@@ -78,17 +118,18 @@ class StatisticsManage:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_my_statistics(
|
async def get_my_statistics(
|
||||||
cls, user_id: str, group_id: str | None, day: int | None, title: str
|
cls,
|
||||||
|
user_id: str,
|
||||||
|
group_id: str | None,
|
||||||
|
start_time: datetime | None,
|
||||||
|
title: str,
|
||||||
):
|
):
|
||||||
query = Statistics.filter(user_id=user_id)
|
data_list = await get_statistics_plugin_counts_cached(
|
||||||
if group_id:
|
"user",
|
||||||
query = query.filter(group_id=group_id)
|
plugin_name=None,
|
||||||
if day:
|
start_time=start_time,
|
||||||
query = query.filter(create_time__gte=TimeUtils.get_day_start())
|
user_id=user_id,
|
||||||
data_list = (
|
group_id=group_id,
|
||||||
await query.annotate(count=Count("id"))
|
|
||||||
.group_by("plugin_name")
|
|
||||||
.values_list("plugin_name", "count")
|
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
await cls.__build_image(data_list, title)
|
await cls.__build_image(data_list, title)
|
||||||
@@ -97,14 +138,14 @@ class StatisticsManage:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_group_statistics(cls, group_id: str, day: int | None, title: str):
|
async def get_group_statistics(
|
||||||
query = Statistics.filter(group_id=group_id)
|
cls, group_id: str, start_time: datetime | None, title: str
|
||||||
if day:
|
):
|
||||||
query = query.filter(create_time__gte=TimeUtils.get_day_start())
|
data_list = await get_statistics_plugin_counts_cached(
|
||||||
data_list = (
|
"group",
|
||||||
await query.annotate(count=Count("id"))
|
plugin_name=None,
|
||||||
.group_by("plugin_name")
|
start_time=start_time,
|
||||||
.values_list("plugin_name", "count")
|
group_id=group_id,
|
||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
await cls.__build_image(data_list, title)
|
await cls.__build_image(data_list, title)
|
||||||
|
|||||||
+134
-4
@@ -23,6 +23,121 @@ RESTART_POLL_INTERVAL = 0.5
|
|||||||
WORKER_SOFT_EXIT_TIMEOUT = 15.0
|
WORKER_SOFT_EXIT_TIMEOUT = 15.0
|
||||||
WORKER_TERMINATE_TIMEOUT = 5.0
|
WORKER_TERMINATE_TIMEOUT = 5.0
|
||||||
WORKER_KILL_TIMEOUT = 5.0
|
WORKER_KILL_TIMEOUT = 5.0
|
||||||
|
ENV_EXAMPLE_FILE = ".env.example"
|
||||||
|
ENV_DEV_FILE = ".env.dev"
|
||||||
|
|
||||||
|
|
||||||
|
def _env_assignment_key(line: str, *, include_commented: bool = False) -> str | None:
|
||||||
|
stripped = line.strip()
|
||||||
|
if include_commented and stripped.startswith("#"):
|
||||||
|
stripped = stripped[1:].lstrip()
|
||||||
|
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
||||||
|
return None
|
||||||
|
key = stripped.split("=", 1)[0].strip()
|
||||||
|
return key if key.replace("_", "").isalnum() else None
|
||||||
|
|
||||||
|
|
||||||
|
def _env_key(line: str) -> str | None:
|
||||||
|
return _env_assignment_key(line)
|
||||||
|
|
||||||
|
|
||||||
|
def _env_block_key(block: list[str]) -> str | None:
|
||||||
|
for line in block:
|
||||||
|
if key := _env_key(line):
|
||||||
|
return key
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _env_block_anchor_key(block: list[str]) -> str | None:
|
||||||
|
for line in block:
|
||||||
|
if key := _env_assignment_key(line, include_commented=True):
|
||||||
|
return key
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _split_env_blocks(lines: list[str]) -> list[tuple[int, list[str]]]:
|
||||||
|
blocks: list[tuple[int, list[str]]] = []
|
||||||
|
current: list[str] = []
|
||||||
|
start_index = 0
|
||||||
|
for index, line in enumerate(lines):
|
||||||
|
if line.strip():
|
||||||
|
if not current:
|
||||||
|
start_index = index
|
||||||
|
current.append(line)
|
||||||
|
elif current:
|
||||||
|
blocks.append((start_index, current))
|
||||||
|
current = []
|
||||||
|
|
||||||
|
if current:
|
||||||
|
blocks.append((start_index, current))
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def _find_env_block_start(lines: list[str], key: str) -> int | None:
|
||||||
|
for start_index, block in _split_env_blocks(lines):
|
||||||
|
if _env_block_anchor_key(block) == key:
|
||||||
|
return start_index
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_env_block_before(
|
||||||
|
lines: list[str],
|
||||||
|
index: int,
|
||||||
|
block: list[str],
|
||||||
|
) -> list[str]:
|
||||||
|
insert_block = block.copy()
|
||||||
|
if index > 0 and lines[index - 1].strip():
|
||||||
|
insert_block.insert(0, "\n")
|
||||||
|
if index < len(lines) and insert_block and insert_block[-1].strip():
|
||||||
|
insert_block.append("\n")
|
||||||
|
return lines[:index] + insert_block + lines[index:]
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_env_missing_items(project_root: Path) -> None:
|
||||||
|
"""Copy missing .env keys from .env.example without touching existing values."""
|
||||||
|
example_path = project_root / ENV_EXAMPLE_FILE
|
||||||
|
env_path = project_root / ENV_DEV_FILE
|
||||||
|
if not example_path.exists():
|
||||||
|
return
|
||||||
|
if not env_path.exists():
|
||||||
|
env_path.write_text(example_path.read_text(encoding="utf-8"), encoding="utf-8")
|
||||||
|
_launcher_log("已根据 .env.example 生成 .env.dev")
|
||||||
|
return
|
||||||
|
|
||||||
|
example_lines = example_path.read_text(encoding="utf-8").splitlines(keepends=True)
|
||||||
|
env_lines = env_path.read_text(encoding="utf-8").splitlines(keepends=True)
|
||||||
|
example_blocks = _split_env_blocks(example_lines)
|
||||||
|
existing_keys = {key for line in env_lines if (key := _env_key(line))}
|
||||||
|
missing_blocks: list[tuple[int, list[str]]] = []
|
||||||
|
|
||||||
|
for block_index, (_, block) in enumerate(example_blocks):
|
||||||
|
key = _env_block_key(block)
|
||||||
|
if key and key not in existing_keys:
|
||||||
|
missing_blocks.append((block_index, block))
|
||||||
|
|
||||||
|
if not missing_blocks:
|
||||||
|
return
|
||||||
|
|
||||||
|
updated_lines = env_lines
|
||||||
|
added_keys: list[str] = []
|
||||||
|
for block_index, block in missing_blocks:
|
||||||
|
key = _env_block_key(block)
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
anchor_index = len(updated_lines)
|
||||||
|
for _, next_block in example_blocks[block_index + 1 :]:
|
||||||
|
next_key = _env_block_anchor_key(next_block)
|
||||||
|
if not next_key:
|
||||||
|
continue
|
||||||
|
if (found := _find_env_block_start(updated_lines, next_key)) is not None:
|
||||||
|
anchor_index = found
|
||||||
|
break
|
||||||
|
updated_lines = _insert_env_block_before(updated_lines, anchor_index, block)
|
||||||
|
existing_keys.add(key)
|
||||||
|
added_keys.append(key)
|
||||||
|
|
||||||
|
env_path.write_text("".join(updated_lines), encoding="utf-8")
|
||||||
|
_launcher_log(f"已补齐 .env.dev 缺失配置: {', '.join(added_keys)}")
|
||||||
|
|
||||||
|
|
||||||
def _launcher_log(message: str) -> None:
|
def _launcher_log(message: str) -> None:
|
||||||
@@ -53,7 +168,8 @@ def _ensure_project_root() -> Path:
|
|||||||
|
|
||||||
def _run_worker() -> None:
|
def _run_worker() -> None:
|
||||||
"""启动 Bot worker(必须在项目目录下执行)"""
|
"""启动 Bot worker(必须在项目目录下执行)"""
|
||||||
_ensure_project_root()
|
project_root = _ensure_project_root()
|
||||||
|
_sync_env_missing_items(project_root)
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import platform
|
import platform
|
||||||
@@ -91,18 +207,32 @@ def _run_worker() -> None:
|
|||||||
f"使用 {htmlrender_browser_channel} 作为 htmlrender 驱动启动..."
|
f"使用 {htmlrender_browser_channel} 作为 htmlrender 驱动启动..."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
nonebot.init(htmlrender_browser_channel=htmlrender_browser_channel)
|
||||||
|
|
||||||
from nonebot.adapters.onebot.v11 import Adapter as OneBotV11Adapter
|
from nonebot.adapters.onebot.v11 import Adapter as OneBotV11Adapter
|
||||||
|
|
||||||
nonebot.init(htmlrender_browser_channel=htmlrender_browser_channel)
|
from zhenxun.configs.config import BotConfig
|
||||||
|
|
||||||
driver = nonebot.get_driver()
|
driver = nonebot.get_driver()
|
||||||
driver.register_adapter(OneBotV11Adapter)
|
driver.register_adapter(OneBotV11Adapter)
|
||||||
|
enabled_adapters = ["OneBot V11"]
|
||||||
|
|
||||||
|
if BotConfig.qq_adapter_load:
|
||||||
|
try:
|
||||||
|
from nonebot.adapters.qq import Adapter as QQAdapter # type: ignore
|
||||||
|
except ImportError as e:
|
||||||
|
raise RuntimeError(
|
||||||
|
"QQ_ADAPTER_LOAD=True 但未安装 nonebot-adapter-qq,"
|
||||||
|
"请安装后再开启 QQ 官方适配器。"
|
||||||
|
) from e
|
||||||
|
driver.register_adapter(QQAdapter)
|
||||||
|
enabled_adapters.append("QQ")
|
||||||
|
|
||||||
|
nonebot.logger.info(f"已启用适配器: {', '.join(enabled_adapters)}")
|
||||||
|
|
||||||
nonebot.load_plugins("zhenxun/builtin_plugins")
|
nonebot.load_plugins("zhenxun/builtin_plugins")
|
||||||
nonebot.load_plugins("zhenxun/plugins")
|
nonebot.load_plugins("zhenxun/plugins")
|
||||||
|
|
||||||
from zhenxun.configs.config import BotConfig
|
|
||||||
|
|
||||||
for ext in BotConfig.ext_path:
|
for ext in BotConfig.ext_path:
|
||||||
ext = ext.strip()
|
ext = ext.strip()
|
||||||
if ext:
|
if ext:
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ class BotSetting(BaseModel):
|
|||||||
"""官bot id:账号id"""
|
"""官bot id:账号id"""
|
||||||
ext_path: list[str] = Field(default_factory=list)
|
ext_path: list[str] = Field(default_factory=list)
|
||||||
"""第三方插件路径"""
|
"""第三方插件路径"""
|
||||||
|
qq_adapter_load: bool = False
|
||||||
|
"""是否加载 QQ 官方适配器"""
|
||||||
|
|
||||||
def get_qbot_uid(self, qbot_id: str) -> str | None:
|
def get_qbot_uid(self, qbot_id: str) -> str | None:
|
||||||
"""获取官bot账号id
|
"""获取官bot账号id
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections import deque
|
||||||
|
import contextlib
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from zhenxun.services.log import logger
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .bot_message_store import BotMessageStore
|
||||||
|
|
||||||
|
LOG_COMMAND = "BotMessageStore"
|
||||||
|
|
||||||
|
_BUFFER_MAX_RETAIN = 20_000
|
||||||
|
_FLUSH_TRIGGER_SIZE = 64
|
||||||
|
_FLUSH_BATCH_SIZE = 500
|
||||||
|
_FLUSH_INTERVAL_SECONDS = 5.0
|
||||||
|
_DROP_LOG_INTERVAL_SECONDS = 10.0
|
||||||
|
|
||||||
|
_buffer: deque[BotMessageStore] = deque()
|
||||||
|
_buffer_lock = asyncio.Lock()
|
||||||
|
_flush_lock = asyncio.Lock()
|
||||||
|
_flush_task: asyncio.Task[None] | None = None
|
||||||
|
_dropped = 0
|
||||||
|
_last_drop_log_at = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_flush_task() -> None:
|
||||||
|
global _flush_task
|
||||||
|
if _flush_task is not None and not _flush_task.done():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
return
|
||||||
|
_flush_task = loop.create_task(_flush_loop())
|
||||||
|
|
||||||
|
|
||||||
|
def _record_drop() -> None:
|
||||||
|
global _dropped, _last_drop_log_at
|
||||||
|
_dropped += 1
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - _last_drop_log_at < _DROP_LOG_INTERVAL_SECONDS:
|
||||||
|
return
|
||||||
|
_last_drop_log_at = now
|
||||||
|
logger.warning(
|
||||||
|
f"bot_message_store buffer full, dropped {_dropped} records, "
|
||||||
|
f"backlog={len(_buffer)}",
|
||||||
|
LOG_COMMAND,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _flush_loop() -> None:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(_FLUSH_INTERVAL_SECONDS)
|
||||||
|
try:
|
||||||
|
await flush_bot_message_store_buffer("定时")
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("定时批量写入 Bot 发送记录失败", LOG_COMMAND, e=exc)
|
||||||
|
|
||||||
|
|
||||||
|
async def append_bot_message_store_record(record: BotMessageStore) -> None:
|
||||||
|
_ensure_flush_task()
|
||||||
|
async with _buffer_lock:
|
||||||
|
if len(_buffer) >= _BUFFER_MAX_RETAIN:
|
||||||
|
_buffer.popleft()
|
||||||
|
_record_drop()
|
||||||
|
_buffer.append(record)
|
||||||
|
should_flush = len(_buffer) >= _FLUSH_TRIGGER_SIZE and not _flush_lock.locked()
|
||||||
|
if should_flush:
|
||||||
|
await flush_bot_message_store_buffer("缓冲区触发")
|
||||||
|
|
||||||
|
|
||||||
|
async def flush_bot_message_store_buffer(reason: str) -> int:
|
||||||
|
from .bot_message_store import BotMessageStore
|
||||||
|
|
||||||
|
async with _flush_lock:
|
||||||
|
written = 0
|
||||||
|
while True:
|
||||||
|
batch: list[BotMessageStore] = []
|
||||||
|
async with _buffer_lock:
|
||||||
|
while _buffer and len(batch) < _FLUSH_BATCH_SIZE:
|
||||||
|
batch.append(_buffer.popleft())
|
||||||
|
if not batch:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
await BotMessageStore.bulk_create(batch, batch_size=_FLUSH_BATCH_SIZE)
|
||||||
|
except Exception as exc:
|
||||||
|
async with _buffer_lock:
|
||||||
|
retain_count = max(_BUFFER_MAX_RETAIN - len(_buffer), 0)
|
||||||
|
for record in reversed(batch[-retain_count:]):
|
||||||
|
_buffer.appendleft(record)
|
||||||
|
logger.error(f"{reason}批量写入 Bot 发送记录失败", LOG_COMMAND, e=exc)
|
||||||
|
return written
|
||||||
|
written += len(batch)
|
||||||
|
if written:
|
||||||
|
logger.debug(f"{reason}批量写入 Bot 发送记录 {written} 条", LOG_COMMAND)
|
||||||
|
return written
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_bot_message_store_buffer() -> int:
|
||||||
|
global _flush_task
|
||||||
|
task = _flush_task
|
||||||
|
_flush_task = None
|
||||||
|
if task is not None:
|
||||||
|
task.cancel()
|
||||||
|
with contextlib.suppress(BaseException):
|
||||||
|
await task
|
||||||
|
return await flush_bot_message_store_buffer("关闭")
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
|
from tortoise import fields
|
||||||
|
|
||||||
|
from zhenxun.services.db_context import Model
|
||||||
|
|
||||||
|
|
||||||
|
class AuthDecisionLog(Model):
|
||||||
|
id = fields.IntField(pk=True, generated=True, auto_increment=True)
|
||||||
|
"""自增id"""
|
||||||
|
bot_id = fields.CharField(255, null=True, description="Bot ID")
|
||||||
|
"""Bot ID"""
|
||||||
|
platform = fields.CharField(64, null=True, description="平台")
|
||||||
|
"""平台"""
|
||||||
|
group_id = fields.CharField(255, null=True, description="群组id")
|
||||||
|
"""群组id"""
|
||||||
|
user_id = fields.CharField(255, null=True, description="用户id")
|
||||||
|
"""用户id"""
|
||||||
|
module = fields.CharField(255, null=True, description="插件模块")
|
||||||
|
"""插件模块"""
|
||||||
|
effect = fields.CharField(32, description="决策结果")
|
||||||
|
"""决策结果 allow/deny/skip/defer/error"""
|
||||||
|
reason = fields.CharField(255, null=True, description="原因")
|
||||||
|
"""原因"""
|
||||||
|
shadow_effect = fields.CharField(32, null=True, description="影子决策结果")
|
||||||
|
"""影子决策结果"""
|
||||||
|
shadow_reason = fields.CharField(255, null=True, description="影子决策原因")
|
||||||
|
"""影子决策原因"""
|
||||||
|
side_effect_state = fields.TextField(null=True, description="副作用状态")
|
||||||
|
"""副作用状态 JSON 摘要"""
|
||||||
|
latency_ms = fields.FloatField(default=0, description="耗时毫秒")
|
||||||
|
"""耗时毫秒"""
|
||||||
|
overloaded = fields.BooleanField(default=False, description="是否过载")
|
||||||
|
"""是否过载"""
|
||||||
|
create_time = fields.DatetimeField(auto_now_add=True, description="创建时间")
|
||||||
|
"""创建时间"""
|
||||||
|
|
||||||
|
class Meta: # pyright: ignore [reportIncompatibleVariableOverride]
|
||||||
|
table = "auth_decision_log"
|
||||||
|
table_description = "权限决策追加审计日志"
|
||||||
|
indexes: ClassVar = [
|
||||||
|
("create_time",),
|
||||||
|
("module", "create_time"),
|
||||||
|
("effect", "create_time"),
|
||||||
|
]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _run_script(cls):
|
||||||
|
return []
|
||||||
@@ -229,8 +229,4 @@ class BanConsole(Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _run_script(cls):
|
async def _run_script(cls):
|
||||||
return [
|
return []
|
||||||
"CREATE INDEX idx_ban_console_user_id ON ban_console(user_id);",
|
|
||||||
"CREATE INDEX idx_ban_console_group_id ON ban_console(group_id);",
|
|
||||||
"ALTER TABLE ban_console ADD COLUMN ban_reason TEXT DEFAULT NULL;",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ from typing import Literal, overload
|
|||||||
|
|
||||||
from tortoise import fields
|
from tortoise import fields
|
||||||
|
|
||||||
from zhenxun.configs.config import BotConfig
|
|
||||||
from zhenxun.services.cache.runtime_cache import BotMemoryCache
|
from zhenxun.services.cache.runtime_cache import BotMemoryCache
|
||||||
from zhenxun.services.db_context import Model
|
from zhenxun.services.db_context import Model
|
||||||
|
from zhenxun.services.db_context.schema_ops import AlterColumnType, RenameColumn
|
||||||
from zhenxun.utils.enum import CacheType
|
from zhenxun.utils.enum import CacheType
|
||||||
|
|
||||||
|
|
||||||
@@ -470,32 +470,9 @@ class BotConsole(Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _run_script(cls):
|
async def _run_script(cls):
|
||||||
db_type = (BotConfig.get_sql_type() or "").lower()
|
return [
|
||||||
|
RenameColumn("bot_console", "block_plugin", "block_plugins"),
|
||||||
scripts = [
|
RenameColumn("bot_console", "block_task", "block_tasks"),
|
||||||
"ALTER TABLE bot_console RENAME COLUMN block_plugin TO block_plugins;",
|
AlterColumnType("bot_console", "block_plugins", "TEXT"),
|
||||||
"ALTER TABLE bot_console RENAME COLUMN block_task TO block_tasks;",
|
AlterColumnType("bot_console", "block_tasks", "TEXT"),
|
||||||
"ALTER TABLE bot_console ADD available_plugins text default '';",
|
|
||||||
"ALTER TABLE bot_console ADD available_tasks text default '';",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
if "postgres" in db_type:
|
|
||||||
scripts.extend(
|
|
||||||
[
|
|
||||||
"ALTER TABLE bot_console ALTER COLUMN block_plugins TYPE TEXT;",
|
|
||||||
"ALTER TABLE bot_console ALTER COLUMN block_tasks TYPE TEXT;",
|
|
||||||
"ALTER TABLE bot_console ALTER COLUMN available_plugins TYPE TEXT;",
|
|
||||||
"ALTER TABLE bot_console ALTER COLUMN available_tasks TYPE TEXT;",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
elif "mysql" in db_type:
|
|
||||||
scripts.extend(
|
|
||||||
[
|
|
||||||
"ALTER TABLE bot_console MODIFY COLUMN block_plugins TEXT;",
|
|
||||||
"ALTER TABLE bot_console MODIFY COLUMN block_tasks TEXT;",
|
|
||||||
"ALTER TABLE bot_console MODIFY COLUMN available_plugins TEXT;",
|
|
||||||
"ALTER TABLE bot_console MODIFY COLUMN available_tasks TEXT;",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
return scripts
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ from tortoise import fields
|
|||||||
from zhenxun.services.db_context import Model
|
from zhenxun.services.db_context import Model
|
||||||
from zhenxun.utils.enum import BotSentType
|
from zhenxun.utils.enum import BotSentType
|
||||||
|
|
||||||
|
from ._bot_message_buffer import append_bot_message_store_record
|
||||||
|
|
||||||
|
|
||||||
class BotMessageStore(Model):
|
class BotMessageStore(Model):
|
||||||
id = fields.IntField(pk=True, generated=True, auto_increment=True)
|
id = fields.IntField(pk=True, generated=True, auto_increment=True)
|
||||||
@@ -27,3 +29,27 @@ class BotMessageStore(Model):
|
|||||||
class Meta: # pyright: ignore [reportIncompatibleVariableOverride]
|
class Meta: # pyright: ignore [reportIncompatibleVariableOverride]
|
||||||
table = "bot_message_store"
|
table = "bot_message_store"
|
||||||
table_description = "Bot发送消息列表"
|
table_description = "Bot发送消息列表"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def append_buffered(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
bot_id: str | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
group_id: str | None = None,
|
||||||
|
sent_type: BotSentType,
|
||||||
|
text: str | None = None,
|
||||||
|
plain_text: str | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
await append_bot_message_store_record(
|
||||||
|
cls(
|
||||||
|
bot_id=bot_id,
|
||||||
|
user_id=user_id,
|
||||||
|
group_id=group_id,
|
||||||
|
sent_type=sent_type,
|
||||||
|
text=text,
|
||||||
|
plain_text=plain_text,
|
||||||
|
platform=platform,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import ClassVar, Literal
|
from typing import Any, ClassVar, Literal
|
||||||
from typing_extensions import Self
|
from typing_extensions import Self
|
||||||
|
|
||||||
from tortoise import fields
|
from tortoise import fields
|
||||||
from tortoise.functions import Count
|
from tortoise.expressions import Q
|
||||||
|
|
||||||
from zhenxun.services.db_context import Model
|
from zhenxun.services.db_context import Model
|
||||||
|
from zhenxun.services.db_context.schema_ops import AlterColumnType, RenameColumn
|
||||||
|
|
||||||
|
|
||||||
class ChatHistory(Model):
|
class ChatHistory(Model):
|
||||||
@@ -35,6 +36,41 @@ class ChatHistory(Model):
|
|||||||
("user_id", "group_id"),
|
("user_id", "group_id"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _platform_from_scope(cls, platform_scope: str | None) -> str | None:
|
||||||
|
"""Map the new fine-grained scope back to the legacy platform column."""
|
||||||
|
if not platform_scope:
|
||||||
|
return None
|
||||||
|
scope = str(platform_scope).lower()
|
||||||
|
if scope in {"qq", "qq_client", "qq_api"} or scope.startswith("qq_"):
|
||||||
|
return "qq"
|
||||||
|
if "onebot" in scope:
|
||||||
|
return "qq"
|
||||||
|
return scope
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def scoped_query(cls, platform_scope: str | None = None, **filters: Any):
|
||||||
|
"""Return a chat-history query compatible with platform_scope callers.
|
||||||
|
|
||||||
|
chat_history currently stores the coarse legacy ``platform`` column rather
|
||||||
|
than a dedicated ``platform_scope`` column, so this method intentionally
|
||||||
|
stays as a thin compatibility shim.
|
||||||
|
"""
|
||||||
|
query = cls.filter(**filters)
|
||||||
|
if not platform_scope:
|
||||||
|
return query
|
||||||
|
if "platform" in filters or any(k.startswith("platform__") for k in filters):
|
||||||
|
return query
|
||||||
|
|
||||||
|
platform = cls._platform_from_scope(platform_scope)
|
||||||
|
if not platform:
|
||||||
|
return query
|
||||||
|
if platform == "qq" and str(platform_scope).lower() in {"qq", "qq_client"}:
|
||||||
|
return query.filter(
|
||||||
|
Q(platform=platform) | Q(platform__isnull=True) | Q(platform="")
|
||||||
|
)
|
||||||
|
return query.filter(platform=platform)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_group_msg_rank(
|
async def get_group_msg_rank(
|
||||||
cls,
|
cls,
|
||||||
@@ -42,7 +78,7 @@ class ChatHistory(Model):
|
|||||||
limit: int = 10,
|
limit: int = 10,
|
||||||
order: str = "DESC",
|
order: str = "DESC",
|
||||||
date_scope: tuple[datetime, datetime] | None = None,
|
date_scope: tuple[datetime, datetime] | None = None,
|
||||||
) -> list[Self]:
|
) -> list[tuple[str, int]]:
|
||||||
"""获取排行数据
|
"""获取排行数据
|
||||||
|
|
||||||
参数:
|
参数:
|
||||||
@@ -51,18 +87,9 @@ class ChatHistory(Model):
|
|||||||
order: 排序类型,desc,des
|
order: 排序类型,desc,des
|
||||||
date_scope: 日期范围
|
date_scope: 日期范围
|
||||||
"""
|
"""
|
||||||
o = "-" if order == "DESC" else ""
|
from zhenxun.services.hot_query_cache import get_chat_history_rank_cached
|
||||||
query = cls.filter(group_id=gid) if gid else cls
|
|
||||||
if date_scope:
|
return await get_chat_history_rank_cached(cls, gid, limit, order, date_scope)
|
||||||
filter_scope = (date_scope[0].isoformat(" "), date_scope[1].isoformat(" "))
|
|
||||||
query = query.filter(create_time__range=filter_scope)
|
|
||||||
return list(
|
|
||||||
await query.annotate(count=Count("user_id"))
|
|
||||||
.order_by(f"{o}count")
|
|
||||||
.group_by("user_id")
|
|
||||||
.limit(limit)
|
|
||||||
.values_list("user_id", "count")
|
|
||||||
) # type: ignore
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_group_first_msg_datetime(
|
async def get_group_first_msg_datetime(
|
||||||
@@ -73,22 +100,21 @@ class ChatHistory(Model):
|
|||||||
参数:
|
参数:
|
||||||
group_id: 群组id
|
group_id: 群组id
|
||||||
"""
|
"""
|
||||||
if group_id:
|
from zhenxun.services.hot_query_cache import (
|
||||||
message = (
|
get_chat_history_first_msg_datetime_cached,
|
||||||
await cls.filter(group_id=group_id).order_by("create_time").first()
|
)
|
||||||
)
|
|
||||||
else:
|
return await get_chat_history_first_msg_datetime_cached(cls, group_id)
|
||||||
message = await cls.all().order_by("create_time").first()
|
|
||||||
return message.create_time if message else None
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_message(
|
async def get_message(
|
||||||
cls,
|
cls,
|
||||||
uid: str,
|
uid: str | None,
|
||||||
gid: str,
|
gid: str | None,
|
||||||
type_: Literal["user", "group"],
|
type_: Literal["user", "group"],
|
||||||
msg_type: Literal["private", "group"] | None = None,
|
msg_type: Literal["private", "group"] | None = None,
|
||||||
days: int | tuple[datetime, datetime] | None = None,
|
days: int | tuple[datetime, datetime] | None = None,
|
||||||
|
platform_scope: str | None = None,
|
||||||
) -> list[Self]:
|
) -> list[Self]:
|
||||||
"""获取消息查询query
|
"""获取消息查询query
|
||||||
|
|
||||||
@@ -98,15 +124,16 @@ class ChatHistory(Model):
|
|||||||
type_: 类型,私聊或群聊
|
type_: 类型,私聊或群聊
|
||||||
msg_type: 消息类型,用户或群聊
|
msg_type: 消息类型,用户或群聊
|
||||||
days: 限制日期
|
days: 限制日期
|
||||||
|
platform_scope: 兼容细粒度平台作用域
|
||||||
"""
|
"""
|
||||||
if type_ == "user":
|
if type_ == "user":
|
||||||
query = cls.filter(user_id=uid)
|
query = cls.scoped_query(platform_scope=platform_scope, user_id=uid)
|
||||||
if msg_type == "private":
|
if msg_type == "private":
|
||||||
query = query.filter(group_id__isnull=True)
|
query = query.filter(group_id__isnull=True)
|
||||||
elif msg_type == "group":
|
elif msg_type == "group":
|
||||||
query = query.filter(group_id__not_isnull=True)
|
query = query.filter(group_id__not_isnull=True)
|
||||||
else:
|
else:
|
||||||
query = cls.filter(group_id=gid)
|
query = cls.scoped_query(platform_scope=platform_scope, group_id=gid)
|
||||||
if uid:
|
if uid:
|
||||||
query = query.filter(user_id=uid)
|
query = query.filter(user_id=uid)
|
||||||
if days:
|
if days:
|
||||||
@@ -128,13 +155,15 @@ class ChatHistory(Model):
|
|||||||
# 允许 plain_text 为空
|
# 允许 plain_text 为空
|
||||||
"alter table chat_history alter plain_text drop not null;",
|
"alter table chat_history alter plain_text drop not null;",
|
||||||
# 将user_id改为user_id
|
# 将user_id改为user_id
|
||||||
"ALTER TABLE chat_history RENAME COLUMN user_qq TO user_id;",
|
RenameColumn("chat_history", "user_qq", "user_id"),
|
||||||
"ALTER TABLE chat_history "
|
AlterColumnType(
|
||||||
"ALTER COLUMN user_id TYPE character varying(255);",
|
"chat_history",
|
||||||
"ALTER TABLE chat_history "
|
"user_id",
|
||||||
"ALTER COLUMN group_id TYPE character varying(255);",
|
{"postgres": "character varying(255)", "mysql": "VARCHAR(255)"},
|
||||||
# 添加bot_id字段
|
),
|
||||||
"ALTER TABLE chat_history ADD bot_id VARCHAR(255);",
|
AlterColumnType(
|
||||||
"ALTER TABLE chat_history ALTER COLUMN bot_id TYPE character varying(255);",
|
"chat_history",
|
||||||
"ALTER TABLE chat_history ADD COLUMN platform character varying(255);",
|
"group_id",
|
||||||
|
{"postgres": "character varying(255)", "mysql": "VARCHAR(255)"},
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ from zhenxun.configs.config import BotConfig
|
|||||||
from zhenxun.models.group_console import GroupConsole
|
from zhenxun.models.group_console import GroupConsole
|
||||||
from zhenxun.services.db_context import Model
|
from zhenxun.services.db_context import Model
|
||||||
from zhenxun.services.log import logger
|
from zhenxun.services.log import logger
|
||||||
from zhenxun.utils.common_utils import SqlUtils
|
|
||||||
from zhenxun.utils.enum import RequestHandleType, RequestType
|
from zhenxun.utils.enum import RequestHandleType, RequestType
|
||||||
from zhenxun.utils.exception import NotFoundError
|
from zhenxun.utils.exception import NotFoundError
|
||||||
from zhenxun.utils.manager.bot_profile_manager import BotProfileManager
|
from zhenxun.utils.manager.bot_profile_manager import BotProfileManager
|
||||||
@@ -173,6 +172,4 @@ class FgRequest(Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _run_script(cls):
|
async def _run_script(cls):
|
||||||
return [
|
return []
|
||||||
SqlUtils.add_column("fg_request", "message_ids", "character varying(255)")
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from tortoise import fields
|
from tortoise import fields
|
||||||
|
|
||||||
from zhenxun.services.db_context import Model
|
from zhenxun.services.db_context import Model
|
||||||
|
from zhenxun.services.db_context.schema_ops import DropColumn
|
||||||
|
|
||||||
|
|
||||||
class FriendUser(Model):
|
class FriendUser(Model):
|
||||||
@@ -30,4 +31,4 @@ class FriendUser(Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _run_script(cls):
|
def _run_script(cls):
|
||||||
return ["ALTER TABLE friend_users DROP COLUMN nickname;"]
|
return [DropColumn("friend_users", "nickname")]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import uuid
|
|||||||
from tortoise import fields
|
from tortoise import fields
|
||||||
|
|
||||||
from zhenxun.services.db_context import Model
|
from zhenxun.services.db_context import Model
|
||||||
|
from zhenxun.services.db_context.schema_ops import DropColumn
|
||||||
|
|
||||||
|
|
||||||
class GoodsInfo(Model):
|
class GoodsInfo(Model):
|
||||||
@@ -158,11 +159,6 @@ class GoodsInfo(Model):
|
|||||||
@classmethod
|
@classmethod
|
||||||
async def _run_script(cls):
|
async def _run_script(cls):
|
||||||
return [
|
return [
|
||||||
"ALTER TABLE goods_info ADD uuid VARCHAR(255);",
|
|
||||||
"ALTER TABLE goods_info ADD daily_limit Integer DEFAULT 0;",
|
|
||||||
"ALTER TABLE goods_info ADD is_passive boolean DEFAULT False;",
|
|
||||||
"ALTER TABLE goods_info ADD icon VARCHAR(255);",
|
|
||||||
# 删除 daily_purchase_limit 字段
|
# 删除 daily_purchase_limit 字段
|
||||||
"ALTER TABLE goods_info DROP daily_purchase_limit;",
|
DropColumn("goods_info", "daily_purchase_limit"),
|
||||||
"ALTER TABLE goods_info ADD partition VARCHAR(255);",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ from typing_extensions import Self
|
|||||||
from tortoise import fields
|
from tortoise import fields
|
||||||
from tortoise.backends.base.client import BaseDBAsyncClient
|
from tortoise.backends.base.client import BaseDBAsyncClient
|
||||||
|
|
||||||
from zhenxun.configs.config import BotConfig
|
|
||||||
from zhenxun.models.plugin_info import PluginInfo
|
from zhenxun.models.plugin_info import PluginInfo
|
||||||
from zhenxun.models.task_info import TaskInfo
|
from zhenxun.models.task_info import TaskInfo
|
||||||
from zhenxun.services.cache import CacheRoot
|
from zhenxun.services.cache import CacheRoot
|
||||||
from zhenxun.services.cache.runtime_cache import GroupMemoryCache
|
from zhenxun.services.cache.runtime_cache import GroupMemoryCache
|
||||||
from zhenxun.services.data_access import DataAccess
|
from zhenxun.services.data_access import DataAccess
|
||||||
from zhenxun.services.db_context import Model
|
from zhenxun.services.db_context import Model
|
||||||
|
from zhenxun.services.db_context.schema_ops import AlterColumnType, CreateIndex
|
||||||
from zhenxun.utils.enum import CacheType, DbLockType, PluginType
|
from zhenxun.utils.enum import CacheType, DbLockType, PluginType
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -516,49 +516,13 @@ class GroupConsole(Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _run_script(cls):
|
def _run_script(cls):
|
||||||
db_type = (BotConfig.get_sql_type() or "").lower()
|
return [
|
||||||
|
CreateIndex(
|
||||||
scripts = [
|
"group_console",
|
||||||
"ALTER TABLE group_console ADD superuser_block_plugin"
|
("group_id",),
|
||||||
" Text NOT NULL DEFAULT '';",
|
name="idx_group_console_group_null_channel",
|
||||||
"ALTER TABLE group_console ADD superuser_block_task"
|
where="channel_id IS NULL",
|
||||||
" Text NOT NULL DEFAULT '';",
|
|
||||||
"CREATE INDEX idx_group_console_group_id ON group_console(group_id);",
|
|
||||||
(
|
|
||||||
"CREATE INDEX idx_group_console_group_null_channel ON "
|
|
||||||
"group_console(group_id) WHERE channel_id IS NULL;"
|
|
||||||
),
|
),
|
||||||
|
AlterColumnType("group_console", "block_plugin", "TEXT"),
|
||||||
|
AlterColumnType("group_console", "block_task", "TEXT"),
|
||||||
]
|
]
|
||||||
|
|
||||||
if "postgres" in db_type:
|
|
||||||
scripts.extend(
|
|
||||||
[
|
|
||||||
("ALTER TABLE group_console ALTER COLUMN block_plugin TYPE TEXT;"),
|
|
||||||
(
|
|
||||||
"ALTER TABLE group_console ALTER COLUMN "
|
|
||||||
"superuser_block_plugin TYPE TEXT;"
|
|
||||||
),
|
|
||||||
("ALTER TABLE group_console ALTER COLUMN block_task TYPE TEXT;"),
|
|
||||||
(
|
|
||||||
"ALTER TABLE group_console ALTER COLUMN "
|
|
||||||
"superuser_block_task TYPE TEXT;"
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
elif "mysql" in db_type:
|
|
||||||
scripts.extend(
|
|
||||||
[
|
|
||||||
("ALTER TABLE group_console MODIFY COLUMN block_plugin TEXT;"),
|
|
||||||
(
|
|
||||||
"ALTER TABLE group_console MODIFY COLUMN "
|
|
||||||
"superuser_block_plugin TEXT;"
|
|
||||||
),
|
|
||||||
("ALTER TABLE group_console MODIFY COLUMN block_task TEXT;"),
|
|
||||||
(
|
|
||||||
"ALTER TABLE group_console MODIFY COLUMN "
|
|
||||||
"superuser_block_task TEXT;"
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
return scripts
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ from typing import ClassVar
|
|||||||
|
|
||||||
from tortoise import fields
|
from tortoise import fields
|
||||||
|
|
||||||
from zhenxun.configs.config import BotConfig
|
|
||||||
from zhenxun.services.db_context import Model
|
from zhenxun.services.db_context import Model
|
||||||
|
from zhenxun.services.db_context.schema_ops import AlterColumnType, DropColumn
|
||||||
|
|
||||||
|
|
||||||
class GroupInfoUser(Model):
|
class GroupInfoUser(Model):
|
||||||
@@ -35,9 +35,9 @@ class GroupInfoUser(Model):
|
|||||||
参数:
|
参数:
|
||||||
group_id: 群号
|
group_id: 群号
|
||||||
"""
|
"""
|
||||||
return set(
|
from zhenxun.services.hot_query_cache import get_group_user_ids
|
||||||
await cls.filter(group_id=group_id).values_list("user_id", flat=True)
|
|
||||||
) # type: ignore
|
return await get_group_user_ids(group_id)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_user_all_group(cls, user_id: str) -> list[str]:
|
async def get_user_all_group(cls, user_id: str) -> list[str]:
|
||||||
@@ -46,60 +46,24 @@ class GroupInfoUser(Model):
|
|||||||
参数:
|
参数:
|
||||||
user_id: 用户id
|
user_id: 用户id
|
||||||
"""
|
"""
|
||||||
return list(
|
from zhenxun.services.hot_query_cache import get_user_group_ids
|
||||||
await cls.filter(user_id=user_id).values_list("group_id", flat=True)
|
|
||||||
) # type: ignore
|
return await get_user_group_ids(user_id)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _run_script(cls):
|
async def _run_script(cls):
|
||||||
db_type = (BotConfig.get_sql_type() or "").lower()
|
return [
|
||||||
scripts = ["ALTER TABLE group_info_users DROP COLUMN nickname;"]
|
DropColumn("group_info_users", "nickname"),
|
||||||
|
AlterColumnType(
|
||||||
if "postgres" in db_type:
|
"group_info_users",
|
||||||
scripts.extend(
|
"user_id",
|
||||||
[
|
{"postgres": "character varying(255)", "mysql": "VARCHAR(255)"},
|
||||||
(
|
nullable=False,
|
||||||
"ALTER TABLE group_info_users ADD COLUMN IF NOT EXISTS "
|
),
|
||||||
"platform character varying(255);"
|
AlterColumnType(
|
||||||
),
|
"group_info_users",
|
||||||
(
|
"group_id",
|
||||||
"ALTER TABLE group_info_users ALTER COLUMN user_id "
|
{"postgres": "character varying(255)", "mysql": "VARCHAR(255)"},
|
||||||
"TYPE character varying(255) USING user_id::character varying;"
|
nullable=False,
|
||||||
),
|
),
|
||||||
(
|
]
|
||||||
"ALTER TABLE group_info_users ALTER COLUMN group_id "
|
|
||||||
"TYPE character varying(255) USING group_id::character varying;"
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"ALTER TABLE group_info_users ALTER COLUMN platform "
|
|
||||||
"TYPE character varying(255) USING platform::character varying;"
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
elif "mysql" in db_type:
|
|
||||||
scripts.extend(
|
|
||||||
[
|
|
||||||
(
|
|
||||||
"ALTER TABLE group_info_users ADD COLUMN "
|
|
||||||
"platform VARCHAR(255) NULL;"
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"ALTER TABLE group_info_users MODIFY COLUMN "
|
|
||||||
"user_id VARCHAR(255) NOT NULL;"
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"ALTER TABLE group_info_users MODIFY COLUMN "
|
|
||||||
"group_id VARCHAR(255) NOT NULL;"
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"ALTER TABLE group_info_users MODIFY COLUMN "
|
|
||||||
"platform VARCHAR(255) NULL;"
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
elif "sqlite" in db_type:
|
|
||||||
scripts.append(
|
|
||||||
"ALTER TABLE group_info_users ADD COLUMN platform VARCHAR(255);"
|
|
||||||
)
|
|
||||||
|
|
||||||
return scripts
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from tortoise import fields
|
|||||||
|
|
||||||
from zhenxun.services.cache.runtime_cache import LevelUserMemoryCache
|
from zhenxun.services.cache.runtime_cache import LevelUserMemoryCache
|
||||||
from zhenxun.services.db_context import Model
|
from zhenxun.services.db_context import Model
|
||||||
|
from zhenxun.services.db_context.schema_ops import AlterColumnType, RenameColumn
|
||||||
from zhenxun.utils.enum import CacheType
|
from zhenxun.utils.enum import CacheType
|
||||||
|
|
||||||
|
|
||||||
@@ -151,9 +152,16 @@ class LevelUser(Model):
|
|||||||
async def _run_script(cls):
|
async def _run_script(cls):
|
||||||
return [
|
return [
|
||||||
# 将user_id改为user_id
|
# 将user_id改为user_id
|
||||||
"ALTER TABLE level_users RENAME COLUMN user_qq TO user_id;",
|
RenameColumn("level_users", "user_qq", "user_id"),
|
||||||
"ALTER TABLE level_users ALTER COLUMN user_id TYPE character varying(255);",
|
AlterColumnType(
|
||||||
|
"level_users",
|
||||||
|
"user_id",
|
||||||
|
{"postgres": "character varying(255)", "mysql": "VARCHAR(255)"},
|
||||||
|
),
|
||||||
# 将user_id字段类型改为character varying(255)
|
# 将user_id字段类型改为character varying(255)
|
||||||
"ALTER TABLE level_users "
|
AlterColumnType(
|
||||||
"ALTER COLUMN group_id TYPE character varying(255);",
|
"level_users",
|
||||||
|
"group_id",
|
||||||
|
{"postgres": "character varying(255)", "mysql": "VARCHAR(255)"},
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -206,12 +206,4 @@ class PluginInfo(Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _run_script(cls):
|
async def _run_script(cls):
|
||||||
return [
|
return []
|
||||||
"ALTER TABLE plugin_info ADD COLUMN parent character varying(255);",
|
|
||||||
"ALTER TABLE plugin_info ADD COLUMN is_show boolean DEFAULT true;",
|
|
||||||
"ALTER TABLE plugin_info ADD COLUMN ignore_prompt boolean DEFAULT false;",
|
|
||||||
"ALTER TABLE plugin_info ADD COLUMN impression float DEFAULT 0;",
|
|
||||||
"CREATE INDEX idx_plugin_info_module ON plugin_info(module);",
|
|
||||||
"ALTER TABLE plugin_info ADD COLUMN ignore_statistics"
|
|
||||||
" boolean DEFAULT false;",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
|
from tortoise import fields
|
||||||
|
|
||||||
|
from zhenxun.services.db_context import Model
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeBackpressureLog(Model):
|
||||||
|
id = fields.IntField(pk=True, generated=True, auto_increment=True)
|
||||||
|
"""自增id"""
|
||||||
|
scope_key = fields.CharField(255, null=True, description="作用域")
|
||||||
|
"""作用域"""
|
||||||
|
reason = fields.CharField(255, null=True, description="原因")
|
||||||
|
"""原因"""
|
||||||
|
lane = fields.CharField(64, null=True, description="调度通道")
|
||||||
|
"""调度通道"""
|
||||||
|
action = fields.CharField(64, description="处理动作")
|
||||||
|
"""处理动作 execute/skip/defer/signal"""
|
||||||
|
queue_size = fields.IntField(default=0, description="队列长度")
|
||||||
|
"""队列长度"""
|
||||||
|
active_count = fields.IntField(default=0, description="活跃数量")
|
||||||
|
"""活跃数量"""
|
||||||
|
duration_ms = fields.FloatField(default=0, description="持续耗时毫秒")
|
||||||
|
"""持续耗时毫秒"""
|
||||||
|
create_time = fields.DatetimeField(auto_now_add=True, description="创建时间")
|
||||||
|
"""创建时间"""
|
||||||
|
|
||||||
|
class Meta: # pyright: ignore [reportIncompatibleVariableOverride]
|
||||||
|
table = "runtime_backpressure_log"
|
||||||
|
table_description = "运行时背压追加审计日志"
|
||||||
|
indexes: ClassVar = [
|
||||||
|
("create_time",),
|
||||||
|
("scope_key", "create_time"),
|
||||||
|
("lane", "create_time"),
|
||||||
|
]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _run_script(cls):
|
||||||
|
return []
|
||||||
@@ -3,6 +3,7 @@ from typing import ClassVar
|
|||||||
from tortoise import fields
|
from tortoise import fields
|
||||||
|
|
||||||
from zhenxun.services.db_context import Model
|
from zhenxun.services.db_context import Model
|
||||||
|
from zhenxun.services.db_context.schema_ops import AlterColumnType, RenameColumn
|
||||||
|
|
||||||
|
|
||||||
class Statistics(Model):
|
class Statistics(Model):
|
||||||
@@ -32,9 +33,16 @@ class Statistics(Model):
|
|||||||
@classmethod
|
@classmethod
|
||||||
async def _run_script(cls):
|
async def _run_script(cls):
|
||||||
return [
|
return [
|
||||||
"ALTER TABLE statistics RENAME COLUMN user_qq TO user_id;",
|
RenameColumn("statistics", "user_qq", "user_id"),
|
||||||
# 将user_qq改为user_id
|
# 将user_qq改为user_id
|
||||||
"ALTER TABLE statistics ALTER COLUMN user_id TYPE character varying(255);",
|
AlterColumnType(
|
||||||
"ALTER TABLE statistics ALTER COLUMN group_id TYPE character varying(255);",
|
"statistics",
|
||||||
"ALTER TABLE statistics ADD bot_id Text DEFAULT '';",
|
"user_id",
|
||||||
|
{"postgres": "character varying(255)", "mysql": "VARCHAR(255)"},
|
||||||
|
),
|
||||||
|
AlterColumnType(
|
||||||
|
"statistics",
|
||||||
|
"group_id",
|
||||||
|
{"postgres": "character varying(255)", "mysql": "VARCHAR(255)"},
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -101,8 +101,4 @@ class TaskInfo(Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _run_script(cls):
|
async def _run_script(cls):
|
||||||
return [
|
return []
|
||||||
"ALTER TABLE task_info ADD default_status boolean DEFAULT true;",
|
|
||||||
"ALTER TABLE task_info ADD load_status boolean DEFAULT false;",
|
|
||||||
# 默认状态
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
from dataclasses import dataclass
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
|
||||||
from tortoise import fields
|
from tortoise import fields
|
||||||
from tortoise.exceptions import IntegrityError
|
from tortoise.exceptions import IntegrityError
|
||||||
|
from tortoise.expressions import F
|
||||||
|
from tortoise.transactions import in_transaction
|
||||||
|
|
||||||
from zhenxun.models.goods_info import GoodsInfo
|
from zhenxun.models.goods_info import GoodsInfo
|
||||||
from zhenxun.services.buffered_writers import append_user_gold_log
|
from zhenxun.services.buffered_writers import append_user_gold_log
|
||||||
@@ -11,6 +14,41 @@ from zhenxun.utils.enum import CacheType, GoldHandle
|
|||||||
from zhenxun.utils.exception import GoodsNotFound, InsufficientGold
|
from zhenxun.utils.exception import GoodsNotFound, InsufficientGold
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class GoldReservation:
|
||||||
|
user_id: str
|
||||||
|
gold: int
|
||||||
|
handle: GoldHandle
|
||||||
|
plugin_module: str
|
||||||
|
platform: str | None = None
|
||||||
|
committed: bool = False
|
||||||
|
released: bool = False
|
||||||
|
|
||||||
|
async def commit(self) -> None:
|
||||||
|
if self.committed or self.released:
|
||||||
|
return
|
||||||
|
await append_user_gold_log(
|
||||||
|
user_id=self.user_id,
|
||||||
|
gold=self.gold,
|
||||||
|
handle=self.handle,
|
||||||
|
source=self.plugin_module,
|
||||||
|
)
|
||||||
|
self.committed = True
|
||||||
|
|
||||||
|
async def release(self) -> None:
|
||||||
|
if self.released or self.committed:
|
||||||
|
return
|
||||||
|
self.released = True
|
||||||
|
async with in_transaction() as connection:
|
||||||
|
updated = (
|
||||||
|
await UserConsole.filter(user_id=self.user_id)
|
||||||
|
.using_db(connection)
|
||||||
|
.update(gold=F("gold") + self.gold)
|
||||||
|
)
|
||||||
|
if updated:
|
||||||
|
await UserConsole.invalidate_user_cache(self.user_id)
|
||||||
|
|
||||||
|
|
||||||
class UserConsole(Model):
|
class UserConsole(Model):
|
||||||
id = fields.IntField(pk=True, generated=True, auto_increment=True)
|
id = fields.IntField(pk=True, generated=True, auto_increment=True)
|
||||||
"""自增id"""
|
"""自增id"""
|
||||||
@@ -150,6 +188,51 @@ class UserConsole(Model):
|
|||||||
user_id=user_id, gold=gold, handle=handle, source=plugin_module
|
user_id=user_id, gold=gold, handle=handle, source=plugin_module
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def reserve_gold(
|
||||||
|
cls,
|
||||||
|
user_id: str,
|
||||||
|
gold: int,
|
||||||
|
handle: GoldHandle,
|
||||||
|
plugin_module: str,
|
||||||
|
platform: str | None = None,
|
||||||
|
) -> GoldReservation:
|
||||||
|
"""预扣金币;插件最终未执行时可 release 补偿。"""
|
||||||
|
async with in_transaction() as connection:
|
||||||
|
user = await cls.filter(user_id=user_id).using_db(connection).get_or_none()
|
||||||
|
if user is None:
|
||||||
|
try:
|
||||||
|
user = await cls.create(
|
||||||
|
using_db=connection,
|
||||||
|
user_id=user_id,
|
||||||
|
platform=platform,
|
||||||
|
uid=await cls.get_new_uid(),
|
||||||
|
)
|
||||||
|
except IntegrityError:
|
||||||
|
user = await cls.filter(user_id=user_id).using_db(connection).get()
|
||||||
|
if user.gold < gold:
|
||||||
|
raise InsufficientGold()
|
||||||
|
updated = (
|
||||||
|
await cls.filter(user_id=user_id, gold__gte=gold)
|
||||||
|
.using_db(connection)
|
||||||
|
.update(gold=F("gold") - gold)
|
||||||
|
)
|
||||||
|
if not updated:
|
||||||
|
raise InsufficientGold()
|
||||||
|
return GoldReservation(
|
||||||
|
user_id=user_id,
|
||||||
|
gold=gold,
|
||||||
|
handle=handle,
|
||||||
|
plugin_module=plugin_module,
|
||||||
|
platform=platform,
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def invalidate_user_cache(cls, user_id: str) -> None:
|
||||||
|
from zhenxun.services.cache import CacheRoot
|
||||||
|
|
||||||
|
await CacheRoot.invalidate_cache(CacheType.USERS, user_id)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def add_props(
|
async def add_props(
|
||||||
cls, user_id: str, goods_uuid: str, num: int = 1, platform: str | None = None
|
cls, user_id: str, goods_uuid: str, num: int = 1, platform: str | None = None
|
||||||
@@ -223,7 +306,4 @@ class UserConsole(Model):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def _run_script(cls):
|
async def _run_script(cls):
|
||||||
return [
|
return []
|
||||||
"CREATE INDEX idx_user_console_user_id ON user_console(user_id);",
|
|
||||||
"CREATE INDEX idx_user_console_uid ON user_console(uid);",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -0,0 +1,547 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections import deque
|
||||||
|
import contextlib
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from typing import Any, TypeVar
|
||||||
|
|
||||||
|
from tortoise import Tortoise
|
||||||
|
|
||||||
|
from zhenxun.builtin_plugins.hooks.auth_runtime_config import (
|
||||||
|
AUTH_OBSERVABILITY_RUNTIME_CONFIG,
|
||||||
|
)
|
||||||
|
from zhenxun.models.auth_decision_log import AuthDecisionLog
|
||||||
|
from zhenxun.models.runtime_backpressure_log import RuntimeBackpressureLog
|
||||||
|
from zhenxun.services.log import logger
|
||||||
|
from zhenxun.utils.manager.priority_manager import PriorityLifecycle
|
||||||
|
|
||||||
|
LOG_COMMAND = "AuthObservability"
|
||||||
|
|
||||||
|
_BUFFER_MAX_RETAIN = AUTH_OBSERVABILITY_RUNTIME_CONFIG.buffer_max_retain
|
||||||
|
_FLUSH_TRIGGER_SIZE = AUTH_OBSERVABILITY_RUNTIME_CONFIG.flush_trigger_size
|
||||||
|
_FLUSH_BATCH_SIZE = AUTH_OBSERVABILITY_RUNTIME_CONFIG.flush_batch_size
|
||||||
|
_FLUSH_INTERVAL_SECONDS = AUTH_OBSERVABILITY_RUNTIME_CONFIG.flush_interval_seconds
|
||||||
|
_DROP_LOG_INTERVAL_SECONDS = AUTH_OBSERVABILITY_RUNTIME_CONFIG.drop_log_interval_seconds
|
||||||
|
_ALLOW_SAMPLE_RATE = AUTH_OBSERVABILITY_RUNTIME_CONFIG.allow_sample_rate
|
||||||
|
_OVERLOADED_ALLOW_SAMPLE_RATE = (
|
||||||
|
AUTH_OBSERVABILITY_RUNTIME_CONFIG.overloaded_allow_sample_rate
|
||||||
|
)
|
||||||
|
_NON_ALLOW_SAMPLE_RATE = AUTH_OBSERVABILITY_RUNTIME_CONFIG.non_allow_sample_rate
|
||||||
|
_BACKPRESSURE_SAMPLE_RATE = AUTH_OBSERVABILITY_RUNTIME_CONFIG.backpressure_sample_rate
|
||||||
|
_BACKPRESSURE_SEVERE_ACTIVE_THRESHOLD = (
|
||||||
|
AUTH_OBSERVABILITY_RUNTIME_CONFIG.backpressure_severe_active_threshold
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class AuthDecisionLogRecord:
|
||||||
|
bot_id: str | None
|
||||||
|
platform: str | None
|
||||||
|
group_id: str | None
|
||||||
|
user_id: str | None
|
||||||
|
module: str | None
|
||||||
|
effect: str
|
||||||
|
reason: str | None = None
|
||||||
|
shadow_effect: str | None = None
|
||||||
|
shadow_reason: str | None = None
|
||||||
|
side_effect_state: dict[str, Any] | None = None
|
||||||
|
latency_ms: float = 0.0
|
||||||
|
overloaded: bool = False
|
||||||
|
|
||||||
|
def to_model(self) -> AuthDecisionLog:
|
||||||
|
return AuthDecisionLog(
|
||||||
|
bot_id=self.bot_id,
|
||||||
|
platform=self.platform,
|
||||||
|
group_id=self.group_id,
|
||||||
|
user_id=self.user_id,
|
||||||
|
module=self.module,
|
||||||
|
effect=self.effect,
|
||||||
|
reason=self.reason,
|
||||||
|
shadow_effect=self.shadow_effect,
|
||||||
|
shadow_reason=self.shadow_reason,
|
||||||
|
side_effect_state=json.dumps(
|
||||||
|
self.side_effect_state,
|
||||||
|
ensure_ascii=False,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)[:4000]
|
||||||
|
if self.side_effect_state
|
||||||
|
else None,
|
||||||
|
latency_ms=self.latency_ms,
|
||||||
|
overloaded=self.overloaded,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class RuntimeBackpressureLogRecord:
|
||||||
|
scope_key: str | None
|
||||||
|
reason: str | None
|
||||||
|
lane: str | None
|
||||||
|
action: str
|
||||||
|
queue_size: int = 0
|
||||||
|
active_count: int = 0
|
||||||
|
duration_ms: float = 0.0
|
||||||
|
|
||||||
|
def to_model(self) -> RuntimeBackpressureLog:
|
||||||
|
return RuntimeBackpressureLog(
|
||||||
|
scope_key=self.scope_key,
|
||||||
|
reason=self.reason,
|
||||||
|
lane=self.lane,
|
||||||
|
action=self.action,
|
||||||
|
queue_size=self.queue_size,
|
||||||
|
active_count=self.active_count,
|
||||||
|
duration_ms=self.duration_ms,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_auth_decision_buffer: deque[AuthDecisionLogRecord] = deque()
|
||||||
|
_backpressure_buffer: deque[RuntimeBackpressureLogRecord] = deque()
|
||||||
|
_buffer_lock = asyncio.Lock()
|
||||||
|
_flush_lock = asyncio.Lock()
|
||||||
|
_flush_task: asyncio.Task[None] | None = None
|
||||||
|
_dropped = 0
|
||||||
|
_last_drop_log_at = 0.0
|
||||||
|
_last_schema_repair_at = 0.0
|
||||||
|
_SCHEMA_REPAIR_INTERVAL_SECONDS = 300.0
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_flush_task() -> None:
|
||||||
|
global _flush_task
|
||||||
|
if _flush_task is not None and not _flush_task.done():
|
||||||
|
return
|
||||||
|
_flush_task = asyncio.create_task(_flush_loop())
|
||||||
|
|
||||||
|
|
||||||
|
def _record_drop() -> None:
|
||||||
|
global _dropped, _last_drop_log_at
|
||||||
|
_dropped += 1
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - _last_drop_log_at < _DROP_LOG_INTERVAL_SECONDS:
|
||||||
|
return
|
||||||
|
_last_drop_log_at = now
|
||||||
|
logger.warning(
|
||||||
|
"auth observability buffer full, dropped "
|
||||||
|
f"{_dropped} records, auth_backlog={len(_auth_decision_buffer)}, "
|
||||||
|
f"backpressure_backlog={len(_backpressure_buffer)}",
|
||||||
|
LOG_COMMAND,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sample(rate: float) -> bool:
|
||||||
|
if rate >= 1:
|
||||||
|
return True
|
||||||
|
if rate <= 0:
|
||||||
|
return False
|
||||||
|
return random.random() < rate
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_decision_sample_rate(effect: str, overloaded: bool) -> float:
|
||||||
|
if effect != "allow":
|
||||||
|
return _NON_ALLOW_SAMPLE_RATE
|
||||||
|
if overloaded:
|
||||||
|
return _OVERLOADED_ALLOW_SAMPLE_RATE
|
||||||
|
return _ALLOW_SAMPLE_RATE
|
||||||
|
|
||||||
|
|
||||||
|
def _backpressure_sample_rate(record: RuntimeBackpressureLogRecord) -> float:
|
||||||
|
if record.reason and record.reason.startswith("hooks_"):
|
||||||
|
return 1.0
|
||||||
|
if record.active_count >= _BACKPRESSURE_SEVERE_ACTIVE_THRESHOLD:
|
||||||
|
return 1.0
|
||||||
|
if record.action in {"skip", "defer"}:
|
||||||
|
return _BACKPRESSURE_SAMPLE_RATE
|
||||||
|
return min(_BACKPRESSURE_SAMPLE_RATE, 0.02)
|
||||||
|
|
||||||
|
|
||||||
|
async def _append_auth_decision_record(record: AuthDecisionLogRecord) -> None:
|
||||||
|
_ensure_flush_task()
|
||||||
|
async with _buffer_lock:
|
||||||
|
total = len(_auth_decision_buffer) + len(_backpressure_buffer)
|
||||||
|
if total >= _BUFFER_MAX_RETAIN:
|
||||||
|
if len(_auth_decision_buffer) >= len(_backpressure_buffer):
|
||||||
|
with contextlib.suppress(IndexError):
|
||||||
|
_auth_decision_buffer.popleft()
|
||||||
|
else:
|
||||||
|
with contextlib.suppress(IndexError):
|
||||||
|
_backpressure_buffer.popleft()
|
||||||
|
_record_drop()
|
||||||
|
_auth_decision_buffer.append(record)
|
||||||
|
should_flush = (
|
||||||
|
len(_auth_decision_buffer) + len(_backpressure_buffer)
|
||||||
|
>= _FLUSH_TRIGGER_SIZE
|
||||||
|
and not _flush_lock.locked()
|
||||||
|
)
|
||||||
|
if should_flush:
|
||||||
|
# Fire-and-forget keeps auth hot path independent of database stalls.
|
||||||
|
asyncio.create_task(flush_auth_observability_buffer("缓冲区触发")) # noqa: RUF006
|
||||||
|
|
||||||
|
|
||||||
|
async def _append_backpressure_record(record: RuntimeBackpressureLogRecord) -> None:
|
||||||
|
_ensure_flush_task()
|
||||||
|
async with _buffer_lock:
|
||||||
|
total = len(_auth_decision_buffer) + len(_backpressure_buffer)
|
||||||
|
if total >= _BUFFER_MAX_RETAIN:
|
||||||
|
if len(_auth_decision_buffer) >= len(_backpressure_buffer):
|
||||||
|
with contextlib.suppress(IndexError):
|
||||||
|
_auth_decision_buffer.popleft()
|
||||||
|
else:
|
||||||
|
with contextlib.suppress(IndexError):
|
||||||
|
_backpressure_buffer.popleft()
|
||||||
|
_record_drop()
|
||||||
|
_backpressure_buffer.append(record)
|
||||||
|
should_flush = (
|
||||||
|
len(_auth_decision_buffer) + len(_backpressure_buffer)
|
||||||
|
>= _FLUSH_TRIGGER_SIZE
|
||||||
|
and not _flush_lock.locked()
|
||||||
|
)
|
||||||
|
if should_flush:
|
||||||
|
# Fire-and-forget keeps auth hot path independent of database stalls.
|
||||||
|
asyncio.create_task(flush_auth_observability_buffer("缓冲区触发")) # noqa: RUF006
|
||||||
|
|
||||||
|
|
||||||
|
async def append_auth_decision_log(
|
||||||
|
*,
|
||||||
|
bot_id: str | None,
|
||||||
|
platform: str | None,
|
||||||
|
group_id: str | None,
|
||||||
|
user_id: str | None,
|
||||||
|
module: str | None,
|
||||||
|
effect: str,
|
||||||
|
reason: str | None = None,
|
||||||
|
shadow_effect: str | None = None,
|
||||||
|
shadow_reason: str | None = None,
|
||||||
|
side_effect_state: dict[str, Any] | None = None,
|
||||||
|
latency_ms: float = 0.0,
|
||||||
|
overloaded: bool = False,
|
||||||
|
) -> None:
|
||||||
|
if shadow_effect is None and not _sample(
|
||||||
|
_auth_decision_sample_rate(effect, overloaded)
|
||||||
|
):
|
||||||
|
return
|
||||||
|
record = AuthDecisionLogRecord(
|
||||||
|
bot_id=bot_id,
|
||||||
|
platform=platform,
|
||||||
|
group_id=group_id,
|
||||||
|
user_id=user_id,
|
||||||
|
module=module,
|
||||||
|
effect=effect,
|
||||||
|
reason=(reason or "")[:255] or None,
|
||||||
|
shadow_effect=(shadow_effect or "")[:32] or None,
|
||||||
|
shadow_reason=(shadow_reason or "")[:255] or None,
|
||||||
|
side_effect_state=side_effect_state,
|
||||||
|
latency_ms=latency_ms,
|
||||||
|
overloaded=overloaded,
|
||||||
|
)
|
||||||
|
await _append_auth_decision_record(record)
|
||||||
|
|
||||||
|
|
||||||
|
async def append_runtime_backpressure_log(
|
||||||
|
*,
|
||||||
|
scope_key: str | None,
|
||||||
|
reason: str | None,
|
||||||
|
lane: str | None,
|
||||||
|
action: str,
|
||||||
|
queue_size: int = 0,
|
||||||
|
active_count: int = 0,
|
||||||
|
duration_ms: float = 0.0,
|
||||||
|
) -> None:
|
||||||
|
record = RuntimeBackpressureLogRecord(
|
||||||
|
scope_key=(scope_key or "")[:255] or None,
|
||||||
|
reason=(reason or "")[:255] or None,
|
||||||
|
lane=(lane or "")[:64] or None,
|
||||||
|
action=action,
|
||||||
|
queue_size=queue_size,
|
||||||
|
active_count=active_count,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
)
|
||||||
|
if not _sample(_backpressure_sample_rate(record)):
|
||||||
|
return
|
||||||
|
await _append_backpressure_record(record)
|
||||||
|
|
||||||
|
|
||||||
|
async def _flush_loop() -> None:
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(_FLUSH_INTERVAL_SECONDS)
|
||||||
|
try:
|
||||||
|
await flush_auth_observability_buffer("定时")
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("定时批量写入权限观测日志失败", LOG_COMMAND, e=exc)
|
||||||
|
|
||||||
|
|
||||||
|
async def _drain_batch(buffer: deque[T]) -> list[T]:
|
||||||
|
batch: list[T] = []
|
||||||
|
async with _buffer_lock:
|
||||||
|
while buffer and len(batch) < _FLUSH_BATCH_SIZE:
|
||||||
|
batch.append(buffer.popleft())
|
||||||
|
return batch
|
||||||
|
|
||||||
|
|
||||||
|
async def _restore_batch(buffer: deque[T], batch: list[T]) -> None:
|
||||||
|
async with _buffer_lock:
|
||||||
|
retain_count = max(_BUFFER_MAX_RETAIN - len(buffer), 0)
|
||||||
|
for record in reversed(batch[-retain_count:]):
|
||||||
|
buffer.appendleft(record)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_schema_mismatch_error(exc: Exception) -> bool:
|
||||||
|
message = str(exc).lower()
|
||||||
|
return any(
|
||||||
|
marker in message
|
||||||
|
for marker in (
|
||||||
|
"no column named",
|
||||||
|
"unknown column",
|
||||||
|
"column does not exist",
|
||||||
|
"no such column",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _try_repair_auth_schema_once() -> bool:
|
||||||
|
global _last_schema_repair_at
|
||||||
|
now = time.monotonic()
|
||||||
|
if now - _last_schema_repair_at < _SCHEMA_REPAIR_INTERVAL_SECONDS:
|
||||||
|
return False
|
||||||
|
_last_schema_repair_at = now
|
||||||
|
try:
|
||||||
|
from zhenxun.services.db_context.schema_guard import repair_table_schema
|
||||||
|
|
||||||
|
await repair_table_schema("auth_decision_log")
|
||||||
|
await repair_table_schema("runtime_backpressure_log")
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("权限观测日志表结构自修复失败", LOG_COMMAND, e=exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def flush_auth_observability_buffer(reason: str) -> int:
|
||||||
|
async with _flush_lock:
|
||||||
|
written = 0
|
||||||
|
while True:
|
||||||
|
auth_batch = await _drain_batch(_auth_decision_buffer)
|
||||||
|
backpressure_batch = await _drain_batch(_backpressure_buffer)
|
||||||
|
if not auth_batch and not backpressure_batch:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
if auth_batch:
|
||||||
|
await AuthDecisionLog.bulk_create(
|
||||||
|
[record.to_model() for record in auth_batch],
|
||||||
|
_FLUSH_BATCH_SIZE,
|
||||||
|
)
|
||||||
|
written += len(auth_batch)
|
||||||
|
if backpressure_batch:
|
||||||
|
await RuntimeBackpressureLog.bulk_create(
|
||||||
|
[record.to_model() for record in backpressure_batch],
|
||||||
|
_FLUSH_BATCH_SIZE,
|
||||||
|
)
|
||||||
|
written += len(backpressure_batch)
|
||||||
|
except Exception as exc:
|
||||||
|
if _is_schema_mismatch_error(exc):
|
||||||
|
if await _try_repair_auth_schema_once():
|
||||||
|
try:
|
||||||
|
if auth_batch:
|
||||||
|
await AuthDecisionLog.bulk_create(
|
||||||
|
[record.to_model() for record in auth_batch],
|
||||||
|
_FLUSH_BATCH_SIZE,
|
||||||
|
)
|
||||||
|
written += len(auth_batch)
|
||||||
|
if backpressure_batch:
|
||||||
|
await RuntimeBackpressureLog.bulk_create(
|
||||||
|
[
|
||||||
|
record.to_model()
|
||||||
|
for record in backpressure_batch
|
||||||
|
],
|
||||||
|
_FLUSH_BATCH_SIZE,
|
||||||
|
)
|
||||||
|
written += len(backpressure_batch)
|
||||||
|
continue
|
||||||
|
except Exception as retry_exc:
|
||||||
|
exc = retry_exc
|
||||||
|
dropped = len(auth_batch) + len(backpressure_batch)
|
||||||
|
logger.warning(
|
||||||
|
f"{reason}批量写入权限观测日志遇到表结构不匹配,"
|
||||||
|
f"已丢弃低优先级观测日志 {dropped} 条,等待下次启动修复",
|
||||||
|
LOG_COMMAND,
|
||||||
|
e=exc,
|
||||||
|
)
|
||||||
|
return written
|
||||||
|
await _restore_batch(_auth_decision_buffer, auth_batch)
|
||||||
|
await _restore_batch(_backpressure_buffer, backpressure_batch)
|
||||||
|
logger.error(f"{reason}批量写入权限观测日志失败", LOG_COMMAND, e=exc)
|
||||||
|
return written
|
||||||
|
if written:
|
||||||
|
logger.debug(f"{reason}批量写入权限观测日志 {written} 条", LOG_COMMAND)
|
||||||
|
return written
|
||||||
|
|
||||||
|
|
||||||
|
async def stop_auth_observability_buffer() -> int:
|
||||||
|
global _flush_task
|
||||||
|
task = _flush_task
|
||||||
|
_flush_task = None
|
||||||
|
if task is not None:
|
||||||
|
task.cancel()
|
||||||
|
with contextlib.suppress(BaseException):
|
||||||
|
await task
|
||||||
|
return await flush_auth_observability_buffer("关闭")
|
||||||
|
|
||||||
|
|
||||||
|
def _percentile(values: list[float], ratio: float) -> float:
|
||||||
|
if not values:
|
||||||
|
return 0.0
|
||||||
|
ordered = sorted(values)
|
||||||
|
index = min(max(round((len(ordered) - 1) * ratio), 0), len(ordered) - 1)
|
||||||
|
return round(ordered[index], 3)
|
||||||
|
|
||||||
|
|
||||||
|
def _bucket_counts(rows: list[dict[str, Any]], field: str) -> dict[str, int]:
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for row in rows:
|
||||||
|
key = str(row.get(field) or "<none>")
|
||||||
|
counts[key] = counts.get(key, 0) + 1
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
def _lane_budget_advice(rows: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||||
|
buckets: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
for row in rows:
|
||||||
|
lane = str(row.get("lane") or "<unknown>")
|
||||||
|
buckets.setdefault(lane, []).append(row)
|
||||||
|
advice: dict[str, dict[str, Any]] = {}
|
||||||
|
for lane, items in buckets.items():
|
||||||
|
if lane == "<unknown>":
|
||||||
|
continue
|
||||||
|
durations = [float(item.get("duration_ms") or 0.0) for item in items]
|
||||||
|
slow_waits = sum(1 for value in durations if value >= 200.0)
|
||||||
|
active_max = max(
|
||||||
|
(int(item.get("active_count") or 0) for item in items), default=0
|
||||||
|
)
|
||||||
|
total = len(items)
|
||||||
|
if not total:
|
||||||
|
continue
|
||||||
|
pressure_ratio = slow_waits / total
|
||||||
|
if pressure_ratio >= 0.2 or active_max >= 5:
|
||||||
|
action = "increase_or_split"
|
||||||
|
elif pressure_ratio == 0 and active_max <= 1 and total >= 20:
|
||||||
|
action = "can_reduce"
|
||||||
|
else:
|
||||||
|
action = "keep"
|
||||||
|
advice[lane] = {
|
||||||
|
"samples": total,
|
||||||
|
"slow_waits": slow_waits,
|
||||||
|
"pressure_ratio": round(pressure_ratio, 3),
|
||||||
|
"active_max": active_max,
|
||||||
|
"p95_duration_ms": _percentile(durations, 0.95),
|
||||||
|
"action": action,
|
||||||
|
}
|
||||||
|
return advice
|
||||||
|
|
||||||
|
|
||||||
|
def _query_placeholder() -> str:
|
||||||
|
try:
|
||||||
|
connection = Tortoise.get_connection("default")
|
||||||
|
if (
|
||||||
|
getattr(connection, "capabilities", None)
|
||||||
|
and getattr(
|
||||||
|
connection.capabilities,
|
||||||
|
"dialect",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
== "postgres"
|
||||||
|
):
|
||||||
|
return "$1"
|
||||||
|
except Exception:
|
||||||
|
return "?"
|
||||||
|
return "?"
|
||||||
|
|
||||||
|
|
||||||
|
async def build_auth_observability_report(*, hours: float = 24.0) -> dict[str, Any]:
|
||||||
|
since = datetime.now() - timedelta(hours=hours)
|
||||||
|
db = Tortoise.get_connection("default")
|
||||||
|
placeholder = _query_placeholder()
|
||||||
|
auth_rows = await db.execute_query_dict(
|
||||||
|
"SELECT module, effect, reason, shadow_effect, shadow_reason, latency_ms, "
|
||||||
|
f"overloaded FROM auth_decision_log WHERE create_time >= {placeholder} "
|
||||||
|
"ORDER BY create_time DESC LIMIT 100000",
|
||||||
|
[since],
|
||||||
|
)
|
||||||
|
backpressure_rows = await db.execute_query_dict(
|
||||||
|
"SELECT scope_key, lane, reason, action, queue_size, active_count, duration_ms "
|
||||||
|
f"FROM runtime_backpressure_log WHERE create_time >= {placeholder} "
|
||||||
|
"ORDER BY create_time DESC LIMIT 100000",
|
||||||
|
[since],
|
||||||
|
)
|
||||||
|
|
||||||
|
module_buckets: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
for row in auth_rows:
|
||||||
|
module_buckets.setdefault(str(row.get("module") or "<unknown>"), []).append(row)
|
||||||
|
module_stats: list[dict[str, Any]] = []
|
||||||
|
for module, items in module_buckets.items():
|
||||||
|
latencies = [float(item.get("latency_ms") or 0.0) for item in items]
|
||||||
|
module_stats.append(
|
||||||
|
{
|
||||||
|
"module": module,
|
||||||
|
"total": len(items),
|
||||||
|
"effects": _bucket_counts(items, "effect"),
|
||||||
|
"shadow_effects": _bucket_counts(items, "shadow_effect"),
|
||||||
|
"avg_latency_ms": round(sum(latencies) / len(latencies), 3)
|
||||||
|
if latencies
|
||||||
|
else 0.0,
|
||||||
|
"p95_latency_ms": _percentile(latencies, 0.95),
|
||||||
|
"overloaded": sum(1 for item in items if bool(item.get("overloaded"))),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
backpressure_buckets: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
for row in backpressure_rows:
|
||||||
|
key = f"{row.get('lane') or '<unknown>'}:{row.get('reason') or '<none>'}"
|
||||||
|
backpressure_buckets.setdefault(key, []).append(row)
|
||||||
|
backpressure_stats: list[dict[str, Any]] = []
|
||||||
|
for key, items in backpressure_buckets.items():
|
||||||
|
durations = [float(item.get("duration_ms") or 0.0) for item in items]
|
||||||
|
backpressure_stats.append(
|
||||||
|
{
|
||||||
|
"key": key,
|
||||||
|
"total": len(items),
|
||||||
|
"actions": _bucket_counts(items, "action"),
|
||||||
|
"avg_duration_ms": round(sum(durations) / len(durations), 3)
|
||||||
|
if durations
|
||||||
|
else 0.0,
|
||||||
|
"p95_duration_ms": _percentile(durations, 0.95),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"created_at": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"window_hours": hours,
|
||||||
|
"auth_decisions": {
|
||||||
|
"total": len(auth_rows),
|
||||||
|
"effects": _bucket_counts(auth_rows, "effect"),
|
||||||
|
"shadow_effects": _bucket_counts(auth_rows, "shadow_effect"),
|
||||||
|
"top_modules_by_p95": sorted(
|
||||||
|
module_stats,
|
||||||
|
key=lambda item: (item["p95_latency_ms"], item["total"]),
|
||||||
|
reverse=True,
|
||||||
|
)[:30],
|
||||||
|
},
|
||||||
|
"backpressure": {
|
||||||
|
"total": len(backpressure_rows),
|
||||||
|
"lane_budget_advice": _lane_budget_advice(backpressure_rows),
|
||||||
|
"top_reasons": sorted(
|
||||||
|
backpressure_stats,
|
||||||
|
key=lambda item: (item["total"], item["p95_duration_ms"]),
|
||||||
|
reverse=True,
|
||||||
|
)[:30],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@PriorityLifecycle.on_shutdown(priority=90)
|
||||||
|
async def _flush_auth_observability_buffer_on_shutdown() -> None:
|
||||||
|
await stop_auth_observability_buffer()
|
||||||
+68
@@ -715,12 +715,22 @@ class PluginInfoMemoryCache:
|
|||||||
return
|
return
|
||||||
await cls.refresh()
|
await cls.refresh()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_loaded(cls) -> bool:
|
||||||
|
return cls._loaded
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_by_module(cls, module: str) -> "PluginInfo | None":
|
async def get_by_module(cls, module: str) -> "PluginInfo | None":
|
||||||
if not cls._loaded:
|
if not cls._loaded:
|
||||||
await cls.ensure_loaded()
|
await cls.ensure_loaded()
|
||||||
return cls._to_model(cls._by_module.get(module))
|
return cls._to_model(cls._by_module.get(module))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_by_module_if_ready(cls, module: str) -> "PluginInfo | None":
|
||||||
|
if not cls._loaded:
|
||||||
|
return None
|
||||||
|
return cls._to_model(cls._by_module.get(module))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_all(cls) -> dict[str, "PluginInfo"]:
|
async def get_all(cls) -> dict[str, "PluginInfo"]:
|
||||||
if not cls._loaded:
|
if not cls._loaded:
|
||||||
@@ -851,6 +861,10 @@ class BotMemoryCache:
|
|||||||
return
|
return
|
||||||
await cls.refresh()
|
await cls.refresh()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_loaded(cls) -> bool:
|
||||||
|
return cls._loaded
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get(cls, bot_id: str | None) -> BotSnapshot | None:
|
async def get(cls, bot_id: str | None) -> BotSnapshot | None:
|
||||||
bot_id = cls._normalize(bot_id)
|
bot_id = cls._normalize(bot_id)
|
||||||
@@ -866,6 +880,19 @@ class BotMemoryCache:
|
|||||||
cls._mark_negative(bot_id)
|
cls._mark_negative(bot_id)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_if_ready(cls, bot_id: str | None) -> BotSnapshot | None:
|
||||||
|
bot_id = cls._normalize(bot_id)
|
||||||
|
if not bot_id or not cls._loaded:
|
||||||
|
return None
|
||||||
|
entry = cls._by_id.get(bot_id)
|
||||||
|
if entry:
|
||||||
|
return entry
|
||||||
|
if cls._is_negative(bot_id):
|
||||||
|
return None
|
||||||
|
cls._mark_negative(bot_id)
|
||||||
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_all(cls) -> dict[str, BotSnapshot]:
|
async def get_all(cls) -> dict[str, BotSnapshot]:
|
||||||
if not cls._loaded:
|
if not cls._loaded:
|
||||||
@@ -1199,6 +1226,10 @@ class LevelUserMemoryCache:
|
|||||||
return
|
return
|
||||||
await cls.refresh()
|
await cls.refresh()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_loaded(cls) -> bool:
|
||||||
|
return cls._loaded
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def ensure_fresh(cls) -> None:
|
async def ensure_fresh(cls) -> None:
|
||||||
interval = LEVEL_MEM_REFRESH_INTERVAL
|
interval = LEVEL_MEM_REFRESH_INTERVAL
|
||||||
@@ -1244,6 +1275,23 @@ class LevelUserMemoryCache:
|
|||||||
group_user = cls._by_key.get(group_key)
|
group_user = cls._by_key.get(group_key)
|
||||||
return global_user, group_user
|
return global_user, group_user
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_levels_if_ready(
|
||||||
|
cls, user_id: str | None, group_id: str | None
|
||||||
|
) -> tuple[LevelUserSnapshot | None, LevelUserSnapshot | None] | None:
|
||||||
|
if not cls._loaded:
|
||||||
|
return None
|
||||||
|
global_user = None
|
||||||
|
group_user = None
|
||||||
|
global_key = cls._key(user_id, "")
|
||||||
|
if global_key:
|
||||||
|
global_user = cls._by_key.get(global_key)
|
||||||
|
if group_id:
|
||||||
|
group_key = cls._key(user_id, group_id)
|
||||||
|
if group_key:
|
||||||
|
group_user = cls._by_key.get(group_key)
|
||||||
|
return global_user, group_user
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_max_level(cls, user_id: str | None) -> int:
|
async def get_max_level(cls, user_id: str | None) -> int:
|
||||||
user_id = cls._normalize(user_id)
|
user_id = cls._normalize(user_id)
|
||||||
@@ -1576,6 +1624,10 @@ class PluginLimitMemoryCache:
|
|||||||
return
|
return
|
||||||
await cls.refresh()
|
await cls.refresh()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def is_loaded(cls) -> bool:
|
||||||
|
return cls._loaded
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_limits(cls, module: str) -> list[PluginLimitSnapshot]:
|
async def get_limits(cls, module: str) -> list[PluginLimitSnapshot]:
|
||||||
normalized = cls._normalize(module)
|
normalized = cls._normalize(module)
|
||||||
@@ -1592,6 +1644,22 @@ class PluginLimitMemoryCache:
|
|||||||
cls._mark_negative(module)
|
cls._mark_negative(module)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_limits_if_ready(cls, module: str) -> list[PluginLimitSnapshot] | None:
|
||||||
|
normalized = cls._normalize(module)
|
||||||
|
if not normalized:
|
||||||
|
return []
|
||||||
|
module = normalized
|
||||||
|
if not cls._loaded:
|
||||||
|
return None
|
||||||
|
limits = cls._by_module.get(module)
|
||||||
|
if limits is not None:
|
||||||
|
return limits
|
||||||
|
if cls._is_negative(module):
|
||||||
|
return []
|
||||||
|
cls._mark_negative(module)
|
||||||
|
return []
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_all_limits(cls) -> list[PluginLimitSnapshot]:
|
def get_all_limits(cls) -> list[PluginLimitSnapshot]:
|
||||||
return list(cls._by_id.values())
|
return list(cls._by_id.values())
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import re
|
import re
|
||||||
|
from typing import Literal
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
@@ -27,8 +29,12 @@ from .config import (
|
|||||||
prompt,
|
prompt,
|
||||||
)
|
)
|
||||||
from .exceptions import DbConnectError, DbUrlIsNode
|
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
|
from .utils import with_db_timeout
|
||||||
|
|
||||||
|
Dialect = Literal["sqlite", "postgres", "mysql", "unknown"]
|
||||||
|
|
||||||
MODELS = db_model.models
|
MODELS = db_model.models
|
||||||
SCRIPT_METHOD = db_model.script_method
|
SCRIPT_METHOD = db_model.script_method
|
||||||
|
|
||||||
@@ -47,7 +53,66 @@ __all__ = [
|
|||||||
|
|
||||||
driver = nonebot.get_driver()
|
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:
|
def get_config() -> dict:
|
||||||
@@ -131,20 +196,39 @@ async def init():
|
|||||||
f"<u><y>{len(db_model.script_method)}</y></u> 个..."
|
f"<u><y>{len(db_model.script_method)}</y></u> 个..."
|
||||||
)
|
)
|
||||||
sql_list = []
|
sql_list = []
|
||||||
|
allow_guarded_ops = _allow_guarded_schema_ops()
|
||||||
for module, func in db_model.script_method:
|
for module, func in db_model.script_method:
|
||||||
try:
|
try:
|
||||||
sql = await func() if is_coroutine_callable(func) else func()
|
items = await func() if is_coroutine_callable(func) else func()
|
||||||
if sql:
|
if not items:
|
||||||
sql_list += sql
|
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:
|
except Exception as e:
|
||||||
logger.debug(f"{module} 执行SCRIPT_METHOD方法出错...", e=e)
|
logger.debug(f"{module} 执行SCRIPT_METHOD方法出错...", e=e)
|
||||||
if sql_list:
|
if sql_list:
|
||||||
fingerprint = hashlib.md5(
|
fingerprint = hashlib.md5(
|
||||||
json.dumps(sorted(sql_list), ensure_ascii=False).encode()
|
json.dumps(sorted(sql_list), ensure_ascii=False).encode()
|
||||||
).hexdigest()
|
).hexdigest()
|
||||||
|
script_hash_file = _db_script_hash_file(fingerprint)
|
||||||
need_run = not (
|
need_run = not (
|
||||||
_SCRIPT_HASH_FILE.exists()
|
script_hash_file.exists()
|
||||||
and _SCRIPT_HASH_FILE.read_text(encoding="utf-8").strip()
|
and json.loads(script_hash_file.read_text(encoding="utf-8")).get(
|
||||||
|
"script_fingerprint"
|
||||||
|
)
|
||||||
== fingerprint
|
== fingerprint
|
||||||
)
|
)
|
||||||
if need_run:
|
if need_run:
|
||||||
@@ -186,12 +270,16 @@ async def init():
|
|||||||
|
|
||||||
for sql in sql_list:
|
for sql in sql_list:
|
||||||
# 对于 ALTER TABLE 操作,先检查表是否存在
|
# 对于 ALTER TABLE 操作,先检查表是否存在
|
||||||
if sql.strip().upper().startswith("ALTER TABLE"):
|
sql_upper = sql.strip().upper()
|
||||||
match = re.match(
|
if sql_upper.startswith("ALTER TABLE"):
|
||||||
r"ALTER\s+TABLE\s+(\w+)", sql, re.IGNORECASE
|
table_name = _extract_alter_table_name(sql)
|
||||||
)
|
if table_name:
|
||||||
if match:
|
if not await table_exists(table_name):
|
||||||
table_name = match.group(1)
|
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):
|
if not await table_exists(table_name):
|
||||||
logger.debug(f"跳过SQL(表不存在): {sql}")
|
logger.debug(f"跳过SQL(表不存在): {sql}")
|
||||||
continue
|
continue
|
||||||
@@ -236,13 +324,31 @@ async def init():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"执行SQL: {sql} 错误...", e=e)
|
logger.debug(f"执行SQL: {sql} 错误...", e=e)
|
||||||
logger.debug("SCRIPT_METHOD方法执行完毕!")
|
logger.debug("SCRIPT_METHOD方法执行完毕!")
|
||||||
_SCRIPT_HASH_FILE.parent.mkdir(parents=True, exist_ok=True)
|
script_hash_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
_SCRIPT_HASH_FILE.write_text(fingerprint, encoding="utf-8")
|
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:
|
else:
|
||||||
logger.debug("迁移脚本无变化,跳过执行")
|
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("开始生成数据库表结构...")
|
logger.debug("开始生成数据库表结构...")
|
||||||
await Tortoise.generate_schemas()
|
await Tortoise.generate_schemas()
|
||||||
logger.debug("数据库表结构生成完毕!")
|
logger.debug("数据库表结构生成完毕!")
|
||||||
|
await repair_safe_schema_drift()
|
||||||
logger.info("Database loaded successfully!")
|
logger.info("Database loaded successfully!")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise DbConnectError(f"数据库连接错误... e:{e}") from e
|
raise DbConnectError(f"数据库连接错误... e:{e}") from e
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ MYSQL_CONFIG = {
|
|||||||
|
|
||||||
SQLITE_CONFIG = {
|
SQLITE_CONFIG = {
|
||||||
"journal_mode": "WAL", # 提高并发写入性能
|
"journal_mode": "WAL", # 提高并发写入性能
|
||||||
"timeout": 30, # 锁等待超时(可选)
|
"busy_timeout": 30000, # SQLite 锁等待超时,单位毫秒
|
||||||
|
"foreign_keys": "ON",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,593 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from tortoise import Tortoise
|
||||||
|
from tortoise.exceptions import OperationalError
|
||||||
|
|
||||||
|
from zhenxun.services.log import logger
|
||||||
|
|
||||||
|
from .config import DB_TIMEOUT_SECONDS, LOG_COMMAND
|
||||||
|
|
||||||
|
Dialect = Literal["sqlite", "postgres", "mysql", "unknown"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class SchemaGuardResult:
|
||||||
|
checked_tables: int = 0
|
||||||
|
repaired_columns: int = 0
|
||||||
|
repaired_indexes: int = 0
|
||||||
|
skipped_columns: int = 0
|
||||||
|
skipped_indexes: int = 0
|
||||||
|
type_mismatches: int = 0
|
||||||
|
warnings: int = 0
|
||||||
|
drift: list[dict[str, Any]] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ColumnInfo:
|
||||||
|
name: str
|
||||||
|
data_type: str
|
||||||
|
nullable: bool | None = None
|
||||||
|
default: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _quote_identifier(identifier: str, dialect: Dialect) -> str:
|
||||||
|
escaped = identifier.replace('"', '""')
|
||||||
|
if dialect == "mysql":
|
||||||
|
return f"`{identifier.replace('`', '``')}`"
|
||||||
|
return f'"{escaped}"'
|
||||||
|
|
||||||
|
|
||||||
|
def _connection_dialect(connection: Any) -> Dialect:
|
||||||
|
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"
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_safe_missing_field(field: Any) -> bool:
|
||||||
|
if getattr(field, "pk", False):
|
||||||
|
return False
|
||||||
|
if getattr(field, "generated", False):
|
||||||
|
return False
|
||||||
|
if bool(getattr(field, "null", False)):
|
||||||
|
return True
|
||||||
|
default = getattr(field, "default", None)
|
||||||
|
return default is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _field_sql_type(field: Any, dialect: Dialect) -> str | None:
|
||||||
|
if dialect != "unknown" and hasattr(field, "get_for_dialect"):
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
value = field.get_for_dialect(dialect, "SQL_TYPE")
|
||||||
|
if value:
|
||||||
|
return str(value)
|
||||||
|
value = getattr(field, "SQL_TYPE", None)
|
||||||
|
return str(value) if value else None
|
||||||
|
|
||||||
|
|
||||||
|
def _is_db_field(field: Any, dialect: Dialect) -> bool:
|
||||||
|
if getattr(field, "virtual", False):
|
||||||
|
return False
|
||||||
|
return _field_sql_type(field, dialect) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _field_default_sql(field: Any, dialect: Dialect) -> str:
|
||||||
|
default = getattr(field, "default", None)
|
||||||
|
if default is None or callable(default):
|
||||||
|
return ""
|
||||||
|
if isinstance(default, bool):
|
||||||
|
if dialect == "postgres":
|
||||||
|
return " DEFAULT TRUE" if default else " DEFAULT FALSE"
|
||||||
|
return " DEFAULT 1" if default else " DEFAULT 0"
|
||||||
|
if isinstance(default, int | float):
|
||||||
|
return f" DEFAULT {default}"
|
||||||
|
if isinstance(default, str):
|
||||||
|
escaped = default.replace("'", "''")
|
||||||
|
return f" DEFAULT '{escaped}'"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _missing_column_sql(
|
||||||
|
table: str,
|
||||||
|
column: str,
|
||||||
|
field: Any,
|
||||||
|
dialect: Dialect,
|
||||||
|
) -> str | None:
|
||||||
|
sql_type = _field_sql_type(field, dialect)
|
||||||
|
if not sql_type:
|
||||||
|
return None
|
||||||
|
table_sql = _quote_identifier(table, dialect)
|
||||||
|
column_sql = _quote_identifier(column, dialect)
|
||||||
|
null_sql = "" if bool(getattr(field, "null", False)) else " NOT NULL"
|
||||||
|
default_sql = _field_default_sql(field, dialect)
|
||||||
|
if not bool(getattr(field, "null", False)) and not default_sql:
|
||||||
|
return None
|
||||||
|
if dialect == "postgres":
|
||||||
|
return (
|
||||||
|
f"ALTER TABLE {table_sql} ADD COLUMN IF NOT EXISTS "
|
||||||
|
f"{column_sql} {sql_type}{default_sql}{null_sql}"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"ALTER TABLE {table_sql} ADD COLUMN "
|
||||||
|
f"{column_sql} {sql_type}{default_sql}{null_sql}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _table_columns(
|
||||||
|
connection: Any, table: str, dialect: Dialect
|
||||||
|
) -> dict[str, ColumnInfo] | None:
|
||||||
|
if dialect == "sqlite":
|
||||||
|
rows = await connection.execute_query_dict(
|
||||||
|
f"PRAGMA table_xinfo({_quote_identifier(table, dialect)})"
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
str(row.get("name")): ColumnInfo(
|
||||||
|
name=str(row.get("name")),
|
||||||
|
data_type=str(row.get("type") or ""),
|
||||||
|
nullable=not bool(row.get("notnull")),
|
||||||
|
default=str(row.get("dflt_value"))
|
||||||
|
if row.get("dflt_value") is not None
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
if row.get("name")
|
||||||
|
}
|
||||||
|
if dialect == "postgres":
|
||||||
|
rows = await connection.execute_query_dict(
|
||||||
|
"SELECT column_name, data_type, is_nullable, column_default "
|
||||||
|
"FROM information_schema.columns "
|
||||||
|
"WHERE table_schema = current_schema() AND table_name = $1",
|
||||||
|
[table],
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
str(row.get("column_name")): ColumnInfo(
|
||||||
|
name=str(row.get("column_name")),
|
||||||
|
data_type=str(row.get("data_type") or ""),
|
||||||
|
nullable=str(row.get("is_nullable") or "").upper() == "YES",
|
||||||
|
default=str(row.get("column_default"))
|
||||||
|
if row.get("column_default") is not None
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
if row.get("column_name")
|
||||||
|
}
|
||||||
|
if dialect == "mysql":
|
||||||
|
rows = await connection.execute_query_dict(
|
||||||
|
"SELECT COLUMN_NAME AS column_name, COLUMN_TYPE AS column_type, "
|
||||||
|
"IS_NULLABLE AS is_nullable, COLUMN_DEFAULT AS column_default "
|
||||||
|
"FROM INFORMATION_SCHEMA.COLUMNS "
|
||||||
|
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s",
|
||||||
|
[table],
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
str(row.get("column_name")): ColumnInfo(
|
||||||
|
name=str(row.get("column_name")),
|
||||||
|
data_type=str(row.get("column_type") or ""),
|
||||||
|
nullable=str(row.get("is_nullable") or "").upper() == "YES",
|
||||||
|
default=str(row.get("column_default"))
|
||||||
|
if row.get("column_default") is not None
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
if row.get("column_name")
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_type(value: str, dialect: Dialect) -> str:
|
||||||
|
text = value.lower().strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
if dialect == "sqlite":
|
||||||
|
if "int" in text:
|
||||||
|
return "integer"
|
||||||
|
if any(part in text for part in ("char", "clob", "text", "varchar")):
|
||||||
|
return "text"
|
||||||
|
if any(part in text for part in ("real", "floa", "doub")):
|
||||||
|
return "real"
|
||||||
|
if "blob" in text:
|
||||||
|
return "blob"
|
||||||
|
if "bool" in text:
|
||||||
|
return "integer"
|
||||||
|
if "timestamp" in text or "datetime" in text:
|
||||||
|
return "text"
|
||||||
|
return text
|
||||||
|
if dialect == "postgres":
|
||||||
|
text = text.replace("character varying", "varchar")
|
||||||
|
if text.startswith("timestamp"):
|
||||||
|
return "timestamp"
|
||||||
|
if text in {"boolean", "bool"}:
|
||||||
|
return "boolean"
|
||||||
|
return text
|
||||||
|
if dialect == "mysql":
|
||||||
|
if text.startswith("tinyint(1)") or text == "bool" or text == "boolean":
|
||||||
|
return "boolean"
|
||||||
|
if text.startswith("datetime") or text.startswith("timestamp"):
|
||||||
|
return "datetime"
|
||||||
|
return text
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _field_type_compatible(field: Any, column: ColumnInfo, dialect: Dialect) -> bool:
|
||||||
|
expected = _field_sql_type(field, dialect)
|
||||||
|
if not expected:
|
||||||
|
return True
|
||||||
|
expected_norm = _normalize_type(expected, dialect)
|
||||||
|
actual_norm = _normalize_type(column.data_type, dialect)
|
||||||
|
if not expected_norm or not actual_norm:
|
||||||
|
return True
|
||||||
|
if expected_norm == actual_norm:
|
||||||
|
return True
|
||||||
|
if dialect == "sqlite":
|
||||||
|
# SQLite affinity is intentionally loose; varchar/text and bool/integer are
|
||||||
|
# compatible enough for startup validation.
|
||||||
|
compatible = {
|
||||||
|
("varchar", "text"),
|
||||||
|
("text", "varchar"),
|
||||||
|
("boolean", "integer"),
|
||||||
|
("integer", "boolean"),
|
||||||
|
}
|
||||||
|
return (expected_norm, actual_norm) in compatible
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _index_name(table: str, columns: tuple[str, ...]) -> str:
|
||||||
|
raw = f"idx_{table}_{'_'.join(columns)}"
|
||||||
|
return raw[:62]
|
||||||
|
|
||||||
|
|
||||||
|
async def _table_indexes(
|
||||||
|
connection: Any, table: str, dialect: Dialect
|
||||||
|
) -> set[tuple[str, ...]]:
|
||||||
|
indexes: set[tuple[str, ...]] = set()
|
||||||
|
if dialect == "sqlite":
|
||||||
|
rows = await connection.execute_query_dict(
|
||||||
|
f"PRAGMA index_list({_quote_identifier(table, dialect)})"
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
if bool(row.get("unique")):
|
||||||
|
continue
|
||||||
|
index_name = row.get("name")
|
||||||
|
if not index_name:
|
||||||
|
continue
|
||||||
|
info = await connection.execute_query_dict(
|
||||||
|
f"PRAGMA index_info({_quote_identifier(str(index_name), dialect)})"
|
||||||
|
)
|
||||||
|
columns = tuple(
|
||||||
|
str(item.get("name"))
|
||||||
|
for item in sorted(info, key=lambda item: int(item.get("seqno") or 0))
|
||||||
|
if item.get("name")
|
||||||
|
)
|
||||||
|
if columns:
|
||||||
|
indexes.add(columns)
|
||||||
|
return indexes
|
||||||
|
if dialect == "postgres":
|
||||||
|
rows = await connection.execute_query_dict(
|
||||||
|
"SELECT indexname, indexdef FROM pg_indexes "
|
||||||
|
"WHERE schemaname = current_schema() AND tablename = $1",
|
||||||
|
[table],
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
indexdef = str(row.get("indexdef") or "")
|
||||||
|
if " UNIQUE INDEX " in indexdef.upper():
|
||||||
|
continue
|
||||||
|
start = indexdef.rfind("(")
|
||||||
|
end = indexdef.rfind(")")
|
||||||
|
if start < 0 or end <= start:
|
||||||
|
continue
|
||||||
|
columns = tuple(
|
||||||
|
part.strip().strip('"')
|
||||||
|
for part in indexdef[start + 1 : end].split(",")
|
||||||
|
if part.strip()
|
||||||
|
)
|
||||||
|
if columns:
|
||||||
|
indexes.add(columns)
|
||||||
|
return indexes
|
||||||
|
if dialect == "mysql":
|
||||||
|
rows = await connection.execute_query_dict(
|
||||||
|
"SELECT INDEX_NAME, COLUMN_NAME, SEQ_IN_INDEX, NON_UNIQUE "
|
||||||
|
"FROM INFORMATION_SCHEMA.STATISTICS "
|
||||||
|
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s",
|
||||||
|
[table],
|
||||||
|
)
|
||||||
|
grouped: dict[str, list[tuple[int, str]]] = {}
|
||||||
|
unique_names: set[str] = set()
|
||||||
|
for row in rows:
|
||||||
|
name = str(row.get("INDEX_NAME") or "")
|
||||||
|
column = str(row.get("COLUMN_NAME") or "")
|
||||||
|
if not name or not column:
|
||||||
|
continue
|
||||||
|
if int(row.get("NON_UNIQUE") or 0) == 0:
|
||||||
|
unique_names.add(name)
|
||||||
|
continue
|
||||||
|
grouped.setdefault(name, []).append(
|
||||||
|
(int(row.get("SEQ_IN_INDEX") or 0), column)
|
||||||
|
)
|
||||||
|
for name, items in grouped.items():
|
||||||
|
if name in unique_names:
|
||||||
|
continue
|
||||||
|
columns = tuple(column for _, column in sorted(items))
|
||||||
|
if columns:
|
||||||
|
indexes.add(columns)
|
||||||
|
return indexes
|
||||||
|
return indexes
|
||||||
|
|
||||||
|
|
||||||
|
def _index_sql(table: str, columns: tuple[str, ...], dialect: Dialect) -> str | None:
|
||||||
|
if not columns:
|
||||||
|
return None
|
||||||
|
table_sql = _quote_identifier(table, dialect)
|
||||||
|
index_sql = _quote_identifier(_index_name(table, columns), dialect)
|
||||||
|
columns_sql = ", ".join(_quote_identifier(column, dialect) for column in columns)
|
||||||
|
if dialect in {"sqlite", "postgres"}:
|
||||||
|
return (
|
||||||
|
f"CREATE INDEX IF NOT EXISTS {index_sql} " f"ON {table_sql}({columns_sql})"
|
||||||
|
)
|
||||||
|
if dialect == "mysql":
|
||||||
|
return f"CREATE INDEX {index_sql} ON {table_sql}({columns_sql})"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _meta_index_columns(index: Any) -> tuple[str, ...]:
|
||||||
|
if isinstance(index, str):
|
||||||
|
return (index,)
|
||||||
|
if isinstance(index, list | tuple):
|
||||||
|
return tuple(str(column) for column in index if column)
|
||||||
|
fields = getattr(index, "fields", None)
|
||||||
|
if fields:
|
||||||
|
return tuple(str(column) for column in fields if column)
|
||||||
|
return ()
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_drift(table: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"table": table,
|
||||||
|
"missing_columns": [],
|
||||||
|
"extra_columns": [],
|
||||||
|
"type_mismatches": [],
|
||||||
|
"missing_indexes": [],
|
||||||
|
"unsafe_changes": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _write_schema_report(result: SchemaGuardResult) -> None:
|
||||||
|
report = {
|
||||||
|
"created_at": datetime.now().isoformat(timespec="seconds"),
|
||||||
|
"summary": {
|
||||||
|
"checked_tables": result.checked_tables,
|
||||||
|
"repaired_columns": result.repaired_columns,
|
||||||
|
"repaired_indexes": result.repaired_indexes,
|
||||||
|
"skipped_columns": result.skipped_columns,
|
||||||
|
"skipped_indexes": result.skipped_indexes,
|
||||||
|
"type_mismatches": result.type_mismatches,
|
||||||
|
"warnings": result.warnings,
|
||||||
|
},
|
||||||
|
"tables": result.drift or [],
|
||||||
|
}
|
||||||
|
path = Path() / "data" / "db" / "schema_report.json"
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
await asyncio.to_thread(
|
||||||
|
path.write_text,
|
||||||
|
json.dumps(report, ensure_ascii=False, indent=2),
|
||||||
|
"utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def repair_safe_schema_drift() -> SchemaGuardResult:
|
||||||
|
"""Repair low-risk missing columns after Tortoise schema creation.
|
||||||
|
|
||||||
|
This guard intentionally avoids type changes, constraints, unique indexes, foreign
|
||||||
|
keys, and SQLite table rebuilds. It is a startup-only safety net for model/schema
|
||||||
|
drift caused by copied databases or skipped legacy script hashes.
|
||||||
|
"""
|
||||||
|
result = SchemaGuardResult()
|
||||||
|
result.drift = []
|
||||||
|
connection = Tortoise.get_connection("default")
|
||||||
|
dialect = _connection_dialect(connection)
|
||||||
|
if dialect == "unknown":
|
||||||
|
logger.debug("SchemaGuard 跳过未知数据库方言", LOG_COMMAND)
|
||||||
|
return result
|
||||||
|
|
||||||
|
app = Tortoise.apps.get("models", {})
|
||||||
|
for model in app.values():
|
||||||
|
meta = getattr(model, "_meta", None)
|
||||||
|
table = getattr(meta, "db_table", None) or getattr(meta, "table", None)
|
||||||
|
if not table:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
columns = await _table_columns(connection, table, dialect)
|
||||||
|
except Exception as exc:
|
||||||
|
result.warnings += 1
|
||||||
|
logger.debug(f"SchemaGuard 检查表 {table} 失败", LOG_COMMAND, e=exc)
|
||||||
|
continue
|
||||||
|
if columns is None:
|
||||||
|
continue
|
||||||
|
result.checked_tables += 1
|
||||||
|
drift = _empty_drift(table)
|
||||||
|
fields_map = getattr(meta, "fields_map", {}) or {}
|
||||||
|
expected_sources: set[str] = set()
|
||||||
|
for field_name, field in fields_map.items():
|
||||||
|
if not _is_db_field(field, dialect):
|
||||||
|
continue
|
||||||
|
source = str(getattr(field, "source_field", None) or field_name)
|
||||||
|
expected_sources.add(source)
|
||||||
|
if source in columns:
|
||||||
|
if not _field_type_compatible(field, columns[source], dialect):
|
||||||
|
result.type_mismatches += 1
|
||||||
|
drift["type_mismatches"].append(
|
||||||
|
{
|
||||||
|
"column": source,
|
||||||
|
"expected": _field_sql_type(field, dialect),
|
||||||
|
"actual": columns[source].data_type,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
drift["missing_columns"].append(source)
|
||||||
|
if not _is_safe_missing_field(field):
|
||||||
|
result.skipped_columns += 1
|
||||||
|
drift["unsafe_changes"].append(
|
||||||
|
{
|
||||||
|
"kind": "missing_required_column",
|
||||||
|
"column": source,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"SchemaGuard 跳过非低风险缺字段: {table}.{source}",
|
||||||
|
LOG_COMMAND,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
sql = _missing_column_sql(table, source, field, dialect)
|
||||||
|
if not sql:
|
||||||
|
result.skipped_columns += 1
|
||||||
|
logger.debug(
|
||||||
|
f"SchemaGuard 无法生成补字段 SQL: {table}.{source}",
|
||||||
|
LOG_COMMAND,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
connection.execute_query_dict(sql),
|
||||||
|
timeout=DB_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
columns[source] = ColumnInfo(name=source, data_type="")
|
||||||
|
result.repaired_columns += 1
|
||||||
|
logger.info(f"SchemaGuard 已补齐字段: {table}.{source}", LOG_COMMAND)
|
||||||
|
except OperationalError as exc:
|
||||||
|
err = str(exc).lower()
|
||||||
|
if any(
|
||||||
|
text in err
|
||||||
|
for text in ("duplicate column", "already exists", "已存在")
|
||||||
|
):
|
||||||
|
columns[source] = ColumnInfo(name=source, data_type="")
|
||||||
|
continue
|
||||||
|
result.warnings += 1
|
||||||
|
logger.warning(
|
||||||
|
f"SchemaGuard 补齐字段失败: {table}.{source}",
|
||||||
|
LOG_COMMAND,
|
||||||
|
e=exc,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
result.warnings += 1
|
||||||
|
logger.warning(
|
||||||
|
f"SchemaGuard 补齐字段失败: {table}.{source}",
|
||||||
|
LOG_COMMAND,
|
||||||
|
e=exc,
|
||||||
|
)
|
||||||
|
drift["extra_columns"] = sorted(
|
||||||
|
column for column in columns.keys() if column not in expected_sources
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
existing_indexes = await _table_indexes(connection, table, dialect)
|
||||||
|
except Exception as exc:
|
||||||
|
result.warnings += 1
|
||||||
|
logger.debug(f"SchemaGuard 检查索引 {table} 失败", LOG_COMMAND, e=exc)
|
||||||
|
existing_indexes = set()
|
||||||
|
indexes = getattr(meta, "indexes", ()) or ()
|
||||||
|
for index in indexes:
|
||||||
|
index_columns = _meta_index_columns(index)
|
||||||
|
if not index_columns or index_columns in existing_indexes:
|
||||||
|
continue
|
||||||
|
drift["missing_indexes"].append(list(index_columns))
|
||||||
|
if not all(column in columns for column in index_columns):
|
||||||
|
result.skipped_indexes += 1
|
||||||
|
logger.debug(
|
||||||
|
f"SchemaGuard 跳过缺字段索引: {table}.{index_columns}",
|
||||||
|
LOG_COMMAND,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
sql = _index_sql(table, index_columns, dialect)
|
||||||
|
if not sql:
|
||||||
|
result.skipped_indexes += 1
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
connection.execute_query_dict(sql),
|
||||||
|
timeout=DB_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
existing_indexes.add(index_columns)
|
||||||
|
result.repaired_indexes += 1
|
||||||
|
logger.debug(
|
||||||
|
f"SchemaGuard 已补齐索引: {table}.{index_columns}",
|
||||||
|
LOG_COMMAND,
|
||||||
|
)
|
||||||
|
except OperationalError as exc:
|
||||||
|
err = str(exc).lower()
|
||||||
|
if any(
|
||||||
|
text in err for text in ("already exists", "duplicate", "已存在")
|
||||||
|
):
|
||||||
|
existing_indexes.add(index_columns)
|
||||||
|
continue
|
||||||
|
result.warnings += 1
|
||||||
|
logger.warning(
|
||||||
|
f"SchemaGuard 补齐索引失败: {table}.{index_columns}",
|
||||||
|
LOG_COMMAND,
|
||||||
|
e=exc,
|
||||||
|
)
|
||||||
|
if any(
|
||||||
|
drift[key]
|
||||||
|
for key in (
|
||||||
|
"missing_columns",
|
||||||
|
"extra_columns",
|
||||||
|
"type_mismatches",
|
||||||
|
"missing_indexes",
|
||||||
|
"unsafe_changes",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
result.drift.append(drift)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"SchemaGuard 完成: "
|
||||||
|
f"checked={result.checked_tables}, "
|
||||||
|
f"repaired_columns={result.repaired_columns}, "
|
||||||
|
f"repaired_indexes={result.repaired_indexes}, "
|
||||||
|
f"skipped_columns={result.skipped_columns}, "
|
||||||
|
f"skipped_indexes={result.skipped_indexes}, "
|
||||||
|
f"type_mismatches={result.type_mismatches}, "
|
||||||
|
f"warnings={result.warnings}",
|
||||||
|
LOG_COMMAND,
|
||||||
|
)
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await _write_schema_report(result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def repair_table_schema(table_name: str) -> SchemaGuardResult:
|
||||||
|
"""Repair one table by reusing the startup guard path.
|
||||||
|
|
||||||
|
The current guard is cheap enough and already table-scoped internally by model
|
||||||
|
metadata, so this helper keeps write-path recovery simple and conservative.
|
||||||
|
"""
|
||||||
|
result = SchemaGuardResult()
|
||||||
|
result.drift = []
|
||||||
|
connection = Tortoise.get_connection("default")
|
||||||
|
dialect = _connection_dialect(connection)
|
||||||
|
if dialect == "unknown":
|
||||||
|
return result
|
||||||
|
app = Tortoise.apps.get("models", {})
|
||||||
|
for model in app.values():
|
||||||
|
meta = getattr(model, "_meta", None)
|
||||||
|
table = getattr(meta, "db_table", None) or getattr(meta, "table", None)
|
||||||
|
if table == table_name:
|
||||||
|
# Keep the implementation conservative: repairing all tables is still
|
||||||
|
# startup-style work and avoids a second partial code path.
|
||||||
|
return await repair_safe_schema_drift()
|
||||||
|
return result
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Literal, Protocol
|
||||||
|
|
||||||
|
Dialect = Literal["sqlite", "postgres", "mysql", "unknown"]
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaOpRisk(str, Enum):
|
||||||
|
SAFE = "safe"
|
||||||
|
GUARDED = "guarded"
|
||||||
|
MANUAL = "manual"
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaOp(Protocol):
|
||||||
|
risk: SchemaOpRisk
|
||||||
|
|
||||||
|
def to_sql(self, dialect: Dialect) -> list[str]: ...
|
||||||
|
|
||||||
|
|
||||||
|
def quote_identifier(identifier: str, dialect: Dialect) -> str:
|
||||||
|
if dialect == "mysql":
|
||||||
|
return f"`{identifier.replace('`', '``')}`"
|
||||||
|
return f'"{identifier.replace(chr(34), chr(34) + chr(34))}"'
|
||||||
|
|
||||||
|
|
||||||
|
def _column_type(column_type: str | dict[str, str], dialect: Dialect) -> str:
|
||||||
|
if isinstance(column_type, dict):
|
||||||
|
return column_type.get(dialect) or column_type.get("default") or "TEXT"
|
||||||
|
return column_type
|
||||||
|
|
||||||
|
|
||||||
|
def _default_sql(default: str | float | bool | None, dialect: Dialect) -> str:
|
||||||
|
if default is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(default, bool):
|
||||||
|
if dialect == "postgres":
|
||||||
|
return " DEFAULT TRUE" if default else " DEFAULT FALSE"
|
||||||
|
return " DEFAULT 1" if default else " DEFAULT 0"
|
||||||
|
if isinstance(default, int | float):
|
||||||
|
return f" DEFAULT {default}"
|
||||||
|
escaped = default.replace("'", "''")
|
||||||
|
return f" DEFAULT '{escaped}'"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AddColumn:
|
||||||
|
table: str
|
||||||
|
column: str
|
||||||
|
column_type: str | dict[str, str]
|
||||||
|
nullable: bool = True
|
||||||
|
default: str | float | bool | None = None
|
||||||
|
risk: SchemaOpRisk = SchemaOpRisk.SAFE
|
||||||
|
|
||||||
|
def to_sql(self, dialect: Dialect) -> list[str]:
|
||||||
|
table = quote_identifier(self.table, dialect)
|
||||||
|
column = quote_identifier(self.column, dialect)
|
||||||
|
column_type = _column_type(self.column_type, dialect)
|
||||||
|
null_sql = "" if self.nullable else " NOT NULL"
|
||||||
|
default_sql = _default_sql(self.default, dialect)
|
||||||
|
if not self.nullable and not default_sql:
|
||||||
|
return []
|
||||||
|
if dialect == "postgres":
|
||||||
|
return [
|
||||||
|
"ALTER TABLE "
|
||||||
|
f"{table} ADD COLUMN IF NOT EXISTS {column} "
|
||||||
|
f"{column_type}{default_sql}{null_sql}"
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
"ALTER TABLE "
|
||||||
|
f"{table} ADD COLUMN {column} {column_type}{default_sql}{null_sql}"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CreateIndex:
|
||||||
|
table: str
|
||||||
|
columns: tuple[str, ...]
|
||||||
|
name: str | None = None
|
||||||
|
if_not_exists: bool = True
|
||||||
|
unique: bool = False
|
||||||
|
where: str | None = None
|
||||||
|
risk: SchemaOpRisk = SchemaOpRisk.SAFE
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
table: str,
|
||||||
|
columns: tuple[str, ...] | list[str],
|
||||||
|
name: str | None = None,
|
||||||
|
if_not_exists: bool = True,
|
||||||
|
unique: bool = False,
|
||||||
|
where: str | None = None,
|
||||||
|
risk: SchemaOpRisk | None = None,
|
||||||
|
) -> None:
|
||||||
|
object.__setattr__(self, "table", table)
|
||||||
|
object.__setattr__(self, "columns", tuple(columns))
|
||||||
|
object.__setattr__(self, "name", name)
|
||||||
|
object.__setattr__(self, "if_not_exists", if_not_exists)
|
||||||
|
object.__setattr__(self, "unique", unique)
|
||||||
|
object.__setattr__(self, "where", where)
|
||||||
|
resolved_risk = risk or (SchemaOpRisk.MANUAL if unique else SchemaOpRisk.SAFE)
|
||||||
|
object.__setattr__(self, "risk", resolved_risk)
|
||||||
|
|
||||||
|
def to_sql(self, dialect: Dialect) -> list[str]:
|
||||||
|
if self.unique or not self.columns:
|
||||||
|
return []
|
||||||
|
name = self.name or f"idx_{self.table}_{'_'.join(self.columns)}"[:62]
|
||||||
|
if dialect == "mysql" and self.where:
|
||||||
|
return []
|
||||||
|
exists_sql = (
|
||||||
|
"IF NOT EXISTS " if self.if_not_exists and dialect != "mysql" else ""
|
||||||
|
)
|
||||||
|
table = quote_identifier(self.table, dialect)
|
||||||
|
index = quote_identifier(name, dialect)
|
||||||
|
columns = ", ".join(
|
||||||
|
quote_identifier(column, dialect) for column in self.columns
|
||||||
|
)
|
||||||
|
where = f" WHERE {self.where}" if self.where and dialect != "mysql" else ""
|
||||||
|
return [f"CREATE INDEX {exists_sql}{index} ON {table}({columns}){where}"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RenameColumn:
|
||||||
|
table: str
|
||||||
|
old: str
|
||||||
|
new: str
|
||||||
|
risk: SchemaOpRisk = SchemaOpRisk.GUARDED
|
||||||
|
|
||||||
|
def to_sql(self, dialect: Dialect) -> list[str]:
|
||||||
|
table = quote_identifier(self.table, dialect)
|
||||||
|
old = quote_identifier(self.old, dialect)
|
||||||
|
new = quote_identifier(self.new, dialect)
|
||||||
|
return [f"ALTER TABLE {table} RENAME COLUMN {old} TO {new}"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DropColumn:
|
||||||
|
table: str
|
||||||
|
column: str
|
||||||
|
risk: SchemaOpRisk = SchemaOpRisk.GUARDED
|
||||||
|
|
||||||
|
def to_sql(self, dialect: Dialect) -> list[str]:
|
||||||
|
table = quote_identifier(self.table, dialect)
|
||||||
|
column = quote_identifier(self.column, dialect)
|
||||||
|
return [f"ALTER TABLE {table} DROP COLUMN {column}"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AlterColumnType:
|
||||||
|
table: str
|
||||||
|
column: str
|
||||||
|
column_type: str | dict[str, str]
|
||||||
|
nullable: bool | None = None
|
||||||
|
risk: SchemaOpRisk = SchemaOpRisk.GUARDED
|
||||||
|
|
||||||
|
def to_sql(self, dialect: Dialect) -> list[str]:
|
||||||
|
column_type = _column_type(self.column_type, dialect)
|
||||||
|
table = quote_identifier(self.table, dialect)
|
||||||
|
column = quote_identifier(self.column, dialect)
|
||||||
|
if dialect == "postgres":
|
||||||
|
return [f"ALTER TABLE {table} ALTER COLUMN {column} TYPE {column_type}"]
|
||||||
|
if dialect == "mysql":
|
||||||
|
null_sql = " NULL" if self.nullable else " NOT NULL"
|
||||||
|
if self.nullable is None:
|
||||||
|
null_sql = ""
|
||||||
|
return [
|
||||||
|
f"ALTER TABLE {table} MODIFY COLUMN {column} {column_type}{null_sql}"
|
||||||
|
]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_schema_ops(items: list[str | SchemaOp], dialect: Dialect) -> list[str]:
|
||||||
|
sql_list: list[str] = []
|
||||||
|
for item in items:
|
||||||
|
if isinstance(item, str):
|
||||||
|
sql_list.append(item)
|
||||||
|
else:
|
||||||
|
sql_list.extend(item.to_sql(dialect))
|
||||||
|
return sql_list
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from tortoise.functions import Count
|
||||||
|
|
||||||
|
from zhenxun.services.cache.bounded_ttl import BoundedTTLCache
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class GroupMemberSnapshot:
|
||||||
|
id: int
|
||||||
|
user_id: str
|
||||||
|
user_name: str
|
||||||
|
group_id: str
|
||||||
|
user_join_time: datetime | None
|
||||||
|
uid: int | None
|
||||||
|
platform: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def _member_cache_sizeof(members: tuple[GroupMemberSnapshot, ...]) -> int:
|
||||||
|
size = 0
|
||||||
|
for member in members:
|
||||||
|
size += 96
|
||||||
|
size += len(member.user_id) + len(member.user_name) + len(member.group_id)
|
||||||
|
size += len(member.platform or "")
|
||||||
|
return size
|
||||||
|
|
||||||
|
|
||||||
|
_GROUP_MEMBER_CACHE = BoundedTTLCache[str, tuple[GroupMemberSnapshot, ...]](
|
||||||
|
"hot_group_info_users",
|
||||||
|
ttl_seconds=45,
|
||||||
|
max_items=512,
|
||||||
|
max_total_bytes=32 * 1024 * 1024,
|
||||||
|
sizeof=_member_cache_sizeof,
|
||||||
|
)
|
||||||
|
_GROUP_USER_IDS_CACHE = BoundedTTLCache[str, tuple[str, ...]](
|
||||||
|
"hot_group_info_user_ids",
|
||||||
|
ttl_seconds=45,
|
||||||
|
max_items=2048,
|
||||||
|
)
|
||||||
|
_GROUP_MEMBER_BY_ID_CACHE = BoundedTTLCache[str, tuple[GroupMemberSnapshot | None]](
|
||||||
|
"hot_group_info_user_by_id",
|
||||||
|
ttl_seconds=45,
|
||||||
|
max_items=50000,
|
||||||
|
)
|
||||||
|
_USER_GROUP_CACHE = BoundedTTLCache[str, tuple[str, ...]](
|
||||||
|
"hot_group_info_user_groups",
|
||||||
|
ttl_seconds=45,
|
||||||
|
max_items=4096,
|
||||||
|
)
|
||||||
|
_USER_NAME_CACHE = BoundedTTLCache[str, str](
|
||||||
|
"hot_group_info_user_names",
|
||||||
|
ttl_seconds=45,
|
||||||
|
max_items=20000,
|
||||||
|
)
|
||||||
|
_CHAT_RANK_CACHE = BoundedTTLCache[str, tuple[tuple[str, int], ...]](
|
||||||
|
"hot_chat_history_rank",
|
||||||
|
ttl_seconds=20,
|
||||||
|
max_items=512,
|
||||||
|
)
|
||||||
|
_CHAT_FIRST_MSG_CACHE = BoundedTTLCache[str, tuple[datetime | None]](
|
||||||
|
"hot_chat_history_first_msg",
|
||||||
|
ttl_seconds=300,
|
||||||
|
max_items=2048,
|
||||||
|
)
|
||||||
|
_STATISTICS_COUNT_CACHE = BoundedTTLCache[str, tuple[tuple[str, int], ...]](
|
||||||
|
"hot_statistics_plugin_counts",
|
||||||
|
ttl_seconds=20,
|
||||||
|
max_items=512,
|
||||||
|
)
|
||||||
|
|
||||||
|
_GROUP_MEMBER_LOCKS: dict[str, asyncio.Lock] = {}
|
||||||
|
_GROUP_USER_IDS_LOCKS: dict[str, asyncio.Lock] = {}
|
||||||
|
_USER_GROUP_LOCKS: dict[str, asyncio.Lock] = {}
|
||||||
|
_CHAT_RANK_LOCKS: dict[str, asyncio.Lock] = {}
|
||||||
|
_CHAT_FIRST_MSG_LOCKS: dict[str, asyncio.Lock] = {}
|
||||||
|
_STATISTICS_LOCKS: dict[str, asyncio.Lock] = {}
|
||||||
|
_MAX_LOCK_POOL_SIZE = 4096
|
||||||
|
|
||||||
|
|
||||||
|
def _get_lock(pool: dict[str, asyncio.Lock], key: str) -> asyncio.Lock:
|
||||||
|
lock = pool.get(key)
|
||||||
|
if lock is None:
|
||||||
|
if len(pool) >= _MAX_LOCK_POOL_SIZE:
|
||||||
|
for old_key, old_lock in list(pool.items()):
|
||||||
|
if not old_lock.locked():
|
||||||
|
pool.pop(old_key, None)
|
||||||
|
break
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
pool[key] = lock
|
||||||
|
return lock
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_id(value: object) -> str:
|
||||||
|
return str(value or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_ids(values: Iterable[object] | None) -> tuple[str, ...] | None:
|
||||||
|
if values is None:
|
||||||
|
return None
|
||||||
|
return tuple(dict.fromkeys(v for value in values if (v := _normalize_id(value))))
|
||||||
|
|
||||||
|
|
||||||
|
async def get_group_members(
|
||||||
|
group_id: str | int | None,
|
||||||
|
) -> tuple[GroupMemberSnapshot, ...]:
|
||||||
|
"""Return lightweight group-member snapshots with a short runtime TTL."""
|
||||||
|
group_key = _normalize_id(group_id)
|
||||||
|
if not group_key:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
cached = await _GROUP_MEMBER_CACHE.get(group_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
lock = _get_lock(_GROUP_MEMBER_LOCKS, group_key)
|
||||||
|
async with lock:
|
||||||
|
cached = await _GROUP_MEMBER_CACHE.get(group_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
from zhenxun.models.group_member_info import GroupInfoUser
|
||||||
|
|
||||||
|
rows = await GroupInfoUser.filter(group_id=group_key).values_list(
|
||||||
|
"id",
|
||||||
|
"user_id",
|
||||||
|
"user_name",
|
||||||
|
"user_join_time",
|
||||||
|
"uid",
|
||||||
|
"platform",
|
||||||
|
)
|
||||||
|
members = tuple(
|
||||||
|
GroupMemberSnapshot(
|
||||||
|
id=int(row[0] or 0),
|
||||||
|
user_id=str(row[1] or ""),
|
||||||
|
user_name=str(row[2] or ""),
|
||||||
|
group_id=group_key,
|
||||||
|
user_join_time=row[3],
|
||||||
|
uid=int(row[4]) if row[4] is not None else None,
|
||||||
|
platform=str(row[5]) if row[5] else None,
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
if row[1]
|
||||||
|
)
|
||||||
|
await _GROUP_MEMBER_CACHE.set(group_key, members)
|
||||||
|
await _GROUP_USER_IDS_CACHE.set(
|
||||||
|
group_key, tuple(member.user_id for member in members)
|
||||||
|
)
|
||||||
|
return members
|
||||||
|
|
||||||
|
|
||||||
|
async def get_group_member_map(
|
||||||
|
group_id: str | int | None,
|
||||||
|
user_ids: Iterable[object] | None = None,
|
||||||
|
) -> dict[str, GroupMemberSnapshot]:
|
||||||
|
group_key = _normalize_id(group_id)
|
||||||
|
if not group_key:
|
||||||
|
return {}
|
||||||
|
wanted = _normalize_ids(user_ids)
|
||||||
|
if wanted is not None and not wanted:
|
||||||
|
return {}
|
||||||
|
if wanted is None:
|
||||||
|
members = await get_group_members(group_key)
|
||||||
|
return {member.user_id: member for member in members}
|
||||||
|
|
||||||
|
cached_members = await _GROUP_MEMBER_CACHE.get(group_key)
|
||||||
|
if cached_members is not None:
|
||||||
|
wanted_set = set(wanted)
|
||||||
|
return {
|
||||||
|
member.user_id: member
|
||||||
|
for member in cached_members
|
||||||
|
if member.user_id in wanted_set
|
||||||
|
}
|
||||||
|
|
||||||
|
result: dict[str, GroupMemberSnapshot] = {}
|
||||||
|
missing: list[str] = []
|
||||||
|
for user_id in wanted:
|
||||||
|
cache_key = f"{group_key}:{user_id}"
|
||||||
|
cached = await _GROUP_MEMBER_BY_ID_CACHE.get(cache_key)
|
||||||
|
if cached is None:
|
||||||
|
missing.append(user_id)
|
||||||
|
else:
|
||||||
|
member = cached[0]
|
||||||
|
if member is not None:
|
||||||
|
result[user_id] = member
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
from zhenxun.models.group_member_info import GroupInfoUser
|
||||||
|
|
||||||
|
rows = await GroupInfoUser.filter(
|
||||||
|
group_id=group_key, user_id__in=missing
|
||||||
|
).values_list(
|
||||||
|
"id",
|
||||||
|
"user_id",
|
||||||
|
"user_name",
|
||||||
|
"user_join_time",
|
||||||
|
"uid",
|
||||||
|
"platform",
|
||||||
|
)
|
||||||
|
found: set[str] = set()
|
||||||
|
for row in rows:
|
||||||
|
if not row[1]:
|
||||||
|
continue
|
||||||
|
member = GroupMemberSnapshot(
|
||||||
|
id=int(row[0] or 0),
|
||||||
|
user_id=str(row[1] or ""),
|
||||||
|
user_name=str(row[2] or ""),
|
||||||
|
group_id=group_key,
|
||||||
|
user_join_time=row[3],
|
||||||
|
uid=int(row[4]) if row[4] is not None else None,
|
||||||
|
platform=str(row[5]) if row[5] else None,
|
||||||
|
)
|
||||||
|
result[member.user_id] = member
|
||||||
|
found.add(member.user_id)
|
||||||
|
await _GROUP_MEMBER_BY_ID_CACHE.set(
|
||||||
|
f"{group_key}:{member.user_id}", (member,)
|
||||||
|
)
|
||||||
|
for user_id in missing:
|
||||||
|
if user_id not in found:
|
||||||
|
await _GROUP_MEMBER_BY_ID_CACHE.set(f"{group_key}:{user_id}", (None,))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def get_group_member(
|
||||||
|
group_id: str | int | None,
|
||||||
|
user_id: str | int | None,
|
||||||
|
) -> GroupMemberSnapshot | None:
|
||||||
|
user_key = _normalize_id(user_id)
|
||||||
|
if not user_key:
|
||||||
|
return None
|
||||||
|
return (await get_group_member_map(group_id, [user_key])).get(user_key)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_group_user_ids(group_id: str | int | None) -> set[str]:
|
||||||
|
group_key = _normalize_id(group_id)
|
||||||
|
if not group_key:
|
||||||
|
return set()
|
||||||
|
cached = await _GROUP_USER_IDS_CACHE.get(group_key)
|
||||||
|
if cached is not None:
|
||||||
|
return set(cached)
|
||||||
|
|
||||||
|
cached_members = await _GROUP_MEMBER_CACHE.get(group_key)
|
||||||
|
if cached_members is not None:
|
||||||
|
user_ids = tuple(member.user_id for member in cached_members)
|
||||||
|
await _GROUP_USER_IDS_CACHE.set(group_key, user_ids)
|
||||||
|
return set(user_ids)
|
||||||
|
|
||||||
|
lock = _get_lock(_GROUP_USER_IDS_LOCKS, group_key)
|
||||||
|
async with lock:
|
||||||
|
cached = await _GROUP_USER_IDS_CACHE.get(group_key)
|
||||||
|
if cached is not None:
|
||||||
|
return set(cached)
|
||||||
|
|
||||||
|
from zhenxun.models.group_member_info import GroupInfoUser
|
||||||
|
|
||||||
|
rows = await GroupInfoUser.filter(group_id=group_key).values_list(
|
||||||
|
"user_id", flat=True
|
||||||
|
)
|
||||||
|
user_ids = tuple(str(user_id) for user_id in rows if user_id)
|
||||||
|
await _GROUP_USER_IDS_CACHE.set(group_key, user_ids)
|
||||||
|
return set(user_ids)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_group_ids(user_id: str | int | None) -> list[str]:
|
||||||
|
user_key = _normalize_id(user_id)
|
||||||
|
if not user_key:
|
||||||
|
return []
|
||||||
|
|
||||||
|
cached = await _USER_GROUP_CACHE.get(user_key)
|
||||||
|
if cached is not None:
|
||||||
|
return list(cached)
|
||||||
|
|
||||||
|
lock = _get_lock(_USER_GROUP_LOCKS, user_key)
|
||||||
|
async with lock:
|
||||||
|
cached = await _USER_GROUP_CACHE.get(user_key)
|
||||||
|
if cached is not None:
|
||||||
|
return list(cached)
|
||||||
|
|
||||||
|
from zhenxun.models.group_member_info import GroupInfoUser
|
||||||
|
|
||||||
|
rows = await GroupInfoUser.filter(user_id=user_key).values_list(
|
||||||
|
"group_id", flat=True
|
||||||
|
)
|
||||||
|
group_ids = tuple(str(group_id) for group_id in rows if group_id)
|
||||||
|
await _USER_GROUP_CACHE.set(user_key, group_ids)
|
||||||
|
return list(group_ids)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_member_names(
|
||||||
|
user_ids: Iterable[object],
|
||||||
|
group_id: str | int | None = None,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
user_keys = _normalize_ids(user_ids) or ()
|
||||||
|
if not user_keys:
|
||||||
|
return {}
|
||||||
|
if group_id:
|
||||||
|
members = await get_group_member_map(group_id, user_keys)
|
||||||
|
return {user_id: members[user_id].user_name for user_id in members}
|
||||||
|
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
missing: list[str] = []
|
||||||
|
for user_id in user_keys:
|
||||||
|
cached = await _USER_NAME_CACHE.get(user_id)
|
||||||
|
if cached is None:
|
||||||
|
missing.append(user_id)
|
||||||
|
else:
|
||||||
|
result[user_id] = cached
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
from zhenxun.models.group_member_info import GroupInfoUser
|
||||||
|
|
||||||
|
rows = await GroupInfoUser.filter(user_id__in=missing).values_list(
|
||||||
|
"user_id", "user_name"
|
||||||
|
)
|
||||||
|
for user_id, user_name in rows:
|
||||||
|
user_key = str(user_id)
|
||||||
|
if user_key not in result:
|
||||||
|
result[user_key] = str(user_name or "")
|
||||||
|
for user_id in missing:
|
||||||
|
await _USER_NAME_CACHE.set(user_id, result.get(user_id, ""))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def get_member_name(
|
||||||
|
user_id: str | int | None,
|
||||||
|
group_id: str | int | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
user_key = _normalize_id(user_id)
|
||||||
|
if not user_key:
|
||||||
|
return None
|
||||||
|
return (await get_member_names([user_key], group_id)).get(user_key) or None
|
||||||
|
|
||||||
|
|
||||||
|
async def invalidate_group_members(
|
||||||
|
group_id: str | int | None = None,
|
||||||
|
user_ids: Iterable[object] | None = None,
|
||||||
|
) -> None:
|
||||||
|
if group_id is None:
|
||||||
|
await _GROUP_MEMBER_CACHE.clear()
|
||||||
|
await _GROUP_USER_IDS_CACHE.clear()
|
||||||
|
await _GROUP_MEMBER_BY_ID_CACHE.clear()
|
||||||
|
return
|
||||||
|
group_key = _normalize_id(group_id)
|
||||||
|
await _GROUP_MEMBER_CACHE.delete(group_key)
|
||||||
|
await _GROUP_USER_IDS_CACHE.delete(group_key)
|
||||||
|
normalized_ids = _normalize_ids(user_ids)
|
||||||
|
if normalized_ids is None:
|
||||||
|
await _GROUP_MEMBER_BY_ID_CACHE.clear()
|
||||||
|
return
|
||||||
|
for user_id in normalized_ids:
|
||||||
|
await _GROUP_MEMBER_BY_ID_CACHE.delete(f"{group_key}:{user_id}")
|
||||||
|
|
||||||
|
|
||||||
|
async def invalidate_member_names(user_ids: Iterable[object] | None = None) -> None:
|
||||||
|
if user_ids is None:
|
||||||
|
await _USER_NAME_CACHE.clear()
|
||||||
|
await _USER_GROUP_CACHE.clear()
|
||||||
|
return
|
||||||
|
for user_id in _normalize_ids(user_ids) or ():
|
||||||
|
await _USER_NAME_CACHE.delete(user_id)
|
||||||
|
await _USER_GROUP_CACHE.delete(user_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _datetime_key(value: datetime | None) -> str:
|
||||||
|
return value.isoformat(" ", timespec="seconds") if value else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _date_scope_key(date_scope: tuple[datetime, datetime] | None) -> str:
|
||||||
|
if not date_scope:
|
||||||
|
return ""
|
||||||
|
end_bucket = int(date_scope[1].timestamp() // 20)
|
||||||
|
return f"{_datetime_key(date_scope[0])}..bucket:{end_bucket}"
|
||||||
|
|
||||||
|
|
||||||
|
async def get_chat_history_rank_cached(
|
||||||
|
model: Any,
|
||||||
|
gid: str | None,
|
||||||
|
limit: int = 10,
|
||||||
|
order: str = "DESC",
|
||||||
|
date_scope: tuple[datetime, datetime] | None = None,
|
||||||
|
) -> list[tuple[str, int]]:
|
||||||
|
key = f"{gid or '*'}:{limit}:{order}:{_date_scope_key(date_scope)}"
|
||||||
|
cached = await _CHAT_RANK_CACHE.get(key)
|
||||||
|
if cached is not None:
|
||||||
|
return list(cached)
|
||||||
|
|
||||||
|
lock = _get_lock(_CHAT_RANK_LOCKS, key)
|
||||||
|
async with lock:
|
||||||
|
cached = await _CHAT_RANK_CACHE.get(key)
|
||||||
|
if cached is not None:
|
||||||
|
return list(cached)
|
||||||
|
|
||||||
|
order_prefix = "-" if order == "DESC" else ""
|
||||||
|
query: Any = model.filter(group_id=gid) if gid else model
|
||||||
|
if date_scope:
|
||||||
|
filter_scope = (
|
||||||
|
date_scope[0].isoformat(" "),
|
||||||
|
date_scope[1].isoformat(" "),
|
||||||
|
)
|
||||||
|
query = query.filter(create_time__range=filter_scope)
|
||||||
|
rows = (
|
||||||
|
await query.annotate(count=Count("user_id"))
|
||||||
|
.order_by(f"{order_prefix}count")
|
||||||
|
.group_by("user_id")
|
||||||
|
.limit(limit)
|
||||||
|
.values_list("user_id", "count")
|
||||||
|
)
|
||||||
|
result = tuple((str(user_id), int(count)) for user_id, count in rows)
|
||||||
|
await _CHAT_RANK_CACHE.set(key, result)
|
||||||
|
return list(result)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_chat_history_first_msg_datetime_cached(
|
||||||
|
model: Any,
|
||||||
|
group_id: str | None,
|
||||||
|
) -> datetime | None:
|
||||||
|
key = group_id or "*"
|
||||||
|
cached = await _CHAT_FIRST_MSG_CACHE.get(key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached[0]
|
||||||
|
|
||||||
|
lock = _get_lock(_CHAT_FIRST_MSG_LOCKS, key)
|
||||||
|
async with lock:
|
||||||
|
cached = await _CHAT_FIRST_MSG_CACHE.get(key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached[0]
|
||||||
|
|
||||||
|
query: Any = model.filter(group_id=group_id) if group_id else model.all()
|
||||||
|
message = await query.order_by("create_time").first()
|
||||||
|
result = getattr(message, "create_time", None) if message else None
|
||||||
|
await _CHAT_FIRST_MSG_CACHE.set(key, (result,))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def get_statistics_plugin_counts_cached(
|
||||||
|
scope: Literal["global", "user", "group"],
|
||||||
|
*,
|
||||||
|
plugin_name: str | None,
|
||||||
|
start_time: datetime | None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
group_id: str | None = None,
|
||||||
|
) -> list[tuple[str, int]]:
|
||||||
|
key = (
|
||||||
|
f"{scope}:{plugin_name or ''}:{_datetime_key(start_time)}:"
|
||||||
|
f"{user_id or ''}:{group_id or ''}"
|
||||||
|
)
|
||||||
|
cached = await _STATISTICS_COUNT_CACHE.get(key)
|
||||||
|
if cached is not None:
|
||||||
|
return list(cached)
|
||||||
|
|
||||||
|
lock = _get_lock(_STATISTICS_LOCKS, key)
|
||||||
|
async with lock:
|
||||||
|
cached = await _STATISTICS_COUNT_CACHE.get(key)
|
||||||
|
if cached is not None:
|
||||||
|
return list(cached)
|
||||||
|
|
||||||
|
from zhenxun.models.statistics import Statistics
|
||||||
|
|
||||||
|
query: Any = Statistics
|
||||||
|
if scope == "user":
|
||||||
|
query = Statistics.filter(user_id=user_id)
|
||||||
|
if group_id:
|
||||||
|
query = query.filter(group_id=group_id)
|
||||||
|
elif scope == "group":
|
||||||
|
query = Statistics.filter(group_id=group_id)
|
||||||
|
if plugin_name:
|
||||||
|
query = query.filter(plugin_name=plugin_name)
|
||||||
|
if start_time:
|
||||||
|
query = query.filter(create_time__gte=start_time)
|
||||||
|
rows = (
|
||||||
|
await query.annotate(count=Count("id"))
|
||||||
|
.group_by("plugin_name")
|
||||||
|
.values_list("plugin_name", "count")
|
||||||
|
)
|
||||||
|
result = tuple((str(plugin), int(count)) for plugin, count in rows)
|
||||||
|
await _STATISTICS_COUNT_CACHE.set(key, result)
|
||||||
|
return list(result)
|
||||||
@@ -140,6 +140,9 @@ def register_runtime_bootstrap(_driver) -> None:
|
|||||||
global _thread_executor
|
global _thread_executor
|
||||||
await _stop_launcher_watchdog()
|
await _stop_launcher_watchdog()
|
||||||
await stop_send_queue()
|
await stop_send_queue()
|
||||||
|
from zhenxun.models._bot_message_buffer import stop_bot_message_store_buffer
|
||||||
|
|
||||||
|
await stop_bot_message_store_buffer()
|
||||||
await stop_memory_governor()
|
await stop_memory_governor()
|
||||||
executor = _thread_executor
|
executor = _thread_executor
|
||||||
_thread_executor = None
|
_thread_executor = None
|
||||||
|
|||||||
+183
-17
@@ -1,30 +1,124 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
from collections import defaultdict
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from dataclasses import dataclass
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any, ClassVar, cast
|
||||||
|
|
||||||
from nonebot.adapters import Bot
|
from nonebot.adapters import Bot
|
||||||
|
from nonebot.adapters.onebot.v11 import Adapter as OneBotV11Adapter
|
||||||
|
from nonebot.adapters.onebot.v11 import Bot as OneBotV11Bot
|
||||||
|
|
||||||
from zhenxun.services.log import logger
|
from zhenxun.services.log import logger
|
||||||
|
|
||||||
_SEND_APIS = {"send_msg", "send_group_msg", "send_private_msg", "send_like"}
|
_SEND_APIS = {"send_msg", "send_group_msg", "send_private_msg", "send_like"}
|
||||||
|
_OBSERVED_SEND_APIS = {"send_msg", "send_group_msg", "send_private_msg"}
|
||||||
_WORKERS = 3
|
_WORKERS = 3
|
||||||
_MIN_INTERVAL = 0.05
|
_MIN_INTERVAL = 0.05
|
||||||
_QUEUE_MAXSIZE = 2000
|
_QUEUE_MAXSIZE = 2000
|
||||||
_SHUTDOWN_DRAIN_TIMEOUT_SECONDS = 3.0
|
_SHUTDOWN_DRAIN_TIMEOUT_SECONDS = 3.0
|
||||||
_QUEUE_PRESSURE_LOG_INTERVAL = 10.0
|
_QUEUE_PRESSURE_LOG_INTERVAL = 10.0
|
||||||
_QUEUE: asyncio.Queue[tuple[Bot, str, dict[str, Any], asyncio.Future[Any]]] = (
|
_QUEUE: asyncio.Queue[
|
||||||
asyncio.Queue(maxsize=_QUEUE_MAXSIZE)
|
tuple[Bot, str, dict[str, Any], asyncio.Future[Any], str | None]
|
||||||
)
|
] = asyncio.Queue(maxsize=_QUEUE_MAXSIZE)
|
||||||
_SEND_LOCK = asyncio.Lock()
|
_SEND_LOCK = asyncio.Lock()
|
||||||
_LAST_SEND_TS = 0.0
|
_LAST_SEND_TS = 0.0
|
||||||
_API_SEMAPHORE = asyncio.Semaphore(3)
|
_API_SEMAPHORE = asyncio.Semaphore(3)
|
||||||
_ORIG_CALL_API = Bot.call_api
|
_ORIG_CALL_API = OneBotV11Adapter._call_api
|
||||||
_PATCHED = False
|
_PATCHED = False
|
||||||
_WORKER_TASKS: list[asyncio.Task] = []
|
_WORKER_TASKS: list[asyncio.Task] = []
|
||||||
_QUEUE_TIMEOUT_COUNT = 0
|
_QUEUE_TIMEOUT_COUNT = 0
|
||||||
_SEND_LIKE_DROP_COUNT = 0
|
_SEND_LIKE_DROP_COUNT = 0
|
||||||
_LAST_QUEUE_PRESSURE_LOG = 0.0
|
_LAST_QUEUE_PRESSURE_LOG = 0.0
|
||||||
_STOPPING = False
|
_STOPPING = False
|
||||||
|
_CURRENT_SEND_TRACE_ID: ContextVar[str | None] = ContextVar(
|
||||||
|
"zhenxun_send_trace_id",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
_MAX_OBSERVED_RECORDS_PER_TRACE = 12
|
||||||
|
_MAX_OBSERVED_TEXT_LEN = 900
|
||||||
|
|
||||||
|
|
||||||
|
def _send_platform_scope(adapter: Any) -> str:
|
||||||
|
if adapter is None:
|
||||||
|
return "unknown"
|
||||||
|
if isinstance(adapter, OneBotV11Adapter):
|
||||||
|
return "qq_client"
|
||||||
|
get_name = getattr(adapter, "get_name", None)
|
||||||
|
if callable(get_name):
|
||||||
|
try:
|
||||||
|
name = str(get_name()).lower()
|
||||||
|
except Exception:
|
||||||
|
name = ""
|
||||||
|
else:
|
||||||
|
name = adapter.__class__.__name__.lower()
|
||||||
|
if name == "qq" or "qq" in name:
|
||||||
|
return "qq_api"
|
||||||
|
return name or "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SendObservation:
|
||||||
|
trace_id: str
|
||||||
|
api: str
|
||||||
|
text: str
|
||||||
|
raw_message: str
|
||||||
|
result: Any
|
||||||
|
timestamp: float
|
||||||
|
|
||||||
|
|
||||||
|
class SendObserver:
|
||||||
|
_records: ClassVar[dict[str, list[SendObservation]]] = defaultdict(list)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
@contextmanager
|
||||||
|
def activate(cls, trace_id: str):
|
||||||
|
trace_key = str(trace_id or "").strip()
|
||||||
|
token = _CURRENT_SEND_TRACE_ID.set(trace_key or None)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
_CURRENT_SEND_TRACE_ID.reset(token)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def record(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
trace_id: str | None,
|
||||||
|
api: str,
|
||||||
|
data: dict[str, Any],
|
||||||
|
result: Any,
|
||||||
|
) -> None:
|
||||||
|
trace_key = str(trace_id or "").strip()
|
||||||
|
if not trace_key or api not in _OBSERVED_SEND_APIS:
|
||||||
|
return
|
||||||
|
target = cls._records[trace_key]
|
||||||
|
if len(target) >= _MAX_OBSERVED_RECORDS_PER_TRACE:
|
||||||
|
return
|
||||||
|
raw_message = _message_to_text(data.get("message"))
|
||||||
|
target.append(
|
||||||
|
SendObservation(
|
||||||
|
trace_id=trace_key,
|
||||||
|
api=api,
|
||||||
|
text=_compact_text(raw_message),
|
||||||
|
raw_message=raw_message[:_MAX_OBSERVED_TEXT_LEN],
|
||||||
|
result=result,
|
||||||
|
timestamp=time.time(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def pop(cls, trace_id: str) -> list[SendObservation]:
|
||||||
|
return cls._records.pop(str(trace_id or "").strip(), [])
|
||||||
|
|
||||||
|
|
||||||
|
def observe_send_trace(trace_id: str):
|
||||||
|
return SendObserver.activate(trace_id)
|
||||||
|
|
||||||
|
|
||||||
|
def pop_send_observations(trace_id: str) -> list[SendObservation]:
|
||||||
|
return SendObserver.pop(trace_id)
|
||||||
|
|
||||||
|
|
||||||
async def _rate_limit():
|
async def _rate_limit():
|
||||||
@@ -50,17 +144,45 @@ def _log_queue_pressure(reason: str) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _direct_call_api(bot: Bot, api: str, data: dict[str, Any]) -> Any:
|
async def _direct_call_api(
|
||||||
|
adapter: OneBotV11Adapter,
|
||||||
|
bot: Bot,
|
||||||
|
api: str,
|
||||||
|
data: dict[str, Any],
|
||||||
|
trace_id: str | None = None,
|
||||||
|
) -> Any:
|
||||||
await _rate_limit()
|
await _rate_limit()
|
||||||
async with _API_SEMAPHORE:
|
async with _API_SEMAPHORE:
|
||||||
return await _ORIG_CALL_API(bot, api, **data)
|
try:
|
||||||
|
result = await _ORIG_CALL_API(
|
||||||
|
adapter,
|
||||||
|
cast(OneBotV11Bot, bot),
|
||||||
|
api,
|
||||||
|
**data,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
SendObserver.record(
|
||||||
|
trace_id=trace_id,
|
||||||
|
api=api,
|
||||||
|
data=data,
|
||||||
|
result={"ok": False, "error": str(exc)},
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
SendObserver.record(trace_id=trace_id, api=api, data=data, result=result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def _worker(worker_id: int):
|
async def _worker(worker_id: int):
|
||||||
while True:
|
while True:
|
||||||
bot, api, data, future = await _QUEUE.get()
|
bot, api, data, future, trace_id = await _QUEUE.get()
|
||||||
try:
|
try:
|
||||||
result = await _direct_call_api(bot, api, data)
|
result = await _direct_call_api(
|
||||||
|
cast(OneBotV11Adapter, bot.adapter),
|
||||||
|
bot,
|
||||||
|
api,
|
||||||
|
data,
|
||||||
|
trace_id=trace_id,
|
||||||
|
)
|
||||||
if not future.done():
|
if not future.done():
|
||||||
future.set_result(result)
|
future.set_result(result)
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
@@ -80,15 +202,28 @@ async def _worker(worker_id: int):
|
|||||||
_QUEUE.task_done()
|
_QUEUE.task_done()
|
||||||
|
|
||||||
|
|
||||||
async def _queued_call_api(self: Bot, api: str, **data: Any):
|
async def _queued_call_api(
|
||||||
|
adapter: OneBotV11Adapter,
|
||||||
|
bot: Bot,
|
||||||
|
api: str,
|
||||||
|
**data: Any,
|
||||||
|
):
|
||||||
|
if _send_platform_scope(adapter) != "qq_client":
|
||||||
|
return await _ORIG_CALL_API(adapter, cast(OneBotV11Bot, bot), api, **data)
|
||||||
if api not in _SEND_APIS:
|
if api not in _SEND_APIS:
|
||||||
return await _ORIG_CALL_API(self, api, **data)
|
return await _ORIG_CALL_API(adapter, cast(OneBotV11Bot, bot), api, **data)
|
||||||
if _STOPPING:
|
if _STOPPING:
|
||||||
return await _direct_call_api(self, api, data)
|
return await _direct_call_api(
|
||||||
|
adapter,
|
||||||
|
bot,
|
||||||
|
api,
|
||||||
|
data,
|
||||||
|
trace_id=_CURRENT_SEND_TRACE_ID.get(),
|
||||||
|
)
|
||||||
|
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
future: asyncio.Future[Any] = loop.create_future()
|
future: asyncio.Future[Any] = loop.create_future()
|
||||||
queue_item = (self, api, data, future)
|
queue_item = (bot, api, data, future, _CURRENT_SEND_TRACE_ID.get())
|
||||||
try:
|
try:
|
||||||
_QUEUE.put_nowait(queue_item)
|
_QUEUE.put_nowait(queue_item)
|
||||||
except asyncio.QueueFull:
|
except asyncio.QueueFull:
|
||||||
@@ -100,7 +235,13 @@ async def _queued_call_api(self: Bot, api: str, **data: Any):
|
|||||||
global _QUEUE_TIMEOUT_COUNT
|
global _QUEUE_TIMEOUT_COUNT
|
||||||
_QUEUE_TIMEOUT_COUNT += 1
|
_QUEUE_TIMEOUT_COUNT += 1
|
||||||
_log_queue_pressure(f"{api} fallback to direct send because queue is full")
|
_log_queue_pressure(f"{api} fallback to direct send because queue is full")
|
||||||
return await _direct_call_api(self, api, data)
|
return await _direct_call_api(
|
||||||
|
adapter,
|
||||||
|
bot,
|
||||||
|
api,
|
||||||
|
data,
|
||||||
|
trace_id=_CURRENT_SEND_TRACE_ID.get(),
|
||||||
|
)
|
||||||
return await future
|
return await future
|
||||||
|
|
||||||
|
|
||||||
@@ -108,7 +249,7 @@ def _drain_pending_futures(reason: str) -> int:
|
|||||||
drained = 0
|
drained = 0
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
_, _, _, future = _QUEUE.get_nowait()
|
_, _, _, future, _ = _QUEUE.get_nowait()
|
||||||
except asyncio.QueueEmpty:
|
except asyncio.QueueEmpty:
|
||||||
break
|
break
|
||||||
if not future.done():
|
if not future.done():
|
||||||
@@ -122,7 +263,7 @@ def patch_send_queue() -> None:
|
|||||||
global _PATCHED
|
global _PATCHED
|
||||||
if _PATCHED:
|
if _PATCHED:
|
||||||
return
|
return
|
||||||
Bot.call_api = _queued_call_api # type: ignore[assignment]
|
OneBotV11Adapter._call_api = _queued_call_api # type: ignore[assignment]
|
||||||
_PATCHED = True
|
_PATCHED = True
|
||||||
|
|
||||||
|
|
||||||
@@ -130,7 +271,7 @@ def unpatch_send_queue() -> None:
|
|||||||
global _PATCHED
|
global _PATCHED
|
||||||
if not _PATCHED:
|
if not _PATCHED:
|
||||||
return
|
return
|
||||||
Bot.call_api = _ORIG_CALL_API # type: ignore[assignment]
|
OneBotV11Adapter._call_api = _ORIG_CALL_API # type: ignore[assignment]
|
||||||
_PATCHED = False
|
_PATCHED = False
|
||||||
|
|
||||||
|
|
||||||
@@ -138,12 +279,37 @@ async def start_send_queue() -> None:
|
|||||||
global _STOPPING
|
global _STOPPING
|
||||||
patch_send_queue()
|
patch_send_queue()
|
||||||
_STOPPING = False
|
_STOPPING = False
|
||||||
|
_WORKER_TASKS[:] = [task for task in _WORKER_TASKS if not task.done()]
|
||||||
if _WORKER_TASKS:
|
if _WORKER_TASKS:
|
||||||
return
|
return
|
||||||
for idx in range(_WORKERS):
|
for idx in range(_WORKERS):
|
||||||
_WORKER_TASKS.append(asyncio.create_task(_worker(idx)))
|
_WORKER_TASKS.append(asyncio.create_task(_worker(idx)))
|
||||||
|
|
||||||
|
|
||||||
|
def _message_to_text(message: Any) -> str:
|
||||||
|
if message is None:
|
||||||
|
return ""
|
||||||
|
if hasattr(message, "extract_plain_text"):
|
||||||
|
try:
|
||||||
|
text = str(message.extract_plain_text())
|
||||||
|
if text.strip():
|
||||||
|
return text
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
return str(message)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(f"send observation stringify failed: {exc}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _compact_text(text: str) -> str:
|
||||||
|
normalized = " ".join(str(text or "").split())
|
||||||
|
if len(normalized) <= _MAX_OBSERVED_TEXT_LEN:
|
||||||
|
return normalized
|
||||||
|
return normalized[: _MAX_OBSERVED_TEXT_LEN - 1].rstrip() + "…"
|
||||||
|
|
||||||
|
|
||||||
async def stop_send_queue() -> None:
|
async def stop_send_queue() -> None:
|
||||||
global _STOPPING
|
global _STOPPING
|
||||||
_STOPPING = True
|
_STOPPING = True
|
||||||
|
|||||||
@@ -24,6 +24,19 @@ from zhenxun.utils.message import MessageUtils
|
|||||||
driver = nonebot.get_driver()
|
driver = nonebot.get_driver()
|
||||||
|
|
||||||
|
|
||||||
|
def _adapter_name(bot: Bot) -> str:
|
||||||
|
adapter = getattr(bot, "adapter", None)
|
||||||
|
if adapter is None:
|
||||||
|
return ""
|
||||||
|
get_name = getattr(adapter, "get_name", None)
|
||||||
|
if callable(get_name):
|
||||||
|
try:
|
||||||
|
return str(get_name()).lower()
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
return adapter.__class__.__name__.lower()
|
||||||
|
|
||||||
|
|
||||||
class UserData(BaseModel):
|
class UserData(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
"""昵称"""
|
"""昵称"""
|
||||||
@@ -380,6 +393,47 @@ class PlatformUtils:
|
|||||||
return "qq" if platform.startswith("qq") else platform
|
return "qq" if platform.startswith("qq") else platform
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_platform_scope(cls, t: Bot | Uninfo | object) -> str:
|
||||||
|
"""获取细粒度平台作用域,不改变旧 get_platform 返回值。"""
|
||||||
|
if isinstance(t, Bot):
|
||||||
|
adapter_name = _adapter_name(t)
|
||||||
|
if "onebot" in adapter_name:
|
||||||
|
return "qq_client"
|
||||||
|
if adapter_name == "qq" or "qq" in adapter_name:
|
||||||
|
return "qq_api"
|
||||||
|
if BotConfig.get_qbot_uid(t.self_id):
|
||||||
|
return "qq_api"
|
||||||
|
return adapter_name or cls.get_platform(t)
|
||||||
|
|
||||||
|
scope = str(getattr(t, "scope", "") or "").lower()
|
||||||
|
if not scope:
|
||||||
|
basic = getattr(t, "basic", None)
|
||||||
|
if isinstance(basic, dict):
|
||||||
|
scope = str(basic.get("scope") or "").lower()
|
||||||
|
if "qq_client" in scope:
|
||||||
|
return "qq_client"
|
||||||
|
if "qq_api" in scope:
|
||||||
|
return "qq_api"
|
||||||
|
if scope.startswith("qq"):
|
||||||
|
return "qq"
|
||||||
|
|
||||||
|
adapter = getattr(t, "adapter", None)
|
||||||
|
if adapter is not None:
|
||||||
|
name = (
|
||||||
|
adapter.get_name().lower()
|
||||||
|
if callable(getattr(adapter, "get_name", None))
|
||||||
|
else adapter.__class__.__name__.lower()
|
||||||
|
)
|
||||||
|
if "onebot" in name:
|
||||||
|
return "qq_client"
|
||||||
|
if name == "qq" or "qq" in name:
|
||||||
|
return "qq_api"
|
||||||
|
return name
|
||||||
|
|
||||||
|
platform = str(getattr(t, "platform", "") or "").lower()
|
||||||
|
return "qq_client" if platform == "qq" else platform or "unknown"
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def is_forward_merge_supported(cls, t: Bot | Uninfo) -> bool:
|
def is_forward_merge_supported(cls, t: Bot | Uninfo) -> bool:
|
||||||
"""是否支持转发消息
|
"""是否支持转发消息
|
||||||
|
|||||||
Reference in New Issue
Block a user