210 lines
7.1 KiB
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);
|
|
}
|
|
}
|