v7.0.0-beta.2 release

This commit is contained in:
HeuJung
2026-04-20 20:37:49 +09:00
parent 6595fd0eb5
commit fa8eba7a0d
1165 changed files with 107118 additions and 762127 deletions
-83
View File
@@ -1,83 +0,0 @@
<?php
namespace Database\Factories;
use App\Enums\ExtensionOwnerType;
use App\Enums\MailSendStatus;
use App\Models\MailSendLog;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* 메일 발송 이력 팩토리
*
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\MailSendLog>
*/
class MailSendLogFactory extends Factory
{
/**
* 모델 클래스
*
* @var string
*/
protected $model = MailSendLog::class;
/**
* 기본 상태 정의
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'sender_email' => $this->faker->safeEmail(),
'sender_name' => $this->faker->name(),
'recipient_email' => $this->faker->safeEmail(),
'recipient_name' => $this->faker->name(),
'subject' => $this->faker->sentence(),
'body' => '<p>'.$this->faker->paragraph().'</p>',
'template_type' => $this->faker->randomElement(['welcome', 'password_reset', 'new_comment']),
'extension_type' => ExtensionOwnerType::Core->value,
'extension_identifier' => 'core',
'source' => $this->faker->randomElement(['notification', 'test_mail', null]),
'status' => MailSendStatus::Sent->value,
'error_message' => null,
'sent_at' => now(),
];
}
/**
* 발송 실패 상태
*/
public function failed(): static
{
return $this->state(fn (array $attributes) => [
'status' => MailSendStatus::Failed->value,
'error_message' => $this->faker->sentence(),
]);
}
/**
* 특정 확장 지정
*
* @param ExtensionOwnerType $type 확장 타입
* @param string $identifier 확장 식별자
* @return static
*/
public function forExtension(ExtensionOwnerType $type, string $identifier): static
{
return $this->state(fn (array $attributes) => [
'extension_type' => $type->value,
'extension_identifier' => $identifier,
]);
}
/**
* 특정 템플릿 유형 지정
*/
public function withTemplateType(string $templateType): static
{
return $this->state(fn (array $attributes) => [
'template_type' => $templateType,
]);
}
}
@@ -1,90 +0,0 @@
<?php
namespace Database\Factories;
use App\Models\MailTemplate;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* MailTemplate 팩토리
*
* @extends Factory<MailTemplate>
*/
class MailTemplateFactory extends Factory
{
/**
* @var string 팩토리 모델 클래스
*/
protected $model = MailTemplate::class;
/**
* 기본 상태를 정의합니다.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'type' => $this->faker->unique()->slug(2),
'subject' => [
'ko' => '[테스트] '.$this->faker->sentence(),
'en' => '[Test] '.$this->faker->sentence(),
],
'body' => [
'ko' => '<p>'.$this->faker->paragraph().'</p>',
'en' => '<p>'.$this->faker->paragraph().'</p>',
],
'variables' => [
['key' => 'name', 'description' => '수신자 이름'],
['key' => 'app_name', 'description' => '사이트명'],
],
'is_active' => true,
'is_default' => true,
'updated_by' => null,
];
}
/**
* 비활성 상태
*
* @return static
*/
public function inactive(): static
{
return $this->state(fn () => ['is_active' => false]);
}
/**
* 특정 유형
*
* @param string $type 템플릿 유형
* @return static
*/
public function withType(string $type): static
{
return $this->state(fn () => ['type' => $type]);
}
/**
* 변수 포함 본문
*
* @return static
*/
public function withVariables(): static
{
return $this->state(fn () => [
'subject' => [
'ko' => '[{app_name}] {name}님 환영합니다',
'en' => '[{app_name}] Welcome {name}',
],
'body' => [
'ko' => '<p>{name}님, {app_name}에 가입해 주셔서 감사합니다.</p>',
'en' => '<p>Welcome {name}, thank you for joining {app_name}.</p>',
],
'variables' => [
['key' => 'name', 'description' => '수신자 이름'],
['key' => 'app_name', 'description' => '사이트명'],
],
]);
}
}
@@ -0,0 +1,38 @@
<?php
namespace Database\Factories;
use App\Models\NotificationDefinition;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* NotificationDefinition 팩토리
*
* @extends Factory<NotificationDefinition>
*/
class NotificationDefinitionFactory extends Factory
{
protected $model = NotificationDefinition::class;
/**
* 기본 상태를 정의합니다.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'type' => $this->faker->unique()->slug(2),
'hook_prefix' => 'core',
'extension_type' => 'core',
'extension_identifier' => '',
'name' => ['ko' => $this->faker->words(2, true), 'en' => $this->faker->words(2, true)],
'description' => ['ko' => $this->faker->sentence(), 'en' => $this->faker->sentence()],
'variables' => [],
'channels' => ['database'],
'hooks' => [],
'is_active' => true,
'is_default' => false,
];
}
}
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 알림 정의 테이블 생성.
*
* @return void
*/
public function up(): void
{
Schema::create('notification_definitions', function (Blueprint $table) {
$table->id()->comment('ID');
$table->string('type', 100)->unique()->comment('알림 타입 (welcome, order_confirmed 등)');
$table->string('hook_prefix', 100)->comment('훅 접두사 (core.auth, sirsoft-ecommerce 등)');
$table->string('extension_type', 20)->comment('확장 타입: core, module, plugin');
$table->string('extension_identifier', 100)->comment('확장 식별자: core, sirsoft-board 등');
$table->text('name')->comment('다국어 이름 ({"ko": "회원가입 환영", "en": "Welcome"})');
$table->mediumText('description')->nullable()->comment('다국어 설명');
$table->text('variables')->nullable()->comment('사용 가능 변수 메타데이터 ([{key, description}])');
$table->text('channels')->comment('활성 채널 (["mail", "database"])');
$table->text('hooks')->comment('트리거 훅 목록 (["core.auth.after_register"])');
$table->boolean('is_active')->default(true)->comment('활성 여부');
$table->boolean('is_default')->default(true)->comment('시더 생성 여부');
$table->timestamps();
});
}
/**
* 알림 정의 테이블 삭제.
*
* @return void
*/
public function down(): void
{
Schema::dropIfExists('notification_definitions');
}
};
@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 알림 템플릿 테이블 생성.
*
* @return void
*/
public function up(): void
{
Schema::create('notification_templates', function (Blueprint $table) {
$table->id()->comment('ID');
$table->foreignId('definition_id')->constrained('notification_definitions')->cascadeOnDelete()->comment('알림 정의 ID');
$table->string('channel', 50)->comment('채널: mail, database, fcm');
$table->text('subject')->nullable()->comment('다국어 제목 ({"ko": "...", "en": "..."})');
$table->mediumText('body')->comment('다국어 본문 ({"ko": "...", "en": "..."})');
$table->boolean('is_active')->default(true)->comment('해당 채널 활성 여부');
$table->boolean('is_default')->default(true)->comment('시더 생성 여부');
$table->text('user_overrides')->nullable()->comment('사용자가 수정한 필드명 목록');
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete()->comment('수정자');
$table->timestamps();
$table->unique(['definition_id', 'channel']);
});
}
/**
* 알림 템플릿 테이블 삭제.
*
* @return void
*/
public function down(): void
{
Schema::dropIfExists('notification_templates');
}
};
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 사이트내 알림 테이블 생성 (Laravel 표준).
*
* @return void
*/
public function up(): void
{
Schema::create('notifications', function (Blueprint $table) {
$table->uuid('id')->primary()->comment('UUID');
$table->string('type')->comment('알림 클래스명');
$table->string('notifiable_type')->comment('알림 수신 모델 타입');
$table->unsignedBigInteger('notifiable_id')->comment('알림 수신 모델 ID');
$table->index(['notifiable_type', 'notifiable_id']);
$table->text('data')->comment('알림 데이터 (JSON)');
$table->timestamp('read_at')->nullable()->comment('읽음 시각');
$table->timestamps();
});
}
/**
* 사이트내 알림 테이블 삭제.
*
* @return void
*/
public function down(): void
{
Schema::dropIfExists('notifications');
}
};
@@ -0,0 +1,55 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 알림 발송 이력 테이블 생성.
*
* @return void
*/
public function up(): void
{
Schema::create('notification_logs', function (Blueprint $table) {
$table->id()->comment('ID');
$table->string('channel', 50)->comment('채널: mail, database, fcm 등');
$table->string('notification_type', 100)->comment('알림 타입: welcome, order_confirmed 등');
$table->string('extension_type', 20)->default('core')->comment('확장 타입: core, module, plugin');
$table->string('extension_identifier', 100)->default('core')->comment('확장 식별자');
$table->foreignId('recipient_user_id')->nullable()->constrained('users')->nullOnDelete()
->comment('수신자 회원 ID (회원인 경우)');
$table->string('recipient_identifier', 255)->comment('수신자 식별자 (채널별: 이메일, 디바이스토큰, user_id 등)');
$table->string('recipient_name', 255)->nullable()->comment('수신자 표시명 (발송 시점 스냅샷)');
$table->foreignId('sender_user_id')->nullable()->constrained('users')->nullOnDelete()
->comment('발송자 회원 ID (null=시스템 자동)');
$table->string('subject', 500)->nullable()->comment('렌더링된 제목');
$table->longText('body')->nullable()->comment('렌더링된 본문');
$table->string('status', 20)->default('sent')->comment('상태: sent, failed, skipped');
$table->text('error_message')->nullable()->comment('에러 메시지');
$table->string('source', 100)->nullable()->comment('발송 출처: notification, test_mail 등');
$table->timestamp('sent_at')->useCurrent()->comment('발송 시각');
$table->timestamps();
$table->index('channel', 'idx_notification_logs_channel');
$table->index('notification_type', 'idx_notification_logs_type');
$table->index('status', 'idx_notification_logs_status');
$table->index('sent_at', 'idx_notification_logs_sent_at');
$table->index('recipient_user_id', 'idx_notification_logs_recipient_user');
$table->index('recipient_identifier', 'idx_notification_logs_recipient_id');
$table->index(['extension_type', 'extension_identifier'], 'idx_notification_logs_extension');
});
}
/**
* 알림 발송 이력 테이블 삭제.
*
* @return void
*/
public function down(): void
{
Schema::dropIfExists('notification_logs');
}
};
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 알림 템플릿에 click_url 컬럼 추가.
*
* 알림센터에서 알림 클릭 시 이동할 URL 패턴을 저장합니다.
* 변수 플레이스홀더 사용 가능 (예: /mypage/orders/{order_number}).
* nullable — 미정의 시 프론트엔드 fallback 경로 사용.
*/
public function up(): void
{
Schema::table('notification_templates', function (Blueprint $table) {
$table->string('click_url', 500)
->nullable()
->after('body')
->comment('알림 클릭 시 이동 URL 패턴 (변수 치환 가능, 예: /mypage/orders/{order_number})');
});
}
/**
* click_url 컬럼 제거.
*/
public function down(): void
{
Schema::table('notification_templates', function (Blueprint $table) {
if (Schema::hasColumn('notification_templates', 'click_url')) {
$table->dropColumn('click_url');
}
});
}
};
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 알림 템플릿에 수신자 규칙 컬럼을 추가합니다.
*
* 채널별 독립 수신자 설정을 위해 templates 레벨에 recipients를 배치합니다.
*/
public function up(): void
{
Schema::table('notification_templates', function (Blueprint $table) {
$table->text('recipients')
->nullable()
->comment('수신자 규칙 JSON ([{type, value, relation, exclude_trigger_user}])')
->after('click_url');
});
}
/**
* 수신자 규칙 컬럼을 제거합니다.
*/
public function down(): void
{
Schema::table('notification_templates', function (Blueprint $table) {
if (Schema::hasColumn('notification_templates', 'recipients')) {
$table->dropColumn('recipients');
}
});
}
};
@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* notification_definitions 테이블에 user_overrides 컬럼 추가.
*
* 사용자가 수정한 필드명 목록을 JSON 으로 저장하여 시더 재실행 시 보존합니다.
*
* @return void
*/
public function up(): void
{
Schema::table('notification_definitions', function (Blueprint $table) {
$table->text('user_overrides')->nullable()->after('is_default')
->comment('유저가 수정한 필드명 목록 (예: ["name", "is_active"])');
});
}
/**
* user_overrides 컬럼 삭제.
*
* @return void
*/
public function down(): void
{
if (! Schema::hasColumn('notification_definitions', 'user_overrides')) {
return;
}
Schema::table('notification_definitions', function (Blueprint $table) {
$table->dropColumn('user_overrides');
});
}
};
@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* schedules 테이블에 user_overrides 컬럼 추가 (사전 설계).
*
* 현재는 시더 없이 사용자 수동 생성 중심이지만, 향후 코어/모듈이 기본 스케줄
* 시더를 도입하면 syncOrCreateFromUpgrade 호출만 추가하면 됩니다.
*
* @return void
*/
public function up(): void
{
Schema::table('schedules', function (Blueprint $table) {
$table->text('user_overrides')->nullable()->after('is_active')
->comment('유저가 수정한 필드명 목록 (예: ["expression", "command", "timeout"])');
});
}
/**
* user_overrides 컬럼 삭제.
*
* @return void
*/
public function down(): void
{
if (! Schema::hasColumn('schedules', 'user_overrides')) {
return;
}
Schema::table('schedules', function (Blueprint $table) {
$table->dropColumn('user_overrides');
});
}
};
@@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 모듈/플러그인 테이블에 vendor_mode 컬럼 추가.
*
* 마지막 설치 시 사용된 vendor 모드(auto/composer/bundled)를 기록하여,
* 업데이트 시 동일 모드를 상속할 수 있도록 합니다.
*/
public function up(): void
{
Schema::table('modules', function (Blueprint $table) {
if (! Schema::hasColumn('modules', 'vendor_mode')) {
$table->string('vendor_mode', 16)
->default('auto')
->after('status')
->comment('Vendor 설치 모드 (auto|composer|bundled)');
}
});
Schema::table('plugins', function (Blueprint $table) {
if (! Schema::hasColumn('plugins', 'vendor_mode')) {
$table->string('vendor_mode', 16)
->default('auto')
->after('status')
->comment('Vendor 설치 모드 (auto|composer|bundled)');
}
});
}
/**
* 마이그레이션 롤백.
*/
public function down(): void
{
Schema::table('modules', function (Blueprint $table) {
if (Schema::hasColumn('modules', 'vendor_mode')) {
$table->dropColumn('vendor_mode');
}
});
Schema::table('plugins', function (Blueprint $table) {
if (Schema::hasColumn('plugins', 'vendor_mode')) {
$table->dropColumn('vendor_mode');
}
});
}
};
+2 -2
View File
@@ -19,7 +19,7 @@ class DatabaseSeeder extends Seeder
AdminUserSeeder::class,
RolePermissionSeeder::class,
CoreAdminMenuSeeder::class,
MailTemplateSeeder::class,
NotificationDefinitionSeeder::class,
]);
// 샘플 시더 (--sample 옵션 시에만 실행)
@@ -29,7 +29,7 @@ class DatabaseSeeder extends Seeder
$this->call([
Sample\DummyUserSeeder::class,
Sample\MailSendLogSeeder::class,
Sample\NotificationLogSeeder::class,
Sample\TemplateSeeder::class,
Sample\TemplateLayoutVersionSeeder::class,
Sample\ActivityLogSampleSeeder::class,
-51
View File
@@ -1,51 +0,0 @@
<?php
namespace Database\Seeders;
use App\Models\MailTemplate;
use Illuminate\Database\Seeder;
class MailTemplateSeeder extends Seeder
{
/**
* 코어 메일 템플릿을 시딩합니다.
*
* @return void
*/
public function run(): void
{
$this->command->info('코어 메일 템플릿 시딩 시작...');
$templates = $this->getDefaultTemplates();
foreach ($templates as $data) {
MailTemplate::updateOrCreate(
['type' => $data['type']],
[
'subject' => $data['subject'],
'body' => $data['body'],
'variables' => $data['variables'],
'is_active' => true,
'is_default' => true,
]
);
$this->command->info(" - {$data['type']} 템플릿 등록 완료");
}
$this->command->info('코어 메일 템플릿 시딩 완료 ('.count($templates).'종)');
}
/**
* 기본 템플릿 데이터를 반환합니다.
* 정의는 config/core.php의 mail_templates에서 읽습니다.
*
* 리셋 기능에서도 사용하기 위해 public으로 제공합니다.
*
* @return array<int, array{type: string, subject: array, body: array, variables: array}> 기본 템플릿 배열
*/
public function getDefaultTemplates(): array
{
return config('core.mail_templates', []);
}
}
@@ -0,0 +1,226 @@
<?php
namespace Database\Seeders;
use App\Extension\Helpers\NotificationSyncHelper;
use Illuminate\Database\Seeder;
class NotificationDefinitionSeeder extends Seeder
{
/**
* 코어 알림 정의 및 템플릿을 시딩합니다.
*
* 데이터 정합성 (upsert + stale cleanup) 은 NotificationSyncHelper 에 위임하며,
* 사용자 수정(user_overrides) 은 helper 내부 HasUserOverrides trait 를 통해 보존됩니다.
*
* @return void
*/
public function run(): void
{
$this->command?->info('코어 알림 정의 시딩 시작...');
$helper = app(NotificationSyncHelper::class);
$definitions = $this->getDefaultDefinitions();
$definedTypes = [];
foreach ($definitions as $data) {
$definition = $helper->syncDefinition($data);
$definedTypes[] = $definition->type;
$definedChannels = [];
foreach ($data['templates'] as $template) {
$helper->syncTemplate($definition->id, $template);
$definedChannels[] = $template['channel'];
}
// 완전 동기화: seeder 에서 제거된 channel 의 template 삭제
$helper->cleanupStaleTemplates($definition->id, $definedChannels);
$this->command?->info(" - {$data['type']} 알림 정의 등록 완료");
}
// 완전 동기화: seeder 에서 제거된 코어 definition 삭제 (cascade 로 template 도 정리)
$helper->cleanupStaleDefinitions('core', 'core', $definedTypes);
$this->command?->info('코어 알림 정의 시딩 완료 ('.count($definitions).'종)');
}
/**
* 코어 기본 알림 정의 데이터를 반환합니다.
*
* @return array
*/
public function getDefaultDefinitions(): array
{
return [
$this->welcomeDefinition(),
$this->resetPasswordDefinition(),
$this->passwordChangedDefinition(),
];
}
/**
* 회원가입 환영 알림 정의.
*
* @return array
*/
private function welcomeDefinition(): array
{
return [
'type' => 'welcome',
'hook_prefix' => 'core.auth',
'extension_type' => 'core',
'extension_identifier' => 'core',
'name' => ['ko' => '회원가입 환영', 'en' => 'Welcome'],
'description' => ['ko' => '회원가입 완료 시 발송되는 환영 알림', 'en' => 'Welcome notification sent upon registration'],
'channels' => ['mail', 'database'],
'hooks' => ['core.auth.after_register'],
'variables' => [
['key' => 'name', 'description' => '수신자 이름'],
['key' => 'app_name', 'description' => '사이트명'],
['key' => 'action_url', 'description' => '로그인 페이지 URL'],
['key' => 'site_url', 'description' => '사이트 URL'],
],
'templates' => [
[
'channel' => 'mail',
'recipients' => [['type' => 'trigger_user']],
'subject' => [
'ko' => '[{app_name}] 회원가입을 환영합니다',
'en' => '[{app_name}] Welcome to Our Service',
],
'body' => [
'ko' => '<h1>{name}님, 환영합니다!</h1>'
.'<p>{app_name}에 가입해 주셔서 감사합니다.</p>'
.'<p>이제 모든 서비스를 이용하실 수 있습니다. 아래 버튼을 클릭하여 로그인해 주세요.</p>'
.'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 24px 0;"><tr><td align="center"><a href="{action_url}" style="display: inline-block; padding: 12px 32px; background-color: #2d3748; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 600; font-size: 14px;">로그인하기</a></td></tr></table>'
.'<p>감사합니다,<br><a href="{site_url}">{app_name}</a></p>',
'en' => '<h1>Welcome, {name}!</h1>'
.'<p>Thank you for joining {app_name}.</p>'
.'<p>You now have access to all our services. Click the button below to log in.</p>'
.'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 24px 0;"><tr><td align="center"><a href="{action_url}" style="display: inline-block; padding: 12px 32px; background-color: #2d3748; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 600; font-size: 14px;">Log In</a></td></tr></table>'
.'<p>Thank you,<br><a href="{site_url}">{app_name}</a></p>',
],
],
[
'channel' => 'database',
'recipients' => [['type' => 'trigger_user']],
'subject' => ['ko' => '회원가입을 환영합니다', 'en' => 'Welcome to our service'],
'body' => ['ko' => '{name}님, {app_name}에 가입해 주셔서 감사합니다.', 'en' => 'Welcome {name}, thank you for joining {app_name}.'],
'click_url' => '/mypage',
],
],
];
}
/**
* 비밀번호 재설정 알림 정의.
*
* @return array
*/
private function resetPasswordDefinition(): array
{
return [
'type' => 'reset_password',
'hook_prefix' => 'core.auth',
'extension_type' => 'core',
'extension_identifier' => 'core',
'name' => ['ko' => '비밀번호 재설정', 'en' => 'Password Reset'],
'description' => ['ko' => '비밀번호 재설정 요청 시 발송되는 알림', 'en' => 'Notification sent when password reset is requested'],
'channels' => ['mail', 'database'],
'hooks' => ['core.auth.after_reset_password_request'],
'variables' => [
['key' => 'name', 'description' => '수신자 이름'],
['key' => 'app_name', 'description' => '사이트명'],
['key' => 'action_url', 'description' => '비밀번호 재설정 URL'],
['key' => 'expire_minutes', 'description' => '링크 만료 시간(분)'],
['key' => 'site_url', 'description' => '사이트 URL'],
],
'templates' => [
[
'channel' => 'mail',
'recipients' => [['type' => 'trigger_user']],
'subject' => [
'ko' => '[{app_name}] 비밀번호 재설정 안내',
'en' => '[{app_name}] Password Reset Request',
],
'body' => [
'ko' => '<h1>{name}님, 안녕하세요.</h1>'
.'<p>비밀번호 재설정 요청을 받았습니다. 아래 버튼을 클릭하여 비밀번호를 재설정해 주세요.</p>'
.'<p>이 링크는 <strong>{expire_minutes}분</strong> 후 만료됩니다.</p>'
.'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 24px 0;"><tr><td align="center"><a href="{action_url}" style="display: inline-block; padding: 12px 32px; background-color: #2d3748; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 600; font-size: 14px;">비밀번호 재설정</a></td></tr></table>'
.'<p>비밀번호 재설정을 요청하지 않으셨다면, 이 이메일을 무시해 주세요.</p>'
.'<p>감사합니다,<br><a href="{site_url}">{app_name}</a></p>',
'en' => '<h1>Hello, {name}.</h1>'
.'<p>We received a request to reset your password. Click the button below to set a new password.</p>'
.'<p>This link will expire in <strong>{expire_minutes} minutes</strong>.</p>'
.'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 24px 0;"><tr><td align="center"><a href="{action_url}" style="display: inline-block; padding: 12px 32px; background-color: #2d3748; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 600; font-size: 14px;">Reset Password</a></td></tr></table>'
.'<p>If you did not request a password reset, please ignore this email.</p>'
.'<p>Thank you,<br><a href="{site_url}">{app_name}</a></p>',
],
],
[
'channel' => 'database',
'recipients' => [['type' => 'trigger_user']],
'subject' => ['ko' => '비밀번호 재설정 안내', 'en' => 'Password Reset Request'],
'body' => ['ko' => '{name}님, 비밀번호 재설정이 요청되었습니다. 본인이 요청하지 않았다면 이 알림을 무시해 주세요.', 'en' => '{name}, a password reset has been requested. If you did not request this, please ignore this notification.'],
'click_url' => '{action_url}',
],
],
];
}
/**
* 비밀번호 변경 완료 알림 정의.
*
* @return array
*/
private function passwordChangedDefinition(): array
{
return [
'type' => 'password_changed',
'hook_prefix' => 'core.auth',
'extension_type' => 'core',
'extension_identifier' => 'core',
'name' => ['ko' => '비밀번호 변경', 'en' => 'Password Changed'],
'description' => ['ko' => '비밀번호 변경 완료 시 발송되는 알림', 'en' => 'Notification sent when password is changed'],
'channels' => ['mail', 'database'],
'hooks' => ['core.auth.after_password_changed'],
'variables' => [
['key' => 'name', 'description' => '수신자 이름'],
['key' => 'app_name', 'description' => '사이트명'],
['key' => 'action_url', 'description' => '로그인 페이지 URL'],
['key' => 'site_url', 'description' => '사이트 URL'],
],
'templates' => [
[
'channel' => 'mail',
'recipients' => [['type' => 'trigger_user']],
'subject' => [
'ko' => '[{app_name}] 비밀번호가 변경되었습니다',
'en' => '[{app_name}] Your Password Has Been Changed',
],
'body' => [
'ko' => '<h1>{name}님, 안녕하세요.</h1>'
.'<p>계정의 비밀번호가 성공적으로 변경되었습니다.</p>'
.'<p><strong>본인이 변경하지 않았다면, 즉시 고객 지원팀에 문의하시기 바랍니다.</strong></p>'
.'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 24px 0;"><tr><td align="center"><a href="{action_url}" style="display: inline-block; padding: 12px 32px; background-color: #2d3748; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 600; font-size: 14px;">로그인하기</a></td></tr></table>'
.'<p>감사합니다,<br><a href="{site_url}">{app_name}</a></p>',
'en' => '<h1>Hello, {name}.</h1>'
.'<p>Your account password has been successfully changed.</p>'
.'<p><strong>If you did not make this change, please contact our support team immediately.</strong></p>'
.'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 24px 0;"><tr><td align="center"><a href="{action_url}" style="display: inline-block; padding: 12px 32px; background-color: #2d3748; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 600; font-size: 14px;">Log In</a></td></tr></table>'
.'<p>Thank you,<br><a href="{site_url}">{app_name}</a></p>',
],
],
[
'channel' => 'database',
'recipients' => [['type' => 'trigger_user']],
'subject' => ['ko' => '비밀번호가 변경되었습니다', 'en' => 'Your password has been changed'],
'body' => ['ko' => '{name}님, 비밀번호가 변경되었습니다. 본인이 변경하지 않았다면 즉시 고객 지원팀에 문의하시기 바랍니다.', 'en' => '{name}, your password has been changed. If you did not make this change, please contact support immediately.'],
'click_url' => '/mypage/change-password',
],
],
];
}
}
+28 -3
View File
@@ -57,6 +57,11 @@ class RolePermissionSeeder extends Seeder
* 계층형 권한을 생성합니다.
* 구조: 모듈(1레벨) → 카테고리(2레벨) → 개별 권한(3레벨)
* 정의는 config/core.php의 permissions에서 읽습니다.
*
* type 결정 우선순위 (CoreUpdateService::syncCoreRolesAndPermissions와 동일):
* - 개별 권한: 명시된 type > 상위 카테고리 type
* - 카테고리: 명시된 type > 하위가 모두 user이면 User, 그 외 Admin
* - 모듈 루트: 명시된 type > Admin
*/
private function createHierarchicalPermissions(): void
{
@@ -70,7 +75,9 @@ class RolePermissionSeeder extends Seeder
'description' => $moduleConfig['description'],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => PermissionType::Admin,
'type' => isset($moduleConfig['type'])
? PermissionType::from($moduleConfig['type'])
: PermissionType::Admin,
'order' => $moduleConfig['order'],
'parent_id' => null,
]);
@@ -79,6 +86,19 @@ class RolePermissionSeeder extends Seeder
$categories = $permConfig['categories'];
foreach ($categories as $categoryData) {
// 카테고리 type 결정: 명시 > 하위 권한 type 자동 추론
$childTypes = collect($categoryData['permissions'] ?? [])
->map(fn ($p) => $p['type'] ?? 'admin')
->unique();
$categoryType = ($childTypes->count() === 1 && $childTypes->first() === 'user')
? PermissionType::User
: PermissionType::Admin;
if (isset($categoryData['type'])) {
$categoryType = PermissionType::from($categoryData['type']);
}
// 2레벨: 카테고리 생성
$category = Permission::create([
'identifier' => $categoryData['identifier'],
@@ -86,20 +106,25 @@ class RolePermissionSeeder extends Seeder
'description' => $categoryData['description'],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => PermissionType::Admin,
'type' => $categoryType,
'order' => $categoryData['order'],
'parent_id' => $coreModule->id,
]);
// 3레벨: 개별 권한 생성
foreach ($categoryData['permissions'] as $permData) {
// 개별 권한 type: 명시 우선, 없으면 카테고리 type 상속
$permissionType = isset($permData['type'])
? PermissionType::from($permData['type'])
: $categoryType;
$permissionData = [
'identifier' => $permData['identifier'],
'name' => $permData['name'],
'description' => $permData['description'],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => PermissionType::Admin,
'type' => $permissionType,
'order' => $permData['order'],
'parent_id' => $category->id,
];
@@ -5,7 +5,7 @@ namespace Database\Seeders\Sample;
use App\Enums\ActivityLogType;
use App\Models\ActivityLog;
use App\Models\Attachment;
use App\Models\MailTemplate;
use App\Models\NotificationTemplate;
use App\Models\Menu;
use App\Models\Role;
use App\Models\Schedule;
@@ -80,7 +80,7 @@ class ActivityLogSampleSeeder extends Seeder
$count += $this->seedAttachmentLogs($admins);
// 메일 템플릿 로그
$count += $this->seedMailTemplateLogs($admins);
$count += $this->seedNotificationTemplateLogs($admins);
// 모듈/플러그인 설정 로그
$count += $this->seedExtensionSettingsLogs($admins);
@@ -810,35 +810,35 @@ class ActivityLogSampleSeeder extends Seeder
* @param Collection $admins 관리자 컬렉션
* @return int 생성된 레코드 수
*/
private function seedMailTemplateLogs(Collection $admins): int
private function seedNotificationTemplateLogs(Collection $admins): int
{
$mailTemplates = MailTemplate::get();
if ($mailTemplates->isEmpty()) {
$this->command->info(' - 메일 템플릿 데이터 없음 - 메일 템플릿 로그 스킵');
$templates = NotificationTemplate::with('definition')->get();
if ($templates->isEmpty()) {
$this->command->info(' - 알림 템플릿 데이터 없음 - 알림 템플릿 로그 스킵');
return 0;
}
$morphType = (new MailTemplate)->getMorphClass();
$morphType = (new NotificationTemplate)->getMorphClass();
$actions = [
[
'action' => 'mail_template.update', 'key' => 'mail_template_update', 'loggable' => true,
'params' => fn ($mt) => ['template_name' => $mt->type],
'changes' => fn ($mt) => null,
'properties' => fn ($mt) => ['template_type' => $mt->type, 'locale' => ['ko', 'en'][array_rand(['ko', 'en'])]],
'action' => 'notification_template.update', 'key' => 'notification_template_update', 'loggable' => true,
'params' => fn ($t) => ['template_name' => $t->definition?->type ?? $t->channel],
'changes' => fn ($t) => null,
'properties' => fn ($t) => ['template_type' => $t->definition?->type ?? '', 'channel' => $t->channel, 'locale' => ['ko', 'en'][array_rand(['ko', 'en'])]],
],
[
'action' => 'mail_template.toggle_active', 'key' => 'mail_template_toggle_active', 'loggable' => true,
'params' => fn ($mt) => ['template_name' => $mt->type],
'changes' => fn ($mt) => null,
'properties' => fn ($mt) => ['template_type' => $mt->type, 'is_active' => (bool) mt_rand(0, 1)],
'action' => 'notification_template.toggle_active', 'key' => 'notification_template_toggle_active', 'loggable' => true,
'params' => fn ($t) => ['template_name' => $t->definition?->type ?? $t->channel],
'changes' => fn ($t) => null,
'properties' => fn ($t) => ['template_type' => $t->definition?->type ?? '', 'channel' => $t->channel, 'is_active' => (bool) mt_rand(0, 1)],
],
];
$count = $this->generateResourceLogs($mailTemplates, $admins, ActivityLogType::Admin, $morphType, $actions);
$count = $this->generateResourceLogs($templates, $admins, ActivityLogType::Admin, $morphType, $actions);
$this->command->info(" - 메일 템플릿 로그: {$count}건");
$this->command->info(" - 알림 템플릿 로그: {$count}건");
return $count;
}
@@ -1,150 +0,0 @@
<?php
namespace Database\Seeders\Sample;
use App\Enums\ExtensionOwnerType;
use App\Enums\MailSendStatus;
use App\Models\MailSendLog;
use App\Traits\HasSeederCounts;
use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon;
/**
* 코어 메일 발송 이력 개발용 시더
*
* 코어 메일 템플릿(welcome, reset_password, password_changed)을 기반으로
* 발송 이력 샘플 데이터를 생성합니다.
*
* 개발/테스트 용도이므로 설치 시 자동 실행되지 않습니다.
* 수동 실행: php artisan db:seed --class=MailSendLogSeeder
*/
class MailSendLogSeeder extends Seeder
{
use HasSeederCounts;
/**
* 코어 메일 템플릿 유형별 제목 패턴
*
* @var array<string, array{ko: string, en: string}>
*/
private array $templateSubjects = [
'welcome' => [
'ko' => '[그누보드7] 회원가입을 환영합니다',
'en' => '[Gnuboard7] Welcome to Our Service',
],
'reset_password' => [
'ko' => '[그누보드7] 비밀번호 재설정 안내',
'en' => '[Gnuboard7] Password Reset Request',
],
'password_changed' => [
'ko' => '[그누보드7] 비밀번호가 변경되었습니다',
'en' => '[Gnuboard7] Your Password Has Been Changed',
],
];
/**
* 시더 실행
*
* @return void
*/
public function run(): void
{
$count = $this->getSeederCount('mail_send_logs', 30);
$this->command->info('코어 메일 발송 이력 시딩 시작...');
$templateTypes = array_keys($this->templateSubjects);
$recipients = $this->getRecipients();
for ($i = 0; $i < $count; $i++) {
$templateType = $templateTypes[array_rand($templateTypes)];
$recipient = $recipients[array_rand($recipients)];
$status = $this->randomStatus();
$sentAt = $this->randomSentAt();
MailSendLog::create([
'recipient_email' => $recipient['email'],
'recipient_name' => $recipient['name'],
'subject' => $this->templateSubjects[$templateType]['ko'],
'template_type' => $templateType,
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'source' => 'notification',
'status' => $status->value,
'error_message' => $status === MailSendStatus::Failed
? $this->randomErrorMessage()
: null,
'sent_at' => $sentAt,
]);
}
$this->command->info("코어 메일 발송 이력 시딩 완료 ({$count}건)");
}
/**
* 샘플 수신자 목록을 반환합니다.
*
* @return array<int, array{email: string, name: string}>
*/
private function getRecipients(): array
{
return [
['email' => 'hong@example.com', 'name' => '홍길동'],
['email' => 'kim@example.com', 'name' => '김철수'],
['email' => 'lee@example.com', 'name' => '이영희'],
['email' => 'park@example.com', 'name' => '박민수'],
['email' => 'choi@example.com', 'name' => '최지은'],
['email' => 'jung@example.com', 'name' => '정수민'],
['email' => 'kang@example.com', 'name' => '강하늘'],
['email' => 'yoon@example.com', 'name' => '윤서연'],
['email' => 'john@example.com', 'name' => 'John Doe'],
['email' => 'jane@example.com', 'name' => 'Jane Smith'],
];
}
/**
* 가중치 기반 랜덤 상태를 반환합니다.
*
* @return MailSendStatus
*/
private function randomStatus(): MailSendStatus
{
$rand = mt_rand(1, 100);
if ($rand <= 80) {
return MailSendStatus::Sent;
} elseif ($rand <= 95) {
return MailSendStatus::Failed;
}
return MailSendStatus::Skipped;
}
/**
* 최근 90일 내 랜덤 발송 시각을 반환합니다.
*
* @return Carbon
*/
private function randomSentAt(): Carbon
{
return Carbon::now()->subDays(mt_rand(0, 90))->subHours(mt_rand(0, 23))->subMinutes(mt_rand(0, 59));
}
/**
* 랜덤 에러 메시지를 반환합니다.
*
* @return string
*/
private function randomErrorMessage(): string
{
$messages = [
'Connection timed out after 30 seconds',
'SMTP server returned 550: Mailbox not found',
'DNS resolution failed for mail server',
'Authentication failed: Invalid credentials',
'Message rejected: Recipient address rejected',
];
return $messages[array_rand($messages)];
}
}
@@ -0,0 +1,103 @@
<?php
namespace Database\Seeders\Sample;
use App\Enums\NotificationLogStatus;
use App\Models\NotificationLog;
use App\Traits\HasSeederCounts;
use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon;
/**
* 알림 발송 이력 개발용 시더
*
* 다양한 채널/타입의 알림 발송 이력 샘플 데이터를 생성합니다.
* 개발/테스트 용도이므로 설치 시 자동 실행되지 않습니다.
*/
class NotificationLogSeeder extends Seeder
{
use HasSeederCounts;
/**
* 시더 실행.
*
* @return void
*/
public function run(): void
{
$count = $this->getSeederCount('notification_logs', 50);
$this->command->info('알림 발송 이력 시딩 시작...');
$channels = ['mail', 'database'];
$types = [
['type' => 'welcome', 'ext_type' => 'core', 'ext_id' => 'core'],
['type' => 'reset_password', 'ext_type' => 'core', 'ext_id' => 'core'],
['type' => 'password_changed', 'ext_type' => 'core', 'ext_id' => 'core'],
['type' => 'order_confirmed', 'ext_type' => 'module', 'ext_id' => 'sirsoft-ecommerce'],
['type' => 'order_shipped', 'ext_type' => 'module', 'ext_id' => 'sirsoft-ecommerce'],
['type' => 'new_comment', 'ext_type' => 'module', 'ext_id' => 'sirsoft-board'],
['type' => 'new_post_admin', 'ext_type' => 'module', 'ext_id' => 'sirsoft-board'],
];
$recipients = $this->getRecipients();
for ($i = 0; $i < $count; $i++) {
$channel = $channels[array_rand($channels)];
$typeInfo = $types[array_rand($types)];
$recipient = $recipients[array_rand($recipients)];
$status = $this->randomStatus();
$sentAt = Carbon::now()->subDays(mt_rand(0, 30))->subHours(mt_rand(0, 23))->subMinutes(mt_rand(0, 59));
NotificationLog::create([
'channel' => $channel,
'notification_type' => $typeInfo['type'],
'extension_type' => $typeInfo['ext_type'],
'extension_identifier' => $typeInfo['ext_id'],
'recipient_identifier' => $channel === 'mail' ? $recipient['email'] : (string) ($i + 1),
'recipient_name' => $recipient['name'],
'subject' => "[G7] {$typeInfo['type']} 알림",
'status' => $status->value,
'error_message' => $status === NotificationLogStatus::Failed ? 'SMTP connection refused' : null,
'source' => 'notification',
'sent_at' => $sentAt,
]);
}
$this->command->info("알림 발송 이력 시딩 완료 ({$count}건)");
}
/**
* 샘플 수신자 목록.
*
* @return array
*/
private function getRecipients(): array
{
return [
['email' => 'hong@example.com', 'name' => '홍길동'],
['email' => 'kim@example.com', 'name' => '김철수'],
['email' => 'lee@example.com', 'name' => '이영희'],
['email' => 'park@example.com', 'name' => '박민수'],
['email' => 'john@example.com', 'name' => 'John Doe'],
['email' => 'jane@example.com', 'name' => 'Jane Smith'],
];
}
/**
* 가중치 기반 랜덤 상태.
*
* @return NotificationLogStatus
*/
private function randomStatus(): NotificationLogStatus
{
$rand = mt_rand(1, 100);
if ($rand <= 80) {
return NotificationLogStatus::Sent;
} elseif ($rand <= 95) {
return NotificationLogStatus::Failed;
}
return NotificationLogStatus::Skipped;
}
}