Files
wecom_it_smart_desk/backend/app/services/cache_service.py
T

233 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# =============================================================================
# 企微IT智能服务台 — Redis 缓存服务
# =============================================================================
# 说明:封装 Redis 缓存操作,提供:
# 1. 消息去重(基于企微 MsgId)
# 2. 内容去重(基于用户 ID + 内容哈希,防快速重复发送)
# 3. 通用缓存读写(供其他服务复用)
#
# 去重窗口:
# - MsgId 去重:5 分钟(与企微重试窗口一致)
# - 内容去重:60 秒(防止用户快速重复发送相同消息)
# =============================================================================
import hashlib
import logging
from typing import Optional
import redis.asyncio as aioredis
logger = logging.getLogger(__name__)
# Redis key 前缀
MSG_DEDUP_PREFIX = "msg:dedup"
CONTENT_DEDUP_PREFIX = "msg:dedup:content"
# 默认 TTL(秒)
DEFAULT_MSG_DEDUP_TTL = 300 # 5 分钟,与企微重试窗口一致
DEFAULT_CONTENT_DEDUP_TTL = 60 # 60 秒,防止快速重复发送
class CacheService:
"""Redis 缓存服务,提供消息去重和通用缓存操作。
使用 Redis 的 SETNX + EXPIRE 语义实现幂等去重:
- 首次写入成功 → 返回 False(非重复)
- 再次写入失败 → 返回 True(重复)
降级策略:Redis 不可用时,去重检查自动放行(宁可重复处理,不可丢消息)。
Attributes:
redis: Redis 异步客户端(可为 None,降级时跳过去重)
"""
def __init__(self, redis_client: Optional[aioredis.Redis] = None):
"""初始化缓存服务。
Args:
redis_client: Redis 异步客户端实例。
为 None 时去重检查自动放行(降级模式)。
"""
self.redis = redis_client
# --------------------------------------------------------------------------
# 消息去重(基于企微 MsgId
# --------------------------------------------------------------------------
async def is_duplicate(
self,
msg_id: str,
ttl: int = DEFAULT_MSG_DEDUP_TTL,
) -> bool:
"""基于企微 MsgId 判断消息是否重复。
利用 Redis SETNX 语义实现幂等检查:
- key 不存在 → 设置 key(带 TTL)→ 返回 False(非重复)
- key 已存在 → 跳过写入 → 返回 True(重复)
降级策略:Redis 不可用时返回 False(放行),记录警告日志。
Args:
msg_id: 企微消息唯一 IDMsgId 字段)
ttl: 去重窗口(秒),默认 300 秒(5 分钟)
Returns:
bool: True=重复消息(应跳过处理),False=首次收到(正常处理)
"""
if not msg_id:
logger.warning("msg_id 为空,跳过去重检查")
return False
if self.redis is None:
logger.debug("Redis 不可用,跳过 MsgId 去重检查(降级放行)")
return False
key = f"{MSG_DEDUP_PREFIX}:{msg_id}"
try:
# SETNX + EXPIRE 原子操作:key 不存在时设置并返回 True,已存在返回 False
is_new = await self.redis.set(key, "1", nx=True, ex=ttl)
if is_new:
logger.debug(f"MsgId 去重: 新消息 msg_id={msg_id}")
return False
else:
logger.info(f"MsgId 去重: 重复消息已过滤 msg_id={msg_id}")
return True
except Exception as e:
logger.warning(f"MsgId 去重检查异常(降级放行): msg_id={msg_id}, error={e}")
return False
# --------------------------------------------------------------------------
# 内容去重(基于用户 ID + 内容哈希)
# --------------------------------------------------------------------------
async def is_duplicate_content(
self,
user_id: str,
content: str,
ttl: int = DEFAULT_CONTENT_DEDUP_TTL,
) -> bool:
"""基于用户 ID + 内容哈希判断是否为快速重复发送。
场景:用户在短时间内连续发送相同内容的消息(如网络卡顿导致重复点击)。
与 MsgId 去重不同,这里处理的是不同 MsgId 但内容完全相同的消息。
使用 SHA256 对 user_id + content 生成哈希作为 Redis key
窗口默认 60 秒(防止快速重复发送,但不影响正常重新提问)。
降级策略:Redis 不可用时返回 False(放行),记录警告日志。
Args:
user_id: 发送者企微 UserID
content: 消息内容
ttl: 去重窗口(秒),默认 60 秒
Returns:
bool: True=重复内容(应跳过处理),False=首次收到(正常处理)
"""
if not user_id or not content:
logger.debug("user_id 或 content 为空,跳过内容去重检查")
return False
if self.redis is None:
logger.debug("Redis 不可用,跳过内容去重检查(降级放行)")
return False
# 使用 SHA256 生成内容哈希,避免 Redis key 中存储原始内容
content_hash = hashlib.sha256(f"{user_id}:{content}".encode("utf-8")).hexdigest()[:16]
key = f"{CONTENT_DEDUP_PREFIX}:{user_id}:{content_hash}"
try:
is_new = await self.redis.set(key, "1", nx=True, ex=ttl)
if is_new:
logger.debug(f"内容去重: 新消息 user_id={user_id}, hash={content_hash}")
return False
else:
logger.info(f"内容去重: 重复内容已过滤 user_id={user_id}, hash={content_hash}")
return True
except Exception as e:
logger.warning(
f"内容去重检查异常(降级放行): user_id={user_id}, hash={content_hash}, error={e}"
)
return False
# --------------------------------------------------------------------------
# 通用缓存操作
# --------------------------------------------------------------------------
async def get(self, key: str) -> Optional[str]:
"""从 Redis 获取缓存值。
Args:
key: 缓存 key
Returns:
Optional[str]: 缓存值,不存在或 Redis 不可用时返回 None
"""
if self.redis is None:
return None
try:
value = await self.redis.get(key)
return value.decode("utf-8") if isinstance(value, bytes) else value
except Exception as e:
logger.warning(f"Redis GET 异常: key={key}, error={e}")
return None
async def set(
self,
key: str,
value: str,
ttl: Optional[int] = None,
) -> bool:
"""向 Redis 写入缓存值。
Args:
key: 缓存 key
value: 缓存值
ttl: 过期时间(秒),为 None 时永不过期
Returns:
bool: True=写入成功,False=写入失败或 Redis 不可用
"""
if self.redis is None:
return False
try:
if ttl is not None:
await self.redis.setex(key, ttl, value)
else:
await self.redis.set(key, value)
return True
except Exception as e:
logger.warning(f"Redis SET 异常: key={key}, error={e}")
return False
async def delete(self, key: str) -> bool:
"""从 Redis 删除缓存 key。
Args:
key: 缓存 key
Returns:
bool: True=删除成功,False=删除失败或 Redis 不可用
"""
if self.redis is None:
return False
try:
await self.redis.delete(key)
return True
except Exception as e:
logger.warning(f"Redis DELETE 异常: key={key}, error={e}")
return False
# 默认实例:Redis 客户端在应用启动时通过 init_cache_service() 注入
# 为什么:ws.py 等模块需要导入一个 cache_service 实例来读取 Redis
cache_service = CacheService()