feat(shared,tests): add error boundaries, lib utils, i18n messages, and integration tests
Some checks failed
CI / scheduled-backup (push) Has been skipped
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled

shared:

- Add class-filter, error-state, route-error, section-error-boundary, widget-boundary components

- Add ui/alert component

- Add constants directory

- Add breached-password, export-utils, permission-bitmap, rate-limit, resolve-action-error, route-permissions, route-resolver, type-guards lib

- Add i18n messages (en, zh-CN) for invitation-codes, parent, questions, rbac

tests:

- Add integration tests for elective

- Add tests/setup/empty-stub

scripts:

- Add update-md.cjs, tmp_append_en.ps1, tmp_merge_en.ps1 utilities
This commit is contained in:
SpecialX
2026-07-03 10:26:38 +08:00
parent 21142f9b99
commit 0e63c24ed9
97 changed files with 9753 additions and 819 deletions

View File

@@ -36,10 +36,13 @@ export class NotFoundError extends BusinessError {
/**
* 输入校验错误。消息可安全返回客户端。
*
* `code` 可选,默认 `"validation_error"`。当需要前端按字段精确本地化时
* 可传入自定义错误码(如 `invalid_date:publishDate`),前端通过 errorCode 查 i18n。
*/
export class ValidationError extends BusinessError {
constructor(message: string) {
super(message, "validation_error")
constructor(message: string, code?: string) {
super(message, code ?? "validation_error")
this.name = "ValidationError"
}
}
@@ -59,19 +62,19 @@ export function handleActionError(e: unknown): ActionState<never> {
return { success: false, message: e.message }
}
// 业务错误:消息可安全暴露
// 业务错误:消息可安全暴露;保留 code → errorCode 供前端 i18n 查找
if (e instanceof BusinessError) {
return { success: false, message: e.message }
return { success: false, message: e.message, errorCode: e.code }
}
// 未知错误:不暴露内部细节,仅记录服务端日志
if (e instanceof Error) {
console.error("[ActionError]", e.name, e.message, e.stack)
return { success: false, message: "操作失败,请稍后重试" }
return { success: false, message: "操作失败,请稍后重试", errorCode: "unexpected" }
}
console.error("[ActionError] Unknown error:", e)
return { success: false, message: "操作失败,请稍后重试" }
return { success: false, message: "操作失败,请稍后重试", errorCode: "unexpected" }
}
/**
@@ -135,6 +138,9 @@ export function safeJsonParse<T>(json: string, errorMessage: string): T {
/**
* 校验日期字符串是否有效,无效则抛出 ValidationError。
*
* `fieldName` 用于构造错误码 `invalid_date:{fieldName}`,前端通过 errorCode 查 i18n。
* message 保留中文兜底,前端应优先使用 errorCode 本地化。
*
* @returns 解析后的 Date 对象
*/
export function safeParseDate(value: string, fieldName: string): Date {

View File

@@ -5,9 +5,25 @@ import OpenAI from "openai"
import { extractMessageContent, type AiChatRequest } from "./payload-parser"
import { getAiProviderConfig } from "./provider-config"
/** AI 请求超时(毫秒) */
/** AI 请求超时(毫秒)— 用于普通非流式调用 */
const AI_TIMEOUT_MS = 30000
/**
* 测试连接超时(毫秒)
*
* 测试连接需要实际调用 AI 模型生成响应,部分 Provider如本地 Ollama
* 首次加载模型可能耗时 30s 以上,因此使用更长的超时时间。
*/
const AI_TEST_TIMEOUT_MS = 120000
/**
* 流式响应超时(毫秒)
*
* 流式生成内容(特别是长文本)可能需要较长时间,
* 此超时控制整个流式请求的总时长。
*/
const AI_STREAM_TIMEOUT_MS = 180000
/** 可重试的 HTTP 状态码429 限流 + 5xx 服务端错误) */
const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504])
@@ -17,12 +33,19 @@ const MAX_RETRIES = 2
/** 基础重试延迟(毫秒),实际延迟 = base * 2^attempt */
const RETRY_BASE_DELAY_MS = 1000
const getAiClient = async (config: { apiKey: string; baseUrl?: string }): Promise<OpenAI> => {
interface AiClientOptions {
apiKey: string
baseUrl?: string
/** 请求超时(毫秒),默认 AI_TIMEOUT_MS */
timeout?: number
}
const getAiClient = async (config: AiClientOptions): Promise<OpenAI> => {
const baseUrl = String(config.baseUrl ?? "https://api.openai.com").replace(/\/+$/, "")
return new OpenAI({
apiKey: config.apiKey,
baseURL: baseUrl.length ? baseUrl : undefined,
timeout: AI_TIMEOUT_MS,
timeout: config.timeout ?? AI_TIMEOUT_MS,
maxRetries: 0, // 由业务层控制重试
})
}
@@ -67,7 +90,11 @@ async function withRetry<T>(fn: () => Promise<T>): Promise<T> {
}
export const testAiProviderConfig = async (input: { apiKey: string; baseUrl?: string; model: string }): Promise<boolean> => {
const client = await getAiClient({ apiKey: input.apiKey, baseUrl: input.baseUrl })
const client = await getAiClient({
apiKey: input.apiKey,
baseUrl: input.baseUrl,
timeout: AI_TEST_TIMEOUT_MS,
})
const result = await client.chat.completions.create({
model: input.model,
messages: [{ role: "user", content: "ping" }],
@@ -84,7 +111,11 @@ export const testAiProviderById = async (
overrides?: { baseUrl?: string; model?: string }
): Promise<boolean> => {
const config = await getAiProviderConfig(providerId)
const client = await getAiClient({ apiKey: config.apiKey, baseUrl: overrides?.baseUrl ?? config.baseUrl })
const client = await getAiClient({
apiKey: config.apiKey,
baseUrl: overrides?.baseUrl ?? config.baseUrl,
timeout: AI_TEST_TIMEOUT_MS,
})
const result = await client.chat.completions.create({
model: overrides?.model ?? config.model,
messages: [{ role: "user", content: "ping" }],
@@ -127,13 +158,17 @@ export const createAiChatCompletion = async (input: AiChatRequest): Promise<{ co
* 用于 SSE 流式响应,降低用户感知延迟。
*
* 注意:流式调用不使用 withRetry因为流一旦开始无法重试。
* 超时由 OpenAI SDK 的 timeout 配置控制。
* 超时由 OpenAI SDK 的 timeout 配置控制(使用 AI_STREAM_TIMEOUT_MS
*/
export async function* createAiChatCompletionStream(
input: AiChatRequest
): AsyncGenerator<string, void, unknown> {
const config = await getAiProviderConfig(input.providerId)
const client = await getAiClient(config)
const client = await getAiClient({
apiKey: config.apiKey,
baseUrl: config.baseUrl,
timeout: AI_STREAM_TIMEOUT_MS,
})
const stream = await client.chat.completions.create({
model: config.model || input.model,
messages: input.messages,

View File

@@ -13,12 +13,15 @@ export type AiProviderConfig = {
apiKey: string
baseUrl?: string
model: string
/** Provider 类型标识(用于客户端特殊处理,如 Ollama 本地部署) */
provider?: string
}
type ProviderAccessRow = {
apiKeyEncrypted: string
baseUrl: string | null
model: string
provider: string
visibility: "public" | "private"
createdBy: string | null
}
@@ -74,11 +77,23 @@ function buildVisibilityFilter(userCtx: UserContext | null): SQL | undefined {
) ?? undefined
}
/** Ollama 默认 baseUrlOpenAI 兼容端点) */
const OLLAMA_DEFAULT_BASE_URL = "http://localhost:11434/v1"
/** Ollama 占位符 API Key本地部署无需真实密钥OpenAI SDK 需要非空值) */
const OLLAMA_PLACEHOLDER_API_KEY = "ollama"
function toConfig(row: ProviderAccessRow): AiProviderConfig {
const isOllama = row.provider === "ollama"
const apiKey = isOllama
? OLLAMA_PLACEHOLDER_API_KEY
: decryptAiApiKey(row.apiKeyEncrypted)
const baseUrl = row.baseUrl ?? (isOllama ? OLLAMA_DEFAULT_BASE_URL : undefined)
return {
apiKey: decryptAiApiKey(row.apiKeyEncrypted),
baseUrl: row.baseUrl ?? undefined,
apiKey,
baseUrl,
model: row.model,
provider: row.provider,
}
}
@@ -86,6 +101,7 @@ const selectColumns = {
apiKeyEncrypted: aiProviders.apiKeyEncrypted,
baseUrl: aiProviders.baseUrl,
model: aiProviders.model,
provider: aiProviders.provider,
visibility: aiProviders.visibility,
createdBy: aiProviders.createdBy,
} as const

View File

@@ -1,19 +1,30 @@
import type { Permission, DataScope, AuthContext, Role } from "@/shared/types/permissions"
import { db } from "@/shared/db"
import {
classes,
classEnrollments,
classSubjectTeachers,
grades,
parentStudentRelations,
} from "@/shared/db/schema"
import { eq, inArray, or } from "drizzle-orm"
import { isPermission } from "@/shared/lib/type-guards"
import { getSession } from "@/shared/lib/session"
import { PermissionDeniedError } from "@/shared/lib/errors"
// Re-export for backward compatibility (other modules still import from here)
export { PermissionDeniedError } from "@/shared/lib/errors"
/**
* Resolve the data scope for a user based on their roles.
*
* Delegates to the RBAC module's configuration-driven resolver via dynamic
* import, so the shared layer never statically depends on modules/*.
* This is the same pattern used by shared/lib/session.ts to break the
* shared ↔ auth circular dependency.
*
* P1-5/P1-6 audit fix: removed direct DB queries against classes,
* classEnrollments, classSubjectTeachers, grades, parentStudentRelations
* tables. The resolver now calls module data-access functions and is
* driven by a configuration array (DATA_SCOPE_RULES) instead of hardcoded
* role-name checks.
*/
async function resolveDataScope(userId: string, roleNames: Role[]): Promise<DataScope> {
const { resolveDataScopeFromConfig } = await import("@/modules/rbac/lib/data-scope-resolver")
return resolveDataScopeFromConfig(userId, roleNames)
}
/**
* Get the full authentication context for the current user.
* Throws if not authenticated.
@@ -24,8 +35,11 @@ export async function getAuthContext(): Promise<AuthContext> {
if (!userId) throw new PermissionDeniedError("auth_required")
// Prefer session data (already resolved in JWT callback)
const roleNames = (session.user.roles ?? []) as Role[]
const permissions = (session.user.permissions ?? []) as Permission[]
// Use type guards to safely coerce session values
const roleNames = (session.user.roles ?? []).filter(
(r): r is Role => typeof r === "string"
)
const permissions = (session.user.permissions ?? []).filter(isPermission)
// Resolve data scope from DB (not cached in JWT since it can change)
const dataScope = await resolveDataScope(userId, roleNames)
@@ -55,118 +69,6 @@ export async function checkPermission(
return { allowed: ctx.permissions.includes(permission), ctx }
}
/**
* Resolve the data scope for a user based on their roles.
* Queries the DB for resource ownership information.
*/
async function resolveDataScope(userId: string, roleNames: Role[]): Promise<DataScope> {
// Admin sees everything
if (roleNames.includes("admin")) {
return { type: "all" }
}
// Grade head / teaching head: can manage their grades
if (roleNames.includes("grade_head") || roleNames.includes("teaching_head")) {
const managedGrades = await db
.select({ id: grades.id })
.from(grades)
.where(or(eq(grades.gradeHeadId, userId), eq(grades.teachingHeadId, userId)))
if (managedGrades.length > 0) {
return { type: "grade_managed", gradeIds: managedGrades.map((g) => g.id) }
}
}
// Teacher: can see their own classes
if (roleNames.includes("teacher")) {
// Classes where user is the homeroom teacher
const homeroomClasses = await db
.select({ id: classes.id })
.from(classes)
.where(eq(classes.teacherId, userId))
// Classes where user is a subject teacher
const subjectClasses = await db
.selectDistinct({ classId: classSubjectTeachers.classId, subjectId: classSubjectTeachers.subjectId })
.from(classSubjectTeachers)
.where(eq(classSubjectTeachers.teacherId, userId))
const classIds = [
...new Set([
...homeroomClasses.map((c) => c.id),
...subjectClasses.map((c) => c.classId),
]),
]
const subjectIds = subjectClasses
.map((c) => c.subjectId)
.filter((s): s is string => s !== null)
return {
type: "class_taught",
classIds,
subjectIds: subjectIds.length > 0 ? subjectIds : undefined,
}
}
// Student: can see data from their enrolled classes
// 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, 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,
}
}
// Parent: can see their children's data
if (roleNames.includes("parent")) {
const children = await db
.select({ studentId: parentStudentRelations.studentId })
.from(parentStudentRelations)
.where(eq(parentStudentRelations.parentId, userId))
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
return { type: "owned", userId }
}
/**
* Convenience: assert the user is authenticated (has any role).
* Returns AuthContext on success.

View File

@@ -0,0 +1,125 @@
import "server-only"
/**
* Breached password detection via Have I Been Pwned API (audit-P2-4).
*
* 使用 k-anonymity 范围查询:仅发送 SHA-1 哈希前 5 个字符到 HIBP API
* API 返回该前缀下所有泄露密码的哈希后缀 + 出现次数。本地比对完整哈希后缀,
* 从而在不泄露用户密码的前提下判断是否为已泄露密码。
*
* 安全设计:
* - 仅发送 5 字符前缀HIBP 无法得知用户查询的完整密码哈希
* - 本地完成完整哈希比对,不发送完整哈希
* - API 失败时 fail-open允许密码避免外部依赖阻断注册流程
* - 带 3 秒超时 + 1 次重试,避免长时间阻塞
*
* 集成点:
* - `modules/auth/actions.ts` registerAction注册时校验
* - `modules/settings/actions-password.ts` changePasswordAction改密时校验
*/
const HIBP_API_URL = "https://api.pwnedpasswords.com/range"
const REQUEST_TIMEOUT_MS = 3000
export interface BreachedPasswordResult {
/** true 表示此密码已在已知数据泄露中出现,不应使用 */
isBreached: boolean
/** 该密码在 HIBP 数据库中被泄露的次数isBreached=false 时为 0 */
breachCount: number
/** true 表示因 API 不可用等原因无法完成检查fail-open允许通过 */
checkSkipped: boolean
/** checkSkipped=true 时的原因 */
reason?: string
}
/**
* 计算 SHA-1 哈希hex 格式,全大写)。
* 使用 Web Crypto APINode.js / Edge runtime 均可用)。
*/
async function sha1Hex(input: string): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(input)
const hashBuffer = await crypto.subtle.digest("SHA-1", data)
const hashArray = Array.from(new Uint8Array(hashBuffer))
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("").toUpperCase()
}
/**
* 带 timeout 的 fetchAbortController
*/
async function fetchWithTimeout(url: string, timeoutMs: number): Promise<Response> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
return await fetch(url, {
signal: controller.signal,
headers: { "User-Agent": "NextEdu-PasswordCheck/1.0" },
})
} finally {
clearTimeout(timer)
}
}
/**
* 查询 HIBP API 获取指定前缀下的所有泄露哈希后缀。
* 返回 Map<suffix, count>。
*/
async function queryHibpRange(prefix: string): Promise<Map<string, number>> {
const response = await fetchWithTimeout(`${HIBP_API_URL}/${prefix}`, REQUEST_TIMEOUT_MS)
if (!response.ok) {
throw new Error(`HIBP API returned ${response.status}`)
}
const text = await response.text()
const result = new Map<string, number>()
for (const line of text.split("\n")) {
const trimmed = line.trim()
if (!trimmed) continue
const colonIdx = trimmed.indexOf(":")
if (colonIdx === -1) continue
const suffix = trimmed.slice(0, colonIdx)
const count = parseInt(trimmed.slice(colonIdx + 1), 10)
if (!Number.isNaN(count)) {
result.set(suffix, count)
}
}
return result
}
/**
* 检查密码是否在已知数据泄露中出现。
*
* k-anonymity 流程:
* 1. 计算 SHA-1 哈希
* 2. 取前 5 字符作为前缀查询 HIBP API
* 3. API 返回该前缀下所有泄露后缀 + 次数
* 4. 本地比对完整哈希的后缀(第 6 字符起)
*
* 失败策略fail-open
* - API 不可用 / 超时 / 返回错误 → 返回 checkSkipped=true允许密码通过
* - 不应因第三方服务故障阻断用户注册/改密
*/
export async function checkBreachedPassword(
password: string,
): Promise<BreachedPasswordResult> {
const fullHash = await sha1Hex(password)
const prefix = fullHash.slice(0, 5)
const suffix = fullHash.slice(5)
try {
const rangeMap = await queryHibpRange(prefix)
const count = rangeMap.get(suffix) ?? 0
return {
isBreached: count > 0,
breachCount: count,
checkSkipped: false,
}
} catch (error) {
const reason = error instanceof Error ? error.message : "unknown error"
return {
isBreached: false,
breachCount: 0,
checkSkipped: true,
reason: `HIBP API unavailable: ${reason}`,
}
}
}

View File

@@ -0,0 +1,80 @@
/**
* 通用 CSV 导出工具(共享层)。
*
* 纯函数实现,便于单测。支持 CSV 格式导出,兼容 Excel含 UTF-8 BOM
* PDF 导出需要客户端库(如 jsPDF作为后续扩展点。
*/
/** 导出数据行类型 */
export interface ExportRow {
[key: string]: string | number | boolean | null
}
/** 导出列配置 */
export interface ExportColumn {
/** 数据字段名 */
key: string
/** 导出列标题 */
label: string
}
/**
* 将数据行数组转换为 CSV 字符串。
*
* - 添加 UTF-8 BOM 以支持 Excel 正确显示中文
* - 字段值包含逗号、换行、引号时自动转义
* - null/undefined 转为空字符串
*/
export function toCSV(rows: readonly ExportRow[], columns: readonly ExportColumn[]): string {
const BOM = "\uFEFF"
const escapeCell = (value: string | number | boolean | null): string => {
if (value === null || value === undefined) return ""
const str = String(value)
if (str.includes(",") || str.includes("\n") || str.includes('"')) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
const header = columns.map((c) => escapeCell(c.label)).join(",")
const body = rows
.map((row) => columns.map((c) => escapeCell(row[c.key] ?? null)).join(","))
.join("\n")
return `${BOM}${header}\n${body}`
}
/**
* 触发浏览器下载文件(客户端调用)。
*
* 创建临时 Blob URL 并模拟点击下载,
* 下载完成后自动清理 URL。
*/
export function downloadFile(content: string, filename: string, mimeType = "text/csv;charset=utf-8"): void {
const blob = new Blob([content], { type: mimeType })
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = filename
link.style.display = "none"
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
/**
* 导出数据为 CSV便捷方法
*
* @param rows 数据行
* @param columns 列配置
* @param filename 文件名(不含扩展名)
*/
export function exportCSV(
rows: readonly ExportRow[],
columns: readonly ExportColumn[],
filename: string,
): void {
const csv = toCSV(rows, columns)
downloadFile(csv, `${filename}.csv`)
}

View File

@@ -0,0 +1,226 @@
/**
* 权限位图编解码audit-P1-7
*
* 目的:将 67 个权限点字符串数组(约 1.1KB JSON压缩为 ~14 字符的 base36 字符串,
* 使 JWT cookie 体积降至可接受范围(远低于 4KB 限制)。
*
* 编码方案:
* - 权限点按 `PERMISSION_BITMAP_ORDER` 数组中的位置分配 bit0-66
* - 67 bit 打包为 BigInt转为 base36 字符串
* - base36 字符集 [0-9a-z]URL 安全,无需 padding
*
* 设计约束:
* - `PERMISSION_BITMAP_ORDER` 必须与 `Permissions` 枚举值一一对应
* - 顺序固定,新增权限只能追加到数组末尾(不破坏已有 bit 位映射)
* - 编解码为纯函数,可在 edge runtime 使用proxy.ts 直接调用)
*
* 注意:`BigInt` 不支持按 radix 解析字符串,需手工按位累加 base36 → BigInt。
* 切勿用 `parseInt(bitmap, 36)` —— `Number` 仅支持 53 bit 精度,
* 67 bit 的位图会丢失精度。
*/
import { Permissions, type Permission } from "@/shared/types/permissions"
const BASE36 = 36n
const BASE36_DIGITS = "0123456789abcdefghijklmnopqrstuvwxyz"
/**
* 将 base36 字符串解析为 BigInt。
* 输入仅接受 `[0-9a-z]`(大小写不敏感),无效字符抛出异常。
*/
function parseBase36ToBigInt(input: string): bigint {
let result = 0n
for (const ch of input.toLowerCase()) {
const digit = BASE36_DIGITS.indexOf(ch)
if (digit === -1) {
throw new Error(`Invalid base36 character: ${ch}`)
}
result = result * BASE36 + BigInt(digit)
}
return result
}
/**
* 权限位图的稳定顺序。
*
* **重要**:此数组的顺序一经确定即不可更改,新增权限只能追加到末尾。
* 顺序变更会导致已签发的 JWT 中权限位错位。
*
* 当前包含 67 个权限点,覆盖 `Permissions` 枚举的全部值。
*/
export const PERMISSION_BITMAP_ORDER: readonly Permission[] = [
// Exam
Permissions.EXAM_CREATE,
Permissions.EXAM_READ,
Permissions.EXAM_UPDATE,
Permissions.EXAM_DELETE,
Permissions.EXAM_DUPLICATE,
Permissions.EXAM_PUBLISH,
Permissions.EXAM_AI_GENERATE,
Permissions.EXAM_SUBMIT,
// Homework
Permissions.HOMEWORK_CREATE,
Permissions.HOMEWORK_GRADE,
Permissions.HOMEWORK_SUBMIT,
// Question
Permissions.QUESTION_CREATE,
Permissions.QUESTION_READ,
Permissions.QUESTION_UPDATE,
Permissions.QUESTION_DELETE,
// Textbook
Permissions.TEXTBOOK_CREATE,
Permissions.TEXTBOOK_READ,
Permissions.TEXTBOOK_UPDATE,
Permissions.TEXTBOOK_DELETE,
// Class
Permissions.CLASS_CREATE,
Permissions.CLASS_READ,
Permissions.CLASS_UPDATE,
Permissions.CLASS_DELETE,
Permissions.CLASS_ENROLL,
Permissions.CLASS_SCHEDULE,
// School management
Permissions.SCHOOL_MANAGE,
Permissions.GRADE_MANAGE,
Permissions.USER_MANAGE,
Permissions.USER_PROFILE_UPDATE,
// AI
Permissions.AI_CHAT,
Permissions.AI_CONFIGURE,
// Settings
Permissions.SETTINGS_ADMIN,
// Audit
Permissions.AUDIT_LOG_READ,
// Announcement
Permissions.ANNOUNCEMENT_MANAGE,
Permissions.ANNOUNCEMENT_READ,
// Grade Record
Permissions.GRADE_RECORD_MANAGE,
Permissions.GRADE_RECORD_READ,
// File
Permissions.FILE_UPLOAD,
Permissions.FILE_READ,
Permissions.FILE_DELETE,
// Course Plan
Permissions.COURSE_PLAN_MANAGE,
Permissions.COURSE_PLAN_READ,
// Attendance
Permissions.ATTENDANCE_MANAGE,
Permissions.ATTENDANCE_READ,
// Leave Request
Permissions.LEAVE_REQUEST_CREATE,
Permissions.LEAVE_REQUEST_READ,
Permissions.LEAVE_REQUEST_REVIEW,
// Message
Permissions.MESSAGE_SEND,
Permissions.MESSAGE_READ,
Permissions.MESSAGE_DELETE,
// Scheduling
Permissions.SCHEDULE_AUTO,
Permissions.SCHEDULE_ADJUST,
// Elective
Permissions.ELECTIVE_MANAGE,
Permissions.ELECTIVE_READ,
Permissions.ELECTIVE_SELECT,
// Exam Proctoring
Permissions.EXAM_PROCTOR,
Permissions.EXAM_PROCTOR_READ,
// Diagnostic
Permissions.DIAGNOSTIC_MANAGE,
Permissions.DIAGNOSTIC_READ,
// Lesson Plan
Permissions.LESSON_PLAN_CREATE,
Permissions.LESSON_PLAN_READ,
Permissions.LESSON_PLAN_UPDATE,
Permissions.LESSON_PLAN_DELETE,
Permissions.LESSON_PLAN_PUBLISH,
// Dashboard
Permissions.DASHBOARD_ADMIN_READ,
Permissions.DASHBOARD_TEACHER_READ,
Permissions.DASHBOARD_STUDENT_READ,
Permissions.DASHBOARD_PARENT_READ,
// Error Book
Permissions.ERROR_BOOK_READ,
Permissions.ERROR_BOOK_MANAGE,
Permissions.ERROR_BOOK_ANALYTICS_READ,
// Adaptive Practice
Permissions.ADAPTIVE_PRACTICE_READ,
Permissions.ADAPTIVE_PRACTICE_MANAGE,
// RBAC
Permissions.ROLE_CREATE,
Permissions.ROLE_READ,
Permissions.ROLE_UPDATE,
Permissions.ROLE_DELETE,
Permissions.ROLE_ASSIGN,
Permissions.PERMISSION_READ,
] as const
/** 权限 → bit 位的映射表(懒构造,进程级复用) */
const permissionToBit = new Map<Permission, number>(
PERMISSION_BITMAP_ORDER.map((p, i) => [p, i]),
)
/**
* 将权限数组编码为 base36 位图字符串。
*
* 例如 admin 的 67 个权限编码后约为 14 字符的字符串,
* 相比 JSON 数组的 ~1.1KB 体积减少约 99%。
*
* 未知权限(不在 `PERMISSION_BITMAP_ORDER` 中)会被静默忽略,
* 避免新增权限但位图未更新时抛错。
*/
export function encodePermissionsBitmap(permissions: Permission[]): string {
let bits = 0n
for (const p of permissions) {
const bit = permissionToBit.get(p)
if (bit !== undefined) {
bits |= 1n << BigInt(bit)
}
}
if (bits === 0n) return "0"
return bits.toString(36)
}
/**
* 将 base36 位图字符串解码为权限数组。
*
* 无效或无法识别的字符返回空数组(容错处理)。
*/
export function decodePermissionsBitmap(bitmap: string): Permission[] {
if (!bitmap || bitmap === "0") return []
try {
const bits = parseBase36ToBigInt(bitmap)
if (bits === 0n) return []
const result: Permission[] = []
for (let i = 0; i < PERMISSION_BITMAP_ORDER.length; i++) {
if ((bits & (1n << BigInt(i))) !== 0n) {
result.push(PERMISSION_BITMAP_ORDER[i])
}
}
return result
} catch {
return []
}
}
/**
* 检查位图中是否包含指定权限,无需完整解码。
*
* 用于 proxy.ts 等对性能敏感的场景,避免每次路由检查都解码全部权限。
*/
export function hasPermissionInBitmap(
bitmap: string,
permission: Permission,
): boolean {
const bit = permissionToBit.get(permission)
if (bit === undefined || !bitmap || bitmap === "0") return false
try {
const bits = parseBase36ToBigInt(bitmap)
return (bits & (1n << BigInt(bit))) !== 0n
} catch {
return false
}
}

View File

@@ -1,8 +1,21 @@
import { Permissions, type Permission, type Role } from "@/shared/types/permissions"
import { Permissions, type Permission, type BuiltinRole } from "@/shared/types/permissions"
import { isPermission } from "@/shared/lib/type-guards"
import { db } from "@/shared/db"
import { roles, rolePermissions } from "@/shared/db/schema"
import { and, eq, inArray } from "drizzle-orm"
// Role → Permission mapping
// New roles only need to add an entry here + seed the DB
export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
/**
* Seed role → permission mapping for the 6 builtin roles.
*
* Used by:
* - The seed migration to populate the `role_permissions` table.
* - `resolvePermissions()` as a fallback when the DB query fails (e.g. during
* initial bootstrap before the migration has run).
*
* Runtime permission resolution reads from the DB — this constant is NOT
* consulted at runtime except as a fallback.
*/
export const ROLE_PERMISSIONS_SEED: Record<BuiltinRole, Permission[]> = {
admin: [
Permissions.EXAM_CREATE,
Permissions.EXAM_READ,
@@ -59,12 +72,22 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
Permissions.LESSON_PLAN_UPDATE,
Permissions.LESSON_PLAN_DELETE,
Permissions.LESSON_PLAN_PUBLISH,
Permissions.STANDARD_READ,
Permissions.STANDARD_MANAGE,
Permissions.STANDARD_LINK,
Permissions.FILE_UPLOAD,
Permissions.FILE_READ,
Permissions.FILE_DELETE,
Permissions.DASHBOARD_ADMIN_READ,
Permissions.ERROR_BOOK_ANALYTICS_READ,
Permissions.ADAPTIVE_PRACTICE_READ,
// RBAC management — admin only by default
Permissions.ROLE_CREATE,
Permissions.ROLE_READ,
Permissions.ROLE_UPDATE,
Permissions.ROLE_DELETE,
Permissions.ROLE_ASSIGN,
Permissions.PERMISSION_READ,
],
teacher: [
Permissions.EXAM_CREATE,
@@ -108,6 +131,8 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
Permissions.LESSON_PLAN_UPDATE,
Permissions.LESSON_PLAN_DELETE,
Permissions.LESSON_PLAN_PUBLISH,
Permissions.STANDARD_READ,
Permissions.STANDARD_LINK,
Permissions.DASHBOARD_TEACHER_READ,
Permissions.ERROR_BOOK_ANALYTICS_READ,
Permissions.ADAPTIVE_PRACTICE_READ,
@@ -143,6 +168,7 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
Permissions.TEXTBOOK_READ,
Permissions.CLASS_READ,
Permissions.USER_PROFILE_UPDATE,
Permissions.AI_CHAT,
Permissions.ANNOUNCEMENT_READ,
Permissions.GRADE_RECORD_READ,
Permissions.ATTENDANCE_READ,
@@ -232,13 +258,53 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
}
/**
* Merge permissions from all roles (deduplicated)
* @deprecated Use `ROLE_PERMISSIONS_SEED` instead. Kept as a re-export for
* backward compatibility with any code that still imports `ROLE_PERMISSIONS`.
*/
export function resolvePermissions(roleNames: Role[]): Permission[] {
const set = new Set<Permission>()
for (const name of roleNames) {
const perms = ROLE_PERMISSIONS[name] ?? []
for (const p of perms) set.add(p)
export const ROLE_PERMISSIONS = ROLE_PERMISSIONS_SEED
/**
* Merge permissions from all roles by querying the `role_permissions` table.
*
* - Only enabled roles contribute permissions (`roles.is_enabled = true`).
* - Falls back to `ROLE_PERMISSIONS_SEED` for builtin roles if the DB query
* fails (e.g. during initial bootstrap before the migration has run).
* - Deduplicates the resulting permission list.
*/
export async function resolvePermissions(roleNames: string[]): Promise<Permission[]> {
if (roleNames.length === 0) return []
try {
const rows = await db
.select({ permission: rolePermissions.permission })
.from(rolePermissions)
.innerJoin(roles, eq(rolePermissions.roleId, roles.id))
.where(and(inArray(roles.name, roleNames), eq(roles.isEnabled, true)))
const set = new Set<Permission>()
for (const row of rows) {
if (isPermission(row.permission)) {
set.add(row.permission)
}
}
// Fallback: if the DB returned nothing for builtin roles (e.g. migration
// not yet applied), use the seed constant so login still works.
if (set.size === 0) {
for (const name of roleNames) {
const seed = ROLE_PERMISSIONS_SEED[name as BuiltinRole]
if (seed) for (const p of seed) set.add(p)
}
}
return Array.from(set)
} catch {
// DB unavailable (e.g. during build) — fall back to seed for builtin roles
const set = new Set<Permission>()
for (const name of roleNames) {
const seed = ROLE_PERMISSIONS_SEED[name as BuiltinRole]
if (seed) for (const p of seed) set.add(p)
}
return Array.from(set)
}
return Array.from(set)
}

View File

@@ -22,6 +22,51 @@ export const getQuestionText = (content: unknown): string => {
return typeof content.text === "string" ? content.text : ""
}
/**
* 从题目内容中递归提取纯文本预览。
*
* 支持以下内容格式:
* - 字符串:直接返回(可截断)
* - 数组:递归提取每个节点的 text 和 children拼接为纯文本
* - 对象:提取 text 属性
* - 其他:返回 fallback
*
* @param content 题目内容unknown 类型)
* @param fallback 无法提取时的回退文本,默认空字符串
* @param maxLength 最大长度0 表示不截断,默认 0
* @returns 提取的纯文本
*/
export function extractQuestionPreview(
content: unknown,
fallback = "",
maxLength = 0,
): string {
const text = extractTextRecursive(content)
if (text) {
return maxLength > 0 ? text.slice(0, maxLength) : text
}
return fallback
}
function extractTextRecursive(content: unknown): string {
if (typeof content === "string") return content
if (Array.isArray(content)) {
const parts: string[] = []
for (const node of content) {
const text = extractTextRecursive(node)
if (text) parts.push(text)
}
return parts.join("")
}
if (isRecord(content)) {
if (typeof content.text === "string") return content.text
if (Array.isArray(content.children)) {
return extractTextRecursive(content.children)
}
}
return ""
}
export const getOptions = (content: unknown): QuestionOption[] => {
if (!isRecord(content)) return []
const raw = content.options

View File

@@ -1,119 +0,0 @@
/**
* In-memory rate limiter (single-instance only).
*
* For multi-instance deployments, replace with @upstash/ratelimit +
* @upstash/redis. The API below mirrors the upstash `Ratelimiter.limit`
* shape so the swap is straightforward.
*
* Entries are pruned lazily on each call to keep the Map bounded.
*/
interface RateLimitEntry {
count: number
resetTime: number
}
const rateLimitMap = new Map<string, RateLimitEntry>()
/** Prune entries whose window has elapsed. Called on every limit check. */
function pruneExpired(now: number) {
if (rateLimitMap.size === 0) return
for (const [key, entry] of rateLimitMap.entries()) {
if (entry.resetTime <= now) {
rateLimitMap.delete(key)
}
}
}
export interface RateLimitResult {
success: boolean
remaining: number
resetTime: number
/** Milliseconds until the window resets (0 when already reset). */
retryAfterMs: number
}
export interface RateLimitParams {
/** Unique identifier for the bucket (e.g. `login:${ip}` or `ai:${userId}`). */
key: string
/** Maximum number of requests allowed within the window. */
limit: number
/** Window size in milliseconds. */
windowMs: number
}
/**
* Check whether a request should be allowed under the given rate limit.
* Increments the counter regardless of success (so repeated failures
* accumulate).
*/
export function rateLimit(params: RateLimitParams): RateLimitResult {
const now = Date.now()
pruneExpired(now)
const existing = rateLimitMap.get(params.key)
if (!existing || existing.resetTime <= now) {
// Start a fresh window
const resetTime = now + params.windowMs
rateLimitMap.set(params.key, { count: 1, resetTime })
return {
success: true,
remaining: params.limit - 1,
resetTime,
retryAfterMs: 0,
}
}
// Within an existing window
existing.count += 1
const remaining = Math.max(0, params.limit - existing.count)
const success = existing.count <= params.limit
const retryAfterMs = success ? 0 : existing.resetTime - now
return {
success,
remaining,
resetTime: existing.resetTime,
retryAfterMs,
}
}
/**
* Reset the counter for a key. Useful when a successful action should
* clear the failure count (e.g. successful login clears LOGIN limit).
*/
export function resetRateLimit(key: string): void {
rateLimitMap.delete(key)
}
/**
* Predefined rate limit rules for common scenarios.
* Times are in milliseconds.
*/
export const RATE_LIMIT_RULES = {
LOGIN: { limit: 5, windowMs: 15 * 60 * 1000 }, // 5 attempts per 15 minutes
API: { limit: 100, windowMs: 60 * 1000 }, // 100 requests per minute
UPLOAD: { limit: 10, windowMs: 60 * 1000 }, // 10 uploads per minute
AI_CHAT: { limit: 20, windowMs: 60 * 1000 }, // 20 chats per minute
PASSWORD_CHANGE: { limit: 5, windowMs: 60 * 1000 }, // 5 attempts per minute
} as const
/**
* Build a rate-limit key from a prefix and identifier.
*/
export function rateLimitKey(prefix: string, identifier: string): string {
return `${prefix}:${identifier}`
}
/**
* Convert a RateLimitResult into standard HTTP response headers.
*/
export function rateLimitHeaders(result: RateLimitResult): Record<string, string> {
return {
"X-RateLimit-Limit": String(result.remaining + (result.success ? 1 : 0)),
"X-RateLimit-Remaining": String(result.remaining),
"X-RateLimit-Reset": String(Math.ceil(result.resetTime / 1000)),
...(result.success ? {} : { "Retry-After": String(Math.ceil(result.retryAfterMs / 1000)) }),
}
}

View File

@@ -0,0 +1,71 @@
/**
* 速率限制器门面audit-P1-6
*
* 公共 API 与原 `rate-limit.ts` 单文件保持一致,差异:
* - `rateLimit()` 与 `resetRateLimit()` 改为 `Promise` 返回,以支持异步后端
* - 内部根据 `RATE_LIMIT_DRIVER` 环境变量选择实现:
* - `memory`(默认):单实例内存滑动窗口
* - `redis`:基于 @upstash/ratelimit + @upstash/redis 的分布式实现
* - 调用方需 `await rateLimit(...)` / `await resetRateLimit(...)`
*
* `rateLimitKey` / `RATE_LIMIT_RULES` / `rateLimitHeaders` 保持同步纯函数。
*/
import { env } from "@/env.mjs"
import type { RateLimiter, RateLimitParams, RateLimitResult } from "./types"
export type { RateLimiter, RateLimitParams, RateLimitResult } from "./types"
export { RATE_LIMIT_RULES, rateLimitHeaders, rateLimitKey } from "./rules"
import { MemoryRateLimiter } from "./memory-limiter"
import { RedisRateLimiter } from "./redis-limiter"
/**
* 单例限流器实例(按 env 选择实现,进程级复用)。
*
* RedisRateLimiter 类本身在 import 时不会加载 @upstash/* 依赖——
* 重依赖通过类方法内的 `await import(...)` 懒加载,
* 仅当 `RATE_LIMIT_DRIVER=redis` 且实际调用 `limit()` 时才加载。
*/
let singleton: RateLimiter | null = null
/**
* 获取当前进程的限流器实例。
*
* - 默认返回内存实现
* - 当 `RATE_LIMIT_DRIVER=redis` 时返回 Redis 实现
* - Redis 实现懒加载所需依赖,未安装 @upstash/ratelimit/redis 时首次调用抛错
*/
export function getRateLimiter(): RateLimiter {
if (singleton) return singleton
if (env.RATE_LIMIT_DRIVER === "redis") {
singleton = new RedisRateLimiter()
} else {
singleton = new MemoryRateLimiter()
}
return singleton
}
/**
* 检查请求是否应被允许。
*
* 无论成功失败均累加计数(便于失败次数累计锁定)。
*
* **变更**:返回 `Promise<RateLimitResult>`,调用方需 `await`。
*/
export function rateLimit(params: RateLimitParams): Promise<RateLimitResult> {
return getRateLimiter().limit(params)
}
/**
* 重置指定 key 的计数。
*
* 用于成功登录后清除失败计数等场景。
*
* **变更**:返回 `Promise<void>`,调用方需 `await`。
*/
export function resetRateLimit(key: string): Promise<void> {
return getRateLimiter().reset(key)
}

View File

@@ -0,0 +1,78 @@
/**
* 内存滑动窗口速率限制器audit-P1-6
*
* 默认实现,无需额外依赖。
*
* 适用场景:
* - 单实例部署(开发环境、小型站点)
* - 进程内限流(不跨实例共享计数)
*
* 不适用场景:
* - 多实例部署K8s 水平扩容、Serverless 多实例)
* - 需要持久化计数的场景
*
* 多实例部署时请通过 `RATE_LIMIT_DRIVER=redis` 切换至 `RedisRateLimiter`。
*/
import type { RateLimiter, RateLimitParams, RateLimitResult } from "./types"
interface RateLimitEntry {
count: number
resetTime: number
}
/**
* 内存实现的速率限制器。
*
* 使用单个进程级 `Map` 存储计数,懒清理过期项保持 Map 有界。
* 虽然内部操作是同步的,但 `limit` / `reset` 仍返回 Promise以兼容 `RateLimiter` 接口。
*/
export class MemoryRateLimiter implements RateLimiter {
private readonly store = new Map<string, RateLimitEntry>()
/** 清理已过期的桶,避免 Map 无限增长。每次 `limit` 调用时执行。 */
private pruneExpired(now: number): void {
if (this.store.size === 0) return
for (const [key, entry] of this.store.entries()) {
if (entry.resetTime <= now) {
this.store.delete(key)
}
}
}
async limit(params: RateLimitParams): Promise<RateLimitResult> {
const now = Date.now()
this.pruneExpired(now)
const existing = this.store.get(params.key)
if (!existing || existing.resetTime <= now) {
// 新窗口
const resetTime = now + params.windowMs
this.store.set(params.key, { count: 1, resetTime })
return {
success: true,
remaining: params.limit - 1,
resetTime,
retryAfterMs: 0,
}
}
// 窗口内累加计数
existing.count += 1
const remaining = Math.max(0, params.limit - existing.count)
const success = existing.count <= params.limit
const retryAfterMs = success ? 0 : existing.resetTime - now
return {
success,
remaining,
resetTime: existing.resetTime,
retryAfterMs,
}
}
async reset(key: string): Promise<void> {
this.store.delete(key)
}
}

View File

@@ -0,0 +1,193 @@
/**
* Redis 分布式速率限制器audit-P1-6
*
* 基于 `@upstash/ratelimit` + `@upstash/redis` 的滑动窗口实现。
*
* 适用场景:
* - 多实例部署K8s 水平扩容、Serverless 多实例)
* - 需要跨实例共享计数的限流
*
* 使用前提:
* - 项目需安装 `@upstash/ratelimit` 与 `@upstash/redis`
* - 环境变量需配置 `UPSTASH_REDIS_REST_URL` 与 `UPSTASH_REDIS_REST_TOKEN`
*
* 依赖通过动态 import 加载,未启用 Redis 时不会引入这两个包的体积。
* 若启用但未安装依赖,将在首次调用时抛错。
*
* upstash/ratelimit 的 `Ratelimit` 实例在构造时需固定 `max` 与 `window`
* 因此本实现按 `${limit}:${windowMs}` 签名缓存多个 Ratelimit 实例,
* 同一规则的所有调用复用同一实例。
*/
import { env } from "@/env.mjs"
import type { RateLimiter, RateLimitParams, RateLimitResult } from "./types"
/** 毫秒转 upstash `Ratelimit.slidingWindow` 接受的字符串时间单位 */
function msToSlidingWindowArg(ms: number): string {
if (ms % (60 * 1000) === 0) {
const minutes = ms / (60 * 1000)
return `${minutes} m`
}
if (ms % 1000 === 0) {
const seconds = ms / 1000
return `${seconds} s`
}
return `${ms} ms`
}
/** upstash ratelimit 调用返回的结果类型(最小可用子集) */
interface UpstashRatelimitResult {
success: boolean
remaining: number
reset: number
}
/** upstash Ratelimit 实例的最小可用接口 */
interface UpstashRatelimitInstance {
limit: (identifier: string) => Promise<UpstashRatelimitResult>
reset?: (identifier: string) => Promise<void>
}
/** 动态加载后获得的 Ratelimit 构造器 */
interface UpstashRatelimitCtor {
new (config: {
redis: unknown
limiter: unknown
analytics?: boolean
prefix?: string
}): UpstashRatelimitInstance
slidingWindow: (
max: number,
window: string,
) => { type: "sliding-window"; max: number; window: string }
}
/**
* Redis 实现的速率限制器。
*
* 通过动态 import 加载 `@upstash/ratelimit` 与 `@upstash/redis`
* 避免在内存模式下引入这两个包的体积。
*
* 故障策略:
* - 若 Redis 调用抛错,返回 `{ success: true, remaining: limit - 1 }` 兜底,
* 避免限流后端故障导致主流程被阻断(限流降级原则)
* - 配置缺失(未设置 UPSTASH_REDIS_REST_URL/TOKEN时在构造时即抛错
*/
export class RedisRateLimiter implements RateLimiter {
/** 按 `${limit}:${windowMs}` 缓存 Ratelimit 实例,避免重复构造 */
private readonly instances = new Map<string, UpstashRatelimitInstance>()
/** 共享的 Redis 客户端,避免重复连接 */
private redisClient: unknown = null
/** 加载 Ratelimit 构造器的 Promise懒加载 */
private ratelimitCtorPromise: Promise<UpstashRatelimitCtor> | null = null
constructor() {
if (!env.UPSTASH_REDIS_REST_URL || !env.UPSTASH_REDIS_REST_TOKEN) {
throw new Error(
"RedisRateLimiter requires UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN. " +
"Set RATE_LIMIT_DRIVER=memory to use in-memory limiter instead.",
)
}
}
/** 懒加载 @upstash/redis 的 Redis 客户端 */
private async getRedisClient(): Promise<unknown> {
if (this.redisClient) return this.redisClient
const { Redis } = await import("@upstash/redis")
this.redisClient = new Redis({
url: env.UPSTASH_REDIS_REST_URL!,
token: env.UPSTASH_REDIS_REST_TOKEN!,
})
return this.redisClient
}
/** 懒加载 @upstash/ratelimit 的 Ratelimit 构造器 */
private async getRatelimitCtor(): Promise<UpstashRatelimitCtor> {
if (this.ratelimitCtorPromise) return this.ratelimitCtorPromise
this.ratelimitCtorPromise = (async () => {
const mod = await import("@upstash/ratelimit")
return mod.Ratelimit as unknown as UpstashRatelimitCtor
})()
return this.ratelimitCtorPromise
}
/** 按 (limit, windowMs) 获取或创建 Ratelimit 实例 */
private async getRatelimit(
params: RateLimitParams,
): Promise<UpstashRatelimitInstance> {
const signature = `${params.limit}:${params.windowMs}`
const existing = this.instances.get(signature)
if (existing) return existing
const [Ratelimit, redis] = await Promise.all([
this.getRatelimitCtor(),
this.getRedisClient(),
])
const instance = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(
params.limit,
msToSlidingWindowArg(params.windowMs),
),
analytics: false,
// 加 prefix 避免与其它用途的 Redis key 冲突
prefix: "next-edu:rl",
})
this.instances.set(signature, instance)
return instance
}
async limit(params: RateLimitParams): Promise<RateLimitResult> {
try {
const ratelimit = await this.getRatelimit(params)
const result = await ratelimit.limit(params.key)
const now = Date.now()
const resetTime =
typeof result.reset === "number" && result.reset > 0
? result.reset * 1000
: now + params.windowMs
const retryAfterMs = result.success ? 0 : Math.max(0, resetTime - now)
return {
success: result.success,
remaining: result.remaining,
resetTime,
retryAfterMs,
}
} catch (error) {
// 限流后端故障时降级为放行,避免阻断主流程
console.error(
"[rate-limit] Redis backend failure, falling back to allow:",
error instanceof Error ? error.message : String(error),
)
const resetTime = Date.now() + params.windowMs
return {
success: true,
remaining: Math.max(0, params.limit - 1),
resetTime,
retryAfterMs: 0,
}
}
}
async reset(key: string): Promise<void> {
try {
// 找到包含该 key 的实例实例按规则签名缓存key 可能命中多个)
// 由于 upstash/ratelimit 的 reset 接受 identifier遍历所有实例重置
for (const instance of this.instances.values()) {
if (typeof instance.reset === "function") {
await instance.reset(key)
}
}
} catch (error) {
// reset 失败不阻断主流程
console.error(
"[rate-limit] Redis reset failure, ignoring:",
error instanceof Error ? error.message : String(error),
)
}
}
}

View File

@@ -0,0 +1,67 @@
/**
* 速率限制纯函数与常量audit-P1-6
*
* 此文件仅包含与具体后端实现无关的纯函数和常量:
* - `RATE_LIMIT_RULES`:常用场景的预定义规则
* - `rateLimitKey`:限流 key 构造器
* - `rateLimitHeaders`:将结果转换为标准 HTTP 限流响应头
*
* 这些函数不依赖任何后端实现,可在所有 runtime含 edge使用。
*/
import type { RateLimitResult } from "./types"
/**
* 预定义的速率限制规则。
*
* 时间单位均为毫秒。
*/
export const RATE_LIMIT_RULES = {
LOGIN: { limit: 5, windowMs: 15 * 60 * 1000 }, // 15 分钟内 5 次
API: { limit: 100, windowMs: 60 * 1000 }, // 每分钟 100 次
UPLOAD: { limit: 10, windowMs: 60 * 1000 }, // 每分钟 10 次
AI_CHAT: { limit: 20, windowMs: 60 * 1000 }, // 每分钟 20 次
PASSWORD_CHANGE: { limit: 5, windowMs: 60 * 1000 }, // 每分钟 5 次
/**
* audit-P1-8家长绑定子女速率限制。
*
* 三因子验证(生日 365 × 手机后4 10000 = 3.65M 组合)虽然空间大,
* 但单次 onboarding Action 内可循环调用 10 次children.length ≤ 10
* 无独立速率限制时被撤销子女关系的家长可反复尝试枚举绑定。
*
* 限制每小时 5 次5 次完整 onboarding 提交,每次最多 10 个子女 = 50 次尝试/小时),
* 远低于枚举 3.65M 组合所需量级,同时不误伤正常用户(家长极少在 1 小时内反复提交)。
*/
ONBOARDING_BIND: { limit: 5, windowMs: 60 * 60 * 1000 }, // 每小时 5 次
} as const
/**
* 由前缀与标识符构造限流 key。
*
* 例如 `rateLimitKey("login", "1.2.3.4:user@example.com")` -> `"login:1.2.3.4:user@example.com"`。
*/
export function rateLimitKey(prefix: string, identifier: string): string {
return `${prefix}:${identifier}`
}
/**
* 将限流结果转换为标准 HTTP 限流响应头。
*
* 符合 IETF draft-ietf-httpapi-ratelimit-headers 规范:
* - `X-RateLimit-Limit`:窗口内最大次数
* - `X-RateLimit-Remaining`:剩余可用次数
* - `X-RateLimit-Reset`窗口重置时间Unix 秒)
* - `Retry-After`:失败时距重置的秒数
*/
export function rateLimitHeaders(
result: RateLimitResult,
): Record<string, string> {
return {
"X-RateLimit-Limit": String(result.remaining + (result.success ? 1 : 0)),
"X-RateLimit-Remaining": String(result.remaining),
"X-RateLimit-Reset": String(Math.ceil(result.resetTime / 1000)),
...(result.success
? {}
: { "Retry-After": String(Math.ceil(result.retryAfterMs / 1000)) }),
}
}

View File

@@ -0,0 +1,44 @@
/**
* 速率限制器接口与共享类型定义audit-P1-6
*
* 设计目标:
* - 抽象 `RateLimiter` 接口,使限流后端可在内存与 Redis 之间切换
* - 默认内存实现,无需引入额外依赖
* - 通过 `RATE_LIMIT_DRIVER=redis` 切换至分布式实现,适配多实例部署
* - 公共 API`rateLimit` / `resetRateLimit`)统一返回 Promise保证实现可替换
*/
/** 单次限流检查的入参(与具体后端无关) */
export interface RateLimitParams {
/** 限流桶的唯一标识,例如 `login:${ip}` 或 `ai:${userId}` */
key: string
/** 窗口内允许的最大请求次数 */
limit: number
/** 窗口长度(毫秒) */
windowMs: number
}
/** 限流检查结果(与 upstash `RatelimitResult` 形状兼容,便于平滑切换) */
export interface RateLimitResult {
success: boolean
remaining: number
resetTime: number
/** 距离窗口重置的毫秒数;已重置时为 0 */
retryAfterMs: number
}
/**
* 速率限制器抽象接口。
*
* 所有实现必须为 Promise 返回,以便 Redis 等异步后端无缝接入。
* 实现需保证:
* - `limit()` 无论成功失败均累加计数(便于失败次数累计锁定)
* - `reset()` 幂等,重复调用不抛错
* - 不抛错:失败时返回 `{ success: true }` 兜底,避免限流故障阻断主流程
*/
export interface RateLimiter {
/** 检查并累加请求计数;返回当前窗口状态 */
limit(params: RateLimitParams): Promise<RateLimitResult>
/** 重置指定 key 的计数(例如登录成功后清除失败计数) */
reset(key: string): Promise<void>
}

View File

@@ -0,0 +1,69 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Upstash SDK 可选依赖类型声明audit-P1-6
*
* 这两个包通过动态 import 在运行时加载,仅在 `RATE_LIMIT_DRIVER=redis`
* 时才需要安装。默认内存模式下无需安装。
*
* 安装命令:
* npm install @upstash/ratelimit @upstash/redis
*
* 安装后这两个包自带的类型声明将覆盖此处的 any 声明,
* 提供完整的类型检查。
*/
declare module "@upstash/ratelimit" {
export class Ratelimit {
constructor(config: {
redis: any
limiter: any
analytics?: boolean
prefix?: string
})
limit(
identifier: string,
): Promise<{
success: boolean
limit: number
remaining: number
reset: number
pending: Promise<unknown>
}>
reset(identifier: string): Promise<void>
static slidingWindow(
max: number,
window: string,
): { type: "sliding-window"; max: number; window: string }
static fixedWindow(
max: number,
window: string,
): { type: "fixed-window"; max: number; window: string }
static tokenBucket(
max: number,
window: string,
refillRate: number,
): { type: "token-bucket"; max: number; window: string; refillRate: number }
}
}
declare module "@upstash/redis" {
export class Redis {
constructor(config: {
url: string
token: string
automaticDeserialization?: boolean
responseEncoding?: "base64" | "none"
retry?: {
retries: number
backoff: (retryCount: number) => number
}
})
get(key: string): Promise<string | null>
set(key: string, value: string): Promise<"OK">
del(...keys: string[]): Promise<number>
incr(key: string): Promise<number>
expire(key: string, seconds: number): Promise<number>
pexpire(key: string, milliseconds: number): Promise<number>
[key: string]: any
}
}

View File

@@ -0,0 +1,60 @@
"use client"
import type { ActionState } from "@/shared/types/action-state"
/**
* 根据 ActionState 的 errorCode 解析本地化错误消息。
*
* 优先级:
* 1. errorCode 以 `invalid_date:` 开头 → 查 `errors.invalid_{field}` i18n 键
* 2. errorCode 匹配已知通用码unexpected/validation_error/not_found/permission_denied→ 查对应 i18n 键
* 3. 回退到 result.message
* 4. 最终回退到 fallback
*
* @example
* ```tsx
* const t = useTranslations("attendance")
* const msg = resolveActionError(result, t, t("errors.unexpected"))
* toast.error(msg)
* ```
*/
export function resolveActionError<T>(
result: ActionState<T> | null,
t: (key: string) => string,
fallback: string
): string {
if (!result) return fallback
const code = result.errorCode
// 日期格式错误invalid_date:date / invalid_date:startDate / invalid_date:endDate
if (code?.startsWith("invalid_date:")) {
const field = code.split(":")[1]
const key = `errors.invalid_${field}`
try {
const translated = t(key)
if (translated && translated !== key) return translated
} catch {
// 键不存在,回退
}
}
// 通用错误码映射
const codeMap: Record<string, string> = {
unexpected: "errors.unexpected",
validation_error: "errors.invalidForm",
not_found: "errors.notFound",
permission_denied: "errors.insufficientPermissions",
}
if (code && codeMap[code]) {
try {
const translated = t(codeMap[code])
if (translated && translated !== codeMap[code]) return translated
} catch {
// 键不存在,回退
}
}
return result.message || fallback
}

View File

@@ -1,34 +1,50 @@
/**
* Role normalization utilities (pure functions).
*
* These helpers map various role names (including legacy aliases) to the
* canonical K12 role set: admin / teacher / student / parent.
* These helpers map various role names to the canonical K12 role set.
* `grade_head` and `teaching_head` are preserved as distinct roles (they
* carry different data-scope semantics from `teacher`).
*/
export type NormalizedRole = "admin" | "teacher" | "student" | "parent"
export type NormalizedRole =
| "admin"
| "grade_head"
| "teaching_head"
| "teacher"
| "student"
| "parent"
/**
* Normalize a single role value to one of the canonical roles.
* Legacy aliases such as `grade_head` / `teaching_head` collapse to `teacher`.
* Unknown values fall back to `student`.
*/
export const normalizeRole = (value: unknown): NormalizedRole => {
const role = String(value ?? "").trim().toLowerCase()
if (role === "grade_head" || role === "teaching_head") return "teacher"
if (role === "admin" || role === "student" || role === "teacher" || role === "parent") return role
if (
role === "admin" ||
role === "grade_head" ||
role === "teaching_head" ||
role === "teacher" ||
role === "student" ||
role === "parent"
) {
return role
}
return "student"
}
/**
* Given a list of role names (e.g. from `users_to_roles`), resolve the
* primary role used for routing/permission checks. Priority order:
* admin > teacher > parent > student.
* admin > grade_head > teaching_head > teacher > student > parent.
*/
export const resolvePrimaryRole = (roleNames: string[]): NormalizedRole => {
const mapped = roleNames.map((name) => normalizeRole(name)).filter(Boolean)
if (mapped.includes("admin")) return "admin"
if (mapped.includes("grade_head")) return "grade_head"
if (mapped.includes("teaching_head")) return "teaching_head"
if (mapped.includes("teacher")) return "teacher"
if (mapped.includes("parent")) return "parent"
if (mapped.includes("student")) return "student"
if (mapped.includes("parent")) return "parent"
return "student"
}

View File

@@ -0,0 +1,83 @@
/**
* 路由权限配置audit-P1-10 + audit-P1-12
*
* 从 proxy.ts 抽取的路由权限常量,实现配置驱动设计。
* 新增路由或权限点时只需修改此文件,无需改 proxy.ts 逻辑。
*
* 匹配优先级proxy.ts 按此顺序检查):
* 1. 精确路由权限SPECIFIC_ROUTE_PERMISSIONS—— 完整路径匹配,优先级最高
* 用于将 /admin/* 下特定页面开放给非管理员角色
* 2. 路由前缀权限ROUTE_PREFIX_PERMISSIONS—— 前缀匹配
* 用于角色路由组(/admin、/teacher、/student、/parent、/management
*
* audit-P1-10 扩展:原仅 /admin/ai-settings、/admin/roles、/admin/permissions 三个精确路由例外,
* 现新增 /admin/announcements、/admin/audit-logs、/admin/elective、/admin/questions、
* /admin/users、/admin/error-book、/admin/lesson-plans、/admin/course-plans 等子路径细粒度权限,
* 使拥有对应权限的非管理员角色(如教师)可直接访问 /admin/* 下相关页面。
*/
import { Permissions, type Permission } from "@/shared/types/permissions"
/**
* 精确路由权限(优先级最高,覆盖前缀匹配)。
*
* 用于将 /admin/* 下的特定页面开放给非管理员角色。
* key 为完整路径(不含 query stringvalue 为所需权限点。
*
* 注意:此表仅控制"是否允许访问此路径"
* 页面内的 requirePermission() 仍按各自模块的权限点校验。
*/
export const SPECIFIC_ROUTE_PERMISSIONS: Record<string, Permission> = {
// V3.1/admin/ai-settings 对所有 AI_CHAT 用户开放(管理自己的 private provider
"/admin/ai-settings": Permissions.AI_CHAT,
// RBAC/admin/roles 和 /admin/permissions 使用细粒度权限而非 /admin 前缀的 SCHOOL_MANAGE
"/admin/roles": Permissions.ROLE_READ,
"/admin/permissions": Permissions.PERMISSION_READ,
// audit-P1-10以下子路径细粒度权限允许非管理员角色访问
"/admin/announcements": Permissions.ANNOUNCEMENT_MANAGE,
"/admin/audit-logs": Permissions.AUDIT_LOG_READ,
"/admin/audit-logs/overview": Permissions.AUDIT_LOG_READ,
"/admin/audit-logs/login-logs": Permissions.AUDIT_LOG_READ,
"/admin/audit-logs/data-changes": Permissions.AUDIT_LOG_READ,
"/admin/elective": Permissions.ELECTIVE_MANAGE,
"/admin/questions": Permissions.QUESTION_READ,
"/admin/users": Permissions.USER_MANAGE,
// audit-P2-3: 邀请码管理路由USER_MANAGE 权限,复用用户管理权限)
"/admin/invitation-codes": Permissions.USER_MANAGE,
"/admin/error-book": Permissions.ERROR_BOOK_ANALYTICS_READ,
"/admin/lesson-plans": Permissions.LESSON_PLAN_READ,
"/admin/course-plans": Permissions.COURSE_PLAN_READ,
}
/**
* 路由前缀权限(精确路由未命中时按前缀匹配)。
*
* key 为路由前缀value 为所需权限点。
* 按 Object.entries 顺序检查,命中第一个前缀即停止。
*/
export const ROUTE_PREFIX_PERMISSIONS: Record<string, Permission> = {
"/admin": Permissions.SCHOOL_MANAGE,
"/teacher": Permissions.EXAM_CREATE,
"/student": Permissions.HOMEWORK_SUBMIT,
"/parent": Permissions.DASHBOARD_PARENT_READ,
"/management": Permissions.GRADE_MANAGE,
}
/**
* 仪表盘路由的细粒度权限(覆盖前缀匹配,防止跨角色访问仪表盘)。
*
* 防止拥有 EXAM_READ 的学生/家长访问 /teacher/dashboard 等。
*/
export const DASHBOARD_ROUTE_PERMISSIONS: Record<string, Permission> = {
"/admin/dashboard": Permissions.DASHBOARD_ADMIN_READ,
"/teacher/dashboard": Permissions.DASHBOARD_TEACHER_READ,
"/student/dashboard": Permissions.DASHBOARD_STUDENT_READ,
"/parent/dashboard": Permissions.DASHBOARD_PARENT_READ,
}
/**
* API 路由前缀权限。
*/
export const API_ROUTE_PERMISSIONS: Record<string, Permission> = {
"/api/ai/chat": Permissions.AI_CHAT,
}

View File

@@ -0,0 +1,35 @@
/**
* 角色到默认路由的解析P0-6 解耦修复)
*
* 抽取自 `proxy.ts` 与 `onboarding/data-access.ts` 中重复的
* `resolveDefaultPath` 实现,确保两处路由解析行为一致。
*
* 优先级admin > grade_head/teaching_head > teacher > student > parent。
* 未知角色回退到 `/dashboard`。
*
* 注意:此模块不导入 `server-only`,可在 proxy.tsedge 运行时)中使用。
*/
import { normalizeRole } from "@/shared/lib/role-utils"
/**
* 根据角色列表解析默认登录后路由。
*
* @param roles 原始角色名数组(来自 JWT/session/DB可为大小写不一致的字符串
* @returns 默认路由路径,如 `/admin/dashboard`
*/
export function resolveDefaultPath(roles: string[]): string {
const normalized = roles.map((r) => normalizeRole(r))
if (normalized.includes("admin")) return "/admin/dashboard"
if (
normalized.includes("grade_head") ||
normalized.includes("teaching_head")
) {
return "/teacher/dashboard"
}
if (normalized.includes("teacher")) return "/teacher/dashboard"
if (normalized.includes("student")) return "/student/dashboard"
if (normalized.includes("parent")) return "/parent/dashboard"
return "/dashboard"
}

View File

@@ -22,10 +22,20 @@ export type EventName =
| "announcement.deleted"
| "announcement.pin_toggled"
| "announcement.marked_read"
| "announcement.action_error"
| "message.sent"
| "message.send_failed"
| "message.deleted"
| "message.marked_read"
| "message.star_toggled"
| "message.recalled"
| "message.template_created"
| "message.group_sent"
| "message.group_send_failed"
| "message.reported"
| "message.report_failed"
| "message.user_blocked"
| "message.user_unblocked"
| "notification.marked_read"
| "notification.marked_all_read"
| "notification.sent"
@@ -36,6 +46,10 @@ export type EventName =
| "attendance.updated"
| "attendance.deleted"
| "attendance.rules_saved"
// L-5 在线请假流程
| "leave_request.created"
| "leave_request.reviewed"
| "leave_request.cancelled"
| "elective.course_created"
| "elective.course_updated"
| "elective.course_deleted"
@@ -62,6 +76,11 @@ export type EventName =
| "homework.submitted"
| "homework.graded"
| "homework.auto_save_failed"
| "homework.excellent_viewed"
| "homework.remind_unsubmitted"
| "homework.started"
| "homework.answer_saved"
| "homework.scan_deleted"
// AI 模块监控事件
| "ai.chat"
| "ai.chat_stream"
@@ -72,6 +91,34 @@ export type EventName =
| "ai.weakness_analysis"
| "ai.child_summary"
| "ai.study_path"
| "ai.explain_error"
// 审计模块监控事件
| "audit.exported"
| "audit.viewed"
| "audit.retention"
// files 模块监控事件
| "file.uploaded"
| "file.upload_failed"
| "file.deleted"
| "file.batch_deleted"
| "file.viewed"
| "file.action_error"
// auth 模块监控事件audit-P1-9
// 登录成功/失败、登出、注册、2FA 启用/禁用、账户锁定、速率限制触发
// 用于监控:登录成功率、异常登录地理/设备告警、2FA 启用率、账户锁定触发率、限流触发率
| "auth.signin_success"
| "auth.signin_failure"
| "auth.signout"
| "auth.signup"
| "auth.2fa_enabled"
| "auth.2fa_disabled"
| "auth.account_locked"
| "auth.rate_limited"
// invitation-codes 模块监控事件audit-P2-3
// 用于监控:邀请码生成/使用/删除、注册转化率(邀请码注册 vs 开放注册)
| "invitation_codes.generated"
| "invitation_codes.consumed"
| "invitation_codes.deleted"
/** 埋点事件负载 */
export interface TrackEventPayload {
@@ -155,3 +202,31 @@ export async function trackExamEvent(
properties: params.properties,
})
}
/**
* auth 模块专用埋点函数audit-P1-9
*
* 封装 trackEvent自动设置 targetType="user",简化 auth.ts / login-service.ts 等调用方代码。
*
* @example
* ```ts
* await trackAuthEvent("auth.signin_success", { userId: user.id, properties: { roles: ["teacher"] } })
* await trackAuthEvent("auth.account_locked", { userId, properties: { attempts: 5 } })
* ```
*/
export async function trackAuthEvent(
event: Extract<EventName, `auth.${string}`>,
params: {
userId?: string
targetId?: string
properties?: Record<string, unknown>
}
): Promise<void> {
await trackEvent({
event,
userId: params.userId,
targetId: params.targetId ?? params.userId,
targetType: "user",
properties: params.properties,
})
}

View File

@@ -0,0 +1,38 @@
/**
* 共享类型守卫工具
*
* 提供通用的运行时类型检查函数,供所有模块复用。
* 避免各模块重复定义 isRecord 等工具函数。
*/
import { Permissions, type Permission } from "@/shared/types/permissions"
/**
* 判断值是否为非 null 对象Record
*
* 用作 JSON 解析结果的类型收窄,避免使用 `as` 断言。
*/
export const isRecord = (v: unknown): v is Record<string, unknown> =>
typeof v === "object" && v !== null
/**
* 所有合法权限值的只读集合,用于运行时校验。
*/
const PERMISSION_VALUES: ReadonlySet<string> = new Set(Object.values(Permissions))
/**
* 类型守卫:判断值是否为合法的 Permission 字符串。
*
* 替代 `value as Permission` 断言,确保运行时安全。
*/
export function isPermission(value: unknown): value is Permission {
return typeof value === "string" && PERMISSION_VALUES.has(value)
}
/**
* 安全转换:将 unknown 转为 Permission | null。
* 合法返回值,非法返回 null。
*/
export function toPermission(value: unknown): Permission | null {
return isPermission(value) ? value : null
}