feat(shared,tests): add error boundaries, lib utils, i18n messages, and integration tests
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:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 默认 baseUrl(OpenAI 兼容端点) */
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
125
src/shared/lib/breached-password.ts
Normal file
125
src/shared/lib/breached-password.ts
Normal 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 API(Node.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 的 fetch(AbortController)。
|
||||
*/
|
||||
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}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
80
src/shared/lib/export-utils.ts
Normal file
80
src/shared/lib/export-utils.ts
Normal 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`)
|
||||
}
|
||||
226
src/shared/lib/permission-bitmap.ts
Normal file
226
src/shared/lib/permission-bitmap.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* 权限位图编解码(audit-P1-7)
|
||||
*
|
||||
* 目的:将 67 个权限点字符串数组(约 1.1KB JSON)压缩为 ~14 字符的 base36 字符串,
|
||||
* 使 JWT cookie 体积降至可接受范围(远低于 4KB 限制)。
|
||||
*
|
||||
* 编码方案:
|
||||
* - 权限点按 `PERMISSION_BITMAP_ORDER` 数组中的位置分配 bit(0-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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)) }),
|
||||
}
|
||||
}
|
||||
71
src/shared/lib/rate-limit/index.ts
Normal file
71
src/shared/lib/rate-limit/index.ts
Normal 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)
|
||||
}
|
||||
78
src/shared/lib/rate-limit/memory-limiter.ts
Normal file
78
src/shared/lib/rate-limit/memory-limiter.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
193
src/shared/lib/rate-limit/redis-limiter.ts
Normal file
193
src/shared/lib/rate-limit/redis-limiter.ts
Normal 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
67
src/shared/lib/rate-limit/rules.ts
Normal file
67
src/shared/lib/rate-limit/rules.ts
Normal 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)) }),
|
||||
}
|
||||
}
|
||||
44
src/shared/lib/rate-limit/types.ts
Normal file
44
src/shared/lib/rate-limit/types.ts
Normal 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>
|
||||
}
|
||||
69
src/shared/lib/rate-limit/upstash-modules.d.ts
vendored
Normal file
69
src/shared/lib/rate-limit/upstash-modules.d.ts
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
60
src/shared/lib/resolve-action-error.ts
Normal file
60
src/shared/lib/resolve-action-error.ts
Normal 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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
83
src/shared/lib/route-permissions.ts
Normal file
83
src/shared/lib/route-permissions.ts
Normal 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 string),value 为所需权限点。
|
||||
*
|
||||
* 注意:此表仅控制"是否允许访问此路径",
|
||||
* 页面内的 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,
|
||||
}
|
||||
35
src/shared/lib/route-resolver.ts
Normal file
35
src/shared/lib/route-resolver.ts
Normal 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.ts(edge 运行时)中使用。
|
||||
*/
|
||||
|
||||
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"
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
38
src/shared/lib/type-guards.ts
Normal file
38
src/shared/lib/type-guards.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user