training_data/apps/server/src/upload/tus.service.ts

119 lines
4.7 KiB
TypeScript

import { Injectable, OnModuleInit, Logger } from '@nestjs/common';
import { Server, Uid, Upload } from "@nice/tus"
import { FileStore } from '@nice/tus';
import { Request, Response } from "express"
import { db, ResourceStatus } from '@nice/common';
import { getFilenameWithoutExt } from '@server/utils/file';
import { ResourceService } from '@server/models/resource/resource.service';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { QueueJobType } from '@server/queue/types';
import { nanoid } from 'nanoid-cjs';
import { slugify } from 'transliteration';
import path from 'path';
const FILE_UPLOAD_CONFIG = {
directory: process.env.UPLOAD_DIR,
maxSizeBytes: 20_000_000_000, // 20GB
expirationPeriod: 24 * 60 * 60 * 1000 // 24 hours
};
@Injectable()
export class TusService implements OnModuleInit {
private readonly logger = new Logger(TusService.name);
private tusServer: Server;
constructor(private readonly resourceService: ResourceService,
@InjectQueue("file-queue") private fileQueue: Queue
) { }
onModuleInit() {
this.initializeTusServer();
this.setupTusEventHandlers();
}
private initializeTusServer() {
this.tusServer = new Server({
namingFunction(req, metadata) {
const safeFilename = slugify(metadata.filename);
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const uniqueId = nanoid(10);
return `${year}/${month}/${day}/${uniqueId}/${safeFilename}`;
},
path: '/upload',
datastore: new FileStore({
directory: FILE_UPLOAD_CONFIG.directory,
expirationPeriodInMilliseconds: FILE_UPLOAD_CONFIG.expirationPeriod
}),
maxSize: FILE_UPLOAD_CONFIG.maxSizeBytes,
postReceiveInterval: 1000,
getFileIdFromRequest: (req, lastPath) => {
const match = req.url.match(/\/upload\/(.+)/);
return match ? match[1] : lastPath;
}
});
}
private setupTusEventHandlers() {
this.tusServer.on("POST_CREATE", this.handleUploadCreate.bind(this));
this.tusServer.on("POST_FINISH", this.handleUploadFinish.bind(this));
}
private getFileId(uploadId: string) {
return uploadId.replace(/\/[^/]+$/, '')
}
private async handleUploadCreate(req: Request, res: Response, upload: Upload, url: string) {
try {
const fileId = this.getFileId(upload.id)
const filename = upload.metadata.filename
await this.resourceService.create({
data: {
title: getFilenameWithoutExt(upload.metadata.filename),
filename,
fileId, // 移除最后的文件名
url: upload.id,
metadata: upload.metadata,
status: ResourceStatus.UPLOADING
}
});
} catch (error) {
this.logger.error('Failed to create resource during upload', error);
}
}
private async handleUploadFinish(req: Request, res: Response, upload: Upload) {
try {
const resource = await this.resourceService.update({
where: { fileId: this.getFileId(upload.id) },
data: { status: ResourceStatus.UPLOADED }
});
this.fileQueue.add(QueueJobType.FILE_PROCESS, { resource }, { jobId: resource.id })
this.logger.log(`Upload finished ${resource.url}`);
} catch (error) {
this.logger.error('Failed to update resource after upload', error);
}
}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async cleanupExpiredUploads() {
try {
// Delete incomplete uploads older than 24 hours
const deletedResources = await db.resource.deleteMany({
where: {
createdAt: { lt: new Date(Date.now() - FILE_UPLOAD_CONFIG.expirationPeriod) },
status: ResourceStatus.UPLOADING
}
});
const expiredUploadCount = await this.tusServer.cleanUpExpiredUploads();
this.logger.log(`Cleanup complete: ${deletedResources.count} resources and ${expiredUploadCount} uploads removed`);
} catch (error) {
this.logger.error('Expired uploads cleanup failed', error);
}
}
async handleTus(req: Request, res: Response) {
return this.tusServer.handle(req, res);
}
}