diff --git a/app/Http/Controllers/ESign/EsignVerificationController.php b/app/Http/Controllers/ESign/EsignVerificationController.php new file mode 100644 index 00000000..2fbc2fae --- /dev/null +++ b/app/Http/Controllers/ESign/EsignVerificationController.php @@ -0,0 +1,215 @@ +service = $service; + } + + // ─── 화면 라우트 ─── + + public function dashboard(Request $request): View|Response + { + if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('esign-verification.dashboard')); + } + + return view('esign.verification.dashboard'); + } + + public function templates(Request $request): View|Response + { + if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('esign-verification.templates')); + } + + return view('esign.verification.templates'); + } + + public function demo(Request $request): View|Response + { + if ($request->header('HX-Request')) { + return response('', 200)->header('HX-Redirect', route('esign-verification.demo')); + } + + return view('esign.verification.demo'); + } + + // ─── API: 확인 템플릿 CRUD ─── + + public function indexTemplates(Request $request): JsonResponse + { + $tenantId = Auth::user()->tenant_id; + + $templates = EsignVerificationTemplate::where('tenant_id', $tenantId) + ->where('is_active', true) + ->orderBy('created_at', 'desc') + ->get(); + + return response()->json(['success' => true, 'data' => $templates]); + } + + public function storeTemplate(Request $request): JsonResponse + { + $request->validate([ + 'name' => 'required|string|max:100', + 'category' => 'nullable|string|max:50', + 'steps' => 'required|array|min:1', + 'steps.*.order' => 'required|integer|min:1', + 'steps.*.text' => 'required|string|max:200', + 'steps.*.threshold' => 'required|numeric|min:50|max:100', + 'pass_threshold' => 'nullable|numeric|min:50|max:100', + 'max_attempts' => 'nullable|integer|min:1|max:20', + ]); + + $tenantId = Auth::user()->tenant_id; + + $template = EsignVerificationTemplate::create([ + 'tenant_id' => $tenantId, + 'name' => $request->input('name'), + 'category' => $request->input('category'), + 'steps' => $request->input('steps'), + 'pass_threshold' => $request->input('pass_threshold', 80.00), + 'max_attempts' => $request->input('max_attempts', 5), + 'is_active' => true, + 'created_by' => Auth::id(), + ]); + + return response()->json(['success' => true, 'data' => $template], 201); + } + + public function showTemplate(int $id): JsonResponse + { + $tenantId = Auth::user()->tenant_id; + + $template = EsignVerificationTemplate::where('tenant_id', $tenantId) + ->findOrFail($id); + + return response()->json(['success' => true, 'data' => $template]); + } + + public function updateTemplate(Request $request, int $id): JsonResponse + { + $request->validate([ + 'name' => 'required|string|max:100', + 'category' => 'nullable|string|max:50', + 'steps' => 'required|array|min:1', + 'steps.*.order' => 'required|integer|min:1', + 'steps.*.text' => 'required|string|max:200', + 'steps.*.threshold' => 'required|numeric|min:50|max:100', + 'pass_threshold' => 'nullable|numeric|min:50|max:100', + 'max_attempts' => 'nullable|integer|min:1|max:20', + ]); + + $tenantId = Auth::user()->tenant_id; + + $template = EsignVerificationTemplate::where('tenant_id', $tenantId) + ->findOrFail($id); + + $template->update([ + 'name' => $request->input('name'), + 'category' => $request->input('category'), + 'steps' => $request->input('steps'), + 'pass_threshold' => $request->input('pass_threshold', 80.00), + 'max_attempts' => $request->input('max_attempts', 5), + ]); + + return response()->json(['success' => true, 'data' => $template]); + } + + public function destroyTemplate(int $id): JsonResponse + { + $tenantId = Auth::user()->tenant_id; + + $template = EsignVerificationTemplate::where('tenant_id', $tenantId) + ->findOrFail($id); + + $template->update(['is_active' => false]); + + return response()->json(['success' => true, 'message' => '템플릿이 비활성화되었습니다.']); + } + + // ─── API: 데모 (인식 테스트) ─── + + public function demoRecognize(Request $request): JsonResponse + { + $request->validate([ + 'image' => 'required|string', + 'expected_text' => 'required|string|max:200', + ]); + + $result = $this->service->demo( + $request->input('image'), + $request->input('expected_text'), + ); + + return response()->json(['success' => true, 'data' => $result]); + } + + // ─── API: 검증 이력 ─── + + public function verificationHistory(Request $request): JsonResponse + { + $tenantId = Auth::user()->tenant_id; + + $query = EsignHandwritingVerification::where('tenant_id', $tenantId) + ->orderBy('created_at', 'desc'); + + if ($request->filled('contract_id')) { + $query->where('contract_id', $request->input('contract_id')); + } + + $data = $query->limit(100)->get(); + + return response()->json(['success' => true, 'data' => $data]); + } + + // ─── API: 통계 ─── + + public function stats(): JsonResponse + { + $tenantId = Auth::user()->tenant_id; + + $total = EsignHandwritingVerification::where('tenant_id', $tenantId)->count(); + $passed = EsignHandwritingVerification::where('tenant_id', $tenantId)->where('is_passed', true)->count(); + $failed = $total - $passed; + $avgScore = EsignHandwritingVerification::where('tenant_id', $tenantId) + ->whereNotNull('similarity_score') + ->avg('similarity_score') ?? 0; + $avgAttempts = EsignHandwritingVerification::where('tenant_id', $tenantId) + ->where('is_passed', true) + ->avg('attempt_number') ?? 0; + + $templateCount = EsignVerificationTemplate::where('tenant_id', $tenantId) + ->where('is_active', true) + ->count(); + + return response()->json([ + 'success' => true, + 'data' => [ + 'total_verifications' => $total, + 'passed' => $passed, + 'failed' => $failed, + 'pass_rate' => $total > 0 ? round(($passed / $total) * 100, 1) : 0, + 'avg_score' => round($avgScore, 1), + 'avg_attempts' => round($avgAttempts, 1), + 'template_count' => $templateCount, + ], + ]); + } +} diff --git a/app/Services/ESign/HandwritingVerificationService.php b/app/Services/ESign/HandwritingVerificationService.php index ac17f055..16885d9f 100644 --- a/app/Services/ESign/HandwritingVerificationService.php +++ b/app/Services/ESign/HandwritingVerificationService.php @@ -407,12 +407,16 @@ public function demo(string $base64Image, string $expectedText): array $threshold = (float) config('esign.handwriting_verification.default_threshold', 80.0); + $isPassed = $similarityScore >= $threshold; + $hints = $isPassed ? [] : $this->generateHints($similarityScore, $recognizedText, $expectedText); + return [ 'recognized_text' => $recognizedText, 'similarity_score' => $similarityScore, - 'is_passed' => $similarityScore >= $threshold, - 'engine' => $hwrResult['engine'], - 'confidence' => $hwrResult['confidence'], + 'is_passed' => $isPassed, + 'hwr_engine' => $hwrResult['engine'], + 'hwr_confidence' => $hwrResult['confidence'], + 'hints' => array_map(fn ($h) => $h['message'], $hints), ]; } diff --git a/config/esign.php b/config/esign.php new file mode 100644 index 00000000..232e4049 --- /dev/null +++ b/config/esign.php @@ -0,0 +1,39 @@ + [ + 'enabled' => env('ESIGN_HWR_ENABLED', true), + + // HWR 엔진 우선순위: clova, google_vision, tesseract + 'engine' => env('ESIGN_HWR_ENGINE', 'clova'), + 'fallback_engine' => env('ESIGN_HWR_FALLBACK', 'google_vision'), + + // Naver Clova OCR + 'clova' => [ + 'api_url' => env('CLOVA_OCR_API_URL'), + 'secret_key' => env('CLOVA_OCR_SECRET_KEY'), + ], + + // Google Cloud Vision + 'google_vision' => [ + 'api_key' => env('GOOGLE_VISION_API_KEY'), + ], + + // 검증 설정 + 'default_threshold' => (float) env('ESIGN_HWR_THRESHOLD', 80.0), + 'max_attempts' => (int) env('ESIGN_HWR_MAX_ATTEMPTS', 5), + 'min_strokes' => 5, + 'recognition_timeout' => 5, + + // 이미지 전처리 + 'image_max_width' => 1200, + 'image_format' => 'png', + ], + +]; diff --git a/resources/views/esign/verification/dashboard.blade.php b/resources/views/esign/verification/dashboard.blade.php new file mode 100644 index 00000000..803bde98 --- /dev/null +++ b/resources/views/esign/verification/dashboard.blade.php @@ -0,0 +1,168 @@ +@extends('layouts.app') + +@section('title', '전자서명 고도화') +@section('page-title', '전자서명 고도화 — 필기 문구 확인') + +@push('styles') + +@endpush + +@section('content') +
+ + {{-- 네비게이션 --}} +
+ 대시보드 + 확인 템플릿 + 인식 테스트 +
+ + {{-- 통계 --}} +
+
+
-
+
총 검증 횟수
+
+
+
-
+
통과율
+
+
+
-
+
평균 일치율
+
+
+
-
+
평균 시도 횟수
+
+
+
-
+
확인 템플릿
+
+
+ + {{-- 서비스 흐름도 --}} +
+
+ 전자서명 고도화 흐름 + NEW +
+

+ 보험사와 동일한 방식으로, 서명 전 지정 문구를 자필로 작성하고 인식률을 검증합니다. +

+
+
+
1
+
링크 접속
+
기존 유지
+
+
+
+
2
+
OTP 인증
+
기존 유지
+
+
+
+
3
+
필기 확인
+
고도화 추가
+
+
+
+
4
+
서명/도장
+
기존 유지
+
+
+
+
5
+
완료
+
기존 유지
+
+
+
+ + {{-- 핵심 기능 --}} +
+
핵심 기능
+
+
+

✍️ 자필 문구 작성

+

모바일/태블릿/웹에서 지정 문구를 직접 손으로 작성합니다. 가이드 문구가 연하게 표시되어 따라 쓸 수 있습니다.

+
+
+

🔍 HWR 필기 인식

+

Naver Clova OCR / Google Vision API로 자필 이미지를 텍스트로 변환합니다. 한국어 필기체에 특화된 인식 엔진을 사용합니다.

+
+
+

📊 유사도 검증

+

Levenshtein + 글자 유사도 알고리즘으로 인식 결과와 기대 문구의 일치율을 계산합니다. 기본 80% 이상 통과.

+
+
+

📋 확인 템플릿

+

계약 유형별로 확인 문구 세트를 미리 정의합니다. 다단계 확인(내용확인 + 본인이름 쓰기)이 가능합니다.

+
+
+

🛡️ 보안 강화

+

스트로크 데이터 + 이미지 해시 + IP/UA 기록으로 위변조를 방지합니다. 모든 시도가 감사 로그에 기록됩니다.

+
+
+

📱 멀티 디바이스

+

모바일 터치, 태블릿 스타일러스, 웹 마우스 모두 지원합니다. 디바이스별 캔버스 크기가 자동 최적화됩니다.

+
+
+
+ +
+@endsection + +@push('scripts') + +@endpush diff --git a/resources/views/esign/verification/demo.blade.php b/resources/views/esign/verification/demo.blade.php new file mode 100644 index 00000000..4dfcb019 --- /dev/null +++ b/resources/views/esign/verification/demo.blade.php @@ -0,0 +1,409 @@ +@extends('layouts.app') + +@section('title', '필기 인식 테스트') +@section('page-title', '전자서명 고도화 — 필기 인식 테스트') + +@push('styles') + +@endpush + +@section('content') +
+
+ 대시보드 + 확인 템플릿 + 인식 테스트 +
+ +
+
+@endsection + +@push('scripts') + + + + +@verbatim + +@endverbatim +@endpush diff --git a/resources/views/esign/verification/templates.blade.php b/resources/views/esign/verification/templates.blade.php new file mode 100644 index 00000000..6809e42d --- /dev/null +++ b/resources/views/esign/verification/templates.blade.php @@ -0,0 +1,241 @@ +@extends('layouts.app') + +@section('title', '확인 템플릿 관리') +@section('page-title', '전자서명 고도화 — 확인 템플릿 관리') + +@push('styles') + +@endpush + +@section('content') +
+
+ 대시보드 + 확인 템플릿 + 인식 테스트 +
+ +
+
+@endsection + +@push('scripts') + + + + +@verbatim + +@endverbatim +@endpush diff --git a/routes/web.php b/routes/web.php index e4ef9067..7e28829e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -33,6 +33,7 @@ use App\Http\Controllers\ESign\EsignApiController; use App\Http\Controllers\ESign\EsignController; use App\Http\Controllers\ESign\EsignPublicController; +use App\Http\Controllers\ESign\EsignVerificationController; use App\Http\Controllers\FcmController; use App\Http\Controllers\GoogleCloud\AiGuideController as GoogleCloudAiGuideController; use App\Http\Controllers\GoogleCloud\CloudApiPricingController as GoogleCloudCloudApiPricingController; @@ -2106,6 +2107,37 @@ }); }); +/* +|-------------------------------------------------------------------------- +| E-Sign 고도화 — 필기 문구 확인 (Handwriting Verification) +|-------------------------------------------------------------------------- +*/ +Route::middleware('auth')->prefix('esign-verification')->name('esign-verification.')->group(function () { + // 화면 라우트 + Route::get('/', [EsignVerificationController::class, 'dashboard'])->name('dashboard'); + Route::get('/templates', [EsignVerificationController::class, 'templates'])->name('templates'); + Route::get('/demo', [EsignVerificationController::class, 'demo'])->name('demo'); + + // API 라우트 + Route::prefix('api')->name('api.')->group(function () { + // 통계 + Route::get('/stats', [EsignVerificationController::class, 'stats'])->name('stats'); + + // 확인 템플릿 CRUD + Route::get('/templates', [EsignVerificationController::class, 'indexTemplates'])->name('templates.index'); + Route::post('/templates', [EsignVerificationController::class, 'storeTemplate'])->name('templates.store'); + Route::get('/templates/{id}', [EsignVerificationController::class, 'showTemplate'])->whereNumber('id')->name('templates.show'); + Route::put('/templates/{id}', [EsignVerificationController::class, 'updateTemplate'])->whereNumber('id')->name('templates.update'); + Route::delete('/templates/{id}', [EsignVerificationController::class, 'destroyTemplate'])->whereNumber('id')->name('templates.destroy'); + + // 데모 (인식 테스트) + Route::post('/demo', [EsignVerificationController::class, 'demoRecognize'])->name('demo'); + + // 검증 이력 + Route::get('/history', [EsignVerificationController::class, 'verificationHistory'])->name('history'); + }); +}); + /* |-------------------------------------------------------------------------- | YouTube Shorts AI Generator (Veo 3.1)