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
This commit is contained in:
SpecialX
2026-06-24 12:04:09 +08:00
parent e3b8455b31
commit 1f833097e2
9 changed files with 299 additions and 53 deletions

View File

@@ -27,6 +27,13 @@ const DASHBOARD_ROUTE_PERMISSIONS: Record<string, string> = {
"/parent/dashboard": Permissions.DASHBOARD_PARENT_READ,
}
// 精确路由权限(优先级最高,覆盖 ROUTE_PERMISSIONS 的前缀匹配)
// 用于将 /admin/* 下的特定页面开放给非管理员角色
// V3.1/admin/ai-settings 对所有 AI_CHAT 用户开放(管理自己的 private provider
const SPECIFIC_ROUTE_PERMISSIONS: Record<string, string> = {
"/admin/ai-settings": Permissions.AI_CHAT,
}
// API route prefix → required permission
const API_PERMISSIONS: Record<string, string> = {
"/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)) {

View File

@@ -46,9 +46,11 @@ function SheetContent({
className,
children,
side = "right",
hideClose = false,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "bottom" | "left" | "right"
hideClose?: boolean
}) {
return (
<SheetPortal>
@@ -70,10 +72,12 @@ function SheetContent({
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
{hideClose ? null : (
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)

View File

@@ -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"
/**
* 已知的业务错误类型,消息可以安全返回给客户端。

View File

@@ -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<UserContext | null> {
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<AiProviderConfig> => {
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)
}

View File

@@ -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<Data
}
// Student: can see data from their enrolled classes
// Pre-resolve classIds here to avoid N+1 queries in data-access layer
// Pre-resolve classIds and gradeIds here to avoid N+1 queries in data-access layer
if (roleNames.includes("student")) {
const enrolledClasses = await db
.select({ classId: classEnrollments.classId })
.select({ classId: classEnrollments.classId, gradeId: classes.gradeId })
.from(classEnrollments)
.innerJoin(classes, eq(classEnrollments.classId, classes.id))
.where(eq(classEnrollments.studentId, userId))
const gradeIds = [
...new Set(
enrolledClasses
.map((c) => 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<Data
.from(parentStudentRelations)
.where(eq(parentStudentRelations.parentId, userId))
return { type: "children", childrenIds: children.map((c) => 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

15
src/shared/lib/errors.ts Normal file
View File

@@ -0,0 +1,15 @@
/**
* 共享错误类,无服务端依赖,可安全用于客户端和服务端。
*/
/**
* 权限不足错误。由 requirePermission 抛出。
*/
export class PermissionDeniedError extends Error {
constructor(permission: string) {
super(
`权限不足:需要 ${permission} 权限。请联系管理员授权或切换账号后重试。`
)
this.name = "PermissionDeniedError"
}
}

View File

@@ -64,6 +64,7 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
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<Role, Permission[]> = {
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<Role, Permission[]> = {
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<Role, Permission[]> = {
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<Role, Permission[]> = {
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<Role, Permission[]> = {
Permissions.ELECTIVE_READ,
Permissions.EXAM_PROCTOR_READ,
Permissions.DIAGNOSTIC_READ,
Permissions.LESSON_PLAN_READ,
Permissions.ERROR_BOOK_ANALYTICS_READ,
Permissions.ADAPTIVE_PRACTICE_READ,
],
}

View File

@@ -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<string, unknown> =>
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
}

View File

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