관리자 회원/정책, 회원관리
This commit is contained in:
parent
754d6e2497
commit
4fb4f2ae32
@ -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;
|
||||||
|
|||||||
152
app/Http/Controllers/Admin/Members/AdminMembersController.php
Normal file
152
app/Http/Controllers/Admin/Members/AdminMembersController.php
Normal 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'] ?? '강제탈퇴 및 비식별 처리가 완료되었습니다.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 패턴이 불편함
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 = [];
|
|
||||||
}
|
|
||||||
@ -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',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
182
app/Repositories/Admin/Member/AdminMemberRepository.php
Normal file
182
app/Repositories/Admin/Member/AdminMemberRepository.php
Normal 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' => '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 '-';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 = [];
|
||||||
|
|||||||
428
app/Services/Admin/Member/AdminMemberService.php
Normal file
428
app/Services/Admin/Member/AdminMemberService.php
Normal 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]; }
|
||||||
|
}
|
||||||
@ -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'];
|
||||||
|
|||||||
@ -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 --}}
|
||||||
|
|||||||
344
resources/views/admin/members/index.blade.php
Normal file
344
resources/views/admin/members/index.blade.php
Normal 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
|
||||||
46
resources/views/admin/members/password_reset.blade.php
Normal file
46
resources/views/admin/members/password_reset.blade.php
Normal 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>
|
||||||
440
resources/views/admin/members/show.blade.php
Normal file
440
resources/views/admin/members/show.blade.php
Normal 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
|
||||||
@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user