From 3e771276878665b0aacdb19fa4882eba8aa7e74d Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Thu, 16 Jul 2026 16:50:34 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD(=E6=8F=90=E9=86=92)=EF=BC=9A?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E9=A1=B9=E7=9B=AE=E6=8F=90=E9=86=92=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=E4=B8=8E=E6=A1=8C=E9=9D=A2=E9=80=9A=E7=9F=A5=E9=93=BE?= =?UTF-8?q?=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 增加通用提醒状态、数据库迁移和定时同步,覆盖风险时效、文件回执、项目里程碑、访视窗口与协作申请。 新增网页端和桌面端提醒中心、真实投递诊断与固定隐私通知正文,并补齐登录来源聚合、测试和说明文档。 --- ...0260716_05_expand_generic_notifications.py | 63 ++ backend/app/api/v1/desktop_notifications.py | 6 +- backend/app/api/v1/notifications.py | 17 +- backend/app/api/v1/visits.py | 18 + backend/app/core/config.py | 1 + backend/app/main.py | 3 + backend/app/models/desktop_notification.py | 8 +- backend/app/models/notification.py | 6 +- backend/app/schemas/notification.py | 26 +- backend/app/services/collaboration_service.py | 1 + .../services/desktop_notification_service.py | 122 ++- backend/app/services/document_service.py | 24 +- .../app/services/notification_scheduler.py | 78 ++ backend/app/services/notification_service.py | 196 ++++- .../app/services/project_reminder_service.py | 511 +++++++++++- backend/app/services/user_login_sessions.py | 10 +- backend/tests/test_notification_service.py | 261 ++++++- backend/tests/test_user_login_sessions.py | 49 ++ docker-compose.yaml | 1 + docs/desktop-phase-2-design.md | 6 +- docs/onlyoffice-collaboration.md | 2 +- docs/project-notifications.md | 72 ++ frontend/scripts/verify-desktop-release.mjs | 4 +- frontend/src/api/desktopNotifications.ts | 4 +- frontend/src/api/notifications.test.ts | 9 +- frontend/src/api/notifications.ts | 25 +- .../components/ApiEndpointPermissions.test.ts | 3 + .../src/components/ApiEndpointPermissions.vue | 8 +- frontend/src/components/DesktopLayout.vue | 34 + .../src/components/Layout.desktop.test.ts | 30 +- frontend/src/components/WebLayout.vue | 28 +- .../useProjectNotifications.test.ts | 4 + .../composables/useProjectNotifications.ts | 30 +- frontend/src/locales/zh-CN.ts | 2 +- frontend/src/plugins/elementPlus.test.ts | 14 + frontend/src/plugins/elementPlus.ts | 4 +- frontend/src/router/index.ts | 7 + frontend/src/runtime/notifications.test.ts | 18 +- frontend/src/runtime/notifications.ts | 8 +- .../desktopNotificationManager.test.ts | 66 +- .../src/session/desktopNotificationManager.ts | 77 ++ frontend/src/styles/main.css | 4 + frontend/src/types/notifications.ts | 4 + frontend/src/views/DesktopPreferences.vue | 361 ++++++++- .../src/views/ProjectNotifications.test.ts | 16 + frontend/src/views/ProjectNotifications.vue | 739 ++++++++++++++++++ .../views/admin/UserLoginActivitiesDrawer.vue | 35 +- .../src/views/admin/UsersLoginStatus.test.ts | 2 +- .../views/admin/loginActivitySources.test.ts | 68 ++ .../src/views/admin/loginActivitySources.ts | 56 ++ 50 files changed, 2894 insertions(+), 247 deletions(-) create mode 100644 backend/alembic/versions/20260716_05_expand_generic_notifications.py create mode 100644 backend/app/services/notification_scheduler.py create mode 100644 docs/project-notifications.md create mode 100644 frontend/src/plugins/elementPlus.test.ts create mode 100644 frontend/src/views/ProjectNotifications.test.ts create mode 100644 frontend/src/views/ProjectNotifications.vue create mode 100644 frontend/src/views/admin/loginActivitySources.test.ts create mode 100644 frontend/src/views/admin/loginActivitySources.ts diff --git a/backend/alembic/versions/20260716_05_expand_generic_notifications.py b/backend/alembic/versions/20260716_05_expand_generic_notifications.py new file mode 100644 index 00000000..64ff5751 --- /dev/null +++ b/backend/alembic/versions/20260716_05_expand_generic_notifications.py @@ -0,0 +1,63 @@ +"""Expand generic notifications and route desktop delivery through them. + +Revision ID: 20260716_05 +Revises: 20260716_04 +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + + +revision = "20260716_05" +down_revision = "20260716_04" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "notifications", + sa.Column("requires_action", sa.Boolean(), nullable=False, server_default=sa.true()), + ) + op.add_column( + "notifications", + sa.Column("due_at", sa.DateTime(timezone=True), nullable=True), + ) + op.alter_column("desktop_notification_deliveries", "distribution_id", nullable=True) + op.add_column( + "desktop_notification_deliveries", + sa.Column("notification_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + op.create_foreign_key( + "fk_desktop_notification_deliveries_notification", + "desktop_notification_deliveries", + "notifications", + ["notification_id"], + ["id"], + ondelete="CASCADE", + ) + op.create_unique_constraint( + "uq_desktop_notification_user_notification", + "desktop_notification_deliveries", + ["user_id", "notification_id"], + ) + + +def downgrade() -> None: + op.drop_constraint( + "uq_desktop_notification_user_notification", + "desktop_notification_deliveries", + type_="unique", + ) + op.drop_constraint( + "fk_desktop_notification_deliveries_notification", + "desktop_notification_deliveries", + type_="foreignkey", + ) + op.drop_column("desktop_notification_deliveries", "notification_id") + # Legacy rows always have a distribution id; rows created by the generic delivery path do not. + op.execute("DELETE FROM desktop_notification_deliveries WHERE distribution_id IS NULL") + op.alter_column("desktop_notification_deliveries", "distribution_id", nullable=False) + op.drop_column("notifications", "due_at") + op.drop_column("notifications", "requires_action") diff --git a/backend/app/api/v1/desktop_notifications.py b/backend/app/api/v1/desktop_notifications.py index b1940e84..5303da9e 100644 --- a/backend/app/api/v1/desktop_notifications.py +++ b/backend/app/api/v1/desktop_notifications.py @@ -75,12 +75,12 @@ async def acknowledge_notifications( ) -@router.post("/{distribution_id}/read", status_code=status.HTTP_204_NO_CONTENT) +@router.post("/{notification_id}/read", status_code=status.HTTP_204_NO_CONTENT) async def mark_notification_read( - distribution_id: uuid.UUID, + notification_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> None: await desktop_notification_service.mark_notification_read( - db, current_user.id, distribution_id + db, current_user.id, notification_id ) diff --git a/backend/app/api/v1/notifications.py b/backend/app/api/v1/notifications.py index 9e915a39..dc3fdb59 100644 --- a/backend/app/api/v1/notifications.py +++ b/backend/app/api/v1/notifications.py @@ -1,4 +1,3 @@ -import logging import uuid from fastapi import APIRouter, Depends, Response, status @@ -9,7 +8,6 @@ from app.schemas.notification import GeneralNotificationFeed, GeneralNotificatio from app.services import document_service, notification_service, project_reminder_service router = APIRouter() -logger = logging.getLogger(__name__) @router.get( @@ -40,20 +38,25 @@ async def list_notifications( ) async def list_general_notifications( study_id: uuid.UUID, + skip: int = 0, limit: int = 10, + category: str | None = None, + unread_only: bool = False, + requires_action: bool | None = None, db: AsyncSession = Depends(get_db_session), current_user=Depends(get_current_user), ) -> GeneralNotificationFeed: - try: - await project_reminder_service.sync_project_reminders(db, study_id, current_user) - except Exception: - await db.rollback() - logger.warning("Failed to synchronize legacy project reminders", exc_info=True) + # Fail closed: stale reminders may contain details for a permission that was just revoked. + await project_reminder_service.sync_project_reminders(db, study_id, current_user) return await notification_service.list_feed( db, study_id=study_id, recipient_id=current_user.id, + skip=skip, limit=limit, + category=category, + unread_only=unread_only, + requires_action=requires_action, ) diff --git a/backend/app/api/v1/visits.py b/backend/app/api/v1/visits.py index 553f1d70..1dc8e063 100644 --- a/backend/app/api/v1/visits.py +++ b/backend/app/api/v1/visits.py @@ -12,6 +12,7 @@ from app.crud import study as study_crud from app.crud import visit as visit_crud from app.schemas.subject import SubjectUpdate from app.schemas.visit import EarlyTerminationCreate, VisitCreate, VisitRead, VisitUpdate +from app.services import notification_service router = APIRouter() @@ -42,6 +43,17 @@ async def _ensure_subject_active(db: AsyncSession, subject) -> None: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="中心已停用") +async def _resolve_visit_reminders(db: AsyncSession, visit_ids: list[uuid.UUID]) -> None: + for visit_id in set(visit_ids): + await notification_service.resolve_source_notifications( + db, + source_type="SUBJECT_VISIT_WINDOW", + source_id=str(visit_id), + ) + if visit_ids: + await db.commit() + + @router.get( "/", response_model=list[VisitRead], @@ -145,6 +157,8 @@ async def create_early_termination( if subject.baseline_date and termination_in.termination_date < subject.baseline_date: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="提前终止日期不能早于基线/治疗日期") + existing_visit_ids = [visit.id for visit in await visit_crud.list_visits(db, subject_id)] + try: visit = await visit_crud.create_early_termination_visit( db, @@ -164,6 +178,7 @@ async def create_early_termination( drop_reason=reason, ), ) + await _resolve_visit_reminders(db, existing_visit_ids) await audit_crud.log_action( db, study_id=study_id, @@ -209,6 +224,8 @@ async def update_visit( old_status = visit.status updated = await visit_crud.update_visit(db, visit, visit_in) await subject_crud.sync_subject_status(db, subject) + if updated.actual_date is not None or updated.status in {"DONE", "CANCELLED"}: + await _resolve_visit_reminders(db, [updated.id]) detail = None if visit_in.status: detail = json.dumps( @@ -253,6 +270,7 @@ async def delete_visit( visit_detail = _visit_audit_detail("删除", subject, visit) await visit_crud.delete_visit(db, visit) await subject_crud.sync_subject_status(db, subject) + await _resolve_visit_reminders(db, [visit_id]) await audit_crud.log_action( db, study_id=study_id, diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 3ddee4d5..3762e9a7 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -51,6 +51,7 @@ class Settings(BaseSettings): MONITORING_RETENTION_INTERVAL_SECONDS: int = Field(default=86400, ge=60, le=604800) USER_LOGIN_ACTIVITY_RETENTION_DAYS: int = Field(default=180, ge=30, le=3650) USER_SESSION_ONLINE_SECONDS: int = Field(default=300, ge=60, le=3600) + NOTIFICATION_SYNC_INTERVAL_SECONDS: int = Field(default=300, ge=60, le=3600) ONLYOFFICE_ENABLED: bool = False ONLYOFFICE_JWT_SECRET: Optional[str] = None ONLYOFFICE_INTERNAL_URL: str = "http://onlyoffice" diff --git a/backend/app/main.py b/backend/app/main.py index 2e74be0c..9d6c85d3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -20,6 +20,7 @@ from app.crud.user import ensure_admin_exists from app.db.base import Base from app.db.session import SessionLocal, engine from app.services.visit_scheduler import run_daily_lost_visit_job +from app.services.notification_scheduler import run_notification_sync_job from app.services.permission_log_writer import start_log_writer, stop_log_writer from app.services.permission_metric_aggregator import run_hourly_metric_aggregation from app.services.source_location_aggregator import run_hourly_source_location_aggregation @@ -67,6 +68,7 @@ async def lifespan(_: FastAPI): await conn.run_sync(Base.metadata.create_all) async with SessionLocal() as session: await ensure_admin_exists(session) + notification_scheduler_task = asyncio.create_task(run_notification_sync_job(stop_event)) retention_task = asyncio.create_task(run_monitoring_retention(stop_event)) yield stop_event.set() @@ -75,6 +77,7 @@ async def lifespan(_: FastAPI): await scheduler_task await aggregator_task await source_location_aggregator_task + await notification_scheduler_task await retention_task diff --git a/backend/app/models/desktop_notification.py b/backend/app/models/desktop_notification.py index 28e083fd..c577dfdf 100644 --- a/backend/app/models/desktop_notification.py +++ b/backend/app/models/desktop_notification.py @@ -28,6 +28,7 @@ class DesktopNotificationDelivery(Base): __tablename__ = "desktop_notification_deliveries" __table_args__ = ( UniqueConstraint("user_id", "distribution_id", name="uq_desktop_notification_user_distribution"), + UniqueConstraint("user_id", "notification_id", name="uq_desktop_notification_user_notification"), Index("ix_desktop_notification_claim", "user_id", "delivered_at", "claimed_at"), ) @@ -35,8 +36,11 @@ class DesktopNotificationDelivery(Base): user_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False ) - distribution_id: Mapped[uuid.UUID] = mapped_column( - UUID(as_uuid=True), ForeignKey("distributions.id", ondelete="CASCADE"), nullable=False + distribution_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("distributions.id", ondelete="CASCADE"), nullable=True + ) + notification_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), ForeignKey("notifications.id", ondelete="CASCADE"), nullable=True ) claim_token: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), nullable=True) claimed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py index 0d350757..ca3d5303 100644 --- a/backend/app/models/notification.py +++ b/backend/app/models/notification.py @@ -3,7 +3,7 @@ from __future__ import annotations import uuid from datetime import datetime -from sqlalchemy import DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func +from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column @@ -41,6 +41,10 @@ class Notification(Base): source_id: Mapped[str] = mapped_column(String(100), nullable=False) source_version: Mapped[str | None] = mapped_column(String(100), nullable=True) dedupe_key: Mapped[str] = mapped_column(String(255), nullable=False) + requires_action: Mapped[bool] = mapped_column( + Boolean, nullable=False, default=True, server_default="true" + ) + due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py index cad32d48..d76c83ee 100644 --- a/backend/app/schemas/notification.py +++ b/backend/app/schemas/notification.py @@ -37,17 +37,6 @@ class DesktopNotificationClaimRequest(BaseModel): limit: int = 20 -class DesktopNotificationClaimResponse(BaseModel): - claim_token: uuid.UUID | None = None - lease_expires_at: datetime | None = None - items: list[NotificationItem] - - -class DesktopNotificationAckRequest(BaseModel): - claim_token: uuid.UUID - delivered_ids: list[uuid.UUID] - - class GeneralNotificationRead(BaseModel): id: uuid.UUID study_id: uuid.UUID @@ -59,12 +48,27 @@ class GeneralNotificationRead(BaseModel): action_path: str | None = None source_type: str source_id: str + requires_action: bool = True + due_at: datetime | None = None read_at: datetime | None = None + resolved_at: datetime | None = None created_at: datetime model_config = ConfigDict(from_attributes=True) +class DesktopNotificationClaimResponse(BaseModel): + claim_token: uuid.UUID | None = None + lease_expires_at: datetime | None = None + items: list[GeneralNotificationRead] + + +class DesktopNotificationAckRequest(BaseModel): + claim_token: uuid.UUID + delivered_ids: list[uuid.UUID] + + class GeneralNotificationFeed(BaseModel): unread_count: int + total_count: int items: list[GeneralNotificationRead] diff --git a/backend/app/services/collaboration_service.py b/backend/app/services/collaboration_service.py index 2d342172..2eae4ea5 100644 --- a/backend/app/services/collaboration_service.py +++ b/backend/app/services/collaboration_service.py @@ -1139,6 +1139,7 @@ async def create_edit_request( source_type="COLLABORATION_EDIT_REQUEST", source_id=str(request.id), dedupe_key=f"collaboration-edit-request:{request.id}", + source_version="PENDING", ) await db.commit() await db.refresh(request) diff --git a/backend/app/services/desktop_notification_service.py b/backend/app/services/desktop_notification_service.py index 3171178e..58fd7c8b 100644 --- a/backend/app/services/desktop_notification_service.py +++ b/backend/app/services/desktop_notification_service.py @@ -10,12 +10,9 @@ from app.models.desktop_notification import ( DesktopNotificationDelivery, DesktopNotificationSubscription, ) -from app.models.distribution import Distribution, DistributionStatus, DistributionTargetType -from app.models.document import Document -from app.models.document_version import DocumentVersion -from app.models.study import Study +from app.models.notification import Notification from app.models.study_member import StudyMember -from app.schemas.notification import NotificationItem +from app.models.user import User CLAIM_LEASE = timedelta(minutes=5) @@ -47,46 +44,28 @@ async def set_subscription( def _eligible_query(user_id: uuid.UUID, enabled_at: datetime, lease_cutoff: datetime): - role_target_exists = exists( + active_membership = exists( select(StudyMember.id).where( - StudyMember.study_id == Document.trial_id, + StudyMember.study_id == Notification.study_id, StudyMember.user_id == user_id, StudyMember.is_active.is_(True), - StudyMember.role_in_study == Distribution.target_id, ) ) - target_matches = or_( - and_( - Distribution.target_type == DistributionTargetType.USER, - Distribution.target_id == str(user_id), - ), - and_( - Distribution.target_type == DistributionTargetType.ROLE, - role_target_exists, - ), - ) return ( - select( - Distribution, - Document, - DocumentVersion, - Study, - DesktopNotificationDelivery, - ) - .join(Document, Distribution.document_id == Document.id) - .join(DocumentVersion, Distribution.version_id == DocumentVersion.id) - .join(Study, Document.trial_id == Study.id) + select(Notification, DesktopNotificationDelivery) .outerjoin( DesktopNotificationDelivery, and_( - DesktopNotificationDelivery.distribution_id == Distribution.id, + DesktopNotificationDelivery.notification_id == Notification.id, DesktopNotificationDelivery.user_id == user_id, ), ) .where( - Distribution.status == DistributionStatus.ACTIVE, - Distribution.created_at >= enabled_at, - target_matches, + Notification.recipient_id == user_id, + Notification.created_at >= enabled_at, + Notification.resolved_at.is_(None), + Notification.read_at.is_(None), + active_membership, or_( DesktopNotificationDelivery.id.is_(None), and_( @@ -98,8 +77,8 @@ def _eligible_query(user_id: uuid.UUID, enabled_at: datetime, lease_cutoff: date ), ), ) - .order_by(Distribution.created_at.asc()) - .with_for_update(of=Distribution, skip_locked=True) + .order_by(Notification.created_at.asc()) + .with_for_update(of=Notification, skip_locked=True) ) @@ -107,45 +86,46 @@ async def claim_notifications( db: AsyncSession, user_id: uuid.UUID, limit: int, -) -> tuple[uuid.UUID | None, datetime | None, list[NotificationItem]]: +) -> tuple[uuid.UUID | None, datetime | None, list[Notification]]: subscription = await get_subscription(db, user_id) if not subscription or not subscription.enabled or not subscription.enabled_at: return None, None, [] + # Reconcile every active project before selecting desktop deliveries so permission + # changes fail closed and time-based reminders do not depend on opening the web Feed. + user = await db.get(User, user_id) + if user is None or not user.is_active: + return None, None, [] + study_ids = (await db.scalars( + select(StudyMember.study_id).where( + StudyMember.user_id == user_id, + StudyMember.is_active.is_(True), + ) + )).all() + from app.services.project_reminder_service import sync_project_reminders + + for study_id in set(study_ids): + await sync_project_reminders(db, study_id, user) + now = datetime.now(timezone.utc) token = uuid.uuid4() rows = ( await db.execute( - _eligible_query(user_id, subscription.enabled_at, now - CLAIM_LEASE).limit(max(1, min(limit, 50))) + _eligible_query(user_id, subscription.enabled_at, now - CLAIM_LEASE) + .limit(max(1, min(limit, 50))) ) ).all() - items: list[NotificationItem] = [] - for distribution, document, version, study, delivery in rows: + items: list[Notification] = [] + for notification, delivery in rows: if delivery is None: delivery = DesktopNotificationDelivery( user_id=user_id, - distribution_id=distribution.id, + notification_id=notification.id, ) db.add(delivery) delivery.claim_token = token delivery.claimed_at = now - items.append( - NotificationItem( - id=distribution.id, - document_id=document.id, - version_id=version.id, - document_title=document.title, - document_no=document.doc_no, - version_no=version.version_no, - change_summary=version.change_summary, - effective_at=version.effective_at, - created_at=distribution.created_at, - study_id=study.id, - study_name=study.name, - delivered_at=delivery.delivered_at, - read_at=delivery.read_at, - ) - ) + items.append(notification) await db.commit() if not items: return None, None, [] @@ -164,7 +144,7 @@ async def acknowledge_notifications( .where( DesktopNotificationDelivery.user_id == user_id, DesktopNotificationDelivery.claim_token == claim_token, - DesktopNotificationDelivery.distribution_id.in_(delivered_ids), + DesktopNotificationDelivery.notification_id.in_(delivered_ids), ) .values(delivered_at=datetime.now(timezone.utc)) ) @@ -174,21 +154,17 @@ async def acknowledge_notifications( async def mark_notification_read( db: AsyncSession, user_id: uuid.UUID, - distribution_id: uuid.UUID, + notification_id: uuid.UUID, ) -> None: - delivery = ( - await db.execute( - select(DesktopNotificationDelivery).where( - DesktopNotificationDelivery.user_id == user_id, - DesktopNotificationDelivery.distribution_id == distribution_id, - ) - ) - ).scalar_one_or_none() - if delivery is None: - delivery = DesktopNotificationDelivery( - user_id=user_id, - distribution_id=distribution_id, - ) - db.add(delivery) - delivery.read_at = datetime.now(timezone.utc) + item = await db.scalar(select(Notification).where( + Notification.id == notification_id, + Notification.recipient_id == user_id, + Notification.resolved_at.is_(None), + )) + if item is None: + return + now = datetime.now(timezone.utc) + item.read_at = item.read_at or now + if not item.requires_action: + item.resolved_at = item.resolved_at or now await db.commit() diff --git a/backend/app/services/document_service.py b/backend/app/services/document_service.py index 4316fb11..a97e56ef 100644 --- a/backend/app/services/document_service.py +++ b/backend/app/services/document_service.py @@ -11,7 +11,7 @@ from urllib.parse import quote import aiofiles from fastapi import HTTPException, UploadFile, status from fastapi.responses import FileResponse -from sqlalchemy import and_, delete as sa_delete, or_, select, update as sa_update +from sqlalchemy import String, and_, cast, delete as sa_delete, or_, select, update as sa_update from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import get_cra_site_scope @@ -32,6 +32,7 @@ from app.models.distribution import Distribution, DistributionStatus, Distributi from app.models.document import Document, DocumentScopeType, DocumentStatus from app.models.document_version import DocumentVersion, DocumentVersionStatus from app.models.desktop_notification import DesktopNotificationDelivery +from app.models.notification import Notification from app.models.study import Study from app.schemas.acknowledgement import AcknowledgementCreate from app.schemas.distribution import DistributionCreate, DistributionRead, DistributionStats @@ -39,6 +40,7 @@ from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary from app.schemas.document_version import DocumentVersionRead, DocumentVersionSummary from app.schemas.notification import NotificationItem from app.schemas.user import UserDisplay +from app.services import notification_service UPLOAD_ROOT = Path(__file__).resolve().parent.parent / "uploads" / "documents" @@ -688,6 +690,10 @@ async def create_acknowledgement( if distribution.target_type == DistributionTargetType.ROLE: if distribution.target_id not in ("ADMIN" if is_system_admin(current_user) else "", getattr(membership, "role_in_study", "")): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不在分发范围内") + if distribution.target_type == DistributionTargetType.SITE and not is_system_admin(current_user): + site_ids = await site_crud.list_ids_by_contact_user(db, doc.trial_id, current_user.id) + if distribution.target_id not in {str(site_id) for site_id in site_ids}: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不在分发中心范围内") if payload.ack_type != AcknowledgementType.RECEIVED: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="仅支持已接收回执") @@ -722,6 +728,12 @@ async def create_acknowledgement( operator_role=await get_operator_role_label(db, doc.trial_id, current_user), ) ) + await notification_service.resolve_source_notifications( + db, + source_type="DOCUMENT_DISTRIBUTION", + source_id=str(distribution.id), + recipient_id=current_user.id, + ) await db.commit() await db.refresh(ack) return ack @@ -812,10 +824,18 @@ async def list_distribution_notifications( .join(DocumentVersion, Distribution.version_id == DocumentVersion.id) .join(Document, Distribution.document_id == Document.id) .join(Study, Document.trial_id == Study.id) + .outerjoin( + Notification, + and_( + Notification.source_type == "DOCUMENT_DISTRIBUTION", + Notification.source_id == cast(Distribution.id, String), + Notification.recipient_id == current_user.id, + ), + ) .outerjoin( DesktopNotificationDelivery, and_( - DesktopNotificationDelivery.distribution_id == Distribution.id, + DesktopNotificationDelivery.notification_id == Notification.id, DesktopNotificationDelivery.user_id == current_user.id, ), ) diff --git a/backend/app/services/notification_scheduler.py b/backend/app/services/notification_scheduler.py new file mode 100644 index 00000000..3178ba9a --- /dev/null +++ b/backend/app/services/notification_scheduler.py @@ -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") diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index dd938e45..a2dc8926 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -8,10 +8,24 @@ from fastapi import HTTPException, status from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession +from app.models.desktop_notification import DesktopNotificationDelivery from app.models.notification import Notification from app.schemas.notification import GeneralNotificationFeed +async def _requeue_desktop_delivery(db: AsyncSession, notification_id: uuid.UUID) -> None: + """Allow an escalated/reopened reminder to be delivered again by the desktop channel.""" + await db.execute( + update(DesktopNotificationDelivery) + .where(DesktopNotificationDelivery.notification_id == notification_id) + .values( + claim_token=None, + claimed_at=None, + delivered_at=None, + ) + ) + + async def create_recipient_notifications( db: AsyncSession, *, @@ -25,6 +39,9 @@ async def create_recipient_notifications( source_type: str, source_id: str, dedupe_key: str, + source_version: str | None = None, + requires_action: bool = True, + due_at: datetime | None = None, ) -> None: recipients = set(recipient_ids) if not recipients: @@ -46,10 +63,78 @@ async def create_recipient_notifications( action_path=action_path, source_type=source_type, source_id=source_id, + source_version=source_version, dedupe_key=dedupe_key, + requires_action=requires_action, + due_at=due_at, )) +async def sync_state_notification( + db: AsyncSession, + *, + study_id: uuid.UUID, + recipient_id: uuid.UUID, + category: str, + priority: str, + title: str, + message: str, + action_path: str, + source_type: str, + source_id: str, + source_version: str, + active: bool, + due_at: datetime | None = None, + requires_action: bool = True, + dedupe_key: str | None = None, +) -> None: + """Synchronize one actionable business state into a recipient reminder.""" + dedupe_key = dedupe_key or f"state:{source_type}:{source_id}" + item = await db.scalar(select(Notification).where( + Notification.recipient_id == recipient_id, + Notification.dedupe_key == dedupe_key, + )) + now = datetime.now(timezone.utc) + if not active: + if item and item.resolved_at is None: + item.resolved_at = now + item.read_at = item.read_at or now + return + if item is None: + db.add(Notification( + study_id=study_id, + recipient_id=recipient_id, + category=category, + priority=priority, + title=title, + message=message, + action_path=action_path, + source_type=source_type, + source_id=source_id, + source_version=source_version, + dedupe_key=dedupe_key, + requires_action=requires_action, + due_at=due_at, + )) + return + + should_notify_again = item.resolved_at is not None or item.source_version != source_version + item.study_id = study_id + item.category = category + item.priority = priority + item.title = title + item.message = message + item.action_path = action_path + item.source_version = source_version + item.requires_action = requires_action + item.due_at = due_at + if should_notify_again: + item.read_at = None + item.created_at = now + await _requeue_desktop_delivery(db, item.id) + item.resolved_at = None + + async def sync_aggregate_notification( db: AsyncSession, *, @@ -63,6 +148,7 @@ async def sync_aggregate_notification( source_type: str, source_id: str, count: int, + due_at: datetime | None = None, ) -> None: dedupe_key = f"aggregate:{source_type}:{source_id}" item = await db.scalar(select(Notification).where( @@ -89,32 +175,83 @@ async def sync_aggregate_notification( source_id=source_id, source_version=version, dedupe_key=dedupe_key, + requires_action=True, + due_at=due_at, )) return previous_count = int(item.source_version or 0) + item.category = category item.priority = priority item.title = title item.message = message item.action_path = action_path item.source_version = version + item.due_at = due_at if item.resolved_at is not None or count > previous_count: item.read_at = None item.created_at = now + await _requeue_desktop_delivery(db, item.id) item.resolved_at = None +async def resolve_missing_recipient_sources( + db: AsyncSession, + *, + study_id: uuid.UUID, + recipient_id: uuid.UUID, + source_type: str, + active_source_ids: set[str], +) -> None: + filters = [ + Notification.study_id == study_id, + Notification.recipient_id == recipient_id, + Notification.source_type == source_type, + Notification.resolved_at.is_(None), + ] + if active_source_ids: + filters.append(Notification.source_id.not_in(active_source_ids)) + now = datetime.now(timezone.utc) + await db.execute( + update(Notification) + .where(*filters) + .values(resolved_at=now, read_at=func.coalesce(Notification.read_at, now)) + ) + + async def resolve_source_notifications( db: AsyncSession, *, source_type: str, source_id: str, + recipient_id: uuid.UUID | None = None, +) -> None: + filters = [ + Notification.source_type == source_type, + Notification.source_id == source_id, + Notification.resolved_at.is_(None), + ] + if recipient_id is not None: + filters.append(Notification.recipient_id == recipient_id) + now = datetime.now(timezone.utc) + await db.execute( + update(Notification) + .where(*filters) + .values(resolved_at=now, read_at=func.coalesce(Notification.read_at, now)) + ) + + +async def resolve_recipient_study_notifications( + db: AsyncSession, + *, + study_id: uuid.UUID, + recipient_id: uuid.UUID, ) -> None: now = datetime.now(timezone.utc) await db.execute( update(Notification) .where( - Notification.source_type == source_type, - Notification.source_id == source_id, + Notification.study_id == study_id, + Notification.recipient_id == recipient_id, Notification.resolved_at.is_(None), ) .values(resolved_at=now, read_at=func.coalesce(Notification.read_at, now)) @@ -126,7 +263,11 @@ async def list_feed( *, study_id: uuid.UUID, recipient_id: uuid.UUID, + skip: int = 0, limit: int = 10, + category: str | None = None, + unread_only: bool = False, + requires_action: bool | None = None, ) -> GeneralNotificationFeed: active_filter = ( Notification.study_id == study_id, @@ -136,13 +277,36 @@ async def list_feed( unread_count = int(await db.scalar( select(func.count(Notification.id)).where(*active_filter, Notification.read_at.is_(None)) ) or 0) + + filters = [ + Notification.study_id == study_id, + Notification.recipient_id == recipient_id, + Notification.resolved_at.is_(None), + ] + if category: + filters.append(Notification.category == category) + if unread_only: + filters.append(Notification.read_at.is_(None)) + if requires_action is not None: + filters.append(Notification.requires_action.is_(requires_action)) + total_count = int(await db.scalar( + select(func.count(Notification.id)).where(*filters) + ) or 0) items = (await db.scalars( select(Notification) - .where(*active_filter) - .order_by(Notification.read_at.is_not(None), Notification.created_at.desc()) - .limit(max(1, min(limit, 50))) + .where(*filters) + .order_by( + Notification.read_at.is_not(None), + Notification.created_at.desc(), + ) + .offset(max(0, skip)) + .limit(max(1, min(limit, 100))) )).all() - return GeneralNotificationFeed(unread_count=unread_count, items=list(items)) + return GeneralNotificationFeed( + unread_count=unread_count, + total_count=total_count, + items=list(items), + ) async def mark_read( @@ -161,7 +325,10 @@ async def mark_read( if item is None: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="通知不存在") if item.read_at is None: - item.read_at = datetime.now(timezone.utc) + now = datetime.now(timezone.utc) + item.read_at = now + if not item.requires_action: + item.resolved_at = now await db.commit() await db.refresh(item) return item @@ -173,6 +340,7 @@ async def mark_all_read( study_id: uuid.UUID, recipient_id: uuid.UUID, ) -> None: + now = datetime.now(timezone.utc) await db.execute( update(Notification) .where( @@ -180,7 +348,19 @@ async def mark_all_read( Notification.recipient_id == recipient_id, Notification.resolved_at.is_(None), Notification.read_at.is_(None), + Notification.requires_action.is_(False), ) - .values(read_at=datetime.now(timezone.utc)) + .values(read_at=now, resolved_at=now) + ) + await db.execute( + update(Notification) + .where( + Notification.study_id == study_id, + Notification.recipient_id == recipient_id, + Notification.resolved_at.is_(None), + Notification.read_at.is_(None), + Notification.requires_action.is_(True), + ) + .values(read_at=now) ) await db.commit() diff --git a/backend/app/services/project_reminder_service.py b/backend/app/services/project_reminder_service.py index 46bf610f..6529485e 100644 --- a/backend/app/services/project_reminder_service.py +++ b/backend/app/services/project_reminder_service.py @@ -1,61 +1,152 @@ from __future__ import annotations import uuid +from datetime import date, datetime, time, timedelta, timezone +from zoneinfo import ZoneInfo +from sqlalchemy import and_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import get_cra_site_scope, is_system_admin from app.core.project_permissions import role_has_api_permission -from app.crud import ae as ae_crud from app.crud import member as member_crud -from app.crud import monitoring_visit_issue as monitoring_issue_crud +from app.crud import site as site_crud +from app.models.acknowledgement import Acknowledgement, AcknowledgementType +from app.models.ae import AdverseEvent +from app.models.collaboration import CollaborationEditRequest, CollaborationFile, CollaborationMember +from app.models.distribution import Distribution, DistributionStatus, DistributionTargetType +from app.models.document import Document, DocumentStatus +from app.models.document_version import DocumentVersion +from app.models.milestone import Milestone +from app.models.monitoring_visit_issue import MonitoringVisitIssue +from app.models.site import Site +from app.models.subject import Subject +from app.models.user import User +from app.models.visit import Visit from app.services import notification_service +BUSINESS_TIMEZONE = ZoneInfo("Asia/Shanghai") +RISK_DUE_SOON_DAYS = 3 +MILESTONE_DUE_SOON_DAYS = 7 +VISIT_WINDOW_DUE_SOON_DAYS = 3 -async def sync_project_reminders(db: AsyncSession, study_id: uuid.UUID, user) -> None: - membership = await member_crud.get_member(db, study_id, user.id) - if not is_system_admin(user) and (not membership or not membership.is_active): - return - role = membership.role_in_study if membership else "" + +def _date_due_at(value: date | None) -> datetime | None: + if value is None: + return None + return datetime.combine(value, time.max, tzinfo=BUSINESS_TIMEZONE).astimezone(timezone.utc) + + +def _as_utc(value: datetime | None) -> datetime | None: + if value is None: + return None + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +async def _count_and_earliest(db: AsyncSession, model, *filters): + return (await db.execute( + select(func.count(model.id), func.min(model.report_due_date if model is AdverseEvent else model.due_at)) + .where(*filters) + )).one() + + +async def _sync_risk_reminders( + db: AsyncSession, + *, + study_id: uuid.UUID, + user, + role: str, + current_time: datetime, +) -> None: can_read_aes = is_system_admin(user) or await role_has_api_permission( db, study_id, role, "subject_aes:read" ) can_read_monitoring = is_system_admin(user) or await role_has_api_permission( db, study_id, role, "monitoring_issues:read" ) + cra_scope = await get_cra_site_scope(db, study_id, user) + site_ids = cra_scope[0] if cra_scope else None + today = current_time.astimezone(BUSINESS_TIMEZONE).date() + due_soon_date = today + timedelta(days=RISK_DUE_SOON_DAYS) + ae_scope = [AdverseEvent.study_id == study_id, AdverseEvent.status != "CLOSED"] + if site_ids is not None: + ae_scope.append(AdverseEvent.site_id.in_(site_ids)) overdue_aes = 0 + overdue_ae_due = None + due_soon_aes = 0 + due_soon_ae_due = None if can_read_aes: - cra_scope = await get_cra_site_scope(db, study_id, user) - items = await ae_crud.list_ae( + overdue_aes, overdue_ae_due = await _count_and_earliest( db, - study_id, - overdue=True, - site_ids=cra_scope[0] if cra_scope else None, + AdverseEvent, + *ae_scope, + AdverseEvent.report_due_date.is_not(None), + AdverseEvent.report_due_date < today, + ) + due_soon_aes, due_soon_ae_due = await _count_and_earliest( + db, + AdverseEvent, + *ae_scope, + AdverseEvent.report_due_date >= today, + AdverseEvent.report_due_date <= due_soon_date, ) - overdue_aes = len(items) await notification_service.sync_aggregate_notification( db, study_id=study_id, recipient_id=user.id, category="RISK_OVERDUE_AE", - priority="HIGH", + priority="URGENT", title="逾期 AE 待处理", message=f"当前有 {overdue_aes} 条逾期 AE 需要跟进", action_path="/risk-issues/sae", source_type="RISK_OVERDUE_AE", source_id=str(study_id), - count=overdue_aes if can_read_aes else 0, + count=int(overdue_aes) if can_read_aes else 0, + due_at=_date_due_at(overdue_ae_due), + ) + await notification_service.sync_aggregate_notification( + db, + study_id=study_id, + recipient_id=user.id, + category="RISK_AE_DUE_SOON", + priority="HIGH", + title="AE 上报时限临近", + message=f"未来 {RISK_DUE_SOON_DAYS} 天内有 {due_soon_aes} 条 AE 到达上报时限", + action_path="/risk-issues/sae", + source_type="RISK_AE_DUE_SOON", + source_id=str(study_id), + count=int(due_soon_aes) if can_read_aes else 0, + due_at=_date_due_at(due_soon_ae_due), ) + monitoring_scope = [ + MonitoringVisitIssue.study_id == study_id, + MonitoringVisitIssue.status == "OPEN", + MonitoringVisitIssue.due_at.is_not(None), + ] + if site_ids is not None: + monitoring_scope.append(MonitoringVisitIssue.site_id.in_(site_ids)) overdue_monitoring = 0 + overdue_monitoring_due = None + due_soon_monitoring = 0 + due_soon_monitoring_due = None if can_read_monitoring: - overdue_monitoring = len(await monitoring_issue_crud.list_issues( + overdue_monitoring, overdue_monitoring_due = await _count_and_earliest( db, - study_id, - overdue=True, - limit=2000, - )) + MonitoringVisitIssue, + *monitoring_scope, + MonitoringVisitIssue.due_at < current_time, + ) + due_soon_monitoring, due_soon_monitoring_due = await _count_and_earliest( + db, + MonitoringVisitIssue, + *monitoring_scope, + MonitoringVisitIssue.due_at >= current_time, + MonitoringVisitIssue.due_at <= current_time + timedelta(days=RISK_DUE_SOON_DAYS), + ) await notification_service.sync_aggregate_notification( db, study_id=study_id, @@ -67,6 +158,384 @@ async def sync_project_reminders(db: AsyncSession, study_id: uuid.UUID, user) -> action_path="/risk-issues/monitoring-visits", source_type="RISK_OVERDUE_MONITORING", source_id=str(study_id), - count=overdue_monitoring if can_read_monitoring else 0, + count=int(overdue_monitoring) if can_read_monitoring else 0, + due_at=_as_utc(overdue_monitoring_due), + ) + await notification_service.sync_aggregate_notification( + db, + study_id=study_id, + recipient_id=user.id, + category="RISK_MONITORING_DUE_SOON", + priority="HIGH", + title="监查问题整改时限临近", + message=f"未来 {RISK_DUE_SOON_DAYS} 天内有 {due_soon_monitoring} 条监查问题到期", + action_path="/risk-issues/monitoring-visits", + source_type="RISK_MONITORING_DUE_SOON", + source_id=str(study_id), + count=int(due_soon_monitoring) if can_read_monitoring else 0, + due_at=_as_utc(due_soon_monitoring_due), + ) + + +async def _sync_document_distribution_reminders( + db: AsyncSession, + *, + study_id: uuid.UUID, + user, + role: str, + current_time: datetime, +) -> None: + can_read_documents = is_system_admin(user) or await role_has_api_permission( + db, study_id, role, "documents:read" + ) + if not can_read_documents: + await notification_service.resolve_missing_recipient_sources( + db, + study_id=study_id, + recipient_id=user.id, + source_type="DOCUMENT_DISTRIBUTION", + active_source_ids=set(), + ) + return + site_ids = await site_crud.list_ids_by_contact_user(db, study_id, user.id) + target_filters = [ + and_( + Distribution.target_type == DistributionTargetType.USER, + Distribution.target_id == str(user.id), + ), + and_( + Distribution.target_type == DistributionTargetType.ROLE, + Distribution.target_id == role, + ), + ] + if site_ids: + target_filters.append(and_( + Distribution.target_type == DistributionTargetType.SITE, + Distribution.target_id.in_({str(site_id) for site_id in site_ids}), + )) + rows = (await db.execute( + select(Distribution, Document, DocumentVersion) + .join(Document, Distribution.document_id == Document.id) + .join(DocumentVersion, Distribution.version_id == DocumentVersion.id) + .outerjoin( + Acknowledgement, + and_( + Acknowledgement.distribution_id == Distribution.id, + Acknowledgement.user_id == user.id, + Acknowledgement.ack_type == AcknowledgementType.RECEIVED, + ), + ) + .where( + Document.trial_id == study_id, + Document.status == DocumentStatus.ACTIVE, + Distribution.status == DistributionStatus.ACTIVE, + Acknowledgement.id.is_(None), + or_(*target_filters), + ) + )).all() + + active_source_ids: set[str] = set() + for distribution, document, version in rows: + source_id = str(distribution.id) + active_source_ids.add(source_id) + distribution_due_at = _as_utc(distribution.due_at) + if distribution_due_at and distribution_due_at < current_time: + stage = "OVERDUE" + category = "DOCUMENT_ACK_OVERDUE" + priority = "URGENT" + title = "文件回执已逾期" + elif distribution_due_at and distribution_due_at <= current_time + timedelta(days=RISK_DUE_SOON_DAYS): + stage = "DUE_SOON" + category = "DOCUMENT_ACK_DUE_SOON" + priority = "HIGH" + title = "文件回执即将到期" + else: + stage = "PENDING" + category = "DOCUMENT_DISTRIBUTION" + priority = "NORMAL" + title = "有新的文件版本待接收" + due_version = distribution_due_at.isoformat() if distribution_due_at else "none" + await notification_service.sync_state_notification( + db, + study_id=study_id, + recipient_id=user.id, + category=category, + priority=priority, + title=title, + message=f"“{document.title}” {version.version_no} 已分发,请完成接收回执", + action_path=f"/documents/{document.id}", + source_type="DOCUMENT_DISTRIBUTION", + source_id=source_id, + source_version=f"{stage}:{due_version}", + active=True, + due_at=distribution_due_at, + ) + await notification_service.resolve_missing_recipient_sources( + db, + study_id=study_id, + recipient_id=user.id, + source_type="DOCUMENT_DISTRIBUTION", + active_source_ids=active_source_ids, + ) + + +async def _sync_milestone_reminders( + db: AsyncSession, + *, + study_id: uuid.UUID, + user, + role: str, + current_time: datetime, +) -> None: + can_read = is_system_admin(user) or await role_has_api_permission( + db, study_id, role, "project_milestones:read" + ) + active_source_ids: set[str] = set() + if can_read: + today = current_time.astimezone(BUSINESS_TIMEZONE).date() + due_soon_date = today + timedelta(days=MILESTONE_DUE_SOON_DAYS) + milestones = (await db.scalars( + select(Milestone).where( + Milestone.study_id == study_id, + Milestone.owner_id == user.id, + Milestone.status != "DONE", + ) + )).all() + for milestone in milestones: + due_date = milestone.adjusted_end_date or milestone.planned_date + if due_date is None or due_date > due_soon_date: + continue + source_id = str(milestone.id) + active_source_ids.add(source_id) + overdue = due_date < today + await notification_service.sync_state_notification( + db, + study_id=study_id, + recipient_id=user.id, + category="MILESTONE_OVERDUE" if overdue else "MILESTONE_DUE_SOON", + priority="HIGH" if overdue else "NORMAL", + title="项目里程碑已逾期" if overdue else "项目里程碑即将到期", + message=f"“{milestone.name}”计划日期为 {due_date.isoformat()}", + action_path="/project/milestones", + source_type="PROJECT_MILESTONE", + source_id=source_id, + source_version=f"{'OVERDUE' if overdue else 'DUE_SOON'}:{due_date.isoformat()}:{milestone.status}", + active=True, + due_at=_date_due_at(due_date), + ) + await notification_service.resolve_missing_recipient_sources( + db, + study_id=study_id, + recipient_id=user.id, + source_type="PROJECT_MILESTONE", + active_source_ids=active_source_ids, + ) + + +async def _sync_visit_window_reminders( + db: AsyncSession, + *, + study_id: uuid.UUID, + user, + role: str, + current_time: datetime, +) -> None: + """Project active visit windows to the members who can actually maintain them.""" + can_manage_visits = is_system_admin(user) or await role_has_api_permission( + db, study_id, role, "visits:update" + ) + active_source_ids: set[str] = set() + if can_manage_visits: + cra_scope = await get_cra_site_scope(db, study_id, user) + site_ids = cra_scope[0] if cra_scope else None + filters = [ + Visit.study_id == study_id, + Visit.actual_date.is_(None), + Visit.status.not_in(["DONE", "CANCELLED"]), + Subject.status.not_in(["COMPLETED", "DROPPED"]), + Site.is_active.is_(True), + ] + if site_ids is not None: + filters.append(Subject.site_id.in_(site_ids)) + rows = (await db.execute( + select(Visit, Subject) + .join(Subject, Subject.id == Visit.subject_id) + .join(Site, Site.id == Subject.site_id) + .where(*filters) + )).all() + + today = current_time.astimezone(BUSINESS_TIMEZONE).date() + due_soon_date = today + timedelta(days=VISIT_WINDOW_DUE_SOON_DAYS) + for visit, subject in rows: + window_start = visit.window_start or visit.planned_date + window_end = visit.window_end or visit.planned_date + if window_start is None or window_end is None or window_start > window_end: + continue + + if window_end < today: + category = "VISIT_WINDOW_MISSED" + priority = "HIGH" + title = "受试者访视已错过窗口" + message = "一个受试者访视已错过计划窗口,请进入详情核对并处理" + stage = "MISSED" + elif window_start <= due_soon_date: + category = "VISIT_WINDOW_DUE_SOON" + priority = "NORMAL" + title = "受试者访视窗口临近" + message = "一个受试者访视已进入近期窗口,请及时核对访视安排" + stage = "DUE_SOON" + else: + continue + + source_id = str(visit.id) + active_source_ids.add(source_id) + await notification_service.sync_state_notification( + db, + study_id=study_id, + recipient_id=user.id, + category=category, + priority=priority, + title=title, + message=message, + action_path=f"/subjects/{subject.id}", + source_type="SUBJECT_VISIT_WINDOW", + source_id=source_id, + source_version=f"{stage}:{window_start.isoformat()}:{window_end.isoformat()}", + active=True, + due_at=_date_due_at(window_end), + ) + await notification_service.resolve_missing_recipient_sources( + db, + study_id=study_id, + recipient_id=user.id, + source_type="SUBJECT_VISIT_WINDOW", + active_source_ids=active_source_ids, + ) + + +async def _sync_collaboration_edit_request_reminders( + db: AsyncSession, + *, + study_id: uuid.UUID, + user, + role: str, +) -> None: + can_read = is_system_admin(user) or await role_has_api_permission( + db, study_id, role, "collaboration:read" + ) + if not can_read: + await notification_service.resolve_missing_recipient_sources( + db, + study_id=study_id, + recipient_id=user.id, + source_type="COLLABORATION_EDIT_REQUEST", + active_source_ids=set(), + ) + return + manages_file = or_( + CollaborationFile.owner_id == user.id, + select(CollaborationMember.id).where( + CollaborationMember.file_id == CollaborationFile.id, + CollaborationMember.user_id == user.id, + CollaborationMember.role == "MANAGER", + ).exists(), + ) + rows = (await db.execute( + select(CollaborationEditRequest, CollaborationFile, User) + .join(CollaborationFile, CollaborationFile.id == CollaborationEditRequest.file_id) + .join(User, User.id == CollaborationEditRequest.requester_id) + .where( + CollaborationFile.study_id == study_id, + CollaborationFile.status == "ACTIVE", + CollaborationFile.deleted_at.is_(None), + CollaborationEditRequest.status == "PENDING", + manages_file, + ) + )).all() + active_source_ids: set[str] = set() + for request, item, requester in rows: + source_id = str(request.id) + active_source_ids.add(source_id) + requester_name = str(requester.full_name or requester.email or "项目成员") + await notification_service.sync_state_notification( + db, + study_id=study_id, + recipient_id=user.id, + category="COLLABORATION_EDIT_REQUEST", + priority="NORMAL", + title="新的编辑权限申请", + message=f"{requester_name} 申请编辑“{item.title}”", + action_path=f"/knowledge/collaboration?editRequestFile={item.id}", + source_type="COLLABORATION_EDIT_REQUEST", + source_id=source_id, + source_version="PENDING", + active=True, + dedupe_key=f"collaboration-edit-request:{request.id}", + ) + await notification_service.resolve_missing_recipient_sources( + db, + study_id=study_id, + recipient_id=user.id, + source_type="COLLABORATION_EDIT_REQUEST", + active_source_ids=active_source_ids, + ) + + +async def sync_project_reminders( + db: AsyncSession, + study_id: uuid.UUID, + user, + *, + now: datetime | None = None, +) -> None: + membership = await member_crud.get_member(db, study_id, user.id) + if not membership or not membership.is_active: + await notification_service.resolve_recipient_study_notifications( + db, + study_id=study_id, + recipient_id=user.id, + ) + await db.commit() + return + role = membership.role_in_study + current_time = now or datetime.now(timezone.utc) + if current_time.tzinfo is None: + current_time = current_time.replace(tzinfo=timezone.utc) + else: + current_time = current_time.astimezone(timezone.utc) + + await _sync_risk_reminders( + db, + study_id=study_id, + user=user, + role=role, + current_time=current_time, + ) + await _sync_document_distribution_reminders( + db, + study_id=study_id, + user=user, + role=role, + current_time=current_time, + ) + await _sync_milestone_reminders( + db, + study_id=study_id, + user=user, + role=role, + current_time=current_time, + ) + await _sync_visit_window_reminders( + db, + study_id=study_id, + user=user, + role=role, + current_time=current_time, + ) + await _sync_collaboration_edit_request_reminders( + db, + study_id=study_id, + user=user, + role=role, ) await db.commit() diff --git a/backend/app/services/user_login_sessions.py b/backend/app/services/user_login_sessions.py index e523217a..b7570500 100644 --- a/backend/app/services/user_login_sessions.py +++ b/backend/app/services/user_login_sessions.py @@ -195,7 +195,7 @@ async def get_login_summaries( "last_login_at": row.login_at, "last_seen_at": row.last_seen_at, "client_type": row.client_type, - "active_session_count": 0, + "active_sources": set(), }, ) row_last_seen_at = _as_utc(row.last_seen_at) @@ -204,14 +204,16 @@ async def get_login_summaries( ): current["last_seen_at"] = row_last_seen_at if row.ended_at is None and row_last_seen_at >= cutoff: - current["active_session_count"] += 1 + source_key = (row.client_type, row.login_ip) if row.login_ip else ("session", row.id) + current["active_sources"].add(source_key) for user_id, item in mutable.items(): + active_session_count = len(item["active_sources"]) summaries[user_id] = UserLoginSummary( - status="ONLINE" if item["active_session_count"] else "OFFLINE", + status="ONLINE" if active_session_count else "OFFLINE", last_login_at=item["last_login_at"], last_seen_at=item["last_seen_at"], client_type=item["client_type"], - active_session_count=item["active_session_count"], + active_session_count=active_session_count, ) return summaries diff --git a/backend/tests/test_notification_service.py b/backend/tests/test_notification_service.py index 946709b1..a68d9956 100644 --- a/backend/tests/test_notification_service.py +++ b/backend/tests/test_notification_service.py @@ -1,19 +1,24 @@ import uuid -from datetime import datetime, timezone +from datetime import date, datetime, timedelta, timezone from types import SimpleNamespace from unittest.mock import AsyncMock, Mock import pytest +from app.api.v1 import visits as visits_api from app.models.notification import Notification +from app.models.desktop_notification import DesktopNotificationDelivery from app.models.collaboration import CollaborationEditRequest -from app.services import collaboration_service, notification_service, project_reminder_service +from app.services import collaboration_service, desktop_notification_service, notification_service, project_reminder_service def test_generic_notification_table_has_recipient_dedupe_and_state_indexes(): table = Notification.__table__ assert table.name == "notifications" - assert {"study_id", "recipient_id", "category", "action_path", "source_type", "source_id", "read_at", "resolved_at"} <= set(table.columns.keys()) + assert { + "study_id", "recipient_id", "category", "action_path", "source_type", "source_id", + "requires_action", "due_at", "read_at", "resolved_at", + } <= set(table.columns.keys()) assert any(constraint.name == "uq_notifications_recipient_dedupe" for constraint in table.constraints) assert {index.name for index in table.indexes} >= { "ix_notifications_recipient_study_state", @@ -21,6 +26,23 @@ def test_generic_notification_table_has_recipient_dedupe_and_state_indexes(): } +def test_desktop_delivery_targets_the_generic_notification_feed(): + table = DesktopNotificationDelivery.__table__ + assert table.columns.notification_id.nullable is True + assert any( + constraint.name == "uq_desktop_notification_user_notification" + for constraint in table.constraints + ) + statement = str(desktop_notification_service._eligible_query( + uuid.uuid4(), + datetime.now(timezone.utc), + datetime.now(timezone.utc), + )) + assert "notifications" in statement + assert "notification_id" in statement + assert "resolved_at" in statement + + @pytest.mark.asyncio async def test_recipient_notification_creation_is_deduplicated_per_recipient(): existing_id = uuid.uuid4() @@ -64,24 +86,237 @@ async def test_resolving_a_source_closes_every_recipient_notification(): @pytest.mark.asyncio -async def test_project_risks_are_materialized_into_the_generic_notification_table(monkeypatch): +async def test_project_reminder_sync_runs_every_supported_rule_group(monkeypatch): study_id = uuid.uuid4() user = SimpleNamespace(id=uuid.uuid4(), is_admin=False) membership = SimpleNamespace(is_active=True, role_in_study="PM") db = SimpleNamespace(commit=AsyncMock()) - sync = AsyncMock() + risk_sync = AsyncMock() + distribution_sync = AsyncMock() + milestone_sync = AsyncMock() + visit_sync = AsyncMock() + collaboration_sync = AsyncMock() monkeypatch.setattr(project_reminder_service.member_crud, "get_member", AsyncMock(return_value=membership)) - monkeypatch.setattr(project_reminder_service, "role_has_api_permission", AsyncMock(return_value=True)) - monkeypatch.setattr(project_reminder_service, "get_cra_site_scope", AsyncMock(return_value=None)) - monkeypatch.setattr(project_reminder_service.ae_crud, "list_ae", AsyncMock(return_value=[object(), object()])) - monkeypatch.setattr(project_reminder_service.monitoring_issue_crud, "list_issues", AsyncMock(return_value=[object()])) - monkeypatch.setattr(project_reminder_service.notification_service, "sync_aggregate_notification", sync) + monkeypatch.setattr(project_reminder_service, "_sync_risk_reminders", risk_sync) + monkeypatch.setattr(project_reminder_service, "_sync_document_distribution_reminders", distribution_sync) + monkeypatch.setattr(project_reminder_service, "_sync_milestone_reminders", milestone_sync) + monkeypatch.setattr(project_reminder_service, "_sync_visit_window_reminders", visit_sync) + monkeypatch.setattr(project_reminder_service, "_sync_collaboration_edit_request_reminders", collaboration_sync) await project_reminder_service.sync_project_reminders(db, study_id, user) - assert sync.await_count == 2 - assert sync.await_args_list[0].kwargs["count"] == 2 - assert sync.await_args_list[1].kwargs["count"] == 1 + risk_sync.assert_awaited_once() + distribution_sync.assert_awaited_once() + milestone_sync.assert_awaited_once() + visit_sync.assert_awaited_once() + collaboration_sync.assert_awaited_once() + assert collaboration_sync.await_args.kwargs["role"] == "PM" + db.commit.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_inactive_membership_resolves_stale_project_notifications(monkeypatch): + db = SimpleNamespace(commit=AsyncMock()) + resolve = AsyncMock() + user = SimpleNamespace(id=uuid.uuid4(), is_admin=True) + study_id = uuid.uuid4() + monkeypatch.setattr(project_reminder_service.member_crud, "get_member", AsyncMock(return_value=None)) + monkeypatch.setattr( + project_reminder_service.notification_service, + "resolve_recipient_study_notifications", + resolve, + ) + + await project_reminder_service.sync_project_reminders(db, study_id, user) + + resolve.assert_awaited_once_with(db, study_id=study_id, recipient_id=user.id) + db.commit.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_project_risks_include_due_soon_and_overdue_aggregates(monkeypatch): + study_id = uuid.uuid4() + user = SimpleNamespace(id=uuid.uuid4(), is_admin=False) + now = datetime(2026, 7, 16, 2, 0, tzinfo=timezone.utc) + db = SimpleNamespace() + sync = AsyncMock() + monkeypatch.setattr(project_reminder_service, "role_has_api_permission", AsyncMock(return_value=True)) + monkeypatch.setattr(project_reminder_service, "get_cra_site_scope", AsyncMock(return_value=None)) + monkeypatch.setattr( + project_reminder_service, + "_count_and_earliest", + AsyncMock(side_effect=[ + (2, date(2026, 7, 15)), + (1, date(2026, 7, 18)), + (3, now - timedelta(hours=2)), + (4, now + timedelta(days=1)), + ]), + ) + monkeypatch.setattr(project_reminder_service.notification_service, "sync_aggregate_notification", sync) + + await project_reminder_service._sync_risk_reminders( + db, + study_id=study_id, + user=user, + role="PM", + current_time=now, + ) + + assert [call.kwargs["category"] for call in sync.await_args_list] == [ + "RISK_OVERDUE_AE", + "RISK_AE_DUE_SOON", + "RISK_OVERDUE_MONITORING", + "RISK_MONITORING_DUE_SOON", + ] + assert [call.kwargs["count"] for call in sync.await_args_list] == [2, 1, 3, 4] + + +@pytest.mark.asyncio +async def test_visit_window_reminders_are_actionable_scoped_and_privacy_safe(monkeypatch): + study_id = uuid.uuid4() + user = SimpleNamespace(id=uuid.uuid4(), is_admin=False) + site_id = uuid.uuid4() + due_visit_id = uuid.uuid4() + missed_visit_id = uuid.uuid4() + due_subject_id = uuid.uuid4() + missed_subject_id = uuid.uuid4() + due_visit = SimpleNamespace( + id=due_visit_id, + planned_date=date(2026, 7, 19), + window_start=date(2026, 7, 18), + window_end=date(2026, 7, 20), + ) + missed_visit = SimpleNamespace( + id=missed_visit_id, + planned_date=date(2026, 7, 14), + window_start=date(2026, 7, 13), + window_end=date(2026, 7, 15), + ) + due_subject = SimpleNamespace(id=due_subject_id, subject_no="S-PRIVACY-001") + missed_subject = SimpleNamespace(id=missed_subject_id, subject_no="S-PRIVACY-002") + rows = SimpleNamespace(all=lambda: [ + (due_visit, due_subject), + (missed_visit, missed_subject), + ]) + db = SimpleNamespace(execute=AsyncMock(return_value=rows)) + sync = AsyncMock() + resolve = AsyncMock() + monkeypatch.setattr(project_reminder_service, "role_has_api_permission", AsyncMock(return_value=True)) + monkeypatch.setattr( + project_reminder_service, + "get_cra_site_scope", + AsyncMock(return_value=({site_id}, {"研究中心"})), + ) + monkeypatch.setattr(project_reminder_service.notification_service, "sync_state_notification", sync) + monkeypatch.setattr( + project_reminder_service.notification_service, + "resolve_missing_recipient_sources", + resolve, + ) + + await project_reminder_service._sync_visit_window_reminders( + db, + study_id=study_id, + user=user, + role="CRA", + current_time=datetime(2026, 7, 16, 2, 0, tzinfo=timezone.utc), + ) + + assert [call.kwargs["category"] for call in sync.await_args_list] == [ + "VISIT_WINDOW_DUE_SOON", + "VISIT_WINDOW_MISSED", + ] + assert [call.kwargs["priority"] for call in sync.await_args_list] == ["NORMAL", "HIGH"] + assert sync.await_args_list[0].kwargs["action_path"] == f"/subjects/{due_subject_id}" + assert sync.await_args_list[1].kwargs["action_path"] == f"/subjects/{missed_subject_id}" + messages = " ".join(call.kwargs["message"] for call in sync.await_args_list) + assert "S-PRIVACY-001" not in messages + assert "S-PRIVACY-002" not in messages + resolve.assert_awaited_once_with( + db, + study_id=study_id, + recipient_id=user.id, + source_type="SUBJECT_VISIT_WINDOW", + active_source_ids={str(due_visit_id), str(missed_visit_id)}, + ) + + +@pytest.mark.asyncio +async def test_visit_window_reminders_resolve_when_recipient_cannot_manage_visits(monkeypatch): + study_id = uuid.uuid4() + user = SimpleNamespace(id=uuid.uuid4(), is_admin=False) + db = SimpleNamespace(execute=AsyncMock()) + resolve = AsyncMock() + monkeypatch.setattr(project_reminder_service, "role_has_api_permission", AsyncMock(return_value=False)) + monkeypatch.setattr( + project_reminder_service.notification_service, + "resolve_missing_recipient_sources", + resolve, + ) + + await project_reminder_service._sync_visit_window_reminders( + db, + study_id=study_id, + user=user, + role="QA", + current_time=datetime(2026, 7, 16, 2, 0, tzinfo=timezone.utc), + ) + + db.execute.assert_not_awaited() + resolve.assert_awaited_once_with( + db, + study_id=study_id, + recipient_id=user.id, + source_type="SUBJECT_VISIT_WINDOW", + active_source_ids=set(), + ) + + +@pytest.mark.asyncio +async def test_completed_visit_closes_every_recipient_reminder_immediately(monkeypatch): + first_id = uuid.uuid4() + second_id = uuid.uuid4() + db = SimpleNamespace(commit=AsyncMock()) + resolve = AsyncMock() + monkeypatch.setattr(visits_api.notification_service, "resolve_source_notifications", resolve) + + await visits_api._resolve_visit_reminders(db, [first_id, second_id, first_id]) + + assert resolve.await_count == 2 + assert {call.kwargs["source_id"] for call in resolve.await_args_list} == { + str(first_id), + str(second_id), + } + assert all( + call.kwargs["source_type"] == "SUBJECT_VISIT_WINDOW" + for call in resolve.await_args_list + ) + db.commit.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_reading_an_informational_notification_archives_it(): + item = SimpleNamespace( + id=uuid.uuid4(), + read_at=None, + resolved_at=None, + requires_action=False, + ) + db = SimpleNamespace( + scalar=AsyncMock(return_value=item), + commit=AsyncMock(), + refresh=AsyncMock(), + ) + + result = await notification_service.mark_read( + db, + study_id=uuid.uuid4(), + recipient_id=uuid.uuid4(), + notification_id=item.id, + ) + + assert result.read_at is not None + assert result.resolved_at == result.read_at db.commit.assert_awaited_once() diff --git a/backend/tests/test_user_login_sessions.py b/backend/tests/test_user_login_sessions.py index 2e950b6d..f7231766 100644 --- a/backend/tests/test_user_login_sessions.py +++ b/backend/tests/test_user_login_sessions.py @@ -134,6 +134,55 @@ async def test_login_summary_marks_recent_unended_sessions_online(db_session, mo assert summaries[user.id].client_type == "desktop" +@pytest.mark.asyncio +async def test_login_summary_counts_same_client_and_ip_as_one_active_source(db_session, monkeypatch): + now = datetime.now(timezone.utc) + monkeypatch.setattr(settings, "USER_SESSION_ONLINE_SECONDS", 300) + user = User( + id=uuid.uuid4(), + email="deduplicated-login-source@example.com", + password_hash="hash", + full_name="Deduplicated Login Source", + clinical_department="IT", + status=UserStatus.ACTIVE, + ) + db_session.add(user) + db_session.add_all( + [ + UserLoginSession( + id=uuid.uuid4(), + user_id=user.id, + client_type="web", + login_ip="192.168.97.1", + login_at=now - timedelta(minutes=2), + last_seen_at=now - timedelta(seconds=20), + ), + UserLoginSession( + id=uuid.uuid4(), + user_id=user.id, + client_type="web", + login_ip="192.168.97.1", + login_at=now - timedelta(minutes=1), + last_seen_at=now - timedelta(seconds=10), + ), + UserLoginSession( + id=uuid.uuid4(), + user_id=user.id, + client_type="desktop", + login_ip="192.168.97.1", + login_at=now - timedelta(minutes=1), + last_seen_at=now - timedelta(seconds=5), + ), + ] + ) + await db_session.commit() + + summaries = await get_login_summaries(db_session, [user.id]) + + assert summaries[user.id].status == "ONLINE" + assert summaries[user.id].active_session_count == 2 + + @pytest.mark.asyncio async def test_user_list_filters_online_and_offline_accounts(db_session, monkeypatch): now = datetime.now(timezone.utc) diff --git a/docker-compose.yaml b/docker-compose.yaml index 03257ae8..90d0e6b4 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -38,6 +38,7 @@ services: MONITORING_RETENTION_INTERVAL_SECONDS: ${MONITORING_RETENTION_INTERVAL_SECONDS:-86400} USER_LOGIN_ACTIVITY_RETENTION_DAYS: ${USER_LOGIN_ACTIVITY_RETENTION_DAYS:-180} USER_SESSION_ONLINE_SECONDS: ${USER_SESSION_ONLINE_SECONDS:-300} + NOTIFICATION_SYNC_INTERVAL_SECONDS: ${NOTIFICATION_SYNC_INTERVAL_SECONDS:-300} TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-127.0.0.1/32,::1/128,172.16.0.0/12,192.168.0.0/16} MONITORING_SERVER_PUBLIC_IP: ${MONITORING_SERVER_PUBLIC_IP:-} MONITORING_PUBLIC_IP_DISCOVERY_URLS: ${MONITORING_PUBLIC_IP_DISCOVERY_URLS:-https://api64.ipify.org,https://icanhazip.com} diff --git a/docs/desktop-phase-2-design.md b/docs/desktop-phase-2-design.md index 7a3f167c..f40bca03 100644 --- a/docs/desktop-phase-2-design.md +++ b/docs/desktop-phase-2-design.md @@ -77,12 +77,12 @@ API: - `POST /api/v1/desktop-notifications/ack` - `POST /api/v1/desktop-notifications/{distribution_id}/read` -claim 跨有效项目查询匹配当前用户或角色的活动文件分发,使用五分钟租约、唯一约束和事务防重。客户端显示系统通知后 ack;显示失败则等待租约到期后重试。 +claim 跨有效项目查询当前用户未读且未解决的通用业务提醒,使用五分钟租约、唯一约束和事务防重。客户端显示系统通知后 ack;显示失败则等待租约到期后重试。系统通知只是通用提醒 Feed 的可选投递通道,不单独维护文件分发提醒来源。 系统通知正文只显示通用内容: -- 标题:`CTMS 文件更新` -- 正文:`有新的文件版本待查看` +- 标题:`CTMS 待办提醒` +- 正文:`有新的业务提醒待查看` 项目、文件、版本等详细信息仅在应用内列表显示,避免锁屏泄露。 diff --git a/docs/onlyoffice-collaboration.md b/docs/onlyoffice-collaboration.md index f807af18..b4c57eda 100644 --- a/docs/onlyoffice-collaboration.md +++ b/docs/onlyoffice-collaboration.md @@ -20,7 +20,7 @@ - 文件所有者始终拥有编辑、管理、导出和转让能力;文件管理者始终拥有编辑、管理和导出能力,但不能转让所有权;文件编辑者可以编辑,导出能力由文件“权限设置”开关控制。系统管理员继续保留平台级完整访问能力。 - 授予编辑者、管理者或转让所有权时,目标账号必须是当前项目的有效成员,并具备对应项目角色资格;不合格账号在联系人列表中不可选择,后端同时拒绝绕过界面的授权请求。 - 文件管理者可开启“允许申请编辑权限”。只读项目成员可从 CTMS 顶栏或 ONLYOFFICE 的“请求编辑”入口提交申请;批准后写入文件级编辑者授权,拒绝或重复处理均有明确状态和审计记录。匿名链接访问者不能申请系统账号权限。 -- 顶栏铃铛通过通用通知 Feed 展示当前收件人的未关闭通知并记录已读状态;编辑权限申请通知可直接进入对应文件的“权限设置”。原逾期 AE 和逾期监查问题也先同步为通用通知,再由同一 Feed 返回,不再由网页和桌面布局分别临时汇总。 +- 顶栏铃铛通过通用通知 Feed 展示当前收件人的未关闭通知并记录已读状态;编辑权限申请通知可直接进入对应文件的“权限设置”,审批结果作为一次性信息通知申请人。AE、监查问题、文件回执和里程碑时效提醒也由同一 Feed 返回,具体规则见 `docs/project-notifications.md`。 - Excel 文件可通过“允许所有协作者添加、删除工作表”控制工作簿结构。关闭时 CTMS 在新的不可变修订中启用工作簿结构保护并推进文件代次;重新开启时恢复文件原有的工作簿保护状态。该开关作用于所有协作者且不影响单元格内容编辑权限。 - 仅文档所有者和系统管理员可转让所有权。目标联系人必须具备文件管理者资格;转让后目标联系人升级为文件管理者,原所有者保留管理者身份,所有权变更写入审计。 - 文件编辑器“下载为”由文件导出规则判定:所有者和管理者始终允许,编辑者受文件开关控制,并由 CTMS 网页/桌面文件运行时保存到本机且记录下载审计。“另存为…”还会在项目工作区创建独立协作文件,因此额外要求项目级 `collaboration:create` 权限。 diff --git a/docs/project-notifications.md b/docs/project-notifications.md new file mode 100644 index 00000000..39398b32 --- /dev/null +++ b/docs/project-notifications.md @@ -0,0 +1,72 @@ +# 项目提醒中心 + +## 产品边界 + +项目提醒是既有业务状态面向明确收件人的可操作投影,不是独立任务、日历或聊天系统。源业务记录和业务审计始终是权威数据;提醒的阅读、桌面投递均不能替代 AE 上报、问题关闭、文件回执、审批或其他业务动作。 + +当前范围只包括已认证在线客户端: + +- 网页端和桌面端共用当前项目的通用提醒 Feed。 +- 顶栏铃铛展示摘要,`/project/notifications` 提供当前提醒、未读和待处理筛选。已经解决或因权限变化失效的提醒不再向客户端返回。 +- 桌面系统通知是用户主动开启的可选投递渠道,只显示不含项目、文件、受试者或风险详情的通用文案。 +- 不提供离线提醒、本地业务权威数据、个人自由定时任务、短信、即时通讯或浏览器 Push。 +- 管理员权限监控告警、登录会话提醒和客户端更新“稍后提醒”不进入业务提醒中心。 + +## 状态模型 + +`notifications` 是收件人级通用提醒表: + +- `read_at`:用户已经看过提醒。 +- `resolved_at`:源业务已经完成、关闭、失效,或一次性信息通知已经被阅读。 +- `requires_action`:区分业务待办和一次性信息通知。 +- `due_at`:用于展示业务截止时间;业务规则仍以源记录为准。 +- `source_type`、`source_id`、`source_version` 和 `dedupe_key`:用于来源追踪、幂等同步、阶段升级和自动关闭。 + +阅读待处理提醒不会写入 `resolved_at`。业务动作完成后由对应服务即时关闭提醒,定时同步还会对遗漏或权限变化进行对账。一次性信息通知在首次阅读时归档。 + +桌面投递状态继续保存在 `desktop_notification_deliveries`,但通过 `notification_id` 关联通用提醒。投递成功只记录 `delivered_at`,不自动标记业务提醒已读或已处理;提醒升级或重新打开时可以重新进入桌面投递队列。 + +桌面客户端每 60 秒执行一次在线投递检查:先确认本机系统权限,再读取当前账号的服务端订阅,随后领取待投递提醒、调用系统通知并确认实际成功投递的提醒。任一系统通知调用失败时不会确认该条提醒;网络或投递失败采用指数退避,最长 15 分钟。用户可在“设置 → 通知”查看本次客户端运行期间的最近检查、最近投递和当前链路状态,并主动执行一次真实业务提醒检查。 + +## 当前规则 + +| 来源 | 收件人 | 触发和升级 | 自动关闭 | +|---|---|---|---| +| 协作编辑申请 | 文件所有者、文件管理者 | 申请创建 | 申请批准或拒绝 | +| 协作申请结果 | 申请人 | 申请批准或拒绝 | 申请人阅读后归档 | +| 文件分发与回执 | 指定用户、指定角色、中心联系人 | 新分发;3 天内到期;逾期 | 用户完成接收回执、分发关闭或权限失效 | +| AE 上报时效 | 具备 AE 读取权限且在中心范围内的项目成员 | 3 天内到期;逾期;按数量增加重新提醒 | 数量归零或权限失效 | +| 监查问题整改 | 具备监查问题读取权限且在中心范围内的项目成员 | 3 天内到期;逾期;按数量增加重新提醒 | 数量归零或权限失效 | +| 项目里程碑 | 里程碑负责人 | 7 天内到期;逾期;日期或状态变化重新提醒 | 完成、负责人变化或日期移出提醒窗口 | +| 受试者访视窗口 | 具备访视更新权限的有效项目成员;CRA 仅限负责中心 | 窗口开始前 3 天、窗口进行中;错过窗口后升级 | 录入实际访视、取消访视、受试者结束、中心停用或权限/中心范围失效 | + +所有规则都要求当前有效项目成员和对应业务权限。系统管理员不会仅因平台权限而批量接收所有项目提醒;需要作为有效项目成员或明确业务收件人进入提醒范围。 + +日期型 AE、里程碑和访视窗口规则按 `Asia/Shanghai` 业务日计算,带时区的截止时间统一按 UTC 存储和比较。若未来引入项目级时区,应由项目配置替代固定业务时区,不能使用浏览器本地时区驱动服务端规则。 + +访视提醒按访视记录生成并可跳转到对应受试者详情,但提醒标题和正文不包含受试者编号、姓名、中心名称或其他可识别信息。收件人资格以 `visits:update` 及其前置权限为准,避免仅具备只读权限的 PV、QA、CTA 等角色被动接收受试者访视提醒。 + +## 同步与诊断 + +服务端启动提醒同步任务,默认每 300 秒对所有有效项目成员执行幂等同步;间隔通过 `NOTIFICATION_SYNC_INTERVAL_SECONDS` 配置,允许 60–3600 秒。PostgreSQL advisory lock 防止多个后端进程重复执行全量任务。 + +用户读取 Feed 时还会执行一次当前用户轻量对账,用于弥补短时任务失败。单个成员同步失败不会中断其他成员;失败写入 `ctms.notifications` 日志。提醒创建、更新和自动关闭本身不替代源业务审计。 + +## 桌面隐私边界 + +系统通知固定为: + +- 标题:`CTMS 待办提醒` +- 正文:`有新的业务提醒待查看` + +系统通知 API 不接受动态标题或正文。项目名、文件名、版本、受试者、AE、监查问题等详情只允许在完成 token 校验和项目权限确认后的应用内 Feed 中展示;访视提醒即使在应用内 Feed 也不直接显示受试者标识,只通过受控详情路径访问。 + +## 系统通知授权边界 + +系统权限和服务端订阅是两个独立条件: + +- 首次开启时,客户端先请求操作系统授权;只有操作系统返回已授权,才开启当前账号的服务端订阅。 +- 用户在操作系统中撤销权限后,服务端订阅可以仍然保持开启,但客户端不会领取提醒,设置页会明确显示“系统权限已拒绝”。 +- macOS 或 Windows 已拒绝权限时,系统通常不会再次展示授权框。设置页分别引导用户前往“系统设置 → 通知 → CTMS”或“设置 → 系统 → 通知 → CTMS”,返回后通过“重新检测权限”恢复链路。 +- “发送测试通知”只验证本机通知能力,不访问业务提醒队列;“立即检查业务提醒”会走服务端订阅、领取、系统投递和确认的真实路径。 +- CTMS 不申请打开任意 URL、执行系统命令或读取操作系统通知内容的额外 Tauri 权限。授权引导仅展示设置路径,避免为便利入口扩大桌面原生能力边界。 diff --git a/frontend/scripts/verify-desktop-release.mjs b/frontend/scripts/verify-desktop-release.mjs index a2c7ba6e..b00f9997 100644 --- a/frontend/scripts/verify-desktop-release.mjs +++ b/frontend/scripts/verify-desktop-release.mjs @@ -362,8 +362,8 @@ const verifySourceSafety = async () => { const verifyNotificationBoundary = async () => { const source = await readFile(resolve(sourceDir, "runtime/notifications.ts"), "utf8"); - assert(source.includes('title: "CTMS 文件更新"'), "Desktop notification title must stay generic."); - assert(source.includes('body: "有新的文件版本待查看"'), "Desktop notification body must stay generic."); + assert(source.includes('title: "CTMS 待办提醒"'), "Desktop notification title must stay generic."); + assert(source.includes('body: "有新的业务提醒待查看"'), "Desktop notification body must stay generic."); assert(!/showSystemNotification\s*=\s*async\s*\([^)]*[a-zA-Z]/.test(source), "Desktop notification body must not accept dynamic business content."); }; diff --git a/frontend/src/api/desktopNotifications.ts b/frontend/src/api/desktopNotifications.ts index bd377e13..dd949ecb 100644 --- a/frontend/src/api/desktopNotifications.ts +++ b/frontend/src/api/desktopNotifications.ts @@ -1,5 +1,5 @@ import { apiGet, apiPost, apiPut } from "./axios"; -import type { NotificationItem } from "../types/notifications"; +import type { GeneralNotificationItem } from "../types/notifications"; export interface DesktopNotificationSubscription { enabled: boolean; @@ -9,7 +9,7 @@ export interface DesktopNotificationSubscription { export interface DesktopNotificationClaim { claim_token?: string | null; lease_expires_at?: string | null; - items: NotificationItem[]; + items: GeneralNotificationItem[]; } export const getDesktopNotificationSubscription = () => diff --git a/frontend/src/api/notifications.test.ts b/frontend/src/api/notifications.test.ts index e95e7371..11731b6d 100644 --- a/frontend/src/api/notifications.test.ts +++ b/frontend/src/api/notifications.test.ts @@ -11,18 +11,23 @@ describe("general notification API", () => { it("loads an uncached recipient feed and updates read state", async () => { const { listGeneralNotifications, + markAllGeneralNotificationsRead, markGeneralNotificationRead, } = await import("./notifications"); - listGeneralNotifications("study-1", 8); + listGeneralNotifications("study-1", { limit: 8, unread_only: true }); markGeneralNotificationRead("study-1", "notification-1"); + markAllGeneralNotificationsRead("study-1"); expect(apiGet).toHaveBeenCalledWith( "/api/v1/studies/study-1/notifications/feed", - { params: { limit: 8 }, cache: false, disableRequestDedupe: true }, + { params: { limit: 8, unread_only: true }, cache: false, disableRequestDedupe: true }, ); expect(apiPost).toHaveBeenCalledWith( "/api/v1/studies/study-1/notifications/notification-1/read", ); + expect(apiPost).toHaveBeenCalledWith( + "/api/v1/studies/study-1/notifications/read-all", + ); }); }); diff --git a/frontend/src/api/notifications.ts b/frontend/src/api/notifications.ts index 0664ccd9..cf0a423e 100644 --- a/frontend/src/api/notifications.ts +++ b/frontend/src/api/notifications.ts @@ -1,14 +1,27 @@ import { apiGet, apiPost } from "./axios"; import type { GeneralNotificationFeed, GeneralNotificationItem } from "../types/notifications"; -export const listGeneralNotifications = (studyId: string, limit = 10) => - apiGet(`/api/v1/studies/${studyId}/notifications/feed`, { - params: { limit }, - cache: false, - disableRequestDedupe: true, - }); +export interface GeneralNotificationQuery { + skip?: number; + limit?: number; + category?: string; + unread_only?: boolean; + requires_action?: boolean; +} + +export const listGeneralNotifications = ( + studyId: string, + query: GeneralNotificationQuery = {}, +) => apiGet(`/api/v1/studies/${studyId}/notifications/feed`, { + params: { limit: 10, ...query }, + cache: false, + disableRequestDedupe: true, +}); export const markGeneralNotificationRead = (studyId: string, notificationId: string) => apiPost( `/api/v1/studies/${studyId}/notifications/${notificationId}/read`, ); + +export const markAllGeneralNotificationsRead = (studyId: string) => + apiPost(`/api/v1/studies/${studyId}/notifications/read-all`); diff --git a/frontend/src/components/ApiEndpointPermissions.test.ts b/frontend/src/components/ApiEndpointPermissions.test.ts index 64161bf9..24de4fd7 100644 --- a/frontend/src/components/ApiEndpointPermissions.test.ts +++ b/frontend/src/components/ApiEndpointPermissions.test.ts @@ -260,6 +260,9 @@ describe("ApiEndpointPermissions.vue", () => { ]); expect((wrapper.vm as any).filteredOperations.filter((operation: any) => operation.operation_key === "subject_aes:read")).toHaveLength(2); expect((wrapper.vm as any).filteredOperations.filter((operation: any) => operation.operation_key === "subject_pds:list")).toHaveLength(2); + expect((wrapper.vm as any).displayOperations).toHaveLength(5); + expect((wrapper.vm as any).totalPermissionCount).toBe(3); + expect((wrapper.vm as any).filteredPermissionCount).toBe(3); }); it("keeps contract fee permissions as one module section", () => { diff --git a/frontend/src/components/ApiEndpointPermissions.vue b/frontend/src/components/ApiEndpointPermissions.vue index 4990012b..c5eee324 100644 --- a/frontend/src/components/ApiEndpointPermissions.vue +++ b/frontend/src/components/ApiEndpointPermissions.vue @@ -6,7 +6,7 @@ 有效权限矩阵 只读 - 显示 {{ filteredOperations.length }} / {{ displayOperations.length }} 项权限 + 显示 {{ filteredPermissionCount }} / {{ totalPermissionCount }} 项权限
{ return filtered; }); +const countUniquePermissions = (rows: DisplayOperation[]): number => + new Set(rows.map((operation) => operation.operation_key)).size; + +const totalPermissionCount = computed(() => countUniquePermissions(displayOperations.value)); +const filteredPermissionCount = computed(() => countUniquePermissions(filteredOperations.value)); + // 权限类型、模块与模块细分列合并行 const spanMethod = ({ row, rowIndex, columnIndex }: { row: DisplayOperation; rowIndex: number; column: any; columnIndex: number }) => { if (columnIndex === 0) { diff --git a/frontend/src/components/DesktopLayout.vue b/frontend/src/components/DesktopLayout.vue index 25a6d63e..dc360396 100644 --- a/frontend/src/components/DesktopLayout.vue +++ b/frontend/src/components/DesktopLayout.vue @@ -296,6 +296,18 @@
{{ headerRemindersLoading ? TEXT.common.loading : TEXT.common.labels.projectRemindersEmpty }}
+
+ + 查看全部提醒 + + + 全部已读 + +
@@ -2148,6 +2160,28 @@ useDesktopShortcuts( text-align: center; } +.reminder-actions { + display: grid; + grid-template-columns: 1fr 1fr; + margin-top: 6px; + border-top: 1px solid rgba(138, 155, 176, 0.2); +} + +.reminder-actions .reminder-footer:only-child { + grid-column: 1 / -1; +} + +.reminder-footer { + justify-content: center; + color: #2f6fed !important; + font-size: 12px; + font-weight: 700; +} + +.reminder-footer.is-secondary { + color: #718096 !important; +} + .activity-button { position: relative; } diff --git a/frontend/src/components/Layout.desktop.test.ts b/frontend/src/components/Layout.desktop.test.ts index d20da7d0..54ed53d9 100644 --- a/frontend/src/components/Layout.desktop.test.ts +++ b/frontend/src/components/Layout.desktop.test.ts @@ -15,6 +15,7 @@ const readTauriLibSource = () => readSource(resolve(__dirname, "../../src-tauri/ const readDesktopPreferencesSource = () => readSource(resolve(__dirname, "../views/DesktopPreferences.vue")); const readDesktopServerSettingsSource = () => readSource(resolve(__dirname, "../views/DesktopServerSettings.vue")); const readDesktopUpdateManagerSource = () => readSource(resolve(__dirname, "../session/desktopUpdateManager.ts")); +const readDesktopNotificationManagerSource = () => readSource(resolve(__dirname, "../session/desktopNotificationManager.ts")); const readDesktopActivityCenterSource = () => readSource(resolve(__dirname, "../session/desktopActivityCenter.ts")); const readProfileSettingsSource = () => readSource(resolve(__dirname, "../views/ProfileSettings.vue")); const readLoginSource = () => readSource(resolve(__dirname, "../views/Login.vue")); @@ -484,9 +485,10 @@ describe("desktop layout shell", () => { expect(navigation).toContain("item.children?.length ? item.children : [item]"); }); - it("keeps notification and update preferences lightweight", () => { + it("shows the real notification delivery path and keeps native notification access constrained", () => { const serverSettings = readDesktopServerSettingsSource(); const preferences = readDesktopPreferencesSource(); + const desktopNotificationManager = readDesktopNotificationManagerSource(); const desktopUpdateManager = readDesktopUpdateManagerSource(); const desktopLayout = readDesktopLayoutSource(); @@ -503,17 +505,25 @@ describe("desktop layout shell", () => { expect(preferences).not.toContain("服务器连通性正常"); expect(preferences).not.toContain("服务器连接已确认"); expect(preferences).toContain("auth.logout({ rememberCurrentStudy: false })"); - expect(preferences).not.toContain("系统通知已开启"); - expect(preferences).not.toContain("系统通知已关闭"); - expect(preferences).not.toContain("已授权"); - expect(preferences).toContain("showNotificationPermissionTag"); + expect(preferences).toContain("系统权限:{{ notificationPermissionText }}"); + expect(preferences).toContain("服务端订阅"); + expect(preferences).toContain("真实业务路径"); + expect(preferences).toContain("授权并开启"); + expect(preferences).toContain("重新检测权限"); + expect(preferences).toContain("立即检查业务提醒"); + expect(preferences).toContain("系统设置 → 通知 → CTMS"); + expect(preferences).toContain("设置 → 系统 → 通知 → CTMS"); expect(preferences).toContain("showSystemNotificationProbe"); expect(preferences).toContain("sendDesktopNotificationTest"); - expect(preferences).toContain("测试通知"); - expect(preferences).not.toContain("通知诊断"); + expect(preferences).toContain("发送测试通知"); + expect(preferences).toContain("openProjectNotificationCenter"); + expect(preferences).toContain("listenDesktopNotificationStatus"); expect(preferences).not.toContain("更新诊断"); - expect(preferences).not.toContain("listenDesktopNotificationDiagnostics"); expect(preferences).not.toContain(["@tauri-apps", "plugin-notification"].join("/")); + expect(desktopNotificationManager).toContain("claimDesktopNotifications"); + expect(desktopNotificationManager).toContain("acknowledgeDesktopNotifications"); + expect(desktopNotificationManager).toContain('state: "permission-required"'); + expect(desktopNotificationManager).toContain('state: deliveredIds.length ? "delivered" : "ready"'); expect(preferences).toContain("listenDesktopUpdateStatus"); expect(preferences).toContain("promptForPendingDesktopUpdate"); expect(preferences).toContain("当前已是最新版本"); @@ -603,6 +613,10 @@ describe("desktop layout shell", () => { expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-table td.el-table__cell"); expect(styles).toContain("padding: 7px 10px;"); expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-pagination"); + expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-radio-button__inner"); + expect(styles).toContain("display: inline-flex;"); + expect(styles).toContain("align-items: center;"); + expect(styles).toContain("justify-content: center;"); expect(styles).toContain(".desktop-workbench .desktop-route-shell .el-drawer__body"); expect(styles).toContain(":root[data-ctms-theme=\"dark\"] .desktop-workbench .desktop-route-shell .hero-stat"); }); diff --git a/frontend/src/components/WebLayout.vue b/frontend/src/components/WebLayout.vue index f953e578..cebc6414 100644 --- a/frontend/src/components/WebLayout.vue +++ b/frontend/src/components/WebLayout.vue @@ -289,7 +289,18 @@
{{ headerRemindersLoading ? TEXT.common.loading : TEXT.common.labels.projectRemindersEmpty }}
- +
+ + 查看全部提醒 + + + 全部已读 + +
@@ -1848,6 +1859,21 @@ useDesktopShortcuts( font-weight: 600; } +.header-reminder-actions { + display: grid; + grid-template-columns: 1fr 1fr; + margin-top: 6px; + border-top: 1px solid rgba(148, 163, 184, 0.2); +} + +.header-reminder-actions .header-reminder-footer:only-child { + grid-column: 1 / -1; +} + +.header-reminder-footer.is-secondary { + color: #64748b !important; +} + .collapse-btn { border: 1px solid transparent; color: var(--ctms-text-secondary); diff --git a/frontend/src/composables/useProjectNotifications.test.ts b/frontend/src/composables/useProjectNotifications.test.ts index d1c0e4e0..e86ff50c 100644 --- a/frontend/src/composables/useProjectNotifications.test.ts +++ b/frontend/src/composables/useProjectNotifications.test.ts @@ -10,11 +10,15 @@ describe("project notification feed contract", () => { it("uses recipient notifications for both web and desktop reminder bells", () => { expect(source).toContain("listGeneralNotifications"); expect(source).toContain("markGeneralNotificationRead"); + expect(source).toContain("markAllGeneralNotificationsRead"); + expect(source).toContain('/project/notifications'); expect(source).toContain("POLL_INTERVAL_MS = 60_000"); expect(source).toContain("PROJECT_NOTIFICATIONS_CHANGED_EVENT"); expect(webLayout).toContain("useProjectNotifications()"); expect(desktopLayout).toContain("useProjectNotifications()"); expect(webLayout).not.toContain("fetchOverdueAesCount"); expect(desktopLayout).not.toContain("fetchOverdueAesCount"); + expect(webLayout).toContain('command="notification:center"'); + expect(desktopLayout).toContain('command="notification:center"'); }); }); diff --git a/frontend/src/composables/useProjectNotifications.ts b/frontend/src/composables/useProjectNotifications.ts index 27b90a27..5be76642 100644 --- a/frontend/src/composables/useProjectNotifications.ts +++ b/frontend/src/composables/useProjectNotifications.ts @@ -1,6 +1,10 @@ import { computed, ref } from "vue"; import { useRoute, useRouter } from "vue-router"; -import { listGeneralNotifications, markGeneralNotificationRead } from "../api/notifications"; +import { + listGeneralNotifications, + markAllGeneralNotificationsRead, + markGeneralNotificationRead, +} from "../api/notifications"; import { useStudyStore } from "../store/study"; import type { GeneralNotificationFeed, GeneralNotificationItem } from "../types/notifications"; @@ -32,7 +36,7 @@ export const useProjectNotifications = () => { const route = useRoute(); const router = useRouter(); const headerRemindersLoading = ref(false); - const feed = ref({ unread_count: 0, items: [] }); + const feed = ref({ unread_count: 0, total_count: 0, items: [] }); let requestId = 0; let pollTimer: number | undefined; @@ -40,12 +44,12 @@ export const useProjectNotifications = () => { const studyId = study.currentStudy?.id; const currentRequest = ++requestId; if (!studyId || route.path.startsWith("/admin")) { - feed.value = { unread_count: 0, items: [] }; + feed.value = { unread_count: 0, total_count: 0, items: [] }; return; } headerRemindersLoading.value = true; try { - const { data } = await listGeneralNotifications(studyId, 10); + const { data } = await listGeneralNotifications(studyId, { limit: 10 }); if (currentRequest === requestId && study.currentStudy?.id === studyId) feed.value = data; } catch { // 短时网络异常时保留当前内存中的提醒,避免角标在轮询失败时闪烁消失。 @@ -67,6 +71,17 @@ export const useProjectNotifications = () => { const headerReminderBadgeValue = computed(() => headerReminderTotal.value > 99 ? "99+" : headerReminderTotal.value); const handleReminderCommand = async (command: string) => { + if (command === "notification:center") { + await router.push("/project/notifications"); + return; + } + if (command === "notification:read-all") { + const studyId = study.currentStudy?.id; + if (!studyId || feed.value.unread_count <= 0) return; + await markAllGeneralNotificationsRead(studyId); + await loadHeaderReminders(); + return; + } if (!command.startsWith("notification:")) return; const notificationId = command.slice("notification:".length); const item = feed.value.items.find((candidate) => candidate.id === notificationId); @@ -75,10 +90,15 @@ export const useProjectNotifications = () => { if (!item.read_at) { item.read_at = new Date().toISOString(); feed.value.unread_count = Math.max(0, feed.value.unread_count - 1); - await markGeneralNotificationRead(studyId, item.id).catch(() => { + const response = await markGeneralNotificationRead(studyId, item.id).catch(() => { item.read_at = null; feed.value.unread_count += 1; + return null; }); + if (response?.data.resolved_at) { + feed.value.items = feed.value.items.filter((candidate) => candidate.id !== item.id); + feed.value.total_count = Math.max(0, feed.value.total_count - 1); + } } if (item.action_path?.startsWith("/")) await router.push(item.action_path); }; diff --git a/frontend/src/locales/zh-CN.ts b/frontend/src/locales/zh-CN.ts index 84f2c9e8..5a09176f 100644 --- a/frontend/src/locales/zh-CN.ts +++ b/frontend/src/locales/zh-CN.ts @@ -99,7 +99,7 @@ export const TEXT = { dataUpdatedAt: "数据", projectReminders: "提醒", projectRemindersSubtitle: "业务通知与待办", - projectRemindersEmpty: "暂无未处理通知", + projectRemindersEmpty: "暂无业务提醒", overdueAes: "逾期 AE", overdueAesDesc: "需关注安全性事件处理时效", overdueMonitoringIssues: "逾期监查问题", diff --git a/frontend/src/plugins/elementPlus.test.ts b/frontend/src/plugins/elementPlus.test.ts new file mode 100644 index 00000000..dfd4ec79 --- /dev/null +++ b/frontend/src/plugins/elementPlus.test.ts @@ -0,0 +1,14 @@ +import { createApp, defineComponent } from "vue"; +import { describe, expect, it } from "vitest"; +import { installElementPlus } from "./elementPlus"; + +describe("installElementPlus", () => { + it("registers radio group subcomponents used by the monitoring time selector", () => { + const app = createApp(defineComponent({ template: "
" })); + + installElementPlus(app); + + expect(app.component("ElRadioGroup")).toBeDefined(); + expect(app.component("ElRadioButton")).toBeDefined(); + }); +}); diff --git a/frontend/src/plugins/elementPlus.ts b/frontend/src/plugins/elementPlus.ts index 5ff80dc0..0ae8fcb5 100644 --- a/frontend/src/plugins/elementPlus.ts +++ b/frontend/src/plugins/elementPlus.ts @@ -127,6 +127,8 @@ const components = [ ]; export const installElementPlus = (app: App): void => { - for (const component of components) app.use(component); + for (const component of components) { + if (component.name) app.component(component.name, component); + } app.use(ElLoading); }; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 8dcb2db2..a07e4d69 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -31,6 +31,7 @@ const EmailSettings = () => import("../views/admin/EmailSettings.vue"); const ProjectDetail = () => import("../views/admin/ProjectDetail.vue"); const ProjectOverview = () => import("../views/ia/ProjectOverview.vue"); const ProjectMilestones = () => import("../views/ia/ProjectMilestones.vue"); +const ProjectNotifications = () => import("../views/ProjectNotifications.vue"); const FeeContracts = () => import("../views/fees/ContractFees.vue"); const FeeContractDetail = () => import("../views/fees/ContractFeeDetail.vue"); const DrugShipments = () => import("../views/ia/DrugShipments.vue"); @@ -149,6 +150,12 @@ const routes: RouteRecordRaw[] = [ component: ProjectMilestones, meta: { title: TEXT.menu.projectMilestones, requiresStudy: true }, }, + { + path: "project/notifications", + name: "ProjectNotifications", + component: ProjectNotifications, + meta: { title: "提醒中心", requiresStudy: true }, + }, { path: "fees/contracts", name: "FeeContracts", diff --git a/frontend/src/runtime/notifications.test.ts b/frontend/src/runtime/notifications.test.ts index d6083cb6..838e72de 100644 --- a/frontend/src/runtime/notifications.test.ts +++ b/frontend/src/runtime/notifications.test.ts @@ -1,5 +1,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { getNotificationPermission, requestNotificationPermission, showSystemNotificationProbe } from "./notifications"; +import { + getNotificationPermission, + requestNotificationPermission, + showSystemNotification, + showSystemNotificationProbe, +} from "./notifications"; const isPermissionGrantedMock = vi.hoisted(() => vi.fn()); const requestPermissionMock = vi.hoisted(() => vi.fn()); @@ -96,4 +101,15 @@ describe("notification runtime", () => { body: "系统通知已可用", }); }); + + it("keeps business details out of the desktop system notification", async () => { + enableTauriRuntime(); + setBrowserNotificationPermission("granted"); + + expect(await showSystemNotification()).toBe(true); + expect(notificationDispatchMock).toHaveBeenCalledWith({ + title: "CTMS 待办提醒", + body: "有新的业务提醒待查看", + }); + }); }); diff --git a/frontend/src/runtime/notifications.ts b/frontend/src/runtime/notifications.ts index 69658d90..873311d8 100644 --- a/frontend/src/runtime/notifications.ts +++ b/frontend/src/runtime/notifications.ts @@ -7,9 +7,9 @@ type StaticNotificationPayload = { body: string; }; -const fileUpdateNotification: StaticNotificationPayload = { - title: "CTMS 文件更新", - body: "有新的文件版本待查看", +const businessReminderNotification: StaticNotificationPayload = { + title: "CTMS 待办提醒", + body: "有新的业务提醒待查看", }; const notificationProbe: StaticNotificationPayload = { @@ -53,7 +53,7 @@ const sendStaticSystemNotification = async (payload: StaticNotificationPayload): }; export const showSystemNotification = async (): Promise => - sendStaticSystemNotification(fileUpdateNotification); + sendStaticSystemNotification(businessReminderNotification); export const showSystemNotificationProbe = async (): Promise => sendStaticSystemNotification(notificationProbe); diff --git a/frontend/src/session/desktopNotificationManager.test.ts b/frontend/src/session/desktopNotificationManager.test.ts index 5229bcee..c02f1487 100644 --- a/frontend/src/session/desktopNotificationManager.test.ts +++ b/frontend/src/session/desktopNotificationManager.test.ts @@ -55,7 +55,11 @@ describe("desktop notification manager", () => { showNotificationMock .mockResolvedValueOnce(true) .mockRejectedValueOnce(new Error("notification failed")); - const { initDesktopNotificationManager, triggerDesktopNotificationPoll } = await import("./desktopNotificationManager"); + const { + getDesktopNotificationStatus, + initDesktopNotificationManager, + triggerDesktopNotificationPoll, + } = await import("./desktopNotificationManager"); initDesktopNotificationManager(); triggerDesktopNotificationPoll(); @@ -64,6 +68,11 @@ describe("desktop notification manager", () => { expect(showNotificationMock).toHaveBeenCalledTimes(2); expect(acknowledgeNotificationsMock).toHaveBeenCalledWith("claim-token", ["notification-1"]); expect(acknowledgeNotificationsMock).toHaveBeenCalledTimes(1); + expect(getDesktopNotificationStatus()).toMatchObject({ + state: "error", + lastDeliveredCount: 0, + consecutiveFailures: 1, + }); }); it("does not acknowledge notifications when the system dispatch does not happen", async () => { @@ -83,7 +92,11 @@ describe("desktop notification manager", () => { it("does not claim notifications before operating system permission is granted", async () => { getPermissionMock.mockResolvedValue("prompt"); - const { initDesktopNotificationManager, triggerDesktopNotificationPoll } = await import("./desktopNotificationManager"); + const { + getDesktopNotificationStatus, + initDesktopNotificationManager, + triggerDesktopNotificationPoll, + } = await import("./desktopNotificationManager"); initDesktopNotificationManager(); triggerDesktopNotificationPoll(); @@ -91,5 +104,54 @@ describe("desktop notification manager", () => { expect(claimNotificationsMock).not.toHaveBeenCalled(); expect(acknowledgeNotificationsMock).not.toHaveBeenCalled(); + expect(getDesktopNotificationStatus()).toMatchObject({ + state: "permission-required", + lastDeliveredCount: 0, + consecutiveFailures: 0, + }); + }); + + it("publishes real delivery progress without exposing claimed reminder content", async () => { + const { + getDesktopNotificationStatus, + initDesktopNotificationManager, + listenDesktopNotificationStatus, + triggerDesktopNotificationPoll, + } = await import("./desktopNotificationManager"); + const states: string[] = []; + const unlisten = listenDesktopNotificationStatus((snapshot) => states.push(snapshot.state)); + + initDesktopNotificationManager(); + triggerDesktopNotificationPoll(); + await vi.runOnlyPendingTimersAsync(); + + expect(states).toEqual(expect.arrayContaining(["idle", "polling", "delivered"])); + expect(getDesktopNotificationStatus()).toMatchObject({ + state: "delivered", + lastDeliveredCount: 2, + consecutiveFailures: 0, + }); + expect(getDesktopNotificationStatus().lastCheckedAt).toBeTruthy(); + expect(getDesktopNotificationStatus().lastDeliveredAt).toBeTruthy(); + unlisten(); + }); + + it("reports a disabled server subscription without claiming reminders", async () => { + getSubscriptionMock.mockResolvedValue({ data: { enabled: false } }); + const { + getDesktopNotificationStatus, + initDesktopNotificationManager, + triggerDesktopNotificationPoll, + } = await import("./desktopNotificationManager"); + + initDesktopNotificationManager(); + triggerDesktopNotificationPoll(); + await vi.runOnlyPendingTimersAsync(); + + expect(claimNotificationsMock).not.toHaveBeenCalled(); + expect(getDesktopNotificationStatus()).toMatchObject({ + state: "subscription-disabled", + lastDeliveredCount: 0, + }); }); }); diff --git a/frontend/src/session/desktopNotificationManager.ts b/frontend/src/session/desktopNotificationManager.ts index fd9e5ba3..8b72b7e0 100644 --- a/frontend/src/session/desktopNotificationManager.ts +++ b/frontend/src/session/desktopNotificationManager.ts @@ -13,9 +13,39 @@ import { const POLL_INTERVAL_MS = 60_000; const MAX_BACKOFF_MS = 15 * 60_000; +export type DesktopNotificationDeliveryState = + | "idle" + | "signed-out" + | "permission-required" + | "subscription-disabled" + | "polling" + | "ready" + | "delivered" + | "error"; + +export interface DesktopNotificationStatusSnapshot { + state: DesktopNotificationDeliveryState; + lastCheckedAt?: string; + lastDeliveredAt?: string; + lastDeliveredCount: number; + consecutiveFailures: number; +} + let initialized = false; let timer: number | null = null; let failureCount = 0; +let status: DesktopNotificationStatusSnapshot = { + state: "idle", + lastDeliveredCount: 0, + consecutiveFailures: 0, +}; +const statusListeners = new Set<(snapshot: DesktopNotificationStatusSnapshot) => void>(); + +const publishStatus = (patch: Partial) => { + status = { ...status, ...patch }; + const snapshot = { ...status }; + statusListeners.forEach((listener) => listener(snapshot)); +}; const schedule = (delay: number) => { if (timer !== null) window.clearTimeout(timer); @@ -23,22 +53,41 @@ const schedule = (delay: number) => { }; const poll = async () => { + timer = null; if (!getToken()) { failureCount = 0; + publishStatus({ + state: "signed-out", + lastCheckedAt: new Date().toISOString(), + consecutiveFailures: 0, + }); schedule(POLL_INTERVAL_MS); return; } try { const permission = await getNotificationPermission(); if (permission !== "granted") { + failureCount = 0; + publishStatus({ + state: "permission-required", + lastCheckedAt: new Date().toISOString(), + consecutiveFailures: 0, + }); schedule(POLL_INTERVAL_MS); return; } const { data: subscription } = await getDesktopNotificationSubscription(); if (!subscription.enabled) { + failureCount = 0; + publishStatus({ + state: "subscription-disabled", + lastCheckedAt: new Date().toISOString(), + consecutiveFailures: 0, + }); schedule(POLL_INTERVAL_MS); return; } + publishStatus({ state: "polling" }); const { data } = await claimDesktopNotifications(); const deliveredIds: string[] = []; let deliveryFailed = false; @@ -60,13 +109,36 @@ const poll = async () => { throw new Error("desktop notification delivery failed"); } failureCount = 0; + const checkedAt = new Date().toISOString(); + publishStatus({ + state: deliveredIds.length ? "delivered" : "ready", + lastCheckedAt: checkedAt, + lastDeliveredAt: deliveredIds.length ? checkedAt : status.lastDeliveredAt, + lastDeliveredCount: deliveredIds.length || status.lastDeliveredCount, + consecutiveFailures: 0, + }); schedule(POLL_INTERVAL_MS); } catch { failureCount += 1; + publishStatus({ + state: "error", + lastCheckedAt: new Date().toISOString(), + consecutiveFailures: failureCount, + }); schedule(Math.min(POLL_INTERVAL_MS * 2 ** failureCount, MAX_BACKOFF_MS)); } }; +export const getDesktopNotificationStatus = (): DesktopNotificationStatusSnapshot => ({ ...status }); + +export const listenDesktopNotificationStatus = ( + listener: (snapshot: DesktopNotificationStatusSnapshot) => void, +): (() => void) => { + statusListeners.add(listener); + listener(getDesktopNotificationStatus()); + return () => statusListeners.delete(listener); +}; + export const initDesktopNotificationManager = (): void => { if (initialized || !isTauriRuntime()) return; initialized = true; @@ -84,4 +156,9 @@ export const stopDesktopNotificationManager = (): void => { timer = null; initialized = false; failureCount = 0; + publishStatus({ + state: "idle", + lastDeliveredCount: 0, + consecutiveFailures: 0, + }); }; diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 61301b4f..b7d4eafc 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -930,9 +930,13 @@ body { } .desktop-workbench .desktop-route-shell .el-radio-button__inner { + display: inline-flex; + align-items: center; + justify-content: center; min-height: 26px; padding: 5px 10px; border-radius: 5px; + line-height: 1; } .desktop-workbench .desktop-route-shell .el-drawer__header { diff --git a/frontend/src/types/notifications.ts b/frontend/src/types/notifications.ts index 843cb4cb..ee832d26 100644 --- a/frontend/src/types/notifications.ts +++ b/frontend/src/types/notifications.ts @@ -25,11 +25,15 @@ export interface GeneralNotificationItem { action_path?: string | null; source_type: string; source_id: string; + requires_action: boolean; + due_at?: string | null; read_at?: string | null; + resolved_at?: string | null; created_at: string; } export interface GeneralNotificationFeed { unread_count: number; + total_count: number; items: GeneralNotificationItem[]; } diff --git a/frontend/src/views/DesktopPreferences.vue b/frontend/src/views/DesktopPreferences.vue index b74f4002..4f19483b 100644 --- a/frontend/src/views/DesktopPreferences.vue +++ b/frontend/src/views/DesktopPreferences.vue @@ -153,24 +153,84 @@
-
-
-
系统通知
-
不含项目详情的文件更新提示
+
+
+
+
系统通知
+
开启后定期领取服务端真实业务提醒,系统弹窗不显示业务详情
+
+
+ + 系统权限:{{ notificationPermissionText }} + + +
-
- + - - {{ notificationPermissionText }} - - - - 测试通知 - + +
+ 系统权限 + {{ notificationPermissionText }} + 服务端订阅 + {{ desktopNotificationsEnabled ? "已开启" : "未开启" }} + 真实业务路径 + {{ notificationDeliveryStatusText }} + 最近检查 + {{ notificationLastCheckedText }} + 最近投递 + {{ notificationLastDeliveredText }} +
+ +

+ 系统弹窗固定显示“CTMS 待办提醒”,项目、文件、AE、监查问题和受试者信息仅在登录后的提醒中心展示。 +

+ +
+ + 授权并开启 + + + 重新检测权限 + + + 立即检查业务提醒 + + + + 发送测试通知 + + 打开提醒中心 +
@@ -253,7 +313,12 @@ import { promptForPendingDesktopUpdate, type DesktopUpdateStatusSnapshot, } from "../session/desktopUpdateManager"; -import { triggerDesktopNotificationPoll } from "../session/desktopNotificationManager"; +import { + getDesktopNotificationStatus, + listenDesktopNotificationStatus, + triggerDesktopNotificationPoll, + type DesktopNotificationStatusSnapshot, +} from "../session/desktopNotificationManager"; import { useAuthStore } from "../store/auth"; import { useStudyStore } from "../store/study"; @@ -309,8 +374,10 @@ const desktopShortcuts = ref(readDesktopShortcutPref const capturingShortcutAction = ref(null); const shortcutCaptureElement = ref(null); const notificationPermission = ref("unsupported"); +const desktopNotificationStatus = ref(getDesktopNotificationStatus()); const desktopUpdateStatus = ref(getDesktopUpdateStatus()); let updateStatusUnlisten: (() => void) | undefined; +let notificationStatusUnlisten: (() => void) | undefined; let connectionPendingTimer: number | undefined; const themeOptions = [ { value: "system" as const, label: "跟随系统", icon: Monitor }, @@ -345,19 +412,95 @@ const clientMetadataRows = computed(() => [ ]); const notificationPermissionText = computed(() => { + if (notificationPermission.value === "granted") return "已授权"; if (notificationPermission.value === "denied") return "已拒绝"; if (notificationPermission.value === "prompt") return "待授权"; return "不可用"; }); -const showNotificationPermissionTag = computed(() => notificationPermission.value !== "granted"); - const notificationPermissionTagType = computed(() => { + if (notificationPermission.value === "granted") return "success"; if (notificationPermission.value === "denied") return "danger"; if (notificationPermission.value === "prompt") return "warning"; return "info"; }); +const desktopNotificationCheckLoading = computed(() => desktopNotificationStatus.value.state === "polling"); + +const formatNotificationTime = (value?: string) => { + if (!value) return "尚未检查"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "尚未检查"; + return date.toLocaleString("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); +}; + +const notificationDeliveryStatusText = computed(() => { + const labels: Record = { + idle: "等待桌面通知服务启动", + "signed-out": "登录后自动检查", + "permission-required": "等待系统授权", + "subscription-disabled": "等待开启服务端订阅", + polling: "正在调用真实业务提醒接口", + ready: "链路正常,暂无待投递提醒", + delivered: "已完成领取、系统投递和服务端确认", + error: "检查失败,将自动退避重试", + }; + return labels[desktopNotificationStatus.value.state]; +}); + +const notificationLastCheckedText = computed(() => + formatNotificationTime(desktopNotificationStatus.value.lastCheckedAt), +); + +const notificationLastDeliveredText = computed(() => { + if (!desktopNotificationStatus.value.lastDeliveredAt) return "本次运行尚无投递"; + return `${formatNotificationTime(desktopNotificationStatus.value.lastDeliveredAt)} · ${desktopNotificationStatus.value.lastDeliveredCount} 条`; +}); + +const notificationSystemSettingsPath = computed(() => { + if (clientMetadata.platform === "macos") return "系统设置 → 通知 → CTMS → 打开“允许通知”"; + if (clientMetadata.platform === "windows") return "设置 → 系统 → 通知 → CTMS → 打开通知"; + return "系统通知设置 → CTMS → 允许通知"; +}); + +const notificationGuidanceTone = computed<"success" | "warning" | "error" | "info">(() => { + if (notificationPermission.value === "unsupported") return "info"; + if (notificationPermission.value === "denied") return "error"; + if (notificationPermission.value === "prompt") return "warning"; + if (desktopNotificationStatus.value.state === "error") return "error"; + return desktopNotificationsEnabled.value ? "success" : "warning"; +}); + +const notificationGuidanceTitle = computed(() => { + if (notificationPermission.value === "unsupported") return "当前客户端不支持系统通知"; + if (notificationPermission.value === "denied") return "需要在系统设置中恢复 CTMS 通知权限"; + if (notificationPermission.value === "prompt") return "允许 CTMS 发送系统通知"; + if (!desktopNotificationsEnabled.value) return "系统权限已就绪,服务端订阅尚未开启"; + if (desktopNotificationStatus.value.state === "error") return "真实业务提醒检查失败"; + if (desktopNotificationStatus.value.state === "delivered") return "真实业务提醒已完成系统投递"; + return "真实业务提醒已开启"; +}); + +const notificationGuidanceDescription = computed(() => { + if (notificationPermission.value === "unsupported") return "请在 CTMS Desktop 中配置系统通知。"; + if (notificationPermission.value === "denied") { + return `macOS/Windows 不会再次弹出授权框。请前往:${notificationSystemSettingsPath.value},返回后点击“重新检测权限”。`; + } + if (notificationPermission.value === "prompt") { + return "点击“授权并开启”后,系统会请求一次通知权限;允许后才会开启当前账号的服务端订阅。"; + } + if (!desktopNotificationsEnabled.value) return "打开右侧开关后,客户端会定期领取并确认当前账号的待投递业务提醒。"; + if (desktopNotificationStatus.value.state === "error") return "客户端会自动重试;也可以检查网络后立即重新检查。"; + return "客户端每分钟检查一次;系统弹窗只作安全提示,业务处理仍在登录后的提醒中心完成。"; +}); + const desktopUpdateDescription = computed(() => { const pending = desktopUpdateStatus.value.pendingUpdate; if (desktopUpdateStatus.value.checking) return "正在检查签名更新"; @@ -621,38 +764,94 @@ const saveDesktopServerConfig = async () => { }; const loadNotificationState = async () => { - notificationPermission.value = await getNotificationPermission(); + try { + notificationPermission.value = await getNotificationPermission(); + } catch { + notificationPermission.value = "unsupported"; + } try { const { data } = await getDesktopNotificationSubscription(); desktopNotificationsEnabled.value = data.enabled; } catch { desktopNotificationsEnabled.value = false; } + triggerDesktopNotificationPoll(); +}; + +const updateDesktopNotificationSubscription = async (enabled: boolean) => { + if (desktopNotificationLoading.value) return false; + const previousEnabled = desktopNotificationsEnabled.value; + desktopNotificationLoading.value = true; + try { + if (enabled) { + notificationPermission.value = await getNotificationPermission(); + if (notificationPermission.value === "prompt") { + notificationPermission.value = await requestNotificationPermission(); + } + if (notificationPermission.value !== "granted") { + ElMessage.warning(`系统通知权限未开启:${notificationSystemSettingsPath.value}`); + return false; + } + } + const { data } = await setDesktopNotificationSubscription(enabled); + desktopNotificationsEnabled.value = data.enabled; + triggerDesktopNotificationPoll(); + return true; + } catch (error: any) { + desktopNotificationsEnabled.value = previousEnabled; + ElMessage.error(error?.response?.data?.detail || "系统通知设置失败"); + return false; + } finally { + desktopNotificationLoading.value = false; + } }; const onDesktopNotificationChange = async (value: string | number | boolean) => { + await updateDesktopNotificationSubscription(Boolean(value)); +}; + +const authorizeAndEnableDesktopNotifications = async () => { + await updateDesktopNotificationSubscription(true); +}; + +const refreshDesktopNotificationPermission = async () => { if (desktopNotificationLoading.value) return; desktopNotificationLoading.value = true; try { - if (value) { - notificationPermission.value = await requestNotificationPermission(); - if (notificationPermission.value !== "granted") { - desktopNotificationsEnabled.value = false; - ElMessage.warning("系统通知权限未开启,请在系统设置中允许 CTMS 通知"); - return; - } + notificationPermission.value = await getNotificationPermission(); + if (notificationPermission.value === "granted") { + triggerDesktopNotificationPoll(); + ElMessage.success("已检测到系统通知权限"); + return; } - const { data } = await setDesktopNotificationSubscription(Boolean(value)); - desktopNotificationsEnabled.value = data.enabled; - if (data.enabled) triggerDesktopNotificationPoll(); - } catch (error: any) { - desktopNotificationsEnabled.value = !Boolean(value); - ElMessage.error(error?.response?.data?.detail || "系统通知设置失败"); + ElMessage.warning(`仍未检测到系统通知权限:${notificationSystemSettingsPath.value}`); + } catch { + notificationPermission.value = "unsupported"; + ElMessage.error("系统通知权限检测失败"); } finally { desktopNotificationLoading.value = false; } }; +const checkDesktopBusinessNotifications = async () => { + if (desktopNotificationCheckLoading.value) return; + try { + notificationPermission.value = await getNotificationPermission(); + if (notificationPermission.value !== "granted") { + ElMessage.warning(`系统通知权限未开启:${notificationSystemSettingsPath.value}`); + return; + } + if (!desktopNotificationsEnabled.value) { + await updateDesktopNotificationSubscription(true); + return; + } + triggerDesktopNotificationPoll(); + ElMessage.info("已发起真实业务提醒检查"); + } catch { + ElMessage.error("业务提醒检查失败"); + } +}; + const sendDesktopNotificationTest = async () => { if (desktopNotificationTestLoading.value) return; desktopNotificationTestLoading.value = true; @@ -662,7 +861,7 @@ const sendDesktopNotificationTest = async () => { notificationPermission.value = await requestNotificationPermission(); } if (notificationPermission.value !== "granted") { - ElMessage.warning("系统通知权限未开启,请在系统设置中允许 CTMS 通知"); + ElMessage.warning(`系统通知权限未开启:${notificationSystemSettingsPath.value}`); return; } const sent = await showSystemNotificationProbe(); @@ -678,6 +877,11 @@ const sendDesktopNotificationTest = async () => { } }; +const openProjectNotificationCenter = () => { + emit("close-request"); + void router.push("/project/notifications"); +}; + const checkDesktopUpdateNow = async () => { if (desktopUpdateChecking.value) return; desktopUpdateChecking.value = true; @@ -765,6 +969,9 @@ onMounted(() => { updateStatusUnlisten = listenDesktopUpdateStatus((status) => { desktopUpdateStatus.value = status; }); + notificationStatusUnlisten = listenDesktopNotificationStatus((status) => { + desktopNotificationStatus.value = status; + }); void loadNotificationState(); }); @@ -773,6 +980,7 @@ onBeforeUnmount(() => { window.removeEventListener("pointerdown", cancelDesktopShortcutCaptureOnPointerDown, { capture: true }); clearConnectionPendingTimer(); updateStatusUnlisten?.(); + notificationStatusUnlisten?.(); }); @@ -1090,6 +1298,7 @@ h3 { .preferences-panel-enter-active .connection-alert, .preferences-panel-enter-active .connection-diagnostic, .preferences-panel-enter-active .preference-row, +.preferences-panel-enter-active .notification-guidance, .preferences-panel-enter-active .shortcut-settings, .preferences-panel-enter-active .metadata-panel { animation: preference-card-rise 220ms cubic-bezier(0.2, 0.8, 0.2, 1) both; @@ -1287,6 +1496,75 @@ h3 { flex-shrink: 0; } +/* ===================================================== + * 系统通知授权与真实投递状态 + * ===================================================== */ +.notification-settings { + display: grid; + gap: 10px; +} + +.notification-primary-row { + align-items: flex-start; +} + +.notification-guidance { + display: grid; + gap: 14px; + padding: 14px 16px 16px; + border: 1px solid var(--pref-border-card); + border-radius: 10px; + background: var(--pref-bg-card); + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.05), 0 1px 1px rgba(15, 23, 42, 0.03); +} + +.notification-guidance :deep(.el-alert) { + align-items: flex-start; + border-radius: 8px; +} + +.notification-guidance :deep(.el-alert__description) { + line-height: 1.55; +} + +.notification-status-grid { + display: grid; + grid-template-columns: 84px minmax(0, 1fr); + gap: 8px 12px; + padding: 0 2px; +} + +.notification-status-grid span { + color: var(--pref-text-muted); + font-size: 11.5px; +} + +.notification-status-grid strong { + min-width: 0; + color: var(--pref-text-primary); + font-size: 11.5px; + font-weight: 620; + overflow-wrap: anywhere; +} + +.notification-privacy-note { + margin: 0; + padding: 10px 12px; + border-radius: 8px; + background: #f7f9fc; + color: var(--pref-text-secondary); + font-size: 11.5px; + line-height: 1.55; +} + +.notification-actions { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: 8px; +} + /* ===================================================== * 快捷键 * ===================================================== */ @@ -1528,6 +1806,7 @@ code { :global([data-ctms-theme="dark"] .desktop-preferences .server-config-form), :global([data-ctms-theme="dark"] .desktop-preferences .connection-diagnostic), :global([data-ctms-theme="dark"] .desktop-preferences .preference-row), +:global([data-ctms-theme="dark"] .desktop-preferences .notification-guidance), :global([data-ctms-theme="dark"] .desktop-preferences .shortcut-settings), :global([data-ctms-theme="dark"] .desktop-preferences .metadata-panel) { border-color: var(--pref-border-card); @@ -1547,6 +1826,19 @@ code { color: var(--pref-text-primary); } +:global([data-ctms-theme="dark"] .desktop-preferences .notification-status-grid span) { + color: var(--pref-text-muted); +} + +:global([data-ctms-theme="dark"] .desktop-preferences .notification-status-grid strong) { + color: var(--pref-text-primary); +} + +:global([data-ctms-theme="dark"] .desktop-preferences .notification-privacy-note) { + background: #0d1520; + color: var(--pref-text-secondary); +} + :global([data-ctms-theme="dark"] .desktop-preferences .theme-segmented) { @@ -1593,6 +1885,7 @@ code { .preference-panel :deep(.el-button), .connection-alert, .connection-diagnostic, + .notification-guidance, .connection-feedback-enter-active, .connection-feedback-leave-active, .preferences-header-enter-active, diff --git a/frontend/src/views/ProjectNotifications.test.ts b/frontend/src/views/ProjectNotifications.test.ts new file mode 100644 index 00000000..efaf9278 --- /dev/null +++ b/frontend/src/views/ProjectNotifications.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const source = readFileSync(resolve(__dirname, "./ProjectNotifications.vue"), "utf8"); + +describe("project notification center contract", () => { + it("keeps read state separate from actionable business state", () => { + expect(source).toContain("已读不代表业务已完成"); + expect(source).toContain("requires_action"); + expect(source).not.toContain("include_resolved"); + expect(source).toContain("markAllGeneralNotificationsRead"); + expect(source).toContain('category.startsWith("VISIT_WINDOW_")'); + expect(source).toContain('return "访视窗口"'); + }); +}); diff --git a/frontend/src/views/ProjectNotifications.vue b/frontend/src/views/ProjectNotifications.vue new file mode 100644 index 00000000..e3f0de0d --- /dev/null +++ b/frontend/src/views/ProjectNotifications.vue @@ -0,0 +1,739 @@ + + + + + diff --git a/frontend/src/views/admin/UserLoginActivitiesDrawer.vue b/frontend/src/views/admin/UserLoginActivitiesDrawer.vue index ede1ec46..4478366e 100644 --- a/frontend/src/views/admin/UserLoginActivitiesDrawer.vue +++ b/frontend/src/views/admin/UserLoginActivitiesDrawer.vue @@ -43,7 +43,7 @@ {{ scope.row.client_type === 'desktop' ? '桌面端' : '网页端' }} {{ clientDescription(scope.row) }} - 合并 {{ scope.row.grouped_count }} 条 + 累计 {{ scope.row.grouped_count }} 次会话
@@ -76,6 +76,10 @@ import { ElMessage } from "element-plus"; import { fetchUserLoginActivities } from "../../api/users"; import type { UserInfo, UserLoginActivity } from "../../types/api"; import { displayDateTime } from "../../utils/display"; +import { + groupLoginActivitiesBySource, + type DisplayLoginActivity, +} from "./loginActivitySources"; const props = defineProps<{ modelValue: boolean; @@ -95,8 +99,6 @@ const visibleProxy = computed({ set: (value: boolean) => emit("update:modelValue", value), }); -type DisplayLoginActivity = UserLoginActivity & { grouped_count: number }; - const resolvedActivityStatus = (item: UserLoginActivity) => { if (item.activity_status) return item.activity_status; if (item.ended_at) return "ENDED"; @@ -120,30 +122,9 @@ const activityStatusDescription = (item: UserLoginActivity) => { }; const clientDescription = (item: UserLoginActivity) => [item.client_platform, item.client_version].filter(Boolean).join(" · ") || "--"; -const activitySourceKey = (item: UserLoginActivity) => { - if (!item.login_ip) return `session:${item.id}`; - return [item.login_ip, item.client_type, item.client_platform || "", item.client_source || ""].join("|"); -}; -const sourceActivities = computed(() => { - const rows: DisplayLoginActivity[] = []; - const groupedHistory = new Map(); - activities.value.forEach((item) => { - const row: DisplayLoginActivity = { ...item, grouped_count: 1 }; - if (resolvedActivityStatus(item) === "ONLINE") { - rows.push(row); - return; - } - const key = activitySourceKey(item); - const existing = groupedHistory.get(key); - if (existing) { - existing.grouped_count += 1; - return; - } - groupedHistory.set(key, row); - rows.push(row); - }); - return rows; -}); +const sourceActivities = computed(() => + groupLoginActivitiesBySource(activities.value, resolvedActivityStatus), +); const displayActivities = computed(() => activityViewMode.value === "source" ? sourceActivities.value diff --git a/frontend/src/views/admin/UsersLoginStatus.test.ts b/frontend/src/views/admin/UsersLoginStatus.test.ts index 25f6f079..afadc3b6 100644 --- a/frontend/src/views/admin/UsersLoginStatus.test.ts +++ b/frontend/src/views/admin/UsersLoginStatus.test.ts @@ -22,7 +22,7 @@ describe("admin user login status", () => { expect(drawer).toContain("resolvedActivityStatus"); expect(drawer).toContain("最近来源"); expect(drawer).toContain("全部会话"); - expect(drawer).toContain("activitySourceKey"); + expect(drawer).toContain("groupLoginActivitiesBySource"); expect(drawer).toContain("grouped_count"); expect(drawer).toContain("scope.row.ended_at"); expect(drawer).not.toContain("const isRecent"); diff --git a/frontend/src/views/admin/loginActivitySources.test.ts b/frontend/src/views/admin/loginActivitySources.test.ts new file mode 100644 index 00000000..6e4bf934 --- /dev/null +++ b/frontend/src/views/admin/loginActivitySources.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import type { UserLoginActivity } from "../../types/api"; +import { activitySourceKey, groupLoginActivitiesBySource } from "./loginActivitySources"; + +const activity = (overrides: Partial): UserLoginActivity => ({ + id: "session-1", + client_type: "web", + client_platform: "macos", + client_version: "0.1.0", + login_ip: "192.168.97.1", + login_at: "2026-07-16T02:23:04Z", + last_seen_at: "2026-07-16T03:08:32Z", + activity_status: "ENDED", + ...overrides, +}); + +describe("login activity sources", () => { + it("uses only client type and a recorded IP as the source identity", () => { + expect(activitySourceKey(activity({ client_platform: "windows", client_source: "installer" }))).toBe( + "web|192.168.97.1", + ); + expect(activitySourceKey(activity({ id: "legacy", login_ip: null }))).toBe("session:legacy"); + }); + + it("merges online and historical sessions from the same client and IP", () => { + const rows = groupLoginActivitiesBySource( + [ + activity({ + id: "current", + login_at: "2026-07-16T07:22:42Z", + last_seen_at: "2026-07-16T07:53:00Z", + activity_status: "ONLINE", + ended_at: null, + }), + activity({ id: "history", ended_at: "2026-07-16T03:08:32Z" }), + activity({ + id: "desktop", + client_type: "desktop", + login_at: "2026-07-15T08:42:02Z", + last_seen_at: "2026-07-16T07:53:42Z", + activity_status: "ONLINE", + ended_at: null, + }), + ], + (item) => item.activity_status!, + ); + + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ id: "desktop", grouped_count: 1, activity_status: "ONLINE" }); + expect(rows[1]).toMatchObject({ + id: "current", + grouped_count: 2, + login_at: "2026-07-16T02:23:04Z", + last_seen_at: "2026-07-16T07:53:00Z", + activity_status: "ONLINE", + ended_at: null, + }); + }); + + it("does not merge sessions whose IP was not recorded", () => { + const rows = groupLoginActivitiesBySource( + [activity({ id: "legacy-1", login_ip: null }), activity({ id: "legacy-2", login_ip: null })], + (item) => item.activity_status!, + ); + + expect(rows).toHaveLength(2); + }); +}); diff --git a/frontend/src/views/admin/loginActivitySources.ts b/frontend/src/views/admin/loginActivitySources.ts new file mode 100644 index 00000000..28edf075 --- /dev/null +++ b/frontend/src/views/admin/loginActivitySources.ts @@ -0,0 +1,56 @@ +import type { UserLoginActivity } from "../../types/api"; + +export type DisplayLoginActivity = UserLoginActivity & { grouped_count: number }; + +type ActivityStatus = NonNullable; + +const activityTime = (value: string) => new Date(value).getTime(); + +export const activitySourceKey = (item: UserLoginActivity) => { + if (!item.login_ip) return `session:${item.id}`; + return [item.client_type, item.login_ip].join("|"); +}; + +export const groupLoginActivitiesBySource = ( + activities: UserLoginActivity[], + resolveStatus: (item: UserLoginActivity) => ActivityStatus, +): DisplayLoginActivity[] => { + const grouped = new Map< + string, + { row: DisplayLoginActivity; firstLoginAt: string; hasOnlineSession: boolean } + >(); + + activities.forEach((item) => { + const key = activitySourceKey(item); + const status = resolveStatus(item); + const existing = grouped.get(key); + if (!existing) { + grouped.set(key, { + row: { ...item, activity_status: status, grouped_count: 1 }, + firstLoginAt: item.login_at, + hasOnlineSession: status === "ONLINE", + }); + return; + } + + existing.row.grouped_count += 1; + existing.hasOnlineSession ||= status === "ONLINE"; + if (activityTime(item.login_at) < activityTime(existing.firstLoginAt)) { + existing.firstLoginAt = item.login_at; + } + if (activityTime(item.last_seen_at) > activityTime(existing.row.last_seen_at)) { + const groupedCount = existing.row.grouped_count; + existing.row = { ...item, activity_status: status, grouped_count: groupedCount }; + } + }); + + return Array.from(grouped.values()) + .map(({ row, firstLoginAt, hasOnlineSession }) => ({ + ...row, + login_at: firstLoginAt, + activity_status: hasOnlineSession ? "ONLINE" : row.activity_status, + ended_at: hasOnlineSession ? null : row.ended_at, + end_reason: hasOnlineSession ? null : row.end_reason, + })) + .sort((left, right) => activityTime(right.last_seen_at) - activityTime(left.last_seen_at)); +};