diff --git a/app/Http/Controllers/ESign/EsignApiController.php b/app/Http/Controllers/ESign/EsignApiController.php index d42bbe82..4cccdbf9 100644 --- a/app/Http/Controllers/ESign/EsignApiController.php +++ b/app/Http/Controllers/ESign/EsignApiController.php @@ -532,6 +532,8 @@ public function store(Request $request): JsonResponse 'expires_at' => $request->input('expires_at') ? \Carbon\Carbon::parse($request->input('expires_at')) : now()->addDays($request->input('expires_days', 30)), + 'verification_required' => (bool) $request->input('verification_required', false), + 'verification_template_id' => $request->input('verification_template_id') ?: null, 'created_by' => $userId, 'updated_by' => $userId, ]); diff --git a/app/Http/Controllers/ESign/EsignPublicController.php b/app/Http/Controllers/ESign/EsignPublicController.php index 096fd834..80fc14b1 100644 --- a/app/Http/Controllers/ESign/EsignPublicController.php +++ b/app/Http/Controllers/ESign/EsignPublicController.php @@ -137,6 +137,7 @@ public function getContract(string $token): JsonResponse 'send_method' => $contract->tenant_id == 1 ? 'email' : ($contract->send_method ?? 'email'), 'is_signable' => $isSignable, 'status_message' => $statusMessage, + 'verification_required' => (bool) $contract->verification_required, ], ]); } @@ -1065,4 +1066,165 @@ private function applyCompanyStamp(EsignSigner $signer, int $tenantId): ?string return $localPath; } + + // ─── 필기 확인 (Handwriting Verification) ─── + + /** + * 필기 확인 페이지 + */ + public function verification(string $token): View + { + return view('esign.sign.verification', ['token' => $token]); + } + + /** + * 필기 확인 단계 정보 조회 + */ + public function getVerification(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 || ! $contract->verification_required) { + return response()->json(['success' => false, 'message' => '필기 확인이 필요하지 않은 계약입니다.'], 400); + } + + $service = app(\App\Services\ESign\HandwritingVerificationService::class); + $templateId = $contract->verification_template_id; + $steps = $service->getVerificationSteps($contract->id, $signer->id, $templateId); + $allPassed = $service->isAllStepsPassed($contract->id, $signer->id, $steps); + + // 이미 모두 통과한 경우 서명자 상태 업데이트 + if ($allPassed && $signer->verification_status !== 'passed') { + $signer->update([ + 'verification_status' => 'passed', + 'verification_passed_at' => now(), + ]); + } + + // 감사 로그 + EsignAuditLog::create([ + 'tenant_id' => $contract->tenant_id, + 'contract_id' => $contract->id, + 'signer_id' => $signer->id, + 'action' => 'verification_viewed', + 'ip_address' => request()->ip(), + 'user_agent' => substr(request()->userAgent() ?? '', 0, 500), + 'created_at' => now(), + ]); + + return response()->json([ + 'success' => true, + 'data' => [ + 'contract_title' => $contract->title, + 'signer_name' => $signer->name, + 'steps' => $steps, + 'all_passed' => $allPassed, + 'max_attempts' => $contract->verificationTemplate + ? $contract->verificationTemplate->max_attempts + : (int) config('esign.handwriting_verification.max_attempts', 5), + ], + ]); + } + + /** + * 필기 인식 제출 + */ + public function submitVerification(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 || ! $contract->verification_required) { + return response()->json(['success' => false, 'message' => '필기 확인이 필요하지 않은 계약입니다.'], 400); + } + + $request = request(); + $request->validate([ + 'step_order' => 'required|integer|min:1', + 'handwriting_image' => 'required|string', + ]); + + $service = app(\App\Services\ESign\HandwritingVerificationService::class); + $templateId = $contract->verification_template_id; + $steps = $service->getVerificationSteps($contract->id, $signer->id, $templateId); + + $stepOrder = (int) $request->input('step_order'); + $currentStep = collect($steps)->firstWhere('order', $stepOrder); + + if (! $currentStep) { + return response()->json(['success' => false, 'message' => '잘못된 단계입니다.'], 400); + } + + $maxAttempts = $contract->verificationTemplate + ? $contract->verificationTemplate->max_attempts + : (int) config('esign.handwriting_verification.max_attempts', 5); + + $result = $service->submitHandwriting( + tenantId: $contract->tenant_id, + contractId: $contract->id, + signerId: $signer->id, + stepOrder: $stepOrder, + promptText: $currentStep['text'], + base64Image: $request->input('handwriting_image'), + threshold: $currentStep['threshold'] ?? 80.0, + maxAttempts: $maxAttempts, + ); + + // 감사 로그 + EsignAuditLog::create([ + 'tenant_id' => $contract->tenant_id, + 'contract_id' => $contract->id, + 'signer_id' => $signer->id, + 'action' => $result['is_passed'] ? 'verification_passed' : 'verification_attempted', + 'ip_address' => request()->ip(), + 'user_agent' => substr(request()->userAgent() ?? '', 0, 500), + 'metadata' => [ + 'step_order' => $stepOrder, + 'similarity_score' => $result['similarity_score'], + 'is_passed' => $result['is_passed'], + 'attempt_number' => $result['attempt_number'], + ], + 'created_at' => now(), + ]); + + // 모든 단계 통과 시 서명자 상태 업데이트 + if ($result['is_passed']) { + $allSteps = $service->getVerificationSteps($contract->id, $signer->id, $templateId); + if ($service->isAllStepsPassed($contract->id, $signer->id, $allSteps)) { + $signer->update([ + 'verification_status' => 'passed', + 'verification_passed_at' => now(), + ]); + + EsignAuditLog::create([ + 'tenant_id' => $contract->tenant_id, + 'contract_id' => $contract->id, + 'signer_id' => $signer->id, + 'action' => 'verification_completed', + 'ip_address' => request()->ip(), + 'user_agent' => substr(request()->userAgent() ?? '', 0, 500), + 'created_at' => now(), + ]); + } + } + + return response()->json([ + 'success' => true, + 'data' => [ + 'recognized_text' => $result['recognized_text'], + 'similarity_score' => $result['similarity_score'], + 'is_passed' => $result['is_passed'], + 'attempt_number' => $result['attempt_number'], + 'attempts_remaining' => $result['attempts_remaining'], + 'hints' => array_map(fn ($h) => is_array($h) ? $h['message'] : $h, $result['hints'] ?? []), + ], + ]); + } } diff --git a/app/Models/ESign/EsignContract.php b/app/Models/ESign/EsignContract.php index 47e863a5..1b414526 100644 --- a/app/Models/ESign/EsignContract.php +++ b/app/Models/ESign/EsignContract.php @@ -3,6 +3,7 @@ namespace App\Models\ESign; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; @@ -28,6 +29,8 @@ class EsignContract extends Model 'send_method', 'sms_fallback', 'completion_template_name', + 'verification_required', + 'verification_template_id', 'metadata', 'expires_at', 'completed_at', @@ -39,6 +42,8 @@ class EsignContract extends Model protected $casts = [ 'original_file_size' => 'integer', 'sms_fallback' => 'boolean', + 'verification_required' => 'boolean', + 'verification_template_id' => 'integer', 'metadata' => 'array', 'expires_at' => 'datetime', 'completed_at' => 'datetime', @@ -54,6 +59,11 @@ public function signFields(): HasMany return $this->hasMany(EsignSignField::class, 'contract_id'); } + public function verificationTemplate(): BelongsTo + { + return $this->belongsTo(EsignVerificationTemplate::class, 'verification_template_id'); + } + public function auditLogs(): HasMany { return $this->hasMany(EsignAuditLog::class, 'contract_id'); diff --git a/app/Models/ESign/EsignSigner.php b/app/Models/ESign/EsignSigner.php index 84596b02..bfbff160 100644 --- a/app/Models/ESign/EsignSigner.php +++ b/app/Models/ESign/EsignSigner.php @@ -47,6 +47,8 @@ class EsignSigner extends Model 'sign_user_agent', 'status', 'rejected_reason', + 'verification_status', + 'verification_passed_at', ]; protected $casts = [ @@ -57,6 +59,7 @@ class EsignSigner extends Model 'auth_verified_at' => 'datetime', 'signed_at' => 'datetime', 'consent_agreed_at' => 'datetime', + 'verification_passed_at' => 'datetime', ]; protected $hidden = [ diff --git a/resources/views/esign/create.blade.php b/resources/views/esign/create.blade.php index 66308e54..35c0b1c4 100644 --- a/resources/views/esign/create.blade.php +++ b/resources/views/esign/create.blade.php @@ -547,6 +547,9 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i = const [existingFileName, setExistingFileName] = useState(''); const [editFields, setEditFields] = useState([]); const [editSigners, setEditSigners] = useState([]); + const [verificationRequired, setVerificationRequired] = useState(false); + const [verificationTemplateId, setVerificationTemplateId] = useState(''); + const [verificationTemplates, setVerificationTemplates] = useState([]); const fileRef = useRef(null); const hasTemplates = templates.length > 0; @@ -563,6 +566,10 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i = .then(r => r.json()) .then(json => { if (json.success) setTemplates(json.data); }) .catch(() => {}); + fetch('/esign-verification/api/templates', { headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' } }) + .then(r => r.json()) + .then(json => { if (json.success) setVerificationTemplates(json.data || []); }) + .catch(() => {}); // 수정 모드: 기존 계약 데이터 로드 if (IS_EDIT) { @@ -1149,6 +1156,8 @@ className={`w-full text-left px-3 py-2.5 rounded-lg mb-1 transition-colors ${i = Object.entries(form).forEach(([k, v]) => { if (v) fd.append(k, v); }); if (file) fd.append('file', file); if (!IS_EDIT && templateId) fd.append('template_id', templateId); + fd.append('verification_required', verificationRequired ? '1' : '0'); + if (verificationRequired && verificationTemplateId) fd.append('verification_template_id', verificationTemplateId); if (!IS_EDIT && Object.keys(metadata).length > 0) { Object.entries(metadata).forEach(([k, v]) => { fd.append(`metadata[${k}]`, v || ''); }); } @@ -1264,6 +1273,32 @@ className="w-full border border-gray-300 rounded-md px-2.5 py-1.5 text-sm focus:

기본값: 오늘로부터 7일 후

+ + {/* 필기 확인 (고도화) */} +
+
+ + 서명 전 자필 문구 작성 + 인식 검증 (보험사 방식) +
+ {verificationRequired && ( +
+ +

+ 템플릿 관리에서 확인 문구를 설정할 수 있습니다. +

+
+ )} +
diff --git a/resources/views/esign/sign/auth.blade.php b/resources/views/esign/sign/auth.blade.php index b90fd8e6..c2ff11bf 100644 --- a/resources/views/esign/sign/auth.blade.php +++ b/resources/views/esign/sign/auth.blade.php @@ -30,6 +30,7 @@ const [otpSending, setOtpSending] = useState(false); const [verifying, setVerifying] = useState(false); const [error, setError] = useState(''); + const [verificationRequired, setVerificationRequired] = useState(false); const fetchContract = useCallback(async () => { try { @@ -41,6 +42,7 @@ setContract(json.data.contract); setSigner(json.data.signer); setSendMethod(json.data.send_method || 'email'); + setVerificationRequired(!!json.data.verification_required); setStep('info'); } else { setError(json.message || '계약 정보를 불러올 수 없습니다.'); @@ -84,7 +86,9 @@ const json = await res.json(); if (json.success) { sessionStorage.setItem('esign_session_token', json.data.sign_session_token); - location.href = `/esign/sign/${TOKEN}/sign`; + location.href = verificationRequired + ? `/esign/sign/${TOKEN}/verification` + : `/esign/sign/${TOKEN}/sign`; } else { setError(json.message || '인증 코드가 올바르지 않습니다.'); } diff --git a/resources/views/esign/sign/verification.blade.php b/resources/views/esign/sign/verification.blade.php new file mode 100644 index 00000000..5dae7d6d --- /dev/null +++ b/resources/views/esign/sign/verification.blade.php @@ -0,0 +1,374 @@ + + + + + + 필기 확인 - SAM E-Sign + @vite(['resources/css/app.css']) + + +
+ + + + + +@verbatim + +@endverbatim + + diff --git a/routes/web.php b/routes/web.php index 563e92d1..635784c5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2251,6 +2251,7 @@ Route::prefix('esign/sign')->group(function () { // 화면 라우트 Route::get('/{token}', [EsignPublicController::class, 'auth'])->name('esign.sign.auth'); + Route::get('/{token}/verification', [EsignPublicController::class, 'verification'])->name('esign.sign.verification'); Route::get('/{token}/sign', [EsignPublicController::class, 'sign'])->name('esign.sign.do'); Route::get('/{token}/done', [EsignPublicController::class, 'done'])->name('esign.sign.done'); @@ -2258,6 +2259,8 @@ Route::get('/{token}/api/contract', [EsignPublicController::class, 'getContract'])->name('esign.sign.api.contract'); Route::post('/{token}/api/otp/send', [EsignPublicController::class, 'sendOtp'])->name('esign.sign.api.otp.send'); Route::post('/{token}/api/otp/verify', [EsignPublicController::class, 'verifyOtp'])->name('esign.sign.api.otp.verify'); + Route::get('/{token}/api/verification', [EsignPublicController::class, 'getVerification'])->name('esign.sign.api.verification'); + Route::post('/{token}/api/verification/submit', [EsignPublicController::class, 'submitVerification'])->name('esign.sign.api.verification.submit'); Route::post('/{token}/api/submit', [EsignPublicController::class, 'submitSignature'])->name('esign.sign.api.submit'); Route::post('/{token}/api/reject', [EsignPublicController::class, 'rejectContract'])->name('esign.sign.api.reject'); Route::get('/{token}/api/document', [EsignPublicController::class, 'downloadDocument'])->name('esign.sign.api.document');