# **BACKEND DEVELOPMENT** ## **Phase 2: High-Integrity Data & File Management** ### **T2.1 CommonModule** ### **T2.2 FileStorageService - Two-Phase Storage** ระบบนี้ออกแบบมาเพื่อแก้ปัญหา "ไฟล์ขยะ" (Orphan Files) ที่เกิดจากการอัปโหลดแล้ว User ไม่กดยืนยัน โดยเราจะแบ่งการทำงานเป็น 2 เฟส: 1. **Upload (Temp):** เอาไฟล์ไปพักไว้ก่อน (ยังไม่ลง DB ถาวร) 2. **Commit (Permanent):** เมื่อ User กด Save ฟอร์มสำเร็จ ค่อยย้ายไฟล์ไปเก็บจริง ----- #### ขั้นตอนที่ 1: ติดตั้ง Libraries ที่จำเป็น เราต้องใช้ `multer` (จัดการ Upload), `uuid` (สร้างชื่อไฟล์/Temp ID), และ `fs-extra` (จัดการย้ายไฟล์) ครับ รันคำสั่งใน Terminal: ```bash pnpm add @nestjs/platform-express multer uuid fs-extra pnpm add -D @types/multer @types/uuid @types/fs-extra ``` #### ขั้นตอนที่ 2: สร้าง Module และ Entity เราจะสร้าง Module แยกออกมาเพื่อจัดการเรื่องไฟล์โดยเฉพาะครับ รันคำสั่ง: ```bash nest g module modules/file-storage nest g service modules/file-storage nest g controller modules/file-storage ``` จากนั้นสร้าง **Attachment Entity** ให้ตรงตาม Data Dictionary สร้างไฟล์: `src/modules/file-storage/entities/attachment.entity.ts` ```typescript import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; import { User } from '../../user/entities/user.entity.js'; @Entity('attachments') export class Attachment { @PrimaryGeneratedColumn() id!: number; @Column({ name: 'original_filename', length: 255 }) originalFilename!: string; @Column({ name: 'stored_filename', length: 255 }) storedFilename!: string; @Column({ name: 'file_path', length: 500 }) filePath!: string; @Column({ name: 'mime_type', length: 100 }) mimeType!: string; @Column({ name: 'file_size' }) fileSize!: number; @Column({ name: 'is_temporary', default: true }) isTemporary!: boolean; @Column({ name: 'temp_id', length: 100, nullable: true }) tempId?: string; @Column({ name: 'expires_at', type: 'datetime', nullable: true }) expiresAt?: Date; @Column({ length: 64, nullable: true }) checksum?: string; @Column({ name: 'uploaded_by_user_id' }) uploadedByUserId!: number; @CreateDateColumn({ name: 'created_at' }) createdAt!: Date; // Relation กับ User (คนอัปโหลด) @ManyToOne(() => User) @JoinColumn({ name: 'uploaded_by_user_id' }) uploadedBy?: User; } ``` #### ขั้นตอนที่ 3: ลงทะเบียน Entity อย่าลืมไปลงทะเบียน `Attachment` ใน `FileStorageModule` ครับ แก้ไขไฟล์ `src/modules/file-storage/file-storage.module.ts`: ```typescript import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { FileStorageService } from './file-storage.service.js'; import { FileStorageController } from './file-storage.controller.js'; import { Attachment } from './entities/attachment.entity.js'; @Module({ imports: [TypeOrmModule.forFeature([Attachment])], controllers: [FileStorageController], providers: [FileStorageService], exports: [FileStorageService], // Export ให้ Module อื่น (เช่น Correspondence) เรียกใช้ตอน Commit }) export class FileStorageModule {} ``` ----- #### 📥 งานถัดไป (สำคัญมาก) ตอนนี้เรามีถังเก็บข้อมูล (Entity) แล้ว ขั้นตอนต่อไปเราจะเขียน **`FileStorageService`** ซึ่งจะมี Logic สำคัญ 2 ส่วน: 1. **`upload(file, userId)`**: เซฟลง Temp, สร้าง Temp ID 2. **`commit(tempIds)`**: ย้ายจาก Temp -\> Permanent, เปลี่ยน `is_temporary` เป็น false เยี่ยมครับ\! เรามาลุยเขียน **`FileStorageService`** ที่เป็นหัวใจสำคัญของการจัดการไฟล์กันครับ Service นี้จะทำหน้าที่ **จัดการไฟล์ลง Disk** และ **บันทึกข้อมูลลง Database** โดยใช้หลักการ Two-Phase Storage ตามแผนครับ ##### 1. แก้ไข `src/modules/file-storage/file-storage.service.ts` ผมเขียนโค้ดให้รองรับทั้งการสร้าง Checksum, การจัดการ Path และการย้ายไฟล์ (Commit) ครับ > **หมายเหตุ:** เพื่อให้รันบนเครื่อง Local (Windows/Mac) ได้โดยไม่ Error เรื่อง Path `/share/dms-data` ผมจะตั้งค่าให้ใช้โฟลเดอร์ `./uploads` ในโปรเจกต์แทนถ้ารันบน Local ครับ ```typescript import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In } from 'typeorm'; import { ConfigService } from '@nestjs/config'; 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.js'; @Injectable() export class FileStorageService { private readonly logger = new Logger(FileStorageService.name); private readonly uploadRoot: string; constructor( @InjectRepository(Attachment) private attachmentRepository: Repository, private configService: ConfigService, ) { // ใช้ Path จริงถ้าอยู่บน Server (Production) หรือใช้ ./uploads ถ้าอยู่ Local this.uploadRoot = this.configService.get('NODE_ENV') === 'production' ? '/share/dms-data' : path.join(process.cwd(), 'uploads'); // สร้างโฟลเดอร์รอไว้เลยถ้ายังไม่มี fs.ensureDirSync(path.join(this.uploadRoot, 'temp')); } /** * Phase 1: Upload (บันทึกไฟล์ลง Temp) */ async upload(file: Express.Multer.File, userId: number): Promise { const tempId = uuidv4(); const fileExt = path.extname(file.originalname); const storedFilename = `${uuidv4()}${fileExt}`; const tempPath = path.join(this.uploadRoot, 'temp', 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: file.originalname, 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 */ async commit(tempIds: string[]): Promise { const attachments = await this.attachmentRepository.find({ where: { tempId: In(tempIds), isTemporary: true }, }); if (attachments.length !== tempIds.length) { throw new NotFoundException('Some files not found or already committed'); } const committedAttachments: Attachment[] = []; const today = new Date(); const year = today.getFullYear().toString(); const month = (today.getMonth() + 1).toString().padStart(2, '0'); // โฟลเดอร์ถาวรแยกตาม ปี/เดือน const permanentDir = path.join(this.uploadRoot, 'permanent', year, month); await fs.ensureDir(permanentDir); for (const att of attachments) { const oldPath = att.filePath; const newPath = path.join(permanentDir, att.storedFilename); try { // ย้ายไฟล์ await fs.move(oldPath, newPath, { overwrite: true }); // อัปเดตข้อมูลใน DB att.filePath = newPath; att.isTemporary = false; att.tempId = undefined; // เคลียร์ tempId att.expiresAt = undefined; // เคลียร์วันหมดอายุ committedAttachments.push(await this.attachmentRepository.save(att)); } catch (error) { this.logger.error(`Failed to move file from ${oldPath} to ${newPath}`, error); // ถ้า error ตัวนึง ควรจะ rollback หรือ throw error (ในที่นี้ throw เพื่อให้ Transaction ของผู้เรียกจัดการ) throw new BadRequestException(`Failed to commit file: ${att.originalFilename}`); } } return committedAttachments; } private calculateChecksum(buffer: Buffer): string { return crypto.createHash('sha256').update(buffer).digest('hex'); } } ``` ----- ##### 2. สร้าง `FileStorageController` (Endpoint สำหรับ Upload) เราจะสร้าง API สำหรับ Upload ไฟล์ ซึ่งจะรับไฟล์ผ่าน `multipart/form-data` ครับ แก้ไขไฟล์: `src/modules/file-storage/file-storage.controller.ts` ```typescript import { Controller, Post, UseInterceptors, UploadedFile, UseGuards, Request, ParseFilePipe, MaxFileSizeValidator, FileTypeValidator, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { FileStorageService } from './file-storage.service.js'; import { JwtAuthGuard } from '../../common/auth/jwt-auth.guard.js'; @Controller('files') @UseGuards(JwtAuthGuard) export class FileStorageController { constructor(private readonly fileStorageService: FileStorageService) {} @Post('upload') @UseInterceptors(FileInterceptor('file')) // รับ field ชื่อ 'file' async uploadFile( @UploadedFile( new ParseFilePipe({ validators: [ new MaxFileSizeValidator({ maxSize: 50 * 1024 * 1024 }), // 50MB // ตรวจสอบประเภทไฟล์ (Regex) new FileTypeValidator({ fileType: /(pdf|msword|openxmlformats|zip|octet-stream)/ }), ], }), ) file: Express.Multer.File, @Request() req, ) { // ส่ง userId จาก Token ไปด้วย return this.fileStorageService.upload(file, req.user.userId); } } ``` ----- ##### 3. ทดสอบด้วย Postman 1. **Start Server:** `pnpm start:dev` 2. **Login:** ขอ Token ของ Admin หรือ User มาก่อน 3. **สร้าง Request ใหม่:** * **Method:** `POST` * **URL:** `http://localhost:3000/api/files/upload` * **Auth:** Bearer Token * **Body:** * เลือก `form-data` * Key: `file` (เลือกชนิดเป็น **File**) -\> เลือกไฟล์ PDF หรือรูปภาพสักไฟล์ * **Send** **ผลลัพธ์ที่ควรได้:** JSON ตอบกลับที่มีข้อมูลไฟล์ และ `isTemporary: true`, `tempId: "..."` ครับ ถ้า Upload ผ่านแล้ว ไฟล์จะไปโผล่ในโฟลเดอร์ `uploads/temp` ในโปรเจกต์ของคุณครับ ### **T2.2 Debug** #### ปัญหานี้เกิดจาก **Strict Mode** อีกเช่นเคยครับ (ขออภัยครับที่โค้ดตัวอย่างก่อนหน้าผมลืมระบุ Type ให้ `req` 😅) ใน Strict Mode เราห้ามปล่อยให้ตัวแปรเป็น `any` โดยไม่ตั้งใจครับ ในกรณีนี้ `req` เป็น Object ที่ NestJS ส่งมาให้ ซึ่งจะมีข้อมูล `user` ติดมาด้วย (จากการทำงานของ `JwtAuthGuard`) **วิธีแก้ไข:** เราต้องสร้าง Interface ขึ้นมาบอก TypeScript ว่า `req` หน้าตาเป็นอย่างไรครับ แก้ไขไฟล์ `src/modules/file-storage/file-storage.controller.ts` ดังนี้ครับ: ไฟล์: `src/modules/file-storage/file-storage.controller.ts` (ฉบับสมบูรณ์) ```typescript import { Controller, Post, UseInterceptors, UploadedFile, UseGuards, Request, ParseFilePipe, MaxFileSizeValidator, FileTypeValidator, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { FileStorageService } from './file-storage.service.js'; import { JwtAuthGuard } from '../../common/auth/jwt-auth.guard.js'; // ✅ 1. สร้าง Interface เพื่อระบุ Type ของ Request interface RequestWithUser { user: { userId: number; username: string; }; } @Controller('files') @UseGuards(JwtAuthGuard) export class FileStorageController { constructor(private readonly fileStorageService: FileStorageService) {} @Post('upload') @UseInterceptors(FileInterceptor('file')) async uploadFile( @UploadedFile( new ParseFilePipe({ validators: [ new MaxFileSizeValidator({ maxSize: 50 * 1024 * 1024 }), // 50MB new FileTypeValidator({ fileType: /(pdf|msword|openxmlformats|zip|octet-stream)/ }), ], }), ) file: Express.Multer.File, @Request() req: RequestWithUser, // ✅ 2. ระบุ Type ตรงนี้แทน any ) { return this.fileStorageService.upload(file, req.user.userId); } } ``` #### ปัญหานี้เกิดจากคุณใช้ **HTTP Method ผิด** ในการทดสอบผ่าน Postman ครับ Error แจ้งว่า `Cannot GET ...` แสดงว่าคุณกำลังส่ง Request แบบ **GET** แต่ใน `FileStorageController` เราประกาศ Endpoint นี้ไว้ด้วย **`@Post('upload')`** ครับ ### 🛠️ วิธีแก้ไขใน Postman 1. เปลี่ยน **Method** ด้านซ้ายของช่อง URL จาก `GET` เป็น **`POST`** 2. ตรวจสอบว่า URL คือ `http://localhost:3000/api/files/upload` 3. ไปที่แท็บ **Body** -> เลือก **form-data** 4. ในช่อง Key ใส่คำว่า `file` (ต้องตรงกับในโค้ด `@UseInterceptors(FileInterceptor('file'))`) 5. เปลี่ยนชนิดของ Key จาก `Text` เป็น **`File`** (จะอยู่ขวาสุดของช่อง Key) 6. เลือกไฟล์จากเครื่องของคุณ 7. กด **Send** อีกครั้งครับ ### 🚀 Task T2.3 Document Numbering (Double-Lock Mechanism) นี่คือฟีเจอร์ที่ **สำคัญที่สุด** และ **ท้าทายที่สุด** ของระบบนี้ครับ **โจทย์:** เราต้องสร้างเลขที่เอกสาร (เช่น `LCBP3-RFA-2568-0001`) โดยรับประกันว่า: 1. **ห้ามซ้ำ:** แม้จะมีคนกดปุ่มสร้างพร้อมกัน 100 คน (Race Condition) 2. **ห้ามข้าม:** เลขต้องเรียงกันสวยงาม 3. **ความเร็วสูง:** ต้องไม่ทำให้ระบบค้าง เราจะใช้เทคนิค **Double-Lock** ตามแผน: **Redis Lock (ด่านแรก)** + **Optimistic Lock (ด่านสุดท้าย)** ----- #### ขั้นตอนที่ 1: ติดตั้ง Redis Client เราต้องใช้ `ioredis` (สำหรับคุยกับ Redis) และ `redlock` (สำหรับทำ Distributed Lock) ครับ รันคำสั่ง: ```bash pnpm add ioredis redlock pnpm add -D @types/ioredis ``` *(หมายเหตุ: `redlock` เวอร์ชันล่าสุดอาจรวมอยู่ใน ioredis หรือใช้ library แยก ตรวจสอบ version compatibility ด้วยครับ แต่วิธีมาตรฐานคือลงแยก)* #### ขั้นตอนที่ 2: สร้าง Module และ Entities เราจะสร้าง Module ภายใน (Internal Module) ที่ไม่เปิด Controller ให้ภายนอกเรียกตรงๆ แต่จะให้ Service อื่น (เช่น Correspondence) เรียกใช้ครับ รันคำสั่ง: ```bash nest g module modules/document-numbering nest g service modules/document-numbering ``` #### ขั้นตอนที่ 3: สร้าง Entities สร้างไฟล์: `src/modules/document-numbering/entities/document-number-format.entity.ts` *(เก็บ Template เช่น `{ORG}-{TYPE}-{SEQ:4}`)* ```typescript import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn, Unique } from 'typeorm'; import { Project } from '../../project/entities/project.entity.js'; // เรายังไม่มี CorrespondenceType Entity เดี๋ยวสร้าง Dummy ไว้ก่อน หรือข้าม Relation ไปก่อนได้ // แต่ตามหลักควรมี CorrespondenceType (Master Data) @Entity('document_number_formats') @Unique(['projectId', 'correspondenceTypeId']) // 1 Project + 1 Type มีได้แค่ 1 Format export class DocumentNumberFormat { @PrimaryGeneratedColumn() id!: number; @Column({ name: 'project_id' }) projectId!: number; @Column({ name: 'correspondence_type_id' }) correspondenceTypeId!: number; @Column({ name: 'format_template', length: 255 }) formatTemplate!: string; // เช่น "{ORG_CODE}-{TYPE_CODE}-{YEAR}-{SEQ:4}" // Relation @ManyToOne(() => Project) @JoinColumn({ name: 'project_id' }) project?: Project; } ``` สร้างไฟล์: `src/modules/document-numbering/entities/document-number-counter.entity.ts` *(เก็บเลขล่าสุด)* ```typescript import { Entity, Column, PrimaryColumn, VersionColumn } from 'typeorm'; @Entity('document_number_counters') export class DocumentNumberCounter { // Composite Primary Key (Project + Org + Type + Year) @PrimaryColumn({ name: 'project_id' }) projectId!: number; @PrimaryColumn({ name: 'originator_organization_id' }) originatorId!: number; @PrimaryColumn({ name: 'correspondence_type_id' }) typeId!: number; @PrimaryColumn({ name: 'current_year' }) year!: number; @Column({ name: 'last_number', default: 0 }) lastNumber!: number; // ✨ หัวใจสำคัญของ Optimistic Lock @VersionColumn() version!: number; } ``` #### ขั้นตอนที่ 4: ลงทะเบียนใน Module แก้ไข `src/modules/document-numbering/document-numbering.module.ts`: ```typescript import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { DocumentNumberingService } from './document-numbering.service.js'; import { DocumentNumberFormat } from './entities/document-number-format.entity.js'; import { DocumentNumberCounter } from './entities/document-number-counter.entity.js'; @Module({ imports: [ TypeOrmModule.forFeature([DocumentNumberFormat, DocumentNumberCounter]), ], providers: [DocumentNumberingService], exports: [DocumentNumberingService], // Export ให้คนอื่นเรียกใช้ }) export class DocumentNumberingModule {} ``` ----- **งานถัดไป (ยากสุด):** เขียน Logic ใน `DocumentNumberingService` เพื่อ: 1. Connect Redis 2. Acquire Lock (`doc_num:lock:...`) 3. อ่านค่า DB -\> บวกเลข -\> บันทึก 4. ถ้า Version ชนกัน (Optimistic Lock Error) -\> Retry 5. Release Lock #### ไม่ต้องกังวลเรื่อง **Warning** ครับ ข้อความ `deprecated @types/ioredis` หมายความว่าไลบรารี `ioredis` เวอร์ชันใหม่ (v5+) เขาแถม Type Definitions มาให้ในตัวแล้ว เราเลยไม่จำเป็นต้องลง `@types/ioredis` แยกอีก (แต่ลงไว้ก็ไม่เสียหาย แค่ซ้ำซ้อนครับ) ดังนั้น **ผ่าน** ครับ ลุยต่อได้เลย\! ----- #### 🏗️ งานหลัก: เขียน Logic ใน `DocumentNumberingService` นี่คือโค้ดส่วนที่ซับซ้อนที่สุดส่วนหนึ่งของระบบครับ เพราะต้องผสมผสานทั้ง **Redis**, **Database Transaction**, และ **Retry Logic** เข้าด้วยกันเพื่อให้ได้เลขที่ไม่ซ้ำ 100% สร้าง/แก้ไขไฟล์: `src/modules/document-numbering/document-numbering.service.ts` ```typescript import { Injectable, OnModuleInit, OnModuleDestroy, InternalServerErrorException, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, OptimisticLockVersionMismatchError } from 'typeorm'; import { ConfigService } from '@nestjs/config'; import Redis from 'ioredis'; import Redlock from 'redlock'; import { DocumentNumberCounter } from './entities/document-number-counter.entity.js'; import { DocumentNumberFormat } from './entities/document-number-format.entity.js'; @Injectable() export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(DocumentNumberingService.name); private redisClient: Redis; private redlock: Redlock; constructor( @InjectRepository(DocumentNumberCounter) private counterRepo: Repository, @InjectRepository(DocumentNumberFormat) private formatRepo: Repository, private configService: ConfigService, ) {} // 1. เริ่มต้นเชื่อมต่อ Redis และ Redlock เมื่อ Module ถูกโหลด onModuleInit() { this.redisClient = new Redis({ host: this.configService.get('REDIS_HOST'), port: this.configService.get('REDIS_PORT'), password: this.configService.get('REDIS_PASSWORD'), }); this.redlock = new Redlock( [this.redisClient], { driftFactor: 0.01, retryCount: 10, // ลองใหม่ 10 ครั้งถ้า Lock ไม่สำเร็จ retryDelay: 200, // รอ 200ms ก่อนลองใหม่ retryJitter: 200, } ); this.logger.log('Redis & Redlock initialized for Document Numbering'); } onModuleDestroy() { this.redisClient.disconnect(); } /** * ฟังก์ชันหลักสำหรับขอเลขที่เอกสารถัดไป * @param projectId ID โครงการ * @param orgId ID องค์กรผู้ส่ง * @param typeId ID ประเภทเอกสาร * @param year ปีปัจจุบัน (ค.ศ.) * @param replacements ค่าที่จะเอาไปแทนที่ใน Template (เช่น { ORG_CODE: 'TEAM' }) */ async generateNextNumber( projectId: number, orgId: number, typeId: number, year: number, replacements: Record = {}, ): Promise { const resourceKey = `doc_num:${projectId}:${typeId}:${year}`; const ttl = 5000; // Lock จะหมดอายุใน 5 วินาที (ป้องกัน Deadlock) let lock; try { // 🔒 Step 1: Redis Lock (Distributed Lock) // ป้องกันไม่ให้ Process อื่นเข้ามายุ่งกับ Counter ตัวนี้พร้อมกัน lock = await this.redlock.acquire([resourceKey], ttl); // 🔄 Step 2: Optimistic Locking Loop (Safety Net) // เผื่อ Redis Lock หลุด หรือมีคนแทรกได้จริงๆ DB จะช่วยกันไว้อีกชั้น const maxRetries = 3; for (let i = 0; i < maxRetries; i++) { try { // 2.1 ดึง Counter ปัจจุบัน let counter = await this.counterRepo.findOne({ where: { projectId, originatorId: orgId, typeId, year }, }); // ถ้ายังไม่มี ให้สร้างใหม่ (เริ่มที่ 0) if (!counter) { counter = this.counterRepo.create({ projectId, originatorId: orgId, typeId, year, lastNumber: 0, }); } // 2.2 บวกเลข counter.lastNumber += 1; // 2.3 บันทึก (จุดนี้ TypeORM จะเช็ค Version ให้เอง) await this.counterRepo.save(counter); // 2.4 ถ้าบันทึกผ่าน -> สร้าง String ตาม Format return await this.formatNumber(projectId, typeId, counter.lastNumber, replacements); } catch (err) { // ถ้า Version ชนกัน (Optimistic Lock Error) ให้วนลูปทำใหม่ if (err instanceof OptimisticLockVersionMismatchError) { this.logger.warn(`Optimistic Lock Hit! Retrying... (${i + 1}/${maxRetries})`); continue; } throw err; // ถ้าเป็น Error อื่น ให้โยนออกไปเลย } } throw new InternalServerErrorException('Failed to generate document number after retries'); } catch (err) { this.logger.error('Error generating document number', err); throw err; } finally { // 🔓 Step 3: Release Redis Lock เสมอ (ไม่ว่าจะสำเร็จหรือล้มเหลว) if (lock) { await lock.release().catch(() => {}); // ignore error if lock expired } } } // Helper: แปลงเลขเป็น String ตาม Template (เช่น {ORG}-{SEQ:004}) private async formatNumber( projectId: number, typeId: number, seq: number, replacements: Record ): Promise { // 1. หา Template const format = await this.formatRepo.findOne({ where: { projectId, correspondenceTypeId: typeId } }); // ถ้าไม่มี Template ให้ใช้ Default: {SEQ} let template = format ? format.formatTemplate : '{SEQ:4}'; // 2. แทนที่ค่าต่างๆ (ORG_CODE, TYPE_CODE, YEAR) for (const [key, value] of Object.entries(replacements)) { template = template.replace(new RegExp(`{${key}}`, 'g'), value); } // 3. แทนที่ SEQ (รองรับรูปแบบ {SEQ:4} คือเติม 0 ข้างหน้าให้ครบ 4 หลัก) template = template.replace(/{SEQ(?::(\d+))?}/g, (_, digits) => { const pad = digits ? parseInt(digits, 10) : 0; return seq.toString().padStart(pad, '0'); }); return template; } } ``` ----- **✅ สิ่งที่ Logic นี้ทำ:** 1. **Redlock:** บล็อคไม่ให้ใครมายุ่งกับ Counter ชุดนี้ (Project+Type+Year) ชั่วคราว 2. **Auto Create:** ถ้ายังไม่เคยมีเลขในปีนี้ มันจะสร้าง record เริ่มต้นให้เอง 3. **Optimistic Lock:** ถ้าจังหวะนรกจริงๆ (เช่น Redis ล่มชั่วขณะ) แล้วมี 2 request หลุดเข้ามา `save()` พร้อมกัน Database จะยอมแค่คนแรก คนที่สองจะเจอ Error แล้วระบบจะวนลูปทำให้ใหม่อัตโนมัติ 4. **Formatting:** รองรับ Template ยืดหยุ่น เช่น `{ORG}-{TYPE}-{SEQ:4}` จะกลายเป็น `TEAM-RFA-0001` **Task นี้ไม่มี Controller** เพราะเป็น Service ภายในให้โมดูลอื่นเรียกใช้ครับ ตอนนี้ระบบ **Core Backend** (Auth, User, Project, File, Numbering) ถือว่าเสร็จสมบูรณ์พร้อมสำหรับการทำ Business Logic (RFA, Correspondence) ใน Phase ต่อไปแล้วครับ! ต้องการให้ผมพาทำ **Integration Test** เพื่อทดสอบว่าระบบรันเลขนี้ "ทนทาน" จริงไหม (ยิง 100 request พร้อมกัน) หรือจะไปขึ้น Phase 3 เลยครับ? ### **Task T2.4 SecurityModule - Enhanced Security** เป้าหมายของ Task นี้คือ: 1. **Rate Limiting:** ป้องกันการโดนยิง API รัวๆ (Brute-force / DDoS) ด้วย `@nestjs/throttler` 2. **Security Headers:** ป้องกันการโจมตีพื้นฐานทาง Web (XSS, Clickjacking) ด้วย `helmet` ----- #### ขั้นตอนที่ 1: ติดตั้ง Libraries รันคำสั่ง: ```bash pnpm add @nestjs/throttler helmet ``` #### ขั้นตอนที่ 2: ตั้งค่า Rate Limiting (Global Guard) เราจะตั้งค่าให้ทั้งระบบมีการจำกัดจำนวนการเรียกใช้งานครับ (เช่น สูงสุด 10 ครั้ง ใน 60 วินาที สำหรับ Default) แต่สามารถปรับจูนแยกแต่ละ Controller ได้ แก้ไขไฟล์ `src/app.module.ts`: ```typescript import { Module } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; // <--- เพิ่ม Import นี้ import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { BullModule } from '@nestjs/bullmq'; import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler'; // <--- เพิ่ม Import นี้ import { AppController } from './app.controller.js'; import { AppService } from './app.service.js'; import { envValidationSchema } from './common/config/env.validation.js'; import { UserModule } from './modules/user/user.module.js'; import { ProjectModule } from './modules/project/project.module.js'; import { FileStorageModule } from './modules/file-storage/file-storage.module.js'; import { DocumentNumberingModule } from './modules/document-numbering/document-numbering.module.js'; import { AuthModule } from './common/auth/auth.module.js'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true, envFilePath: '.env', validationSchema: envValidationSchema, validationOptions: { abortEarly: true }, }), // 🛡️ 1. Setup Throttler Module (Rate Limiting) ThrottlerModule.forRoot([ { ttl: 60000, // 60 วินาที (Time to Live) limit: 100, // ยิงได้สูงสุด 100 ครั้ง (Global Default) }, ]), TypeOrmModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], useFactory: async (configService: ConfigService) => ({ type: 'mariadb', host: configService.get('DB_HOST'), port: configService.get('DB_PORT'), username: configService.get('DB_USERNAME'), password: configService.get('DB_PASSWORD'), database: configService.get('DB_DATABASE'), autoLoadEntities: true, synchronize: false, // เราใช้ false ตามที่ตกลงกัน }), }), BullModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], useFactory: async (configService: ConfigService) => ({ connection: { host: configService.get('REDIS_HOST'), port: configService.get('REDIS_PORT'), password: configService.get('REDIS_PASSWORD'), }, }), }), AuthModule, UserModule, ProjectModule, FileStorageModule, DocumentNumberingModule, ], controllers: [AppController], providers: [ AppService, // 🛡️ 2. Register Global Guard { provide: APP_GUARD, useClass: ThrottlerGuard, }, ], }) export class AppModule {} ``` #### ขั้นตอนที่ 3: ตั้งค่า Helmet (Security Headers) `Helmet` จะช่วยตั้งค่า HTTP Headers ให้ปลอดภัยขึ้น (เช่น ซ่อนข้อมูล Server, ป้องกัน XSS) โดยเราจะใส่ไว้ใน `main.ts` แก้ไขไฟล์ `src/main.ts`: ```typescript import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module.js'; import { ValidationPipe } from '@nestjs/common'; import { TransformInterceptor } from './common/interceptors/transform.interceptor.js'; import { HttpExceptionFilter } from './common/exceptions/http-exception.filter.js'; import helmet from 'helmet'; // <--- Import Helmet async function bootstrap() { const app = await NestFactory.create(AppModule); // 🛡️ 1. เปิดใช้งาน Helmet (Security Headers) app.use(helmet()); // 🛡️ 2. เปิดใช้งาน CORS (เพื่อให้ Frontend จากโดเมนอื่นเรียกใช้ได้) // ใน Production ควรระบุ origin ให้ชัดเจน แทนที่จะเป็น * app.enableCors({ origin: true, // หรือระบุเช่น ['https://lcbp3.np-dms.work'] methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', credentials: true, }); app.setGlobalPrefix('api'); app.useGlobalPipes( new ValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true, }), ); app.useGlobalInterceptors(new TransformInterceptor()); app.useGlobalFilters(new HttpExceptionFilter()); await app.listen(process.env.PORT || 3000); console.log(`Application is running on: ${await app.getUrl()}`); } bootstrap(); ``` ----- #### 💡 วิธีปรับจูน Rate Limit เฉพาะจุด (Optional) ถ้าคุณต้องการให้บาง Endpoint (เช่น Login) เข้มงวดกว่าปกติ หรือบางอัน (เช่น Upload) ผ่อนปรนกว่าปกติ ทำได้ดังนี้ครับ: **ตัวอย่าง: เพิ่มความเข้มงวดให้ Login (กัน Brute Force)** ใน `src/common/auth/auth.controller.ts`: ```typescript import { Throttle } from '@nestjs/throttler'; // ... @Post('login') @Throttle({ default: { limit: 5, ttl: 60000 } }) // 🔒 ให้ลองได้แค่ 5 ครั้ง ใน 1 นาที async login(@Body() loginDto: LoginDto) { // ... } ``` **ตัวอย่าง: ยกเว้นการนับ (เช่น Health Check)** ```typescript import { SkipThrottle } from '@nestjs/throttler'; @SkipThrottle() @Get('health') check() { ... } ``` ----- #### ✅ เสร็จสิ้น T2.4 ตอนนี้ API ของเรามีเกราะป้องกันพื้นฐานครบแล้วครับ: 1. **Rate Limit:** ป้องกันการยิงรัวๆ 2. **Helmet:** ป้องกัน XSS/Header Exploits 3. **CORS:** ควบคุมโดเมนที่เรียกใช้ได้ ----- #### 🚀 T2.5 JSON Schema System (Final Task of Phase 2) Task สุดท้ายของ Phase 2 คือการเตรียมระบบ **Validate JSON** สำหรับฟีเจอร์ RFA/Correspondence ที่เราจะสร้างใน Phase 3 ครับ **สิ่งที่ต้องทำ:** 1. สร้าง `JsonSchema` Entity (ตาม Data Dictionary) 2. สร้าง `JsonSchemaModule` และ Service สำหรับ Validate ข้อมูล ### **T2.5 JSON Schema System** ระบบนี้จะช่วยให้เรามั่นใจว่าข้อมูลในฟิลด์ `details` (JSON) ของเอกสารต่างๆ (เช่น RFA, Correspondence) มีโครงสร้างที่ถูกต้องเสมอ ไม่ใช่แค่ Text อะไรก็ได้ ----- #### ขั้นตอนที่ 1: ติดตั้ง Library (AJV) เราจะใช้ **AJV (Another JSON Schema Validator)** ซึ่งเป็นมาตรฐานอุตสาหกรรมสำหรับ Validate JSON ใน Node.js ครับ รันคำสั่ง: ```bash pnpm add ajv ajv-formats ``` #### ขั้นตอนที่ 2: สร้าง Module และ Entity สร้าง Module สำหรับจัดการ JSON Schema ครับ รันคำสั่ง: ```bash nest g module modules/json-schema nest g service modules/json-schema nest g controller modules/json-schema ``` สร้างไฟล์ Entity: `src/modules/json-schema/entities/json-schema.entity.ts` *(อ้างอิงตาม Data Dictionary)* ```typescript import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm'; @Entity('json_schemas') export class JsonSchema { @PrimaryGeneratedColumn() id!: number; @Column({ name: 'schema_code', unique: true, length: 100 }) schemaCode!: string; // เช่น 'RFA_DWG_V1' @Column({ default: 1 }) version!: number; @Column({ name: 'schema_definition', type: 'json' }) schemaDefinition!: any; // เก็บ JSON Schema มาตรฐาน (Draft 7/2019-09) @Column({ name: 'is_active', default: true }) isActive!: boolean; @CreateDateColumn({ name: 'created_at' }) createdAt!: Date; @UpdateDateColumn({ name: 'updated_at' }) updatedAt!: Date; } ``` #### ขั้นตอนที่ 3: เขียน Logic ใน Service (Validator) Service นี้จะทำหน้าที่: 1. โหลด Schema จาก Database 2. ใช้ AJV ตรวจสอบความถูกต้องของข้อมูล 3. Cache ตัว Validator ไว้เพื่อความเร็ว (ไม่ต้อง Compile ใหม่ทุกครั้ง) แก้ไขไฟล์: `src/modules/json-schema/json-schema.service.ts` ```typescript import { Injectable, OnModuleInit, BadRequestException, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import Ajv from 'ajv'; import addFormats from 'ajv-formats'; import { JsonSchema } from './entities/json-schema.entity.js'; @Injectable() export class JsonSchemaService implements OnModuleInit { private ajv: Ajv; // Cache ตัว Validator ที่ Compile แล้ว เพื่อประสิทธิภาพ private validators = new Map(); constructor( @InjectRepository(JsonSchema) private schemaRepo: Repository, ) { // ตั้งค่า AJV this.ajv = new Ajv({ allErrors: true, strict: false }); // strict: false เพื่อยืดหยุ่นกับ custom keywords addFormats(this.ajv); // รองรับ format เช่น email, date-time } onModuleInit() { // (Optional) โหลด Schema ทั้งหมดมา Cache ตอนเริ่ม App ก็ได้ // แต่ตอนนี้ใช้วิธี Lazy Load (โหลดเมื่อใช้) ไปก่อน } /** * ตรวจสอบข้อมูล JSON ว่าถูกต้องตาม Schema หรือไม่ */ async validate(schemaCode: string, data: any): Promise { let validate = this.validators.get(schemaCode); // ถ้ายังไม่มีใน Cache หรือต้องการตัวล่าสุด ให้ดึงจาก DB if (!validate) { const schema = await this.schemaRepo.findOne({ where: { schemaCode, isActive: true } }); if (!schema) { throw new NotFoundException(`JSON Schema '${schemaCode}' not found`); } try { validate = this.ajv.compile(schema.schemaDefinition); this.validators.set(schemaCode, validate); } catch (error: any) { throw new BadRequestException(`Invalid Schema Definition for '${schemaCode}': ${error.message}`); } } const valid = validate(data); if (!valid) { // รวบรวม Error ทั้งหมดส่งกลับไป const errors = validate.errors?.map((e: any) => `${e.instancePath} ${e.message}`).join(', '); throw new BadRequestException(`JSON Validation Failed: ${errors}`); } return true; } // ฟังก์ชันสำหรับสร้าง/อัปเดต Schema (สำหรับ Admin) async createOrUpdate(schemaCode: string, definition: any) { // ตรวจสอบก่อนว่า Definition เป็น JSON Schema ที่ถูกต้องไหม try { this.ajv.compile(definition); } catch (error: any) { throw new BadRequestException(`Invalid JSON Schema format: ${error.message}`); } let schema = await this.schemaRepo.findOne({ where: { schemaCode } }); if (schema) { schema.schemaDefinition = definition; schema.version += 1; } else { schema = this.schemaRepo.create({ schemaCode, schemaDefinition: definition, version: 1, }); } // Clear Cache เก่า this.validators.delete(schemaCode); return this.schemaRepo.save(schema); } } ``` #### ขั้นตอนที่ 4: สร้าง Controller (สำหรับ Admin จัดการ Schema) แก้ไขไฟล์: `src/modules/json-schema/json-schema.controller.ts` ```typescript import { Controller, Post, Body, Param, UseGuards } from '@nestjs/common'; import { JsonSchemaService } from './json-schema.service.js'; import { JwtAuthGuard } from '../../common/auth/jwt-auth.guard.js'; import { RbacGuard } from '../../common/auth/rbac.guard.js'; import { RequirePermission } from '../../common/decorators/require-permission.decorator.js'; @Controller('json-schemas') @UseGuards(JwtAuthGuard, RbacGuard) export class JsonSchemaController { constructor(private readonly schemaService: JsonSchemaService) {} @Post(':code') @RequirePermission('system.manage_all') // เฉพาะ Superadmin หรือผู้มีสิทธิ์จัดการ System create(@Param('code') code: string, @Body() definition: any) { return this.schemaService.createOrUpdate(code, definition); } // Endpoint สำหรับ Test Validate (Optional) @Post(':code/validate') @RequirePermission('document.view') async validate(@Param('code') code: string, @Body() data: any) { const isValid = await this.schemaService.validate(code, data); return { valid: isValid }; } } ``` #### ขั้นตอนที่ 5: ลงทะเบียน Module 1. แก้ไข `src/modules/json-schema/json-schema.module.ts` ให้ลงทะเบียน Entity ```typescript import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { JsonSchemaService } from './json-schema.service.js'; import { JsonSchemaController } from './json-schema.controller.js'; import { JsonSchema } from './entities/json-schema.entity.js'; @Module({ imports: [TypeOrmModule.forFeature([JsonSchema])], controllers: [JsonSchemaController], providers: [JsonSchemaService], exports: [JsonSchemaService], // Export ให้ Module อื่นเรียกใช้ .validate() }) export class JsonSchemaModule {} ``` 2. **สำคัญ:** เพิ่ม `JsonSchemaModule` เข้าไปใน `src/app.module.ts` ครับ (ต่อจาก DocumentNumberingModule ก็ได้) ```typescript // src/app.module.ts import { JsonSchemaModule } from './modules/json-schema/json-schema.module.js'; @Module({ imports: [ // ... DocumentNumberingModule, JsonSchemaModule, // <--- เพิ่ม ], // ... }) export class AppModule {} ``` ----- #### ✅ จบ Phase 2 สมบูรณ์แบบครับ\! ตอนนี้ระบบของเรามี: 1. **Security:** Auth, RBAC, Rate Limit, Helmet 2. **Data Integrity:** File Two-Phase, Document Numbering Locking 3. **Validation:** JSON Schema Validator Backend ของเราแข็งแกร่งมากแล้วครับ พร้อมที่จะไปสร้าง Business Logic (RFA, Correspondence) ใน Phase 3 ต่อไป **ขั้นตอนถัดไป:** ลองรัน `pnpm start:dev` เพื่อเช็คว่าไม่มี Error แล้วเราจะถือว่าปิดจ็อบ Phase 2 ได้เลยครับ ถ้าผ่านแล้ว พิมพ์ **"ปิด Phase 2 เริ่ม Phase 3"** ได้เลยครับ เราจะไปสร้าง "Unified Workflow Engine" กัน\! ### T2.5 Error** ขออภัยครับ ผมลืมใส่บรรทัด **Import** ให้ในตัวอย่างก่อนหน้านี้ครับ 😅 Error `Cannot find name 'Throttle'` เกิดจาก TypeScript ไม่รู้จัก Decorator `@Throttle` เพราะเรายังไม่ได้ดึงมันมาจาก Library ครับ #### 🛠️ วิธีแก้ไข ให้แก้ไขไฟล์ `src/common/auth/auth.controller.ts` โดยเพิ่มการ import `Throttle` จาก `@nestjs/throttler` ที่ส่วนบนสุดของไฟล์ครับ **ไฟล์: `src/common/auth/auth.controller.ts`** ```typescript import { Controller, Post, Body, UnauthorizedException } from '@nestjs/common'; import { Throttle } from '@nestjs/throttler'; // <--- ✅ เพิ่มบรรทัดนี้ครับ import { AuthService } from './auth.service.js'; import { LoginDto } from './dto/login.dto.js'; import { RegisterDto } from './dto/register.dto.js'; @Controller('auth') export class AuthController { constructor(private authService: AuthService) {} @Post('login') // จำกัดการ Login: ได้แค่ 5 ครั้ง ใน 60 วินาที (60000 ms) ต่อ IP @Throttle({ default: { limit: 5, ttl: 60000 } }) async login(@Body() loginDto: LoginDto) { const user = await this.authService.validateUser( loginDto.username, loginDto.password, ); if (!user) { throw new UnauthorizedException('Invalid credentials'); } return this.authService.login(user); } @Post('register-admin') async register(@Body() registerDto: RegisterDto) { return this.authService.register(registerDto); } } ``` ลอง Save แล้วรัน `pnpm start:dev` อีกครั้งครับ คราวนี้น่าจะผ่านฉลุยและปิดจ็อบ **Phase 2** ได้อย่างสมบูรณ์ครับ\! ถ้าไม่มี Error แล้ว พิมพ์ **"ปิด Phase 2 เริ่ม Phase 3"** ได้เลยครับ เราจะไปลุยเรื่อง Workflow Engine กัน\! #### ปัญหาเหล่านี้เกิดจาก **Strict Mode** ครับ 1. **Error TS2564 (Property has no initializer):** ตัวแปร `redisClient` และ `redlock` ถูกประกาศไว้แต่ไม่ได้ใส่ค่าใน `constructor` (เพราะเราไปใส่ใน `onModuleInit` แทน) TypeScript เลยกลัวว่ามันจะเป็น undefined * **วิธีแก้:** ใส่เครื่องหมาย **`!`** ต่อท้ายชื่อตัวแปร เพื่อยืนยันว่า "เดี๋ยวมีค่าแน่นอน ไม่ต้องห่วง" 2. **Error TS7016 (Could not find a declaration file for 'redlock'):** ไลบรารี `redlock` (เวอร์ชันที่เราลง) ไม่มีไฟล์ Type Definition มาให้ หรือ TypeScript หาไม่เจอ * **วิธีแก้:** สร้างไฟล์ประกาศ Type (Declaration File) ขึ้นมาเองเพื่อบอก TypeScript ว่า "รู้จัก Module นี้นะ" ----- **🛠️ วิธีแก้ไข** ##### 1. แก้ไขไฟล์ `document-numbering.service.ts` (ใส่ `!`) แก้ไข `src/modules/document-numbering/document-numbering.service.ts` ตรงส่วนประกาศตัวแปรครับ: ```typescript // ... imports @Injectable() export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(DocumentNumberingService.name); // ✅ ใส่ ! ต่อท้ายชื่อตัวแปร private redisClient!: Redis; private redlock!: Redlock; constructor( // ... ) {} // ... ``` ##### 2. สร้างไฟล์ Declaration สำหรับ Redlock เนื่องจากเราหา `@types/redlock` ที่ตรงรุ่นยาก ให้สร้างไฟล์บอก Type เองง่ายๆ ครับ สร้างไฟล์ใหม่: `src/redlock.d.ts` (สร้างไว้ใน src เลยครับ) ```typescript declare module 'redlock' { import { Redis } from 'ioredis'; export interface Options { driftFactor?: number; retryCount?: number; retryDelay?: number; retryJitter?: number; automaticExtensionThreshold?: number; } export interface Lock { redlock: Redlock; resource: string; value: string | null; expiration: number; attempts: number; release(): Promise; extend(ttl: number): Promise; } export default class Redlock { constructor(clients: Redis[], options?: Options); acquire(resources: string[], ttl: number): Promise; release(lock: Lock): Promise; quit(): Promise; } } ``` ##### 3. ทดสอบรันใหม่ ลอง Save ทั้ง 2 ไฟล์ แล้วรัน `pnpm start:dev` ดูครับ Error ทั้งสองควรจะหายไปครับ ถ้าผ่านแล้ว พิมพ์ **"ปิด Phase 2 เริ่ม Phase 3"** ได้เลยครับ เราจะไปลุย **Phase 3: Unified Workflow Engine** ซึ่งเป็นหัวใจของระบบนี้ครับ! #### ปัญหานี้เกิดจาก **Dependency Injection** ของ NestJS ครับ **สาเหตุ:** 1. `JsonSchemaController` เรียกใช้ **`RbacGuard`** 2. `RbacGuard` ต้องใช้ **`UserService`** เพื่อเช็คสิทธิ์ (ดึง permission จาก DB) 3. แต่ใน **`JsonSchemaModule`** เราลืม Import `UserModule` เข้ามา ทำให้มันหา `UserService` ไม่เจอครับ ##### 🛠️ วิธีแก้ไข ให้แก้ไขไฟล์ `src/modules/json-schema/json-schema.module.ts` โดยเพิ่ม **`UserModule`** เข้าไปในรายการ `imports` ครับ **ไฟล์: `src/modules/json-schema/json-schema.module.ts`** ```typescript import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { JsonSchemaService } from './json-schema.service.js'; import { JsonSchemaController } from './json-schema.controller.js'; import { JsonSchema } from './entities/json-schema.entity.js'; import { UserModule } from '../user/user.module.js'; // <--- 1. Import UserModule @Module({ imports: [ TypeOrmModule.forFeature([JsonSchema]), UserModule, // <--- 2. ใส่ UserModule ใน imports ], controllers: [JsonSchemaController], providers: [JsonSchemaService], exports: [JsonSchemaService], }) export class JsonSchemaModule {} ``` ลอง Save แล้วรันใหม่ Error นี้น่าจะหายไปครับ ถ้าผ่านแล้ว พิมพ์ **"ปิด Phase 2 เริ่ม Phase 3"** ได้เลยครับ เราจะไปลุยเรื่อง Unified Workflow Engine กันต่อครับ\! #### ปัญหานี้เกิดจาก **รหัสผ่านของ Redis ในไฟล์ `.env` ไม่ตรงกับที่ตั้งไว้ใน Redis Container (docker-compose.yml)** ครับ Error `WRONGPASS` ชัดเจนว่าพยายามเชื่อมต่อแล้ว แต่รหัสผ่านผิด **🛠️ วิธีแก้ไข** ทำตามขั้นตอนนี้เพื่อ Sync รหัสผ่านให้ตรงกันครับ: ##### 1. เช็คไฟล์ `docker-compose.yml` เปิดไฟล์ `docker-compose.yml` แล้วดูบรรทัด `command` ของ service `redis` ว่าตั้งรหัสผ่านว่าอะไร ```yaml redis: # ... # ดูตรงคำว่า "redis_password_secure" (หรือค่าที่คุณตั้งไว้) command: redis-server --requirepass "redis_password_secure" ``` ##### 2. เช็คไฟล์ `.env` เปิดไฟล์ `.env` แล้วดูค่า `REDIS_PASSWORD` ```env REDIS_PASSWORD=redis_password_secure ``` **⚠️ ต้องแก้ให้ 2 ไฟล์นี้มีค่าตรงกันเป๊ะๆ ครับ** ##### 3. รีเซ็ต Redis Container (สำคัญมาก\!) ถ้าคุณแก้ไฟล์ `docker-compose.yml` หรือ `.env` แล้ว แต่ยังไม่ได้ Restart Container ตัว Redis อาจจะยังจำค่าเก่าอยู่ หรือจำค่า Default (ไม่มีรหัส) ให้รันคำสั่งนี้เพื่อ **ลบและสร้าง Redis ใหม่** ให้จำค่าล่าสุดครับ: ```bash # 1. หยุดและลบ container redis เดิม docker-compose stop redis docker-compose rm -f redis # 2. สร้างใหม่ (มันจะอ่านค่าจาก docker-compose.yml ใหม่) docker-compose up -d redis ``` ##### 4. ทดสอบรัน Server ใหม่ ```bash pnpm start:dev ``` คราวนี้น่าจะเชื่อมต่อผ่านแล้วครับ\! (ถ้ายังไม่ได้ ลองเช็คว่าเผลอมี Redis ตัวอื่นรันซ้อนอยู่หรือเปล่าด้วย `docker ps` ครับ) ----- ## ปิด Phase 2 เริ่ม Phase 3 ถ้าแก้ไขผ่านแล้ว พิมพ์ **"ปิด Phase 2 เริ่ม Phase 3"** ได้เลยครับ เราจะไปลุย **Unified Workflow Engine** ที่เป็นหัวใจสำคัญของระบบใน Phase 3 กันครับ\!