From a7aa2e2cd2009c297792e58bdf5ffa274306d0ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Sat, 14 Feb 2026 17:01:14 +0900 Subject: [PATCH] =?UTF-8?q?fix:E-Sign=20=EC=95=8C=EB=A6=BC=ED=86=A1=20cert?= =?UTF-8?q?Key=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B0=9C=EC=86=A1=20=EC=83=81=ED=83=9C=20=EC=B6=94=EC=A0=81=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sendATKakaotalkEx() 호출 시 존재하지 않는 certKey 파라미터 제거 (TypeError 버그) - sendAlimtalk/dispatchNotification 결과 반환 (void → array) - send/remind 응답에 notification_results 포함 - 감사 로그 metadata에 서명자별 알림 발송 결과 저장 - EsignPublicController 다음 서명자/완료 알림에도 동일 수정 적용 - detail.blade.php: 발송 방식 배지, 서명자 연락처, 알림 오류 배너, 활동 로그 발송 결과 표시 - send.blade.php: 발송 후 알림 실패 시 경고 메시지 표시 Co-Authored-By: Claude Opus 4.6 --- .../Controllers/ESign/EsignApiController.php | 105 +++++++++++++++--- .../ESign/EsignPublicController.php | 72 +++++++++--- resources/views/esign/detail.blade.php | 86 ++++++++++++-- resources/views/esign/send.blade.php | 16 ++- 4 files changed, 242 insertions(+), 37 deletions(-) diff --git a/app/Http/Controllers/ESign/EsignApiController.php b/app/Http/Controllers/ESign/EsignApiController.php index dbef0bce..59bf0da6 100644 --- a/app/Http/Controllers/ESign/EsignApiController.php +++ b/app/Http/Controllers/ESign/EsignApiController.php @@ -712,9 +712,15 @@ public function send(Request $request, int $id): JsonResponse $targetSigners = $first ? collect([$first]) : collect(); } + $notificationResults = []; foreach ($targetSigners as $signer) { $signer->update(['status' => 'notified']); - $this->dispatchNotification($contract, $signer, $sendMethod, $smsFallback); + $results = $this->dispatchNotification($contract, $signer, $sendMethod, $smsFallback); + $notificationResults[] = [ + 'signer_id' => $signer->id, + 'signer_name' => $signer->name, + 'results' => $results, + ]; } EsignAuditLog::create([ @@ -723,11 +729,34 @@ public function send(Request $request, int $id): JsonResponse 'action' => 'sign_request_sent', 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent(), - 'metadata' => ['sent_by' => auth()->id(), 'send_method' => $sendMethod], + 'metadata' => [ + 'sent_by' => auth()->id(), + 'send_method' => $sendMethod, + 'notification_results' => $notificationResults, + ], 'created_at' => now(), ]); - return response()->json(['success' => true, 'message' => '서명 요청이 발송되었습니다.']); + // 실패한 알림 확인 + $failures = []; + foreach ($notificationResults as $nr) { + foreach ($nr['results'] as $r) { + if (!$r['success']) { + $failures[] = "{$nr['signer_name']}: {$r['channel']} 실패 ({$r['error']})"; + } + } + } + + $message = '서명 요청이 발송되었습니다.'; + if (!empty($failures)) { + $message .= ' (일부 알림 실패: ' . implode(', ', $failures) . ')'; + } + + return response()->json([ + 'success' => true, + 'message' => $message, + 'notification_results' => $notificationResults, + ]); } /** @@ -748,14 +777,20 @@ public function remind(Request $request, int $id): JsonResponse ->orderBy('sign_order') ->first(); + $notificationResults = []; if ($nextSigner) { $nextSigner->update(['status' => 'notified']); - $this->dispatchNotification( + $results = $this->dispatchNotification( $contract, $nextSigner, $contract->send_method ?? 'alimtalk', $contract->sms_fallback ?? true, isReminder: true, ); + $notificationResults[] = [ + 'signer_id' => $nextSigner->id, + 'signer_name' => $nextSigner->name, + 'results' => $results, + ]; } EsignAuditLog::create([ @@ -767,15 +802,32 @@ public function remind(Request $request, int $id): JsonResponse 'metadata' => [ 'reminded_by' => auth()->id(), 'target_signer_id' => $nextSigner?->id, + 'notification_results' => $notificationResults, ], 'created_at' => now(), ]); + // 실패 확인 + $failures = []; + foreach ($notificationResults as $nr) { + foreach ($nr['results'] as $r) { + if (!$r['success']) { + $failures[] = "{$r['channel']} 실패 ({$r['error']})"; + } + } + } + + $message = $nextSigner + ? "{$nextSigner->name}에게 리마인더가 발송되었습니다." + : '리마인더가 기록되었습니다.'; + if (!empty($failures)) { + $message .= ' (일부 알림 실패: ' . implode(', ', $failures) . ')'; + } + return response()->json([ 'success' => true, - 'message' => $nextSigner - ? "{$nextSigner->name}에게 리마인더가 발송되었습니다." - : '리마인더가 기록되었습니다.', + 'message' => $message, + 'notification_results' => $notificationResults, ]); } @@ -788,18 +840,32 @@ private function dispatchNotification( string $sendMethod, bool $smsFallback, bool $isReminder = false, - ): void { + ): array { + $results = []; + // 알림톡 발송 if (in_array($sendMethod, ['alimtalk', 'both']) && $signer->phone) { - $this->sendAlimtalk($contract, $signer, $smsFallback, $isReminder); + $results[] = $this->sendAlimtalk($contract, $signer, $smsFallback, $isReminder); } // 이메일 발송 (email/both 선택 시, 또는 알림톡인데 번호 없으면 폴백) if (in_array($sendMethod, ['email', 'both']) || ($sendMethod === 'alimtalk' && !$signer->phone)) { if ($signer->email) { - Mail::to($signer->email)->send(new EsignRequestMail($contract, $signer, $isReminder)); + try { + Mail::to($signer->email)->send(new EsignRequestMail($contract, $signer, $isReminder)); + $results[] = ['success' => true, 'channel' => 'email', 'error' => null]; + } catch (\Throwable $e) { + \Log::warning('E-Sign 이메일 발송 실패', [ + 'contract_id' => $contract->id, + 'signer_id' => $signer->id, + 'error' => $e->getMessage(), + ]); + $results[] = ['success' => false, 'channel' => 'email', 'error' => $e->getMessage()]; + } } } + + return $results; } /** @@ -810,11 +876,11 @@ private function sendAlimtalk( EsignSigner $signer, bool $smsFallback = true, bool $isReminder = false, - ): void { + ): array { try { $member = BarobillMember::where('tenant_id', $contract->tenant_id)->first(); if (!$member) { - return; + return ['success' => false, 'channel' => 'alimtalk', 'error' => '바로빌 회원 미등록']; } $barobill = app(BarobillService::class); @@ -833,9 +899,8 @@ private function sendAlimtalk( ? "[SAM] {$signer->name}님, 전자계약 서명 요청이 도착했습니다. {$signUrl}" : ''; - $barobill->sendATKakaotalkEx( + $result = $barobill->sendATKakaotalkEx( corpNum: $member->corp_num, - certKey: $member->cert_key, senderId: $member->kakaotalk_sender_id ?? '', templateName: $templateName, receiverName: $signer->name, @@ -852,12 +917,24 @@ private function sendAlimtalk( ], smsMessage: $smsMessage, ); + + if (!($result['success'] ?? false)) { + \Log::warning('E-Sign 알림톡 발송 실패', [ + 'contract_id' => $contract->id, + 'signer_id' => $signer->id, + 'error' => $result['error'] ?? 'Unknown error', + ]); + return ['success' => false, 'channel' => 'alimtalk', 'error' => $result['error'] ?? 'API 호출 실패']; + } + + return ['success' => true, 'channel' => 'alimtalk', 'error' => null]; } catch (\Throwable $e) { \Log::warning('E-Sign 알림톡 발송 실패', [ 'contract_id' => $contract->id, 'signer_id' => $signer->id, 'error' => $e->getMessage(), ]); + return ['success' => false, 'channel' => 'alimtalk', 'error' => $e->getMessage()]; } } diff --git a/app/Http/Controllers/ESign/EsignPublicController.php b/app/Http/Controllers/ESign/EsignPublicController.php index c3fc6576..8945612f 100644 --- a/app/Http/Controllers/ESign/EsignPublicController.php +++ b/app/Http/Controllers/ESign/EsignPublicController.php @@ -310,19 +310,25 @@ public function submitSignature(Request $request, string $token): JsonResponse // 모든 서명자에게 완료 알림 발송 $sendMethod = $contract->send_method ?? 'alimtalk'; foreach ($allSigners as $completedSigner) { + $completionResults = []; try { // 이메일 발송 if (in_array($sendMethod, ['email', 'both']) || !$completedSigner->phone) { if ($completedSigner->email) { - Mail::to($completedSigner->email)->send( - new EsignCompletedMail($contract, $completedSigner, $allSigners) - ); + try { + Mail::to($completedSigner->email)->send( + new EsignCompletedMail($contract, $completedSigner, $allSigners) + ); + $completionResults[] = ['success' => true, 'channel' => 'email', 'error' => null]; + } catch (\Throwable $e) { + $completionResults[] = ['success' => false, 'channel' => 'email', 'error' => $e->getMessage()]; + } } } // 알림톡 발송 if (in_array($sendMethod, ['alimtalk', 'both']) && $completedSigner->phone) { - $this->sendCompletionAlimtalk($contract, $completedSigner); + $completionResults[] = $this->sendCompletionAlimtalk($contract, $completedSigner); } EsignAuditLog::create([ @@ -332,7 +338,14 @@ public function submitSignature(Request $request, string $token): JsonResponse 'action' => 'completion_notification_sent', 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent(), - 'metadata' => ['send_method' => $sendMethod], + 'metadata' => [ + 'send_method' => $sendMethod, + 'notification_results' => [[ + 'signer_id' => $completedSigner->id, + 'signer_name' => $completedSigner->name, + 'results' => $completionResults, + ]], + ], 'created_at' => now(), ]); } catch (\Throwable $e) { @@ -358,6 +371,8 @@ public function submitSignature(Request $request, string $token): JsonResponse $nextSendMethod = $contract->send_method ?? 'alimtalk'; $nextSmsFallback = $contract->sms_fallback ?? true; + $notificationResults = []; + // 알림톡 발송 if (in_array($nextSendMethod, ['alimtalk', 'both']) && $nextSigner->phone) { try { @@ -367,9 +382,8 @@ public function submitSignature(Request $request, string $token): JsonResponse $barobill->setServerMode($member->is_test_mode ? 'test' : 'production'); $nextSignUrl = config('app.url') . '/esign/sign/' . $nextSigner->access_token; $nextExpires = $contract->expires_at?->format('Y-m-d H:i') ?? '없음'; - $barobill->sendATKakaotalkEx( + $atResult = $barobill->sendATKakaotalkEx( corpNum: $member->corp_num, - certKey: $member->cert_key, senderId: $member->kakaotalk_sender_id ?? '', templateName: '전자계약_서명요청', receiverName: $nextSigner->name, @@ -379,16 +393,30 @@ public function submitSignature(Request $request, string $token): JsonResponse buttons: [['Name' => '계약서 확인하기', 'ButtonType' => 'WL', 'Url1' => $nextSignUrl, 'Url2' => $nextSignUrl]], smsMessage: $nextSmsFallback ? "[SAM] {$nextSigner->name}님, 전자계약 서명 요청이 도착했습니다. {$nextSignUrl}" : '', ); + $notificationResults[] = [ + 'success' => $atResult['success'] ?? false, + 'channel' => 'alimtalk', + 'error' => $atResult['error'] ?? null, + ]; + } else { + $notificationResults[] = ['success' => false, 'channel' => 'alimtalk', 'error' => '바로빌 회원 미등록']; } } catch (\Throwable $e) { Log::warning('다음 서명자 알림톡 발송 실패', ['error' => $e->getMessage()]); + $notificationResults[] = ['success' => false, 'channel' => 'alimtalk', 'error' => $e->getMessage()]; } } // 이메일 발송 if (in_array($nextSendMethod, ['email', 'both']) || ($nextSendMethod === 'alimtalk' && !$nextSigner->phone)) { if ($nextSigner->email) { - Mail::to($nextSigner->email)->send(new EsignRequestMail($contract, $nextSigner)); + try { + Mail::to($nextSigner->email)->send(new EsignRequestMail($contract, $nextSigner)); + $notificationResults[] = ['success' => true, 'channel' => 'email', 'error' => null]; + } catch (\Throwable $e) { + Log::warning('다음 서명자 이메일 발송 실패', ['error' => $e->getMessage()]); + $notificationResults[] = ['success' => false, 'channel' => 'email', 'error' => $e->getMessage()]; + } } } @@ -399,7 +427,14 @@ public function submitSignature(Request $request, string $token): JsonResponse 'action' => 'sign_request_sent', 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent(), - 'metadata' => ['triggered_by' => 'auto_after_sign'], + 'metadata' => [ + 'triggered_by' => 'auto_after_sign', + 'notification_results' => [[ + 'signer_id' => $nextSigner->id, + 'signer_name' => $nextSigner->name, + 'results' => $notificationResults, + ]], + ], 'created_at' => now(), ]); } @@ -484,12 +519,12 @@ public function downloadDocument(string $token): StreamedResponse|JsonResponse // ─── Private ─── - private function sendCompletionAlimtalk(EsignContract $contract, EsignSigner $signer): void + private function sendCompletionAlimtalk(EsignContract $contract, EsignSigner $signer): array { try { $member = BarobillMember::where('tenant_id', $contract->tenant_id)->first(); if (!$member) { - return; + return ['success' => false, 'channel' => 'alimtalk', 'error' => '바로빌 회원 미등록']; } $barobill = app(BarobillService::class); @@ -498,9 +533,8 @@ private function sendCompletionAlimtalk(EsignContract $contract, EsignSigner $si $signUrl = config('app.url') . '/esign/sign/' . $signer->access_token; $completedAt = $contract->completed_at?->format('Y-m-d H:i') ?? now()->format('Y-m-d H:i'); - $barobill->sendATKakaotalkEx( + $result = $barobill->sendATKakaotalkEx( corpNum: $member->corp_num, - certKey: $member->cert_key, senderId: $member->kakaotalk_sender_id ?? '', templateName: '전자계약_완료', receiverName: $signer->name, @@ -519,12 +553,24 @@ private function sendCompletionAlimtalk(EsignContract $contract, EsignSigner $si ? "[SAM] {$signer->name}님, 전자계약이 완료되었습니다. {$signUrl}" : '', ); + + if (!($result['success'] ?? false)) { + Log::warning('E-Sign 완료 알림톡 발송 실패', [ + 'contract_id' => $contract->id, + 'signer_id' => $signer->id, + 'error' => $result['error'] ?? 'Unknown error', + ]); + return ['success' => false, 'channel' => 'alimtalk', 'error' => $result['error'] ?? 'API 호출 실패']; + } + + return ['success' => true, 'channel' => 'alimtalk', 'error' => null]; } catch (\Throwable $e) { Log::warning('E-Sign 완료 알림톡 발송 실패', [ 'contract_id' => $contract->id, 'signer_id' => $signer->id, 'error' => $e->getMessage(), ]); + return ['success' => false, 'channel' => 'alimtalk', 'error' => $e->getMessage()]; } } diff --git a/resources/views/esign/detail.blade.php b/resources/views/esign/detail.blade.php index a605484a..cca161ba 100644 --- a/resources/views/esign/detail.blade.php +++ b/resources/views/esign/detail.blade.php @@ -51,10 +51,19 @@ }; const ACTION_MAP = { - created: '계약 생성', sent: '서명 요청 발송', viewed: '문서 열람', + contract_created: '계약 생성', sign_request_sent: '서명 요청 발송', viewed: '문서 열람', otp_sent: 'OTP 발송', authenticated: '본인인증 완료', signed: '서명 완료', - rejected: '서명 거절', completed: '계약 완료', cancelled: '계약 취소', reminded: '리마인더 발송', - downloaded: '문서 다운로드', + rejected: '서명 거절', contract_completed: '계약 완료', contract_cancelled: '계약 취소', + reminded: '리마인더 발송', downloaded: '문서 다운로드', + completion_notification_sent: '완료 알림 발송', + // legacy keys + created: '계약 생성', sent: '서명 요청 발송', completed: '계약 완료', cancelled: '계약 취소', +}; + +const SEND_METHOD_MAP = { + alimtalk: { label: '알림톡', color: 'bg-yellow-100 text-yellow-700' }, + email: { label: '이메일', color: 'bg-blue-100 text-blue-700' }, + both: { label: '알림톡+이메일', color: 'bg-purple-100 text-purple-700' }, }; const StatusBadge = ({ status }) => { @@ -97,6 +106,22 @@ const c = contract; + // 마지막 발송 로그에서 알림 실패 확인 + const lastSendLog = (c.audit_logs || []).find(l => + ['sign_request_sent', 'reminded', 'completion_notification_sent'].includes(l.action) + && l.metadata?.notification_results + ); + const notifFailures = []; + if (lastSendLog?.metadata?.notification_results) { + for (const nr of lastSendLog.metadata.notification_results) { + for (const r of (nr.results || [])) { + if (!r.success) { + notifFailures.push({ name: nr.signer_name, channel: r.channel, error: r.error }); + } + } + } + } + return (
@@ -114,6 +139,18 @@ className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hov
+ {/* 알림 오류 배너 */} + {notifFailures.length > 0 && ( +
+

알림 발송 실패

+
    + {notifFailures.map((f, i) => ( +
  • {f.name}: {f.channel} 실패 - {f.error}
  • + ))} +
+
+ )} +
{/* 왼쪽: 계약 정보 */}
@@ -125,6 +162,17 @@ className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hov
파일 크기
{c.original_file_size ? `${(c.original_file_size / 1024 / 1024).toFixed(2)} MB` : '-'}
생성일
{fmtDate(c.created_at, false)}
만료일
{fmtDate(c.expires_at, false) || '-'}
+ {c.send_method && ( +
+
발송 방식
+
+ {(() => { + const sm = SEND_METHOD_MAP[c.send_method] || { label: c.send_method, color: 'bg-gray-100 text-gray-700' }; + return {sm.label}; + })()} +
+
+ )} {c.description &&
설명
{c.description}
}
@@ -141,7 +189,10 @@ className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hov {s.sign_order || i+1}

{s.name} ({s.role === 'creator' ? '작성자' : '상대방'})

-

{s.email}

+
+ {s.phone && 📱 {s.phone}} + {s.email && ✉ {s.email}} +
@@ -157,14 +208,31 @@ className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hov {/* 감사 로그 */}

활동 로그

-
+
{(c.audit_logs || []).length === 0 ? (

활동 로그가 없습니다.

) : (c.audit_logs || []).map(log => ( -
- {fmtDate(log.created_at)} - {ACTION_MAP[log.action] || log.action} - {log.signer && - {log.signer.name}} +
+
+ {fmtDate(log.created_at)} + {ACTION_MAP[log.action] || log.action} + {log.signer && - {log.signer.name}} +
+ {/* 알림 발송 결과 표시 */} + {log.metadata?.notification_results && ( +
+ {log.metadata.notification_results.map((nr, ni) => ( +
+ {(nr.results || []).map((r, ri) => ( + + {r.success ? '✓' : '✗'} {nr.signer_name}: {r.channel === 'alimtalk' ? '알림톡' : '이메일'} + {!r.success && r.error && ({r.error})} + + ))} +
+ ))} +
+ )}
))}
diff --git a/resources/views/esign/send.blade.php b/resources/views/esign/send.blade.php index c3f8e426..5269a2a7 100644 --- a/resources/views/esign/send.blade.php +++ b/resources/views/esign/send.blade.php @@ -57,7 +57,21 @@ }); const json = await res.json(); if (json.success) { - alert('서명 요청이 발송되었습니다.'); + // 알림 실패 여부 확인 + const results = json.notification_results || []; + const failures = []; + for (const nr of results) { + for (const r of (nr.results || [])) { + if (!r.success) { + failures.push(`${nr.signer_name}: ${r.channel === 'alimtalk' ? '알림톡' : '이메일'} 실패 (${r.error})`); + } + } + } + if (failures.length > 0) { + alert(`서명 요청이 발송되었으나 일부 알림이 실패했습니다:\n\n${failures.join('\n')}\n\n상세 페이지에서 확인해 주세요.`); + } else { + alert('서명 요청이 발송되었습니다.'); + } location.href = `/esign/${CONTRACT_ID}`; } else { alert(json.message || '발송에 실패했습니다.');