giftcon_dev/app/Services/Admin/AdminMeService.php

210 lines
7.1 KiB
PHP

<?php
namespace App\Services\Admin;
use App\Repositories\Admin\AdminUserRepository;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
final class AdminMeService
{
public function __construct(
private readonly AdminUserRepository $repo,
private readonly AdminAuditService $audit,
) {}
public function decryptPhoneForMe($adminUser): string
{
$enc = (string) ($adminUser->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);
}
}