274 lines
8.7 KiB
PHP
274 lines
8.7 KiB
PHP
<?php
|
|
|
|
namespace App\Database\Sample;
|
|
|
|
use App\Enums\NotificationLogStatus;
|
|
use App\Models\NotificationDefinition;
|
|
use App\Models\NotificationLog;
|
|
use App\Models\User;
|
|
use App\Traits\HasSeederCounts;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
|
use Illuminate\Database\Seeder;
|
|
use Illuminate\Support\Carbon;
|
|
|
|
/**
|
|
* 알림 발송 이력 샘플 시더 추상 베이스.
|
|
*
|
|
* 코어/모듈/플러그인이 각자 영역의 발송 이력을 채울 수 있도록 공통 골격을 제공한다.
|
|
* - 등록된 NotificationDefinition 중 자기 영역 정의만 추려서 사용
|
|
* - 수신자/발송자 = 실제 등록된 G7 사용자
|
|
* - 상태 분포 = 운영 트래픽 비율 (sent 80%, failed 13%, skipped 7%)
|
|
* - sent_at = 최근 60일 분포
|
|
*
|
|
* 서브클래스는 영역 필터(applyDefinitionScope) + 카운트 키/기본값 + 라벨 + 본문/제목 맵을 정의한다.
|
|
*/
|
|
abstract class AbstractNotificationLogSampleSeeder extends Seeder
|
|
{
|
|
use HasSeederCounts;
|
|
|
|
/**
|
|
* 발송 실패 시 사용할 실제 SMTP/메일 게이트웨이 에러 메시지.
|
|
*
|
|
* @var array<int, string>
|
|
*/
|
|
protected array $errorMessages = [
|
|
'SMTP connection refused: smtp.gmail.com:587',
|
|
'Connection timed out after 10s',
|
|
'Mailbox unavailable: 550 5.1.1 user unknown',
|
|
'TLS handshake failed',
|
|
'Rate limit exceeded (provider quota)',
|
|
'Recipient address rejected: domain not found',
|
|
'Authentication failed: invalid credentials',
|
|
'Greylisted, retry later (450 4.2.0)',
|
|
];
|
|
|
|
/**
|
|
* 발송 건너뜀 사유.
|
|
*
|
|
* @var array<int, string>
|
|
*/
|
|
protected array $skipReasons = [
|
|
'Template inactive',
|
|
'User opted out',
|
|
'Channel disabled by user preference',
|
|
'Quiet hours policy applied',
|
|
'Duplicate suppression window',
|
|
];
|
|
|
|
/**
|
|
* 알림 정의 쿼리에 영역 필터를 적용한다 (예: extension_type='core' 또는 extension_identifier='sirsoft-board').
|
|
*
|
|
* @param Builder $query NotificationDefinition 쿼리
|
|
* @return Builder 영역 필터가 적용된 쿼리
|
|
*/
|
|
abstract protected function applyDefinitionScope(Builder $query): Builder;
|
|
|
|
/**
|
|
* 카운트 옵션 키 (예: 'core_notification_logs', 'ecommerce_notification_logs').
|
|
*
|
|
* @return string 카운트 옵션 키
|
|
*/
|
|
abstract protected function countKey(): string;
|
|
|
|
/**
|
|
* 기본 생성 건수.
|
|
*
|
|
* @return int 기본 건수
|
|
*/
|
|
abstract protected function defaultCount(): int;
|
|
|
|
/**
|
|
* 콘솔 메시지에 사용할 영역 라벨 (예: '코어', '이커머스 모듈').
|
|
*
|
|
* @return string 영역 라벨
|
|
*/
|
|
abstract protected function scopeLabel(): string;
|
|
|
|
/**
|
|
* 알림 타입별 한국어 제목 맵.
|
|
*
|
|
* @return array<string, string> [type => subject]
|
|
*/
|
|
abstract protected function subjectMap(): array;
|
|
|
|
/**
|
|
* 알림 타입별 한국어 본문 빌더 맵.
|
|
*
|
|
* @return array<string, callable(User, Carbon): string> [type => fn($recipient, $sentAt) => string]
|
|
*/
|
|
abstract protected function bodyMap(): array;
|
|
|
|
/**
|
|
* 시더 실행.
|
|
*/
|
|
public function run(): void
|
|
{
|
|
$count = $this->getSeederCount($this->countKey(), $this->defaultCount());
|
|
$label = $this->scopeLabel();
|
|
|
|
$users = User::query()->get(['id', 'name', 'email']);
|
|
if ($users->isEmpty()) {
|
|
$this->command->warn("사용자 데이터가 없어 {$label} 알림 발송 이력 시더를 건너뜁니다.");
|
|
|
|
return;
|
|
}
|
|
|
|
$definitions = $this->applyDefinitionScope(
|
|
NotificationDefinition::query()->where('is_active', true)
|
|
)->get(['type', 'extension_type', 'extension_identifier', 'channels']);
|
|
|
|
if ($definitions->isEmpty()) {
|
|
$this->command->warn("{$label} 영역 활성 알림 정의가 없어 시더를 건너뜁니다.");
|
|
|
|
return;
|
|
}
|
|
|
|
$admin = User::query()
|
|
->whereHas('roles', fn ($q) => $q->where('identifier', 'admin'))
|
|
->first();
|
|
$adminId = $admin?->id;
|
|
|
|
$this->command->info("{$label} 알림 발송 이력 시딩 시작... ({$count}건)");
|
|
|
|
$now = Carbon::now();
|
|
$batch = [];
|
|
|
|
for ($i = 0; $i < $count; $i++) {
|
|
$definition = $definitions->random();
|
|
$channel = $this->pickChannel($definition->channels ?? ['mail']);
|
|
$recipient = $users->random();
|
|
[$status, $error] = $this->randomStatusAndError();
|
|
$sentAt = $this->randomSentAt($now);
|
|
|
|
$batch[] = [
|
|
'channel' => $channel,
|
|
'notification_type' => $definition->type,
|
|
'extension_type' => $definition->extension_type,
|
|
'extension_identifier' => $definition->extension_identifier,
|
|
'recipient_user_id' => $recipient->id,
|
|
'recipient_identifier' => $channel === 'mail'
|
|
? $recipient->email
|
|
: (string) $recipient->id,
|
|
'recipient_name' => $recipient->name,
|
|
'sender_user_id' => $this->isAdminBoundType($definition->type) ? null : $adminId,
|
|
'subject' => $this->renderSubject($definition->type),
|
|
'body' => $this->renderBody($definition->type, $recipient, $sentAt),
|
|
'status' => $status->value,
|
|
'error_message' => $error,
|
|
'source' => 'notification',
|
|
'sent_at' => $sentAt,
|
|
'created_at' => $sentAt,
|
|
'updated_at' => $sentAt,
|
|
];
|
|
}
|
|
|
|
foreach (array_chunk($batch, 100) as $chunk) {
|
|
NotificationLog::insert($chunk);
|
|
}
|
|
|
|
$this->command->info("{$label} 알림 발송 이력 시딩 완료 ({$count}건)");
|
|
}
|
|
|
|
/**
|
|
* 알림 정의에 등록된 채널 중 하나를 선택한다.
|
|
*
|
|
* @param array<int, string> $channels 활성 채널 배열
|
|
* @return string 선택된 채널
|
|
*/
|
|
protected function pickChannel(array $channels): string
|
|
{
|
|
if (empty($channels)) {
|
|
return 'mail';
|
|
}
|
|
|
|
if (in_array('mail', $channels, true) && in_array('database', $channels, true)) {
|
|
return mt_rand(1, 100) <= 60 ? 'mail' : 'database';
|
|
}
|
|
|
|
return $channels[array_rand($channels)];
|
|
}
|
|
|
|
/**
|
|
* 가중치 기반 상태/에러 메시지 페어를 반환한다.
|
|
*
|
|
* @return array{0: NotificationLogStatus, 1: string|null}
|
|
*/
|
|
protected function randomStatusAndError(): array
|
|
{
|
|
$rand = mt_rand(1, 100);
|
|
|
|
if ($rand <= 80) {
|
|
return [NotificationLogStatus::Sent, null];
|
|
}
|
|
|
|
if ($rand <= 93) {
|
|
return [
|
|
NotificationLogStatus::Failed,
|
|
$this->errorMessages[array_rand($this->errorMessages)],
|
|
];
|
|
}
|
|
|
|
return [
|
|
NotificationLogStatus::Skipped,
|
|
$this->skipReasons[array_rand($this->skipReasons)],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 최근 60일 내 임의 발송 시각을 반환한다.
|
|
*
|
|
* @param Carbon $now 기준 시각
|
|
* @return Carbon 발송 시각
|
|
*/
|
|
protected function randomSentAt(Carbon $now): Carbon
|
|
{
|
|
return (clone $now)
|
|
->subDays(mt_rand(0, 60))
|
|
->subHours(mt_rand(0, 23))
|
|
->subMinutes(mt_rand(0, 59))
|
|
->subSeconds(mt_rand(0, 59));
|
|
}
|
|
|
|
/**
|
|
* 알림 타입이 관리자 수신용인지 (sender_user_id null 처리).
|
|
*
|
|
* @param string $type 알림 타입
|
|
* @return bool 관리자 수신용이면 true
|
|
*/
|
|
protected function isAdminBoundType(string $type): bool
|
|
{
|
|
return str_ends_with($type, '_admin');
|
|
}
|
|
|
|
/**
|
|
* 알림 타입별 제목 (서브클래스 subjectMap 우선, fallback 은 generic).
|
|
*
|
|
* @param string $type 알림 타입
|
|
* @return string 제목
|
|
*/
|
|
protected function renderSubject(string $type): string
|
|
{
|
|
return $this->subjectMap()[$type] ?? "[G7] {$type} 알림";
|
|
}
|
|
|
|
/**
|
|
* 알림 타입별 본문 (서브클래스 bodyMap 우선, fallback 은 generic).
|
|
*
|
|
* @param string $type 알림 타입
|
|
* @param User $recipient 수신자
|
|
* @param Carbon $sentAt 발송 시각
|
|
* @return string 본문
|
|
*/
|
|
protected function renderBody(string $type, User $recipient, Carbon $sentAt): string
|
|
{
|
|
$builder = $this->bodyMap()[$type] ?? null;
|
|
|
|
if ($builder === null) {
|
|
return "안녕하세요 {$recipient->name}님,\n\n{$type} 알림이 도착했습니다.";
|
|
}
|
|
|
|
return $builder($recipient, $sentAt);
|
|
}
|
|
}
|