import { z } from 'zod'; import { createTRPCRouter, permissionRequiredProcedure } from '@/server/trpc'; import { generatePresignedPostPolicy, generatePresignedGetObject, type PresignedPostPolicyOptions, type PresignedGetObjectOptions } from '@/server/minio'; import { TRPCError, inferProcedureInput, inferProcedureOutput } from '@trpc/server'; import { randomBytes } from 'crypto'; import { Permissions } from '@/constants/permissions'; interface UploadConfig { /** 业务类型(category) public/开头是可以公开GetObject的 */ category: string; /** 最大文件大小(字节),默认 100MB */ maxSize?: number; /** 允许的文件类型(MIME类型,支持精确匹配和通配符如 'image/*'),默认允许所有类型 */ allowedContentType?: string; /** 过期时间(秒),默认 1 小时 */ expirySeconds?: number; /** 所需权限,默认为空字符串(需要登录但无特定权限要求) */ permission?: string; } // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface SingleUploadConfig extends UploadConfig {} // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface BatchUploadConfig extends UploadConfig {} interface DownloadConfig { /** 过期时间(秒),默认 1 小时 */ expirySeconds?: number; /** 所需权限,默认为空字符串(需要登录但无特定权限要求) */ permission?: string; } /** * 生成随机哈希字符串 */ function generateHash(length: number = 7): string { return randomBytes(Math.ceil(length / 2)) .toString('hex') .slice(0, length); } /** * 创建单文件上传接口的工厂函数 * * 生成的对象路径格式:${category}/${userId}/${hash}_${fileName} * * @param config 单文件上传配置 * @returns tRPC mutation procedure */ export function createSingleUploadProcedure(config: SingleUploadConfig) { const { category, maxSize = 100 * 1024 * 1024, // 默认 100MB allowedContentType, expirySeconds = 3600, permission = '', } = config; return permissionRequiredProcedure(permission) .input( z.object({ fileName: z.string().min(1, '文件名不能为空'), }) ) .mutation(async ({ input, ctx }) => { const { fileName } = input; const userId = ctx.session!.user.id; const hash = generateHash(); // 单文件上传指定完整的文件路径 const prefix = `${category}/${userId}`; const fullFileName = `${hash}_${fileName}`; try { const policyOptions: PresignedPostPolicyOptions = { prefix, fileName: fullFileName, expirySeconds: expirySeconds, maxSize, allowedContentType, }; const result = await generatePresignedPostPolicy(policyOptions); return { postURL: result.postURL, formData: result.formData, objectName: result.objectName, expiresIn: expirySeconds, maxSize, allowedContentType, }; } catch (error) { console.error('生成单文件上传策略失败:', error); throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '生成上传策略失败', }); } }); } /** * 创建批量上传接口的工厂函数 * * 生成的对象路径格式:${category}/${userId}/${hash}/(前缀,客户端上传时需要在此前缀下) * * @param config 批量上传配置 * @returns tRPC mutation procedure */ export function createBatchUploadProcedure(config: BatchUploadConfig) { const { category, maxSize = 100 * 1024 * 1024, // 默认 100MB allowedContentType, expirySeconds = 3600, permission = '', } = config; return permissionRequiredProcedure(permission) .mutation(async ({ ctx }) => { const userId = ctx.session!.user.id; const hash = generateHash(); // 批量上传只校验前缀,不指定具体文件名 const prefix = `${category}/${userId}/${hash}`; try { const policyOptions: PresignedPostPolicyOptions = { prefix, expirySeconds: expirySeconds, maxSize, allowedContentType, }; const result = await generatePresignedPostPolicy(policyOptions); return { postURL: result.postURL, formData: result.formData, prefix: result.objectName, expiresIn: expirySeconds, maxSize, allowedContentType, }; } catch (error) { console.error('生成批量上传策略失败:', error); throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '生成上传策略失败', }); } }); } /** * 创建下载接口的工厂函数 * * 生成预签名的 GET URL,用于下载对象 * 文件名默认使用对象的 x-amz-meta-original-filename 元信息 * * @param config 下载配置 * @returns tRPC mutation procedure */ export function createDownloadProcedure(config: DownloadConfig) { const { expirySeconds = 3600, permission = '', } = config; return permissionRequiredProcedure(permission) .input( z.object({ objectName: z.string().min(1, '对象名称不能为空'), fileName: z.string().optional(), contentType: z.string().optional(), }) ) .mutation(async ({ input }) => { const { objectName, fileName, contentType } = input; try { const options: PresignedGetObjectOptions = { objectName, expirySeconds, responseHeaders: {}, }; // 如果指定了自定义文件名,覆盖默认行为 if (fileName) { options.responseHeaders!['response-content-disposition'] = `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`; } // 如果指定了自定义 Content-Type,覆盖默认行为 if (contentType) { options.responseHeaders!['response-content-type'] = contentType; } const result = await generatePresignedGetObject(options); return { url: result.url, expiresIn: result.expiresIn, }; } catch (error) { console.error('生成下载链接失败:', error); throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: '生成下载链接失败', }); } }); } /** * 上传路由 * 提供文件上传相关的接口,使用工厂函数创建特定业务的上传接口 */ export const uploadRouter = createTRPCRouter({ }); export type SingleUploadProcedureInput = inferProcedureInput>; // createSingleUploadProcedure 创建的接口调用参数 export type SingleUploadProcedureOutput = inferProcedureOutput>; // createSingleUploadProcedure 创建的接口返回值 export type BatchUploadProcedureInput = inferProcedureInput>; // createBatchUploadProcedure 创建的接口调用参数 export type BatchUploadProcedureOutput = inferProcedureOutput>; // createBatchUploadProcedure 创建的接口返回值 export type DownloadProcedureInput = inferProcedureInput>; // createDownloadProcedure 创建的接口调用参数 export type DownloadProcedureOutput = inferProcedureOutput>; // createDownloadProcedure 创建的接口返回值