false, 'message' => '예약 시간을 입력해 주세요.']; } } try { $targets = match ($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']; $invalid = $targets['invalid']; $dup = $targets['duplicate']; $items = $targets['items']; if ($valid < 1) { return ['ok' => false, 'message' => '유효한 수신번호가 없습니다.']; } $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), ]); //sms mms 발송 $this->repo->insertItemsChunked($batchId, $items); $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'], 'scheduled_at' => $scheduledAt, ]; $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, ]; } /** * 단건: "010..." 또는 "010...,값,값,값..." */ private function buildTargetsOne(string $toRaw, string $message): array { [$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, 'invalid' => 0, 'duplicate' => 0, 'items' => [[ 'seq' => 1, 'to_number' => $digits, 'message_final' => $msgFinal, 'sms_type' => $this->guessSmsType($msgFinal), 'status' => 'queued', ]], ]; } /** * 대량(붙여넣기): 1줄=1명 * - 전화번호만 가능 * - 또는 "전화번호,치환값..." 가능 (토큰 치환 지원) * - 최대 100건 (중복 제거 후) */ private function buildTargetsMany(string $text, string $message): array { $lines = preg_split('/\r\n|\n|\r/', (string)$text) ?: []; $lines = array_values(array_filter(array_map('trim', $lines), fn($v) => $v !== '')); $total = count($lines); $invalid = 0; // 메시지에 토큰이 있으면 필요 args 수 계산 $idxs = $this->extractTemplateIndexes($message); $needArgs = count($idxs); // phone별 첫 줄만 사용(중복 제거) $firstByPhone = []; // phone => [args, lineNo] $dup = 0; foreach ($lines as $i => $line) { $lineNo = $i + 1; [$phone, $args] = $this->parseInlineRow($line); if (!$this->isValidKrMobile($phone)) { $invalid++; continue; } // 토큰 사용중이면 이 줄에 치환값이 충분해야 함 if ($needArgs > 0) { $this->assertTokensContiguousFrom2($idxs, "대량 {$lineNo}라인"); if (count($args) < $needArgs) { throw new \RuntimeException("대량 {$lineNo}라인: 토큰 치환값이 부족합니다. 필요 {$needArgs}개인데 입력은 ".count($args)."개입니다. (형식: 전화번호,값,값,...)"); } } if (!isset($firstByPhone[$phone])) { $firstByPhone[$phone] = [$args, $lineNo]; } else { $dup++; } } $uniqPhones = array_keys($firstByPhone); // 100건 제한(중복 제거 후) if (count($uniqPhones) > 100) { throw new \RuntimeException('대량(붙여넣기)은 최대 100건입니다. 100건 초과는 CSV 업로드를 이용해 주세요.'); } $items = []; $seq = 1; foreach ($uniqPhones as $phone) { [$args] = $firstByPhone[$phone]; $msgFinal = $this->applyTokensIfNeeded($message, $args, '대량 붙여넣기'); $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, ]; } /** * 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 (int)$s, $raw); sort($idxs); return $idxs; } private function guessSmsType(string $message): string { $len = @mb_strlen($message, 'EUC-KR'); return ($len !== false && $len > 90) ? 'mms' : 'sms'; } private function calcBytesCiLike(string $str): int { $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; } 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; $out = []; foreach ($row as $cell) { $out[] = $this->toUtf8((string)$cell); } yield $out; } } finally { fclose($fh); } } private function toUtf8(string $s): string { $s = preg_replace('/^\xEF\xBB\xBF/', '', $s) ?? $s; if (mb_check_encoding($s, 'UTF-8')) return $s; $converted = @mb_convert_encoding($s, 'UTF-8', 'CP949,EUC-KR,ISO-8859-1'); return is_string($converted) ? $converted : $s; } }