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