fix:E-Sign 알림톡 certKey 버그 수정 및 발송 상태 추적 기능 추가

- 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 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-14 17:01:14 +09:00
parent bea7bd5987
commit a7aa2e2cd2
4 changed files with 242 additions and 37 deletions

View File

@@ -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()];
}
}

View File

@@ -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()];
}
}