diff --git a/app/Http/Controllers/Admin/AdminAdminsController.php b/app/Http/Controllers/Admin/AdminAdminsController.php index 58697e1..d8ef9a4 100644 --- a/app/Http/Controllers/Admin/AdminAdminsController.php +++ b/app/Http/Controllers/Admin/AdminAdminsController.php @@ -1,6 +1,5 @@ service->list($request->all()); + return view('admin.members.index', $data); + } + + public function show(int $memNo, Request $request) + { + $data = $this->service->showData($memNo); + + if (!($data['member'] ?? null)) { + return redirect()->route('admin.members.index')->with('toast', [ + 'type' => 'danger', + 'title' => '조회 실패', + 'message' => '회원을 찾을 수 없습니다.', + ]); + } + + return view('admin.members.show', $data); + } + + public function update(int $memNo, Request $request) + { + $actorId = (int) auth('admin')->id(); + + $res = $this->service->updateMember( + memNo: $memNo, + input: $request->all(), + actorAdminId: $actorId, + ip: (string) $request->ip(), + ua: (string) ($request->userAgent() ?? ''), + ); + + if (!($res['ok'] ?? false)) { + return redirect()->back()->withInput()->with('toast', [ + 'type' => 'danger', + 'title' => '저장 실패', + 'message' => $res['message'] ?? '저장에 실패했습니다.', + ]); + } + + return redirect() + ->route('admin.members.show', ['memNo' => $memNo]) + ->with('toast', [ + 'type' => 'success', + 'title' => '저장 완료', + 'message' => $res['message'] ?? '변경되었습니다.', + ]); + } + + public function addMemo(int $memNo, Request $request) + { + $data = $request->validate([ + 'memo' => ['required', 'string', 'max:1000'], + ]); + + $res = $this->service->addMemo( + memNo: $memNo, + memo: trim((string) $data['memo']), + actorAdminId: (int) auth('admin')->id(), + ip: (string) $request->ip(), + ua: (string) ($request->userAgent() ?? ''), + ); + + if (!($res['ok'] ?? false)) { + return redirect()->back()->with('toast', [ + 'type' => 'danger', + 'title' => '메모 실패', + 'message' => $res['message'] ?? '메모 저장에 실패했습니다.', + ]); + } + + return redirect() + ->route('admin.members.show', ['memNo' => $memNo]) + ->with('toast', [ + 'type' => 'success', + 'title' => '메모 등록', + 'message' => '메모가 추가되었습니다.', + ]); + } + + public function resetPassword(int $memNo, Request $request) + { + // 옵션: mode=random|email (기본 random) + $data = $request->validate([ + 'mode' => ['nullable', 'string', 'in:random,email'], + ]); + + $res = $this->service->resetPassword( + memNo: $memNo, + mode: (string)($data['mode'] ?? 'random'), + actorAdminId: (int) auth('admin')->id(), + ip: (string) $request->ip(), + ua: (string) ($request->userAgent() ?? ''), + ); + + if (!($res['ok'] ?? false)) { + return redirect()->back()->with('toast', [ + 'type' => 'danger', + 'title' => '초기화 실패', + 'message' => $res['message'] ?? '비밀번호 초기화에 실패했습니다.', + ]); + } + + return redirect() + ->route('admin.members.show', ['memNo' => $memNo]) + ->with('toast', [ + 'type' => 'success', + 'title' => '비밀번호 초기화', + 'message' => $res['message'] ?? '임시 비밀번호가 발급되었습니다.', + ]); + } + + public function forceOut(int $memNo, Request $request) + { + $res = $this->service->forceOut( + memNo: $memNo, + actorAdminId: (int) auth('admin')->id(), + ip: (string) $request->ip(), + ua: (string) ($request->userAgent() ?? ''), + ); + + if (!($res['ok'] ?? false)) { + return redirect()->back()->with('toast', [ + 'type' => 'danger', + 'title' => '강제탈퇴 실패', + 'message' => $res['message'] ?? '강제탈퇴 처리에 실패했습니다.', + ]); + } + + return redirect() + ->route('admin.members.show', ['memNo' => $memNo]) + ->with('toast', [ + 'type' => 'success', + 'title' => '강제탈퇴 처리', + 'message' => $res['message'] ?? '강제탈퇴 및 비식별 처리가 완료되었습니다.', + ]); + } +} diff --git a/app/Models/Member/MemAddress.php b/app/Models/Member/MemAddress.php deleted file mode 100644 index 4568bdc..0000000 --- a/app/Models/Member/MemAddress.php +++ /dev/null @@ -1,22 +0,0 @@ -belongsTo(MemInfo::class, 'mem_no', 'mem_no'); - } -} diff --git a/app/Models/Member/MemAuth.php b/app/Models/Member/MemAuth.php index 2668684..74a1554 100644 --- a/app/Models/Member/MemAuth.php +++ b/app/Models/Member/MemAuth.php @@ -8,8 +8,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class MemAuth extends Model { - - protected $table = 'mem_auth'; // 복합키라 Eloquent 기본 save/update 패턴이 불편함 diff --git a/app/Models/Member/MemAuthInfo.php b/app/Models/Member/MemAuthInfo.php index 0dbc889..5c374ec 100644 --- a/app/Models/Member/MemAuthInfo.php +++ b/app/Models/Member/MemAuthInfo.php @@ -8,8 +8,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class MemAuthInfo extends Model { - - protected $table = 'mem_auth_info'; protected $primaryKey = 'mem_no'; public $incrementing = false; diff --git a/app/Models/Member/MemAuthLog.php b/app/Models/Member/MemAuthLog.php index 80d08ac..d5294e3 100644 --- a/app/Models/Member/MemAuthLog.php +++ b/app/Models/Member/MemAuthLog.php @@ -8,8 +8,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class MemAuthLog extends Model { - - protected $table = 'mem_auth_log'; protected $primaryKey = 'seq'; public $incrementing = true; diff --git a/app/Models/Member/MemInfo.php b/app/Models/Member/MemInfo.php index 9f2d42a..167f30e 100644 --- a/app/Models/Member/MemInfo.php +++ b/app/Models/Member/MemInfo.php @@ -2,10 +2,7 @@ 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 @@ -16,10 +13,6 @@ class MemInfo extends Model public $incrementing = true; public $timestamps = false; - /** - * ✅ 보안: 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', @@ -36,121 +29,6 @@ class MemInfo extends Model 'admin_memo','modify_log', ]; - /** - * ✅ 레거시 zero-date(0000-00-00 ...)가 있으면 datetime/date cast는 예외/오작동 가능. - * 안전하게 JSON 컬럼만 cast (나머지는 safe accessor로 뽑아 쓰자) - */ - protected $casts = [ - 'admin_memo' => 'array', - 'modify_log' => 'array', - ]; - - /* ===================== - * Relationships - * ===================== */ - - public function authInfo(): HasOne - { - return $this->hasOne(MemAuthInfo::class, 'mem_no', 'mem_no'); - } - - public function authRows(): HasMany - { - // mem_auth 복합키 테이블이어도 조회 관계는 가능 - return $this->hasMany(MemAuth::class, 'mem_no', 'mem_no'); - } - - public function authLogs(): HasMany - { - return $this->hasMany(MemAuthLog::class, 'mem_no', 'mem_no'); - } - - public function addresses(): HasMany - { - return $this->hasMany(MemAddress::class, 'mem_no', 'mem_no'); - } - - public function joinLogs(): HasMany - { - return $this->hasMany(MemJoinLog::class, 'mem_no', 'mem_no'); - } - - public function stRing(): HasOne - { - return $this->hasOne(MemStRing::class, 'mem_no', 'mem_no'); - } - - public function loginRecents(): HasMany - { - return $this->hasMany(MemLoginRecent::class, 'mem_no', 'mem_no'); - } - - public function modLogs(): HasMany - { - return $this->hasMany(MemModLog::class, 'mem_no', 'mem_no'); - } - - /* ===================== - * 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 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; @@ -162,19 +40,4 @@ class MemInfo extends Model 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 b539b08..f65d2df 100644 --- a/app/Models/Member/MemJoinFilter.php +++ b/app/Models/Member/MemJoinFilter.php @@ -7,8 +7,6 @@ use Illuminate\Database\Eloquent\Model; class MemJoinFilter extends Model { - - protected $table = 'mem_join_filter'; protected $primaryKey = 'seq'; public $incrementing = true; diff --git a/app/Models/Member/MemJoinLog.php b/app/Models/Member/MemJoinLog.php deleted file mode 100644 index b561eea..0000000 --- a/app/Models/Member/MemJoinLog.php +++ /dev/null @@ -1,25 +0,0 @@ -belongsTo(MemInfo::class, 'mem_no', 'mem_no'); - } -} diff --git a/app/Models/Member/MemLoginRecent.php b/app/Models/Member/MemLoginRecent.php deleted file mode 100644 index 9791db5..0000000 --- a/app/Models/Member/MemLoginRecent.php +++ /dev/null @@ -1,25 +0,0 @@ -belongsTo(MemInfo::class, 'mem_no', 'mem_no'); - } -} diff --git a/app/Models/Member/MemLoginYear.php b/app/Models/Member/MemLoginYear.php deleted file mode 100644 index 521cfbe..0000000 --- a/app/Models/Member/MemLoginYear.php +++ /dev/null @@ -1,28 +0,0 @@ -forYear(2026)->create([...]) - */ -class MemLoginYear extends Model -{ - - - protected $primaryKey = 'seq'; - public $incrementing = true; - protected $keyType = 'int'; - public $timestamps = false; - - protected $guarded = []; - - public function forYear(int $year): self - { - $this->setTable('mem_login_' . $year); - return $this; - } -} diff --git a/app/Models/Member/MemModLog.php b/app/Models/Member/MemModLog.php deleted file mode 100644 index 5ff5893..0000000 --- a/app/Models/Member/MemModLog.php +++ /dev/null @@ -1,17 +0,0 @@ - 'array', - ]; -} diff --git a/app/Repositories/Admin/AdminUserRepository.php b/app/Repositories/Admin/AdminUserRepository.php index c7334cc..6d69aa4 100644 --- a/app/Repositories/Admin/AdminUserRepository.php +++ b/app/Repositories/Admin/AdminUserRepository.php @@ -523,4 +523,39 @@ final class AdminUserRepository 'updated_at' => now(), ]) > 0; } + + public function getEmailMapByIds(array $ids): array + { + $ids = array_values(array_unique(array_filter(array_map('intval', $ids), fn($v)=>$v>0))); + if (empty($ids)) return []; + + // id => email + return DB::table('admin_users') + ->whereIn('id', $ids) + ->pluck('email', 'id') + ->map(fn($v)=>(string)$v) + ->all(); + } + + public function getMetaMapByIds(array $ids): array + { + $ids = array_values(array_unique(array_filter(array_map('intval', $ids), fn($v)=>$v>0))); + if (empty($ids)) return []; + + $rows = DB::table('admin_users') + ->whereIn('id', $ids) + ->get(['id','email','name','nickname']); + + $map = []; + foreach ($rows as $r) { + $id = (int)$r->id; + $map[$id] = [ + 'email' => (string)($r->email ?? ''), + 'name' => (string)($r->name ?? ''), + 'nick' => (string)($r->nickname ?? ''), + ]; + } + return $map; + } + } diff --git a/app/Repositories/Admin/Member/AdminMemberRepository.php b/app/Repositories/Admin/Member/AdminMemberRepository.php new file mode 100644 index 0000000..0b1a166 --- /dev/null +++ b/app/Repositories/Admin/Member/AdminMemberRepository.php @@ -0,0 +1,182 @@ +where('m.mem_no', (int)$digits); + } else { + $q->whereRaw('1=0'); // 잘못된 입력이면 결과 없음 + } + break; + + case 'name': + $q->where('m.name', 'like', "%{$keyword}%"); + break; + + case 'email': + $q->where('m.email', 'like', "%{$keyword}%"); + break; + + case 'phone': + if ($phoneEnc !== '') { + $q->where('m.cell_phone', $phoneEnc); + } else { + $q->whereRaw('1=0'); // phone_enc 없으면 매칭 불가 + } + break; + + default: + // qf가 없거나(all)일 때: 기존 통합 검색 + phone_enc 있으면 OR로 포함 + $q->where(function ($w) use ($keyword, $digits, $phoneEnc) { + if ($digits !== '' && ctype_digit($digits)) { + $w->orWhere('m.mem_no', (int)$digits); + } + + $w->orWhere('m.name', 'like', "%{$keyword}%") + ->orWhere('m.email', 'like', "%{$keyword}%"); + + if ($phoneEnc !== '') { + $w->orWhere('m.cell_phone', $phoneEnc); + } + }); + break; + } + } + + // stat_3 + $stat3 = (string)($filters['stat_3'] ?? ''); + if ($stat3 !== '') { + $q->where('m.stat_3', $stat3); + } + + // date range + $dateFrom = trim((string)($filters['date_from'] ?? '')); + if ($dateFrom !== '') { + $q->where('m.dt_reg', '>=', $dateFrom.' 00:00:00'); + } + $dateTo = trim((string)($filters['date_to'] ?? '')); + if ($dateTo !== '') { + $q->where('m.dt_reg', '<=', $dateTo.' 23:59:59'); + } + + $q->orderByDesc('m.mem_no'); + + return $q->select([ + 'm.mem_no', + 'm.stat_1','m.stat_2','m.stat_3','m.stat_4','m.stat_5', + 'm.name','m.birth','m.gender','m.native', + 'm.cell_corp','m.cell_phone','m.email', + 'm.login_fail_cnt', + 'm.dt_login','m.dt_reg','m.dt_mod', + ])->paginate($perPage)->withQueryString(); + } + + + public function findMember(int $memNo): ?object + { + return DB::table('mem_info')->where('mem_no', $memNo)->first(); + } + + public function lockMemberForUpdate(int $memNo): ?object + { + return DB::table('mem_info')->where('mem_no', $memNo)->lockForUpdate()->first(); + } + + public function updateMember(int $memNo, array $data): bool + { + $data['dt_mod'] = $data['dt_mod'] ?? now()->format('Y-m-d H:i:s'); + return DB::table('mem_info')->where('mem_no', $memNo)->update($data) > 0; + } + + public function getAuthRowsForMember(int $memNo): array + { + return DB::table('mem_auth') + ->where('mem_no', $memNo) + ->orderBy('auth_type') + ->get() + ->map(fn($r)=>(array)$r) + ->all(); + } + + public function getAuthMapForMembers(array $memNos): array + { + if (empty($memNos)) return []; + + $rows = DB::table('mem_auth') + ->whereIn('mem_no', $memNos) + ->get(['mem_no','auth_type','auth_state','auth_date']); + + $map = []; + foreach ($rows as $r) { + $no = (int)$r->mem_no; + $map[$no] ??= []; + $map[$no][(string)$r->auth_type] = [ + 'state' => (string)$r->auth_state, + 'date' => (string)$r->auth_date, + ]; + } + return $map; + } + + public function getAuthInfo(int $memNo): ?array + { + $row = DB::table('mem_auth_info')->where('mem_no', $memNo)->first(); + if (!$row) return null; + + $json = (string)($row->auth_info ?? ''); + $arr = json_decode($json, true); + return is_array($arr) ? $arr : null; + } + + public function getAuthLogs(int $memNo, int $limit = 30): array + { + return DB::table('mem_auth_log') + ->where('mem_no', $memNo) + ->orderByDesc('seq') + ->limit($limit) + ->get() + ->map(fn($r)=>(array)$r) + ->all(); + } + + public function getAddresses(int $memNo): array + { + return DB::table('mem_address') + ->where('mem_no', $memNo) + ->orderByDesc('seq') + ->get() + ->map(fn($r)=>(array)$r) + ->all(); + } + + public function anonymizeAddresses(int $memNo): void + { + DB::table('mem_address') + ->where('mem_no', $memNo) + ->update([ + 'shipping' => '', + 'zipNo' => '', + 'roadAddrPart1' => '', + 'jibunAddr' => '', + 'addrDetail' => '', + ]); + } +} diff --git a/app/Repositories/Member/MemberAuthRepository.php b/app/Repositories/Member/MemberAuthRepository.php index 296ccac..52dd1b4 100644 --- a/app/Repositories/Member/MemberAuthRepository.php +++ b/app/Repositories/Member/MemberAuthRepository.php @@ -7,7 +7,6 @@ use App\Models\Member\MemAuthInfo; 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; diff --git a/app/Services/Admin/AdminOtpService.php b/app/Services/Admin/AdminOtpService.php deleted file mode 100644 index a5b71e3..0000000 --- a/app/Services/Admin/AdminOtpService.php +++ /dev/null @@ -1,99 +0,0 @@ - (string) $adminUserId, - 'otp_hash' => $otpHash, - 'attempts' => '0', - 'resend_count' => '0', - 'resend_after' => (string) (time() + $cooldown), - 'ip' => $ip, - 'ua' => mb_substr($ua, 0, 250), - ]); - Redis::expire($key, $ttl); - - return [ - 'challenge_id' => $challengeId, - 'otp' => $otp, // DB 저장 금지, “발송에만” 사용 - ]; - } - - public function canResend(string $challengeId): array - { - $key = (string) config('admin.redis_prefix', 'admin:2fa:') . 'challenge:' . $challengeId; - $resendAfter = (int) (Redis::hget($key, 'resend_after') ?: 0); - - if ($resendAfter > time()) { - return ['ok' => false, 'wait' => $resendAfter - time()]; - } - return ['ok' => true, 'wait' => 0]; - } - - public function resend(string $challengeId): ?string - { - $key = (string) config('admin.redis_prefix', 'admin:2fa:') . 'challenge:' . $challengeId; - - if (!Redis::exists($key)) return null; - - $cooldown = (int) config('admin.otp_resend_cooldown', 30); - $otpHashKey = (string) config('admin.otp_hash_key', ''); - - $otp = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT); - $otpHash = hash_hmac('sha256', $otp, $otpHashKey); - - Redis::hset($key, 'otp_hash', $otpHash); - Redis::hincrby($key, 'resend_count', 1); - Redis::hset($key, 'resend_after', (string) (time() + $cooldown)); - - return $otp; - } - - public function verify(string $challengeId, string $otpInput): array - { - $key = (string) config('admin.redis_prefix', 'admin:2fa:') . 'challenge:' . $challengeId; - if (!Redis::exists($key)) { - return ['ok' => false, 'reason' => 'expired']; - } - - $maxAttempts = (int) config('admin.otp_max_attempts', 5); - $attempts = (int) (Redis::hget($key, 'attempts') ?: 0); - if ($attempts >= $maxAttempts) { - return ['ok' => false, 'reason' => 'locked']; - } - - $otpHashKey = (string) config('admin.otp_hash_key', ''); - $expected = (string) Redis::hget($key, 'otp_hash'); - $given = hash_hmac('sha256', trim($otpInput), $otpHashKey); - - if (!hash_equals($expected, $given)) { - Redis::hincrby($key, 'attempts', 1); - $left = max(0, $maxAttempts - ($attempts + 1)); - return ['ok' => false, 'reason' => 'mismatch', 'left' => $left]; - } - - $adminUserId = (int) (Redis::hget($key, 'admin_user_id') ?: 0); - Redis::del($key); - - return ['ok' => true, 'admin_user_id' => $adminUserId]; - } -} diff --git a/app/Services/Admin/AdminUserManageService.php b/app/Services/Admin/AdminUserManageService.php deleted file mode 100644 index c3c546b..0000000 --- a/app/Services/Admin/AdminUserManageService.php +++ /dev/null @@ -1,295 +0,0 @@ -repo->getAllRoles(); // [id, code, name] - } - - /** @return array{0:mixed,1:array,2:array} */ - public function list(Request $request): array - { - $pager = $this->repo->paginateUsers([ - 'q' => (string)$request->query('q', ''), - 'status' => (string)$request->query('status', ''), - 'role' => (string)$request->query('role', ''), - ], 20); - - $ids = $pager->getCollection()->pluck('id')->map(fn($v)=>(int)$v)->all(); - - $roleMap = $this->repo->getRoleMapForUsers($ids); - $permCntMap = $this->repo->getPermissionCountMapForUsers($ids); - - // last_login_ip가 binary일 수 있으니 텍스트 변환 보조값 붙이기 - $pager->getCollection()->transform(function ($u) { - $u->last_login_ip_text = $this->ipToText($u->last_login_ip ?? null); - return $u; - }); - - return [$pager, $roleMap, $permCntMap]; - } - - public function editData(int $id, int $actorId): array - { - $u = $this->repo->find($id); - if (!$u) { - return ['ok' => false, 'message' => '관리자 정보를 찾을 수 없습니다.']; - } - - $rolesAll = $this->repo->getAllRoles(); - $roleIds = $this->repo->getRoleIdsForUser($id); - $roles = $this->repo->getRolesForUser($id); - $perms = $this->repo->getPermissionsForUser($id); - - $u->last_login_ip_text = $this->ipToText($u->last_login_ip ?? null); - - return [ - 'ok' => true, - 'me_actor_id' => $actorId, - 'user' => $u, - 'rolesAll' => $rolesAll, - 'roleIds' => $roleIds, - 'roles' => $roles, - 'perms' => $perms, - ]; - } - - public function update(int $id, int $actorId, Request $request): array - { - $u = $this->repo->find($id); - if (!$u) return ['ok' => false, 'message' => '관리자 정보를 찾을 수 없습니다.']; - - // 입력값(컬럼 존재 여부와 무관하게 validate는 가능) - $data = $request->validate([ - 'nickname' => ['required','string','min:2','max:80'], - 'name' => ['required','string','min:2','max:80'], - 'email' => ['required','email','max:190'], - - 'status' => ['nullable','string','max:20'], - 'two_factor_mode' => ['nullable','string','max:20'], - 'totp_enabled' => ['nullable'], - - 'phone' => ['nullable','string','max:30'], - - 'roles' => ['array'], - 'roles.*' => ['integer'], - ]); - - // 안전장치: 자기 자신 비활성화 방지(사고 방지) - if ($actorId === $id) { - if (($data['status'] ?? '') && in_array($data['status'], ['disabled','suspended','deleted'], true)) { - return ['ok'=>false,'message'=>'본인 계정은 비활성/정지할 수 없습니다.']; - } - } - - $before = $this->snapshotUser($u); - - // phone 처리(선택) - $phoneDigits = ''; - $phoneEnc = null; - $phoneHash = null; - - $rawPhone = trim((string)($data['phone'] ?? '')); - if ($rawPhone !== '') { - $phoneDigits = $this->normalizeKoreanPhone($rawPhone); - if ($phoneDigits === '') { - return ['ok'=>false,'errors'=>['phone'=>'휴대폰 번호 형식이 올바르지 않습니다.']]; - } - - $hashKey = (string) config('admin.phone_hash_key', env('ADMIN_PHONE_HASH_KEY', '')); - if ($hashKey === '') { - return ['ok'=>false,'message'=>'ADMIN_PHONE_HASH_KEY 설정이 필요합니다.']; - } - - $phoneHash = hash_hmac('sha256', $phoneDigits, $hashKey); - $phoneEnc = Crypt::encryptString($phoneDigits); - - // 중복 방지(운영용) — 테스트 정책이면 여기 조건 완화 가능 - if ($this->repo->existsPhoneHash($phoneHash, $id)) { - return ['ok'=>false,'errors'=>['phone'=>'이미 사용 중인 휴대폰 번호입니다.']]; - } - } - - $payload = [ - 'nickname' => (string)$data['nickname'], - 'name' => (string)$data['name'], - 'email' => (string)$data['email'], - - 'status' => (string)($data['status'] ?? $u->status ?? 'active'), - 'two_factor_mode' => (string)($data['two_factor_mode'] ?? $u->two_factor_mode ?? 'sms'), - 'totp_enabled' => isset($data['totp_enabled']) ? (int)!!$data['totp_enabled'] : (int)($u->totp_enabled ?? 0), - - 'phone_enc' => $phoneEnc, - 'phone_hash' => $phoneHash, - - 'updated_by' => $actorId, - ]; - - $roleIds = array_values(array_unique(array_map('intval', $data['roles'] ?? []))); - - return DB::transaction(function () use ($id, $actorId, $request, $payload, $roleIds, $before) { - $ok = $this->repo->updateById($id, $payload); - if (!$ok) return ['ok'=>false,'message'=>'저장에 실패했습니다.']; - - // 역할 동기화(선택) - if (!empty($roleIds)) { - // 안전장치: 본인 super_admin 제거 금지 - if ($actorId === $id) { - $cur = $this->repo->getRolesForUser($id); - $hasSuper = collect($cur)->contains(fn($r) => ($r['code'] ?? '') === 'super_admin'); - if ($hasSuper) { - $superId = $this->repo->findRoleIdByCode('super_admin'); - if ($superId && !in_array($superId, $roleIds, true)) { - return ['ok'=>false,'message'=>'본인 super_admin 역할은 제거할 수 없습니다.']; - } - } - } - - $this->repo->syncRoles($id, $roleIds, $actorId); - } - - $u2 = $this->repo->find($id); - $after = $this->snapshotUser($u2); - - $this->audit->log( - actorAdminId: $actorId, - action: 'admin_user.update', - targetType: 'admin_user', - targetId: $id, - before: $before, - after: $after, - ip: (string)$request->ip(), - ua: (string)$request->userAgent(), - ); - - return ['ok'=>true,'message'=>'관리자 정보가 저장되었습니다.']; - }); - } - - public function resetPasswordToEmail(int $id, int $actorId, Request $request): array - { - $u = $this->repo->find($id); - if (!$u) return ['ok'=>false,'message'=>'관리자 정보를 찾을 수 없습니다.']; - - $before = $this->snapshotUser($u); - - // 임시 비밀번호 = 이메일 - $temp = (string)($u->email ?? ''); - if ($temp === '') return ['ok'=>false,'message'=>'이메일이 없어 초기화할 수 없습니다.']; - - return DB::transaction(function () use ($u, $id, $actorId, $request, $temp, $before) { - // password cast(hashed) 있으면 plain 넣어도 자동 해싱됨 - $this->repo->setTemporaryPassword($u, $temp, $actorId); - - $u2 = $this->repo->find($id); - $after = $this->snapshotUser($u2); - - $this->audit->log( - actorAdminId: $actorId, - action: 'admin_user.password_reset_to_email', - targetType: 'admin_user', - targetId: $id, - before: $before, - after: $after, - ip: (string)$request->ip(), - ua: (string)$request->userAgent(), - ); - - return ['ok'=>true,'message'=>'비밀번호가 이메일로 초기화되었습니다. (다음 로그인 시 변경 강제)']; - }); - } - - public function unlock(int $id, int $actorId, Request $request): array - { - $u = $this->repo->find($id); - if (!$u) return ['ok'=>false,'message'=>'관리자 정보를 찾을 수 없습니다.']; - - $before = $this->snapshotUser($u); - - $payload = [ - 'locked_until' => null, - 'failed_login_count' => 0, - 'updated_by' => $actorId, - ]; - - return DB::transaction(function () use ($id, $actorId, $request, $payload, $before) { - $ok = $this->repo->updateById($id, $payload); - if (!$ok) return ['ok'=>false,'message'=>'잠금 해제에 실패했습니다.']; - - $u2 = $this->repo->find($id); - $after = $this->snapshotUser($u2); - - $this->audit->log( - actorAdminId: $actorId, - action: 'admin_user.unlock', - targetType: 'admin_user', - targetId: $id, - before: $before, - after: $after, - ip: (string)$request->ip(), - ua: (string)$request->userAgent(), - ); - - return ['ok'=>true,'message'=>'잠금이 해제되었습니다.']; - }); - } - - private function snapshotUser($u): array - { - if (!$u) return []; - return [ - 'id' => (int)($u->id ?? 0), - 'email' => (string)($u->email ?? ''), - 'nickname' => (string)($u->nickname ?? ''), - 'name' => (string)($u->name ?? ''), - 'status' => (string)($u->status ?? ''), - 'two_factor_mode' => (string)($u->two_factor_mode ?? ''), - 'totp_enabled' => (int)($u->totp_enabled ?? 0), - 'locked_until' => (string)($u->locked_until ?? ''), - 'last_login_at' => (string)($u->last_login_at ?? ''), - ]; - } - - private function normalizeKoreanPhone(string $raw): string - { - $n = preg_replace('/\D+/', '', $raw) ?? ''; - if ($n === '') return ''; - - // 010xxxxxxxx 형태만 간단 허용(필요시 확장) - if (str_starts_with($n, '010') && strlen($n) === 11) return $n; - if (str_starts_with($n, '01') && strlen($n) >= 10 && strlen($n) <= 11) return $n; - - return ''; - } - - private function ipToText($binOrText): string - { - if ($binOrText === null) return '-'; - if (is_string($binOrText)) { - // 이미 문자열 IP면 그대로 - if (str_contains($binOrText, '.') || str_contains($binOrText, ':')) return $binOrText; - - // binary(4/16) 가능성 - $len = strlen($binOrText); - if ($len === 4 || $len === 16) { - $t = @inet_ntop($binOrText); - return $t ?: '-'; - } - } - return '-'; - } -} diff --git a/app/Services/Admin/Mail/AdminMailService.php b/app/Services/Admin/Mail/AdminMailService.php index 2676efc..92c87d1 100644 --- a/app/Services/Admin/Mail/AdminMailService.php +++ b/app/Services/Admin/Mail/AdminMailService.php @@ -56,7 +56,6 @@ final class AdminMailService 'one'=>'단건', 'many'=>'여러건', 'csv'=>'CSV 업로드', - 'db'=>'DB 검색', ]; } @@ -295,17 +294,6 @@ final class AdminMailService if (!$file) return ['ok'=>false,'message'=>'CSV 파일을 업로드하세요.']; $raw = array_merge($raw, $this->parseEmailsCsv($file->getRealPath())); } - else { // db - $q = trim((string)($data['db_q'] ?? '')); - $limit = (int)($data['db_limit'] ?? 3000); - if ($q === '') return ['ok'=>false,'message'=>'DB 검색어를 입력하세요.']; - - $members = []; // TODO - - foreach ($members as $m) { - $raw[] = ['email'=>$m['email'], 'name'=>$m['name'] ?? '', 'tokens'=>[]]; - } - } $total = count($raw); $seen = []; diff --git a/app/Services/Admin/Member/AdminMemberService.php b/app/Services/Admin/Member/AdminMemberService.php new file mode 100644 index 0000000..45df31d --- /dev/null +++ b/app/Services/Admin/Member/AdminMemberService.php @@ -0,0 +1,428 @@ +normalizeKrPhone($digits); + if ($phone !== '') { + $filters['phone_enc'] = $this->encryptPhone($phone); + } + } + } + $page = $this->repo->paginateMembers($filters, 20); + + $memNos = $page->getCollection()->pluck('mem_no')->map(fn($v)=>(int)$v)->all(); + $authMapAll = $this->repo->getAuthMapForMembers($memNos); + + $authMap = []; + foreach ($authMapAll as $no => $types) { + foreach (self::AUTH_TYPES_SHOW as $t) { + if (isset($types[$t])) $authMap[(int)$no][$t] = $types[$t]; + } + } + + // 표시용 맵 + $phoneMap = []; + $corpMap = $this->corpMap(); + foreach ($page as $m) { + $no = (int)$m->mem_no; + $plain = $this->plainPhone((string)($m->cell_phone ?? '')); + $phoneMap[$no] = $this->formatPhone($plain); + } + + return [ + 'page' => $page, + 'filters' => $filters, + 'authMap' => $authMap, + + 'stat3Map' => $this->stat3Map(), + 'genderMap' => $this->genderMap(), + 'nativeMap' => $this->nativeMap(), + 'corpMap' => $corpMap, + 'phoneMap' => $phoneMap, + ]; + } + + public function showData(int $memNo): array + { + $m = $this->repo->findMember($memNo); + if (!$m) return ['member' => null]; + + $member = (object)((array)$m); + + $corpLabel = $this->corpMap()[(string)($member->cell_corp ?? 'n')] ?? '-'; + $plainPhone = $this->plainPhone((string)($member->cell_phone ?? '')); + $phoneDisplay = $this->formatPhone($plainPhone); + + $adminMemo = $this->decodeJsonArray($member->admin_memo ?? null); + $modifyLog = $this->decodeJsonArray($member->modify_log ?? null); + $modifyLog = array_reverse($modifyLog); + + $actorIds = []; + foreach ($modifyLog as $it) { + $aid = (int)($it['actor_admin_id'] ?? 0); + if ($aid > 0) $actorIds[] = $aid; + } + + + // 인증/주소/로그 + $authRows = array_values(array_filter( + $this->repo->getAuthRowsForMember($memNo), + fn($r)=> in_array((string)($r['auth_type'] ?? ''), self::AUTH_TYPES_SHOW, true) + )); + + $authInfo = $this->repo->getAuthInfo($memNo); + $authLogs = $this->repo->getAuthLogs($memNo, 30); + $addresses = $this->repo->getAddresses($memNo); + + // 계좌 표시(수정 불가 / 표시만) + $bank = $this->buildBankDisplay($member, $authInfo); + + $adminMap = $this->adminRepo->getMetaMapByIds($actorIds); + + return [ + 'member' => $member, + 'corpLabel' => $corpLabel, + 'plainPhone' => $plainPhone, + 'phoneDisplay' => $phoneDisplay, + + 'adminMemo' => $adminMemo, + 'modifyLog' => $modifyLog, + + 'authRows' => $authRows, + 'authInfo' => $authInfo, + 'authLogs' => $authLogs, + 'addresses' => $addresses, + + 'stat3Map' => $this->stat3Map(), + 'genderMap' => $this->genderMap(), + 'nativeMap' => $this->nativeMap(), + 'corpMap' => $this->corpMap(), + + 'bank' => $bank, + + 'modifyLog' => $modifyLog, + 'adminMap' => $adminMap, + ]; + } + + /** + * ✅ 업데이트 허용: stat_3(1~3만), cell_corp, cell_phone + * ❌ 금지: 이름/이메일/수신동의/계좌/기타 + */ + public function updateMember(int $memNo, array $input, int $actorAdminId, string $ip = '', string $ua = ''): array + { + try { + return DB::transaction(function () use ($memNo, $input, $actorAdminId, $ip, $ua) { + $before = $this->repo->lockMemberForUpdate($memNo); + if (!$before) return $this->fail('회원을 찾을 수 없습니다.'); + + $beforeArr = (array)$before; + $data = []; + $changes = []; + + // ✅ stat_3: 1~3만 변경 허용, 4~6은 시스템 상태로 변경 금지 + if (array_key_exists('stat_3', $input)) { + $s3 = (string)($input['stat_3'] ?? ''); + if (!in_array($s3, ['1','2','3','4','5','6'], true)) { + return $this->fail('회원상태(stat_3)는 1~6만 유효합니다.'); + } + if (in_array($s3, ['4','5','6'], true)) { + return $this->fail('4~6 상태는 시스템 상태로 관리자 변경이 불가합니다.'); + } + + if ($s3 !== (string)($before->stat_3 ?? '')) { + $data['stat_3'] = $s3; + $data['dt_stat_3'] = now()->format('Y-m-d H:i:s'); + } + } + + // ✅ 통신사 + if (array_key_exists('cell_corp', $input)) { + $corp = (string)($input['cell_corp'] ?? 'n'); + $allowed = ['n','01','02','03','04','05','06']; + if (!in_array($corp, $allowed, true)) return $this->fail('통신사 코드가 올바르지 않습니다.'); + if ($corp !== (string)($before->cell_corp ?? 'n')) $data['cell_corp'] = $corp; + } + + // ✅ 휴대폰(암호화 저장) + if (array_key_exists('cell_phone', $input)) { + $raw = trim((string)($input['cell_phone'] ?? '')); + if ($raw === '') { + // 전화번호 비우는 것 자체는 허용하되 운영 정책에 따라 막고 싶으면 여기서 fail 처리 + $enc = ''; + } else { + $phone = $this->normalizeKrPhone($raw); + if ($phone === '') return $this->fail('휴대폰 번호 형식이 올바르지 않습니다.'); + $enc = $this->encryptPhone($phone); + } + + if ((string)($before->cell_phone ?? '') !== $enc) { + $data['cell_phone'] = $enc; + } + } + + if (empty($data)) { + return $this->ok('변경사항이 없습니다.'); + } + + foreach ($data as $k => $v) { + $beforeVal = $beforeArr[$k] ?? null; + if ((string)$beforeVal !== (string)$v) { + $changes[$k] = ['before' => $beforeVal, 'after' => $v]; + } + } + + // modify_log append + $modify = $this->decodeJsonArray($before->modify_log ?? null); + $modify = $this->appendJson($modify, [ + 'ts' => now()->format('Y-m-d H:i:s'), + 'actor_admin_id' => $actorAdminId, + 'action' => 'member_update', + 'ip' => $ip, + 'ua' => $ua, + 'changes' => $changes, + ], 300); + + $data['modify_log'] = $this->encodeJsonOrNull($modify); + + $ok = $this->repo->updateMember($memNo, $data); + if (!$ok) return $this->fail('저장에 실패했습니다.'); + + return $this->ok('변경되었습니다.'); + }); + } catch (\Throwable $e) { + return $this->fail('저장 중 오류가 발생했습니다. (DB)'); + } + } + + // ------------------------- + // Maps + // ------------------------- + private function stat3Map(): array + { + return [ + '1' => '1. 로그인정상', + '2' => '2. 로그인만가능', + '3' => '3. 로그인불가 (접근금지)', + '4' => '4. 탈퇴완료(아이디보관)', + '5' => '5. 탈퇴완료', + '6' => '6. 휴면회원', + ]; + } + + private function genderMap(): array + { + return ['1' => '남자', '0' => '여자', 'n' => '-']; + } + + private function nativeMap(): array + { + return ['1' => '내국인', '2' => '외국인', 'n' => '-']; + } + + private function corpMap(): array + { + return [ + 'n' => '-', + '01' => 'SKT', + '02' => 'KT', + '03' => 'LGU+', + '04' => 'SKT(알뜰폰)', + '05' => 'KT(알뜰폰)', + '06' => 'LGU+(알뜰폰)', + ]; + } + + // ------------------------- + // Phone helpers + // ------------------------- + private function normalizeKrPhone(string $raw): string + { + $n = preg_replace('/\D+/', '', $raw) ?? ''; + if ($n === '') return ''; + if (str_starts_with($n, '82')) $n = '0'.substr($n, 2); + if (!preg_match('/^01[016789]\d{7,8}$/', $n)) return ''; + return $n; + } + + private function encryptPhone(string $raw010): string + { + $seed = app(CiSeedCrypto::class); + return (string) $seed->encrypt($raw010); + } + + // DB 컬럼이 평문/암호문 섞여있을 수 있어 “plain digits”로 뽑아줌 + private function plainPhone(string $cellPhoneCol): string + { + $v = trim($cellPhoneCol); + if ($v === '') return ''; + + $digits = preg_replace('/\D+/', '', $v) ?? ''; + if (preg_match('/^\d{10,11}$/', $digits)) return $digits; + + try { + $seed = app(CiSeedCrypto::class); + $plain = (string) $seed->decrypt($v); + $plainDigits = preg_replace('/\D+/', '', $plain) ?? ''; + if (preg_match('/^\d{10,11}$/', $plainDigits)) return $plainDigits; + } catch (\Throwable $e) { + // ignore + } + + return ''; + } + + private function formatPhone(string $digits): string + { + if (!preg_match('/^\d{10,11}$/', $digits)) return '-'; + if (strlen($digits) === 11) { + return substr($digits,0,3).'-'.substr($digits,3,4).'-'.substr($digits,7,4); + } + // 10자리 + return substr($digits,0,3).'-'.substr($digits,3,3).'-'.substr($digits,6,4); + } + + // ------------------------- + // Bank display (readonly) + // ------------------------- + private function buildBankDisplay(object $member, ?array $authInfo): array + { + $bankName = trim((string)($member->bank_name ?? '')); + $acc = trim((string)($member->bank_act_num ?? '')); + $holder = trim((string)($member->name ?? '')); + + // authInfo 쪽에 계좌인증 정보가 있으면 우선 사용(키는 프로젝트마다 다르니 방어적으로) + if (is_array($authInfo)) { + $maybe = $authInfo['account'] ?? $authInfo['bank'] ?? null; + if (is_array($maybe)) { + $bankName = trim((string)($maybe['bank_name'] ?? $maybe['bankName'] ?? $bankName)); + $acc = trim((string)($maybe['bank_act_num'] ?? $maybe['account'] ?? $maybe['account_number'] ?? $acc)); + $holder = trim((string)($maybe['holder'] ?? $maybe['name'] ?? $holder)); + } + } + + $has = ($bankName !== '' && $acc !== ''); + $masked = $has ? $this->maskAccount($acc) : ''; + + return [ + 'has' => $has, + 'bank_name' => $bankName, + 'account' => $masked, + 'holder' => ($holder !== '' ? $holder : '-'), + ]; + } + + private function maskAccount(string $acc): string + { + // 암호문/특수문자 섞여도 최소 마스킹은 해줌 + $d = preg_replace('/\s+/', '', $acc); + if (mb_strlen($d) <= 4) return $d; + return str_repeat('*', max(0, mb_strlen($d)-4)).mb_substr($d, -4); + } + + // ------------------------- + // JSON helpers + // ------------------------- + private function decodeJsonArray($jsonOrNull): array + { + if ($jsonOrNull === null) return []; + $s = trim((string)$jsonOrNull); + if ($s === '') return []; + $arr = json_decode($s, true); + return is_array($arr) ? $arr : []; + } + + private function encodeJsonOrNull(array $arr): ?string + { + if (empty($arr)) return null; + return json_encode($arr, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + } + + private function appendJson(array $list, array $entry, int $max = 200): array + { + array_unshift($list, $entry); + if (count($list) > $max) $list = array_slice($list, 0, $max); + return $list; + } + + public function addMemo(int $memNo, string $memo, int $actorAdminId, string $ip = '', string $ua = ''): array + { + $memo = trim($memo); + if ($memo === '') return $this->fail('메모를 입력해 주세요.'); + if (mb_strlen($memo) > 1000) return $this->fail('메모는 1000자 이내로 입력해 주세요.'); + + try { + return DB::transaction(function () use ($memNo, $memo, $actorAdminId, $ip, $ua) { + + // row lock + $before = $this->repo->lockMemberForUpdate($memNo); + if (!$before) return $this->fail('회원을 찾을 수 없습니다.'); + + // 기존 memo json + $adminMemo = $this->decodeJsonArray($before->admin_memo ?? null); + + // append + $adminMemo = $this->appendJson($adminMemo, [ + 'ts' => now()->format('Y-m-d H:i:s'), + 'actor_admin_id' => $actorAdminId, + 'ip' => $ip, + 'ua' => $ua, + 'memo' => $memo, + ], 300); + + // (선택) modify_log에도 남기기 + $modify = $this->decodeJsonArray($before->modify_log ?? null); + $modify = $this->appendJson($modify, [ + 'ts' => now()->format('Y-m-d H:i:s'), + 'actor_admin_id' => $actorAdminId, + 'action' => 'admin_memo_add', + 'ip' => $ip, + 'ua' => $ua, + 'changes' => ['admin_memo' => ['before' => null, 'after' => 'added']], + ], 300); + + $data = [ + 'admin_memo' => $this->encodeJsonOrNull($adminMemo), + 'modify_log' => $this->encodeJsonOrNull($modify), + 'dt_mod' => now()->format('Y-m-d H:i:s'), // 최근정보변경일시 반영 + ]; + + $ok = $this->repo->updateMember($memNo, $data); + if (!$ok) return $this->fail('메모 저장에 실패했습니다.'); + + return $this->ok('메모가 추가되었습니다.'); + }); + } catch (\Throwable $e) { + return $this->fail('메모 저장 중 오류가 발생했습니다. (DB)'); + } + } + + + private function ok(string $msg): array { return ['ok'=>true,'message'=>$msg]; } + private function fail(string $msg): array { return ['ok'=>false,'message'=>$msg]; } +} diff --git a/app/Services/MemInfoService.php b/app/Services/MemInfoService.php index f737380..e62906c 100644 --- a/app/Services/MemInfoService.php +++ b/app/Services/MemInfoService.php @@ -306,7 +306,7 @@ class MemInfoService // stat_3 차단 로직 (CI3 id_exists 반영) if ((string)$mem->stat_3 === '3') { - return ['ok'=>false, 'message'=>"접근금지 계정입니다.

고객센터 1833-4856로 문의 하세요"]; + return ['ok'=>false, 'message'=>"접근금지 계정입니다.\n\n고객센터 1833-4856 로 문의 하세요"]; } if ((string)$mem->stat_3 === '4') { return ['ok'=>false, 'message'=>'아이디 혹은 비밀번호가 일치하지 않습니다.2']; diff --git a/resources/views/admin/mail/send.blade.php b/resources/views/admin/mail/send.blade.php index 1c66af1..cf4ac80 100644 --- a/resources/views/admin/mail/send.blade.php +++ b/resources/views/admin/mail/send.blade.php @@ -149,7 +149,6 @@ -
@@ -236,22 +235,6 @@
- -
-
회원 DB 검색 발송
-
- * 서버에서 조건에 맞는 회원을 찾아 임시 리스트를 만든 뒤, 큐로 천천히 발송합니다. -
- -
- - -
- -
- 예) gmail.com / 홍길동 / mem_no:123 -
-
{{-- RIGHT: message/template/preview --}} diff --git a/resources/views/admin/members/index.blade.php b/resources/views/admin/members/index.blade.php new file mode 100644 index 0000000..89142af --- /dev/null +++ b/resources/views/admin/members/index.blade.php @@ -0,0 +1,344 @@ +@extends('admin.layouts.app') + +@section('title', '회원 관리') +@section('page_title', '회원 관리') +@section('page_desc', '회원상태/기본정보/인증을 조회합니다.') +@section('content_class', 'a-content--full') + +@push('head') + +@endpush + +@section('content') + @php + // stat_3 매핑 (1~6) + $stat3Map = $stat3Map ?? [ + '1' => '1. 로그인정상', + '2' => '2. 로그인만가능', + '3' => '3. 로그인불가(접근금지)', + '4' => '4. 탈퇴완료(아이디보관)', + '5' => '5. 탈퇴완료', + '6' => '6. 휴면회원', + ]; + + // stat_3 pill 색상 + $stat3Pill = function(string $s3): string { + return match ($s3) { + '1' => 'pill--ok', + '2' => 'pill--warn', + '3' => 'pill--bad', + '4', '5' => 'pill--muted', + '6' => 'pill--warn', + default => 'pill--muted', + }; + }; + + // 성별 라벨 + $genderLabel = function(?string $g): string { + $g = (string)($g ?? ''); + return match ($g) { + '1' => '남자', + '0' => '여자', + default => '-', + }; + }; + + // 세는나이: 현재년도 - 출생년도 + 1 + $koreanAge = function($birth): ?int { + $b = (string)($birth ?? ''); + if ($b === '' || $b === '0000-00-00') return null; + try { + $y = \Carbon\Carbon::parse($b)->year; + if ($y < 1900) return null; + return (int) now()->year - $y + 1; + } catch (\Throwable $e) { + return null; + } + }; + + // 만나이: 생일 지났으면 diffInYears 그대로, 아니면 -1 반영되는 Carbon diffInYears 사용 + $manAge = function($birth): ?int { + $b = (string)($birth ?? ''); + if ($b === '' || $b === '0000-00-00') return null; + try { + $dob = \Carbon\Carbon::parse($b); + if ($dob->year < 1900) return null; + return (int) $dob->diffInYears(now()); // full years + } catch (\Throwable $e) { + return null; + } + }; + + // authMap 어떤 형태든 Y만 뽑기 (email/cell/account만, vow/otp는 제외) + $pickAuthOk = function($auth): array { + $out = ['email'=>false,'cell'=>false,'account'=>false]; + + $stateOf = function($v): string { + if (is_string($v)) return $v; + if (is_array($v)) { + if (isset($v['auth_state'])) return (string)$v['auth_state']; + if (isset($v['state'])) return (string)$v['state']; + return ''; + } + if (is_object($v)) { + if (isset($v->auth_state)) return (string)$v->auth_state; + if (isset($v->state)) return (string)$v->state; + return ''; + } + return ''; + }; + + // assoc + if (is_array($auth) && (array_key_exists('email',$auth) || array_key_exists('cell',$auth) || array_key_exists('account',$auth))) { + foreach (['email','cell','account'] as $t) { + $out[$t] = ($stateOf($auth[$t] ?? '') === 'Y'); + } + return $out; + } + + // rows list + if (is_array($auth)) { + foreach ($auth as $row) { + $type = ''; + $state = ''; + if (is_array($row)) { + $type = (string)($row['auth_type'] ?? $row['type'] ?? ''); + $state = (string)($row['auth_state'] ?? $row['state'] ?? ''); + } elseif (is_object($row)) { + $type = (string)($row->auth_type ?? $row->type ?? ''); + $state = (string)($row->auth_state ?? $row->state ?? ''); + } + if (in_array($type, ['email','cell','account'], true) && $state === 'Y') { + $out[$type] = true; + } + } + return $out; + } + + return $out; + }; + + $qf = (string)($filters['qf'] ?? 'all'); + @endphp + +
+
+
+
회원 관리
+
회원상태/기본정보/인증을 조회합니다.
+
+ +
+
+
+ +
+ +
+ +
+ +
+ +
+ +
+
+ +
+ + 초기화 +
+
+
+
+
+ +
+
{{ $page->total() }}
+ +
+ + + + + + + + + + + + + + + + @forelse($page as $m) + @php + $no = (int)($m->mem_no ?? 0); + + $gender = $genderLabel($m->gender ?? null); + $ageK = $koreanAge($m->birth ?? null); + $ageM = $manAge($m->birth ?? null); + + $s3 = (string)($m->stat_3 ?? ''); + $s3Label = $stat3Map[$s3] ?? ($s3 !== '' ? $s3 : '-'); + $s3Pill = $stat3Pill($s3); + + $authRaw = $authMap[$no] ?? ($authMap[(string)$no] ?? null); + $ok = $pickAuthOk($authRaw); + + $joinAt = $m->dt_reg ?? '-'; + @endphp + + + + + {{-- 성명 --}} + + + {{-- 성별/나이(세는나이 + 만나이) --}} + + + {{-- 이메일 --}} + + + {{-- 회원상태(색상 분기) --}} + + + {{-- 인증: Y만 표시 --}} + + + {{-- 가입일 --}} + + + + + @empty + + + + @endforelse + +
MEM_NO성명성별/나이이메일회원상태인증(Y만)가입일관리
{{ $no }}
{{ $m->name ?? '-' }}
+
+ {{ $gender }} + @if($ageK !== null || $ageM !== null) + + {{ $ageK !== null ? "{$ageK}세" : '-' }} + (만 {{ $ageM !== null ? "{$ageM}세" : '-' }}) + + @else + - + @endif +
+
+ @if(!empty($m->email)) + {{ $m->email }} + @else + - + @endif + + ● {{ $s3Label }} + +
+ @if($ok['email']) + 이메일 + @endif + @if($ok['cell']) + 휴대폰 + @endif + @if($ok['account']) + 계좌 + @endif + + @if(!$ok['email'] && !$ok['cell'] && !$ok['account']) + - + @endif +
+
{{ $joinAt }} + + 보기 + +
데이터가 없습니다.
+
+ +
+ {{ $page->links() }} +
+
+ + +@endsection diff --git a/resources/views/admin/members/password_reset.blade.php b/resources/views/admin/members/password_reset.blade.php new file mode 100644 index 0000000..e83f5c7 --- /dev/null +++ b/resources/views/admin/members/password_reset.blade.php @@ -0,0 +1,46 @@ + + + + + + {{ $subject ?? '' }} + + +
+
+
{{ $brand ?? 'Service' }}
+
{{ $subject ?? '' }}
+ +
+ @if(!empty($name)) +
{{ $name }} 님,
+ @endif + +
비밀번호 초기화 요청이 접수되어 임시 비밀번호가 발급되었습니다.
+ +
+
임시 비밀번호
+
{{ $temp_password ?? '' }}
+
+ +
로그인 후 즉시 비밀번호를 변경해 주세요.
+ + @if(!empty($siteUrl)) + + 사이트로 이동 + + @endif + +
+ 본 메일은 발신전용입니다. +
+
+
+ +
+ © {{ $year ?? date('Y') }} {{ $brand ?? 'Service' }} +
+
+ + diff --git a/resources/views/admin/members/show.blade.php b/resources/views/admin/members/show.blade.php new file mode 100644 index 0000000..6dd3097 --- /dev/null +++ b/resources/views/admin/members/show.blade.php @@ -0,0 +1,440 @@ +@extends('admin.layouts.app') + +@section('title', '회원 상세') +@section('page_title', '회원 상세') +@section('page_desc', '회원상태/전화번호 변경/메모/변경이력을 관리합니다.') + +@push('head') + +@endpush + +@section('content') + @php + $no = (int)($member->mem_no ?? 0); + + $s3 = (string)($member->stat_3 ?? '1'); + $s3Label = $stat3Map[$s3] ?? ('stat_3='.$s3); + $sPill = 'pill--muted'; + if ($s3==='1') $sPill='pill--ok'; + elseif($s3==='2') $sPill='pill--warn'; + elseif($s3==='3') $sPill='pill--bad'; + + $g = (string)($member->gender ?? 'n'); + $n = (string)($member->native ?? 'n'); + $gender = $genderMap[$g] ?? '-'; + $native = $nativeMap[$n] ?? '-'; + + $birth = (string)($member->birth ?? ''); + $birth = ($birth && $birth !== '0000-00-00') ? $birth : '-'; + + $dtReg = $member->dt_reg ?? '-'; + $dtMod = $member->dt_mod ?? '-'; + $dtLogin = $member->dt_login ?? '-'; + $failCnt = (int)($member->login_fail_cnt ?? 0); + + $rcvE = (string)($member->rcv_email ?? 'n'); + $rcvS = (string)($member->rcv_sms ?? 'n'); + + // stat_3 select: 4~6은 disabled + $editableOptions = ['1','2','3']; + @endphp + +
+
+
+
회원 상세
+
+ #{{ $no }} / {{ $member->name ?? '-' }} + @if(!empty($member->email)) + · {{ $member->email }} + @endif +
+
+ + + ← 목록 + +
+
+ + {{-- KV --}} +
+
+
회원상태
+
● {{ $s3Label }}
+
+ +
+
성명/기본정보
+
+ {{ $member->name ?? '-' }} +
+ 성별: {{ $gender }} · 생년월일: {{ $birth }} · {{ $native }} +
+
+
+ +
+
휴대폰(통신사)
+
+ {{ $corpLabel ?? '-' }} + {{ $phoneDisplay ?? '-' }} +
+
+ +
+
수신동의
+
+ Email : {{ $rcvE }} + SMS : {{ $rcvS }} +
+
+ +
+
로그인 실패횟수
+
{{ $failCnt }}
+
+ +
+
최근로그인 일시
+
{{ $dtLogin }}
+
+ +
+
가입일시
+
{{ $dtReg }}
+
+ +
+
최근정보변경 일시
+
{{ $dtMod }}
+
+ +
+
계좌정보
+
+ @if(($bank['has'] ?? false)) +
{{ $bank['bank_name'] ?? '-' }}
+
{{ $bank['account'] ?? '-' }}
+
예금주: {{ $bank['holder'] ?? '-' }}
+ @else + 등록안됨 + @endif +
+
+
+ +
+ {{-- 수정 폼: stat_3(1~3), 통신사, 전화번호만 --}} +
+ @csrf + +
+
접근상태/전화번호 변경
+ +
+ + +
+ ※ 4~6은 시스템 상태로 변경 불가 +
+
+ +
+ + +
+ +
+ + +
+ +
+
주의사항 : 꼭 아래 내용 회원에게 수신 후 처리해 주시기 바랍니다.
+
+ - 이름: 홍길동 (현재 인증받은 회원성명과 동일해야 함)
+ - 핀포유 가입 이메일: test@test.com
+ - 연락 가능한 전화번호: 010-000-0000

+ + 첨부파일 (아래 이메일 첨부파일 안내를 확인해주세요.)
+ 1. 신분증 사진
+ 2. 신분증을 들고 있는 본인 사진(비대면 실명확인)
+ 3. 통신사 이용계약증명서 사진(화면캡쳐 이미지 사용불가) +
+
+ +
+ ※ 이름/이메일/수신동의/계좌정보는 변경 불가 +
+
+
+ + {{-- 인증/주소 --}} +
+
인증/주소
+ +
+
+ + + + + + + + + + @forelse($authRows as $r) + @php $st = (string)($r['auth_state'] ?? 'N'); @endphp + + + + + + @empty + + @endforelse + +
typestatedate
{{ $r['auth_type'] ?? '-' }} + ● {{ $st }} + {{ $r['auth_date'] ?? '-' }}
인증 내역이 없습니다.
+
+
+ +
+
주소
+
+ @forelse($addresses as $a) +
+
+ #{{ $a['seq'] ?? '-' }} + gubun={{ $a['gubun'] ?? '-' }} + shipping={{ $a['shipping'] ?? '-' }} + {{ $a['date'] ?? '-' }} +
+
+ ({{ $a['zipNo'] ?? '' }}) + {{ $a['roadAddrPart1'] ?? '' }} + {{ $a['jibunAddr'] ?? '' }} + {{ $a['addrDetail'] ?? '' }} +
+
+ @empty +
주소가 없습니다.
+ @endforelse +
+
+
+
+ + {{-- 관리자 메모 --}} +
+
+
+
관리자 메모
+
+ +
+ @csrf + + +
+
+ +
+
+ @forelse($adminMemo as $it) +
+
+ {{ $it['ts'] ?? '-' }} + @php + $aid = (int)($it['actor_admin_id'] ?? 0); + $am = $aid > 0 ? ($adminMap[$aid] ?? null) : null; + + $aEmail = is_array($am) ? trim((string)($am['email'] ?? '')) : ''; + $aName = is_array($am) ? trim((string)($am['name'] ?? '')) : ''; + + // 출력: email / 이름 + $aDisp = trim(($aEmail !== '' ? $aEmail : '-')." / ".($aName !== '' ? $aName : '-')); + @endphp + + admin : {{ $aDisp }} + @if(!empty($it['ip'])) {{ $it['ip'] }} @endif +
+
{{ $it['memo'] ?? '' }}
+
+ @empty +
메모가 없습니다.
+ @endforelse +
+
+
+ + {{-- 관리자 변경이력(modify_log) --}} +
+
+
관리자 변경이력
+
+ 너무 길면 아래 영역이 스크롤됩니다. +
+
+ + + +
+
+ @forelse($modifyLog as $it) + @php + $aid = (int)($it['actor_admin_id'] ?? 0); + $am = $aid > 0 ? ($adminMap[$aid] ?? null) : null; + $aLabel = '-'; + if (is_array($am)) { + $name = trim((string)($am['name'] ?? '')); + $email = trim((string)($am['email'] ?? '')); + $aLabel = $email." / ".$name; + } + @endphp + +
+
+ {{ $it['ts'] ?? '-' }} + + admin : {!! ' '.$aLabel !!} + + @if(!empty($it['action'])) {{ $it['action'] }} @endif + @if(!empty($it['ip'])) {{ $it['ip'] }} @endif +
+ + @if(!empty($it['changes']) && is_array($it['changes'])) +
+ @foreach($it['changes'] as $k=>$chg) + @php + $b = $chg['before'] ?? null; + $a = $chg['after'] ?? null; + + $before = is_scalar($b) ? (string)$b : '[...]'; + $after = is_scalar($a) ? (string)$a : '[...]'; + @endphp + +
+ {{ $k }} + + {{ $before }} + + {{ $after }} + +
+ @endforeach +
+ @else +
변경 상세 없음
+ @endif +
+ @empty +
변경이력이 없습니다.
+ @endforelse +
+
+
+ + + + + {{-- 하단 액션바 --}} +
+ + ← 뒤로가기 + + +
+ +
+
+@endsection diff --git a/routes/admin.php b/routes/admin.php index d1e7a91..42feffd 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -11,6 +11,7 @@ use App\Http\Controllers\Admin\Mail\AdminMailLogController; use App\Http\Controllers\Admin\Mail\AdminMailTemplateController; use App\Http\Controllers\Admin\Notice\AdminNoticeController; use App\Http\Controllers\Admin\Qna\AdminQnaController; +use App\Http\Controllers\Admin\Members\AdminMembersController; use Illuminate\Support\Facades\Route; Route::middleware(['web'])->group(function () { @@ -145,63 +146,61 @@ Route::middleware(['web'])->group(function () { Route::get('/', [AdminNoticeController::class, 'index'])->name('index'); Route::get('/create', [AdminNoticeController::class, 'create'])->name('create'); Route::post('/', [AdminNoticeController::class, 'store'])->name('store'); - - Route::get('/{id}', [AdminNoticeController::class, 'edit']) - ->whereNumber('id') - ->name('edit'); - - Route::put('/{id}', [AdminNoticeController::class, 'update']) - ->whereNumber('id') - ->name('update'); - - Route::delete('/{id}', [AdminNoticeController::class, 'destroy']) - ->whereNumber('id') - ->name('destroy'); - - // 첨부파일 다운로드 (slot: 1|2) + Route::get('/{id}', [AdminNoticeController::class, 'edit'])->whereNumber('id')->name('edit'); + Route::put('/{id}', [AdminNoticeController::class, 'update'])->whereNumber('id')->name('update'); + Route::delete('/{id}', [AdminNoticeController::class, 'destroy'])->whereNumber('id')->name('destroy'); Route::get('/{id}/file/{slot}', [AdminNoticeController::class, 'download']) ->whereNumber('id') ->whereIn('slot', ['1', '2']) ->name('file'); }); - Route::prefix('qna')->name('admin.qna.')->group(function () { - Route::get('/', [AdminQnaController::class, 'index'])->name('index'); - Route::get('/{seq}', [AdminQnaController::class, 'show'])->name('show'); - - // 업무 액션 - Route::post('/{seq}/assign', [AdminQnaController::class, 'assignToMe'])->name('assign'); - Route::post('/{seq}/start', [AdminQnaController::class, 'startWork'])->name('start'); - Route::post('/{seq}/return', [AdminQnaController::class, 'returnWork'])->name('return'); - Route::post('/{seq}/postpone', [AdminQnaController::class, 'postponeWork'])->name('postpone'); - Route::post('/{seq}/answer', [AdminQnaController::class, 'saveAnswer'])->name('answer.save'); - Route::post('/{seq}/complete', [AdminQnaController::class, 'completeWork'])->name('complete'); - - // 내부 메모(선택) - Route::post('/{seq}/memo', [AdminQnaController::class, 'addMemo'])->name('memo.add'); + Route::prefix('qna') + ->name('admin.qna.') + ->middleware('admin.role:super_admin,support') + ->group(function () { + Route::get('/', [AdminQnaController::class, 'index'])->name('index'); + Route::get('/{seq}', [AdminQnaController::class, 'show'])->name('show'); + // 업무 액션 + Route::post('/{seq}/assign', [AdminQnaController::class, 'assignToMe'])->name('assign'); + Route::post('/{seq}/start', [AdminQnaController::class, 'startWork'])->name('start'); + Route::post('/{seq}/return', [AdminQnaController::class, 'returnWork'])->name('return'); + Route::post('/{seq}/postpone', [AdminQnaController::class, 'postponeWork'])->name('postpone'); + Route::post('/{seq}/answer', [AdminQnaController::class, 'saveAnswer'])->name('answer.save'); + Route::post('/{seq}/complete', [AdminQnaController::class, 'completeWork'])->name('complete'); + // 내부 메모(선택) + Route::post('/{seq}/memo', [AdminQnaController::class, 'addMemo'])->name('memo.add'); }); - /** - * 아래는 메뉴는 있지만 실제 라우트/컨트롤러가 아직 없으니, - * 구현 시점에만 같은 패턴으로 그룹에 admin.role 을 붙이면 됨. - * - * 예) - * - support 전용: - * Route::prefix('/inquiry')->name('admin.inquiry.') - * ->middleware('admin.role:support') - * ->group(...) - * - * - finance 전용: - * Route::prefix('/settlement')->name('admin.settlement.') - * ->middleware('admin.role:finance') - * ->group(...) - * - * - product 전용: - * Route::prefix('/products')->name('admin.products.') - * ->middleware('admin.role:product') - * ->group(...) - */ + Route::prefix('members') + ->name('admin.members.') + ->middleware('admin.role:super_admin,support') + ->group(function () { + + Route::get('/', [AdminMembersController::class, 'index']) + ->name('index'); + + Route::get('/{memNo}', [AdminMembersController::class, 'show']) + ->whereNumber('memNo') + ->name('show'); + + Route::post('/{memNo}', [AdminMembersController::class, 'update']) + ->whereNumber('memNo') + ->name('update'); + + Route::post('/{memNo}/memo', [AdminMembersController::class, 'addMemo']) + ->whereNumber('memNo') + ->name('memo.add'); + + Route::post('/{memNo}/reset-password', [AdminMembersController::class, 'resetPassword']) + ->whereNumber('memNo') + ->name('password.reset'); + + Route::post('/{memNo}/force-out', [AdminMembersController::class, 'forceOut']) + ->whereNumber('memNo') + ->name('force_out'); + }); }); });