"""站内通知服务""" from __future__ import annotations from datetime import datetime, timedelta, timezone from typing import Any, Iterable, Optional from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession from ..models import Notification, User class NotificationService: def __init__(self, db: AsyncSession): self.db = db async def create_notification( self, *, tenant_id: int, user_id: int, title: str, content: Optional[str] = None, category: str = "system", source: Optional[str] = None, entity_type: Optional[str] = None, entity_id: Optional[int] = None, target_url: Optional[str] = None, kind: Optional[str] = None, priority: Optional[str] = None, status: Optional[str] = None, blocking_reason: Optional[str] = None, recommended_action: Optional[str] = None, dedupe_key: Optional[str] = None, occurrence_count: int = 1, payload: Optional[dict[str, Any]] = None, commit: bool = False, ) -> Notification: normalized_dedupe_key = (dedupe_key or "").strip()[:255] or None if normalized_dedupe_key: existing = ( await self.db.execute( select(Notification) .where( and_( Notification.tenant_id == int(tenant_id), Notification.user_id == int(user_id), Notification.dedupe_key == normalized_dedupe_key, Notification.is_read.is_(False), ) ) .order_by(Notification.updated_at.desc(), Notification.id.desc()) .limit(1) ) ).scalar_one_or_none() if existing is not None: existing.category = (category or existing.category or "system")[:50] existing.title = (title or existing.title or "系统通知")[:200] existing.content = (content or existing.content or "")[:4000] or None existing.source = (source or existing.source or "")[:50] or None existing.entity_type = (entity_type or existing.entity_type or "")[:50] or None existing.entity_id = int(entity_id) if entity_id is not None else existing.entity_id existing.target_url = (target_url or existing.target_url or "")[:500] or None existing.kind = (kind or existing.kind or "")[:30] or None existing.priority = (priority or existing.priority or "")[:20] or None existing.status = (status or existing.status or "")[:30] or None existing.blocking_reason = (blocking_reason or existing.blocking_reason or "")[:2000] or None existing.recommended_action = (recommended_action or existing.recommended_action or "")[:80] or None existing.payload = payload or existing.payload existing.occurrence_count = max(1, int(existing.occurrence_count or 1)) + max(1, int(occurrence_count or 1)) await self.db.flush() if commit: await self.db.commit() return existing row = Notification( tenant_id=int(tenant_id), user_id=int(user_id), category=(category or "system")[:50], title=(title or "系统通知")[:200], content=(content or "")[:4000] or None, source=(source or "")[:50] or None, entity_type=(entity_type or "")[:50] or None, entity_id=int(entity_id) if entity_id is not None else None, target_url=(target_url or "")[:500] or None, kind=(kind or "")[:30] or None, priority=(priority or "")[:20] or None, status=(status or "")[:30] or None, blocking_reason=(blocking_reason or "")[:2000] or None, recommended_action=(recommended_action or "")[:80] or None, dedupe_key=normalized_dedupe_key, occurrence_count=max(1, int(occurrence_count or 1)), payload=payload or None, is_read=False, ) self.db.add(row) await self.db.flush() if commit: await self.db.commit() return row async def create_notifications( self, *, tenant_id: int, user_ids: Iterable[int], title: str, content: Optional[str] = None, category: str = "system", source: Optional[str] = None, entity_type: Optional[str] = None, entity_id: Optional[int] = None, target_url: Optional[str] = None, kind: Optional[str] = None, priority: Optional[str] = None, status: Optional[str] = None, blocking_reason: Optional[str] = None, recommended_action: Optional[str] = None, dedupe_key: Optional[str] = None, occurrence_count: int = 1, payload: Optional[dict[str, Any]] = None, commit: bool = False, ) -> list[Notification]: rows: list[Notification] = [] for user_id in user_ids: if user_id is None: continue rows.append( await self.create_notification( tenant_id=tenant_id, user_id=int(user_id), title=title, content=content, category=category, source=source, entity_type=entity_type, entity_id=entity_id, target_url=target_url, kind=kind, priority=priority, status=status, blocking_reason=blocking_reason, recommended_action=recommended_action, dedupe_key=dedupe_key, occurrence_count=occurrence_count, payload=payload, commit=False, ) ) if commit and rows: await self.db.commit() return rows async def notify_signal_created( self, *, tenant_id: int, signal_id: int, signal_content: str, source: str = "feishu", target_url: Optional[str] = None, recipient_user_ids: Optional[Iterable[int]] = None, proposer: Optional[str] = None, occurred_at: Optional[datetime] = None, ) -> int: """向指定用户(或租户内全部活跃用户)发送“新需求已生成”通知。""" user_ids = await self._resolve_recipients( tenant_id=tenant_id, recipient_user_ids=recipient_user_ids, ) if not user_ids: return 0 snippet = (signal_content or "").strip() if len(snippet) > 80: snippet = f"{snippet[:80]}..." if not snippet: snippet = "飞书消息触发了新的需求信号。" proposer_name = (proposer or "").strip() or "未知用户" occurred_text = self._format_cn_time(occurred_at) title = "AI 已识别新需求" content = ( f"提出人:{proposer_name}\n" f"时间:{occurred_text}\n" f"内容:{snippet}\n" f"信号ID:{signal_id}" ) rows = await self.create_notifications( tenant_id=tenant_id, user_ids=user_ids, category="requirement", title=title, content=content, source=source, entity_type="signal", entity_id=signal_id, target_url=target_url, kind="info", priority="medium", status="notified", recommended_action="view_result", dedupe_key=f"signal-created:{signal_id}", payload={ "message_class": "info", "project_id": None, "project_name": None, "summary": snippet, }, commit=True, ) return len(rows) @staticmethod def _format_cn_time(dt: Optional[datetime]) -> str: if not dt: dt = datetime.now(timezone.utc) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) cn_tz = timezone(timedelta(hours=8)) return dt.astimezone(cn_tz).strftime("%Y-%m-%d %H:%M:%S") async def _resolve_recipients( self, *, tenant_id: int, recipient_user_ids: Optional[Iterable[int]] = None, ) -> list[int]: if recipient_user_ids: return [int(uid) for uid in recipient_user_ids if uid is not None] result = await self.db.execute( select(User.id).where( and_( User.tenant_id == tenant_id, User.is_active.is_(True), ) ) ) return [int(uid) for uid in result.scalars().all()]