giftcon_dev/app/Services/Admin/Member/AdminMemberService.php
2026-02-11 10:43:37 +09:00

429 lines
16 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);
$adminMemo = $this->decodeJsonArray($member->admin_memo ?? null);
$modifyLog = $this->decodeJsonArray($member->modify_log ?? null);
$modifyLog = array_reverse($modifyLog);
$actorIds = [];
foreach ($modifyLog as $it) {
$aid = (int)($it['actor_admin_id'] ?? 0);
if ($aid > 0) $actorIds[] = $aid;
}
// 인증/주소/로그
$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($actorIds);
return [
'member' => $member,
'corpLabel' => $corpLabel,
'plainPhone' => $plainPhone,
'phoneDisplay' => $phoneDisplay,
'adminMemo' => $adminMemo,
'modifyLog' => $modifyLog,
'authRows' => $authRows,
'authInfo' => $authInfo,
'authLogs' => $authLogs,
'addresses' => $addresses,
'stat3Map' => $this->stat3Map(),
'genderMap' => $this->genderMap(),
'nativeMap' => $this->nativeMap(),
'corpMap' => $this->corpMap(),
'bank' => $bank,
'modifyLog' => $modifyLog,
'adminMap' => $adminMap,
];
}
/**
* ✅ 업데이트 허용: 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('회원을 찾을 수 없습니다.');
$beforeArr = (array)$before;
$data = [];
$changes = [];
// ✅ 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 상태는 시스템 상태로 관리자 변경이 불가합니다.');
}
if ($s3 !== (string)($before->stat_3 ?? '')) {
$data['stat_3'] = $s3;
$data['dt_stat_3'] = now()->format('Y-m-d H:i:s');
}
}
// ✅ 통신사
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('통신사 코드가 올바르지 않습니다.');
if ($corp !== (string)($before->cell_corp ?? 'n')) $data['cell_corp'] = $corp;
}
// ✅ 휴대폰(암호화 저장)
if (array_key_exists('cell_phone', $input)) {
$raw = trim((string)($input['cell_phone'] ?? ''));
if ($raw === '') {
// 전화번호 비우는 것 자체는 허용하되 운영 정책에 따라 막고 싶으면 여기서 fail 처리
$enc = '';
} else {
$phone = $this->normalizeKrPhone($raw);
if ($phone === '') return $this->fail('휴대폰 번호 형식이 올바르지 않습니다.');
$enc = $this->encryptPhone($phone);
}
if ((string)($before->cell_phone ?? '') !== $enc) {
$data['cell_phone'] = $enc;
}
}
if (empty($data)) {
return $this->ok('변경사항이 없습니다.');
}
foreach ($data as $k => $v) {
$beforeVal = $beforeArr[$k] ?? null;
if ((string)$beforeVal !== (string)$v) {
$changes[$k] = ['before' => $beforeVal, 'after' => $v];
}
}
// modify_log append
$modify = $this->decodeJsonArray($before->modify_log ?? null);
$modify = $this->appendJson($modify, [
'ts' => now()->format('Y-m-d H:i:s'),
'actor_admin_id' => $actorAdminId,
'action' => 'member_update',
'ip' => $ip,
'ua' => $ua,
'changes' => $changes,
], 300);
$data['modify_log'] = $this->encodeJsonOrNull($modify);
$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);
}
// -------------------------
// JSON helpers
// -------------------------
private function decodeJsonArray($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 encodeJsonOrNull(array $arr): ?string
{
if (empty($arr)) return null;
return json_encode($arr, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
private function appendJson(array $list, array $entry, int $max = 200): array
{
array_unshift($list, $entry);
if (count($list) > $max) $list = array_slice($list, 0, $max);
return $list;
}
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) {
// row lock
$before = $this->repo->lockMemberForUpdate($memNo);
if (!$before) return $this->fail('회원을 찾을 수 없습니다.');
// 기존 memo json
$adminMemo = $this->decodeJsonArray($before->admin_memo ?? null);
// append
$adminMemo = $this->appendJson($adminMemo, [
'ts' => now()->format('Y-m-d H:i:s'),
'actor_admin_id' => $actorAdminId,
'ip' => $ip,
'ua' => $ua,
'memo' => $memo,
], 300);
// (선택) modify_log에도 남기기
$modify = $this->decodeJsonArray($before->modify_log ?? null);
$modify = $this->appendJson($modify, [
'ts' => now()->format('Y-m-d H:i:s'),
'actor_admin_id' => $actorAdminId,
'action' => 'admin_memo_add',
'ip' => $ip,
'ua' => $ua,
'changes' => ['admin_memo' => ['before' => null, 'after' => 'added']],
], 300);
$data = [
'admin_memo' => $this->encodeJsonOrNull($adminMemo),
'modify_log' => $this->encodeJsonOrNull($modify),
'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]; }
}