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:
22
src/proxy.ts
22
src/proxy.ts
@@ -27,6 +27,13 @@ const DASHBOARD_ROUTE_PERMISSIONS: Record<string, string> = {
|
|||||||
"/parent/dashboard": Permissions.DASHBOARD_PARENT_READ,
|
"/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
|
// API route prefix → required permission
|
||||||
const API_PERMISSIONS: Record<string, string> = {
|
const API_PERMISSIONS: Record<string, string> = {
|
||||||
"/api/ai/chat": Permissions.AI_CHAT,
|
"/api/ai/chat": Permissions.AI_CHAT,
|
||||||
@@ -99,7 +106,20 @@ export async function proxy(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check page route permissions
|
// 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)) {
|
if (Object.prototype.hasOwnProperty.call(DASHBOARD_ROUTE_PERMISSIONS, pathname)) {
|
||||||
const requiredPerm = DASHBOARD_ROUTE_PERMISSIONS[pathname]
|
const requiredPerm = DASHBOARD_ROUTE_PERMISSIONS[pathname]
|
||||||
if (!permissions.includes(requiredPerm)) {
|
if (!permissions.includes(requiredPerm)) {
|
||||||
|
|||||||
@@ -46,9 +46,11 @@ function SheetContent({
|
|||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
side = "right",
|
side = "right",
|
||||||
|
hideClose = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
side?: "top" | "bottom" | "left" | "right"
|
side?: "top" | "bottom" | "left" | "right"
|
||||||
|
hideClose?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SheetPortal>
|
<SheetPortal>
|
||||||
@@ -70,10 +72,12 @@ function SheetContent({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{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">
|
{hideClose ? null : (
|
||||||
<XIcon className="size-4" />
|
<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">
|
||||||
<span className="sr-only">Close</span>
|
<XIcon className="size-4" />
|
||||||
</SheetPrimitive.Close>
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
)}
|
||||||
</SheetPrimitive.Content>
|
</SheetPrimitive.Content>
|
||||||
</SheetPortal>
|
</SheetPortal>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ActionState } from "@/shared/types/action-state"
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
import { PermissionDeniedError } from "@/shared/lib/auth-guard"
|
import { PermissionDeniedError } from "@/shared/lib/errors"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 已知的业务错误类型,消息可以安全返回给客户端。
|
* 已知的业务错误类型,消息可以安全返回给客户端。
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import "server-only"
|
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 { db } from "@/shared/db"
|
||||||
import { aiProviders } from "@/shared/db/schema"
|
import { aiProviders } from "@/shared/db/schema"
|
||||||
|
import { getSession } from "@/shared/lib/session"
|
||||||
|
import { Permissions } from "@/shared/types/permissions"
|
||||||
|
|
||||||
import { decryptAiApiKey } from "./api-key-crypto"
|
import { decryptAiApiKey } from "./api-key-crypto"
|
||||||
|
|
||||||
@@ -13,57 +15,118 @@ export type AiProviderConfig = {
|
|||||||
model: string
|
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> => {
|
export const getAiProviderConfig = async (providerId?: string): Promise<AiProviderConfig> => {
|
||||||
|
const userCtx = await resolveUserContext()
|
||||||
|
|
||||||
if (providerId) {
|
if (providerId) {
|
||||||
const [selected] = await db
|
const [selected] = await db
|
||||||
.select({
|
.select(selectColumns)
|
||||||
apiKeyEncrypted: aiProviders.apiKeyEncrypted,
|
|
||||||
baseUrl: aiProviders.baseUrl,
|
|
||||||
model: aiProviders.model,
|
|
||||||
})
|
|
||||||
.from(aiProviders)
|
.from(aiProviders)
|
||||||
.where(eq(aiProviders.id, providerId))
|
.where(eq(aiProviders.id, providerId))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
if (!selected) throw new Error("AI provider not configured")
|
if (!selected) throw new Error("AI provider not configured")
|
||||||
return {
|
if (!canAccessProvider(selected, userCtx)) {
|
||||||
apiKey: decryptAiApiKey(selected.apiKeyEncrypted),
|
throw new Error("AI provider access denied")
|
||||||
baseUrl: selected.baseUrl ?? undefined,
|
|
||||||
model: selected.model,
|
|
||||||
}
|
}
|
||||||
|
return toConfig(selected)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const visibilityFilter = buildVisibilityFilter(userCtx)
|
||||||
|
|
||||||
|
const defaultWhere = visibilityFilter
|
||||||
|
? and(eq(aiProviders.isDefault, true), visibilityFilter)
|
||||||
|
: eq(aiProviders.isDefault, true)
|
||||||
|
|
||||||
const [active] = await db
|
const [active] = await db
|
||||||
.select({
|
.select(selectColumns)
|
||||||
apiKeyEncrypted: aiProviders.apiKeyEncrypted,
|
|
||||||
baseUrl: aiProviders.baseUrl,
|
|
||||||
model: aiProviders.model,
|
|
||||||
})
|
|
||||||
.from(aiProviders)
|
.from(aiProviders)
|
||||||
.where(eq(aiProviders.isDefault, true))
|
.where(defaultWhere)
|
||||||
.orderBy(desc(aiProviders.updatedAt))
|
.orderBy(desc(aiProviders.updatedAt))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
if (active) {
|
if (active) return toConfig(active)
|
||||||
return {
|
|
||||||
apiKey: decryptAiApiKey(active.apiKeyEncrypted),
|
|
||||||
baseUrl: active.baseUrl ?? undefined,
|
|
||||||
model: active.model,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [fallback] = await db
|
const [fallback] = await db
|
||||||
.select({
|
.select(selectColumns)
|
||||||
apiKeyEncrypted: aiProviders.apiKeyEncrypted,
|
|
||||||
baseUrl: aiProviders.baseUrl,
|
|
||||||
model: aiProviders.model,
|
|
||||||
})
|
|
||||||
.from(aiProviders)
|
.from(aiProviders)
|
||||||
|
.where(visibilityFilter ?? undefined)
|
||||||
.orderBy(desc(aiProviders.updatedAt))
|
.orderBy(desc(aiProviders.updatedAt))
|
||||||
.limit(1)
|
.limit(1)
|
||||||
if (!fallback) throw new Error("AI provider not configured")
|
if (!fallback) throw new Error("AI provider not configured")
|
||||||
|
|
||||||
return {
|
return toConfig(fallback)
|
||||||
apiKey: decryptAiApiKey(fallback.apiKeyEncrypted),
|
|
||||||
baseUrl: fallback.baseUrl ?? undefined,
|
|
||||||
model: fallback.model,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,17 +7,12 @@ import {
|
|||||||
grades,
|
grades,
|
||||||
parentStudentRelations,
|
parentStudentRelations,
|
||||||
} from "@/shared/db/schema"
|
} from "@/shared/db/schema"
|
||||||
import { eq, or } from "drizzle-orm"
|
import { eq, inArray, or } from "drizzle-orm"
|
||||||
import { getSession } from "@/shared/lib/session"
|
import { getSession } from "@/shared/lib/session"
|
||||||
|
import { PermissionDeniedError } from "@/shared/lib/errors"
|
||||||
|
|
||||||
export class PermissionDeniedError extends Error {
|
// Re-export for backward compatibility (other modules still import from here)
|
||||||
constructor(permission: string) {
|
export { PermissionDeniedError } from "@/shared/lib/errors"
|
||||||
super(
|
|
||||||
`权限不足:需要 ${permission} 权限。请联系管理员授权或切换账号后重试。`
|
|
||||||
)
|
|
||||||
this.name = "PermissionDeniedError"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the full authentication context for the current user.
|
* 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
|
// 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")) {
|
if (roleNames.includes("student")) {
|
||||||
const enrolledClasses = await db
|
const enrolledClasses = await db
|
||||||
.select({ classId: classEnrollments.classId })
|
.select({ classId: classEnrollments.classId, gradeId: classes.gradeId })
|
||||||
.from(classEnrollments)
|
.from(classEnrollments)
|
||||||
|
.innerJoin(classes, eq(classEnrollments.classId, classes.id))
|
||||||
.where(eq(classEnrollments.studentId, userId))
|
.where(eq(classEnrollments.studentId, userId))
|
||||||
|
|
||||||
|
const gradeIds = [
|
||||||
|
...new Set(
|
||||||
|
enrolledClasses
|
||||||
|
.map((c) => c.gradeId)
|
||||||
|
.filter((g): g is string => g !== null),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: "class_members",
|
type: "class_members",
|
||||||
classIds: enrolledClasses.map((c) => c.classId),
|
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)
|
.from(parentStudentRelations)
|
||||||
.where(eq(parentStudentRelations.parentId, userId))
|
.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
|
// Fallback: only own data
|
||||||
|
|||||||
15
src/shared/lib/errors.ts
Normal file
15
src/shared/lib/errors.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* 共享错误类,无服务端依赖,可安全用于客户端和服务端。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限不足错误。由 requirePermission 抛出。
|
||||||
|
*/
|
||||||
|
export class PermissionDeniedError extends Error {
|
||||||
|
constructor(permission: string) {
|
||||||
|
super(
|
||||||
|
`权限不足:需要 ${permission} 权限。请联系管理员授权或切换账号后重试。`
|
||||||
|
)
|
||||||
|
this.name = "PermissionDeniedError"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,7 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
|||||||
Permissions.FILE_DELETE,
|
Permissions.FILE_DELETE,
|
||||||
Permissions.DASHBOARD_ADMIN_READ,
|
Permissions.DASHBOARD_ADMIN_READ,
|
||||||
Permissions.ERROR_BOOK_ANALYTICS_READ,
|
Permissions.ERROR_BOOK_ANALYTICS_READ,
|
||||||
|
Permissions.ADAPTIVE_PRACTICE_READ,
|
||||||
],
|
],
|
||||||
teacher: [
|
teacher: [
|
||||||
Permissions.EXAM_CREATE,
|
Permissions.EXAM_CREATE,
|
||||||
@@ -109,6 +110,7 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
|||||||
Permissions.LESSON_PLAN_PUBLISH,
|
Permissions.LESSON_PLAN_PUBLISH,
|
||||||
Permissions.DASHBOARD_TEACHER_READ,
|
Permissions.DASHBOARD_TEACHER_READ,
|
||||||
Permissions.ERROR_BOOK_ANALYTICS_READ,
|
Permissions.ERROR_BOOK_ANALYTICS_READ,
|
||||||
|
Permissions.ADAPTIVE_PRACTICE_READ,
|
||||||
],
|
],
|
||||||
student: [
|
student: [
|
||||||
Permissions.EXAM_READ,
|
Permissions.EXAM_READ,
|
||||||
@@ -129,9 +131,12 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
|||||||
Permissions.ELECTIVE_SELECT,
|
Permissions.ELECTIVE_SELECT,
|
||||||
Permissions.ELECTIVE_READ,
|
Permissions.ELECTIVE_READ,
|
||||||
Permissions.DIAGNOSTIC_READ,
|
Permissions.DIAGNOSTIC_READ,
|
||||||
|
Permissions.LESSON_PLAN_READ,
|
||||||
Permissions.DASHBOARD_STUDENT_READ,
|
Permissions.DASHBOARD_STUDENT_READ,
|
||||||
Permissions.ERROR_BOOK_READ,
|
Permissions.ERROR_BOOK_READ,
|
||||||
Permissions.ERROR_BOOK_MANAGE,
|
Permissions.ERROR_BOOK_MANAGE,
|
||||||
|
Permissions.ADAPTIVE_PRACTICE_READ,
|
||||||
|
Permissions.ADAPTIVE_PRACTICE_MANAGE,
|
||||||
],
|
],
|
||||||
parent: [
|
parent: [
|
||||||
Permissions.EXAM_READ,
|
Permissions.EXAM_READ,
|
||||||
@@ -144,8 +149,10 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
|||||||
Permissions.MESSAGE_SEND,
|
Permissions.MESSAGE_SEND,
|
||||||
Permissions.MESSAGE_READ,
|
Permissions.MESSAGE_READ,
|
||||||
Permissions.MESSAGE_DELETE,
|
Permissions.MESSAGE_DELETE,
|
||||||
|
Permissions.LESSON_PLAN_READ,
|
||||||
Permissions.DASHBOARD_PARENT_READ,
|
Permissions.DASHBOARD_PARENT_READ,
|
||||||
Permissions.ERROR_BOOK_READ,
|
Permissions.ERROR_BOOK_READ,
|
||||||
|
Permissions.ADAPTIVE_PRACTICE_READ,
|
||||||
],
|
],
|
||||||
grade_head: [
|
grade_head: [
|
||||||
Permissions.EXAM_CREATE,
|
Permissions.EXAM_CREATE,
|
||||||
@@ -183,7 +190,9 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
|||||||
Permissions.EXAM_PROCTOR_READ,
|
Permissions.EXAM_PROCTOR_READ,
|
||||||
Permissions.DIAGNOSTIC_MANAGE,
|
Permissions.DIAGNOSTIC_MANAGE,
|
||||||
Permissions.DIAGNOSTIC_READ,
|
Permissions.DIAGNOSTIC_READ,
|
||||||
|
Permissions.LESSON_PLAN_READ,
|
||||||
Permissions.ERROR_BOOK_ANALYTICS_READ,
|
Permissions.ERROR_BOOK_ANALYTICS_READ,
|
||||||
|
Permissions.ADAPTIVE_PRACTICE_READ,
|
||||||
],
|
],
|
||||||
teaching_head: [
|
teaching_head: [
|
||||||
Permissions.EXAM_CREATE,
|
Permissions.EXAM_CREATE,
|
||||||
@@ -216,7 +225,9 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
|||||||
Permissions.ELECTIVE_READ,
|
Permissions.ELECTIVE_READ,
|
||||||
Permissions.EXAM_PROCTOR_READ,
|
Permissions.EXAM_PROCTOR_READ,
|
||||||
Permissions.DIAGNOSTIC_READ,
|
Permissions.DIAGNOSTIC_READ,
|
||||||
|
Permissions.LESSON_PLAN_READ,
|
||||||
Permissions.ERROR_BOOK_ANALYTICS_READ,
|
Permissions.ERROR_BOOK_ANALYTICS_READ,
|
||||||
|
Permissions.ADAPTIVE_PRACTICE_READ,
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
99
src/shared/lib/question-content.ts
Normal file
99
src/shared/lib/question-content.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -131,6 +131,12 @@ export const Permissions = {
|
|||||||
ERROR_BOOK_MANAGE: "error_book:manage",
|
ERROR_BOOK_MANAGE: "error_book:manage",
|
||||||
/** 读取班级/年级/全校错题统计分析(教师、年级主任、教务主任、管理员) */
|
/** 读取班级/年级/全校错题统计分析(教师、年级主任、教务主任、管理员) */
|
||||||
ERROR_BOOK_ANALYTICS_READ: "error_book:analytics_read",
|
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>
|
} as const satisfies Record<string, string>
|
||||||
|
|
||||||
export type Permission = (typeof Permissions)[keyof typeof Permissions]
|
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.
|
* - `owned`: only rows created by the user.
|
||||||
* - `class_members`: rows visible to members of the user's enrolled classes (student).
|
* - `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.
|
* `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).
|
* - `grade_managed`: rows within grades the user manages (grade_head / teaching_head).
|
||||||
* - `class_taught`: rows within classes the user teaches (teacher).
|
* - `class_taught`: rows within classes the user teaches (teacher).
|
||||||
* - `children`: rows belonging to the user's children (parent).
|
* - `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 =
|
export type DataScope =
|
||||||
| { type: "all" }
|
| { type: "all" }
|
||||||
| { type: "owned"; userId: string }
|
| { type: "owned"; userId: string }
|
||||||
| { type: "class_members"; classIds: string[] }
|
| { type: "class_members"; classIds: string[]; gradeIds?: string[] }
|
||||||
| { type: "grade_managed"; gradeIds: string[] }
|
| { type: "grade_managed"; gradeIds: string[] }
|
||||||
| { type: "class_taught"; classIds: string[]; subjectIds?: string[] }
|
| { type: "class_taught"; classIds: string[]; subjectIds?: string[] }
|
||||||
| { type: "children"; childrenIds: string[] }
|
| { type: "children"; childrenIds: string[]; gradeIds?: string[] }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authentication context for the current request.
|
* Authentication context for the current request.
|
||||||
|
|||||||
Reference in New Issue
Block a user