- 删除根 docs/ 中的 audit/issue 文档 - 删除 api-server/docs/ 中的 16 个审核/里程碑文档 - rag-worker/ 从 api-server 迁出为独立项目 - 新增 admin 导入批量重新解析、按知识库筛选、后端排序 - 新增 source 软删除标记,防止已删 source 重新解析 - 修复 upload-url 500(重复 UploadSession 创建 + 缺 sizeBytes) - 修复 loadContextByScope 读 chunks 而非依赖 textLength - 修复 MaterialReadingProgress.create() increment 语法错误 - 新增 LoggingInterceptor 记录所有 HTTP 请求 - 新增学习会话批量删除接口 - enrichWithNames 增加 email 回退 - 全局异常过滤器和 name-resolver 增强 Co-Authored-By: Claude <noreply@anthropic.com>
140 lines
4.0 KiB
TypeScript
140 lines
4.0 KiB
TypeScript
import { Injectable, BadRequestException, Logger } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { CosStorageProvider } from './cos-storage.provider';
|
|
import { LocalStorageProvider } from './local-storage.provider';
|
|
import {
|
|
sanitizeFilename,
|
|
validateFileUpload,
|
|
} from '../../common/utils/security.util';
|
|
|
|
export interface CreateUploadUrlInput {
|
|
filename: string;
|
|
mimeType: string;
|
|
sizeBytes: number;
|
|
objectKey?: string;
|
|
}
|
|
|
|
export interface UploadUrlResult {
|
|
uploadUrl: string;
|
|
objectKey: string;
|
|
bucket: string;
|
|
region: string;
|
|
expiresIn: number;
|
|
}
|
|
|
|
export interface VerifiedUpload {
|
|
size: number;
|
|
etag: string;
|
|
contentType: string;
|
|
}
|
|
|
|
@Injectable()
|
|
export class StorageService {
|
|
private readonly logger = new Logger(StorageService.name);
|
|
private readonly driver: 'cos' | 'local';
|
|
|
|
constructor(
|
|
private readonly cos: CosStorageProvider,
|
|
private readonly local: LocalStorageProvider,
|
|
private readonly configService: ConfigService,
|
|
) {
|
|
this.driver = (configService.get<string>('storage.driver', 'cos')) as 'cos' | 'local';
|
|
this.logger.log(`Storage driver: ${this.driver}`);
|
|
}
|
|
|
|
getUploadPath(filename: string): string {
|
|
const basePath = this.configService.get<string>(
|
|
'storage.localPath',
|
|
'./uploads',
|
|
);
|
|
return `${basePath}/${filename}`;
|
|
}
|
|
|
|
generateObjectKey(userId: string, originalFilename: string, uploadId?: string): string {
|
|
const date = new Date();
|
|
const yearMonth = `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}`;
|
|
const safeName = sanitizeFilename(originalFilename);
|
|
const env = this.configService.get<string>('app.nodeEnv', 'development');
|
|
const prefix = env === 'production' ? '' : 'dev/';
|
|
return uploadId
|
|
? `${prefix}${userId}/${yearMonth}/${uploadId}-${safeName}`
|
|
: `${prefix}${userId}/${yearMonth}/${safeName}`;
|
|
}
|
|
|
|
async createUploadUrl(
|
|
userId: string,
|
|
input: CreateUploadUrlInput,
|
|
expiresIn = 3600,
|
|
): Promise<UploadUrlResult> {
|
|
validateFileUpload(input.mimeType, input.sizeBytes);
|
|
|
|
const objectKey = input.objectKey ?? this.generateObjectKey(userId, input.filename);
|
|
return this.createUploadUrlForObject(objectKey, expiresIn);
|
|
}
|
|
|
|
async createUploadUrlForObject(
|
|
objectKey: string,
|
|
expiresIn = 3600,
|
|
): Promise<UploadUrlResult> {
|
|
if (this.driver === 'local') {
|
|
const result = await this.local.generateUploadUrl(objectKey, expiresIn);
|
|
return {
|
|
uploadUrl: result.uploadUrl,
|
|
objectKey: result.objectKey,
|
|
bucket: result.bucket,
|
|
region: result.region,
|
|
expiresIn,
|
|
};
|
|
}
|
|
|
|
const result = await this.cos.generateUploadUrl(objectKey, expiresIn);
|
|
return {
|
|
uploadUrl: result.uploadUrl,
|
|
objectKey: result.objectKey,
|
|
bucket: result.bucket,
|
|
region: result.region,
|
|
expiresIn,
|
|
};
|
|
}
|
|
|
|
async verifyUpload(objectKey: string): Promise<VerifiedUpload> {
|
|
const info = this.driver === 'local'
|
|
? await this.local.headObject(objectKey)
|
|
: await this.cos.headObject(objectKey);
|
|
|
|
if (!info) {
|
|
throw new BadRequestException('文件未被上传到存储服务');
|
|
}
|
|
return info;
|
|
}
|
|
|
|
async getDownloadUrl(
|
|
objectKey: string,
|
|
expiresInSeconds?: number,
|
|
): Promise<string> {
|
|
return this.driver === 'local'
|
|
? this.local.generateDownloadUrl(objectKey, expiresInSeconds)
|
|
: this.cos.generateDownloadUrl(objectKey, expiresInSeconds);
|
|
}
|
|
|
|
async deleteObject(objectKey: string): Promise<void> {
|
|
if (this.driver === 'local') {
|
|
await this.local.deleteObject(objectKey);
|
|
} else {
|
|
await this.cos.deleteObject(objectKey);
|
|
}
|
|
}
|
|
|
|
async getObject(objectKey: string): Promise<Buffer | null> {
|
|
return this.driver === 'local'
|
|
? this.local.getObject(objectKey)
|
|
: this.cos.getObject(objectKey);
|
|
}
|
|
|
|
async healthCheck(): Promise<boolean> {
|
|
return this.driver === 'local'
|
|
? this.local.healthCheck()
|
|
: this.cos.healthCheck();
|
|
}
|
|
}
|