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); } }