From 1f833097e27ec6e5b018f0d86adb4654acf14318 Mon Sep 17 00:00:00 2001 From: SpecialX <47072643+wangxiner55@users.noreply.github.com> Date: Wed, 24 Jun 2026 12:04:09 +0800 Subject: [PATCH] feat(shared): add errors lib, question-content, and update permissions and UI - Add errors lib for standardized error handling - Add question-content lib for question content processing - Update action-utils, ai/provider-config, auth-guard, permissions, types/permissions - Update UI sheet component - Update proxy middleware --- src/proxy.ts | 22 ++++- src/shared/components/ui/sheet.tsx | 12 ++- src/shared/lib/action-utils.ts | 2 +- src/shared/lib/ai/provider-config.ts | 129 ++++++++++++++++++++------- src/shared/lib/auth-guard.ts | 50 ++++++++--- src/shared/lib/errors.ts | 15 ++++ src/shared/lib/permissions.ts | 11 +++ src/shared/lib/question-content.ts | 99 ++++++++++++++++++++ src/shared/types/permissions.ts | 12 ++- 9 files changed, 299 insertions(+), 53 deletions(-) create mode 100644 src/shared/lib/errors.ts create mode 100644 src/shared/lib/question-content.ts diff --git a/src/proxy.ts b/src/proxy.ts index 4a7fb77..f8431d7 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -27,6 +27,13 @@ const DASHBOARD_ROUTE_PERMISSIONS: Record = { "/parent/dashboard": Permissions.DASHBOARD_PARENT_READ, } +// 精确路由权限(优先级最高,覆盖 ROUTE_PERMISSIONS 的前缀匹配) +// 用于将 /admin/* 下的特定页面开放给非管理员角色 +// V3.1:/admin/ai-settings 对所有 AI_CHAT 用户开放(管理自己的 private provider) +const SPECIFIC_ROUTE_PERMISSIONS: Record = { + "/admin/ai-settings": Permissions.AI_CHAT, +} + // API route prefix → required permission const API_PERMISSIONS: Record = { "/api/ai/chat": Permissions.AI_CHAT, @@ -99,7 +106,20 @@ export async function proxy(request: NextRequest) { } // Check page route permissions - // 优先检查仪表盘路由的细粒度权限(防止跨角色访问仪表盘) + // 优先级 1:精确路由权限(覆盖前缀匹配,用于将 /admin/* 下特定页面开放给非管理员) + if (Object.prototype.hasOwnProperty.call(SPECIFIC_ROUTE_PERMISSIONS, pathname)) { + const requiredPerm = SPECIFIC_ROUTE_PERMISSIONS[pathname] + if (!permissions.includes(requiredPerm)) { + const defaultPath = resolveDefaultPath(roles) + const redirectUrl = new URL(defaultPath, request.url) + redirectUrl.searchParams.set("from", pathname) + redirectUrl.searchParams.set("reason", "forbidden") + return NextResponse.redirect(redirectUrl) + } + return NextResponse.next() + } + + // 优先级 2:仪表盘路由的细粒度权限(防止跨角色访问仪表盘) if (Object.prototype.hasOwnProperty.call(DASHBOARD_ROUTE_PERMISSIONS, pathname)) { const requiredPerm = DASHBOARD_ROUTE_PERMISSIONS[pathname] if (!permissions.includes(requiredPerm)) { diff --git a/src/shared/components/ui/sheet.tsx b/src/shared/components/ui/sheet.tsx index a2e8f2f..e9bd121 100644 --- a/src/shared/components/ui/sheet.tsx +++ b/src/shared/components/ui/sheet.tsx @@ -46,9 +46,11 @@ function SheetContent({ className, children, side = "right", + hideClose = false, ...props }: React.ComponentProps & { side?: "top" | "bottom" | "left" | "right" + hideClose?: boolean }) { return ( @@ -70,10 +72,12 @@ function SheetContent({ {...props} > {children} - - - Close - + {hideClose ? null : ( + + + Close + + )} ) diff --git a/src/shared/lib/action-utils.ts b/src/shared/lib/action-utils.ts index 1744d4f..292b696 100644 --- a/src/shared/lib/action-utils.ts +++ b/src/shared/lib/action-utils.ts @@ -8,7 +8,7 @@ */ import type { ActionState } from "@/shared/types/action-state" -import { PermissionDeniedError } from "@/shared/lib/auth-guard" +import { PermissionDeniedError } from "@/shared/lib/errors" /** * 已知的业务错误类型,消息可以安全返回给客户端。 diff --git a/src/shared/lib/ai/provider-config.ts b/src/shared/lib/ai/provider-config.ts index 16192f7..ae0bf74 100644 --- a/src/shared/lib/ai/provider-config.ts +++ b/src/shared/lib/ai/provider-config.ts @@ -1,9 +1,11 @@ import "server-only" -import { desc, eq } from "drizzle-orm" +import { and, desc, eq, or, type SQL } from "drizzle-orm" import { db } from "@/shared/db" import { aiProviders } from "@/shared/db/schema" +import { getSession } from "@/shared/lib/session" +import { Permissions } from "@/shared/types/permissions" import { decryptAiApiKey } from "./api-key-crypto" @@ -13,57 +15,118 @@ export type AiProviderConfig = { model: string } +type ProviderAccessRow = { + apiKeyEncrypted: string + baseUrl: string | null + model: string + visibility: "public" | "private" + createdBy: string | null +} + +type UserContext = { userId: string; isAdmin: boolean } + +/** + * 解析当前用户上下文,用于可见性校验 + * + * @returns 用户上下文,或 null(无 session 时) + */ +async function resolveUserContext(): Promise { + const session = await getSession() + const userId = session?.user?.id + if (!userId) return null + const permissions = (session.user.permissions ?? []) as string[] + return { + userId, + isAdmin: permissions.includes(Permissions.AI_CONFIGURE), + } +} + +/** + * 校验当前用户是否有权访问指定 Provider + * + * 规则: + * - 无 session(系统路径):允许(向后兼容) + * - 管理员(AI_CONFIGURE):允许访问任意 Provider + * - 普通用户:仅允许访问 public Provider 或自己创建的 private Provider + */ +function canAccessProvider( + provider: { visibility: "public" | "private"; createdBy: string | null }, + userCtx: UserContext | null +): boolean { + if (!userCtx) return true + if (userCtx.isAdmin) return true + if (provider.visibility === "public") return true + if (provider.createdBy === userCtx.userId) return true + return false +} + +/** + * 构造可见性过滤条件 + * + * 管理员或无 session 时返回 undefined(不过滤); + * 普通用户返回 (visibility = public OR createdBy = userId)。 + */ +function buildVisibilityFilter(userCtx: UserContext | null): SQL | undefined { + if (!userCtx || userCtx.isAdmin) return undefined + return or( + eq(aiProviders.visibility, "public"), + eq(aiProviders.createdBy, userCtx.userId) + ) ?? undefined +} + +function toConfig(row: ProviderAccessRow): AiProviderConfig { + return { + apiKey: decryptAiApiKey(row.apiKeyEncrypted), + baseUrl: row.baseUrl ?? undefined, + model: row.model, + } +} + +const selectColumns = { + apiKeyEncrypted: aiProviders.apiKeyEncrypted, + baseUrl: aiProviders.baseUrl, + model: aiProviders.model, + visibility: aiProviders.visibility, + createdBy: aiProviders.createdBy, +} as const + export const getAiProviderConfig = async (providerId?: string): Promise => { + const userCtx = await resolveUserContext() + if (providerId) { const [selected] = await db - .select({ - apiKeyEncrypted: aiProviders.apiKeyEncrypted, - baseUrl: aiProviders.baseUrl, - model: aiProviders.model, - }) + .select(selectColumns) .from(aiProviders) .where(eq(aiProviders.id, providerId)) .limit(1) if (!selected) throw new Error("AI provider not configured") - return { - apiKey: decryptAiApiKey(selected.apiKeyEncrypted), - baseUrl: selected.baseUrl ?? undefined, - model: selected.model, + if (!canAccessProvider(selected, userCtx)) { + throw new Error("AI provider access denied") } + return toConfig(selected) } + const visibilityFilter = buildVisibilityFilter(userCtx) + + const defaultWhere = visibilityFilter + ? and(eq(aiProviders.isDefault, true), visibilityFilter) + : eq(aiProviders.isDefault, true) + const [active] = await db - .select({ - apiKeyEncrypted: aiProviders.apiKeyEncrypted, - baseUrl: aiProviders.baseUrl, - model: aiProviders.model, - }) + .select(selectColumns) .from(aiProviders) - .where(eq(aiProviders.isDefault, true)) + .where(defaultWhere) .orderBy(desc(aiProviders.updatedAt)) .limit(1) - if (active) { - return { - apiKey: decryptAiApiKey(active.apiKeyEncrypted), - baseUrl: active.baseUrl ?? undefined, - model: active.model, - } - } + if (active) return toConfig(active) const [fallback] = await db - .select({ - apiKeyEncrypted: aiProviders.apiKeyEncrypted, - baseUrl: aiProviders.baseUrl, - model: aiProviders.model, - }) + .select(selectColumns) .from(aiProviders) + .where(visibilityFilter ?? undefined) .orderBy(desc(aiProviders.updatedAt)) .limit(1) if (!fallback) throw new Error("AI provider not configured") - return { - apiKey: decryptAiApiKey(fallback.apiKeyEncrypted), - baseUrl: fallback.baseUrl ?? undefined, - model: fallback.model, - } + return toConfig(fallback) } diff --git a/src/shared/lib/auth-guard.ts b/src/shared/lib/auth-guard.ts index 93e5902..f386156 100644 --- a/src/shared/lib/auth-guard.ts +++ b/src/shared/lib/auth-guard.ts @@ -7,17 +7,12 @@ import { grades, parentStudentRelations, } from "@/shared/db/schema" -import { eq, or } from "drizzle-orm" +import { eq, inArray, or } from "drizzle-orm" import { getSession } from "@/shared/lib/session" +import { PermissionDeniedError } from "@/shared/lib/errors" -export class PermissionDeniedError extends Error { - constructor(permission: string) { - super( - `权限不足:需要 ${permission} 权限。请联系管理员授权或切换账号后重试。` - ) - this.name = "PermissionDeniedError" - } -} +// Re-export for backward compatibility (other modules still import from here) +export { PermissionDeniedError } from "@/shared/lib/errors" /** * Get the full authentication context for the current user. @@ -114,16 +109,26 @@ async function resolveDataScope(userId: string, roleNames: Role[]): Promise c.gradeId) + .filter((g): g is string => g !== null), + ), + ] + return { type: "class_members", classIds: enrolledClasses.map((c) => c.classId), + gradeIds: gradeIds.length > 0 ? gradeIds : undefined, } } @@ -134,7 +139,28 @@ async function resolveDataScope(userId: string, roleNames: Role[]): Promise c.studentId) } + const childrenIds = children.map((c) => c.studentId) + + // Pre-resolve gradeIds from children's enrolled classes + let gradeIds: string[] | undefined + if (childrenIds.length > 0) { + const childrenClasses = await db + .select({ gradeId: classes.gradeId }) + .from(classEnrollments) + .innerJoin(classes, eq(classEnrollments.classId, classes.id)) + .where(inArray(classEnrollments.studentId, childrenIds)) + + const uniqueGradeIds = [ + ...new Set( + childrenClasses + .map((c) => c.gradeId) + .filter((g): g is string => g !== null), + ), + ] + gradeIds = uniqueGradeIds.length > 0 ? uniqueGradeIds : undefined + } + + return { type: "children", childrenIds, gradeIds } } // Fallback: only own data diff --git a/src/shared/lib/errors.ts b/src/shared/lib/errors.ts new file mode 100644 index 0000000..ff5ee7e --- /dev/null +++ b/src/shared/lib/errors.ts @@ -0,0 +1,15 @@ +/** + * 共享错误类,无服务端依赖,可安全用于客户端和服务端。 + */ + +/** + * 权限不足错误。由 requirePermission 抛出。 + */ +export class PermissionDeniedError extends Error { + constructor(permission: string) { + super( + `权限不足:需要 ${permission} 权限。请联系管理员授权或切换账号后重试。` + ) + this.name = "PermissionDeniedError" + } +} diff --git a/src/shared/lib/permissions.ts b/src/shared/lib/permissions.ts index 8930c19..f01b28c 100644 --- a/src/shared/lib/permissions.ts +++ b/src/shared/lib/permissions.ts @@ -64,6 +64,7 @@ export const ROLE_PERMISSIONS: Record = { Permissions.FILE_DELETE, Permissions.DASHBOARD_ADMIN_READ, Permissions.ERROR_BOOK_ANALYTICS_READ, + Permissions.ADAPTIVE_PRACTICE_READ, ], teacher: [ Permissions.EXAM_CREATE, @@ -109,6 +110,7 @@ export const ROLE_PERMISSIONS: Record = { Permissions.LESSON_PLAN_PUBLISH, Permissions.DASHBOARD_TEACHER_READ, Permissions.ERROR_BOOK_ANALYTICS_READ, + Permissions.ADAPTIVE_PRACTICE_READ, ], student: [ Permissions.EXAM_READ, @@ -129,9 +131,12 @@ export const ROLE_PERMISSIONS: Record = { Permissions.ELECTIVE_SELECT, Permissions.ELECTIVE_READ, Permissions.DIAGNOSTIC_READ, + Permissions.LESSON_PLAN_READ, Permissions.DASHBOARD_STUDENT_READ, Permissions.ERROR_BOOK_READ, Permissions.ERROR_BOOK_MANAGE, + Permissions.ADAPTIVE_PRACTICE_READ, + Permissions.ADAPTIVE_PRACTICE_MANAGE, ], parent: [ Permissions.EXAM_READ, @@ -144,8 +149,10 @@ export const ROLE_PERMISSIONS: Record = { Permissions.MESSAGE_SEND, Permissions.MESSAGE_READ, Permissions.MESSAGE_DELETE, + Permissions.LESSON_PLAN_READ, Permissions.DASHBOARD_PARENT_READ, Permissions.ERROR_BOOK_READ, + Permissions.ADAPTIVE_PRACTICE_READ, ], grade_head: [ Permissions.EXAM_CREATE, @@ -183,7 +190,9 @@ export const ROLE_PERMISSIONS: Record = { Permissions.EXAM_PROCTOR_READ, Permissions.DIAGNOSTIC_MANAGE, Permissions.DIAGNOSTIC_READ, + Permissions.LESSON_PLAN_READ, Permissions.ERROR_BOOK_ANALYTICS_READ, + Permissions.ADAPTIVE_PRACTICE_READ, ], teaching_head: [ Permissions.EXAM_CREATE, @@ -216,7 +225,9 @@ export const ROLE_PERMISSIONS: Record = { Permissions.ELECTIVE_READ, Permissions.EXAM_PROCTOR_READ, Permissions.DIAGNOSTIC_READ, + Permissions.LESSON_PLAN_READ, Permissions.ERROR_BOOK_ANALYTICS_READ, + Permissions.ADAPTIVE_PRACTICE_READ, ], } diff --git a/src/shared/lib/question-content.ts b/src/shared/lib/question-content.ts new file mode 100644 index 0000000..3f9f675 --- /dev/null +++ b/src/shared/lib/question-content.ts @@ -0,0 +1,99 @@ +/** + * 题目内容解析纯函数(共享层) + * + * 从 `unknown` 类型的题目内容中安全提取文本、选项、正确答案等。 + * 所有函数均为纯函数,无副作用,便于单测。 + * + * 这些函数被 homework、error-book 等多个模块共享使用, + * 避免各模块重复实现题目内容解析逻辑。 + */ + +export type QuestionOption = { + id: string + text: string + isCorrect?: boolean +} + +export const isRecord = (v: unknown): v is Record => + typeof v === "object" && v !== null + +export const getQuestionText = (content: unknown): string => { + if (!isRecord(content)) return "" + return typeof content.text === "string" ? content.text : "" +} + +export const getOptions = (content: unknown): QuestionOption[] => { + if (!isRecord(content)) return [] + const raw = content.options + if (!Array.isArray(raw)) return [] + const out: QuestionOption[] = [] + for (const item of raw) { + if (!isRecord(item)) continue + const id = typeof item.id === "string" ? item.id : "" + const text = typeof item.text === "string" ? item.text : "" + if (!id || !text) continue + const isCorrect = item.isCorrect === true + out.push({ id, text, isCorrect }) + } + return out +} + +export const getChoiceCorrectIds = (content: unknown): string[] => { + return getOptions(content) + .filter((o): o is QuestionOption & { isCorrect: true } => o.isCorrect === true) + .map((o) => o.id) +} + +export const getJudgmentCorrectAnswer = (content: unknown): boolean | null => { + if (!isRecord(content)) return null + return typeof content.correctAnswer === "boolean" ? content.correctAnswer : null +} + +export const getTextCorrectAnswers = (content: unknown): string[] => { + if (!isRecord(content)) return [] + const raw = content.correctAnswer + if (typeof raw === "string") return [raw] + if (Array.isArray(raw)) return raw.filter((x): x is string => typeof x === "string") + return [] +} + +export type QuestionType = "single_choice" | "multiple_choice" | "judgment" | "text" | string + +export const extractAnswerValue = (studentAnswer: unknown): unknown => { + if (isRecord(studentAnswer) && "answer" in studentAnswer) return studentAnswer.answer + return studentAnswer +} + +/** + * 从题目内容中提取正确答案,统一序列化为可存储的 JSON 格式。 + * + * - 选择题:返回正确选项 ID 数组 + * - 判断题:返回布尔值 + * - 填空题:返回正确答案字符串数组 + * - 其他题型或无标准答案:返回 null + * + * @param questionType 题目类型 + * @param content 题目内容(unknown 类型) + * @returns 序列化后的正确答案,或 null + */ +export function extractCorrectAnswer( + questionType: string, + content: unknown, +): unknown { + if (questionType === "single_choice" || questionType === "multiple_choice") { + const ids = getChoiceCorrectIds(content) + return ids.length > 0 ? ids : null + } + + if (questionType === "judgment") { + const answer = getJudgmentCorrectAnswer(content) + return answer // 可能是 true/false/null + } + + if (questionType === "text") { + const answers = getTextCorrectAnswers(content) + return answers.length > 0 ? answers : null + } + + return null +} diff --git a/src/shared/types/permissions.ts b/src/shared/types/permissions.ts index 9d954ac..b94187e 100644 --- a/src/shared/types/permissions.ts +++ b/src/shared/types/permissions.ts @@ -131,6 +131,12 @@ export const Permissions = { ERROR_BOOK_MANAGE: "error_book:manage", /** 读取班级/年级/全校错题统计分析(教师、年级主任、教务主任、管理员) */ ERROR_BOOK_ANALYTICS_READ: "error_book:analytics_read", + + // Adaptive Practice (专项练习) + /** 读取自己的专项练习记录(学生)或子女的练习记录(家长) */ + ADAPTIVE_PRACTICE_READ: "adaptive_practice:read", + /** 发起和管理自己的专项练习(学生) */ + ADAPTIVE_PRACTICE_MANAGE: "adaptive_practice:manage", } as const satisfies Record export type Permission = (typeof Permissions)[keyof typeof Permissions] @@ -160,17 +166,19 @@ export function isRole(value: string): value is Role { * - `owned`: only rows created by the user. * - `class_members`: rows visible to members of the user's enrolled classes (student). * `classIds` is pre-resolved by `resolveDataScope` to avoid N+1 queries downstream. + * `gradeIds` is pre-resolved from enrolled classes for grade-based filtering (e.g. lesson plans). * - `grade_managed`: rows within grades the user manages (grade_head / teaching_head). * - `class_taught`: rows within classes the user teaches (teacher). * - `children`: rows belonging to the user's children (parent). + * `gradeIds` is pre-resolved from children's enrolled classes for grade-based filtering. */ export type DataScope = | { type: "all" } | { type: "owned"; userId: string } - | { type: "class_members"; classIds: string[] } + | { type: "class_members"; classIds: string[]; gradeIds?: string[] } | { type: "grade_managed"; gradeIds: string[] } | { type: "class_taught"; classIds: string[]; subjectIds?: string[] } - | { type: "children"; childrenIds: string[] } + | { type: "children"; childrenIds: string[]; gradeIds?: string[] } /** * Authentication context for the current request.