로그인/가입 아이피 필터 작업

This commit is contained in:
sungro815 2026-02-12 13:48:48 +09:00
parent f4ac32ae33
commit f13196780e
15 changed files with 2554 additions and 4 deletions

View File

@ -0,0 +1,36 @@
<?php
namespace App\Console\Commands\Marketing;
use App\Services\Admin\Member\MemberMarketingBatchService;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
final class BuildMemberMarketingStats extends Command
{
protected $signature = 'marketing:members:build-stats
{--date= : 기준일(YYYY-MM-DD). 비우면 어제}
{--no-lock : Redis lock 없이 실행(디버그용)}';
protected $description = '전일 기준 회원 마케팅 통계(mem_marketing_*) 생성/갱신';
public function handle(MemberMarketingBatchService $service): int
{
$opt = (string) ($this->option('date') ?? '');
$asOf = $opt !== ''
? Carbon::parse($opt)->toDateString()
: now()->subDay()->toDateString();
// no-lock 옵션은 Service쪽에서 lock을 무시하려면 구조를 바꿔야 하는데,
// 운영에서는 필요 없고, 테스트는 스케줄 없이 직접 커맨드 실행하면 되니 일단 유지.
$res = $service->run($asOf);
if (($res['skipped'] ?? false) === true) {
$this->warn("SKIPPED (locked): {$asOf}");
return self::SUCCESS;
}
$this->info("DONE {$asOf} | daily={$res['daily_rows']} total={$res['total_rows']} stats={$res['stats_rows']}");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,144 @@
<?php
namespace App\Http\Controllers\Admin\Members;
use App\Http\Controllers\Controller;
use App\Services\Admin\Member\AdminMemberJoinFilterService;
use Illuminate\Http\Request;
final class AdminMemberJoinFilterController extends Controller
{
public function __construct(
private readonly AdminMemberJoinFilterService $service,
) {}
public function index(Request $request)
{
// ✅ query 전달(전역 request 의존 줄이기)
$data = $this->service->indexData($request->query());
return view('admin.members.join_filters.index', $data);
}
/**
* AJAX row
*/
public function get(int $seq, Request $request)
{
$row = $this->service->getRow($seq);
if (!$row) {
return response()->json([
'ok' => false,
'message' => 'NOT_FOUND',
'row' => null,
], 404);
}
return response()->json([
'ok' => true,
'row' => $row,
]);
}
private function safeReturnTo(?string $rt): ?string
{
$rt = trim((string)$rt);
if ($rt === '') return null;
// 상대경로만 허용 (오픈리다이렉트 방지)
if (!str_starts_with($rt, '/')) return null;
// join-filters 화면으로만 복귀 허용
if (!str_contains($rt, 'join-filters')) return null;
return $rt;
}
public function store(Request $request)
{
$payload = $this->validated($request);
$res = $this->service->store(
input: $payload,
actorName: $this->actorName(),
);
$to = $this->safeReturnTo($request->input('_return_to'))
?? route('admin.join-filters.index', [], false);
return redirect()->to($to)
->with('toast', [
'type' => ($res['ok'] ?? false) ? 'success' : 'danger',
'title' => ($res['ok'] ?? false) ? '등록 완료' : '등록 실패',
'message' => $res['message'] ?? '',
]);
}
public function update(int $seq, Request $request)
{
$payload = $this->validated($request);
$res = $this->service->update(
seq: $seq,
input: $payload,
actorName: $this->actorName(),
);
$to = $this->safeReturnTo($request->input('_return_to'))
?? route('admin.join-filters.index', [], false);
return redirect()->to($to)
->with('toast', [
'type' => ($res['ok'] ?? false) ? 'success' : 'danger',
'title' => ($res['ok'] ?? false) ? '수정 완료' : '수정 실패',
'message' => $res['message'] ?? '',
]);
}
public function destroy(int $seq, Request $request)
{
$res = $this->service->destroy($seq);
$to = $this->safeReturnTo($request->input('_return_to'))
?? route('admin.join-filters.index', [], false);
return redirect()->to($to)
->with('toast', [
'type' => ($res['ok'] ?? false) ? 'success' : 'danger',
'title' => ($res['ok'] ?? false) ? '삭제 완료' : '삭제 실패',
'message' => $res['message'] ?? '',
]);
}
private function actorName(): string
{
$name = trim((string) session('admin_ctx.name'));
if ($name !== '') return $name;
$u = auth('admin')->user();
return trim((string)($u->name ?? $u->nickname ?? $u->email ?? ''));
}
private function validated(Request $request): array
{
$data = $request->validate([
'gubun_code' => ['required', 'string', 'in:01,02'],
'filter' => ['required', 'string', 'max:50'],
'join_block' => ['required', 'string', 'in:S,A,N'],
'admin_phone' => ['nullable', 'array'],
'admin_phone.*' => ['string', 'max:30'],
// ✅ 모달에서 유지용(검증대상 아님) - withInput을 위해 받아둠
'filter_seq' => ['nullable', 'string', 'max:20'],
]);
if (($data['join_block'] ?? '') === 'S' && empty($data['admin_phone'])) {
return redirect()->back()->withInput()->withErrors([
'admin_phone' => '관리자SMS알림(S)을 선택한 경우 알림받을 관리자를 선택해야 합니다.',
])->throwResponse();
}
return $data;
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\Admin\Members;
use App\Services\Admin\Member\AdminMemberMarketingService;
use Illuminate\Http\Request;
final class AdminMemberMarketingController
{
public function __construct(
private readonly AdminMemberMarketingService $service,
) {}
public function index(Request $request)
{
$data = $this->service->index($request->all());
return view('admin.members.marketing', $data);
}
public function export(Request $request)
{
$data = $request->validate([
'zip_password' => ['required', 'string', 'min:4', 'max:64'],
// filters
'qf' => ['nullable','string','in:all,mem_no,name,email,phone'],
'q' => ['nullable','string','max:100'],
'stat_3' => ['nullable','string','in:1,2,3,4,5,6'],
'reg_from' => ['nullable','date'],
'reg_to' => ['nullable','date'],
'no_login' => ['nullable','string','in:0,1'],
'inactive_days_gte' => ['nullable','integer','min:0','max:36500'],
'has_purchase' => ['nullable','string','in:all,1,0'],
'recent_purchase' => ['nullable','string','in:all,30,90'],
'min_purchase_count' => ['nullable','integer','min:0','max:999999'],
'min_purchase_amount' => ['nullable','integer','min:0','max:999999999999'],
// 추가 옵션(접기 영역)
'optin_sms' => ['nullable','string','in:all,1,0'],
'optin_email' => ['nullable','string','in:all,1,0'],
'has_phone' => ['nullable','string','in:all,1,0'],
'has_email' => ['nullable','string','in:all,1,0'],
]);
$zipPassword = (string) $data['zip_password'];
unset($data['zip_password']);
$res = $this->service->exportZip($data, $zipPassword);
if (!($res['ok'] ?? false)) {
return redirect()->back()->with('toast', [
'type' => 'danger',
'title' => '다운로드 실패',
'message' => $res['message'] ?? '파일 생성에 실패했습니다.',
]);
}
return response()
->download($res['zip_path'], $res['download_name'])
->deleteFileAfterSend(true);
}
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Repositories\Admin\Member;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
final class AdminMemberJoinFilterRepository
{
private const TABLE = 'mem_join_filter';
public function paginateFilters(array $filters, int $perPage = 20): LengthAwarePaginator
{
$q = DB::table(self::TABLE);
$gubun = trim((string)($filters['gubun_code'] ?? ''));
if ($gubun !== '') $q->where('gubun_code', $gubun);
$jb = trim((string)($filters['join_block'] ?? ''));
if ($jb !== '') $q->where('join_block', $jb);
$ip = trim((string)($filters['filter_ip'] ?? ''));
if ($ip !== '') $q->where('filter', $ip);
$q->orderByDesc('dt_reg');
return $q->paginate($perPage)->withQueryString();
}
public function find(int $seq): ?array
{
$row = DB::table(self::TABLE)->where('seq', $seq)->first();
return $row ? (array)$row : null;
}
public function lockForUpdate(int $seq): ?array
{
$row = DB::table(self::TABLE)->where('seq', $seq)->lockForUpdate()->first();
return $row ? (array)$row : null;
}
public function insert(array $data): int
{
return (int) DB::table(self::TABLE)->insertGetId($data);
}
// ✅ bool 말고 affected rows
public function update(int $seq, array $data): int
{
return (int) DB::table(self::TABLE)->where('seq', $seq)->update($data);
}
public function delete(int $seq): int
{
return (int) DB::table(self::TABLE)->where('seq', $seq)->delete();
}
public function listActiveAdminsForSms(): array
{
return DB::table('admin_users')
->select(['id', 'email', 'name', 'phone_enc'])
->where('status', 'active')
->orderBy('name', 'asc')
->get()
->map(fn($r) => (array)$r)
->all();
}
}

View File

@ -0,0 +1,242 @@
<?php
namespace App\Repositories\Admin\Member;
use Illuminate\Support\Facades\DB;
final class AdminMemberMarketingRepository
{
public function getAsOfDate(): ?string
{
$row = DB::table('mem_marketing_stats')->selectRaw('MAX(as_of_date) as d')->first();
return $row?->d ? (string)$row->d : null;
}
public function countMatches(array $filters): int
{
return (int) $this->baseQuery($filters)->count();
}
public function fetchPreview(array $filters, int $limit = 10): array
{
return $this->baseQuery($filters)
->orderByDesc('ms.mem_no')
->limit($limit)
->get($this->selectCols())
->map(fn($r)=>(array)$r)
->all();
}
public function chunkExport(array $filters, int $chunkSize, callable $cb): void
{
$q = $this->baseQuery($filters)->orderByDesc('ms.mem_no');
$last = null;
while (true) {
$qq = clone $q;
if ($last !== null) $qq->where('ms.mem_no', '<', $last);
$rows = $qq->limit($chunkSize)->get($this->selectCols());
if ($rows->isEmpty()) break;
$arr = $rows->map(fn($r)=>(array)$r)->all();
$cb($arr);
$last = (int) end($arr)['mem_no'];
}
}
private function selectCols(): array
{
return [
'ms.mem_no',
'ms.as_of_date',
'ms.stat_3',
'ms.reg_at',
'ms.last_login_at',
'ms.has_login',
'ms.days_since_reg',
'ms.days_since_login',
'ms.has_email',
'ms.has_phone',
'ms.optin_email',
'ms.optin_sms',
'ms.optin_push',
'ms.first_purchase_at',
'ms.last_purchase_at',
'ms.purchase_count_total',
'ms.purchase_amount_total',
'ms.purchase_count_30d',
'ms.purchase_amount_30d',
'ms.purchase_count_90d',
'ms.purchase_amount_90d',
// mem_info (다운로드/표시용)
'mi.name',
'mi.email',
'mi.cell_phone',
];
}
private function baseQuery(array $filters)
{
$q = DB::table('mem_marketing_stats as ms')
->join('mem_info as mi', 'mi.mem_no', '=', 'ms.mem_no');
// ✅ 스냅샷(기준일) 고정: 기본은 최신
$asOf = trim((string)($filters['as_of_date'] ?? ''));
if ($asOf === '') $asOf = $this->getAsOfDate() ?: '';
if ($asOf !== '') $q->where('ms.as_of_date', $asOf);
// -----------------------------
// 키워드 검색
// -----------------------------
$qf = (string)($filters['qf'] ?? 'all'); // all|mem_no|name|email|phone
$keyword = trim((string)($filters['q'] ?? ''));
$phoneEnc = (string)($filters['phone_enc'] ?? '');
$digits = preg_replace('/\D+/', '', $keyword) ?? '';
$isPhoneDigits = (bool) preg_match('/^\d{10,11}$/', $digits);
if ($keyword !== '') {
switch ($qf) {
case 'mem_no':
if ($digits !== '' && ctype_digit($digits)) {
$q->where('ms.mem_no', (int)$digits);
} else {
$q->whereRaw('1=0');
}
break;
case 'name':
$q->where('mi.name', 'like', "%{$keyword}%");
break;
case 'email':
$q->where('mi.email', 'like', "%{$keyword}%");
break;
case 'phone':
if ($isPhoneDigits || $phoneEnc !== '') {
$q->where(function ($w) use ($digits, $phoneEnc, $isPhoneDigits) {
// mem_info.cell_phone 이 평문/암호문 섞여 있을 수 있어 둘 다 비교
if ($isPhoneDigits) $w->orWhere('mi.cell_phone', $digits);
if ($phoneEnc !== '') $w->orWhere('mi.cell_phone', $phoneEnc);
});
} else {
$q->whereRaw('1=0');
}
break;
default: // all
$q->where(function ($w) use ($keyword, $digits, $isPhoneDigits, $phoneEnc) {
if ($digits !== '' && ctype_digit($digits)) {
$w->orWhere('ms.mem_no', (int)$digits);
}
$w->orWhere('mi.name', 'like', "%{$keyword}%")
->orWhere('mi.email', 'like', "%{$keyword}%");
if ($isPhoneDigits) $w->orWhere('mi.cell_phone', $digits);
if ($phoneEnc !== '') $w->orWhere('mi.cell_phone', $phoneEnc);
});
break;
}
}
// -----------------------------
// stat_3
// -----------------------------
$stat3 = trim((string)($filters['stat_3'] ?? ''));
if ($stat3 !== '') {
$q->where('ms.stat_3', (int)$stat3);
}
// -----------------------------
// 가입일 범위 (달력)
// -----------------------------
$regFrom = trim((string)($filters['reg_from'] ?? ''));
if ($regFrom !== '') {
$q->where('ms.reg_at', '>=', $regFrom.' 00:00:00');
}
$regTo = trim((string)($filters['reg_to'] ?? ''));
if ($regTo !== '') {
$q->where('ms.reg_at', '<=', $regTo.' 23:59:59');
}
// -----------------------------
// 로그인 기록 없음 / 미접속일수
// -----------------------------
$noLogin = (string)($filters['no_login'] ?? '0');
if ($noLogin === '1') {
$q->where('ms.has_login', 0);
}
$inactive = $filters['inactive_days_gte'] ?? null;
if ($inactive !== null && $inactive !== '') {
$n = max(0, (int)$inactive);
$q->where(function ($w) use ($n) {
// 1) 로그인 기록 있는 회원: 마지막 로그인 기준 미접속
$w->where('ms.days_since_login', '>=', $n)
// 2) 로그인 기록 없는 회원: 가입일 기준으로 "미접속" 취급
->orWhere(function ($w2) use ($n) {
$w2->where('ms.has_login', 0)
->where('ms.days_since_reg', '>=', $n);
});
});
}
$recentLogin = $filters['recent_login_days_lte'] ?? null;
if ($recentLogin !== null && $recentLogin !== '') {
$n = max(0, (int)$recentLogin);
$q->where('ms.has_login', 1)
->whereNotNull('ms.days_since_login')
->where('ms.days_since_login', '<=', $n);
}
// -----------------------------
// 수신동의 / 정보유무
// -----------------------------
$optSms = (string)($filters['optin_sms'] ?? 'all');
if ($optSms === '1') $q->where('ms.optin_sms', 1);
if ($optSms === '0') $q->where('ms.optin_sms', 0);
$optEmail = (string)($filters['optin_email'] ?? 'all');
if ($optEmail === '1') $q->where('ms.optin_email', 1);
if ($optEmail === '0') $q->where('ms.optin_email', 0);
$hasPhone = (string)($filters['has_phone'] ?? 'all');
if ($hasPhone === '1') $q->where('ms.has_phone', 1);
if ($hasPhone === '0') $q->where('ms.has_phone', 0);
$hasEmail = (string)($filters['has_email'] ?? 'all');
if ($hasEmail === '1') $q->where('ms.has_email', 1);
if ($hasEmail === '0') $q->where('ms.has_email', 0);
// -----------------------------
// 구매조건
// -----------------------------
$hasPurchase = (string)($filters['has_purchase'] ?? 'all');
if ($hasPurchase === '1') $q->where('ms.purchase_count_total', '>', 0);
if ($hasPurchase === '0') $q->where('ms.purchase_count_total', '=', 0);
$recent = (string)($filters['recent_purchase'] ?? 'all');
if ($recent === '30') $q->where('ms.purchase_count_30d', '>', 0);
if ($recent === '90') $q->where('ms.purchase_count_90d', '>', 0);
$minCnt = $filters['min_purchase_count'] ?? null;
if ($minCnt !== null && $minCnt !== '') $q->where('ms.purchase_count_total', '>=', (int)$minCnt);
$minAmt = $filters['min_purchase_amount'] ?? null;
if ($minAmt !== null && $minAmt !== '') $q->where('ms.purchase_amount_total', '>=', (int)$minAmt);
return $q;
}
}

View File

@ -0,0 +1,221 @@
<?php
namespace App\Repositories\Admin\Member;
use Illuminate\Support\Facades\DB;
final class MemberMarketingBatchRepository
{
public function upsertDaily(string $asOfDate): int
{
$from = $asOfDate.' 00:00:00';
$to = $asOfDate.' 23:59:59';
$sql = <<<SQL
INSERT INTO mem_marketing_purchase_daily
(ymd, mem_no,
paid_count, paid_amount, paid_first_at, paid_last_at,
cancel_count, cancel_amount)
SELECT
? AS ymd,
x.mem_no,
SUM(x.paid_count) AS paid_count,
SUM(x.paid_amount) AS paid_amount,
MIN(x.paid_first_at) AS paid_first_at,
MAX(x.paid_last_at) AS paid_last_at,
SUM(x.cancel_count) AS cancel_count,
SUM(x.cancel_amount) AS cancel_amount
FROM (
SELECT
po.mem_no,
COUNT(*) AS paid_count,
SUM(po.pay_money) AS paid_amount,
MIN(po.dt_stat_pay_p) AS paid_first_at,
MAX(po.dt_stat_pay_p) AS paid_last_at,
0 AS cancel_count,
0 AS cancel_amount
FROM pin_order po
WHERE po.mem_no IS NOT NULL
AND po.stat_pay = 'p'
AND po.dt_stat_pay_p BETWEEN ? AND ?
GROUP BY po.mem_no
UNION ALL
SELECT
po.mem_no,
0 AS paid_count,
0 AS paid_amount,
NULL AS paid_first_at,
NULL AS paid_last_at,
COUNT(*) AS cancel_count,
SUM(po.pay_money) AS cancel_amount
FROM pin_order po
WHERE po.mem_no IS NOT NULL
AND po.stat_pay = 'c'
AND po.dt_stat_cancel BETWEEN ? AND ?
GROUP BY po.mem_no
) x
GROUP BY x.mem_no
ON DUPLICATE KEY UPDATE
paid_count = VALUES(paid_count),
paid_amount = VALUES(paid_amount),
paid_first_at = VALUES(paid_first_at),
paid_last_at = VALUES(paid_last_at),
cancel_count = VALUES(cancel_count),
cancel_amount = VALUES(cancel_amount);
SQL;
// rowcount는 드라이버/설정에 따라 정확하지 않을 수 있어 “실제 건수”는 별도 count로 잡는다.
DB::statement($sql, [$asOfDate, $from, $to, $from, $to]);
return (int) DB::table('mem_marketing_purchase_daily')->where('ymd', $asOfDate)->count();
}
public function rebuildPurchaseTotal(string $asOfDate): int
{
DB::statement('DROP TABLE IF EXISTS mem_marketing_purchase_total_tmp');
DB::statement('CREATE TABLE mem_marketing_purchase_total_tmp LIKE mem_marketing_purchase_total');
$sql = <<<SQL
INSERT INTO mem_marketing_purchase_total_tmp
(mem_no, first_purchase_at, last_purchase_at,
paid_count_total, paid_amount_total,
cancel_count_total, cancel_amount_total,
as_of_date)
SELECT
d.mem_no,
MIN(d.paid_first_at) AS first_purchase_at,
MAX(d.paid_last_at) AS last_purchase_at,
SUM(d.paid_count) AS paid_count_total,
SUM(d.paid_amount) AS paid_amount_total,
SUM(d.cancel_count) AS cancel_count_total,
SUM(d.cancel_amount) AS cancel_amount_total,
? AS as_of_date
FROM mem_marketing_purchase_daily d
GROUP BY d.mem_no;
SQL;
DB::statement($sql, [$asOfDate]);
// 스왑
DB::statement('DROP TABLE IF EXISTS mem_marketing_purchase_total_old');
DB::statement('RENAME TABLE mem_marketing_purchase_total TO mem_marketing_purchase_total_old, mem_marketing_purchase_total_tmp TO mem_marketing_purchase_total');
DB::statement('DROP TABLE mem_marketing_purchase_total_old');
return (int) DB::table('mem_marketing_purchase_total')->count();
}
public function rebuildStats(string $asOfDate): int
{
DB::statement('DROP TABLE IF EXISTS mem_marketing_stats_tmp');
DB::statement('CREATE TABLE mem_marketing_stats_tmp LIKE mem_marketing_stats');
$sql = <<<SQL
INSERT INTO mem_marketing_stats_tmp (
mem_no,
stat_3, stat_3_at,
reg_at, last_login_at, has_login,
days_since_reg, days_since_login,
has_email, has_phone, optin_email, optin_sms, optin_push,
pv_sns, country_code,
first_purchase_at, last_purchase_at,
purchase_count_total, purchase_amount_total,
purchase_count_30d, purchase_amount_30d,
purchase_count_90d, purchase_amount_90d,
as_of_date
)
SELECT
mi.mem_no,
CAST(mi.stat_3 AS UNSIGNED) AS stat_3,
NULLIF(mi.dt_stat_3, '0000-00-00 00:00:00') AS stat_3_at,
NULLIF(mi.dt_reg, '0000-00-00 00:00:00') AS reg_at,
NULLIF(mi.dt_login, '0000-00-00 00:00:00') AS last_login_at,
CASE WHEN NULLIF(mi.dt_login, '0000-00-00 00:00:00') IS NULL THEN 0 ELSE 1 END AS has_login,
CASE
WHEN NULLIF(mi.dt_reg, '0000-00-00 00:00:00') IS NULL THEN 0
ELSE GREATEST(DATEDIFF(?, DATE(NULLIF(mi.dt_reg, '0000-00-00 00:00:00'))), 0)
END AS days_since_reg,
CASE
WHEN NULLIF(mi.dt_login, '0000-00-00 00:00:00') IS NULL THEN NULL
ELSE GREATEST(DATEDIFF(?, DATE(NULLIF(mi.dt_login, '0000-00-00 00:00:00'))), 0)
END AS days_since_login,
CASE WHEN mi.email IS NULL OR mi.email='' THEN 0 ELSE 1 END AS has_email,
CASE WHEN mi.cell_phone IS NULL OR mi.cell_phone='' THEN 0 ELSE 1 END AS has_phone,
CASE WHEN mi.rcv_email='y' THEN 1 ELSE 0 END AS optin_email,
CASE WHEN mi.rcv_sms ='y' THEN 1 ELSE 0 END AS optin_sms,
CASE WHEN mi.rcv_push ='y' THEN 1 ELSE 0 END AS optin_push,
mi.pv_sns,
mi.country_code,
pt.first_purchase_at,
pt.last_purchase_at,
COALESCE(pt.paid_count_total, 0) AS purchase_count_total,
COALESCE(pt.paid_amount_total, 0) AS purchase_amount_total,
COALESCE(w30.c30, 0) AS purchase_count_30d,
COALESCE(w30.a30, 0) AS purchase_amount_30d,
COALESCE(w90.c90, 0) AS purchase_count_90d,
COALESCE(w90.a90, 0) AS purchase_amount_90d,
? AS as_of_date
FROM mem_info mi
LEFT JOIN mem_marketing_purchase_total pt
ON pt.mem_no = mi.mem_no
LEFT JOIN (
SELECT mem_no, SUM(paid_count) c30, SUM(paid_amount) a30
FROM mem_marketing_purchase_daily
WHERE ymd BETWEEN DATE_SUB(?, INTERVAL 29 DAY) AND ?
GROUP BY mem_no
) w30 ON w30.mem_no = mi.mem_no
LEFT JOIN (
SELECT mem_no, SUM(paid_count) c90, SUM(paid_amount) a90
FROM mem_marketing_purchase_daily
WHERE ymd BETWEEN DATE_SUB(?, INTERVAL 89 DAY) AND ?
GROUP BY mem_no
) w90 ON w90.mem_no = mi.mem_no;
SQL;
// placeholders: DATEDIFF(?,...), DATEDIFF(?,...), as_of_date, w30 range(?,?), w90 range(?,?)
DB::statement($sql, [
$asOfDate, $asOfDate,
$asOfDate,
$asOfDate, $asOfDate,
$asOfDate, $asOfDate,
]);
// 스왑
DB::statement('DROP TABLE IF EXISTS mem_marketing_stats_old');
DB::statement('RENAME TABLE mem_marketing_stats TO mem_marketing_stats_old, mem_marketing_stats_tmp TO mem_marketing_stats');
DB::statement('DROP TABLE mem_marketing_stats_old');
return (int) DB::table('mem_marketing_stats')->count();
}
public function upsertBatchRunStart(string $asOfDate): void
{
DB::statement(
"INSERT INTO marketing_batch_runs (as_of_date, status, daily_rows, total_rows, stats_rows, error_message, started_at, finished_at)
VALUES (?, 'running', 0, 0, 0, NULL, NOW(), NULL)
ON DUPLICATE KEY UPDATE status='running', error_message=NULL, started_at=VALUES(started_at), finished_at=NULL",
[$asOfDate]
);
}
public function markBatchRunSuccess(string $asOfDate, int $dailyRows, int $totalRows, int $statsRows): void
{
DB::statement(
"UPDATE marketing_batch_runs
SET status='success', daily_rows=?, total_rows=?, stats_rows=?, finished_at=NOW()
WHERE as_of_date=?",
[$dailyRows, $totalRows, $statsRows, $asOfDate]
);
}
public function markBatchRunFailed(string $asOfDate, string $err): void
{
DB::statement(
"UPDATE marketing_batch_runs
SET status='failed', error_message=?, finished_at=NOW()
WHERE as_of_date=?",
[$err, $asOfDate]
);
}
}

View File

@ -584,6 +584,9 @@ final class AdminAuthService
return [ return [
'id' => (int)$admin->id, 'id' => (int)$admin->id,
'email' => (string)($admin->email ?? ''), 'email' => (string)($admin->email ?? ''),
'name' => (string)($admin->name ?? ''),
'nickname' => (string)($admin->nickname ?? ''),
'phone_enc' => (string)($admin->phone_enc ?? ''),
'roles' => $roles, 'roles' => $roles,
'role_ids' => $roleIds, 'role_ids' => $roleIds,
'role_names' => $roleNames, 'role_names' => $roleNames,

View File

@ -0,0 +1,282 @@
<?php
namespace App\Services\Admin\Member;
use App\Repositories\Admin\Member\AdminMemberJoinFilterRepository;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
final class AdminMemberJoinFilterService
{
public function __construct(
private readonly AdminMemberJoinFilterRepository $repo,
) {}
public function indexData(array $query = []): array
{
$filters = [
'gubun_code' => (string)($query['gubun_code'] ?? request()->query('gubun_code', '')),
'join_block' => (string)($query['join_block'] ?? request()->query('join_block', '')),
'filter_ip' => trim((string)($query['filter_ip'] ?? request()->query('filter_ip', ''))),
];
if ($filters['filter_ip'] !== '' && !preg_match('/^[0-9.]+$/', $filters['filter_ip'])) {
$filters['filter_ip'] = '';
}
$page = $this->repo->paginateFilters($filters, 20);
$rows = [];
foreach ($page->items() as $item) {
$r = is_array($item) ? $item : (array)$item;
$arr = json_decode((string)($r['admin_phone'] ?? '[]'), true);
$r['admin_phone_arr'] = is_array($arr) ? $arr : [];
$rows[] = $r;
}
$adminsRaw = $this->repo->listActiveAdminsForSms();
$admins = [];
foreach ($adminsRaw as $a) {
$id = (int)($a['id'] ?? 0);
$enc = trim((string)($a['phone_enc'] ?? ''));
$digits = ($enc !== '') ? $this->decryptPhoneEncToDigits($enc, $id) : '';
$plain010 = ($digits !== '') ? $this->normalizeKrPhone($digits) : '';
$value = ($plain010 !== '') ? $plain010 : $digits;
$admins[] = [
'id' => $id,
'email' => (string)($a['email'] ?? ''),
'name' => (string)($a['name'] ?? ''),
'phone_plain' => $value,
'phone_display' => ($plain010 !== '' ? $this->formatPhone($plain010) : ($digits !== '' ? $digits : '')),
'has_phone' => ($value !== ''),
];
}
return [
'page' => $page,
'rows' => $rows,
'filters' => $filters,
'joinBlockCode' => $this->joinBlockCode(),
'joinBlockStyle' => $this->joinBlockStyle(),
'gubunCodeMap' => $this->gubunCodeMap(),
'admins' => $admins,
];
}
public function getRow(int $seq): ?array
{
$row = $this->repo->find($seq);
if (!$row) return null;
$arr = json_decode((string)($row['admin_phone'] ?? '[]'), true);
$row['admin_phone_arr'] = is_array($arr) ? $arr : [];
return $row;
}
public function store(array $input, string $actorName): array
{
try {
return DB::transaction(function () use ($input, $actorName) {
$data = $this->buildSaveData($input, $actorName);
$seq = $this->repo->insert($data);
if ($seq <= 0) return $this->fail('등록에 실패했습니다.');
return $this->ok('정상적으로 등록되었습니다.', ['seq' => $seq]);
});
} catch (\Throwable $e) {
Log::error('[join-filter] store failed', [
'err' => $e->getMessage(),
]);
// ✅ 유효성/형식 에러는 메시지 그대로 노출(관리자 페이지라 OK)
$msg = ($e instanceof \RuntimeException) ? $e->getMessage() : '등록 중 오류가 발생했습니다.';
return $this->fail($msg);
}
}
public function update(int $seq, array $input, string $actorName): array
{
try {
return DB::transaction(function () use ($seq, $input, $actorName) {
$before = $this->repo->lockForUpdate($seq);
if (!$before) return $this->fail('대상을 찾을 수 없습니다.');
$data = $this->buildSaveData($input, $actorName);
$affected = $this->repo->update($seq, $data);
// ✅ 0건(변경 없음)도 성공 처리
if ($affected === 0) return $this->ok('변경사항이 없습니다. (저장 완료)');
if ($affected > 0) return $this->ok('정상적으로 수정되었습니다.');
return $this->fail('수정에 실패했습니다.');
});
} catch (\Throwable $e) {
Log::error('[join-filter] update failed', [
'seq' => $seq,
'err' => $e->getMessage(),
]);
$msg = ($e instanceof \RuntimeException) ? $e->getMessage() : '수정 중 오류가 발생했습니다.';
return $this->fail($msg);
}
}
public function destroy(int $seq): array
{
try {
return DB::transaction(function () use ($seq) {
$before = $this->repo->lockForUpdate($seq);
if (!$before) return $this->fail('대상을 찾을 수 없습니다.');
$affected = $this->repo->delete($seq);
if ($affected <= 0) return $this->fail('삭제에 실패했습니다.');
return $this->ok('정상적으로 삭제되었습니다.');
});
} catch (\Throwable $e) {
Log::error('[join-filter] destroy failed', [
'seq' => $seq,
'err' => $e->getMessage(),
]);
return $this->fail('삭제 중 오류가 발생했습니다.');
}
}
private function buildSaveData(array $input, string $actorName): array
{
$gubunCode = (string)($input['gubun_code'] ?? '');
if (!in_array($gubunCode, ['01', '02'], true)) $gubunCode = '01';
$gubun = $this->gubunCodeMap()[$gubunCode] ?? 'IP 필터';
$joinBlock = (string)($input['join_block'] ?? 'N');
if (!in_array($joinBlock, ['S', 'A', 'N'], true)) $joinBlock = 'N';
$filter = trim((string)($input['filter'] ?? ''));
if ($filter === '' || !$this->validateIpByGubun($filter, $gubunCode)) {
throw new \RuntimeException('아이피 형식이 올바르지 않습니다. (C=3옥텟, D=4옥텟)');
}
$phones = $input['admin_phone'] ?? [];
if (!is_array($phones) || empty($phones)) {
$phones = ['SMS발송없음'];
} else {
$out = [];
foreach ($phones as $p) {
$d = preg_replace('/\D+/', '', (string)$p) ?: '';
$n = $this->normalizeKrPhone($d);
$out[] = ($n !== '' ? $n : $d);
}
$out = array_values(array_unique(array_filter($out, fn($v)=>$v !== '')));
$phones = !empty($out) ? $out : ['SMS발송없음'];
}
return [
'gubun' => $gubun,
'gubun_code' => $gubunCode,
'filter' => $filter,
'join_block' => $joinBlock,
'admin_phone' => json_encode($phones, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'admin' => $actorName,
'dt_reg' => now()->format('Y-m-d H:i:s'),
];
}
private function validateIpByGubun(string $ip, string $gubunCode): bool
{
$ip = trim($ip);
if ($ip === '' || !preg_match('/^[0-9.]+$/', $ip)) return false;
$parts = explode('.', $ip);
if ($gubunCode === '01' && count($parts) !== 3) return false;
if ($gubunCode === '02' && count($parts) !== 4) return false;
foreach ($parts as $p) {
if ($p === '' || !ctype_digit($p)) return false;
$n = (int)$p;
if ($n < 0 || $n > 255) return false;
}
return true;
}
private function gubunCodeMap(): array
{
return ['01' => 'IP C-class 필터', '02' => 'IP D-class 필터'];
}
private function joinBlockCode(): array
{
return ['S' => '관리자SMS알림', 'A' => '회원가입차단', 'N' => '필터끔'];
}
private function joinBlockStyle(): array
{
return ['S' => 'pill--warn', 'A' => 'pill--bad', 'N' => 'pill--muted'];
}
private function decryptPhoneEncToDigits(string $enc, int $adminId = 0): string
{
$enc = (string)$enc;
if ($enc === '') return '';
try {
$raw = Crypt::decryptString($enc);
$digits = preg_replace('/\D+/', '', (string)$raw) ?: '';
return preg_match('/^\d{9,15}$/', $digits) ? $digits : '';
} catch (\Throwable $e1) {
try {
$raw = decrypt($enc, false);
$digits = preg_replace('/\D+/', '', (string)$raw) ?: '';
return preg_match('/^\d{9,15}$/', $digits) ? $digits : '';
} catch (\Throwable $e2) {
if (preg_match('/^\d{9,15}$/', $enc)) return $enc;
Log::warning('[join-filter] admin phone decrypt failed', [
'admin_id' => $adminId,
'enc_len' => strlen($enc),
'enc_head' => substr($enc, 0, 16),
'e1' => $e1->getMessage(),
'e2' => $e2->getMessage(),
]);
return '';
}
}
}
private function normalizeKrPhone(string $raw): string
{
$n = preg_replace('/\D+/', '', $raw) ?? '';
if ($n === '') return '';
if (str_starts_with($n, '82')) $n = '0' . substr($n, 2);
if (!preg_match('/^01[016789]\d{7,8}$/', $n)) return '';
return $n;
}
private function 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 ok(string $msg, array $extra = []): array
{
return array_merge(['ok' => true, 'message' => $msg], $extra);
}
private function fail(string $msg): array
{
return ['ok' => false, 'message' => $msg];
}
}

View File

@ -0,0 +1,361 @@
<?php
namespace App\Services\Admin\Member;
use App\Repositories\Admin\Member\AdminMemberMarketingRepository;
use App\Support\LegacyCrypto\CiSeedCrypto;
use Illuminate\Support\Facades\Log;
final class AdminMemberMarketingService
{
public function __construct(
private readonly AdminMemberMarketingRepository $repo,
) {}
public function index(array $filters): array
{
$filters = $this->normalizeFilters($filters);
$asOfDate = $this->repo->getAsOfDate();
$total = $this->repo->countMatches($filters);
$rows = $this->repo->fetchPreview($filters, 10);
$mode = (string)($filters['login_mode'] ?? 'none');
$days = trim((string)($filters['login_days'] ?? ''));
unset($filters['inactive_days_gte'], $filters['recent_login_days_lte']); // 기존키 정리
if ($mode === 'inactive' && $days !== '') {
$filters['inactive_days_gte'] = (int)$days;
} elseif ($mode === 'recent' && $days !== '') {
$filters['recent_login_days_lte'] = (int)$days;
} else {
// none or invalid
$filters['login_mode'] = 'none';
$filters['login_days'] = '';
}
foreach ($rows as &$r) {
$plain = $this->plainPhone((string)($r['cell_phone'] ?? ''));
$r['phone_plain'] = $plain;
$r['phone_display'] = $this->formatPhone($plain);
$s3 = (string)($r['stat_3'] ?? '');
$r['stat_3_label'] = $this->stat3Map()[$s3] ?? $s3;
}
unset($r);
return [
'filters' => $filters,
'asOfDate' => $asOfDate,
'total' => $total,
'rows' => $rows,
'stat3Map' => $this->stat3Map(),
// 라디오용
'yesnoAll' => ['all'=>'전체', '1'=>'예', '0'=>'아니오'],
'agreeAll' => ['all'=>'전체', '1'=>'동의', '0'=>'미동의'],
'recentMap' => ['all'=>'전체', '30'=>'최근 30일 구매', '90'=>'최근 90일 구매'],
'qfMap' => [
'all' => '통합',
'mem_no' => '회원번호',
'name' => '성명',
'email' => '이메일',
'phone' => '전화번호',
],
];
}
public function exportZip(array $filters, string $zipPassword): array
{
$filters = $this->normalizeFilters($filters);
$asOfDate = $this->repo->getAsOfDate() ?: now()->format('Y-m-d');
$ts = now()->format('Ymd_His');
$tmpDir = storage_path('app/tmp/marketing');
if (!is_dir($tmpDir)) @mkdir($tmpDir, 0775, true);
$baseName = "marketing_members_{$asOfDate}_{$ts}";
$xlsxPath = "{$tmpDir}/{$baseName}.xlsx";
$csvPath = "{$tmpDir}/{$baseName}.csv";
$zipPath = "{$tmpDir}/{$baseName}.zip";
$useXlsx = class_exists(\PhpOffice\PhpSpreadsheet\Spreadsheet::class);
try {
if ($useXlsx) {
$this->buildXlsx($filters, $xlsxPath);
$this->makeEncryptedZip($zipPath, $zipPassword, $xlsxPath, basename($xlsxPath));
@unlink($xlsxPath);
} else {
$this->buildCsv($filters, $csvPath);
$this->makeEncryptedZip($zipPath, $zipPassword, $csvPath, basename($csvPath));
@unlink($csvPath);
}
return [
'ok' => true,
'zip_path' => $zipPath,
'download_name' => basename($zipPath),
];
} catch (\Throwable $e) {
Log::error('[marketing-export] failed', ['err' => $e->getMessage()]);
@unlink($xlsxPath); @unlink($csvPath); @unlink($zipPath);
return ['ok' => false, 'message' => '파일 생성 중 오류가 발생했습니다. (서버 로그 확인)'];
}
}
private function normalizeFilters(array $filters): array
{
$filters['qf'] = (string)($filters['qf'] ?? 'all');
$filters['q'] = trim((string)($filters['q'] ?? ''));
if ($filters['q'] !== '') {
$digits = preg_replace('/\D+/', '', (string)$filters['q']) ?? '';
if (preg_match('/^\d{10,11}$/', $digits)) {
$phone = $this->normalizeKrPhone($digits);
if ($phone !== '') $filters['phone_enc'] = $this->encryptPhone($phone);
}
}
foreach (['optin_sms','optin_email','has_phone','has_email','has_purchase','recent_purchase'] as $k) {
$filters[$k] = (string)($filters[$k] ?? 'all');
}
$filters['stat_3'] = trim((string)($filters['stat_3'] ?? ''));
$filters['reg_from'] = trim((string)($filters['reg_from'] ?? ''));
$filters['reg_to'] = trim((string)($filters['reg_to'] ?? ''));
$filters['no_login'] = (string)($filters['no_login'] ?? '0');
$filters['inactive_days_gte'] = ($filters['inactive_days_gte'] ?? '') !== '' ? (int)$filters['inactive_days_gte'] : null;
$filters['min_purchase_count'] = ($filters['min_purchase_count'] ?? '') !== '' ? (int)$filters['min_purchase_count'] : null;
$filters['min_purchase_amount'] = ($filters['min_purchase_amount'] ?? '') !== '' ? (int)$filters['min_purchase_amount'] : null;
return $filters;
}
// -------------------------
// ✅ 다운로드 헤더(한글)
// -------------------------
private function headersKorean(): array
{
return [
'회원번호',
'성명',
'전화번호',
'이메일',
'로그인상태',
'가입일시',
'최근로그인일시',
'미접속일수',
'SMS수신동의',
'이메일수신동의',
'구매여부',
'누적구매횟수',
'누적구매금액',
'최근30일구매횟수',
'최근30일구매금액',
'최근90일구매횟수',
'최근90일구매금액',
'최근구매일시',
'통계기준일',
];
}
private function buildCsv(array $filters, string $path): void
{
$fp = fopen($path, 'w');
if (!$fp) throw new \RuntimeException('cannot open csv');
// Excel 한글 깨짐 방지 BOM
fwrite($fp, "\xEF\xBB\xBF");
fputcsv($fp, $this->headersKorean());
$seed = app(CiSeedCrypto::class);
$statMap = $this->stat3Map();
$this->repo->chunkExport($filters, 2000, function (array $rows) use ($fp, $seed, $statMap) {
foreach ($rows as $r) {
$phonePlain = $this->plainPhone((string)($r['cell_phone'] ?? ''), $seed);
$pcTotal = (int)($r['purchase_count_total'] ?? 0);
$hasPurchase = $pcTotal > 0 ? '있음' : '없음';
$s3 = (string)($r['stat_3'] ?? '');
$s3Label = $statMap[$s3] ?? $s3;
fputcsv($fp, [
(int)$r['mem_no'],
(string)($r['name'] ?? ''),
$phonePlain, // 마케팅 발송용: 숫자만
(string)($r['email'] ?? ''),
$s3Label,
(string)($r['reg_at'] ?? ''),
(string)($r['last_login_at'] ?? ''),
($r['days_since_login'] === null ? '' : (int)$r['days_since_login']),
((int)($r['optin_sms'] ?? 0) === 1 ? '동의' : '미동의'),
((int)($r['optin_email'] ?? 0) === 1 ? '동의' : '미동의'),
$hasPurchase,
$pcTotal,
(int)($r['purchase_amount_total'] ?? 0),
(int)($r['purchase_count_30d'] ?? 0),
(int)($r['purchase_amount_30d'] ?? 0),
(int)($r['purchase_count_90d'] ?? 0),
(int)($r['purchase_amount_90d'] ?? 0),
(string)($r['last_purchase_at'] ?? ''),
(string)($r['as_of_date'] ?? ''),
]);
}
});
fclose($fp);
}
private function buildXlsx(array $filters, string $path): void
{
$spreadsheet = new \PhpOffice\PhpSpreadsheet\Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('회원추출');
$headers = $this->headersKorean();
foreach ($headers as $i => $h) {
$sheet->setCellValueByColumnAndRow($i + 1, 1, $h);
}
$rowNo = 2;
$seed = app(CiSeedCrypto::class);
$statMap = $this->stat3Map();
$this->repo->chunkExport($filters, 2000, function (array $rows) use ($sheet, &$rowNo, $seed, $statMap) {
foreach ($rows as $r) {
$phonePlain = $this->plainPhone((string)($r['cell_phone'] ?? ''), $seed);
$pcTotal = (int)($r['purchase_count_total'] ?? 0);
$hasPurchase = $pcTotal > 0 ? '있음' : '없음';
$s3 = (string)($r['stat_3'] ?? '');
$s3Label = $statMap[$s3] ?? $s3;
$values = [
(int)$r['mem_no'],
(string)($r['name'] ?? ''),
$phonePlain,
(string)($r['email'] ?? ''),
$s3Label,
(string)($r['reg_at'] ?? ''),
(string)($r['last_login_at'] ?? ''),
($r['days_since_login'] === null ? '' : (int)$r['days_since_login']),
((int)($r['optin_sms'] ?? 0) === 1 ? '동의' : '미동의'),
((int)($r['optin_email'] ?? 0) === 1 ? '동의' : '미동의'),
$hasPurchase,
$pcTotal,
(int)($r['purchase_amount_total'] ?? 0),
(int)($r['purchase_count_30d'] ?? 0),
(int)($r['purchase_amount_30d'] ?? 0),
(int)($r['purchase_count_90d'] ?? 0),
(int)($r['purchase_amount_90d'] ?? 0),
(string)($r['last_purchase_at'] ?? ''),
(string)($r['as_of_date'] ?? ''),
];
foreach ($values as $i => $v) {
$sheet->setCellValueByColumnAndRow($i + 1, $rowNo, $v);
}
$rowNo++;
}
});
$writer = new \PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet);
$writer->save($path);
}
private function makeEncryptedZip(string $zipPath, string $password, string $filePath, string $innerName): void
{
if (!class_exists(\ZipArchive::class)) throw new \RuntimeException('ZipArchive not available');
$zip = new \ZipArchive();
if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
throw new \RuntimeException('cannot open zip');
}
$zip->setPassword($password);
$zip->addFile($filePath, $innerName);
if (method_exists($zip, 'setEncryptionName')) {
$method = defined('\ZipArchive::EM_AES_256') ? \ZipArchive::EM_AES_256 : \ZipArchive::EM_TRAD_PKWARE;
$zip->setEncryptionName($innerName, $method, $password);
}
$zip->close();
}
// -------------------------
// Maps / helpers
// -------------------------
private function stat3Map(): array
{
return [
'1' => '로그인정상',
/*'2' => '2. 로그인만가능',
'3' => '3. 로그인불가 (접근금지)',
'4' => '4. 탈퇴완료(아이디보관)',
'5' => '5. 탈퇴완료',*/
'6' => '휴면회원',
];
}
private function normalizeKrPhone(string $raw): string
{
$n = preg_replace('/\D+/', '', $raw) ?? '';
if ($n === '') return '';
if (str_starts_with($n, '82')) $n = '0'.substr($n, 2);
if (!preg_match('/^01[016789]\d{7,8}$/', $n)) return '';
return $n;
}
private function encryptPhone(string $raw010): string
{
$seed = app(CiSeedCrypto::class);
return (string) $seed->encrypt($raw010);
}
private function plainPhone(string $cellPhoneCol, ?CiSeedCrypto $seed = null): string
{
$v = trim($cellPhoneCol);
if ($v === '') return '';
$digits = preg_replace('/\D+/', '', $v) ?? '';
if (preg_match('/^\d{10,11}$/', $digits)) return $digits;
try {
$seed = $seed ?: app(CiSeedCrypto::class);
$plain = (string) $seed->decrypt($v);
$plainDigits = preg_replace('/\D+/', '', $plain) ?? '';
if (preg_match('/^\d{10,11}$/', $plainDigits)) return $plainDigits;
} catch (\Throwable $e) {}
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);
}
}

View File

@ -0,0 +1,58 @@
<?php
namespace App\Services\Admin\Member;
use App\Repositories\Admin\Member\MemberMarketingBatchRepository;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
final class MemberMarketingBatchService
{
public function __construct(
private readonly MemberMarketingBatchRepository $repo
) {}
public function run(string $asOfDate): array
{
// 방어: 같은 날 중복 실행 방지(스케줄 withoutOverlapping + 추가 안전장치)
$lockKey = "lock:marketing:members:build-stats:{$asOfDate}";
$lock = Cache::lock($lockKey, 3600);
if (!$lock->get()) {
Log::warning('[marketing-batch] skipped by lock', ['as_of_date' => $asOfDate]);
return ['skipped' => true, 'as_of_date' => $asOfDate];
}
try {
$this->repo->upsertBatchRunStart($asOfDate);
Log::info('[marketing-batch] step A upsertDaily start', ['as_of_date' => $asOfDate]);
$dailyRows = $this->repo->upsertDaily($asOfDate);
Log::info('[marketing-batch] step A upsertDaily done', ['daily_rows' => $dailyRows]);
Log::info('[marketing-batch] step B rebuildPurchaseTotal start', ['as_of_date' => $asOfDate]);
$totalRows = $this->repo->rebuildPurchaseTotal($asOfDate);
Log::info('[marketing-batch] step B rebuildPurchaseTotal done', ['total_rows' => $totalRows]);
Log::info('[marketing-batch] step C rebuildStats start', ['as_of_date' => $asOfDate]);
$statsRows = $this->repo->rebuildStats($asOfDate);
Log::info('[marketing-batch] step C rebuildStats done', ['stats_rows' => $statsRows]);
$this->repo->markBatchRunSuccess($asOfDate, $dailyRows, $totalRows, $statsRows);
return [
'skipped' => false,
'as_of_date' => $asOfDate,
'daily_rows' => $dailyRows,
'total_rows' => $totalRows,
'stats_rows' => $statsRows,
];
} catch (\Throwable $e) {
$this->repo->markBatchRunFailed($asOfDate, $e->getMessage());
Log::error('[marketing-batch] failed', ['as_of_date' => $asOfDate, 'err' => $e->getMessage()]);
throw $e;
} finally {
optional($lock)->release();
}
}
}

View File

@ -0,0 +1,600 @@
{{-- resources/views/admin/members/join_filters/index.blade.php --}}
@extends('admin.layouts.app')
@section('title', '회원가입 필터')
@section('page_title', '회원가입 필터')
@section('page_desc', '회원가입 IP 필터를 등록/수정/삭제합니다.')
@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 .sel{width:180px;}
.filters .ip{width:240px;}
.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);}
.lbtn--danger{background:rgba(244,63,94,.88);border-color:rgba(244,63,94,.95);color:#fff;}
.lbtn--danger:hover{background:rgba(244,63,94,.98);}
.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--bad{border-color:rgba(244,63,94,.35);background:rgba(244,63,94,.10);} /* A */
.pill--warn{border-color:rgba(245,158,11,.35);background:rgba(245,158,11,.12);} /* S */
.pill--muted{opacity:.9;} /* N */
.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;}
.line_through{ text-decoration:line-through; color:#b7b7b7; opacity:.9; }
/* 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(860px, 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__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);}
.fgrid{display:grid;grid-template-columns:1fr;gap:12px;}
@media (min-width: 860px){ .fgrid{grid-template-columns:1fr 1fr;} }
.field{display:flex;flex-direction:column;gap:6px;}
.field label{font-size:12px;opacity:.85;}
.help{font-size:12px;opacity:.75;line-height:1.6;}
.radios{display:flex;gap:14px;flex-wrap:wrap;align-items:center;padding:10px;border-radius:14px;border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.03);}
.radios label{display:inline-flex;gap:8px;align-items:center;font-size:13px;opacity:.95;margin:0;}
.warnbox{border:1px solid rgba(245,158,11,.35);background:rgba(245,158,11,.10);border-radius:16px;padding:12px;}
.warnbox b{font-weight:900;}
.adminGrid{display:grid;grid-template-columns:1fr;gap:10px;margin-top:6px;}
@media (min-width: 860px){ .adminGrid{grid-template-columns:1fr 1fr;} }
.adminItem{display:flex;gap:10px;align-items:center;padding:10px;border-radius:14px;border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.03);}
.adminItem input[type=checkbox]{transform:scale(1.05);}
.adminItem__txt{display:flex;flex-direction:column;gap:4px;min-width:0;}
.adminItem__txt .t{font-weight:900;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.adminItem__txt .d{font-size:12px;opacity:.8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
</style>
@endpush
@section('content')
@php
$gubunMap = $gubunCodeMap ?? [];
$jbMap = $joinBlockCode ?? [];
$jbStyle = $joinBlockStyle ?? ['S'=>'pill--warn','A'=>'pill--bad','N'=>'pill--muted'];
$gSel = (string)($filters['gubun_code'] ?? '');
$jSel = (string)($filters['join_block'] ?? '');
$ip = (string)($filters['filter_ip'] ?? '');
// ✅ 중요: 절대 URL(https/http) 꼬임 방지용으로 relative route 사용
$indexUrl = route('admin.join-filters.index', [], false);
$storeUrl = route('admin.join-filters.store', [], false);
$getTpl = route('admin.join-filters.get', ['seq' => '__SEQ__'], false);
$updateTpl = route('admin.join-filters.update', ['seq' => '__SEQ__'], false);
// ✅ ParseError 방지: old payload는 PHP에서 먼저 만든다
$oldPayload = [
'filter_seq' => old('filter_seq', ''),
'gubun_code' => old('gubun_code', '01'),
'filter' => old('filter', ''),
'join_block' => old('join_block', 'S'),
'admin_phone' => old('admin_phone', []),
];
@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;">등록/수정은 모달에서 처리 · 수정은 AJAX로 row 로드</div>
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end;">
<form method="GET" action="{{ $indexUrl }}" class="filters">
<div>
<select class="a-input sel" name="gubun_code">
<option value="">필터구분 전체</option>
<option value="02" {{ $gSel==='02'?'selected':'' }}>D-class</option>
<option value="01" {{ $gSel==='01'?'selected':'' }}>C-class</option>
</select>
</div>
<div>
<select class="a-input sel" name="join_block">
<option value="">가입차단여부 전체</option>
<option value="A" {{ $jSel==='A'?'selected':'' }}>회원가입차단</option>
<option value="S" {{ $jSel==='S'?'selected':'' }}>관리자SMS알림</option>
<option value="N" {{ $jSel==='N'?'selected':'' }}>필터끔</option>
</select>
</div>
<div>
<input class="a-input ip" name="filter_ip" value="{{ $ip }}" placeholder="아이피만 입력 (예: 192.168.0 / 192.168.0.1)">
</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>
<button class="lbtn lbtn--primary" type="button" id="btnOpenCreate">+ 아이피차단 필터등록</button>
</div>
</div>
</div>
<div class="a-card" style="padding:16px;">
<div class="a-muted" style="margin-bottom:10px;"> <b>{{ $page->total() }}</b> (20개씩)</div>
<div style="overflow:auto;">
<table class="a-table table" style="width:100%; min-width:1100px;">
<thead>
<tr>
<th style="width:80px;">SEQ</th>
<th style="width:210px;">필터구분 [코드]</th>
<th style="width:220px;">필터내용</th>
<th style="width:220px;">가입차단여부</th>
<th>관리자전화번호</th>
<th style="width:120px;">등록자</th>
<th style="width:170px;">등록시간</th>
<th style="width:190px;">처리</th>
</tr>
</thead>
<tbody>
@forelse(($rows ?? []) as $r)
@php
$seq = (int)($r['seq'] ?? 0);
$jb = (string)($r['join_block'] ?? 'N');
$phones = $r['admin_phone_arr'] ?? [];
$styleVal = $jbStyle[$jb] ?? 'pill--muted';
$styleCls = is_array($styleVal) ? (string)($styleVal['class'] ?? 'pill--muted') : (string)$styleVal;
$trCls = ($jb === 'N') ? 'line_through' : '';
@endphp
<tr class="{{ $trCls }}">
<td class="a-muted">{{ $seq }}</td>
<td>
{{ $r['gubun'] ?? '-' }}
<span class="a-muted">[{{ $r['gubun_code'] ?? '-' }}]</span>
</td>
<td><span class="mono">{{ $r['filter'] ?? '-' }}</span></td>
<td>
<span class="pill {{ $styleCls }}">
{{ $jbMap[$jb] ?? $jb }}
</span>
</td>
<td>
@php
$formatPhone = function ($v) {
$s = trim((string)$v);
if ($s === '' || $s === 'SMS발송없음') return '';
$d = preg_replace('/\D+/', '', $s);
// 11자리(010xxxxxxxx / 011xxxxxxxx 등)
if (preg_match('/^\d{11}$/', $d)) {
return substr($d,0,3).'-'.substr($d,3,4).'-'.substr($d,7,4);
}
// 10자리(구형 일부 케이스)
if (preg_match('/^\d{10}$/', $d)) {
return substr($d,0,3).'-'.substr($d,3,3).'-'.substr($d,6,4);
}
// 그 외는 원문 유지
return $s;
};
$phonesLine = collect($phones ?? [])
->map($formatPhone)
->filter(fn($x) => $x !== '')
->implode(', ');
@endphp
@if($phonesLine === '')
<span class="a-muted">-</span>
@else
<span class="mono">{{ $phonesLine }}</span>
@endif
</td>
<td class="a-muted">{{ $r['admin'] ?? '-' }}</td>
<td class="a-muted">{{ $r['dt_reg'] ?? '-' }}</td>
<td style="display:flex;gap:8px;align-items:center;justify-content:flex-end;">
<button type="button"
class="lbtn lbtn--ghost lbtn--sm btnEdit"
data-seq="{{ $seq }}">
수정
</button>
<form method="POST" action="{{ route('admin.join-filters.destroy', ['seq'=>$seq], false) }}" style="display:inline;">
@csrf
<input type="hidden" name="_return_to" value="{{ request()->getRequestUri() }}">
<button class="lbtn lbtn--danger lbtn--sm" type="submit" onclick="return confirm('삭제할까요?');">삭제</button>
</form>
</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="joinFilterModalBack" aria-hidden="true">
<div class="modalx" role="dialog" aria-modal="true" aria-labelledby="joinFilterModalTitle">
<div class="modalx__head">
<div>
<div class="modalx__title" id="joinFilterModalTitle">회원가입 알림/차단 아이피 등록</div>
<div class="modalx__desc" id="joinFilterModalDesc">C/D class 아이피 필터를 입력하고 처리방법을 선택하세요.</div>
</div>
<button class="lbtn lbtn--ghost lbtn--sm" type="button" id="btnCloseModal">닫기 </button>
</div>
<form method="POST" id="joinFilterForm" action="{{ $storeUrl }}">
@csrf
<input type="hidden" name="_return_to" value="{{ request()->getRequestUri() }}">
{{-- join_block != S disabled된 체크값을 hidden으로 보강 제출 --}}
<div id="adminPhoneHidden"></div>
<div class="modalx__body">
<input type="hidden" name="filter_seq" id="filter_seq" value="">
{{-- 서버 에러가 있으면 모달 안에서 바로 보이게 --}}
@if($errors->any())
<div class="warnbox" style="margin-bottom:12px;">
<b>저장 실패</b><br>
<div style="margin-top:6px; font-size:12px; line-height:1.6;">
@foreach($errors->all() as $msg)
<div> {{ $msg }}</div>
@endforeach
</div>
</div>
@endif
<div class="fgrid">
<div class="field">
<label>필터구분</label>
<select class="a-input" name="gubun_code" id="gubun_code">
<option value="01">IP C class (192.168.0)</option>
<option value="02">IP D class (192.168.0.1)</option>
</select>
<div class="help">C-class: 3옥텟 / D-class: 4옥텟</div>
</div>
<div class="field">
<label>필터내용 (아이피만)</label>
<input class="a-input" type="text" name="filter" id="filter" placeholder="예: 192.168.0 또는 192.168.0.1">
<div class="help">숫자와 (.) 입력</div>
</div>
</div>
<div class="field" style="margin-top:12px;">
<label>처리방법</label>
<div class="radios" id="join_block_wrap">
<label><input type="radio" name="join_block" value="S"> <span class="pill pill--warn"> 관리자SMS알림</span></label>
<label><input type="radio" name="join_block" value="A"> <span class="pill pill--bad"> 회원가입차단</span></label>
<label><input type="radio" name="join_block" value="N"> <span class="pill pill--muted"> 필터끔</span></label>
</div>
</div>
<div class="field" style="margin-top:14px;">
<label>SMS로 알람받을 관리자 선택</label>
<div class="help">
- <b>관리자SMS알림</b> 때만 선택/변경 가능합니다.<br>
</div>
<div class="adminGrid" id="adminGrid">
@foreach(($admins ?? []) as $a0)
@php
$a = is_array($a0) ? $a0 : (array)$a0;
$pname = (string)($a['name'] ?? '');
$pemail = (string)($a['email'] ?? '');
$plain = (string)($a['phone_plain'] ?? '');
$plainDigits = preg_replace('/\D+/', '', $plain) ?: '';
$pdisp = (string)($a['phone_display'] ?? '');
$hasPhone = $plainDigits !== '';
@endphp
<label class="adminItem" style="{{ $hasPhone ? '' : 'opacity:.55;' }}">
<input type="checkbox"
name="admin_phone[]"
value="{{ $plainDigits }}"
data-orig-disabled="{{ $hasPhone ? '0' : '1' }}"
{{ $hasPhone ? '' : 'disabled' }}>
<div class="adminItem__txt">
<div class="t">{{ $pname !== '' ? $pname : '-' }}</div>
<div class="d">
{{ $pdisp !== '' ? $pdisp : '전화번호 없음' }}
@if($pemail !== '')
<span class="a-muted"> · {{ $pemail }}</span>
@endif
</div>
</div>
</label>
@endforeach
</div>
<div class="warnbox" style="margin-top:12px;">
<b>주의</b><br>
- 회원가입차단 선택 가입이 차단됩니다.<br>
- 필터끔은 리스트에 남기되 동작하지 않게 관리할 사용하세요.
</div>
</div>
</div>
<div class="modalx__foot">
<button class="lbtn lbtn--ghost" type="button" id="btnCancelModal">닫기</button>
<button class="lbtn lbtn--primary" type="submit" id="btnSubmitModal">저장하기</button>
</div>
</form>
</div>
</div>
<script>
(function(){
const getTpl = @json($getTpl);
const updateTpl = @json($updateTpl);
const storeUrl = @json($storeUrl);
const modalBack = document.getElementById('joinFilterModalBack');
const btnOpenCreate = document.getElementById('btnOpenCreate');
const btnClose = document.getElementById('btnCloseModal');
const btnCancel = document.getElementById('btnCancelModal');
const form = document.getElementById('joinFilterForm');
const titleEl = document.getElementById('joinFilterModalTitle');
const descEl = document.getElementById('joinFilterModalDesc');
const seqEl = document.getElementById('filter_seq');
const gubunEl = document.getElementById('gubun_code');
const filterEl = document.getElementById('filter');
const submitBtn = document.getElementById('btnSubmitModal');
const hiddenBox = document.getElementById('adminPhoneHidden');
const radios = Array.from(document.querySelectorAll('input[name="join_block"]'));
const adminChecks = Array.from(document.querySelectorAll('#adminGrid input[type="checkbox"][name="admin_phone[]"]'));
const digits = (s) => String(s || '').replace(/\D+/g, '');
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 = '';
};
const currentJoinBlock = () => radios.find(r => r.checked)?.value || 'N';
const syncHiddenPhones = () => {
if (!hiddenBox) return;
hiddenBox.innerHTML = '';
const jb = currentJoinBlock();
if (jb === 'S') return;
adminChecks.forEach(chk => {
if (chk.getAttribute('data-orig-disabled') === '1') return;
if (!chk.checked) return;
const v = digits(chk.value);
if (!v) return;
const inp = document.createElement('input');
inp.type = 'hidden';
inp.name = 'admin_phone[]';
inp.value = v;
hiddenBox.appendChild(inp);
});
};
const setAdminChecksEnabled = (enabled) => {
adminChecks.forEach(chk => {
if (chk.getAttribute('data-orig-disabled') === '1') return;
chk.disabled = !enabled;
});
syncHiddenPhones();
};
const pickJoinBlock = (v) => {
radios.forEach(r => r.checked = (r.value === v));
setAdminChecksEnabled(v === 'S');
};
const clearAdminChecks = () => {
adminChecks.forEach(chk => {
if (chk.getAttribute('data-orig-disabled') === '1') return;
chk.checked = false;
});
syncHiddenPhones();
};
const resetFormForCreate = () => {
titleEl.textContent = '회원가입 알림/차단 아이피 등록';
descEl.textContent = 'C/D class 아이피 필터를 입력하고 처리방법을 선택하세요.';
form.action = storeUrl;
seqEl.value = '';
gubunEl.value = '01';
filterEl.value = '';
pickJoinBlock('S');
clearAdminChecks();
submitBtn.textContent = '등록';
submitBtn.disabled = false;
};
const applyAdminChecksFromRow = (row) => {
const arr = Array.isArray(row.admin_phone_arr) ? row.admin_phone_arr : [];
const set = new Set(arr.map(v => digits(v)).filter(v => v));
adminChecks.forEach(chk => {
if (chk.getAttribute('data-orig-disabled') === '1') return;
chk.checked = set.has(digits(chk.value));
});
syncHiddenPhones();
};
const fillFormForEdit = (row) => {
const seq = row.seq ?? row.filter_seq ?? '';
titleEl.textContent = `회원가입 필터 수정 #${seq}`;
descEl.textContent = '수정 후 저장하기를 누르면 즉시 반영됩니다.';
form.action = updateTpl.replace('__SEQ__', encodeURIComponent(seq));
seqEl.value = String(seq);
gubunEl.value = String(row.gubun_code ?? '01');
filterEl.value = String(row.filter ?? '');
const jb = String(row.join_block ?? 'N');
pickJoinBlock(jb);
applyAdminChecksFromRow(row);
submitBtn.textContent = '저장';
submitBtn.disabled = false;
};
// radio change
radios.forEach(r => {
r.addEventListener('change', () => {
const v = currentJoinBlock();
setAdminChecksEnabled(v === 'S');
});
});
// open create
btnOpenCreate?.addEventListener('click', () => {
resetFormForCreate();
openModal();
setTimeout(() => filterEl?.focus(), 30);
});
// close
btnClose?.addEventListener('click', closeModal);
btnCancel?.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(); });
// submit
form?.addEventListener('submit', () => {
syncHiddenPhones();
submitBtn.disabled = true;
});
// ✅ edit -> AJAX get
Array.from(document.querySelectorAll('.btnEdit')).forEach(btn => {
btn.addEventListener('click', async () => {
const seq = btn.getAttribute('data-seq');
if (!seq) return;
const url = getTpl.replace('__SEQ__', encodeURIComponent(seq));
try {
btn.disabled = true;
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();
// ✅ 컨트롤러 응답이 {ok,row} 인데 row를 직접 쓰던게 버그였음
const row = payload?.row ?? payload;
if (!row) throw new Error('NOT_FOUND');
// 방어: admin_phone_arr 없으면 admin_phone에서 복구
if (!Array.isArray(row.admin_phone_arr)) {
try {
const a = JSON.parse(row.admin_phone || '[]');
row.admin_phone_arr = Array.isArray(a) ? a : [];
} catch (e) {
row.admin_phone_arr = [];
}
}
fillFormForEdit(row);
openModal();
setTimeout(() => filterEl?.focus(), 30);
} 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('수정 정보를 불러오지 못했습니다.');
} finally {
btn.disabled = false;
}
});
});
// default
pickJoinBlock('S');
// ✅ 서버 validation 에러가 있으면 모달 다시 열어서 입력값 복구
const hasErrors = @json($errors->any());
const oldPayload = @js($oldPayload); // ✅ ParseError 방지 + JS 안전 변환
if (hasErrors) {
const seq = String(oldPayload.filter_seq || '');
const row = {
seq: seq,
gubun_code: oldPayload.gubun_code,
filter: oldPayload.filter,
join_block: oldPayload.join_block,
admin_phone_arr: Array.isArray(oldPayload.admin_phone) ? oldPayload.admin_phone : [],
};
if (seq) fillFormForEdit(row);
else {
resetFormForCreate();
gubunEl.value = String(oldPayload.gubun_code || '01');
filterEl.value = String(oldPayload.filter || '');
pickJoinBlock(String(oldPayload.join_block || 'S'));
applyAdminChecksFromRow(row);
}
openModal();
setTimeout(() => filterEl?.focus(), 30);
}
})();
</script>
@endsection

View File

@ -0,0 +1,424 @@
@extends('admin.layouts.app')
@section('title', '마케팅 회원 추출')
@section('page_title', '마케팅 회원 추출')
@section('page_desc', '조건에 맞는 회원을 빠르게 조회하고, 전체를 ZIP(암호)로 내려받습니다. (화면은 상위 10건만 표시)')
@push('head')
<style>
.lbtn{padding:8px 12px;font-size:13px;border-radius:12px;line-height:1.1;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:6px;
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);color:inherit;cursor:pointer;}
.lbtn:hover{background:rgba(255,255,255,.10);text-decoration:none;}
.lbtn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;}
.lbtn--primary:hover{background:rgba(59,130,246,.98);}
.lbtn--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;}
.kvgrid{display:grid;grid-template-columns:1fr;gap:12px;}
@media (min-width: 980px){ .kvgrid{grid-template-columns:1fr 1fr 1fr;} }
.kv{padding:14px;border-radius:16px;border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.04);}
.kv .k{font-size:12px;opacity:.8;margin-bottom:6px;}
.kv .v{font-weight:900;}
.grid2{display:grid;grid-template-columns:1fr;gap:12px;}
@media (min-width: 980px){ .grid2{grid-template-columns:1.15fr .85fr;} }
.warnbox{border:1px solid rgba(245,158,11,.35);background:rgba(245,158,11,.10);border-radius:16px;padding:12px;}
.warnbox b{font-weight:900;}
/* radio chips */
.chipset{display:flex;flex-wrap:wrap;gap:8px;}
.chip{position:relative;}
.chip input{position:absolute;opacity:0;pointer-events:none;}
.chip label{
display:inline-flex;align-items:center;gap:6px;
padding:7px 10px;border-radius:999px;font-size:12px;cursor:pointer;
border:1px solid rgba(255,255,255,.12);
background:rgba(255,255,255,.05);
}
.chip input:checked + label{
border-color:rgba(59,130,246,.75);
background:rgba(59,130,246,.18);
}
details.optbox{border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.03);border-radius:14px;padding:10px;}
details.optbox summary{cursor:pointer; font-weight:900; list-style:none;}
details.optbox summary::-webkit-details-marker{display:none;}
.help{font-size:12px;opacity:.85;margin-top:6px;}
</style>
@endpush
@section('content')
@php
$f = $filters ?? [];
$stat3 = (string)($f['stat_3'] ?? '');
$regFrom = (string)($f['reg_from'] ?? '');
$regTo = (string)($f['reg_to'] ?? '');
// ✅ 로그인 필터: mode + days (둘 중 하나만)
$loginMode = (string)($f['login_mode'] ?? 'none'); // none|inactive|recent
$loginDays = (string)($f['login_days'] ?? '');
// 구매/최근구매
$hasPur = (string)($f['has_purchase'] ?? 'all'); // all|1|0
$recent = (string)($f['recent_purchase'] ?? 'all'); // all|30|90
$minCnt = (string)($f['min_purchase_count'] ?? '');
$minAmt = (string)($f['min_purchase_amount'] ?? '');
// 수신동의/정보유무
$optSms = (string)($f['optin_sms'] ?? 'all'); // all|1|0
$optEmail = (string)($f['optin_email'] ?? 'all'); // all|1|0
$hasPhone = (string)($f['has_phone'] ?? 'all'); // all|1|0
$hasEmail = (string)($f['has_email'] ?? 'all'); // all|1|0
@endphp
<div class="kvgrid" style="margin-bottom:16px;">
<div class="kv">
<div class="k">조건 매칭 건수</div>
<div class="v">{{ number_format((int)($total ?? 0)) }} </div>
</div>
<div class="kv">
<div class="k">화면 표시</div>
<div class="v">상위 10건만 미리보기</div>
</div>
<div class="kv">
<div class="k">통계 기준일</div>
<div class="v"><span class="mono">{{ $as_of_date ?? '-' }}</span></div>
</div>
</div>
<div class="grid2" style="margin-bottom:16px;">
{{-- 검색/필터 --}}
<form method="GET" action="{{ route('admin.marketing.index') }}">
<div class="a-card" style="padding:16px;">
<div style="font-weight:900; margin-bottom:12px;">검색/필터</div>
{{-- stat_3 --}}
<div class="a-field" style="margin-bottom:12px;">
<label class="a-label">회원상태 (stat_3)</label>
<div class="chipset">
<span class="chip">
<input id="s3_all" type="radio" name="stat_3" value="" {{ $stat3==='' ? 'checked' : '' }}>
<label for="s3_all">전체</label>
</span>
@foreach(($stat3Map ?? []) as $k=>$label)
<span class="chip">
<input id="s3_{{ $k }}" type="radio" name="stat_3" value="{{ $k }}" {{ $stat3===(string)$k ? 'checked' : '' }}>
<label for="s3_{{ $k }}">{{ $label }}</label>
</span>
@endforeach
</div>
</div>
{{-- 가입일 --}}
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:12px;">
<div class="a-field">
<label class="a-label">가입일 시작</label>
<input class="a-input" type="date" name="reg_from" value="{{ $regFrom }}" data-date>
</div>
<div class="a-field">
<label class="a-label">가입일 종료</label>
<input class="a-input" type="date" name="reg_to" value="{{ $regTo }}" data-date>
</div>
</div>
{{-- 로그인 필터 ( 하나만) --}}
<div class="a-field" style="margin-bottom:12px;">
<label class="a-label">로그인 기준 필터 (1개만 선택)</label>
<div class="chipset" style="margin-bottom:10px;">
<span class="chip">
<input id="lm_none" type="radio" name="login_mode" value="none" {{ $loginMode==='none' ? 'checked' : '' }}>
<label for="lm_none">사용 안함</label>
</span>
<span class="chip">
<input id="lm_inactive" type="radio" name="login_mode" value="inactive" {{ $loginMode==='inactive' ? 'checked' : '' }}>
<label for="lm_inactive">미접속일수(이상)</label>
</span>
<span class="chip">
<input id="lm_recent" type="radio" name="login_mode" value="recent" {{ $loginMode==='recent' ? 'checked' : '' }}>
<label for="lm_recent">최근 로그인(이내)</label>
</span>
</div>
<input class="a-input" type="number" name="login_days" min="0" value="{{ $loginDays }}"
placeholder="예: 30" id="login_days_input">
<div class="help">
- <b>미접속일수(이상)</b>: 최근 로그인으로부터 N일 이상 지난 회원<br>
- <b>최근 로그인(이내)</b>: 최근 로그인으로부터 N일 이내인 회원 (: 30 최근 30 로그인)
</div>
</div>
{{-- 구매 조건 --}}
<div class="a-field" style="margin-bottom:12px;">
<label class="a-label">구매 여부</label>
<div class="chipset" style="margin-bottom:10px;">
<span class="chip">
<input id="hp_all" type="radio" name="has_purchase" value="all" {{ $hasPur==='all' ? 'checked' : '' }}>
<label for="hp_all">전체</label>
</span>
<span class="chip">
<input id="hp_1" type="radio" name="has_purchase" value="1" {{ $hasPur==='1' ? 'checked' : '' }}>
<label for="hp_1">구매 있음</label>
</span>
<span class="chip">
<input id="hp_0" type="radio" name="has_purchase" value="0" {{ $hasPur==='0' ? 'checked' : '' }}>
<label for="hp_0">구매 없음</label>
</span>
</div>
<label class="a-label">최근 구매</label>
<div class="chipset" style="margin-bottom:10px;">
<span class="chip">
<input id="rp_all" type="radio" name="recent_purchase" value="all" {{ $recent==='all' ? 'checked' : '' }}>
<label for="rp_all">전체</label>
</span>
<span class="chip">
<input id="rp_30" type="radio" name="recent_purchase" value="30" {{ $recent==='30' ? 'checked' : '' }}>
<label for="rp_30">최근 30 구매</label>
</span>
<span class="chip">
<input id="rp_90" type="radio" name="recent_purchase" value="90" {{ $recent==='90' ? 'checked' : '' }}>
<label for="rp_90">최근 90 구매</label>
</span>
</div>
<details class="optbox" style="margin-bottom:0;">
<summary>구매 상세 조건(선택)</summary>
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-top:10px;">
<div>
<label class="a-label">최소 누적구매횟수</label>
<input class="a-input" type="number" name="min_purchase_count" min="0" value="{{ $minCnt }}" placeholder="예: 1">
</div>
<div>
<label class="a-label">최소 누적구매금액</label>
<input class="a-input" type="number" name="min_purchase_amount" min="0" value="{{ $minAmt }}" placeholder="예: 10000">
</div>
</div>
</details>
</div>
{{-- 추가 옵션 --}}
<details class="optbox" style="margin-bottom:12px;">
<summary>추가 옵션 (수신동의/정보유무)</summary>
<div style="margin-top:10px;">
<label class="a-label">SMS 수신동의</label>
<div class="chipset">
<span class="chip">
<input id="os_all" type="radio" name="optin_sms" value="all" {{ $optSms==='all' ? 'checked' : '' }}>
<label for="os_all">전체</label>
</span>
<span class="chip">
<input id="os_1" type="radio" name="optin_sms" value="1" {{ $optSms==='1' ? 'checked' : '' }}>
<label for="os_1">수신동의</label>
</span>
<span class="chip">
<input id="os_0" type="radio" name="optin_sms" value="0" {{ $optSms==='0' ? 'checked' : '' }}>
<label for="os_0">수신미동의</label>
</span>
</div>
</div>
<div style="margin-top:10px;">
<label class="a-label">이메일 수신동의</label>
<div class="chipset">
<span class="chip">
<input id="oe_all" type="radio" name="optin_email" value="all" {{ $optEmail==='all' ? 'checked' : '' }}>
<label for="oe_all">전체</label>
</span>
<span class="chip">
<input id="oe_1" type="radio" name="optin_email" value="1" {{ $optEmail==='1' ? 'checked' : '' }}>
<label for="oe_1">수신동의</label>
</span>
<span class="chip">
<input id="oe_0" type="radio" name="optin_email" value="0" {{ $optEmail==='0' ? 'checked' : '' }}>
<label for="oe_0">수신미동의</label>
</span>
</div>
</div>
<div style="margin-top:10px;">
<label class="a-label">전화번호 있음</label>
<div class="chipset">
<span class="chip">
<input id="hp2_all" type="radio" name="has_phone" value="all" {{ $hasPhone==='all' ? 'checked' : '' }}>
<label for="hp2_all">전체</label>
</span>
<span class="chip">
<input id="hp2_1" type="radio" name="has_phone" value="1" {{ $hasPhone==='1' ? 'checked' : '' }}>
<label for="hp2_1">있음</label>
</span>
<span class="chip">
<input id="hp2_0" type="radio" name="has_phone" value="0" {{ $hasPhone==='0' ? 'checked' : '' }}>
<label for="hp2_0">없음</label>
</span>
</div>
</div>
<div style="margin-top:10px;">
<label class="a-label">이메일 있음</label>
<div class="chipset">
<span class="chip">
<input id="he2_all" type="radio" name="has_email" value="all" {{ $hasEmail==='all' ? 'checked' : '' }}>
<label for="he2_all">전체</label>
</span>
<span class="chip">
<input id="he2_1" type="radio" name="has_email" value="1" {{ $hasEmail==='1' ? 'checked' : '' }}>
<label for="he2_1">있음</label>
</span>
<span class="chip">
<input id="he2_0" type="radio" name="has_email" value="0" {{ $hasEmail==='0' ? 'checked' : '' }}>
<label for="he2_0">없음</label>
</span>
</div>
</div>
</details>
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<button class="lbtn lbtn--primary" type="submit">조회</button>
<a class="lbtn" href="{{ route('admin.marketing.index') }}">초기화</a>
</div>
<div class="warnbox" style="margin-top:12px;">
<b>안내:</b> 화면은 상위 10건만 표시합니다. 다운로드는 조건에 매칭되는 전체 데이터가 포함됩니다.
</div>
</div>
</form>
{{-- 다운로드 --}}
<div class="a-card" style="padding:16px;">
<div style="font-weight:900; margin-bottom:10px;">전체 다운로드 (ZIP 암호화)</div>
<div class="warnbox" style="margin-bottom:12px;">
- 다운로드 파일에는 <b>성명/전화번호/이메일</b> 포함됩니다.<br>
- ZIP 비밀번호는 서버에 저장하지 않습니다. 분실 주의
</div>
<form method="POST" action="{{ route('admin.marketing.export') }}">
@csrf
{{-- filters hidden --}}
@foreach(($filters ?? []) as $k=>$v)
@if(is_scalar($v) && $k !== 'phone_enc')
<input type="hidden" name="{{ $k }}" value="{{ $v }}">
@endif
@endforeach
<div class="a-field" style="margin-bottom:12px;">
<label class="a-label">ZIP 비밀번호(필수)</label>
<input class="a-input" type="password" name="zip_password" required minlength="4" maxlength="64"
placeholder="예: 1234 또는 강력한 비밀번호">
@error('zip_password') <div style="color:#ff4d4f;margin-top:6px;">{{ $message }}</div> @enderror
</div>
<button class="lbtn lbtn--primary" type="submit">
전체 다운로드 ({{ number_format((int)($total ?? 0)) }})
</button>
</form>
</div>
</div>
{{-- 미리보기 --}}
<div class="a-card" style="padding:16px;">
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap;">
<div style="font-weight:900;">미리보기 (상위 10)</div>
<div class="a-muted" style="font-size:12px;"> {{ number_format((int)($total ?? 0)) }} 상위 10</div>
</div>
<div style="margin-top:12px; overflow:auto;">
<table class="a-table" style="width:100%; min-width:1200px;">
<thead>
<tr>
<th style="width:90px;">회원번호</th>
<th style="width:140px;">성명</th>
<th style="width:150px;">전화번호</th>
<th style="width:240px;">이메일</th>
<th style="width:220px;">회원상태</th>
<th style="width:150px;">가입일시</th>
<th style="width:150px;">최근로그인</th>
<th style="width:110px;">미접속일수</th>
<th style="width:110px;">누적구매횟수</th>
<th style="width:140px;">누적구매금액</th>
<th style="width:150px;">최근구매일</th>
<th style="width:110px;">SMS수신</th>
<th style="width:110px;">이메일수신</th>
</tr>
</thead>
<tbody>
@forelse(($rows ?? []) as $r)
@php
$no = (int)($r['mem_no'] ?? 0);
$inactiveDays = $r['days_since_login'];
$inactiveDays = ($inactiveDays === null ? '-' : (string)$inactiveDays);
@endphp
<tr>
<td><a class="mono" href="{{ route('admin.members.show', ['memNo'=>$no]) }}">#{{ $no }}</a></td>
<td>{{ $r['name'] ?? '-' }}</td>
<td><span class="mono">{{ $r['phone_display'] ?? '-' }}</span></td>
<td><span class="mono">{{ $r['email'] ?? '' }}</span></td>
<td><span class="mono">{{ $r['stat_3_label'] ?? ($r['stat_3'] ?? '') }}</span></td>
<td class="a-muted">{{ $r['reg_at'] ?? '-' }}</td>
<td class="a-muted">{{ $r['last_login_at'] ?? '-' }}</td>
<td class="a-muted">{{ $inactiveDays }}</td>
<td class="a-muted">{{ number_format((int)($r['purchase_count_total'] ?? 0)) }}</td>
<td class="a-muted">{{ number_format((int)($r['purchase_amount_total'] ?? 0)) }}</td>
<td class="a-muted">{{ $r['last_purchase_at'] ?? '-' }}</td>
<td class="a-muted">{{ ((int)($r['optin_sms'] ?? 0)===1) ? '수신동의' : '수신미동의' }}</td>
<td class="a-muted">{{ ((int)($r['optin_email'] ?? 0)===1) ? '수신동의' : '수신미동의' }}</td>
</tr>
@empty
<tr><td colspan="13" class="a-muted" style="padding:12px;">조건에 해당하는 데이터가 없습니다.</td></tr>
@endforelse
</tbody>
</table>
</div>
</div>
@push('scripts')
<script>
// ✅ date input 클릭/포커스 시 달력 즉시 표시 (Chrome/Edge 지원)
document.querySelectorAll('input[type="date"][data-date]').forEach(function (el) {
const tryShowPicker = function (ev) {
// isTrusted: 실제 사용자 입력만 허용
if (ev && ev.isTrusted === false) return;
if (typeof el.showPicker === 'function') {
try {
el.showPicker();
} catch (e) {
// 일부 브라우저/상황에서 막히면 조용히 무시 (기본 동작에 맡김)
}
}
};
// focus는 user gesture로 인정 안 되는 케이스가 많아서 제거
el.addEventListener('pointerdown', tryShowPicker);
el.addEventListener('mousedown', tryShowPicker); // pointerdown 미지원 대비
el.addEventListener('click', tryShowPicker); // 마지막 보루
});
// ✅ 로그인 필터: mode에 따라 숫자 입력 활성/비활성
const modeEls = document.querySelectorAll('input[name="login_mode"]');
const daysEl = document.getElementById('login_days_input');
function applyLoginMode() {
const mode = document.querySelector('input[name="login_mode"]:checked')?.value || 'none';
if (mode === 'none') {
daysEl.value = '';
daysEl.setAttribute('disabled', 'disabled');
} else {
daysEl.removeAttribute('disabled');
daysEl.focus();
}
}
modeEls.forEach(r => r.addEventListener('change', applyLoginMode));
applyLoginMode();
</script>
@endpush
@endsection

View File

@ -18,10 +18,8 @@
'title' => '회원/정책', 'title' => '회원/정책',
'items' => [ 'items' => [
['label' => '회원 관리', 'route' => 'admin.members.index','roles' => ['super_admin','support']], ['label' => '회원 관리', 'route' => 'admin.members.index','roles' => ['super_admin','support']],
['label' => '회원 데이터추출', 'route' => 'admin.signup-filter.index','roles' => ['super_admin','support']], ['label' => '회원 데이터추출', 'route' => 'admin.marketing.index','roles' => ['super_admin','support']],
['label' => '로그인/가입 아이피 필터설정', 'route' => 'admin.signup-filter.index','roles' => ['super_admin','support']], ['label' => '로그인/가입 아이피 필터설정', 'route' => 'admin.join-filters.index','roles' => ['super_admin','support']],
['label' => '블랙리스트/제재', 'route' => 'admin.sanctions.index','roles' => ['super_admin','support']],
['label' => '마케팅 수신동의', 'route' => 'admin.marketing.index','roles' => ['super_admin','support']],
], ],
], ],
[ [

View File

@ -12,6 +12,9 @@ use App\Http\Controllers\Admin\Mail\AdminMailTemplateController;
use App\Http\Controllers\Admin\Notice\AdminNoticeController; use App\Http\Controllers\Admin\Notice\AdminNoticeController;
use App\Http\Controllers\Admin\Qna\AdminQnaController; use App\Http\Controllers\Admin\Qna\AdminQnaController;
use App\Http\Controllers\Admin\Members\AdminMembersController; use App\Http\Controllers\Admin\Members\AdminMembersController;
use App\Http\Controllers\Admin\Members\AdminMemberMarketingController;
use App\Http\Controllers\Admin\Members\AdminMemberJoinFilterController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::middleware(['web'])->group(function () { Route::middleware(['web'])->group(function () {
@ -201,6 +204,44 @@ Route::middleware(['web'])->group(function () {
->whereNumber('memNo') ->whereNumber('memNo')
->name('force_out'); ->name('force_out');
}); });
Route::prefix('marketing')
->name('admin.marketing.')
->middleware('admin.role:super_admin,support')
->group(function () {
Route::get('/', [AdminMemberMarketingController::class, 'index'])
->name('index');
Route::post('/export', [AdminMemberMarketingController::class, 'export'])
->name('export');
});
Route::prefix('join-filters')
->name('admin.join-filters.')
->middleware('admin.role:super_admin')
->group(function () {
Route::get('/', [AdminMemberJoinFilterController::class, 'index'])
->name('index');
Route::get('/{seq}', [AdminMemberJoinFilterController::class, 'get'])
->whereNumber('seq')
->name('get'); // AJAX row
Route::post('/', [AdminMemberJoinFilterController::class, 'store'])
->name('store');
Route::match(['POST','PUT'], '/{seq}', [AdminMemberJoinFilterController::class, 'update'])
->whereNumber('seq')
->name('update');
Route::match(['POST','DELETE'], '/{seq}/delete', [AdminMemberJoinFilterController::class, 'destroy'])
->whereNumber('seq')
->name('destroy');
});
}); });
}); });

View File

@ -115,6 +115,12 @@ registerScheduleCron('admin_mail_dispatch_due', '* * * * *', 'admin-mail:dispatc
'run_in_background' => true, 'run_in_background' => true,
]); ]);
registerScheduleCron('marketing_members_stats_daily', '10 3 * * *', 'marketing:members:build-stats', [
'without_overlapping' => true,
'on_one_server' => true,
'run_in_background' => true,
]);