mirror of
https://github.com/zhenxun-org/zhenxun_bot.git
synced 2026-09-28 16:20:56 +08:00
bugfix:修复命令前缀判断问题、修复超级用户无法豁免问题 (#2141)
* bugfix:修复命令前缀判断问题、修复超级用户无法豁免问题 * bugfix:修复格式问题
This commit is contained in:
@@ -346,7 +346,7 @@ class LimitManager:
|
|||||||
limit = limit_model.limit
|
limit = limit_model.limit
|
||||||
limiter = limit_model.limiter
|
limiter = limit_model.limiter
|
||||||
is_limit = (
|
is_limit = (
|
||||||
LimitWatchType.ALL
|
limit.watch_type == LimitWatchType.ALL
|
||||||
or (group_id and limit.watch_type == LimitWatchType.GROUP)
|
or (group_id and limit.watch_type == LimitWatchType.GROUP)
|
||||||
or (not group_id and limit.watch_type == LimitWatchType.USER)
|
or (not group_id and limit.watch_type == LimitWatchType.USER)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ class HandlerDescriptor:
|
|||||||
class AlconnaShortcutDescriptor:
|
class AlconnaShortcutDescriptor:
|
||||||
pattern: str
|
pattern: str
|
||||||
fuzzy: bool = False
|
fuzzy: bool = False
|
||||||
|
prefix: bool = False
|
||||||
flags: int = 0
|
flags: int = 0
|
||||||
|
|
||||||
|
|
||||||
@@ -1059,6 +1060,7 @@ def _extract_alconna_shortcut_descriptors(
|
|||||||
AlconnaShortcutDescriptor(
|
AlconnaShortcutDescriptor(
|
||||||
pattern=pattern,
|
pattern=pattern,
|
||||||
fuzzy=bool(getattr(args, "fuzzy", False)),
|
fuzzy=bool(getattr(args, "fuzzy", False)),
|
||||||
|
prefix=bool(getattr(args, "prefix", False)),
|
||||||
flags=int(getattr(args, "flags", 0) or 0),
|
flags=int(getattr(args, "flags", 0) or 0),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -1140,7 +1142,7 @@ def matcher_alconna_head_matches(
|
|||||||
if decision == "unknown":
|
if decision == "unknown":
|
||||||
saw_unknown = True
|
saw_unknown = True
|
||||||
for shortcut in alconna.shortcuts:
|
for shortcut in alconna.shortcuts:
|
||||||
decision = _alconna_shortcut_matches(text, shortcut)
|
decision = _alconna_shortcut_matches(text, shortcut, alconna.prefixes)
|
||||||
if decision == "match":
|
if decision == "match":
|
||||||
return "match"
|
return "match"
|
||||||
if decision == "unknown":
|
if decision == "unknown":
|
||||||
@@ -1194,11 +1196,43 @@ def _alconna_literal_head_matches(text: str, head: str, *, compact: bool) -> boo
|
|||||||
def _alconna_shortcut_matches(
|
def _alconna_shortcut_matches(
|
||||||
text: str,
|
text: str,
|
||||||
shortcut: AlconnaShortcutDescriptor,
|
shortcut: AlconnaShortcutDescriptor,
|
||||||
|
prefixes: tuple[str, ...] = (),
|
||||||
) -> ActivationDecision:
|
) -> ActivationDecision:
|
||||||
pattern = shortcut.pattern.strip()
|
pattern = shortcut.pattern.strip()
|
||||||
if not pattern:
|
if not pattern:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
saw_unknown = False
|
||||||
|
for normalized in _alconna_shortcut_patterns(pattern, shortcut, prefixes):
|
||||||
|
decision = _alconna_shortcut_pattern_matches(text, normalized, shortcut)
|
||||||
|
if decision == "match":
|
||||||
|
return "match"
|
||||||
|
if decision == "unknown":
|
||||||
|
saw_unknown = True
|
||||||
|
return "unknown" if saw_unknown else "miss"
|
||||||
|
|
||||||
|
|
||||||
|
def _alconna_shortcut_patterns(
|
||||||
|
pattern: str,
|
||||||
|
shortcut: AlconnaShortcutDescriptor,
|
||||||
|
prefixes: tuple[str, ...],
|
||||||
|
) -> tuple[str, ...]:
|
||||||
normalized = normalize_shortcut_pattern(pattern)
|
normalized = normalize_shortcut_pattern(pattern)
|
||||||
|
if not normalized:
|
||||||
|
return ()
|
||||||
|
patterns = [normalized]
|
||||||
|
if shortcut.prefix:
|
||||||
|
for prefix in prefixes or ("",):
|
||||||
|
candidate = normalize_shortcut_pattern(f"{prefix}{normalized}")
|
||||||
|
if candidate and candidate not in patterns:
|
||||||
|
patterns.append(candidate)
|
||||||
|
return tuple(patterns)
|
||||||
|
|
||||||
|
|
||||||
|
def _alconna_shortcut_pattern_matches(
|
||||||
|
text: str,
|
||||||
|
normalized: str,
|
||||||
|
shortcut: AlconnaShortcutDescriptor,
|
||||||
|
) -> ActivationDecision:
|
||||||
placeholder_match = _placeholder_shortcut_decision(text, normalized)
|
placeholder_match = _placeholder_shortcut_decision(text, normalized)
|
||||||
if placeholder_match == "match":
|
if placeholder_match == "match":
|
||||||
return "match"
|
return "match"
|
||||||
@@ -1211,8 +1245,10 @@ def _alconna_shortcut_matches(
|
|||||||
return "match"
|
return "match"
|
||||||
try:
|
try:
|
||||||
if shortcut.fuzzy:
|
if shortcut.fuzzy:
|
||||||
return "match" if re.match(f"^{pattern}", text, shortcut.flags) else "miss"
|
return (
|
||||||
return "match" if re.fullmatch(pattern, text, shortcut.flags) else "miss"
|
"match" if re.match(f"^{normalized}", text, shortcut.flags) else "miss"
|
||||||
|
)
|
||||||
|
return "match" if re.fullmatch(normalized, text, shortcut.flags) else "miss"
|
||||||
except re.error:
|
except re.error:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
|||||||
@@ -496,10 +496,11 @@ def _event_text_candidates(
|
|||||||
candidates.append(normalized)
|
candidates.append(normalized)
|
||||||
|
|
||||||
add(_trie_command_text_from_state(state))
|
add(_trie_command_text_from_state(state))
|
||||||
add(_trie_raw_command_from_state(state))
|
trie_raw = _trie_raw_command_from_state(state)
|
||||||
|
add(trie_raw)
|
||||||
trie_arg = _trie_command_arg_text_from_state(state)
|
trie_arg = _trie_command_arg_text_from_state(state)
|
||||||
if _trie_raw_command_from_state(state) and trie_arg:
|
if trie_raw and trie_arg:
|
||||||
add(f"{_trie_raw_command_from_state(state)} {trie_arg}")
|
add(f"{trie_raw} {trie_arg}")
|
||||||
add(plain_text)
|
add(plain_text)
|
||||||
if event is not None:
|
if event is not None:
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
@@ -794,9 +795,10 @@ def _prepare_handle_event_state(event: Event, state: dict) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _build_matcher_state(base_state: dict) -> dict:
|
def _build_matcher_state(base_state: dict) -> dict:
|
||||||
|
# 第一次调用即在 base_state 写入副作用缓存对象;copy() 后 matcher_state
|
||||||
|
# 与之共享同一引用,无需二次 get(B7)。
|
||||||
get_permission_side_effect_cache(state=base_state)
|
get_permission_side_effect_cache(state=base_state)
|
||||||
matcher_state = base_state.copy()
|
matcher_state = base_state.copy()
|
||||||
get_permission_side_effect_cache(state=matcher_state)
|
|
||||||
return matcher_state
|
return matcher_state
|
||||||
|
|
||||||
|
|
||||||
@@ -965,25 +967,28 @@ async def _db_section():
|
|||||||
DB_ACTIVE_COUNT = max(DB_ACTIVE_COUNT - 1, 0)
|
DB_ACTIVE_COUNT = max(DB_ACTIVE_COUNT - 1, 0)
|
||||||
|
|
||||||
|
|
||||||
|
_POLICY_SKIP_MESSAGES = {
|
||||||
|
"user_or_group_banned": "user or group banned (cached)",
|
||||||
|
"superuser_required": "超级管理员权限不足...",
|
||||||
|
"admin_required": "管理员权限不足...",
|
||||||
|
"bot_not_found": "Bot不存在,阻断权限检测...",
|
||||||
|
"bot_sleeping": "Bot休眠中阻断权限检测...",
|
||||||
|
"bot_plugin_blocked": "Bot插件权限检查结果为关闭...",
|
||||||
|
"group_not_found": "群组信息不存在...",
|
||||||
|
"group_blacklisted": "群组黑名单, 目标群组群权限权限-1...",
|
||||||
|
"group_sleeping": "群组休眠状态...",
|
||||||
|
"group_level_low": "群等级限制...",
|
||||||
|
"admin_level_low": "管理员权限不足...",
|
||||||
|
"plugin_disabled_in_group": "该插件在群组中已被禁用...",
|
||||||
|
"plugin_superuser_blocked_in_group": "超级管理员禁用了该群此功能...",
|
||||||
|
"plugin_blocked_in_group": "该群未开启此功能...",
|
||||||
|
"plugin_disabled_in_private": "该插件在私聊中已被禁用...",
|
||||||
|
"plugin_global_disabled": "全局未开启此功能...",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _policy_skip_message(reason: str) -> str:
|
def _policy_skip_message(reason: str) -> str:
|
||||||
return {
|
return _POLICY_SKIP_MESSAGES.get(reason, reason or "permission denied")
|
||||||
"user_or_group_banned": "user or group banned (cached)",
|
|
||||||
"superuser_required": "超级管理员权限不足...",
|
|
||||||
"admin_required": "管理员权限不足...",
|
|
||||||
"bot_not_found": "Bot不存在,阻断权限检测...",
|
|
||||||
"bot_sleeping": "Bot休眠中阻断权限检测...",
|
|
||||||
"bot_plugin_blocked": "Bot插件权限检查结果为关闭...",
|
|
||||||
"group_not_found": "群组信息不存在...",
|
|
||||||
"group_blacklisted": "群组黑名单, 目标群组群权限权限-1...",
|
|
||||||
"group_sleeping": "群组休眠状态...",
|
|
||||||
"group_level_low": "群等级限制...",
|
|
||||||
"admin_level_low": "管理员权限不足...",
|
|
||||||
"plugin_disabled_in_group": "该插件在群组中已被禁用...",
|
|
||||||
"plugin_superuser_blocked_in_group": "超级管理员禁用了该群此功能...",
|
|
||||||
"plugin_blocked_in_group": "该群未开启此功能...",
|
|
||||||
"plugin_disabled_in_private": "该插件在私聊中已被禁用...",
|
|
||||||
"plugin_global_disabled": "全局未开启此功能...",
|
|
||||||
}.get(reason, reason or "permission denied")
|
|
||||||
|
|
||||||
|
|
||||||
# 超时装饰器
|
# 超时装饰器
|
||||||
@@ -1342,18 +1347,24 @@ async def _check_ban_from_snapshot(
|
|||||||
hook_recorder: HookTraceRecorder,
|
hook_recorder: HookTraceRecorder,
|
||||||
session: Uninfo,
|
session: Uninfo,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
# skip_ban 上移到 cached 判断之前(A7),否则豁免参数对 cached 命中形同虚设。
|
||||||
|
if skip_ban:
|
||||||
|
hook_recorder.set("auth_ban", "skipped")
|
||||||
|
return
|
||||||
|
is_superuser = bool(getattr(prep.permission_context, "is_superuser", False))
|
||||||
ban_cache_state = prep.snapshot.ban_state
|
ban_cache_state = prep.snapshot.ban_state
|
||||||
if event_cache is not None:
|
if event_cache is not None:
|
||||||
ban_cache_state = event_cache.get("ban_state")
|
ban_cache_state = event_cache.get("ban_state")
|
||||||
if ban_cache_state is True:
|
if ban_cache_state is True:
|
||||||
|
# 超级用户豁免(A7):与 PDP / 旧轨权威路径保持一致,避免被 ban 后无法自救。
|
||||||
|
if is_superuser:
|
||||||
|
hook_recorder.set("auth_ban", "cached_superuser_exempt")
|
||||||
|
return
|
||||||
hook_recorder.set("auth_ban", "cached")
|
hook_recorder.set("auth_ban", "cached")
|
||||||
raise SkipPluginException("user or group banned (cached)")
|
raise SkipPluginException("user or group banned (cached)")
|
||||||
if ban_cache_state is False:
|
if ban_cache_state is False:
|
||||||
hook_recorder.set("auth_ban", "cached")
|
hook_recorder.set("auth_ban", "cached")
|
||||||
return
|
return
|
||||||
if skip_ban:
|
|
||||||
hook_recorder.set("auth_ban", "skipped")
|
|
||||||
return
|
|
||||||
|
|
||||||
ban_start = time.time()
|
ban_start = time.time()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -246,7 +246,11 @@ async def route_gate_stage(
|
|||||||
if deps.is_hidden_plugin(ctx.matcher):
|
if deps.is_hidden_plugin(ctx.matcher):
|
||||||
ctx.stop(allowed=True, effect="allow", reason="hidden_plugin")
|
ctx.stop(allowed=True, effect="allow", reason="hidden_plugin")
|
||||||
return
|
return
|
||||||
if ctx.event_cache is not None and ctx.event_cache.get("ban_state") is True:
|
if (
|
||||||
|
ctx.event_cache is not None
|
||||||
|
and ctx.event_cache.get("ban_state") is True
|
||||||
|
and not ctx.event_context.is_superuser
|
||||||
|
):
|
||||||
ctx.decision_effect = "skip"
|
ctx.decision_effect = "skip"
|
||||||
ctx.decision_reason = "ban_cached"
|
ctx.decision_reason = "ban_cached"
|
||||||
raise SkipPluginException("user or group banned (cached)")
|
raise SkipPluginException("user or group banned (cached)")
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
@@ -116,8 +117,14 @@ class PolicyDecisionPoint:
|
|||||||
if not bot_data.status and not context.allow_sleep_bypass:
|
if not bot_data.status and not context.allow_sleep_bypass:
|
||||||
return PolicyDecision("deny", "bot_sleeping")
|
return PolicyDecision("deny", "bot_sleeping")
|
||||||
module = snapshot.profile.module
|
module = snapshot.profile.module
|
||||||
if module and self._module_in_block_string(module, bot_data.block_plugins):
|
if module:
|
||||||
return PolicyDecision("deny", "bot_plugin_blocked")
|
value = bot_data.block_plugins or ""
|
||||||
|
# 缓存解析后的 frozenset,避免每次 bot 检查重复 split(B8-3);
|
||||||
|
# 仍保留原子串判定以保持行为等价。
|
||||||
|
if CommonUtils.format(module) in value or module in self._bot_block_set(
|
||||||
|
bot_data
|
||||||
|
):
|
||||||
|
return PolicyDecision("deny", "bot_plugin_blocked")
|
||||||
return PolicyDecision("allow", "bot_allowed")
|
return PolicyDecision("allow", "bot_allowed")
|
||||||
|
|
||||||
def decide_group(self, context: PolicyContext) -> PolicyDecision:
|
def decide_group(self, context: PolicyContext) -> PolicyDecision:
|
||||||
@@ -208,6 +215,17 @@ class PolicyDecisionPoint:
|
|||||||
setattr(group, "superuser_block_plugin_set", super_block_set)
|
setattr(group, "superuser_block_plugin_set", super_block_set)
|
||||||
return block_set, super_block_set
|
return block_set, super_block_set
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _bot_block_set(bot_data: object) -> frozenset[str]:
|
||||||
|
block_set = getattr(bot_data, "block_plugin_set", None)
|
||||||
|
if block_set is None:
|
||||||
|
block_set = _parse_block_modules(
|
||||||
|
getattr(bot_data, "block_plugins", "") or ""
|
||||||
|
)
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
setattr(bot_data, "block_plugin_set", block_set)
|
||||||
|
return block_set
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _module_in_block_string(module: str, value: str | None) -> bool:
|
def _module_in_block_string(module: str, value: str | None) -> bool:
|
||||||
if not value:
|
if not value:
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class AuthDispatchRuntimeConfig:
|
|||||||
circuit_reset_time: int = 300
|
circuit_reset_time: int = 300
|
||||||
matcher_route_prefilter_ttl: int = 2
|
matcher_route_prefilter_ttl: int = 2
|
||||||
prefilter_stats_log_interval: float = 10.0
|
prefilter_stats_log_interval: float = 10.0
|
||||||
cache_sweep_interval: float = 1.0
|
cache_sweep_interval: float = 45.0
|
||||||
dispatch_stats_log_interval: float = 10.0
|
dispatch_stats_log_interval: float = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,11 @@ async def _(
|
|||||||
if matcher.type == "notice":
|
if matcher.type == "notice":
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# AI 重路由注入的合成事件不计入恶意检测(A6):AI 链路有自己的预算/审批,
|
||||||
|
# 不应被人类反垃圾逻辑封禁(此前批量转发误封超级用户的事故根因之一)。
|
||||||
|
if getattr(event, "_ai_triggered", False):
|
||||||
|
return
|
||||||
|
|
||||||
# 提前判断插件类型,跳过不需要检测的插件
|
# 提前判断插件类型,跳过不需要检测的插件
|
||||||
if plugin := matcher.plugin:
|
if plugin := matcher.plugin:
|
||||||
if metadata := plugin.metadata:
|
if metadata := plugin.metadata:
|
||||||
@@ -99,6 +104,13 @@ async def _(
|
|||||||
|
|
||||||
user_id = resolve_actor_user_id(event, session.id1)
|
user_id = resolve_actor_user_id(event, session.id1)
|
||||||
group_id = resolve_event_group_id(event, session.id3 or session.id2)
|
group_id = resolve_event_group_id(event, session.id3 or session.id2)
|
||||||
|
# 超级用户豁免恶意检测(A6):与权威权限路径保持一致,避免误封管理者。
|
||||||
|
if user_id:
|
||||||
|
is_superuser = state.get("_zx_is_superuser")
|
||||||
|
if not isinstance(is_superuser, bool):
|
||||||
|
is_superuser = user_id in bot.config.superusers
|
||||||
|
if is_superuser:
|
||||||
|
return
|
||||||
malicious_check_time = float(_get_positive_config("MALICIOUS_CHECK_TIME", float))
|
malicious_check_time = float(_get_positive_config("MALICIOUS_CHECK_TIME", float))
|
||||||
malicious_ban_count = int(_get_positive_config("MALICIOUS_BAN_COUNT", int))
|
malicious_ban_count = int(_get_positive_config("MALICIOUS_BAN_COUNT", int))
|
||||||
malicious_ban_time = int(_get_positive_config("MALICIOUS_BAN_TIME", int))
|
malicious_ban_time = int(_get_positive_config("MALICIOUS_BAN_TIME", int))
|
||||||
|
|||||||
@@ -53,8 +53,12 @@ async def _flush_statistics_buffer(reason: str) -> int:
|
|||||||
|
|
||||||
|
|
||||||
async def _append_statistics(record: Statistics) -> None:
|
async def _append_statistics(record: Statistics) -> None:
|
||||||
TEMP_LIST.append(record)
|
# 在锁内追加(B8),与 flush 的 copy+clear 串行,消除逻辑窗口;
|
||||||
if len(TEMP_LIST) >= STATS_BUFFER_FLUSH_SIZE and not _STATS_FLUSH_LOCK.locked():
|
# flush 自身再次获取同一把锁,故在锁外触发避免重入。
|
||||||
|
async with _STATS_FLUSH_LOCK:
|
||||||
|
TEMP_LIST.append(record)
|
||||||
|
should_flush = len(TEMP_LIST) >= STATS_BUFFER_FLUSH_SIZE
|
||||||
|
if should_flush:
|
||||||
await _flush_statistics_buffer("缓冲区触发")
|
await _flush_statistics_buffer("缓冲区触发")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class Statistics(Model):
|
|||||||
"""群聊id"""
|
"""群聊id"""
|
||||||
plugin_name = fields.CharField(255)
|
plugin_name = fields.CharField(255)
|
||||||
"""插件名称"""
|
"""插件名称"""
|
||||||
create_time = fields.DatetimeField(auto_now=True)
|
create_time = fields.DatetimeField(auto_now_add=True)
|
||||||
"""添加日期"""
|
"""添加日期"""
|
||||||
bot_id = fields.CharField(255, null=True)
|
bot_id = fields.CharField(255, null=True)
|
||||||
"""Bot Id"""
|
"""Bot Id"""
|
||||||
|
|||||||
@@ -151,9 +151,11 @@ class UserConsole(Model):
|
|||||||
source: 来源
|
source: 来源
|
||||||
platform: 平台.
|
platform: 平台.
|
||||||
"""
|
"""
|
||||||
user = await cls._get_user_for_write(user_id=user_id, platform=platform)
|
await cls._get_user_for_write(user_id=user_id, platform=platform)
|
||||||
user.gold += gold
|
# 原子自增,避免并发 read-modify-write 丢币(A2);filter().update()
|
||||||
await user.save(update_fields=["gold"])
|
# 不触发基类 save() 的缓存失效,需手动失效。
|
||||||
|
await cls.filter(user_id=user_id).update(gold=F("gold") + gold)
|
||||||
|
await cls.invalidate_user_cache(user_id)
|
||||||
await append_user_gold_log(
|
await append_user_gold_log(
|
||||||
user_id=user_id, gold=gold, handle=GoldHandle.GET, source=source
|
user_id=user_id, gold=gold, handle=GoldHandle.GET, source=source
|
||||||
)
|
)
|
||||||
@@ -182,8 +184,14 @@ class UserConsole(Model):
|
|||||||
user = await cls._get_user_for_write(user_id=user_id, platform=platform)
|
user = await cls._get_user_for_write(user_id=user_id, platform=platform)
|
||||||
if user.gold < gold:
|
if user.gold < gold:
|
||||||
raise InsufficientGold()
|
raise InsufficientGold()
|
||||||
user.gold -= gold
|
# 原子扣减 + gold__gte 守卫,防并发超扣(A2);未命中说明余额已被
|
||||||
await user.save(update_fields=["gold"])
|
# 其他协程扣走,按金币不足处理。
|
||||||
|
updated = await cls.filter(user_id=user_id, gold__gte=gold).update(
|
||||||
|
gold=F("gold") - gold
|
||||||
|
)
|
||||||
|
if not updated:
|
||||||
|
raise InsufficientGold()
|
||||||
|
await cls.invalidate_user_cache(user_id)
|
||||||
await append_user_gold_log(
|
await append_user_gold_log(
|
||||||
user_id=user_id, gold=gold, handle=handle, source=plugin_module
|
user_id=user_id, gold=gold, handle=handle, source=plugin_module
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
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
|
||||||
@@ -21,3 +23,9 @@ class UserGoldLog(Model):
|
|||||||
class Meta: # pyright: ignore [reportIncompatibleVariableOverride]
|
class Meta: # pyright: ignore [reportIncompatibleVariableOverride]
|
||||||
table = "user_gold_log"
|
table = "user_gold_log"
|
||||||
table_description = "用户金币记录表"
|
table_description = "用户金币记录表"
|
||||||
|
# 高频写表补索引(H4):按 user_id / (user_id, create_time) 查询流水,
|
||||||
|
# SchemaGuard 启动时自动建,不改变查询语义。
|
||||||
|
indexes: ClassVar = [
|
||||||
|
("user_id",),
|
||||||
|
("user_id", "create_time"),
|
||||||
|
]
|
||||||
|
|||||||
+29
-9
@@ -74,8 +74,12 @@ class BoundedTTLCache(Generic[K, V]):
|
|||||||
self._sets = 0
|
self._sets = 0
|
||||||
self._evictions = 0
|
self._evictions = 0
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
self._last_sweep = 0.0
|
||||||
self.__class__._instances.add(self)
|
self.__class__._instances.add(self)
|
||||||
|
|
||||||
|
# 全量过期清理的最小间隔(B1):避免每次 get 都 O(N) 扫描整表。
|
||||||
|
_SWEEP_INTERVAL = 30.0
|
||||||
|
|
||||||
def _expire_at(self, now: float) -> float:
|
def _expire_at(self, now: float) -> float:
|
||||||
if self._ttl_seconds <= 0:
|
if self._ttl_seconds <= 0:
|
||||||
return sys.float_info.max
|
return sys.float_info.max
|
||||||
@@ -106,23 +110,37 @@ class BoundedTTLCache(Generic[K, V]):
|
|||||||
self._evictions += 1
|
self._evictions += 1
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _cleanup_nolock(self, now: float) -> None:
|
def _enforce_capacity_nolock(self) -> None:
|
||||||
expired_keys = [
|
"""容量边界强制(O(溢出量)):仅在新增后调用,不扫描全表。"""
|
||||||
key for key, (expire_at, _, _) in self._cache.items() if expire_at <= now
|
|
||||||
]
|
|
||||||
for key in expired_keys:
|
|
||||||
if self._remove_key_nolock(key):
|
|
||||||
self._evictions += 1
|
|
||||||
while len(self._cache) > self._max_items:
|
while len(self._cache) > self._max_items:
|
||||||
self._pop_oldest_nolock()
|
self._pop_oldest_nolock()
|
||||||
if self._max_total_bytes is not None:
|
if self._max_total_bytes is not None:
|
||||||
while self._total_bytes > self._max_total_bytes and self._cache:
|
while self._total_bytes > self._max_total_bytes and self._cache:
|
||||||
self._pop_oldest_nolock()
|
self._pop_oldest_nolock()
|
||||||
|
|
||||||
|
def _sweep_expired_nolock(self, now: float) -> None:
|
||||||
|
"""全量过期清理(O(N)):由节流器或后台/governor 低频触发。"""
|
||||||
|
self._last_sweep = now
|
||||||
|
expired_keys = [
|
||||||
|
key for key, (expire_at, _, _) in self._cache.items() if expire_at <= now
|
||||||
|
]
|
||||||
|
for key in expired_keys:
|
||||||
|
if self._remove_key_nolock(key):
|
||||||
|
self._evictions += 1
|
||||||
|
|
||||||
|
def _maybe_sweep_nolock(self, now: float) -> None:
|
||||||
|
if now - self._last_sweep >= self._SWEEP_INTERVAL:
|
||||||
|
self._sweep_expired_nolock(now)
|
||||||
|
|
||||||
|
def _cleanup_nolock(self, now: float) -> None:
|
||||||
|
"""完整清理(全量过期 + 容量):保留给 stats / 显式调用。"""
|
||||||
|
self._sweep_expired_nolock(now)
|
||||||
|
self._enforce_capacity_nolock()
|
||||||
|
|
||||||
async def get(self, key: K) -> V | None:
|
async def get(self, key: K) -> V | None:
|
||||||
now = time.monotonic()
|
now = time.monotonic()
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
self._cleanup_nolock(now)
|
# 仅做命中项的单条过期检查(O(1)),全量清理改为低频节流(B1)。
|
||||||
item = self._cache.get(key)
|
item = self._cache.get(key)
|
||||||
if item is None:
|
if item is None:
|
||||||
self._misses += 1
|
self._misses += 1
|
||||||
@@ -148,7 +166,9 @@ class BoundedTTLCache(Generic[K, V]):
|
|||||||
self._total_bytes += value_size
|
self._total_bytes += value_size
|
||||||
self._sets += 1
|
self._sets += 1
|
||||||
self._cache.move_to_end(key)
|
self._cache.move_to_end(key)
|
||||||
self._cleanup_nolock(now)
|
# 新增后必做容量强制(廉价);全量过期清理走低频节流(B1)。
|
||||||
|
self._maybe_sweep_nolock(now)
|
||||||
|
self._enforce_capacity_nolock()
|
||||||
return key in self._cache
|
return key in self._cache
|
||||||
|
|
||||||
async def delete(self, key: K) -> bool:
|
async def delete(self, key: K) -> bool:
|
||||||
|
|||||||
@@ -146,6 +146,10 @@ class DataAccess(Generic[T]):
|
|||||||
返回:
|
返回:
|
||||||
str | None: 缓存键,如果无法构建则返回None
|
str | None: 缓存键,如果无法构建则返回None
|
||||||
"""
|
"""
|
||||||
|
# 含 ORM lookup 后缀(如 channel_id__isnull / x__gte)的查询无法可靠映射到
|
||||||
|
# 单条主键缓存键,退化为直查 DB,避免空串与 NULL 语义混淆导致错误命中(A5)。
|
||||||
|
if any("__" in key for key in kwargs):
|
||||||
|
return None
|
||||||
if isinstance(self.key_field, tuple):
|
if isinstance(self.key_field, tuple):
|
||||||
# 多字段主键
|
# 多字段主键
|
||||||
key_parts = []
|
key_parts = []
|
||||||
|
|||||||
@@ -278,6 +278,7 @@ async def _execute_single_job_instance(
|
|||||||
schedule_id=schedule.id,
|
schedule_id=schedule.id,
|
||||||
plugin_name=plugin_name,
|
plugin_name=plugin_name,
|
||||||
bot_id=bot.self_id,
|
bot_id=bot.self_id,
|
||||||
|
platform_scope=PlatformUtils.get_platform_scope(bot),
|
||||||
group_id=group_id,
|
group_id=group_id,
|
||||||
job_kwargs=schedule.job_kwargs if isinstance(schedule.job_kwargs, dict) else {},
|
job_kwargs=schedule.job_kwargs if isinstance(schedule.job_kwargs, dict) else {},
|
||||||
)
|
)
|
||||||
@@ -381,6 +382,7 @@ async def _execute_job(
|
|||||||
)
|
)
|
||||||
if bot is None:
|
if bot is None:
|
||||||
return
|
return
|
||||||
|
context_override.platform_scope = PlatformUtils.get_platform_scope(bot)
|
||||||
logger.info(f"开始执行临时任务: {plugin_name}")
|
logger.info(f"开始执行临时任务: {plugin_name}")
|
||||||
injected_params = {"context": context_override}
|
injected_params = {"context": context_override}
|
||||||
state: T_State = {ScheduleContext: context_override}
|
state: T_State = {ScheduleContext: context_override}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ async def _load_schedules_from_db():
|
|||||||
schedule_id=0,
|
schedule_id=0,
|
||||||
plugin_name=job_id,
|
plugin_name=job_id,
|
||||||
bot_id=None,
|
bot_id=None,
|
||||||
|
platform_scope=None,
|
||||||
group_id=None,
|
group_id=None,
|
||||||
job_kwargs={},
|
job_kwargs={},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ class SchedulerManager:
|
|||||||
schedule_id=0,
|
schedule_id=0,
|
||||||
plugin_name=f"runtime::{func.__module__}",
|
plugin_name=f"runtime::{func.__module__}",
|
||||||
bot_id=None,
|
bot_id=None,
|
||||||
|
platform_scope=None,
|
||||||
group_id=None,
|
group_id=None,
|
||||||
job_kwargs={},
|
job_kwargs={},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ class ScheduleContext(BaseModel):
|
|||||||
schedule_id: int = Field(..., description="数据库中的任务ID")
|
schedule_id: int = Field(..., description="数据库中的任务ID")
|
||||||
plugin_name: str = Field(..., description="任务所属的插件名称")
|
plugin_name: str = Field(..., description="任务所属的插件名称")
|
||||||
bot_id: str | None = Field(None, description="执行任务的Bot ID")
|
bot_id: str | None = Field(None, description="执行任务的Bot ID")
|
||||||
|
platform_scope: str | None = Field(None, description="执行任务的细粒度平台作用域")
|
||||||
group_id: str | None = Field(None, description="当前执行实例的目标群组ID")
|
group_id: str | None = Field(None, description="当前执行实例的目标群组ID")
|
||||||
job_kwargs: dict = Field(default_factory=dict, description="任务配置的参数")
|
job_kwargs: dict = Field(default_factory=dict, description="任务配置的参数")
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,46 @@ class PlatformUtils:
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def resolve_bot(
|
||||||
|
cls,
|
||||||
|
bot_id: str | None = None,
|
||||||
|
platform_scope: str | None = None,
|
||||||
|
log_cmd: str | None = None,
|
||||||
|
) -> Bot | None:
|
||||||
|
"""Resolve a bot without randomly selecting the QQ official adapter.
|
||||||
|
|
||||||
|
Background jobs that require OneBot APIs should pass
|
||||||
|
``platform_scope="qq_client"``. When no scope is supplied and multiple
|
||||||
|
bots are online, a single OneBot client is preferred; otherwise the
|
||||||
|
ambiguous selection is skipped.
|
||||||
|
"""
|
||||||
|
if bot_id:
|
||||||
|
try:
|
||||||
|
bot = nonebot.get_bot(bot_id)
|
||||||
|
except KeyError:
|
||||||
|
logger.warning(f"Bot:{bot_id} 对象未连接或不存在", log_cmd)
|
||||||
|
return None
|
||||||
|
if platform_scope and cls.get_platform_scope(bot) != platform_scope:
|
||||||
|
logger.warning(f"Bot:{bot_id} 平台作用域不匹配,已跳过。", log_cmd)
|
||||||
|
return None
|
||||||
|
return bot
|
||||||
|
|
||||||
|
bots = list(nonebot.get_bots().values())
|
||||||
|
if platform_scope:
|
||||||
|
bots = [
|
||||||
|
bot for bot in bots if cls.get_platform_scope(bot) == platform_scope
|
||||||
|
]
|
||||||
|
if not bots:
|
||||||
|
logger.warning("当前没有匹配的 Bot,已跳过。", log_cmd)
|
||||||
|
return None
|
||||||
|
if len(bots) == 1:
|
||||||
|
return bots[0]
|
||||||
|
if platform_scope is None:
|
||||||
|
return cls._resolve_unique_qq_client_bot(log_cmd)
|
||||||
|
logger.warning("存在多个匹配的 Bot,无法安全选择,已跳过。", log_cmd)
|
||||||
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def is_qbot(cls, session: Uninfo | Bot) -> bool:
|
def is_qbot(cls, session: Uninfo | Bot) -> bool:
|
||||||
"""判断bot是否为qq官bot
|
"""判断bot是否为qq官bot
|
||||||
@@ -437,10 +477,13 @@ class PlatformUtils:
|
|||||||
info = interface.basic_info()
|
info = interface.basic_info()
|
||||||
platform = info["scope"].lower()
|
platform = info["scope"].lower()
|
||||||
return "qq" if platform.startswith("qq") else platform
|
return "qq" if platform.startswith("qq") else platform
|
||||||
|
adapter_name = _adapter_name(t)
|
||||||
|
if "onebot" in adapter_name or adapter_name == "qq":
|
||||||
|
return "qq"
|
||||||
|
return adapter_name or "unknown"
|
||||||
else:
|
else:
|
||||||
platform = t.basic["scope"].lower()
|
platform = t.basic["scope"].lower()
|
||||||
return "qq" if platform.startswith("qq") else platform
|
return "qq" if platform.startswith("qq") else platform
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_platform_scope(cls, t: Bot | Uninfo | object) -> str:
|
def get_platform_scope(cls, t: Bot | Uninfo | object) -> str:
|
||||||
|
|||||||
Reference in New Issue
Block a user