giftcon_dev/app/Services/Admin/AdminUserManageService.php

296 lines
11 KiB
PHP

<?php
namespace App\Services\Admin;
use App\Repositories\Admin\AdminUserRepository;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Schema;
final class AdminUserManageService
{
public function __construct(
private readonly AdminUserRepository $repo,
private readonly AdminAuditService $audit,
) {}
public function getAllRoles(): array
{
return $this->repo->getAllRoles(); // [id, code, name]
}
/** @return array{0:mixed,1:array,2:array} */
public function list(Request $request): array
{
$pager = $this->repo->paginateUsers([
'q' => (string)$request->query('q', ''),
'status' => (string)$request->query('status', ''),
'role' => (string)$request->query('role', ''),
], 20);
$ids = $pager->getCollection()->pluck('id')->map(fn($v)=>(int)$v)->all();
$roleMap = $this->repo->getRoleMapForUsers($ids);
$permCntMap = $this->repo->getPermissionCountMapForUsers($ids);
// last_login_ip가 binary일 수 있으니 텍스트 변환 보조값 붙이기
$pager->getCollection()->transform(function ($u) {
$u->last_login_ip_text = $this->ipToText($u->last_login_ip ?? null);
return $u;
});
return [$pager, $roleMap, $permCntMap];
}
public function editData(int $id, int $actorId): array
{
$u = $this->repo->find($id);
if (!$u) {
return ['ok' => false, 'message' => '관리자 정보를 찾을 수 없습니다.'];
}
$rolesAll = $this->repo->getAllRoles();
$roleIds = $this->repo->getRoleIdsForUser($id);
$roles = $this->repo->getRolesForUser($id);
$perms = $this->repo->getPermissionsForUser($id);
$u->last_login_ip_text = $this->ipToText($u->last_login_ip ?? null);
return [
'ok' => true,
'me_actor_id' => $actorId,
'user' => $u,
'rolesAll' => $rolesAll,
'roleIds' => $roleIds,
'roles' => $roles,
'perms' => $perms,
];
}
public function update(int $id, int $actorId, Request $request): array
{
$u = $this->repo->find($id);
if (!$u) return ['ok' => false, 'message' => '관리자 정보를 찾을 수 없습니다.'];
// 입력값(컬럼 존재 여부와 무관하게 validate는 가능)
$data = $request->validate([
'nickname' => ['required','string','min:2','max:80'],
'name' => ['required','string','min:2','max:80'],
'email' => ['required','email','max:190'],
'status' => ['nullable','string','max:20'],
'two_factor_mode' => ['nullable','string','max:20'],
'totp_enabled' => ['nullable'],
'phone' => ['nullable','string','max:30'],
'roles' => ['array'],
'roles.*' => ['integer'],
]);
// 안전장치: 자기 자신 비활성화 방지(사고 방지)
if ($actorId === $id) {
if (($data['status'] ?? '') && in_array($data['status'], ['disabled','suspended','deleted'], true)) {
return ['ok'=>false,'message'=>'본인 계정은 비활성/정지할 수 없습니다.'];
}
}
$before = $this->snapshotUser($u);
// phone 처리(선택)
$phoneDigits = '';
$phoneEnc = null;
$phoneHash = null;
$rawPhone = trim((string)($data['phone'] ?? ''));
if ($rawPhone !== '') {
$phoneDigits = $this->normalizeKoreanPhone($rawPhone);
if ($phoneDigits === '') {
return ['ok'=>false,'errors'=>['phone'=>'휴대폰 번호 형식이 올바르지 않습니다.']];
}
$hashKey = (string) config('admin.phone_hash_key', env('ADMIN_PHONE_HASH_KEY', ''));
if ($hashKey === '') {
return ['ok'=>false,'message'=>'ADMIN_PHONE_HASH_KEY 설정이 필요합니다.'];
}
$phoneHash = hash_hmac('sha256', $phoneDigits, $hashKey);
$phoneEnc = Crypt::encryptString($phoneDigits);
// 중복 방지(운영용) — 테스트 정책이면 여기 조건 완화 가능
if ($this->repo->existsPhoneHash($phoneHash, $id)) {
return ['ok'=>false,'errors'=>['phone'=>'이미 사용 중인 휴대폰 번호입니다.']];
}
}
$payload = [
'nickname' => (string)$data['nickname'],
'name' => (string)$data['name'],
'email' => (string)$data['email'],
'status' => (string)($data['status'] ?? $u->status ?? 'active'),
'two_factor_mode' => (string)($data['two_factor_mode'] ?? $u->two_factor_mode ?? 'sms'),
'totp_enabled' => isset($data['totp_enabled']) ? (int)!!$data['totp_enabled'] : (int)($u->totp_enabled ?? 0),
'phone_enc' => $phoneEnc,
'phone_hash' => $phoneHash,
'updated_by' => $actorId,
];
$roleIds = array_values(array_unique(array_map('intval', $data['roles'] ?? [])));
return DB::transaction(function () use ($id, $actorId, $request, $payload, $roleIds, $before) {
$ok = $this->repo->updateById($id, $payload);
if (!$ok) return ['ok'=>false,'message'=>'저장에 실패했습니다.'];
// 역할 동기화(선택)
if (!empty($roleIds)) {
// 안전장치: 본인 super_admin 제거 금지
if ($actorId === $id) {
$cur = $this->repo->getRolesForUser($id);
$hasSuper = collect($cur)->contains(fn($r) => ($r['code'] ?? '') === 'super_admin');
if ($hasSuper) {
$superId = $this->repo->findRoleIdByCode('super_admin');
if ($superId && !in_array($superId, $roleIds, true)) {
return ['ok'=>false,'message'=>'본인 super_admin 역할은 제거할 수 없습니다.'];
}
}
}
$this->repo->syncRoles($id, $roleIds, $actorId);
}
$u2 = $this->repo->find($id);
$after = $this->snapshotUser($u2);
$this->audit->log(
actorAdminId: $actorId,
action: 'admin_user.update',
targetType: 'admin_user',
targetId: $id,
before: $before,
after: $after,
ip: (string)$request->ip(),
ua: (string)$request->userAgent(),
);
return ['ok'=>true,'message'=>'관리자 정보가 저장되었습니다.'];
});
}
public function resetPasswordToEmail(int $id, int $actorId, Request $request): array
{
$u = $this->repo->find($id);
if (!$u) return ['ok'=>false,'message'=>'관리자 정보를 찾을 수 없습니다.'];
$before = $this->snapshotUser($u);
// 임시 비밀번호 = 이메일
$temp = (string)($u->email ?? '');
if ($temp === '') return ['ok'=>false,'message'=>'이메일이 없어 초기화할 수 없습니다.'];
return DB::transaction(function () use ($u, $id, $actorId, $request, $temp, $before) {
// password cast(hashed) 있으면 plain 넣어도 자동 해싱됨
$this->repo->setTemporaryPassword($u, $temp, $actorId);
$u2 = $this->repo->find($id);
$after = $this->snapshotUser($u2);
$this->audit->log(
actorAdminId: $actorId,
action: 'admin_user.password_reset_to_email',
targetType: 'admin_user',
targetId: $id,
before: $before,
after: $after,
ip: (string)$request->ip(),
ua: (string)$request->userAgent(),
);
return ['ok'=>true,'message'=>'비밀번호가 이메일로 초기화되었습니다. (다음 로그인 시 변경 강제)'];
});
}
public function unlock(int $id, int $actorId, Request $request): array
{
$u = $this->repo->find($id);
if (!$u) return ['ok'=>false,'message'=>'관리자 정보를 찾을 수 없습니다.'];
$before = $this->snapshotUser($u);
$payload = [
'locked_until' => null,
'failed_login_count' => 0,
'updated_by' => $actorId,
];
return DB::transaction(function () use ($id, $actorId, $request, $payload, $before) {
$ok = $this->repo->updateById($id, $payload);
if (!$ok) return ['ok'=>false,'message'=>'잠금 해제에 실패했습니다.'];
$u2 = $this->repo->find($id);
$after = $this->snapshotUser($u2);
$this->audit->log(
actorAdminId: $actorId,
action: 'admin_user.unlock',
targetType: 'admin_user',
targetId: $id,
before: $before,
after: $after,
ip: (string)$request->ip(),
ua: (string)$request->userAgent(),
);
return ['ok'=>true,'message'=>'잠금이 해제되었습니다.'];
});
}
private function snapshotUser($u): array
{
if (!$u) return [];
return [
'id' => (int)($u->id ?? 0),
'email' => (string)($u->email ?? ''),
'nickname' => (string)($u->nickname ?? ''),
'name' => (string)($u->name ?? ''),
'status' => (string)($u->status ?? ''),
'two_factor_mode' => (string)($u->two_factor_mode ?? ''),
'totp_enabled' => (int)($u->totp_enabled ?? 0),
'locked_until' => (string)($u->locked_until ?? ''),
'last_login_at' => (string)($u->last_login_at ?? ''),
];
}
private function normalizeKoreanPhone(string $raw): string
{
$n = preg_replace('/\D+/', '', $raw) ?? '';
if ($n === '') return '';
// 010xxxxxxxx 형태만 간단 허용(필요시 확장)
if (str_starts_with($n, '010') && strlen($n) === 11) return $n;
if (str_starts_with($n, '01') && strlen($n) >= 10 && strlen($n) <= 11) return $n;
return '';
}
private function ipToText($binOrText): string
{
if ($binOrText === null) return '-';
if (is_string($binOrText)) {
// 이미 문자열 IP면 그대로
if (str_contains($binOrText, '.') || str_contains($binOrText, ':')) return $binOrText;
// binary(4/16) 가능성
$len = strlen($binOrText);
if ($len === 4 || $len === 16) {
$t = @inet_ntop($binOrText);
return $t ?: '-';
}
}
return '-';
}
}