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; } }