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:
SpecialX
2026-06-23 10:13:57 +08:00
parent 696346dc08
commit f75602d14e
39 changed files with 2557 additions and 110 deletions

View File

@@ -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: 消息草稿 CRUDmessage_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)
}