시스템로그 , 다날인증, 계좌번호인증, 비밀번호변경, 로그인로그, 회원가입차단 알림로그, 관리자활동로그 작업
This commit is contained in:
parent
6d4195aacd
commit
6e8e8b5a57
43
app/Http/Controllers/Admin/Log/AdminAuditLogController.php
Normal file
43
app/Http/Controllers/Admin/Log/AdminAuditLogController.php
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin\Log;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Admin\Log\AdminAuditLogService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class AdminAuditLogController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AdminAuditLogService $service,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$data = $this->service->indexData($request->query());
|
||||||
|
|
||||||
|
// ✅ view 파일명: 컨트롤러와 이름 맞춤 (index.blade.php 사용 안함)
|
||||||
|
return view('admin.log.AdminAuditLogController', $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX 단건 조회 (모달 상세)
|
||||||
|
*/
|
||||||
|
public function show(int $id, Request $request)
|
||||||
|
{
|
||||||
|
$item = $this->service->getItem($id);
|
||||||
|
|
||||||
|
if (!$item) {
|
||||||
|
return response()->json([
|
||||||
|
'ok' => false,
|
||||||
|
'message' => 'NOT_FOUND',
|
||||||
|
'item' => null,
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'ok' => true,
|
||||||
|
'item' => $item,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin\Log;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Admin\Log\MemberAccountLogService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class MemberAccountLogController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly MemberAccountLogService $service,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$data = $this->service->indexData($request->query());
|
||||||
|
|
||||||
|
// index.blade.php 금지 → 컨트롤러명과 동일
|
||||||
|
return view('admin.log.MemberAccountLogController', $data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin\Log;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Admin\Log\MemberDanalAuthTelLogService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class MemberDanalAuthTelLogController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly MemberDanalAuthTelLogService $service,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$data = $this->service->indexData($request->query());
|
||||||
|
|
||||||
|
return view('admin.log.member_danalauthtel_log', $data);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Http/Controllers/Admin/Log/MemberJoinLogController.php
Normal file
22
app/Http/Controllers/Admin/Log/MemberJoinLogController.php
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin\Log;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Admin\Log\MemberJoinLogService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class MemberJoinLogController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly MemberJoinLogService $service,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$data = $this->service->indexData($request->query());
|
||||||
|
|
||||||
|
// ✅ index.blade.php 금지 → 컨트롤러명과 동일한 뷰 파일
|
||||||
|
return view('admin.log.MemberJoinLogController', $data);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Http/Controllers/Admin/Log/MemberLoginLogController.php
Normal file
22
app/Http/Controllers/Admin/Log/MemberLoginLogController.php
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin\Log;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Admin\Log\MemberLoginLogService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class MemberLoginLogController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly MemberLoginLogService $service,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$data = $this->service->indexData($request->query());
|
||||||
|
|
||||||
|
// index.blade.php 금지 → 컨트롤러명과 동일
|
||||||
|
return view('admin.log.MemberLoginLogController', $data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin\Log;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\Admin\Log\MemberPasswdModifyLogService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
final class MemberPasswdModifyLogController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly MemberPasswdModifyLogService $service,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$data = $this->service->indexData($request->query());
|
||||||
|
|
||||||
|
// index.blade.php 금지 → 컨트롤러명과 동일
|
||||||
|
return view('admin.log.MemberPasswdModifyLogController', $data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -413,6 +413,7 @@ final class InfoGateController extends Controller
|
|||||||
$repo->updatePasswordOnly($memNo, $newPw);
|
$repo->updatePasswordOnly($memNo, $newPw);
|
||||||
$repo->logPasswordResetSuccess(
|
$repo->logPasswordResetSuccess(
|
||||||
$memNo,
|
$memNo,
|
||||||
|
$email,
|
||||||
(string) $request->ip(),
|
(string) $request->ip(),
|
||||||
(string) $request->userAgent(),
|
(string) $request->userAgent(),
|
||||||
'S'
|
'S'
|
||||||
@ -506,6 +507,12 @@ final class InfoGateController extends Controller
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$repo->updatePin2Only($memNo, $newPin2);
|
$repo->updatePin2Only($memNo, $newPin2);
|
||||||
|
$repo->logPasswordResetSuccess2Only(
|
||||||
|
$memNo,
|
||||||
|
$email,
|
||||||
|
(string) $request->ip(),
|
||||||
|
(string) $request->userAgent()
|
||||||
|
);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
Log::error('[mypage] pin2 update failed', [
|
Log::error('[mypage] pin2 update failed', [
|
||||||
'mem_no' => $memNo,
|
'mem_no' => $memNo,
|
||||||
|
|||||||
99
app/Repositories/Admin/Log/AdminAuditLogRepository.php
Normal file
99
app/Repositories/Admin/Log/AdminAuditLogRepository.php
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repositories\Admin\Log;
|
||||||
|
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class AdminAuditLogRepository
|
||||||
|
{
|
||||||
|
private const TABLE = 'admin_audit_logs';
|
||||||
|
|
||||||
|
public function paginate(array $filters, int $perPage = 30): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$q = DB::table(self::TABLE . ' as l')
|
||||||
|
->leftJoin('admin_users as au', 'au.id', '=', 'l.actor_admin_user_id')
|
||||||
|
->select([
|
||||||
|
'l.id',
|
||||||
|
'l.actor_admin_user_id',
|
||||||
|
'l.action',
|
||||||
|
'l.target_type',
|
||||||
|
'l.target_id',
|
||||||
|
'l.ip',
|
||||||
|
'l.created_at',
|
||||||
|
'au.email as actor_email',
|
||||||
|
'au.name as actor_name',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 기간
|
||||||
|
if (!empty($filters['date_from'])) {
|
||||||
|
$q->where('l.created_at', '>=', $filters['date_from'] . ' 00:00:00');
|
||||||
|
}
|
||||||
|
if (!empty($filters['date_to'])) {
|
||||||
|
$q->where('l.created_at', '<=', $filters['date_to'] . ' 23:59:59');
|
||||||
|
}
|
||||||
|
|
||||||
|
// actor 검색 (email/name)
|
||||||
|
$actorQ = trim((string)($filters['actor_q'] ?? ''));
|
||||||
|
if ($actorQ !== '') {
|
||||||
|
$like = '%' . $this->escapeLike($actorQ) . '%';
|
||||||
|
$q->where(function ($qq) use ($like) {
|
||||||
|
$qq->where('au.email', 'like', $like)
|
||||||
|
->orWhere('au.name', 'like', $like);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// action (prefix match)
|
||||||
|
$action = trim((string)($filters['action'] ?? ''));
|
||||||
|
if ($action !== '') {
|
||||||
|
$q->where('l.action', 'like', $this->escapeLike($action) . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
// target_type (prefix match)
|
||||||
|
$tt = trim((string)($filters['target_type'] ?? ''));
|
||||||
|
if ($tt !== '') {
|
||||||
|
$q->where('l.target_type', 'like', $this->escapeLike($tt) . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ip (prefix match)
|
||||||
|
$ip = trim((string)($filters['ip'] ?? ''));
|
||||||
|
if ($ip !== '') {
|
||||||
|
$q->where('l.ip', 'like', $this->escapeLike($ip) . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 최신순
|
||||||
|
$q->orderByDesc('l.created_at')->orderByDesc('l.id');
|
||||||
|
|
||||||
|
return $q->paginate($perPage)->withQueryString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findOne(int $id): ?array
|
||||||
|
{
|
||||||
|
$row = DB::table(self::TABLE . ' as l')
|
||||||
|
->leftJoin('admin_users as au', 'au.id', '=', 'l.actor_admin_user_id')
|
||||||
|
->select([
|
||||||
|
'l.id',
|
||||||
|
'l.actor_admin_user_id',
|
||||||
|
'l.action',
|
||||||
|
'l.target_type',
|
||||||
|
'l.target_id',
|
||||||
|
'l.before_json',
|
||||||
|
'l.after_json',
|
||||||
|
'l.ip',
|
||||||
|
'l.user_agent',
|
||||||
|
'l.created_at',
|
||||||
|
'au.email as actor_email',
|
||||||
|
'au.name as actor_name',
|
||||||
|
])
|
||||||
|
->where('l.id', $id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return $row ? (array)$row : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function escapeLike(string $s): string
|
||||||
|
{
|
||||||
|
// MySQL LIKE escape (%, _)
|
||||||
|
return str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $s);
|
||||||
|
}
|
||||||
|
}
|
||||||
97
app/Repositories/Admin/Log/MemberAccountLogRepository.php
Normal file
97
app/Repositories/Admin/Log/MemberAccountLogRepository.php
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repositories\Admin\Log;
|
||||||
|
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class MemberAccountLogRepository
|
||||||
|
{
|
||||||
|
private const TABLE = 'mem_account_log';
|
||||||
|
|
||||||
|
public function paginate(array $filters, int $perPage = 30): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$q = DB::table(self::TABLE . ' as l')
|
||||||
|
->select([
|
||||||
|
'l.seq',
|
||||||
|
'l.mem_no',
|
||||||
|
'l.request_data',
|
||||||
|
'l.result_data',
|
||||||
|
'l.request_time',
|
||||||
|
'l.result_time',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 기간(요청시간 기준)
|
||||||
|
if (!empty($filters['date_from'])) {
|
||||||
|
$q->where('l.request_time', '>=', $filters['date_from'] . ' 00:00:00');
|
||||||
|
}
|
||||||
|
if (!empty($filters['date_to'])) {
|
||||||
|
$q->where('l.request_time', '<=', $filters['date_to'] . ' 23:59:59');
|
||||||
|
}
|
||||||
|
|
||||||
|
// mem_no
|
||||||
|
if (($filters['mem_no'] ?? null) !== null) {
|
||||||
|
$q->where('l.mem_no', (int)$filters['mem_no']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// bank_code (요청 JSON)
|
||||||
|
$bank = trim((string)($filters['bank_code'] ?? ''));
|
||||||
|
if ($bank !== '') {
|
||||||
|
$q->whereRaw(
|
||||||
|
"JSON_UNQUOTE(JSON_EXTRACT(l.request_data, '$.bank_code')) = ?",
|
||||||
|
[$bank]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// status (결과 JSON)
|
||||||
|
$status = trim((string)($filters['status'] ?? ''));
|
||||||
|
if ($status !== '') {
|
||||||
|
if ($status === 'ok') {
|
||||||
|
$q->whereRaw("JSON_EXTRACT(l.result_data, '$.status') = 200");
|
||||||
|
} elseif ($status === 'fail') {
|
||||||
|
$q->whereRaw("JSON_EXTRACT(l.result_data, '$.status') <> 200");
|
||||||
|
} elseif (ctype_digit($status)) {
|
||||||
|
$q->whereRaw("JSON_EXTRACT(l.result_data, '$.status') = ?", [(int)$status]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// account (요청 JSON) - 부분검색
|
||||||
|
$account = trim((string)($filters['account'] ?? ''));
|
||||||
|
if ($account !== '') {
|
||||||
|
$like = '%' . $this->escapeLike($account) . '%';
|
||||||
|
$q->whereRaw(
|
||||||
|
"JSON_UNQUOTE(JSON_EXTRACT(l.request_data, '$.account')) LIKE ?",
|
||||||
|
[$like]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// name (요청 JSON: mam_accountname)
|
||||||
|
$name = trim((string)($filters['name'] ?? ''));
|
||||||
|
if ($name !== '') {
|
||||||
|
$like = '%' . $this->escapeLike($name) . '%';
|
||||||
|
$q->whereRaw(
|
||||||
|
"JSON_UNQUOTE(JSON_EXTRACT(l.request_data, '$.mam_accountname')) LIKE ?",
|
||||||
|
[$like]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// q: request/result 전체 LIKE (운영에서 필요할 때만)
|
||||||
|
$kw = trim((string)($filters['q'] ?? ''));
|
||||||
|
if ($kw !== '') {
|
||||||
|
$like = '%' . $this->escapeLike($kw) . '%';
|
||||||
|
$q->where(function ($qq) use ($like) {
|
||||||
|
$qq->where('l.request_data', 'like', $like)
|
||||||
|
->orWhere('l.result_data', 'like', $like);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$q->orderByDesc('l.request_time')->orderByDesc('l.seq');
|
||||||
|
|
||||||
|
return $q->paginate($perPage)->withQueryString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function escapeLike(string $s): string
|
||||||
|
{
|
||||||
|
return str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $s);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repositories\Admin\Log;
|
||||||
|
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class MemberDanalAuthTelLogRepository
|
||||||
|
{
|
||||||
|
private const TABLE = 'mem_danalauthtel_log';
|
||||||
|
|
||||||
|
public function paginate(array $filters, int $perPage = 30): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$q = DB::table(self::TABLE)->select([
|
||||||
|
'seq','gubun','TID','res_code','mem_no','info','rgdate',
|
||||||
|
// ✅ MariaDB: CAST(... AS JSON) 금지 → info에 바로 JSON_EXTRACT
|
||||||
|
DB::raw("JSON_UNQUOTE(JSON_EXTRACT(`info`, '$.mobile_number')) AS mobile_number"),
|
||||||
|
DB::raw("JSON_UNQUOTE(JSON_EXTRACT(`info`, '$._mno')) AS info_mno"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// mem_no 검색: 컬럼 mem_no + info._mno 둘 다
|
||||||
|
$memNo = (string)($filters['mem_no'] ?? '');
|
||||||
|
if ($memNo !== '') {
|
||||||
|
$q->where(function ($qq) use ($memNo) {
|
||||||
|
$qq->where('mem_no', (int)$memNo)
|
||||||
|
->orWhereRaw("JSON_UNQUOTE(JSON_EXTRACT(`info`, '$._mno')) = ?", [$memNo]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// phone 검색: 숫자만 받아서 부분검색
|
||||||
|
$pd = (string)($filters['phone_digits'] ?? '');
|
||||||
|
if ($pd !== '') {
|
||||||
|
$q->whereRaw(
|
||||||
|
"REPLACE(JSON_UNQUOTE(JSON_EXTRACT(`info`, '$.mobile_number')),'-','') LIKE ?",
|
||||||
|
['%'.$pd.'%']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$q->orderByDesc('rgdate')->orderByDesc('seq');
|
||||||
|
|
||||||
|
return $q->paginate($perPage)->withQueryString();
|
||||||
|
}
|
||||||
|
}
|
||||||
83
app/Repositories/Admin/Log/MemberJoinLogRepository.php
Normal file
83
app/Repositories/Admin/Log/MemberJoinLogRepository.php
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repositories\Admin\Log;
|
||||||
|
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class MemberJoinLogRepository
|
||||||
|
{
|
||||||
|
private const TABLE = 'mem_join_log';
|
||||||
|
|
||||||
|
public function paginate(array $filters, int $perPage = 30): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$q = DB::table(self::TABLE . ' as l')
|
||||||
|
->select([
|
||||||
|
'l.seq',
|
||||||
|
'l.gubun',
|
||||||
|
'l.mem_no',
|
||||||
|
'l.cell_corp',
|
||||||
|
'l.cell_phone',
|
||||||
|
'l.email',
|
||||||
|
'l.ip4',
|
||||||
|
'l.ip4_c',
|
||||||
|
'l.error_code',
|
||||||
|
'l.dt_reg',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 기간(dt_reg)
|
||||||
|
if (!empty($filters['date_from'])) {
|
||||||
|
$q->where('l.dt_reg', '>=', $filters['date_from'] . ' 00:00:00');
|
||||||
|
}
|
||||||
|
if (!empty($filters['date_to'])) {
|
||||||
|
$q->where('l.dt_reg', '<=', $filters['date_to'] . ' 23:59:59');
|
||||||
|
}
|
||||||
|
|
||||||
|
// gubun
|
||||||
|
$gubun = trim((string)($filters['gubun'] ?? ''));
|
||||||
|
if ($gubun !== '') $q->where('l.gubun', $gubun);
|
||||||
|
|
||||||
|
// error_code (2자리)
|
||||||
|
$err = trim((string)($filters['error_code'] ?? ''));
|
||||||
|
if ($err !== '') $q->where('l.error_code', $err);
|
||||||
|
|
||||||
|
// mem_no
|
||||||
|
if (($filters['mem_no'] ?? null) !== null) {
|
||||||
|
$q->where('l.mem_no', (int)$filters['mem_no']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// phone exact (encrypt 결과)
|
||||||
|
if (!empty($filters['phone_enc'])) {
|
||||||
|
$q->where('l.cell_phone', (string)$filters['phone_enc']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// email (부분검색)
|
||||||
|
$email = trim((string)($filters['email'] ?? ''));
|
||||||
|
if ($email !== '') {
|
||||||
|
$like = '%' . $this->escapeLike($email) . '%';
|
||||||
|
$q->where('l.email', 'like', $like);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ip4 prefix
|
||||||
|
$ip4 = trim((string)($filters['ip4'] ?? ''));
|
||||||
|
if ($ip4 !== '') {
|
||||||
|
$q->where('l.ip4', 'like', $this->escapeLike($ip4) . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ip4_c prefix
|
||||||
|
$ip4c = trim((string)($filters['ip4_c'] ?? ''));
|
||||||
|
if ($ip4c !== '') {
|
||||||
|
$q->where('l.ip4_c', 'like', $this->escapeLike($ip4c) . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최신순
|
||||||
|
$q->orderByDesc('l.dt_reg')->orderByDesc('l.seq');
|
||||||
|
|
||||||
|
return $q->paginate($perPage)->withQueryString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function escapeLike(string $s): string
|
||||||
|
{
|
||||||
|
return str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $s);
|
||||||
|
}
|
||||||
|
}
|
||||||
88
app/Repositories/Admin/Log/MemberLoginLogRepository.php
Normal file
88
app/Repositories/Admin/Log/MemberLoginLogRepository.php
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repositories\Admin\Log;
|
||||||
|
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class MemberLoginLogRepository
|
||||||
|
{
|
||||||
|
public function paginateByYear(int $year, array $filters, int $perPage = 30): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$table = 'mem_login_' . $year;
|
||||||
|
|
||||||
|
$q = DB::table($table . ' as l')
|
||||||
|
->select([
|
||||||
|
'l.seq',
|
||||||
|
'l.mem_no',
|
||||||
|
'l.sf',
|
||||||
|
'l.conn',
|
||||||
|
'l.ip4',
|
||||||
|
'l.ip4_c',
|
||||||
|
'l.error_code',
|
||||||
|
'l.dt_reg',
|
||||||
|
'l.platform',
|
||||||
|
'l.browser',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 기간(dt_reg)
|
||||||
|
if (!empty($filters['date_from'])) {
|
||||||
|
$q->where('l.dt_reg', '>=', $filters['date_from'] . ' 00:00:00');
|
||||||
|
}
|
||||||
|
if (!empty($filters['date_to'])) {
|
||||||
|
$q->where('l.dt_reg', '<=', $filters['date_to'] . ' 23:59:59.999999');
|
||||||
|
}
|
||||||
|
|
||||||
|
// mem_no
|
||||||
|
if (($filters['mem_no'] ?? null) !== null) {
|
||||||
|
$q->where('l.mem_no', (int)$filters['mem_no']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 성공/실패
|
||||||
|
if (!empty($filters['sf'])) {
|
||||||
|
$q->where('l.sf', $filters['sf']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 접속경로 1/2
|
||||||
|
if (!empty($filters['conn'])) {
|
||||||
|
$q->where('l.conn', $filters['conn']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ip prefix
|
||||||
|
$ip4 = trim((string)($filters['ip4'] ?? ''));
|
||||||
|
if ($ip4 !== '') {
|
||||||
|
$q->where('l.ip4', 'like', $this->escapeLike($ip4) . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$ip4c = trim((string)($filters['ip4_c'] ?? ''));
|
||||||
|
if ($ip4c !== '') {
|
||||||
|
$q->where('l.ip4_c', 'like', $this->escapeLike($ip4c) . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
// error_code
|
||||||
|
$err = trim((string)($filters['error_code'] ?? ''));
|
||||||
|
if ($err !== '') {
|
||||||
|
$q->where('l.error_code', $err);
|
||||||
|
}
|
||||||
|
|
||||||
|
// platform/browser (contains)
|
||||||
|
$platform = trim((string)($filters['platform'] ?? ''));
|
||||||
|
if ($platform !== '') {
|
||||||
|
$q->where('l.platform', 'like', '%' . $this->escapeLike($platform) . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$browser = trim((string)($filters['browser'] ?? ''));
|
||||||
|
if ($browser !== '') {
|
||||||
|
$q->where('l.browser', 'like', '%' . $this->escapeLike($browser) . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$q->orderByDesc('l.dt_reg')->orderByDesc('l.seq');
|
||||||
|
|
||||||
|
return $q->paginate($perPage)->withQueryString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function escapeLike(string $s): string
|
||||||
|
{
|
||||||
|
return str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $s);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Repositories\Admin\Log;
|
||||||
|
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class MemberPasswdModifyLogRepository
|
||||||
|
{
|
||||||
|
private const TABLE = 'mem_passwd_modify';
|
||||||
|
|
||||||
|
public function paginate(array $filters, int $perPage = 30): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$q = DB::table(self::TABLE . ' as p')
|
||||||
|
->select(['p.seq', 'p.state', 'p.info', 'p.rgdate']);
|
||||||
|
|
||||||
|
// 기간
|
||||||
|
if (!empty($filters['date_from'])) {
|
||||||
|
$q->where('p.rgdate', '>=', $filters['date_from'] . ' 00:00:00');
|
||||||
|
}
|
||||||
|
if (!empty($filters['date_to'])) {
|
||||||
|
$q->where('p.rgdate', '<=', $filters['date_to'] . ' 23:59:59');
|
||||||
|
}
|
||||||
|
|
||||||
|
// state
|
||||||
|
if (!empty($filters['state'])) {
|
||||||
|
$q->where('p.state', $filters['state']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// mem_no (JSON)
|
||||||
|
if (($filters['mem_no'] ?? null) !== null) {
|
||||||
|
$q->whereRaw(
|
||||||
|
"JSON_UNQUOTE(JSON_EXTRACT(p.info, '$.mem_no')) = ?",
|
||||||
|
[(string)((int)$filters['mem_no'])]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// type (신포맷)
|
||||||
|
$type = trim((string)($filters['type'] ?? ''));
|
||||||
|
if ($type !== '') {
|
||||||
|
$q->whereRaw(
|
||||||
|
"JSON_UNQUOTE(JSON_EXTRACT(p.info, '$.type')) = ?",
|
||||||
|
[$type]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// email (구포맷)
|
||||||
|
$email = trim((string)($filters['email'] ?? ''));
|
||||||
|
if ($email !== '') {
|
||||||
|
$like = '%' . $this->escapeLike($email) . '%';
|
||||||
|
|
||||||
|
$q->where(function ($qq) use ($like) {
|
||||||
|
$qq->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(p.info, '$.user_email')) LIKE ?", [$like])
|
||||||
|
->orWhereRaw("JSON_UNQUOTE(JSON_EXTRACT(p.info, '$.email')) LIKE ?", [$like]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ip: 신포맷 remote_addr prefix OR 구포맷 auth_key/info contains
|
||||||
|
$ip = trim((string)($filters['ip'] ?? ''));
|
||||||
|
if ($ip !== '') {
|
||||||
|
$like = $this->escapeLike($ip) . '%';
|
||||||
|
$like2 = '%' . $this->escapeLike($ip) . '%';
|
||||||
|
|
||||||
|
$q->where(function ($qq) use ($like, $like2) {
|
||||||
|
$qq->whereRaw("JSON_UNQUOTE(JSON_EXTRACT(p.info, '$.remote_addr')) LIKE ?", [$like])
|
||||||
|
->orWhere('p.info', 'like', $like2);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// q: info 전체 검색(필요할 때만)
|
||||||
|
$kw = trim((string)($filters['q'] ?? ''));
|
||||||
|
if ($kw !== '') {
|
||||||
|
$q->where('p.info', 'like', '%' . $this->escapeLike($kw) . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$q->orderByDesc('p.rgdate')->orderByDesc('p.seq');
|
||||||
|
|
||||||
|
return $q->paginate($perPage)->withQueryString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function escapeLike(string $s): string
|
||||||
|
{
|
||||||
|
return str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $s);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -742,7 +742,7 @@ class MemberAuthRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public function logPasswordResetSuccess(int $memNo, string $ip, string $agent, string $state = 'E'): void
|
public function logPasswordResetSuccess(int $memNo, string $email, string $ip, string $agent, string $state = 'E'): void
|
||||||
{
|
{
|
||||||
$now = now()->format('Y-m-d H:i:s');
|
$now = now()->format('Y-m-d H:i:s');
|
||||||
|
|
||||||
@ -756,6 +756,27 @@ class MemberAuthRepository
|
|||||||
'state' => $state,
|
'state' => $state,
|
||||||
'info' => json_encode([
|
'info' => json_encode([
|
||||||
'mem_no' => (string)$memNo,
|
'mem_no' => (string)$memNo,
|
||||||
|
'email' => $email,
|
||||||
|
'type' => "로그인비밀번호변경",
|
||||||
|
'redate' => $now,
|
||||||
|
'remote_addr' => $ip,
|
||||||
|
'agent' => substr((string)$agent, 0, 500), // 길이 방어(일관 처리)
|
||||||
|
], JSON_UNESCAPED_UNICODE),
|
||||||
|
'rgdate' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logPasswordResetSuccess2Only(int $memNo, string $email, string $ip, string $agent): void
|
||||||
|
{
|
||||||
|
$now = now()->format('Y-m-d H:i:s');
|
||||||
|
$state = 'S';
|
||||||
|
|
||||||
|
DB::table('mem_passwd_modify')->insert([
|
||||||
|
'state' => $state,
|
||||||
|
'info' => json_encode([
|
||||||
|
'mem_no' => (string)$memNo,
|
||||||
|
'email' => $email,
|
||||||
|
'type' => "2차비밀번호변경",
|
||||||
'redate' => $now,
|
'redate' => $now,
|
||||||
'remote_addr' => $ip,
|
'remote_addr' => $ip,
|
||||||
'agent' => substr((string)$agent, 0, 500), // 길이 방어(일관 처리)
|
'agent' => substr((string)$agent, 0, 500), // 길이 방어(일관 처리)
|
||||||
|
|||||||
106
app/Services/Admin/Log/AdminAuditLogService.php
Normal file
106
app/Services/Admin/Log/AdminAuditLogService.php
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Admin\Log;
|
||||||
|
|
||||||
|
use App\Repositories\Admin\Log\AdminAuditLogRepository;
|
||||||
|
|
||||||
|
final class AdminAuditLogService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AdminAuditLogRepository $repo,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function indexData(array $query = []): array
|
||||||
|
{
|
||||||
|
$filters = [
|
||||||
|
'date_from' => $this->safeDate($query['date_from'] ?? ''),
|
||||||
|
'date_to' => $this->safeDate($query['date_to'] ?? ''),
|
||||||
|
'actor_q' => $this->safeStr($query['actor_q'] ?? '', 80),
|
||||||
|
'action' => $this->safeStr($query['action'] ?? '', 60),
|
||||||
|
'target_type' => $this->safeStr($query['target_type'] ?? '', 80),
|
||||||
|
'ip' => $this->safeStr($query['ip'] ?? '', 45),
|
||||||
|
];
|
||||||
|
|
||||||
|
// ✅ 기간 역전 방지
|
||||||
|
if ($filters['date_from'] && $filters['date_to']) {
|
||||||
|
if (strcmp($filters['date_from'], $filters['date_to']) > 0) {
|
||||||
|
[$filters['date_from'], $filters['date_to']] = [$filters['date_to'], $filters['date_from']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$page = $this->repo->paginate($filters, 30);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'page' => $page,
|
||||||
|
'items' => $page->items(),
|
||||||
|
'filters' => $filters,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getItem(int $id): ?array
|
||||||
|
{
|
||||||
|
$row = $this->repo->findOne($id);
|
||||||
|
if (!$row) return null;
|
||||||
|
|
||||||
|
$beforeRaw = $row['before_json'] ?? null;
|
||||||
|
$afterRaw = $row['after_json'] ?? null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => (int)($row['id'] ?? 0),
|
||||||
|
|
||||||
|
'actor_admin_user_id' => (int)($row['actor_admin_user_id'] ?? 0),
|
||||||
|
'actor_email' => (string)($row['actor_email'] ?? ''),
|
||||||
|
'actor_name' => (string)($row['actor_name'] ?? ''),
|
||||||
|
|
||||||
|
'action' => (string)($row['action'] ?? ''),
|
||||||
|
'target_type' => (string)($row['target_type'] ?? ''),
|
||||||
|
'target_id' => (int)($row['target_id'] ?? 0),
|
||||||
|
|
||||||
|
'ip' => (string)($row['ip'] ?? ''),
|
||||||
|
'created_at' => (string)($row['created_at'] ?? ''),
|
||||||
|
|
||||||
|
'user_agent' => (string)($row['user_agent'] ?? ''),
|
||||||
|
|
||||||
|
// ✅ pretty 출력 (모달에서 바로 textContent로 넣기 좋게)
|
||||||
|
'before_pretty' => $this->prettyJson($beforeRaw),
|
||||||
|
'after_pretty' => $this->prettyJson($afterRaw),
|
||||||
|
|
||||||
|
// 원문도 필요하면 남겨둠
|
||||||
|
'before_raw' => $beforeRaw,
|
||||||
|
'after_raw' => $afterRaw,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function safeStr(mixed $v, int $max): string
|
||||||
|
{
|
||||||
|
$s = trim((string)$v);
|
||||||
|
if ($s === '') return '';
|
||||||
|
if (mb_strlen($s) > $max) $s = mb_substr($s, 0, $max);
|
||||||
|
return $s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function safeDate(mixed $v): ?string
|
||||||
|
{
|
||||||
|
$s = trim((string)$v);
|
||||||
|
if ($s === '') return null;
|
||||||
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $s)) return null;
|
||||||
|
return $s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function prettyJson(?string $raw): string
|
||||||
|
{
|
||||||
|
$raw = $raw !== null ? trim((string)$raw) : '';
|
||||||
|
if ($raw === '') return '-';
|
||||||
|
|
||||||
|
$decoded = json_decode($raw, true);
|
||||||
|
if (!is_array($decoded) && !is_object($decoded)) {
|
||||||
|
// 깨진 JSON이면 원문 출력
|
||||||
|
return $raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return json_encode(
|
||||||
|
$decoded,
|
||||||
|
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
|
||||||
|
) ?: $raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
160
app/Services/Admin/Log/MemberAccountLogService.php
Normal file
160
app/Services/Admin/Log/MemberAccountLogService.php
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Admin\Log;
|
||||||
|
|
||||||
|
use App\Repositories\Admin\Log\MemberAccountLogRepository;
|
||||||
|
|
||||||
|
final class MemberAccountLogService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly MemberAccountLogRepository $repo,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function indexData(array $query = []): array
|
||||||
|
{
|
||||||
|
$filters = [
|
||||||
|
'date_from' => $this->safeDate($query['date_from'] ?? ''),
|
||||||
|
'date_to' => $this->safeDate($query['date_to'] ?? ''),
|
||||||
|
|
||||||
|
'mem_no' => $this->safeInt($query['mem_no'] ?? null),
|
||||||
|
'bank_code' => $this->safeStr($query['bank_code'] ?? '', 10),
|
||||||
|
'status' => $this->safeStr($query['status'] ?? '', 10), // ok/fail/200/520...
|
||||||
|
|
||||||
|
'account' => $this->safeStr($query['account'] ?? '', 40),
|
||||||
|
'name' => $this->safeStr($query['name'] ?? '', 40),
|
||||||
|
'q' => $this->safeStr($query['q'] ?? '', 120),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($filters['date_from'] && $filters['date_to']) {
|
||||||
|
if (strcmp($filters['date_from'], $filters['date_to']) > 0) {
|
||||||
|
[$filters['date_from'], $filters['date_to']] = [$filters['date_to'], $filters['date_from']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$page = $this->repo->paginate($filters, 30);
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
foreach ($page->items() as $it) {
|
||||||
|
$r = is_array($it) ? $it : (array)$it;
|
||||||
|
|
||||||
|
$reqRaw = (string)($r['request_data'] ?? '');
|
||||||
|
$resRaw = (string)($r['result_data'] ?? '');
|
||||||
|
|
||||||
|
$req = $this->decodeJson($reqRaw);
|
||||||
|
$res = $this->decodeJson($resRaw);
|
||||||
|
|
||||||
|
$memNo = (int)($r['mem_no'] ?? 0);
|
||||||
|
|
||||||
|
$account = trim((string)($req['account'] ?? ''));
|
||||||
|
$accountMasked = $this->maskAccount($account);
|
||||||
|
|
||||||
|
$bankCode = trim((string)($req['bank_code'] ?? ''));
|
||||||
|
$proType = trim((string)($req['account_protype'] ?? ''));
|
||||||
|
$name = trim((string)($req['mam_accountname'] ?? ''));
|
||||||
|
|
||||||
|
$status = (int)($res['status'] ?? 0);
|
||||||
|
$ok = ($status === 200);
|
||||||
|
|
||||||
|
$depositor = trim((string)($res['depositor'] ?? ''));
|
||||||
|
$errCode = trim((string)($res['error_code'] ?? ''));
|
||||||
|
$errMsg = trim((string)($res['error_message'] ?? ''));
|
||||||
|
|
||||||
|
// JSON pretty (api_key는 마스킹)
|
||||||
|
$reqPretty = $this->prettyJson($this->maskApiKey($req), $reqRaw);
|
||||||
|
$resPretty = $this->prettyJson($res, $resRaw);
|
||||||
|
|
||||||
|
$items[] = array_merge($r, [
|
||||||
|
'mem_link' => $memNo > 0 ? ('/members/' . $memNo) : null,
|
||||||
|
|
||||||
|
'account' => $account,
|
||||||
|
'account_masked' => $accountMasked,
|
||||||
|
'bank_code' => $bankCode,
|
||||||
|
'account_protype' => $proType,
|
||||||
|
'mam_accountname' => $name,
|
||||||
|
|
||||||
|
'status_int' => $status,
|
||||||
|
'status_label' => $ok ? 'SUCCESS' : 'FAIL',
|
||||||
|
'status_badge' => $ok ? 'badge--ok' : 'badge--bad',
|
||||||
|
|
||||||
|
'depositor' => $depositor,
|
||||||
|
'error_code' => $errCode,
|
||||||
|
'error_message' => $errMsg,
|
||||||
|
|
||||||
|
'request_pretty' => $reqPretty,
|
||||||
|
'result_pretty' => $resPretty,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'filters' => $filters,
|
||||||
|
'page' => $page,
|
||||||
|
'items' => $items,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decodeJson(string $raw): array
|
||||||
|
{
|
||||||
|
$raw = trim($raw);
|
||||||
|
if ($raw === '') return [];
|
||||||
|
$arr = json_decode($raw, true);
|
||||||
|
return is_array($arr) ? $arr : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function prettyJson(array $arr, string $fallbackRaw): string
|
||||||
|
{
|
||||||
|
if (!empty($arr)) {
|
||||||
|
return (string)json_encode($arr, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
return $fallbackRaw;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function maskApiKey(array $req): array
|
||||||
|
{
|
||||||
|
if (!isset($req['api_key'])) return $req;
|
||||||
|
|
||||||
|
$v = (string)$req['api_key'];
|
||||||
|
$v = trim($v);
|
||||||
|
if ($v === '') return $req;
|
||||||
|
|
||||||
|
// 앞 4 + ... + 뒤 4
|
||||||
|
$head = mb_substr($v, 0, 4);
|
||||||
|
$tail = mb_substr($v, -4);
|
||||||
|
$req['api_key'] = $head . '…' . $tail;
|
||||||
|
|
||||||
|
return $req;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function maskAccount(string $account): string
|
||||||
|
{
|
||||||
|
$s = trim($account);
|
||||||
|
if ($s === '') return '';
|
||||||
|
|
||||||
|
$len = mb_strlen($s);
|
||||||
|
if ($len <= 4) return $s;
|
||||||
|
|
||||||
|
return str_repeat('*', max(0, $len - 4)) . mb_substr($s, -4);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function safeStr(mixed $v, int $max): string
|
||||||
|
{
|
||||||
|
$s = trim((string)$v);
|
||||||
|
if ($s === '') return '';
|
||||||
|
if (mb_strlen($s) > $max) $s = mb_substr($s, 0, $max);
|
||||||
|
return $s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function safeInt(mixed $v): ?int
|
||||||
|
{
|
||||||
|
if ($v === null || $v === '') return null;
|
||||||
|
if (!is_numeric($v)) return null;
|
||||||
|
$n = (int)$v;
|
||||||
|
return $n >= 0 ? $n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function safeDate(mixed $v): ?string
|
||||||
|
{
|
||||||
|
$s = trim((string)$v);
|
||||||
|
if ($s === '') return null;
|
||||||
|
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $s) ? $s : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
92
app/Services/Admin/Log/MemberDanalAuthTelLogService.php
Normal file
92
app/Services/Admin/Log/MemberDanalAuthTelLogService.php
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Admin\Log;
|
||||||
|
|
||||||
|
use App\Repositories\Admin\Log\MemberDanalAuthTelLogRepository;
|
||||||
|
|
||||||
|
final class MemberDanalAuthTelLogService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly MemberDanalAuthTelLogRepository $repo,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function indexData(array $query = []): array
|
||||||
|
{
|
||||||
|
$filters = [
|
||||||
|
'mem_no' => trim((string)($query['mem_no'] ?? '')),
|
||||||
|
'phone' => trim((string)($query['phone'] ?? '')),
|
||||||
|
];
|
||||||
|
|
||||||
|
// mem_no: 숫자만
|
||||||
|
if ($filters['mem_no'] !== '' && !ctype_digit($filters['mem_no'])) {
|
||||||
|
$filters['mem_no'] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// phone: 숫자만 (10~11자리 아니어도 부분검색 가능하게 숫자만 유지)
|
||||||
|
$filters['phone_digits'] = preg_replace('/\D+/', '', $filters['phone']) ?: '';
|
||||||
|
|
||||||
|
$page = $this->repo->paginate($filters, 30);
|
||||||
|
|
||||||
|
$rows = [];
|
||||||
|
foreach ($page->items() as $it) {
|
||||||
|
$r = is_array($it) ? $it : (array)$it;
|
||||||
|
|
||||||
|
$phone = (string)($r['mobile_number'] ?? '');
|
||||||
|
$r['phone_digits'] = preg_replace('/\D+/', '', $phone) ?: '';
|
||||||
|
$r['phone_display'] = $this->formatPhone($r['phone_digits']) ?: $phone;
|
||||||
|
|
||||||
|
// JSON(모달용) - 개인정보 최소화 (mem_no/전화 + 결과/거래정보만)
|
||||||
|
$infoArr = $this->decodeJson((string)($r['info'] ?? ''));
|
||||||
|
$r['info_sanitized'] = $this->sanitizeInfo($infoArr, $r);
|
||||||
|
|
||||||
|
$rows[] = $r;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'page' => $page,
|
||||||
|
'rows' => $rows,
|
||||||
|
'filters' => $filters,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decodeJson(string $json): array
|
||||||
|
{
|
||||||
|
$json = trim($json);
|
||||||
|
if ($json === '') return [];
|
||||||
|
$arr = json_decode($json, true);
|
||||||
|
return is_array($arr) ? $arr : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sanitizeInfo(array $info, array $row): array
|
||||||
|
{
|
||||||
|
// 허용 필드만 남김 (요구사항: mem_no + 전화만 노출)
|
||||||
|
// + 로그 판단에 필요한 최소 정보(RETURNCODE/RETURNMSG/TID/telecom 등)만
|
||||||
|
$out = [];
|
||||||
|
|
||||||
|
$out['gubun'] = (string)($row['gubun'] ?? '');
|
||||||
|
$out['res_code'] = (string)($row['res_code'] ?? '');
|
||||||
|
$out['TID'] = (string)($row['TID'] ?? '');
|
||||||
|
$out['mem_no'] = (string)($row['mem_no'] ?? '');
|
||||||
|
|
||||||
|
// info에서 가져오되, 화면엔 전화번호만
|
||||||
|
$out['mobile_number'] = (string)($info['mobile_number'] ?? ($row['mobile_number'] ?? ''));
|
||||||
|
|
||||||
|
// 결과코드/메시지(있으면)
|
||||||
|
$out['RETURNCODE'] = (string)($info['RETURNCODE'] ?? ($info['res_code'] ?? ''));
|
||||||
|
$out['RETURNMSG'] = (string)($info['RETURNMSG'] ?? '');
|
||||||
|
|
||||||
|
// telecom은 개인식별 정보는 아니지만, 원하면 모달에서도 빼도 됨.
|
||||||
|
// 지금은 로그 파악에 도움돼서 유지.
|
||||||
|
if (isset($info['telecom'])) $out['telecom'] = (string)$info['telecom'];
|
||||||
|
|
||||||
|
// CI/DI/name/birthday/sex/email 등은 완전히 제거
|
||||||
|
return array_filter($out, fn($v) => $v !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
return substr($digits, 0, 3).'-'.substr($digits, 3, 3).'-'.substr($digits, 6, 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
179
app/Services/Admin/Log/MemberJoinLogService.php
Normal file
179
app/Services/Admin/Log/MemberJoinLogService.php
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Admin\Log;
|
||||||
|
|
||||||
|
use App\Repositories\Admin\Log\MemberJoinLogRepository;
|
||||||
|
use App\Support\LegacyCrypto\CiSeedCrypto;
|
||||||
|
|
||||||
|
final class MemberJoinLogService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly MemberJoinLogRepository $repo,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function indexData(array $query = []): array
|
||||||
|
{
|
||||||
|
$filters = [
|
||||||
|
'date_from' => $this->safeDate($query['date_from'] ?? ''),
|
||||||
|
'date_to' => $this->safeDate($query['date_to'] ?? ''),
|
||||||
|
|
||||||
|
'gubun' => $this->safeStr($query['gubun'] ?? '', 50),
|
||||||
|
'error_code' => $this->safeStr($query['error_code'] ?? '', 2),
|
||||||
|
|
||||||
|
'mem_no' => $this->safeInt($query['mem_no'] ?? null),
|
||||||
|
'email' => $this->safeStr($query['email'] ?? '', 80),
|
||||||
|
|
||||||
|
'ip4' => $this->safeIpPrefix($query['ip4'] ?? ''),
|
||||||
|
'ip4_c' => $this->safeIpPrefix($query['ip4_c'] ?? ''),
|
||||||
|
|
||||||
|
// 사용자 입력 전화번호(plain)
|
||||||
|
'phone' => $this->safeStr($query['phone'] ?? '', 30),
|
||||||
|
// 검색용 encrypt 결과(정확일치)
|
||||||
|
'phone_enc' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
// ✅ 기간 역전 방지
|
||||||
|
if ($filters['date_from'] && $filters['date_to']) {
|
||||||
|
if (strcmp($filters['date_from'], $filters['date_to']) > 0) {
|
||||||
|
[$filters['date_from'], $filters['date_to']] = [$filters['date_to'], $filters['date_from']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 전화번호 검색: encrypt(숫자) = cell_phone (정확일치)
|
||||||
|
$phoneDigits = preg_replace('/\D+/', '', (string)$filters['phone']) ?: '';
|
||||||
|
if ($phoneDigits !== '' && preg_match('/^\d{10,11}$/', $phoneDigits)) {
|
||||||
|
try {
|
||||||
|
$seed = app(CiSeedCrypto::class);
|
||||||
|
$filters['phone_enc'] = (string)$seed->encrypt($phoneDigits);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// encrypt 실패하면 검색조건 무시(리스트는 정상 출력)
|
||||||
|
$filters['phone_enc'] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$page = $this->repo->paginate($filters, 30);
|
||||||
|
|
||||||
|
// ✅ 리스트 표시용 가공(복호화/포맷)
|
||||||
|
$seed = app(CiSeedCrypto::class);
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
foreach ($page->items() as $it) {
|
||||||
|
$r = is_array($it) ? $it : (array)$it;
|
||||||
|
|
||||||
|
[$corpLabel, $corpBadge] = $this->corpLabel((string)($r['cell_corp'] ?? 'n'));
|
||||||
|
|
||||||
|
$phoneEnc = (string)($r['cell_phone'] ?? '');
|
||||||
|
$phoneDigits = $this->decryptPhoneDigits($seed, $phoneEnc);
|
||||||
|
$phoneDisplay = $this->formatPhone($phoneDigits);
|
||||||
|
|
||||||
|
$email = trim((string)($r['email'] ?? ''));
|
||||||
|
if ($email === '' || $email === '-') $email = '-';
|
||||||
|
|
||||||
|
$memNo = (int)($r['mem_no'] ?? 0);
|
||||||
|
|
||||||
|
$items[] = array_merge($r, [
|
||||||
|
'corp_label' => $corpLabel,
|
||||||
|
'corp_badge' => $corpBadge,
|
||||||
|
|
||||||
|
'phone_plain' => $phoneDigits,
|
||||||
|
'phone_display' => ($phoneDisplay !== '' ? $phoneDisplay : ($phoneDigits !== '' ? $phoneDigits : '-')),
|
||||||
|
|
||||||
|
'email_display' => $email,
|
||||||
|
|
||||||
|
'mem_no_int' => $memNo,
|
||||||
|
'mem_link' => ($memNo > 0) ? ('/members/' . $memNo) : null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'page' => $page,
|
||||||
|
'items' => $items,
|
||||||
|
'filters' => $filters,
|
||||||
|
'corp_map' => $this->corpMapForUi(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function safeStr(mixed $v, int $max): string
|
||||||
|
{
|
||||||
|
$s = trim((string)$v);
|
||||||
|
if ($s === '') return '';
|
||||||
|
if (mb_strlen($s) > $max) $s = mb_substr($s, 0, $max);
|
||||||
|
return $s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function safeInt(mixed $v): ?int
|
||||||
|
{
|
||||||
|
if ($v === null || $v === '') return null;
|
||||||
|
if (!is_numeric($v)) return null;
|
||||||
|
$n = (int)$v;
|
||||||
|
return $n >= 0 ? $n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function safeDate(mixed $v): ?string
|
||||||
|
{
|
||||||
|
$s = trim((string)$v);
|
||||||
|
if ($s === '') return null;
|
||||||
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $s)) return null;
|
||||||
|
return $s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function safeIpPrefix(mixed $v): string
|
||||||
|
{
|
||||||
|
$s = trim((string)$v);
|
||||||
|
if ($s === '') return '';
|
||||||
|
// prefix 검색 용도: 숫자/점만 허용
|
||||||
|
if (!preg_match('/^[0-9.]+$/', $s)) return '';
|
||||||
|
return $s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decryptPhoneDigits(CiSeedCrypto $seed, string $enc): string
|
||||||
|
{
|
||||||
|
$enc = trim($enc);
|
||||||
|
if ($enc === '') return '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$plain = (string)$seed->decrypt($enc);
|
||||||
|
$digits = preg_replace('/\D+/', '', $plain) ?: '';
|
||||||
|
return preg_match('/^\d{10,11}$/', $digits) ? $digits : '';
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
return substr($digits, 0, 3) . '-' . substr($digits, 3, 3) . '-' . substr($digits, 6, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function corpLabel(string $code): array
|
||||||
|
{
|
||||||
|
$map = [
|
||||||
|
'01' => ['SKT', 'badge--skt'],
|
||||||
|
'02' => ['KT', 'badge--kt'],
|
||||||
|
'03' => ['LGU+', 'badge--lgu'],
|
||||||
|
'04' => ['SKT(알뜰)', 'badge--mvno'],
|
||||||
|
'05' => ['KT(알뜰)', 'badge--mvno'],
|
||||||
|
'06' => ['LGU+(알뜰)', 'badge--mvno'],
|
||||||
|
'n' => ['-', 'badge--muted'],
|
||||||
|
];
|
||||||
|
|
||||||
|
return $map[$code] ?? ['-', 'badge--muted'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function corpMapForUi(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'' => '전체',
|
||||||
|
'01' => 'SKT',
|
||||||
|
'02' => 'KT',
|
||||||
|
'03' => 'LGU+',
|
||||||
|
'04' => 'SKT(알뜰)',
|
||||||
|
'05' => 'KT(알뜰)',
|
||||||
|
'06' => 'LGU+(알뜰)',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
129
app/Services/Admin/Log/MemberLoginLogService.php
Normal file
129
app/Services/Admin/Log/MemberLoginLogService.php
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Admin\Log;
|
||||||
|
|
||||||
|
use App\Repositories\Admin\Log\MemberLoginLogRepository;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
final class MemberLoginLogService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly MemberLoginLogRepository $repo,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function indexData(array $query = []): array
|
||||||
|
{
|
||||||
|
$currentYear = (int)date('Y');
|
||||||
|
$years = $this->availableYears(2018, $currentYear);
|
||||||
|
|
||||||
|
$year = (int)($query['year'] ?? $currentYear);
|
||||||
|
if (!in_array($year, $years, true)) {
|
||||||
|
// 테이블 없거나 허용 범위 밖이면 최신년도 fallback
|
||||||
|
$year = !empty($years) ? max($years) : $currentYear;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filters = [
|
||||||
|
'year' => $year,
|
||||||
|
'date_from' => $this->safeDate($query['date_from'] ?? ''),
|
||||||
|
'date_to' => $this->safeDate($query['date_to'] ?? ''),
|
||||||
|
|
||||||
|
'mem_no' => $this->safeInt($query['mem_no'] ?? null),
|
||||||
|
|
||||||
|
'sf' => $this->safeEnum($query['sf'] ?? '', ['s','f']), // 성공/실패
|
||||||
|
'conn' => $this->safeEnum($query['conn'] ?? '', ['1','2']), // pc/mobile
|
||||||
|
|
||||||
|
'ip4' => $this->safeIpPrefix($query['ip4'] ?? ''),
|
||||||
|
'ip4_c' => $this->safeIpPrefix($query['ip4_c'] ?? ''),
|
||||||
|
|
||||||
|
'error_code'=> $this->safeStr($query['error_code'] ?? '', 10),
|
||||||
|
|
||||||
|
'platform' => $this->safeStr($query['platform'] ?? '', 30),
|
||||||
|
'browser' => $this->safeStr($query['browser'] ?? '', 40),
|
||||||
|
];
|
||||||
|
|
||||||
|
// 기간 역전 방지
|
||||||
|
if ($filters['date_from'] && $filters['date_to']) {
|
||||||
|
if (strcmp($filters['date_from'], $filters['date_to']) > 0) {
|
||||||
|
[$filters['date_from'], $filters['date_to']] = [$filters['date_to'], $filters['date_from']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$page = $this->repo->paginateByYear($year, $filters, 30);
|
||||||
|
|
||||||
|
// 화면 가공(라벨/링크 등)
|
||||||
|
$items = [];
|
||||||
|
foreach ($page->items() as $it) {
|
||||||
|
$r = is_array($it) ? $it : (array)$it;
|
||||||
|
|
||||||
|
$sf = (string)($r['sf'] ?? 's');
|
||||||
|
$conn = (string)($r['conn'] ?? '1');
|
||||||
|
|
||||||
|
$items[] = array_merge($r, [
|
||||||
|
'sf_label' => $sf === 'f' ? '실패' : '성공',
|
||||||
|
'sf_badge' => $sf === 'f' ? 'badge--bad' : 'badge--ok',
|
||||||
|
|
||||||
|
'conn_label' => $conn === '2' ? 'M' : 'PC',
|
||||||
|
'conn_badge' => $conn === '2' ? 'badge--mvno' : 'badge--muted',
|
||||||
|
|
||||||
|
'mem_no_int' => (int)($r['mem_no'] ?? 0),
|
||||||
|
'mem_link' => ((int)($r['mem_no'] ?? 0) > 0) ? ('/members/' . (int)$r['mem_no']) : null,
|
||||||
|
|
||||||
|
// 실패가 아니면 에러코드 화면에서 비워도 됨(뷰에서 처리)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'years' => $years,
|
||||||
|
'filters' => $filters,
|
||||||
|
'page' => $page,
|
||||||
|
'items' => $items,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function availableYears(int $from, int $to): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
for ($y = $from; $y <= $to; $y++) {
|
||||||
|
$t = 'mem_login_' . $y;
|
||||||
|
if (Schema::hasTable($t)) $out[] = $y;
|
||||||
|
}
|
||||||
|
// 없으면 그냥 범위라도 반환(운영/개발에서 초기 셋업 대비)
|
||||||
|
return !empty($out) ? $out : [$to];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function safeStr(mixed $v, int $max): string
|
||||||
|
{
|
||||||
|
$s = trim((string)$v);
|
||||||
|
if ($s === '') return '';
|
||||||
|
if (mb_strlen($s) > $max) $s = mb_substr($s, 0, $max);
|
||||||
|
return $s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function safeEnum(mixed $v, array $allowed): string
|
||||||
|
{
|
||||||
|
$s = trim((string)$v);
|
||||||
|
return in_array($s, $allowed, true) ? $s : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function safeInt(mixed $v): ?int
|
||||||
|
{
|
||||||
|
if ($v === null || $v === '') return null;
|
||||||
|
if (!is_numeric($v)) return null;
|
||||||
|
$n = (int)$v;
|
||||||
|
return $n >= 0 ? $n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function safeDate(mixed $v): ?string
|
||||||
|
{
|
||||||
|
$s = trim((string)$v);
|
||||||
|
if ($s === '') return null;
|
||||||
|
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $s) ? $s : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function safeIpPrefix(mixed $v): string
|
||||||
|
{
|
||||||
|
$s = trim((string)$v);
|
||||||
|
if ($s === '') return '';
|
||||||
|
return preg_match('/^[0-9.]+$/', $s) ? $s : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
162
app/Services/Admin/Log/MemberPasswdModifyLogService.php
Normal file
162
app/Services/Admin/Log/MemberPasswdModifyLogService.php
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Admin\Log;
|
||||||
|
|
||||||
|
use App\Repositories\Admin\Log\MemberPasswdModifyLogRepository;
|
||||||
|
|
||||||
|
final class MemberPasswdModifyLogService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly MemberPasswdModifyLogRepository $repo,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function indexData(array $query = []): array
|
||||||
|
{
|
||||||
|
$filters = [
|
||||||
|
'date_from' => $this->safeDate($query['date_from'] ?? ''),
|
||||||
|
'date_to' => $this->safeDate($query['date_to'] ?? ''),
|
||||||
|
|
||||||
|
'state' => $this->safeEnum($query['state'] ?? '', ['S','E']), // S 직접, E 찾기
|
||||||
|
'mem_no' => $this->safeInt($query['mem_no'] ?? null),
|
||||||
|
|
||||||
|
'type' => $this->safeStr($query['type'] ?? '', 40), // 신포맷 type
|
||||||
|
'email' => $this->safeStr($query['email'] ?? '', 80), // 구포맷 user_email
|
||||||
|
'ip' => $this->safeIpPrefix($query['ip'] ?? ''), // remote_addr or auth_key contains
|
||||||
|
'q' => $this->safeStr($query['q'] ?? '', 120), // info 전체 like
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($filters['date_from'] && $filters['date_to']) {
|
||||||
|
if (strcmp($filters['date_from'], $filters['date_to']) > 0) {
|
||||||
|
[$filters['date_from'], $filters['date_to']] = [$filters['date_to'], $filters['date_from']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$page = $this->repo->paginate($filters, 30);
|
||||||
|
|
||||||
|
$items = [];
|
||||||
|
foreach ($page->items() as $it) {
|
||||||
|
$r = is_array($it) ? $it : (array)$it;
|
||||||
|
|
||||||
|
$infoRaw = (string)($r['info'] ?? '');
|
||||||
|
$infoArr = $this->decodeJson($infoRaw);
|
||||||
|
|
||||||
|
$state = (string)($r['state'] ?? '');
|
||||||
|
$memNo = (int)($infoArr['mem_no'] ?? 0);
|
||||||
|
|
||||||
|
$type = trim((string)($infoArr['type'] ?? ''));
|
||||||
|
$eventType = $type !== '' ? $type : ($state === 'E' ? '비밀번호찾기 변경' : '비밀번호변경');
|
||||||
|
|
||||||
|
$eventTime = trim((string)($infoArr['redate'] ?? ''));
|
||||||
|
if ($eventTime === '') $eventTime = (string)($r['rgdate'] ?? '');
|
||||||
|
|
||||||
|
$ip = trim((string)($infoArr['remote_addr'] ?? ''));
|
||||||
|
if ($ip === '') {
|
||||||
|
$ip = $this->extractIpFromAuthKey((string)($infoArr['auth_key'] ?? '')) ?: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$email = trim((string)($infoArr['user_email'] ?? ''));
|
||||||
|
if ($email === '') $email = trim((string)($infoArr['email'] ?? ''));
|
||||||
|
|
||||||
|
$agent = trim((string)($infoArr['agent'] ?? ''));
|
||||||
|
|
||||||
|
$items[] = array_merge($r, [
|
||||||
|
'info_arr' => $infoArr,
|
||||||
|
|
||||||
|
'mem_no_int' => $memNo,
|
||||||
|
'mem_link' => $memNo > 0 ? ('/members/' . $memNo) : null,
|
||||||
|
|
||||||
|
'state_label' => $state === 'E' ? '비번찾기' : '직접변경',
|
||||||
|
'state_badge' => $state === 'E' ? 'badge--warn' : 'badge--ok',
|
||||||
|
|
||||||
|
'event_type' => $eventType,
|
||||||
|
'event_time' => $eventTime,
|
||||||
|
|
||||||
|
'ip_norm' => $ip,
|
||||||
|
'email_norm' => $email,
|
||||||
|
'agent_norm' => $agent,
|
||||||
|
|
||||||
|
// 펼침용(구포맷 핵심 필드)
|
||||||
|
'auth_key' => (string)($infoArr['auth_key'] ?? ''),
|
||||||
|
'auth_effective_time' => (string)($infoArr['auth_effective_time'] ?? ''),
|
||||||
|
|
||||||
|
// raw pretty
|
||||||
|
'info_pretty' => $this->prettyJson($infoArr, $infoRaw),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'filters' => $filters,
|
||||||
|
'page' => $page,
|
||||||
|
'items' => $items,
|
||||||
|
|
||||||
|
'stateMap' => [
|
||||||
|
'S' => '직접변경',
|
||||||
|
'E' => '비번찾기',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function decodeJson(string $raw): array
|
||||||
|
{
|
||||||
|
$raw = trim($raw);
|
||||||
|
if ($raw === '') return [];
|
||||||
|
$arr = json_decode($raw, true);
|
||||||
|
return is_array($arr) ? $arr : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function prettyJson(array $arr, string $fallbackRaw): string
|
||||||
|
{
|
||||||
|
if (!empty($arr)) {
|
||||||
|
return (string)json_encode($arr, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||||
|
}
|
||||||
|
return $fallbackRaw;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractIpFromAuthKey(string $authKey): ?string
|
||||||
|
{
|
||||||
|
$authKey = trim($authKey);
|
||||||
|
if ($authKey === '') return null;
|
||||||
|
|
||||||
|
// auth_key 끝에 "1.85.101.111" 같은 IP가 붙는 케이스 대응 (마지막 매치 사용)
|
||||||
|
if (preg_match_all('/(\d{1,3}(?:\.\d{1,3}){3})/', $authKey, $m) && !empty($m[1])) {
|
||||||
|
return (string)end($m[1]);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function safeStr(mixed $v, int $max): string
|
||||||
|
{
|
||||||
|
$s = trim((string)$v);
|
||||||
|
if ($s === '') return '';
|
||||||
|
if (mb_strlen($s) > $max) $s = mb_substr($s, 0, $max);
|
||||||
|
return $s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function safeEnum(mixed $v, array $allowed): string
|
||||||
|
{
|
||||||
|
$s = trim((string)$v);
|
||||||
|
return in_array($s, $allowed, true) ? $s : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function safeInt(mixed $v): ?int
|
||||||
|
{
|
||||||
|
if ($v === null || $v === '') return null;
|
||||||
|
if (!is_numeric($v)) return null;
|
||||||
|
$n = (int)$v;
|
||||||
|
return $n >= 0 ? $n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function safeDate(mixed $v): ?string
|
||||||
|
{
|
||||||
|
$s = trim((string)$v);
|
||||||
|
if ($s === '') return null;
|
||||||
|
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $s) ? $s : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function safeIpPrefix(mixed $v): string
|
||||||
|
{
|
||||||
|
$s = trim((string)$v);
|
||||||
|
if ($s === '') return '';
|
||||||
|
return preg_match('/^[0-9.]+$/', $s) ? $s : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -228,6 +228,7 @@ class FindPasswordService
|
|||||||
$this->members->updatePasswordOnly($memNo, $newPassword);
|
$this->members->updatePasswordOnly($memNo, $newPassword);
|
||||||
$this->members->logPasswordResetSuccess(
|
$this->members->logPasswordResetSuccess(
|
||||||
$memNo,
|
$memNo,
|
||||||
|
$member->email,
|
||||||
(string) $request->ip(),
|
(string) $request->ip(),
|
||||||
(string) $request->userAgent(),
|
(string) $request->userAgent(),
|
||||||
'E'
|
'E'
|
||||||
|
|||||||
306
docs/product_table/pfy_phase1_schema.md
Normal file
306
docs/product_table/pfy_phase1_schema.md
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
# Phase 1 DB 스키마 (상품등록/전시 + SKU + 판매채널 + 결제수단 + 자사핀재고 + 이미지)
|
||||||
|
|
||||||
|
- 신규 테이블은 모두 `pfy_` 접두사 사용
|
||||||
|
- MariaDB (InnoDB, `utf8mb4`) 기준
|
||||||
|
- 레거시/기존 회원/관리자 테이블과 FK는 **1단계에서는 걸지 않음(컬럼만 준비)**
|
||||||
|
→ 2단계(주문/결제)부터 FK/연동 강화
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1) `pfy_categories` : 1차/2차 카테고리 트리
|
||||||
|
|
||||||
|
- `parent_id` 로 1차/2차 구성
|
||||||
|
- `sort` / `is_active` 로 전시 제어
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS pfy_categories (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'PK',
|
||||||
|
parent_id BIGINT UNSIGNED NULL COMMENT '상위 카테고리 ID (1차는 NULL, 2차는 1차의 id)',
|
||||||
|
name VARCHAR(100) NOT NULL COMMENT '카테고리명',
|
||||||
|
slug VARCHAR(120) NOT NULL COMMENT 'URL/식별용 슬러그(유니크 권장)',
|
||||||
|
sort INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '정렬값(작을수록 먼저)',
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '노출 여부(1=노출,0=숨김)',
|
||||||
|
icon_path VARCHAR(255) NULL COMMENT '아이콘 경로(선택)',
|
||||||
|
banner_path VARCHAR(255) NULL COMMENT '배너 경로(선택)',
|
||||||
|
desc_short VARCHAR(255) NULL COMMENT '카테고리 짧은 설명(선택)',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_pfy_categories_slug (slug),
|
||||||
|
KEY idx_pfy_categories_parent_sort (parent_id, sort)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='[PFY] 카테고리(1차/2차 트리)';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) `pfy_media_files` : 업로드된 파일(상품 이미지 등) 메타데이터
|
||||||
|
|
||||||
|
- 업로드된 이미지를 “선택”해서 상품에 연결하는 용도
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS pfy_media_files (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'PK',
|
||||||
|
kind ENUM('image','file') NOT NULL DEFAULT 'image' COMMENT '파일 종류',
|
||||||
|
disk VARCHAR(40) NOT NULL DEFAULT 'public' COMMENT '스토리지 디스크명(Laravel disks)',
|
||||||
|
path VARCHAR(500) NOT NULL COMMENT '스토리지 상대 경로',
|
||||||
|
original_name VARCHAR(255) NULL COMMENT '원본 파일명',
|
||||||
|
mime VARCHAR(120) NULL COMMENT 'MIME 타입',
|
||||||
|
size_bytes BIGINT UNSIGNED NULL COMMENT '파일 크기(bytes)',
|
||||||
|
width INT UNSIGNED NULL COMMENT '이미지 폭(px, 이미지인 경우)',
|
||||||
|
height INT UNSIGNED NULL COMMENT '이미지 높이(px, 이미지인 경우)',
|
||||||
|
checksum_sha256 CHAR(64) NULL COMMENT '파일 무결성 체크섬(선택)',
|
||||||
|
uploaded_by_admin_id BIGINT UNSIGNED NULL COMMENT '업로드 관리자 ID(레거시/기존 admin_users 연동 예정)',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_pfy_media_files_kind (kind),
|
||||||
|
KEY idx_pfy_media_files_created (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='[PFY] 업로드 파일 메타(상품이미지 선택/재사용)';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) `pfy_products` : 상품(전시 단위)
|
||||||
|
|
||||||
|
- 상품명/타입/판매기간/상태/대표이미지/매입가능 여부
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS pfy_products (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'PK',
|
||||||
|
category_id BIGINT UNSIGNED NOT NULL COMMENT '2차 카테고리 ID(pfy_categories.id)',
|
||||||
|
name VARCHAR(160) NOT NULL COMMENT '상품명(수기)',
|
||||||
|
type ENUM('online','delivery') NOT NULL DEFAULT 'online' COMMENT '상품타입(온라인/배송)',
|
||||||
|
is_buyback_allowed ENUM('Y','N') NOT NULL DEFAULT 'Y' COMMENT '매입가능여부(Y=가능,N=불가)',
|
||||||
|
sale_period_type ENUM('always','ranged') NOT NULL DEFAULT 'always' COMMENT '판매기간 타입(상시/기간설정)',
|
||||||
|
sale_start_at DATETIME NULL COMMENT '판매 시작일(기간설정일 때)',
|
||||||
|
sale_end_at DATETIME NULL COMMENT '판매 종료일(기간설정일 때)',
|
||||||
|
status ENUM('active','hidden','soldout') NOT NULL DEFAULT 'active' COMMENT '노출상태(노출/숨김/품절)',
|
||||||
|
main_image_id BIGINT UNSIGNED NULL COMMENT '대표 이미지(pfy_media_files.id)',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_pfy_products_category (category_id),
|
||||||
|
KEY idx_pfy_products_status (status),
|
||||||
|
KEY idx_pfy_products_sale_period (sale_period_type, sale_start_at, sale_end_at),
|
||||||
|
CONSTRAINT fk_pfy_products_category
|
||||||
|
FOREIGN KEY (category_id) REFERENCES pfy_categories(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_pfy_products_main_image
|
||||||
|
FOREIGN KEY (main_image_id) REFERENCES pfy_media_files(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='[PFY] 상품(전시 단위)';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) `pfy_product_contents` : 상품 상세 에디터 3종(1:1)
|
||||||
|
|
||||||
|
- 상세설명/이용안내/주의사항 HTML 저장
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS pfy_product_contents (
|
||||||
|
product_id BIGINT UNSIGNED NOT NULL COMMENT 'PK & FK(pfy_products.id)',
|
||||||
|
detail_html LONGTEXT NULL COMMENT '상세설명(HTML)',
|
||||||
|
guide_html LONGTEXT NULL COMMENT '이용안내(HTML)',
|
||||||
|
caution_html LONGTEXT NULL COMMENT '주의사항(HTML)',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일',
|
||||||
|
PRIMARY KEY (product_id),
|
||||||
|
CONSTRAINT fk_pfy_product_contents_product
|
||||||
|
FOREIGN KEY (product_id) REFERENCES pfy_products(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='[PFY] 상품 에디터 컨텐츠(상세/안내/주의)';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) `pfy_product_skus` : 권종/가격 단위(SKU)
|
||||||
|
|
||||||
|
- 정상가/할인율/판매가(스냅샷 계산 저장 추천)
|
||||||
|
- 재고는 `stock_mode` 로 (연동판매는 `infinite`, 자사핀은 `limited`)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS pfy_product_skus (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'PK',
|
||||||
|
product_id BIGINT UNSIGNED NOT NULL COMMENT '상품 ID(pfy_products.id)',
|
||||||
|
denomination INT UNSIGNED NOT NULL COMMENT '권종 금액(예: 10000, 50000)',
|
||||||
|
normal_price INT UNSIGNED NOT NULL COMMENT '정상가(원)',
|
||||||
|
discount_rate DECIMAL(5,2) NOT NULL DEFAULT 0.00 COMMENT '할인율(%)',
|
||||||
|
sale_price INT UNSIGNED NOT NULL COMMENT '판매가(원) - 운영/정산 안정 위해 계산 후 저장 권장',
|
||||||
|
stock_mode ENUM('infinite','limited') NOT NULL DEFAULT 'infinite' COMMENT '재고방식(무한/한정)',
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '사용여부(1=사용,0=중지)',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_pfy_skus_product (product_id),
|
||||||
|
KEY idx_pfy_skus_active (product_id, is_active),
|
||||||
|
KEY idx_pfy_skus_denomination (product_id, denomination),
|
||||||
|
CONSTRAINT fk_pfy_skus_product
|
||||||
|
FOREIGN KEY (product_id) REFERENCES pfy_products(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='[PFY] 상품 SKU(권종/가격 단위)';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) `pfy_sku_sale_channels` : SKU 판매방식/연동채널 (기본: 1 SKU = 1 판매채널)
|
||||||
|
|
||||||
|
### `sale_mode`
|
||||||
|
- `SELF_PIN` : 자사핀 재고 판매
|
||||||
|
- `VENDOR_API_SHOW` : 연동발행 후 우리 사이트에서 핀 표시
|
||||||
|
- `VENDOR_SMS` : 업체가 SMS로 즉시 발송(핀 저장 최소화 가능)
|
||||||
|
|
||||||
|
### `vendor`
|
||||||
|
- `DANAL` / `KORCULTURE` / `KPREPAID` (필요 시 확장)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS pfy_sku_sale_channels (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'PK',
|
||||||
|
sku_id BIGINT UNSIGNED NOT NULL COMMENT 'SKU ID(pfy_product_skus.id)',
|
||||||
|
sale_mode ENUM('SELF_PIN','VENDOR_API_SHOW','VENDOR_SMS') NOT NULL COMMENT '판매모드',
|
||||||
|
vendor ENUM('NONE','DANAL','KORCULTURE','KPREPAID') NOT NULL DEFAULT 'NONE' COMMENT '연동업체(없으면 NONE)',
|
||||||
|
vendor_product_code VARCHAR(40) NULL COMMENT '업체 상품코드(예: DANAL CULTURE, KPREPAID 1231 등)',
|
||||||
|
pg ENUM('NONE','DANAL') NOT NULL DEFAULT 'DANAL' COMMENT '결제 PG(현재 DANAL, 추후 확장)',
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '사용여부(1=사용,0=중지)',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_pfy_sale_channels_sku (sku_id),
|
||||||
|
KEY idx_pfy_sale_channels_vendor (vendor, vendor_product_code),
|
||||||
|
CONSTRAINT fk_pfy_sale_channels_sku
|
||||||
|
FOREIGN KEY (sku_id) REFERENCES pfy_product_skus(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='[PFY] SKU 판매채널/연동 설정';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) `pfy_payment_methods` : 결제수단 마스터
|
||||||
|
|
||||||
|
- `code` 를 PK로 사용(안정적, pivot에 쓰기 편함)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS pfy_payment_methods (
|
||||||
|
code VARCHAR(30) NOT NULL COMMENT '결제수단 코드(PK)',
|
||||||
|
label VARCHAR(60) NOT NULL COMMENT '표시명(관리자/회원)',
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '사용여부',
|
||||||
|
sort INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '노출 정렬',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일',
|
||||||
|
PRIMARY KEY (code),
|
||||||
|
KEY idx_pfy_payment_methods_active (is_active, sort)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='[PFY] 결제수단 마스터';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) `pfy_sku_payment_methods` : SKU별 허용 결제수단(pivot)
|
||||||
|
|
||||||
|
- SKU마다 가능한 결제수단 체크박스 용도
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS pfy_sku_payment_methods (
|
||||||
|
sku_id BIGINT UNSIGNED NOT NULL COMMENT 'SKU ID(pfy_product_skus.id)',
|
||||||
|
payment_method_code VARCHAR(30) NOT NULL COMMENT '결제수단 코드(pfy_payment_methods.code)',
|
||||||
|
is_enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '허용 여부(1=허용,0=비허용)',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일',
|
||||||
|
PRIMARY KEY (sku_id, payment_method_code),
|
||||||
|
KEY idx_pfy_sku_pm_method (payment_method_code, is_enabled),
|
||||||
|
CONSTRAINT fk_pfy_sku_pm_sku
|
||||||
|
FOREIGN KEY (sku_id) REFERENCES pfy_product_skus(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_pfy_sku_pm_method
|
||||||
|
FOREIGN KEY (payment_method_code) REFERENCES pfy_payment_methods(code)
|
||||||
|
ON UPDATE CASCADE ON DELETE RESTRICT
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='[PFY] SKU별 허용 결제수단 매핑';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9) `pfy_pin_batches` : 자사핀 입력 작업 단위(업로드/수기 등록)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS pfy_pin_batches (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'PK',
|
||||||
|
sku_id BIGINT UNSIGNED NOT NULL COMMENT 'SKU ID(pfy_product_skus.id)',
|
||||||
|
source_type ENUM('upload','manual') NOT NULL COMMENT '핀 등록 방식(파일/수기)',
|
||||||
|
original_filename VARCHAR(255) NULL COMMENT '업로드 파일명(업로드일 때)',
|
||||||
|
total_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '배치 총 핀 개수',
|
||||||
|
uploaded_by_admin_id BIGINT UNSIGNED NULL COMMENT '업로드 관리자 ID(레거시/기존 admin_users 연동 예정)',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_pfy_pin_batches_sku (sku_id, created_at),
|
||||||
|
CONSTRAINT fk_pfy_pin_batches_sku
|
||||||
|
FOREIGN KEY (sku_id) REFERENCES pfy_product_skus(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE RESTRICT
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='[PFY] 자사핀 입력 배치(업로드/수기)';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10) `pfy_pin_items` : 핀 1건 단위 재고(암호화 저장)
|
||||||
|
|
||||||
|
- `pin_enc` : 쌍방향 암호화 저장값(복호화는 권한 통과한 구매자만)
|
||||||
|
- `pin_fingerprint` : 중복핀 방지(유니크)
|
||||||
|
|
||||||
|
### `status`
|
||||||
|
- `available` : 사용가능(재고)
|
||||||
|
- `reserved` : 결제 대기/재고 예약
|
||||||
|
- `sold` : 판매 완료(주문 라인에 귀속될 예정)
|
||||||
|
- `revoked` : 폐기/무효
|
||||||
|
- `recalled` : 회수(미사용 회수 프로세스에서 사용)
|
||||||
|
|
||||||
|
> 1단계에서는 주문 테이블이 없으므로 `reserved_order_id`, `sold_order_item_id` 는 FK 없이 컬럼만 준비
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS pfy_pin_items (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'PK',
|
||||||
|
batch_id BIGINT UNSIGNED NOT NULL COMMENT '배치 ID(pfy_pin_batches.id)',
|
||||||
|
sku_id BIGINT UNSIGNED NOT NULL COMMENT 'SKU ID(pfy_product_skus.id)',
|
||||||
|
pin_enc TEXT NOT NULL COMMENT '핀 암호문(쌍방향 암호화 결과)',
|
||||||
|
pin_fingerprint CHAR(64) NOT NULL COMMENT '중복 방지 해시(sha256 등) - UNIQUE',
|
||||||
|
status ENUM('available','reserved','sold','revoked','recalled') NOT NULL DEFAULT 'available' COMMENT '재고 상태',
|
||||||
|
reserved_order_id BIGINT UNSIGNED NULL COMMENT '예약된 주문 ID(2단계에서 orders FK 예정)',
|
||||||
|
reserved_until DATETIME NULL COMMENT '예약 만료 시각(스케줄러로 자동 해제)',
|
||||||
|
sold_order_item_id BIGINT UNSIGNED NULL COMMENT '판매된 주문아이템 ID(2단계에서 order_items FK 예정)',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성일',
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일',
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_pfy_pin_items_fingerprint (pin_fingerprint),
|
||||||
|
KEY idx_pfy_pin_items_sku_status (sku_id, status),
|
||||||
|
KEY idx_pfy_pin_items_batch (batch_id),
|
||||||
|
KEY idx_pfy_pin_items_reserved_until (status, reserved_until),
|
||||||
|
CONSTRAINT fk_pfy_pin_items_batch
|
||||||
|
FOREIGN KEY (batch_id) REFERENCES pfy_pin_batches(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE RESTRICT,
|
||||||
|
CONSTRAINT fk_pfy_pin_items_sku
|
||||||
|
FOREIGN KEY (sku_id) REFERENCES pfy_product_skus(id)
|
||||||
|
ON UPDATE CASCADE ON DELETE RESTRICT
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='[PFY] 자사핀 재고(암호화 저장)';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## (선택) 결제수단 초기 데이터 예시
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO pfy_payment_methods(code,label,sort) VALUES
|
||||||
|
('CARD_GENERAL','신용카드(일반)',10),
|
||||||
|
('CARD_CASHBACK','신용카드(환급성)',11),
|
||||||
|
('MOBILE','휴대폰',20),
|
||||||
|
('VBANK','무통장입금',30),
|
||||||
|
('KBANKPAY','케이뱅크페이',40),
|
||||||
|
('PAYCOIN','페이코인',50);
|
||||||
|
```
|
||||||
281
resources/views/admin/log/AdminAuditLogController.blade.php
Normal file
281
resources/views/admin/log/AdminAuditLogController.blade.php
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
{{-- resources/views/admin/log/AdminAuditLogController.blade.php --}}
|
||||||
|
@extends('admin.layouts.app')
|
||||||
|
|
||||||
|
@section('title', '관리자 감사 로그')
|
||||||
|
@section('page_title', '관리자 감사 로그')
|
||||||
|
@section('page_desc', '관리자 행위 감사로그를 조회합니다. (상세는 모달)')
|
||||||
|
@section('content_class', 'a-content--full')
|
||||||
|
|
||||||
|
@push('head')
|
||||||
|
<style>
|
||||||
|
.bar{display:flex;justify-content:space-between;align-items:flex-end;gap:12px;flex-wrap:wrap;}
|
||||||
|
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;}
|
||||||
|
.filters .inp{width:170px;}
|
||||||
|
.filters .inpWide{width:220px;}
|
||||||
|
|
||||||
|
.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;}
|
||||||
|
.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;display:inline-block;}
|
||||||
|
.muted{opacity:.8;font-size:12px;}
|
||||||
|
.nowrap{white-space:nowrap;}
|
||||||
|
.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);}
|
||||||
|
|
||||||
|
/* modal */
|
||||||
|
.mback{position:fixed;inset:0;display:none;align-items:center;justify-content:center;z-index:9999;
|
||||||
|
background:rgba(0,0,0,.55);backdrop-filter:blur(2px);}
|
||||||
|
.mback.is-open{display:flex;}
|
||||||
|
.modalx{width:min(1100px, 94vw);max-height:88vh;overflow:auto;border-radius:18px;
|
||||||
|
border:1px solid rgba(255,255,255,.12);background:rgba(18,18,18,.96);box-shadow:0 20px 60px rgba(0,0,0,.45);}
|
||||||
|
.modalx__head{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;padding:16px 16px 10px;border-bottom:1px solid rgba(255,255,255,.10);}
|
||||||
|
.modalx__title{font-weight:900;font-size:16px;}
|
||||||
|
.modalx__desc{font-size:12px;opacity:.8;margin-top:4px;}
|
||||||
|
.modalx__body{padding:16px;}
|
||||||
|
.modalx__foot{display:flex;justify-content:flex-end;gap:8px;padding:12px 16px;border-top:1px solid rgba(255,255,255,.10);}
|
||||||
|
|
||||||
|
.infoGrid{display:grid;grid-template-columns:1fr;gap:10px;margin-bottom:12px;}
|
||||||
|
@media (min-width: 920px){ .infoGrid{grid-template-columns:1fr 1fr;} }
|
||||||
|
.ibox{border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.03);border-radius:14px;padding:10px;}
|
||||||
|
.ibox .k{font-size:12px;opacity:.75;margin-bottom:4px;}
|
||||||
|
.ibox .v{font-size:13px;word-break:break-word;}
|
||||||
|
|
||||||
|
.jsonGrid{display:grid;grid-template-columns:1fr;gap:12px;}
|
||||||
|
@media (min-width: 1000px){ .jsonGrid{grid-template-columns:1fr 1fr;} }
|
||||||
|
.prebox{border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.03);border-radius:14px;padding:10px;overflow:auto;}
|
||||||
|
.prebox .k{font-size:12px;opacity:.75;margin-bottom:8px;font-weight:800;}
|
||||||
|
pre{margin:0;white-space:pre; font-size:12px; line-height:1.5;
|
||||||
|
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;}
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
@php
|
||||||
|
$indexUrl = route('admin.systemlog.admin-audit-logs', [], false);
|
||||||
|
$showTpl = route('admin.systemlog.admin-audit-logs.show', ['id' => '__ID__'], false);
|
||||||
|
|
||||||
|
$f = $filters ?? [];
|
||||||
|
$dateFrom = (string)($f['date_from'] ?? '');
|
||||||
|
$dateTo = (string)($f['date_to'] ?? '');
|
||||||
|
$actorQ = (string)($f['actor_q'] ?? '');
|
||||||
|
$action = (string)($f['action'] ?? '');
|
||||||
|
$tt = (string)($f['target_type'] ?? '');
|
||||||
|
$ip = (string)($f['ip'] ?? '');
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="a-card" style="padding:16px; margin-bottom:16px;">
|
||||||
|
<div class="bar">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:900;font-size:16px;">관리자 감사 로그</div>
|
||||||
|
<div class="muted" style="margin-top:4px;">검색/페이징 지원 · 상세는 AJAX 모달</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="GET" action="{{ $indexUrl }}" class="filters">
|
||||||
|
<div>
|
||||||
|
<label class="muted">From</label>
|
||||||
|
<input class="a-input inp" type="date" name="date_from" value="{{ $dateFrom }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="muted">To</label>
|
||||||
|
<input class="a-input inp" type="date" name="date_to" value="{{ $dateTo }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="muted">Actor(이메일/이름)</label>
|
||||||
|
<input class="a-input inpWide" name="actor_q" value="{{ $actorQ }}" placeholder="ex) sungro / gmail">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="muted">action</label>
|
||||||
|
<input class="a-input inp" name="action" value="{{ $action }}" placeholder="ex) admin_user_update">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="muted">target_type</label>
|
||||||
|
<input class="a-input inp" name="target_type" value="{{ $tt }}" placeholder="ex) admin_users">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="muted">ip</label>
|
||||||
|
<input class="a-input inp" name="ip" value="{{ $ip }}" placeholder="ex) 210.96.">
|
||||||
|
</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="{{ $indexUrl }}">초기화</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</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:1050px;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:90px;">ID</th>
|
||||||
|
<th style="width:260px;">Actor</th>
|
||||||
|
<th style="width:220px;">Action</th>
|
||||||
|
<th style="width:200px;">Target Type</th>
|
||||||
|
<th style="width:180px;">IP</th>
|
||||||
|
<th style="width:190px;">Created</th>
|
||||||
|
<th style="width:120px;">상세</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse(($items ?? []) as $r0)
|
||||||
|
@php
|
||||||
|
$r = is_array($r0) ? $r0 : (array)$r0;
|
||||||
|
$id = (int)($r['id'] ?? 0);
|
||||||
|
$aEmail = (string)($r['actor_email'] ?? '');
|
||||||
|
$aName = (string)($r['actor_name'] ?? '');
|
||||||
|
$actorTxt = trim(($aName !== '' ? $aName : '-') . ($aEmail !== '' ? " ({$aEmail})" : ''));
|
||||||
|
@endphp
|
||||||
|
<tr>
|
||||||
|
<td class="a-muted">{{ $id }}</td>
|
||||||
|
<td>
|
||||||
|
<div style="font-weight:900;">{{ $aName !== '' ? $aName : '-' }}</div>
|
||||||
|
<div class="a-muted" style="font-size:12px;">{{ $aEmail !== '' ? $aEmail : 'admin_users 미조회' }}</div>
|
||||||
|
</td>
|
||||||
|
<td><span class="mono">{{ $r['action'] ?? '-' }}</span></td>
|
||||||
|
<td><span class="mono">{{ $r['target_type'] ?? '-' }}</span></td>
|
||||||
|
<td class="nowrap">{{ $r['ip'] ?? '-' }}</td>
|
||||||
|
<td class="a-muted">{{ $r['created_at'] ?? '-' }}</td>
|
||||||
|
<td style="text-align:right;">
|
||||||
|
<button type="button"
|
||||||
|
class="lbtn lbtn--sm btnAuditDetail"
|
||||||
|
data-id="{{ $id }}">
|
||||||
|
상세보기
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td colspan="7" class="a-muted" style="padding:16px;">데이터가 없습니다.</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:12px;">
|
||||||
|
{{ $page->onEachSide(1)->links('vendor.pagination.admin') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@include('admin.log._audit_log_modal')
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const showTpl = @json($showTpl);
|
||||||
|
|
||||||
|
const modalBack = document.getElementById('auditModalBack');
|
||||||
|
const btnClose = document.getElementById('auditBtnClose');
|
||||||
|
const btnOk = document.getElementById('auditBtnOk');
|
||||||
|
|
||||||
|
const el = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
const setText = (node, text) => {
|
||||||
|
if (!node) return;
|
||||||
|
node.textContent = (text === null || text === undefined || text === '') ? '-' : String(text);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openModal = () => {
|
||||||
|
modalBack.classList.add('is-open');
|
||||||
|
modalBack.setAttribute('aria-hidden', 'false');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
modalBack.classList.remove('is-open');
|
||||||
|
modalBack.setAttribute('aria-hidden', 'true');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
btnClose?.addEventListener('click', closeModal);
|
||||||
|
btnOk?.addEventListener('click', closeModal);
|
||||||
|
modalBack?.addEventListener('click', (e) => { if (e.target === modalBack) closeModal(); });
|
||||||
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && modalBack.classList.contains('is-open')) closeModal(); });
|
||||||
|
|
||||||
|
const fillLoading = () => {
|
||||||
|
setText(el('auditTitle'), '감사로그 상세');
|
||||||
|
setText(el('auditDesc'), '로딩 중...');
|
||||||
|
setText(el('audit_id'), '-');
|
||||||
|
setText(el('audit_actor'), '-');
|
||||||
|
setText(el('audit_action'), '-');
|
||||||
|
setText(el('audit_target'), '-');
|
||||||
|
setText(el('audit_ip'), '-');
|
||||||
|
setText(el('audit_created'), '-');
|
||||||
|
setText(el('audit_ua'), '-');
|
||||||
|
setText(el('audit_before'), '로딩 중...');
|
||||||
|
setText(el('audit_after'), '로딩 중...');
|
||||||
|
};
|
||||||
|
|
||||||
|
const fillItem = (item) => {
|
||||||
|
const actor = `${item.actor_name || '-'} (${item.actor_email || '-'}) [#${item.actor_admin_user_id || 0}]`;
|
||||||
|
|
||||||
|
setText(el('auditTitle'), `감사로그 #${item.id}`);
|
||||||
|
setText(el('auditDesc'), 'before/after JSON과 user_agent를 확인하세요.');
|
||||||
|
|
||||||
|
setText(el('audit_id'), item.id);
|
||||||
|
setText(el('audit_actor'), actor);
|
||||||
|
setText(el('audit_action'), item.action);
|
||||||
|
setText(el('audit_target'), `${item.target_type} #${item.target_id}`);
|
||||||
|
setText(el('audit_ip'), item.ip);
|
||||||
|
setText(el('audit_created'), item.created_at);
|
||||||
|
|
||||||
|
setText(el('audit_ua'), item.user_agent);
|
||||||
|
setText(el('audit_before'), item.before_pretty);
|
||||||
|
setText(el('audit_after'), item.after_pretty);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadDetail(id){
|
||||||
|
const url = showTpl.replace('__ID__', encodeURIComponent(id));
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401 || res.status === 419) throw new Error('AUTH');
|
||||||
|
if (res.status === 403) throw new Error('FORBIDDEN');
|
||||||
|
if (!res.ok) throw new Error('HTTP_' + res.status);
|
||||||
|
|
||||||
|
const ct = (res.headers.get('content-type') || '').toLowerCase();
|
||||||
|
if (!ct.includes('application/json')) throw new Error('NOT_JSON');
|
||||||
|
|
||||||
|
const payload = await res.json();
|
||||||
|
if (!payload?.ok || !payload?.item) throw new Error('NOT_FOUND');
|
||||||
|
return payload.item;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.btnAuditDetail').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const id = btn.getAttribute('data-id');
|
||||||
|
if (!id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
btn.disabled = true;
|
||||||
|
fillLoading();
|
||||||
|
openModal();
|
||||||
|
|
||||||
|
const item = await loadDetail(id);
|
||||||
|
fillItem(item);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
if (String(e.message) === 'AUTH') alert('세션이 만료되었습니다. 다시 로그인해 주세요.');
|
||||||
|
else if (String(e.message) === 'FORBIDDEN') alert('권한이 없습니다. (super_admin만 접근)');
|
||||||
|
else if (String(e.message) === 'NOT_JSON') alert('응답이 JSON이 아닙니다. (프록시/APP_URL/리다이렉트 가능)');
|
||||||
|
else alert('상세 정보를 불러오지 못했습니다.');
|
||||||
|
closeModal();
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
@endsection
|
||||||
380
resources/views/admin/log/MemberAccountLogController.blade.php
Normal file
380
resources/views/admin/log/MemberAccountLogController.blade.php
Normal file
@ -0,0 +1,380 @@
|
|||||||
|
@extends('admin.layouts.app')
|
||||||
|
|
||||||
|
@section('title', '계좌 성명인증 로그')
|
||||||
|
@section('page_title', '계좌 성명인증 로그')
|
||||||
|
@section('page_desc', '더즌 성명인증 요청/결과 로그를 조회합니다.')
|
||||||
|
@section('content_class', 'a-content--full')
|
||||||
|
|
||||||
|
@push('head')
|
||||||
|
<style>
|
||||||
|
.bar{display:flex;justify-content:space-between;align-items:flex-end;gap:12px;flex-wrap:wrap;}
|
||||||
|
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;}
|
||||||
|
.filters .inp{width:160px;}
|
||||||
|
.filters .inpWide{width:220px;}
|
||||||
|
.filters .sel{width:140px;}
|
||||||
|
|
||||||
|
.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;}
|
||||||
|
|
||||||
|
.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;display:inline-block;}
|
||||||
|
.muted{opacity:.8;font-size:12px;}
|
||||||
|
.nowrap{white-space:nowrap;}
|
||||||
|
.ellipsis{max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;vertical-align:bottom;}
|
||||||
|
|
||||||
|
.badge{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);}
|
||||||
|
.badge--ok{border-color:rgba(34,197,94,.35);background:rgba(34,197,94,.10);}
|
||||||
|
.badge--bad{border-color:rgba(244,63,94,.35);background:rgba(244,63,94,.10);}
|
||||||
|
|
||||||
|
a.memlink{color:#fff;text-decoration:none;}
|
||||||
|
a.memlink:hover{color:#fff;text-decoration:underline;}
|
||||||
|
|
||||||
|
/* modal */
|
||||||
|
.mback{position:fixed;inset:0;display:none;align-items:center;justify-content:center;z-index:9999;
|
||||||
|
background:rgba(0,0,0,.55);backdrop-filter:blur(2px);}
|
||||||
|
.mback.is-open{display:flex;}
|
||||||
|
.modalx{width:min(980px, 92vw);max-height:88vh;overflow:hidden;border-radius:18px;
|
||||||
|
border:1px solid rgba(255,255,255,.12);background:rgba(18,18,18,.96);box-shadow:0 20px 60px rgba(0,0,0,.45);}
|
||||||
|
.modalx__head{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;padding:16px 16px 10px;border-bottom:1px solid rgba(255,255,255,.10);}
|
||||||
|
.modalx__title{font-weight:900;font-size:16px;}
|
||||||
|
.modalx__desc{font-size:12px;opacity:.8;margin-top:4px;}
|
||||||
|
.modalx__body{padding:16px;overflow:auto;max-height:70vh;}
|
||||||
|
.modalx__foot{display:flex;justify-content:flex-end;gap:8px;padding:12px 16px;border-top:1px solid rgba(255,255,255,.10);}
|
||||||
|
|
||||||
|
.kv{display:grid;grid-template-columns:170px 1fr;gap:8px;margin-bottom:12px;}
|
||||||
|
.kv .k{opacity:.75;font-size:12px;}
|
||||||
|
.kv .v{font-size:12px;word-break:break-all;}
|
||||||
|
.prebox{border:1px solid rgba(255,255,255,.10);border-radius:14px;background:rgba(255,255,255,.03);padding:12px;margin-top:12px;}
|
||||||
|
.prebox .t{font-weight:900;font-size:12px;opacity:.85;margin-bottom:8px;}
|
||||||
|
.prebox pre{margin:0;white-space:pre-wrap;word-break:break-word;font-size:12px;line-height:1.5;
|
||||||
|
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;}
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
@php
|
||||||
|
$indexUrl = route('admin.systemlog.member-account-logs', [], false);
|
||||||
|
|
||||||
|
$f = $filters ?? [];
|
||||||
|
$dateFrom = (string)($f['date_from'] ?? '');
|
||||||
|
$dateTo = (string)($f['date_to'] ?? '');
|
||||||
|
$memNo = (string)($f['mem_no'] ?? '');
|
||||||
|
$bank = (string)($f['bank_code'] ?? '');
|
||||||
|
$status = (string)($f['status'] ?? '');
|
||||||
|
$account = (string)($f['account'] ?? '');
|
||||||
|
$name = (string)($f['name'] ?? '');
|
||||||
|
$q = (string)($f['q'] ?? '');
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="a-card" style="padding:16px; margin-bottom:16px;">
|
||||||
|
<div class="bar">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:900;font-size:16px;">계좌 성명인증 로그</div>
|
||||||
|
<div class="muted" style="margin-top:4px;">요청/결과 JSON은 모달로 표시 (row 높이 고정)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="GET" action="{{ $indexUrl }}" class="filters">
|
||||||
|
<div>
|
||||||
|
<label class="muted">From</label>
|
||||||
|
<input class="a-input inp" type="date" name="date_from" value="{{ $dateFrom }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="muted">To</label>
|
||||||
|
<input class="a-input inp" type="date" name="date_to" value="{{ $dateTo }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="muted">mem_no</label>
|
||||||
|
<input class="a-input inp" name="mem_no" value="{{ $memNo }}" inputmode="numeric" placeholder="41970">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="muted">bank_code</label>
|
||||||
|
<input class="a-input inp" name="bank_code" value="{{ $bank }}" placeholder="003">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="muted">status</label>
|
||||||
|
<select class="a-input sel" name="status">
|
||||||
|
<option value="">전체</option>
|
||||||
|
<option value="ok" {{ $status==='ok'?'selected':'' }}>성공(200)</option>
|
||||||
|
<option value="fail" {{ $status==='fail'?'selected':'' }}>실패(!=200)</option>
|
||||||
|
<option value="200" {{ $status==='200'?'selected':'' }}>200</option>
|
||||||
|
<option value="520" {{ $status==='520'?'selected':'' }}>520</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="muted">account</label>
|
||||||
|
<input class="a-input inpWide" name="account" value="{{ $account }}" placeholder="계좌 일부">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="muted">name</label>
|
||||||
|
<input class="a-input inp" name="name" value="{{ $name }}" placeholder="예금주">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="muted">q(JSON)</label>
|
||||||
|
<input class="a-input inpWide" name="q" value="{{ $q }}" placeholder="error_code / natv_tr_no ...">
|
||||||
|
</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="{{ $indexUrl }}">초기화</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</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:1400px;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:90px;">SEQ</th>
|
||||||
|
<th style="width:190px;">request_time</th>
|
||||||
|
<th style="width:190px;">result_time</th>
|
||||||
|
<th style="width:140px;">mem_no</th>
|
||||||
|
<th style="width:120px;">bank</th>
|
||||||
|
<th style="width:260px;">account</th>
|
||||||
|
<th style="width:180px;">name</th>
|
||||||
|
<th style="width:120px;">protype</th>
|
||||||
|
<th style="width:120px;">status</th>
|
||||||
|
<th style="width:220px;">depositor/error</th>
|
||||||
|
<th style="width:140px;">JSON</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
@forelse(($items ?? []) as $r0)
|
||||||
|
@php
|
||||||
|
$r = is_array($r0) ? $r0 : (array)$r0;
|
||||||
|
$seq = (int)($r['seq'] ?? 0);
|
||||||
|
|
||||||
|
$memNoInt = (int)($r['mem_no'] ?? 0);
|
||||||
|
$memLink = $r['mem_link'] ?? null;
|
||||||
|
|
||||||
|
$bankCode = (string)($r['bank_code'] ?? '');
|
||||||
|
$acctMask = (string)($r['account_masked'] ?? '');
|
||||||
|
$nameV = (string)($r['mam_accountname'] ?? '');
|
||||||
|
$proType = (string)($r['account_protype'] ?? '');
|
||||||
|
|
||||||
|
$statusInt = (int)($r['status_int'] ?? 0);
|
||||||
|
$badge = (string)($r['status_badge'] ?? 'badge--bad');
|
||||||
|
$label = (string)($r['status_label'] ?? '');
|
||||||
|
|
||||||
|
$depositor = (string)($r['depositor'] ?? '');
|
||||||
|
$errCode = (string)($r['error_code'] ?? '');
|
||||||
|
$errMsg = (string)($r['error_message'] ?? '');
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td class="a-muted">{{ $seq }}</td>
|
||||||
|
<td class="a-muted nowrap">{{ $r['request_time'] ?? '-' }}</td>
|
||||||
|
<td class="a-muted nowrap">{{ $r['result_time'] ?? '-' }}</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
@if($memNoInt > 0 && $memLink)
|
||||||
|
<a href="{{ $memLink }}" class="mono memlink" target="_blank" rel="noopener">{{ $memNoInt }}</a>
|
||||||
|
@else
|
||||||
|
<span class="mono">-</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
@if($bankCode !== '')
|
||||||
|
<span class="mono">{{ $bankCode }}</span>
|
||||||
|
@else
|
||||||
|
<span class="a-muted">-</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
@if($acctMask !== '')
|
||||||
|
<span class="mono">{{ $acctMask }}</span>
|
||||||
|
@else
|
||||||
|
<span class="a-muted">-</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
@if($nameV !== '')
|
||||||
|
<span class="mono">{{ $nameV }}</span>
|
||||||
|
@else
|
||||||
|
<span class="a-muted">-</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
@if($proType !== '')
|
||||||
|
<span class="mono">{{ $proType }}</span>
|
||||||
|
@else
|
||||||
|
<span class="a-muted">-</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<span class="badge {{ $badge }}">
|
||||||
|
{{ $label }} ({{ $statusInt }})
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
@if($depositor !== '')
|
||||||
|
<span class="mono ellipsis" title="{{ $depositor }}">{{ $depositor }}</span>
|
||||||
|
@elseif($errCode !== '' || $errMsg !== '')
|
||||||
|
<span class="mono ellipsis" title="{{ trim($errCode.' '.$errMsg) }}">{{ trim($errCode.' '.$errMsg) }}</span>
|
||||||
|
@else
|
||||||
|
<span class="a-muted">-</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="nowrap" style="text-align:right;">
|
||||||
|
<button type="button"
|
||||||
|
class="lbtn lbtn--ghost lbtn--sm btnJson"
|
||||||
|
data-seq="{{ $seq }}"
|
||||||
|
data-mem="{{ $memNoInt }}"
|
||||||
|
data-bank="{{ e($bankCode) }}"
|
||||||
|
data-account="{{ e((string)($r['account'] ?? '')) }}"
|
||||||
|
data-name="{{ e($nameV) }}"
|
||||||
|
data-protype="{{ e($proType) }}"
|
||||||
|
data-status="{{ $statusInt }}"
|
||||||
|
data-err="{{ e(trim($errCode.' '.$errMsg)) }}"
|
||||||
|
data-depositor="{{ e($depositor) }}">
|
||||||
|
JSON 보기
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<textarea id="reqStore-{{ $seq }}" style="display:none;">{{ $r['request_pretty'] ?? ($r['request_data'] ?? '') }}</textarea>
|
||||||
|
<textarea id="resStore-{{ $seq }}" style="display:none;">{{ $r['result_pretty'] ?? ($r['result_data'] ?? '') }}</textarea>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td colspan="11" class="a-muted" style="padding:16px;">데이터가 없습니다.</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:12px;">
|
||||||
|
{{ $page->onEachSide(1)->links('vendor.pagination.admin') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- JSON Modal --}}
|
||||||
|
<div class="mback" id="jsonModalBack" aria-hidden="true">
|
||||||
|
<div class="modalx" role="dialog" aria-modal="true" aria-labelledby="jsonModalTitle">
|
||||||
|
<div class="modalx__head">
|
||||||
|
<div>
|
||||||
|
<div class="modalx__title" id="jsonModalTitle">계좌 성명인증 JSON</div>
|
||||||
|
<div class="modalx__desc" id="jsonModalDesc">요청/결과 JSON</div>
|
||||||
|
</div>
|
||||||
|
<button class="lbtn lbtn--ghost lbtn--sm" type="button" id="btnJsonClose">닫기 ✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modalx__body">
|
||||||
|
<div class="kv" id="jsonModalKv"></div>
|
||||||
|
|
||||||
|
<div class="prebox">
|
||||||
|
<div class="t">Request JSON</div>
|
||||||
|
<pre id="jsonReqPre"></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="prebox">
|
||||||
|
<div class="t">Result JSON</div>
|
||||||
|
<pre id="jsonResPre"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modalx__foot">
|
||||||
|
<button class="lbtn lbtn--ghost" type="button" id="btnJsonCopy">복사</button>
|
||||||
|
<button class="lbtn lbtn--primary" type="button" id="btnJsonOk">확인</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const back = document.getElementById('jsonModalBack');
|
||||||
|
const kv = document.getElementById('jsonModalKv');
|
||||||
|
const preReq = document.getElementById('jsonReqPre');
|
||||||
|
const preRes = document.getElementById('jsonResPre');
|
||||||
|
|
||||||
|
const btnClose = document.getElementById('btnJsonClose');
|
||||||
|
const btnOk = document.getElementById('btnJsonOk');
|
||||||
|
const btnCopy = document.getElementById('btnJsonCopy');
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
back.classList.add('is-open');
|
||||||
|
back.setAttribute('aria-hidden', 'false');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
};
|
||||||
|
const close = () => {
|
||||||
|
back.classList.remove('is-open');
|
||||||
|
back.setAttribute('aria-hidden', 'true');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
btnClose?.addEventListener('click', close);
|
||||||
|
btnOk?.addEventListener('click', close);
|
||||||
|
back?.addEventListener('click', (e)=>{ if(e.target === back) close(); });
|
||||||
|
document.addEventListener('keydown', (e)=>{ if(e.key === 'Escape' && back.classList.contains('is-open')) close(); });
|
||||||
|
|
||||||
|
btnCopy?.addEventListener('click', async () => {
|
||||||
|
const text = (preReq.textContent || '') + "\n\n" + (preRes.textContent || '');
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
btnCopy.textContent = '복사됨';
|
||||||
|
setTimeout(()=>btnCopy.textContent='복사', 900);
|
||||||
|
} catch (e) {
|
||||||
|
alert('복사를 지원하지 않는 환경입니다.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderKv = (pairs) => {
|
||||||
|
kv.innerHTML = '';
|
||||||
|
pairs.filter(p => p.v && String(p.v).trim() !== '').forEach(p => {
|
||||||
|
const k = document.createElement('div'); k.className='k'; k.textContent=p.k;
|
||||||
|
const v = document.createElement('div'); v.className='v';
|
||||||
|
const span = document.createElement('span'); span.className='mono'; span.textContent=p.v;
|
||||||
|
v.appendChild(span);
|
||||||
|
kv.appendChild(k); kv.appendChild(v);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
document.querySelectorAll('.btnJson').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const seq = btn.getAttribute('data-seq');
|
||||||
|
if (!seq) return;
|
||||||
|
|
||||||
|
const reqTa = document.getElementById('reqStore-' + seq);
|
||||||
|
const resTa = document.getElementById('resStore-' + seq);
|
||||||
|
|
||||||
|
const req = reqTa ? reqTa.value : '';
|
||||||
|
const res = resTa ? resTa.value : '';
|
||||||
|
|
||||||
|
renderKv([
|
||||||
|
{k:'mem_no', v: btn.getAttribute('data-mem') || ''},
|
||||||
|
{k:'bank_code', v: btn.getAttribute('data-bank') || ''},
|
||||||
|
{k:'account', v: btn.getAttribute('data-account') || ''},
|
||||||
|
{k:'name', v: btn.getAttribute('data-name') || ''},
|
||||||
|
{k:'protype', v: btn.getAttribute('data-protype') || ''},
|
||||||
|
{k:'status', v: btn.getAttribute('data-status') || ''},
|
||||||
|
{k:'depositor', v: btn.getAttribute('data-depositor') || ''},
|
||||||
|
{k:'error', v: btn.getAttribute('data-err') || ''},
|
||||||
|
]);
|
||||||
|
|
||||||
|
preReq.textContent = req || '';
|
||||||
|
preRes.textContent = res || '';
|
||||||
|
open();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
@endsection
|
||||||
188
resources/views/admin/log/MemberJoinLogController.blade.php
Normal file
188
resources/views/admin/log/MemberJoinLogController.blade.php
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
{{-- resources/views/admin/log/MemberJoinLogController.blade.php --}}
|
||||||
|
@extends('admin.layouts.app')
|
||||||
|
|
||||||
|
@section('title', '회원가입 필터 로그')
|
||||||
|
@section('page_title', '회원가입 필터 로그')
|
||||||
|
@section('page_desc', '회원가입 시 필터에 걸린 기록을 조회합니다.')
|
||||||
|
@section('content_class', 'a-content--full')
|
||||||
|
|
||||||
|
@push('head')
|
||||||
|
<style>
|
||||||
|
.bar{display:flex;justify-content:space-between;align-items:flex-end;gap:12px;flex-wrap:wrap;}
|
||||||
|
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;}
|
||||||
|
.filters .inp{width:170px;}
|
||||||
|
.filters .inpWide{width:220px;}
|
||||||
|
|
||||||
|
.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--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);}
|
||||||
|
|
||||||
|
.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;display:inline-block;}
|
||||||
|
.muted{opacity:.8;font-size:12px;}
|
||||||
|
.nowrap{white-space:nowrap;}
|
||||||
|
|
||||||
|
.badge{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);}
|
||||||
|
.badge--skt{border-color:rgba(34,197,94,.35);background:rgba(34,197,94,.10);}
|
||||||
|
.badge--kt{border-color:rgba(59,130,246,.35);background:rgba(59,130,246,.10);}
|
||||||
|
.badge--lgu{border-color:rgba(236,72,153,.35);background:rgba(236,72,153,.10);}
|
||||||
|
.badge--mvno{border-color:rgba(245,158,11,.35);background:rgba(245,158,11,.10);}
|
||||||
|
.badge--muted{opacity:.9;}
|
||||||
|
.badge--bad{border-color:rgba(244,63,94,.35);background:rgba(244,63,94,.10);}
|
||||||
|
|
||||||
|
/* ✅ 회원번호 링크 색상 흰색 */
|
||||||
|
a.memlink{color:#fff;text-decoration:none;}
|
||||||
|
a.memlink:hover{color:#fff;text-decoration:underline;}
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
@php
|
||||||
|
$indexUrl = route('admin.systemlog.member-join-logs', [], false);
|
||||||
|
|
||||||
|
$f = $filters ?? [];
|
||||||
|
|
||||||
|
$dateFrom = (string)($f['date_from'] ?? '');
|
||||||
|
$dateTo = (string)($f['date_to'] ?? '');
|
||||||
|
$gubun = (string)($f['gubun'] ?? '');
|
||||||
|
$memNo = (string)($f['mem_no'] ?? '');
|
||||||
|
$phone = (string)($f['phone'] ?? '');
|
||||||
|
$email = (string)($f['email'] ?? '');
|
||||||
|
$ip4 = (string)($f['ip4'] ?? '');
|
||||||
|
$ip4c = (string)($f['ip4_c'] ?? '');
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="a-card" style="padding:16px; margin-bottom:16px;">
|
||||||
|
<div class="bar">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:900;font-size:16px;">회원가입 필터 로그</div>
|
||||||
|
<div class="muted" style="margin-top:4px;">리스트에서 전체 정보 표시 · 검색/페이징 지원</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="GET" action="{{ $indexUrl }}" class="filters">
|
||||||
|
<div>
|
||||||
|
<label class="muted">From</label>
|
||||||
|
<input class="a-input inp" type="date" name="date_from" value="{{ $dateFrom }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="muted">To</label>
|
||||||
|
<input class="a-input inp" type="date" name="date_to" value="{{ $dateTo }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="muted">gubun</label>
|
||||||
|
<input class="a-input inp" name="gubun" value="{{ $gubun }}" placeholder="ex) 01">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="muted">mem_no</label>
|
||||||
|
<input class="a-input inp" name="mem_no" value="{{ $memNo }}" inputmode="numeric" placeholder="ex) 70464">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="muted">phone(정확검색)</label>
|
||||||
|
<input class="a-input inp" name="phone" value="{{ $phone }}" placeholder="01111111111">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="muted">email</label>
|
||||||
|
<input class="a-input inpWide" name="email" value="{{ $email }}" placeholder="gmail.com / ryu">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="muted">ip4</label>
|
||||||
|
<input class="a-input inp" name="ip4" value="{{ $ip4 }}" placeholder="221.150.">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="muted">ip4_c</label>
|
||||||
|
<input class="a-input inp" name="ip4_c" value="{{ $ip4c }}" placeholder="221.150.109">
|
||||||
|
</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="{{ $indexUrl }}">초기화</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</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;">SEQ</th>
|
||||||
|
<th style="width:190px;">일시</th>
|
||||||
|
<th style="width:160px;">회원</th>
|
||||||
|
<th style="width:420px;">전화/이메일</th>
|
||||||
|
<th style="width:260px;">IP</th>
|
||||||
|
<th style="width:120px;">gubun</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse(($items ?? []) as $r0)
|
||||||
|
@php
|
||||||
|
$r = is_array($r0) ? $r0 : (array)$r0;
|
||||||
|
$seq = (int)($r['seq'] ?? 0);
|
||||||
|
|
||||||
|
$memNoInt = (int)($r['mem_no_int'] ?? 0);
|
||||||
|
$memLink = $r['mem_link'] ?? null;
|
||||||
|
|
||||||
|
$emailDisp = (string)($r['email_display'] ?? '');
|
||||||
|
if ($emailDisp === '-' ) $emailDisp = '';
|
||||||
|
|
||||||
|
$ip4v = (string)($r['ip4'] ?? '');
|
||||||
|
$ip4cv = (string)($r['ip4_c'] ?? '');
|
||||||
|
@endphp
|
||||||
|
<tr>
|
||||||
|
<td class="a-muted">{{ $seq }}</td>
|
||||||
|
<td class="a-muted nowrap">{{ $r['dt_reg'] ?? '-' }}</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
@if($memNoInt > 0 && $memLink)
|
||||||
|
<a href="{{ $memLink }}" class="mono memlink" target="_blank" rel="noopener">{{ $memNoInt }}</a>
|
||||||
|
@else
|
||||||
|
<span class="badge badge--bad">가입차단</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{{-- ✅ 전화/이메일 한줄: "전화번호 바로뒤 이메일" (이메일 없으면 빈값) --}}
|
||||||
|
<td>
|
||||||
|
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
|
||||||
|
<span class="badge {{ $r['corp_badge'] ?? 'badge--muted' }}">{{ $r['corp_label'] ?? '-' }}</span>
|
||||||
|
<span class="mono">{{ $r['phone_display'] ?? '-' }}</span>
|
||||||
|
@if($emailDisp !== '')
|
||||||
|
<span class="muted">{{ $emailDisp }}</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{{-- ✅ IP 한줄: ip4 + ip4_c --}}
|
||||||
|
<td class="nowrap">
|
||||||
|
<span class="mono">{{ $ip4v !== '' ? $ip4v : '-' }}</span>
|
||||||
|
@if($ip4cv !== '')
|
||||||
|
<span class="mono" style="opacity:.75">{{ $ip4cv }}</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td><span class="mono">{{ $r['gubun'] ?? '-' }}</span></td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td colspan="6" class="a-muted" style="padding:16px;">데이터가 없습니다.</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:12px;">
|
||||||
|
{{ $page->onEachSide(1)->links('vendor.pagination.admin') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
246
resources/views/admin/log/MemberLoginLogController.blade.php
Normal file
246
resources/views/admin/log/MemberLoginLogController.blade.php
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
{{-- resources/views/admin/log/MemberLoginLogController.blade.php --}}
|
||||||
|
@extends('admin.layouts.app')
|
||||||
|
|
||||||
|
@section('title', '로그인 로그')
|
||||||
|
@section('page_title', '로그인 로그')
|
||||||
|
@section('page_desc', '연도별 로그인 이력을 조회합니다.')
|
||||||
|
@section('content_class', 'a-content--full')
|
||||||
|
|
||||||
|
@push('head')
|
||||||
|
<style>
|
||||||
|
.bar{display:flex;justify-content:space-between;align-items:flex-end;gap:12px;flex-wrap:wrap;}
|
||||||
|
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;}
|
||||||
|
.filters .inp{width:160px;}
|
||||||
|
.filters .inpWide{width:200px;}
|
||||||
|
.filters .sel{width:140px;}
|
||||||
|
|
||||||
|
.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;}
|
||||||
|
|
||||||
|
.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;display:inline-block;}
|
||||||
|
.muted{opacity:.8;font-size:12px;}
|
||||||
|
.nowrap{white-space:nowrap;}
|
||||||
|
|
||||||
|
.badge{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);}
|
||||||
|
.badge--ok{border-color:rgba(34,197,94,.35);background:rgba(34,197,94,.10);}
|
||||||
|
.badge--bad{border-color:rgba(244,63,94,.35);background:rgba(244,63,94,.10);}
|
||||||
|
.badge--muted{opacity:.9;}
|
||||||
|
.badge--mvno{border-color:rgba(245,158,11,.35);background:rgba(245,158,11,.10);}
|
||||||
|
|
||||||
|
/* mem link white */
|
||||||
|
a.memlink{color:#fff;text-decoration:none;}
|
||||||
|
a.memlink:hover{color:#fff;text-decoration:underline;}
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
@php
|
||||||
|
$indexUrl = route('admin.systemlog.member-login-logs', [], false);
|
||||||
|
|
||||||
|
$f = $filters ?? [];
|
||||||
|
$years = $years ?? [];
|
||||||
|
|
||||||
|
$year = (int)($f['year'] ?? (int)date('Y'));
|
||||||
|
$dateFrom = (string)($f['date_from'] ?? '');
|
||||||
|
$dateTo = (string)($f['date_to'] ?? '');
|
||||||
|
|
||||||
|
$memNo = (string)($f['mem_no'] ?? '');
|
||||||
|
$sf = (string)($f['sf'] ?? '');
|
||||||
|
$conn = (string)($f['conn'] ?? '');
|
||||||
|
|
||||||
|
$ip4 = (string)($f['ip4'] ?? '');
|
||||||
|
$ip4c = (string)($f['ip4_c'] ?? '');
|
||||||
|
|
||||||
|
$err = (string)($f['error_code'] ?? '');
|
||||||
|
$platform = (string)($f['platform'] ?? '');
|
||||||
|
$browser = (string)($f['browser'] ?? '');
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="a-card" style="padding:16px; margin-bottom:16px;">
|
||||||
|
<div class="bar">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:900;font-size:16px;">로그인 로그</div>
|
||||||
|
<div class="muted" style="margin-top:4px;">연도별(mem_login_YYYY) 테이블 조회 · 검색/페이징</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="GET" action="{{ $indexUrl }}" class="filters" id="frmFilters">
|
||||||
|
<div>
|
||||||
|
<label class="muted">년도</label>
|
||||||
|
<select class="a-input sel" name="year" id="selYear">
|
||||||
|
@foreach($years as $y)
|
||||||
|
<option value="{{ $y }}" {{ (int)$y === (int)$year ? 'selected' : '' }}>{{ $y }}</option>
|
||||||
|
@endforeach
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="muted">From</label>
|
||||||
|
<input class="a-input inp" type="date" name="date_from" value="{{ $dateFrom }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="muted">To</label>
|
||||||
|
<input class="a-input inp" type="date" name="date_to" value="{{ $dateTo }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="muted">mem_no</label>
|
||||||
|
<input class="a-input inp" name="mem_no" value="{{ $memNo }}" inputmode="numeric" placeholder="ex) 70665">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="muted">성공/실패</label>
|
||||||
|
<select class="a-input sel" name="sf">
|
||||||
|
<option value="">전체</option>
|
||||||
|
<option value="s" {{ $sf==='s'?'selected':'' }}>성공</option>
|
||||||
|
<option value="f" {{ $sf==='f'?'selected':'' }}>실패</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="muted">PC/M</label>
|
||||||
|
<select class="a-input sel" name="conn">
|
||||||
|
<option value="">전체</option>
|
||||||
|
<option value="1" {{ $conn==='1'?'selected':'' }}>PC</option>
|
||||||
|
<option value="2" {{ $conn==='2'?'selected':'' }}>Mobile</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="muted">ip4</label>
|
||||||
|
<input class="a-input inp" name="ip4" value="{{ $ip4 }}" placeholder="211.235.">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="muted">ip4_c</label>
|
||||||
|
<input class="a-input inp" name="ip4_c" value="{{ $ip4c }}" placeholder="211.235.75">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="muted">error_code</label>
|
||||||
|
<input class="a-input inp" name="error_code" value="{{ $err }}" placeholder="pw / E3">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="muted">platform</label>
|
||||||
|
<input class="a-input inp" name="platform" value="{{ $platform }}" placeholder="Android / iOS">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="muted">browser</label>
|
||||||
|
<input class="a-input inpWide" name="browser" value="{{ $browser }}" placeholder="Chrome / Safari">
|
||||||
|
</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="{{ $indexUrl }}">초기화</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</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:1200px;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:90px;">SEQ</th>
|
||||||
|
<th style="width:210px;">일시</th>
|
||||||
|
<th style="width:150px;">회원</th>
|
||||||
|
<th style="width:140px;">결과</th>
|
||||||
|
<th style="width:140px;">경로</th>
|
||||||
|
<th style="width:320px;">IP</th>
|
||||||
|
<th>플랫폼/브라우저</th>
|
||||||
|
<th style="width:160px;">실패코드</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse(($items ?? []) as $r0)
|
||||||
|
@php
|
||||||
|
$r = is_array($r0) ? $r0 : (array)$r0;
|
||||||
|
$seq = (int)($r['seq'] ?? 0);
|
||||||
|
|
||||||
|
$memNoInt = (int)($r['mem_no_int'] ?? 0);
|
||||||
|
$memLink = $r['mem_link'] ?? null;
|
||||||
|
|
||||||
|
$sf = (string)($r['sf'] ?? 's');
|
||||||
|
$errCode = trim((string)($r['error_code'] ?? ''));
|
||||||
|
// 성공이면 실패코드는 비움(요구에 따라 여기서 제어)
|
||||||
|
$errShow = ($sf === 'f' && $errCode !== '') ? $errCode : '';
|
||||||
|
|
||||||
|
$ip4 = (string)($r['ip4'] ?? '');
|
||||||
|
$ip4c = (string)($r['ip4_c'] ?? '');
|
||||||
|
|
||||||
|
$plat = trim((string)($r['platform'] ?? ''));
|
||||||
|
$brow = trim((string)($r['browser'] ?? ''));
|
||||||
|
@endphp
|
||||||
|
<tr>
|
||||||
|
<td class="a-muted">{{ $seq }}</td>
|
||||||
|
<td class="a-muted nowrap">{{ $r['dt_reg'] ?? '-' }}</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
@if($memNoInt > 0 && $memLink)
|
||||||
|
<a href="{{ $memLink }}" class="mono memlink" target="_blank" rel="noopener">{{ $memNoInt }}</a>
|
||||||
|
@else
|
||||||
|
<span class="mono">0</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<span class="badge {{ $r['sf_badge'] ?? 'badge--muted' }}">{{ $r['sf_label'] ?? '-' }}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<span class="badge {{ $r['conn_badge'] ?? 'badge--muted' }}">{{ $r['conn_label'] ?? '-' }}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td class="nowrap">
|
||||||
|
<span class="mono">{{ $ip4 !== '' ? $ip4 : '-' }}</span>
|
||||||
|
@if($ip4c !== '')
|
||||||
|
<span class="mono" style="opacity:.75">{{ $ip4c }}</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<span class="mono">{{ $plat !== '' ? $plat : '-' }}</span>
|
||||||
|
<span class="mono" style="opacity:.85">{{ $brow !== '' ? $brow : '-' }}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
@if($errShow !== '')
|
||||||
|
<span class="mono">{{ $errShow }}</span>
|
||||||
|
@else
|
||||||
|
<span class="a-muted">-</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td colspan="8" class="a-muted" style="padding:16px;">데이터가 없습니다.</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:12px;">
|
||||||
|
{{ $page->onEachSide(1)->links('vendor.pagination.admin') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const sel = document.getElementById('selYear');
|
||||||
|
const frm = document.getElementById('frmFilters');
|
||||||
|
if (!sel || !frm) return;
|
||||||
|
|
||||||
|
sel.addEventListener('change', () => {
|
||||||
|
// ✅ 선택 즉시 이동(= GET submit), page 파라미터는 자동 리셋
|
||||||
|
frm.submit();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
@endsection
|
||||||
@ -0,0 +1,338 @@
|
|||||||
|
{{-- resources/views/admin/log/MemberPasswdModifyLogController.blade.php --}}
|
||||||
|
@extends('admin.layouts.app')
|
||||||
|
|
||||||
|
@section('title', '비밀번호 변경 로그')
|
||||||
|
@section('page_title', '비밀번호 변경 로그')
|
||||||
|
@section('page_desc', '로그인/2차 비밀번호 변경 및 비밀번호 찾기 변경 이력을 조회합니다.')
|
||||||
|
@section('content_class', 'a-content--full')
|
||||||
|
|
||||||
|
@push('head')
|
||||||
|
<style>
|
||||||
|
.bar{display:flex;justify-content:space-between;align-items:flex-end;gap:12px;flex-wrap:wrap;}
|
||||||
|
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;}
|
||||||
|
.filters .inp{width:160px;}
|
||||||
|
.filters .inpWide{width:220px;}
|
||||||
|
.filters .sel{width:140px;}
|
||||||
|
|
||||||
|
.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;}
|
||||||
|
|
||||||
|
.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;display:inline-block;}
|
||||||
|
.muted{opacity:.8;font-size:12px;}
|
||||||
|
.nowrap{white-space:nowrap;}
|
||||||
|
.ellipsis{max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;vertical-align:bottom;}
|
||||||
|
|
||||||
|
.badge{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);}
|
||||||
|
.badge--ok{border-color:rgba(34,197,94,.35);background:rgba(34,197,94,.10);}
|
||||||
|
.badge--warn{border-color:rgba(245,158,11,.35);background:rgba(245,158,11,.10);}
|
||||||
|
|
||||||
|
/* mem link white */
|
||||||
|
a.memlink{color:#fff;text-decoration:none;}
|
||||||
|
a.memlink:hover{color:#fff;text-decoration:underline;}
|
||||||
|
|
||||||
|
details{border:1px solid rgba(255,255,255,.10);border-radius:12px;background:rgba(255,255,255,.03);padding:10px;}
|
||||||
|
details summary{cursor:pointer;user-select:none;font-weight:800;font-size:12px;opacity:.9;}
|
||||||
|
pre{margin:10px 0 0;white-space:pre-wrap;word-break:break-word;font-size:12px;line-height:1.5;
|
||||||
|
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;}
|
||||||
|
.kv{display:grid;grid-template-columns:160px 1fr;gap:8px;margin-top:10px;}
|
||||||
|
.kv .k{opacity:.75;font-size:12px;}
|
||||||
|
.kv .v{font-size:12px;word-break:break-word;}
|
||||||
|
|
||||||
|
/* json modal */
|
||||||
|
.mback{position:fixed;inset:0;display:none;align-items:center;justify-content:center;z-index:9999;
|
||||||
|
background:rgba(0,0,0,.55);backdrop-filter:blur(2px);}
|
||||||
|
.mback.is-open{display:flex;}
|
||||||
|
.modalx{width:min(980px, 92vw);max-height:88vh;overflow:hidden;border-radius:18px;
|
||||||
|
border:1px solid rgba(255,255,255,.12);background:rgba(18,18,18,.96);box-shadow:0 20px 60px rgba(0,0,0,.45);}
|
||||||
|
.modalx__head{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;padding:16px 16px 10px;border-bottom:1px solid rgba(255,255,255,.10);}
|
||||||
|
.modalx__title{font-weight:900;font-size:16px;}
|
||||||
|
.modalx__desc{font-size:12px;opacity:.8;margin-top:4px;}
|
||||||
|
.modalx__body{padding:16px;overflow:auto;max-height:70vh;}
|
||||||
|
.modalx__foot{display:flex;justify-content:flex-end;gap:8px;padding:12px 16px;border-top:1px solid rgba(255,255,255,.10);}
|
||||||
|
|
||||||
|
.kv{display:grid;grid-template-columns:170px 1fr;gap:8px;margin-bottom:12px;}
|
||||||
|
.kv .k{opacity:.75;font-size:12px;}
|
||||||
|
.kv .v{font-size:12px;word-break:break-all;} /* auth_key 같은 긴 문자열 깨짐 방지 */
|
||||||
|
.prebox{border:1px solid rgba(255,255,255,.10);border-radius:14px;background:rgba(255,255,255,.03);padding:12px;}
|
||||||
|
.prebox pre{margin:0;white-space:pre-wrap;word-break:break-word;font-size:12px;line-height:1.5;
|
||||||
|
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;}
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
@php
|
||||||
|
$indexUrl = route('admin.systemlog.member-passwd-modify-logs', [], false);
|
||||||
|
|
||||||
|
$f = $filters ?? [];
|
||||||
|
$stateMap = $stateMap ?? ['S'=>'직접변경','E'=>'비번찾기'];
|
||||||
|
|
||||||
|
$dateFrom = (string)($f['date_from'] ?? '');
|
||||||
|
$dateTo = (string)($f['date_to'] ?? '');
|
||||||
|
$state = (string)($f['state'] ?? '');
|
||||||
|
$memNo = (string)($f['mem_no'] ?? '');
|
||||||
|
$type = (string)($f['type'] ?? '');
|
||||||
|
$email = (string)($f['email'] ?? '');
|
||||||
|
$ip = (string)($f['ip'] ?? '');
|
||||||
|
$q = (string)($f['q'] ?? '');
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="a-card" style="padding:16px; margin-bottom:16px;">
|
||||||
|
<div class="bar">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:900;font-size:16px;">비밀번호 변경 로그</div>
|
||||||
|
<div class="muted" style="margin-top:4px;">구/신 JSON 혼재 → 핵심 필드 정규화 + 원본 JSON 펼쳐보기</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="GET" action="{{ $indexUrl }}" class="filters">
|
||||||
|
<div>
|
||||||
|
<label class="muted">From</label>
|
||||||
|
<input class="a-input inp" type="date" name="date_from" value="{{ $dateFrom }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="muted">To</label>
|
||||||
|
<input class="a-input inp" type="date" name="date_to" value="{{ $dateTo }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="muted">state</label>
|
||||||
|
<select class="a-input sel" name="state">
|
||||||
|
<option value="">전체</option>
|
||||||
|
<option value="S" {{ $state==='S'?'selected':'' }}>직접변경(S)</option>
|
||||||
|
<option value="E" {{ $state==='E'?'selected':'' }}>비번찾기(E)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="muted">mem_no</label>
|
||||||
|
<input class="a-input inp" name="mem_no" value="{{ $memNo }}" inputmode="numeric" placeholder="5359">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="muted">type(신포맷)</label>
|
||||||
|
<input class="a-input inpWide" name="type" value="{{ $type }}" placeholder="로그인비밀번호변경 / 2차비밀번호변경">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="muted">email(구포맷)</label>
|
||||||
|
<input class="a-input inpWide" name="email" value="{{ $email }}" placeholder="naver.com">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="muted">ip</label>
|
||||||
|
<input class="a-input inp" name="ip" value="{{ $ip }}" placeholder="210.96.">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="muted">q(info 검색)</label>
|
||||||
|
<input class="a-input inpWide" name="q" value="{{ $q }}" placeholder="agent / auth_key ...">
|
||||||
|
</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="{{ $indexUrl }}">초기화</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</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:1300px;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:90px;">SEQ</th>
|
||||||
|
<th style="width:190px;">rgdate</th>
|
||||||
|
<th style="width:130px;">state</th>
|
||||||
|
<th style="width:140px;">mem_no</th>
|
||||||
|
<th style="width:200px;">type</th>
|
||||||
|
<th style="width:220px;">ip</th>
|
||||||
|
<th style="width:240px;">email</th>
|
||||||
|
<th>JSON</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse(($items ?? []) as $r0)
|
||||||
|
@php
|
||||||
|
$r = is_array($r0) ? $r0 : (array)$r0;
|
||||||
|
$seq = (int)($r['seq'] ?? 0);
|
||||||
|
|
||||||
|
$memNoInt = (int)($r['mem_no_int'] ?? 0);
|
||||||
|
$memLink = $r['mem_link'] ?? null;
|
||||||
|
|
||||||
|
$agent = (string)($r['agent_norm'] ?? '');
|
||||||
|
$emailN = (string)($r['email_norm'] ?? '');
|
||||||
|
$ipN = (string)($r['ip_norm'] ?? '');
|
||||||
|
|
||||||
|
$authKey = (string)($r['auth_key'] ?? '');
|
||||||
|
$authEff = (string)($r['auth_effective_time'] ?? '');
|
||||||
|
@endphp
|
||||||
|
<tr>
|
||||||
|
<td class="a-muted">{{ $seq }}</td>
|
||||||
|
<td class="a-muted nowrap">{{ $r['rgdate'] ?? '-' }}</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
<span class="badge {{ $r['state_badge'] ?? 'badge--warn' }}">
|
||||||
|
{{ $r['state_label'] ?? ($stateMap[$r['state'] ?? ''] ?? '-') }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
@if($memNoInt > 0 && $memLink)
|
||||||
|
<a href="{{ $memLink }}" class="mono memlink" target="_blank" rel="noopener">{{ $memNoInt }}</a>
|
||||||
|
@else
|
||||||
|
<span class="mono">-</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td><span class="mono">{{ $r['event_type'] ?? '-' }}</span></td>
|
||||||
|
|
||||||
|
<td class="nowrap">
|
||||||
|
@if($ipN !== '')
|
||||||
|
<span class="mono">{{ $ipN }}</span>
|
||||||
|
@else
|
||||||
|
<span class="a-muted">-</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td>
|
||||||
|
@if($emailN !== '')
|
||||||
|
<span class="mono">{{ $emailN }}</span>
|
||||||
|
@else
|
||||||
|
<span class="a-muted">-</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
|
||||||
|
@php
|
||||||
|
$agent = (string)($r['agent_norm'] ?? '');
|
||||||
|
$authKey = (string)($r['auth_key'] ?? '');
|
||||||
|
$authEff = (string)($r['auth_effective_time'] ?? '');
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<td class="nowrap" style="text-align:right;">
|
||||||
|
<button type="button"
|
||||||
|
class="lbtn lbtn--ghost lbtn--sm btnJson"
|
||||||
|
data-seq="{{ $seq }}"
|
||||||
|
data-agent="{{ e($agent) }}"
|
||||||
|
data-auth-key="{{ e($authKey) }}"
|
||||||
|
data-auth-eff="{{ e($authEff) }}">
|
||||||
|
JSON 보기
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{{-- AJAX 없이: row별 JSON을 숨겨두고 모달에서 꺼내씀 --}}
|
||||||
|
<textarea id="jsonStore-{{ $seq }}" style="display:none;">{{ $r['info_pretty'] ?? ($r['info'] ?? '') }}</textarea>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td colspan="9" class="a-muted" style="padding:16px;">데이터가 없습니다.</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:12px;">
|
||||||
|
{{ $page->onEachSide(1)->links('vendor.pagination.admin') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mback" id="jsonModalBack" aria-hidden="true">
|
||||||
|
<div class="modalx" role="dialog" aria-modal="true" aria-labelledby="jsonModalTitle">
|
||||||
|
<div class="modalx__head">
|
||||||
|
<div>
|
||||||
|
<div class="modalx__title" id="jsonModalTitle">JSON</div>
|
||||||
|
<div class="modalx__desc" id="jsonModalDesc">로그 상세 JSON</div>
|
||||||
|
</div>
|
||||||
|
<button class="lbtn lbtn--ghost lbtn--sm" type="button" id="btnJsonClose">닫기 ✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modalx__body">
|
||||||
|
<div class="kv" id="jsonModalKv"></div>
|
||||||
|
<div class="prebox">
|
||||||
|
<pre id="jsonModalPre"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modalx__foot">
|
||||||
|
<button class="lbtn lbtn--ghost" type="button" id="btnJsonCopy">복사</button>
|
||||||
|
<button class="lbtn lbtn--primary" type="button" id="btnJsonOk">확인</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const back = document.getElementById('jsonModalBack');
|
||||||
|
const pre = document.getElementById('jsonModalPre');
|
||||||
|
const kv = document.getElementById('jsonModalKv');
|
||||||
|
const btnClose = document.getElementById('btnJsonClose');
|
||||||
|
const btnOk = document.getElementById('btnJsonOk');
|
||||||
|
const btnCopy = document.getElementById('btnJsonCopy');
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
back.classList.add('is-open');
|
||||||
|
back.setAttribute('aria-hidden', 'false');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
};
|
||||||
|
const close = () => {
|
||||||
|
back.classList.remove('is-open');
|
||||||
|
back.setAttribute('aria-hidden', 'true');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
btnClose?.addEventListener('click', close);
|
||||||
|
btnOk?.addEventListener('click', close);
|
||||||
|
back?.addEventListener('click', (e)=>{ if(e.target === back) close(); });
|
||||||
|
document.addEventListener('keydown', (e)=>{ if(e.key === 'Escape' && back.classList.contains('is-open')) close(); });
|
||||||
|
|
||||||
|
btnCopy?.addEventListener('click', async () => {
|
||||||
|
const text = pre.textContent || '';
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
btnCopy.textContent = '복사됨';
|
||||||
|
setTimeout(()=>btnCopy.textContent='복사', 900);
|
||||||
|
} catch (e) {
|
||||||
|
alert('복사를 지원하지 않는 환경입니다.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderKv = (pairs) => {
|
||||||
|
kv.innerHTML = '';
|
||||||
|
pairs.filter(p => p.v && String(p.v).trim() !== '').forEach(p => {
|
||||||
|
const k = document.createElement('div'); k.className='k'; k.textContent=p.k;
|
||||||
|
const v = document.createElement('div'); v.className='v';
|
||||||
|
const span = document.createElement('span'); span.className='mono'; span.textContent=p.v;
|
||||||
|
v.appendChild(span);
|
||||||
|
kv.appendChild(k); kv.appendChild(v);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
document.querySelectorAll('.btnJson').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const seq = btn.getAttribute('data-seq');
|
||||||
|
if (!seq) return;
|
||||||
|
|
||||||
|
const ta = document.getElementById('jsonStore-' + seq);
|
||||||
|
const raw = ta ? ta.value : '';
|
||||||
|
|
||||||
|
const agent = btn.getAttribute('data-agent') || '';
|
||||||
|
const authKey = btn.getAttribute('data-auth-key') || '';
|
||||||
|
const authEff = btn.getAttribute('data-auth-eff') || '';
|
||||||
|
|
||||||
|
renderKv([
|
||||||
|
{k:'agent', v: agent},
|
||||||
|
{k:'auth_key', v: authKey},
|
||||||
|
{k:'auth_effective_time', v: authEff},
|
||||||
|
]);
|
||||||
|
|
||||||
|
pre.textContent = raw || '';
|
||||||
|
open();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
@endsection
|
||||||
61
resources/views/admin/log/_audit_log_modal.blade.php
Normal file
61
resources/views/admin/log/_audit_log_modal.blade.php
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
{{-- resources/views/admin/log/_audit_log_modal.blade.php --}}
|
||||||
|
<div class="mback" id="auditModalBack" aria-hidden="true">
|
||||||
|
<div class="modalx" role="dialog" aria-modal="true" aria-labelledby="auditTitle">
|
||||||
|
<div class="modalx__head">
|
||||||
|
<div>
|
||||||
|
<div class="modalx__title" id="auditTitle">감사로그 상세</div>
|
||||||
|
<div class="modalx__desc" id="auditDesc">before/after JSON과 user_agent를 확인하세요.</div>
|
||||||
|
</div>
|
||||||
|
<button class="lbtn lbtn--ghost lbtn--sm" type="button" id="auditBtnClose">닫기 ✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modalx__body">
|
||||||
|
<div class="infoGrid">
|
||||||
|
<div class="ibox">
|
||||||
|
<div class="k">ID</div>
|
||||||
|
<div class="v mono" id="audit_id">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="ibox">
|
||||||
|
<div class="k">Actor</div>
|
||||||
|
<div class="v" id="audit_actor">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="ibox">
|
||||||
|
<div class="k">Action</div>
|
||||||
|
<div class="v mono" id="audit_action">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="ibox">
|
||||||
|
<div class="k">Target</div>
|
||||||
|
<div class="v mono" id="audit_target">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="ibox">
|
||||||
|
<div class="k">IP</div>
|
||||||
|
<div class="v mono" id="audit_ip">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="ibox">
|
||||||
|
<div class="k">Created</div>
|
||||||
|
<div class="v mono" id="audit_created">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ibox" style="margin-bottom:12px;">
|
||||||
|
<div class="k">User Agent</div>
|
||||||
|
<div class="v" id="audit_ua" style="font-size:12px; opacity:.9; word-break:break-word;">-</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="jsonGrid">
|
||||||
|
<div class="prebox">
|
||||||
|
<div class="k">before_json</div>
|
||||||
|
<pre id="audit_before">-</pre>
|
||||||
|
</div>
|
||||||
|
<div class="prebox">
|
||||||
|
<div class="k">after_json</div>
|
||||||
|
<pre id="audit_after">-</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modalx__foot">
|
||||||
|
<button class="lbtn lbtn--ghost" type="button" id="auditBtnOk">확인</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
235
resources/views/admin/log/member_danalauthtel_log.blade.php
Normal file
235
resources/views/admin/log/member_danalauthtel_log.blade.php
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
@extends('admin.layouts.app')
|
||||||
|
|
||||||
|
@section('title', '다날 휴대폰 본인인증 로그')
|
||||||
|
@section('page_title', '다날 휴대폰 본인인증 로그')
|
||||||
|
@section('page_desc', 'mem_no / 전화번호로 조회 (이름/CI/DI 등 개인정보 미노출)')
|
||||||
|
@section('content_class', 'a-content--full')
|
||||||
|
|
||||||
|
@push('head')
|
||||||
|
<style>
|
||||||
|
.bar{display:flex;justify-content:space-between;align-items:flex-end;gap:12px;flex-wrap:wrap;}
|
||||||
|
.filters{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end;}
|
||||||
|
.filters .inp{width:220px;}
|
||||||
|
.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;}
|
||||||
|
.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);}
|
||||||
|
|
||||||
|
.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,.10);}
|
||||||
|
.pill--bad{border-color:rgba(244,63,94,.35);background:rgba(244,63,94,.10);}
|
||||||
|
.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;display:inline-block;}
|
||||||
|
.mono a{color:#fff;text-decoration:none;}
|
||||||
|
.mono a:hover{text-decoration:underline;}
|
||||||
|
|
||||||
|
/* modal */
|
||||||
|
.mback{position:fixed;inset:0;display:none;align-items:center;justify-content:center;z-index:9999;
|
||||||
|
background:rgba(0,0,0,.55);backdrop-filter:blur(2px);}
|
||||||
|
.mback.is-open{display:flex;}
|
||||||
|
.modalx{width:min(900px, 92vw);max-height:88vh;overflow:auto;border-radius:18px;
|
||||||
|
border:1px solid rgba(255,255,255,.12);background:rgba(18,18,18,.96);box-shadow:0 20px 60px rgba(0,0,0,.45);}
|
||||||
|
.modalx__head{display:flex;justify-content:space-between;align-items:flex-start;gap:10px;padding:16px 16px 10px;border-bottom:1px solid rgba(255,255,255,.10);}
|
||||||
|
.modalx__title{font-weight:900;font-size:16px;}
|
||||||
|
.modalx__body{padding:16px;}
|
||||||
|
.kv{display:grid;grid-template-columns:140px 1fr;gap:8px 12px;font-size:13px;margin-bottom:14px;}
|
||||||
|
.kv .k{opacity:.75;}
|
||||||
|
.pre{white-space:pre;overflow:auto;max-height:52vh;padding:12px;border-radius:14px;
|
||||||
|
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.04);
|
||||||
|
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;line-height:1.55;}
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
@php
|
||||||
|
$memNo = (string)($filters['mem_no'] ?? '');
|
||||||
|
$phone = (string)($filters['phone'] ?? '');
|
||||||
|
$indexUrl = route('admin.systemlog.member-danalauthtel-logs', [], false);
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="a-card" style="padding:16px; margin-bottom:16px;">
|
||||||
|
<div class="bar">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:900;font-size:16px;">다날 휴대폰 본인인증 로그</div>
|
||||||
|
<div class="a-muted" style="font-size:12px;margin-top:4px;">mem_no / 전화번호만 검색 · 이름/CI/DI 등은 표시하지 않음</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="GET" action="{{ $indexUrl }}" class="filters">
|
||||||
|
<div>
|
||||||
|
<label class="a-muted" style="font-size:12px;">mem_no</label>
|
||||||
|
<input class="a-input inp" name="mem_no" value="{{ $memNo }}" placeholder="예: 70687">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="a-muted" style="font-size:12px;">전화번호</label>
|
||||||
|
<input class="a-input inp" name="phone" value="{{ $phone }}" placeholder="예: 01084492969">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:8px;">
|
||||||
|
<button class="lbtn lbtn--ghost" type="submit">검색</button>
|
||||||
|
<a class="lbtn lbtn--ghost" href="{{ $indexUrl }}">초기화</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</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;">SEQ</th>
|
||||||
|
<th style="width:90px;">구분</th>
|
||||||
|
<th style="width:110px;">결과</th>
|
||||||
|
<th style="width:160px;">회원번호</th>
|
||||||
|
<th style="width:210px;">전화번호</th>
|
||||||
|
<th>TID</th>
|
||||||
|
<th style="width:200px;">인증시간</th>
|
||||||
|
<th style="width:140px;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@forelse(($rows ?? []) as $r)
|
||||||
|
@php
|
||||||
|
$seq = (int)($r['seq'] ?? 0);
|
||||||
|
$g = (string)($r['gubun'] ?? '');
|
||||||
|
$rc = (string)($r['res_code'] ?? '');
|
||||||
|
$mno = (int)($r['mem_no'] ?? 0);
|
||||||
|
$tid = (string)($r['TID'] ?? '');
|
||||||
|
$dt = (string)($r['rgdate'] ?? '');
|
||||||
|
|
||||||
|
$gLabel = ($g === 'J') ? '가입' : (($g === 'M') ? '수정' : '-');
|
||||||
|
$resOk = ($rc === '0000');
|
||||||
|
$resCls = $resOk ? 'pill--ok' : 'pill--bad';
|
||||||
|
|
||||||
|
$phoneDisp = (string)($r['phone_display'] ?? '');
|
||||||
|
$memberUrl = $mno > 0 ? ('/members/'.$mno) : '';
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td class="a-muted">{{ $seq }}</td>
|
||||||
|
<td><span class="pill">{{ $gLabel }}</span></td>
|
||||||
|
<td><span class="pill {{ $resCls }}">{{ $rc !== '' ? $rc : '-' }}</span></td>
|
||||||
|
<td>
|
||||||
|
@if($mno > 0)
|
||||||
|
<span class="mono"><a href="{{ $memberUrl }}">{{ $mno }}</a></span>
|
||||||
|
@else
|
||||||
|
<span class="pill pill--muted">가입차단/미생성</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if($phoneDisp !== '')
|
||||||
|
<span class="mono">{{ $phoneDisp }}</span>
|
||||||
|
@else
|
||||||
|
<span class="a-muted">-</span>
|
||||||
|
@endif
|
||||||
|
</td>
|
||||||
|
<td><span class="mono">{{ $tid !== '' ? $tid : '-' }}</span></td>
|
||||||
|
<td class="a-muted">{{ $dt !== '' ? $dt : '-' }}</td>
|
||||||
|
<td style="text-align:right;">
|
||||||
|
<button type="button"
|
||||||
|
class="lbtn lbtn--ghost lbtn--sm btnJson"
|
||||||
|
data-seq="{{ $seq }}">
|
||||||
|
JSON 보기
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{{-- row별 JSON(모달용) - 안전하게 script에 저장 --}}
|
||||||
|
<script type="application/json" id="info-json-{{ $seq }}">
|
||||||
|
{!! json_encode(($r['info_sanitized'] ?? []), JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES|JSON_HEX_TAG|JSON_HEX_APOS|JSON_HEX_AMP|JSON_HEX_QUOT) !!}
|
||||||
|
</script>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@empty
|
||||||
|
<tr><td colspan="8" class="a-muted" style="padding:16px;">데이터가 없습니다.</td></tr>
|
||||||
|
@endforelse
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:12px;">
|
||||||
|
{{ $page->onEachSide(1)->links('vendor.pagination.admin') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Modal --}}
|
||||||
|
<div class="mback" id="jsonModalBack" aria-hidden="true">
|
||||||
|
<div class="modalx" role="dialog" aria-modal="true" aria-labelledby="jsonModalTitle">
|
||||||
|
<div class="modalx__head">
|
||||||
|
<div>
|
||||||
|
<div class="modalx__title" id="jsonModalTitle">JSON 상세</div>
|
||||||
|
<div class="a-muted" style="font-size:12px;margin-top:4px;">CI/DI/이름/생년월일/성별/이메일은 표시하지 않습니다.</div>
|
||||||
|
</div>
|
||||||
|
<button class="lbtn lbtn--ghost lbtn--sm" type="button" id="btnCloseJsonModal">닫기 ✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modalx__body">
|
||||||
|
<div class="kv" id="jsonKv"></div>
|
||||||
|
<div class="pre" id="jsonPre">{}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
const back = document.getElementById('jsonModalBack');
|
||||||
|
const btnClose = document.getElementById('btnCloseJsonModal');
|
||||||
|
const kv = document.getElementById('jsonKv');
|
||||||
|
const pre = document.getElementById('jsonPre');
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
back.classList.add('is-open');
|
||||||
|
back.setAttribute('aria-hidden', 'false');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
};
|
||||||
|
const close = () => {
|
||||||
|
back.classList.remove('is-open');
|
||||||
|
back.setAttribute('aria-hidden', 'true');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const setKV = (obj) => {
|
||||||
|
const pairs = [
|
||||||
|
['gubun', obj.gubun || '-'],
|
||||||
|
['res_code', obj.res_code || '-'],
|
||||||
|
['mem_no', obj.mem_no || '-'],
|
||||||
|
['mobile_number', obj.mobile_number || '-'],
|
||||||
|
['TID', obj.TID || '-'],
|
||||||
|
['RETURNCODE', obj.RETURNCODE || '-'],
|
||||||
|
];
|
||||||
|
kv.innerHTML = pairs.map(([k,v]) => `
|
||||||
|
<div class="k">${k}</div><div class="v"><span class="mono">${String(v)}</span></div>
|
||||||
|
`).join('');
|
||||||
|
};
|
||||||
|
|
||||||
|
document.querySelectorAll('.btnJson').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const seq = btn.getAttribute('data-seq');
|
||||||
|
const el = document.getElementById('info-json-' + seq);
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
let obj = {};
|
||||||
|
try {
|
||||||
|
obj = JSON.parse(el.textContent || '{}') || {};
|
||||||
|
} catch(e) {
|
||||||
|
obj = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
setKV(obj);
|
||||||
|
pre.textContent = JSON.stringify(obj, null, 2);
|
||||||
|
|
||||||
|
open();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
btnClose?.addEventListener('click', close);
|
||||||
|
back?.addEventListener('click', (e) => { if (e.target === back) close(); });
|
||||||
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && back.classList.contains('is-open')) close(); });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
@endsection
|
||||||
@ -46,16 +46,35 @@
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'title' => '상품권 관리',
|
'title' => '상품 관리',
|
||||||
'items' => [
|
'items' => [
|
||||||
['label' => '상품 리스트', 'route' => 'admin.products.index','roles' => ['super_admin','product']],
|
// 1) 기본 데이터(전시 구조)
|
||||||
['label' => '상품 등록', 'route' => 'admin.products.create','roles' => ['super_admin','product']],
|
['label' => '카테고리 관리', 'route' => 'admin.categories.index', 'roles' => ['super_admin','product']], // ✅ 추천(신규)
|
||||||
['label' => '판매 코드 관리', 'route' => 'admin.sale-codes.index','roles' => ['super_admin','product']],
|
|
||||||
['label' => '핀 번호 관리', 'route' => 'admin.pins.index','roles' => ['super_admin','product']],
|
// 2) 상품 등록/관리
|
||||||
['label' => '메인 노출 관리', 'route' => 'admin.exposure.index','roles' => ['super_admin','product']],
|
['label' => '상품 리스트', 'route' => 'admin.products.index', 'roles' => ['super_admin','product']],
|
||||||
['label' => '결제 수수료/정책', 'route' => 'admin.fees.index','roles' => ['super_admin','product']],
|
['label' => '상품 등록', 'route' => 'admin.products.create', 'roles' => ['super_admin','product']],
|
||||||
|
|
||||||
|
// 3) SKU/가격/권종 (상품 상세에서 같이 관리해도 되지만, 별도 메뉴가 있으면 운영 편함)
|
||||||
|
['label' => '금액권/가격 관리', 'route' => 'admin.skus.index', 'roles' => ['super_admin','product']], // ✅ 추천(신규)
|
||||||
|
|
||||||
|
// 4) 판매채널/연동코드 (DANAL/KORCULTURE/KPREPAID 코드 매핑)
|
||||||
|
['label' => '판매 코드 관리', 'route' => 'admin.sale-codes.index', 'roles' => ['super_admin','product']],
|
||||||
|
|
||||||
|
// 5) 자산(이미지) 관리
|
||||||
|
['label' => '이미지 라이브러리', 'route' => 'admin.media.index', 'roles' => ['super_admin','product']], // ✅ 추천(신규)
|
||||||
|
|
||||||
|
// 6) 자사 핀 재고/회수/추출
|
||||||
|
['label' => '핀 번호 관리', 'route' => 'admin.pins.index', 'roles' => ['super_admin','product']],
|
||||||
|
// ['label' => '핀 회수/추출', 'route' => 'admin.pins.recalls', 'roles' => ['super_admin','product']], // ✅ 핀 메뉴 내부 탭으로 처리해도 OK
|
||||||
|
|
||||||
|
// 7) 메인 노출/전시
|
||||||
|
['label' => '메인 노출 관리', 'route' => 'admin.exposure.index', 'roles' => ['super_admin','product']],
|
||||||
|
|
||||||
|
// 8) 결제 정책(상품쪽에서 설정하지만 성격상 “정책”이므로 아래쪽)
|
||||||
|
['label' => '결제 수수료/정책', 'route' => 'admin.fees.index', 'roles' => ['super_admin','product']],
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
|
||||||
[
|
[
|
||||||
'title' => '매입/정산',
|
'title' => '매입/정산',
|
||||||
'items' => [
|
'items' => [
|
||||||
@ -75,11 +94,13 @@
|
|||||||
[
|
[
|
||||||
'title' => '시스템 로그',
|
'title' => '시스템 로그',
|
||||||
'items' => [
|
'items' => [
|
||||||
['label' => '로그인 로그', 'route' => 'admin.logs.login','roles' => ['super_admin','finance','product','support']],
|
['label' => '다날 결제 로그', 'route' => 'admin.logs.pay','roles' => ['super_admin','finance','product','support']],
|
||||||
['label' => '다날 인증 로그', 'route' => 'admin.logs.danal','roles' => ['super_admin','finance','product','support']],
|
['label' => '회원 다날 인증 로그', 'route' => 'admin.systemlog.member-danalauthtel-logs','roles' => ['super_admin']],
|
||||||
['label' => '결제 로그', 'route' => 'admin.logs.pay','roles' => ['super_admin','finance','product','support']],
|
['label' => '회원 계좌번호 성명인증 로그', 'route' => 'admin.systemlog.member-account-logs','roles' => ['super_admin']],
|
||||||
['label' => '기타 로그', 'route' => 'admin.logs.misc','roles' => ['super_admin','finance','product','support']],
|
['label' => '회원 비밀번호변경 로그', 'route' => 'admin.systemlog.member-passwd-modify-logs','roles' => ['super_admin']],
|
||||||
['label' => '관리자 활동 로그', 'route' => 'admin.logs.audit','roles' => ['super_admin','finance','product','support']],
|
['label' => '회원 로그인 로그', 'route' => 'admin.systemlog.member-login-logs','roles' => ['super_admin']],
|
||||||
|
['label' => '회원 가입차단/알림 로그', 'route' => 'admin.systemlog.member-join-logs','roles' => ['super_admin']],
|
||||||
|
['label' => '관리자 활동 로그', 'route' => 'admin.systemlog.admin-audit-logs','roles' => ['super_admin']],
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@ -14,7 +14,12 @@ use App\Http\Controllers\Admin\Qna\AdminQnaController;
|
|||||||
use App\Http\Controllers\Admin\Members\AdminMembersController;
|
use App\Http\Controllers\Admin\Members\AdminMembersController;
|
||||||
use App\Http\Controllers\Admin\Members\AdminMemberMarketingController;
|
use App\Http\Controllers\Admin\Members\AdminMemberMarketingController;
|
||||||
use App\Http\Controllers\Admin\Members\AdminMemberJoinFilterController;
|
use App\Http\Controllers\Admin\Members\AdminMemberJoinFilterController;
|
||||||
|
use App\Http\Controllers\Admin\Log\AdminAuditLogController;
|
||||||
|
use App\Http\Controllers\Admin\Log\MemberJoinLogController;
|
||||||
|
use App\Http\Controllers\Admin\Log\MemberLoginLogController;
|
||||||
|
use App\Http\Controllers\Admin\Log\MemberPasswdModifyLogController;
|
||||||
|
use App\Http\Controllers\Admin\Log\MemberAccountLogController;
|
||||||
|
use App\Http\Controllers\Admin\Log\MemberDanalAuthTelLogController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::middleware(['web'])->group(function () {
|
Route::middleware(['web'])->group(function () {
|
||||||
@ -241,6 +246,33 @@ Route::middleware(['web'])->group(function () {
|
|||||||
->name('destroy');
|
->name('destroy');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Route::prefix('systemlog')
|
||||||
|
->name('admin.systemlog.')
|
||||||
|
->middleware('admin.role:super_admin')
|
||||||
|
->group(function () {
|
||||||
|
|
||||||
|
Route::get('/admin-audit-logs', [AdminAuditLogController::class, 'index'])
|
||||||
|
->name('admin-audit-logs');
|
||||||
|
|
||||||
|
Route::get('/admin-audit-logs/{id}', [AdminAuditLogController::class, 'show'])
|
||||||
|
->whereNumber('id')
|
||||||
|
->name('admin-audit-logs.show');
|
||||||
|
|
||||||
|
Route::get('/member-join-logs', [MemberJoinLogController::class, 'index'])
|
||||||
|
->name('member-join-logs');
|
||||||
|
|
||||||
|
Route::get('/member-login-logs', [MemberLoginLogController::class, 'index'])
|
||||||
|
->name('member-login-logs');
|
||||||
|
|
||||||
|
Route::get('/member-passwd-modify-logs', [MemberPasswdModifyLogController::class, 'index'])
|
||||||
|
->name('member-passwd-modify-logs');
|
||||||
|
|
||||||
|
Route::get('/member-account-logs', [MemberAccountLogController::class, 'index'])
|
||||||
|
->name('member-account-logs');
|
||||||
|
|
||||||
|
Route::get('/member-danalauthtel-logs', [MemberDanalAuthTelLogController::class, 'index'])
|
||||||
|
->name('member-danalauthtel-logs');
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user