233 lines
8.1 KiB
Python
233 lines
8.1 KiB
Python
# =============================================================================
|
||
# 企微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: 企微消息唯一 ID(MsgId 字段)
|
||
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()
|