From 07be73c1b710039555d1ca8f69589c3e004ba787 Mon Sep 17 00:00:00 2001 From: Rumio <32546670+webjoin111@users.noreply.github.com> Date: Sun, 28 Sep 2025 08:53:10 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(avatar):=20=E5=BC=95=E5=85=A5?= =?UTF-8?q?=E5=A4=B4=E5=83=8F=E7=BC=93=E5=AD=98=E6=9C=8D=E5=8A=A1=E5=B9=B6?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=A4=B4=E5=83=8F=E8=8E=B7=E5=8F=96=20(#2055?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: webjoin111 <455457521@qq.com> --- .../chat_history/chat_message_handle.py | 8 +- zhenxun/builtin_plugins/help/_data_source.py | 4 +- zhenxun/builtin_plugins/info/my_info.py | 6 +- .../mahiro_bank/data_source.py | 6 +- zhenxun/builtin_plugins/shop/_data_source.py | 9 +- .../builtin_plugins/sign_in/_data_source.py | 11 +- zhenxun/builtin_plugins/sign_in/utils.py | 9 +- zhenxun/services/__init__.py | 2 + zhenxun/services/avatar_service.py | 141 ++++++++++++++++++ zhenxun/utils/platform.py | 2 +- 10 files changed, 176 insertions(+), 22 deletions(-) create mode 100644 zhenxun/services/avatar_service.py diff --git a/zhenxun/builtin_plugins/chat_history/chat_message_handle.py b/zhenxun/builtin_plugins/chat_history/chat_message_handle.py index 5e5d7df7..39e08375 100644 --- a/zhenxun/builtin_plugins/chat_history/chat_message_handle.py +++ b/zhenxun/builtin_plugins/chat_history/chat_message_handle.py @@ -19,12 +19,12 @@ from zhenxun.configs.config import Config from zhenxun.configs.utils import Command, PluginExtraData, RegisterConfig from zhenxun.models.chat_history import ChatHistory from zhenxun.models.group_member_info import GroupInfoUser +from zhenxun.services import avatar_service from zhenxun.services.log import logger from zhenxun.ui.builders import TableBuilder from zhenxun.ui.models import ImageCell, TextCell from zhenxun.utils.enum import PluginType from zhenxun.utils.message import MessageUtils -from zhenxun.utils.platform import PlatformUtils __plugin_meta__ = PluginMetadata( name="消息统计", @@ -147,12 +147,14 @@ async def _( user_in_group.user_name if user_in_group else f"{uid_str}(已退群)" ) - avatar_url = PlatformUtils.get_user_avatar_url(uid_str, platform) + avatar_path = await avatar_service.get_avatar_path(platform, uid_str) rows_data.append( [ TextCell(content=str(len(rows_data) + 1)), - ImageCell(src=avatar_url or "", shape="circle"), + ImageCell( + src=avatar_path.as_uri() if avatar_path else "", shape="circle" + ), TextCell(content=user_name), TextCell(content=str(num), bold=True), ] diff --git a/zhenxun/builtin_plugins/help/_data_source.py b/zhenxun/builtin_plugins/help/_data_source.py index f86aca8b..585c59d5 100644 --- a/zhenxun/builtin_plugins/help/_data_source.py +++ b/zhenxun/builtin_plugins/help/_data_source.py @@ -13,6 +13,7 @@ from zhenxun.models.statistics import Statistics from zhenxun.services import ( LLMException, LLMMessage, + avatar_service, generate, ) from zhenxun.services.log import logger @@ -105,7 +106,8 @@ async def create_help_img( platform = PlatformUtils.get_platform(session) bot_id = BotConfig.get_qbot_uid(session.self_id) or session.self_id - bot_avatar_url = PlatformUtils.get_user_avatar_url(bot_id, platform) or "" + bot_avatar_path = await avatar_service.get_avatar_path(platform, bot_id) + bot_avatar_url = bot_avatar_path.as_uri() if bot_avatar_path else "" builder = PluginMenuBuilder( bot_name=BotConfig.self_nickname, diff --git a/zhenxun/builtin_plugins/info/my_info.py b/zhenxun/builtin_plugins/info/my_info.py index d3e4a819..8e827231 100644 --- a/zhenxun/builtin_plugins/info/my_info.py +++ b/zhenxun/builtin_plugins/info/my_info.py @@ -11,6 +11,7 @@ from zhenxun.models.level_user import LevelUser from zhenxun.models.sign_user import SignUser from zhenxun.models.statistics import Statistics from zhenxun.models.user_console import UserConsole +from zhenxun.services import avatar_service from zhenxun.utils.platform import PlatformUtils RACE = [ @@ -139,9 +140,8 @@ async def get_user_info( bytes: 图片数据 """ platform = PlatformUtils.get_platform(session) or "qq" - avatar_url = ( - PlatformUtils.get_user_avatar_url(user_id, platform, session.self_id) or "" - ) + avatar_path = await avatar_service.get_avatar_path(platform, user_id) + avatar_url = avatar_path.as_uri() if avatar_path else "" user = await UserConsole.get_user(user_id, platform) permission_level = await LevelUser.get_user_level(user_id, group_id) diff --git a/zhenxun/builtin_plugins/mahiro_bank/data_source.py b/zhenxun/builtin_plugins/mahiro_bank/data_source.py index 8e210447..b0b3990c 100644 --- a/zhenxun/builtin_plugins/mahiro_bank/data_source.py +++ b/zhenxun/builtin_plugins/mahiro_bank/data_source.py @@ -11,6 +11,7 @@ from zhenxun.models.mahiro_bank import MahiroBank from zhenxun.models.mahiro_bank_log import MahiroBankLog from zhenxun.models.sign_user import SignUser from zhenxun.models.user_console import UserConsole +from zhenxun.services import avatar_service from zhenxun.utils.enum import BankHandleType, GoldHandle from zhenxun.utils.platform import PlatformUtils @@ -210,9 +211,8 @@ class BankManager: for deposit in user_today_deposit ] platform = PlatformUtils.get_platform(session) - avatar_url = PlatformUtils.get_user_avatar_url( - user_id, platform, session.self_id - ) + avatar_path = await avatar_service.get_avatar_path(platform, user_id) + avatar_url = avatar_path.as_uri() if avatar_path else "" return { "name": uname, "rank": rank + 1, diff --git a/zhenxun/builtin_plugins/shop/_data_source.py b/zhenxun/builtin_plugins/shop/_data_source.py index 15f5935a..e0ee984a 100644 --- a/zhenxun/builtin_plugins/shop/_data_source.py +++ b/zhenxun/builtin_plugins/shop/_data_source.py @@ -21,6 +21,7 @@ from zhenxun.models.group_member_info import GroupInfoUser from zhenxun.models.user_console import UserConsole from zhenxun.models.user_gold_log import UserGoldLog from zhenxun.models.user_props_log import UserPropsLog +from zhenxun.services import avatar_service from zhenxun.services.log import logger from zhenxun.ui.models import ImageCell, TextCell from zhenxun.utils.enum import GoldHandle, PropHandle @@ -123,12 +124,14 @@ async def gold_rank(session: Uninfo, group_id: str | None, num: int) -> bytes | data_list = [] platform = PlatformUtils.get_platform(session) for i, user in enumerate(user_list): - ava_url = PlatformUtils.get_user_avatar_url(user[0], platform, session.self_id) + avatar_path = await avatar_service.get_avatar_path(platform, user[0]) data_list.append( [ TextCell(content=f"{i + 1}"), - ImageCell(src=ava_url or "", shape="circle") - if platform == "qq" + ImageCell( + src=avatar_path.as_uri() if avatar_path else "", shape="circle" + ) + if avatar_path else TextCell(content=""), TextCell(content=uid2name.get(user[0]) or user[0]), TextCell(content=str(user[1]), bold=True), diff --git a/zhenxun/builtin_plugins/sign_in/_data_source.py b/zhenxun/builtin_plugins/sign_in/_data_source.py index ec64083b..7310e13f 100644 --- a/zhenxun/builtin_plugins/sign_in/_data_source.py +++ b/zhenxun/builtin_plugins/sign_in/_data_source.py @@ -13,6 +13,7 @@ from zhenxun.models.group_member_info import GroupInfoUser from zhenxun.models.sign_log import SignLog from zhenxun.models.sign_user import SignUser from zhenxun.models.user_console import UserConsole +from zhenxun.services.avatar_service import avatar_service from zhenxun.services.log import logger from zhenxun.ui.models import ImageCell, TextCell from zhenxun.utils.platform import PlatformUtils @@ -79,14 +80,16 @@ class SignManage: data_list = [] platform = PlatformUtils.get_platform(session) for i, user in enumerate(user_list): - ava_url = PlatformUtils.get_user_avatar_url( - user[0], platform, session.self_id + avatar_path = await avatar_service.get_avatar_path( + platform=user[3] or "qq", identifier=user[0] ) data_list.append( [ TextCell(content=f"{i + 1}"), - ImageCell(src=ava_url or "", shape="circle") - if user[3] == "qq" + ImageCell( + src=avatar_path.as_uri() if avatar_path else "", shape="circle" + ) + if avatar_path else TextCell(content=""), TextCell(content=uid2name.get(user[0]) or user[0]), TextCell(content=str(user[1]), bold=True), diff --git a/zhenxun/builtin_plugins/sign_in/utils.py b/zhenxun/builtin_plugins/sign_in/utils.py index 2a78aa68..047e8ab3 100644 --- a/zhenxun/builtin_plugins/sign_in/utils.py +++ b/zhenxun/builtin_plugins/sign_in/utils.py @@ -11,6 +11,7 @@ from nonebot_plugin_uninfo import Uninfo from zhenxun import ui from zhenxun.configs.config import BotConfig, Config from zhenxun.models.sign_user import SignUser +from zhenxun.services import avatar_service from zhenxun.utils.manager.priority_manager import PriorityLifecycle from zhenxun.utils.platform import PlatformUtils @@ -212,13 +213,13 @@ async def _generate_html_card( if len(nickname) > 6: font_size = 27 + avatar_path = await avatar_service.get_avatar_path( + PlatformUtils.get_platform(session), user.user_id + ) user_info = { "nickname": nickname, "uid_str": uid_formatted, - "avatar_url": PlatformUtils.get_user_avatar_url( - user.user_id, PlatformUtils.get_platform(session), session.self_id - ) - or "", + "avatar_url": avatar_path.as_uri() if avatar_path else "", "sign_count": user.sign_count, "font_size": font_size, } diff --git a/zhenxun/services/__init__.py b/zhenxun/services/__init__.py index 6b2b5bb5..5bc353a6 100644 --- a/zhenxun/services/__init__.py +++ b/zhenxun/services/__init__.py @@ -18,6 +18,7 @@ require("nonebot_plugin_htmlrender") require("nonebot_plugin_uninfo") require("nonebot_plugin_waiter") +from .avatar_service import avatar_service from .db_context import Model, disconnect, with_db_timeout from .llm import ( AI, @@ -57,6 +58,7 @@ __all__ = [ "Model", "PluginInit", "PluginInitManager", + "avatar_service", "chat", "clear_model_cache", "code", diff --git a/zhenxun/services/avatar_service.py b/zhenxun/services/avatar_service.py new file mode 100644 index 00000000..2a051a39 --- /dev/null +++ b/zhenxun/services/avatar_service.py @@ -0,0 +1,141 @@ +""" +头像缓存服务 + +提供一个统一的、带缓存的头像获取服务,支持多平台和可配置的过期策略。 +""" + +import os +from pathlib import Path +import time + +from nonebot_plugin_apscheduler import scheduler + +from zhenxun.configs.config import Config +from zhenxun.configs.path_config import DATA_PATH +from zhenxun.services.log import logger +from zhenxun.utils.http_utils import AsyncHttpx +from zhenxun.utils.platform import PlatformUtils + +Config.add_plugin_config( + "avatar_cache", + "ENABLED", + True, + help="是否启用头像缓存功能", + default_value=True, + type=bool, +) +Config.add_plugin_config( + "avatar_cache", + "TTL_DAYS", + 7, + help="头像缓存的有效期(天)", + default_value=7, + type=int, +) +Config.add_plugin_config( + "avatar_cache", + "CLEANUP_INTERVAL_HOURS", + 24, + help="后台清理过期缓存的间隔时间(小时)", + default_value=24, + type=int, +) + + +class AvatarService: + """ + 一个集中式的头像缓存服务,提供L1(内存)和L2(文件)两级缓存。 + """ + + def __init__(self): + self.cache_path = (DATA_PATH / "cache" / "avatars").resolve() + self.cache_path.mkdir(parents=True, exist_ok=True) + self._memory_cache: dict[str, Path] = {} + + def _get_cache_path(self, platform: str, identifier: str) -> Path: + """ + 根据平台和ID生成存储的文件路径。 + 例如: data/cache/avatars/qq/123456789.png + """ + identifier = str(identifier) + return self.cache_path / platform / f"{identifier}.png" + + async def get_avatar_path( + self, platform: str, identifier: str, force_refresh: bool = False + ) -> Path | None: + """ + 获取用户或群组的头像本地路径。 + + 参数: + platform: 平台名称 (e.g., 'qq') + identifier: 用户ID或群组ID + force_refresh: 是否强制刷新缓存 + + 返回: + Path | None: 头像的本地文件路径,如果获取失败则返回None。 + """ + if not Config.get_config("avatar_cache", "ENABLED"): + return None + + cache_key = f"{platform}-{identifier}" + if not force_refresh and cache_key in self._memory_cache: + if self._memory_cache[cache_key].exists(): + return self._memory_cache[cache_key] + + local_path = self._get_cache_path(platform, identifier) + ttl_seconds = Config.get_config("avatar_cache", "TTL_DAYS", 7) * 86400 + + if not force_refresh and local_path.exists(): + try: + file_mtime = os.path.getmtime(local_path) + if time.time() - file_mtime < ttl_seconds: + self._memory_cache[cache_key] = local_path + return local_path + except FileNotFoundError: + pass + + avatar_url = PlatformUtils.get_user_avatar_url(identifier, platform) + if not avatar_url: + return None + + local_path.parent.mkdir(parents=True, exist_ok=True) + + if await AsyncHttpx.download_file(avatar_url, local_path): + self._memory_cache[cache_key] = local_path + return local_path + else: + logger.warning(f"下载头像失败: {avatar_url}", "AvatarService") + return None + + async def _cleanup_cache(self): + """后台定时清理过期的缓存文件""" + if not Config.get_config("avatar_cache", "ENABLED"): + return + + logger.info("开始执行头像缓存清理任务...", "AvatarService") + ttl_seconds = Config.get_config("avatar_cache", "TTL_DAYS", 7) * 86400 + now = time.time() + deleted_count = 0 + for root, _, files in os.walk(self.cache_path): + for name in files: + file_path = Path(root) / name + try: + if now - os.path.getmtime(file_path) > ttl_seconds: + file_path.unlink() + deleted_count += 1 + except FileNotFoundError: + continue + + logger.info( + f"头像缓存清理完成,共删除 {deleted_count} 个过期文件。", "AvatarService" + ) + + +avatar_service = AvatarService() + + +@scheduler.scheduled_job( + "interval", hours=Config.get_config("avatar_cache", "CLEANUP_INTERVAL_HOURS", 24) +) +async def _run_avatar_cache_cleanup(): + await avatar_service._cleanup_cache() diff --git a/zhenxun/utils/platform.py b/zhenxun/utils/platform.py index 13bf4144..161c098c 100644 --- a/zhenxun/utils/platform.py +++ b/zhenxun/utils/platform.py @@ -247,7 +247,7 @@ class PlatformUtils: if platform != "qq": return None if user_id.isdigit(): - return f"http://q1.qlogo.cn/g?b=qq&nk={user_id}&s=160" + return f"http://q1.qlogo.cn/g?b=qq&nk={user_id}&s=640" else: return f"https://q.qlogo.cn/qqapp/{appid}/{user_id}/640"