Hair Keeper v1.0.0:一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑
This commit is contained in:
255
src/server/routers/upload.ts
Normal file
255
src/server/routers/upload.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
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({
|
||||
// 供给照片批量上传接口
|
||||
supplyPhotos: createBatchUploadProcedure({
|
||||
category: 'transfer/supply',
|
||||
maxSize: 1 * 1024 * 1024, // 1MB
|
||||
allowedContentType: 'image/*',
|
||||
expirySeconds: 3600,
|
||||
permission: Permissions.TRANSFER_SUPPLY_CREATE,
|
||||
}),
|
||||
supplyPdfs: createBatchUploadProcedure({
|
||||
category: 'transfer/supply',
|
||||
maxSize: 1 * 1024 * 1024, // 1MB
|
||||
allowedContentType: 'application/pdf',
|
||||
expirySeconds: 3600,
|
||||
permission: Permissions.TRANSFER_SUPPLY_CREATE,
|
||||
}),
|
||||
// 已上传的供给照片下载
|
||||
downloadSupplyPhotos: createDownloadProcedure({
|
||||
expirySeconds: 3600,
|
||||
permission: Permissions.TRANSFER_SUPPLY_CREATE,
|
||||
})
|
||||
});
|
||||
|
||||
export type SingleUploadProcedureInput = inferProcedureInput<ReturnType<typeof createSingleUploadProcedure>>; // createSingleUploadProcedure 创建的接口调用参数
|
||||
export type SingleUploadProcedureOutput = inferProcedureOutput<ReturnType<typeof createSingleUploadProcedure>>; // createSingleUploadProcedure 创建的接口返回值
|
||||
export type BatchUploadProcedureInput = inferProcedureInput<ReturnType<typeof createBatchUploadProcedure>>; // createBatchUploadProcedure 创建的接口调用参数
|
||||
export type BatchUploadProcedureOutput = inferProcedureOutput<ReturnType<typeof createBatchUploadProcedure>>; // createBatchUploadProcedure 创建的接口返回值
|
||||
export type DownloadProcedureInput = inferProcedureInput<ReturnType<typeof createDownloadProcedure>>; // createDownloadProcedure 创建的接口调用参数
|
||||
export type DownloadProcedureOutput = inferProcedureOutput<ReturnType<typeof createDownloadProcedure>>; // createDownloadProcedure 创建的接口返回值
|
||||
Reference in New Issue
Block a user