giftcon_dev/app/Services/Admin/Sms/AdminSmsService.php

402 lines
13 KiB
PHP

<?php
namespace App\Services\Admin\Sms;
use App\Repositories\Admin\Sms\AdminSmsBatchRepository;
use App\Services\SmsService;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
final class AdminSmsService
{
public function __construct(
private readonly AdminSmsBatchRepository $repo,
private readonly SmsService $smsService,
) {}
/**
* @param int $adminId
* @param array $data 컨트롤러 validate 결과
* @return array ['ok'=>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<count($idxs); $i++) {
if ($idxs[$i] !== (2 + $i)) {
$need = 2 + $i;
throw new \RuntimeException("템플릿 토큰 {_text_0{$need}_} 이(가) 없습니다.");
}
}
$needArgs = count($idxs); // 02~ => 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;
}
}