feat(avatar): 引入头像缓存服务并优化头像获取 (#2055)
Some checks failed
检查bot是否运行正常 / bot check (push) Has been cancelled
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, javascript-typescript) (push) Has been cancelled
CodeQL Code Security Analysis / Analyze (${{ matrix.language }}) (none, python) (push) Has been cancelled
Sequential Lint and Type Check / ruff-call (push) Has been cancelled
Release Drafter / Update Release Draft (push) Has been cancelled
Force Sync to Aliyun / sync (push) Has been cancelled
Update Version / update-version (push) Has been cancelled
Sequential Lint and Type Check / pyright-call (push) Has been cancelled

Co-authored-by: webjoin111 <455457521@qq.com>
This commit is contained in:
Rumio 2025-09-28 08:53:10 +08:00 committed by GitHub
parent 7e6896fa01
commit 07be73c1b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 176 additions and 22 deletions

View File

@ -19,12 +19,12 @@ from zhenxun.configs.config import Config
from zhenxun.configs.utils import Command, PluginExtraData, RegisterConfig from zhenxun.configs.utils import Command, PluginExtraData, RegisterConfig
from zhenxun.models.chat_history import ChatHistory from zhenxun.models.chat_history import ChatHistory
from zhenxun.models.group_member_info import GroupInfoUser from zhenxun.models.group_member_info import GroupInfoUser
from zhenxun.services import avatar_service
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.ui.builders import TableBuilder from zhenxun.ui.builders import TableBuilder
from zhenxun.ui.models import ImageCell, TextCell from zhenxun.ui.models import ImageCell, TextCell
from zhenxun.utils.enum import PluginType from zhenxun.utils.enum import PluginType
from zhenxun.utils.message import MessageUtils from zhenxun.utils.message import MessageUtils
from zhenxun.utils.platform import PlatformUtils
__plugin_meta__ = PluginMetadata( __plugin_meta__ = PluginMetadata(
name="消息统计", name="消息统计",
@ -147,12 +147,14 @@ async def _(
user_in_group.user_name if user_in_group else f"{uid_str}(已退群)" 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( rows_data.append(
[ [
TextCell(content=str(len(rows_data) + 1)), 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=user_name),
TextCell(content=str(num), bold=True), TextCell(content=str(num), bold=True),
] ]

View File

@ -13,6 +13,7 @@ from zhenxun.models.statistics import Statistics
from zhenxun.services import ( from zhenxun.services import (
LLMException, LLMException,
LLMMessage, LLMMessage,
avatar_service,
generate, generate,
) )
from zhenxun.services.log import logger from zhenxun.services.log import logger
@ -105,7 +106,8 @@ async def create_help_img(
platform = PlatformUtils.get_platform(session) platform = PlatformUtils.get_platform(session)
bot_id = BotConfig.get_qbot_uid(session.self_id) or session.self_id 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( builder = PluginMenuBuilder(
bot_name=BotConfig.self_nickname, bot_name=BotConfig.self_nickname,

View File

@ -11,6 +11,7 @@ from zhenxun.models.level_user import LevelUser
from zhenxun.models.sign_user import SignUser from zhenxun.models.sign_user import SignUser
from zhenxun.models.statistics import Statistics from zhenxun.models.statistics import Statistics
from zhenxun.models.user_console import UserConsole from zhenxun.models.user_console import UserConsole
from zhenxun.services import avatar_service
from zhenxun.utils.platform import PlatformUtils from zhenxun.utils.platform import PlatformUtils
RACE = [ RACE = [
@ -139,9 +140,8 @@ async def get_user_info(
bytes: 图片数据 bytes: 图片数据
""" """
platform = PlatformUtils.get_platform(session) or "qq" platform = PlatformUtils.get_platform(session) or "qq"
avatar_url = ( avatar_path = await avatar_service.get_avatar_path(platform, user_id)
PlatformUtils.get_user_avatar_url(user_id, platform, session.self_id) or "" avatar_url = avatar_path.as_uri() if avatar_path else ""
)
user = await UserConsole.get_user(user_id, platform) user = await UserConsole.get_user(user_id, platform)
permission_level = await LevelUser.get_user_level(user_id, group_id) permission_level = await LevelUser.get_user_level(user_id, group_id)

View File

@ -11,6 +11,7 @@ from zhenxun.models.mahiro_bank import MahiroBank
from zhenxun.models.mahiro_bank_log import MahiroBankLog from zhenxun.models.mahiro_bank_log import MahiroBankLog
from zhenxun.models.sign_user import SignUser from zhenxun.models.sign_user import SignUser
from zhenxun.models.user_console import UserConsole from zhenxun.models.user_console import UserConsole
from zhenxun.services import avatar_service
from zhenxun.utils.enum import BankHandleType, GoldHandle from zhenxun.utils.enum import BankHandleType, GoldHandle
from zhenxun.utils.platform import PlatformUtils from zhenxun.utils.platform import PlatformUtils
@ -210,9 +211,8 @@ class BankManager:
for deposit in user_today_deposit for deposit in user_today_deposit
] ]
platform = PlatformUtils.get_platform(session) platform = PlatformUtils.get_platform(session)
avatar_url = PlatformUtils.get_user_avatar_url( avatar_path = await avatar_service.get_avatar_path(platform, user_id)
user_id, platform, session.self_id avatar_url = avatar_path.as_uri() if avatar_path else ""
)
return { return {
"name": uname, "name": uname,
"rank": rank + 1, "rank": rank + 1,

View File

@ -21,6 +21,7 @@ from zhenxun.models.group_member_info import GroupInfoUser
from zhenxun.models.user_console import UserConsole from zhenxun.models.user_console import UserConsole
from zhenxun.models.user_gold_log import UserGoldLog from zhenxun.models.user_gold_log import UserGoldLog
from zhenxun.models.user_props_log import UserPropsLog from zhenxun.models.user_props_log import UserPropsLog
from zhenxun.services import avatar_service
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.ui.models import ImageCell, TextCell from zhenxun.ui.models import ImageCell, TextCell
from zhenxun.utils.enum import GoldHandle, PropHandle 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 = [] data_list = []
platform = PlatformUtils.get_platform(session) platform = PlatformUtils.get_platform(session)
for i, user in enumerate(user_list): 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( data_list.append(
[ [
TextCell(content=f"{i + 1}"), TextCell(content=f"{i + 1}"),
ImageCell(src=ava_url or "", shape="circle") ImageCell(
if platform == "qq" src=avatar_path.as_uri() if avatar_path else "", shape="circle"
)
if avatar_path
else TextCell(content=""), else TextCell(content=""),
TextCell(content=uid2name.get(user[0]) or user[0]), TextCell(content=uid2name.get(user[0]) or user[0]),
TextCell(content=str(user[1]), bold=True), TextCell(content=str(user[1]), bold=True),

View File

@ -13,6 +13,7 @@ from zhenxun.models.group_member_info import GroupInfoUser
from zhenxun.models.sign_log import SignLog from zhenxun.models.sign_log import SignLog
from zhenxun.models.sign_user import SignUser from zhenxun.models.sign_user import SignUser
from zhenxun.models.user_console import UserConsole from zhenxun.models.user_console import UserConsole
from zhenxun.services.avatar_service import avatar_service
from zhenxun.services.log import logger from zhenxun.services.log import logger
from zhenxun.ui.models import ImageCell, TextCell from zhenxun.ui.models import ImageCell, TextCell
from zhenxun.utils.platform import PlatformUtils from zhenxun.utils.platform import PlatformUtils
@ -79,14 +80,16 @@ class SignManage:
data_list = [] data_list = []
platform = PlatformUtils.get_platform(session) platform = PlatformUtils.get_platform(session)
for i, user in enumerate(user_list): for i, user in enumerate(user_list):
ava_url = PlatformUtils.get_user_avatar_url( avatar_path = await avatar_service.get_avatar_path(
user[0], platform, session.self_id platform=user[3] or "qq", identifier=user[0]
) )
data_list.append( data_list.append(
[ [
TextCell(content=f"{i + 1}"), TextCell(content=f"{i + 1}"),
ImageCell(src=ava_url or "", shape="circle") ImageCell(
if user[3] == "qq" src=avatar_path.as_uri() if avatar_path else "", shape="circle"
)
if avatar_path
else TextCell(content=""), else TextCell(content=""),
TextCell(content=uid2name.get(user[0]) or user[0]), TextCell(content=uid2name.get(user[0]) or user[0]),
TextCell(content=str(user[1]), bold=True), TextCell(content=str(user[1]), bold=True),

View File

@ -11,6 +11,7 @@ from nonebot_plugin_uninfo import Uninfo
from zhenxun import ui from zhenxun import ui
from zhenxun.configs.config import BotConfig, Config from zhenxun.configs.config import BotConfig, Config
from zhenxun.models.sign_user import SignUser from zhenxun.models.sign_user import SignUser
from zhenxun.services import avatar_service
from zhenxun.utils.manager.priority_manager import PriorityLifecycle from zhenxun.utils.manager.priority_manager import PriorityLifecycle
from zhenxun.utils.platform import PlatformUtils from zhenxun.utils.platform import PlatformUtils
@ -212,13 +213,13 @@ async def _generate_html_card(
if len(nickname) > 6: if len(nickname) > 6:
font_size = 27 font_size = 27
avatar_path = await avatar_service.get_avatar_path(
PlatformUtils.get_platform(session), user.user_id
)
user_info = { user_info = {
"nickname": nickname, "nickname": nickname,
"uid_str": uid_formatted, "uid_str": uid_formatted,
"avatar_url": PlatformUtils.get_user_avatar_url( "avatar_url": avatar_path.as_uri() if avatar_path else "",
user.user_id, PlatformUtils.get_platform(session), session.self_id
)
or "",
"sign_count": user.sign_count, "sign_count": user.sign_count,
"font_size": font_size, "font_size": font_size,
} }

View File

@ -18,6 +18,7 @@ require("nonebot_plugin_htmlrender")
require("nonebot_plugin_uninfo") require("nonebot_plugin_uninfo")
require("nonebot_plugin_waiter") require("nonebot_plugin_waiter")
from .avatar_service import avatar_service
from .db_context import Model, disconnect, with_db_timeout from .db_context import Model, disconnect, with_db_timeout
from .llm import ( from .llm import (
AI, AI,
@ -57,6 +58,7 @@ __all__ = [
"Model", "Model",
"PluginInit", "PluginInit",
"PluginInitManager", "PluginInitManager",
"avatar_service",
"chat", "chat",
"clear_model_cache", "clear_model_cache",
"code", "code",

View File

@ -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()

View File

@ -247,7 +247,7 @@ class PlatformUtils:
if platform != "qq": if platform != "qq":
return None return None
if user_id.isdigit(): 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: else:
return f"https://q.qlogo.cn/qqapp/{appid}/{user_id}/640" return f"https://q.qlogo.cn/qqapp/{appid}/{user_id}/640"