优化社交图展示并为通讯录接入本地头像缓存
This commit is contained in:
@@ -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/<path:wxid>', 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/<roomid>', methods=['GET'])
|
||||
@login_required
|
||||
def api_group_members(roomid):
|
||||
|
||||
@@ -669,7 +669,7 @@
|
||||
|
||||
<el-dialog title="公共好友详情" :visible.sync="publicDetailDialogVisible" width="50%">
|
||||
<div class="detail-avatar-wrap">
|
||||
<el-avatar size="large" :src="currentPublic.small_head_img_url" @error="() => true" class="detail-avatar">
|
||||
<el-avatar size="large" :src="getHeadImage(currentPublic.wxid)" @error="() => true" class="detail-avatar">
|
||||
<img src="/static/logo.png"/>
|
||||
</el-avatar>
|
||||
</div>
|
||||
@@ -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; },
|
||||
|
||||
@@ -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'<g transform="translate({label_x:.1f},{label_y:.1f}) rotate({angle_deg:.1f})">'
|
||||
f'<rect x="-{label_width / 2:.1f}" y="-11" width="{label_width:.1f}" height="22" '
|
||||
@@ -1503,17 +1486,17 @@ class ValueRankPlugin(MessagePluginInterface):
|
||||
f'{html.escape(str(nick)[:1] or "?")}</text>'
|
||||
)
|
||||
|
||||
# 节点文案放到节点外侧:
|
||||
# 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'<text x="{title_x:.1f}" y="{title_y:.1f}" text-anchor="{anchor}" '
|
||||
f'font-size="19" fill="#2F3B52" font-weight="700">{safe_nick}</text>'
|
||||
f'font-size="16" fill="#2F3B52" font-weight="700">{safe_nick}</text>'
|
||||
)
|
||||
node_svg_parts.append(
|
||||
f'<text x="{info_x:.1f}" y="{info_y:.1f}" text-anchor="{anchor}" '
|
||||
f'font-size="14" fill="#6C7A96">连接{partner_count}人 · 互动{score:.1f}</text>'
|
||||
f'font-size="12" fill="#6C7A96">连接{partner_count} · {score:.1f}</text>'
|
||||
)
|
||||
|
||||
group_title = html.escape(ContactManager.get_instance().get_nickname(group_id) or group_id)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user