# SAM E-Sign API 명세서

> 버전: 1.0.0
> 최종 수정: 2026-02-12
> 작성자: IT 혁신팀

---

## 목차

1. [개요](#1-개요)
2. [인증](#2-인증)
3. [공통 규칙](#3-공통-규칙)
4. [계약 관리 API](#4-계약-관리-api)
5. [서명 프로세스 API](#5-서명-프로세스-api)
6. [데이터 모델](#6-데이터-모델)
7. [에러 코드](#7-에러-코드)
8. [워크플로우](#8-워크플로우)
9. [이메일 알림](#9-이메일-알림)
10. [보안](#10-보안)
11. [부록: Enum 값 정리](#11-부록-enum-값-정리)

---

## 1. 개요

### 1.1 기본 정보

| 항목 | 값 |
|------|-----|
| Base URL | `https://api.sam.kr/api/v1/esign` |
| 프로토콜 | HTTPS |
| 데이터 형식 | JSON (multipart/form-data는 파일 업로드 시) |
| 문자 인코딩 | UTF-8 |
| 인증 방식 | API Key + Bearer Token / Access Token |

### 1.2 API 그룹

| 그룹 | 경로 접두사 | 인증 | 엔드포인트 수 | 대상 |
|------|-----------|------|-------------|------|
| 계약 관리 | `/api/v1/esign/contracts` | API Key + Bearer Token | 10개 | MNG 관리자 |
| 서명 프로세스 | `/api/v1/esign/sign/{token}` | API Key + Access Token | 6개 | 외부 서명자 |

### 1.3 엔드포인트 요약

#### 계약 관리 API (인증 필요)

| # | Method | Path | 설명 |
|---|--------|------|------|
| 1 | GET | `/contracts` | 계약 목록 조회 |
| 2 | GET | `/contracts/stats` | 상태별 통계 조회 |
| 3 | GET | `/contracts/{id}` | 계약 상세 조회 |
| 4 | POST | `/contracts` | 계약 생성 |
| 5 | POST | `/contracts/{id}/fields` | 서명 필드 설정 |
| 6 | POST | `/contracts/{id}/send` | 서명 요청 발송 |
| 7 | POST | `/contracts/{id}/remind` | 리마인더 발송 |
| 8 | POST | `/contracts/{id}/cancel` | 계약 취소 |
| 9 | GET | `/contracts/{id}/download` | PDF 다운로드 |
| 10 | GET | `/contracts/{id}/verify` | 무결성 검증 |

#### 서명 프로세스 API (토큰 기반)

| # | Method | Path | 설명 |
|---|--------|------|------|
| 1 | GET | `/sign/{token}` | 계약 정보 조회 |
| 2 | POST | `/sign/{token}/otp/send` | OTP 발송 |
| 3 | POST | `/sign/{token}/otp/verify` | OTP 인증 |
| 4 | GET | `/sign/{token}/document` | 계약 문서 조회 |
| 5 | POST | `/sign/{token}/submit` | 서명 제출 |
| 6 | POST | `/sign/{token}/reject` | 서명 거절 |

---

## 2. 인증

### 2.1 API Key 인증

모든 API 요청에 API Key가 필요합니다.

```http
X-API-Key: {api-key}
```

- 미들웨어: `auth.apikey`
- API Key가 없거나 유효하지 않으면 `401 Unauthorized` 반환

### 2.2 Bearer Token 인증 (계약 관리 API)

계약 관리 API는 추가로 Bearer Token이 필요합니다.

```http
Authorization: Bearer {sanctum-token}
```

- 미들웨어: `auth:sanctum`
- Laravel Sanctum 기반
- 로그인 후 발급되는 Personal Access Token 사용

### 2.3 Access Token 인증 (서명 프로세스 API)

서명 프로세스 API는 URL 경로에 포함된 토큰으로 인증합니다.

```
/api/v1/esign/sign/{access_token}
```

- 128자 랜덤 문자열 (서명자별 고유)
- 계약 발송 시 자동 생성
- `token_expires_at` 이후 만료

---

## 3. 공통 규칙

### 3.1 응답 형식

#### 성공 응답

```json
{
    "success": true,
    "message": "요청 성공 메시지",
    "data": { ... }
}
```

#### 에러 응답

```json
{
    "success": false,
    "message": "[에러코드] 에러 메시지",
    "error": {
        "code": 404,
        "details": null
    }
}
```

#### 유효성 검증 에러 (422)

```json
{
    "message": "The title field is required.",
    "errors": {
        "title": ["The title field is required."],
        "file": ["The file must be a file of type: pdf."]
    }
}
```

### 3.2 페이지네이션

목록 조회 API는 Laravel 표준 페이지네이션 형식을 사용합니다.

```json
{
    "success": true,
    "data": {
        "current_page": 1,
        "data": [ ... ],
        "first_page_url": "...?page=1",
        "from": 1,
        "last_page": 5,
        "last_page_url": "...?page=5",
        "links": [ ... ],
        "next_page_url": "...?page=2",
        "path": "...",
        "per_page": 20,
        "prev_page_url": null,
        "to": 20,
        "total": 100
    }
}
```

### 3.3 Multi-Tenant

- 모든 데이터는 `tenant_id`로 격리됩니다
- `BelongsToTenant` 글로벌 스코프가 자동으로 필터링합니다
- 다른 테넌트의 데이터에는 접근할 수 없습니다

### 3.4 Soft Delete

- 계약(`esign_contracts`)은 Soft Delete를 지원합니다
- 삭제된 데이터는 `deleted_at` 필드에 삭제 시각이 기록됩니다
- 감사 로그(`esign_audit_logs`)는 삭제 불가능합니다

---

## 4. 계약 관리 API

> 미들웨어: `auth.apikey`, `auth:sanctum`
> 경로 접두사: `/api/v1/esign/contracts`

### 4.1 계약 목록 조회

```
GET /api/v1/esign/contracts
```

계약 목록을 페이지네이션으로 조회합니다. 필터 및 검색을 지원합니다.

**Query Parameters**

| 파라미터 | 타입 | 필수 | 기본값 | 설명 |
|---------|------|------|--------|------|
| `status` | string | - | - | 계약 상태 필터 |
| `search` | string | - | - | 제목 또는 계약 코드 검색 |
| `date_from` | date | - | - | 생성일 시작 (YYYY-MM-DD) |
| `date_to` | date | - | - | 생성일 종료 (YYYY-MM-DD) |
| `per_page` | integer | - | 20 | 페이지당 항목 수 |

**Request**

```http
GET /api/v1/esign/contracts?status=pending&search=공급&per_page=10 HTTP/1.1
X-API-Key: {api-key}
Authorization: Bearer {token}
```

**Response (200 OK)**

```json
{
    "success": true,
    "message": "데이터 조회 완료",
    "data": {
        "current_page": 1,
        "data": [
            {
                "id": 1,
                "tenant_id": 1,
                "contract_code": "ES-20260212-A1B2C3",
                "title": "제품 공급 계약서",
                "description": "2026년 제품 공급 계약",
                "sign_order_type": "counterpart_first",
                "original_file_name": "supply-contract.pdf",
                "original_file_size": 102400,
                "status": "pending",
                "expires_at": "2026-02-26T00:00:00.000000Z",
                "completed_at": null,
                "created_by": 1,
                "created_at": "2026-02-12T10:00:00.000000Z",
                "updated_at": "2026-02-12T10:30:00.000000Z",
                "signers": [
                    {
                        "id": 1,
                        "name": "김철수",
                        "email": "kim@example.com",
                        "role": "counterpart",
                        "status": "notified",
                        "signed_at": null
                    },
                    {
                        "id": 2,
                        "name": "이영희",
                        "email": "lee@company.com",
                        "role": "creator",
                        "status": "waiting",
                        "signed_at": null
                    }
                ],
                "creator": {
                    "id": 1,
                    "name": "관리자"
                }
            }
        ],
        "per_page": 10,
        "total": 25
    }
}
```

---

### 4.2 상태별 통계 조회

```
GET /api/v1/esign/contracts/stats
```

계약 상태별 건수를 집계합니다.

**Request**

```http
GET /api/v1/esign/contracts/stats HTTP/1.1
X-API-Key: {api-key}
Authorization: Bearer {token}
```

**Response (200 OK)**

```json
{
    "success": true,
    "message": "데이터 조회 완료",
    "data": {
        "total": 150,
        "draft": 10,
        "pending": 25,
        "partially_signed": 15,
        "completed": 80,
        "expired": 5,
        "cancelled": 10,
        "rejected": 5
    }
}
```

---

### 4.3 계약 상세 조회

```
GET /api/v1/esign/contracts/{id}
```

계약의 상세 정보를 서명자, 서명 필드, 감사 로그와 함께 조회합니다.

**Path Parameters**

| 파라미터 | 타입 | 설명 |
|---------|------|------|
| `id` | integer | 계약 ID |

**Request**

```http
GET /api/v1/esign/contracts/1 HTTP/1.1
X-API-Key: {api-key}
Authorization: Bearer {token}
```

**Response (200 OK)**

```json
{
    "success": true,
    "message": "데이터 조회 완료",
    "data": {
        "id": 1,
        "tenant_id": 1,
        "contract_code": "ES-20260212-A1B2C3",
        "title": "제품 공급 계약서",
        "description": "2026년 제품 공급 계약",
        "sign_order_type": "counterpart_first",
        "original_file_path": "esign/1/originals/abc123.pdf",
        "original_file_name": "supply-contract.pdf",
        "original_file_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
        "original_file_size": 102400,
        "signed_file_path": null,
        "signed_file_hash": null,
        "status": "pending",
        "expires_at": "2026-02-26T00:00:00.000000Z",
        "completed_at": null,
        "created_by": 1,
        "updated_by": 1,
        "created_at": "2026-02-12T10:00:00.000000Z",
        "updated_at": "2026-02-12T10:30:00.000000Z",
        "signers": [
            {
                "id": 1,
                "tenant_id": 1,
                "contract_id": 1,
                "role": "counterpart",
                "sign_order": 1,
                "name": "김철수",
                "email": "kim@example.com",
                "phone": "010-1234-5678",
                "token_expires_at": "2026-02-26T00:00:00.000000Z",
                "otp_attempts": 0,
                "auth_verified_at": null,
                "signature_image_path": null,
                "signed_at": null,
                "consent_agreed_at": null,
                "sign_ip_address": null,
                "sign_user_agent": null,
                "status": "notified",
                "rejected_reason": null
            },
            {
                "id": 2,
                "tenant_id": 1,
                "contract_id": 1,
                "role": "creator",
                "sign_order": 2,
                "name": "이영희",
                "email": "lee@company.com",
                "phone": "010-9876-5432",
                "status": "waiting"
            }
        ],
        "sign_fields": [
            {
                "id": 1,
                "contract_id": 1,
                "signer_id": 1,
                "page_number": 1,
                "position_x": "10.50",
                "position_y": "80.25",
                "width": "20.00",
                "height": "10.00",
                "field_type": "signature",
                "field_label": "상대방 서명",
                "field_value": null,
                "is_required": true,
                "sort_order": 0
            }
        ],
        "audit_logs": [
            {
                "id": 2,
                "contract_id": 1,
                "signer_id": null,
                "action": "sent",
                "ip_address": "192.168.1.100",
                "user_agent": "Mozilla/5.0...",
                "metadata": null,
                "created_at": "2026-02-12T10:30:00.000000Z"
            },
            {
                "id": 1,
                "contract_id": 1,
                "signer_id": null,
                "action": "created",
                "ip_address": "192.168.1.100",
                "user_agent": "Mozilla/5.0...",
                "metadata": null,
                "created_at": "2026-02-12T10:00:00.000000Z"
            }
        ],
        "creator": {
            "id": 1,
            "name": "관리자"
        }
    }
}
```

**Error (404)**

```json
{
    "success": false,
    "message": "[404] 데이터를 찾을 수 없습니다",
    "error": { "code": 404, "details": null }
}
```

---

### 4.4 계약 생성

```
POST /api/v1/esign/contracts
```

새 전자계약을 생성합니다. PDF 파일 업로드와 함께 작성자/상대방 정보를 등록합니다.

**Content-Type**: `multipart/form-data`

**Request Body**

| 필드 | 타입 | 필수 | 검증 규칙 | 설명 |
|------|------|------|----------|------|
| `title` | string | O | max:200 | 계약 제목 |
| `description` | string | - | max:2000 | 계약 설명 |
| `sign_order_type` | string | - | in:counterpart_first,creator_first | 서명 순서 (기본: counterpart_first) |
| `file` | file | O | mimes:pdf, max:20480 | PDF 파일 (최대 20MB) |
| `expires_at` | date | - | after:now | 만료일 (기본: 14일 후) |
| `creator_name` | string | O | max:100 | 작성자 이름 |
| `creator_email` | string | O | email, max:255 | 작성자 이메일 |
| `creator_phone` | string | - | max:20 | 작성자 전화번호 |
| `counterpart_name` | string | O | max:100 | 상대방 이름 |
| `counterpart_email` | string | O | email, max:255 | 상대방 이메일 |
| `counterpart_phone` | string | - | max:20 | 상대방 전화번호 |

**Request (cURL)**

```bash
curl -X POST https://api.sam.kr/api/v1/esign/contracts \
  -H "X-API-Key: {api-key}" \
  -H "Authorization: Bearer {token}" \
  -F "title=제품 공급 계약서" \
  -F "description=2026년 제품 공급 계약" \
  -F "sign_order_type=counterpart_first" \
  -F "file=@/path/to/contract.pdf" \
  -F "expires_at=2026-02-26" \
  -F "creator_name=이영희" \
  -F "creator_email=lee@company.com" \
  -F "creator_phone=010-9876-5432" \
  -F "counterpart_name=김철수" \
  -F "counterpart_email=kim@example.com" \
  -F "counterpart_phone=010-1234-5678"
```

**Response (200 OK)**

```json
{
    "success": true,
    "message": "계약이 생성되었습니다",
    "data": {
        "id": 1,
        "contract_code": "ES-20260212-A1B2C3",
        "title": "제품 공급 계약서",
        "description": "2026년 제품 공급 계약",
        "sign_order_type": "counterpart_first",
        "original_file_name": "contract.pdf",
        "original_file_hash": "e3b0c44298fc1c14...",
        "original_file_size": 102400,
        "status": "draft",
        "expires_at": "2026-02-26T00:00:00.000000Z",
        "created_by": 1,
        "signers": [
            {
                "id": 1,
                "role": "counterpart",
                "sign_order": 1,
                "name": "김철수",
                "email": "kim@example.com",
                "status": "waiting"
            },
            {
                "id": 2,
                "role": "creator",
                "sign_order": 2,
                "name": "이영희",
                "email": "lee@company.com",
                "status": "waiting"
            }
        ]
    }
}
```

**Error (422 Validation)**

```json
{
    "message": "The title field is required. (and 2 more errors)",
    "errors": {
        "title": ["The title field is required."],
        "file": ["The file must be a file of type: pdf."],
        "counterpart_email": ["The counterpart email must be a valid email address."]
    }
}
```

**처리 순서**:
1. PDF 파일을 `storage/app/esign/{tenant_id}/originals/` 에 저장
2. SHA-256 해시 생성 및 저장
3. 계약 코드 자동 생성 (`ES-YYYYMMDD-XXXXXX`)
4. 서명자 2명 생성 (creator, counterpart)
5. 각 서명자에게 128자 access_token 발급
6. 감사 로그 기록 (`created`)

---

### 4.5 서명 필드 설정

```
POST /api/v1/esign/contracts/{id}/fields
```

PDF 문서 위에 서명 위치를 설정합니다. 기존 필드는 삭제 후 재생성됩니다.

**Content-Type**: `application/json`

**Path Parameters**

| 파라미터 | 타입 | 설명 |
|---------|------|------|
| `id` | integer | 계약 ID |

**Request Body**

| 필드 | 타입 | 필수 | 검증 규칙 | 설명 |
|------|------|------|----------|------|
| `fields` | array | O | min:1 | 서명 필드 배열 |
| `fields.*.signer_id` | integer | O | exists:esign_signers,id | 서명자 ID |
| `fields.*.page_number` | integer | O | min:1 | 페이지 번호 |
| `fields.*.position_x` | numeric | O | min:0, max:100 | X 좌표 (%) |
| `fields.*.position_y` | numeric | O | min:0, max:100 | Y 좌표 (%) |
| `fields.*.width` | numeric | O | min:1, max:100 | 너비 (%) |
| `fields.*.height` | numeric | O | min:1, max:100 | 높이 (%) |
| `fields.*.field_type` | string | - | in:signature,stamp,text,date,checkbox | 유형 (기본: signature) |
| `fields.*.field_label` | string | - | max:100 | 라벨 |
| `fields.*.is_required` | boolean | - | - | 필수 여부 (기본: true) |
| `fields.*.sort_order` | integer | - | min:0 | 정렬 순서 (기본: 0) |

**Request**

```json
POST /api/v1/esign/contracts/1/fields

{
    "fields": [
        {
            "signer_id": 1,
            "page_number": 3,
            "position_x": 10.5,
            "position_y": 80.25,
            "width": 20.0,
            "height": 10.0,
            "field_type": "signature",
            "field_label": "상대방 서명",
            "is_required": true,
            "sort_order": 0
        },
        {
            "signer_id": 2,
            "page_number": 3,
            "position_x": 60.0,
            "position_y": 80.25,
            "width": 20.0,
            "height": 10.0,
            "field_type": "signature",
            "field_label": "작성자 서명",
            "is_required": true,
            "sort_order": 1
        }
    ]
}
```

**Response (200 OK)**

```json
{
    "success": true,
    "message": "서명 필드가 설정되었습니다",
    "data": {
        "id": 1,
        "contract_code": "ES-20260212-A1B2C3",
        "status": "draft",
        "sign_fields": [
            {
                "id": 1,
                "signer_id": 1,
                "page_number": 3,
                "position_x": "10.50",
                "position_y": "80.25",
                "width": "20.00",
                "height": "10.00",
                "field_type": "signature",
                "field_label": "상대방 서명",
                "is_required": true,
                "sort_order": 0
            },
            {
                "id": 2,
                "signer_id": 2,
                "page_number": 3,
                "position_x": "60.00",
                "position_y": "80.25",
                "width": "20.00",
                "height": "10.00",
                "field_type": "signature",
                "field_label": "작성자 서명",
                "is_required": true,
                "sort_order": 1
            }
        ]
    }
}
```

**에러 조건**:
- `404`: 계약을 찾을 수 없음
- `400`: `draft` 상태가 아닌 계약 → 필드 설정 불가

**좌표 체계**:
- 모든 좌표는 **백분율(%)** 기준 (0~100)
- `position_x`: 페이지 왼쪽 기준 가로 위치
- `position_y`: 페이지 상단 기준 세로 위치
- `width`, `height`: 페이지 대비 크기

---

### 4.6 서명 요청 발송

```
POST /api/v1/esign/contracts/{id}/send
```

계약을 발송하고 첫 번째 서명자에게 이메일을 보냅니다.

**Path Parameters**

| 파라미터 | 타입 | 설명 |
|---------|------|------|
| `id` | integer | 계약 ID |

**Request**

```http
POST /api/v1/esign/contracts/1/send HTTP/1.1
X-API-Key: {api-key}
Authorization: Bearer {token}
```

**Response (200 OK)**

```json
{
    "success": true,
    "message": "계약이 발송되었습니다",
    "data": {
        "id": 1,
        "contract_code": "ES-20260212-A1B2C3",
        "status": "pending",
        "signers": [
            {
                "id": 1,
                "status": "notified",
                "email": "kim@example.com"
            }
        ]
    }
}
```

**전제 조건**:
- 계약 상태가 `draft`여야 함
- 서명 필드가 최소 1개 이상 설정되어 있어야 함

**처리 순서**:
1. 계약 상태: `draft` → `pending`
2. 첫 번째 서명자(sign_order=1) 상태: `waiting` → `notified`
3. 첫 번째 서명자에게 `EsignRequestMail` 발송
4. 감사 로그 기록 (`sent`)

---

### 4.7 리마인더 발송

```
POST /api/v1/esign/contracts/{id}/remind
```

아직 서명하지 않은 서명자에게 알림 이메일을 재발송합니다.

**Path Parameters**

| 파라미터 | 타입 | 설명 |
|---------|------|------|
| `id` | integer | 계약 ID |

**Response (200 OK)**

```json
{
    "success": true,
    "message": "서명 알림이 재발송되었습니다",
    "data": {
        "id": 1,
        "contract_code": "ES-20260212-A1B2C3",
        "status": "pending"
    }
}
```

**전제 조건**:
- 계약 상태가 `pending` 또는 `partially_signed`여야 함

---

### 4.8 계약 취소

```
POST /api/v1/esign/contracts/{id}/cancel
```

진행 중인 계약을 취소합니다.

**Path Parameters**

| 파라미터 | 타입 | 설명 |
|---------|------|------|
| `id` | integer | 계약 ID |

**Response (200 OK)**

```json
{
    "success": true,
    "message": "계약이 취소되었습니다",
    "data": {
        "id": 1,
        "contract_code": "ES-20260212-A1B2C3",
        "status": "cancelled"
    }
}
```

**에러 조건**:
- `400`: 이미 `completed` 또는 `cancelled` 상태인 계약

---

### 4.9 계약 파일 다운로드

```
GET /api/v1/esign/contracts/{id}/download
```

계약 PDF 파일을 다운로드합니다. 서명 완료 시 서명된 파일, 미완료 시 원본을 반환합니다.

**Path Parameters**

| 파라미터 | 타입 | 설명 |
|---------|------|------|
| `id` | integer | 계약 ID |

**Response (200 OK)**

```
Content-Type: application/pdf
Content-Disposition: attachment; filename="supply-contract.pdf"

<binary PDF data>
```

**에러 조건**:
- `404`: 계약 또는 파일을 찾을 수 없음

---

### 4.10 무결성 검증

```
GET /api/v1/esign/contracts/{id}/verify
```

저장된 해시와 현재 파일의 해시를 비교하여 문서 변조 여부를 확인합니다.

**Path Parameters**

| 파라미터 | 타입 | 설명 |
|---------|------|------|
| `id` | integer | 계약 ID |

**Response (200 OK)**

```json
{
    "success": true,
    "message": "무결성 검증 완료",
    "data": {
        "verified": true,
        "original_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
    }
}
```

변조가 감지된 경우:

```json
{
    "success": true,
    "message": "무결성 검증 완료",
    "data": {
        "verified": false,
        "original_hash": "e3b0c44298fc1c14..."
    }
}
```

---

## 5. 서명 프로세스 API

> 미들웨어: `auth.apikey`
> 경로 접두사: `/api/v1/esign/sign/{token}`
> 인증: Access Token (URL 경로 포함)

### 5.1 계약 정보 조회

```
GET /api/v1/esign/sign/{token}
```

토큰으로 계약 및 서명자 정보를 조회합니다. 서명자가 이메일 링크를 클릭하면 호출됩니다.

**Path Parameters**

| 파라미터 | 타입 | 설명 |
|---------|------|------|
| `token` | string | 서명자 Access Token (128자) |

**Response (200 OK)**

```json
{
    "success": true,
    "message": "데이터 조회 완료",
    "data": {
        "contract": {
            "id": 1,
            "contract_code": "ES-20260212-A1B2C3",
            "title": "제품 공급 계약서",
            "description": "2026년 제품 공급 계약",
            "status": "pending",
            "expires_at": "2026-02-26T00:00:00.000000Z",
            "signers": [
                {
                    "id": 1,
                    "name": "김철수",
                    "role": "counterpart",
                    "status": "notified",
                    "signed_at": null
                }
            ]
        },
        "signer": {
            "id": 1,
            "role": "counterpart",
            "sign_order": 1,
            "name": "김철수",
            "email": "kim@example.com",
            "phone": "010-1234-5678",
            "status": "notified",
            "auth_verified_at": null,
            "signed_at": null
        }
    }
}
```

**에러 조건**:
- `404`: 유효하지 않은 토큰
- `400`: 토큰 만료

---

### 5.2 OTP 발송

```
POST /api/v1/esign/sign/{token}/otp/send
```

서명자 이메일로 6자리 OTP 코드를 발송합니다.

**Path Parameters**

| 파라미터 | 타입 | 설명 |
|---------|------|------|
| `token` | string | 서명자 Access Token |

**Response (200 OK)**

```json
{
    "success": true,
    "message": "OTP가 발송되었습니다",
    "data": {
        "message": "OTP가 발송되었습니다"
    }
}
```

**참고**:
- OTP: 6자리 숫자 (100000~999999)
- 유효 시간: 5분
- 최대 시도 횟수: 5회
- 재발송 시 기존 OTP는 무효화되고 새 OTP 발급
- 개발 환경: OTP가 로그에 출력됨

---

### 5.3 OTP 인증

```
POST /api/v1/esign/sign/{token}/otp/verify
```

발송된 OTP를 검증하고 세션 토큰을 발급합니다.

**Path Parameters**

| 파라미터 | 타입 | 설명 |
|---------|------|------|
| `token` | string | 서명자 Access Token |

**Request Body**

| 필드 | 타입 | 필수 | 검증 규칙 | 설명 |
|------|------|------|----------|------|
| `otp_code` | string | O | size:6 | OTP 코드 |

**Request**

```json
{
    "otp_code": "482637"
}
```

**Response (200 OK)**

```json
{
    "success": true,
    "message": "OTP 인증이 완료되었습니다",
    "data": {
        "sign_session_token": "a3f8b2c1d4e5f6...",
        "signer": {
            "id": 1,
            "status": "authenticated",
            "auth_verified_at": "2026-02-12T11:00:00.000000Z"
        }
    }
}
```

**에러 조건**:

| 상태 코드 | 조건 | 메시지 |
|----------|------|--------|
| 400 | OTP 미발송 | OTP가 발송되지 않았습니다 |
| 400 | OTP 만료 (5분 초과) | OTP가 만료되었습니다 |
| 400 | OTP 불일치 | OTP가 일치하지 않습니다 |
| 400 | 시도 횟수 초과 (5회) | OTP 최대 시도 횟수를 초과했습니다 |

---

### 5.4 계약 문서 조회

```
GET /api/v1/esign/sign/{token}/document
```

계약 PDF를 브라우저에서 표시할 수 있도록 스트리밍합니다.

**Path Parameters**

| 파라미터 | 타입 | 설명 |
|---------|------|------|
| `token` | string | 서명자 Access Token |

**Response (200 OK)**

```
Content-Type: application/pdf

<binary PDF data>
```

**참고**: `Content-Disposition` 헤더 없음 (브라우저 인라인 표시)

---

### 5.5 서명 제출

```
POST /api/v1/esign/sign/{token}/submit
```

서명 이미지를 제출하고 서명을 완료합니다.

**Path Parameters**

| 파라미터 | 타입 | 설명 |
|---------|------|------|
| `token` | string | 서명자 Access Token |

**Request Body**

| 필드 | 타입 | 필수 | 설명 |
|------|------|------|------|
| `signature_image` | string | O | Base64 인코딩된 서명 이미지 (PNG) |

**Request**

```json
{
    "signature_image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA..."
}
```

**Response (200 OK)**

```json
{
    "success": true,
    "message": "서명이 완료되었습니다",
    "data": {
        "id": 1,
        "contract_id": 1,
        "name": "김철수",
        "status": "signed",
        "signed_at": "2026-02-12T11:05:00.000000Z",
        "consent_agreed_at": "2026-02-12T11:05:00.000000Z",
        "sign_ip_address": "203.246.xx.xx",
        "signature_image_path": "esign/1/signatures/1_1.png"
    }
}
```

**전제 조건**:
- OTP 인증 완료 필수 (`auth_verified_at` 존재)
- 아직 서명하지 않은 상태여야 함
- 계약이 서명 가능 상태여야 함 (pending 또는 partially_signed)

**처리 순서**:
1. Base64 이미지 → PNG 파일로 저장 (`esign/{tenant_id}/signatures/{contract_id}_{signer_id}.png`)
2. 서명자 상태: `authenticated` → `signed`
3. `signed_at`, `consent_agreed_at`, IP, User Agent 기록
4. 감사 로그 기록 (`signed`)
5. 모든 서명자 완료 여부 확인 → 완료 시 계약 상태: `completed`
6. 순차 서명인 경우 다음 서명자에게 이메일 발송

---

### 5.6 서명 거절

```
POST /api/v1/esign/sign/{token}/reject
```

서명을 거절합니다. 계약 전체가 거절 상태로 변경됩니다.

**Path Parameters**

| 파라미터 | 타입 | 설명 |
|---------|------|------|
| `token` | string | 서명자 Access Token |

**Request Body**

| 필드 | 타입 | 필수 | 검증 규칙 | 설명 |
|------|------|------|----------|------|
| `reason` | string | O | max:1000 | 거절 사유 |

**Request**

```json
{
    "reason": "계약 조건에 대해 추가 협의가 필요합니다."
}
```

**Response (200 OK)**

```json
{
    "success": true,
    "message": "서명이 거절되었습니다",
    "data": {
        "id": 1,
        "contract_id": 1,
        "name": "김철수",
        "status": "rejected",
        "rejected_reason": "계약 조건에 대해 추가 협의가 필요합니다."
    }
}
```

**처리 순서**:
1. 서명자 상태: → `rejected`
2. `rejected_reason` 기록
3. 계약 상태: → `rejected`
4. 감사 로그 기록 (`rejected`)

---

## 6. 데이터 모델

### 6.1 esign_contracts (계약)

| 컬럼 | 타입 | NULL | 기본값 | 설명 |
|------|------|------|--------|------|
| id | bigint unsigned | NO | AUTO | PK |
| tenant_id | bigint unsigned | NO | - | 테넌트 ID (FK) |
| contract_code | varchar(50) | NO | - | 계약 코드 (UNIQUE) |
| title | varchar(200) | NO | - | 계약 제목 |
| description | text | YES | NULL | 계약 설명 |
| sign_order_type | enum | NO | counterpart_first | 서명 순서 |
| original_file_path | varchar(500) | NO | - | 원본 PDF 경로 |
| original_file_name | varchar(255) | NO | - | 원본 파일명 |
| original_file_hash | varchar(64) | NO | - | SHA-256 해시 |
| original_file_size | int unsigned | NO | - | 파일 크기 (bytes) |
| signed_file_path | varchar(500) | YES | NULL | 서명 PDF 경로 |
| signed_file_hash | varchar(64) | YES | NULL | 서명 파일 해시 |
| status | enum | NO | draft | 계약 상태 |
| expires_at | datetime | YES | NULL | 만료일시 |
| completed_at | datetime | YES | NULL | 완료일시 |
| created_by | bigint unsigned | NO | - | 생성자 ID |
| updated_by | bigint unsigned | NO | - | 수정자 ID |
| deleted_by | bigint unsigned | YES | NULL | 삭제자 ID |
| created_at | timestamp | NO | CURRENT | 생성일시 |
| updated_at | timestamp | NO | CURRENT | 수정일시 |
| deleted_at | timestamp | YES | NULL | 삭제일시 |

**인덱스**: `contract_code` (UNIQUE), `tenant_id` + `status` (INDEX)

### 6.2 esign_signers (서명자)

| 컬럼 | 타입 | NULL | 기본값 | 설명 |
|------|------|------|--------|------|
| id | bigint unsigned | NO | AUTO | PK |
| tenant_id | bigint unsigned | NO | - | 테넌트 ID |
| contract_id | bigint unsigned | NO | - | 계약 ID (FK) |
| role | enum | NO | - | 역할 (creator/counterpart) |
| sign_order | tinyint unsigned | NO | - | 서명 순서 |
| name | varchar(100) | NO | - | 이름 |
| email | varchar(255) | NO | - | 이메일 |
| phone | varchar(20) | YES | NULL | 전화번호 |
| access_token | varchar(128) | NO | - | 액세스 토큰 (UNIQUE) |
| token_expires_at | datetime | YES | NULL | 토큰 만료일시 |
| otp_code | varchar(6) | YES | NULL | OTP 코드 |
| otp_expires_at | datetime | YES | NULL | OTP 만료일시 |
| otp_attempts | tinyint unsigned | NO | 0 | OTP 시도 횟수 |
| auth_verified_at | datetime | YES | NULL | 인증 완료 일시 |
| signature_image_path | varchar(500) | YES | NULL | 서명 이미지 경로 |
| signed_at | datetime | YES | NULL | 서명 일시 |
| consent_agreed_at | datetime | YES | NULL | 동의 일시 |
| sign_ip_address | varchar(45) | YES | NULL | 서명 IP (IPv6 지원) |
| sign_user_agent | varchar(500) | YES | NULL | 서명 User Agent |
| status | enum | NO | waiting | 서명자 상태 |
| rejected_reason | text | YES | NULL | 거절 사유 |
| created_at | timestamp | NO | CURRENT | 생성일시 |
| updated_at | timestamp | NO | CURRENT | 수정일시 |

**인덱스**: `access_token` (UNIQUE), `contract_id` (INDEX)

### 6.3 esign_sign_fields (서명 필드)

| 컬럼 | 타입 | NULL | 기본값 | 설명 |
|------|------|------|--------|------|
| id | bigint unsigned | NO | AUTO | PK |
| tenant_id | bigint unsigned | NO | - | 테넌트 ID |
| contract_id | bigint unsigned | NO | - | 계약 ID (FK) |
| signer_id | bigint unsigned | NO | - | 서명자 ID (FK) |
| page_number | int unsigned | NO | - | 페이지 번호 |
| position_x | decimal(5,2) | NO | - | X 좌표 (%) |
| position_y | decimal(5,2) | NO | - | Y 좌표 (%) |
| width | decimal(5,2) | NO | - | 너비 (%) |
| height | decimal(5,2) | NO | - | 높이 (%) |
| field_type | enum | NO | signature | 필드 유형 |
| field_label | varchar(100) | YES | NULL | 필드 라벨 |
| field_value | text | YES | NULL | 필드 값 |
| is_required | boolean | NO | true | 필수 여부 |
| sort_order | int | NO | 0 | 정렬 순서 |
| created_at | timestamp | NO | CURRENT | 생성일시 |
| updated_at | timestamp | NO | CURRENT | 수정일시 |

### 6.4 esign_audit_logs (감사 로그)

| 컬럼 | 타입 | NULL | 기본값 | 설명 |
|------|------|------|--------|------|
| id | bigint unsigned | NO | AUTO | PK |
| tenant_id | bigint unsigned | NO | - | 테넌트 ID |
| contract_id | bigint unsigned | NO | - | 계약 ID (FK) |
| signer_id | bigint unsigned | YES | NULL | 서명자 ID (FK) |
| action | varchar(50) | NO | - | 액션 유형 |
| ip_address | varchar(45) | YES | NULL | IP 주소 |
| user_agent | varchar(500) | YES | NULL | User Agent |
| metadata | json | YES | NULL | 추가 메타데이터 |
| created_at | timestamp | NO | CURRENT | 생성일시 |

**특징**: `updated_at` 없음, Soft Delete 없음 (삭제 불가)

---

## 7. 에러 코드

### 7.1 HTTP 상태 코드

| 코드 | 설명 | 사용 상황 |
|------|------|----------|
| 200 | 성공 | 모든 성공 응답 |
| 400 | Bad Request | 비즈니스 로직 에러 |
| 401 | Unauthorized | 인증 실패 |
| 404 | Not Found | 리소스 없음 |
| 422 | Unprocessable Entity | 유효성 검증 실패 |
| 500 | Internal Server Error | 서버 오류 |

### 7.2 E-Sign 에러 메시지

| 키 (i18n) | 메시지 | 상태 코드 |
|-----------|--------|----------|
| `error.esign.invalid_token` | 유효하지 않은 토큰입니다 | 404 |
| `error.esign.token_expired` | 토큰이 만료되었습니다 | 400 |
| `error.esign.already_completed` | 이미 완료된 계약입니다 | 400 |
| `error.esign.already_cancelled` | 이미 취소된 계약입니다 | 400 |
| `error.esign.invalid_status_for_send` | 발송 가능한 상태가 아닙니다 | 400 |
| `error.esign.no_sign_fields` | 서명 필드가 설정되지 않았습니다 | 400 |
| `error.esign.cannot_remind` | 알림을 발송할 수 없습니다 | 400 |
| `error.esign.fields_only_in_draft` | 초안 상태에서만 필드를 설정할 수 있습니다 | 400 |
| `error.esign.contract_not_signable` | 서명 가능한 계약이 아닙니다 | 400 |
| `error.esign.otp_max_attempts` | OTP 최대 시도 횟수를 초과했습니다 | 400 |
| `error.esign.otp_not_sent` | OTP가 발송되지 않았습니다 | 400 |
| `error.esign.otp_expired` | OTP가 만료되었습니다 | 400 |
| `error.esign.otp_invalid` | OTP가 일치하지 않습니다 | 400 |
| `error.esign.not_verified` | OTP 인증이 필요합니다 | 400 |
| `error.esign.already_signed` | 이미 서명되었습니다 | 400 |
| `error.esign.file_not_found` | 파일을 찾을 수 없습니다 | 404 |

---

## 8. 워크플로우

### 8.1 기본 서명 플로우

```
[관리자]                              [시스템]                            [서명자]
   │                                    │                                  │
   │── POST /contracts ──────────────>│                                  │
   │<── 201 계약 생성 (draft) ────────│                                  │
   │                                    │                                  │
   │── POST /contracts/{id}/fields ──>│                                  │
   │<── 200 필드 설정 완료 ───────────│                                  │
   │                                    │                                  │
   │── POST /contracts/{id}/send ────>│                                  │
   │<── 200 발송 완료 (pending) ──────│── Email 발송 ──────────────────>│
   │                                    │                                  │
   │                                    │<── GET /sign/{token} ───────────│
   │                                    │── 200 계약 정보 ───────────────>│
   │                                    │                                  │
   │                                    │<── POST /sign/{token}/otp/send ─│
   │                                    │── 200 OTP 발송 ────────────────>│
   │                                    │                                  │
   │                                    │<── POST /sign/{token}/otp/verify│
   │                                    │── 200 인증 완료 + 세션토큰 ────>│
   │                                    │                                  │
   │                                    │<── GET /sign/{token}/document ──│
   │                                    │── 200 PDF 스트리밍 ────────────>│
   │                                    │                                  │
   │                                    │<── POST /sign/{token}/submit ───│
   │                                    │── 200 서명 완료 ───────────────>│
   │                                    │                                  │
   │                                    │ (다음 서명자에게 Email)          │
   │                                    │                                  │
   │                                    │ (모든 서명 완료 → completed)     │
```

### 8.2 상태 전이 다이어그램

#### 계약 상태

```
draft ──(send)──> pending ──(1명 서명)──> partially_signed ──(전원 서명)──> completed
  │                  │                          │
  │                  │──(거절)──> rejected       │──(거절)──> rejected
  │                  │                          │
  │──(취소)──> cancelled   ──(취소)──> cancelled     ──(취소)──> cancelled
  │
  │──(만료)──> expired
```

#### 서명자 상태

```
waiting ──(알림)──> notified ──(OTP인증)──> authenticated ──(서명)──> signed
                      │                         │
                      │──(거절)──> rejected      │──(거절)──> rejected
```

### 8.3 순차 서명 (counterpart_first)

```
1. 계약 발송 → 상대방(sign_order=1)에게 이메일
2. 상대방 서명 완료 → 계약 상태: partially_signed
3. 작성자(sign_order=2)에게 자동 이메일 발송
4. 작성자 서명 완료 → 계약 상태: completed
```

### 8.4 순차 서명 (creator_first)

```
1. 계약 발송 → 작성자(sign_order=1)에게 이메일
2. 작성자 서명 완료 → 계약 상태: partially_signed
3. 상대방(sign_order=2)에게 자동 이메일 발송
4. 상대방 서명 완료 → 계약 상태: completed
```

---

## 9. 이메일 알림

### 9.1 EsignRequestMail

서명 요청 이메일을 발송합니다.

| 항목 | 값 |
|------|-----|
| 발송 주체 | `shine1324@gmail.com` (Gmail SMTP) |
| 수신자 | 서명자 이메일 |
| 제목 | `[SAM E-Sign] 전자계약 서명을 요청합니다` |
| 발송 방식 | `Mail::queue()` (비동기) |

**발송 시점**:
- 계약 발송 시: 첫 번째 서명자에게
- 이전 서명자 서명 완료 시: 다음 서명자에게
- 리마인더 발송 시: 미서명 서명자에게

**이메일 내용**:
- 계약 제목
- 계약 설명
- 만료일
- 서명 링크: `{MNG_URL}/esign/sign/{access_token}`

---

## 10. 보안

### 10.1 인증 계층

| 계층 | 방식 | 대상 |
|------|------|------|
| 1단계 | API Key | 모든 API |
| 2단계 | Bearer Token (Sanctum) | 계약 관리 API |
| 2단계 | Access Token (128자) | 서명 프로세스 API |
| 3단계 | OTP (6자리, 5분) | 서명 제출 전 |

### 10.2 데이터 보호

| 항목 | 방법 |
|------|------|
| 파일 무결성 | SHA-256 해시 생성 및 검증 |
| 데이터 격리 | Multi-Tenant (`tenant_id` 글로벌 스코프) |
| 감사 추적 | 모든 액션 로그 (IP, User Agent, 시각) |
| 토큰 보안 | 128자 랜덤 + 만료일 |
| OTP 보안 | 5분 만료 + 최대 5회 시도 |
| 전송 보안 | HTTPS (TLS) |

### 10.3 파일 저장 경로

```
storage/app/esign/{tenant_id}/
├── originals/          # 원본 PDF
│   └── {uuid}.pdf
├── signatures/         # 서명 이미지
│   └── {contract_id}_{signer_id}.png
└── signed/             # 서명 완료 PDF
    └── {uuid}_signed.pdf
```

---

## 11. 부록: Enum 값 정리

### 계약 상태 (esign_contracts.status)

| 값 | 한글 | 설명 |
|----|------|------|
| `draft` | 초안 | 생성됨, 아직 발송 전 |
| `pending` | 대기 | 서명 요청 발송됨 |
| `partially_signed` | 일부 서명 | 1명 이상 서명 완료 |
| `completed` | 완료 | 모든 서명 완료 |
| `expired` | 만료 | 기한 초과 |
| `cancelled` | 취소 | 관리자가 취소 |
| `rejected` | 거절 | 서명자가 거절 |

### 서명 순서 (esign_contracts.sign_order_type)

| 값 | 한글 | 설명 |
|----|------|------|
| `counterpart_first` | 상대방 먼저 | 상대방 → 작성자 순서 |
| `creator_first` | 작성자 먼저 | 작성자 → 상대방 순서 |

### 서명자 역할 (esign_signers.role)

| 값 | 한글 | 설명 |
|----|------|------|
| `creator` | 작성자 | 계약 작성자 (갑) |
| `counterpart` | 상대방 | 서명 상대방 (을) |

### 서명자 상태 (esign_signers.status)

| 값 | 한글 | 설명 |
|----|------|------|
| `waiting` | 대기 | 초기 상태 |
| `notified` | 알림됨 | 이메일 발송됨 |
| `authenticated` | 인증됨 | OTP 인증 완료 |
| `signed` | 서명됨 | 서명 제출 완료 |
| `rejected` | 거절됨 | 서명 거절 |

### 필드 유형 (esign_sign_fields.field_type)

| 값 | 한글 | 설명 |
|----|------|------|
| `signature` | 서명 | 수기 서명 |
| `stamp` | 도장 | 도장 이미지 |
| `text` | 텍스트 | 텍스트 입력 |
| `date` | 날짜 | 날짜 입력 |
| `checkbox` | 체크박스 | 체크 표시 |

### 감사 로그 액션 (esign_audit_logs.action)

| 값 | 한글 | 설명 |
|----|------|------|
| `created` | 생성 | 계약 생성 |
| `sent` | 발송 | 서명 요청 발송 |
| `viewed` | 조회 | 서명자가 계약 조회 |
| `otp_sent` | OTP 발송 | OTP 이메일 발송 |
| `authenticated` | 인증 | OTP 인증 완료 |
| `signed` | 서명 | 서명 제출 |
| `rejected` | 거절 | 서명 거절 |
| `completed` | 완료 | 모든 서명 완료 |
| `cancelled` | 취소 | 관리자가 계약 취소 |
| `reminded` | 리마인드 | 리마인더 발송 |
| `downloaded` | 다운로드 | PDF 파일 다운로드 |

---

> **문서 끝** | SAM E-Sign API 명세서 v1.0.0
