Files
hair-keeper/src/server/routers/upload.ts
liuyh 5020bd1532 feat: Hair Keeper v1.1.0 版本更新
本次更新包含以下主要改进:

## 新功能
- 添加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>
2025-12-23 16:58:55 +08:00

237 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 创建的接口返回值