관리자 회원/정책, 회원관리

This commit is contained in:
sungro815 2026-02-11 10:43:37 +09:00
parent 754d6e2497
commit 4fb4f2ae32
26 changed files with 1675 additions and 759 deletions

View File

@ -1,6 +1,5 @@
<?php <?php
namespace App\Http\Controllers\Admin; namespace App\Http\Controllers\Admin;
namespace App\Http\Controllers\Admin;
use App\Services\Admin\AdminAdminsService; use App\Services\Admin\AdminAdminsService;
use Illuminate\Http\Request; use Illuminate\Http\Request;

View File

@ -0,0 +1,152 @@
<?php
namespace App\Http\Controllers\Admin\Members;
use App\Services\Admin\Member\AdminMemberService;
use Illuminate\Http\Request;
final class AdminMembersController
{
public function __construct(
private readonly AdminMemberService $service,
) {}
public function index(Request $request)
{
$data = $this->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'] ?? '강제탈퇴 및 비식별 처리가 완료되었습니다.',
]);
}
}

View File

@ -1,22 +0,0 @@
<?php
namespace App\Models\Member;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MemAddress extends Model
{ protected $table = 'mem_address';
protected $primaryKey = 'seq';
public $incrementing = true;
protected $keyType = 'int';
public $timestamps = false;
protected $guarded = [];
public function member(): BelongsTo
{
return $this->belongsTo(MemInfo::class, 'mem_no', 'mem_no');
}
}

View File

@ -8,8 +8,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MemAuth extends Model class MemAuth extends Model
{ {
protected $table = 'mem_auth'; protected $table = 'mem_auth';
// 복합키라 Eloquent 기본 save/update 패턴이 불편함 // 복합키라 Eloquent 기본 save/update 패턴이 불편함

View File

@ -8,8 +8,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MemAuthInfo extends Model class MemAuthInfo extends Model
{ {
protected $table = 'mem_auth_info'; protected $table = 'mem_auth_info';
protected $primaryKey = 'mem_no'; protected $primaryKey = 'mem_no';
public $incrementing = false; public $incrementing = false;

View File

@ -8,8 +8,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MemAuthLog extends Model class MemAuthLog extends Model
{ {
protected $table = 'mem_auth_log'; protected $table = 'mem_auth_log';
protected $primaryKey = 'seq'; protected $primaryKey = 'seq';
public $incrementing = true; public $incrementing = true;

View File

@ -2,10 +2,7 @@
namespace App\Models\Member; namespace App\Models\Member;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
class MemInfo extends Model class MemInfo extends Model
@ -16,10 +13,6 @@ class MemInfo extends Model
public $incrementing = true; public $incrementing = true;
public $timestamps = false; public $timestamps = false;
/**
* 보안: guarded=[](전부 허용) 위험하니,
* 기존 App\Models\MemInfo allowlist(fillable) 방식 유지.
*/
protected $fillable = [ protected $fillable = [
'stat_1','stat_2','stat_3','stat_4','stat_5', 'stat_1','stat_2','stat_3','stat_4','stat_5',
'name','name_first','name_mid','name_last', 'name','name_first','name_mid','name_last',
@ -36,121 +29,6 @@ class MemInfo extends Model
'admin_memo','modify_log', '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 private function safeCarbon(?string $value): ?Carbon
{ {
if (!$value) return null; if (!$value) return null;
@ -162,19 +40,4 @@ class MemInfo extends Model
return null; 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);
}
} }

View File

@ -7,8 +7,6 @@ use Illuminate\Database\Eloquent\Model;
class MemJoinFilter extends Model class MemJoinFilter extends Model
{ {
protected $table = 'mem_join_filter'; protected $table = 'mem_join_filter';
protected $primaryKey = 'seq'; protected $primaryKey = 'seq';
public $incrementing = true; public $incrementing = true;

View File

@ -1,25 +0,0 @@
<?php
namespace App\Models\Member;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MemJoinLog extends Model
{
protected $table = 'mem_join_log';
protected $primaryKey = 'seq';
public $incrementing = true;
protected $keyType = 'int';
public $timestamps = false;
protected $guarded = [];
public function member(): BelongsTo
{
return $this->belongsTo(MemInfo::class, 'mem_no', 'mem_no');
}
}

View File

@ -1,25 +0,0 @@
<?php
namespace App\Models\Member;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class MemLoginRecent extends Model
{
protected $table = 'mem_login_recent';
protected $primaryKey = 'seq';
public $incrementing = true;
protected $keyType = 'int';
public $timestamps = false;
protected $guarded = [];
public function member(): BelongsTo
{
return $this->belongsTo(MemInfo::class, 'mem_no', 'mem_no');
}
}

View File

@ -1,28 +0,0 @@
<?php
namespace App\Models\Member;
use Illuminate\Database\Eloquent\Model;
/**
* 연도별 테이블(mem_login_2026 ) 런타임에 붙이는 모델.
* 쓰기 : (new MemLoginYear())->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;
}
}

View File

@ -1,17 +0,0 @@
<?php
namespace App\Models\Member;
use Illuminate\Database\Eloquent\Model;
class MemModLog extends Model
{
protected $table = 'mem_mod_log';
protected $primaryKey = 'seq';
public $incrementing = true;
protected $keyType = 'int';
public $timestamps = false;
protected $guarded = [];
}

View File

@ -1,23 +0,0 @@
<?php
namespace App\Models\Member;
use Illuminate\Database\Eloquent\Model;
class MemPasswdModify extends Model
{
protected $table = 'mem_passwd_modify';
protected $primaryKey = 'seq';
public $incrementing = true;
protected $keyType = 'int';
public $timestamps = false;
protected $guarded = [];
protected $casts = [
'info' => 'array',
];
}

View File

@ -523,4 +523,39 @@ final class AdminUserRepository
'updated_at' => now(), 'updated_at' => now(),
]) > 0; ]) > 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;
}
} }

View File

@ -0,0 +1,182 @@
<?php
namespace App\Repositories\Admin\Member;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
final class AdminMemberRepository
{
public function paginateMembers(array $filters, int $perPage = 30): LengthAwarePaginator
{
$q = DB::table('mem_info as m');
$qf = (string)($filters['qf'] ?? ''); // mem_no|name|email|phone (또는 ''/all)
$keyword = trim((string)($filters['q'] ?? ''));
$phoneEnc = (string)($filters['phone_enc'] ?? '');
if ($keyword !== '') {
$digits = preg_replace('/\D+/', '', $keyword) ?? '';
switch ($qf) {
case 'mem_no':
if ($digits !== '' && ctype_digit($digits)) {
$q->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' => '',
]);
}
}

View File

@ -7,7 +7,6 @@ use App\Models\Member\MemAuthInfo;
use App\Models\Member\MemAuthLog; use App\Models\Member\MemAuthLog;
use App\Models\Member\MemInfo; use App\Models\Member\MemInfo;
use App\Models\Member\MemJoinFilter; use App\Models\Member\MemJoinFilter;
use App\Models\Member\MemJoinLog;
use App\Support\Legacy\Carrier; use App\Support\Legacy\Carrier;
use App\Services\SmsService; use App\Services\SmsService;
use App\Services\MemInfoService; use App\Services\MemInfoService;

View File

@ -1,99 +0,0 @@
<?php
namespace App\Services\Admin;
use Illuminate\Support\Facades\Redis;
use Illuminate\Support\Str;
class AdminOtpService
{
public function startChallenge(int $adminUserId, string $phoneE164, string $ip, string $ua): array
{
$challengeId = Str::random(40);
$otp = str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT);
$prefix = (string) config('admin.redis_prefix', 'admin:2fa:');
$key = $prefix . 'challenge:' . $challengeId;
$otpHashKey = (string) config('admin.otp_hash_key', '');
$otpHash = hash_hmac('sha256', $otp, $otpHashKey);
$ttl = (int) config('admin.otp_ttl', 300);
$cooldown = (int) config('admin.otp_resend_cooldown', 30);
Redis::hmset($key, [
'admin_user_id' => (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];
}
}

View File

@ -1,295 +0,0 @@
<?php
namespace App\Services\Admin;
use App\Repositories\Admin\AdminUserRepository;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Schema;
final class AdminUserManageService
{
public function __construct(
private readonly AdminUserRepository $repo,
private readonly AdminAuditService $audit,
) {}
public function getAllRoles(): array
{
return $this->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 '-';
}
}

View File

@ -56,7 +56,6 @@ final class AdminMailService
'one'=>'단건', 'one'=>'단건',
'many'=>'여러건', 'many'=>'여러건',
'csv'=>'CSV 업로드', 'csv'=>'CSV 업로드',
'db'=>'DB 검색',
]; ];
} }
@ -295,17 +294,6 @@ final class AdminMailService
if (!$file) return ['ok'=>false,'message'=>'CSV 파일을 업로드하세요.']; if (!$file) return ['ok'=>false,'message'=>'CSV 파일을 업로드하세요.'];
$raw = array_merge($raw, $this->parseEmailsCsv($file->getRealPath())); $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); $total = count($raw);
$seen = []; $seen = [];

View File

@ -0,0 +1,428 @@
<?php
namespace App\Services\Admin\Member;
use App\Repositories\Admin\Member\AdminMemberRepository;
use App\Repositories\Admin\AdminUserRepository;
use App\Repositories\Member\MemberAuthRepository;
use App\Services\MailService;
use App\Support\LegacyCrypto\CiSeedCrypto;
use Illuminate\Support\Facades\DB;
final class AdminMemberService
{
private const AUTH_TYPES_SHOW = ['email','cell','account','vow'];
public function __construct(
private readonly AdminMemberRepository $repo,
private readonly AdminUserRepository $adminRepo,
private readonly MemberAuthRepository $members,
private readonly MailService $mail,
) {}
public function list(array $filters): array
{
// q가 휴대폰이면 동등검색(암호문 비교)
$keyword = trim((string)($filters['q'] ?? ''));
if ($keyword !== '') {
$digits = preg_replace('/\D+/', '', $keyword) ?? '';
if (preg_match('/^\d{10,11}$/', $digits)) {
$phone = $this->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]; }
}

View File

@ -306,7 +306,7 @@ class MemInfoService
// stat_3 차단 로직 (CI3 id_exists 반영) // stat_3 차단 로직 (CI3 id_exists 반영)
if ((string)$mem->stat_3 === '3') { if ((string)$mem->stat_3 === '3') {
return ['ok'=>false, 'message'=>"접근금지 계정입니다.<br><br>고객센터 1833-4856로 문의 하세요"]; return ['ok'=>false, 'message'=>"접근금지 계정입니다.\n\n고객센터 1833-4856 로 문의 하세요"];
} }
if ((string)$mem->stat_3 === '4') { if ((string)$mem->stat_3 === '4') {
return ['ok'=>false, 'message'=>'아이디 혹은 비밀번호가 일치하지 않습니다.2']; return ['ok'=>false, 'message'=>'아이디 혹은 비밀번호가 일치하지 않습니다.2'];

View File

@ -149,7 +149,6 @@
<button type="button" class="mbtn is-active" data-tab="one">단건</button> <button type="button" class="mbtn is-active" data-tab="one">단건</button>
<button type="button" class="mbtn" data-tab="many">여러건</button> <button type="button" class="mbtn" data-tab="many">여러건</button>
<button type="button" class="mbtn" data-tab="template">템플릿(CSV)</button> <button type="button" class="mbtn" data-tab="template">템플릿(CSV)</button>
<button type="button" class="mbtn" data-tab="db">DB 검색</button>
</div> </div>
<section data-panel="one"> <section data-panel="one">
@ -236,22 +235,6 @@
</div> </div>
</div> </div>
</section> </section>
<section data-panel="db" style="display:none;">
<div class="a-muted" style="margin-bottom:6px;">회원 DB 검색 발송</div>
<div class="a-muted mhelp" style="margin-bottom:10px;">
* 서버에서 조건에 맞는 회원을 찾아 임시 리스트를 만든 , 큐로 천천히 발송합니다.
</div>
<div class="mrow">
<input class="a-input" name="db_q" id="memberQ" placeholder="이메일/성명/회원번호 등" style="width:320px;">
<input class="a-input" name="db_limit" id="memberLimit" placeholder="최대 발송수(예: 1000)" style="width:160px;">
</div>
<div class="a-muted mhelp" style="margin-top:8px;">
) <span class="mmono">gmail.com</span> / <span class="mmono">홍길동</span> / <span class="mmono">mem_no:123</span>
</div>
</section>
</div> </div>
{{-- RIGHT: message/template/preview --}} {{-- RIGHT: message/template/preview --}}

View File

@ -0,0 +1,344 @@
@extends('admin.layouts.app')
@section('title', '회원 관리')
@section('page_title', '회원 관리')
@section('page_desc', '회원상태/기본정보/인증을 조회합니다.')
@section('content_class', 'a-content--full')
@push('head')
<style>
/* members index only */
.bar{display:flex;justify-content:space-between;align-items:flex-end;gap:12px;flex-wrap:wrap;}
.bar__left .t{font-weight:900;font-size:16px;}
.bar__left .d{font-size:12px;margin-top:4px;}
.bar__right{display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;}
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;}
.filters .q{width:260px;}
.filters .qf{width:140px;}
.filters .st{width:220px;}
.filters .dt{width:150px;}
.lbtn{padding:8px 12px;font-size:13px;border-radius:12px;line-height:1.1;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:6px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);color:inherit;cursor:pointer;}
.lbtn:hover{background:rgba(255,255,255,.10);text-decoration:none;}
.lbtn--ghost{background:transparent;}
.lbtn--sm{padding:7px 10px;font-size:12px;border-radius:11px;}
.pill{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;font-size:12px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);}
.pill--ok{border-color:rgba(34,197,94,.35);background:rgba(34,197,94,.12);}
.pill--bad{border-color:rgba(244,63,94,.35);background:rgba(244,63,94,.10);}
.pill--warn{border-color:rgba(245,158,11,.35);background:rgba(245,158,11,.12);}
.pill--muted{opacity:.9;}
.mono{padding:4px 8px;border-radius:10px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10);
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;}
.table td{vertical-align:top;}
.badges{display:flex;gap:6px;flex-wrap:wrap;}
.nameLine{font-weight:900;}
.ageBox{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
.ageChip{padding:4px 8px;border-radius:999px;border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);font-size:12px;}
</style>
@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
<div class="a-card" style="padding:16px; margin-bottom:16px;">
<div class="bar">
<div class="bar__left">
<div class="t">회원 관리</div>
<div class="a-muted d">회원상태/기본정보/인증을 조회합니다.</div>
</div>
<div class="bar__right">
<form method="GET" action="{{ route('admin.members.index') }}" class="filters">
<div>
<select class="a-input qf" name="qf" id="qf">
<option value="mem_no" {{ $qf==='mem_no'?'selected':'' }}>회원번호</option>
<option value="name" {{ $qf==='name'?'selected':'' }}>이름</option>
<option value="email" {{ $qf==='email'?'selected':'' }}>이메일</option>
<option value="phone" {{ $qf==='phone'?'selected':'' }}>휴대폰</option>
</select>
</div>
<div>
<input class="a-input q"
id="q"
name="q"
value="{{ $filters['q'] ?? '' }}"
placeholder="회원번호/이름/이메일/휴대폰">
</div>
<div>
<select class="a-input st" name="stat_3">
<option value="">회원상태 전체</option>
@foreach($stat3Map as $k=>$label)
<option value="{{ $k }}" {{ (($filters['stat_3'] ?? '')===(string)$k)?'selected':'' }}>
{{ $label }}
</option>
@endforeach
</select>
</div>
<div><input class="a-input dt" type="date" name="date_from" value="{{ $filters['date_from'] ?? '' }}"></div>
<div><input class="a-input dt" type="date" name="date_to" value="{{ $filters['date_to'] ?? '' }}"></div>
<div style="display:flex; gap:8px; align-items:flex-end;">
<button class="lbtn lbtn--ghost" type="submit">검색</button>
<a class="lbtn lbtn--ghost" href="{{ route('admin.members.index') }}">초기화</a>
</div>
</form>
</div>
</div>
</div>
<div class="a-card" style="padding:16px;">
<div class="a-muted" style="margin-bottom:10px;"> <b>{{ $page->total() }}</b></div>
<div style="overflow:auto;">
<table class="a-table table" style="width:100%; min-width:1100px;">
<thead>
<tr>
<th style="width:90px;">MEM_NO</th>
<th style="width:220px;">성명</th>
<th style="width:180px;">성별/나이</th>
<th>이메일</th>
<th style="width:200px;">회원상태</th>
<th style="width:240px;">인증(Y만)</th>
<th style="width:180px;">가입일</th>
<th style="width:90px; text-align:right;">관리</th>
</tr>
</thead>
<tbody>
@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
<tr>
<td class="a-muted">{{ $no }}</td>
{{-- 성명 --}}
<td><div class="nameLine">{{ $m->name ?? '-' }}</div></td>
{{-- 성별/나이(세는나이 + 만나이) --}}
<td>
<div class="ageBox">
<span class="mono">{{ $gender }}</span>
@if($ageK !== null || $ageM !== null)
<span class="ageChip">
{{ $ageK !== null ? "{$ageK}" : '-' }}
<span class="a-muted" style="margin-left:6px;">( {{ $ageM !== null ? "{$ageM}" : '-' }})</span>
</span>
@else
<span class="a-muted">-</span>
@endif
</div>
</td>
{{-- 이메일 --}}
<td>
@if(!empty($m->email))
<span class="mono">{{ $m->email }}</span>
@else
<span class="a-muted">-</span>
@endif
</td>
{{-- 회원상태(색상 분기) --}}
<td>
<span class="pill {{ $s3Pill }}"> {{ $s3Label }}</span>
</td>
{{-- 인증: Y만 표시 --}}
<td>
<div class="badges">
@if($ok['email'])
<span class="a-chip">이메일</span>
@endif
@if($ok['cell'])
<span class="a-chip">휴대폰</span>
@endif
@if($ok['account'])
<span class="a-chip">계좌</span>
@endif
@if(!$ok['email'] && !$ok['cell'] && !$ok['account'])
<span class="a-muted">-</span>
@endif
</div>
</td>
{{-- 가입일 --}}
<td class="a-muted">{{ $joinAt }}</td>
<td style="text-align:right;">
<a class="lbtn lbtn--ghost lbtn--sm"
href="{{ route('admin.members.show', ['memNo'=>$no]) }}">
보기
</a>
</td>
</tr>
@empty
<tr>
<td colspan="8" class="a-muted" style="padding:18px;">데이터가 없습니다.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
<div style="margin-top:14px;">
{{ $page->links() }}
</div>
</div>
<script>
(function(){
const qf = document.getElementById('qf');
const q = document.getElementById('q');
if (!qf || !q) return;
const ph = {
all: '회원번호/이름/이메일/휴대폰',
mem_no: '회원번호',
name: '이름',
email: '이메일',
phone: '휴대폰(숫자만)',
};
const apply = () => {
const v = qf.value || 'all';
q.placeholder = ph[v] || ph.all;
};
qf.addEventListener('change', apply);
apply();
})();
</script>
@endsection

View File

@ -0,0 +1,46 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{ $subject ?? '' }}</title>
</head>
<body style="margin:0;padding:0;background:#f6f7fb;">
<div style="max-width:640px;margin:0 auto;padding:24px;">
<div style="background:#ffffff;border-radius:14px;padding:22px;border:1px solid #e9ebf3;">
<div style="font-weight:900;font-size:16px;margin-bottom:8px;">{{ $brand ?? 'Service' }}</div>
<div style="color:#666;font-size:13px;margin-bottom:16px;">{{ $subject ?? '' }}</div>
<div style="font-size:14px;line-height:1.6;color:#222;">
@if(!empty($name))
<div style="margin-bottom:10px;"><b>{{ $name }}</b> ,</div>
@endif
<div style="margin-bottom:14px;">비밀번호 초기화 요청이 접수되어 임시 비밀번호가 발급되었습니다.</div>
<div style="background:#f3f5ff;border:1px solid #d9ddff;border-radius:12px;padding:14px;margin-bottom:14px;">
<div style="font-size:12px;color:#556;margin-bottom:6px;">임시 비밀번호</div>
<div style="font-weight:900;font-size:18px;letter-spacing:1px;">{{ $temp_password ?? '' }}</div>
</div>
<div style="margin-bottom:14px;">로그인 즉시 비밀번호를 변경해 주세요.</div>
@if(!empty($siteUrl))
<a href="{{ $siteUrl }}"
style="display:inline-block;background:#3b82f6;color:#fff;text-decoration:none;padding:10px 14px;border-radius:12px;font-weight:800;">
사이트로 이동
</a>
@endif
<div style="margin-top:18px;color:#777;font-size:12px;">
메일은 발신전용입니다.
</div>
</div>
</div>
<div style="text-align:center;color:#999;font-size:12px;margin-top:14px;">
© {{ $year ?? date('Y') }} {{ $brand ?? 'Service' }}
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,440 @@
@extends('admin.layouts.app')
@section('title', '회원 상세')
@section('page_title', '회원 상세')
@section('page_desc', '회원상태/전화번호 변경/메모/변경이력을 관리합니다.')
@push('head')
<style>
/* members show only */
.lbtn{padding:8px 12px;font-size:13px;border-radius:12px;line-height:1.1;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:6px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);color:inherit;cursor:pointer;}
.lbtn:hover{background:rgba(255,255,255,.10);text-decoration:none;}
.lbtn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;}
.lbtn--primary:hover{background:rgba(59,130,246,.98);}
.lbtn--danger{background:rgba(244,63,94,.88);border-color:rgba(244,63,94,.95);color:#fff;}
.lbtn--danger:hover{background:rgba(244,63,94,.98);}
.lbtn--ghost{background:transparent;}
.lbtn--sm{padding:7px 10px;font-size:12px;border-radius:11px;}
.lbtn--wide{padding:10px 14px;font-weight:800;}
.pill{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;font-size:12px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);}
.pill--ok{border-color:rgba(34,197,94,.35);background:rgba(34,197,94,.12);}
.pill--bad{border-color:rgba(244,63,94,.35);background:rgba(244,63,94,.10);}
.pill--warn{border-color:rgba(245,158,11,.35);background:rgba(245,158,11,.12);}
.pill--muted{opacity:.9;}
.mono{padding:4px 8px;border-radius:10px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10);
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;}
.kvgrid{display:grid;grid-template-columns:1fr;gap:12px;}
@media (min-width: 980px){ .kvgrid{grid-template-columns:1fr 1fr 1fr;} }
.kv{padding:14px;border-radius:16px;border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.04);}
.kv .k{font-size:12px;opacity:.8;margin-bottom:6px;}
.kv .v{font-weight:900;}
.actions{position:sticky;bottom:10px;z-index:5;margin-top:12px;
display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;align-items:center;
padding:12px;border-radius:16px;border:1px solid rgba(255,255,255,.10);background:rgba(0,0,0,.25);backdrop-filter:blur(10px);}
.actions__right{display:flex;gap:8px;flex-wrap:wrap;align-items:center;}
.memo{border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.04);border-radius:16px;padding:12px;}
.memo__item{padding:10px;border-radius:12px;border:1px solid rgba(255,255,255,.08);background:rgba(255,255,255,.03);}
.memo__meta{font-size:12px;opacity:.8;margin-bottom:6px;display:flex;gap:10px;flex-wrap:wrap;}
.grid2{display:grid;grid-template-columns:1fr;gap:12px;}
@media (min-width: 980px){ .grid2{grid-template-columns:1fr 1fr;} }
.warnbox{border:1px solid rgba(245,158,11,.35);background:rgba(245,158,11,.10);border-radius:16px;padding:12px;}
.warnbox b{font-weight:900;}
</style>
@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
<div class="a-card" style="padding:16px; margin-bottom:16px;">
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:12px; flex-wrap:wrap;">
<div>
<div style="font-weight:900; font-size:16px;">회원 상세</div>
<div class="a-muted" style="font-size:12px; margin-top:4px;">
#{{ $no }} / <b>{{ $member->name ?? '-' }}</b>
@if(!empty($member->email))
· <span class="mono">{{ $member->email }}</span>
@endif
</div>
</div>
<a class="lbtn lbtn--ghost lbtn--sm"
href="{{ route('admin.members.index', request()->only(['q','stat_3','date_from','date_to','page'])) }}">
목록
</a>
</div>
</div>
{{-- KV --}}
<div class="kvgrid" style="margin-bottom:16px;">
<div class="kv">
<div class="k">회원상태</div>
<div class="v"><span class="pill {{ $sPill }}"> {{ $s3Label }}</span></div>
</div>
<div class="kv">
<div class="k">성명/기본정보</div>
<div class="v">
{{ $member->name ?? '-' }}
<div class="a-muted" style="font-size:12px;margin-top:6px;">
성별: {{ $gender }} · 생년월일: {{ $birth }} · {{ $native }}
</div>
</div>
</div>
<div class="kv">
<div class="k">휴대폰(통신사)</div>
<div class="v">
<span class="mono">{{ $corpLabel ?? '-' }}</span>
<span class="mono">{{ $phoneDisplay ?? '-' }}</span>
</div>
</div>
<div class="kv">
<div class="k">수신동의</div>
<div class="v">
<span class="mono">Email : {{ $rcvE }}</span>
<span class="mono">SMS : {{ $rcvS }}</span>
</div>
</div>
<div class="kv">
<div class="k">로그인 실패횟수</div>
<div class="v">{{ $failCnt }}</div>
</div>
<div class="kv">
<div class="k">최근로그인 일시</div>
<div class="v">{{ $dtLogin }}</div>
</div>
<div class="kv">
<div class="k">가입일시</div>
<div class="v">{{ $dtReg }}</div>
</div>
<div class="kv">
<div class="k">최근정보변경 일시</div>
<div class="v">{{ $dtMod }}</div>
</div>
<div class="kv">
<div class="k">계좌정보</div>
<div class="v">
@if(($bank['has'] ?? false))
<div><span class="mono">{{ $bank['bank_name'] ?? '-' }}</span></div>
<div style="margin-top:6px;"><span class="mono">{{ $bank['account'] ?? '-' }}</span></div>
<div class="a-muted" style="font-size:12px;margin-top:6px;">예금주: <b>{{ $bank['holder'] ?? '-' }}</b></div>
@else
<span class="a-muted">등록안됨</span>
@endif
</div>
</div>
</div>
<div class="grid2" style="margin-bottom:16px;">
{{-- 수정 : stat_3(1~3), 통신사, 전화번호만 --}}
<form id="memberEditForm"
method="POST"
action="{{ route('admin.members.update', ['memNo'=>$no]) }}"
onsubmit="this.querySelector('button[type=submit][data-submit=save]')?.setAttribute('disabled','disabled');">
@csrf
<div class="a-card" style="padding:16px;">
<div style="font-weight:900; margin-bottom:10px;">접근상태/전화번호 변경</div>
<div class="a-field" style="margin-bottom:12px;">
<label class="a-label">회원상태</label>
<select class="a-input" name="stat_3">
@foreach($stat3Map as $k=>$label)
@php
$disabled = in_array((string)$k, ['4','5','6'], true) ? 'disabled' : '';
@endphp
<option value="{{ $k }}" {{ (string)old('stat_3', $s3)===(string)$k ? 'selected' : '' }} {{ $disabled }}>
{{ $label }}
</option>
@endforeach
</select>
<div class="a-muted" style="font-size:12px;margin-top:6px;">
4~6 시스템 상태로 변경 불가
</div>
</div>
<div class="a-field" style="margin-bottom:12px;">
<label class="a-label">통신사</label>
<select class="a-input" name="cell_corp">
@php $corpSel = (string)old('cell_corp', $member->cell_corp ?? 'n'); @endphp
@foreach($corpMap as $k=>$label)
<option value="{{ $k }}" {{ $corpSel===(string)$k ? 'selected' : '' }}>
{{ $label }}
</option>
@endforeach
</select>
</div>
<div class="a-field">
<label class="a-label">전화번호(숫자만)</label>
<input class="a-input" name="cell_phone"
value="{{ old('cell_phone', $plainPhone ?? '') }}"
placeholder="01000000000">
</div>
<div class="warnbox" style="margin-top:12px;">
<div style="font-weight:900;margin-bottom:6px;">주의사항 : 아래 내용 회원에게 수신 처리해 주시기 바랍니다.</div>
<div style="font-size:13px;line-height:1.7;">
- 이름: <b>홍길동</b> (현재 인증받은 회원성명과 동일해야 )<br>
- 핀포유 가입 이메일: <b>test@test.com</b><br>
- 연락 가능한 전화번호: <b>010-000-0000</b><br><br>
<b>첨부파일</b> (아래 이메일 첨부파일 안내를 확인해주세요.)<br>
1. 신분증 사진<br>
2. 신분증을 들고 있는 본인 사진(비대면 실명확인)<br>
3. 통신사 이용계약증명서 사진(화면캡쳐 이미지 사용불가)
</div>
</div>
<div class="a-muted" style="font-size:12px;margin-top:10px;">
이름/이메일/수신동의/계좌정보는 변경 불가
</div>
</div>
</form>
{{-- 인증/주소 --}}
<div class="a-card" style="padding:16px;">
<div style="font-weight:900; margin-bottom:10px;">인증/주소</div>
<div style="margin-bottom:14px;">
<div style="overflow:auto;">
<table class="a-table" style="width:100%; min-width:520px;">
<thead>
<tr>
<th style="width:120px;">type</th>
<th style="width:90px;">state</th>
<th>date</th>
</tr>
</thead>
<tbody>
@forelse($authRows as $r)
@php $st = (string)($r['auth_state'] ?? 'N'); @endphp
<tr>
<td><span class="mono">{{ $r['auth_type'] ?? '-' }}</span></td>
<td>
<span class="pill {{ $st==='Y' ? 'pill--ok' : 'pill--muted' }}"> {{ $st }}</span>
</td>
<td class="a-muted">{{ $r['auth_date'] ?? '-' }}</td>
</tr>
@empty
<tr><td colspan="3" class="a-muted" style="padding:12px;">인증 내역이 없습니다.</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
<div>
<div class="a-muted" style="font-size:12px;margin-bottom:8px;">주소</div>
<div style="display:grid; gap:10px;">
@forelse($addresses as $a)
<div class="memo__item">
<div class="memo__meta">
<span class="mono">#{{ $a['seq'] ?? '-' }}</span>
<span class="mono">gubun={{ $a['gubun'] ?? '-' }}</span>
<span class="mono">shipping={{ $a['shipping'] ?? '-' }}</span>
<span class="a-muted">{{ $a['date'] ?? '-' }}</span>
</div>
<div style="font-size:13px;">
({{ $a['zipNo'] ?? '' }})
{{ $a['roadAddrPart1'] ?? '' }}
{{ $a['jibunAddr'] ?? '' }}
{{ $a['addrDetail'] ?? '' }}
</div>
</div>
@empty
<div class="a-muted">주소가 없습니다.</div>
@endforelse
</div>
</div>
</div>
</div>
{{-- 관리자 메모 --}}
<div class="a-card" style="padding:16px; margin-bottom:16px;">
<div style="display:flex;justify-content:space-between;align-items:flex-end;gap:12px;flex-wrap:wrap;">
<div>
<div style="font-weight:900;">관리자 메모</div>
</div>
<form method="POST" action="{{ route('admin.members.memo.add', ['memNo'=>$no]) }}"
style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;">
@csrf
<input class="a-input" name="memo" placeholder="메모 입력 (최대 1000자)" style="width:360px;max-width:70vw;">
<button class="lbtn lbtn--primary" type="submit">추가</button>
</form>
</div>
<div class="memo" style="margin-top:12px;">
<div style="display:grid; gap:10px;">
@forelse($adminMemo as $it)
<div class="memo__item">
<div class="memo__meta">
<span class="mono">{{ $it['ts'] ?? '-' }}</span>
@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
<span class="mono">admin : {{ $aDisp }}</span>
@if(!empty($it['ip'])) <span class="mono">{{ $it['ip'] }}</span> @endif
</div>
<div style="white-space:pre-wrap; font-size:13px;">{{ $it['memo'] ?? '' }}</div>
</div>
@empty
<div class="a-muted">메모가 없습니다.</div>
@endforelse
</div>
</div>
</div>
{{-- 관리자 변경이력(modify_log) --}}
<div class="a-card" style="padding:16px; margin-bottom:16px;">
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;">
<div style="font-weight:900;">관리자 변경이력</div>
<div class="a-muted" style="font-size:12px;">
너무 길면 아래 영역이 스크롤됩니다.
</div>
</div>
<style>
.mlog{margin-top:12px; max-height:320px; overflow:auto; padding-right:6px;}
.mlog__item{padding:10px 12px; border:1px solid rgba(255,255,255,.08); background:rgba(255,255,255,.03); border-radius:14px;}
.mlog__item + .mlog__item{margin-top:10px;}
.mlog__meta{display:flex; gap:8px; flex-wrap:wrap; align-items:center;}
.mlog__meta .mono{font-size:12px;}
/* changes: 한 줄씩 */
.mlog__changes{margin-top:8px; display:grid; gap:6px;}
.chgline{display:flex; gap:8px; align-items:baseline; flex-wrap:wrap;}
.chgline__k{font-size:12px; opacity:.85; font-weight:800;}
.chgline__v{font-size:12px; opacity:.95;}
.arrow{opacity:.7; padding:0 4px;}
</style>
<div class="mlog">
<div style="display:grid; gap:10px;">
@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
<div class="mlog__item">
<div class="mlog__meta">
<span class="mono">{{ $it['ts'] ?? '-' }}</span>
<span class="mono"> admin : {!! ' '.$aLabel !!}</span>
@if(!empty($it['action'])) <span class="mono">{{ $it['action'] }}</span> @endif
@if(!empty($it['ip'])) <span class="mono">{{ $it['ip'] }}</span> @endif
</div>
@if(!empty($it['changes']) && is_array($it['changes']))
<div class="mlog__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
<div class="chgline">
<span class="chgline__k">{{ $k }}</span>
<span class="chgline__v">
{{ $before }}
<span class="arrow"></span>
{{ $after }}
</span>
</div>
@endforeach
</div>
@else
<div class="a-muted" style="font-size:12px; margin-top:6px;">변경 상세 없음</div>
@endif
</div>
@empty
<div class="a-muted">변경이력이 없습니다.</div>
@endforelse
</div>
</div>
</div>
{{-- 하단 액션바 --}}
<div class="actions">
<a class="lbtn lbtn--ghost"
href="{{ route('admin.members.index', request()->only(['q','stat_3','date_from','date_to','page'])) }}">
뒤로가기
</a>
<div class="actions__right">
<button class="lbtn lbtn--primary lbtn--wide"
form="memberEditForm"
type="submit"
data-submit="save">
저장
</button>
</div>
</div>
@endsection

View File

@ -11,6 +11,7 @@ use App\Http\Controllers\Admin\Mail\AdminMailLogController;
use App\Http\Controllers\Admin\Mail\AdminMailTemplateController; use App\Http\Controllers\Admin\Mail\AdminMailTemplateController;
use App\Http\Controllers\Admin\Notice\AdminNoticeController; use App\Http\Controllers\Admin\Notice\AdminNoticeController;
use App\Http\Controllers\Admin\Qna\AdminQnaController; use App\Http\Controllers\Admin\Qna\AdminQnaController;
use App\Http\Controllers\Admin\Members\AdminMembersController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::middleware(['web'])->group(function () { Route::middleware(['web'])->group(function () {
@ -145,63 +146,61 @@ Route::middleware(['web'])->group(function () {
Route::get('/', [AdminNoticeController::class, 'index'])->name('index'); Route::get('/', [AdminNoticeController::class, 'index'])->name('index');
Route::get('/create', [AdminNoticeController::class, 'create'])->name('create'); Route::get('/create', [AdminNoticeController::class, 'create'])->name('create');
Route::post('/', [AdminNoticeController::class, 'store'])->name('store'); Route::post('/', [AdminNoticeController::class, 'store'])->name('store');
Route::get('/{id}', [AdminNoticeController::class, 'edit'])->whereNumber('id')->name('edit');
Route::get('/{id}', [AdminNoticeController::class, 'edit']) Route::put('/{id}', [AdminNoticeController::class, 'update'])->whereNumber('id')->name('update');
->whereNumber('id') Route::delete('/{id}', [AdminNoticeController::class, 'destroy'])->whereNumber('id')->name('destroy');
->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}/file/{slot}', [AdminNoticeController::class, 'download']) Route::get('/{id}/file/{slot}', [AdminNoticeController::class, 'download'])
->whereNumber('id') ->whereNumber('id')
->whereIn('slot', ['1', '2']) ->whereIn('slot', ['1', '2'])
->name('file'); ->name('file');
}); });
Route::prefix('qna')->name('admin.qna.')->group(function () { Route::prefix('qna')
Route::get('/', [AdminQnaController::class, 'index'])->name('index'); ->name('admin.qna.')
Route::get('/{seq}', [AdminQnaController::class, 'show'])->name('show'); ->middleware('admin.role:super_admin,support')
->group(function () {
// 업무 액션 Route::get('/', [AdminQnaController::class, 'index'])->name('index');
Route::post('/{seq}/assign', [AdminQnaController::class, 'assignToMe'])->name('assign'); Route::get('/{seq}', [AdminQnaController::class, 'show'])->name('show');
Route::post('/{seq}/start', [AdminQnaController::class, 'startWork'])->name('start'); // 업무 액션
Route::post('/{seq}/return', [AdminQnaController::class, 'returnWork'])->name('return'); Route::post('/{seq}/assign', [AdminQnaController::class, 'assignToMe'])->name('assign');
Route::post('/{seq}/postpone', [AdminQnaController::class, 'postponeWork'])->name('postpone'); Route::post('/{seq}/start', [AdminQnaController::class, 'startWork'])->name('start');
Route::post('/{seq}/answer', [AdminQnaController::class, 'saveAnswer'])->name('answer.save'); Route::post('/{seq}/return', [AdminQnaController::class, 'returnWork'])->name('return');
Route::post('/{seq}/complete', [AdminQnaController::class, 'completeWork'])->name('complete'); 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::post('/{seq}/memo', [AdminQnaController::class, 'addMemo'])->name('memo.add');
}); });
/** Route::prefix('members')
* 아래는 메뉴는 있지만 실제 라우트/컨트롤러가 아직 없으니, ->name('admin.members.')
* 구현 시점에만 같은 패턴으로 그룹에 admin.role 붙이면 . ->middleware('admin.role:super_admin,support')
* ->group(function () {
* )
* - support 전용: Route::get('/', [AdminMembersController::class, 'index'])
* Route::prefix('/inquiry')->name('admin.inquiry.') ->name('index');
* ->middleware('admin.role:support')
* ->group(...) Route::get('/{memNo}', [AdminMembersController::class, 'show'])
* ->whereNumber('memNo')
* - finance 전용: ->name('show');
* Route::prefix('/settlement')->name('admin.settlement.')
* ->middleware('admin.role:finance') Route::post('/{memNo}', [AdminMembersController::class, 'update'])
* ->group(...) ->whereNumber('memNo')
* ->name('update');
* - product 전용:
* Route::prefix('/products')->name('admin.products.') Route::post('/{memNo}/memo', [AdminMembersController::class, 'addMemo'])
* ->middleware('admin.role:product') ->whereNumber('memNo')
* ->group(...) ->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');
});
}); });
}); });