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