fix : Base Service 추가 및 다국어 설정, DepartmentService 서비스를 static에서 인스턴스로 변경

- 모든 서비스를 인스턴스구조로 변경예정
  * 규모가 커질수록 → 인스턴스(=DI) 설계가 유리
  * 작고 단순한 유틸/순수 함수만 스태틱으로 유지
  * DI/모킹/테스트 쉬움 (서비스 교체·부분 테스트 가능)
  * 의존성 교체 쉬움 (Repo/캐시/로거…)
  * 상태·컨텍스트 주입 명확 (테넌트/유저/트랜잭션)
This commit is contained in:
2025-08-20 20:23:01 +09:00
parent 6932e4fbcc
commit 05745ee338
6 changed files with 120 additions and 56 deletions

View File

@@ -4,6 +4,7 @@
use Illuminate\Support\Facades\DB;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpKernel\Exception\HttpException;
class ApiResponse
{
@@ -159,6 +160,16 @@ public static function handle(
return self::success($data, $responseTitle.' 성공', $debug);
} catch (\Throwable $e) {
// HttpException 계열은 상태코드/메시지를 그대로 반영
if ($e instanceof HttpException) {
return self::error(
$e->getMessage() ?: ($responseTitle.' 실패'),
$e->getStatusCode(),
['details' => config('app.debug') ? $e->getTraceAsString() : null]
);
}
return self::error($responseTitle.' 실패', 500, [
'details' => $e->getMessage(),
]);

View File

@@ -26,11 +26,12 @@ public function store(Request $request)
}
// GET /v1/departments/{id}
public function show($id, Request $request)
public function show(int $id, Request $request, DepartmentService $service)
{
return ApiResponse::handle(function () use ($id, $request) {
return DepartmentService::show((int)$id, $request->all());
}, '부서 단건 조회');
return \App\Helpers\ApiResponse::handle(
fn() => $service->show($id, $request->all()),
'부서 단건 조회'
);
}
// PATCH /v1/departments/{id}

View File

@@ -10,12 +10,12 @@
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
class DepartmentService
class DepartmentService extends Service
{
/**
* 공통 검증 헬퍼: 실패 시 JsonResponse 반환
* 공통 검증 헬퍼: 실패 시 ['error'=>..., 'code'=>...] 형태로 반환
*/
private static function v(array $params, array $rules)
protected function v(array $params, array $rules)
{
$v = Validator::make($params, $rules);
if ($v->fails()) {
@@ -25,15 +25,16 @@ private static function v(array $params, array $rules)
}
/** 목록 */
public static function index(array $params)
public function index(array $params)
{
$p = self::v($params, [
$p = $this->v($params, [
'q' => 'nullable|string|max:100',
'is_active' => 'nullable|in:0,1',
'page' => 'nullable|integer|min:1',
'per_page' => 'nullable|integer|min:1|max:200',
]);
if ($p instanceof JsonResponse) return $p;
if (isset($p['error'])) return $p;
$q = Department::query();
@@ -52,14 +53,16 @@ public static function index(array $params)
$perPage = $p['per_page'] ?? 20;
$page = $p['page'] ?? null;
// 페이징 객체는 'result'로 반환
return $q->paginate($perPage, ['*'], 'page', $page);
}
/** 생성 */
public static function store(array $params)
public function store(array $params)
{
$p = self::v($params, [
// 테넌트 강제가 필요하면 아래 라인 사용:
// $this->tenantIdOrFail();
$p = $this->v($params, [
'code' => 'nullable|string|max:50',
'name' => 'required|string|max:100',
'description' => 'nullable|string|max:255',
@@ -68,6 +71,7 @@ public static function store(array $params)
'created_by' => 'nullable|integer|min:1',
]);
if ($p instanceof JsonResponse) return $p;
if (isset($p['error'])) return $p;
if (!empty($p['code'])) {
$exists = Department::query()->where('code', $p['code'])->exists();
@@ -88,23 +92,23 @@ public static function store(array $params)
}
/** 단건 */
public static function show(int $id, array $params)
public function show(int $id, array $params = [])
{
if (!$id) return ['error' => 'id가 필요합니다.', 'code' => 400];
$res = Department::query()->where('id', $id)->first();
if (empty($res['data'])) {
$res = Department::query()->find($id);
if (!$res) {
return ['error' => '부서를 찾을 수 없습니다.', 'code' => 404];
}
return $res;
}
/** 수정 */
public static function update(int $id, array $params)
public function update(int $id, array $params)
{
if (!$id) return ['error' => 'id가 필요합니다.', 'code' => 400];
$p = self::v($params, [
$p = $this->v($params, [
'code' => 'nullable|string|max:50',
'name' => 'nullable|string|max:100',
'description' => 'nullable|string|max:255',
@@ -113,6 +117,7 @@ public static function update(int $id, array $params)
'updated_by' => 'nullable|integer|min:1',
]);
if ($p instanceof JsonResponse) return $p;
if (isset($p['error'])) return $p;
$dept = Department::query()->find($id);
if (!$dept) return ['error' => '부서를 찾을 수 없습니다.', 'code' => 404];
@@ -138,14 +143,15 @@ public static function update(int $id, array $params)
}
/** 삭제(soft) */
public static function destroy(int $id, array $params)
public function destroy(int $id, array $params)
{
if (!$id) return ['error' => 'id가 필요합니다.', 'code' => 400];
$p = self::v($params, [
$p = $this->v($params, [
'deleted_by' => 'nullable|integer|min:1',
]);
if ($p instanceof JsonResponse) return $p;
if (isset($p['error'])) return $p;
$dept = Department::query()->find($id);
if (!$dept) return ['error' => '부서를 찾을 수 없습니다.', 'code' => 404];
@@ -160,13 +166,14 @@ public static function destroy(int $id, array $params)
}
/** 부서 사용자 목록 */
public static function listUsers(int $deptId, array $params)
public function listUsers(int $deptId, array $params)
{
$p = self::v($params, [
$p = $this->v($params, [
'page' => 'nullable|integer|min:1',
'per_page' => 'nullable|integer|min:1|max:200',
]);
if ($p instanceof JsonResponse) return $p;
if (isset($p['error'])) return $p;
$dept = Department::query()->find($deptId);
if (!$dept) return ['error' => '부서를 찾을 수 없습니다.', 'code' => 404];
@@ -181,14 +188,15 @@ public static function listUsers(int $deptId, array $params)
}
/** 사용자 배정 (단건) */
public static function attachUser(int $deptId, array $params)
public function attachUser(int $deptId, array $params)
{
$p = self::v($params, [
$p = $this->v($params, [
'user_id' => 'required|integer|min:1',
'is_primary' => 'nullable|in:0,1',
'joined_at' => 'nullable|date',
]);
if ($p instanceof JsonResponse) return $p;
if (isset($p['error'])) return $p;
$dept = Department::query()->find($deptId);
if (!$dept) return ['error' => '부서를 찾을 수 없습니다.', 'code' => 404];
@@ -227,14 +235,12 @@ public static function attachUser(int $deptId, array $params)
return ['department_id' => $dept->id, 'user_id' => $p['user_id']];
});
// 트랜잭션 내부에서 에러 응답이 나올 수 있으므로 분기
if ($result instanceof JsonResponse) return $result;
return $result;
}
/** 사용자 제거(soft) */
public static function detachUser(int $deptId, int $userId, array $params)
public function detachUser(int $deptId, int $userId, array $params)
{
$du = DepartmentUser::whereNull('deleted_at')
->where('department_id', $deptId)
@@ -252,12 +258,13 @@ public static function detachUser(int $deptId, int $userId, array $params)
}
/** 주부서 설정/해제 */
public static function setPrimary(int $deptId, int $userId, array $params)
public function setPrimary(int $deptId, int $userId, array $params)
{
$p = self::v($params, [
$p = $this->v($params, [
'is_primary' => 'required|in:0,1',
]);
if ($p instanceof JsonResponse) return $p;
if (isset($p['error'])) return $p;
$result = DB::transaction(function () use ($deptId, $userId, $p) {
$du = DepartmentUser::whereNull('deleted_at')
@@ -282,20 +289,20 @@ public static function setPrimary(int $deptId, int $userId, array $params)
});
if ($result instanceof JsonResponse) return $result;
return $result;
}
/** 부서 권한 목록 */
public static function listPermissions(int $deptId, array $params)
public function listPermissions(int $deptId, array $params)
{
$p = self::v($params, [
$p = $this->v($params, [
'menu_id' => 'nullable|integer|min:1',
'is_allowed' => 'nullable|in:0,1',
'page' => 'nullable|integer|min:1',
'per_page' => 'nullable|integer|min:1|max:200',
]);
if ($p instanceof JsonResponse) return $p;
if (isset($p['error'])) return $p;
$dept = Department::query()->find($deptId);
if (!$dept) return ['error' => '부서를 찾을 수 없습니다.', 'code' => 404];
@@ -316,14 +323,15 @@ public static function listPermissions(int $deptId, array $params)
}
/** 권한 부여/차단 upsert */
public static function upsertPermission(int $deptId, array $params)
public function upsertPermission(int $deptId, array $params)
{
$p = self::v($params, [
$p = $this->v($params, [
'permission_id' => 'required|integer|min:1',
'menu_id' => 'nullable|integer|min:1',
'is_allowed' => 'nullable|in:0,1',
]);
if ($p instanceof JsonResponse) return $p;
if (isset($p['error'])) return $p;
$dept = Department::query()->find($deptId);
if (!$dept) return ['error' => '부서를 찾을 수 없습니다.', 'code' => 404];
@@ -340,16 +348,17 @@ public static function upsertPermission(int $deptId, array $params)
$model->save();
// 변경 후 목록 반환
return self::listPermissions($deptId, []);
return $this->listPermissions($deptId, []);
}
/** 권한 제거 (menu_id 없으면 전체 제거) */
public static function revokePermission(int $deptId, int $permissionId, array $params)
public function revokePermission(int $deptId, int $permissionId, array $params)
{
$p = self::v($params, [
$p = $this->v($params, [
'menu_id' => 'nullable|integer|min:1',
]);
if ($p instanceof JsonResponse) return $p;
if (isset($p['error'])) return $p;
$q = DepartmentPermission::whereNull('deleted_at')
->where('department_id', $deptId)

37
app/Services/Service.php Normal file
View File

@@ -0,0 +1,37 @@
<?php
namespace App\Services;
use Illuminate\Auth\AuthenticationException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
abstract class Service
{
/** 활성 테넌트 ID(없으면 null) */
protected function tenantIdOrNull(): ?int
{
$id = app('tenant_id');
return $id ? (int) $id : null;
}
/** 활성 테넌트 ID(없으면 400 Bad Request + i18n 메시지로 예외) */
protected function tenantId(): int
{
$id = $this->tenantIdOrNull();
if (!$id) {
// ko/error.php 의 'tenant_id' 키 사용
throw new BadRequestHttpException(__('error.tenant_id'));
}
return $id;
}
/** (선택) API 사용자 ID 필요할 때 401로 던지고 싶다면 */
protected function apiUserIdOrFail(): int
{
$uid = app('api_user');
if (!$uid) {
// Handler에서 AuthenticationException은 401로 처리 중
throw new AuthenticationException(__('auth.unauthenticated'));
}
return (int) $uid;
}
}

7
lang/ko/error.php Normal file
View File

@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
return [
'tenant_id' => '활성 테넌트 없음',
];

View File

@@ -182,34 +182,33 @@
// 테넌트 필드 설정
Route::prefix('fields')->group(function () {
Route::get ('', [TenantFieldSettingController::class, 'index']); // 목록(효과값)
Route::put ('/bulk', [TenantFieldSettingController::class, 'bulkUpsert']); // 대량 저장
Route::patch ('/{key}', [TenantFieldSettingController::class, 'updateOne']); // 단건 수정
Route::get ('', [TenantFieldSettingController::class, 'index'])->name('v1.fields.index'); // 필드 설정 목록(전역+테넌트 병합 효과값)
Route::put ('/bulk', [TenantFieldSettingController::class, 'bulkUpsert'])->name('v1.fields.bulk'); // 필드 설정 대량 저장(트랜잭션 처리)
Route::patch ('/{key}', [TenantFieldSettingController::class, 'updateOne'])->name('v1.fields.update'); // 필드 설정 단건 수정/업데이트
});
// 옵션 그룹/값
Route::prefix('opt-groups')->group(function () {
Route::get ('', [TenantOptionGroupController::class, 'index']);
Route::post ('', [TenantOptionGroupController::class, 'store']);
Route::get ('/{id}', [TenantOptionGroupController::class, 'show']);
Route::patch ('/{id}', [TenantOptionGroupController::class, 'update']);
Route::delete('/{id}', [TenantOptionGroupController::class, 'destroy']);
Route::get ('/{gid}/values', [TenantOptionValueController::class, 'index']);
Route::post ('/{gid}/values', [TenantOptionValueController::class, 'store']);
Route::get ('/{gid}/values/{id}', [TenantOptionValueController::class, 'show']);
Route::patch ('/{gid}/values/{id}', [TenantOptionValueController::class, 'update']);
Route::delete ('/{gid}/values/{id}', [TenantOptionValueController::class, 'destroy']);
Route::patch ('/{gid}/values/reorder', [TenantOptionValueController::class, 'reorder']); // [{id,sort_order}]
Route::get ('', [TenantOptionGroupController::class, 'index'])->name('v1.opt-groups.index'); // 옵션 그룹 목록
Route::post ('', [TenantOptionGroupController::class, 'store'])->name('v1.opt-groups.store'); // 옵션 그룹 생성
Route::get ('/{id}', [TenantOptionGroupController::class, 'show'])->name('v1.opt-groups.show'); // 옵션 그룹 단건 조회
Route::patch ('/{id}', [TenantOptionGroupController::class, 'update'])->name('v1.opt-groups.update'); // 옵션 그룹 수정
Route::delete('/{id}', [TenantOptionGroupController::class, 'destroy'])->name('v1.opt-groups.destroy'); // 옵션 그룹 삭제
Route::get ('/{gid}/values', [TenantOptionValueController::class, 'index'])->name('v1.opt-groups.values.index'); // 옵션 값 목록
Route::post ('/{gid}/values', [TenantOptionValueController::class, 'store'])->name('v1.opt-groups.values.store'); // 옵션 값 생성
Route::get ('/{gid}/values/{id}', [TenantOptionValueController::class, 'show'])->name('v1.opt-groups.values.show'); // 옵션 값 단건 조회
Route::patch ('/{gid}/values/{id}', [TenantOptionValueController::class, 'update'])->name('v1.opt-groups.values.update'); // 옵션 값 수정
Route::delete('/{gid}/values/{id}', [TenantOptionValueController::class, 'destroy'])->name('v1.opt-groups.values.destroy'); // 옵션 값 삭제
Route::patch ('/{gid}/values/reorder', [TenantOptionValueController::class, 'reorder'])->name('v1.opt-groups.values.reorder'); // 옵션 값 정렬순서 재배치
});
// 회원 프로필(테넌트 기준)
Route::prefix('profiles')->group(function () {
Route::get ('', [TenantUserProfileController::class, 'index']); // 목록
Route::get ('/{userId}', [TenantUserProfileController::class, 'show']); // 단건
Route::patch ('/{userId}', [TenantUserProfileController::class, 'update']); // 수정(관리자)
Route::get ('/me', [TenantUserProfileController::class, 'me']); // 내 프로필
Route::patch ('/me', [TenantUserProfileController::class, 'updateMe']); // 내 정보 수정
Route::get ('', [TenantUserProfileController::class, 'index'])->name('v1.profiles.index'); // 프로필 목록(테넌트 기준)
Route::get ('/{userId}', [TenantUserProfileController::class, 'show'])->name('v1.profiles.show'); // 특정 사용자 프로필 조회
Route::patch ('/{userId}', [TenantUserProfileController::class, 'update'])->name('v1.profiles.update'); // 특정 사용자 프로필 수정(관리자)
Route::get ('/me', [TenantUserProfileController::class, 'me'])->name('v1.profiles.me'); // 내 프로필 조회
Route::patch ('/me', [TenantUserProfileController::class, 'updateMe'])->name('v1.profiles.me.update'); // 내 프로필 수정
});
});