From 0db9e2bdc51927c9d653e32ec1b90f39a1322400 Mon Sep 17 00:00:00 2001 From: sungro815 Date: Sat, 7 Feb 2026 22:51:27 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A9=94=EC=9D=BC,=20sms=20=EC=98=88=EC=95=BD?= =?UTF-8?q?=EB=B0=9C=EC=86=A1=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Admin/Sms/AdminSmsController.php | 21 +- .../Admin/Sms/AdminSmsLogController.php | 41 +- app/Services/Admin/Mail/AdminMailService.php | 15 +- app/Services/Admin/Sms/AdminSmsLogService.php | 49 +- app/Services/Admin/Sms/AdminSmsService.php | 401 +++++----- app/Services/SmsService.php | 51 +- resources/css/admin.css | 37 + .../views/admin/mail/logs/index.blade.php | 42 +- .../views/admin/mail/logs/show.blade.php | 21 +- resources/views/admin/mail/send.blade.php | 236 +++++- .../views/admin/sms/logs/index.blade.php | 49 +- resources/views/admin/sms/logs/show.blade.php | 14 +- resources/views/admin/sms/send.blade.php | 688 +++++++++++++++--- .../views/vendor/pagination/admin.blade.php | 48 ++ routes/console.php | 12 +- 15 files changed, 1299 insertions(+), 426 deletions(-) create mode 100644 resources/views/vendor/pagination/admin.blade.php diff --git a/app/Http/Controllers/Admin/Sms/AdminSmsController.php b/app/Http/Controllers/Admin/Sms/AdminSmsController.php index b95e36e..9a1d629 100644 --- a/app/Http/Controllers/Admin/Sms/AdminSmsController.php +++ b/app/Http/Controllers/Admin/Sms/AdminSmsController.php @@ -25,32 +25,19 @@ final class AdminSmsController extends Controller { $data = $request->validate([ 'from_number' => ['required','string','max:30'], - 'send_mode' => ['required','in:one,many,template'], + 'send_mode' => ['required','in:one,many,template'], // template == CSV 업로드 'message' => ['required','string','max:2000'], 'sms_type_hint' => ['nullable','in:auto,sms,mms'], + + 'schedule_type' => ['required','in:now,schedule'], 'scheduled_at' => ['nullable','date_format:Y-m-d H:i'], - // one - 'to_number' => ['nullable','string','max:30'], - - // many + 'to_number' => ['nullable','string','max:500'], 'to_numbers_text'=> ['nullable','string','max:500000'], - 'to_numbers_csv' => ['nullable','file','mimes:csv,txt','max:5120'], - - // template 'template_csv' => ['nullable','file','mimes:csv,txt','max:5120'], ]); - // template은 super_admin만 (서버 강제) - $roleNames = (array) data_get(session('admin_ctx', []), 'role_names', []); - if (($data['send_mode'] ?? '') === 'template' && !in_array('super_admin', $roleNames, true)) { - return back()->with('toast', [ - 'type' => 'danger', 'title' => '권한 없음', 'message' => '템플릿 발송은 super_admin만 가능합니다.' - ]); - } - $adminId = (int) auth('admin')->id(); - $res = $this->service->createBatch($adminId, $data, $request); if (!$res['ok']) { diff --git a/app/Http/Controllers/Admin/Sms/AdminSmsLogController.php b/app/Http/Controllers/Admin/Sms/AdminSmsLogController.php index 3f63e35..ad2689f 100644 --- a/app/Http/Controllers/Admin/Sms/AdminSmsLogController.php +++ b/app/Http/Controllers/Admin/Sms/AdminSmsLogController.php @@ -12,25 +12,29 @@ final class AdminSmsLogController extends Controller private readonly AdminSmsLogService $service, ) {} - /** - * GET admin.sms.logs - */ public function index(Request $request) { - $filters = $request->only([ - 'status', 'send_mode', 'q', 'date_from', 'date_to', + $statusKeys = array_keys($this->service->getStatusLabels()); + $modeKeys = array_keys($this->service->getModeLabels()); + + $filters = $request->validate([ + 'status' => ['nullable', 'in:'.implode(',', $statusKeys)], + 'send_mode' => ['nullable', 'in:'.implode(',', $modeKeys)], + 'date_from' => ['nullable', 'date'], + 'date_to' => ['nullable', 'date'], + 'q' => ['nullable', 'string', 'max:120'], ]); - $batches = $this->service->paginateBatches($filters, 30); + $batches = $this->service->paginateBatches($filters, 20); return view('admin.sms.logs.index', [ - 'batches' => $batches, + 'batches' => $batches, + 'filters' => $filters, + 'labels' => $this->service->getStatusLabels(), + 'modeLabels' => $this->service->getModeLabels(), ]); } - /** - * GET admin.sms.logs.show - */ public function show(int $batchId, Request $request) { $batch = $this->service->getBatch($batchId); @@ -42,12 +46,23 @@ final class AdminSmsLogController extends Controller ]); } - $filters = $request->only(['status', 'to', 'q']); + $itemStatusKeys = array_keys($this->service->getItemStatusLabels()); + + $filters = $request->validate([ + 'status' => ['nullable', 'in:'.implode(',', $itemStatusKeys)], + 'to' => ['nullable', 'string', 'max:40'], + 'q' => ['nullable', 'string', 'max:120'], + ]); + $items = $this->service->paginateItems($batchId, $filters, 50); return view('admin.sms.logs.show', [ - 'batch' => $batch, - 'items' => $items, + 'batch' => $batch, + 'items' => $items, + 'filters' => $filters, + 'labels' => $this->service->getStatusLabels(), + 'modeLabels' => $this->service->getModeLabels(), + 'itemLabels' => $this->service->getItemStatusLabels(), ]); } } diff --git a/app/Services/Admin/Mail/AdminMailService.php b/app/Services/Admin/Mail/AdminMailService.php index 1417f99..bf1cb6d 100644 --- a/app/Services/Admin/Mail/AdminMailService.php +++ b/app/Services/Admin/Mail/AdminMailService.php @@ -54,7 +54,7 @@ final class AdminMailService { return [ 'one'=>'단건', - 'many'=>'직접입력(여러건)', + 'many'=>'여러건', 'csv'=>'CSV 업로드', 'db'=>'DB 검색', ]; @@ -120,6 +120,13 @@ final class AdminMailService public function listBatches(array $filters, int $perPage=20) { + $page = $this->repo->listBatches($filters, $perPage); + + if (is_object($page) && method_exists($page, 'appends')) { + $clean = array_filter($filters, fn($v) => $v !== null && $v !== ''); + $page->appends($clean); + } + return $this->repo->listBatches($filters, $perPage); } @@ -129,6 +136,12 @@ final class AdminMailService if (!$batch) return ['ok'=>false]; $items = $this->repo->listItems($batchId, $filters, $perPage); + + if (is_object($items) && method_exists($items, 'appends')) { + $clean = array_filter($filters, fn($v) => $v !== null && $v !== ''); + $items->appends($clean); + } + return ['ok'=>true,'batch'=>$batch,'items'=>$items]; } diff --git a/app/Services/Admin/Sms/AdminSmsLogService.php b/app/Services/Admin/Sms/AdminSmsLogService.php index 8ab7c55..49ccac8 100644 --- a/app/Services/Admin/Sms/AdminSmsLogService.php +++ b/app/Services/Admin/Sms/AdminSmsLogService.php @@ -11,9 +11,48 @@ final class AdminSmsLogService private readonly AdminSmsBatchRepository $repo, ) {} + public function getStatusLabels(): array + { + return [ + 'scheduled' => '예약대기', + 'queued' => '대기', + 'submitting' => '전송중', + 'submitted' => '전송완료', + 'partial' => '일부실패', + 'failed' => '실패', + 'canceled' => '취소', + ]; + } + + public function getItemStatusLabels(): array + { + return [ + 'queued' => '대기', + 'submitted' => '성공', + 'failed' => '실패', + 'canceled' => '취소', + 'skipped' => '스킵', + ]; + } + + public function getModeLabels(): array + { + return [ + 'one' => '단건', + 'many' => '대량', + 'template' => '템플릿', + ]; + } + public function paginateBatches(array $filters, int $perPage = 30): LengthAwarePaginator { - return $this->repo->paginateBatches($filters, $perPage); + $page = $this->repo->paginateBatches($filters, $perPage); + + // ✅ 페이지 이동 시 필터 유지 + $clean = array_filter($filters, fn($v) => $v !== null && $v !== ''); + $page->appends($clean); + + return $page; } public function getBatch(int $batchId): ?object @@ -23,6 +62,12 @@ final class AdminSmsLogService public function paginateItems(int $batchId, array $filters, int $perPage = 50): LengthAwarePaginator { - return $this->repo->paginateItems($batchId, $filters, $perPage); + $page = $this->repo->paginateItems($batchId, $filters, $perPage); + + // ✅ 상세 페이지에서도 필터 유지 + $clean = array_filter($filters, fn($v) => $v !== null && $v !== ''); + $page->appends($clean); + + return $page; } } diff --git a/app/Services/Admin/Sms/AdminSmsService.php b/app/Services/Admin/Sms/AdminSmsService.php index f84b758..12dbdd2 100644 --- a/app/Services/Admin/Sms/AdminSmsService.php +++ b/app/Services/Admin/Sms/AdminSmsService.php @@ -15,19 +15,20 @@ final class AdminSmsService ) {} /** - * @param int $adminId - * @param array $data 컨트롤러 validate 결과 - * @return array ['ok'=>bool, 'batch_id'=>int, 'total'=>int, 'valid'=>int, 'invalid'=>int, 'duplicate'=>int, 'message'=>string] + * send_mode: + * - one : 단건 (전화번호만 or "전화번호,치환값..." 가능) + * - many: 대량 붙여넣기 (1줄=1명, "전화번호" 또는 "전화번호,치환값..." 가능) / 최대 100건(중복 제거 후) + * - csv : CSV 업로드 (1행=1명, phone + 치환값...) */ public function createBatch(int $adminId, array $data, Request $request): array { - $mode = (string)($data['send_mode'] ?? 'one'); - $from = (string)($data['from_number'] ?? ''); - $msgRaw = (string)($data['message'] ?? ''); + $mode = (string)($data['send_mode'] ?? 'one'); + $from = (string)($data['from_number'] ?? ''); + $msgRaw = (string)($data['message'] ?? ''); $smsTypeHint = (string)($data['sms_type_hint'] ?? 'auto'); $scheduleType = (string)($data['schedule_type'] ?? 'now'); - $scheduledAt = null; + $scheduledAt = null; if ($scheduleType === 'schedule') { $scheduledAt = !empty($data['scheduled_at']) ? $data['scheduled_at'] : null; @@ -36,36 +37,27 @@ final class AdminSmsService } } - // 1) 대상 생성 try { $targets = match ($mode) { - 'one' => $this->buildTargetsOne((string)($data['to_number'] ?? ''), $msgRaw), - 'many' => $this->buildTargetsMany( - (string)($data['to_numbers_text'] ?? ''), - $request->file('to_numbers_csv'), - $msgRaw - ), - 'template' => $this->buildTargetsTemplate( - $request->file('template_csv'), - $msgRaw - ), - default => throw new \RuntimeException('지원하지 않는 send_mode 입니다.'), + 'one' => $this->buildTargetsOne((string)($data['to_number'] ?? ''), $msgRaw), + 'many' => $this->buildTargetsMany((string)($data['to_numbers_text'] ?? ''), $msgRaw), + 'csv' => $this->buildTargetsCsv($request->file('template_csv'), $msgRaw), + default => throw new \RuntimeException('지원하지 않는 send_mode 입니다.'), }; } catch (\Throwable $e) { return ['ok' => false, 'message' => $e->getMessage()]; } - $total = $targets['total']; - $valid = $targets['valid']; + $total = $targets['total']; + $valid = $targets['valid']; $invalid = $targets['invalid']; - $dup = $targets['duplicate']; - $items = $targets['items']; // seq 포함된 배열 + $dup = $targets['duplicate']; + $items = $targets['items']; if ($valid < 1) { return ['ok' => false, 'message' => '유효한 수신번호가 없습니다.']; } - // 2) batch insert $byteLen = $this->calcBytesCiLike($msgRaw); $batchId = $this->repo->insertBatch([ @@ -85,46 +77,43 @@ final class AdminSmsService 'invalid_count' => $invalid, 'status' => ($scheduleType === 'schedule') ? 'scheduled' : 'queued', - 'request_ip' => $request->ip(), 'user_agent' => substr((string)$request->userAgent(), 0, 255), ]); - // 3) items bulk insert (예약이면 queued, 즉시도 일단 queued로 넣고 이후 submitted/failed로 업데이트) + //sms mms 발송 $this->repo->insertItemsChunked($batchId, $items); + $this->repo->updateBatch($batchId, ['status' => 'submitting']); - // 4) 즉시 발송이면 실제 provider queue(DB)에 insert (SmsService가 sms_server에 넣음) - if ($scheduleType === 'now') { - $this->repo->updateBatch($batchId, ['status' => 'submitting']); + $submitted = 0; + $failed = 0; - $submitted = 0; - $failed = 0; + foreach ($items as $it) { + $payload = [ + 'from_number' => $from, + 'to_number' => $it['to_number'], + 'message' => $it['message_final'], + 'sms_type' => $it['sms_type'], + 'scheduled_at' => $scheduledAt, + ]; - foreach ($items as $it) { - $payload = [ - 'from_number' => $from, - 'to_number' => $it['to_number'], - 'message' => $it['message_final'], - 'sms_type' => $it['sms_type'], // 힌트(최종은 SmsService 내부 기준) - ]; + $ok = $this->smsService->send($payload, 'lguplus'); - $ok = $this->smsService->send($payload, 'lguplus'); - - if ($ok) { - $submitted++; - $this->repo->updateItemBySeq($batchId, (int)$it['seq'], [ - 'status' => 'submitted', - 'submitted_at' => now()->format('Y-m-d H:i:s'), - ]); - } else { - $failed++; - $this->repo->updateItemBySeq($batchId, (int)$it['seq'], [ - 'status' => 'failed', - 'provider_message' => 'SmsService send() returned false', - ]); - } + if ($ok) { + $submitted++; + $this->repo->updateItemBySeq($batchId, (int)$it['seq'], [ + 'status' => 'submitted', + 'submitted_at' => now()->format('Y-m-d H:i:s'), + ]); + } else { + $failed++; + $this->repo->updateItemBySeq($batchId, (int)$it['seq'], [ + 'status' => 'failed', + 'provider_message' => 'SmsService send() returned false', + ]); } + $status = 'submitted'; if ($submitted < 1) $status = 'failed'; else if ($failed > 0) $status = 'partial'; @@ -145,13 +134,19 @@ final class AdminSmsService ]; } - private function buildTargetsOne(string $to, string $message): array + /** + * 단건: "010..." 또는 "010...,값,값,값..." + */ + private function buildTargetsOne(string $toRaw, string $message): array { - $digits = $this->normalizePhone($to); + [$digits, $args] = $this->parseInlineRow($toRaw); + if (!$this->isValidKrMobile($digits)) { return ['total'=>1,'valid'=>0,'invalid'=>1,'duplicate'=>0,'items'=>[]]; } + $msgFinal = $this->applyTokensIfNeeded($message, $args, '단건 입력'); + return [ 'total' => 1, 'valid' => 1, @@ -160,146 +155,73 @@ final class AdminSmsService 'items' => [[ 'seq' => 1, 'to_number' => $digits, - 'message_final' => $message, - 'sms_type' => $this->guessSmsType($message), + 'message_final' => $msgFinal, + 'sms_type' => $this->guessSmsType($msgFinal), 'status' => 'queued', ]], ]; } - private function buildTargetsMany(string $text, ?UploadedFile $csv, string $message): array + /** + * 대량(붙여넣기): 1줄=1명 + * - 전화번호만 가능 + * - 또는 "전화번호,치환값..." 가능 (토큰 치환 지원) + * - 최대 100건 (중복 제거 후) + */ + private function buildTargetsMany(string $text, string $message): array { - $phones = []; + $lines = preg_split('/\r\n|\n|\r/', (string)$text) ?: []; + $lines = array_values(array_filter(array_map('trim', $lines), fn($v) => $v !== '')); - // 1) textarea - if (trim($text) !== '') { - $parts = preg_split('/[\s,]+/', $text) ?: []; - foreach ($parts as $p) { - $d = $this->normalizePhone((string)$p); - if ($d !== '') $phones[] = $d; - } - } - - // 2) csv (첫 컬럼만 phone으로 사용) - if ($csv instanceof UploadedFile) { - foreach ($this->readCsvLines($csv) as $row) { - if (empty($row)) continue; - $d = $this->normalizePhone((string)($row[0] ?? '')); - if ($d !== '') $phones[] = $d; - } - } - - $total = count($phones); - $validList = []; + $total = count($lines); $invalid = 0; - foreach ($phones as $p) { - if ($this->isValidKrMobile($p)) $validList[] = $p; - else $invalid++; - } + // 메시지에 토큰이 있으면 필요 args 수 계산 + $idxs = $this->extractTemplateIndexes($message); + $needArgs = count($idxs); - $uniq = array_values(array_unique($validList)); - $dup = count($validList) - count($uniq); + // phone별 첫 줄만 사용(중복 제거) + $firstByPhone = []; // phone => [args, lineNo] + $dup = 0; - $smsType = $this->guessSmsType($message); - $items = []; - $seq = 1; - foreach ($uniq as $p) { - $items[] = [ - 'seq' => $seq++, - 'to_number' => $p, - 'message_final' => $message, - 'sms_type' => $smsType, - 'status' => 'queued', - ]; - } + foreach ($lines as $i => $line) { + $lineNo = $i + 1; + [$phone, $args] = $this->parseInlineRow($line); - return [ - 'total' => $total, - 'valid' => count($uniq), - 'invalid' => $invalid, - 'duplicate' => $dup, - 'items' => $items, - ]; - } - - private function buildTargetsTemplate(?UploadedFile $csv, string $message): array - { - if (!$csv instanceof UploadedFile) { - throw new \RuntimeException('템플릿 CSV 파일을 업로드해 주세요.'); - } - - // placeholder 검증: {_text_02_}부터 연속인지 - $idxs = $this->extractTemplateIndexes($message); // [2,3,4...] - if (empty($idxs)) { - throw new \RuntimeException('문구에 템플릿 토큰({_text_02_}~)이 없습니다.'); - } - if ($idxs[0] !== 2) { - throw new \RuntimeException('첫 템플릿 토큰은 {_text_02_}로 시작해야 합니다.'); - } - for ($i=0; $i args 개수 - $lines = $this->readCsvLines($csv); - - $total = 0; - $invalid = 0; - $validPhones = []; - $rowsToBuild = []; - - $lineNo = 0; - foreach ($lines as $row) { - $lineNo++; - if (empty($row)) continue; - - $total++; - - $phone = $this->normalizePhone((string)($row[0] ?? '')); if (!$this->isValidKrMobile($phone)) { $invalid++; continue; } - // args 검증: row[1..needArgs] must exist and not empty - for ($i=0; $i<$needArgs; $i++) { - $v = (string)($row[$i+1] ?? ''); - if (trim($v) === '') { - throw new \RuntimeException("CSV {$lineNo}라인: {_text_0".(2+$i)."_} 매칭 값이 없습니다."); + // 토큰 사용중이면 이 줄에 치환값이 충분해야 함 + if ($needArgs > 0) { + $this->assertTokensContiguousFrom2($idxs, "대량 {$lineNo}라인"); + if (count($args) < $needArgs) { + throw new \RuntimeException("대량 {$lineNo}라인: 토큰 치환값이 부족합니다. 필요 {$needArgs}개인데 입력은 ".count($args)."개입니다. (형식: 전화번호,값,값,...)"); } } - $rowsToBuild[] = [$phone, $row]; // 나중에 치환 - $validPhones[] = $phone; + if (!isset($firstByPhone[$phone])) { + $firstByPhone[$phone] = [$args, $lineNo]; + } else { + $dup++; + } } - $uniqPhones = array_values(array_unique($validPhones)); - $dup = count($validPhones) - count($uniqPhones); + $uniqPhones = array_keys($firstByPhone); - // phone별 첫 등장 row만 사용(중복 번호는 제거) - $firstRowByPhone = []; - foreach ($rowsToBuild as [$phone, $row]) { - if (!isset($firstRowByPhone[$phone])) { - $firstRowByPhone[$phone] = $row; - } + // 100건 제한(중복 제거 후) + if (count($uniqPhones) > 100) { + throw new \RuntimeException('대량(붙여넣기)은 최대 100건입니다. 100건 초과는 CSV 업로드를 이용해 주세요.'); } $items = []; $seq = 1; foreach ($uniqPhones as $phone) { - $row = $firstRowByPhone[$phone]; - - $msgFinal = $message; - for ($i=0; $i<$needArgs; $i++) { - $token = '{_text_0'.(2+$i).'_}'; - $msgFinal = str_replace($token, (string)$row[$i+1], $msgFinal); - } + [$args] = $firstByPhone[$phone]; + $msgFinal = $this->applyTokensIfNeeded($message, $args, '대량 붙여넣기'); $items[] = [ 'seq' => $seq++, 'to_number' => $phone, @@ -318,6 +240,140 @@ final class AdminSmsService ]; } + /** + * CSV 업로드: 1행=1명 (phone + 치환값...) + * - 토큰 없으면 phone만 있어도 OK + * - 토큰 있으면 컬럼이 부족하면 에러 + */ + private function buildTargetsCsv(?UploadedFile $csv, string $message): array + { + if (!$csv instanceof UploadedFile) { + throw new \RuntimeException('CSV 파일을 업로드해 주세요.'); + } + + $idxs = $this->extractTemplateIndexes($message); + $needArgs = count($idxs); + if ($needArgs > 0) { + $this->assertTokensContiguousFrom2($idxs, 'CSV'); + } + + $total = 0; + $invalid = 0; + + $firstByPhone = []; // phone => row + $dup = 0; + + $lineNo = 0; + foreach ($this->readCsvLines($csv) as $row) { + $lineNo++; + if (empty($row)) continue; + + $total++; + + $phone = $this->normalizePhone((string)($row[0] ?? '')); + if (!$this->isValidKrMobile($phone)) { + $invalid++; + continue; + } + + if ($needArgs > 0) { + for ($i=0; $i<$needArgs; $i++) { + $v = trim((string)($row[$i+1] ?? '')); + if ($v === '') { + throw new \RuntimeException("CSV {$lineNo}라인: {_text_0".(2+$i)."_} 매칭 값이 없습니다."); + } + } + } + + if (!isset($firstByPhone[$phone])) { + $firstByPhone[$phone] = $row; + } else { + $dup++; + } + } + + $items = []; + $seq = 1; + foreach (array_keys($firstByPhone) as $phone) { + $row = $firstByPhone[$phone]; + + $args = []; + if ($needArgs > 0) { + for ($i=0; $i<$needArgs; $i++) $args[] = trim((string)$row[$i+1]); + } + + $msgFinal = $this->applyTokensIfNeeded($message, $args, 'CSV'); + $items[] = [ + 'seq' => $seq++, + 'to_number' => $phone, + 'message_final' => $msgFinal, + 'sms_type' => $this->guessSmsType($msgFinal), + 'status' => 'queued', + ]; + } + + return [ + 'total' => $total, + 'valid' => count($items), + 'invalid' => $invalid, + 'duplicate' => $dup, + 'items' => $items, + ]; + } + + private function parseInlineRow(string $line): array + { + $line = trim((string)$line); + if ($line === '') return ['', []]; + + $parts = array_map('trim', explode(',', $line)); + // 빈값 제거는 args의 공백도 날릴 수 있으니 "앞쪽만" 최소 정리 + $parts = array_values($parts); + + $phone = $this->normalizePhone($parts[0] ?? ''); + $args = array_slice($parts, 1); // {_text_02_}부터 순서대로 + // args는 trim만 + $args = array_map(fn($v) => trim((string)$v), $args); + + return [$phone, $args]; + } + + private function applyTokensIfNeeded(string $message, array $args, string $label): string + { + $idxs = $this->extractTemplateIndexes($message); + if (empty($idxs)) return $message; + + $this->assertTokensContiguousFrom2($idxs, $label); + + $needArgs = count($idxs); + if (count($args) < $needArgs) { + throw new \RuntimeException("{$label}: 토큰 치환값이 부족합니다. 필요 {$needArgs}개인데 입력은 ".count($args)."개입니다."); + } + + $out = $message; + for ($i=0; $i<$needArgs; $i++) { + $token = '{_text_0'.(2+$i).'_}'; + $out = str_replace($token, (string)$args[$i], $out); + } + return $out; + } + + private function assertTokensContiguousFrom2(array $idxs, string $label): void + { + $idxs = array_values($idxs); + sort($idxs); + + if ($idxs[0] !== 2) { + throw new \RuntimeException("{$label}: 첫 토큰은 {_text_02_}로 시작해야 합니다."); + } + for ($i=0; $i 90) ? 'mms' : 'sms'; } private function calcBytesCiLike(string $str): int { - // CI/JS 방식: ASCII 1, 그 외 2 $bytes = 0; $len = mb_strlen($str, 'UTF-8'); for ($i=0; $i<$len; $i++) { @@ -357,10 +411,6 @@ final class AdminSmsService return $bytes; } - /** - * CSV를 "라인 단위"로 읽어서 배열(row)로 반환 - * - UTF-8/CP949/EUC-KR 어느 정도 허용 - */ private function readCsvLines(UploadedFile $csv): \Generator { $path = $csv->getRealPath(); @@ -373,11 +423,9 @@ final class AdminSmsService while (($row = fgetcsv($fh)) !== false) { if ($row === [null] || $row === false) continue; - // encoding normalize $out = []; foreach ($row as $cell) { - $cell = (string)$cell; - $out[] = $this->toUtf8($cell); + $out[] = $this->toUtf8((string)$cell); } yield $out; } @@ -388,13 +436,8 @@ final class AdminSmsService private function toUtf8(string $s): string { - // BOM 제거 $s = preg_replace('/^\xEF\xBB\xBF/', '', $s) ?? $s; - - // 이미 UTF-8이면 그대로 if (mb_check_encoding($s, 'UTF-8')) return $s; - - // CP949/EUC-KR 가능성 $converted = @mb_convert_encoding($s, 'UTF-8', 'CP949,EUC-KR,ISO-8859-1'); return is_string($converted) ? $converted : $s; } diff --git a/app/Services/SmsService.php b/app/Services/SmsService.php index 897ee86..bd0c6fa 100644 --- a/app/Services/SmsService.php +++ b/app/Services/SmsService.php @@ -2,8 +2,6 @@ namespace App\Services; -use App\Models\Sms\ScTran; -use App\Models\Sms\MmsMsg; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Session; @@ -19,6 +17,7 @@ class SmsService * - subject (mms일 때 없으면 자동 생성) * - sms_type (sms|mms) (없으면 길이로 판단) * - country (없으면 세션/기본 82) + * - scheduled_at (옵션) : 'Y-m-d H:i' 또는 'Y-m-d H:i:s' (미래면 예약, 아니면 즉시) * * @param string|null $companyName * @return bool @@ -51,15 +50,13 @@ class SmsService return false; } - // 4) 업체별 발송 + // 4) 업체별 발송 (실제는 업체 DB insert) return match ($this->companyName) { 'lguplus' => $this->lguplusSend($payload), - 'sms2' => $this->sms2Send($payload), - default => false, + 'sms2' => $this->sms2Send($payload), + default => false, }; } catch (\Throwable $e) { - // 운영에서는 로깅 권장 - // logger()->error('SmsService send failed', ['e' => $e]); return false; } } @@ -83,21 +80,54 @@ class SmsService return true; } + /** + * ✅ 예약시간 처리(CI3 로직 그대로) + * - scheduled_at이 없으면 now() + * - 'Y-m-d H:i'면 ':00' 붙임 + * - 미래면 그 시간, 과거/형식오류면 now() + */ + private function resolveProviderSendDate(?string $scheduledAt): string + { + $now = now()->format('Y-m-d H:i:s'); + if (!$scheduledAt) return $now; + + $scheduled = trim($scheduledAt); + + // 폼은 Y-m-d H:i 로 오니 :00 붙임 + if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/', $scheduled)) { + $scheduled .= ':00'; + } + + if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $scheduled)) { + if (strtotime($now) < strtotime($scheduled)) { + return $scheduled; // 미래면 예약 + } + } + + return $now; // 과거/형식오류면 즉시 + } + /** * CI: lguplus_send 이식 * - 메시지 길이(EUC-KR) 기준 90 이하 sms, 초과 mms * - sms_type이 명시되면 그걸 우선 + * - ✅ scheduled_at이 미래면 업체 예약 컬럼에 해당 시간으로 insert + * - SMS: SC_TRAN.TR_SENDDATE + * - MMS: MMS_MSG.REQDATE */ private function lguplusSend(array $data): bool { $conn = DB::connection('sms_server'); $smsSendType = $this->resolveSendType($data); - return (bool) $conn->transaction(function () use ($conn, $smsSendType, $data) { + // ✅ 예약/즉시 결정 + $sendDate = $this->resolveProviderSendDate($data['scheduled_at'] ?? null); + + return (bool) $conn->transaction(function () use ($conn, $smsSendType, $data, $sendDate) { if ($smsSendType === 'sms') { return $conn->table('SC_TRAN')->insert([ - 'TR_SENDDATE' => now()->format('Y-m-d H:i:s'), + 'TR_SENDDATE' => $sendDate, 'TR_SENDSTAT' => '0', 'TR_MSGTYPE' => '0', 'TR_PHONE' => $data['to_number'], @@ -113,7 +143,7 @@ class SmsService 'PHONE' => $data['to_number'], 'CALLBACK' => $data['from_number'], 'STATUS' => '0', - 'REQDATE' => now()->format('Y-m-d H:i:s'), + 'REQDATE' => $sendDate, 'MSG' => $data['message'], 'FILE_CNT' => 0, 'FILE_PATH1' => '', @@ -122,7 +152,6 @@ class SmsService }); } - private function resolveSendType(array $data): string { // CI 로직과 동일한 우선순위 유지 diff --git a/resources/css/admin.css b/resources/css/admin.css index bdd70e5..3d80a2a 100644 --- a/resources/css/admin.css +++ b/resources/css/admin.css @@ -1296,3 +1296,40 @@ html,body{ height:100%; } } } +.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);} +.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--ghost{background:transparent;} +.batch-link-btn{ + display:inline-flex; + align-items:center; + justify-content:center; + padding:6px 10px; + border-radius:10px; + font-size:12px; + font-weight:800; + text-decoration:none !important; + + color:#fff !important; /* 글씨 흰색 */ + background:rgba(59,130,246,.88); + border:1px solid rgba(59,130,246,.95); + box-shadow: 0 1px 0 rgba(0,0,0,.25); +} + +.batch-link-btn:hover{ + background:rgba(59,130,246,.98); + transform: translateY(-1px); +} + +.batch-link-btn:active{ + transform: translateY(0); +} +tr.row-link{ cursor:pointer; } +tr.row-link:hover td{ + background: rgba(255,255,255,.03); +} diff --git a/resources/views/admin/mail/logs/index.blade.php b/resources/views/admin/mail/logs/index.blade.php index f472ae5..b032b75 100644 --- a/resources/views/admin/mail/logs/index.blade.php +++ b/resources/views/admin/mail/logs/index.blade.php @@ -34,21 +34,8 @@ @section('content') @php - $statusLabel = [ - 'scheduled' => '예약', - 'queued' => '대기', - 'submitting' => '발송중', - 'submitted' => '완료', - 'partial' => '부분성공', - 'failed' => '실패', - 'canceled' => '취소', - ]; - $modeLabel = [ - 'one' => '단건', - 'many' => '여러건', - 'template' => '템플릿CSV', - 'db' => 'DB검색', - ]; + $statusLabel = $labels ?? []; + $modeLabel = $modeLabels ?? []; @endphp
@@ -121,8 +108,8 @@ @php $st = (string)$b->status; $pillClass = match ($st) { - 'submitted' => 'pill--ok', - 'partial','submitting','queued','scheduled' => 'pill--warn', + 'sent' => 'pill--ok', + 'partial','sending','queued','scheduled' => 'pill--warn', 'failed','canceled' => 'pill--bad', default => 'pill--muted', }; @@ -131,10 +118,13 @@ $sent = (int)($b->sent_count ?? 0); $total = (int)($b->valid_count ?? $b->total_count ?? 0); @endphp - + - #{{ $b->id }} + + #{{ $b->id }} + + {{ $b->created_at }} {{ $b->admin_name ?? ('#'.$b->admin_user_id) }} @@ -152,7 +142,7 @@
- {{ $batches->links() }} + {{ $batches->onEachSide(1)->links('vendor.pagination.admin') }}
@@ -173,6 +163,16 @@ to.value = ''; }); })(); + document.addEventListener('click', (e) => { + const tr = e.target.closest('tr.row-link'); + if (!tr) return; + + // a, button, input 등 "클릭 가능한 요소"를 눌렀으면 기본 동작 존중 + if (e.target.closest('a,button,input,select,textarea,label')) return; + + const url = tr.getAttribute('data-url'); + if (url) window.location.href = url; + }); @endpush @endsection diff --git a/resources/views/admin/mail/logs/show.blade.php b/resources/views/admin/mail/logs/show.blade.php index 89d537e..00b99c6 100644 --- a/resources/views/admin/mail/logs/show.blade.php +++ b/resources/views/admin/mail/logs/show.blade.php @@ -22,21 +22,9 @@ @section('content') @php - $statusLabel = [ - 'scheduled' => '예약', - 'queued' => '대기', - 'submitting' => '발송중', - 'submitted' => '완료', - 'partial' => '부분성공', - 'failed' => '실패', - 'canceled' => '취소', - ]; - $modeLabel = [ - 'one' => '단건', - 'many' => '여러건', - 'template' => '템플릿CSV', - 'db' => 'DB검색', - ]; + $statusLabel = $labels ?? []; + $modeLabel = $modeLabels ?? []; + $sent = (int)($batch->sent_count ?? 0); $total = (int)($batch->valid_count ?? $batch->total_count ?? 0); @@ -149,9 +137,8 @@ -
- {{ $items->links() }} + {{ $items->onEachSide(1)->links('vendor.pagination.admin') }}
@endsection diff --git a/resources/views/admin/mail/send.blade.php b/resources/views/admin/mail/send.blade.php index 44f61df..1c66af1 100644 --- a/resources/views/admin/mail/send.blade.php +++ b/resources/views/admin/mail/send.blade.php @@ -120,8 +120,18 @@
- - (5분 단위 권장) + + {{-- ✅ 직접 입력 불가(실수 방지): readonly + 선택 버튼 --}} + + +
@@ -327,6 +337,50 @@ + {{-- ===== 예약발송 날짜/시간/분(5분단위) 선택 모달 ===== --}} + + {{-- ===== 전체 메일 형태 미리보기: 레이어 팝업 + 내부 스크롤 + iframe ===== --}}