feat: [esign] OTP 인증코드 SMS 발송 기능 추가

- send_method가 alimtalk/both일 때 SMS로 OTP 발송
- 바로빌 sendATKakaotalkEx SMS 대체발송 기능 활용
- SMS 실패 시 이메일 폴백
- auth.blade.php UI 메시지 SMS/이메일 분기 표시
This commit is contained in:
김보곤
2026-02-26 09:06:36 +09:00
parent daee3e3334
commit 59f68e272c
2 changed files with 113 additions and 9 deletions

View File

@@ -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 {

View File

@@ -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 @@
</div>
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-xs text-gray-500 mb-1">서명자</p>
<p className="font-medium">{signer?.name} ({signer?.email})</p>
<p className="font-medium">{signer?.name} ({signer?.email || signer?.phone})</p>
</div>
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-xs text-gray-500 mb-1">서명 기한</p>
@@ -124,7 +128,9 @@
</div>
</div>
<p className="text-sm text-gray-600 mb-4">
본인인증을 위해 등록된 이메일로 인증 코드를 발송합니다.
{(sendMethod === 'alimtalk' || sendMethod === 'both') && signer?.phone
? '본인인증을 위해 등록된 휴대폰으로 SMS 인증 코드를 발송합니다.'
: '본인인증을 위해 등록된 이메일로 인증 코드를 발송합니다.'}
</p>
<button onClick={sendOtp} disabled={otpSending}
className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium disabled:opacity-50">
@@ -137,7 +143,9 @@ className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-
<>
<h2 className="text-lg font-semibold mb-4">인증 코드 입력</h2>
<p className="text-sm text-gray-600 mb-4">
<strong>{signer?.email}</strong> 발송된 6자리 인증 코드를 입력해 주세요.
{otpChannel === 'sms'
? <><strong>{signer?.phone}</strong>으로 발송된 6자리 인증 코드를 입력해 주세요.</>
: <><strong>{signer?.email}</strong> 발송된 6자리 인증 코드를 입력해 주세요.</>}
</p>
<form onSubmit={verifyOtp} className="space-y-4">
<input type="text" value={otp} onChange={e => setOtp(e.target.value.replace(/\D/g, '').slice(0, 6))}