Files
lcbp3/backend/src/common/file-storage/file-storage.service.ts
T
admin 1a162bf320
CI / CD Pipeline / build (push) Successful in 4m54s
CI / CD Pipeline / deploy (push) Failing after 12m9s
feat(rfa-ai): Complete RFA Approval Refactor and AI Model Revision
2026-05-16 10:59:53 +07:00

468 lines
17 KiB
TypeScript

// File: src/common/file-storage/file-storage.service.ts
import {
Injectable,
NotFoundException,
BadRequestException,
Logger,
Optional,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import * as fs from 'fs-extra';
import * as path from 'path';
import * as crypto from 'crypto';
import { v4 as uuidv4 } from 'uuid';
import { Attachment } from './entities/attachment.entity';
import { ForbiddenException } from '@nestjs/common'; // ✅ Import เพิ่ม
import {
QUEUE_AI_BATCH,
QUEUE_AI_REALTIME,
} from '../../modules/common/constants/queue.constants';
@Injectable()
export class FileStorageService {
private readonly logger = new Logger(FileStorageService.name);
private readonly tempDir: string;
private readonly permanentDir: string;
constructor(
@InjectRepository(Attachment)
private attachmentRepository: Repository<Attachment>,
private configService: ConfigService,
@Optional() @InjectQueue('rag-ocr') private readonly ragOcrQueue?: Queue,
@Optional()
@InjectQueue(QUEUE_AI_REALTIME)
private readonly aiRealtimeQueue?: Queue,
@Optional()
@InjectQueue(QUEUE_AI_BATCH)
private readonly aiBatchQueue?: Queue
) {
// ใช้ env vars จาก docker-compose สำหรับ Production
// ถ้าไม่ได้กำหนดจะ fallback เป็น ./uploads/temp และ ./uploads/permanent
this.tempDir =
this.configService.get<string>('UPLOAD_TEMP_DIR') ||
path.join(process.cwd(), 'uploads', 'temp');
this.permanentDir =
this.configService.get<string>('UPLOAD_PERMANENT_DIR') ||
path.join(process.cwd(), 'uploads', 'permanent');
// สร้างโฟลเดอร์ temp และ permanent รอไว้เลยถ้ายังไม่มี
fs.ensureDirSync(this.tempDir);
fs.ensureDirSync(this.permanentDir);
}
/**
* Phase 1: Upload (บันทึกไฟล์ลง Temp)
*/
async upload(file: Express.Multer.File, userId: number): Promise<Attachment> {
const tempId = uuidv4();
// Fix: แปลงชื่อไฟล์จาก Latin1 → UTF-8 (Multer/busboy decodes as Latin1 by default)
const originalFilename = this.fixMulterFilename(file.originalname);
const fileExt = path.extname(originalFilename);
const storedFilename = `${uuidv4()}${fileExt}`;
const tempPath = path.join(this.tempDir, storedFilename);
// 1. คำนวณ Checksum (SHA-256) เพื่อความปลอดภัยและความถูกต้องของไฟล์
const checksum = this.calculateChecksum(file.buffer);
// 2. บันทึกไฟล์ลง Disk (Temp Folder)
try {
await fs.writeFile(tempPath, file.buffer);
} catch (error) {
this.logger.error(`Failed to write file: ${tempPath}`, error);
throw new BadRequestException('File upload failed');
}
// 3. สร้าง Record ใน Database
const attachment = this.attachmentRepository.create({
originalFilename,
storedFilename: storedFilename,
filePath: tempPath, // เก็บ path ปัจจุบันไปก่อน
mimeType: file.mimetype,
fileSize: file.size,
isTemporary: true,
tempId: tempId,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // หมดอายุใน 24 ชม.
checksum: checksum,
uploadedByUserId: userId,
});
return this.attachmentRepository.save(attachment);
}
/**
* Phase 2: Commit (ย้ายไฟล์จาก Temp -> Permanent)
* เมธอดนี้จะถูกเรียกโดย Service อื่น (เช่น CorrespondenceService) เมื่อกด Save
*/
/**
* Phase 2: Commit (ย้ายไฟล์จาก Temp -> Permanent)
* เมธอดนี้จะถูกเรียกโดย Service อื่น (เช่น CorrespondenceService) เมื่อกด Save
* Updated [Phase 2]: Support issueDate and documentType for organized storage
*/
async commit(
tempIds: string[],
options?: {
issueDate?: Date;
documentType?: string;
ragMeta?: {
docType: string;
docNumber: string | null;
revision: string | null;
projectCode: string;
projectPublicId: string;
classification: 'PUBLIC' | 'INTERNAL' | 'CONFIDENTIAL';
};
}
): Promise<Attachment[]> {
if (!tempIds || tempIds.length === 0) {
return [];
}
const attachments = await this.attachmentRepository.find({
where: { tempId: In(tempIds), isTemporary: true },
});
if (attachments.length !== tempIds.length) {
// แจ้งเตือนแต่อาจจะไม่ throw ถ้าต้องการให้ process ต่อไปได้บางส่วน (ขึ้นอยู่กับ business logic)
// แต่เพื่อความปลอดภัยควรแจ้งว่าไฟล์ไม่ครบ
this.logger.warn(
`Expected ${tempIds.length} files to commit, but found ${attachments.length}`
);
throw new NotFoundException('Some files not found or already committed');
}
const committedAttachments: Attachment[] = [];
// Use issueDate if provided, otherwise default to current date
const refDate = options?.issueDate
? new Date(options.issueDate)
: new Date();
// Validate Date (in case invalid string passed)
const effectiveDate = isNaN(refDate.getTime()) ? new Date() : refDate;
const year = effectiveDate.getFullYear().toString();
const month = (effectiveDate.getMonth() + 1).toString().padStart(2, '0');
// Construct Path: permanent/{DocumentType}/{YYYY}/{MM}/filename
const docTypeFolder = options?.documentType || 'General';
// โฟลเดอร์ถาวรแยกตาม Type/ปี/เดือน
const permanentDir = path.join(
this.permanentDir,
docTypeFolder,
year,
month
);
await fs.ensureDir(permanentDir);
for (const att of attachments) {
const oldPath = att.filePath;
const newPath = path.join(permanentDir, att.storedFilename);
try {
// ย้ายไฟล์
if (await fs.pathExists(oldPath)) {
await fs.move(oldPath, newPath, { overwrite: true });
// อัปเดตข้อมูลใน DB
att.filePath = newPath;
att.isTemporary = false;
att.tempId = undefined; // เคลียร์ tempId
att.expiresAt = undefined; // เคลียร์วันหมดอายุ
att.referenceDate = effectiveDate; // Save reference date
const saved = await this.attachmentRepository.save(att);
committedAttachments.push(saved);
if (this.ragOcrQueue && options?.ragMeta) {
await this.ragOcrQueue
.add(
'ocr',
{
attachmentPublicId: saved.publicId,
filePath: saved.filePath,
...options.ragMeta,
},
{ jobId: saved.publicId }
)
.catch((err: unknown) => {
this.logger.error(
`Failed to enqueue rag-ocr for ${saved.publicId}`,
err instanceof Error ? err.stack : String(err)
);
});
}
if (options?.ragMeta?.projectPublicId) {
await this.enqueueAiJobsForCommittedAttachment(
saved,
options.ragMeta.projectPublicId
);
}
} else {
this.logger.error(`File missing during commit: ${oldPath}`);
throw new NotFoundException(
`File not found on disk: ${att.originalFilename}`
);
}
} catch (error) {
this.logger.error(
`Failed to move file from ${oldPath} to ${newPath}`,
error
);
throw new BadRequestException(
`Failed to commit file: ${att.originalFilename}`
);
}
}
return committedAttachments;
}
/**
* ADR-021: Preview File by publicId (Content-Disposition: inline)
* ดึงไฟล์มาเป็น Stream สำหรับแสดงผลใน Browser โดยตรง (ใช้กับ FilePreviewModal)
*/
async preview(
publicId: string
): Promise<{ stream: fs.ReadStream; attachment: Attachment }> {
const attachment = await this.attachmentRepository.findOne({
where: { publicId },
});
if (!attachment) {
throw new NotFoundException(`Attachment not found`);
}
const filePath = attachment.filePath;
if (!fs.existsSync(filePath)) {
this.logger.error(`Preview file missing on disk: ${filePath}`);
throw new NotFoundException('File not found on server storage');
}
const stream = fs.createReadStream(filePath);
return { stream, attachment };
}
/**
* Download File
* ดึงไฟล์มาเป็น Stream เพื่อส่งกลับไปให้ Controller
*/
async download(
id: number
): Promise<{ stream: fs.ReadStream; attachment: Attachment }> {
// 1. ค้นหาข้อมูลไฟล์จาก DB
const attachment = await this.attachmentRepository.findOne({
where: { id },
});
if (!attachment) {
throw new NotFoundException(`Attachment #${id} not found`);
}
// 2. ตรวจสอบว่าไฟล์มีอยู่จริงบน Disk หรือไม่
const filePath = attachment.filePath;
if (!fs.existsSync(filePath)) {
this.logger.error(`File missing on disk: ${filePath}`);
throw new NotFoundException('File not found on server storage');
}
// 3. สร้าง Read Stream (มีประสิทธิภาพกว่าการโหลดทั้งไฟล์เข้า Memory)
const stream = fs.createReadStream(filePath);
return { stream, attachment };
}
/**
* แก้ปัญหา Multer/busboy ถอดรหัสชื่อไฟล์เป็น Latin1 แทน UTF-8
* ทำให้ภาษาไทยกลายเป็น mojibake (เช่น ผรม → 脿赂聹脿赂拢脿赂隆)
* วิธีแก้: แปลง latin1 bytes กลับเป็น UTF-8
*/
private fixMulterFilename(originalname: string): string {
try {
const decoded = Buffer.from(originalname, 'latin1').toString('utf8');
// ตรวจสอบว่า decoded string มี valid UTF-8 characters
// ถ้า originalname เป็น ASCII อยู่แล้ว ผลลัพธ์จะเหมือนเดิม
return decoded;
} catch {
return originalname;
}
}
private calculateChecksum(buffer: Buffer): string {
return crypto.createHash('sha256').update(buffer).digest('hex');
}
private async enqueueAiJobsForCommittedAttachment(
attachment: Attachment,
projectPublicId: string
): Promise<void> {
const commonPayload = {
documentPublicId: attachment.publicId,
projectPublicId,
payload: { pdfPath: attachment.filePath },
};
const suggestResult = await this.aiRealtimeQueue
?.add(
'ai-suggest',
{
...commonPayload,
jobType: 'ai-suggest',
idempotencyKey: `suggest:${attachment.publicId}`,
},
{ jobId: `suggest:${attachment.publicId}` }
)
.then(() => true)
.catch((err: unknown) => {
this.logger.warn(
`AI job queue failed, document saved without AI: ${attachment.publicId} (${err instanceof Error ? err.message : String(err)})`
);
return false;
});
const embedResult = await this.aiBatchQueue
?.add(
'embed-document',
{
...commonPayload,
jobType: 'embed-document',
idempotencyKey: `embed:${attachment.publicId}`,
},
{ jobId: `embed:${attachment.publicId}` }
)
.then(() => true)
.catch((err: unknown) => {
this.logger.warn(
`AI job queue failed, document saved without AI: ${attachment.publicId} (${err instanceof Error ? err.message : String(err)})`
);
return false;
});
if (suggestResult === false || embedResult === false) {
await this.attachmentRepository.update(
{ publicId: attachment.publicId },
{ aiProcessingStatus: 'FAILED' }
);
}
}
/**
* ✅ NEW: Import Staging File (For Legacy Migration)
* ย้ายไฟล์จาก staging_ai ไปยัง permanent storage โดยตรง
*/
async importStagingFile(
sourceFilePath: string,
userId: number,
options?: { issueDate?: Date; documentType?: string }
): Promise<Attachment> {
if (!(await fs.pathExists(sourceFilePath))) {
this.logger.error(`Staging file not found: ${sourceFilePath}`);
throw new NotFoundException(`Source file not found: ${sourceFilePath}`);
}
// 1. Get file stats & checksum
const stats = await fs.stat(sourceFilePath);
const fileExt = path.extname(sourceFilePath);
const originalFilename = path.basename(sourceFilePath);
const storedFilename = `${uuidv4()}${fileExt}`;
// Determine mime type basic
let mimeType = 'application/octet-stream';
if (fileExt.toLowerCase() === '.pdf') mimeType = 'application/pdf';
else if (fileExt.toLowerCase() === '.xlsx')
mimeType =
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
const fileBuffer = await fs.readFile(sourceFilePath);
const checksum = this.calculateChecksum(fileBuffer);
// 2. Generate Permanent Path
const refDate = options?.issueDate || new Date();
const effectiveDate = isNaN(refDate.getTime()) ? new Date() : refDate;
const year = effectiveDate.getFullYear().toString();
const month = (effectiveDate.getMonth() + 1).toString().padStart(2, '0');
const docTypeFolder = options?.documentType || 'General';
const permanentDir = path.join(
this.permanentDir,
docTypeFolder,
year,
month
);
await fs.ensureDir(permanentDir);
const newPath = path.join(permanentDir, storedFilename);
// 3. Move File
try {
await fs.move(sourceFilePath, newPath, { overwrite: true });
} catch (error) {
this.logger.error(`Failed to move staging file to ${newPath}`, error);
throw new BadRequestException('Failed to process staging file');
}
// 4. Create Database Record
const attachment = this.attachmentRepository.create({
originalFilename,
storedFilename,
filePath: newPath,
mimeType,
fileSize: stats.size,
isTemporary: false,
referenceDate: effectiveDate,
checksum,
uploadedByUserId: userId,
});
return this.attachmentRepository.save(attachment);
}
/**
* ✅ NEW: Delete File
* ลบไฟล์ออกจาก Disk และ Database
*/
async delete(id: number, userId: number): Promise<void> {
// 1. ค้นหาไฟล์
const attachment = await this.attachmentRepository.findOne({
where: { id },
});
if (!attachment) {
throw new NotFoundException(`Attachment #${id} not found`);
}
// 2. ตรวจสอบความเป็นเจ้าของ (Security Check)
// อนุญาตให้ลบถ้าเป็นคนอัปโหลดเอง
// (ในอนาคตอาจเพิ่มเงื่อนไข OR User เป็น Admin/Document Control)
if (attachment.uploadedByUserId !== userId) {
this.logger.warn(
`User ${userId} tried to delete file ${id} owned by ${attachment.uploadedByUserId}`
);
throw new ForbiddenException('You are not allowed to delete this file');
}
// 3. ลบไฟล์ออกจาก Disk
try {
if (await fs.pathExists(attachment.filePath)) {
await fs.remove(attachment.filePath);
} else {
this.logger.warn(
`File not found on disk during deletion: ${attachment.filePath}`
);
}
} catch (error) {
this.logger.error(
`Failed to delete file from disk: ${attachment.filePath}`,
error
);
throw new BadRequestException('Failed to delete file from storage');
}
// 4. ลบ Record ออกจาก Database
await this.attachmentRepository.remove(attachment);
this.logger.log(`File deleted: ${id} by user ${userId}`);
}
}