diff --git a/scripts/arch-scan/arch.db b/scripts/arch-scan/arch.db index c61b832..6a49726 100644 Binary files a/scripts/arch-scan/arch.db and b/scripts/arch-scan/arch.db differ diff --git a/src/modules/messaging/actions-bulk.ts b/src/modules/messaging/actions-bulk.ts new file mode 100644 index 0000000..e8fa5c9 --- /dev/null +++ b/src/modules/messaging/actions-bulk.ts @@ -0,0 +1,105 @@ +"use server" + +import { invalidateFor } from "@/shared/lib/cache" +import { PermissionDeniedError, requirePermission } from "@/shared/lib/auth-guard" +import { trackEvent } from "@/shared/lib/track-event" +import { Permissions } from "@/shared/types/permissions" +import type { ActionState } from "@/shared/types/action-state" + +import { BulkMessageIdsSchema } from "./schema" +import { + bulkMarkMessagesAsRead, + bulkDeleteMessages, + bulkToggleMessagesStar, +} from "./data-access" + +// --------------------------------------------------------------------------- +// P2-1: 批量操作 Server Actions +// --------------------------------------------------------------------------- + +export async function bulkMarkMessagesAsReadAction( + ids: string[] +): Promise> { + try { + const ctx = await requirePermission(Permissions.MESSAGE_READ) + + const parsed = BulkMessageIdsSchema.safeParse({ ids }) + if (!parsed.success) { + return { success: false, message: "Invalid message IDs", errors: parsed.error.flatten().fieldErrors } + } + + const updated = await bulkMarkMessagesAsRead(parsed.data.ids, ctx.userId) + await invalidateFor("messaging.bulkMarkRead") + + void trackEvent({ + event: "message.marked_read", + userId: ctx.userId, + targetType: "message", + properties: { count: updated, bulk: true }, + }) + + return { success: true, message: "Messages marked as read", data: { updated } } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + if (e instanceof Error) return { success: false, message: e.message } + return { success: false, message: "Unexpected error" } + } +} + +export async function bulkDeleteMessagesAction( + ids: string[] +): Promise> { + try { + const ctx = await requirePermission(Permissions.MESSAGE_DELETE) + + const parsed = BulkMessageIdsSchema.safeParse({ ids }) + if (!parsed.success) { + return { success: false, message: "Invalid message IDs", errors: parsed.error.flatten().fieldErrors } + } + + await bulkDeleteMessages(parsed.data.ids, ctx.userId) + await invalidateFor("messaging.bulkDelete") + + void trackEvent({ + event: "message.deleted", + userId: ctx.userId, + targetType: "message", + properties: { count: parsed.data.ids.length, bulk: true }, + }) + + return { success: true, message: "Messages deleted" } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + if (e instanceof Error) return { success: false, message: e.message } + return { success: false, message: "Unexpected error" } + } +} + +export async function bulkToggleMessagesStarAction( + ids: string[] +): Promise> { + try { + const ctx = await requirePermission(Permissions.MESSAGE_READ) + + const parsed = BulkMessageIdsSchema.safeParse({ ids }) + if (!parsed.success) { + return { success: false, message: "Invalid message IDs", errors: parsed.error.flatten().fieldErrors } + } + + await bulkToggleMessagesStar(parsed.data.ids, ctx.userId) + await invalidateFor("messaging.bulkToggleStar") + + void trackEvent({ + event: "message.star_toggled", + userId: ctx.userId, + targetType: "message", + properties: { count: parsed.data.ids.length, bulk: true }, + }) + + return { success: true, message: "Star toggled" } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + if (e instanceof Error) return { success: false, message: e.message } + return { success: false, message: "Unexpected error" } + } +} diff --git a/src/modules/messaging/actions-core.ts b/src/modules/messaging/actions-core.ts new file mode 100644 index 0000000..eaa8b20 --- /dev/null +++ b/src/modules/messaging/actions-core.ts @@ -0,0 +1,375 @@ +"use server" + +import { invalidateFor } from "@/shared/lib/cache" +import { getTranslations } from "next-intl/server" +import { PermissionDeniedError, requirePermission } from "@/shared/lib/auth-guard" +import { trackEvent } from "@/shared/lib/track-event" +import { Permissions } from "@/shared/types/permissions" +import type { AuthContext } from "@/shared/types/permissions" +import type { ActionState } from "@/shared/types/action-state" +import { sendNotification } from "@/modules/notifications/dispatcher" + +import { + SendMessageSchema, + MessageIdSchema, +} from "./schema" +import { + getMessages, + getMessageById, + getMessageThread, + createMessage, + markMessageAsRead, + deleteMessage, + getRecipients, + getUnreadMessageCount, + toggleMessageStar, + isReceiverAllowed, + recallMessage, + isEitherUserBlocked, + getMessageAttachments, +} from "./data-access" +import type { Message, MessageType, RecipientOption, MessageAttachment } from "./types" + +export async function sendMessageAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + let ctx: AuthContext | null = null + let parsedInput: { + receiverId: string + subject: string | null + content: string + parentMessageId: string | null + } | null = null + try { + ctx = await requirePermission(Permissions.MESSAGE_SEND) + + const parsed = SendMessageSchema.safeParse({ + receiverId: formData.get("receiverId"), + subject: formData.get("subject") || undefined, + content: formData.get("content"), + parentMessageId: formData.get("parentMessageId") || undefined, + }) + + if (!parsed.success) { + return { success: false, message: "Invalid form data", errors: parsed.error.flatten().fieldErrors } + } + + const input = parsed.data + parsedInput = input + if (input.receiverId === ctx.userId) { + return { success: false, message: "Cannot send a message to yourself" } + } + + // P0-1: Server Action 二次校验收件人是否在 sender 的 DataScope 允许范围内 + // 防止绕过前端 Select 直接构造 FormData 提交任意 receiverId 越权发送 + const allowed = await isReceiverAllowed(ctx.userId, input.receiverId, ctx.dataScope) + if (!allowed) { + return { success: false, message: "Recipient not allowed" } + } + + // P2-5: 屏蔽校验 —— 发送方或接收方任一屏蔽对方,则禁止发送 + const blocked = await isEitherUserBlocked(ctx.userId, input.receiverId) + if (blocked) { + return { success: false, message: "Cannot send message due to block" } + } + + // P0-6: 校验 parentMessageId 属于当前用户参与的会话 + // 防止将回复消息注入到他人会话线程 + if (input.parentMessageId) { + const parent = await getMessageById(input.parentMessageId, ctx.userId) + if (!parent) { + return { success: false, message: "Parent message not accessible" } + } + } + + const id = await createMessage({ + senderId: ctx.userId, + receiverId: input.receiverId, + subject: input.subject, + content: input.content, + parentMessageId: input.parentMessageId, + }) + + // Notify the receiver about the new message via the notifications dispatcher. + // This respects user notification preferences (SMS/WeChat/Email/In-App). + const t = await getTranslations("messages") + const notifyTitle = input.subject + ? t("notification.messageTitle", { subject: input.subject }) + : t("notification.messageTitleNoSubject") + await sendNotification({ + userId: input.receiverId, + type: "info", + title: notifyTitle, + content: input.content.slice(0, 200), + actionUrl: `/messages/${id}`, + metadata: { messageType: "message", messageId: id }, + }) + + await invalidateFor("messaging.send", { id }) + + void trackEvent({ + event: "message.sent", + userId: ctx.userId, + targetId: id, + targetType: "message", + properties: { receiverId: input.receiverId, hasSubject: !!input.subject }, + }) + + return { success: true, message: "Message sent", data: id } + } catch (e) { + // P1-9: 失败埋点(权限拒绝除外,避免噪音) + if (ctx && parsedInput && !(e instanceof PermissionDeniedError)) { + void trackEvent({ + event: "message.send_failed", + userId: ctx.userId, + targetType: "message", + properties: { + receiverId: parsedInput.receiverId, + reason: e instanceof Error ? e.message : "unknown", + }, + }) + } + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + if (e instanceof Error) return { success: false, message: e.message } + return { success: false, message: "Unexpected error" } + } +} + +export async function markMessageAsReadAction(messageId: string): Promise> { + try { + const ctx = await requirePermission(Permissions.MESSAGE_READ) + + const parsed = MessageIdSchema.safeParse({ messageId }) + if (!parsed.success) { + return { success: false, message: "Invalid message id", errors: parsed.error.flatten().fieldErrors } + } + + await markMessageAsRead(parsed.data.messageId, ctx.userId) + await invalidateFor("messaging.markRead", { id: parsed.data.messageId }) + + void trackEvent({ + event: "message.marked_read", + userId: ctx.userId, + targetId: parsed.data.messageId, + targetType: "message", + }) + + return { success: true, message: "Marked as read" } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + if (e instanceof Error) return { success: false, message: e.message } + return { success: false, message: "Unexpected error" } + } +} + +export async function deleteMessageAction(messageId: string): Promise> { + try { + const ctx = await requirePermission(Permissions.MESSAGE_DELETE) + + const parsed = MessageIdSchema.safeParse({ messageId }) + if (!parsed.success) { + return { success: false, message: "Invalid message id", errors: parsed.error.flatten().fieldErrors } + } + + await deleteMessage(parsed.data.messageId, ctx.userId) + await invalidateFor("messaging.delete", { id: parsed.data.messageId }) + + void trackEvent({ + event: "message.deleted", + userId: ctx.userId, + targetId: parsed.data.messageId, + targetType: "message", + }) + + return { success: true, message: "Message deleted" } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + if (e instanceof Error) return { success: false, message: e.message } + return { success: false, message: "Unexpected error" } + } +} + +export async function getMessagesAction( + params: { + type: MessageType + page?: number + pageSize?: number + keyword?: string + starredOnly?: boolean + /** P2-11: 按发件人筛选 */ + senderId?: string + /** P2-11: 按日期范围筛选 */ + dateFrom?: string + dateTo?: string + /** P2-11: 按已读状态筛选 */ + isRead?: boolean + } +): Promise> { + try { + const ctx = await requirePermission(Permissions.MESSAGE_READ) + const result = await getMessages({ userId: ctx.userId, ...params }) + return { success: true, data: result } + } 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" } + } +} + +/** + * P1-3: 获取消息线程(根消息 + 回复链)。 + * + * 替代直接调用 data-access.getMessageThread,统一通过 Server Action 入口 + * 做权限校验。返回的 Message[] 已按时间倒序(与 data-access 一致), + * UI 层可按需 reverse 为正序展示。 + * + * 注:原 getMessageDetailAction 已删除(P0-4 死代码,详情数据由 + * getMessageDetailPageData 编排函数 + RSC 直接获取,组件通过 props 接收)。 + */ +export async function getMessageThreadAction(messageId: string): Promise> { + try { + const ctx = await requirePermission(Permissions.MESSAGE_READ) + + const parsed = MessageIdSchema.safeParse({ messageId }) + if (!parsed.success) { + return { success: false, message: "Invalid message id", errors: parsed.error.flatten().fieldErrors } + } + + // data-access.getMessageThread 内部会校验当前用户对根消息的访问权 + const thread = await getMessageThread(parsed.data.messageId, ctx.userId) + return { success: true, data: thread } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + if (e instanceof Error) return { success: false, message: e.message } + return { success: false, message: "Unexpected error" } + } +} + +export async function getRecipientsAction(): Promise> { + try { + const ctx = await requirePermission(Permissions.MESSAGE_SEND) + const recipients = await getRecipients(ctx.userId, ctx.dataScope) + return { success: true, data: recipients } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + if (e instanceof Error) return { success: false, message: e.message } + return { success: false, message: "Unexpected error" } + } +} + +export async function getUnreadMessageCountAction(): Promise> { + try { + const ctx = await requirePermission(Permissions.MESSAGE_READ) + const count = await getUnreadMessageCount(ctx.userId) + return { success: true, data: count } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + if (e instanceof Error) return { success: false, message: e.message } + return { success: false, message: "Unexpected error" } + } +} + +// --------------------------------------------------------------------------- +// V2-P2-13c: 消息星标 Server Actions +// --------------------------------------------------------------------------- + +export async function toggleMessageStarAction(messageId: string): Promise> { + try { + const ctx = await requirePermission(Permissions.MESSAGE_READ) + + const parsed = MessageIdSchema.safeParse({ messageId }) + if (!parsed.success) { + return { success: false, message: "Invalid message id", errors: parsed.error.flatten().fieldErrors } + } + + await toggleMessageStar(parsed.data.messageId, ctx.userId) + await invalidateFor("messaging.toggleStar", { id: parsed.data.messageId }) + + void trackEvent({ + event: "message.star_toggled", + userId: ctx.userId, + targetId: parsed.data.messageId, + targetType: "message", + }) + + return { success: true, message: "Star toggled" } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + if (e instanceof Error) return { success: false, message: e.message } + return { success: false, message: "Unexpected error" } + } +} + +// --------------------------------------------------------------------------- +// P2-4: 消息撤回 Server Action(2 分钟窗口内仅发送方可撤回) +// --------------------------------------------------------------------------- + +export async function recallMessageAction( + messageId: string +): Promise> { + try { + const ctx = await requirePermission(Permissions.MESSAGE_SEND) + + const parsed = MessageIdSchema.safeParse({ messageId }) + if (!parsed.success) { + return { success: false, message: "Invalid message id", errors: parsed.error.flatten().fieldErrors } + } + + const result = await recallMessage(parsed.data.messageId, ctx.userId) + + if (result === "not_found") { + return { success: false, message: "Message not found or not owned" } + } + if (result === "already_recalled") { + return { success: false, message: "Message already recalled" } + } + if (result === "expired") { + return { success: false, message: "Recall window expired" } + } + + await invalidateFor("messaging.recall", { id: parsed.data.messageId }) + + void trackEvent({ + event: "message.recalled", + userId: ctx.userId, + targetId: parsed.data.messageId, + targetType: "message", + }) + + return { success: true, message: "Message recalled" } + } 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" } + } +} + +// --------------------------------------------------------------------------- +// P2-3: 消息附件 Server Actions +// --------------------------------------------------------------------------- + +/** + * P2-3: 获取指定消息的附件列表。 + * + * 校验当前用户对消息有访问权(必须是发送方或接收方),防止越权访问附件。 + * 附件存储复用 files 模块的 fileAttachments 表(targetType="message")。 + */ +export async function getMessageAttachmentsAction( + messageId: string +): Promise> { + try { + const ctx = await requirePermission(Permissions.MESSAGE_READ) + // 校验访问权:必须是消息的发送方或接收方 + const msg = await getMessageById(messageId, ctx.userId) + if (!msg) { + return { success: false, message: "Message not found" } + } + const attachments = await getMessageAttachments(messageId) + return { success: true, data: attachments } + } 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" } + } +} diff --git a/src/modules/messaging/actions-group.ts b/src/modules/messaging/actions-group.ts new file mode 100644 index 0000000..fc04b2d --- /dev/null +++ b/src/modules/messaging/actions-group.ts @@ -0,0 +1,114 @@ +"use server" + +import { invalidateFor } from "@/shared/lib/cache" +import { getTranslations } from "next-intl/server" +import { PermissionDeniedError, requirePermission } from "@/shared/lib/auth-guard" +import { trackEvent } from "@/shared/lib/track-event" +import { Permissions } from "@/shared/types/permissions" +import type { AuthContext } from "@/shared/types/permissions" +import type { ActionState } from "@/shared/types/action-state" +import { sendNotification } from "@/modules/notifications/dispatcher" + +import { SendGroupMessageSchema } from "./schema" +import { sendGroupMessage } from "./data-access" + +// --------------------------------------------------------------------------- +// P2-2: 群组消息 Server Action(教师→全班家长,fan-out on write) +// --------------------------------------------------------------------------- + +export async function sendGroupMessageAction( + prevState: ActionState<{ recipientCount: number }> | null, + formData: FormData +): Promise> { + let ctx: AuthContext | null = null + let parsedInput: { classId: string; subject: string | null; content: string } | null = null + try { + ctx = await requirePermission(Permissions.MESSAGE_SEND) + + const parsed = SendGroupMessageSchema.safeParse({ + classId: formData.get("classId"), + subject: formData.get("subject") || undefined, + content: formData.get("content"), + }) + + if (!parsed.success) { + return { success: false, message: "Invalid form data", errors: parsed.error.flatten().fieldErrors } + } + + const input = parsed.data + parsedInput = input + + // P2-2: 校验当前教师是否有权向该班级群发(必须在其 class_taught 范围内) + const allowed = ctx.dataScope.type === "class_taught" + && ctx.dataScope.classIds.includes(input.classId) + if (!allowed) { + return { success: false, message: "Not allowed to send group message to this class" } + } + + const result = await sendGroupMessage({ + senderId: ctx.userId, + classId: input.classId, + subject: input.subject, + content: input.content, + }) + + if (result.recipientIds.length === 0) { + return { success: false, message: "No recipients found in this class" } + } + + // 通知每个家长 + const t = await getTranslations("messages") + const notifyTitle = input.subject + ? t("notification.messageTitle", { subject: input.subject }) + : t("notification.messageTitleNoSubject") + const notifyContent = input.content.slice(0, 200) + + await Promise.all( + result.recipientIds.map((parentId) => + sendNotification({ + userId: parentId, + type: "info", + title: notifyTitle, + content: notifyContent, + actionUrl: "/messages", + metadata: { messageType: "group_message", groupMessageId: result.groupMessageId }, + }) + ) + ) + + await invalidateFor("messaging.sendGroup") + + void trackEvent({ + event: "message.group_sent", + userId: ctx.userId, + targetType: "message", + properties: { + classId: input.classId, + recipientCount: result.recipientIds.length, + groupMessageId: result.groupMessageId, + }, + }) + + return { + success: true, + message: "Group message sent", + data: { recipientCount: result.recipientIds.length }, + } + } catch (e) { + // P1-9: 失败埋点(权限拒绝除外) + if (ctx && parsedInput && !(e instanceof PermissionDeniedError)) { + void trackEvent({ + event: "message.group_send_failed", + userId: ctx.userId, + targetType: "message", + properties: { + classId: parsedInput.classId, + reason: e instanceof Error ? e.message : "unknown", + }, + }) + } + 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" } + } +} diff --git a/src/modules/messaging/actions-reports.ts b/src/modules/messaging/actions-reports.ts new file mode 100644 index 0000000..09c1b40 --- /dev/null +++ b/src/modules/messaging/actions-reports.ts @@ -0,0 +1,201 @@ +"use server" + +import { invalidateFor } from "@/shared/lib/cache" +import { PermissionDeniedError, requirePermission } from "@/shared/lib/auth-guard" +import { trackEvent } from "@/shared/lib/track-event" +import { Permissions } from "@/shared/types/permissions" +import type { AuthContext } from "@/shared/types/permissions" +import type { ActionState } from "@/shared/types/action-state" + +import { + ReportMessageSchema, + BlockUserSchema, +} from "./schema" +import { + getMessageById, + reportMessage, + hasUserReportedMessage, + blockUser, + unblockUser, + getUserBlocks, +} from "./data-access" +import type { UserBlock } from "./types" + +// --------------------------------------------------------------------------- +// P2-5: 消息举报 + 用户屏蔽 Server Actions +// --------------------------------------------------------------------------- + +/** + * P2-5: 举报消息。 + * + * - 校验消息存在且当前用户为接收方(只能举报收到的消息) + * - 防重复:同一用户对同一消息只能举报一次 + */ +export async function reportMessageAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + let ctx: AuthContext | null = null + let parsedInput: { messageId: string; reason: string; description?: string } | null = null + try { + ctx = await requirePermission(Permissions.MESSAGE_READ) + + const parsed = ReportMessageSchema.safeParse({ + messageId: formData.get("messageId"), + reason: formData.get("reason"), + description: formData.get("description") || undefined, + }) + + if (!parsed.success) { + return { success: false, message: "Invalid form data", errors: parsed.error.flatten().fieldErrors } + } + + const input = parsed.data + parsedInput = input + + // 校验消息存在且当前用户为接收方(防越权举报) + const msg = await getMessageById(input.messageId, ctx.userId) + if (!msg) { + return { success: false, message: "Message not found" } + } + // 举报人必须是接收方(不能举报自己发的消息) + if (msg.senderId === ctx.userId) { + return { success: false, message: "Cannot report your own message" } + } + + const result = await reportMessage({ + reporterId: ctx.userId, + messageId: input.messageId, + reportedUserId: msg.senderId, + reason: input.reason as "spam" | "harassment" | "inappropriate" | "other", + description: input.description ?? null, + }) + + if (result === "already_reported") { + return { success: false, message: "Already reported this message" } + } + + void trackEvent({ + event: "message.reported", + userId: ctx.userId, + targetId: input.messageId, + targetType: "message", + properties: { reason: input.reason }, + }) + + return { success: true, message: "Message reported", data: null } + } catch (e) { + if (ctx && parsedInput && !(e instanceof PermissionDeniedError)) { + void trackEvent({ + event: "message.report_failed", + userId: ctx.userId, + targetId: parsedInput.messageId, + targetType: "message", + properties: { reason: parsedInput.reason }, + }) + } + 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" } + } +} + +/** + * P2-5: 检查当前用户是否已举报某条消息(用于 UI 禁用状态)。 + */ +export async function checkReportedAction(messageId: string): Promise> { + try { + const ctx = await requirePermission(Permissions.MESSAGE_READ) + const reported = await hasUserReportedMessage(ctx.userId, messageId) + return { success: true, data: reported } + } 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" } + } +} + +/** + * P2-5: 屏蔽用户。 + */ +export async function blockUserAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + try { + const ctx = await requirePermission(Permissions.MESSAGE_SEND) + + const parsed = BlockUserSchema.safeParse({ + blockedId: formData.get("blockedId"), + }) + + if (!parsed.success) { + return { success: false, message: "Invalid form data", errors: parsed.error.flatten().fieldErrors } + } + + const input = parsed.data + + const result = await blockUser(ctx.userId, input.blockedId) + + if (result === "self_block") { + return { success: false, message: "Cannot block yourself" } + } + if (result === "already_blocked") { + return { success: false, message: "User already blocked" } + } + + await invalidateFor("messaging.block", { id: input.blockedId }) + + void trackEvent({ + event: "message.user_blocked", + userId: ctx.userId, + targetId: input.blockedId, + targetType: "user", + }) + + return { success: true, message: "User blocked", data: null } + } 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" } + } +} + +/** + * P2-5: 取消屏蔽用户。 + */ +export async function unblockUserAction(blockedId: string): Promise> { + try { + const ctx = await requirePermission(Permissions.MESSAGE_SEND) + await unblockUser(ctx.userId, blockedId) + await invalidateFor("messaging.unblock", { id: blockedId }) + + void trackEvent({ + event: "message.user_unblocked", + userId: ctx.userId, + targetId: blockedId, + targetType: "user", + }) + + return { success: true, message: "User unblocked", data: null } + } 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" } + } +} + +/** + * P2-5: 获取当前用户的屏蔽列表。 + */ +export async function getUserBlocksAction(): Promise> { + try { + const ctx = await requirePermission(Permissions.MESSAGE_SEND) + const blocks = await getUserBlocks(ctx.userId) + return { success: true, data: blocks } + } 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" } + } +} diff --git a/src/modules/messaging/actions-templates.ts b/src/modules/messaging/actions-templates.ts new file mode 100644 index 0000000..bfebd58 --- /dev/null +++ b/src/modules/messaging/actions-templates.ts @@ -0,0 +1,220 @@ +"use server" + +import { invalidateFor } from "@/shared/lib/cache" +import { PermissionDeniedError, requirePermission } from "@/shared/lib/auth-guard" +import { trackEvent } from "@/shared/lib/track-event" +import { Permissions } from "@/shared/types/permissions" +import type { ActionState } from "@/shared/types/action-state" + +import { + MessageDraftSchema, + MessageTemplateSchema, + MessageIdSchema, +} from "./schema" +import { + getMessageDrafts, + createMessageDraft, + updateMessageDraft, + deleteMessageDraft, + getMessageTemplates, + createMessageTemplate, + updateMessageTemplate, + deleteMessageTemplate, +} from "./data-access" +import type { MessageDraft, MessageTemplate } from "./types" + +// --------------------------------------------------------------------------- +// V2-P2-13c: 消息草稿 Server Actions +// --------------------------------------------------------------------------- + +export async function getMessageDraftsAction(): Promise> { + try { + const ctx = await requirePermission(Permissions.MESSAGE_SEND) + const drafts = await getMessageDrafts(ctx.userId) + return { success: true, data: drafts } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + if (e instanceof Error) return { success: false, message: e.message } + return { success: false, message: "Unexpected error" } + } +} + +export async function saveMessageDraftAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + try { + const ctx = await requirePermission(Permissions.MESSAGE_SEND) + + const parsed = MessageDraftSchema.safeParse({ + draftId: formData.get("draftId") ?? undefined, + receiverId: formData.get("receiverId") ?? undefined, + subject: formData.get("subject") ?? undefined, + content: formData.get("content") ?? undefined, + parentMessageId: formData.get("parentMessageId") ?? undefined, + }) + + if (!parsed.success) { + return { success: false, message: "Invalid form data", errors: parsed.error.flatten().fieldErrors } + } + + const input = parsed.data + // P2-10: 从 FormData 读取 deviceId 和 expectedVersion(可选) + const deviceId = (formData.get("deviceId") as string | null) ?? null + const expectedVersionRaw = formData.get("expectedVersion") + const expectedVersion = expectedVersionRaw ? Number(expectedVersionRaw) : undefined + + const draftData = { + receiverId: input.receiverId || null, + subject: input.subject || null, + content: input.content || null, + parentMessageId: input.parentMessageId || null, + deviceId, + expectedVersion, + } + + if (input.draftId) { + // P2-10: updateMessageDraft 现在返回 "ok" | "not_found" | "conflict" + const result = await updateMessageDraft(input.draftId, ctx.userId, draftData) + if (result === "conflict") { + // P2-10: 版本冲突,客户端应拉取最新版本 + return { success: false, message: "Draft version conflict" } + } + if (result === "not_found") { + // P2-10: 草稿不存在(可能已在其他设备删除),重新创建 + const id = await createMessageDraft({ + userId: ctx.userId, + receiverId: draftData.receiverId, + subject: draftData.subject, + content: draftData.content, + parentMessageId: draftData.parentMessageId, + deviceId: draftData.deviceId, + }) + return { success: true, message: "Draft recreated", data: id } + } + return { success: true, message: "Draft saved", data: input.draftId } + } + + const id = await createMessageDraft({ + userId: ctx.userId, + receiverId: draftData.receiverId, + subject: draftData.subject, + content: draftData.content, + parentMessageId: draftData.parentMessageId, + deviceId: draftData.deviceId, + }) + + return { success: true, message: "Draft created", data: id } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + if (e instanceof Error) return { success: false, message: e.message } + return { success: false, message: "Unexpected error" } + } +} + +export async function deleteMessageDraftAction(draftId: string): Promise> { + try { + const ctx = await requirePermission(Permissions.MESSAGE_SEND) + + await deleteMessageDraft(draftId, ctx.userId) + await invalidateFor("messaging.draft.delete") + + return { success: true, message: "Draft deleted" } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + if (e instanceof Error) return { success: false, message: e.message } + return { success: false, message: "Unexpected error" } + } +} + +// --------------------------------------------------------------------------- +// P2-6: 快捷回复模板 Server Actions +// --------------------------------------------------------------------------- + +export async function getMessageTemplatesAction(): Promise> { + try { + const ctx = await requirePermission(Permissions.MESSAGE_SEND) + const templates = await getMessageTemplates(ctx.userId) + return { success: true, data: templates } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + if (e instanceof Error) return { success: false, message: e.message } + return { success: false, message: "Unexpected error" } + } +} + +export async function saveMessageTemplateAction( + prevState: ActionState | null, + formData: FormData +): Promise> { + try { + const ctx = await requirePermission(Permissions.MESSAGE_SEND) + + const parsed = MessageTemplateSchema.safeParse({ + templateId: formData.get("templateId") ?? undefined, + title: formData.get("title"), + content: formData.get("content"), + sortOrder: formData.get("sortOrder") !== null + ? Number(formData.get("sortOrder")) + : undefined, + }) + + if (!parsed.success) { + return { success: false, message: "Invalid form data", errors: parsed.error.flatten().fieldErrors } + } + + const input = parsed.data + + if (input.templateId) { + await updateMessageTemplate(input.templateId, ctx.userId, { + title: input.title, + content: input.content, + sortOrder: input.sortOrder, + }) + await invalidateFor("messaging.template.save", { id: input.templateId }) + return { success: true, message: "Template updated", data: input.templateId } + } + + const id = await createMessageTemplate({ + userId: ctx.userId, + title: input.title, + content: input.content, + sortOrder: input.sortOrder, + }) + + await invalidateFor("messaging.template.save", { id }) + + void trackEvent({ + event: "message.template_created", + userId: ctx.userId, + targetId: id, + targetType: "message", + }) + + return { success: true, message: "Template created", data: id } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + if (e instanceof Error) return { success: false, message: e.message } + return { success: false, message: "Unexpected error" } + } +} + +export async function deleteMessageTemplateAction(templateId: string): Promise> { + try { + const ctx = await requirePermission(Permissions.MESSAGE_SEND) + + const parsed = MessageIdSchema.safeParse({ messageId: templateId }) + if (!parsed.success) { + return { success: false, message: "Invalid template id", errors: parsed.error.flatten().fieldErrors } + } + + await deleteMessageTemplate(parsed.data.messageId, ctx.userId) + await invalidateFor("messaging.template.delete", { id: parsed.data.messageId }) + + return { success: true, message: "Template deleted" } + } catch (e) { + if (e instanceof PermissionDeniedError) return { success: false, message: e.message } + if (e instanceof Error) return { success: false, message: e.message } + return { success: false, message: "Unexpected error" } + } +} diff --git a/src/modules/messaging/actions.ts b/src/modules/messaging/actions.ts index bbb9f54..d936e79 100644 --- a/src/modules/messaging/actions.ts +++ b/src/modules/messaging/actions.ts @@ -1,972 +1,47 @@ "use server" -import { invalidateFor } from "@/shared/lib/cache" -import { getTranslations } from "next-intl/server" -import { PermissionDeniedError, requirePermission } from "@/shared/lib/auth-guard" -import { trackEvent } from "@/shared/lib/track-event" -import { Permissions } from "@/shared/types/permissions" -import type { AuthContext } from "@/shared/types/permissions" -import type { ActionState } from "@/shared/types/action-state" -import { sendNotification } from "@/modules/notifications/dispatcher" - -import { - SendMessageSchema, - MessageIdSchema, - MessageDraftSchema, - BulkMessageIdsSchema, - MessageTemplateSchema, - SendGroupMessageSchema, - ReportMessageSchema, - BlockUserSchema, -} from "./schema" -import { - getMessages, - getMessageById, - getMessageThread, - createMessage, - markMessageAsRead, - deleteMessage, - getRecipients, - getUnreadMessageCount, - toggleMessageStar, - getMessageDrafts, - createMessageDraft, - updateMessageDraft, - deleteMessageDraft, - isReceiverAllowed, - bulkMarkMessagesAsRead, - bulkDeleteMessages, - bulkToggleMessagesStar, - recallMessage, - getMessageTemplates, - createMessageTemplate, - updateMessageTemplate, - deleteMessageTemplate, - sendGroupMessage, - reportMessage, - hasUserReportedMessage, - blockUser, - unblockUser, - isEitherUserBlocked, - getUserBlocks, - getMessageAttachments, -} from "./data-access" -import type { Message, MessageType, MessageDraft, MessageTemplate, RecipientOption, UserBlock, MessageAttachment } from "./types" - -export async function sendMessageAction( - prevState: ActionState | null, - formData: FormData -): Promise> { - let ctx: AuthContext | null = null - let parsedInput: { - receiverId: string - subject: string | null - content: string - parentMessageId: string | null - } | null = null - try { - ctx = await requirePermission(Permissions.MESSAGE_SEND) - - const parsed = SendMessageSchema.safeParse({ - receiverId: formData.get("receiverId"), - subject: formData.get("subject") || undefined, - content: formData.get("content"), - parentMessageId: formData.get("parentMessageId") || undefined, - }) - - if (!parsed.success) { - return { success: false, message: "Invalid form data", errors: parsed.error.flatten().fieldErrors } - } - - const input = parsed.data - parsedInput = input - if (input.receiverId === ctx.userId) { - return { success: false, message: "Cannot send a message to yourself" } - } - - // P0-1: Server Action 二次校验收件人是否在 sender 的 DataScope 允许范围内 - // 防止绕过前端 Select 直接构造 FormData 提交任意 receiverId 越权发送 - const allowed = await isReceiverAllowed(ctx.userId, input.receiverId, ctx.dataScope) - if (!allowed) { - return { success: false, message: "Recipient not allowed" } - } - - // P2-5: 屏蔽校验 —— 发送方或接收方任一屏蔽对方,则禁止发送 - const blocked = await isEitherUserBlocked(ctx.userId, input.receiverId) - if (blocked) { - return { success: false, message: "Cannot send message due to block" } - } - - // P0-6: 校验 parentMessageId 属于当前用户参与的会话 - // 防止将回复消息注入到他人会话线程 - if (input.parentMessageId) { - const parent = await getMessageById(input.parentMessageId, ctx.userId) - if (!parent) { - return { success: false, message: "Parent message not accessible" } - } - } - - const id = await createMessage({ - senderId: ctx.userId, - receiverId: input.receiverId, - subject: input.subject, - content: input.content, - parentMessageId: input.parentMessageId, - }) - - // Notify the receiver about the new message via the notifications dispatcher. - // This respects user notification preferences (SMS/WeChat/Email/In-App). - const t = await getTranslations("messages") - const notifyTitle = input.subject - ? t("notification.messageTitle", { subject: input.subject }) - : t("notification.messageTitleNoSubject") - await sendNotification({ - userId: input.receiverId, - type: "info", - title: notifyTitle, - content: input.content.slice(0, 200), - actionUrl: `/messages/${id}`, - metadata: { messageType: "message", messageId: id }, - }) - - await invalidateFor("messaging.send", { id }) - - void trackEvent({ - event: "message.sent", - userId: ctx.userId, - targetId: id, - targetType: "message", - properties: { receiverId: input.receiverId, hasSubject: !!input.subject }, - }) - - return { success: true, message: "Message sent", data: id } - } catch (e) { - // P1-9: 失败埋点(权限拒绝除外,避免噪音) - if (ctx && parsedInput && !(e instanceof PermissionDeniedError)) { - void trackEvent({ - event: "message.send_failed", - userId: ctx.userId, - targetType: "message", - properties: { - receiverId: parsedInput.receiverId, - reason: e instanceof Error ? e.message : "unknown", - }, - }) - } - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } - } -} - -export async function markMessageAsReadAction(messageId: string): Promise> { - try { - const ctx = await requirePermission(Permissions.MESSAGE_READ) - - const parsed = MessageIdSchema.safeParse({ messageId }) - if (!parsed.success) { - return { success: false, message: "Invalid message id", errors: parsed.error.flatten().fieldErrors } - } - - await markMessageAsRead(parsed.data.messageId, ctx.userId) - await invalidateFor("messaging.markRead", { id: parsed.data.messageId }) - - void trackEvent({ - event: "message.marked_read", - userId: ctx.userId, - targetId: parsed.data.messageId, - targetType: "message", - }) - - return { success: true, message: "Marked as read" } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } - } -} - -export async function deleteMessageAction(messageId: string): Promise> { - try { - const ctx = await requirePermission(Permissions.MESSAGE_DELETE) - - const parsed = MessageIdSchema.safeParse({ messageId }) - if (!parsed.success) { - return { success: false, message: "Invalid message id", errors: parsed.error.flatten().fieldErrors } - } - - await deleteMessage(parsed.data.messageId, ctx.userId) - await invalidateFor("messaging.delete", { id: parsed.data.messageId }) - - void trackEvent({ - event: "message.deleted", - userId: ctx.userId, - targetId: parsed.data.messageId, - targetType: "message", - }) - - return { success: true, message: "Message deleted" } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } - } -} - -export async function getMessagesAction( - params: { - type: MessageType - page?: number - pageSize?: number - keyword?: string - starredOnly?: boolean - /** P2-11: 按发件人筛选 */ - senderId?: string - /** P2-11: 按日期范围筛选 */ - dateFrom?: string - dateTo?: string - /** P2-11: 按已读状态筛选 */ - isRead?: boolean - } -): Promise> { - try { - const ctx = await requirePermission(Permissions.MESSAGE_READ) - const result = await getMessages({ userId: ctx.userId, ...params }) - return { success: true, data: result } - } 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" } - } -} - /** - * P1-3: 获取消息线程(根消息 + 回复链)。 + * 私信 Server Actions(barrel 重新导出) * - * 替代直接调用 data-access.getMessageThread,统一通过 Server Action 入口 - * 做权限校验。返回的 Message[] 已按时间倒序(与 data-access 一致), - * UI 层可按需 reverse 为正序展示。 - * - * 注:原 getMessageDetailAction 已删除(P0-4 死代码,详情数据由 - * getMessageDetailPageData 编排函数 + RSC 直接获取,组件通过 props 接收)。 + * 原 actions.ts 973 行接近 1000 行硬上限,拆分为 5 个按职责划分的子文件 + * (与 data-access 拆分对齐)。本文件仅作为公共 API 入口,保持向后兼容。 + * 通知相关 Server Actions 已迁移至 @/modules/notifications/actions。 */ -export async function getMessageThreadAction(messageId: string): Promise> { - try { - const ctx = await requirePermission(Permissions.MESSAGE_READ) - const parsed = MessageIdSchema.safeParse({ messageId }) - if (!parsed.success) { - return { success: false, message: "Invalid message id", errors: parsed.error.flatten().fieldErrors } - } - - // data-access.getMessageThread 内部会校验当前用户对根消息的访问权 - const thread = await getMessageThread(parsed.data.messageId, ctx.userId) - return { success: true, data: thread } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } - } -} - -export async function getRecipientsAction(): Promise> { - try { - const ctx = await requirePermission(Permissions.MESSAGE_SEND) - const recipients = await getRecipients(ctx.userId, ctx.dataScope) - return { success: true, data: recipients } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } - } -} - -export async function getUnreadMessageCountAction(): Promise> { - try { - const ctx = await requirePermission(Permissions.MESSAGE_READ) - const count = await getUnreadMessageCount(ctx.userId) - return { success: true, data: count } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } - } -} - -// --------------------------------------------------------------------------- -// V2-P2-13c: 消息星标 Server Actions -// --------------------------------------------------------------------------- - -export async function toggleMessageStarAction(messageId: string): Promise> { - try { - const ctx = await requirePermission(Permissions.MESSAGE_READ) - - const parsed = MessageIdSchema.safeParse({ messageId }) - if (!parsed.success) { - return { success: false, message: "Invalid message id", errors: parsed.error.flatten().fieldErrors } - } - - await toggleMessageStar(parsed.data.messageId, ctx.userId) - await invalidateFor("messaging.toggleStar", { id: parsed.data.messageId }) - - void trackEvent({ - event: "message.star_toggled", - userId: ctx.userId, - targetId: parsed.data.messageId, - targetType: "message", - }) - - return { success: true, message: "Star toggled" } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } - } -} - -// --------------------------------------------------------------------------- -// V2-P2-13c: 消息草稿 Server Actions -// --------------------------------------------------------------------------- - -export async function getMessageDraftsAction(): Promise> { - try { - const ctx = await requirePermission(Permissions.MESSAGE_SEND) - const drafts = await getMessageDrafts(ctx.userId) - return { success: true, data: drafts } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } - } -} - -export async function saveMessageDraftAction( - prevState: ActionState | null, - formData: FormData -): Promise> { - try { - const ctx = await requirePermission(Permissions.MESSAGE_SEND) - - const parsed = MessageDraftSchema.safeParse({ - draftId: formData.get("draftId") ?? undefined, - receiverId: formData.get("receiverId") ?? undefined, - subject: formData.get("subject") ?? undefined, - content: formData.get("content") ?? undefined, - parentMessageId: formData.get("parentMessageId") ?? undefined, - }) - - if (!parsed.success) { - return { success: false, message: "Invalid form data", errors: parsed.error.flatten().fieldErrors } - } - - const input = parsed.data - // P2-10: 从 FormData 读取 deviceId 和 expectedVersion(可选) - const deviceId = (formData.get("deviceId") as string | null) ?? null - const expectedVersionRaw = formData.get("expectedVersion") - const expectedVersion = expectedVersionRaw ? Number(expectedVersionRaw) : undefined - - const draftData = { - receiverId: input.receiverId || null, - subject: input.subject || null, - content: input.content || null, - parentMessageId: input.parentMessageId || null, - deviceId, - expectedVersion, - } - - if (input.draftId) { - // P2-10: updateMessageDraft 现在返回 "ok" | "not_found" | "conflict" - const result = await updateMessageDraft(input.draftId, ctx.userId, draftData) - if (result === "conflict") { - // P2-10: 版本冲突,客户端应拉取最新版本 - return { success: false, message: "Draft version conflict" } - } - if (result === "not_found") { - // P2-10: 草稿不存在(可能已在其他设备删除),重新创建 - const id = await createMessageDraft({ - userId: ctx.userId, - receiverId: draftData.receiverId, - subject: draftData.subject, - content: draftData.content, - parentMessageId: draftData.parentMessageId, - deviceId: draftData.deviceId, - }) - return { success: true, message: "Draft recreated", data: id } - } - return { success: true, message: "Draft saved", data: input.draftId } - } - - const id = await createMessageDraft({ - userId: ctx.userId, - receiverId: draftData.receiverId, - subject: draftData.subject, - content: draftData.content, - parentMessageId: draftData.parentMessageId, - deviceId: draftData.deviceId, - }) - - return { success: true, message: "Draft created", data: id } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } - } -} - -export async function deleteMessageDraftAction(draftId: string): Promise> { - try { - const ctx = await requirePermission(Permissions.MESSAGE_SEND) - - await deleteMessageDraft(draftId, ctx.userId) - await invalidateFor("messaging.draft.delete") - - return { success: true, message: "Draft deleted" } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } - } -} - -// --------------------------------------------------------------------------- -// P2-1: 批量操作 Server Actions -// --------------------------------------------------------------------------- - -export async function bulkMarkMessagesAsReadAction( - ids: string[] -): Promise> { - try { - const ctx = await requirePermission(Permissions.MESSAGE_READ) - - const parsed = BulkMessageIdsSchema.safeParse({ ids }) - if (!parsed.success) { - return { success: false, message: "Invalid message IDs", errors: parsed.error.flatten().fieldErrors } - } - - const updated = await bulkMarkMessagesAsRead(parsed.data.ids, ctx.userId) - await invalidateFor("messaging.bulkMarkRead") - - void trackEvent({ - event: "message.marked_read", - userId: ctx.userId, - targetType: "message", - properties: { count: updated, bulk: true }, - }) - - return { success: true, message: "Messages marked as read", data: { updated } } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } - } -} - -export async function bulkDeleteMessagesAction( - ids: string[] -): Promise> { - try { - const ctx = await requirePermission(Permissions.MESSAGE_DELETE) - - const parsed = BulkMessageIdsSchema.safeParse({ ids }) - if (!parsed.success) { - return { success: false, message: "Invalid message IDs", errors: parsed.error.flatten().fieldErrors } - } - - await bulkDeleteMessages(parsed.data.ids, ctx.userId) - await invalidateFor("messaging.bulkDelete") - - void trackEvent({ - event: "message.deleted", - userId: ctx.userId, - targetType: "message", - properties: { count: parsed.data.ids.length, bulk: true }, - }) - - return { success: true, message: "Messages deleted" } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } - } -} - -export async function bulkToggleMessagesStarAction( - ids: string[] -): Promise> { - try { - const ctx = await requirePermission(Permissions.MESSAGE_READ) - - const parsed = BulkMessageIdsSchema.safeParse({ ids }) - if (!parsed.success) { - return { success: false, message: "Invalid message IDs", errors: parsed.error.flatten().fieldErrors } - } - - await bulkToggleMessagesStar(parsed.data.ids, ctx.userId) - await invalidateFor("messaging.bulkToggleStar") - - void trackEvent({ - event: "message.star_toggled", - userId: ctx.userId, - targetType: "message", - properties: { count: parsed.data.ids.length, bulk: true }, - }) - - return { success: true, message: "Star toggled" } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } - } -} - -// --------------------------------------------------------------------------- -// P2-4: 消息撤回 Server Action(2 分钟窗口内仅发送方可撤回) -// --------------------------------------------------------------------------- - -export async function recallMessageAction( - messageId: string -): Promise> { - try { - const ctx = await requirePermission(Permissions.MESSAGE_SEND) - - const parsed = MessageIdSchema.safeParse({ messageId }) - if (!parsed.success) { - return { success: false, message: "Invalid message id", errors: parsed.error.flatten().fieldErrors } - } - - const result = await recallMessage(parsed.data.messageId, ctx.userId) - - if (result === "not_found") { - return { success: false, message: "Message not found or not owned" } - } - if (result === "already_recalled") { - return { success: false, message: "Message already recalled" } - } - if (result === "expired") { - return { success: false, message: "Recall window expired" } - } - - await invalidateFor("messaging.recall", { id: parsed.data.messageId }) - - void trackEvent({ - event: "message.recalled", - userId: ctx.userId, - targetId: parsed.data.messageId, - targetType: "message", - }) - - return { success: true, message: "Message recalled" } - } 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" } - } -} - -// --------------------------------------------------------------------------- -// P2-2: 群组消息 Server Action(教师→全班家长,fan-out on write) -// --------------------------------------------------------------------------- - -export async function sendGroupMessageAction( - prevState: ActionState<{ recipientCount: number }> | null, - formData: FormData -): Promise> { - let ctx: AuthContext | null = null - let parsedInput: { classId: string; subject: string | null; content: string } | null = null - try { - ctx = await requirePermission(Permissions.MESSAGE_SEND) - - const parsed = SendGroupMessageSchema.safeParse({ - classId: formData.get("classId"), - subject: formData.get("subject") || undefined, - content: formData.get("content"), - }) - - if (!parsed.success) { - return { success: false, message: "Invalid form data", errors: parsed.error.flatten().fieldErrors } - } - - const input = parsed.data - parsedInput = input - - // P2-2: 校验当前教师是否有权向该班级群发(必须在其 class_taught 范围内) - const allowed = ctx.dataScope.type === "class_taught" - && ctx.dataScope.classIds.includes(input.classId) - if (!allowed) { - return { success: false, message: "Not allowed to send group message to this class" } - } - - const result = await sendGroupMessage({ - senderId: ctx.userId, - classId: input.classId, - subject: input.subject, - content: input.content, - }) - - if (result.recipientIds.length === 0) { - return { success: false, message: "No recipients found in this class" } - } - - // 通知每个家长 - const t = await getTranslations("messages") - const notifyTitle = input.subject - ? t("notification.messageTitle", { subject: input.subject }) - : t("notification.messageTitleNoSubject") - const notifyContent = input.content.slice(0, 200) - - await Promise.all( - result.recipientIds.map((parentId) => - sendNotification({ - userId: parentId, - type: "info", - title: notifyTitle, - content: notifyContent, - actionUrl: "/messages", - metadata: { messageType: "group_message", groupMessageId: result.groupMessageId }, - }) - ) - ) - - await invalidateFor("messaging.sendGroup") - - void trackEvent({ - event: "message.group_sent", - userId: ctx.userId, - targetType: "message", - properties: { - classId: input.classId, - recipientCount: result.recipientIds.length, - groupMessageId: result.groupMessageId, - }, - }) - - return { - success: true, - message: "Group message sent", - data: { recipientCount: result.recipientIds.length }, - } - } catch (e) { - // P1-9: 失败埋点(权限拒绝除外) - if (ctx && parsedInput && !(e instanceof PermissionDeniedError)) { - void trackEvent({ - event: "message.group_send_failed", - userId: ctx.userId, - targetType: "message", - properties: { - classId: parsedInput.classId, - reason: e instanceof Error ? e.message : "unknown", - }, - }) - } - 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" } - } -} - -// --------------------------------------------------------------------------- -// P2-6: 快捷回复模板 Server Actions -// --------------------------------------------------------------------------- - -export async function getMessageTemplatesAction(): Promise> { - try { - const ctx = await requirePermission(Permissions.MESSAGE_SEND) - const templates = await getMessageTemplates(ctx.userId) - return { success: true, data: templates } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } - } -} - -export async function saveMessageTemplateAction( - prevState: ActionState | null, - formData: FormData -): Promise> { - try { - const ctx = await requirePermission(Permissions.MESSAGE_SEND) - - const parsed = MessageTemplateSchema.safeParse({ - templateId: formData.get("templateId") ?? undefined, - title: formData.get("title"), - content: formData.get("content"), - sortOrder: formData.get("sortOrder") !== null - ? Number(formData.get("sortOrder")) - : undefined, - }) - - if (!parsed.success) { - return { success: false, message: "Invalid form data", errors: parsed.error.flatten().fieldErrors } - } - - const input = parsed.data - - if (input.templateId) { - await updateMessageTemplate(input.templateId, ctx.userId, { - title: input.title, - content: input.content, - sortOrder: input.sortOrder, - }) - await invalidateFor("messaging.template.save", { id: input.templateId }) - return { success: true, message: "Template updated", data: input.templateId } - } - - const id = await createMessageTemplate({ - userId: ctx.userId, - title: input.title, - content: input.content, - sortOrder: input.sortOrder, - }) - - await invalidateFor("messaging.template.save", { id }) - - void trackEvent({ - event: "message.template_created", - userId: ctx.userId, - targetId: id, - targetType: "message", - }) - - return { success: true, message: "Template created", data: id } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } - } -} - -export async function deleteMessageTemplateAction(templateId: string): Promise> { - try { - const ctx = await requirePermission(Permissions.MESSAGE_SEND) - - const parsed = MessageIdSchema.safeParse({ messageId: templateId }) - if (!parsed.success) { - return { success: false, message: "Invalid template id", errors: parsed.error.flatten().fieldErrors } - } - - await deleteMessageTemplate(parsed.data.messageId, ctx.userId) - await invalidateFor("messaging.template.delete", { id: parsed.data.messageId }) - - return { success: true, message: "Template deleted" } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } - } -} - -// --------------------------------------------------------------------------- -// 通知相关 Server Actions(getNotificationsAction / markNotificationAsReadAction / -// markAllNotificationsAsReadAction / getUnreadNotificationCountAction / -// getNotificationPreferencesAction / updateNotificationPreferencesAction) -// 已迁移至 @/modules/notifications/actions。请直接从 notifications 模块导入。 -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// P2-5: 消息举报 + 用户屏蔽 Server Actions -// --------------------------------------------------------------------------- - -/** - * P2-5: 举报消息。 - * - * - 校验消息存在且当前用户为接收方(只能举报收到的消息) - * - 防重复:同一用户对同一消息只能举报一次 - */ -export async function reportMessageAction( - prevState: ActionState | null, - formData: FormData -): Promise> { - let ctx: AuthContext | null = null - let parsedInput: { messageId: string; reason: string; description?: string } | null = null - try { - ctx = await requirePermission(Permissions.MESSAGE_READ) - - const parsed = ReportMessageSchema.safeParse({ - messageId: formData.get("messageId"), - reason: formData.get("reason"), - description: formData.get("description") || undefined, - }) - - if (!parsed.success) { - return { success: false, message: "Invalid form data", errors: parsed.error.flatten().fieldErrors } - } - - const input = parsed.data - parsedInput = input - - // 校验消息存在且当前用户为接收方(防越权举报) - const msg = await getMessageById(input.messageId, ctx.userId) - if (!msg) { - return { success: false, message: "Message not found" } - } - // 举报人必须是接收方(不能举报自己发的消息) - if (msg.senderId === ctx.userId) { - return { success: false, message: "Cannot report your own message" } - } - - const result = await reportMessage({ - reporterId: ctx.userId, - messageId: input.messageId, - reportedUserId: msg.senderId, - reason: input.reason as "spam" | "harassment" | "inappropriate" | "other", - description: input.description ?? null, - }) - - if (result === "already_reported") { - return { success: false, message: "Already reported this message" } - } - - void trackEvent({ - event: "message.reported", - userId: ctx.userId, - targetId: input.messageId, - targetType: "message", - properties: { reason: input.reason }, - }) - - return { success: true, message: "Message reported", data: null } - } catch (e) { - if (ctx && parsedInput && !(e instanceof PermissionDeniedError)) { - void trackEvent({ - event: "message.report_failed", - userId: ctx.userId, - targetId: parsedInput.messageId, - targetType: "message", - properties: { reason: parsedInput.reason }, - }) - } - 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" } - } -} - -/** - * P2-5: 检查当前用户是否已举报某条消息(用于 UI 禁用状态)。 - */ -export async function checkReportedAction(messageId: string): Promise> { - try { - const ctx = await requirePermission(Permissions.MESSAGE_READ) - const reported = await hasUserReportedMessage(ctx.userId, messageId) - return { success: true, data: reported } - } 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" } - } -} - -/** - * P2-5: 屏蔽用户。 - */ -export async function blockUserAction( - prevState: ActionState | null, - formData: FormData -): Promise> { - try { - const ctx = await requirePermission(Permissions.MESSAGE_SEND) - - const parsed = BlockUserSchema.safeParse({ - blockedId: formData.get("blockedId"), - }) - - if (!parsed.success) { - return { success: false, message: "Invalid form data", errors: parsed.error.flatten().fieldErrors } - } - - const input = parsed.data - - const result = await blockUser(ctx.userId, input.blockedId) - - if (result === "self_block") { - return { success: false, message: "Cannot block yourself" } - } - if (result === "already_blocked") { - return { success: false, message: "User already blocked" } - } - - await invalidateFor("messaging.block", { id: input.blockedId }) - - void trackEvent({ - event: "message.user_blocked", - userId: ctx.userId, - targetId: input.blockedId, - targetType: "user", - }) - - return { success: true, message: "User blocked", data: null } - } 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" } - } -} - -/** - * P2-5: 取消屏蔽用户。 - */ -export async function unblockUserAction(blockedId: string): Promise> { - try { - const ctx = await requirePermission(Permissions.MESSAGE_SEND) - await unblockUser(ctx.userId, blockedId) - await invalidateFor("messaging.unblock", { id: blockedId }) - - void trackEvent({ - event: "message.user_unblocked", - userId: ctx.userId, - targetId: blockedId, - targetType: "user", - }) - - return { success: true, message: "User unblocked", data: null } - } 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" } - } -} - -/** - * P2-5: 获取当前用户的屏蔽列表。 - */ -export async function getUserBlocksAction(): Promise> { - try { - const ctx = await requirePermission(Permissions.MESSAGE_SEND) - const blocks = await getUserBlocks(ctx.userId) - return { success: true, data: blocks } - } 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" } - } -} - -// --------------------------------------------------------------------------- -// P2-3: 消息附件 Server Actions -// --------------------------------------------------------------------------- - -/** - * P2-3: 获取指定消息的附件列表。 - * - * 校验当前用户对消息有访问权(必须是发送方或接收方),防止越权访问附件。 - * 附件存储复用 files 模块的 fileAttachments 表(targetType="message")。 - */ -export async function getMessageAttachmentsAction( - messageId: string -): Promise> { - try { - const ctx = await requirePermission(Permissions.MESSAGE_READ) - // 校验访问权:必须是消息的发送方或接收方 - const msg = await getMessageById(messageId, ctx.userId) - if (!msg) { - return { success: false, message: "Message not found" } - } - const attachments = await getMessageAttachments(messageId) - return { success: true, data: attachments } - } catch (e) { - if (e instanceof PermissionDeniedError) return { success: false, message: e.message } - if (e instanceof Error) return { success: false, message: e.message } - return { success: false, message: "Unexpected error" } - } -} +export { + sendMessageAction, + markMessageAsReadAction, + deleteMessageAction, + getMessagesAction, + getMessageThreadAction, + getRecipientsAction, + getUnreadMessageCountAction, + toggleMessageStarAction, + recallMessageAction, + getMessageAttachmentsAction, +} from "./actions-core" + +export { + bulkMarkMessagesAsReadAction, + bulkDeleteMessagesAction, + bulkToggleMessagesStarAction, +} from "./actions-bulk" + +export { sendGroupMessageAction } from "./actions-group" + +export { + getMessageDraftsAction, + saveMessageDraftAction, + deleteMessageDraftAction, + getMessageTemplatesAction, + saveMessageTemplateAction, + deleteMessageTemplateAction, +} from "./actions-templates" + +export { + reportMessageAction, + checkReportedAction, + blockUserAction, + unblockUserAction, + getUserBlocksAction, +} from "./actions-reports" diff --git a/src/modules/questions/data-access-cross-module.ts b/src/modules/questions/data-access-cross-module.ts new file mode 100644 index 0000000..b782c24 --- /dev/null +++ b/src/modules/questions/data-access-cross-module.ts @@ -0,0 +1,170 @@ +import 'server-only'; + +import { db } from "@/shared/db"; +import { questions, questionsToKnowledgePoints } from "@/shared/db/schema"; +import { count, inArray } from "drizzle-orm"; +import { cacheFn } from "@/shared/lib/cache"; +import { getKnowledgePointNamesByIds } from "@/modules/textbooks/data-access"; + +// --------------------------------------------------------------------------- +// Cross-module query interfaces — read-only access for other modules +// --------------------------------------------------------------------------- + +export type QuestionKnowledgePoint = { + questionId: string + knowledgePointId: string + knowledgePointName: string +} + +/** Returns knowledge points associated with the given question ids. */ +export const getKnowledgePointsForQuestionsRaw = async ( + questionIds: string[] +): Promise> => { + const result = new Map() + const uniqueIds = Array.from(new Set(questionIds.filter((v): v is string => typeof v === "string" && v.length > 0))) + if (uniqueIds.length === 0) return result + + // 1. 查询 questionId -> knowledgePointId 关联(questions 模块自有表) + const rows = await db + .select({ + questionId: questionsToKnowledgePoints.questionId, + knowledgePointId: questionsToKnowledgePoints.knowledgePointId, + }) + .from(questionsToKnowledgePoints) + .where(inArray(questionsToKnowledgePoints.questionId, uniqueIds)) + + if (rows.length === 0) return result + + // 2. 跨模块批量解析知识点名称,避免直接 JOIN knowledgePoints 表 + const kpIds = Array.from(new Set(rows.map((r) => r.knowledgePointId))) + const kpNameMap = await getKnowledgePointNamesByIds(kpIds) + + // 3. 组装结果 + for (const r of rows) { + const list = result.get(r.questionId) ?? [] + list.push({ + questionId: r.questionId, + knowledgePointId: r.knowledgePointId, + knowledgePointName: kpNameMap.get(r.knowledgePointId) ?? "", + }) + result.set(r.questionId, list) + } + return result +} +export const getKnowledgePointsForQuestions = cacheFn(getKnowledgePointsForQuestionsRaw, { + tags: ["questions"], + ttl: 300, + keyParts: ["questions", "getKnowledgePointsForQuestions"], +}) + +/** + * 跨模块接口:获取题目内容与类型(供 error-book 模块提取正确答案使用)。 + * + * error-book 模块在采集错题时需要从题目内容中提取正确答案(correctAnswer), + * 此接口返回题目的 content 和 type,由 error-book 模块调用 + * `homework/lib/question-content-utils.ts` 中的纯函数进行提取。 + */ +export type QuestionContentForErrorCollection = { + content: unknown + type: string +} + +export const getQuestionsContentForErrorCollectionRaw = async ( + questionIds: string[], +): Promise> => { + const result = new Map() + const uniqueIds = Array.from(new Set(questionIds.filter((v): v is string => typeof v === "string" && v.length > 0))) + if (uniqueIds.length === 0) return result + + const rows = await db + .select({ + id: questions.id, + content: questions.content, + type: questions.type, + }) + .from(questions) + .where(inArray(questions.id, uniqueIds)) + + for (const r of rows) { + result.set(r.id, { content: r.content, type: r.type }) + } + return result +} +export const getQuestionsContentForErrorCollection = cacheFn( + getQuestionsContentForErrorCollectionRaw, + { + tags: ["questions"], + ttl: 300, + keyParts: ["questions", "getQuestionsContentForErrorCollection"], + }, +) + +/** + * 按 ID 批量获取题目类型映射。 + * + * exams 等模块在组卷/成绩录入时需要题目 type 字段,此接口封装对 questions 表的查询, + * 避免跨模块直接 JOIN questions 表(违反三层架构)。 + * + * 返回 Map,未找到的 ID 不会出现在 Map 中。 + */ +export const getQuestionTypeMapByIdsRaw = async ( + questionIds: string[], +): Promise> => { + const result = new Map() + const uniqueIds = Array.from(new Set(questionIds.filter((v): v is string => typeof v === "string" && v.length > 0))) + if (uniqueIds.length === 0) return result + + const rows = await db + .select({ id: questions.id, type: questions.type }) + .from(questions) + .where(inArray(questions.id, uniqueIds)) + + for (const r of rows) { + result.set(r.id, r.type) + } + return result +} +export const getQuestionTypeMapByIds = cacheFn(getQuestionTypeMapByIdsRaw, { + tags: ["questions"], + ttl: 300, + keyParts: ["questions", "getQuestionTypeMapByIds"], +}) + +// --------------------------------------------------------------------------- +// Cross-module batch interfaces — question count per knowledge point +// --------------------------------------------------------------------------- + +/** + * 跨模块接口:按知识点 ID 批量获取关联题目数。 + * + * 供 textbooks 模块知识图谱使用,避免直接查询 questionsToKnowledgePoints 表。 + * + * @param knowledgePointIds 知识点 ID 列表 + * @returns Map + */ +export const getQuestionCountByKpIdsRaw = async ( + knowledgePointIds: string[] +): Promise> => { + const result = new Map() + const uniqueIds = Array.from(new Set(knowledgePointIds.filter((v): v is string => typeof v === "string" && v.length > 0))) + if (uniqueIds.length === 0) return result + + const rows = await db + .select({ + knowledgePointId: questionsToKnowledgePoints.knowledgePointId, + count: count(), + }) + .from(questionsToKnowledgePoints) + .where(inArray(questionsToKnowledgePoints.knowledgePointId, uniqueIds)) + .groupBy(questionsToKnowledgePoints.knowledgePointId) + + for (const r of rows) { + result.set(r.knowledgePointId, Number(r.count)) + } + return result +} +export const getQuestionCountByKpIds = cacheFn(getQuestionCountByKpIdsRaw, { + tags: ["questions"], + ttl: 300, + keyParts: ["questions", "getQuestionCountByKpIds"], +}) diff --git a/src/modules/questions/data-access-import-export.ts b/src/modules/questions/data-access-import-export.ts new file mode 100644 index 0000000..a8b72f8 --- /dev/null +++ b/src/modules/questions/data-access-import-export.ts @@ -0,0 +1,121 @@ +import 'server-only'; + +import { db } from "@/shared/db"; +import { questions } from "@/shared/db/schema"; +import { and, desc, eq, inArray, sql, type SQL } from "drizzle-orm"; +import { cacheFn } from "@/shared/lib/cache"; +import type { QuestionType } from "./types"; +import { insertQuestionWithRelations } from "./data-access-questions"; + +// --------------------------------------------------------------------------- +// 导入/导出接口 +// --------------------------------------------------------------------------- + +/** 导出题目格式(JSON) */ +export interface QuestionExportItem { + id: string + type: QuestionType + difficulty: number + content: unknown + knowledgePoints: { id: string; name: string }[] + createdAt: Date + updatedAt: Date +} + +/** + * 导出题目为结构化 JSON 格式。 + * + * 支持按 IDs 导出指定题目,或导出全部题目(受 pageSize 限制)。 + * 遵循权限范围:非 all 范围只能导出自己创建的题目。 + */ +export async function exportQuestionsRaw( + questionIds?: string[], + canExportAll = true, + authorId?: string +): Promise { + const conditions: SQL[] = [] + + if (questionIds && questionIds.length > 0) { + conditions.push(inArray(questions.id, questionIds)) + } + + if (!canExportAll && authorId) { + conditions.push(eq(questions.authorId, authorId)) + } + + conditions.push(sql`${questions.parentId} IS NULL`) + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined + + const rows = await db.query.questions.findMany({ + where: whereClause, + // P3-8: LIMIT 从 1000 调低至 100,避免单次导出拉取过多记录 + limit: 100, + orderBy: [desc(questions.createdAt)], + with: { + knowledgePoints: { + with: { + knowledgePoint: true, + }, + }, + }, + }) + + return rows.map((row) => ({ + id: row.id, + type: row.type, + difficulty: row.difficulty ?? 1, + content: row.content, + knowledgePoints: (row.knowledgePoints ?? []).map((rel) => ({ + id: rel.knowledgePoint.id, + name: rel.knowledgePoint.name, + })), + createdAt: row.createdAt, + updatedAt: row.updatedAt, + })) +} +export const exportQuestions = cacheFn(exportQuestionsRaw, { + tags: ["questions"], + ttl: 60, + keyParts: ["questions", "exportQuestions"], +}) + +/** 导入题目的单条输入 */ +export interface QuestionImportItem { + type: QuestionType + difficulty: number + content: unknown + knowledgePointIds?: string[] +} + +/** + * 批量导入题目。 + * + * 单事务保证原子性,任一题目导入失败则全部回滚。 + * 返回创建的题目 ID 列表。 + */ +export async function importQuestions( + items: QuestionImportItem[], + authorId: string +): Promise { + if (items.length === 0) return [] + + return await db.transaction(async (tx) => { + const createdIds: string[] = [] + for (const item of items) { + const id = await insertQuestionWithRelations( + tx, + { + content: item.content, + type: item.type, + difficulty: item.difficulty, + knowledgePointIds: item.knowledgePointIds, + }, + authorId, + null + ) + createdIds.push(id) + } + return createdIds + }) +} diff --git a/src/modules/questions/data-access-options.ts b/src/modules/questions/data-access-options.ts new file mode 100644 index 0000000..5639591 --- /dev/null +++ b/src/modules/questions/data-access-options.ts @@ -0,0 +1,88 @@ +import 'server-only'; + +import { cacheFn } from "@/shared/lib/cache"; +import { + getChaptersByTextbookId as getChaptersByTextbookIdFromTextbooks, + getKnowledgePointOptions as getKnowledgePointOptionsFromTextbooks, + getKnowledgePointsByChapterId as getKnowledgePointsByChapterIdFromTextbooks, + getTextbooks as getTextbooksFromTextbooks, +} from "@/modules/textbooks/data-access"; +import type { ChapterOption, KnowledgePointOption, TextbookOption } from "./types"; + +export async function getKnowledgePointOptionsRaw(): Promise { + // Delegate to textbooks module data-access to avoid direct queries on + // textbooks/chapters/knowledgePoints tables (owned by textbooks module). + return await getKnowledgePointOptionsFromTextbooks() +} +export const getKnowledgePointOptions = cacheFn(getKnowledgePointOptionsRaw, { + tags: ["questions"], + ttl: 600, + keyParts: ["questions", "getKnowledgePointOptions"], +}) + +/** + * 获取教材选项列表(级联筛选第一级)。 + * + * 委托 textbooks 模块 data-access 获取教材列表,避免直接查询 textbooks 表。 + */ +export async function getTextbookOptionsRaw(): Promise { + const textbooks = await getTextbooksFromTextbooks() + return textbooks.map((tb) => ({ + id: tb.id, + title: tb.title, + subject: tb.subject, + grade: tb.grade, + })) +} +export const getTextbookOptions = cacheFn(getTextbookOptionsRaw, { + tags: ["questions"], + ttl: 600, + keyParts: ["questions", "getTextbookOptions"], +}) + +/** + * 获取指定教材下的章节选项列表(级联筛选第二级)。 + * + * 委托 textbooks 模块 data-access 获取章节树,展平为选项列表。 + */ +export async function getChapterOptionsRaw(textbookId: string): Promise { + const chapters = await getChaptersByTextbookIdFromTextbooks(textbookId) + const options: ChapterOption[] = [] + + function flatten(chs: typeof chapters, depth = 0): void { + for (const ch of chs) { + options.push({ + id: ch.id, + title: ch.title, + parentId: ch.parentId ?? null, + depth, + }) + if (ch.children && ch.children.length > 0) { + flatten(ch.children, depth + 1) + } + } + } + + flatten(chapters) + return options +} +export const getChapterOptions = cacheFn(getChapterOptionsRaw, { + tags: ["questions"], + ttl: 600, + keyParts: ["questions", "getChapterOptions"], +}) + +/** + * 获取指定章节下的知识点选项列表(级联筛选第三级)。 + * + * 委托 textbooks 模块 data-access 获取知识点列表。 + */ +export async function getKnowledgePointOptionsByChapterRaw(chapterId: string): Promise<{ id: string; name: string }[]> { + const kps = await getKnowledgePointsByChapterIdFromTextbooks(chapterId) + return kps.map((kp) => ({ id: kp.id, name: kp.name })) +} +export const getKnowledgePointOptionsByChapter = cacheFn(getKnowledgePointOptionsByChapterRaw, { + tags: ["questions"], + ttl: 600, + keyParts: ["questions", "getKnowledgePointOptionsByChapter"], +}) diff --git a/src/modules/questions/data-access-questions.ts b/src/modules/questions/data-access-questions.ts new file mode 100644 index 0000000..1deedce --- /dev/null +++ b/src/modules/questions/data-access-questions.ts @@ -0,0 +1,469 @@ +import 'server-only'; + +import { db } from "@/shared/db"; +import { questions, questionsToKnowledgePoints } from "@/shared/db/schema"; +import { and, count, desc, eq, inArray, sql, type SQL } from "drizzle-orm"; +import { cacheFn } from "@/shared/lib/cache"; +import { createId } from "@paralleldrive/cuid2"; +import { + getKnowledgePointsByChapterId as getKnowledgePointsByChapterIdFromTextbooks, + getKnowledgePointsByTextbookId as getKnowledgePointsByTextbookIdFromTextbooks, +} from "@/modules/textbooks/data-access"; +import type { CreateQuestionInput } from "./schema"; +import type { Question, QuestionType } from "./types"; + +type Tx = Parameters[0]>[0] + +/** + * F-02: 将原始查询字符串转换为 MySQL FULLTEXT BOOLEAN MODE 查询格式。 + * 按空白拆分,转义 BOOLEAN MODE 特殊字符,每个词加 + 前缀和 * 后缀(前缀匹配)。 + * 例如 "math question" → "+math* +question*" + */ +function toBooleanModeQuery(q: string): string { + const words = q.trim().split(/\s+/).filter(Boolean) + if (words.length === 0) return "" + const escapeWord = (w: string): string => `+${w.replace(/[+\-<>()~*"']/g, " ").trim()}*` + return words.map(escapeWord).filter((w) => w !== "+*").join(" ") +} + +export type UpdateQuestionInput = { + type: QuestionType + difficulty: number + content: unknown + knowledgePointIds?: string[] +} + +export type GetQuestionsParams = { + q?: string; + page?: number; + pageSize?: number; + ids?: string[]; + knowledgePointId?: string; + textbookId?: string; + chapterId?: string; + type?: QuestionType; + difficulty?: number; +}; + +/** + * 分页查询题目列表,支持按 ID 集合、关键词全文检索、题目类型/难度、 + * 知识点/章节/教材级联筛选。 + * + * 级联筛选通过 textbooks 模块 data-access 解析知识点 ID 集合,避免直接查询 + * textbooks/chapters/knowledgePoints 表。子题(parentId 非 null)默认不返回, + * 除非通过 `ids` 显式指定。 + * + * @returns 包含 data 与 meta(page/pageSize/total/totalPages)的对象 + */ +export const getQuestionsRaw = async ({ + q, + page = 1, + pageSize = 50, + ids, + knowledgePointId, + textbookId, + chapterId, + type, + difficulty, +}: GetQuestionsParams = {}) => { + const safePage = typeof page === "number" && page >= 1 ? page : 1 + const safePageSize = typeof pageSize === "number" && pageSize > 0 ? pageSize : 50 + const offset = (safePage - 1) * safePageSize; + + const conditions: SQL[] = []; + + if (ids && ids.length > 0) { + conditions.push(inArray(questions.id, ids)); + } + + if (q && q.trim().length > 0) { + // F-02: 使用 FULLTEXT 索引(content_text 生成列)+ MATCH AGAINST 替代 LIKE '%xxx%' 全表扫描 + const booleanQuery = toBooleanModeQuery(q.trim()) + if (booleanQuery) { + conditions.push( + sql`MATCH(${questions.contentText}) AGAINST(${booleanQuery} IN BOOLEAN MODE)` + ) + } + } + + if (type) { + conditions.push(eq(questions.type, type)); + } + + if (difficulty) { + conditions.push(eq(questions.difficulty, difficulty)); + } + + // 级联筛选:通过 textbooks data-access 获取知识点 ID 集合, + // 再按知识点过滤题目。避免直接查询 textbooks/chapters 表。 + if (knowledgePointId) { + conditions.push( + inArray( + questions.id, + db + .select({ questionId: questionsToKnowledgePoints.questionId }) + .from(questionsToKnowledgePoints) + .where(eq(questionsToKnowledgePoints.knowledgePointId, knowledgePointId)) + ) + ); + } else if (chapterId) { + // 按章节筛选:获取该章节下所有知识点 ID + const kps = await getKnowledgePointsByChapterIdFromTextbooks(chapterId) + const kpIds = kps.map((kp) => kp.id) + if (kpIds.length > 0) { + conditions.push( + inArray( + questions.id, + db + .select({ questionId: questionsToKnowledgePoints.questionId }) + .from(questionsToKnowledgePoints) + .where(inArray(questionsToKnowledgePoints.knowledgePointId, kpIds)) + ) + ); + } else { + // 章节下无知识点,返回空结果 + conditions.push(sql`1 = 0`) + } + } else if (textbookId) { + // 按教材筛选:获取该教材下所有知识点 ID + const kps = await getKnowledgePointsByTextbookIdFromTextbooks(textbookId) + const kpIds = kps.map((kp) => kp.id) + if (kpIds.length > 0) { + conditions.push( + inArray( + questions.id, + db + .select({ questionId: questionsToKnowledgePoints.questionId }) + .from(questionsToKnowledgePoints) + .where(inArray(questionsToKnowledgePoints.knowledgePointId, kpIds)) + ) + ); + } else { + conditions.push(sql`1 = 0`) + } + } + + if (!ids || ids.length === 0) { + conditions.push(sql`${questions.parentId} IS NULL`) + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + const [totalResult] = await db + .select({ count: count() }) + .from(questions) + .where(whereClause); + + const total = Number(totalResult?.count ?? 0); + + const rows = await db.query.questions.findMany({ + where: whereClause, + limit: safePageSize, + offset: offset, + orderBy: [desc(questions.createdAt)], + with: { + knowledgePoints: { + with: { + knowledgePoint: true, + }, + }, + author: { + columns: { + id: true, + name: true, + image: true, + }, + }, + children: true, + }, + }); + + return { + data: rows.map((row) => { + const knowledgePoints = + row.knowledgePoints?.map((rel) => rel.knowledgePoint) ?? []; + + const author = row.author + ? { + id: row.author.id, + name: row.author.name, + image: row.author.image, + } + : null; + + const mapped: Question = { + id: row.id, + content: row.content, + type: row.type, + difficulty: row.difficulty ?? 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + author, + knowledgePoints: knowledgePoints.map((kp) => ({ id: kp.id, name: kp.name })), + childrenCount: row.children?.length ?? 0, + }; + + return mapped; + }), + meta: { + page: safePage, + pageSize: safePageSize, + total, + totalPages: Math.ceil(total / safePageSize), + }, + }; +}; +export const getQuestions = cacheFn(getQuestionsRaw, { + tags: ["questions"], + ttl: 300, + keyParts: ["questions", "getQuestions"], +}) + +export type QuestionsDashboardStats = { + questionCount: number +} + +/** + * 获取题目仪表盘统计(题目总数)。 + * + * @returns 包含 questionCount 的统计对象 + */ +export const getQuestionsDashboardStatsRaw = async (): Promise => { + const [row] = await db.select({ value: count() }).from(questions) + return { questionCount: Number(row?.value ?? 0) } +} +export const getQuestionsDashboardStats = cacheFn(getQuestionsDashboardStatsRaw, { + tags: ["questions"], + ttl: 300, + keyParts: ["questions", "getQuestionsDashboardStats"], +}) + +export async function insertQuestionWithRelations( + tx: Tx, + input: CreateQuestionInput, + authorId: string, + parentId: string | null = null +): Promise { + const newQuestionId = createId(); + + await tx.insert(questions).values({ + id: newQuestionId, + content: input.content, + type: input.type, + difficulty: input.difficulty, + authorId: authorId, + parentId: parentId, + }); + + if (input.knowledgePointIds && input.knowledgePointIds.length > 0) { + await tx.insert(questionsToKnowledgePoints).values( + input.knowledgePointIds.map((kpId) => ({ + questionId: newQuestionId, + knowledgePointId: kpId, + })) + ); + } + + if (input.subQuestions && input.subQuestions.length > 0) { + for (const subQ of input.subQuestions) { + await insertQuestionWithRelations(tx, subQ, authorId, newQuestionId); + } + } + + return newQuestionId; +} + +/** + * 创建题目(含子题与知识点关联),在单事务内递归插入。 + * + * 递归遍历 `input.subQuestions` 创建子题,parentId 指向父题 ID。 + * 任一子题插入失败则整个事务回滚。 + * + * @param input - 题目输入(content/type/difficulty/knowledgePointIds/subQuestions) + * @param authorId - 创建者用户 ID + * @returns 新创建的根题目 ID + */ +export async function createQuestionWithRelations( + input: CreateQuestionInput, + authorId: string +): Promise { + return await db.transaction(async (tx) => { + return await insertQuestionWithRelations(tx, input, authorId, null); + }); +} + +/** + * 按 ID 更新题目(content/type/difficulty)并替换知识点关联。 + * + * 在单事务内:1) 更新题目字段;2) 删除原 questionsToKnowledgePoints 关联; + * 3) 插入新关联。权限范围由 `canEditAll` 控制——非 all 范围只能更新自己创建的题目。 + * + * @param id - 待更新的题目 ID + * @param input - 更新输入(type/difficulty/content/knowledgePointIds) + * @param canEditAll - 是否有全部数据范围权限 + * @param authorId - 当前用户 ID(用于权限过滤) + */ +export async function updateQuestionById( + id: string, + input: UpdateQuestionInput, + canEditAll: boolean, + authorId: string +): Promise { + await db.transaction(async (tx) => { + await tx + .update(questions) + .set({ + type: input.type, + difficulty: input.difficulty, + content: input.content, + updatedAt: new Date(), + }) + .where( + canEditAll + ? eq(questions.id, id) + : and(eq(questions.id, id), eq(questions.authorId, authorId)) + ); + + await tx + .delete(questionsToKnowledgePoints) + .where(eq(questionsToKnowledgePoints.questionId, id)); + + if (input.knowledgePointIds && input.knowledgePointIds.length > 0) { + await tx.insert(questionsToKnowledgePoints).values( + input.knowledgePointIds.map((kpId) => ({ + questionId: id, + knowledgePointId: kpId, + })) + ); + } + }); +} + +/** + * 批量收集给定根题目 ID 的所有后代(含自身)ID。 + * + * 使用 BFS 逐层 `inArray` 查询替代原先每节点逐条 SELECT 的 N+1 模式: + * 每层只发 1 条 SQL,整体复杂度从 O(N) 降至 O(depth)。 + * `visited` 跨调用共享以避免循环引用造成的无限递归。 + */ +async function collectDescendantQuestionIds( + tx: Tx, + rootIds: string[], + visited: Set = new Set(), +): Promise { + const queue: string[] = [] + for (const id of rootIds) { + if (!visited.has(id)) { + visited.add(id) + queue.push(id) + } + } + + while (queue.length > 0) { + const currentLevel = queue.splice(0, queue.length) + const children = await tx + .select({ id: questions.id }) + .from(questions) + .where(inArray(questions.parentId, currentLevel)); + + for (const child of children) { + if (!visited.has(child.id)) { + visited.add(child.id) + queue.push(child.id) + } + } + } + + return Array.from(visited) +} + +async function deleteQuestionRecursive( + tx: Tx, + questionId: string, + visited: Set = new Set(), +): Promise { + // 收集所有后代 ID(含自身),单条 inArray 批量删除, + // 替代原先每层每子节点逐条 SELECT + DELETE 的 N+1 模式。 + // 环检测由 collectDescendantQuestionIds 内部的 visited Set 保证。 + const allIds = await collectDescendantQuestionIds(tx, [questionId], visited) + if (allIds.length === 0) return + await tx.delete(questions).where(inArray(questions.id, allIds)) +} + +/** + * 按 ID 递归删除题目(含所有子题)。 + * + * 在事务内:1) 查询题目并校验存在性;2) 校验权限(非 all 范围只能删除自己创建的题目); + * 3) 通过 BFS 收集所有后代 ID 后单条 `inArray` 批量删除。 + * + * @param questionId - 待删除的根题目 ID + * @param canDeleteAll - 是否有全部数据范围权限 + * @param authorId - 当前用户 ID(用于权限过滤) + * @throws 当题目不存在或无权删除时抛出 Error + */ +export async function deleteQuestionByIdRecursive( + questionId: string, + canDeleteAll: boolean, + authorId: string +): Promise { + await db.transaction(async (tx) => { + const q = await tx.query.questions.findFirst({ + where: eq(questions.id, questionId), + }); + + if (!q) { + throw new Error("Question not found"); + } + + if (!canDeleteAll && q.authorId !== authorId) { + throw new Error("Unauthorized"); + } + + await deleteQuestionRecursive(tx, questionId); + }); +} + +/** + * 批量删除题目(级联删除子题)。 + * + * 遵循权限范围:非 all 范围只能删除自己创建的题目。 + * 单事务保证原子性,任一题目删除失败则全部回滚。 + * + * @param questionIds - 待删除的题目 ID 列表 + * @param canDeleteAll - 是否有全部数据范围权限 + * @param authorId - 当前用户 ID(用于权限过滤) + * @returns 实际删除的题目数量 + */ +export async function deleteQuestionsBatch( + questionIds: string[], + canDeleteAll: boolean, + authorId: string +): Promise { + if (questionIds.length === 0) return 0 + + const uniqueIds = Array.from(new Set(questionIds)) + return await db.transaction(async (tx) => { + // 权限过滤:非 all 范围只能删除自己创建的题目 + const whereClause = canDeleteAll + ? inArray(questions.id, uniqueIds) + : and(inArray(questions.id, uniqueIds), eq(questions.authorId, authorId)) + + const targetRows = await tx + .select({ id: questions.id }) + .from(questions) + .where(whereClause) + + const targetIds = targetRows.map((r) => r.id) + if (targetIds.length === 0) return 0 + + // 一次性收集所有 targetIds 的后代(含自身),单条 inArray 删除, + // 替代原先循环调用 deleteQuestionRecursive 产生的 N×depth 查询。 + const visited = new Set() + const allIds = await collectDescendantQuestionIds(tx, targetIds, visited) + if (allIds.length > 0) { + await tx.delete(questions).where(inArray(questions.id, allIds)) + } + + return targetIds.length + }) +} diff --git a/src/modules/questions/data-access.ts b/src/modules/questions/data-access.ts index 42612f0..9403e3b 100644 --- a/src/modules/questions/data-access.ts +++ b/src/modules/questions/data-access.ts @@ -1,827 +1,48 @@ -import 'server-only'; +// Barrel export for questions data-access. +// 业务方仍通过 `@/modules/questions/data-access` 导入,子文件按职责拆分。 -import { db } from "@/shared/db"; -import { questions, questionsToKnowledgePoints } from "@/shared/db/schema"; -import { and, count, desc, eq, inArray, sql, type SQL } from "drizzle-orm"; -import { cacheFn } from "@/shared/lib/cache"; -import { createId } from "@paralleldrive/cuid2"; -import { - getChaptersByTextbookId as getChaptersByTextbookIdFromTextbooks, - getKnowledgePointOptions as getKnowledgePointOptionsFromTextbooks, - getKnowledgePointsByChapterId as getKnowledgePointsByChapterIdFromTextbooks, - getKnowledgePointsByTextbookId as getKnowledgePointsByTextbookIdFromTextbooks, - getTextbooks as getTextbooksFromTextbooks, - getKnowledgePointNamesByIds, -} from "@/modules/textbooks/data-access"; -import type { CreateQuestionInput } from "./schema"; -import type { ChapterOption, KnowledgePointOption, Question, QuestionType, TextbookOption } from "./types"; +export { + getQuestionsRaw, + getQuestions, + getQuestionsDashboardStatsRaw, + getQuestionsDashboardStats, + createQuestionWithRelations, + updateQuestionById, + deleteQuestionByIdRecursive, + deleteQuestionsBatch, + type UpdateQuestionInput, + type GetQuestionsParams, + type QuestionsDashboardStats, +} from "./data-access-questions"; -type Tx = Parameters[0]>[0] +export { + getKnowledgePointOptionsRaw, + getKnowledgePointOptions, + getTextbookOptionsRaw, + getTextbookOptions, + getChapterOptionsRaw, + getChapterOptions, + getKnowledgePointOptionsByChapterRaw, + getKnowledgePointOptionsByChapter, +} from "./data-access-options"; -/** - * F-02: 将原始查询字符串转换为 MySQL FULLTEXT BOOLEAN MODE 查询格式。 - * 按空白拆分,转义 BOOLEAN MODE 特殊字符,每个词加 + 前缀和 * 后缀(前缀匹配)。 - * 例如 "math question" → "+math* +question*" - */ -function toBooleanModeQuery(q: string): string { - const words = q.trim().split(/\s+/).filter(Boolean) - if (words.length === 0) return "" - const escapeWord = (w: string): string => `+${w.replace(/[+\-<>()~*"']/g, " ").trim()}*` - return words.map(escapeWord).filter((w) => w !== "+*").join(" ") -} - -export type UpdateQuestionInput = { - type: QuestionType - difficulty: number - content: unknown - knowledgePointIds?: string[] -} - -export type GetQuestionsParams = { - q?: string; - page?: number; - pageSize?: number; - ids?: string[]; - knowledgePointId?: string; - textbookId?: string; - chapterId?: string; - type?: QuestionType; - difficulty?: number; -}; - -/** - * 分页查询题目列表,支持按 ID 集合、关键词全文检索、题目类型/难度、 - * 知识点/章节/教材级联筛选。 - * - * 级联筛选通过 textbooks 模块 data-access 解析知识点 ID 集合,避免直接查询 - * textbooks/chapters/knowledgePoints 表。子题(parentId 非 null)默认不返回, - * 除非通过 `ids` 显式指定。 - * - * @returns 包含 data 与 meta(page/pageSize/total/totalPages)的对象 - */ -export const getQuestionsRaw = async ({ - q, - page = 1, - pageSize = 50, - ids, - knowledgePointId, - textbookId, - chapterId, - type, - difficulty, -}: GetQuestionsParams = {}) => { - const safePage = typeof page === "number" && page >= 1 ? page : 1 - const safePageSize = typeof pageSize === "number" && pageSize > 0 ? pageSize : 50 - const offset = (safePage - 1) * safePageSize; - - const conditions: SQL[] = []; - - if (ids && ids.length > 0) { - conditions.push(inArray(questions.id, ids)); - } - - if (q && q.trim().length > 0) { - // F-02: 使用 FULLTEXT 索引(content_text 生成列)+ MATCH AGAINST 替代 LIKE '%xxx%' 全表扫描 - const booleanQuery = toBooleanModeQuery(q.trim()) - if (booleanQuery) { - conditions.push( - sql`MATCH(${questions.contentText}) AGAINST(${booleanQuery} IN BOOLEAN MODE)` - ) - } - } - - if (type) { - conditions.push(eq(questions.type, type)); - } - - if (difficulty) { - conditions.push(eq(questions.difficulty, difficulty)); - } - - // 级联筛选:通过 textbooks data-access 获取知识点 ID 集合, - // 再按知识点过滤题目。避免直接查询 textbooks/chapters 表。 - if (knowledgePointId) { - conditions.push( - inArray( - questions.id, - db - .select({ questionId: questionsToKnowledgePoints.questionId }) - .from(questionsToKnowledgePoints) - .where(eq(questionsToKnowledgePoints.knowledgePointId, knowledgePointId)) - ) - ); - } else if (chapterId) { - // 按章节筛选:获取该章节下所有知识点 ID - const kps = await getKnowledgePointsByChapterIdFromTextbooks(chapterId) - const kpIds = kps.map((kp) => kp.id) - if (kpIds.length > 0) { - conditions.push( - inArray( - questions.id, - db - .select({ questionId: questionsToKnowledgePoints.questionId }) - .from(questionsToKnowledgePoints) - .where(inArray(questionsToKnowledgePoints.knowledgePointId, kpIds)) - ) - ); - } else { - // 章节下无知识点,返回空结果 - conditions.push(sql`1 = 0`) - } - } else if (textbookId) { - // 按教材筛选:获取该教材下所有知识点 ID - const kps = await getKnowledgePointsByTextbookIdFromTextbooks(textbookId) - const kpIds = kps.map((kp) => kp.id) - if (kpIds.length > 0) { - conditions.push( - inArray( - questions.id, - db - .select({ questionId: questionsToKnowledgePoints.questionId }) - .from(questionsToKnowledgePoints) - .where(inArray(questionsToKnowledgePoints.knowledgePointId, kpIds)) - ) - ); - } else { - conditions.push(sql`1 = 0`) - } - } - - if (!ids || ids.length === 0) { - conditions.push(sql`${questions.parentId} IS NULL`) - } - - const whereClause = conditions.length > 0 ? and(...conditions) : undefined; - - const [totalResult] = await db - .select({ count: count() }) - .from(questions) - .where(whereClause); - - const total = Number(totalResult?.count ?? 0); - - const rows = await db.query.questions.findMany({ - where: whereClause, - limit: safePageSize, - offset: offset, - orderBy: [desc(questions.createdAt)], - with: { - knowledgePoints: { - with: { - knowledgePoint: true, - }, - }, - author: { - columns: { - id: true, - name: true, - image: true, - }, - }, - children: true, - }, - }); - - return { - data: rows.map((row) => { - const knowledgePoints = - row.knowledgePoints?.map((rel) => rel.knowledgePoint) ?? []; - - const author = row.author - ? { - id: row.author.id, - name: row.author.name, - image: row.author.image, - } - : null; - - const mapped: Question = { - id: row.id, - content: row.content, - type: row.type, - difficulty: row.difficulty ?? 1, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - author, - knowledgePoints: knowledgePoints.map((kp) => ({ id: kp.id, name: kp.name })), - childrenCount: row.children?.length ?? 0, - }; - - return mapped; - }), - meta: { - page: safePage, - pageSize: safePageSize, - total, - totalPages: Math.ceil(total / safePageSize), - }, - }; -}; -export const getQuestions = cacheFn(getQuestionsRaw, { - tags: ["questions"], - ttl: 300, - keyParts: ["questions", "getQuestions"], -}) - -export type QuestionsDashboardStats = { - questionCount: number -} - -/** - * 获取题目仪表盘统计(题目总数)。 - * - * @returns 包含 questionCount 的统计对象 - */ -export const getQuestionsDashboardStatsRaw = async (): Promise => { - const [row] = await db.select({ value: count() }).from(questions) - return { questionCount: Number(row?.value ?? 0) } -} -export const getQuestionsDashboardStats = cacheFn(getQuestionsDashboardStatsRaw, { - tags: ["questions"], - ttl: 300, - keyParts: ["questions", "getQuestionsDashboardStats"], -}) - -async function insertQuestionWithRelations( - tx: Tx, - input: CreateQuestionInput, - authorId: string, - parentId: string | null = null -): Promise { - const newQuestionId = createId(); - - await tx.insert(questions).values({ - id: newQuestionId, - content: input.content, - type: input.type, - difficulty: input.difficulty, - authorId: authorId, - parentId: parentId, - }); - - if (input.knowledgePointIds && input.knowledgePointIds.length > 0) { - await tx.insert(questionsToKnowledgePoints).values( - input.knowledgePointIds.map((kpId) => ({ - questionId: newQuestionId, - knowledgePointId: kpId, - })) - ); - } - - if (input.subQuestions && input.subQuestions.length > 0) { - for (const subQ of input.subQuestions) { - await insertQuestionWithRelations(tx, subQ, authorId, newQuestionId); - } - } - - return newQuestionId; -} - -/** - * 创建题目(含子题与知识点关联),在单事务内递归插入。 - * - * 递归遍历 `input.subQuestions` 创建子题,parentId 指向父题 ID。 - * 任一子题插入失败则整个事务回滚。 - * - * @param input - 题目输入(content/type/difficulty/knowledgePointIds/subQuestions) - * @param authorId - 创建者用户 ID - * @returns 新创建的根题目 ID - */ -export async function createQuestionWithRelations( - input: CreateQuestionInput, - authorId: string -): Promise { - return await db.transaction(async (tx) => { - return await insertQuestionWithRelations(tx, input, authorId, null); - }); -} - -/** - * 按 ID 更新题目(content/type/difficulty)并替换知识点关联。 - * - * 在单事务内:1) 更新题目字段;2) 删除原 questionsToKnowledgePoints 关联; - * 3) 插入新关联。权限范围由 `canEditAll` 控制——非 all 范围只能更新自己创建的题目。 - * - * @param id - 待更新的题目 ID - * @param input - 更新输入(type/difficulty/content/knowledgePointIds) - * @param canEditAll - 是否有全部数据范围权限 - * @param authorId - 当前用户 ID(用于权限过滤) - */ -export async function updateQuestionById( - id: string, - input: UpdateQuestionInput, - canEditAll: boolean, - authorId: string -): Promise { - await db.transaction(async (tx) => { - await tx - .update(questions) - .set({ - type: input.type, - difficulty: input.difficulty, - content: input.content, - updatedAt: new Date(), - }) - .where( - canEditAll - ? eq(questions.id, id) - : and(eq(questions.id, id), eq(questions.authorId, authorId)) - ); - - await tx - .delete(questionsToKnowledgePoints) - .where(eq(questionsToKnowledgePoints.questionId, id)); - - if (input.knowledgePointIds && input.knowledgePointIds.length > 0) { - await tx.insert(questionsToKnowledgePoints).values( - input.knowledgePointIds.map((kpId) => ({ - questionId: id, - knowledgePointId: kpId, - })) - ); - } - }); -} - -/** - * 批量收集给定根题目 ID 的所有后代(含自身)ID。 - * - * 使用 BFS 逐层 `inArray` 查询替代原先每节点逐条 SELECT 的 N+1 模式: - * 每层只发 1 条 SQL,整体复杂度从 O(N) 降至 O(depth)。 - * `visited` 跨调用共享以避免循环引用造成的无限递归。 - */ -async function collectDescendantQuestionIds( - tx: Tx, - rootIds: string[], - visited: Set = new Set(), -): Promise { - const queue: string[] = [] - for (const id of rootIds) { - if (!visited.has(id)) { - visited.add(id) - queue.push(id) - } - } - - while (queue.length > 0) { - const currentLevel = queue.splice(0, queue.length) - const children = await tx - .select({ id: questions.id }) - .from(questions) - .where(inArray(questions.parentId, currentLevel)); - - for (const child of children) { - if (!visited.has(child.id)) { - visited.add(child.id) - queue.push(child.id) - } - } - } - - return Array.from(visited) -} - -async function deleteQuestionRecursive( - tx: Tx, - questionId: string, - visited: Set = new Set(), -): Promise { - // 收集所有后代 ID(含自身),单条 inArray 批量删除, - // 替代原先每层每子节点逐条 SELECT + DELETE 的 N+1 模式。 - // 环检测由 collectDescendantQuestionIds 内部的 visited Set 保证。 - const allIds = await collectDescendantQuestionIds(tx, [questionId], visited) - if (allIds.length === 0) return - await tx.delete(questions).where(inArray(questions.id, allIds)) -} - -/** - * 按 ID 递归删除题目(含所有子题)。 - * - * 在事务内:1) 查询题目并校验存在性;2) 校验权限(非 all 范围只能删除自己创建的题目); - * 3) 通过 BFS 收集所有后代 ID 后单条 `inArray` 批量删除。 - * - * @param questionId - 待删除的根题目 ID - * @param canDeleteAll - 是否有全部数据范围权限 - * @param authorId - 当前用户 ID(用于权限过滤) - * @throws 当题目不存在或无权删除时抛出 Error - */ -export async function deleteQuestionByIdRecursive( - questionId: string, - canDeleteAll: boolean, - authorId: string -): Promise { - await db.transaction(async (tx) => { - const q = await tx.query.questions.findFirst({ - where: eq(questions.id, questionId), - }); - - if (!q) { - throw new Error("Question not found"); - } - - if (!canDeleteAll && q.authorId !== authorId) { - throw new Error("Unauthorized"); - } - - await deleteQuestionRecursive(tx, questionId); - }); -} - -/** - * 批量删除题目(级联删除子题)。 - * - * 遵循权限范围:非 all 范围只能删除自己创建的题目。 - * 单事务保证原子性,任一题目删除失败则全部回滚。 - * - * @param questionIds - 待删除的题目 ID 列表 - * @param canDeleteAll - 是否有全部数据范围权限 - * @param authorId - 当前用户 ID(用于权限过滤) - * @returns 实际删除的题目数量 - */ -export async function deleteQuestionsBatch( - questionIds: string[], - canDeleteAll: boolean, - authorId: string -): Promise { - if (questionIds.length === 0) return 0 - - const uniqueIds = Array.from(new Set(questionIds)) - return await db.transaction(async (tx) => { - // 权限过滤:非 all 范围只能删除自己创建的题目 - const whereClause = canDeleteAll - ? inArray(questions.id, uniqueIds) - : and(inArray(questions.id, uniqueIds), eq(questions.authorId, authorId)) - - const targetRows = await tx - .select({ id: questions.id }) - .from(questions) - .where(whereClause) - - const targetIds = targetRows.map((r) => r.id) - if (targetIds.length === 0) return 0 - - // 一次性收集所有 targetIds 的后代(含自身),单条 inArray 删除, - // 替代原先循环调用 deleteQuestionRecursive 产生的 N×depth 查询。 - const visited = new Set() - const allIds = await collectDescendantQuestionIds(tx, targetIds, visited) - if (allIds.length > 0) { - await tx.delete(questions).where(inArray(questions.id, allIds)) - } - - return targetIds.length - }) -} - -export async function getKnowledgePointOptionsRaw(): Promise { - // Delegate to textbooks module data-access to avoid direct queries on - // textbooks/chapters/knowledgePoints tables (owned by textbooks module). - return await getKnowledgePointOptionsFromTextbooks() -} -export const getKnowledgePointOptions = cacheFn(getKnowledgePointOptionsRaw, { - tags: ["questions"], - ttl: 600, - keyParts: ["questions", "getKnowledgePointOptions"], -}) - -/** - * 获取教材选项列表(级联筛选第一级)。 - * - * 委托 textbooks 模块 data-access 获取教材列表,避免直接查询 textbooks 表。 - */ -export async function getTextbookOptionsRaw(): Promise { - const textbooks = await getTextbooksFromTextbooks() - return textbooks.map((tb) => ({ - id: tb.id, - title: tb.title, - subject: tb.subject, - grade: tb.grade, - })) -} -export const getTextbookOptions = cacheFn(getTextbookOptionsRaw, { - tags: ["questions"], - ttl: 600, - keyParts: ["questions", "getTextbookOptions"], -}) - -/** - * 获取指定教材下的章节选项列表(级联筛选第二级)。 - * - * 委托 textbooks 模块 data-access 获取章节树,展平为选项列表。 - */ -export async function getChapterOptionsRaw(textbookId: string): Promise { - const chapters = await getChaptersByTextbookIdFromTextbooks(textbookId) - const options: ChapterOption[] = [] - - function flatten(chs: typeof chapters, depth = 0): void { - for (const ch of chs) { - options.push({ - id: ch.id, - title: ch.title, - parentId: ch.parentId ?? null, - depth, - }) - if (ch.children && ch.children.length > 0) { - flatten(ch.children, depth + 1) - } - } - } - - flatten(chapters) - return options -} -export const getChapterOptions = cacheFn(getChapterOptionsRaw, { - tags: ["questions"], - ttl: 600, - keyParts: ["questions", "getChapterOptions"], -}) - -/** - * 获取指定章节下的知识点选项列表(级联筛选第三级)。 - * - * 委托 textbooks 模块 data-access 获取知识点列表。 - */ -export async function getKnowledgePointOptionsByChapterRaw(chapterId: string): Promise<{ id: string; name: string }[]> { - const kps = await getKnowledgePointsByChapterIdFromTextbooks(chapterId) - return kps.map((kp) => ({ id: kp.id, name: kp.name })) -} -export const getKnowledgePointOptionsByChapter = cacheFn(getKnowledgePointOptionsByChapterRaw, { - tags: ["questions"], - ttl: 600, - keyParts: ["questions", "getKnowledgePointOptionsByChapter"], -}) - -// --------------------------------------------------------------------------- -// Cross-module query interfaces — read-only access for other modules -// --------------------------------------------------------------------------- - -export type QuestionKnowledgePoint = { - questionId: string - knowledgePointId: string - knowledgePointName: string -} - -/** Returns knowledge points associated with the given question ids. */ -export const getKnowledgePointsForQuestionsRaw = async ( - questionIds: string[] -): Promise> => { - const result = new Map() - const uniqueIds = Array.from(new Set(questionIds.filter((v): v is string => typeof v === "string" && v.length > 0))) - if (uniqueIds.length === 0) return result - - // 1. 查询 questionId -> knowledgePointId 关联(questions 模块自有表) - const rows = await db - .select({ - questionId: questionsToKnowledgePoints.questionId, - knowledgePointId: questionsToKnowledgePoints.knowledgePointId, - }) - .from(questionsToKnowledgePoints) - .where(inArray(questionsToKnowledgePoints.questionId, uniqueIds)) - - if (rows.length === 0) return result - - // 2. 跨模块批量解析知识点名称,避免直接 JOIN knowledgePoints 表 - const kpIds = Array.from(new Set(rows.map((r) => r.knowledgePointId))) - const kpNameMap = await getKnowledgePointNamesByIds(kpIds) - - // 3. 组装结果 - for (const r of rows) { - const list = result.get(r.questionId) ?? [] - list.push({ - questionId: r.questionId, - knowledgePointId: r.knowledgePointId, - knowledgePointName: kpNameMap.get(r.knowledgePointId) ?? "", - }) - result.set(r.questionId, list) - } - return result -} -export const getKnowledgePointsForQuestions = cacheFn(getKnowledgePointsForQuestionsRaw, { - tags: ["questions"], - ttl: 300, - keyParts: ["questions", "getKnowledgePointsForQuestions"], -}) - -/** - * 跨模块接口:获取题目内容与类型(供 error-book 模块提取正确答案使用)。 - * - * error-book 模块在采集错题时需要从题目内容中提取正确答案(correctAnswer), - * 此接口返回题目的 content 和 type,由 error-book 模块调用 - * `homework/lib/question-content-utils.ts` 中的纯函数进行提取。 - */ -export type QuestionContentForErrorCollection = { - content: unknown - type: string -} - -export const getQuestionsContentForErrorCollectionRaw = async ( - questionIds: string[], -): Promise> => { - const result = new Map() - const uniqueIds = Array.from(new Set(questionIds.filter((v): v is string => typeof v === "string" && v.length > 0))) - if (uniqueIds.length === 0) return result - - const rows = await db - .select({ - id: questions.id, - content: questions.content, - type: questions.type, - }) - .from(questions) - .where(inArray(questions.id, uniqueIds)) - - for (const r of rows) { - result.set(r.id, { content: r.content, type: r.type }) - } - return result -} -export const getQuestionsContentForErrorCollection = cacheFn( +export { + getKnowledgePointsForQuestionsRaw, + getKnowledgePointsForQuestions, getQuestionsContentForErrorCollectionRaw, - { - tags: ["questions"], - ttl: 300, - keyParts: ["questions", "getQuestionsContentForErrorCollection"], - }, -) + getQuestionsContentForErrorCollection, + getQuestionTypeMapByIdsRaw, + getQuestionTypeMapByIds, + getQuestionCountByKpIdsRaw, + getQuestionCountByKpIds, + type QuestionKnowledgePoint, + type QuestionContentForErrorCollection, +} from "./data-access-cross-module"; -/** - * 按 ID 批量获取题目类型映射。 - * - * exams 等模块在组卷/成绩录入时需要题目 type 字段,此接口封装对 questions 表的查询, - * 避免跨模块直接 JOIN questions 表(违反三层架构)。 - * - * 返回 Map,未找到的 ID 不会出现在 Map 中。 - */ -export const getQuestionTypeMapByIdsRaw = async ( - questionIds: string[], -): Promise> => { - const result = new Map() - const uniqueIds = Array.from(new Set(questionIds.filter((v): v is string => typeof v === "string" && v.length > 0))) - if (uniqueIds.length === 0) return result - - const rows = await db - .select({ id: questions.id, type: questions.type }) - .from(questions) - .where(inArray(questions.id, uniqueIds)) - - for (const r of rows) { - result.set(r.id, r.type) - } - return result -} -export const getQuestionTypeMapByIds = cacheFn(getQuestionTypeMapByIdsRaw, { - tags: ["questions"], - ttl: 300, - keyParts: ["questions", "getQuestionTypeMapByIds"], -}) - -// --------------------------------------------------------------------------- -// 导入/导出接口 -// --------------------------------------------------------------------------- - -/** 导出题目格式(JSON) */ -export interface QuestionExportItem { - id: string - type: QuestionType - difficulty: number - content: unknown - knowledgePoints: { id: string; name: string }[] - createdAt: Date - updatedAt: Date -} - -/** - * 导出题目为结构化 JSON 格式。 - * - * 支持按 IDs 导出指定题目,或导出全部题目(受 pageSize 限制)。 - * 遵循权限范围:非 all 范围只能导出自己创建的题目。 - */ -export async function exportQuestionsRaw( - questionIds?: string[], - canExportAll = true, - authorId?: string -): Promise { - const conditions: SQL[] = [] - - if (questionIds && questionIds.length > 0) { - conditions.push(inArray(questions.id, questionIds)) - } - - if (!canExportAll && authorId) { - conditions.push(eq(questions.authorId, authorId)) - } - - conditions.push(sql`${questions.parentId} IS NULL`) - - const whereClause = conditions.length > 0 ? and(...conditions) : undefined - - const rows = await db.query.questions.findMany({ - where: whereClause, - // P3-8: LIMIT 从 1000 调低至 100,避免单次导出拉取过多记录 - limit: 100, - orderBy: [desc(questions.createdAt)], - with: { - knowledgePoints: { - with: { - knowledgePoint: true, - }, - }, - }, - }) - - return rows.map((row) => ({ - id: row.id, - type: row.type, - difficulty: row.difficulty ?? 1, - content: row.content, - knowledgePoints: (row.knowledgePoints ?? []).map((rel) => ({ - id: rel.knowledgePoint.id, - name: rel.knowledgePoint.name, - })), - createdAt: row.createdAt, - updatedAt: row.updatedAt, - })) -} -export const exportQuestions = cacheFn(exportQuestionsRaw, { - tags: ["questions"], - ttl: 60, - keyParts: ["questions", "exportQuestions"], -}) - -/** 导入题目的单条输入 */ -export interface QuestionImportItem { - type: QuestionType - difficulty: number - content: unknown - knowledgePointIds?: string[] -} - -/** - * 批量导入题目。 - * - * 单事务保证原子性,任一题目导入失败则全部回滚。 - * 返回创建的题目 ID 列表。 - */ -export async function importQuestions( - items: QuestionImportItem[], - authorId: string -): Promise { - if (items.length === 0) return [] - - return await db.transaction(async (tx) => { - const createdIds: string[] = [] - for (const item of items) { - const id = await insertQuestionWithRelations( - tx, - { - content: item.content, - type: item.type, - difficulty: item.difficulty, - knowledgePointIds: item.knowledgePointIds, - }, - authorId, - null - ) - createdIds.push(id) - } - return createdIds - }) -} - -// --------------------------------------------------------------------------- -// Cross-module batch interfaces — question count per knowledge point -// --------------------------------------------------------------------------- - -/** - * 跨模块接口:按知识点 ID 批量获取关联题目数。 - * - * 供 textbooks 模块知识图谱使用,避免直接查询 questionsToKnowledgePoints 表。 - * - * @param knowledgePointIds 知识点 ID 列表 - * @returns Map - */ -export const getQuestionCountByKpIdsRaw = async ( - knowledgePointIds: string[] -): Promise> => { - const result = new Map() - const uniqueIds = Array.from(new Set(knowledgePointIds.filter((v): v is string => typeof v === "string" && v.length > 0))) - if (uniqueIds.length === 0) return result - - const rows = await db - .select({ - knowledgePointId: questionsToKnowledgePoints.knowledgePointId, - count: count(), - }) - .from(questionsToKnowledgePoints) - .where(inArray(questionsToKnowledgePoints.knowledgePointId, uniqueIds)) - .groupBy(questionsToKnowledgePoints.knowledgePointId) - - for (const r of rows) { - result.set(r.knowledgePointId, Number(r.count)) - } - return result -} -export const getQuestionCountByKpIds = cacheFn(getQuestionCountByKpIdsRaw, { - tags: ["questions"], - ttl: 300, - keyParts: ["questions", "getQuestionCountByKpIds"], -}) +export { + exportQuestionsRaw, + exportQuestions, + importQuestions, + type QuestionExportItem, + type QuestionImportItem, +} from "./data-access-import-export"; diff --git a/src/modules/textbooks/data-access-chapters.ts b/src/modules/textbooks/data-access-chapters.ts new file mode 100644 index 0000000..5588334 --- /dev/null +++ b/src/modules/textbooks/data-access-chapters.ts @@ -0,0 +1,244 @@ +import "server-only" + +import { and, asc, eq, inArray, isNull, or } from "drizzle-orm" +import { createId } from "@paralleldrive/cuid2" + +import { cacheFn } from "@/shared/lib/cache" +import { db } from "@/shared/db" +import { chapters, knowledgePoints, knowledgePointPrerequisites } from "@/shared/db/schema" +import { NotFoundError } from "@/shared/lib/action-utils" +import type { Chapter } from "./types" +import type { + CreateChapterInput, + UpdateChapterContentInput, +} from "./schema" +import { buildChapterTree } from "./utils" + +export const getChaptersByTextbookIdRaw = async (textbookId: string): Promise => { + const rows = await db + .select({ + id: chapters.id, + textbookId: chapters.textbookId, + title: chapters.title, + order: chapters.order, + parentId: chapters.parentId, + content: chapters.content, + createdAt: chapters.createdAt, + updatedAt: chapters.updatedAt, + }) + .from(chapters) + .where(eq(chapters.textbookId, textbookId)) + .orderBy(asc(chapters.order), asc(chapters.title)) + + return buildChapterTree( + rows.map((r) => ({ + id: r.id, + textbookId: r.textbookId, + title: r.title, + order: r.order, + parentId: r.parentId, + content: r.content ?? null, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + })) + ) +} +export const getChaptersByTextbookId = cacheFn(getChaptersByTextbookIdRaw, { + tags: ["textbooks"], + ttl: 300, + keyParts: ["textbooks", "getChaptersByTextbookId"], +}) + +/** + * 在指定教材下创建章节(content 初始为空字符串,children 为空数组)。 + * + * @param data - 章节输入(textbookId/title/order?/parentId?) + * @returns 新创建的章节对象 + */ +export async function createChapter(data: CreateChapterInput): Promise { + const id = createId() + const now = new Date() + + const row = { + id, + textbookId: data.textbookId, + title: data.title.trim(), + order: data.order ?? 0, + parentId: data.parentId ?? null, + content: "", + createdAt: now, + updatedAt: now, + } + + await db.insert(chapters).values(row) + + return { + id: row.id, + textbookId: row.textbookId, + title: row.title, + order: row.order, + parentId: row.parentId, + content: row.content, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + children: [], + } +} + +/** + * 更新章节正文 content 字段,并返回更新后的章节对象。 + * + * @param data - 更新输入(chapterId + content) + * @returns 更新后的章节对象(children 为空数组) + * @throws {NotFoundError} 当章节不存在时抛出 + */ +export async function updateChapterContent(data: UpdateChapterContentInput): Promise { + await db.update(chapters).set({ content: data.content }).where(eq(chapters.id, data.chapterId)) + + const [row] = await db + .select({ + id: chapters.id, + textbookId: chapters.textbookId, + title: chapters.title, + order: chapters.order, + parentId: chapters.parentId, + content: chapters.content, + createdAt: chapters.createdAt, + updatedAt: chapters.updatedAt, + }) + .from(chapters) + .where(eq(chapters.id, data.chapterId)) + .limit(1) + + if (!row) throw new NotFoundError("章节") + + return { + id: row.id, + textbookId: row.textbookId, + title: row.title, + order: row.order, + parentId: row.parentId, + content: row.content ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + children: [], + } +} + +/** + * 删除章节及其所有后代章节,并清理后代知识点及其前置依赖记录。 + * + * 通过本地构建 childrenByParent 索引 + 栈遍历收集所有后代 ID, + * 在事务内:1) 清理 knowledgePointPrerequisites 表中引用被删知识点的孤儿记录; + * 2) 删除知识点;3) 删除章节。 + * + * @param id - 待删除的章节 ID + */ +export async function deleteChapter(id: string): Promise { + const [target] = await db + .select({ id: chapters.id, textbookId: chapters.textbookId }) + .from(chapters) + .where(eq(chapters.id, id)) + .limit(1) + + if (!target) return + + const all = await db + .select({ id: chapters.id, parentId: chapters.parentId }) + .from(chapters) + .where(eq(chapters.textbookId, target.textbookId)) + + const childrenByParent = new Map() + for (const ch of all) { + if (!ch.parentId) continue + const arr = childrenByParent.get(ch.parentId) ?? [] + arr.push(ch.id) + childrenByParent.set(ch.parentId, arr) + } + + const idsToDelete: string[] = [] + const stack: string[] = [id] + const seen = new Set() + while (stack.length) { + const cur = stack.pop() + if (!cur) break + if (seen.has(cur)) continue + seen.add(cur) + idsToDelete.push(cur) + const kids = childrenByParent.get(cur) + if (kids) stack.push(...kids) + } + + // P0 数据完整性修复:先查询被删章节下的所有知识点 ID,用于清理孤儿前置依赖记录 + const kpsInChapters = await db + .select({ id: knowledgePoints.id }) + .from(knowledgePoints) + .where(inArray(knowledgePoints.chapterId, idsToDelete)) + const kpIds = kpsInChapters.map((k) => k.id) + + await db.transaction(async (tx) => { + // P0 修复:清理 knowledgePointPrerequisites 表中引用被删知识点的孤儿记录 + // 包括作为 knowledgePointId 和作为 prerequisiteKpId 的记录 + if (kpIds.length > 0) { + await tx + .delete(knowledgePointPrerequisites) + .where( + or( + inArray(knowledgePointPrerequisites.knowledgePointId, kpIds), + inArray(knowledgePointPrerequisites.prerequisiteKpId, kpIds), + ), + ) + } + await tx.delete(knowledgePoints).where(inArray(knowledgePoints.chapterId, idsToDelete)) + await tx.delete(chapters).where(inArray(chapters.id, idsToDelete)) + }) +} + +/** + * 调整章节在同级中的位置(可同时切换 parentId),单事务内批量更新 order。 + * + * 流程:1) 读取目标章节;2) 查询同 parent 下兄弟;3) 移除目标后插入到 newIndex; + * 4) 在事务内并行 UPDATE 变更行(order 或 parentId)。 + * + * @param chapterId - 待移动的章节 ID + * @param newIndex - 目标位置(0-based) + * @param parentId - 目标父章节 ID(顶层为 null) + * @throws 当章节不存在时抛出 NotFoundError + */ +export async function reorderChapters(chapterId: string, newIndex: number, parentId: string | null): Promise { + const [target] = await db.select().from(chapters).where(eq(chapters.id, chapterId)).limit(1) + if (!target) throw new NotFoundError("章节") + + const siblings = await db + .select() + .from(chapters) + .where( + and( + eq(chapters.textbookId, target.textbookId), + parentId ? eq(chapters.parentId, parentId) : isNull(chapters.parentId) + ) + ) + .orderBy(asc(chapters.order)) + + const currentSiblings = siblings.filter((c) => c.id !== chapterId) + currentSiblings.splice(newIndex, 0, target) + + await db.transaction(async (tx) => { + // 批量并行 UPDATE 替代原先串行 await 循环,单事务保证原子性。 + // 仅对需要变更的行发起 UPDATE(order 变化或 parentId 变化)。 + await Promise.all( + currentSiblings.map((ch, i) => { + if (ch.order !== i || (ch.id === chapterId && ch.parentId !== parentId)) { + return tx + .update(chapters) + .set({ + order: i, + parentId: ch.id === chapterId ? parentId : ch.parentId + }) + .where(eq(chapters.id, ch.id)) + } + return Promise.resolve() + }) + ) + }) +} diff --git a/src/modules/textbooks/data-access-cross-module.ts b/src/modules/textbooks/data-access-cross-module.ts new file mode 100644 index 0000000..6fa3506 --- /dev/null +++ b/src/modules/textbooks/data-access-cross-module.ts @@ -0,0 +1,152 @@ +import "server-only" + +import { asc, eq, inArray } from "drizzle-orm" + +import { cacheFn } from "@/shared/lib/cache" +import { db } from "@/shared/db" +import { chapters, knowledgePoints, textbooks } from "@/shared/db/schema" + +/** F-05: 列表查询默认上限,防止无界查询拉爆内存 */ +const DEFAULT_LIMIT = 1000 + +// --------------------------------------------------------------------------- +// Cross-module query interfaces — read-only access for other modules +// --------------------------------------------------------------------------- + +export type KnowledgePointOption = { + id: string + name: string + chapterId: string | null + chapterTitle: string | null + textbookId: string | null + textbookTitle: string | null + subject: string | null + grade: string | null +} + +/** + * 获取所有知识点选项(含章节和教材信息)。 + * 供 questions 模块跨模块调用使用,避免直接查询 textbooks/chapters/knowledgePoints 表。 + */ +export const getKnowledgePointOptionsRaw = async (): Promise => { + const rows = await db + .select({ + id: knowledgePoints.id, + name: knowledgePoints.name, + chapterId: chapters.id, + chapterTitle: chapters.title, + textbookId: textbooks.id, + textbookTitle: textbooks.title, + subject: textbooks.subject, + grade: textbooks.grade, + }) + .from(knowledgePoints) + .leftJoin(chapters, eq(chapters.id, knowledgePoints.chapterId)) + .leftJoin(textbooks, eq(textbooks.id, chapters.textbookId)) + .orderBy( + asc(textbooks.title), + asc(chapters.order), + asc(chapters.title), + asc(knowledgePoints.order), + asc(knowledgePoints.name) + ) + .limit(DEFAULT_LIMIT) + + return rows.map((row) => ({ + id: row.id, + name: row.name, + chapterId: row.chapterId ?? null, + chapterTitle: row.chapterTitle ?? null, + textbookId: row.textbookId ?? null, + textbookTitle: row.textbookTitle ?? null, + subject: row.subject ?? null, + grade: row.grade ?? null, + })) +} +export const getKnowledgePointOptions = cacheFn(getKnowledgePointOptionsRaw, { + tags: ["textbooks"], + ttl: 300, + keyParts: ["textbooks", "getKnowledgePointOptions"], +}) + +// --------------------------------------------------------------------------- +// Cross-module batch title/name resolvers +// --------------------------------------------------------------------------- + +/** + * 跨模块接口:按教材 ID 批量获取教材标题。 + * + * 供 lesson-preparation 等模块解析课案关联的教材名称,避免直接查询 textbooks 表。 + */ +export const getTextbookTitlesByIdsRaw = async ( + textbookIds: string[] +): Promise> => { + const result = new Map() + const uniqueIds = Array.from(new Set(textbookIds.filter((v): v is string => typeof v === "string" && v.length > 0))) + if (uniqueIds.length === 0) return result + + const rows = await db + .select({ id: textbooks.id, title: textbooks.title }) + .from(textbooks) + .where(inArray(textbooks.id, uniqueIds)) + + for (const r of rows) result.set(r.id, r.title) + return result +} +export const getTextbookTitlesByIds = cacheFn(getTextbookTitlesByIdsRaw, { + tags: ["textbooks"], + ttl: 300, + keyParts: ["textbooks", "getTextbookTitlesByIds"], +}) + +/** + * 跨模块接口:按章节 ID 批量获取章节标题。 + * + * 供 lesson-preparation 等模块解析课案关联的章节名称,避免直接查询 chapters 表。 + */ +export const getChapterTitlesByIdsRaw = async ( + chapterIds: string[] +): Promise> => { + const result = new Map() + const uniqueIds = Array.from(new Set(chapterIds.filter((v): v is string => typeof v === "string" && v.length > 0))) + if (uniqueIds.length === 0) return result + + const rows = await db + .select({ id: chapters.id, title: chapters.title }) + .from(chapters) + .where(inArray(chapters.id, uniqueIds)) + + for (const r of rows) result.set(r.id, r.title) + return result +} +export const getChapterTitlesByIds = cacheFn(getChapterTitlesByIdsRaw, { + tags: ["textbooks"], + ttl: 300, + keyParts: ["textbooks", "getChapterTitlesByIds"], +}) + +/** + * 跨模块接口:按知识点 ID 批量获取知识点名称。 + * + * 供 questions 模块解析题目关联的知识点名称,避免直接查询 knowledgePoints 表。 + */ +export const getKnowledgePointNamesByIdsRaw = async ( + knowledgePointIds: string[] +): Promise> => { + const result = new Map() + const uniqueIds = Array.from(new Set(knowledgePointIds.filter((v): v is string => typeof v === "string" && v.length > 0))) + if (uniqueIds.length === 0) return result + + const rows = await db + .select({ id: knowledgePoints.id, name: knowledgePoints.name }) + .from(knowledgePoints) + .where(inArray(knowledgePoints.id, uniqueIds)) + + for (const r of rows) result.set(r.id, r.name) + return result +} +export const getKnowledgePointNamesByIds = cacheFn(getKnowledgePointNamesByIdsRaw, { + tags: ["textbooks"], + ttl: 300, + keyParts: ["textbooks", "getKnowledgePointNamesByIds"], +}) diff --git a/src/modules/textbooks/data-access-knowledge-points.ts b/src/modules/textbooks/data-access-knowledge-points.ts new file mode 100644 index 0000000..c824676 --- /dev/null +++ b/src/modules/textbooks/data-access-knowledge-points.ts @@ -0,0 +1,132 @@ +import "server-only" + +import { asc, eq } from "drizzle-orm" +import { createId } from "@paralleldrive/cuid2" + +import { cacheFn } from "@/shared/lib/cache" +import { db } from "@/shared/db" +import { chapters, knowledgePoints } from "@/shared/db/schema" +import type { KnowledgePoint } from "./types" +import type { + CreateKnowledgePointInput, + UpdateKnowledgePointInput, +} from "./schema" + +/** + * 获取指定章节下的知识点列表,按 order 与 name 升序。 + * + * @param chapterId - 章节 ID + * @returns 知识点数组 + */ +export const getKnowledgePointsByChapterIdRaw = async (chapterId: string): Promise => { + const rows = await db + .select({ + id: knowledgePoints.id, + name: knowledgePoints.name, + description: knowledgePoints.description, + parentId: knowledgePoints.parentId, + chapterId: knowledgePoints.chapterId, + level: knowledgePoints.level, + order: knowledgePoints.order, + }) + .from(knowledgePoints) + .where(eq(knowledgePoints.chapterId, chapterId)) + .orderBy(asc(knowledgePoints.order), asc(knowledgePoints.name)) + + return rows.map((r) => ({ + id: r.id, + name: r.name, + description: r.description ?? null, + parentId: r.parentId ?? null, + chapterId: r.chapterId ?? undefined, + level: r.level ?? 0, + order: r.order ?? 0, + })) +} +export const getKnowledgePointsByChapterId = cacheFn(getKnowledgePointsByChapterIdRaw, { + tags: ["textbooks"], + ttl: 300, + keyParts: ["textbooks", "getKnowledgePointsByChapterId"], +}) + +/** + * 获取指定教材下的全部知识点(通过 chapters 表 JOIN 过滤),按章节 order、 + * 知识点 order、知识点 name 升序。 + * + * @param textbookId - 教材 ID + * @returns 知识点数组 + */ +export const getKnowledgePointsByTextbookIdRaw = async (textbookId: string): Promise => { + const rows = await db + .select({ + id: knowledgePoints.id, + name: knowledgePoints.name, + description: knowledgePoints.description, + parentId: knowledgePoints.parentId, + chapterId: knowledgePoints.chapterId, + level: knowledgePoints.level, + order: knowledgePoints.order, + }) + .from(knowledgePoints) + .innerJoin(chapters, eq(chapters.id, knowledgePoints.chapterId)) + .where(eq(chapters.textbookId, textbookId)) + .orderBy(asc(chapters.order), asc(knowledgePoints.order), asc(knowledgePoints.name)) + + return rows.map((r) => ({ + id: r.id, + name: r.name, + description: r.description ?? null, + parentId: r.parentId ?? null, + chapterId: r.chapterId ?? undefined, + level: r.level ?? 0, + order: r.order ?? 0, + })) +} +export const getKnowledgePointsByTextbookId = cacheFn(getKnowledgePointsByTextbookIdRaw, { + tags: ["textbooks"], + ttl: 300, + keyParts: ["textbooks", "getKnowledgePointsByTextbookId"], +}) + +/** + * 创建知识点(level/order 默认 0,需调用方按需更新)。 + * + * @param data - 知识点输入(name/description?/anchorText?/chapterId/parentId?) + */ +export async function createKnowledgePoint(data: CreateKnowledgePointInput): Promise { + await db.insert(knowledgePoints).values({ + id: createId(), + name: data.name, + description: data.description, + anchorText: data.anchorText, + chapterId: data.chapterId, + parentId: data.parentId, + level: 0, // Default level + order: 0, // Default order + }) +} + +/** + * 按 ID 更新知识点(name/description/anchorText),不动 level/order/parentId。 + * + * @param data - 知识点更新输入(id/name/description?/anchorText?) + */ +export async function updateKnowledgePoint(data: UpdateKnowledgePointInput): Promise { + await db + .update(knowledgePoints) + .set({ + name: data.name, + description: data.description, + anchorText: data.anchorText, + }) + .where(eq(knowledgePoints.id, data.id)) +} + +/** + * 按 ID 删除知识点。 + * + * @param id - 待删除的知识点 ID + */ +export async function deleteKnowledgePoint(id: string): Promise { + await db.delete(knowledgePoints).where(eq(knowledgePoints.id, id)) +} diff --git a/src/modules/textbooks/data-access-prerequisites.ts b/src/modules/textbooks/data-access-prerequisites.ts new file mode 100644 index 0000000..f456069 --- /dev/null +++ b/src/modules/textbooks/data-access-prerequisites.ts @@ -0,0 +1,64 @@ +import "server-only" + +import { and, eq } from "drizzle-orm" + +import { cacheFn } from "@/shared/lib/cache" +import { db } from "@/shared/db" +import { chapters, knowledgePoints, knowledgePointPrerequisites } from "@/shared/db/schema" +import type { + CreatePrerequisiteInput, + DeletePrerequisiteInput, +} from "./schema" + +// ===== Prerequisite CRUD ===== + +/** + * 创建知识点前置依赖关系(knowledgePointId → prerequisiteKpId)。 + * + * @param data - 前置依赖输入(knowledgePointId/prerequisiteKpId) + */ +export async function createPrerequisite(data: CreatePrerequisiteInput): Promise { + await db.insert(knowledgePointPrerequisites).values({ + knowledgePointId: data.knowledgePointId, + prerequisiteKpId: data.prerequisiteKpId, + }) +} + +/** + * 删除知识点前置依赖关系(按 knowledgePointId + prerequisiteKpId 联合主键)。 + * + * @param data - 待删除的前置依赖输入(knowledgePointId/prerequisiteKpId) + */ +export async function deletePrerequisite(data: DeletePrerequisiteInput): Promise { + await db + .delete(knowledgePointPrerequisites) + .where(and( + eq(knowledgePointPrerequisites.knowledgePointId, data.knowledgePointId), + eq(knowledgePointPrerequisites.prerequisiteKpId, data.prerequisiteKpId), + )) +} + +/** + * 获取教材下所有知识点的前置依赖边列表。 + * 用于循环检测。 + */ +export async function getPrerequisiteEdgesForTextbookRaw( + textbookId: string, +): Promise> { + const rows = await db + .select({ + knowledgePointId: knowledgePointPrerequisites.knowledgePointId, + prerequisiteKpId: knowledgePointPrerequisites.prerequisiteKpId, + }) + .from(knowledgePointPrerequisites) + .innerJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointPrerequisites.knowledgePointId)) + .innerJoin(chapters, eq(chapters.id, knowledgePoints.chapterId)) + .where(eq(chapters.textbookId, textbookId)) + + return rows.map((r): [string, string] => [r.knowledgePointId, r.prerequisiteKpId]) +} +export const getPrerequisiteEdgesForTextbook = cacheFn(getPrerequisiteEdgesForTextbookRaw, { + tags: ["textbooks"], + ttl: 300, + keyParts: ["textbooks", "getPrerequisiteEdgesForTextbook"], +}) diff --git a/src/modules/textbooks/data-access-textbooks.ts b/src/modules/textbooks/data-access-textbooks.ts new file mode 100644 index 0000000..8d741d0 --- /dev/null +++ b/src/modules/textbooks/data-access-textbooks.ts @@ -0,0 +1,291 @@ +import "server-only" + +import { and, asc, count, eq, like, or, sql, type SQL } from "drizzle-orm" +import { createId } from "@paralleldrive/cuid2" + +import { cacheFn } from "@/shared/lib/cache" +import { db } from "@/shared/db" +import { chapters, textbooks } from "@/shared/db/schema" +import { escapeLikePattern, NotFoundError } from "@/shared/lib/action-utils" +import type { Textbook } from "./types" +import type { + CreateTextbookInput, + UpdateTextbookInput, +} from "./schema" +import { normalizeOptional } from "./utils" + +/** F-05: 列表查询默认上限,防止无界查询拉爆内存 */ +const DEFAULT_LIMIT = 1000 + +/** + * 数据范围过滤参数。 + * 学生端应传入 grade 按年级过滤;教师/admin 端不传则返回全量。 + */ +export interface TextbookQueryScope { + /** 按年级过滤(学生端传入学生所在年级) */ + grade?: string +} + +export const getTextbooksRaw = async (query?: string, subject?: string, grade?: string): Promise => { + const conditions: SQL[] = [] + + const q = query?.trim() + if (q) { + // F-02: 前缀匹配可走索引,避免 %xxx% 全表扫描 + const needle = `${escapeLikePattern(q)}%` + const nameCond = or( + like(textbooks.title, needle), + like(textbooks.subject, needle), + like(textbooks.grade, needle), + like(textbooks.publisher, needle) + ) + if (nameCond) conditions.push(nameCond) + } + + const s = subject?.trim() + if (s && s !== "all") conditions.push(eq(textbooks.subject, s)) + + const g = grade?.trim() + if (g && g !== "all") conditions.push(eq(textbooks.grade, g)) + + const rows = await db + .select({ + id: textbooks.id, + title: textbooks.title, + subject: textbooks.subject, + grade: textbooks.grade, + publisher: textbooks.publisher, + createdAt: textbooks.createdAt, + updatedAt: textbooks.updatedAt, + chaptersCount: sql`COUNT(${chapters.id})`, + }) + .from(textbooks) + .leftJoin(chapters, eq(chapters.textbookId, textbooks.id)) + .where(conditions.length ? and(...conditions) : undefined) + .groupBy(textbooks.id, textbooks.title, textbooks.subject, textbooks.grade, textbooks.publisher, textbooks.createdAt, textbooks.updatedAt) + .orderBy(asc(textbooks.title), asc(textbooks.subject), asc(textbooks.grade)) + .limit(DEFAULT_LIMIT) + + return rows.map((r) => ({ + id: r.id, + title: r.title, + subject: r.subject, + grade: r.grade, + publisher: r.publisher, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + _count: { chapters: Number(r.chaptersCount ?? 0) }, + })) +} +export const getTextbooks = cacheFn(getTextbooksRaw, { + tags: ["textbooks"], + ttl: 300, + keyParts: ["textbooks", "getTextbooks"], +}) + +export const getTextbookByIdRaw = async (id: string): Promise => { + const [row] = await db + .select({ + id: textbooks.id, + title: textbooks.title, + subject: textbooks.subject, + grade: textbooks.grade, + publisher: textbooks.publisher, + createdAt: textbooks.createdAt, + updatedAt: textbooks.updatedAt, + chaptersCount: sql`COUNT(${chapters.id})`, + }) + .from(textbooks) + .leftJoin(chapters, eq(chapters.textbookId, textbooks.id)) + .where(eq(textbooks.id, id)) + .groupBy(textbooks.id, textbooks.title, textbooks.subject, textbooks.grade, textbooks.publisher, textbooks.createdAt, textbooks.updatedAt) + .limit(1) + + if (!row) return undefined + + return { + id: row.id, + title: row.title, + subject: row.subject, + grade: row.grade, + publisher: row.publisher, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + _count: { chapters: Number(row.chaptersCount ?? 0) }, + } +} +export const getTextbookById = cacheFn(getTextbookByIdRaw, { + tags: ["textbooks"], + ttl: 300, + keyParts: ["textbooks", "getTextbookById"], +}) + +/** + * 创建教材。 + * + * @param data - 教材输入(title/subject/grade/publisher) + * @returns 新创建的教材对象(chaptersCount 为 0) + */ +export async function createTextbook(data: CreateTextbookInput): Promise { + const id = createId() + const now = new Date() + + const row = { + id, + title: data.title.trim(), + subject: data.subject.trim(), + grade: normalizeOptional(data.grade), + publisher: normalizeOptional(data.publisher), + createdAt: now, + updatedAt: now, + } + + await db.insert(textbooks).values(row) + + return { + ...row, + _count: { chapters: 0 }, + } +} + +/** + * 更新教材字段(title/subject/grade/publisher)。 + * + * @param data - 更新输入(必须含 id) + * @returns 更新后的教材对象 + * @throws {NotFoundError} 当教材不存在时抛出 + */ +export async function updateTextbook(data: UpdateTextbookInput): Promise { + await db + .update(textbooks) + .set({ + title: data.title.trim(), + subject: data.subject.trim(), + grade: normalizeOptional(data.grade), + publisher: normalizeOptional(data.publisher), + }) + .where(eq(textbooks.id, data.id)) + + const updated = await getTextbookById(data.id) + if (!updated) throw new NotFoundError("教材") + return updated +} + +/** + * 删除教材(DB 级联删除关联章节,但章节下的知识点需应用层显式清理)。 + * + * @param id - 教材 ID + */ +export async function deleteTextbook(id: string): Promise { + await db.delete(textbooks).where(eq(textbooks.id, id)) +} + +export type TextbooksDashboardStats = { + textbookCount: number + chapterCount: number +} + +/** + * 获取教材仪表盘统计(教材总数与章节总数)。 + * + * @returns 包含 textbookCount / chapterCount 的统计对象 + */ +export const getTextbooksDashboardStatsRaw = async (): Promise => { + const [textbookCountRow, chapterCountRow] = await Promise.all([ + db.select({ value: count() }).from(textbooks), + db.select({ value: count() }).from(chapters), + ]) + return { + textbookCount: Number(textbookCountRow[0]?.value ?? 0), + chapterCount: Number(chapterCountRow[0]?.value ?? 0), + } +} +export const getTextbooksDashboardStats = cacheFn(getTextbooksDashboardStatsRaw, { + tags: ["textbooks"], + ttl: 300, + keyParts: ["textbooks", "getTextbooksDashboardStats"], +}) + +// --------------------------------------------------------------------------- +// 带 scope 的查询(P1-1 数据范围过滤) +// --------------------------------------------------------------------------- + +/** + * 按数据范围获取教材列表。 + * + * 学生端应传入 `scope.grade` 按年级过滤,避免跨年级越权读取。 + */ +export const getTextbooksWithScopeRaw = async ( + query?: string, + subject?: string, + grade?: string, + scope?: TextbookQueryScope +): Promise => { + const conditions: SQL[] = [] + + const q = query?.trim() + if (q) { + // F-02: 前缀匹配可走索引,避免 %xxx% 全表扫描 + const needle = `${escapeLikePattern(q)}%` + const nameCond = or( + like(textbooks.title, needle), + like(textbooks.subject, needle), + like(textbooks.grade, needle), + like(textbooks.publisher, needle) + ) + if (nameCond) conditions.push(nameCond) + } + + const s = subject?.trim() + if (s && s !== "all") conditions.push(eq(textbooks.subject, s)) + + // URL 参数 grade(用户筛选) + const g = grade?.trim() + if (g && g !== "all") conditions.push(eq(textbooks.grade, g)) + + // scope.grade(数据范围过滤,学生端强制按年级过滤) + const scopeGrade = scope?.grade?.trim() + if (scopeGrade) conditions.push(eq(textbooks.grade, scopeGrade)) + + const rows = await db + .select({ + id: textbooks.id, + title: textbooks.title, + subject: textbooks.subject, + grade: textbooks.grade, + publisher: textbooks.publisher, + createdAt: textbooks.createdAt, + updatedAt: textbooks.updatedAt, + chaptersCount: sql`COUNT(${chapters.id})`, + }) + .from(textbooks) + .leftJoin(chapters, eq(chapters.textbookId, textbooks.id)) + .where(conditions.length ? and(...conditions) : undefined) + .groupBy( + textbooks.id, + textbooks.title, + textbooks.subject, + textbooks.grade, + textbooks.publisher, + textbooks.createdAt, + textbooks.updatedAt + ) + .orderBy(asc(textbooks.title), asc(textbooks.subject), asc(textbooks.grade)) + .limit(DEFAULT_LIMIT) + + return rows.map((r) => ({ + id: r.id, + title: r.title, + subject: r.subject, + grade: r.grade, + publisher: r.publisher, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + _count: { chapters: Number(r.chaptersCount ?? 0) }, + })) +} +export const getTextbooksWithScope = cacheFn(getTextbooksWithScopeRaw, { + tags: ["textbooks"], + ttl: 300, + keyParts: ["textbooks", "getTextbooksWithScope"], +}) diff --git a/src/modules/textbooks/data-access-verification.ts b/src/modules/textbooks/data-access-verification.ts new file mode 100644 index 0000000..dc07ef9 --- /dev/null +++ b/src/modules/textbooks/data-access-verification.ts @@ -0,0 +1,62 @@ +import "server-only" + +import { eq } from "drizzle-orm" + +import { cacheFn } from "@/shared/lib/cache" +import { db } from "@/shared/db" +import { chapters, knowledgePoints } from "@/shared/db/schema" + +// --------------------------------------------------------------------------- +// 资源归属校验(P0-4) +// --------------------------------------------------------------------------- + +/** + * 校验章节是否属于指定教材。 + * + * 用于 Server Action 二次校验,防止越权操作其他教材的章节。 + * + * @returns true 表示归属一致 + */ +export async function verifyChapterBelongsToTextbookRaw( + chapterId: string, + textbookId: string +): Promise { + const [row] = await db + .select({ textbookId: chapters.textbookId }) + .from(chapters) + .where(eq(chapters.id, chapterId)) + .limit(1) + + if (!row) return false + return row.textbookId === textbookId +} +export const verifyChapterBelongsToTextbook = cacheFn(verifyChapterBelongsToTextbookRaw, { + tags: ["textbooks"], + ttl: 60, + keyParts: ["textbooks", "verifyChapterBelongsToTextbook"], +}) + +/** + * 校验知识点是否属于指定教材(通过 chapter → textbook 关联)。 + * + * 用于 Server Action 二次校验,防止越权操作其他教材的知识点。 + */ +export async function verifyKnowledgePointBelongsToTextbookRaw( + kpId: string, + textbookId: string +): Promise { + const [row] = await db + .select({ textbookId: chapters.textbookId }) + .from(knowledgePoints) + .innerJoin(chapters, eq(chapters.id, knowledgePoints.chapterId)) + .where(eq(knowledgePoints.id, kpId)) + .limit(1) + + if (!row) return false + return row.textbookId === textbookId +} +export const verifyKnowledgePointBelongsToTextbook = cacheFn(verifyKnowledgePointBelongsToTextbookRaw, { + tags: ["textbooks"], + ttl: 60, + keyParts: ["textbooks", "verifyKnowledgePointBelongsToTextbook"], +}) diff --git a/src/modules/textbooks/data-access.ts b/src/modules/textbooks/data-access.ts index e8b1ae2..1a6542c 100644 --- a/src/modules/textbooks/data-access.ts +++ b/src/modules/textbooks/data-access.ts @@ -1,906 +1,17 @@ import "server-only" -import { and, asc, count, eq, inArray, like, or, sql, isNull, type SQL } from "drizzle-orm" -import { createId } from "@paralleldrive/cuid2" - -import { cacheFn } from "@/shared/lib/cache" -import { db } from "@/shared/db" -import { chapters, knowledgePoints, knowledgePointPrerequisites, textbooks } from "@/shared/db/schema" -import { escapeLikePattern, NotFoundError } from "@/shared/lib/action-utils" -import type { - Chapter, - KnowledgePoint, - Textbook, -} from "./types" -import type { - CreateChapterInput, - CreateKnowledgePointInput, - CreatePrerequisiteInput, - CreateTextbookInput, - DeletePrerequisiteInput, - UpdateChapterContentInput, - UpdateKnowledgePointInput, - UpdateTextbookInput, -} from "./schema" -import { - buildChapterTree, - findChapterById, - normalizeOptional, - sortChapters, -} from "./utils" - -export { buildChapterTree, findChapterById, normalizeOptional, sortChapters } - -/** F-05: 列表查询默认上限,防止无界查询拉爆内存 */ -const DEFAULT_LIMIT = 1000 - -/** - * 数据范围过滤参数。 - * 学生端应传入 grade 按年级过滤;教师/admin 端不传则返回全量。 - */ -export interface TextbookQueryScope { - /** 按年级过滤(学生端传入学生所在年级) */ - grade?: string -} - -export const getTextbooksRaw = async (query?: string, subject?: string, grade?: string): Promise => { - const conditions: SQL[] = [] - - const q = query?.trim() - if (q) { - // F-02: 前缀匹配可走索引,避免 %xxx% 全表扫描 - const needle = `${escapeLikePattern(q)}%` - const nameCond = or( - like(textbooks.title, needle), - like(textbooks.subject, needle), - like(textbooks.grade, needle), - like(textbooks.publisher, needle) - ) - if (nameCond) conditions.push(nameCond) - } - - const s = subject?.trim() - if (s && s !== "all") conditions.push(eq(textbooks.subject, s)) - - const g = grade?.trim() - if (g && g !== "all") conditions.push(eq(textbooks.grade, g)) - - const rows = await db - .select({ - id: textbooks.id, - title: textbooks.title, - subject: textbooks.subject, - grade: textbooks.grade, - publisher: textbooks.publisher, - createdAt: textbooks.createdAt, - updatedAt: textbooks.updatedAt, - chaptersCount: sql`COUNT(${chapters.id})`, - }) - .from(textbooks) - .leftJoin(chapters, eq(chapters.textbookId, textbooks.id)) - .where(conditions.length ? and(...conditions) : undefined) - .groupBy(textbooks.id, textbooks.title, textbooks.subject, textbooks.grade, textbooks.publisher, textbooks.createdAt, textbooks.updatedAt) - .orderBy(asc(textbooks.title), asc(textbooks.subject), asc(textbooks.grade)) - .limit(DEFAULT_LIMIT) - - return rows.map((r) => ({ - id: r.id, - title: r.title, - subject: r.subject, - grade: r.grade, - publisher: r.publisher, - createdAt: r.createdAt, - updatedAt: r.updatedAt, - _count: { chapters: Number(r.chaptersCount ?? 0) }, - })) -} -export const getTextbooks = cacheFn(getTextbooksRaw, { - tags: ["textbooks"], - ttl: 300, - keyParts: ["textbooks", "getTextbooks"], -}) - -export const getTextbookByIdRaw = async (id: string): Promise => { - const [row] = await db - .select({ - id: textbooks.id, - title: textbooks.title, - subject: textbooks.subject, - grade: textbooks.grade, - publisher: textbooks.publisher, - createdAt: textbooks.createdAt, - updatedAt: textbooks.updatedAt, - chaptersCount: sql`COUNT(${chapters.id})`, - }) - .from(textbooks) - .leftJoin(chapters, eq(chapters.textbookId, textbooks.id)) - .where(eq(textbooks.id, id)) - .groupBy(textbooks.id, textbooks.title, textbooks.subject, textbooks.grade, textbooks.publisher, textbooks.createdAt, textbooks.updatedAt) - .limit(1) - - if (!row) return undefined - - return { - id: row.id, - title: row.title, - subject: row.subject, - grade: row.grade, - publisher: row.publisher, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - _count: { chapters: Number(row.chaptersCount ?? 0) }, - } -} -export const getTextbookById = cacheFn(getTextbookByIdRaw, { - tags: ["textbooks"], - ttl: 300, - keyParts: ["textbooks", "getTextbookById"], -}) - -export const getChaptersByTextbookIdRaw = async (textbookId: string): Promise => { - const rows = await db - .select({ - id: chapters.id, - textbookId: chapters.textbookId, - title: chapters.title, - order: chapters.order, - parentId: chapters.parentId, - content: chapters.content, - createdAt: chapters.createdAt, - updatedAt: chapters.updatedAt, - }) - .from(chapters) - .where(eq(chapters.textbookId, textbookId)) - .orderBy(asc(chapters.order), asc(chapters.title)) - - return buildChapterTree( - rows.map((r) => ({ - id: r.id, - textbookId: r.textbookId, - title: r.title, - order: r.order, - parentId: r.parentId, - content: r.content ?? null, - createdAt: r.createdAt, - updatedAt: r.updatedAt, - })) - ) -} -export const getChaptersByTextbookId = cacheFn(getChaptersByTextbookIdRaw, { - tags: ["textbooks"], - ttl: 300, - keyParts: ["textbooks", "getChaptersByTextbookId"], -}) - -/** - * 创建教材。 - * - * @param data - 教材输入(title/subject/grade/publisher) - * @returns 新创建的教材对象(chaptersCount 为 0) - */ -export async function createTextbook(data: CreateTextbookInput): Promise { - const id = createId() - const now = new Date() - - const row = { - id, - title: data.title.trim(), - subject: data.subject.trim(), - grade: normalizeOptional(data.grade), - publisher: normalizeOptional(data.publisher), - createdAt: now, - updatedAt: now, - } - - await db.insert(textbooks).values(row) - - return { - ...row, - _count: { chapters: 0 }, - } -} - -/** - * 更新教材字段(title/subject/grade/publisher)。 - * - * @param data - 更新输入(必须含 id) - * @returns 更新后的教材对象 - * @throws {NotFoundError} 当教材不存在时抛出 - */ -export async function updateTextbook(data: UpdateTextbookInput): Promise { - await db - .update(textbooks) - .set({ - title: data.title.trim(), - subject: data.subject.trim(), - grade: normalizeOptional(data.grade), - publisher: normalizeOptional(data.publisher), - }) - .where(eq(textbooks.id, data.id)) - - const updated = await getTextbookById(data.id) - if (!updated) throw new NotFoundError("教材") - return updated -} - -/** - * 删除教材(DB 级联删除关联章节,但章节下的知识点需应用层显式清理)。 - * - * @param id - 教材 ID - */ -export async function deleteTextbook(id: string): Promise { - await db.delete(textbooks).where(eq(textbooks.id, id)) -} - -/** - * 在指定教材下创建章节(content 初始为空字符串,children 为空数组)。 - * - * @param data - 章节输入(textbookId/title/order?/parentId?) - * @returns 新创建的章节对象 - */ -export async function createChapter(data: CreateChapterInput): Promise { - const id = createId() - const now = new Date() - - const row = { - id, - textbookId: data.textbookId, - title: data.title.trim(), - order: data.order ?? 0, - parentId: data.parentId ?? null, - content: "", - createdAt: now, - updatedAt: now, - } - - await db.insert(chapters).values(row) - - return { - id: row.id, - textbookId: row.textbookId, - title: row.title, - order: row.order, - parentId: row.parentId, - content: row.content, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - children: [], - } -} - -/** - * 更新章节正文 content 字段,并返回更新后的章节对象。 - * - * @param data - 更新输入(chapterId + content) - * @returns 更新后的章节对象(children 为空数组) - * @throws {NotFoundError} 当章节不存在时抛出 - */ -export async function updateChapterContent(data: UpdateChapterContentInput): Promise { - await db.update(chapters).set({ content: data.content }).where(eq(chapters.id, data.chapterId)) - - const [row] = await db - .select({ - id: chapters.id, - textbookId: chapters.textbookId, - title: chapters.title, - order: chapters.order, - parentId: chapters.parentId, - content: chapters.content, - createdAt: chapters.createdAt, - updatedAt: chapters.updatedAt, - }) - .from(chapters) - .where(eq(chapters.id, data.chapterId)) - .limit(1) - - if (!row) throw new NotFoundError("章节") - - return { - id: row.id, - textbookId: row.textbookId, - title: row.title, - order: row.order, - parentId: row.parentId, - content: row.content ?? null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - children: [], - } -} - -/** - * 删除章节及其所有后代章节,并清理后代知识点及其前置依赖记录。 - * - * 通过本地构建 childrenByParent 索引 + 栈遍历收集所有后代 ID, - * 在事务内:1) 清理 knowledgePointPrerequisites 表中引用被删知识点的孤儿记录; - * 2) 删除知识点;3) 删除章节。 - * - * @param id - 待删除的章节 ID - */ -export async function deleteChapter(id: string): Promise { - const [target] = await db - .select({ id: chapters.id, textbookId: chapters.textbookId }) - .from(chapters) - .where(eq(chapters.id, id)) - .limit(1) - - if (!target) return - - const all = await db - .select({ id: chapters.id, parentId: chapters.parentId }) - .from(chapters) - .where(eq(chapters.textbookId, target.textbookId)) - - const childrenByParent = new Map() - for (const ch of all) { - if (!ch.parentId) continue - const arr = childrenByParent.get(ch.parentId) ?? [] - arr.push(ch.id) - childrenByParent.set(ch.parentId, arr) - } - - const idsToDelete: string[] = [] - const stack: string[] = [id] - const seen = new Set() - while (stack.length) { - const cur = stack.pop() - if (!cur) break - if (seen.has(cur)) continue - seen.add(cur) - idsToDelete.push(cur) - const kids = childrenByParent.get(cur) - if (kids) stack.push(...kids) - } - - // P0 数据完整性修复:先查询被删章节下的所有知识点 ID,用于清理孤儿前置依赖记录 - const kpsInChapters = await db - .select({ id: knowledgePoints.id }) - .from(knowledgePoints) - .where(inArray(knowledgePoints.chapterId, idsToDelete)) - const kpIds = kpsInChapters.map((k) => k.id) - - await db.transaction(async (tx) => { - // P0 修复:清理 knowledgePointPrerequisites 表中引用被删知识点的孤儿记录 - // 包括作为 knowledgePointId 和作为 prerequisiteKpId 的记录 - if (kpIds.length > 0) { - await tx - .delete(knowledgePointPrerequisites) - .where( - or( - inArray(knowledgePointPrerequisites.knowledgePointId, kpIds), - inArray(knowledgePointPrerequisites.prerequisiteKpId, kpIds), - ), - ) - } - await tx.delete(knowledgePoints).where(inArray(knowledgePoints.chapterId, idsToDelete)) - await tx.delete(chapters).where(inArray(chapters.id, idsToDelete)) - }) -} - -/** - * 获取指定章节下的知识点列表,按 order 与 name 升序。 - * - * @param chapterId - 章节 ID - * @returns 知识点数组 - */ -export const getKnowledgePointsByChapterIdRaw = async (chapterId: string): Promise => { - const rows = await db - .select({ - id: knowledgePoints.id, - name: knowledgePoints.name, - description: knowledgePoints.description, - parentId: knowledgePoints.parentId, - chapterId: knowledgePoints.chapterId, - level: knowledgePoints.level, - order: knowledgePoints.order, - }) - .from(knowledgePoints) - .where(eq(knowledgePoints.chapterId, chapterId)) - .orderBy(asc(knowledgePoints.order), asc(knowledgePoints.name)) - - return rows.map((r) => ({ - id: r.id, - name: r.name, - description: r.description ?? null, - parentId: r.parentId ?? null, - chapterId: r.chapterId ?? undefined, - level: r.level ?? 0, - order: r.order ?? 0, - })) -} -export const getKnowledgePointsByChapterId = cacheFn(getKnowledgePointsByChapterIdRaw, { - tags: ["textbooks"], - ttl: 300, - keyParts: ["textbooks", "getKnowledgePointsByChapterId"], -}) - -/** - * 获取指定教材下的全部知识点(通过 chapters 表 JOIN 过滤),按章节 order、 - * 知识点 order、知识点 name 升序。 - * - * @param textbookId - 教材 ID - * @returns 知识点数组 - */ -export const getKnowledgePointsByTextbookIdRaw = async (textbookId: string): Promise => { - const rows = await db - .select({ - id: knowledgePoints.id, - name: knowledgePoints.name, - description: knowledgePoints.description, - parentId: knowledgePoints.parentId, - chapterId: knowledgePoints.chapterId, - level: knowledgePoints.level, - order: knowledgePoints.order, - }) - .from(knowledgePoints) - .innerJoin(chapters, eq(chapters.id, knowledgePoints.chapterId)) - .where(eq(chapters.textbookId, textbookId)) - .orderBy(asc(chapters.order), asc(knowledgePoints.order), asc(knowledgePoints.name)) - - return rows.map((r) => ({ - id: r.id, - name: r.name, - description: r.description ?? null, - parentId: r.parentId ?? null, - chapterId: r.chapterId ?? undefined, - level: r.level ?? 0, - order: r.order ?? 0, - })) -} -export const getKnowledgePointsByTextbookId = cacheFn(getKnowledgePointsByTextbookIdRaw, { - tags: ["textbooks"], - ttl: 300, - keyParts: ["textbooks", "getKnowledgePointsByTextbookId"], -}) - -/** - * 创建知识点(level/order 默认 0,需调用方按需更新)。 - * - * @param data - 知识点输入(name/description?/anchorText?/chapterId/parentId?) - */ -export async function createKnowledgePoint(data: CreateKnowledgePointInput): Promise { - await db.insert(knowledgePoints).values({ - id: createId(), - name: data.name, - description: data.description, - anchorText: data.anchorText, - chapterId: data.chapterId, - parentId: data.parentId, - level: 0, // Default level - order: 0, // Default order - }) -} - -/** - * 按 ID 更新知识点(name/description/anchorText),不动 level/order/parentId。 - * - * @param data - 知识点更新输入(id/name/description?/anchorText?) - */ -export async function updateKnowledgePoint(data: UpdateKnowledgePointInput): Promise { - await db - .update(knowledgePoints) - .set({ - name: data.name, - description: data.description, - anchorText: data.anchorText, - }) - .where(eq(knowledgePoints.id, data.id)) -} - -/** - * 按 ID 删除知识点。 - * - * @param id - 待删除的知识点 ID - */ -export async function deleteKnowledgePoint(id: string): Promise { - await db.delete(knowledgePoints).where(eq(knowledgePoints.id, id)) -} - -/** - * 调整章节在同级中的位置(可同时切换 parentId),单事务内批量更新 order。 - * - * 流程:1) 读取目标章节;2) 查询同 parent 下兄弟;3) 移除目标后插入到 newIndex; - * 4) 在事务内并行 UPDATE 变更行(order 或 parentId)。 - * - * @param chapterId - 待移动的章节 ID - * @param newIndex - 目标位置(0-based) - * @param parentId - 目标父章节 ID(顶层为 null) - * @throws 当章节不存在时抛出 NotFoundError - */ -export async function reorderChapters(chapterId: string, newIndex: number, parentId: string | null): Promise { - const [target] = await db.select().from(chapters).where(eq(chapters.id, chapterId)).limit(1) - if (!target) throw new NotFoundError("章节") - - const siblings = await db - .select() - .from(chapters) - .where( - and( - eq(chapters.textbookId, target.textbookId), - parentId ? eq(chapters.parentId, parentId) : isNull(chapters.parentId) - ) - ) - .orderBy(asc(chapters.order)) - - const currentSiblings = siblings.filter((c) => c.id !== chapterId) - currentSiblings.splice(newIndex, 0, target) - - await db.transaction(async (tx) => { - // 批量并行 UPDATE 替代原先串行 await 循环,单事务保证原子性。 - // 仅对需要变更的行发起 UPDATE(order 变化或 parentId 变化)。 - await Promise.all( - currentSiblings.map((ch, i) => { - if (ch.order !== i || (ch.id === chapterId && ch.parentId !== parentId)) { - return tx - .update(chapters) - .set({ - order: i, - parentId: ch.id === chapterId ? parentId : ch.parentId - }) - .where(eq(chapters.id, ch.id)) - } - return Promise.resolve() - }) - ) - }) -} - -export type TextbooksDashboardStats = { - textbookCount: number - chapterCount: number -} - -/** - * 获取教材仪表盘统计(教材总数与章节总数)。 - * - * @returns 包含 textbookCount / chapterCount 的统计对象 - */ -export const getTextbooksDashboardStatsRaw = async (): Promise => { - const [textbookCountRow, chapterCountRow] = await Promise.all([ - db.select({ value: count() }).from(textbooks), - db.select({ value: count() }).from(chapters), - ]) - return { - textbookCount: Number(textbookCountRow[0]?.value ?? 0), - chapterCount: Number(chapterCountRow[0]?.value ?? 0), - } -} -export const getTextbooksDashboardStats = cacheFn(getTextbooksDashboardStatsRaw, { - tags: ["textbooks"], - ttl: 300, - keyParts: ["textbooks", "getTextbooksDashboardStats"], -}) - -// --------------------------------------------------------------------------- -// 资源归属校验(P0-4) -// --------------------------------------------------------------------------- - -/** - * 校验章节是否属于指定教材。 - * - * 用于 Server Action 二次校验,防止越权操作其他教材的章节。 - * - * @returns true 表示归属一致 - */ -export async function verifyChapterBelongsToTextbookRaw( - chapterId: string, - textbookId: string -): Promise { - const [row] = await db - .select({ textbookId: chapters.textbookId }) - .from(chapters) - .where(eq(chapters.id, chapterId)) - .limit(1) - - if (!row) return false - return row.textbookId === textbookId -} -export const verifyChapterBelongsToTextbook = cacheFn(verifyChapterBelongsToTextbookRaw, { - tags: ["textbooks"], - ttl: 60, - keyParts: ["textbooks", "verifyChapterBelongsToTextbook"], -}) - -/** - * 校验知识点是否属于指定教材(通过 chapter → textbook 关联)。 - * - * 用于 Server Action 二次校验,防止越权操作其他教材的知识点。 - */ -export async function verifyKnowledgePointBelongsToTextbookRaw( - kpId: string, - textbookId: string -): Promise { - const [row] = await db - .select({ textbookId: chapters.textbookId }) - .from(knowledgePoints) - .innerJoin(chapters, eq(chapters.id, knowledgePoints.chapterId)) - .where(eq(knowledgePoints.id, kpId)) - .limit(1) - - if (!row) return false - return row.textbookId === textbookId -} -export const verifyKnowledgePointBelongsToTextbook = cacheFn(verifyKnowledgePointBelongsToTextbookRaw, { - tags: ["textbooks"], - ttl: 60, - keyParts: ["textbooks", "verifyKnowledgePointBelongsToTextbook"], -}) - -// --------------------------------------------------------------------------- -// 带 scope 的查询(P1-1 数据范围过滤) -// --------------------------------------------------------------------------- - -/** - * 按数据范围获取教材列表。 - * - * 学生端应传入 `scope.grade` 按年级过滤,避免跨年级越权读取。 - */ -export const getTextbooksWithScopeRaw = async ( - query?: string, - subject?: string, - grade?: string, - scope?: TextbookQueryScope -): Promise => { - const conditions: SQL[] = [] - - const q = query?.trim() - if (q) { - // F-02: 前缀匹配可走索引,避免 %xxx% 全表扫描 - const needle = `${escapeLikePattern(q)}%` - const nameCond = or( - like(textbooks.title, needle), - like(textbooks.subject, needle), - like(textbooks.grade, needle), - like(textbooks.publisher, needle) - ) - if (nameCond) conditions.push(nameCond) - } - - const s = subject?.trim() - if (s && s !== "all") conditions.push(eq(textbooks.subject, s)) - - // URL 参数 grade(用户筛选) - const g = grade?.trim() - if (g && g !== "all") conditions.push(eq(textbooks.grade, g)) - - // scope.grade(数据范围过滤,学生端强制按年级过滤) - const scopeGrade = scope?.grade?.trim() - if (scopeGrade) conditions.push(eq(textbooks.grade, scopeGrade)) - - const rows = await db - .select({ - id: textbooks.id, - title: textbooks.title, - subject: textbooks.subject, - grade: textbooks.grade, - publisher: textbooks.publisher, - createdAt: textbooks.createdAt, - updatedAt: textbooks.updatedAt, - chaptersCount: sql`COUNT(${chapters.id})`, - }) - .from(textbooks) - .leftJoin(chapters, eq(chapters.textbookId, textbooks.id)) - .where(conditions.length ? and(...conditions) : undefined) - .groupBy( - textbooks.id, - textbooks.title, - textbooks.subject, - textbooks.grade, - textbooks.publisher, - textbooks.createdAt, - textbooks.updatedAt - ) - .orderBy(asc(textbooks.title), asc(textbooks.subject), asc(textbooks.grade)) - .limit(DEFAULT_LIMIT) - - return rows.map((r) => ({ - id: r.id, - title: r.title, - subject: r.subject, - grade: r.grade, - publisher: r.publisher, - createdAt: r.createdAt, - updatedAt: r.updatedAt, - _count: { chapters: Number(r.chaptersCount ?? 0) }, - })) -} -export const getTextbooksWithScope = cacheFn(getTextbooksWithScopeRaw, { - tags: ["textbooks"], - ttl: 300, - keyParts: ["textbooks", "getTextbooksWithScope"], -}) - -// --------------------------------------------------------------------------- -// Cross-module query interfaces — read-only access for other modules -// --------------------------------------------------------------------------- - -export type KnowledgePointOption = { - id: string - name: string - chapterId: string | null - chapterTitle: string | null - textbookId: string | null - textbookTitle: string | null - subject: string | null - grade: string | null -} - -/** - * 获取所有知识点选项(含章节和教材信息)。 - * 供 questions 模块跨模块调用使用,避免直接查询 textbooks/chapters/knowledgePoints 表。 - */ -export const getKnowledgePointOptionsRaw = async (): Promise => { - const rows = await db - .select({ - id: knowledgePoints.id, - name: knowledgePoints.name, - chapterId: chapters.id, - chapterTitle: chapters.title, - textbookId: textbooks.id, - textbookTitle: textbooks.title, - subject: textbooks.subject, - grade: textbooks.grade, - }) - .from(knowledgePoints) - .leftJoin(chapters, eq(chapters.id, knowledgePoints.chapterId)) - .leftJoin(textbooks, eq(textbooks.id, chapters.textbookId)) - .orderBy( - asc(textbooks.title), - asc(chapters.order), - asc(chapters.title), - asc(knowledgePoints.order), - asc(knowledgePoints.name) - ) - .limit(DEFAULT_LIMIT) - - return rows.map((row) => ({ - id: row.id, - name: row.name, - chapterId: row.chapterId ?? null, - chapterTitle: row.chapterTitle ?? null, - textbookId: row.textbookId ?? null, - textbookTitle: row.textbookTitle ?? null, - subject: row.subject ?? null, - grade: row.grade ?? null, - })) -} -export const getKnowledgePointOptions = cacheFn(getKnowledgePointOptionsRaw, { - tags: ["textbooks"], - ttl: 300, - keyParts: ["textbooks", "getKnowledgePointOptions"], -}) - -// ===== Prerequisite CRUD ===== - -/** - * 创建知识点前置依赖关系(knowledgePointId → prerequisiteKpId)。 - * - * @param data - 前置依赖输入(knowledgePointId/prerequisiteKpId) - */ -export async function createPrerequisite(data: CreatePrerequisiteInput): Promise { - await db.insert(knowledgePointPrerequisites).values({ - knowledgePointId: data.knowledgePointId, - prerequisiteKpId: data.prerequisiteKpId, - }) -} - -/** - * 删除知识点前置依赖关系(按 knowledgePointId + prerequisiteKpId 联合主键)。 - * - * @param data - 待删除的前置依赖输入(knowledgePointId/prerequisiteKpId) - */ -export async function deletePrerequisite(data: DeletePrerequisiteInput): Promise { - await db - .delete(knowledgePointPrerequisites) - .where(and( - eq(knowledgePointPrerequisites.knowledgePointId, data.knowledgePointId), - eq(knowledgePointPrerequisites.prerequisiteKpId, data.prerequisiteKpId), - )) -} - -/** - * 获取教材下所有知识点的前置依赖边列表。 - * 用于循环检测。 - */ -export async function getPrerequisiteEdgesForTextbookRaw( - textbookId: string, -): Promise> { - const rows = await db - .select({ - knowledgePointId: knowledgePointPrerequisites.knowledgePointId, - prerequisiteKpId: knowledgePointPrerequisites.prerequisiteKpId, - }) - .from(knowledgePointPrerequisites) - .innerJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointPrerequisites.knowledgePointId)) - .innerJoin(chapters, eq(chapters.id, knowledgePoints.chapterId)) - .where(eq(chapters.textbookId, textbookId)) - - return rows.map((r): [string, string] => [r.knowledgePointId, r.prerequisiteKpId]) -} -export const getPrerequisiteEdgesForTextbook = cacheFn(getPrerequisiteEdgesForTextbookRaw, { - tags: ["textbooks"], - ttl: 300, - keyParts: ["textbooks", "getPrerequisiteEdgesForTextbook"], -}) - -// --------------------------------------------------------------------------- -// Cross-module batch title/name resolvers -// --------------------------------------------------------------------------- - -/** - * 跨模块接口:按教材 ID 批量获取教材标题。 - * - * 供 lesson-preparation 等模块解析课案关联的教材名称,避免直接查询 textbooks 表。 - */ -export const getTextbookTitlesByIdsRaw = async ( - textbookIds: string[] -): Promise> => { - const result = new Map() - const uniqueIds = Array.from(new Set(textbookIds.filter((v): v is string => typeof v === "string" && v.length > 0))) - if (uniqueIds.length === 0) return result - - const rows = await db - .select({ id: textbooks.id, title: textbooks.title }) - .from(textbooks) - .where(inArray(textbooks.id, uniqueIds)) - - for (const r of rows) result.set(r.id, r.title) - return result -} -export const getTextbookTitlesByIds = cacheFn(getTextbookTitlesByIdsRaw, { - tags: ["textbooks"], - ttl: 300, - keyParts: ["textbooks", "getTextbookTitlesByIds"], -}) - -/** - * 跨模块接口:按章节 ID 批量获取章节标题。 - * - * 供 lesson-preparation 等模块解析课案关联的章节名称,避免直接查询 chapters 表。 - */ -export const getChapterTitlesByIdsRaw = async ( - chapterIds: string[] -): Promise> => { - const result = new Map() - const uniqueIds = Array.from(new Set(chapterIds.filter((v): v is string => typeof v === "string" && v.length > 0))) - if (uniqueIds.length === 0) return result - - const rows = await db - .select({ id: chapters.id, title: chapters.title }) - .from(chapters) - .where(inArray(chapters.id, uniqueIds)) - - for (const r of rows) result.set(r.id, r.title) - return result -} -export const getChapterTitlesByIds = cacheFn(getChapterTitlesByIdsRaw, { - tags: ["textbooks"], - ttl: 300, - keyParts: ["textbooks", "getChapterTitlesByIds"], -}) - -/** - * 跨模块接口:按知识点 ID 批量获取知识点名称。 - * - * 供 questions 模块解析题目关联的知识点名称,避免直接查询 knowledgePoints 表。 - */ -export const getKnowledgePointNamesByIdsRaw = async ( - knowledgePointIds: string[] -): Promise> => { - const result = new Map() - const uniqueIds = Array.from(new Set(knowledgePointIds.filter((v): v is string => typeof v === "string" && v.length > 0))) - if (uniqueIds.length === 0) return result - - const rows = await db - .select({ id: knowledgePoints.id, name: knowledgePoints.name }) - .from(knowledgePoints) - .where(inArray(knowledgePoints.id, uniqueIds)) - - for (const r of rows) result.set(r.id, r.name) - return result -} -export const getKnowledgePointNamesByIds = cacheFn(getKnowledgePointNamesByIdsRaw, { - tags: ["textbooks"], - ttl: 300, - keyParts: ["textbooks", "getKnowledgePointNamesByIds"], -}) +// 重新导出 utils 中的工具函数,保持向后兼容 +export { buildChapterTree, findChapterById, normalizeOptional, sortChapters } from "./utils" + +// 教材 CRUD + 查询 + scope + 仪表盘统计 +export * from "./data-access-textbooks" +// 章节 CRUD + reorder +export * from "./data-access-chapters" +// 知识点 CRUD + 查询 +export * from "./data-access-knowledge-points" +// 资源归属校验 +export * from "./data-access-verification" +// 前置依赖 CRUD +export * from "./data-access-prerequisites" +// 跨模块批量解析接口 +export * from "./data-access-cross-module" diff --git a/src/shared/db/schema.ts b/src/shared/db/schema.ts index 703d50f..8f1a09f 100644 --- a/src/shared/db/schema.ts +++ b/src/shared/db/schema.ts @@ -1,2244 +1,31 @@ -import { - mysqlTable, - varchar, - text, - timestamp, - int, - primaryKey, - index, - uniqueIndex, - json, - mysqlEnum, - boolean, - foreignKey, - date, - datetime, - decimal, - bigint -} from "drizzle-orm/mysql-core"; -import { sql } from "drizzle-orm"; -import { createId } from "@paralleldrive/cuid2"; -import type { AdapterAccountType } from "next-auth/adapters"; - -// --- Helper for ID generation (CUID2) --- -const id = (name: string) => varchar(name, { length: 128 }).notNull().$defaultFn(() => createId()); - -// --- 1. Users & Auth (Auth.js v5 Standard + RBAC) --- - -export const users = mysqlTable("users", { - id: id("id").primaryKey(), - name: varchar("name", { length: 255 }), - email: varchar("email", { length: 255 }).notNull().unique(), - emailVerified: timestamp("emailVerified", { mode: "date" }), - image: varchar("image", { length: 255 }), - // Credentials Auth (Optional) - password: varchar("password", { length: 255 }), - - phone: varchar("phone", { length: 30 }), - address: varchar("address", { length: 255 }), - gender: varchar("gender", { length: 20 }), - age: int("age"), - gradeId: varchar("grade_id", { length: 128 }), - departmentId: varchar("department_id", { length: 128 }), - onboardedAt: timestamp("onboarded_at", { mode: "date" }), - - // 未成年人信息保护 - birthDate: date("birth_date"), - guardianName: varchar("guardian_name", { length: 255 }), - guardianPhone: varchar("guardian_phone", { length: 20 }), - guardianRelation: varchar("guardian_relation", { length: 50 }), - consentAcceptedAt: datetime("consent_accepted_at"), - - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - emailIdx: index("email_idx").on(table.email), - gradeIdIdx: index("users_grade_id_idx").on(table.gradeId), - departmentIdIdx: index("users_department_id_idx").on(table.departmentId), -})); - -// Auth.js: Accounts (OAuth providers) -export const accounts = mysqlTable("accounts", { - userId: varchar("userId", { length: 128 }) - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - type: varchar("type", { length: 255 }).$type().notNull(), - provider: varchar("provider", { length: 255 }).notNull(), - providerAccountId: varchar("providerAccountId", { length: 255 }).notNull(), - refresh_token: text("refresh_token"), - access_token: text("access_token"), - expires_at: int("expires_at"), - token_type: varchar("token_type", { length: 255 }), - scope: varchar("scope", { length: 255 }), - id_token: text("id_token"), - session_state: varchar("session_state", { length: 255 }), - }, - (account) => ({ - compoundKey: primaryKey({ - columns: [account.provider, account.providerAccountId], - }), - userIdIdx: index("account_userId_idx").on(account.userId), - }) -); - -/** - * @deprecated audit-P1-9:此表在 JWT 策略下从未被写入数据。 - * - * NextAuth 配置为 `session: { strategy: "jwt" }`(见 `src/auth.ts`), - * 所有 session 信息存储在 JWT cookie 中,不写入此表。 - * `revokeAllOtherSessionsAction` 已改为 no-op(返回 0 并记录日志)。 - * `getUsersDashboardStats` 已不再查询此表。 - * - * 此表定义暂时保留是为了: - * 1. 避免删除表定义后 drizzle-kit 生成 DROP TABLE 迁移误删生产数据 - * 2. 未来若切换为 `strategy: "database"` 或引入 JWT 黑名单可复用 - * - * 后续应在显式迁移中删除此表(同时删除 relations.ts 中的 sessionsRelations)。 - */ -// Auth.js: Sessions -export const sessions = mysqlTable("sessions", { - sessionToken: varchar("sessionToken", { length: 255 }).primaryKey(), - userId: varchar("userId", { length: 128 }) - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - expires: timestamp("expires", { mode: "date" }).notNull(), -}, (table) => ({ - userIdIdx: index("session_userId_idx").on(table.userId), -})); - -// Auth.js: Verification Tokens -export const verificationTokens = mysqlTable("verificationTokens", { - identifier: varchar("identifier", { length: 255 }).notNull(), - token: varchar("token", { length: 255 }).notNull(), - expires: timestamp("expires", { mode: "date" }).notNull(), -}, (vt) => ({ - compoundKey: primaryKey({ columns: [vt.identifier, vt.token] }), -})); - -// --- Custom RBAC Extensions --- - -export const roles = mysqlTable("roles", { - id: id("id").primaryKey(), - name: varchar("name", { length: 50 }).notNull().unique(), // e.g., 'admin', 'teacher', 'student', 'grade_head' - description: varchar("description", { length: 255 }), - // Builtin roles (admin/teacher/student/parent/grade_head/teaching_head) are - // seeded with is_system = true and cannot be deleted. The `admin` role is - // additionally fully locked (no permission edits, no disable, no rename). - isSystem: boolean("is_system").default(false).notNull(), - // Disabled roles do not contribute permissions at login time. - isEnabled: boolean("is_enabled").default(true).notNull(), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}); - -// Many-to-Many: Users <-> Roles -// Solves: "A teacher can also be a Grade Head" -export const usersToRoles = mysqlTable("users_to_roles", { - userId: varchar("user_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), - roleId: varchar("role_id", { length: 128 }).notNull().references(() => roles.id, { onDelete: "cascade" }), -}, (table) => ({ - pk: primaryKey({ columns: [table.userId, table.roleId] }), - userIdIdx: index("user_id_idx").on(table.userId), -})); - -// Role -> Permissions (fine-grained RBAC) -export const rolePermissions = mysqlTable("role_permissions", { - roleId: varchar("role_id", { length: 128 }).notNull().references(() => roles.id, { onDelete: "cascade" }), - permission: varchar("permission", { length: 100 }).notNull(), -}, (table) => ({ - pk: primaryKey({ columns: [table.roleId, table.permission] }), - roleIdIdx: index("role_permissions_role_idx").on(table.roleId), -})); - -// --- 2. Knowledge Points (Tree Structure) --- - -export const knowledgePoints = mysqlTable("knowledge_points", { - id: id("id").primaryKey(), - name: varchar("name", { length: 255 }).notNull(), - description: text("description"), - anchorText: varchar("anchor_text", { length: 255 }), - - // Tree Structure: Parent KP - parentId: varchar("parent_id", { length: 128 }), // Self-reference defined in relations - - chapterId: varchar("chapter_id", { length: 128 }), - - // Metadata for ordering or level - level: int("level").default(0), - order: int("order").default(0), - - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - parentIdIdx: index("parent_id_idx").on(table.parentId), - chapterIdIdx: index("kp_chapter_id_idx").on(table.chapterId), -})); - -// --- 知识点前置依赖(知识图谱) --- -export const knowledgePointPrerequisites = mysqlTable("knowledge_point_prerequisites", { - knowledgePointId: varchar("knowledge_point_id", { length: 128 }).notNull(), - prerequisiteKpId: varchar("prerequisite_kp_id", { length: 128 }).notNull(), - createdAt: timestamp("created_at").defaultNow().notNull(), -}, (table) => ({ - kpPairPk: primaryKey({ columns: [table.knowledgePointId, table.prerequisiteKpId], name: "kp_prereq_pk" }), - kpIdx: index("kp_prereq_kp_idx").on(table.knowledgePointId), - prereqIdx: index("kp_prereq_prereq_idx").on(table.prerequisiteKpId), - kpFk: foreignKey({ - columns: [table.knowledgePointId], - foreignColumns: [knowledgePoints.id], - name: "kp_prereq_kp_fk", - }).onDelete("cascade"), - prereqFk: foreignKey({ - columns: [table.prerequisiteKpId], - foreignColumns: [knowledgePoints.id], - name: "kp_prereq_prereq_fk", - }).onDelete("cascade"), -})); - -// --- 3. Question Bank (Core) --- - -export const questionTypeEnum = mysqlEnum("type", ["single_choice", "multiple_choice", "text", "judgment", "composite"]); - -export const questions = mysqlTable("questions", { - id: id("id").primaryKey(), - - // Content can be JSON to store rich text, images, etc. or just text. - // Using JSON for flexibility in a modern ed-tech app (e.g. SlateJS nodes). - content: json("content").notNull(), - - // P3-6: 题目内容全文检索文本镜像。 - // - STORED 生成列:每次 INSERT/UPDATE content 时由 MySQL 自动同步 - // - 使用 CAST(content AS CHAR) 把 JSON 序列化为纯文本以支持 FULLTEXT - // - 配合 questions_content_text_ft_idx FULLTEXT 索引(见对应 drizzle 迁移)实现 MATCH AGAINST 检索 - // drizzle-kit 无法生成 FULLTEXT 索引声明,迁移文件中需手动追加 CREATE FULLTEXT INDEX - contentText: text("content_text").generatedAlwaysAs( - sql`CAST(content AS CHAR)`, - { mode: "stored" }, - ), - - type: questionTypeEnum.notNull(), - difficulty: int("difficulty").default(1), // 1-5 - - // Self-reference for "Infinite Nesting" (Parent Question) - // e.g., A reading comprehension passage (Parent) -> 5 sub-questions (Children) - parentId: varchar("parent_id", { length: 128 }), - - authorId: varchar("author_id", { length: 128 }).notNull().references(() => users.id), - - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - parentIdIdx: index("parent_id_idx").on(table.parentId), // Critical for querying children - authorIdIdx: index("author_id_idx").on(table.authorId), - // P3-6: 题目按类型+难度筛选的高频查询索引 - typeDifficultyIdx: index("questions_type_difficulty_idx").on(table.type, table.difficulty), -})); - -// Many-to-Many: Questions <-> Knowledge Points -export const questionsToKnowledgePoints = mysqlTable("questions_to_knowledge_points", { - questionId: varchar("question_id", { length: 128 }).notNull(), - knowledgePointId: varchar("knowledge_point_id", { length: 128 }).notNull(), -}, (table) => ({ - pk: primaryKey({ columns: [table.questionId, table.knowledgePointId] }), - kpIdx: index("kp_idx").on(table.knowledgePointId), // For querying "All questions under this KP" - qFk: foreignKey({ - columns: [table.questionId], - foreignColumns: [questions.id], - name: "q_kp_qid_fk" - }).onDelete("cascade"), - kpFk: foreignKey({ - columns: [table.knowledgePointId], - foreignColumns: [knowledgePoints.id], - name: "q_kp_kpid_fk" - }).onDelete("cascade"), -})); - -// --- 4. Academic / Teaching Flow --- - -export const subjects = mysqlTable("subjects", { - id: id("id").primaryKey(), - name: varchar("name", { length: 100 }).notNull().unique(), - code: varchar("code", { length: 50 }).unique(), - order: int("order").default(0), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - nameIdx: index("subjects_name_idx").on(table.name), -})); - -export const textbooks = mysqlTable("textbooks", { - id: id("id").primaryKey(), - title: varchar("title", { length: 255 }).notNull(), - subject: varchar("subject", { length: 100 }).notNull(), - grade: varchar("grade", { length: 50 }), - publisher: varchar("publisher", { length: 100 }), - - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}); - -export const chapters = mysqlTable("chapters", { - id: id("id").primaryKey(), - textbookId: varchar("textbook_id", { length: 128 }).notNull().references(() => textbooks.id, { onDelete: "cascade" }), - title: varchar("title", { length: 255 }).notNull(), - order: int("order").default(0), - - // Chapters can also be nested - parentId: varchar("parent_id", { length: 128 }), - - content: text("content"), - - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - textbookIdx: index("textbook_idx").on(table.textbookId), - parentIdIdx: index("parent_id_idx").on(table.parentId), -})); - -// --- 5. School Management --- - -export const departments = mysqlTable("departments", { - id: id("id").primaryKey(), - name: varchar("name", { length: 255 }).notNull().unique(), - description: text("description"), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - nameIdx: index("departments_name_idx").on(table.name), -})) - -export const classrooms = mysqlTable("classrooms", { - id: id("id").primaryKey(), - name: varchar("name", { length: 255 }).notNull().unique(), - building: varchar("building", { length: 100 }), - floor: int("floor"), - capacity: int("capacity"), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - nameIdx: index("classrooms_name_idx").on(table.name), -})) - -export const academicYears = mysqlTable("academic_years", { - id: id("id").primaryKey(), - name: varchar("name", { length: 100 }).notNull().unique(), - startDate: timestamp("start_date", { mode: "date" }).notNull(), - endDate: timestamp("end_date", { mode: "date" }).notNull(), - isActive: boolean("is_active").default(false).notNull(), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - nameIdx: index("academic_years_name_idx").on(table.name), - activeIdx: index("academic_years_active_idx").on(table.isActive), -})) - -export const schools = mysqlTable("schools", { - id: id("id").primaryKey(), - name: varchar("name", { length: 255 }).notNull().unique(), - code: varchar("code", { length: 50 }).unique(), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - nameIdx: index("schools_name_idx").on(table.name), - codeIdx: index("schools_code_idx").on(table.code), -})) - -export const grades = mysqlTable("grades", { - id: id("id").primaryKey(), - schoolId: varchar("school_id", { length: 128 }).notNull(), - name: varchar("name", { length: 100 }).notNull(), - order: int("order").default(0).notNull(), - gradeHeadId: varchar("grade_head_id", { length: 128 }), - teachingHeadId: varchar("teaching_head_id", { length: 128 }), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - schoolIdx: index("grades_school_idx").on(table.schoolId), - schoolNameUnique: index("grades_school_name_uniq").on(table.schoolId, table.name), - gradeHeadIdx: index("grades_grade_head_idx").on(table.gradeHeadId), - teachingHeadIdx: index("grades_teaching_head_idx").on(table.teachingHeadId), - schoolFk: foreignKey({ - columns: [table.schoolId], - foreignColumns: [schools.id], - name: "g_s_fk", - }).onDelete("cascade"), - gradeHeadFk: foreignKey({ - columns: [table.gradeHeadId], - foreignColumns: [users.id], - name: "g_gh_fk", - }).onDelete("set null"), - teachingHeadFk: foreignKey({ - columns: [table.teachingHeadId], - foreignColumns: [users.id], - name: "g_th_fk", - }).onDelete("set null"), -})) - -// --- 6. Classes / Enrollment / Schedule --- - -export const classEnrollmentStatusEnum = mysqlEnum("class_enrollment_status", ["active", "inactive"]); - -export const classes = mysqlTable("classes", { - id: id("id").primaryKey(), - schoolName: varchar("school_name", { length: 255 }), - schoolId: varchar("school_id", { length: 128 }), - name: varchar("name", { length: 255 }).notNull(), - grade: varchar("grade", { length: 50 }).notNull(), - gradeId: varchar("grade_id", { length: 128 }), - homeroom: varchar("homeroom", { length: 50 }), - room: varchar("room", { length: 50 }), - invitationCode: varchar("invitation_code", { length: 6 }).unique(), - teacherId: varchar("teacher_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - teacherIdx: index("classes_teacher_idx").on(table.teacherId), - gradeIdx: index("classes_grade_idx").on(table.grade), - schoolIdx: index("classes_school_idx").on(table.schoolId), - gradeIdIdx: index("classes_grade_id_idx").on(table.gradeId), - schoolFk: foreignKey({ - columns: [table.schoolId], - foreignColumns: [schools.id], - name: "c_s_fk", - }).onDelete("set null"), - gradeFk: foreignKey({ - columns: [table.gradeId], - foreignColumns: [grades.id], - name: "c_g_fk", - }).onDelete("set null"), -})); - -export const classSubjectTeachers = mysqlTable("class_subject_teachers", { - classId: varchar("class_id", { length: 128 }).notNull(), - subjectId: varchar("subject_id", { length: 128 }).notNull(), - teacherId: varchar("teacher_id", { length: 128 }).references(() => users.id, { onDelete: "set null" }), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - pk: primaryKey({ columns: [table.classId, table.subjectId] }), - classIdx: index("class_subject_teachers_class_idx").on(table.classId), - teacherIdx: index("class_subject_teachers_teacher_idx").on(table.teacherId), - subjectIdIdx: index("class_subject_teachers_subject_id_idx").on(table.subjectId), - classFk: foreignKey({ - columns: [table.classId], - foreignColumns: [classes.id], - name: "cst_c_fk", - }).onDelete("cascade"), - subjectFk: foreignKey({ - columns: [table.subjectId], - foreignColumns: [subjects.id], - name: "cst_s_fk", - }).onDelete("cascade"), -})); - -/** - * 班级邀请码表(v3 新增,对标 Google Classroom / 钉钉教育 / 智学网)。 - * - * 设计要点: - * - 独立表而非挂在 classes 表上,支持有效期/次数/审计/多码并存 - * - 6 位字母数字(剔除歧义字符 0/O/1/I/L),空间 22^6 ≈ 1.13 亿 - * - status 枚举:active/disabled/expired/exhausted - * - max_uses NULL=无限;expires_at NULL=永久 - * - 软删除:revoke 时设置 status=disabled + revoked_at,不物理删除(审计需要) - * - * 与 classes.invitationCode 的关系: - * - 新表上线后,enrollStudentByInvitationCode / enrollTeacherByInvitationCode 优先查新表 - * - classes.invitationCode 保留作为 fallback,下个版本移除 - */ -export const classInvitationCodeStatusEnum = mysqlEnum("class_invitation_code_status", [ - "active", - "disabled", - "expired", - "exhausted", -]); - -export const classInvitationCodes = mysqlTable("class_invitation_codes", { - id: id("id").primaryKey(), - classId: varchar("class_id", { length: 128 }).notNull().references(() => classes.id, { onDelete: "cascade" }), - code: varchar("code", { length: 8 }).notNull().unique(), - status: classInvitationCodeStatusEnum.default("active").notNull(), - maxUses: int("max_uses"), - usedCount: int("used_count").default(0).notNull(), - expiresAt: timestamp("expires_at"), - createdBy: varchar("created_by", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), - revokedAt: timestamp("revoked_at"), - revokedBy: varchar("revoked_by", { length: 128 }), - note: varchar("note", { length: 255 }), -}, (table) => ({ - codeIdx: uniqueIndex("class_invitation_codes_code_idx").on(table.code), - classIdx: index("class_invitation_codes_class_idx").on(table.classId), - statusExpiresIdx: index("class_invitation_codes_status_expires_idx").on(table.status, table.expiresAt), - classFk: foreignKey({ - columns: [table.classId], - foreignColumns: [classes.id], - name: "cic_c_fk", - }).onDelete("cascade"), -})); - -export const classEnrollments = mysqlTable("class_enrollments", { - classId: varchar("class_id", { length: 128 }).notNull(), - studentId: varchar("student_id", { length: 128 }).notNull(), - status: classEnrollmentStatusEnum.default("active").notNull(), - createdAt: timestamp("created_at").defaultNow().notNull(), -}, (table) => ({ - pk: primaryKey({ columns: [table.classId, table.studentId] }), - classIdx: index("class_enrollments_class_idx").on(table.classId), - studentIdx: index("class_enrollments_student_idx").on(table.studentId), - classFk: foreignKey({ - columns: [table.classId], - foreignColumns: [classes.id], - name: "ce_c_fk", - }).onDelete("cascade"), - studentFk: foreignKey({ - columns: [table.studentId], - foreignColumns: [users.id], - name: "ce_s_fk", - }).onDelete("cascade"), -})); - -export const classSchedule = mysqlTable("class_schedule", { - id: id("id").primaryKey(), - classId: varchar("class_id", { length: 128 }).notNull(), - weekday: int("weekday").notNull(), - startTime: varchar("start_time", { length: 5 }).notNull(), - endTime: varchar("end_time", { length: 5 }).notNull(), - course: varchar("course", { length: 255 }).notNull(), - location: varchar("location", { length: 100 }), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - classIdx: index("class_schedule_class_idx").on(table.classId), - classDayIdx: index("class_schedule_class_day_idx").on(table.classId, table.weekday), - classFk: foreignKey({ - columns: [table.classId], - foreignColumns: [classes.id], - name: "cs_c_fk", - }).onDelete("cascade"), -})); - -// --- 7. Exam Proctoring Config (考试监考配置) --- - -export const examModeEnum = mysqlEnum("exam_mode", ["homework", "timed", "proctored"]); -export const proctoringEventTypeEnum = mysqlEnum("event_type", [ - "tab_switch", "window_blur", "copy_attempt", "paste_attempt", - "right_click", "devtools_open", "fullscreen_exit", "idle_timeout" -]); - -export const exams = mysqlTable("exams", { - id: id("id").primaryKey(), - title: varchar("title", { length: 255 }).notNull(), - description: text("description"), - - /** - * Stores the hierarchical structure of the exam. - * Expected JSON Schema (3-level: section > group > question): - * type ExamStructure = Array< - * | { type: 'section', title: string, level?: number, children: ExamStructure } - * | { type: 'group', title: string, instruction?: string, children: ExamStructure } - * | { type: 'question', questionId: string, score: number } - * > - * - * Note: This is for UI presentation/ordering. - * Real relational integrity is maintained in 'exam_questions' table. - */ - structure: json("structure"), - - creatorId: varchar("creator_id", { length: 128 }).notNull().references(() => users.id), - - // Link to Subjects and Grades - subjectId: varchar("subject_id", { length: 128 }).references(() => subjects.id), - gradeId: varchar("grade_id", { length: 128 }).references(() => grades.id), - - startTime: timestamp("start_time"), - endTime: timestamp("end_time"), - - // P2: Online exam mode + proctoring settings - examMode: examModeEnum.default("homework"), - durationMinutes: int("duration_minutes"), - shuffleQuestions: boolean("shuffle_questions").default(false), - allowLateStart: boolean("allow_late_start").default(false), - lateStartGraceMinutes: int("late_start_grace_minutes").default(0), - antiCheatEnabled: boolean("anti_cheat_enabled").default(false), - - // Status: draft, published, ongoing, finished - status: varchar("status", { length: 50 }).default("draft"), - - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - subjectIdx: index("exams_subject_idx").on(table.subjectId), - gradeIdx: index("exams_grade_idx").on(table.gradeId), - // P3-6: 考试状态按时间排序、按创建者筛选的高频查询索引 - statusCreatedIdx: index("exams_status_created_idx").on(table.status, table.createdAt), - creatorIdx: index("exams_creator_idx").on(table.creatorId), -})); - -// Linking Exams to Questions (Many-to-Many often, or One-to-Many if specific to exam) -// Usually questions are reused, so Many-to-Many -export const examQuestions = mysqlTable("exam_questions", { - examId: varchar("exam_id", { length: 128 }).notNull().references(() => exams.id, { onDelete: "cascade" }), - questionId: varchar("question_id", { length: 128 }).notNull().references(() => questions.id, { onDelete: "cascade" }), - score: int("score").default(0), - order: int("order").default(0), -}, (table) => ({ - pk: primaryKey({ columns: [table.examId, table.questionId] }), -})); - -export const examSubmissions = mysqlTable("exam_submissions", { - id: id("id").primaryKey(), - examId: varchar("exam_id", { length: 128 }).notNull().references(() => exams.id, { onDelete: "cascade" }), - studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), - - score: int("score"), // Total score - status: varchar("status", { length: 50 }).default("started"), // started, submitted, graded - - submittedAt: timestamp("submitted_at"), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - examStudentIdx: index("exam_student_idx").on(table.examId, table.studentId), - // P3-6: 提交记录按考试状态筛选、按提交时间排序的高频查询索引 - examStatusIdx: index("exam_submissions_exam_status_idx").on(table.examId, table.status), - submittedAtIdx: index("exam_submissions_submitted_at_idx").on(table.submittedAt), -})); - -export const submissionAnswers = mysqlTable("submission_answers", { - id: id("id").primaryKey(), - submissionId: varchar("submission_id", { length: 128 }).notNull().references(() => examSubmissions.id, { onDelete: "cascade" }), - questionId: varchar("question_id", { length: 128 }).notNull().references(() => questions.id), - - answerContent: json("answer_content"), // Student's answer - score: int("score"), // Score for this specific question - feedback: text("feedback"), // Teacher's feedback - - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - submissionIdx: index("submission_idx").on(table.submissionId), -})); - -export const homeworkAssignments = mysqlTable("homework_assignments", { - id: id("id").primaryKey(), - sourceExamId: varchar("source_exam_id", { length: 128 }), - title: varchar("title", { length: 255 }).notNull(), - description: text("description"), - structure: json("structure"), - status: varchar("status", { length: 50 }).default("draft"), - creatorId: varchar("creator_id", { length: 128 }).notNull(), - availableAt: timestamp("available_at"), - dueAt: timestamp("due_at"), - allowLate: boolean("allow_late").default(false).notNull(), - lateDueAt: timestamp("late_due_at"), - maxAttempts: int("max_attempts").default(1).notNull(), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - creatorIdx: index("hw_assignment_creator_idx").on(table.creatorId), - sourceExamIdx: index("hw_assignment_source_exam_idx").on(table.sourceExamId), - statusIdx: index("hw_assignment_status_idx").on(table.status), - sourceExamFk: foreignKey({ - columns: [table.sourceExamId], - foreignColumns: [exams.id], - name: "hw_asg_exam_fk", - }).onDelete("cascade"), - creatorFk: foreignKey({ - columns: [table.creatorId], - foreignColumns: [users.id], - name: "hw_asg_creator_fk", - }).onDelete("cascade"), -})); - -export const homeworkAssignmentQuestions = mysqlTable("homework_assignment_questions", { - assignmentId: varchar("assignment_id", { length: 128 }).notNull(), - questionId: varchar("question_id", { length: 128 }).notNull(), - score: int("score").default(0), - order: int("order").default(0), -}, (table) => ({ - pk: primaryKey({ columns: [table.assignmentId, table.questionId] }), - assignmentIdx: index("hw_assignment_questions_assignment_idx").on(table.assignmentId), - assignmentFk: foreignKey({ - columns: [table.assignmentId], - foreignColumns: [homeworkAssignments.id], - name: "hw_aq_a_fk", - }).onDelete("cascade"), - questionFk: foreignKey({ - columns: [table.questionId], - foreignColumns: [questions.id], - name: "hw_aq_q_fk", - }).onDelete("cascade"), -})); - -export const homeworkAssignmentTargets = mysqlTable("homework_assignment_targets", { - assignmentId: varchar("assignment_id", { length: 128 }).notNull(), - studentId: varchar("student_id", { length: 128 }).notNull(), - createdAt: timestamp("created_at").defaultNow().notNull(), -}, (table) => ({ - pk: primaryKey({ columns: [table.assignmentId, table.studentId] }), - assignmentIdx: index("hw_assignment_targets_assignment_idx").on(table.assignmentId), - studentIdx: index("hw_assignment_targets_student_idx").on(table.studentId), - assignmentFk: foreignKey({ - columns: [table.assignmentId], - foreignColumns: [homeworkAssignments.id], - name: "hw_at_a_fk", - }).onDelete("cascade"), - studentFk: foreignKey({ - columns: [table.studentId], - foreignColumns: [users.id], - name: "hw_at_s_fk", - }).onDelete("cascade"), -})); - -export const homeworkSubmissions = mysqlTable("homework_submissions", { - id: id("id").primaryKey(), - assignmentId: varchar("assignment_id", { length: 128 }).notNull(), - studentId: varchar("student_id", { length: 128 }).notNull(), - attemptNo: int("attempt_no").default(1).notNull(), - score: int("score"), - status: varchar("status", { length: 50 }).default("started"), - startedAt: timestamp("started_at").defaultNow().notNull(), - submittedAt: timestamp("submitted_at"), - isLate: boolean("is_late").default(false).notNull(), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - assignmentStudentIdx: index("hw_assignment_student_idx").on(table.assignmentId, table.studentId), - assignmentFk: foreignKey({ - columns: [table.assignmentId], - foreignColumns: [homeworkAssignments.id], - name: "hw_sub_a_fk", - }).onDelete("cascade"), - studentFk: foreignKey({ - columns: [table.studentId], - foreignColumns: [users.id], - name: "hw_sub_student_fk", - }).onDelete("cascade"), -})); - -export const homeworkAnswers = mysqlTable("homework_answers", { - id: id("id").primaryKey(), - submissionId: varchar("submission_id", { length: 128 }).notNull(), - questionId: varchar("question_id", { length: 128 }).notNull(), - answerContent: json("answer_content"), - score: int("score"), - feedback: text("feedback"), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - submissionIdx: index("hw_answer_submission_idx").on(table.submissionId), - submissionQuestionIdx: index("hw_answer_submission_question_idx").on(table.submissionId, table.questionId), - submissionFk: foreignKey({ - columns: [table.submissionId], - foreignColumns: [homeworkSubmissions.id], - name: "hw_ans_sub_fk", - }).onDelete("cascade"), - questionFk: foreignKey({ - columns: [table.questionId], - foreignColumns: [questions.id], - name: "hw_ans_q_fk", - }), -})); - -export const aiProviderVisibilityEnum = mysqlEnum("visibility", ["public", "private"]); - -export const aiProviders = mysqlTable("ai_providers", { - id: id("id").primaryKey(), - provider: mysqlEnum("provider", ["zhipu", "openai", "gemini", "custom", "ollama"]).notNull(), - baseUrl: varchar("base_url", { length: 512 }), - model: varchar("model", { length: 128 }).notNull(), - apiKeyEncrypted: text("api_key_encrypted").notNull(), - apiKeyLast4: varchar("api_key_last4", { length: 4 }), - isDefault: boolean("is_default").default(false).notNull(), - // V3: 可见性 — public 由管理员发布,全员可用;private 仅创建者可见 - visibility: aiProviderVisibilityEnum.default("private").notNull(), - createdBy: varchar("created_by", { length: 128 }), - updatedBy: varchar("updated_by", { length: 128 }), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - providerIdx: index("ai_provider_idx").on(table.provider), - defaultIdx: index("ai_provider_default_idx").on(table.isDefault), - visibilityIdx: index("ai_provider_visibility_idx").on(table.visibility), - createdByIdx: index("ai_provider_created_by_idx").on(table.createdBy), -})); - -// --- 8. Announcements --- - -export const announcementTypeEnum = mysqlEnum("type", ["school", "grade", "class"]); -export const announcementStatusEnum = mysqlEnum("status", ["draft", "published", "archived"]); - -export const announcements = mysqlTable("announcements", { - id: id("id").primaryKey(), - title: varchar("title", { length: 255 }).notNull(), - content: text("content").notNull(), - type: announcementTypeEnum.default("school").notNull(), - status: announcementStatusEnum.default("draft").notNull(), - targetGradeId: varchar("target_grade_id", { length: 128 }), - 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) => ({ - authorIdx: index("announcements_author_idx").on(table.authorId), - statusIdx: index("announcements_status_idx").on(table.status), - 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 --- - -export const auditLogStatusEnum = mysqlEnum("status", ["success", "failure"]); - -export const auditLogs = mysqlTable("audit_logs", { - id: id("id").primaryKey(), - userId: varchar("user_id", { length: 128 }).notNull(), - userName: varchar("user_name", { length: 255 }).notNull(), - action: varchar("action", { length: 255 }).notNull(), - module: varchar("module", { length: 128 }).notNull(), - targetId: varchar("target_id", { length: 128 }), - targetType: varchar("target_type", { length: 128 }), - detail: text("detail"), - ipAddress: varchar("ip_address", { length: 45 }), - userAgent: varchar("user_agent", { length: 512 }), - status: auditLogStatusEnum.default("success").notNull(), - createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), -}, (table) => ({ - userIdIdx: index("audit_logs_user_id_idx").on(table.userId), - moduleIdx: index("audit_logs_module_idx").on(table.module), - actionIdx: index("audit_logs_action_idx").on(table.action), - statusIdx: index("audit_logs_status_idx").on(table.status), - createdAtIdx: index("audit_logs_created_at_idx").on(table.createdAt), -})); - -export const loginLogActionEnum = mysqlEnum("action", ["signin", "signout", "signup"]); -export const loginLogStatusEnum = mysqlEnum("status", ["success", "failure"]); - -export const loginLogs = mysqlTable("login_logs", { - id: id("id").primaryKey(), - userId: varchar("user_id", { length: 128 }), - userEmail: varchar("user_email", { length: 255 }).notNull(), - action: loginLogActionEnum.notNull(), - status: loginLogStatusEnum.default("success").notNull(), - ipAddress: varchar("ip_address", { length: 45 }), - userAgent: varchar("user_agent", { length: 512 }), - errorMessage: text("error_message"), - createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), -}, (table) => ({ - userIdIdx: index("login_logs_user_id_idx").on(table.userId), - userEmailIdx: index("login_logs_user_email_idx").on(table.userEmail), - actionIdx: index("login_logs_action_idx").on(table.action), - statusIdx: index("login_logs_status_idx").on(table.status), - createdAtIdx: index("login_logs_created_at_idx").on(table.createdAt), -})); - -// --- 9b. Invitation Codes (注册邀请码,audit-P2-3 新增) --- - -/** - * 注册邀请码表(audit-P2-3) - * - * 管理员批量生成,用于: - * - 预分配注册角色(student/teacher/parent/admin) - * - 可选绑定邮箱(限定指定用户使用) - * - 可选绑定班级(注册后自动加入班级) - * - 可选过期时间 - */ -export const invitationCodes = mysqlTable("invitation_codes", { - id: id("id").primaryKey(), - /** 邀请码明文(8-12 位大写字母+数字组合,unique) */ - code: varchar("code", { length: 64 }).notNull().unique(), - /** 可选:限定邮箱使用(未设置则任意邮箱可用) */ - email: varchar("email", { length: 255 }), - /** 注册后分配的角色(student/teacher/parent/admin) */ - role: varchar("role", { length: 50 }).notNull(), - /** 可选:注册后自动加入的班级 ID */ - classId: varchar("class_id", { length: 128 }), - /** 可选:管理员备注 */ - notes: varchar("notes", { length: 500 }), - createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), - /** 创建者(管理员 userId) */ - createdBy: varchar("created_by", { length: 128 }), - /** 可选:过期时间(未设置则永久有效) */ - expiresAt: timestamp("expires_at", { mode: "date" }), - /** 已使用者 userId(未使用为 null) */ - usedBy: varchar("used_by", { length: 128 }), - /** 使用时间(未使用为 null) */ - usedAt: timestamp("used_at", { mode: "date" }), -}, (table) => ({ - codeIdx: index("invitation_codes_code_idx").on(table.code), - emailIdx: index("invitation_codes_email_idx").on(table.email), - usedByIdx: index("invitation_codes_used_by_idx").on(table.usedBy), -})); - -// --- 10. Data Change Logs (数据变更日志) --- - -export const dataChangeLogActionEnum = mysqlEnum("action", ["create", "update", "delete"]); - -export const dataChangeLogs = mysqlTable("data_change_logs", { - id: varchar("id", { length: 128 }).primaryKey(), - tableName: varchar("table_name", { length: 128 }).notNull(), - recordId: varchar("record_id", { length: 128 }).notNull(), - action: dataChangeLogActionEnum.notNull(), - oldValue: text("old_value"), - newValue: text("new_value"), - changedBy: varchar("changed_by", { length: 128 }).notNull(), - changedByName: varchar("changed_by_name", { length: 255 }).notNull(), - ipAddress: varchar("ip_address", { length: 45 }), - createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), -}, (table) => ({ - tableNameIdx: index("data_change_logs_table_name_idx").on(table.tableName), - recordIdIdx: index("data_change_logs_record_id_idx").on(table.recordId), - actionIdx: index("data_change_logs_action_idx").on(table.action), - changedByIdx: index("data_change_logs_changed_by_idx").on(table.changedBy), - createdAtIdx: index("data_change_logs_created_at_idx").on(table.createdAt), -})); - -// Re-export old courses table if needed or deprecate it. -// Assuming we are replacing the old simple schema with this robust one. -// But if there were existing tables, we might keep them or comment them out. -// For this task, I will overwrite completely as this is a "System Architect" redesign. - -// --- 11. Grade Records (成绩录入) --- - -export const gradeRecordTypeEnum = mysqlEnum("type", ["exam", "quiz", "homework", "other"]); -export const gradeRecordSemesterEnum = mysqlEnum("semester", ["1", "2"]); - -export const gradeRecords = mysqlTable("grade_records", { - id: id("id").primaryKey(), - studentId: varchar("student_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" }), - examId: varchar("exam_id", { length: 128 }), - academicYearId: varchar("academic_year_id", { length: 128 }), - title: varchar("title", { length: 255 }).notNull(), - score: decimal("score", { precision: 6, scale: 2 }).notNull(), - fullScore: decimal("full_score", { precision: 6, scale: 2 }).default("100").notNull(), - type: gradeRecordTypeEnum.default("exam").notNull(), - semester: gradeRecordSemesterEnum.default("1").notNull(), - recordedBy: varchar("recorded_by", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), - remark: text("remark"), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - studentIdx: index("grade_records_student_idx").on(table.studentId), - classIdx: index("grade_records_class_idx").on(table.classId), - subjectIdx: index("grade_records_subject_idx").on(table.subjectId), - examIdx: index("grade_records_exam_idx").on(table.examId), - classSubjectIdx: index("grade_records_class_subject_idx").on(table.classId, table.subjectId), - recordedByIdx: index("grade_records_recorded_by_idx").on(table.recordedBy), - classFk: foreignKey({ - columns: [table.classId], - foreignColumns: [classes.id], - name: "gr_c_fk", - }).onDelete("cascade"), - studentFk: foreignKey({ - columns: [table.studentId], - foreignColumns: [users.id], - name: "gr_s_fk", - }).onDelete("cascade"), - subjectFk: foreignKey({ - columns: [table.subjectId], - foreignColumns: [subjects.id], - name: "gr_sub_fk", - }).onDelete("cascade"), - recordedByFk: foreignKey({ - columns: [table.recordedBy], - foreignColumns: [users.id], - name: "gr_rb_fk", - }).onDelete("cascade"), -})); - -// --- 11.1 Grade Record Answers (成绩每题得分) --- - -export const gradeRecordAnswers = mysqlTable("grade_record_answers", { - id: id("id").primaryKey(), - gradeRecordId: varchar("grade_record_id", { length: 128 }).notNull().references(() => gradeRecords.id, { onDelete: "cascade" }), - questionId: varchar("question_id", { length: 128 }).notNull().references(() => questions.id, { onDelete: "cascade" }), - score: decimal("score", { precision: 6, scale: 2 }).notNull(), - fullScore: decimal("full_score", { precision: 6, scale: 2 }).notNull(), - feedback: text("feedback"), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - gradeRecordIdx: index("grade_record_answers_record_idx").on(table.gradeRecordId), - questionIdx: index("grade_record_answers_question_idx").on(table.questionId), - gradeRecordFk: foreignKey({ - columns: [table.gradeRecordId], - foreignColumns: [gradeRecords.id], - name: "gra_gr_fk", - }).onDelete("cascade"), - questionFk: foreignKey({ - columns: [table.questionId], - foreignColumns: [questions.id], - name: "gra_q_fk", - }).onDelete("cascade"), -})); - -// --- 12. File Attachments (文件附件) --- - -export const fileAttachments = mysqlTable("file_attachments", { - id: varchar("id", { length: 128 }).primaryKey(), - filename: varchar("filename", { length: 255 }).notNull(), - originalName: varchar("original_name", { length: 255 }).notNull(), - mimeType: varchar("mime_type", { length: 128 }).notNull(), - size: bigint("size", { mode: "number" }).notNull(), - storagePath: varchar("storage_path", { length: 512 }).notNull(), - url: varchar("url", { length: 512 }), - uploaderId: varchar("uploader_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), - targetType: varchar("target_type", { length: 128 }), - targetId: varchar("target_id", { length: 128 }), - createdAt: timestamp("created_at").defaultNow().notNull(), -}, (table) => ({ - uploaderIdx: index("file_attachments_uploader_idx").on(table.uploaderId), - targetIdx: index("file_attachments_target_idx").on(table.targetType, table.targetId), - createdAtIdx: index("file_attachments_created_at_idx").on(table.createdAt), -})); - -// --- 13. Course Plans (课程计划) --- - -export const coursePlanStatusEnum = mysqlEnum("status", ["planning", "active", "completed", "paused"]); -export const coursePlanSemesterEnum = mysqlEnum("semester", ["1", "2"]); - -export const coursePlans = mysqlTable("course_plans", { - id: id("id").primaryKey(), - classId: varchar("class_id", { length: 128 }).notNull().references(() => classes.id, { onDelete: "cascade" }), - subjectId: varchar("subject_id", { length: 128 }).notNull().references(() => subjects.id, { onDelete: "cascade" }), - teacherId: varchar("teacher_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), - academicYearId: varchar("academic_year_id", { length: 128 }), - semester: coursePlanSemesterEnum.default("1").notNull(), - totalHours: int("total_hours").default(0).notNull(), - completedHours: int("completed_hours").default(0).notNull(), - weeklyHours: int("weekly_hours").default(0).notNull(), - startDate: date("start_date"), - endDate: date("end_date"), - syllabus: text("syllabus"), - objectives: text("objectives"), - status: coursePlanStatusEnum.default("planning").notNull(), - createdBy: varchar("created_by", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - classIdx: index("course_plans_class_idx").on(table.classId), - teacherIdx: index("course_plans_teacher_idx").on(table.teacherId), - subjectIdx: index("course_plans_subject_idx").on(table.subjectId), - statusIdx: index("course_plans_status_idx").on(table.status), - classSubjectIdx: index("course_plans_class_subject_idx").on(table.classId, table.subjectId), - classFk: foreignKey({ - columns: [table.classId], - foreignColumns: [classes.id], - name: "cp_c_fk", - }).onDelete("cascade"), - subjectFk: foreignKey({ - columns: [table.subjectId], - foreignColumns: [subjects.id], - name: "cp_s_fk", - }).onDelete("cascade"), - teacherFk: foreignKey({ - columns: [table.teacherId], - foreignColumns: [users.id], - name: "cp_t_fk", - }).onDelete("cascade"), - createdByFk: foreignKey({ - columns: [table.createdBy], - foreignColumns: [users.id], - name: "cp_cb_fk", - }).onDelete("cascade"), -})); - -export const coursePlanItems = mysqlTable("course_plan_items", { - id: id("id").primaryKey(), - planId: varchar("plan_id", { length: 128 }).notNull().references(() => coursePlans.id, { onDelete: "cascade" }), - week: int("week").notNull(), - topic: varchar("topic", { length: 255 }).notNull(), - content: text("content"), - hours: int("hours").default(2).notNull(), - textbookChapter: varchar("textbook_chapter", { length: 255 }), - notes: text("notes"), - isCompleted: boolean("is_completed").default(false).notNull(), - completedAt: date("completed_at"), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - planIdx: index("course_plan_items_plan_idx").on(table.planId), - planWeekIdx: index("course_plan_items_plan_week_idx").on(table.planId, table.week), - planFk: foreignKey({ - columns: [table.planId], - foreignColumns: [coursePlans.id], - name: "cpi_p_fk", - }).onDelete("cascade"), -})); - -// --- 14. Messages (站内消息) --- - -export const messages = mysqlTable("messages", { - id: id("id").primaryKey(), - senderId: varchar("sender_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), - receiverId: varchar("receiver_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), - subject: varchar("subject", { length: 255 }), - content: text("content").notNull(), - isRead: boolean("is_read").default(false).notNull(), - readAt: timestamp("read_at", { mode: "date" }), - parentMessageId: varchar("parent_message_id", { length: 128 }), // 回复链 - // 软删除:发送方/接收方各自独立删除,互不影响 - senderDeletedAt: timestamp("sender_deleted_at", { mode: "date" }), - receiverDeletedAt: timestamp("receiver_deleted_at", { mode: "date" }), - // V2-P2-13c: 消息星标(接收方可标记重要消息) - isStarred: boolean("is_starred").default(false).notNull(), - // P2-4: 消息撤回(发送方在 2 分钟窗口内可撤回;非空即表示已撤回) - recalledAt: timestamp("recalled_at", { mode: "date" }), - // P2-2: 群组消息 ID(fan-out on write:教师群发时每条消息共享同一 groupMessageId) - groupMessageId: varchar("group_message_id", { length: 128 }), - createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), -}, (table) => ({ - senderIdx: index("messages_sender_idx").on(table.senderId), - receiverIdx: index("messages_receiver_idx").on(table.receiverId), - 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), - // P2-2: 群组消息索引(按 groupMessageId 聚合查询) - groupMessageIdx: index("messages_group_message_idx").on(table.groupMessageId), -})); - -// --- 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 }), - // P2-10: 跨设备草稿同步 - 设备 ID(标识来源设备) - deviceId: varchar("device_id", { length: 128 }), - // P2-10: 跨设备草稿同步 - 版本号(乐观锁,冲突检测) - version: int("version").default(1).notNull(), - 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), - // P2-10: 支持按 userId + parentMessageId 查询最新版本草稿 - userParentIdx: index("message_drafts_user_parent_idx").on(table.userId, table.parentMessageId), -})); - -// --- 14c. Message Templates (快捷回复模板, P2-6) --- - -export const messageTemplates = mysqlTable("message_templates", { - id: id("id").primaryKey(), - userId: varchar("user_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), - title: varchar("title", { length: 100 }).notNull(), - content: text("content").notNull(), - // 排序权重(越小越靠前),默认 0 - sortOrder: int("sort_order").default(0).notNull(), - updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().onUpdateNow().notNull(), - createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), -}, (table) => ({ - userIdx: index("message_templates_user_idx").on(table.userId), - userSortIdx: index("message_templates_user_sort_idx").on(table.userId, table.sortOrder), -})); - -// --- 14d. Message Reports (消息举报, P2-5) --- - -export const messageReports = mysqlTable("message_reports", { - id: id("id").primaryKey(), - // 举报人 - reporterId: varchar("reporter_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), - // 被举报消息 ID - messageId: varchar("message_id", { length: 128 }).notNull().references(() => messages.id, { onDelete: "cascade" }), - // 被举报人(冗余,便于按被举报人查询) - reportedUserId: varchar("reported_user_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), - // 举报理由分类:spam / harassment / inappropriate / other - reason: varchar("reason", { length: 50 }).notNull(), - // 详细描述(可选) - description: text("description"), - // 举报状态:pending / reviewed / dismissed / actioned - status: varchar("status", { length: 20 }).notNull().default("pending"), - createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), - updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - reporterIdx: index("message_reports_reporter_idx").on(table.reporterId), - messageIdx: index("message_reports_message_idx").on(table.messageId), - reportedUserIdx: index("message_reports_reported_user_idx").on(table.reportedUserId), - statusIdx: index("message_reports_status_idx").on(table.status), -})); - -// --- 14e. User Blocks (用户屏蔽, P2-5) --- - -export const userBlocks = mysqlTable("user_blocks", { - id: id("id").primaryKey(), - // 屏蔽发起人 - blockerId: varchar("blocker_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), - // 被屏蔽人 - blockedId: varchar("blocked_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), - createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), -}, (table) => ({ - // 同一对 (blocker, blocked) 只能存在一条记录 - blockerBlockedUnique: uniqueIndex("user_blocks_blocker_blocked_uniq").on(table.blockerId, table.blockedId), - blockerIdx: index("user_blocks_blocker_idx").on(table.blockerId), -})); - -// --- 15. Message Notifications (消息通知) --- - -export const messageNotifications = mysqlTable("message_notifications", { - id: id("id").primaryKey(), - userId: varchar("user_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), - type: varchar("type", { length: 128 }).notNull(), // "message", "announcement", "homework", "grade", "diagnostic" - title: varchar("title", { length: 255 }).notNull(), - 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 (通知偏好) --- - -export const notificationPreferences = mysqlTable("notification_preferences", { - id: varchar("id", { length: 128 }).primaryKey(), - userId: varchar("user_id", { length: 128 }).notNull().unique().references(() => users.id, { onDelete: "cascade" }), - emailEnabled: boolean("email_enabled").default(false).notNull(), - smsEnabled: boolean("sms_enabled").default(false).notNull(), - pushEnabled: boolean("push_enabled").default(true).notNull(), - homeworkNotifications: boolean("homework_notifications").default(true).notNull(), - gradeNotifications: boolean("grade_notifications").default(true).notNull(), - announcementNotifications: boolean("announcement_notifications").default(true).notNull(), - messageNotifications: boolean("message_notifications").default(true).notNull(), - attendanceNotifications: boolean("attendance_notifications").default(true).notNull(), - // 免打扰时段(格式 "HH:mm",如 "22:00") - quietHoursEnabled: boolean("quiet_hours_enabled").default(false).notNull(), - quietHoursStart: varchar("quiet_hours_start", { length: 5 }), - quietHoursEnd: varchar("quiet_hours_end", { length: 5 }), - createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), - updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - userIdIdx: index("notification_preferences_user_idx").on(table.userId), - userFk: foreignKey({ - columns: [table.userId], - foreignColumns: [users.id], - name: "np_u_fk", - }).onDelete("cascade"), -})); - -// --- 17. Parent-Student Relations (家长-子女关联) --- - -export const parentStudentRelations = mysqlTable("parent_student_relations", { - id: varchar("id", { length: 128 }).primaryKey().$defaultFn(() => createId()), - parentId: varchar("parent_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), - studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), - relation: varchar("relation", { length: 50 }), // 父亲/母亲/其他 - createdAt: timestamp("created_at").defaultNow().notNull(), -}, (table) => ({ - parentIdx: index("parent_student_relations_parent_idx").on(table.parentId), - studentIdx: index("parent_student_relations_student_idx").on(table.studentId), - parentFk: foreignKey({ - columns: [table.parentId], - foreignColumns: [users.id], - name: "psr_p_fk", - }).onDelete("cascade"), - studentFk: foreignKey({ - columns: [table.studentId], - foreignColumns: [users.id], - name: "psr_s_fk", - }).onDelete("cascade"), -})); - -// --- 18. Attendance (考勤管理) --- - -export const attendanceStatusEnum = mysqlEnum("status", ["present", "absent", "late", "early_leave", "excused", "school_activity"]); - -/** - * L-6 节次考勤:节次枚举。 - * - morning_reading: 早读 - * - morning: 上午 - * - afternoon: 下午 - * - evening: 晚自习 - * - full_day: 全日(默认值,向后兼容:null 也表示全日) - */ -export const attendancePeriodEnum = mysqlEnum("period", ["morning_reading", "morning", "afternoon", "evening", "full_day"]); - -export const attendanceRecords = mysqlTable("attendance_records", { - id: id("id").primaryKey(), - studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), - classId: varchar("class_id", { length: 128 }).notNull().references(() => classes.id, { onDelete: "cascade" }), - scheduleId: varchar("schedule_id", { length: 128 }), - date: date("date").notNull(), - status: attendanceStatusEnum.notNull(), - // L-6:节次考勤字段。null 表示全日记录(向后兼容已有数据)。 - period: attendancePeriodEnum, - remark: text("remark"), - reason: varchar("reason", { length: 255 }), - recordedBy: varchar("recorded_by", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - studentIdx: index("attendance_records_student_idx").on(table.studentId), - classIdx: index("attendance_records_class_idx").on(table.classId), - dateIdx: index("attendance_records_date_idx").on(table.date), - classDateIdx: index("attendance_records_class_date_idx").on(table.classId, table.date), - studentDateIdx: index("attendance_records_student_date_idx").on(table.studentId, table.date), - scheduleIdx: index("attendance_records_schedule_idx").on(table.scheduleId), - recordedByIdx: index("attendance_records_recorded_by_idx").on(table.recordedBy), - // L-6:班级 + 日期 + 节次复合索引,支持按节次查询班级当日点名 - classDatePeriodIdx: index("attendance_records_class_date_period_idx").on(table.classId, table.date, table.period), - classFk: foreignKey({ - columns: [table.classId], - foreignColumns: [classes.id], - name: "ar_c_fk", - }).onDelete("cascade"), - studentFk: foreignKey({ - columns: [table.studentId], - foreignColumns: [users.id], - name: "ar_s_fk", - }).onDelete("cascade"), - recordedByFk: foreignKey({ - columns: [table.recordedBy], - foreignColumns: [users.id], - name: "ar_rb_fk", - }).onDelete("cascade"), -})); - -export const attendanceRules = mysqlTable("attendance_rules", { - id: id("id").primaryKey(), - classId: varchar("class_id", { length: 128 }).references(() => classes.id, { onDelete: "cascade" }), - lateThresholdMinutes: int("late_threshold_minutes").default(15), - earlyLeaveThresholdMinutes: int("early_leave_threshold_minutes").default(15), - enableAutoMark: boolean("enable_auto_mark").default(false), - // L-3:出勤率阈值预警(教师端配置驱动) - attendanceRateThreshold: int("attendance_rate_threshold").default(90), - consecutiveAbsenceThreshold: int("consecutive_absence_threshold").default(3), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").onUpdateNow().notNull(), -}, (table) => ({ - classIdx: index("attendance_rules_class_idx").on(table.classId), - classFk: foreignKey({ - columns: [table.classId], - foreignColumns: [classes.id], - name: "atr_c_fk", - }).onDelete("cascade"), -})); - -// --- 18b. Leave Requests (L-5 在线请假流程) --- - -/** - * 请假类型。 - * - sick: 病假 - * - personal: 事假 - * - family: 家庭事务 - * - other: 其他 - */ -export const leaveTypeEnum = mysqlEnum("leave_type", [ - "sick", - "personal", - "family", - "other", -]); - -/** - * 请假审批状态机:pending → approved/rejected;approved/pending 可被 requester 取消为 cancelled。 - * - pending: 待审批 - * - approved: 已批准(自动同步考勤为 excused) - * - rejected: 已拒绝 - * - cancelled: 已撤销(申请人在审批前主动撤销) - */ -export const leaveStatusEnum = mysqlEnum("leave_status", [ - "pending", - "approved", - "rejected", - "cancelled", -]); - -export const leaveRequests = mysqlTable("leave_requests", { - id: id("id").primaryKey(), - /** 请假学生 ID */ - studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), - /** 提交人 ID(家长代子女提交时为家长 userId;学生本人提交时等于 studentId) */ - requesterId: varchar("requester_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), - /** 学生所属班级 ID(提交时快照,便于按班级筛选审批) */ - classId: varchar("class_id", { length: 128 }).notNull().references(() => classes.id, { onDelete: "cascade" }), - leaveType: leaveTypeEnum.notNull(), - /** 起始日期(含) */ - startDate: date("start_date").notNull(), - /** 结束日期(含) */ - endDate: date("end_date").notNull(), - /** 请假事由 */ - reason: text("reason").notNull(), - status: leaveStatusEnum.default("pending").notNull(), - /** 审批人 ID(pending/cancelled 状态为 null) */ - reviewerId: varchar("reviewer_id", { length: 128 }).references(() => users.id, { onDelete: "set null" }), - /** 审批意见(拒绝时必填,批准时可选) */ - reviewComment: text("review_comment"), - reviewedAt: timestamp("reviewed_at"), - /** 是否已同步考勤记录(approved 后异步写入 attendance_records,置为 true) */ - attendanceSynced: boolean("attendance_synced").default(false).notNull(), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - studentIdx: index("leave_requests_student_idx").on(table.studentId), - requesterIdx: index("leave_requests_requester_idx").on(table.requesterId), - classIdx: index("leave_requests_class_idx").on(table.classId), - statusIdx: index("leave_requests_status_idx").on(table.status), - reviewerIdx: index("leave_requests_reviewer_idx").on(table.reviewerId), - /** 班级+状态查询索引(教师审批列表常用) */ - classStatusIdx: index("leave_requests_class_status_idx").on(table.classId, table.status), - /** 学生+状态查询索引(家长/学生列表常用) */ - studentStatusIdx: index("leave_requests_student_status_idx").on(table.studentId, table.status), - classFk: foreignKey({ - columns: [table.classId], - foreignColumns: [classes.id], - name: "lr_c_fk", - }).onDelete("cascade"), - studentFk: foreignKey({ - columns: [table.studentId], - foreignColumns: [users.id], - name: "lr_s_fk", - }).onDelete("cascade"), - requesterFk: foreignKey({ - columns: [table.requesterId], - foreignColumns: [users.id], - name: "lr_rq_fk", - }).onDelete("cascade"), - reviewerFk: foreignKey({ - columns: [table.reviewerId], - foreignColumns: [users.id], - name: "lr_rv_fk", - }).onDelete("set null"), -})); - -// --- 19. Password Security (密码安全策略) --- - -export const passwordSecurity = mysqlTable("password_security", { - id: varchar("id", { length: 128 }).primaryKey().$defaultFn(() => createId()), - userId: varchar("user_id", { length: 128 }).notNull().unique().references(() => users.id, { onDelete: "cascade" }), - failedLoginAttempts: int("failed_login_attempts").default(0).notNull(), - lockedUntil: timestamp("locked_until", { mode: "date" }), - passwordChangedAt: timestamp("password_changed_at").defaultNow().notNull(), - mustChangePassword: boolean("must_change_password").default(false).notNull(), - lastPasswordChange: timestamp("last_password_change", { mode: "date" }), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - userIdIdx: index("password_security_user_idx").on(table.userId), - userFk: foreignKey({ - columns: [table.userId], - foreignColumns: [users.id], - name: "ps_u_fk", - }).onDelete("cascade"), -})); - -// --- 20. Scheduling Rules & Schedule Changes (排课规则与调课) --- - -export const schedulingRules = mysqlTable("scheduling_rules", { - id: id("id").primaryKey(), - classId: varchar("class_id", { length: 128 }), // null=全局规则 - maxDailyHours: int("max_daily_hours").default(8), - maxContinuousHours: int("max_continuous_hours").default(2), - lunchBreakStart: varchar("lunch_break_start", { length: 10 }).default("12:00"), - lunchBreakEnd: varchar("lunch_break_end", { length: 10 }).default("13:00"), - morningStart: varchar("morning_start", { length: 10 }).default("08:00"), - afternoonEnd: varchar("afternoon_end", { length: 10 }).default("17:00"), - avoidBackToBack: boolean("avoid_back_to_back").default(false), - balancedSubjects: boolean("balanced_subjects").default(true), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - classIdx: index("scheduling_rules_class_idx").on(table.classId), -})); - -export const scheduleChangeStatusEnum = mysqlEnum("status", ["pending", "approved", "rejected", "completed"]); - -export const scheduleChanges = mysqlTable("schedule_changes", { - id: id("id").primaryKey(), - originalScheduleId: varchar("original_schedule_id", { length: 128 }), - classId: varchar("class_id", { length: 128 }).notNull(), - originalTeacherId: varchar("original_teacher_id", { length: 128 }), - substituteTeacherId: varchar("substitute_teacher_id", { length: 128 }), - originalDate: date("original_date"), - newDate: date("new_date"), - newStartTime: varchar("new_start_time", { length: 10 }), - newEndTime: varchar("new_end_time", { length: 10 }), - reason: text("reason"), - status: scheduleChangeStatusEnum.default("pending").notNull(), - requestedBy: varchar("requested_by", { length: 128 }).notNull(), - approvedBy: varchar("approved_by", { length: 128 }), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - classIdx: index("schedule_changes_class_idx").on(table.classId), - statusIdx: index("schedule_changes_status_idx").on(table.status), - requestedByIdx: index("schedule_changes_requested_by_idx").on(table.requestedBy), - originalScheduleIdx: index("schedule_changes_original_schedule_idx").on(table.originalScheduleId), -})); - -// --- 21. Elective Course Management (选课管理) --- - -export const electiveCourseStatusEnum = mysqlEnum("status", ["draft", "open", "closed", "cancelled"]); -export const electiveSelectionModeEnum = mysqlEnum("selection_mode", ["fcfs", "lottery"]); -export const courseSelectionStatusEnum = mysqlEnum("selection_status", ["selected", "enrolled", "waitlist", "dropped", "rejected"]); - -export const electiveCourses = mysqlTable("elective_courses", { - id: id("id").primaryKey(), - name: varchar("name", { length: 255 }).notNull(), - subjectId: varchar("subject_id", { length: 128 }).references(() => subjects.id, { onDelete: "set null" }), - teacherId: varchar("teacher_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), - gradeId: varchar("grade_id", { length: 128 }).references(() => grades.id, { onDelete: "set null" }), - description: text("description"), - capacity: int("capacity").default(30).notNull(), - enrolledCount: int("enrolled_count").default(0).notNull(), - classroom: varchar("classroom", { length: 100 }), - schedule: varchar("schedule", { length: 255 }), - startDate: date("start_date"), - endDate: date("end_date"), - selectionStartAt: datetime("selection_start_at"), - selectionEndAt: datetime("selection_end_at"), - /** 退课截止时间(P2-4 新增):超过此时间学生不可退课;为 null 表示不限制 */ - dropDeadline: datetime("drop_deadline"), - status: electiveCourseStatusEnum.default("draft").notNull(), - selectionMode: electiveSelectionModeEnum.default("fcfs").notNull(), - credit: decimal("credit", { precision: 3, scale: 1 }).default("1.0"), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - teacherIdx: index("elective_courses_teacher_idx").on(table.teacherId), - subjectIdx: index("elective_courses_subject_idx").on(table.subjectId), - gradeIdx: index("elective_courses_grade_idx").on(table.gradeId), - statusIdx: index("elective_courses_status_idx").on(table.status), -})); - -export const courseSelections = mysqlTable("course_selections", { - id: id("id").primaryKey(), - courseId: varchar("course_id", { length: 128 }).notNull().references(() => electiveCourses.id, { onDelete: "cascade" }), - studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), - status: courseSelectionStatusEnum.default("selected").notNull(), - priority: int("priority").default(1), - selectedAt: timestamp("selected_at").defaultNow().notNull(), - enrolledAt: timestamp("enrolled_at"), - droppedAt: timestamp("dropped_at"), - /** 退课理由(P2-4 新增):学生退课时可选填写的理由,便于学校运营分析 */ - dropReason: varchar("drop_reason", { length: 255 }), - lotteryRank: int("lottery_rank"), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - courseStudentPk: primaryKey({ columns: [table.courseId, table.studentId] }), - courseIdx: index("course_selections_course_idx").on(table.courseId), - studentIdx: index("course_selections_student_idx").on(table.studentId), - statusIdx: index("course_selections_status_idx").on(table.status), -})); - -// --- 22. Exam Proctoring Events (考试监考事件) --- - -export const examProctoringEvents = mysqlTable("exam_proctoring_events", { - id: id("id").primaryKey(), - submissionId: varchar("submission_id", { length: 128 }).notNull().references(() => examSubmissions.id, { onDelete: "cascade" }), - studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), - examId: varchar("exam_id", { length: 128 }).notNull().references(() => exams.id, { onDelete: "cascade" }), - eventType: proctoringEventTypeEnum.notNull(), - eventDetail: text("event_detail"), - occurredAt: timestamp("occurred_at").defaultNow().notNull(), - createdAt: timestamp("created_at").defaultNow().notNull(), -}, (table) => ({ - submissionIdx: index("proctoring_submission_idx").on(table.submissionId), - studentIdx: index("proctoring_student_idx").on(table.studentId), - examIdx: index("proctoring_exam_idx").on(table.examId), - eventTypeIdx: index("proctoring_event_type_idx").on(table.eventType), -})); - -// --- 23. Learning Diagnostic (学情诊断报告) --- - -export const knowledgePointMastery = mysqlTable("knowledge_point_mastery", { - id: id("id").primaryKey(), - // 显式 foreignKey 见表参数(避免自动生成 FK 名过长 > 64 字符) - studentId: varchar("student_id", { length: 128 }).notNull(), - knowledgePointId: varchar("knowledge_point_id", { length: 128 }).notNull(), - masteryLevel: decimal("mastery_level", { precision: 5, scale: 2 }).default("0").notNull(), - totalQuestions: int("total_questions").default(0).notNull(), - correctQuestions: int("correct_questions").default(0).notNull(), - lastAssessedAt: timestamp("last_assessed_at").defaultNow().notNull(), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - studentKpPk: primaryKey({ columns: [table.studentId, table.knowledgePointId] }), - studentIdx: index("mastery_student_idx").on(table.studentId), - kpIdx: index("mastery_kp_idx").on(table.knowledgePointId), - studentFk: foreignKey({ - columns: [table.studentId], - foreignColumns: [users.id], - name: "kpm_student_fk", - }).onDelete("cascade"), - kpFk: foreignKey({ - columns: [table.knowledgePointId], - foreignColumns: [knowledgePoints.id], - name: "kpm_kp_fk", - }).onDelete("cascade"), -})); - -export const diagnosticReportStatusEnum = mysqlEnum("report_status", ["draft", "published", "archived"]); -export const diagnosticReportTypeEnum = mysqlEnum("report_type", ["individual", "class", "grade"]); - -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" }), - // v4-P2-3: 年级报告关联的 gradeId,用于年级级别诊断 - gradeId: varchar("grade_id", { length: 128 }).references(() => grades.id, { onDelete: "set null" }), - reportType: diagnosticReportTypeEnum.default("individual").notNull(), - period: varchar("period", { length: 50 }), - summary: text("summary"), - strengths: json("strengths"), - weaknesses: json("weaknesses"), - recommendations: json("recommendations"), - overallScore: decimal("overall_score", { precision: 5, scale: 2 }), - status: diagnosticReportStatusEnum.default("draft").notNull(), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - studentIdx: index("diagnostic_student_idx").on(table.studentId), - 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), - gradeIdx: index("diagnostic_grade_idx").on(table.gradeId), -})); - -// --- 24. Lesson Preparation (备课) --- - -export const lessonPlans = mysqlTable("lesson_plans", { - id: id("id").primaryKey(), - title: varchar("title", { length: 255 }).notNull(), - textbookId: varchar("textbook_id", { length: 128 }).references(() => textbooks.id), - chapterId: varchar("chapter_id", { length: 128 }).references(() => chapters.id), - coursePlanItemId: varchar("course_plan_item_id", { length: 128 }), - subjectId: varchar("subject_id", { length: 128 }).references(() => subjects.id), - gradeId: varchar("grade_id", { length: 128 }).references(() => grades.id), - templateId: varchar("template_id", { length: 128 }), - templateName: varchar("template_name", { length: 100 }), - content: json("content").notNull(), - status: varchar("status", { length: 50 }).default("draft").notNull(), - creatorId: varchar("creator_id", { length: 128 }).notNull().references(() => users.id), - lastSavedAt: timestamp("last_saved_at"), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - creatorIdx: index("lp_creator_idx").on(table.creatorId), - statusIdx: index("lp_status_idx").on(table.status), - textbookChapterIdx: index("lp_textbook_chapter_idx").on(table.textbookId, table.chapterId), - subjectGradeIdx: index("lp_subject_grade_idx").on(table.subjectId, table.gradeId), -})); - -export const lessonPlanVersions = mysqlTable("lesson_plan_versions", { - id: id("id").primaryKey(), - planId: varchar("plan_id", { length: 128 }).notNull().references(() => lessonPlans.id, { onDelete: "cascade" }), - versionNo: int("version_no").notNull(), - label: varchar("label", { length: 100 }), - content: json("content").notNull(), - isAuto: boolean("is_auto").default(false).notNull(), - creatorId: varchar("creator_id", { length: 128 }).notNull().references(() => users.id), - createdAt: timestamp("created_at").defaultNow().notNull(), -}, (table) => ({ - planVersionIdx: index("lpv_plan_version_idx").on(table.planId, table.versionNo), - planCreatedIdx: index("lpv_plan_created_idx").on(table.planId, table.createdAt), -})); - -export const lessonPlanTemplates = mysqlTable("lesson_plan_templates", { - id: id("id").primaryKey(), - name: varchar("name", { length: 100 }).notNull(), - type: varchar("type", { length: 50 }).notNull(), - scope: varchar("scope", { length: 50 }).notNull(), - blocks: json("blocks").notNull(), - creatorId: varchar("creator_id", { length: 128 }).references(() => users.id), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - typeCreatorIdx: index("lpt_type_creator_idx").on(table.type, table.creatorId), -})); - -// M2 协同备课:Block 级评论表 -export const lessonPlanComments = mysqlTable("lesson_plan_comments", { - id: id("id").primaryKey(), - planId: varchar("plan_id", { length: 128 }).notNull().references(() => lessonPlans.id, { onDelete: "cascade" }), - blockId: varchar("block_id", { length: 128 }).notNull(), - content: text("content").notNull(), - authorId: varchar("author_id", { length: 128 }).notNull().references(() => users.id), - resolved: boolean("resolved").default(false).notNull(), - parentCommentId: varchar("parent_comment_id", { length: 128 }), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - planBlockIdx: index("lpc_plan_block_idx").on(table.planId, table.blockId), - authorIdx: index("lpc_author_idx").on(table.authorId), -})); - -// M3 审核工作流:审核记录表 -export const lessonPlanReviewRecords = mysqlTable("lesson_plan_review_records", { - id: id("id").primaryKey(), - planId: varchar("plan_id", { length: 128 }).notNull().references(() => lessonPlans.id, { onDelete: "cascade" }), - reviewerId: varchar("reviewer_id", { length: 128 }).notNull().references(() => users.id), - decision: varchar("decision", { length: 20 }).notNull(), // approved | rejected - comment: text("comment"), - previousStatus: varchar("previous_status", { length: 50 }).notNull(), - newStatus: varchar("new_status", { length: 50 }).notNull(), - createdAt: timestamp("created_at").defaultNow().notNull(), -}, (table) => ({ - planCreatedIdx: index("lprr_plan_created_idx").on(table.planId, table.createdAt), - reviewerIdx: index("lprr_reviewer_idx").on(table.reviewerId), -})); - -// M6 资源附件库:附件关联表 -export const lessonPlanAttachments = mysqlTable("lesson_plan_attachments", { - id: id("id").primaryKey(), - planId: varchar("plan_id", { length: 128 }).notNull().references(() => lessonPlans.id, { onDelete: "cascade" }), - blockId: varchar("block_id", { length: 128 }), - fileId: varchar("file_id", { length: 128 }).notNull(), - displayName: varchar("display_name", { length: 255 }).notNull(), - attachmentType: varchar("attachment_type", { length: 50 }).default("reference").notNull(), // reference | material | supplementary - uploadedBy: varchar("uploaded_by", { length: 128 }).notNull().references(() => users.id), - createdAt: timestamp("created_at").defaultNow().notNull(), -}, (table) => ({ - planIdx: index("lpa_plan_idx").on(table.planId), - blockIdx: index("lpa_block_idx").on(table.blockId), - fileIdx: index("lpa_file_idx").on(table.fileId), -})); - -// M12 代课教师机制:代课教师映射表 -export const lessonPlanSubstitutes = mysqlTable("lesson_plan_substitutes", { - id: id("id").primaryKey(), - planId: varchar("plan_id", { length: 128 }).notNull().references(() => lessonPlans.id, { onDelete: "cascade" }), - originalTeacherId: varchar("original_teacher_id", { length: 128 }).notNull().references(() => users.id), - substituteTeacherId: varchar("substitute_teacher_id", { length: 128 }).notNull().references(() => users.id), - startDate: date("start_date").notNull(), - endDate: date("end_date"), - reason: varchar("reason", { length: 255 }), - status: varchar("status", { length: 20 }).default("active").notNull(), // active | expired | cancelled - createdBy: varchar("created_by", { length: 128 }).notNull().references(() => users.id), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - planIdx: index("lps_plan_idx").on(table.planId), - originalTeacherIdx: index("lps_original_teacher_idx").on(table.originalTeacherId), - substituteTeacherIdx: index("lps_substitute_teacher_idx").on(table.substituteTeacherId), - dateRangeIdx: index("lps_date_range_idx").on(table.startDate, table.endDate), -})); - -// V5-7:课案-课时绑定表(将课案绑定到具体班级的某个日期/节次) -export const lessonPlanSchedules = mysqlTable("lesson_plan_schedules", { - id: id("id").primaryKey(), - planId: varchar("plan_id", { length: 128 }).notNull().references(() => lessonPlans.id, { onDelete: "cascade" }), - classId: varchar("class_id", { length: 128 }).notNull().references(() => classes.id, { onDelete: "cascade" }), - /** 排课日期 YYYY-MM-DD */ - scheduledDate: date("scheduled_date").notNull(), - /** 节次序号(1-12,对应学校节次安排) */ - period: int("period").notNull(), - /** 关联 class_schedule.id(如使用课表)*/ - classScheduleId: varchar("class_schedule_id", { length: 128 }), - /** 教学时长(分钟),默认 40 */ - durationMin: int("duration_min").default(40).notNull(), - createdBy: varchar("created_by", { length: 128 }).notNull().references(() => users.id), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - planIdx: index("lpsc_plan_idx").on(table.planId), - classDateIdx: index("lpsc_class_date_idx").on(table.classId, table.scheduledDate), - teacherDateIdx: index("lpsc_plan_date_idx").on(table.planId, table.scheduledDate), -})); - -// --- 25. System Settings (系统设置 - 键值对存储) --- - -export const systemSettings = mysqlTable("system_settings", { - id: id("id").primaryKey(), - /** 设置分组:school_info / security_policy / file_upload / notification_config */ - category: varchar("category", { length: 50 }).notNull(), - /** 设置键名,如 schoolName / passwordMinLength / maxFileSize */ - key: varchar("key", { length: 100 }).notNull(), - /** 设置值(JSON 字符串,支持字符串/数字/布尔/对象) */ - value: text("value").notNull(), - /** 值类型:string / number / boolean / json */ - valueType: varchar("value_type", { length: 20 }).default("string").notNull(), - updatedBy: varchar("updated_by", { length: 128 }), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - categoryKeyIdx: uniqueIndex("ss_category_key_idx").on(table.category, table.key), - categoryIdx: index("ss_category_idx").on(table.category), -})); - -// --- 26. Error Book (错题本) --- - -export const errorBookSourceTypeEnum = mysqlEnum("source_type", ["exam", "homework", "manual"]); -export const errorBookStatusEnum = mysqlEnum("error_status", ["new", "learning", "mastered", "archived"]); -export const errorBookReviewResultEnum = mysqlEnum("review_result", ["again", "hard", "good", "easy"]); - -/** - * 错题本条目 - 存储学生的错题记录。 - * 来源可以是考试提交、作业提交,或学生手动添加。 - * 采用简化版 SM-2 间隔重复算法调度复习。 - */ -export const errorBookItems = mysqlTable("error_book_items", { - id: id("id").primaryKey(), - studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), - questionId: varchar("question_id", { length: 128 }).notNull().references(() => questions.id), - - // 来源 - sourceType: errorBookSourceTypeEnum.default("manual").notNull(), - /** exam_submission_id / homework_submission_id / null(手动添加) */ - sourceId: varchar("source_id", { length: 128 }), - - // 作答快照(冗余存储,避免源记录被删除后丢失上下文) - studentAnswer: json("student_answer"), - correctAnswer: json("correct_answer"), - - // 分类元数据(冗余,加速筛选) - subjectId: varchar("subject_id", { length: 128 }), - knowledgePointIds: json("knowledge_point_ids"), - - // 状态与掌握度 - status: errorBookStatusEnum.default("new").notNull(), - /** 0-5 掌握程度,0=未学,5=已掌握 */ - masteryLevel: int("mastery_level").default(0).notNull(), - - // SM-2 间隔重复参数 - nextReviewAt: timestamp("next_review_at", { mode: "date" }), - /** 复习间隔(天) */ - reviewInterval: int("review_interval").default(1).notNull(), - reviewCount: int("review_count").default(0).notNull(), - /** 连续答对次数(用于判断是否标记为已掌握) */ - correctStreak: int("correct_streak").default(0).notNull(), - - // 学生笔记与反思 - note: text("note"), - /** 错误原因标签(JSON 数组,如 ["粗心", "概念不清", "计算错误"]) */ - errorTags: json("error_tags"), - - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - studentIdx: index("eb_item_student_idx").on(table.studentId), - studentStatusIdx: index("eb_item_student_status_idx").on(table.studentId, table.status), - studentReviewIdx: index("eb_item_student_review_idx").on(table.studentId, table.nextReviewAt), - questionIdx: index("eb_item_question_idx").on(table.questionId), - subjectIdx: index("eb_item_subject_idx").on(table.subjectId), - sourceIdx: index("eb_item_source_idx").on(table.sourceType, table.sourceId), -})); - -/** - * 错题复习记录 - 每次复习的历史快照。 - * 用于追踪复习进度和算法调参。 - */ -export const errorBookReviews = mysqlTable("error_book_reviews", { - id: id("id").primaryKey(), - itemId: varchar("item_id", { length: 128 }).notNull().references(() => errorBookItems.id, { onDelete: "cascade" }), - studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), - - /** 复习自评结果:again(重来) / hard(困难) / good(良好) / easy(简单) */ - result: errorBookReviewResultEnum.notNull(), - reviewedAt: timestamp("reviewed_at").defaultNow().notNull(), - - /** 本次复习后的新间隔(天) */ - newInterval: int("new_interval"), - newMasteryLevel: int("new_mastery_level"), - - createdAt: timestamp("created_at").defaultNow().notNull(), -}, (table) => ({ - itemIdx: index("eb_review_item_idx").on(table.itemId), - 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, timestamp: number } */ - content: json("content").notNull(), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), - /** - * P3-6 协同录入锁机制: - * - lockedBy: 当前持有编辑锁的教师 ID(null 表示无锁) - * - lockedAt: 锁获取时间(用于过期判定,默认 5 分钟自动释放) - * - lockToken: 锁定令牌(前端续期时校验,防止伪造他人续期) - */ - lockedBy: varchar("locked_by", { length: 128 }), - lockedAt: timestamp("locked_at"), - lockToken: varchar("lock_token", { length: 64 }), -}, (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), - // P3-6: 班级-科目-类型粒度的锁查询索引(用于跨用户协同冲突检测) - classSubjectLockIdx: index("gd_class_subject_lock_idx").on( - table.classId, - table.subjectId, - table.type, - table.lockedBy - ), -})); - -/** - * P3-7 成绩申诉状态机枚举。 - * - pending: 学生已提交申诉,等待教师复核 - * - approved: 教师批准申诉(可能调整分数) - * - rejected: 教师驳回申诉(维持原分数) - * - withdrawn: 学生主动撤回申诉 - */ -export const gradeAppealStatusEnum = mysqlEnum("appeal_status", [ - "pending", - "approved", - "rejected", - "withdrawn", -]); - -/** - * 成绩申诉记录 - 学生对成绩提出异议,教师复核后修改并留痕。 - * - * P3-7: 满足合规性要求,成绩修改全程留痕。 - * 状态机:pending → approved/rejected/withdrawn - * 一条 grade_record 只允许有一条 pending 申诉,避免重复申诉。 - */ -export const gradeAppeals = mysqlTable("grade_appeals", { - id: id("id").primaryKey(), - gradeRecordId: varchar("grade_record_id", { length: 128 }).notNull().references(() => gradeRecords.id, { onDelete: "cascade" }), - studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), - /** 申诉快照:申诉时的原始分数(审计留痕,防止后续修改后追溯困难) */ - originalScore: decimal("original_score", { precision: 6, scale: 2 }).notNull(), - /** 学生期望的分数(选填,便于教师判断诉求) */ - expectedScore: decimal("expected_score", { precision: 6, scale: 2 }), - /** 申诉理由 */ - reason: text("reason").notNull(), - status: gradeAppealStatusEnum.default("pending").notNull(), - /** 复核教师 ID(pending 状态为 null) */ - reviewerId: varchar("reviewer_id", { length: 128 }).references(() => users.id, { onDelete: "set null" }), - /** 教师复核意见 */ - reviewComment: text("review_comment"), - /** 审核后的调整分数(approved 且分数有变时填写) */ - adjustedScore: decimal("adjusted_score", { precision: 6, scale: 2 }), - reviewedAt: timestamp("reviewed_at"), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - gradeRecordIdx: index("ga_grade_record_idx").on(table.gradeRecordId), - studentIdx: index("ga_student_idx").on(table.studentId), - statusIdx: index("ga_status_idx").on(table.status), - reviewerIdx: index("ga_reviewer_idx").on(table.reviewerId), - /** pending 状态查询索引(应用层校验唯一性,避免多次申诉) */ - pendingLookupIdx: index("ga_pending_lookup_idx").on( - table.gradeRecordId, - table.status - ), -})); - -// --- 28. Adaptive Practice (专项练习闭环) --- - -/** - * 专项练习类型。 - * - error_variant: 错题变式练习(从错题本发起,生成变式题) - * - knowledge_point: 知识点专项练习(按知识点抽题) - * - weak_chapter: 薄弱章节练习(按掌握度自动推荐) - * - ai_recommended: AI 推荐练习(AI 根据学情综合推荐) - */ -export const practiceTypeEnum = mysqlEnum("practice_type", [ - "error_variant", - "knowledge_point", - "weak_chapter", - "ai_recommended", -]); - -export const practiceStatusEnum = mysqlEnum("practice_status", [ - "in_progress", - "completed", - "abandoned", -]); - -export const practiceAnswerStatusEnum = mysqlEnum("practice_answer_status", [ - "pending", - "answered", - "skipped", -]); - -/** - * 专项练习会话 - 学生发起的一次专项练习。 - * - * 记录练习类型、来源元数据(知识点/错题/章节)、状态和统计。 - * 练习题目存储在 practiceAnswers 表中。 - */ -export const practiceSessions = mysqlTable("practice_sessions", { - id: id("id").primaryKey(), - studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), - subjectId: varchar("subject_id", { length: 128 }), - - practiceType: practiceTypeEnum.notNull(), - /** 来源元数据:{ knowledgePointIds?, errorBookItemIds?, chapterId?, difficulty?, sourceQuestionId? } */ - sourceMeta: json("source_meta"), - - status: practiceStatusEnum.default("in_progress").notNull(), - - totalQuestions: int("total_questions").default(0).notNull(), - answeredQuestions: int("answered_questions").default(0).notNull(), - correctCount: int("correct_count").default(0).notNull(), - - startedAt: timestamp("started_at", { mode: "date" }).defaultNow().notNull(), - completedAt: timestamp("completed_at", { mode: "date" }), - - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - studentIdx: index("ps_student_idx").on(table.studentId), - studentStatusIdx: index("ps_student_status_idx").on(table.studentId, table.status), - subjectIdx: index("ps_subject_idx").on(table.subjectId), -})); - -/** - * 专项练习答题记录 - 会话中每道题的作答情况。 - * - * 支持两种题目来源: - * 1. 题库中的题目(questionId 指向 questions 表) - * 2. AI 生成的变式题(variantContent 存储 JSON 内容,questionId 指向原题) - */ -export const practiceAnswers = mysqlTable("practice_answers", { - id: id("id").primaryKey(), - sessionId: varchar("session_id", { length: 128 }).notNull().references(() => practiceSessions.id, { onDelete: "cascade" }), - studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), - - questionId: varchar("question_id", { length: 128 }).notNull().references(() => questions.id), - /** 变式题内容(AI 生成的变式题,未入库时存储在此字段) */ - variantContent: json("variant_content"), - /** 是否为变式题 */ - isVariant: boolean("is_variant").default(false).notNull(), - - orderIndex: int("order_index").notNull(), - status: practiceAnswerStatusEnum.default("pending").notNull(), - - studentAnswer: json("student_answer"), - isCorrect: boolean("is_correct"), - score: int("score"), - maxScore: int("max_score").default(1).notNull(), - - answeredAt: timestamp("answered_at", { mode: "date" }), - - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - sessionIdx: index("pa_session_idx").on(table.sessionId), - studentIdx: index("pa_student_idx").on(table.studentId), - questionIdx: index("pa_question_idx").on(table.questionId), - sessionOrderIdx: index("pa_session_order_idx").on(table.sessionId, table.orderIndex), -})); - -// --- 29. Standards (M1 课标对标体系) --- - -/** - * M1 课标库:支持国家标准/课标/自定义三层级。 - * 课案通过 lessonPlanStandards 关联表实现多对多关系。 - */ -export const standards = mysqlTable("standards", { - id: id("id").primaryKey(), - code: varchar("code", { length: 100 }).notNull(), - title: varchar("title", { length: 255 }).notNull(), - description: text("description"), - /** 层级:national(国家标准)/ curriculum(课标)/ custom(自定义) */ - level: varchar("level", { length: 20 }).notNull(), - parentId: varchar("parent_id", { length: 128 }), - subjectId: varchar("subject_id", { length: 128 }).references(() => subjects.id), - gradeId: varchar("grade_id", { length: 128 }).references(() => grades.id), - /** 学段:primary / junior_high / senior_high */ - stage: varchar("stage", { length: 20 }), - sortOrder: int("sort_order").default(0).notNull(), - isActive: boolean("is_active").default(true).notNull(), - createdBy: varchar("created_by", { length: 128 }).references(() => users.id), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - codeIdx: uniqueIndex("std_code_idx").on(table.code), - parentIdx: index("std_parent_idx").on(table.parentId), - subjectGradeIdx: index("std_subject_grade_idx").on(table.subjectId, table.gradeId), - levelIdx: index("std_level_idx").on(table.level), -})); - -/** M1 课案 ↔ 课标 多对多关联 */ -export const lessonPlanStandards = mysqlTable("lesson_plan_standards", { - id: id("id").primaryKey(), - planId: varchar("plan_id", { length: 128 }).notNull().references(() => lessonPlans.id, { onDelete: "cascade" }), - standardId: varchar("standard_id", { length: 128 }).notNull().references(() => standards.id, { onDelete: "cascade" }), - /** 关联类型:primary(主要对标)/ related(相关对标) */ - relationType: varchar("relation_type", { length: 20 }).default("primary").notNull(), - createdBy: varchar("created_by", { length: 128 }).notNull().references(() => users.id), - createdAt: timestamp("created_at").defaultNow().notNull(), -}, (table) => ({ - planStandardIdx: uniqueIndex("lps_plan_standard_idx").on(table.planId, table.standardId), - standardIdx: index("lps_standard_idx").on(table.standardId), -})); - -// --- 30. Formative Assessment (M5 形成性评价闭环) --- - -/** - * M5 互动组件:发布课案时嵌入的 poll/quiz。 - * 学生作答后结果回写到 lessonPlanFormativeResponses。 - */ -export const lessonPlanFormativeItems = mysqlTable("lesson_plan_formative_items", { - id: id("id").primaryKey(), - planId: varchar("plan_id", { length: 128 }).notNull().references(() => lessonPlans.id, { onDelete: "cascade" }), - blockId: varchar("block_id", { length: 128 }).notNull(), - /** 互动类型:poll / quiz / exit_ticket */ - interactionType: varchar("interaction_type", { length: 20 }).notNull(), - /** 题目内容(JSON:题目/选项/正确答案) */ - payload: json("payload").notNull(), - /** 是否启用实时反馈 */ - instantFeedback: boolean("instant_feedback").default(false).notNull(), - orderIndex: int("order_index").default(0).notNull(), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), -}, (table) => ({ - planBlockIdx: index("lpfi_plan_block_idx").on(table.planId, table.blockId), -})); - -/** M5 学生作答记录 */ -export const lessonPlanFormativeResponses = mysqlTable("lesson_plan_formative_responses", { - id: id("id").primaryKey(), - // 显式 foreignKey 见表参数(避免自动生成 FK 名过长 > 64 字符) - itemId: varchar("item_id", { length: 128 }).notNull(), - studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), - classId: varchar("class_id", { length: 128 }), - /** 学生作答(JSON) */ - response: json("response").notNull(), - /** 是否正确(quiz 类型适用) */ - isCorrect: boolean("is_correct"), - /** 作答耗时(秒) */ - durationSec: int("duration_sec"), - createdAt: timestamp("created_at").defaultNow().notNull(), -}, (table) => ({ - itemIdx: index("lpfr_item_idx").on(table.itemId), - studentIdx: index("lpfr_student_idx").on(table.studentId), - classIdx: index("lpfr_class_idx").on(table.classId), - itemFk: foreignKey({ - columns: [table.itemId], - foreignColumns: [lessonPlanFormativeItems.id], - name: "lpfr_item_fk", - }).onDelete("cascade"), -})); - -// --- 31. Lesson Plan Analytics (M10 备课分析仪表盘) --- - -/** - * M10 备课分析快照:按日聚合的备课投入/质量统计。 - * 用于仪表盘展示"教师备课投入""模板使用率""课标覆盖热力图"。 - */ -export const lessonPlanAnalyticsDaily = mysqlTable("lesson_plan_analytics_daily", { - id: id("id").primaryKey(), - snapshotDate: date("snapshot_date").notNull(), - teacherId: varchar("teacher_id", { length: 128 }).notNull().references(() => users.id), - subjectId: varchar("subject_id", { length: 128 }), - gradeId: varchar("grade_id", { length: 128 }), - /** 当日新建课案数 */ - newPlansCount: int("new_plans_count").default(0).notNull(), - /** 当日编辑时长(分钟) */ - editDurationMin: int("edit_duration_min").default(0).notNull(), - /** 当日保存版本数 */ - savedVersionsCount: int("saved_versions_count").default(0).notNull(), - /** 当日提交审核数 */ - submittedCount: int("submitted_count").default(0).notNull(), - /** 当日发布数 */ - publishedCount: int("published_count").default(0).notNull(), - /** 关联的课标数量 */ - standardsLinked: int("standards_linked").default(0).notNull(), - createdAt: timestamp("created_at").defaultNow().notNull(), -}, (table) => ({ - dateTeacherIdx: uniqueIndex("lpad_date_teacher_idx").on(table.snapshotDate, table.teacherId), - dateIdx: index("lpad_date_idx").on(table.snapshotDate), - subjectGradeIdx: index("lpad_subject_grade_idx").on(table.subjectId, table.gradeId), -})); - -// --- 32. Lesson Plan AI Evaluation (M8 AI 评估) --- - -/** - * M8 AI 课案质量评估记录:保存 AI 对课案的质量评分与建议。 - */ -export const lessonPlanAiEvaluations = mysqlTable("lesson_plan_ai_evaluations", { - id: id("id").primaryKey(), - planId: varchar("plan_id", { length: 128 }).notNull().references(() => lessonPlans.id, { onDelete: "cascade" }), - versionNo: int("version_no").notNull(), - /** 总体评分(0-100) */ - overallScore: int("overall_score"), - /** 维度评分(JSON:{ clarity, alignment, engagement, differentiation, assessment }) */ - dimensionScores: json("dimension_scores"), - /** AI 建议文本 */ - suggestions: text("suggestions"), - /** 推荐的课标 ID 列表(JSON) */ - recommendedStandardIds: json("recommended_standard_ids"), - evaluatedBy: varchar("evaluated_by", { length: 128 }).notNull(), - createdAt: timestamp("created_at").defaultNow().notNull(), -}, (table) => ({ - planVersionIdx: index("lpae_plan_version_idx").on(table.planId, table.versionNo), - planIdx: index("lpae_plan_idx").on(table.planId), -})); +// Barrel export for schema modules. +// 拆分自原 schema.ts(2245 行)→ 按业务域分组的多个子文件,每个 < 500 行。 +// 所有原 import 路径 `@/shared/db/schema` 仍然有效(文件优先于同名文件夹解析)。 + +export * from "./schema/users"; +export * from "./schema/rbac"; +export * from "./schema/knowledge-points"; +export * from "./schema/questions"; +export * from "./schema/academic"; +export * from "./schema/school"; +export * from "./schema/classes"; +export * from "./schema/exams"; +export * from "./schema/homework"; +export * from "./schema/ai-providers"; +export * from "./schema/announcements"; +export * from "./schema/audit"; +export * from "./schema/grade-records"; +export * from "./schema/files"; +export * from "./schema/course-plans"; +export * from "./schema/messaging"; +export * from "./schema/notifications"; +export * from "./schema/attendance"; +export * from "./schema/scheduling"; +export * from "./schema/elective-courses"; +export * from "./schema/learning-diagnostic"; +export * from "./schema/lesson-preparation"; +export * from "./schema/system-settings"; +export * from "./schema/error-book"; +export * from "./schema/practice"; +export * from "./schema/standards"; +export * from "./schema/formative-assessment"; diff --git a/src/shared/db/schema/_helpers.ts b/src/shared/db/schema/_helpers.ts new file mode 100644 index 0000000..1c01983 --- /dev/null +++ b/src/shared/db/schema/_helpers.ts @@ -0,0 +1,6 @@ +import { varchar } from "drizzle-orm/mysql-core"; +import { createId } from "@paralleldrive/cuid2"; + +// --- Helper for ID generation (CUID2) --- +export const id = (name: string) => + varchar(name, { length: 128 }).notNull().$defaultFn(() => createId()); diff --git a/src/shared/db/schema/academic.ts b/src/shared/db/schema/academic.ts new file mode 100644 index 0000000..4561f35 --- /dev/null +++ b/src/shared/db/schema/academic.ts @@ -0,0 +1,51 @@ +import { + mysqlTable, + varchar, + text, + timestamp, + int, + index, +} from "drizzle-orm/mysql-core"; +import { id } from "./_helpers"; + +// --- 4. Academic / Teaching Flow --- + +export const subjects = mysqlTable("subjects", { + id: id("id").primaryKey(), + name: varchar("name", { length: 100 }).notNull().unique(), + code: varchar("code", { length: 50 }).unique(), + order: int("order").default(0), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + nameIdx: index("subjects_name_idx").on(table.name), +})); + +export const textbooks = mysqlTable("textbooks", { + id: id("id").primaryKey(), + title: varchar("title", { length: 255 }).notNull(), + subject: varchar("subject", { length: 100 }).notNull(), + grade: varchar("grade", { length: 50 }), + publisher: varchar("publisher", { length: 100 }), + + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}); + +export const chapters = mysqlTable("chapters", { + id: id("id").primaryKey(), + textbookId: varchar("textbook_id", { length: 128 }).notNull().references(() => textbooks.id, { onDelete: "cascade" }), + title: varchar("title", { length: 255 }).notNull(), + order: int("order").default(0), + + // Chapters can also be nested + parentId: varchar("parent_id", { length: 128 }), + + content: text("content"), + + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + textbookIdx: index("textbook_idx").on(table.textbookId), + parentIdIdx: index("parent_id_idx").on(table.parentId), +})); diff --git a/src/shared/db/schema/ai-providers.ts b/src/shared/db/schema/ai-providers.ts new file mode 100644 index 0000000..06d75a6 --- /dev/null +++ b/src/shared/db/schema/ai-providers.ts @@ -0,0 +1,35 @@ +import { + mysqlTable, + varchar, + text, + timestamp, + index, + mysqlEnum, + boolean, +} from "drizzle-orm/mysql-core"; +import { id } from "./_helpers"; + +// --- AI Providers --- + +export const aiProviderVisibilityEnum = mysqlEnum("visibility", ["public", "private"]); + +export const aiProviders = mysqlTable("ai_providers", { + id: id("id").primaryKey(), + provider: mysqlEnum("provider", ["zhipu", "openai", "gemini", "custom", "ollama"]).notNull(), + baseUrl: varchar("base_url", { length: 512 }), + model: varchar("model", { length: 128 }).notNull(), + apiKeyEncrypted: text("api_key_encrypted").notNull(), + apiKeyLast4: varchar("api_key_last4", { length: 4 }), + isDefault: boolean("is_default").default(false).notNull(), + // V3: 可见性 — public 由管理员发布,全员可用;private 仅创建者可见 + visibility: aiProviderVisibilityEnum.default("private").notNull(), + createdBy: varchar("created_by", { length: 128 }), + updatedBy: varchar("updated_by", { length: 128 }), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + providerIdx: index("ai_provider_idx").on(table.provider), + defaultIdx: index("ai_provider_default_idx").on(table.isDefault), + visibilityIdx: index("ai_provider_visibility_idx").on(table.visibility), + createdByIdx: index("ai_provider_created_by_idx").on(table.createdBy), +})); diff --git a/src/shared/db/schema/announcements.ts b/src/shared/db/schema/announcements.ts new file mode 100644 index 0000000..006bf9f --- /dev/null +++ b/src/shared/db/schema/announcements.ts @@ -0,0 +1,56 @@ +import { + mysqlTable, + varchar, + text, + timestamp, + index, + datetime, + mysqlEnum, + boolean, + uniqueIndex, +} from "drizzle-orm/mysql-core"; +import { id } from "./_helpers"; +import { users } from "./users"; + +// --- 8. Announcements --- + +export const announcementTypeEnum = mysqlEnum("type", ["school", "grade", "class"]); +export const announcementStatusEnum = mysqlEnum("status", ["draft", "published", "archived"]); + +export const announcements = mysqlTable("announcements", { + id: id("id").primaryKey(), + title: varchar("title", { length: 255 }).notNull(), + content: text("content").notNull(), + type: announcementTypeEnum.default("school").notNull(), + status: announcementStatusEnum.default("draft").notNull(), + targetGradeId: varchar("target_grade_id", { length: 128 }), + 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) => ({ + authorIdx: index("announcements_author_idx").on(table.authorId), + statusIdx: index("announcements_status_idx").on(table.status), + 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), +})); diff --git a/src/shared/db/schema/attendance.ts b/src/shared/db/schema/attendance.ts new file mode 100644 index 0000000..18febe9 --- /dev/null +++ b/src/shared/db/schema/attendance.ts @@ -0,0 +1,177 @@ +import { + mysqlTable, + varchar, + text, + timestamp, + int, + index, + date, + foreignKey, + mysqlEnum, + boolean, +} from "drizzle-orm/mysql-core"; +import { id } from "./_helpers"; +import { users } from "./users"; +import { classes } from "./classes"; + +// --- 18. Attendance (考勤管理) --- + +export const attendanceStatusEnum = mysqlEnum("status", ["present", "absent", "late", "early_leave", "excused", "school_activity"]); + +/** + * L-6 节次考勤:节次枚举。 + * - morning_reading: 早读 + * - morning: 上午 + * - afternoon: 下午 + * - evening: 晚自习 + * - full_day: 全日(默认值,向后兼容:null 也表示全日) + */ +export const attendancePeriodEnum = mysqlEnum("period", ["morning_reading", "morning", "afternoon", "evening", "full_day"]); + +export const attendanceRecords = mysqlTable("attendance_records", { + id: id("id").primaryKey(), + studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + classId: varchar("class_id", { length: 128 }).notNull().references(() => classes.id, { onDelete: "cascade" }), + scheduleId: varchar("schedule_id", { length: 128 }), + date: date("date").notNull(), + status: attendanceStatusEnum.notNull(), + // L-6:节次考勤字段。null 表示全日记录(向后兼容已有数据)。 + period: attendancePeriodEnum, + remark: text("remark"), + reason: varchar("reason", { length: 255 }), + recordedBy: varchar("recorded_by", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + studentIdx: index("attendance_records_student_idx").on(table.studentId), + classIdx: index("attendance_records_class_idx").on(table.classId), + dateIdx: index("attendance_records_date_idx").on(table.date), + classDateIdx: index("attendance_records_class_date_idx").on(table.classId, table.date), + studentDateIdx: index("attendance_records_student_date_idx").on(table.studentId, table.date), + scheduleIdx: index("attendance_records_schedule_idx").on(table.scheduleId), + recordedByIdx: index("attendance_records_recorded_by_idx").on(table.recordedBy), + // L-6:班级 + 日期 + 节次复合索引,支持按节次查询班级当日点名 + classDatePeriodIdx: index("attendance_records_class_date_period_idx").on(table.classId, table.date, table.period), + classFk: foreignKey({ + columns: [table.classId], + foreignColumns: [classes.id], + name: "ar_c_fk", + }).onDelete("cascade"), + studentFk: foreignKey({ + columns: [table.studentId], + foreignColumns: [users.id], + name: "ar_s_fk", + }).onDelete("cascade"), + recordedByFk: foreignKey({ + columns: [table.recordedBy], + foreignColumns: [users.id], + name: "ar_rb_fk", + }).onDelete("cascade"), +})); + +export const attendanceRules = mysqlTable("attendance_rules", { + id: id("id").primaryKey(), + classId: varchar("class_id", { length: 128 }).references(() => classes.id, { onDelete: "cascade" }), + lateThresholdMinutes: int("late_threshold_minutes").default(15), + earlyLeaveThresholdMinutes: int("early_leave_threshold_minutes").default(15), + enableAutoMark: boolean("enable_auto_mark").default(false), + // L-3:出勤率阈值预警(教师端配置驱动) + attendanceRateThreshold: int("attendance_rate_threshold").default(90), + consecutiveAbsenceThreshold: int("consecutive_absence_threshold").default(3), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").onUpdateNow().notNull(), +}, (table) => ({ + classIdx: index("attendance_rules_class_idx").on(table.classId), + classFk: foreignKey({ + columns: [table.classId], + foreignColumns: [classes.id], + name: "atr_c_fk", + }).onDelete("cascade"), +})); + +// --- 18b. Leave Requests (L-5 在线请假流程) --- + +/** + * 请假类型。 + * - sick: 病假 + * - personal: 事假 + * - family: 家庭事务 + * - other: 其他 + */ +export const leaveTypeEnum = mysqlEnum("leave_type", [ + "sick", + "personal", + "family", + "other", +]); + +/** + * 请假审批状态机:pending → approved/rejected;approved/pending 可被 requester 取消为 cancelled。 + * - pending: 待审批 + * - approved: 已批准(自动同步考勤为 excused) + * - rejected: 已拒绝 + * - cancelled: 已撤销(申请人在审批前主动撤销) + */ +export const leaveStatusEnum = mysqlEnum("leave_status", [ + "pending", + "approved", + "rejected", + "cancelled", +]); + +export const leaveRequests = mysqlTable("leave_requests", { + id: id("id").primaryKey(), + /** 请假学生 ID */ + studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + /** 提交人 ID(家长代子女提交时为家长 userId;学生本人提交时等于 studentId) */ + requesterId: varchar("requester_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + /** 学生所属班级 ID(提交时快照,便于按班级筛选审批) */ + classId: varchar("class_id", { length: 128 }).notNull().references(() => classes.id, { onDelete: "cascade" }), + leaveType: leaveTypeEnum.notNull(), + /** 起始日期(含) */ + startDate: date("start_date").notNull(), + /** 结束日期(含) */ + endDate: date("end_date").notNull(), + /** 请假事由 */ + reason: text("reason").notNull(), + status: leaveStatusEnum.default("pending").notNull(), + /** 审批人 ID(pending/cancelled 状态为 null) */ + reviewerId: varchar("reviewer_id", { length: 128 }).references(() => users.id, { onDelete: "set null" }), + /** 审批意见(拒绝时必填,批准时可选) */ + reviewComment: text("review_comment"), + reviewedAt: timestamp("reviewed_at"), + /** 是否已同步考勤记录(approved 后异步写入 attendance_records,置为 true) */ + attendanceSynced: boolean("attendance_synced").default(false).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + studentIdx: index("leave_requests_student_idx").on(table.studentId), + requesterIdx: index("leave_requests_requester_idx").on(table.requesterId), + classIdx: index("leave_requests_class_idx").on(table.classId), + statusIdx: index("leave_requests_status_idx").on(table.status), + reviewerIdx: index("leave_requests_reviewer_idx").on(table.reviewerId), + /** 班级+状态查询索引(教师审批列表常用) */ + classStatusIdx: index("leave_requests_class_status_idx").on(table.classId, table.status), + /** 学生+状态查询索引(家长/学生列表常用) */ + studentStatusIdx: index("leave_requests_student_status_idx").on(table.studentId, table.status), + classFk: foreignKey({ + columns: [table.classId], + foreignColumns: [classes.id], + name: "lr_c_fk", + }).onDelete("cascade"), + studentFk: foreignKey({ + columns: [table.studentId], + foreignColumns: [users.id], + name: "lr_s_fk", + }).onDelete("cascade"), + requesterFk: foreignKey({ + columns: [table.requesterId], + foreignColumns: [users.id], + name: "lr_rq_fk", + }).onDelete("cascade"), + reviewerFk: foreignKey({ + columns: [table.reviewerId], + foreignColumns: [users.id], + name: "lr_rv_fk", + }).onDelete("set null"), +})); diff --git a/src/shared/db/schema/audit.ts b/src/shared/db/schema/audit.ts new file mode 100644 index 0000000..c24ad25 --- /dev/null +++ b/src/shared/db/schema/audit.ts @@ -0,0 +1,116 @@ +import { + mysqlTable, + varchar, + text, + timestamp, + index, + mysqlEnum, +} from "drizzle-orm/mysql-core"; +import { id } from "./_helpers"; + +// --- 9. Audit & Login Logs --- + +export const auditLogStatusEnum = mysqlEnum("status", ["success", "failure"]); + +export const auditLogs = mysqlTable("audit_logs", { + id: id("id").primaryKey(), + userId: varchar("user_id", { length: 128 }).notNull(), + userName: varchar("user_name", { length: 255 }).notNull(), + action: varchar("action", { length: 255 }).notNull(), + module: varchar("module", { length: 128 }).notNull(), + targetId: varchar("target_id", { length: 128 }), + targetType: varchar("target_type", { length: 128 }), + detail: text("detail"), + ipAddress: varchar("ip_address", { length: 45 }), + userAgent: varchar("user_agent", { length: 512 }), + status: auditLogStatusEnum.default("success").notNull(), + createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), +}, (table) => ({ + userIdIdx: index("audit_logs_user_id_idx").on(table.userId), + moduleIdx: index("audit_logs_module_idx").on(table.module), + actionIdx: index("audit_logs_action_idx").on(table.action), + statusIdx: index("audit_logs_status_idx").on(table.status), + createdAtIdx: index("audit_logs_created_at_idx").on(table.createdAt), +})); + +export const loginLogActionEnum = mysqlEnum("action", ["signin", "signout", "signup"]); +export const loginLogStatusEnum = mysqlEnum("status", ["success", "failure"]); + +export const loginLogs = mysqlTable("login_logs", { + id: id("id").primaryKey(), + userId: varchar("user_id", { length: 128 }), + userEmail: varchar("user_email", { length: 255 }).notNull(), + action: loginLogActionEnum.notNull(), + status: loginLogStatusEnum.default("success").notNull(), + ipAddress: varchar("ip_address", { length: 45 }), + userAgent: varchar("user_agent", { length: 512 }), + errorMessage: text("error_message"), + createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), +}, (table) => ({ + userIdIdx: index("login_logs_user_id_idx").on(table.userId), + userEmailIdx: index("login_logs_user_email_idx").on(table.userEmail), + actionIdx: index("login_logs_action_idx").on(table.action), + statusIdx: index("login_logs_status_idx").on(table.status), + createdAtIdx: index("login_logs_created_at_idx").on(table.createdAt), +})); + +// --- 9b. Invitation Codes (注册邀请码,audit-P2-3 新增) --- + +/** + * 注册邀请码表(audit-P2-3) + * + * 管理员批量生成,用于: + * - 预分配注册角色(student/teacher/parent/admin) + * - 可选绑定邮箱(限定指定用户使用) + * - 可选绑定班级(注册后自动加入班级) + * - 可选过期时间 + */ +export const invitationCodes = mysqlTable("invitation_codes", { + id: id("id").primaryKey(), + /** 邀请码明文(8-12 位大写字母+数字组合,unique) */ + code: varchar("code", { length: 64 }).notNull().unique(), + /** 可选:限定邮箱使用(未设置则任意邮箱可用) */ + email: varchar("email", { length: 255 }), + /** 注册后分配的角色(student/teacher/parent/admin) */ + role: varchar("role", { length: 50 }).notNull(), + /** 可选:注册后自动加入的班级 ID */ + classId: varchar("class_id", { length: 128 }), + /** 可选:管理员备注 */ + notes: varchar("notes", { length: 500 }), + createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), + /** 创建者(管理员 userId) */ + createdBy: varchar("created_by", { length: 128 }), + /** 可选:过期时间(未设置则永久有效) */ + expiresAt: timestamp("expires_at", { mode: "date" }), + /** 已使用者 userId(未使用为 null) */ + usedBy: varchar("used_by", { length: 128 }), + /** 使用时间(未使用为 null) */ + usedAt: timestamp("used_at", { mode: "date" }), +}, (table) => ({ + codeIdx: index("invitation_codes_code_idx").on(table.code), + emailIdx: index("invitation_codes_email_idx").on(table.email), + usedByIdx: index("invitation_codes_used_by_idx").on(table.usedBy), +})); + +// --- 10. Data Change Logs (数据变更日志) --- + +export const dataChangeLogActionEnum = mysqlEnum("action", ["create", "update", "delete"]); + +export const dataChangeLogs = mysqlTable("data_change_logs", { + id: varchar("id", { length: 128 }).primaryKey(), + tableName: varchar("table_name", { length: 128 }).notNull(), + recordId: varchar("record_id", { length: 128 }).notNull(), + action: dataChangeLogActionEnum.notNull(), + oldValue: text("old_value"), + newValue: text("new_value"), + changedBy: varchar("changed_by", { length: 128 }).notNull(), + changedByName: varchar("changed_by_name", { length: 255 }).notNull(), + ipAddress: varchar("ip_address", { length: 45 }), + createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), +}, (table) => ({ + tableNameIdx: index("data_change_logs_table_name_idx").on(table.tableName), + recordIdIdx: index("data_change_logs_record_id_idx").on(table.recordId), + actionIdx: index("data_change_logs_action_idx").on(table.action), + changedByIdx: index("data_change_logs_changed_by_idx").on(table.changedBy), + createdAtIdx: index("data_change_logs_created_at_idx").on(table.createdAt), +})); diff --git a/src/shared/db/schema/classes.ts b/src/shared/db/schema/classes.ts new file mode 100644 index 0000000..99c931d --- /dev/null +++ b/src/shared/db/schema/classes.ts @@ -0,0 +1,159 @@ +import { + mysqlTable, + varchar, + timestamp, + int, + index, + mysqlEnum, + foreignKey, + primaryKey, + uniqueIndex, +} from "drizzle-orm/mysql-core"; +import { id } from "./_helpers"; +import { users } from "./users"; +import { schools, grades } from "./school"; +import { subjects } from "./academic"; + +// --- 6. Classes / Enrollment / Schedule --- + +export const classEnrollmentStatusEnum = mysqlEnum("class_enrollment_status", ["active", "inactive"]); + +export const classes = mysqlTable("classes", { + id: id("id").primaryKey(), + schoolName: varchar("school_name", { length: 255 }), + schoolId: varchar("school_id", { length: 128 }), + name: varchar("name", { length: 255 }).notNull(), + grade: varchar("grade", { length: 50 }).notNull(), + gradeId: varchar("grade_id", { length: 128 }), + homeroom: varchar("homeroom", { length: 50 }), + room: varchar("room", { length: 50 }), + invitationCode: varchar("invitation_code", { length: 6 }).unique(), + teacherId: varchar("teacher_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + teacherIdx: index("classes_teacher_idx").on(table.teacherId), + gradeIdx: index("classes_grade_idx").on(table.grade), + schoolIdx: index("classes_school_idx").on(table.schoolId), + gradeIdIdx: index("classes_grade_id_idx").on(table.gradeId), + schoolFk: foreignKey({ + columns: [table.schoolId], + foreignColumns: [schools.id], + name: "c_s_fk", + }).onDelete("set null"), + gradeFk: foreignKey({ + columns: [table.gradeId], + foreignColumns: [grades.id], + name: "c_g_fk", + }).onDelete("set null"), +})); + +export const classSubjectTeachers = mysqlTable("class_subject_teachers", { + classId: varchar("class_id", { length: 128 }).notNull(), + subjectId: varchar("subject_id", { length: 128 }).notNull(), + teacherId: varchar("teacher_id", { length: 128 }).references(() => users.id, { onDelete: "set null" }), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + pk: primaryKey({ columns: [table.classId, table.subjectId] }), + classIdx: index("class_subject_teachers_class_idx").on(table.classId), + teacherIdx: index("class_subject_teachers_teacher_idx").on(table.teacherId), + subjectIdIdx: index("class_subject_teachers_subject_id_idx").on(table.subjectId), + classFk: foreignKey({ + columns: [table.classId], + foreignColumns: [classes.id], + name: "cst_c_fk", + }).onDelete("cascade"), + subjectFk: foreignKey({ + columns: [table.subjectId], + foreignColumns: [subjects.id], + name: "cst_s_fk", + }).onDelete("cascade"), +})); + +/** + * 班级邀请码表(v3 新增,对标 Google Classroom / 钉钉教育 / 智学网)。 + * + * 设计要点: + * - 独立表而非挂在 classes 表上,支持有效期/次数/审计/多码并存 + * - 6 位字母数字(剔除歧义字符 0/O/1/I/L),空间 22^6 ≈ 1.13 亿 + * - status 枚举:active/disabled/expired/exhausted + * - max_uses NULL=无限;expires_at NULL=永久 + * - 软删除:revoke 时设置 status=disabled + revoked_at,不物理删除(审计需要) + * + * 与 classes.invitationCode 的关系: + * - 新表上线后,enrollStudentByInvitationCode / enrollTeacherByInvitationCode 优先查新表 + * - classes.invitationCode 保留作为 fallback,下个版本移除 + */ +export const classInvitationCodeStatusEnum = mysqlEnum("class_invitation_code_status", [ + "active", + "disabled", + "expired", + "exhausted", +]); + +export const classInvitationCodes = mysqlTable("class_invitation_codes", { + id: id("id").primaryKey(), + classId: varchar("class_id", { length: 128 }).notNull().references(() => classes.id, { onDelete: "cascade" }), + code: varchar("code", { length: 8 }).notNull().unique(), + status: classInvitationCodeStatusEnum.default("active").notNull(), + maxUses: int("max_uses"), + usedCount: int("used_count").default(0).notNull(), + expiresAt: timestamp("expires_at"), + createdBy: varchar("created_by", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), + revokedAt: timestamp("revoked_at"), + revokedBy: varchar("revoked_by", { length: 128 }), + note: varchar("note", { length: 255 }), +}, (table) => ({ + codeIdx: uniqueIndex("class_invitation_codes_code_idx").on(table.code), + classIdx: index("class_invitation_codes_class_idx").on(table.classId), + statusExpiresIdx: index("class_invitation_codes_status_expires_idx").on(table.status, table.expiresAt), + classFk: foreignKey({ + columns: [table.classId], + foreignColumns: [classes.id], + name: "cic_c_fk", + }).onDelete("cascade"), +})); + +export const classEnrollments = mysqlTable("class_enrollments", { + classId: varchar("class_id", { length: 128 }).notNull(), + studentId: varchar("student_id", { length: 128 }).notNull(), + status: classEnrollmentStatusEnum.default("active").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), +}, (table) => ({ + pk: primaryKey({ columns: [table.classId, table.studentId] }), + classIdx: index("class_enrollments_class_idx").on(table.classId), + studentIdx: index("class_enrollments_student_idx").on(table.studentId), + classFk: foreignKey({ + columns: [table.classId], + foreignColumns: [classes.id], + name: "ce_c_fk", + }).onDelete("cascade"), + studentFk: foreignKey({ + columns: [table.studentId], + foreignColumns: [users.id], + name: "ce_s_fk", + }).onDelete("cascade"), +})); + +export const classSchedule = mysqlTable("class_schedule", { + id: id("id").primaryKey(), + classId: varchar("class_id", { length: 128 }).notNull(), + weekday: int("weekday").notNull(), + startTime: varchar("start_time", { length: 5 }).notNull(), + endTime: varchar("end_time", { length: 5 }).notNull(), + course: varchar("course", { length: 255 }).notNull(), + location: varchar("location", { length: 100 }), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + classIdx: index("class_schedule_class_idx").on(table.classId), + classDayIdx: index("class_schedule_class_day_idx").on(table.classId, table.weekday), + classFk: foreignKey({ + columns: [table.classId], + foreignColumns: [classes.id], + name: "cs_c_fk", + }).onDelete("cascade"), +})); diff --git a/src/shared/db/schema/course-plans.ts b/src/shared/db/schema/course-plans.ts new file mode 100644 index 0000000..4e1dee6 --- /dev/null +++ b/src/shared/db/schema/course-plans.ts @@ -0,0 +1,90 @@ +import { + mysqlTable, + varchar, + text, + timestamp, + int, + index, + date, + foreignKey, + mysqlEnum, + boolean, +} from "drizzle-orm/mysql-core"; +import { id } from "./_helpers"; +import { users } from "./users"; +import { classes } from "./classes"; +import { subjects } from "./academic"; + +// --- 13. Course Plans (课程计划) --- + +export const coursePlanStatusEnum = mysqlEnum("status", ["planning", "active", "completed", "paused"]); +export const coursePlanSemesterEnum = mysqlEnum("semester", ["1", "2"]); + +export const coursePlans = mysqlTable("course_plans", { + id: id("id").primaryKey(), + classId: varchar("class_id", { length: 128 }).notNull().references(() => classes.id, { onDelete: "cascade" }), + subjectId: varchar("subject_id", { length: 128 }).notNull().references(() => subjects.id, { onDelete: "cascade" }), + teacherId: varchar("teacher_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + academicYearId: varchar("academic_year_id", { length: 128 }), + semester: coursePlanSemesterEnum.default("1").notNull(), + totalHours: int("total_hours").default(0).notNull(), + completedHours: int("completed_hours").default(0).notNull(), + weeklyHours: int("weekly_hours").default(0).notNull(), + startDate: date("start_date"), + endDate: date("end_date"), + syllabus: text("syllabus"), + objectives: text("objectives"), + status: coursePlanStatusEnum.default("planning").notNull(), + createdBy: varchar("created_by", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + classIdx: index("course_plans_class_idx").on(table.classId), + teacherIdx: index("course_plans_teacher_idx").on(table.teacherId), + subjectIdx: index("course_plans_subject_idx").on(table.subjectId), + statusIdx: index("course_plans_status_idx").on(table.status), + classSubjectIdx: index("course_plans_class_subject_idx").on(table.classId, table.subjectId), + classFk: foreignKey({ + columns: [table.classId], + foreignColumns: [classes.id], + name: "cp_c_fk", + }).onDelete("cascade"), + subjectFk: foreignKey({ + columns: [table.subjectId], + foreignColumns: [subjects.id], + name: "cp_s_fk", + }).onDelete("cascade"), + teacherFk: foreignKey({ + columns: [table.teacherId], + foreignColumns: [users.id], + name: "cp_t_fk", + }).onDelete("cascade"), + createdByFk: foreignKey({ + columns: [table.createdBy], + foreignColumns: [users.id], + name: "cp_cb_fk", + }).onDelete("cascade"), +})); + +export const coursePlanItems = mysqlTable("course_plan_items", { + id: id("id").primaryKey(), + planId: varchar("plan_id", { length: 128 }).notNull().references(() => coursePlans.id, { onDelete: "cascade" }), + week: int("week").notNull(), + topic: varchar("topic", { length: 255 }).notNull(), + content: text("content"), + hours: int("hours").default(2).notNull(), + textbookChapter: varchar("textbook_chapter", { length: 255 }), + notes: text("notes"), + isCompleted: boolean("is_completed").default(false).notNull(), + completedAt: date("completed_at"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + planIdx: index("course_plan_items_plan_idx").on(table.planId), + planWeekIdx: index("course_plan_items_plan_week_idx").on(table.planId, table.week), + planFk: foreignKey({ + columns: [table.planId], + foreignColumns: [coursePlans.id], + name: "cpi_p_fk", + }).onDelete("cascade"), +})); diff --git a/src/shared/db/schema/elective-courses.ts b/src/shared/db/schema/elective-courses.ts new file mode 100644 index 0000000..cf293e9 --- /dev/null +++ b/src/shared/db/schema/elective-courses.ts @@ -0,0 +1,73 @@ +import { + mysqlTable, + varchar, + text, + timestamp, + int, + index, + date, + datetime, + decimal, + mysqlEnum, + primaryKey, +} from "drizzle-orm/mysql-core"; +import { id } from "./_helpers"; +import { users } from "./users"; +import { subjects } from "./academic"; +import { grades } from "./school"; + +// --- 21. Elective Course Management (选课管理) --- + +export const electiveCourseStatusEnum = mysqlEnum("status", ["draft", "open", "closed", "cancelled"]); +export const electiveSelectionModeEnum = mysqlEnum("selection_mode", ["fcfs", "lottery"]); +export const courseSelectionStatusEnum = mysqlEnum("selection_status", ["selected", "enrolled", "waitlist", "dropped", "rejected"]); + +export const electiveCourses = mysqlTable("elective_courses", { + id: id("id").primaryKey(), + name: varchar("name", { length: 255 }).notNull(), + subjectId: varchar("subject_id", { length: 128 }).references(() => subjects.id, { onDelete: "set null" }), + teacherId: varchar("teacher_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + gradeId: varchar("grade_id", { length: 128 }).references(() => grades.id, { onDelete: "set null" }), + description: text("description"), + capacity: int("capacity").default(30).notNull(), + enrolledCount: int("enrolled_count").default(0).notNull(), + classroom: varchar("classroom", { length: 100 }), + schedule: varchar("schedule", { length: 255 }), + startDate: date("start_date"), + endDate: date("end_date"), + selectionStartAt: datetime("selection_start_at"), + selectionEndAt: datetime("selection_end_at"), + /** 退课截止时间(P2-4 新增):超过此时间学生不可退课;为 null 表示不限制 */ + dropDeadline: datetime("drop_deadline"), + status: electiveCourseStatusEnum.default("draft").notNull(), + selectionMode: electiveSelectionModeEnum.default("fcfs").notNull(), + credit: decimal("credit", { precision: 3, scale: 1 }).default("1.0"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + teacherIdx: index("elective_courses_teacher_idx").on(table.teacherId), + subjectIdx: index("elective_courses_subject_idx").on(table.subjectId), + gradeIdx: index("elective_courses_grade_idx").on(table.gradeId), + statusIdx: index("elective_courses_status_idx").on(table.status), +})); + +export const courseSelections = mysqlTable("course_selections", { + id: id("id").primaryKey(), + courseId: varchar("course_id", { length: 128 }).notNull().references(() => electiveCourses.id, { onDelete: "cascade" }), + studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + status: courseSelectionStatusEnum.default("selected").notNull(), + priority: int("priority").default(1), + selectedAt: timestamp("selected_at").defaultNow().notNull(), + enrolledAt: timestamp("enrolled_at"), + droppedAt: timestamp("dropped_at"), + /** 退课理由(P2-4 新增):学生退课时可选填写的理由,便于学校运营分析 */ + dropReason: varchar("drop_reason", { length: 255 }), + lotteryRank: int("lottery_rank"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + courseStudentPk: primaryKey({ columns: [table.courseId, table.studentId] }), + courseIdx: index("course_selections_course_idx").on(table.courseId), + studentIdx: index("course_selections_student_idx").on(table.studentId), + statusIdx: index("course_selections_status_idx").on(table.status), +})); diff --git a/src/shared/db/schema/error-book.ts b/src/shared/db/schema/error-book.ts new file mode 100644 index 0000000..307b856 --- /dev/null +++ b/src/shared/db/schema/error-book.ts @@ -0,0 +1,95 @@ +import { + mysqlTable, + varchar, + text, + timestamp, + int, + index, + json, + mysqlEnum, +} from "drizzle-orm/mysql-core"; +import { id } from "./_helpers"; +import { users } from "./users"; +import { questions } from "./questions"; + +// --- 26. Error Book (错题本) --- + +export const errorBookSourceTypeEnum = mysqlEnum("source_type", ["exam", "homework", "manual"]); +export const errorBookStatusEnum = mysqlEnum("error_status", ["new", "learning", "mastered", "archived"]); +export const errorBookReviewResultEnum = mysqlEnum("review_result", ["again", "hard", "good", "easy"]); + +/** + * 错题本条目 - 存储学生的错题记录。 + * 来源可以是考试提交、作业提交,或学生手动添加。 + * 采用简化版 SM-2 间隔重复算法调度复习。 + */ +export const errorBookItems = mysqlTable("error_book_items", { + id: id("id").primaryKey(), + studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + questionId: varchar("question_id", { length: 128 }).notNull().references(() => questions.id), + + // 来源 + sourceType: errorBookSourceTypeEnum.default("manual").notNull(), + /** exam_submission_id / homework_submission_id / null(手动添加) */ + sourceId: varchar("source_id", { length: 128 }), + + // 作答快照(冗余存储,避免源记录被删除后丢失上下文) + studentAnswer: json("student_answer"), + correctAnswer: json("correct_answer"), + + // 分类元数据(冗余,加速筛选) + subjectId: varchar("subject_id", { length: 128 }), + knowledgePointIds: json("knowledge_point_ids"), + + // 状态与掌握度 + status: errorBookStatusEnum.default("new").notNull(), + /** 0-5 掌握程度,0=未学,5=已掌握 */ + masteryLevel: int("mastery_level").default(0).notNull(), + + // SM-2 间隔重复参数 + nextReviewAt: timestamp("next_review_at", { mode: "date" }), + /** 复习间隔(天) */ + reviewInterval: int("review_interval").default(1).notNull(), + reviewCount: int("review_count").default(0).notNull(), + /** 连续答对次数(用于判断是否标记为已掌握) */ + correctStreak: int("correct_streak").default(0).notNull(), + + // 学生笔记与反思 + note: text("note"), + /** 错误原因标签(JSON 数组,如 ["粗心", "概念不清", "计算错误"]) */ + errorTags: json("error_tags"), + + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + studentIdx: index("eb_item_student_idx").on(table.studentId), + studentStatusIdx: index("eb_item_student_status_idx").on(table.studentId, table.status), + studentReviewIdx: index("eb_item_student_review_idx").on(table.studentId, table.nextReviewAt), + questionIdx: index("eb_item_question_idx").on(table.questionId), + subjectIdx: index("eb_item_subject_idx").on(table.subjectId), + sourceIdx: index("eb_item_source_idx").on(table.sourceType, table.sourceId), +})); + +/** + * 错题复习记录 - 每次复习的历史快照。 + * 用于追踪复习进度和算法调参。 + */ +export const errorBookReviews = mysqlTable("error_book_reviews", { + id: id("id").primaryKey(), + itemId: varchar("item_id", { length: 128 }).notNull().references(() => errorBookItems.id, { onDelete: "cascade" }), + studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + + /** 复习自评结果:again(重来) / hard(困难) / good(良好) / easy(简单) */ + result: errorBookReviewResultEnum.notNull(), + reviewedAt: timestamp("reviewed_at").defaultNow().notNull(), + + /** 本次复习后的新间隔(天) */ + newInterval: int("new_interval"), + newMasteryLevel: int("new_mastery_level"), + + createdAt: timestamp("created_at").defaultNow().notNull(), +}, (table) => ({ + itemIdx: index("eb_review_item_idx").on(table.itemId), + studentIdx: index("eb_review_student_idx").on(table.studentId), + studentReviewedIdx: index("eb_review_student_reviewed_idx").on(table.studentId, table.reviewedAt), +})); diff --git a/src/shared/db/schema/exams.ts b/src/shared/db/schema/exams.ts new file mode 100644 index 0000000..647ff18 --- /dev/null +++ b/src/shared/db/schema/exams.ts @@ -0,0 +1,136 @@ +import { + mysqlTable, + varchar, + text, + timestamp, + int, + index, + json, + mysqlEnum, + boolean, + primaryKey, +} from "drizzle-orm/mysql-core"; +import { id } from "./_helpers"; +import { users } from "./users"; +import { subjects } from "./academic"; +import { grades } from "./school"; +import { questions } from "./questions"; + +// --- 7. Exam Proctoring Config (考试监考配置) --- + +export const examModeEnum = mysqlEnum("exam_mode", ["homework", "timed", "proctored"]); +export const proctoringEventTypeEnum = mysqlEnum("event_type", [ + "tab_switch", "window_blur", "copy_attempt", "paste_attempt", + "right_click", "devtools_open", "fullscreen_exit", "idle_timeout" +]); + +export const exams = mysqlTable("exams", { + id: id("id").primaryKey(), + title: varchar("title", { length: 255 }).notNull(), + description: text("description"), + + /** + * Stores the hierarchical structure of the exam. + * Expected JSON Schema (3-level: section > group > question): + * type ExamStructure = Array< + * | { type: 'section', title: string, level?: number, children: ExamStructure } + * | { type: 'group', title: string, instruction?: string, children: ExamStructure } + * | { type: 'question', questionId: string, score: number } + * > + * + * Note: This is for UI presentation/ordering. + * Real relational integrity is maintained in 'exam_questions' table. + */ + structure: json("structure"), + + creatorId: varchar("creator_id", { length: 128 }).notNull().references(() => users.id), + + // Link to Subjects and Grades + subjectId: varchar("subject_id", { length: 128 }).references(() => subjects.id), + gradeId: varchar("grade_id", { length: 128 }).references(() => grades.id), + + startTime: timestamp("start_time"), + endTime: timestamp("end_time"), + + // P2: Online exam mode + proctoring settings + examMode: examModeEnum.default("homework"), + durationMinutes: int("duration_minutes"), + shuffleQuestions: boolean("shuffle_questions").default(false), + allowLateStart: boolean("allow_late_start").default(false), + lateStartGraceMinutes: int("late_start_grace_minutes").default(0), + antiCheatEnabled: boolean("anti_cheat_enabled").default(false), + + // Status: draft, published, ongoing, finished + status: varchar("status", { length: 50 }).default("draft"), + + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + subjectIdx: index("exams_subject_idx").on(table.subjectId), + gradeIdx: index("exams_grade_idx").on(table.gradeId), + // P3-6: 考试状态按时间排序、按创建者筛选的高频查询索引 + statusCreatedIdx: index("exams_status_created_idx").on(table.status, table.createdAt), + creatorIdx: index("exams_creator_idx").on(table.creatorId), +})); + +// Linking Exams to Questions (Many-to-Many often, or One-to-Many if specific to exam) +// Usually questions are reused, so Many-to-Many +export const examQuestions = mysqlTable("exam_questions", { + examId: varchar("exam_id", { length: 128 }).notNull().references(() => exams.id, { onDelete: "cascade" }), + questionId: varchar("question_id", { length: 128 }).notNull().references(() => questions.id, { onDelete: "cascade" }), + score: int("score").default(0), + order: int("order").default(0), +}, (table) => ({ + pk: primaryKey({ columns: [table.examId, table.questionId] }), +})); + +export const examSubmissions = mysqlTable("exam_submissions", { + id: id("id").primaryKey(), + examId: varchar("exam_id", { length: 128 }).notNull().references(() => exams.id, { onDelete: "cascade" }), + studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + + score: int("score"), // Total score + status: varchar("status", { length: 50 }).default("started"), // started, submitted, graded + + submittedAt: timestamp("submitted_at"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + examStudentIdx: index("exam_student_idx").on(table.examId, table.studentId), + // P3-6: 提交记录按考试状态筛选、按提交时间排序的高频查询索引 + examStatusIdx: index("exam_submissions_exam_status_idx").on(table.examId, table.status), + submittedAtIdx: index("exam_submissions_submitted_at_idx").on(table.submittedAt), +})); + +export const submissionAnswers = mysqlTable("submission_answers", { + id: id("id").primaryKey(), + submissionId: varchar("submission_id", { length: 128 }).notNull().references(() => examSubmissions.id, { onDelete: "cascade" }), + questionId: varchar("question_id", { length: 128 }).notNull().references(() => questions.id), + + answerContent: json("answer_content"), // Student's answer + score: int("score"), // Score for this specific question + feedback: text("feedback"), // Teacher's feedback + + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + submissionIdx: index("submission_idx").on(table.submissionId), +})); + +// --- 22. Exam Proctoring Events (考试监考事件) --- + +export const examProctoringEvents = mysqlTable("exam_proctoring_events", { + id: id("id").primaryKey(), + submissionId: varchar("submission_id", { length: 128 }).notNull().references(() => examSubmissions.id, { onDelete: "cascade" }), + studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + examId: varchar("exam_id", { length: 128 }).notNull().references(() => exams.id, { onDelete: "cascade" }), + eventType: proctoringEventTypeEnum.notNull(), + eventDetail: text("event_detail"), + occurredAt: timestamp("occurred_at").defaultNow().notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), +}, (table) => ({ + submissionIdx: index("proctoring_submission_idx").on(table.submissionId), + studentIdx: index("proctoring_student_idx").on(table.studentId), + examIdx: index("proctoring_exam_idx").on(table.examId), + eventTypeIdx: index("proctoring_event_type_idx").on(table.eventType), +})); diff --git a/src/shared/db/schema/files.ts b/src/shared/db/schema/files.ts new file mode 100644 index 0000000..a137d1d --- /dev/null +++ b/src/shared/db/schema/files.ts @@ -0,0 +1,28 @@ +import { + mysqlTable, + varchar, + timestamp, + index, + bigint, +} from "drizzle-orm/mysql-core"; +import { users } from "./users"; + +// --- 12. File Attachments (文件附件) --- + +export const fileAttachments = mysqlTable("file_attachments", { + id: varchar("id", { length: 128 }).primaryKey(), + filename: varchar("filename", { length: 255 }).notNull(), + originalName: varchar("original_name", { length: 255 }).notNull(), + mimeType: varchar("mime_type", { length: 128 }).notNull(), + size: bigint("size", { mode: "number" }).notNull(), + storagePath: varchar("storage_path", { length: 512 }).notNull(), + url: varchar("url", { length: 512 }), + uploaderId: varchar("uploader_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + targetType: varchar("target_type", { length: 128 }), + targetId: varchar("target_id", { length: 128 }), + createdAt: timestamp("created_at").defaultNow().notNull(), +}, (table) => ({ + uploaderIdx: index("file_attachments_uploader_idx").on(table.uploaderId), + targetIdx: index("file_attachments_target_idx").on(table.targetType, table.targetId), + createdAtIdx: index("file_attachments_created_at_idx").on(table.createdAt), +})); diff --git a/src/shared/db/schema/formative-assessment.ts b/src/shared/db/schema/formative-assessment.ts new file mode 100644 index 0000000..6b88e44 --- /dev/null +++ b/src/shared/db/schema/formative-assessment.ts @@ -0,0 +1,137 @@ +import { + mysqlTable, + varchar, + text, + timestamp, + int, + index, + json, + date, + boolean, + foreignKey, + uniqueIndex, +} from "drizzle-orm/mysql-core"; +import { id } from "./_helpers"; +import { users } from "./users"; +import { lessonPlans } from "./lesson-preparation"; +import { standards } from "./standards"; + +// --- 30. Formative Assessment (M5 形成性评价闭环) --- + +/** + * M5 互动组件:发布课案时嵌入的 poll/quiz。 + * 学生作答后结果回写到 lessonPlanFormativeResponses。 + */ +export const lessonPlanFormativeItems = mysqlTable("lesson_plan_formative_items", { + id: id("id").primaryKey(), + planId: varchar("plan_id", { length: 128 }).notNull().references(() => lessonPlans.id, { onDelete: "cascade" }), + blockId: varchar("block_id", { length: 128 }).notNull(), + /** 互动类型:poll / quiz / exit_ticket */ + interactionType: varchar("interaction_type", { length: 20 }).notNull(), + /** 题目内容(JSON:题目/选项/正确答案) */ + payload: json("payload").notNull(), + /** 是否启用实时反馈 */ + instantFeedback: boolean("instant_feedback").default(false).notNull(), + orderIndex: int("order_index").default(0).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + planBlockIdx: index("lpfi_plan_block_idx").on(table.planId, table.blockId), +})); + +/** M5 学生作答记录 */ +export const lessonPlanFormativeResponses = mysqlTable("lesson_plan_formative_responses", { + id: id("id").primaryKey(), + // 显式 foreignKey 见表参数(避免自动生成 FK 名过长 > 64 字符) + itemId: varchar("item_id", { length: 128 }).notNull(), + studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + classId: varchar("class_id", { length: 128 }), + /** 学生作答(JSON) */ + response: json("response").notNull(), + /** 是否正确(quiz 类型适用) */ + isCorrect: boolean("is_correct"), + /** 作答耗时(秒) */ + durationSec: int("duration_sec"), + createdAt: timestamp("created_at").defaultNow().notNull(), +}, (table) => ({ + itemIdx: index("lpfr_item_idx").on(table.itemId), + studentIdx: index("lpfr_student_idx").on(table.studentId), + classIdx: index("lpfr_class_idx").on(table.classId), + itemFk: foreignKey({ + columns: [table.itemId], + foreignColumns: [lessonPlanFormativeItems.id], + name: "lpfr_item_fk", + }).onDelete("cascade"), +})); + +// --- 31. Lesson Plan Analytics (M10 备课分析仪表盘) --- + +/** + * M10 备课分析快照:按日聚合的备课投入/质量统计。 + * 用于仪表盘展示"教师备课投入""模板使用率""课标覆盖热力图"。 + */ +export const lessonPlanAnalyticsDaily = mysqlTable("lesson_plan_analytics_daily", { + id: id("id").primaryKey(), + snapshotDate: date("snapshot_date").notNull(), + teacherId: varchar("teacher_id", { length: 128 }).notNull().references(() => users.id), + subjectId: varchar("subject_id", { length: 128 }), + gradeId: varchar("grade_id", { length: 128 }), + /** 当日新建课案数 */ + newPlansCount: int("new_plans_count").default(0).notNull(), + /** 当日编辑时长(分钟) */ + editDurationMin: int("edit_duration_min").default(0).notNull(), + /** 当日保存版本数 */ + savedVersionsCount: int("saved_versions_count").default(0).notNull(), + /** 当日提交审核数 */ + submittedCount: int("submitted_count").default(0).notNull(), + /** 当日发布数 */ + publishedCount: int("published_count").default(0).notNull(), + /** 关联的课标数量 */ + standardsLinked: int("standards_linked").default(0).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), +}, (table) => ({ + dateTeacherIdx: uniqueIndex("lpad_date_teacher_idx").on(table.snapshotDate, table.teacherId), + dateIdx: index("lpad_date_idx").on(table.snapshotDate), + subjectGradeIdx: index("lpad_subject_grade_idx").on(table.subjectId, table.gradeId), +})); + +// --- 32. Lesson Plan AI Evaluation (M8 AI 评估) --- + +/** + * M8 AI 课案质量评估记录:保存 AI 对课案的质量评分与建议。 + */ +export const lessonPlanAiEvaluations = mysqlTable("lesson_plan_ai_evaluations", { + id: id("id").primaryKey(), + planId: varchar("plan_id", { length: 128 }).notNull().references(() => lessonPlans.id, { onDelete: "cascade" }), + versionNo: int("version_no").notNull(), + /** 总体评分(0-100) */ + overallScore: int("overall_score"), + /** 维度评分(JSON:{ clarity, alignment, engagement, differentiation, assessment }) */ + dimensionScores: json("dimension_scores"), + /** AI 建议文本 */ + suggestions: text("suggestions"), + /** 推荐的课标 ID 列表(JSON) */ + recommendedStandardIds: json("recommended_standard_ids"), + evaluatedBy: varchar("evaluated_by", { length: 128 }).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), +}, (table) => ({ + planVersionIdx: index("lpae_plan_version_idx").on(table.planId, table.versionNo), + planIdx: index("lpae_plan_idx").on(table.planId), +})); + +// --- M1 课案 ↔ 课标 多对多关联 --- +// 注:lessonPlanStandards 同时引用 lessonPlans 和 standards,放在此处避免循环依赖。 + +/** M1 课案 ↔ 课标 多对多关联 */ +export const lessonPlanStandards = mysqlTable("lesson_plan_standards", { + id: id("id").primaryKey(), + planId: varchar("plan_id", { length: 128 }).notNull().references(() => lessonPlans.id, { onDelete: "cascade" }), + standardId: varchar("standard_id", { length: 128 }).notNull().references(() => standards.id, { onDelete: "cascade" }), + /** 关联类型:primary(主要对标)/ related(相关对标) */ + relationType: varchar("relation_type", { length: 20 }).default("primary").notNull(), + createdBy: varchar("created_by", { length: 128 }).notNull().references(() => users.id), + createdAt: timestamp("created_at").defaultNow().notNull(), +}, (table) => ({ + planStandardIdx: uniqueIndex("lps_plan_standard_idx").on(table.planId, table.standardId), + standardIdx: index("lps_standard_idx").on(table.standardId), +})); diff --git a/src/shared/db/schema/grade-records.ts b/src/shared/db/schema/grade-records.ts new file mode 100644 index 0000000..c4c9adf --- /dev/null +++ b/src/shared/db/schema/grade-records.ts @@ -0,0 +1,189 @@ +import { + mysqlTable, + varchar, + text, + timestamp, + index, + decimal, + foreignKey, + mysqlEnum, + json, + uniqueIndex, +} from "drizzle-orm/mysql-core"; +import { id } from "./_helpers"; +import { users } from "./users"; +import { classes } from "./classes"; +import { subjects } from "./academic"; +import { questions } from "./questions"; + +// --- 11. Grade Records (成绩录入) --- + +export const gradeRecordTypeEnum = mysqlEnum("type", ["exam", "quiz", "homework", "other"]); +export const gradeRecordSemesterEnum = mysqlEnum("semester", ["1", "2"]); + +export const gradeRecords = mysqlTable("grade_records", { + id: id("id").primaryKey(), + studentId: varchar("student_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" }), + examId: varchar("exam_id", { length: 128 }), + academicYearId: varchar("academic_year_id", { length: 128 }), + title: varchar("title", { length: 255 }).notNull(), + score: decimal("score", { precision: 6, scale: 2 }).notNull(), + fullScore: decimal("full_score", { precision: 6, scale: 2 }).default("100").notNull(), + type: gradeRecordTypeEnum.default("exam").notNull(), + semester: gradeRecordSemesterEnum.default("1").notNull(), + recordedBy: varchar("recorded_by", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + remark: text("remark"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + studentIdx: index("grade_records_student_idx").on(table.studentId), + classIdx: index("grade_records_class_idx").on(table.classId), + subjectIdx: index("grade_records_subject_idx").on(table.subjectId), + examIdx: index("grade_records_exam_idx").on(table.examId), + classSubjectIdx: index("grade_records_class_subject_idx").on(table.classId, table.subjectId), + recordedByIdx: index("grade_records_recorded_by_idx").on(table.recordedBy), + classFk: foreignKey({ + columns: [table.classId], + foreignColumns: [classes.id], + name: "gr_c_fk", + }).onDelete("cascade"), + studentFk: foreignKey({ + columns: [table.studentId], + foreignColumns: [users.id], + name: "gr_s_fk", + }).onDelete("cascade"), + subjectFk: foreignKey({ + columns: [table.subjectId], + foreignColumns: [subjects.id], + name: "gr_sub_fk", + }).onDelete("cascade"), + recordedByFk: foreignKey({ + columns: [table.recordedBy], + foreignColumns: [users.id], + name: "gr_rb_fk", + }).onDelete("cascade"), +})); + +// --- 11.1 Grade Record Answers (成绩每题得分) --- + +export const gradeRecordAnswers = mysqlTable("grade_record_answers", { + id: id("id").primaryKey(), + gradeRecordId: varchar("grade_record_id", { length: 128 }).notNull().references(() => gradeRecords.id, { onDelete: "cascade" }), + questionId: varchar("question_id", { length: 128 }).notNull().references(() => questions.id, { onDelete: "cascade" }), + score: decimal("score", { precision: 6, scale: 2 }).notNull(), + fullScore: decimal("full_score", { precision: 6, scale: 2 }).notNull(), + feedback: text("feedback"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + gradeRecordIdx: index("grade_record_answers_record_idx").on(table.gradeRecordId), + questionIdx: index("grade_record_answers_question_idx").on(table.questionId), + gradeRecordFk: foreignKey({ + columns: [table.gradeRecordId], + foreignColumns: [gradeRecords.id], + name: "gra_gr_fk", + }).onDelete("cascade"), + questionFk: foreignKey({ + columns: [table.questionId], + foreignColumns: [questions.id], + name: "gra_q_fk", + }).onDelete("cascade"), +})); + +// --- 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, timestamp: number } */ + content: json("content").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), + /** + * P3-6 协同录入锁机制: + * - lockedBy: 当前持有编辑锁的教师 ID(null 表示无锁) + * - lockedAt: 锁获取时间(用于过期判定,默认 5 分钟自动释放) + * - lockToken: 锁定令牌(前端续期时校验,防止伪造他人续期) + */ + lockedBy: varchar("locked_by", { length: 128 }), + lockedAt: timestamp("locked_at"), + lockToken: varchar("lock_token", { length: 64 }), +}, (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), + // P3-6: 班级-科目-类型粒度的锁查询索引(用于跨用户协同冲突检测) + classSubjectLockIdx: index("gd_class_subject_lock_idx").on( + table.classId, + table.subjectId, + table.type, + table.lockedBy + ), +})); + +/** + * P3-7 成绩申诉状态机枚举。 + * - pending: 学生已提交申诉,等待教师复核 + * - approved: 教师批准申诉(可能调整分数) + * - rejected: 教师驳回申诉(维持原分数) + * - withdrawn: 学生主动撤回申诉 + */ +export const gradeAppealStatusEnum = mysqlEnum("appeal_status", [ + "pending", + "approved", + "rejected", + "withdrawn", +]); + +/** + * 成绩申诉记录 - 学生对成绩提出异议,教师复核后修改并留痕。 + * + * P3-7: 满足合规性要求,成绩修改全程留痕。 + * 状态机:pending → approved/rejected/withdrawn + * 一条 grade_record 只允许有一条 pending 申诉,避免重复申诉。 + */ +export const gradeAppeals = mysqlTable("grade_appeals", { + id: id("id").primaryKey(), + gradeRecordId: varchar("grade_record_id", { length: 128 }).notNull().references(() => gradeRecords.id, { onDelete: "cascade" }), + studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + /** 申诉快照:申诉时的原始分数(审计留痕,防止后续修改后追溯困难) */ + originalScore: decimal("original_score", { precision: 6, scale: 2 }).notNull(), + /** 学生期望的分数(选填,便于教师判断诉求) */ + expectedScore: decimal("expected_score", { precision: 6, scale: 2 }), + /** 申诉理由 */ + reason: text("reason").notNull(), + status: gradeAppealStatusEnum.default("pending").notNull(), + /** 复核教师 ID(pending 状态为 null) */ + reviewerId: varchar("reviewer_id", { length: 128 }).references(() => users.id, { onDelete: "set null" }), + /** 教师复核意见 */ + reviewComment: text("review_comment"), + /** 审核后的调整分数(approved 且分数有变时填写) */ + adjustedScore: decimal("adjusted_score", { precision: 6, scale: 2 }), + reviewedAt: timestamp("reviewed_at"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + gradeRecordIdx: index("ga_grade_record_idx").on(table.gradeRecordId), + studentIdx: index("ga_student_idx").on(table.studentId), + statusIdx: index("ga_status_idx").on(table.status), + reviewerIdx: index("ga_reviewer_idx").on(table.reviewerId), + /** pending 状态查询索引(应用层校验唯一性,避免多次申诉) */ + pendingLookupIdx: index("ga_pending_lookup_idx").on( + table.gradeRecordId, + table.status + ), +})); diff --git a/src/shared/db/schema/homework.ts b/src/shared/db/schema/homework.ts new file mode 100644 index 0000000..71b7610 --- /dev/null +++ b/src/shared/db/schema/homework.ts @@ -0,0 +1,139 @@ +import { + mysqlTable, + varchar, + text, + timestamp, + int, + index, + json, + boolean, + foreignKey, + primaryKey, +} from "drizzle-orm/mysql-core"; +import { id } from "./_helpers"; +import { users } from "./users"; +import { exams } from "./exams"; +import { questions } from "./questions"; + +// --- Homework Assignments & Submissions --- + +export const homeworkAssignments = mysqlTable("homework_assignments", { + id: id("id").primaryKey(), + sourceExamId: varchar("source_exam_id", { length: 128 }), + title: varchar("title", { length: 255 }).notNull(), + description: text("description"), + structure: json("structure"), + status: varchar("status", { length: 50 }).default("draft"), + creatorId: varchar("creator_id", { length: 128 }).notNull(), + availableAt: timestamp("available_at"), + dueAt: timestamp("due_at"), + allowLate: boolean("allow_late").default(false).notNull(), + lateDueAt: timestamp("late_due_at"), + maxAttempts: int("max_attempts").default(1).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + creatorIdx: index("hw_assignment_creator_idx").on(table.creatorId), + sourceExamIdx: index("hw_assignment_source_exam_idx").on(table.sourceExamId), + statusIdx: index("hw_assignment_status_idx").on(table.status), + sourceExamFk: foreignKey({ + columns: [table.sourceExamId], + foreignColumns: [exams.id], + name: "hw_asg_exam_fk", + }).onDelete("cascade"), + creatorFk: foreignKey({ + columns: [table.creatorId], + foreignColumns: [users.id], + name: "hw_asg_creator_fk", + }).onDelete("cascade"), +})); + +export const homeworkAssignmentQuestions = mysqlTable("homework_assignment_questions", { + assignmentId: varchar("assignment_id", { length: 128 }).notNull(), + questionId: varchar("question_id", { length: 128 }).notNull(), + score: int("score").default(0), + order: int("order").default(0), +}, (table) => ({ + pk: primaryKey({ columns: [table.assignmentId, table.questionId] }), + assignmentIdx: index("hw_assignment_questions_assignment_idx").on(table.assignmentId), + assignmentFk: foreignKey({ + columns: [table.assignmentId], + foreignColumns: [homeworkAssignments.id], + name: "hw_aq_a_fk", + }).onDelete("cascade"), + questionFk: foreignKey({ + columns: [table.questionId], + foreignColumns: [questions.id], + name: "hw_aq_q_fk", + }).onDelete("cascade"), +})); + +export const homeworkAssignmentTargets = mysqlTable("homework_assignment_targets", { + assignmentId: varchar("assignment_id", { length: 128 }).notNull(), + studentId: varchar("student_id", { length: 128 }).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), +}, (table) => ({ + pk: primaryKey({ columns: [table.assignmentId, table.studentId] }), + assignmentIdx: index("hw_assignment_targets_assignment_idx").on(table.assignmentId), + studentIdx: index("hw_assignment_targets_student_idx").on(table.studentId), + assignmentFk: foreignKey({ + columns: [table.assignmentId], + foreignColumns: [homeworkAssignments.id], + name: "hw_at_a_fk", + }).onDelete("cascade"), + studentFk: foreignKey({ + columns: [table.studentId], + foreignColumns: [users.id], + name: "hw_at_s_fk", + }).onDelete("cascade"), +})); + +export const homeworkSubmissions = mysqlTable("homework_submissions", { + id: id("id").primaryKey(), + assignmentId: varchar("assignment_id", { length: 128 }).notNull(), + studentId: varchar("student_id", { length: 128 }).notNull(), + attemptNo: int("attempt_no").default(1).notNull(), + score: int("score"), + status: varchar("status", { length: 50 }).default("started"), + startedAt: timestamp("started_at").defaultNow().notNull(), + submittedAt: timestamp("submitted_at"), + isLate: boolean("is_late").default(false).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + assignmentStudentIdx: index("hw_assignment_student_idx").on(table.assignmentId, table.studentId), + assignmentFk: foreignKey({ + columns: [table.assignmentId], + foreignColumns: [homeworkAssignments.id], + name: "hw_sub_a_fk", + }).onDelete("cascade"), + studentFk: foreignKey({ + columns: [table.studentId], + foreignColumns: [users.id], + name: "hw_sub_student_fk", + }).onDelete("cascade"), +})); + +export const homeworkAnswers = mysqlTable("homework_answers", { + id: id("id").primaryKey(), + submissionId: varchar("submission_id", { length: 128 }).notNull(), + questionId: varchar("question_id", { length: 128 }).notNull(), + answerContent: json("answer_content"), + score: int("score"), + feedback: text("feedback"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + submissionIdx: index("hw_answer_submission_idx").on(table.submissionId), + submissionQuestionIdx: index("hw_answer_submission_question_idx").on(table.submissionId, table.questionId), + submissionFk: foreignKey({ + columns: [table.submissionId], + foreignColumns: [homeworkSubmissions.id], + name: "hw_ans_sub_fk", + }).onDelete("cascade"), + questionFk: foreignKey({ + columns: [table.questionId], + foreignColumns: [questions.id], + name: "hw_ans_q_fk", + }), +})); diff --git a/src/shared/db/schema/knowledge-points.ts b/src/shared/db/schema/knowledge-points.ts new file mode 100644 index 0000000..66f7b40 --- /dev/null +++ b/src/shared/db/schema/knowledge-points.ts @@ -0,0 +1,87 @@ +import { + mysqlTable, + varchar, + text, + timestamp, + int, + index, + primaryKey, + foreignKey, + decimal, +} from "drizzle-orm/mysql-core"; +import { id } from "./_helpers"; +import { users } from "./users"; + +// --- 2. Knowledge Points (Tree Structure) --- + +export const knowledgePoints = mysqlTable("knowledge_points", { + id: id("id").primaryKey(), + name: varchar("name", { length: 255 }).notNull(), + description: text("description"), + anchorText: varchar("anchor_text", { length: 255 }), + + // Tree Structure: Parent KP + parentId: varchar("parent_id", { length: 128 }), // Self-reference defined in relations + + chapterId: varchar("chapter_id", { length: 128 }), + + // Metadata for ordering or level + level: int("level").default(0), + order: int("order").default(0), + + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + parentIdIdx: index("parent_id_idx").on(table.parentId), + chapterIdIdx: index("kp_chapter_id_idx").on(table.chapterId), +})); + +// --- 知识点前置依赖(知识图谱) --- +export const knowledgePointPrerequisites = mysqlTable("knowledge_point_prerequisites", { + knowledgePointId: varchar("knowledge_point_id", { length: 128 }).notNull(), + prerequisiteKpId: varchar("prerequisite_kp_id", { length: 128 }).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), +}, (table) => ({ + kpPairPk: primaryKey({ columns: [table.knowledgePointId, table.prerequisiteKpId], name: "kp_prereq_pk" }), + kpIdx: index("kp_prereq_kp_idx").on(table.knowledgePointId), + prereqIdx: index("kp_prereq_prereq_idx").on(table.prerequisiteKpId), + kpFk: foreignKey({ + columns: [table.knowledgePointId], + foreignColumns: [knowledgePoints.id], + name: "kp_prereq_kp_fk", + }).onDelete("cascade"), + prereqFk: foreignKey({ + columns: [table.prerequisiteKpId], + foreignColumns: [knowledgePoints.id], + name: "kp_prereq_prereq_fk", + }).onDelete("cascade"), +})); + +// --- 23. Learning Diagnostic (学情诊断报告) - 知识点掌握度 --- + +export const knowledgePointMastery = mysqlTable("knowledge_point_mastery", { + id: id("id").primaryKey(), + // 显式 foreignKey 见表参数(避免自动生成 FK 名过长 > 64 字符) + studentId: varchar("student_id", { length: 128 }).notNull(), + knowledgePointId: varchar("knowledge_point_id", { length: 128 }).notNull(), + masteryLevel: decimal("mastery_level", { precision: 5, scale: 2 }).default("0").notNull(), + totalQuestions: int("total_questions").default(0).notNull(), + correctQuestions: int("correct_questions").default(0).notNull(), + lastAssessedAt: timestamp("last_assessed_at").defaultNow().notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + studentKpPk: primaryKey({ columns: [table.studentId, table.knowledgePointId] }), + studentIdx: index("mastery_student_idx").on(table.studentId), + kpIdx: index("mastery_kp_idx").on(table.knowledgePointId), + studentFk: foreignKey({ + columns: [table.studentId], + foreignColumns: [users.id], + name: "kpm_student_fk", + }).onDelete("cascade"), + kpFk: foreignKey({ + columns: [table.knowledgePointId], + foreignColumns: [knowledgePoints.id], + name: "kpm_kp_fk", + }).onDelete("cascade"), +})); diff --git a/src/shared/db/schema/learning-diagnostic.ts b/src/shared/db/schema/learning-diagnostic.ts new file mode 100644 index 0000000..36691b1 --- /dev/null +++ b/src/shared/db/schema/learning-diagnostic.ts @@ -0,0 +1,46 @@ +import { + mysqlTable, + varchar, + text, + timestamp, + index, + json, + decimal, + mysqlEnum, +} from "drizzle-orm/mysql-core"; +import { id } from "./_helpers"; +import { users } from "./users"; +import { classes } from "./classes"; +import { grades } from "./school"; + +// --- 23. Learning Diagnostic (学情诊断报告) --- + +export const diagnosticReportStatusEnum = mysqlEnum("report_status", ["draft", "published", "archived"]); +export const diagnosticReportTypeEnum = mysqlEnum("report_type", ["individual", "class", "grade"]); + +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" }), + // v4-P2-3: 年级报告关联的 gradeId,用于年级级别诊断 + gradeId: varchar("grade_id", { length: 128 }).references(() => grades.id, { onDelete: "set null" }), + reportType: diagnosticReportTypeEnum.default("individual").notNull(), + period: varchar("period", { length: 50 }), + summary: text("summary"), + strengths: json("strengths"), + weaknesses: json("weaknesses"), + recommendations: json("recommendations"), + overallScore: decimal("overall_score", { precision: 5, scale: 2 }), + status: diagnosticReportStatusEnum.default("draft").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + studentIdx: index("diagnostic_student_idx").on(table.studentId), + 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), + gradeIdx: index("diagnostic_grade_idx").on(table.gradeId), +})); diff --git a/src/shared/db/schema/lesson-preparation.ts b/src/shared/db/schema/lesson-preparation.ts new file mode 100644 index 0000000..4bd198c --- /dev/null +++ b/src/shared/db/schema/lesson-preparation.ts @@ -0,0 +1,157 @@ +import { + mysqlTable, + varchar, + text, + timestamp, + int, + index, + json, + date, + boolean, +} from "drizzle-orm/mysql-core"; +import { id } from "./_helpers"; +import { users } from "./users"; +import { textbooks, chapters, subjects } from "./academic"; +import { grades } from "./school"; +import { classes } from "./classes"; + +// --- 24. Lesson Preparation (备课) --- + +export const lessonPlans = mysqlTable("lesson_plans", { + id: id("id").primaryKey(), + title: varchar("title", { length: 255 }).notNull(), + textbookId: varchar("textbook_id", { length: 128 }).references(() => textbooks.id), + chapterId: varchar("chapter_id", { length: 128 }).references(() => chapters.id), + coursePlanItemId: varchar("course_plan_item_id", { length: 128 }), + subjectId: varchar("subject_id", { length: 128 }).references(() => subjects.id), + gradeId: varchar("grade_id", { length: 128 }).references(() => grades.id), + templateId: varchar("template_id", { length: 128 }), + templateName: varchar("template_name", { length: 100 }), + content: json("content").notNull(), + status: varchar("status", { length: 50 }).default("draft").notNull(), + creatorId: varchar("creator_id", { length: 128 }).notNull().references(() => users.id), + lastSavedAt: timestamp("last_saved_at"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + creatorIdx: index("lp_creator_idx").on(table.creatorId), + statusIdx: index("lp_status_idx").on(table.status), + textbookChapterIdx: index("lp_textbook_chapter_idx").on(table.textbookId, table.chapterId), + subjectGradeIdx: index("lp_subject_grade_idx").on(table.subjectId, table.gradeId), +})); + +export const lessonPlanVersions = mysqlTable("lesson_plan_versions", { + id: id("id").primaryKey(), + planId: varchar("plan_id", { length: 128 }).notNull().references(() => lessonPlans.id, { onDelete: "cascade" }), + versionNo: int("version_no").notNull(), + label: varchar("label", { length: 100 }), + content: json("content").notNull(), + isAuto: boolean("is_auto").default(false).notNull(), + creatorId: varchar("creator_id", { length: 128 }).notNull().references(() => users.id), + createdAt: timestamp("created_at").defaultNow().notNull(), +}, (table) => ({ + planVersionIdx: index("lpv_plan_version_idx").on(table.planId, table.versionNo), + planCreatedIdx: index("lpv_plan_created_idx").on(table.planId, table.createdAt), +})); + +export const lessonPlanTemplates = mysqlTable("lesson_plan_templates", { + id: id("id").primaryKey(), + name: varchar("name", { length: 100 }).notNull(), + type: varchar("type", { length: 50 }).notNull(), + scope: varchar("scope", { length: 50 }).notNull(), + blocks: json("blocks").notNull(), + creatorId: varchar("creator_id", { length: 128 }).references(() => users.id), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + typeCreatorIdx: index("lpt_type_creator_idx").on(table.type, table.creatorId), +})); + +// M2 协同备课:Block 级评论表 +export const lessonPlanComments = mysqlTable("lesson_plan_comments", { + id: id("id").primaryKey(), + planId: varchar("plan_id", { length: 128 }).notNull().references(() => lessonPlans.id, { onDelete: "cascade" }), + blockId: varchar("block_id", { length: 128 }).notNull(), + content: text("content").notNull(), + authorId: varchar("author_id", { length: 128 }).notNull().references(() => users.id), + resolved: boolean("resolved").default(false).notNull(), + parentCommentId: varchar("parent_comment_id", { length: 128 }), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + planBlockIdx: index("lpc_plan_block_idx").on(table.planId, table.blockId), + authorIdx: index("lpc_author_idx").on(table.authorId), +})); + +// M3 审核工作流:审核记录表 +export const lessonPlanReviewRecords = mysqlTable("lesson_plan_review_records", { + id: id("id").primaryKey(), + planId: varchar("plan_id", { length: 128 }).notNull().references(() => lessonPlans.id, { onDelete: "cascade" }), + reviewerId: varchar("reviewer_id", { length: 128 }).notNull().references(() => users.id), + decision: varchar("decision", { length: 20 }).notNull(), // approved | rejected + comment: text("comment"), + previousStatus: varchar("previous_status", { length: 50 }).notNull(), + newStatus: varchar("new_status", { length: 50 }).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), +}, (table) => ({ + planCreatedIdx: index("lprr_plan_created_idx").on(table.planId, table.createdAt), + reviewerIdx: index("lprr_reviewer_idx").on(table.reviewerId), +})); + +// M6 资源附件库:附件关联表 +export const lessonPlanAttachments = mysqlTable("lesson_plan_attachments", { + id: id("id").primaryKey(), + planId: varchar("plan_id", { length: 128 }).notNull().references(() => lessonPlans.id, { onDelete: "cascade" }), + blockId: varchar("block_id", { length: 128 }), + fileId: varchar("file_id", { length: 128 }).notNull(), + displayName: varchar("display_name", { length: 255 }).notNull(), + attachmentType: varchar("attachment_type", { length: 50 }).default("reference").notNull(), // reference | material | supplementary + uploadedBy: varchar("uploaded_by", { length: 128 }).notNull().references(() => users.id), + createdAt: timestamp("created_at").defaultNow().notNull(), +}, (table) => ({ + planIdx: index("lpa_plan_idx").on(table.planId), + blockIdx: index("lpa_block_idx").on(table.blockId), + fileIdx: index("lpa_file_idx").on(table.fileId), +})); + +// M12 代课教师机制:代课教师映射表 +export const lessonPlanSubstitutes = mysqlTable("lesson_plan_substitutes", { + id: id("id").primaryKey(), + planId: varchar("plan_id", { length: 128 }).notNull().references(() => lessonPlans.id, { onDelete: "cascade" }), + originalTeacherId: varchar("original_teacher_id", { length: 128 }).notNull().references(() => users.id), + substituteTeacherId: varchar("substitute_teacher_id", { length: 128 }).notNull().references(() => users.id), + startDate: date("start_date").notNull(), + endDate: date("end_date"), + reason: varchar("reason", { length: 255 }), + status: varchar("status", { length: 20 }).default("active").notNull(), // active | expired | cancelled + createdBy: varchar("created_by", { length: 128 }).notNull().references(() => users.id), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + planIdx: index("lps_plan_idx").on(table.planId), + originalTeacherIdx: index("lps_original_teacher_idx").on(table.originalTeacherId), + substituteTeacherIdx: index("lps_substitute_teacher_idx").on(table.substituteTeacherId), + dateRangeIdx: index("lps_date_range_idx").on(table.startDate, table.endDate), +})); + +// V5-7:课案-课时绑定表(将课案绑定到具体班级的某个日期/节次) +export const lessonPlanSchedules = mysqlTable("lesson_plan_schedules", { + id: id("id").primaryKey(), + planId: varchar("plan_id", { length: 128 }).notNull().references(() => lessonPlans.id, { onDelete: "cascade" }), + classId: varchar("class_id", { length: 128 }).notNull().references(() => classes.id, { onDelete: "cascade" }), + /** 排课日期 YYYY-MM-DD */ + scheduledDate: date("scheduled_date").notNull(), + /** 节次序号(1-12,对应学校节次安排) */ + period: int("period").notNull(), + /** 关联 class_schedule.id(如使用课表)*/ + classScheduleId: varchar("class_schedule_id", { length: 128 }), + /** 教学时长(分钟),默认 40 */ + durationMin: int("duration_min").default(40).notNull(), + createdBy: varchar("created_by", { length: 128 }).notNull().references(() => users.id), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + planIdx: index("lpsc_plan_idx").on(table.planId), + classDateIdx: index("lpsc_class_date_idx").on(table.classId, table.scheduledDate), + teacherDateIdx: index("lpsc_plan_date_idx").on(table.planId, table.scheduledDate), +})); diff --git a/src/shared/db/schema/messaging.ts b/src/shared/db/schema/messaging.ts new file mode 100644 index 0000000..7890a09 --- /dev/null +++ b/src/shared/db/schema/messaging.ts @@ -0,0 +1,123 @@ +import { + mysqlTable, + varchar, + text, + timestamp, + int, + index, + boolean, + uniqueIndex, +} from "drizzle-orm/mysql-core"; +import { id } from "./_helpers"; +import { users } from "./users"; + +// --- 14. Messages (站内消息) --- + +export const messages = mysqlTable("messages", { + id: id("id").primaryKey(), + senderId: varchar("sender_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + receiverId: varchar("receiver_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + subject: varchar("subject", { length: 255 }), + content: text("content").notNull(), + isRead: boolean("is_read").default(false).notNull(), + readAt: timestamp("read_at", { mode: "date" }), + parentMessageId: varchar("parent_message_id", { length: 128 }), // 回复链 + // 软删除:发送方/接收方各自独立删除,互不影响 + senderDeletedAt: timestamp("sender_deleted_at", { mode: "date" }), + receiverDeletedAt: timestamp("receiver_deleted_at", { mode: "date" }), + // V2-P2-13c: 消息星标(接收方可标记重要消息) + isStarred: boolean("is_starred").default(false).notNull(), + // P2-4: 消息撤回(发送方在 2 分钟窗口内可撤回;非空即表示已撤回) + recalledAt: timestamp("recalled_at", { mode: "date" }), + // P2-2: 群组消息 ID(fan-out on write:教师群发时每条消息共享同一 groupMessageId) + groupMessageId: varchar("group_message_id", { length: 128 }), + createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), +}, (table) => ({ + senderIdx: index("messages_sender_idx").on(table.senderId), + receiverIdx: index("messages_receiver_idx").on(table.receiverId), + 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), + // P2-2: 群组消息索引(按 groupMessageId 聚合查询) + groupMessageIdx: index("messages_group_message_idx").on(table.groupMessageId), +})); + +// --- 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 }), + // P2-10: 跨设备草稿同步 - 设备 ID(标识来源设备) + deviceId: varchar("device_id", { length: 128 }), + // P2-10: 跨设备草稿同步 - 版本号(乐观锁,冲突检测) + version: int("version").default(1).notNull(), + 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), + // P2-10: 支持按 userId + parentMessageId 查询最新版本草稿 + userParentIdx: index("message_drafts_user_parent_idx").on(table.userId, table.parentMessageId), +})); + +// --- 14c. Message Templates (快捷回复模板, P2-6) --- + +export const messageTemplates = mysqlTable("message_templates", { + id: id("id").primaryKey(), + userId: varchar("user_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + title: varchar("title", { length: 100 }).notNull(), + content: text("content").notNull(), + // 排序权重(越小越靠前),默认 0 + sortOrder: int("sort_order").default(0).notNull(), + updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().onUpdateNow().notNull(), + createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), +}, (table) => ({ + userIdx: index("message_templates_user_idx").on(table.userId), + userSortIdx: index("message_templates_user_sort_idx").on(table.userId, table.sortOrder), +})); + +// --- 14d. Message Reports (消息举报, P2-5) --- + +export const messageReports = mysqlTable("message_reports", { + id: id("id").primaryKey(), + // 举报人 + reporterId: varchar("reporter_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + // 被举报消息 ID + messageId: varchar("message_id", { length: 128 }).notNull().references(() => messages.id, { onDelete: "cascade" }), + // 被举报人(冗余,便于按被举报人查询) + reportedUserId: varchar("reported_user_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + // 举报理由分类:spam / harassment / inappropriate / other + reason: varchar("reason", { length: 50 }).notNull(), + // 详细描述(可选) + description: text("description"), + // 举报状态:pending / reviewed / dismissed / actioned + status: varchar("status", { length: 20 }).notNull().default("pending"), + createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + reporterIdx: index("message_reports_reporter_idx").on(table.reporterId), + messageIdx: index("message_reports_message_idx").on(table.messageId), + reportedUserIdx: index("message_reports_reported_user_idx").on(table.reportedUserId), + statusIdx: index("message_reports_status_idx").on(table.status), +})); + +// --- 14e. User Blocks (用户屏蔽, P2-5) --- + +export const userBlocks = mysqlTable("user_blocks", { + id: id("id").primaryKey(), + // 屏蔽发起人 + blockerId: varchar("blocker_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + // 被屏蔽人 + blockedId: varchar("blocked_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), +}, (table) => ({ + // 同一对 (blocker, blocked) 只能存在一条记录 + blockerBlockedUnique: uniqueIndex("user_blocks_blocker_blocked_uniq").on(table.blockerId, table.blockedId), + blockerIdx: index("user_blocks_blocker_idx").on(table.blockerId), +})); diff --git a/src/shared/db/schema/notifications.ts b/src/shared/db/schema/notifications.ts new file mode 100644 index 0000000..988da8e --- /dev/null +++ b/src/shared/db/schema/notifications.ts @@ -0,0 +1,89 @@ +import { + mysqlTable, + varchar, + text, + timestamp, + index, + boolean, + foreignKey, +} from "drizzle-orm/mysql-core"; +import { id } from "./_helpers"; +import { users } from "./users"; + +// --- 15. Message Notifications (消息通知) --- + +export const messageNotifications = mysqlTable("message_notifications", { + id: id("id").primaryKey(), + userId: varchar("user_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + type: varchar("type", { length: 128 }).notNull(), // "message", "announcement", "homework", "grade", "diagnostic" + title: varchar("title", { length: 255 }).notNull(), + 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 (通知偏好) --- + +export const notificationPreferences = mysqlTable("notification_preferences", { + id: varchar("id", { length: 128 }).primaryKey(), + userId: varchar("user_id", { length: 128 }).notNull().unique().references(() => users.id, { onDelete: "cascade" }), + emailEnabled: boolean("email_enabled").default(false).notNull(), + smsEnabled: boolean("sms_enabled").default(false).notNull(), + pushEnabled: boolean("push_enabled").default(true).notNull(), + homeworkNotifications: boolean("homework_notifications").default(true).notNull(), + gradeNotifications: boolean("grade_notifications").default(true).notNull(), + announcementNotifications: boolean("announcement_notifications").default(true).notNull(), + messageNotifications: boolean("message_notifications").default(true).notNull(), + attendanceNotifications: boolean("attendance_notifications").default(true).notNull(), + // 免打扰时段(格式 "HH:mm",如 "22:00") + quietHoursEnabled: boolean("quiet_hours_enabled").default(false).notNull(), + quietHoursStart: varchar("quiet_hours_start", { length: 5 }), + quietHoursEnd: varchar("quiet_hours_end", { length: 5 }), + createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + userIdIdx: index("notification_preferences_user_idx").on(table.userId), + userFk: foreignKey({ + columns: [table.userId], + foreignColumns: [users.id], + name: "np_u_fk", + }).onDelete("cascade"), +})); diff --git a/src/shared/db/schema/practice.ts b/src/shared/db/schema/practice.ts new file mode 100644 index 0000000..05f6267 --- /dev/null +++ b/src/shared/db/schema/practice.ts @@ -0,0 +1,110 @@ +import { + mysqlTable, + varchar, + timestamp, + int, + index, + json, + boolean, + mysqlEnum, +} from "drizzle-orm/mysql-core"; +import { id } from "./_helpers"; +import { users } from "./users"; +import { questions } from "./questions"; + +// --- 28. Adaptive Practice (专项练习闭环) --- + +/** + * 专项练习类型。 + * - error_variant: 错题变式练习(从错题本发起,生成变式题) + * - knowledge_point: 知识点专项练习(按知识点抽题) + * - weak_chapter: 薄弱章节练习(按掌握度自动推荐) + * - ai_recommended: AI 推荐练习(AI 根据学情综合推荐) + */ +export const practiceTypeEnum = mysqlEnum("practice_type", [ + "error_variant", + "knowledge_point", + "weak_chapter", + "ai_recommended", +]); + +export const practiceStatusEnum = mysqlEnum("practice_status", [ + "in_progress", + "completed", + "abandoned", +]); + +export const practiceAnswerStatusEnum = mysqlEnum("practice_answer_status", [ + "pending", + "answered", + "skipped", +]); + +/** + * 专项练习会话 - 学生发起的一次专项练习。 + * + * 记录练习类型、来源元数据(知识点/错题/章节)、状态和统计。 + * 练习题目存储在 practiceAnswers 表中。 + */ +export const practiceSessions = mysqlTable("practice_sessions", { + id: id("id").primaryKey(), + studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + subjectId: varchar("subject_id", { length: 128 }), + + practiceType: practiceTypeEnum.notNull(), + /** 来源元数据:{ knowledgePointIds?, errorBookItemIds?, chapterId?, difficulty?, sourceQuestionId? } */ + sourceMeta: json("source_meta"), + + status: practiceStatusEnum.default("in_progress").notNull(), + + totalQuestions: int("total_questions").default(0).notNull(), + answeredQuestions: int("answered_questions").default(0).notNull(), + correctCount: int("correct_count").default(0).notNull(), + + startedAt: timestamp("started_at", { mode: "date" }).defaultNow().notNull(), + completedAt: timestamp("completed_at", { mode: "date" }), + + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + studentIdx: index("ps_student_idx").on(table.studentId), + studentStatusIdx: index("ps_student_status_idx").on(table.studentId, table.status), + subjectIdx: index("ps_subject_idx").on(table.subjectId), +})); + +/** + * 专项练习答题记录 - 会话中每道题的作答情况。 + * + * 支持两种题目来源: + * 1. 题库中的题目(questionId 指向 questions 表) + * 2. AI 生成的变式题(variantContent 存储 JSON 内容,questionId 指向原题) + */ +export const practiceAnswers = mysqlTable("practice_answers", { + id: id("id").primaryKey(), + sessionId: varchar("session_id", { length: 128 }).notNull().references(() => practiceSessions.id, { onDelete: "cascade" }), + studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + + questionId: varchar("question_id", { length: 128 }).notNull().references(() => questions.id), + /** 变式题内容(AI 生成的变式题,未入库时存储在此字段) */ + variantContent: json("variant_content"), + /** 是否为变式题 */ + isVariant: boolean("is_variant").default(false).notNull(), + + orderIndex: int("order_index").notNull(), + status: practiceAnswerStatusEnum.default("pending").notNull(), + + studentAnswer: json("student_answer"), + isCorrect: boolean("is_correct"), + score: int("score"), + maxScore: int("max_score").default(1).notNull(), + + answeredAt: timestamp("answered_at", { mode: "date" }), + + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + sessionIdx: index("pa_session_idx").on(table.sessionId), + studentIdx: index("pa_student_idx").on(table.studentId), + questionIdx: index("pa_question_idx").on(table.questionId), + sessionOrderIdx: index("pa_session_order_idx").on(table.sessionId, table.orderIndex), +})); diff --git a/src/shared/db/schema/questions.ts b/src/shared/db/schema/questions.ts new file mode 100644 index 0000000..88c5c98 --- /dev/null +++ b/src/shared/db/schema/questions.ts @@ -0,0 +1,74 @@ +import { + mysqlTable, + varchar, + text, + timestamp, + int, + index, + json, + mysqlEnum, + primaryKey, + foreignKey, +} from "drizzle-orm/mysql-core"; +import { sql } from "drizzle-orm"; +import { id } from "./_helpers"; +import { users } from "./users"; +import { knowledgePoints } from "./knowledge-points"; + +// --- 3. Question Bank (Core) --- + +export const questionTypeEnum = mysqlEnum("type", ["single_choice", "multiple_choice", "text", "judgment", "composite"]); + +export const questions = mysqlTable("questions", { + id: id("id").primaryKey(), + + // Content can be JSON to store rich text, images, etc. or just text. + // Using JSON for flexibility in a modern ed-tech app (e.g. SlateJS nodes). + content: json("content").notNull(), + + // P3-6: 题目内容全文检索文本镜像。 + // - STORED 生成列:每次 INSERT/UPDATE content 时由 MySQL 自动同步 + // - 使用 CAST(content AS CHAR) 把 JSON 序列化为纯文本以支持 FULLTEXT + // - 配合 questions_content_text_ft_idx FULLTEXT 索引(见对应 drizzle 迁移)实现 MATCH AGAINST 检索 + // drizzle-kit 无法生成 FULLTEXT 索引,迁移文件中需手动追加 CREATE FULLTEXT INDEX + contentText: text("content_text").generatedAlwaysAs( + sql`CAST(content AS CHAR)`, + { mode: "stored" }, + ), + + type: questionTypeEnum.notNull(), + difficulty: int("difficulty").default(1), // 1-5 + + // Self-reference for "Infinite Nesting" (Parent Question) + // e.g., A reading comprehension passage (Parent) -> 5 sub-questions (Children) + parentId: varchar("parent_id", { length: 128 }), + + authorId: varchar("author_id", { length: 128 }).notNull().references(() => users.id), + + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + parentIdIdx: index("parent_id_idx").on(table.parentId), // Critical for querying children + authorIdIdx: index("author_id_idx").on(table.authorId), + // P3-6: 题目按类型+难度筛选的高频查询索引 + typeDifficultyIdx: index("questions_type_difficulty_idx").on(table.type, table.difficulty), +})); + +// Many-to-Many: Questions <-> Knowledge Points +export const questionsToKnowledgePoints = mysqlTable("questions_to_knowledge_points", { + questionId: varchar("question_id", { length: 128 }).notNull(), + knowledgePointId: varchar("knowledge_point_id", { length: 128 }).notNull(), +}, (table) => ({ + pk: primaryKey({ columns: [table.questionId, table.knowledgePointId] }), + kpIdx: index("kp_idx").on(table.knowledgePointId), // For querying "All questions under this KP" + qFk: foreignKey({ + columns: [table.questionId], + foreignColumns: [questions.id], + name: "q_kp_qid_fk" + }).onDelete("cascade"), + kpFk: foreignKey({ + columns: [table.knowledgePointId], + foreignColumns: [knowledgePoints.id], + name: "q_kp_kpid_fk" + }).onDelete("cascade"), +})); diff --git a/src/shared/db/schema/rbac.ts b/src/shared/db/schema/rbac.ts new file mode 100644 index 0000000..9bc2a09 --- /dev/null +++ b/src/shared/db/schema/rbac.ts @@ -0,0 +1,45 @@ +import { + mysqlTable, + varchar, + boolean, + timestamp, + primaryKey, + index, +} from "drizzle-orm/mysql-core"; +import { id } from "./_helpers"; +import { users } from "./users"; + +// --- Custom RBAC Extensions --- + +export const roles = mysqlTable("roles", { + id: id("id").primaryKey(), + name: varchar("name", { length: 50 }).notNull().unique(), // e.g., 'admin', 'teacher', 'student', 'grade_head' + description: varchar("description", { length: 255 }), + // Builtin roles (admin/teacher/student/parent/grade_head/teaching_head) are + // seeded with is_system = true and cannot be deleted. The `admin` role is + // additionally fully locked (no permission edits, no disable, no rename). + isSystem: boolean("is_system").default(false).notNull(), + // Disabled roles do not contribute permissions at login time. + isEnabled: boolean("is_enabled").default(true).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}); + +// Many-to-Many: Users <-> Roles +// Solves: "A teacher can also be a Grade Head" +export const usersToRoles = mysqlTable("users_to_roles", { + userId: varchar("user_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + roleId: varchar("role_id", { length: 128 }).notNull().references(() => roles.id, { onDelete: "cascade" }), +}, (table) => ({ + pk: primaryKey({ columns: [table.userId, table.roleId] }), + userIdIdx: index("user_id_idx").on(table.userId), +})); + +// Role -> Permissions (fine-grained RBAC) +export const rolePermissions = mysqlTable("role_permissions", { + roleId: varchar("role_id", { length: 128 }).notNull().references(() => roles.id, { onDelete: "cascade" }), + permission: varchar("permission", { length: 100 }).notNull(), +}, (table) => ({ + pk: primaryKey({ columns: [table.roleId, table.permission] }), + roleIdIdx: index("role_permissions_role_idx").on(table.roleId), +})); diff --git a/src/shared/db/schema/scheduling.ts b/src/shared/db/schema/scheduling.ts new file mode 100644 index 0000000..de06c02 --- /dev/null +++ b/src/shared/db/schema/scheduling.ts @@ -0,0 +1,56 @@ +import { + mysqlTable, + varchar, + text, + timestamp, + int, + index, + date, + mysqlEnum, + boolean, +} from "drizzle-orm/mysql-core"; +import { id } from "./_helpers"; + +// --- 20. Scheduling Rules & Schedule Changes (排课规则与调课) --- + +export const schedulingRules = mysqlTable("scheduling_rules", { + id: id("id").primaryKey(), + classId: varchar("class_id", { length: 128 }), // null=全局规则 + maxDailyHours: int("max_daily_hours").default(8), + maxContinuousHours: int("max_continuous_hours").default(2), + lunchBreakStart: varchar("lunch_break_start", { length: 10 }).default("12:00"), + lunchBreakEnd: varchar("lunch_break_end", { length: 10 }).default("13:00"), + morningStart: varchar("morning_start", { length: 10 }).default("08:00"), + afternoonEnd: varchar("afternoon_end", { length: 10 }).default("17:00"), + avoidBackToBack: boolean("avoid_back_to_back").default(false), + balancedSubjects: boolean("balanced_subjects").default(true), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + classIdx: index("scheduling_rules_class_idx").on(table.classId), +})); + +export const scheduleChangeStatusEnum = mysqlEnum("status", ["pending", "approved", "rejected", "completed"]); + +export const scheduleChanges = mysqlTable("schedule_changes", { + id: id("id").primaryKey(), + originalScheduleId: varchar("original_schedule_id", { length: 128 }), + classId: varchar("class_id", { length: 128 }).notNull(), + originalTeacherId: varchar("original_teacher_id", { length: 128 }), + substituteTeacherId: varchar("substitute_teacher_id", { length: 128 }), + originalDate: date("original_date"), + newDate: date("new_date"), + newStartTime: varchar("new_start_time", { length: 10 }), + newEndTime: varchar("new_end_time", { length: 10 }), + reason: text("reason"), + status: scheduleChangeStatusEnum.default("pending").notNull(), + requestedBy: varchar("requested_by", { length: 128 }).notNull(), + approvedBy: varchar("approved_by", { length: 128 }), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + classIdx: index("schedule_changes_class_idx").on(table.classId), + statusIdx: index("schedule_changes_status_idx").on(table.status), + requestedByIdx: index("schedule_changes_requested_by_idx").on(table.requestedBy), + originalScheduleIdx: index("schedule_changes_original_schedule_idx").on(table.originalScheduleId), +})); diff --git a/src/shared/db/schema/school.ts b/src/shared/db/schema/school.ts new file mode 100644 index 0000000..d887e0d --- /dev/null +++ b/src/shared/db/schema/school.ts @@ -0,0 +1,91 @@ +import { + mysqlTable, + varchar, + text, + timestamp, + int, + index, + boolean, + foreignKey, +} from "drizzle-orm/mysql-core"; +import { id } from "./_helpers"; +import { users } from "./users"; + +// --- 5. School Management --- + +export const departments = mysqlTable("departments", { + id: id("id").primaryKey(), + name: varchar("name", { length: 255 }).notNull().unique(), + description: text("description"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + nameIdx: index("departments_name_idx").on(table.name), +})) + +export const classrooms = mysqlTable("classrooms", { + id: id("id").primaryKey(), + name: varchar("name", { length: 255 }).notNull().unique(), + building: varchar("building", { length: 100 }), + floor: int("floor"), + capacity: int("capacity"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + nameIdx: index("classrooms_name_idx").on(table.name), +})) + +export const academicYears = mysqlTable("academic_years", { + id: id("id").primaryKey(), + name: varchar("name", { length: 100 }).notNull().unique(), + startDate: timestamp("start_date", { mode: "date" }).notNull(), + endDate: timestamp("end_date", { mode: "date" }).notNull(), + isActive: boolean("is_active").default(false).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + nameIdx: index("academic_years_name_idx").on(table.name), + activeIdx: index("academic_years_active_idx").on(table.isActive), +})) + +export const schools = mysqlTable("schools", { + id: id("id").primaryKey(), + name: varchar("name", { length: 255 }).notNull().unique(), + code: varchar("code", { length: 50 }).unique(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + nameIdx: index("schools_name_idx").on(table.name), + codeIdx: index("schools_code_idx").on(table.code), +})) + +export const grades = mysqlTable("grades", { + id: id("id").primaryKey(), + schoolId: varchar("school_id", { length: 128 }).notNull(), + name: varchar("name", { length: 100 }).notNull(), + order: int("order").default(0).notNull(), + gradeHeadId: varchar("grade_head_id", { length: 128 }), + teachingHeadId: varchar("teaching_head_id", { length: 128 }), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + schoolIdx: index("grades_school_idx").on(table.schoolId), + schoolNameUnique: index("grades_school_name_uniq").on(table.schoolId, table.name), + gradeHeadIdx: index("grades_grade_head_idx").on(table.gradeHeadId), + teachingHeadIdx: index("grades_teaching_head_idx").on(table.teachingHeadId), + schoolFk: foreignKey({ + columns: [table.schoolId], + foreignColumns: [schools.id], + name: "g_s_fk", + }).onDelete("cascade"), + gradeHeadFk: foreignKey({ + columns: [table.gradeHeadId], + foreignColumns: [users.id], + name: "g_gh_fk", + }).onDelete("set null"), + teachingHeadFk: foreignKey({ + columns: [table.teachingHeadId], + foreignColumns: [users.id], + name: "g_th_fk", + }).onDelete("set null"), +})) diff --git a/src/shared/db/schema/standards.ts b/src/shared/db/schema/standards.ts new file mode 100644 index 0000000..6ff7849 --- /dev/null +++ b/src/shared/db/schema/standards.ts @@ -0,0 +1,44 @@ +import { + mysqlTable, + varchar, + text, + timestamp, + int, + index, + uniqueIndex, + boolean, +} from "drizzle-orm/mysql-core"; +import { id } from "./_helpers"; +import { users } from "./users"; +import { subjects } from "./academic"; +import { grades } from "./school"; + +// --- 29. Standards (M1 课标对标体系) --- + +/** + * M1 课标库:支持国家标准/课标/自定义三层级。 + * 课案通过 lessonPlanStandards 关联表实现多对多关系。 + */ +export const standards = mysqlTable("standards", { + id: id("id").primaryKey(), + code: varchar("code", { length: 100 }).notNull(), + title: varchar("title", { length: 255 }).notNull(), + description: text("description"), + /** 层级:national(国家标准)/ curriculum(课标)/ custom(自定义) */ + level: varchar("level", { length: 20 }).notNull(), + parentId: varchar("parent_id", { length: 128 }), + subjectId: varchar("subject_id", { length: 128 }).references(() => subjects.id), + gradeId: varchar("grade_id", { length: 128 }).references(() => grades.id), + /** 学段:primary / junior_high / senior_high */ + stage: varchar("stage", { length: 20 }), + sortOrder: int("sort_order").default(0).notNull(), + isActive: boolean("is_active").default(true).notNull(), + createdBy: varchar("created_by", { length: 128 }).references(() => users.id), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + codeIdx: uniqueIndex("std_code_idx").on(table.code), + parentIdx: index("std_parent_idx").on(table.parentId), + subjectGradeIdx: index("std_subject_grade_idx").on(table.subjectId, table.gradeId), + levelIdx: index("std_level_idx").on(table.level), +})); diff --git a/src/shared/db/schema/system-settings.ts b/src/shared/db/schema/system-settings.ts new file mode 100644 index 0000000..8067bf9 --- /dev/null +++ b/src/shared/db/schema/system-settings.ts @@ -0,0 +1,29 @@ +import { + mysqlTable, + varchar, + text, + timestamp, + index, + uniqueIndex, +} from "drizzle-orm/mysql-core"; +import { id } from "./_helpers"; + +// --- 25. System Settings (系统设置 - 键值对存储) --- + +export const systemSettings = mysqlTable("system_settings", { + id: id("id").primaryKey(), + /** 设置分组:school_info / security_policy / file_upload / notification_config */ + category: varchar("category", { length: 50 }).notNull(), + /** 设置键名,如 schoolName / passwordMinLength / maxFileSize */ + key: varchar("key", { length: 100 }).notNull(), + /** 设置值(JSON 字符串,支持字符串/数字/布尔/对象) */ + value: text("value").notNull(), + /** 值类型:string / number / boolean / json */ + valueType: varchar("value_type", { length: 20 }).default("string").notNull(), + updatedBy: varchar("updated_by", { length: 128 }), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + categoryKeyIdx: uniqueIndex("ss_category_key_idx").on(table.category, table.key), + categoryIdx: index("ss_category_idx").on(table.category), +})); diff --git a/src/shared/db/schema/users.ts b/src/shared/db/schema/users.ts new file mode 100644 index 0000000..52d029a --- /dev/null +++ b/src/shared/db/schema/users.ts @@ -0,0 +1,152 @@ +import { + mysqlTable, + varchar, + text, + timestamp, + int, + index, + primaryKey, + date, + datetime, + boolean, + foreignKey, +} from "drizzle-orm/mysql-core"; +import { createId } from "@paralleldrive/cuid2"; +import type { AdapterAccountType } from "next-auth/adapters"; +import { id } from "./_helpers"; + +// --- 1. Users & Auth (Auth.js v5 Standard) --- + +export const users = mysqlTable("users", { + id: id("id").primaryKey(), + name: varchar("name", { length: 255 }), + email: varchar("email", { length: 255 }).notNull().unique(), + emailVerified: timestamp("emailVerified", { mode: "date" }), + image: varchar("image", { length: 255 }), + // Credentials Auth (Optional) + password: varchar("password", { length: 255 }), + + phone: varchar("phone", { length: 30 }), + address: varchar("address", { length: 255 }), + gender: varchar("gender", { length: 20 }), + age: int("age"), + gradeId: varchar("grade_id", { length: 128 }), + departmentId: varchar("department_id", { length: 128 }), + onboardedAt: timestamp("onboarded_at", { mode: "date" }), + + // 未成年人信息保护 + birthDate: date("birth_date"), + guardianName: varchar("guardian_name", { length: 255 }), + guardianPhone: varchar("guardian_phone", { length: 20 }), + guardianRelation: varchar("guardian_relation", { length: 50 }), + consentAcceptedAt: datetime("consent_accepted_at"), + + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + emailIdx: index("email_idx").on(table.email), + gradeIdIdx: index("users_grade_id_idx").on(table.gradeId), + departmentIdIdx: index("users_department_id_idx").on(table.departmentId), +})); + +// Auth.js: Accounts (OAuth providers) +export const accounts = mysqlTable("accounts", { + userId: varchar("userId", { length: 128 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + type: varchar("type", { length: 255 }).$type().notNull(), + provider: varchar("provider", { length: 255 }).notNull(), + providerAccountId: varchar("providerAccountId", { length: 255 }).notNull(), + refresh_token: text("refresh_token"), + access_token: text("access_token"), + expires_at: int("expires_at"), + token_type: varchar("token_type", { length: 255 }), + scope: varchar("scope", { length: 255 }), + id_token: text("id_token"), + session_state: varchar("session_state", { length: 255 }), + }, + (account) => ({ + compoundKey: primaryKey({ + columns: [account.provider, account.providerAccountId], + }), + userIdIdx: index("account_userId_idx").on(account.userId), + }) +); + +/** + * @deprecated audit-P1-9:此表在 JWT 策略下从未被写入数据。 + * + * NextAuth 配置为 `session: { strategy: "jwt" }`(见 `src/auth.ts`), + * 所有 session 信息存储在 JWT cookie 中,不写入此表。 + * `revokeAllOtherSessionsAction` 已改为 no-op(返回 0 并记录日志)。 + * `getUsersDashboardStats` 已不再查询此表。 + * + * 此表定义暂时保留是为了: + * 1. 避免删除表定义后 drizzle-kit 生成 DROP TABLE 迁移误删生产数据 + * 2. 未来若切换为 `strategy: "database"` 或引入 JWT 黑名单可复用 + * + * 后续应在显式迁移中删除此表(同时删除 relations.ts 中的 sessionsRelations)。 + */ +// Auth.js: Sessions +export const sessions = mysqlTable("sessions", { + sessionToken: varchar("sessionToken", { length: 255 }).primaryKey(), + userId: varchar("userId", { length: 128 }) + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + expires: timestamp("expires", { mode: "date" }).notNull(), +}, (table) => ({ + userIdIdx: index("session_userId_idx").on(table.userId), +})); + +// Auth.js: Verification Tokens +export const verificationTokens = mysqlTable("verificationTokens", { + identifier: varchar("identifier", { length: 255 }).notNull(), + token: varchar("token", { length: 255 }).notNull(), + expires: timestamp("expires", { mode: "date" }).notNull(), +}, (vt) => ({ + compoundKey: primaryKey({ columns: [vt.identifier, vt.token] }), +})); + +// --- 17. Parent-Student Relations (家长-子女关联) --- + +export const parentStudentRelations = mysqlTable("parent_student_relations", { + id: varchar("id", { length: 128 }).primaryKey().$defaultFn(() => createId()), + parentId: varchar("parent_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }), + relation: varchar("relation", { length: 50 }), // 父亲/母亲/其他 + createdAt: timestamp("created_at").defaultNow().notNull(), +}, (table) => ({ + parentIdx: index("parent_student_relations_parent_idx").on(table.parentId), + studentIdx: index("parent_student_relations_student_idx").on(table.studentId), + parentFk: foreignKey({ + columns: [table.parentId], + foreignColumns: [users.id], + name: "psr_p_fk", + }).onDelete("cascade"), + studentFk: foreignKey({ + columns: [table.studentId], + foreignColumns: [users.id], + name: "psr_s_fk", + }).onDelete("cascade"), +})); + +// --- 19. Password Security (密码安全策略) --- + +export const passwordSecurity = mysqlTable("password_security", { + id: varchar("id", { length: 128 }).primaryKey().$defaultFn(() => createId()), + userId: varchar("user_id", { length: 128 }).notNull().unique().references(() => users.id, { onDelete: "cascade" }), + failedLoginAttempts: int("failed_login_attempts").default(0).notNull(), + lockedUntil: timestamp("locked_until", { mode: "date" }), + passwordChangedAt: timestamp("password_changed_at").defaultNow().notNull(), + mustChangePassword: boolean("must_change_password").default(false).notNull(), + lastPasswordChange: timestamp("last_password_change", { mode: "date" }), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), +}, (table) => ({ + userIdIdx: index("password_security_user_idx").on(table.userId), + userFk: foreignKey({ + columns: [table.userId], + foreignColumns: [users.id], + name: "ps_u_fk", + }).onDelete("cascade"), +})); diff --git a/src/shared/lib/cache/invalidation-map-academic.ts b/src/shared/lib/cache/invalidation-map-academic.ts new file mode 100644 index 0000000..2e9dee6 --- /dev/null +++ b/src/shared/lib/cache/invalidation-map-academic.ts @@ -0,0 +1,248 @@ +import type { InvalidationRule } from "./types" + +/** + * course-plans / diagnostic / elective / error-book / exams 模块的失效规则。 + * + * 详见 `invalidation-map.ts` 中的聚合说明。 + */ +export const ACADEMIC_INVALIDATION_RULES = { + // ===== course-plans 模块 ===== + "course-plans.create": { + tags: ["course-plans"], + queryKeys: [["course-plans", "list"]], + paths: [ + "/admin/course-plans", + "/teacher/course-plans", + "/parent/course-plans", + "/student/course-plans", + ], + }, + "course-plans.update": { + tags: ["course-plans"], + queryKeys: [["course-plans", "detail"], ["course-plans", "list"]], + paths: [ + "/admin/course-plans", + "/teacher/course-plans", + "/parent/course-plans", + "/student/course-plans", + ], + }, + "course-plans.delete": { + tags: ["course-plans"], + queryKeys: [["course-plans", "list"]], + paths: [ + "/admin/course-plans", + "/teacher/course-plans", + "/parent/course-plans", + "/student/course-plans", + ], + }, + "course-plans.item.create": { + tags: ["course-plans"], + queryKeys: [["course-plans", "items"]], + paths: [ + "/admin/course-plans", + "/teacher/course-plans", + "/parent/course-plans", + "/student/course-plans", + ], + }, + "course-plans.item.update": { + tags: ["course-plans"], + queryKeys: [["course-plans", "items"]], + paths: [ + "/admin/course-plans", + "/teacher/course-plans", + "/parent/course-plans", + "/student/course-plans", + ], + }, + "course-plans.item.delete": { + tags: ["course-plans"], + queryKeys: [["course-plans", "items"]], + paths: [ + "/admin/course-plans", + "/teacher/course-plans", + "/parent/course-plans", + "/student/course-plans", + ], + }, + "course-plans.item.toggleCompleted": { + tags: ["course-plans"], + queryKeys: [["course-plans", "items"]], + paths: [ + "/admin/course-plans", + "/teacher/course-plans", + "/parent/course-plans", + "/student/course-plans", + ], + }, + "course-plans.item.reorder": { + tags: ["course-plans"], + queryKeys: [["course-plans", "items"]], + paths: [ + "/admin/course-plans", + "/teacher/course-plans", + "/parent/course-plans", + "/student/course-plans", + ], + }, + "course-plans.item.bulkToggle": { + tags: ["course-plans"], + queryKeys: [["course-plans", "items"]], + paths: [ + "/admin/course-plans", + "/teacher/course-plans", + "/parent/course-plans", + "/student/course-plans", + ], + }, + "course-plans.copy": { + tags: ["course-plans"], + queryKeys: [["course-plans", "list"]], + paths: [ + "/admin/course-plans", + "/teacher/course-plans", + "/parent/course-plans", + "/student/course-plans", + ], + }, + + // ===== diagnostic 模块 ===== + "diagnostic.generateStudentReport": { + tags: ["diagnostic"], + queryKeys: [["diagnostic", "list"]], + paths: ["/teacher/diagnostic"], + }, + "diagnostic.generateClassReport": { + tags: ["diagnostic"], + queryKeys: [["diagnostic", "list"]], + paths: ["/teacher/diagnostic"], + }, + "diagnostic.generateGradeReport": { + tags: ["diagnostic"], + queryKeys: [["diagnostic", "list"]], + paths: ["/teacher/diagnostic", "/admin/diagnostic"], + }, + "diagnostic.publish": { + tags: ["diagnostic"], + queryKeys: [["diagnostic", "list"]], + paths: ["/teacher/diagnostic"], + }, + "diagnostic.delete": { + tags: ["diagnostic"], + queryKeys: [["diagnostic", "list"]], + paths: ["/teacher/diagnostic"], + }, + + // ===== elective 模块 ===== + "elective.create": { + tags: ["elective"], + queryKeys: [["elective", "list"]], + paths: ["/admin/elective", "/teacher/elective", "/student/elective", "/parent/elective"], + }, + "elective.update": { + tags: ["elective"], + queryKeys: [["elective", "detail"], ["elective", "list"]], + paths: ["/admin/elective", "/teacher/elective", "/student/elective", "/parent/elective"], + }, + "elective.delete": { + tags: ["elective"], + queryKeys: [["elective", "list"]], + paths: ["/admin/elective", "/teacher/elective", "/student/elective", "/parent/elective"], + }, + "elective.openSelection": { + tags: ["elective"], + queryKeys: [["elective", "list"]], + paths: ["/admin/elective", "/teacher/elective", "/student/elective", "/parent/elective"], + }, + "elective.closeSelection": { + tags: ["elective"], + queryKeys: [["elective", "list"]], + paths: ["/admin/elective", "/teacher/elective", "/student/elective", "/parent/elective"], + }, + "elective.runLottery": { + tags: ["elective"], + queryKeys: [["elective", "list"]], + paths: ["/admin/elective", "/teacher/elective", "/student/elective", "/parent/elective"], + }, + "elective.selectCourse": { + tags: ["elective"], + queryKeys: [["elective", "selections"]], + paths: ["/admin/elective", "/teacher/elective", "/student/elective", "/parent/elective"], + }, + "elective.dropCourse": { + tags: ["elective"], + queryKeys: [["elective", "selections"]], + paths: ["/admin/elective", "/teacher/elective", "/student/elective", "/parent/elective"], + }, + + // ===== error-book 模块 ===== + "error-book.create": { + tags: ["error-book"], + queryKeys: [["error-book", "list"]], + paths: ["/student/error-book"], + }, + "error-book.update": { + tags: ["error-book"], + queryKeys: [["error-book", "detail"], ["error-book", "list"]], + paths: ["/student/error-book"], + }, + "error-book.review": { + tags: ["error-book"], + queryKeys: [["error-book", "detail"]], + paths: ["/student/error-book"], + }, + "error-book.delete": { + tags: ["error-book"], + queryKeys: [["error-book", "list"]], + paths: ["/student/error-book"], + }, + "error-book.archive": { + tags: ["error-book"], + queryKeys: [["error-book", "list"]], + paths: ["/student/error-book"], + }, + "error-book.collect": { + tags: ["error-book"], + queryKeys: [["error-book", "list"]], + paths: ["/student/error-book"], + }, + + // ===== exams 模块 ===== + "exams.create": { + tags: ["exams"], + queryKeys: [["exams", "list"]], + paths: ["/teacher/exams/all"], + }, + "exams.createAi": { + tags: ["exams"], + queryKeys: [["exams", "list"]], + paths: ["/teacher/exams/all"], + }, + "exams.regenerateAiQuestion": { + tags: ["exams"], + queryKeys: [["exams", "detail"]], + paths: [], + }, + "exams.update": { + tags: ["exams"], + queryKeys: [["exams", "detail"], ["exams", "list"]], + paths: ["/teacher/exams/all"], + }, + "exams.delete": { + tags: ["exams"], + queryKeys: [["exams", "list"]], + paths: ["/teacher/exams/all"], + }, + "exams.duplicate": { + tags: ["exams"], + queryKeys: [["exams", "list"]], + paths: ["/teacher/exams/all"], + }, + "exams.richContent.save": { + tags: ["exams"], + queryKeys: [["exams", "detail"]], + paths: ["/teacher/exams/all"], + }, +} as const satisfies Record diff --git a/src/shared/lib/cache/invalidation-map-admin.ts b/src/shared/lib/cache/invalidation-map-admin.ts new file mode 100644 index 0000000..4642f51 --- /dev/null +++ b/src/shared/lib/cache/invalidation-map-admin.ts @@ -0,0 +1,349 @@ +import type { InvalidationRule } from "./types" + +/** + * questions / rbac / scheduling / school / settings / standards / textbooks / users 模块的失效规则。 + * + * 详见 `invalidation-map.ts` 中的聚合说明。 + */ +export const ADMIN_INVALIDATION_RULES = { + // ===== questions 模块 ===== + "questions.create": { + tags: ["questions"], + queryKeys: [["questions", "list"]], + paths: ["/teacher/questions", "/admin/questions"], + }, + "questions.update": { + tags: ["questions"], + queryKeys: [["questions", "detail"], ["questions", "list"]], + paths: ["/teacher/questions", "/admin/questions"], + }, + "questions.delete": { + tags: ["questions"], + queryKeys: [["questions", "list"]], + paths: ["/teacher/questions", "/admin/questions"], + }, + "questions.deleteBatch": { + tags: ["questions"], + queryKeys: [["questions", "list"]], + paths: ["/teacher/questions", "/admin/questions"], + }, + "questions.import": { + tags: ["questions"], + queryKeys: [["questions", "list"]], + paths: ["/teacher/questions", "/admin/questions"], + }, + + // ===== rbac 模块 ===== + "rbac.role.create": { + tags: ["rbac"], + queryKeys: [["rbac", "roles"]], + paths: ["/admin/roles"], + }, + "rbac.role.update": { + tags: ["rbac"], + queryKeys: [["rbac", "roles"]], + paths: ["/admin/roles"], + }, + "rbac.role.delete": { + tags: ["rbac"], + queryKeys: [["rbac", "roles"]], + paths: ["/admin/roles"], + }, + "rbac.role.toggleEnabled": { + tags: ["rbac"], + queryKeys: [["rbac", "roles"]], + paths: ["/admin/roles"], + }, + "rbac.role.setPermissions": { + tags: ["rbac"], + queryKeys: [["rbac", "roles"]], + paths: ["/admin/roles"], + }, + "rbac.assignUserRoles": { + tags: ["rbac"], + queryKeys: [["rbac", "userRoles"]], + paths: ["/admin/users"], + }, + + // ===== scheduling 模块 ===== + "scheduling.saveRules": { + tags: ["scheduling"], + queryKeys: [["scheduling", "rules"]], + paths: ["/admin/scheduling/rules"], + }, + "scheduling.autoSchedule": { + tags: ["scheduling"], + queryKeys: [["scheduling", "list"]], + paths: ["/admin/scheduling/auto", "/teacher/classes/schedule"], + }, + "scheduling.applyAutoSchedule": { + tags: ["scheduling"], + queryKeys: [["scheduling", "changes"]], + paths: ["/teacher/schedule-changes", "/admin/scheduling/changes"], + }, + "scheduling.requestChange": { + tags: ["scheduling"], + queryKeys: [["scheduling", "changes"]], + paths: ["/admin/scheduling/changes", "/teacher/schedule-changes"], + }, + "scheduling.approveChange": { + tags: ["scheduling"], + queryKeys: [["scheduling", "changes"]], + paths: ["/admin/scheduling/changes", "/teacher/schedule-changes"], + }, + "scheduling.rejectChange": { + tags: ["scheduling"], + queryKeys: [["scheduling", "changes"]], + paths: ["/admin/scheduling/changes", "/teacher/schedule-changes"], + }, + + // ===== school 模块 ===== + "school.department.create": { + tags: ["school"], + queryKeys: [["school", "departments"]], + paths: ["/admin/school/departments"], + }, + "school.department.update": { + tags: ["school"], + queryKeys: [["school", "departments"]], + paths: ["/admin/school/departments"], + }, + "school.department.delete": { + tags: ["school"], + queryKeys: [["school", "departments"]], + paths: ["/admin/school/departments"], + }, + "school.academicYear.create": { + tags: ["school"], + queryKeys: [["school", "academicYears"]], + paths: ["/admin/school/academic-year"], + }, + "school.academicYear.update": { + tags: ["school"], + queryKeys: [["school", "academicYears"]], + paths: ["/admin/school/academic-year"], + }, + "school.academicYear.delete": { + tags: ["school"], + queryKeys: [["school", "academicYears"]], + paths: ["/admin/school/academic-year"], + }, + "school.school.create": { + tags: ["school"], + queryKeys: [["school", "schools"]], + paths: ["/admin/school/schools", "/admin/school/grades"], + }, + "school.school.update": { + tags: ["school"], + queryKeys: [["school", "schools"]], + paths: ["/admin/school/schools", "/admin/school/grades"], + }, + "school.school.delete": { + tags: ["school"], + queryKeys: [["school", "schools"]], + paths: ["/admin/school/schools", "/admin/school/grades"], + }, + "school.grade.create": { + tags: ["school"], + queryKeys: [["school", "grades"]], + paths: ["/admin/school/grades"], + }, + "school.grade.update": { + tags: ["school"], + queryKeys: [["school", "grades"]], + paths: ["/admin/school/grades"], + }, + "school.grade.delete": { + tags: ["school"], + queryKeys: [["school", "grades"]], + paths: ["/admin/school/grades"], + }, + "school.grade.promote": { + tags: ["school"], + queryKeys: [["school", "grades"]], + paths: ["/admin/school/grades"], + }, + + // ===== settings 模块 ===== + "settings.aiProvider.upsert": { + tags: ["settings"], + queryKeys: [["settings", "aiProviders"]], + paths: ["/admin/ai-settings", "/settings"], + }, + "settings.aiProvider.test": { + tags: ["settings"], + queryKeys: [["settings", "aiProviders"]], + paths: ["/admin/ai-settings", "/settings"], + }, + "settings.aiProvider.delete": { + tags: ["settings"], + queryKeys: [["settings", "aiProviders"]], + paths: ["/admin/ai-settings", "/settings"], + }, + "settings.avatar.update": { + tags: ["settings"], + queryKeys: [["settings", "avatar"]], + paths: ["/profile", "/settings"], + }, + "settings.avatar.remove": { + tags: ["settings"], + queryKeys: [["settings", "avatar"]], + paths: ["/profile", "/settings"], + }, + "settings.brand.save": { + tags: ["settings"], + queryKeys: [["settings", "brand"]], + paths: ["/admin/settings", "/(auth)/login", "/(auth)/register"], + }, + "settings.password.change": { + tags: ["settings"], + queryKeys: [["settings", "password"]], + paths: ["/settings"], + }, + "settings.security.setup2FA": { + tags: ["settings"], + queryKeys: [["settings", "security"]], + paths: ["/settings"], + }, + "settings.security.verify2FA": { + tags: ["settings"], + queryKeys: [["settings", "security"]], + paths: ["/settings"], + }, + "settings.security.disable2FA": { + tags: ["settings"], + queryKeys: [["settings", "security"]], + paths: ["/settings"], + }, + "settings.security.regenerateBackupCodes": { + tags: ["settings"], + queryKeys: [["settings", "security"]], + paths: ["/settings"], + }, + "settings.security.revokeSessions": { + tags: ["settings"], + queryKeys: [["settings", "security"]], + paths: ["/settings"], + }, + "settings.profile.update": { + tags: ["settings"], + queryKeys: [["settings", "profile"]], + paths: ["/settings"], + }, + "settings.notifications.updatePreferences": { + tags: ["settings"], + queryKeys: [["settings", "notificationPreferences"]], + paths: ["/settings"], + }, + "settings.systemSettings.save": { + tags: ["settings"], + queryKeys: [["settings", "system"]], + paths: ["/admin/settings"], + }, + + // ===== standards 模块 ===== + "standards.create": { + tags: ["standards"], + queryKeys: [["standards", "list"]], + paths: ["/admin/standards"], + }, + "standards.update": { + tags: ["standards"], + queryKeys: [["standards", "detail"], ["standards", "list"]], + paths: ["/admin/standards"], + }, + "standards.deactivate": { + tags: ["standards"], + queryKeys: [["standards", "list"]], + paths: ["/admin/standards"], + }, + "standards.linkPlan": { + tags: ["standards"], + queryKeys: [["standards", "planLinks"]], + paths: ["/teacher/lesson-plans"], + }, + "standards.unlinkPlan": { + tags: ["standards"], + queryKeys: [["standards", "planLinks"]], + paths: ["/teacher/lesson-plans"], + }, + + // ===== textbooks 模块 ===== + "textbooks.create": { + tags: ["textbooks"], + queryKeys: [["textbooks", "list"]], + paths: ["/teacher/textbooks"], + }, + "textbooks.update": { + tags: ["textbooks"], + queryKeys: [["textbooks", "detail"], ["textbooks", "list"]], + paths: ["/teacher/textbooks"], + }, + "textbooks.delete": { + tags: ["textbooks"], + queryKeys: [["textbooks", "list"]], + paths: ["/teacher/textbooks"], + }, + "textbooks.chapter.create": { + tags: ["textbooks"], + queryKeys: [["textbooks", "chapters"]], + paths: ["/teacher/textbooks"], + }, + "textbooks.chapter.update": { + tags: ["textbooks"], + queryKeys: [["textbooks", "chapters"]], + paths: ["/teacher/textbooks"], + }, + "textbooks.chapter.delete": { + tags: ["textbooks"], + queryKeys: [["textbooks", "chapters"]], + paths: ["/teacher/textbooks"], + }, + "textbooks.chapter.reorder": { + tags: ["textbooks"], + queryKeys: [["textbooks", "chapters"]], + paths: ["/teacher/textbooks"], + }, + "textbooks.knowledgePoint.create": { + tags: ["textbooks"], + queryKeys: [["textbooks", "knowledgePoints"]], + paths: ["/teacher/textbooks"], + }, + "textbooks.knowledgePoint.update": { + tags: ["textbooks"], + queryKeys: [["textbooks", "knowledgePoints"]], + paths: ["/teacher/textbooks"], + }, + "textbooks.knowledgePoint.delete": { + tags: ["textbooks"], + queryKeys: [["textbooks", "knowledgePoints"]], + paths: ["/teacher/textbooks"], + }, + "textbooks.prerequisite.create": { + tags: ["textbooks"], + queryKeys: [["textbooks", "prerequisites"]], + paths: ["/teacher/textbooks"], + }, + "textbooks.prerequisite.delete": { + tags: ["textbooks"], + queryKeys: [["textbooks", "prerequisites"]], + paths: ["/teacher/textbooks"], + }, + + // ===== users 模块 ===== + "users.update": { + tags: ["users"], + queryKeys: [["users", "detail"]], + paths: ["/profile", "/settings"], + }, + "users.import": { + tags: ["users"], + queryKeys: [["users", "list"]], + paths: ["/admin/users/import"], + }, + "users.delete": { + tags: ["users"], + queryKeys: [["users", "list"]], + paths: ["/admin/users"], + }, +} as const satisfies Record diff --git a/src/shared/lib/cache/invalidation-map-classes.ts b/src/shared/lib/cache/invalidation-map-classes.ts new file mode 100644 index 0000000..ecdd497 --- /dev/null +++ b/src/shared/lib/cache/invalidation-map-classes.ts @@ -0,0 +1,156 @@ +import type { InvalidationRule } from "./types" + +/** + * classes / i18n / adaptive-practice / announcements / attendance / audit 模块的失效规则。 + * + * 详见 `invalidation-map.ts` 中的聚合说明。 + */ +export const CLASSES_INVALIDATION_RULES = { + // ===== classes 模块 ===== + "classes.create": { + tags: ["classes", "classes:list"], + queryKeys: [["classes", "list"]], + paths: ["/teacher/classes/my", "/admin/classes"], + }, + "classes.update": { + tags: ["classes", "classes:detail", "classes:detail:{id}"], + queryKeys: [["classes", "detail"], ["classes", "list"]], + // paths 不含动态段:revalidatePath("/teacher/classes/my") 已能覆盖详情页([id] 路由) + paths: ["/teacher/classes/my", "/admin/classes"], + }, + "classes.delete": { + tags: ["classes", "classes:list", "classes:detail:{id}"], + queryKeys: [["classes", "list"], ["classes", "detail"]], + paths: ["/teacher/classes/my", "/admin/classes"], + }, + "classes.students.update": { + tags: ["classes:students:{classId}"], + queryKeys: [["classes", "students"]], + paths: ["/teacher/classes/my"], + }, + "classes.schedule.update": { + tags: ["classes:schedule:{classId}"], + queryKeys: [["classes", "schedule"]], + paths: ["/teacher/classes/schedule"], + }, + "classes.grade.update": { + tags: ["classes"], + queryKeys: [["classes", "list"]], + paths: [], + }, + "classes.invitation.create": { + tags: ["classes:invitations:{classId}"], + queryKeys: [["classes", "invitations"]], + paths: ["/teacher/classes/my/{classId}"], + }, + "classes.invitation.revoke": { + tags: ["classes:invitations:{classId}"], + queryKeys: [["classes", "invitations"]], + paths: ["/teacher/classes/my/{classId}"], + }, + + // ===== i18n 模块 ===== + "i18n.setLocale": { + tags: ["i18n"], + queryKeys: [["i18n"]], + paths: ["/"], + }, + + // ===== adaptive-practice 模块 ===== + "adaptive-practice.create": { + tags: ["adaptive-practice"], + queryKeys: [["adaptive-practice", "list"]], + paths: ["/student/practice"], + }, + "adaptive-practice.submit": { + tags: ["adaptive-practice"], + queryKeys: [["adaptive-practice", "detail"]], + paths: ["/student/practice"], + }, + "adaptive-practice.complete": { + tags: ["adaptive-practice"], + queryKeys: [["adaptive-practice", "list"], ["adaptive-practice", "detail"]], + paths: ["/student/practice"], + }, + "adaptive-practice.abandon": { + tags: ["adaptive-practice"], + queryKeys: [["adaptive-practice", "list"]], + paths: ["/student/practice"], + }, + + // ===== announcements 模块 ===== + "announcements.create": { + tags: ["announcements"], + queryKeys: [["announcements", "list"]], + paths: ["/admin/announcements", "/announcements"], + }, + "announcements.update": { + tags: ["announcements"], + queryKeys: [["announcements", "detail"], ["announcements", "list"]], + paths: ["/admin/announcements", "/announcements"], + }, + "announcements.delete": { + tags: ["announcements"], + queryKeys: [["announcements", "list"]], + paths: ["/admin/announcements", "/announcements"], + }, + "announcements.publish": { + tags: ["announcements"], + queryKeys: [["announcements", "list"]], + paths: ["/admin/announcements", "/announcements"], + }, + "announcements.archive": { + tags: ["announcements"], + queryKeys: [["announcements", "list"]], + paths: ["/admin/announcements", "/announcements"], + }, + "announcements.togglePin": { + tags: ["announcements"], + queryKeys: [["announcements", "list"]], + paths: ["/admin/announcements", "/announcements"], + }, + "announcements.markRead": { + tags: ["announcements"], + queryKeys: [["announcements", "unread"]], + paths: [], + }, + + // ===== attendance 模块 ===== + "attendance.record": { + tags: ["attendance"], + queryKeys: [["attendance", "list"]], + paths: ["/teacher/attendance"], + }, + "attendance.batchRecord": { + tags: ["attendance"], + queryKeys: [["attendance", "list"]], + paths: ["/teacher/attendance"], + }, + "attendance.update": { + tags: ["attendance"], + queryKeys: [["attendance", "detail"], ["attendance", "list"]], + paths: ["/teacher/attendance"], + }, + "attendance.delete": { + tags: ["attendance"], + queryKeys: [["attendance", "list"]], + paths: ["/teacher/attendance"], + }, + "attendance.saveRules": { + tags: ["attendance"], + queryKeys: [["attendance", "rules"]], + paths: ["/teacher/attendance"], + }, + + // ===== audit 模块 ===== + "audit.saveRetentionConfig": { + tags: ["audit"], + queryKeys: [["audit", "config"]], + paths: ["/admin/audit-logs/overview", "/admin/audit-logs"], + }, + "audit.purge": { + tags: ["audit"], + queryKeys: [["audit", "list"]], + paths: ["/admin/audit-logs/overview", "/admin/audit-logs"], + }, +} as const satisfies Record diff --git a/src/shared/lib/cache/invalidation-map-grades.ts b/src/shared/lib/cache/invalidation-map-grades.ts new file mode 100644 index 0000000..7800306 --- /dev/null +++ b/src/shared/lib/cache/invalidation-map-grades.ts @@ -0,0 +1,166 @@ +import type { InvalidationRule } from "./types" + +/** + * grades / homework / invitation-codes / leave-requests 模块的失效规则。 + * + * 详见 `invalidation-map.ts` 中的聚合说明。 + */ +export const GRADES_INVALIDATION_RULES = { + // ===== grades 模块 ===== + "grades.create": { + tags: ["grades"], + queryKeys: [["grades", "list"]], + paths: ["/teacher/grades"], + }, + "grades.batchCreate": { + tags: ["grades"], + queryKeys: [["grades", "list"]], + paths: ["/teacher/grades"], + }, + "grades.batchCreateByExam": { + tags: ["grades"], + queryKeys: [["grades", "list"]], + paths: ["/teacher/grades"], + }, + "grades.undoBatchCreate": { + tags: ["grades"], + queryKeys: [["grades", "list"]], + paths: ["/teacher/grades"], + }, + "grades.update": { + tags: ["grades"], + queryKeys: [["grades", "detail"], ["grades", "list"]], + paths: ["/teacher/grades"], + }, + "grades.delete": { + tags: ["grades"], + queryKeys: [["grades", "list"]], + paths: ["/teacher/grades"], + }, + "grades.bulkDelete": { + tags: ["grades"], + queryKeys: [["grades", "list"]], + paths: ["/teacher/grades"], + }, + "grades.import": { + tags: ["grades"], + queryKeys: [["grades", "list"]], + paths: ["/teacher/grades", "/teacher/grades/entry"], + }, + "grades.draft.save": { + tags: ["grades"], + queryKeys: [["grades", "draft"]], + paths: ["/teacher/grades"], + }, + "grades.draft.delete": { + tags: ["grades"], + queryKeys: [["grades", "draft"]], + paths: [], + }, + "grades.appeal.create": { + tags: ["grades"], + queryKeys: [["grades", "appeals"]], + paths: ["/student/grades"], + }, + "grades.appeal.withdraw": { + tags: ["grades"], + queryKeys: [["grades", "appeals"]], + paths: ["/student/grades"], + }, + "grades.appeal.review": { + tags: ["grades"], + queryKeys: [["grades", "appeals"]], + paths: ["/teacher/grades", "/student/grades"], + }, + "grades.lock.acquire": { + tags: ["grades"], + queryKeys: [["grades", "lock"]], + paths: [], + }, + "grades.lock.renew": { + tags: ["grades"], + queryKeys: [["grades", "lock"]], + paths: [], + }, + "grades.lock.release": { + tags: ["grades"], + queryKeys: [["grades", "lock"]], + paths: [], + }, + + // ===== homework 模块 ===== + "homework.create": { + tags: ["homework"], + queryKeys: [["homework", "list"]], + paths: ["/teacher/homework/assignments", "/teacher/homework/submissions"], + }, + "homework.startSubmission": { + tags: ["homework"], + queryKeys: [["homework", "submissions"]], + paths: ["/student/learning/assignments"], + }, + "homework.saveAnswer": { + tags: ["homework"], + queryKeys: [["homework", "submissions"]], + paths: [], + }, + "homework.submit": { + tags: ["homework"], + queryKeys: [["homework", "submissions"]], + paths: ["/teacher/homework/submissions", "/student/learning/assignments"], + }, + "homework.grade": { + tags: ["homework"], + queryKeys: [["homework", "submissions"]], + paths: ["/teacher/homework/submissions"], + }, + "homework.batchAutoGrade": { + tags: ["homework"], + queryKeys: [["homework", "submissions"]], + paths: ["/teacher/homework/submissions", "/teacher/homework/assignments"], + }, + "homework.deleteScan": { + tags: ["homework"], + queryKeys: [["homework", "scans"]], + paths: [], + }, + "homework.remind": { + tags: ["homework"], + queryKeys: [["homework", "list"]], + paths: [], + }, + + // ===== invitation-codes 模块 ===== + "invitation-codes.generate": { + tags: ["invitation-codes"], + queryKeys: [["invitation-codes", "list"]], + paths: ["/admin/invitation-codes"], + }, + "invitation-codes.delete": { + tags: ["invitation-codes"], + queryKeys: [["invitation-codes", "list"]], + paths: ["/admin/invitation-codes"], + }, + "invitation-codes.validate": { + tags: ["invitation-codes"], + queryKeys: [["invitation-codes"]], + paths: [], + }, + + // ===== leave-requests 模块 ===== + "leave-requests.create": { + tags: ["leave-requests"], + queryKeys: [["leave-requests", "list"]], + paths: ["/parent/leave", "/student/leave", "/teacher/leave"], + }, + "leave-requests.review": { + tags: ["leave-requests"], + queryKeys: [["leave-requests", "list"]], + paths: ["/parent/leave", "/student/leave", "/teacher/leave"], + }, + "leave-requests.cancel": { + tags: ["leave-requests"], + queryKeys: [["leave-requests", "list"]], + paths: ["/parent/leave", "/student/leave", "/teacher/leave"], + }, +} as const satisfies Record diff --git a/src/shared/lib/cache/invalidation-map-lesson-preparation.ts b/src/shared/lib/cache/invalidation-map-lesson-preparation.ts new file mode 100644 index 0000000..92b7d8d --- /dev/null +++ b/src/shared/lib/cache/invalidation-map-lesson-preparation.ts @@ -0,0 +1,150 @@ +import type { InvalidationRule } from "./types" + +/** + * lesson-preparation 模块的失效规则。 + * + * 详见 `invalidation-map.ts` 中的聚合说明。 + */ +export const LESSON_PREPARATION_INVALIDATION_RULES = { + // ===== lesson-preparation 模块 ===== + "lesson-preparation.create": { + tags: ["lesson-preparation"], + queryKeys: [["lesson-preparation", "list"]], + paths: ["/teacher/lesson-plans"], + }, + "lesson-preparation.update": { + tags: ["lesson-preparation"], + queryKeys: [["lesson-preparation", "detail"], ["lesson-preparation", "list"]], + paths: ["/teacher/lesson-plans"], + }, + "lesson-preparation.saveVersion": { + tags: ["lesson-preparation"], + queryKeys: [["lesson-preparation", "versions"]], + paths: [], + }, + "lesson-preparation.revertVersion": { + tags: ["lesson-preparation"], + queryKeys: [["lesson-preparation", "versions"]], + paths: ["/teacher/lesson-plans"], + }, + "lesson-preparation.delete": { + tags: ["lesson-preparation"], + queryKeys: [["lesson-preparation", "list"]], + paths: ["/teacher/lesson-plans"], + }, + "lesson-preparation.publish": { + tags: ["lesson-preparation"], + queryKeys: [["lesson-preparation", "detail"]], + paths: ["/teacher/lesson-plans"], + }, + "lesson-preparation.unpublish": { + tags: ["lesson-preparation"], + queryKeys: [["lesson-preparation", "detail"]], + paths: ["/teacher/lesson-plans"], + }, + "lesson-preparation.duplicate": { + tags: ["lesson-preparation"], + queryKeys: [["lesson-preparation", "list"]], + paths: ["/teacher/lesson-plans"], + }, + "lesson-preparation.template.save": { + tags: ["lesson-preparation"], + queryKeys: [["lesson-preparation", "templates"]], + paths: [], + }, + "lesson-preparation.template.delete": { + tags: ["lesson-preparation"], + queryKeys: [["lesson-preparation", "templates"]], + paths: ["/teacher/lesson-plans"], + }, + "lesson-preparation.formative.create": { + tags: ["lesson-preparation"], + queryKeys: [["lesson-preparation", "formative"]], + paths: ["/teacher/lesson-plans"], + }, + "lesson-preparation.formative.update": { + tags: ["lesson-preparation"], + queryKeys: [["lesson-preparation", "formative"]], + paths: ["/teacher/lesson-plans"], + }, + "lesson-preparation.formative.delete": { + tags: ["lesson-preparation"], + queryKeys: [["lesson-preparation", "formative"]], + paths: ["/teacher/lesson-plans"], + }, + "lesson-preparation.formative.submitResponse": { + tags: ["lesson-preparation"], + queryKeys: [["lesson-preparation", "formative"]], + paths: ["/teacher/lesson-plans"], + }, + "lesson-preparation.comment.create": { + tags: ["lesson-preparation"], + queryKeys: [["lesson-preparation", "comments"]], + paths: ["/teacher/lesson-plans"], + }, + "lesson-preparation.comment.update": { + tags: ["lesson-preparation"], + queryKeys: [["lesson-preparation", "comments"]], + paths: ["/teacher/lesson-plans"], + }, + "lesson-preparation.comment.toggleResolved": { + tags: ["lesson-preparation"], + queryKeys: [["lesson-preparation", "comments"]], + paths: ["/teacher/lesson-plans"], + }, + "lesson-preparation.comment.delete": { + tags: ["lesson-preparation"], + queryKeys: [["lesson-preparation", "comments"]], + paths: ["/teacher/lesson-plans"], + }, + "lesson-preparation.substitute.create": { + tags: ["lesson-preparation"], + queryKeys: [["lesson-preparation", "substitutes"]], + paths: ["/teacher/lesson-plans"], + }, + "lesson-preparation.substitute.updateStatus": { + tags: ["lesson-preparation"], + queryKeys: [["lesson-preparation", "substitutes"]], + paths: ["/teacher/lesson-plans"], + }, + "lesson-preparation.substitute.delete": { + tags: ["lesson-preparation"], + queryKeys: [["lesson-preparation", "substitutes"]], + paths: ["/teacher/lesson-plans"], + }, + "lesson-preparation.attachment.create": { + tags: ["lesson-preparation"], + queryKeys: [["lesson-preparation", "detail"]], + paths: ["/teacher/lesson-plans"], + }, + "lesson-preparation.review.submit": { + tags: ["lesson-preparation"], + queryKeys: [["lesson-preparation", "detail"]], + paths: ["/teacher/lesson-plans"], + }, + "lesson-preparation.review.review": { + tags: ["lesson-preparation"], + queryKeys: [["lesson-preparation", "detail"]], + paths: ["/teacher/lesson-plans"], + }, + "lesson-preparation.review.withdraw": { + tags: ["lesson-preparation"], + queryKeys: [["lesson-preparation", "detail"]], + paths: ["/teacher/lesson-plans"], + }, + "lesson-preparation.schedule.create": { + tags: ["lesson-preparation"], + queryKeys: [["lesson-preparation", "schedules"]], + paths: ["/teacher/lesson-plans", "/teacher/lesson-plans/calendar"], + }, + "lesson-preparation.schedule.delete": { + tags: ["lesson-preparation"], + queryKeys: [["lesson-preparation", "schedules"]], + paths: ["/teacher/lesson-plans", "/teacher/lesson-plans/calendar"], + }, + "lesson-preparation.publishHomework": { + tags: ["lesson-preparation", "homework"], + queryKeys: [["lesson-preparation", "list"], ["homework", "list"]], + paths: ["/teacher/lesson-plans", "/teacher/homework"], + }, +} as const satisfies Record diff --git a/src/shared/lib/cache/invalidation-map-messaging.ts b/src/shared/lib/cache/invalidation-map-messaging.ts new file mode 100644 index 0000000..e7ba4a9 --- /dev/null +++ b/src/shared/lib/cache/invalidation-map-messaging.ts @@ -0,0 +1,136 @@ +import type { InvalidationRule } from "./types" + +/** + * messaging / notifications / onboarding / proctoring 模块的失效规则。 + * + * 详见 `invalidation-map.ts` 中的聚合说明。 + */ +export const MESSAGING_INVALIDATION_RULES = { + // ===== messaging 模块 ===== + "messaging.send": { + tags: ["messaging"], + queryKeys: [["messaging", "list"]], + paths: ["/messages"], + }, + "messaging.markRead": { + tags: ["messaging"], + queryKeys: [["messaging", "detail"], ["messaging", "unread"]], + paths: ["/messages"], + }, + "messaging.delete": { + tags: ["messaging"], + queryKeys: [["messaging", "list"]], + paths: ["/messages"], + }, + "messaging.toggleStar": { + tags: ["messaging"], + queryKeys: [["messaging", "detail"]], + paths: ["/messages"], + }, + "messaging.draft.save": { + tags: ["messaging"], + queryKeys: [["messaging", "drafts"]], + paths: ["/messages/compose"], + }, + "messaging.draft.delete": { + tags: ["messaging"], + queryKeys: [["messaging", "drafts"]], + paths: ["/messages"], + }, + "messaging.bulkMarkRead": { + tags: ["messaging"], + queryKeys: [["messaging", "list"], ["messaging", "unread"]], + paths: ["/messages"], + }, + "messaging.bulkDelete": { + tags: ["messaging"], + queryKeys: [["messaging", "list"]], + paths: ["/messages"], + }, + "messaging.bulkToggleStar": { + tags: ["messaging"], + queryKeys: [["messaging", "list"]], + paths: ["/messages"], + }, + "messaging.recall": { + tags: ["messaging"], + queryKeys: [["messaging", "detail"]], + paths: ["/messages"], + }, + "messaging.sendGroup": { + tags: ["messaging"], + queryKeys: [["messaging", "list"]], + paths: ["/messages", "/messages/compose"], + }, + "messaging.template.save": { + tags: ["messaging"], + queryKeys: [["messaging", "templates"]], + paths: ["/messages"], + }, + "messaging.template.delete": { + tags: ["messaging"], + queryKeys: [["messaging", "templates"]], + paths: ["/messages"], + }, + "messaging.report": { + tags: ["messaging"], + queryKeys: [["messaging", "detail"]], + paths: ["/messages"], + }, + "messaging.block": { + tags: ["messaging"], + queryKeys: [["messaging", "blocks"]], + paths: ["/messages"], + }, + "messaging.unblock": { + tags: ["messaging"], + queryKeys: [["messaging", "blocks"]], + paths: ["/messages"], + }, + + // ===== notifications 模块 ===== + "notifications.send": { + tags: ["notifications"], + queryKeys: [["notifications", "list"]], + paths: [], + }, + "notifications.sendClass": { + tags: ["notifications"], + queryKeys: [["notifications", "list"]], + paths: [], + }, + "notifications.markRead": { + tags: ["notifications"], + queryKeys: [["notifications", "detail"], ["notifications", "unread"]], + paths: ["/messages"], + }, + "notifications.markAllRead": { + tags: ["notifications"], + queryKeys: [["notifications", "list"], ["notifications", "unread"]], + paths: ["/messages"], + }, + "notifications.archive": { + tags: ["notifications"], + queryKeys: [["notifications", "list"]], + paths: ["/messages"], + }, + "notifications.updatePreferences": { + tags: ["notifications"], + queryKeys: [["notifications", "preferences"]], + paths: ["/settings"], + }, + + // ===== onboarding 模块 ===== + "onboarding.complete": { + tags: ["onboarding"], + queryKeys: [["onboarding", "status"]], + paths: ["/onboarding"], + }, + + // ===== proctoring 模块 ===== + "proctoring.recordEvent": { + tags: ["proctoring"], + queryKeys: [["proctoring", "events"]], + paths: ["/teacher/exams"], + }, +} as const satisfies Record diff --git a/src/shared/lib/cache/invalidation-map.ts b/src/shared/lib/cache/invalidation-map.ts index 6bc019b..4e90be1 100644 --- a/src/shared/lib/cache/invalidation-map.ts +++ b/src/shared/lib/cache/invalidation-map.ts @@ -1,4 +1,10 @@ import type { InvalidationRule } from "./types" +import { ACADEMIC_INVALIDATION_RULES } from "./invalidation-map-academic" +import { ADMIN_INVALIDATION_RULES } from "./invalidation-map-admin" +import { CLASSES_INVALIDATION_RULES } from "./invalidation-map-classes" +import { GRADES_INVALIDATION_RULES } from "./invalidation-map-grades" +import { LESSON_PREPARATION_INVALIDATION_RULES } from "./invalidation-map-lesson-preparation" +import { MESSAGING_INVALIDATION_RULES } from "./invalidation-map-messaging" /** * 模板占位符填充。 @@ -31,1164 +37,17 @@ export function fillTemplate( * - tags:模块级粒度 `["{module}"]`(简化迁移,不强制资源级) * - queryKeys:`[["{module}"]]` 或 `[["{module}", "list"]]` * - paths:从原 revalidatePath 调用中提取;动态段(`[id]` 路由)用父路径覆盖 + * + * 拆分说明:规则按业务域分布在 `invalidation-map-*.ts` 子文件中, + * 此处通过 spread 聚合成单一 `INVALIDATION_MAP`,保留对外的稳定 API。 */ export const INVALIDATION_MAP = { - // ===== classes 模块 ===== - "classes.create": { - tags: ["classes", "classes:list"], - queryKeys: [["classes", "list"]], - paths: ["/teacher/classes/my", "/admin/classes"], - }, - "classes.update": { - tags: ["classes", "classes:detail", "classes:detail:{id}"], - queryKeys: [["classes", "detail"], ["classes", "list"]], - // paths 不含动态段:revalidatePath("/teacher/classes/my") 已能覆盖详情页([id] 路由) - paths: ["/teacher/classes/my", "/admin/classes"], - }, - "classes.delete": { - tags: ["classes", "classes:list", "classes:detail:{id}"], - queryKeys: [["classes", "list"], ["classes", "detail"]], - paths: ["/teacher/classes/my", "/admin/classes"], - }, - "classes.students.update": { - tags: ["classes:students:{classId}"], - queryKeys: [["classes", "students"]], - paths: ["/teacher/classes/my"], - }, - "classes.schedule.update": { - tags: ["classes:schedule:{classId}"], - queryKeys: [["classes", "schedule"]], - paths: ["/teacher/classes/schedule"], - }, - "classes.grade.update": { - tags: ["classes"], - queryKeys: [["classes", "list"]], - paths: [], - }, - "classes.invitation.create": { - tags: ["classes:invitations:{classId}"], - queryKeys: [["classes", "invitations"]], - paths: ["/teacher/classes/my/{classId}"], - }, - "classes.invitation.revoke": { - tags: ["classes:invitations:{classId}"], - queryKeys: [["classes", "invitations"]], - paths: ["/teacher/classes/my/{classId}"], - }, - - // ===== i18n 模块 ===== - "i18n.setLocale": { - tags: ["i18n"], - queryKeys: [["i18n"]], - paths: ["/"], - }, - - // ===== adaptive-practice 模块 ===== - "adaptive-practice.create": { - tags: ["adaptive-practice"], - queryKeys: [["adaptive-practice", "list"]], - paths: ["/student/practice"], - }, - "adaptive-practice.submit": { - tags: ["adaptive-practice"], - queryKeys: [["adaptive-practice", "detail"]], - paths: ["/student/practice"], - }, - "adaptive-practice.complete": { - tags: ["adaptive-practice"], - queryKeys: [["adaptive-practice", "list"], ["adaptive-practice", "detail"]], - paths: ["/student/practice"], - }, - "adaptive-practice.abandon": { - tags: ["adaptive-practice"], - queryKeys: [["adaptive-practice", "list"]], - paths: ["/student/practice"], - }, - - // ===== announcements 模块 ===== - "announcements.create": { - tags: ["announcements"], - queryKeys: [["announcements", "list"]], - paths: ["/admin/announcements", "/announcements"], - }, - "announcements.update": { - tags: ["announcements"], - queryKeys: [["announcements", "detail"], ["announcements", "list"]], - paths: ["/admin/announcements", "/announcements"], - }, - "announcements.delete": { - tags: ["announcements"], - queryKeys: [["announcements", "list"]], - paths: ["/admin/announcements", "/announcements"], - }, - "announcements.publish": { - tags: ["announcements"], - queryKeys: [["announcements", "list"]], - paths: ["/admin/announcements", "/announcements"], - }, - "announcements.archive": { - tags: ["announcements"], - queryKeys: [["announcements", "list"]], - paths: ["/admin/announcements", "/announcements"], - }, - "announcements.togglePin": { - tags: ["announcements"], - queryKeys: [["announcements", "list"]], - paths: ["/admin/announcements", "/announcements"], - }, - "announcements.markRead": { - tags: ["announcements"], - queryKeys: [["announcements", "unread"]], - paths: [], - }, - - // ===== attendance 模块 ===== - "attendance.record": { - tags: ["attendance"], - queryKeys: [["attendance", "list"]], - paths: ["/teacher/attendance"], - }, - "attendance.batchRecord": { - tags: ["attendance"], - queryKeys: [["attendance", "list"]], - paths: ["/teacher/attendance"], - }, - "attendance.update": { - tags: ["attendance"], - queryKeys: [["attendance", "detail"], ["attendance", "list"]], - paths: ["/teacher/attendance"], - }, - "attendance.delete": { - tags: ["attendance"], - queryKeys: [["attendance", "list"]], - paths: ["/teacher/attendance"], - }, - "attendance.saveRules": { - tags: ["attendance"], - queryKeys: [["attendance", "rules"]], - paths: ["/teacher/attendance"], - }, - - // ===== audit 模块 ===== - "audit.saveRetentionConfig": { - tags: ["audit"], - queryKeys: [["audit", "config"]], - paths: ["/admin/audit-logs/overview", "/admin/audit-logs"], - }, - "audit.purge": { - tags: ["audit"], - queryKeys: [["audit", "list"]], - paths: ["/admin/audit-logs/overview", "/admin/audit-logs"], - }, - - // ===== course-plans 模块 ===== - "course-plans.create": { - tags: ["course-plans"], - queryKeys: [["course-plans", "list"]], - paths: [ - "/admin/course-plans", - "/teacher/course-plans", - "/parent/course-plans", - "/student/course-plans", - ], - }, - "course-plans.update": { - tags: ["course-plans"], - queryKeys: [["course-plans", "detail"], ["course-plans", "list"]], - paths: [ - "/admin/course-plans", - "/teacher/course-plans", - "/parent/course-plans", - "/student/course-plans", - ], - }, - "course-plans.delete": { - tags: ["course-plans"], - queryKeys: [["course-plans", "list"]], - paths: [ - "/admin/course-plans", - "/teacher/course-plans", - "/parent/course-plans", - "/student/course-plans", - ], - }, - "course-plans.item.create": { - tags: ["course-plans"], - queryKeys: [["course-plans", "items"]], - paths: [ - "/admin/course-plans", - "/teacher/course-plans", - "/parent/course-plans", - "/student/course-plans", - ], - }, - "course-plans.item.update": { - tags: ["course-plans"], - queryKeys: [["course-plans", "items"]], - paths: [ - "/admin/course-plans", - "/teacher/course-plans", - "/parent/course-plans", - "/student/course-plans", - ], - }, - "course-plans.item.delete": { - tags: ["course-plans"], - queryKeys: [["course-plans", "items"]], - paths: [ - "/admin/course-plans", - "/teacher/course-plans", - "/parent/course-plans", - "/student/course-plans", - ], - }, - "course-plans.item.toggleCompleted": { - tags: ["course-plans"], - queryKeys: [["course-plans", "items"]], - paths: [ - "/admin/course-plans", - "/teacher/course-plans", - "/parent/course-plans", - "/student/course-plans", - ], - }, - "course-plans.item.reorder": { - tags: ["course-plans"], - queryKeys: [["course-plans", "items"]], - paths: [ - "/admin/course-plans", - "/teacher/course-plans", - "/parent/course-plans", - "/student/course-plans", - ], - }, - "course-plans.item.bulkToggle": { - tags: ["course-plans"], - queryKeys: [["course-plans", "items"]], - paths: [ - "/admin/course-plans", - "/teacher/course-plans", - "/parent/course-plans", - "/student/course-plans", - ], - }, - "course-plans.copy": { - tags: ["course-plans"], - queryKeys: [["course-plans", "list"]], - paths: [ - "/admin/course-plans", - "/teacher/course-plans", - "/parent/course-plans", - "/student/course-plans", - ], - }, - - // ===== diagnostic 模块 ===== - "diagnostic.generateStudentReport": { - tags: ["diagnostic"], - queryKeys: [["diagnostic", "list"]], - paths: ["/teacher/diagnostic"], - }, - "diagnostic.generateClassReport": { - tags: ["diagnostic"], - queryKeys: [["diagnostic", "list"]], - paths: ["/teacher/diagnostic"], - }, - "diagnostic.generateGradeReport": { - tags: ["diagnostic"], - queryKeys: [["diagnostic", "list"]], - paths: ["/teacher/diagnostic", "/admin/diagnostic"], - }, - "diagnostic.publish": { - tags: ["diagnostic"], - queryKeys: [["diagnostic", "list"]], - paths: ["/teacher/diagnostic"], - }, - "diagnostic.delete": { - tags: ["diagnostic"], - queryKeys: [["diagnostic", "list"]], - paths: ["/teacher/diagnostic"], - }, - - // ===== elective 模块 ===== - "elective.create": { - tags: ["elective"], - queryKeys: [["elective", "list"]], - paths: ["/admin/elective", "/teacher/elective", "/student/elective", "/parent/elective"], - }, - "elective.update": { - tags: ["elective"], - queryKeys: [["elective", "detail"], ["elective", "list"]], - paths: ["/admin/elective", "/teacher/elective", "/student/elective", "/parent/elective"], - }, - "elective.delete": { - tags: ["elective"], - queryKeys: [["elective", "list"]], - paths: ["/admin/elective", "/teacher/elective", "/student/elective", "/parent/elective"], - }, - "elective.openSelection": { - tags: ["elective"], - queryKeys: [["elective", "list"]], - paths: ["/admin/elective", "/teacher/elective", "/student/elective", "/parent/elective"], - }, - "elective.closeSelection": { - tags: ["elective"], - queryKeys: [["elective", "list"]], - paths: ["/admin/elective", "/teacher/elective", "/student/elective", "/parent/elective"], - }, - "elective.runLottery": { - tags: ["elective"], - queryKeys: [["elective", "list"]], - paths: ["/admin/elective", "/teacher/elective", "/student/elective", "/parent/elective"], - }, - "elective.selectCourse": { - tags: ["elective"], - queryKeys: [["elective", "selections"]], - paths: ["/admin/elective", "/teacher/elective", "/student/elective", "/parent/elective"], - }, - "elective.dropCourse": { - tags: ["elective"], - queryKeys: [["elective", "selections"]], - paths: ["/admin/elective", "/teacher/elective", "/student/elective", "/parent/elective"], - }, - - // ===== error-book 模块 ===== - "error-book.create": { - tags: ["error-book"], - queryKeys: [["error-book", "list"]], - paths: ["/student/error-book"], - }, - "error-book.update": { - tags: ["error-book"], - queryKeys: [["error-book", "detail"], ["error-book", "list"]], - paths: ["/student/error-book"], - }, - "error-book.review": { - tags: ["error-book"], - queryKeys: [["error-book", "detail"]], - paths: ["/student/error-book"], - }, - "error-book.delete": { - tags: ["error-book"], - queryKeys: [["error-book", "list"]], - paths: ["/student/error-book"], - }, - "error-book.archive": { - tags: ["error-book"], - queryKeys: [["error-book", "list"]], - paths: ["/student/error-book"], - }, - "error-book.collect": { - tags: ["error-book"], - queryKeys: [["error-book", "list"]], - paths: ["/student/error-book"], - }, - - // ===== exams 模块 ===== - "exams.create": { - tags: ["exams"], - queryKeys: [["exams", "list"]], - paths: ["/teacher/exams/all"], - }, - "exams.createAi": { - tags: ["exams"], - queryKeys: [["exams", "list"]], - paths: ["/teacher/exams/all"], - }, - "exams.regenerateAiQuestion": { - tags: ["exams"], - queryKeys: [["exams", "detail"]], - paths: [], - }, - "exams.update": { - tags: ["exams"], - queryKeys: [["exams", "detail"], ["exams", "list"]], - paths: ["/teacher/exams/all"], - }, - "exams.delete": { - tags: ["exams"], - queryKeys: [["exams", "list"]], - paths: ["/teacher/exams/all"], - }, - "exams.duplicate": { - tags: ["exams"], - queryKeys: [["exams", "list"]], - paths: ["/teacher/exams/all"], - }, - "exams.richContent.save": { - tags: ["exams"], - queryKeys: [["exams", "detail"]], - paths: ["/teacher/exams/all"], - }, - - // ===== grades 模块 ===== - "grades.create": { - tags: ["grades"], - queryKeys: [["grades", "list"]], - paths: ["/teacher/grades"], - }, - "grades.batchCreate": { - tags: ["grades"], - queryKeys: [["grades", "list"]], - paths: ["/teacher/grades"], - }, - "grades.batchCreateByExam": { - tags: ["grades"], - queryKeys: [["grades", "list"]], - paths: ["/teacher/grades"], - }, - "grades.undoBatchCreate": { - tags: ["grades"], - queryKeys: [["grades", "list"]], - paths: ["/teacher/grades"], - }, - "grades.update": { - tags: ["grades"], - queryKeys: [["grades", "detail"], ["grades", "list"]], - paths: ["/teacher/grades"], - }, - "grades.delete": { - tags: ["grades"], - queryKeys: [["grades", "list"]], - paths: ["/teacher/grades"], - }, - "grades.bulkDelete": { - tags: ["grades"], - queryKeys: [["grades", "list"]], - paths: ["/teacher/grades"], - }, - "grades.import": { - tags: ["grades"], - queryKeys: [["grades", "list"]], - paths: ["/teacher/grades", "/teacher/grades/entry"], - }, - "grades.draft.save": { - tags: ["grades"], - queryKeys: [["grades", "draft"]], - paths: ["/teacher/grades"], - }, - "grades.draft.delete": { - tags: ["grades"], - queryKeys: [["grades", "draft"]], - paths: [], - }, - "grades.appeal.create": { - tags: ["grades"], - queryKeys: [["grades", "appeals"]], - paths: ["/student/grades"], - }, - "grades.appeal.withdraw": { - tags: ["grades"], - queryKeys: [["grades", "appeals"]], - paths: ["/student/grades"], - }, - "grades.appeal.review": { - tags: ["grades"], - queryKeys: [["grades", "appeals"]], - paths: ["/teacher/grades", "/student/grades"], - }, - "grades.lock.acquire": { - tags: ["grades"], - queryKeys: [["grades", "lock"]], - paths: [], - }, - "grades.lock.renew": { - tags: ["grades"], - queryKeys: [["grades", "lock"]], - paths: [], - }, - "grades.lock.release": { - tags: ["grades"], - queryKeys: [["grades", "lock"]], - paths: [], - }, - - // ===== homework 模块 ===== - "homework.create": { - tags: ["homework"], - queryKeys: [["homework", "list"]], - paths: ["/teacher/homework/assignments", "/teacher/homework/submissions"], - }, - "homework.startSubmission": { - tags: ["homework"], - queryKeys: [["homework", "submissions"]], - paths: ["/student/learning/assignments"], - }, - "homework.saveAnswer": { - tags: ["homework"], - queryKeys: [["homework", "submissions"]], - paths: [], - }, - "homework.submit": { - tags: ["homework"], - queryKeys: [["homework", "submissions"]], - paths: ["/teacher/homework/submissions", "/student/learning/assignments"], - }, - "homework.grade": { - tags: ["homework"], - queryKeys: [["homework", "submissions"]], - paths: ["/teacher/homework/submissions"], - }, - "homework.batchAutoGrade": { - tags: ["homework"], - queryKeys: [["homework", "submissions"]], - paths: ["/teacher/homework/submissions", "/teacher/homework/assignments"], - }, - "homework.deleteScan": { - tags: ["homework"], - queryKeys: [["homework", "scans"]], - paths: [], - }, - "homework.remind": { - tags: ["homework"], - queryKeys: [["homework", "list"]], - paths: [], - }, - - // ===== invitation-codes 模块 ===== - "invitation-codes.generate": { - tags: ["invitation-codes"], - queryKeys: [["invitation-codes", "list"]], - paths: ["/admin/invitation-codes"], - }, - "invitation-codes.delete": { - tags: ["invitation-codes"], - queryKeys: [["invitation-codes", "list"]], - paths: ["/admin/invitation-codes"], - }, - "invitation-codes.validate": { - tags: ["invitation-codes"], - queryKeys: [["invitation-codes"]], - paths: [], - }, - - // ===== leave-requests 模块 ===== - "leave-requests.create": { - tags: ["leave-requests"], - queryKeys: [["leave-requests", "list"]], - paths: ["/parent/leave", "/student/leave", "/teacher/leave"], - }, - "leave-requests.review": { - tags: ["leave-requests"], - queryKeys: [["leave-requests", "list"]], - paths: ["/parent/leave", "/student/leave", "/teacher/leave"], - }, - "leave-requests.cancel": { - tags: ["leave-requests"], - queryKeys: [["leave-requests", "list"]], - paths: ["/parent/leave", "/student/leave", "/teacher/leave"], - }, - - // ===== lesson-preparation 模块 ===== - "lesson-preparation.create": { - tags: ["lesson-preparation"], - queryKeys: [["lesson-preparation", "list"]], - paths: ["/teacher/lesson-plans"], - }, - "lesson-preparation.update": { - tags: ["lesson-preparation"], - queryKeys: [["lesson-preparation", "detail"], ["lesson-preparation", "list"]], - paths: ["/teacher/lesson-plans"], - }, - "lesson-preparation.saveVersion": { - tags: ["lesson-preparation"], - queryKeys: [["lesson-preparation", "versions"]], - paths: [], - }, - "lesson-preparation.revertVersion": { - tags: ["lesson-preparation"], - queryKeys: [["lesson-preparation", "versions"]], - paths: ["/teacher/lesson-plans"], - }, - "lesson-preparation.delete": { - tags: ["lesson-preparation"], - queryKeys: [["lesson-preparation", "list"]], - paths: ["/teacher/lesson-plans"], - }, - "lesson-preparation.publish": { - tags: ["lesson-preparation"], - queryKeys: [["lesson-preparation", "detail"]], - paths: ["/teacher/lesson-plans"], - }, - "lesson-preparation.unpublish": { - tags: ["lesson-preparation"], - queryKeys: [["lesson-preparation", "detail"]], - paths: ["/teacher/lesson-plans"], - }, - "lesson-preparation.duplicate": { - tags: ["lesson-preparation"], - queryKeys: [["lesson-preparation", "list"]], - paths: ["/teacher/lesson-plans"], - }, - "lesson-preparation.template.save": { - tags: ["lesson-preparation"], - queryKeys: [["lesson-preparation", "templates"]], - paths: [], - }, - "lesson-preparation.template.delete": { - tags: ["lesson-preparation"], - queryKeys: [["lesson-preparation", "templates"]], - paths: ["/teacher/lesson-plans"], - }, - "lesson-preparation.formative.create": { - tags: ["lesson-preparation"], - queryKeys: [["lesson-preparation", "formative"]], - paths: ["/teacher/lesson-plans"], - }, - "lesson-preparation.formative.update": { - tags: ["lesson-preparation"], - queryKeys: [["lesson-preparation", "formative"]], - paths: ["/teacher/lesson-plans"], - }, - "lesson-preparation.formative.delete": { - tags: ["lesson-preparation"], - queryKeys: [["lesson-preparation", "formative"]], - paths: ["/teacher/lesson-plans"], - }, - "lesson-preparation.formative.submitResponse": { - tags: ["lesson-preparation"], - queryKeys: [["lesson-preparation", "formative"]], - paths: ["/teacher/lesson-plans"], - }, - "lesson-preparation.comment.create": { - tags: ["lesson-preparation"], - queryKeys: [["lesson-preparation", "comments"]], - paths: ["/teacher/lesson-plans"], - }, - "lesson-preparation.comment.update": { - tags: ["lesson-preparation"], - queryKeys: [["lesson-preparation", "comments"]], - paths: ["/teacher/lesson-plans"], - }, - "lesson-preparation.comment.toggleResolved": { - tags: ["lesson-preparation"], - queryKeys: [["lesson-preparation", "comments"]], - paths: ["/teacher/lesson-plans"], - }, - "lesson-preparation.comment.delete": { - tags: ["lesson-preparation"], - queryKeys: [["lesson-preparation", "comments"]], - paths: ["/teacher/lesson-plans"], - }, - "lesson-preparation.substitute.create": { - tags: ["lesson-preparation"], - queryKeys: [["lesson-preparation", "substitutes"]], - paths: ["/teacher/lesson-plans"], - }, - "lesson-preparation.substitute.updateStatus": { - tags: ["lesson-preparation"], - queryKeys: [["lesson-preparation", "substitutes"]], - paths: ["/teacher/lesson-plans"], - }, - "lesson-preparation.substitute.delete": { - tags: ["lesson-preparation"], - queryKeys: [["lesson-preparation", "substitutes"]], - paths: ["/teacher/lesson-plans"], - }, - "lesson-preparation.attachment.create": { - tags: ["lesson-preparation"], - queryKeys: [["lesson-preparation", "detail"]], - paths: ["/teacher/lesson-plans"], - }, - "lesson-preparation.review.submit": { - tags: ["lesson-preparation"], - queryKeys: [["lesson-preparation", "detail"]], - paths: ["/teacher/lesson-plans"], - }, - "lesson-preparation.review.review": { - tags: ["lesson-preparation"], - queryKeys: [["lesson-preparation", "detail"]], - paths: ["/teacher/lesson-plans"], - }, - "lesson-preparation.review.withdraw": { - tags: ["lesson-preparation"], - queryKeys: [["lesson-preparation", "detail"]], - paths: ["/teacher/lesson-plans"], - }, - "lesson-preparation.schedule.create": { - tags: ["lesson-preparation"], - queryKeys: [["lesson-preparation", "schedules"]], - paths: ["/teacher/lesson-plans", "/teacher/lesson-plans/calendar"], - }, - "lesson-preparation.schedule.delete": { - tags: ["lesson-preparation"], - queryKeys: [["lesson-preparation", "schedules"]], - paths: ["/teacher/lesson-plans", "/teacher/lesson-plans/calendar"], - }, - "lesson-preparation.publishHomework": { - tags: ["lesson-preparation", "homework"], - queryKeys: [["lesson-preparation", "list"], ["homework", "list"]], - paths: ["/teacher/lesson-plans", "/teacher/homework"], - }, - - // ===== messaging 模块 ===== - "messaging.send": { - tags: ["messaging"], - queryKeys: [["messaging", "list"]], - paths: ["/messages"], - }, - "messaging.markRead": { - tags: ["messaging"], - queryKeys: [["messaging", "detail"], ["messaging", "unread"]], - paths: ["/messages"], - }, - "messaging.delete": { - tags: ["messaging"], - queryKeys: [["messaging", "list"]], - paths: ["/messages"], - }, - "messaging.toggleStar": { - tags: ["messaging"], - queryKeys: [["messaging", "detail"]], - paths: ["/messages"], - }, - "messaging.draft.save": { - tags: ["messaging"], - queryKeys: [["messaging", "drafts"]], - paths: ["/messages/compose"], - }, - "messaging.draft.delete": { - tags: ["messaging"], - queryKeys: [["messaging", "drafts"]], - paths: ["/messages"], - }, - "messaging.bulkMarkRead": { - tags: ["messaging"], - queryKeys: [["messaging", "list"], ["messaging", "unread"]], - paths: ["/messages"], - }, - "messaging.bulkDelete": { - tags: ["messaging"], - queryKeys: [["messaging", "list"]], - paths: ["/messages"], - }, - "messaging.bulkToggleStar": { - tags: ["messaging"], - queryKeys: [["messaging", "list"]], - paths: ["/messages"], - }, - "messaging.recall": { - tags: ["messaging"], - queryKeys: [["messaging", "detail"]], - paths: ["/messages"], - }, - "messaging.sendGroup": { - tags: ["messaging"], - queryKeys: [["messaging", "list"]], - paths: ["/messages", "/messages/compose"], - }, - "messaging.template.save": { - tags: ["messaging"], - queryKeys: [["messaging", "templates"]], - paths: ["/messages"], - }, - "messaging.template.delete": { - tags: ["messaging"], - queryKeys: [["messaging", "templates"]], - paths: ["/messages"], - }, - "messaging.report": { - tags: ["messaging"], - queryKeys: [["messaging", "detail"]], - paths: ["/messages"], - }, - "messaging.block": { - tags: ["messaging"], - queryKeys: [["messaging", "blocks"]], - paths: ["/messages"], - }, - "messaging.unblock": { - tags: ["messaging"], - queryKeys: [["messaging", "blocks"]], - paths: ["/messages"], - }, - - // ===== notifications 模块 ===== - "notifications.send": { - tags: ["notifications"], - queryKeys: [["notifications", "list"]], - paths: [], - }, - "notifications.sendClass": { - tags: ["notifications"], - queryKeys: [["notifications", "list"]], - paths: [], - }, - "notifications.markRead": { - tags: ["notifications"], - queryKeys: [["notifications", "detail"], ["notifications", "unread"]], - paths: ["/messages"], - }, - "notifications.markAllRead": { - tags: ["notifications"], - queryKeys: [["notifications", "list"], ["notifications", "unread"]], - paths: ["/messages"], - }, - "notifications.archive": { - tags: ["notifications"], - queryKeys: [["notifications", "list"]], - paths: ["/messages"], - }, - "notifications.updatePreferences": { - tags: ["notifications"], - queryKeys: [["notifications", "preferences"]], - paths: ["/settings"], - }, - - // ===== onboarding 模块 ===== - "onboarding.complete": { - tags: ["onboarding"], - queryKeys: [["onboarding", "status"]], - paths: ["/onboarding"], - }, - - // ===== proctoring 模块 ===== - "proctoring.recordEvent": { - tags: ["proctoring"], - queryKeys: [["proctoring", "events"]], - paths: ["/teacher/exams"], - }, - - // ===== questions 模块 ===== - "questions.create": { - tags: ["questions"], - queryKeys: [["questions", "list"]], - paths: ["/teacher/questions", "/admin/questions"], - }, - "questions.update": { - tags: ["questions"], - queryKeys: [["questions", "detail"], ["questions", "list"]], - paths: ["/teacher/questions", "/admin/questions"], - }, - "questions.delete": { - tags: ["questions"], - queryKeys: [["questions", "list"]], - paths: ["/teacher/questions", "/admin/questions"], - }, - "questions.deleteBatch": { - tags: ["questions"], - queryKeys: [["questions", "list"]], - paths: ["/teacher/questions", "/admin/questions"], - }, - "questions.import": { - tags: ["questions"], - queryKeys: [["questions", "list"]], - paths: ["/teacher/questions", "/admin/questions"], - }, - - // ===== rbac 模块 ===== - "rbac.role.create": { - tags: ["rbac"], - queryKeys: [["rbac", "roles"]], - paths: ["/admin/roles"], - }, - "rbac.role.update": { - tags: ["rbac"], - queryKeys: [["rbac", "roles"]], - paths: ["/admin/roles"], - }, - "rbac.role.delete": { - tags: ["rbac"], - queryKeys: [["rbac", "roles"]], - paths: ["/admin/roles"], - }, - "rbac.role.toggleEnabled": { - tags: ["rbac"], - queryKeys: [["rbac", "roles"]], - paths: ["/admin/roles"], - }, - "rbac.role.setPermissions": { - tags: ["rbac"], - queryKeys: [["rbac", "roles"]], - paths: ["/admin/roles"], - }, - "rbac.assignUserRoles": { - tags: ["rbac"], - queryKeys: [["rbac", "userRoles"]], - paths: ["/admin/users"], - }, - - // ===== scheduling 模块 ===== - "scheduling.saveRules": { - tags: ["scheduling"], - queryKeys: [["scheduling", "rules"]], - paths: ["/admin/scheduling/rules"], - }, - "scheduling.autoSchedule": { - tags: ["scheduling"], - queryKeys: [["scheduling", "list"]], - paths: ["/admin/scheduling/auto", "/teacher/classes/schedule"], - }, - "scheduling.applyAutoSchedule": { - tags: ["scheduling"], - queryKeys: [["scheduling", "changes"]], - paths: ["/teacher/schedule-changes", "/admin/scheduling/changes"], - }, - "scheduling.requestChange": { - tags: ["scheduling"], - queryKeys: [["scheduling", "changes"]], - paths: ["/admin/scheduling/changes", "/teacher/schedule-changes"], - }, - "scheduling.approveChange": { - tags: ["scheduling"], - queryKeys: [["scheduling", "changes"]], - paths: ["/admin/scheduling/changes", "/teacher/schedule-changes"], - }, - "scheduling.rejectChange": { - tags: ["scheduling"], - queryKeys: [["scheduling", "changes"]], - paths: ["/admin/scheduling/changes", "/teacher/schedule-changes"], - }, - - // ===== school 模块 ===== - "school.department.create": { - tags: ["school"], - queryKeys: [["school", "departments"]], - paths: ["/admin/school/departments"], - }, - "school.department.update": { - tags: ["school"], - queryKeys: [["school", "departments"]], - paths: ["/admin/school/departments"], - }, - "school.department.delete": { - tags: ["school"], - queryKeys: [["school", "departments"]], - paths: ["/admin/school/departments"], - }, - "school.academicYear.create": { - tags: ["school"], - queryKeys: [["school", "academicYears"]], - paths: ["/admin/school/academic-year"], - }, - "school.academicYear.update": { - tags: ["school"], - queryKeys: [["school", "academicYears"]], - paths: ["/admin/school/academic-year"], - }, - "school.academicYear.delete": { - tags: ["school"], - queryKeys: [["school", "academicYears"]], - paths: ["/admin/school/academic-year"], - }, - "school.school.create": { - tags: ["school"], - queryKeys: [["school", "schools"]], - paths: ["/admin/school/schools", "/admin/school/grades"], - }, - "school.school.update": { - tags: ["school"], - queryKeys: [["school", "schools"]], - paths: ["/admin/school/schools", "/admin/school/grades"], - }, - "school.school.delete": { - tags: ["school"], - queryKeys: [["school", "schools"]], - paths: ["/admin/school/schools", "/admin/school/grades"], - }, - "school.grade.create": { - tags: ["school"], - queryKeys: [["school", "grades"]], - paths: ["/admin/school/grades"], - }, - "school.grade.update": { - tags: ["school"], - queryKeys: [["school", "grades"]], - paths: ["/admin/school/grades"], - }, - "school.grade.delete": { - tags: ["school"], - queryKeys: [["school", "grades"]], - paths: ["/admin/school/grades"], - }, - "school.grade.promote": { - tags: ["school"], - queryKeys: [["school", "grades"]], - paths: ["/admin/school/grades"], - }, - - // ===== settings 模块 ===== - "settings.aiProvider.upsert": { - tags: ["settings"], - queryKeys: [["settings", "aiProviders"]], - paths: ["/admin/ai-settings", "/settings"], - }, - "settings.aiProvider.test": { - tags: ["settings"], - queryKeys: [["settings", "aiProviders"]], - paths: ["/admin/ai-settings", "/settings"], - }, - "settings.aiProvider.delete": { - tags: ["settings"], - queryKeys: [["settings", "aiProviders"]], - paths: ["/admin/ai-settings", "/settings"], - }, - "settings.avatar.update": { - tags: ["settings"], - queryKeys: [["settings", "avatar"]], - paths: ["/profile", "/settings"], - }, - "settings.avatar.remove": { - tags: ["settings"], - queryKeys: [["settings", "avatar"]], - paths: ["/profile", "/settings"], - }, - "settings.brand.save": { - tags: ["settings"], - queryKeys: [["settings", "brand"]], - paths: ["/admin/settings", "/(auth)/login", "/(auth)/register"], - }, - "settings.password.change": { - tags: ["settings"], - queryKeys: [["settings", "password"]], - paths: ["/settings"], - }, - "settings.security.setup2FA": { - tags: ["settings"], - queryKeys: [["settings", "security"]], - paths: ["/settings"], - }, - "settings.security.verify2FA": { - tags: ["settings"], - queryKeys: [["settings", "security"]], - paths: ["/settings"], - }, - "settings.security.disable2FA": { - tags: ["settings"], - queryKeys: [["settings", "security"]], - paths: ["/settings"], - }, - "settings.security.regenerateBackupCodes": { - tags: ["settings"], - queryKeys: [["settings", "security"]], - paths: ["/settings"], - }, - "settings.security.revokeSessions": { - tags: ["settings"], - queryKeys: [["settings", "security"]], - paths: ["/settings"], - }, - "settings.profile.update": { - tags: ["settings"], - queryKeys: [["settings", "profile"]], - paths: ["/settings"], - }, - "settings.notifications.updatePreferences": { - tags: ["settings"], - queryKeys: [["settings", "notificationPreferences"]], - paths: ["/settings"], - }, - "settings.systemSettings.save": { - tags: ["settings"], - queryKeys: [["settings", "system"]], - paths: ["/admin/settings"], - }, - - // ===== standards 模块 ===== - "standards.create": { - tags: ["standards"], - queryKeys: [["standards", "list"]], - paths: ["/admin/standards"], - }, - "standards.update": { - tags: ["standards"], - queryKeys: [["standards", "detail"], ["standards", "list"]], - paths: ["/admin/standards"], - }, - "standards.deactivate": { - tags: ["standards"], - queryKeys: [["standards", "list"]], - paths: ["/admin/standards"], - }, - "standards.linkPlan": { - tags: ["standards"], - queryKeys: [["standards", "planLinks"]], - paths: ["/teacher/lesson-plans"], - }, - "standards.unlinkPlan": { - tags: ["standards"], - queryKeys: [["standards", "planLinks"]], - paths: ["/teacher/lesson-plans"], - }, - - // ===== textbooks 模块 ===== - "textbooks.create": { - tags: ["textbooks"], - queryKeys: [["textbooks", "list"]], - paths: ["/teacher/textbooks"], - }, - "textbooks.update": { - tags: ["textbooks"], - queryKeys: [["textbooks", "detail"], ["textbooks", "list"]], - paths: ["/teacher/textbooks"], - }, - "textbooks.delete": { - tags: ["textbooks"], - queryKeys: [["textbooks", "list"]], - paths: ["/teacher/textbooks"], - }, - "textbooks.chapter.create": { - tags: ["textbooks"], - queryKeys: [["textbooks", "chapters"]], - paths: ["/teacher/textbooks"], - }, - "textbooks.chapter.update": { - tags: ["textbooks"], - queryKeys: [["textbooks", "chapters"]], - paths: ["/teacher/textbooks"], - }, - "textbooks.chapter.delete": { - tags: ["textbooks"], - queryKeys: [["textbooks", "chapters"]], - paths: ["/teacher/textbooks"], - }, - "textbooks.chapter.reorder": { - tags: ["textbooks"], - queryKeys: [["textbooks", "chapters"]], - paths: ["/teacher/textbooks"], - }, - "textbooks.knowledgePoint.create": { - tags: ["textbooks"], - queryKeys: [["textbooks", "knowledgePoints"]], - paths: ["/teacher/textbooks"], - }, - "textbooks.knowledgePoint.update": { - tags: ["textbooks"], - queryKeys: [["textbooks", "knowledgePoints"]], - paths: ["/teacher/textbooks"], - }, - "textbooks.knowledgePoint.delete": { - tags: ["textbooks"], - queryKeys: [["textbooks", "knowledgePoints"]], - paths: ["/teacher/textbooks"], - }, - "textbooks.prerequisite.create": { - tags: ["textbooks"], - queryKeys: [["textbooks", "prerequisites"]], - paths: ["/teacher/textbooks"], - }, - "textbooks.prerequisite.delete": { - tags: ["textbooks"], - queryKeys: [["textbooks", "prerequisites"]], - paths: ["/teacher/textbooks"], - }, - - // ===== users 模块 ===== - "users.update": { - tags: ["users"], - queryKeys: [["users", "detail"]], - paths: ["/profile", "/settings"], - }, - "users.import": { - tags: ["users"], - queryKeys: [["users", "list"]], - paths: ["/admin/users/import"], - }, - "users.delete": { - tags: ["users"], - queryKeys: [["users", "list"]], - paths: ["/admin/users"], - }, + ...CLASSES_INVALIDATION_RULES, + ...ACADEMIC_INVALIDATION_RULES, + ...GRADES_INVALIDATION_RULES, + ...LESSON_PREPARATION_INVALIDATION_RULES, + ...MESSAGING_INVALIDATION_RULES, + ...ADMIN_INVALIDATION_RULES, } as const satisfies Record export type InvalidationActionId = keyof typeof INVALIDATION_MAP