2026-02-12 07:02:48 +09:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Http\Controllers\ESign;
|
|
|
|
|
|
|
|
|
|
use App\Http\Controllers\Controller;
|
2026-02-12 16:26:28 +09:00
|
|
|
use App\Models\ESign\EsignAuditLog;
|
|
|
|
|
use App\Models\ESign\EsignContract;
|
|
|
|
|
use App\Models\ESign\EsignSigner;
|
|
|
|
|
use Illuminate\Http\JsonResponse;
|
2026-02-12 07:02:48 +09:00
|
|
|
use Illuminate\Http\Request;
|
2026-02-12 16:38:21 +09:00
|
|
|
use Illuminate\Support\Facades\Storage;
|
2026-02-12 16:26:28 +09:00
|
|
|
use Illuminate\Support\Str;
|
2026-02-12 07:02:48 +09:00
|
|
|
use Illuminate\View\View;
|
2026-02-12 16:38:21 +09:00
|
|
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
2026-02-12 07:02:48 +09:00
|
|
|
|
|
|
|
|
class EsignPublicController extends Controller
|
|
|
|
|
{
|
2026-02-12 16:26:28 +09:00
|
|
|
// ─── 화면 라우트 ───
|
|
|
|
|
|
2026-02-12 07:02:48 +09:00
|
|
|
public function auth(string $token): View
|
|
|
|
|
{
|
|
|
|
|
return view('esign.sign.auth', ['token' => $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]);
|
|
|
|
|
}
|
2026-02-12 16:26:28 +09:00
|
|
|
|
|
|
|
|
// ─── 서명 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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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)],
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 16:38:21 +09:00
|
|
|
/**
|
|
|
|
|
* 서명 제출
|
|
|
|
|
*/
|
|
|
|
|
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',
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 16:26:28 +09:00
|
|
|
// ─── 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;
|
|
|
|
|
}
|
2026-02-12 07:02:48 +09:00
|
|
|
}
|