功能(提醒):统一项目提醒中心与桌面通知链路
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled

增加通用提醒状态、数据库迁移和定时同步,覆盖风险时效、文件回执、项目里程碑、访视窗口与协作申请。

新增网页端和桌面端提醒中心、真实投递诊断与固定隐私通知正文,并补齐登录来源聚合、测试和说明文档。
This commit is contained in:
Cheng Zhou
2026-07-16 16:50:34 +08:00
parent 88bd0c5942
commit 3e77127687
50 changed files with 2894 additions and 247 deletions
@@ -0,0 +1,78 @@
"""Server-side materialization for time and state driven business reminders."""
from __future__ import annotations
import asyncio
import logging
from sqlalchemy import select, text
from app.core.config import settings
from app.db.session import SessionLocal
from app.models.study_member import StudyMember
from app.models.user import User, UserStatus
from app.services.project_reminder_service import sync_project_reminders
logger = logging.getLogger("ctms.notifications")
NOTIFICATION_SYNC_LOCK_KEY = 0x43544D53 # "CTMS"
async def sync_all_project_reminders_once() -> int:
"""Synchronize every active project member once, with a cross-worker advisory lock."""
async with SessionLocal() as lock_session:
acquired = bool(await lock_session.scalar(
text("SELECT pg_try_advisory_lock(:lock_key)"),
{"lock_key": NOTIFICATION_SYNC_LOCK_KEY},
))
if not acquired:
return 0
try:
memberships = (await lock_session.execute(
select(StudyMember.study_id, StudyMember.user_id)
.join(User, User.id == StudyMember.user_id)
.where(
StudyMember.is_active.is_(True),
User.status == UserStatus.ACTIVE,
)
)).all()
await lock_session.commit()
synced = 0
for study_id, user_id in memberships:
async with SessionLocal() as session:
try:
user = await session.get(User, user_id)
if user is None or not user.is_active:
continue
await sync_project_reminders(session, study_id, user)
synced += 1
except Exception:
await session.rollback()
logger.exception(
"Failed to synchronize reminders for study=%s user=%s",
study_id,
user_id,
)
return synced
finally:
await lock_session.execute(
text("SELECT pg_advisory_unlock(:lock_key)"),
{"lock_key": NOTIFICATION_SYNC_LOCK_KEY},
)
async def run_notification_sync_job(stop_event: asyncio.Event) -> None:
logger.info("Notification synchronization task started")
while not stop_event.is_set():
try:
synced = await sync_all_project_reminders_once()
logger.debug("Synchronized reminders for %s project memberships", synced)
except Exception:
logger.exception("Notification synchronization task failed")
try:
await asyncio.wait_for(
stop_event.wait(),
timeout=settings.NOTIFICATION_SYNC_INTERVAL_SECONDS,
)
except asyncio.TimeoutError:
continue
logger.info("Notification synchronization task stopped")