From 621bb911db6470423f94dc71ae8728b6ce8f8c39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 12 Feb 2026 08:56:29 +0900 Subject: [PATCH] =?UTF-8?q?docs:E-Sign=20API=20=EB=AA=85=EC=84=B8=EC=84=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(16=EA=B0=9C=20=EC=97=94=EB=93=9C?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- projects/e-sign/api-specification.md | 1512 ++++++++++++++++++++++++++ 1 file changed, 1512 insertions(+) create mode 100644 projects/e-sign/api-specification.md diff --git a/projects/e-sign/api-specification.md b/projects/e-sign/api-specification.md new file mode 100644 index 0000000..ebe5bc2 --- /dev/null +++ b/projects/e-sign/api-specification.md @@ -0,0 +1,1512 @@ +# 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" + + +``` + +**에러 조건**: +- `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 + + +``` + +**참고**: `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