fix : 권한관리 기능 추가 (각 기능 확인 필요)

- 메뉴관리
- 역할관리
- 부서관리
- 메뉴, 부서, 역할, 유저 - 권한 연동
This commit is contained in:
2025-08-16 03:25:06 +09:00
parent 68d97c166f
commit 73d06e03b0
34 changed files with 3656 additions and 84 deletions

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use App\Models\Commons\Menu;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\PermissionRegistrar;
class SeedMenuPermissions extends Command
{
protected $signature = 'sam:seed-menu-perms {--tenant=} {--guard=api}';
protected $description = 'Create missing permissions menu:{id}.{action} for all menus';
public function handle(): int
{
$tenant = $this->option('tenant') ? (int)$this->option('tenant') : null;
$guard = $this->option('guard') ?: 'api';
$actions = config('authz.menu_actions', ['view','create','update','delete','approve']);
$menus = Menu::query()
->when($tenant !== null, fn ($q) => $q->where('tenant_id', $tenant))
->get();
$count = 0;
foreach ($menus as $m) {
app(PermissionRegistrar::class)->setPermissionsTeamId((int)$m->tenant_id);
foreach ($actions as $act) {
Permission::firstOrCreate([
'tenant_id' => (int)$m->tenant_id,
'guard_name' => $guard,
'name' => "menu:{$m->id}.{$act}",
]);
$count++;
}
}
app(PermissionRegistrar::class)->forgetCachedPermissions();
$this->info("Ensured {$count} permissions.");
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace App\Http\Controllers\Api\V1;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Services\DepartmentService;
use App\Helpers\ApiResponse;
class DepartmentController extends Controller
{
// GET /v1/departments
public function index(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return DepartmentService::index($request->all());
}, '부서 목록 조회');
}
// POST /v1/departments
public function store(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return DepartmentService::store($request->all());
}, '부서 생성');
}
// GET /v1/departments/{id}
public function show($id, Request $request)
{
return ApiResponse::handle(function () use ($id, $request) {
return DepartmentService::show((int)$id, $request->all());
}, '부서 단건 조회');
}
// PATCH /v1/departments/{id}
public function update($id, Request $request)
{
return ApiResponse::handle(function () use ($id, $request) {
return DepartmentService::update((int)$id, $request->all());
}, '부서 수정');
}
// DELETE /v1/departments/{id}
public function destroy($id, Request $request)
{
return ApiResponse::handle(function () use ($id, $request) {
return DepartmentService::destroy((int)$id, $request->all());
}, '부서 삭제');
}
// GET /v1/departments/{id}/users
public function listUsers($id, Request $request)
{
return ApiResponse::handle(function () use ($id, $request) {
return DepartmentService::listUsers((int)$id, $request->all());
}, '부서 사용자 목록');
}
// POST /v1/departments/{id}/users
public function attachUser($id, Request $request)
{
return ApiResponse::handle(function () use ($id, $request) {
return DepartmentService::attachUser((int)$id, $request->all());
}, '부서 사용자 배정');
}
// DELETE /v1/departments/{id}/users/{user}
public function detachUser($id, $user, Request $request)
{
return ApiResponse::handle(function () use ($id, $user, $request) {
return DepartmentService::detachUser((int)$id, (int)$user, $request->all());
}, '부서 사용자 제거');
}
// PATCH /v1/departments/{id}/users/{user}/primary
public function setPrimary($id, $user, Request $request)
{
return ApiResponse::handle(function () use ($id, $user, $request) {
return DepartmentService::setPrimary((int)$id, (int)$user, $request->all());
}, '주 부서 설정/해제');
}
// GET /v1/departments/{id}/permissions
public function listPermissions($id, Request $request)
{
return ApiResponse::handle(function () use ($id, $request) {
return DepartmentService::listPermissions((int)$id, $request->all());
}, '부서 권한 목록');
}
// POST /v1/departments/{id}/permissions
public function upsertPermission($id, Request $request)
{
return ApiResponse::handle(function () use ($id, $request) {
return DepartmentService::upsertPermission((int)$id, $request->all());
}, '부서 권한 부여/차단');
}
// DELETE /v1/departments/{id}/permissions/{permission}
public function revokePermission($id, $permission, Request $request)
{
return ApiResponse::handle(function () use ($id, $permission, $request) {
return DepartmentService::revokePermission((int)$id, (int)$permission, $request->all());
}, '부서 권한 제거');
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Http\Controllers\Api\V1;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Services\MenuService;
use App\Helpers\ApiResponse;
class MenuController extends Controller
{
public function index(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return MenuService::index($request->all());
}, '메뉴 목록 조회');
}
public function show($id)
{
return ApiResponse::handle(function () use ($id) {
return MenuService::show(['id' => (int)$id]);
}, '메뉴 단건 조회');
}
public function store(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return MenuService::store($request->all());
}, '메뉴 등록');
}
public function update(Request $request, $id)
{
return ApiResponse::handle(function () use ($request, $id) {
$params = $request->all(); $params['id'] = (int)$id;
return MenuService::update($params);
}, '메뉴 수정');
}
public function destroy($id)
{
return ApiResponse::handle(function () use ($id) {
return MenuService::destroy(['id' => (int)$id]);
}, '메뉴 삭제');
}
public function reorder(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return MenuService::reorder($request->all());
}, '메뉴 정렬 변경');
}
public function toggle(Request $request, $id)
{
return ApiResponse::handle(function () use ($request, $id) {
$params = $request->all(); $params['id'] = (int)$id;
return MenuService::toggle($params);
}, '메뉴 상태 토글');
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers\Api\V1;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Services\Authz\RoleService;
use App\Helpers\ApiResponse;
class RoleController extends Controller
{
public function index(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return RoleService::index($request->all());
}, '역할 목록 조회');
}
public function store(Request $request)
{
return ApiResponse::handle(function () use ($request) {
return RoleService::store($request->all());
}, '역할 생성');
}
public function show($id)
{
return ApiResponse::handle(function () use ($id) {
return RoleService::show((int)$id);
}, '역할 상세 조회');
}
public function update(Request $request, $id)
{
return ApiResponse::handle(function () use ($request, $id) {
return RoleService::update((int)$id, $request->all());
}, '역할 수정');
}
public function destroy($id)
{
return ApiResponse::handle(function () use ($id) {
return RoleService::destroy((int)$id);
}, '역할 삭제');
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Api\V1;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Services\Authz\RolePermissionService;
use App\Helpers\ApiResponse;
class RolePermissionController extends Controller
{
public function index($id, Request $request)
{
return ApiResponse::handle(function () use ($id) {
return RolePermissionService::list((int)$id);
}, '역할 퍼미션 목록 조회');
}
public function grant($id, Request $request)
{
return ApiResponse::handle(function () use ($id, $request) {
return RolePermissionService::grant((int)$id, $request->all());
}, '역할 퍼미션 부여');
}
public function revoke($id, Request $request)
{
return ApiResponse::handle(function () use ($id, $request) {
return RolePermissionService::revoke((int)$id, $request->all());
}, '역할 퍼미션 회수');
}
public function sync($id, Request $request)
{
return ApiResponse::handle(function () use ($id, $request) {
return RolePermissionService::sync((int)$id, $request->all());
}, '역할 퍼미션 동기화');
}
}

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers\Api\V1;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Services\Authz\UserRoleService;
use App\Helpers\ApiResponse;
class UserRoleController extends Controller
{
public function index($id)
{
return ApiResponse::handle(function () use ($id) {
return UserRoleService::list((int)$id);
}, '사용자의 역할 목록 조회');
}
public function grant($id, Request $request)
{
return ApiResponse::handle(function () use ($id, $request) {
return UserRoleService::grant((int)$id, $request->all());
}, '사용자에게 역할 부여');
}
public function revoke($id, Request $request)
{
return ApiResponse::handle(function () use ($id, $request) {
return UserRoleService::revoke((int)$id, $request->all());
}, '사용자의 역할 회수');
}
public function sync($id, Request $request)
{
return ApiResponse::handle(function () use ($id, $request) {
return UserRoleService::sync((int)$id, $request->all());
}, '사용자의 역할 동기화');
}
}

View File

@@ -4,22 +4,54 @@
use Closure;
use Illuminate\Http\Request;
use App\Services\AdminPermissionService;
use Spatie\Permission\PermissionRegistrar;
use App\Services\Authz\AccessService;
use App\Models\Members\User as UserModel;
class CheckPermission
{
public function handle(Request $request, Closure $next, string $permissionCode)
public function handle(Request $request, Closure $next)
{
$userToken = $request->input('user_token');
if (!$userToken) {
$userToken = $request->header('X-API-KEY');
if (!$userToken) {
return response()->json(['error' => '토큰이 없습니다.'], 401);
}
// Perm 키 가져오기 (attributes 우선, 없으면 route defaults)
$perm = $request->attributes->get('perm')
?? ($request->route()?->defaults['perm'] ?? null);
// 다중 ANY-매칭
$permsAny = $request->attributes->get('perms_any');
// perm 미지정 라우트 처리 정책
// TODO :: 초기 도입 단계: 통과. (정책에 따라 403으로 바꿔도 됨)
if (!$perm && !$permsAny) {
return $next($request);
// return response()->json(['success'=>false,'message'=>'권한 설정 누락','data'=>null], 403);
}
if (!AdminPermissionService::hasPermission($userToken, $permissionCode)) {
return response()->json(['error' => '권한이 없습니다.'], 403);
// 컨텍스트 확보
$tenantId = (int) app('tenant_id');
$userId = (int) app('api_user');
if (!$tenantId || !$userId) {
return response()->json(['success'=>false,'message'=>'인증 또는 테넌트 정보가 없습니다.','data'=>null], 401);
}
$user = UserModel::find($userId);
if (!$user) {
return response()->json(['success'=>false,'message'=>'사용자 없음','data'=>null], 401);
}
// Spatie Teams 컨텍스트 고정
app(PermissionRegistrar::class)->setPermissionsTeamId($tenantId);
// 최종 판정(DENY 최우선/부서 ALLOW 포함)
if ($permsAny) {
foreach ($permsAny as $p) {
if (AccessService::allows($user, $p, $tenantId, 'api')) {
return $next($request);
}
}
return response()->json(['success'=>false,'message'=>'권한이 없습니다.','data'=>null], 403);
}
if (! AccessService::allows($user, $perm, $tenantId, 'api')) {
return response()->json(['success'=>false,'message'=>'권한이 없습니다.','data'=>null], 403);
}
return $next($request);

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class PermMapper
{
/** HTTP 메서드 → 액션 버킷 */
private array $actionMap = [
'GET' => 'view',
'HEAD' => 'view',
'POST' => 'create',
'PUT' => 'update',
'PATCH' => 'update',
'DELETE' => 'delete',
];
public function handle(Request $request, Closure $next)
{
$route = $request->route();
if (!$route) {
return $next($request);
}
// 1) 이미 perm이 attributes에 있으면 존중
if ($request->attributes->get('perm')) {
return $next($request);
}
// 2) 라우트 defaults로 강제 지정된 perm/permission 우선
$forced = $route->defaults['perm'] ?? $route->defaults['permission'] ?? null;
if ($forced) {
$request->attributes->set('perm', $forced);
return $next($request);
}
// 3) menu_id 가 지정된 경우: HTTP 메서드 → 액션으로 perm 생성
$menuId = $route->defaults['menu_id'] ?? null;
if ($menuId) {
$action = $this->actionMap[$request->method()] ?? 'view';
$request->attributes->set('perm', "menu:{$menuId}.{$action}");
}
// 4) menu_id/perm 둘 다 없으면 설정 안 함(체크 미들웨어 정책에 따름)
return $next($request);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Models\Commons;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
class Department extends Model
{
use SoftDeletes, BelongsToTenant, ModelTrait;
protected $table = 'departments';
protected $fillable = [
'tenant_id','code','name','description','is_active','sort_order',
'created_by','updated_by','deleted_by',
];
protected $casts = [
'tenant_id' => 'integer',
'is_active' => 'integer',
'sort_order'=> 'integer',
];
/** Relations */
public function departmentUsers()
{
return $this->hasMany(DepartmentUser::class, 'department_id');
}
public function users()
{
// User 네임스페이스가 다르면 여기만 맞춰줘.
return $this->belongsToMany(\App\Models\User::class, 'department_user', 'department_id', 'user_id')
->withPivot(['tenant_id','is_primary','joined_at','left_at','created_at','updated_at','deleted_at'])
->withTimestamps();
}
public function departmentPermissions()
{
return $this->hasMany(DepartmentPermission::class, 'department_id');
}
public function permissions()
{
return $this->belongsToMany(\Spatie\Permission\Models\Permission::class, 'department_permissions', 'department_id', 'permission_id')
->withPivot(['tenant_id','menu_id','is_allowed','created_at','updated_at','deleted_at'])
->withTimestamps();
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Models\Commons;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
class DepartmentPermission extends Model
{
use SoftDeletes, BelongsToTenant, ModelTrait;
protected $table = 'department_permissions';
protected $fillable = [
'tenant_id','department_id','permission_id','menu_id','is_allowed',
];
protected $casts = [
'tenant_id' => 'integer',
'department_id' => 'integer',
'permission_id' => 'integer',
'menu_id' => 'integer',
'is_allowed' => 'integer',
];
public function department()
{
return $this->belongsTo(Department::class, 'department_id');
}
public function permission()
{
return $this->belongsTo(\Spatie\Permission\Models\Permission::class, 'permission_id');
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Models\Commons;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
class DepartmentUser extends Model
{
use SoftDeletes, BelongsToTenant, ModelTrait;
protected $table = 'department_user';
protected $fillable = [
'tenant_id','department_id','user_id','is_primary','joined_at','left_at',
];
protected $casts = [
'tenant_id' => 'integer',
'department_id' => 'integer',
'user_id' => 'integer',
'is_primary' => 'integer',
'joined_at' => 'datetime',
'left_at' => 'datetime',
];
public function department()
{
return $this->belongsTo(Department::class, 'department_id');
}
public function user()
{
// User 네임스페이스가 다르면 여기만 맞춰줘.
return $this->belongsTo(\App\Models\User::class, 'user_id');
}
}

View File

@@ -3,11 +3,14 @@
namespace App\Models\Commons;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Menu extends Model
{
use SoftDeletes;
protected $fillable = [
'tenant_id', 'parent_id', 'name', 'url', 'is_active', 'sort_order',
'tenant_id', 'parent_id', 'slug', 'name', 'url', 'is_active', 'sort_order',
'hidden', 'is_external', 'external_url', 'icon'
];

View File

@@ -2,7 +2,6 @@
namespace App\Models\Members;
use App\Models\Commons\Role;
use App\Models\Commons\File;
use App\Models\Tenants\Tenant;
use App\Traits\ModelTrait;
@@ -13,10 +12,15 @@
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Spatie\Permission\Traits\HasRoles;
use Spatie\Permission\Models\Role as SpatieRole;
use App\Models\Commons\Role as CommonRole;
class User extends Authenticatable
{
use HasApiTokens, Notifiable, SoftDeletes, ModelTrait, HasRoles;
protected $guard_name = 'api'; // ★ 중요: 권한/역할 가드 통일
protected $fillable = [
'user_id',
'name',
@@ -56,9 +60,10 @@ public function userRoles()
return $this->hasMany(UserRole::class);
}
public function roles()
public function orgRoles()
{
return $this->belongsToMany(Role::class, 'user_roles')->withPivot('tenant_id', 'assigned_at');
return $this->belongsToMany(CommonRole::class, 'user_roles')
->withPivot(['tenant_id', 'assigned_at']);
}
public function userTenantById($tenantId)

View File

@@ -0,0 +1,98 @@
<?php
namespace App\Observers;
use App\Models\Commons\Menu; // ← 실제 경로 확인
use Spatie\Permission\Models\Permission;
use Spatie\Permission\PermissionRegistrar;
class MenuObserver
{
protected function actions(): array
{
// config/authz.php 에서 오버라이드 가능 (없으면 기본값)
return config('authz.menu_actions', ['view','create','update','delete','approve']);
}
protected string $guard = 'api';
public function created(Menu $menu): void
{
if (!$this->shouldHandle($menu)) return;
$this->setTeam((int)$menu->tenant_id);
$this->ensurePermissions($menu);
$this->forgetCache();
}
public function updated(Menu $menu): void
{
// 메뉴 ID 기반: 권한 이름 변경 불필요
// 필요 시, 기본 롤 자동 부여/회수 같은 정책을 이곳에…
}
public function deleted(Menu $menu): void
{
if (!$this->shouldHandle($menu)) return;
$this->setTeam((int)$menu->tenant_id);
$this->removePermissions($menu);
$this->forgetCache();
}
public function restored(Menu $menu): void
{
if (!$this->shouldHandle($menu)) return;
$this->setTeam((int)$menu->tenant_id);
$this->ensurePermissions($menu);
$this->forgetCache();
}
public function forceDeleted(Menu $menu): void
{
if (!$this->shouldHandle($menu)) return;
$this->setTeam((int)$menu->tenant_id);
$this->removePermissions($menu);
$this->forgetCache();
}
/** teams 사용 시 tenant_id 없는 공용 메뉴는 스킵하는 게 안전 */
protected function shouldHandle(Menu $menu): bool
{
return !is_null($menu->tenant_id);
}
protected function setTeam(int $tenantId): void
{
app(PermissionRegistrar::class)->setPermissionsTeamId($tenantId);
}
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}",
]);
}
}
protected function removePermissions(Menu $menu): void
{
Permission::where('tenant_id', (int)$menu->tenant_id)
->where('guard_name', $this->guard)
->where(function ($q) use ($menu) {
foreach ($this->actions() as $act) {
$q->orWhere('name', "menu:{$menu->id}.{$act}");
}
})->delete();
}
protected function forgetCache(): void
{
app(PermissionRegistrar::class)->forgetCachedPermissions();
}
}

View File

@@ -5,6 +5,8 @@
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\DB;
use App\Models\Commons\Menu;
use App\Observers\MenuObserver;
class AppServiceProvider extends ServiceProvider
{
@@ -27,7 +29,7 @@ public function register(): void
*/
public function boot(): void
{
//
// DB::enableQueryLog();
Builder::macro('debug', function($debug = null) {
if (is_null($debug) && app()->environment('local')) {
$debug = true;
@@ -37,5 +39,8 @@ public function boot(): void
}
return $this;
});
// 메뉴 생성/수정/삭제 ↔ 권한 자동 동기화
Menu::observe(MenuObserver::class);
}
}

View File

@@ -0,0 +1,111 @@
<?php
namespace App\Services\Authz;
use App\Models\Members\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Cache;
use Carbon\Carbon;
final class AccessService
{
public static function allows(User $user, string $permission, int $tenantId, ?string $guardName = null): bool
{
$guard = $guardName ?? config('auth.defaults.guard', 'api'); // ★ 기본 가드
$ver = Cache::get("access:version:$tenantId", 1); // ★ 버전 토큰
$key = "access:$tenantId:{$user->id}:$guard:$permission:v{$ver}"; // ★ 키 강화
return Cache::remember($key, now()->addSeconds(20), function () use ($user, $permission, $tenantId, $guard) {
// 1) 개인 DENY
if (self::hasUserOverride($user->id, $permission, $tenantId, false, $guard)) {
return false;
}
// 2) Spatie can (팀 컨텍스트는 미들웨어에서 이미 세팅됨)
if ($user->can($permission)) {
return true;
}
// 3) 부서 ALLOW
if (self::departmentAllows($user->id, $permission, $tenantId, $guard)) {
return true;
}
// 4) 개인 ALLOW
if (self::hasUserOverride($user->id, $permission, $tenantId, true, $guard)) {
return true;
}
return false;
});
}
protected static function hasUserOverride(
int $userId,
string $permissionName,
int $tenantId,
bool $allow,
?string $guardName = null
): bool {
$now = now();
$guard = $guardName ?? config('auth.defaults.guard', 'api'); // ★
$q = DB::table('user_permission_overrides as uo')
->join('permissions as p', 'p.id', '=', 'uo.permission_id')
->whereNull('uo.deleted_at')
->where('uo.user_id', $userId)
->where('uo.tenant_id', $tenantId)
->where('p.name', $permissionName)
->where('p.tenant_id', $tenantId) // ★ 테넌트 일치
->where('p.guard_name', $guard) // ★ 가드 일치
->where(function ($w) use ($now) {
$w->whereNull('uo.effective_from')->orWhere('uo.effective_from', '<=', $now);
})
->where(function ($w) use ($now) {
$w->whereNull('uo.effective_to')->orWhere('uo.effective_to', '>=', $now);
})
->where('uo.is_allowed', $allow ? 1 : 0);
return $q->exists();
}
protected static function departmentAllows(
int $userId,
string $permissionName,
int $tenantId,
?string $guardName = null
): bool {
$guard = $guardName ?? config('auth.defaults.guard', 'api'); // ★
$q = DB::table('department_user as du')
->join('department_permissions as dp', function ($j) {
$j->on('dp.department_id', '=', 'du.department_id')
->whereNull('dp.deleted_at')
->where('dp.is_allowed', 1);
})
->join('permissions as p', 'p.id', '=', 'dp.permission_id')
->whereNull('du.deleted_at')
->where('du.user_id', $userId)
->where('du.tenant_id', $tenantId)
->where('dp.tenant_id', $tenantId)
->where('p.tenant_id', $tenantId) // ★ 테넌트 일치
->where('p.guard_name', $guard) // ★ 가드 일치
->where('p.name', $permissionName);
return $q->exists();
}
public static function allowsOrAbort(User $user, string $permission, int $tenantId, ?string $guardName = null): void
{
if (! self::allows($user, $permission, $tenantId, $guardName)) {
abort(403, 'Forbidden');
}
}
// (선택) 권한 변경 시 호출해 캐시 무효화
public static function bumpVersion(int $tenantId): void
{
Cache::increment("access:version:$tenantId");
}
}

View File

@@ -0,0 +1,214 @@
<?php
namespace App\Services\Authz;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Spatie\Permission\Models\Role;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\PermissionRegistrar;
use App\Helpers\ApiResponse;
class RolePermissionService
{
protected static string $guard = 'api';
/** 현재 테넌트 컨텍스트로 팀 고정 */
protected static function setTeam(int $tenantId): void
{
app(PermissionRegistrar::class)->setPermissionsTeamId($tenantId);
}
/** 역할 로드 (테넌트/가드 검증) */
protected static function loadRoleOrError(int $roleId, int $tenantId): ?Role
{
$role = Role::query()
->where('tenant_id', $tenantId)
->where('guard_name', self::$guard)
->find($roleId);
return $role;
}
/** A) permission_names[] → 그대로 사용
* B) menus[] + actions[] → "menu:{id}.{act}" 배열로 변환(필요 시 Permission 생성)
*/
protected static function resolvePermissionNames(int $tenantId, array $params): array
{
$names = [];
if (!empty($params['permission_names']) && is_array($params['permission_names'])) {
// 문자열 배열만 추림
foreach ($params['permission_names'] as $n) {
if (is_string($n) && $n !== '') $names[] = trim($n);
}
}
if (!empty($params['menus']) && is_array($params['menus']) &&
!empty($params['actions']) && is_array($params['actions'])) {
$allowed = config('authz.menu_actions', ['view','create','update','delete','approve']);
$acts = array_values(array_unique(array_filter(array_map('trim', $params['actions']))));
$acts = array_intersect($acts, $allowed);
$menuIds = array_values(array_unique(array_map('intval', $params['menus'])));
foreach ($menuIds as $mid) {
foreach ($acts as $act) {
$names[] = "menu:{$mid}.{$act}";
}
}
}
// 빈/중복 제거
$names = array_values(array_unique(array_filter($names)));
// 존재하지 않는 Permission은 생성(tenant+guard 포함)
foreach ($names as $permName) {
Permission::firstOrCreate([
'tenant_id' => $tenantId,
'guard_name' => self::$guard,
'name' => $permName,
]);
}
return $names;
}
/** 역할의 퍼미션 목록 */
public static function list(int $roleId)
{
$tenantId = (int) app('tenant_id');
$role = self::loadRoleOrError($roleId, $tenantId);
if (!$role) {
return ApiResponse::error('역할을 찾을 수 없습니다.', 404);
}
self::setTeam($tenantId);
$perms = $role->permissions()
->where('tenant_id', $tenantId)
->where('guard_name', self::$guard)
->orderBy('name')
->get(['id','tenant_id','name','guard_name','created_at','updated_at']);
return ApiResponse::response('result', $perms);
}
/** 부여 (중복 무시) */
public static function grant(int $roleId, array $params = [])
{
$tenantId = (int) app('tenant_id');
$role = self::loadRoleOrError($roleId, $tenantId);
if (!$role) {
return ApiResponse::error('역할을 찾을 수 없습니다.', 404);
}
// 유효성: 두 방식 중 하나만 요구하진 않지만, 최소 하나는 있어야 함
$v = Validator::make($params, [
'permission_names' => 'sometimes|array',
'permission_names.*' => 'string|min:1',
'menus' => 'sometimes|array',
'menus.*' => 'integer|min:1',
'actions' => 'sometimes|array',
'actions.*' => [
'string', Rule::in(config('authz.menu_actions', ['view','create','update','delete','approve'])),
],
]);
if ($v->fails()) {
return ApiResponse::error($v->errors()->first(), 422);
}
if (empty($params['permission_names']) && (empty($params['menus']) || empty($params['actions']))) {
return ApiResponse::error('permission_names 또는 menus+actions 중 하나는 필요합니다.', 422);
}
self::setTeam($tenantId);
$names = self::resolvePermissionNames($tenantId, $params);
if (empty($names)) {
return ApiResponse::error('유효한 퍼미션이 없습니다.', 422);
}
// Spatie: 이름 배열 부여 OK (teams 컨텍스트 적용됨)
$role->givePermissionTo($names);
return ApiResponse::response('success');
}
/** 회수 (없는 건 무시) */
public static function revoke(int $roleId, array $params = [])
{
$tenantId = (int) app('tenant_id');
$role = self::loadRoleOrError($roleId, $tenantId);
if (!$role) {
return ApiResponse::error('역할을 찾을 수 없습니다.', 404);
}
$v = Validator::make($params, [
'permission_names' => 'sometimes|array',
'permission_names.*' => 'string|min:1',
'menus' => 'sometimes|array',
'menus.*' => 'integer|min:1',
'actions' => 'sometimes|array',
'actions.*' => [
'string', Rule::in(config('authz.menu_actions', ['view','create','update','delete','approve'])),
],
]);
if ($v->fails()) {
return ApiResponse::error($v->errors()->first(), 422);
}
if (empty($params['permission_names']) && (empty($params['menus']) || empty($params['actions']))) {
return ApiResponse::error('permission_names 또는 menus+actions 중 하나는 필요합니다.', 422);
}
self::setTeam($tenantId);
$names = self::resolvePermissionNames($tenantId, $params);
if (empty($names)) {
return ApiResponse::error('유효한 퍼미션이 없습니다.', 422);
}
$role->revokePermissionTo($names);
return ApiResponse::response('success');
}
/** 동기화(완전 교체) */
public static function sync(int $roleId, array $params = [])
{
$tenantId = (int) app('tenant_id');
$role = self::loadRoleOrError($roleId, $tenantId);
if (!$role) {
return ApiResponse::error('역할을 찾을 수 없습니다.', 404);
}
$v = Validator::make($params, [
'permission_names' => 'sometimes|array',
'permission_names.*' => 'string|min:1',
'menus' => 'sometimes|array',
'menus.*' => 'integer|min:1',
'actions' => 'sometimes|array',
'actions.*' => [
'string', Rule::in(config('authz.menu_actions', ['view','create','update','delete','approve'])),
],
]);
if ($v->fails()) {
return ApiResponse::error($v->errors()->first(), 422);
}
if (empty($params['permission_names']) && (empty($params['menus']) || empty($params['actions']))) {
return ApiResponse::error('permission_names 또는 menus+actions 중 하나는 필요합니다.', 422);
}
self::setTeam($tenantId);
$names = self::resolvePermissionNames($tenantId, $params); // 존재하지 않으면 생성
// 동기화
$role->syncPermissions($names);
return ApiResponse::response('success');
}
}

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Services\Authz;
use Illuminate\Validation\Rule;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Facades\DB;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
use App\Helpers\ApiResponse;
class RoleService
{
protected static string $guard = 'api';
/** 목록 */
public static function index(array $params = [])
{
$tenantId = (int) app('tenant_id');
$page = (int)($params['page'] ?? 1);
$size = (int)($params['size'] ?? 10);
$q = trim((string)($params['q'] ?? ''));
$query = Role::query()
->where('tenant_id', $tenantId)
->where('guard_name', self::$guard);
if ($q !== '') {
$query->where(function($w) use ($q) {
$w->where('name', 'like', "%{$q}%")
->orWhere('description', 'like', "%{$q}%");
});
}
$list = $query->orderBy('id','desc')
->paginate($size, ['*'], 'page', $page);
return ApiResponse::response('result', $list);
}
/** 생성 */
public static function store(array $params = [])
{
$tenantId = (int) app('tenant_id');
$v = Validator::make($params, [
'name' => [
'required','string','max:100',
Rule::unique('roles','name')->where(fn($q)=>$q
->where('tenant_id',$tenantId)
->where('guard_name', self::$guard)),
],
'description' => 'nullable|string|max:255',
]);
if ($v->fails()) {
return ApiResponse::error($v->errors()->first(), 422);
}
app(PermissionRegistrar::class)->setPermissionsTeamId($tenantId);
$role = Role::create([
'tenant_id' => $tenantId,
'guard_name' => self::$guard,
'name' => $v->validated()['name'],
'description'=> $params['description'] ?? null,
]);
return ApiResponse::response('result', $role);
}
/** 단건 */
public static function show(int $id)
{
$tenantId = (int) app('tenant_id');
$role = Role::where('tenant_id',$tenantId)
->where('guard_name', self::$guard)
->find($id);
if (!$role) {
return ApiResponse::error('역할을 찾을 수 없습니다.', 404);
}
return ApiResponse::response('result', $role);
}
/** 수정 */
public static function update(int $id, array $params = [])
{
$tenantId = (int) app('tenant_id');
$role = Role::where('tenant_id',$tenantId)
->where('guard_name', self::$guard)
->find($id);
if (!$role) {
return ApiResponse::error('역할을 찾을 수 없습니다.', 404);
}
$v = Validator::make($params, [
'name' => [
'sometimes','string','max:100',
Rule::unique('roles','name')
->where(fn($q)=>$q->where('tenant_id',$tenantId)->where('guard_name', self::$guard))
->ignore($role->id),
],
'description' => 'sometimes|nullable|string|max:255',
]);
if ($v->fails()) {
return ApiResponse::error($v->errors()->first(), 422);
}
$payload = $v->validated();
$role->fill($payload)->save();
return ApiResponse::response('result', $role);
}
/** 삭제 (현재는 하드삭제) */
public static function destroy(int $id)
{
$tenantId = (int) app('tenant_id');
$role = Role::where('tenant_id',$tenantId)
->where('guard_name', self::$guard)
->find($id);
if (!$role) {
return ApiResponse::error('역할을 찾을 수 없습니다.', 404);
}
DB::transaction(function () use ($role) {
// 연관 피벗은 스파티가 onDelete cascade 하므로 기본 동작으로 OK
$role->delete(); // ※ 기본 Spatie Role은 SoftDeletes 미사용 → 하드 삭제
});
return ApiResponse::response('success');
}
}

View File

@@ -0,0 +1,203 @@
<?php
namespace App\Services\Authz;
use App\Helpers\ApiResponse;
use App\Models\Members\User;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
class UserRoleService
{
protected static string $guard = 'api';
/** 팀(테넌트) 컨텍스트 고정 */
protected static function setTeam(int $tenantId): void
{
app(PermissionRegistrar::class)->setPermissionsTeamId($tenantId);
}
/** 유저 로드 (존재 체크) */
protected static function loadUserOrError(int $userId): ?User
{
return User::find($userId);
}
/** 입력으로 받은 role_names[] 또는 role_ids[] → 실제 역할 이름 배열로 변환(검증 포함) */
protected static function resolveRoleNames(int $tenantId, array $params): array
{
$names = [];
// A) role_names[] 직접
if (!empty($params['role_names']) && is_array($params['role_names'])) {
foreach ($params['role_names'] as $n) {
if (is_string($n) && $n !== '') $names[] = trim($n);
}
}
// B) role_ids[] → 이름으로 변환
if (!empty($params['role_ids']) && is_array($params['role_ids'])) {
$ids = array_values(array_unique(array_map('intval', $params['role_ids'])));
if (!empty($ids)) {
$rows = Role::query()
->where('tenant_id', $tenantId)
->where('guard_name', self::$guard)
->whereIn('id', $ids)
->pluck('name')
->all();
$names = array_merge($names, $rows);
}
}
// 정제
$names = array_values(array_unique(array_filter($names)));
// 존재하지 않는 이름이 섞였는지 확인(실패 시 422로 안내하고 싶다면 여기서 검사)
if (!empty($names)) {
$count = Role::query()
->where('tenant_id', $tenantId)
->where('guard_name', self::$guard)
->whereIn('name', $names)
->count();
if ($count !== count($names)) {
// 존재하지 않는 역할 이름이 포함됨
// 필요하면 어떤 이름이 없는지 찾아서 에러 반환하도록 개선 가능
}
}
return $names;
}
/** 목록 */
public static function list(int $userId)
{
$tenantId = (int) app('tenant_id');
$user = self::loadUserOrError($userId);
if (!$user) {
return ApiResponse::error('사용자를 찾을 수 없습니다.', 404);
}
self::setTeam($tenantId);
// 현재 테넌트의 역할만
$roles = $user->roles()
->where('roles.tenant_id', $tenantId)
->where('roles.guard_name', self::$guard)
->orderBy('roles.id', 'desc')
->get(['roles.id','roles.tenant_id','roles.name','roles.description','roles.guard_name','roles.created_at','roles.updated_at']);
return ApiResponse::response('result', $roles);
}
/** 부여 (중복 무시) */
public static function grant(int $userId, array $params = [])
{
$tenantId = (int) app('tenant_id');
$user = self::loadUserOrError($userId);
if (!$user) {
return ApiResponse::error('사용자를 찾을 수 없습니다.', 404);
}
$v = Validator::make($params, [
'role_names' => 'sometimes|array',
'role_names.*' => 'string|min:1',
'role_ids' => 'sometimes|array',
'role_ids.*' => 'integer|min:1',
]);
if ($v->fails()) {
return ApiResponse::error($v->errors()->first(), 422);
}
if (empty($params['role_names']) && empty($params['role_ids'])) {
return ApiResponse::error('role_names 또는 role_ids 중 하나는 필요합니다.', 422);
}
self::setTeam($tenantId);
$names = self::resolveRoleNames($tenantId, $params);
if (empty($names)) {
return ApiResponse::error('유효한 역할이 없습니다.', 422);
}
// Spatie: 이름 배열로 부여 (teams 컨텍스트 적용)
$user->assignRole($names);
return ApiResponse::response('success');
}
/** 회수 (없는 건 무시) */
public static function revoke(int $userId, array $params = [])
{
$tenantId = (int) app('tenant_id');
$user = self::loadUserOrError($userId);
if (!$user) {
return ApiResponse::error('사용자를 찾을 수 없습니다.', 404);
}
$v = Validator::make($params, [
'role_names' => 'sometimes|array',
'role_names.*' => 'string|min:1',
'role_ids' => 'sometimes|array',
'role_ids.*' => 'integer|min:1',
]);
if ($v->fails()) {
return ApiResponse::error($v->errors()->first(), 422);
}
if (empty($params['role_names']) && empty($params['role_ids'])) {
return ApiResponse::error('role_names 또는 role_ids 중 하나는 필요합니다.', 422);
}
self::setTeam($tenantId);
$names = self::resolveRoleNames($tenantId, $params);
if (empty($names)) {
return ApiResponse::error('유효한 역할이 없습니다.', 422);
}
$user->removeRole($names); // 배열 허용
return ApiResponse::response('success');
}
/** 동기화(완전 교체) */
public static function sync(int $userId, array $params = [])
{
$tenantId = (int) app('tenant_id');
$user = self::loadUserOrError($userId);
if (!$user) {
return ApiResponse::error('사용자를 찾을 수 없습니다.', 404);
}
$v = Validator::make($params, [
'role_names' => 'sometimes|array',
'role_names.*' => 'string|min:1',
'role_ids' => 'sometimes|array',
'role_ids.*' => 'integer|min:1',
]);
if ($v->fails()) {
return ApiResponse::error($v->errors()->first(), 422);
}
if (empty($params['role_names']) && empty($params['role_ids'])) {
return ApiResponse::error('role_names 또는 role_ids 중 하나는 필요합니다.', 422);
}
self::setTeam($tenantId);
$names = self::resolveRoleNames($tenantId, $params);
if (empty($names)) {
// 빈 목록으로 sync = 모두 제거 의도라면 허용할 수도 있음.
// 정책에 맞춰 처리: 여기서는 빈 목록이면 실패 처리
return ApiResponse::error('유효한 역할이 없습니다.', 422);
}
$user->syncRoles($names); // 교체
return ApiResponse::response('success');
}
}

View File

@@ -0,0 +1,316 @@
<?php
namespace App\Services;
use App\Models\Commons\Department;
use App\Models\Commons\DepartmentUser;
use App\Models\Commons\DepartmentPermission;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Carbon\Carbon;
class DepartmentService
{
private static function v(array $params, array $rules)
{
$v = Validator::make($params, $rules);
if ($v->fails()) abort(422, $v->errors()->first());
return $v->validated();
}
/** 목록 */
public static function index(array $params)
{
$p = self::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',
]);
$q = Department::query();
if (isset($p['is_active'])) $q->where('is_active', (int)$p['is_active']);
if (!empty($p['q'])) {
$q->where(function($w) use ($p) {
$w->where('name','like','%'.$p['q'].'%')
->orWhere('code','like','%'.$p['q'].'%');
});
}
return $q->orderBy('sort_order')->orderBy('name')->paginate($p['per_page'] ?? 20);
}
/** 생성 */
public static function store(array $params)
{
$p = self::v($params, [
'code' => 'nullable|string|max:50',
'name' => 'required|string|max:100',
'description' => 'nullable|string|max:255',
'is_active' => 'nullable|in:0,1',
'sort_order' => 'nullable|integer',
'created_by' => 'nullable|integer|min:1',
]);
if (!empty($p['code'])) {
$exists = Department::query()->where('code', $p['code'])->exists();
if ($exists) abort(409, '이미 존재하는 부서 코드입니다.');
}
$dept = Department::create([
'code' => $p['code'] ?? null,
'name' => $p['name'],
'description' => $p['description'] ?? null,
'is_active' => isset($p['is_active']) ? (int)$p['is_active'] : 1,
'sort_order' => $p['sort_order'] ?? 0,
'created_by' => $p['created_by'] ?? null,
'updated_by' => $p['created_by'] ?? null,
]);
return self::show($dept->id, []);
}
/** 단건 */
public static function show(int $id, array $params)
{
$dept = Department::query()->find($id);
if (!$dept) abort(404, '부서를 찾을 수 없습니다.');
return $dept;
}
/** 수정 */
public static function update(int $id, array $params)
{
$p = self::v($params, [
'code' => 'nullable|string|max:50',
'name' => 'nullable|string|max:100',
'description' => 'nullable|string|max:255',
'is_active' => 'nullable|in:0,1',
'sort_order' => 'nullable|integer',
'updated_by' => 'nullable|integer|min:1',
]);
$dept = Department::query()->find($id);
if (!$dept) abort(404, '부서를 찾을 수 없습니다.');
if (array_key_exists('code', $p) && !is_null($p['code'])) {
$exists = Department::query()
->where('code', $p['code'])
->where('id','!=',$id)
->exists();
if ($exists) abort(409, '이미 존재하는 부서 코드입니다.');
}
$dept->fill([
'code' => array_key_exists('code', $p) ? $p['code'] : $dept->code,
'name' => $p['name'] ?? $dept->name,
'description' => $p['description'] ?? $dept->description,
'is_active' => isset($p['is_active']) ? (int)$p['is_active'] : $dept->is_active,
'sort_order' => $p['sort_order'] ?? $dept->sort_order,
'updated_by' => $p['updated_by'] ?? $dept->updated_by,
])->save();
return $dept->fresh();
}
/** 삭제(soft) */
public static function destroy(int $id, array $params)
{
$p = self::v($params, [
'deleted_by' => 'nullable|integer|min:1',
]);
$dept = Department::query()->find($id);
if (!$dept) abort(404, '부서를 찾을 수 없습니다.');
if (!empty($p['deleted_by'])) {
$dept->deleted_by = $p['deleted_by'];
$dept->save();
}
$dept->delete();
return ['id'=>$id, 'deleted_at'=>now()->toDateTimeString()];
}
/** 부서 사용자 목록 */
public static function listUsers(int $deptId, array $params)
{
$p = self::v($params, [
'page' => 'nullable|integer|min:1',
'per_page' => 'nullable|integer|min:1|max:200',
]);
$dept = Department::query()->find($deptId);
if (!$dept) abort(404, '부서를 찾을 수 없습니다.');
return $dept->departmentUsers()
->with('user')
->orderByDesc('is_primary')
->orderBy('id')
->paginate($p['per_page'] ?? 20);
}
/** 사용자 배정 (단건) */
public static function attachUser(int $deptId, array $params)
{
$p = self::v($params, [
'user_id' => 'required|integer|min:1',
'is_primary' => 'nullable|in:0,1',
'joined_at' => 'nullable|date',
]);
$dept = Department::query()->find($deptId);
if (!$dept) abort(404, '부서를 찾을 수 없습니다.');
return DB::transaction(function () use ($dept, $p) {
$du = DepartmentUser::withTrashed()
->where('department_id', $dept->id)
->where('user_id', $p['user_id'])
->first();
if ($du && is_null($du->deleted_at)) {
abort(409, '이미 배정된 사용자입니다.');
}
if (!empty($p['is_primary']) && (int)$p['is_primary'] === 1) {
DepartmentUser::whereNull('deleted_at')
->where('user_id', $p['user_id'])
->update(['is_primary'=>0]);
}
$payload = [
'department_id' => $dept->id,
'user_id' => $p['user_id'],
'is_primary' => isset($p['is_primary']) ? (int)$p['is_primary'] : 0,
'joined_at' => !empty($p['joined_at']) ? Carbon::parse($p['joined_at']) : now(),
];
if ($du) {
$du->fill($payload);
$du->restore();
$du->save();
} else {
DepartmentUser::create($payload);
}
return ['department_id'=>$dept->id, 'user_id'=>$p['user_id']];
});
}
/** 사용자 제거(soft) */
public static function detachUser(int $deptId, int $userId, array $params)
{
$du = DepartmentUser::whereNull('deleted_at')
->where('department_id', $deptId)
->where('user_id', $userId)
->first();
if (!$du) abort(404, '배정된 사용자를 찾을 수 없습니다.');
$du->delete();
return ['user_id'=>$userId, 'deleted_at'=>now()->toDateTimeString()];
}
/** 주부서 설정/해제 */
public static function setPrimary(int $deptId, int $userId, array $params)
{
$p = self::v($params, [
'is_primary' => 'required|in:0,1',
]);
return DB::transaction(function () use ($deptId, $userId, $p) {
$du = DepartmentUser::whereNull('deleted_at')
->where('department_id', $deptId)
->where('user_id', $userId)
->first();
if (!$du) abort(404, '배정된 사용자를 찾을 수 없습니다.');
if ((int)$p['is_primary'] === 1) {
DepartmentUser::whereNull('deleted_at')
->where('user_id', $userId)
->update(['is_primary' => 0]);
}
$du->is_primary = (int)$p['is_primary'];
$du->save();
return ['user_id'=>$userId,'department_id'=>$deptId,'is_primary'=>$du->is_primary];
});
}
/** 부서 권한 목록 */
public static function listPermissions(int $deptId, array $params)
{
$p = self::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',
]);
$dept = Department::query()->find($deptId);
if (!$dept) abort(404, '부서를 찾을 수 없습니다.');
$q = DepartmentPermission::query()
->whereNull('deleted_at')
->where('department_id', $deptId);
if (isset($p['menu_id'])) $q->where('menu_id', $p['menu_id']);
if (isset($p['is_allowed'])) $q->where('is_allowed', (int)$p['is_allowed']);
return $q->orderByDesc('is_allowed')->orderBy('permission_id')->paginate($p['per_page'] ?? 20);
}
/** 권한 부여/차단 upsert */
public static function upsertPermission(int $deptId, array $params)
{
$p = self::v($params, [
'permission_id' => 'required|integer|min:1',
'menu_id' => 'nullable|integer|min:1',
'is_allowed' => 'nullable|in:0,1',
]);
$dept = Department::query()->find($deptId);
if (!$dept) abort(404, '부서를 찾을 수 없습니다.');
$payload = [
'department_id' => $deptId,
'permission_id' => $p['permission_id'],
'menu_id' => $p['menu_id'] ?? null,
];
$model = DepartmentPermission::withTrashed()->firstOrNew($payload);
$model->is_allowed = isset($p['is_allowed']) ? (int)$p['is_allowed'] : 1;
$model->deleted_at = null;
$model->save();
return self::listPermissions($deptId, []);
}
/** 권한 제거 (menu_id 없으면 전체 제거) */
public static function revokePermission(int $deptId, int $permissionId, array $params)
{
$p = self::v($params, [
'menu_id' => 'nullable|integer|min:1',
]);
$q = DepartmentPermission::whereNull('deleted_at')
->where('department_id', $deptId)
->where('permission_id', $permissionId);
if (isset($p['menu_id'])) $q->where('menu_id', $p['menu_id']);
$rows = $q->get();
if ($rows->isEmpty()) abort(404, '대상 권한을 찾을 수 없습니다.');
foreach ($rows as $row) $row->delete();
return [
'permission_id' => $permissionId,
'menu_id' => $p['menu_id'] ?? null,
'deleted_count' => $rows->count(),
];
}
}

View File

@@ -0,0 +1,220 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Arr;
use Illuminate\Validation\ValidationException;
use Illuminate\Support\Facades\Validator;
class MenuService
{
protected static function tenantId(array $params): ?int
{
return $params['tenant_id'] ?? request()->attributes->get('tenant_id');
}
protected static function actorId(array $params): ?int
{
return $params['user_id'] ?? (request()->user()->id ?? null);
}
public static function index(array $params)
{
$tenantId = self::tenantId($params);
$q = DB::table('menus')->whereNull('deleted_at');
if (!is_null($tenantId)) {
$q->where(function ($w) use ($tenantId) {
$w->whereNull('tenant_id')->orWhere('tenant_id', $tenantId);
});
}
// 옵션: parent_id / is_active / hidden 필터
if (isset($params['parent_id'])) $q->where('parent_id', $params['parent_id']);
if (isset($params['is_active'])) $q->where('is_active', (int)$params['is_active']);
if (isset($params['hidden'])) $q->where('hidden', (int)$params['hidden']);
return $q->orderBy('parent_id')->orderBy('sort_order')->get();
}
public static function show(array $params)
{
$id = (int)($params['id'] ?? 0);
$tenantId = self::tenantId($params);
$q = DB::table('menus')->where('id', $id)->whereNull('deleted_at');
if (!is_null($tenantId)) {
$q->where(function ($w) use ($tenantId) {
$w->whereNull('tenant_id')->orWhere('tenant_id', $tenantId);
});
}
$row = $q->first();
throw_if(!$row, ValidationException::withMessages(['id' => 'Menu not found']));
return $row;
}
public static function store(array $params)
{
$tenantId = self::tenantId($params);
$userId = self::actorId($params);
$v = Validator::make($params, [
'parent_id' => ['nullable','integer'],
'name' => ['required','string','max:100'],
'slug' => ['nullable','string','max:150'],
'url' => ['nullable','string','max:255'],
'is_active' => ['nullable','boolean'],
'sort_order' => ['nullable','integer'],
'hidden' => ['nullable','boolean'],
'is_external' => ['nullable','boolean'],
'external_url' => ['nullable','string','max:255'],
'icon' => ['nullable','string','max:50'],
]);
$data = $v->validated();
// slug 유니크(테넌트 범위) 체크
if (!empty($data['slug'])) {
$exists = DB::table('menus')
->whereNull('deleted_at')
->when(!is_null($tenantId), fn($q)=>$q->where('tenant_id',$tenantId), fn($q)=>$q->whereNull('tenant_id'))
->where('slug',$data['slug'])
->exists();
if ($exists) {
throw ValidationException::withMessages(['slug'=>'이미 사용 중인 슬러그입니다.']);
}
}
$now = now();
$id = DB::table('menus')->insertGetId([
'tenant_id' => $tenantId,
'parent_id' => $data['parent_id'] ?? null,
'name' => $data['name'],
'slug' => $data['slug'] ?? null,
'url' => $data['url'] ?? null,
'is_active' => (int)($data['is_active'] ?? 1),
'sort_order' => (int)($data['sort_order'] ?? 0),
'hidden' => (int)($data['hidden'] ?? 0),
'is_external' => (int)($data['is_external'] ?? 0),
'external_url' => $data['external_url'] ?? null,
'icon' => $data['icon'] ?? null,
'created_at' => $now,
'updated_at' => $now,
'created_by' => $userId,
'updated_by' => $userId,
]);
return ['id' => $id];
}
public static function update(array $params)
{
$id = (int)($params['id'] ?? 0);
$tenantId = self::tenantId($params);
$userId = self::actorId($params);
$v = Validator::make($params, [
'parent_id' => ['nullable','integer'],
'name' => ['nullable','string','max:100'],
'slug' => ['nullable','string','max:150'],
'url' => ['nullable','string','max:255'],
'is_active' => ['nullable','boolean'],
'sort_order' => ['nullable','integer'],
'hidden' => ['nullable','boolean'],
'is_external' => ['nullable','boolean'],
'external_url' => ['nullable','string','max:255'],
'icon' => ['nullable','string','max:50'],
]);
$data = $v->validated();
// 대상 존재 확인 & 테넌트 범위
$exists = DB::table('menus')->where('id',$id)->whereNull('deleted_at')
->when(!is_null($tenantId), fn($q)=>$q->where('tenant_id',$tenantId), fn($q)=>$q->whereNull('tenant_id'))
->exists();
if (!$exists) throw ValidationException::withMessages(['id'=>'Menu not found']);
// slug 유니크(테넌트 범위) 체크
if (!empty($data['slug'])) {
$dup = DB::table('menus')->whereNull('deleted_at')
->when(!is_null($tenantId), fn($q)=>$q->where('tenant_id',$tenantId), fn($q)=>$q->whereNull('tenant_id'))
->where('slug',$data['slug'])->where('id','<>',$id)->exists();
if ($dup) throw ValidationException::withMessages(['slug'=>'이미 사용 중인 슬러그입니다.']);
}
$update = Arr::only($data, ['parent_id','name','slug','url','is_active','sort_order','hidden','is_external','external_url','icon']);
$update = array_filter($update, fn($v)=>!is_null($v));
$update['updated_at'] = now();
$update['updated_by'] = $userId;
DB::table('menus')->where('id',$id)->update($update);
return ['id' => $id];
}
public static function destroy(array $params)
{
$id = (int)($params['id'] ?? 0);
$tenantId = self::tenantId($params);
$userId = self::actorId($params);
$q = DB::table('menus')->where('id',$id)->whereNull('deleted_at');
$q = !is_null($tenantId)
? $q->where('tenant_id',$tenantId)
: $q->whereNull('tenant_id');
$row = $q->first();
if (!$row) throw ValidationException::withMessages(['id'=>'Menu not found']);
DB::table('menus')->where('id',$id)->update([
'deleted_at' => now(),
'deleted_by' => $userId,
]);
return ['id' => $id, 'deleted' => true];
}
/**
* 정렬 일괄 변경
* $params = [ ['id'=>10, 'sort_order'=>1], ... ]
*/
public static function reorder(array $params)
{
if (!is_array($params) || empty($params)) {
throw ValidationException::withMessages(['items'=>'유효한 정렬 목록이 필요합니다.']);
}
DB::transaction(function () use ($params) {
foreach ($params as $it) {
if (!isset($it['id'], $it['sort_order'])) continue;
DB::table('menus')->where('id',(int)$it['id'])->update([
'sort_order' => (int)$it['sort_order'],
'updated_at' => now(),
]);
}
});
return true;
}
/**
* 상태 토글
* 허용 필드: is_active / hidden / is_external
*/
public static function toggle(array $params)
{
$id = (int)($params['id'] ?? 0);
$userId = self::actorId($params);
$payload = array_filter([
'is_active' => isset($params['is_active']) ? (int)$params['is_active'] : null,
'hidden' => isset($params['hidden']) ? (int)$params['hidden'] : null,
'is_external' => isset($params['is_external']) ? (int)$params['is_external'] : null,
], fn($v)=>!is_null($v));
if (empty($payload)) {
throw ValidationException::withMessages(['toggle'=>'변경할 필드가 없습니다.']);
}
$payload['updated_at'] = now();
$payload['updated_by'] = $userId;
DB::table('menus')->where('id',$id)->update($payload);
return ['id' => $id];
}
}

View File

@@ -0,0 +1,383 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(
* name="Department",
* description="부서 관리(목록/조회/등록/수정/삭제) + 부서원 관리 + 부서 권한 매핑"
* )
*/
/**
* =========================
* Schemas
* =========================
*/
/**
* @OA\Schema(
* schema="Department",
* type="object",
* description="부서 상세",
* required={"id","name"},
* @OA\Property(property="id", type="integer", example=7),
* @OA\Property(property="tenant_id", type="integer", example=1),
* @OA\Property(property="code", type="string", nullable=true, example="OPS"),
* @OA\Property(property="name", type="string", example="운영팀"),
* @OA\Property(property="description", type="string", nullable=true, example="서비스 운영 총괄"),
* @OA\Property(property="is_active", type="integer", example=1),
* @OA\Property(property="sort_order", type="integer", example=10),
* @OA\Property(property="created_at", type="string", format="date-time", example="2025-08-16 10:00:00"),
* @OA\Property(property="updated_at", type="string", format="date-time", example="2025-08-16 10:00:00")
* )
*
* @OA\Schema(
* schema="DepartmentBrief",
* type="object",
* description="부서 요약",
* required={"id","name"},
* @OA\Property(property="id", type="integer", example=7),
* @OA\Property(property="code", type="string", nullable=true, example="OPS"),
* @OA\Property(property="name", type="string", example="운영팀"),
* @OA\Property(property="is_active", type="integer", example=1),
* @OA\Property(property="sort_order", type="integer", example=10)
* )
*
* @OA\Schema(
* schema="DepartmentList",
* type="array",
* @OA\Items(ref="#/components/schemas/DepartmentBrief")
* )
*
* @OA\Schema(
* schema="DepartmentCreateRequest",
* type="object",
* required={"name"},
* @OA\Property(property="code", type="string", nullable=true, example="OPS"),
* @OA\Property(property="name", type="string", example="운영팀"),
* @OA\Property(property="description", type="string", nullable=true, example="서비스 운영 총괄"),
* @OA\Property(property="is_active", type="integer", enum={0,1}, example=1),
* @OA\Property(property="sort_order", type="integer", example=0)
* )
*
* @OA\Schema(
* schema="DepartmentUpdateRequest",
* type="object",
* @OA\Property(property="code", type="string", nullable=true, example="OPS2"),
* @OA\Property(property="name", type="string", example="운영기획팀"),
* @OA\Property(property="description", type="string", nullable=true, example="운영 기획/성과 관리"),
* @OA\Property(property="is_active", type="integer", enum={0,1}, example=1),
* @OA\Property(property="sort_order", type="integer", example=5)
* )
*
* @OA\Schema(
* schema="DepartmentUserAttachRequest",
* type="object",
* required={"user_id"},
* @OA\Property(property="user_id", type="integer", example=12),
* @OA\Property(property="is_primary", type="integer", enum={0,1}, nullable=true, example=0)
* )
*
* @OA\Schema(
* schema="DepartmentPermissionUpsertRequest",
* type="object",
* required={"permission_id"},
* @OA\Property(property="permission_id", type="integer", example=25),
* @OA\Property(property="menu_id", type="integer", nullable=true, example=101, description="특정 메뉴에 한정하려면 지정"),
* @OA\Property(property="is_allowed", type="integer", enum={0,1}, example=1, description="1=ALLOW, 0=DENY(차단)")
* )
*
* @OA\Schema(
* schema="UserBrief",
* type="object",
* description="부서원 요약",
* required={"id","name","email"},
* @OA\Property(property="id", type="integer", example=12),
* @OA\Property(property="name", type="string", example="홍길동"),
* @OA\Property(property="email", type="string", format="email", example="hong@example.com"),
* @OA\Property(property="phone", type="string", nullable=true, example="010-1234-5678"),
* @OA\Property(property="is_active", type="integer", example=1)
* )
*/
class DepartmentApi
{
/**
* @OA\Get(
* path="/api/v1/departments",
* summary="부서 목록 조회",
* description="테넌트 범위 내 부서 목록을 페이징으로 반환합니다. (q로 이름/코드 검색)",
* tags={"Department"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="page", in="query", required=false, @OA\Schema(type="integer", example=1)),
* @OA\Parameter(name="per_page", in="query", required=false, @OA\Schema(type="integer", example=10)),
* @OA\Parameter(name="q", in="query", required=false, @OA\Schema(type="string", example="운영")),
* @OA\Parameter(name="is_active", in="query", required=false, @OA\Schema(type="integer", enum={0,1}, example=1)),
* @OA\Response(response=200, description="목록 조회 성공",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", type="object",
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="per_page", type="integer", example=10),
* @OA\Property(property="total", type="integer", example=2),
* @OA\Property(property="data", ref="#/components/schemas/DepartmentList")
* ))
* }
* )
* ),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function index() {}
/**
* @OA\Get(
* path="/api/v1/departments/{id}",
* summary="부서 단건 조회",
* description="ID로 부서 상세를 조회합니다.",
* tags={"Department"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=7)),
* @OA\Response(response=200, description="단건 조회 성공",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Department"))
* }
* )
* ),
* @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function show() {}
/**
* @OA\Post(
* path="/api/v1/departments",
* summary="부서 생성",
* description="새 부서를 생성합니다.",
* tags={"Department"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/DepartmentCreateRequest")),
* @OA\Response(response=200, description="생성 성공",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Department"))
* }
* )
* ),
* @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function store() {}
/**
* @OA\Patch(
* path="/api/v1/departments/{id}",
* summary="부서 수정",
* description="기존 부서 정보를 부분 수정합니다.",
* tags={"Department"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=7)),
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/DepartmentUpdateRequest")),
* @OA\Response(response=200, description="수정 성공",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Department"))
* }
* )
* ),
* @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function update() {}
/**
* @OA\Delete(
* path="/api/v1/departments/{id}",
* summary="부서 삭제",
* description="지정한 부서를 삭제합니다(소프트 삭제 권장).",
* tags={"Department"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=7)),
* @OA\Response(response=200, description="삭제 성공",
* @OA\JsonContent(ref="#/components/schemas/ApiResponse")
* ),
* @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function destroy() {}
/**
* @OA\Get(
* path="/api/v1/departments/{id}/users",
* summary="부서 사용자 목록",
* description="해당 부서에 속한 사용자 목록을 페이징으로 반환합니다.",
* tags={"Department"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=7)),
* @OA\Parameter(name="page", in="query", required=false, @OA\Schema(type="integer", example=1)),
* @OA\Parameter(name="per_page", in="query", required=false, @OA\Schema(type="integer", example=20)),
* @OA\Response(response=200, description="조회 성공",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", type="object",
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="per_page", type="integer", example=20),
* @OA\Property(property="total", type="integer", example=1),
* @OA\Property(property="data", type="array", @OA\Items(ref="#/components/schemas/UserBrief"))
* ))
* }
* )
* ),
* @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function users() {}
/**
* @OA\Post(
* path="/api/v1/departments/{id}/users",
* summary="부서 사용자 배정(단건)",
* description="특정 사용자 한 명을 해당 부서에 배정합니다.",
* tags={"Department"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=7)),
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/DepartmentUserAttachRequest")),
* @OA\Response(response=200, description="배정 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")),
* @OA\Response(response=409, description="이미 배정됨", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function attachUser() {}
/**
* @OA\Delete(
* path="/api/v1/departments/{id}/users/{user}",
* summary="부서 사용자 해제(단건)",
* description="해당 부서에서 특정 사용자를 제거합니다.",
* tags={"Department"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=7)),
* @OA\Parameter(name="user", in="path", required=true, @OA\Schema(type="integer", example=12)),
* @OA\Response(response=200, description="해제 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")),
* @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function detachUser() {}
/**
* @OA\Patch(
* path="/api/v1/departments/{id}/users/{user}/primary",
* summary="주부서 설정/해제",
* description="부서 사용자 주부서 여부를 설정 또는 해제합니다.",
* tags={"Department"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=7)),
* @OA\Parameter(name="user", in="path", required=true, @OA\Schema(type="integer", example=12)),
* @OA\RequestBody(
* required=true,
* @OA\JsonContent(
* @OA\Property(property="is_primary", type="integer", enum={0,1}, example=1)
* )
* ),
* @OA\Response(response=200, description="설정 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")),
* @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function setPrimary() {}
/**
* @OA\Get(
* path="/api/v1/departments/{id}/permissions",
* summary="부서 권한 목록",
* description="부서에 매핑된 권한(메뉴 한정 포함)을 조회합니다.",
* tags={"Department"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=7)),
* @OA\Response(response=200, description="조회 성공",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(
* property="data",
* type="array",
* @OA\Items(type="object",
* @OA\Property(property="permission_id", type="integer", example=25),
* @OA\Property(property="menu_id", type="integer", nullable=true, example=101),
* @OA\Property(property="is_allowed", type="integer", example=1)
* )
* ))
* }
* )
* ),
* @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function listPermissions() {}
/**
* @OA\Post(
* path="/api/v1/departments/{id}/permissions",
* summary="부서 권한 부여/차단(단건 Upsert)",
* description="permission_id 기준으로 ALLOW(1) 또는 DENY(0) 처리합니다. menu_id 지정 시 해당 메뉴 범위로 제한됩니다.",
* tags={"Department"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=7)),
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/DepartmentPermissionUpsertRequest")),
* @OA\Response(response=200, description="적용 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")),
* @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function upsertPermission() {}
/**
* @OA\Delete(
* path="/api/v1/departments/{id}/permissions/{permission}",
* summary="부서 권한 해제(단건)",
* description="지정 권한을 부서 매핑에서 제거합니다. (menu_id 쿼리 파라미터로 특정 메뉴 범위를 지정 가능)",
* tags={"Department"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=7)),
* @OA\Parameter(name="permission", in="path", required=true, @OA\Schema(type="integer", example=25)),
* @OA\Parameter(name="menu_id", in="query", required=false, @OA\Schema(type="integer", example=101)),
* @OA\Response(response=200, description="해제 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")),
* @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function revokePermission() {}
}

View File

@@ -1,6 +0,0 @@
<?php
namespace App\Swagger\v1;

232
app/Swagger/v1/MenuApi.php Normal file
View File

@@ -0,0 +1,232 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="Menu", description="메뉴 관리(목록/조회/등록/수정/삭제/정렬/토글)")
*/
/**
* @OA\Schema(
* schema="Menu",
* type="object",
* description="메뉴 상세",
* required={"id","name"},
* @OA\Property(property="id", type="integer", example=12),
* @OA\Property(property="tenant_id", type="integer", nullable=true, example=1, description="null=공용"),
* @OA\Property(property="parent_id", type="integer", nullable=true, example=null),
* @OA\Property(property="name", type="string", example="메뉴 관리"),
* @OA\Property(property="slug", type="string", nullable=true, example="menu.manage"),
* @OA\Property(property="url", type="string", nullable=true, example="/admin/menus"),
* @OA\Property(property="is_active", type="integer", example=1),
* @OA\Property(property="sort_order", type="integer", example=10),
* @OA\Property(property="hidden", type="integer", example=0),
* @OA\Property(property="is_external", type="integer", example=0),
* @OA\Property(property="external_url", type="string", nullable=true, example=null),
* @OA\Property(property="icon", type="string", nullable=true, example="list"),
* @OA\Property(property="created_at", type="string", format="date-time", example="2025-08-15 10:00:00"),
* @OA\Property(property="updated_at", type="string", format="date-time", example="2025-08-15 10:00:00"),
* @OA\Property(property="deleted_at", type="string", format="date-time", nullable=true, example=null)
* )
*
* @OA\Schema(
* schema="MenuBrief",
* type="object",
* description="메뉴 요약",
* required={"id","name"},
* @OA\Property(property="id", type="integer", example=12),
* @OA\Property(property="parent_id", type="integer", nullable=true, example=null),
* @OA\Property(property="name", type="string", example="메뉴 관리"),
* @OA\Property(property="slug", type="string", nullable=true, example="menu.manage"),
* @OA\Property(property="url", type="string", nullable=true, example="/admin/menus"),
* @OA\Property(property="is_active", type="integer", example=1),
* @OA\Property(property="sort_order", type="integer", example=10),
* @OA\Property(property="hidden", type="integer", example=0),
* @OA\Property(property="is_external", type="integer", example=0),
* @OA\Property(property="external_url", type="string", nullable=true, example=null),
* @OA\Property(property="icon", type="string", nullable=true, example="list")
* )
*
* @OA\Schema(
* schema="MenuList",
* type="array",
* @OA\Items(ref="#/components/schemas/MenuBrief")
* )
*
* @OA\Schema(
* schema="MenuCreateRequest",
* type="object",
* required={"name"},
* @OA\Property(property="parent_id", type="integer", nullable=true, example=null, description="상위 메뉴 ID"),
* @OA\Property(property="name", type="string", example="새 메뉴", description="메뉴명"),
* @OA\Property(property="slug", type="string", nullable=true, example="menu.new", description="권한 키로도 활용"),
* @OA\Property(property="url", type="string", nullable=true, example="/admin/new"),
* @OA\Property(property="is_active", type="boolean", example=true),
* @OA\Property(property="sort_order", type="integer", example=0),
* @OA\Property(property="hidden", type="boolean", example=false),
* @OA\Property(property="is_external", type="boolean", example=false),
* @OA\Property(property="external_url", type="string", nullable=true, example=null),
* @OA\Property(property="icon", type="string", nullable=true, example="plus")
* )
*
* @OA\Schema(
* schema="MenuUpdateRequest",
* type="object",
* @OA\Property(property="parent_id", type="integer", nullable=true, example=null),
* @OA\Property(property="name", type="string", example="메뉴명 변경"),
* @OA\Property(property="slug", type="string", nullable=true, example="menu.changed"),
* @OA\Property(property="url", type="string", nullable=true, example="/admin/changed"),
* @OA\Property(property="is_active", type="boolean", example=true),
* @OA\Property(property="sort_order", type="integer", example=5),
* @OA\Property(property="hidden", type="boolean", example=false),
* @OA\Property(property="is_external", type="boolean", example=false),
* @OA\Property(property="external_url", type="string", nullable=true, example=null),
* @OA\Property(property="icon", type="string", nullable=true, example="edit")
* )
*/
class MenuApi
{
/**
* @OA\Get(
* path="/api/v1/menus",
* summary="메뉴 목록 조회",
* description="테넌트 범위 내(또는 공용) 메뉴 목록을 반환합니다. parent_id/is_active/hidden 필터 지원.",
* tags={"Menu"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="parent_id", in="query", required=false, @OA\Schema(type="integer", example=0)),
* @OA\Parameter(name="is_active", in="query", required=false, @OA\Schema(type="integer", example=1)),
* @OA\Parameter(name="hidden", in="query", required=false, @OA\Schema(type="integer", example=0)),
* @OA\Response(response=200, description="목록 조회 성공",
* @OA\JsonContent(allOf={@OA\Schema(ref="#/components/schemas/ApiResponse"), @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/MenuList"))})
* ),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function index() {}
/**
* @OA\Get(
* path="/api/v1/menus/{id}",
* summary="메뉴 단건 조회",
* description="ID로 메뉴 상세 정보를 조회합니다.",
* tags={"Menu"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=12)),
* @OA\Response(response=200, description="단건 조회 성공",
* @OA\JsonContent(allOf={@OA\Schema(ref="#/components/schemas/ApiResponse"), @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Menu"))})
* ),
* @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function show() {}
/**
* @OA\Post(
* path="/api/v1/menus",
* summary="메뉴 등록",
* description="새로운 메뉴를 등록합니다.",
* tags={"Menu"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/MenuCreateRequest")),
* @OA\Response(response=200, description="등록 성공",
* @OA\JsonContent(allOf={@OA\Schema(ref="#/components/schemas/ApiResponse"), @OA\Schema(@OA\Property(property="data", type="object", @OA\Property(property="id", type="integer", example=12)))})
* ),
* @OA\Response(response=400, description="파라미터 오류", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\ResponsE(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function store() {}
/**
* @OA\Patch(
* path="/api/v1/menus/{id}",
* summary="메뉴 수정",
* description="기존 메뉴 정보를 수정합니다(부분 수정).",
* tags={"Menu"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=12)),
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/MenuUpdateRequest")),
* @OA\Response(response=200, description="수정 성공",
* @OA\JsonContent(allOf={@OA\Schema(ref="#/components/schemas/ApiResponse"), @OA\Schema(@OA\Property(property="data", type="object", @OA\Property(property="id", type="integer", example=12)))})
* ),
* @OA\Response(response=400, description="파라미터 오류", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function update() {}
/**
* @OA\Delete(
* path="/api/v1/menus/{id}",
* summary="메뉴 삭제(소프트 삭제)",
* description="지정한 메뉴를 소프트 삭제합니다.",
* tags={"Menu"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=12)),
* @OA\Response(response=200, description="삭제 성공",
* @OA\JsonContent(allOf={@OA\Schema(ref="#/components/schemas/ApiResponse"), @OA\Schema(@OA\Property(property="data", type="object", @OA\Property(property="id", type="integer", example=12), @OA\Property(property="deleted", type="boolean", example=true)))})
* ),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function destroy() {}
/**
* @OA\Post(
* path="/api/v1/menus/reorder",
* summary="메뉴 정렬 변경",
* description="여러 메뉴의 sort_order를 일괄 변경합니다.",
* tags={"Menu"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\RequestBody(required=true, @OA\JsonContent(
* type="array",
* @OA\Items(type="object",
* @OA\Property(property="id", type="integer", example=12),
* @OA\Property(property="sort_order", type="integer", example=1)
* )
* )),
* @OA\Response(response=200, description="정렬 변경 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")),
* @OA\Response(response=400, description="파라미터 오류", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function reorder() {}
/**
* @OA\Post(
* path="/api/v1/menus/{id}/toggle",
* summary="메뉴 상태 토글",
* description="is_active / hidden / is_external 중 하나 이상을 토글합니다.",
* tags={"Menu"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=12)),
* @OA\RequestBody(required=true, @OA\JsonContent(type="object",
* @OA\Property(property="is_active", type="boolean", nullable=true, example=true),
* @OA\Property(property="hidden", type="boolean", nullable=true, example=false),
* @OA\Property(property="is_external", type="boolean", nullable=true, example=false)
* )),
* @OA\Response(response=200, description="토글 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")),
* @OA\Response(response=400, description="파라미터 오류", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function toggle() {}
}

180
app/Swagger/v1/RoleApi.php Normal file
View File

@@ -0,0 +1,180 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(name="Role", description="역할 관리(목록/조회/등록/수정/삭제)")
*/
/**
* @OA\Schema(
* schema="Role",
* type="object",
* description="역할 상세",
* required={"id","name","guard_name"},
* @OA\Property(property="id", type="integer", example=3),
* @OA\Property(property="tenant_id", type="integer", example=1),
* @OA\Property(property="name", type="string", example="menu-manager"),
* @OA\Property(property="description", type="string", nullable=true, example="메뉴 관리 역할"),
* @OA\Property(property="guard_name", type="string", example="api"),
* @OA\Property(property="created_at", type="string", format="date-time", example="2025-08-16 10:00:00"),
* @OA\Property(property="updated_at", type="string", format="date-time", example="2025-08-16 10:00:00")
* )
*
* @OA\Schema(
* schema="RoleBrief",
* type="object",
* description="역할 요약",
* required={"id","name"},
* @OA\Property(property="id", type="integer", example=3),
* @OA\Property(property="name", type="string", example="readonly"),
* @OA\Property(property="description", type="string", nullable=true, example="읽기 전용"),
* @OA\Property(property="guard_name", type="string", example="api")
* )
*
* @OA\Schema(
* schema="RoleList",
* type="array",
* @OA\Items(ref="#/components/schemas/RoleBrief")
* )
*
* @OA\Schema(
* schema="RoleCreateRequest",
* type="object",
* required={"name"},
* @OA\Property(property="name", type="string", example="menu-manager", description="역할명(테넌트+가드 내 고유)"),
* @OA\Property(property="description", type="string", nullable=true, example="메뉴 관리 역할")
* )
*
* @OA\Schema(
* schema="RoleUpdateRequest",
* type="object",
* @OA\Property(property="name", type="string", example="menu-admin"),
* @OA\Property(property="description", type="string", nullable=true, example="설명 변경")
* )
*/
class RoleApi
{
/**
* @OA\Get(
* path="/api/v1/roles",
* summary="역할 목록 조회",
* description="테넌트 범위 내 역할 목록을 페이징으로 반환합니다. (q로 이름/설명 검색)",
* tags={"Role"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="page", in="query", required=false, @OA\Schema(type="integer", example=1)),
* @OA\Parameter(name="size", in="query", required=false, @OA\Schema(type="integer", example=10)),
* @OA\Parameter(name="q", in="query", required=false, @OA\Schema(type="string", example="read")),
* @OA\Response(response=200, description="목록 조회 성공",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", type="object",
* @OA\Property(property="current_page", type="integer", example=1),
* @OA\Property(property="per_page", type="integer", example=10),
* @OA\Property(property="total", type="integer", example=2),
* @OA\Property(property="data", ref="#/components/schemas/RoleList")
* ))
* }
* )
* ),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function index() {}
/**
* @OA\Post(
* path="/api/v1/roles",
* summary="역할 생성",
* description="새로운 역할을 생성합니다.",
* tags={"Role"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/RoleCreateRequest")),
* @OA\Response(response=200, description="생성 성공",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Role"))
* }
* )
* ),
* @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function store() {}
/**
* @OA\Get(
* path="/api/v1/roles/{id}",
* summary="역할 단건 조회",
* description="ID로 역할 상세를 조회합니다.",
* tags={"Role"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=3)),
* @OA\Response(response=200, description="단건 조회 성공",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Role"))
* }
* )
* ),
* @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function show() {}
/**
* @OA\Patch(
* path="/api/v1/roles/{id}",
* summary="역할 수정",
* description="기존 역할 정보를 부분 수정합니다.",
* tags={"Role"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=3)),
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/RoleUpdateRequest")),
* @OA\Response(response=200, description="수정 성공",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/Role"))
* }
* )
* ),
* @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function update() {}
/**
* @OA\Delete(
* path="/api/v1/roles/{id}",
* summary="역할 삭제",
* description="지정한 역할을 삭제합니다.",
* tags={"Role"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer", example=3)),
* @OA\Response(response=200, description="삭제 성공",
* @OA\JsonContent(ref="#/components/schemas/ApiResponse")
* ),
* @OA\Response(response=404, description="데이터 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function destroy() {}
}

View File

@@ -0,0 +1,171 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(
* name="RolePermission",
* description="역할-퍼미션 매핑(조회/부여/회수/동기화)"
* )
*/
/**
* @OA\Schema(
* schema="PermissionBrief",
* type="object",
* description="퍼미션 요약",
* required={"id","name","guard_name"},
* @OA\Property(property="id", type="integer", example=15),
* @OA\Property(property="tenant_id", type="integer", example=1),
* @OA\Property(property="name", type="string", example="menu:101.view"),
* @OA\Property(property="guard_name", type="string", example="api"),
* @OA\Property(property="created_at", type="string", format="date-time", example="2025-08-16 10:00:00"),
* @OA\Property(property="updated_at", type="string", format="date-time", example="2025-08-16 10:00:00")
* )
*
* @OA\Schema(
* schema="PermissionList",
* type="array",
* @OA\Items(ref="#/components/schemas/PermissionBrief")
* )
*
* @OA\Schema(
* schema="RolePermissionGrantRequest",
* type="object",
* description="역할에 퍼미션 부여. 방법 A: permission_names 배열. 방법 B: menus + actions 조합.",
* oneOf={
* @OA\Schema(
* description="방법 A: 퍼미션 이름 배열",
* required={"permission_names"},
* @OA\Property(property="permission_names", type="array", @OA\Items(type="string"), example={"menu:101.view","menu:101.create"})
* ),
* @OA\Schema(
* description="방법 B: 메뉴+액션 조합",
* required={"menus","actions"},
* @OA\Property(property="menus", type="array", @OA\Items(type="integer"), example={101,102}),
* @OA\Property(property="actions", type="array", @OA\Items(type="string"), example={"view","create","update","delete"})
* )
* }
* )
*
* @OA\Schema(
* schema="RolePermissionRevokeRequest",
* type="object",
* description="역할에서 퍼미션 회수. 방법 A: permission_names 배열. 방법 B: menus + actions 조합.",
* oneOf={
* @OA\Schema(
* description="방법 A: 퍼미션 이름 배열",
* required={"permission_names"},
* @OA\Property(property="permission_names", type="array", @OA\Items(type="string"), example={"menu:101.view","menu:101.create"})
* ),
* @OA\Schema(
* description="방법 B: 메뉴+액션 조합",
* required={"menus","actions"},
* @OA\Property(property="menus", type="array", @OA\Items(type="integer"), example={101}),
* @OA\Property(property="actions", type="array", @OA\Items(type="string"), example={"create"})
* )
* }
* )
*
* @OA\Schema(
* schema="RolePermissionSyncRequest",
* type="object",
* description="역할의 퍼미션을 전달된 목록으로 완전히 교체(동기화). 방법 A 또는 B.",
* oneOf={
* @OA\Schema(
* description="방법 A: 퍼미션 이름 배열",
* required={"permission_names"},
* @OA\Property(property="permission_names", type="array", @OA\Items(type="string"), example={"menu:101.view","menu:101.update"})
* ),
* @OA\Schema(
* description="방법 B: 메뉴+액션 조합",
* required={"menus","actions"},
* @OA\Property(property="menus", type="array", @OA\Items(type="integer"), example={101,102}),
* @OA\Property(property="actions", type="array", @OA\Items(type="string"), example={"view","update"})
* )
* }
* )
*/
class RolePermissionApi
{
/**
* @OA\Get(
* path="/api/v1/roles/{id}/permissions",
* summary="역할의 퍼미션 목록 조회",
* description="해당 역할에 현재 부여된 퍼미션 목록을 반환합니다.",
* tags={"RolePermission"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer"), example=3),
* @OA\Response(response=200, description="조회 성공",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(@OA\Property(property="data", ref="#/components/schemas/PermissionList"))
* }
* )
* ),
* @OA\Response(response=404, description="역할 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function list() {}
/**
* @OA\Post(
* path="/api/v1/roles/{id}/permissions",
* summary="역할에 퍼미션 부여",
* description="퍼미션 이름 배열 또는 메뉴ID+액션 조합으로 역할에 권한을 부여합니다.",
* tags={"RolePermission"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer"), example=3),
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/RolePermissionGrantRequest")),
* @OA\Response(response=200, description="부여 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")),
* @OA\Response(response=404, description="역할/퍼미션 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function grant() {}
/**
* @OA\Delete(
* path="/api/v1/roles/{id}/permissions",
* summary="역할에서 퍼미션 회수",
* description="퍼미션 이름 배열 또는 메뉴ID+액션 조합으로 권한을 회수합니다.",
* tags={"RolePermission"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer"), example=3),
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/RolePermissionRevokeRequest")),
* @OA\Response(response=200, description="회수 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")),
* @OA\Response(response=404, description="역할/퍼미션 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function revoke() {}
/**
* @OA\Put(
* path="/api/v1/roles/{id}/permissions/sync",
* summary="역할의 퍼미션 동기화(교체)",
* description="전달된 목록으로 역할의 권한을 완전히 교체합니다.",
* tags={"RolePermission"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer"), example=3),
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/RolePermissionSyncRequest")),
* @OA\Response(response=200, description="동기화 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")),
* @OA\Response(response=404, description="역할/퍼미션 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function sync() {}
}

View File

@@ -0,0 +1,151 @@
<?php
namespace App\Swagger\v1;
/**
* @OA\Tag(
* name="UserRole",
* description="사용자-역할 매핑(조회/부여/회수/동기화)"
* )
*/
/**
* @OA\Schema(
* schema="UserRoleGrantRequest",
* type="object",
* description="사용자에게 역할 부여. role_names 또는 role_ids 중 하나 사용.",
* oneOf={
* @OA\Schema(
* required={"role_names"},
* @OA\Property(property="role_names", type="array", @OA\Items(type="string"), example={"menu-manager","readonly"})
* ),
* @OA\Schema(
* required={"role_ids"},
* @OA\Property(property="role_ids", type="array", @OA\Items(type="integer"), example={1,2})
* )
* }
* )
*
* @OA\Schema(
* schema="UserRoleRevokeRequest",
* type="object",
* description="사용자로부터 역할 회수. role_names 또는 role_ids 중 하나 사용.",
* oneOf={
* @OA\Schema(
* required={"role_names"},
* @OA\Property(property="role_names", type="array", @OA\Items(type="string"), example={"readonly"})
* ),
* @OA\Schema(
* required={"role_ids"},
* @OA\Property(property="role_ids", type="array", @OA\Items(type="integer"), example={2})
* )
* }
* )
*
* @OA\Schema(
* schema="UserRoleSyncRequest",
* type="object",
* description="사용자의 역할을 전달된 목록으로 완전히 교체. role_names 또는 role_ids 중 하나 사용.",
* oneOf={
* @OA\Schema(
* required={"role_names"},
* @OA\Property(property="role_names", type="array", @OA\Items(type="string"), example={"menu-manager"})
* ),
* @OA\Schema(
* required={"role_ids"},
* @OA\Property(property="role_ids", type="array", @OA\Items(type="integer"), example={1})
* )
* }
* )
*/
class UserRoleApi
{
/**
* @OA\Get(
* path="/api/v1/users/{id}/roles",
* summary="사용자의 역할 목록 조회",
* description="해당 사용자에게 현재 부여된 역할 목록을 반환합니다.",
* tags={"UserRole"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer"), example=1),
* @OA\Response(
* response=200,
* description="조회 성공",
* @OA\JsonContent(
* allOf={
* @OA\Schema(ref="#/components/schemas/ApiResponse"),
* @OA\Schema(
* @OA\Property(
* property="data",
* type="array",
* @OA\Items(ref="#/components/schemas/RoleBrief")
* )
* )
* }
* )
* ),
* @OA\Response(response=404, description="사용자 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function index() {}
/**
* @OA\Post(
* path="/api/v1/users/{id}/roles",
* summary="사용자에게 역할 부여",
* description="role_names 또는 role_ids로 여러 역할을 부여합니다.",
* tags={"UserRole"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer"), example=1),
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/UserRoleGrantRequest")),
* @OA\Response(response=200, description="부여 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")),
* @OA\Response(response=404, description="사용자/역할 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function grant() {}
/**
* @OA\Delete(
* path="/api/v1/users/{id}/roles",
* summary="사용자의 역할 회수",
* description="role_names 또는 role_ids로 여러 역할을 회수합니다.",
* tags={"UserRole"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer"), example=1),
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/UserRoleRevokeRequest")),
* @OA\Response(response=200, description="회수 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")),
* @OA\Response(response=404, description="사용자/역할 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function revoke() {}
/**
* @OA\Put(
* path="/api/v1/users/{id}/roles/sync",
* summary="사용자의 역할 동기화(교체)",
* description="전달된 목록으로 사용자의 역할을 완전히 교체합니다.",
* tags={"UserRole"},
* security={{"ApiKeyAuth": {}},{"BearerAuth": {}}},
* @OA\Parameter(name="id", in="path", required=true, @OA\Schema(type="integer"), example=1),
* @OA\RequestBody(required=true, @OA\JsonContent(ref="#/components/schemas/UserRoleSyncRequest")),
* @OA\Response(response=200, description="동기화 성공", @OA\JsonContent(ref="#/components/schemas/ApiResponse")),
* @OA\Response(response=404, description="사용자/역할 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=422, description="검증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=401, description="인증 실패", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=403, description="권한 없음", @OA\JsonContent(ref="#/components/schemas/ErrorResponse")),
* @OA\Response(response=500, description="서버 에러", @OA\JsonContent(ref="#/components/schemas/ErrorResponse"))
* )
*/
public function sync() {}
}

View File

@@ -10,6 +10,8 @@
use App\Http\Middleware\CorsMiddleware;
use App\Http\Middleware\ApiKeyMiddleware;
use App\Http\Middleware\CheckSwaggerAuth;
use App\Http\Middleware\SetSpatieTeamContext;
use App\Http\Middleware\PermMapper;
use App\Http\Middleware\CheckPermission;
$app = Application::configure(basePath: dirname(__DIR__))
@@ -23,9 +25,10 @@
$middleware->append(CorsMiddleware::class);
$middleware->alias([
'auth.apikey' => ApiKeyMiddleware::class,
'auth.apikey' => ApiKeyMiddleware::class, // 인증: apikey + basic auth
'swagger.auth' => CheckSwaggerAuth::class,
'permission' => CheckPermission::class,
'perm.map' => PermMapper::class, // 전처리: 라우트명 → perm 키 생성/주입
'permission' => CheckPermission::class, // 검사: perm 키로 접근 허용/차단
]);
})
->withExceptions(function (Exceptions $exceptions) {

6
config/authz.php Normal file
View File

@@ -0,0 +1,6 @@
<?php
return [
// 메뉴 생성 시 자동으로 만들 권한 액션 세트
'menu_actions' => ['view','create','update','delete','approve', 'export', 'manage'],
];

View File

@@ -0,0 +1,221 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
/**
* 1) MENUS: 권한관리용 필드 추가
* - slug: 메뉴/퍼미션 키 매핑용 고유 식별자 (테넌트 범위 유니크)
* - SoftDeletes + acted-by 컬럼
*/
Schema::table('menus', function (Blueprint $table) {
// slug 추가 (권한 키로 활용) - 테넌트 내 유니크
if (!Schema::hasColumn('menus', 'slug')) {
$table->string('slug', 150)->nullable()->after('name')->comment('메뉴 슬러그(권한 키)');
}
// Soft delete
if (!Schema::hasColumn('menus', 'deleted_at')) {
$table->softDeletes()->comment('소프트삭제 시각');
}
// acted-by (누가 생성/수정/삭제 했는지)
if (!Schema::hasColumn('menus', 'created_by')) {
$table->unsignedBigInteger('created_by')->nullable()->after('updated_at')->comment('생성자 사용자 ID');
}
if (!Schema::hasColumn('menus', 'updated_by')) {
$table->unsignedBigInteger('updated_by')->nullable()->after('created_by')->comment('수정자 사용자 ID');
}
if (!Schema::hasColumn('menus', 'deleted_by')) {
$table->unsignedBigInteger('deleted_by')->nullable()->after('updated_by')->comment('삭제자 사용자 ID');
}
// 인덱스/유니크
// slug는 테넌트 범위에서 유니크 보장
$table->unique(['tenant_id', 'slug'], 'menus_tenant_slug_unique');
$table->index(['tenant_id', 'is_active', 'hidden'], 'menus_tenant_active_hidden_idx');
$table->index(['sort_order'], 'menus_sort_idx');
});
/**
* 2) 부서 테이블
*/
if (!Schema::hasTable('departments')) {
Schema::create('departments', function (Blueprint $table) {
$table->bigIncrements('id')->comment('PK: 부서 ID');
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
$table->string('code', 50)->nullable()->comment('부서 코드');
$table->string('name', 100)->comment('부서명');
$table->string('description', 255)->nullable()->comment('설명');
$table->tinyInteger('is_active')->default(1)->comment('활성여부(1=활성,0=비활성)');
$table->integer('sort_order')->default(0)->comment('정렬순서');
$table->timestamps();
$table->softDeletes();
// acted-by
$table->unsignedBigInteger('created_by')->nullable()->comment('생성자 사용자 ID');
$table->unsignedBigInteger('updated_by')->nullable()->comment('수정자 사용자 ID');
$table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자 사용자 ID');
// 인덱스
$table->unique(['tenant_id', 'code'], 'dept_tenant_code_unique');
$table->index(['tenant_id', 'name'], 'dept_tenant_name_idx');
$table->index(['tenant_id', 'is_active'], 'dept_tenant_active_idx');
});
}
/**
* 3) 부서-사용자 매핑
* - 동일 부서-사용자 중복 매핑 방지(tenant_id 포함)
*/
if (!Schema::hasTable('department_user')) {
Schema::create('department_user', function (Blueprint $table) {
$table->bigIncrements('id')->comment('PK');
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
$table->unsignedBigInteger('department_id')->comment('부서 ID');
$table->unsignedBigInteger('user_id')->comment('사용자 ID');
$table->tinyInteger('is_primary')->default(0)->comment('주 부서 여부(1=Y,0=N)');
$table->timestamp('joined_at')->nullable()->comment('부서 배정일');
$table->timestamp('left_at')->nullable()->comment('부서 이탈일');
$table->timestamps();
$table->softDeletes();
// 인덱스/유니크
$table->unique(['tenant_id', 'department_id', 'user_id'], 'dept_user_unique');
$table->index(['tenant_id', 'user_id'], 'dept_user_user_idx');
$table->index(['tenant_id', 'department_id'], 'dept_user_dept_idx');
});
}
/**
* 4) 부서별 권한 매핑
* - Spatie permissions 테이블의 permission_id와 연결(실 FK는 미사용)
* - ALLOW/DENY는 is_allowed로 표현
* - 필요시 메뉴 단위 범위 제한을 위해 menu_id(옵션)
*/
if (!Schema::hasTable('department_permissions')) {
Schema::create('department_permissions', function (Blueprint $table) {
$table->bigIncrements('id')->comment('PK');
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
$table->unsignedBigInteger('department_id')->comment('부서 ID');
$table->unsignedBigInteger('permission_id')->comment('Spatie permissions.id');
$table->unsignedBigInteger('menu_id')->nullable()->comment('메뉴 ID (선택), 특정 메뉴 범위 권한');
$table->tinyInteger('is_allowed')->default(1)->comment('허용여부(1=ALLOW,0=DENY)');
$table->timestamps();
$table->softDeletes();
// 인덱스/유니크 (동일 권한 중복 방지)
$table->unique(
['tenant_id', 'department_id', 'permission_id', 'menu_id'],
'dept_perm_unique'
);
$table->index(['tenant_id', 'department_id'], 'dept_perm_dept_idx');
$table->index(['tenant_id', 'permission_id'], 'dept_perm_perm_idx');
$table->index(['tenant_id', 'menu_id'], 'dept_perm_menu_idx');
});
}
/**
* 5) 사용자 퍼미션 오버라이드 (개인 단위 허용/차단)
* - 개인 DENY가 최우선 → 해석 레이어에서 우선순위 처리
*/
if (!Schema::hasTable('user_permission_overrides')) {
Schema::create('user_permission_overrides', function (Blueprint $table) {
$table->bigIncrements('id')->comment('PK');
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
$table->unsignedBigInteger('user_id')->comment('사용자 ID');
$table->unsignedBigInteger('permission_id')->comment('Spatie permissions.id');
$table->unsignedBigInteger('menu_id')->nullable()->comment('메뉴 ID (선택)');
$table->tinyInteger('is_allowed')->default(1)->comment('허용여부(1=ALLOW,0=DENY)');
$table->string('reason', 255)->nullable()->comment('사유/메모');
$table->timestamp('effective_from')->nullable()->comment('효력 시작');
$table->timestamp('effective_to')->nullable()->comment('효력 종료');
$table->timestamps();
$table->softDeletes();
// 중복 방지
$table->unique(
['tenant_id', 'user_id', 'permission_id', 'menu_id'],
'user_perm_override_unique'
);
$table->index(['tenant_id', 'user_id'], 'user_perm_user_idx');
$table->index(['tenant_id', 'permission_id'], 'user_perm_perm_idx');
});
}
}
public function down(): void
{
// 생성 테이블 역순 드롭
Schema::dropIfExists('user_permission_overrides');
Schema::dropIfExists('department_permissions');
Schema::dropIfExists('department_user');
Schema::dropIfExists('departments');
// MENUS 원복: 추가했던 컬럼/인덱스 제거
Schema::table('menus', function (Blueprint $table) {
// 인덱스/유니크 먼저 제거
if ($this->indexExists('menus', 'menus_tenant_slug_unique')) {
$table->dropUnique('menus_tenant_slug_unique');
}
if ($this->indexExists('menus', 'menus_tenant_active_hidden_idx')) {
$table->dropIndex('menus_tenant_active_hidden_idx');
}
if ($this->indexExists('menus', 'menus_sort_idx')) {
$table->dropIndex('menus_sort_idx');
}
// 컬럼 제거 (존재 체크는 직접 불가하므로 try-catch는 생략)
if (Schema::hasColumn('menus', 'slug')) {
$table->dropColumn('slug');
}
if (Schema::hasColumn('menus', 'deleted_at')) {
$table->dropSoftDeletes();
}
if (Schema::hasColumn('menus', 'deleted_by')) {
$table->dropColumn('deleted_by');
}
if (Schema::hasColumn('menus', 'updated_by')) {
$table->dropColumn('updated_by');
}
if (Schema::hasColumn('menus', 'created_by')) {
$table->dropColumn('created_by');
}
});
}
/**
* Laravel의 Blueprint에서는 인덱스 존재 체크가 불가하므로
* 스키마 매니저를 직접 조회하는 헬퍼.
*/
private function indexExists(string $table, string $indexName): bool
{
try {
$connection = Schema::getConnection();
$schemaManager = $connection->getDoctrineSchemaManager();
$doctrineTable = $schemaManager->introspectTable(
$connection->getTablePrefix() . $table
);
foreach ($doctrineTable->getIndexes() as $idx) {
if ($idx->getName() === $indexName) {
return true;
}
}
} catch (\Throwable $e) {
// 무시
}
return false;
}
};

View File

@@ -0,0 +1,261 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
// ===== permissions =====
if (Schema::hasTable('permissions')) {
Schema::table('permissions', function (Blueprint $table) {
if (!Schema::hasColumn('permissions', 'guard_name')) {
$table->string('guard_name', 50)->default('api')->after('name');
}
if (!Schema::hasColumn('permissions', 'tenant_id')) {
$table->unsignedBigInteger('tenant_id')->nullable()->after('guard_name');
$table->index(['tenant_id']);
}
});
// unique(name, guard_name, tenant_id)
// 기존 unique가 name 또는 (name, guard_name) 로 있을 수 있으니 안전하게 제거 후 재생성
$this->dropUniqueIfExists('permissions', 'permissions_name_unique');
$this->dropUniqueIfExists('permissions', 'permissions_name_guard_name_unique');
$this->dropUniqueIfExists('permissions', 'uk_permissions_name_guard_tenant');
Schema::table('permissions', function (Blueprint $table) {
$table->unique(['name', 'guard_name', 'tenant_id'], 'uk_permissions_name_guard_tenant');
});
}
// ===== roles =====
if (Schema::hasTable('roles')) {
Schema::table('roles', function (Blueprint $table) {
if (!Schema::hasColumn('roles', 'guard_name')) {
$table->string('guard_name', 50)->default('api')->after('name');
}
if (!Schema::hasColumn('roles', 'tenant_id')) {
// 이미 있으시다 했지만 혹시 없을 경우 대비
$table->unsignedBigInteger('tenant_id')->nullable()->after('guard_name');
}
});
// unique(tenant_id, name, guard_name) 로 교체
$this->dropUniqueIfExists('roles', 'uk_roles_tenant_name');
$this->dropUniqueIfExists('roles', 'roles_name_unique');
$this->dropUniqueIfExists('roles', 'roles_name_guard_name_unique');
$this->dropUniqueIfExists('roles', 'uk_roles_tenant_name_guard');
Schema::table('roles', function (Blueprint $table) {
$table->unique(['tenant_id', 'name', 'guard_name'], 'uk_roles_tenant_name_guard');
});
// 인덱스 보강
$this->createIndexIfNotExists('roles', 'roles_tenant_id_index', ['tenant_id']);
}
// ===== model_has_roles =====
if (Schema::hasTable('model_has_roles')) {
Schema::table('model_has_roles', function (Blueprint $table) {
if (!Schema::hasColumn('model_has_roles', 'tenant_id')) {
$table->unsignedBigInteger('tenant_id')->nullable()->after('model_id');
}
});
// ✅ FK가 role_id를 참조하므로, PK 교체 전에 보조 인덱스 추가
$this->createIndexIfNotExists('model_has_roles', 'mhr_role_id_idx', ['role_id']);
// PK: (role_id, model_id, model_type, tenant_id)
$this->replacePrimaryKey(
'model_has_roles',
['role_id', 'model_id', 'model_type'],
['role_id', 'model_id', 'model_type', 'tenant_id']
);
// 인덱스 (model_id, model_type, tenant_id)
$this->dropIndexIfExists('model_has_roles', 'model_has_roles_model_id_model_type_index');
$this->createIndexIfNotExists(
'model_has_roles',
'model_has_roles_model_id_model_type_tenant_id_index',
['model_id', 'model_type', 'tenant_id']
);
}
// ===== model_has_permissions =====
if (Schema::hasTable('model_has_permissions')) {
Schema::table('model_has_permissions', function (Blueprint $table) {
if (!Schema::hasColumn('model_has_permissions', 'tenant_id')) {
$table->unsignedBigInteger('tenant_id')->nullable()->after('model_id');
}
});
// ✅ FK가 permission_id를 참조하므로, PK 교체 전에 보조 인덱스 추가
$this->createIndexIfNotExists('model_has_permissions', 'mhp_permission_id_idx', ['permission_id']);
// PK: (permission_id, model_id, model_type, tenant_id)
$this->replacePrimaryKey(
'model_has_permissions',
['permission_id', 'model_id', 'model_type'],
['permission_id', 'model_id', 'model_type', 'tenant_id']
);
// 인덱스 (model_id, model_type, tenant_id)
$this->dropIndexIfExists('model_has_permissions', 'model_has_permissions_model_id_model_type_index');
$this->createIndexIfNotExists(
'model_has_permissions',
'model_has_permissions_model_id_model_type_tenant_id_index',
['model_id', 'model_type', 'tenant_id']
);
}
}
public function down(): void
{
// 되돌리기 (가능한 범위에서)
if (Schema::hasTable('model_has_permissions')) {
// PK 원복: (permission_id, model_id, model_type)
$this->replacePrimaryKey('model_has_permissions', ['permission_id', 'model_id', 'model_type', 'tenant_id'], ['permission_id', 'model_id', 'model_type']);
$this->dropIndexIfExists('model_has_permissions', 'model_has_permissions_model_id_model_type_tenant_id_index');
$this->createIndexIfNotExists('model_has_permissions', 'model_has_permissions_model_id_model_type_index', ['model_id', 'model_type']);
if (Schema::hasColumn('model_has_permissions', 'tenant_id')) {
Schema::table('model_has_permissions', function (Blueprint $table) {
$table->dropColumn('tenant_id');
});
}
}
if (Schema::hasTable('model_has_roles')) {
// PK 원복: (role_id, model_id, model_type)
$this->replacePrimaryKey('model_has_roles', ['role_id', 'model_id', 'model_type', 'tenant_id'], ['role_id', 'model_id', 'model_type']);
$this->dropIndexIfExists('model_has_roles', 'model_has_roles_model_id_model_type_tenant_id_index');
$this->createIndexIfNotExists('model_has_roles', 'model_has_roles_model_id_model_type_index', ['model_id', 'model_type']);
if (Schema::hasColumn('model_has_roles', 'tenant_id')) {
Schema::table('model_has_roles', function (Blueprint $table) {
$table->dropColumn('tenant_id');
});
}
}
if (Schema::hasTable('roles')) {
// unique 원복: (tenant_id, name)
$this->dropUniqueIfExists('roles', 'uk_roles_tenant_name_guard');
Schema::table('roles', function (Blueprint $table) {
$table->unique(['tenant_id', 'name'], 'uk_roles_tenant_name');
});
if (Schema::hasColumn('roles', 'guard_name')) {
Schema::table('roles', function (Blueprint $table) {
$table->dropColumn('guard_name');
});
}
}
if (Schema::hasTable('permissions')) {
// unique 원복: (name) 또는 (name, guard_name) 상황에 따라
$this->dropUniqueIfExists('permissions', 'uk_permissions_name_guard_tenant');
Schema::table('permissions', function (Blueprint $table) {
// 최소 name unique로 복원
$table->unique(['name'], 'permissions_name_unique');
});
if (Schema::hasColumn('permissions', 'tenant_id')) {
Schema::table('permissions', function (Blueprint $table) {
$table->dropColumn('tenant_id');
});
}
if (Schema::hasColumn('permissions', 'guard_name')) {
Schema::table('permissions', function (Blueprint $table) {
$table->dropColumn('guard_name');
});
}
}
}
// ---------- helpers ----------
private function dropUniqueIfExists(string $table, string $index): void
{
try {
Schema::table($table, function (Blueprint $t) use ($index) {
$t->dropUnique($index);
});
} catch (\Throwable $e) {
// 무시 (없으면 통과)
}
}
private function dropIndexIfExists(string $table, string $index): void
{
try {
Schema::table($table, function (Blueprint $t) use ($index) {
$t->dropIndex($index);
});
} catch (\Throwable $e) {
// 무시
}
}
private function createIndexIfNotExists(string $table, string $index, array $columns): void
{
// 라라벨은 인덱스 존재 체크 API가 없어 try/catch로 처리
try {
Schema::table($table, function (Blueprint $t) use ($index, $columns) {
$t->index($columns, $index);
});
} catch (\Throwable $e) {
// 이미 있으면 통과
}
}
/**
* 기존 PK를 다른 조합으로 교체
*/
private function replacePrimaryKey(string $table, array $old, array $new): void
{
try {
Schema::table($table, function (Blueprint $t) use ($old) {
$t->dropPrimary($this->primaryNameGuess($table, $old));
});
} catch (\Throwable $e) {
// 일부 DB는 이름 지정 안 하면 실패 → 수동 SQL
$this->dropPrimaryKeyBySql($table);
}
Schema::table($table, function (Blueprint $t) use ($new) {
$t->primary($new);
});
}
private function primaryNameGuess(string $table, array $cols): string
{
// 보통 {table}_primary 이지만, 드라이버/버전에 따라 다를 수 있어 try/catch로 대체
return "{$table}_primary";
}
private function dropPrimaryKeyBySql(string $table): void
{
$driver = DB::getDriverName();
if ($driver === 'mysql') {
DB::statement("ALTER TABLE `{$table}` DROP PRIMARY KEY");
} elseif ($driver === 'pgsql') {
// PostgreSQL은 PK 제약명 조회 후 드롭이 필요. 간단히 시도:
$constraint = DB::selectOne("
SELECT constraint_name
FROM information_schema.table_constraints
WHERE table_name = ? AND constraint_type = 'PRIMARY KEY'
LIMIT 1
", [$table]);
if ($constraint && isset($constraint->constraint_name)) {
DB::statement("ALTER TABLE \"{$table}\" DROP CONSTRAINT \"{$constraint->constraint_name}\"");
}
} else {
// 기타 드라이버는 수동 처리 생략
}
}
};

View File

@@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration {
public function up(): void
{
// 1) 유니크 인덱스 존재 시 제거 (information_schema로 확인)
$hasUnique = DB::table('information_schema.statistics')
->whereRaw('TABLE_SCHEMA = DATABASE()')
->where('TABLE_NAME', 'menus')
->where('INDEX_NAME', 'menus_tenant_slug_unique')
->exists();
if ($hasUnique) {
Schema::table('menus', function (Blueprint $table) {
$table->dropUnique('menus_tenant_slug_unique');
});
}
// 2) slug 컬럼 존재 시 제거
if (Schema::hasColumn('menus', 'slug')) {
Schema::table('menus', function (Blueprint $table) {
$table->dropColumn('slug');
});
}
}
public function down(): void
{
// 1) slug 컬럼 복구
if (!Schema::hasColumn('menus', 'slug')) {
Schema::table('menus', function (Blueprint $table) {
$table->string('slug', 150)->nullable()->comment('메뉴 슬러그(권한 키)');
});
}
// 2) 유니크 인덱스 복구 (없을 때만)
$hasUnique = DB::table('information_schema.statistics')
->whereRaw('TABLE_SCHEMA = DATABASE()')
->where('TABLE_NAME', 'menus')
->where('INDEX_NAME', 'menus_tenant_slug_unique')
->exists();
if (! $hasUnique) {
Schema::table('menus', function (Blueprint $table) {
$table->unique(['tenant_id', 'slug'], 'menus_tenant_slug_unique');
});
}
}
};

View File

@@ -1,52 +1,71 @@
<!doctype html> <!-- Important: must specify -->
<!doctype html>
<html>
<head>
<meta charset="utf-8"> <!-- Important: rapi-doc uses utf8 characters -->
<meta charset="utf-8">
<script type="module" src="https://unpkg.com/rapidoc/dist/rapidoc-min.js"></script>
</head>
<body>
<rapi-doc
id="doc"
spec-url="/docs?api-docs.json"
render-style="focused"
theme="light"
show-method-in-nav-bar="as-colored-block"
show-header="false"
allow-server-selection="true"
allow-search="true"
allow-advanced-search="true"
allow-server-override="true"
allow-authentication="true"
allow-api-list-style-selection="true"
show-info="true"
show-components="true"
show-try="true"
show-path-in-nav-bar="true"
bg-color="#f8f9fa"
primary-color="#206a9e"
nav-bg-color="#2d3e50"
nav-text-color="#f5f5f5"
nav-hover-bg-color="#496080"
nav-active-bg-color="#206a9e"
nav-item-spacing="compact"
font-size="small"
default-schema-tab="example"
style="width:100vw; height:100vh;"
></rapi-doc>
<script type="module">
const doc = document.getElementById('doc');
// 스펙이 로드되어 컴포넌트 내부가 그려진 뒤 실행
doc.addEventListener('spec-loaded', () => {
const root = doc.shadowRoot;
if (!root) return;
// 1) collapse-all 버튼이 있으면 클릭
const collapseBtn =
root.querySelector('[data-action="collapse-all"]') ||
root.querySelector('[title="Collapse all"]') ||
root.querySelector('.collapse-all');
if (collapseBtn) {
collapseBtn.click();
return;
}
// 2) 버튼이 없으면 펼쳐진 섹션 헤더들을 클릭해서 접기 (폴백)
const selectors = [
'[expanded] .tag-head',
'.tag.is-expanded .tag-head',
'[expanded] .section-endpoint-head',
'.section.is-expanded .section-endpoint-head',
'[aria-expanded="true"] .tag-head'
];
const collapseAll = () => {
selectors.forEach(sel => {
root.querySelectorAll(sel).forEach(el => el.click());
});
};
collapseAll();
// 느린 렌더 케이스 대비, 살짝 늦게 한 번 더
setTimeout(collapseAll, 200);
});
</script>
</body>
</html>
<!--
render-style="focused"
layout="column"
show-header="true"
allow-server-selection="true"
allow-search="true"
allow-advanced-search="true"
allow-server-override="true"
allow-authentication="true"
allow-api-list-style-selection="true"
bg-color="#f8f9fa"
primary-color="#205081"
nav-bg-color="#2d3e50"
nav-text-color="#f5f5f5"
nav-hover-bg-color="#496080"
nav-active-bg-color="#205081"
font-size="medium"
default-schema-tab="schema"
style="width:100vw; height:100vh;"
-->

View File

@@ -4,7 +4,6 @@
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\V1\CommonController;
use App\Http\Controllers\Api\V1\ApiController;
use App\Http\Controllers\Api\V1\AdminApiController;
use App\Http\Controllers\Api\V1\FileController;
use App\Http\Controllers\Api\V1\ProductController;
use App\Http\Controllers\Api\V1\MaterialController;
@@ -13,6 +12,11 @@
use App\Http\Controllers\Api\V1\UserController;
use App\Http\Controllers\Api\V1\TenantController;
use App\Http\Controllers\Api\V1\AdminController;
use App\Http\Controllers\Api\V1\MenuController;
use App\Http\Controllers\Api\V1\RoleController;
use App\Http\Controllers\Api\V1\RolePermissionController;
use App\Http\Controllers\Api\V1\UserRoleController;
use App\Http\Controllers\Api\V1\DepartmentController;
// error test
Route::get('/test-error', function () {
@@ -33,10 +37,6 @@
Route::middleware('auth:sanctum')->post('/logout', [ApiController::class, 'logout']);
// Admin API
Route::post('admin/list', [AdminApiController::class, 'list'])->middleware('permission:SR'); // 관리자 리스트 조회
// Common API
Route::prefix('common')->group(function () {
Route::get('code', [CommonController::class, 'getComeCode'])->name('v1.common.code'); // 공통코드 조회
@@ -114,30 +114,66 @@
Route::resource('models', ModelController::class)->except(['v1.create', 'edit']); // 모델관리
Route::resource('boms', BomController::class)->except(['v1.create', 'edit']); // BOM관리
});
// Menu API
Route::middleware(['perm.map', 'permission'])->prefix('menus')->group(function () {
Route::get ('/', [MenuController::class, 'index'])->name('v1.menus.index');
Route::get ('/{id}', [MenuController::class, 'show'])->name('v1.menus.show');
Route::post ('/', [MenuController::class, 'store'])->name('v1.menus.store');
Route::patch ('/{id}', [MenuController::class, 'update'])->name('v1.menus.update');
Route::delete('/{id}', [MenuController::class, 'destroy'])->name('v1.menus.destroy');
Route::post ('/reorder', [MenuController::class, 'reorder'])->name('v1.menus.reorder');
Route::post ('/{id}/toggle', [MenuController::class, 'toggle'])->name('v1.menus.toggle');
});
// Role API
Route::prefix('roles')->group(function () {
Route::get ('/', [RoleController::class, 'index'])->name('v1.roles.index'); // view
Route::post ('/', [RoleController::class, 'store'])->name('v1.roles.store'); // create
Route::get ('/{id}', [RoleController::class, 'show'])->name('v1.roles.show'); // view
Route::patch ('/{id}', [RoleController::class, 'update'])->name('v1.roles.update'); // update
Route::delete('/{id}', [RoleController::class, 'destroy'])->name('v1.roles.destroy');// delete
});
/*
|--------------------------------------------------------------------------
| V1 - Admin 영역 (관리자 전용 사용자 관리)
|--------------------------------------------------------------------------
| Swagger: AdminApi.php
| - GET /api/v1/admin/users
| - POST /api/v1/admin/users
| - GET /api/v1/admin/users/{id}
| - PUT /api/v1/admin/users/{id}
| - PATCH /api/v1/admin/users/{id}/status
| - DELETE /api/v1/admin/users/{id}
| - POST /api/v1/admin/users/{id}/restore
| - POST /api/v1/admin/users/{id}/roles
| - DELETE /api/v1/admin/users/{id}/roles/{role}
| - POST /api/v1/admin/users/{id}/reset-password
*/
Route::prefix('v1_DEV/admin')
->middleware(['apikey', 'auth:sanctum', 'can:admin']) // 예: 'can:admin' 또는 커스텀 'is_admin'
->group(function () {
// Role Permission API
Route::prefix('roles/{id}/permissions')->group(function () {
Route::get ('/', [RolePermissionController::class, 'index'])->name('v1.roles.perms.index'); // list
Route::post ('/', [RolePermissionController::class, 'grant'])->name('v1.roles.perms.grant'); // grant
Route::delete('/', [RolePermissionController::class, 'revoke'])->name('v1.roles.perms.revoke'); // revoke
Route::put ('/sync', [RolePermissionController::class, 'sync'])->name('v1.roles.perms.sync'); // sync
});
// User Role API
Route::prefix('users/{id}/roles')->group(function () {
Route::get ('/', [UserRoleController::class, 'index'])->name('v1.users.roles.index'); // list
Route::post ('/', [UserRoleController::class, 'grant'])->name('v1.users.roles.grant'); // grant
Route::delete('/', [UserRoleController::class, 'revoke'])->name('v1.users.roles.revoke'); // revoke
Route::put ('/sync', [UserRoleController::class, 'sync'])->name('v1.users.roles.sync'); // sync
});
// Department API
Route::prefix('departments')->group(function () {
Route::get ('', [DepartmentController::class, 'index'])->name('departments.index'); // 목록
Route::post ('', [DepartmentController::class, 'store'])->name('departments.store'); // 생성
Route::get ('/{id}', [DepartmentController::class, 'show'])->name('departments.show'); // 단건
Route::patch ('/{id}', [DepartmentController::class, 'update'])->name('departments.update'); // 수정
Route::delete('/{id}', [DepartmentController::class, 'destroy'])->name('departments.destroy'); // 삭제(soft)
// 부서-사용자
Route::get ('/{id}/users', [DepartmentController::class, 'listUsers'])->name('departments.users.index'); // 부서 사용자 목록
Route::post ('/{id}/users', [DepartmentController::class, 'attachUser'])->name('departments.users.attach'); // 사용자 배정(주/부서)
Route::delete('/{id}/users/{user}', [DepartmentController::class, 'detachUser'])->name('departments.users.detach'); // 사용자 제거
Route::patch ('/{id}/users/{user}/primary', [DepartmentController::class, 'setPrimary'])->name('departments.users.primary'); // 주부서 설정/해제
// 부서-권한
Route::get ('/{id}/permissions', [DepartmentController::class, 'listPermissions'])->name('departments.permissions.index'); // 권한 목록
Route::post ('/{id}/permissions', [DepartmentController::class, 'upsertPermission'])->name('departments.permissions.upsert'); // 권한 부여/차단(메뉴별 가능)
Route::delete('/{id}/permissions/{permission}', [DepartmentController::class, 'revokePermission'])->name('departments.permissions.revoke'); // 권한 제거(해당 메뉴 범위까지)
});
});
});