Hair Keeper v1.0.0:一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑

This commit is contained in:
2025-11-13 15:24:54 +08:00
commit 42be39b343
249 changed files with 38843 additions and 0 deletions

View 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 创建的接口返回值