本次更新包含以下主要改进: ## 新功能 - 添加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>
237 lines
7.5 KiB
TypeScript
237 lines
7.5 KiB
TypeScript
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<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 创建的接口返回值
|