From c2bc110c578a03d1cc36401b361162bba80662f5 Mon Sep 17 00:00:00 2001 From: liuwei Date: Thu, 23 Apr 2026 13:32:40 +0800 Subject: [PATCH] =?UTF-8?q?=E5=93=8D=E5=BA=94=E6=8C=87=E4=BB=A4=E5=AA=92?= =?UTF-8?q?=E8=B5=84=E5=8F=91=E9=80=81=E5=A2=9E=E5=8A=A0=E5=86=85=E5=AD=98?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 在趣味指令插件中新增媒资缓存:首次发送读磁盘,后续优先从内存读取,减少重复I/O。 2. 缓存键包含路径+mtime+size,文件更新后可自动回源读取新内容。 3. 增加单文件上限与总容量上限,并采用LRU淘汰策略防止内存膨胀。 4. 图片语音视频发送链路改为优先使用缓存字节数据发送。 --- plugins/fun_command_play/config.toml | 6 ++ plugins/fun_command_play/main.py | 135 +++++++++++++++++++++++++-- 2 files changed, 133 insertions(+), 8 deletions(-) diff --git a/plugins/fun_command_play/config.toml b/plugins/fun_command_play/config.toml index b07d613..4019238 100644 --- a/plugins/fun_command_play/config.toml +++ b/plugins/fun_command_play/config.toml @@ -1,2 +1,8 @@ [FunCommandPlay] enable = true +# 媒资缓存开关:开启后首次发送读磁盘,后续复用内存缓存。 +media-cache-enable = true +# 单个媒资文件允许缓存的最大体积(MB)。 +media-cache-max-file-mb = 80 +# 媒资缓存总上限(MB),超限后按最近最少使用策略淘汰。 +media-cache-max-total-mb = 200 diff --git a/plugins/fun_command_play/main.py b/plugins/fun_command_play/main.py index 0235194..cfbac17 100644 --- a/plugins/fun_command_play/main.py +++ b/plugins/fun_command_play/main.py @@ -9,6 +9,8 @@ import asyncio import os +import threading +from collections import OrderedDict from pathlib import Path from typing import Any, Dict, List, Optional, Tuple @@ -64,6 +66,16 @@ class FunCommandPlayPlugin(MessagePluginInterface): self.feature = self.register_feature() self.rule_service: Optional[FunCommandRuleService] = None self.enable = True + # 媒资缓存开关:默认开启,首次发送时读磁盘,后续直接走内存,降低 I/O 开销。 + self.media_cache_enable = True + # 单个文件允许缓存的最大体积(字节)。超过阈值则只发送不入缓存,防止单文件挤爆内存。 + self.media_cache_max_file_bytes = 80 * 1024 * 1024 + # 全局缓存总上限(字节)。采用 LRU 淘汰,越久未使用越先被移除。 + self.media_cache_max_total_bytes = 200 * 1024 * 1024 + # 缓存结构:key -> bytes。OrderedDict 用于维护访问顺序,实现 LRU。 + self._media_cache: "OrderedDict[str, bytes]" = OrderedDict() + self._media_cache_total_bytes = 0 + self._media_cache_lock = threading.RLock() def initialize(self, context: Dict[str, Any]) -> bool: """初始化插件。""" @@ -78,6 +90,17 @@ class FunCommandPlayPlugin(MessagePluginInterface): # 读取开关配置:保留插件级总开关,便于快速停用。 plugin_cfg = self._config.get("FunCommandPlay", {}) self.enable = bool(plugin_cfg.get("enable", True)) + # 媒资缓存参数支持通过配置覆盖,便于你按机器内存规模灵活调整。 + # 约定单位为 MB,方便运维理解和调整。 + self.media_cache_enable = bool(plugin_cfg.get("media-cache-enable", True)) + self.media_cache_max_file_bytes = max( + int(plugin_cfg.get("media-cache-max-file-mb", 80) or 80), + 1 + ) * 1024 * 1024 + self.media_cache_max_total_bytes = max( + int(plugin_cfg.get("media-cache-max-total-mb", 200) or 200), + 10 + ) * 1024 * 1024 # 初始化规则服务,确保首次启动就有表结构。 redis_client = db_manager.get_redis_connection() @@ -92,6 +115,98 @@ class FunCommandPlayPlugin(MessagePluginInterface): self.LOG.debug(f"[{self.name}] 插件初始化完成") return True + @staticmethod + def _build_media_cache_key(media_kind: str, media_path: str) -> str: + """构建媒资缓存键。 + + 关键点: + 1. 键里带上绝对路径 + 文件修改时间 + 文件大小,确保文件内容变更后会自动命中新键。 + 2. 不依赖手动清缓存,更新本地文件后下一次发送会自然回源读取新内容。 + """ + abs_path = os.path.abspath(media_path) + stat = os.stat(abs_path) + return f"{media_kind}:{abs_path}:{int(stat.st_mtime_ns)}:{int(stat.st_size)}" + + def _try_get_media_bytes_from_cache(self, cache_key: str) -> Optional[bytes]: + """尝试从内存缓存读取媒资字节。 + + 读取后会把条目移动到末尾,表示最近访问,配合 LRU 淘汰策略使用。 + """ + with self._media_cache_lock: + cached = self._media_cache.get(cache_key) + if cached is None: + return None + self._media_cache.move_to_end(cache_key) + return cached + + def _put_media_bytes_to_cache(self, cache_key: str, payload: bytes) -> None: + """写入媒资到内存缓存,并执行 LRU 淘汰。 + + 规则: + 1. 单文件超过阈值不入缓存。 + 2. 新写入前先清理旧键占用(若覆盖)。 + 3. 超过总容量时,从最久未使用项开始淘汰。 + """ + if not self.media_cache_enable: + return + if payload is None: + return + + payload_size = len(payload) + if payload_size <= 0: + return + if payload_size > self.media_cache_max_file_bytes: + return + + with self._media_cache_lock: + old_payload = self._media_cache.pop(cache_key, None) + if old_payload is not None: + self._media_cache_total_bytes -= len(old_payload) + + self._media_cache[cache_key] = payload + self._media_cache_total_bytes += payload_size + self._media_cache.move_to_end(cache_key) + + while self._media_cache_total_bytes > self.media_cache_max_total_bytes and self._media_cache: + stale_key, stale_payload = self._media_cache.popitem(last=False) + self._media_cache_total_bytes -= len(stale_payload) + self.LOG.debug(f"[{self.name}] 媒资缓存淘汰: {stale_key}") + + def _load_media_bytes(self, media_kind: str, media_path: str) -> Optional[bytes]: + """加载媒资字节:先缓存,后磁盘。 + + 行为: + 1. 首次发送读取磁盘并缓存。 + 2. 后续发送命中缓存,避免重复磁盘 I/O。 + """ + if not media_path: + return None + if not os.path.exists(media_path): + self.LOG.warning(f"[{self.name}] 媒资路径不存在: {media_path}") + return None + + try: + cache_key = self._build_media_cache_key(media_kind=media_kind, media_path=media_path) + except Exception as e: + self.LOG.warning(f"[{self.name}] 构建媒资缓存键失败,将回退磁盘读取: path={media_path}, error={e}") + cache_key = "" + + if cache_key: + cached = self._try_get_media_bytes_from_cache(cache_key) + if cached is not None: + return cached + + try: + with open(media_path, "rb") as fp: + payload = fp.read() + except Exception as e: + self.LOG.error(f"[{self.name}] 读取媒资文件失败: path={media_path}, error={e}") + return None + + if cache_key: + self._put_media_bytes_to_cache(cache_key, payload) + return payload + def start(self) -> bool: self.status = PluginStatus.RUNNING self.LOG.info(f"[{self.name}] 插件已启动") @@ -238,28 +353,32 @@ class FunCommandPlayPlugin(MessagePluginInterface): if action_type == "image": image_path = self._render_template(str(action.get("path", "") or ""), context) - if image_path and os.path.exists(image_path): - await bot.send_image_message(target_id, Path(image_path)) + image_bytes = self._load_media_bytes(media_kind="image", media_path=image_path) + if image_bytes: + await bot.send_image_message(target_id, image_bytes) return if action_type == "voice": voice_path = self._render_template(str(action.get("path", "") or ""), context) voice_format = str(action.get("format", "") or "").strip().lower() - if voice_path and os.path.exists(voice_path): + voice_bytes = self._load_media_bytes(media_kind="voice", media_path=voice_path) + if voice_bytes: if not voice_format: suffix = Path(voice_path).suffix.lower() voice_format = "wav" if suffix == ".wav" else "mp3" - await bot.send_voice_message(target_id, Path(voice_path), voice_format) + await bot.send_voice_message(target_id, voice_bytes, voice_format) return if action_type == "video": video_path = self._render_template(str(action.get("path", "") or ""), context) cover_path = self._render_template(str(action.get("cover_path", "") or ""), context) - if video_path and os.path.exists(video_path): - if cover_path and os.path.exists(cover_path): - await bot.send_video_message(target_id, Path(video_path), Path(cover_path)) + video_bytes = self._load_media_bytes(media_kind="video", media_path=video_path) + if video_bytes: + cover_bytes = self._load_media_bytes(media_kind="image", media_path=cover_path) if cover_path else None + if cover_bytes: + await bot.send_video_message(target_id, video_bytes, cover_bytes) else: - await bot.send_video_message(target_id, Path(video_path)) + await bot.send_video_message(target_id, video_bytes) return if action_type == "link":