diff --git a/app/Http/Controllers/Web/Auth/FindIdController.php b/app/Http/Controllers/Web/Auth/FindIdController.php
index 172a4b8..c3ea323 100644
--- a/app/Http/Controllers/Web/Auth/FindIdController.php
+++ b/app/Http/Controllers/Web/Auth/FindIdController.php
@@ -5,7 +5,7 @@ namespace App\Http\Controllers\Web\Auth;
use App\Http\Controllers\Controller;
use App\Services\SmsService;
use App\Support\LegacyCrypto\CiSeedCrypto;
-use App\Models\MemInfo;
+use App\Models\Member\MemInfo;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Validator;
@@ -95,24 +95,24 @@ class FindIdController extends Controller
// SMS 발송
try {
-// $smsPayload = [
-// 'from_number' => config('services.sms.from', '1833-4856'), // 기본 발신번호
-// 'to_number' => $phone,
-// 'message' => "[PIN FOR YOU] 인증번호 {$code} 를 입력해 주세요. (3분 이내)",
-// 'sms_type' => 'sms', // 짧으니 sms 고정(원하면 자동 판단으로 빼도 됨)
-// // 'country' => '82', // 필요 시 강제
-// ];
-//
-// $ok = app(SmsService::class)->send($smsPayload, 'lguplus');
-//
-// if (!$ok) {
-// // 실패 시 세션 정리(인증 진행 꼬임 방지)
-// $request->session()->forget('find_id');
-// return response()->json([
-// 'ok' => false,
-// 'message' => '문자 발송에 실패했습니다. 잠시 후 다시 시도해 주세요.',
-// ], 500);
-// }
+ $smsPayload = [
+ 'from_number' => config('services.sms.from', '1833-4856'), // 기본 발신번호
+ 'to_number' => $phone,
+ 'message' => "[PIN FOR YOU] 인증번호 {$code} 를 입력해 주세요. (3분 이내)",
+ 'sms_type' => 'sms', // 짧으니 sms 고정(원하면 자동 판단으로 빼도 됨)
+ // 'country' => '82', // 필요 시 강제
+ ];
+
+ $ok = app(SmsService::class)->send($smsPayload, 'lguplus');
+
+ if (!$ok) {
+ // 실패 시 세션 정리(인증 진행 꼬임 방지)
+ $request->session()->forget('find_id');
+ return response()->json([
+ 'ok' => false,
+ 'message' => '문자 발송에 실패했습니다. 잠시 후 다시 시도해 주세요.',
+ ], 500);
+ }
} catch (\Throwable $e) {
$request->session()->forget('find_id');
diff --git a/app/Http/Controllers/Web/Auth/FindPasswordController.php b/app/Http/Controllers/Web/Auth/FindPasswordController.php
index f2be313..8fae1b8 100644
--- a/app/Http/Controllers/Web/Auth/FindPasswordController.php
+++ b/app/Http/Controllers/Web/Auth/FindPasswordController.php
@@ -5,7 +5,7 @@ namespace App\Http\Controllers\Web\Auth;
use App\Services\MailService;
use App\Http\Controllers\Controller;
-use App\Models\MemInfo;
+use App\Models\Member\MemInfo;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\RateLimiter;
diff --git a/app/Http/Controllers/Web/Auth/LoginController.php b/app/Http/Controllers/Web/Auth/LoginController.php
new file mode 100644
index 0000000..5ad5d36
--- /dev/null
+++ b/app/Http/Controllers/Web/Auth/LoginController.php
@@ -0,0 +1,111 @@
+ ['required', 'string', 'email', 'max:60'],
+ 'mem_pw' => ['required', 'string', 'max:100'],
+ 'return_url'=> ['nullable', 'string', 'max:2000'],
+ ];
+
+ if (app()->environment('production')) {
+ $rules['g-recaptcha-response'] = ['required', new RecaptchaV3Rule('login')];
+ }
+
+ $v = Validator::make($request->all(), $rules, [
+ 'mem_email.required' => '아이디 혹은 비밀번호가 일치하지 않습니다.',
+ 'mem_email.email' => '아이디 혹은 비밀번호가 일치하지 않습니다.',
+ 'mem_pw.required' => '아이디 혹은 비밀번호가 일치하지 않습니다.',
+ 'g-recaptcha-response.required' => '올바른 접근이 아닙니다.',
+ ]);
+
+ if ($v->fails()) {
+ return back()->withErrors($v)->withInput();
+ }
+
+ $email = strtolower(trim((string)$request->input('mem_email')));
+ $pw = (string)$request->input('mem_pw');
+
+ // return_url: 오픈리다이렉트 방지 (내부 path만 허용)
+ $returnUrl = (string)($request->input('return_url') ?? '/');
+ if ($returnUrl === '' || str_starts_with($returnUrl, 'http://') || str_starts_with($returnUrl, 'https://') || str_starts_with($returnUrl, '//')) {
+ $returnUrl = '/';
+ }
+ if (!str_starts_with($returnUrl, '/')) {
+ $returnUrl = '/';
+ }
+
+ $res = $memInfoService->attemptLegacyLogin([
+ 'email' => $email,
+ 'password' => $pw,
+ 'ip' => $request->ip(),
+ 'ua' => substr((string)$request->userAgent(), 0, 500),
+ 'return_url' => $returnUrl,
+ ]);
+
+ if (!$res['ok']) {
+ // UI 처리 방식은 프로젝트 스타일에 맞춰
+ // (일단 errors로 처리)
+ return back()
+ ->withErrors(['login' => $res['message']])
+ ->withInput(['mem_email' => $email]);
+ }
+
+ // 세션 저장
+ AuthSession::putMember($res['session']);
+
+
+ return redirect()->to($res['redirect'] ?? $returnUrl);
+ }
+
+ /**
+ * (옵션) 휴면 해제 링크 처리 - 최소 골격
+ * 실제 로직은 다음 단계에서 dormancy 테이블 검증/만료/상태변경까지 붙이면 됨
+ */
+ public function dormancyPrc(Request $request)
+ {
+ // TODO: Crypt::decryptString(authnum) -> "auth_key|seq"
+ // TODO: mem_dormancy 검증/만료/인증완료 처리
+ return redirect()->route('web.auth.login')
+ ->withErrors(['login' => '휴면 해제 처리는 다음 단계에서 연결합니다.']);
+ }
+
+ public function logout(Request $request)
+ {
+ $request->session()->forget('_sess');
+
+ // (선택) 회원가입/본인인증 진행 세션까지 같이 정리하고 싶으면 추가
+ $request->session()->forget('signup');
+ $request->session()->forget('register');
+
+ // (선택) 디버그 세션 정리
+ $request->session()->forget('debug');
+
+ // ✅ 세션 저장
+ $request->session()->save();
+
+ return redirect()->route('web.home')
+ ->with('ui_dialog', [
+ 'type' => 'alert',
+ 'title' => '안내',
+ 'message' => '로그아웃 되었습니다.',
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Web/Auth/RegisterController.php b/app/Http/Controllers/Web/Auth/RegisterController.php
index a1f57c5..290f607 100644
--- a/app/Http/Controllers/Web/Auth/RegisterController.php
+++ b/app/Http/Controllers/Web/Auth/RegisterController.php
@@ -12,8 +12,11 @@ use Illuminate\Support\Facades\Log;
class RegisterController extends Controller
{
- public function showStep0()
+ public function showStep0(Request $request)
{
+ //기존 인증 받다가 중지된 정보가 있다면 세션값 지우기
+ $this->clearSession($request, 'signup');
+ $this->clearSession($request, 'register');
return view('web.auth.register');
}
@@ -32,9 +35,12 @@ class RegisterController extends Controller
{
$v = Validator::make($request->all(), [
'phone' => ['required', 'string', 'max:20'],
+ 'carrier' => ['required', 'in:01,02,03,04,05,06'], //01:SKT,02:KT,03:LGU+,04:SKT(알뜰폰),05:KT(알뜰폰),06:LGU+(알뜰폰)'
'g-recaptcha-response' => ['required', new RecaptchaV3Rule('register_phone_check')],
], [
'phone.required' => '휴대폰 번호를 입력해 주세요.',
+ 'carrier.required' => '통신사를 선택해 주세요.',
+ 'carrier.in' => '통신사 선택 값이 올바르지 않습니다.',
'g-recaptcha-response.required' => '보안 검증에 실패했습니다. 다시 시도해 주세요.',
]);
@@ -42,8 +48,43 @@ class RegisterController extends Controller
return response()->json(['ok' => false, 'message' => $v->errors()->first()], 422);
}
- $ip4 = $request->ip() ?: '';
- $result = $repo->step0PhoneCheck((string)$request->input('phone'), $ip4);
+ /* 회원가입 필터 아이피로 차단 및 관리자 알림*/
+ $ip = $request->ip();
+ $ip4c = implode('.', array_slice(explode('.', $ip), 0, 3)); //C-CLASS CHECK (210.96.177)
+ if (!$request->session()->has('signup.ipf_result')) {
+ $ipCheck = $repo->precheckJoinIpFilterAndLog([
+ 'mem_no' => 0,
+ 'cell_corp' => $request->input('carrier'),
+ 'cell_phone' => $request->input('phone'),
+ 'email' => '-',
+ 'ip4' => $ip,
+ 'ip4_c' => $ip4c,
+ ]);
+
+ $request->session()->put('signup.ipf_result', $ipCheck['result']); // A/S/P
+ $request->session()->put('signup.ipf_seq', (int)($ipCheck['seq'] ?? 0));
+
+ if ($ipCheck['result'] === 'A') {
+ // 가입 차단
+ return response()->json([
+ 'ok' => false,
+ 'message' => '회원가입에 문제가 발생하였습니다. 고객센터에 문의하세요!',
+ ], 403);
+ }
+ }
+ /* 회원가입 필터 아이피로 차단 및 관리자 알림*/
+
+ $map = [ //다날로 보내는 값
+ '01' => 'SKT', '02' => 'KTF', '03' => 'LGT',
+ '04' => 'MVNO','05' => 'MVNO','06' => 'MVNO',
+ ];
+
+ $result = $repo->step0PhoneCheck(
+ (string) $request->input('phone'),
+ (string) $request->input('carrier'),
+ );
+
+ $danalCarrier = $map[$result['carrier']] ?? null;
if (!($result['ok'] ?? false)) {
$reason = $result['reason'] ?? 'error';
@@ -59,7 +100,8 @@ class RegisterController extends Controller
}
$request->session()->put('signup.step', 1);
- $request->session()->put('signup.phone', $result['phone']); // 필요하면
+ $request->session()->put('signup.phone', $result['phone']);
+ $request->session()->put('signup.carrier', $danalCarrier);
$request->session()->save();
return response()->json([
@@ -68,6 +110,7 @@ class RegisterController extends Controller
'message' => $result['message'] ?? '',
'redirect' => $result['redirect'] ?? null,
'phone' => $result['phone'] ?? null,
+ 'carrier' => $danalCarrier,
], 200);
}
@@ -154,21 +197,42 @@ class RegisterController extends Controller
public function danalStart(Request $request)
{
// fields는 JSON으로 받을 것
- $fieldsJson = (string)$request->input('fields', '');
+ $fieldsJson = (string) $request->input('fields', '');
$fields = json_decode($fieldsJson, true);
if (!is_array($fields) || empty($fields)) {
abort(400, 'Invalid Danal fields');
}
+ $platform = strtolower((string) $request->input('platform', ($fields['platform'] ?? '')));
+ $isMobile = false;
+ if ($platform === 'mobile') {
+ $isMobile = true;
+ } elseif ($platform === 'web') {
+ $isMobile = false;
+ } else {
+ $ua = (string) $request->header('User-Agent', '');
+ $isMobile = (bool) preg_match('/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i', $ua);
+ }
+
+ $fields['IsDstAddr'] = $request->session()->get('signup.phone');
+ $fields['IsCarrier'] = $request->session()->get('signup.carrier');
+
+ $action = $isMobile
+ ? 'https://wauth.teledit.com/Danal/WebAuth/Mobile/Start.php'
+ : 'https://wauth.teledit.com/Danal/WebAuth/Web/Start.php';
+
+ unset($fields['platform']);
+
return view('web.auth.danal_autosubmit', [
- 'action' => 'https://wauth.teledit.com/Danal/WebAuth/Web/Start.php',
+ 'action' => $action,
'fields' => $fields,
+ 'isMobile' => $isMobile,
]);
}
- public function danalResult(Request $request, DanalAuthtelService $danal)
+ public function danalResult(Request $request, DanalAuthtelService $danal, MemberAuthRepository $repo)
{
$payload = $request->all();
@@ -198,6 +262,12 @@ class RegisterController extends Controller
Log::info('[DANAL][CONFIRM] res', $this->maskDanalPayloadForLog($res));
}
+ //repo로 로그 1차 저장” (성공/실패 무조건 저장)
+ $logSeq = $repo->insertDanalAuthLog('J', (array)$res);
+ if ($logSeq > 0) {
+ $request->session()->put('register.danal_log_seq', $logSeq);
+ }
+
$ok = (($res['RETURNCODE'] ?? '') === '0000');
if (!$ok) {
@@ -243,8 +313,6 @@ class RegisterController extends Controller
]);
}
-
-
// 성공: 다음 단계에서 쓸 데이터 세션 저장
$request->session()->put('register.pass_verified', true);
$request->session()->put('register.pass_payload', $res);
@@ -271,6 +339,160 @@ class RegisterController extends Controller
return view('web.auth.profile');
}
+ public function checkLoginId(Request $request, MemberAuthRepository $repo)
+ {
+ $v = Validator::make($request->all(), [
+ 'login_id' => ['required', 'string', 'max:60', 'email'],
+ ], [
+ 'login_id.required' => '아이디(이메일)를 입력해 주세요.',
+ 'login_id.email' => '이메일 형식으로 입력해 주세요.',
+ ]);
+
+ if ($v->fails()) {
+ return response()->json([
+ 'ok' => false,
+ 'message' => $v->errors()->first(),
+ ], 422);
+ }
+
+ $email = (string) $request->input('login_id');
+
+ // ✅ repo에서 mem_info.email 중복 체크
+ $exists = $repo->existsEmail($email);
+
+ return response()->json([
+ 'ok' => !$exists,
+ 'message' => $exists ? '이미 사용 중인 아이디입니다.' : '사용 가능한 아이디입니다.',
+ ]);
+ }
+
+ public function submitProfile(Request $request, MemberAuthRepository $repo)
+ {
+ // 0) PASS 인증 안됐으면 컷
+ if (!$request->session()->get('register.pass_verified')) {
+ return redirect()->route('web.auth.register.terms')
+ ->with('ui_dialog', [
+ 'type' => 'alert',
+ 'title' => '안내',
+ 'message' => '본인인증 후 진행해 주세요.',
+ ]);
+ }
+
+ // 1) 입력값 검증 (아이디=이메일)
+ $v = Validator::make($request->all(), [
+ 'login_id' => ['required', 'string', 'email', 'max:60'],
+ 'password' => [
+ 'required', 'string', 'min:8', 'max:20',
+ 'regex:/^(?=.*[A-Za-z])(?=.*\d)(?=.*[^A-Za-z0-9\s]).+$/'
+ ],
+ 'password_confirmation' => ['required', 'same:password'],
+ 'pin2' => ['required', 'digits:4'],
+ 'pin2_confirmation' => ['required', 'same:pin2'],
+ ], [
+ 'login_id.required' => '아이디(이메일)를 입력해 주세요.',
+ 'login_id.email' => '아이디는 이메일 형식이어야 합니다.',
+ 'password.required' => '비밀번호를 입력해 주세요.',
+ 'password.min' => '비밀번호는 8자리 이상이어야 합니다.',
+ 'password.max' => '비밀번호는 20자리를 초과할 수 없습니다.',
+ 'password.regex' => '비밀번호는 영문+숫자+특수문자를 포함해야 합니다.',
+ 'password_confirmation.same' => '비밀번호 확인이 일치하지 않습니다.',
+ 'pin2.required' => '2차 비밀번호(숫자 4자리)를 입력해 주세요.',
+ 'pin2.digits' => '2차 비밀번호는 숫자 4자리여야 합니다.',
+ 'pin2_confirmation.same' => '2차 비밀번호 확인이 일치하지 않습니다.',
+ ]);
+
+ if ($v->fails()) {
+ return back()->withErrors($v)->withInput();
+ }
+
+ // 2) 아이디(=이메일) 정규화
+ $email = strtolower(trim((string)$request->input('login_id')));
+
+ // 3) 중복 체크는 Repository로만
+ if ($repo->existsEmail($email)) {
+ return back()->withErrors(['login_id' => '이미 사용 중인 아이디입니다.'])->withInput();
+ }
+
+ // 4) PASS payload + signup 세션값 가져오기
+ $pv = (array) $request->session()->get('register.pass_payload', []);
+ $signupPhone = (string) $request->session()->get('signup.phone', '');
+ $signupCarrier = (string) $request->session()->get('signup.carrier', '');
+
+ if ($signupPhone !== $pv['PHONE'] || $signupCarrier !== $pv['CARRIER']) {
+ return back()->withErrors(['login_id' => '처음등록한 전화번호와 인증받은 전화번호 정보가 일치 하지 않습니다..'])->withInput();
+ }
+
+
+ // 5) 가공(서버에서 최종 사용 데이터 구성)
+ $final = [
+ // 회원 입력
+ 'email' => $email,
+ 'password_plain' => (string)$request->input('password'), // 실제 저장은 Hash로 (아래에서 처리)
+ 'pin2_plain' => (string) $request->input('pin2'),
+ // PASS 인증 결과
+ 'pass' => [
+ 'return_code' => $pv['RETURNCODE'] ?? null,
+ 'tid' => $pv['TID'] ?? null,
+ 'name' => $pv['NAME'] ?? null,
+ 'dob' => $pv['DOB'] ?? null,
+ 'sex' => $pv['SEX'] ?? null,
+
+ 'foreigner' => $pv['FOREIGNER'] ?? null,
+ 'phone' => $pv['PHONE'] ?? $signupPhone,
+ 'carrier' => $pv['CARRIER'] ?? $signupCarrier,
+ 'ci' => $pv['CI'] ?? null,
+ 'di' => $pv['DI'] ?? null,
+ ],
+ 'meta' => [
+ 'ip' => $request->ip(),
+ 'ua' => substr((string)$request->userAgent(), 0, 200),
+ ],
+ ];
+
+ // 비밀번호는 로그에 남기면 사고남 → 마스킹/제거
+ $logFinal = $final;
+ $logFinal['password_plain'] = '***masked***';
+ $logFinal['pin2_plain'] = '***masked***';
+
+ // 6) 컨트롤러에서 "넘어온 값" 확인: 로그로 보기
+ Log::info('[register.profile.submit] payload', $logFinal);
+
+ // 7) 여기부터 실제 저장 로직 (Repository로 위임)
+ try {
+ $res = $repo->createMemberFromSignup($final, $request->session()->all());
+
+ // 성공 처리 후 세션 정리/이동
+ $this->clearSession($request, 'signup');
+ $this->clearSession($request, 'register');
+
+ return redirect()->route('web.auth.login')
+ ->with('ui_dialog', [
+ 'type'=>'alert',
+ 'title'=>'완료',
+ 'message'=>'가입이 완료되었습니다. 로그인해 주세요.',
+ ]);
+
+ } catch (\Throwable $e) {
+ Log::error('[register.profile.submit] commit failed', [
+ 'email' => $final['email'] ?? null,
+ 'err' => $e->getMessage(),
+ 'trace' => substr($e->getTraceAsString(), 0, 2000),
+ ]);
+
+ // PASS 세션은 살아있게 두고(=register/pass 유지),
+ // 입력값은 withInput()으로 다시 채워서 profile로 복귀
+ return redirect()->route('web.auth.register.profile')
+ ->withInput()
+ ->with('ui_dialog', [
+ 'type' => 'alert',
+ 'title' => '가입 실패',
+ 'message' => '저장 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
+ ]);
+ }
+
+ }
+
+
private function maskDanalPayloadForLog(array $payload): array
{
@@ -294,5 +516,10 @@ class RegisterController extends Controller
return $masked;
}
+ private function clearSession(Request $request, string $key = 'signup'): void
+ {
+ $request->session()->forget($key);
+ }
+
}
diff --git a/app/Http/Middleware/LegacyAuth.php b/app/Http/Middleware/LegacyAuth.php
new file mode 100644
index 0000000..325dfaf
--- /dev/null
+++ b/app/Http/Middleware/LegacyAuth.php
@@ -0,0 +1,29 @@
+session()->get('_sess._login_', false);
+
+ if (!$loggedIn) {
+ // 로그인 성공 후 원래 가려던 곳으로 보내기 위해 intended 저장
+ // (Laravel auth 안 쓰더라도 이 키는 redirect()->intended()가 알아서 씀)
+ $request->session()->put('url.intended', $request->fullUrl());
+
+ return redirect()->route('web.auth.login')
+ ->with('ui_dialog', [
+ 'type' => 'alert',
+ 'title' => '안내',
+ 'message' => '로그인 필요합니다.',
+ ]);
+ }
+
+ return $next($request);
+ }
+}
diff --git a/app/Http/Middleware/LegacyGuest.php b/app/Http/Middleware/LegacyGuest.php
new file mode 100644
index 0000000..7290c8f
--- /dev/null
+++ b/app/Http/Middleware/LegacyGuest.php
@@ -0,0 +1,22 @@
+session()->get('_sess._login_', false);
+
+ if ($loggedIn) {
+ // 이미 로그인 상태면 auth 페이지 접근 막고 홈(또는 intended)으로
+ $to = $request->session()->pull('url.intended', '/');
+ return redirect($to ?: '/');
+ }
+
+ return $next($request);
+ }
+}
diff --git a/app/Models/AdminUser.php b/app/Models/AdminUser.php
index 3690afa..5b984cc 100644
--- a/app/Models/AdminUser.php
+++ b/app/Models/AdminUser.php
@@ -10,6 +10,7 @@ class AdminUser extends Authenticatable
use Notifiable;
protected $table = 'admin_users';
+ public $timestamps = false;
protected $fillable = [
'email','password',
diff --git a/app/Models/MemInfo.php b/app/Models/MemInfo.php
index ea74894..50996e7 100644
--- a/app/Models/MemInfo.php
+++ b/app/Models/MemInfo.php
@@ -12,8 +12,6 @@ class MemInfo extends Model
protected $primaryKey = 'mem_no';
public $incrementing = true;
protected $keyType = 'int';
-
- // mem_info는 created_at/updated_at 컬럼이 dt_reg/dt_mod 라서 기본 timestamps 안 씀
public $timestamps = false;
/**
diff --git a/app/Models/Member/MemAddress.php b/app/Models/Member/MemAddress.php
index fa3a815..4568bdc 100644
--- a/app/Models/Member/MemAddress.php
+++ b/app/Models/Member/MemAddress.php
@@ -7,13 +7,11 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MemAddress extends Model
-{
-
-
- protected $table = 'mem_address';
+{ protected $table = 'mem_address';
protected $primaryKey = 'seq';
public $incrementing = true;
protected $keyType = 'int';
+ public $timestamps = false;
protected $guarded = [];
diff --git a/app/Models/Member/MemAuth.php b/app/Models/Member/MemAuth.php
index b2f31a7..2668684 100644
--- a/app/Models/Member/MemAuth.php
+++ b/app/Models/Member/MemAuth.php
@@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MemAuth extends Model
{
-
+
protected $table = 'mem_auth';
@@ -17,6 +17,7 @@ class MemAuth extends Model
protected $primaryKey = null;
public $incrementing = false;
protected $keyType = 'string';
+ public $timestamps = false;
protected $guarded = [];
diff --git a/app/Models/Member/MemAuthInfo.php b/app/Models/Member/MemAuthInfo.php
index a00465d..0dbc889 100644
--- a/app/Models/Member/MemAuthInfo.php
+++ b/app/Models/Member/MemAuthInfo.php
@@ -8,12 +8,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MemAuthInfo extends Model
{
-
+
protected $table = 'mem_auth_info';
protected $primaryKey = 'mem_no';
public $incrementing = false;
protected $keyType = 'int';
+ public $timestamps = false;
protected $guarded = [];
diff --git a/app/Models/Member/MemAuthLog.php b/app/Models/Member/MemAuthLog.php
index b640842..80d08ac 100644
--- a/app/Models/Member/MemAuthLog.php
+++ b/app/Models/Member/MemAuthLog.php
@@ -8,12 +8,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MemAuthLog extends Model
{
-
+
protected $table = 'mem_auth_log';
protected $primaryKey = 'seq';
public $incrementing = true;
protected $keyType = 'int';
+ public $timestamps = false;
protected $guarded = [];
diff --git a/app/Models/Member/MemInfo.php b/app/Models/Member/MemInfo.php
index d80931e..9f2d42a 100644
--- a/app/Models/Member/MemInfo.php
+++ b/app/Models/Member/MemInfo.php
@@ -2,24 +2,44 @@
namespace App\Models\Member;
+use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
-
+use Illuminate\Support\Carbon;
class MemInfo extends Model
{
- //
-
protected $table = 'mem_info';
protected $primaryKey = 'mem_no';
protected $keyType = 'int';
public $incrementing = true;
+ public $timestamps = false;
- // 테이블이 legacy라 fillable 대신 guarded 추천 (내부에서만 쓰면 []도 가능)
- protected $guarded = [];
+ /**
+ * ✅ 보안: guarded=[](전부 허용) 는 위험하니,
+ * 기존 App\Models\MemInfo 의 allowlist(fillable) 방식 유지.
+ */
+ protected $fillable = [
+ 'stat_1','stat_2','stat_3','stat_4','stat_5',
+ 'name','name_first','name_mid','name_last',
+ 'birth','gender','native',
+ 'cell_corp','cell_phone','email','pv_sns',
+ 'bank_code','bank_name','bank_act_num','bank_vact_num',
+ 'rcv_email','rcv_sms','rcv_push',
+ 'login_fail_cnt',
+ 'dt_login','dt_reg','dt_mod',
+ 'dt_rcv_email','dt_rcv_sms','dt_rcv_push',
+ 'dt_stat_1','dt_stat_2','dt_stat_3','dt_stat_4','dt_stat_5',
+ 'ip_reg','ci_v','ci','di',
+ 'country_code','country_name',
+ 'admin_memo','modify_log',
+ ];
- // zero-date 때문에 datetime/date cast는 걸지 않음 (필요시 별도 accessor로 안전 파싱)
+ /**
+ * ✅ 레거시 zero-date(0000-00-00 ...)가 있으면 datetime/date cast는 예외/오작동 가능.
+ * 안전하게 JSON 컬럼만 cast (나머지는 safe accessor로 뽑아 쓰자)
+ */
protected $casts = [
'admin_memo' => 'array',
'modify_log' => 'array',
@@ -36,7 +56,7 @@ class MemInfo extends Model
public function authRows(): HasMany
{
- // mem_auth 복합키 테이블이지만 조회 관계는 문제 없음
+ // mem_auth 복합키 테이블이어도 조회 관계는 가능
return $this->hasMany(MemAuth::class, 'mem_no', 'mem_no');
}
@@ -71,17 +91,90 @@ class MemInfo extends Model
}
/* =====================
- * Helpers (optional)
+ * Scopes (기존 App\Models\MemInfo에서 가져옴)
* ===================== */
+ public function scopeActive(Builder $q): Builder
+ {
+ // CI에서 stat_3 == 3 접근금지 / 4 탈퇴신청 / 5 탈퇴완료
+ return $q->whereNotIn('stat_3', ['3','4','5']);
+ }
+
+ public function scopeByEmail(Builder $q, string $email): Builder
+ {
+ return $q->where('email', strtolower($email));
+ }
+
+ public function scopeByPhoneLookup(Builder $q, string $phoneNormalized): Builder
+ {
+ // TODO: cell_phone이 암호화면 단순 where 비교 불가
+ // 추천: cell_phone_hash 같은 정규화+해시 컬럼 만들어 lookup
+ return $q;
+ }
+
+ /* =====================
+ * Helpers (둘 모델 통합)
+ * ===================== */
+
+ public function isBlocked(): bool
+ {
+ return (string) $this->stat_3 === '3';
+ }
+
+ public function isWithdrawnOrRequested(): bool
+ {
+ return in_array((string) $this->stat_3, ['4','5'], true);
+ }
+
public function isWithdrawn(): bool
{
- // legacy: dt_out 기본값이 0000-00-00... 이므로 문자열 비교로 처리
- return isset($this->attributes['dt_out']) && $this->attributes['dt_out'] !== '0000-00-00 00:00:00';
+ // legacy: dt_out 기본값이 0000-00-00 00:00:00 일 수 있음
+ $v = $this->attributes['dt_out'] ?? null;
+ return !empty($v) && $v !== '0000-00-00 00:00:00';
}
public function hasEmail(): bool
{
return !empty($this->attributes['email']);
}
+
+ public function isFirstLogin(): bool
+ {
+ $dtLogin = $this->dt_login_at();
+ $dtReg = $this->dt_reg_at();
+
+ if (!$dtLogin || !$dtReg) return false;
+ return $dtLogin->equalTo($dtReg);
+ }
+
+ /* =====================
+ * Safe datetime accessors
+ * ===================== */
+
+ private function safeCarbon(?string $value): ?Carbon
+ {
+ if (!$value) return null;
+ if ($value === '0000-00-00 00:00:00' || $value === '0000-00-00') return null;
+
+ try {
+ return Carbon::parse($value);
+ } catch (\Throwable $e) {
+ return null;
+ }
+ }
+
+ public function dt_login_at(): ?Carbon
+ {
+ return $this->safeCarbon($this->attributes['dt_login'] ?? null);
+ }
+
+ public function dt_reg_at(): ?Carbon
+ {
+ return $this->safeCarbon($this->attributes['dt_reg'] ?? null);
+ }
+
+ public function dt_mod_at(): ?Carbon
+ {
+ return $this->safeCarbon($this->attributes['dt_mod'] ?? null);
+ }
}
diff --git a/app/Models/Member/MemJoinFilter.php b/app/Models/Member/MemJoinFilter.php
index cf2cddd..b539b08 100644
--- a/app/Models/Member/MemJoinFilter.php
+++ b/app/Models/Member/MemJoinFilter.php
@@ -13,6 +13,7 @@ class MemJoinFilter extends Model
protected $primaryKey = 'seq';
public $incrementing = true;
protected $keyType = 'int';
+ public $timestamps = false;
protected $guarded = [];
diff --git a/app/Models/Member/MemJoinLog.php b/app/Models/Member/MemJoinLog.php
index 995589a..b561eea 100644
--- a/app/Models/Member/MemJoinLog.php
+++ b/app/Models/Member/MemJoinLog.php
@@ -8,12 +8,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MemJoinLog extends Model
{
-
+
protected $table = 'mem_join_log';
protected $primaryKey = 'seq';
public $incrementing = true;
protected $keyType = 'int';
+ public $timestamps = false;
protected $guarded = [];
diff --git a/app/Models/Member/MemLoginRecent.php b/app/Models/Member/MemLoginRecent.php
index add44c7..9791db5 100644
--- a/app/Models/Member/MemLoginRecent.php
+++ b/app/Models/Member/MemLoginRecent.php
@@ -8,12 +8,13 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MemLoginRecent extends Model
{
-
+
protected $table = 'mem_login_recent';
protected $primaryKey = 'seq';
public $incrementing = true;
protected $keyType = 'int';
+ public $timestamps = false;
protected $guarded = [];
diff --git a/app/Models/Member/MemLoginYear.php b/app/Models/Member/MemLoginYear.php
index 1ba558a..521cfbe 100644
--- a/app/Models/Member/MemLoginYear.php
+++ b/app/Models/Member/MemLoginYear.php
@@ -11,11 +11,12 @@ use Illuminate\Database\Eloquent\Model;
*/
class MemLoginYear extends Model
{
-
+
protected $primaryKey = 'seq';
public $incrementing = true;
protected $keyType = 'int';
+ public $timestamps = false;
protected $guarded = [];
diff --git a/app/Models/Member/MemModLog.php b/app/Models/Member/MemModLog.php
index e6a95cd..5ff5893 100644
--- a/app/Models/Member/MemModLog.php
+++ b/app/Models/Member/MemModLog.php
@@ -7,12 +7,11 @@ use Illuminate\Database\Eloquent\Model;
class MemModLog extends Model
{
-
-
protected $table = 'mem_mod_log';
protected $primaryKey = 'seq';
public $incrementing = true;
protected $keyType = 'int';
+ public $timestamps = false;
protected $guarded = [];
}
diff --git a/app/Models/Member/MemPasswdModify.php b/app/Models/Member/MemPasswdModify.php
index 668ea6a..450ba8d 100644
--- a/app/Models/Member/MemPasswdModify.php
+++ b/app/Models/Member/MemPasswdModify.php
@@ -7,12 +7,13 @@ use Illuminate\Database\Eloquent\Model;
class MemPasswdModify extends Model
{
-
+
protected $table = 'mem_passwd_modify';
protected $primaryKey = 'seq';
public $incrementing = true;
protected $keyType = 'int';
+ public $timestamps = false;
protected $guarded = [];
diff --git a/app/Models/Member/MemStRing.php b/app/Models/Member/MemStRing.php
index cc5e54c..0a36a54 100644
--- a/app/Models/Member/MemStRing.php
+++ b/app/Models/Member/MemStRing.php
@@ -1,24 +1,34 @@
belongsTo(MemInfo::class, 'mem_no', 'mem_no');
+ $col = 'str_' . (int)$set; // str_0 / str_1 / str_2
+
+ $row = DB::table('mem_st_ring')
+ ->select($col)
+ ->where('mem_no', $memNo)
+ ->first();
+
+ if (!$row) return null;
+
+ $val = $row->{$col} ?? null;
+ $val = is_string($val) ? trim($val) : null;
+
+ return $val !== '' ? $val : null;
+ }
+
+ public function getLegacyPass2(int $memNo): ?string
+ {
+ $val = DB::table('mem_st_ring')
+ ->where('mem_no', $memNo)
+ ->value('passwd2');
+
+ $val = is_string($val) ? trim($val) : null;
+ return $val !== '' ? $val : null;
}
}
diff --git a/app/Repositories/Member/MemStRingRepository.php b/app/Repositories/Member/MemStRingRepository.php
new file mode 100644
index 0000000..56c2bbd
--- /dev/null
+++ b/app/Repositories/Member/MemStRingRepository.php
@@ -0,0 +1,64 @@
+toDateTimeString();
+
+ [$str0, $str1, $str2] = CiPassword::makeAll($plainPassword);
+ $pass2 = 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 = ?, str_1 = ?, str_2 = ?, dt_reg = ?, passwd2 = ?, passwd2_reg = ?",
+ [
+ $memNo, $str0, $str1, $str2, $dt, $pass2, $dt,
+ $str0, $str1, $str2, $dt, $pass2, $dt,
+ ]
+ );
+ }
+
+ /**
+ * CI3 modify_type == "1_passwd" 대응 (1차만)
+ */
+ public function upsertPassword1(int $memNo, string $plainPassword, ?string $dtNow = null): void
+ {
+ $dt = $dtNow ?: Carbon::now()->toDateTimeString();
+ [$str0, $str1, $str2] = CiPassword::makeAll($plainPassword);
+
+ DB::statement(
+ "INSERT INTO mem_st_ring (mem_no, str_0, str_1, str_2, dt_reg)
+ VALUES (?, ?, ?, ?, ?)
+ ON DUPLICATE KEY UPDATE str_0=?, str_1=?, str_2=?, dt_reg=?",
+ [$memNo, $str0, $str1, $str2, $dt, $str0, $str1, $str2, $dt]
+ );
+ }
+
+ /**
+ * CI3 modify_type == "2_passwd" 대응 (2차만)
+ */
+ public function upsertPassword2(int $memNo, string $pin2, ?string $dtNow = null): void
+ {
+ $dt = $dtNow ?: Carbon::now()->toDateTimeString();
+ $pass2 = CiPassword::makePass2($pin2);
+
+ DB::statement(
+ "INSERT INTO mem_st_ring (mem_no, passwd2, passwd2_reg)
+ VALUES (?, ?, ?)
+ ON DUPLICATE KEY UPDATE passwd2=?, passwd2_reg=?",
+ [$memNo, $pass2, $dt, $pass2, $dt]
+ );
+ }
+}
diff --git a/app/Repositories/Member/MemberAuthRepository.php b/app/Repositories/Member/MemberAuthRepository.php
index 444f7ba..0b7baf9 100644
--- a/app/Repositories/Member/MemberAuthRepository.php
+++ b/app/Repositories/Member/MemberAuthRepository.php
@@ -8,12 +8,22 @@ use App\Models\Member\MemAuthLog;
use App\Models\Member\MemInfo;
use App\Models\Member\MemJoinFilter;
use App\Models\Member\MemJoinLog;
+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 (기존)
* ========================================================= */
@@ -99,15 +109,6 @@ class MemberAuthRepository
->value('auth_state');
}
- public function isVerified(int $memNo, string $authType): bool
- {
- return $this->getState($memNo, $authType) === MemAuth::STATE_Y;
- }
-
- /* =========================================================
- * Step0: phone check + join_filter + join_log
- * ========================================================= */
-
private function normalizeKoreanPhone(string $raw): ?string
{
$digits = preg_replace('/\D+/', '', $raw ?? '');
@@ -156,25 +157,10 @@ class MemberAuthRepository
return ['hit' => true, 'block' => false, 'gubun' => $r->gubun ?? 'filter_hit', 'row' => $r];
}
- public function writeJoinLog(array $data): void
- {
- MemJoinLog::query()->create([
- 'gubun' => $data['gubun'] ?? null,
- 'mem_no' => (int)($data['mem_no'] ?? 0),
- 'cell_corp' => $data['cell_corp'] ?? 'n',
- 'cell_phone' => $data['cell_phone'] ?? '',
- 'email' => $data['email'] ?? null,
- 'ip4' => $data['ip4'] ?? '',
- 'ip4_c' => $data['ip4_c'] ?? '',
- 'error_code' => $data['error_code'] ?? '',
- 'dt_reg' => Carbon::now()->toDateTimeString(),
- ]);
- }
-
/**
* Step0 통합 처리
*/
- public function step0PhoneCheck(string $rawPhone, string $ip4 = ''): array
+ public function step0PhoneCheck(string $rawPhone, string $carrier = '', string $ip4 = ''): array
{
$base = [
'ok' => false,
@@ -182,6 +168,7 @@ class MemberAuthRepository
'message' => '',
'redirect' => null,
'phone' => null,
+ 'carrier' => null,
'filter' => null,
'admin_phones' => [],
];
@@ -190,6 +177,9 @@ class MemberAuthRepository
$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'] ?? [];
@@ -215,16 +205,17 @@ class MemberAuthRepository
$base['phone'] = $phone;
// 2) 이미 회원인지 체크
- $member = $this->findMemberByPhone($phone);
- 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, // 필요하면 유지
- ]);
- }
+ $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);
@@ -247,9 +238,7 @@ class MemberAuthRepository
]);
}
-
-
- private function findMemberByPhone(string $phone): ?object
+ private function findMemberByPhone(string $phone, string $carrier): ?object
{
/** @var CiSeedCrypto $seed */
$seed = app(CiSeedCrypto::class);
@@ -259,10 +248,21 @@ class MemberAuthRepository
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
{
@@ -274,13 +274,12 @@ class MemberAuthRepository
}
private function checkJoinFilterByIp(string $ip4, string $ip4c): ?array
- {$ip4 = "19dd.0";
+ {
// 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'])
@@ -338,6 +337,271 @@ class MemberAuthRepository
$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,
+ ]);
+ }
+
+
}
diff --git a/app/Services/MemInfoService.php b/app/Services/MemInfoService.php
index 1e77e3a..ebe31c4 100644
--- a/app/Services/MemInfoService.php
+++ b/app/Services/MemInfoService.php
@@ -2,9 +2,14 @@
namespace App\Services;
-use App\Models\MemInfo;
+use App\Models\Member\MemInfo;
+use App\Support\LegacyCrypto\CiSeedCrypto;
+use App\Support\LegacyCrypto\CiPassword;
+use App\Support\Legacy\LoginReason;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Carbon;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Schema;
class MemInfoService
{
@@ -49,13 +54,12 @@ class MemInfoService
/**
* CI: mem_reg() (간소화 버전)
* - 실제로는 validation은 FormRequest에서 처리 권장
- * - stat_3=3 (1969 이전 출생 접근금지) 룰 포함
*/
public function register(array $data): MemInfo
{
return DB::transaction(function () use ($data) {
- $email = strtolower($data['email']);
+ $email = strtolower((string)($data['email'] ?? ''));
// 중복 체크 + 잠금 (CI for update)
$exists = MemInfo::query()
@@ -68,11 +72,12 @@ class MemInfoService
}
$now = Carbon::now()->format('Y-m-d H:i:s');
+ $notnull = "1000-01-01 00:00:00";
$mem = new MemInfo();
$mem->email = $email;
- $mem->name = $data['name'] ?? '';
- $mem->pv_sns = $data['pv_sns'] ?? 'self';
+ $mem->name = (string)($data['name'] ?? '');
+ $mem->pv_sns = (string)($data['pv_sns'] ?? 'self');
$promotion = !empty($data['promotion']) ? 'y' : 'n';
$mem->rcv_email = $promotion;
@@ -85,26 +90,42 @@ class MemInfoService
$mem->dt_rcv_email = $now;
$mem->dt_rcv_sms = $now;
$mem->dt_rcv_push = $now;
-
$mem->dt_stat_1 = $now;
$mem->dt_stat_2 = $now;
$mem->dt_stat_3 = $now;
$mem->dt_stat_4 = $now;
$mem->dt_stat_5 = $now;
- $mem->ip_reg = $data['ip_reg'] ?? request()->ip();
+ $mem->dt_mod = $notnull;
+ $mem->dt_vact = $notnull;
+ $mem->dt_dor = $notnull;
+ $mem->dt_ret_dor = $notnull;
+ $mem->dt_req_out = "1000-01-01";
+ $mem->dt_out = $notnull;
+
+ $mem->ip_reg = (string)($data['ip_reg'] ?? request()->ip());
// 국가/본인인증 값들
- $mem->country_code = $data['country_code'] ?? '';
- $mem->country_name = $data['country_name'] ?? '';
- $mem->birth = $data['birth'] ?? '0000-00-00';
- $mem->cell_corp = $data['cell_corp'] ?? 'n';
- $mem->cell_phone = $data['cell_phone'] ?? ''; // ⚠️ 암호화 저장이라면 여기서 암호화해서 넣어야 함
- $mem->native = $data['native'] ?? 'n';
+ $mem->country_code = (string)($data['country_code'] ?? '');
+ $mem->country_name = (string)($data['country_name'] ?? '');
+ $mem->birth = (string)($data['birth'] ?? '0000-00-00');
+ $mem->cell_corp = (string)($data['cell_corp'] ?? 'n');
+
+ // 휴대폰 암호화 (빈값이면 빈값)
+ $rawPhone = (string)($data['cell_phone'] ?? '');
+ if ($rawPhone !== '') {
+ /** @var CiSeedCrypto $seed */
+ $seed = app(CiSeedCrypto::class);
+ $mem->cell_phone = (string)$seed->encrypt($rawPhone);
+ } else {
+ $mem->cell_phone = '';
+ }
+
+ $mem->native = (string)($data['native'] ?? 'n');
$mem->ci = $data['ci'] ?? null;
- $mem->ci_v = $data['ci_v'] ?? '';
+ $mem->ci_v = (string)($data['ci_v'] ?? '');
$mem->di = $data['di'] ?? null;
- $mem->gender = $data['gender'] ?? 'n';
+ $mem->gender = (string)($data['gender'] ?? 'n');
// 1969년 이전 출생 접근금지(stat_3=3)
$birthY = (int)substr((string)$mem->birth, 0, 4);
@@ -118,9 +139,6 @@ class MemInfoService
});
}
- /**
- * CI: last_login()
- */
public function updateLastLogin(int $memNo): void
{
MemInfo::query()
@@ -132,9 +150,6 @@ class MemInfoService
]);
}
- /**
- * CI: fail_count()
- */
public function incrementLoginFail(int $memNo): void
{
MemInfo::query()
@@ -144,4 +159,333 @@ class MemInfoService
'dt_mod' => Carbon::now()->format('Y-m-d H:i:s'),
]);
}
+
+ private function dt6(): string
+ {
+ return Carbon::now()->format('Y-m-d H:i:s.u');
+ }
+
+ private function ip4c(string $ip): string
+ {
+ $oct = explode('.', $ip);
+ if (count($oct) >= 3) {
+ return $oct[0].'.'.$oct[1].'.'.$oct[2];
+ }
+ return $ip;
+ }
+
+ private function parseUa(string $ua): array
+ {
+ $platform = '';
+ if (stripos($ua, 'Windows') !== false) $platform = 'Windows';
+ elseif (stripos($ua, 'Mac OS X') !== false) $platform = 'macOS';
+ elseif (stripos($ua, 'Android') !== false) $platform = 'Android';
+ elseif (stripos($ua, 'iPhone') !== false || stripos($ua, 'iPad') !== false) $platform = 'iOS';
+ elseif (stripos($ua, 'Linux') !== false) $platform = 'Linux';
+
+ $browser = 'Unknown';
+ $version = '';
+ $candidates = [
+ 'Edg/' => 'Edge',
+ 'Chrome/' => 'Chrome',
+ 'Firefox/' => 'Firefox',
+ 'Safari/' => 'Safari',
+ ];
+
+ foreach ($candidates as $needle => $name) {
+ $pos = stripos($ua, $needle);
+ if ($pos !== false) {
+ $browser = $name;
+ $sub = substr($ua, $pos + strlen($needle));
+ $version = preg_split('/[^0-9\.]/', $sub)[0] ?? '';
+ // Safari는 Chrome UA에도 같이 끼므로 Chrome 우선순위를 위에서 처리
+ break;
+ }
+ }
+
+ return [$platform, trim($browser), trim($version)];
+ }
+
+ /**
+ * mem_auth 기반 레벨 계산 (CI3 get_mem_level 이식)
+ */
+ private function getMemLevel(int $memNo): array
+ {
+ $rows = DB::table('mem_auth')
+ ->select(['auth_type','auth_state'])
+ ->where('mem_no', $memNo)
+ ->where('auth_state', 'Y')
+ ->limit(50)
+ ->get();
+
+ $state = ['email'=>false,'cell'=>false,'account'=>false,'otp'=>false,'ars'=>false];
+
+ foreach ($rows as $r) {
+ $t = strtolower((string)$r->auth_type);
+ if (array_key_exists($t, $state)) $state[$t] = true;
+ }
+
+ $level = 0;
+ if ($state['email']) $level = 1;
+ if ($level === 1 && $state['cell']) $level = 2;
+ if ($level === 2 && $state['account']) $level = 3;
+ if ($level === 3 && $state['otp']) $level = 4;
+ if ($level === 2 && $state['ars']) $level = 5;
+
+ return ['level'=>$level, 'auth_state'=>$state];
+ }
+
+ /**
+ * 연도별 로그인 테이블 자동 생성 (B 선택)
+ * - mem_login_recent 스키마를 그대로 복제
+ */
+ private function ensureLoginYearlyTable(int $year): string
+ {
+ $year = (int)$year;
+ if ($year < 2000 || $year > 2100) {
+ // 안전장치
+ return 'mem_login_recent';
+ }
+
+ $table = "mem_login_{$year}";
+
+ // DB마다 동작이 달라서 가장 단순하고 확실한 DDL로 처리
+ // CREATE TABLE IF NOT EXISTS mem_login_YYYY LIKE mem_login_recent
+ DB::statement("CREATE TABLE IF NOT EXISTS `{$table}` LIKE `mem_login_recent`");
+
+ return $table;
+ }
+
+ private function insertLoginLog(string $table, array $d): void
+ {
+ DB::statement(
+ "INSERT INTO `{$table}`
+ SET mem_no=?,
+ sf=?,
+ conn=?,
+ ip4_aton=inet_aton(?),
+ ip4=?,
+ ip4_c=SUBSTRING_INDEX(?,'.',3),
+ dt_reg=?,
+ platform=?,
+ browser=?,
+ pattern=?,
+ error_code=?",
+ [
+ $d['mem_no'],
+ $d['sf'],
+ $d['conn'],
+ $d['ip4'],
+ $d['ip4'],
+ $d['ip4'],
+ $d['dt_reg'],
+ $d['platform'],
+ $d['browser'],
+ $d['pattern'],
+ $d['error_code'],
+ ]
+ );
+ }
+
+
+ public function attemptLegacyLogin(array $in): array
+ {
+ $email = strtolower(trim((string)($in['email'] ?? '')));
+ $pw = (string)($in['password'] ?? '');
+ $ip = (string)($in['ip'] ?? request()->ip());
+ $ua = (string)($in['ua'] ?? '');
+ $returnUrl = (string)($in['return_url'] ?? '/');
+
+ if ($email === '' || $pw === '') {
+ return ['ok'=>false, 'message'=>'아이디 혹은 비밀번호가 일치하지 않습니다.'];
+ }
+
+ $dtNow6 = $this->dt6();
+ $dtY = (int)substr($dtNow6, 0, 4);
+
+ // UA 파싱
+ [$platform, $browser, $version] = $this->parseUa($ua);
+ $browserFull = trim($browser.' '.$version);
+
+ //$yearTable = $this->ensureLoginYearlyTable((int)$dtY);
+
+
+ return DB::transaction(function () use ($email, $pw, $ip, $ua, $returnUrl, $dtNow6, $dtY, $platform, $browserFull) {
+ $yearTable = "mem_login_".(int)$dtY;
+
+ /** @var MemInfo|null $mem */
+ $mem = MemInfo::query()
+ ->select([
+ 'mem_no','email','name','cell_phone','cell_corp',
+ 'dt_login','dt_reg','login_fail_cnt',
+ 'pv_sns',
+ 'stat_1','stat_2','stat_3','stat_4','stat_5',
+ 'native','country_code'
+ ])
+ ->where('email', $email)
+ ->lockForUpdate()
+ ->first();
+
+ // 아이디 없음 -> mem_no가 없으니 fail_count/log 저장 불가. 메시지만 통일.
+ if (!$mem) {
+ return ['ok'=>false, 'message'=>'아이디 혹은 비밀번호가 일치하지 않습니다.1'];
+ }
+
+ // stat_3 차단 로직 (CI3 id_exists 반영)
+ if ((string)$mem->stat_3 === '3') {
+ return ['ok'=>false, 'message'=>"접근금지 계정입니다.
고객센터 1833-4856로 문의 하세요"];
+ }
+ if ((string)$mem->stat_3 === '4') {
+ return ['ok'=>false, 'message'=>'아이디 혹은 비밀번호가 일치하지 않습니다.2'];
+ }
+ if ((string)$mem->stat_3 === '5') {
+ return ['ok'=>false, 'message'=>'아이디 혹은 비밀번호가 일치하지 않습니다.3'];
+ }
+
+ // 휴면(stat_3=6) 처리: 지금은 테이블 저장 + 안내까지만 (메일 연결은 다음 단계에서)
+ if ((string)$mem->stat_3 === '6') {
+ // TODO: mem_dormancy insert + authnum 생성 + 메일 발송 연결
+ return ['ok'=>false, 'message'=>'회원님 계정은 휴면계정입니다. 이메일 인증 후 이용 가능합니다.4'];
+ }
+
+ // mem_st_ring 비번 로드
+ $ring = DB::table('mem_st_ring')->where('mem_no', $mem->mem_no)->first();
+ if (!$ring || empty($ring->str_0)) {
+ $reason = config('legacy.login_reason.L_NOT_EXISTS_PASS');
+ // 실패 카운트 + 실패 로그
+ $this->incrementLoginFail((int)$mem->mem_no);
+
+ $log = [
+ 'mem_no' => (int)$mem->mem_no,
+ 'sf' => 'f',
+ 'conn' => '1',
+ 'ip4' => $ip,
+ 'ip4_c' => $this->ip4c($ip),
+ 'dt_reg' => $dtNow6,
+ 'platform' => $platform,
+ 'browser' => $browserFull,
+ 'pattern' => 'self',
+ 'error_code' => $reason,
+ ];
+
+
+ $this->insertLoginLog('mem_login_recent', $log);
+ $this->insertLoginLog($yearTable, $log);
+
+ return ['ok'=>false, 'message'=>'아이디 혹은 비밀번호가 일치하지 않습니다.5'];
+ }
+
+ // 비번 검증 (PASS_SET=0)
+ $try = CiPassword::make($pw, 0);
+ $dbPass = (string)$ring->str_0;
+
+ if ($try === '' || strcmp($try, $dbPass) !== 0) {
+ $reason = config('legacy.login_reason.L_INCORRECT_PASS');
+ $failCnt = (int)$mem->login_fail_cnt;
+
+ // 5회 실패 안내(>=4면 이번이 5회)
+ if ($failCnt >= 4) {
+ $reason = config('legacy.login_reason.L_LOGIN_FAIL');
+ }
+
+ // 실패 카운트 + 실패 로그
+ $this->incrementLoginFail((int)$mem->mem_no);
+
+ $log = [
+ 'mem_no' => (int)$mem->mem_no,
+ 'sf' => 'f',
+ 'conn' => '1',
+ 'ip4' => $ip,
+ 'ip4_c' => $this->ip4c($ip),
+ 'dt_reg' => $dtNow6,
+ 'platform' => $platform,
+ 'browser' => $browserFull,
+ 'pattern' => 'self',
+ 'error_code' => $reason,
+ ];
+
+ $this->insertLoginLog('mem_login_recent', $log);
+ $this->insertLoginLog($yearTable, $log);
+
+ if ($failCnt >= 4) {
+ return ['ok'=>false, 'message'=>"5회이상 실패시 비밀번호찾기 후 이용 바랍니다.(클릭)"];
+ }
+
+ return ['ok'=>false, 'message'=>"비밀번호가 일치하지 않습니다.\n비밀번호 실패횟수 : ".($failCnt+1)."\n5회 이상 실패시 인증을 다시받아야 합니다."];
+ }
+
+ // 레벨 체크 (email 인증 필수)
+ $levelInfo = $this->getMemLevel((int)$mem->mem_no);
+ if (($levelInfo['level'] ?? 0) < 1 || empty($levelInfo['auth_state']['email'])) {
+ return [
+ 'ok'=>false,
+ 'message'=>"
이메일 인증 완료후 이용가능합니다. \n이메일주소(".$mem->email.") 메일을 확인하세요\n",
+ ];
+ }
+
+ // 로그인 차단 IP 대역 체크
+ $ip4c = $this->ip4c($ip);
+ $blocked = DB::table('filter_login_ip_reject')
+ ->where('mem_no', $mem->mem_no)
+ ->where('ip4_c', $ip4c)
+ ->exists();
+
+ if ($blocked) {
+ return ['ok'=>false, 'message'=>"회원님의 설정에 의해 접속이 차단되었습니다.6"];
+ }
+
+ // 최근 로그인 업데이트(성공 시 fail_count reset)
+ $this->updateLastLogin((int)$mem->mem_no);
+
+ // 성공 로그 저장(최근 + 연도별)
+ $log = [
+ 'mem_no' => (int)$mem->mem_no,
+ 'sf' => 's',
+ 'conn' => '1',
+ 'ip4' => $ip,
+ 'ip4_c' => $ip4c,
+ 'dt_reg' => $dtNow6,
+ 'platform' => $platform,
+ 'browser' => $browserFull,
+ 'pattern' => 'self',
+ 'error_code' => '',
+ ];
+
+ $this->insertLoginLog('mem_login_recent', $log);
+ $this->insertLoginLog($yearTable, $log);
+
+ // 첫 로그인 여부
+ $login1st = 'n';
+ if ((string)$mem->dt_login === (string)$mem->dt_reg) {
+ $login1st = 'y';
+ }
+
+ // ✅ 세션 payload (CI3 키 유지)
+ $session = [
+ '_login_' => true,
+ '_mid' => (string)$mem->email,
+ '_mno' => (int)$mem->mem_no,
+ '_mname' => (string)$mem->name,
+ '_mstat_1' => (string)$mem->stat_1,
+ '_mstat_2' => (string)$mem->stat_2,
+ '_mstat_3' => (string)$mem->stat_3,
+ '_mstat_4' => (string)$mem->stat_4,
+ '_mstat_5' => (string)$mem->stat_5,
+ '_mcell' => (string)$mem->cell_phone,
+ '_mpv_sns' => (string)$mem->pv_sns,
+ '_mnative' => (string)$mem->native,
+ '_mcountry_code' => (string)$mem->country_code,
+ '_ip' => $ip,
+ '_login_1st' => $login1st,
+ '_dt_reg' => (string)$mem->dt_reg,
+ 'auth_ars' => !empty($levelInfo['auth_state']['ars']) ? 'Y' : 'N',
+ ];
+
+ return [
+ 'ok' => true,
+ 'session' => $session,
+ 'redirect' => $returnUrl ?: '/',
+ ];
+ });
+ }
}
diff --git a/app/Support/AuthSession.php b/app/Support/AuthSession.php
new file mode 100644
index 0000000..61f0881
--- /dev/null
+++ b/app/Support/AuthSession.php
@@ -0,0 +1,10 @@
+put('_sess', $data); // 예시 (너희 세션 구조에 맞게)
+ }
+}
diff --git a/app/Support/Legacy/Carrier.php b/app/Support/Legacy/Carrier.php
new file mode 100644
index 0000000..e4d4110
--- /dev/null
+++ b/app/Support/Legacy/Carrier.php
@@ -0,0 +1,23 @@
+validateCsrfTokens(except: [
'auth/register/danal/result', //다날 PASS 콜백 (외부 서버가 호출)
]);
+
+ //페이지 접근권한 미들웨어 등록
+ $middleware->alias([
+ 'legacy.auth' => \App\Http\Middleware\LegacyAuth::class, //로그인후 접근가능
+ 'legacy.guest' => \App\Http\Middleware\LegacyGuest::class, //게스트 접근가능페이지
+ ]);
+
})
->withExceptions(function (Exceptions $exceptions): void {
diff --git a/config/legacy.php b/config/legacy.php
index c713a79..85cf179 100644
--- a/config/legacy.php
+++ b/config/legacy.php
@@ -6,5 +6,46 @@ return [
'inner_encoding' => 'UTF-8',
'block' => 16,
'iv' => [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16],
-];
+ //로그인 오류 분류
+ 'login_reason' => [
+ 'L_NOT_EXISTS_ID' => 'E1', # 아이디 없음
+ 'L_NOT_EXISTS_PASS' => 'E2', # 패스워드 없음
+ 'L_INCORRECT_PASS' => 'E3', # 패스워드 불일치
+ 'L_REJECT' => 'E4', # 회원설정 접속차단
+ 'L_LOGIN_TIME' => 'E5', # 최근로그인일시 업데이트실패
+ 'L_LOGIN_LOG_RECENT' => 'E6', # 로그인성공 로그 기록 실패
+ 'L_LOGIN_LOG_YYYY' => 'E7', # 로그인성공 로그 기록 실패
+ 'L_NOT_SNS_REG' => 'E8', # SNS 연동 가입 아님
+ 'L_INCORRECT_SNS' => 'E9', # 아이디는 같지만 가입 sns 틀림
+ 'L_LOGIN_FAIL' => 'E0', # 로그인5회오류
+ ],
+
+ //통신사.
+ 'carrier' => [
+ 'default' => 'n',
+
+ // 표준 코드
+ 'codes' => [
+ 'SKT' => '01',
+ 'KT' => '02',
+ 'LGU+' => '03',
+ 'LGU' => '03',
+ 'LGT' => '03',
+
+ // 알뜰폰(표기 케이스 방어)
+ 'SKT_MVNO' => '04',
+ 'MVNO_SKT' => '04',
+
+ 'KT_MVNO' => '05',
+ 'MVNO_KT' => '05',
+
+ 'LGU_MVNO' => '06',
+ 'LGU+_MVNO' => '06',
+ 'MVNO_LGU' => '06',
+ ],
+
+ // 이미 코드로 들어온 경우 허용
+ 'allowed_codes' => ['01','02','03','04','05','06','n'],
+ ],
+];
diff --git a/resources/css/web.css b/resources/css/web.css
index f1a48bd..fa91502 100644
--- a/resources/css/web.css
+++ b/resources/css/web.css
@@ -152,15 +152,6 @@ img {
display: block;
}
-/* Header layout fix */
-.site-header .container{
- height: 100%;
- display: flex;
- align-items: center; /* 전체 세로 가운데 */
- justify-content: space-between;
- gap: 16px;
-}
-
.header-left{
display: flex;
align-items: center; /* 로고+메뉴 세로 가운데 */
@@ -298,11 +289,12 @@ h1, h2, h3, h4, h5, h6 {
background-color: var(--color-bg-tint);
}
-/* Header */
.site-header {
- position: sticky;
+ position: fixed;
top: 0;
- z-index: 50;
+ left: 0;
+ right: 0;
+ z-index: 9999;
background-color: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--color-border);
@@ -314,6 +306,20 @@ h1, h2, h3, h4, h5, h6 {
height: var(--header-height-scroll);
}
+/* 헤더에 가려지지 않게 상단 여백 확보 */
+body {
+ padding-top: var(--header-height-desktop);
+}
+
+/* Header layout fix */
+.site-header .container{
+ height: 100%;
+ display: flex;
+ align-items: center; /* 전체 세로 가운데 */
+ justify-content: space-between;
+ gap: 16px;
+}
+
.nav-link {
font-size: 17px;
font-weight: 600;
@@ -4039,3 +4045,331 @@ body.is-drawer-open{
.pagination-wrap { margin-top: 18px; }
.pg-btn, .pg-page, .pg-ellipsis { height: 32px; font-size: 12px; }
}
+
+/* =========================================================
+ Header Profile Dropdown (Tailwind-less fallback)
+ - Works with the exact Blade markup you pasted
+ ========================================================= */
+
+/* wrapper positioning */
+.header-profile {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+}
+
+/* button (btn btn-ghost fallback) */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ border: 1px solid transparent;
+ border-radius: 10px;
+ padding: 8px 12px;
+ font-size: 14px;
+ line-height: 1;
+ cursor: pointer;
+ user-select: none;
+ text-decoration: none;
+ white-space: nowrap;
+}
+
+.btn-ghost {
+ background: transparent;
+ color: rgba(15, 23, 42, 0.85); /* slate-ish */
+}
+
+.btn-ghost:hover {
+ background: rgba(2, 6, 23, 0.06);
+ border-color: rgba(2, 6, 23, 0.08);
+}
+
+.btn-ghost:active {
+ background: rgba(2, 6, 23, 0.10);
+}
+
+/* truncate + max width (truncate max-w-[120px]) */
+.header-profile .truncate {
+ display: inline-block;
+ max-width: 120px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* dropdown panel wrapper:
+ markup uses: absolute right-0 mt-2 w-48 hidden group-hover:block
+ We'll style the dropdown container and show on hover */
+.header-profile .profile-dropdown {
+ position: absolute;
+ right: 0;
+ top: 100%; /* 버튼 바로 아래에 붙임 */
+ margin-top: 0; /* ✅ gap 제거 */
+ width: 192px;
+ display: none;
+ z-index: 50;
+}
+
+.profile-dropdown .profile-card {
+ margin-top: 10px; /* ✅ 시각적 간격은 카드에만 */
+}
+
+/* show on hover (group-hover:block replacement) */
+.header-profile:hover .profile-dropdown {
+ display: block;
+}
+
+.header-profile:hover .profile-dropdown,
+.header-profile .profile-dropdown:hover {
+ display: block;
+}
+
+.header-profile .profile-dropdown::before {
+ content: "";
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: -12px; /* 버튼 아래 ~ 메뉴 위 연결 */
+ height: 12px;
+}
+
+/* dropdown card (rounded-xl border bg-white shadow-lg overflow-hidden) */
+.profile-dropdown .profile-card {
+ background: #fff;
+ border: 1px solid rgba(2, 6, 23, 0.10);
+ border-radius: 14px; /* rounded-xl 느낌 */
+ box-shadow: 0 12px 30px rgba(2, 6, 23, 0.12);
+ overflow: hidden;
+ min-width: 192px;
+}
+
+/* top email line */
+.profile-dropdown .profile-meta {
+ padding: 10px 14px;
+ font-size: 12px;
+ color: rgba(2, 6, 23, 0.55);
+ background: rgba(2, 6, 23, 0.02);
+}
+
+/* separators */
+.profile-dropdown .profile-sep {
+ height: 1px;
+ background: rgba(2, 6, 23, 0.08);
+}
+
+/* menu links */
+.profile-dropdown a,
+.profile-dropdown button {
+ display: block;
+ width: 100%;
+ text-align: left;
+ padding: 10px 14px;
+ font-size: 14px;
+ color: rgba(15, 23, 42, 0.90);
+ text-decoration: none;
+ background: transparent;
+ border: 0;
+}
+
+.profile-dropdown a:hover,
+.profile-dropdown button:hover {
+ background: rgba(2, 6, 23, 0.04);
+}
+
+.profile-dropdown a:active,
+.profile-dropdown button:active {
+ background: rgba(2, 6, 23, 0.07);
+}
+
+/* logout button emphasis */
+.profile-dropdown .logout-btn {
+ color: rgba(220, 38, 38, 0.92); /* red */
+}
+
+/* accessibility: keyboard focus */
+.profile-dropdown a:focus,
+.profile-dropdown button:focus,
+.btn:focus {
+ outline: none;
+}
+
+.profile-dropdown a:focus-visible,
+.profile-dropdown button:focus-visible,
+.btn:focus-visible {
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.25);
+ border-radius: 10px;
+}
+
+/* Optional: small arrow pointer */
+.profile-dropdown .profile-card::before {
+ content: "";
+ position: absolute;
+ right: 18px;
+ top: -7px;
+ width: 14px;
+ height: 14px;
+ background: #fff;
+ border-left: 1px solid rgba(2, 6, 23, 0.10);
+ border-top: 1px solid rgba(2, 6, 23, 0.10);
+ transform: rotate(45deg);
+}
+
+/* ensure ::before positions correctly */
+.profile-dropdown {
+ position: absolute;
+}
+.profile-dropdown .profile-card {
+ position: relative;
+}
+
+/* Mobile: open on tap is JS territory, but at least keep it usable */
+@media (max-width: 640px) {
+ .header-profile .truncate { max-width: 90px; }
+ .header-profile .profile-dropdown { width: 180px; }
+}
+
+/* Mobile Drawer User Card (v2) */
+.m-usercard2{
+ border: 1px solid var(--color-border);
+ background: rgba(255,255,255,.92);
+ backdrop-filter: blur(10px);
+ border-radius: 16px;
+ padding: 14px;
+ display: grid;
+ gap: 12px;
+ margin: 15px;
+}
+
+/* head */
+.m-usercard2__head{
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ align-items: center;
+ gap: 10px;
+}
+
+.m-usercard2__badge{
+ font-size: 11px;
+ font-weight: 800;
+ letter-spacing: .06em;
+ padding: 6px 8px;
+ border-radius: 999px;
+ border: 1px solid var(--color-border);
+ background: rgba(0,0,0,.03);
+}
+
+.m-usercard2__title{
+ font-weight: 800;
+ font-size: 14px;
+ line-height: 1.2;
+}
+
+.m-usercard2__sub{
+ margin-top: 4px;
+ font-size: 12px;
+ color: var(--color-muted, #6b7280);
+ font-weight: 500;
+}
+
+.m-usercard2__icon{
+ width: 34px;
+ height: 34px;
+ display: grid;
+ place-items: center;
+ border-radius: 10px;
+ border: 1px solid var(--color-border);
+ background: rgba(0,0,0,.03);
+ text-decoration: none;
+}
+
+/* profile */
+.m-usercard2__profile{
+ display: grid;
+ grid-template-columns: 44px 1fr;
+ align-items: center;
+ gap: 12px;
+ padding: 12px;
+ border-radius: 14px;
+ border: 1px solid var(--color-border);
+ background: rgba(255,255,255,.65);
+}
+
+.m-usercard2__avatar{
+ width: 44px;
+ height: 44px;
+ border-radius: 14px;
+ display: grid;
+ place-items: center;
+ font-weight: 900;
+ background: rgba(0,0,0,.06);
+}
+
+.m-usercard2__avatar--ghost{
+ background: rgba(0,0,0,.03);
+}
+
+.m-usercard2__info{
+ min-width: 0;
+}
+
+.m-usercard2__name{
+ font-weight: 900;
+ font-size: 14px;
+ line-height: 1.2;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.m-usercard2__meta{
+ margin-top: 4px;
+ font-size: 12px;
+ color: var(--color-muted, #6b7280);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* actions */
+.m-usercard2__actions{
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+ align-items: center;
+}
+
+/* button system (drawer-only) */
+.m-btn2{
+ height: 42px;
+ border-radius: 12px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ font-weight: 800;
+ text-decoration: none;
+ border: 1px solid transparent;
+ background: var(--color-primary, #ff6a00);
+ color: #fff;
+}
+
+.m-btn2--ghost{
+ background: transparent;
+ border-color: var(--color-border);
+ color: var(--color-text, #111827);
+}
+
+.m-btn2--text{
+ background: transparent;
+ border-color: transparent;
+ color: var(--color-muted, #6b7280);
+}
+
+/* 로그인 상태: 로그아웃은 2칸을 차지해서 아래로 명확히 */
+.m-usercard2.is-login .m-usercard2__logout{
+ grid-column: 1 / -1;
+}
+
+.m-usercard2__logout{ margin: 0; }
+.m-usercard2__logout button{ width: 100%; }
diff --git a/resources/views/web/auth/debug_payload.blade.php b/resources/views/web/auth/debug_payload.blade.php
new file mode 100644
index 0000000..04ab297
--- /dev/null
+++ b/resources/views/web/auth/debug_payload.blade.php
@@ -0,0 +1,61 @@
+@extends('web.layouts.auth')
+
+@section('title', '회원가입 Debug | PIN FOR YOU')
+
+@section('auth_content')
+
+{{ json_encode($data, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT) }}
+
+
+{{ json_encode($last['queries'] ?? [], JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT) }}
+
+