(int)($admin->id ?? 0), 'email' => (string)($admin->email ?? ''), 'name' => (string)($admin->name ?? ''), 'nickname' => (string)($admin->nickname ?? ''), 'status' => (string)($admin->status ?? ''), 'totp_enabled' => (int)($admin->totp_enabled ?? 0), 'totp_verified_at' => $admin->totp_verified_at ? (string)$admin->totp_verified_at : null, // ❗시크릿은 절대 로그 저장 금지 'totp_secret_set' => !empty($admin->totp_secret_enc) ? 1 : 0, 'locked_until' => $admin->locked_until ? (string)$admin->locked_until : null, 'failed_login_count' => (int)($admin->failed_login_count ?? 0), 'must_reset_password' => (int)($admin->must_reset_password ?? 0), ]; } private function packRoles(array $roles): array { // repo->getRolesForUser()가 반환하는 형태(id, code, name)를 // 네가 원하는 id/name/label로 정리 return array_map(function ($r) { $r = (array)$r; return [ 'id' => (int)($r['id'] ?? 0), 'name' => (string)($r['code'] ?? ''), // ex) super_admin 'label' => (string)($r['name'] ?? ''), // ex) 최고관리자 ]; }, $roles); } private function snapRolesForUser(int $adminId): array { $roles = $this->packRoles($this->repo->getRolesForUser($adminId)); return [ 'roles' => $roles, 'role_ids' => array_values(array_map(fn($x)=> (int)$x['id'], $roles)), 'role_names' => array_values(array_map(fn($x)=> (string)$x['name'], $roles)), ]; } public function list(array $filters): array { $page = $this->repo->paginateUsers($filters, 20); $ids = $page->getCollection()->pluck('id')->map(fn($v)=>(int)$v)->all(); $roleMap = $this->repo->getRoleMapForUsers($ids); // permission count는 테이블 없을 수도 있으니 안전하게 $permCnt = method_exists($this->repo, 'getPermissionCountMapForUsers') ? $this->repo->getPermissionCountMapForUsers($ids) : []; return [ 'page' => $page, 'filters' => $filters, 'roleMap' => $roleMap, 'permCnt' => $permCnt, ]; } public function editData(int $id): array { $admin = $this->repo->find($id); $roles = $admin ? $this->repo->getRolesForUser($id) : []; $allRoles = $this->repo->getAllRoles(); $roleIds = $admin ? $this->repo->getRoleIdsForUser($id) : []; // phone 보여주기(암호화/시리얼라이즈 혼재 방어) $phone = null; if ($admin && property_exists($admin, 'phone_enc') && $admin->phone_enc) { $phone = $this->readPhone((string)$admin->phone_enc); } return [ 'admin' => $admin, 'phone' => $phone, 'roles' => $roles, 'allRoles' => $allRoles, 'roleIds' => $roleIds, ]; } public function update(int $id, array $input, int $actorId, string $ip = '', string $ua = ''): array { $admin = $this->repo->find($id); if (!$admin) return $this->fail('관리자를 찾을 수 없습니다.'); // before snapshot (admin + roles) $before = array_merge( ['admin' => $this->snapAdmin($admin)], $this->snapRolesForUser($id), ); // 본인 계정 비활성화 방지 if ((int)$id === (int)$actorId && isset($input['status']) && $input['status'] !== 'active') { return $this->fail('본인 계정은 비활성화할 수 없습니다.'); } // 버그 수정: $data가 아니라 $input 기준 $wantTotp = (int)($input['totp_enabled'] ?? 0) === 1; // TOTP를 켜려면 "등록 완료" 상태여야 함 (secret + verified_at) if ($wantTotp && (empty($admin->totp_secret_enc) || empty($admin->totp_verified_at))) { return $this->fail('Google OTP가 등록 완료 상태가 아니라 선택할 수 없습니다.'); } $data = [ 'name' => trim((string)($input['name'] ?? '')), 'nickname' => trim((string)($input['nickname'] ?? '')), 'status' => (string)($input['status'] ?? 'active'), 'totp_enabled' => $wantTotp ? 1 : 0, 'updated_by' => $actorId ?: null, ]; // phone 처리(컬럼 있는 경우만) $rawPhone = trim((string)($input['phone'] ?? '')); if ($rawPhone !== '') { $phone = $this->normalizeKrPhone($rawPhone); if ($phone === '') return $this->fail('휴대폰 번호 형식이 올바르지 않습니다.'); $hashKey = (string)config('admin.phone_hash_key', env('ADMIN_PHONE_HASH_KEY', '')); if ($hashKey === '') return $this->fail('ADMIN_PHONE_HASH_KEY 가 설정되지 않았습니다.'); $e164 = $this->toE164Kr($phone); // update는 e164로 해시하니, 여기서도 e164 기준 유지 $hash = hash_hmac('sha256', $e164, $hashKey); if (method_exists($this->repo, 'existsPhoneHash') && $this->repo->existsPhoneHash($hash, $id)) { return $this->fail('이미 사용 중인 휴대폰 번호입니다.'); } $data['phone_hash'] = $hash; $data['phone_enc'] = Crypt::encryptString($e164); if (Schema::hasColumn('admin_users', 'phone_last4')) { $data['phone_last4'] = substr(preg_replace('/\D+/', '', $phone), -4); } } // null만 제외 (0은 유지) $data = array_filter($data, fn($v)=>$v !== null); $ok = $this->repo->updateById($id, $data); // ⚠️ updateById가 "변경사항 없음"이면 0 리턴할 수도 있음 // 지금은 0이면 실패 처리인데, 원하면 아래처럼 완화 가능: // if (!$ok && empty($data)) { $ok = true; } if (!$ok) return $this->fail('저장에 실패했습니다. 잠시 후 다시 시도해 주세요.'); // roles sync $roleIds = $input['role_ids'] ?? []; if (is_array($roleIds)) { $roleIds = array_values(array_filter(array_map('intval', $roleIds), fn($v)=>$v>0)); $this->repo->syncRoles($id, $roleIds, $actorId); } // after snapshot $afterAdmin = $this->repo->find($id); $after = array_merge( ['admin' => $this->snapAdmin($afterAdmin)], $this->snapRolesForUser($id), ); // Audit log (actor=수정한 관리자, target=수정당한 관리자) $this->audit->log( actorAdminId: $actorId, action: 'admin_user_update', targetType: 'admin_users', targetId: $id, before: $before, after: $after, ip: $ip, ua: $ua, ); return $this->ok('변경되었습니다.'); } public function resetPasswordToEmail(int $id, int $actorId, string $ip = '', string $ua = ''): array { $admin = $this->repo->find($id); if (!$admin) return $this->fail('관리자를 찾을 수 없습니다.'); $before = ['admin' => $this->snapAdmin($admin)]; $email = (string)($admin->email ?? ''); if ($email === '') return $this->fail('이메일이 비어 있어 초기화할 수 없습니다.'); $payload = [ 'password' => Hash::make($email), // ❗비번 원문/해시는 로그에 남기지 않음 'must_reset_password' => 1, 'password_changed_at' => now(), 'updated_by' => $actorId ?: null, 'updated_at' => now(), ]; $ok = $this->repo->updateById($id, $payload); if (!$ok) return $this->fail('비밀번호 초기화에 실패했습니다.'); $afterAdmin = $this->repo->find($id); $after = ['admin' => $this->snapAdmin($afterAdmin)]; $this->audit->log( actorAdminId: $actorId, action: 'admin_user_reset_password_to_email', targetType: 'admin_users', targetId: $id, before: $before, after: $after, ip: $ip, ua: $ua, ); return $this->ok('임시 비밀번호가 이메일로 초기화되었습니다. (로그인 후 OTP 진행)'); } public function unlock(int $id, int $actorId, string $ip = '', string $ua = ''): array { $admin = $this->repo->find($id); if (!$admin) return $this->fail('관리자를 찾을 수 없습니다.'); $before = ['admin' => $this->snapAdmin($admin)]; $ok = $this->repo->updateById($id, [ 'locked_until' => null, 'failed_login_count' => 0, 'updated_by' => $actorId ?: null, ]); if (!$ok) return $this->fail('잠금 해제에 실패했습니다.'); $afterAdmin = $this->repo->find($id); $after = ['admin' => $this->snapAdmin($afterAdmin)]; $this->audit->log( actorAdminId: $actorId, action: 'admin_user_unlock', targetType: 'admin_users', targetId: $id, before: $before, after: $after, ip: $ip, ua: $ua, ); return $this->ok('잠금이 해제되었습니다.'); } private function ok(string $msg): array { return ['ok'=>true,'message'=>$msg]; } private function fail(string $msg): array { return ['ok'=>false,'message'=>$msg]; } private function normalizeKrPhone(string $raw): string { $n = preg_replace('/\D+/', '', $raw) ?? ''; if ($n === '') return ''; if (str_starts_with($n, '82')) $n = '0'.substr($n, 2); if (!preg_match('/^01[016789]\d{7,8}$/', $n)) return ''; return $n; } private function toE164Kr(string $raw010): string { $n = preg_replace('/\D+/', '', $raw010) ?? ''; if ($n === '') return '+82'; if (str_starts_with($n, '0')) $n = substr($n, 1); return '+82'.$n; } private function readPhone(string $phoneEnc): ?string { // s:11:"010..."; 형태(구데이터) 방어: 그냥 숫자만 뽑아냄 if (preg_match('/^s:\d+:"([^"]+)";$/', $phoneEnc, $m)) { $n = preg_replace('/\D+/', '', $m[1]) ?? ''; return $n ?: null; } // decrypt 시도 try { $v = Crypt::decryptString($phoneEnc); $n = preg_replace('/\D+/', '', $v) ?? ''; if ($n === '') return null; if (str_starts_with($n, '82')) $n = '0'.substr($n, 2); return $n; } catch (\Throwable $e) { return null; } } public function getAssignableRoles(): array { // value(code) => label return [ ['code' => 'super_admin', 'label' => '최고관리자'], ['code' => 'finance', 'label' => '정산관리'], ['code' => 'product', 'label' => '상품관리'], ['code' => 'support', 'label' => 'CS/상담'], ]; } public function createAdmin(array $payload, int $actorId, string $ip = '', string $ua = ''): array { $email = (string)($payload['email'] ?? ''); $name = (string)($payload['name'] ?? ''); $nick = (string)($payload['nickname'] ?? ''); $roleCode = (string)($payload['role'] ?? ''); $phoneDigits = (string)($payload['phone_digits'] ?? ''); $hashKey = (string) config('admin.phone_hash_key', env('ADMIN_PHONE_HASH_KEY', '')); if ($hashKey === '') { return $this->fail('설정 오류: ADMIN_PHONE_HASH_KEY 가 비어있어 등록할 수 없습니다.'); } // role 존재 확인 (DB에 실제 role row가 있어야 함) $role = $this->repo->findRoleByCode($roleCode); if (!$role) { return $this->fail('역할(Role) 설정이 올바르지 않습니다. (role not found)'); } $roleId = (int)($role['id'] ?? 0); if ($roleId < 1) { return $this->fail('역할(Role) 설정이 올바르지 않습니다.'); } // phone_* 생성 $phoneEnc = Crypt::encryptString($phoneDigits); $phoneLast4 = substr($phoneDigits, -4); $phoneHash = hash_hmac('sha256', $phoneDigits, $hashKey); // 임시 비밀번호(서버 생성) $tempPassword = $email; $pwHash = Hash::make($tempPassword); // 단방향 해시 (복호화 불가) try { return DB::transaction(function () use ( $email, $name, $nick, $phoneEnc, $phoneHash, $phoneLast4, $pwHash, $tempPassword, $roleId, $actorId, $ip, $ua ) { // 중복 방어(이메일/휴대폰) if ($this->repo->existsByEmail($email)) { return $this->fail('이미 등록된 이메일입니다.'); } if ($this->repo->existsByPhoneHash($phoneHash)) { return $this->fail('이미 등록된 휴대폰 번호입니다.'); } // admin_users insert $adminId = $this->repo->insertAdminUser([ 'email' => $email, 'password' => $pwHash, 'name' => $name, 'nickname' => $nick, 'phone_enc' => $phoneEnc, 'phone_hash' => $phoneHash, 'phone_last4' => $phoneLast4, 'status' => 'active', 'must_reset_password' => 1, // 신규는 기본 SMS 인증으로 시작(너 정책: 0=sms, 1=otp) 'totp_enabled' => 0, 'totp_secret_enc' => null, 'totp_verified_at' => null, 'failed_login_count' => 0, 'locked_until' => null, 'created_by' => $actorId ?: null, 'updated_by' => $actorId ?: null, 'created_at' => now(), 'updated_at' => now(), ]); if ($adminId < 1) { return $this->fail('관리자 생성에 실패했습니다.'); } // 단일 role 유지: 기존 role 매핑 제거 후 updateOrInsert $this->repo->deleteRoleMappingsExcept($adminId, $roleId); $this->repo->upsertRoleMapping($adminId, $roleId); $afterAdmin = $this->repo->find($adminId); $after = array_merge( ['admin' => $this->snapAdmin($afterAdmin)], $this->snapRolesForUser($adminId), ); $this->audit->log( actorAdminId: $actorId, action: 'admin_user_create', targetType: 'admin_users', targetId: $adminId, before: null, after: $after, ip: $ip, ua: $ua, ); return [ 'ok' => true, 'admin_id' => $adminId, 'temp_password' => '', // 노출 안함(어차피 이메일) 'message' => '관리자 계정이 등록되었습니다. 임시 비밀번호는 이메일입니다. (다음 로그인 시 변경 강제)', ]; }); } catch (\Throwable $e) { return $this->fail('등록 중 오류가 발생했습니다. (DB)'); } } }