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

@@ -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" }
}
}

View 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 })
)
})
})

View File

@@ -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()
}
}

View File

@@ -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")}

View File

@@ -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)))
}

View 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" }
)
})
})

View File

@@ -124,8 +124,8 @@ export async function sendNotification(
})
)
// 记录发送日志
logNotificationSendBatch(results)
// 记录发送日志(传入 payload 用于持久化)
await logNotificationSendBatch(results, { userId: payload.userId, title: payload.title })
return results
}

View 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,
}
}

View 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
*
* 优先使用 SSEServer-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,
}
}

View File

@@ -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"

View File

@@ -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