phone_enc ?? ''); if ($enc === '') return ''; try { // 최근 방식(encryptString) 우선 $v = Crypt::decryptString($enc); } catch (\Throwable) { try { // 과거 방식(encrypt) 호환: decrypt()는 unserialize까지 해줌 $v = Crypt::decrypt($enc); } catch (\Throwable) { return ''; } } // decryptString으로 풀렸는데 serialized string이면 안전하게 한 번 풀기 if (is_string($v) && preg_match('/^s:\d+:"/', $v)) { try { $u = @unserialize($v, ['allowed_classes' => false]); if (is_string($u)) return $u; } catch (\Throwable) { // ignore } } return is_string($v) ? $v : ''; } public function updateProfile(int $adminId, Request $request): array { $phone = trim((string) $request->input('phone', '')); $data = $request->validate([ 'nickname' => ['required', 'string', 'min:2', 'max:80'], // 닉네임(예: super admin) 'name' => ['required', 'string', 'min:2', 'max:80'], // 성명(본명) 'phone' => ['nullable', 'string', 'max:30'], ]); $me = $this->repo->find($adminId); if (!$me) { return ['ok' => false, 'errors' => ['common' => '사용자 정보를 찾을 수 없습니다.']]; } $before = [ 'nickname' => (string)($me->nickname ?? ''), 'name' => (string)($me->name ?? ''), 'phone_last4' => $this->last4($this->decryptPhoneForMe($me)), ]; $normalized = ''; $phoneHash = null; $phoneEnc = null; if ($phone !== '') { $normalized = $this->normalizeKoreanPhone($phone); if ($normalized === '') { return ['ok' => false, 'errors' => ['phone' => '휴대폰 번호 형식이 올바르지 않습니다.']]; } $phoneHash = $this->makePhoneHash($normalized); $phoneEnc = \Illuminate\Support\Facades\Crypt::encryptString($normalized); if ($this->repo->existsPhoneHash($phoneHash, $adminId)) { return ['ok' => false, 'errors' => ['phone' => '이미 사용 중인 휴대폰 번호입니다.']]; } } $payload = [ 'nickname' => (string)$data['nickname'], 'name' => (string)$data['name'], 'phone_enc' => $phoneEnc, 'phone_hash' => $phoneHash, // updated_by 컬럼이 없어도 Repository가 자동 제거함 'updated_by' => $adminId, ]; return DB::transaction(function () use ($adminId, $payload, $request, $before) { $ok = $this->repo->updateById($adminId, $payload); if (!$ok) { return ['ok' => false, 'errors' => ['common' => '저장에 실패했습니다. 잠시 후 다시 시도해 주세요.']]; } $after = [ 'nickname' => (string)$payload['nickname'], 'name' => (string)$payload['name'], 'phone_last4' => $this->last4($payload['phone_enc'] ? \Illuminate\Support\Facades\Crypt::decryptString($payload['phone_enc']) : ''), ]; $this->audit->log( actorAdminId: $adminId, action: 'admin.me.update', targetType: 'admin_user', targetId: $adminId, before: $before, after: $after, ip: (string) $request->ip(), ua: (string) $request->userAgent(), ); return ['ok' => true, 'message' => '내 정보가 변경되었습니다.']; }); } public function changePassword(int $adminId, Request $request): array { $data = $request->validate([ 'current_password' => ['required', 'string'], 'password' => ['required', 'string', 'min:10', 'max:72', 'confirmed'], ]); $me = $this->repo->find($adminId); if (!$me) { return ['ok' => false, 'errors' => ['common' => '사용자 정보를 찾을 수 없습니다.']]; } if (!Hash::check((string)$data['current_password'], (string)$me->password)) { return ['ok' => false, 'errors' => ['current_password' => '현재 비밀번호가 일치하지 않습니다.']]; } $before = [ 'password_changed_at' => (string)($me->password_changed_at ?? ''), ]; return DB::transaction(function () use ($adminId, $data, $request, $before) { $hash = Hash::make((string)$data['password']); $ok = $this->repo->update($adminId, [ 'password' => $hash, 'password_changed_at' => now(), 'updated_by' => $adminId, ]); if (!$ok) { return ['ok' => false, 'errors' => ['common' => '비밀번호 변경에 실패했습니다.']]; } $this->audit->log( actorAdminId: $adminId, action: 'admin.me.password.change', targetType: 'admin_user', targetId: $adminId, before: $before, after: ['password_changed_at' => (string)now()], ip: (string) $request->ip(), ua: (string) $request->userAgent(), ); return ['ok' => true, 'message' => '비밀번호가 변경되었습니다.']; }); } private function normalizeKoreanPhone(string $raw): string { $v = preg_replace('/\D+/', '', $raw ?? ''); if (!$v) return ''; // +82 / 82 처리 if (str_starts_with($v, '82')) { $v = '0' . substr($v, 2); } // 010 / 011 등 최소 10~11자리만 허용(운영 정책에 맞게 조정 가능) if (!preg_match('/^0\d{9,10}$/', $v)) { return ''; } return $v; } private function makePhoneHash(string $normalizedPhone): string { $key = (string) config('security.phone_hash_key', config('app.key')); // app.key가 base64:로 시작하면 decode if (str_starts_with($key, 'base64:')) { $key = base64_decode(substr($key, 7)) ?: $key; } return hash_hmac('sha256', $normalizedPhone, $key); } private function last4(string $phone): string { $p = preg_replace('/\D+/', '', $phone ?? ''); if (strlen($p) < 4) return ''; return substr($p, -4); } }