## 2025-11-17 (일) - BP-MES Phase 1: Items BOM API 및 File Upload API 구현 완료 ### 주요 작업 - BP-MES Phase 1 Day 6-9: Items BOM API 구현 (Code-based Adapter 패턴) - BP-MES Phase 1 Day 10-12: Items File Upload API 구현 (절곡도, 시방서, 인정서) - BP-MES Phase 1 Day 13-14: 통합 테스트 가이드 작성 ### 추가된 파일 #### Items BOM API (Day 6-9) 1. **app/Http/Controllers/Api/V1/ItemsBomController.php** (184줄) - Code-based BOM API Adapter - ProductBomService 100% 재사용 - 10개 엔드포인트 구현 - Code→ID 변환 후 기존 Service 호출 2. **app/Swagger/v1/ItemsBomApi.php** (460줄) - 10개 엔드포인트 Swagger 문서 - BOMLine, BOMTree, BOMCreateRequest, BOMUpdateRequest 스키마 - 예시 데이터 및 상세 설명 #### Items File Upload API (Day 10-12) 3. **app/Http/Controllers/Api/V1/ItemsFileController.php** (157줄) - 파일 업로드/삭제 Controller - 3가지 파일 타입 지원 (bending_diagram, specification, certification) - Storage facade 사용, items/{code}/{type} 경로 저장 4. **app/Http/Requests/ItemsFileUploadRequest.php** (105줄) - 파일 타입별 검증 (이미지: jpg/png/gif/svg, 문서: pdf/doc/hwp) - 파일 크기 제한 (이미지 10MB, 문서 20MB) - 인증 정보 및 절곡 상세 정보 검증 5. **app/Swagger/v1/ItemsFileApi.php** (179줄) - 파일 업로드/삭제 Swagger 문서 - ItemFileUploadResponse, ItemFileDeleteResponse 스키마 - multipart/form-data 요청 형식 정의 6. **database/migrations/2025_11_17_125437_add_file_fields_to_products_table.php** (124줄) - products 테이블에 9개 파일 필드 추가 - bending_diagram, bending_details (JSON) - specification_file, specification_file_name - certification_file, certification_file_name - certification_number, certification_start_date, certification_end_date #### 통합 테스트 (Day 13-14) 7. **BP-MES_PHASE1_INTEGRATION_TEST.md** (통합 테스트 가이드) - 17개 엔드포인트 검증 가이드 - 4개 통합 시나리오 (CRUD, BOM, File, 전체) - 에러 처리 검증 케이스 - 성능 검증 기준 - Swagger UI 테스트 체크리스트 ### 수정된 파일 1. **app/Models/Products/Product.php** - fillable에 9개 파일 필드 추가 - casts: bending_details (array), certification_start_date/end_date (date) 2. **routes/api.php** - Items BOM 10개 엔드포인트 등록 - Items File 2개 엔드포인트 등록 3. **lang/ko/message.php** - BOM 메시지 키 추가 (created, updated, deleted) ### 작업 내용 #### 1. Items BOM API (Day 6-9) **설계 결정:** - ✅ Adapter 패턴 채택 (Code-based API ← ID-based Service) - ✅ ProductBomService 100% 재사용 (코드 중복 방지) - ✅ 코드→ID 변환만 Adapter에서 처리 - ✅ 비즈니스 로직은 기존 Service 활용 **구현 엔드포인트 (10개):** 1. GET /items/{code}/bom - BOM 목록 (flat) 2. GET /items/{code}/bom/tree - BOM 트리 (계층) 3. POST /items/{code}/bom - BOM 추가 (bulk upsert) 4. PUT /items/{code}/bom/{lineId} - BOM 수정 5. DELETE /items/{code}/bom/{lineId} - BOM 삭제 6. GET /items/{code}/bom/summary - BOM 요약 7. GET /items/{code}/bom/validate - BOM 검증 8. POST /items/{code}/bom/replace - BOM 전체 교체 9. POST /items/{code}/bom/reorder - BOM 정렬 10. GET /items/{code}/bom/categories - 카테고리 목록 **Adapter 패턴 구현:** ```php class ItemsBomController extends Controller { public function __construct(private ProductBomService $service) {} public function index(string $code, Request $request) { return ApiResponse::handle(function () use ($code, $request) { $productId = $this->getProductIdByCode($code); return $this->service->index($productId, $request->all()); }, __('message.bom.fetch')); } private function getProductIdByCode(string $code): int { $product = Product::where('tenant_id', app('tenant_id')) ->where('code', $code) ->firstOrFail(['id']); return $product->id; } } ``` #### 2. Items File Upload API (Day 10-12) **파일 타입:** - `bending_diagram`: 절곡도 (이미지 - jpg, png, gif, svg, bmp, webp) - 최대 크기: 10MB - 추가 필드: bending_details (JSON 배열) - `specification`: 시방서 (문서 - pdf, doc, docx, xls, xlsx, hwp) - 최대 크기: 20MB - 원본 파일명 저장 - `certification`: 인정서 (문서 - pdf, doc, docx, xls, xlsx, hwp) - 최대 크기: 20MB - 원본 파일명 + 인증 정보 (번호, 시작일, 종료일) **파일 저장 구조:** ``` storage/app/public/items/{code}/{type}/{filename} 예: storage/app/public/items/P-001/bending_diagram/abc123.jpg ``` **구현 로직:** ```php public function upload(string $code, ItemsFileUploadRequest $request) { $product = $this->getProductByCode($code); $fileType = $validated['type']; $file = $validated['file']; // 파일 저장 $directory = sprintf('items/%s/%s', $code, $fileType); $filePath = Storage::disk('public')->putFile($directory, $file); // Product 업데이트 $updateData = $this->buildUpdateData($fileType, $filePath, ...); $product->update($updateData); return [ 'file_url' => Storage::url($filePath), 'file_path' => $filePath, 'file_name' => $file->getClientOriginalName(), 'product' => $product->fresh() ]; } ``` #### 3. 통합 테스트 (Day 13-14) **검증 항목:** - ✅ 17개 API 엔드포인트 등록 확인 - ✅ Swagger 문서 생성 확인 (999KB) - ✅ Migration 실행 상태 확인 (batch 28) - ✅ Product 모델 fillable/casts 확인 - ✅ 통합 테스트 가이드 작성 **테스트 시나리오:** 1. 시나리오 1: 기본 CRUD 흐름 (생성 → 조회 → 수정 → 삭제) 2. 시나리오 2: BOM 관리 흐름 (추가 → 트리 → 요약 → 검증 → 수정 → 정렬) 3. 시나리오 3: 파일 업로드 흐름 (3개 파일 타입 업로드 → 조회 → 삭제) 4. 시나리오 4: 전체 통합 (품목 생성 → BOM → 파일 → 검증) **에러 처리 검증:** - 파일 업로드: 잘못된 파일 타입, 크기 초과, 인증 기간 검증 - BOM: 존재하지 않는 품목, 순환 참조 - CRUD: 중복 코드, 필수 필드 누락 ### Git 커밋 기록 ```bash # Commit 1: Items BOM API git commit 87a3f2b feat: Items BOM API 구현 (Code-based Adapter) - ItemsBomController 생성 (184줄, 10개 엔드포인트) - ItemsBomApi Swagger 문서 (460줄) - routes/api.php에 10개 route 등록 - lang/ko/message.php BOM 메시지 추가 - ProductBomService 100% 재사용 # Commit 2: Items File Upload API git commit 4749761 feat: 품목 파일 업로드 API 구현 (절곡도, 시방서, 인정서) - Products 테이블에 9개 파일 필드 추가 - ItemsFileController 구현 (157줄) - ItemsFileUploadRequest 검증 (105줄) - ItemsFileApi Swagger 문서 (179줄) - routes/api.php에 2개 route 등록 ``` ### DB Schema 변경사항 #### products 테이블 파일 필드 (9개) - Migration 2025_11_17_125437 ```sql -- 절곡도 bending_diagram VARCHAR(255) NULL COMMENT '절곡도 파일 경로 (이미지 URL)' bending_details JSON NULL COMMENT '절곡 상세 정보 (BendingDetail[])' -- 시방서 specification_file VARCHAR(255) NULL COMMENT '시방서 파일 경로' specification_file_name VARCHAR(255) NULL COMMENT '시방서 원본 파일명' -- 인정서 certification_file VARCHAR(255) NULL COMMENT '인정서 파일 경로' certification_file_name VARCHAR(255) NULL COMMENT '인정서 원본 파일명' -- 인증 정보 certification_number VARCHAR(50) NULL COMMENT '인증번호' certification_start_date DATE NULL COMMENT '인증 시작일' certification_end_date DATE NULL COMMENT '인증 종료일' ``` ### API 엔드포인트 요약 **Items CRUD (5개):** - GET /items - 목록 - POST /items - 생성 - GET /items/code/{code} - 조회 - PUT /items/{code} - 수정 - DELETE /items/{code} - 삭제 **Items BOM (10개):** - GET /items/{code}/bom - 목록 - GET /items/{code}/bom/tree - 트리 - POST /items/{code}/bom - 추가 - PUT /items/{code}/bom/{lineId} - 수정 - DELETE /items/{code}/bom/{lineId} - 삭제 - GET /items/{code}/bom/summary - 요약 - GET /items/{code}/bom/validate - 검증 - POST /items/{code}/bom/replace - 전체 교체 - POST /items/{code}/bom/reorder - 정렬 - GET /items/{code}/bom/categories - 카테고리 **Items File (2개):** - POST /items/{code}/files - 파일 업로드 - DELETE /items/{code}/files/{type} - 파일 삭제 **총 17개 엔드포인트** ### 검증 완료 항목 - [x] Routes 등록 검증 (17개 엔드포인트) - [x] Swagger 문서 생성 (999KB, Items Files 태그 포함) - [x] Migration 실행 (batch 28) - [x] Product 모델 fillable/casts 확인 - [x] Pint 코드 포맷팅 통과 - [x] 통합 테스트 가이드 작성 - [x] Git 커밋 완료 (2개) ### 다음 단계 **Phase 1 완료:** - ✅ Day 1-2: products/product_components 테이블 확장 - ✅ Day 3-5: ItemsController CRUD API - ✅ Day 6-9: ItemsBomController API - ✅ Day 10-12: ItemsFileController API - ✅ Day 13-14: 통합 테스트 **Phase 2 제안:** - Frontend 연동 (React/Vue) - BOM 계산 로직 (수식 평가, 조건부 처리) - 파일 미리보기 기능 - 대량 데이터 Import/Export - 품목 복제 기능 - 변경 이력 추적 - 통합 검색 개선 --- # SAM API 저장소 작업 현황 ## 2025-11-14 (목) - BP-MES Phase 1: products/product_components 테이블 확장 ### 주요 작업 - BP-MES 프로젝트 Phase 1 Day 1-2: Migration 파일 작성 및 실행 - products 테이블에 33개 필드 추가 (ItemMaster 구조 지원) - product_components 테이블에 5개 필드 추가 (BOMLine 수식 계산) - Product/ProductComponent 모델 업데이트 - code-workflow 스킬 사용한 체계적 개발 (분석→수정→검증→정리→커밋) ### 추가된 파일 1. **database/migrations/2025_11_13_120000_extend_products_table_for_bp_mes.php** - products 테이블 확장 Migration - 33개 필드 추가 (공통 8개, FG 3개, PT 9개, 절곡품 5개, 인증 7개, 동적확장 1개) - 인덱스 추가 (is_active, product_category, part_type, part_usage) - up()/down() 메서드 완전 구현 - hasColumn() 체크로 안전성 확보 2. **database/migrations/2025_11_13_120001_extend_product_components_table_for_bp_mes.php** - product_components 테이블 확장 Migration - 5개 필드 추가 (quantity_formula, condition, is_bending, bending_diagram, bending_details) - is_bending 인덱스 추가 - 수식 계산 및 조건부 BOM 지원 ### 수정된 파일 1. **app/Models/Products/Product.php** - fillable에 33개 필드 추가 - 공통: margin_rate, processing_cost, labor_cost, install_cost, safety_stock, lead_time, is_variable_size - FG 전용: product_category, lot_abbreviation, note - PT 전용: part_type, part_usage, installation_type, assembly_type, side_spec_width, side_spec_height, assembly_length, guide_rail_model_type, guide_rail_model - 절곡품: bending_diagram, bending_details, material, length, bending_length - 인증: certification_number, certification_start_date, certification_end_date, specification_file, specification_file_name, certification_file, certification_file_name - 동적 확장: options - casts 추가: is_variable_size (boolean), bending_details (array), options (array), certification_start_date/end_date (date) 2. **app/Models/Products/ProductComponent.php** - fillable에 5개 필드 추가: quantity_formula, condition, is_bending, bending_diagram, bending_details - casts 추가: is_bending (boolean), bending_details (array) 3. **LOGICAL_RELATIONSHIPS.md** - 자동 생성 타임스탬프 업데이트 (2025-11-14 08:41:17) ### 작업 내용 #### 1. 분석 단계 (Sequential Thinking MCP 활용) - BACKEND_DEVELOPMENT_ROADMAP_V2.md 분석 (lines 44-123, 355-377) - 기존 products/product_components 테이블 구조 파악 - 필드 충돌 여부 확인 (모두 신규 필드, 충돌 없음) - Product/ProductComponent 모델 구조 분석 #### 2. 수정 단계 - Migration 파일 2개 작성 (총 507줄 추가) - 모델 fillable/casts 업데이트 - 체계적 주석 및 그룹화 #### 3. 검증 단계 - Pint 코드 포맷팅 통과 (Migration 2파일, Model 2파일) - Migration 실행 성공 (products: 307.43ms, product_components: 82.19ms) - Migration 상태 확인 (batch 27로 실행됨) #### 4. 정리 단계 - 임시 파일 없음 확인 - 디버깅 코드 없음 확인 #### 5. 커밋 ```bash git commit d5bfb24 feat: BP-MES Phase 1 - products/product_components 테이블 확장 ``` ### DB Schema 변경사항 #### products 테이블 추가 필드 (33개) ```sql -- 공통 (8개) is_active BOOLEAN DEFAULT true margin_rate DECIMAL(5,2) processing_cost, labor_cost, install_cost DECIMAL(10,2) safety_stock, lead_time INT is_variable_size BOOLEAN DEFAULT false -- FG 전용 (3개) product_category VARCHAR(20) lot_abbreviation VARCHAR(10) note TEXT -- PT 전용 (9개) part_type, installation_type, assembly_type VARCHAR(20) part_usage VARCHAR(30) side_spec_width, side_spec_height, assembly_length VARCHAR(20) guide_rail_model_type, guide_rail_model VARCHAR(50) -- 절곡품 (5개) bending_diagram VARCHAR(255) bending_details JSON material VARCHAR(50) length, bending_length VARCHAR(20) -- 인증 (7개) certification_number VARCHAR(50) certification_start_date, certification_end_date DATE specification_file, specification_file_name VARCHAR(255) certification_file, certification_file_name VARCHAR(255) -- 동적 확장 (1개) options JSON ``` #### product_components 테이블 추가 필드 (5개) ```sql quantity_formula TEXT -- "W * 2", "H + 100" condition TEXT -- "MOTOR='Y'", "WIDTH > 3000" is_bending BOOLEAN DEFAULT false bending_diagram VARCHAR(255) bending_details JSON ``` ### Git 커밋 - **d5bfb24**: feat: BP-MES Phase 1 - products/product_components 테이블 확장 ### 다음 단계 - [ ] Phase 1 Day 3-5: ItemsController/Service 구현 - [ ] RESTful API 엔드포인트 개발 (index, show, store, update, destroy) - [ ] Swagger 문서 작성 - [ ] FormRequest 검증 추가 --- ## 2025-11-13 (수) 19:30 - API Key 보안 강화 및 Rate Limiting 구현 ### 주요 작업 - 글로벌 미들웨어로 API Key 검증 적용 - 화이트리스트 확장 (Swagger, Health check 등 공개 엔드포인트) - Rate Limiting 미들웨어 추가 (10회/분, IP 기반) - 보안 로그 강화 (무단 접근 시도 기록) - 민감 정보 로깅 제외 (password 필드) - code-workflow 스킬 사용한 체계적 보안 강화 ### 수정된 파일 1. **app/Http/Middleware/ApiKeyMiddleware.php** - 화이트리스트 라우트 패턴 매칭 추가 (와일드카드 지원) - 공개 라우트 API Key 검증 스킵 - 보안 로그 강화 (Log::warning으로 무단 접근 기록) - 민감 정보 제외 ($request->except(['password', 'password_confirmation'])) 2. **app/Http/Middleware/ApiRateLimiter.php** (신규) - IP 기반 Rate Limiting (10회/분) - API Key 없는 요청에 대한 속도 제한 - 429 Too Many Requests 응답 - retry_after 헤더 포함 3. **bootstrap/app.php** - 글로벌 미들웨어 적용 (ApiRateLimiter, ApiKeyMiddleware) - 미들웨어 실행 순서 최적화 (Rate Limit → API Key 검증) 4. **app/Services/MemberService.php** - 부서 역할 기반 권한 조회 로직 개선 ### 작업 내용 #### 1. 보안 문제 분석 **문제:** `@fs/etc/passwd` 같은 악의적 경로 탐색 공격이 Laravel 라우터까지 도달 **IP:** 213.136.76.215 (자동화된 보안 스캔봇) **영향:** 불필요한 서버 리소스 낭비, 로그 오염 #### 2. 이중 보호 구조 구현 ``` Nginx (L7) → 악의적 패턴 즉시 403 차단 ↓ Laravel 글로벌 미들웨어 (Rate Limiting) ↓ Laravel 글로벌 미들웨어 (API Key 검증) ↓ 컨트롤러 ``` #### 3. 화이트리스트 라우트 - api/v1/login, signup, register, refresh, debug-apikey - api-docs/* (Swagger UI) - docs/api-docs.json (Swagger JSON) - up (Health check) ### Git 커밋 ```bash # API 저장소 (6f7d754) git commit -m "feat: API Key 보안 강화 및 Rate Limiting 구현 - 글로벌 미들웨어로 API Key 검증 적용 - 화이트리스트 확장 (Swagger, Health check 등) - Rate Limiting 미들웨어 추가 (10회/분) - 보안 로그 강화 (무단 접근 시도 기록) - 민감 정보 로깅 제외 (password 필드)" ``` --- ## 2025-11-10 (일) 21:30 - 파일 업로드 DB 에러 및 메시지 구조 개선 ### 주요 작업 - files 테이블 감사 컬럼 추가 (created_by, updated_by, uploaded_by) - ApiResponse::handle() 메시지 로직 개선 (다국어 지원) - code-workflow 스킬 사용한 체계적 수정 ### 수정된 파일 1. **database/migrations/2025_11_10_190208_enhance_files_table.php** - created_by, updated_by, uploaded_by 컬럼 추가 - down() 메서드 안전한 롤백 로직 추가 2. **app/Helpers/ApiResponse.php** - handle() 164번: ' 성공' 접미사 제거 - handle() 177번, 145번: ' 실패' 접미사 제거 - 다국어 지원을 위한 완성된 문장 구조 유지 3. **app/Http/Controllers/Api/V1/FileStorageController.php** - ApiResponse 네임스페이스 수정 (App\Utils → App\Helpers) 4. **app/Http/Requests/Api/V1/FileUploadRequest.php** - 파일 검증 규칙 수정 (allowed_extensions 사용) ### 작업 내용 #### 1. DB 컬럼 누락 에러 수정 **에러:** `SQLSTATE[42S22]: Column not found: 1054 Unknown column 'created_by'` **원인:** File 모델 fillable에는 있으나 실제 테이블에는 없음 **해결:** 마이그레이션에 created_by, updated_by, uploaded_by 컬럼 추가 #### 2. 메시지 구조 개선 **문제:** "파일이 업로드되었습니다. 실패" (성공 문구 + 실패 접미사) **원인:** ApiResponse에서 ' 성공', ' 실패' 하드코딩 + 완성된 문장 충돌 **해결:** 접미사 제거, 완성된 문장 그대로 사용 (다국어 지원) **결과:** - 성공: "파일이 업로드되었습니다." ✅ - 실패: "서버 에러" (details에 실제 에러) ✅ ### Git 커밋 ```bash git commit -m "fix: 파일 업로드 DB 컬럼 누락 및 메시지 구조 개선 - files 테이블에 감사 컬럼 추가 (created_by, updated_by, uploaded_by) - ApiResponse::handle() 메시지 로직 개선 (접미사 제거) - 다국어 지원을 위한 완성된 문장 구조 유지" ``` ### TODO - [ ] **메시지 시스템 전면 개편** (나중에) - message.php를 동사원형으로 변경 - 다국어 접미사 통일 (success/fail) - 영향도: 50개+ 파일 수정 필요 --- ## 2025-11-10 (일) 20:00 - 파일 저장소 시스템 버그 수정 및 신규 테넌트 폴더 자동 생성 ### 주요 작업 - **FolderSeeder 네임스페이스 수정**: `\App\Models\Tenant` → `\App\Models\Tenants\Tenant` - **FileStorageController use 문 수정**: 잘못된 네임스페이스 구분자 수정 (`/` → `\`) - **TenantObserver 확장**: 신규 테넌트 생성 시 기본 폴더 자동 생성 로직 추가 - **Storage 디렉토리 권한 설정 안내**: `storage/app/tenants/` 생성 및 권한 설정 ### 수정된 파일: **Database Seeder:** - `database/seeders/FolderSeeder.php` - 네임스페이스 수정 (lines 20-21) - 수정 전: `\App\Models\Tenant::findOrFail()`, `\App\Models\Tenant::all()` - 수정 후: `\App\Models\Tenants\Tenant::findOrFail()`, `\App\Models\Tenants\Tenant::all()` **Controller:** - `app/Http/Controllers/Api/V1/FileStorageController.php` - use 문 수정 (line 7) - 수정 전: `use App\Http\Requests\Api\V1/FileMoveRequest;` - 수정 후: `use App\Http\Requests\Api\V1\FileMoveRequest;` **Observer:** - `app/Observers/TenantObserver.php` - 신규 테넌트 기본 폴더 자동 생성 로직 추가 - 기존 TenantBootstrapper 유지 - 5개 기본 폴더 자동 생성 (생산관리, 품질관리, 회계, 인사, 일반) - try-catch 에러 처리 및 로깅 ### 작업 내용: #### 1. Seeder 네임스페이스 오류 수정 - **문제**: `php artisan db:seed --class=FolderSeeder` 실행 시 "Class 'App\Models\Tenant' not found" 에러 - **원인**: Tenant 모델이 `App\Models\Tenants\Tenant`에 있으나 `App\Models\Tenant`로 참조 - **해결**: FolderSeeder의 Tenant 참조를 올바른 네임스페이스로 수정 #### 2. Controller 구문 오류 수정 - **문제**: Pint 실행 시 "syntax error, unexpected '/'" 에러 - **원인**: use 문에서 잘못된 네임스페이스 구분자 사용 (`/` 대신 `\`) - **해결**: FileStorageController의 use 문 구분자를 백슬래시로 수정 #### 3. 신규 테넌트 자동 폴더 생성 - **목적**: 신규 테넌트 회원가입 시 수동으로 Seeder를 실행하지 않아도 기본 폴더가 자동 생성되도록 개선 - **구현**: TenantObserver의 `created()` 메서드에 폴더 생성 로직 추가 - **동작**: 1. `Tenant::create()` 호출 시 Observer 자동 트리거 2. TenantBootstrapper 실행 (기존 로직 유지) 3. 5개 기본 폴더 자동 생성 (신규) 4. 에러 발생 시 로그 기록하되 테넌트 생성은 계속 진행 #### 4. Storage 디렉토리 설정 ```bash # 디렉토리 생성 mkdir -p storage/app/tenants # 권한 설정 chmod 775 storage/app/tenants # 로컬 개발 환경에서는 현재 사용자 소유권으로 충분 # 프로덕션 환경에서는 웹서버 사용자로 소유권 설정 필요 ``` ### 테스트 시나리오: 1. **기존 테넌트 폴더 생성**: ```bash php artisan db:seed --class=FolderSeeder ``` 2. **신규 테넌트 폴더 자동 생성**: - 회원가입 API 호출 또는 `Tenant::create()` 실행 - 자동으로 5개 기본 폴더 생성됨 ### Git 커밋: - `aeeeba6` - fix: 파일 저장소 버그 수정 및 신규 테넌트 폴더 자동 생성 --- ## 2025-11-10 (일) - 파일 저장소 시스템 구현 완료 (Phase 2~5) ### 주요 작업 - **파일 저장소 시스템 완성**: Models, Services, Controllers, Commands, Swagger, Config, Routes 전체 구현 - **25개 파일 생성/수정**: Phase 2-5 완료로 구현 가이드 기준 100% 달성 - **코드 품질 검증**: Pint 포맷팅 완료, Swagger 문서 생성 완료 ### 추가된 파일 (21개): **Models (3개):** - `app/Models/Folder.php` - 폴더 관리 모델 - `app/Models/FileShareLink.php` - 공유 링크 모델 - `app/Models/Commons/File.php` - 기존 파일 모델 확장 **Services (2개):** - `app/Services/FileStorageService.php` - 파일 저장소 서비스 (Legacy FileService 충돌 방지) - `app/Services/FolderService.php` - 폴더 관리 서비스 **FormRequests (5개):** - `app/Http/Requests/Api/V1/FileUploadRequest.php` - 파일 업로드 검증 - `app/Http/Requests/Api/V1/FileMoveRequest.php` - 파일 이동 검증 - `app/Http/Requests/Api/V1/FolderStoreRequest.php` - 폴더 생성 검증 - `app/Http/Requests/Api/V1/FolderUpdateRequest.php` - 폴더 수정 검증 - `app/Http/Requests/Api/V1/ShareLinkRequest.php` - 공유 링크 생성 검증 **Controllers (2개):** - `app/Http/Controllers/Api/V1/FileStorageController.php` - 파일 저장소 컨트롤러 - `app/Http/Controllers/Api/V1/FolderController.php` - 폴더 관리 컨트롤러 **Commands (4개):** - `app/Console/Commands/CleanupTempFiles.php` - 7일 이상 임시 파일 정리 - `app/Console/Commands/CleanupTrash.php` - 30일 이상 휴지통 파일 정리 - `app/Console/Commands/CleanupExpiredLinks.php` - 만료된 공유 링크 정리 - `app/Console/Commands/RecordStorageUsage.php` - 일일 용량 사용량 기록 **Swagger (2개):** - `app/Swagger/v1/FileApi.php` - 파일 저장소 API 문서 - `app/Swagger/v1/FolderApi.php` - 폴더 관리 API 문서 **Database (5개 - Phase 1에서 완료):** - `database/migrations/2025_11_10_190355_create_file_share_links_table.php` - `database/migrations/2025_11_10_190355_create_file_deletion_logs_table.php` - `database/migrations/2025_11_10_190355_create_storage_usage_history_table.php` - `database/migrations/2025_11_10_190355_add_storage_columns_to_tenants.php` - `database/seeders/FolderSeeder.php` ### 수정된 파일 (4개): **Config:** - `config/filesystems.php` - tenant disk, 파일 제약사항, 저장소 정책, 공유 링크 설정 추가 **i18n:** - `lang/ko/message.php` - 11개 파일/폴더 성공 메시지 추가 - `lang/ko/error.php` - 17개 파일/폴더 에러 메시지 추가 **Routes:** - `routes/api.php` - 파일 저장소 및 폴더 관리 라우트 추가 - `routes/console.php` - 4개 스케줄러 등록 (Laravel 12 표준) **Tenant Model:** - `app/Models/Tenants/Tenant.php` - 저장소 용량 관리 메서드 8개 추가 ### 작업 내용: #### 1. Phase 2: Models (4개) **Folder.php:** ```php - BelongsToTenant, ModelTrait - scopeActive(), scopeOrdered() - files() HasMany 관계 ``` **FileShareLink.php:** ```php - 자동 64자 토큰 생성 (bin2hex(random_bytes(32))) - isExpired(), isValid(), isDownloadLimitReached() - incrementDownloadCount() 다운로드 추적 ``` **File.php (확장):** ```php - BelongsToTenant 추가 - moveToFolder() - temp → folder_key 이동 - permanentDelete() - 물리 삭제 + 용량 차감 - download() - Storage Facade 통합 ``` **Tenant.php (확장):** ```php - canUpload() - 90% 경고 → 7일 유예 로직 - incrementStorage(), decrementStorage() - resetGracePeriod(), isInGracePeriod() ``` #### 2. Phase 3: Services/Controllers/Requests (9개) **FileStorageService (신규 서비스):** ```php - upload() - temp 업로드 + 용량 체크 - moveToFolder() - temp → folder 이동 - deleteFile() - soft delete + 삭제 로그 - restoreFile() - 복구 - permanentDelete() - 물리 삭제 - createShareLink() - 공유 링크 생성 - getFileByShareToken() - 공유 링크 검증 ``` **FolderService:** ```php - index() - 폴더 목록 (display_order) - store() - 폴더 생성 (자동 순서) - update() - 폴더 수정 - destroy() - 비활성화 (파일 있으면 거부) - reorder() - 순서 일괄 변경 ``` **FileStorageController:** ```php 10개 엔드포인트: - upload, move, index, show, trash, download - destroy, restore, permanentDelete, createShareLink ``` **FolderController:** ```php 6개 엔드포인트: - index, store, show, update, destroy, reorder ``` #### 3. Phase 4: Commands + Scheduler + Swagger (7개) **Commands (4개):** ```php CleanupTempFiles (매일 03:30) - 7일 이상 temp 파일 삭제 - Storage + DB 동기화 CleanupTrash (매일 03:40) - 30일 이상 삭제 파일 영구 삭제 - file_deletion_logs 기록 CleanupExpiredLinks (매일 03:50) - 만료된 공유 링크 삭제 RecordStorageUsage (매일 04:00) - 테넌트별 용량 사용량 기록 - 폴더별 사용량 JSON 저장 ``` **routes/console.php (Laravel 12):** ```php Schedule::command('storage:cleanup-temp') ->dailyAt('03:30') ->appendOutputTo(storage_path('logs/scheduler.log')) ->onSuccess/onFailure 로그 ``` **Swagger 문서 (2개):** ```php FileApi.php: - 10개 엔드포인트 완전 문서화 - File 모델 스키마 정의 - FileUploadRequest, FileMoveRequest, ShareLinkRequest 스키마 FolderApi.php: - 6개 엔드포인트 완전 문서화 - Folder 모델 스키마 정의 - FolderStoreRequest, FolderUpdateRequest, FolderReorderRequest 스키마 ``` #### 4. Phase 5: Config/i18n/Routes (4개) **config/filesystems.php:** ```php 'tenant' => [ 'driver' => 'local', 'root' => storage_path('app/tenants'), ] 'file_constraints' => [ 'max_file_size' => 20MB, 'allowed_extensions' => [pdf, doc, image, archive...], ] 'storage_policies' => [ 'default_limit' => 10GB, 'warning_threshold' => 0.9, 'grace_period_days' => 7, 'trash_retention_days' => 30, ] 'share_link' => [ 'expiry_hours' => 24, 'max_downloads' => null, ] ``` **i18n 메시지 (28개):** ```php lang/ko/message.php (11개): - file_uploaded, files_moved, file_deleted - file_restored, file_permanently_deleted - share_link_created, storage_exceeded_grace_period - folder_created, folder_updated, folder_deleted - folders_reordered lang/ko/error.php (17개): - file_not_found, folder_not_found - storage_quota_exceeded, share_link_expired - folder_key_duplicate, folder_has_files - color_format, expiry_hours_min/max ``` **routes/api.php:** ```php 파일 저장소 (10개): - POST /files/upload - POST /files/move - GET /files (+ trash) - GET/DELETE /files/{id} - POST /files/{id}/restore - DELETE /files/{id}/permanent - POST /files/{id}/share - GET /files/share/{token} (공개) 폴더 관리 (6개): - GET/POST /folders - GET/PUT/DELETE /folders/{id} - POST /folders/reorder ``` ### 설계 특징: **1. 경로 구조:** ``` /storage/app/tenants/ ├── {tenant_id}/ │ ├── temp/{year}/{month}/{stored_name} # 업로드 직후 │ ├── product/{year}/{month}/{stored_name} # 문서 첨부 후 │ ├── quality/{year}/{month}/{stored_name} │ └── accounting/{year}/{month}/{stored_name} ``` **2. 워크플로우:** ``` 1. 파일 업로드 - POST /files/upload - temp 폴더에 저장 (is_temp=true) - 64자 난수 파일명 (보안) 2. 문서에 첨부 - POST /files/move - temp → folder_key 이동 - document_id, document_type 설정 3. 공유 링크 생성 - POST /files/{id}/share - 64자 토큰 + 24시간 만료 - 다운로드 횟수 추적 4. 삭제/복구 - DELETE (soft delete) - POST restore (복구) - DELETE permanent (영구 삭제) ``` **3. 용량 관리:** ``` 업로드 시: - tenants.storage_used 증가 - 90% 도달 → 경고 이메일 + 7일 유예 - 100% 초과 + 유예 기간 내 → 업로드 허용 - 유예 만료 → 업로드 차단 ``` **4. 자동 정리:** ``` 매일 새벽: - 03:30: 7일 이상 temp 파일 삭제 - 03:40: 30일 이상 휴지통 파일 영구 삭제 - 03:50: 만료된 공유 링크 삭제 - 04:00: 테넌트별 용량 사용량 기록 ``` ### 기술 세부사항: #### Laravel 12 Scheduler (🔴 중요!) ```php // ❌ 기존 (Laravel 11): Kernel.php protected function schedule(Schedule $schedule) { ... } // ✅ Laravel 12: routes/console.php Schedule::command('storage:cleanup-temp') ->dailyAt('03:30') ->appendOutputTo(storage_path('logs/scheduler.log')) ->onSuccess/onFailure ``` #### Storage Facade 추상화 ```php // 현재: local disk Storage::disk('tenant')->put($path, $file); // 미래: S3로 전환 (설정만 변경) 'tenant' => ['driver' => 's3', 'bucket' => env('AWS_BUCKET')] ``` #### 보안: 64bit 난수 파일명 ```php bin2hex(random_bytes(32)) → "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2.pdf" ``` ### SAM API Development Rules 준수: ✅ **Service-First 아키텍처:** - FileStorageService, FolderService에 모든 로직 - Controller는 DI + ApiResponse::handle() ✅ **FormRequest 검증:** - 5개 FormRequest로 모든 검증 분리 ✅ **i18n 메시지 키:** - __('message.xxx'), __('error.xxx') 28개 추가 ✅ **Swagger 문서:** - 별도 파일 (FileApi.php, FolderApi.php) - 16개 엔드포인트 완전 문서화 ✅ **멀티테넌시:** - BelongsToTenant 스코프 - tenant_id 격리 ✅ **감사 로그:** - file_deletion_logs 테이블 - created_by, updated_by, deleted_by ✅ **SoftDeletes:** - File 모델 soft delete - 30일 휴지통 보관 ✅ **코드 품질:** - Laravel Pint 포맷팅 완료 - Swagger 문서 생성 완료 ### 예상 효과: 1. **완전한 파일 관리**: 업로드 → 이동 → 공유 → 삭제 → 복구 전체 워크플로우 2. **용량 제어**: 90% 경고 → 7일 유예 → 차단 단계별 관리 3. **자동 정리**: 임시/삭제 파일 자동 정리로 디스크 최적화 4. **클라우드 전환 용이**: Storage Facade로 S3 마이그레이션 간단 5. **감사 추적**: 파일 삭제 로그, 용량 사용량 히스토리 ### 다음 작업: - [ ] 마이그레이션 실행: `php artisan migrate` - [ ] 폴더 시더 실행: `php artisan db:seed --class=FolderSeeder` - [ ] storage/app/tenants/ 디렉토리 생성 및 권한 설정 - [ ] API 테스트 (Postman/Swagger UI) - [ ] Frontend 파일 업로드 UI 구현 ### Git 커밋 준비: - 다음 커밋 예정: `feat: 파일 저장소 시스템 구현 완료 (Phase 2-5, 25개 파일)` --- ## 2025-11-10 (일) - 파일 저장 시스템 구현 시작 (Phase 1: DB 마이그레이션) ### 주요 작업 - **파일 저장 시스템 기반 구축**: 로컬 저장 우선, 클라우드(S3) 전환 가능 구조 - **DB 마이그레이션 7개 생성**: files 테이블 개선, folders, file_share_links, file_deletion_logs, storage_usage_history, tenants 용량 관리 - **설계 기반**: `/claudedocs/file_storage_implementation_guide.md` 참조 ### 추가된 파일 (7개): - `database/migrations/2025_11_10_190208_enhance_files_table.php` - files 테이블 구조 개선 (완료) - `database/migrations/2025_11_10_190257_create_folders_table.php` - 동적 폴더 관리 테이블 (완료) - `database/migrations/2025_11_10_190355_create_file_share_links_table.php` - 외부 공유 링크 테이블 (stub) - `database/migrations/2025_11_10_190355_create_file_deletion_logs_table.php` - 파일 삭제 로그 테이블 (stub) - `database/migrations/2025_11_10_190355_create_storage_usage_history_table.php` - 용량 히스토리 테이블 (stub) - `database/migrations/2025_11_10_190355_add_storage_columns_to_tenants.php` - 테넌트 용량 관리 컬럼 (stub) - `database/seeders/FolderSeeder.php` - 기본 폴더 시더 (stub) ### 작업 내용: #### 1. files 테이블 개선 (완료) ```php // 새로운 컬럼 - display_name: 사용자가 보는 파일명 (예: 도면.pdf) - stored_name: 실제 저장 파일명 (예: a1b2c3d4e5f6g7h8.pdf, 64bit 난수) - folder_id: folders 테이블 FK - is_temp: temp 폴더 여부 (업로드 직후 true) - file_type: document/image/excel/archive - document_id: 문서 ID (polymorphic 대체) - document_type: 문서 타입 (work_order, quality_check 등) - deleted_by: 삭제자 ID // 인덱스 - idx_tenant_folder: (tenant_id, folder_id) - is_temp, document_id, created_at, stored_name ``` #### 2. folders 테이블 생성 (완료) ```php // 동적 폴더 관리 - folder_key: product, quality, accounting (고유 키) - folder_name: 생산관리, 품질관리, 회계 (표시명) - display_order: 정렬 순서 - is_active: 활성 여부 - icon, color: UI 커스터마이징 // 유니크 제약 - (tenant_id, folder_key) // 인덱스 - (tenant_id, is_active) - (tenant_id, display_order) ``` #### 3. 나머지 마이그레이션 (stub 생성만 완료) - file_share_links: 24시간 임시 공유 링크 - file_deletion_logs: 삭제 감사 추적 - storage_usage_history: 용량 사용량 히스토리 - tenants 용량 관리: storage_limit, storage_used, storage_warning_sent_at, storage_grace_period_until ### 설계 특징: **1. 로컬 → 클라우드 전환 용이:** ```php // 현재 (로컬) 'tenant' => [ 'driver' => 'local', 'root' => storage_path('app/tenants'), ] // 전환 후 (S3) - driver만 변경 'tenant' => [ 'driver' => 's3', 'bucket' => env('AWS_BUCKET'), ] ``` **2. 파일 경로 구조:** ``` /storage/app/tenants/ ├── {tenant_id}/ │ ├── product/{year}/{month}/{stored_name} │ ├── quality/{year}/{month}/{stored_name} │ ├── accounting/{year}/{month}/{stored_name} │ └── temp/{year}/{month}/{stored_name} ``` **3. 용량 관리:** - 기본 한도: 10GB - 90% 경고 → 이메일 발송 + 7일 유예 - 100% 초과 → 유예 기간 내 업로드 허용 - 유예 만료 → 업로드 차단 ### 다음 작업 (새 세션에서 진행): **Phase 2: 모델 및 Service (4개)** - [ ] File 모델 리팩토링 (BelongsToTenant, Storage 통합) - [ ] Folder 모델 생성 - [ ] FileShareLink 모델 생성 - [ ] FileService 전면 리팩토링 (Storage Facade 사용) **Phase 3: Controller 및 API (7개)** - [ ] FolderService 생성 - [ ] FormRequest 5개 생성 - [ ] FileController 리팩토링 - [ ] FolderController 생성 **Phase 4: 문서 및 배치 (6개)** - [ ] Swagger 문서 2개 - [ ] Commands 4개 (temp 정리, 휴지통 정리, 링크 정리, 용량 기록) **Phase 5: 설정 (3개)** - [ ] config/filesystems.php 수정 - [ ] i18n 메시지 추가 - [ ] routes/api.php 업데이트 ### Git 커밋 준비: - 다음 커밋 예정: `feat: 파일 저장 시스템 DB 마이그레이션 (Phase 1)` --- ## 2025-11-10 (일) - API 토큰 관리 시스템 구현 (액세스/리프레시 토큰 분리 + 자동 정리) ### 주요 작업 - **액세스/리프레시 토큰 분리**: 액세스 토큰(2시간), 리프레시 토큰(7일) 독립 관리 - **환경별 설정**: .env 기반 토큰 만료 시간 설정 (설정 없으면 무제한) - **토큰 갱신 엔드포인트**: POST /api/v1/refresh (리프레시 토큰으로 새 토큰 발급) - **보안 강화**: 리프레시 토큰 일회성 사용, 사용자당 1개 리프레시 토큰만 유지 - **에러 처리**: TOKEN_EXPIRED 에러 코드로 프론트엔드 자동 리프레시 지원 - **자동 정리 스케줄러**: 만료 토큰 자동 삭제 (매일 새벽 3:20) ### 추가된 파일: - `app/Services/AuthService.php` - 토큰 발급/갱신 통합 서비스 (119줄) - `app/Http/Controllers/Api/V1/RefreshController.php` - 토큰 갱신 컨트롤러 (32줄) - `app/Http/Requests/Api/V1/RefreshRequest.php` - 리프레시 토큰 검증 (22줄) - `app/Swagger/v1/RefreshApi.php` - 토큰 갱신 API 문서 (69줄) ### 수정된 파일: - `.env` - 토큰 만료 설정 추가 (ACCESS: 120분, REFRESH: 10080분) - `config/sanctum.php` - 토큰 만료 설정 키 추가 - `app/Http/Controllers/Api/V1/ApiController.php` - 로그인 시 AuthService 사용 - `app/Exceptions/Handler.php` - 토큰 만료 에러 처리 (TOKEN_EXPIRED) - `app/Http/Middleware/ApiKeyMiddleware.php` - refresh 라우트 화이트리스트 추가 - `app/Swagger/v1/AuthApi.php` - 로그인 응답에 토큰 필드 추가 - `lang/ko/error.php` - 토큰 관련 에러 메시지 4개 추가 - `lang/ko/message.php` - token_refreshed 메시지 추가 - `routes/api.php` - POST /api/v1/refresh 라우트 추가 ### 작업 내용: #### 1. AuthService 구현 **토큰 발급 (issueTokens):** ```php public static function issueTokens(User $user): array { // 기존 리프레시 토큰 삭제 (한 사용자당 하나만 유지) $user->tokens()->where('name', 'refresh-token')->delete(); // 액세스 토큰 만료 시간 (분 단위, null이면 무제한) $accessExpiration = Config::get('sanctum.access_token_expiration'); $accessExpiration = $accessExpiration ? (int) $accessExpiration : null; $accessExpiresAt = $accessExpiration ? now()->addMinutes($accessExpiration) : null; // 리프레시 토큰 만료 시간 (분 단위, null이면 무제한) $refreshExpiration = Config::get('sanctum.refresh_token_expiration'); $refreshExpiration = $refreshExpiration ? (int) $refreshExpiration : null; $refreshExpiresAt = $refreshExpiration ? now()->addMinutes($refreshExpiration) : null; // 액세스 토큰 생성 $accessToken = $user->createToken('access-token', ['*'], $accessExpiresAt); // 리프레시 토큰 생성 $refreshToken = $user->createToken('refresh-token', ['refresh'], $refreshExpiresAt); return [ 'access_token' => $accessToken->plainTextToken, 'refresh_token' => $refreshToken->plainTextToken, 'token_type' => 'Bearer', 'expires_in' => $accessExpiration ? $accessExpiration * 60 : null, 'expires_at' => $accessExpiresAt ? $accessExpiresAt->toDateTimeString() : null, ]; } ``` **토큰 갱신 (refreshTokens):** ```php public static function refreshTokens(string $refreshToken): ?array { // 리프레시 토큰 검증 $token = \Laravel\Sanctum\PersonalAccessToken::findToken($refreshToken); if (!$token || $token->name !== 'refresh-token') { return null; } // 만료 확인 if ($token->expires_at && $token->expires_at->isPast()) { $token->delete(); return null; } $user = $token->tokenable; // 기존 리프레시 토큰 삭제 (사용 후 폐기) $token->delete(); // 새로운 액세스 + 리프레시 토큰 발급 return self::issueTokens($user); } ``` **핵심 특징:** - ✅ 사용자당 1개의 리프레시 토큰만 유지 - ✅ 리프레시 토큰은 일회성 (사용 후 삭제) - ✅ 토큰 갱신 시 액세스 + 리프레시 모두 새로 발급 - ✅ 타입 캐스팅 (.env 값은 문자열이므로 int 변환 필수) #### 2. RefreshController 구현 ```php public function refresh(RefreshRequest $request): JsonResponse { $refreshToken = $request->validated()['refresh_token']; // 리프레시 토큰으로 새로운 토큰 발급 $tokens = AuthService::refreshTokens($refreshToken); if (!$tokens) { return response()->json([ 'error' => __('error.refresh_token_invalid_or_expired'), 'error_code' => 'TOKEN_EXPIRED', ], 401); } return response()->json([ 'message' => __('message.token_refreshed'), 'access_token' => $tokens['access_token'], 'refresh_token' => $tokens['refresh_token'], 'token_type' => $tokens['token_type'], 'expires_in' => $tokens['expires_in'], 'expires_at' => $tokens['expires_at'], ]); } ``` #### 3. Handler 토큰 만료 에러 처리 ```php // 401 Unauthorized if ($exception instanceof AuthenticationException) { // 토큰 만료 여부 확인 $errorCode = null; $message = '인증 실패'; // Bearer 토큰이 있는 경우 만료 여부 확인 $bearerToken = $request->bearerToken(); if ($bearerToken) { $token = \Laravel\Sanctum\PersonalAccessToken::findToken($bearerToken); if ($token && $token->expires_at && $token->expires_at->isPast()) { $errorCode = 'TOKEN_EXPIRED'; $message = __('error.token_expired'); } } return response()->json([ 'success' => false, 'message' => $message, 'error_code' => $errorCode, 'data' => null, ], 401); } ``` #### 4. 환경 설정 (.env) ```env # Sanctum 토큰 만료 설정 (분 단위, null이면 무제한) SANCTUM_ACCESS_TOKEN_EXPIRATION=120 # 2시간 (운영 기준) SANCTUM_REFRESH_TOKEN_EXPIRATION=10080 # 7일 ``` #### 5. Swagger 문서 **POST /api/v1/refresh:** ```php @OA\Post( path="/api/v1/refresh", tags={"Auth"}, summary="토큰 갱신 (리프레시 토큰으로 새로운 액세스 토큰 발급)", description="리프레시 토큰을 사용하여 새로운 액세스 토큰과 리프레시 토큰을 발급받습니다. 리프레시 토큰은 사용 후 폐기되며 새로운 리프레시 토큰이 발급됩니다.", security={{"ApiKeyAuth": {}}}, ) ``` **로그인 응답 업데이트:** ```php @OA\Property(property="access_token", type="string", example="1|abc123xyz456", description="액세스 토큰 (API 호출에 사용)"), @OA\Property(property="refresh_token", type="string", example="2|def456uvw789", description="리프레시 토큰 (액세스 토큰 갱신에 사용)"), @OA\Property(property="token_type", type="string", example="Bearer", description="토큰 타입"), @OA\Property(property="expires_in", type="integer", nullable=true, example=7200, description="액세스 토큰 만료 시간 (초 단위, null이면 무제한)"), @OA\Property(property="expires_at", type="string", nullable=true, example="2025-11-10 16:00:00", description="액세스 토큰 만료 시각 (null이면 무제한)"), ``` ### 기술 세부사항: #### OAuth 2.0 표준 준수 - `token_type: "Bearer"` 포함 (RFC 6749 표준) - 토큰 갱신 시 refresh token rotation (보안 강화) - 만료 시간 명시 (expires_in, expires_at) #### 보안 설계 ``` 1. 리프레시 토큰 일회성: - 사용 시 즉시 삭제 - 새 리프레시 토큰 발급 - 도난 토큰 재사용 방지 2. 사용자당 1개 제한: - 새 리프레시 토큰 발급 시 이전 것 삭제 - 멀티 디바이스 로그인 제한 (필요 시 변경 가능) 3. 타입 안전성: - .env 값 타입 캐스팅 필수 - Carbon::addMinutes()는 int만 허용 ``` #### 데이터베이스 영향 ```sql -- personal_access_tokens 테이블 SELECT id, name, expires_at, created_at FROM personal_access_tokens WHERE tokenable_id = 1 ORDER BY id DESC LIMIT 5; -- 결과: ID: 184 | Name: refresh-token | Expires: 2025-11-17 11:06:28 ID: 183 | Name: access-token | Expires: 2025-11-10 13:06:28 ``` ### SAM API Development Rules 준수: ✅ **Service-First 아키텍처:** - AuthService에 모든 토큰 로직 - Controller는 DI + 응답만 ✅ **FormRequest 검증:** - RefreshRequest로 리프레시 토큰 검증 ✅ **i18n 메시지 키:** - __('message.token_refreshed'), __('error.xxx') 사용 ✅ **Swagger 문서:** - 별도 파일 (app/Swagger/v1/RefreshApi.php) - Auth 태그로 그룹화 ✅ **보안:** - 토큰 일회성 사용 - 만료 시간 검증 - 에러 코드 명시 (TOKEN_EXPIRED) ✅ **코드 품질:** - 타입 안전성 (int 캐스팅) - 명확한 주석 ### 테스트 결과: **Tinker 테스트:** ```bash php artisan tinker --execute=" \$user = User::find(1); \$tokens = \App\Services\AuthService::issueTokens(\$user); echo 'Access Token: ' . substr(\$tokens['access_token'], 0, 20) . '...' . PHP_EOL; echo 'Refresh Token: ' . substr(\$tokens['refresh_token'], 0, 20) . '...' . PHP_EOL; echo 'Expires In: ' . \$tokens['expires_in'] . ' seconds' . PHP_EOL; echo 'Expires At: ' . \$tokens['expires_at'] . PHP_EOL; " # 결과: Access Token: 177|MtYCVI4XDqX5GXA... Refresh Token: 178|rpoDdTsZ9orU2g3... Expires In: 7200 seconds (120 minutes) Expires At: 2025-11-10 13:01:21 ``` **API 엔드포인트 테스트:** ```bash # 로그인 curl -X POST "http://api.sam.kr/api/v1/login" \ -H "Content-Type: application/json" \ -H "X-API-KEY: 42Jfwc6EaRQ04GNRmLR5kzJp5UudSOzGGqjmdk1a" \ -d '{"email":"hamss@codebridge-x.com","password":"test1234"}' # 토큰 갱신 curl -X POST "http://api.sam.kr/api/v1/refresh" \ -H "Content-Type: application/json" \ -H "X-API-KEY: 42Jfwc6EaRQ04GNRmLR5kzJp5UudSOzGGqjmdk1a" \ -d '{"refresh_token":"182|vsdUYz2WVaFxC05TWp4M0njVLhh833jPK6ilN5AB8ee106ad"}' # 응답: { "message": "토큰이 갱신되었습니다", "access_token": "183|pfbAqUvAZ2meTVKisDDC8MwnhBUCoMVsK7GXoh8aa1c832c5", "refresh_token": "184|yNJJiqNF4GeH2u3YFAQr7mISYmLdEfiSdq9CdD00c1d7538d", "token_type": "Bearer", "expires_in": 7200, "expires_at": "2025-11-10 13:06:28" } ``` **데이터베이스 검증:** ```sql -- 최근 발급된 토큰 확인 SELECT id, name, expires_at, created_at FROM personal_access_tokens WHERE tokenable_id = 1 ORDER BY id DESC LIMIT 5; -- 결과: ✅ 새 토큰 발급: Access (ID: 183) + Refresh (ID: 184) ✅ 이전 리프레시 토큰 삭제: ID 182 삭제됨 ✅ 만료 시간 설정: Access 2시간 후, Refresh 7일 후 ``` ### 예상 효과: 1. **보안 강화**: 단기 액세스 토큰 + 장기 리프레시 토큰 2. **세션 관리**: 리프레시 토큰 갱신으로 지속적인 로그인 유지 3. **에러 처리**: TOKEN_EXPIRED 코드로 프론트엔드 자동 리프레시 구현 가능 4. **유연성**: 환경별 토큰 만료 시간 설정 (개발/운영 분리) ### 다음 작업: - [x] AuthService 구현 - [x] RefreshController 구현 - [x] Handler 에러 처리 - [x] Swagger 문서 작성 (Auth 태그) - [x] i18n 메시지 추가 - [x] Tinker 테스트 - [x] API 엔드포인트 테스트 - [x] DB 검증 - [ ] Frontend 토큰 갱신 로직 구현 - [ ] 만료 토큰 정리 스케줄러 (선택) ### Git 커밋: - 다음 커밋 예정: `feat: API 토큰 관리 시스템 구현 (액세스/리프레시 토큰 분리)` --- ## 2025-11-10 (일) - 회원가입 쿼리 최적화 (268개 → 58개, 78% 감소) ### 주요 작업 - **MenuObserver 성능 최적화**: Bulk insert + 지연 캐시 삭제로 메뉴당 28개 쿼리 → 3개 쿼리 - **RegisterService 중복 제거**: 권한 생성 로직 중복 제거 (27개 쿼리 감소) - **캐시 삭제 최적화**: 126개 캐시 삭제 → 11개 (91% 감소) - **확장성 유지**: 관리자의 메뉴 추가 시에도 동일한 최적화 적용 ### 수정된 파일: - `app/Observers/MenuObserver.php` - Bulk insert 및 DB::afterCommit() 활용 - `app/Services/RegisterService.php` - 중복 권한 생성 로직 제거 ### 작업 내용: #### 1. 문제 분석 **증상:** ``` 회원가입 시 268개 쿼리 실행 (과다) - MenuObserver: 9개 메뉴 × 28개 = 252개 - RegisterService 중복: 9개 × 3개 = 27개 - 기타: 19개 ``` **원인:** - MenuObserver가 메뉴 생성 시마다 7개 권한을 **개별 INSERT** (menu:{id}.view, create, update, delete, approve, export, manage) - 각 권한 INSERT마다 **캐시 즉시 삭제** (배치 처리 안 됨) - RegisterService가 **다른 패턴**으로 권한 중복 생성 (menu.{id}) **쿼리 분석:** ``` 메뉴 1개당: - SELECT 존재확인 × 7 = 7개 - INSERT 권한 × 7 = 7개 - DELETE 캐시 × 7 × 2 = 14개 총 28개 쿼리 9개 메뉴 × 28 = 252개 쿼리 ``` #### 2. MenuObserver.php 최적화 **Before (개별 INSERT):** ```php protected function ensurePermissions(Menu $menu): void { foreach ($this->actions() as $act) { Permission::firstOrCreate([ 'tenant_id' => (int) $menu->tenant_id, 'guard_name' => $this->guard, 'name' => "menu:{$menu->id}.{$act}", ]); // 7번 반복 = 28개 쿼리 } } ``` **After (Bulk Insert + 지연 캐시):** ```php protected function ensurePermissions(Menu $menu): void { $actions = $this->actions(); $permissionsData = []; $now = now(); foreach ($actions as $act) { $permissionsData[] = [ 'tenant_id' => (int) $menu->tenant_id, 'guard_name' => $this->guard, 'name' => "menu:{$menu->id}.{$act}", 'created_at' => $now, 'updated_at' => $now, ]; } // Bulk insert (7개를 1번에) DB::table('permissions')->insertOrIgnore($permissionsData); } public function created(Menu $menu): void { // ... $this->ensurePermissions($menu); $this->forgetCacheAfterCommit(); // 트랜잭션 종료 후 1번만 } protected function forgetCacheAfterCommit(): void { DB::afterCommit(function () { app(PermissionRegistrar::class)->forgetCachedPermissions(); }); } ``` **개선 효과:** - 메뉴 1개당: 28개 쿼리 → **3개 쿼리** (bulk insert + 지연 캐시) - 9개 메뉴: 252개 → **27개 쿼리** #### 3. RegisterService.php 중복 제거 **Before (중복 권한 생성):** ```php // 8. Create permissions for each menu and assign to role $permissions = []; foreach ($menuIds as $menuId) { $permName = "menu.{$menuId}"; // ❌ 다른 패턴 (menu.{id}) $perm = Permission::firstOrCreate([ 'tenant_id' => $tenant->id, 'guard_name' => 'api', 'name' => $permName, ]); // 9개 × 3개 쿼리 = 27개 추가 쿼리 $permissions[] = $perm; } $role->syncPermissions($permissions); ``` **After (Observer 권한 재사용):** ```php // 8. Get all permissions created by MenuObserver (menu:{id}.{action} pattern) $permissionNames = []; $actions = config('authz.menu_actions', ['view', 'create', 'update', 'delete', 'approve']); foreach ($menuIds as $menuId) { foreach ($actions as $action) { $permissionNames[] = "menu:{$menuId}.{$action}"; } } $permissions = Permission::whereIn('name', $permissionNames) ->where('tenant_id', $tenant->id) ->where('guard_name', 'api') ->get(); // 1개 쿼리로 모든 권한 조회 // 9. Assign all menu permissions to system_manager role $role->syncPermissions($permissions); ``` **개선 효과:** - 중복 생성 제거: **27개 쿼리 감소** - 권한 패턴 통일: `menu:{id}.{action}` 형식으로 일관성 유지 #### 4. 최종 결과 **쿼리 구성 (총 58개):** ``` - INSERT menus : 9개 - INSERT permissions (bulk) : 9개 (메뉴당 7개씩 일괄) - DELETE cache : 11개 (이전 126개 → 91% 감소) - INSERT tenants/users/roles : 5개 - INSERT tenant_bootstrap : 6개 - SELECT/기타 : 18개 ────────────────────────────────────── 총합: 58개 (이전 268개 대비 78% 감소) ``` **데이터 검증:** ``` ✅ 메뉴: 9개 생성 ✅ 권한: 63개 생성 (9메뉴 × 7액션) - 액션: view, create, update, delete, approve, export, manage ✅ 권한 패턴: menu:{id}.{action} (통일됨) ✅ Role 할당: system_manager에 모든 권한 부여 ``` ### 기술 세부사항: #### Bulk Insert 최적화 ```php // Before: 7번의 개별 INSERT Permission::firstOrCreate([...]); // × 7 // After: 1번의 Bulk INSERT DB::table('permissions')->insertOrIgnore([ [...], // 7개의 레코드 [...], // ... ]); ``` #### 지연 캐시 삭제 (DB::afterCommit) ```php // Before: 권한마다 즉시 캐시 삭제 foreach ($actions as $act) { Permission::firstOrCreate([...]); app(PermissionRegistrar::class)->forgetCachedPermissions(); // × 7 } // After: 트랜잭션 종료 후 1번만 DB::afterCommit(function () { app(PermissionRegistrar::class)->forgetCachedPermissions(); // × 1 }); ``` #### 권한 패턴 통일 ``` Before: - MenuObserver: menu:{id}.view, menu:{id}.create, ... - RegisterService: menu.{id} (중복!) After: - MenuObserver: menu:{id}.view, menu:{id}.create, ... - RegisterService: MenuObserver 권한 재사용 (중복 제거) ``` ### SAM API Development Rules 준수: ✅ **성능 최적화:** - Bulk insert로 쿼리 횟수 최소화 - 캐시 삭제를 트랜잭션 단위로 배치 처리 ✅ **확장성 유지:** - 관리자가 나중에 메뉴 추가 시에도 동일한 최적화 적용 - Role/Department/User별 세밀한 권한 제어 가능 ✅ **코드 일관성:** - 권한 패턴 통일 (menu:{id}.{action}) - 중복 로직 제거 ✅ **코드 품질:** - Laravel Pint 포맷팅 완료 (2 files) ### 예상 효과: 1. **성능 향상**: 회원가입 응답 속도 개선 (쿼리 78% 감소) 2. **서버 부하 감소**: DB 커넥션 사용량 대폭 감소 3. **확장성 유지**: 미래 메뉴 추가 시에도 최적화 효과 지속 4. **유지보수성**: 권한 패턴 통일로 코드 이해도 향상 ### 테스트 결과: ```bash php artisan tinker --execute=" DB::enableQueryLog(); \$result = App\Services\RegisterService::register([...]); \$queries = DB::getQueryLog(); echo '쿼리 수: ' . count(\$queries) . '개'; " # 결과: 58개 (이전 268개) ``` ### 다음 작업: - [x] MenuObserver bulk insert 구현 - [x] 지연 캐시 삭제 (DB::afterCommit) - [x] RegisterService 중복 권한 생성 제거 - [x] Pint 포맷팅 - [x] 회원가입 테스트 및 쿼리 수 검증 ### Git 커밋: - 커밋 메시지: `perf: 회원가입 쿼리 최적화 (268개 → 58개, 78% 감소)` --- ## 2025-11-10 (일) - 회원가입 메뉴 생성 오류 수정 및 검증 에러 처리 개선 ### 주요 작업 - **MenusStep 컬럼 오류 수정**: 존재하지 않는 컬럼(code, route_name, depth, description) 제거 - **하이브리드 메뉴 생성 방식 도입**: TenantBootstrapper에서 MenusStep 비활성화, MenuBootstrapService 활용 - **ValidationException 처리 개선**: 실제 검증 에러 메시지 표시 (422 상태 코드) ### 수정된 파일: - `app/Services/TenantBootstrap/Steps/MenusStep.php` - 실제 DB 스키마에 맞게 컬럼 수정 - `app/Services/TenantBootstrap/RecipeRegistry.php` - MenusStep 비활성화 (주석 처리) - `app/Exceptions/Handler.php` - ValidationException 처리 로직 개선 ### 작업 내용: #### 1. 문제 분석 **증상:** ``` SQLSTATE[42S22]: Column not found: 1054 Unknown column 'code' in 'field list' SQL: insert into `menus` (..., `code`, `route_name`, `depth`, `description`, ...) ``` **원인:** - `TenantObserver`가 Tenant 생성 시 자동으로 `TenantBootstrapper::bootstrap()` 호출 - `MenusStep.php`가 실제 DB에 없는 컬럼(`code`, `route_name`, `depth`, `description`) 사용 시도 - `RegisterService.php`의 `MenuBootstrapService::createDefaultMenus()`와 중복 실행 **쿼리 과다 실행:** - 메뉴 9개 생성 시 272개 쿼리 실행 - MenuObserver가 메뉴당 7개 권한 자동 생성 (view/create/update/delete/approve/export/manage) - 중복 메뉴 생성 + 중복 권한 생성 #### 2. MenusStep.php 수정 **Before (잘못된 컬럼):** ```php $newId = DB::table('menus')->insertGetId([ 'tenant_id' => $tenantId, 'parent_id' => $newParentId, 'name' => $menu->name, 'code' => $menu->code ?? null, // ❌ 존재하지 않음 'icon' => $menu->icon ?? null, 'url' => $menu->url ?? null, 'route_name' => $menu->route_name ?? null, // ❌ 존재하지 않음 'sort_order' => $menu->sort_order ?? 0, 'is_active' => $menu->is_active ?? 1, 'depth' => $menu->depth ?? 0, // ❌ 존재하지 않음 'description' => $menu->description ?? null, // ❌ 존재하지 않음 'created_at' => now(), 'updated_at' => now(), ]); ``` **After (실제 DB 스키마):** ```php $newId = DB::table('menus')->insertGetId([ 'tenant_id' => $tenantId, 'parent_id' => $newParentId, 'name' => $menu->name, 'icon' => $menu->icon ?? null, 'url' => $menu->url ?? null, 'sort_order' => $menu->sort_order ?? 0, 'is_active' => $menu->is_active ?? 1, 'hidden' => $menu->hidden ?? 0, // ✅ 실제 컬럼 'is_external' => $menu->is_external ?? 0, // ✅ 실제 컬럼 'external_url' => $menu->external_url ?? null, // ✅ 실제 컬럼 'created_at' => now(), 'updated_at' => now(), ]); ``` **실제 DB 컬럼:** ```sql id, tenant_id, parent_id, name, url, is_active, sort_order, hidden, is_external, external_url, icon, created_at, updated_at, created_by, updated_by, deleted_by, deleted_at ``` #### 3. 하이브리드 메뉴 생성 방식 도입 **배경:** - **Option A**: TenantBootstrapper (글로벌 메뉴 복제, DB 의존) - **Option B**: MenuBootstrapService (코드 기반, Git 버전 관리) **선택: 하이브리드 접근** (Best Practice) ``` TenantObserver → TenantBootstrapper ├─ CapabilityProfilesStep ✅ (유지) ├─ CategoriesStep ✅ (유지) ├─ MenusStep ❌ (비활성화) └─ SettingsStep ✅ (유지) RegisterService → MenuBootstrapService ✅ (메뉴 생성) ``` **장점:** - ✅ 메뉴 구조가 코드로 명확하게 정의됨 (`MenuBootstrapService.php`) - ✅ Git으로 버전 관리 가능 - ✅ 새 메뉴 추가가 간단 (코드만 수정) - ✅ 글로벌 메뉴 DB 데이터 불필요 - ✅ 부트스트랩 시스템 장점 유지 (CapabilityProfiles, Categories, Settings) **RecipeRegistry.php 수정:** ```php default => [ // STANDARD new CapabilityProfilesStep, new CategoriesStep, // new MenusStep, // Disabled: Use MenuBootstrapService in RegisterService instead new SettingsStep, ], ``` #### 4. ValidationException 처리 개선 **문제:** ```php // Before - 모든 validation 에러를 "필수 파라미터 누락"으로 변환 if ( $exception instanceof ValidationException || $exception instanceof BadRequestHttpException ) { return response()->json([ 'success' => false, 'message' => '필수 파라미터 누락', // ❌ 실제 에러 메시지 손실 'data' => null, ], 400); } ``` **증상:** - Slack: "이메일은(는) 이미 사용 중입니다" (실제 에러) - API 응답: "필수 파라미터 누락" (잘못된 메시지) **수정:** ```php // After - 실제 검증 에러 메시지 표시 if ($exception instanceof ValidationException) { return response()->json([ 'success' => false, 'message' => '입력값 검증 실패', 'data' => [ 'errors' => $exception->errors(), // ✅ 실제 에러 정보 ], ], 422); // ✅ 표준 validation 실패 코드 } if ($exception instanceof BadRequestHttpException) { return response()->json([ 'success' => false, 'message' => '잘못된 요청', 'data' => null, ], 400); } ``` **개선 효과:** ```json // Before { "success": false, "message": "필수 파라미터 누락", "data": null } // After { "success": false, "message": "입력값 검증 실패", "data": { "errors": { "email": ["이메일은(는) 이미 사용 중입니다."], "user_id": ["사용자 아이디은(는) 이미 사용 중입니다."] } } } ``` ### 기술 세부사항: #### 메뉴 생성 방식 비교 **TenantBootstrapper + MenusStep (기존):** - 장점: 체계적인 부트스트랩 시스템, 레시피 기반 확장 - 단점: 글로벌 메뉴 DB 데이터 필요, Git 버전 관리 불가, 메뉴 추가 시 DB 수정 필요 **MenuBootstrapService (새 방식):** - 장점: 코드 기반, Git 버전 관리, 메뉴 추가 간단 - 단점: 부트스트랩 시스템과 분리 **하이브리드 (선택):** - 데이터 부트스트랩(CapabilityProfiles, Categories, Settings)은 TenantBootstrapper 사용 - 메뉴 생성은 코드 기반 MenuBootstrapService 사용 - 양쪽 장점 활용 #### HTTP 상태 코드 표준화 - **422 Unprocessable Entity**: Validation 실패 (표준) - **400 Bad Request**: 잘못된 요청 형식 - **401 Unauthorized**: 인증 실패 - **403 Forbidden**: 권한 없음 - **404 Not Found**: 리소스 없음 - **500 Internal Server Error**: 서버 에러 ### SAM API Development Rules 준수: ✅ **Service-First 아키텍처:** - MenuBootstrapService에 메뉴 생성 로직 ✅ **멀티테넌시:** - Tenant context 명시적 설정 - BelongsToTenant 스코프 활용 ✅ **코드 품질:** - 실제 DB 스키마와 일치 - 명확한 주석 (비활성화 이유 설명) ✅ **에러 처리:** - 표준 HTTP 상태 코드 - 실제 검증 에러 메시지 표시 ### 예상 효과: 1. **회원가입 정상 동작**: SQL 에러 해결 2. **쿼리 최적화**: 272개 → 약 100개 (중복 제거) 3. **유지보수 편의성**: 코드 기반 메뉴 관리 4. **명확한 에러 메시지**: 사용자가 정확한 문제 파악 가능 ### 다음 작업: - [x] MenusStep.php 컬럼 수정 - [x] RecipeRegistry.php MenusStep 비활성화 - [x] Handler.php ValidationException 처리 개선 - [x] 캐시 클리어 - [x] 회원가입 API 테스트 (성공/실패 케이스) ### Git 커밋: - 커밋 메시지: `fix: 회원가입 메뉴 생성 오류 수정 및 검증 에러 처리 개선` --- ## 2025-11-06 (수) - Login API 응답 개선 (사용자/테넌트/메뉴 정보 포함) ### 주요 작업 - **Login API 응답 확장**: 토큰 외에 user, tenant, menus 정보 추가 - **테넌트 우선순위 로직**: is_default → is_active → null 순서로 선택 - **권한 기반 메뉴 필터링**: menu:{id}.view 권한 + override allow/deny 적용 - **Permission Overrides 활용**: 시간 기반 명시적 허용/차단 지원 - **메뉴 외부 링크 지원**: is_external, external_url 필드 추가 ### 수정된 파일: - `app/Services/MemberService.php` - getUserInfoForLogin() 메서드 추가 (130줄) + 외부 링크 필드 추가 - `app/Http/Controllers/Api/V1/ApiController.php` - login() 응답 구조 변경 (8줄) - `app/Swagger/v1/AuthApi.php` - login() 엔드포인트 문서 업데이트 (80줄) + 외부 링크 스키마 추가 ### 작업 내용: #### 1. MemberService::getUserInfoForLogin() 구현 **5단계 프로세스:** ```php 1. 사용자 기본 정보 조회 - User::find($userId) - 반환: {id, user_id, name, email, phone} 2. 활성 테넛트 조회 (우선순위) - 1순위: is_default=1 - 2순위: is_active=1 (첫 번째) - 없으면: return {user, tenant: null, menus: []} 3. 테넛트 정보 구성 - 기본 테넌트: {id, company_name, business_num, tenant_st_code} - 추가 테넌트 목록: other_tenants[] 4. 메뉴 권한 체크 (menu:{menu_id}.view 패턴) - 4-1. 기본 Role 권한 (model_has_permissions 테이블) - 4-2. Override 권한 (permission_overrides 테이블) - 4-3. 최종 권한 계산: deny(-1) > allow(1) > base permission 5. 메뉴 목록 조회 - Menu::whereIn('id', $allowedMenuIds) - 정렬: parent_id → sort_order - 반환: {id, parent_id, name, url, icon, sort_order, is_external, external_url} ``` **권한 우선순위 로직:** ```php foreach ($allMenuPermissions as $permName) { // 1. Override deny 체크 if (isset($overrides[$permName]) && $overrides[$permName]->effect === -1) { continue; // 강제 차단 } // 2. Override allow 또는 기본 Role 권한 if ( (isset($overrides[$permName]) && $overrides[$permName]->effect === 1) || in_array($permName, $rolePermissions, true) ) { $allowedMenuIds[] = $menuId; } } ``` **시간 기반 Override 적용:** ```php ->where(function ($q) { $q->whereNull('permission_overrides.effective_from') ->orWhere('permission_overrides.effective_from', '<=', now()); }) ->where(function ($q) { $q->whereNull('permission_overrides.effective_to') ->orWhere('permission_overrides.effective_to', '>=', now()); }) ``` #### 2. ApiController::login() 응답 변경 **기존 응답:** ```json { "message": "로그인 성공", "user_token": "1|abc123xyz" } ``` **개선된 응답:** ```json { "message": "로그인 성공", "user_token": "1|abc123xyz", "user": { "id": 1, "user_id": "hamss", "name": "홍길동", "email": "hamss@example.com", "phone": "010-1234-5678" }, "tenant": { "id": 1, "company_name": "주식회사 코드브리지", "business_num": "123-45-67890", "tenant_st_code": "ACTIVE", "other_tenants": [ { "tenant_id": 2, "company_name": "주식회사 샘플", "business_num": "987-65-43210", "tenant_st_code": "ACTIVE" } ] }, "menus": [ { "id": 1, "parent_id": null, "name": "대시보드", "url": "/dashboard", "icon": "dashboard", "sort_order": 1 } ] } ``` **테넌트 없는 경우:** ```json { "message": "로그인 성공", "user_token": "1|abc123xyz", "user": { ... }, "tenant": null, "menus": [] } ``` #### 3. Swagger 문서 업데이트 **응답 스키마 (AuthApi.php):** - 200 응답: 테넌트 있는 경우 (완전한 정보) - 200 (테넌트 없음): tenant=null, menus=[] 케이스 - 400: 필수 파라미터 누락 - 401: 비밀번호 불일치 - 404: 사용자를 찾을 수 없음 **주요 변경사항:** ```php @OA\Property( property="tenant", type="object", nullable=true, description="활성 테넌트 정보 (is_default=1 우선, 없으면 is_active=1 중 첫 번째, 없으면 null)", // ... 스키마 정의 ) @OA\Property( property="menus", type="array", description="사용자가 접근 가능한 메뉴 목록 (menu:{menu_id}.view 권한 체크, override deny/allow 적용)", // ... 스키마 정의 ) ``` ### 기술 세부사항: #### Permission Overrides 테이블 구조 ```sql CREATE TABLE permission_overrides ( tenant_id BIGINT UNSIGNED, model_type VARCHAR(255), -- User::class model_id BIGINT UNSIGNED, -- User ID permission_id BIGINT UNSIGNED, effect TINYINT, -- 1=ALLOW, -1=DENY effective_from TIMESTAMP NULL, effective_to TIMESTAMP NULL ); ``` #### 권한 체크 세 가지 방법 (모두 사용) 1. **Spatie hasPermissionTo()**: Role 기반 자동 상속 2. **permission_overrides**: 명시적 allow/deny with 시간 제약 3. **Role-based inheritance**: Spatie 자동 처리 **우선순위:** override deny > override allow > base permission #### 성능 특성 - **현재 방식**: 6-7 쿼리, 100-200ms - **최적화 (캐싱 없음)**: 4 쿼리, 50-100ms - **캐싱 적용 시**: 1 쿼리 (캐시 후), 10-20ms **선택:** 세밀한 제어 우선 (로그인 시에만 실행되므로 성능 영향 최소) ### SAM API Development Rules 준수: ✅ **Service-First 아키텍처:** - MemberService에 모든 비즈니스 로직 - Controller는 DI + 호출만 ✅ **멀티테넌시:** - BelongsToTenant 스코프 활용 - Tenant context 명시적 처리 ✅ **보안:** - 민감 정보 제외 (password, remember_token, timestamps, audit columns) - 권한 기반 메뉴 필터링 ✅ **Swagger 문서:** - 별도 파일 (app/Swagger/v1/AuthApi.php) - 완전한 응답 스키마 (테넌트 있음/없음 케이스) ✅ **코드 품질:** - Laravel Pint 포맷팅 완료 (3 files, 1 style issue fixed) ### 예상 효과: 1. **클라이언트 편의성**: 1회 로그인으로 모든 정보 획득 2. **네트워크 최적화**: 추가 API 호출 불필요 (/me 엔드포인트 미호출) 3. **세밀한 권한 제어**: Override 기능으로 일시적 권한 부여/차단 4. **멀티테넌트 지원**: 여러 테넌트 소속 시 전환 가능 정보 제공 ### 다음 작업: - [ ] Swagger 재생성 (`php artisan l5-swagger:generate`) - [ ] Postman/Swagger UI로 API 테스트 - [ ] Frontend 로그인 화면에서 응답 데이터 처리 - [ ] 캐싱 전략 고려 (필요 시) ### Git 커밋 준비: - 다음 커밋 예정: `feat: 로그인 응답에 사용자/테넌트/메뉴 정보 추가` --- ## 2025-11-06 (수) - Register API 개발 (/api/v1/register) ### 주요 작업 - **Register API 전체 구현**: 회원가입 + 테넌트 생성 + 시스템 관리자 권한 자동 부여 - **글로벌 메뉴 복제 로직**: 새 테넌트 생성 시 글로벌 메뉴 자동 복사 (parent_id 매핑) - **사업자번호 조건부 유효성 검사**: 정식 서비스(active) 업체만 unique 제약 - **완전한 Swagger 문서**: 상세한 요청/응답 스키마 및 에러 케이스 ### 추가된 파일: - `app/Http/Requests/RegisterRequest.php` - 회원가입 요청 검증 (FormRequest) - `app/Services/RegisterService.php` - 통합 비즈니스 로직 (DB 트랜잭션) - `app/Http/Controllers/Api/V1/RegisterController.php` - 컨트롤러 (ApiResponse::handle) - `app/Swagger/v1/RegisterApi.php` - Swagger 문서 ### 수정된 파일: - `app/Services/TenantBootstrap/Steps/MenusStep.php` - 글로벌 메뉴 복제 로직 구현 - `lang/ko/message.php` - `registered` 키 추가 - `lang/ko/error.php` - 4개 에러 메시지 추가 (business_num_format, business_num_duplicate_active, user_id_format, phone_format) - `routes/api.php` - POST /api/v1/register 라우트 추가 ### 작업 내용: #### 1. RegisterRequest 검증 규칙 **사용자 필드:** ```php 'user_id' => 'required|string|max:255|regex:/^[a-zA-Z0-9_-]+$/|unique:users,user_id', 'name' => 'required|string|max:255', 'email' => 'required|string|email|max:255|unique:users,email', 'phone' => 'nullable|string|max:20|regex:/^[0-9-]+$/', 'password' => 'required|string|min:8|confirmed', 'position' => 'nullable|string|max:100', // options JSON에 저장 ``` **테넌트 필드:** ```php 'company_name' => 'required|string|max:255', 'business_num' => [ 'required', 'string', 'regex:/^\d{3}-\d{2}-\d{5}$/', Rule::unique('tenants', 'business_num')->where(function ($query) { return $query->where('tenant_st_code', 'active'); // ⚠️ active만 unique }), ], 'company_scale' => 'nullable|string|max:50', // options JSON에 저장 'industry' => 'nullable|string|max:100', // options JSON에 저장 ``` **핵심 특징:** - ✅ 사업자번호: `tenant_st_code='active'`인 경우만 unique (trial/none은 중복 허용) - ✅ 비밀번호: confirmed 규칙 (password_confirmation 필요) - ✅ 커스텀 에러 메시지: i18n 키 사용 #### 2. RegisterService 비즈니스 로직 **전체 프로세스 (DB::transaction 래핑):** ```php 1. Tenant 생성 - company_name, business_num - tenant_st_code = 'trial' (데모 버전) - options = {company_scale, industry} 2. TenantBootstrap 실행 (STANDARD 레시피) - MenusStep: 글로벌 메뉴 복제 (parent_id 매핑) - CategoriesStep, SettingsStep 등 3. User 생성 - user_id, name, email, phone - password = Hash::make() - options = {position} 4. TenantUserProfile 생성 - is_default = 1, is_active = 1 5. Tenant Context 설정 - app()->bind('tenant_id', $tenant->id) - PermissionRegistrar::setPermissionsTeamId($tenant->id) 6. system_manager Role 생성 - guard_name = 'api' - description = '시스템 관리자' 7. 모든 테넌트 메뉴 권한 생성 및 할당 - Menu::where('tenant_id', $tenant->id)->pluck('id') - Permission::firstOrCreate(['name' => "menu.{menu_id}"]) - $role->syncPermissions($permissions) 8. User에게 system_manager Role 할당 - $user->assignRole($role) 9. 결과 반환 - user: {id, user_id, name, email, phone, options} - tenant: {id, company_name, business_num, tenant_st_code, options} ``` **주의 사항 (자동 적용됨):** - ⚠️ **트랜잭션 필수**: 실패 시 전체 롤백 - ⚠️ **멀티테넌시**: Tenant context 명시적 설정 - ⚠️ **보안**: Hash::make() 사용, 입력 검증 - ⚠️ **글로벌 메뉴 복제**: parent_id 매핑으로 계층 구조 유지 - ⚠️ **사업자번호 검증**: 조건부 unique (active만) #### 3. MenusStep 글로벌 메뉴 복제 로직 **기존 문제:** - ROOT 메뉴만 생성하는 stub 구현 - 글로벌 메뉴가 복사되지 않음 **개선 내용:** ```php public function run(int $tenantId): void { // 1. 중복 실행 방지 if (Menu::where('tenant_id', $tenantId)->exists()) { return; } // 2. 글로벌 메뉴 조회 (계층 순서로 정렬) $globalMenus = DB::table('menus') ->whereNull('tenant_id') ->orderByRaw('parent_id IS NULL DESC, parent_id ASC, sort_order ASC') ->get(); // 3. parent_id 매핑 (old_id => new_id) $parentIdMap = []; foreach ($globalMenus as $menu) { // 4. 부모 ID 매핑 확인 $newParentId = null; if ($menu->parent_id !== null && isset($parentIdMap[$menu->parent_id])) { $newParentId = $parentIdMap[$menu->parent_id]; } // 5. 새 메뉴 생성 $newId = DB::table('menus')->insertGetId([ 'tenant_id' => $tenantId, 'parent_id' => $newParentId, // ⚠️ 매핑된 parent_id 사용 'name' => $menu->name, 'code' => $menu->code ?? null, // ... 모든 필드 복사 ]); // 6. 매핑 저장 $parentIdMap[$menu->id] = $newId; } } ``` **핵심:** - ✅ 루트 메뉴 우선 처리 (`parent_id IS NULL DESC`) - ✅ parent_id 매핑으로 계층 구조 정확히 유지 - ✅ 모든 메뉴 속성 보존 (name, code, icon, url, route_name 등) #### 4. RegisterController 구현 **패턴:** ```php public function register(RegisterRequest $request) { return ApiResponse::handle(function () use ($request) { return RegisterService::register($request->validated()); }, __('message.registered')); } ``` **특징:** - ✅ FormRequest 타입 힌트 (자동 검증) - ✅ Service DI + ApiResponse::handle() - ✅ i18n 메시지 키 사용 - ✅ Controller는 단순 래퍼 역할 #### 5. Swagger 문서 (RegisterApi.php) **요청 스키마:** ```php required: user_id, name, email, password, password_confirmation, company_name, business_num optional: phone, position, company_scale, industry ``` **응답 스키마 (200):** ```php { "success": true, "message": "회원가입이 완료되었습니다", "data": { "user": { "id": 1, "user_id": "john_doe", "name": "홍길동", "email": "john@example.com", "phone": "010-1234-5678", "options": {"position": "개발팀장"} }, "tenant": { "id": 1, "company_name": "(주)테크컴퍼니", "business_num": "123-45-67890", "tenant_st_code": "trial", "options": { "company_scale": "중소기업", "industry": "IT/소프트웨어" } } } } ``` **에러 응답 (422):** ```php { "success": false, "message": "유효성 검증에 실패했습니다", "errors": { "user_id": ["이미 사용 중인 아이디입니다"], "business_num": ["사업자등록번호 형식이 올바르지 않습니다 (000-00-00000)"] } } ``` #### 6. i18n 메시지 추가 **lang/ko/message.php:** ```php 'registered' => '회원가입이 완료되었습니다.', ``` **lang/ko/error.php:** ```php 'business_num_format' => '사업자등록번호 형식이 올바르지 않습니다 (000-00-00000)', 'business_num_duplicate_active' => '이미 등록된 사업자등록번호입니다 (정식 서비스 업체)', 'user_id_format' => '아이디는 영문, 숫자, _, - 만 사용할 수 있습니다', 'phone_format' => '전화번호 형식이 올바르지 않습니다', ``` #### 7. Routes 등록 **routes/api.php:** ```php use App\Http\Controllers\Api\V1\RegisterController; Route::middleware('auth.apikey')->group(function () { Route::post('register', [RegisterController::class, 'register'])->name('v1.register'); }); ``` **엔드포인트:** - POST /api/v1/register (auth.apikey 미들웨어) ### SAM API Development Rules 준수: ✅ **Service-First 아키텍처:** - RegisterService에 모든 비즈니스 로직 - Controller는 DI + ApiResponse::handle()만 ✅ **FormRequest 검증:** - RegisterRequest로 모든 검증 규칙 분리 ✅ **i18n 메시지 키:** - __('message.registered'), __('error.xxx') 사용 ✅ **Swagger 문서:** - 별도 파일 (app/Swagger/v1/RegisterApi.php) - 완전한 요청/응답 스키마 ✅ **멀티테넌시:** - BelongsToTenant 스코프 (Tenant, Role, Permission) - Explicit tenant context 설정 ✅ **감사 로그:** - created_by, updated_by 컬럼 포함 ✅ **SoftDeletes:** - Tenant, User 모델에 적용 ### 기술 세부사항: #### 조건부 Unique 제약 ```php // trial/none 테넌트는 사업자번호 중복 허용 Rule::unique('tenants', 'business_num')->where(function ($query) { return $query->where('tenant_st_code', 'active'); }) ``` #### parent_id 매핑 알고리즘 ```php // 1. 루트 메뉴 먼저 처리 (parent_id IS NULL) // 2. insertGetId로 새 ID 캡처 // 3. old_id => new_id 매핑 저장 // 4. 자식 메뉴 처리 시 매핑된 parent_id 사용 $parentIdMap[$oldId] = $newId; $newParentId = $parentIdMap[$menu->parent_id] ?? null; ``` #### DB Transaction ```php return DB::transaction(function () use ($params) { // 모든 작업이 성공하거나 전체 롤백 $tenant = Tenant::create([...]); app(RecipeRegistry::class)->bootstrap($tenant->id); $user = User::create([...]); // ... return ['user' => $user->only([...]), 'tenant' => $tenant->only([...])]; }); ``` ### 예상 효과: 1. **원스톱 가입**: 1회 요청으로 모든 설정 완료 2. **즉시 사용 가능**: system_manager 권한으로 모든 메뉴 접근 3. **멀티테넌트 격리**: 각 테넌트별 독립적인 메뉴 구조 4. **유연한 검증**: trial 단계에서는 사업자번호 중복 허용 ### 다음 작업: - [ ] Swagger 재생성 (`php artisan l5-swagger:generate`) - [ ] Postman/Swagger UI로 API 테스트 - [ ] Frontend 회원가입 화면 구현 - [ ] 이메일 인증 기능 추가 (선택) - [ ] API 문서 최종 검토 ### Git 커밋 준비: - 다음 커밋 예정: `feat: 회원가입 + 테넌트 생성 통합 API 추가 (/api/v1/register)` --- ## 2025-11-10 (일) - 스케줄러 Laravel 12 표준 방식 전환 + 로그 기록 ### 주요 작업 - **Laravel 12 표준 방식 적용**: Kernel.php → routes/console.php로 스케줄러 마이그레이션 - **로그 기록 기능 추가**: 실행 결과 및 성공/실패 이벤트 로그 - **스케줄러 정리**: Kernel.php 레거시 코드 정리 ### 수정된 파일: - `routes/console.php` - Laravel 12 표준 스케줄러 정의 + 로그 기록 - `app/Console/Kernel.php` - schedule() 메서드 정리 (주석 처리) ### 작업 내용: #### 1. routes/console.php 마이그레이션 (Laravel 12 표준) **변경 전 (Kernel.php):** ```php protected function schedule(Schedule $schedule): void { $schedule->command('audit:prune')->dailyAt('03:10'); $schedule->command('sanctum:prune-expired --hours=24')->dailyAt('03:20'); } ``` **변경 후 (routes/console.php):** ```php use Illuminate\Support\Facades\Schedule; // 감사 로그 정리 (매일 새벽 03:10) Schedule::command('audit:prune') ->dailyAt('03:10') ->appendOutputTo(storage_path('logs/scheduler.log')) ->onSuccess(function () { \Illuminate\Support\Facades\Log::info('✅ audit:prune 스케줄러 실행 성공', ['time' => now()]); }) ->onFailure(function () { \Illuminate\Support\Facades\Log::error('❌ audit:prune 스케줄러 실행 실패', ['time' => now()]); }); // 만료 토큰 정리 (매일 새벽 03:20) Schedule::command('sanctum:prune-expired --hours=24') ->dailyAt('03:20') ->appendOutputTo(storage_path('logs/scheduler.log')) ->onSuccess(function () { \Illuminate\Support\Facades\Log::info('✅ sanctum:prune-expired 스케줄러 실행 성공', ['time' => now()]); }) ->onFailure(function () { \Illuminate\Support\Facades\Log::error('❌ sanctum:prune-expired 스케줄러 실행 실패', ['time' => now()]); }); ``` #### 2. Kernel.php 정리 **app/Console/Kernel.php:** ```php protected function schedule(Schedule $schedule): void { // Laravel 12부터는 routes/console.php에서 스케줄러를 정의합니다. // Schedule::command() 방식 사용 } ``` #### 3. 로그 기록 방식 **2가지 로그 파일:** 1. **storage/logs/scheduler.log** - 명령어 실행 결과 ``` Pruned 0 audit log rows older than 390 days. Tokens expired for more than [24 hours] pruned successfully. ``` 2. **storage/logs/laravel.log** - 성공/실패 이벤트 ``` [2025-11-10 03:10:15] production.INFO: ✅ audit:prune 스케줄러 실행 성공 {"time":"2025-11-10 03:10:15"} [2025-11-10 03:20:20] production.INFO: ✅ sanctum:prune-expired 스케줄러 실행 성공 {"time":"2025-11-10 03:20:20"} ``` #### 4. 스케줄러 확인 방법 **등록 확인:** ```bash php artisan schedule:list # 결과: # 10 3 * * * php artisan audit:prune ................. Next Due: 13시간 후 # 20 3 * * * php artisan sanctum:prune-expired ... Next Due: 13시간 후 ``` **로그 확인:** ```bash # 실행 결과 확인 cat storage/logs/scheduler.log tail -f storage/logs/scheduler.log # 실시간 모니터링 # 성공/실패 이벤트 확인 tail -f storage/logs/laravel.log | grep "스케줄러" # 성공 로그만 grep "✅" storage/logs/laravel.log # 실패 로그만 grep "❌" storage/logs/laravel.log ``` ### 기술 세부사항: #### Laravel 11+ 스케줄러 아키텍처 변경 - **Laravel 10 이하**: app/Console/Kernel.php의 schedule() 메서드 - **Laravel 11+**: routes/console.php에서 Schedule 파사드 사용 - **이점**: - 라우트와 함께 콘솔 명령 관리 - 더 간결한 구조 - 향후 유지보수 용이 #### 로그 기록 전략 - **appendOutputTo**: 명령어 stdout/stderr를 파일에 추가 - **onSuccess/onFailure**: 실행 결과에 따라 Laravel Log 기록 - **비동기 처리**: Log 파사드가 자동으로 처리 ### 예상 효과: 1. **표준 방식 준수**: Laravel 12 공식 권장 방식 2. **실행 추적**: 스케줄러 실행 이력 확인 가능 3. **문제 진단**: 실패 로그로 즉시 문제 파악 4. **운영 편의성**: 로그 파일 분석으로 시스템 모니터링 ### 다음 작업: - [ ] Frontend 토큰 갱신 로직 구현 (권장) - [ ] 토큰 만료 모니터링 대시보드 (선택) ### Git 커밋: 798d514 - feat: API 토큰 관리 시스템 구현 (액세스/리프레시 토큰 분리) (다음 커밋 예정: feat: 스케줄러 Laravel 12 표준 방식 전환 + 로그 기록) --- (이전 작업 내역은 그대로 유지...)