feat:E-Sign 전자계약 서명 솔루션 백엔드 구현

- 마이그레이션 4개 (esign_contracts, esign_signers, esign_sign_fields, esign_audit_logs)
- 모델 4개 (EsignContract, EsignSigner, EsignSignField, EsignAuditLog)
- 서비스 4개 (EsignContractService, EsignSignService, EsignPdfService, EsignAuditService)
- 컨트롤러 2개 (EsignContractController, EsignSignController)
- FormRequest 4개 (ContractStore, FieldConfigure, SignSubmit, SignReject)
- Mail 1개 (EsignRequestMail + 이메일 템플릿)
- API 라우트 (인증 계약 관리 + 토큰 기반 서명 프로세스)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-12 07:02:39 +09:00
parent 818f764aa5
commit 6958be1fd8
22 changed files with 1673 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('esign_contracts', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->onDelete('cascade')->comment('테넌트 ID');
$table->string('contract_code', 50)->unique()->comment('계약 코드');
$table->string('title', 200)->comment('계약 제목');
$table->text('description')->nullable()->comment('계약 설명');
$table->enum('sign_order_type', ['counterpart_first', 'creator_first'])->default('counterpart_first')->comment('서명 순서 (counterpart_first: 상대방 먼저, creator_first: 작성자 먼저)');
$table->string('original_file_path', 500)->nullable()->comment('원본 PDF 파일 경로');
$table->string('original_file_name', 255)->nullable()->comment('원본 파일명');
$table->string('original_file_hash', 64)->nullable()->comment('원본 파일 SHA-256 해시');
$table->unsignedBigInteger('original_file_size')->nullable()->comment('원본 파일 크기 (bytes)');
$table->string('signed_file_path', 500)->nullable()->comment('서명 완료 PDF 파일 경로');
$table->string('signed_file_hash', 64)->nullable()->comment('서명 완료 파일 SHA-256 해시');
$table->enum('status', ['draft', 'pending', 'partially_signed', 'completed', 'expired', 'cancelled', 'rejected'])->default('draft')->comment('계약 상태');
$table->timestamp('expires_at')->nullable()->comment('계약 만료일시');
$table->timestamp('completed_at')->nullable()->comment('계약 완료일시');
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()->comment('생성자');
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete()->comment('수정자');
$table->foreignId('deleted_by')->nullable()->constrained('users')->nullOnDelete()->comment('삭제자');
$table->softDeletes();
$table->timestamps();
$table->index('tenant_id', 'idx_esign_contracts_tenant');
$table->index('status', 'idx_esign_contracts_status');
$table->index('expires_at', 'idx_esign_contracts_expires');
});
}
public function down(): void
{
Schema::dropIfExists('esign_contracts');
}
};

View File

@@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('esign_signers', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->onDelete('cascade')->comment('테넌트 ID');
$table->foreignId('contract_id')->constrained('esign_contracts')->onDelete('cascade')->comment('계약 ID');
$table->enum('role', ['creator', 'counterpart'])->comment('서명자 역할');
$table->tinyInteger('sign_order')->default(1)->comment('서명 순서');
$table->string('name', 100)->comment('서명자 이름');
$table->string('email', 255)->comment('서명자 이메일');
$table->string('phone', 20)->nullable()->comment('서명자 전화번호');
$table->string('access_token', 128)->unique()->comment('서명 접근 토큰');
$table->timestamp('token_expires_at')->nullable()->comment('토큰 만료일시');
$table->string('otp_code', 6)->nullable()->comment('OTP 인증코드');
$table->timestamp('otp_expires_at')->nullable()->comment('OTP 만료일시');
$table->tinyInteger('otp_attempts')->default(0)->comment('OTP 시도 횟수');
$table->timestamp('auth_verified_at')->nullable()->comment('본인인증 완료일시');
$table->string('signature_image_path', 500)->nullable()->comment('서명 이미지 경로');
$table->timestamp('signed_at')->nullable()->comment('서명 완료일시');
$table->timestamp('consent_agreed_at')->nullable()->comment('동의 일시');
$table->string('sign_ip_address', 45)->nullable()->comment('서명 시 IP 주소');
$table->string('sign_user_agent', 500)->nullable()->comment('서명 시 User Agent');
$table->enum('status', ['waiting', 'notified', 'authenticated', 'signed', 'rejected'])->default('waiting')->comment('서명자 상태');
$table->text('rejected_reason')->nullable()->comment('거절 사유');
$table->timestamps();
$table->index('tenant_id', 'idx_esign_signers_tenant');
$table->index('contract_id', 'idx_esign_signers_contract');
$table->index('status', 'idx_esign_signers_status');
});
}
public function down(): void
{
Schema::dropIfExists('esign_signers');
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('esign_sign_fields', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->onDelete('cascade')->comment('테넌트 ID');
$table->foreignId('contract_id')->constrained('esign_contracts')->onDelete('cascade')->comment('계약 ID');
$table->foreignId('signer_id')->constrained('esign_signers')->onDelete('cascade')->comment('서명자 ID');
$table->unsignedSmallInteger('page_number')->comment('페이지 번호');
$table->decimal('position_x', 8, 2)->comment('X 좌표 (%)');
$table->decimal('position_y', 8, 2)->comment('Y 좌표 (%)');
$table->decimal('width', 8, 2)->comment('너비 (%)');
$table->decimal('height', 8, 2)->comment('높이 (%)');
$table->enum('field_type', ['signature', 'stamp', 'text', 'date', 'checkbox'])->default('signature')->comment('필드 유형');
$table->string('field_label', 100)->nullable()->comment('필드 라벨');
$table->text('field_value')->nullable()->comment('필드 값');
$table->boolean('is_required')->default(true)->comment('필수 여부');
$table->unsignedSmallInteger('sort_order')->default(0)->comment('정렬 순서');
$table->timestamps();
$table->index('contract_id', 'idx_esign_fields_contract');
$table->index('signer_id', 'idx_esign_fields_signer');
});
}
public function down(): void
{
Schema::dropIfExists('esign_sign_fields');
}
};

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('esign_audit_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->onDelete('cascade')->comment('테넌트 ID');
$table->foreignId('contract_id')->constrained('esign_contracts')->onDelete('cascade')->comment('계약 ID');
$table->foreignId('signer_id')->nullable()->constrained('esign_signers')->nullOnDelete()->comment('서명자 ID');
$table->string('action', 50)->comment('액션 (created, sent, viewed, otp_sent, authenticated, signed, rejected, completed, cancelled, reminded)');
$table->string('ip_address', 45)->nullable()->comment('IP 주소');
$table->string('user_agent', 500)->nullable()->comment('User Agent');
$table->json('metadata')->nullable()->comment('추가 메타데이터');
$table->timestamp('created_at')->useCurrent()->comment('생성일시');
$table->index('contract_id', 'idx_esign_audit_contract');
$table->index(['contract_id', 'action'], 'idx_esign_audit_contract_action');
});
}
public function down(): void
{
Schema::dropIfExists('esign_audit_logs');
}
};