refactor: split 5 oversized files into domain-specific subfiles (0 violations)
This commit is contained in:
Binary file not shown.
105
src/modules/messaging/actions-bulk.ts
Normal file
105
src/modules/messaging/actions-bulk.ts
Normal 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" }
|
||||
}
|
||||
}
|
||||
375
src/modules/messaging/actions-core.ts
Normal file
375
src/modules/messaging/actions-core.ts
Normal 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 Action(2 分钟窗口内仅发送方可撤回)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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" }
|
||||
}
|
||||
}
|
||||
114
src/modules/messaging/actions-group.ts
Normal file
114
src/modules/messaging/actions-group.ts
Normal 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" }
|
||||
}
|
||||
}
|
||||
201
src/modules/messaging/actions-reports.ts
Normal file
201
src/modules/messaging/actions-reports.ts
Normal 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" }
|
||||
}
|
||||
}
|
||||
220
src/modules/messaging/actions-templates.ts
Normal file
220
src/modules/messaging/actions-templates.ts
Normal 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
170
src/modules/questions/data-access-cross-module.ts
Normal file
170
src/modules/questions/data-access-cross-module.ts
Normal 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"],
|
||||
})
|
||||
121
src/modules/questions/data-access-import-export.ts
Normal file
121
src/modules/questions/data-access-import-export.ts
Normal 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
|
||||
})
|
||||
}
|
||||
88
src/modules/questions/data-access-options.ts
Normal file
88
src/modules/questions/data-access-options.ts
Normal 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"],
|
||||
})
|
||||
469
src/modules/questions/data-access-questions.ts
Normal file
469
src/modules/questions/data-access-questions.ts
Normal 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 与 meta(page/pageSize/total/totalPages)的对象
|
||||
*/
|
||||
export const getQuestionsRaw = async ({
|
||||
q,
|
||||
page = 1,
|
||||
pageSize = 50,
|
||||
ids,
|
||||
knowledgePointId,
|
||||
textbookId,
|
||||
chapterId,
|
||||
type,
|
||||
difficulty,
|
||||
}: GetQuestionsParams = {}) => {
|
||||
const safePage = typeof page === "number" && page >= 1 ? page : 1
|
||||
const safePageSize = typeof pageSize === "number" && pageSize > 0 ? pageSize : 50
|
||||
const offset = (safePage - 1) * safePageSize;
|
||||
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
if (ids && ids.length > 0) {
|
||||
conditions.push(inArray(questions.id, ids));
|
||||
}
|
||||
|
||||
if (q && q.trim().length > 0) {
|
||||
// F-02: 使用 FULLTEXT 索引(content_text 生成列)+ MATCH AGAINST 替代 LIKE '%xxx%' 全表扫描
|
||||
const booleanQuery = toBooleanModeQuery(q.trim())
|
||||
if (booleanQuery) {
|
||||
conditions.push(
|
||||
sql`MATCH(${questions.contentText}) AGAINST(${booleanQuery} IN BOOLEAN MODE)`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (type) {
|
||||
conditions.push(eq(questions.type, type));
|
||||
}
|
||||
|
||||
if (difficulty) {
|
||||
conditions.push(eq(questions.difficulty, difficulty));
|
||||
}
|
||||
|
||||
// 级联筛选:通过 textbooks data-access 获取知识点 ID 集合,
|
||||
// 再按知识点过滤题目。避免直接查询 textbooks/chapters 表。
|
||||
if (knowledgePointId) {
|
||||
conditions.push(
|
||||
inArray(
|
||||
questions.id,
|
||||
db
|
||||
.select({ questionId: questionsToKnowledgePoints.questionId })
|
||||
.from(questionsToKnowledgePoints)
|
||||
.where(eq(questionsToKnowledgePoints.knowledgePointId, knowledgePointId))
|
||||
)
|
||||
);
|
||||
} else if (chapterId) {
|
||||
// 按章节筛选:获取该章节下所有知识点 ID
|
||||
const kps = await getKnowledgePointsByChapterIdFromTextbooks(chapterId)
|
||||
const kpIds = kps.map((kp) => kp.id)
|
||||
if (kpIds.length > 0) {
|
||||
conditions.push(
|
||||
inArray(
|
||||
questions.id,
|
||||
db
|
||||
.select({ questionId: questionsToKnowledgePoints.questionId })
|
||||
.from(questionsToKnowledgePoints)
|
||||
.where(inArray(questionsToKnowledgePoints.knowledgePointId, kpIds))
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// 章节下无知识点,返回空结果
|
||||
conditions.push(sql`1 = 0`)
|
||||
}
|
||||
} else if (textbookId) {
|
||||
// 按教材筛选:获取该教材下所有知识点 ID
|
||||
const kps = await getKnowledgePointsByTextbookIdFromTextbooks(textbookId)
|
||||
const kpIds = kps.map((kp) => kp.id)
|
||||
if (kpIds.length > 0) {
|
||||
conditions.push(
|
||||
inArray(
|
||||
questions.id,
|
||||
db
|
||||
.select({ questionId: questionsToKnowledgePoints.questionId })
|
||||
.from(questionsToKnowledgePoints)
|
||||
.where(inArray(questionsToKnowledgePoints.knowledgePointId, kpIds))
|
||||
)
|
||||
);
|
||||
} else {
|
||||
conditions.push(sql`1 = 0`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!ids || ids.length === 0) {
|
||||
conditions.push(sql`${questions.parentId} IS NULL`)
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const [totalResult] = await db
|
||||
.select({ count: count() })
|
||||
.from(questions)
|
||||
.where(whereClause);
|
||||
|
||||
const total = Number(totalResult?.count ?? 0);
|
||||
|
||||
const rows = await db.query.questions.findMany({
|
||||
where: whereClause,
|
||||
limit: safePageSize,
|
||||
offset: offset,
|
||||
orderBy: [desc(questions.createdAt)],
|
||||
with: {
|
||||
knowledgePoints: {
|
||||
with: {
|
||||
knowledgePoint: true,
|
||||
},
|
||||
},
|
||||
author: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
children: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
data: rows.map((row) => {
|
||||
const knowledgePoints =
|
||||
row.knowledgePoints?.map((rel) => rel.knowledgePoint) ?? [];
|
||||
|
||||
const author = row.author
|
||||
? {
|
||||
id: row.author.id,
|
||||
name: row.author.name,
|
||||
image: row.author.image,
|
||||
}
|
||||
: null;
|
||||
|
||||
const mapped: Question = {
|
||||
id: row.id,
|
||||
content: row.content,
|
||||
type: row.type,
|
||||
difficulty: row.difficulty ?? 1,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
author,
|
||||
knowledgePoints: knowledgePoints.map((kp) => ({ id: kp.id, name: kp.name })),
|
||||
childrenCount: row.children?.length ?? 0,
|
||||
};
|
||||
|
||||
return mapped;
|
||||
}),
|
||||
meta: {
|
||||
page: safePage,
|
||||
pageSize: safePageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / safePageSize),
|
||||
},
|
||||
};
|
||||
};
|
||||
export const getQuestions = cacheFn(getQuestionsRaw, {
|
||||
tags: ["questions"],
|
||||
ttl: 300,
|
||||
keyParts: ["questions", "getQuestions"],
|
||||
})
|
||||
|
||||
export type QuestionsDashboardStats = {
|
||||
questionCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取题目仪表盘统计(题目总数)。
|
||||
*
|
||||
* @returns 包含 questionCount 的统计对象
|
||||
*/
|
||||
export const getQuestionsDashboardStatsRaw = async (): Promise<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
|
||||
})
|
||||
}
|
||||
@@ -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 与 meta(page/pageSize/total/totalPages)的对象
|
||||
*/
|
||||
export const getQuestionsRaw = async ({
|
||||
q,
|
||||
page = 1,
|
||||
pageSize = 50,
|
||||
ids,
|
||||
knowledgePointId,
|
||||
textbookId,
|
||||
chapterId,
|
||||
type,
|
||||
difficulty,
|
||||
}: GetQuestionsParams = {}) => {
|
||||
const safePage = typeof page === "number" && page >= 1 ? page : 1
|
||||
const safePageSize = typeof pageSize === "number" && pageSize > 0 ? pageSize : 50
|
||||
const offset = (safePage - 1) * safePageSize;
|
||||
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
if (ids && ids.length > 0) {
|
||||
conditions.push(inArray(questions.id, ids));
|
||||
}
|
||||
|
||||
if (q && q.trim().length > 0) {
|
||||
// F-02: 使用 FULLTEXT 索引(content_text 生成列)+ MATCH AGAINST 替代 LIKE '%xxx%' 全表扫描
|
||||
const booleanQuery = toBooleanModeQuery(q.trim())
|
||||
if (booleanQuery) {
|
||||
conditions.push(
|
||||
sql`MATCH(${questions.contentText}) AGAINST(${booleanQuery} IN BOOLEAN MODE)`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (type) {
|
||||
conditions.push(eq(questions.type, type));
|
||||
}
|
||||
|
||||
if (difficulty) {
|
||||
conditions.push(eq(questions.difficulty, difficulty));
|
||||
}
|
||||
|
||||
// 级联筛选:通过 textbooks data-access 获取知识点 ID 集合,
|
||||
// 再按知识点过滤题目。避免直接查询 textbooks/chapters 表。
|
||||
if (knowledgePointId) {
|
||||
conditions.push(
|
||||
inArray(
|
||||
questions.id,
|
||||
db
|
||||
.select({ questionId: questionsToKnowledgePoints.questionId })
|
||||
.from(questionsToKnowledgePoints)
|
||||
.where(eq(questionsToKnowledgePoints.knowledgePointId, knowledgePointId))
|
||||
)
|
||||
);
|
||||
} else if (chapterId) {
|
||||
// 按章节筛选:获取该章节下所有知识点 ID
|
||||
const kps = await getKnowledgePointsByChapterIdFromTextbooks(chapterId)
|
||||
const kpIds = kps.map((kp) => kp.id)
|
||||
if (kpIds.length > 0) {
|
||||
conditions.push(
|
||||
inArray(
|
||||
questions.id,
|
||||
db
|
||||
.select({ questionId: questionsToKnowledgePoints.questionId })
|
||||
.from(questionsToKnowledgePoints)
|
||||
.where(inArray(questionsToKnowledgePoints.knowledgePointId, kpIds))
|
||||
)
|
||||
);
|
||||
} else {
|
||||
// 章节下无知识点,返回空结果
|
||||
conditions.push(sql`1 = 0`)
|
||||
}
|
||||
} else if (textbookId) {
|
||||
// 按教材筛选:获取该教材下所有知识点 ID
|
||||
const kps = await getKnowledgePointsByTextbookIdFromTextbooks(textbookId)
|
||||
const kpIds = kps.map((kp) => kp.id)
|
||||
if (kpIds.length > 0) {
|
||||
conditions.push(
|
||||
inArray(
|
||||
questions.id,
|
||||
db
|
||||
.select({ questionId: questionsToKnowledgePoints.questionId })
|
||||
.from(questionsToKnowledgePoints)
|
||||
.where(inArray(questionsToKnowledgePoints.knowledgePointId, kpIds))
|
||||
)
|
||||
);
|
||||
} else {
|
||||
conditions.push(sql`1 = 0`)
|
||||
}
|
||||
}
|
||||
|
||||
if (!ids || ids.length === 0) {
|
||||
conditions.push(sql`${questions.parentId} IS NULL`)
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const [totalResult] = await db
|
||||
.select({ count: count() })
|
||||
.from(questions)
|
||||
.where(whereClause);
|
||||
|
||||
const total = Number(totalResult?.count ?? 0);
|
||||
|
||||
const rows = await db.query.questions.findMany({
|
||||
where: whereClause,
|
||||
limit: safePageSize,
|
||||
offset: offset,
|
||||
orderBy: [desc(questions.createdAt)],
|
||||
with: {
|
||||
knowledgePoints: {
|
||||
with: {
|
||||
knowledgePoint: true,
|
||||
},
|
||||
},
|
||||
author: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
children: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
data: rows.map((row) => {
|
||||
const knowledgePoints =
|
||||
row.knowledgePoints?.map((rel) => rel.knowledgePoint) ?? [];
|
||||
|
||||
const author = row.author
|
||||
? {
|
||||
id: row.author.id,
|
||||
name: row.author.name,
|
||||
image: row.author.image,
|
||||
}
|
||||
: null;
|
||||
|
||||
const mapped: Question = {
|
||||
id: row.id,
|
||||
content: row.content,
|
||||
type: row.type,
|
||||
difficulty: row.difficulty ?? 1,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
author,
|
||||
knowledgePoints: knowledgePoints.map((kp) => ({ id: kp.id, name: kp.name })),
|
||||
childrenCount: row.children?.length ?? 0,
|
||||
};
|
||||
|
||||
return mapped;
|
||||
}),
|
||||
meta: {
|
||||
page: safePage,
|
||||
pageSize: safePageSize,
|
||||
total,
|
||||
totalPages: Math.ceil(total / safePageSize),
|
||||
},
|
||||
};
|
||||
};
|
||||
export const getQuestions = cacheFn(getQuestionsRaw, {
|
||||
tags: ["questions"],
|
||||
ttl: 300,
|
||||
keyParts: ["questions", "getQuestions"],
|
||||
})
|
||||
|
||||
export type QuestionsDashboardStats = {
|
||||
questionCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取题目仪表盘统计(题目总数)。
|
||||
*
|
||||
* @returns 包含 questionCount 的统计对象
|
||||
*/
|
||||
export const getQuestionsDashboardStatsRaw = async (): Promise<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";
|
||||
|
||||
244
src/modules/textbooks/data-access-chapters.ts
Normal file
244
src/modules/textbooks/data-access-chapters.ts
Normal 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 循环,单事务保证原子性。
|
||||
// 仅对需要变更的行发起 UPDATE(order 变化或 parentId 变化)。
|
||||
await Promise.all(
|
||||
currentSiblings.map((ch, i) => {
|
||||
if (ch.order !== i || (ch.id === chapterId && ch.parentId !== parentId)) {
|
||||
return tx
|
||||
.update(chapters)
|
||||
.set({
|
||||
order: i,
|
||||
parentId: ch.id === chapterId ? parentId : ch.parentId
|
||||
})
|
||||
.where(eq(chapters.id, ch.id))
|
||||
}
|
||||
return Promise.resolve()
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
152
src/modules/textbooks/data-access-cross-module.ts
Normal file
152
src/modules/textbooks/data-access-cross-module.ts
Normal 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"],
|
||||
})
|
||||
132
src/modules/textbooks/data-access-knowledge-points.ts
Normal file
132
src/modules/textbooks/data-access-knowledge-points.ts
Normal 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))
|
||||
}
|
||||
64
src/modules/textbooks/data-access-prerequisites.ts
Normal file
64
src/modules/textbooks/data-access-prerequisites.ts
Normal 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"],
|
||||
})
|
||||
291
src/modules/textbooks/data-access-textbooks.ts
Normal file
291
src/modules/textbooks/data-access-textbooks.ts
Normal 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"],
|
||||
})
|
||||
62
src/modules/textbooks/data-access-verification.ts
Normal file
62
src/modules/textbooks/data-access-verification.ts
Normal 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"],
|
||||
})
|
||||
@@ -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 循环,单事务保证原子性。
|
||||
// 仅对需要变更的行发起 UPDATE(order 变化或 parentId 变化)。
|
||||
await Promise.all(
|
||||
currentSiblings.map((ch, i) => {
|
||||
if (ch.order !== i || (ch.id === chapterId && ch.parentId !== parentId)) {
|
||||
return tx
|
||||
.update(chapters)
|
||||
.set({
|
||||
order: i,
|
||||
parentId: ch.id === chapterId ? parentId : ch.parentId
|
||||
})
|
||||
.where(eq(chapters.id, ch.id))
|
||||
}
|
||||
return Promise.resolve()
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export type TextbooksDashboardStats = {
|
||||
textbookCount: number
|
||||
chapterCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取教材仪表盘统计(教材总数与章节总数)。
|
||||
*
|
||||
* @returns 包含 textbookCount / chapterCount 的统计对象
|
||||
*/
|
||||
export const getTextbooksDashboardStatsRaw = async (): Promise<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
6
src/shared/db/schema/_helpers.ts
Normal file
6
src/shared/db/schema/_helpers.ts
Normal 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());
|
||||
51
src/shared/db/schema/academic.ts
Normal file
51
src/shared/db/schema/academic.ts
Normal 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),
|
||||
}));
|
||||
35
src/shared/db/schema/ai-providers.ts
Normal file
35
src/shared/db/schema/ai-providers.ts
Normal 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),
|
||||
}));
|
||||
56
src/shared/db/schema/announcements.ts
Normal file
56
src/shared/db/schema/announcements.ts
Normal 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),
|
||||
}));
|
||||
177
src/shared/db/schema/attendance.ts
Normal file
177
src/shared/db/schema/attendance.ts
Normal 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/rejected;approved/pending 可被 requester 取消为 cancelled。
|
||||
* - pending: 待审批
|
||||
* - approved: 已批准(自动同步考勤为 excused)
|
||||
* - rejected: 已拒绝
|
||||
* - cancelled: 已撤销(申请人在审批前主动撤销)
|
||||
*/
|
||||
export const leaveStatusEnum = mysqlEnum("leave_status", [
|
||||
"pending",
|
||||
"approved",
|
||||
"rejected",
|
||||
"cancelled",
|
||||
]);
|
||||
|
||||
export const leaveRequests = mysqlTable("leave_requests", {
|
||||
id: id("id").primaryKey(),
|
||||
/** 请假学生 ID */
|
||||
studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
/** 提交人 ID(家长代子女提交时为家长 userId;学生本人提交时等于 studentId) */
|
||||
requesterId: varchar("requester_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
/** 学生所属班级 ID(提交时快照,便于按班级筛选审批) */
|
||||
classId: varchar("class_id", { length: 128 }).notNull().references(() => classes.id, { onDelete: "cascade" }),
|
||||
leaveType: leaveTypeEnum.notNull(),
|
||||
/** 起始日期(含) */
|
||||
startDate: date("start_date").notNull(),
|
||||
/** 结束日期(含) */
|
||||
endDate: date("end_date").notNull(),
|
||||
/** 请假事由 */
|
||||
reason: text("reason").notNull(),
|
||||
status: leaveStatusEnum.default("pending").notNull(),
|
||||
/** 审批人 ID(pending/cancelled 状态为 null) */
|
||||
reviewerId: varchar("reviewer_id", { length: 128 }).references(() => users.id, { onDelete: "set null" }),
|
||||
/** 审批意见(拒绝时必填,批准时可选) */
|
||||
reviewComment: text("review_comment"),
|
||||
reviewedAt: timestamp("reviewed_at"),
|
||||
/** 是否已同步考勤记录(approved 后异步写入 attendance_records,置为 true) */
|
||||
attendanceSynced: boolean("attendance_synced").default(false).notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
|
||||
}, (table) => ({
|
||||
studentIdx: index("leave_requests_student_idx").on(table.studentId),
|
||||
requesterIdx: index("leave_requests_requester_idx").on(table.requesterId),
|
||||
classIdx: index("leave_requests_class_idx").on(table.classId),
|
||||
statusIdx: index("leave_requests_status_idx").on(table.status),
|
||||
reviewerIdx: index("leave_requests_reviewer_idx").on(table.reviewerId),
|
||||
/** 班级+状态查询索引(教师审批列表常用) */
|
||||
classStatusIdx: index("leave_requests_class_status_idx").on(table.classId, table.status),
|
||||
/** 学生+状态查询索引(家长/学生列表常用) */
|
||||
studentStatusIdx: index("leave_requests_student_status_idx").on(table.studentId, table.status),
|
||||
classFk: foreignKey({
|
||||
columns: [table.classId],
|
||||
foreignColumns: [classes.id],
|
||||
name: "lr_c_fk",
|
||||
}).onDelete("cascade"),
|
||||
studentFk: foreignKey({
|
||||
columns: [table.studentId],
|
||||
foreignColumns: [users.id],
|
||||
name: "lr_s_fk",
|
||||
}).onDelete("cascade"),
|
||||
requesterFk: foreignKey({
|
||||
columns: [table.requesterId],
|
||||
foreignColumns: [users.id],
|
||||
name: "lr_rq_fk",
|
||||
}).onDelete("cascade"),
|
||||
reviewerFk: foreignKey({
|
||||
columns: [table.reviewerId],
|
||||
foreignColumns: [users.id],
|
||||
name: "lr_rv_fk",
|
||||
}).onDelete("set null"),
|
||||
}));
|
||||
116
src/shared/db/schema/audit.ts
Normal file
116
src/shared/db/schema/audit.ts
Normal 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),
|
||||
}));
|
||||
159
src/shared/db/schema/classes.ts
Normal file
159
src/shared/db/schema/classes.ts
Normal 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"),
|
||||
}));
|
||||
90
src/shared/db/schema/course-plans.ts
Normal file
90
src/shared/db/schema/course-plans.ts
Normal 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"),
|
||||
}));
|
||||
73
src/shared/db/schema/elective-courses.ts
Normal file
73
src/shared/db/schema/elective-courses.ts
Normal 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),
|
||||
}));
|
||||
95
src/shared/db/schema/error-book.ts
Normal file
95
src/shared/db/schema/error-book.ts
Normal 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),
|
||||
}));
|
||||
136
src/shared/db/schema/exams.ts
Normal file
136
src/shared/db/schema/exams.ts
Normal 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),
|
||||
}));
|
||||
28
src/shared/db/schema/files.ts
Normal file
28
src/shared/db/schema/files.ts
Normal 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),
|
||||
}));
|
||||
137
src/shared/db/schema/formative-assessment.ts
Normal file
137
src/shared/db/schema/formative-assessment.ts
Normal 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),
|
||||
}));
|
||||
189
src/shared/db/schema/grade-records.ts
Normal file
189
src/shared/db/schema/grade-records.ts
Normal 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: 当前持有编辑锁的教师 ID(null 表示无锁)
|
||||
* - lockedAt: 锁获取时间(用于过期判定,默认 5 分钟自动释放)
|
||||
* - lockToken: 锁定令牌(前端续期时校验,防止伪造他人续期)
|
||||
*/
|
||||
lockedBy: varchar("locked_by", { length: 128 }),
|
||||
lockedAt: timestamp("locked_at"),
|
||||
lockToken: varchar("lock_token", { length: 64 }),
|
||||
}, (table) => ({
|
||||
userDraftIdx: uniqueIndex("gd_user_class_subject_type_idx").on(
|
||||
table.userId,
|
||||
table.classId,
|
||||
table.subjectId,
|
||||
table.type
|
||||
),
|
||||
userUpdatedIdx: index("gd_user_updated_idx").on(table.userId, table.updatedAt),
|
||||
// P3-6: 班级-科目-类型粒度的锁查询索引(用于跨用户协同冲突检测)
|
||||
classSubjectLockIdx: index("gd_class_subject_lock_idx").on(
|
||||
table.classId,
|
||||
table.subjectId,
|
||||
table.type,
|
||||
table.lockedBy
|
||||
),
|
||||
}));
|
||||
|
||||
/**
|
||||
* P3-7 成绩申诉状态机枚举。
|
||||
* - pending: 学生已提交申诉,等待教师复核
|
||||
* - approved: 教师批准申诉(可能调整分数)
|
||||
* - rejected: 教师驳回申诉(维持原分数)
|
||||
* - withdrawn: 学生主动撤回申诉
|
||||
*/
|
||||
export const gradeAppealStatusEnum = mysqlEnum("appeal_status", [
|
||||
"pending",
|
||||
"approved",
|
||||
"rejected",
|
||||
"withdrawn",
|
||||
]);
|
||||
|
||||
/**
|
||||
* 成绩申诉记录 - 学生对成绩提出异议,教师复核后修改并留痕。
|
||||
*
|
||||
* P3-7: 满足合规性要求,成绩修改全程留痕。
|
||||
* 状态机:pending → approved/rejected/withdrawn
|
||||
* 一条 grade_record 只允许有一条 pending 申诉,避免重复申诉。
|
||||
*/
|
||||
export const gradeAppeals = mysqlTable("grade_appeals", {
|
||||
id: id("id").primaryKey(),
|
||||
gradeRecordId: varchar("grade_record_id", { length: 128 }).notNull().references(() => gradeRecords.id, { onDelete: "cascade" }),
|
||||
studentId: varchar("student_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
/** 申诉快照:申诉时的原始分数(审计留痕,防止后续修改后追溯困难) */
|
||||
originalScore: decimal("original_score", { precision: 6, scale: 2 }).notNull(),
|
||||
/** 学生期望的分数(选填,便于教师判断诉求) */
|
||||
expectedScore: decimal("expected_score", { precision: 6, scale: 2 }),
|
||||
/** 申诉理由 */
|
||||
reason: text("reason").notNull(),
|
||||
status: gradeAppealStatusEnum.default("pending").notNull(),
|
||||
/** 复核教师 ID(pending 状态为 null) */
|
||||
reviewerId: varchar("reviewer_id", { length: 128 }).references(() => users.id, { onDelete: "set null" }),
|
||||
/** 教师复核意见 */
|
||||
reviewComment: text("review_comment"),
|
||||
/** 审核后的调整分数(approved 且分数有变时填写) */
|
||||
adjustedScore: decimal("adjusted_score", { precision: 6, scale: 2 }),
|
||||
reviewedAt: timestamp("reviewed_at"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
|
||||
}, (table) => ({
|
||||
gradeRecordIdx: index("ga_grade_record_idx").on(table.gradeRecordId),
|
||||
studentIdx: index("ga_student_idx").on(table.studentId),
|
||||
statusIdx: index("ga_status_idx").on(table.status),
|
||||
reviewerIdx: index("ga_reviewer_idx").on(table.reviewerId),
|
||||
/** pending 状态查询索引(应用层校验唯一性,避免多次申诉) */
|
||||
pendingLookupIdx: index("ga_pending_lookup_idx").on(
|
||||
table.gradeRecordId,
|
||||
table.status
|
||||
),
|
||||
}));
|
||||
139
src/shared/db/schema/homework.ts
Normal file
139
src/shared/db/schema/homework.ts
Normal 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",
|
||||
}),
|
||||
}));
|
||||
87
src/shared/db/schema/knowledge-points.ts
Normal file
87
src/shared/db/schema/knowledge-points.ts
Normal 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"),
|
||||
}));
|
||||
46
src/shared/db/schema/learning-diagnostic.ts
Normal file
46
src/shared/db/schema/learning-diagnostic.ts
Normal 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),
|
||||
}));
|
||||
157
src/shared/db/schema/lesson-preparation.ts
Normal file
157
src/shared/db/schema/lesson-preparation.ts
Normal 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),
|
||||
}));
|
||||
123
src/shared/db/schema/messaging.ts
Normal file
123
src/shared/db/schema/messaging.ts
Normal 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: 群组消息 ID(fan-out on write:教师群发时每条消息共享同一 groupMessageId)
|
||||
groupMessageId: varchar("group_message_id", { length: 128 }),
|
||||
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
senderIdx: index("messages_sender_idx").on(table.senderId),
|
||||
receiverIdx: index("messages_receiver_idx").on(table.receiverId),
|
||||
isReadIdx: index("messages_is_read_idx").on(table.isRead),
|
||||
parentIdx: index("messages_parent_idx").on(table.parentMessageId),
|
||||
receiverReadIdx: index("messages_receiver_read_idx").on(table.receiverId, table.isRead),
|
||||
// V2-P2-13c: 星标索引
|
||||
receiverStarredIdx: index("messages_receiver_starred_idx").on(table.receiverId, table.isStarred),
|
||||
// P2-2: 群组消息索引(按 groupMessageId 聚合查询)
|
||||
groupMessageIdx: index("messages_group_message_idx").on(table.groupMessageId),
|
||||
}));
|
||||
|
||||
// --- 14b. Message Drafts (消息草稿) ---
|
||||
|
||||
export const messageDrafts = mysqlTable("message_drafts", {
|
||||
id: id("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
receiverId: varchar("receiver_id", { length: 128 }).references(() => users.id, { onDelete: "cascade" }),
|
||||
subject: varchar("subject", { length: 255 }),
|
||||
content: text("content"),
|
||||
parentMessageId: varchar("parent_message_id", { length: 128 }),
|
||||
// P2-10: 跨设备草稿同步 - 设备 ID(标识来源设备)
|
||||
deviceId: varchar("device_id", { length: 128 }),
|
||||
// P2-10: 跨设备草稿同步 - 版本号(乐观锁,冲突检测)
|
||||
version: int("version").default(1).notNull(),
|
||||
updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().onUpdateNow().notNull(),
|
||||
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
userIdx: index("message_drafts_user_idx").on(table.userId),
|
||||
userUpdatedIdx: index("message_drafts_user_updated_idx").on(table.userId, table.updatedAt),
|
||||
// P2-10: 支持按 userId + parentMessageId 查询最新版本草稿
|
||||
userParentIdx: index("message_drafts_user_parent_idx").on(table.userId, table.parentMessageId),
|
||||
}));
|
||||
|
||||
// --- 14c. Message Templates (快捷回复模板, P2-6) ---
|
||||
|
||||
export const messageTemplates = mysqlTable("message_templates", {
|
||||
id: id("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
title: varchar("title", { length: 100 }).notNull(),
|
||||
content: text("content").notNull(),
|
||||
// 排序权重(越小越靠前),默认 0
|
||||
sortOrder: int("sort_order").default(0).notNull(),
|
||||
updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().onUpdateNow().notNull(),
|
||||
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
userIdx: index("message_templates_user_idx").on(table.userId),
|
||||
userSortIdx: index("message_templates_user_sort_idx").on(table.userId, table.sortOrder),
|
||||
}));
|
||||
|
||||
// --- 14d. Message Reports (消息举报, P2-5) ---
|
||||
|
||||
export const messageReports = mysqlTable("message_reports", {
|
||||
id: id("id").primaryKey(),
|
||||
// 举报人
|
||||
reporterId: varchar("reporter_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
// 被举报消息 ID
|
||||
messageId: varchar("message_id", { length: 128 }).notNull().references(() => messages.id, { onDelete: "cascade" }),
|
||||
// 被举报人(冗余,便于按被举报人查询)
|
||||
reportedUserId: varchar("reported_user_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
// 举报理由分类:spam / harassment / inappropriate / other
|
||||
reason: varchar("reason", { length: 50 }).notNull(),
|
||||
// 详细描述(可选)
|
||||
description: text("description"),
|
||||
// 举报状态:pending / reviewed / dismissed / actioned
|
||||
status: varchar("status", { length: 20 }).notNull().default("pending"),
|
||||
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().onUpdateNow().notNull(),
|
||||
}, (table) => ({
|
||||
reporterIdx: index("message_reports_reporter_idx").on(table.reporterId),
|
||||
messageIdx: index("message_reports_message_idx").on(table.messageId),
|
||||
reportedUserIdx: index("message_reports_reported_user_idx").on(table.reportedUserId),
|
||||
statusIdx: index("message_reports_status_idx").on(table.status),
|
||||
}));
|
||||
|
||||
// --- 14e. User Blocks (用户屏蔽, P2-5) ---
|
||||
|
||||
export const userBlocks = mysqlTable("user_blocks", {
|
||||
id: id("id").primaryKey(),
|
||||
// 屏蔽发起人
|
||||
blockerId: varchar("blocker_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
// 被屏蔽人
|
||||
blockedId: varchar("blocked_id", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
// 同一对 (blocker, blocked) 只能存在一条记录
|
||||
blockerBlockedUnique: uniqueIndex("user_blocks_blocker_blocked_uniq").on(table.blockerId, table.blockedId),
|
||||
blockerIdx: index("user_blocks_blocker_idx").on(table.blockerId),
|
||||
}));
|
||||
89
src/shared/db/schema/notifications.ts
Normal file
89
src/shared/db/schema/notifications.ts
Normal 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"),
|
||||
}));
|
||||
110
src/shared/db/schema/practice.ts
Normal file
110
src/shared/db/schema/practice.ts
Normal 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),
|
||||
}));
|
||||
74
src/shared/db/schema/questions.ts
Normal file
74
src/shared/db/schema/questions.ts
Normal 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"),
|
||||
}));
|
||||
45
src/shared/db/schema/rbac.ts
Normal file
45
src/shared/db/schema/rbac.ts
Normal 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),
|
||||
}));
|
||||
56
src/shared/db/schema/scheduling.ts
Normal file
56
src/shared/db/schema/scheduling.ts
Normal 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),
|
||||
}));
|
||||
91
src/shared/db/schema/school.ts
Normal file
91
src/shared/db/schema/school.ts
Normal 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"),
|
||||
}))
|
||||
44
src/shared/db/schema/standards.ts
Normal file
44
src/shared/db/schema/standards.ts
Normal 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),
|
||||
}));
|
||||
29
src/shared/db/schema/system-settings.ts
Normal file
29
src/shared/db/schema/system-settings.ts
Normal 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),
|
||||
}));
|
||||
152
src/shared/db/schema/users.ts
Normal file
152
src/shared/db/schema/users.ts
Normal 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"),
|
||||
}));
|
||||
248
src/shared/lib/cache/invalidation-map-academic.ts
vendored
Normal file
248
src/shared/lib/cache/invalidation-map-academic.ts
vendored
Normal 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>
|
||||
349
src/shared/lib/cache/invalidation-map-admin.ts
vendored
Normal file
349
src/shared/lib/cache/invalidation-map-admin.ts
vendored
Normal 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>
|
||||
156
src/shared/lib/cache/invalidation-map-classes.ts
vendored
Normal file
156
src/shared/lib/cache/invalidation-map-classes.ts
vendored
Normal 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>
|
||||
166
src/shared/lib/cache/invalidation-map-grades.ts
vendored
Normal file
166
src/shared/lib/cache/invalidation-map-grades.ts
vendored
Normal 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>
|
||||
150
src/shared/lib/cache/invalidation-map-lesson-preparation.ts
vendored
Normal file
150
src/shared/lib/cache/invalidation-map-lesson-preparation.ts
vendored
Normal 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>
|
||||
136
src/shared/lib/cache/invalidation-map-messaging.ts
vendored
Normal file
136
src/shared/lib/cache/invalidation-map-messaging.ts
vendored
Normal 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>
|
||||
1171
src/shared/lib/cache/invalidation-map.ts
vendored
1171
src/shared/lib/cache/invalidation-map.ts
vendored
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user