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

View File

@@ -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 (
<div className="p-4 sm:p-6">
<div className="flex items-center gap-3 mb-6">
@@ -114,6 +139,18 @@ className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hov
<StatusBadge status={c.status} />
</div>
{/* 알림 오류 배너 */}
{notifFailures.length > 0 && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-sm font-medium text-red-700 mb-1">알림 발송 실패</p>
<ul className="text-xs text-red-600 space-y-0.5">
{notifFailures.map((f, i) => (
<li key={i}>{f.name}: {f.channel} 실패 - {f.error}</li>
))}
</ul>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 왼쪽: 계약 정보 */}
<div className="lg:col-span-2 space-y-6">
@@ -125,6 +162,17 @@ className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hov
<div><dt className="text-gray-500">파일 크기</dt><dd>{c.original_file_size ? `${(c.original_file_size / 1024 / 1024).toFixed(2)} MB` : '-'}</dd></div>
<div><dt className="text-gray-500">생성일</dt><dd>{fmtDate(c.created_at, false)}</dd></div>
<div><dt className="text-gray-500">만료일</dt><dd>{fmtDate(c.expires_at, false) || '-'}</dd></div>
{c.send_method && (
<div>
<dt className="text-gray-500">발송 방식</dt>
<dd>
{(() => {
const sm = SEND_METHOD_MAP[c.send_method] || { label: c.send_method, color: 'bg-gray-100 text-gray-700' };
return <span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${sm.color}`}>{sm.label}</span>;
})()}
</dd>
</div>
)}
{c.description && <div className="col-span-2"><dt className="text-gray-500">설명</dt><dd>{c.description}</dd></div>}
</dl>
</div>
@@ -141,7 +189,10 @@ className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hov
<span className="w-8 h-8 rounded-full bg-blue-100 text-blue-700 flex items-center justify-center text-sm font-bold">{s.sign_order || i+1}</span>
<div>
<p className="font-medium text-sm">{s.name} <span className="text-xs text-gray-400">({s.role === 'creator' ? '작성자' : '상대방'})</span></p>
<p className="text-xs text-gray-500">{s.email}</p>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5">
{s.phone && <span className="text-xs text-gray-500" title="휴대폰">📱 {s.phone}</span>}
{s.email && <span className="text-xs text-gray-500" title="이메일"> {s.email}</span>}
</div>
</div>
</div>
<div className="text-right">
@@ -157,14 +208,31 @@ className="w-8 h-8 flex items-center justify-center rounded-lg text-gray-400 hov
{/* 감사 로그 */}
<div className="bg-white rounded-lg border p-6">
<h2 className="text-lg font-semibold mb-4">활동 로그</h2>
<div className="space-y-2 max-h-64 overflow-y-auto">
<div className="space-y-2 max-h-80 overflow-y-auto">
{(c.audit_logs || []).length === 0 ? (
<p className="text-sm text-gray-400">활동 로그가 없습니다.</p>
) : (c.audit_logs || []).map(log => (
<div key={log.id} className="flex items-start gap-3 text-sm py-2 border-b last:border-0">
<span className="text-xs text-gray-400 whitespace-nowrap">{fmtDate(log.created_at)}</span>
<span className="font-medium">{ACTION_MAP[log.action] || log.action}</span>
{log.signer && <span className="text-gray-500">- {log.signer.name}</span>}
<div key={log.id} className="py-2 border-b last:border-0">
<div className="flex items-start gap-3 text-sm">
<span className="text-xs text-gray-400 whitespace-nowrap">{fmtDate(log.created_at)}</span>
<span className="font-medium">{ACTION_MAP[log.action] || log.action}</span>
{log.signer && <span className="text-gray-500">- {log.signer.name}</span>}
</div>
{/* 알림 발송 결과 표시 */}
{log.metadata?.notification_results && (
<div className="ml-20 mt-1 space-y-0.5">
{log.metadata.notification_results.map((nr, ni) => (
<div key={ni} className="text-xs">
{(nr.results || []).map((r, ri) => (
<span key={ri} className={`inline-flex items-center gap-1 mr-2 ${r.success ? 'text-green-600' : 'text-red-500'}`}>
{r.success ? '✓' : '✗'} {nr.signer_name}: {r.channel === 'alimtalk' ? '알림톡' : '이메일'}
{!r.success && r.error && <span className="text-red-400">({r.error})</span>}
</span>
))}
</div>
))}
</div>
)}
</div>
))}
</div>

View File

@@ -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 || '발송에 실패했습니다.');