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

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

新增网页端和桌面端提醒中心、真实投递诊断与固定隐私通知正文,并补齐登录来源聚合、测试和说明文档。
This commit is contained in:
Cheng Zhou
2026-07-16 16:50:34 +08:00
parent 88bd0c5942
commit 3e77127687
50 changed files with 2894 additions and 247 deletions
+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
)
+10 -7
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:
await project_reminder_service.sync_project_reminders(db, study_id, current_user)
except Exception:
await db.rollback()
logger.warning("Failed to synchronize legacy project reminders", exc_info=True)
# Fail closed: stale reminders may contain details for a permission that was just revoked.
await project_reminder_service.sync_project_reminders(db, study_id, current_user)
return await notification_service.list_feed(
db,
study_id=study_id,
recipient_id=current_user.id,
skip=skip,
limit=limit,
category=category,
unread_only=unread_only,
requires_action=requires_action,
)
+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,