Files
sam-manage/app/Services/UserService.php
김보곤 9b36052a8f fix: [user] 사용자 영구삭제 시 FK 제약 위반 500 에러 수정
- users.id를 참조하는 모든 FK를 information_schema에서 동적 조회
- NULLABLE FK → NULL 설정, NOT NULL FK → 관련 행 삭제
- 기존 5개 테이블만 처리하던 것을 전체 FK 대응으로 확장
2026-03-03 19:58:07 +09:00

602 lines
22 KiB
PHP

<?php
namespace App\Services;
use App\Mail\UserPasswordMail;
use App\Models\DepartmentUser;
use App\Models\User;
use App\Models\UserRole;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
class UserService
{
public function __construct(
private readonly ArchiveService $archiveService
) {}
/**
* 사용자 목록 조회 (페이지네이션)
*/
public function getUsers(array $filters = [], int $perPage = 15): LengthAwarePaginator
{
$tenantId = session('selected_tenant_id');
$query = User::query()->select('users.*')->withTrashed();
// 슈퍼관리자 보호: 일반관리자는 슈퍼관리자를 볼 수 없음
if (! auth()->user()?->is_super_admin) {
$query->where('is_super_admin', false);
}
// 역할/부서/테넌트 관계 eager loading (테넌트별)
if ($tenantId) {
$query->with([
'userRoles' => fn ($q) => $q->where('tenant_id', $tenantId)->with('role'),
'departmentUsers' => fn ($q) => $q->where('tenant_id', $tenantId)->with('department'),
'tenants',
]);
// 재직상태 서브쿼리
$query->addSelect(['employee_status' => DB::table('tenant_user_profiles')
->select('employee_status')
->whereColumn('user_id', 'users.id')
->where('tenant_id', $tenantId)
->limit(1),
]);
} else {
$query->with(['tenants']);
}
// 테넌트 필터링 (user_tenants pivot을 통한 필터링)
if ($tenantId) {
$query->whereHas('tenants', function ($q) use ($tenantId) {
$q->where('tenants.id', $tenantId);
});
}
// Soft Delete 필터
if (isset($filters['trashed'])) {
if ($filters['trashed'] === 'only') {
$query->onlyTrashed();
} elseif ($filters['trashed'] === 'with') {
$query->withTrashed();
}
}
// 검색 필터
if (! empty($filters['search'])) {
$search = $filters['search'];
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")
->orWhere('phone', 'like', "%{$search}%")
->orWhere('user_id', 'like', "%{$search}%");
});
}
// 활성 상태 필터
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
$query->where('is_active', $filters['is_active']);
}
// 재직상태 필터 (tenant_user_profiles.employee_status)
if (! empty($filters['employee_status']) && $tenantId) {
$status = $filters['employee_status'];
$query->whereExists(function ($sub) use ($tenantId, $status) {
$sub->select(DB::raw(1))
->from('tenant_user_profiles')
->whereColumn('tenant_user_profiles.user_id', 'users.id')
->where('tenant_user_profiles.tenant_id', $tenantId)
->where('tenant_user_profiles.employee_status', $status);
});
}
return $query->orderBy('created_at', 'desc')->paginate($perPage);
}
/**
* 사용자 상세 조회
*/
public function getUserById(int $id): ?User
{
return User::find($id);
}
/**
* 사용자 생성
* - 본사(HQ): 임의 비밀번호 생성 + 메일 발송
* - 비본사: 입력된 비밀번호 사용 (메일 발송 안 함)
*/
public function createUser(array $data): User
{
$tenantId = session('selected_tenant_id');
// 비밀번호 처리: 입력된 비밀번호가 있으면 사용, 없으면 자동 생성
$passwordProvided = ! empty($data['password']);
if ($passwordProvided) {
// 비본사: 입력된 비밀번호 사용
$data['password'] = Hash::make($data['password']);
$plainPassword = null; // 메일 발송하지 않음
} else {
// 본사: 임의 비밀번호 생성 (8자리 영문+숫자)
$plainPassword = $this->generateRandomPassword();
$data['password'] = Hash::make($plainPassword);
}
// User 모델의 fillable이 아닌 필드 분리
unset($data['password_confirmation']);
$positionKey = $data['position_key'] ?? null;
$jobTitleKey = $data['job_title_key'] ?? null;
unset($data['position_key'], $data['job_title_key']);
// is_active 처리
$data['is_active'] = isset($data['is_active']) && $data['is_active'] == '1';
// 최초 로그인 시 비밀번호 변경 필요
$data['must_change_password'] = true;
// 생성자 정보
$data['created_by'] = auth()->id();
// 사용자 생성
$user = User::create($data);
// user_tenants pivot에 관계 추가
if ($tenantId) {
$user->tenants()->attach($tenantId, [
'is_active' => true,
'is_default' => true,
'joined_at' => now(),
]);
// 역할/부서 동기화
$roleIds = $data['role_ids'] ?? [];
$departmentIds = $data['department_ids'] ?? [];
$this->syncRoles($user, $tenantId, $roleIds);
$this->syncDepartments($user, $tenantId, $departmentIds);
}
// position_key, job_title_key → tenant_user_profiles 저장
if ($tenantId) {
$profileFields = [];
if (! empty($positionKey)) {
$profileFields['position_key'] = $positionKey;
}
if (! empty($jobTitleKey)) {
$profileFields['job_title_key'] = $jobTitleKey;
}
if (! empty($profileFields)) {
DB::table('tenant_user_profiles')->updateOrInsert(
['tenant_id' => $tenantId, 'user_id' => $user->id],
$profileFields
);
}
}
// 본사만 비밀번호 안내 메일 발송 (비본사는 관리자가 직접 알려줌)
if ($plainPassword !== null) {
$this->sendPasswordMail($user, $plainPassword, true);
}
return $user;
}
/**
* 비밀번호 초기화 (관리자용: 임의 비밀번호 생성 + 메일 발송)
*/
public function resetPassword(int $id): bool
{
$user = $this->getUserById($id);
if (! $user) {
return false;
}
// 임의 비밀번호 생성
$plainPassword = $this->generateRandomPassword();
// 비밀번호 업데이트 + 비밀번호 변경 필요 플래그
$user->password = Hash::make($plainPassword);
$user->must_change_password = true;
$user->updated_by = auth()->id();
$user->save();
// 비밀번호 초기화 안내 메일 발송
$this->sendPasswordMail($user, $plainPassword, false);
return true;
}
/**
* 임의 비밀번호 생성 (8자리 영문+숫자 조합)
*/
private function generateRandomPassword(int $length = 8): string
{
// 영문 대소문자 + 숫자 조합으로 가독성 좋은 비밀번호 생성
// 혼동되는 문자 제외: 0, O, l, 1, I
$chars = 'abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789';
return substr(str_shuffle(str_repeat($chars, 3)), 0, $length);
}
/**
* 비밀번호 안내 메일 발송
*/
private function sendPasswordMail(User $user, string $password, bool $isNewUser): void
{
Mail::to($user->email)->send(new UserPasswordMail($user, $password, $isNewUser));
}
/**
* 사용자 수정
*/
public function updateUser(int $id, array $data): bool
{
$user = $this->getUserById($id);
if (! $user) {
return false;
}
$tenantId = session('selected_tenant_id');
// 비밀번호가 입력된 경우만 업데이트
if (! empty($data['password'])) {
$data['password'] = Hash::make($data['password']);
} else {
unset($data['password']);
}
// is_active 처리
$data['is_active'] = isset($data['is_active']) && $data['is_active'] == '1';
// is_super_admin 처리 (슈퍼관리자만 수정 가능하므로 validated 데이터에 있을 때만)
if (array_key_exists('is_super_admin', $data)) {
$data['is_super_admin'] = $data['is_super_admin'] == '1';
}
// 수정자 정보
$data['updated_by'] = auth()->id();
// 역할/부서 동기화 (테넌트가 선택된 경우)
if ($tenantId) {
$roleIds = $data['role_ids'] ?? [];
$departmentIds = $data['department_ids'] ?? [];
$this->syncRoles($user, $tenantId, $roleIds);
$this->syncDepartments($user, $tenantId, $departmentIds);
}
// position_key, job_title_key, employee_status → tenant_user_profiles 저장
if ($tenantId) {
$profileFields = [];
if (array_key_exists('position_key', $data)) {
$profileFields['position_key'] = $data['position_key'] ?: null;
}
if (array_key_exists('job_title_key', $data)) {
$profileFields['job_title_key'] = $data['job_title_key'] ?: null;
}
if (array_key_exists('employee_status', $data)) {
$profileFields['employee_status'] = $data['employee_status'] ?: 'active';
}
if (array_key_exists('department_id', $data)) {
$profileFields['department_id'] = $data['department_id'] ?: null;
}
if (! empty($profileFields)) {
DB::table('tenant_user_profiles')->updateOrInsert(
['tenant_id' => $tenantId, 'user_id' => $id],
$profileFields
);
}
}
// role_ids, department_ids, position/job_title은 User 모델의 fillable이 아니므로 제거
unset($data['role_ids'], $data['department_ids'], $data['position_key'], $data['job_title_key'], $data['employee_status'], $data['department_id']);
return $user->update($data);
}
/**
* 사용자 역할 동기화 (특정 테넌트)
*/
public function syncRoles(User $user, int $tenantId, array $roleIds): void
{
// 기존 역할 삭제 (해당 테넌트만) - forceDelete로 실제 삭제
UserRole::withTrashed()
->where('user_id', $user->id)
->where('tenant_id', $tenantId)
->forceDelete();
// 새 역할 추가
foreach ($roleIds as $roleId) {
UserRole::create([
'user_id' => $user->id,
'tenant_id' => $tenantId,
'role_id' => $roleId,
'assigned_at' => now(),
]);
}
}
/**
* 사용자 부서 동기화 (특정 테넌트)
*/
public function syncDepartments(User $user, int $tenantId, array $departmentIds): void
{
// 기존 부서 삭제 (해당 테넌트만) - forceDelete로 실제 삭제
DepartmentUser::withTrashed()
->where('user_id', $user->id)
->where('tenant_id', $tenantId)
->forceDelete();
// 새 부서 추가 (첫 번째를 primary로)
foreach ($departmentIds as $index => $departmentId) {
DepartmentUser::create([
'user_id' => $user->id,
'tenant_id' => $tenantId,
'department_id' => $departmentId,
'is_primary' => $index === 0,
'joined_at' => now(),
'created_by' => auth()->id(),
]);
}
}
/**
* 사용자 삭제 (Soft Delete)
*/
public function deleteUser(int $id): bool
{
$user = $this->getUserById($id);
if (! $user) {
return false;
}
$user->deleted_by = auth()->id();
$user->save();
return $user->delete();
}
/**
* 사용자 복원
*/
public function restoreUser(int $id): bool
{
$user = User::onlyTrashed()->findOrFail($id);
return $user->restore();
}
/**
* 사용자 영구 삭제 (슈퍼관리자 전용)
*
* 1. 사용자 데이터를 아카이브에 저장
* 2. 관련 데이터 삭제
* 3. 사용자 영구 삭제
*/
public function forceDeleteUser(int $id): bool
{
$user = User::withTrashed()->findOrFail($id);
return DB::transaction(function () use ($user) {
// 1. 아카이브에 저장 (복원 가능하도록)
$this->archiveService->archiveUser($user);
// 2. 피벗/소유 테이블 삭제
$user->tenants()->detach();
DB::table('user_roles')->where('user_id', $user->id)->delete();
DB::table('department_user')->where('user_id', $user->id)->delete();
DB::table('sales_partners')->where('user_id', $user->id)->delete();
DB::table('sales_manager_documents')->where('user_id', $user->id)->delete();
DB::table('login_tokens')->where('user_id', $user->id)->delete();
DB::table('notification_settings')->where('user_id', $user->id)->delete();
DB::table('notification_setting_group_states')->where('user_id', $user->id)->delete();
// 3. 하위 사용자의 parent_id 해제
User::where('parent_id', $user->id)->update(['parent_id' => null]);
// 4. DB에서 users.id를 참조하는 모든 FK를 동적 조회 → NULL 또는 삭제
$dbName = DB::getDatabaseName();
$fkRefs = DB::select("
SELECT kcu.TABLE_NAME, kcu.COLUMN_NAME, c.IS_NULLABLE
FROM information_schema.KEY_COLUMN_USAGE kcu
JOIN information_schema.COLUMNS c
ON c.TABLE_SCHEMA = kcu.TABLE_SCHEMA
AND c.TABLE_NAME = kcu.TABLE_NAME
AND c.COLUMN_NAME = kcu.COLUMN_NAME
WHERE kcu.REFERENCED_TABLE_NAME = 'users'
AND kcu.REFERENCED_COLUMN_NAME = 'id'
AND kcu.TABLE_SCHEMA = ?
", [$dbName]);
$skipTables = ['user_tenants', 'user_roles', 'department_user', 'sales_partners', 'sales_manager_documents', 'login_tokens', 'notification_settings', 'notification_setting_group_states', 'users'];
foreach ($fkRefs as $ref) {
if (in_array($ref->TABLE_NAME, $skipTables)) {
continue;
}
if ($ref->IS_NULLABLE === 'YES') {
DB::table($ref->TABLE_NAME)->where($ref->COLUMN_NAME, $user->id)->update([$ref->COLUMN_NAME => null]);
} else {
DB::table($ref->TABLE_NAME)->where($ref->COLUMN_NAME, $user->id)->delete();
}
}
// 5. 사용자 영구 삭제
return $user->forceDelete();
});
}
/**
* 모달용 사용자 상세 정보 조회
*/
public function getUserForModal(int $id): ?User
{
$tenantId = session('selected_tenant_id');
$query = User::query()
->with('deletedByUser')
->withTrashed();
// 역할/부서 관계 eager loading (테넌트별)
if ($tenantId) {
$query->with([
'userRoles' => fn ($q) => $q->where('tenant_id', $tenantId)->with('role'),
'departmentUsers' => fn ($q) => $q->where('tenant_id', $tenantId)->with('department'),
'tenants',
]);
} else {
$query->with(['userRoles.role', 'departmentUsers.department', 'tenants']);
}
$user = $query->find($id);
// 권한 카운트 추가
if ($user && $tenantId) {
$permissionCounts = $this->getUserPermissionCounts($user->id, $tenantId);
$user->web_permission_count = $permissionCounts['web'];
$user->api_permission_count = $permissionCounts['api'];
}
return $user;
}
/**
* 사용자별 guard별 권한 개수 조회 (역할 + 부서 + 개인 오버라이드 통합)
*/
private function getUserPermissionCounts(int $userId, int $tenantId): array
{
$result = ['web' => 0, 'api' => 0];
$now = now();
foreach (['web', 'api'] as $guardName) {
// 1. 역할 권한
$rolePermissions = \DB::table('model_has_roles as mhr')
->join('role_has_permissions as rhp', 'rhp.role_id', '=', 'mhr.role_id')
->join('permissions as p', 'p.id', '=', 'rhp.permission_id')
->where('mhr.model_type', User::class)
->where('mhr.model_id', $userId)
->where('p.guard_name', $guardName)
->where('p.name', 'like', 'menu:%')
->pluck('p.name')
->toArray();
// 2. 부서 권한
$deptPermissions = \DB::table('department_user as du')
->join('permission_overrides as po', function ($j) use ($now, $tenantId) {
$j->on('po.model_id', '=', 'du.department_id')
->where('po.model_type', 'App\\Models\\Tenants\\Department')
->where('po.tenant_id', $tenantId)
->whereNull('po.deleted_at')
->where('po.effect', 1)
->where(function ($w) use ($now) {
$w->whereNull('po.effective_from')->orWhere('po.effective_from', '<=', $now);
})
->where(function ($w) use ($now) {
$w->whereNull('po.effective_to')->orWhere('po.effective_to', '>=', $now);
});
})
->join('permissions as p', 'p.id', '=', 'po.permission_id')
->whereNull('du.deleted_at')
->where('du.user_id', $userId)
->where('du.tenant_id', $tenantId)
->where('p.guard_name', $guardName)
->where('p.name', 'like', 'menu:%')
->pluck('p.name')
->toArray();
// 3. 개인 오버라이드 (ALLOW)
$personalAllows = \DB::table('permission_overrides as po')
->join('permissions as p', 'p.id', '=', 'po.permission_id')
->where('po.model_type', User::class)
->where('po.model_id', $userId)
->where('po.tenant_id', $tenantId)
->where('po.effect', 1)
->whereNull('po.deleted_at')
->where(function ($w) use ($now) {
$w->whereNull('po.effective_from')->orWhere('po.effective_from', '<=', $now);
})
->where(function ($w) use ($now) {
$w->whereNull('po.effective_to')->orWhere('po.effective_to', '>=', $now);
})
->where('p.guard_name', $guardName)
->where('p.name', 'like', 'menu:%')
->pluck('p.name')
->toArray();
// 4. 개인 오버라이드 (DENY) - 제외할 권한
$personalDenies = \DB::table('permission_overrides as po')
->join('permissions as p', 'p.id', '=', 'po.permission_id')
->where('po.model_type', User::class)
->where('po.model_id', $userId)
->where('po.tenant_id', $tenantId)
->where('po.effect', 0)
->whereNull('po.deleted_at')
->where(function ($w) use ($now) {
$w->whereNull('po.effective_from')->orWhere('po.effective_from', '<=', $now);
})
->where(function ($w) use ($now) {
$w->whereNull('po.effective_to')->orWhere('po.effective_to', '>=', $now);
})
->where('p.guard_name', $guardName)
->where('p.name', 'like', 'menu:%')
->pluck('p.name')
->toArray();
// 통합: (역할 OR 부서 OR 개인ALLOW) - 개인DENY
$allAllowed = array_unique(array_merge($rolePermissions, $deptPermissions, $personalAllows));
$effectivePermissions = array_diff($allAllowed, $personalDenies);
$result[$guardName] = count($effectivePermissions);
}
return $result;
}
/**
* 활성 사용자 목록 조회 (드롭다운용)
*/
public function getActiveUsers()
{
$tenantId = session('selected_tenant_id');
$query = User::query()->where('is_active', true);
// 슈퍼관리자 보호: 일반관리자는 슈퍼관리자를 볼 수 없음
if (! auth()->user()?->is_super_admin) {
$query->where('is_super_admin', false);
}
// 테넌트 필터링 (user_tenants pivot을 통한 필터링)
if ($tenantId) {
$query->whereHas('tenants', function ($q) use ($tenantId) {
$q->where('tenants.id', $tenantId);
});
}
return $query->orderBy('name')->get();
}
/**
* 슈퍼관리자 보호: 일반관리자가 슈퍼관리자에 접근할 수 있는지 확인
*
* @param int $targetUserId 대상 사용자 ID
* @return bool true면 접근 가능, false면 접근 불가
*/
public function canAccessUser(int $targetUserId): bool
{
// withTrashed()를 사용하여 soft-deleted 사용자도 확인 (복원 시 필요)
$targetUser = User::withTrashed()->find($targetUserId);
$currentUser = auth()->user();
// 대상 사용자가 슈퍼관리자이고 현재 사용자가 슈퍼관리자가 아니면 접근 불가
if ($targetUser?->is_super_admin && ! $currentUser?->is_super_admin) {
return false;
}
return true;
}
}