Files
sam-manage/app/Http/Controllers/ESign/EsignPublicController.php
김보곤 13567217a7 feat: [esign] 로그인 페이지에 전자계약 서명 바로가기 추가
- 알림톡 버튼 클릭 시 전화번호 입력으로 서명 페이지 이동
- 바로빌 템플릿 URL 변경 전 임시 우회 방법
2026-02-24 19:03:24 +09:00

670 lines
28 KiB
PHP

<?php
namespace App\Http\Controllers\ESign;
use App\Http\Controllers\Controller;
use App\Mail\EsignCompletedMail;
use App\Mail\EsignRequestMail;
use App\Models\Barobill\BarobillMember;
use App\Models\ESign\EsignAuditLog;
use App\Models\ESign\EsignContract;
use App\Models\ESign\EsignSigner;
use App\Services\Barobill\BarobillService;
use App\Services\ESign\PdfSignatureService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class EsignPublicController extends Controller
{
// ─── 전자계약 서명 확인 (전화번호 기반) ───
public function verifyPhone(Request $request)
{
$phone = preg_replace('/[^0-9]/', '', $request->input('phone', ''));
if (strlen($phone) < 10) {
return back()->withErrors(['phone' => '올바른 전화번호를 입력해 주세요.']);
}
$signer = EsignSigner::withoutGlobalScopes()
->where('phone', $phone)
->whereIn('status', ['pending', 'notified'])
->whereHas('contract', fn ($q) => $q->where('status', 'pending'))
->latest('created_at')
->first();
if (! $signer) {
return back()->withErrors(['phone' => '대기 중인 전자계약이 없습니다.']);
}
return redirect("/esign/sign/{$signer->access_token}");
}
// ─── 화면 라우트 ───
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]);
}
// ─── 서명 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);
}
// 서명 가능 여부 판별
$isSignable = in_array($contract->status, ['pending', 'partially_signed'])
&& in_array($signer->status, ['notified', 'viewing', 'authenticated']);
$statusMessage = null;
$unsignableStatuses = [
'draft' => '아직 발송되지 않은 계약입니다.',
'cancelled' => '취소된 계약입니다.',
'rejected' => '거절된 계약입니다.',
];
if (isset($unsignableStatuses[$contract->status])) {
$statusMessage = $unsignableStatuses[$contract->status];
} elseif ($signer->status === 'signed') {
$statusMessage = '이미 서명을 완료하였습니다.';
}
// 서명 가능한 상태에서만 감사 로그 기록
if ($isSignable) {
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,
'has_stamp' => (bool) $signer->signature_image_path,
'signed_at' => $signer->signed_at,
],
'is_signable' => $isSignable,
'status_message' => $statusMessage,
],
]);
}
/**
* 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)],
]);
}
/**
* 서명 제출
*/
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);
}
$useStamp = $request->boolean('use_stamp');
if ($useStamp) {
$stampImage = $request->input('stamp_image');
if ($stampImage) {
// 고객 업로드 도장: base64 디코딩 후 저장
$imageData = base64_decode($stampImage);
if (strlen($imageData) > 5 * 1024 * 1024) {
return response()->json(['success' => false, 'message' => '도장 이미지는 5MB 이하만 가능합니다.'], 422);
}
$imagePath = "esign/{$contract->tenant_id}/signatures/{$contract->id}_{$signer->id}_stamp_".time().'.png';
Storage::disk('local')->put($imagePath, $imageData);
$signer->update(['signature_image_path' => $imagePath]);
} elseif (! $signer->signature_image_path) {
return response()->json(['success' => false, 'message' => '등록된 도장 이미지가 없습니다.'], 422);
}
} else {
// 직접 서명: signature_image 필수
$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]);
}
// 서명자 상태 업데이트
$signer->update([
'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(),
]);
// PDF에 서명 이미지/텍스트 합성
try {
$pdfService = new PdfSignatureService;
$pdfService->mergeSignatures($contract);
} catch (\Throwable $e) {
Log::error('PDF 서명 합성 실패', [
'contract_id' => $contract->id,
'error' => $e->getMessage(),
]);
}
// 계약 완료 감사 로그
EsignAuditLog::create([
'tenant_id' => $contract->tenant_id,
'contract_id' => $contract->id,
'signer_id' => $signer->id,
'action' => 'contract_completed',
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'metadata' => ['total_signers' => $allSigners->count()],
'created_at' => now(),
]);
// 모든 서명자에게 완료 알림 발송
$sendMethod = $contract->send_method ?? 'alimtalk';
foreach ($allSigners as $completedSigner) {
$completionResults = [];
try {
// 이메일 발송
if (in_array($sendMethod, ['email', 'both']) || ! $completedSigner->phone) {
if ($completedSigner->email) {
try {
Mail::to($completedSigner->email)->send(
new EsignCompletedMail($contract, $completedSigner, $allSigners)
);
$completionResults[] = ['success' => true, 'channel' => 'email', 'error' => null];
} catch (\Throwable $e) {
$completionResults[] = ['success' => false, 'channel' => 'email', 'error' => $e->getMessage()];
}
}
}
// 알림톡 발송
if (in_array($sendMethod, ['alimtalk', 'both']) && $completedSigner->phone) {
$completionResults[] = $this->sendCompletionAlimtalk($contract, $completedSigner);
}
EsignAuditLog::create([
'tenant_id' => $contract->tenant_id,
'contract_id' => $contract->id,
'signer_id' => $completedSigner->id,
'action' => 'completion_notification_sent',
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'metadata' => [
'send_method' => $sendMethod,
'notification_results' => [[
'signer_id' => $completedSigner->id,
'signer_name' => $completedSigner->name,
'results' => $completionResults,
]],
],
'created_at' => now(),
]);
} catch (\Throwable $e) {
Log::error('계약 완료 알림 발송 실패', [
'contract_id' => $contract->id,
'signer_id' => $completedSigner->id,
'error' => $e->getMessage(),
]);
}
}
} else {
$contract->update(['status' => 'partially_signed']);
// 다음 서명자에게 자동 알림 발송 (순차 서명)
$nextSigner = EsignSigner::withoutGlobalScopes()
->where('contract_id', $contract->id)
->whereIn('status', ['waiting', 'pending'])
->orderBy('sign_order')
->first();
if ($nextSigner) {
$nextSigner->update(['status' => 'notified']);
$nextSendMethod = $contract->send_method ?? 'alimtalk';
$nextSmsFallback = $contract->sms_fallback ?? true;
$notificationResults = [];
// 알림톡 발송
if (in_array($nextSendMethod, ['alimtalk', 'both']) && $nextSigner->phone) {
try {
$member = BarobillMember::where('tenant_id', $contract->tenant_id)->first();
if ($member) {
$barobill = app(BarobillService::class);
$barobill->setServerMode($member->server_mode ?? 'production');
$nextSignUrl = config('app.url').'/esign/sign/'.$nextSigner->access_token;
$nextExpires = $contract->expires_at?->format('Y-m-d H:i') ?? '없음';
// 채널 ID 조회
$channelResult = $barobill->getKakaotalkChannels($member->biz_no);
$yellowId = '';
if ($channelResult['success'] ?? false) {
$chData = $channelResult['data'];
if (is_object($chData) && isset($chData->KakaotalkChannel)) {
$ch = is_array($chData->KakaotalkChannel) ? $chData->KakaotalkChannel[0] : $chData->KakaotalkChannel;
$yellowId = $ch->ChannelId ?? '';
}
}
// 템플릿 본문 조회하여 변수 치환
$tplResult = $barobill->getKakaotalkTemplates($member->biz_no, $yellowId);
$tplMessage = null;
if ($tplResult['success'] ?? false) {
$tplData = $tplResult['data'];
$tplItems = [];
if (is_object($tplData) && isset($tplData->KakaotalkTemplate)) {
$tplItems = is_array($tplData->KakaotalkTemplate) ? $tplData->KakaotalkTemplate : [$tplData->KakaotalkTemplate];
}
foreach ($tplItems as $t) {
if (($t->TemplateName ?? '') === '전자계약_서명요청') {
$tplMessage = str_replace(
['#{이름}', '#{계약명}', '#{기한}'],
[$nextSigner->name, $contract->title, $nextExpires],
$t->TemplateContent
);
break;
}
}
}
$atResult = $barobill->sendATKakaotalkEx(
corpNum: $member->biz_no,
senderId: $member->barobill_id,
yellowId: $yellowId,
templateName: '전자계약_서명요청',
receiverName: $nextSigner->name,
receiverNum: preg_replace('/[^0-9]/', '', $nextSigner->phone),
title: '',
message: $tplMessage ?? "안녕하세요, {$nextSigner->name}님.\n전자계약 서명 요청이 도착했습니다.\n\n■ 계약명: {$contract->title}\n■ 서명 기한: {$nextExpires}\n\n아래 버튼을 눌러 계약서를 확인하고 서명해 주세요.",
buttons: [['Name' => '계약서 확인하기', 'ButtonType' => 'WL', 'Url1' => $nextSignUrl, 'Url2' => $nextSignUrl]],
smsMessage: $nextSmsFallback ? "[SAM] {$nextSigner->name}님, 전자계약 서명 요청이 도착했습니다. {$nextSignUrl}" : '',
);
$notificationResults[] = [
'success' => $atResult['success'] ?? false,
'channel' => 'alimtalk',
'error' => $atResult['error'] ?? null,
];
} else {
$notificationResults[] = ['success' => false, 'channel' => 'alimtalk', 'error' => '바로빌 회원 미등록'];
}
} catch (\Throwable $e) {
Log::warning('다음 서명자 알림톡 발송 실패', ['error' => $e->getMessage()]);
$notificationResults[] = ['success' => false, 'channel' => 'alimtalk', 'error' => $e->getMessage()];
}
}
// 이메일 발송
if (in_array($nextSendMethod, ['email', 'both']) || ($nextSendMethod === 'alimtalk' && ! $nextSigner->phone)) {
if ($nextSigner->email) {
try {
Mail::to($nextSigner->email)->send(new EsignRequestMail($contract, $nextSigner));
$notificationResults[] = ['success' => true, 'channel' => 'email', 'error' => null];
} catch (\Throwable $e) {
Log::warning('다음 서명자 이메일 발송 실패', ['error' => $e->getMessage()]);
$notificationResults[] = ['success' => false, 'channel' => 'email', 'error' => $e->getMessage()];
}
}
}
EsignAuditLog::create([
'tenant_id' => $contract->tenant_id,
'contract_id' => $contract->id,
'signer_id' => $nextSigner->id,
'action' => 'sign_request_sent',
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'metadata' => [
'triggered_by' => 'auto_after_sign',
'notification_results' => [[
'signer_id' => $nextSigner->id,
'signer_name' => $nextSigner->name,
'results' => $notificationResults,
]],
],
'created_at' => now(),
]);
}
}
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);
}
// 서명 완료된 PDF가 있으면 우선 제공
if ($contract->signed_file_path && Storage::disk('local')->exists($contract->signed_file_path)) {
$filePath = $contract->signed_file_path;
} else {
// 서명 전: 텍스트/날짜/체크박스 필드가 합성된 미리보기 PDF 생성
try {
$pdfService = new PdfSignatureService;
$filePath = $pdfService->generatePreview($contract);
} catch (\Throwable $e) {
Log::warning('미리보기 PDF 생성 실패, 원본 제공', ['error' => $e->getMessage()]);
$filePath = $contract->original_file_path;
}
}
if (! Storage::disk('local')->exists($filePath)) {
return response()->json(['success' => false, 'message' => '문서 파일이 존재하지 않습니다.'], 404);
}
$fileName = $contract->original_file_name ?: ($contract->title.'.pdf');
return Storage::disk('local')->download($filePath, $fileName, [
'Content-Type' => 'application/pdf',
]);
}
// ─── Private ───
private function sendCompletionAlimtalk(EsignContract $contract, EsignSigner $signer): array
{
try {
$member = BarobillMember::where('tenant_id', $contract->tenant_id)->first();
if (! $member) {
return ['success' => false, 'channel' => 'alimtalk', 'error' => '바로빌 회원 미등록'];
}
$barobill = app(BarobillService::class);
$barobill->setServerMode($member->server_mode ?? 'production');
// 채널 ID 조회
$channelResult = $barobill->getKakaotalkChannels($member->biz_no);
$yellowId = '';
if ($channelResult['success'] ?? false) {
$chData = $channelResult['data'];
if (is_object($chData) && isset($chData->KakaotalkChannel)) {
$ch = is_array($chData->KakaotalkChannel) ? $chData->KakaotalkChannel[0] : $chData->KakaotalkChannel;
$yellowId = $ch->ChannelId ?? '';
}
}
$signUrl = config('app.url').'/esign/sign/'.$signer->access_token;
$completedAt = $contract->completed_at?->format('Y-m-d H:i') ?? now()->format('Y-m-d H:i');
$result = $barobill->sendATKakaotalkEx(
corpNum: $member->biz_no,
senderId: $member->barobill_id,
yellowId: $yellowId,
templateName: '전자계약_완료',
receiverName: $signer->name,
receiverNum: preg_replace('/[^0-9]/', '', $signer->phone),
title: '',
message: "안녕하세요, {$signer->name}님.\n전자계약이 모든 서명자의 서명 완료로 확정되었습니다.\n\n■ 계약명: {$contract->title}\n■ 완료일: {$completedAt}\n\n아래 버튼에서 서명 완료된 계약서를 확인할 수 있습니다.",
buttons: [
[
'Name' => '계약서 확인하기',
'ButtonType' => 'WL',
'Url1' => $signUrl,
'Url2' => $signUrl,
],
],
smsMessage: ($contract->sms_fallback ?? true)
? "[SAM] {$signer->name}님, 전자계약이 완료되었습니다. {$signUrl}"
: '',
);
if (! ($result['success'] ?? false)) {
Log::warning('E-Sign 완료 알림톡 발송 실패', [
'contract_id' => $contract->id,
'signer_id' => $signer->id,
'error' => $result['error'] ?? 'Unknown error',
]);
return ['success' => false, 'channel' => 'alimtalk', 'error' => $result['error'] ?? 'API 호출 실패'];
}
return ['success' => true, 'channel' => 'alimtalk', 'error' => null];
} catch (\Throwable $e) {
Log::warning('E-Sign 완료 알림톡 발송 실패', [
'contract_id' => $contract->id,
'signer_id' => $signer->id,
'error' => $e->getMessage(),
]);
return ['success' => false, 'channel' => 'alimtalk', 'error' => $e->getMessage()];
}
}
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;
}
}