diff --git a/app/Http/Controllers/ESign/EsignPublicController.php b/app/Http/Controllers/ESign/EsignPublicController.php index 7dffc204..edc355ba 100644 --- a/app/Http/Controllers/ESign/EsignPublicController.php +++ b/app/Http/Controllers/ESign/EsignPublicController.php @@ -127,11 +127,13 @@ public function getContract(string $token): JsonResponse '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, 'signed_at' => $signer->signed_at, ], + 'send_method' => $contract->send_method ?? 'email', 'is_signable' => $isSignable, 'status_message' => $statusMessage, ], @@ -161,10 +163,28 @@ public function sendOtp(string $token): JsonResponse 'otp_attempts' => 0, ]); - // OTP 이메일 발송 - \Illuminate\Support\Facades\Mail::to($signer->email)->send( - new \App\Mail\EsignOtpMail($signer->name, $otpCode) - ); + $sendMethod = $contract->send_method ?? 'email'; + $channel = 'email'; + + // 알림톡/both 방식이고 전화번호가 있으면 SMS로 발송 + if (in_array($sendMethod, ['alimtalk', 'both']) && $signer->phone) { + $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, @@ -173,11 +193,19 @@ public function sendOtp(string $token): JsonResponse 'action' => 'otp_sent', 'ip_address' => request()->ip(), 'user_agent' => request()->userAgent(), - 'metadata' => ['email' => $signer->email], + 'metadata' => [ + 'channel' => $channel, + 'email' => $channel === 'email' ? $signer->email : null, + 'phone' => $channel === 'sms' ? $signer->phone : null, + ], 'created_at' => now(), ]); - return response()->json(['success' => true, 'message' => '인증 코드가 발송되었습니다.']); + return response()->json([ + 'success' => true, + 'message' => '인증 코드가 발송되었습니다.', + 'channel' => $channel, + ]); } /** @@ -585,6 +613,74 @@ public function downloadDocument(string $token): StreamedResponse|JsonResponse // ─── Private ─── + /** + * SMS로 OTP 발송 (바로빌 알림톡 SMS 대체발송 기능 활용) + */ + 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'); + + // 채널 ID 조회 + $channelResult = $barobill->getKakaotalkChannels($member->biz_no); + $yellowId = ''; + if ($channelResult['success'] ?? false) { + $chData = $channelResult['data']; + if (is_object($chData) && isset($chData->KakaotalkChannel)) { + $ch = is_array($chData->KakaotalkChannel) ? $chData->KakaotalkChannel[0] : $chData->KakaotalkChannel; + $yellowId = $ch->ChannelId ?? ''; + } + } + + $smsText = "[SAM] 전자계약 인증코드: {$otpCode} (5분 이내 입력)"; + + $result = $barobill->sendATKakaotalkEx( + corpNum: $member->biz_no, + senderId: $member->barobill_id, + yellowId: $yellowId, + templateName: '인증코드', + receiverName: $signer->name, + receiverNum: preg_replace('/[^0-9]/', '', $signer->phone), + title: '', + message: $smsText, + buttons: [], + smsMessage: $smsText, + smsSenderNum: preg_replace('/[^0-9]/', '', $member->manager_hp), + ); + + 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 { diff --git a/resources/views/esign/sign/auth.blade.php b/resources/views/esign/sign/auth.blade.php index 83b63262..b90fd8e6 100644 --- a/resources/views/esign/sign/auth.blade.php +++ b/resources/views/esign/sign/auth.blade.php @@ -23,6 +23,8 @@ const App = () => { const [contract, setContract] = useState(null); const [signer, setSigner] = useState(null); + const [sendMethod, setSendMethod] = useState('email'); + const [otpChannel, setOtpChannel] = useState(null); const [step, setStep] = useState('loading'); // loading, info, otp const [otp, setOtp] = useState(''); const [otpSending, setOtpSending] = useState(false); @@ -38,6 +40,7 @@ if (json.success) { setContract(json.data.contract); setSigner(json.data.signer); + setSendMethod(json.data.send_method || 'email'); setStep('info'); } else { setError(json.message || '계약 정보를 불러올 수 없습니다.'); @@ -59,6 +62,7 @@ }); const json = await res.json(); if (json.success) { + setOtpChannel(json.channel || 'email'); setStep('otp'); } else { setError(json.message || 'OTP 발송에 실패했습니다.'); @@ -116,7 +120,7 @@
서명자
-{signer?.name} ({signer?.email})
+{signer?.name} ({signer?.email || signer?.phone})
서명 기한
@@ -124,7 +128,9 @@- 본인인증을 위해 등록된 이메일로 인증 코드를 발송합니다. + {(sendMethod === 'alimtalk' || sendMethod === 'both') && signer?.phone + ? '본인인증을 위해 등록된 휴대폰으로 SMS 인증 코드를 발송합니다.' + : '본인인증을 위해 등록된 이메일로 인증 코드를 발송합니다.'}