Files
NextEdu/src/modules/files/data-access.ts

338 lines
9.0 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, type SQL } from "drizzle-orm"
import { cacheFn } from "@/shared/lib/cache"
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 getFileAttachmentRaw = 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 getFileAttachment = cacheFn(getFileAttachmentRaw, {
tags: ["files"],
ttl: 300,
keyParts: ["files", "getFileAttachment"],
})
/**
* 按关联资源查询文件列表
*/
export const getFileAttachmentsByTargetRaw = 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 getFileAttachmentsByTarget = cacheFn(getFileAttachmentsByTargetRaw, {
tags: ["files"],
ttl: 300,
keyParts: ["files", "getFileAttachmentsByTarget"],
})
/**
* 按上传者查询文件列表
*/
export const getFileAttachmentsByUploaderRaw = 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 getFileAttachmentsByUploader = cacheFn(getFileAttachmentsByUploaderRaw, {
tags: ["files"],
ttl: 300,
keyParts: ["files", "getFileAttachmentsByUploader"],
})
/**
* 查询所有文件(用于管理员文件管理页面)
*/
export const getAllFileAttachmentsRaw = 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 const getAllFileAttachments = cacheFn(getAllFileAttachmentsRaw, {
tags: ["files"],
ttl: 300,
keyParts: ["files", "getAllFileAttachments"],
})
/**
* 删除文件附件记录
*/
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 getFileAttachmentsWithFiltersRaw = async (params: FileAttachmentQueryParams): Promise<FileAttachment[]> => {
try {
const { mimeType, search, limit = 100, offset = 0 } = params
const conditions: SQL[] = []
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 getFileAttachmentsWithFilters = cacheFn(getFileAttachmentsWithFiltersRaw, {
tags: ["files"],
ttl: 300,
keyParts: ["files", "getFileAttachmentsWithFilters"],
})
/**
* 获取文件统计信息(总数、总大小、按类型分组)
*/
export const getFileStatsRaw = 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: [] }
}
}
export const getFileStats = cacheFn(getFileStatsRaw, {
tags: ["files"],
ttl: 300,
keyParts: ["files", "getFileStats"],
})
/**
* 按 URL 查询文件附件(用于头像等场景的旧文件清理)
*/
export const getFileByUrlRaw = async (url: string): Promise<FileAttachment | null> => {
try {
const [row] = await db
.select()
.from(fileAttachments)
.where(eq(fileAttachments.url, url))
.limit(1)
return row ? mapRow(row) : null
} catch (error) {
console.error("getFileByUrl failed:", error)
return null
}
}
export const getFileByUrl = cacheFn(getFileByUrlRaw, {
tags: ["files"],
ttl: 300,
keyParts: ["files", "getFileByUrl"],
})
/**
* 按 ID 列表批量查询文件(用于批量删除前获取磁盘路径)
*/
export const getFileAttachmentsByIdsRaw = 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 []
}
}
export const getFileAttachmentsByIds = cacheFn(getFileAttachmentsByIdsRaw, {
tags: ["files"],
ttl: 300,
keyParts: ["files", "getFileAttachmentsByIds"],
})