diff --git a/app/Http/Controllers/ESign/EsignPublicController.php b/app/Http/Controllers/ESign/EsignPublicController.php index c6f5716b..5552485e 100644 --- a/app/Http/Controllers/ESign/EsignPublicController.php +++ b/app/Http/Controllers/ESign/EsignPublicController.php @@ -8,8 +8,10 @@ use App\Models\ESign\EsignSigner; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Illuminate\View\View; +use Symfony\Component\HttpFoundation\StreamedResponse; class EsignPublicController extends Controller { @@ -178,6 +180,134 @@ public function verifyOtp(Request $request, string $token): JsonResponse ]); } + /** + * 서명 제출 + */ + 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); + } + + $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, + '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(), + ]); + } else { + $contract->update(['status' => 'partially_signed']); + } + + 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); + } + + if (! Storage::disk('local')->exists($contract->original_file_path)) { + return response()->json(['success' => false, 'message' => '문서 파일이 존재하지 않습니다.'], 404); + } + + $fileName = $contract->original_file_name ?: ($contract->title . '.pdf'); + + return Storage::disk('local')->download($contract->original_file_path, $fileName, [ + 'Content-Type' => 'application/pdf', + ]); + } + // ─── Private ─── private function findSigner(string $token): ?EsignSigner diff --git a/resources/views/esign/sign/done.blade.php b/resources/views/esign/sign/done.blade.php index 77cb1aba..eb0a4043 100644 --- a/resources/views/esign/sign/done.blade.php +++ b/resources/views/esign/sign/done.blade.php @@ -19,8 +19,6 @@ const { useState, useEffect } = React; const TOKEN = document.getElementById('esign-done-root')?.dataset.token; -const API = window.SAM_CONFIG?.apiBaseUrl || '{{ config("services.api.base_url", "") }}'; -const API_KEY = '{{ config("services.api.key", "") }}'; const App = () => { const [contract, setContract] = useState(null); @@ -30,8 +28,8 @@ useEffect(() => { (async () => { try { - const res = await fetch(`${API}/api/v1/esign/sign/${TOKEN}`, { - headers: { 'Accept': 'application/json', 'X-API-Key': API_KEY }, + const res = await fetch(`/esign/sign/${TOKEN}/api/contract`, { + headers: { 'Accept': 'application/json' }, }); const json = await res.json(); if (json.success) { diff --git a/resources/views/esign/sign/sign.blade.php b/resources/views/esign/sign/sign.blade.php index 2dc136b4..fe1cc1a4 100644 --- a/resources/views/esign/sign/sign.blade.php +++ b/resources/views/esign/sign/sign.blade.php @@ -20,8 +20,13 @@ const { useState, useEffect, useCallback, useRef } = React; const TOKEN = document.getElementById('esign-sign-root')?.dataset.token; -const API = window.SAM_CONFIG?.apiBaseUrl || '{{ config("services.api.base_url", "") }}'; -const API_KEY = '{{ config("services.api.key", "") }}'; + +// 인라인 스타일 (Blade {{ }} 충돌 방지) +const STYLES = { + canvasWrap: { height: '200px' }, + canvas: { width: '100%', height: '100%', touchAction: 'none' }, + previewWrap: { height: '200px' }, +}; const App = () => { const [contract, setContract] = useState(null); @@ -37,8 +42,8 @@ // 계약 정보 로드 const fetchContract = useCallback(async () => { try { - const res = await fetch(`${API}/api/v1/esign/sign/${TOKEN}`, { - headers: { 'Accept': 'application/json', 'X-API-Key': API_KEY }, + const res = await fetch(`/esign/sign/${TOKEN}/api/contract`, { + headers: { 'Accept': 'application/json' }, }); const json = await res.json(); if (json.success) { @@ -88,9 +93,9 @@ setSubmitting(true); setError(''); try { - const res = await fetch(`${API}/api/v1/esign/sign/${TOKEN}/submit`, { + const res = await fetch(`/esign/sign/${TOKEN}/api/submit`, { method: 'POST', - headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', 'X-API-Key': API_KEY }, + headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ signature_image: signatureData }), }); const json = await res.json(); @@ -108,9 +113,9 @@ const reason = prompt('거절 사유를 입력해 주세요:'); if (!reason) return; try { - const res = await fetch(`${API}/api/v1/esign/sign/${TOKEN}/reject`, { + const res = await fetch(`/esign/sign/${TOKEN}/api/reject`, { method: 'POST', - headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', 'X-API-Key': API_KEY }, + headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ reason }), }); const json = await res.json(); @@ -153,7 +158,7 @@

계약 문서 확인

PDF 문서

- 문서 열기 / 다운로드 @@ -185,8 +190,8 @@ className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-

아래 영역에 서명을 입력해 주세요.

-
- +
+
@@ -206,7 +211,7 @@ className="w-full py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-

서명 확인

아래 서명이 맞는지 확인해 주세요.

-
+
{signatureData && 서명}
diff --git a/routes/web.php b/routes/web.php index 82bf7843..103855cb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1431,4 +1431,7 @@ Route::get('/{token}/api/contract', [EsignPublicController::class, 'getContract'])->name('esign.sign.api.contract'); Route::post('/{token}/api/otp/send', [EsignPublicController::class, 'sendOtp'])->name('esign.sign.api.otp.send'); Route::post('/{token}/api/otp/verify', [EsignPublicController::class, 'verifyOtp'])->name('esign.sign.api.otp.verify'); + Route::post('/{token}/api/submit', [EsignPublicController::class, 'submitSignature'])->name('esign.sign.api.submit'); + Route::post('/{token}/api/reject', [EsignPublicController::class, 'rejectContract'])->name('esign.sign.api.reject'); + Route::get('/{token}/api/document', [EsignPublicController::class, 'downloadDocument'])->name('esign.sign.api.document'); });