docs:E-Sign 기술 설계 문서 보완 (실제 구현 반영)
- 테이블명 수정 (contracts→esign_contracts 등 4개) - 서비스 클래스명 실제 구현과 일치 (4개→4개) - 계약코드 형식 수정 (ESIGN-→ES-YYYYMMDD-XXXXXX) - OTP 시도횟수 수정 (3회→5회) - 좌표 단위 수정 (pt→% 0~100) - 구현 파일 구조 섹션 추가 (API 19개, MNG 12개) - 모델 상세 섹션 추가 (Traits, 상수, 관계도) - FormRequest 검증 규칙 섹션 추가 (4개) - 에러 처리 패턴 및 i18n 메시지 키 섹션 추가 - Multi-tenant 아키텍처 섹션 추가 - 프론트엔드 아키텍처 섹션 추가 (React 하이브리드, HTMX) - 미구현 기능 목록 섹션 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **프로젝트명**: SAM E-Sign (가칭)
|
> **프로젝트명**: SAM E-Sign (가칭)
|
||||||
> **작성일**: 2026-02-12
|
> **작성일**: 2026-02-12
|
||||||
> **버전**: v1.0 Draft
|
> **버전**: v1.0 (구현 완료)
|
||||||
> **작성자**: DX 추진팀
|
> **작성자**: DX 추진팀
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -86,10 +86,10 @@
|
|||||||
│ ┌──────────────────────────┐ │
|
│ ┌──────────────────────────┐ │
|
||||||
│ │ MySQL (sam-mysql-1) │ │
|
│ │ MySQL (sam-mysql-1) │ │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ │ - contracts │ │
|
│ │ - esign_contracts │ │
|
||||||
│ │ - contract_signers │ │
|
│ │ - esign_signers │ │
|
||||||
│ │ - contract_sign_fields │ │
|
│ │ - esign_sign_fields │ │
|
||||||
│ │ - contract_audit_logs │ │
|
│ │ - esign_audit_logs │ │
|
||||||
│ └──────────────────────────┘ │
|
│ └──────────────────────────┘ │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ ┌──────────────────────────┐ │
|
│ ┌──────────────────────────┐ │
|
||||||
@@ -105,12 +105,10 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
app/Services/ESign/
|
app/Services/ESign/
|
||||||
├── ContractService.php # 계약 CRUD, 상태 관리
|
├── EsignContractService.php # 계약 CRUD, 상태 관리, 발송/리마인더
|
||||||
├── SignatureService.php # 서명 처리, 이미지 저장
|
├── EsignSignService.php # 서명 처리, OTP 인증, 토큰 관리
|
||||||
├── PdfService.php # PDF 합성, 해시 생성/검증
|
├── EsignPdfService.php # PDF 합성, 해시 생성/검증
|
||||||
├── AuthenticationService.php # OTP 생성/검증, 토큰 관리
|
└── EsignAuditService.php # 감사 추적 로그 기록
|
||||||
├── NotificationService.php # 이메일 발송 (서명 요청, 완료 알림)
|
|
||||||
└── AuditLogService.php # 감사 추적 로그 기록
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -158,7 +156,7 @@ app/Services/ESign/
|
|||||||
→ 서버: 토큰 유효성 검증 (만료, 사용 여부)
|
→ 서버: 토큰 유효성 검증 (만료, 사용 여부)
|
||||||
2. 본인인증 게이트
|
2. 본인인증 게이트
|
||||||
- 이메일로 6자리 OTP 발송
|
- 이메일로 6자리 OTP 발송
|
||||||
- OTP 입력 (3회 제한, 5분 유효)
|
- OTP 입력 (5회 제한, 5분 유효)
|
||||||
→ 서버: 인증 성공 시 세션에 verified 상태 저장
|
→ 서버: 인증 성공 시 세션에 verified 상태 저장
|
||||||
→ 감사 로그: 'identity_verified'
|
→ 감사 로그: 'identity_verified'
|
||||||
3. 계약서 열람
|
3. 계약서 열람
|
||||||
@@ -238,24 +236,24 @@ app/Services/ESign/
|
|||||||
### 4.1 ER 다이어그램 (텍스트)
|
### 4.1 ER 다이어그램 (텍스트)
|
||||||
|
|
||||||
```
|
```
|
||||||
contracts (1) ──── (N) contract_signers
|
esign_contracts (1) ──── (N) esign_signers
|
||||||
│ │
|
│ │
|
||||||
│ │
|
│ │
|
||||||
(1) (1)
|
(1) (1)
|
||||||
│ │
|
│ │
|
||||||
(N) (N)
|
(N) (N)
|
||||||
contract_sign_fields contract_audit_logs
|
esign_sign_fields esign_audit_logs
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.2 contracts (계약서)
|
### 4.2 esign_contracts (계약서)
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE contracts (
|
CREATE TABLE esign_contracts (
|
||||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
|
||||||
-- 계약 정보
|
-- 계약 정보
|
||||||
contract_code VARCHAR(30) NOT NULL UNIQUE, -- 'ESIGN-2026-000001'
|
contract_code VARCHAR(30) NOT NULL UNIQUE, -- 'ES-20260212-A1B2C3'
|
||||||
title VARCHAR(255) NOT NULL, -- 계약 제목
|
title VARCHAR(255) NOT NULL, -- 계약 제목
|
||||||
description TEXT NULL, -- 계약 설명
|
description TEXT NULL, -- 계약 설명
|
||||||
sign_order_type ENUM('counterpart_first', 'creator_first') DEFAULT 'counterpart_first',
|
sign_order_type ENUM('counterpart_first', 'creator_first') DEFAULT 'counterpart_first',
|
||||||
@@ -286,10 +284,10 @@ CREATE TABLE contracts (
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.3 contract_signers (서명자)
|
### 4.3 esign_signers (서명자)
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE contract_signers (
|
CREATE TABLE esign_signers (
|
||||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
contract_id BIGINT UNSIGNED NOT NULL,
|
contract_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
|
||||||
@@ -328,26 +326,26 @@ CREATE TABLE contract_signers (
|
|||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
FOREIGN KEY (contract_id) REFERENCES contracts(id) ON DELETE CASCADE,
|
FOREIGN KEY (contract_id) REFERENCES esign_contracts(id) ON DELETE CASCADE,
|
||||||
INDEX idx_access_token (access_token),
|
INDEX idx_access_token (access_token),
|
||||||
INDEX idx_contract_role (contract_id, role)
|
INDEX idx_contract_role (contract_id, role)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.4 contract_sign_fields (서명 위치/필드)
|
### 4.4 esign_sign_fields (서명 위치/필드)
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE contract_sign_fields (
|
CREATE TABLE esign_sign_fields (
|
||||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
contract_id BIGINT UNSIGNED NOT NULL,
|
contract_id BIGINT UNSIGNED NOT NULL,
|
||||||
signer_id BIGINT UNSIGNED NOT NULL, -- contract_signers.id
|
signer_id BIGINT UNSIGNED NOT NULL, -- esign_signers.id
|
||||||
|
|
||||||
-- 위치 정보
|
-- 위치 정보
|
||||||
page_number INT UNSIGNED NOT NULL, -- PDF 페이지 번호 (1부터)
|
page_number INT UNSIGNED NOT NULL, -- PDF 페이지 번호 (1부터)
|
||||||
position_x DECIMAL(8,2) NOT NULL, -- X 좌표 (pt 단위)
|
position_x DECIMAL(8,4) NOT NULL, -- X 좌표 (% 단위, 0~100)
|
||||||
position_y DECIMAL(8,2) NOT NULL, -- Y 좌표 (pt 단위)
|
position_y DECIMAL(8,4) NOT NULL, -- Y 좌표 (% 단위, 0~100)
|
||||||
width DECIMAL(8,2) NOT NULL, -- 너비 (pt)
|
width DECIMAL(8,4) NOT NULL, -- 너비 (% 단위, 1~100)
|
||||||
height DECIMAL(8,2) NOT NULL, -- 높이 (pt)
|
height DECIMAL(8,4) NOT NULL, -- 높이 (% 단위, 1~100)
|
||||||
|
|
||||||
-- 필드 정보
|
-- 필드 정보
|
||||||
field_type ENUM('signature', 'stamp', 'text', 'date', 'checkbox') NOT NULL DEFAULT 'signature',
|
field_type ENUM('signature', 'stamp', 'text', 'date', 'checkbox') NOT NULL DEFAULT 'signature',
|
||||||
@@ -358,16 +356,16 @@ CREATE TABLE contract_sign_fields (
|
|||||||
sort_order INT UNSIGNED DEFAULT 0,
|
sort_order INT UNSIGNED DEFAULT 0,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
FOREIGN KEY (contract_id) REFERENCES contracts(id) ON DELETE CASCADE,
|
FOREIGN KEY (contract_id) REFERENCES esign_contracts(id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY (signer_id) REFERENCES contract_signers(id) ON DELETE CASCADE,
|
FOREIGN KEY (signer_id) REFERENCES esign_signers(id) ON DELETE CASCADE,
|
||||||
INDEX idx_contract_page (contract_id, page_number)
|
INDEX idx_contract_page (contract_id, page_number)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.5 contract_audit_logs (감사 추적 로그)
|
### 4.5 esign_audit_logs (감사 추적 로그)
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE contract_audit_logs (
|
CREATE TABLE esign_audit_logs (
|
||||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||||
contract_id BIGINT UNSIGNED NOT NULL,
|
contract_id BIGINT UNSIGNED NOT NULL,
|
||||||
signer_id BIGINT UNSIGNED NULL, -- NULL이면 시스템 이벤트
|
signer_id BIGINT UNSIGNED NULL, -- NULL이면 시스템 이벤트
|
||||||
@@ -402,7 +400,7 @@ CREATE TABLE contract_audit_logs (
|
|||||||
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
FOREIGN KEY (contract_id) REFERENCES contracts(id) ON DELETE CASCADE,
|
FOREIGN KEY (contract_id) REFERENCES esign_contracts(id) ON DELETE CASCADE,
|
||||||
INDEX idx_contract_action (contract_id, action),
|
INDEX idx_contract_action (contract_id, action),
|
||||||
INDEX idx_created_at (created_at)
|
INDEX idx_created_at (created_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
@@ -434,7 +432,7 @@ GET /api/v1/esign/contracts
|
|||||||
"data": [
|
"data": [
|
||||||
{
|
{
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"contract_code": "ESIGN-2026-000001",
|
"contract_code": "ES-20260212-A1B2C3",
|
||||||
"title": "소프트웨어 개발 용역 계약서",
|
"title": "소프트웨어 개발 용역 계약서",
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"signers": [
|
"signers": [
|
||||||
@@ -471,7 +469,7 @@ Content-Type: multipart/form-data
|
|||||||
{
|
{
|
||||||
"data": {
|
"data": {
|
||||||
"id": 1,
|
"id": 1,
|
||||||
"contract_code": "ESIGN-2026-000001",
|
"contract_code": "ES-20260212-A1B2C3",
|
||||||
"status": "draft",
|
"status": "draft",
|
||||||
"original_file_hash": "a1b2c3d4e5f6...",
|
"original_file_hash": "a1b2c3d4e5f6...",
|
||||||
"signers": [
|
"signers": [
|
||||||
@@ -526,10 +524,10 @@ Content-Type: application/json
|
|||||||
{
|
{
|
||||||
"signer_id": 1,
|
"signer_id": 1,
|
||||||
"page_number": 3,
|
"page_number": 3,
|
||||||
"position_x": 120.5,
|
"position_x": 15.5,
|
||||||
"position_y": 650.0,
|
"position_y": 82.0,
|
||||||
"width": 150,
|
"width": 20.0,
|
||||||
"height": 60,
|
"height": 8.0,
|
||||||
"field_type": "signature",
|
"field_type": "signature",
|
||||||
"field_label": "갑 (생성자) 서명",
|
"field_label": "갑 (생성자) 서명",
|
||||||
"is_required": true
|
"is_required": true
|
||||||
@@ -537,10 +535,10 @@ Content-Type: application/json
|
|||||||
{
|
{
|
||||||
"signer_id": 2,
|
"signer_id": 2,
|
||||||
"page_number": 3,
|
"page_number": 3,
|
||||||
"position_x": 350.5,
|
"position_x": 55.5,
|
||||||
"position_y": 650.0,
|
"position_y": 82.0,
|
||||||
"width": 150,
|
"width": 20.0,
|
||||||
"height": 60,
|
"height": 8.0,
|
||||||
"field_type": "signature",
|
"field_type": "signature",
|
||||||
"field_label": "을 (상대방) 서명",
|
"field_label": "을 (상대방) 서명",
|
||||||
"is_required": true
|
"is_required": true
|
||||||
@@ -548,10 +546,10 @@ Content-Type: application/json
|
|||||||
{
|
{
|
||||||
"signer_id": 1,
|
"signer_id": 1,
|
||||||
"page_number": 3,
|
"page_number": 3,
|
||||||
"position_x": 120.5,
|
"position_x": 15.5,
|
||||||
"position_y": 720.0,
|
"position_y": 92.0,
|
||||||
"width": 100,
|
"width": 15.0,
|
||||||
"height": 25,
|
"height": 4.0,
|
||||||
"field_type": "date",
|
"field_type": "date",
|
||||||
"field_label": "서명일",
|
"field_label": "서명일",
|
||||||
"is_required": true
|
"is_required": true
|
||||||
@@ -596,7 +594,7 @@ POST /api/v1/esign/sign/{access_token}/otp/send
|
|||||||
{
|
{
|
||||||
"message": "인증코드가 이메일로 발송되었습니다",
|
"message": "인증코드가 이메일로 발송되었습니다",
|
||||||
"expires_in": 300,
|
"expires_in": 300,
|
||||||
"remaining_attempts": 3
|
"remaining_attempts": 5
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -748,7 +746,7 @@ $isValid = hash_equals($contract->original_file_hash, $currentHash);
|
|||||||
인증 플로우:
|
인증 플로우:
|
||||||
1. 서명 링크 접속 (access_token 검증)
|
1. 서명 링크 접속 (access_token 검증)
|
||||||
2. OTP 발송 (이메일)
|
2. OTP 발송 (이메일)
|
||||||
3. OTP 입력 (6자리, 5분 유효, 3회 제한)
|
3. OTP 입력 (6자리, 5분 유효, 5회 제한)
|
||||||
4. 인증 성공 → sign_session_token 발급 (JWT, 30분 유효)
|
4. 인증 성공 → sign_session_token 발급 (JWT, 30분 유효)
|
||||||
5. 이후 모든 서명 API 호출 시 sign_session_token 필요
|
5. 이후 모든 서명 API 호출 시 sign_session_token 필요
|
||||||
```
|
```
|
||||||
@@ -759,7 +757,7 @@ $isValid = hash_equals($contract->original_file_hash, $currentHash);
|
|||||||
|------|-----|------|
|
|------|-----|------|
|
||||||
| OTP 길이 | 6자리 숫자 | 무작위 생성 |
|
| OTP 길이 | 6자리 숫자 | 무작위 생성 |
|
||||||
| 유효 시간 | 5분 | 초과 시 재발송 필요 |
|
| 유효 시간 | 5분 | 초과 시 재발송 필요 |
|
||||||
| 시도 제한 | 3회 | 초과 시 토큰 무효화 |
|
| 시도 제한 | 5회 | 초과 시 토큰 무효화 |
|
||||||
| 재발송 간격 | 60초 | 연속 발송 방지 |
|
| 재발송 간격 | 60초 | 연속 발송 방지 |
|
||||||
| 저장 방식 | bcrypt 해시 | DB에 평문 저장 금지 |
|
| 저장 방식 | bcrypt 해시 | DB에 평문 저장 금지 |
|
||||||
|
|
||||||
@@ -827,7 +825,7 @@ storage/app/esign/
|
|||||||
┌──────────────────────────────────────────┐
|
┌──────────────────────────────────────────┐
|
||||||
│ 전자서명 감사 증적 (Audit Trail) │
|
│ 전자서명 감사 증적 (Audit Trail) │
|
||||||
├──────────────────────────────────────────┤
|
├──────────────────────────────────────────┤
|
||||||
│ 계약 번호: ESIGN-2026-000001 │
|
│ 계약 번호: ES-20260212-A1B2C3 │
|
||||||
│ 문서 해시: a1b2c3d4e5f6... │
|
│ 문서 해시: a1b2c3d4e5f6... │
|
||||||
│ │
|
│ │
|
||||||
│ [서명자 A - 갑] │
|
│ [서명자 A - 갑] │
|
||||||
@@ -947,4 +945,385 @@ storage/app/esign/
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*이 문서는 SAM E-Sign 솔루션의 초기 기술 설계입니다. 구현 과정에서 상세 내용이 변경될 수 있습니다.*
|
## 10. 구현 파일 구조
|
||||||
|
|
||||||
|
### 10.1 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 # 인증 필요 (10 엔드포인트)
|
||||||
|
└── EsignSignController.php # 토큰 기반 (6 엔드포인트)
|
||||||
|
|
||||||
|
app/Http/Requests/ESign/
|
||||||
|
├── ContractStoreRequest.php
|
||||||
|
├── FieldConfigureRequest.php
|
||||||
|
├── SignSubmitRequest.php
|
||||||
|
└── SignRejectRequest.php
|
||||||
|
|
||||||
|
app/Mail/
|
||||||
|
└── EsignRequestMail.php
|
||||||
|
|
||||||
|
routes/api/v1/
|
||||||
|
└── esign.php # 16 엔드포인트 정의
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 MNG 프로젝트 (`/home/aweso/sam/mng`)
|
||||||
|
|
||||||
|
```
|
||||||
|
app/Http/Controllers/ESign/
|
||||||
|
├── EsignController.php # 인증 필요 (5 화면)
|
||||||
|
└── EsignPublicController.php # 비인증 (3 화면)
|
||||||
|
|
||||||
|
resources/views/esign/
|
||||||
|
├── dashboard.blade.php # 대시보드 (React)
|
||||||
|
├── create.blade.php # 계약 생성 (React)
|
||||||
|
├── detail.blade.php # 계약 상세 (React)
|
||||||
|
├── fields.blade.php # 서명 위치 지정 (React + PDF.js)
|
||||||
|
├── send.blade.php # 서명 요청 발송 (React)
|
||||||
|
└── sign/
|
||||||
|
├── auth.blade.php # 본인인증 OTP (React)
|
||||||
|
├── sign.blade.php # 서명 수행 (React + SignaturePad)
|
||||||
|
└── done.blade.php # 서명 완료 (React)
|
||||||
|
|
||||||
|
routes/web.php # esign 라우트 그룹 추가
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 모델 상세
|
||||||
|
|
||||||
|
### 11.1 공통 Traits
|
||||||
|
|
||||||
|
| Trait | 적용 모델 | 기능 |
|
||||||
|
|-------|----------|------|
|
||||||
|
| `BelongsToTenant` | 전체 4개 | tenant_id 기반 글로벌 스코프, 다중 테넌트 격리 |
|
||||||
|
| `Auditable` | EsignContract | created_by, updated_by, deleted_by 자동 기록 |
|
||||||
|
| `SoftDeletes` | EsignContract | 논리 삭제 (deleted_at) |
|
||||||
|
|
||||||
|
### 11.2 EsignContract 상수
|
||||||
|
|
||||||
|
```php
|
||||||
|
// 계약 상태
|
||||||
|
const STATUS_DRAFT = 'draft';
|
||||||
|
const STATUS_PENDING = 'pending';
|
||||||
|
const STATUS_PARTIALLY_SIGNED = 'partially_signed';
|
||||||
|
const STATUS_COMPLETED = 'completed';
|
||||||
|
const STATUS_EXPIRED = 'expired';
|
||||||
|
const STATUS_CANCELLED = 'cancelled';
|
||||||
|
const STATUS_REJECTED = 'rejected';
|
||||||
|
|
||||||
|
// 서명 순서
|
||||||
|
const SIGN_ORDER_COUNTERPART_FIRST = 'counterpart_first';
|
||||||
|
const SIGN_ORDER_CREATOR_FIRST = 'creator_first';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.3 EsignSigner 상수 및 숨김 필드
|
||||||
|
|
||||||
|
```php
|
||||||
|
// 역할
|
||||||
|
const ROLE_CREATOR = 'creator';
|
||||||
|
const ROLE_COUNTERPART = 'counterpart';
|
||||||
|
|
||||||
|
// 서명자 상태
|
||||||
|
const STATUS_WAITING = 'waiting';
|
||||||
|
const STATUS_NOTIFIED = 'notified';
|
||||||
|
const STATUS_AUTHENTICATED = 'authenticated';
|
||||||
|
const STATUS_SIGNED = 'signed';
|
||||||
|
const STATUS_REJECTED = 'rejected';
|
||||||
|
|
||||||
|
// API 응답에서 제외 (보안)
|
||||||
|
protected $hidden = ['access_token', 'otp_code'];
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.4 EsignAuditLog 액션 타입
|
||||||
|
|
||||||
|
```php
|
||||||
|
const ACTION_CREATED = 'created';
|
||||||
|
const ACTION_SENT = 'sent';
|
||||||
|
const ACTION_VIEWED = 'viewed';
|
||||||
|
const ACTION_OTP_SENT = 'otp_sent';
|
||||||
|
const ACTION_AUTHENTICATED = 'authenticated';
|
||||||
|
const ACTION_SIGNED = 'signed';
|
||||||
|
const ACTION_REJECTED = 'rejected';
|
||||||
|
const ACTION_COMPLETED = 'completed';
|
||||||
|
const ACTION_CANCELLED = 'cancelled';
|
||||||
|
const ACTION_REMINDED = 'reminded';
|
||||||
|
const ACTION_DOWNLOADED = 'downloaded';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.5 모델 관계도
|
||||||
|
|
||||||
|
```
|
||||||
|
EsignContract
|
||||||
|
├── signers() → HasMany → EsignSigner
|
||||||
|
├── signFields() → HasMany → EsignSignField
|
||||||
|
├── auditLogs() → HasMany → EsignAuditLog
|
||||||
|
└── creator() → BelongsTo → User
|
||||||
|
|
||||||
|
EsignSigner
|
||||||
|
├── contract() → BelongsTo → EsignContract
|
||||||
|
└── signFields() → HasMany → EsignSignField
|
||||||
|
|
||||||
|
EsignSignField
|
||||||
|
├── contract() → BelongsTo → EsignContract
|
||||||
|
└── signer() → BelongsTo → EsignSigner
|
||||||
|
|
||||||
|
EsignAuditLog
|
||||||
|
├── contract() → BelongsTo → EsignContract
|
||||||
|
└── signer() → BelongsTo → EsignSigner
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. FormRequest 검증 규칙
|
||||||
|
|
||||||
|
### 12.1 ContractStoreRequest (계약 생성)
|
||||||
|
|
||||||
|
| 필드 | 규칙 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| title | required, string, max:200 | 계약 제목 |
|
||||||
|
| description | nullable, string, max:2000 | 계약 설명 |
|
||||||
|
| file | required, file, mimes:pdf, max:20480 | PDF 파일 (최대 20MB) |
|
||||||
|
| sign_order_type | nullable, in:counterpart_first,creator_first | 서명 순서 |
|
||||||
|
| 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 | 상대방(을) 전화번호 |
|
||||||
|
|
||||||
|
### 12.2 FieldConfigureRequest (서명 위치 설정)
|
||||||
|
|
||||||
|
| 필드 | 규칙 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| fields | required, array, min:1 | 서명 필드 배열 |
|
||||||
|
| fields.*.signer_id | required, exists:esign_signers,id | 서명자 ID |
|
||||||
|
| fields.*.page_number | required, integer, min:1 | PDF 페이지 번호 |
|
||||||
|
| fields.*.position_x | required, numeric, between:0,100 | X 좌표 (%) |
|
||||||
|
| fields.*.position_y | required, numeric, between:0,100 | Y 좌표 (%) |
|
||||||
|
| fields.*.width | required, numeric, between:1,100 | 너비 (%) |
|
||||||
|
| fields.*.height | required, numeric, between:1,100 | 높이 (%) |
|
||||||
|
| fields.*.field_type | required, in:signature,stamp,text,date,checkbox | 필드 유형 |
|
||||||
|
| fields.*.field_label | nullable, string, max:100 | 필드 라벨 |
|
||||||
|
| fields.*.is_required | nullable, boolean | 필수 여부 |
|
||||||
|
| fields.*.sort_order | nullable, integer, min:0 | 정렬 순서 |
|
||||||
|
|
||||||
|
### 12.3 SignSubmitRequest (서명 제출)
|
||||||
|
|
||||||
|
| 필드 | 규칙 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| signature_image | required, string | Base64 인코딩 서명 이미지 |
|
||||||
|
|
||||||
|
### 12.4 SignRejectRequest (서명 거절)
|
||||||
|
|
||||||
|
| 필드 | 규칙 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| reason | required, string, max:1000 | 거절 사유 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. 에러 처리 패턴
|
||||||
|
|
||||||
|
### 13.1 ApiResponse::handle() 패턴
|
||||||
|
|
||||||
|
모든 컨트롤러는 `ApiResponse::handle()` 래퍼를 사용하여 일관된 응답 형식을 보장합니다.
|
||||||
|
|
||||||
|
```php
|
||||||
|
// 성공 응답
|
||||||
|
return ApiResponse::handle(
|
||||||
|
fn() => $this->contractService->create($request->validated()),
|
||||||
|
__('message.esign.contract_created'), // i18n 메시지 키
|
||||||
|
201
|
||||||
|
);
|
||||||
|
|
||||||
|
// 에러 시 자동 처리
|
||||||
|
// → 400: 잘못된 요청 (Validation)
|
||||||
|
// → 403: 권한 없음
|
||||||
|
// → 404: 리소스 없음
|
||||||
|
// → 500: 서버 에러
|
||||||
|
```
|
||||||
|
|
||||||
|
### 13.2 응답 구조
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 성공
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "계약이 성공적으로 생성되었습니다.",
|
||||||
|
"data": { ... }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 실패
|
||||||
|
{
|
||||||
|
"success": false,
|
||||||
|
"message": "에러 메시지",
|
||||||
|
"errors": { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 13.3 i18n 메시지 키
|
||||||
|
|
||||||
|
```php
|
||||||
|
// 성공 메시지 (message.esign.*)
|
||||||
|
'contract_created' => '계약이 성공적으로 생성되었습니다.',
|
||||||
|
'contract_cancelled' => '계약이 취소되었습니다.',
|
||||||
|
'contract_sent' => '서명 요청이 발송되었습니다.',
|
||||||
|
'fields_configured' => '서명 위치가 설정되었습니다.',
|
||||||
|
'otp_sent' => '인증코드가 발송되었습니다.',
|
||||||
|
'otp_verified' => '본인인증이 완료되었습니다.',
|
||||||
|
'signature_submitted' => '서명이 완료되었습니다.',
|
||||||
|
'contract_rejected' => '서명이 거절되었습니다.',
|
||||||
|
|
||||||
|
// 에러 메시지 (error.esign.*)
|
||||||
|
'contract_not_found' => '계약을 찾을 수 없습니다.',
|
||||||
|
'invalid_status' => '현재 상태에서는 이 작업을 수행할 수 없습니다.',
|
||||||
|
'token_expired' => '서명 링크가 만료되었습니다.',
|
||||||
|
'otp_max_attempts' => 'OTP 입력 횟수를 초과했습니다.',
|
||||||
|
'otp_invalid' => '인증코드가 일치하지 않습니다.',
|
||||||
|
'already_signed' => '이미 서명이 완료되었습니다.',
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Multi-tenant 아키텍처
|
||||||
|
|
||||||
|
### 14.1 데이터 격리
|
||||||
|
|
||||||
|
모든 E-Sign 테이블에 `tenant_id` 컬럼이 포함되어 있으며, `BelongsToTenant` trait의 글로벌 스코프에 의해 자동으로 현재 테넌트의 데이터만 조회됩니다.
|
||||||
|
|
||||||
|
```php
|
||||||
|
// BelongsToTenant trait의 글로벌 스코프
|
||||||
|
// → SELECT * FROM esign_contracts WHERE tenant_id = {현재 테넌트}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 14.2 비인증 접근 시 (공개 서명)
|
||||||
|
|
||||||
|
서명자(B)는 로그인 없이 토큰 기반으로 접근하므로, 테넌트 스코프를 우회해야 합니다.
|
||||||
|
|
||||||
|
```php
|
||||||
|
// EsignAuditService::logPublic()
|
||||||
|
// → withoutGlobalScopes()를 사용하여 tenant 스코프 우회
|
||||||
|
// → tenant_id를 명시적으로 전달
|
||||||
|
|
||||||
|
public function logPublic(int $tenantId, int $contractId, string $action, ...): EsignAuditLog
|
||||||
|
{
|
||||||
|
return EsignAuditLog::withoutGlobalScopes()->create([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'contract_id' => $contractId,
|
||||||
|
'action' => $action,
|
||||||
|
// ...
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 14.3 계약 코드 생성
|
||||||
|
|
||||||
|
테넌트 간 충돌을 방지하기 위해 날짜 + 랜덤 문자열 형식을 사용합니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
형식: ES-{YYYYMMDD}-{6자리 랜덤}
|
||||||
|
예시: ES-20260212-A1B2C3
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. 프론트엔드 아키텍처
|
||||||
|
|
||||||
|
### 15.1 React 하이브리드 패턴
|
||||||
|
|
||||||
|
모든 MNG 뷰는 Blade 레이아웃 안에서 React 18 컴포넌트를 마운트하는 하이브리드 방식입니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ Blade 레이아웃 (app.blade.php) │
|
||||||
|
│ ├── 사이드바 메뉴 (HTMX 기반) │
|
||||||
|
│ ├── 상단 헤더 │
|
||||||
|
│ └── 콘텐츠 영역 │
|
||||||
|
│ └── <div id="esign-xxx-root"></div> │
|
||||||
|
│ └── React 컴포넌트 마운트 │
|
||||||
|
│ └── API 호출 (fetch → api.sam.kr)│
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 15.2 CDN 의존성
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- React 18 -->
|
||||||
|
<script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||||||
|
|
||||||
|
<!-- Babel (JSX 브라우저 트랜스파일링) -->
|
||||||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||||
|
|
||||||
|
<!-- PDF.js (서명 위치 지정, 서명 수행 화면) -->
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
|
||||||
|
|
||||||
|
<!-- SignaturePad (서명 캡처) -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/signature_pad@4.1.7/dist/signature_pad.umd.min.js"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 15.3 HTMX 네비게이션
|
||||||
|
|
||||||
|
E-Sign 화면은 React를 사용하므로 HTMX 부분 로드 시 스크립트가 실행되지 않습니다.
|
||||||
|
따라서 모든 컨트롤러에서 `HX-Redirect` 헤더를 반환하여 전체 페이지를 로드합니다.
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function dashboard(Request $request): View|Response
|
||||||
|
{
|
||||||
|
if ($request->header('HX-Request')) {
|
||||||
|
return response('', 200)
|
||||||
|
->header('HX-Redirect', route('esign.dashboard'));
|
||||||
|
}
|
||||||
|
return view('esign.dashboard');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 15.4 React Root ID 매핑
|
||||||
|
|
||||||
|
| 화면 | Root Element ID |
|
||||||
|
|------|----------------|
|
||||||
|
| 대시보드 | `#esign-dashboard-root` |
|
||||||
|
| 계약 생성 | `#esign-create-root` |
|
||||||
|
| 계약 상세 | `#esign-detail-root` |
|
||||||
|
| 서명 위치 지정 | `#esign-fields-root` |
|
||||||
|
| 서명 요청 발송 | `#esign-send-root` |
|
||||||
|
| 본인인증 OTP | `#esign-auth-root` |
|
||||||
|
| 서명 수행 | `#esign-sign-root` |
|
||||||
|
| 서명 완료 | `#esign-done-root` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. 미구현 기능 (v1.1 이후)
|
||||||
|
|
||||||
|
| 기능 | 현재 상태 | 구현 방안 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| PDF 서명 합성 | 스텁 구현 | FPDI + FPDF 라이브러리로 원본 PDF에 서명 이미지 삽입 |
|
||||||
|
| 감사 증적 페이지 | 스텁 구현 | 완료 PDF 마지막 페이지에 서명 이력 자동 추가 |
|
||||||
|
| 파일 암호화 | 미구현 | AES-256-CBC로 원본 PDF 암호화 저장 |
|
||||||
|
| 자동 만료 처리 | 미구현 | Laravel Scheduler로 만료된 계약 상태 자동 변경 |
|
||||||
|
| 자동 리마인더 | 미구현 | 만료 3일 전 자동 알림 이메일 발송 |
|
||||||
|
| SMS OTP | 미구현 | CoolSMS/NHN Cloud 연동 |
|
||||||
|
| OTP bcrypt 해싱 | 미구현 | OTP 코드 DB 저장 시 bcrypt 적용 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*이 문서는 SAM E-Sign v1.0 구현 기준 기술 설계서입니다. 최종 업데이트: 2026-02-12*
|
||||||
|
|||||||
Reference in New Issue
Block a user