chore: initial baseline with P0-safety .gitignore

This commit is contained in:
Simon
2026-06-14 16:49:18 +08:00
commit 63262292d7
510 changed files with 146008 additions and 0 deletions
+232
View File
@@ -0,0 +1,232 @@
# =============================================================================
# 企微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()