PMFlow/backend/app/services/notification_service.py

243 lines
9.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""站内通知服务"""
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
from ..utils.timezone import CN_TZ, now_cn_aware
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 = now_cn_aware()
if dt.tzinfo is None:
dt = dt.replace(tzinfo=CN_TZ)
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()]