input('phone', '')); if (strlen($phone) < 10) { return back()->withErrors(['phone' => '올바른 전화번호를 입력해 주세요.']); } $signer = EsignSigner::withoutGlobalScopes() ->where('phone', $phone) ->whereIn('status', ['pending', 'notified']) ->whereHas('contract', fn ($q) => $q->where('status', 'pending')) ->latest('created_at') ->first(); if (! $signer) { return back()->withErrors(['phone' => '대기 중인 전자계약이 없습니다.']); } return redirect("/esign/sign/{$signer->access_token}"); } // ─── 화면 라우트 ─── public function auth(string $token): View { return view('esign.sign.auth', ['token' => $token]); } public function sign(string $token): View { return view('esign.sign.sign', ['token' => $token]); } public function done(string $token): View { return view('esign.sign.done', ['token' => $token]); } // ─── 서명 API (토큰 기반, 비인증) ─── /** * 토큰으로 계약/서명자 정보 조회 */ public function getContract(string $token): JsonResponse { $signer = EsignSigner::withoutGlobalScopes() ->where('access_token', $token) ->first(); if (! $signer) { return response()->json(['success' => false, 'message' => '유효하지 않은 서명 링크입니다.'], 404); } if ($signer->token_expires_at && $signer->token_expires_at->isPast()) { return response()->json(['success' => false, 'message' => '서명 링크가 만료되었습니다.'], 400); } $contract = EsignContract::withoutGlobalScopes() ->with(['signers:id,contract_id,name,role,status,signed_at']) ->find($signer->contract_id); if (! $contract) { return response()->json(['success' => false, 'message' => '계약을 찾을 수 없습니다.'], 404); } // 서명 가능 여부 판별 $isSignable = in_array($contract->status, ['pending', 'partially_signed']) && in_array($signer->status, ['notified', 'viewing', 'authenticated']); $statusMessage = null; $unsignableStatuses = [ 'draft' => '아직 발송되지 않은 계약입니다.', 'cancelled' => '취소된 계약입니다.', 'rejected' => '거절된 계약입니다.', ]; if (isset($unsignableStatuses[$contract->status])) { $statusMessage = $unsignableStatuses[$contract->status]; } elseif ($signer->status === 'signed') { $statusMessage = '이미 서명을 완료하였습니다.'; } // 서명 가능한 상태에서만 감사 로그 기록 if ($isSignable) { EsignAuditLog::create([ 'tenant_id' => $contract->tenant_id, 'contract_id' => $contract->id, 'signer_id' => $signer->id, 'action' => 'viewed', 'ip_address' => request()->ip(), 'user_agent' => request()->userAgent(), 'created_at' => now(), ]); } return response()->json([ 'success' => true, 'data' => [ 'contract' => $contract, 'signer' => [ 'id' => $signer->id, 'name' => $signer->name, 'email' => $signer->email, 'phone' => $signer->phone, 'role' => $signer->role, 'status' => $signer->status, 'has_stamp' => (bool) $signer->signature_image_path || ($signer->role === 'creator' && $this->hasCompanyStamp($contract->tenant_id)), 'signed_at' => $signer->signed_at, ], 'send_method' => $contract->tenant_id == 1 ? 'email' : ($contract->send_method ?? 'email'), 'is_signable' => $isSignable, 'status_message' => $statusMessage, ], ]); } /** * OTP 발송 */ public function sendOtp(string $token): JsonResponse { $signer = $this->findSigner($token); if (! $signer) { return response()->json(['success' => false, 'message' => '유효하지 않은 서명 링크입니다.'], 404); } $contract = EsignContract::withoutGlobalScopes()->find($signer->contract_id); if (! $contract || ! in_array($contract->status, ['pending', 'partially_signed'])) { return response()->json(['success' => false, 'message' => '서명할 수 없는 상태입니다.'], 400); } $otpCode = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT); $signer->update([ 'otp_code' => $otpCode, 'otp_expires_at' => now()->addMinutes(5), 'otp_attempts' => 0, ]); $sendMethod = $contract->send_method ?? 'email'; $channel = 'email'; // 알림톡/both 방식이고 전화번호가 있으면 SMS로 발송 (상대방만 SMS, 본사(creator)는 이메일 유지) if (in_array($sendMethod, ['alimtalk', 'both']) && $signer->phone && $signer->role === EsignSigner::ROLE_COUNTERPART) { $smsSent = $this->sendOtpViaSms($contract, $signer, $otpCode); if ($smsSent) { $channel = 'sms'; } else { // SMS 실패 시 이메일 폴백 if ($signer->email) { Mail::to($signer->email)->send(new \App\Mail\EsignOtpMail($signer->name, $otpCode)); $channel = 'email'; } else { return response()->json(['success' => false, 'message' => 'OTP 발송에 실패했습니다.'], 500); } } } else { // 이메일 방식 또는 전화번호 없음 Mail::to($signer->email)->send(new \App\Mail\EsignOtpMail($signer->name, $otpCode)); $channel = 'email'; } EsignAuditLog::create([ 'tenant_id' => $contract->tenant_id, 'contract_id' => $contract->id, 'signer_id' => $signer->id, 'action' => 'otp_sent', 'ip_address' => request()->ip(), 'user_agent' => request()->userAgent(), 'metadata' => [ 'channel' => $channel, 'email' => $channel === 'email' ? $signer->email : null, 'phone' => $channel === 'sms' ? $signer->phone : null, ], 'created_at' => now(), ]); return response()->json([ 'success' => true, 'message' => '인증 코드가 발송되었습니다.', 'channel' => $channel, ]); } /** * OTP 검증 */ public function verifyOtp(Request $request, string $token): JsonResponse { $signer = $this->findSigner($token); if (! $signer) { return response()->json(['success' => false, 'message' => '유효하지 않은 서명 링크입니다.'], 404); } if ($signer->otp_attempts >= 5) { return response()->json(['success' => false, 'message' => '인증 시도 횟수를 초과했습니다.'], 400); } if (! $signer->otp_code || ! $signer->otp_expires_at) { return response()->json(['success' => false, 'message' => '인증 코드가 발송되지 않았습니다.'], 400); } if ($signer->otp_expires_at->isPast()) { return response()->json(['success' => false, 'message' => '인증 코드가 만료되었습니다.'], 400); } $signer->increment('otp_attempts'); $otpCode = $request->input('otp_code'); if ($signer->otp_code !== $otpCode) { return response()->json(['success' => false, 'message' => '인증 코드가 올바르지 않습니다.'], 400); } $contract = EsignContract::withoutGlobalScopes()->find($signer->contract_id); $signer->update([ 'auth_verified_at' => now(), 'otp_code' => null, 'otp_expires_at' => null, 'status' => 'authenticated', ]); EsignAuditLog::create([ 'tenant_id' => $contract->tenant_id, 'contract_id' => $contract->id, 'signer_id' => $signer->id, 'action' => 'authenticated', 'ip_address' => request()->ip(), 'user_agent' => request()->userAgent(), 'created_at' => now(), ]); return response()->json([ 'success' => true, 'data' => ['sign_session_token' => Str::random(64)], ]); } /** * 서명 제출 */ public function submitSignature(Request $request, string $token): JsonResponse { $signer = $this->findSigner($token); if (! $signer) { return response()->json(['success' => false, 'message' => '유효하지 않은 서명 링크입니다.'], 404); } if (! in_array($signer->status, ['authenticated', 'viewing'])) { return response()->json(['success' => false, 'message' => '서명할 수 없는 상태입니다.'], 400); } $contract = EsignContract::withoutGlobalScopes()->find($signer->contract_id); if (! $contract || ! in_array($contract->status, ['pending', 'partially_signed'])) { return response()->json(['success' => false, 'message' => '서명할 수 없는 계약 상태입니다.'], 400); } $useStamp = $request->boolean('use_stamp'); if ($useStamp) { $stampImage = $request->input('stamp_image'); if ($stampImage) { // 고객 업로드 도장: base64 디코딩 후 저장 $imageData = base64_decode($stampImage); if (strlen($imageData) > 5 * 1024 * 1024) { return response()->json(['success' => false, 'message' => '도장 이미지는 5MB 이하만 가능합니다.'], 422); } $imagePath = "esign/{$contract->tenant_id}/signatures/{$contract->id}_{$signer->id}_stamp_".time().'.png'; Storage::disk('local')->put($imagePath, $imageData); $signer->update(['signature_image_path' => $imagePath]); } elseif (! $signer->signature_image_path) { // 기존 계약: tenant_settings에서 법인도장 가져오기 if ($signer->role === 'creator') { $stampPath = $this->applyCompanyStamp($signer, $contract->tenant_id); if (! $stampPath) { return response()->json(['success' => false, 'message' => '등록된 도장 이미지가 없습니다.'], 422); } } else { return response()->json(['success' => false, 'message' => '등록된 도장 이미지가 없습니다.'], 422); } } } else { // 직접 서명: signature_image 필수 $signatureImage = $request->input('signature_image'); if (! $signatureImage) { return response()->json(['success' => false, 'message' => '서명 이미지가 필요합니다.'], 422); } // 서명 이미지 저장 $imageData = base64_decode($signatureImage); $imagePath = "esign/{$contract->tenant_id}/signatures/{$contract->id}_{$signer->id}_".time().'.png'; Storage::disk('local')->put($imagePath, $imageData); $signer->update(['signature_image_path' => $imagePath]); } // 서명자 상태 업데이트 $signer->update([ 'signed_at' => now(), 'consent_agreed_at' => now(), 'sign_ip_address' => $request->ip(), 'sign_user_agent' => $request->userAgent(), 'status' => 'signed', ]); // 감사 로그 EsignAuditLog::create([ 'tenant_id' => $contract->tenant_id, 'contract_id' => $contract->id, 'signer_id' => $signer->id, 'action' => 'signed', 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent(), 'created_at' => now(), ]); // 모든 서명자가 서명 완료했는지 확인 $allSigners = EsignSigner::withoutGlobalScopes() ->where('contract_id', $contract->id) ->get(); $allSigned = $allSigners->every(fn ($s) => $s->status === 'signed'); if ($allSigned) { $contract->update([ 'status' => 'completed', 'completed_at' => now(), ]); // PDF에 서명 이미지/텍스트 합성 try { $pdfService = new PdfSignatureService; $pdfService->mergeSignatures($contract); } catch (\Throwable $e) { Log::error('PDF 서명 합성 실패', [ 'contract_id' => $contract->id, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); } // 계약 완료 감사 로그 EsignAuditLog::create([ 'tenant_id' => $contract->tenant_id, 'contract_id' => $contract->id, 'signer_id' => $signer->id, 'action' => 'contract_completed', 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent(), 'metadata' => ['total_signers' => $allSigners->count()], 'created_at' => now(), ]); // 모든 서명자에게 완료 알림 발송 $sendMethod = $contract->send_method ?? 'alimtalk'; foreach ($allSigners as $completedSigner) { $completionResults = []; try { // 본사(creator): 이메일로 완료 알림 // 상대방(counterpart): 알림톡(카카오톡) + PDF 다운로드 링크 $isCounterpart = $completedSigner->role === EsignSigner::ROLE_COUNTERPART; // 이메일 발송 조건: // 1) email/both 선택 시 // 2) 본사(creator)는 항상 이메일 // 3) 상대방이지만 전화번호 없으면 이메일 폴백 $shouldSendEmail = in_array($sendMethod, ['email', 'both']) || ! $isCounterpart || ($isCounterpart && ! $completedSigner->phone); if ($shouldSendEmail && $completedSigner->email) { 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()]; } } // 알림톡 발송: 상대방(counterpart)에게 카카오톡으로 서명 완료 PDF 전달 if (in_array($sendMethod, ['alimtalk', 'both']) && $isCounterpart && $completedSigner->phone) { $alimtalkResult = $this->sendCompletionAlimtalk($contract, $completedSigner); $completionResults[] = $alimtalkResult; // 알림톡 실패 시 이메일 폴백 (아직 이메일 안 보낸 경우) if (! ($alimtalkResult['success'] ?? false) && ! $shouldSendEmail && $completedSigner->email) { 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()]; } } } EsignAuditLog::create([ 'tenant_id' => $contract->tenant_id, 'contract_id' => $contract->id, 'signer_id' => $completedSigner->id, 'action' => 'completion_notification_sent', 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent(), 'metadata' => [ 'send_method' => $sendMethod, 'signer_role' => $completedSigner->role, 'notification_results' => [[ 'signer_id' => $completedSigner->id, 'signer_name' => $completedSigner->name, 'results' => $completionResults, ]], ], 'created_at' => now(), ]); } catch (\Throwable $e) { Log::error('계약 완료 알림 발송 실패', [ 'contract_id' => $contract->id, 'signer_id' => $completedSigner->id, 'error' => $e->getMessage(), ]); } } } else { $contract->update(['status' => 'partially_signed']); // 다음 서명자에게 자동 알림 발송 (순차 서명) $nextSigner = EsignSigner::withoutGlobalScopes() ->where('contract_id', $contract->id) ->whereIn('status', ['waiting', 'pending']) ->orderBy('sign_order') ->first(); if ($nextSigner) { $nextSigner->update(['status' => 'notified']); $nextSendMethod = $contract->send_method ?? 'alimtalk'; $nextSmsFallback = $contract->sms_fallback ?? true; $nextIsCounterpart = $nextSigner->role === EsignSigner::ROLE_COUNTERPART; $notificationResults = []; $alimtalkFailed = false; // 알림톡 발송: 상대방(counterpart)에게만 카카오톡 발송 if (in_array($nextSendMethod, ['alimtalk', 'both']) && $nextIsCounterpart && $nextSigner->phone) { try { $member = BarobillMember::where('tenant_id', $contract->tenant_id)->first(); if ($member && $member->biz_no) { $barobill = app(BarobillService::class); $barobill->setServerMode($member->server_mode ?? 'production'); $nextSignUrl = config('app.url').'/esign/sign/'.$nextSigner->access_token; $nextExpires = $contract->expires_at?->format('Y-m-d H:i') ?? '없음'; // 채널 ID 조회 $channelId = $this->getKakaotalkChannelId($barobill, $member->biz_no); if ($channelId) { // 템플릿 본문 + 버튼 조회 $nextTemplateName = $this->resolveTemplateName('전자계약_서명요청'); $tplData = $this->getTemplateData($barobill, $member->biz_no, $channelId, $nextTemplateName); $tplMessage = $tplData['content'] ? str_replace( ['#{이름}', '#{계약명}', '#{기한}'], [$nextSigner->name, $contract->title, $nextExpires], $tplData['content'] ) : null; // 버튼: 템플릿에서 가져온 URL의 #{토큰} 치환 $buttons = ! empty($tplData['buttons']) ? $tplData['buttons'] : [ ['Name' => '계약서 확인하기', 'ButtonType' => 'WL', 'Url1' => $nextSignUrl, 'Url2' => $nextSignUrl], ]; foreach ($buttons as &$btn) { foreach (['Url1', 'Url2'] as $urlKey) { if (! empty($btn[$urlKey])) { $btn[$urlKey] = str_replace( ['#{토큰}', '#{%ED%86%A0%ED%81%B0}'], [$nextSigner->access_token, $nextSigner->access_token], urldecode($btn[$urlKey]) ); } } } unset($btn); $atResult = $barobill->sendATKakaotalkEx( corpNum: $member->biz_no, senderId: $member->barobill_id, yellowId: $channelId, templateName: $nextTemplateName, receiverName: $nextSigner->name, receiverNum: preg_replace('/[^0-9]/', '', $nextSigner->phone), title: '', message: $tplMessage ?? "안녕하세요, {$nextSigner->name}님.\n전자계약 서명 요청이 도착했습니다.\n\n■ 계약명: {$contract->title}\n■ 서명 기한: {$nextExpires}\n\n아래 버튼을 눌러 계약서를 확인하고 서명해 주세요.", buttons: $buttons, smsMessage: $nextSmsFallback ? "[SAM] {$nextSigner->name}님, 전자계약 서명 요청이 도착했습니다. {$nextSignUrl}" : '', ); $alimtalkFailed = ! ($atResult['success'] ?? false); $notificationResults[] = [ 'success' => $atResult['success'] ?? false, 'channel' => 'alimtalk', 'error' => $atResult['error'] ?? null, ]; } else { $alimtalkFailed = true; $notificationResults[] = ['success' => false, 'channel' => 'alimtalk', 'error' => '등록된 카카오톡 채널 없음']; } } else { $alimtalkFailed = true; $notificationResults[] = ['success' => false, 'channel' => 'alimtalk', 'error' => '바로빌 회원 미등록']; } } catch (\Throwable $e) { Log::warning('다음 서명자 알림톡 발송 실패', ['error' => $e->getMessage()]); $alimtalkFailed = true; $notificationResults[] = ['success' => false, 'channel' => 'alimtalk', 'error' => $e->getMessage()]; } } // 이메일 발송 조건: // 1) email/both 선택 시 // 2) 본사(creator)는 항상 이메일 // 3) 상대방이지만 전화번호 없으면 이메일 폴백 // 4) 알림톡 발송 실패 시 이메일 자동 폴백 $shouldSendEmail = in_array($nextSendMethod, ['email', 'both']) || ! $nextIsCounterpart || ($nextSendMethod === 'alimtalk' && $nextIsCounterpart && ! $nextSigner->phone) || ($nextSendMethod === 'alimtalk' && $nextIsCounterpart && $alimtalkFailed); if ($shouldSendEmail && $nextSigner->email) { 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()]; } } EsignAuditLog::create([ 'tenant_id' => $contract->tenant_id, 'contract_id' => $contract->id, 'signer_id' => $nextSigner->id, 'action' => 'sign_request_sent', 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent(), 'metadata' => [ 'triggered_by' => 'auto_after_sign', 'signer_role' => $nextSigner->role, 'notification_results' => [[ 'signer_id' => $nextSigner->id, 'signer_name' => $nextSigner->name, 'results' => $notificationResults, ]], ], 'created_at' => now(), ]); } } return response()->json(['success' => true, 'message' => '서명이 완료되었습니다.']); } /** * 계약 거절 */ public function rejectContract(Request $request, string $token): JsonResponse { $signer = $this->findSigner($token); if (! $signer) { return response()->json(['success' => false, 'message' => '유효하지 않은 서명 링크입니다.'], 404); } $reason = $request->input('reason', ''); $contract = EsignContract::withoutGlobalScopes()->find($signer->contract_id); $signer->update([ 'status' => 'rejected', 'rejected_reason' => $reason, ]); $contract->update(['status' => 'rejected']); EsignAuditLog::create([ 'tenant_id' => $contract->tenant_id, 'contract_id' => $contract->id, 'signer_id' => $signer->id, 'action' => 'rejected', 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent(), 'metadata' => ['reason' => $reason], 'created_at' => now(), ]); return response()->json(['success' => true, 'message' => '서명이 거절되었습니다.']); } /** * 계약 문서 다운로드 */ public function downloadDocument(string $token): StreamedResponse|JsonResponse { $signer = $this->findSigner($token); if (! $signer) { return response()->json(['success' => false, 'message' => '유효하지 않은 서명 링크입니다.'], 404); } $contract = EsignContract::withoutGlobalScopes()->find($signer->contract_id); if (! $contract || ! $contract->original_file_path) { return response()->json(['success' => false, 'message' => '문서를 찾을 수 없습니다.'], 404); } // 서명 완료된 PDF가 있으면 우선 제공 if ($contract->signed_file_path && Storage::disk('local')->exists($contract->signed_file_path)) { $filePath = $contract->signed_file_path; } elseif ($contract->status === 'completed') { // 계약 완료 상태인데 서명 PDF가 없으면 재생성 시도 try { $pdfService = new PdfSignatureService; $filePath = $pdfService->mergeSignatures($contract); Log::info('서명 PDF 재생성 성공', ['contract_id' => $contract->id, 'path' => $filePath]); } catch (\Throwable $e) { Log::error('서명 PDF 재생성 실패', [ 'contract_id' => $contract->id, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); // 재생성 실패 시 미리보기 PDF 폴백 (서명 제외, 텍스트/날짜/체크박스만) try { $filePath = $pdfService->generatePreview($contract); } catch (\Throwable $e2) { Log::warning('미리보기 PDF 생성도 실패, 원본 제공', ['error' => $e2->getMessage()]); $filePath = $contract->original_file_path; } } } else { // 서명 전: 텍스트/날짜/체크박스 필드가 합성된 미리보기 PDF 생성 try { $pdfService = new PdfSignatureService; $filePath = $pdfService->generatePreview($contract); } catch (\Throwable $e) { Log::error('미리보기 PDF 생성 실패, 원본 제공', [ 'contract_id' => $contract->id, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); $filePath = $contract->original_file_path; } } if (! Storage::disk('local')->exists($filePath)) { return response()->json(['success' => false, 'message' => '문서 파일이 존재하지 않습니다.'], 404); } $fileName = $contract->original_file_name ?: ($contract->title.'.pdf'); return Storage::disk('local')->download($filePath, $fileName, [ 'Content-Type' => 'application/pdf', ]); } // ─── Private ─── /** * SMS로 OTP 발송 (바로빌 독립 SMS API 사용) */ private function sendOtpViaSms(EsignContract $contract, EsignSigner $signer, string $otpCode): bool { try { $member = BarobillMember::where('tenant_id', $contract->tenant_id)->first(); if (! $member || ! $member->manager_hp) { Log::warning('OTP SMS 발송 실패: 바로빌 회원 또는 발신번호 없음', [ 'contract_id' => $contract->id, 'tenant_id' => $contract->tenant_id, ]); return false; } $barobill = app(BarobillService::class); $barobill->setServerMode($member->server_mode ?? 'production'); $fromNumber = preg_replace('/[^0-9]/', '', $member->manager_hp); $toNumber = preg_replace('/[^0-9]/', '', $signer->phone); $smsText = "[SAM] 전자계약 인증코드: {$otpCode} (5분 이내 입력)"; $result = $barobill->sendSMSMessage( corpNum: $member->biz_no, senderId: $member->barobill_id, fromNumber: $fromNumber, toName: $signer->name, toNumber: $toNumber, contents: $smsText, ); if ($result['success'] ?? false) { return true; } Log::warning('OTP SMS 발송 API 실패', [ 'contract_id' => $contract->id, 'signer_id' => $signer->id, 'error' => $result['error'] ?? 'Unknown', ]); return false; } catch (\Throwable $e) { Log::error('OTP SMS 발송 예외', [ 'contract_id' => $contract->id, 'signer_id' => $signer->id, 'error' => $e->getMessage(), ]); return false; } } private function sendCompletionAlimtalk(EsignContract $contract, EsignSigner $signer): array { try { $member = BarobillMember::where('tenant_id', $contract->tenant_id)->first(); if (! $member || ! $member->biz_no) { return ['success' => false, 'channel' => 'alimtalk', 'error' => '바로빌 회원 미등록']; } $barobill = app(BarobillService::class); $barobill->setServerMode($member->server_mode ?? 'production'); // 채널 ID 조회 $channelId = $this->getKakaotalkChannelId($barobill, $member->biz_no); if (! $channelId) { return ['success' => false, 'channel' => 'alimtalk', 'error' => '등록된 카카오톡 채널이 없습니다']; } $templateName = $contract->completion_template_name ?: $this->resolveTemplateName('전자계약_완료'); $documentUrl = config('app.url').'/esign/sign/'.$signer->access_token.'/api/document'; $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'); // 등록된 템플릿 본문 + 버튼 조회 $tplData = $this->getTemplateData($barobill, $member->biz_no, $channelId, $templateName); $templateContent = $tplData['content']; $templateButtons = $tplData['buttons']; if ($templateContent) { $message = str_replace( ['#{이름}', '#{계약명}', '#{완료일}'], [$signer->name, $contract->title, $completedAt], $templateContent ); } else { Log::warning('E-Sign 완료 알림톡: 템플릿 내용 조회 실패, 하드코딩 폴백 사용', [ 'template_name' => $templateName, 'channel_id' => $channelId, ]); $message = "안녕하세요, {$signer->name}님.\n전자계약이 모든 서명자의 서명 완료로 확정되었습니다.\n\n■ 계약명: {$contract->title}\n■ 완료일: {$completedAt}\n\n아래 버튼에서 서명 완료된 계약서를 확인하고 다운로드할 수 있습니다."; } // 버튼: 템플릿에서 가져온 URL의 #{토큰}만 치환 $buttons = ! empty($templateButtons) ? $templateButtons : [ [ 'Name' => '계약서 다운로드', 'ButtonType' => 'WL', 'Url1' => $documentUrl, 'Url2' => $documentUrl, ], ]; foreach ($buttons as &$btn) { foreach (['Url1', 'Url2'] as $urlKey) { if (! empty($btn[$urlKey])) { $btn[$urlKey] = str_replace( ['#{토큰}', '#{%ED%86%A0%ED%81%B0}'], [$signer->access_token, $signer->access_token], urldecode($btn[$urlKey]) ); // 완료 알림톡: 버튼 URL을 문서 다운로드 엔드포인트로 강제 변경 // 템플릿 버튼 URL이 서명 페이지(/esign/sign/{token})를 가리키므로 // 완료된 계약서 PDF 다운로드(/esign/sign/{token}/api/document)로 교체 if (str_contains($btn[$urlKey], '/esign/sign/') && ! str_contains($btn[$urlKey], '/api/document')) { $btn[$urlKey] = $documentUrl; } } } } unset($btn); // 버튼 URL 도메인을 현재 환경의 도메인으로 치환 $appHost = parse_url(config('app.url'), PHP_URL_HOST); foreach ($buttons as &$btn) { foreach (['Url1', 'Url2'] as $urlKey) { if (! empty($btn[$urlKey])) { $parsed = parse_url($btn[$urlKey]); if (isset($parsed['host']) && $parsed['host'] !== $appHost) { $btn[$urlKey] = str_replace($parsed['host'], $appHost, $btn[$urlKey]); } } } } unset($btn); $receiverNum = preg_replace('/[^0-9]/', '', $signer->phone); Log::info('E-Sign 완료 알림톡 발송 시도', [ 'contract_id' => $contract->id, 'signer_id' => $signer->id, 'signer_role' => $signer->role, 'template_name' => $templateName, 'template_from_api' => (bool) $templateContent, 'buttons_from_api' => ! empty($templateButtons), 'receiver_num' => $receiverNum, ]); $result = $barobill->sendATKakaotalkEx( corpNum: $member->biz_no, senderId: $member->barobill_id, yellowId: $channelId, templateName: $templateName, receiverName: $signer->name, receiverNum: $receiverNum, title: '', message: $message, buttons: $buttons, smsMessage: ($contract->sms_fallback ?? true) ? "[SAM] {$signer->name}님, 전자계약이 완료되었습니다. 계약서 다운로드: {$documentUrl}" : '', ); // 발송 접수 후 결과 확인 if (($result['success'] ?? false) && ! empty($result['data']) && is_string($result['data'])) { $sendKey = $result['data']; Log::info('E-Sign 완료 알림톡 접수 성공', [ 'contract_id' => $contract->id, 'send_key' => $sendKey, ]); sleep(3); $sendResult = $barobill->getSendKakaotalk($member->biz_no, $sendKey); $resultData = $sendResult['data'] ?? null; $resultCode = is_object($resultData) ? ($resultData->ResultCode ?? null) : ($resultData['ResultCode'] ?? null); $resultMsg = is_object($resultData) ? ($resultData->ResultMessage ?? null) : ($resultData['ResultMessage'] ?? null); Log::info('E-Sign 완료 알림톡 전달 결과', [ 'contract_id' => $contract->id, 'send_key' => $sendKey, 'result_code' => $resultCode, 'result_message' => $resultMsg, ]); if ($resultCode !== null && $resultCode != 1) { return [ 'success' => false, 'channel' => 'alimtalk', 'error' => "카카오톡 전달 실패: {$resultMsg} (코드: {$resultCode})", ]; } } 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()]; } } /** * 카카오톡 채널 ID 조회 (바로빌 API) */ private function getKakaotalkChannelId(BarobillService $barobill, string $bizNo): ?string { $result = $barobill->getKakaotalkChannels($bizNo); if (! ($result['success'] ?? false) || empty($result['data'])) { return null; } $data = $result['data']; if (is_object($data) && isset($data->KakaotalkChannel)) { $channels = is_array($data->KakaotalkChannel) ? $data->KakaotalkChannel : [$data->KakaotalkChannel]; } elseif (is_array($data) && isset($data['KakaotalkChannel'])) { $channels = is_array($data['KakaotalkChannel']) ? $data['KakaotalkChannel'] : [$data['KakaotalkChannel']]; } else { $channels = is_array($data) ? $data : [$data]; } $channel = $channels[0] ?? null; if (! $channel) { return null; } return is_array($channel) ? ($channel['ChannelId'] ?? null) : ($channel->ChannelId ?? null); } /** * 바로빌 등록 템플릿의 본문 내용 및 버튼 정보 조회 * * @return array{content: string|null, buttons: array} */ private function getTemplateData(BarobillService $barobill, string $bizNo, string $channelId, string $templateName): array { $empty = ['content' => null, 'buttons' => []]; $result = $barobill->getKakaotalkTemplates($bizNo, $channelId); if (! ($result['success'] ?? false) || empty($result['data'])) { return $empty; } $data = $result['data']; $items = []; if (is_object($data) && isset($data->KakaotalkTemplate)) { $items = is_array($data->KakaotalkTemplate) ? $data->KakaotalkTemplate : [$data->KakaotalkTemplate]; } foreach ($items as $tpl) { if (($tpl->TemplateName ?? '') === $templateName) { $buttons = []; $btnData = $tpl->Buttons ?? null; if ($btnData) { $btnList = $btnData->KakaotalkButton ?? null; if ($btnList) { $btnList = is_array($btnList) ? $btnList : [$btnList]; foreach ($btnList as $btn) { $buttons[] = [ 'Name' => $btn->Name ?? '', 'ButtonType' => $btn->ButtonType ?? 'WL', 'Url1' => $btn->Url1 ?? '', 'Url2' => $btn->Url2 ?? '', ]; } } } return [ 'content' => $tpl->TemplateContent ?? null, 'buttons' => $buttons, ]; } } return $empty; } /** * 환경별 알림톡 템플릿명 반환 (운영: 원본, 개발: _DEV 접미사) */ private function resolveTemplateName(string $baseName): string { return $baseName.(app()->environment('production') ? '' : '_DEV'); } private function findSigner(string $token): ?EsignSigner { $signer = EsignSigner::withoutGlobalScopes() ->where('access_token', $token) ->first(); if ($signer && $signer->token_expires_at && $signer->token_expires_at->isPast()) { return null; } return $signer; } private function hasCompanyStamp(int $tenantId): bool { $stamp = TenantSetting::where('tenant_id', $tenantId) ->where('setting_group', 'esign') ->where('setting_key', 'company_stamp') ->first(); if (! $stamp) { return false; } $val = $stamp->setting_value; return ! empty($val['gcs_object']) || ! empty($val['local_path']); } private function applyCompanyStamp(EsignSigner $signer, int $tenantId): ?string { $stamp = TenantSetting::where('tenant_id', $tenantId) ->where('setting_group', 'esign') ->where('setting_key', 'company_stamp') ->first(); if (! $stamp) { return null; } $val = $stamp->setting_value; $imageData = null; if (! empty($val['gcs_object'])) { $gcs = app(\App\Services\GoogleCloudStorageService::class); $signedUrl = $gcs->getSignedUrl($val['gcs_object'], 5); if ($signedUrl) { $imageData = @file_get_contents($signedUrl); } } elseif (! empty($val['local_path']) && Storage::disk('local')->exists($val['local_path'])) { $imageData = Storage::disk('local')->get($val['local_path']); } if (! $imageData) { return null; } $localPath = "esign/{$tenantId}/signatures/{$signer->contract_id}_{$signer->id}_stamp.png"; Storage::disk('local')->put($localPath, $imageData); $signer->update(['signature_image_path' => $localPath]); return $localPath; } }