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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user