From 7e04708d79b9a9adf868ff817400d076a9db87a2 Mon Sep 17 00:00:00 2001 From: sungro815 Date: Fri, 6 Feb 2026 09:45:11 +0900 Subject: [PATCH] =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B8=20ui/ux=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=B0=8F=20sms=20=EB=B0=9C=EC=86=A1=EA=B4=80?= =?UTF-8?q?=EB=A6=AC,=20=ED=85=9C=ED=94=8C=EB=A6=BF,=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=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 | 69 ++ .../Admin/Sms/AdminSmsLogController.php | 53 ++ .../Admin/Sms/AdminSmsTemplateController.php | 145 +++++ .../Admin/Sms/AdminSmsBatchRepository.php | 152 +++++ .../Admin/Sms/AdminSmsTemplateRepository.php | 66 ++ app/Services/Admin/Sms/AdminSmsLogService.php | 28 + app/Services/Admin/Sms/AdminSmsService.php | 401 ++++++++++++ .../Admin/Sms/AdminSmsTemplateService.php | 59 ++ app/Services/SmsService.php | 50 +- resources/js/admin.js | 3 + resources/views/admin/admins/create.blade.php | 61 +- resources/views/admin/admins/edit.blade.php | 358 ++++++----- resources/views/admin/admins/index.blade.php | 242 ++++--- resources/views/admin/me/password.blade.php | 66 +- resources/views/admin/me/security.blade.php | 301 ++++++--- resources/views/admin/me/show.blade.php | 292 +++++---- .../views/admin/partials/sidebar.blade.php | 4 +- .../views/admin/sms/logs/index.blade.php | 224 +++++++ resources/views/admin/sms/logs/show.blade.php | 158 +++++ resources/views/admin/sms/send.blade.php | 594 ++++++++++++++++++ .../views/admin/templates/form.blade.php | 159 +++++ .../views/admin/templates/index.blade.php | 127 ++++ routes/admin.php | 34 +- 23 files changed, 3099 insertions(+), 547 deletions(-) create mode 100644 app/Http/Controllers/Admin/Sms/AdminSmsController.php create mode 100644 app/Http/Controllers/Admin/Sms/AdminSmsLogController.php create mode 100644 app/Http/Controllers/Admin/Sms/AdminSmsTemplateController.php create mode 100644 app/Repositories/Admin/Sms/AdminSmsBatchRepository.php create mode 100644 app/Repositories/Admin/Sms/AdminSmsTemplateRepository.php create mode 100644 app/Services/Admin/Sms/AdminSmsLogService.php create mode 100644 app/Services/Admin/Sms/AdminSmsService.php create mode 100644 app/Services/Admin/Sms/AdminSmsTemplateService.php create mode 100644 resources/views/admin/sms/logs/index.blade.php create mode 100644 resources/views/admin/sms/logs/show.blade.php create mode 100644 resources/views/admin/sms/send.blade.php create mode 100644 resources/views/admin/templates/form.blade.php create mode 100644 resources/views/admin/templates/index.blade.php diff --git a/app/Http/Controllers/Admin/Sms/AdminSmsController.php b/app/Http/Controllers/Admin/Sms/AdminSmsController.php new file mode 100644 index 0000000..b95e36e --- /dev/null +++ b/app/Http/Controllers/Admin/Sms/AdminSmsController.php @@ -0,0 +1,69 @@ +templateService->activeForSend(200); + return view('admin.sms.send', [ + 'templates' => $templates, + ]); + } + + public function store(Request $request) + { + $data = $request->validate([ + 'from_number' => ['required','string','max:30'], + 'send_mode' => ['required','in:one,many,template'], + 'message' => ['required','string','max:2000'], + 'sms_type_hint' => ['nullable','in:auto,sms,mms'], + 'scheduled_at' => ['nullable','date_format:Y-m-d H:i'], + + // one + 'to_number' => ['nullable','string','max:30'], + + // many + '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']) { + return back()->with('toast', [ + 'type' => 'danger', 'title' => '실패', 'message' => $res['message'] ?? '처리에 실패했습니다.' + ])->withInput(); + } + + return redirect()->route('admin.sms.logs.show', ['batchId' => $res['batch_id']]) + ->with('toast', [ + 'type' => 'success', + 'title' => '접수 완료', + 'message' => "총 {$res['total']}건 중 유효 {$res['valid']}건을 등록했습니다." + ]); + } +} diff --git a/app/Http/Controllers/Admin/Sms/AdminSmsLogController.php b/app/Http/Controllers/Admin/Sms/AdminSmsLogController.php new file mode 100644 index 0000000..3f63e35 --- /dev/null +++ b/app/Http/Controllers/Admin/Sms/AdminSmsLogController.php @@ -0,0 +1,53 @@ +only([ + 'status', 'send_mode', 'q', 'date_from', 'date_to', + ]); + + $batches = $this->service->paginateBatches($filters, 30); + + return view('admin.sms.logs.index', [ + 'batches' => $batches, + ]); + } + + /** + * GET admin.sms.logs.show + */ + public function show(int $batchId, Request $request) + { + $batch = $this->service->getBatch($batchId); + if (!$batch) { + return redirect()->route('admin.sms.logs')->with('toast', [ + 'type' => 'danger', + 'title' => '없음', + 'message' => '해당 발송 이력을 찾을 수 없습니다.', + ]); + } + + $filters = $request->only(['status', 'to', 'q']); + $items = $this->service->paginateItems($batchId, $filters, 50); + + return view('admin.sms.logs.show', [ + 'batch' => $batch, + 'items' => $items, + ]); + } +} diff --git a/app/Http/Controllers/Admin/Sms/AdminSmsTemplateController.php b/app/Http/Controllers/Admin/Sms/AdminSmsTemplateController.php new file mode 100644 index 0000000..8c6aed9 --- /dev/null +++ b/app/Http/Controllers/Admin/Sms/AdminSmsTemplateController.php @@ -0,0 +1,145 @@ +only(['active', 'q']); + $templates = $this->service->list($filters, 30); + + return view('admin.templates.index', [ + 'templates' => $templates, + ]); + } + + /** + * GET admin.templates.create + */ + public function create() + { + return view('admin.templates.form', [ + 'mode' => 'create', + 'tpl' => (object)[ + 'id' => null, + 'code' => '', + 'title' => '', + 'body' => '', + 'description' => '', + 'is_active' => 1, + ], + ]); + } + + /** + * POST admin.templates.store + */ + public function store(Request $request) + { + $data = $request->validate([ + 'code' => ['required', 'string', 'max:60', 'regex:/^[a-zA-Z0-9\-_]{3,60}$/'], + 'title' => ['required', 'string', 'max:120'], + 'body' => ['required', 'string', 'max:5000'], + 'description' => ['nullable', 'string', 'max:255'], + // checkbox는 존재 여부로 처리 + ]); + + $adminId = (int) auth('admin')->id(); + + $res = $this->service->create($adminId, [ + 'code' => $data['code'], + 'title' => $data['title'], + 'body' => $data['body'], + 'description' => $data['description'] ?? null, + 'is_active' => $request->has('is_active') ? 1 : 0, + ]); + + if (!$res['ok']) { + return back()->withInput()->with('toast', [ + 'type' => 'danger', + 'title' => '실패', + 'message' => $res['message'] ?? '템플릿 생성에 실패했습니다.', + ]); + } + + return redirect()->route('admin.templates.edit', ['id' => $res['id']])->with('toast', [ + 'type' => 'success', + 'title' => '완료', + 'message' => '템플릿이 생성되었습니다.', + ]); + } + + /** + * GET admin.templates.edit + */ + public function edit(int $id) + { + $tpl = $this->service->get($id); + if (!$tpl) { + return redirect()->route('admin.templates.index')->with('toast', [ + 'type' => 'danger', + 'title' => '없음', + 'message' => '템플릿을 찾을 수 없습니다.', + ]); + } + + return view('admin.templates.form', [ + 'mode' => 'edit', + 'tpl' => $tpl, + ]); + } + + /** + * PUT admin.templates.update + */ + public function update(int $id, Request $request) + { + $tpl = $this->service->get($id); + if (!$tpl) { + return redirect()->route('admin.templates.index')->with('toast', [ + 'type' => 'danger', + 'title' => '없음', + 'message' => '템플릿을 찾을 수 없습니다.', + ]); + } + + $data = $request->validate([ + 'title' => ['required', 'string', 'max:120'], + 'body' => ['required', 'string', 'max:5000'], + 'description' => ['nullable', 'string', 'max:255'], + ]); + + $res = $this->service->update($id, [ + 'title' => $data['title'], + 'body' => $data['body'], + 'description' => $data['description'] ?? null, + 'is_active' => $request->has('is_active') ? 1 : 0, + ]); + + if (!$res['ok']) { + return back()->withInput()->with('toast', [ + 'type' => 'danger', + 'title' => '실패', + 'message' => '템플릿 수정에 실패했습니다.', + ]); + } + + return redirect()->route('admin.templates.edit', ['id' => $id])->with('toast', [ + 'type' => 'success', + 'title' => '완료', + 'message' => '저장되었습니다.', + ]); + } +} diff --git a/app/Repositories/Admin/Sms/AdminSmsBatchRepository.php b/app/Repositories/Admin/Sms/AdminSmsBatchRepository.php new file mode 100644 index 0000000..8acae9f --- /dev/null +++ b/app/Repositories/Admin/Sms/AdminSmsBatchRepository.php @@ -0,0 +1,152 @@ +insertGetId([ + 'admin_user_id' => (int)($data['admin_user_id'] ?? 0), + 'from_number' => (string)($data['from_number'] ?? ''), + 'send_mode' => (string)($data['send_mode'] ?? 'one'), + 'schedule_type' => (string)($data['schedule_type'] ?? 'now'), + 'scheduled_at' => $data['scheduled_at'] ?? null, + + 'message_raw' => (string)($data['message_raw'] ?? ''), + 'byte_len' => (int)($data['byte_len'] ?? 0), + 'sms_type_hint' => (string)($data['sms_type_hint'] ?? 'auto'), + + 'total_count' => (int)($data['total_count'] ?? 0), + 'valid_count' => (int)($data['valid_count'] ?? 0), + 'duplicate_count' => (int)($data['duplicate_count'] ?? 0), + 'invalid_count' => (int)($data['invalid_count'] ?? 0), + + 'status' => (string)($data['status'] ?? 'queued'), + + 'request_ip' => $data['request_ip'] ?? null, + 'user_agent' => $data['user_agent'] ?? null, + 'error_message' => $data['error_message'] ?? null, + + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + /** + * items: [['seq'=>1,'to_number'=>'010...','message_final'=>'...','sms_type'=>'sms','status'=>'queued', ...], ...] + */ + public function insertItemsChunked(int $batchId, array $items, int $chunkSize = 1000): void + { + $now = now(); + $buf = []; + + foreach ($items as $it) { + $buf[] = [ + 'batch_id' => $batchId, + 'seq' => (int)($it['seq'] ?? 0), + 'to_number' => (string)($it['to_number'] ?? ''), + 'message_final' => (string)($it['message_final'] ?? ''), + 'sms_type' => (string)($it['sms_type'] ?? 'sms'), + 'status' => (string)($it['status'] ?? 'queued'), + 'provider' => (string)($it['provider'] ?? 'lguplus'), + 'provider_msg_id' => $it['provider_msg_id'] ?? null, + 'provider_code' => $it['provider_code'] ?? null, + 'provider_message' => $it['provider_message'] ?? null, + 'submitted_at' => $it['submitted_at'] ?? null, + 'created_at' => $now, + 'updated_at' => $now, + ]; + + if (count($buf) >= $chunkSize) { + DB::table('admin_sms_batch_items')->insert($buf); + $buf = []; + } + } + + if (!empty($buf)) { + DB::table('admin_sms_batch_items')->insert($buf); + } + } + + public function updateBatch(int $batchId, array $data): int + { + $data['updated_at'] = now(); + return (int) DB::table('admin_sms_batches')->where('id', $batchId)->update($data); + } + + public function updateItemBySeq(int $batchId, int $seq, array $data): int + { + $data['updated_at'] = now(); + return (int) DB::table('admin_sms_batch_items') + ->where('batch_id', $batchId) + ->where('seq', $seq) + ->update($data); + } + + public function findBatch(int $batchId): ?object + { + return DB::table('admin_sms_batches')->where('id', $batchId)->first(); + } + + public function paginateBatches(array $filters, int $perPage = 30): LengthAwarePaginator + { + $q = DB::table('admin_sms_batches as b') + ->leftJoin('admin_users as au', 'au.id', '=', 'b.admin_user_id') + ->select([ + 'b.*', + DB::raw('COALESCE(au.nickname, au.name, au.email) as admin_name'), + DB::raw('au.email as admin_email'), + ]) + ->orderByDesc('b.id'); + + if (!empty($filters['status'])) { + $q->where('b.status', (string)$filters['status']); + } + if (!empty($filters['send_mode'])) { + $q->where('b.send_mode', (string)$filters['send_mode']); + } + if (!empty($filters['q'])) { + $kw = '%' . (string)$filters['q'] . '%'; + $q->where(function ($w) use ($kw) { + $w->where('b.message_raw', 'like', $kw) + ->orWhere('b.from_number', 'like', $kw) + ->orWhere('b.request_ip', 'like', $kw); + }); + } + if (!empty($filters['date_from'])) { + $q->where('b.created_at', '>=', $filters['date_from'] . ' 00:00:00'); + } + if (!empty($filters['date_to'])) { + $q->where('b.created_at', '<=', $filters['date_to'] . ' 23:59:59'); + } + + return $q->paginate($perPage)->withQueryString(); + } + + public function paginateItems(int $batchId, array $filters, int $perPage = 50): LengthAwarePaginator + { + $q = DB::table('admin_sms_batch_items') + ->where('batch_id', $batchId) + ->orderBy('seq'); + + if (!empty($filters['status'])) { + $q->where('status', (string)$filters['status']); + } + if (!empty($filters['to'])) { + $to = preg_replace('/\D+/', '', (string)$filters['to']); + if ($to !== '') $q->where('to_number', 'like', '%' . $to . '%'); + } + if (!empty($filters['q'])) { + $kw = '%' . (string)$filters['q'] . '%'; + $q->where('message_final', 'like', $kw); + } + + return $q->paginate($perPage)->withQueryString(); + } +} diff --git a/app/Repositories/Admin/Sms/AdminSmsTemplateRepository.php b/app/Repositories/Admin/Sms/AdminSmsTemplateRepository.php new file mode 100644 index 0000000..dcc258e --- /dev/null +++ b/app/Repositories/Admin/Sms/AdminSmsTemplateRepository.php @@ -0,0 +1,66 @@ +orderByDesc('id'); + + if (isset($filters['active']) && $filters['active'] !== '') { + $q->where('is_active', (int)$filters['active']); + } + if (!empty($filters['q'])) { + $kw = '%' . (string)$filters['q'] . '%'; + $q->where(function ($w) use ($kw) { + $w->where('code', 'like', $kw) + ->orWhere('title', 'like', $kw) + ->orWhere('body', 'like', $kw); + }); + } + + return $q->paginate($perPage)->withQueryString(); + } + + public function find(int $id): ?object + { + return DB::table('admin_sms_templates')->where('id', $id)->first(); + } + + public function insert(array $data): int + { + $now = now(); + return (int) DB::table('admin_sms_templates')->insertGetId([ + 'code' => (string)($data['code'] ?? ''), + 'title' => (string)($data['title'] ?? ''), + 'body' => (string)($data['body'] ?? ''), + 'description' => $data['description'] ?? null, + 'is_active' => (int)($data['is_active'] ?? 1), + 'created_by' => $data['created_by'] ?? null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + public function update(int $id, array $data): int + { + $data['updated_at'] = now(); + return (int) DB::table('admin_sms_templates')->where('id', $id)->update($data); + } + + public function listActive(int $limit = 200): array + { + return DB::table('admin_sms_templates') + ->select(['id','code','title','body']) + ->where('is_active', 1) + ->orderBy('title') + ->limit($limit) + ->get() + ->all(); + } + +} diff --git a/app/Services/Admin/Sms/AdminSmsLogService.php b/app/Services/Admin/Sms/AdminSmsLogService.php new file mode 100644 index 0000000..8ab7c55 --- /dev/null +++ b/app/Services/Admin/Sms/AdminSmsLogService.php @@ -0,0 +1,28 @@ +repo->paginateBatches($filters, $perPage); + } + + public function getBatch(int $batchId): ?object + { + return $this->repo->findBatch($batchId); + } + + public function paginateItems(int $batchId, array $filters, int $perPage = 50): LengthAwarePaginator + { + return $this->repo->paginateItems($batchId, $filters, $perPage); + } +} diff --git a/app/Services/Admin/Sms/AdminSmsService.php b/app/Services/Admin/Sms/AdminSmsService.php new file mode 100644 index 0000000..f84b758 --- /dev/null +++ b/app/Services/Admin/Sms/AdminSmsService.php @@ -0,0 +1,401 @@ +bool, 'batch_id'=>int, 'total'=>int, 'valid'=>int, 'invalid'=>int, 'duplicate'=>int, 'message'=>string] + */ + 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'] ?? ''); + $smsTypeHint = (string)($data['sms_type_hint'] ?? 'auto'); + + $scheduleType = (string)($data['schedule_type'] ?? 'now'); + $scheduledAt = null; + + if ($scheduleType === 'schedule') { + $scheduledAt = !empty($data['scheduled_at']) ? $data['scheduled_at'] : null; + if ($scheduledAt === null) { + return ['ok' => false, 'message' => '예약 시간을 입력해 주세요.']; + } + } + + // 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 입니다.'), + }; + } catch (\Throwable $e) { + return ['ok' => false, 'message' => $e->getMessage()]; + } + + $total = $targets['total']; + $valid = $targets['valid']; + $invalid = $targets['invalid']; + $dup = $targets['duplicate']; + $items = $targets['items']; // seq 포함된 배열 + + if ($valid < 1) { + return ['ok' => false, 'message' => '유효한 수신번호가 없습니다.']; + } + + // 2) batch insert + $byteLen = $this->calcBytesCiLike($msgRaw); + + $batchId = $this->repo->insertBatch([ + 'admin_user_id' => $adminId, + 'from_number' => $from, + 'send_mode' => $mode, + 'schedule_type' => $scheduleType, + 'scheduled_at' => $scheduledAt, + + 'message_raw' => $msgRaw, + 'byte_len' => $byteLen, + 'sms_type_hint' => in_array($smsTypeHint, ['auto','sms','mms'], true) ? $smsTypeHint : 'auto', + + 'total_count' => $total, + 'valid_count' => $valid, + 'duplicate_count' => $dup, + '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로 업데이트) + $this->repo->insertItemsChunked($batchId, $items); + + // 4) 즉시 발송이면 실제 provider queue(DB)에 insert (SmsService가 sms_server에 넣음) + if ($scheduleType === 'now') { + $this->repo->updateBatch($batchId, ['status' => 'submitting']); + + $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'], // 힌트(최종은 SmsService 내부 기준) + ]; + + $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', + ]); + } + } + + $status = 'submitted'; + if ($submitted < 1) $status = 'failed'; + else if ($failed > 0) $status = 'partial'; + + $this->repo->updateBatch($batchId, [ + 'status' => $status, + 'error_message' => $failed > 0 ? "failed={$failed}" : null, + ]); + } + + return [ + 'ok' => true, + 'batch_id' => $batchId, + 'total' => $total, + 'valid' => $valid, + 'invalid' => $invalid, + 'duplicate' => $dup, + ]; + } + + private function buildTargetsOne(string $to, string $message): array + { + $digits = $this->normalizePhone($to); + if (!$this->isValidKrMobile($digits)) { + return ['total'=>1,'valid'=>0,'invalid'=>1,'duplicate'=>0,'items'=>[]]; + } + + return [ + 'total' => 1, + 'valid' => 1, + 'invalid' => 0, + 'duplicate' => 0, + 'items' => [[ + 'seq' => 1, + 'to_number' => $digits, + 'message_final' => $message, + 'sms_type' => $this->guessSmsType($message), + 'status' => 'queued', + ]], + ]; + } + + private function buildTargetsMany(string $text, ?UploadedFile $csv, string $message): array + { + $phones = []; + + // 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 = []; + $invalid = 0; + + foreach ($phones as $p) { + if ($this->isValidKrMobile($p)) $validList[] = $p; + else $invalid++; + } + + $uniq = array_values(array_unique($validList)); + $dup = count($validList) - count($uniq); + + $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', + ]; + } + + 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)."_} 매칭 값이 없습니다."); + } + } + + $rowsToBuild[] = [$phone, $row]; // 나중에 치환 + $validPhones[] = $phone; + } + + $uniqPhones = array_values(array_unique($validPhones)); + $dup = count($validPhones) - count($uniqPhones); + + // phone별 첫 등장 row만 사용(중복 번호는 제거) + $firstRowByPhone = []; + foreach ($rowsToBuild as [$phone, $row]) { + if (!isset($firstRowByPhone[$phone])) { + $firstRowByPhone[$phone] = $row; + } + } + + $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); + } + + $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 normalizePhone(string $v): string + { + return preg_replace('/\D+/', '', $v) ?? ''; + } + + private function isValidKrMobile(string $digits): bool + { + return (bool) preg_match('/^01\d{8,9}$/', $digits); + } + + private function extractTemplateIndexes(string $message): array + { + preg_match_all('/\{_text_(0[2-9])_\}/', $message, $m); + $raw = array_unique($m[1] ?? []); + $idxs = array_map(fn($s) => (int)$s, $raw); + sort($idxs); + return $idxs; + } + + private function guessSmsType(string $message): string + { + // SmsService의 기존 기준을 “대충 맞춰”주는 용도(최종 판정은 provider/서비스) + $len = @mb_strlen($message, 'EUC-KR'); + return ($len !== false && $len > 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++) { + $ch = mb_substr($str, $i, 1, 'UTF-8'); + $ord = unpack('N', mb_convert_encoding($ch, 'UCS-4BE', 'UTF-8'))[1] ?? 0; + $bytes += ($ord > 127) ? 2 : 1; + } + return $bytes; + } + + /** + * CSV를 "라인 단위"로 읽어서 배열(row)로 반환 + * - UTF-8/CP949/EUC-KR 어느 정도 허용 + */ + private function readCsvLines(UploadedFile $csv): \Generator + { + $path = $csv->getRealPath(); + if ($path === false) return; + + $fh = fopen($path, 'rb'); + if (!$fh) return; + + try { + 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); + } + yield $out; + } + } finally { + fclose($fh); + } + } + + 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/Admin/Sms/AdminSmsTemplateService.php b/app/Services/Admin/Sms/AdminSmsTemplateService.php new file mode 100644 index 0000000..10fb2b2 --- /dev/null +++ b/app/Services/Admin/Sms/AdminSmsTemplateService.php @@ -0,0 +1,59 @@ +repo->paginate($filters, $perPage); + } + + public function get(int $id): ?object + { + return $this->repo->find($id); + } + + public function create(int $adminId, array $data): array + { + $code = strtolower(trim((string)($data['code'] ?? ''))); + if ($code === '' || !preg_match('/^[a-z0-9\-_]{3,60}$/', $code)) { + return ['ok'=>false, 'message'=>'code는 영문/숫자/대시/언더바 3~60자로 입력하세요.']; + } + + $id = $this->repo->insert([ + 'code' => $code, + 'title' => trim((string)($data['title'] ?? '')), + 'body' => (string)($data['body'] ?? ''), + 'description' => trim((string)($data['description'] ?? '')) ?: null, + 'is_active' => (int)($data['is_active'] ?? 1), + 'created_by' => $adminId, + ]); + + return ['ok'=>true, 'id'=>$id]; + } + + public function update(int $id, array $data): array + { + $affected = $this->repo->update($id, [ + 'title' => trim((string)($data['title'] ?? '')), + 'body' => (string)($data['body'] ?? ''), + 'description' => trim((string)($data['description'] ?? '')) ?: null, + 'is_active' => (int)($data['is_active'] ?? 1), + ]); + + return ['ok'=>($affected >= 0)]; + } + + public function activeForSend(int $limit = 200): array + { + return $this->repo->listActive($limit); + } + +} diff --git a/app/Services/SmsService.php b/app/Services/SmsService.php index aee463e..897ee86 100644 --- a/app/Services/SmsService.php +++ b/app/Services/SmsService.php @@ -91,50 +91,38 @@ class SmsService private function lguplusSend(array $data): bool { $conn = DB::connection('sms_server'); - - // sms/mms 결정 $smsSendType = $this->resolveSendType($data); - return $conn->transaction(function () use ($smsSendType, $data) { + return (bool) $conn->transaction(function () use ($conn, $smsSendType, $data) { + if ($smsSendType === 'sms') { - // CI의 SC_TRAN insert - $insert = [ + return $conn->table('SC_TRAN')->insert([ 'TR_SENDDATE' => now()->format('Y-m-d H:i:s'), 'TR_SENDSTAT' => '0', 'TR_MSGTYPE' => '0', 'TR_PHONE' => $data['to_number'], 'TR_CALLBACK' => $data['from_number'], 'TR_MSG' => $data['message'], - ]; - - // Eloquent 사용 - return (bool) ScTran::create($insert); - // 또는 Query Builder: - // return $conn->table('SC_TRAN')->insert($insert); - - } else { - // CI의 MMS_MSG insert - $subject = $data['subject'] ?? mb_substr($data['message'], 0, 22, 'UTF-8'); - - $insert = [ - 'SUBJECT' => $subject, - 'PHONE' => $data['to_number'], - 'CALLBACK' => $data['from_number'], - 'STATUS' => '0', - 'REQDATE' => now()->format('Y-m-d H:i:s'), - 'MSG' => $data['message'], - 'FILE_CNT' => 0, - 'FILE_PATH1' => '', - 'TYPE' => '0', - ]; - - return (bool) MmsMsg::create($insert); - // 또는 Query Builder: - // return $conn->table('MMS_MSG')->insert($insert); + ]); } + + $subject = $data['subject'] ?? mb_substr($data['message'], 0, 22, 'UTF-8'); + + return $conn->table('MMS_MSG')->insert([ + 'SUBJECT' => $subject, + 'PHONE' => $data['to_number'], + 'CALLBACK' => $data['from_number'], + 'STATUS' => '0', + 'REQDATE' => now()->format('Y-m-d H:i:s'), + 'MSG' => $data['message'], + 'FILE_CNT' => 0, + 'FILE_PATH1' => '', + 'TYPE' => '0', + ]); }); } + private function resolveSendType(array $data): string { // CI 로직과 동일한 우선순위 유지 diff --git a/resources/js/admin.js b/resources/js/admin.js index cce6a77..3e26598 100644 --- a/resources/js/admin.js +++ b/resources/js/admin.js @@ -127,3 +127,6 @@ window.__adminFlash = []; }); })(); + + + diff --git a/resources/views/admin/admins/create.blade.php b/resources/views/admin/admins/create.blade.php index 27fd668..c9cb939 100644 --- a/resources/views/admin/admins/create.blade.php +++ b/resources/views/admin/admins/create.blade.php @@ -1,32 +1,46 @@ @extends('admin.layouts.app') @section('title', '관리자 계정 등록') +@section('page_title', '관리자 계정 등록') +@section('page_desc', '임시 비밀번호 생성 후 다음 로그인 시 변경이 강제됩니다.') + +@push('head') + +@endpush @section('content') +
+
+
+
관리자 계정 등록
+
+ 비밀번호는 입력하지 않습니다. 서버에서 임시 비밀번호 생성 → 다음 로그인 시 변경 강제 +
+
+ + + ← 목록 + +
+
+
@csrf -
-
-
-
관리자 계정 등록
-
- 비밀번호는 입력하지 않습니다. 서버에서 임시 비밀번호를 생성하며, 다음 로그인 시 변경이 강제됩니다. -
-
- - - 목록 - -
-
- -
- -
+
@@ -64,9 +78,10 @@ @error('role')
{{ $message }}
@enderror
- +
+ + 취소 +
@endsection diff --git a/resources/views/admin/admins/edit.blade.php b/resources/views/admin/admins/edit.blade.php index 9bfc75f..45b384f 100644 --- a/resources/views/admin/admins/edit.blade.php +++ b/resources/views/admin/admins/edit.blade.php @@ -1,6 +1,43 @@ @extends('admin.layouts.app') @section('title', '관리자 정보 수정') +@section('page_title', '관리자 정보 수정') +@section('page_desc', '기본정보/상태/2FA/역할을 관리합니다.') + +@push('head') + +@endpush @section('content') @php @@ -8,35 +45,108 @@ $lockedUntil = $admin->locked_until ?? null; $isLocked = false; - if (!empty($lockedUntil)) { - try { - $isLocked = \Carbon\Carbon::parse($lockedUntil)->isFuture(); - } catch (\Throwable $e) { - $isLocked = true; // 파싱 실패 시 보수적으로 잠김 처리 - } + try { $isLocked = \Carbon\Carbon::parse($lockedUntil)->isFuture(); } + catch (\Throwable $e) { $isLocked = true; } } - $lockLabel = (string)($admin->locked_until ?? ''); - $lockLabelLabel = $lockLabel === "" ? '계정정상' : '계정잠금'; - $lockLabelColor = $lockLabel === "" ? '#2b7fff' : '#ff4d4f'; - $st = (string)($admin->status ?? 'active'); $statusLabel = $st === 'active' ? '활성' : '비활성'; - $statusColor = $st === 'active' ? '#2b7fff' : '#ff4d4f'; + $statusPill = $st === 'active' ? 'pill--ok' : 'pill--bad'; + + $hasSecret = !empty($admin->totp_secret_enc); + $isModeOtp = (int)($admin->totp_enabled ?? 0) === 1; @endphp - {{-- ===== 상단 정보 패널 ===== --}} -
+ {{-- 요약 카드 --}} +
+
+
+
관리자 정보 수정
+
+ #{{ $admin->id ?? '-' }} / {{ $admin->email ?? '-' }} +
+
-
-
관리자번호/이메일
-
{{ $admin->id ?? '-' }} / {{ $admin->email ?? '-' }}
+ + ← 목록 + +
+
+ + {{-- KV 그리드 --}} +
+
+
상태
+
+ ● {{ $statusLabel }} + @if($st !== 'active') + (로그인 불가) + @endif +
-
-
현재 역할
-
+
+
계정 잠금
+
+ @if($isLocked) + ● 잠김 +
{{ $admin->locked_until }}
+ @else + ● 정상 +
※ 3회 실패 시 잠김
+ @endif +
+
+ +
+
로그인 실패 횟수
+
{{ (int)($admin->failed_login_count ?? 0) }}
+
+ +
+
최근 로그인 IP
+
{{ $ip }}
+
+ +
+
최근 로그인 시간
+
{{ $admin->last_login_at ?? '-' }}
+
+ +
+
2FA
+
+ @if($hasSecret) + OTP 등록됨 + @else + OTP 미등록 + @endif + + 현재모드: {{ $isModeOtp ? 'OTP' : 'SMS' }} + +
+
+ +
+
생성
+
{{ $admin->created_at ?? '-' }}
+
+ +
+
최근 수정
+
{{ $admin->updated_at ?? '-' }}
+
+ +
+
비활성 처리자
+
{{ $admin->deleted_by ?? '-' }}
+
+ +
+
현재 역할
+
@forelse($roles as $rr) {{ $rr['name'] ?? ($rr['code'] ?? '-') }} @empty @@ -44,180 +154,120 @@ @endforelse
- -
-
상태
-
- {{ $statusLabel }} - @if($st !== 'active') - (관리자 로그인 불가) - @endif -
-
- -
-
계정상태
-
- {{ $lockLabelLabel }} -
-
- ※ 로그인 비밀번호 3회 연속 실패 시 계정이 잠깁니다. -
-
- -
-
로그인 실패 횟수
-
{{ (int)($admin->failed_login_count ?? 0) }}
-
- -
-
마지막 로그인 아이피
-
{{ $ip }}
-
- -
-
마지막 로그인 시간
-
{{ $admin->last_login_at ?? '-' }}
-
- -
-
관리자 생성 일시
-
{{ $admin->created_at ?? '-' }}
-
- -
-
최근 정보수정일
-
{{ $admin->updated_at ?? '-' }}
-
- -
-
비활성화 처리자
-
{{ $admin->deleted_by ?? '-' }}
-
-
- - {{-- ===== 수정 폼(단 하나) ===== --}} + {{-- 수정 폼 --}}
@csrf -
-
- - -
- -
- - -
- -
- - -
- ※ 저장 시 phone_hash + phone_enc 갱신 +
+
+
+ +
-
-
- - -
+
+ + +
-
- - - - - @if(empty($admin->totp_secret_enc)) +
+ +
- ※ Google OTP 미등록 상태라 선택할 수 없습니다. (등록은 ‘내 정보’에서만 가능) + ※ 저장 시 phone_hash + phone_enc 갱신
- @endif -
+
-
- -
여러 개 선택 가능
+
+ + +
-
- @foreach($allRoles as $r) - @php $rid = (int)$r['id']; @endphp - - @endforeach +
+ + + + + @if(!$hasSecret) +
+ ※ OTP 미등록 상태라 선택할 수 없습니다. (등록은 ‘내 정보’에서만 가능) +
+ @endif +
+ +
+ +
여러 개 선택 가능
+ +
+ @foreach($allRoles as $r) + @php $rid = (int)$r['id']; @endphp + + @endforeach +
- {{-- ===== 하단 액션바(폼 밖) ===== --}} -
- - 뒤로가기 + {{-- 하단 액션바 --}} +
+ + ← 뒤로가기 -
- @if($lockLabel) +
+ @if(!empty($admin->locked_until))
+ onsubmit="this.querySelector('button[type=submit]')?.setAttribute('disabled','disabled');"> @csrf - +
@endif -
+ onsubmit="this.querySelector('button[type=submit]')?.setAttribute('disabled','disabled');"> @csrf - +
-
diff --git a/resources/views/admin/admins/index.blade.php b/resources/views/admin/admins/index.blade.php index 7281384..6daeb35 100644 --- a/resources/views/admin/admins/index.blade.php +++ b/resources/views/admin/admins/index.blade.php @@ -1,104 +1,174 @@ @extends('admin.layouts.app') @section('title', '관리자 계정 관리') +@section('page_title', '관리자 계정 관리') +@section('page_desc', '계정/2FA/최근로그인/역할 정보를 관리합니다.') @section('content_class', 'a-content--full') + +@push('head') + +@endpush + @section('content') -
-
-
-
관리자 계정 관리
-
계정/2FA/최근로그인/역할 정보를 관리합니다.
+
+
+
+
관리자 계정 관리
+
계정/2FA/최근로그인/역할 정보를 관리합니다.
-
- - 관리자 등록 +
+ + + 관리자 등록 -
- - - + +
+ +
+ +
+ +
+ +
+ + 초기화 +
-
- - - - - - - - - - - - - - - - - - @forelse($page as $u) - @php - $uid = (int)$u->id; - $roles = $roleMap[$uid] ?? []; - $pc = $permCnt[$uid] ?? 0; - @endphp - - - - - - @php - $st = (string)($u->status ?? ''); - $stLabel = $st === 'active' ? '활성' : ($st === 'blocked' ? '비활성' : ($st ?: '-')); - @endphp - +
+
{{ $page->total() }}
- @php - $isLocked = !empty($u->locked_until); - @endphp -
- - - - - - - @empty - - @endforelse - -
ID닉네임성명이메일상태잠금2FA 모드TOTP역할최근 로그인관리
{{ $uid }}{{ $u->nickname ?? '-' }}{{ $u->name ?? '-' }}{{ $u->email ?? '-' }} - {{ $stLabel }} - - @if($isLocked) - 계정잠금 - @else - 계정정상 - @endif - {{ $u->two_factor_mode ?? 'sms' }}{{ (int)($u->totp_enabled ?? 0) === 1 ? 'On' : 'Off' }} - @forelse($roles as $r) - {{ $r['name'] ?? $r['code'] }} - @empty - - - @endforelse - {{ $u->last_login_at ?? '-' }} - 보기 -
데이터가 없습니다.
+
+ + + + + + + + + + + + + + + + + + @forelse($page as $u) + @php + $uid = (int)$u->id; + $roles = $roleMap[$uid] ?? []; + $st = (string)($u->status ?? ''); + $isLocked = !empty($u->locked_until); -
- {{ $page->links() }} + $stLabel = $st === 'active' ? '활성' : ($st === 'blocked' ? '비활성' : ($st ?: '-')); + $stPill = $st === 'active' ? 'pill--ok' : ($st === 'blocked' ? 'pill--bad' : 'pill--muted'); + + $twofa = (string)($u->two_factor_mode ?? 'sms'); + $twofaLabel = ($twofa === 'totp' || $twofa === 'otp') ? 'OTP' : 'SMS'; + + $totpOn = (int)($u->totp_enabled ?? 0) === 1; + @endphp + +
+ + + + + + + + + + + + + + + + + + + + @empty + + @endforelse + +
ID닉네임성명이메일상태잠금2FA 모드TOTP역할최근 로그인관리
{{ $uid }}{{ $u->nickname ?? '-' }}{{ $u->name ?? '-' }}{{ $u->email ?? '-' }} + ● {{ $stLabel }} + + @if($isLocked) + ● 잠김 + @else + ● 정상 + @endif + {{ $twofaLabel }} + @if($totpOn) + On + @else + Off + @endif + + @forelse($roles as $r) + {{ $r['name'] ?? $r['code'] }} + @empty + - + @endforelse + {{ $u->last_login_at ?? '-' }} + + 보기 + +
데이터가 없습니다.
+
+ +
+ {{ $page->links() }} +
@endsection diff --git a/resources/views/admin/me/password.blade.php b/resources/views/admin/me/password.blade.php index 75c38d3..242d4a2 100644 --- a/resources/views/admin/me/password.blade.php +++ b/resources/views/admin/me/password.blade.php @@ -4,43 +4,81 @@ @section('page_title', '비밀번호 변경') @section('page_desc', '현재 비밀번호 확인 후 변경') +@push('head') + +@endpush + @section('content')
-
-
+
+
-
비밀번호 변경
-
변경 시 감사로그가 기록됩니다.
+
비밀번호 변경
+
변경 시 감사로그가 기록됩니다.
+ 보안
-
+ @csrf
- + @error('current_password')
{{ $message }}
@enderror
- + @error('password')
{{ $message }}
@enderror +
* 영문/숫자/특수문자 조합 권장
- +
- +
- - ← 내정보로 - +
+ + ← 내 정보로 +
+ +
+ * 비밀번호 변경 후 다음 로그인부터 새 비밀번호가 적용됩니다.
+ * OTP/SMS 2FA와 별개로 비밀번호 자체는 주기적으로 변경하는 것을 권장합니다. +
diff --git a/resources/views/admin/me/security.blade.php b/resources/views/admin/me/security.blade.php index e528311..c907daf 100644 --- a/resources/views/admin/me/security.blade.php +++ b/resources/views/admin/me/security.blade.php @@ -1,140 +1,237 @@ @extends('admin.layouts.app') @section('title', '보안 설정 (2차 인증)') +@section('page_title', '보안 설정') +@section('page_desc', '2차 인증(SMS / Google OTP) 관리') + +@push('head') + +@endpush @section('content') -
-
-
-
2차 인증 (Google OTP)
-
- SMS 또는 Google OTP(TOTP)로 2차 인증을 진행합니다. +
+ + {{-- Header --}} +
+
+
+
2차 인증 (SMS / Google OTP)
+
+ SMS 또는 Google OTP(TOTP)로 2차 인증을 진행합니다. +
+ + ← 뒤로가기 +
+
+ + {{-- 이용 안내 --}} +
+
+
이용 안내
+ 도움말
- 뒤로가기 +
+
    +
  1. Google Authenticator / Microsoft Authenticator 등 OTP 앱을 설치합니다.
  2. +
  3. 등록 시작 버튼을 눌러 QR을 생성합니다.
  4. +
  5. 앱에서 QR을 스캔하거나, 시크릿을 수동으로 입력합니다.
  6. +
  7. 앱에 표시되는 6자리 코드를 입력하면 등록이 완료됩니다.
  8. +
  9. 등록 완료 후에만 Google OTP 인증으로 전환할 수 있습니다.
  10. +
+
+ * OTP는 30초마다 바뀌므로, 시간이 지나면 새 코드로 다시 입력하세요. +
+
-
-
+ {{-- 상태/모드 --}} +
+
+
+
현재 상태
+
+ @if($isRegistered) + Google OTP 등록됨 + ({{ $admin->totp_verified_at }}) + @elseif($isPending) + 등록 진행 중 + (QR 스캔 후 코드 확인) + @else + 미등록 + @endif +
+
- {{-- 이용 안내 --}} -
-
이용 안내
-
- 1) Google Authenticator / Microsoft Authenticator 등 앱 설치
- 2) 등록 시작 → QR 스캔(또는 시크릿 수동 입력)
- 3) 앱에 표시되는 6자리 코드를 입력해 등록 완료
- 4) 필요 시 재등록(새 시크릿 발급) 또는 삭제 가능 -
-
+
+ @csrf + 2차 인증방법 -
+ - {{-- 상태/모드 --}} -
-
-
-
현재 상태
-
- @if($isRegistered) - Google OTP 등록됨 - ({{ $admin->totp_verified_at }}) - @elseif($isPending) - 등록 진행 중 - @else - 미등록 + + + @if(!$isRegistered) +
+ ※ Google OTP 미등록 상태에서는 OTP 인증으로 전환할 수 없습니다. +
@endif -
+
- -
- @csrf - - - - - @if(!$isRegistered) -
- ※ Google OTP 미등록 상태에서는 OTP 인증으로 전환할 수 없습니다. -
- @endif -
-
-
+ {{-- 등록/확인 UI --}} + @if($isPending) +
- {{-- 등록/확인 UI --}} - @if($isPending) -
-
등록 진행
+ {{-- QR --}} +
+
+
QR 코드 스캔
+ 진행중 +
-
-
-
QR 코드 스캔
-
- {!! $qrSvg !!} +
+
OTP 앱에서 QR을 스캔하세요
+
+ {!! $qrSvg !!} +
-
-
시크릿(수동 입력용)
-
- {{ $secret }} + {{-- 시크릿 + 코드 입력 --}} +
+
+
시크릿 / 등록 완료
+ 수동 입력 가능
-
+
+
시크릿(수동 입력용)
+
+ {{ $secret }} +
+
+ * 앱에서 “수동 입력”을 선택하고 위 시크릿을 등록할 수 있습니다. +
+
-
+
+ + @csrf @error('code')
{{ $message }}
@enderror - -
- -
- @csrf - -
-
-
-
- @else - {{-- 미등록 / 등록됨 --}} -
-
- @if(!$isRegistered) -
- @csrf - -
- @else -
- @csrf - +
+ +
+ style="margin-top:10px;" + data-confirm="등록을 취소하고 OTP 정보를 삭제할까요?"> @csrf - +
+
+
+ @else + {{-- 미등록 / 등록됨 --}} +
+
+
+
관리
+
+ 등록/재등록/삭제를 통해 OTP 정보를 관리할 수 있습니다. +
+
+ +
+ @if(!$isRegistered) +
+ @csrf + +
+ @else +
+ @csrf + +
+ +
+ @csrf + +
+ @endif +
+
+ + @if($isRegistered) +
+
+
✅ 등록 완료 상태입니다. “2차 인증방법”에서 Google OTP 인증으로 전환할 수 있습니다.
+
@endif
-
- @endif + @endif + + @endsection diff --git a/resources/views/admin/me/show.blade.php b/resources/views/admin/me/show.blade.php index 330cd00..10cc448 100644 --- a/resources/views/admin/me/show.blade.php +++ b/resources/views/admin/me/show.blade.php @@ -2,170 +2,194 @@ @section('title', '내 정보') @section('page_title', '내 정보') -@section('page_desc', '프로필/연락처/보안 상태') +@section('page_desc', '프로필 / 연락처 / 보안 상태') + +@push('head') + +@endpush @section('content') -
+ @php + $hasSecret = !empty($me->totp_secret_enc); + $isVerified = !empty($me->totp_verified_at); + $isEnabled = (int)($me->totp_enabled ?? 0) === 1; -
-
-
+ $modeLabel = $isEnabled ? 'Google OTP(TOTP)' : 'SMS 인증'; + + $totpLabel = '미등록'; + $totpPill = 'pill--muted'; + if ($hasSecret && $isVerified) { $totpLabel='등록됨'; $totpPill='pill--ok'; } + elseif ($hasSecret && !$isVerified) { $totpLabel='등록 진행중'; $totpPill='pill--warn'; } + @endphp + +
+
+ + {{-- 기본 정보 --}} +
+
-
기본 정보
-
이메일은 변경 불가
+
기본 정보
+
이메일은 변경할 수 없습니다.
+
프로필
-
+ @csrf -
+
-
- - - @error('nickname')
{{ $message }}
@enderror +
+
+ + + @error('nickname')
{{ $message }}
@enderror +
+ +
+ + + @error('name')
{{ $message }}
@enderror +
+ +
+ + +
숫자만 입력 권장
+ @error('phone')
{{ $message }}
@enderror +
-
- - - @error('name')
{{ $message }}
@enderror +
+ + * 저장 시 즉시 반영됩니다.
- -
- - - @error('phone')
{{ $message }}
@enderror -
- -
-
-
+ {{-- 보안 --}} +
+
-
보안
-
비밀번호 변경 및 2FA 상태
+
보안
+
비밀번호 변경 및 2FA 상태
+
2FA
- @php - $hasSecret = !empty($me->totp_secret_enc); - $isVerified = !empty($me->totp_verified_at); - $isEnabled = (int)($me->totp_enabled ?? 0) === 1; - @endphp - -
-
-
2FA 모드
-
- {{ ($isEnabled=='1') ? 'google TOPT' : 'SMS' }} -
+
+
2FA 모드
+
+ ● {{ $modeLabel }} +
로그인 시 추가 인증 방식입니다.
-
-
TOTP
-
- @php - $hasSecret = !empty($me->totp_secret_enc); // 버튼 기준 (등록 플로우 진입 여부) - $isVerified = !empty($me->totp_verified_at); // 등록 완료 여부 - $isModeOtp = (int)($me->totp_enabled ?? 0) === 1; // 현재 로그인 인증 방식 표시용(조건 X) - @endphp - - {{-- 1) 등록 상태 표시 (등록/미등록/진행중) --}} - @if($hasSecret && $isVerified) - TOTP 등록됨 - -
({{ $me->totp_verified_at }}) -
- @elseif($hasSecret && !$isVerified) - 등록 진행중 - - (인증코드 확인 필요) - - @else - 미등록 - @endif -
-
-
-
내 역할
-
-
- @forelse(($roles ?? []) as $r) - - {{ $r['name'] }} - {{ $r['code'] }} - - @empty - 부여된 역할이 없습니다. - @endforelse -
-
-
- -{{--
--}} -{{--
내 권한
--}} -{{--
--}} -{{--
--}} -{{-- @forelse(($perms ?? []) as $p)--}} -{{-- {{ $p['code'] }}--}} -{{-- @empty--}} -{{-- 권한 정보가 없습니다.--}} -{{-- @endforelse--}} -{{--
--}} -{{--
--}} -{{--
--}} - -
-
최근 로그인
-
- {{ $me->last_login_at ? $me->last_login_at : '-' }} -
-
-
- - - 비밀번호 변경 - - - -
-
- @if(!$hasSecret) - - Google OTP 등록 - +
TOTP 상태
+
+ ● {{ $totpLabel }} + @if($hasSecret && $isVerified) +
등록일: {{ $me->totp_verified_at }}
+ @elseif($hasSecret && !$isVerified) +
QR 등록 후, 인증코드 확인이 필요합니다.
@else - - 2FA 인증방법 변경 / Google OTP 관리 - +
Google Authenticator(또는 호환 앱)로 등록할 수 있습니다.
@endif
+ +
내 역할
+
+
+ @forelse(($roles ?? []) as $r) + + {{ $r['name'] }} + {{ $r['code'] }} + + @empty + 부여된 역할이 없습니다. + @endforelse +
+
+ +
최근 로그인
+
+ {{ $me->last_login_at ? $me->last_login_at : '-' }} +
최근 로그인 시간 기준입니다.
+
-
-
+
+
+ + 비밀번호 변경 + + + @if(!$hasSecret) + Google OTP 등록 + @else + 2FA 방식 변경 / OTP 관리 + @endif +
+ +
+ * OTP를 등록해도, 최종 로그인 방식(OTP/SMS)은 “2FA 방식 변경”에서 선택할 수 있습니다. +
+
+ +
@endsection diff --git a/resources/views/admin/partials/sidebar.blade.php b/resources/views/admin/partials/sidebar.blade.php index b7772f6..4b20338 100644 --- a/resources/views/admin/partials/sidebar.blade.php +++ b/resources/views/admin/partials/sidebar.blade.php @@ -17,9 +17,9 @@ [ 'title' => '알림/메시지', 'items' => [ - ['label' => '관리자 SMS 발송', 'route' => 'admin.sms.send','roles' => ['super_admin','finance','product','support']], + ['label' => 'SMS 발송', 'route' => 'admin.sms.send','roles' => ['super_admin','finance','product','support']], ['label' => 'SMS 발송 이력', 'route' => 'admin.sms.logs','roles' => ['super_admin','finance','product','support']], - ['label' => '알림 템플릿', 'route' => 'admin.templates.index','roles' => ['super_admin','finance','product','support']], + ['label' => 'SMS 템플릿', 'route' => 'admin.templates.index','roles' => ['super_admin','finance','product','support']], ], ], [ diff --git a/resources/views/admin/sms/logs/index.blade.php b/resources/views/admin/sms/logs/index.blade.php new file mode 100644 index 0000000..b35ee90 --- /dev/null +++ b/resources/views/admin/sms/logs/index.blade.php @@ -0,0 +1,224 @@ +@extends('admin.layouts.app') + +@section('title', 'SMS 발송 이력') +@section('page_title', 'SMS 발송 이력') +@section('page_desc', '배치 단위로 조회합니다.') +@php + $STATUS_LABELS = [ + 'scheduled' => '예약대기', + 'queued' => '대기', + 'submitting' => '전송중', + 'submitted' => '전송완료', + 'partial' => '일부실패', + 'failed' => '실패', + 'canceled' => '취소', + ]; + + $MODE_LABELS = [ + 'one' => '단건', + 'many' => '대량', + 'template' => '템플릿', + ]; +@endphp +@push('head') + +@endpush + +@section('content') +
+
+ +
+
상태
+ + +
+ +
+
모드
+ +
+ + {{-- 기간: 달력 선택 (from/to) --}} +
+
기간
+
+ + ~ + + +
+
+ +
+
검색
+ +
+ +
+ + 발송 + 필터 초기화 +
+
+
+ +
+
+
{{ $batches->total() }}
+
+ @if((string)request('date_from') !== '' || (string)request('date_to') !== '') + 기간: {{ request('date_from') ?: '무제한' }} ~ {{ request('date_to') ?: '무제한' }} + @endif + @if((string)request('q') !== '') +  / 검색어: {{ request('q') }} + @endif +
+
+ +
+ + + + + + + + + + + + + + + @forelse($batches as $b) + @php + $st = (string)$b->status; + $pillClass = match ($st) { + 'submitted' => 'pill--ok', + 'partial','submitting','queued','scheduled' => 'pill--warn', + 'failed','canceled' => 'pill--bad', + default => 'pill--muted', + }; + $stLabel = $STATUS_LABELS[$st] ?? $st; + @endphp + + + + + + + + + + + @empty + + @endforelse + +
Batch생성일시작성자모드예약건수상태문구
+ #{{ $b->id }} + {{ $b->created_at }}{{ $b->admin_name ?? ('#'.$b->admin_user_id) }}{{ $MODE_LABELS[$b->send_mode] ?? $b->send_mode }}{{ $b->scheduled_at ?? '-' }}{{ $b->valid_count }}/{{ $b->total_count }}● {{ $stLabel }}
{{ $b->message_raw }}
데이터가 없습니다.
+
+ +
+ {{ $batches->links() }} +
+
+ @push('scripts') + + @endpush +@endsection diff --git a/resources/views/admin/sms/logs/show.blade.php b/resources/views/admin/sms/logs/show.blade.php new file mode 100644 index 0000000..34301c8 --- /dev/null +++ b/resources/views/admin/sms/logs/show.blade.php @@ -0,0 +1,158 @@ +@extends('admin.layouts.app') + +@section('title', 'SMS 이력 상세') +@section('page_title', 'SMS 이력 상세') +@section('page_desc', '배치 및 수신자별 상세') + +@push('head') + +@endpush + +@section('content') + @php + $st = (string)$batch->status; + $pillClass = match ($st) { + 'submitted' => 'pill--ok', + 'partial','submitting','queued','scheduled' => 'pill--warn', + 'failed','canceled' => 'pill--bad', + default => 'pill--warn', + }; + @endphp + +
+
+
+
Batch ID
+
#{{ $batch->id }}
+
+
+
상태
+
● {{ $batch->status }}
+
+
+
모드
+
{{ $batch->send_mode }}
+
+
+
예약
+
{{ $batch->scheduled_at ?? '-' }}
+
+
+
건수
+
+ 유효 {{ $batch->valid_count }} / + 전체 {{ $batch->total_count }} / + 중복 {{ $batch->duplicate_count }} / + 오류 {{ $batch->invalid_count }} +
+
+
+ +
+ +
문구
+
{{ $batch->message_raw }}
+ + +
+ +
+
+ +
+
상태
+ +
+ +
+
수신번호
+ +
+ +
+
메시지 검색
+ +
+ +
+ + 초기화 +
+
+ +
+ + + + + + + + + + + + + @forelse($items as $it) + @php + $ist = (string)$it->status; + $ic = match ($ist) { + 'submitted' => 'pill--ok', + 'failed' => 'pill--bad', + default => 'pill--warn', + }; + @endphp + + + + + + + + + @empty + + @endforelse + +
Seq수신번호타입상태제출시간메시지
{{ $it->seq }}{{ $it->to_number }}{{ $it->sms_type }}● {{ $it->status }}{{ $it->submitted_at ?? '-' }}
{{ $it->message_final }}
상세 데이터가 없습니다.
+
+ +
+ {{ $items->links() }} +
+
+@endsection diff --git a/resources/views/admin/sms/send.blade.php b/resources/views/admin/sms/send.blade.php new file mode 100644 index 0000000..13e41cf --- /dev/null +++ b/resources/views/admin/sms/send.blade.php @@ -0,0 +1,594 @@ +@extends('admin.layouts.app') + +@section('title', '관리자 SMS 발송') +@section('page_title', '관리자 SMS 발송') +@section('page_desc', '단건 / 대량 / 템플릿 발송') + +@push('head') + +@endpush + +@section('content') +
+ @csrf + + + + + {{-- 상단 바 --}} +
+
+
+
발신번호
+ +
고정 (수정불가)
+
+ +
+
발송 시점
+
+ + + +
+
예약은 5분 단위 권장
+
+ +
+
SMS
+
0 Bytes
+
+
+
+ +
+ {{-- 좌측: 수신/모드 --}} +
+
발송 유형
+ +
+ + + +
+ +
+
수신번호 (1건)
+ +
+ +
+
+
수신번호 (여러건)
+
0건
+
+ + + +
+ + +
+
+ +
* 서버에서 최종 정규화/중복/오류 제거합니다.
+
+ +
+
+ + * 첫 번째 컬럼은 수신번호, 이후 컬럼은 치환값입니다. +
+ + {{-- CSV 미리보기 --}} + + + + + + {{-- 이용 안내 --}} +
+
템플릿 CSV 이용 안내
+ +
+
+ 1) CSV 파일 작성 요령
+ 각 줄이 “1명”이며, 쉼표(,)로 컬럼을 구분합니다. + +
+
예시
+
+ 01036828958,홍길동,20260111 + 01036828901,이순신,20260112 + 01036828902,김개똥,20260113 +
+
+ +
    +
  • 1번째 컬럼: 수신 전화번호(고정)
  • +
  • 2번째 컬럼: 사용자 문구 1 (→ {_text_02_})
  • +
  • 3번째 컬럼: 사용자 문구 2 (→ {_text_03_})
  • +
  • … 이런 식으로 뒤 컬럼이 계속 이어집니다.
  • +
+
+ +
+ 2) 발송 문구와 매칭(치환) 규칙
+ 발송 문구에 토큰을 넣으면 CSV의 값으로 자동 치환됩니다. + +
+
발송 문구 예시
+
+ 안녕하세요 {_text_02_} 회원님 + 오늘 날짜는 {_text_03_} 입니다 +
+
+ +
+
치환 결과 예시(첫 줄 기준)
+
    +
  • {_text_02_}홍길동
  • +
  • {_text_03_}20210111
  • +
+
+ 즉, 첫 줄의 값(홍길동/20210111)이 문구에 들어가서 “개인화 메시지”가 됩니다. +
+
+
+ +
+ 3) 사용 가능한 토큰 범위
+ + 사용자 문구는 {_text_02_}부터 최대 8개까지 지원합니다. + ({_text_02_} ~ {_text_09_}) + +
+
+
+ +
+ +
+ + {{-- 우측: 문구/프리셋/액션 --}} +
+
+
문구 템플릿(프리셋) 선택
+ +
+ + + + +
+ +
* “덮어쓰기”는 현재 문구를 교체합니다.
+
+ +
+
발송 문구
+ + +
+ + + + 토큰 빠른 삽입 +
+ +
+ +
+ + 발송 이력 +
+ +
* 90 bytes 초과는 MMS 표시(최종은 서버 기준)
+
+
+
+
+ + @push('scripts') + + + @endpush +@endsection diff --git a/resources/views/admin/templates/form.blade.php b/resources/views/admin/templates/form.blade.php new file mode 100644 index 0000000..e7f4773 --- /dev/null +++ b/resources/views/admin/templates/form.blade.php @@ -0,0 +1,159 @@ +@extends('admin.layouts.app') + +@section('title', $mode === 'create' ? '템플릿 생성' : '템플릿 수정') +@section('page_title', $mode === 'create' ? '템플릿 생성' : '템플릿 수정') +@section('page_desc', 'SMS 발송 화면에서 선택해 빠르게 삽입할 수 있습니다.') + +@push('head') + +@endpush + +@section('content') +
+ @csrf + @if($mode !== 'create') + @method('PUT') + @endif + +
+ {{-- 상단 헤더 카드 --}} +
+
+
+
+ {{ $mode === 'create' ? '새 템플릿 만들기' : '템플릿 수정' }} +
+
+ 발송 화면에서 선택해 문구를 빠르게 삽입할 수 있습니다. +
+
+ +
+
+ 상태: {{ (int)old('is_active', $tpl->is_active ?? 1) === 1 ? '활성' : '비활성' }} +
+ @if($mode !== 'create') +
ID: {{ $tpl->id }}
+ @endif +
+
+
+ + {{-- 본문 그리드 --}} +
+
+ {{-- code --}} + @if($mode === 'create') +
+
Code (unique)
+ +
영문/숫자/대시/언더바 3~60
+
+ @else +
+
Code
+
{{ $tpl->code }}
+
+ @endif + + {{-- title --}} +
+
제목
+ +
+ + {{-- description --}} +
+
설명 (선택)
+ +
+ + {{-- active --}} +
+ +
비활성 템플릿은 발송 화면 목록에 표시되지 않습니다.
+
+
+ +
+ {{-- body --}} +
+
+
본문
+
토큰을 넣어 템플릿 발송/개인화에 활용할 수 있습니다.
+
+
+ + + + 토큰 빠른 삽입 +
+
+ + + +
+ 템플릿 발송 CSV 예시
+ 01036828958,홍길동,20210111
+ ({_text_02_}=홍길동, {_text_03_}=20210111) +
+
+
+ + {{-- 하단 액션 --}} +
+
+ + 목록 +
+
+
+
+ + @push('scripts') + + @endpush +@endsection diff --git a/resources/views/admin/templates/index.blade.php b/resources/views/admin/templates/index.blade.php new file mode 100644 index 0000000..7b4b30b --- /dev/null +++ b/resources/views/admin/templates/index.blade.php @@ -0,0 +1,127 @@ +@extends('admin.layouts.app') + +@section('title', 'SMS 템플릿') +@section('page_title', 'SMS 템플릿') +@section('page_desc', '자주 쓰는 메시지 템플릿 관리') + +@push('head') + +@endpush + +@section('content') + {{-- 필터/검색 --}} +
+
+
+
활성
+ +
+ +
+
검색
+ +
+ +
+ + 새 템플릿 +
+ + @if(request('active') !== null || request('q') !== null) + + @endif +
+
+ + {{-- 리스트 --}} +
+
+
{{ $templates->total() }}
+
+ @if(request('active') !== null && request('active') !== '') + 상태: {{ request('active')==='1' ? '활성' : '비활성' }} + @endif + @if((string)request('q') !== '') +   / 검색: {{ request('q') }} + @endif +
+
+ +
+ + + + + + + + + + + + + @forelse($templates as $t) + + + + + + + + + @empty + + @endforelse + +
ID상태CodeTitleBody
{{ $t->id }} + @if((int)$t->is_active === 1) + ● 활성 + @else + ● 비활성 + @endif + {{ $t->code }}{{ $t->title }}
{{ $t->body }}
+ 수정 +
데이터가 없습니다.
+
+ +
+ {{ $templates->links() }} +
+
+@endsection diff --git a/routes/admin.php b/routes/admin.php index 76b713a..e815e20 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -1,8 +1,11 @@ group(function () { @@ -80,6 +83,35 @@ Route::middleware(['web'])->group(function () { Route::post('/{id}/unlock', [AdminAdminsController::class, 'unlock'])->name('unlock'); }); + Route::prefix('/sms')->group(function () { + // 발송 + Route::get('/send', [AdminSmsController::class, 'create'])->name('admin.sms.send'); + Route::post('/send', [AdminSmsController::class, 'store'])->name('admin.sms.send.store'); + + // 이력 + Route::get('/logs', [AdminSmsLogController::class, 'index'])->name('admin.sms.logs'); + Route::get('/logs/{batchId}', [AdminSmsLogController::class, 'show'])->name('admin.sms.logs.show'); + }); + + Route::prefix('templates')->name('admin.templates.')->group(function () { + Route::get('/', [AdminSmsTemplateController::class, 'index']) + ->name('index'); + + Route::get('/create', [AdminSmsTemplateController::class, 'create']) + ->name('create'); + + Route::post('/', [AdminSmsTemplateController::class, 'store']) + ->name('store'); + + Route::get('/{id}', [AdminSmsTemplateController::class, 'edit']) + ->whereNumber('id') + ->name('edit'); + + Route::put('/{id}', [AdminSmsTemplateController::class, 'update']) + ->whereNumber('id') + ->name('update'); + }); + /** * 아래는 메뉴는 있지만 실제 라우트/컨트롤러가 아직 없으니, * 구현 시점에만 같은 패턴으로 그룹에 admin.role 을 붙이면 됨.