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 (가칭)
|
||||
> **작성일**: 2026-02-12
|
||||
> **버전**: v1.0 Draft
|
||||
> **버전**: v1.0 (구현 완료)
|
||||
> **작성자**: DX 추진팀
|
||||
|
||||
---
|
||||
@@ -86,10 +86,10 @@
|
||||
│ ┌──────────────────────────┐ │
|
||||
│ │ MySQL (sam-mysql-1) │ │
|
||||
│ │ │ │
|
||||
│ │ - contracts │ │
|
||||
│ │ - contract_signers │ │
|
||||
│ │ - contract_sign_fields │ │
|
||||
│ │ - contract_audit_logs │ │
|
||||
│ │ - esign_contracts │ │
|
||||
│ │ - esign_signers │ │
|
||||
│ │ - esign_sign_fields │ │
|
||||
│ │ - esign_audit_logs │ │
|
||||
│ └──────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──────────────────────────┐ │
|
||||
@@ -105,12 +105,10 @@
|
||||
|
||||
```
|
||||
app/Services/ESign/
|
||||
├── ContractService.php # 계약 CRUD, 상태 관리
|
||||
├── SignatureService.php # 서명 처리, 이미지 저장
|
||||
├── PdfService.php # PDF 합성, 해시 생성/검증
|
||||
├── AuthenticationService.php # OTP 생성/검증, 토큰 관리
|
||||
├── NotificationService.php # 이메일 발송 (서명 요청, 완료 알림)
|
||||
└── AuditLogService.php # 감사 추적 로그 기록
|
||||
├── EsignContractService.php # 계약 CRUD, 상태 관리, 발송/리마인더
|
||||
├── EsignSignService.php # 서명 처리, OTP 인증, 토큰 관리
|
||||
├── EsignPdfService.php # PDF 합성, 해시 생성/검증
|
||||
└── EsignAuditService.php # 감사 추적 로그 기록
|
||||
```
|
||||
|
||||
---
|
||||
@@ -158,7 +156,7 @@ app/Services/ESign/
|
||||
→ 서버: 토큰 유효성 검증 (만료, 사용 여부)
|
||||
2. 본인인증 게이트
|
||||
- 이메일로 6자리 OTP 발송
|
||||
- OTP 입력 (3회 제한, 5분 유효)
|
||||
- OTP 입력 (5회 제한, 5분 유효)
|
||||
→ 서버: 인증 성공 시 세션에 verified 상태 저장
|
||||
→ 감사 로그: 'identity_verified'
|
||||
3. 계약서 열람
|
||||
@@ -238,24 +236,24 @@ app/Services/ESign/
|
||||
### 4.1 ER 다이어그램 (텍스트)
|
||||
|
||||
```
|
||||
contracts (1) ──── (N) contract_signers
|
||||
│ │
|
||||
│ │
|
||||
(1) (1)
|
||||
│ │
|
||||
(N) (N)
|
||||
contract_sign_fields contract_audit_logs
|
||||
esign_contracts (1) ──── (N) esign_signers
|
||||
│ │
|
||||
│ │
|
||||
(1) (1)
|
||||
│ │
|
||||
(N) (N)
|
||||
esign_sign_fields esign_audit_logs
|
||||
```
|
||||
|
||||
### 4.2 contracts (계약서)
|
||||
### 4.2 esign_contracts (계약서)
|
||||
|
||||
```sql
|
||||
CREATE TABLE contracts (
|
||||
CREATE TABLE esign_contracts (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
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, -- 계약 제목
|
||||
description TEXT NULL, -- 계약 설명
|
||||
sign_order_type ENUM('counterpart_first', 'creator_first') DEFAULT 'counterpart_first',
|
||||
@@ -286,10 +284,10 @@ CREATE TABLE contracts (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
### 4.3 contract_signers (서명자)
|
||||
### 4.3 esign_signers (서명자)
|
||||
|
||||
```sql
|
||||
CREATE TABLE contract_signers (
|
||||
CREATE TABLE esign_signers (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
contract_id BIGINT UNSIGNED NOT NULL,
|
||||
|
||||
@@ -328,26 +326,26 @@ CREATE TABLE contract_signers (
|
||||
created_at TIMESTAMP DEFAULT 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_contract_role (contract_id, role)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
### 4.4 contract_sign_fields (서명 위치/필드)
|
||||
### 4.4 esign_sign_fields (서명 위치/필드)
|
||||
|
||||
```sql
|
||||
CREATE TABLE contract_sign_fields (
|
||||
CREATE TABLE esign_sign_fields (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
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부터)
|
||||
position_x DECIMAL(8,2) NOT NULL, -- X 좌표 (pt 단위)
|
||||
position_y DECIMAL(8,2) NOT NULL, -- Y 좌표 (pt 단위)
|
||||
width DECIMAL(8,2) NOT NULL, -- 너비 (pt)
|
||||
height DECIMAL(8,2) NOT NULL, -- 높이 (pt)
|
||||
position_x DECIMAL(8,4) NOT NULL, -- X 좌표 (% 단위, 0~100)
|
||||
position_y DECIMAL(8,4) NOT NULL, -- Y 좌표 (% 단위, 0~100)
|
||||
width DECIMAL(8,4) NOT NULL, -- 너비 (% 단위, 1~100)
|
||||
height DECIMAL(8,4) NOT NULL, -- 높이 (% 단위, 1~100)
|
||||
|
||||
-- 필드 정보
|
||||
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,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
FOREIGN KEY (contract_id) REFERENCES contracts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (signer_id) REFERENCES contract_signers(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (contract_id) REFERENCES esign_contracts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (signer_id) REFERENCES esign_signers(id) ON DELETE CASCADE,
|
||||
INDEX idx_contract_page (contract_id, page_number)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
```
|
||||
|
||||
### 4.5 contract_audit_logs (감사 추적 로그)
|
||||
### 4.5 esign_audit_logs (감사 추적 로그)
|
||||
|
||||
```sql
|
||||
CREATE TABLE contract_audit_logs (
|
||||
CREATE TABLE esign_audit_logs (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
contract_id BIGINT UNSIGNED NOT NULL,
|
||||
signer_id BIGINT UNSIGNED NULL, -- NULL이면 시스템 이벤트
|
||||
@@ -402,7 +400,7 @@ CREATE TABLE contract_audit_logs (
|
||||
|
||||
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_created_at (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -434,7 +432,7 @@ GET /api/v1/esign/contracts
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"contract_code": "ESIGN-2026-000001",
|
||||
"contract_code": "ES-20260212-A1B2C3",
|
||||
"title": "소프트웨어 개발 용역 계약서",
|
||||
"status": "pending",
|
||||
"signers": [
|
||||
@@ -471,7 +469,7 @@ Content-Type: multipart/form-data
|
||||
{
|
||||
"data": {
|
||||
"id": 1,
|
||||
"contract_code": "ESIGN-2026-000001",
|
||||
"contract_code": "ES-20260212-A1B2C3",
|
||||
"status": "draft",
|
||||
"original_file_hash": "a1b2c3d4e5f6...",
|
||||
"signers": [
|
||||
@@ -526,10 +524,10 @@ Content-Type: application/json
|
||||
{
|
||||
"signer_id": 1,
|
||||
"page_number": 3,
|
||||
"position_x": 120.5,
|
||||
"position_y": 650.0,
|
||||
"width": 150,
|
||||
"height": 60,
|
||||
"position_x": 15.5,
|
||||
"position_y": 82.0,
|
||||
"width": 20.0,
|
||||
"height": 8.0,
|
||||
"field_type": "signature",
|
||||
"field_label": "갑 (생성자) 서명",
|
||||
"is_required": true
|
||||
@@ -537,10 +535,10 @@ Content-Type: application/json
|
||||
{
|
||||
"signer_id": 2,
|
||||
"page_number": 3,
|
||||
"position_x": 350.5,
|
||||
"position_y": 650.0,
|
||||
"width": 150,
|
||||
"height": 60,
|
||||
"position_x": 55.5,
|
||||
"position_y": 82.0,
|
||||
"width": 20.0,
|
||||
"height": 8.0,
|
||||
"field_type": "signature",
|
||||
"field_label": "을 (상대방) 서명",
|
||||
"is_required": true
|
||||
@@ -548,10 +546,10 @@ Content-Type: application/json
|
||||
{
|
||||
"signer_id": 1,
|
||||
"page_number": 3,
|
||||
"position_x": 120.5,
|
||||
"position_y": 720.0,
|
||||
"width": 100,
|
||||
"height": 25,
|
||||
"position_x": 15.5,
|
||||
"position_y": 92.0,
|
||||
"width": 15.0,
|
||||
"height": 4.0,
|
||||
"field_type": "date",
|
||||
"field_label": "서명일",
|
||||
"is_required": true
|
||||
@@ -596,7 +594,7 @@ POST /api/v1/esign/sign/{access_token}/otp/send
|
||||
{
|
||||
"message": "인증코드가 이메일로 발송되었습니다",
|
||||
"expires_in": 300,
|
||||
"remaining_attempts": 3
|
||||
"remaining_attempts": 5
|
||||
}
|
||||
```
|
||||
|
||||
@@ -748,7 +746,7 @@ $isValid = hash_equals($contract->original_file_hash, $currentHash);
|
||||
인증 플로우:
|
||||
1. 서명 링크 접속 (access_token 검증)
|
||||
2. OTP 발송 (이메일)
|
||||
3. OTP 입력 (6자리, 5분 유효, 3회 제한)
|
||||
3. OTP 입력 (6자리, 5분 유효, 5회 제한)
|
||||
4. 인증 성공 → sign_session_token 발급 (JWT, 30분 유효)
|
||||
5. 이후 모든 서명 API 호출 시 sign_session_token 필요
|
||||
```
|
||||
@@ -759,7 +757,7 @@ $isValid = hash_equals($contract->original_file_hash, $currentHash);
|
||||
|------|-----|------|
|
||||
| OTP 길이 | 6자리 숫자 | 무작위 생성 |
|
||||
| 유효 시간 | 5분 | 초과 시 재발송 필요 |
|
||||
| 시도 제한 | 3회 | 초과 시 토큰 무효화 |
|
||||
| 시도 제한 | 5회 | 초과 시 토큰 무효화 |
|
||||
| 재발송 간격 | 60초 | 연속 발송 방지 |
|
||||
| 저장 방식 | bcrypt 해시 | DB에 평문 저장 금지 |
|
||||
|
||||
@@ -827,7 +825,7 @@ storage/app/esign/
|
||||
┌──────────────────────────────────────────┐
|
||||
│ 전자서명 감사 증적 (Audit Trail) │
|
||||
├──────────────────────────────────────────┤
|
||||
│ 계약 번호: ESIGN-2026-000001 │
|
||||
│ 계약 번호: ES-20260212-A1B2C3 │
|
||||
│ 문서 해시: a1b2c3d4e5f6... │
|
||||
│ │
|
||||
│ [서명자 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