307 lines
11 KiB
PHP
307 lines
11 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Admin;
|
|
|
|
use App\Repositories\Admin\AdminUserRepository;
|
|
use Illuminate\Support\Facades\Crypt;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
|
|
final class AdminAdminsService
|
|
{
|
|
public function __construct(
|
|
private readonly AdminUserRepository $repo,
|
|
) {}
|
|
|
|
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): array
|
|
{
|
|
$admin = $this->repo->find($id);
|
|
if (!$admin) return $this->fail('관리자를 찾을 수 없습니다.');
|
|
|
|
// 본인 계정 비활성화 방지(사고 방지)
|
|
if ((int)$id === (int)$actorId && isset($input['status']) && $input['status'] !== 'active') {
|
|
return $this->fail('본인 계정은 비활성화할 수 없습니다.');
|
|
}
|
|
|
|
$wantTotp = (int)($data['totp_enabled'] ?? 0) === 1;
|
|
|
|
if ($wantTotp && empty($admin->totp_secret_enc)) {
|
|
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);
|
|
$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은 업데이트 제외(컬럼 유무 필터는 repo에서 함)
|
|
$data = array_filter($data, fn($v)=>$v !== null);
|
|
|
|
$ok = $this->repo->updateById($id, $data);
|
|
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);
|
|
}
|
|
|
|
return $this->ok('변경되었습니다.');
|
|
}
|
|
|
|
public function resetPasswordToEmail(int $id, int $actorId): array
|
|
{
|
|
$admin = $this->repo->find($id);
|
|
if (!$admin) return $this->fail('관리자를 찾을 수 없습니다.');
|
|
|
|
$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('비밀번호 초기화에 실패했습니다.');
|
|
return $this->ok('임시 비밀번호가 이메일로 초기화되었습니다. (로그인 후 OTP 진행)');
|
|
}
|
|
|
|
public function unlock(int $id, int $actorId): array
|
|
{
|
|
$admin = $this->repo->find($id);
|
|
if (!$admin) return $this->fail('관리자를 찾을 수 없습니다.');
|
|
|
|
$ok = $this->repo->updateById($id, [
|
|
'locked_until' => null,
|
|
'failed_login_count' => 0,
|
|
'updated_by' => $actorId ?: null,
|
|
]);
|
|
|
|
if (!$ok) return $this->fail('잠금 해제에 실패했습니다.');
|
|
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): 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
|
|
) {
|
|
// 중복 방어(이메일/휴대폰)
|
|
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);
|
|
|
|
return [
|
|
'ok' => true,
|
|
'admin_id' => $adminId,
|
|
'temp_password' => '', // 노출 안함(어차피 이메일)
|
|
'message' => '관리자 계정이 등록되었습니다. 임시 비밀번호는 이메일입니다. (다음 로그인 시 변경 강제)',
|
|
];
|
|
});
|
|
} catch (\Throwable $e) {
|
|
return $this->fail('등록 중 오류가 발생했습니다. (DB)');
|
|
}
|
|
}
|
|
}
|