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

351
src/server/minio.ts Normal file
View File

@@ -0,0 +1,351 @@
import 'server-only'
import { Client } from 'minio';
// 初始化 MinIO 客户端
export const minioClient = new Client({
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
port: parseInt(process.env.MINIO_API_PORT || '9000'),
useSSL: process.env.MINIO_USE_SSL === 'true',
accessKey: process.env.MINIO_ROOT_USER || '',
secretKey: process.env.MINIO_ROOT_PASSWORD || '',
});
export const BUCKET_NAME = process.env.MINIO_BUCKET || 'app-files';
// 桶初始化标志
let bucketInitialized = false;
/**
* 检查并初始化存储桶
* 如果桶不存在则创建,并设置为公开读取策略(可根据需求调整)
*/
export async function ensureBucketExists(): Promise<void> {
if (bucketInitialized) {
return;
}
try {
const exists = await minioClient.bucketExists(BUCKET_NAME);
if (!exists) {
await minioClient.makeBucket(BUCKET_NAME, 'cn-beijing-1');
console.log(`MinIO bucket '${BUCKET_NAME}' created successfully`);
// 设置桶策略:默认私有,但 /public 路径下的文件公开可读
const policy = {
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Principal: { AWS: ['*'] },
Action: ['s3:GetObject'],
Resource: [`arn:aws:s3:::${BUCKET_NAME}/public/*`],
},
],
};
await minioClient.setBucketPolicy(BUCKET_NAME, JSON.stringify(policy));
console.log(`MinIO bucket '${BUCKET_NAME}' policy set: private by default, public read for /public path`);
}
bucketInitialized = true;
} catch (error) {
console.error('Failed to ensure bucket exists:', error);
throw error;
}
}
/**
* 预签名 POST 策略配置
*/
export interface PresignedPostPolicyOptions {
/** 对象名称前缀(不含文件名) public开头是可以公开GetObject */
prefix: string;
/** 文件名(可选,如果指定则只允许上传该文件名) */
fileName?: string;
/** 过期时间(秒),默认 1 小时 */
expirySeconds?: number;
/** 最大文件大小(字节),默认 100MB */
maxSize?: number;
/** 允许的文件类型MIME类型数组默认允许所有类型 */
allowedContentType?: string;
/** 是否允许上传原始文件名元信息,默认 true */
allowOriginalFilename?: boolean;
}
/**
* 预签名 POST 策略结果
*/
export interface PresignedPostPolicyResult {
/** POST 请求的 URL */
postURL: string;
/** POST 请求需要的表单字段 */
formData: Record<string, string>;
/** 对象名称(完整路径) */
objectName: string;
}
/**
* 生成预签名的 POST 策略(用于客户端直传)
* 支持单文件上传指定fileName或批量上传不指定fileName只校验前缀
* 如果客户端无法上传,通过 mc admin trace <ALIAS> --verbose --all 排查minio存储桶那边的详细日志
*/
export async function generatePresignedPostPolicy(
options: PresignedPostPolicyOptions
): Promise<PresignedPostPolicyResult> {
await ensureBucketExists();
const {
prefix,
fileName,
expirySeconds = 3600,
maxSize = 100 * 1024 * 1024, // 默认 100MB
allowedContentType,
allowOriginalFilename = true
} = options;
try {
const policy = minioClient.newPostPolicy();
// 设置过期时间
const expiryDate = new Date();
expiryDate.setSeconds(expiryDate.getSeconds() + expirySeconds);
policy.setExpires(expiryDate);
// 设置存储桶
policy.setBucket(BUCKET_NAME);
// 构建完整的对象名称
const objectName = fileName ? `${prefix}/${fileName}` : prefix;
// 如果指定了文件名,则精确匹配;否则只匹配前缀
if (fileName) {
policy.setKey(objectName);
} else {
policy.setKeyStartsWith(prefix);
}
// 设置文件大小限制
policy.setContentLengthRange(1, maxSize);
// 设置允许的内容类型(支持精确匹配和通配符)
if (allowedContentType) {
// 判断是否为通配符模式(如 'image/*'
if (allowedContentType.endsWith('/*')) {
// 将通配符模式转换为前缀(如 'image/*' -> 'image/'
const prefix = allowedContentType.slice(0, -1);
policy.setContentTypeStartsWith(prefix);
} else {
// 精确匹配
policy.setContentType(allowedContentType);
}
}
if (allowOriginalFilename) {
// 允许客户端设置 x-amz-meta-original-filename 元信息
policy.policy.conditions.push(['starts-with', '$x-amz-meta-original-filename', ''])
}
// 生成预签名 POST 数据
const presignedData = await minioClient.presignedPostPolicy(policy);
return {
postURL: presignedData.postURL,
formData: presignedData.formData,
objectName,
};
} catch (error) {
console.error('Failed to generate presigned POST policy:', error);
throw error;
}
}
/**
* 预签名 GET 对象配置
*/
export interface PresignedGetObjectOptions {
/** 对象名称(文件路径) */
objectName: string;
/** 过期时间(秒),默认 1 小时 */
expirySeconds?: number;
/** 自定义响应头 */
responseHeaders?: {
/** 下载时的文件名,默认使用对象的 x-amz-meta-original-filename 元信息 */
'response-content-disposition'?: string;
/** 内容类型 */
'response-content-type'?: string;
};
}
/**
* 预签名 GET 对象结果
*/
export interface PresignedGetObjectResult {
/** 预签名的 GET URL */
url: string;
/** 过期时间(秒) */
expiresIn: number;
}
/**
* 生成预签名的 GET URL用于下载对象
* 文件名默认使用对象的 x-amz-meta-original-filename 元信息
*/
export async function generatePresignedGetObject(
options: PresignedGetObjectOptions
): Promise<PresignedGetObjectResult> {
await ensureBucketExists();
const {
objectName,
expirySeconds = 3600,
responseHeaders = {},
} = options;
try {
// 如果没有指定响应头,尝试从对象元数据中获取
if (!responseHeaders['response-content-disposition'] || !responseHeaders['response-content-type']) {
try {
const metadata = await minioClient.statObject(BUCKET_NAME, objectName);
// 设置 Content-Disposition文件名
if (!responseHeaders['response-content-disposition']) {
const originalFilename = metadata.metaData?.['original-filename'];
if (originalFilename) {
// 使用 attachment 强制下载,并设置文件名
responseHeaders['response-content-disposition'] =
`attachment; filename*=UTF-8''${originalFilename}`;
}
}
// 设置 Content-Type
if (!responseHeaders['response-content-type'] && metadata.metaData?.['content-type']) {
responseHeaders['response-content-type'] = metadata.metaData['content-type'];
}
} catch (error) {
// 如果获取元数据失败,继续使用默认行为
console.warn('Failed to get object metadata:', error);
}
}
// 生成预签名 URL
const url = await minioClient.presignedGetObject(
BUCKET_NAME,
objectName,
expirySeconds,
responseHeaders
);
return {
url,
expiresIn: expirySeconds,
};
} catch (error) {
console.error('Failed to generate presigned GET URL:', error);
throw error;
}
}
/**
* 删除对象
* @param objectName 对象名称(文件路径)
*/
export async function deleteObject(objectName: string): Promise<void> {
await ensureBucketExists();
try {
await minioClient.removeObject(BUCKET_NAME, objectName);
} catch (error) {
console.error('Failed to delete object:', error);
throw error;
}
}
/**
* 批量删除对象
* @param objectNames 对象名称数组
*/
export async function deleteBatchObjects(objectNames: string[]): Promise<void> {
await ensureBucketExists();
try {
await minioClient.removeObjects(BUCKET_NAME, objectNames);
} catch (error) {
console.error('Failed to delete batch objects:', error);
throw error;
}
}
/**
* 检查对象是否存在
* @param objectName 对象名称(文件路径)
* @returns 是否存在
*/
export async function objectExists(objectName: string): Promise<boolean> {
await ensureBucketExists();
try {
await minioClient.statObject(BUCKET_NAME, objectName);
return true;
} catch (error) {
return false;
}
}
/**
* 批量检查对象是否存在
* @param objectNames 对象名称数组
* @returns 对象存在状态的映射表 { objectName: boolean }
*/
export async function batchObjectExists(
objectNames: string[]
): Promise<Record<string, boolean>> {
await ensureBucketExists();
const results: Record<string, boolean> = {};
// 并发检查所有对象
await Promise.all(
objectNames.map(async (objectName) => {
try {
await minioClient.statObject(BUCKET_NAME, objectName);
results[objectName] = true;
} catch (error) {
results[objectName] = false;
}
})
);
return results;
}
/**
* 获取对象元数据
* @param objectName 对象名称(文件路径)
* @returns 对象元数据
*/
export async function getObjectMetadata(objectName: string) {
await ensureBucketExists();
try {
const stat = await minioClient.statObject(BUCKET_NAME, objectName);
return stat;
} catch (error) {
console.error('Failed to get object metadata:', error);
throw error;
}
}
/**
* 生成公开访问的 URL需要桶策略支持公开读取
* @param objectName 对象名称(文件路径)
* @returns 公开访问 URL
*/
export function getPublicUrl(objectName: string): string {
const protocol = process.env.MINIO_USE_SSL === 'true' ? 'https' : 'http';
const endpoint = process.env.MINIO_ENDPOINT || 'localhost';
const port = process.env.MINIO_API_PORT || '9000';
return `${protocol}://${endpoint}:${port}/${BUCKET_NAME}/${objectName}`;
}