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,116 @@
<?php
namespace App\Http\Controllers\Api\V1\ESign;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\ESign\ContractStoreRequest;
use App\Http\Requests\ESign\FieldConfigureRequest;
use App\Services\ESign\EsignContractService;
use App\Services\ESign\EsignPdfService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class EsignContractController extends Controller
{
public function __construct(
private EsignContractService $service,
private EsignPdfService $pdfService,
) {}
public function index(Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
return $this->service->list($request->all());
}, __('message.fetched'));
}
public function show(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->show($id);
}, __('message.fetched'));
}
public function store(ContractStoreRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($request) {
return $this->service->create($request->validated() + ['file' => $request->file('file')]);
}, __('message.created'));
}
public function cancel(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->cancel($id);
}, __('message.esign.cancelled'));
}
public function configureFields(int $id, FieldConfigureRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($id, $request) {
return $this->service->configureFields($id, $request->validated()['fields']);
}, __('message.esign.fields_configured'));
}
public function send(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->send($id);
}, __('message.esign.sent'));
}
public function remind(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
return $this->service->remind($id);
}, __('message.esign.reminded'));
}
public function stats(): JsonResponse
{
return ApiResponse::handle(function () {
return $this->service->stats();
}, __('message.fetched'));
}
public function download(int $id): \Symfony\Component\HttpFoundation\StreamedResponse|JsonResponse
{
try {
$contract = $this->service->show($id);
$filePath = $contract->signed_file_path ?? $contract->original_file_path;
if (! $filePath || ! Storage::disk('local')->exists($filePath)) {
return ApiResponse::error(__('error.esign.file_not_found'), 404);
}
$fileName = $contract->original_file_name ?? 'contract.pdf';
return Storage::disk('local')->download($filePath, $fileName);
} catch (\Throwable $e) {
return ApiResponse::error($e->getMessage(), 500);
}
}
public function verify(int $id): JsonResponse
{
return ApiResponse::handle(function () use ($id) {
$contract = $this->service->show($id);
if (! $contract->original_file_path || ! $contract->original_file_hash) {
return ['verified' => false, 'message' => '파일 정보가 없습니다.'];
}
$isValid = $this->pdfService->verifyIntegrity(
$contract->original_file_path,
$contract->original_file_hash
);
return [
'verified' => $isValid,
'original_hash' => $contract->original_file_hash,
];
}, __('message.esign.verified'));
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace App\Http\Controllers\Api\V1\ESign;
use App\Helpers\ApiResponse;
use App\Http\Controllers\Controller;
use App\Http\Requests\ESign\SignRejectRequest;
use App\Http\Requests\ESign\SignSubmitRequest;
use App\Services\ESign\EsignSignService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
class EsignSignController extends Controller
{
public function __construct(
private EsignSignService $service,
) {}
public function getContract(string $token): JsonResponse
{
return ApiResponse::handle(function () use ($token) {
return $this->service->getByToken($token);
}, __('message.fetched'));
}
public function sendOtp(string $token): JsonResponse
{
return ApiResponse::handle(function () use ($token) {
return $this->service->sendOtp($token);
}, __('message.esign.otp_sent'));
}
public function verifyOtp(string $token, Request $request): JsonResponse
{
return ApiResponse::handle(function () use ($token, $request) {
$request->validate(['otp_code' => 'required|string|size:6']);
return $this->service->verifyOtp($token, $request->input('otp_code'));
}, __('message.esign.otp_verified'));
}
public function getDocument(string $token): \Symfony\Component\HttpFoundation\StreamedResponse|JsonResponse
{
try {
$data = $this->service->getByToken($token);
$contract = $data['contract'];
$filePath = $contract->original_file_path;
if (! $filePath || ! Storage::disk('local')->exists($filePath)) {
return ApiResponse::error(__('error.esign.file_not_found'), 404);
}
return Storage::disk('local')->response($filePath, null, [
'Content-Type' => 'application/pdf',
]);
} catch (\Throwable $e) {
return ApiResponse::error($e->getMessage(), $e->getCode() ?: 500);
}
}
public function submit(string $token, SignSubmitRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($token, $request) {
return $this->service->submitSignature($token, $request->validated());
}, __('message.esign.signed'));
}
public function reject(string $token, SignRejectRequest $request): JsonResponse
{
return ApiResponse::handle(function () use ($token, $request) {
return $this->service->reject($token, $request->validated()['reason']);
}, __('message.esign.rejected'));
}
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Http\Requests\ESign;
use App\Models\ESign\EsignContract;
use Illuminate\Foundation\Http\FormRequest;
class ContractStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => 'required|string|max:200',
'description' => 'nullable|string|max:2000',
'sign_order_type' => 'nullable|string|in:' . implode(',', EsignContract::SIGN_ORDERS),
'file' => 'required|file|mimes:pdf|max:20480',
'expires_at' => 'nullable|date|after:now',
'creator_name' => 'required|string|max:100',
'creator_email' => 'required|email|max:255',
'creator_phone' => 'nullable|string|max:20',
'counterpart_name' => 'required|string|max:100',
'counterpart_email' => 'required|email|max:255',
'counterpart_phone' => 'nullable|string|max:20',
];
}
public function messages(): array
{
return [
'title.required' => __('validation.required', ['attribute' => '계약 제목']),
'file.required' => __('validation.required', ['attribute' => 'PDF 파일']),
'file.mimes' => __('validation.mimes', ['attribute' => '파일', 'values' => 'PDF']),
'file.max' => __('validation.max.file', ['attribute' => '파일', 'max' => '20MB']),
'creator_name.required' => __('validation.required', ['attribute' => '작성자 이름']),
'creator_email.required' => __('validation.required', ['attribute' => '작성자 이메일']),
'counterpart_name.required' => __('validation.required', ['attribute' => '상대방 이름']),
'counterpart_email.required' => __('validation.required', ['attribute' => '상대방 이메일']),
];
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Requests\ESign;
use App\Models\ESign\EsignSignField;
use Illuminate\Foundation\Http\FormRequest;
class FieldConfigureRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'fields' => 'required|array|min:1',
'fields.*.signer_id' => 'required|integer|exists:esign_signers,id',
'fields.*.page_number' => 'required|integer|min:1',
'fields.*.position_x' => 'required|numeric|min:0|max:100',
'fields.*.position_y' => 'required|numeric|min:0|max:100',
'fields.*.width' => 'required|numeric|min:1|max:100',
'fields.*.height' => 'required|numeric|min:1|max:100',
'fields.*.field_type' => 'nullable|string|in:' . implode(',', EsignSignField::FIELD_TYPES),
'fields.*.field_label' => 'nullable|string|max:100',
'fields.*.is_required' => 'nullable|boolean',
'fields.*.sort_order' => 'nullable|integer|min:0',
];
}
public function messages(): array
{
return [
'fields.required' => __('validation.required', ['attribute' => '서명 필드']),
'fields.min' => '최소 1개 이상의 서명 필드가 필요합니다.',
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests\ESign;
use Illuminate\Foundation\Http\FormRequest;
class SignRejectRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'reason' => 'required|string|max:1000',
];
}
public function messages(): array
{
return [
'reason.required' => __('validation.required', ['attribute' => '거절 사유']),
];
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Http\Requests\ESign;
use Illuminate\Foundation\Http\FormRequest;
class SignSubmitRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'signature_image' => 'required|string',
];
}
public function messages(): array
{
return [
'signature_image.required' => __('validation.required', ['attribute' => '서명 이미지']),
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Mail;
use App\Models\ESign\EsignContract;
use App\Models\ESign\EsignSigner;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class EsignRequestMail extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public EsignContract $contract,
public EsignSigner $signer,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: "[SAM] 전자계약 서명 요청 - {$this->contract->title}",
);
}
public function content(): Content
{
$signUrl = config('app.mng_url', config('app.url'))
. '/esign/sign/' . $this->signer->access_token;
return new Content(
html: 'emails.esign.request',
with: [
'contractTitle' => $this->contract->title,
'signerName' => $this->signer->name,
'signUrl' => $signUrl,
'expiresAt' => $this->contract->expires_at?->format('Y-m-d H:i'),
],
);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Models\ESign;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EsignAuditLog extends Model
{
use BelongsToTenant;
protected $table = 'esign_audit_logs';
public $timestamps = false;
// 액션 상수
public const ACTION_CREATED = 'created';
public const ACTION_SENT = 'sent';
public const ACTION_VIEWED = 'viewed';
public const ACTION_OTP_SENT = 'otp_sent';
public const ACTION_AUTHENTICATED = 'authenticated';
public const ACTION_SIGNED = 'signed';
public const ACTION_REJECTED = 'rejected';
public const ACTION_COMPLETED = 'completed';
public const ACTION_CANCELLED = 'cancelled';
public const ACTION_REMINDED = 'reminded';
public const ACTION_DOWNLOADED = 'downloaded';
protected $fillable = [
'tenant_id',
'contract_id',
'signer_id',
'action',
'ip_address',
'user_agent',
'metadata',
'created_at',
];
protected $casts = [
'metadata' => 'array',
'created_at' => 'datetime',
];
// === Relations ===
public function contract(): BelongsTo
{
return $this->belongsTo(EsignContract::class, 'contract_id');
}
public function signer(): BelongsTo
{
return $this->belongsTo(EsignSigner::class, 'signer_id');
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace App\Models\ESign;
use App\Models\Members\User;
use App\Traits\Auditable;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class EsignContract extends Model
{
use Auditable, BelongsToTenant, SoftDeletes;
protected $table = 'esign_contracts';
// 상태 상수
public const STATUS_DRAFT = 'draft';
public const STATUS_PENDING = 'pending';
public const STATUS_PARTIALLY_SIGNED = 'partially_signed';
public const STATUS_COMPLETED = 'completed';
public const STATUS_EXPIRED = 'expired';
public const STATUS_CANCELLED = 'cancelled';
public const STATUS_REJECTED = 'rejected';
public const STATUSES = [
self::STATUS_DRAFT,
self::STATUS_PENDING,
self::STATUS_PARTIALLY_SIGNED,
self::STATUS_COMPLETED,
self::STATUS_EXPIRED,
self::STATUS_CANCELLED,
self::STATUS_REJECTED,
];
// 서명 순서 상수
public const SIGN_ORDER_COUNTERPART_FIRST = 'counterpart_first';
public const SIGN_ORDER_CREATOR_FIRST = 'creator_first';
public const SIGN_ORDERS = [
self::SIGN_ORDER_COUNTERPART_FIRST,
self::SIGN_ORDER_CREATOR_FIRST,
];
protected $fillable = [
'tenant_id',
'contract_code',
'title',
'description',
'sign_order_type',
'original_file_path',
'original_file_name',
'original_file_hash',
'original_file_size',
'signed_file_path',
'signed_file_hash',
'status',
'expires_at',
'completed_at',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'original_file_size' => 'integer',
'expires_at' => 'datetime',
'completed_at' => 'datetime',
];
// === Relations ===
public function signers(): HasMany
{
return $this->hasMany(EsignSigner::class, 'contract_id');
}
public function signFields(): HasMany
{
return $this->hasMany(EsignSignField::class, 'contract_id');
}
public function auditLogs(): HasMany
{
return $this->hasMany(EsignAuditLog::class, 'contract_id');
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
// === Scopes ===
public function scopeStatus($query, string $status)
{
return $query->where('status', $status);
}
public function scopeActive($query)
{
return $query->whereNotIn('status', [
self::STATUS_CANCELLED,
self::STATUS_EXPIRED,
self::STATUS_REJECTED,
]);
}
// === Helpers ===
public function isExpired(): bool
{
return $this->expires_at && $this->expires_at->isPast();
}
public function canSign(): bool
{
return in_array($this->status, [
self::STATUS_PENDING,
self::STATUS_PARTIALLY_SIGNED,
]) && ! $this->isExpired();
}
public function getNextSigner(): ?EsignSigner
{
return $this->signers()
->whereIn('status', [EsignSigner::STATUS_WAITING, EsignSigner::STATUS_NOTIFIED, EsignSigner::STATUS_AUTHENTICATED])
->orderBy('sign_order')
->first();
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Models\ESign;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EsignSignField extends Model
{
use BelongsToTenant;
protected $table = 'esign_sign_fields';
// 필드 유형 상수
public const TYPE_SIGNATURE = 'signature';
public const TYPE_STAMP = 'stamp';
public const TYPE_TEXT = 'text';
public const TYPE_DATE = 'date';
public const TYPE_CHECKBOX = 'checkbox';
public const FIELD_TYPES = [
self::TYPE_SIGNATURE,
self::TYPE_STAMP,
self::TYPE_TEXT,
self::TYPE_DATE,
self::TYPE_CHECKBOX,
];
protected $fillable = [
'tenant_id',
'contract_id',
'signer_id',
'page_number',
'position_x',
'position_y',
'width',
'height',
'field_type',
'field_label',
'field_value',
'is_required',
'sort_order',
];
protected $casts = [
'page_number' => 'integer',
'position_x' => 'decimal:2',
'position_y' => 'decimal:2',
'width' => 'decimal:2',
'height' => 'decimal:2',
'is_required' => 'boolean',
'sort_order' => 'integer',
];
// === Relations ===
public function contract(): BelongsTo
{
return $this->belongsTo(EsignContract::class, 'contract_id');
}
public function signer(): BelongsTo
{
return $this->belongsTo(EsignSigner::class, 'signer_id');
}
}

View File

@@ -0,0 +1,104 @@
<?php
namespace App\Models\ESign;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class EsignSigner extends Model
{
use BelongsToTenant;
protected $table = 'esign_signers';
// 역할 상수
public const ROLE_CREATOR = 'creator';
public const ROLE_COUNTERPART = 'counterpart';
// 상태 상수
public const STATUS_WAITING = 'waiting';
public const STATUS_NOTIFIED = 'notified';
public const STATUS_AUTHENTICATED = 'authenticated';
public const STATUS_SIGNED = 'signed';
public const STATUS_REJECTED = 'rejected';
public const STATUSES = [
self::STATUS_WAITING,
self::STATUS_NOTIFIED,
self::STATUS_AUTHENTICATED,
self::STATUS_SIGNED,
self::STATUS_REJECTED,
];
protected $fillable = [
'tenant_id',
'contract_id',
'role',
'sign_order',
'name',
'email',
'phone',
'access_token',
'token_expires_at',
'otp_code',
'otp_expires_at',
'otp_attempts',
'auth_verified_at',
'signature_image_path',
'signed_at',
'consent_agreed_at',
'sign_ip_address',
'sign_user_agent',
'status',
'rejected_reason',
];
protected $casts = [
'sign_order' => 'integer',
'otp_attempts' => 'integer',
'token_expires_at' => 'datetime',
'otp_expires_at' => 'datetime',
'auth_verified_at' => 'datetime',
'signed_at' => 'datetime',
'consent_agreed_at' => 'datetime',
];
protected $hidden = [
'access_token',
'otp_code',
];
// === Relations ===
public function contract(): BelongsTo
{
return $this->belongsTo(EsignContract::class, 'contract_id');
}
public function signFields(): HasMany
{
return $this->hasMany(EsignSignField::class, 'signer_id');
}
// === Helpers ===
public function isVerified(): bool
{
return $this->auth_verified_at !== null;
}
public function hasSigned(): bool
{
return $this->signed_at !== null;
}
public function canSign(): bool
{
return $this->isVerified()
&& ! $this->hasSigned()
&& $this->status !== self::STATUS_REJECTED
&& $this->contract->canSign();
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Services\ESign;
use App\Models\ESign\EsignAuditLog;
use App\Services\Service;
class EsignAuditService extends Service
{
public function log(
int $contractId,
string $action,
?int $signerId = null,
?array $metadata = null
): EsignAuditLog {
$request = request();
return EsignAuditLog::create([
'tenant_id' => $this->tenantId(),
'contract_id' => $contractId,
'signer_id' => $signerId,
'action' => $action,
'ip_address' => $request?->ip(),
'user_agent' => $request?->userAgent() ? mb_substr($request->userAgent(), 0, 500) : null,
'metadata' => $metadata,
'created_at' => now(),
]);
}
public function logPublic(
int $tenantId,
int $contractId,
string $action,
?int $signerId = null,
?array $metadata = null
): EsignAuditLog {
$request = request();
return EsignAuditLog::withoutGlobalScopes()->create([
'tenant_id' => $tenantId,
'contract_id' => $contractId,
'signer_id' => $signerId,
'action' => $action,
'ip_address' => $request?->ip(),
'user_agent' => $request?->userAgent() ? mb_substr($request->userAgent(), 0, 500) : null,
'metadata' => $metadata,
'created_at' => now(),
]);
}
public function getContractLogs(int $contractId): \Illuminate\Database\Eloquent\Collection
{
return EsignAuditLog::where('contract_id', $contractId)
->with('signer:id,name,email,role')
->orderBy('created_at', 'desc')
->get();
}
}

View File

@@ -0,0 +1,298 @@
<?php
namespace App\Services\ESign;
use App\Mail\EsignRequestMail;
use App\Models\ESign\EsignAuditLog;
use App\Models\ESign\EsignContract;
use App\Models\ESign\EsignSignField;
use App\Models\ESign\EsignSigner;
use App\Services\Service;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class EsignContractService extends Service
{
public function __construct(
private EsignAuditService $auditService,
private EsignPdfService $pdfService,
) {}
public function list(array $params): LengthAwarePaginator
{
$query = EsignContract::query()
->with(['signers:id,contract_id,name,email,role,status,signed_at', 'creator:id,name'])
->orderBy('created_at', 'desc');
if (! empty($params['status'])) {
$query->where('status', $params['status']);
}
if (! empty($params['search'])) {
$search = $params['search'];
$query->where(function ($q) use ($search) {
$q->where('title', 'like', "%{$search}%")
->orWhere('contract_code', 'like', "%{$search}%");
});
}
if (! empty($params['date_from'])) {
$query->whereDate('created_at', '>=', $params['date_from']);
}
if (! empty($params['date_to'])) {
$query->whereDate('created_at', '<=', $params['date_to']);
}
$perPage = $params['per_page'] ?? 20;
return $query->paginate($perPage);
}
public function stats(): array
{
$tenantId = $this->tenantId();
$counts = EsignContract::where('tenant_id', $tenantId)
->selectRaw('status, COUNT(*) as count')
->groupBy('status')
->pluck('count', 'status')
->toArray();
return [
'total' => array_sum($counts),
'draft' => $counts[EsignContract::STATUS_DRAFT] ?? 0,
'pending' => $counts[EsignContract::STATUS_PENDING] ?? 0,
'partially_signed' => $counts[EsignContract::STATUS_PARTIALLY_SIGNED] ?? 0,
'completed' => $counts[EsignContract::STATUS_COMPLETED] ?? 0,
'expired' => $counts[EsignContract::STATUS_EXPIRED] ?? 0,
'cancelled' => $counts[EsignContract::STATUS_CANCELLED] ?? 0,
'rejected' => $counts[EsignContract::STATUS_REJECTED] ?? 0,
];
}
public function create(array $data): EsignContract
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($data, $tenantId, $userId) {
// PDF 파일 저장
$file = $data['file'];
$filePath = $file->store("esign/{$tenantId}/originals", 'local');
$fileHash = hash_file('sha256', $file->getRealPath());
// 계약 코드 생성
$contractCode = 'ES-' . now()->format('Ymd') . '-' . strtoupper(Str::random(6));
// 서명 순서 설정
$signOrderType = $data['sign_order_type'] ?? EsignContract::SIGN_ORDER_COUNTERPART_FIRST;
$contract = EsignContract::create([
'tenant_id' => $tenantId,
'contract_code' => $contractCode,
'title' => $data['title'],
'description' => $data['description'] ?? null,
'sign_order_type' => $signOrderType,
'original_file_path' => $filePath,
'original_file_name' => $file->getClientOriginalName(),
'original_file_hash' => $fileHash,
'original_file_size' => $file->getSize(),
'status' => EsignContract::STATUS_DRAFT,
'expires_at' => $data['expires_at'] ?? now()->addDays(14),
'created_by' => $userId,
'updated_by' => $userId,
]);
// 서명자 생성 - 작성자 (creator)
$creatorOrder = $signOrderType === EsignContract::SIGN_ORDER_CREATOR_FIRST ? 1 : 2;
$counterpartOrder = $signOrderType === EsignContract::SIGN_ORDER_CREATOR_FIRST ? 2 : 1;
EsignSigner::create([
'tenant_id' => $tenantId,
'contract_id' => $contract->id,
'role' => EsignSigner::ROLE_CREATOR,
'sign_order' => $creatorOrder,
'name' => $data['creator_name'],
'email' => $data['creator_email'],
'phone' => $data['creator_phone'] ?? null,
'access_token' => Str::random(128),
'token_expires_at' => $contract->expires_at,
'status' => EsignSigner::STATUS_WAITING,
]);
// 서명자 생성 - 상대방 (counterpart)
EsignSigner::create([
'tenant_id' => $tenantId,
'contract_id' => $contract->id,
'role' => EsignSigner::ROLE_COUNTERPART,
'sign_order' => $counterpartOrder,
'name' => $data['counterpart_name'],
'email' => $data['counterpart_email'],
'phone' => $data['counterpart_phone'] ?? null,
'access_token' => Str::random(128),
'token_expires_at' => $contract->expires_at,
'status' => EsignSigner::STATUS_WAITING,
]);
$this->auditService->log($contract->id, EsignAuditLog::ACTION_CREATED);
return $contract->fresh(['signers', 'creator:id,name']);
});
}
public function show(int $id): EsignContract
{
$contract = EsignContract::with([
'signers',
'signFields',
'auditLogs' => fn ($q) => $q->orderBy('created_at', 'desc'),
'auditLogs.signer:id,name,email,role',
'creator:id,name',
])->find($id);
if (! $contract) {
throw new NotFoundHttpException(__('error.not_found'));
}
return $contract;
}
public function cancel(int $id): EsignContract
{
$userId = $this->apiUserId();
return DB::transaction(function () use ($id, $userId) {
$contract = EsignContract::find($id);
if (! $contract) {
throw new NotFoundHttpException(__('error.not_found'));
}
if ($contract->status === EsignContract::STATUS_COMPLETED) {
throw new BadRequestHttpException(__('error.esign.already_completed'));
}
if ($contract->status === EsignContract::STATUS_CANCELLED) {
throw new BadRequestHttpException(__('error.esign.already_cancelled'));
}
$contract->update([
'status' => EsignContract::STATUS_CANCELLED,
'updated_by' => $userId,
]);
$this->auditService->log($contract->id, EsignAuditLog::ACTION_CANCELLED);
return $contract->fresh(['signers', 'creator:id,name']);
});
}
public function send(int $id): EsignContract
{
$userId = $this->apiUserId();
return DB::transaction(function () use ($id, $userId) {
$contract = EsignContract::with('signers')->find($id);
if (! $contract) {
throw new NotFoundHttpException(__('error.not_found'));
}
if (! in_array($contract->status, [EsignContract::STATUS_DRAFT])) {
throw new BadRequestHttpException(__('error.esign.invalid_status_for_send'));
}
// 서명 필드가 설정되어 있는지 확인
$fieldsCount = EsignSignField::where('contract_id', $contract->id)->count();
if ($fieldsCount === 0) {
throw new BadRequestHttpException(__('error.esign.no_sign_fields'));
}
$contract->update([
'status' => EsignContract::STATUS_PENDING,
'updated_by' => $userId,
]);
// 첫 번째 서명자에게 알림 발송
$nextSigner = $contract->getNextSigner();
if ($nextSigner) {
$nextSigner->update(['status' => EsignSigner::STATUS_NOTIFIED]);
Mail::to($nextSigner->email)->queue(
new EsignRequestMail($contract, $nextSigner)
);
}
$this->auditService->log($contract->id, EsignAuditLog::ACTION_SENT);
return $contract->fresh(['signers', 'creator:id,name']);
});
}
public function remind(int $id): EsignContract
{
$contract = EsignContract::with('signers')->find($id);
if (! $contract) {
throw new NotFoundHttpException(__('error.not_found'));
}
if (! $contract->canSign()) {
throw new BadRequestHttpException(__('error.esign.cannot_remind'));
}
$nextSigner = $contract->getNextSigner();
if ($nextSigner) {
Mail::to($nextSigner->email)->queue(
new EsignRequestMail($contract, $nextSigner)
);
}
$this->auditService->log($contract->id, EsignAuditLog::ACTION_REMINDED);
return $contract;
}
public function configureFields(int $id, array $fields): EsignContract
{
return DB::transaction(function () use ($id, $fields) {
$contract = EsignContract::with('signers')->find($id);
if (! $contract) {
throw new NotFoundHttpException(__('error.not_found'));
}
if ($contract->status !== EsignContract::STATUS_DRAFT) {
throw new BadRequestHttpException(__('error.esign.fields_only_in_draft'));
}
// 기존 필드 삭제 후 재생성
EsignSignField::where('contract_id', $contract->id)->delete();
foreach ($fields as $field) {
EsignSignField::create([
'tenant_id' => $contract->tenant_id,
'contract_id' => $contract->id,
'signer_id' => $field['signer_id'],
'page_number' => $field['page_number'],
'position_x' => $field['position_x'],
'position_y' => $field['position_y'],
'width' => $field['width'],
'height' => $field['height'],
'field_type' => $field['field_type'] ?? EsignSignField::TYPE_SIGNATURE,
'field_label' => $field['field_label'] ?? null,
'is_required' => $field['is_required'] ?? true,
'sort_order' => $field['sort_order'] ?? 0,
]);
}
return $contract->fresh(['signers', 'signFields']);
});
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Services\ESign;
use App\Services\Service;
use Illuminate\Support\Facades\Storage;
class EsignPdfService extends Service
{
public function generateHash(string $filePath): string
{
$fullPath = Storage::disk('local')->path($filePath);
return hash_file('sha256', $fullPath);
}
public function verifyIntegrity(string $filePath, string $expectedHash): bool
{
$actualHash = $this->generateHash($filePath);
return hash_equals($expectedHash, $actualHash);
}
public function composeSigned(
string $originalPath,
array $signerImages,
array $signFields
): string {
// FPDI/FPDF 기반 PDF 합성 - 추후 구현
// 현재는 원본 파일을 signed 경로로 복사
$signedPath = str_replace('originals/', 'signed/', $originalPath);
$signedDir = dirname(Storage::disk('local')->path($signedPath));
if (! is_dir($signedDir)) {
mkdir($signedDir, 0755, true);
}
Storage::disk('local')->copy($originalPath, $signedPath);
return $signedPath;
}
public function addAuditPage(string $pdfPath, array $auditData): string
{
// 감사 증적 페이지 추가 - 추후 FPDI/FPDF로 구현
return $pdfPath;
}
}

View File

@@ -0,0 +1,274 @@
<?php
namespace App\Services\ESign;
use App\Mail\EsignRequestMail;
use App\Models\ESign\EsignAuditLog;
use App\Models\ESign\EsignContract;
use App\Models\ESign\EsignSigner;
use App\Services\Service;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class EsignSignService extends Service
{
public function __construct(
private EsignAuditService $auditService,
private EsignPdfService $pdfService,
) {}
public function getByToken(string $token): array
{
$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'));
}
$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;
}
}

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');
}
};

View File

@@ -0,0 +1,68 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>전자계약 서명 요청</title>
</head>
<body style="margin: 0; padding: 0; background-color: #f5f5f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="padding: 40px 20px;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<!-- Header -->
<tr>
<td style="background-color: #2563eb; padding: 32px 40px; text-align: center;">
<h1 style="margin: 0; color: #ffffff; font-size: 24px; font-weight: 600;">전자계약 서명 요청</h1>
</td>
</tr>
<!-- Body -->
<tr>
<td style="padding: 40px;">
<p style="margin: 0 0 16px; font-size: 16px; color: #333;">안녕하세요, <strong>{{ $signerName }}</strong>.</p>
<p style="margin: 0 0 24px; font-size: 15px; color: #555; line-height: 1.6;">
아래 전자계약에 대한 서명이 요청되었습니다.<br>
링크를 클릭하여 서명을 진행해 주세요.
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f8fafc; border-radius: 6px; margin-bottom: 24px;">
<tr>
<td style="padding: 20px;">
<p style="margin: 0 0 8px; font-size: 13px; color: #888;">계약 제목</p>
<p style="margin: 0 0 16px; font-size: 16px; color: #333; font-weight: 600;">{{ $contractTitle }}</p>
<p style="margin: 0 0 8px; font-size: 13px; color: #888;">서명 기한</p>
<p style="margin: 0; font-size: 15px; color: #ef4444;">{{ $expiresAt }}</p>
</td>
</tr>
</table>
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center" style="padding: 8px 0;">
<a href="{{ $signUrl }}" style="display: inline-block; background-color: #2563eb; color: #ffffff; text-decoration: none; padding: 14px 40px; border-radius: 6px; font-size: 16px; font-weight: 600;">
서명하기
</a>
</td>
</tr>
</table>
<p style="margin: 24px 0 0; font-size: 13px; color: #999; line-height: 1.6;">
버튼이 동작하지 않으면 아래 링크를 브라우저에 직접 입력해 주세요:<br>
<a href="{{ $signUrl }}" style="color: #2563eb; word-break: break-all;">{{ $signUrl }}</a>
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #f8fafc; padding: 24px 40px; text-align: center; border-top: 1px solid #e5e7eb;">
<p style="margin: 0; font-size: 12px; color: #999;">
메일은 SAM 전자계약 시스템에서 자동 발송되었습니다.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -40,6 +40,7 @@
require __DIR__.'/api/v1/stats.php'; require __DIR__.'/api/v1/stats.php';
require __DIR__.'/api/v1/app.php'; require __DIR__.'/api/v1/app.php';
require __DIR__.'/api/v1/audit.php'; require __DIR__.'/api/v1/audit.php';
require __DIR__.'/api/v1/esign.php';
// 공유 링크 다운로드 (인증 불필요 - auth.apikey 그룹 밖) // 공유 링크 다운로드 (인증 불필요 - auth.apikey 그룹 밖)
Route::get('/files/share/{token}', [FileStorageController::class, 'downloadShared'])->name('v1.files.share.download'); Route::get('/files/share/{token}', [FileStorageController::class, 'downloadShared'])->name('v1.files.share.download');

36
routes/api/v1/esign.php Normal file
View File

@@ -0,0 +1,36 @@
<?php
/**
* 전자계약(E-Sign) API 라우트 (v1)
*
* - 계약 관리 (인증 필요)
* - 서명 프로세스 (토큰 기반, 비인증)
*/
use App\Http\Controllers\Api\V1\ESign\EsignContractController;
use App\Http\Controllers\Api\V1\ESign\EsignSignController;
use Illuminate\Support\Facades\Route;
// E-Sign Contract API (인증 필요 - auth.apikey 그룹 내)
Route::prefix('esign/contracts')->group(function () {
Route::get('', [EsignContractController::class, 'index'])->name('v1.esign.contracts.index');
Route::post('', [EsignContractController::class, 'store'])->name('v1.esign.contracts.store');
Route::get('/stats', [EsignContractController::class, 'stats'])->name('v1.esign.contracts.stats');
Route::get('/{id}', [EsignContractController::class, 'show'])->whereNumber('id')->name('v1.esign.contracts.show');
Route::post('/{id}/cancel', [EsignContractController::class, 'cancel'])->whereNumber('id')->name('v1.esign.contracts.cancel');
Route::post('/{id}/fields', [EsignContractController::class, 'configureFields'])->whereNumber('id')->name('v1.esign.contracts.fields');
Route::post('/{id}/send', [EsignContractController::class, 'send'])->whereNumber('id')->name('v1.esign.contracts.send');
Route::post('/{id}/remind', [EsignContractController::class, 'remind'])->whereNumber('id')->name('v1.esign.contracts.remind');
Route::get('/{id}/download', [EsignContractController::class, 'download'])->whereNumber('id')->name('v1.esign.contracts.download');
Route::get('/{id}/verify', [EsignContractController::class, 'verify'])->whereNumber('id')->name('v1.esign.contracts.verify');
});
// E-Sign Sign API (토큰 기반, 비인증 - auth.apikey 그룹 내에서 토큰으로 접근)
Route::prefix('esign/sign/{token}')->group(function () {
Route::get('', [EsignSignController::class, 'getContract'])->name('v1.esign.sign.contract');
Route::post('/otp/send', [EsignSignController::class, 'sendOtp'])->name('v1.esign.sign.otp.send');
Route::post('/otp/verify', [EsignSignController::class, 'verifyOtp'])->name('v1.esign.sign.otp.verify');
Route::get('/document', [EsignSignController::class, 'getDocument'])->name('v1.esign.sign.document');
Route::post('/submit', [EsignSignController::class, 'submit'])->name('v1.esign.sign.submit');
Route::post('/reject', [EsignSignController::class, 'reject'])->name('v1.esign.sign.reject');
});