feat: 完成 P1 全部功能 + 修复 proxy 导出 + 切换 MySQL 端口至 14013

## P1 功能(20 项)
- 站内消息系统、家长仪表盘、学生考勤管理
- Excel 导入导出、用户批量导入、成绩导出
- 排课规则+自动排课+课表调整
- 成绩趋势+对比分析、密码安全策略、速率限制
- 数据变更日志、文件预览+存储策略、全文检索
- 依赖审计集成 CI、数据库定时备份、E2E 测试完善
- 通知偏好管理

## 基础设施修复
- src/proxy.ts: 将 middleware 导出重命名为 proxy(Next.js 16 要求)
- .env: MySQL 端口从 13002 切换至 14013
- scripts/create-db.ts: 新增数据库初始化脚本

## 架构文档同步
- 004_architecture_impact_map.md 和 005_architecture_data.json
  完整记录所有新增表、模块、路由、权限、依赖关系
This commit is contained in:
SpecialX
2026-06-17 13:44:37 +08:00
parent 125f7ec54c
commit 3b6272c99d
195 changed files with 27274 additions and 416 deletions

View File

@@ -0,0 +1,50 @@
"use server"
import { createId } from "@paralleldrive/cuid2"
import { headers } from "next/headers"
import { db } from "@/shared/db"
import { auditLogs } from "@/shared/db/schema"
import { auth } from "@/auth"
export type AuditLogStatus = "success" | "failure"
export interface LogAuditParams {
action: string
module: string
targetId?: string
targetType?: string
detail?: Record<string, unknown>
status?: AuditLogStatus
}
/**
* Record an audit log entry for the current authenticated user.
* Silently fails on error so it never breaks the main operation.
*/
export async function logAudit(params: LogAuditParams): Promise<void> {
try {
const session = await auth()
const headerList = await headers()
const ipAddress =
headerList.get("x-forwarded-for") ??
headerList.get("x-real-ip") ??
"unknown"
const userAgent = headerList.get("user-agent") ?? "unknown"
await db.insert(auditLogs).values({
id: createId(),
userId: session?.user?.id ?? "unknown",
userName: session?.user?.name ?? "unknown",
action: params.action,
module: params.module,
targetId: params.targetId ?? null,
targetType: params.targetType ?? null,
detail: params.detail ? JSON.stringify(params.detail) : null,
ipAddress,
userAgent,
status: params.status ?? "success",
})
} catch {
// Silently fail - logging should not break the main operation
}
}

View File

@@ -5,6 +5,7 @@ import {
classes,
classSubjectTeachers,
grades,
parentStudentRelations,
} from "@/shared/db/schema"
import { eq, or } from "drizzle-orm"
@@ -116,8 +117,12 @@ async function resolveDataScope(userId: string, roleNames: string[]): Promise<Da
// Parent: can see their children's data
if (roleNames.includes("parent")) {
// TODO: implement parent-child relationship lookup
return { type: "children", childrenIds: [] }
const children = await db
.select({ studentId: parentStudentRelations.studentId })
.from(parentStudentRelations)
.where(eq(parentStudentRelations.parentId, userId))
return { type: "children", childrenIds: children.map((c) => c.studentId) }
}
// Fallback: only own data

View File

@@ -0,0 +1,47 @@
"use server"
import { createId } from "@paralleldrive/cuid2"
import { headers } from "next/headers"
import { auth } from "@/auth"
import { db } from "@/shared/db"
import { dataChangeLogs } from "@/shared/db/schema"
export type DataChangeAction = "create" | "update" | "delete"
export interface LogDataChangeParams {
tableName: string
recordId: string
action: DataChangeAction
oldValue?: Record<string, unknown>
newValue?: Record<string, unknown>
}
/**
* Record a data change log entry for the current authenticated user.
* Silently fails on error so it never blocks the main operation.
*/
export async function logDataChange(params: LogDataChangeParams): Promise<void> {
try {
const session = await auth()
const headerList = await headers()
const ipAddress =
headerList.get("x-forwarded-for") ??
headerList.get("x-real-ip") ??
"unknown"
await db.insert(dataChangeLogs).values({
id: createId(),
tableName: params.tableName,
recordId: params.recordId,
action: params.action,
oldValue: params.oldValue ? JSON.stringify(params.oldValue) : null,
newValue: params.newValue ? JSON.stringify(params.newValue) : null,
changedBy: session?.user?.id ?? "unknown",
changedByName: session?.user?.name ?? "unknown",
ipAddress,
})
} catch {
// Silently fail - change logging must not break the main operation
}
}

173
src/shared/lib/excel.ts Normal file
View File

@@ -0,0 +1,173 @@
import "server-only"
import ExcelJS from "exceljs"
export type ExcelColumn = {
header: string
key: string
width?: number
}
export type ExcelSheet = {
name: string
columns: ExcelColumn[]
rows: Record<string, unknown>[]
}
export type TemplateColumn = ExcelColumn & {
note?: string
}
export type TemplateSheet = {
name: string
columns: TemplateColumn[]
sampleRows?: Record<string, unknown>[]
}
export type ParsedSheet = {
sheetName: string
rows: Record<string, unknown>[]
}
/**
* 导出数据到 Excel Buffer
*/
export async function exportToExcel(params: {
sheets: ExcelSheet[]
}): Promise<Buffer> {
const workbook = new ExcelJS.Workbook()
for (const sheet of params.sheets) {
const worksheet = workbook.addWorksheet(sheet.name, {
views: [{ state: "frozen", ySplit: 1 }],
})
worksheet.columns = sheet.columns.map((col) => ({
header: col.header,
key: col.key,
width: col.width ?? 20,
}))
// Header style
const headerRow = worksheet.getRow(1)
headerRow.font = { bold: true }
headerRow.fill = {
type: "pattern",
pattern: "solid",
fgColor: { argb: "FFE0E7FF" },
}
headerRow.alignment = { vertical: "middle", horizontal: "left" }
for (const row of sheet.rows) {
worksheet.addRow(row)
}
worksheet.autoFilter = {
from: { row: 1, column: 1 },
to: { row: 1, column: sheet.columns.length },
}
}
const arrayBuffer = await workbook.xlsx.writeBuffer()
return Buffer.from(arrayBuffer)
}
/**
* 从 Buffer 解析 Excel
*/
export async function parseExcel(buffer: Buffer): Promise<ParsedSheet[]> {
const workbook = new ExcelJS.Workbook()
await workbook.xlsx.load(buffer as unknown as ArrayBuffer)
const result: ParsedSheet[] = []
workbook.worksheets.forEach((worksheet) => {
if (worksheet.rowCount === 0) {
result.push({ sheetName: worksheet.name, rows: [] })
return
}
const headerRow = worksheet.getRow(1)
const headers: string[] = []
const keys: string[] = []
headerRow.eachCell((cell, colNumber) => {
const header = String(cell.value ?? "").trim()
headers.push(header)
keys.push(header || `column_${colNumber}`)
})
const rows: Record<string, unknown>[] = []
for (let rowNum = 2; rowNum <= worksheet.rowCount; rowNum++) {
const row = worksheet.getRow(rowNum)
const record: Record<string, unknown> = {}
let hasValue = false
keys.forEach((key, idx) => {
const cell = row.getCell(idx + 1)
const value = cell.value
if (value !== null && value !== undefined && value !== "") {
record[key] = typeof value === "object" && "text" in value
? String((value as { text: string }).text)
: value
hasValue = true
} else {
record[key] = ""
}
})
if (hasValue) rows.push(record)
}
result.push({ sheetName: worksheet.name, rows })
})
return result
}
/**
* 生成导入模板 Buffer
*/
export async function generateTemplate(params: {
sheets: TemplateSheet[]
}): Promise<Buffer> {
const workbook = new ExcelJS.Workbook()
for (const sheet of params.sheets) {
const worksheet = workbook.addWorksheet(sheet.name)
worksheet.columns = sheet.columns.map((col) => ({
header: col.header,
key: col.key,
width: col.width ?? 22,
}))
const headerRow = worksheet.getRow(1)
headerRow.font = { bold: true }
headerRow.fill = {
type: "pattern",
pattern: "solid",
fgColor: { argb: "FFE0E7FF" },
}
// Notes row (row 2)
const noteRow = worksheet.getRow(2)
sheet.columns.forEach((col, idx) => {
const cell = noteRow.getCell(idx + 1)
cell.value = col.note ?? ""
cell.font = { italic: true, color: { argb: "FF6B7280" } }
})
// Sample rows (starting from row 3)
const sampleRows = sheet.sampleRows ?? []
sampleRows.forEach((row) => worksheet.addRow(row))
// Empty row for user input
if (sampleRows.length === 0) {
worksheet.addRow({})
}
}
const arrayBuffer = await workbook.xlsx.writeBuffer()
return Buffer.from(arrayBuffer)
}

View File

@@ -0,0 +1,70 @@
import { createId } from "@paralleldrive/cuid2"
// 允许的 MIME 类型图片、PDF、Office 文档、文本、压缩包
export const ALLOWED_MIME_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/svg+xml",
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"text/plain",
"text/markdown",
"application/zip",
"application/x-rar-compressed",
] as const
// 最大文件大小10MB
export const MAX_FILE_SIZE = 10 * 1024 * 1024
/**
* 判断 MIME 类型是否在允许列表中
*/
export function isAllowedMimeType(mimeType: string): boolean {
return (ALLOWED_MIME_TYPES as readonly string[]).includes(mimeType)
}
/**
* 从文件名中提取扩展名(小写,不含点)
*/
export function getFileExtension(filename: string): string {
const idx = filename.lastIndexOf(".")
if (idx <= 0 || idx === filename.length - 1) return ""
return filename.slice(idx + 1).toLowerCase()
}
/**
* 根据原始文件名生成存储路径uploads/YYYY-MM/cuid.ext
* 路径相对于 public/ 目录
*/
export function generateStoragePath(originalName: string): string {
const ext = getFileExtension(originalName)
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, "0")
const monthDir = `${year}-${month}`
const id = createId()
const filename = ext ? `${id}.${ext}` : id
return `uploads/${monthDir}/${filename}`
}
/**
* 将字节数格式化为人类可读的字符串
*/
export function formatFileSize(bytes: number): string {
if (bytes <= 0) return "0 B"
const units = ["B", "KB", "MB", "GB", "TB"]
const i = Math.min(
units.length - 1,
Math.floor(Math.log(bytes) / Math.log(1024))
)
const value = bytes / Math.pow(1024, i)
const rounded = i === 0 ? value.toString() : value.toFixed(1)
return `${rounded} ${units[i]}`
}

View File

@@ -0,0 +1,46 @@
"use server"
import { createId } from "@paralleldrive/cuid2"
import { headers } from "next/headers"
import { db } from "@/shared/db"
import { loginLogs } from "@/shared/db/schema"
export type LoginLogAction = "signin" | "signout" | "signup"
export type LoginLogStatus = "success" | "failure"
export interface LogLoginEventParams {
userId?: string
userEmail: string
action: LoginLogAction
status?: LoginLogStatus
errorMessage?: string
}
/**
* Record a login-related log entry (signin/signout/signup).
* Does NOT depend on auth context since it runs during auth events.
* Silently fails on error so it never breaks the auth flow.
*/
export async function logLoginEvent(params: LogLoginEventParams): Promise<void> {
try {
const headerList = await headers()
const ipAddress =
headerList.get("x-forwarded-for") ??
headerList.get("x-real-ip") ??
"unknown"
const userAgent = headerList.get("user-agent") ?? "unknown"
await db.insert(loginLogs).values({
id: createId(),
userId: params.userId ?? null,
userEmail: params.userEmail,
action: params.action,
status: params.status ?? "success",
ipAddress,
userAgent,
errorMessage: params.errorMessage ?? null,
})
} catch {
// Silently fail - logging should not break the auth flow
}
}

View File

@@ -0,0 +1,119 @@
/**
* Password security policy and account lockout helpers.
*
* These utilities are pure (no DB / I/O) so they can be safely used
* in both server and client contexts.
*/
export const PASSWORD_RULES = {
minLength: 8,
requireUppercase: true,
requireLowercase: true,
requireNumber: true,
requireSpecialChar: false,
maxLoginAttempts: 5,
lockoutDurationMinutes: 30,
} as const
export interface PasswordValidationResult {
valid: boolean
errors: string[]
}
/**
* Validate a password against the configured policy.
*/
export function validatePassword(password: string): PasswordValidationResult {
const errors: string[] = []
if (password.length < PASSWORD_RULES.minLength) {
errors.push(`Password must be at least ${PASSWORD_RULES.minLength} characters long`)
}
if (PASSWORD_RULES.requireUppercase && !/[A-Z]/.test(password)) {
errors.push("Password must contain at least one uppercase letter")
}
if (PASSWORD_RULES.requireLowercase && !/[a-z]/.test(password)) {
errors.push("Password must contain at least one lowercase letter")
}
if (PASSWORD_RULES.requireNumber && !/[0-9]/.test(password)) {
errors.push("Password must contain at least one number")
}
if (PASSWORD_RULES.requireSpecialChar && !/[^A-Za-z0-9]/.test(password)) {
errors.push("Password must contain at least one special character")
}
return { valid: errors.length === 0, errors }
}
export type PasswordStrength = "weak" | "medium" | "strong"
/**
* Compute a coarse password strength label based on length and character
* diversity. Useful for client-side strength indicators.
*/
export function getPasswordStrength(password: string): PasswordStrength {
if (password.length === 0) return "weak"
let score = 0
if (password.length >= 8) score++
if (password.length >= 12) score++
if (/[a-z]/.test(password)) score++
if (/[A-Z]/.test(password)) score++
if (/[0-9]/.test(password)) score++
if (/[^A-Za-z0-9]/.test(password)) score++
if (score <= 2) return "weak"
if (score <= 4) return "medium"
return "strong"
}
/**
* Determine whether an account should be considered locked given its
* failed-attempt count and the timestamp of the most recent failure.
*
* The lockout is lifted automatically once `lockoutDurationMinutes` have
* elapsed since `lastFailedAt`.
*/
export function isAccountLocked(
failedAttempts: number,
lastFailedAt: Date | null
): boolean {
if (failedAttempts < PASSWORD_RULES.maxLoginAttempts) return false
if (!lastFailedAt) return false
const lockoutMs = PASSWORD_RULES.lockoutDurationMinutes * 60 * 1000
const elapsed = Date.now() - lastFailedAt.getTime()
return elapsed < lockoutMs
}
/**
* Compute the remaining lockout time in milliseconds (0 if unlocked).
*/
export function getRemainingLockoutMs(
failedAttempts: number,
lastFailedAt: Date | null
): number {
if (!isAccountLocked(failedAttempts, lastFailedAt)) return 0
if (!lastFailedAt) return 0
const lockoutMs = PASSWORD_RULES.lockoutDurationMinutes * 60 * 1000
const elapsed = Date.now() - lastFailedAt.getTime()
return Math.max(0, lockoutMs - elapsed)
}
/**
* Human-readable summary of the password policy. Used by the UI to
* display requirements next to the password input.
*/
export const PASSWORD_REQUIREMENT_HINTS: string[] = [
`At least ${PASSWORD_RULES.minLength} characters`,
PASSWORD_RULES.requireUppercase ? "At least one uppercase letter (A-Z)" : null,
PASSWORD_RULES.requireLowercase ? "At least one lowercase letter (a-z)" : null,
PASSWORD_RULES.requireNumber ? "At least one number (0-9)" : null,
PASSWORD_RULES.requireSpecialChar
? "At least one special character (!@#$...)"
: null,
].filter((s): s is string => Boolean(s))

View File

@@ -33,6 +33,20 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
Permissions.AI_CHAT,
Permissions.AI_CONFIGURE,
Permissions.SETTINGS_ADMIN,
Permissions.AUDIT_LOG_READ,
Permissions.ANNOUNCEMENT_MANAGE,
Permissions.ANNOUNCEMENT_READ,
Permissions.GRADE_RECORD_MANAGE,
Permissions.GRADE_RECORD_READ,
Permissions.COURSE_PLAN_MANAGE,
Permissions.COURSE_PLAN_READ,
Permissions.ATTENDANCE_MANAGE,
Permissions.ATTENDANCE_READ,
Permissions.MESSAGE_SEND,
Permissions.MESSAGE_READ,
Permissions.MESSAGE_DELETE,
Permissions.SCHEDULE_AUTO,
Permissions.SCHEDULE_ADJUST,
],
teacher: [
Permissions.EXAM_CREATE,
@@ -55,6 +69,15 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
Permissions.CLASS_ENROLL,
Permissions.CLASS_SCHEDULE,
Permissions.AI_CHAT,
Permissions.ANNOUNCEMENT_READ,
Permissions.GRADE_RECORD_MANAGE,
Permissions.GRADE_RECORD_READ,
Permissions.COURSE_PLAN_READ,
Permissions.ATTENDANCE_MANAGE,
Permissions.ATTENDANCE_READ,
Permissions.MESSAGE_SEND,
Permissions.MESSAGE_READ,
Permissions.MESSAGE_DELETE,
],
student: [
Permissions.EXAM_READ,
@@ -63,11 +86,23 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
Permissions.TEXTBOOK_READ,
Permissions.CLASS_READ,
Permissions.AI_CHAT,
Permissions.ANNOUNCEMENT_READ,
Permissions.GRADE_RECORD_READ,
Permissions.COURSE_PLAN_READ,
Permissions.ATTENDANCE_READ,
Permissions.MESSAGE_READ,
Permissions.MESSAGE_DELETE,
],
parent: [
Permissions.EXAM_READ,
Permissions.TEXTBOOK_READ,
Permissions.CLASS_READ,
Permissions.ANNOUNCEMENT_READ,
Permissions.GRADE_RECORD_READ,
Permissions.ATTENDANCE_READ,
Permissions.MESSAGE_SEND,
Permissions.MESSAGE_READ,
Permissions.MESSAGE_DELETE,
],
grade_head: [
Permissions.EXAM_CREATE,
@@ -93,6 +128,13 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
Permissions.CLASS_SCHEDULE,
Permissions.GRADE_MANAGE,
Permissions.AI_CHAT,
Permissions.ANNOUNCEMENT_READ,
Permissions.GRADE_RECORD_READ,
Permissions.COURSE_PLAN_READ,
Permissions.ATTENDANCE_READ,
Permissions.MESSAGE_SEND,
Permissions.MESSAGE_READ,
Permissions.MESSAGE_DELETE,
],
teaching_head: [
Permissions.EXAM_CREATE,
@@ -114,6 +156,13 @@ export const ROLE_PERMISSIONS: Record<string, Permission[]> = {
Permissions.CLASS_READ,
Permissions.GRADE_MANAGE,
Permissions.AI_CHAT,
Permissions.ANNOUNCEMENT_READ,
Permissions.GRADE_RECORD_READ,
Permissions.COURSE_PLAN_READ,
Permissions.ATTENDANCE_READ,
Permissions.MESSAGE_SEND,
Permissions.MESSAGE_READ,
Permissions.MESSAGE_DELETE,
],
}

View File

@@ -0,0 +1,119 @@
/**
* 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,74 @@
import "server-only"
import { mkdir, readFile, writeFile, unlink, access } from "fs/promises"
import path from "path"
/**
* Storage provider abstraction for file persistence.
* Allows swapping local disk storage for cloud providers (OSS, S3) without
* changing call sites.
*/
export interface StorageProvider {
/** Persist a file buffer at the given relative path; returns the public URL. */
save(file: Buffer, storagePath: string): Promise<string>
/** Read a file buffer by its relative storage path. */
read(storagePath: string): Promise<Buffer>
/** Remove a file by its relative storage path. */
delete(storagePath: string): Promise<void>
/** Check whether a file exists at the relative storage path. */
exists(storagePath: string): Promise<boolean>
/** Resolve the public URL for a relative storage path. */
getUrl(storagePath: string): string
}
/**
* Local disk storage provider.
* Files are persisted under `public/uploads/...` and served at `/uploads/...`.
*/
export class LocalStorageProvider implements StorageProvider {
private readonly publicDir = path.join(process.cwd(), "public")
async save(file: Buffer, storagePath: string): Promise<string> {
const absolutePath = path.join(this.publicDir, storagePath)
const dir = path.dirname(absolutePath)
await mkdir(dir, { recursive: true })
await writeFile(absolutePath, file)
return this.getUrl(storagePath)
}
async read(storagePath: string): Promise<Buffer> {
const absolutePath = path.join(this.publicDir, storagePath)
return readFile(absolutePath)
}
async delete(storagePath: string): Promise<void> {
const absolutePath = path.join(this.publicDir, storagePath)
try {
await unlink(absolutePath)
} catch {
// File may already be gone; ignore
}
}
async exists(storagePath: string): Promise<boolean> {
const absolutePath = path.join(this.publicDir, storagePath)
try {
await access(absolutePath)
return true
} catch {
return false
}
}
getUrl(storagePath: string): string {
// Storage paths are relative to public/; URLs start with "/"
if (storagePath.startsWith("/")) return storagePath
return `/${storagePath}`
}
}
/**
* Default storage provider (local disk).
* Swap this instance to migrate to OSS/S3 without touching call sites.
*/
export const storageProvider: StorageProvider = new LocalStorageProvider()