feat(modules-comm): update messaging, notifications, onboarding, parent, proctoring, questions, rbac
- messaging: update message-compose, message-detail, message-draft-list, message-group-compose, message-list, message-report-block, message-template-picker, unread-message-badge, data-access - notifications: update notification-list, data-access, use-notification-stream, preferences - onboarding: update actions, data-access, use-onboarding-form - parent: update child-schedule-card, parent-export-button - proctoring: update data-access - questions: update batch-operations, create-question-dialog, import-export-buttons, question-actions, data-access - rbac: update actions, permission-catalog
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { ArrowLeft, Check, Loader2, Save, Send } from "lucide-react"
|
||||
|
||||
@@ -110,7 +110,7 @@ export function MessageCompose({
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
if (!receiverId) {
|
||||
toast.error(t("form.selectRecipient"))
|
||||
notify.error(t("form.selectRecipient"))
|
||||
return
|
||||
}
|
||||
// P0-5: 标记已提交,阻止后续自动保存 effect 触发新草稿创建
|
||||
@@ -129,7 +129,7 @@ export function MessageCompose({
|
||||
if (draftIdRef.current) {
|
||||
void deleteMessageDraftAction(draftIdRef.current)
|
||||
}
|
||||
toast.success(res.message)
|
||||
notify.success(res.message)
|
||||
router.push("/messages")
|
||||
router.refresh()
|
||||
} else {
|
||||
@@ -137,12 +137,12 @@ export function MessageCompose({
|
||||
if (res.errors) {
|
||||
setFieldErrors(res.errors)
|
||||
}
|
||||
toast.error(res.message || t("messages.sendFailed"))
|
||||
notify.error(res.message || t("messages.sendFailed"))
|
||||
// 提交失败时重置 submittedRef 允许后续自动保存
|
||||
submittedRef.current = false
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("messages.sendFailed"))
|
||||
notify.error(t("messages.sendFailed"))
|
||||
submittedRef.current = false
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
@@ -163,14 +163,14 @@ export function MessageCompose({
|
||||
if (res.success && res.data) {
|
||||
setDraftId(res.data)
|
||||
setSaveStatus("saved")
|
||||
toast.success(t("messages.draftSaved"))
|
||||
notify.success(t("messages.draftSaved"))
|
||||
} else {
|
||||
setSaveStatus("idle")
|
||||
toast.error(res.message)
|
||||
notify.error(res.message)
|
||||
}
|
||||
} catch {
|
||||
setSaveStatus("idle")
|
||||
toast.error(t("messages.sendFailed"))
|
||||
notify.error(t("messages.sendFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState, useOptimistic, useTransition } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { ArrowLeft, Loader2, Mail, Reply, RotateCcw, Star, Trash2 } from "lucide-react"
|
||||
|
||||
@@ -98,14 +98,14 @@ export function MessageDetail({
|
||||
try {
|
||||
const res = await deleteMessageAction(message.id)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
notify.success(res.message)
|
||||
router.push("/messages")
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("messages.deleteFailed"))
|
||||
notify.error(res.message || t("messages.deleteFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("messages.deleteFailed"))
|
||||
notify.error(t("messages.deleteFailed"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
setDeleteOpen(false)
|
||||
@@ -120,14 +120,14 @@ export function MessageDetail({
|
||||
try {
|
||||
const res = await toggleMessageStarAction(message.id)
|
||||
if (res.success) {
|
||||
toast.success(t("messages.starToggled"))
|
||||
notify.success(t("messages.starToggled"))
|
||||
// 同步数据源,让 useOptimistic 回滚到最新服务端值
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message)
|
||||
notify.error(res.message)
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("messages.sendFailed"))
|
||||
notify.error(t("messages.sendFailed"))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -140,18 +140,18 @@ export function MessageDetail({
|
||||
if (res.success) {
|
||||
// 同步 UI:立即显示"已撤回"占位
|
||||
setIsRecalled(true)
|
||||
toast.success(t("messages.recalled"))
|
||||
notify.success(t("messages.recalled"))
|
||||
router.refresh()
|
||||
} else {
|
||||
// 失败:根据返回消息判断是否超时
|
||||
if (res.message === "Recall window expired") {
|
||||
toast.error(t("messages.recallExpired"))
|
||||
notify.error(t("messages.recallExpired"))
|
||||
} else {
|
||||
toast.error(res.message || t("messages.recallFailed"))
|
||||
notify.error(res.message || t("messages.recallFailed"))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("messages.recallFailed"))
|
||||
notify.error(t("messages.recallFailed"))
|
||||
} finally {
|
||||
setIsRecalling(false)
|
||||
setRecallOpen(false)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { FileText, Loader2, Trash2 } from "lucide-react"
|
||||
|
||||
@@ -38,13 +38,13 @@ export function MessageDraftList({ drafts }: { drafts: MessageDraft[] }) {
|
||||
if (!res.success) {
|
||||
// 回滚
|
||||
setItems(snapshot)
|
||||
toast.error(res.message)
|
||||
notify.error(res.message)
|
||||
} else {
|
||||
toast.success(t("messages.draftDeleted"))
|
||||
notify.success(t("messages.draftDeleted"))
|
||||
}
|
||||
} catch {
|
||||
setItems(snapshot)
|
||||
toast.error(t("messages.sendFailed"))
|
||||
notify.error(t("messages.sendFailed"))
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
setDeleteOpen(null)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { ArrowLeft, Send, Loader2 } from "lucide-react"
|
||||
|
||||
@@ -62,7 +62,7 @@ export function MessageGroupCompose({
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
if (!classId) {
|
||||
toast.error(t("form.selectClass"))
|
||||
notify.error(t("form.selectClass"))
|
||||
return
|
||||
}
|
||||
formData.set("classId", classId)
|
||||
@@ -73,25 +73,25 @@ export function MessageGroupCompose({
|
||||
const res = await sendGroupMessageAction(null, formData)
|
||||
if (res.success) {
|
||||
const count = res.data?.recipientCount ?? 0
|
||||
toast.success(t("messages.groupSent", { count }))
|
||||
notify.success(t("messages.groupSent", { count }))
|
||||
router.push("/messages")
|
||||
router.refresh()
|
||||
} else {
|
||||
// 识别业务层返回的特定错误(无收件人 / 越权)
|
||||
const msg = res.message ?? ""
|
||||
if (msg.includes("No recipients")) {
|
||||
toast.error(t("messages.groupNoRecipients"))
|
||||
notify.error(t("messages.groupNoRecipients"))
|
||||
} else if (msg.includes("Not allowed")) {
|
||||
toast.error(t("messages.groupNotAuthorized"))
|
||||
notify.error(t("messages.groupNotAuthorized"))
|
||||
} else {
|
||||
toast.error(t("messages.groupSendFailed"))
|
||||
notify.error(t("messages.groupSendFailed"))
|
||||
}
|
||||
if (res.errors) {
|
||||
setFieldErrors(res.errors)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("messages.groupSendFailed"))
|
||||
notify.error(t("messages.groupSendFailed"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Mail, MailOpen, Plus, Send, Inbox, Search, Loader2, ChevronLeft, ChevronRight, Star, RotateCcw, Users } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -139,7 +139,7 @@ export function MessageList({
|
||||
try {
|
||||
const res = await messageService.toggleStar(messageId)
|
||||
if (res.success) {
|
||||
toast.success(t("messages.starToggled"))
|
||||
notify.success(t("messages.starToggled"))
|
||||
// P1-2: 当前在 starred Tab 时重新加载星标列表
|
||||
if (tab === "starred") {
|
||||
void loadStarred()
|
||||
@@ -147,10 +147,10 @@ export function MessageList({
|
||||
// 同步数据源,让 useOptimistic 回滚到最新服务端值
|
||||
await router.refresh()
|
||||
} else {
|
||||
toast.error(res.message)
|
||||
notify.error(res.message)
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("messages.sendFailed"))
|
||||
notify.error(t("messages.sendFailed"))
|
||||
} finally {
|
||||
setTogglingStarId(null)
|
||||
}
|
||||
@@ -170,16 +170,16 @@ export function MessageList({
|
||||
if (res.success) {
|
||||
// 同步 UI:立即显示"已撤回"占位
|
||||
setRecalledOverride((prev) => ({ ...prev, [message.id]: true }))
|
||||
toast.success(t("messages.recalled"))
|
||||
notify.success(t("messages.recalled"))
|
||||
} else {
|
||||
if (res.message === "Recall window expired") {
|
||||
toast.error(t("messages.recallExpired"))
|
||||
notify.error(t("messages.recallExpired"))
|
||||
} else {
|
||||
toast.error(res.message || t("messages.recallFailed"))
|
||||
notify.error(res.message || t("messages.recallFailed"))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("messages.recallFailed"))
|
||||
notify.error(t("messages.recallFailed"))
|
||||
} finally {
|
||||
setRecallingId(null)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Flag, Ban, Loader2 } from "lucide-react"
|
||||
|
||||
@@ -65,22 +65,22 @@ export function MessageReportBlock({
|
||||
try {
|
||||
const res = await reportMessageAction(null, formData)
|
||||
if (res.success) {
|
||||
toast.success(t("messages.reported"))
|
||||
notify.success(t("messages.reported"))
|
||||
setReportOpen(false)
|
||||
setDescription("")
|
||||
setReason("spam")
|
||||
} else {
|
||||
// 识别已举报
|
||||
if (res.message?.includes("Already reported")) {
|
||||
toast.error(t("messages.alreadyReported"))
|
||||
notify.error(t("messages.alreadyReported"))
|
||||
} else if (res.message?.includes("own message")) {
|
||||
toast.error(t("messages.reportSelf"))
|
||||
notify.error(t("messages.reportSelf"))
|
||||
} else {
|
||||
toast.error(res.message || t("messages.reportFailed"))
|
||||
notify.error(res.message || t("messages.reportFailed"))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("messages.reportFailed"))
|
||||
notify.error(t("messages.reportFailed"))
|
||||
} finally {
|
||||
setIsReporting(false)
|
||||
}
|
||||
@@ -93,20 +93,20 @@ export function MessageReportBlock({
|
||||
formData.set("blockedId", senderId)
|
||||
const res = await blockUserAction(null, formData)
|
||||
if (res.success) {
|
||||
toast.success(t("messages.blocked"))
|
||||
notify.success(t("messages.blocked"))
|
||||
setBlockOpen(false)
|
||||
router.refresh()
|
||||
} else {
|
||||
if (res.message?.includes("already")) {
|
||||
toast.error(t("messages.alreadyBlocked"))
|
||||
notify.error(t("messages.alreadyBlocked"))
|
||||
} else if (res.message?.includes("yourself")) {
|
||||
toast.error(t("messages.blockSelf"))
|
||||
notify.error(t("messages.blockSelf"))
|
||||
} else {
|
||||
toast.error(res.message || t("messages.blockFailed"))
|
||||
notify.error(res.message || t("messages.blockFailed"))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("messages.blockFailed"))
|
||||
notify.error(t("messages.blockFailed"))
|
||||
} finally {
|
||||
setIsBlocking(false)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { ChevronDown, FileText, Plus, Trash2 } from "lucide-react"
|
||||
|
||||
@@ -78,7 +78,7 @@ export function MessageTemplatePicker({
|
||||
|
||||
const handleInsert = (template: MessageTemplate): void => {
|
||||
onInsert(template.content)
|
||||
toast.success(t("templates.insert"))
|
||||
notify.success(t("templates.insert"))
|
||||
}
|
||||
|
||||
const openCreate = (): void => {
|
||||
@@ -102,14 +102,14 @@ export function MessageTemplatePicker({
|
||||
try {
|
||||
const res = await saveMessageTemplateAction(null, formData)
|
||||
if (res.success) {
|
||||
toast.success(t("templates.saved"))
|
||||
notify.success(t("templates.saved"))
|
||||
setManageOpen(false)
|
||||
await loadTemplates()
|
||||
} else {
|
||||
toast.error(res.message)
|
||||
notify.error(res.message)
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("templates.saved"))
|
||||
notify.error(t("templates.saved"))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
@@ -120,13 +120,13 @@ export function MessageTemplatePicker({
|
||||
try {
|
||||
const res = await deleteMessageTemplateAction(templateId)
|
||||
if (res.success) {
|
||||
toast.success(t("templates.deleted"))
|
||||
notify.success(t("templates.deleted"))
|
||||
await loadTemplates()
|
||||
} else {
|
||||
toast.error(res.message)
|
||||
notify.error(res.message)
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("templates.deleted"))
|
||||
notify.error(t("templates.deleted"))
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ export function UnreadMessageBadge() {
|
||||
|
||||
// 兜底:如果 SSE 在 5 秒内未收到任何数据,启动轮询作为保险
|
||||
const fallbackTimer = setTimeout(() => {
|
||||
if (active && usingSSE && count === 0) {
|
||||
if (active && usingSSE) {
|
||||
// SSE 可能未推送初始数据,拉取一次
|
||||
void fetchCount()
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Bell, CheckCheck, MessageSquare, Megaphone, PenTool, GraduationCap, Stethoscope } from "lucide-react"
|
||||
|
||||
@@ -49,13 +49,13 @@ export function NotificationList({ notifications }: { notifications: Notificatio
|
||||
try {
|
||||
const res = await markAllNotificationsAsReadAction()
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
notify.success(res.message)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("messages.markReadFailed"))
|
||||
notify.error(res.message || t("messages.markReadFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("messages.markReadFailed"))
|
||||
notify.error(t("messages.markReadFailed"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
@@ -68,7 +68,7 @@ export function NotificationList({ notifications }: { notifications: Notificatio
|
||||
router.refresh()
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("messages.markReadFailed"))
|
||||
notify.error(t("messages.markReadFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ export function NotificationList({ notifications }: { notifications: Notificatio
|
||||
router.refresh()
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("messages.archiveFailed"))
|
||||
notify.error(t("messages.archiveFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { and, count, desc, eq } from "drizzle-orm"
|
||||
import { db } from "@/shared/db"
|
||||
import { messageNotifications, notificationLogs, users } from "@/shared/db/schema"
|
||||
import { cacheFn } from "@/shared/lib/cache"
|
||||
import { serializeDateRequired as toIsoRequired } from "@/shared/lib/date-utils"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
import type { ChannelRecipient } from "./channels/types"
|
||||
import type {
|
||||
@@ -36,7 +37,6 @@ import type {
|
||||
} from "./types"
|
||||
|
||||
const log = createModuleLogger("notifications")
|
||||
const toIsoRequired = (d: Date): string => d.toISOString()
|
||||
|
||||
const isNotificationType = (v: unknown): v is NotificationType =>
|
||||
v === "message" || v === "announcement" || v === "homework" || v === "grade" || v === "diagnostic"
|
||||
|
||||
@@ -158,7 +158,9 @@ export function useNotificationStream(options?: UseNotificationStreamOptions): U
|
||||
setIsUsingFallback(true)
|
||||
return
|
||||
}
|
||||
const data = JSON.parse(event.data) as NotificationStreamData
|
||||
const raw: unknown = JSON.parse(event.data)
|
||||
// 从 unknown 转换:SSE 数据结构由后端保证,字段使用前已做 typeof 校验
|
||||
const data = raw as NotificationStreamData
|
||||
if (data.type === "update") {
|
||||
if (typeof data.unreadCount === "number") setUnreadCount(data.unreadCount)
|
||||
if (data.notifications) setNotifications(data.notifications)
|
||||
|
||||
@@ -19,13 +19,12 @@ import { and, eq } from "drizzle-orm"
|
||||
import { db } from "@/shared/db"
|
||||
import { notificationPreferences } from "@/shared/db/schema"
|
||||
import { cacheFn } from "@/shared/lib/cache"
|
||||
import { serializeDateRequired as toIso } from "@/shared/lib/date-utils"
|
||||
import type {
|
||||
NotificationPreferences,
|
||||
UpdateNotificationPreferencesInput,
|
||||
} from "./types"
|
||||
|
||||
const toIso = (d: Date): string => d.toISOString()
|
||||
|
||||
const mapRow = (
|
||||
row: typeof notificationPreferences.$inferSelect
|
||||
): NotificationPreferences => ({
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"use server"
|
||||
|
||||
import { eq } from "drizzle-orm"
|
||||
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import { requireAuth } from "@/shared/lib/auth-guard"
|
||||
import { invalidateFor } from "@/shared/lib/cache"
|
||||
@@ -12,8 +10,6 @@ import {
|
||||
rateLimitKey,
|
||||
RATE_LIMIT_RULES,
|
||||
} from "@/shared/lib/rate-limit"
|
||||
import { db } from "@/shared/db"
|
||||
import { users } from "@/shared/db/schema"
|
||||
import {
|
||||
enrollStudentByInvitationCode,
|
||||
enrollTeacherByInvitationCode,
|
||||
@@ -22,11 +18,18 @@ import { DEFAULT_CLASS_SUBJECTS } from "@/modules/classes/types"
|
||||
import { OnboardingSchema } from "./schema"
|
||||
import {
|
||||
getOnboardingStatus,
|
||||
getUserOnboardedAt,
|
||||
markUserOnboarded,
|
||||
updateUserProfile,
|
||||
bindParentToChild,
|
||||
} from "./data-access"
|
||||
import type { OnboardingCompleteData, OnboardingFailureItem } from "./types"
|
||||
|
||||
// G3-030 修复:以类型标注替代 as 断言,避免对元组类型做 widening 断言。
|
||||
// DEFAULT_CLASS_SUBJECTS 是 `as const` 元组,.includes() 需要 readonly string[] 入参,
|
||||
// 显式标注的常量使其在不使用 as 的情况下满足 .includes() 签名。
|
||||
const CLASS_SUBJECTS_READONLY: readonly string[] = DEFAULT_CLASS_SUBJECTS
|
||||
|
||||
/**
|
||||
* 查询当前用户 onboarding 状态。
|
||||
* 供服务端组件 / 客户端组件读取,决定是否渲染引导流程。
|
||||
@@ -69,13 +72,10 @@ export async function completeOnboardingAction(
|
||||
const userId = ctx.userId
|
||||
|
||||
// P0-5 服务端幂等:已完成的用户直接返回成功,防止双击或刷新重复提交
|
||||
const [existingUser] = await db
|
||||
.select({ onboardedAt: users.onboardedAt })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1)
|
||||
// 审计 v1 P0 修复(G5-002):改调 data-access 函数,消除 actions 直查 DB
|
||||
const existingOnboardedAt = await getUserOnboardedAt(userId)
|
||||
|
||||
if (existingUser?.onboardedAt) {
|
||||
if (existingOnboardedAt) {
|
||||
const roleNames = ctx.roles
|
||||
return {
|
||||
success: true,
|
||||
@@ -108,7 +108,7 @@ export async function completeOnboardingAction(
|
||||
// 教师任课科目过滤:仅保留系统默认科目
|
||||
const validTeacherSubjects = input.teacherSubjects.filter(
|
||||
(s): s is (typeof DEFAULT_CLASS_SUBJECTS)[number] =>
|
||||
(DEFAULT_CLASS_SUBJECTS as readonly string[]).includes(s)
|
||||
CLASS_SUBJECTS_READONLY.includes(s)
|
||||
)
|
||||
|
||||
// audit-P1-8:家长绑定子女独立速率限制。
|
||||
@@ -144,8 +144,9 @@ export async function completeOnboardingAction(
|
||||
// 收集局部失败项(P1-2):班级码/子女绑定失败不回滚整个事务
|
||||
const failures: OnboardingFailureItem[] = []
|
||||
|
||||
// 事务包裹全部写入
|
||||
const result = await db.transaction(async (tx) => {
|
||||
// 审计 v1 P0 修复(G5-002):移除 db.transaction 包裹,消除 actions 对 db 的直接依赖。
|
||||
// 原 db.transaction 实际上只保护 onboardedAt 的更新(其他 data-access 函数均用 db 不在事务内),
|
||||
// 移除后行为一致。所有写操作通过 data-access 函数完成。
|
||||
// 1. 更新基础资料
|
||||
await updateUserProfile(userId, {
|
||||
name: input.name,
|
||||
@@ -219,14 +220,10 @@ export async function completeOnboardingAction(
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 最后标记 onboarded(事务内,确保原子性)
|
||||
await tx
|
||||
.update(users)
|
||||
.set({ onboardedAt: new Date() })
|
||||
.where(eq(users.id, userId))
|
||||
// 5. 最后标记 onboarded(审计 v1 P0 修复:改调 data-access 函数)
|
||||
await markUserOnboarded(userId)
|
||||
|
||||
return { defaultPath: resolveDefaultPath(normalizedRoles) }
|
||||
})
|
||||
const result = { defaultPath: resolveDefaultPath(normalizedRoles) }
|
||||
|
||||
// P0-4 审计日志:记录 onboarding 完成(含失败项明细,对标 PowerSchool/Veracross)
|
||||
await logAudit({
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import "server-only"
|
||||
|
||||
import { eq, and } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
@@ -49,6 +51,47 @@ export const getOnboardingStatus = cacheFn(getOnboardingStatusRaw, {
|
||||
keyParts: ["onboarding", "status"],
|
||||
})
|
||||
|
||||
/**
|
||||
* 读取用户 onboardedAt 时间戳。
|
||||
*
|
||||
* 用于 completeOnboardingAction 的服务端幂等检查(P0-5):
|
||||
* 若已完成 onboarding 则直接返回成功,防止双击或刷新重复提交。
|
||||
*
|
||||
* 审计 v1 P0 修复(G5-002):从 actions.ts 下沉到 data-access,
|
||||
* 消除 actions 层直查 DB 的违规(A-09)。
|
||||
*/
|
||||
export async function getUserOnboardedAtRaw(userId: string): Promise<Date | null> {
|
||||
const [row] = await db
|
||||
.select({ onboardedAt: users.onboardedAt })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1)
|
||||
return row?.onboardedAt ?? null
|
||||
}
|
||||
|
||||
export const getUserOnboardedAt = cacheFn(getUserOnboardedAtRaw, {
|
||||
tags: ["onboarding"],
|
||||
ttl: 300,
|
||||
keyParts: ["onboarding", "onboarded-at"],
|
||||
})
|
||||
|
||||
/**
|
||||
* 标记用户 onboarding 完成(写入 users.onboardedAt)。
|
||||
*
|
||||
* 审计 v1 P0 修复(G5-002):从 actions.ts 事务内 tx.update 下沉到 data-access,
|
||||
* 消除 actions 层直查 schema 表的违规(A-09)。
|
||||
*
|
||||
* 注意:此函数使用 db 而非事务参数。原 actions.ts 中的 db.transaction 包裹
|
||||
* 实际上只保护了 onboardedAt 的更新(其他 data-access 函数均用 db 不在事务内),
|
||||
* 移除事务包裹后行为一致。
|
||||
*/
|
||||
export async function markUserOnboarded(userId: string): Promise<void> {
|
||||
await db
|
||||
.update(users)
|
||||
.set({ onboardedAt: new Date() })
|
||||
.where(eq(users.id, userId))
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户基础资料(姓名/电话/住址)。
|
||||
* 不涉及角色与班级绑定,独立可复用。
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as React from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useSession } from "next-auth/react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
|
||||
import { type ClassSubject } from "@/modules/classes/types"
|
||||
import { completeOnboardingAction } from "@/modules/onboarding/actions"
|
||||
@@ -96,11 +96,11 @@ export function useOnboardingForm(initialStatus: OnboardingStatus) {
|
||||
|
||||
const onNext = () => {
|
||||
if (currentStepId === "basicInfo" && !canNext) {
|
||||
toast.error(t("validation.needNamePhone"))
|
||||
notify.error(t("validation.needNamePhone"))
|
||||
return
|
||||
}
|
||||
if (currentStepId === "roleInfo" && isParent && !canNext) {
|
||||
toast.error(t("validation.needOneChild"))
|
||||
notify.error(t("validation.needOneChild"))
|
||||
return
|
||||
}
|
||||
goToStep(step + 1)
|
||||
@@ -133,7 +133,7 @@ export function useOnboardingForm(initialStatus: OnboardingStatus) {
|
||||
(c) => c.childEmail.trim() && c.childBirthDate.trim() && c.childPhoneSuffix.trim(),
|
||||
)
|
||||
if (validChildren.length === 0) {
|
||||
toast.error(t("validation.needOneChild"))
|
||||
notify.error(t("validation.needOneChild"))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -154,14 +154,14 @@ export function useOnboardingForm(initialStatus: OnboardingStatus) {
|
||||
|
||||
const result = await completeOnboardingAction(null, formData)
|
||||
if (!result.success) {
|
||||
toast.error(result.message ?? t("toast.submitFailed"))
|
||||
notify.error(result.message ?? t("toast.submitFailed"))
|
||||
return
|
||||
}
|
||||
|
||||
if (result.message && result.message.includes("绑定失败")) {
|
||||
toast.warning(result.message)
|
||||
notify.warning(result.message)
|
||||
} else {
|
||||
toast.success(t("toast.completeSuccess"))
|
||||
notify.success(t("toast.completeSuccess"))
|
||||
}
|
||||
|
||||
await update?.()
|
||||
@@ -170,7 +170,7 @@ export function useOnboardingForm(initialStatus: OnboardingStatus) {
|
||||
router.refresh()
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : t("toast.submitFailed")
|
||||
toast.error(msg)
|
||||
notify.error(msg)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,10 @@ export function ChildScheduleCard({
|
||||
if (!grouped[key]) grouped[key] = []
|
||||
grouped[key].push(item)
|
||||
}
|
||||
const weekdays = Object.keys(grouped).map(Number).sort((a, b) => a - b) as Array<1 | 2 | 3 | 4 | 5 | 6 | 7>
|
||||
const weekdays = Object.keys(grouped)
|
||||
.map(Number)
|
||||
.filter((n): n is 1 | 2 | 3 | 4 | 5 | 6 | 7 => n >= 1 && n <= 7)
|
||||
.sort((a, b) => a - b)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
import { Download, Loader2 } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -30,7 +30,7 @@ export function ParentExportButton({
|
||||
const result = await safeActionCall(
|
||||
() => exportGradesAction({ studentId }),
|
||||
{
|
||||
onError: () => toast.error(`Failed to export grades for ${studentName}.`),
|
||||
onError: () => notify.error(`Failed to export grades for ${studentName}.`),
|
||||
onFinally: () => setIsExporting(false),
|
||||
}
|
||||
)
|
||||
@@ -53,12 +53,12 @@ export function ParentExportButton({
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success(`Grades exported for ${studentName}.`)
|
||||
notify.success(`Grades exported for ${studentName}.`)
|
||||
} catch {
|
||||
toast.error("Failed to download the export file.")
|
||||
notify.error("Failed to download the export file.")
|
||||
}
|
||||
} else if (result) {
|
||||
toast.error(result.message || `Failed to export grades for ${studentName}.`)
|
||||
notify.error(result.message || `Failed to export grades for ${studentName}.`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ import type {
|
||||
} from "./types"
|
||||
import { ABNORMAL_EVENT_THRESHOLD } from "./types"
|
||||
|
||||
/** F-05: 列表查询默认上限,防止无界查询拉爆内存 */
|
||||
const DEFAULT_LIMIT = 1000
|
||||
|
||||
const ALL_EVENT_TYPES: ProctoringEventType[] = [
|
||||
"tab_switch",
|
||||
"window_blur",
|
||||
@@ -146,6 +149,7 @@ export const getProctoringEventsRaw = async (
|
||||
.from(examProctoringEvents)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(examProctoringEvents.occurredAt))
|
||||
.limit(DEFAULT_LIMIT)
|
||||
|
||||
if (rows.length === 0) return []
|
||||
|
||||
@@ -183,6 +187,7 @@ export const getProctoringEventsBySubmissionRaw = async (submissionId: string):
|
||||
const rows = await db.query.examProctoringEvents.findMany({
|
||||
where: eq(examProctoringEvents.submissionId, submissionId),
|
||||
orderBy: [desc(examProctoringEvents.occurredAt)],
|
||||
limit: DEFAULT_LIMIT,
|
||||
})
|
||||
|
||||
return rows.map((row) => ({
|
||||
@@ -305,7 +310,8 @@ export const getStudentProctoringStatusesRaw = async (examId: string): Promise<S
|
||||
inArray(examProctoringEvents.studentId, studentIds),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(examProctoringEvents.occurredAt)),
|
||||
.orderBy(desc(examProctoringEvents.occurredAt))
|
||||
.limit(DEFAULT_LIMIT),
|
||||
])
|
||||
|
||||
// 4. 按学生聚合
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
import { deleteQuestionsBatchAction } from "../actions"
|
||||
import { usePermission } from "@/shared/hooks/use-permission"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
|
||||
interface BatchOperationsProps {
|
||||
/** 选中的题目 ID 列表 */
|
||||
@@ -53,16 +53,16 @@ export function BatchOperations({ selectedIds, onClearSelection }: BatchOperatio
|
||||
const res = await deleteQuestionsBatchAction(undefined, fd)
|
||||
if (res.success) {
|
||||
const deleted = res.data?.deleted ?? 0
|
||||
toast.success(t("batch.deleteSuccess", { count: deleted }))
|
||||
notify.success(t("batch.deleteSuccess", { count: deleted }))
|
||||
setShowDeleteDialog(false)
|
||||
onClearSelection()
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("batch.deleteFailed"))
|
||||
notify.error(res.message || t("batch.deleteFailed"))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to batch delete questions", e)
|
||||
toast.error(t("batch.deleteFailed"))
|
||||
notify.error(t("batch.deleteFailed"))
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import { SelectField } from "@/shared/components/form-fields/select-field"
|
||||
import { TextareaField } from "@/shared/components/form-fields/textarea-field"
|
||||
import { BaseQuestionSchema } from "../schema"
|
||||
import { createQuestionAction, updateQuestionAction } from "../actions"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
import type { Question } from "../types"
|
||||
import {
|
||||
parseQuestionContent,
|
||||
@@ -164,7 +164,7 @@ export function CreateQuestionDialog({
|
||||
setIsPending(true)
|
||||
try {
|
||||
if (isEdit && !initialData?.id) {
|
||||
toast.error(t("error.missingId"))
|
||||
notify.error(t("error.missingId"))
|
||||
return
|
||||
}
|
||||
const payload = {
|
||||
@@ -180,18 +180,18 @@ export function CreateQuestionDialog({
|
||||
? await updateQuestionAction(undefined, fd)
|
||||
: await createQuestionAction(undefined, fd)
|
||||
if (res.success) {
|
||||
toast.success(isEdit ? t("error.updatedSuccess") : t("error.createdSuccess"))
|
||||
notify.success(isEdit ? t("error.updatedSuccess") : t("error.createdSuccess"))
|
||||
onOpenChange(false)
|
||||
router.refresh()
|
||||
if (!isEdit) {
|
||||
form.reset()
|
||||
}
|
||||
} else {
|
||||
toast.error(res.message || t("error.operationFailed"))
|
||||
notify.error(res.message || t("error.operationFailed"))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to submit question", e)
|
||||
toast.error(t("error.unexpected"))
|
||||
notify.error(t("error.unexpected"))
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { usePermission } from "@/shared/hooks/use-permission"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { exportQuestionsAction, importQuestionsAction } from "../actions"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
|
||||
/**
|
||||
* 题目导入/导出组件。
|
||||
@@ -60,13 +60,13 @@ export function ImportExportButtons(): React.ReactNode {
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success(t("importExport.exportSuccess", { count: res.data.length }))
|
||||
notify.success(t("importExport.exportSuccess", { count: res.data.length }))
|
||||
} else {
|
||||
toast.error(res.message || t("importExport.exportFailed"))
|
||||
notify.error(res.message || t("importExport.exportFailed"))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to export questions", e)
|
||||
toast.error(t("importExport.exportFailed"))
|
||||
notify.error(t("importExport.exportFailed"))
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
@@ -83,11 +83,11 @@ export function ImportExportButtons(): React.ReactNode {
|
||||
setPendingImportData(text)
|
||||
setShowImportDialog(true)
|
||||
} else {
|
||||
toast.error(t("importExport.invalidFile"))
|
||||
notify.error(t("importExport.invalidFile"))
|
||||
}
|
||||
}
|
||||
reader.onerror = () => {
|
||||
toast.error(t("importExport.readFailed"))
|
||||
notify.error(t("importExport.readFailed"))
|
||||
}
|
||||
reader.readAsText(file)
|
||||
// 重置 input 以便重复选择同一文件
|
||||
@@ -103,16 +103,16 @@ export function ImportExportButtons(): React.ReactNode {
|
||||
const res = await importQuestionsAction(undefined, fd)
|
||||
if (res.success) {
|
||||
const imported = res.data?.imported ?? 0
|
||||
toast.success(t("importExport.importSuccess", { count: imported }))
|
||||
notify.success(t("importExport.importSuccess", { count: imported }))
|
||||
setShowImportDialog(false)
|
||||
setPendingImportData(null)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("importExport.importFailed"))
|
||||
notify.error(res.message || t("importExport.importFailed"))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to import questions", e)
|
||||
toast.error(t("importExport.importFailed"))
|
||||
notify.error(t("importExport.importFailed"))
|
||||
} finally {
|
||||
setIsImporting(false)
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ import { CreateQuestionDialog } from "./create-question-dialog"
|
||||
import { QuestionContentRenderer } from "./question-content-renderer"
|
||||
import { usePermission } from "@/shared/hooks/use-permission"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
|
||||
interface QuestionActionsProps {
|
||||
question: Question
|
||||
@@ -59,10 +59,10 @@ export function QuestionActions({ question }: QuestionActionsProps): React.React
|
||||
const copyId = (): void => {
|
||||
try {
|
||||
navigator.clipboard.writeText(question.id)
|
||||
toast.success(t("actions.copyIdSuccess"))
|
||||
notify.success(t("actions.copyIdSuccess"))
|
||||
} catch (e) {
|
||||
console.error("Failed to copy question ID to clipboard", e)
|
||||
toast.error(t("actions.copyIdFailed"))
|
||||
notify.error(t("actions.copyIdFailed"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,15 +73,15 @@ export function QuestionActions({ question }: QuestionActionsProps): React.React
|
||||
fd.set("questionId", question.id)
|
||||
const res = await deleteQuestionAction(undefined, fd)
|
||||
if (res.success) {
|
||||
toast.success(t("actions.deleteSuccess"))
|
||||
notify.success(t("actions.deleteSuccess"))
|
||||
setShowDeleteDialog(false)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("actions.deleteFailed"))
|
||||
notify.error(res.message || t("actions.deleteFailed"))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to delete question", e)
|
||||
toast.error(t("actions.deleteFailed"))
|
||||
notify.error(t("actions.deleteFailed"))
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'server-only';
|
||||
|
||||
import { db } from "@/shared/db";
|
||||
import { knowledgePoints, questions, questionsToKnowledgePoints } from "@/shared/db/schema";
|
||||
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";
|
||||
@@ -11,12 +11,25 @@ import {
|
||||
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";
|
||||
|
||||
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
|
||||
@@ -36,6 +49,16 @@ export type GetQuestionsParams = {
|
||||
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,
|
||||
@@ -58,10 +81,13 @@ export const getQuestionsRaw = async ({
|
||||
}
|
||||
|
||||
if (q && q.trim().length > 0) {
|
||||
const needle = `%${q.trim().toLowerCase()}%`;
|
||||
// F-02: 使用 FULLTEXT 索引(content_text 生成列)+ MATCH AGAINST 替代 LIKE '%xxx%' 全表扫描
|
||||
const booleanQuery = toBooleanModeQuery(q.trim())
|
||||
if (booleanQuery) {
|
||||
conditions.push(
|
||||
sql`LOWER(CAST(${questions.content} AS CHAR)) LIKE ${needle}`
|
||||
);
|
||||
sql`MATCH(${questions.contentText}) AGAINST(${booleanQuery} IN BOOLEAN MODE)`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (type) {
|
||||
@@ -201,6 +227,11 @@ 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) }
|
||||
@@ -246,6 +277,16 @@ async function insertQuestionWithRelations(
|
||||
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
|
||||
@@ -255,6 +296,17 @@ export async function createQuestionWithRelations(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 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,
|
||||
@@ -291,29 +343,68 @@ export async function updateQuestionById(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量收集给定根题目 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> {
|
||||
if (visited.has(questionId)) {
|
||||
// 环检测:避免在异常数据(如循环引用)下无限递归
|
||||
return
|
||||
}
|
||||
visited.add(questionId)
|
||||
|
||||
const children = await tx
|
||||
.select({ id: questions.id })
|
||||
.from(questions)
|
||||
.where(eq(questions.parentId, questionId));
|
||||
|
||||
for (const child of children) {
|
||||
await deleteQuestionRecursive(tx, child.id, visited);
|
||||
}
|
||||
|
||||
await tx.delete(questions).where(eq(questions.id, questionId));
|
||||
// 收集所有后代 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,
|
||||
@@ -369,26 +460,35 @@ export async function deleteQuestionsBatch(
|
||||
const targetIds = targetRows.map((r) => r.id)
|
||||
if (targetIds.length === 0) return 0
|
||||
|
||||
for (const id of targetIds) {
|
||||
await deleteQuestionRecursive(tx, id)
|
||||
// 一次性收集所有 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 getKnowledgePointOptions(): Promise<KnowledgePointOption[]> {
|
||||
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 getTextbookOptions(): Promise<TextbookOption[]> {
|
||||
export async function getTextbookOptionsRaw(): Promise<TextbookOption[]> {
|
||||
const textbooks = await getTextbooksFromTextbooks()
|
||||
return textbooks.map((tb) => ({
|
||||
id: tb.id,
|
||||
@@ -397,13 +497,18 @@ export async function getTextbookOptions(): Promise<TextbookOption[]> {
|
||||
grade: tb.grade,
|
||||
}))
|
||||
}
|
||||
export const getTextbookOptions = cacheFn(getTextbookOptionsRaw, {
|
||||
tags: ["questions"],
|
||||
ttl: 600,
|
||||
keyParts: ["questions", "getTextbookOptions"],
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取指定教材下的章节选项列表(级联筛选第二级)。
|
||||
*
|
||||
* 委托 textbooks 模块 data-access 获取章节树,展平为选项列表。
|
||||
*/
|
||||
export async function getChapterOptions(textbookId: string): Promise<ChapterOption[]> {
|
||||
export async function getChapterOptionsRaw(textbookId: string): Promise<ChapterOption[]> {
|
||||
const chapters = await getChaptersByTextbookIdFromTextbooks(textbookId)
|
||||
const options: ChapterOption[] = []
|
||||
|
||||
@@ -424,16 +529,26 @@ export async function getChapterOptions(textbookId: string): Promise<ChapterOpti
|
||||
flatten(chapters)
|
||||
return options
|
||||
}
|
||||
export const getChapterOptions = cacheFn(getChapterOptionsRaw, {
|
||||
tags: ["questions"],
|
||||
ttl: 600,
|
||||
keyParts: ["questions", "getChapterOptions"],
|
||||
})
|
||||
|
||||
/**
|
||||
* 获取指定章节下的知识点选项列表(级联筛选第三级)。
|
||||
*
|
||||
* 委托 textbooks 模块 data-access 获取知识点列表。
|
||||
*/
|
||||
export async function getKnowledgePointOptionsByChapter(chapterId: string): Promise<{ id: string; name: string }[]> {
|
||||
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
|
||||
@@ -453,22 +568,28 @@ export const getKnowledgePointsForQuestionsRaw = async (
|
||||
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: knowledgePoints.id,
|
||||
knowledgePointName: knowledgePoints.name,
|
||||
knowledgePointId: questionsToKnowledgePoints.knowledgePointId,
|
||||
})
|
||||
.from(questionsToKnowledgePoints)
|
||||
.innerJoin(knowledgePoints, eq(knowledgePoints.id, questionsToKnowledgePoints.knowledgePointId))
|
||||
.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: r.knowledgePointName,
|
||||
knowledgePointName: kpNameMap.get(r.knowledgePointId) ?? "",
|
||||
})
|
||||
result.set(r.questionId, list)
|
||||
}
|
||||
@@ -574,7 +695,7 @@ export interface QuestionExportItem {
|
||||
* 支持按 IDs 导出指定题目,或导出全部题目(受 pageSize 限制)。
|
||||
* 遵循权限范围:非 all 范围只能导出自己创建的题目。
|
||||
*/
|
||||
export async function exportQuestions(
|
||||
export async function exportQuestionsRaw(
|
||||
questionIds?: string[],
|
||||
canExportAll = true,
|
||||
authorId?: string
|
||||
@@ -620,6 +741,11 @@ export async function exportQuestions(
|
||||
updatedAt: row.updatedAt,
|
||||
}))
|
||||
}
|
||||
export const exportQuestions = cacheFn(exportQuestionsRaw, {
|
||||
tags: ["questions"],
|
||||
ttl: 60,
|
||||
keyParts: ["questions", "exportQuestions"],
|
||||
})
|
||||
|
||||
/** 导入题目的单条输入 */
|
||||
export interface QuestionImportItem {
|
||||
@@ -660,3 +786,42 @@ export async function importQuestions(
|
||||
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"],
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ import { after } from "next/server"
|
||||
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions, type Permission } from "@/shared/types/permissions"
|
||||
import { Permissions, type Permission, isPermission } from "@/shared/types/permissions"
|
||||
import { logAudit } from "@/shared/lib/audit-logger"
|
||||
import { logDataChange } from "@/shared/lib/change-logger"
|
||||
import { invalidateFor } from "@/shared/lib/cache"
|
||||
@@ -248,8 +248,8 @@ export async function setRolePermissionsAction(
|
||||
}
|
||||
|
||||
const before = await getRolePermissions(parsed.data.roleId)
|
||||
// Schema validates all strings are valid permission values, so the cast is safe.
|
||||
const permissions = parsed.data.permissions as Permission[]
|
||||
// Schema 已用 refine 校验所有字符串均为合法权限值,再用类型守卫收窄到 Permission[]
|
||||
const permissions = parsed.data.permissions.filter(isPermission)
|
||||
await setRolePermissions(parsed.data.roleId, permissions)
|
||||
|
||||
trackPermissionChange({
|
||||
|
||||
@@ -128,6 +128,8 @@ export const PERMISSION_CATALOG: PermissionGroup[] = [
|
||||
labelKey: "rbac:permissions.group.audit",
|
||||
permissions: [
|
||||
{ key: "AUDIT_LOG_READ", value: Permissions.AUDIT_LOG_READ, labelKey: "rbac:permissions.audit.read", descriptionKey: "rbac:permissions.audit.readDesc" },
|
||||
{ key: "AUDIT_LOG_PURGE", value: Permissions.AUDIT_LOG_PURGE, labelKey: "rbac:permissions.audit.purge", descriptionKey: "rbac:permissions.audit.purgeDesc" },
|
||||
{ key: "AUDIT_RETENTION_MANAGE", value: Permissions.AUDIT_RETENTION_MANAGE, labelKey: "rbac:permissions.audit.retentionManage", descriptionKey: "rbac:permissions.audit.retentionManageDesc" },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user