redis_client.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370
  1. import redis
  2. import json
  3. import requests
  4. import time
  5. import random
  6. from typing import Optional, Tuple
  7. from contextlib import contextmanager
  8. from dotenv import load_dotenv
  9. import os
  10. load_dotenv()
  11. REDIS_HOST = os.getenv("REDIS_HOST")
  12. REDIS_PORT = os.getenv("REDIS_PORT")
  13. REDIS_TTL = 1800 # 30分钟
  14. THIRD_PARTY_API = "http://eco.zhongsou.com/eco/user/user.redis.info.groovy"
  15. try:
  16. redis_client = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True)
  17. except Exception:
  18. redis_client = None
  19. def get_app_user(token: str) -> Optional[dict]:
  20. """
  21. 通过第三方 token 获取用户信息
  22. 先查 Redis 缓存,未命中则调第三方接口并写入缓存
  23. Args:
  24. token: App 端传入的第三方 token
  25. Returns:
  26. 用户信息字典,token 无效或接口异常时返回 None
  27. """
  28. cache_key = f"app_user:{token}"
  29. # 1. 查 Redis 缓存
  30. try:
  31. if redis_client:
  32. cached = redis_client.get(cache_key)
  33. if cached:
  34. return json.loads(cached)
  35. except Exception as e:
  36. print(f"Redis 读取失败,降级调第三方接口: {e}")
  37. # 2. 调第三方接口
  38. try:
  39. resp = requests.get(THIRD_PARTY_API, params={"token": token}, timeout=5)
  40. data = resp.json()
  41. if data.get("head", {}).get("status") != 200:
  42. return None
  43. body = data.get("body")
  44. if not body or not body.get("userId"):
  45. return None
  46. user_info = {
  47. "userId": body["userId"],
  48. "userName": body["userName"],
  49. "nickName": body["nickName"],
  50. "mobile": body["mobile"],
  51. "imageUrl": body.get("imageUrl", ""),
  52. "appName": body["appName"],
  53. }
  54. # 3. 写入 Redis 缓存
  55. try:
  56. if redis_client:
  57. redis_client.setex(cache_key, REDIS_TTL, json.dumps(user_info))
  58. except Exception as e:
  59. print(f"Redis 写入失败: {e}")
  60. return user_info
  61. except Exception as e:
  62. print(f"第三方接口调用失败: {e}")
  63. return None
  64. # ==================== 群聊AI频率控制 ====================
  65. class RedisReplyController:
  66. """
  67. 基于Redis的AI回复频率控制器
  68. - 支持多实例部署
  69. - 冷却时间控制
  70. - 每小时配额限制
  71. - 群消息密度检测
  72. - 分布式锁
  73. """
  74. def __init__(self, redis_client):
  75. self.redis = redis_client
  76. def can_reply(self, ai_role: str, priority: str, group_id: str) -> Tuple[bool, str]:
  77. """
  78. 判断AI是否可以回复
  79. Args:
  80. ai_role: AI角色(entrepreneur | femininity)
  81. priority: 优先级(mentioned | force | optional)
  82. group_id: 群ID
  83. Returns:
  84. (can_reply: bool, reason: str)
  85. """
  86. if not self.redis:
  87. return True, "Redis不可用,直接通过"
  88. try:
  89. now = int(time.time())
  90. # 1. 冷却时间检查
  91. last_reply_key = f"im:ai:{group_id}:{ai_role}:last_reply"
  92. last_reply = self.redis.get(last_reply_key)
  93. if last_reply:
  94. elapsed = now - int(last_reply)
  95. # 根据优先级设置冷却时间
  96. if priority == 'mentioned':
  97. # 明确@:随机30-60秒(防止恶意探测规律)
  98. cooldown = random.randint(30, 60)
  99. elif priority == 'force':
  100. cooldown = 60
  101. else:
  102. cooldown = 300
  103. if elapsed < cooldown:
  104. return False, f"冷却中,还需 {cooldown - elapsed} 秒"
  105. # 2. 每小时配额检查(明确@和强制触发不受限)
  106. if priority not in ['mentioned', 'force']:
  107. hour_key = f"im:ai:{group_id}:{ai_role}:count:{now // 3600}"
  108. count = self.redis.get(hour_key)
  109. count = int(count) if count else 0
  110. if count >= 5: # v2.0: 从12次降为5次/小时
  111. return False, f"本小时配额已用完 ({count}/5)"
  112. # 3. 群消息密度检查(明确@和强制触发不受限)
  113. if priority not in ['mentioned', 'force']:
  114. density_key = f"im:group:{group_id}:msg_density"
  115. recent_count = self.redis.llen(density_key)
  116. if recent_count > 20:
  117. return False, f"群内讨论热烈 ({recent_count}条/5分钟)"
  118. return True, "通过"
  119. except Exception as e:
  120. print(f"Redis频率检查失败: {e}")
  121. return True, "Redis异常,直接通过"
  122. def record_reply(self, ai_role: str, group_id: str):
  123. """
  124. 记录AI回复
  125. Args:
  126. ai_role: AI角色
  127. group_id: 群ID
  128. """
  129. if not self.redis:
  130. return
  131. try:
  132. now = int(time.time())
  133. # 记录最后回复时间(1小时有效期)
  134. last_reply_key = f"im:ai:{group_id}:{ai_role}:last_reply"
  135. self.redis.set(last_reply_key, now, ex=3600)
  136. # 增加小时计数
  137. hour_key = f"im:ai:{group_id}:{ai_role}:count:{now // 3600}"
  138. self.redis.incr(hour_key)
  139. self.redis.expire(hour_key, 3600)
  140. except Exception as e:
  141. print(f"Redis记录回复失败: {e}")
  142. def record_group_message(self, group_id: str, msg_seq: int):
  143. """
  144. 记录群消息(用于密度检测)
  145. Args:
  146. group_id: 群ID
  147. msg_seq: 消息序列号
  148. """
  149. if not self.redis:
  150. return
  151. try:
  152. now = int(time.time())
  153. density_key = f"im:group:{group_id}:msg_density"
  154. # 添加到列表头部
  155. self.redis.lpush(density_key, f"{now}:{msg_seq}")
  156. # 只保留最近50条
  157. self.redis.ltrim(density_key, 0, 49)
  158. # 5分钟过期
  159. self.redis.expire(density_key, 300)
  160. # 同时更新群最后消息时间
  161. last_msg_key = f"im:group:{group_id}:last_msg_time"
  162. self.redis.set(last_msg_key, now, ex=3600)
  163. except Exception as e:
  164. print(f"Redis记录群消息失败: {e}")
  165. def get_last_group_message_time(self, group_id: str) -> int:
  166. """
  167. 获取群最后消息时间
  168. Args:
  169. group_id: 群ID
  170. Returns:
  171. 时间戳(秒),如果不存在返回0
  172. """
  173. if not self.redis:
  174. return 0
  175. try:
  176. key = f"im:group:{group_id}:last_msg_time"
  177. timestamp = self.redis.get(key)
  178. return int(timestamp) if timestamp else 0
  179. except Exception as e:
  180. print(f"Redis获取群最后消息时间失败: {e}")
  181. return 0
  182. # ==================== 消息幂等去重 ====================
  183. def is_message_processed(group_id: str, msg_seq: int) -> bool:
  184. """
  185. 检查消息是否已处理
  186. Args:
  187. group_id: 群ID
  188. msg_seq: 消息序列号
  189. Returns:
  190. True: 已处理,False: 未处理
  191. """
  192. if not redis_client:
  193. return False
  194. try:
  195. key = f"im:processed:{group_id}:{msg_seq}"
  196. return redis_client.exists(key) > 0
  197. except Exception as e:
  198. print(f"Redis检查消息处理状态失败: {e}")
  199. return False
  200. def mark_message_processed(group_id: str, msg_seq: int):
  201. """
  202. 标记消息已处理(10分钟有效期)
  203. Args:
  204. group_id: 群ID
  205. msg_seq: 消息序列号
  206. """
  207. if not redis_client:
  208. return
  209. try:
  210. key = f"im:processed:{group_id}:{msg_seq}"
  211. redis_client.setex(key, 600, "1") # 10分钟TTL
  212. except Exception as e:
  213. print(f"Redis标记消息已处理失败: {e}")
  214. # ==================== 分布式锁 ====================
  215. @contextmanager
  216. def acquire_reply_lock(group_id: str, timeout: int = 5):
  217. """
  218. 获取回复锁(分布式锁)
  219. 同一时刻只允许一个AI回复,防止并发抢答
  220. Args:
  221. group_id: 群ID
  222. timeout: 锁超时时间(秒)
  223. Yields:
  224. bool: True表示获取到锁,False表示未获取到
  225. Example:
  226. with acquire_reply_lock(group_id) as locked:
  227. if not locked:
  228. return
  229. # 执行AI回复逻辑
  230. """
  231. if not redis_client:
  232. yield True
  233. return
  234. lock_key = f"im:reply_lock:{group_id}"
  235. lock_value = str(time.time())
  236. acquired = False
  237. try:
  238. # 尝试获取锁(NX: 不存在才设置,EX: 过期时间)
  239. acquired = redis_client.set(lock_key, lock_value, nx=True, ex=timeout)
  240. yield bool(acquired)
  241. finally:
  242. # 释放锁
  243. if acquired:
  244. try:
  245. redis_client.delete(lock_key)
  246. except Exception:
  247. pass # 锁会自动过期,删除失败不影响
  248. # ==================== 群聊上下文缓存 ====================
  249. def get_cached_group_context(group_id: str) -> Optional[list]:
  250. """
  251. 从Redis获取缓存的群聊上下文
  252. Args:
  253. group_id: 群ID
  254. Returns:
  255. 群聊上下文列表,未命中返回None
  256. """
  257. if not redis_client:
  258. return None
  259. cache_key = f"im:group_context:{group_id}"
  260. try:
  261. cached = redis_client.get(cache_key)
  262. if cached:
  263. return json.loads(cached)
  264. except Exception as e:
  265. print(f"Redis获取群上下文缓存失败: {e}")
  266. return None
  267. def cache_group_context(group_id: str, context: list, ttl: int = 120):
  268. """
  269. 将群聊上下文缓存到Redis
  270. Args:
  271. group_id: 群ID
  272. context: 群聊上下文列表
  273. ttl: 缓存时间(秒),默认2分钟
  274. """
  275. if not redis_client:
  276. return
  277. cache_key = f"im:group_context:{group_id}"
  278. try:
  279. redis_client.setex(cache_key, ttl, json.dumps(context, ensure_ascii=False))
  280. except Exception as e:
  281. print(f"Redis缓存群上下文失败: {e}")
  282. # ==================== 全局实例 ====================
  283. # 频率控制器实例
  284. reply_controller = RedisReplyController(redis_client)