fix: 修复插件任务生命周期管理,避免用户困惑的报错行为 (#125)

- 修复 'NoneType' 报错 (Fix #125): 引入插件终止标志位并完善任务生命周期清理逻辑。
- 强化资源清理:重载时主动取消并等待所有运行中的异步任务(包括手动分析、自动调度和依赖安装),移除危险的属性置空操作,彻底消除重载时的属性访问竞态。
This commit is contained in:
SXP-Simon
2026-03-25 16:49:14 +08:00
parent 1ab409a49e
commit d03b3d5b42
6 changed files with 115 additions and 72 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# QQ群日常分析插件 - PDF 功能说明 # 群日常分析插件 - PDF 功能说明
## 概述 ## 概述
+97 -66
View File
@@ -1,5 +1,5 @@
""" """
QQ群日常分析插件 群日常分析插件
基于群聊记录生成精美的日常分析报告,包含话题总结、用户画像、统计数据等 基于群聊记录生成精美的日常分析报告,包含话题总结、用户画像、统计数据等
重构版本 - 使用模块化架构,支持跨平台 重构版本 - 使用模块化架构,支持跨平台
@@ -144,7 +144,7 @@ class GroupDailyAnalysis(Star):
) )
self._initialized = False self._initialized = False
self._discovery_run = False # 是否已尝试过运行发现逻辑 self._terminating = False # 生命周期标志
self._init_lock = asyncio.Lock() self._init_lock = asyncio.Lock()
self._background_tasks: set[asyncio.Task] = set() self._background_tasks: set[asyncio.Task] = set()
@@ -157,8 +157,6 @@ class GroupDailyAnalysis(Star):
self._background_tasks.add(self._init_task) self._background_tasks.add(self._init_task)
self._init_task.add_done_callback(self._background_tasks.discard) self._init_task.add_done_callback(self._background_tasks.discard)
except RuntimeError: except RuntimeError:
# 如果当前没有 running loop (例如在非异步初始化的环境中),
# 则依赖 on_platform_loaded 钩子执行初始化
self._init_task = None self._init_task = None
# orchestrators 缓存已移至 应用层逻辑 (分析服务) 或 暂时移除以简化。 # orchestrators 缓存已移至 应用层逻辑 (分析服务) 或 暂时移除以简化。
@@ -226,23 +224,30 @@ class GroupDailyAnalysis(Star):
async def terminate(self): async def terminate(self):
"""插件被卸载/停用时调用,清理资源""" """插件被卸载/停用时调用,清理资源"""
if self._terminating:
return
self._terminating = True
try: try:
logger.info("开始清理QQ群日常分析插件资源...") logger.info("开始清理群日常分析插件资源...")
# 1. 停止所有后台任务 # 1. 停止所有后台任务
if self._background_tasks: if self._background_tasks:
logger.info(f"正在取消 {len(self._background_tasks)}后台任务...") logger.info(f"正在取消 {len(self._background_tasks)}运行中的任务...")
for task in self._background_tasks: for task in self._background_tasks:
if not task.done(): if not task.done():
task.cancel() task.cancel()
# 等待任务结束 # 等待任务结束,给予 3 秒宽限期
await asyncio.gather(*self._background_tasks, return_exceptions=True) try:
await asyncio.wait(list(self._background_tasks), timeout=3.0)
except Exception:
pass
self._background_tasks.clear() self._background_tasks.clear()
# 2. 停止各个组件 # 2. 停止各个组件 (顺序:先调度器,后底层服务)
if self.auto_scheduler: if self.auto_scheduler:
logger.info("正在停止自动调度器...") logger.debug("正在停止自动调度器...")
self.auto_scheduler.unschedule_jobs(self.context) self.auto_scheduler.unschedule_jobs(self.context)
if self.retry_manager: if self.retry_manager:
@@ -251,19 +256,15 @@ class GroupDailyAnalysis(Star):
if self.template_preview_router: if self.template_preview_router:
await self.template_preview_router.unregister_handlers() await self.template_preview_router.unregister_handlers()
# 3. 释放实例属性引用 (使用 type: ignore 允许 None 赋值)
self.auto_scheduler = None # type: ignore
self.bot_manager = None # type: ignore
if self.report_generator: if self.report_generator:
await self.report_generator.close() await self.report_generator.close()
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群日常分析插件资源清理完成") # 3. [关键修复] 只有在任务全部清理后,才清理引用。
# 实际上,在 terminate 结束后,self 本身就会被 GC 释放,
# 这里的显式 None 更多是为了协助循环引用清理,但由于异步任务存在竞态,
# 我们可以通过 check _terminating 标志位来保护。
# 为了彻底解决 #125,我们保留引用,让 GC 自然回收。
logger.info("群日常分析插件资源清理完成")
except Exception as e: except Exception as e:
logger.error(f"插件资源清理失败: {e}") logger.error(f"插件资源清理失败: {e}")
@@ -463,58 +464,67 @@ class GroupDailyAnalysis(Star):
分析群聊日常活动(跨平台支持) 分析群聊日常活动(跨平台支持)
用法: /群分析 [天数] 用法: /群分析 [天数]
""" """
event.should_call_llm(True) # 阻止默认 LLM 解析 if self._terminating:
group_id = self._get_group_id_from_event(event)
platform_id = self._get_platform_id_from_event(event)
if not group_id:
yield event.plain_result("❌ 请在群聊中使用此命令")
return return
# 更新bot实例 current_task = asyncio.current_task()
self.bot_manager.update_from_event(event) if current_task:
self._background_tasks.add(current_task)
# 优先使用 UMO 进行权限检查 (兼容白名单 UMO 格式)
check_target = getattr(event, "unified_msg_origin", None)
if not check_target:
check_target = f"{platform_id}:GroupMessage:{group_id}"
if not self.config_manager.is_group_allowed(check_target):
# Fallback checks (simple ID) are handled inside is_group_allowed logic if list item has no colon
# But if list item HAS colon, we need precise match.
# If prompt fails, try simple ID as fallback for permissive cases?
# No, config_manager.is_group_allowed already handles simple ID matching if whitelist item is simple ID.
yield event.plain_result("❌ 此群未启用日常分析功能")
return
# 获取群名以生成语义化的 TraceID
group_name = ""
try: try:
event.should_call_llm(True) # 阻止默认 LLM 解析
group_id = self._get_group_id_from_event(event)
platform_id = self._get_platform_id_from_event(event)
if not group_id:
yield event.plain_result("❌ 请在群聊中使用此命令")
return
# 更新bot实例
self.bot_manager.update_from_event(event)
# 优先使用 UMO 进行权限检查 (兼容白名单 UMO 格式)
check_target = getattr(event, "unified_msg_origin", None)
if not check_target:
check_target = f"{platform_id}:GroupMessage:{group_id}"
if not self.config_manager.is_group_allowed(check_target):
# Fallback checks (simple ID) are handled inside is_group_allowed logic if list item has no colon
# But if list item HAS colon, we need precise match.
# If prompt fails, try simple ID as fallback for permissive cases?
# No, config_manager.is_group_allowed already handles simple ID matching if whitelist item is simple ID.
yield event.plain_result("❌ 此群未启用日常分析功能")
return
# 获取群名以生成语义化的 TraceID
group_name = ""
try:
adapter = self.bot_manager.get_adapter(platform_id)
if adapter:
info = await adapter.get_group_info(group_id)
if info and info.group_name:
group_name = info.group_name
except Exception:
pass
# 设置 TraceID (语义化格式: manual_群名_HHmm)
trace_id = TraceContext.generate(
prefix="manual", group_name=group_name or group_id
)
TraceContext.set(trace_id)
# 表情回应 或 文本提示(二选一,由配置开关控制)
adapter = self.bot_manager.get_adapter(platform_id) adapter = self.bot_manager.get_adapter(platform_id)
if adapter: orig_msg_id = getattr(event.message_obj, "message_id", None)
info = await adapter.get_group_info(group_id) use_text_reply = self.config_manager.get_enable_analysis_reply()
if info and info.group_name:
group_name = info.group_name
except Exception:
pass
# 设置 TraceID (语义化格式: manual_群名_HHmm) if use_text_reply:
trace_id = TraceContext.generate( yield event.plain_result("🔍 正在启动分析引擎,正在拉取最近消息...")
prefix="manual", group_name=group_name or group_id elif adapter and orig_msg_id:
) await adapter.set_reaction(
TraceContext.set(trace_id) event.get_group_id(), orig_msg_id, "🔍"
) # 🔍
# 表情回应 或 文本提示(二选一,由配置开关控制)
adapter = self.bot_manager.get_adapter(platform_id)
orig_msg_id = getattr(event.message_obj, "message_id", None)
use_text_reply = self.config_manager.get_enable_analysis_reply()
if use_text_reply:
yield event.plain_result("🔍 正在启动分析引擎,正在拉取最近消息...")
elif adapter and orig_msg_id:
await adapter.set_reaction(event.get_group_id(), orig_msg_id, "🔍") # 🔍
try:
# 调用 DDD 应用级服务 # 调用 DDD 应用级服务
result = await self.analysis_service.execute_daily_analysis( result = await self.analysis_service.execute_daily_analysis(
group_id=group_id, platform_id=platform_id, manual=True, days=days group_id=group_id, platform_id=platform_id, manual=True, days=days
@@ -538,16 +548,25 @@ class GroupDailyAnalysis(Star):
except DuplicateGroupTaskError: except DuplicateGroupTaskError:
yield event.plain_result("📊 该群的分析任务正在执行中,请稍后再试哦~") yield event.plain_result("📊 该群的分析任务正在执行中,请稍后再试哦~")
except asyncio.CancelledError:
logger.info("群分析任务被取消 (插件重载或卸载)")
except Exception as e: except Exception as e:
logger.error(f"群分析失败: {e}", exc_info=True) logger.error(f"群分析失败: {e}", exc_info=True)
yield event.plain_result( yield event.plain_result(
f"❌ 分析失败: {str(e)}。请检查网络连接和LLM配置,或联系管理员" f"❌ 分析失败: {str(e)}。请检查网络连接和LLM配置,或联系管理员"
) )
finally:
if current_task:
self._background_tasks.discard(current_task)
async def _send_analysis_report( async def _send_analysis_report(
self, event: AstrMessageEvent, result: dict self, event: AstrMessageEvent, result: dict
) -> AsyncGenerator: ) -> AsyncGenerator:
"""处理分析结果的渲染和发送""" """处理分析结果的渲染和发送"""
if self._terminating or not self.config_manager:
logger.warning("插件正在关闭,停止发送报告")
return
group_id = result["group_id"] group_id = result["group_id"]
platform_id = result["platform_id"] platform_id = result["platform_id"]
analysis_result = result["analysis_result"] analysis_result = result["analysis_result"]
@@ -755,17 +774,29 @@ class GroupDailyAnalysis(Star):
安装 PDF 功能依赖(跨平台支持) 安装 PDF 功能依赖(跨平台支持)
用法: /安装PDF 用法: /安装PDF
""" """
yield event.plain_result("🔄 开始安装 PDF 功能依赖,请稍候...") if self._terminating:
return
current_task = asyncio.current_task()
if current_task:
self._background_tasks.add(current_task)
try: try:
yield event.plain_result("🔄 开始安装 PDF 功能依赖,请稍候...")
result = await PDFInstaller.install_playwright( result = await PDFInstaller.install_playwright(
self.config_manager, task_registry=self._background_tasks self.config_manager, task_registry=self._background_tasks
) )
yield event.plain_result(result) yield event.plain_result(result)
except asyncio.CancelledError:
logger.info("PDF 安装任务被取消")
except Exception as e: except Exception as e:
logger.error(f"安装 PDF 依赖失败: {e}", exc_info=True) logger.error(f"安装 PDF 依赖失败: {e}", exc_info=True)
yield event.plain_result(f"❌ 安装过程中出现错误: {str(e)}") yield event.plain_result(f"❌ 安装过程中出现错误: {str(e)}")
finally:
if current_task:
self._background_tasks.discard(current_task)
@filter.command("分析设置", alias={"analysis_settings"}) @filter.command("分析设置", alias={"analysis_settings"})
@filter.permission_type(PermissionType.ADMIN) @filter.permission_type(PermissionType.ADMIN)
+1 -1
View File
@@ -1,5 +1,5 @@
""" """
QQ群日常分析插件 - 源代码包 群日常分析插件 - 源代码包
本包包含插件的核心实现,采用 DDD (领域驱动设计) 架构: 本包包含插件的核心实现,采用 DDD (领域驱动设计) 架构:
- application: 应用层 - 编排领域服务,处理用例 - application: 应用层 - 编排领域服务,处理用例
@@ -531,7 +531,7 @@
{{quotes_html | safe}} {{quotes_html | safe}}
</div> </div>
<div class="footer"> <div class="footer">
由 AstrBot QQ群日常分析插件 生成 | {{current_datetime}}<br> 由 AstrBot 群日常分析插件 生成 | {{current_datetime}}<br>
SXP-Simon/astrbot_plugin_qq_group_daily_analysis<br> SXP-Simon/astrbot_plugin_qq_group_daily_analysis<br>
<span style="opacity: 0.7;">AI分析消耗:{{total_tokens}} tokens (输入: {{prompt_tokens}}, 输出: {{completion_tokens}})</span> <span style="opacity: 0.7;">AI分析消耗:{{total_tokens}} tokens (输入: {{prompt_tokens}}, 输出: {{completion_tokens}})</span>
</div> </div>
@@ -825,7 +825,7 @@
<footer> <footer>
<div class="footer-row"> <div class="footer-row">
<div>CRT_EXPORT_CHANNEL // QQ群日常分析插件</div> <div>CRT_EXPORT_CHANNEL // AstrBot 群日常分析插件</div>
<div class="footer-author">SXP-Simon / astrbot_plugin_qq_group_daily_analysis</div> <div class="footer-author">SXP-Simon / astrbot_plugin_qq_group_daily_analysis</div>
</div> </div>
<div class="footer-row"> <div class="footer-row">
+14 -2
View File
@@ -51,6 +51,7 @@ class AutoScheduler:
# Cache: group_id -> group_name (populated lazily) # Cache: group_id -> group_name (populated lazily)
self._group_name_cache: dict[str, str] = {} self._group_name_cache: dict[str, str] = {}
self._terminating = False # 终止标志位
def set_bot_instance(self, bot_instance): def set_bot_instance(self, bot_instance):
"""设置bot实例(保持向后兼容)""" """设置bot实例(保持向后兼容)"""
@@ -269,6 +270,7 @@ class AutoScheduler:
def unschedule_jobs(self, context): def unschedule_jobs(self, context):
"""取消定时任务""" """取消定时任务"""
self._terminating = True
if ( if (
not context not context
or not hasattr(context, "cron_manager") or not hasattr(context, "cron_manager")
@@ -343,6 +345,8 @@ class AutoScheduler:
async def _run_auto_analysis(self): async def _run_auto_analysis(self):
"""执行传统自动分析 - 并发处理所有群聊""" """执行传统自动分析 - 并发处理所有群聊"""
if self._terminating:
return
try: try:
logger.info("开始执行自动群聊分析(并发模式)") logger.info("开始执行自动群聊分析(并发模式)")
@@ -424,6 +428,9 @@ class AutoScheduler:
trace_id = TraceContext.generate(prefix="group", group_name=group_name) trace_id = TraceContext.generate(prefix="group", group_name=group_name)
TraceContext.set(trace_id) TraceContext.set(trace_id)
if self._terminating:
return
logger.info( logger.info(
f"开始为群 {group_id} 执行自动分析 (Platform: {target_platform_id or 'Auto'})" f"开始为群 {group_id} 执行自动分析 (Platform: {target_platform_id or 'Auto'})"
) )
@@ -473,9 +480,11 @@ class AutoScheduler:
# ================================================================ # ================================================================
async def _run_incremental_analysis(self): async def _run_incremental_analysis(self):
"""执行增量分析 - 为所有启用的群聊执行一次增量分析批次""" """执行增量分析任务 - 并发处理模式"""
if self._terminating:
return
try: try:
logger.info("开始执行增量分析(交错并发模式)") logger.info("开始执行自动增量分析(并发模式)")
enabled_targets = await self._get_enabled_targets() enabled_targets = await self._get_enabled_targets()
@@ -586,6 +595,9 @@ class AutoScheduler:
trace_id = TraceContext.generate(prefix="incr", group_name=group_name) trace_id = TraceContext.generate(prefix="incr", group_name=group_name)
TraceContext.set(trace_id) TraceContext.set(trace_id)
if self._terminating:
return
logger.info( logger.info(
f"开始为群 {group_id} 执行增量分析 " f"开始为群 {group_id} 执行增量分析 "
f"(Platform: {target_platform_id or 'Auto'})" f"(Platform: {target_platform_id or 'Auto'})"