131 lines
4.3 KiB
TypeScript
Executable File
131 lines
4.3 KiB
TypeScript
Executable File
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';
|
|
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);
|
|
await this.resourceService.create({
|
|
data: {
|
|
title: getFilenameWithoutExt(upload.metadata.filename),
|
|
fileId, // 移除最后的文件名
|
|
url: upload.id,
|
|
meta: 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);
|
|
}
|
|
}
|