本次更新包含以下主要改进: ## 新功能 - 添加quickstart.sh脚本帮助用户快速使用模板项目 - 添加simple_deploy.sh便于部署 - 新增院系管理功能(DeptAdmin),支持增删改查院系管理员信息 - 用户可以在header中切换管理的院系 - 添加zustand全局状态管理 - 添加DEFAULT_USER_PASSWORD环境变量,作为创建用户时的默认密码 - 添加p-limit库和DB_PARALLEL_LIMIT环境变量控制数据库批次操作并发数 ## 安全修复 - 修复Next.js CVE-2025-66478漏洞 - 限制只有超级管理员才能创建超级管理员用户 ## 开发环境优化 - 开发终端兼容云端环境 - MinIO客户端直传兼容云端环境 - 开发容器增加vim和Claude Code插件 - 编程代理改用Claude - docker-compose.yml添加全局name属性 ## Bug修复与代码优化 - 删除用户时级联删除SelectionLog - 手机端关闭侧边栏后刷新页面延迟调整(300ms=>350ms) - instrumentation.ts移至src内部以适配生产环境 - 删除部分引发类型错误的无用代码 - 优化quickstart.sh远程仓库推送相关配置 ## 文件变更 - 新增49个文件,修改多个配置和源代码文件 - 重构用户管理模块目录结构 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
374 lines
11 KiB
TypeScript
374 lines
11 KiB
TypeScript
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';
|
||
|
||
/**
|
||
* 获取客户端访问的基础 URL
|
||
* 优先使用 MINIO_SERVER_URL(公网地址),否则使用内部地址
|
||
*/
|
||
function getClientBaseUrl(): string {
|
||
if (process.env.MINIO_SERVER_URL) {
|
||
return process.env.MINIO_SERVER_URL;
|
||
}
|
||
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}`;
|
||
}
|
||
|
||
/**
|
||
* 替换预签名 URL 中的内部地址为客户端可访问的地址
|
||
*/
|
||
function replaceUrlBase(originalUrl: string): string {
|
||
const clientBase = getClientBaseUrl();
|
||
const url = new URL(originalUrl);
|
||
const clientUrl = new URL(clientBase);
|
||
url.protocol = clientUrl.protocol;
|
||
url.hostname = clientUrl.hostname;
|
||
url.port = clientUrl.port; // 空字符串表示使用协议默认端口
|
||
return url.toString();
|
||
}
|
||
|
||
// 桶初始化标志
|
||
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: replaceUrlBase(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: replaceUrlBase(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 {
|
||
return `${getClientBaseUrl()}/${BUCKET_NAME}/${objectName}`;
|
||
} |