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