445 lines
14 KiB
PHP
445 lines
14 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,
|
|
) {}
|
|
|
|
/**
|
|
* 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'] ?? '');
|
|
$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' => '예약 시간을 입력해 주세요.'];
|
|
}
|
|
}
|
|
|
|
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<count($idxs); $i++) {
|
|
if ($idxs[$i] !== (2 + $i)) {
|
|
$need = 2 + $i;
|
|
throw new \RuntimeException("{$label}: {_text_0{$need}_} 토큰이 누락되었습니다.");
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
{
|
|
$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;
|
|
}
|
|
}
|