- 마이그레이션 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>
275 lines
8.9 KiB
PHP
275 lines
8.9 KiB
PHP
<?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;
|
|
}
|
|
}
|