feat: [esign] OTP 인증코드 SMS 발송 기능 추가
- send_method가 alimtalk/both일 때 SMS로 OTP 발송 - 바로빌 sendATKakaotalkEx SMS 대체발송 기능 활용 - SMS 실패 시 이메일 폴백 - auth.blade.php UI 메시지 SMS/이메일 분기 표시
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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))}
|
||||
|
||||
Reference in New Issue
Block a user