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:
@@ -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,
|
||||
]);
|
||||
|
||||
@@ -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'] ?? []),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user