giftcon_dev/app/Repositories/Member/MemberAuthRepository.php
2026-02-11 10:43:37 +09:00

773 lines
27 KiB
PHP

<?php
namespace App\Repositories\Member;
use App\Models\Member\MemAuth;
use App\Models\Member\MemAuthInfo;
use App\Models\Member\MemAuthLog;
use App\Models\Member\MemInfo;
use App\Models\Member\MemJoinFilter;
use App\Support\Legacy\Carrier;
use App\Services\SmsService;
use App\Services\MemInfoService;
use App\Support\LegacyCrypto\CiSeedCrypto;
use App\Support\LegacyCrypto\CiPassword;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Carbon;
class MemberAuthRepository
{
public function __construct(private readonly MemInfoService $memInfoService)
{
}
/* =========================================================
* mem_auth (기존)
* ========================================================= */
public function upsertState(
int $memNo,
string $authType,
string $authState,
?string $authDate = null
): void {
$authDate = $authDate ?: Carbon::now()->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 === '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,
]);
}
public function findByEmailAndName(string $emailLower, string $name): ?MemInfo
{
// ⚠️ 성명 컬럼이 name이 아니면 여기만 바꾸면 됨
return MemInfo::query()
->whereNotNull('email')->where('email', '<>', '')
->whereRaw('LOWER(email) = ?', [$emailLower])
->where('name', $name)
->orderByDesc('mem_no')
->first();
}
public function findByMemNo(int $memNo): ?MemInfo
{
return MemInfo::query()
->where('mem_no', $memNo)
->first();
}
public function existsByEncryptedPhone(string $phoneEnc): bool
{
return DB::table('mem_info')
->whereNotNull('email')
->where('email', '<>', '')
->where('cell_phone', $phoneEnc)
->exists();
}
/**
* 해당 암호화 휴대폰으로 가입된 이메일 목록(중복 제거)
*/
public function getEmailsByEncryptedPhone(string $phoneEnc): array
{
// 여러 mem_no에서 같은 email이 나올 수 있으니 distinct 처리
return DB::table('mem_info')
->select('email')
->whereNotNull('email')
->where('email', '<>', '')
->where('cell_phone', $phoneEnc)
->orderByDesc('mem_no')
->distinct()
->pluck('email')
->values()
->all();
}
//비밀번호 수정
public function updatePasswordOnly(int $memNo, string $pwPlain): void
{
if ($memNo <= 0 || $pwPlain === '') {
throw new \InvalidArgumentException('invalid_payload');
}
[$str0, $str1, $str2] = CiPassword::makeAll($pwPlain);
$now = now()->format('Y-m-d H:i:s');
$affected = DB::table('mem_st_ring')
->where('mem_no', $memNo)
->update([
'str_0' => $str0,
'str_1' => $str1,
'str_2' => $str2,
'dt_reg' => $now,
]);
// 기존회원인데 mem_st_ring row가 없다? 이건 데이터 이상으로 보고 명확히 실패 처리
if ($affected <= 0) {
throw new \RuntimeException('mem_st_ring_not_found');
}
}
//회원 탈퇴 1차 비밀번호 검증
public function verifyLegacyPassword(int $memNo, string $pwPlain): bool
{
$pwPlain = (string) $pwPlain;
if ($memNo <= 0 || $pwPlain === '') {
return false;
}
// ✅ 1차 비번은 mem_st_ring.str_0
$stored = (string) (DB::table('mem_st_ring')
->where('mem_no', $memNo)
->value('str_0') ?? '');
if ($stored === '') {
return false;
}
// ✅ CI 방식(PASS_SET=0) - 기존 attemptLegacyLogin과 동일
$try = (string) CiPassword::make($pwPlain, 0);
if ($try === '') {
return false;
}
return hash_equals($stored, $try);
}
// 2차 비밀번호 검증
public function verifyPin2(int $memNo, string $pin2Plain): bool
{
if ($memNo <= 0) return false;
// 4자리 숫자만 허용
if (!preg_match('/^\d{4}$/', $pin2Plain)) return false;
$stored = (string) DB::table('mem_st_ring')
->where('mem_no', $memNo)
->value('passwd2');
if ($stored === '') return false;
$hash = CiPassword::makePass2($pin2Plain);
return hash_equals($stored, $hash);
}
// 2차 비밀번호 수정
public function updatePin2Only(int $memNo, string $pin2Plain): void
{
if ($memNo <= 0 || $pin2Plain === '') {
throw new \InvalidArgumentException('invalid_payload');
}
// 4자리 숫자 강제 (컨트롤러에서도 validate 하지만 2중 방어)
if (!preg_match('/^\d{4}$/', $pin2Plain)) {
throw new \InvalidArgumentException('invalid_pin2');
}
$passwd2 = CiPassword::makePass2($pin2Plain);
$now = now()->format('Y-m-d H:i:s');
// mem_st_ring 존재 전제: 없으면 데이터 이상으로 실패 처리(비번 변경과 동일 정책)
$affected = DB::table('mem_st_ring')
->where('mem_no', $memNo)
->update([
'passwd2' => $passwd2,
'passwd2_reg' => $now,
]);
if ($affected <= 0) {
throw new \RuntimeException('mem_st_ring_not_found');
}
}
public function logPasswordResetSuccess(int $memNo, string $ip, string $agent, string $state = 'E'): void
{
$now = now()->format('Y-m-d H:i:s');
// state: E=비밀번호찾기 변경, S=직접 변경
$state = strtoupper(trim($state));
if (!in_array($state, ['E', 'S'], true)) {
$state = 'E';
}
DB::table('mem_passwd_modify')->insert([
'state' => $state,
'info' => json_encode([
'mem_no' => (string)$memNo,
'redate' => $now,
'remote_addr' => $ip,
'agent' => substr((string)$agent, 0, 500), // 길이 방어(일관 처리)
], JSON_UNESCAPED_UNICODE),
'rgdate' => $now,
]);
}
}