260322:1648 Correct Coresspondence / Doing RFA / Correct CI
CI Pipeline / build (push) Failing after 12m41s
Build and Deploy / deploy (push) Failing after 2m44s

This commit is contained in:
admin
2026-03-22 16:48:12 +07:00
parent e5deedb42e
commit 11984bfa29
683 changed files with 105251 additions and 29068 deletions
@@ -1,17 +1,17 @@
# **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 ที่จำเป็น
@@ -111,7 +111,7 @@ import { Attachment } from './entities/attachment.entity.js';
export class FileStorageModule {}
```
-----
---
#### 📥 งานถัดไป (สำคัญมาก)
@@ -149,14 +149,12 @@ export class FileStorageService {
constructor(
@InjectRepository(Attachment)
private attachmentRepository: Repository<Attachment>,
private configService: ConfigService,
private configService: ConfigService
) {
// ใช้ Path จริงถ้าอยู่บน Server (Production) หรือใช้ ./uploads ถ้าอยู่ Local
this.uploadRoot =
this.configService.get('NODE_ENV') === 'production'
? '/share/dms-data'
: path.join(process.cwd(), 'uploads');
this.configService.get('NODE_ENV') === 'production' ? '/share/dms-data' : path.join(process.cwd(), 'uploads');
// สร้างโฟลเดอร์รอไว้เลยถ้ายังไม่มี
fs.ensureDirSync(path.join(this.uploadRoot, 'temp'));
}
@@ -215,7 +213,7 @@ export class FileStorageService {
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);
@@ -227,7 +225,7 @@ export class FileStorageService {
try {
// ย้ายไฟล์
await fs.move(oldPath, newPath, { overwrite: true });
// อัปเดตข้อมูลใน DB
att.filePath = newPath;
att.isTemporary = false;
@@ -251,7 +249,7 @@ export class FileStorageService {
}
```
-----
---
##### 2. สร้าง `FileStorageController` (Endpoint สำหรับ Upload)
@@ -290,10 +288,10 @@ export class FileStorageController {
// ตรวจสอบประเภทไฟล์ (Regex)
new FileTypeValidator({ fileType: /(pdf|msword|openxmlformats|zip|octet-stream)/ }),
],
}),
})
)
file: Express.Multer.File,
@Request() req,
@Request() req
) {
// ส่ง userId จาก Token ไปด้วย
return this.fileStorageService.upload(file, req.user.userId);
@@ -301,20 +299,20 @@ export class FileStorageController {
}
```
-----
---
##### 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**
- **Method:** `POST`
- **URL:** `http://localhost:3000/api/files/upload`
- **Auth:** Bearer Token
- **Body:**
- เลือก `form-data`
- Key: `file` (เลือกชนิดเป็น **File**) -\> เลือกไฟล์ PDF หรือรูปภาพสักไฟล์
- **Send**
**ผลลัพธ์ที่ควรได้:**
JSON ตอบกลับที่มีข้อมูลไฟล์ และ `isTemporary: true`, `tempId: "..."` ครับ
@@ -371,10 +369,10 @@ export class FileStorageController {
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
@Request() req: RequestWithUser // ✅ 2. ระบุ Type ตรงนี้แทน any
) {
return this.fileStorageService.upload(file, req.user.userId);
}
@@ -407,7 +405,7 @@ Error แจ้งว่า `Cannot GET ...` แสดงว่าคุณก
เราจะใช้เทคนิค **Double-Lock** ตามแผน: **Redis Lock (ด่านแรก)** + **Optimistic Lock (ด่านสุดท้าย)**
-----
---
#### ขั้นตอนที่ 1: ติดตั้ง Redis Client
@@ -420,7 +418,7 @@ pnpm add ioredis redlock
pnpm add -D @types/ioredis
```
*(หมายเหตุ: `redlock` เวอร์ชันล่าสุดอาจรวมอยู่ใน ioredis หรือใช้ library แยก ตรวจสอบ version compatibility ด้วยครับ แต่วิธีมาตรฐานคือลงแยก)*
_(หมายเหตุ: `redlock` เวอร์ชันล่าสุดอาจรวมอยู่ใน ioredis หรือใช้ library แยก ตรวจสอบ version compatibility ด้วยครับ แต่วิธีมาตรฐานคือลงแยก)_
#### ขั้นตอนที่ 2: สร้าง Module และ Entities
@@ -436,7 +434,7 @@ nest g service modules/document-numbering
#### ขั้นตอนที่ 3: สร้าง Entities
สร้างไฟล์: `src/modules/document-numbering/entities/document-number-format.entity.ts`
*(เก็บ Template เช่น `{ORG}-{TYPE}-{SEQ:4}`)*
_(เก็บ Template เช่น `{ORG}-{TYPE}-{SEQ:4}`)_
```typescript
import { Entity, Column, PrimaryGeneratedColumn, ManyToOne, JoinColumn, Unique } from 'typeorm';
@@ -467,7 +465,7 @@ export class DocumentNumberFormat {
```
สร้างไฟล์: `src/modules/document-numbering/entities/document-number-counter.entity.ts`
*(เก็บเลขล่าสุด)*
_(เก็บเลขล่าสุด)_
```typescript
import { Entity, Column, PrimaryColumn, VersionColumn } from 'typeorm';
@@ -491,8 +489,8 @@ export class DocumentNumberCounter {
lastNumber!: number;
// ✨ หัวใจสำคัญของ Optimistic Lock
@VersionColumn()
version!: number;
@VersionColumn()
version!: number;
}
```
@@ -508,16 +506,14 @@ import { DocumentNumberFormat } from './entities/document-number-format.entity.j
import { DocumentNumberCounter } from './entities/document-number-counter.entity.js';
@Module({
imports: [
TypeOrmModule.forFeature([DocumentNumberFormat, DocumentNumberCounter]),
],
imports: [TypeOrmModule.forFeature([DocumentNumberFormat, DocumentNumberCounter])],
providers: [DocumentNumberingService],
exports: [DocumentNumberingService], // Export ให้คนอื่นเรียกใช้
})
export class DocumentNumberingModule {}
```
-----
---
**งานถัดไป (ยากสุด):** เขียน Logic ใน `DocumentNumberingService` เพื่อ:
@@ -531,7 +527,7 @@ export class DocumentNumberingModule {}
ดังนั้น **ผ่าน** ครับ ลุยต่อได้เลย\!
-----
---
#### 🏗️ งานหลัก: เขียน Logic ใน `DocumentNumberingService`
@@ -560,7 +556,7 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
private counterRepo: Repository<DocumentNumberCounter>,
@InjectRepository(DocumentNumberFormat)
private formatRepo: Repository<DocumentNumberFormat>,
private configService: ConfigService,
private configService: ConfigService
) {}
// 1. เริ่มต้นเชื่อมต่อ Redis และ Redlock เมื่อ Module ถูกโหลด
@@ -571,16 +567,13 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
password: this.configService.get<string>('REDIS_PASSWORD'),
});
this.redlock = new Redlock(
[this.redisClient],
{
driftFactor: 0.01,
retryCount: 10, // ลองใหม่ 10 ครั้งถ้า Lock ไม่สำเร็จ
retryDelay: 200, // รอ 200ms ก่อนลองใหม่
retryJitter: 200,
}
);
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');
}
@@ -601,11 +594,11 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
orgId: number,
typeId: number,
year: number,
replacements: Record<string, string> = {},
replacements: Record<string, string> = {}
): Promise<string> {
const resourceKey = `doc_num:${projectId}:${typeId}:${year}`;
const ttl = 5000; // Lock จะหมดอายุใน 5 วินาที (ป้องกัน Deadlock)
let lock;
try {
// 🔒 Step 1: Redis Lock (Distributed Lock)
@@ -641,19 +634,17 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
// 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;
continue;
}
throw err; // ถ้าเป็น Error อื่น ให้โยนออกไปเลย
}
}
throw new InternalServerErrorException('Failed to generate document number after retries');
throw new InternalServerErrorException('Failed to generate document number after retries');
} catch (err) {
this.logger.error('Error generating document number', err);
throw err;
@@ -667,16 +658,16 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
// Helper: แปลงเลขเป็น String ตาม Template (เช่น {ORG}-{SEQ:004})
private async formatNumber(
projectId: number,
typeId: number,
seq: number,
projectId: number,
typeId: number,
seq: number,
replacements: Record<string, string>
): Promise<string> {
// 1. หา Template
const format = await this.formatRepo.findOne({ where: { projectId, correspondenceTypeId: typeId } });
// ถ้าไม่มี Template ให้ใช้ Default: {SEQ}
let template = format ? format.formatTemplate : '{SEQ:4}';
let template = format ? format.formatTemplate : '{SEQ:4}';
// 2. แทนที่ค่าต่างๆ (ORG_CODE, TYPE_CODE, YEAR)
for (const [key, value] of Object.entries(replacements)) {
@@ -694,7 +685,7 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
}
```
-----
---
**✅ สิ่งที่ Logic นี้ทำ:**
@@ -716,7 +707,7 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
1. **Rate Limiting:** ป้องกันการโดนยิง API รัวๆ (Brute-force / DDoS) ด้วย `@nestjs/throttler`
2. **Security Headers:** ป้องกันการโจมตีพื้นฐานทาง Web (XSS, Clickjacking) ด้วย `helmet`
-----
---
#### ขั้นตอนที่ 1: ติดตั้ง Libraries
@@ -756,7 +747,7 @@ import { AuthModule } from './common/auth/auth.module.js';
validationSchema: envValidationSchema,
validationOptions: { abortEarly: true },
}),
// 🛡️ 1. Setup Throttler Module (Rate Limiting)
ThrottlerModule.forRoot([
{
@@ -844,7 +835,7 @@ async function bootstrap() {
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
})
);
app.useGlobalInterceptors(new TransformInterceptor());
@@ -856,7 +847,7 @@ async function bootstrap() {
bootstrap();
```
-----
---
#### 💡 วิธีปรับจูน Rate Limit เฉพาะจุด (Optional)
@@ -887,7 +878,7 @@ import { SkipThrottle } from '@nestjs/throttler';
check() { ... }
```
-----
---
#### ✅ เสร็จสิ้น T2.4
@@ -897,7 +888,7 @@ check() { ... }
2. **Helmet:** ป้องกัน XSS/Header Exploits
3. **CORS:** ควบคุมโดเมนที่เรียกใช้ได้
-----
---
#### 🚀 T2.5 JSON Schema System (Final Task of Phase 2)
@@ -912,7 +903,7 @@ Task สุดท้ายของ Phase 2 คือการเตรียม
ระบบนี้จะช่วยให้เรามั่นใจว่าข้อมูลในฟิลด์ `details` (JSON) ของเอกสารต่างๆ (เช่น RFA, Correspondence) มีโครงสร้างที่ถูกต้องเสมอ ไม่ใช่แค่ Text อะไรก็ได้
-----
---
#### ขั้นตอนที่ 1: ติดตั้ง Library (AJV)
@@ -937,7 +928,7 @@ nest g controller modules/json-schema
```
สร้างไฟล์ Entity: `src/modules/json-schema/entities/json-schema.entity.ts`
*(อ้างอิงตาม Data Dictionary)*
_(อ้างอิงตาม Data Dictionary)_
```typescript
import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn } from 'typeorm';
@@ -989,11 +980,11 @@ import { JsonSchema } from './entities/json-schema.entity.js';
export class JsonSchemaService implements OnModuleInit {
private ajv: Ajv;
// Cache ตัว Validator ที่ Compile แล้ว เพื่อประสิทธิภาพ
private validators = new Map<string, any>();
private validators = new Map<string, any>();
constructor(
@InjectRepository(JsonSchema)
private schemaRepo: Repository<JsonSchema>,
private schemaRepo: Repository<JsonSchema>
) {
// ตั้งค่า AJV
this.ajv = new Ajv({ allErrors: true, strict: false }); // strict: false เพื่อยืดหยุ่นกับ custom keywords
@@ -1013,8 +1004,8 @@ export class JsonSchemaService implements OnModuleInit {
// ถ้ายังไม่มีใน Cache หรือต้องการตัวล่าสุด ให้ดึงจาก DB
if (!validate) {
const schema = await this.schemaRepo.findOne({
where: { schemaCode, isActive: true }
const schema = await this.schemaRepo.findOne({
where: { schemaCode, isActive: true },
});
if (!schema) {
@@ -1094,7 +1085,7 @@ export class JsonSchemaController {
// Endpoint สำหรับ Test Validate (Optional)
@Post(':code/validate')
@RequirePermission('document.view')
@RequirePermission('document.view')
async validate(@Param('code') code: string, @Body() data: any) {
const isValid = await this.schemaService.validate(code, data);
return { valid: isValid };
@@ -1143,7 +1134,7 @@ import { JsonSchemaModule } from './modules/json-schema/json-schema.module.js';
export class AppModule {}
```
-----
---
#### ✅ จบ Phase 2 สมบูรณ์แบบครับ\!
@@ -1160,7 +1151,7 @@ Backend ของเราแข็งแกร่งมากแล้วคร
ถ้าผ่านแล้ว พิมพ์ **"ปิด Phase 2 เริ่ม Phase 3"** ได้เลยครับ เราจะไปสร้าง "Unified Workflow Engine" กัน\!
### T2.5 Error**
### T2.5 Error\*\*
ขออภัยครับ ผมลืมใส่บรรทัด **Import** ให้ในตัวอย่างก่อนหน้านี้ครับ 😅
@@ -1185,12 +1176,9 @@ export class AuthController {
@Post('login')
// จำกัดการ Login: ได้แค่ 5 ครั้ง ใน 60 วินาที (60000 ms) ต่อ IP
@Throttle({ default: { limit: 5, ttl: 60000 } })
@Throttle({ default: { limit: 5, ttl: 60000 } })
async login(@Body() loginDto: LoginDto) {
const user = await this.authService.validateUser(
loginDto.username,
loginDto.password,
);
const user = await this.authService.validateUser(loginDto.username, loginDto.password);
if (!user) {
throw new UnauthorizedException('Invalid credentials');
@@ -1213,14 +1201,12 @@ export class AuthController {
#### ปัญหาเหล่านี้เกิดจาก **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 นี้นะ"
* **วิธีแก้:** สร้างไฟล์ประกาศ Type (Declaration File) ขึ้นมาเองเพื่อบอก TypeScript ว่า "รู้จัก Module นี้นะ"
-----
---
**🛠️ วิธีแก้ไข**
@@ -1234,7 +1220,7 @@ export class AuthController {
@Injectable()
export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(DocumentNumberingService.name);
// ✅ ใส่ ! ต่อท้ายชื่อตัวแปร
private redisClient!: Redis;
private redlock!: Redlock;
@@ -1242,7 +1228,7 @@ export class DocumentNumberingService implements OnModuleInit, OnModuleDestroy {
constructor(
// ...
) {}
// ...
```
@@ -1340,10 +1326,10 @@ Error `WRONGPASS` ชัดเจนว่าพยายามเชื่อ
เปิดไฟล์ `docker-compose.yml` แล้วดูบรรทัด `command` ของ service `redis` ว่าตั้งรหัสผ่านว่าอะไร
```yaml
redis:
# ...
# ดูตรงคำว่า "redis_password_secure" (หรือค่าที่คุณตั้งไว้)
command: redis-server --requirepass "redis_password_secure"
redis:
# ...
# ดูตรงคำว่า "redis_password_secure" (หรือค่าที่คุณตั้งไว้)
command: redis-server --requirepass "redis_password_secure"
```
##### 2. เช็คไฟล์ `.env`
@@ -1379,8 +1365,8 @@ pnpm start:dev
คราวนี้น่าจะเชื่อมต่อผ่านแล้วครับ\! (ถ้ายังไม่ได้ ลองเช็คว่าเผลอมี Redis ตัวอื่นรันซ้อนอยู่หรือเปล่าด้วย `docker ps` ครับ)
-----
---
## ปิด Phase 2 เริ่ม Phase 3
ถ้าแก้ไขผ่านแล้ว พิมพ์ **"ปิด Phase 2 เริ่ม Phase 3"** ได้เลยครับ เราจะไปลุย **Unified Workflow Engine** ที่เป็นหัวใจสำคัญของระบบใน Phase 3 กันครับ\!
ถ้าแก้ไขผ่านแล้ว พิมพ์ **"ปิด Phase 2 เริ่ม Phase 3"** ได้เลยครับ เราจะไปลุย **Unified Workflow Engine** ที่เป็นหัวใจสำคัญของระบบใน Phase 3 กันครับ\!