251120:1700 Backend T3.4

This commit is contained in:
admin
2025-11-20 17:14:15 +07:00
parent 859475b9f0
commit 20c0f51e2a
42 changed files with 1818 additions and 10 deletions

View File

@@ -0,0 +1,164 @@
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<DocumentNumberCounter>,
@InjectRepository(DocumentNumberFormat)
private formatRepo: Repository<DocumentNumberFormat>,
private configService: ConfigService,
) {}
// 1. เริ่มต้นเชื่อมต่อ Redis และ Redlock เมื่อ Module ถูกโหลด
onModuleInit() {
this.redisClient = new Redis({
host: this.configService.get<string>('REDIS_HOST'),
port: this.configService.get<number>('REDIS_PORT'),
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.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<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)
// ป้องกันไม่ให้ 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<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}';
// 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;
}
}