where('access_token', $token) ->first(); if (! $signer) { throw new NotFoundHttpException(__('error.esign.invalid_token')); } if ($signer->token_expires_at && $signer->token_expires_at->isPast()) { throw new BadRequestHttpException(__('error.esign.token_expired')); } $contract = EsignContract::withoutGlobalScopes() ->with(['signers:id,contract_id,name,role,status,signed_at']) ->find($signer->contract_id); if (! $contract) { throw new NotFoundHttpException(__('error.not_found')); } $this->auditService->logPublic( $contract->tenant_id, $contract->id, EsignAuditLog::ACTION_VIEWED, $signer->id ); return [ 'contract' => $contract, 'signer' => $signer, ]; } public function sendOtp(string $token): array { $signer = $this->findSignerByToken($token); $contract = EsignContract::withoutGlobalScopes()->find($signer->contract_id); if (! $contract || ! $contract->canSign()) { throw new BadRequestHttpException(__('error.esign.contract_not_signable')); } // OTP 생성 (6자리) $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 이메일 발송 (실제 발송은 추후 Mail 클래스에서) // 현재는 개발 편의를 위해 로그에만 기록 \Illuminate\Support\Facades\Log::info("E-Sign OTP: {$otpCode} for {$signer->email}"); $this->auditService->logPublic( $contract->tenant_id, $contract->id, EsignAuditLog::ACTION_OTP_SENT, $signer->id, ['email' => $signer->email] ); return ['message' => __('message.esign.otp_sent')]; } public function verifyOtp(string $token, string $otpCode): array { $signer = $this->findSignerByToken($token); if ($signer->otp_attempts >= 5) { throw new BadRequestHttpException(__('error.esign.otp_max_attempts')); } if (! $signer->otp_code || ! $signer->otp_expires_at) { throw new BadRequestHttpException(__('error.esign.otp_not_sent')); } if ($signer->otp_expires_at->isPast()) { throw new BadRequestHttpException(__('error.esign.otp_expired')); } $signer->increment('otp_attempts'); if ($signer->otp_code !== $otpCode) { throw new BadRequestHttpException(__('error.esign.otp_invalid')); } $contract = EsignContract::withoutGlobalScopes()->find($signer->contract_id); $signer->update([ 'auth_verified_at' => now(), 'otp_code' => null, 'otp_expires_at' => null, 'status' => EsignSigner::STATUS_AUTHENTICATED, ]); $this->auditService->logPublic( $contract->tenant_id, $contract->id, EsignAuditLog::ACTION_AUTHENTICATED, $signer->id ); // sign_session_token 발급 (JWT 대신 단순 토큰) $sessionToken = Str::random(64); return [ 'sign_session_token' => $sessionToken, 'signer' => $signer->fresh(), ]; } public function submitSignature(string $token, array $data): EsignSigner { $signer = $this->findSignerByToken($token); if (! $signer->isVerified()) { throw new BadRequestHttpException(__('error.esign.not_verified')); } if ($signer->hasSigned()) { throw new BadRequestHttpException(__('error.esign.already_signed')); } $contract = EsignContract::withoutGlobalScopes()->find($signer->contract_id); if (! $contract || ! $contract->canSign()) { throw new BadRequestHttpException(__('error.esign.contract_not_signable')); } return DB::transaction(function () use ($signer, $contract, $data) { // 서명 이미지 저장 $signatureImagePath = null; if (! empty($data['signature_image'])) { $imageData = base64_decode($data['signature_image']); $signatureImagePath = "esign/{$contract->tenant_id}/signatures/{$contract->id}_{$signer->id}.png"; \Illuminate\Support\Facades\Storage::disk('local')->put($signatureImagePath, $imageData); } $request = request(); $signer->update([ 'signature_image_path' => $signatureImagePath, 'signed_at' => now(), 'consent_agreed_at' => now(), 'sign_ip_address' => $request->ip(), 'sign_user_agent' => mb_substr($request->userAgent() ?? '', 0, 500), 'status' => EsignSigner::STATUS_SIGNED, ]); $this->auditService->logPublic( $contract->tenant_id, $contract->id, EsignAuditLog::ACTION_SIGNED, $signer->id, ['ip' => $request->ip()] ); // 완료 여부 확인 및 처리 $this->checkAndComplete($contract); return $signer->fresh(); }); } public function reject(string $token, string $reason): EsignSigner { $signer = $this->findSignerByToken($token); if ($signer->hasSigned()) { throw new BadRequestHttpException(__('error.esign.already_signed')); } $contract = EsignContract::withoutGlobalScopes()->find($signer->contract_id); return DB::transaction(function () use ($signer, $contract, $reason) { $signer->update([ 'status' => EsignSigner::STATUS_REJECTED, 'rejected_reason' => $reason, ]); $contract->update([ 'status' => EsignContract::STATUS_REJECTED, ]); $this->auditService->logPublic( $contract->tenant_id, $contract->id, EsignAuditLog::ACTION_REJECTED, $signer->id, ['reason' => $reason] ); return $signer->fresh(); }); } private function checkAndComplete(EsignContract $contract): void { $allSigned = $contract->signers() ->where('status', '!=', EsignSigner::STATUS_SIGNED) ->doesntExist(); if ($allSigned) { // 모든 서명 완료 $contract->update([ 'status' => EsignContract::STATUS_COMPLETED, 'completed_at' => now(), ]); $this->auditService->logPublic( $contract->tenant_id, $contract->id, EsignAuditLog::ACTION_COMPLETED ); } else { // 부분 서명 상태 업데이트 $signedCount = $contract->signers()->where('status', EsignSigner::STATUS_SIGNED)->count(); if ($signedCount > 0 && $contract->status === EsignContract::STATUS_PENDING) { $contract->update(['status' => EsignContract::STATUS_PARTIALLY_SIGNED]); } // 다음 서명자에게 알림 $nextSigner = $contract->getNextSigner(); if ($nextSigner && $nextSigner->status === EsignSigner::STATUS_WAITING) { $nextSigner->update(['status' => EsignSigner::STATUS_NOTIFIED]); Mail::to($nextSigner->email)->queue( new EsignRequestMail($contract, $nextSigner) ); } } } private function findSignerByToken(string $token): EsignSigner { $signer = EsignSigner::withoutGlobalScopes() ->where('access_token', $token) ->first(); if (! $signer) { throw new NotFoundHttpException(__('error.esign.invalid_token')); } if ($signer->token_expires_at && $signer->token_expires_at->isPast()) { throw new BadRequestHttpException(__('error.esign.token_expired')); } return $signer; } }