toDateString(); DB::table('mem_auth')->updateOrInsert( ['mem_no' => $memNo, 'auth_type' => $authType], ['auth_state' => $authState, 'auth_date' => $authDate] ); } public function markRequested(int $memNo, string $authType, array $logInfo = []): void { $this->setStateWithLog($memNo, $authType, MemAuth::STATE_R, MemAuthLog::STATE_P, $logInfo); } public function markProcessing(int $memNo, string $authType, array $logInfo = []): void { $this->setStateWithLog($memNo, $authType, MemAuth::STATE_P, MemAuthLog::STATE_P, $logInfo); } public function markSuccess(int $memNo, string $authType, array $logInfo = []): void { $this->setStateWithLog($memNo, $authType, MemAuth::STATE_Y, MemAuthLog::STATE_S, $logInfo); } public function markFail(int $memNo, string $authType, array $logInfo = []): void { $this->setStateWithLog($memNo, $authType, MemAuth::STATE_N, MemAuthLog::STATE_F, $logInfo); } public function mergeAuthInfo(int $memNo, string $authType, array $payload): void { DB::transaction(function () use ($memNo, $authType, $payload) { $row = MemAuthInfo::query()->find($memNo); if (!$row) { $row = new MemAuthInfo(); $row->mem_no = $memNo; $row->auth_info = []; } $data = $row->auth_info ?: []; $data[$authType] = array_merge($data[$authType] ?? [], $payload); $row->auth_info = $data; $row->save(); }); } private function setStateWithLog( int $memNo, string $authType, string $authState, string $logState, array $logInfo ): void { DB::transaction(function () use ($memNo, $authType, $authState, $logState, $logInfo) { $this->upsertState($memNo, $authType, $authState); MemAuthLog::query()->create([ 'mem_no' => $memNo, 'type' => $authType, 'state' => $logState, 'info' => $logInfo, 'rgdate' => Carbon::now()->toDateTimeString(), ]); }); } public function getState(int $memNo, string $authType): ?string { return DB::table('mem_auth') ->where('mem_no', $memNo) ->where('auth_type', $authType) ->value('auth_state'); } private function normalizeKoreanPhone(string $raw): ?string { $digits = preg_replace('/\D+/', '', $raw ?? ''); if (strlen($digits) !== 11) { return null; } if (substr($digits, 0, 3) !== '010') { return null; } return $digits; // ✅ 무조건 11자리 숫자만 리턴 } /** * filter 컬럼에 phone/ip/ip_c가 들어있다는 전제의 기본 구현. * - join_block: A 차단 / S 주의(알림) / N 비활성 */ public function checkJoinFilter(string $phone, string $ip4 = '', string $ip4c = ''): ?array { $targets = array_values(array_filter([$phone, $ip4, $ip4c])); if (!$targets) return null; $rows = MemJoinFilter::query() ->whereIn('filter', $targets) ->where(function ($q) { $q->whereNull('join_block')->orWhere('join_block', '!=', 'N'); }) ->orderByDesc('seq') ->get(); if ($rows->isEmpty()) return null; foreach ($rows as $r) { if ((string)$r->join_block === 'A') { return ['hit' => true, 'block' => true, 'gubun' => $r->gubun ?? 'filter_block', 'row' => $r]; } } foreach ($rows as $r) { if ((string)$r->join_block === 'S') { return ['hit' => true, 'block' => false, 'gubun' => $r->gubun ?? 'filter_notice', 'row' => $r]; } } $r = $rows->first(); return ['hit' => true, 'block' => false, 'gubun' => $r->gubun ?? 'filter_hit', 'row' => $r]; } /** * Step0 통합 처리 */ public function step0PhoneCheck(string $rawPhone, string $carrier = '', string $ip4 = ''): array { $base = [ 'ok' => false, 'reason' => '', 'message' => '', 'redirect' => null, 'phone' => null, 'carrier' => null, 'filter' => null, 'admin_phones' => [], ]; // 0) IP 필터 먼저 $ip4c = $this->ipToCClass($ip4); $ipHit = $this->checkJoinFilterByIp($ip4, $ip4c); $carrier = trim($carrier); $base['carrier'] = $carrier ?: null; if ($ipHit) { $base['filter'] = $ipHit; $base['admin_phones'] = $ipHit['admin_phones'] ?? []; } if ($ipHit && ($ipHit['join_block'] ?? '') === 'A') { return array_merge($base, [ 'ok' => false, 'reason' => 'blocked_ip', 'message' => '현재 가입이 제한된 정보입니다. 고객센터로 문의해 주세요.', ]); } // 1) 전화번호 검증 $phone = $this->normalizeKoreanPhone($rawPhone); if (!$phone) { return array_merge($base, [ 'ok' => false, 'reason' => 'invalid_phone', 'message' => '휴대폰 번호 형식이 올바르지 않습니다.', ]); } $base['phone'] = $phone; // 2) 이미 회원인지 체크 $member = $this->findMemberByPhone($phone, $carrier); // if ($member && !empty($member->mem_no)) { // return array_merge($base, [ // 'ok' => false, // 'reason' => 'already_member', // 'message' => "이미 가입된 전화번호 입니다.\n\n아이디 찾기로 이동할까요?", // 'redirect' => route('web.auth.find_id'), // 'matched_mem_no' => (int) $member->mem_no, // 'matched_cell_corp' => $member->cell_corp ?? null, // ✅ 필요시 // ]); // } // 3) 기존 phone+ip 필터 $filter = $this->checkJoinFilter($phone, $ip4, $ip4c); if ($filter && ($filter['block'] ?? false) === true) { return array_merge($base, [ 'ok' => false, 'reason' => 'blocked', 'filter' => $filter, 'message' => '현재 가입이 제한된 정보입니다. 고객센터로 문의해 주세요.', ]); } // 4) 성공(가입 가능) return array_merge($base, [ 'ok' => true, 'reason' => 'ok', 'filter' => $filter ?: $ipHit, 'message' => "회원가입 가능한 전화번호 입니다.\n\n약관동의 페이지로 이동합니다.", 'redirect' => route('web.auth.register.terms'), ]); } private function findMemberByPhone(string $phone, string $carrier): ?object { /** @var CiSeedCrypto $seed */ $seed = app(CiSeedCrypto::class); $phoneEnc = $seed->encrypt($phone); // 실제로 매칭되는 회원 1건을 가져옴 return DB::table('mem_info') ->select('mem_no', 'cell_phone') ->where('cell_phone', $phoneEnc) ->where('cell_corp', $carrier) ->limit(1) ->first(); } public function existsEmail(string $email): bool { $email = trim($email); if ($email === '') return false; return DB::table('mem_info') ->where('email', $email) // ✅ mem_info.email ->exists(); } public function ipToCClass(string $ip): string { if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { return ''; } $p = explode('.', $ip); return count($p) === 4 ? ($p[0] . '.' . $p[1] . '.' . $p[2] . '.0') : ''; } private function checkJoinFilterByIp(string $ip4, string $ip4c): ?array { // IPv4 아니면 필터 적용 안 함 if (!filter_var($ip4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { return null; } // 우선순위: A(차단) > S(관리자알림) > N(무시) $row = DB::table('mem_join_filter') ->whereIn('join_block', ['A', 'S']) ->where(function ($q) use ($ip4, $ip4c) { // ✅ C class (보통 gubun_code='01', filter='162.168.0.0') $q->where(function ($q2) use ($ip4c) { $q2->where('gubun_code', '01') ->where('filter', $ip4c); }); // ✅ D class (보통 gubun_code='02', filter='162.168.0.2') $q->orWhere(function ($q2) use ($ip4) { $q2->where('gubun_code', '02') ->where('filter', $ip4); }); // ✅ (레거시) gubun_code가 01인데도 filter에 단일 IP가 들어있는 케이스 방어 $q->orWhere(function ($q2) use ($ip4) { $q2->where('gubun_code', '01') ->where('filter', $ip4); }); }) ->orderByRaw("CASE join_block WHEN 'A' THEN 0 WHEN 'S' THEN 1 ELSE 9 END") ->limit(1) ->first(); if (!$row) return null; $adminPhones = $this->parseAdminPhones($row->admin_phone ?? ''); return [ 'gubun_code' => $row->gubun_code ?? null, 'join_block' => $row->join_block ?? null, 'filter' => $row->filter ?? null, 'admin_phones' => $adminPhones, 'ip' => $ip4, 'ip4c' => $ip4c, ]; } private function parseAdminPhones($value): array { // admin_phone: ["010-3682-8958", ...] 형태(=JSON 배열)라고 했으니 JSON 우선 if (is_array($value)) return $value; $s = trim((string)$value); if ($s === '') return []; $json = json_decode($s, true); if (json_last_error() === JSON_ERROR_NONE && is_array($json)) { return array_values(array_filter(array_map('trim', $json))); } // 혹시 "010...,010..." 같은 문자열이면 콤마 분리 fallback $parts = array_map('trim', explode(',', $s)); return array_values(array_filter($parts)); } public function createMemberFromSignup(array $final, array $sessionAll = []): array { // 0) 필수값 최소 체크(컨트롤러에서 검증했지만 방어) $email = strtolower(trim((string)($final['email'] ?? ''))); $pwPlain = (string)($final['password_plain'] ?? ''); $pin2 = (string)($final['pin2_plain'] ?? ''); if ($email === '' || $pwPlain === '' || !preg_match('/^\d{4}$/', $pin2)) { throw new \RuntimeException('invalid_payload'); } $pass = (array)($final['pass'] ?? []); $name = (string)($pass['name'] ?? ''); $carrier = Carrier::toCode((string)($pass['carrier'] ?? 'n')); //통신사 코드로 변경 $phone = preg_replace('/\D+/', '', (string)($pass['phone'] ?? '')); $ci = $pass['ci'] ?? null; $di = $pass['di'] ?? null; // DOB: YYYYMMDD -> YYYY-MM-DD $dob = preg_replace('/\D+/', '', (string)($pass['dob'] ?? '')); $birth = '0000-00-00'; if (strlen($dob) >= 8) { $birth = substr($dob, 0, 4).'-'.substr($dob, 4, 2).'-'.substr($dob, 6, 2); } // foreigner/native/country $foreigner = (string)($pass['foreigner'] ?? '0'); // 값 정의는 네 PASS 규격대로 $native = ($foreigner === '1') ? '2' : '1'; // 외국인=2, 내국인=1 $countryCode = ($native === '1') ? '82' : ''; $countryName = ($native === '1') ? 'Republic of Korea(대한민국)' : ''; // gender mapping (프로젝트 규칙에 맞춰 조정) $sex = (string)($pass['sex'] ?? ''); $gender = match (strtoupper($sex)) { '1', 'M', 'MALE' => '1', '0', 'F', 'FEMALE', '2' => '0', default => '0', }; $ip = (string)($final['meta']['ip'] ?? request()->ip()); $promotion = (string)(data_get($sessionAll, 'signup.promotion', '')) === 'on'; return DB::transaction(function () use ( $email, $pwPlain, $pin2, $name, $birth, $carrier, $phone, $native, $countryCode, $countryName, $gender, $ci, $di, $ip, $promotion, $sessionAll ) { // 1) 접근금지회원 체크(간단 버전) // prohibit_access() 정확한 조건이 있으면 그 조건대로 바꾸면 됨) if (!empty($ci)) { $blocked = DB::table('mem_info') ->where('ci', $ci) ->whereIn('stat_3', ['3','4','5']) ->exists(); if ($blocked) { throw new \RuntimeException('prohibit_access'); } } // 2) mem_info 생성 (휴대폰 암호화 포함은 MemInfoService::register가 처리) $mem = $this->memInfoService->register([ 'email' => $email, 'name' => $name, 'pv_sns' => 'self', 'promotion' => $promotion, 'ip_reg' => $ip, 'country_code' => $countryCode, 'country_name' => $countryName, 'birth' => $birth, 'cell_corp' => $carrier, 'cell_phone' => $phone, // 평문 → register()에서 암호화 'native' => $native, 'gender' => $gender, 'ci' => $ci, 'di' => $di, 'ci_v' => '1', ]); $memNo = (int)$mem->mem_no; $now = Carbon::now()->format('Y-m-d H:i:s'); //다날 로그 업데이트 $danalLogSeq = (int) data_get($sessionAll, 'register.danal_log_seq', 0); if ($danalLogSeq > 0) { $this->updateDanalAuthLogMemNo($danalLogSeq, $memNo); } /*회원가입 아이피 필터 업데이트*/ $ipfResult = (string) data_get($sessionAll, 'signup.ipf_result', ''); $ipfSeq = (int) data_get($sessionAll, 'signup.ipf_seq', 0); // if ($ipfResult === 'A') { // // 이 케이스는 원래 step0에서 막혀야 정상이지만, 방어적으로 한번 더 // throw new \RuntimeException('회원가입이 제한된 IP입니다.'); // } if ($ipfResult === 'S' && $ipfSeq > 0) { $this->updateJoinLogAfterSignup($ipfSeq, $memNo, $email); } /*회원가입 아이피 필터 업데이트*/ // 3) mem_st_ring 비번 저장 (CI3 macro->pass + sha512(pin2)와 동일) [$str0, $str1, $str2] = CiPassword::makeAll($pwPlain); $passwd2 = CiPassword::makePass2($pin2); DB::statement( "INSERT INTO mem_st_ring (mem_no, str_0, str_1, str_2, dt_reg, passwd2, passwd2_reg) VALUES (?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE str_0=VALUES(str_0), str_1=VALUES(str_1), str_2=VALUES(str_2), dt_reg=VALUES(dt_reg), passwd2=VALUES(passwd2), passwd2_reg=VALUES(passwd2_reg)", [$memNo, $str0, $str1, $str2, $now, $passwd2, $now] ); // 4) mem_auth 휴대폰 인증 처리 DB::table('mem_auth')->updateOrInsert( ['mem_no' => $memNo, 'auth_type' => 'cell'], ['auth_state' => 'Y', 'auth_date' => date('Y-m-d')] ); // 5) mem_auth_log 가입 로그 저장 (CI3처럼 세션 스냅샷) DB::table('mem_auth_log')->insert([ 'mem_no' => $memNo, 'type' => 'signup', 'state' => 'S', 'info' => json_encode($sessionAll, JSON_UNESCAPED_UNICODE), 'rgdate' => $now, ]); // 6) (TODO) email_auth init + 메일 발송은 다음 단계에서 연결 // 지금은 우선 회원 저장까지 완성 return ['ok' => true, 'mem_no' => $memNo]; }); } public function insertDanalAuthLog(string $gubun, array $res): int { // gubun: 'J' (회원가입), 'M'(정보수정) try { return (int) DB::table('mem_danalauthtel_log')->insertGetId([ 'gubun' => $gubun, 'TID' => (string)($res['TID'] ?? ''), 'res_code' => (string)($res['RETURNCODE'] ?? ''), 'mem_no' => null, 'info' => json_encode($res, JSON_UNESCAPED_UNICODE), 'rgdate' => now()->format('Y-m-d H:i:s'), ]); } catch (\Throwable $e) { Log::error('[danal] insert log failed', [ 'err' => $e->getMessage(), ]); return 0; // 로그 실패해도 플로우는 계속 } } public function updateDanalAuthLogMemNo(int $logSeq, int $memNo): void { if ($logSeq <= 0 || $memNo <= 0) return; try { DB::table('mem_danalauthtel_log') ->where('seq', $logSeq) ->update(['mem_no' => $memNo]); } catch (\Throwable $e) { Log::error('[danal] update log mem_no failed', [ 'seq' => $logSeq, 'mem_no' => $memNo, 'err' => $e->getMessage(), ]); // 여기서 throw 할지 말지는 정책인데, // 레거시 흐름대로면 "가입은 살리고 로그만 실패"가 맞음. } } public function precheckJoinIpFilterAndLog(array $userInfo): array { // $userInfo keys: // mem_no(없으면 0), cell_corp, cell_phone(암호화), email('-'), ip4, ip4_c(앞 3옥텟), dt_reg(optional) $ip4 = (string)($userInfo['ip4'] ?? ''); $ip4c = (string)($userInfo['ip4_c'] ?? ''); if ($ip4 === '' || $ip4c === '') { return ['result' => 'P', 'seq' => 0, 'admin_phones' => []]; } // join_block 우선순위 A > S $row = DB::table('mem_join_filter') ->whereIn('join_block', ['A', 'S']) ->whereIn('gubun_code', ['01', '02']) ->where(function ($q) use ($ip4, $ip4c) { $q->where('filter', $ip4c) ->orWhere('filter', $ip4); }) ->orderByRaw("CASE join_block WHEN 'A' THEN 0 WHEN 'S' THEN 1 ELSE 9 END") ->orderByDesc('seq') ->first(); if (!$row) { return ['result' => 'P', 'seq' => 0, 'admin_phones' => []]; } $result = (string)($row->join_block ?? 'P'); // 'A' or 'S' $gubun = (string)($row->gubun_code ?? ''); // admin_phone JSON decode $adminPhones = []; $raw = $row->admin_phone ?? null; if (is_string($raw) && $raw !== '') { $j = json_decode($raw, true); if (json_last_error() === JSON_ERROR_NONE && is_array($j)) { $adminPhones = array_values(array_filter(array_map('trim', $j))); } } // 관리자 SMS (S만 보낼지, A도 보낼지는 정책인데) // CI3 코드는 S에서만 발송했지만, 주석/정책상 A도 발송하는게 더 안전해서 A도 발송하도록 권장. if (in_array($result, ['S','A'], true) && !empty($adminPhones)) { foreach ($adminPhones as $phone) { $smsPayload = [ 'from_number' => config('services.sms.from', '1833-4856'), 'to_number' => $phone, 'message' => '[PIN FOR YOU] 회원가입필터 IP에서 가입 시도됨! 관리자 확인요망 '.date('m-d H:i'), 'sms_type' => 'sms', ]; app(SmsService::class)->send($smsPayload, 'lguplus'); } } // ✅ mem_join_log 기록 (CI3: S 또는 A 일때만 기록) $seq = (int) DB::table('mem_join_log')->insertGetId([ 'gubun' => $gubun, // CI3: gubun_code를 gubun에 저장 'mem_no' => (int)($userInfo['mem_no'] ?? 0), // 가입 전이면 0 'cell_corp' => (string)($userInfo['cell_corp'] ?? 'n'), 'cell_phone' => (string)($userInfo['cell_phone'] ?? ''), 'email' => (string)($userInfo['email'] ?? '-'), 'ip4' => $ip4, 'ip4_c' => $ip4c, 'error_code' => $result, // ✅ join_block을 error_code에 저장(추적용) 'dt_reg' => $userInfo['dt_reg'] ?? date('Y-m-d H:i:s'), ]); return [ 'result' => $result, // 'A' or 'S' 'seq' => $seq, 'admin_phones' => $adminPhones, ]; } /** * 가입 성공 후 mem_no/email 업데이트 (CI3 ip_check_update 대응) */ public function updateJoinLogAfterSignup(int $seq, int $memNo, string $email): void { DB::table('mem_join_log') ->where('seq', $seq) ->update([ 'mem_no' => $memNo, 'email' => $email, ]); } }