Files
sam-docs/projects/e-sign/api-specification.md

1513 lines
42 KiB
Markdown
Raw Normal View History

# 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