242 lines
9.0 KiB
Python
242 lines
9.0 KiB
Python
"""站内通知服务"""
|
||
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()]
|