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, $ip, $ua) { $before = $this->repo->lockMemberForUpdate($memNo); if (!$before) return $this->fail('회원을 찾을 수 없습니다.'); $now = now(); $nowStr = $now->format('Y-m-d H:i:s'); $legacyWhen = $now->format('y-m-d H:i:s'); $data = []; // 기존 modify_log에서 레거시 state_log[] 뽑기 $stateLog = $this->legacyStateLogList($before->modify_log ?? null); $beforeStateLogLast = empty($stateLog) ? null : $stateLog[count($stateLog) - 1]; // (audit용) before 스냅샷: 민감/대용량 제외 + phone 마스킹 $beforePlainPhone = $this->plainPhone((string)($before->cell_phone ?? '')); $beforeAudit = [ 'mem_no' => $memNo, 'stat_3' => (string)($before->stat_3 ?? ''), 'dt_stat_3' => $before->dt_stat_3 ?? null, 'cell_corp' => (string)($before->cell_corp ?? 'n'), 'cell_phone' => $this->maskPhoneForLog($beforePlainPhone), 'dt_mod' => $before->dt_mod ?? null, 'state_log_last' => $beforeStateLogLast, ]; // (audit용) after 스냅샷은 before에서 변경분만 덮어쓰기 $afterAudit = $beforeAudit; // 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'] = $nowStr; $stateLog[] = [ 'when' => $legacyWhen, 'after' => $s3, 'title' => '회원상태 접근권한 변경', 'before' => $beforeS3, 'admin_num' => (string)$actorAdminId, ]; $afterAudit['stat_3'] = $s3; $afterAudit['dt_stat_3'] = $nowStr; } } // 통신사 변경 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' => $legacyWhen, 'after' => $corp, 'title' => '통신사 변경', 'before' => $beforeCorp, 'admin_num' => (string)$actorAdminId, ]; $afterAudit['cell_corp'] = $corp; } } // 휴대폰 변경(암호화 저장) $afterPhoneMasked = null; 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 저장 원하면 여기만 변경) $stateLog[] = [ 'when' => $legacyWhen, 'after' => $this->maskPhoneForLog($afterPlain), 'title' => '휴대폰번호 변경', 'before' => $this->maskPhoneForLog($beforePlainPhone), 'admin_num' => (string)$actorAdminId, ]; $afterPhoneMasked = $this->maskPhoneForLog($afterPlain); $afterAudit['cell_phone'] = $afterPhoneMasked; } } 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'] = $nowStr; $ok = $this->repo->updateMember($memNo, $data); if (!$ok) return $this->fail('저장에 실패했습니다.'); // audit log (update 성공 후, 같은 트랜잭션 안에서) $afterStateLogLast = empty($stateLog) ? null : $stateLog[count($stateLog) - 1]; $afterAudit['dt_mod'] = $nowStr; $afterAudit['state_log_last'] = $afterStateLogLast; $this->audit->log( actorAdminId: $actorAdminId, action: 'admin.member.update', targetType: 'member', targetId: $memNo, before: $beforeAudit, after: $afterAudit, ip: $ip, ua: $ua, ); 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, $ip, $ua) { $before = $this->repo->lockMemberForUpdate($memNo); if (!$before) return $this->fail('회원을 찾을 수 없습니다.'); $now = now(); $nowStr = $now->format('Y-m-d H:i:s'); $legacyWhen = $now->format('y-m-d H:i:s'); // 레거시(2자리년도) // 기존 admin_memo에서 레거시 memo[] 뽑기 $list = $this->legacyAdminMemoList($before->admin_memo ?? null); $beforeCount = is_array($list) ? count($list) : 0; $beforeLast = $beforeCount > 0 ? $list[$beforeCount - 1] : null; // append (레거시 키 유지) $newItem = [ 'memo' => $memo, 'when' => $legacyWhen, 'admin_num' => (string)$actorAdminId, ]; $list[] = $newItem; $list = $this->trimLegacyList($list, 300); // admin_memo는 반드시 {"memo":[...]} 형태 $obj = ['memo' => $list]; $data = [ 'admin_memo' => $this->encodeJsonObjectOrNull($obj), 'dt_mod' => $nowStr, ]; $ok = $this->repo->updateMember($memNo, $data); if (!$ok) return $this->fail('메모 저장에 실패했습니다.'); // audit (성공 후) $afterCount = count($list); $afterLast = $afterCount > 0 ? $list[$afterCount - 1] : null; // 감사로그에는 메모 원문을 다 넣지 말고(유출/개인정보 위험), // "추가됨" + 길이/해시/마지막 항목 일부 정도만 남기는 게 안전함. $beforeAudit = [ 'mem_no' => $memNo, 'dt_mod' => $before->dt_mod ?? null, 'admin_memo_cnt' => $beforeCount, 'admin_memo_last'=> $beforeLast, ]; $afterAudit = [ 'mem_no' => $memNo, 'dt_mod' => $nowStr, 'admin_memo_cnt' => $afterCount, 'admin_memo_last'=> $afterLast, 'added' => [ 'when' => $newItem['when'], 'admin_num' => $newItem['admin_num'], 'memo_len' => mb_strlen($memo), // 필요하면 아래처럼 해시만 남겨도 됨(원문 미저장) // 'memo_sha256' => hash('sha256', $memo), ], ]; $this->audit->log( actorAdminId: $actorAdminId, action: 'admin.member.memo.add', targetType: 'member', targetId: $memNo, before: $beforeAudit, after: $afterAudit, ip: $ip, ua: $ua, ); 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); } }