refactor: 报告中的群员头像重构为 diskcache 缓存,过期时间3天;优化代码可读性; (#118 @oculr)

* refactor: 报告中的群员头像重构为diskcache缓存,过期时间3天;优化代码可读性;

* refactor: 进一步优化头像处理逻辑与资源管理生命周期

- **头像处理性能优化**:
  - 将 Base64 转换重构为同步方法,消除微型 CPU 计算任务的异步上下文切换开销。
  - 增强图片 MIME 类型识别能力,通过分析文件头支持 WebP 和 JPEG 魔数检测。
  - 改善下载失败处理:移除了失败结果的持久化缓存(Negative Caching),确保在网络波动恢复后能自动重新尝试获取。
- **资源与生命周期管理**:
  - 完善插件终止流程:增加了显式的缓存关闭逻辑,并与插件卸载钩子集成,确保正确释放持久化层的文件句柄。
- **鲁棒性与调试增强**:
  - 加强对第三方头像获取器返回值的 URL 协议校验,并补充了相应的警告日志。
  - 优化了 Base64 转换等环节的异常日志记录格式,增加了对堆栈追踪的支持。
- **代码整洁度**:
  - 统一并清理了重复的模块导入,移除了方法内部冗余的库导入。
  - 全量应用 Ruff 格式化规范,优化了导入顺序及整体代码布局。

---------

Co-authored-by: SXP-Simon <sxp20061207@163.com>
This commit is contained in:
oculr
2026-03-22 23:36:28 +08:00
committed by GitHub
co-authored by SXP-Simon
parent 51d14dad0b
commit f40243492f
3 changed files with 140 additions and 127 deletions
+9 -5
View File
@@ -14,7 +14,7 @@ from astrbot.api import AstrBotConfig
from astrbot.api import logger as astrbot_logger
from astrbot.api.event import AstrMessageEvent, filter
from astrbot.api.event.filter import PermissionType
from astrbot.api.star import Context, Star
from astrbot.api.star import Context, Star, StarTools
from astrbot.core.message.components import File
from .src.application.commands.template_command_service import (
@@ -83,7 +83,9 @@ class GroupDailyAnalysis(Star):
self.bot_manager.set_context(context)
self.bot_manager.set_plugin_instance(self)
self.history_manager = HistoryManager(self)
self.report_generator = ReportGenerator(self.config_manager)
self.report_generator = ReportGenerator(
self.config_manager, StarTools.get_data_dir()
)
# Telegram 注册表 (持久层)
self.telegram_group_registry = TelegramGroupRegistry(self)
@@ -252,6 +254,8 @@ class GroupDailyAnalysis(Star):
# 3. 释放实例属性引用 (使用 type: ignore 允许 None 赋值)
self.auto_scheduler = None # type: ignore
self.bot_manager = None # type: ignore
if self.report_generator:
self.report_generator.close()
self.report_generator = None # type: ignore
self.config_manager = None # type: ignore
self.message_processing_service = None # type: ignore
@@ -547,7 +551,7 @@ class GroupDailyAnalysis(Star):
output_format = self.config_manager.get_output_format()
# 定义获取回调
async def avatar_getter(user_id: str) -> str | None:
async def avatar_url_getter(user_id: str) -> str | None:
return await adapter.get_user_avatar_url(user_id)
async def nickname_getter(user_id: str) -> str | None:
@@ -564,7 +568,7 @@ class GroupDailyAnalysis(Star):
analysis_result,
group_id,
self.html_render,
avatar_getter=avatar_getter,
avatar_url_getter=avatar_url_getter,
nickname_getter=nickname_getter,
)
@@ -592,7 +596,7 @@ class GroupDailyAnalysis(Star):
pdf_path = await self.report_generator.generate_pdf_report(
analysis_result,
group_id,
avatar_getter=avatar_getter,
avatar_url_getter=avatar_url_getter,
nickname_getter=nickname_getter,
)
if pdf_path:
+1
View File
@@ -1 +1,2 @@
playwright>=1.40.0
diskcache
+93 -85
View File
@@ -11,17 +11,22 @@ from datetime import datetime
from pathlib import Path
import aiohttp
from diskcache import Cache
from ...domain.repositories.report_repository import IReportGenerator
from ...utils.logger import logger
from ..visualization.activity_charts import ActivityVisualizer
from .templates import HTMLTemplates
MAX_CONCURRENT_DOWNLOADS = 10
AVATAR_CACHE_EXPIRE_TIME = 259200
class ReportGenerator(IReportGenerator):
"""报告生成器"""
def __init__(self, config_manager):
def __init__(self, config_manager, data_dir):
self._avatar_session = None
self.config_manager = config_manager
self.activity_visualizer = ActivityVisualizer()
self.html_templates = HTMLTemplates(config_manager) # 实例化HTML模板管理器
@@ -31,7 +36,10 @@ class ReportGenerator(IReportGenerator):
self._render_semaphore = asyncio.Semaphore(max_concurrent)
# 运行时缓存,用于在一次分析任务中避免重复下载同一个头像
self._runtime_avatar_cache = {} # user_id -> base64_uri
self._avatar_cache = Cache(str(data_dir / "avatar")) # user_id -> base64_uri
self._avatar_session_concurrent_semaphore = asyncio.Semaphore(
MAX_CONCURRENT_DOWNLOADS
)
self._avatar_session = None
async def generate_image_report(
@@ -39,7 +47,7 @@ class ReportGenerator(IReportGenerator):
analysis_result: dict,
group_id: str,
html_render_func,
avatar_getter=None,
avatar_url_getter=None,
nickname_getter=None,
) -> tuple[str | None, str | None]:
"""
@@ -49,7 +57,8 @@ class ReportGenerator(IReportGenerator):
analysis_result: 分析结果字典
group_id: 群组ID
html_render_func: HTML渲染函数
avatar_getter: 异步回调函数,接收 user_id 返回 avatar_url/data
avatar_url_getter: 异步回调函数,接收 user_id 返回 avatar_url/data
nickname_getter: 昵称获取函数
Returns:
tuple[str | None, str | None]: (image_url, html_content)
@@ -60,7 +69,7 @@ class ReportGenerator(IReportGenerator):
render_payload = await self._prepare_render_data(
analysis_result,
chart_template="activity_chart.html",
avatar_getter=avatar_getter,
avatar_url_getter=avatar_url_getter,
nickname_getter=nickname_getter,
)
@@ -190,13 +199,12 @@ class ReportGenerator(IReportGenerator):
if self._avatar_session:
await self._avatar_session.close()
self._avatar_session = None
self._runtime_avatar_cache.clear()
async def generate_pdf_report(
self,
analysis_result: dict,
group_id: str,
avatar_getter=None,
avatar_url_getter=None,
nickname_getter=None,
) -> str | None:
"""生成PDF格式的分析报告"""
@@ -216,7 +224,7 @@ class ReportGenerator(IReportGenerator):
render_data = await self._prepare_render_data(
analysis_result,
chart_template="activity_chart_pdf.html",
avatar_getter=avatar_getter,
avatar_url_getter=avatar_url_getter,
nickname_getter=nickname_getter,
)
logger.info(f"PDF 渲染数据准备完成,包含 {len(render_data)} 个字段")
@@ -289,7 +297,7 @@ class ReportGenerator(IReportGenerator):
self,
analysis_result: dict,
chart_template: str = "activity_chart.html",
avatar_getter=None,
avatar_url_getter=None,
nickname_getter=None,
) -> dict:
"""准备渲染数据"""
@@ -306,7 +314,7 @@ class ReportGenerator(IReportGenerator):
for i, topic in enumerate(topics[:max_topics], 1):
# 处理话题详情中的用户引用头像
processed_detail = await self._render_mentions(
topic.detail, avatar_getter, nickname_getter, user_analysis
topic.detail, avatar_url_getter, nickname_getter, user_analysis
)
topics_list.append(
{
@@ -327,7 +335,9 @@ class ReportGenerator(IReportGenerator):
titles_list = []
for title in user_titles[:max_user_titles]:
# 获取用户头像
avatar_data = await self._get_user_avatar(str(title.user_id), avatar_getter)
avatar_data = await self._get_user_avatar(
str(title.user_id), avatar_url_getter
)
title_data = {
"name": title.name,
"title": title.title,
@@ -347,13 +357,13 @@ class ReportGenerator(IReportGenerator):
quotes_list = []
for quote in stats.golden_quotes[:max_golden_quotes]:
avatar_url = (
await self._get_user_avatar(str(quote.user_id), avatar_getter)
await self._get_user_avatar(str(quote.user_id), avatar_url_getter)
if quote.user_id
else None
)
# 处理解析锐评中的用户引用头像
processed_reason = await self._render_mentions(
quote.reason, avatar_getter, nickname_getter, user_analysis
quote.reason, avatar_url_getter, nickname_getter, user_analysis
)
quotes_list.append(
{
@@ -408,7 +418,7 @@ class ReportGenerator(IReportGenerator):
async def _render_mentions(
self,
text: str,
avatar_getter,
avatar_url_getter,
nickname_getter=None,
user_analysis: dict | None = None,
) -> str:
@@ -425,7 +435,7 @@ class ReportGenerator(IReportGenerator):
async def replacer(match):
uid = match.group(1)
url = await self._get_user_avatar(
uid, avatar_getter
uid, avatar_url_getter
) # 内部已有缓存,无需顶层并发获取
name = None
@@ -528,64 +538,77 @@ class ReportGenerator(IReportGenerator):
# Telegram file URL: .../file/bot<token>/<file_path>
return re.sub(r"/bot[^/]+/", "/bot<redacted>/", url)
async def _get_user_avatar(self, user_id: str, avatar_getter=None) -> str:
async def _get_user_avatar(self, avatar_id: str, avatar_url_getter=None) -> str:
"""
获取用户头像的 Base64 Data URI。
增加了运行时内存缓存,避免单次生成任务中重复下载
使用磁盘缓存,支持跨任务复用。获取失败时不缓存结果,以便后续请求重试
"""
# 0. 检查运行时缓存
if user_id in self._runtime_avatar_cache:
return self._runtime_avatar_cache[user_id]
# 1. 检查缓存 (仅包含成功的头像数据)
if avatar_id in self._avatar_cache:
return self._avatar_cache[avatar_id]
res = await self._get_user_avatar_internal(user_id, avatar_getter)
self._runtime_avatar_cache[user_id] = res
return res
# 2. 尝试获取头像字节流
avatar_bytes = await self._get_user_avatar_bytes(avatar_id, avatar_url_getter)
async def _get_user_avatar_internal(self, user_id: str, avatar_getter=None) -> str:
if not avatar_bytes:
# 获取失败时返回默认头像,但不存入缓存,以便下次重试
logger.warning(f"获取用户头像失败 {avatar_id},本次将使用回退头像")
return self._get_default_avatar_base64()
# 3. 获取成功:转换并缓存
avatar = self._b64_with_mime(avatar_bytes)
if avatar:
self._avatar_cache.set(avatar_id, avatar, expire=AVATAR_CACHE_EXPIRE_TIME)
return avatar
# 最终兜底
return self._get_default_avatar_base64()
def _b64_with_mime(self, _bytes: bytes) -> str | None:
"""将字节数据转换为 Base64 Data URI,并自动识别 MIME 类型。"""
try:
b64 = base64.b64encode(_bytes).decode("utf-8")
# 简单判断 mime type
mime = "image/jpeg"
if _bytes.startswith(b"\x89PNG"):
mime = "image/png"
elif _bytes.startswith(b"GIF8"):
mime = "image/gif"
elif _bytes.startswith(b"RIFF") and b"WEBP" in _bytes[8:16]:
mime = "image/webp"
elif _bytes.startswith(b"\xff\xd8"):
mime = "image/jpeg"
return f"data:{mime};base64,{b64}"
except Exception as e:
logger.error(f"base64 转换失败: {e}", exc_info=True)
return None
async def _get_user_avatar_bytes(
self, user_id: str, avatar_url_getter=None
) -> bytes | None:
"""核心头像获取逻辑"""
import base64
file_content = None
if not self._avatar_session:
self._avatar_session = aiohttp.ClientSession(
trust_env=True, timeout=aiohttp.ClientTimeout(total=15)
)
try:
# 1. 准备缓存目录
# 使用 plugin_data 目录以确保持久化和标准结构
temp_dir = Path(
"data/plugin_data/astrbot_plugin_qq_group_daily_analysis/cache/avatars"
)
if not temp_dir.exists():
await asyncio.to_thread(temp_dir.mkdir, parents=True, exist_ok=True)
# 使用小尺寸 (40px) 以优化性能
file_name = f"{user_id}_40.jpg"
file_path = temp_dir / file_name
file_content = None
# 2. 检查缓存
if file_path.exists() and file_path.stat().st_size > 0:
# 异步读取缓存
try:
file_content = await asyncio.to_thread(file_path.read_bytes)
except Exception:
pass
# 3. 如果无缓存,获取 URL 并下载
if not file_content:
async with self._avatar_session_concurrent_semaphore:
avatar_url = None
if avatar_getter:
if avatar_url_getter:
try:
# avatar_getter 应该返回 URL
result = await avatar_getter(user_id)
if result and result.startswith("http"):
# avatar_url_getter 应该返回 URL
result = await avatar_url_getter(user_id)
if result:
if result.startswith("http"):
avatar_url = result
else:
logger.warning(
f"custom avatar_url_getter 返回了非 HTTP URL: {result[:50]}..."
)
except Exception as e:
logger.warning(f"使用 custom avatar_getter 获取头像失败: {e}")
logger.warning(f"使用 custom avatar_url_getter 获取头像失败: {e}")
# 4. Fallback URL (仅针对看起来像 QQ 号的 ID)
if not avatar_url:
if user_id.isdigit() and 5 <= len(user_id) <= 12:
# 强制使用 spec=40
@@ -594,7 +617,7 @@ class ReportGenerator(IReportGenerator):
)
else:
# 其他平台若无 URL,无法获取头像
return self._get_default_avatar_base64()
return None
# 5. 下载并保存
safe_avatar_url = self._safe_url_for_log(avatar_url)
@@ -612,15 +635,11 @@ class ReportGenerator(IReportGenerator):
elif content.startswith(b"GIF8"): # GIF
is_valid_image = True
elif (
content.startswith(b"RIFF")
and b"WEBP" in content[:16]
content.startswith(b"RIFF") and b"WEBP" in content[:16]
): # WebP
is_valid_image = True
if is_valid_image:
await asyncio.to_thread(
file_path.write_bytes, content
)
file_content = content
else:
logger.warning(
@@ -633,35 +652,24 @@ class ReportGenerator(IReportGenerator):
except Exception as e:
logger.warning(f"下载头像网络错误 {safe_avatar_url}: {e}")
# 6. 转换为 Base64 Data URI
if file_content:
b64 = base64.b64encode(file_content).decode("utf-8")
# 简单判断 mime type
mime = "image/jpeg"
if file_content.startswith(b"\x89PNG"):
mime = "image/png"
elif file_content.startswith(b"GIF8"):
mime = "image/gif"
elif file_content.startswith(b"RIFF"):
mime = "image/webp"
return f"data:{mime};base64,{b64}"
return self._get_default_avatar_base64()
except Exception as e:
logger.error(f"获取用户头像失败 {user_id}: {e}")
return self._get_default_avatar_base64()
return file_content
def _get_default_avatar_base64(self) -> str:
"""返回默认头像 (灰色圆形占位符)"""
import base64
# 一个简单的灰色圆圈 SVG 转 Base64
svg = '<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="50" r="50" fill="#ddd"/></svg>'
b64 = base64.b64encode(svg.encode("utf-8")).decode("utf-8")
return f"data:image/svg+xml;base64,{b64}"
def close(self):
"""释放资源,关闭缓存和 session"""
try:
if self._avatar_cache:
self._avatar_cache.close()
logger.debug("头像缓存已关闭")
except Exception as e:
logger.warning(f"关闭头像缓存失败: {e}")
async def _html_to_pdf(self, html_content: str, output_path: str) -> bool:
"""将 HTML 内容转换为 PDF 文件"""
try: