docs:E-Sign 전자계약 구현 가이드 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-12 07:26:40 +09:00
parent c5b1eb050e
commit 5f44e83aaf
2 changed files with 787 additions and 2 deletions

View File

@@ -0,0 +1,755 @@
# SAM E-Sign 구현 가이드
> **프로젝트명**: SAM E-Sign (전자계약 서명 솔루션)
> **구현일**: 2026-02-12
> **버전**: v1.0
> **설계 문서**: [technical-design.md](./technical-design.md)
---
## 1. 구현 개요
모두싸인과 유사한 간편 전자계약 서명 솔루션을 SAM 시스템에 구현했다.
API 프로젝트에 백엔드 로직(모델, 서비스, 컨트롤러, 라우트)을, MNG 프로젝트에 프론트엔드(컨트롤러, React 뷰)를 구축했다.
### 구현 범위
| 영역 | 내용 |
|------|------|
| 데이터베이스 | 마이그레이션 4개 (esign_ 접두사 테이블) |
| 모델 | 4개 (EsignContract, EsignSigner, EsignSignField, EsignAuditLog) |
| 서비스 | 4개 (Contract, Sign, Pdf, Audit) |
| API 컨트롤러 | 2개 (Contract 10엔드포인트, Sign 6엔드포인트) |
| FormRequest | 4개 (Store, FieldConfigure, SignSubmit, SignReject) |
| 메일 | 1개 (EsignRequestMail) |
| MNG 컨트롤러 | 2개 (인증 5화면, 공개 3화면) |
| MNG 뷰 | 8개 (React 하이브리드) |
| API 라우트 | 16개 |
| MNG 라우트 | 8개 |
| i18n | message 12키 + error 16키 |
**총 파일**: 신규 29개 + 수정 4개 = 33개
---
## 2. 파일 구조
### API 프로젝트 (`/home/aweso/sam/api`)
```
database/migrations/
├── 2026_02_12_100000_create_esign_contracts_table.php
├── 2026_02_12_110000_create_esign_signers_table.php
├── 2026_02_12_120000_create_esign_sign_fields_table.php
└── 2026_02_12_130000_create_esign_audit_logs_table.php
app/Models/ESign/
├── EsignContract.php
├── EsignSigner.php
├── EsignSignField.php
└── EsignAuditLog.php
app/Services/ESign/
├── EsignContractService.php
├── EsignSignService.php
├── EsignPdfService.php
└── EsignAuditService.php
app/Http/Controllers/Api/V1/ESign/
├── EsignContractController.php
└── EsignSignController.php
app/Http/Requests/ESign/
├── ContractStoreRequest.php
├── FieldConfigureRequest.php
├── SignSubmitRequest.php
└── SignRejectRequest.php
app/Mail/
└── EsignRequestMail.php
resources/views/emails/esign/
└── request.blade.php
routes/api/v1/
└── esign.php
routes/api.php # 수정 (esign.php include 추가)
lang/ko/message.php # 수정 (esign 키 추가)
lang/ko/error.php # 수정 (esign 키 추가)
```
### MNG 프로젝트 (`/home/aweso/sam/mng`)
```
app/Http/Controllers/ESign/
├── EsignController.php # 인증 필요 (5개 메서드)
└── EsignPublicController.php # 비인증 (3개 메서드)
resources/views/esign/
├── dashboard.blade.php # 대시보드 (통계 + 목록)
├── create.blade.php # 계약 생성 (PDF 업로드)
├── detail.blade.php # 계약 상세 (현황 + 로그)
├── fields.blade.php # 서명 위치 지정 (PDF.js)
├── send.blade.php # 서명 요청 발송 확인
└── sign/
├── auth.blade.php # 공개 - OTP 본인인증
├── sign.blade.php # 공개 - 서명 수행
└── done.blade.php # 공개 - 서명 완료
routes/web.php # 수정 (esign 라우트 추가)
```
---
## 3. 데이터베이스 스키마
### 3.1 esign_contracts (계약 마스터)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | BIGINT PK | 자동 증가 |
| tenant_id | BIGINT FK | 테넌트 |
| contract_code | VARCHAR(50) UNIQUE | 계약 코드 (ES-YYYYMMDD-RANDOM) |
| title | VARCHAR(200) | 계약 제목 |
| description | TEXT NULL | 계약 설명 |
| sign_order_type | ENUM | counterpart_first, creator_first |
| original_file_path | VARCHAR(500) | 원본 PDF 경로 |
| original_file_name | VARCHAR(255) | 원본 파일명 |
| original_file_hash | VARCHAR(64) | SHA-256 해시 |
| original_file_size | INT UNSIGNED | 파일 크기 (bytes) |
| signed_file_path | VARCHAR(500) NULL | 서명 완료 PDF 경로 |
| signed_file_hash | VARCHAR(64) NULL | 서명 완료 PDF 해시 |
| status | ENUM | draft/pending/partially_signed/completed/expired/cancelled/rejected |
| expires_at | TIMESTAMP | 서명 기한 |
| completed_at | TIMESTAMP NULL | 완료 시각 |
| created_by/updated_by/deleted_by | BIGINT NULL | 감사 필드 |
| timestamps, softDeletes | | |
**인덱스**: tenant_id+status, contract_code(unique), expires_at
### 3.2 esign_signers (서명자)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | BIGINT PK | |
| tenant_id | BIGINT FK | 테넌트 |
| contract_id | BIGINT FK | → esign_contracts |
| role | ENUM | creator, counterpart |
| sign_order | TINYINT | 서명 순서 (1 또는 2) |
| name | VARCHAR(100) | 서명자 이름 |
| email | VARCHAR(255) | 이메일 |
| phone | VARCHAR(20) NULL | 전화번호 |
| access_token | VARCHAR(128) UNIQUE | 서명 링크 토큰 |
| token_expires_at | TIMESTAMP | 토큰 만료 |
| otp_code | VARCHAR(10) NULL | OTP 코드 |
| otp_expires_at | TIMESTAMP NULL | OTP 만료 |
| otp_attempts | TINYINT DEFAULT 0 | OTP 시도 횟수 |
| auth_verified_at | TIMESTAMP NULL | 본인인증 완료 |
| signature_image_path | VARCHAR(500) NULL | 서명 이미지 경로 |
| signed_at | TIMESTAMP NULL | 서명 시각 |
| consent_agreed_at | TIMESTAMP NULL | 동의 시각 |
| sign_ip_address | VARCHAR(45) NULL | 서명 IP |
| sign_user_agent | TEXT NULL | User-Agent |
| status | ENUM | waiting/notified/authenticated/signed/rejected |
| rejected_reason | TEXT NULL | 거절 사유 |
### 3.3 esign_sign_fields (서명 필드)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | BIGINT PK | |
| tenant_id | BIGINT FK | 테넌트 |
| contract_id | BIGINT FK | → esign_contracts |
| signer_id | BIGINT FK | → esign_signers |
| page_number | INT UNSIGNED | PDF 페이지 번호 |
| position_x | DECIMAL(8,4) | X 좌표 (%) |
| position_y | DECIMAL(8,4) | Y 좌표 (%) |
| width | DECIMAL(8,4) | 너비 (%) |
| height | DECIMAL(8,4) | 높이 (%) |
| field_type | ENUM | signature/stamp/text/date/checkbox |
| field_label | VARCHAR(100) NULL | 필드 라벨 |
| field_value | TEXT NULL | 입력된 값 |
| is_required | BOOLEAN DEFAULT true | 필수 여부 |
| sort_order | INT DEFAULT 0 | 정렬 순서 |
### 3.4 esign_audit_logs (감사 로그)
| 컬럼 | 타입 | 설명 |
|------|------|------|
| id | BIGINT PK | |
| tenant_id | BIGINT FK | 테넌트 |
| contract_id | BIGINT FK | → esign_contracts |
| signer_id | BIGINT FK NULL | → esign_signers (NULL = 시스템 이벤트) |
| action | VARCHAR(50) | 액션 코드 |
| ip_address | VARCHAR(45) NULL | IP 주소 |
| user_agent | TEXT NULL | User-Agent |
| metadata | JSON NULL | 추가 데이터 |
| created_at | TIMESTAMP | 생성 시각 (삭제 불가) |
**감사 로그 액션 코드**:
| 코드 | 설명 |
|------|------|
| created | 계약 생성 |
| sent | 서명 요청 발송 |
| viewed | 문서 열람 |
| otp_sent | OTP 발송 |
| authenticated | 본인인증 완료 |
| signed | 서명 완료 |
| rejected | 서명 거절 |
| completed | 계약 완료 |
| cancelled | 계약 취소 |
| reminded | 리마인더 발송 |
| downloaded | 문서 다운로드 |
### ER 다이어그램
```
esign_contracts (1) ──── (N) esign_signers
│ │
(1) (1)
│ │
(N) (N)
esign_sign_fields esign_audit_logs
```
---
## 4. 모델 계층
### 4.1 EsignContract
**파일**: `app/Models/ESign/EsignContract.php`
```php
use Auditable, BelongsToTenant, SoftDeletes;
// 상수
STATUS_DRAFT, STATUS_PENDING, STATUS_PARTIALLY_SIGNED,
STATUS_COMPLETED, STATUS_EXPIRED, STATUS_CANCELLED, STATUS_REJECTED
SIGN_ORDER_COUNTERPART_FIRST, SIGN_ORDER_CREATOR_FIRST
// 관계
signers() HasMany(EsignSigner)
signFields() HasMany(EsignSignField)
auditLogs() HasMany(EsignAuditLog)
creator() BelongsTo(User, 'created_by')
// 스코프
scopeStatus($query, $status)
scopeActive($query) // pending, partially_signed
// 헬퍼
isExpired(): bool // expires_at < now
canSign(): bool // pending 또는 partially_signed + 미만료
getNextSigner(): ?EsignSigner // sign_order 기준 다음 서명자
```
### 4.2 EsignSigner
**파일**: `app/Models/ESign/EsignSigner.php`
```php
use BelongsToTenant;
// 상수
ROLE_CREATOR, ROLE_COUNTERPART
STATUS_WAITING, STATUS_NOTIFIED, STATUS_AUTHENTICATED, STATUS_SIGNED, STATUS_REJECTED
// Hidden 필드: access_token, otp_code
// 관계
contract() BelongsTo(EsignContract)
signFields() HasMany(EsignSignField)
// 헬퍼
isVerified(): bool // auth_verified_at !== null
hasSigned(): bool // signed_at !== null
canSign(): bool // !hasSigned + !rejected + contract.canSign
```
### 4.3 EsignSignField
**파일**: `app/Models/ESign/EsignSignField.php`
```php
use BelongsToTenant;
// 상수
TYPE_SIGNATURE, TYPE_STAMP, TYPE_TEXT, TYPE_DATE, TYPE_CHECKBOX
// 관계
contract() BelongsTo(EsignContract)
signer() BelongsTo(EsignSigner)
```
### 4.4 EsignAuditLog
**파일**: `app/Models/ESign/EsignAuditLog.php`
```php
use BelongsToTenant;
$timestamps = false; // created_at만 사용
$casts = ['metadata' => 'array'];
// 상수
ACTION_CREATED, ACTION_SENT, ACTION_VIEWED, ACTION_OTP_SENT,
ACTION_AUTHENTICATED, ACTION_SIGNED, ACTION_REJECTED,
ACTION_COMPLETED, ACTION_CANCELLED, ACTION_REMINDED, ACTION_DOWNLOADED
// 관계
contract() BelongsTo(EsignContract)
signer() BelongsTo(EsignSigner)
```
---
## 5. 서비스 계층
모든 서비스는 `App\Services\Service`를 상속하며, `tenantId()``apiUserId()`를 사용한다.
### 5.1 EsignContractService
**파일**: `app/Services/ESign/EsignContractService.php`
**의존성**: EsignAuditService, EsignPdfService
| 메서드 | 설명 |
|--------|------|
| `list(array $params)` | 필터/페이지네이션 목록 (status, search, date_from/to, per_page) |
| `stats()` | 상태별 통계 (GROUP BY status) |
| `create(array $data)` | 계약 생성 + PDF 저장 + 해시 + 서명자 2인 생성 |
| `show(int $id)` | 상세 조회 (signers, signFields, auditLogs eager loading) |
| `cancel(int $id)` | 계약 취소 (draft/pending만 가능) |
| `send(int $id)` | 서명 요청 발송 (상태 → pending, 이메일 발송) |
| `remind(int $id)` | 리마인더 발송 (다음 서명자에게 이메일 재발송) |
| `configureFields(int $id, array $fields)` | 서명 위치 설정 (기존 삭제 후 재생성, draft만) |
**계약 코드 생성 규칙**: `ES-YYYYMMDD-RANDOM6` (예: ES-20260212-A3F2K9)
### 5.2 EsignSignService
**파일**: `app/Services/ESign/EsignSignService.php`
**의존성**: EsignAuditService, EsignPdfService
**특이사항**: 모든 쿼리에서 `withoutGlobalScopes()` 사용 (토큰 기반 공개 접근)
| 메서드 | 설명 |
|--------|------|
| `getByToken(string $token)` | 토큰으로 계약+서명자 조회 (만료/상태 검증) |
| `sendOtp(string $token)` | 6자리 OTP 생성 + 5분 만료 + 이메일 발송 |
| `verifyOtp(string $token, string $otpCode)` | OTP 검증 (최대 5회) → sign_session_token JWT 발급 |
| `submitSignature(string $token, array $data)` | 서명 이미지 저장 + 자동 완료 체크 |
| `reject(string $token, string $reason)` | 서명 거절 → 계약 상태 rejected |
| `checkAndComplete(EsignContract)` | (private) 양쪽 서명 완료 시 자동 completed 처리 |
**OTP 보안 규칙**:
- 6자리 숫자
- 5분 유효 (`otp_expires_at`)
- 최대 5회 시도 (`otp_attempts`)
- 초과 시 토큰 무효화
### 5.3 EsignPdfService
**파일**: `app/Services/ESign/EsignPdfService.php`
| 메서드 | 설명 | 상태 |
|--------|------|------|
| `generateHash(string $filePath)` | SHA-256 해시 생성 | 구현 완료 |
| `verifyIntegrity(string $filePath, string $expectedHash)` | 해시 비교 검증 (hash_equals) | 구현 완료 |
| `composeSigned(...)` | 원본 PDF + 서명 이미지 합성 | 스텁 (FPDI 추후) |
| `addAuditPage(...)` | 감사 증적 페이지 추가 | 스텁 (FPDI 추후) |
### 5.4 EsignAuditService
**파일**: `app/Services/ESign/EsignAuditService.php`
| 메서드 | 설명 |
|--------|------|
| `log(int $contractId, string $action, ...)` | 감사 로그 기록 (인증 컨텍스트) |
| `logPublic(int $tenantId, int $contractId, ...)` | 감사 로그 기록 (공개 접근, withoutGlobalScopes) |
| `getContractLogs(int $contractId)` | 계약별 감사 로그 조회 (signer 포함) |
---
## 6. API 엔드포인트
### 6.1 계약 관리 API (인증 필요)
미들웨어: `auth.apikey`
| Method | Path | Controller | 설명 |
|--------|------|-----------|------|
| GET | `/api/v1/esign/contracts` | index | 계약 목록 (필터/페이지네이션) |
| POST | `/api/v1/esign/contracts` | store | 계약 생성 (multipart/form-data) |
| GET | `/api/v1/esign/contracts/stats` | stats | 상태별 통계 |
| GET | `/api/v1/esign/contracts/{id}` | show | 계약 상세 |
| POST | `/api/v1/esign/contracts/{id}/cancel` | cancel | 계약 취소 |
| POST | `/api/v1/esign/contracts/{id}/fields` | configureFields | 서명 위치 설정 |
| POST | `/api/v1/esign/contracts/{id}/send` | send | 서명 요청 발송 |
| POST | `/api/v1/esign/contracts/{id}/remind` | remind | 리마인더 발송 |
| GET | `/api/v1/esign/contracts/{id}/download` | download | PDF 다운로드 |
| GET | `/api/v1/esign/contracts/{id}/verify` | verify | 무결성 검증 |
### 6.2 서명 수행 API (토큰 기반, 비인증)
미들웨어: API 키만 필요 (테넌트 인증 없음)
| Method | Path | Controller | 설명 |
|--------|------|-----------|------|
| GET | `/api/v1/esign/sign/{token}` | getContract | 계약 정보 조회 |
| POST | `/api/v1/esign/sign/{token}/otp/send` | sendOtp | OTP 발송 |
| POST | `/api/v1/esign/sign/{token}/otp/verify` | verifyOtp | OTP 검증 |
| GET | `/api/v1/esign/sign/{token}/document` | getDocument | PDF 스트리밍 |
| POST | `/api/v1/esign/sign/{token}/submit` | submit | 서명 제출 |
| POST | `/api/v1/esign/sign/{token}/reject` | reject | 서명 거절 |
### 6.3 응답 패턴
모든 API는 `ApiResponse::handle()` 패턴을 따른다:
```json
// 성공
{
"success": true,
"message": "처리 완료",
"data": { ... }
}
// 실패
{
"success": false,
"message": "오류 메시지",
"error": { ... }
}
```
---
## 7. MNG 화면 구성
### 7.1 라우트 구조
**인증 필요** (미들웨어: auth):
| 경로 | 컨트롤러 | 뷰 |
|------|---------|-----|
| GET `/esign` | EsignController@dashboard | esign/dashboard |
| GET `/esign/create` | EsignController@create | esign/create |
| GET `/esign/{id}` | EsignController@detail | esign/detail |
| GET `/esign/{id}/fields` | EsignController@fields | esign/fields |
| GET `/esign/{id}/send` | EsignController@send | esign/send |
**공개** (비인증):
| 경로 | 컨트롤러 | 뷰 |
|------|---------|-----|
| GET `/esign/sign/{token}` | EsignPublicController@auth | esign/sign/auth |
| GET `/esign/sign/{token}/sign` | EsignPublicController@sign | esign/sign/sign |
| GET `/esign/sign/{token}/done` | EsignPublicController@done | esign/sign/done |
### 7.2 뷰 기술 스택
| 뷰 | 레이아웃 | 기술 |
|-----|---------|------|
| dashboard | layouts.app | React 18 + Babel |
| create | layouts.app | React 18 + Babel |
| detail | layouts.app | React 18 + Babel |
| fields | layouts.app | React 18 + PDF.js + Babel |
| send | layouts.app | React 18 + Babel |
| sign/auth | Standalone HTML | React 18 + Babel |
| sign/sign | Standalone HTML | React 18 + SignaturePad + Babel |
| sign/done | Standalone HTML | React 18 + Babel |
**공통 패턴**:
- 인증 화면: `@extends('layouts.app')` + HX-Redirect 패턴
- 공개 화면: 독립 HTML (layouts.app 미사용, Tailwind CSS만 로드)
- API 호출: `window.SAM_CONFIG?.apiBaseUrl` 또는 `config('services.api.base_url')`
- 인증 토큰: `sessionStorage.getItem('api_access_token')` (인증), `esign_session_token` (공개)
### 7.3 화면별 기능
**대시보드** (`dashboard.blade.php`):
- 상태별 통계 카드 (전체/진행중/대기/완료/만료)
- 상태 필터, 검색, 날짜 범위 필터
- 계약 목록 테이블 (페이지네이션)
- 상태 배지 (색상 구분)
**계약 생성** (`create.blade.php`):
- 계약 정보 입력 (제목, 설명, 서명 순서, 기한)
- PDF 파일 업로드 (드래그&드롭)
- 작성자 정보, 상대방 정보 입력
- multipart/form-data 제출
**계약 상세** (`detail.blade.php`):
- 계약 기본 정보
- 서명자 현황 (작성자/상대방 상태, 서명 시각)
- 감사 로그 타임라인
- 액션 버튼 (발송, 리마인더, 취소, 다운로드, 검증)
**서명 위치 지정** (`fields.blade.php`):
- PDF.js로 PDF 렌더링
- 페이지 네비게이션
- 서명자별 필드 추가 (클릭으로 위치 지정)
- 필드 드래그/리사이즈
- 필드 타입 설정 (서명/도장/텍스트/날짜/체크박스)
- 좌표값 (% 기반)
**서명 요청 발송** (`send.blade.php`):
- 발송 전 체크리스트 (서명 필드 설정 여부)
- 서명 순서 확인
- 최종 발송 버튼
**본인인증** (`sign/auth.blade.php`):
- 계약 정보 표시 (제목, 서명자, 기한)
- OTP 발송 버튼
- 6자리 OTP 입력 폼
- 재발송 기능
**서명 수행** (`sign/sign.blade.php`):
- 3단계: 문서 확인 → 서명 입력 → 서명 확인
- 문서 확인: PDF 다운로드 링크 + 동의 체크박스
- 서명 입력: SignaturePad 캔버스 (터치/마우스)
- 서명 확인: 미리보기 + 제출/다시서명 선택
- 거절 기능 (사유 입력)
**서명 완료** (`sign/done.blade.php`):
- 서명 완료/거절/기타 상태 분기 표시
- 계약 정보, 서명자 이름, 서명 일시 표시
---
## 8. 핵심 플로우
### 8.1 계약 생성 → 발송
```
[관리자]
1. /esign/create 접속
2. 계약 정보 + PDF 업로드 + 서명자 정보 입력
3. POST /api/v1/esign/contracts
→ EsignContractService::create()
→ PDF 저장, SHA-256 해시, 계약 코드 생성
→ 서명자 2인 생성 (access_token 128자)
→ 상태: DRAFT
4. /esign/{id}/fields 에서 서명 위치 지정
→ POST /api/v1/esign/contracts/{id}/fields
→ configureFields() 호출
5. /esign/{id}/send 에서 발송 확인
→ POST /api/v1/esign/contracts/{id}/send
→ 상태: DRAFT → PENDING
→ 첫 서명자: WAITING → NOTIFIED
→ EsignRequestMail 이메일 발송
```
### 8.2 서명 수행
```
[서명자 B - 이메일 링크 클릭]
1. /esign/sign/{token} 접속 → auth 화면
2. GET /api/v1/esign/sign/{token} → 계약 정보 확인
3. POST /api/v1/esign/sign/{token}/otp/send → OTP 발송
4. POST /api/v1/esign/sign/{token}/otp/verify → OTP 검증
→ sign_session_token 발급
→ sessionStorage에 저장
5. /esign/sign/{token}/sign 이동 → sign 화면
6. 문서 확인 + 동의 체크 + 서명 입력 (SignaturePad)
7. POST /api/v1/esign/sign/{token}/submit
→ 서명 이미지 저장 (base64 → PNG 파일)
→ 상태: AUTHENTICATED → SIGNED
→ checkAndComplete() 호출
8. /esign/sign/{token}/done 이동 → done 화면
```
### 8.3 자동 완료 처리
```
EsignSignService::checkAndComplete()
1. 모든 서명자 상태 확인
2. 전원 SIGNED → 계약 상태 COMPLETED + completed_at 설정
3. 아직 미서명자 있음 → PARTIALLY_SIGNED
→ 다음 서명 순서 서명자에게 이메일 발송
→ 다음 서명자 상태: NOTIFIED
```
### 8.4 상태 전이
**계약 상태**:
```
DRAFT → PENDING (send)
PENDING → PARTIALLY_SIGNED (첫 서명 완료)
PARTIALLY_SIGNED → COMPLETED (모든 서명 완료)
DRAFT/PENDING → CANCELLED (관리자 취소)
PENDING/PARTIALLY_SIGNED → REJECTED (서명자 거절)
PENDING/PARTIALLY_SIGNED → EXPIRED (기한 초과, 추후 스케줄러)
```
**서명자 상태**:
```
WAITING → NOTIFIED (이메일 발송)
NOTIFIED → AUTHENTICATED (OTP 인증)
AUTHENTICATED → SIGNED (서명 완료)
NOTIFIED/AUTHENTICATED → REJECTED (거절)
```
---
## 9. 보안 설계
### 9.1 토큰 기반 접근
| 토큰 | 용도 | 생성 | 유효기간 |
|------|------|------|---------|
| access_token | 서명 링크 URL | `Str::random(128)` | 계약 만료일까지 |
| sign_session_token | OTP 인증 후 세션 | `Str::random(64)` | 별도 관리 없음 |
### 9.2 OTP 인증
- 6자리 숫자 (`random_int(100000, 999999)`)
- 5분 유효 (`otp_expires_at = now + 5min`)
- 최대 5회 시도 (`otp_attempts`)
- 초과 시 에러 반환 (토큰 재사용 불가 상태)
### 9.3 파일 무결성
- 업로드 시 SHA-256 해시 계산 → `original_file_hash`에 저장
- 검증 시 `hash_equals()` 사용 (타이밍 공격 방지)
- 서명 완료 PDF도 별도 해시 저장 (`signed_file_hash`)
### 9.4 멀티테넌트
- 모든 모델에 `BelongsToTenant` 트레이트 적용
- 공개 서명 API에서는 `withoutGlobalScopes()` 사용
- 감사 로그 기록 시 `logPublic()` 메서드로 tenant_id 명시
### 9.5 감사 추적
- 모든 주요 행위를 `esign_audit_logs`에 기록
- IP 주소, User-Agent 자동 수집
- 감사 로그는 삭제 불가 (`SoftDeletes` 미적용)
---
## 10. i18n (국제화)
### message 키 (`lang/ko/message.php` → `esign` 배열)
| 키 | 메시지 |
|----|--------|
| created | 전자계약이 생성되었습니다 |
| cancelled | 전자계약이 취소되었습니다 |
| sent | 서명 요청이 발송되었습니다 |
| reminded | 리마인더가 발송되었습니다 |
| fields_configured | 서명 필드가 설정되었습니다 |
| otp_sent | 인증 코드가 발송되었습니다 |
| otp_verified | 본인인증이 완료되었습니다 |
| signed | 서명이 완료되었습니다 |
| rejected | 서명이 거절되었습니다 |
| completed | 전자계약이 완료되었습니다 |
| verified | 문서 무결성이 확인되었습니다 |
| downloaded | 문서가 다운로드되었습니다 |
### error 키 (`lang/ko/error.php` → `esign` 배열)
| 키 | 메시지 |
|----|--------|
| invalid_token | 유효하지 않은 서명 링크입니다 |
| token_expired | 서명 링크가 만료되었습니다 |
| contract_not_signable | 현재 서명할 수 없는 계약입니다 |
| already_completed | 이미 완료된 계약입니다 |
| already_cancelled | 이미 취소된 계약입니다 |
| already_signed | 이미 서명이 완료되었습니다 |
| invalid_status_for_send | 발송할 수 없는 상태입니다 |
| no_sign_fields | 서명 필드가 설정되지 않았습니다 |
| cannot_remind | 리마인더를 발송할 수 없는 상태입니다 |
| fields_only_in_draft | 초안 상태에서만 필드를 설정할 수 있습니다 |
| not_verified | 본인인증이 필요합니다 |
| otp_max_attempts | 인증 시도 횟수를 초과했습니다 |
| otp_not_sent | 인증 코드가 발송되지 않았습니다 |
| otp_expired | 인증 코드가 만료되었습니다 |
| otp_invalid | 인증 코드가 올바르지 않습니다 |
| file_not_found | 파일을 찾을 수 없습니다 |
---
## 11. 재사용 패턴
### API 프로젝트
| 패턴 | 파일 | 용도 |
|------|------|------|
| Service 베이스 | `app/Services/Service.php` | tenantId(), apiUserId(), setContext() |
| ApiResponse | `app/Helpers/ApiResponse.php` | handle() 패턴으로 일관된 응답 |
| Auditable Trait | `app/Traits/Auditable.php` | created_by, updated_by, deleted_by 자동 관리 |
| BelongsToTenant | `app/Traits/BelongsToTenant.php` | TenantScope 글로벌 스코프 |
### MNG 프로젝트
| 패턴 | 파일 | 용도 |
|------|------|------|
| 레이아웃 | `views/layouts/app.blade.php` | 사이드바 + 콘텐츠 영역 |
| React 하이브리드 | `views/finance/journal-entries.blade.php` | CDN React + Babel 패턴 참고 |
| HX-Redirect | 컨트롤러 패턴 | HTMX 부분 로드 시 전체 리로드 |
| SidebarMenu | `app/Services/SidebarMenuService.php` | DB 기반 메뉴 |
---
## 12. 추후 구현 예정
| 항목 | 우선순위 | 설명 |
|------|---------|------|
| PDF 합성 (FPDI) | 높음 | 원본 PDF에 서명 이미지 오버레이 |
| 감사 증적 페이지 | 높음 | 완료 PDF 마지막에 감사 정보 페이지 추가 |
| 파일 암호화 (AES-256) | 중간 | 원본 PDF 암호화 저장 |
| 만료 자동 처리 | 중간 | 스케줄러로 expires_at 초과 계약 expired 처리 |
| 리마인더 자동 발송 | 낮음 | 만료 3일 전 자동 리마인드 |
| SMS OTP | 낮음 | 이메일 외 SMS 인증 지원 |
| OTP bcrypt 해싱 | 중간 | 현재 평문 저장 → bcrypt 해싱 |
---
## 13. 메뉴 추가 안내
메뉴 시더 실행 금지 규칙에 따라, 아래 tinker 명령어를 사용자가 직접 실행해야 합니다:
```bash
# 1. 상위 메뉴 "전자계약 (E-Sign)" 추가
ssh sam-server "cd /home/webservice/mng && php artisan tinker --execute=\"
\\\$menu = App\\\\Models\\\\Commons\\\\Menu::create([
'tenant_id' => 1,
'parent_id' => null,
'name' => '전자계약',
'url' => '/esign',
'icon' => 'ri-quill-pen-line',
'sort_order' => 25,
'is_active' => true,
]);
echo 'Created menu ID: ' . \\\$menu->id;
\""
# 2. 하위 메뉴 추가 (parent_id를 위에서 생성된 ID로 교체)
ssh sam-server "cd /home/webservice/mng && php artisan tinker --execute=\"
App\\\\Models\\\\Commons\\\\Menu::create([
'tenant_id' => 1,
'parent_id' => <상위메뉴ID>,
'name' => '계약 대시보드',
'url' => '/esign',
'icon' => 'ri-dashboard-line',
'sort_order' => 1,
'is_active' => true,
]);
App\\\\Models\\\\Commons\\\\Menu::create([
'tenant_id' => 1,
'parent_id' => <상위메뉴ID>,
'name' => '새 계약 생성',
'url' => '/esign/create',
'icon' => 'ri-add-line',
'sort_order' => 2,
'is_active' => true,
]);
\""
```
---
*이 문서는 SAM E-Sign v1.0 구현 결과를 기록한 것입니다. 추후 기능 추가 시 업데이트됩니다.*

View File

@@ -1,7 +1,7 @@
# 프로젝트 문서 인덱스
> SAM 시스템 개발 프로젝트별 문서 모음
> **최종 업데이트**: 2025-12-23
> **최종 업데이트**: 2026-02-12
---
@@ -17,6 +17,7 @@
| [mng-mobile-responsive](#mng-mobile-responsive---모바일-반응형) | 🟡 진행중 | mng 모바일 반응형 개선 |
| [auto-login](#auto-login---자동-로그인) | ⚪ 대기 | 자동 로그인 기능 |
| [migration-5130-mng](#migration-5130-mng---5130--mng-마이그레이션) | 🟡 진행중 | 5130 → mng 통합 마이그레이션 |
| [e-sign](#e-sign---전자계약-서명) | 🟢 v1.0 구현 완료 | 전자계약 서명 솔루션 (SAM E-Sign) |
---
@@ -175,6 +176,34 @@
---
### e-sign - 전자계약 서명
**경로**: `docs/projects/e-sign/`
**상태**: 🟢 v1.0 구현 완료 (2026-02-12)
**목표**: 모두싸인과 유사한 간편 전자계약 서명 솔루션 자체 구축
**핵심 문서**:
- [technical-design.md](./e-sign/technical-design.md) - 기술 설계 문서
- [implementation-guide.md](./e-sign/implementation-guide.md) - 구현 가이드
**구현 범위**:
| 영역 | 수량 |
|------|------|
| DB 마이그레이션 | 4개 (esign_contracts, esign_signers, esign_sign_fields, esign_audit_logs) |
| API 모델 | 4개 |
| API 서비스 | 4개 |
| API 컨트롤러 | 2개 (16 엔드포인트) |
| MNG 컨트롤러 | 2개 (8 화면) |
| MNG 뷰 | 8개 (React 하이브리드) |
**기술 스택**: Laravel 11 + React 18 + SignaturePad + PDF.js
**참고 자료**:
- `esign-storyboard.pptx` - 화면 스토리보드
- `storyboard-config.json` - 스토리보드 설정
---
## 디렉토리 구조
```
@@ -202,7 +231,8 @@ docs/projects/
├── legacy-5130/ # 레거시 분석 (참조용)
├── mng-mobile-responsive/ # 모바일 반응형
├── auto-login/ # 자동 로그인
── migration-5130-mng/ # 5130→mng 마이그레이션
── migration-5130-mng/ # 5130→mng 마이그레이션
└── e-sign/ # 전자계약 서명 (SAM E-Sign)
```
---