feat: [esign] 전자서명 고도화 — 서명 플로우 연동 + 계약 생성 마법사 + 감사 로그

- esign_contracts: verification_required, verification_template_id 컬럼 추가
- esign_signers: verification_status, verification_passed_at 컬럼 추가
- 서명 플로우: OTP 인증 → 필기 확인(verification.blade.php) → 서명/도장
- auth.blade.php: verification_required 시 필기 확인 페이지로 리다이렉트
- create.blade.php: 필기 확인 사용 토글 + 템플릿 선택 UI
- EsignApiController: store()에 verification 필드 저장
- EsignPublicController: getVerification(), submitVerification() 추가
- 감사 로그: verification_viewed, verification_attempted, verification_passed, verification_completed 액션
This commit is contained in:
김보곤
2026-03-22 22:54:27 +09:00
parent 4839aa91b0
commit 6e77ae64ca
8 changed files with 594 additions and 1 deletions

View File

@@ -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,
]);

View File

@@ -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'] ?? []),
],
]);
}
}