39 KiB
รายงานการตรวจสอบ Unhandled Exception และ Crash Loop
Batch 11: Controllers 101-110
วันที่ตรวจสอบ: 2026-05-08
จำนวน Controllers ที่ตรวจสอบ: 10 Controllers
Controllers ที่ตรวจสอบในชุดนี้
- ProfileSalaryTempController.ts
- ReportController.ts - เกินขนาด 256KB (skip)
- ScriptProfileOrgController.ts
- WorkflowController.ts
- ProfileTrainingController.ts
- OrganizationController.ts - ตรวจสอบแล้วใน batch ก่อนหน้า
- PositionController.ts - ตรวจสอบแล้วใน batch ก่อนหน้า
- ProfileController.ts - ตรวจสอบแล้วใน batch ก่อนหน้า
- CommandController.ts - ตรวจสอบแล้วใน batch ก่อนหน้า
- User Controller.ts - ตรวจสอบแล้วใน batch ก่อนหน้า
รายการปัญหาที่พบ
1. 🔴 CRITICAL - ProfileSalaryTempController.ts - Unhandled Exception in Large Loop
File & Location: ProfileSalaryTempController.ts - changeSortEditGenAll() method
Problem Type: 1. Unhandled Exception / 2. Missing Error Handle
Root Cause:
@Get("change/sort/all")
public async changeSortEditGenAll() {
try {
const profiles = await this.profileRepo.find();
let num = 1;
for await (const item of profiles) {
let salaryOld = await this.salaryOldRepo.find({
where: { profileId: item.id },
order: { commandDateAffect: "ASC", order: "ASC" },
});
salaryOld.forEach((item: any, i) => {
item.order = i + 1;
});
num = num + 1;
console.log(num);
await this.salaryOldRepo.save(salaryOld);
}
return new HttpSuccess();
} catch {
throw new HttpError(HttpStatusCode.NOT_FOUND, "ไม่สามารถดำเนินการได้");
}
}
ปัญหาที่พบ:
- Loading all profiles at once -
await this.profileRepo.find()โหลดข้อมูลทั้งหมดเข้า memory อาจทำให้เกิด Out of Memory - No transaction management - แต่ละรอบบันทึกแยกกัน หากเกิด error ข้อมูลบางส่วนอาจถูกบันทึกแล้วบางส่วนไม่ได้
- No error handling per iteration - หากเกิด error ใน loop ที่ profile ใด profile หนึ่ง ทั้งกระบวนการจะหยุดทันที
- Generic catch block - catch แล้ว throw generic error โดยไม่ log รายละเอียดของ error ทำให้ debug ยาก
- No timeout protection - หากมี profiles จำนวนมาก อาจทำให้ request ค้างนานเกินไป
Recommended Fix:
@Get("change/sort/all")
public async changeSortEditGenAll() {
const queryRunner = AppDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
let processedCount = 0;
let failedCount = 0;
const errors: Array<{profileId: string, error: string}> = [];
try {
const BATCH_SIZE = 500;
let offset = 0;
let hasMore = true;
while (hasMore) {
const profiles = await queryRunner.manager.find(Profile, {
select: ["id"],
take: BATCH_SIZE,
skip: offset,
order: { id: 'ASC' }
});
if (profiles.length === 0) {
hasMore = false;
break;
}
for (const profile of profiles) {
try {
const salaryOld = await queryRunner.manager.find(ProfileSalary, {
where: { profileId: profile.id },
order: { commandDateAffect: "ASC", order: "ASC" },
});
// Update order
for (let i = 0; i < salaryOld.length; i++) {
salaryOld[i].order = i + 1;
}
await queryRunner.manager.save(salaryOld);
processedCount++;
if (processedCount % 100 === 0) {
console.log(`Processed ${processedCount} profiles`);
}
} catch (itemError: any) {
failedCount++;
errors.push({
profileId: profile.id,
error: itemError?.message || String(itemError)
});
console.error(`Failed to process profile ${profile.id}:`, itemError);
}
}
// Commit per batch to avoid large transaction
await queryRunner.commitTransaction();
await queryRunner.startTransaction();
offset += BATCH_SIZE;
}
await queryRunner.commitTransaction();
return new HttpSuccess({
message: "ปรับปรุงลำดับเสร็จสิ้น",
processed: processedCount,
failed: failedCount,
errors: errors.slice(0, 100) // ส่งเฉพาะ 100 errors แรก
});
} catch (error: any) {
await queryRunner.rollbackTransaction();
console.error("changeSortEditGenAll failed:", error);
throw new HttpError(
HttpStatusCode.INTERNAL_SERVER_ERROR,
`ไม่สามารถดำเนินการได้: ${error?.message || 'Unknown error'}`
);
} finally {
await queryRunner.release();
}
}
2. 🔴 CRITICAL - ScriptProfileOrgController.ts - Unhandled External API Error
File & Location: ScriptProfileOrgController.ts - cronjobUpdateOrg() method
Problem Type: 1. Unhandled Exception / 2. Missing Error Handle
Root Cause:
await axios.put(`${process.env.API_URL}/leave-beginning/schedule/update-dna`, payloads, {
headers: {
"Content-Type": "application/json",
api_key: process.env.API_KEY,
},
timeout: 30000, // 30 second timeout
});
ปัญหาที่พบ:
- No error handling for external API - หาก external API ล้มหรือ timeout จะเกิด unhandled exception
- No retry logic - ไม่มีการ retry หาก external API ล้มชั่วคราว
- No validation of environment variables -
process.env.API_URLหรือprocess.env.API_KEYอาจเป็น undefined - Payload size not validated - หาก payloads ขนาดใหญ่เกินไปอาจทำให้ external API ล้ม
- Circuit breaker not implemented - หาก external API ล้มต่อเนื่อง จะไม่มีการหยุดชั่วคราว
Recommended Fix:
// Add at class level
private apiFailureCount = 0;
private readonly API_FAILURE_THRESHOLD = 5;
private readonly API_RETRY_ATTEMPTS = 3;
private isCircuitOpen = false;
@Post("update-org")
public async cronjobUpdateOrg(@Request() request: RequestWithUser) {
// Idempotency check
if (this.isRunning) {
console.log("cronjobUpdateOrg: Job already running, skipping this execution");
return new HttpSuccess({
message: "Job already running",
skipped: true,
});
}
// Circuit breaker check
if (this.isCircuitOpen) {
console.warn("cronjobUpdateOrg: Circuit breaker is OPEN, skipping execution");
return new HttpSuccess({
message: "Circuit breaker is open",
skipped: true,
});
}
this.isRunning = true;
const startTime = Date.now();
try {
// Validate environment variables first
const apiUrl = process.env.API_URL;
const apiKey = process.env.API_KEY;
if (!apiUrl) {
throw new Error("API_URL environment variable is not set");
}
if (!apiKey) {
throw new Error("API_KEY environment variable is not set");
}
const windowStart = new Date(Date.now() - this.UPDATE_WINDOW_HOURS * 60 * 60 * 1000);
console.log("cronjobUpdateOrg: Starting job", {
windowHours: this.UPDATE_WINDOW_HOURS,
windowStart: windowStart.toISOString(),
batchSize: this.BATCH_SIZE,
});
// ... existing database queries ...
// Build payloads
const payloads = this.buildPayloads(posMasters, posMasterEmployee, posMasterEmployeeTemp);
if (payloads.length === 0) {
console.log("cronjobUpdateOrg: No records to process");
return new HttpSuccess({
message: "No records to process",
processed: 0,
});
}
// Validate payload size before sending
if (payloads.length > 10000) {
console.warn(`cronjobUpdateOrg: Payload size ${payloads.length} exceeds 10000, splitting`);
}
// Update profile's org structure in leave service with retry logic
console.log("cronjobUpdateOrg: Calling leave service API", {
payloadCount: payloads.length,
});
let apiSuccess = false;
let lastError: any;
for (let attempt = 1; attempt <= this.API_RETRY_ATTEMPTS; attempt++) {
try {
await axios.put(
`${apiUrl}/leave-beginning/schedule/update-dna`,
payloads,
{
headers: {
"Content-Type": "application/json",
api_key: apiKey,
},
timeout: 60000, // 60 second timeout (increased)
validateStatus: (status) => status < 500, // Retry on server errors only
}
);
apiSuccess = true;
this.apiFailureCount = 0; // Reset failure count on success
console.log(`cronjobUpdateOrg: API call succeeded on attempt ${attempt}`);
break;
} catch (error: any) {
lastError = error;
console.error(`cronjobUpdateOrg: API call attempt ${attempt} failed:`, error.message);
// Don't retry on client errors (4xx)
if (error.response?.status >= 400 && error.response?.status < 500) {
break;
}
// Wait before retry with exponential backoff
if (attempt < this.API_RETRY_ATTEMPTS) {
const delay = Math.min(1000 * Math.pow(2, attempt - 1), 10000);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
if (!apiSuccess) {
this.apiFailureCount++;
// Open circuit breaker if threshold reached
if (this.apiFailureCount >= this.API_FAILURE_THRESHOLD) {
this.isCircuitOpen = true;
console.error("cronjobUpdateOrg: Circuit breaker OPENED due to repeated failures");
// Auto-close after 5 minutes
setTimeout(() => {
this.isCircuitOpen = false;
this.apiFailureCount = 0;
console.log("cronjobUpdateOrg: Circuit breaker CLOSED");
}, 5 * 60 * 1000);
}
throw new HttpError(
HttpStatus.INTERNAL_SERVER_ERROR,
`ไม่สามารถเรียก Leave Service API ได้: ${lastError?.message || 'Unknown error'}`
);
}
// ... rest of the method ...
} catch (error: any) {
const duration = Date.now() - startTime;
console.error("cronjobUpdateOrg: Job failed", {
duration: `${duration}ms`,
error: error.message,
stack: error.stack,
});
throw new HttpError(
HttpStatus.INTERNAL_SERVER_ERROR,
`Internal server error: ${error?.message || 'Unknown error'}`
);
} finally {
this.isRunning = false;
}
}
3. 🔴 CRITICAL - ScriptProfileOrgController.ts - Race Condition in Idempotency Check
File & Location: ScriptProfileOrgController.ts - Class properties and cronjobUpdateOrg() method
Problem Type: 1. Unhandled Exception / 2. Missing Error Handle
Root Cause:
private isRunning = false;
@Post("update-org")
public async cronjobUpdateOrg(@Request() request: RequestWithUser) {
if (this.isRunning) {
console.log("cronjobUpdateOrg: Job already running, skipping this execution");
return new HttpSuccess({
message: "Job already running",
skipped: true,
});
}
this.isRunning = true;
// ... rest of the method
finally {
this.isRunning = false;
}
}
ปัญหาที่พบ:
- Race condition - หากมี 2 requests มาพร้อมกัน
isRunningอาจถูกตั้งค่าเป็น false โดยทั้ง 2 requests อ่านเป็น false - Stuck state - หากเกิด error ก่อนถึง finally block หรือ process crash ทิ้ง
isRunningจะค้างที่ true ตลอดไป - No timeout - หาก job ทำงานนานเกินไป ไม่มีกลไก timeout
- Instance variable in containerized environment - ในระบบ microservices ที่มีหลาย instances แต่ละ instance จะมี
isRunningเป็นของตัวเอง ทำให้อาจมีการรันซ้ำกัน
Recommended Fix:
// Use Redis or database for distributed lock
import { createClient } from 'redis';
@Route("api/v1/org/script-profile-org")
@Tags("Keycloak Sync")
@Security("bearerAuth")
export class ScriptProfileOrgController extends Controller {
// ... existing repositories ...
private readonly redisClient = createClient({
url: process.env.REDIS_URL || 'redis://localhost:6379',
socket: {
connectTimeout: 5000,
},
});
private readonly LOCK_TIMEOUT = 10 * 60 * 1000; // 10 minutes
private readonly LOCK_KEY = 'cronjob:update-org:lock';
private async acquireLock(): Promise<boolean> {
try {
await this.redisClient.connect();
const result = await this.redisClient.set(
this.LOCK_KEY,
'locked',
{
NX: true, // Only set if not exists
PX: this.LOCK_TIMEOUT, // Expire after timeout
}
);
return result === 'OK';
} catch (error) {
console.error('Failed to acquire lock:', error);
return false;
} finally {
await this.redisClient.quit();
}
}
private async releaseLock(): Promise<void> {
try {
await this.redisClient.connect();
await this.redisClient.del(this.LOCK_KEY);
} catch (error) {
console.error('Failed to release lock:', error);
} finally {
await this.redisClient.quit();
}
}
@Post("update-org")
public async cronjobUpdateOrg(@Request() request: RequestWithUser) {
// Try to acquire lock
const lockAcquired = await this.acquireLock();
if (!lockAcquired) {
console.log("cronjobUpdateOrg: Job already running or lock not acquired, skipping");
return new HttpSuccess({
message: "Job already running",
skipped: true,
});
}
const startTime = Date.now();
try {
// ... rest of the method ...
const duration = Date.now() - startTime;
console.log("cronjobUpdateOrg: Job completed", {
duration: `${duration}ms`,
processed: payloads.length,
});
return new HttpSuccess({
message: "Update org completed",
processed: payloads.length,
syncResults,
duration: `${duration}ms`,
});
} catch (error: any) {
const duration = Date.now() - startTime;
console.error("cronjobUpdateOrg: Job failed", {
duration: `${duration}ms`,
error: error.message,
stack: error.stack,
});
// Still release lock even on error
throw new HttpError(
HttpStatus.INTERNAL_SERVER_ERROR,
`Internal server error: ${error?.message || 'Unknown error'}`
);
} finally {
// Always release lock
await this.releaseLock();
}
}
}
4. 🟡 HIGH - WorkflowController.ts - Missing Error Handling in Workflow Creation
File & Location: WorkflowController.ts - checkWorkflow() method
Problem Type: 2. Missing Error Handle
Root Cause:
@Post("add-workflow")
public async checkWorkflow(
@Request() req: RequestWithUser,
@Body() body: { ... }
) {
const [userProfileOfficer, userProfileEmployee, metaWorkflow] = await Promise.all([
this.profileRepo.findOne({...}),
this.profileEmployeeRepo.findOne({...}),
this.metaWorkflowRepo.findOne({...}),
]);
let profileType = "OFFICER";
let profile: any = userProfileOfficer;
if (!profile) {
profileType = "EMPLOYEE";
profile = userProfileEmployee;
if (!profile) throw new HttpError(HttpStatus.NOT_FOUND, "ไม่พบข้อมูลผู้ใช้งาน");
}
if (!metaWorkflow) throw new HttpError(HttpStatus.NOT_FOUND, "ไม่พบกระบวนการนี้ได้");
// ... สร้าง workflow ...
const notificationReceivers = stateOperatorUsersToCreate
.filter((user) => firstStateOperators.some((op) => op.operator === user.operator))
.map((user) => ({
receiverUserId: user.profileType === "OFFICER" ? user.profileId : user.profileEmployeeId,
notiLink: notiLink,
}));
// ส่ง notification แบบ fire-and-forget
new CallAPI()
.PostData(req, "/placement/noti/profiles", {...})
.catch((error) => {
console.error("Error calling API:", error);
});
return new HttpSuccess();
}
ปัญหาที่พบ:
- Partial error handling - มี try-catch รอบ workflow creation แต่ไม่ครอบคลุมทั้งหมด
- Notification failure doesn't affect workflow - หาก notification ล้ม จะไม่ทำให้ workflow ล้มด้วย ซึ่งอาจเป็นที่ต้องการ แต่ควรมีการ log ไว้ชัดเจน
- No cleanup on partial failure - หากสร้าง workflow สำเร็จแต่สร้าง states ล้ม จะมีข้อมูล partial อยู่ใน database
- Missing transaction - การสร้าง workflow มีหลายขั้นตอนแต่ไม่มี transaction
Recommended Fix:
@Post("add-workflow")
public async checkWorkflow(
@Request() req: RequestWithUser,
@Body() body: { ... }
) {
const queryRunner = AppDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// ขั้นที่ 1: ค้นหา profile และ metaWorkflow
const [userProfileOfficer, userProfileEmployee, metaWorkflow] = await Promise.all([
queryRunner.manager.findOne(Profile, {
where: { keycloak: req.user.sub },
select: ["id", "keycloak"],
}),
queryRunner.manager.findOne(ProfileEmployee, {
where: { keycloak: req.user.sub },
select: ["id", "keycloak"],
}),
queryRunner.manager.findOne(MetaWorkflow, {
where: {
sysName: body.sysName,
posLevelName: body.posLevelName,
posTypeName: body.posTypeName,
},
}),
]);
let profileType = "OFFICER";
let profile: any = userProfileOfficer;
if (!profile) {
profileType = "EMPLOYEE";
profile = userProfileEmployee;
if (!profile) {
throw new HttpError(HttpStatus.NOT_FOUND, "ไม่พบข้อมูลผู้ใช้งาน");
}
}
if (!metaWorkflow) {
throw new HttpError(HttpStatus.NOT_FOUND, "ไม่พบกระบวนการนี้ได้");
}
const meta = {
createdUserId: req.user.sub,
createdFullName: req.user.name,
lastUpdateUserId: req.user.sub,
lastUpdateFullName: req.user.name,
createdAt: new Date(),
lastUpdatedAt: new Date(),
};
// ขั้นที่ 2: สร้าง workflow และดึง metaState
const workflow = new Workflow();
Object.assign(workflow, {
...metaWorkflow,
id: undefined,
...meta,
...body,
profileType: profileType,
system: body.sysName,
});
const savedWorkflow = await queryRunner.manager.save(workflow);
const metaStates = await queryRunner.manager.find(MetaState, {
where: { metaWorkflowId: metaWorkflow.id },
order: { order: "ASC" },
});
// ขั้นที่ 3: สร้าง states
const statesToCreate = metaStates.map((item) => {
const state = new State();
Object.assign(state, { ...item, id: undefined, workflowId: savedWorkflow.id, ...meta });
return state;
});
const savedStates = await queryRunner.manager.save(statesToCreate);
// ขั้นที่ 4: อัปเดต workflow.stateId
const firstState = savedStates.find((state) => state.order === 1);
if (firstState) {
savedWorkflow.stateId = firstState.id;
await queryRunner.manager.save(savedWorkflow);
}
// ขั้นที่ 5: ดึงและสร้าง stateOperators
const metaStateIds = metaStates.map((item) => item.id);
const allMetaStateOperators = await queryRunner.manager.find(MetaStateOperator, {
where: { metaStateId: In(metaStateIds) },
});
const stateOperatorsToCreate: StateOperator[] = [];
allMetaStateOperators.forEach((metaStateOp) => {
const correspondingState = savedStates.find(
(state) =>
metaStates.find((metaState) => metaState.id === metaStateOp.metaStateId)?.order ===
state.order,
);
if (body.isDeputy) {
if (body.sysName == "SYS_TRANSFER_REQ") {
if (metaStateOp.operator == "PersonnelOfficer" && correspondingState?.order == 1) {
return;
} else if (
metaStateOp.operator == "Officer" &&
[1, 2].includes(correspondingState?.order as number)
) {
metaStateOp.operator = "PersonnelOfficer";
}
}
if (
metaStateOp.operator == "Officer" &&
["REGISTRY_PROFILE", "REGISTRY_PROFILE_EMP", "REGISTRY_IDP"].includes(body.sysName)
) {
metaStateOp.operator = "PersonnelOfficer";
}
}
if (correspondingState) {
const stateOperator = new StateOperator();
Object.assign(stateOperator, {
...metaStateOp,
id: undefined,
stateId: correspondingState.id,
...meta,
});
stateOperatorsToCreate.push(stateOperator);
}
});
await queryRunner.manager.save(stateOperatorsToCreate);
// ขั้นที่ 6: สร้าง StateOperatorUsers
const stateOperatorUsersToCreate: StateOperatorUser[] = [];
let orderNum = 1;
if (profile) {
const ownerStateOperatorUser = new StateOperatorUser();
Object.assign(ownerStateOperatorUser, {
profileId: profileType === "OFFICER" ? profile.id : null,
profileEmployeeId: profileType !== "OFFICER" ? profile.id : null,
profileType: profileType,
operator: "Owner",
order: orderNum,
workflowId: savedWorkflow.id,
...meta,
});
stateOperatorUsersToCreate.push(ownerStateOperatorUser);
}
const profileOfficers = await queryRunner.manager.find(PosMaster, {
where: {
posMasterAssigns: { assignId: body.sysName },
orgRevision: { orgRevisionIsDraft: false, orgRevisionIsCurrent: true },
current_holderId: Not(IsNull()),
...(body.orgRootId && { orgRootId: body.orgRootId }),
},
relations: ["orgChild1"],
});
profileOfficers.forEach((item) => {
if (item.current_holderId) {
orderNum += 1;
const isPersonnelOfficer = item.orgChild1?.isOfficer === true;
const officerStateOperatorUser = new StateOperatorUser();
Object.assign(officerStateOperatorUser, {
profileId: item.current_holderId,
operator: isPersonnelOfficer ? "PersonnelOfficer" : "Officer",
profileType: "OFFICER",
order: orderNum,
workflowId: savedWorkflow.id,
...meta,
});
stateOperatorUsersToCreate.push(officerStateOperatorUser);
}
});
await queryRunner.manager.save(stateOperatorUsersToCreate);
// Commit transaction before sending notification
await queryRunner.commitTransaction();
// ขั้นที่ 7: ส่ง notification (outside transaction)
const firstStateOperators = stateOperatorsToCreate.filter((so) =>
savedStates.find((state) => state.id === so.stateId && state.order === 1),
);
let notiLink = "";
if (body.sysName === "REGISTRY_PROFILE") {
notiLink = `${process.env.VITE_URL_MGT}/registry-officer/request-edit/personal/${body.refId}`;
} else if (body.sysName === "REGISTRY_PROFILE_EMP") {
notiLink = `${process.env.VITE_URL_MGT}/registry-employee/request-edit/personal/${body.refId}`;
} else if (body.sysName === "REGISTRY_IDP") {
notiLink = `${process.env.VITE_URL_MGT}/registry-officer/request-edit-page/${body.refId}`;
}
const notificationReceivers = stateOperatorUsersToCreate
.filter((user) => firstStateOperators.some((op) => op.operator === user.operator))
.map((user) => ({
receiverUserId: user.profileType === "OFFICER" ? user.profileId : user.profileEmployeeId,
notiLink: notiLink,
}));
// ส่ง notification แบบ fire-and-forget แต่ log ไว้ชัดเจน
new CallAPI()
.PostData(req, "/placement/noti/profiles", {
subject: `แจ้ง${savedWorkflow.name}ของ ${body.fullName}`,
body: `แจ้ง${savedWorkflow.name}ของ ${body.fullName}`,
receiverUserIds: notificationReceivers,
payload: "",
isSendMail: true,
isSendInbox: true,
isSendNotification: true,
})
.then(() => {
console.log(`[Workflow] Notification sent successfully for workflow ${savedWorkflow.id}`);
})
.catch((error) => {
console.error(`[Workflow] Failed to send notification for workflow ${savedWorkflow.id}:`, error);
// Log แต่ไม่ throw เพราะ workflow สร้างสำเร็จแล้ว
});
return new HttpSuccess();
} catch (error: any) {
await queryRunner.rollbackTransaction();
console.error('[Workflow] Failed to create workflow:', error);
throw new HttpError(
HttpStatus.INTERNAL_SERVER_ERROR,
`ไม่สามารถสร้าง workflow ได้: ${error?.message || 'Unknown error'}`
);
} finally {
await queryRunner.release();
}
}
5. 🟡 MEDIUM - ProfileSalaryTempController.ts - SQL Injection Risk
File & Location: ProfileSalaryTempController.ts - listProfile() method
Problem Type: 2. Missing Error Handle
Root Cause:
.andWhere(
new Brackets((qb) => {
qb.orWhere(
searchKeyword != null && searchKeyword != ""
? `profile.citizenId like '%${searchKeyword}%'`
: "1=1",
)
// ... หลาย orWhere โดยใส่ค่าโดยตรง
}),
)
ปัญหาที่พบ:
- SQL Injection vulnerability - การใส่
searchKeywordโดยตรงเข้าไปใน query string อาจทำให้เกิด SQL injection - Query syntax error - หาก
searchKeywordมีตัวอักษรพิเศษเช่น single quote (') จะทำให้ query error - No input sanitization - ไม่มีการ sanitize ข้อมูลก่อนใช้ใน query
Recommended Fix:
.andWhere(
new Brackets((qb) => {
const keywordParam = `%${searchKeyword}%`;
if (searchKeyword != null && searchKeyword != "") {
qb.orWhere("profile.citizenId LIKE :keyword", { keyword: keywordParam })
.orWhere("profile.position LIKE :keyword", { keyword: keywordParam })
.orWhere(
"CONCAT(profile.prefix, profile.firstName, ' ', profile.lastName) LIKE :keyword",
{ keyword: keywordParam }
)
.orWhere("posType.posTypeName LIKE :keyword", { keyword: keywordParam })
.orWhere("posLevel.posLevelName LIKE :keyword", { keyword: keywordParam })
.orWhere("orgRoot.orgRootName LIKE :keyword", { keyword: keywordParam })
// ... ใช้ parameterized query ทุกครั้ง
} else {
qb.where("1=1");
}
}),
)
6. 🟡 MEDIUM - WorkflowController.ts - Invalid Switch Case
File & Location: WorkflowController.ts - checkIsCan() method
Problem Type: 2. Missing Error Handle
Root Cause:
switch (body.action.trim().toLocaleUpperCase()) {
case "VIEW":
isCan = operator.canView;
case "UPDATE":
isCan = operator.canUpdate;
case "DELETE":
isCan = operator.canDelete;
case "CANCEL":
isCan = operator.canCancel;
case "OPERATE":
isCan = operator.canOperate;
case "CHANGESTATE":
isCan = operator.canChangeState;
case "COMMENT":
isCan = operator.canComment;
case "SIGN":
isCan = operator.canSign;
default:
isCan = false;
}
ปัญหาที่พบ:
- Missing break statements - ไม่มี
breakหลังแต่ละ case ทำให้ fall-through ไป case ถัดไปเสมอ - Logic error - เช่นถ้า action เป็น "VIEW" จะเช็ค canView แล้ว fall-through ไปเช็ค canUpdate, canDelete, ... และสุดท้ายจะเป็นค่าจาก default case
Recommended Fix:
switch (body.action.trim().toLocaleUpperCase()) {
case "VIEW":
isCan = operator.canView;
break;
case "UPDATE":
isCan = operator.canUpdate;
break;
case "DELETE":
isCan = operator.canDelete;
break;
case "CANCEL":
isCan = operator.canCancel;
break;
case "OPERATE":
isCan = operator.canOperate;
break;
case "CHANGESTATE":
isCan = operator.canChangeState;
break;
case "COMMENT":
isCan = operator.canComment;
break;
case "SIGN":
isCan = operator.canSign;
break;
default:
isCan = false;
break;
}
7. 🟡 MEDIUM - ProfileTrainingController.ts - Unhandled Promise Rejection
File & Location: ProfileTrainingController.ts - editTraining() method
Problem Type: 2. Missing Error Handle
Root Cause:
await Promise.all([
this.trainingRepo.save(record, { data: req }),
setLogDataDiff(req, { before, after: record }),
this.trainingHistoryRepo.save(history, { data: req }),
]);
ปัญหาที่พบ:
- Unhandled Promise rejection - หาก operation ใดๆ ใน Promise.all ล้ม จะเกิด unhandled rejection
- Partial data inconsistency - หาก
save(record)สำเร็จ แต่save(history)ล้ม จะมีข้อมูลไม่สอดคล้องกัน - No try-catch - ไม่มีการ handle error เลย
Recommended Fix:
@Patch("{trainingId}")
public async editTraining(
@Request() req: RequestWithUser,
@Body() body: UpdateProfileTraining,
@Path() trainingId: string,
) {
const queryRunner = AppDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const record = await queryRunner.manager.findOne(ProfileTraining, {
where: { id: trainingId }
});
if (!record) {
throw new HttpError(HttpStatus.NOT_FOUND, "ไม่พบข้อมูล");
}
await new permission().PermissionOrgUserUpdate(req, "SYS_REGISTRY_OFFICER", record.profileId);
const before = structuredClone(record);
const history = new ProfileTrainingHistory();
Object.assign(record, body);
Object.assign(history, { ...record, id: undefined });
history.profileTrainingId = trainingId;
record.lastUpdateUserId = req.user.sub;
record.lastUpdateFullName = req.user.name;
record.lastUpdatedAt = new Date();
history.lastUpdateUserId = req.user.sub;
history.lastUpdateFullName = req.user.name;
history.createdUserId = req.user.sub;
history.createdFullName = req.user.name;
history.createdAt = new Date();
history.lastUpdatedAt = new Date();
// Save within transaction
await queryRunner.manager.save(record);
// Log outside transaction but handle error gracefully
try {
setLogDataDiff(req, { before, after: record });
} catch (logError) {
console.error('[ProfileTraining] Failed to log data diff:', logError);
// Continue anyway - log failure shouldn't break the operation
}
await queryRunner.manager.save(history);
await queryRunner.commitTransaction();
return new HttpSuccess();
} catch (error: any) {
await queryRunner.rollbackTransaction();
if (error instanceof HttpError) {
throw error;
}
throw new HttpError(
HttpStatus.INTERNAL_SERVER_ERROR,
`ไม่สามารถแก้ไขข้อมูลได้: ${error?.message || 'Unknown error'}`
);
} finally {
await queryRunner.release();
}
}
8. 🟢 LOW - ProfileTrainingController.ts - Missing Validation
File & Location: ProfileTrainingController.ts - deleteAllTraining() method
Problem Type: 2. Missing Error Handle
Root Cause:
@Post("delete-all")
public async deleteAllTraining(
@Body() reqBody: { developmentId: string },
@Request() req: RequestWithUser
) {
const trainings = await this.trainingRepo.find({
select: { id: true },
where: { developmentId: reqBody.developmentId },
});
if (trainings.length > 0) {
const trainingIds = trainings.map((x) => x.id);
await this.trainingHistoryRepo.delete({
profileTrainingId: In(trainingIds),
});
await this.trainingRepo.delete({
developmentId: reqBody.developmentId,
});
}
await this.developmentHistoryRepo.delete({
kpiDevelopmentId: reqBody.developmentId,
});
await this.developmentRepo.delete({
kpiDevelopmentId: reqBody.developmentId
});
return new HttpSuccess();
}
ปัญหาที่พบ:
- No input validation - ไม่ validate
developmentIdว่ามีค่าหรือไม่ - No try-catch - ไม่มี error handling เลย
- No transaction - การลบข้อมูลหลายตารางไม่อยู่ใน transaction อาจเกิด partial delete
Recommended Fix:
@Post("delete-all")
public async deleteAllTraining(
@Body() reqBody: { developmentId: string },
@Request() req: RequestWithUser
) {
// Validate input
if (!reqBody.developmentId || reqBody.developmentId.trim() === "") {
throw new HttpError(HttpStatus.BAD_REQUEST, "developmentId ต้องไม่ว่างเปล่า");
}
const queryRunner = AppDataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const trainings = await queryRunner.manager.find(ProfileTraining, {
select: { id: true },
where: { developmentId: reqBody.developmentId },
});
if (trainings.length > 0) {
const trainingIds = trainings.map((x) => x.id);
await queryRunner.manager.delete(ProfileTrainingHistory, {
profileTrainingId: In(trainingIds),
});
await queryRunner.manager.delete(ProfileTraining, {
developmentId: reqBody.developmentId,
});
}
await queryRunner.manager.delete(ProfileDevelopmentHistory, {
kpiDevelopmentId: reqBody.developmentId,
});
await queryRunner.manager.delete(ProfileDevelopment, {
kpiDevelopmentId: reqBody.developmentId
});
await queryRunner.commitTransaction();
return new HttpSuccess({
message: "ลบข้อมูลเสร็จสิ้น",
deletedTrainings: trainings.length
});
} catch (error: any) {
await queryRunner.rollbackTransaction();
throw new HttpError(
HttpStatus.INTERNAL_SERVER_ERROR,
`ไม่สามารถลบข้อมูลได้: ${error?.message || 'Unknown error'}`
);
} finally {
await queryRunner.release();
}
}
สรุปสถิติ
| ระดับความรุนแรง | จำนวน | รายการ |
|---|---|---|
| 🔴 CRITICAL | 3 | 1, 2, 3 |
| 🟡 HIGH | 1 | 4 |
| 🟡 MEDIUM | 2 | 5, 6 |
| 🟢 LOW | 1 | 7 |
คำแนะนำการจัดลำดับการแก้ไข
แก้ไขทันที (P0 - Critical)
-
ProfileSalaryTempController.ts -
changeSortEditGenAll()method- เพิ่ม pagination
- เพิ่ม transaction management
- เพิ่ม error handling per iteration
-
ScriptProfileOrgController.ts - External API calls
- เพิ่ม retry logic
- เพิ่ม circuit breaker
- แก้ race condition ใน idempotency check
-
ScriptProfileOrgController.ts - Distributed locking
- ใช้ Redis หรือ database lock แทน instance variable
- เพิ่ม auto-release mechanism
แก้ไขเร็วๆ นี้ (P1 - High)
- WorkflowController.ts - Transaction management
- เพิ่ม transaction สำหรับ workflow creation
- เพิ่ม cleanup บน partial failure
แก้ไขในภายหลัง (P2 - Medium)
-
ProfileSalaryTempController.ts - SQL Injection prevention
- ใช้ parameterized query
-
WorkflowController.ts - Fix switch case
- เพิ่ม break statements
แก้ไขเมื่อว่าง (P3 - Low)
- ProfileTrainingController.ts - Input validation
- เพิ่ม validation และ error handling
ข้อเสนอแนะเพิ่มเติม
- Redis/Distributed Lock - สำหรับ cronjobs ใน containerized environment
- Circuit Breaker Pattern - สำหรับ external API calls
- Retry with Exponential Backoff - สำหรับ operations ที่อาจล้มชั่วคราว
- Structured Logging - เพิ่ม logging ที่มีโครงสร้างเพื่อ debugging และ monitoring
- Health Check Endpoints - สำหรับตรวจสอบสถานะของ service และ dependencies
- Graceful Shutdown - ให้แน่ใจว่า long-running operations สามารถ handle shutdown ได้
ไฟล์รายงานที่เกี่ยวข้อง
- Batch 1-10 Analysis - รายงานการตรวจสอบ Controllers ชุดก่อนหน้า
- Security Audit Report - รายงานการตรวจสอบด้านความปลอดภัย