12 KiB
12 KiB
Backend Development Guidelines
สำหรับ: NAP-DMS LCBP3 Backend (NestJS + TypeScript) เวอร์ชัน: 1.5.0 อัปเดต: 2025-12-01
🎯 หลักการพื้นฐาน
ระบบ Backend ของเรามุ่งเน้น "Data Integrity First" - ความถูกต้องของข้อมูลต้องมาก่อน ตามด้วย Security และ UX
หลักการหลัก
- Strict Typing: ใช้ TypeScript เต็มรูปแบบ ห้ามใช้
any - Data Integrity: ป้องกัน Race Condition ด้วย Optimistic Locking + Redis Lock
- Security First: ทุก Endpoint ต้องผ่าน Authentication, Authorization, และ Input Validation
- Idempotency: Request สำคัญต้องทำซ้ำได้โดยไม่เกิดผลกระทบซ้ำซ้อน
- Resilience: รองรับ Network Failure และ External Service Downtime
📁 โครงสร้างโปรเจกต์
backend/
├── src/
│ ├── common/ # Shared utilities, decorators, guards
│ │ ├── auth/ # Authentication module
│ │ ├── config/ # Configuration management
│ │ ├── decorators/ # Custom decorators
│ │ ├── guards/ # Auth guards, RBAC
│ │ ├── interceptors/ # Logging, transform, idempotency
│ │ └── file-storage/ # Two-phase file storage
│ ├── modules/ # Business modules (domain-driven)
│ │ ├── user/
│ │ ├── project/
│ │ ├── correspondence/
│ │ ├── rfa/
│ │ ├── workflow-engine/
│ │ └── ...
│ └── database/
│ ├── migrations/
│ └── seeds/
├── test/ # E2E tests
└── scripts/ # Utility scripts
🔐 Security Guidelines
1. Authentication & Authorization
JWT Authentication:
// ใช้ @UseGuards(JwtAuthGuard) สำหรับ Protected Routes
@Controller('projects')
@UseGuards(JwtAuthGuard)
export class ProjectController {
// ...
}
RBAC (4 ระดับ):
// ใช้ @RequirePermission() Decorator
@Post(':id/contracts')
@RequirePermission('contract.create', { scope: 'project' })
async createContract() {
// Level 1: Global Permission
// Level 2: Organization Permission
// Level 3: Project Permission
// Level 4: Contract Permission
}
2. Input Validation
ใช้ DTOs พร้อม class-validator:
import { IsNotEmpty, IsUUID, MaxLength } from 'class-validator';
export class CreateCorrespondenceDto {
@IsNotEmpty({ message: 'ต้องระบุโปรเจกต์' })
@IsUUID('4', { message: 'รูปแบบ Project ID ไม่ถูกต้อง' })
project_id: string;
@IsNotEmpty()
@MaxLength(500)
title: string;
}
3. Rate Limiting
// กำหนด Rate Limit ตาม User Type
@UseGuards(RateLimitGuard)
@RateLimit({ points: 100, duration: 3600 }) // 100 requests/hour
@Post('upload')
async uploadFile() { }
4. Secrets Management
- Production: ใช้ Docker Environment Variables (ไม่ใส่ใน docker-compose.yml)
- Development: ใช้
docker-compose.override.yml(gitignored) - Validation: Validate Environment Variables ตอน Start App
// src/common/config/env.validation.ts
import * as Joi from 'joi';
export const envValidationSchema = Joi.object({
DATABASE_URL: Joi.string().required(),
JWT_SECRET: Joi.string().min(32).required(),
REDIS_URL: Joi.string().required(),
});
🗄️ Database Best Practices
1. Optimistic Locking
ใช้ @VersionColumn() ป้องกัน Race Condition:
@Entity()
export class DocumentNumberCounter {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
last_number: number;
@VersionColumn() // Auto-increment on update
version: number;
}
2. Virtual Columns สำหรับ JSON
สร้าง Index สำหรับ JSON field ที่ใช้ Search บ่อย:
-- Migration Script
ALTER TABLE correspondence_revisions
ADD COLUMN ref_project_id INT GENERATED ALWAYS AS
(JSON_UNQUOTE(JSON_EXTRACT(details, '$.projectId'))) VIRTUAL;
CREATE INDEX idx_ref_project_id ON correspondence_revisions(ref_project_id);
3. Soft Delete
// Base Entity
@Entity()
export abstract class BaseEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@CreateDateColumn()
created_at: Date;
@UpdateDateColumn()
updated_at: Date;
@DeleteDateColumn()
deleted_at: Date; // NULL = Active, NOT NULL = Soft Deleted
}
📦 Core Modules
1. DocumentNumberingModule
Double-Lock Mechanism:
@Injectable()
export class DocumentNumberingService {
async generateNextNumber(context: NumberingContext): Promise<string> {
const lockKey = `doc_num:${context.projectId}:${context.typeId}`;
// Layer 1: Redis Lock (2-5 seconds TTL)
const lock = await this.redisLock.acquire(lockKey, 3000);
try {
// Layer 2: Optimistic DB Lock
const counter = await this.counterRepo.findOne({
where: context,
lock: { mode: 'optimistic' },
});
counter.last_number++;
await this.counterRepo.save(counter); // Throws if version changed
return this.formatNumber(counter);
} finally {
await lock.release();
}
}
}
2. FileStorageService (Two-Phase)
Phase 1: Upload to Temp
@Post('upload')
async uploadFile(@UploadedFile() file: Express.Multer.File) {
// 1. Virus Scan
await this.virusScan(file);
// 2. Save to temp/
const tempId = await this.fileStorage.saveToTemp(file);
// 3. Return temp_id
return { temp_id: tempId, expires_at: addHours(new Date(), 24) };
}
Phase 2: Commit to Permanent
async createCorrespondence(dto: CreateDto, tempFileIds: string[]) {
return this.dataSource.transaction(async (manager) => {
// 1. Create Correspondence
const correspondence = await manager.save(Correspondence, dto);
// 2. Commit Files (ภายใน Transaction)
await this.fileStorage.commitFiles(tempFileIds, correspondence.id, manager);
return correspondence;
});
}
Cleanup Job:
@Cron('0 */6 * * *') // ทุก 6 ชั่วโมง
async cleanupOrphanFiles() {
const expiredFiles = await this.attachmentRepo.find({
where: {
is_temporary: true,
expires_at: LessThan(new Date()),
},
});
for (const file of expiredFiles) {
await this.deleteFile(file.file_path);
await this.attachmentRepo.remove(file);
}
}
3. Idempotency Interceptor
@Injectable()
export class IdempotencyInterceptor implements NestInterceptor {
async intercept(context: ExecutionContext, next: CallHandler) {
const request = context.switchToHttp().getRequest();
const idempotencyKey = request.headers['idempotency-key'];
if (!idempotencyKey) {
throw new BadRequestException('Idempotency-Key required');
}
// ตรวจสอบ Cache
const cached = await this.redis.get(`idempotency:${idempotencyKey}`);
if (cached) {
return of(JSON.parse(cached)); // Return ผลลัพธ์เดิม
}
// Execute & Cache Result
return next.handle().pipe(
tap(async (response) => {
await this.redis.set(
`idempotency:${idempotencyKey}`,
JSON.stringify(response),
'EX',
86400 // 24 hours
);
})
);
}
}
🔄 Workflow Engine Integration
ห้ามสร้างตาราง Routing แยก - ใช้ Unified Workflow Engine
@Injectable()
export class CorrespondenceWorkflowService {
constructor(private workflowEngine: WorkflowEngineService) {}
async submitCorrespondence(corrId: string, templateId: string) {
// สร้าง Workflow Instance
const instance = await this.workflowEngine.createInstance({
definition_name: 'CORRESPONDENCE_ROUTING',
entity_type: 'correspondence',
entity_id: corrId,
template_id: templateId,
});
// Execute Initial Transition
await this.workflowEngine.executeTransition(instance.id, 'SUBMIT');
return instance;
}
}
✅ Testing Standards
1. Unit Tests
describe('DocumentNumberingService', () => {
let service: DocumentNumberingService;
let mockRedisLock: jest.Mocked<RedisLock>;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
DocumentNumberingService,
{ provide: RedisLock, useValue: mockRedisLock },
],
}).compile();
service = module.get(DocumentNumberingService);
});
it('should generate unique numbers concurrently', async () => {
// Test concurrent number generation
const promises = Array(10)
.fill(null)
.map(() => service.generateNextNumber(context));
const results = await Promise.all(promises);
const unique = new Set(results);
expect(unique.size).toBe(10); // ไม่มีเลขซ้ำ
});
});
2. E2E Tests
describe('Correspondence API (e2e)', () => {
it('should create correspondence with idempotency', async () => {
const idempotencyKey = uuidv4();
// Request 1
const response1 = await request(app.getHttpServer())
.post('/correspondences')
.set('Idempotency-Key', idempotencyKey)
.send(createDto);
expect(response1.status).toBe(201);
// Request 2 (Same Key)
const response2 = await request(app.getHttpServer())
.post('/correspondences')
.set('Idempotency-Key', idempotencyKey)
.send(createDto);
expect(response2.status).toBe(201);
expect(response2.body.id).toBe(response1.body.id); // Same entity
});
});
📊 Logging & Monitoring
1. Winston Logger
// src/modules/monitoring/logger/winston.config.ts
export const winstonConfig = {
level: process.env.NODE_ENV === 'production' ? 'warn' : 'debug',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.Console(),
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
],
};
2. Audit Logging
@Post(':id/approve')
@UseInterceptors(AuditLogInterceptor)
async approve(@Param('id') id: string, @CurrentUser() user: User) {
// AuditLogInterceptor จะบันทึก:
// - user_id
// - action: 'correspondence.approve'
// - entity_type: 'correspondence'
// - entity_id: id
// - ip_address
// - timestamp
}
🚫 Anti-Patterns (สิ่งที่ห้ามทำ)
- ❌ ห้ามใช้ SQL Triggers สำหรับ Business Logic
- ❌ ห้ามใช้ .env ใน Production (ใช้ Docker ENV)
- ❌ ห้ามใช้
anyType - ❌ ห้าม Hardcode Secrets
- ❌ ห้ามสร้างตาราง Routing แยก (ใช้ Workflow Engine)
- ❌ ห้ามใช้ console.log (ใช้ Logger)
📚 เอกสารอ้างอิง
🔄 Update History
| Version | Date | Changes |
|---|---|---|
| 1.5.0 | 2025-12-01 | Initial backend guidelines created |