536 lines
20 KiB
PHP
536 lines
20 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Admin\Member;
|
|
|
|
use App\Repositories\Admin\Member\AdminMemberRepository;
|
|
use App\Repositories\Admin\AdminUserRepository;
|
|
use App\Repositories\Member\MemberAuthRepository;
|
|
use App\Services\MailService;
|
|
use App\Support\LegacyCrypto\CiSeedCrypto;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
final class AdminMemberService
|
|
{
|
|
private const AUTH_TYPES_SHOW = ['email','cell','account','vow'];
|
|
|
|
public function __construct(
|
|
private readonly AdminMemberRepository $repo,
|
|
private readonly AdminUserRepository $adminRepo,
|
|
private readonly MemberAuthRepository $members,
|
|
private readonly MailService $mail,
|
|
) {}
|
|
|
|
public function list(array $filters): array
|
|
{
|
|
// q가 휴대폰이면 동등검색(암호문 비교)
|
|
$keyword = trim((string)($filters['q'] ?? ''));
|
|
if ($keyword !== '') {
|
|
$digits = preg_replace('/\D+/', '', $keyword) ?? '';
|
|
if (preg_match('/^\d{10,11}$/', $digits)) {
|
|
$phone = $this->normalizeKrPhone($digits);
|
|
if ($phone !== '') {
|
|
$filters['phone_enc'] = $this->encryptPhone($phone);
|
|
}
|
|
}
|
|
}
|
|
$page = $this->repo->paginateMembers($filters, 20);
|
|
|
|
$memNos = $page->getCollection()->pluck('mem_no')->map(fn($v)=>(int)$v)->all();
|
|
$authMapAll = $this->repo->getAuthMapForMembers($memNos);
|
|
|
|
$authMap = [];
|
|
foreach ($authMapAll as $no => $types) {
|
|
foreach (self::AUTH_TYPES_SHOW as $t) {
|
|
if (isset($types[$t])) $authMap[(int)$no][$t] = $types[$t];
|
|
}
|
|
}
|
|
|
|
// 표시용 맵
|
|
$phoneMap = [];
|
|
$corpMap = $this->corpMap();
|
|
foreach ($page as $m) {
|
|
$no = (int)$m->mem_no;
|
|
$plain = $this->plainPhone((string)($m->cell_phone ?? ''));
|
|
$phoneMap[$no] = $this->formatPhone($plain);
|
|
}
|
|
|
|
return [
|
|
'page' => $page,
|
|
'filters' => $filters,
|
|
'authMap' => $authMap,
|
|
|
|
'stat3Map' => $this->stat3Map(),
|
|
'genderMap' => $this->genderMap(),
|
|
'nativeMap' => $this->nativeMap(),
|
|
'corpMap' => $corpMap,
|
|
'phoneMap' => $phoneMap,
|
|
];
|
|
}
|
|
|
|
public function showData(int $memNo): array
|
|
{
|
|
$m = $this->repo->findMember($memNo);
|
|
if (!$m) return ['member' => null];
|
|
|
|
$member = (object)((array)$m);
|
|
|
|
$corpLabel = $this->corpMap()[(string)($member->cell_corp ?? 'n')] ?? '-';
|
|
$plainPhone = $this->plainPhone((string)($member->cell_phone ?? ''));
|
|
$phoneDisplay = $this->formatPhone($plainPhone);
|
|
|
|
// ✅ 레거시 JSON 파싱 (admin_memo / modify_log)
|
|
$adminMemoList = $this->legacyAdminMemoList($member->admin_memo ?? null); // old -> new normalize
|
|
$stateLogList = $this->legacyStateLogList($member->modify_log ?? null); // old -> new normalize
|
|
|
|
// 화면은 최신이 위로 오게
|
|
$adminMemo = array_reverse($adminMemoList);
|
|
$modifyLog = array_reverse($stateLogList);
|
|
|
|
// ✅ adminMap 대상 admin_num 수집
|
|
$adminSet = [];
|
|
|
|
foreach ($adminMemo as $it) {
|
|
$aid = (int)($it['admin_num'] ?? 0);
|
|
if ($aid > 0) $adminSet[$aid] = true;
|
|
}
|
|
foreach ($modifyLog as $it) {
|
|
$aid = (int)($it['admin_num'] ?? 0);
|
|
if ($aid > 0) $adminSet[$aid] = true;
|
|
}
|
|
|
|
$adminIds = array_keys($adminSet);
|
|
|
|
// 인증/주소/로그 (기존 그대로)
|
|
$authRows = array_values(array_filter(
|
|
$this->repo->getAuthRowsForMember($memNo),
|
|
fn($r)=> in_array((string)($r['auth_type'] ?? ''), self::AUTH_TYPES_SHOW, true)
|
|
));
|
|
|
|
$authInfo = $this->repo->getAuthInfo($memNo);
|
|
$authLogs = $this->repo->getAuthLogs($memNo, 30);
|
|
$addresses = $this->repo->getAddresses($memNo);
|
|
|
|
// 계좌 표시(수정 불가 / 표시만)
|
|
$bank = $this->buildBankDisplay($member, $authInfo);
|
|
|
|
$adminMap = $this->adminRepo->getMetaMapByIds($adminIds);
|
|
|
|
return [
|
|
'member' => $member,
|
|
'corpLabel' => $corpLabel,
|
|
'plainPhone' => $plainPhone,
|
|
'phoneDisplay' => $phoneDisplay,
|
|
|
|
// ✅ 레거시 기반 결과
|
|
'adminMemo' => $adminMemo,
|
|
'modifyLog' => $modifyLog,
|
|
'adminMap' => $adminMap,
|
|
|
|
// 기존 그대로
|
|
'authRows' => $authRows,
|
|
'authInfo' => $authInfo,
|
|
'authLogs' => $authLogs,
|
|
'addresses' => $addresses,
|
|
|
|
'stat3Map' => $this->stat3Map(),
|
|
'genderMap' => $this->genderMap(),
|
|
'nativeMap' => $this->nativeMap(),
|
|
'corpMap' => $this->corpMap(),
|
|
'bank' => $bank,
|
|
];
|
|
}
|
|
|
|
|
|
/**
|
|
* 업데이트 허용: stat_3(1~3만), cell_corp, cell_phone
|
|
* 금지: 이름/이메일/수신동의/계좌/기타
|
|
*/
|
|
public function updateMember(int $memNo, array $input, int $actorAdminId, string $ip = '', string $ua = ''): array
|
|
{
|
|
try {
|
|
return DB::transaction(function () use ($memNo, $input, $actorAdminId) {
|
|
|
|
$before = $this->repo->lockMemberForUpdate($memNo);
|
|
if (!$before) return $this->fail('회원을 찾을 수 없습니다.');
|
|
|
|
$data = [];
|
|
|
|
// ✅ 기존 modify_log에서 레거시 state_log[] 뽑기
|
|
$stateLog = $this->legacyStateLogList($before->modify_log ?? null);
|
|
|
|
// ✅ stat_3 변경 (1~3만 변경 허용, 4~6 금지 정책 유지)
|
|
if (array_key_exists('stat_3', $input)) {
|
|
$s3 = (string)($input['stat_3'] ?? '');
|
|
if (!in_array($s3, ['1','2','3','4','5','6'], true)) {
|
|
return $this->fail('회원상태(stat_3)는 1~6만 유효합니다.');
|
|
}
|
|
if (in_array($s3, ['4','5','6'], true)) {
|
|
return $this->fail('4~6 상태는 시스템 상태로 관리자 변경이 불가합니다.');
|
|
}
|
|
|
|
$beforeS3 = (string)($before->stat_3 ?? '');
|
|
if ($s3 !== $beforeS3) {
|
|
$data['stat_3'] = $s3;
|
|
$data['dt_stat_3'] = now()->format('Y-m-d H:i:s');
|
|
|
|
// ✅ 레거시 형식 로그
|
|
$stateLog[] = [
|
|
'when' => now()->format('y-m-d H:i:s'),
|
|
'after' => $s3,
|
|
'title' => '회원상태 접근권한 변경',
|
|
'before' => $beforeS3,
|
|
'admin_num' => (string)$actorAdminId,
|
|
];
|
|
}
|
|
}
|
|
|
|
// ✅ 통신사 변경
|
|
if (array_key_exists('cell_corp', $input)) {
|
|
$corp = (string)($input['cell_corp'] ?? 'n');
|
|
$allowed = ['n','01','02','03','04','05','06'];
|
|
if (!in_array($corp, $allowed, true)) return $this->fail('통신사 코드가 올바르지 않습니다.');
|
|
|
|
$beforeCorp = (string)($before->cell_corp ?? 'n');
|
|
if ($corp !== $beforeCorp) {
|
|
$data['cell_corp'] = $corp;
|
|
|
|
$stateLog[] = [
|
|
'when' => now()->format('y-m-d H:i:s'),
|
|
'after' => $corp,
|
|
'title' => '통신사 변경',
|
|
'before' => $beforeCorp,
|
|
'admin_num' => (string)$actorAdminId,
|
|
];
|
|
}
|
|
}
|
|
|
|
// ✅ 휴대폰 변경(암호화 저장)
|
|
if (array_key_exists('cell_phone', $input)) {
|
|
$raw = trim((string)($input['cell_phone'] ?? ''));
|
|
|
|
$enc = '';
|
|
$afterPlain = '';
|
|
if ($raw !== '') {
|
|
$phone = $this->normalizeKrPhone($raw);
|
|
if ($phone === '') return $this->fail('휴대폰 번호 형식이 올바르지 않습니다.');
|
|
$enc = $this->encryptPhone($phone);
|
|
$afterPlain = $phone;
|
|
}
|
|
|
|
$beforeEnc = (string)($before->cell_phone ?? '');
|
|
if ($beforeEnc !== $enc) {
|
|
$data['cell_phone'] = $enc;
|
|
|
|
// 로그는 마스킹(평문 full 저장 원하면 여기만 변경)
|
|
$beforePlain = $this->plainPhone($beforeEnc);
|
|
$stateLog[] = [
|
|
'when' => now()->format('y-m-d H:i:s'),
|
|
'after' => $this->maskPhoneForLog($afterPlain),
|
|
'title' => '휴대폰번호 변경',
|
|
'before' => $this->maskPhoneForLog($beforePlain),
|
|
'admin_num' => (string)$actorAdminId,
|
|
];
|
|
}
|
|
}
|
|
|
|
if (empty($data)) {
|
|
return $this->ok('변경사항이 없습니다.');
|
|
}
|
|
|
|
// ✅ 레거시 modify_log 저장: 반드시 {"state_log":[...]}
|
|
$stateLog = $this->trimLegacyList($stateLog, 300);
|
|
$data['modify_log'] = $this->encodeJsonObjectOrNull(['state_log' => $stateLog]);
|
|
|
|
// 최근정보변경일시
|
|
$data['dt_mod'] = now()->format('Y-m-d H:i:s');
|
|
|
|
$ok = $this->repo->updateMember($memNo, $data);
|
|
if (!$ok) return $this->fail('저장에 실패했습니다.');
|
|
|
|
return $this->ok('변경되었습니다.');
|
|
});
|
|
} catch (\Throwable $e) {
|
|
return $this->fail('저장 중 오류가 발생했습니다. (DB)');
|
|
}
|
|
}
|
|
|
|
|
|
// -------------------------
|
|
// Maps
|
|
// -------------------------
|
|
private function stat3Map(): array
|
|
{
|
|
return [
|
|
'1' => '1. 로그인정상',
|
|
'2' => '2. 로그인만가능',
|
|
'3' => '3. 로그인불가 (접근금지)',
|
|
'4' => '4. 탈퇴완료(아이디보관)',
|
|
'5' => '5. 탈퇴완료',
|
|
'6' => '6. 휴면회원',
|
|
];
|
|
}
|
|
|
|
private function genderMap(): array
|
|
{
|
|
return ['1' => '남자', '0' => '여자', 'n' => '-'];
|
|
}
|
|
|
|
private function nativeMap(): array
|
|
{
|
|
return ['1' => '내국인', '2' => '외국인', 'n' => '-'];
|
|
}
|
|
|
|
private function corpMap(): array
|
|
{
|
|
return [
|
|
'n' => '-',
|
|
'01' => 'SKT',
|
|
'02' => 'KT',
|
|
'03' => 'LGU+',
|
|
'04' => 'SKT(알뜰폰)',
|
|
'05' => 'KT(알뜰폰)',
|
|
'06' => 'LGU+(알뜰폰)',
|
|
];
|
|
}
|
|
|
|
// -------------------------
|
|
// Phone helpers
|
|
// -------------------------
|
|
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 encryptPhone(string $raw010): string
|
|
{
|
|
$seed = app(CiSeedCrypto::class);
|
|
return (string) $seed->encrypt($raw010);
|
|
}
|
|
|
|
// DB 컬럼이 평문/암호문 섞여있을 수 있어 “plain digits”로 뽑아줌
|
|
private function plainPhone(string $cellPhoneCol): string
|
|
{
|
|
$v = trim($cellPhoneCol);
|
|
if ($v === '') return '';
|
|
|
|
$digits = preg_replace('/\D+/', '', $v) ?? '';
|
|
if (preg_match('/^\d{10,11}$/', $digits)) return $digits;
|
|
|
|
try {
|
|
$seed = app(CiSeedCrypto::class);
|
|
$plain = (string) $seed->decrypt($v);
|
|
$plainDigits = preg_replace('/\D+/', '', $plain) ?? '';
|
|
if (preg_match('/^\d{10,11}$/', $plainDigits)) return $plainDigits;
|
|
} catch (\Throwable $e) {
|
|
// ignore
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
private function formatPhone(string $digits): string
|
|
{
|
|
if (!preg_match('/^\d{10,11}$/', $digits)) return '-';
|
|
if (strlen($digits) === 11) {
|
|
return substr($digits,0,3).'-'.substr($digits,3,4).'-'.substr($digits,7,4);
|
|
}
|
|
// 10자리
|
|
return substr($digits,0,3).'-'.substr($digits,3,3).'-'.substr($digits,6,4);
|
|
}
|
|
|
|
// -------------------------
|
|
// Bank display (readonly)
|
|
// -------------------------
|
|
private function buildBankDisplay(object $member, ?array $authInfo): array
|
|
{
|
|
$bankName = trim((string)($member->bank_name ?? ''));
|
|
$acc = trim((string)($member->bank_act_num ?? ''));
|
|
$holder = trim((string)($member->name ?? ''));
|
|
|
|
// authInfo 쪽에 계좌인증 정보가 있으면 우선 사용(키는 프로젝트마다 다르니 방어적으로)
|
|
if (is_array($authInfo)) {
|
|
$maybe = $authInfo['account'] ?? $authInfo['bank'] ?? null;
|
|
if (is_array($maybe)) {
|
|
$bankName = trim((string)($maybe['bank_name'] ?? $maybe['bankName'] ?? $bankName));
|
|
$acc = trim((string)($maybe['bank_act_num'] ?? $maybe['account'] ?? $maybe['account_number'] ?? $acc));
|
|
$holder = trim((string)($maybe['holder'] ?? $maybe['name'] ?? $holder));
|
|
}
|
|
}
|
|
|
|
$has = ($bankName !== '' && $acc !== '');
|
|
$masked = $has ? $this->maskAccount($acc) : '';
|
|
|
|
return [
|
|
'has' => $has,
|
|
'bank_name' => $bankName,
|
|
'account' => $masked,
|
|
'holder' => ($holder !== '' ? $holder : '-'),
|
|
];
|
|
}
|
|
|
|
private function maskAccount(string $acc): string
|
|
{
|
|
// 암호문/특수문자 섞여도 최소 마스킹은 해줌
|
|
$d = preg_replace('/\s+/', '', $acc);
|
|
if (mb_strlen($d) <= 4) return $d;
|
|
return str_repeat('*', max(0, mb_strlen($d)-4)).mb_substr($d, -4);
|
|
}
|
|
|
|
public function addMemo(int $memNo, string $memo, int $actorAdminId, string $ip = '', string $ua = ''): array
|
|
{
|
|
$memo = trim($memo);
|
|
if ($memo === '') return $this->fail('메모를 입력해 주세요.');
|
|
if (mb_strlen($memo) > 1000) return $this->fail('메모는 1000자 이내로 입력해 주세요.');
|
|
|
|
try {
|
|
return DB::transaction(function () use ($memNo, $memo, $actorAdminId) {
|
|
|
|
$before = $this->repo->lockMemberForUpdate($memNo);
|
|
if (!$before) return $this->fail('회원을 찾을 수 없습니다.');
|
|
|
|
// ✅ 기존 admin_memo에서 레거시 memo[] 뽑기
|
|
$list = $this->legacyAdminMemoList($before->admin_memo ?? null);
|
|
|
|
// ✅ append (레거시 키 유지)
|
|
$list[] = [
|
|
'memo' => $memo,
|
|
'when' => now()->format('y-m-d H:i:s'), // 레거시(2자리년도)
|
|
'admin_num' => (string)$actorAdminId,
|
|
];
|
|
$list = $this->trimLegacyList($list, 300);
|
|
|
|
// ✅ admin_memo는 반드시 {"memo":[...]} 형태
|
|
$obj = ['memo' => $list];
|
|
|
|
$data = [
|
|
'admin_memo' => $this->encodeJsonObjectOrNull($obj),
|
|
'dt_mod' => now()->format('Y-m-d H:i:s'),
|
|
];
|
|
|
|
$ok = $this->repo->updateMember($memNo, $data);
|
|
if (!$ok) return $this->fail('메모 저장에 실패했습니다.');
|
|
|
|
return $this->ok('메모가 추가되었습니다.');
|
|
});
|
|
} catch (\Throwable $e) {
|
|
return $this->fail('메모 저장 중 오류가 발생했습니다. (DB)');
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private function ok(string $msg): array { return ['ok'=>true,'message'=>$msg]; }
|
|
private function fail(string $msg): array { return ['ok'=>false,'message'=>$msg]; }
|
|
|
|
private function decodeJsonObject($jsonOrNull): array
|
|
{
|
|
if ($jsonOrNull === null) return [];
|
|
$s = trim((string)$jsonOrNull);
|
|
if ($s === '') return [];
|
|
$arr = json_decode($s, true);
|
|
return is_array($arr) ? $arr : [];
|
|
}
|
|
|
|
private function encodeJsonObjectOrNull(array $obj): ?string
|
|
{
|
|
if (empty($obj)) return null;
|
|
return json_encode($obj, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
}
|
|
|
|
// ✅ admin_memo: {"memo":[{memo,when,admin_num}, ...]}
|
|
private function legacyAdminMemoList($adminMemoJson): array
|
|
{
|
|
$obj = $this->decodeJsonObject($adminMemoJson);
|
|
|
|
if (isset($obj['memo']) && is_array($obj['memo'])) {
|
|
return $obj['memo'];
|
|
}
|
|
|
|
// 혹시 예전에 잘못 저장된 list 형태도 최대한 복구해서 보여주기
|
|
if (is_array($obj) && array_is_list($obj)) {
|
|
$out = [];
|
|
foreach ($obj as $it) {
|
|
if (!is_array($it)) continue;
|
|
$memo = (string)($it['memo'] ?? '');
|
|
if ($memo === '') continue;
|
|
|
|
$out[] = [
|
|
'memo' => $memo,
|
|
'when' => $this->legacyWhen((string)($it['when'] ?? $it['ts'] ?? '')),
|
|
'admin_num' => (string)($it['admin_num'] ?? $it['actor_admin_id'] ?? ''),
|
|
];
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
// ✅ modify_log: {"state_log":[{when,after,title,before,admin_num}, ...]}
|
|
private function legacyStateLogList($modifyLogJson): array
|
|
{
|
|
$obj = $this->decodeJsonObject($modifyLogJson);
|
|
|
|
if (isset($obj['state_log']) && is_array($obj['state_log'])) {
|
|
return $obj['state_log'];
|
|
}
|
|
|
|
// 혹시 예전에 잘못 저장된 list 형태도 복구 시도
|
|
if (is_array($obj) && array_is_list($obj)) {
|
|
$out = [];
|
|
foreach ($obj as $it) {
|
|
if (!is_array($it)) continue;
|
|
|
|
$out[] = [
|
|
'when' => $this->legacyWhen((string)($it['when'] ?? $it['ts'] ?? '')),
|
|
'after' => (string)($it['after'] ?? ''),
|
|
'title' => (string)($it['title'] ?? $it['action'] ?? '변경'),
|
|
'before' => (string)($it['before'] ?? ''),
|
|
'admin_num' => (string)($it['admin_num'] ?? $it['actor_admin_id'] ?? ''),
|
|
];
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
private function legacyWhen(string $any): string
|
|
{
|
|
$any = trim($any);
|
|
if ($any === '') return now()->format('y-m-d H:i:s');
|
|
|
|
if (preg_match('/^\d{2}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}$/', $any)) return $any;
|
|
|
|
if (preg_match('/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}$/', $any)) {
|
|
try { return \Carbon\Carbon::parse($any)->format('y-m-d H:i:s'); }
|
|
catch (\Throwable $e) { return now()->format('y-m-d H:i:s'); }
|
|
}
|
|
|
|
try { return \Carbon\Carbon::parse($any)->format('y-m-d H:i:s'); }
|
|
catch (\Throwable $e) { return now()->format('y-m-d H:i:s'); }
|
|
}
|
|
|
|
private function trimLegacyList(array $list, int $max = 300): array
|
|
{
|
|
$n = count($list);
|
|
if ($n <= $max) return $list;
|
|
return array_slice($list, $n - $max);
|
|
}
|
|
|
|
private function maskPhoneForLog(string $digits): string
|
|
{
|
|
$d = preg_replace('/\D+/', '', $digits) ?? '';
|
|
if ($d === '') return '';
|
|
if (strlen($d) <= 4) return str_repeat('*', strlen($d));
|
|
return substr($d, 0, 3) . str_repeat('*', max(0, strlen($d) - 7)) . substr($d, -4);
|
|
}
|
|
|
|
|
|
|
|
}
|