$token]); } public function sign(string $token): View { return view('esign.sign.sign', ['token' => $token]); } public function done(string $token): View { return view('esign.sign.done', ['token' => $token]); } // ─── 서명 API (토큰 기반, 비인증) ─── /** * 토큰으로 계약/서명자 정보 조회 */ public function getContract(string $token): JsonResponse { $signer = EsignSigner::withoutGlobalScopes() ->where('access_token', $token) ->first(); if (! $signer) { return response()->json(['success' => false, 'message' => '유효하지 않은 서명 링크입니다.'], 404); } if ($signer->token_expires_at && $signer->token_expires_at->isPast()) { return response()->json(['success' => false, 'message' => '서명 링크가 만료되었습니다.'], 400); } $contract = EsignContract::withoutGlobalScopes() ->with(['signers:id,contract_id,name,role,status,signed_at']) ->find($signer->contract_id); if (! $contract) { return response()->json(['success' => false, 'message' => '계약을 찾을 수 없습니다.'], 404); } // 서명 가능 여부 판별 $isSignable = in_array($contract->status, ['pending', 'partially_signed']) && in_array($signer->status, ['notified', 'viewing', 'authenticated']); $statusMessage = null; $unsignableStatuses = [ 'draft' => '아직 발송되지 않은 계약입니다.', 'cancelled' => '취소된 계약입니다.', 'rejected' => '거절된 계약입니다.', ]; if (isset($unsignableStatuses[$contract->status])) { $statusMessage = $unsignableStatuses[$contract->status]; } elseif ($signer->status === 'signed') { $statusMessage = '이미 서명을 완료하였습니다.'; } // 서명 가능한 상태에서만 감사 로그 기록 if ($isSignable) { EsignAuditLog::create([ 'tenant_id' => $contract->tenant_id, 'contract_id' => $contract->id, 'signer_id' => $signer->id, 'action' => 'viewed', 'ip_address' => request()->ip(), 'user_agent' => request()->userAgent(), 'created_at' => now(), ]); } return response()->json([ 'success' => true, 'data' => [ 'contract' => $contract, 'signer' => [ 'id' => $signer->id, 'name' => $signer->name, 'email' => $signer->email, 'role' => $signer->role, 'status' => $signer->status, 'has_stamp' => (bool) $signer->signature_image_path, 'signed_at' => $signer->signed_at, ], 'is_signable' => $isSignable, 'status_message' => $statusMessage, ], ]); } /** * OTP 발송 */ public function sendOtp(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 || ! in_array($contract->status, ['pending', 'partially_signed'])) { return response()->json(['success' => false, 'message' => '서명할 수 없는 상태입니다.'], 400); } $otpCode = str_pad(random_int(0, 999999), 6, '0', STR_PAD_LEFT); $signer->update([ 'otp_code' => $otpCode, 'otp_expires_at' => now()->addMinutes(5), 'otp_attempts' => 0, ]); // OTP 이메일 발송 \Illuminate\Support\Facades\Mail::to($signer->email)->send( new \App\Mail\EsignOtpMail($signer->name, $otpCode) ); EsignAuditLog::create([ 'tenant_id' => $contract->tenant_id, 'contract_id' => $contract->id, 'signer_id' => $signer->id, 'action' => 'otp_sent', 'ip_address' => request()->ip(), 'user_agent' => request()->userAgent(), 'metadata' => ['email' => $signer->email], 'created_at' => now(), ]); return response()->json(['success' => true, 'message' => '인증 코드가 발송되었습니다.']); } /** * OTP 검증 */ public function verifyOtp(Request $request, string $token): JsonResponse { $signer = $this->findSigner($token); if (! $signer) { return response()->json(['success' => false, 'message' => '유효하지 않은 서명 링크입니다.'], 404); } if ($signer->otp_attempts >= 5) { return response()->json(['success' => false, 'message' => '인증 시도 횟수를 초과했습니다.'], 400); } if (! $signer->otp_code || ! $signer->otp_expires_at) { return response()->json(['success' => false, 'message' => '인증 코드가 발송되지 않았습니다.'], 400); } if ($signer->otp_expires_at->isPast()) { return response()->json(['success' => false, 'message' => '인증 코드가 만료되었습니다.'], 400); } $signer->increment('otp_attempts'); $otpCode = $request->input('otp_code'); if ($signer->otp_code !== $otpCode) { return response()->json(['success' => false, 'message' => '인증 코드가 올바르지 않습니다.'], 400); } $contract = EsignContract::withoutGlobalScopes()->find($signer->contract_id); $signer->update([ 'auth_verified_at' => now(), 'otp_code' => null, 'otp_expires_at' => null, 'status' => 'authenticated', ]); EsignAuditLog::create([ 'tenant_id' => $contract->tenant_id, 'contract_id' => $contract->id, 'signer_id' => $signer->id, 'action' => 'authenticated', 'ip_address' => request()->ip(), 'user_agent' => request()->userAgent(), 'created_at' => now(), ]); return response()->json([ 'success' => true, 'data' => ['sign_session_token' => Str::random(64)], ]); } /** * 서명 제출 */ public function submitSignature(Request $request, string $token): JsonResponse { $signer = $this->findSigner($token); if (! $signer) { return response()->json(['success' => false, 'message' => '유효하지 않은 서명 링크입니다.'], 404); } if (! in_array($signer->status, ['authenticated', 'viewing'])) { return response()->json(['success' => false, 'message' => '서명할 수 없는 상태입니다.'], 400); } $contract = EsignContract::withoutGlobalScopes()->find($signer->contract_id); if (! $contract || ! in_array($contract->status, ['pending', 'partially_signed'])) { return response()->json(['success' => false, 'message' => '서명할 수 없는 계약 상태입니다.'], 400); } $useStamp = $request->boolean('use_stamp'); if ($useStamp) { // 도장 사용: 이미 저장된 stamp 이미지를 그대로 사용 if (!$signer->signature_image_path) { return response()->json(['success' => false, 'message' => '등록된 도장 이미지가 없습니다.'], 422); } // 기존 signature_image_path 유지, 새 이미지 저장 안 함 } else { // 직접 서명: signature_image 필수 $signatureImage = $request->input('signature_image'); if (!$signatureImage) { return response()->json(['success' => false, 'message' => '서명 이미지가 필요합니다.'], 422); } // 서명 이미지 저장 $imageData = base64_decode($signatureImage); $imagePath = "esign/{$contract->tenant_id}/signatures/{$contract->id}_{$signer->id}_" . time() . '.png'; Storage::disk('local')->put($imagePath, $imageData); $signer->update(['signature_image_path' => $imagePath]); } // 서명자 상태 업데이트 $signer->update([ 'signed_at' => now(), 'consent_agreed_at' => now(), 'sign_ip_address' => $request->ip(), 'sign_user_agent' => $request->userAgent(), 'status' => 'signed', ]); // 감사 로그 EsignAuditLog::create([ 'tenant_id' => $contract->tenant_id, 'contract_id' => $contract->id, 'signer_id' => $signer->id, 'action' => 'signed', 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent(), 'created_at' => now(), ]); // 모든 서명자가 서명 완료했는지 확인 $allSigners = EsignSigner::withoutGlobalScopes() ->where('contract_id', $contract->id) ->get(); $allSigned = $allSigners->every(fn ($s) => $s->status === 'signed'); if ($allSigned) { $contract->update([ 'status' => 'completed', 'completed_at' => now(), ]); // PDF에 서명 이미지/텍스트 합성 try { $pdfService = new PdfSignatureService(); $pdfService->mergeSignatures($contract); } catch (\Throwable $e) { Log::error('PDF 서명 합성 실패', [ 'contract_id' => $contract->id, 'error' => $e->getMessage(), ]); } // 계약 완료 감사 로그 EsignAuditLog::create([ 'tenant_id' => $contract->tenant_id, 'contract_id' => $contract->id, 'signer_id' => $signer->id, 'action' => 'contract_completed', 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent(), 'metadata' => ['total_signers' => $allSigners->count()], 'created_at' => now(), ]); // 모든 서명자에게 완료 이메일 발송 foreach ($allSigners as $completedSigner) { try { Mail::to($completedSigner->email)->send( new EsignCompletedMail($contract, $completedSigner, $allSigners) ); EsignAuditLog::create([ 'tenant_id' => $contract->tenant_id, 'contract_id' => $contract->id, 'signer_id' => $completedSigner->id, 'action' => 'completion_email_sent', 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent(), 'metadata' => ['email' => $completedSigner->email], 'created_at' => now(), ]); } catch (\Throwable $e) { Log::error('계약 완료 이메일 발송 실패', [ 'contract_id' => $contract->id, 'signer_id' => $completedSigner->id, 'error' => $e->getMessage(), ]); } } } else { $contract->update(['status' => 'partially_signed']); // 다음 서명자에게 자동 알림 발송 (순차 서명) $nextSigner = EsignSigner::withoutGlobalScopes() ->where('contract_id', $contract->id) ->whereIn('status', ['waiting', 'pending']) ->orderBy('sign_order') ->first(); if ($nextSigner) { $nextSigner->update(['status' => 'notified']); Mail::to($nextSigner->email)->send(new EsignRequestMail($contract, $nextSigner)); EsignAuditLog::create([ 'tenant_id' => $contract->tenant_id, 'contract_id' => $contract->id, 'signer_id' => $nextSigner->id, 'action' => 'sign_request_sent', 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent(), 'metadata' => ['triggered_by' => 'auto_after_sign'], 'created_at' => now(), ]); } } return response()->json(['success' => true, 'message' => '서명이 완료되었습니다.']); } /** * 계약 거절 */ public function rejectContract(Request $request, string $token): JsonResponse { $signer = $this->findSigner($token); if (! $signer) { return response()->json(['success' => false, 'message' => '유효하지 않은 서명 링크입니다.'], 404); } $reason = $request->input('reason', ''); $contract = EsignContract::withoutGlobalScopes()->find($signer->contract_id); $signer->update([ 'status' => 'rejected', 'rejected_reason' => $reason, ]); $contract->update(['status' => 'rejected']); EsignAuditLog::create([ 'tenant_id' => $contract->tenant_id, 'contract_id' => $contract->id, 'signer_id' => $signer->id, 'action' => 'rejected', 'ip_address' => $request->ip(), 'user_agent' => $request->userAgent(), 'metadata' => ['reason' => $reason], 'created_at' => now(), ]); return response()->json(['success' => true, 'message' => '서명이 거절되었습니다.']); } /** * 계약 문서 다운로드 */ public function downloadDocument(string $token): StreamedResponse|JsonResponse { $signer = $this->findSigner($token); if (! $signer) { return response()->json(['success' => false, 'message' => '유효하지 않은 서명 링크입니다.'], 404); } $contract = EsignContract::withoutGlobalScopes()->find($signer->contract_id); if (! $contract || ! $contract->original_file_path) { return response()->json(['success' => false, 'message' => '문서를 찾을 수 없습니다.'], 404); } // 서명 완료된 PDF가 있으면 우선 제공 if ($contract->signed_file_path && Storage::disk('local')->exists($contract->signed_file_path)) { $filePath = $contract->signed_file_path; } else { // 서명 전: 텍스트/날짜/체크박스 필드가 합성된 미리보기 PDF 생성 try { $pdfService = new PdfSignatureService(); $filePath = $pdfService->generatePreview($contract); } catch (\Throwable $e) { Log::warning('미리보기 PDF 생성 실패, 원본 제공', ['error' => $e->getMessage()]); $filePath = $contract->original_file_path; } } if (! Storage::disk('local')->exists($filePath)) { return response()->json(['success' => false, 'message' => '문서 파일이 존재하지 않습니다.'], 404); } $fileName = $contract->original_file_name ?: ($contract->title . '.pdf'); return Storage::disk('local')->download($filePath, $fileName, [ 'Content-Type' => 'application/pdf', ]); } // ─── Private ─── private function findSigner(string $token): ?EsignSigner { $signer = EsignSigner::withoutGlobalScopes() ->where('access_token', $token) ->first(); if ($signer && $signer->token_expires_at && $signer->token_expires_at->isPast()) { return null; } return $signer; } }