giftcon_dev/app/Services/Admin/Mail/AdminMailService.php
2026-03-03 15:13:16 +09:00

554 lines
19 KiB
PHP

<?php
namespace App\Services\Admin\Mail;
use App\Repositories\Admin\Mail\AdminMailRepository;
use App\Repositories\Admin\Mail\AdminMailTemplateRepository;
use App\Services\MailService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use App\Services\Admin\AdminAuditService;
final class AdminMailService
{
public function __construct(
private readonly AdminMailRepository $repo,
private readonly AdminMailTemplateRepository $tplRepo,
private readonly MailService $mail,
private readonly AdminAuditService $audit,
) {}
public function getSkinOptions(): array
{
return [
['key'=>'hero','label'=>'Hero Classic (이벤트/마케팅)'],
['key'=>'newsletter','label'=>'Newsletter Card (여러 항목)'],
['key'=>'minimal','label'=>'Minimal Transactional (알림/인증)'],
['key'=>'clean','label'=>'Clean Transactional'],
['key'=>'dark','label'=>'Dark Transactional'],
];
}
public function getStatusLabels(): array
{
return [
'scheduled'=>'예약대기',
'queued'=>'대기',
'sending'=>'발송중',
'sent'=>'완료',
'partial'=>'부분실패',
'failed'=>'실패',
'canceled'=>'취소',
];
}
public function getItemStatusLabels(): array
{
return [
'queued'=>'대기',
'sent'=>'성공',
'failed'=>'실패',
'canceled'=>'취소',
'skipped'=>'스킵',
];
}
public function getModeLabels(): array
{
return [
'one'=>'단건',
'many'=>'여러건',
'csv'=>'CSV 업로드',
];
}
public function getActiveTemplates(): array
{
return $this->tplRepo->activeAll();
}
public function listTemplates(array $filters, int $perPage=20)
{
return $this->tplRepo->list($filters, $perPage);
}
public function getTemplate(int $id): ?object
{
return $this->tplRepo->find($id);
}
public function createTemplate(int $adminId, array $data): array
{
$code = (string)($data['code'] ?? '');
if ($this->tplRepo->existsCode($code)) {
return ['ok'=>false,'message'=>'이미 존재하는 code 입니다.'];
}
$payload = [
'code' => $code,
'title' => $data['title'],
'description' => $data['description'] ?? '',
'skin_key' => $data['skin_key'],
'subject_tpl' => $data['subject'],
'body_tpl' => $data['body'],
'hero_image_url' => $data['hero_image_url'] ?? '',
'cta_label' => $data['cta_label'] ?? '',
'cta_url' => $data['cta_url'] ?? '',
'is_active' => (int)(!empty($data['is_active']) ? 1 : 0),
'created_by' => $adminId,
'updated_by' => $adminId,
];
$id = $this->tplRepo->create($payload);
// 성공시에만 감사로그 (기존 흐름 방해 X)
if ((int)$id > 0) {
$req = request();
$this->audit->log(
actorAdminId: $adminId,
action: 'admin.mail_template.create',
targetType: 'mail_template',
targetId: (int)$id,
before: null,
after: array_merge(['id' => (int)$id], $payload),
ip: (string)($req?->ip() ?? ''),
ua: (string)($req?->userAgent() ?? ''),
);
}
return ['ok'=>true,'id'=>$id];
}
public function updateTemplate(int $adminId, int $id, array $data): array
{
// before 스냅샷: repo에 get/find가 있으면 사용, 없으면 null로 진행(흐름 방해 X)
$before = null;
try {
if (method_exists($this->tplRepo, 'find')) {
$before = $this->tplRepo->find($id);
} elseif (method_exists($this->tplRepo, 'get')) {
$before = $this->tplRepo->get($id);
}
} catch (\Throwable $ignored) {}
$payload = [
'title' => $data['title'],
'description' => $data['description'] ?? '',
'skin_key' => $data['skin_key'],
'subject_tpl' => $data['subject'],
'body_tpl' => $data['body'],
'hero_image_url'=> $data['hero_image_url'] ?? '',
'cta_label' => $data['cta_label'] ?? '',
'cta_url' => $data['cta_url'] ?? '',
'is_active' => (int)(($data['is_active'] ?? 0) ? 1 : 0),
'updated_by' => $adminId,
];
$affected = $this->tplRepo->update($id, $payload);
$ok = ($affected >= 0);
// 성공시에만 감사로그
if ($ok) {
$req = request();
$beforeAudit = $before ? (array)$before : ['id' => $id];
$afterAudit = array_merge($beforeAudit, $payload);
$this->audit->log(
actorAdminId: $adminId,
action: 'admin.mail_template.update',
targetType: 'mail_template',
targetId: (int)$id,
before: $beforeAudit,
after: $afterAudit,
ip: (string)($req?->ip() ?? ''),
ua: (string)($req?->userAgent() ?? ''),
);
}
return ['ok'=>$ok];
}
public function listBatches(array $filters, int $perPage=20)
{
$page = $this->repo->listBatches($filters, $perPage);
if (is_object($page) && method_exists($page, 'appends')) {
$clean = array_filter($filters, fn($v) => $v !== null && $v !== '');
$page->appends($clean);
}
return $page;
}
public function getBatchDetail(int $batchId, array $filters, int $perPage=50): array
{
$batch = $this->repo->findBatch($batchId);
if (!$batch) return ['ok'=>false];
$items = $this->repo->listItems($batchId, $filters, $perPage);
if (is_object($items) && method_exists($items, 'appends')) {
$clean = array_filter($filters, fn($v) => $v !== null && $v !== '');
$items->appends($clean);
}
return ['ok'=>true,'batch'=>$batch,'items'=>$items];
}
public function cancelBatch(int $adminId, int $batchId): array
{
$b = $this->repo->findBatch($batchId);
if (!$b) return ['ok'=>false,'message'=>'배치를 찾을 수 없습니다.'];
if (in_array((string)$b->status, ['sent','failed','partial','canceled'], true)) {
return ['ok'=>false,'message'=>'이미 종료된 배치입니다.'];
}
$this->repo->cancelBatch($batchId);
return ['ok'=>true,'message'=>'취소 처리했습니다.'];
}
public function retryFailed(int $adminId, int $batchId): array
{
$b = $this->repo->findBatch($batchId);
if (!$b) return ['ok'=>false,'message'=>'배치를 찾을 수 없습니다.'];
$n = $this->repo->resetFailedToQueued($batchId);
$this->repo->updateBatch($batchId, ['status'=>'queued','last_error'=>'']);
\App\Jobs\Admin\Mail\DispatchMailBatchJob::dispatch($batchId)->onQueue('mail');
return ['ok'=>true,'message'=>"실패 {$n}건을 재시도 대기열로 전환했습니다."];
}
public function createBatch(int $adminId, array $data, Request $request, array $ctx): array
{
$mode = (string)$data['send_mode'];
// (선택) 템플릿을 서버에서 강제 적용하고 싶으면 template_id가 넘어왔을 때 덮어쓰기
// Blade에서 tplSelect에 name="template_id" 꼭 넣어야 들어옴
$tplId = (int)($data['template_id'] ?? 0);
if ($tplId > 0) {
$tpl = $this->tplRepo->find($tplId);
if (!$tpl) return ['ok'=>false,'message'=>'템플릿을 찾을 수 없습니다.'];
$data['skin_key'] = (string)($tpl->skin_key ?? $data['skin_key']);
$data['subject'] = (string)($tpl->subject_tpl ?? $data['subject']);
$data['body'] = (string)($tpl->body_tpl ?? $data['body']);
$data['hero_image_url'] = (string)($tpl->hero_image_url ?? ($data['hero_image_url'] ?? ''));
$data['cta_label'] = (string)($tpl->cta_label ?? ($data['cta_label'] ?? ''));
$data['cta_url'] = (string)($tpl->cta_url ?? ($data['cta_url'] ?? ''));
}
// 1) 수신자 수집
$recipients = $this->collectRecipients($mode, $data, $request);
if (!$recipients['ok']) return $recipients;
$list = $recipients['list']; // [ ['email'=>..., 'name'=>..., 'tokens'=>[...]], ... ]
$total = $recipients['total'];
$valid = count($list);
if ($valid < 1) {
return ['ok'=>false,'message'=>'유효한 수신자가 없습니다.'];
}
// 2) 배치 생성
$scheduledAt = !empty($data['scheduled_at']) ? (string)$data['scheduled_at'].':00' : null;
$status = $scheduledAt ? 'scheduled' : 'queued';
$ipBin = null;
try { $ipBin = @inet_pton($ctx['ip'] ?? '') ?: null; } catch (\Throwable $e) {}
$batchId = $this->repo->createBatch([
'admin_user_id'=>$adminId,
'admin_name'=>$ctx['admin_name'] ?? '',
'created_ip'=>$ipBin,
'created_ua'=>mb_substr((string)($ctx['ua'] ?? ''),0,255),
'send_mode'=>$mode,
'from_email'=>$data['from_email'],
'from_name'=>$data['from_name'] ?? '',
'reply_to'=>$data['reply_to'] ?? '',
'skin_key'=>$data['skin_key'],
'subject_raw'=>$data['subject'],
'body_raw'=>$data['body'],
'hero_image_url'=>$data['hero_image_url'] ?? '',
'cta_label'=>$data['cta_label'] ?? '',
'cta_url'=>$data['cta_url'] ?? '',
'scheduled_at'=>$scheduledAt,
'status'=>$status,
'total_count'=>$total,
'valid_count'=>$valid,
'sent_count'=>0,
'failed_count'=>0,
'canceled_count'=>0,
'last_error'=>'',
]);
// 3) 아이템 insert (토큰 치환)
$rows = [];
$seq = 1;
foreach ($list as $r) {
$tokens = $r['tokens'] ?? [];
// strtr로 치환(키-값 배열)
$subjectFinal = $this->applyTokens((string)$data['subject'], $tokens);
$bodyFinal = $this->applyTokens((string)$data['body'], $tokens);
$rows[] = [
'batch_id'=>$batchId,
'seq'=>$seq++,
'to_email'=>$r['email'],
'to_name'=>$r['name'] ?? '',
'token_json'=>json_encode($tokens, JSON_UNESCAPED_UNICODE),
'subject_final'=>$subjectFinal,
'body_final'=>$bodyFinal,
'status'=>'queued',
'attempts'=>0,
'last_error'=>'',
];
}
foreach (array_chunk($rows, 500) as $chunk) {
$this->repo->bulkInsertItems($batchId, $chunk);
}
if (!$scheduledAt) {
\App\Jobs\Admin\Mail\DispatchMailBatchJob::dispatch($batchId)->onQueue('mail');
}
return [
'ok'=>true,
'batch_id'=>$batchId,
'total'=>$total,
'valid'=>$valid,
'scheduled'=>(bool)$scheduledAt,
];
}
private function collectRecipients(string $mode, array $data, Request $request): array
{
$raw = [];
if ($mode === 'one') {
if (empty($data['to_email'])) return ['ok'=>false,'message'=>'수신자 이메일을 입력하세요.'];
$raw[] = ['email'=>$data['to_email'], 'name'=>$data['to_name'] ?? '', 'tokens'=>[]];
}
elseif ($mode === 'many') {
$text = (string)($data['to_emails_text'] ?? '');
$raw = array_merge($raw, $this->parseEmailsText($text));
}
elseif ($mode === 'csv') {
$file = $request->file('to_emails_csv');
if (!$file) return ['ok'=>false,'message'=>'CSV 파일을 업로드하세요.'];
$raw = array_merge($raw, $this->parseEmailsCsv($file->getRealPath()));
}
$total = count($raw);
$seen = [];
$list = [];
foreach ($raw as $r) {
$email = mb_strtolower(trim((string)($r['email'] ?? '')));
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) continue;
if (isset($seen[$email])) continue;
$seen[$email] = true;
$list[] = [
'email'=>$email,
'name'=>trim((string)($r['name'] ?? '')),
'tokens'=>is_array($r['tokens'] ?? null) ? $r['tokens'] : [],
];
}
return ['ok'=>true,'total'=>$total,'list'=>$list];
}
private function parseEmailsText(string $text): array
{
// 형식:
// email,token1,token2,...
// 1열=email, 2열={_text_02_}, 3열={_text_03_} ...
$lines = preg_split('/\r\n|\r|\n/', $text) ?: [];
$out = [];
foreach ($lines as $line) {
$line = trim((string)$line);
if ($line === '') continue;
// 빈열 유지(열 밀림 방지) => filter 하지 않음
$cols = array_map('trim', explode(',', $line));
$email = $cols[0] ?? '';
$name = $cols[1] ?? ''; // 로그용으로만(원하면 비워도 됨)
$tokens = $this->colsToTokens($cols);
$out[] = ['email'=>$email,'name'=>$name,'tokens'=>$tokens];
}
return $out;
}
private function parseEmailsCsv(string $path): array
{
$out = [];
$fh = fopen($path, 'r');
if (!$fh) return $out;
while (($row = fgetcsv($fh)) !== false) {
if (!$row) continue;
// fgetcsv는 빈값도 배열에 포함됨(열 밀림 방지에 유리)
$cols = array_map(fn($v)=>trim((string)$v), $row);
$email = $cols[0] ?? '';
$name = $cols[1] ?? ''; // 로그용
$tokens = $this->colsToTokens($cols);
$out[] = ['email'=>$email,'name'=>$name,'tokens'=>$tokens];
}
fclose($fh);
return $out;
}
private function colsToTokens(array $cols): array
{
$tokens = [];
if (!empty($cols[1])) $tokens['{_name_}'] = $cols[1];
for ($i = 1; $i < count($cols) && $i <= 8; $i++) { // 최대 {_text_09_} 까지
$tokenNo = str_pad((string)($i + 1), 2, '0', STR_PAD_LEFT); // 02..09
$tokens['{_text_'.$tokenNo.'_}'] = (string)($cols[$i] ?? '');
}
return $tokens;
}
private function applyTokens(string $text, array $tokens): string
{
if ($text === '' || empty($tokens)) return $text;
return strtr($text, $tokens);
}
public function dispatchDueScheduledBatches(int $limit=20): int
{
$rows = $this->repo->pickDueScheduledBatches($limit);
$n = 0;
foreach ($rows as $b) {
$this->repo->setBatchQueued((int)$b->id);
\App\Jobs\Admin\Mail\DispatchMailBatchJob::dispatch((int)$b->id)->onQueue('mail');
$n++;
}
return $n;
}
public function sendItem(object $batch, object $item): array
{
if (in_array((string)$batch->status, ['canceled'], true)) {
$this->repo->updateItem((int)$item->id, [
'status'=>'skipped','last_error'=>'batch_canceled'
]);
return ['ok'=>false,'skipped'=>true];
}
$view = match ((string)$batch->skin_key) {
'newsletter' => 'mail.skins.newsletter',
'minimal' => 'mail.skins.minimal',
'clean' => 'mail.skins.clean',
'dark' => 'mail.skins.dark',
default => 'mail.skins.hero',
};
$subject = (string)($item->subject_final ?? '');
$bodyText = (string)($item->body_final ?? '');
// 핵심: preview와 동일한 변수명으로 통일
$payload = [
'brand' => config('app.name'),
'subtitle' => '공지/이벤트/안내 메일', // 스킨에서 쓰면 보이고, 안 쓰면 무시됨
'logoUrl' => config('services.brand.logo_url', ''),
'subject' => $subject,
'body_html' => nl2br(e($bodyText)),
'body_text' => $bodyText,
'heroUrl' => (string)($batch->hero_image_url ?? ''),
'siteUrl' => config('app.url'),
'year' => date('Y'),
'sns' => [
['label'=>'Instagram', 'url'=>config('services.brand.sns.instagram','')],
['label'=>'YouTube', 'url'=>config('services.brand.sns.youtube','')],
['label'=>'Kakao', 'url'=>config('services.brand.sns.kakao','')],
],
];
try {
$this->mail->sendTemplate(
$item->to_email,
$subject,
$view,
$payload,
queue: false
);
$this->repo->updateItem((int)$item->id, [
'status'=>'sent',
'attempts'=> (int)$item->attempts + 1,
'last_error'=>'',
'sent_at'=> now()->format('Y-m-d H:i:s'),
]);
return ['ok'=>true];
} catch (\Throwable $e) {
$msg = mb_substr($e->getMessage(), 0, 500);
$this->repo->updateItem((int)$item->id, [
'status'=>'failed',
'attempts'=> (int)$item->attempts + 1,
'last_error'=>$msg,
]);
Log::warning('[admin-mail] send failed', [
'batch_id'=>$batch->id,
'item_id'=>$item->id,
'to'=>$item->to_email,
'err'=>$msg,
]);
return ['ok'=>false,'error'=>$msg];
}
}
public function refreshBatchProgress(int $batchId): void
{
$b = $this->repo->findBatch($batchId);
if (!$b) return;
$cnt = $this->repo->countItemsByStatus($batchId);
$valid = (int)$b->valid_count;
$sent = (int)($cnt['sent'] ?? 0);
$failed= (int)($cnt['failed'] ?? 0);
$queued= (int)($cnt['queued'] ?? 0);
$status = (string)$b->status;
if ($status !== 'canceled') {
if ($queued > 0) $status = 'sending';
else {
if ($failed === 0 && $sent >= $valid) $status = 'sent';
elseif ($sent > 0 && $failed > 0) $status = 'partial';
else $status = 'failed';
}
}
$this->repo->updateBatchCounts($batchId, $cnt, $status);
}
}