feat: 全面重构主插件架构,解决并发竞态、资源泄露并优化代码健壮性

【并发与生命周期规范化】
- 引入 asyncio.Lock (_init_lock) 保护初始化流程,确保多入口(Reload/Init/Loaded)触发时维持单例运行,消除重复注册风险。
- 重构初始化任务创建逻辑,增加对当前协程 Loop 的状态检查,预防 Python 3.10+ 环境下常见的运行时异常。
- 引入 _background_tasks 集合统一追踪异步任务生命周期,实现透明的任务注册与自动移除。
【资源清理与加固】
- 增强 terminate() 析构逻辑:在卸载时主动取消所有追踪的后台任务(包括正在进行的分析、渲染或依赖安装),并使用 asyncio.gather 确保安全释放。
- 采用 type: ignore 配合非 Optional 类型声明,在保证 terminate() 能够置空引用的前提下,消除了 Pylance 对全局 50+ 处属性访问的“可能为 None”误报。
【业务流程健壮性】
- 提取超长逻辑:将分析报告的渲染与多格式分发逻辑抽离至 _send_analysis_report 异步生成器,显著降低单一函数圈复杂度。
- 优化图片上传服务:
  * 引入 MAX_PAYLOAD_SIZE (20MB) 机制,拦截超大 Base64/数据,隔离内存风险。
  * 弃用固定路径,改用 tempfile.mkstemp 生成线程安全的唯一临时文件。
  * 完善 finally 块清理逻辑,确保物理文件在上传成功或失败后都能被即时删除。
【指令系统重构】
- 采用子命令模式优化 /分析设置 (analysis_settings),将启用/禁用逻辑下沉至私有处理方法,提升代码可读性。
- 修复 /群分析 异步迭代漏洞,确保分析结果流能正确通过 AsyncGenerator 协议回传。
- 移除所有业务校验中的 assert 断言,替换为更安全的条件分支与错误消息返回。
This commit is contained in:
SXP-Simon
2026-03-11 23:18:09 +08:00
parent f70c37473c
commit 059d6ea68d
+165 -154
View File
@@ -7,6 +7,8 @@ QQ群日常分析插件
import asyncio
import os
from collections.abc import AsyncGenerator
from pathlib import Path
from astrbot.api import AstrBotConfig
from astrbot.api import logger as astrbot_logger
@@ -51,7 +53,7 @@ from .src.utils.trace_context import TraceContext, TraceLogFilter
class GroupDailyAnalysis(Star):
"""群分析插件主类"""
# ── 显式类型声明(消除 Pylance Optional 推断) ──
# ── 显式类型声明 (由 __init__ 初始化) ──
config: AstrBotConfig
config_manager: ConfigManager
bot_manager: BotManager
@@ -141,10 +143,21 @@ class GroupDailyAnalysis(Star):
self._initialized = False
self._discovery_run = False # 是否已尝试过运行发现逻辑
self._init_lock = asyncio.Lock()
self._background_tasks: set[asyncio.Task] = set()
# 异步注册任务,处理插件重载情况
self._init_task = asyncio.create_task(
try:
loop = asyncio.get_running_loop()
self._init_task = loop.create_task(
self._run_initialization("Plugin Reload/Init")
)
self._background_tasks.add(self._init_task)
self._init_task.add_done_callback(self._background_tasks.discard)
except RuntimeError:
# 如果当前没有 running loop (例如在非异步初始化的环境中),
# 则依赖 on_platform_loaded 钩子执行初始化
self._init_task = None
# orchestrators 缓存已移至 应用层逻辑 (分析服务) 或 暂时移除以简化。
# 如果需要高性能缓存,后续可由 AnalysisApplicationService 内部维护。
@@ -156,6 +169,7 @@ class GroupDailyAnalysis(Star):
async def _run_initialization(self, source: str):
"""统一初始化逻辑"""
async with self._init_lock:
# 如果已经成功发现过平台,且不是来自 Platform Loaded 的强制触发,则跳过
if (
self._initialized
@@ -176,33 +190,24 @@ class GroupDailyAnalysis(Star):
try:
# 注册 TraceID 过滤器
trace_filter = TraceLogFilter()
if not any(isinstance(f, TraceLogFilter) for f in astrbot_logger.filters):
if not any(
isinstance(f, TraceLogFilter) for f in astrbot_logger.filters
):
astrbot_logger.addFilter(trace_filter)
astrbot_logger.info("[Trace] TraceID 日志追踪已启用")
logger.info(f"正在执行插件初始化 (来源: {source})...")
# 检查插件是否被启用 (Fix for empty plugin_set issue)
if self.context:
config = self.context.get_config()
# ... 为空修正逻辑保持不变 ...
plugin_set = config.get("plugin_set", [])
if (
isinstance(plugin_set, list)
and "astrbot_plugin_qq_group_daily_analysis" not in plugin_set
):
# 此时不强制修改 config,但可以记录日志
pass
# 1. 尝试发现 bot 实例(即使暂时没有,后续任务触发时也会再扫一遍)
# 1. 尝试发现 bot 实例
await self.bot_manager.initialize_from_config()
# 2. 注册预览路由器 (WebUI 路由注册不依赖在线机器人)
# 2. 注册预览路由器
if self.template_preview_router:
await self.template_preview_router.ensure_handlers_registered(
self.context
)
# 3. 强制注册定时分析任务 (确保 APScheduler 即使在空载时也有任务占位)
# 3. 强制注册定时分析任务
if self.auto_scheduler:
self.auto_scheduler.schedule_jobs(self.context)
@@ -220,36 +225,39 @@ class GroupDailyAnalysis(Star):
async def terminate(self):
"""插件被卸载/停用时调用,清理资源"""
try:
# 取消正在进行的初始化任务
if (
hasattr(self, "_init_task")
and self._init_task
and not self._init_task.done()
):
self._init_task.cancel()
logger.info("开始清理QQ群日常分析插件资源...")
# 停止自动调度器
# 1. 停止所有后台任务
if self._background_tasks:
logger.info(f"正在取消 {len(self._background_tasks)} 个后台任务...")
for task in self._background_tasks:
if not task.done():
task.cancel()
# 等待任务结束
await asyncio.gather(*self._background_tasks, return_exceptions=True)
self._background_tasks.clear()
# 2. 停止各个组件
if self.auto_scheduler:
logger.info("正在停止自动调度器...")
self.auto_scheduler.unschedule_jobs(self.context)
logger.info("自动调度器已停止")
self.auto_scheduler.schedule_jobs(None) # type: ignore
if self.retry_manager:
await self.retry_manager.stop()
if self.template_preview_router:
await self.template_preview_router.unregister_handlers()
# 释放实例属性引用(插件卸载后不再使用)
self.auto_scheduler = None
self.bot_manager = None
self.report_generator = None
self.config_manager = None
self.message_processing_service = None
self.telegram_group_registry = None
self.template_preview_router = None
self.telegram_template_preview_handler = None
# 3. 释放实例属性引用 (使用 type: ignore 允许 None 赋值)
self.auto_scheduler = None # type: ignore
self.bot_manager = None # type: ignore
self.report_generator = None # type: ignore
self.config_manager = None # type: ignore
self.message_processing_service = None # type: ignore
self.telegram_group_registry = None # type: ignore
self.template_preview_router = None # type: ignore
self.telegram_template_preview_handler = None # type: ignore
logger.info("QQ群日常分析插件资源清理完成")
@@ -354,27 +362,36 @@ class GroupDailyAnalysis(Star):
# 2. 将内容准备为文件或数据
image_file = None
created_temp = False
MAX_PAYLOAD_SIZE = 20 * 1024 * 1024 # 20MB 限制
try:
data = None
if image_url.startswith("base64://"):
data = base64.b64decode(image_url[len("base64://") :])
base64_str = image_url[len("base64://") :]
if len(base64_str) * 3 / 4 > MAX_PAYLOAD_SIZE:
logger.warning("图片上传失败:Base64 负载过大")
return
data = base64.b64decode(base64_str)
elif image_url.startswith("data:"):
parts = image_url.split(",", 1)
data = base64.b64decode(parts[1]) if len(parts) == 2 else None
elif os.path.isfile(image_url):
if os.path.isabs(image_url):
image_file = image_url
else:
image_file = os.path.abspath(image_url)
data = None
else:
if len(parts) == 2:
if len(parts[1]) * 3 / 4 > MAX_PAYLOAD_SIZE:
logger.warning("图片上传失败:Data URI 负载过大")
return
data = base64.b64decode(parts[1])
elif os.path.isfile(image_url):
image_file = os.path.abspath(image_url)
if data and not image_file:
# 使用优化的文件名创建临时文件
image_file = os.path.join(tempfile.gettempdir(), nice_filename)
with open(image_file, "wb") as f:
# 使用 tempfile 生成唯一后缀,防止并发冲突
fd, image_file = tempfile.mkstemp(suffix=ext, prefix="group_report_")
try:
with os.fdopen(fd, "wb") as f:
f.write(data)
created_temp = True
except Exception:
os.close(fd)
raise
if not image_file:
return
@@ -475,15 +492,31 @@ class GroupDailyAnalysis(Star):
f"📊 已获取{result['messages_count']}条消息,正在生成渲染报告..."
)
async for res in self._send_analysis_report(event, result, trace_id):
yield res
except DuplicateGroupTaskError:
yield event.plain_result("📊 该群的分析任务正在执行中,请稍后再试哦~")
except Exception as e:
logger.error(f"群分析失败: {e}", exc_info=True)
yield event.plain_result(
f"❌ 分析失败: {str(e)}。请检查网络连接和LLM配置,或联系管理员"
)
async def _send_analysis_report(
self, event: AstrMessageEvent, result: dict, trace_id: str
) -> AsyncGenerator:
"""处理分析结果的渲染和发送"""
group_id = result["group_id"]
platform_id = result["platform_id"]
analysis_result = result["analysis_result"]
adapter = result["adapter"]
output_format = self.config_manager.get_output_format()
# 定义头像获取回调 (Infrastructure delegate)
# 定义获取回调
async def avatar_getter(user_id: str) -> str | None:
return await adapter.get_user_avatar_url(user_id)
# 定义昵称获取回调
async def nickname_getter(user_id: str) -> str | None:
try:
member = await adapter.get_member_info(group_id, user_id)
@@ -494,10 +527,7 @@ class GroupDailyAnalysis(Star):
return None
if output_format == "image":
(
image_url,
html_content,
) = await self.report_generator.generate_image_report(
image_url, html_content = await self.report_generator.generate_image_report(
analysis_result,
group_id,
self.html_render,
@@ -507,15 +537,10 @@ class GroupDailyAnalysis(Star):
if image_url:
caption = f"📊 每日群聊分析报告已生成:\n[ID: {trace_id}]"
# 优先使用适配器的 send_image (由插件适配器统一处理 Base64 转换和路径问题)
# 不再使用 yield event.image_result 回退,防止适配器超时回复导致重复发送图片
await adapter.send_image(group_id, image_url, caption=caption)
# 上传到群文件/群相册 (属于附加功能,不影响消息发送)
await self._try_upload_image(group_id, image_url, platform_id)
elif html_content:
yield event.plain_result("⚠️ 群分析报告图片发送失败,自动重试中。")
# 使用带提示词的重试任务,确保排队发送时视觉一致
await self.retry_manager.add_task(
html_content,
analysis_result,
@@ -527,9 +552,7 @@ class GroupDailyAnalysis(Star):
text_report = self.report_generator.generate_text_report(
analysis_result
)
yield event.plain_result(
f"⚠️ 图片生成失败,回退文本:\n\n{text_report}"
)
yield event.plain_result(f"⚠️ 图片生成失败,回退文本:\n\n{text_report}")
elif output_format == "pdf":
pdf_path = await self.report_generator.generate_pdf_report(
@@ -540,8 +563,6 @@ class GroupDailyAnalysis(Star):
)
if pdf_path:
if not await adapter.send_file(group_id, pdf_path):
from pathlib import Path
yield event.chain_result(
[File(name=Path(pdf_path).name, file=pdf_path)]
)
@@ -549,20 +570,10 @@ class GroupDailyAnalysis(Star):
yield event.plain_result("⚠️ PDF 生成失败。")
else:
text_report = self.report_generator.generate_text_report(
analysis_result
)
text_report = self.report_generator.generate_text_report(analysis_result)
if not await adapter.send_text(group_id, text_report):
yield event.plain_result(text_report)
except DuplicateGroupTaskError:
yield event.plain_result("📊 该群的分析任务正在执行中,请稍后再试哦~")
except Exception as e:
logger.error(f"群分析失败: {e}", exc_info=True)
yield event.plain_result(
f"❌ 分析失败: {str(e)}。请检查网络连接和LLM配置,或联系管理员"
)
@filter.command("设置格式", alias={"set_format"})
@filter.permission_type(PermissionType.ADMIN)
async def set_output_format(self, event: AstrMessageEvent, format_type: str = ""):
@@ -641,7 +652,10 @@ class GroupDailyAnalysis(Star):
if parse_error:
yield event.plain_result(parse_error)
return
assert template_name is not None
if not template_name:
yield event.plain_result(f"❌ 无法解析模板输入: {template_input}")
return
if not await self.template_command_service.template_exists(template_name):
yield event.plain_result(f"❌ 模板 '{template_name}' 不存在")
@@ -702,7 +716,9 @@ class GroupDailyAnalysis(Star):
yield event.plain_result("🔄 开始安装 PDF 功能依赖,请稍候...")
try:
result = await PDFInstaller.install_playwright(self.config_manager)
result = await PDFInstaller.install_playwright(
self.config_manager, task_registry=self._background_tasks
)
yield event.plain_result(result)
except Exception as e:
@@ -728,83 +744,12 @@ class GroupDailyAnalysis(Star):
yield event.plain_result("❌ 请在群聊中使用此命令")
return
elif action == "enable":
mode = self.config_manager.get_group_list_mode()
target_id = event.unified_msg_origin or group_id # 优先使用 UMO
if mode == "whitelist":
glist = self.config_manager.get_group_list()
# 检查 UMO 或 Group ID 是否已在列表中
if not self.config_manager.is_group_allowed(target_id):
glist.append(target_id)
self.config_manager.set_group_list(glist)
yield event.plain_result(
f"✅ 已将当前群加入白名单\nID: {target_id}"
)
self.auto_scheduler.schedule_jobs(self.context)
else:
yield event.plain_result("️ 当前群已在白名单中")
elif mode == "blacklist":
glist = self.config_manager.get_group_list()
# 尝试移除 UMO 和 Group ID
removed = False
if target_id in glist:
glist.remove(target_id)
removed = True
if group_id in glist:
glist.remove(group_id)
removed = True
if removed:
self.config_manager.set_group_list(glist)
yield event.plain_result("✅ 已将当前群从黑名单移除")
self.auto_scheduler.schedule_jobs(self.context)
else:
yield event.plain_result("️ 当前群不在黑名单中")
else:
yield event.plain_result("ℹ️ 当前为无限制模式,所有群聊默认启用")
if action == "enable":
async for result in self._handle_settings_enable(event, group_id):
yield result
elif action == "disable":
mode = self.config_manager.get_group_list_mode()
target_id = event.unified_msg_origin or group_id # 优先使用 UMO
if mode == "whitelist":
glist = self.config_manager.get_group_list()
# 尝试移除 UMO 和 Group ID
removed = False
if target_id in glist:
glist.remove(target_id)
removed = True
if group_id in glist:
glist.remove(group_id)
removed = True
if removed:
self.config_manager.set_group_list(glist)
yield event.plain_result("✅ 已将当前群从白名单移除")
self.auto_scheduler.schedule_jobs(self.context)
else:
yield event.plain_result("️ 当前群不在白名单中")
elif mode == "blacklist":
glist = self.config_manager.get_group_list()
# 检查 UMO 或 Group ID 是否已在列表中
if self.config_manager.is_group_allowed(
target_id
): # 如果允许,说明不在黑名单
glist.append(target_id)
self.config_manager.set_group_list(glist)
yield event.plain_result(
f"✅ 已将当前群加入黑名单\nID: {target_id}"
)
self.auto_scheduler.schedule_jobs(self.context)
else:
yield event.plain_result("️ 当前群已在黑名单中")
else:
yield event.plain_result(
"ℹ️ 当前为无限制模式,如需禁用请切换到黑名单模式"
)
async for result in self._handle_settings_disable(event, group_id):
yield result
elif action == "reload":
self.auto_scheduler.schedule_jobs(self.context)
@@ -940,3 +885,69 @@ class GroupDailyAnalysis(Star):
f"• 参与者: {summary['participants']}\n"
f"• 高峰时段: {summary['peak_hours']}"
)
async def _handle_settings_enable(self, event: AstrMessageEvent, group_id: str):
"""协助逻辑:处理启用设置的分支逻辑"""
mode = self.config_manager.get_group_list_mode()
target_id = event.unified_msg_origin or group_id
if mode == "whitelist":
glist = self.config_manager.get_group_list()
if not self.config_manager.is_group_allowed(target_id):
glist.append(target_id)
self.config_manager.set_group_list(glist)
yield event.plain_result(f"✅ 已将当前群加入白名单\nID: {target_id}")
self.auto_scheduler.schedule_jobs(self.context)
else:
yield event.plain_result("️ 当前群已在白名单中")
elif mode == "blacklist":
glist = self.config_manager.get_group_list()
removed = False
if target_id in glist:
glist.remove(target_id)
removed = True
if group_id in glist:
glist.remove(group_id)
removed = True
if removed:
self.config_manager.set_group_list(glist)
yield event.plain_result("✅ 已将当前群从黑名单移除")
self.auto_scheduler.schedule_jobs(self.context)
else:
yield event.plain_result("️ 当前群不在黑名单中")
else:
yield event.plain_result("ℹ️ 当前为无限制模式,所有群聊默认启用")
async def _handle_settings_disable(self, event: AstrMessageEvent, group_id: str):
"""协助逻辑:处理禁用设置的分支逻辑"""
mode = self.config_manager.get_group_list_mode()
target_id = event.unified_msg_origin or group_id
if mode == "whitelist":
glist = self.config_manager.get_group_list()
removed = False
if target_id in glist:
glist.remove(target_id)
removed = True
if group_id in glist:
glist.remove(group_id)
removed = True
if removed:
self.config_manager.set_group_list(glist)
yield event.plain_result("✅ 已将当前群从白名单移除")
self.auto_scheduler.schedule_jobs(self.context)
else:
yield event.plain_result("️ 当前群不在白名单中")
elif mode == "blacklist":
glist = self.config_manager.get_group_list()
if self.config_manager.is_group_allowed(target_id):
glist.append(target_id)
self.config_manager.set_group_list(glist)
yield event.plain_result(f"✅ 已将当前群加入黑名单\nID: {target_id}")
self.auto_scheduler.schedule_jobs(self.context)
else:
yield event.plain_result("️ 当前群已在黑名单中")
else:
yield event.plain_result("ℹ️ 当前为无限制模式,如需禁用请切换到黑名单模式")