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.
286 lines
7.4 KiB
TypeScript
286 lines
7.4 KiB
TypeScript
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 []
|
||
}
|
||
},
|
||
)
|