# ============================================================================= # 企微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()