# SAM API 저장소 작업 현황 ## 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)` --- (이전 작업 내역은 그대로 유지...)