690525:1320 ADR-028-228-migration #06
CI / CD Pipeline / build (push) Successful in 4m18s
CI / CD Pipeline / deploy (push) Successful in 7m41s

This commit is contained in:
2026-05-25 13:20:17 +07:00
parent dcd1a9855e
commit 001237ea35
18 changed files with 967 additions and 128 deletions
@@ -46,6 +46,13 @@ describe('FileStorageService', () => {
find: jest.fn(),
findOne: jest.fn(),
remove: jest.fn(),
createQueryBuilder: jest.fn(() => ({
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue(null),
})),
},
},
{
@@ -56,19 +56,36 @@ export class FileStorageService {
/**
* Phase 1: Upload (บันทึกไฟล์ลง Temp)
* Idempotent: ถ้าไฟล์ checksum เดิมยังอยู่ใน temp (ไม่หมดอายุ) จะคืน record เดิมแทน
* ป้องกัน orphan files จาก n8n retry
*/
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)
// 2. Checksum-based dedup — ถ้า temp attachment เดิมยังไม่หมดอายุ คืน record เดิมทันที
const existing = await this.attachmentRepository
.createQueryBuilder('a')
.where('a.checksum = :checksum', { checksum })
.andWhere('a.isTemporary = :isTemp', { isTemp: true })
.andWhere('a.uploadedByUserId = :userId', { userId })
.andWhere('a.expiresAt > :now', { now: new Date() })
.getOne();
if (existing) {
this.logger.log(
`Dedup upload: returning existing temp attachment publicId=${existing.publicId} checksum=${checksum}`
);
return existing;
}
const fileExt = path.extname(originalFilename);
const storedFilename = `${uuidv4()}${fileExt}`;
const tempPath = path.join(this.tempDir, storedFilename);
// 3. บันทึกไฟล์ลง Disk (Temp Folder)
try {
await fs.writeFile(tempPath, file.buffer);
} catch (error) {
@@ -76,7 +93,7 @@ export class FileStorageService {
throw new BadRequestException('File upload failed');
}
// 3. สร้าง Record ใน Database
// 4. สร้าง Record ใน Database
const attachment = this.attachmentRepository.create({
originalFilename,
storedFilename: storedFilename,
@@ -84,7 +101,7 @@ export class FileStorageService {
mimeType: file.mimetype,
fileSize: file.size,
isTemporary: true,
tempId: tempId,
tempId: uuidv4(),
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // หมดอายุใน 24 ชม.
checksum: checksum,
uploadedByUserId: userId,
@@ -6,6 +6,7 @@ import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { AiSettingsService } from './ai-settings.service';
import { SystemSetting } from './entities/system-setting.entity';
import { AiAvailableModel } from './entities/ai-available-model.entity';
const DEFAULT_REDIS_TOKEN = 'default_IORedisModuleConnectionToken';
@@ -18,6 +19,15 @@ describe('AiSettingsService', () => {
},
};
const mockAiModelRepo = {
find: jest.fn(),
findOne: jest.fn(),
save: jest.fn(),
create: jest.fn(),
update: jest.fn(),
softDelete: jest.fn(),
};
const mockRedis = {
get: jest.fn(),
set: jest.fn(),
@@ -35,6 +45,10 @@ describe('AiSettingsService', () => {
provide: getRepositoryToken(SystemSetting),
useValue: mockSettingRepo,
},
{
provide: getRepositoryToken(AiAvailableModel),
useValue: mockAiModelRepo,
},
{ provide: DEFAULT_REDIS_TOKEN, useValue: mockRedis },
],
}).compile();
@@ -2,6 +2,7 @@
// Change Log
// - 2026-05-21: เพิ่ม service สำหรับอ่าน/เขียน AI feature toggle พร้อม Redis cache.
// - 2026-05-22: เพิ่ม try-catch ใน getAiFeaturesEnabled() เพื่อความยืดหยุ่นในกรณีที่ฐานข้อมูลยังไม่ได้อัปเกรดตาราง system_settings
// - 2026-05-25: เพิ่ม methods สำหรับจัดการรายการโมเดล AI แบบไดนามิก (ADR-027)
import { Injectable, Logger } from '@nestjs/common';
import { InjectRedis } from '@nestjs-modules/ioredis';
@@ -9,11 +10,17 @@ import { InjectRepository } from '@nestjs/typeorm';
import type Redis from 'ioredis';
import { EntityManager, Repository } from 'typeorm';
import { SystemSetting } from './entities/system-setting.entity';
import { AiAvailableModel } from './entities/ai-available-model.entity';
import { NotFoundException } from '../../common/exceptions';
const AI_FEATURES_ENABLED_KEY = 'AI_FEATURES_ENABLED';
const AI_FEATURES_ENABLED_CACHE_KEY = 'system_settings:AI_FEATURES_ENABLED';
const AI_FEATURES_ENABLED_TTL_SECONDS = 30;
const AI_ACTIVE_MODEL_KEY = 'AI_ACTIVE_MODEL';
const AI_ACTIVE_MODEL_CACHE_KEY = 'system_settings:AI_ACTIVE_MODEL';
const AI_ACTIVE_MODEL_TTL_SECONDS = 30;
/** Service สำหรับจัดการ system_settings ที่เกี่ยวข้องกับ AI Admin Console */
@Injectable()
export class AiSettingsService {
@@ -22,6 +29,8 @@ export class AiSettingsService {
constructor(
@InjectRepository(SystemSetting)
private readonly settingRepo: Repository<SystemSetting>,
@InjectRepository(AiAvailableModel)
private readonly modelRepo: Repository<AiAvailableModel>,
@InjectRedis() private readonly redis: Redis
) {}
@@ -113,4 +122,171 @@ export class AiSettingsService {
private toMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
// --- AI Model Management (ADR-027) ---
/** ดึงรายการโมเดล AI ทั้งหมดที่ใช้งานได้ (รวมถึงที่ไม่ active) */
async getAvailableModels(): Promise<AiAvailableModel[]> {
return this.modelRepo.find({
order: { isDefault: 'DESC', modelName: 'ASC' },
});
}
/** ดึงรายการโมเดล AI ที่ active เท่านั้น */
async getActiveModels(): Promise<AiAvailableModel[]> {
return this.modelRepo.find({
where: { isActive: true },
order: { modelName: 'ASC' },
});
}
/** ดึงโมเดล AI ที่ใช้งานอยู่ปัจจุบัน (active model) */
async getActiveModel(): Promise<string> {
try {
const cachedValue = await this.redis.get(AI_ACTIVE_MODEL_CACHE_KEY);
if (cachedValue) return cachedValue;
const setting = await this.settingRepo.findOne({
where: { settingKey: AI_ACTIVE_MODEL_KEY },
});
const activeModel = setting?.settingValue ?? 'gemma4:e2b';
await this.redis.set(
AI_ACTIVE_MODEL_CACHE_KEY,
activeModel,
'EX',
AI_ACTIVE_MODEL_TTL_SECONDS
);
return activeModel;
} catch (error: unknown) {
this.logger.error(`Failed to get active model: ${this.toMessage(error)}`);
return 'gemma4:e2b';
}
}
/** ตั้งค่าโมเดล AI ที่ใช้งาน (global) */
async setActiveModel(modelName: string, userId: number): Promise<string> {
const model = await this.modelRepo.findOne({
where: { modelName, isActive: true },
});
if (!model) {
throw new NotFoundException('AiAvailableModel', modelName);
}
await this.settingRepo.manager.transaction(
async (manager: EntityManager): Promise<void> => {
const repo = manager.getRepository(SystemSetting);
const existing = await repo.findOne({
where: { settingKey: AI_ACTIVE_MODEL_KEY },
});
const setting =
existing ??
repo.create({
settingKey: AI_ACTIVE_MODEL_KEY,
dataType: 'string',
category: 'ai',
description: 'โมเดล AI ที่ใช้งานอยู่ในระบบ (global)',
isPublic: true,
});
setting.settingValue = modelName;
setting.updatedBy = userId;
await repo.save(setting);
}
);
await this.redis.del(AI_ACTIVE_MODEL_CACHE_KEY);
this.logger.log(
`Active AI model changed to ${modelName} by user ${userId}`
);
return modelName;
}
/** เพิ่มโมเดล AI ใหม่เข้าระบบ (Superadmin only) */
async addModel(
data: {
modelName: string;
modelVersion: string;
description?: string;
vramGb?: number;
},
userId: number
): Promise<AiAvailableModel> {
const existing = await this.modelRepo.findOne({
where: { modelName: data.modelName },
withDeleted: true,
});
if (existing) {
throw new Error(`Model ${data.modelName} already exists`);
}
const model = this.modelRepo.create({
...data,
isActive: true,
isDefault: false,
createdBy: userId,
});
const saved = await this.modelRepo.save(model);
this.logger.log(`New AI model added: ${data.modelName} by user ${userId}`);
return saved;
}
/** ลบโมเดล AI (soft delete) */
async removeModel(modelName: string, userId: number): Promise<void> {
const model = await this.modelRepo.findOne({
where: { modelName },
});
if (!model) {
throw new NotFoundException('AiAvailableModel', modelName);
}
if (model.isDefault) {
throw new Error('Cannot remove default model');
}
const activeModel = await this.getActiveModel();
if (activeModel === modelName) {
throw new Error('Cannot remove currently active model');
}
await this.modelRepo.softRemove(model);
this.logger.log(`AI model removed: ${modelName} by user ${userId}`);
}
/** เปลี่ยนสถานะ active/inactive ของโมเดล */
async toggleModelActive(
modelName: string,
userId: number
): Promise<AiAvailableModel> {
const model = await this.modelRepo.findOne({
where: { modelName },
});
if (!model) {
throw new NotFoundException('AiAvailableModel', modelName);
}
if (model.isDefault && model.isActive) {
throw new Error('Cannot deactivate default model');
}
const activeModel = await this.getActiveModel();
if (activeModel === modelName && model.isActive) {
throw new Error('Cannot deactivate currently active model');
}
model.isActive = !model.isActive;
model.updatedBy = userId;
const saved = await this.modelRepo.save(model);
this.logger.log(
`AI model ${modelName} active status changed to ${model.isActive} by user ${userId}`
);
return saved;
}
}
+110
View File
@@ -282,6 +282,116 @@ export class AiController {
return { aiFeaturesEnabled };
}
// ─── AI Model Management (ADR-027) ─────────────────────────────────────────
@Get('admin/models')
@UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth()
@RequirePermission('system.manage_all')
@ApiOperation({
summary: 'AI Models — ดึงรายการโมเดล AI ทั้งหมดที่ใช้งานได้',
})
async getAvailableModels() {
const models = await this.aiSettingsService.getAvailableModels();
const activeModel = await this.aiSettingsService.getActiveModel();
return { models, activeModel };
}
@Get('admin/models/active')
@UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth()
@RequirePermission('system.manage_all')
@ApiOperation({
summary: 'AI Active Model — ดึงโมเดล AI ที่ใช้งานอยู่ปัจจุบัน',
})
async getActiveModel() {
const activeModel = await this.aiSettingsService.getActiveModel();
return { activeModel };
}
@Post('admin/models/active')
@UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth()
@RequirePermission('system.manage_all')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'AI Set Active Model — ตั้งค่าโมเดล AI ที่ใช้งาน (global)',
})
async setActiveModel(
@Body() dto: { modelName: string },
@CurrentUser() user: User
) {
const activeModel = await this.aiSettingsService.setActiveModel(
dto.modelName,
user.user_id
);
return { activeModel };
}
@Post('admin/models')
@UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth()
@RequirePermission('system.manage_all')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({
summary: 'AI Add Model — เพิ่มโมเดล AI ใหม่เข้าระบบ (Superadmin only)',
})
async addModel(
@Body()
dto: {
modelName: string;
modelVersion: string;
description?: string;
vramGb?: number;
},
@CurrentUser() user: User
) {
const model = await this.aiSettingsService.addModel(dto, user.user_id);
return { model };
}
@Patch('admin/models/:modelName/toggle')
@UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth()
@RequirePermission('system.manage_all')
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'AI Toggle Model — เปลี่ยนสถานะ active/inactive ของโมเดล',
})
@ApiParam({
name: 'modelName',
description: 'ชื่อโมเดล เช่น gemma4:e4b',
})
async toggleModelActive(
@Param('modelName') modelName: string,
@CurrentUser() user: User
) {
const model = await this.aiSettingsService.toggleModelActive(
modelName,
user.user_id
);
return { model };
}
@Delete('admin/models/:modelName')
@UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth()
@RequirePermission('system.manage_all')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({
summary: 'AI Remove Model — ลบโมเดล AI (soft delete)',
})
@ApiParam({
name: 'modelName',
description: 'ชื่อโมเดลที่ต้องการลบ',
})
async removeModel(
@Param('modelName') modelName: string,
@CurrentUser() user: User
): Promise<void> {
await this.aiSettingsService.removeModel(modelName, user.user_id);
}
@Get('admin/health')
@UseGuards(JwtAuthGuard, RbacGuard)
@ApiBearerAuth()
+3
View File
@@ -7,6 +7,7 @@
// - 2026-05-21: ลงทะเบียน SystemSetting, AiSettingsService และ AiEnabledGuard สำหรับ ADR-027.
// - 2026-05-22: นำเข้าและลงทะเบียน CleanupTempFilesWorker (T016) เพื่อลบไฟล์แนบชั่วคราวหมดอายุ
// - 2026-05-23: ลงทะเบียน MigrationProgress + AiMigrationCheckpointService (ADR-023A)
// - 2026-05-25: ลงทะเบียน AiAvailableModel สำหรับ AI Model Management (ADR-027).
// Module สำหรับ AI Gateway — ลงทะเบียน Services และ Controllers (ADR-023)
import { Logger, Module, OnModuleInit } from '@nestjs/common';
@@ -36,6 +37,7 @@ import { AiAuditLog } from './entities/ai-audit-log.entity';
import { MigrationReviewRecord } from './entities/migration-review.entity';
import { MigrationProgress } from './entities/migration-progress.entity';
import { SystemSetting } from './entities/system-setting.entity';
import { AiAvailableModel } from './entities/ai-available-model.entity';
import { AiMigrationCheckpointService } from './ai-migration-checkpoint.service';
import { AiEnabledGuard } from './guards/ai-enabled.guard';
import { UserModule } from '../user/user.module';
@@ -72,6 +74,7 @@ import {
MigrationReviewRecord,
MigrationProgress,
SystemSetting,
AiAvailableModel,
Attachment,
Project,
Organization,
@@ -0,0 +1,61 @@
// File: src/modules/ai/entities/ai-available-model.entity.ts
// Change Log:
// - 2026-05-25: สร้าง Entity AiAvailableModel สำหรับจัดการรายการโมเดล AI แบบไดนามิก (ADR-027)
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
/** Entity สำหรับเก็บรายการโมเดล AI ที่ให้เลือกใช้งานในระบบ */
@Entity('ai_available_models')
@Index(['isActive'])
@Index(['isDefault'])
export class AiAvailableModel {
@PrimaryGeneratedColumn()
id!: number;
@Column({ name: 'model_name', unique: true, length: 100 })
modelName!: string;
@Column({ name: 'model_version', length: 50 })
modelVersion!: string;
@Column({ length: 500, nullable: true })
description?: string;
@Column({
name: 'vram_gb',
type: 'decimal',
precision: 4,
scale: 2,
nullable: true,
})
vramGb?: number;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@Column({ name: 'is_default', type: 'boolean', default: false })
isDefault!: boolean;
@Column({ name: 'created_by', nullable: true })
createdBy?: number;
@Column({ name: 'updated_by', nullable: true })
updatedBy?: number;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
@DeleteDateColumn({ name: 'deleted_at' })
deletedAt?: Date;
}
@@ -395,7 +395,7 @@ Return ONLY a valid JSON object matching this schema. Do NOT include markdown co
});
await this.saveAiAuditLog({
documentPublicId,
aiModel: this.ollamaService.getMainModelName(),
aiModel: await this.ollamaService.getMainModelName(),
status: AiAuditStatus.FAILED,
errorMessage: errMsg,
processingTimeMs: Date.now() - startTime,
@@ -421,7 +421,7 @@ Return ONLY a valid JSON object matching this schema. Do NOT include markdown co
});
await this.saveAiAuditLog({
documentPublicId,
aiModel: this.ollamaService.getMainModelName(),
aiModel: await this.ollamaService.getMainModelName(),
status: AiAuditStatus.FAILED,
errorMessage: errMsg,
processingTimeMs: Date.now() - startTime,
@@ -466,7 +466,7 @@ Return ONLY a valid JSON object matching this schema. Do NOT include markdown co
});
await this.saveAiAuditLog({
documentPublicId,
aiModel: this.ollamaService.getMainModelName(),
aiModel: await this.ollamaService.getMainModelName(),
status: AiAuditStatus.SUCCESS,
aiSuggestionJson: extractedMetadata,
confidenceScore: confidence,
@@ -114,7 +114,7 @@ export class AiRealtimeProcessor extends WorkerHost {
this.aiAuditLogRepo.create({
documentPublicId: job.data.documentPublicId,
aiModel: 'gemma4',
modelName: this.ollamaService.getMainModelName(),
modelName: await this.ollamaService.getMainModelName(),
aiSuggestionJson: normalizedSuggestion,
confidenceScore: this.extractConfidence(normalizedSuggestion),
processingTimeMs: Date.now() - startTime,
@@ -136,7 +136,7 @@ export class AiRealtimeProcessor extends WorkerHost {
this.aiAuditLogRepo.create({
documentPublicId: job.data.documentPublicId,
aiModel: 'gemma4',
modelName: this.ollamaService.getMainModelName(),
modelName: await this.ollamaService.getMainModelName(),
processingTimeMs: Date.now() - startTime,
status: AiAuditStatus.FAILED,
errorMessage: err instanceof Error ? err.message : String(err),
@@ -2,10 +2,12 @@
// Change Log
// - 2026-05-15: เพิ่ม Ollama service สำหรับ ADR-023A 2-model stack.
// - 2026-05-21: เพิ่ม checkHealth สำหรับตรวจสอบสุขภาพและความเร็ว (Latency) ของ Ollama
// - 2026-05-25: เพิ่มการใช้งานโมเดลจาก DB (AiSettingsService) แทน ENV เท่านั้น (ADR-027).
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, Optional } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { AiSettingsService } from '../ai-settings.service';
export interface OllamaGenerateOptions {
timeoutMs?: number;
@@ -17,18 +19,23 @@ export interface OllamaGenerateOptions {
export class OllamaService {
private readonly logger = new Logger(OllamaService.name);
private readonly ollamaUrl: string;
private readonly mainModel: string;
private readonly defaultMainModel: string;
private readonly embedModel: string;
private readonly timeoutMs: number;
constructor(private readonly configService: ConfigService) {
constructor(
private readonly configService: ConfigService,
@Optional()
private readonly aiSettingsService?: AiSettingsService
) {
this.ollamaUrl = this.configService.get<string>(
'OLLAMA_URL',
this.configService.get<string>('AI_HOST_URL', 'http://localhost:11434')
);
this.mainModel = this.configService.get<string>(
// Default fallback model (ADR-023A: gemma4:e2b)
this.defaultMainModel = this.configService.get<string>(
'OLLAMA_MODEL_MAIN',
'gemma4:e4b'
'gemma4:e2b'
);
this.embedModel = this.configService.get<string>(
'OLLAMA_MODEL_EMBED',
@@ -37,16 +44,31 @@ export class OllamaService {
this.timeoutMs = this.configService.get<number>('AI_TIMEOUT_MS', 30000);
}
/** สร้างข้อความตอบกลับจาก gemma4:e4b หรือค่า ENV ที่กำหนด */
/** ดึงชื่อโมเดลที่ใช้งานอยู่ (จาก DB หรือ ENV fallback) */
private async getActiveModelName(): Promise<string> {
if (this.aiSettingsService) {
try {
return await this.aiSettingsService.getActiveModel();
} catch (err: unknown) {
this.logger.warn(
`Failed to get active model from DB: ${err instanceof Error ? err.message : String(err)}`
);
}
}
return this.defaultMainModel;
}
/** สร้างข้อความตอบกลับจากโมเดลที่กำหนด (DB หรือ ENV fallback) */
async generate(
prompt: string,
options: OllamaGenerateOptions = {}
): Promise<string> {
const modelName = await this.getActiveModelName();
try {
const response = await axios.post<{ response: string }>(
`${this.ollamaUrl}/api/generate`,
{
model: this.mainModel,
model: modelName,
prompt,
stream: false,
},
@@ -58,7 +80,7 @@ export class OllamaService {
return response.data.response ?? '';
} catch (err) {
this.logger.error(
'Ollama generate failed',
`Ollama generate failed with model ${modelName}`,
err instanceof Error ? err.stack : String(err)
);
throw err;
@@ -83,9 +105,14 @@ export class OllamaService {
}
}
/** คืนชื่อ main model สำหรับ audit log */
getMainModelName(): string {
return this.mainModel;
/** คืนชื่อ main model สำหรับ audit log (async เพราะต้องเช็ค DB) */
async getMainModelName(): Promise<string> {
return this.getActiveModelName();
}
/** คืนชื่อ main model แบบ sync (fallback สำหรับกรณีที่ไม่ต้องการ async) */
getMainModelNameSync(): string {
return this.defaultMainModel;
}
/** คืนชื่อ embedding model สำหรับ audit log */
@@ -101,13 +128,14 @@ export class OllamaService {
error?: string;
}> {
const startTime = Date.now();
const activeModel = await this.getActiveModelName();
try {
await axios.get(`${this.ollamaUrl}/api/tags`, { timeout: 5000 });
const latencyMs = Date.now() - startTime;
return {
status: 'HEALTHY',
latencyMs,
models: [this.mainModel, this.embedModel],
models: [activeModel, this.embedModel],
};
} catch (err: unknown) {
const latencyMs = Date.now() - startTime;
@@ -120,7 +148,7 @@ export class OllamaService {
return {
status: isTimeout ? 'DEGRADED' : 'DOWN',
latencyMs,
models: [this.mainModel, this.embedModel],
models: [activeModel, this.embedModel],
error,
};
}