> */ private array $colsCache = []; /** @var array */ private array $colTypeCache = []; // ========================= // Basic Find // ========================= public function findByEmail(string $email): ?AdminUser { return AdminUser::query() ->where('email', $email) ->first(); } public function findActiveById(int $id): ?AdminUser { return AdminUser::query() ->whereKey($id) ->where('status', 'active') ->first(); } public function find(int $id): ?object { return DB::table('admin_users')->where('id', $id)->first(); } // ========================= // Login Touch // ========================= public function touchLogin(AdminUser $admin, string $ip, ?string $ua = null): void { $data = [ 'last_login_at' => now(), ]; // last_login_ip 타입이 BINARY일 수도, VARCHAR일 수도 있어서 타입별 처리 if ($this->hasCol('admin_users', 'last_login_ip')) { $type = $this->getColumnType('admin_users', 'last_login_ip'); // e.g. binary, varbinary, varchar... if ($type && (str_contains($type, 'binary') || str_contains($type, 'blob'))) { $data['last_login_ip'] = inet_pton($ip) ?: null; } else { $data['last_login_ip'] = $ip ?: null; } } if ($ua !== null && $this->hasCol('admin_users', 'last_login_ua')) { $data['last_login_ua'] = $ua; } $this->save($admin, $data); } // ========================= // Password // ========================= public function setPassword(AdminUser $admin, string $plainPassword): void { // AdminUser 모델에 password cast(hashed)가 있으면 plain을 넣어도 해싱됨 $data = [ 'password' => $plainPassword, 'must_reset_password' => 0, 'password_changed_at' => now(), ]; $this->save($admin, $data); } public function setTemporaryPassword(AdminUser $admin, string $plainPassword, int $actorId = 0): void { $data = [ 'password' => $plainPassword, // 모델 cast가 해싱 'must_reset_password' => 1, 'password_changed_at' => now(), 'updated_by' => $actorId ?: null, ]; $this->save($admin, $data); } // ========================= // Update (DB safe) // ========================= public function updateById(int $id, array $data): bool { $data['updated_at'] = $data['updated_at'] ?? now(); $data = $this->filterToExistingColumns('admin_users', $data); return DB::table('admin_users') ->where('id', $id) ->update($data) > 0; } /** 기존 코드 호환용 */ public function update(int $id, array $data): bool { return $this->updateById($id, $data); } public function save(AdminUser $admin, array $data): void { $data = $this->filterToExistingColumns('admin_users', $data); foreach ($data as $k => $v) { $admin->{$k} = $v; } $admin->save(); } public function existsPhoneHash(string $hash, int $ignoreId): bool { return DB::table('admin_users') ->whereNull('deleted_at') ->where('phone_hash', $hash) ->where('id', '!=', $ignoreId) ->exists(); } // ========================= // List / Search // ========================= public function paginateUsers(array $filters, int $perPage = 20): LengthAwarePaginator { $q = AdminUser::query(); $keyword = trim((string)($filters['q'] ?? '')); if ($keyword !== '') { $q->where(function ($w) use ($keyword) { if ($this->hasCol('admin_users', 'email')) $w->orWhere('email', 'like', "%{$keyword}%"); if ($this->hasCol('admin_users', 'name')) $w->orWhere('name', 'like', "%{$keyword}%"); if ($this->hasCol('admin_users', 'nickname')) $w->orWhere('nickname', 'like', "%{$keyword}%"); }); } $status = (string)($filters['status'] ?? ''); if ($status !== '' && $this->hasCol('admin_users', 'status')) { $q->where('status', $status); } $role = (string)($filters['role'] ?? ''); if ($role !== '' && Schema::hasTable('admin_role_user') && Schema::hasTable('admin_roles')) { $roleNameCol = $this->firstExistingColumn('admin_roles', ['name','code','slug']); if ($roleNameCol) { $q->whereIn('id', function ($sub) use ($role, $roleNameCol) { $sub->select('aru.admin_user_id') ->from('admin_role_user as aru') ->join('admin_roles as r', 'r.id', '=', 'aru.admin_role_id') ->where("r.{$roleNameCol}", $role); }); } } if ($this->hasCol('admin_users', 'last_login_at')) { $q->orderByDesc('last_login_at'); } else { $q->orderByDesc('id'); } return $q->paginate($perPage)->withQueryString(); } // ========================= // Roles / Permissions // (현재 DB: roles.name/label, perms.name/label 기반도 지원) // ========================= public function getAllRoles(): array { if (!Schema::hasTable('admin_roles')) return []; $codeCol = $this->firstExistingColumn('admin_roles', ['code','name','slug']); $nameCol = $this->firstExistingColumn('admin_roles', ['label','title','name']); return DB::table('admin_roles') ->orderBy('id') ->get([ 'id', DB::raw(($codeCol ? "`{$codeCol}`" : "CAST(id AS CHAR)") . " as code"), DB::raw(($nameCol ? "`{$nameCol}`" : "CAST(id AS CHAR)") . " as name"), ]) ->map(fn($r)=>(array)$r) ->all(); } public function findRoleIdByCode(string $code): ?int { if (!Schema::hasTable('admin_roles')) return null; $codeCol = $this->firstExistingColumn('admin_roles', ['code','name','slug']); if (!$codeCol) return null; $id = DB::table('admin_roles')->where($codeCol, $code)->value('id'); return $id ? (int)$id : null; } public function getRoleIdsForUser(int $adminUserId): array { if (!Schema::hasTable('admin_role_user')) return []; return DB::table('admin_role_user') ->where('admin_user_id', $adminUserId) ->pluck('admin_role_id') ->map(fn($v)=>(int)$v) ->all(); } /** (중복 선언 금지) 상세/내정보/관리페이지 공용 */ public function getRolesForUser(int $adminUserId): array { if (!Schema::hasTable('admin_roles') || !Schema::hasTable('admin_role_user')) return []; $codeCol = $this->firstExistingColumn('admin_roles', ['code','name','slug']); $nameCol = $this->firstExistingColumn('admin_roles', ['label','title','name']); return DB::table('admin_role_user as aru') ->join('admin_roles as r', 'r.id', '=', 'aru.admin_role_id') ->where('aru.admin_user_id', $adminUserId) ->orderBy('r.id') ->get([ 'r.id', DB::raw(($codeCol ? "r.`{$codeCol}`" : "CAST(r.id AS CHAR)") . " as code"), DB::raw(($nameCol ? "r.`{$nameCol}`" : "CAST(r.id AS CHAR)") . " as name"), ]) ->map(fn($row)=>(array)$row) ->all(); } public function getRoleMapForUsers(array $userIds): array { if (empty($userIds)) return []; if (!Schema::hasTable('admin_role_user') || !Schema::hasTable('admin_roles')) return []; $codeCol = $this->firstExistingColumn('admin_roles', ['code','name','slug']); $nameCol = $this->firstExistingColumn('admin_roles', ['label','title','name']); $rows = DB::table('admin_role_user as aru') ->join('admin_roles as r', 'r.id', '=', 'aru.admin_role_id') ->whereIn('aru.admin_user_id', $userIds) ->get([ 'aru.admin_user_id as uid', 'r.id as rid', DB::raw(($codeCol ? "r.`{$codeCol}`" : "CAST(r.id AS CHAR)") . " as code"), DB::raw(($nameCol ? "r.`{$nameCol}`" : "CAST(r.id AS CHAR)") . " as name"), ]); $map = []; foreach ($rows as $row) { $uid = (int)$row->uid; $map[$uid] ??= []; $map[$uid][] = ['id'=>(int)$row->rid,'code'=>(string)$row->code,'name'=>(string)$row->name]; } return $map; } public function getPermissionsForUser(int $adminUserId): array { if (!Schema::hasTable('admin_role_user') || !Schema::hasTable('admin_permission_role') || !Schema::hasTable('admin_permissions')) { return []; } $codeCol = $this->firstExistingColumn('admin_permissions', ['code','name','slug']); $nameCol = $this->firstExistingColumn('admin_permissions', ['label','title','name']); return DB::table('admin_role_user as aru') ->join('admin_permission_role as apr', 'apr.admin_role_id', '=', 'aru.admin_role_id') ->join('admin_permissions as p', 'p.id', '=', 'apr.admin_permission_id') ->where('aru.admin_user_id', $adminUserId) ->distinct() ->orderBy('p.id') ->get([ 'p.id', DB::raw(($codeCol ? "p.`{$codeCol}`" : "CAST(p.id AS CHAR)") . " as code"), DB::raw(($nameCol ? "p.`{$nameCol}`" : "CAST(p.id AS CHAR)") . " as name"), ]) ->map(fn($row)=>(array)$row) ->all(); } public function getPermissionCountMapForUsers(array $userIds): array { if (empty($userIds)) return []; if (!Schema::hasTable('admin_role_user') || !Schema::hasTable('admin_permission_role')) return []; $rows = DB::table('admin_role_user as aru') ->join('admin_permission_role as apr', 'apr.admin_role_id', '=', 'aru.admin_role_id') ->whereIn('aru.admin_user_id', $userIds) ->select('aru.admin_user_id as uid', DB::raw('COUNT(DISTINCT apr.admin_permission_id) as cnt')) ->groupBy('aru.admin_user_id') ->get(); $map = []; foreach ($rows as $r) $map[(int)$r->uid] = (int)$r->cnt; return $map; } public function syncRoles(int $adminUserId, array $roleIds, int $actorId): void { if (!Schema::hasTable('admin_role_user')) return; $roleIds = array_values(array_unique(array_map('intval', $roleIds))); DB::table('admin_role_user') ->where('admin_user_id', $adminUserId) ->whereNotIn('admin_role_id', $roleIds) ->delete(); $existing = DB::table('admin_role_user') ->where('admin_user_id', $adminUserId) ->pluck('admin_role_id') ->map(fn($v)=>(int)$v)->all(); $need = array_values(array_diff($roleIds, $existing)); foreach ($need as $rid) { $row = [ 'admin_user_id' => $adminUserId, 'admin_role_id' => (int)$rid, ]; if ($this->hasCol('admin_role_user', 'assigned_by')) $row['assigned_by'] = $actorId; if ($this->hasCol('admin_role_user', 'assigned_at')) $row['assigned_at'] = now(); DB::table('admin_role_user')->insert($row); } } // ========================= // Helpers // ========================= private function filterToExistingColumns(string $table, array $data): array { $cols = $this->cols($table); $out = []; foreach ($data as $k => $v) { if (in_array($k, $cols, true)) { // null 허용 컬럼만 null 넣고 싶으면 여기서 정책 추가 가능 $out[$k] = $v; } } return $out; } private function cols(string $table): array { if (!isset($this->colsCache[$table])) { $this->colsCache[$table] = Schema::hasTable($table) ? Schema::getColumnListing($table) : []; } return $this->colsCache[$table]; } private function hasCol(string $table, string $col): bool { return in_array($col, $this->cols($table), true); } private function firstExistingColumn(string $table, array $candidates): ?string { $cols = $this->cols($table); foreach ($candidates as $c) { if (in_array($c, $cols, true)) return $c; } return null; } private function getColumnType(string $table, string $col): ?string { $key = "{$table}.{$col}"; if (array_key_exists($key, $this->colTypeCache)) { return $this->colTypeCache[$key]; } try { $row = DB::selectOne("SHOW COLUMNS FROM `{$table}` LIKE ?", [$col]); $type = $row?->Type ?? null; // e.g. varbinary(16), varchar(45) $this->colTypeCache[$key] = $type ? strtolower((string)$type) : null; return $this->colTypeCache[$key]; } catch (\Throwable $e) { $this->colTypeCache[$key] = null; return null; } } public function bumpLoginFail(int $id, int $limit = 3): array { return DB::transaction(function () use ($id, $limit) { $row = DB::table('admin_users') ->select('failed_login_count', 'locked_until') ->where('id', $id) ->lockForUpdate() ->first(); $cur = (int)($row->failed_login_count ?? 0); $next = $cur + 1; $locked = ($row->locked_until ?? null) !== null; $update = [ 'failed_login_count' => $next, 'updated_at' => now(), ]; // 3회 이상이면 "영구잠금" if (!$locked && $next >= $limit) { $update['locked_until'] = now(); $locked = true; } DB::table('admin_users')->where('id', $id)->update($update); return ['count' => $next, 'locked' => $locked]; }); } /** * 로그인 성공 시 실패 카운터/잠금 초기화 */ public function clearLoginFailAndUnlock(int $id): void { DB::table('admin_users') ->where('id', $id) ->update([ 'failed_login_count' => 0, 'locked_until' => null, 'updated_at' => now(), ]); } public function existsByEmail(string $email): bool { return DB::table('admin_users')->where('email', $email)->exists(); } public function existsByPhoneHash(string $phoneHash): bool { return DB::table('admin_users')->where('phone_hash', $phoneHash)->exists(); } public function insertAdminUser(array $data): int { // admin_users 컬럼 그대로 사용 (DB 구조 변경 X) $id = DB::table('admin_users')->insertGetId($data); return (int)$id; } public function findRoleByCode(string $code): ?array { $row = DB::table('admin_roles')->where('name', $code)->first(); if (!$row) return null; return (array)$row; } public function deleteRoleMappingsExcept(int $adminId, int $roleId): void { DB::table('admin_role_user') ->where('admin_user_id', $adminId) ->where('admin_role_id', '!=', $roleId) ->delete(); } public function upsertRoleMapping(int $adminId, int $roleId): void { $table = 'admin_role_user'; $vals = []; $now = now(); // 컬럼 있으면 채움 if (Schema::hasColumn($table, 'assigned_at')) $vals['assigned_at'] = $now; if (Schema::hasColumn($table, 'created_at')) $vals['created_at'] = $now; if (Schema::hasColumn($table, 'updated_at')) $vals['updated_at'] = $now; DB::table($table)->updateOrInsert( ['admin_user_id' => $adminId, 'admin_role_id' => $roleId], $vals ); } public function updateTotpStart(int $id, string $secretEnc): bool { return DB::table('admin_users')->where('id', $id)->update([ 'totp_secret_enc' => $secretEnc, 'totp_verified_at' => null, 'totp_enabled' => 0, // 등록 완료 전에는 SMS로 유지 'updated_at' => now(), ]) > 0; } public function confirmTotp(int $id): bool { return DB::table('admin_users')->where('id', $id)->update([ 'totp_verified_at' => now(), 'totp_enabled' => 1, // 등록 완료되면 OTP 사용으로 전환(원하면 0 유지로 바꿔도 됨) 'updated_at' => now(), ]) > 0; } public function disableTotp(int $id): bool { return DB::table('admin_users')->where('id', $id)->update([ 'totp_secret_enc' => null, 'totp_verified_at' => null, 'totp_enabled' => 0, 'updated_at' => now(), ]) > 0; } public function updateTotpMode(int $id, int $enabled): bool { return DB::table('admin_users')->where('id', $id)->update([ 'totp_enabled' => $enabled ? 1 : 0, 'updated_at' => now(), ]) > 0; } public function getEmailMapByIds(array $ids): array { $ids = array_values(array_unique(array_filter(array_map('intval', $ids), fn($v)=>$v>0))); if (empty($ids)) return []; // id => email return DB::table('admin_users') ->whereIn('id', $ids) ->pluck('email', 'id') ->map(fn($v)=>(string)$v) ->all(); } public function getMetaMapByIds(array $ids): array { $ids = array_values(array_unique(array_filter(array_map('intval', $ids), fn($v)=>$v>0))); if (empty($ids)) return []; $rows = DB::table('admin_users') ->whereIn('id', $ids) ->get(['id','email','name','nickname']); $map = []; foreach ($rows as $r) { $id = (int)$r->id; $map[$id] = [ 'id' => (int)$r->id, 'email' => (string)($r->email ?? ''), 'name' => (string)($r->name ?? ''), 'nick' => (string)($r->nickname ?? ''), ]; } return $map; } }