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 @@
-
| 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 }} + | + + {{-- 인증: Y만 표시 --}} +
+
+ @if($ok['email'])
+ 이메일
+ @endif
+ @if($ok['cell'])
+ 휴대폰
+ @endif
+ @if($ok['account'])
+ 계좌
+ @endif
+
+ @if(!$ok['email'] && !$ok['cell'] && !$ok['account'])
+ -
+ @endif
+
+ |
+
+ {{-- 가입일 --}}
+ {{ $joinAt }} | + ++ + 보기 + + | +
| 데이터가 없습니다. | +|||||||
| type | +state | +date | +
|---|---|---|
| {{ $r['auth_type'] ?? '-' }} | ++ ● {{ $st }} + | +{{ $r['auth_date'] ?? '-' }} | +
| 인증 내역이 없습니다. | ||