nex_docus/backend/app/services/notification_service.py

348 lines
12 KiB
Python
Raw Normal View History

2026-01-06 10:04:06 +00:00
import logging
2026-01-23 07:00:03 +00:00
import json
2026-08-18 16:58:02 +00:00
import re
2026-01-23 07:00:03 +00:00
import time
import uuid
2026-01-06 10:04:06 +00:00
from sqlalchemy.ext.asyncio import AsyncSession
2026-01-23 07:00:03 +00:00
from sqlalchemy import select
2026-01-06 10:04:06 +00:00
from app.models.project import ProjectMember
2026-01-23 07:00:03 +00:00
from app.core.redis_client import get_redis
from typing import List, Optional, Dict, Any
2026-01-06 10:04:06 +00:00
logger = logging.getLogger(__name__)
class NotificationService:
2026-01-23 07:00:03 +00:00
# 通知过期时间14天 (秒)
EXPIRATION_SECONDS = 14 * 24 * 60 * 60
def _get_order_key(self, user_id: int) -> str:
return f"notifications:order:{user_id}"
def _get_content_key(self, user_id: int) -> str:
return f"notifications:content:{user_id}"
2026-08-18 16:58:02 +00:00
@staticmethod
def _extract_project_id_from_link(link: Optional[str]) -> Optional[int]:
"""从通知链接中解析项目ID兼容旧通知没有 project_id 字段的情况)"""
if not link:
return None
match = re.search(r"/projects/(\d+)", link)
return int(match.group(1)) if match else None
def _get_project_id(self, data: Dict[str, Any]) -> Optional[int]:
"""从通知数据中获取项目ID"""
pid = data.get("project_id")
if pid is not None:
try:
return int(pid)
except (TypeError, ValueError):
pass
return self._extract_project_id_from_link(data.get("link"))
2026-01-06 10:04:06 +00:00
async def create_notification(
self,
db: AsyncSession,
user_id: int,
title: str,
content: str = None,
type: str = "info",
category: str = "system",
2026-08-18 16:58:02 +00:00
link: str = None,
project_id: Optional[int] = None
2026-01-23 07:00:03 +00:00
) -> Dict[str, Any]:
"""创建单条通知 (写入 Redis)"""
redis = get_redis()
if not redis:
return None
timestamp = time.time()
notification_id = str(uuid.uuid4())
notification_data = {
"id": notification_id,
"user_id": user_id,
"title": title,
"content": content,
"type": type,
"category": category,
"link": link,
"is_read": False,
"created_at": timestamp
}
2026-08-18 16:58:02 +00:00
if project_id is not None:
notification_data["project_id"] = project_id
2026-01-23 07:00:03 +00:00
json_data = json.dumps(notification_data, ensure_ascii=False)
order_key = self._get_order_key(user_id)
content_key = self._get_content_key(user_id)
async with redis.pipeline() as pipe:
pipe.hset(content_key, notification_id, json_data)
pipe.zadd(order_key, {notification_id: timestamp})
min_score = timestamp - self.EXPIRATION_SECONDS
pipe.zremrangebyscore(order_key, "-inf", min_score)
pipe.expire(order_key, self.EXPIRATION_SECONDS + 86400)
pipe.expire(content_key, self.EXPIRATION_SECONDS + 86400)
await pipe.execute()
return notification_data
2026-01-06 10:04:06 +00:00
async def broadcast_system_notification(
self,
db: AsyncSession,
title: str,
content: str,
user_ids: List[int],
2026-08-18 16:58:02 +00:00
link: str = None,
project_id: Optional[int] = None,
category: str = "system"
2026-01-06 10:04:06 +00:00
):
"""向指定多个用户发送系统通知"""
2026-01-23 07:00:03 +00:00
redis = get_redis()
if not redis:
return
timestamp = time.time()
async with redis.pipeline() as pipe:
for uid in user_ids:
notification_id = str(uuid.uuid4())
notification_data = {
"id": notification_id,
"user_id": uid,
"title": title,
"content": content,
"type": "info",
2026-08-18 16:58:02 +00:00
"category": category,
2026-01-23 07:00:03 +00:00
"link": link,
"is_read": False,
"created_at": timestamp
}
2026-08-18 16:58:02 +00:00
if project_id is not None:
notification_data["project_id"] = project_id
2026-01-23 07:00:03 +00:00
json_data = json.dumps(notification_data, ensure_ascii=False)
order_key = self._get_order_key(uid)
content_key = self._get_content_key(uid)
pipe.hset(content_key, notification_id, json_data)
pipe.zadd(order_key, {notification_id: timestamp})
pipe.expire(order_key, self.EXPIRATION_SECONDS + 86400)
pipe.expire(content_key, self.EXPIRATION_SECONDS + 86400)
await pipe.execute()
2026-01-06 10:04:06 +00:00
async def notify_project_members(
self,
db: AsyncSession,
project_id: int,
exclude_user_id: int,
title: str,
content: str,
link: str = None,
category: str = "project"
):
result = await db.execute(
select(ProjectMember.user_id).where(
ProjectMember.project_id == project_id,
ProjectMember.user_id != exclude_user_id
)
)
member_ids = result.scalars().all()
if member_ids:
2026-01-23 07:00:03 +00:00
await self.broadcast_system_notification(
db,
title=title,
content=content,
user_ids=member_ids,
2026-08-18 16:58:02 +00:00
link=link,
project_id=project_id,
category=category
2026-01-23 07:00:03 +00:00
)
async def get_user_notifications(
self,
user_id: int,
limit: int = 50,
2026-01-28 11:55:01 +00:00
skip: int = 0,
unread_only: bool = False
2026-01-23 07:00:03 +00:00
) -> List[Dict[str, Any]]:
"""获取用户通知列表"""
redis = get_redis()
if not redis:
return []
order_key = self._get_order_key(user_id)
content_key = self._get_content_key(user_id)
2026-01-28 11:55:01 +00:00
# 如果是查询全部且没有过滤,可以直接利用 ZREVRANGE 分页
if not unread_only:
ids = await redis.zrevrange(order_key, skip, skip + limit - 1)
if not ids:
return []
json_strings = await redis.hmget(content_key, ids)
notifications = []
ids_to_remove = []
for i, json_str in enumerate(json_strings):
if json_str:
try:
notifications.append(json.loads(json_str))
except: continue
else:
ids_to_remove.append(ids[i])
if ids_to_remove:
await redis.zrem(order_key, *ids_to_remove)
return notifications
# 如果需要过滤未读,由于 Redis Hash 不支持按值过滤,需要获取较多数据在内存中过滤
# 考虑到数据只保留 14 天,全量获取(最近几百条)在内存中处理是可行的
all_ids = await redis.zrevrange(order_key, 0, -1)
if not all_ids:
2026-01-23 07:00:03 +00:00
return []
2026-01-28 11:55:01 +00:00
all_jsons = await redis.hmget(content_key, all_ids)
2026-01-23 07:00:03 +00:00
notifications = []
2026-01-28 11:55:01 +00:00
for json_str in all_jsons:
2026-01-23 07:00:03 +00:00
if json_str:
try:
2026-01-28 11:55:01 +00:00
data = json.loads(json_str)
if data.get('is_read') is False:
notifications.append(data)
except: continue
2026-01-23 07:00:03 +00:00
2026-01-28 11:55:01 +00:00
# 手动分页
return notifications[skip : skip + limit]
2026-01-23 07:00:03 +00:00
async def get_unread_count(self, user_id: int) -> int:
"""获取未读通知数量"""
redis = get_redis()
if not redis:
return 0
content_key = self._get_content_key(user_id)
all_jsons = await redis.hvals(content_key)
count = 0
for js in all_jsons:
try:
if js:
data = json.loads(js)
if not data.get('is_read'):
count += 1
except:
pass
return count
2026-01-06 10:04:06 +00:00
2026-01-23 07:00:03 +00:00
async def mark_read(self, user_id: int, notification_id: str):
"""标记已读"""
redis = get_redis()
if not redis:
return
content_key = self._get_content_key(user_id)
json_str = await redis.hget(content_key, notification_id)
if json_str:
try:
data = json.loads(json_str)
data['is_read'] = True
await redis.hset(content_key, notification_id, json.dumps(data, ensure_ascii=False))
except:
pass
2026-08-18 16:58:02 +00:00
async def get_unread_count_by_project(self, user_id: int) -> Dict[int, int]:
"""按项目统计未读通知数量(仅项目类通知),返回 {project_id: count}"""
redis = get_redis()
if not redis:
return {}
content_key = self._get_content_key(user_id)
all_jsons = await redis.hvals(content_key)
result: Dict[int, int] = {}
for js in all_jsons:
if not js:
continue
try:
data = json.loads(js)
except Exception:
continue
if data.get("is_read"):
continue
# 关联到项目的未读通知即计入该项目(兼容旧数据 category=system
pid = self._get_project_id(data)
if pid:
result[pid] = result.get(pid, 0) + 1
return result
async def mark_project_read(self, user_id: int, project_id: int) -> int:
"""将指定项目的未读通知全部标记为已读,返回标记数量"""
redis = get_redis()
if not redis:
return 0
content_key = self._get_content_key(user_id)
all_jsons = await redis.hvals(content_key)
updates = {}
count = 0
for js in all_jsons:
if not js:
continue
try:
data = json.loads(js)
except Exception:
continue
if data.get("is_read"):
continue
# 关联到该项目的未读通知全部标记已读(兼容旧数据 category=system
if self._get_project_id(data) == project_id:
data["is_read"] = True
updates[data.get("id")] = json.dumps(data, ensure_ascii=False)
count += 1
if updates:
await redis.hset(content_key, mapping=updates)
return count
2026-01-23 07:00:03 +00:00
async def mark_all_read(self, user_id: int):
"""标记所有已读"""
redis = get_redis()
if not redis:
return
order_key = self._get_order_key(user_id)
content_key = self._get_content_key(user_id)
ids = await redis.zrange(order_key, 0, -1)
if not ids:
return
json_strings = await redis.hmget(content_key, ids)
updates = {}
for i, json_str in enumerate(json_strings):
if json_str:
try:
data = json.loads(json_str)
if not data.get('is_read'):
data['is_read'] = True
updates[ids[i]] = json.dumps(data, ensure_ascii=False)
except:
pass
if updates:
await redis.hset(content_key, mapping=updates)
async def delete_notification(self, user_id: int, notification_id: str):
redis = get_redis()
if not redis:
return
order_key = self._get_order_key(user_id)
content_key = self._get_content_key(user_id)
await redis.zrem(order_key, notification_id)
await redis.hdel(content_key, notification_id)
2026-01-06 10:04:06 +00:00
2026-01-23 07:00:03 +00:00
notification_service = NotificationService()