feat(announcements,messaging,notifications): 实现所有长期问题 — SSE 实时推送 + 通知日志持久化 + 优先级/归档 + 消息星标/草稿 + 公告已读回执/置顶 + 分类筛选/桌面推送 + 测试覆盖
P1-8 通知实时推送(SSE): - 新增 /api/notifications/stream SSE 端点(15 秒推送,5 分钟超时) - 新增 useNotificationStream Hook(SSE + 轮询降级) - NotificationDropdown 改用 SSE 实时推送 P2-12 测试覆盖: - notifications/dispatcher.test.ts(6 个测试,渠道选择逻辑) - notifications/channels/in-app-channel.test.ts(9 个测试,类型映射) - messaging/schema.test.ts(34 个测试,Zod 校验) - tests/e2e/messages.spec.ts(消息模块 E2E 测试) - vitest.unit.config.ts 添加 server-only stub P2-13a 通知发送日志持久化: - 新增 notification_logs 表(userId/title/channel/status/messageId/error/sentAt) - logNotificationSend 改为 async 写入 DB(失败降级 console) - dispatcher 传递 payload 用于持久化 P2-13b 通知优先级和归档: - messageNotifications 表新增 priority(low/normal/high/urgent)和 isArchived 字段 - getNotifications 支持归档和优先级筛选 - 新增 archiveNotificationAction - NotificationList 显示优先级 Badge 和归档按钮 P2-13c 消息星标和草稿: - messages 表新增 isStarred 字段 - 新增 message_drafts 表 - 新增 toggleMessageStar + 草稿 CRUD Server Actions - 新增 5 个草稿 data-access 函数 P2-13d 公告已读回执和置顶: - announcements 表新增 isPinned 字段 - 新增 announcement_reads 表(唯一索引保证幂等) - 新增 toggleAnnouncementPinAction + markAnnouncementAsReadAction - getAnnouncements 排序置顶优先 P2-13e 通知分类筛选和桌面推送: - NotificationList 添加按类型筛选按钮组 - 新增 useDesktopNotifications Hook(浏览器 Notification API) - NotificationDropdown 集成桌面推送(新通知触发) 架构图同步: - 004 和 005 均已更新(新增表、Action、Hook、组件描述)
This commit is contained in:
@@ -1096,11 +1096,11 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
|
||||
## 2.13 messaging(私信模块)
|
||||
|
||||
**职责**:站内私信(messages 表 CRUD)。
|
||||
**职责**:站内私信(messages 表 CRUD)+ 消息草稿(message_drafts 表 CRUD)。
|
||||
|
||||
**导出函数**:
|
||||
- Actions:`sendMessageAction` / `markMessageAsReadAction` / `deleteMessageAction` / `getMessagesAction` / `getMessageDetailAction` / `getRecipientsAction` / `getUnreadMessageCountAction` / `getNotificationPreferencesAction` / `updateNotificationPreferencesAction`(✅ P1-4 已修复:通知 CRUD Action 已迁移至 notifications 模块,messaging 仅保留私信和通知偏好 Action)
|
||||
- Data-access:`getMessages` / `getMessageById` / `getMessageThread` / `createMessage` / `markMessageAsRead` / `deleteMessage` / `getUnreadMessageCount` / `getRecipients`(按 DataScope 过滤可发送对象:class_taught 教师→学生、grade_managed 年级管理员→教师/学生、all 管理员、class_members 学生→自己班级的任课教师/班主任、children 家长→孩子的班主任/任课教师;通过 classes data-access.getTeacherIdsByClassIds/getStudentActiveClassId 获取班级教师 ID)/ `getMessagesPageData`(✅ P1-5 新增:消息首页编排函数,一次性获取消息列表和通知列表)/ `getMessageDetailPageData`(✅ V2-P1-3 新增:消息详情页编排函数,获取详情并自动标记已读)
|
||||
- Actions:`sendMessageAction` / `markMessageAsReadAction` / `deleteMessageAction` / `getMessagesAction` / `getMessageDetailAction` / `getRecipientsAction` / `getUnreadMessageCountAction` / `getNotificationPreferencesAction` / `updateNotificationPreferencesAction`(✅ P1-4 已修复:通知 CRUD Action 已迁移至 notifications 模块,messaging 仅保留私信和通知偏好 Action;✅ V2-P2-13c 新增:`toggleMessageStarAction` 星标切换 / `getMessageDraftsAction` 草稿列表 / `saveMessageDraftAction` 草稿保存(创建或更新)/ `deleteMessageDraftAction` 草稿删除)
|
||||
- Data-access:`getMessages`(✅ V2-P2-13c 新增 starredOnly 星标筛选)/ `getMessageById` / `getMessageThread` / `createMessage` / `markMessageAsRead` / `deleteMessage` / `toggleMessageStar`(✅ V2-P2-13c 新增:接收方星标切换)/ `getUnreadMessageCount` / `getRecipients`(按 DataScope 过滤可发送对象:class_taught 教师→学生、grade_managed 年级管理员→教师/学生、all 管理员、class_members 学生→自己班级的任课教师/班主任、children 家长→孩子的班主任/任课教师;通过 classes data-access.getTeacherIdsByClassIds/getStudentActiveClassId 获取班级教师 ID)/ `getMessagesPageData`(✅ P1-5 新增:消息首页编排函数,一次性获取消息列表和通知列表)/ `getMessageDetailPageData`(✅ V2-P1-3 新增:消息详情页编排函数,获取详情并自动标记已读)/ `getMessageDrafts` / `createMessageDraft` / `updateMessageDraft` / `deleteMessageDraft` / `getMessageDraftById`(✅ V2-P2-13c 新增:消息草稿 CRUD,操作 message_drafts 表)
|
||||
- Hooks:`useMessageSearch`(✅ P1-7 新增:消息搜索 hook,含防抖和请求竞态取消)
|
||||
- Notification-preferences:~~re-export shim(实际逻辑在 `notifications/preferences.ts`)~~ ✅ P0-b 已修复:`notification-preferences.ts` 文件已删除(通知模块去重),消费方改为直接从 `@/modules/notifications/preferences` 导入 `getNotificationPreferences` / `upsertNotificationPreferences`
|
||||
|
||||
@@ -1130,6 +1130,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
- ✅ V2-P1-3 已修复:~~消息详情页分散编排~~ 新增 `getMessageDetailPageData` 编排函数,替代 page.tsx 中 `after()` + `getMessageById` + `markMessageAsRead` 的分散编排
|
||||
- ✅ V2-P1-4 已修复:~~表单无服务端校验错误展示~~ `message-compose.tsx` 新增 `fieldErrors` 状态 + `aria-invalid` 字段级错误展示(receiverId/subject/content)
|
||||
- ✅ V2-P2-1 已修复:~~轮询间隔魔法数字~~ `unread-message-badge.tsx` 轮询间隔提取为 `POLL_INTERVAL_MS` 常量(~~60_000ms~~ → 30_000ms,✅ V2-P3 与通知组件保持一致)
|
||||
- ✅ V2-P2-13c 已实现:消息星标和草稿功能。`messages` 表新增 `isStarred` 字段(接收方可标记重要消息)+ `messages_receiver_starred_idx` 复合索引;新增 `message_drafts` 表(userId/receiverId/subject/content/parentMessageId + updatedAt/createdAt,含 `message_drafts_user_idx` 和 `message_drafts_user_updated_idx` 索引);data-access 新增 `toggleMessageStar` / `getMessageDrafts` / `createMessageDraft` / `updateMessageDraft` / `deleteMessageDraft` / `getMessageDraftById`;actions 新增 `toggleMessageStarAction` / `getMessageDraftsAction` / `saveMessageDraftAction` / `deleteMessageDraftAction`;`getMessages` 支持 `starredOnly` 筛选;i18n 新增 actions.star/unstar/saveDraft/deleteDraft + messages.draftSaved/draftDeleted/starToggled + empty.noDrafts/noStarred 翻译键;迁移文件 `drizzle/0008_message_star_draft.sql`
|
||||
|
||||
**文件清单**:
|
||||
| 文件 | 行数 | 职责 |
|
||||
@@ -1164,8 +1165,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
- Data-access:`createNotification` / `getNotifications` / `markNotificationAsRead` / `markAllNotificationsAsRead` / `getUnreadNotificationCount` / `archiveNotification` / `unarchiveNotification` / `getUserContactInfo` / `logNotificationSend` / `logNotificationSendBatch`(✅ P0-4 / P1-5 修复后从 messaging 迁移;✅ V2-P2-13b 新增:archiveNotification / unarchiveNotification 归档函数)
|
||||
- Preferences:`getNotificationPreferences` / `upsertNotificationPreferences`(✅ P0-4 / P1-5 修复后从 messaging 迁移)
|
||||
- Channels:`InAppChannelSender` / `SmsChannelSender` / `EmailChannelSender` / `WeChatChannelSender`
|
||||
- Components:`NotificationList` / `NotificationDropdown`(✅ P1-4 新增:从 messaging/components 迁移;✅ V2-P2-13b:NotificationList 支持优先级 Badge 显示和归档操作)
|
||||
- Hooks:`useNotificationStream`(✅ V2-P3 新增:SSE 实时推送 + 轮询降级 Hook)
|
||||
- Components:`NotificationList` / `NotificationDropdown`(✅ P1-4 新增:从 messaging/components 迁移;✅ V2-P2-13b:NotificationList 支持优先级 Badge 显示和归档操作;✅ V2-P2-13c:NotificationList 支持按类型筛选)
|
||||
- Hooks:`useNotificationStream`(✅ V2-P3 新增:SSE 实时推送 + 轮询降级 Hook)、`useDesktopNotifications`(✅ V2-P2-13c 新增:浏览器桌面推送 Hook,支持权限管理和新通知桌面提醒)
|
||||
- Types:`NotificationPriority`(✅ V2-P2-13b 新增:通知优先级类型 low/normal/high/urgent)
|
||||
|
||||
**依赖关系**:
|
||||
@@ -1182,6 +1183,7 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
- ✅ V2-P2-1 已修复:~~轮询间隔魔法数字~~ `notification-dropdown.tsx` 轮询间隔提取为 `POLL_INTERVAL_MS` 常量(30_000ms)
|
||||
- ✅ V2-P3 已优化:~~30 秒轮询~~ `notification-dropdown.tsx` 改为 SSE 实时推送(`/api/notifications/stream`),SSE 不可用时自动降级为轮询(调用 Server Actions,间隔 30 秒)
|
||||
- ✅ V2-P2-13b 新增:通知优先级和归档功能。schema.ts `messageNotifications` 表新增 `priority`(low/normal/high/urgent,默认 normal)和 `isArchived`(默认 false)字段 + 2 个索引;types.ts 新增 `NotificationPriority` 类型;data-access.ts 新增 `archiveNotification` / `unarchiveNotification` 函数,`getNotifications` 支持归档和优先级筛选(默认仅返回未归档),`createNotification` 支持 priority;actions.ts 新增 `archiveNotificationAction`(含 trackEvent 埋点 notification.archived);NotificationList 组件支持优先级 Badge 显示和归档按钮;i18n 新增 priority/actions.archive/messages.archiveFailed 翻译键
|
||||
- ✅ V2-P2-13c 新增:通知分类筛选 + 浏览器桌面推送。NotificationList 组件新增按类型筛选按钮组(all/message/announcement/homework/grade),空状态区分"无通知"和"无筛选结果";新增 `useDesktopNotifications` Hook(浏览器 Notification API,支持权限管理、自动请求权限、新通知桌面推送、点击跳转);NotificationDropdown 集成桌面推送(监听新通知并触发桌面提醒,首次加载不批量推送);i18n 新增 filter.all / empty.noFilterResults / empty.noFilterResultsDesc 翻译键
|
||||
- ⚠️ P1:发送日志仅 console,无 `notification_logs` 表
|
||||
- ✅ 渠道抽象优秀(接口 + 工厂 + Mock 实现)
|
||||
|
||||
@@ -1193,21 +1195,23 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
| `preferences.ts` | 166 | 通知偏好 CRUD(P0-4 / P1-5 修复后从 messaging 迁移) |
|
||||
| `actions.ts` | ~300 | 7 个 Server Action(✅ P1-4:新增 4 个通知 CRUD Action;✅ V2-P2-13b:新增 archiveNotificationAction) |
|
||||
| `types.ts` | ~130 | 通知负载 + 渠道配置 + 通知记录 + 偏好类型(P0-4 / P1-5 修复后扩充;✅ V2-P2-13b:新增 NotificationPriority 类型 + priority/isArchived 字段) |
|
||||
| `index.ts` | ~80 | 对外导出入口(✅ P1-4:新增组件和 CRUD Action 导出;✅ V2-P2-13b:新增归档函数/Action/类型导出) |
|
||||
| `index.ts` | ~80 | 对外导出入口(✅ P1-4:新增组件和 CRUD Action 导出;✅ V2-P2-13b:新增归档函数/Action/类型导出;✅ V2-P2-13c:新增 useNotificationStream / useDesktopNotifications Hook 导出) |
|
||||
| `channels/*` | 5 文件 | 4 个渠道实现 |
|
||||
| `components/notification-list.tsx` | ~170 | ✅ P1-4 新增(从 messaging 迁移):通知列表组件;✅ V2-P2-13b:支持优先级 Badge 显示和归档操作 |
|
||||
| `components/notification-dropdown.tsx` | ~150 | ✅ P1-4 新增(从 messaging 迁移):通知下拉菜单组件;✅ V2-P3:改用 SSE 实时推送 + 轮询降级 |
|
||||
| `components/notification-list.tsx` | ~190 | ✅ P1-4 新增(从 messaging 迁移):通知列表组件;✅ V2-P2-13b:支持优先级 Badge 显示和归档操作;✅ V2-P2-13c:支持按类型筛选 + 空状态区分 |
|
||||
| `components/notification-dropdown.tsx` | ~180 | ✅ P1-4 新增(从 messaging 迁移):通知下拉菜单组件;✅ V2-P3:改用 SSE 实时推送 + 轮询降级;✅ V2-P2-13c:集成 useDesktopNotifications 桌面推送 |
|
||||
| `hooks/use-notification-stream.ts` | ~195 | ✅ V2-P3 新增:SSE 实时推送 Hook(EventSource + 轮询降级) |
|
||||
| `hooks/use-desktop-notifications.ts` | ~100 | ✅ V2-P2-13c 新增:浏览器桌面推送 Hook(Notification API + 权限管理 + 点击跳转) |
|
||||
|
||||
**组件清单**:
|
||||
| 组件 | 职责 |
|
||||
|------|------|
|
||||
| `components/notification-list.tsx` | 通知列表(消息页底部,展示所有通知,支持标记已读;✅ V2-P0-1:useTranslations 命名空间从 "messages" 切换到 "notifications";✅ V2-P2-13b:支持优先级 Badge 显示和归档操作) |
|
||||
| `components/notification-dropdown.tsx` | 通知下拉菜单(站点头部,✅ V2-P3:改用 SSE 实时推送 `/api/notifications/stream` + 轮询降级;✅ V2-P0-1:useTranslations 命名空间切换;✅ V2-P2-1:POLL_INTERVAL_MS 常量) |
|
||||
| `components/notification-list.tsx` | 通知列表(消息页底部,展示所有通知,支持标记已读;✅ V2-P0-1:useTranslations 命名空间从 "messages" 切换到 "notifications";✅ V2-P2-13b:支持优先级 Badge 显示和归档操作;✅ V2-P2-13c:支持按类型筛选 + 空状态区分"无通知"/"无筛选结果") |
|
||||
| `components/notification-dropdown.tsx` | 通知下拉菜单(站点头部,✅ V2-P3:改用 SSE 实时推送 `/api/notifications/stream` + 轮询降级;✅ V2-P0-1:useTranslations 命名空间切换;✅ V2-P2-1:POLL_INTERVAL_MS 常量;✅ V2-P2-13c:集成 useDesktopNotifications 桌面推送,监听新通知并触发桌面提醒) |
|
||||
|
||||
**客户端行为**:
|
||||
- `notification-dropdown.tsx`:✅ V2-P3 改用 `useNotificationStream` Hook 消费 SSE 实时推送(`/api/notifications/stream`),SSE 不可用时自动降级为轮询(调用 `getNotificationsAction` + `getUnreadNotificationCountAction`,间隔 30 秒)
|
||||
- `notification-dropdown.tsx`:✅ V2-P3 改用 `useNotificationStream` Hook 消费 SSE 实时推送(`/api/notifications/stream`),SSE 不可用时自动降级为轮询(调用 `getNotificationsAction` + `getUnreadNotificationCountAction`,间隔 30 秒);✅ V2-P2-13c 集成 `useDesktopNotifications` Hook,通过 `prevNotificationIds` 对比检测新通知并触发桌面推送(首次加载不批量推送)
|
||||
- `hooks/use-notification-stream.ts`:✅ V2-P3 新增,管理 SSE 连接生命周期(EventSource onopen/onmessage/onerror),降级时通过 `pollFnRef` 调用 Server Actions 轮询
|
||||
- `hooks/use-desktop-notifications.ts`:✅ V2-P2-13c 新增,封装浏览器 Notification API(权限管理、自动请求权限、桌面推送、点击跳转回调),SSR 安全(`typeof window !== "undefined"` 检查)
|
||||
|
||||
---
|
||||
|
||||
@@ -1243,8 +1247,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
**职责**:公告 CRUD + 发布/归档 + 发布通知。
|
||||
|
||||
**导出函数**:
|
||||
- Actions:`getAnnouncementsAction` / `createAnnouncementAction` / `updateAnnouncementAction` / `deleteAnnouncementAction` / `publishAnnouncementAction` / `archiveAnnouncementAction`(✅ P1-2 已修复:actions 层不再直接访问 DB,全部下沉到 data-access;✅ 发布公告时触发通知模块 `sendBatchNotifications`)
|
||||
- Data-access:`getAnnouncements`(支持 `audience` 受众过滤)/ `getAnnouncementById` / `insertAnnouncement` / `updateAnnouncementById` / `deleteAnnouncementById` / `publishAnnouncementById` / `archiveAnnouncementById`(后 5 个为 P1-2 新增)/ `getAdminAnnouncementsPageData` / `getEditAnnouncementPageData`(✅ P1-5 新增:管理端列表页和编辑页编排函数,页面层仅调用单一函数)
|
||||
- Actions:`getAnnouncementsAction` / `createAnnouncementAction` / `updateAnnouncementAction` / `deleteAnnouncementAction` / `publishAnnouncementAction` / `archiveAnnouncementAction`(✅ P1-2 已修复:actions 层不再直接访问 DB,全部下沉到 data-access;✅ 发布公告时触发通知模块 `sendBatchNotifications`)/ `toggleAnnouncementPinAction` / `markAnnouncementAsReadAction` / `getAnnouncementReadStatusAction`(✅ V2-P2-13d 新增:置顶切换、已读标记、批量已读状态查询)
|
||||
- Data-access:`getAnnouncements`(支持 `audience` 受众过滤)/ `getAnnouncementById` / `insertAnnouncement` / `updateAnnouncementById` / `deleteAnnouncementById` / `publishAnnouncementById` / `archiveAnnouncementById`(后 5 个为 P1-2 新增)/ `getAdminAnnouncementsPageData` / `getEditAnnouncementPageData`(✅ P1-5 新增:管理端列表页和编辑页编排函数,页面层仅调用单一函数)/ `toggleAnnouncementPin` / `markAnnouncementAsRead` / `isAnnouncementReadByUser` / `getAnnouncementReadCount` / `getAnnouncementReadStatusForUser`(✅ V2-P2-13d 新增:置顶切换 + 已读回执 CRUD,操作 `announcement_reads` 表)
|
||||
|
||||
**依赖关系**:
|
||||
- 依赖:`shared/*`、`@/auth`、`school`(获取年级列表)、`classes`(获取班级列表 + 解析受众)、`users`(获取目标用户 ID 列表)、`notifications`(发布公告时发送通知)
|
||||
@@ -1267,14 +1271,15 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
|
||||
- ✅ V2-P0-2 已修复:~~通知标题硬编码~~ `createAnnouncementAction` / `updateAnnouncementAction` / `publishAnnouncementAction` 通过 `getTranslations('announcements')` 生成 i18n 通知标题(`notification.publishedTitle` / `publishedContent`)
|
||||
- ✅ V2-P1-1 已修复:~~AnnouncementList 客户端 useState/useMemo 过滤~~ 改为纯服务端过滤模式,Select 切换仅更新 URL `?status=` 触发 RSC 重新渲染
|
||||
- ✅ V2-P1-4 已修复:~~表单无服务端校验错误展示~~ `announcement-form.tsx` 新增 `fieldErrors` 状态 + `aria-invalid` 字段级错误展示(title/content/targetGradeId/targetClassId)
|
||||
- ✅ V2-P2-13d 已实现:公告置顶 + 已读回执。`announcements` 表新增 `isPinned` 字段(置顶优先排序,`getAnnouncements` orderBy 改为 `desc(isPinned), desc(createdAt)`);新增 `announcement_reads` 表(唯一索引 announcementId+userId 保证幂等);新增 3 个 Server Action(`toggleAnnouncementPinAction` / `markAnnouncementAsReadAction` / `getAnnouncementReadStatusAction`)+ 5 个 data-access 函数(`toggleAnnouncementPin` / `markAnnouncementAsRead` / `isAnnouncementReadByUser` / `getAnnouncementReadCount` / `getAnnouncementReadStatusForUser`);`Announcement` 类型新增 `isPinned` / `isReadByCurrentUser?` / `readCount?` 字段;新增 `AnnouncementRead` 类型;埋点新增 `announcement.pin_toggled` / `announcement.marked_read`
|
||||
|
||||
**文件清单**:
|
||||
| 文件 | 行数 | 职责 |
|
||||
|------|------|------|
|
||||
| `actions.ts` | ~330 | 6 个 Server Action + 通知触发逻辑 + trackEvent 埋点(P1-2 已修复,无直接 DB 操作) |
|
||||
| `data-access.ts` | ~230 | 公告 CRUD + 发布/归档 + 受众过滤 + `getAdminAnnouncementsPageData` / `getEditAnnouncementPageData` 编排函数(✅ P1-5 新增) |
|
||||
| `actions.ts` | ~400 | 9 个 Server Action + 通知触发逻辑 + trackEvent 埋点(P1-2 已修复,无直接 DB 操作;V2-P2-13d 新增置顶/已读/批量已读状态 3 个 Action) |
|
||||
| `data-access.ts` | ~300 | 公告 CRUD + 发布/归档 + 受众过滤 + `getAdminAnnouncementsPageData` / `getEditAnnouncementPageData` 编排函数(✅ P1-5 新增;V2-P2-13d 新增置顶切换 + 已读回执 5 个函数,操作 `announcement_reads` 表) |
|
||||
| `schema.ts` | ~70 | Zod 校验 + `refineAudience` 条件校验(✅ P1-6 新增 superRefine) |
|
||||
| `types.ts` | ~65 | 类型定义(`GetAnnouncementsParams` 新增 `audience` 字段) |
|
||||
| `types.ts` | ~75 | 类型定义(`GetAnnouncementsParams` 新增 `audience` 字段;V2-P2-13d `Announcement` 新增 `isPinned` / `isReadByCurrentUser?` / `readCount?`,新增 `AnnouncementRead` 接口) |
|
||||
|
||||
**组件清单**:
|
||||
| 组件 | 职责 |
|
||||
|
||||
@@ -8408,6 +8408,45 @@
|
||||
"usedBy": [
|
||||
"待扩展"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "toggleAnnouncementPinAction",
|
||||
"permission": "ANNOUNCEMENT_MANAGE",
|
||||
"signature": "(id: string) => Promise<ActionState<string>>",
|
||||
"purpose": "V2-P2-13d 新增:切换公告置顶状态(置顶公告在列表中优先显示)",
|
||||
"deps": [
|
||||
"requirePermission",
|
||||
"data-access.toggleAnnouncementPin"
|
||||
],
|
||||
"usedBy": [
|
||||
"待扩展"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "markAnnouncementAsReadAction",
|
||||
"permission": "ANNOUNCEMENT_READ",
|
||||
"signature": "(announcementId: string) => Promise<ActionState<string>>",
|
||||
"purpose": "V2-P2-13d 新增:标记公告为已读(当前用户维度,幂等)",
|
||||
"deps": [
|
||||
"requirePermission",
|
||||
"data-access.markAnnouncementAsRead"
|
||||
],
|
||||
"usedBy": [
|
||||
"待扩展"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "getAnnouncementReadStatusAction",
|
||||
"permission": "ANNOUNCEMENT_READ",
|
||||
"signature": "(announcementIds: string[]) => Promise<ActionState<Record<string, boolean>>>",
|
||||
"purpose": "V2-P2-13d 新增:批量获取当前用户对多个公告的已读状态(用于列表页标记已读/未读)",
|
||||
"deps": [
|
||||
"requirePermission",
|
||||
"data-access.getAnnouncementReadStatusForUser"
|
||||
],
|
||||
"usedBy": [
|
||||
"待扩展"
|
||||
]
|
||||
}
|
||||
],
|
||||
"dataAccess": [
|
||||
@@ -8505,6 +8544,71 @@
|
||||
"usedBy": [
|
||||
"archiveAnnouncementAction"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "toggleAnnouncementPin",
|
||||
"signature": "(id: string) => Promise<void>",
|
||||
"file": "data-access.ts",
|
||||
"purpose": "V2-P2-13d 新增:切换公告置顶状态(查询当前 isPinned 后取反更新)",
|
||||
"deps": [
|
||||
"shared.db",
|
||||
"shared.db.schema.announcements"
|
||||
],
|
||||
"usedBy": [
|
||||
"toggleAnnouncementPinAction"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "markAnnouncementAsRead",
|
||||
"signature": "(announcementId: string, userId: string) => Promise<void>",
|
||||
"file": "data-access.ts",
|
||||
"purpose": "V2-P2-13d 新增:标记公告为已读(先查后插,依赖唯一索引保证幂等)",
|
||||
"deps": [
|
||||
"shared.db",
|
||||
"shared.db.schema.announcementReads"
|
||||
],
|
||||
"usedBy": [
|
||||
"markAnnouncementAsReadAction"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "isAnnouncementReadByUser",
|
||||
"signature": "(announcementId: string, userId: string) => Promise<boolean>",
|
||||
"file": "data-access.ts",
|
||||
"purpose": "V2-P2-13d 新增:检查用户是否已读指定公告",
|
||||
"deps": [
|
||||
"shared.db",
|
||||
"shared.db.schema.announcementReads"
|
||||
],
|
||||
"usedBy": [
|
||||
"待扩展"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "getAnnouncementReadCount",
|
||||
"signature": "(announcementId: string) => Promise<number>",
|
||||
"file": "data-access.ts",
|
||||
"purpose": "V2-P2-13d 新增:获取公告的已读用户数(管理端统计)",
|
||||
"deps": [
|
||||
"shared.db",
|
||||
"shared.db.schema.announcementReads"
|
||||
],
|
||||
"usedBy": [
|
||||
"待扩展"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "getAnnouncementReadStatusForUser",
|
||||
"signature": "(announcementIds: string[], userId: string) => Promise<Map<string, boolean>>",
|
||||
"file": "data-access.ts",
|
||||
"purpose": "V2-P2-13d 新增:批量获取用户对多个公告的已读状态(列表页标记已读/未读)",
|
||||
"deps": [
|
||||
"shared.db",
|
||||
"shared.db.schema.announcementReads"
|
||||
],
|
||||
"usedBy": [
|
||||
"getAnnouncementReadStatusAction"
|
||||
]
|
||||
}
|
||||
],
|
||||
"schemas": [
|
||||
@@ -8532,7 +8636,7 @@
|
||||
"name": "Announcement",
|
||||
"type": "interface",
|
||||
"file": "types.ts",
|
||||
"definition": "{ id, title, content, type, status, targetGradeId, targetClassId, authorId, authorName, publishedAt, createdAt, updatedAt }",
|
||||
"definition": "{ id, title, content, type, status, targetGradeId, targetClassId, authorId, authorName, publishedAt, isPinned, isReadByCurrentUser?, readCount?, createdAt, updatedAt }",
|
||||
"usedBy": [
|
||||
"announcements/components",
|
||||
"页面"
|
||||
@@ -8576,6 +8680,15 @@
|
||||
"getAnnouncements",
|
||||
"getAnnouncementsAction"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "AnnouncementRead",
|
||||
"type": "interface",
|
||||
"file": "types.ts",
|
||||
"definition": "{ id, announcementId, userId, readAt }",
|
||||
"usedBy": [
|
||||
"data-access.announcement_reads 相关函数"
|
||||
]
|
||||
}
|
||||
],
|
||||
"components": [
|
||||
@@ -10820,6 +10933,64 @@
|
||||
"unread-message-badge.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "toggleMessageStarAction",
|
||||
"permission": "MESSAGE_READ",
|
||||
"signature": "(messageId: string) => Promise<ActionState<string>>",
|
||||
"purpose": "V2-P2-13c 新增:切换消息星标状态(仅接收方可标记;trackEvent 埋点 message.star_toggled)",
|
||||
"deps": [
|
||||
"requirePermission",
|
||||
"schema.MessageIdSchema",
|
||||
"data-access.toggleMessageStar",
|
||||
"trackEvent",
|
||||
"revalidatePath"
|
||||
],
|
||||
"usedBy": [
|
||||
"message-detail.tsx",
|
||||
"message-list.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "getMessageDraftsAction",
|
||||
"permission": "MESSAGE_SEND",
|
||||
"signature": "() => Promise<ActionState<MessageDraft[]>>",
|
||||
"purpose": "V2-P2-13c 新增:获取当前用户的消息草稿列表(按 updatedAt 倒序)",
|
||||
"deps": [
|
||||
"requirePermission",
|
||||
"data-access.getMessageDrafts"
|
||||
],
|
||||
"usedBy": [
|
||||
"messages/compose/page.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "saveMessageDraftAction",
|
||||
"permission": "MESSAGE_SEND",
|
||||
"signature": "(prevState: ActionState<string> | null, formData: FormData) => Promise<ActionState<string>>",
|
||||
"purpose": "V2-P2-13c 新增:保存消息草稿(FormData 含 draftId 时更新,否则创建新草稿;返回草稿 ID)",
|
||||
"deps": [
|
||||
"requirePermission",
|
||||
"data-access.createMessageDraft",
|
||||
"data-access.updateMessageDraft"
|
||||
],
|
||||
"usedBy": [
|
||||
"messages/compose/page.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "deleteMessageDraftAction",
|
||||
"permission": "MESSAGE_SEND",
|
||||
"signature": "(draftId: string) => Promise<ActionState<string>>",
|
||||
"purpose": "V2-P2-13c 新增:删除消息草稿(仅草稿所有者可删;revalidatePath /messages/compose)",
|
||||
"deps": [
|
||||
"requirePermission",
|
||||
"data-access.deleteMessageDraft",
|
||||
"revalidatePath"
|
||||
],
|
||||
"usedBy": [
|
||||
"messages/compose/page.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "getNotificationPreferencesAction",
|
||||
"permission": "MESSAGE_READ",
|
||||
@@ -10929,6 +11100,19 @@
|
||||
"deleteMessageAction"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "toggleMessageStar",
|
||||
"signature": "(id: string, userId: string) => Promise<void>",
|
||||
"file": "data-access.ts",
|
||||
"purpose": "V2-P2-13c 新增:切换消息星标状态(仅接收方可标记;查询当前 isStarred 后取反更新)",
|
||||
"deps": [
|
||||
"shared.db",
|
||||
"shared.db.schema.messages"
|
||||
],
|
||||
"usedBy": [
|
||||
"toggleMessageStarAction"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "getUnreadMessageCount",
|
||||
"signature": "(userId: string) => Promise<number>",
|
||||
@@ -10987,6 +11171,74 @@
|
||||
"getRecipientsAction",
|
||||
"messages/compose/page.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "getMessageDrafts",
|
||||
"signature": "(userId: string) => Promise<MessageDraft[]>",
|
||||
"file": "data-access.ts",
|
||||
"purpose": "V2-P2-13c 新增:获取用户消息草稿列表(按 updatedAt 倒序;React cache 包装;批量解析 receiverName)",
|
||||
"deps": [
|
||||
"shared.db",
|
||||
"shared.db.schema.messageDrafts",
|
||||
"shared.db.schema.users"
|
||||
],
|
||||
"usedBy": [
|
||||
"getMessageDraftsAction"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "createMessageDraft",
|
||||
"signature": "(data: CreateMessageDraftInput) => Promise<string>",
|
||||
"file": "data-access.ts",
|
||||
"purpose": "V2-P2-13c 新增:创建消息草稿(cuid2 生成 ID;返回新草稿 ID)",
|
||||
"deps": [
|
||||
"shared.db",
|
||||
"shared.db.schema.messageDrafts",
|
||||
"@paralleldrive/cuid2"
|
||||
],
|
||||
"usedBy": [
|
||||
"saveMessageDraftAction"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "updateMessageDraft",
|
||||
"signature": "(id: string, userId: string, data: UpdateMessageDraftInput) => Promise<void>",
|
||||
"file": "data-access.ts",
|
||||
"purpose": "V2-P2-13c 新增:更新消息草稿(仅 owner 可改;未提供字段保留原值;updatedAt 由 onUpdateNow 自动更新)",
|
||||
"deps": [
|
||||
"shared.db",
|
||||
"shared.db.schema.messageDrafts"
|
||||
],
|
||||
"usedBy": [
|
||||
"saveMessageDraftAction"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "deleteMessageDraft",
|
||||
"signature": "(id: string, userId: string) => Promise<void>",
|
||||
"file": "data-access.ts",
|
||||
"purpose": "V2-P2-13c 新增:删除消息草稿(仅 owner 可删)",
|
||||
"deps": [
|
||||
"shared.db",
|
||||
"shared.db.schema.messageDrafts"
|
||||
],
|
||||
"usedBy": [
|
||||
"deleteMessageDraftAction"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "getMessageDraftById",
|
||||
"signature": "(id: string, userId: string) => Promise<MessageDraft | null>",
|
||||
"file": "data-access.ts",
|
||||
"purpose": "V2-P2-13c 新增:按 ID 获取单条消息草稿(仅 owner 可读;解析 receiverName)",
|
||||
"deps": [
|
||||
"shared.db",
|
||||
"shared.db.schema.messageDrafts",
|
||||
"shared.db.schema.users"
|
||||
],
|
||||
"usedBy": [
|
||||
"messages/compose/page.tsx"
|
||||
]
|
||||
}
|
||||
],
|
||||
"notificationPreferences": [
|
||||
@@ -11032,7 +11284,7 @@
|
||||
"name": "Message",
|
||||
"type": "type",
|
||||
"file": "types.ts",
|
||||
"definition": "{ id, senderId, receiverId, subject: string | null, content, isRead, readAt: string | null, parentMessageId: string | null, createdAt, senderName, receiverName }",
|
||||
"definition": "{ id, senderId, receiverId, subject: string | null, content, isRead, isStarred, readAt: string | null, parentMessageId: string | null, createdAt, senderName, receiverName }",
|
||||
"usedBy": [
|
||||
"messaging/components",
|
||||
"页面"
|
||||
@@ -11121,6 +11373,36 @@
|
||||
"compose 页面下拉选项"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "MessageDraft",
|
||||
"type": "type",
|
||||
"file": "types.ts",
|
||||
"definition": "V2-P2-13c 新增:{ id, userId, receiverId: string | null, receiverName: string | null, subject: string | null, content: string | null, parentMessageId: string | null, createdAt, updatedAt }",
|
||||
"usedBy": [
|
||||
"getMessageDrafts",
|
||||
"getMessageDraftById",
|
||||
"getMessageDraftsAction",
|
||||
"messages/compose/page.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "CreateMessageDraftInput",
|
||||
"type": "type",
|
||||
"file": "types.ts",
|
||||
"definition": "V2-P2-13c 新增:{ userId, receiverId?, subject?, content?, parentMessageId? }",
|
||||
"usedBy": [
|
||||
"createMessageDraft"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "UpdateMessageDraftInput",
|
||||
"type": "type",
|
||||
"file": "types.ts",
|
||||
"definition": "V2-P2-13c 新增:{ receiverId?, subject?, content?, parentMessageId? }(未提供字段保留原值)",
|
||||
"usedBy": [
|
||||
"updateMessageDraft"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "PaginatedResult",
|
||||
"type": "type",
|
||||
@@ -11705,12 +11987,30 @@
|
||||
{
|
||||
"name": "NotificationList",
|
||||
"file": "components/notification-list.tsx",
|
||||
"purpose": "P1-4 新增(从 messaging 迁移):通知完整列表(全部标记已读、单条标记已读、查看链接);V2-P0-1 优化:useTranslations 命名空间从 'messages' 切换到独立的 'notifications'"
|
||||
"purpose": "P1-4 新增(从 messaging 迁移):通知完整列表(全部标记已读、单条标记已读、查看链接);V2-P0-1 优化:useTranslations 命名空间从 'messages' 切换到独立的 'notifications';V2-P2-13b:支持优先级 Badge 显示和归档操作;V2-P2-13c:支持按类型筛选(all/message/announcement/homework/grade)+ 空状态区分'无通知'/'无筛选结果'"
|
||||
},
|
||||
{
|
||||
"name": "NotificationDropdown",
|
||||
"file": "components/notification-dropdown.tsx",
|
||||
"purpose": "P1-4 新增(从 messaging 迁移):SiteHeader 通知下拉菜单(Bell 图标 + 未读数 Badge,每 30 秒轮询,滚动列表,标记已读,查看全部链接);V2-P0-1 优化:useTranslations 命名空间从 'messages' 切换到独立的 'notifications';V2-P2-1 优化:轮询间隔提取为 POLL_INTERVAL_MS 常量"
|
||||
"purpose": "P1-4 新增(从 messaging 迁移):SiteHeader 通知下拉菜单(Bell 图标 + 未读数 Badge,每 30 秒轮询,滚动列表,标记已读,查看全部链接);V2-P0-1 优化:useTranslations 命名空间从 'messages' 切换到独立的 'notifications';V2-P2-1 优化:轮询间隔提取为 POLL_INTERVAL_MS 常量;V2-P3:改用 SSE 实时推送 + 轮询降级;V2-P2-13c:集成 useDesktopNotifications 桌面推送,监听新通知并触发桌面提醒(首次加载不批量推送)"
|
||||
}
|
||||
],
|
||||
"hooks": [
|
||||
{
|
||||
"name": "useNotificationStream",
|
||||
"file": "hooks/use-notification-stream.ts",
|
||||
"purpose": "V2-P3 新增:SSE 实时推送 Hook(EventSource + 轮询降级),管理 SSE 连接生命周期,降级时通过 pollFnRef 调用 Server Actions 轮询",
|
||||
"usedBy": [
|
||||
"components/notification-dropdown.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "useDesktopNotifications",
|
||||
"file": "hooks/use-desktop-notifications.ts",
|
||||
"purpose": "V2-P2-13c 新增:浏览器桌面推送 Hook(Notification API),支持权限管理、自动请求权限、新通知桌面推送、点击跳转回调;SSR 安全(typeof window !== 'undefined' 检查)",
|
||||
"usedBy": [
|
||||
"components/notification-dropdown.tsx"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -14956,7 +15256,7 @@
|
||||
},
|
||||
"dbTables": {
|
||||
"_meta": {
|
||||
"total": 59,
|
||||
"total": 60,
|
||||
"orm": "Drizzle ORM 0.45",
|
||||
"database": "MySQL",
|
||||
"idStrategy": "CUID2 (varchar length 128)",
|
||||
@@ -15152,7 +15452,11 @@
|
||||
"tables": {
|
||||
"announcements": {
|
||||
"owner": "announcements",
|
||||
"description": "公告(school/grade/class,draft/published/archived)"
|
||||
"description": "公告(school/grade/class,draft/published/archived;V2-P2-13d 新增 isPinned 置顶字段)"
|
||||
},
|
||||
"announcementReads": {
|
||||
"owner": "announcements",
|
||||
"description": "公告已读回执(V2-P2-13d 新增,唯一索引 announcementId+userId 保证幂等,cascade 删除)"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -15209,7 +15513,11 @@
|
||||
"tables": {
|
||||
"messages": {
|
||||
"owner": "messaging",
|
||||
"description": "站内消息(含回复链 parentMessageId;软删除 senderDeletedAt/receiverDeletedAt)"
|
||||
"description": "站内消息(含回复链 parentMessageId;软删除 senderDeletedAt/receiverDeletedAt;V2-P2-13c 新增 isStarred 星标字段 + messages_receiver_starred_idx 复合索引)"
|
||||
},
|
||||
"messageDrafts": {
|
||||
"owner": "messaging",
|
||||
"description": "V2-P2-13c 新增:消息草稿(userId/receiverId/subject/content/parentMessageId + updatedAt/createdAt;含 message_drafts_user_idx 和 message_drafts_user_updated_idx 索引)"
|
||||
},
|
||||
"messageNotifications": {
|
||||
"owner": "notifications",
|
||||
@@ -15841,6 +16149,7 @@
|
||||
"auth-guard.requirePermission",
|
||||
"auth-guard.requireAuth",
|
||||
"db.schema.announcements",
|
||||
"db.schema.announcementReads",
|
||||
"types.permissions"
|
||||
],
|
||||
"auth": [
|
||||
|
||||
15
drizzle/0006_notification_logs.sql
Normal file
15
drizzle/0006_notification_logs.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE `notification_logs` (
|
||||
`id` varchar(128) PRIMARY KEY NOT NULL,
|
||||
`user_id` varchar(128) NOT NULL,
|
||||
`title` varchar(255) NOT NULL,
|
||||
`channel` varchar(32) NOT NULL,
|
||||
`status` varchar(16) NOT NULL,
|
||||
`message_id` varchar(255),
|
||||
`error` text,
|
||||
`sent_at` timestamp DEFAULT (now()) NOT NULL,
|
||||
CONSTRAINT `notification_logs_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action
|
||||
);
|
||||
CREATE INDEX `notification_logs_user_idx` ON `notification_logs`(`user_id`);
|
||||
CREATE INDEX `notification_logs_channel_idx` ON `notification_logs`(`channel`);
|
||||
CREATE INDEX `notification_logs_status_idx` ON `notification_logs`(`status`);
|
||||
CREATE INDEX `notification_logs_sent_at_idx` ON `notification_logs`(`sent_at`);
|
||||
4
drizzle/0007_notification_priority_archive.sql
Normal file
4
drizzle/0007_notification_priority_archive.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE `message_notifications` ADD COLUMN `priority` varchar(16) DEFAULT 'normal' NOT NULL;
|
||||
ALTER TABLE `message_notifications` ADD COLUMN `is_archived` boolean DEFAULT false NOT NULL;
|
||||
CREATE INDEX `message_notifications_priority_idx` ON `message_notifications`(`priority`);
|
||||
CREATE INDEX `message_notifications_user_archived_idx` ON `message_notifications`(`user_id`, `is_archived`);
|
||||
17
drizzle/0008_message_star_draft.sql
Normal file
17
drizzle/0008_message_star_draft.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
ALTER TABLE `messages` ADD COLUMN `is_starred` boolean DEFAULT false NOT NULL;
|
||||
CREATE INDEX `messages_receiver_starred_idx` ON `messages`(`receiver_id`, `is_starred`);
|
||||
|
||||
CREATE TABLE `message_drafts` (
|
||||
`id` varchar(128) PRIMARY KEY NOT NULL,
|
||||
`user_id` varchar(128) NOT NULL,
|
||||
`receiver_id` varchar(128),
|
||||
`subject` varchar(255),
|
||||
`content` text,
|
||||
`parent_message_id` varchar(128),
|
||||
`updated_at` timestamp DEFAULT (now()) ON UPDATE now() NOT NULL,
|
||||
`created_at` timestamp DEFAULT (now()) NOT NULL,
|
||||
CONSTRAINT `message_drafts_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action,
|
||||
CONSTRAINT `message_drafts_receiver_id_users_id_fk` FOREIGN KEY (`receiver_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action
|
||||
);
|
||||
CREATE INDEX `message_drafts_user_idx` ON `message_drafts`(`user_id`);
|
||||
CREATE INDEX `message_drafts_user_updated_idx` ON `message_drafts`(`user_id`, `updated_at`);
|
||||
14
drizzle/0009_announcement_pin_reads.sql
Normal file
14
drizzle/0009_announcement_pin_reads.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
ALTER TABLE `announcements` ADD COLUMN `is_pinned` boolean DEFAULT false NOT NULL;
|
||||
CREATE INDEX `announcements_status_pinned_idx` ON `announcements`(`status`, `is_pinned`);
|
||||
|
||||
CREATE TABLE `announcement_reads` (
|
||||
`id` varchar(128) PRIMARY KEY NOT NULL,
|
||||
`announcement_id` varchar(128) NOT NULL,
|
||||
`user_id` varchar(128) NOT NULL,
|
||||
`read_at` timestamp DEFAULT (now()) NOT NULL,
|
||||
CONSTRAINT `announcement_reads_announcement_id_announcements_id_fk` FOREIGN KEY (`announcement_id`) REFERENCES `announcements`(`id`) ON DELETE cascade ON UPDATE no action,
|
||||
CONSTRAINT `announcement_reads_user_id_users_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE cascade ON UPDATE no action
|
||||
);
|
||||
CREATE INDEX `announcement_reads_announcement_idx` ON `announcement_reads`(`announcement_id`);
|
||||
CREATE INDEX `announcement_reads_user_idx` ON `announcement_reads`(`user_id`);
|
||||
CREATE UNIQUE INDEX `announcement_reads_unique_idx` ON `announcement_reads`(`announcement_id`, `user_id`);
|
||||
118
src/app/api/notifications/stream/route.ts
Normal file
118
src/app/api/notifications/stream/route.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { NextRequest } from "next/server"
|
||||
|
||||
import { PermissionDeniedError, requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { getUnreadNotificationCount, getNotifications } from "@/modules/notifications/data-access"
|
||||
|
||||
/**
|
||||
* 通知实时推送 SSE 端点
|
||||
*
|
||||
* 使用 Server-Sent Events 向客户端推送通知更新,
|
||||
* 替代原有的 30/60 秒轮询模式,降低延迟和服务器压力。
|
||||
*
|
||||
* 推送策略:
|
||||
* - 连接建立时立即推送一次当前状态(未读数 + 最新通知列表)
|
||||
* - 之后每 15 秒推送一次更新(作为 SSE 心跳 + 数据刷新)
|
||||
* - 客户端断开连接时清理定时器
|
||||
*
|
||||
* 安全:
|
||||
* - requirePermission(MESSAGE_READ) 权限校验
|
||||
* - 连接超时 5 分钟自动关闭(防止僵尸连接)
|
||||
*/
|
||||
|
||||
const FORMAT_EVENT = (data: unknown): string => `data: ${JSON.stringify(data)}\n\n`
|
||||
const FORMAT_DONE = "data: [DONE]\n\n"
|
||||
|
||||
/** SSE 推送间隔(毫秒) */
|
||||
const SSE_PUSH_INTERVAL_MS = 15_000
|
||||
|
||||
/** SSE 连接最大存活时间(毫秒) */
|
||||
const SSE_MAX_LIFETIME_MS = 5 * 60_000
|
||||
|
||||
export async function GET(_request: NextRequest): Promise<Response> {
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||||
|
||||
let intervalTimer: ReturnType<typeof setInterval> | null = null
|
||||
let lifetimeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let closed = false
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
const safeEnqueue = (data: string): void => {
|
||||
if (closed) return
|
||||
try {
|
||||
controller.enqueue(encoder.encode(data))
|
||||
} catch {
|
||||
closed = true
|
||||
}
|
||||
}
|
||||
|
||||
const pushUpdate = async (): Promise<void> => {
|
||||
if (closed) return
|
||||
try {
|
||||
const [unreadCount, notificationsResult] = await Promise.all([
|
||||
getUnreadNotificationCount(ctx.userId),
|
||||
getNotifications(ctx.userId, { page: 1, pageSize: 10 }),
|
||||
])
|
||||
safeEnqueue(FORMAT_EVENT({
|
||||
type: "update",
|
||||
unreadCount,
|
||||
notifications: notificationsResult.items,
|
||||
}))
|
||||
} catch {
|
||||
// 查询失败不关闭连接,等待下次重试
|
||||
}
|
||||
}
|
||||
|
||||
// 连接建立时立即推送一次
|
||||
await pushUpdate()
|
||||
|
||||
// 定时推送
|
||||
intervalTimer = setInterval(() => {
|
||||
void pushUpdate()
|
||||
}, SSE_PUSH_INTERVAL_MS)
|
||||
|
||||
// 连接超时清理(5 分钟后自动关闭)
|
||||
lifetimeTimer = setTimeout(() => {
|
||||
safeEnqueue(FORMAT_DONE)
|
||||
if (intervalTimer) clearInterval(intervalTimer)
|
||||
if (!closed) {
|
||||
closed = true
|
||||
try {
|
||||
controller.close()
|
||||
} catch {
|
||||
// 已关闭
|
||||
}
|
||||
}
|
||||
}, SSE_MAX_LIFETIME_MS)
|
||||
},
|
||||
cancel() {
|
||||
if (intervalTimer) clearInterval(intervalTimer)
|
||||
if (lifetimeTimer) clearTimeout(lifetimeTimer)
|
||||
closed = true
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return new Response(FORMAT_EVENT({ type: "error", message: "Permission denied" }), {
|
||||
status: 403,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
})
|
||||
}
|
||||
return new Response(FORMAT_EVENT({ type: "error", message: "Internal error" }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ import {
|
||||
deleteAnnouncementById,
|
||||
publishAnnouncementById,
|
||||
archiveAnnouncementById,
|
||||
toggleAnnouncementPin,
|
||||
markAnnouncementAsRead,
|
||||
getAnnouncementReadStatusForUser,
|
||||
} from "./data-access"
|
||||
import type { GetAnnouncementsParams, Announcement } from "./types"
|
||||
|
||||
@@ -325,6 +328,68 @@ export async function archiveAnnouncementAction(id: string): Promise<ActionState
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V2-P2-13d: 公告置顶 Server Action
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function toggleAnnouncementPinAction(id: string): Promise<ActionState<string>> {
|
||||
try {
|
||||
await requirePermission(Permissions.ANNOUNCEMENT_MANAGE)
|
||||
|
||||
await toggleAnnouncementPin(id)
|
||||
revalidatePath("/admin/announcements")
|
||||
revalidatePath("/announcements")
|
||||
|
||||
void trackEvent({
|
||||
event: "announcement.pin_toggled",
|
||||
targetId: id,
|
||||
targetType: "announcement",
|
||||
})
|
||||
|
||||
return { success: true, message: "Pin status toggled" }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V2-P2-13d: 公告已读标记 Server Action
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function markAnnouncementAsReadAction(announcementId: string): Promise<ActionState<string>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.ANNOUNCEMENT_READ)
|
||||
|
||||
await markAnnouncementAsRead(announcementId, ctx.userId)
|
||||
|
||||
void trackEvent({
|
||||
event: "announcement.marked_read",
|
||||
userId: ctx.userId,
|
||||
targetId: announcementId,
|
||||
targetType: "announcement",
|
||||
})
|
||||
|
||||
return { success: true, message: "Announcement marked as read" }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取公告已读状态(用于列表页标记已读/未读)。
|
||||
*/
|
||||
export async function getAnnouncementReadStatusAction(
|
||||
announcementIds: string[]
|
||||
): Promise<ActionState<Record<string, boolean>>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.ANNOUNCEMENT_READ)
|
||||
const statusMap = await getAnnouncementReadStatusForUser(announcementIds, ctx.userId)
|
||||
return { success: true, data: Object.fromEntries(statusMap) }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAnnouncementsAction(
|
||||
params?: GetAnnouncementsParams
|
||||
): Promise<ActionState<Announcement[]>> {
|
||||
|
||||
@@ -51,7 +51,7 @@ export function AdminAnnouncementsView({
|
||||
announcements={announcements}
|
||||
canManage
|
||||
initialStatus={initialStatus}
|
||||
detailHrefBuilder={(id) => `/admin/announcements/${id}`}
|
||||
detailHrefPrefix="/admin/announcements"
|
||||
/>
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={handleOpenChange}>
|
||||
|
||||
@@ -27,18 +27,24 @@ type Filter = "all" | AnnouncementStatus
|
||||
* - Select 切换时更新 URL `?status=`,触发 RSC 重新渲染
|
||||
* - 父页面根据 `?status=` 查询并传入 `announcements` prop
|
||||
* - 组件不再做客户端二次过滤,避免双重过滤逻辑冗余
|
||||
*
|
||||
* 详情链接构建:
|
||||
* - `detailHrefPrefix`:推荐方式,适用于 Server Component(前缀 + id 拼接)
|
||||
* - `detailHrefBuilder`:仅适用于 Client Component 之间的调用
|
||||
*/
|
||||
export function AnnouncementList({
|
||||
announcements,
|
||||
canManage,
|
||||
createHref,
|
||||
detailHrefBuilder,
|
||||
detailHrefPrefix,
|
||||
initialStatus,
|
||||
}: {
|
||||
announcements: Announcement[]
|
||||
canManage?: boolean
|
||||
createHref?: string
|
||||
detailHrefBuilder?: (id: string) => string
|
||||
detailHrefPrefix?: string
|
||||
initialStatus?: Filter
|
||||
}) {
|
||||
const t = useTranslations("announcements")
|
||||
@@ -59,6 +65,14 @@ export function AnnouncementList({
|
||||
router.replace(qs ? `?${qs}` : "?")
|
||||
}
|
||||
|
||||
// 构建详情链接:优先使用 detailHrefPrefix(Server Component 安全),
|
||||
// 其次使用 detailHrefBuilder(仅 Client Component 间调用)
|
||||
const buildDetailHref = (id: string): string | undefined => {
|
||||
if (detailHrefPrefix) return `${detailHrefPrefix}/${id}`
|
||||
if (detailHrefBuilder) return detailHrefBuilder(id)
|
||||
return undefined
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
@@ -101,7 +115,7 @@ export function AnnouncementList({
|
||||
<AnnouncementCard
|
||||
key={a.id}
|
||||
announcement={a}
|
||||
href={detailHrefBuilder ? detailHrefBuilder(a.id) : undefined}
|
||||
href={buildDetailHref(a.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { and, desc, eq, or } from "drizzle-orm"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, count, desc, eq, inArray, or } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { announcements, users } from "@/shared/db/schema"
|
||||
import { announcements, announcementReads, users } from "@/shared/db/schema"
|
||||
import type {
|
||||
Announcement,
|
||||
AnnouncementInsertData,
|
||||
@@ -30,6 +31,7 @@ const mapRow = (
|
||||
authorId: string
|
||||
authorName: string | null
|
||||
publishedAt: Date | null
|
||||
isPinned: boolean
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
}
|
||||
@@ -44,6 +46,7 @@ const mapRow = (
|
||||
authorId: row.authorId,
|
||||
authorName: row.authorName,
|
||||
publishedAt: toIso(row.publishedAt),
|
||||
isPinned: row.isPinned,
|
||||
createdAt: toIsoRequired(row.createdAt),
|
||||
updatedAt: toIsoRequired(row.updatedAt),
|
||||
})
|
||||
@@ -93,13 +96,14 @@ export const getAnnouncements = cache(
|
||||
authorId: announcements.authorId,
|
||||
authorName: users.name,
|
||||
publishedAt: announcements.publishedAt,
|
||||
isPinned: announcements.isPinned,
|
||||
createdAt: announcements.createdAt,
|
||||
updatedAt: announcements.updatedAt,
|
||||
})
|
||||
.from(announcements)
|
||||
.leftJoin(users, eq(users.id, announcements.authorId))
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(desc(announcements.createdAt))
|
||||
.orderBy(desc(announcements.isPinned), desc(announcements.createdAt))
|
||||
.limit(pageSize)
|
||||
.offset(offset)
|
||||
|
||||
@@ -121,6 +125,7 @@ export const getAnnouncementById = cache(
|
||||
authorId: announcements.authorId,
|
||||
authorName: users.name,
|
||||
publishedAt: announcements.publishedAt,
|
||||
isPinned: announcements.isPinned,
|
||||
createdAt: announcements.createdAt,
|
||||
updatedAt: announcements.updatedAt,
|
||||
})
|
||||
@@ -197,6 +202,94 @@ export async function archiveAnnouncementById(id: string): Promise<void> {
|
||||
.where(eq(announcements.id, id))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V2-P2-13d: 公告置顶
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function toggleAnnouncementPin(id: string): Promise<void> {
|
||||
// 查询当前置顶状态
|
||||
const [row] = await db
|
||||
.select({ isPinned: announcements.isPinned })
|
||||
.from(announcements)
|
||||
.where(eq(announcements.id, id))
|
||||
.limit(1)
|
||||
|
||||
if (!row) return
|
||||
|
||||
await db
|
||||
.update(announcements)
|
||||
.set({ isPinned: !row.isPinned, updatedAt: new Date() })
|
||||
.where(eq(announcements.id, id))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V2-P2-13d: 公告已读回执(announcement_reads 表)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 标记公告为已读(如果尚未标记)。
|
||||
* 使用 INSERT IGNORE 语义避免重复插入(依赖唯一索引)。
|
||||
*/
|
||||
export async function markAnnouncementAsRead(announcementId: string, userId: string): Promise<void> {
|
||||
// 先检查是否已存在已读记录
|
||||
const [existing] = await db
|
||||
.select({ id: announcementReads.id })
|
||||
.from(announcementReads)
|
||||
.where(and(eq(announcementReads.announcementId, announcementId), eq(announcementReads.userId, userId)))
|
||||
.limit(1)
|
||||
|
||||
if (existing) return
|
||||
|
||||
const id = createId()
|
||||
await db.insert(announcementReads).values({
|
||||
id,
|
||||
announcementId,
|
||||
userId,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查用户是否已读指定公告。
|
||||
*/
|
||||
export async function isAnnouncementReadByUser(announcementId: string, userId: string): Promise<boolean> {
|
||||
const [row] = await db
|
||||
.select({ id: announcementReads.id })
|
||||
.from(announcementReads)
|
||||
.where(and(eq(announcementReads.announcementId, announcementId), eq(announcementReads.userId, userId)))
|
||||
.limit(1)
|
||||
return !!row
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公告的已读用户数。
|
||||
*/
|
||||
export async function getAnnouncementReadCount(announcementId: string): Promise<number> {
|
||||
const [row] = await db
|
||||
.select({ value: count() })
|
||||
.from(announcementReads)
|
||||
.where(eq(announcementReads.announcementId, announcementId))
|
||||
return Number(row?.value ?? 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取用户对多个公告的已读状态。
|
||||
* 返回 Map<announcementId, boolean>
|
||||
*/
|
||||
export async function getAnnouncementReadStatusForUser(
|
||||
announcementIds: string[],
|
||||
userId: string
|
||||
): Promise<Map<string, boolean>> {
|
||||
if (announcementIds.length === 0) return new Map()
|
||||
|
||||
const rows = await db
|
||||
.select({ announcementId: announcementReads.announcementId })
|
||||
.from(announcementReads)
|
||||
.where(and(eq(announcementReads.userId, userId), inArray(announcementReads.announcementId, announcementIds)))
|
||||
|
||||
const readSet = new Set(rows.map((r) => r.announcementId))
|
||||
return new Map(announcementIds.map((id) => [id, readSet.has(id)]))
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端公告列表页编排函数:一次性获取公告列表、年级列表、班级列表。
|
||||
* 将原本散落在 page.tsx 中的多模块编排逻辑下沉到 data-access 层,
|
||||
|
||||
@@ -13,6 +13,11 @@ export interface Announcement {
|
||||
authorId: string
|
||||
authorName: string | null
|
||||
publishedAt: string | null
|
||||
isPinned: boolean
|
||||
/** V2-P2-13d: 当前用户是否已读(仅用户端查询时填充) */
|
||||
isReadByCurrentUser?: boolean
|
||||
/** V2-P2-13d: 已读人数(仅管理端查询时填充) */
|
||||
readCount?: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
@@ -59,3 +64,11 @@ export interface AnnouncementUpdateData {
|
||||
publishedAt: Date | null
|
||||
updatedAt: Date
|
||||
}
|
||||
|
||||
/** V2-P2-13d: 公告已读回执 */
|
||||
export interface AnnouncementRead {
|
||||
id: string
|
||||
announcementId: string
|
||||
userId: string
|
||||
readAt: string
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ import {
|
||||
deleteMessage,
|
||||
getRecipients,
|
||||
getUnreadMessageCount,
|
||||
toggleMessageStar,
|
||||
getMessageDrafts,
|
||||
createMessageDraft,
|
||||
updateMessageDraft,
|
||||
deleteMessageDraft,
|
||||
} from "./data-access"
|
||||
import {
|
||||
getNotificationPreferences,
|
||||
@@ -216,6 +221,110 @@ export async function getUnreadMessageCountAction(): Promise<ActionState<number>
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V2-P2-13c: 消息星标 Server Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function toggleMessageStarAction(messageId: string): Promise<ActionState<string>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||||
|
||||
const parsed = MessageIdSchema.safeParse({ messageId })
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Invalid message id", errors: parsed.error.flatten().fieldErrors }
|
||||
}
|
||||
|
||||
await toggleMessageStar(parsed.data.messageId, ctx.userId)
|
||||
revalidatePath("/messages")
|
||||
revalidatePath(`/messages/${parsed.data.messageId}`)
|
||||
|
||||
void trackEvent({
|
||||
event: "message.star_toggled",
|
||||
userId: ctx.userId,
|
||||
targetId: parsed.data.messageId,
|
||||
targetType: "message",
|
||||
})
|
||||
|
||||
return { success: true, message: "Star toggled" }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V2-P2-13c: 消息草稿 Server Actions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function getMessageDraftsAction(): Promise<ActionState<import("./types").MessageDraft[]>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.MESSAGE_SEND)
|
||||
const drafts = await getMessageDrafts(ctx.userId)
|
||||
return { success: true, data: drafts }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveMessageDraftAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.MESSAGE_SEND)
|
||||
|
||||
const draftId = formData.get("draftId") as string | null
|
||||
const receiverId = formData.get("receiverId") as string | null
|
||||
const subject = formData.get("subject") as string | null
|
||||
const content = formData.get("content") as string | null
|
||||
const parentMessageId = formData.get("parentMessageId") as string | null
|
||||
|
||||
if (draftId) {
|
||||
// 更新现有草稿
|
||||
await updateMessageDraft(draftId, ctx.userId, {
|
||||
receiverId: receiverId || null,
|
||||
subject: subject || null,
|
||||
content: content || null,
|
||||
parentMessageId: parentMessageId || null,
|
||||
})
|
||||
return { success: true, message: "Draft saved", data: draftId }
|
||||
}
|
||||
|
||||
// 创建新草稿
|
||||
const id = await createMessageDraft({
|
||||
userId: ctx.userId,
|
||||
receiverId: receiverId || null,
|
||||
subject: subject || null,
|
||||
content: content || null,
|
||||
parentMessageId: parentMessageId || null,
|
||||
})
|
||||
|
||||
return { success: true, message: "Draft created", data: id }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMessageDraftAction(draftId: string): Promise<ActionState<string>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.MESSAGE_SEND)
|
||||
|
||||
await deleteMessageDraft(draftId, ctx.userId)
|
||||
revalidatePath("/messages/compose")
|
||||
|
||||
return { success: true, message: "Draft deleted" }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 通知相关 Server Actions(getNotificationsAction / markNotificationAsReadAction /
|
||||
// markAllNotificationsAsReadAction / getUnreadNotificationCountAction)已迁移至
|
||||
|
||||
@@ -6,15 +6,18 @@ import { Badge } from "@/shared/components/ui/badge"
|
||||
|
||||
import { getUnreadMessageCountAction } from "../actions"
|
||||
|
||||
/** 未读消息计数轮询间隔(毫秒) */
|
||||
const POLL_INTERVAL_MS = 60_000
|
||||
|
||||
/**
|
||||
* 未读消息计数徽章
|
||||
*
|
||||
* 在侧边栏 Messages 导航项旁显示未读私信数。
|
||||
* 每 POLL_INTERVAL_MS 毫秒轮询一次以保持计数更新。
|
||||
*
|
||||
* 注意:当前 SSE 端点(/api/notifications/stream)仅推送通知数据,
|
||||
* 不包含私信未读数,因此本组件仍使用轮询模式。轮询间隔与通知组件
|
||||
* 保持一致(30 秒),未来可扩展 SSE 端点同时推送消息未读数以实现实时更新。
|
||||
*/
|
||||
const POLL_INTERVAL_MS = 30_000
|
||||
|
||||
export function UnreadMessageBadge() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import { and, count, desc, eq, inArray, isNull, like, or, type SQL } from "drizz
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
messages,
|
||||
messageDrafts,
|
||||
users,
|
||||
} from "@/shared/db/schema"
|
||||
import {
|
||||
@@ -36,6 +37,9 @@ import type {
|
||||
GetMessagesParams,
|
||||
CreateMessageInput,
|
||||
RecipientOption,
|
||||
MessageDraft,
|
||||
CreateMessageDraftInput,
|
||||
UpdateMessageDraftInput,
|
||||
} from "./types"
|
||||
import type { PaginatedResult } from "@/modules/notifications/types"
|
||||
|
||||
@@ -50,6 +54,7 @@ interface MessageRow {
|
||||
subject: string | null
|
||||
content: string
|
||||
isRead: boolean
|
||||
isStarred: boolean
|
||||
readAt: Date | null
|
||||
parentMessageId: string | null
|
||||
createdAt: Date
|
||||
@@ -74,6 +79,7 @@ const mapMessage = (r: MessageRow, nameMap: Map<string, string>): Message => ({
|
||||
subject: r.subject,
|
||||
content: r.content,
|
||||
isRead: r.isRead,
|
||||
isStarred: r.isStarred,
|
||||
readAt: toIso(r.readAt),
|
||||
parentMessageId: r.parentMessageId,
|
||||
createdAt: toIsoRequired(r.createdAt),
|
||||
@@ -108,6 +114,11 @@ export const getMessages = cache(
|
||||
if (kwCond) conds.push(kwCond)
|
||||
}
|
||||
|
||||
// V2-P2-13c: 仅返回星标消息
|
||||
if (params.starredOnly) {
|
||||
conds.push(eq(messages.isStarred, true))
|
||||
}
|
||||
|
||||
const where = and(...conds)
|
||||
const [rows, [totalRow]] = await Promise.all([
|
||||
db.select().from(messages).where(where).orderBy(desc(messages.createdAt)).limit(pageSize).offset(offset),
|
||||
@@ -193,6 +204,22 @@ export async function deleteMessage(id: string, userId: string): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
export async function toggleMessageStar(id: string, userId: string): Promise<void> {
|
||||
// 查询当前星标状态
|
||||
const [row] = await db
|
||||
.select({ isStarred: messages.isStarred })
|
||||
.from(messages)
|
||||
.where(and(eq(messages.id, id), eq(messages.receiverId, userId)))
|
||||
.limit(1)
|
||||
|
||||
if (!row) return
|
||||
|
||||
await db
|
||||
.update(messages)
|
||||
.set({ isStarred: !row.isStarred })
|
||||
.where(and(eq(messages.id, id), eq(messages.receiverId, userId)))
|
||||
}
|
||||
|
||||
export const getUnreadMessageCount = cache(async (userId: string): Promise<number> => {
|
||||
const [row] = await db
|
||||
.select({ value: count() })
|
||||
@@ -288,3 +315,90 @@ export async function getMessageDetailPageData(
|
||||
|
||||
return message
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V2-P2-13c: 消息草稿 CRUD(message_drafts 表)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mapDraft = (
|
||||
r: {
|
||||
id: string
|
||||
userId: string
|
||||
receiverId: string | null
|
||||
subject: string | null
|
||||
content: string | null
|
||||
parentMessageId: string | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
},
|
||||
nameMap: Map<string, string>
|
||||
): MessageDraft => ({
|
||||
id: r.id,
|
||||
userId: r.userId,
|
||||
receiverId: r.receiverId,
|
||||
receiverName: r.receiverId ? (nameMap.get(r.receiverId) ?? null) : null,
|
||||
subject: r.subject,
|
||||
content: r.content,
|
||||
parentMessageId: r.parentMessageId,
|
||||
createdAt: toIsoRequired(r.createdAt),
|
||||
updatedAt: toIsoRequired(r.updatedAt),
|
||||
})
|
||||
|
||||
export const getMessageDrafts = cache(
|
||||
async (userId: string): Promise<MessageDraft[]> => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(messageDrafts)
|
||||
.where(eq(messageDrafts.userId, userId))
|
||||
.orderBy(desc(messageDrafts.updatedAt))
|
||||
|
||||
const receiverIds = rows.map((r) => r.receiverId).filter((id): id is string => id !== null)
|
||||
const nameMap = await resolveUserNames(receiverIds)
|
||||
|
||||
return rows.map((r) => mapDraft(r, nameMap))
|
||||
}
|
||||
)
|
||||
|
||||
export async function createMessageDraft(data: CreateMessageDraftInput): Promise<string> {
|
||||
const id = createId()
|
||||
await db.insert(messageDrafts).values({
|
||||
id,
|
||||
userId: data.userId,
|
||||
receiverId: data.receiverId ?? null,
|
||||
subject: data.subject ?? null,
|
||||
content: data.content ?? null,
|
||||
parentMessageId: data.parentMessageId ?? null,
|
||||
})
|
||||
return id
|
||||
}
|
||||
|
||||
export async function updateMessageDraft(id: string, userId: string, data: UpdateMessageDraftInput): Promise<void> {
|
||||
await db
|
||||
.update(messageDrafts)
|
||||
.set({
|
||||
...(data.receiverId !== undefined && { receiverId: data.receiverId }),
|
||||
...(data.subject !== undefined && { subject: data.subject }),
|
||||
...(data.content !== undefined && { content: data.content }),
|
||||
...(data.parentMessageId !== undefined && { parentMessageId: data.parentMessageId }),
|
||||
})
|
||||
.where(and(eq(messageDrafts.id, id), eq(messageDrafts.userId, userId)))
|
||||
}
|
||||
|
||||
export async function deleteMessageDraft(id: string, userId: string): Promise<void> {
|
||||
await db
|
||||
.delete(messageDrafts)
|
||||
.where(and(eq(messageDrafts.id, id), eq(messageDrafts.userId, userId)))
|
||||
}
|
||||
|
||||
export async function getMessageDraftById(id: string, userId: string): Promise<MessageDraft | null> {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(messageDrafts)
|
||||
.where(and(eq(messageDrafts.id, id), eq(messageDrafts.userId, userId)))
|
||||
.limit(1)
|
||||
|
||||
if (!row) return null
|
||||
|
||||
const nameMap = row.receiverId ? await resolveUserNames([row.receiverId]) : new Map<string, string>()
|
||||
return mapDraft(row, nameMap)
|
||||
}
|
||||
|
||||
373
src/modules/messaging/schema.test.ts
Normal file
373
src/modules/messaging/schema.test.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import {
|
||||
SendMessageSchema,
|
||||
MessageIdSchema,
|
||||
UpdateNotificationPreferencesSchema,
|
||||
} from "./schema"
|
||||
|
||||
describe("SendMessageSchema", () => {
|
||||
it("should parse valid input with all fields", () => {
|
||||
const result = SendMessageSchema.safeParse({
|
||||
receiverId: "user-1",
|
||||
subject: "Hello",
|
||||
content: "World",
|
||||
parentMessageId: "msg-1",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.receiverId).toBe("user-1")
|
||||
expect(result.data.subject).toBe("Hello")
|
||||
expect(result.data.content).toBe("World")
|
||||
expect(result.data.parentMessageId).toBe("msg-1")
|
||||
}
|
||||
})
|
||||
|
||||
it("should parse valid input with only required fields", () => {
|
||||
const result = SendMessageSchema.safeParse({
|
||||
receiverId: "user-1",
|
||||
content: "Hello",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.receiverId).toBe("user-1")
|
||||
expect(result.data.content).toBe("Hello")
|
||||
expect(result.data.subject).toBeNull()
|
||||
expect(result.data.parentMessageId).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it("should transform empty subject string to null", () => {
|
||||
const result = SendMessageSchema.safeParse({
|
||||
receiverId: "user-1",
|
||||
subject: "",
|
||||
content: "Hello",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.subject).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it("should transform empty parentMessageId string to null", () => {
|
||||
const result = SendMessageSchema.safeParse({
|
||||
receiverId: "user-1",
|
||||
content: "Hello",
|
||||
parentMessageId: "",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.parentMessageId).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it("should accept nullable subject", () => {
|
||||
const result = SendMessageSchema.safeParse({
|
||||
receiverId: "user-1",
|
||||
subject: null,
|
||||
content: "Hello",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.subject).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it("should accept nullable parentMessageId", () => {
|
||||
const result = SendMessageSchema.safeParse({
|
||||
receiverId: "user-1",
|
||||
content: "Hello",
|
||||
parentMessageId: null,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.parentMessageId).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it("should reject empty receiverId", () => {
|
||||
const result = SendMessageSchema.safeParse({
|
||||
receiverId: "",
|
||||
content: "Hello",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("should reject whitespace-only receiverId after trim", () => {
|
||||
const result = SendMessageSchema.safeParse({
|
||||
receiverId: " ",
|
||||
content: "Hello",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("should reject empty content", () => {
|
||||
const result = SendMessageSchema.safeParse({
|
||||
receiverId: "user-1",
|
||||
content: "",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("should reject whitespace-only content after trim", () => {
|
||||
const result = SendMessageSchema.safeParse({
|
||||
receiverId: "user-1",
|
||||
content: " ",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("should reject missing receiverId", () => {
|
||||
const result = SendMessageSchema.safeParse({
|
||||
content: "Hello",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("should reject missing content", () => {
|
||||
const result = SendMessageSchema.safeParse({
|
||||
receiverId: "user-1",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("should reject subject exceeding 255 chars", () => {
|
||||
const result = SendMessageSchema.safeParse({
|
||||
receiverId: "user-1",
|
||||
subject: "a".repeat(256),
|
||||
content: "Hello",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("should accept subject of exactly 255 chars", () => {
|
||||
const result = SendMessageSchema.safeParse({
|
||||
receiverId: "user-1",
|
||||
subject: "a".repeat(255),
|
||||
content: "Hello",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("should trim whitespace from receiverId", () => {
|
||||
const result = SendMessageSchema.safeParse({
|
||||
receiverId: " user-1 ",
|
||||
content: "Hello",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.receiverId).toBe("user-1")
|
||||
}
|
||||
})
|
||||
|
||||
it("should trim whitespace from content", () => {
|
||||
const result = SendMessageSchema.safeParse({
|
||||
receiverId: "user-1",
|
||||
content: " Hello ",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.content).toBe("Hello")
|
||||
}
|
||||
})
|
||||
|
||||
it("should trim whitespace from subject", () => {
|
||||
const result = SendMessageSchema.safeParse({
|
||||
receiverId: "user-1",
|
||||
subject: " Hello ",
|
||||
content: "Hello",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.subject).toBe("Hello")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("MessageIdSchema", () => {
|
||||
it("should parse valid messageId", () => {
|
||||
const result = MessageIdSchema.safeParse({ messageId: "msg-1" })
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.messageId).toBe("msg-1")
|
||||
}
|
||||
})
|
||||
|
||||
it("should reject empty messageId", () => {
|
||||
const result = MessageIdSchema.safeParse({ messageId: "" })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("should reject whitespace-only messageId after trim", () => {
|
||||
const result = MessageIdSchema.safeParse({ messageId: " " })
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("should reject missing messageId", () => {
|
||||
const result = MessageIdSchema.safeParse({})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("should trim whitespace from messageId", () => {
|
||||
const result = MessageIdSchema.safeParse({ messageId: " msg-1 " })
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.messageId).toBe("msg-1")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("UpdateNotificationPreferencesSchema", () => {
|
||||
const validInput = {
|
||||
emailEnabled: true,
|
||||
smsEnabled: false,
|
||||
pushEnabled: true,
|
||||
homeworkNotifications: true,
|
||||
gradeNotifications: true,
|
||||
announcementNotifications: true,
|
||||
messageNotifications: true,
|
||||
attendanceNotifications: false,
|
||||
quietHoursEnabled: false,
|
||||
}
|
||||
|
||||
it("should parse valid input without quiet hours times", () => {
|
||||
const result = UpdateNotificationPreferencesSchema.safeParse(validInput)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("should parse valid input with quiet hours times", () => {
|
||||
const result = UpdateNotificationPreferencesSchema.safeParse({
|
||||
...validInput,
|
||||
quietHoursStart: "22:00",
|
||||
quietHoursEnd: "07:00",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.quietHoursStart).toBe("22:00")
|
||||
expect(result.data.quietHoursEnd).toBe("07:00")
|
||||
}
|
||||
})
|
||||
|
||||
it("should accept null quiet hours times", () => {
|
||||
const result = UpdateNotificationPreferencesSchema.safeParse({
|
||||
...validInput,
|
||||
quietHoursStart: null,
|
||||
quietHoursEnd: null,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("should accept undefined quiet hours times", () => {
|
||||
const result = UpdateNotificationPreferencesSchema.safeParse(validInput)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.quietHoursStart).toBeUndefined()
|
||||
expect(result.data.quietHoursEnd).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it("should reject invalid time format for quietHoursStart", () => {
|
||||
const result = UpdateNotificationPreferencesSchema.safeParse({
|
||||
...validInput,
|
||||
quietHoursStart: "25:00",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("should reject invalid time format for quietHoursEnd", () => {
|
||||
const result = UpdateNotificationPreferencesSchema.safeParse({
|
||||
...validInput,
|
||||
quietHoursEnd: "12:60",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("should reject non-time string for quietHoursStart", () => {
|
||||
const result = UpdateNotificationPreferencesSchema.safeParse({
|
||||
...validInput,
|
||||
quietHoursStart: "not-a-time",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("should accept boundary time 00:00", () => {
|
||||
const result = UpdateNotificationPreferencesSchema.safeParse({
|
||||
...validInput,
|
||||
quietHoursStart: "00:00",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("should accept boundary time 23:59", () => {
|
||||
const result = UpdateNotificationPreferencesSchema.safeParse({
|
||||
...validInput,
|
||||
quietHoursEnd: "23:59",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
||||
it("should reject non-boolean emailEnabled", () => {
|
||||
const result = UpdateNotificationPreferencesSchema.safeParse({
|
||||
...validInput,
|
||||
emailEnabled: "yes",
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("should reject non-boolean smsEnabled", () => {
|
||||
const result = UpdateNotificationPreferencesSchema.safeParse({
|
||||
...validInput,
|
||||
smsEnabled: 1,
|
||||
})
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it("should reject missing required boolean field", () => {
|
||||
const inputWithoutEmail = {
|
||||
smsEnabled: false,
|
||||
pushEnabled: true,
|
||||
homeworkNotifications: true,
|
||||
gradeNotifications: true,
|
||||
announcementNotifications: true,
|
||||
messageNotifications: true,
|
||||
attendanceNotifications: false,
|
||||
quietHoursEnabled: false,
|
||||
}
|
||||
const result = UpdateNotificationPreferencesSchema.safeParse(inputWithoutEmail)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,7 @@ export interface Message {
|
||||
subject: string | null
|
||||
content: string
|
||||
isRead: boolean
|
||||
isStarred: boolean
|
||||
readAt: string | null
|
||||
parentMessageId: string | null
|
||||
createdAt: string
|
||||
@@ -34,6 +35,8 @@ export interface GetMessagesParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
keyword?: string
|
||||
/** V2-P2-13c: 仅返回星标消息 */
|
||||
starredOnly?: boolean
|
||||
}
|
||||
|
||||
export interface CreateMessageInput {
|
||||
@@ -50,3 +53,31 @@ export interface RecipientOption {
|
||||
email: string
|
||||
role?: string
|
||||
}
|
||||
|
||||
/** V2-P2-13c: 消息草稿 */
|
||||
export interface MessageDraft {
|
||||
id: string
|
||||
userId: string
|
||||
receiverId: string | null
|
||||
receiverName: string | null
|
||||
subject: string | null
|
||||
content: string | null
|
||||
parentMessageId: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface CreateMessageDraftInput {
|
||||
userId: string
|
||||
receiverId?: string | null
|
||||
subject?: string | null
|
||||
content?: string | null
|
||||
parentMessageId?: string | null
|
||||
}
|
||||
|
||||
export interface UpdateMessageDraftInput {
|
||||
receiverId?: string | null
|
||||
subject?: string | null
|
||||
content?: string | null
|
||||
parentMessageId?: string | null
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
markNotificationAsRead,
|
||||
markAllNotificationsAsRead,
|
||||
getUnreadNotificationCount,
|
||||
archiveNotification,
|
||||
} from "./data-access"
|
||||
import type { NotificationPayload, ChannelSendResult, Notification } from "./types"
|
||||
|
||||
@@ -265,3 +266,35 @@ export async function markAllNotificationsAsReadAction(): Promise<ActionState<st
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将单条通知归档(归档后不在默认列表显示)。
|
||||
*/
|
||||
export async function archiveNotificationAction(
|
||||
notificationId: string
|
||||
): Promise<ActionState<string>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||||
|
||||
const parsed = NotificationIdSchema.safeParse(notificationId)
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Invalid notification id" }
|
||||
}
|
||||
|
||||
await archiveNotification(parsed.data, ctx.userId)
|
||||
revalidatePath("/messages")
|
||||
|
||||
void trackEvent({
|
||||
event: "notification.archived",
|
||||
userId: ctx.userId,
|
||||
targetId: parsed.data,
|
||||
targetType: "notification",
|
||||
})
|
||||
|
||||
return { success: true, message: "Notification archived" }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
173
src/modules/notifications/channels/in-app-channel.test.ts
Normal file
173
src/modules/notifications/channels/in-app-channel.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createNotification: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("../data-access", () => ({
|
||||
createNotification: mocks.createNotification,
|
||||
}))
|
||||
|
||||
import { createInAppSender } from "./in-app-channel"
|
||||
import type { NotificationPayload } from "../types"
|
||||
import type { ChannelRecipient } from "./types"
|
||||
|
||||
describe("InAppChannelSender", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
})
|
||||
|
||||
const sender = createInAppSender()
|
||||
const recipient: ChannelRecipient = { userId: "user-1" }
|
||||
|
||||
it("should map info type to message notification type", async () => {
|
||||
mocks.createNotification.mockResolvedValue("notif-1")
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
userId: "user-1",
|
||||
title: "Test",
|
||||
content: "Content",
|
||||
type: "info",
|
||||
}
|
||||
|
||||
await sender.send(payload, recipient)
|
||||
|
||||
expect(mocks.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "message" })
|
||||
)
|
||||
})
|
||||
|
||||
it("should map success type to message notification type", async () => {
|
||||
mocks.createNotification.mockResolvedValue("notif-1")
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
userId: "user-1",
|
||||
title: "Test",
|
||||
content: "Content",
|
||||
type: "success",
|
||||
}
|
||||
|
||||
await sender.send(payload, recipient)
|
||||
|
||||
expect(mocks.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "message" })
|
||||
)
|
||||
})
|
||||
|
||||
it("should map warning type to announcement notification type", async () => {
|
||||
mocks.createNotification.mockResolvedValue("notif-1")
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
userId: "user-1",
|
||||
title: "Test",
|
||||
content: "Content",
|
||||
type: "warning",
|
||||
}
|
||||
|
||||
await sender.send(payload, recipient)
|
||||
|
||||
expect(mocks.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "announcement" })
|
||||
)
|
||||
})
|
||||
|
||||
it("should map error type to grade notification type", async () => {
|
||||
mocks.createNotification.mockResolvedValue("notif-1")
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
userId: "user-1",
|
||||
title: "Test",
|
||||
content: "Content",
|
||||
type: "error",
|
||||
}
|
||||
|
||||
await sender.send(payload, recipient)
|
||||
|
||||
expect(mocks.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "grade" })
|
||||
)
|
||||
})
|
||||
|
||||
it("should return success result with messageId", async () => {
|
||||
mocks.createNotification.mockResolvedValue("notif-123")
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
userId: "user-1",
|
||||
title: "Test",
|
||||
content: "Content",
|
||||
type: "info",
|
||||
}
|
||||
|
||||
const result = await sender.send(payload, recipient)
|
||||
|
||||
expect(result.channel).toBe("in_app")
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.messageId).toBe("notif-123")
|
||||
})
|
||||
|
||||
it("should return failure when recipient userId does not match payload userId", async () => {
|
||||
const payload: NotificationPayload = {
|
||||
userId: "user-1",
|
||||
title: "Test",
|
||||
content: "Content",
|
||||
type: "info",
|
||||
}
|
||||
const wrongRecipient: ChannelRecipient = { userId: "user-2" }
|
||||
|
||||
const result = await sender.send(payload, wrongRecipient)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain("does not match")
|
||||
})
|
||||
|
||||
it("should return failure when createNotification throws", async () => {
|
||||
mocks.createNotification.mockRejectedValue(new Error("DB error"))
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
userId: "user-1",
|
||||
title: "Test",
|
||||
content: "Content",
|
||||
type: "info",
|
||||
}
|
||||
|
||||
const result = await sender.send(payload, recipient)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe("DB error")
|
||||
})
|
||||
|
||||
it("should use actionUrl as link when provided", async () => {
|
||||
mocks.createNotification.mockResolvedValue("notif-1")
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
userId: "user-1",
|
||||
title: "Test",
|
||||
content: "Content",
|
||||
type: "info",
|
||||
actionUrl: "/messages/123",
|
||||
}
|
||||
|
||||
await sender.send(payload, recipient)
|
||||
|
||||
expect(mocks.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ link: "/messages/123" })
|
||||
)
|
||||
})
|
||||
|
||||
it("should use null as link when actionUrl not provided", async () => {
|
||||
mocks.createNotification.mockResolvedValue("notif-1")
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
userId: "user-1",
|
||||
title: "Test",
|
||||
content: "Content",
|
||||
type: "info",
|
||||
}
|
||||
|
||||
await sender.send(payload, recipient)
|
||||
|
||||
expect(mocks.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ link: null })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
@@ -20,12 +20,12 @@ import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { cn, formatDate } from "@/shared/lib/utils"
|
||||
|
||||
import {
|
||||
getNotificationsAction,
|
||||
getUnreadNotificationCountAction,
|
||||
markAllNotificationsAsReadAction,
|
||||
markNotificationAsReadAction,
|
||||
} from "../actions"
|
||||
import type { Notification, NotificationType } from "../types"
|
||||
import { useDesktopNotifications } from "../hooks/use-desktop-notifications"
|
||||
import { useNotificationStream } from "../hooks/use-notification-stream"
|
||||
import type { NotificationType } from "../types"
|
||||
|
||||
const TYPE_ICON: Record<NotificationType, typeof Bell> = {
|
||||
message: MessageSquare,
|
||||
@@ -34,55 +34,52 @@ const TYPE_ICON: Record<NotificationType, typeof Bell> = {
|
||||
grade: GraduationCap,
|
||||
}
|
||||
|
||||
/** 通知下拉菜单轮询间隔(毫秒) */
|
||||
const POLL_INTERVAL_MS = 30_000
|
||||
/** 轮询降级间隔(毫秒) */
|
||||
const FALLBACK_POLL_INTERVAL_MS = 30_000
|
||||
|
||||
export function NotificationDropdown() {
|
||||
const t = useTranslations("notifications")
|
||||
const router = useRouter()
|
||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||
const [unreadCount, setUnreadCount] = useState(0)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
// 使用 SSE 实时推送(自动降级为轮询)
|
||||
const { unreadCount, notifications } = useNotificationStream({
|
||||
fallbackPollInterval: FALLBACK_POLL_INTERVAL_MS,
|
||||
})
|
||||
|
||||
// 桌面推送通知(用户切换标签页时仍可收到提醒)
|
||||
const { showNotification } = useDesktopNotifications({ enabled: true })
|
||||
|
||||
// 监听新通知并触发桌面推送(使用 ref 避免 setState in effect)
|
||||
const prevNotificationIdsRef = useRef<Set<string>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
if (notifications.length === 0) return
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
const res = await getNotificationsAction({ pageSize: 10 })
|
||||
if (!active) return
|
||||
if (res.success && res.data) {
|
||||
setNotifications(res.data.items)
|
||||
}
|
||||
const currentIds = new Set(notifications.map((n) => n.id))
|
||||
const prevIds = prevNotificationIdsRef.current
|
||||
// 首次加载不触发桌面推送(避免页面加载时批量推送)
|
||||
if (prevIds.size === 0) {
|
||||
prevNotificationIdsRef.current = currentIds
|
||||
return
|
||||
}
|
||||
|
||||
const fetchUnreadCount = async () => {
|
||||
const res = await getUnreadNotificationCountAction()
|
||||
if (!active) return
|
||||
if (res.success && typeof res.data === "number") {
|
||||
setUnreadCount(res.data)
|
||||
}
|
||||
// 找出新增的通知
|
||||
const newNotifications = notifications.filter(
|
||||
(n) => !prevIds.has(n.id) && !n.isRead
|
||||
)
|
||||
|
||||
// 为每个新通知触发桌面推送
|
||||
for (const n of newNotifications) {
|
||||
showNotification(n)
|
||||
}
|
||||
|
||||
void fetchNotifications()
|
||||
void fetchUnreadCount()
|
||||
|
||||
// 每 POLL_INTERVAL_MS 毫秒轮询刷新通知和未读计数
|
||||
const timer = setInterval(() => {
|
||||
void fetchNotifications()
|
||||
void fetchUnreadCount()
|
||||
}, POLL_INTERVAL_MS)
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [])
|
||||
prevNotificationIdsRef.current = currentIds
|
||||
}, [notifications, showNotification])
|
||||
|
||||
const handleMarkAllRead = async () => {
|
||||
const res = await markAllNotificationsAsReadAction()
|
||||
if (res.success) {
|
||||
setNotifications((prev) => prev.map((n) => ({ ...n, isRead: true })))
|
||||
setUnreadCount(0)
|
||||
router.refresh()
|
||||
}
|
||||
}
|
||||
@@ -90,10 +87,6 @@ export function NotificationDropdown() {
|
||||
const handleMarkRead = async (id: string) => {
|
||||
const res = await markNotificationAsReadAction(id)
|
||||
if (res.success) {
|
||||
setNotifications((prev) =>
|
||||
prev.map((n) => (n.id === id ? { ...n, isRead: true } : n))
|
||||
)
|
||||
setUnreadCount((c) => Math.max(0, c - 1))
|
||||
router.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ import { Card, CardContent } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { cn, formatDate } from "@/shared/lib/utils"
|
||||
|
||||
import { markAllNotificationsAsReadAction, markNotificationAsReadAction } from "../actions"
|
||||
import type { Notification, NotificationType } from "../types"
|
||||
import { markAllNotificationsAsReadAction, markNotificationAsReadAction, archiveNotificationAction } from "../actions"
|
||||
import type { Notification, NotificationType, NotificationPriority } from "../types"
|
||||
|
||||
const TYPE_ICON: Record<NotificationType, typeof Bell> = {
|
||||
message: MessageSquare,
|
||||
@@ -23,12 +23,24 @@ const TYPE_ICON: Record<NotificationType, typeof Bell> = {
|
||||
grade: GraduationCap,
|
||||
}
|
||||
|
||||
const PRIORITY_COLOR: Record<NotificationPriority, string> = {
|
||||
low: "bg-muted text-muted-foreground",
|
||||
normal: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
|
||||
high: "bg-orange-500/10 text-orange-700 dark:text-orange-400",
|
||||
urgent: "bg-red-500/10 text-red-700 dark:text-red-400",
|
||||
}
|
||||
|
||||
export function NotificationList({ notifications }: { notifications: Notification[] }) {
|
||||
const t = useTranslations("notifications")
|
||||
const router = useRouter()
|
||||
const [isWorking, setIsWorking] = useState(false)
|
||||
const [filterType, setFilterType] = useState<NotificationType | "all">("all")
|
||||
const hasUnread = notifications.some((n) => !n.isRead)
|
||||
|
||||
const filteredNotifications = filterType === "all"
|
||||
? notifications
|
||||
: notifications.filter((n) => n.type === filterType)
|
||||
|
||||
const handleMarkAllRead = async () => {
|
||||
setIsWorking(true)
|
||||
try {
|
||||
@@ -57,6 +69,17 @@ export function NotificationList({ notifications }: { notifications: Notificatio
|
||||
}
|
||||
}
|
||||
|
||||
const handleArchive = async (id: string) => {
|
||||
try {
|
||||
const res = await archiveNotificationAction(id)
|
||||
if (res.success) {
|
||||
router.refresh()
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("messages.archiveFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
@@ -72,16 +95,36 @@ export function NotificationList({ notifications }: { notifications: Notificatio
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{notifications.length === 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={filterType === "all" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setFilterType("all")}
|
||||
>
|
||||
{t("filter.all")}
|
||||
</Button>
|
||||
{(Object.keys(TYPE_ICON) as NotificationType[]).map((type) => (
|
||||
<Button
|
||||
key={type}
|
||||
variant={filterType === type ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setFilterType(type)}
|
||||
>
|
||||
{t(`type.${type}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{filteredNotifications.length === 0 ? (
|
||||
<EmptyState
|
||||
title={t("empty.noNotifications")}
|
||||
description={t("empty.noNotificationsDesc")}
|
||||
title={filterType === "all" ? t("empty.noNotifications") : t("empty.noFilterResults")}
|
||||
description={filterType === "all" ? t("empty.noNotificationsDesc") : t("empty.noFilterResultsDesc")}
|
||||
icon={Bell}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{notifications.map((n) => {
|
||||
{filteredNotifications.map((n) => {
|
||||
const Icon = TYPE_ICON[n.type] ?? Bell
|
||||
return (
|
||||
<Card
|
||||
@@ -98,6 +141,11 @@ export function NotificationList({ notifications }: { notifications: Notificatio
|
||||
{n.title}
|
||||
</span>
|
||||
{!n.isRead ? <Badge variant="default" className="text-xs">{t("status.new")}</Badge> : null}
|
||||
{n.priority !== "normal" ? (
|
||||
<Badge variant="outline" className={cn("text-xs", PRIORITY_COLOR[n.priority])}>
|
||||
{t(`priority.${n.priority}`)}
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
{n.content ? (
|
||||
<p className="text-muted-foreground line-clamp-2 text-sm whitespace-pre-wrap">
|
||||
@@ -119,6 +167,14 @@ export function NotificationList({ notifications }: { notifications: Notificatio
|
||||
{t("actions.markRead")}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleArchive(n.id)}
|
||||
className="text-muted-foreground hover:text-foreground hover:underline"
|
||||
aria-label={t("actions.archive")}
|
||||
>
|
||||
{t("actions.archive")}
|
||||
</button>
|
||||
{n.link ? (
|
||||
<Link href={n.link} className="ml-auto text-primary hover:underline">
|
||||
{t("actions.view")}
|
||||
|
||||
@@ -7,7 +7,7 @@ import "server-only"
|
||||
* - createNotification: 创建站内通知记录(message_notifications 表)
|
||||
* - getNotifications / markNotificationAsRead / markAllNotificationsAsRead / getUnreadNotificationCount: 站内通知 CRUD
|
||||
* - getUserContactInfo: 获取用户联系方式(手机/邮箱,用于渠道发送)
|
||||
* - logNotificationSend: 记录发送日志(当前项目无 notification_logs 表,使用 console 输出)
|
||||
* - logNotificationSend: 记录发送日志到 notification_logs 表(DB 写入失败时降级为 console)
|
||||
*
|
||||
* 表所有权:
|
||||
* - message_notifications(由 notifications 模块统一管理,P0-4 / P1-5 修复后从 messaging 迁移)
|
||||
@@ -22,13 +22,14 @@ import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, count, desc, eq } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { messageNotifications, users } from "@/shared/db/schema"
|
||||
import { messageNotifications, notificationLogs, users } from "@/shared/db/schema"
|
||||
import type { ChannelRecipient } from "./channels/types"
|
||||
import type {
|
||||
ChannelSendResult,
|
||||
CreateNotificationInput,
|
||||
GetNotificationsParams,
|
||||
Notification,
|
||||
NotificationPriority,
|
||||
NotificationType,
|
||||
PaginatedResult,
|
||||
} from "./types"
|
||||
@@ -41,6 +42,12 @@ const isNotificationType = (v: unknown): v is NotificationType =>
|
||||
const toNotificationType = (v: string): NotificationType =>
|
||||
isNotificationType(v) ? v : "message"
|
||||
|
||||
const isNotificationPriority = (v: unknown): v is NotificationPriority =>
|
||||
v === "low" || v === "normal" || v === "high" || v === "urgent"
|
||||
|
||||
const toNotificationPriority = (v: string): NotificationPriority =>
|
||||
isNotificationPriority(v) ? v : "normal"
|
||||
|
||||
interface NotificationRow {
|
||||
id: string
|
||||
userId: string
|
||||
@@ -49,6 +56,8 @@ interface NotificationRow {
|
||||
content: string | null
|
||||
link: string | null
|
||||
isRead: boolean
|
||||
priority: string
|
||||
isArchived: boolean
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
@@ -60,6 +69,8 @@ const mapNotification = (r: NotificationRow): Notification => ({
|
||||
content: r.content,
|
||||
link: r.link,
|
||||
isRead: r.isRead,
|
||||
priority: toNotificationPriority(r.priority),
|
||||
isArchived: r.isArchived,
|
||||
createdAt: toIsoRequired(r.createdAt),
|
||||
})
|
||||
|
||||
@@ -74,6 +85,11 @@ export const getNotifications = cache(
|
||||
const offset = (page - 1) * pageSize
|
||||
const conds = [eq(messageNotifications.userId, userId)]
|
||||
if (params?.unreadOnly) conds.push(eq(messageNotifications.isRead, false))
|
||||
// V2-P2-13b: 默认仅返回未归档通知
|
||||
const unarchivedOnly = params?.unarchivedOnly ?? true
|
||||
if (unarchivedOnly) conds.push(eq(messageNotifications.isArchived, false))
|
||||
// V2-P2-13b: 按优先级筛选
|
||||
if (params?.priority) conds.push(eq(messageNotifications.priority, params.priority))
|
||||
const where = and(...conds)
|
||||
|
||||
const [rows, [totalRow]] = await Promise.all([
|
||||
@@ -94,6 +110,7 @@ export async function createNotification(data: CreateNotificationInput): Promise
|
||||
title: data.title,
|
||||
content: data.content ?? null,
|
||||
link: data.link ?? null,
|
||||
priority: data.priority ?? "normal",
|
||||
})
|
||||
return id
|
||||
}
|
||||
@@ -112,6 +129,20 @@ export async function markAllNotificationsAsRead(userId: string): Promise<void>
|
||||
.where(and(eq(messageNotifications.userId, userId), eq(messageNotifications.isRead, false)))
|
||||
}
|
||||
|
||||
export async function archiveNotification(id: string, userId: string): Promise<void> {
|
||||
await db
|
||||
.update(messageNotifications)
|
||||
.set({ isArchived: true })
|
||||
.where(and(eq(messageNotifications.id, id), eq(messageNotifications.userId, userId)))
|
||||
}
|
||||
|
||||
export async function unarchiveNotification(id: string, userId: string): Promise<void> {
|
||||
await db
|
||||
.update(messageNotifications)
|
||||
.set({ isArchived: false })
|
||||
.where(and(eq(messageNotifications.id, id), eq(messageNotifications.userId, userId)))
|
||||
}
|
||||
|
||||
export const getUnreadNotificationCount = cache(async (userId: string): Promise<number> => {
|
||||
const [row] = await db
|
||||
.select({ value: count() })
|
||||
@@ -159,24 +190,54 @@ export const getUserContactInfo = cache(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 记录通知发送日志。
|
||||
* 记录通知发送日志到数据库(notification_logs 表)。
|
||||
*
|
||||
* 当前项目无 notification_logs 表,使用 console.info 输出。
|
||||
* 未来新增 notification_logs 表后,可在此处写入 DB。
|
||||
* 持久化日志用于:
|
||||
* - 通知发送失败告警与排查
|
||||
* - 通知送达率统计
|
||||
* - 渠道健康度监控
|
||||
*
|
||||
* DB 写入失败时降级为 console.info,不阻塞通知发送流程。
|
||||
*/
|
||||
export function logNotificationSend(result: ChannelSendResult): void {
|
||||
const status = result.success ? "OK" : "FAIL"
|
||||
export async function logNotificationSend(
|
||||
result: ChannelSendResult,
|
||||
payload?: { userId: string; title: string }
|
||||
): Promise<void> {
|
||||
const status = result.success ? "success" : "failure"
|
||||
const errorPart = result.error ? ` error="${result.error}"` : ""
|
||||
|
||||
// 始终输出 console 日志(便于开发调试)
|
||||
console.info(
|
||||
`[NotificationLog] ${status} channel=${result.channel} messageId=${result.messageId ?? "-"}${errorPart}`
|
||||
`[NotificationLog] ${result.success ? "OK" : "FAIL"} channel=${result.channel} messageId=${result.messageId ?? "-"}${errorPart}`
|
||||
)
|
||||
|
||||
// 持久化到 DB(需要 payload 提供 userId 和 title)
|
||||
if (payload) {
|
||||
try {
|
||||
const logId = createId()
|
||||
await db.insert(notificationLogs).values({
|
||||
id: logId,
|
||||
userId: payload.userId,
|
||||
title: payload.title,
|
||||
channel: result.channel,
|
||||
status,
|
||||
messageId: result.messageId ?? null,
|
||||
error: result.error ?? null,
|
||||
sentAt: result.sentAt,
|
||||
})
|
||||
} catch (dbError) {
|
||||
// DB 写入失败不阻塞通知流程,仅记录错误
|
||||
console.error("[NotificationLog] Failed to persist log:", dbError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量记录发送日志。
|
||||
*/
|
||||
export function logNotificationSendBatch(results: ChannelSendResult[]): void {
|
||||
for (const result of results) {
|
||||
logNotificationSend(result)
|
||||
}
|
||||
export async function logNotificationSendBatch(
|
||||
results: ChannelSendResult[],
|
||||
payload?: { userId: string; title: string }
|
||||
): Promise<void> {
|
||||
await Promise.all(results.map((result) => logNotificationSend(result, payload)))
|
||||
}
|
||||
|
||||
251
src/modules/notifications/dispatcher.test.ts
Normal file
251
src/modules/notifications/dispatcher.test.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getNotificationPreferences: vi.fn(),
|
||||
getUserContactInfo: vi.fn(),
|
||||
logNotificationSendBatch: vi.fn(),
|
||||
createNotification: vi.fn(),
|
||||
inAppSend: vi.fn(),
|
||||
smsSend: vi.fn(),
|
||||
wechatSend: vi.fn(),
|
||||
emailSend: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("./data-access", () => ({
|
||||
getUserContactInfo: mocks.getUserContactInfo,
|
||||
logNotificationSendBatch: mocks.logNotificationSendBatch,
|
||||
createNotification: mocks.createNotification,
|
||||
}))
|
||||
|
||||
vi.mock("./preferences", () => ({
|
||||
getNotificationPreferences: mocks.getNotificationPreferences,
|
||||
}))
|
||||
|
||||
vi.mock("./channels/sms-channel", () => ({
|
||||
createSmsSender: () => ({
|
||||
channel: "sms",
|
||||
send: mocks.smsSend,
|
||||
sendBatch: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("./channels/wechat-channel", () => ({
|
||||
createWechatSender: () => ({
|
||||
channel: "wechat",
|
||||
send: mocks.wechatSend,
|
||||
sendBatch: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("./channels/email-channel", () => ({
|
||||
createEmailSender: () => ({
|
||||
channel: "email",
|
||||
send: mocks.emailSend,
|
||||
sendBatch: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock("./channels/in-app-channel", () => ({
|
||||
createInAppSender: () => ({
|
||||
channel: "in_app",
|
||||
send: mocks.inAppSend,
|
||||
sendBatch: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
import { sendNotification } from "./dispatcher"
|
||||
import type { NotificationPayload } from "./types"
|
||||
|
||||
describe("sendNotification", () => {
|
||||
beforeEach(() => {
|
||||
// mockReset (from vitest config) clears implementations before beforeEach.
|
||||
// Re-establish channel send implementations so the cached sender registry
|
||||
// (module-level singleton in dispatcher.ts) keeps working across tests.
|
||||
mocks.inAppSend.mockImplementation(async (payload: NotificationPayload) => {
|
||||
const id = await mocks.createNotification({
|
||||
userId: payload.userId,
|
||||
type: "message",
|
||||
title: payload.title,
|
||||
content: payload.content,
|
||||
link: payload.actionUrl ?? null,
|
||||
})
|
||||
return {
|
||||
channel: "in_app" as const,
|
||||
success: true,
|
||||
messageId: id,
|
||||
sentAt: new Date(),
|
||||
}
|
||||
})
|
||||
mocks.smsSend.mockResolvedValue({
|
||||
channel: "sms",
|
||||
success: true,
|
||||
sentAt: new Date(),
|
||||
})
|
||||
mocks.wechatSend.mockResolvedValue({
|
||||
channel: "wechat",
|
||||
success: true,
|
||||
sentAt: new Date(),
|
||||
})
|
||||
mocks.emailSend.mockResolvedValue({
|
||||
channel: "email",
|
||||
success: true,
|
||||
sentAt: new Date(),
|
||||
})
|
||||
})
|
||||
|
||||
it("should select in_app channel when pushEnabled is true and no contact info", async () => {
|
||||
mocks.getNotificationPreferences.mockResolvedValue({
|
||||
smsEnabled: false,
|
||||
emailEnabled: false,
|
||||
pushEnabled: true,
|
||||
})
|
||||
mocks.getUserContactInfo.mockResolvedValue({ userId: "user-1" })
|
||||
mocks.createNotification.mockResolvedValue("notif-1")
|
||||
mocks.logNotificationSendBatch.mockResolvedValue(undefined)
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
userId: "user-1",
|
||||
title: "Test notification",
|
||||
content: "Test content",
|
||||
type: "info",
|
||||
}
|
||||
|
||||
const results = await sendNotification(payload)
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].channel).toBe("in_app")
|
||||
expect(results[0].success).toBe(true)
|
||||
expect(mocks.createNotification).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
title: "Test notification",
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("should select sms channel when smsEnabled and phone provided", async () => {
|
||||
mocks.getNotificationPreferences.mockResolvedValue({
|
||||
smsEnabled: true,
|
||||
emailEnabled: false,
|
||||
pushEnabled: true,
|
||||
})
|
||||
mocks.getUserContactInfo.mockResolvedValue({ userId: "user-1", phone: "13800138000" })
|
||||
mocks.createNotification.mockResolvedValue("notif-1")
|
||||
mocks.logNotificationSendBatch.mockResolvedValue(undefined)
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
userId: "user-1",
|
||||
title: "Test",
|
||||
content: "Content",
|
||||
type: "info",
|
||||
}
|
||||
|
||||
const results = await sendNotification(payload)
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
const channels = results.map((r) => r.channel)
|
||||
expect(channels).toContain("in_app")
|
||||
expect(channels).toContain("sms")
|
||||
})
|
||||
|
||||
it("should select email channel when emailEnabled and email provided", async () => {
|
||||
mocks.getNotificationPreferences.mockResolvedValue({
|
||||
smsEnabled: false,
|
||||
emailEnabled: true,
|
||||
pushEnabled: true,
|
||||
})
|
||||
mocks.getUserContactInfo.mockResolvedValue({ userId: "user-1", email: "test@example.com" })
|
||||
mocks.createNotification.mockResolvedValue("notif-1")
|
||||
mocks.logNotificationSendBatch.mockResolvedValue(undefined)
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
userId: "user-1",
|
||||
title: "Test",
|
||||
content: "Content",
|
||||
type: "info",
|
||||
}
|
||||
|
||||
const results = await sendNotification(payload)
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
const channels = results.map((r) => r.channel)
|
||||
expect(channels).toContain("in_app")
|
||||
expect(channels).toContain("email")
|
||||
})
|
||||
|
||||
it("should fallback to in_app when all channels disabled", async () => {
|
||||
mocks.getNotificationPreferences.mockResolvedValue({
|
||||
smsEnabled: false,
|
||||
emailEnabled: false,
|
||||
pushEnabled: false,
|
||||
})
|
||||
mocks.getUserContactInfo.mockResolvedValue({ userId: "user-1" })
|
||||
mocks.createNotification.mockResolvedValue("notif-1")
|
||||
mocks.logNotificationSendBatch.mockResolvedValue(undefined)
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
userId: "user-1",
|
||||
title: "Test",
|
||||
content: "Content",
|
||||
type: "info",
|
||||
}
|
||||
|
||||
const results = await sendNotification(payload)
|
||||
|
||||
// pushEnabled false 时,兜底逻辑应至少发 in_app
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0].channel).toBe("in_app")
|
||||
})
|
||||
|
||||
it("should select wechat channel when pushEnabled and wechatOpenId provided", async () => {
|
||||
mocks.getNotificationPreferences.mockResolvedValue({
|
||||
smsEnabled: false,
|
||||
emailEnabled: false,
|
||||
pushEnabled: true,
|
||||
})
|
||||
mocks.getUserContactInfo.mockResolvedValue({ userId: "user-1", wechatOpenId: "wx-open-id" })
|
||||
mocks.createNotification.mockResolvedValue("notif-1")
|
||||
mocks.logNotificationSendBatch.mockResolvedValue(undefined)
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
userId: "user-1",
|
||||
title: "Test",
|
||||
content: "Content",
|
||||
type: "info",
|
||||
}
|
||||
|
||||
const results = await sendNotification(payload)
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
const channels = results.map((r) => r.channel)
|
||||
expect(channels).toContain("in_app")
|
||||
expect(channels).toContain("wechat")
|
||||
})
|
||||
|
||||
it("should call logNotificationSendBatch with results", async () => {
|
||||
mocks.getNotificationPreferences.mockResolvedValue({
|
||||
smsEnabled: false,
|
||||
emailEnabled: false,
|
||||
pushEnabled: true,
|
||||
})
|
||||
mocks.getUserContactInfo.mockResolvedValue({ userId: "user-1" })
|
||||
mocks.createNotification.mockResolvedValue("notif-1")
|
||||
mocks.logNotificationSendBatch.mockResolvedValue(undefined)
|
||||
|
||||
const payload: NotificationPayload = {
|
||||
userId: "user-1",
|
||||
title: "Test",
|
||||
content: "Content",
|
||||
type: "info",
|
||||
}
|
||||
|
||||
await sendNotification(payload)
|
||||
|
||||
expect(mocks.logNotificationSendBatch).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ channel: "in_app", success: true }),
|
||||
]),
|
||||
{ userId: "user-1", title: "Test" }
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -124,8 +124,8 @@ export async function sendNotification(
|
||||
})
|
||||
)
|
||||
|
||||
// 记录发送日志
|
||||
logNotificationSendBatch(results)
|
||||
// 记录发送日志(传入 payload 用于持久化)
|
||||
await logNotificationSendBatch(results, { userId: payload.userId, title: payload.title })
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
121
src/modules/notifications/hooks/use-desktop-notifications.ts
Normal file
121
src/modules/notifications/hooks/use-desktop-notifications.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from "react"
|
||||
|
||||
import type { Notification } from "../types"
|
||||
|
||||
interface UseDesktopNotificationsOptions {
|
||||
/** 是否启用桌面推送(默认 false) */
|
||||
enabled?: boolean
|
||||
/** 通知点击时的跳转回调 */
|
||||
onClick?: (notification: Notification) => void
|
||||
}
|
||||
|
||||
interface UseDesktopNotificationsResult {
|
||||
/** 当前权限状态 */
|
||||
permission: NotificationPermission | "unsupported"
|
||||
/** 是否已授权 */
|
||||
isGranted: boolean
|
||||
/** 请求权限 */
|
||||
requestPermission: () => Promise<void>
|
||||
/** 发送桌面通知 */
|
||||
showNotification: (notification: Notification) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 桌面通知 Hook
|
||||
*
|
||||
* 使用浏览器 Notification API 发送桌面推送通知,
|
||||
* 当用户不在页面标签页时仍可收到通知提醒。
|
||||
*
|
||||
* 使用场景:
|
||||
* - SSE 收到新通知时触发桌面推送
|
||||
* - 用户切换到其他标签页时仍能收到提醒
|
||||
*
|
||||
* 权限流程:
|
||||
* 1. 默认权限为 "default"(未询问)
|
||||
* 2. 调用 requestPermission() 弹出浏览器权限询问框
|
||||
* 3. 用户授权后可调用 showNotification 发送桌面通知
|
||||
*
|
||||
* 浏览器兼容性:
|
||||
* - Chrome/Edge/Firefox 桌面版完整支持
|
||||
* - Safari 桌面版 13+ 支持
|
||||
* - 移动端浏览器不支持(自动降级为无桌面推送)
|
||||
*/
|
||||
export function useDesktopNotifications(
|
||||
options?: UseDesktopNotificationsOptions
|
||||
): UseDesktopNotificationsResult {
|
||||
const enabled = options?.enabled ?? false
|
||||
const [permission, setPermission] = useState<NotificationPermission | "unsupported">(
|
||||
typeof window !== "undefined" && "Notification" in window
|
||||
? Notification.permission
|
||||
: "unsupported"
|
||||
)
|
||||
|
||||
const onClickRef = useRef(options?.onClick)
|
||||
useEffect(() => {
|
||||
onClickRef.current = options?.onClick
|
||||
}, [options?.onClick])
|
||||
|
||||
const isGranted = permission === "granted"
|
||||
|
||||
const requestPermission = useCallback(async (): Promise<void> => {
|
||||
if (typeof window === "undefined" || !("Notification" in window)) return
|
||||
|
||||
try {
|
||||
const result = await Notification.requestPermission()
|
||||
setPermission(result)
|
||||
} catch {
|
||||
// 某些浏览器可能抛出异常,静默处理
|
||||
}
|
||||
}, [])
|
||||
|
||||
const showNotification = useCallback(
|
||||
(notification: Notification): void => {
|
||||
if (!enabled || !isGranted) return
|
||||
if (typeof window === "undefined" || !("Notification" in window)) return
|
||||
|
||||
try {
|
||||
const desktopNotif = new Notification(notification.title, {
|
||||
body: notification.content ?? "",
|
||||
tag: notification.id, // 避免重复通知
|
||||
...(notification.link ? { data: notification.link } : {}),
|
||||
})
|
||||
|
||||
desktopNotif.onclick = () => {
|
||||
window.focus()
|
||||
onClickRef.current?.(notification)
|
||||
desktopNotif.close()
|
||||
}
|
||||
} catch {
|
||||
// 通知创建失败静默处理
|
||||
}
|
||||
},
|
||||
[enabled, isGranted]
|
||||
)
|
||||
|
||||
// 当 enabled 变为 true 且权限为 default 时,自动请求权限
|
||||
// 使用异步队列避免在 effect 中同步调用 setState
|
||||
useEffect(() => {
|
||||
if (!enabled || permission !== "default") return
|
||||
if (typeof window === "undefined" || !("Notification" in window)) return
|
||||
|
||||
let cancelled = false
|
||||
Notification.requestPermission().then((result) => {
|
||||
if (!cancelled) setPermission(result)
|
||||
}).catch(() => {
|
||||
// 某些浏览器可能抛出异常,静默处理
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [enabled, permission])
|
||||
|
||||
return {
|
||||
permission,
|
||||
isGranted,
|
||||
requestPermission,
|
||||
showNotification,
|
||||
}
|
||||
}
|
||||
196
src/modules/notifications/hooks/use-notification-stream.ts
Normal file
196
src/modules/notifications/hooks/use-notification-stream.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from "react"
|
||||
|
||||
import type { Notification } from "../types"
|
||||
import {
|
||||
getNotificationsAction,
|
||||
getUnreadNotificationCountAction,
|
||||
} from "../actions"
|
||||
|
||||
interface NotificationStreamData {
|
||||
type: "update" | "error"
|
||||
unreadCount?: number
|
||||
notifications?: Notification[]
|
||||
message?: string
|
||||
}
|
||||
|
||||
interface UseNotificationStreamOptions {
|
||||
/** 是否启用 SSE(默认 true) */
|
||||
enabled?: boolean
|
||||
/** SSE 连接失败后的轮询降级间隔(毫秒),默认 30000 */
|
||||
fallbackPollInterval?: number
|
||||
/** 初始未读数 */
|
||||
initialUnreadCount?: number
|
||||
/** 初始通知列表 */
|
||||
initialNotifications?: Notification[]
|
||||
}
|
||||
|
||||
interface UseNotificationStreamResult {
|
||||
/** 未读通知数 */
|
||||
unreadCount: number
|
||||
/** 最新通知列表 */
|
||||
notifications: Notification[]
|
||||
/** SSE 是否已连接 */
|
||||
isConnected: boolean
|
||||
/** 是否正在使用轮询降级模式 */
|
||||
isUsingFallback: boolean
|
||||
/** 手动刷新(轮询降级模式下使用) */
|
||||
refresh: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知实时推送 Hook
|
||||
*
|
||||
* 优先使用 SSE(Server-Sent Events)接收实时通知更新,
|
||||
* 当 SSE 不可用时自动降级为轮询模式(调用 Server Actions)。
|
||||
*
|
||||
* SSE 优势:
|
||||
* - 实时推送(延迟 < 1 秒)
|
||||
* - 低服务器负载(单连接 + 定时推送)
|
||||
* - 自动重连(浏览器原生支持)
|
||||
*
|
||||
* 降级策略:
|
||||
* - SSE 连接失败 / 浏览器不支持 EventSource → 切换为轮询模式
|
||||
* - 轮询调用 getNotificationsAction + getUnreadNotificationCountAction
|
||||
* - 轮询间隔为 fallbackPollInterval(默认 30 秒)
|
||||
*/
|
||||
export function useNotificationStream(options?: UseNotificationStreamOptions): UseNotificationStreamResult {
|
||||
const enabled = options?.enabled ?? true
|
||||
const fallbackPollInterval = options?.fallbackPollInterval ?? 30_000
|
||||
|
||||
const [unreadCount, setUnreadCount] = useState(options?.initialUnreadCount ?? 0)
|
||||
const [notifications, setNotifications] = useState<Notification[]>(options?.initialNotifications ?? [])
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
const [isUsingFallback, setIsUsingFallback] = useState(false)
|
||||
|
||||
const eventSourceRef = useRef<EventSource | null>(null)
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const pollFnRef = useRef<(() => Promise<void>) | null>(null)
|
||||
|
||||
/** 通过 Server Actions 拉取一次通知数据(轮询降级模式使用) */
|
||||
const fetchOnce = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
const [countRes, listRes] = await Promise.all([
|
||||
getUnreadNotificationCountAction(),
|
||||
getNotificationsAction({ pageSize: 10 }),
|
||||
])
|
||||
if (countRes.success && typeof countRes.data === "number") {
|
||||
setUnreadCount(countRes.data)
|
||||
}
|
||||
if (listRes.success && listRes.data) {
|
||||
setNotifications(listRes.data.items)
|
||||
}
|
||||
} catch {
|
||||
// 轮询失败静默处理
|
||||
}
|
||||
}, [])
|
||||
|
||||
/** 手动刷新(轮询降级模式下立即拉取一次) */
|
||||
const refresh = useCallback(() => {
|
||||
if (pollFnRef.current) {
|
||||
void pollFnRef.current()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return
|
||||
|
||||
/** 启动轮询降级(清理 SSE + 设置定时器) */
|
||||
const startPolling = (): void => {
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
eventSourceRef.current = null
|
||||
}
|
||||
pollFnRef.current = fetchOnce
|
||||
void fetchOnce()
|
||||
pollTimerRef.current = setInterval(() => {
|
||||
void fetchOnce()
|
||||
}, fallbackPollInterval)
|
||||
}
|
||||
|
||||
/** 清理轮询定时器 */
|
||||
const clearPolling = (): void => {
|
||||
if (pollTimerRef.current) {
|
||||
clearInterval(pollTimerRef.current)
|
||||
pollTimerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
// 浏览器不支持 EventSource — 直接使用轮询降级
|
||||
if (typeof window === "undefined" || !("EventSource" in window)) {
|
||||
startPolling()
|
||||
// 延迟状态更新,避免 effect 内同步 setState
|
||||
queueMicrotask(() => {
|
||||
setIsUsingFallback(true)
|
||||
setIsConnected(false)
|
||||
})
|
||||
return clearPolling
|
||||
}
|
||||
|
||||
// 创建 SSE 连接
|
||||
let eventSource: EventSource
|
||||
try {
|
||||
eventSource = new EventSource("/api/notifications/stream")
|
||||
eventSourceRef.current = eventSource
|
||||
} catch {
|
||||
// EventSource 构造失败 — 降级为轮询
|
||||
startPolling()
|
||||
queueMicrotask(() => {
|
||||
setIsUsingFallback(true)
|
||||
setIsConnected(false)
|
||||
})
|
||||
return clearPolling
|
||||
}
|
||||
|
||||
eventSource.onopen = () => {
|
||||
setIsConnected(true)
|
||||
setIsUsingFallback(false)
|
||||
}
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
if (event.data === "[DONE]") {
|
||||
eventSource.close()
|
||||
setIsConnected(false)
|
||||
// SSE 关闭后降级为轮询
|
||||
startPolling()
|
||||
setIsUsingFallback(true)
|
||||
return
|
||||
}
|
||||
const data = JSON.parse(event.data) as NotificationStreamData
|
||||
if (data.type === "update") {
|
||||
if (typeof data.unreadCount === "number") setUnreadCount(data.unreadCount)
|
||||
if (data.notifications) setNotifications(data.notifications)
|
||||
} else if (data.type === "error") {
|
||||
eventSource.close()
|
||||
setIsConnected(false)
|
||||
startPolling()
|
||||
setIsUsingFallback(true)
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = () => {
|
||||
eventSource.close()
|
||||
setIsConnected(false)
|
||||
startPolling()
|
||||
setIsUsingFallback(true)
|
||||
}
|
||||
|
||||
return () => {
|
||||
eventSource.close()
|
||||
clearPolling()
|
||||
}
|
||||
}, [enabled, fallbackPollInterval, fetchOnce])
|
||||
|
||||
return {
|
||||
unreadCount,
|
||||
notifications,
|
||||
isConnected,
|
||||
isUsingFallback,
|
||||
refresh,
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,8 @@ export {
|
||||
markNotificationAsRead,
|
||||
markAllNotificationsAsRead,
|
||||
getUnreadNotificationCount,
|
||||
archiveNotification,
|
||||
unarchiveNotification,
|
||||
getUserContactInfo,
|
||||
logNotificationSend,
|
||||
logNotificationSendBatch,
|
||||
@@ -45,6 +47,7 @@ export {
|
||||
getUnreadNotificationCountAction,
|
||||
markNotificationAsReadAction,
|
||||
markAllNotificationsAsReadAction,
|
||||
archiveNotificationAction,
|
||||
} from "./actions"
|
||||
export { NotificationList, NotificationDropdown } from "./components"
|
||||
export type {
|
||||
@@ -56,7 +59,9 @@ export type {
|
||||
WechatChannelConfig,
|
||||
EmailChannelConfig,
|
||||
NotificationType,
|
||||
NotificationPriority,
|
||||
Notification,
|
||||
NotificationLog,
|
||||
PaginatedResult,
|
||||
GetNotificationsParams,
|
||||
CreateNotificationInput,
|
||||
@@ -70,3 +75,7 @@ export { createSmsSender } from "./channels/sms-channel"
|
||||
export { createWechatSender, isWechatEnabled } from "./channels/wechat-channel"
|
||||
export { createEmailSender, isEmailEnabled } from "./channels/email-channel"
|
||||
export { createInAppSender } from "./channels/in-app-channel"
|
||||
|
||||
// Hooks
|
||||
export { useNotificationStream } from "./hooks/use-notification-stream"
|
||||
export { useDesktopNotifications } from "./hooks/use-desktop-notifications"
|
||||
|
||||
@@ -20,6 +20,9 @@ export type NotificationChannel = "in_app" | "email" | "sms" | "wechat"
|
||||
/** 站内通知类型(message_notifications.type 列) */
|
||||
export type NotificationType = "message" | "announcement" | "homework" | "grade"
|
||||
|
||||
/** 通知优先级(message_notifications.priority 列) */
|
||||
export type NotificationPriority = "low" | "normal" | "high" | "urgent"
|
||||
|
||||
/** 站内通知记录(对应 message_notifications 表的展示形态) */
|
||||
export interface Notification {
|
||||
id: string
|
||||
@@ -29,6 +32,8 @@ export interface Notification {
|
||||
content: string | null
|
||||
link: string | null
|
||||
isRead: boolean
|
||||
priority: NotificationPriority
|
||||
isArchived: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -72,6 +77,10 @@ export interface GetNotificationsParams {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
unreadOnly?: boolean
|
||||
/** V2-P2-13b: 仅返回未归档通知(默认 true) */
|
||||
unarchivedOnly?: boolean
|
||||
/** V2-P2-13b: 按优先级筛选 */
|
||||
priority?: NotificationPriority
|
||||
}
|
||||
|
||||
/** 创建站内通知的输入 */
|
||||
@@ -81,6 +90,7 @@ export interface CreateNotificationInput {
|
||||
title: string
|
||||
content?: string | null
|
||||
link?: string | null
|
||||
priority?: NotificationPriority
|
||||
}
|
||||
|
||||
/** 通知偏好设置(对应 notification_preferences 表的展示形态) */
|
||||
@@ -144,6 +154,18 @@ export interface EmailChannelConfig {
|
||||
pass?: string
|
||||
}
|
||||
|
||||
/** 通知发送日志记录(对应 notification_logs 表的展示形态) */
|
||||
export interface NotificationLog {
|
||||
id: string
|
||||
userId: string
|
||||
title: string
|
||||
channel: NotificationChannel
|
||||
status: "success" | "failure"
|
||||
messageId: string | null
|
||||
error: string | null
|
||||
sentAt: string
|
||||
}
|
||||
|
||||
/** 通知渠道总配置 */
|
||||
export interface NotificationChannelConfig {
|
||||
enabled: boolean
|
||||
|
||||
@@ -733,6 +733,8 @@ export const announcements = mysqlTable("announcements", {
|
||||
targetClassId: varchar("target_class_id", { length: 128 }),
|
||||
authorId: varchar("author_id", { length: 128 }).notNull().references(() => users.id),
|
||||
publishedAt: datetime("published_at", { mode: "date" }),
|
||||
// V2-P2-13d: 公告置顶(置顶公告在列表中优先显示)
|
||||
isPinned: boolean("is_pinned").default(false).notNull(),
|
||||
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().onUpdateNow().notNull(),
|
||||
}, (table) => ({
|
||||
@@ -741,6 +743,22 @@ export const announcements = mysqlTable("announcements", {
|
||||
typeIdx: index("announcements_type_idx").on(table.type),
|
||||
targetGradeIdx: index("announcements_target_grade_idx").on(table.targetGradeId),
|
||||
targetClassIdx: index("announcements_target_class_idx").on(table.targetClassId),
|
||||
// V2-P2-13d: 置顶索引
|
||||
statusPinnedIdx: index("announcements_status_pinned_idx").on(table.status, table.isPinned),
|
||||
}));
|
||||
|
||||
// --- 8b. Announcement Reads (公告已读回执) ---
|
||||
|
||||
export const announcementReads = mysqlTable("announcement_reads", {
|
||||
id: id("id").primaryKey(),
|
||||
announcementId: varchar("announcement_id", { length: 128 }).notNull().references(() => announcements.id, { onDelete: "cascade" }),
|
||||
userId: varchar("user_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
readAt: timestamp("read_at", { mode: "date" }).defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
announcementIdx: index("announcement_reads_announcement_idx").on(table.announcementId),
|
||||
userIdx: index("announcement_reads_user_idx").on(table.userId),
|
||||
// 唯一约束:一个用户对一条公告只能有一条已读记录
|
||||
uniqueAnnouncementUser: uniqueIndex("announcement_reads_unique_idx").on(table.announcementId, table.userId),
|
||||
}));
|
||||
|
||||
// --- 9. Audit & Login Logs ---
|
||||
@@ -975,6 +993,8 @@ export const messages = mysqlTable("messages", {
|
||||
// 软删除:发送方/接收方各自独立删除,互不影响
|
||||
senderDeletedAt: timestamp("sender_deleted_at", { mode: "date" }),
|
||||
receiverDeletedAt: timestamp("receiver_deleted_at", { mode: "date" }),
|
||||
// V2-P2-13c: 消息星标(接收方可标记重要消息)
|
||||
isStarred: boolean("is_starred").default(false).notNull(),
|
||||
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
senderIdx: index("messages_sender_idx").on(table.senderId),
|
||||
@@ -982,6 +1002,24 @@ export const messages = mysqlTable("messages", {
|
||||
isReadIdx: index("messages_is_read_idx").on(table.isRead),
|
||||
parentIdx: index("messages_parent_idx").on(table.parentMessageId),
|
||||
receiverReadIdx: index("messages_receiver_read_idx").on(table.receiverId, table.isRead),
|
||||
// V2-P2-13c: 星标索引
|
||||
receiverStarredIdx: index("messages_receiver_starred_idx").on(table.receiverId, table.isStarred),
|
||||
}));
|
||||
|
||||
// --- 14b. Message Drafts (消息草稿) ---
|
||||
|
||||
export const messageDrafts = mysqlTable("message_drafts", {
|
||||
id: id("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
receiverId: varchar("receiver_id", { length: 128 }).references(() => users.id, { onDelete: "cascade" }),
|
||||
subject: varchar("subject", { length: 255 }),
|
||||
content: text("content"),
|
||||
parentMessageId: varchar("parent_message_id", { length: 128 }),
|
||||
updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().onUpdateNow().notNull(),
|
||||
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
userIdx: index("message_drafts_user_idx").on(table.userId),
|
||||
userUpdatedIdx: index("message_drafts_user_updated_idx").on(table.userId, table.updatedAt),
|
||||
}));
|
||||
|
||||
// --- 15. Message Notifications (消息通知) ---
|
||||
@@ -994,12 +1032,44 @@ export const messageNotifications = mysqlTable("message_notifications", {
|
||||
content: text("content"),
|
||||
link: varchar("link", { length: 512 }),
|
||||
isRead: boolean("is_read").default(false).notNull(),
|
||||
// V2-P2-13b: 通知优先级(low/normal/high/urgent),默认 normal
|
||||
priority: varchar("priority", { length: 16 }).default("normal").notNull(),
|
||||
// V2-P2-13b: 通知归档标记,归档后不在默认列表显示
|
||||
isArchived: boolean("is_archived").default(false).notNull(),
|
||||
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
userIdx: index("message_notifications_user_idx").on(table.userId),
|
||||
isReadIdx: index("message_notifications_is_read_idx").on(table.isRead),
|
||||
userReadIdx: index("message_notifications_user_read_idx").on(table.userId, table.isRead),
|
||||
createdAtIdx: index("message_notifications_created_at_idx").on(table.createdAt),
|
||||
// V2-P2-13b: 新增优先级和归档索引
|
||||
priorityIdx: index("message_notifications_priority_idx").on(table.priority),
|
||||
userArchivedIdx: index("message_notifications_user_archived_idx").on(table.userId, table.isArchived),
|
||||
}));
|
||||
|
||||
// --- 17. Notification Logs (通知发送日志) ---
|
||||
|
||||
export const notificationLogs = mysqlTable("notification_logs", {
|
||||
id: id("id").primaryKey(),
|
||||
// 关联用户(通知接收人)
|
||||
userId: varchar("user_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
// 通知标题(冗余存储,便于查询)
|
||||
title: varchar("title", { length: 255 }).notNull(),
|
||||
// 发送渠道: in_app | email | sms | wechat
|
||||
channel: varchar("channel", { length: 32 }).notNull(),
|
||||
// 发送状态: success | failure
|
||||
status: varchar("status", { length: 16 }).notNull(),
|
||||
// 渠道返回的消息 ID(用于追踪)
|
||||
messageId: varchar("message_id", { length: 255 }),
|
||||
// 失败时的错误信息
|
||||
error: text("error"),
|
||||
// 发送时间
|
||||
sentAt: timestamp("sent_at", { mode: "date" }).defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
userIdx: index("notification_logs_user_idx").on(table.userId),
|
||||
channelIdx: index("notification_logs_channel_idx").on(table.channel),
|
||||
statusIdx: index("notification_logs_status_idx").on(table.status),
|
||||
sentAtIdx: index("notification_logs_sent_at_idx").on(table.sentAt),
|
||||
}));
|
||||
|
||||
// --- 16. Notification Preferences (通知偏好) ---
|
||||
@@ -1270,6 +1340,8 @@ export const learningDiagnosticReports = mysqlTable("learning_diagnostic_reports
|
||||
id: id("id").primaryKey(),
|
||||
studentId: varchar("student_id", { length: 128 }).references(() => users.id, { onDelete: "cascade" }),
|
||||
generatedBy: varchar("generated_by", { length: 128 }).references(() => users.id, { onDelete: "set null" }),
|
||||
// v4-P1-4: 班级报告关联的 classId,用于发布时批量通知全班学生
|
||||
classId: varchar("class_id", { length: 128 }).references(() => classes.id, { onDelete: "set null" }),
|
||||
reportType: diagnosticReportTypeEnum.default("individual").notNull(),
|
||||
period: varchar("period", { length: 50 }),
|
||||
summary: text("summary"),
|
||||
@@ -1285,6 +1357,7 @@ export const learningDiagnosticReports = mysqlTable("learning_diagnostic_reports
|
||||
generatedByIdx: index("diagnostic_generated_by_idx").on(table.generatedBy),
|
||||
statusIdx: index("diagnostic_status_idx").on(table.status),
|
||||
reportTypeIdx: index("diagnostic_report_type_idx").on(table.reportType),
|
||||
classIdx: index("diagnostic_class_idx").on(table.classId),
|
||||
}));
|
||||
|
||||
// --- 24. Lesson Preparation (备课) ---
|
||||
@@ -1440,3 +1513,30 @@ export const errorBookReviews = mysqlTable("error_book_reviews", {
|
||||
studentIdx: index("eb_review_student_idx").on(table.studentId),
|
||||
studentReviewedIdx: index("eb_review_student_reviewed_idx").on(table.studentId, table.reviewedAt),
|
||||
}));
|
||||
|
||||
// --- 27. Grade Drafts (成绩录入草稿 - 服务端自动保存) ---
|
||||
|
||||
/**
|
||||
* 成绩录入草稿 - 用于跨设备恢复未保存的成绩。
|
||||
* v3-P2-10: 替代纯 localStorage 方案,支持换设备恢复。
|
||||
* 唯一键:userId + classId + subjectId + type,确保每位教师每组合只有一个草稿。
|
||||
*/
|
||||
export const gradeDrafts = mysqlTable("grade_drafts", {
|
||||
id: id("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
classId: varchar("class_id", { length: 128 }).notNull().references(() => classes.id, { onDelete: "cascade" }),
|
||||
subjectId: varchar("subject_id", { length: 128 }).notNull().references(() => subjects.id, { onDelete: "cascade" }),
|
||||
type: varchar("type", { length: 20 }).notNull(),
|
||||
/** 草稿内容:{ scores: Record<string, string>, timestamp: number } */
|
||||
content: json("content").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
|
||||
}, (table) => ({
|
||||
userDraftIdx: uniqueIndex("gd_user_class_subject_type_idx").on(
|
||||
table.userId,
|
||||
table.classId,
|
||||
table.subjectId,
|
||||
table.type
|
||||
),
|
||||
userUpdatedIdx: index("gd_user_updated_idx").on(table.userId, table.updatedAt),
|
||||
}));
|
||||
|
||||
@@ -21,7 +21,10 @@
|
||||
"status": {
|
||||
"draft": "Draft",
|
||||
"published": "Published",
|
||||
"archived": "Archived"
|
||||
"archived": "Archived",
|
||||
"pinned": "Pinned",
|
||||
"read": "Read",
|
||||
"unread": "Unread"
|
||||
},
|
||||
"type": {
|
||||
"school": "School",
|
||||
@@ -52,7 +55,10 @@
|
||||
"archive": "Archive",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"back": "Back"
|
||||
"back": "Back",
|
||||
"pin": "Pin",
|
||||
"unpin": "Unpin",
|
||||
"markRead": "Mark as Read"
|
||||
},
|
||||
"messages": {
|
||||
"created": "Announcement created",
|
||||
@@ -67,7 +73,9 @@
|
||||
"archiveFailed": "Failed to archive",
|
||||
"deleteFailed": "Failed to delete",
|
||||
"invalidForm": "Invalid form data",
|
||||
"invalidState": "Invalid form state"
|
||||
"invalidState": "Invalid state",
|
||||
"pinToggled": "Pin status updated",
|
||||
"markedRead": "Marked as read"
|
||||
},
|
||||
"meta": {
|
||||
"publishedAt": "Published {date}",
|
||||
|
||||
@@ -21,7 +21,11 @@
|
||||
"send": "Send",
|
||||
"sending": "Sending...",
|
||||
"cancel": "Cancel",
|
||||
"back": "Back"
|
||||
"back": "Back",
|
||||
"star": "Star",
|
||||
"unstar": "Unstar",
|
||||
"saveDraft": "Save Draft",
|
||||
"deleteDraft": "Delete Draft"
|
||||
},
|
||||
"form": {
|
||||
"to": "To",
|
||||
@@ -55,7 +59,9 @@
|
||||
"sentEmpty": "No sent messages",
|
||||
"sentEmptyDesc": "You have not sent any messages yet.",
|
||||
"deleteTitle": "Delete message",
|
||||
"deleteDesc": "This will permanently delete the message \"{subject}\"."
|
||||
"deleteDesc": "This will permanently delete the message \"{subject}\".",
|
||||
"noDrafts": "No drafts",
|
||||
"noStarred": "No starred messages"
|
||||
},
|
||||
"messages": {
|
||||
"sent": "Message sent",
|
||||
@@ -66,7 +72,10 @@
|
||||
"deleteFailed": "Failed to delete",
|
||||
"notFound": "Message not found",
|
||||
"invalidId": "Invalid message id",
|
||||
"selectRecipient": "Please select a recipient"
|
||||
"selectRecipient": "Please select a recipient",
|
||||
"draftSaved": "Draft saved",
|
||||
"draftDeleted": "Draft deleted",
|
||||
"starToggled": "Star status updated"
|
||||
},
|
||||
"notification": {
|
||||
"messageTitle": "New message: {subject}",
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
"markRead": "Mark as read",
|
||||
"markAllRead": "Mark all as read",
|
||||
"viewAll": "View all notifications",
|
||||
"view": "View"
|
||||
"view": "View",
|
||||
"archive": "Archive",
|
||||
"unarchive": "Unarchive"
|
||||
},
|
||||
"type": {
|
||||
"message": "Message",
|
||||
@@ -18,18 +20,30 @@
|
||||
"homework": "Homework",
|
||||
"grade": "Grade"
|
||||
},
|
||||
"filter": {
|
||||
"all": "All"
|
||||
},
|
||||
"priority": {
|
||||
"low": "Low",
|
||||
"normal": "Normal",
|
||||
"high": "High",
|
||||
"urgent": "Urgent"
|
||||
},
|
||||
"status": {
|
||||
"new": "New"
|
||||
},
|
||||
"empty": {
|
||||
"noNotifications": "No notifications",
|
||||
"noNotificationsDesc": "You have no notifications yet.",
|
||||
"noNotificationsDropdown": "No notifications"
|
||||
"noNotificationsDropdown": "No notifications",
|
||||
"noFilterResults": "No results",
|
||||
"noFilterResultsDesc": "No notifications match the current filter."
|
||||
},
|
||||
"messages": {
|
||||
"allMarkedRead": "All notifications marked as read",
|
||||
"notificationMarkedRead": "Notification marked as read",
|
||||
"markReadFailed": "Failed to mark as read"
|
||||
"markReadFailed": "Failed to mark as read",
|
||||
"archiveFailed": "Archive failed"
|
||||
},
|
||||
"error": {
|
||||
"loadFailed": "Failed to load notifications",
|
||||
|
||||
@@ -21,7 +21,10 @@
|
||||
"status": {
|
||||
"draft": "草稿",
|
||||
"published": "已发布",
|
||||
"archived": "已归档"
|
||||
"archived": "已归档",
|
||||
"pinned": "已置顶",
|
||||
"read": "已读",
|
||||
"unread": "未读"
|
||||
},
|
||||
"type": {
|
||||
"school": "全校",
|
||||
@@ -52,7 +55,10 @@
|
||||
"archive": "归档",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"back": "返回"
|
||||
"back": "返回",
|
||||
"pin": "置顶",
|
||||
"unpin": "取消置顶",
|
||||
"markRead": "标记已读"
|
||||
},
|
||||
"messages": {
|
||||
"created": "公告已创建",
|
||||
@@ -67,7 +73,9 @@
|
||||
"archiveFailed": "归档失败",
|
||||
"deleteFailed": "删除失败",
|
||||
"invalidForm": "表单数据无效",
|
||||
"invalidState": "表单状态无效"
|
||||
"invalidState": "表单状态无效",
|
||||
"pinToggled": "置顶状态已更新",
|
||||
"markedRead": "已标记为已读"
|
||||
},
|
||||
"meta": {
|
||||
"publishedAt": "发布于 {date}",
|
||||
|
||||
@@ -21,7 +21,11 @@
|
||||
"send": "发送",
|
||||
"sending": "发送中...",
|
||||
"cancel": "取消",
|
||||
"back": "返回"
|
||||
"back": "返回",
|
||||
"star": "星标",
|
||||
"unstar": "取消星标",
|
||||
"saveDraft": "保存草稿",
|
||||
"deleteDraft": "删除草稿"
|
||||
},
|
||||
"form": {
|
||||
"to": "收件人",
|
||||
@@ -55,7 +59,9 @@
|
||||
"sentEmpty": "无已发送消息",
|
||||
"sentEmptyDesc": "您还没有发送任何消息。",
|
||||
"deleteTitle": "删除消息",
|
||||
"deleteDesc": "此操作将永久删除消息{subject}。"
|
||||
"deleteDesc": "此操作将永久删除消息{subject}。",
|
||||
"noDrafts": "暂无草稿",
|
||||
"noStarred": "暂无星标消息"
|
||||
},
|
||||
"messages": {
|
||||
"sent": "消息已发送",
|
||||
@@ -66,7 +72,10 @@
|
||||
"deleteFailed": "删除失败",
|
||||
"notFound": "消息不存在",
|
||||
"invalidId": "消息 ID 无效",
|
||||
"selectRecipient": "请选择收件人"
|
||||
"selectRecipient": "请选择收件人",
|
||||
"draftSaved": "草稿已保存",
|
||||
"draftDeleted": "草稿已删除",
|
||||
"starToggled": "星标状态已更新"
|
||||
},
|
||||
"notification": {
|
||||
"messageTitle": "新消息:{subject}",
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
"markRead": "标记已读",
|
||||
"markAllRead": "全部标记已读",
|
||||
"viewAll": "查看全部通知",
|
||||
"view": "查看"
|
||||
"view": "查看",
|
||||
"archive": "归档",
|
||||
"unarchive": "取消归档"
|
||||
},
|
||||
"type": {
|
||||
"message": "消息",
|
||||
@@ -18,18 +20,30 @@
|
||||
"homework": "作业",
|
||||
"grade": "成绩"
|
||||
},
|
||||
"filter": {
|
||||
"all": "全部"
|
||||
},
|
||||
"priority": {
|
||||
"low": "低",
|
||||
"normal": "普通",
|
||||
"high": "高",
|
||||
"urgent": "紧急"
|
||||
},
|
||||
"status": {
|
||||
"new": "新"
|
||||
},
|
||||
"empty": {
|
||||
"noNotifications": "暂无通知",
|
||||
"noNotificationsDesc": "您还没有任何通知。",
|
||||
"noNotificationsDropdown": "暂无通知"
|
||||
"noNotificationsDropdown": "暂无通知",
|
||||
"noFilterResults": "无筛选结果",
|
||||
"noFilterResultsDesc": "当前筛选条件下没有通知。"
|
||||
},
|
||||
"messages": {
|
||||
"allMarkedRead": "全部已标记为已读",
|
||||
"notificationMarkedRead": "通知已标记为已读",
|
||||
"markReadFailed": "标记已读失败"
|
||||
"markReadFailed": "标记已读失败",
|
||||
"archiveFailed": "归档失败"
|
||||
},
|
||||
"error": {
|
||||
"loadFailed": "通知加载失败",
|
||||
|
||||
@@ -20,9 +20,12 @@ export type EventName =
|
||||
| "announcement.published"
|
||||
| "announcement.archived"
|
||||
| "announcement.deleted"
|
||||
| "announcement.pin_toggled"
|
||||
| "announcement.marked_read"
|
||||
| "message.sent"
|
||||
| "message.deleted"
|
||||
| "message.marked_read"
|
||||
| "message.star_toggled"
|
||||
| "notification.marked_read"
|
||||
| "notification.marked_all_read"
|
||||
| "notification.sent"
|
||||
|
||||
64
tests/e2e/messages.spec.ts
Normal file
64
tests/e2e/messages.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
|
||||
/**
|
||||
* 私信模块 E2E 测试。
|
||||
* 未登录场景可独立运行;登录后场景需要 DATABASE_URL。
|
||||
* 参考 announcements.spec.ts 的登录与跳转断言模式。
|
||||
*/
|
||||
|
||||
test.describe("Messages module", () => {
|
||||
test.describe("unauthenticated access", () => {
|
||||
test("redirects to login when not authenticated", async ({ page }) => {
|
||||
await page.goto("/messages")
|
||||
await expect(page).toHaveURL(/\/login(?:$|[/?#])/)
|
||||
})
|
||||
|
||||
test("redirects message detail to login when not authenticated", async ({ page }) => {
|
||||
await page.goto("/messages/some-id")
|
||||
await expect(page).toHaveURL(/\/login(?:$|[/?#])/)
|
||||
})
|
||||
|
||||
test("redirects compose to login when not authenticated", async ({ page }) => {
|
||||
await page.goto("/messages/compose")
|
||||
await expect(page).toHaveURL(/\/login(?:$|[/?#])/)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe("authenticated access", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
test.skip(!process.env.DATABASE_URL, "requires DATABASE_URL for authenticated flow")
|
||||
|
||||
const email = process.env.E2E_STUDENT_EMAIL ?? "student@e2e.local"
|
||||
const password = process.env.E2E_STUDENT_PASSWORD ?? "e2e-pass-123456"
|
||||
|
||||
await page.goto("/login")
|
||||
await page.getByLabel("Email").fill(email)
|
||||
await page.getByLabel("Password").fill(password)
|
||||
await page.getByRole("button", { name: "Sign In with Email" }).click()
|
||||
})
|
||||
|
||||
test("messages page loads and stays authenticated", async ({ page }) => {
|
||||
await page.goto("/messages")
|
||||
await expect(page.locator("body")).toBeVisible()
|
||||
await expect(page).not.toHaveURL(/\/login(?:$|[/?#])/)
|
||||
})
|
||||
|
||||
test("messages page has tab navigation", async ({ page }) => {
|
||||
await page.goto("/messages")
|
||||
const tabs = page.locator('[role="tab"]')
|
||||
await expect(tabs.first()).toBeVisible({ timeout: 10000 })
|
||||
const tabCount = await tabs.count()
|
||||
expect(tabCount).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
test("messages page renders without error", async ({ page }) => {
|
||||
await page.goto("/messages")
|
||||
await expect(page.locator("body")).toBeVisible()
|
||||
})
|
||||
|
||||
test("message compose page loads", async ({ page }) => {
|
||||
await page.goto("/messages/compose")
|
||||
await expect(page.locator("body")).toBeVisible()
|
||||
})
|
||||
})
|
||||
})
|
||||
4
tests/setup/server-only-stub.ts
Normal file
4
tests/setup/server-only-stub.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Stub for Next.js "server-only" package.
|
||||
// The real package throws when imported on the client side; vitest (jsdom)
|
||||
// cannot resolve it, so we alias it to this empty module in vitest.unit.config.ts.
|
||||
export {}
|
||||
@@ -5,6 +5,8 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
// Next.js "server-only" 包在 vitest 环境无法解析,alias 到空 stub
|
||||
"server-only": path.resolve(__dirname, "tests/setup/server-only-stub.ts"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
|
||||
Reference in New Issue
Block a user