refactor: split 5 oversized files into domain-specific subfiles (0 violations)

This commit is contained in:
SpecialX
2026-07-07 20:13:22 +08:00
parent 7f26bb8f9c
commit 031e8a8175
55 changed files with 6748 additions and 6092 deletions

Binary file not shown.

View File

@@ -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<ActionState<{ updated: number }>> {
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<ActionState<string>> {
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<ActionState<string>> {
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" }
}
}

View File

@@ -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<string> | null,
formData: FormData
): Promise<ActionState<string>> {
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<ActionState<string>> {
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<ActionState<string>> {
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<ActionState<{ items: Message[]; total: number; page: number; pageSize: number; totalPages: number }>> {
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<ActionState<Message[]>> {
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<ActionState<RecipientOption[]>> {
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<ActionState<number>> {
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<ActionState<string>> {
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 Action2 分钟窗口内仅发送方可撤回)
// ---------------------------------------------------------------------------
export async function recallMessageAction(
messageId: string
): Promise<ActionState<string>> {
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<ActionState<MessageAttachment[]>> {
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" }
}
}

View File

@@ -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<ActionState<{ recipientCount: number }>> {
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" }
}
}

View File

@@ -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> | null,
formData: FormData
): Promise<ActionState<null>> {
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<ActionState<boolean>> {
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> | null,
formData: FormData
): Promise<ActionState<null>> {
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<ActionState<null>> {
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<ActionState<UserBlock[]>> {
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" }
}
}

View File

@@ -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<ActionState<MessageDraft[]>> {
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<string> | null,
formData: FormData
): Promise<ActionState<string>> {
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<ActionState<string>> {
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<ActionState<MessageTemplate[]>> {
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<string> | null,
formData: FormData
): Promise<ActionState<string>> {
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<ActionState<string>> {
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" }
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<Map<string, QuestionKnowledgePoint[]>> => {
const result = new Map<string, QuestionKnowledgePoint[]>()
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<Map<string, QuestionContentForErrorCollection>> => {
const result = new Map<string, QuestionContentForErrorCollection>()
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<questionId, type>,未找到的 ID 不会出现在 Map 中。
*/
export const getQuestionTypeMapByIdsRaw = async (
questionIds: string[],
): Promise<Map<string, string>> => {
const result = new Map<string, string>()
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<knowledgePointId, questionCount>
*/
export const getQuestionCountByKpIdsRaw = async (
knowledgePointIds: string[]
): Promise<Map<string, number>> => {
const result = new Map<string, number>()
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"],
})

View File

@@ -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<QuestionExportItem[]> {
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<string[]> {
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
})
}

View File

@@ -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<KnowledgePointOption[]> {
// 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<TextbookOption[]> {
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<ChapterOption[]> {
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"],
})

View File

@@ -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<Parameters<typeof db.transaction>[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 与 metapage/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<QuestionsDashboardStats> => {
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<string> {
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<string> {
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<void> {
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<string> = new Set(),
): Promise<string[]> {
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<string> = new Set(),
): Promise<void> {
// 收集所有后代 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<void> {
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<number> {
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<string>()
const allIds = await collectDescendantQuestionIds(tx, targetIds, visited)
if (allIds.length > 0) {
await tx.delete(questions).where(inArray(questions.id, allIds))
}
return targetIds.length
})
}

View File

@@ -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<Parameters<typeof db.transaction>[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 与 metapage/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<QuestionsDashboardStats> => {
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<string> {
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<string> {
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<void> {
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<string> = new Set(),
): Promise<string[]> {
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<string> = new Set(),
): Promise<void> {
// 收集所有后代 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<void> {
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<number> {
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<string>()
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<KnowledgePointOption[]> {
// 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<TextbookOption[]> {
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<ChapterOption[]> {
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<Map<string, QuestionKnowledgePoint[]>> => {
const result = new Map<string, QuestionKnowledgePoint[]>()
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<Map<string, QuestionContentForErrorCollection>> => {
const result = new Map<string, QuestionContentForErrorCollection>()
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<questionId, type>,未找到的 ID 不会出现在 Map 中。
*/
export const getQuestionTypeMapByIdsRaw = async (
questionIds: string[],
): Promise<Map<string, string>> => {
const result = new Map<string, string>()
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<QuestionExportItem[]> {
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<string[]> {
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<knowledgePointId, questionCount>
*/
export const getQuestionCountByKpIdsRaw = async (
knowledgePointIds: string[]
): Promise<Map<string, number>> => {
const result = new Map<string, number>()
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";

View File

@@ -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<Chapter[]> => {
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<Chapter> {
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<Chapter> {
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<void> {
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<string, string[]>()
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<string>()
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<void> {
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 循环,单事务保证原子性。
// 仅对需要变更的行发起 UPDATEorder 变化或 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()
})
)
})
}

View File

@@ -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<KnowledgePointOption[]> => {
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<Map<string, string>> => {
const result = new Map<string, string>()
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<Map<string, string>> => {
const result = new Map<string, string>()
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<Map<string, string>> => {
const result = new Map<string, string>()
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"],
})

View File

@@ -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<KnowledgePoint[]> => {
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<KnowledgePoint[]> => {
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<void> {
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<void> {
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<void> {
await db.delete(knowledgePoints).where(eq(knowledgePoints.id, id))
}

View File

@@ -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<void> {
await db.insert(knowledgePointPrerequisites).values({
knowledgePointId: data.knowledgePointId,
prerequisiteKpId: data.prerequisiteKpId,
})
}
/**
* 删除知识点前置依赖关系(按 knowledgePointId + prerequisiteKpId 联合主键)。
*
* @param data - 待删除的前置依赖输入knowledgePointId/prerequisiteKpId
*/
export async function deletePrerequisite(data: DeletePrerequisiteInput): Promise<void> {
await db
.delete(knowledgePointPrerequisites)
.where(and(
eq(knowledgePointPrerequisites.knowledgePointId, data.knowledgePointId),
eq(knowledgePointPrerequisites.prerequisiteKpId, data.prerequisiteKpId),
))
}
/**
* 获取教材下所有知识点的前置依赖边列表。
* 用于循环检测。
*/
export async function getPrerequisiteEdgesForTextbookRaw(
textbookId: string,
): Promise<Array<[string, string]>> {
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"],
})

View File

@@ -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<Textbook[]> => {
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<number>`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<Textbook | undefined> => {
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<number>`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<Textbook> {
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<Textbook> {
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<void> {
await db.delete(textbooks).where(eq(textbooks.id, id))
}
export type TextbooksDashboardStats = {
textbookCount: number
chapterCount: number
}
/**
* 获取教材仪表盘统计(教材总数与章节总数)。
*
* @returns 包含 textbookCount / chapterCount 的统计对象
*/
export const getTextbooksDashboardStatsRaw = async (): Promise<TextbooksDashboardStats> => {
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<Textbook[]> => {
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<number>`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"],
})

View File

@@ -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<boolean> {
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<boolean> {
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"],
})

View File

@@ -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<Textbook[]> => {
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<number>`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<Textbook | undefined> => {
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<number>`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<Chapter[]> => {
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<Textbook> {
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<Textbook> {
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<void> {
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<Chapter> {
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<Chapter> {
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<void> {
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<string, string[]>()
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<string>()
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<KnowledgePoint[]> => {
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<KnowledgePoint[]> => {
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<void> {
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<void> {
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<void> {
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<void> {
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 循环,单事务保证原子性。
// 仅对需要变更的行发起 UPDATEorder 变化或 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<TextbooksDashboardStats> => {
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<boolean> {
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<boolean> {
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<Textbook[]> => {
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<number>`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<KnowledgePointOption[]> => {
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<void> {
await db.insert(knowledgePointPrerequisites).values({
knowledgePointId: data.knowledgePointId,
prerequisiteKpId: data.prerequisiteKpId,
})
}
/**
* 删除知识点前置依赖关系(按 knowledgePointId + prerequisiteKpId 联合主键)。
*
* @param data - 待删除的前置依赖输入knowledgePointId/prerequisiteKpId
*/
export async function deletePrerequisite(data: DeletePrerequisiteInput): Promise<void> {
await db
.delete(knowledgePointPrerequisites)
.where(and(
eq(knowledgePointPrerequisites.knowledgePointId, data.knowledgePointId),
eq(knowledgePointPrerequisites.prerequisiteKpId, data.prerequisiteKpId),
))
}
/**
* 获取教材下所有知识点的前置依赖边列表。
* 用于循环检测。
*/
export async function getPrerequisiteEdgesForTextbookRaw(
textbookId: string,
): Promise<Array<[string, string]>> {
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<Map<string, string>> => {
const result = new Map<string, string>()
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<Map<string, string>> => {
const result = new Map<string, string>()
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<Map<string, string>> => {
const result = new Map<string, string>()
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"

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -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/rejectedapproved/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(),
/** 审批人 IDpending/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"),
}));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, string>, timestamp: number } */
content: json("content").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
/**
* P3-6 协同录入锁机制:
* - lockedBy: 当前持有编辑锁的教师 IDnull 表示无锁)
* - 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(),
/** 复核教师 IDpending 状态为 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
),
}));

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: 群组消息 IDfan-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),
}));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<AdapterAccountType>().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"),
}));

View File

@@ -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<string, InvalidationRule>

View File

@@ -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<string, InvalidationRule>

View File

@@ -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<string, InvalidationRule>

View File

@@ -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<string, InvalidationRule>

View File

@@ -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<string, InvalidationRule>

View File

@@ -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<string, InvalidationRule>

File diff suppressed because it is too large Load Diff