diff options
| author | joonhoekim <26rote@gmail.com> | 2025-11-05 10:57:01 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-11-05 10:57:01 +0900 |
| commit | 2e1a87c11c4ed65588342bcd66c3acf9a24f90f7 (patch) | |
| tree | c25aaf9f3ebab94028c4fb8155fe5c036a033624 /lib/approval/approval-workflow.ts | |
| parent | a6ad5fcfb65772b9ae240d9fa02bf9ed1a9160c9 (diff) | |
(김준회) 결재 트랜잭션 개선 (Saga Pattern 도입) 및 문서 작성
Diffstat (limited to 'lib/approval/approval-workflow.ts')
| -rw-r--r-- | lib/approval/approval-workflow.ts | 188 |
1 files changed, 118 insertions, 70 deletions
diff --git a/lib/approval/approval-workflow.ts b/lib/approval/approval-workflow.ts index cc8914f9..ed20e972 100644 --- a/lib/approval/approval-workflow.ts +++ b/lib/approval/approval-workflow.ts @@ -82,78 +82,87 @@ export async function withApproval<T>( throw new Error(`No handler registered for action type: ${actionType}`); } - try { - // 1. 템플릿 조회 및 변수 치환 - const { getApprovalTemplateByName, replaceTemplateVariables } = await import('./template-utils'); - const template = await getApprovalTemplateByName(approvalConfig.templateName); - - let content: string; - if (!template) { - console.warn(`[Approval Workflow] Template not found: ${approvalConfig.templateName}`); - // 템플릿이 없으면 기본 내용 사용 - content = approvalConfig.description || '결재 요청'; - } else { - // 템플릿 변수 치환 - content = await replaceTemplateVariables(template.content, approvalConfig.variables); - } + // 1. 템플릿 조회 및 변수 치환 + const { getApprovalTemplateByName, replaceTemplateVariables } = await import('./template-utils'); + const template = await getApprovalTemplateByName(approvalConfig.templateName); + + let content: string; + if (!template) { + console.warn(`[Approval Workflow] Template not found: ${approvalConfig.templateName}`); + // 템플릿이 없으면 기본 내용 사용 + content = approvalConfig.description || '결재 요청'; + } else { + // 템플릿 변수 치환 + content = await replaceTemplateVariables(template.content, approvalConfig.variables); + } - // 2. Knox 결재 상신 (apInfId 생성, 치환된 content 사용) - const { - submitApproval, - createSubmitApprovalRequest, - createApprovalLine - } = await import('@/lib/knox-api/approval/approval'); - - // 결재선 생성 - const aplns: ApprovalLine[] = []; - - // 기안자 (현재 사용자) - if (approvalConfig.currentUser.epId) { - const drafterLine = await createApprovalLine( - { - epId: approvalConfig.currentUser.epId, - emailAddress: approvalConfig.currentUser.email, - }, - '0', // 기안 - '0' // seq + // 2. Knox 결재 상신 준비 (apInfId 생성, 치환된 content 사용) + const { + submitApproval, + createSubmitApprovalRequest, + createApprovalLine + } = await import('@/lib/knox-api/approval/approval'); + + // 결재선 생성 + const aplns: ApprovalLine[] = []; + + // 기안자 (현재 사용자) + if (approvalConfig.currentUser.epId) { + const drafterLine = await createApprovalLine( + { + epId: approvalConfig.currentUser.epId, + emailAddress: approvalConfig.currentUser.email, + }, + '0', // 기안 + '0' // seq + ); + aplns.push(drafterLine); + } + + // 결재자들 + if (approvalConfig.approvers && approvalConfig.approvers.length > 0) { + for (let i = 0; i < approvalConfig.approvers.length; i++) { + const approverLine = await createApprovalLine( + { epId: approvalConfig.approvers[i] }, + '1', // 승인 + String(i + 1) ); - aplns.push(drafterLine); + aplns.push(approverLine); } - - // 결재자들 - if (approvalConfig.approvers && approvalConfig.approvers.length > 0) { - for (let i = 0; i < approvalConfig.approvers.length; i++) { - const approverLine = await createApprovalLine( - { epId: approvalConfig.approvers[i] }, - '1', // 승인 - String(i + 1) - ); - aplns.push(approverLine); - } + } + + // 결재 요청 생성 + const submitRequest = await createSubmitApprovalRequest( + content, // 치환된 템플릿 content + approvalConfig.title, + aplns, + { + contentsType: 'HTML', // HTML 템플릿 사용 } - - // 결재 요청 생성 - const submitRequest = await createSubmitApprovalRequest( - content, // 치환된 템플릿 content - approvalConfig.title, - aplns, - { - contentsType: 'HTML', // HTML 템플릿 사용 - } - ); - - // Knox 결재 상신 - await submitApproval( - submitRequest, - { - userId: String(approvalConfig.currentUser.id), - epId: approvalConfig.currentUser.epId || '', - emailAddress: approvalConfig.currentUser.email || '', - } - ); + ); - // 3. Pending Action 생성 (approvalLog의 apInfId로 연결) - // Knox에서 apInfId를 반환하지 않고, 요청 시 생성한 apInfId를 사용 + /** + * 트랜잭션 관리 전략 (Saga Pattern): + * + * Knox는 외부 시스템이므로 DB 트랜잭션으로 롤백할 수 없습니다. + * 따라서 다음 순서로 처리하여 데이터 정합성을 보장합니다: + * + * 1. DB에 먼저 pendingAction 생성 (status: 'pending') + * 2. Knox 결재 상신 시도 + * 3-A. Knox 상신 성공 → pendingAction 그대로 유지 + * 3-B. Knox 상신 실패 → pendingAction status를 'failed'로 업데이트 (보상 트랜잭션) + * + * 이렇게 하면: + * - Knox 상신 전에 DB 저장 실패 → 전체 실패 (정상) + * - Knox 상신 실패 → DB에 실패 기록 남음 (추적 가능) + * - Knox 상신 성공 후 DB 업데이트 실패 위험 제거 + */ + + let pendingActionId: number | undefined; + + try { + // 3. Pending Action 먼저 생성 (Knox 상신 전) + console.log(`[Approval Workflow] Creating pending action for ${actionType}`); const [pendingAction] = await db.insert(pendingActions).values({ apInfId: submitRequest.apInfId, actionType, @@ -162,15 +171,54 @@ export async function withApproval<T>( status: 'pending', createdBy: approvalConfig.currentUser.id, }).returning(); + + pendingActionId = pendingAction.id; + console.log(`[Approval Workflow] Pending action created: ${pendingActionId}`); + + // 4. Knox 결재 상신 시도 + console.log(`[Approval Workflow] Submitting to Knox: ${submitRequest.apInfId}`); + await submitApproval( + submitRequest, + { + userId: String(approvalConfig.currentUser.id), + epId: approvalConfig.currentUser.epId || '', + emailAddress: approvalConfig.currentUser.email || '', + } + ); + console.log(`[Approval Workflow] ✅ Knox submission successful: ${submitRequest.apInfId}`); + // 5. 성공 시 정상 반환 return { - pendingActionId: pendingAction.id, + pendingActionId, approvalId: submitRequest.apInfId, - status: 'pending_approval', + status: 'pending_approval' as const, }; } catch (error) { - console.error('Failed to create approval workflow:', error); + console.error(`[Approval Workflow] ❌ Error during approval workflow:`, error); + + // Knox 상신 실패 시 보상 트랜잭션 (Compensating Transaction) + // pendingAction의 상태를 'failed'로 업데이트 + if (typeof pendingActionId === 'number') { + try { + console.log(`[Approval Workflow] Marking pending action as failed: ${pendingActionId}`); + await db.update(pendingActions) + .set({ + status: 'failed', + errorMessage: error instanceof Error + ? `Knox 상신 실패: ${error.message}` + : 'Knox 상신 실패', + executedAt: new Date(), + }) + .where(eq(pendingActions.id, pendingActionId)); + + console.log(`[Approval Workflow] Pending action marked as failed: ${pendingActionId}`); + } catch (updateError) { + console.error(`[Approval Workflow] Failed to update pending action status:`, updateError); + // 상태 업데이트 실패는 로그만 남기고 원래 에러를 throw + } + } + throw error; } } |
