Files
NextEdu/src/modules/files/data-access.ts
SpecialX 49291fcc31 refactor: fix all P0/P1/P2 bugs and architecture issues
Bug fixes (from bugs/ directory):

- Fix cross-module DB queries in 9 modules (homework, grades, parent, diagnostic, elective, proctoring, notifications, scheduling, classes) by routing through data-access functions

- Fix shared/lib <-> auth circular dependency via new session.ts module

- Fix divide-by-zero guard in grades data-access

- Fix audit export data truncation (paginated fetch for full datasets)

- Fix missing transactions in homework grading and elective lottery

- Fix missing revalidatePath in course-plans actions

- Fix frontend permission checks using requirePermission instead of requireAuth

- Fix dashboard role routing using session.user.roles

- Fix student auth pattern (migrate getDemoStudentUser to users module)

- Fix ActionState return type handling in components

Code quality fixes:

- Remove 60+ as type assertions (replace with type guards)

- Remove non-null assertions (use optional chaining or explicit checks)

- Convert dynamic imports to static imports (grades, diagnostic)

- Add React.cache() wrapping for read functions

- Parallelize independent queries with Promise.all

- Add explicit return types to 30+ arrow functions

- Replace any with unknown + type guards

- Fix import type for type-only imports

- Add Zod validation schemas for classes and diagnostic modules

- Extract duplicate code (normalizeRoleName, normalizeBcryptHash, logger IP extraction)

- Add console.error to silent catch blocks

- Fix permission naming consistency (exam:proctor_read -> exam:proctor:read)

Architecture doc sync:

- Update 004_architecture_impact_map.md and 005_architecture_data.json

- Update management-modules-audit.md for P0-7 cross-module fix

Moved deleted proctoring event route to deletes/ folder.
2026-06-19 05:13:34 +08:00

286 lines
7.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import "server-only"
import { and, count, desc, eq, inArray, like, or, sql } from "drizzle-orm"
import { cache } from "react"
import { db } from "@/shared/db"
import { fileAttachments } from "@/shared/db/schema"
import type {
BatchDeleteResult,
CreateFileAttachmentInput,
FileAttachment,
FileAttachmentQueryParams,
FileStats,
} from "./types"
const toIso = (d: Date): string => d.toISOString()
const mapRow = (row: typeof fileAttachments.$inferSelect): FileAttachment => ({
id: row.id,
filename: row.filename,
originalName: row.originalName,
mimeType: row.mimeType,
size: row.size,
storagePath: row.storagePath,
url: row.url,
uploaderId: row.uploaderId,
targetType: row.targetType,
targetId: row.targetId,
createdAt: toIso(row.createdAt),
})
/**
* 插入文件附件记录
*/
export async function createFileAttachment(
data: CreateFileAttachmentInput
): Promise<FileAttachment | null> {
try {
await db.insert(fileAttachments).values({
id: data.id,
filename: data.filename,
originalName: data.originalName,
mimeType: data.mimeType,
size: data.size,
storagePath: data.storagePath,
url: data.url,
uploaderId: data.uploaderId,
targetType: data.targetType ?? null,
targetId: data.targetId ?? null,
})
const created = await getFileAttachment(data.id)
return created
} catch (error) {
console.error("createFileAttachment failed:", error)
return null
}
}
/**
* 按 ID 查询文件附件
*/
export const getFileAttachment = cache(
async (id: string): Promise<FileAttachment | null> => {
try {
const [row] = await db
.select()
.from(fileAttachments)
.where(eq(fileAttachments.id, id))
.limit(1)
return row ? mapRow(row) : null
} catch (error) {
console.error("getFileAttachment failed:", error)
return null
}
},
)
/**
* 按关联资源查询文件列表
*/
export const getFileAttachmentsByTarget = cache(
async (targetType: string, targetId: string): Promise<FileAttachment[]> => {
try {
const rows = await db
.select()
.from(fileAttachments)
.where(
and(
eq(fileAttachments.targetType, targetType),
eq(fileAttachments.targetId, targetId)
)
)
.orderBy(desc(fileAttachments.createdAt))
return rows.map(mapRow)
} catch (error) {
console.error("getFileAttachmentsByTarget failed:", error)
return []
}
},
)
/**
* 按上传者查询文件列表
*/
export const getFileAttachmentsByUploader = cache(
async (uploaderId: string): Promise<FileAttachment[]> => {
try {
const rows = await db
.select()
.from(fileAttachments)
.where(eq(fileAttachments.uploaderId, uploaderId))
.orderBy(desc(fileAttachments.createdAt))
return rows.map(mapRow)
} catch (error) {
console.error("getFileAttachmentsByUploader failed:", error)
return []
}
},
)
/**
* 查询所有文件(用于管理员文件管理页面)
*/
export const getAllFileAttachments = cache(
async (limit = 100): Promise<FileAttachment[]> => {
try {
const rows = await db
.select()
.from(fileAttachments)
.orderBy(desc(fileAttachments.createdAt))
.limit(limit)
return rows.map(mapRow)
} catch (error) {
console.error("getAllFileAttachments failed:", error)
return []
}
},
)
/**
* 删除文件附件记录
*/
export async function deleteFileAttachment(id: string): Promise<boolean> {
try {
await db.delete(fileAttachments).where(eq(fileAttachments.id, id))
return true
} catch (error) {
console.error("deleteFileAttachment failed:", error)
return false
}
}
/**
* 批量删除文件附件记录
* 仅删除数据库记录,磁盘文件由调用方处理
*/
export async function deleteFileAttachments(ids: string[]): Promise<BatchDeleteResult> {
if (ids.length === 0) {
return { success: true, deletedCount: 0, failedIds: [] }
}
try {
await db.delete(fileAttachments).where(inArray(fileAttachments.id, ids))
return { success: true, deletedCount: ids.length, failedIds: [] }
} catch (error) {
console.error("deleteFileAttachments batch failed:", error)
// 失败时回退到逐条删除,尽量多删
const failedIds: string[] = []
let deletedCount = 0
for (const id of ids) {
try {
await db.delete(fileAttachments).where(eq(fileAttachments.id, id))
deletedCount += 1
} catch (err) {
console.error("deleteFileAttachments single failed:", err)
failedIds.push(id)
}
}
return {
success: failedIds.length === 0,
deletedCount,
failedIds,
}
}
}
/**
* 按条件筛选文件列表(管理员页面)
* - mimeType: 精确匹配或前缀匹配(如 "image/"
* - search: 在 originalName / filename 中模糊匹配
*/
export const getFileAttachmentsWithFilters = cache(
async (params: FileAttachmentQueryParams): Promise<FileAttachment[]> => {
try {
const { mimeType, search, limit = 100, offset = 0 } = params
const conditions = []
if (mimeType) {
if (mimeType.endsWith("/")) {
conditions.push(like(fileAttachments.mimeType, `${mimeType}%`))
} else {
conditions.push(eq(fileAttachments.mimeType, mimeType))
}
}
if (search) {
const kw = `%${search}%`
const nameCondition = or(
like(fileAttachments.originalName, kw),
like(fileAttachments.filename, kw)
)
if (nameCondition) conditions.push(nameCondition)
}
const where = conditions.length > 0 ? and(...conditions) : undefined
const rows = await db
.select()
.from(fileAttachments)
.where(where)
.orderBy(desc(fileAttachments.createdAt))
.limit(limit)
.offset(offset)
return rows.map(mapRow)
} catch (error) {
console.error("getFileAttachmentsWithFilters failed:", error)
return []
}
},
)
/**
* 获取文件统计信息(总数、总大小、按类型分组)
*/
export const getFileStats = cache(
async (): Promise<FileStats> => {
try {
const rows = await db
.select({
mimeType: fileAttachments.mimeType,
count: count(),
size: sql<number>`COALESCE(SUM(${fileAttachments.size}), 0)`,
})
.from(fileAttachments)
.groupBy(fileAttachments.mimeType)
const byType = rows.map((r) => ({
mimeType: r.mimeType,
count: Number(r.count),
size: Number(r.size),
}))
const totalCount = byType.reduce((sum, r) => sum + r.count, 0)
const totalSize = byType.reduce((sum, r) => sum + r.size, 0)
return { totalCount, totalSize, byType }
} catch (error) {
console.error("getFileStats failed:", error)
return { totalCount: 0, totalSize: 0, byType: [] }
}
},
)
/**
* 按 ID 列表批量查询文件(用于批量删除前获取磁盘路径)
*/
export const getFileAttachmentsByIds = cache(
async (ids: string[]): Promise<FileAttachment[]> => {
if (ids.length === 0) return []
try {
const rows = await db
.select()
.from(fileAttachments)
.where(inArray(fileAttachments.id, ids))
return rows.map(mapRow)
} catch (error) {
console.error("getFileAttachmentsByIds failed:", error)
return []
}
},
)