segment_mention_service.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. """
  2. Segment Mention Service
  3. 轻量级的知识库片段@提及通知服务
  4. 数据存储在Redis中
  5. """
  6. import json
  7. import uuid
  8. from datetime import datetime, timedelta
  9. from typing import Optional
  10. from extensions.ext_redis import redis_client
  11. from models.account import Account
  12. from models.dataset import Dataset, DocumentSegment
  13. from extensions.ext_database import db
  14. class SegmentMentionService:
  15. """片段@提及服务 - 基于Redis"""
  16. # Redis Key 前缀
  17. COMMENT_KEY_PREFIX = "segment:comment:" # segment:comment:{comment_id}
  18. SEGMENT_COMMENTS_KEY = "segment:comments:{dataset_id}:{segment_id}" # 某个片段的所有评论ID列表
  19. NOTIFICATION_KEY_PREFIX = "user:notification:" # user:notification:{account_id}
  20. UNREAD_COUNT_KEY = "user:notification:unread:{account_id}" # 未读数量
  21. # 数据过期时间(30天)
  22. EXPIRATION_DAYS = 30
  23. EXPIRATION_SECONDS = EXPIRATION_DAYS * 24 * 60 * 60
  24. @staticmethod
  25. def create_comment(
  26. tenant_id: str,
  27. dataset_id: str,
  28. segment_id: str,
  29. content: str,
  30. account_id: str,
  31. mentioned_account_ids: list[str] = None
  32. ) -> dict:
  33. """
  34. 创建评论
  35. Args:
  36. tenant_id: 租户ID
  37. dataset_id: 知识库ID
  38. segment_id: 片段ID
  39. content: 评论内容
  40. account_id: 评论者ID
  41. mentioned_account_ids: @提及的用户ID列表
  42. Returns:
  43. 评论数据字典
  44. """
  45. comment_id = str(uuid.uuid4())
  46. account = db.session.get(Account, account_id)
  47. comment_data = {
  48. 'id': comment_id,
  49. 'tenant_id': tenant_id,
  50. 'dataset_id': dataset_id,
  51. 'segment_id': segment_id,
  52. 'content': content,
  53. 'account_id': account_id,
  54. 'account_name': account.name if account else 'Unknown',
  55. 'account_avatar': account.avatar if account else '',
  56. 'mentioned_account_ids': mentioned_account_ids or [],
  57. 'created_at': datetime.utcnow().isoformat(),
  58. 'is_deleted': False
  59. }
  60. # 存储评论
  61. comment_key = f"{SegmentMentionService.COMMENT_KEY_PREFIX}{comment_id}"
  62. redis_client.setex(
  63. comment_key,
  64. SegmentMentionService.EXPIRATION_SECONDS,
  65. json.dumps(comment_data)
  66. )
  67. # 添加到片段的评论列表
  68. segment_comments_key = SegmentMentionService.SEGMENT_COMMENTS_KEY.format(
  69. dataset_id=dataset_id,
  70. segment_id=segment_id
  71. )
  72. redis_client.lpush(segment_comments_key, comment_id)
  73. redis_client.expire(segment_comments_key, SegmentMentionService.EXPIRATION_SECONDS)
  74. # 发送@提及通知
  75. if mentioned_account_ids:
  76. SegmentMentionService._create_mention_notifications(
  77. comment_id=comment_id,
  78. comment_content=content,
  79. mentioned_account_ids=mentioned_account_ids,
  80. sender_id=account_id,
  81. sender_name=account.name if account else 'Unknown',
  82. dataset_id=dataset_id,
  83. segment_id=segment_id
  84. )
  85. return comment_data
  86. @staticmethod
  87. def get_segment_comments(
  88. dataset_id: str,
  89. segment_id: str,
  90. limit: int = 50
  91. ) -> list[dict]:
  92. """
  93. 获取片段的所有评论
  94. Args:
  95. dataset_id: 知识库ID
  96. segment_id: 片段ID
  97. limit: 最大返回数量
  98. Returns:
  99. 评论列表
  100. """
  101. segment_comments_key = SegmentMentionService.SEGMENT_COMMENTS_KEY.format(
  102. dataset_id=dataset_id,
  103. segment_id=segment_id
  104. )
  105. # 获取评论ID列表(最新的在前)
  106. comment_ids = redis_client.lrange(segment_comments_key, 0, limit - 1)
  107. comments = []
  108. for comment_id in comment_ids:
  109. comment_key = f"{SegmentMentionService.COMMENT_KEY_PREFIX}{comment_id.decode('utf-8')}"
  110. comment_data = redis_client.get(comment_key)
  111. if comment_data:
  112. comment = json.loads(comment_data)
  113. if not comment.get('is_deleted'):
  114. comments.append(comment)
  115. return comments
  116. @staticmethod
  117. def delete_comment(comment_id: str, account_id: str) -> bool:
  118. """
  119. 删除评论(软删除)
  120. Args:
  121. comment_id: 评论ID
  122. account_id: 操作者ID
  123. Returns:
  124. 是否删除成功
  125. """
  126. comment_key = f"{SegmentMentionService.COMMENT_KEY_PREFIX}{comment_id}"
  127. comment_data = redis_client.get(comment_key)
  128. if not comment_data:
  129. return False
  130. comment = json.loads(comment_data)
  131. # 只能删除自己的评论
  132. if comment.get('account_id') != account_id:
  133. raise PermissionError("You can only delete your own comments")
  134. # 软删除
  135. comment['is_deleted'] = True
  136. comment['deleted_at'] = datetime.utcnow().isoformat()
  137. redis_client.setex(
  138. comment_key,
  139. SegmentMentionService.EXPIRATION_SECONDS,
  140. json.dumps(comment)
  141. )
  142. return True
  143. @staticmethod
  144. def _create_mention_notifications(
  145. comment_id: str,
  146. comment_content: str,
  147. mentioned_account_ids: list[str],
  148. sender_id: str,
  149. sender_name: str,
  150. dataset_id: str,
  151. segment_id: str
  152. ):
  153. """
  154. 创建@提及通知
  155. Args:
  156. comment_id: 评论ID
  157. comment_content: 评论内容
  158. mentioned_account_ids: 被@的用户ID列表
  159. sender_id: 发送者ID
  160. sender_name: 发送者名称
  161. dataset_id: 知识库ID
  162. segment_id: 片段ID
  163. """
  164. import logging
  165. logger = logging.getLogger(__name__)
  166. logger.info(f"[MENTION] Creating notifications for comment {comment_id}")
  167. logger.info(f"[MENTION] Mentioned account IDs: {mentioned_account_ids}")
  168. logger.info(f"[MENTION] Sender: {sender_name} ({sender_id})")
  169. # 获取知识库名称
  170. dataset = db.session.get(Dataset, dataset_id)
  171. dataset_name = dataset.name if dataset else 'Unknown Dataset'
  172. # 获取片段信息
  173. segment = db.session.get(DocumentSegment, segment_id)
  174. segment_preview = segment.content[:50] + '...' if segment and len(segment.content) > 50 else (segment.content if segment else '')
  175. for recipient_id in mentioned_account_ids:
  176. # 不给自己发通知
  177. if recipient_id == sender_id:
  178. logger.info(f"[MENTION] Skipping notification to self: {recipient_id}")
  179. continue
  180. logger.info(f"[MENTION] Creating notification for recipient: {recipient_id}")
  181. notification_id = str(uuid.uuid4())
  182. notification_data = {
  183. 'id': notification_id,
  184. 'type': 'mention',
  185. 'title': f'{sender_name} mentioned you in a comment',
  186. 'content': comment_content[:200], # 截取前200字符
  187. 'sender_id': sender_id,
  188. 'sender_name': sender_name,
  189. 'dataset_id': dataset_id,
  190. 'dataset_name': dataset_name,
  191. 'segment_id': segment_id,
  192. 'segment_preview': segment_preview,
  193. 'comment_id': comment_id,
  194. 'link_url': f'/datasets/{dataset_id}/documents',
  195. 'is_read': False,
  196. 'created_at': datetime.utcnow().isoformat()
  197. }
  198. # 存储通知
  199. notification_key = f"{SegmentMentionService.NOTIFICATION_KEY_PREFIX}{recipient_id}:{notification_id}"
  200. redis_client.setex(
  201. notification_key,
  202. SegmentMentionService.EXPIRATION_SECONDS,
  203. json.dumps(notification_data)
  204. )
  205. logger.info(f"[MENTION] Stored notification in Redis: {notification_key}")
  206. # 添加到用户的通知列表
  207. user_notifications_key = f"{SegmentMentionService.NOTIFICATION_KEY_PREFIX}{recipient_id}:list"
  208. redis_client.lpush(user_notifications_key, notification_id)
  209. redis_client.expire(user_notifications_key, SegmentMentionService.EXPIRATION_SECONDS)
  210. logger.info(f"[MENTION] Added to user notification list: {user_notifications_key}")
  211. # 增加未读计数
  212. unread_count_key = SegmentMentionService.UNREAD_COUNT_KEY.format(account_id=recipient_id)
  213. redis_client.incr(unread_count_key)
  214. redis_client.expire(unread_count_key, SegmentMentionService.EXPIRATION_SECONDS)
  215. logger.info(f"[MENTION] Incremented unread count: {unread_count_key}")
  216. # 发布实时通知(用于WebSocket/SSE)
  217. redis_client.publish(
  218. f'segment_notification:{recipient_id}',
  219. json.dumps(notification_data)
  220. )
  221. logger.info(f"[MENTION] Published notification to channel: segment_notification:{recipient_id}")
  222. @staticmethod
  223. def get_user_notifications(
  224. account_id: str,
  225. is_read: Optional[bool] = None,
  226. limit: int = 50
  227. ) -> dict:
  228. """
  229. 获取用户的通知列表
  230. Args:
  231. account_id: 用户ID
  232. is_read: 是否已读(None表示全部)
  233. limit: 最大返回数量
  234. Returns:
  235. 包含通知列表和未读数量的字典
  236. """
  237. import logging
  238. logger = logging.getLogger(__name__)
  239. logger.info(f"[NOTIFY-GET] Getting notifications for account: {account_id}, is_read filter: {is_read}, limit: {limit}")
  240. user_notifications_key = f"{SegmentMentionService.NOTIFICATION_KEY_PREFIX}{account_id}:list"
  241. notification_ids = redis_client.lrange(user_notifications_key, 0, limit - 1)
  242. logger.info(f"[NOTIFY-GET] Found {len(notification_ids)} notification IDs in list: {user_notifications_key}")
  243. notifications = []
  244. for notification_id in notification_ids:
  245. notification_key = f"{SegmentMentionService.NOTIFICATION_KEY_PREFIX}{account_id}:{notification_id.decode('utf-8')}"
  246. notification_data = redis_client.get(notification_key)
  247. logger.info(f"[NOTIFY-GET] Checking key: {notification_key}, exists: {notification_data is not None}")
  248. if notification_data:
  249. notification = json.loads(notification_data)
  250. notification_is_read = notification.get('is_read', False)
  251. logger.info(f"[NOTIFY-GET] Notification {notification_id.decode('utf-8')} is_read: {notification_is_read}, filter is_read: {is_read}")
  252. # 过滤已读/未读
  253. if is_read is not None and notification_is_read != is_read:
  254. logger.info(f"[NOTIFY-GET] Filtering out notification {notification_id.decode('utf-8')} (is_read={notification_is_read}, filter={is_read})")
  255. continue
  256. notifications.append(notification)
  257. logger.info(f"[NOTIFY-GET] Added notification: {notification_id.decode('utf-8')}, is_read: {notification_is_read}")
  258. # 获取未读数量
  259. unread_count_key = SegmentMentionService.UNREAD_COUNT_KEY.format(account_id=account_id)
  260. unread_count = redis_client.get(unread_count_key)
  261. unread_count = int(unread_count) if unread_count else 0
  262. logger.info(f"[NOTIFY-GET] Unread count: {unread_count}, Total notifications: {len(notifications)}")
  263. return {
  264. 'notifications': notifications,
  265. 'unread_count': unread_count,
  266. 'total': len(notifications)
  267. }
  268. @staticmethod
  269. def mark_as_read(account_id: str, notification_ids: list[str]) -> bool:
  270. """
  271. 标记通知为已读
  272. Args:
  273. account_id: 用户ID
  274. notification_ids: 通知ID列表
  275. Returns:
  276. 是否成功
  277. """
  278. for notification_id in notification_ids:
  279. notification_key = f"{SegmentMentionService.NOTIFICATION_KEY_PREFIX}{account_id}:{notification_id}"
  280. notification_data = redis_client.get(notification_key)
  281. if notification_data:
  282. notification = json.loads(notification_data)
  283. # 如果之前未读,现在标记为已读
  284. if not notification.get('is_read'):
  285. notification['is_read'] = True
  286. notification['read_at'] = datetime.utcnow().isoformat()
  287. redis_client.setex(
  288. notification_key,
  289. SegmentMentionService.EXPIRATION_SECONDS,
  290. json.dumps(notification)
  291. )
  292. # 减少未读计数
  293. unread_count_key = SegmentMentionService.UNREAD_COUNT_KEY.format(account_id=account_id)
  294. current_count = redis_client.get(unread_count_key)
  295. if current_count and int(current_count) > 0:
  296. redis_client.decr(unread_count_key)
  297. return True
  298. @staticmethod
  299. def mark_all_as_read(account_id: str) -> bool:
  300. """
  301. 标记所有通知为已读
  302. Args:
  303. account_id: 用户ID
  304. Returns:
  305. 是否成功
  306. """
  307. user_notifications_key = f"{SegmentMentionService.NOTIFICATION_KEY_PREFIX}{account_id}:list"
  308. notification_ids = redis_client.lrange(user_notifications_key, 0, -1)
  309. for notification_id in notification_ids:
  310. notification_key = f"{SegmentMentionService.NOTIFICATION_KEY_PREFIX}{account_id}:{notification_id.decode('utf-8')}"
  311. notification_data = redis_client.get(notification_key)
  312. if notification_data:
  313. notification = json.loads(notification_data)
  314. if not notification.get('is_read'):
  315. notification['is_read'] = True
  316. notification['read_at'] = datetime.utcnow().isoformat()
  317. redis_client.setex(
  318. notification_key,
  319. SegmentMentionService.EXPIRATION_SECONDS,
  320. json.dumps(notification)
  321. )
  322. # 重置未读计数
  323. unread_count_key = SegmentMentionService.UNREAD_COUNT_KEY.format(account_id=account_id)
  324. redis_client.set(unread_count_key, 0)
  325. redis_client.expire(unread_count_key, SegmentMentionService.EXPIRATION_SECONDS)
  326. return True
  327. @staticmethod
  328. def get_unread_count(account_id: str) -> int:
  329. """
  330. 获取用户的未读通知数量
  331. Args:
  332. account_id: 用户ID
  333. Returns:
  334. 未读数量
  335. """
  336. import logging
  337. logger = logging.getLogger(__name__)
  338. unread_count_key = SegmentMentionService.UNREAD_COUNT_KEY.format(account_id=account_id)
  339. logger.info(f"[UNREAD] Getting unread count for account: {account_id}")
  340. logger.info(f"[UNREAD] Redis key: {unread_count_key}")
  341. unread_count = redis_client.get(unread_count_key)
  342. logger.info(f"[UNREAD] Raw value from Redis: {unread_count}")
  343. result = int(unread_count) if unread_count else 0
  344. logger.info(f"[UNREAD] Returning count: {result}")
  345. return result