Compare commits

2 Commits

Author SHA1 Message Date
Cheng Zhou 3e77127687 功能(提醒):统一项目提醒中心与桌面通知链路
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
增加通用提醒状态、数据库迁移和定时同步,覆盖风险时效、文件回执、项目里程碑、访视窗口与协作申请。

新增网页端和桌面端提醒中心、真实投递诊断与固定隐私通知正文,并补齐登录来源聚合、测试和说明文档。
2026-07-16 16:50:34 +08:00
Cheng Zhou 88bd0c5942 fix(审计): 移除共享库审计与预览噪声 2026-07-16 15:29:26 +08:00
61 changed files with 3028 additions and 624 deletions
@@ -0,0 +1,35 @@
"""Remove shared-library and passive Office preview audit noise.
Revision ID: 20260716_04
Revises: 20260716_03
"""
from alembic import op
revision = "20260716_04"
down_revision = "20260716_03"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("""
DELETE FROM audit_logs
WHERE action = 'OFFICE_PREVIEW_OPEN'
OR lower(entity_type) IN (
'faq_category',
'faq_item',
'faq_reply',
'faq_replies',
'precaution',
'knowledge_note',
'knowledge_notes',
'collaboration_file'
)
""")
def downgrade() -> None:
# 被清理的是明确排除出审计范围的共享库和被动预览记录,无法也不应伪造恢复。
pass
@@ -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")
+4
View File
@@ -77,6 +77,7 @@ FAQ_REPLY_ATTACHMENT_PERMISSION_BY_ACTION = {
"read": "faq:read",
"delete": "faq_attachments:delete",
}
SHARED_LIBRARY_ENTITY_TYPES = {"precaution", "faq_replies"}
STARTUP_AUTH_ATTACHMENT_ENTITY_TYPES = {
"startup_kickoff",
"startup_kickoff_minutes",
@@ -239,6 +240,7 @@ async def upload_attachment(
content_type=file.content_type,
uploaded_by=current_user.id,
)
if entity_type not in SHARED_LIBRARY_ENTITY_TYPES:
await audit_crud.log_action(
db,
study_id=study_id,
@@ -442,6 +444,7 @@ async def global_delete_attachment(
if not can_delete:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
await attachment_crud.soft_delete_attachment(db, attachment)
if attachment.entity_type not in SHARED_LIBRARY_ENTITY_TYPES:
await audit_crud.log_action(
db,
study_id=attachment.study_id,
@@ -494,6 +497,7 @@ async def delete_attachment(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限删除附件")
await attachment_crud.soft_delete_attachment(db, attachment)
if entity_type not in SHARED_LIBRARY_ENTITY_TYPES:
await audit_crud.log_action(
db,
study_id=study_id,
+3 -3
View File
@@ -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
)
+1 -36
View File
@@ -1,15 +1,11 @@
import uuid
import json
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, get_operator_role_label, is_system_admin, require_study_not_locked, require_api_permission
from app.core.project_permissions import role_has_api_permission
from app.crud import audit as audit_crud
from app.core.deps import get_current_user, get_db_session, is_system_admin, require_study_not_locked, require_api_permission
from app.crud import faq_category as category_crud
from app.crud import faq_item as item_crud
from app.crud import member as member_crud
from app.schemas.common import PaginatedResponse
from app.schemas.faq import CategoryCreate, CategoryRead, CategoryUpdate
from app.utils.pagination import paginate
@@ -47,16 +43,6 @@ async def create_category(
if dup:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该图标已被其他分类使用")
category = await category_crud.create_category(db, payload)
await audit_crud.log_action(
db,
study_id=payload.study_id,
entity_type="faq_category",
entity_id=category.id,
action="CREATE_FAQ_CATEGORY",
detail=json.dumps({"targetName": category.name, "description": f"创建“{category.name}”分类"}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, payload.study_id, current_user),
)
return CategoryRead.model_validate(category)
@@ -112,16 +98,6 @@ async def update_category(
if not target_study_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须提供项目 ID")
updated = await category_crud.update_category(db, category, payload)
await audit_crud.log_action(
db,
study_id=updated.study_id,
entity_type="faq_category",
entity_id=category_id,
action="UPDATE_FAQ_CATEGORY",
detail=json.dumps({"targetName": updated.name, "description": f"更新“{updated.name}”分类"}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, updated.study_id, current_user),
)
return CategoryRead.model_validate(updated)
@@ -148,16 +124,5 @@ async def delete_category(
item_count = await item_crud.count_items_by_category(db, category_id)
if item_count > 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="分类下存在 FAQ,无法删除")
category_name = category.name
await db.delete(category)
await db.commit()
await audit_crud.log_action(
db,
study_id=category.study_id,
entity_type="faq_category",
entity_id=category_id,
action="DELETE_FAQ_CATEGORY",
detail=json.dumps({"targetName": category_name, "description": f"删除“{category_name}”分类"}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, category.study_id, current_user),
)
+1 -68
View File
@@ -1,12 +1,9 @@
import json
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, get_operator_role_label, is_system_admin, require_study_not_locked, require_api_permission
from app.core.project_permissions import role_has_api_permission
from app.crud import audit as audit_crud
from app.core.deps import get_current_user, get_db_session, is_system_admin, require_study_not_locked, require_api_permission
from app.crud import faq_category as category_crud
from app.crud import faq_item as faq_crud
from app.crud import faq_reply as reply_crud
@@ -31,13 +28,6 @@ def _is_system_admin(current_user) -> bool:
return is_system_admin(current_user)
def _compact_text(value: str | None, max_length: int = 40) -> str:
text = " ".join(str(value or "").split())
if len(text) <= max_length:
return text
return f"{text[:max_length]}..."
@router.post(
"/",
response_model=FaqRead,
@@ -74,17 +64,6 @@ async def create_faq(
reply_in=FaqReplyCreate(content=payload.answer),
)
await faq_crud.set_status(db, item.id, "PROCESSING")
question_name = _compact_text(item.question)
await audit_crud.log_action(
db,
study_id=payload.study_id,
entity_type="faq_item",
entity_id=item.id,
action="CREATE_FAQ_ITEM",
detail=json.dumps({"targetName": question_name, "description": f"创建医学咨询问题“{question_name}"}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, payload.study_id, current_user),
)
return FaqRead.model_validate(item)
@@ -178,19 +157,6 @@ async def update_faq(
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
updated = await faq_crud.update_item(db, item, payload)
action = "UPDATE_FAQ_ITEM"
question_name = _compact_text(updated.question)
detail = json.dumps({"targetName": question_name, "description": f"更新医学咨询问题“{question_name}"}, ensure_ascii=False)
await audit_crud.log_action(
db,
study_id=item.study_id,
entity_type="faq_item",
entity_id=item_id,
action=action,
detail=detail,
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, item.study_id, current_user),
)
return FaqRead.model_validate(updated)
@@ -337,17 +303,6 @@ async def create_reply(
if item.status != "RESOLVED":
await faq_crud.set_status(db, item.id, "PROCESSING")
await faq_crud.touch_item(db, item.id)
question_name = _compact_text(item.question)
await audit_crud.log_action(
db,
study_id=item.study_id,
entity_type="faq_reply",
entity_id=reply.id,
action="CREATE_FAQ_REPLY",
detail=json.dumps({"targetName": question_name, "description": f"回复医学咨询问题“{question_name}"}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, item.study_id, current_user),
)
data = FaqReplyRead.model_validate(reply)
if quote:
if quote.is_deleted:
@@ -380,20 +335,9 @@ async def delete_faq(
item = await faq_crud.get_item(db, item_id)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
question_name = _compact_text(item.question)
await reply_crud.delete_replies_by_faq_id(db, item.id)
await db.delete(item)
await db.commit()
await audit_crud.log_action(
db,
study_id=item.study_id,
entity_type="faq_item",
entity_id=item_id,
action="DELETE_FAQ_ITEM",
detail=json.dumps({"targetName": question_name, "description": f"删除医学咨询问题“{question_name}"}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, item.study_id, current_user),
)
@router.delete(
@@ -415,7 +359,6 @@ async def delete_reply(
item = await faq_crud.get_item(db, item_id)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="FAQ 不存在")
question_name = _compact_text(item.question)
reply = await reply_crud.get_reply(db, reply_id)
if not reply or reply.faq_id != item.id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="回复不存在")
@@ -438,13 +381,3 @@ async def delete_reply(
resolved_by_confirm=False,
)
await faq_crud.touch_item(db, item.id)
await audit_crud.log_action(
db,
study_id=item.study_id,
entity_type="faq_reply",
entity_id=reply_id,
action="DELETE_FAQ_REPLY",
detail=json.dumps({"targetName": question_name, "description": f"删除医学咨询问题“{question_name}”的回复"}, ensure_ascii=False),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, item.study_id, current_user),
)
+9 -6
View File
@@ -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:
# 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)
except Exception:
await db.rollback()
logger.warning("Failed to synchronize legacy project reminders", exc_info=True)
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,
)
+1 -40
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import json
import os
import uuid
from pathlib import Path
@@ -11,9 +10,8 @@ from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.attachments import _ensure_attachment_permission, _ensure_study_exists
from app.core.deps import get_current_user, get_db_session, get_operator_role_label
from app.core.deps import get_current_user, get_db_session
from app.crud import attachment as attachment_crud
from app.crud import audit as audit_crud
from app.crud import document as document_crud
from app.crud import document_version as version_crud
from app.models.collaboration import CollaborationRevision
@@ -33,29 +31,6 @@ def _content_disposition(filename: str) -> str:
return f'inline; filename="{fallback}"; filename*=UTF-8\'\'{encoded}'
async def _log_preview_open(
db: AsyncSession,
*,
study_id: uuid.UUID,
resource_type: str,
resource_id: uuid.UUID,
current_user,
) -> None:
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="ATTACHMENT" if resource_type == "attachment" else "DOCUMENT_VERSION",
entity_id=resource_id,
action="OFFICE_PREVIEW_OPEN",
detail=json.dumps(
{"resource_type": resource_type, "resource_id": str(resource_id), "result": "issued"},
ensure_ascii=True,
),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@router.get(
"/attachments/{attachment_id}/config",
response_model=OnlyOfficePreviewConfigRead,
@@ -98,13 +73,6 @@ async def get_attachment_preview_config(
user_id=current_user.id,
user_name=current_user.full_name,
)
await _log_preview_open(
db,
study_id=attachment.study_id,
resource_type="attachment",
resource_id=attachment.id,
current_user=current_user,
)
response.headers["Cache-Control"] = "no-store"
return result
@@ -151,13 +119,6 @@ async def get_version_preview_config(
user_id=current_user.id,
user_name=current_user.full_name,
)
await _log_preview_open(
db,
study_id=document.trial_id,
resource_type="version",
resource_id=version.id,
current_user=current_user,
)
response.headers["Cache-Control"] = "no-store"
return result
+1 -39
View File
@@ -1,11 +1,9 @@
import uuid
import json
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_operator_role_label, get_current_user, get_db_session, require_study_not_locked, require_api_permission
from app.crud import audit as audit_crud
from app.core.deps import get_current_user, get_db_session, require_study_not_locked, require_api_permission
from app.crud import precaution as precaution_crud
from app.crud import site as site_crud
from app.crud import study as study_crud
@@ -29,11 +27,6 @@ async def _ensure_site_name_active(db: AsyncSession, study_id: uuid.UUID, site_n
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="中心已停用")
def _precaution_audit_detail(action: str, precaution) -> str:
title = str(precaution.title or "").strip() or "注意事项"
return json.dumps({"targetName": title, "description": f"{action}注意事项“{title}"}, ensure_ascii=False)
@router.post(
"/precautions",
response_model=PrecautionRead,
@@ -49,16 +42,6 @@ async def create_precaution(
await _ensure_study_exists(db, study_id)
await _ensure_site_name_active(db, study_id, precaution_in.site_name)
precaution = await precaution_crud.create_precaution(db, study_id, precaution_in, created_by=current_user.id)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="precaution",
entity_id=precaution.id,
action="CREATE_PRECAUTION",
detail=_precaution_audit_detail("创建", precaution),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return PrecautionRead.model_validate(precaution)
@@ -115,16 +98,6 @@ async def update_precaution(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
await _ensure_site_name_active(db, study_id, precaution.site_name)
precaution = await precaution_crud.update_precaution(db, precaution, precaution_in)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="precaution",
entity_id=precaution_id,
action="UPDATE_PRECAUTION",
detail=_precaution_audit_detail("更新", precaution),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
return PrecautionRead.model_validate(precaution)
@@ -144,15 +117,4 @@ async def delete_precaution(
if not precaution or precaution.study_id != study_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="注意事项不存在")
await _ensure_site_name_active(db, study_id, precaution.site_name)
precaution_detail = _precaution_audit_detail("删除", precaution)
await precaution_crud.delete_precaution(db, precaution)
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="precaution",
entity_id=precaution_id,
action="DELETE_PRECAUTION",
detail=precaution_detail,
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
+18
View File
@@ -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,
+1
View File
@@ -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"
+3
View File
@@ -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
+6 -2
View File
@@ -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)
+5 -1
View File
@@ -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())
+15 -11
View File
@@ -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]
+21 -96
View File
@@ -16,9 +16,8 @@ from sqlalchemy import and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.deps import get_operator_role_label, is_system_admin
from app.core.deps import is_system_admin
from app.core.project_permissions import role_has_api_permission
from app.crud import audit as audit_crud
from app.crud import member as member_crud
from app.models.collaboration import (
CollaborationEditRequest,
@@ -427,23 +426,6 @@ async def require_ownership_transfer(db: AsyncSession, item: CollaborationFile,
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="您没有转让此协作文件所有权的权限")
async def _audit(db: AsyncSession, item: CollaborationFile, action: str, user, extra: dict | None = None) -> None:
detail = {"file_id": str(item.id), "title": item.title}
if extra:
detail.update(extra)
await audit_crud.log_action(
db,
study_id=item.study_id,
entity_type="COLLABORATION_FILE",
entity_id=item.id,
action=action,
detail=json.dumps(detail, ensure_ascii=False),
operator_id=user.id,
operator_role=await get_operator_role_label(db, item.study_id, user),
auto_commit=False,
)
async def create_folder(db: AsyncSession, study_id: uuid.UUID, payload: CollaborationFolderCreate, user) -> CollaborationFolder:
if payload.parent_id:
await _folder_or_404(db, study_id, payload.parent_id)
@@ -618,7 +600,6 @@ async def _create_file_from_bytes(
content: bytes,
source: str,
user,
source_file_id: uuid.UUID | None = None,
) -> CollaborationFile:
membership = await member_crud.get_member(db, study_id, user.id)
await _require_role_assignable(db, study_id, user, membership, "MANAGER")
@@ -637,10 +618,6 @@ async def _create_file_from_bytes(
await db.flush()
db.add(CollaborationMember(file_id=item.id, user_id=user.id, role="MANAGER", invited_by=user.id))
await _persist_revision_bytes(db, item, content, source=source, created_by=user.id, original_filename=item.title)
audit_detail = {"source": source}
if source_file_id:
audit_detail["source_file_id"] = str(source_file_id)
await _audit(db, item, "COLLABORATION_FILE_CREATED", user, audit_detail)
await db.commit()
await db.refresh(item)
return item
@@ -775,11 +752,6 @@ async def update_file(
db: AsyncSession, item: CollaborationFile, payload: CollaborationFileUpdate, user
) -> CollaborationFile:
await require_file_manager(db, item, user)
previous_title = item.title
previous_folder_id = item.folder_id
previous_allow_export = item.allow_export
previous_allow_edit_request = item.allow_edit_request
previous_allow_sheet_structure_edit = item.allow_sheet_structure_edit
values = payload.model_dump(exclude_unset=True)
if "folder_id" in values and values["folder_id"]:
await _folder_or_404(db, item.study_id, values["folder_id"])
@@ -810,25 +782,6 @@ async def update_file(
item.generation += 1
for key, value in values.items():
setattr(item, key, value)
title_changed = item.title != previous_title
folder_changed = item.folder_id != previous_folder_id
if title_changed and not folder_changed:
action = "COLLABORATION_FILE_RENAMED"
elif folder_changed and not title_changed:
action = "COLLABORATION_FILE_MOVED"
else:
action = "COLLABORATION_FILE_UPDATED"
await _audit(db, item, action, user, {
"previous_title": previous_title,
"previous_folder_id": str(previous_folder_id) if previous_folder_id else None,
"folder_id": str(item.folder_id) if item.folder_id else None,
"previous_allow_export": previous_allow_export,
"allow_export": item.allow_export,
"previous_allow_edit_request": previous_allow_edit_request,
"allow_edit_request": item.allow_edit_request,
"previous_allow_sheet_structure_edit": previous_allow_sheet_structure_edit,
"allow_sheet_structure_edit": item.allow_sheet_structure_edit,
})
await db.commit()
await db.refresh(item)
return item
@@ -874,7 +827,6 @@ async def copy_file(db: AsyncSession, item: CollaborationFile, user) -> Collabor
content=content,
source="COPY",
user=user,
source_file_id=item.id,
)
@@ -885,14 +837,6 @@ async def prepare_download(db: AsyncSession, item: CollaborationFile, user) -> C
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="协作文件尚无可下载版本")
if not Path(revision.file_uri).is_file():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作文件当前版本不存在")
await _audit(
db,
item,
"COLLABORATION_FILE_DOWNLOADED",
user,
{"revision_id": str(revision.id), "revision_no": revision.revision_no},
)
await db.commit()
return revision
@@ -900,7 +844,6 @@ async def move_to_trash(db: AsyncSession, item: CollaborationFile, user) -> None
await require_file_manager(db, item, user)
item.status = "DELETED"
item.deleted_at = datetime.now(timezone.utc)
await _audit(db, item, "COLLABORATION_FILE_TRASHED", user)
await db.commit()
@@ -908,7 +851,6 @@ async def restore_file(db: AsyncSession, item: CollaborationFile, user) -> Colla
await require_file_manager(db, item, user)
item.status = "ACTIVE"
item.deleted_at = None
await _audit(db, item, "COLLABORATION_FILE_RESTORED", user)
await db.commit()
await db.refresh(item)
return item
@@ -916,14 +858,10 @@ async def restore_file(db: AsyncSession, item: CollaborationFile, user) -> Colla
async def record_export(db: AsyncSession, item: CollaborationFile, user, file_type: str) -> None:
await require_file_exporter(db, item, user)
await _audit(db, item, "COLLABORATION_FILE_EXPORTED", user, {"file_type": file_type})
await db.commit()
async def record_download(db: AsyncSession, item: CollaborationFile, user, file_type: str) -> None:
await require_file_exporter(db, item, user)
await _audit(db, item, "COLLABORATION_FILE_DOWNLOADED", user, {"file_type": file_type})
await db.commit()
async def list_revisions(db: AsyncSession, item: CollaborationFile) -> list[CollaborationRevisionRead]:
@@ -967,10 +905,6 @@ async def delete_revision(
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="当前版本不能删除")
revision.deleted_at = datetime.now(timezone.utc)
revision.deleted_by = user.id
await _audit(db, item, "COLLABORATION_REVISION_DELETED", user, {
"revision_id": str(revision.id),
"revision_no": revision.revision_no,
})
await db.commit()
@@ -984,13 +918,6 @@ async def update_revision(
await require_file_editor(db, item, user)
revision = await get_revision_or_404(db, item, revision_id)
revision.change_summary = payload.change_summary
await _audit(
db,
item,
"COLLABORATION_REVISION_NAMED",
user,
{"revision_id": str(revision.id), "revision_no": revision.revision_no},
)
await db.commit()
await db.refresh(revision)
return revision
@@ -1002,14 +929,6 @@ async def prepare_revision_preview(
revision = await get_revision_or_404(db, item, revision_id)
if not Path(revision.file_uri).is_file():
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作修订内容不存在")
await _audit(
db,
item,
"COLLABORATION_REVISION_PREVIEWED",
user,
{"revision_id": str(revision.id), "revision_no": revision.revision_no},
)
await db.commit()
return revision
@@ -1040,7 +959,6 @@ async def copy_revision(
content=content,
source="COPY",
user=user,
source_file_id=item.id,
)
@@ -1063,7 +981,6 @@ async def restore_revision(
locked = await db.get(CollaborationFile, item.id)
if locked:
locked.generation += 1
await _audit(db, item, "COLLABORATION_REVISION_RESTORED", user, {"revision_no": revision.revision_no})
await db.commit()
await db.refresh(restored)
return restored
@@ -1111,7 +1028,6 @@ async def upsert_member(
else:
member = CollaborationMember(file_id=item.id, user_id=payload.user_id, role=payload.role, invited_by=user.id)
db.add(member)
await _audit(db, item, "COLLABORATION_MEMBER_UPSERTED", user, {"member_id": str(payload.user_id), "role": payload.role})
await db.commit()
await db.refresh(member)
return CollaborationMemberRead(
@@ -1132,7 +1048,6 @@ async def remove_member(db: AsyncSession, item: CollaborationFile, user_id: uuid
if not member:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="协作成员不存在")
await db.delete(member)
await _audit(db, item, "COLLABORATION_MEMBER_REMOVED", user, {"member_id": str(user_id)})
await db.commit()
@@ -1192,7 +1107,6 @@ async def create_edit_request(
request = CollaborationEditRequest(file_id=item.id, requester_id=user.id)
db.add(request)
await db.flush()
await _audit(db, item, "COLLABORATION_EDIT_REQUESTED", user, {"request_id": str(request.id)})
manager_ids = set((await db.scalars(
select(StudyMember.user_id)
.outerjoin(
@@ -1225,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)
@@ -1286,16 +1201,30 @@ async def resolve_edit_request(
request.status = payload.status
request.resolved_by = user.id
request.resolved_at = datetime.now(timezone.utc)
approved = payload.status == "APPROVED"
await notification_service.create_recipient_notifications(
db,
study_id=item.study_id,
recipient_ids=[request.requester_id],
category="COLLABORATION_EDIT_REQUEST_RESULT",
priority="NORMAL",
title="编辑权限申请已通过" if approved else "编辑权限申请未通过",
message=f"您对“{item.title}”的编辑权限申请已{'通过' if approved else '被拒绝'}",
action_path=(
f"/knowledge/collaboration/{item.id}"
if approved
else "/knowledge/collaboration"
),
source_type="COLLABORATION_EDIT_REQUEST_RESULT",
source_id=str(request.id),
dedupe_key=f"collaboration-edit-request-result:{request.id}",
requires_action=False,
)
await notification_service.resolve_source_notifications(
db,
source_type="COLLABORATION_EDIT_REQUEST",
source_id=str(request.id),
)
await _audit(db, item, "COLLABORATION_EDIT_REQUEST_RESOLVED", user, {
"request_id": str(request.id),
"requester_id": str(request.requester_id),
"status": payload.status,
})
await db.commit()
await db.refresh(request)
return _edit_request_read(request, requester)
@@ -1341,10 +1270,6 @@ async def transfer_ownership(
previous_owner_member.role = "MANAGER"
locked.owner_id = payload.new_owner_id
locked.updated_at = datetime.now(timezone.utc)
await _audit(db, locked, "COLLABORATION_OWNERSHIP_TRANSFERRED", user, {
"previous_owner_id": str(previous_owner_id),
"new_owner_id": str(payload.new_owner_id),
})
await db.commit()
await db.refresh(locked)
return locked
@@ -177,19 +177,6 @@ async def update_share_link(
link.failed_attempts = 0
link.last_failed_at = None
link.locked_until = None
await collaboration_service._audit(
db,
item,
"COLLABORATION_SHARE_LINK_UPDATED",
user,
{
"enabled": link.enabled,
"access_mode": link.access_mode,
"file_allow_export": item.allow_export,
"expiry_policy": link.expiry_policy,
"has_password": bool(link.password_hash),
},
)
await db.commit()
await db.refresh(link)
return share_link_read(link)
@@ -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()
+22 -2
View File
@@ -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,
),
)
@@ -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")
+188 -8
View File
@@ -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()
@@ -396,11 +396,6 @@ async def process_callback(
# 强制保存不结束当前共同编辑会话;同步基线可保证 Document
# Server 缓存重建时仍从最近一次持久化内容恢复。
session.base_revision_id = revision.id
if created and actor:
await collaboration_service._audit(
db, item, "COLLABORATION_REVISION_SAVED", actor,
{"revision_no": revision.revision_no, "source": source},
)
elif payload.status == 4:
session.status = "CLOSED"
session.closed_at = datetime.now(timezone.utc)
+492 -23
View File
@@ -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"
)
overdue_aes = 0
if can_read_aes:
cra_scope = await get_cra_site_scope(db, study_id, user)
items = await ae_crud.list_ae(
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:
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()
+6 -4
View File
@@ -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
+22 -18
View File
@@ -674,24 +674,30 @@ def test_subject_audit_uses_structured_business_snapshot():
assert 'detail=f"参与者 {subject_id} 已删除"' not in delete_subject_source
def test_faq_audit_uses_business_descriptions():
"""医学咨询审计详情应使用业务描述,避免 FAQ 技术文案"""
def test_shared_library_does_not_write_audits():
"""共享库各模块及其附件不应写入业务审计"""
api_dir = Path(__file__).resolve().parents[1] / "app" / "api" / "v1"
category_source = (api_dir / "faq_categories.py").read_text()
item_source = (api_dir / "faqs.py").read_text()
services_dir = Path(__file__).resolve().parents[1] / "app" / "services"
non_audited_sources = [
(api_dir / "faq_categories.py").read_text(),
(api_dir / "faqs.py").read_text(),
(api_dir / "precautions.py").read_text(),
(services_dir / "collaboration_service.py").read_text(),
(services_dir / "collaboration_share_service.py").read_text(),
(services_dir / "onlyoffice_collaboration_service.py").read_text(),
]
assert 'description": f"创建“{category.name}”分类"' in category_source
assert 'description": f"更新“{updated.name}”分类"' in category_source
assert 'description": f"删除“{category_name}”分类"' in category_source
assert 'f"创建医学咨询问题“{question_name}"' in item_source
assert 'f"更新医学咨询问题“{question_name}"' in item_source
assert 'f"回复医学咨询问题“{question_name}"' in item_source
assert 'f"删除医学咨询问题“{question_name}"' in item_source
assert 'f"删除医学咨询问题“{question_name}”的回复"' in item_source
assert "FAQ 分类 {category.name} 已创建" not in category_source
assert 'detail="FAQ 已创建"' not in item_source
assert 'detail = "FAQ updated"' not in item_source
assert '"description": "创建医学咨询问题"' not in item_source
for source in non_audited_sources:
assert "audit_crud" not in source
assert "._audit(" not in source
attachment_source = (api_dir / "attachments.py").read_text()
assert 'SHARED_LIBRARY_ENTITY_TYPES = {"precaution", "faq_replies"}' in attachment_source
assert "if entity_type not in SHARED_LIBRARY_ENTITY_TYPES:" in attachment_source
assert "if attachment.entity_type not in SHARED_LIBRARY_ENTITY_TYPES:" in attachment_source
onlyoffice_source = (api_dir / "onlyoffice.py").read_text()
assert "OFFICE_PREVIEW_OPEN" not in onlyoffice_source
def test_domain_audits_use_business_object_names():
@@ -704,7 +710,6 @@ def test_domain_audits_use_business_object_names():
"material_equipments.py",
"visits.py",
"aes.py",
"precautions.py",
"fees_contracts.py",
"startup.py",
"subject_histories.py",
@@ -715,7 +720,6 @@ def test_domain_audits_use_business_object_names():
assert "_equipment_audit_detail" in sources["material_equipments.py"]
assert "_visit_audit_detail" in sources["visits.py"]
assert "_ae_audit_detail" in sources["aes.py"]
assert "_precaution_audit_detail" in sources["precautions.py"]
assert "_contract_audit_detail" in sources["fees_contracts.py"]
assert "_payment_audit_detail" in sources["fees_contracts.py"]
assert "_feasibility_audit_detail" in sources["startup.py"]
+19 -27
View File
@@ -140,7 +140,6 @@ async def test_copy_revision_saves_to_the_selected_workspace_folder(monkeypatch,
content=content,
source="COPY",
user=user,
source_file_id=item.id,
)
@@ -159,22 +158,13 @@ async def test_delete_revision_soft_deletes_a_non_current_version(monkeypatch):
)
db = SimpleNamespace(get=AsyncMock(return_value=revision), commit=AsyncMock())
require_manager = AsyncMock()
audit = AsyncMock()
monkeypatch.setattr(collaboration_service, "require_file_manager", require_manager)
monkeypatch.setattr(collaboration_service, "_audit", audit)
await collaboration_service.delete_revision(db, item, revision_id, user)
require_manager.assert_awaited_once_with(db, item, user)
assert revision.deleted_at is not None
assert revision.deleted_by == user.id
audit.assert_awaited_once_with(
db,
item,
"COLLABORATION_REVISION_DELETED",
user,
{"revision_id": str(revision_id), "revision_no": 2},
)
db.commit.assert_awaited_once()
@@ -260,7 +250,6 @@ async def test_sheet_permission_change_updates_current_file_without_creating_rev
user = SimpleNamespace(id=uuid.uuid4())
db = SimpleNamespace(get=AsyncMock(return_value=revision), commit=AsyncMock(), refresh=AsyncMock())
monkeypatch.setattr(collaboration_service, "require_file_manager", AsyncMock())
monkeypatch.setattr(collaboration_service, "_audit", AsyncMock())
persist_revision = AsyncMock()
monkeypatch.setattr(collaboration_service, "_persist_revision_bytes", persist_revision)
@@ -540,7 +529,6 @@ async def test_share_link_url_stays_immutable_when_disabled_and_reenabled(monkey
refresh=AsyncMock(),
)
monkeypatch.setattr(collaboration_service, "require_file_manager", AsyncMock())
monkeypatch.setattr(collaboration_service, "_audit", AsyncMock())
original_token = collaboration_share_service.share_token(link)
await collaboration_share_service.update_share_link(
@@ -803,12 +791,11 @@ async def test_copy_creates_an_independent_r1_from_the_current_revision(monkeypa
content=content,
source="COPY",
user=user,
source_file_id=item.id,
)
@pytest.mark.asyncio
async def test_download_returns_current_revision_and_records_audit(monkeypatch, tmp_path):
async def test_download_returns_current_revision_without_writing_audit(monkeypatch, tmp_path):
source = tmp_path / "source.docx"
source.write_bytes(collaboration_service.blank_file_bytes("word"))
revision = SimpleNamespace(
@@ -821,22 +808,33 @@ async def test_download_returns_current_revision_and_records_audit(monkeypatch,
user = SimpleNamespace(id=uuid.uuid4())
db = SimpleNamespace(get=AsyncMock(return_value=revision), commit=AsyncMock())
require_exporter = AsyncMock()
audit = AsyncMock()
monkeypatch.setattr(collaboration_service, "require_file_exporter", require_exporter)
monkeypatch.setattr(collaboration_service, "_audit", audit)
result = await collaboration_service.prepare_download(db, item, user)
assert result is revision
require_exporter.assert_awaited_once_with(db, item, user)
audit.assert_awaited_once_with(
db.commit.assert_not_awaited()
@pytest.mark.asyncio
async def test_revision_preview_does_not_write_passive_audit(monkeypatch, tmp_path):
revision_path = tmp_path / "revision.docx"
revision_path.write_bytes(b"office")
revision = SimpleNamespace(id=uuid.uuid4(), file_uri=str(revision_path), revision_no=2)
item = SimpleNamespace(id=uuid.uuid4())
db = SimpleNamespace(commit=AsyncMock())
monkeypatch.setattr(collaboration_service, "get_revision_or_404", AsyncMock(return_value=revision))
result = await collaboration_service.prepare_revision_preview(
db,
item,
"COLLABORATION_FILE_DOWNLOADED",
user,
{"revision_id": str(revision.id), "revision_no": 3},
revision.id,
SimpleNamespace(id=uuid.uuid4()),
)
db.commit.assert_awaited_once()
assert result is revision
db.commit.assert_not_awaited()
@pytest.mark.asyncio
@@ -1041,12 +1039,6 @@ async def test_force_save_updates_session_recovery_revision(monkeypatch):
"append_revision",
AsyncMock(return_value=(revision, True)),
)
monkeypatch.setattr(
onlyoffice_collaboration_service.collaboration_service,
"_audit",
AsyncMock(),
)
result = await onlyoffice_collaboration_service.process_callback(
db,
session.id,
+248 -14
View File
@@ -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()
@@ -128,7 +363,6 @@ async def test_edit_request_notifies_the_active_file_owner_and_managers(monkeypa
"get_member",
AsyncMock(return_value=SimpleNamespace(is_active=True)),
)
monkeypatch.setattr(collaboration_service, "_audit", AsyncMock())
monkeypatch.setattr(collaboration_service.notification_service, "create_recipient_notifications", notify)
result = await collaboration_service.create_edit_request(db, item, user)
-4
View File
@@ -39,12 +39,10 @@ async def test_attachment_config_reuses_permission_and_preserves_original_name(m
content_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
permission = AsyncMock()
audit = AsyncMock()
monkeypatch.setattr(onlyoffice_api.attachment_crud, "get_attachment", AsyncMock(return_value=attachment))
monkeypatch.setattr(onlyoffice_api, "_ensure_study_exists", AsyncMock())
monkeypatch.setattr(onlyoffice_api, "_ensure_attachment_permission", permission)
monkeypatch.setattr(onlyoffice_api.onlyoffice_service, "ensure_onlyoffice_available", AsyncMock())
monkeypatch.setattr(onlyoffice_api, "_log_preview_open", audit)
response = Response()
db = object()
@@ -54,7 +52,6 @@ async def test_attachment_config_reuses_permission_and_preserves_original_name(m
assert result.file_name == "研究方案最终版.DOCX"
assert result.config["document"]["title"] == "研究方案最终版.DOCX"
assert response.headers["Cache-Control"] == "no-store"
audit.assert_awaited_once()
@pytest.mark.asyncio
@@ -96,7 +93,6 @@ async def test_version_config_reuses_document_access_and_version_hash(monkeypatc
monkeypatch.setattr(onlyoffice_api.document_crud, "get", AsyncMock(return_value=document))
monkeypatch.setattr(onlyoffice_api.document_service, "_ensure_study_access", access)
monkeypatch.setattr(onlyoffice_api.onlyoffice_service, "ensure_onlyoffice_available", AsyncMock())
monkeypatch.setattr(onlyoffice_api, "_log_preview_open", AsyncMock())
result = await onlyoffice_api.get_version_preview_config(version.id, Response(), object(), user)
+49
View File
@@ -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)
+1
View File
@@ -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}
+3 -3
View File
@@ -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 待办提醒`
- 正文:`有新的业务提醒待查看`
项目、文件、版本等详细信息仅在应用内列表显示,避免锁屏泄露。
+1 -1
View File
@@ -20,7 +20,7 @@
- 文件所有者始终拥有编辑、管理、导出和转让能力;文件管理者始终拥有编辑、管理和导出能力,但不能转让所有权;文件编辑者可以编辑,导出能力由文件“权限设置”开关控制。系统管理员继续保留平台级完整访问能力。
- 授予编辑者、管理者或转让所有权时,目标账号必须是当前项目的有效成员,并具备对应项目角色资格;不合格账号在联系人列表中不可选择,后端同时拒绝绕过界面的授权请求。
- 文件管理者可开启“允许申请编辑权限”。只读项目成员可从 CTMS 顶栏或 ONLYOFFICE 的“请求编辑”入口提交申请;批准后写入文件级编辑者授权,拒绝或重复处理均有明确状态和审计记录。匿名链接访问者不能申请系统账号权限。
- 顶栏铃铛通过通用通知 Feed 展示当前收件人的未关闭通知并记录已读状态;编辑权限申请通知可直接进入对应文件的“权限设置”。原逾期 AE 和逾期监查问题也先同步为通用通知,再由同一 Feed 返回,不再由网页和桌面布局分别临时汇总
- 顶栏铃铛通过通用通知 Feed 展示当前收件人的未关闭通知并记录已读状态;编辑权限申请通知可直接进入对应文件的“权限设置”,审批结果作为一次性信息通知申请人。AE、监查问题、文件回执和里程碑时效提醒也由同一 Feed 返回,具体规则见 `docs/project-notifications.md`
- Excel 文件可通过“允许所有协作者添加、删除工作表”控制工作簿结构。关闭时 CTMS 在新的不可变修订中启用工作簿结构保护并推进文件代次;重新开启时恢复文件原有的工作簿保护状态。该开关作用于所有协作者且不影响单元格内容编辑权限。
- 仅文档所有者和系统管理员可转让所有权。目标联系人必须具备文件管理者资格;转让后目标联系人升级为文件管理者,原所有者保留管理者身份,所有权变更写入审计。
- 文件编辑器“下载为”由文件导出规则判定:所有者和管理者始终允许,编辑者受文件开关控制,并由 CTMS 网页/桌面文件运行时保存到本机且记录下载审计。“另存为…”还会在项目工作区创建独立协作文件,因此额外要求项目级 `collaboration:create` 权限。
+72
View File
@@ -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` 配置,允许 603600 秒。PostgreSQL advisory lock 防止多个后端进程重复执行全量任务。
用户读取 Feed 时还会执行一次当前用户轻量对账,用于弥补短时任务失败。单个成员同步失败不会中断其他成员;失败写入 `ctms.notifications` 日志。提醒创建、更新和自动关闭本身不替代源业务审计。
## 桌面隐私边界
系统通知固定为:
- 标题:`CTMS 待办提醒`
- 正文:`有新的业务提醒待查看`
系统通知 API 不接受动态标题或正文。项目名、文件名、版本、受试者、AE、监查问题等详情只允许在完成 token 校验和项目权限确认后的应用内 Feed 中展示;访视提醒即使在应用内 Feed 也不直接显示受试者标识,只通过受控详情路径访问。
## 系统通知授权边界
系统权限和服务端订阅是两个独立条件:
- 首次开启时,客户端先请求操作系统授权;只有操作系统返回已授权,才开启当前账号的服务端订阅。
- 用户在操作系统中撤销权限后,服务端订阅可以仍然保持开启,但客户端不会领取提醒,设置页会明确显示“系统权限已拒绝”。
- macOS 或 Windows 已拒绝权限时,系统通常不会再次展示授权框。设置页分别引导用户前往“系统设置 → 通知 → CTMS”或“设置 → 系统 → 通知 → CTMS”,返回后通过“重新检测权限”恢复链路。
- “发送测试通知”只验证本机通知能力,不访问业务提醒队列;“立即检查业务提醒”会走服务端订阅、领取、系统投递和确认的真实路径。
- CTMS 不申请打开任意 URL、执行系统命令或读取操作系统通知内容的额外 Tauri 权限。授权引导仅展示设置路径,避免为便利入口扩大桌面原生能力边界。
+2 -2
View File
@@ -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.");
};
+2 -2
View File
@@ -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 = () =>
+7 -2
View File
@@ -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",
);
});
});
+16 -3
View File
@@ -1,9 +1,19 @@
import { apiGet, apiPost } from "./axios";
import type { GeneralNotificationFeed, GeneralNotificationItem } from "../types/notifications";
export const listGeneralNotifications = (studyId: string, limit = 10) =>
apiGet<GeneralNotificationFeed>(`/api/v1/studies/${studyId}/notifications/feed`, {
params: { limit },
export interface GeneralNotificationQuery {
skip?: number;
limit?: number;
category?: string;
unread_only?: boolean;
requires_action?: boolean;
}
export const listGeneralNotifications = (
studyId: string,
query: GeneralNotificationQuery = {},
) => apiGet<GeneralNotificationFeed>(`/api/v1/studies/${studyId}/notifications/feed`, {
params: { limit: 10, ...query },
cache: false,
disableRequestDedupe: true,
});
@@ -12,3 +22,6 @@ export const markGeneralNotificationRead = (studyId: string, notificationId: str
apiPost<GeneralNotificationItem>(
`/api/v1/studies/${studyId}/notifications/${notificationId}/read`,
);
export const markAllGeneralNotificationsRead = (studyId: string) =>
apiPost<void>(`/api/v1/studies/${studyId}/notifications/read-all`);
@@ -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", () => {
@@ -6,7 +6,7 @@
<span class="toolbar-title">有效权限矩阵</span>
<el-tag size="small" effect="plain" type="info">只读</el-tag>
</div>
<span class="toolbar-result">显示 {{ filteredOperations.length }} / {{ displayOperations.length }} 项权限</span>
<span class="toolbar-result">显示 {{ filteredPermissionCount }} / {{ totalPermissionCount }} 项权限</span>
</div>
<div class="toolbar-filters">
<el-input
@@ -266,6 +266,12 @@ const filteredOperations = computed(() => {
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) {
+34
View File
@@ -296,6 +296,18 @@
<div v-else class="reminder-empty">
{{ headerRemindersLoading ? TEXT.common.loading : TEXT.common.labels.projectRemindersEmpty }}
</div>
<div class="reminder-actions">
<el-dropdown-item command="notification:center" class="reminder-footer">
查看全部提醒
</el-dropdown-item>
<el-dropdown-item
v-if="headerReminderTotal > 0"
command="notification:read-all"
class="reminder-footer is-secondary"
>
全部已读
</el-dropdown-item>
</div>
</div>
</el-dropdown-menu>
</template>
@@ -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;
}
+22 -8
View File
@@ -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");
});
+27 -1
View File
@@ -289,7 +289,18 @@
<div v-else class="header-reminder-empty">
{{ headerRemindersLoading ? TEXT.common.loading : TEXT.common.labels.projectRemindersEmpty }}
</div>
<div class="header-reminder-actions">
<el-dropdown-item command="notification:center" class="header-reminder-footer">
查看全部提醒
</el-dropdown-item>
<el-dropdown-item
v-if="headerReminderTotal > 0"
command="notification:read-all"
class="header-reminder-footer is-secondary"
>
全部已读
</el-dropdown-item>
</div>
</div>
</el-dropdown-menu>
</template>
@@ -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);
@@ -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"');
});
});
@@ -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<GeneralNotificationFeed>({ unread_count: 0, items: [] });
const feed = ref<GeneralNotificationFeed>({ 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);
};
+1 -1
View File
@@ -99,7 +99,7 @@ export const TEXT = {
dataUpdatedAt: "数据",
projectReminders: "提醒",
projectRemindersSubtitle: "业务通知与待办",
projectRemindersEmpty: "暂无未处理通知",
projectRemindersEmpty: "暂无业务提醒",
overdueAes: "逾期 AE",
overdueAesDesc: "需关注安全性事件处理时效",
overdueMonitoringIssues: "逾期监查问题",
+14
View File
@@ -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: "<div />" }));
installElementPlus(app);
expect(app.component("ElRadioGroup")).toBeDefined();
expect(app.component("ElRadioButton")).toBeDefined();
});
});
+3 -1
View File
@@ -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);
};
+7
View File
@@ -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",
+17 -1
View File
@@ -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: "有新的业务提醒待查看",
});
});
});
+4 -4
View File
@@ -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<boolean> =>
sendStaticSystemNotification(fileUpdateNotification);
sendStaticSystemNotification(businessReminderNotification);
export const showSystemNotificationProbe = async (): Promise<boolean> =>
sendStaticSystemNotification(notificationProbe);
@@ -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,
});
});
});
@@ -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<DesktopNotificationStatusSnapshot>) => {
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,
});
};
+4
View File
@@ -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 {
+4
View File
@@ -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[];
}
+318 -25
View File
@@ -153,24 +153,84 @@
</section>
<section v-else-if="activeSectionId === 'notifications'" key="notifications" class="preference-panel">
<div class="preference-row">
<div class="notification-settings">
<div class="preference-row notification-primary-row">
<div>
<div class="row-title">系统通知</div>
<div class="row-desc">不含项目详情的文件更新提示</div>
<div class="row-desc">开启后定期领取服务端真实业务提醒系统弹窗不显示业务详情</div>
</div>
<div class="row-control">
<el-tag size="small" :type="notificationPermissionTagType">
系统权限{{ notificationPermissionText }}
</el-tag>
<el-switch
v-model="desktopNotificationsEnabled"
:model-value="desktopNotificationsEnabled"
:loading="desktopNotificationLoading"
aria-label="系统通知服务端订阅"
@change="onDesktopNotificationChange"
/>
<el-tag v-if="showNotificationPermissionTag" size="small" :type="notificationPermissionTagType">
{{ notificationPermissionText }}
</el-tag>
<el-button size="small" :loading="desktopNotificationTestLoading" @click="sendDesktopNotificationTest">
<el-icon><Bell /></el-icon>
<span>测试通知</span>
</div>
</div>
<div class="notification-guidance" :class="`is-${notificationGuidanceTone}`">
<el-alert
:type="notificationGuidanceTone"
:title="notificationGuidanceTitle"
:description="notificationGuidanceDescription"
show-icon
:closable="false"
/>
<div class="notification-status-grid" aria-label="系统通知投递状态">
<span>系统权限</span>
<strong>{{ notificationPermissionText }}</strong>
<span>服务端订阅</span>
<strong>{{ desktopNotificationsEnabled ? "已开启" : "未开启" }}</strong>
<span>真实业务路径</span>
<strong>{{ notificationDeliveryStatusText }}</strong>
<span>最近检查</span>
<strong>{{ notificationLastCheckedText }}</strong>
<span>最近投递</span>
<strong>{{ notificationLastDeliveredText }}</strong>
</div>
<p class="notification-privacy-note">
系统弹窗固定显示CTMS 待办提醒项目文件AE监查问题和受试者信息仅在登录后的提醒中心展示
</p>
<div class="notification-actions">
<el-button
v-if="notificationPermission === 'prompt'"
type="primary"
:loading="desktopNotificationLoading"
@click="authorizeAndEnableDesktopNotifications"
>
授权并开启
</el-button>
<el-button
v-else-if="notificationPermission === 'denied'"
:loading="desktopNotificationLoading"
@click="refreshDesktopNotificationPermission"
>
重新检测权限
</el-button>
<el-button
v-if="notificationPermission === 'granted' && desktopNotificationsEnabled"
:loading="desktopNotificationCheckLoading"
@click="checkDesktopBusinessNotifications"
>
立即检查业务提醒
</el-button>
<el-button
:disabled="notificationPermission === 'unsupported'"
:loading="desktopNotificationTestLoading"
@click="sendDesktopNotificationTest"
>
<el-icon><Bell /></el-icon>
<span>发送测试通知</span>
</el-button>
<el-button text @click="openProjectNotificationCenter">打开提醒中心</el-button>
</div>
</div>
</div>
</section>
@@ -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<DesktopShortcutPreferences>(readDesktopShortcutPref
const capturingShortcutAction = ref<DesktopShortcutAction | null>(null);
const shortcutCaptureElement = ref<HTMLElement | null>(null);
const notificationPermission = ref<NotificationPermissionState>("unsupported");
const desktopNotificationStatus = ref<DesktopNotificationStatusSnapshot>(getDesktopNotificationStatus());
const desktopUpdateStatus = ref<DesktopUpdateStatusSnapshot>(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<DesktopNotificationStatusSnapshot["state"], string> = {
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 () => {
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 通知");
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?.();
});
</script>
@@ -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,
@@ -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 "访视窗口"');
});
});
+739
View File
@@ -0,0 +1,739 @@
<template>
<section class="notification-page" aria-label="提醒中心已读不代表业务已完成">
<div class="notification-page__canvas">
<header class="notification-page__hero">
<div class="notification-page__header">
<div class="notification-page__intro">
<div class="notification-page__title-row">
<span class="notification-page__title-icon" aria-hidden="true">
<el-icon><Bell /></el-icon>
</span>
<h1>提醒中心</h1>
</div>
</div>
<div class="notification-page__summary" aria-label="提醒概览">
<div
class="notification-summary-item notification-summary-item--unread"
>
<span class="notification-summary-item__icon" aria-hidden="true">
<el-icon><BellFilled /></el-icon>
</span>
<span class="notification-summary-item__label">未读</span>
<strong>{{ feed.unread_count }}</strong>
</div>
<div class="notification-summary-item">
<span class="notification-summary-item__icon" aria-hidden="true">
<el-icon><Tickets /></el-icon>
</span>
<span class="notification-summary-item__label">当前</span>
<strong>{{ feed.total_count }}</strong>
</div>
</div>
<div class="notification-page__actions">
<el-button :icon="Refresh" :loading="loading" @click="loadNotifications">
刷新
</el-button>
<el-button
type="primary"
:icon="CircleCheck"
:disabled="feed.unread_count === 0"
:loading="markingAllRead"
@click="markAllRead"
>
全部标为已读
</el-button>
</div>
</div>
</header>
<section class="notification-content" aria-label="提醒列表">
<div class="notification-toolbar">
<div class="notification-toolbar__main">
<div class="notification-toolbar__title">
<strong>提醒列表</strong>
</div>
<el-radio-group v-model="filter" aria-label="提醒筛选" @change="resetAndLoad">
<el-radio-button value="active">当前提醒</el-radio-button>
<el-radio-button value="unread">仅未读</el-radio-button>
<el-radio-button value="action">仅待处理</el-radio-button>
</el-radio-group>
</div>
</div>
<div v-loading="loading" class="notification-list" aria-live="polite">
<button
v-for="item in feed.items"
:key="item.id"
type="button"
class="notification-row"
:class="{ 'is-read': item.read_at, 'is-resolved': item.resolved_at }"
@click="openNotification(item)"
>
<span class="notification-row__mark" :class="`is-${priorityTone(item.priority)}`"></span>
<span class="notification-row__main">
<span class="notification-row__heading">
<strong>{{ item.title }}</strong>
<el-tag size="small" effect="plain">{{ categoryLabel(item.category) }}</el-tag>
<el-tag
v-if="item.requires_action && !item.resolved_at"
size="small"
type="warning"
effect="light"
>
待处理
</el-tag>
<el-tag v-else-if="item.resolved_at" size="small" type="info" effect="plain">
已结束
</el-tag>
</span>
<span class="notification-row__message">{{ item.message }}</span>
<span v-if="item.due_at" class="notification-row__due">
截止{{ formatDateTime(item.due_at) }}
</span>
</span>
<span class="notification-row__meta">
<span>{{ formatDateTime(item.created_at) }}</span>
<small :class="{ 'is-unread': !item.read_at }">
<span v-if="!item.read_at" aria-hidden="true"></span>
{{ item.read_at ? "已读" : "未读" }}
</small>
</span>
</button>
<el-empty
v-if="!loading && feed.items.length === 0"
class="notification-empty"
:image-size="88"
>
<template #description>
<div class="notification-empty__copy">
<strong>{{ emptyState }}</strong>
</div>
</template>
<el-button
v-if="filter !== 'active'"
text
type="primary"
@click="showAllNotifications"
>
查看当前提醒
</el-button>
</el-empty>
</div>
<div v-if="feed.total_count > pageSize" class="notification-pagination">
<el-pagination
v-model:current-page="page"
background
layout="prev, pager, next"
:page-size="pageSize"
:total="feed.total_count"
@current-change="loadNotifications"
/>
</div>
</section>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { Bell, BellFilled, CircleCheck, Refresh, Tickets } from "@element-plus/icons-vue";
import {
listGeneralNotifications,
markAllGeneralNotificationsRead,
markGeneralNotificationRead,
type GeneralNotificationQuery,
} from "../api/notifications";
import {
notifyProjectNotificationsChanged,
PROJECT_NOTIFICATIONS_CHANGED_EVENT,
} from "../composables/useProjectNotifications";
import { useStudyStore } from "../store/study";
import type { GeneralNotificationFeed, GeneralNotificationItem } from "../types/notifications";
type NotificationFilter = "active" | "unread" | "action";
const router = useRouter();
const study = useStudyStore();
const loading = ref(false);
const markingAllRead = ref(false);
const filter = ref<NotificationFilter>("active");
const page = ref(1);
const pageSize = 20;
const feed = ref<GeneralNotificationFeed>({ unread_count: 0, total_count: 0, items: [] });
let requestId = 0;
const emptyState = computed(() => {
if (filter.value === "unread") return "没有未读提醒";
if (filter.value === "action") return "没有待处理事项";
return "当前暂无提醒";
});
const queryForFilter = (): GeneralNotificationQuery => {
const query: GeneralNotificationQuery = {
skip: (page.value - 1) * pageSize,
limit: pageSize,
};
if (filter.value === "unread") query.unread_only = true;
if (filter.value === "action") query.requires_action = true;
return query;
};
const loadNotifications = async () => {
const studyId = study.currentStudy?.id;
const currentRequest = ++requestId;
if (!studyId) {
feed.value = { unread_count: 0, total_count: 0, items: [] };
return;
}
loading.value = true;
try {
const { data } = await listGeneralNotifications(studyId, queryForFilter());
if (currentRequest === requestId && study.currentStudy?.id === studyId) feed.value = data;
} catch {
//
} finally {
if (currentRequest === requestId) loading.value = false;
}
};
const resetAndLoad = () => {
page.value = 1;
void loadNotifications();
};
const showAllNotifications = () => {
filter.value = "active";
resetAndLoad();
};
const markAllRead = async () => {
const studyId = study.currentStudy?.id;
if (!studyId || markingAllRead.value) return;
markingAllRead.value = true;
try {
await markAllGeneralNotificationsRead(studyId);
notifyProjectNotificationsChanged();
await loadNotifications();
ElMessage.success("已将当前项目提醒全部标为已读");
} finally {
markingAllRead.value = false;
}
};
const openNotification = async (item: GeneralNotificationItem) => {
const studyId = study.currentStudy?.id;
if (!studyId) return;
if (!item.read_at && !item.resolved_at) {
const response = await markGeneralNotificationRead(studyId, item.id).catch(() => null);
if (response) notifyProjectNotificationsChanged();
}
if (item.action_path?.startsWith("/")) {
await router.push(item.action_path);
return;
}
await loadNotifications();
};
const priorityTone = (priority: GeneralNotificationItem["priority"]) => {
if (priority === "URGENT") return "danger";
if (priority === "HIGH") return "warning";
return "info";
};
const categoryLabel = (category: string) => {
if (category.startsWith("RISK_") || category.includes("MONITORING")) return "风险时效";
if (category.startsWith("DOCUMENT_")) return "文件回执";
if (category.startsWith("MILESTONE_")) return "项目里程碑";
if (category.startsWith("VISIT_WINDOW_")) return "访视窗口";
if (category.startsWith("COLLABORATION_")) return "在线协作";
return "业务通知";
};
const formatDateTime = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "-";
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}).format(date);
};
const handleNotificationChange = () => { void loadNotifications(); };
watch(() => study.currentStudy?.id, resetAndLoad);
onMounted(() => {
void loadNotifications();
window.addEventListener("focus", handleNotificationChange);
window.addEventListener(PROJECT_NOTIFICATIONS_CHANGED_EVENT, handleNotificationChange);
});
onBeforeUnmount(() => {
window.removeEventListener("focus", handleNotificationChange);
window.removeEventListener(PROJECT_NOTIFICATIONS_CHANGED_EVENT, handleNotificationChange);
});
</script>
<style scoped>
.notification-page {
--notification-accent: var(--ctms-primary, #3f5d75);
--notification-accent-soft: rgba(63, 93, 117, 0.1);
min-height: 100%;
background: var(--ctms-bg-base, #f9fafb);
color: var(--ctms-text-main, #172033);
}
.notification-page__canvas {
width: min(100%, 1560px);
min-height: 100%;
margin: 0 auto;
display: grid;
grid-template-rows: auto minmax(320px, 1fr);
gap: 0;
align-content: stretch;
padding: clamp(10px, 1.4vw, 18px);
}
.notification-page__hero {
padding: 6px 4px 12px;
border-bottom: 1px solid var(--ctms-border-color, #e5e7eb);
}
.notification-page__header {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: auto minmax(260px, 1fr) auto;
align-items: center;
gap: 18px;
}
.notification-page__intro {
min-width: 0;
}
.notification-page__title-row {
display: flex;
align-items: center;
gap: 10px;
}
.notification-page__title-icon {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 1px solid rgba(63, 93, 117, 0.12);
border-radius: 9px;
background: rgba(255, 255, 255, 0.78);
box-shadow: 0 4px 12px rgba(38, 72, 98, 0.06);
color: var(--notification-accent);
font-size: 17px;
}
.notification-page__header h1 {
margin: 0;
color: var(--ctms-text-main, #172033);
font-size: 22px;
font-weight: 750;
letter-spacing: -0.02em;
line-height: 1.2;
}
.notification-page__actions {
display: flex;
flex: 0 0 auto;
gap: 10px;
}
.notification-page__summary {
display: flex;
align-items: center;
justify-content: start;
}
.notification-summary-item {
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 32px;
padding: 0 18px;
border-right: 1px solid var(--ctms-border-color, #e5e7eb);
}
.notification-summary-item:first-child {
padding-left: 0;
}
.notification-summary-item:last-child {
padding-right: 0;
border-right: 0;
}
.notification-summary-item__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
color: var(--ctms-text-secondary, #64748b);
font-size: 15px;
}
.notification-summary-item--unread .notification-summary-item__icon {
color: var(--notification-accent);
}
.notification-summary-item__label {
min-width: 0;
color: var(--ctms-text-regular, #334155);
font-size: 12px;
font-weight: 650;
}
.notification-summary-item strong {
min-width: 1ch;
margin-left: 4px;
color: var(--ctms-text-main, #172033);
font-size: 19px;
font-variant-numeric: tabular-nums;
line-height: 1;
}
.notification-content {
display: flex;
height: 100%;
min-height: 320px;
flex-direction: column;
background: transparent;
}
.notification-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 4px;
border-bottom: 1px solid var(--el-border-color-lighter);
background: transparent;
color: var(--ctms-text-secondary, #64748b);
font-size: 12px;
}
.notification-toolbar__main {
display: flex;
min-width: 0;
align-items: center;
gap: 12px;
}
.notification-toolbar__title {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
}
.notification-toolbar__title strong {
color: var(--ctms-text-main, #172033);
font-size: 14px;
}
.notification-toolbar :deep(.el-radio-button__inner) {
min-height: 28px;
padding: 5px 12px;
font-weight: 550;
box-shadow: none;
}
.notification-list {
position: relative;
display: flex;
min-height: 240px;
flex: 1;
flex-direction: column;
padding: 0;
}
.notification-row {
width: 100%;
display: grid;
grid-template-columns: 8px minmax(0, 1fr) auto;
gap: 12px;
align-items: flex-start;
padding: 12px 10px;
border: 0;
border-bottom: 1px solid var(--el-border-color-lighter);
border-radius: 10px;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
transition: background-color 160ms ease, box-shadow 160ms ease;
}
.notification-row:hover {
background: var(--ctms-neutral-100, #f5f7fa);
box-shadow: inset 3px 0 0 rgba(63, 93, 117, 0.24);
}
.notification-row:focus-visible {
outline: 2px solid var(--notification-accent);
outline-offset: -2px;
}
.notification-row.is-read {
opacity: 0.76;
}
.notification-row.is-resolved {
opacity: 0.58;
}
.notification-row__mark {
width: 8px;
height: 8px;
margin-top: 7px;
border-radius: 999px;
background: var(--notification-accent);
box-shadow: 0 0 0 4px var(--notification-accent-soft);
}
.notification-row__mark.is-danger {
background: var(--ctms-danger, #c24b4b);
box-shadow: 0 0 0 4px rgba(194, 75, 75, 0.1);
}
.notification-row__mark.is-warning {
background: var(--ctms-warning, #c58b2a);
box-shadow: 0 0 0 4px rgba(197, 139, 42, 0.11);
}
.notification-row__main,
.notification-row__meta {
display: flex;
flex-direction: column;
}
.notification-row__main { gap: 6px; }
.notification-row__heading {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.notification-row__heading strong {
color: var(--ctms-text-main, #172033);
font-size: 14px;
font-weight: 650;
}
.notification-row__message,
.notification-row__due,
.notification-row__meta {
color: var(--ctms-text-secondary, #64748b);
font-size: 12px;
}
.notification-row__message {
line-height: 1.6;
}
.notification-row__due {
color: #a16207;
font-weight: 550;
}
.notification-row__meta {
align-items: flex-end;
gap: 6px;
white-space: nowrap;
}
.notification-row__meta small {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 8px;
border-radius: 999px;
background: var(--ctms-neutral-100, #f5f7fa);
color: var(--ctms-text-secondary, #64748b);
font-size: 10px;
}
.notification-row__meta small.is-unread {
background: var(--notification-accent-soft);
color: var(--notification-accent);
font-weight: 650;
}
.notification-row__meta small > span {
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
}
.notification-empty {
display: flex;
min-height: 240px;
flex: 1;
align-items: center;
justify-content: center;
padding: 22px 16px 28px;
}
.notification-empty :deep(.el-empty__image) {
opacity: 0.72;
filter: saturate(0.55);
}
.notification-empty :deep(.el-empty__description) {
margin-top: 14px;
}
.notification-empty__copy { text-align: center; }
.notification-empty__copy strong {
color: var(--ctms-text-regular, #334155);
font-size: 14px;
font-weight: 650;
}
.notification-empty :deep(.el-empty__bottom) {
margin-top: 8px;
}
.notification-pagination {
display: flex;
justify-content: flex-end;
padding: 14px 20px 18px;
border-top: 1px solid var(--el-border-color-lighter);
}
:global([data-ctms-theme="dark"]) .notification-page {
--notification-accent-soft: rgba(143, 183, 212, 0.14);
background: var(--ctms-bg-base);
}
:global([data-ctms-theme="dark"]) .notification-page__title-icon {
border-color: rgba(143, 183, 212, 0.16);
background: rgba(17, 24, 39, 0.72);
}
:global(.desktop-workbench) .notification-page__canvas {
padding: 10px;
}
:global(.desktop-workbench) .notification-page__hero {
padding: 4px 2px 10px;
}
@media (max-width: 1100px) {
.notification-page__header {
grid-template-columns: 1fr auto;
}
.notification-page__summary {
grid-column: 1 / -1;
grid-row: 2;
}
}
@media (max-width: 767px) {
.notification-page__canvas {
grid-template-rows: auto minmax(320px, 1fr);
padding: 8px;
}
.notification-page__hero {
padding: 4px 2px 10px;
}
.notification-toolbar,
.notification-toolbar__main {
flex-direction: column;
align-items: stretch;
}
.notification-page__header {
grid-template-columns: 1fr;
gap: 10px;
}
.notification-page__actions {
width: 100%;
}
.notification-page__actions :deep(.el-button) {
flex: 1;
}
.notification-page__summary {
grid-column: 1;
grid-row: auto;
}
.notification-toolbar {
gap: 8px;
padding: 9px 10px;
}
.notification-toolbar__main {
gap: 12px;
}
.notification-toolbar :deep(.el-radio-group) {
display: flex;
width: 100%;
}
.notification-toolbar :deep(.el-radio-button) {
flex: 1;
}
.notification-toolbar :deep(.el-radio-button__inner) {
width: 100%;
padding-right: 8px;
padding-left: 8px;
}
.notification-row {
grid-template-columns: 8px minmax(0, 1fr);
padding: 16px 8px;
}
.notification-row__meta {
grid-column: 2;
align-items: flex-start;
flex-direction: row;
align-items: center;
}
}
@media (max-width: 520px) {
.notification-page__title-icon {
width: 34px;
height: 34px;
}
.notification-page__header h1 {
font-size: 21px;
}
.notification-summary-item {
min-height: 30px;
padding: 0 12px;
}
}
</style>
@@ -43,7 +43,7 @@
<strong>{{ scope.row.client_type === 'desktop' ? '桌面端' : '网页端' }}</strong>
<span>{{ clientDescription(scope.row) }}</span>
<el-tag v-if="scope.row.grouped_count > 1" type="info" effect="plain" size="small">
合并 {{ scope.row.grouped_count }}
累计 {{ scope.row.grouped_count }} 次会话
</el-tag>
</div>
</template>
@@ -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<DisplayLoginActivity[]>(() => {
const rows: DisplayLoginActivity[] = [];
const groupedHistory = new Map<string, DisplayLoginActivity>();
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<DisplayLoginActivity[]>(() =>
groupLoginActivitiesBySource(activities.value, resolvedActivityStatus),
);
const displayActivities = computed<DisplayLoginActivity[]>(() =>
activityViewMode.value === "source"
? sourceActivities.value
@@ -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");
@@ -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>): 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);
});
});
@@ -0,0 +1,56 @@
import type { UserLoginActivity } from "../../types/api";
export type DisplayLoginActivity = UserLoginActivity & { grouped_count: number };
type ActivityStatus = NonNullable<UserLoginActivity["activity_status"]>;
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));
};