diff --git a/app/Helpers/ApiResponse.php b/app/Helpers/ApiResponse.php index df8faf7..f9ea595 100644 --- a/app/Helpers/ApiResponse.php +++ b/app/Helpers/ApiResponse.php @@ -25,17 +25,19 @@ public static function debugQueryLog(): array { $logs = DB::getQueryLog(); - return collect($logs)->map(function ($log) { - $query = $log['query']; - foreach ($log['bindings'] as $binding) { - $binding = is_numeric($binding) ? $binding : "'" . addslashes($binding) . "'"; - $query = preg_replace('/\\?/', $binding, $query, 1); - } + return collect($logs) + ->skip(3) + ->map(function ($log) { + $query = $log['query']; + foreach ($log['bindings'] as $binding) { + $binding = is_numeric($binding) ? $binding : "'" . addslashes($binding) . "'"; + $query = preg_replace('/\\?/', $binding, $query, 1); + } - // \n 제거 - $query = str_replace(["\n", "\r"], ' ', $query)." (time: {$log['time']})"; - return trim($query); - })->toArray(); + // \n 제거 + $query = str_replace(["\n", "\r"], ' ', $query)." (time: {$log['time']})"; + return trim($query); + })->toArray(); } # ApiResponse Helper @@ -79,8 +81,7 @@ public static function validate( public static function response($type = '', $query = '', $key = ''): array { - $debug = (app()->environment('local')) ? true : false; - if ($debug) DB::enableQueryLog(); // 쿼리 추적 + $debug = app()->environment('local') && request()->is('api/*'); $result = match ($type) { 'get' => $key ? $query->get()->keyBy($key) : $query->get(), @@ -103,7 +104,12 @@ public static function response($type = '', $query = '', $key = ''): array } $response['data'] = $result; - $response['query'] = ($debug) ? self::debugQueryLog() : []; + $response['query'] = $debug ? self::debugQueryLog() : []; + + // 다음 요청에 로그가 섞이지 않도록 비워준다 (로컬에서만 의미있음) + if ($debug) { + DB::flushQueryLog(); + } return $response; } diff --git a/app/Http/Controllers/Api/V1/TenantController.php b/app/Http/Controllers/Api/V1/TenantController.php index 882889a..4410cea 100644 --- a/app/Http/Controllers/Api/V1/TenantController.php +++ b/app/Http/Controllers/Api/V1/TenantController.php @@ -4,70 +4,52 @@ use Illuminate\Http\Request; use App\Http\Controllers\Controller; -use App\Services\MemberService; +use App\Services\TenantService; use App\Helpers\ApiResponse; class TenantController extends Controller { public function index(Request $request) { - try { - $result = MemberService::getMembers($request); - return ApiResponse::success($result['data'], '회원목록 조회 성공',$result['query']); - } catch (\Throwable $e) { - return ApiResponse::error('회원목록 조회 실패', 500, [ - 'details' => $e->getMessage(), - ]); - } + return ApiResponse::handle(function () use ($request) { + return TenantService::getTenants($request->all()); + }, '테넌트목록 조회'); } - - /** - * 나의 테넌트 전환 - */ - public function switch() + public function show(Request $request) { - // + return ApiResponse::handle(function () use ($request) { + return TenantService::getTenant($request->all()); + }, '테넌트정보 조회'); } - /** - * Store a newly created resource in storage. - */ - - /** - * Show the form for editing the specified resource. - */ - public function edit(string $id) + public function update(Request $request) { - // + return ApiResponse::handle(function () use ($request) { + return TenantService::updateTenant($request->all()); + }, '테넌트정보 수정'); } - /** - * Update the specified resource in storage. - */ - public function update(Request $request, string $id) + public function store(Request $request) { - // + return ApiResponse::handle(function () use ($request) { + return TenantService::storeTenants($request->all()); + }, '테넌트 등록'); } - /** - * Remove the specified resource from storage. - */ - public function delAdmin($userNo, Request $request) + public function destroy(Request $request) { - return ApiResponse::handle(function () use ($userNo, $request) { - return MemberService::delAdmin($userNo); - }, '관리자 제외 성공', '관리자 제외 실패'); + return ApiResponse::handle(function () use ($request) { + return TenantService::destroyTenant($request->all()); + }, '테넌트 삭제(탈퇴)'); } - /** - * 관리자 설정 - */ - public function setAdmin($userNo, Request $request) + public function restore(Request $request) { - return ApiResponse::handle(function () use ($userNo, $request) { - return MemberService::setAdmin($userNo); - }, '관리자 설정 성공', '관리자 설정 실패'); + return ApiResponse::handle(function () use ($request) { + return TenantService::restoreTenant($request->all()); + }, '테넌트 복구'); } + } diff --git a/app/Http/Controllers/Api/V1/UserController.php b/app/Http/Controllers/Api/V1/UserController.php index fbd3f99..4e0107c 100644 --- a/app/Http/Controllers/Api/V1/UserController.php +++ b/app/Http/Controllers/Api/V1/UserController.php @@ -11,28 +11,11 @@ class UserController extends Controller { public function index(Request $request) { - try { - $result = MemberService::getMembers($request); - return ApiResponse::success($result['data'], '회원목록 조회 성공',$result['query']); - } catch (\Throwable $e) { - return ApiResponse::error('회원목록 조회 실패', 500, [ - 'details' => $e->getMessage(), - ]); - } + return ApiResponse::handle(function () use ($request) { + return MemberService::getMembers($request->all()); + }, '회원목록 조회'); } - - /** - * Show the form for creating a new resource. - */ - public function create() - { - // - } - - /** - * Store a newly created resource in storage. - */ public function store(Request $request) { return ApiResponse::handle(function () use ($request) { @@ -47,7 +30,6 @@ public function show($userNo) }, '회원 상세조회'); } - public function me(Request $request) { return ApiResponse::handle(function () use ($request) { @@ -55,7 +37,6 @@ public function me(Request $request) }, '나의 정보 조회'); } - public function meUpdate(Request $request) { return ApiResponse::handle(function () use ($request) { @@ -63,7 +44,6 @@ public function meUpdate(Request $request) }, '나의 정보 수정'); } - public function changePassword(Request $request) { return ApiResponse::handle(function () use ($request) { @@ -85,21 +65,5 @@ public function switchTenant(Request $request) return MemberService::switchMyTenant($tenant_id); }, '활성 테넌트 전환'); } - - /** - * Show the form for editing the specified resource. - */ - public function edit(string $id) - { - // - } - - /** - * Update the specified resource in storage. - */ - public function update(Request $request, string $id) - { - // - } } diff --git a/app/Models/Members/User.php b/app/Models/Members/User.php index 0e8ba97..f44efde 100644 --- a/app/Models/Members/User.php +++ b/app/Models/Members/User.php @@ -49,7 +49,8 @@ class User extends Authenticatable protected $hidden = [ 'password', 'remember_token', - 'two_factor_secret', 'two_factor_recovery_codes', 'two_factor_confirmed_at' + 'two_factor_secret', 'two_factor_recovery_codes', 'two_factor_confirmed_at', + 'deleted_at', ]; public function userTenants() diff --git a/app/Models/Tenants/Tenant.php b/app/Models/Tenants/Tenant.php index fd23fe0..ab4c5b4 100644 --- a/app/Models/Tenants/Tenant.php +++ b/app/Models/Tenants/Tenant.php @@ -16,18 +16,20 @@ class Tenant extends Model use SoftDeletes, ModelTrait; protected $fillable = [ - 'name', + 'company_name', 'code', 'email', 'phone', 'address', + 'business_num', + 'corp_reg_no', + 'ceo_name', + 'homepage', + 'fax', + 'logo', + 'admin_memo', + 'options', 'tenant_st_code', - 'plan_id', - 'subscription_id', - 'max_users', - 'trial_ends_at', - 'expires_at', - 'last_paid_at', 'billing_tp_code', ]; @@ -51,7 +53,6 @@ class Tenant extends Model ]; protected $hidden = [ - 'admin_memo', 'deleted_at', ]; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f3911ca..8ae7c57 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\ServiceProvider; +use Illuminate\Support\Facades\DB; class AppServiceProvider extends ServiceProvider { @@ -12,7 +13,13 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + // 개발환경 + API 라우트에서만 쿼리 로그 수집 + if (app()->environment('local')) { + // 콘솔/큐 등 non-HTTP 컨텍스트 보호 + if (function_exists('request') && request() && request()->is('api/*')) { + DB::enableQueryLog(); + } + } } /** diff --git a/app/Services/MemberService.php b/app/Services/MemberService.php index 33ca235..dbbac45 100644 --- a/app/Services/MemberService.php +++ b/app/Services/MemberService.php @@ -48,9 +48,6 @@ public static function getMember(int $userNo) */ public static function getMyInfo() { - $debug = (app()->environment('local')) ? true : false; - if ($debug) DB::enableQueryLog(); // 쿼리 추적 - $apiUser = app('api_user'); $user = User::with([ @@ -71,8 +68,6 @@ public static function getMyInfo() */ public static function getMyUpdate($request) { - $debug = app()->environment('local'); - if ($debug) DB::enableQueryLog(); $apiUser = app('api_user'); @@ -104,9 +99,6 @@ public static function getMyUpdate($request) */ public static function setMyPassword($request) { - $debug = app()->environment('local'); - if ($debug) DB::enableQueryLog(); - $apiUserId = app('api_user'); // 현재 로그인한 사용자 PK // 유효성 검사 (확인 비밀번호는 선택) @@ -152,8 +144,6 @@ public static function setMyPassword($request) */ public static function getMyTenants() { - $debug = app()->environment('local'); - if ($debug) DB::enableQueryLog(); $apiUser = app('api_user'); $data = UserTenant::join('tenants', 'user_tenants.tenant_id', '=', 'tenants.id') @@ -174,8 +164,6 @@ public static function getMyTenants() */ public static function switchMyTenant(int $tenantId) { - $debug = app()->environment('local'); - if ($debug) DB::enableQueryLog(); $apiUser = app('api_user'); diff --git a/app/Services/TenantService.php b/app/Services/TenantService.php new file mode 100644 index 0000000..3892fdf --- /dev/null +++ b/app/Services/TenantService.php @@ -0,0 +1,358 @@ += 0xAC00 && $code <= 0xD7A3) { // 한글 유니코드 범위 + $index = floor(($code - 0xAC00) / 588); + $initials .= self::INITIALS[$index]; + } + // 한글이 아닌 문자는 무시합니다. + } + + $koreanInitials = ['ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ','ㅅ','ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ']; + $englishInitials = ['G','KK','N','D','TT','R','M','B','BB','S','SS','O','J','JJ','CH','K','T','P','H']; + $initials = strtr($initials, array_combine($koreanInitials, $englishInitials)); + + $initials = str_replace(' ', '', $initials); + return strtoupper($initials); + } + + /** + * 10진수 숫자를 4자리 36진수 문자열로 변환합니다. + * + * @param int $number + * @return string + */ + private function toBase36(int $number): string + { + $result = ''; + $base = strlen($this->base36Chars); + + // **수정된 부분: 4자리 고정** + for ($i = 0; $i < 4; $i++) { + $remainder = $number % $base; + $result = $this->base36Chars[$remainder] . $result; + $number = floor($number / $base); + } + + return $result; + } + + /** + * 36진수 문자열을 10진수 숫자로 변환합니다. + * + * @param string $base36String + * @return int + */ + private function fromBase36(string $base36String): int + { + $number = 0; + $base = strlen($this->base36Chars); + $len = strlen($base36String); + + for ($i = 0; $i < $len; $i++) { + $char = $base36String[$i]; + $charValue = strpos($this->base36Chars, $char); + + // strpos가 false를 반환할 경우를 대비해 예외 처리 + if ($charValue === false) { + return 0; + } + $number += $charValue * ($base ** ($len - 1 - $i)); + } + + return $number; + } + + /** + * 한글 업체명 기반으로 순환형 테넌트 코드를 생성합니다. + * + * @param string $tenantName + * @return string + */ + public function generateTenantCode(string $tenantName): string + { + $cleanTenantName = str_replace(['주식회사', '(주)', '유한회사', '(유)', '유한책임회사', '(유)책', '합명회사', '(합)', '합자회사', '(합자)'], '', $tenantName); + + // 1. 전처리된 업체명에서 초성 약어 생성 + $initials = $this->getInitials($cleanTenantName); + + // 2. 모든 테넌트들의 코드 중 가장 큰 순번을 찾습니다. + $lastNumber = -1; + $existingTenants = Tenant::all(); + + foreach ($existingTenants as $tenant) { + // **수정된 부분: 코드 마지막 4자리를 순번으로 간주** + if (strlen($tenant->code) >= 4) { + $sequenceString = substr($tenant->code, -4); + + // 마지막 4자리가 36진수 문자열인지 확인 + if (strspn($sequenceString, $this->base36Chars) === 4) { + $sequence = $this->fromBase36($sequenceString); + if ($sequence > $lastNumber) { + $lastNumber = $sequence; + } + } + } + } + + // 3. 마지막 순번에 1을 더하고 36^4 (1,679,616)로 나눈 나머지로 순환하도록 유지합니다. + // **수정된 부분: 46656 -> 1679616 으로 변경** + $nextSequence = ($lastNumber + 1) % 1679616; + + // 4. 순번을 4자리 36진수 문자열로 포맷 + // **수정된 부분: toBase36 호출** + $formattedSequence = $this->toBase36($nextSequence); + + // 5. 초성 약어와 순번을 조합하여 최종 코드 생성 + $code = $initials . $formattedSequence; + + return $code; + } + + + /** + * 테넌트 목록 조회 (페이징) + * + * @param array $params [page, size, search 등] + */ + public static function getTenants(array $params = []) + { + + $pageNo = isset($params['page']) ? (int)$params['page'] : 1; + $pageSize = isset($params['size']) ? (int)$params['size'] : 10; + + $query = Tenant::query(); + + // (옵션) 간단 검색 예시: 회사명/코드 + if (!empty($params['q'])) { + $q = trim($params['q']); + $query->where(function ($qq) use ($q) { + $qq->where('company_name', 'like', "%{$q}%") + ->orWhere('code', 'like', "%{$q}%") + ->orWhere('email', 'like', "%{$q}%"); + }); + } + + // (옵션) 정렬 + if (!empty($params['sort']) && in_array($params['sort'], ['company_name','code','created_at','updated_at'])) { + $dir = (!empty($params['dir']) && in_array(strtolower($params['dir']), ['asc','desc'])) ? $params['dir'] : 'desc'; + $query->orderBy($params['sort'], $dir); + } else { + $query->orderByDesc('id'); + } + + $paginator = $query->paginate($pageSize, ['*'], 'page', $pageNo); + + return ApiResponse::response('result', $paginator); + } + + /** + * 단일 테넌트 조회 + * - params.tenant_id 가 있으면 해당 테넌트 + * - 없으면 현재 사용자 기본(is_default=1) 테넌트 + * + * @param array $params [tenant_id] + */ + public static function getTenant(array $params = []) + { + + $tenantId = $params['tenant_id'] ?? app('tenant_id'); + + if (!$tenantId) { + // 현재 사용자 기본 테넌트 조회 + $apiUser = app('api_user'); + $userTenant = UserTenant::where('user_id', $apiUser) + ->where('is_default', 1) + ->first(); + + if (!$userTenant) { + return ApiResponse::error('활성(기본) 테넌트를 찾을 수 없습니다.', 404); + } + $tenantId = $userTenant->tenant_id; + } + + // 필요한 컬럼만 선택 (원하면 조정) + $query = Tenant::query() + ->select('id','company_name','code','email','phone','address','business_num','corp_reg_no','ceo_name','homepage','fax','logo','admin_memo','options','created_at','updated_at') + ->where('id', $tenantId); + + return ApiResponse::response('first', $query); + } + + /** + * 테넌트 등록 + * + * @param array $params + */ + public static function storeTenants(array $params = []) + { + + $validator = Validator::make($params, [ + 'company_name' => 'required|string|max:255', + 'email' => 'nullable|email|max:100', + 'phone' => 'nullable|string|max:30', + 'address' => 'nullable|string|max:255', + 'business_num' => 'nullable|string|max:30', + 'corp_reg_no' => 'nullable|string|max:30', + 'ceo_name' => 'nullable|string|max:100', + 'homepage' => 'nullable|string|max:255', + 'fax' => 'nullable|string|max:50', + 'logo' => 'nullable|string|max:255', + 'admin_memo' => 'nullable|string', + 'options' => 'nullable', // JSON 문자열 저장이라면 'nullable|json' + ]); + + + if ($validator->fails()) { + return ApiResponse::error($validator->errors()->first(), 400); + } + + $payload = $validator->validated(); + + // TenantService 인스턴스를 가져옵니다. + $tenantService = app(TenantService::class); + + // 업체명 기반으로 고유한 코드를 생성합니다. + $code = $tenantService->generateTenantCode($payload['company_name']); + + // 생성된 코드를 페이로드에 추가합니다. + $payload['code'] = $code; + + $tenant = Tenant::create($payload); + + // 생성된 리소스를 그대로 반환 (목록 카드용 요약 원하면 컬럼 제한) + return ApiResponse::response('result', $tenant); + } + + /** + * 테넌트 수정 + * + * @param array $params + */ + public static function updateTenant(array $params = []) + { + + $validator = Validator::make($params, [ + 'company_name' => 'sometimes|string|max:255', + 'email' => 'sometimes|nullable|email|max:100', + 'phone' => 'sometimes|nullable|string|max:30', + 'address' => 'sometimes|nullable|string|max:255', + 'business_num' => 'sometimes|nullable|string|max:30', + 'corp_reg_no' => 'sometimes|nullable|string|max:30', + 'ceo_name' => 'sometimes|nullable|string|max:100', + 'homepage' => 'sometimes|nullable|string|max:255', + 'fax' => 'sometimes|nullable|string|max:50', + 'logo' => 'sometimes|nullable|string|max:255', + 'admin_memo' => 'sometimes|nullable|string', + 'options' => 'sometimes|nullable', // JSON 문자열이면 'sometimes|nullable|json' + ]); + + if ($validator->fails()) { + return ApiResponse::error($validator->errors()->first(), 400); + } + + $payload = $validator->validated(); + $tenantId = app('tenant_id') ?? null; + unset($payload['tenant_id']); + + if (empty($payload)) { + return ApiResponse::error('수정할 데이터가 없습니다.', 400); + } + + $tenant = Tenant::find($tenantId); + if (!$tenant) { + return ApiResponse::error('테넌트를 찾을 수 없습니다.', 404); + } + + $tenant->update($payload); + + return ApiResponse::response('result', $tenant->fresh()); + } + + /** + * 테넌트 삭제(탈퇴) — 소프트 삭제 가정 + * + * @param int $tenant_id + */ + public static function destroyTenant(array $params = []) + { + $tenantId = $params['tenant_id'] ?? app('tenant_id'); + if (!$tenantId) { + return ApiResponse::error('tenant_id가 필요합니다.', 400); + } + + $tenant = Tenant::find($tenantId); + if (!$tenant) { + return ApiResponse::error('테넌트를 찾을 수 없습니다.', 404); + } + + $tenant->delete(); // SoftDeletes 트레이트가 있으면 소프트 삭제 + + return ApiResponse::response('success'); + } + + /** + * 테넌트 복구 (소프트 삭제된 레코드 대상) + * + * @param array $params [tenant_id:int] + */ + public static function restoreTenant(array $params = []) + { + $tenantId = $params['tenant_id'] ?? app('tenant_id'); + + // 소프트 삭제 포함 조회 + $tenant = Tenant::withTrashed()->find($tenantId); + if (!$tenant) { + return ApiResponse::error('테넌트를 찾을 수 없습니다.', 404); + } + + if (is_null($tenant->deleted_at)) { + // 이미 활성 상태 + return ApiResponse::error('이미 활성화된 테넌트입니다.', 400); + } + + $tenant->restore(); + + // 복구 결과를 data에 담고 싶으면 fresh() 후 필요한 필드만 반환 + // return ApiResponse::response('result', $tenant->fresh()); + + return ApiResponse::response('success'); + } +} diff --git a/app/Swagger/v1/TenantApi.php b/app/Swagger/v1/TenantApi.php new file mode 100644 index 0000000..467de9f --- /dev/null +++ b/app/Swagger/v1/TenantApi.php @@ -0,0 +1,281 @@ +name('v1.users.me.tenants.switch'); // 활성 테넌트 전환 }); + + // Tenant API + Route::prefix('tenants')->group(function () { + Route::get('list', [TenantController::class, 'index'])->name('v1.tenant.index'); // 테넌트 목록 조회 + Route::get('/', [TenantController::class, 'show'])->name('v1.tenant.show'); // 테넌트 정보 조회 + Route::put('/', [TenantController::class, 'update'])->name('v1.tenant.update'); // 테넌트 정보 수정 + Route::post('/', [TenantController::class, 'store'])->name('v1.tenant.store'); // 테넌트 등록 + Route::delete('/', [TenantController::class, 'destroy'])->name('v1.tenant.destroy'); // 테넌트 삭제(탈퇴) + Route::put('/restore/{tenant_id}', [TenantController::class, 'restore'])->name('v1.tenant.restore'); // 테넌트 복구 + }); + // File API Route::prefix('file')->group(function () { Route::post('upload', [FileController::class, 'upload'])->name('v1.file.upload'); // 파일 업로드 (등록/수정)