From e573fd9c376677661b0ea3b8b63c5f818cc74c4d Mon Sep 17 00:00:00 2001 From: liuwei Date: Mon, 27 Apr 2026 09:13:01 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=A4=BE=E4=BA=A4=E5=9B=BE?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E5=B9=B6=E4=B8=BA=E9=80=9A=E8=AE=AF=E5=BD=95?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=E6=9C=AC=E5=9C=B0=E5=A4=B4=E5=83=8F=E7=BC=93?= =?UTF-8?q?=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/dashboard/blueprints/contacts.py | 49 +++- .../templates/contacts_management.html | 6 +- plugins/value_rank/main.py | 45 ++-- utils/wechat/contact_manager.py | 211 +++++++++++++++++- 4 files changed, 274 insertions(+), 37 deletions(-) diff --git a/admin/dashboard/blueprints/contacts.py b/admin/dashboard/blueprints/contacts.py index 5cf05cd..2f1e757 100644 --- a/admin/dashboard/blueprints/contacts.py +++ b/admin/dashboard/blueprints/contacts.py @@ -4,7 +4,8 @@ import re import threading import xml.etree.ElementTree as ET from concurrent.futures import ThreadPoolExecutor -from flask import Blueprint, render_template, jsonify, request, current_app +from urllib.parse import quote +from flask import Blueprint, render_template, jsonify, request, current_app, redirect, send_file from .auth import login_required from loguru import logger @@ -242,6 +243,27 @@ def _normalize_recent_message(server, raw_message: dict, chat_type: str, target_ } +def _build_dashboard_head_images(contact_manager): + """构造后台可直接使用的头像地址映射。 + + 说明: + 1. 前端统一访问本蓝图的头像代理接口,这样可以优先命中本地缓存; + 2. 头像 URL 哈希会拼到查询参数里,头像变更后浏览器会自然拉取最新版本; + 3. 即便本地缓存暂时不存在,代理接口也还能回退到远端头像地址,不影响页面展示。 + """ + result = {} + for wxid, remote_url in (contact_manager.get_all_head_images() or {}).items(): + if not remote_url: + result[wxid] = "" + continue + version = contact_manager.get_head_image_version(wxid) + avatar_url = f"/contacts/api/avatar/{quote(str(wxid), safe='')}" + if version: + avatar_url = f"{avatar_url}?v={version}" + result[wxid] = avatar_url + return result + + # 联系人管理页面 @contacts_bp.route('/') @login_required @@ -375,7 +397,9 @@ def api_head_images(): """获取联系人头像信息API""" try: server = current_app.dashboard_server - head_images = server.contact_manager.get_all_head_images() + # 后台页拿到的是“可展示地址”而不是原始远端 URL, + # 这样通讯录页会优先读本地缓存,头像变化时也能自动刷新最新版本。 + head_images = _build_dashboard_head_images(server.contact_manager) return jsonify({ "success": True, @@ -388,6 +412,27 @@ def api_head_images(): return jsonify({"success": False, "error": str(e)}), 500 +@contacts_bp.route('/api/avatar/', methods=['GET']) +@login_required +def api_contact_avatar(wxid): + """返回通讯录头像,本地缓存优先,远端地址兜底。""" + try: + server = current_app.dashboard_server + # 先尝试把头像补齐到本地缓存。 + # 这样页面首次访问某个联系人时,也能顺手把缓存热起来。 + cached_path = server.contact_manager.ensure_head_image_cached(wxid) + if cached_path and os.path.exists(cached_path): + return send_file(cached_path, conditional=True, max_age=86400) + + remote_url = str(server.contact_manager.get_head_image(wxid) or "").strip() + if remote_url: + return redirect(remote_url, code=302) + return jsonify({"success": False, "error": "头像不存在"}), 404 + except Exception as e: + logger.error(f"读取联系人头像失败 wxid={wxid}: {e}") + return jsonify({"success": False, "error": str(e)}), 500 + + @contacts_bp.route('/api/group_members/', methods=['GET']) @login_required def api_group_members(roomid): diff --git a/admin/dashboard/templates/contacts_management.html b/admin/dashboard/templates/contacts_management.html index 8adbd2c..c091756 100644 --- a/admin/dashboard/templates/contacts_management.html +++ b/admin/dashboard/templates/contacts_management.html @@ -669,7 +669,7 @@
- +
@@ -968,6 +968,10 @@ }, refreshContacts() { this.loadContactsData(); this.$message.success('联系人数据已刷新'); }, handleTabClick() { this.currentPage = 1; }, + // 通讯录头像统一走后台代理接口: + // 1. 优先命中服务端已缓存的本地头像; + // 2. 头像更新后会附带版本参数,浏览器不会一直吃旧图; + // 3. 代理接口兜底远端地址,因此这里保持简单读取即可。 getHeadImage(wxid) { return this.headImages[wxid] || ''; }, handleSizeChange(size) { this.pageSize = size; }, handleCurrentChange(page) { this.currentPage = page; }, diff --git a/plugins/value_rank/main.py b/plugins/value_rank/main.py index bf22cda..f0ab034 100644 --- a/plugins/value_rank/main.py +++ b/plugins/value_rank/main.py @@ -1394,8 +1394,8 @@ class ValueRankPlugin(MessagePluginInterface): cm = ContactManager.get_instance() edge_svg_parts: List[str] = [] # 双向@标签层: - # 1. 每条边中点标注“a->b / b->a”; - # 2. 使用白底半透明标签提升可读性,避免与连线重叠难辨识。 + # 1. 每条边只保留双向次数数字,避免标签信息量过大把图面压得太满; + # 2. 仍保留白底半透明标签,确保在密集连线里也能看清数值。 edge_label_parts: List[str] = [] for edge_idx, (a, b, mention_count, score, a_to_b_count, b_to_a_count) in enumerate(selected_edges, start=1): ax, ay = pos_map[a] @@ -1408,8 +1408,7 @@ class ValueRankPlugin(MessagePluginInterface): f'stroke="rgba(33, 150, 243, {opacity:.3f})" stroke-width="{stroke_width:.2f}" />' ) # 通过字典序固定方向,确保同一条边每次渲染文案方向一致。 - # 标签文案改为“昵称 + 双向次数 + 恶搞关系标签”,不再展示 wxid。 - # 为避免标签压在线条上,这里把标签放到“线段法线方向”的偏移位置。 + # 当前标签只保留双向互动数字,同时继续沿法线方向偏移,避免数字压在线条上。 mx, my = (ax + bx) / 2.0, (ay + by) / 2.0 dx, dy = (bx - ax), (by - ay) edge_len = max(math.hypot(dx, dy), 1.0) @@ -1426,25 +1425,9 @@ class ValueRankPlugin(MessagePluginInterface): angle_deg = math.degrees(math.atan2(dy, dx)) if angle_deg > 90 or angle_deg < -90: angle_deg += 180 - nick_a = cm.get_group_name(group_id, a) or a - nick_b = cm.get_group_name(group_id, b) or b - - total_count = int(a_to_b_count) + int(b_to_a_count) - diff_count = abs(int(a_to_b_count) - int(b_to_a_count)) - if total_count >= 12 and diff_count <= 2: - relation_tag = "双向奔赴" - elif diff_count >= 8 and max(int(a_to_b_count), int(b_to_a_count)) >= 10: - relation_tag = "下头互动" - elif total_count <= 2: - relation_tag = "点头之交" - elif int(a_to_b_count) == 0 or int(b_to_a_count) == 0: - relation_tag = "疯狂舔狗" - else: - relation_tag = "互相捧场" - - label_text = f"{nick_a}→{nick_b} {a_to_b_count}/{b_to_a_count} | {relation_tag}" + label_text = f"{int(a_to_b_count)}/{int(b_to_a_count)}" safe_label = html.escape(label_text) - label_width = max(140.0, min(360.0, 8.0 * len(label_text) + 34.0)) + label_width = max(52.0, min(92.0, 10.0 * len(label_text) + 24.0)) edge_label_parts.append( f'' f'' ) - # 节点文案放到节点外侧: - # 1. 外圈节点文本沿“从中心指向外部”的方向偏移,减少互相压住; - # 2. 内圈节点仍保持较近距离,保证中心区域不会炸开; - # 3. 文本锚点根据左右半区自动切换,让阅读方向更自然。 + # 节点文案继续放在头像外侧,但整体收紧一圈: + # 1. 用户反馈昵称和头像分得太开,这里缩短外偏移距离; + # 2. 昵称字号调小,减少节点一多时的横向挤压; + # 3. 统计行与昵称保持较近距离,让“头像-昵称-数据”看起来是一组。 outward_dx = math.cos(angle) outward_dy = math.sin(angle) - label_gap = node_radius + (18.0 if ring_idx == 0 else 28.0 + ring_idx * 4.0) + label_gap = node_radius + (11.0 if ring_idx == 0 else 17.0 + ring_idx * 2.0) title_x = x + outward_dx * label_gap title_y = y + outward_dy * label_gap - info_x = x + outward_dx * (label_gap + 22.0) - info_y = y + outward_dy * (label_gap + 22.0) + info_x = x + outward_dx * (label_gap + 12.0) + info_y = y + outward_dy * (label_gap + 12.0) + 1.5 if abs(outward_dx) < 0.20: anchor = "middle" elif outward_dx > 0: @@ -1522,11 +1505,11 @@ class ValueRankPlugin(MessagePluginInterface): anchor = "end" node_svg_parts.append( f'{safe_nick}' + f'font-size="16" fill="#2F3B52" font-weight="700">{safe_nick}' ) node_svg_parts.append( f'连接{partner_count}人 · 互动{score:.1f}' + f'font-size="12" fill="#6C7A96">连接{partner_count} · {score:.1f}' ) group_title = html.escape(ContactManager.get_instance().get_nickname(group_id) or group_id) diff --git a/utils/wechat/contact_manager.py b/utils/wechat/contact_manager.py index 464ae65..be46186 100644 --- a/utils/wechat/contact_manager.py +++ b/utils/wechat/contact_manager.py @@ -3,10 +3,16 @@ 联系人管理器 - 提供全局访问联系人信息的单例类 """ -from loguru import logger -from typing import Dict, Optional, List, Tuple +import hashlib +import json +import mimetypes +import threading +from pathlib import Path +from typing import Dict, List, Optional, Tuple +from urllib.parse import urlparse -from utils.json_converter import json_to_object +import requests +from loguru import logger class ContactManager: @@ -23,6 +29,10 @@ class ContactManager: _friends: List[str] = [] _group_members: List[Dict] = [] _group_contacts_friends: Dict[str, Dict[str, str]] = {} + _avatar_cache_lock = threading.RLock() + _avatar_cache_dir: Optional[Path] = None + _avatar_manifest_path: Optional[Path] = None + _avatar_manifest: Dict[str, Dict[str, str]] = {} # 定义公共好友列表 _PUBLIC_FRIENDS = { 'fmessage': '朋友推荐消息', @@ -43,6 +53,11 @@ class ContactManager: # 确保初始化代码只执行一次 if not ContactManager._initialized: self._logger.info("初始化联系人管理器") + # 头像缓存采用“本地文件 + manifest 索引”的方式: + # 1. 本地文件用于后台页面与服务端渲染复用,避免每次都走远端头像链接; + # 2. manifest 记录 wxid、头像源地址与本地文件名,重启后也能继续命中缓存; + # 3. 后续只在头像 URL 变化时重新下载,避免每次刷新通讯录都全量拉取头像。 + self._init_avatar_cache() ContactManager._initialized = True @classmethod @@ -74,10 +89,197 @@ class ContactManager: self._friends = friends self._head_images = head_imgs self._group_members = chatroom_members + # 通讯录刷新后顺手做一次头像缓存增量同步: + # 1. 只处理新增头像或 URL 已变化的联系人; + # 2. 不改动业务侧原有头像 URL 存储方式,避免影响其他调用链; + # 3. 让后台展示和后续图片渲染都能尽量命中本地缓存。 + self._sync_avatar_cache() self._logger.info(f"联系人信息已更新,共 {len(contacts)} 个联系人") # 分类联系人 self._classify_contacts() + def _init_avatar_cache(self) -> None: + """初始化头像缓存目录和 manifest。""" + cache_dir = Path(__file__).resolve().parents[2] / "temp" / "contact_avatars" + cache_dir.mkdir(parents=True, exist_ok=True) + self._avatar_cache_dir = cache_dir + self._avatar_manifest_path = cache_dir / "avatar_manifest.json" + self._load_avatar_manifest() + + def _load_avatar_manifest(self) -> None: + """加载头像缓存索引,只保留本地文件仍存在的记录。""" + if not self._avatar_manifest_path or not self._avatar_manifest_path.exists(): + self._avatar_manifest = {} + return + try: + data = json.loads(self._avatar_manifest_path.read_text(encoding="utf-8")) + except Exception as exc: + self._logger.warning(f"加载头像缓存索引失败,将重新建立缓存: {exc}") + self._avatar_manifest = {} + return + + normalized: Dict[str, Dict[str, str]] = {} + for wxid, meta in (data or {}).items(): + if not isinstance(meta, dict): + continue + file_name = str(meta.get("file_name") or "").strip() + remote_url = str(meta.get("remote_url") or "").strip() + if not file_name or not remote_url: + continue + local_path = self._avatar_cache_dir / file_name if self._avatar_cache_dir else None + if not local_path or not local_path.exists(): + continue + normalized[wxid] = { + "file_name": file_name, + "remote_url": remote_url, + } + self._avatar_manifest = normalized + + def _save_avatar_manifest(self) -> None: + """持久化头像缓存索引,保证重启后还能复用已经下载好的头像。""" + if not self._avatar_manifest_path: + return + try: + self._avatar_manifest_path.write_text( + json.dumps(self._avatar_manifest, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + except Exception as exc: + self._logger.warning(f"保存头像缓存索引失败: {exc}") + + def _guess_avatar_extension(self, avatar_url: str, content_type: str) -> str: + """推断头像文件扩展名,尽量保留真实图片类型。""" + guessed_from_type = mimetypes.guess_extension((content_type or "").split(";")[0].strip()) or "" + if guessed_from_type in {".jpe", ".jpeg", ".jpg", ".png", ".gif", ".webp", ".bmp"}: + return ".jpg" if guessed_from_type == ".jpe" else guessed_from_type + parsed_path = Path(urlparse(str(avatar_url or "")).path) + suffix = parsed_path.suffix.lower().strip() + if suffix in {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}: + return suffix + return ".jpg" + + def _build_avatar_file_name(self, wxid: str, avatar_url: str, extension: str) -> str: + """构造稳定文件名,让同一联系人头像 URL 变化后可以自然生成新文件。""" + wxid_hash = hashlib.sha1(str(wxid).encode("utf-8")).hexdigest()[:16] + url_hash = hashlib.sha1(str(avatar_url).encode("utf-8")).hexdigest()[:16] + return f"{wxid_hash}_{url_hash}{extension}" + + def _download_avatar_to_cache(self, wxid: str, avatar_url: str) -> Optional[str]: + """下载头像到本地缓存目录并返回本地文件路径。""" + avatar_url = str(avatar_url or "").strip() + if not avatar_url or not self._avatar_cache_dir: + return None + + try: + response = requests.get(avatar_url, stream=True, timeout=12) + response.raise_for_status() + except Exception as exc: + self._logger.debug(f"下载头像失败 wxid={wxid}: {exc}") + return None + + extension = self._guess_avatar_extension(avatar_url, response.headers.get("Content-Type", "")) + file_name = self._build_avatar_file_name(wxid, avatar_url, extension) + target_path = self._avatar_cache_dir / file_name + temp_path = target_path.with_suffix(f"{target_path.suffix}.tmp") + try: + with temp_path.open("wb") as file_obj: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + file_obj.write(chunk) + temp_path.replace(target_path) + except Exception as exc: + self._logger.warning(f"写入头像缓存失败 wxid={wxid}: {exc}") + try: + if temp_path.exists(): + temp_path.unlink() + except Exception: + pass + return None + finally: + response.close() + return str(target_path) + + def _sync_avatar_cache(self) -> None: + """按当前头像 URL 增量同步本地缓存。""" + if not self._head_images: + return + + manifest_changed = False + with self._avatar_cache_lock: + for wxid, avatar_url in self._head_images.items(): + remote_url = str(avatar_url or "").strip() + if not remote_url: + continue + manifest_item = self._avatar_manifest.get(wxid, {}) + cached_path = self.get_cached_head_image_path(wxid) + # 只有“URL 变了”或“本地文件丢了”才重新下载,避免刷新通讯录时重复打远端。 + if manifest_item.get("remote_url") == remote_url and cached_path: + continue + downloaded_path = self._download_avatar_to_cache(wxid, remote_url) + if not downloaded_path: + continue + self._avatar_manifest[wxid] = { + "file_name": Path(downloaded_path).name, + "remote_url": remote_url, + } + manifest_changed = True + + # 把已经不在通讯录中的头像记录清理掉,避免 manifest 无限增长。 + removed_wxids = [wxid for wxid in self._avatar_manifest.keys() if wxid not in self._head_images] + for wxid in removed_wxids: + self._avatar_manifest.pop(wxid, None) + manifest_changed = True + + if manifest_changed: + self._save_avatar_manifest() + + def get_cached_head_image_path(self, wxid: str) -> str: + """返回头像缓存本地路径,若缓存不存在则返回空字符串。""" + with self._avatar_cache_lock: + meta = self._avatar_manifest.get(wxid) or {} + file_name = str(meta.get("file_name") or "").strip() + if not file_name or not self._avatar_cache_dir: + return "" + local_path = self._avatar_cache_dir / file_name + return str(local_path) if local_path.exists() else "" + + def ensure_head_image_cached(self, wxid: str) -> str: + """确保指定联系人的头像已缓存到本地,返回本地路径。""" + remote_url = str(self._head_images.get(wxid) or "").strip() + manifest_item = self._avatar_manifest.get(wxid) or {} + cached_path = self.get_cached_head_image_path(wxid) + # 如果本地已有缓存,且当前头像 URL 没变,就直接复用; + # 如果 URL 已变化,则继续往下重拉新头像,避免页面一直展示旧图。 + if cached_path and (not remote_url or manifest_item.get("remote_url") == remote_url): + return cached_path + if not remote_url: + return "" + + with self._avatar_cache_lock: + # 双重检查避免高并发下重复下载同一张头像。 + manifest_item = self._avatar_manifest.get(wxid) or {} + cached_path = self.get_cached_head_image_path(wxid) + if cached_path and manifest_item.get("remote_url") == remote_url: + return cached_path + downloaded_path = self._download_avatar_to_cache(wxid, remote_url) + if not downloaded_path: + return "" + self._avatar_manifest[wxid] = { + "file_name": Path(downloaded_path).name, + "remote_url": remote_url, + } + self._save_avatar_manifest() + return downloaded_path + + def get_head_image_version(self, wxid: str) -> str: + """返回头像版本号,用于前端拼接缓存 bust 参数。""" + with self._avatar_cache_lock: + remote_url = str(self._head_images.get(wxid) or "").strip() + meta = self._avatar_manifest.get(wxid) or {} + file_name = str(meta.get("file_name") or "").strip() + version_source = f"{remote_url}|{file_name}" + return hashlib.sha1(version_source.encode("utf-8")).hexdigest()[:12] if version_source.strip("|") else "" + def _classify_contacts(self) -> None: """将联系人分类为群组、个人联系人、公共好友和公众号""" self._group_contacts = {} @@ -193,6 +395,9 @@ class ContactManager: 对应的头像,如果不存在这返回"" """ self._head_images.update({wxid: head_image}) + # 群成员头像变化通常意味着微信端已经给了新的资源地址, + # 这里即时重拉一次本地缓存,保证通讯录和后续渲染尽快拿到最新头像。 + self.ensure_head_image_cached(wxid) return True def get_group_name(self, roomid: str, wxid: str) -> str: