v7.0.0-beta.1 release

This commit is contained in:
HeuJung committed 2026-04-01 10:30:52 +09:00
commit 6595fd0eb5
3967 files changed
+1875383

No files matched your search

+1
View File
@@ -0,0 +1 @@
*.sqlite*
+173
View File
@@ -0,0 +1,173 @@
<?php
namespace Database\Factories;
use App\Enums\AttachmentSourceType;
use App\Models\Attachment;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* 첨부파일 팩토리
*
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Attachment>
*/
class AttachmentFactory extends Factory
{
/**
* 모델 클래스
*
* @var string
*/
protected $model = Attachment::class;
/**
* 기본 상태 정의
*
* @return array<string, mixed>
*/
public function definition(): array
{
$filename = $this->faker->word() . '.jpg';
$storedFilename = Str::uuid() . '.jpg';
return [
'attachmentable_type' => null,
'attachmentable_id' => null,
'source_type' => AttachmentSourceType::Core,
'source_identifier' => null,
'hash' => Str::random(12),
'original_filename' => $filename,
'stored_filename' => $storedFilename,
'disk' => 'local',
'path' => 'attachments/' . date('Y/m/d') . '/' . $storedFilename,
'mime_type' => 'image/jpeg',
'size' => $this->faker->numberBetween(1024, 1024 * 1024),
'collection' => 'default',
'order' => 0,
'meta' => null,
'created_by' => null,
];
}
/**
* 이미지 첨부파일
*/
public function image(): static
{
return $this->state(fn (array $attributes) => [
'mime_type' => $this->faker->randomElement(['image/jpeg', 'image/png', 'image/gif', 'image/webp']),
'original_filename' => $this->faker->word() . '.' . $this->faker->randomElement(['jpg', 'png', 'gif', 'webp']),
'meta' => [
'width' => $this->faker->numberBetween(100, 1920),
'height' => $this->faker->numberBetween(100, 1080),
],
]);
}
/**
* PDF 첨부파일
*/
public function pdf(): static
{
$filename = $this->faker->word() . '.pdf';
$storedFilename = Str::uuid() . '.pdf';
return $this->state(fn (array $attributes) => [
'mime_type' => 'application/pdf',
'original_filename' => $filename,
'stored_filename' => $storedFilename,
'path' => 'attachments/' . date('Y/m/d') . '/' . $storedFilename,
]);
}
/**
* 문서 첨부파일
*/
public function document(): static
{
$extension = $this->faker->randomElement(['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']);
$filename = $this->faker->word() . '.' . $extension;
$storedFilename = Str::uuid() . '.' . $extension;
$mimeTypes = [
'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls' => 'application/vnd.ms-excel',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'ppt' => 'application/vnd.ms-powerpoint',
'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
];
return $this->state(fn (array $attributes) => [
'mime_type' => $mimeTypes[$extension],
'original_filename' => $filename,
'stored_filename' => $storedFilename,
'path' => 'attachments/' . date('Y/m/d') . '/' . $storedFilename,
]);
}
/**
* 특정 사용자가 업로드한 상태
*/
public function uploadedBy(User $user): static
{
return $this->state(fn (array $attributes) => [
'created_by' => $user->id,
]);
}
/**
* 특정 컬렉션 지정
*/
public function inCollection(string $collection): static
{
return $this->state(fn (array $attributes) => [
'collection' => $collection,
]);
}
/**
* 모듈 소스 타입
*/
public function fromModule(string $identifier): static
{
return $this->state(fn (array $attributes) => [
'source_type' => AttachmentSourceType::Module,
'source_identifier' => $identifier,
]);
}
/**
* 플러그인 소스 타입
*/
public function fromPlugin(string $identifier): static
{
return $this->state(fn (array $attributes) => [
'source_type' => AttachmentSourceType::Plugin,
'source_identifier' => $identifier,
]);
}
/**
* 특정 모델에 첨부
*/
public function attachedTo(string $type, int $id): static
{
return $this->state(fn (array $attributes) => [
'attachmentable_type' => $type,
'attachmentable_id' => $id,
]);
}
/**
* 정렬 순서 지정
*/
public function withOrder(int $order): static
{
return $this->state(fn (array $attributes) => [
'order' => $order,
]);
}
}
@@ -0,0 +1,163 @@
<?php
namespace Database\Factories;
use App\Enums\LayoutExtensionType;
use App\Enums\LayoutSourceType;
use App\Models\LayoutExtension;
use App\Models\Template;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<LayoutExtension>
*/
class LayoutExtensionFactory extends Factory
{
/**
* The name of the factory's corresponding model.
*
* @var class-string<LayoutExtension>
*/
protected $model = LayoutExtension::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$vendor = fake()->randomElement(['sirsoft', 'acme', 'demo']);
return [
'template_id' => Template::factory(),
'extension_type' => fake()->randomElement(LayoutExtensionType::cases()),
'target_name' => fake()->randomElement(['sidebar-top', 'sidebar-bottom', 'header-actions', 'admin/dashboard', 'admin/settings']),
'source_type' => fake()->randomElement(LayoutSourceType::cases()),
'source_identifier' => $vendor.'-'.fake()->slug(1),
'content' => $this->generateExtensionData(),
'priority' => fake()->numberBetween(1, 100),
'is_active' => true,
'override_target' => null,
];
}
/**
* 확장 데이터 생성
*
* @return array
*/
private function generateExtensionData(): array
{
return [
'component' => [
'type' => 'basic',
'name' => fake()->randomElement(['Button', 'Div', 'Card', 'Alert']),
'props' => [
'className' => 'test-class',
],
],
];
}
/**
* Extension Point 타입 상태
*/
public function extensionPoint(): static
{
return $this->state(fn (array $attributes) => [
'extension_type' => LayoutExtensionType::ExtensionPoint,
]);
}
/**
* Overlay 타입 상태
*/
public function overlay(): static
{
return $this->state(fn (array $attributes) => [
'extension_type' => LayoutExtensionType::Overlay,
'content' => [
'target_component_id' => 'component-'.fake()->uuid(),
'position' => fake()->randomElement(['append_child', 'prepend', 'replace']),
'component' => [
'type' => 'basic',
'name' => 'Div',
],
],
]);
}
/**
* 모듈 출처 상태
*/
public function fromModule(string $identifier = null): static
{
return $this->state(fn (array $attributes) => [
'source_type' => LayoutSourceType::Module,
'source_identifier' => $identifier ?? 'sirsoft-ecommerce',
]);
}
/**
* 플러그인 출처 상태
*/
public function fromPlugin(string $identifier = null): static
{
return $this->state(fn (array $attributes) => [
'source_type' => LayoutSourceType::Plugin,
'source_identifier' => $identifier ?? 'sirsoft-analytics',
]);
}
/**
* 템플릿 출처 상태 (오버라이드용)
*/
public function fromTemplate(string $identifier = null): static
{
return $this->state(fn (array $attributes) => [
'source_type' => LayoutSourceType::Template,
'source_identifier' => $identifier ?? 'sirsoft-admin_basic',
]);
}
/**
* 오버라이드 타겟 설정
*/
public function overriding(string $targetIdentifier): static
{
return $this->state(fn (array $attributes) => [
'override_target' => $targetIdentifier,
]);
}
/**
* 비활성 상태
*/
public function inactive(): static
{
return $this->state(fn (array $attributes) => [
'is_active' => false,
]);
}
/**
* 우선순위 설정
*/
public function withPriority(int $priority): static
{
return $this->state(fn (array $attributes) => [
'priority' => $priority,
]);
}
/**
* 특정 타겟 설정
*/
public function targeting(string $targetName): static
{
return $this->state(fn (array $attributes) => [
'target_name' => $targetName,
]);
}
}
+83
View File
@@ -0,0 +1,83 @@
<?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,
]);
}
}
@@ -0,0 +1,90 @@
<?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' => '사이트명'],
],
]);
}
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Menu>
*/
class MenuFactory extends Factory
{
/**
* 다국어 필드 생성
*/
protected function generateTranslatableField(callable $generator): array
{
$locales = config('app.translatable_locales', ['ko', 'en']);
$data = [];
foreach ($locales as $locale) {
$data[$locale] = $generator();
}
return $data;
}
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => $this->generateTranslatableField(fn () => $this->faker->words(2, true)),
'slug' => $this->faker->unique()->slug(),
'url' => '/admin/'.$this->faker->slug(),
'icon' => 'fas fa-'.$this->faker->randomElement(['home', 'user', 'cog', 'chart', 'file']),
'parent_id' => null,
'order' => $this->faker->numberBetween(1, 100),
'is_active' => true,
'module_id' => null,
'created_by' => User::factory(),
];
}
/**
* 비활성화된 메뉴 상태
*/
public function inactive(): static
{
return $this->state(fn (array $attributes) => [
'is_active' => false,
]);
}
/**
* 모듈 메뉴 상태
*/
public function moduleMenu(int $moduleId = 1): static
{
return $this->state(fn (array $attributes) => [
'module_id' => $moduleId,
]);
}
/**
* 자식 메뉴 상태
*/
public function childMenu(int $parentId): static
{
return $this->state(fn (array $attributes) => [
'parent_id' => $parentId,
]);
}
/**
* 코어 메뉴 상태 (기본값과 동일하지만 명시적)
*/
public function coreMenu(): static
{
return $this->state(fn (array $attributes) => [
'module_id' => null,
]);
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Module>
*/
class ModuleFactory extends Factory
{
/**
* 다국어 필드 생성
*/
protected function generateTranslatableField(callable $generator): array
{
$locales = config('app.translatable_locales', ['ko', 'en']);
$data = [];
foreach ($locales as $locale) {
$data[$locale] = $generator();
}
return $data;
}
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'identifier' => $this->faker->unique()->slug(2),
'vendor' => $this->faker->word(),
'name' => $this->generateTranslatableField(fn () => $this->faker->words(2, true)),
'version' => $this->faker->numerify('#.#.#'),
'status' => 'active',
'description' => $this->generateTranslatableField(fn () => $this->faker->sentence()),
'config' => null,
'metadata' => null,
'created_by' => null,
'updated_by' => null,
];
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
namespace Database\Factories;
use App\Enums\ExtensionOwnerType;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Permission>
*/
class PermissionFactory extends Factory
{
/**
* 다국어 필드 생성
*/
protected function generateTranslatableField(callable $generator): array
{
$locales = config('app.translatable_locales', ['ko', 'en']);
$data = [];
foreach ($locales as $locale) {
$data[$locale] = $generator();
}
return $data;
}
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'identifier' => $this->faker->unique()->slug(2),
'name' => $this->generateTranslatableField(fn () => $this->faker->words(2, true)),
'description' => $this->generateTranslatableField(fn () => $this->faker->sentence()),
'extension_type' => null,
'extension_identifier' => null,
];
}
/**
* 시스템(코어) 권한 상태
*/
public function system(): static
{
return $this->core();
}
/**
* 코어 권한 상태
*/
public function core(): static
{
return $this->state(fn (array $attributes) => [
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
]);
}
/**
* 사용자 관리 권한 상태
*/
public function userManagement(): static
{
return $this->state(fn (array $attributes) => [
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
]);
}
/**
* 모듈 권한 상태
*
* @param string $identifier 모듈 식별자
*/
public function forModule(string $identifier): static
{
return $this->state(fn (array $attributes) => [
'extension_type' => ExtensionOwnerType::Module,
'extension_identifier' => $identifier,
]);
}
/**
* 플러그인 권한 상태
*
* @param string $identifier 플러그인 식별자
*/
public function forPlugin(string $identifier): static
{
return $this->state(fn (array $attributes) => [
'extension_type' => ExtensionOwnerType::Plugin,
'extension_identifier' => $identifier,
]);
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Plugin>
*/
class PluginFactory extends Factory
{
/**
* 다국어 필드 생성
*/
protected function generateTranslatableField(callable $generator): array
{
$locales = config('app.translatable_locales', ['ko', 'en']);
$data = [];
foreach ($locales as $locale) {
$data[$locale] = $generator();
}
return $data;
}
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'identifier' => $this->faker->unique()->slug(2),
'vendor' => $this->faker->word(),
'name' => $this->generateTranslatableField(fn () => $this->faker->words(2, true)),
'version' => $this->faker->numerify('#.#.#'),
'latest_version' => null,
'description' => $this->generateTranslatableField(fn () => $this->faker->sentence()),
'status' => 'active',
'update_available' => false,
'hooks' => null,
'github_url' => null,
'github_changelog_url' => null,
'metadata' => null,
'created_by' => null,
'updated_by' => null,
];
}
}
+125
View File
@@ -0,0 +1,125 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Role>
*/
class RoleFactory extends Factory
{
/**
* 다국어 필드 생성
*/
protected function generateTranslatableField(callable $generator): array
{
$locales = config('app.translatable_locales', ['ko', 'en']);
$data = [];
foreach ($locales as $locale) {
$data[$locale] = $generator();
}
return $data;
}
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'identifier' => $this->faker->unique()->slug(1),
'name' => $this->generateTranslatableField(fn () => $this->faker->words(2, true)),
'description' => $this->generateTranslatableField(fn () => $this->faker->sentence()),
'is_active' => true,
];
}
/**
* 비활성화된 역할 상태
*/
public function inactive(): static
{
return $this->state(fn (array $attributes) => [
'is_active' => false,
]);
}
/**
* 관리자 역할
*/
public function admin(): static
{
// 기본 번역 정의
$defaultTranslations = [
'name' => [
'ko' => '관리자',
'en' => 'Administrator',
],
'description' => [
'ko' => '시스템의 모든 권한을 가진 관리자입니다.',
'en' => 'Full system administrator with all permissions.',
],
];
// config에서 로케일 가져오기
$locales = config('app.translatable_locales', ['ko', 'en']);
$nameTranslations = [];
$descriptionTranslations = [];
foreach ($locales as $locale) {
// 정의된 번역이 있으면 사용, 없으면 영어 fallback
$nameTranslations[$locale] = $defaultTranslations['name'][$locale]
?? $defaultTranslations['name']['en'];
$descriptionTranslations[$locale] = $defaultTranslations['description'][$locale]
?? $defaultTranslations['description']['en'];
}
return $this->state(fn (array $attributes) => [
'identifier' => 'admin',
'name' => $nameTranslations,
'description' => $descriptionTranslations,
]);
}
/**
* 편집자 역할
*/
public function editor(): static
{
// 기본 번역 정의
$defaultTranslations = [
'name' => [
'ko' => '편집자',
'en' => 'Editor',
],
'description' => [
'ko' => '콘텐츠를 편집할 수 있는 편집자입니다.',
'en' => 'Content editor with editing permissions.',
],
];
// config에서 로케일 가져오기
$locales = config('app.translatable_locales', ['ko', 'en']);
$nameTranslations = [];
$descriptionTranslations = [];
foreach ($locales as $locale) {
// 정의된 번역이 있으면 사용, 없으면 영어 fallback
$nameTranslations[$locale] = $defaultTranslations['name'][$locale]
?? $defaultTranslations['name']['en'];
$descriptionTranslations[$locale] = $defaultTranslations['description'][$locale]
?? $defaultTranslations['description']['en'];
}
return $this->state(fn (array $attributes) => [
'identifier' => 'editor',
'name' => $nameTranslations,
'description' => $descriptionTranslations,
]);
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Template>
*/
class TemplateFactory extends Factory
{
/**
* 다국어 필드 생성
*/
protected function generateTranslatableField(callable $generator): array
{
$locales = config('app.translatable_locales', ['ko', 'en']);
$data = [];
foreach ($locales as $locale) {
$data[$locale] = $generator();
}
return $data;
}
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
$vendor = fake()->randomElement(['sirsoft', 'johndoe', 'acmecorp']);
$type = fake()->randomElement(['admin', 'user']);
return [
'identifier' => $vendor.'-'.fake()->slug(2),
'vendor' => $vendor,
'name' => $this->generateTranslatableField(fn () => fake()->words(2, true)),
'version' => fake()->semver(),
'latest_version' => fake()->semver(),
'update_available' => fake()->boolean(30),
'type' => $type,
'status' => fake()->randomElement(['active', 'inactive']),
'description' => $this->generateTranslatableField(fn () => fake()->sentence()),
'user_modified_at' => fake()->boolean(50) ? fake()->dateTime() : null,
'github_url' => fake()->url(),
'github_changelog_url' => fake()->url(),
'metadata' => [
'author' => fake()->name(),
'license' => 'MIT',
],
'created_by' => null,
'updated_by' => null,
];
}
/**
* 한국어만 있는 템플릿 상태
*/
public function koreanOnly(): static
{
$locales = config('app.translatable_locales', ['ko', 'en']);
$nameTranslations = [];
$descriptionTranslations = [];
foreach ($locales as $locale) {
$nameTranslations[$locale] = $locale === 'ko' ? fake()->words(2, true) : '';
$descriptionTranslations[$locale] = $locale === 'ko' ? fake()->sentence() : '';
}
return $this->state(fn (array $attributes) => [
'name' => $nameTranslations,
'description' => $descriptionTranslations,
]);
}
/**
* 영어만 있는 템플릿 상태
*/
public function englishOnly(): static
{
$locales = config('app.translatable_locales', ['ko', 'en']);
$nameTranslations = [];
$descriptionTranslations = [];
foreach ($locales as $locale) {
$nameTranslations[$locale] = $locale === 'en' ? fake()->words(2, true) : '';
$descriptionTranslations[$locale] = $locale === 'en' ? fake()->sentence() : '';
}
return $this->state(fn (array $attributes) => [
'name' => $nameTranslations,
'description' => $descriptionTranslations,
]);
}
}
@@ -0,0 +1,73 @@
<?php
namespace Database\Factories;
use App\Enums\LayoutSourceType;
use App\Models\Template;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\TemplateLayout>
*/
class TemplateLayoutFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
// 유니크 제약조건 (template_id, name, source_type)을 위해 고유한 name 생성
$layoutName = fake()->unique()->slug(3);
return [
'template_id' => Template::factory(),
'name' => $layoutName,
'content' => [
'version' => '1.0.0',
'layout_name' => $layoutName,
'endpoint' => '/api/'.fake()->word(),
'components' => [],
'data_sources' => [],
'metadata' => [],
],
'extends' => null,
'source_type' => LayoutSourceType::Template,
'source_identifier' => null,
];
}
/**
* 모듈 소스 타입으로 설정
*/
public function fromModule(string $moduleIdentifier): static
{
return $this->state(fn (array $attributes) => [
'source_type' => LayoutSourceType::Module,
'source_identifier' => $moduleIdentifier,
]);
}
/**
* 플러그인 소스 타입으로 설정
*/
public function fromPlugin(string $pluginIdentifier): static
{
return $this->state(fn (array $attributes) => [
'source_type' => LayoutSourceType::Plugin,
'source_identifier' => $pluginIdentifier,
]);
}
/**
* 템플릿 오버라이드로 설정
*/
public function asOverride(string $templateIdentifier): static
{
return $this->state(fn (array $attributes) => [
'source_type' => LayoutSourceType::Template,
'source_identifier' => $templateIdentifier,
]);
}
}
@@ -0,0 +1,36 @@
<?php
namespace Database\Factories;
use App\Models\TemplateLayout;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\TemplateLayoutVersion>
*/
class TemplateLayoutVersionFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'layout_id' => TemplateLayout::factory(),
'version' => fake()->unique()->numberBetween(1, 1000),
'content' => [
'version' => '1.0.0',
'layout_name' => fake()->word(),
'components' => [],
],
'changes_summary' => json_encode([
'added' => fake()->numberBetween(1, 20),
'removed' => fake()->numberBetween(0, 10),
'is_restored' => false,
'restored_from' => null,
]),
];
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'uuid' => Str::orderedUuid()->toString(),
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
'is_super' => false,
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}
@@ -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
{
/**
* SEO 캐시 통계 테이블을 생성합니다.
*
* @return void
*/
public function up(): void
{
Schema::create('seo_cache_stats', function (Blueprint $table) {
$table->id()->comment('ID');
$table->string('url')->comment('요청 URL');
$table->string('locale', 5)->comment('로케일');
$table->string('layout_name')->nullable()->comment('사용된 레이아웃명');
$table->string('module_identifier')->nullable()->comment('레이아웃 소유 모듈 식별자');
$table->string('type')->comment('유형 (hit 또는 miss)');
$table->integer('response_time_ms')->nullable()->comment('렌더링 소요 시간 (ms)');
$table->timestamp('created_at')->useCurrent()->comment('생성 시각');
$table->index('url', 'idx_seo_cache_stats_url');
$table->index('type', 'idx_seo_cache_stats_type');
$table->index('created_at', 'idx_seo_cache_stats_created_at');
});
}
/**
* SEO 캐시 통계 테이블을 삭제합니다.
*
* @return void
*/
public function down(): void
{
Schema::dropIfExists('seo_cache_stats');
}
};
@@ -0,0 +1,58 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name')->comment('사용자 이름');
$table->string('nickname', 50)->nullable()->comment('닉네임');
$table->string('email')->unique()->comment('이메일 주소');
$table->timestamp('email_verified_at')->nullable()->comment('이메일 인증 일시');
$table->string('password')->comment('비밀번호 해시');
$table->string('language', 5)->default('ko')->comment('사용자 언어 설정 (ko: 한국어, en: 영어)');
$table->boolean('is_super')->default(false)->index()->comment('슈퍼 관리자 여부 (삭제 불가, 권한 관리 가능)');
$table->string('timezone', 50)->nullable()->default('Asia/Seoul')->comment('사용자 시간대 (예: Asia/Seoul, UTC)');
$table->string('country', 2)->nullable()->index()->comment('국가 코드 (ISO 3166-1 alpha-2)');
$table->string('status', 20)->default('active')->index()->comment('계정 상태 (active: 활성, inactive: 비활성, blocked: 차단, withdrawn: 탈퇴)');
$table->string('homepage')->nullable()->comment('홈페이지 URL');
$table->string('mobile', 20)->nullable()->index()->comment('휴대폰 번호');
$table->string('phone', 20)->nullable()->index()->comment('전화번호');
$table->string('zipcode', 10)->nullable()->comment('우편번호');
$table->string('address')->nullable()->comment('기본 주소');
$table->string('address_detail')->nullable()->comment('상세 주소');
$table->text('signature')->nullable()->comment('서명');
$table->text('bio')->nullable()->comment('자기소개');
$table->text('admin_memo')->nullable()->comment('관리자 메모');
$table->string('ip_address', 45)->nullable()->index()->comment('마지막 접속 IP 주소');
$table->rememberToken();
$table->timestamps();
$table->timestamp('last_login_at')->nullable()->comment('마지막 로그인 일시');
$table->timestamp('withdrawn_at')->nullable()->comment('탈퇴 일시');
$table->timestamp('blocked_at')->nullable()->comment('차단 일시');
});
if (DB::getDriverName() == 'mysql') {
Schema::table('users', function (Blueprint $table) {
$table->comment('시스템 사용자 정보');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
}
};
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary()->comment('비밀번호 재설정 요청 이메일');
$table->string('token')->comment('비밀번호 재설정 토큰');
$table->timestamp('created_at')->nullable()->comment('생성일시');
});
if (DB::getDriverName() == 'mysql') {
Schema::table('password_reset_tokens', function (Blueprint $table) {
$table->comment('비밀번호 재설정 토큰 정보');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('password_reset_tokens');
}
};
@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary()->comment('세션 ID');
$table->foreignId('user_id')->nullable()->index()->comment('사용자 ID');
$table->string('ip_address', 45)->nullable()->comment('IP 주소');
$table->text('user_agent')->nullable()->comment('사용자 에이전트');
$table->longText('payload')->comment('세션 데이터');
$table->integer('last_activity')->index()->comment('마지막 활동 시간');
});
if (DB::getDriverName() == 'mysql') {
Schema::table('sessions', function (Blueprint $table) {
$table->comment('사용자 세션 정보');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('sessions');
}
};
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary()->comment('캐시 키');
$table->mediumText('value')->comment('캐시 값');
$table->integer('expiration')->comment('만료 시간');
});
if (DB::getDriverName() == 'mysql') {
Schema::table('cache', function (Blueprint $table) {
$table->comment('시스템 캐시 데이터');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
}
};
@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary()->comment('락 키');
$table->string('owner')->comment('락 소유자');
$table->integer('expiration')->comment('만료 시간');
});
if (DB::getDriverName() == 'mysql') {
Schema::table('cache_locks', function (Blueprint $table) {
$table->comment('캐시 락 관리');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache_locks');
}
};
@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index()->comment('큐 이름');
$table->longText('payload')->comment('작업 페이로드');
$table->unsignedTinyInteger('attempts')->comment('시도 횟수');
$table->unsignedInteger('reserved_at')->nullable()->comment('예약 시간');
$table->unsignedInteger('available_at')->comment('실행 가능 시간');
$table->unsignedInteger('created_at')->comment('생성 시간');
});
if (DB::getDriverName() == 'mysql') {
Schema::table('jobs', function (Blueprint $table) {
$table->comment('작업 큐 관리');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
}
};
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary()->comment('배치 ID');
$table->string('name')->comment('배치 이름');
$table->integer('total_jobs')->comment('전체 작업 수');
$table->integer('pending_jobs')->comment('대기 중인 작업 수');
$table->integer('failed_jobs')->comment('실패한 작업 수');
$table->longText('failed_job_ids')->comment('실패한 작업 ID 목록');
$table->mediumText('options')->nullable()->comment('배치 옵션');
$table->integer('cancelled_at')->nullable()->comment('취소 시간');
$table->integer('created_at')->comment('생성 시간');
$table->integer('finished_at')->nullable()->comment('완료 시간');
});
if (DB::getDriverName() == 'mysql') {
Schema::table('job_batches', function (Blueprint $table) {
$table->comment('작업 배치 관리');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('job_batches');
}
};
@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique()->comment('고유 식별자');
$table->text('connection')->comment('연결 정보');
$table->text('queue')->comment('큐 이름');
$table->longText('payload')->comment('작업 페이로드');
$table->longText('exception')->comment('예외 정보');
$table->timestamp('failed_at')->useCurrent()->comment('실패 시간');
});
if (DB::getDriverName() == 'mysql') {
Schema::table('failed_jobs', function (Blueprint $table) {
$table->comment('실패한 작업 로그');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('failed_jobs');
}
};
@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('tokenable');
$table->text('name')->comment('토큰 이름');
$table->string('token', 64)->unique()->comment('액세스 토큰');
$table->text('abilities')->nullable()->comment('토큰 권한');
$table->timestamp('last_used_at')->nullable()->comment('마지막 사용 시간');
$table->timestamp('expires_at')->nullable()->index()->comment('만료 시간');
$table->timestamps();
});
if (DB::getDriverName() == 'mysql') {
Schema::table('personal_access_tokens', function (Blueprint $table) {
$table->comment('개인 액세스 토큰 관리');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('personal_access_tokens');
}
};
@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('permissions', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('parent_id')->nullable()->comment('상위 권한 ID (계층 구조)');
$table->string('identifier')->unique()->comment('권한명 (예: users.create, menus.read)');
$table->text('name')->comment('권한 이름 (다국어 JSON)');
$table->mediumText('description')->nullable()->comment('권한 설명 (다국어 JSON)');
$table->string('extension_type', 20)->nullable()->comment('확장 소유 타입: core(코어), module(모듈), plugin(플러그인), NULL(사용자 정의)');
$table->string('extension_identifier', 255)->nullable()->comment('확장 식별자 (예: core, sirsoft-board, sirsoft-payment)');
$table->string('type', 20)->comment('권한 타입');
$table->string('resource_route_key', 50)->nullable()->comment('리소스 라우트 파라미터명 (예: user, menu, product)');
$table->string('owner_key', 50)->nullable()->comment('소유자 식별 컬럼명 (예: id, created_by, user_id)');
$table->integer('order')->default(0)->comment('정렬 순서');
$table->timestamps();
$table->index('parent_id');
$table->index('type');
$table->index(['extension_type', 'extension_identifier'], 'idx_permissions_extension');
$table->foreign('parent_id')->references('id')->on('permissions')->cascadeOnDelete();
});
if (DB::getDriverName() == 'mysql') {
Schema::table('permissions', function (Blueprint $table) {
$table->comment('시스템 권한 정보');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('permissions');
}
};
@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('permission_hooks', function (Blueprint $table) {
$table->id();
$table->foreignId('permission_id')->constrained('permissions')->cascadeOnDelete();
$table->string('hook_name')->index()->comment('훅 이름 (예: core.attachment.download)');
$table->timestamps();
$table->unique(['permission_id', 'hook_name']);
});
if (DB::getDriverName() == 'mysql') {
Schema::table('permission_hooks', function (Blueprint $table) {
$table->comment('권한-훅 매핑 정보');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('permission_hooks');
}
};
@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('roles', function (Blueprint $table) {
$table->id();
$table->string('identifier')->unique()->comment('역할명 (예: admin, user, manager)');
$table->text('name')->comment('역할 이름 (다국어 JSON)');
$table->mediumText('description')->nullable()->comment('역할 설명 (다국어 JSON)');
$table->string('extension_type', 20)->nullable()->comment('확장 소유 타입: core(코어), module(모듈), plugin(플러그인), NULL(사용자 정의)');
$table->string('extension_identifier', 255)->nullable()->comment('확장 식별자 (예: core, sirsoft-board, sirsoft-payment)');
$table->text('user_overrides')->nullable()->comment('유저가 수정한 필드명 목록 (예: ["name", "permissions"])');
$table->boolean('is_active')->default(true)->comment('활성화 상태 (1: 활성, 0: 비활성)');
$table->timestamps();
$table->index(['extension_type', 'extension_identifier'], 'idx_roles_extension');
});
if (DB::getDriverName() == 'mysql') {
Schema::table('roles', function (Blueprint $table) {
$table->comment('사용자 역할 정보');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('roles');
}
};
@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('role_permissions', function (Blueprint $table) {
$table->id();
$table->foreignId('role_id')->constrained('roles')->cascadeOnDelete();
$table->foreignId('permission_id')->constrained('permissions')->cascadeOnDelete();
$table->enum('scope_type', ['self', 'role'])->nullable()->comment('접근 스코프 (null: 전체, self: 본인, role: 소유역할)');
$table->timestamp('granted_at')->useCurrent()->comment('권한 부여 일시');
$table->foreignId('granted_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->unique(['role_id', 'permission_id']);
});
if (DB::getDriverName() == 'mysql') {
Schema::table('role_permissions', function (Blueprint $table) {
$table->comment('역할-권한 관계 정보');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('role_permissions');
}
};
@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('menus', function (Blueprint $table) {
$table->id();
$table->text('name')->comment('메뉴 이름 (다국어 JSON)');
$table->string('slug')->unique()->comment('메뉴 슬러그');
$table->string('url')->nullable()->comment('메뉴 URL');
$table->string('icon')->nullable()->comment('메뉴 아이콘');
$table->unsignedBigInteger('parent_id')->nullable()->comment('상위 메뉴 ID');
$table->integer('order')->default(0)->comment('메뉴 순서');
$table->boolean('is_active')->default(true)->comment('활성 상태 (1: 활성, 0: 비활성)');
$table->string('extension_type', 20)->nullable()->comment('확장 소유 타입: core(코어), module(모듈), plugin(플러그인), NULL(사용자 정의)');
$table->string('extension_identifier', 255)->nullable()->comment('확장 식별자 (예: core, sirsoft-board, sirsoft-payment)');
$table->text('user_overrides')->nullable()->comment('유저가 수정한 필드명 목록 (예: ["name", "icon", "order"])');
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()->comment('등록자 ID');
$table->timestamps();
$table->index(['parent_id', 'order']);
$table->index(['is_active', 'order']);
$table->index(['extension_type', 'extension_identifier'], 'idx_menus_extension');
$table->foreign('parent_id')->references('id')->on('menus')->cascadeOnDelete();
});
if (DB::getDriverName() == 'mysql') {
Schema::table('menus', function (Blueprint $table) {
$table->comment('시스템 메뉴 관리');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('menus');
}
};
@@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('menu_permissions', function (Blueprint $table) {
$table->id();
$table->foreignId('menu_id')->constrained('menus')->cascadeOnDelete();
$table->foreignId('role_id')->nullable()->constrained('roles')->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->constrained('users')->cascadeOnDelete();
$table->enum('permission_type', ['read', 'write', 'delete'])->default('read')->comment('권한 유형 (read: 읽기, write: 쓰기, delete: 삭제)');
$table->boolean('is_allowed')->default(true)->comment('허용 여부 (1: 허용, 0: 거부)');
$table->timestamp('granted_at')->useCurrent()->comment('권한 부여 일시');
$table->foreignId('granted_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->index(['menu_id', 'role_id']);
$table->index(['menu_id', 'user_id']);
});
if (DB::getDriverName() == 'mysql') {
Schema::table('menu_permissions', function (Blueprint $table) {
$table->comment('메뉴별 접근 권한 정보');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('menu_permissions');
}
};
@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('role_menus', function (Blueprint $table) {
$table->id();
$table->foreignId('role_id')->constrained('roles')->cascadeOnDelete();
$table->foreignId('menu_id')->constrained('menus')->cascadeOnDelete();
$table->enum('permission_type', ['read', 'write', 'delete'])->default('read')->comment('권한 타입 (read: 읽기/접근, write: 수정, delete: 삭제)');
$table->timestamps();
$table->unique(['role_id', 'menu_id', 'permission_type'], 'uk_role_menu_permission');
});
if (DB::getDriverName() == 'mysql') {
Schema::table('role_menus', function (Blueprint $table) {
$table->comment('역할별 메뉴 접근 권한 (피벗 테이블)');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('role_menus');
}
};
@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('user_roles', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete();
$table->foreignId('role_id')->constrained('roles')->cascadeOnDelete();
$table->timestamp('assigned_at')->useCurrent()->comment('역할 할당 일시');
$table->foreignId('assigned_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->unique(['user_id', 'role_id']);
});
if (DB::getDriverName() == 'mysql') {
Schema::table('user_roles', function (Blueprint $table) {
$table->comment('사용자-역할 관계 정보');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('user_roles');
}
};
@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('user_consents', function (Blueprint $table) {
$table->id()->comment('동의 이력 ID');
$table->foreignId('user_id')->constrained('users')->cascadeOnDelete()->comment('사용자 ID');
$table->string('consent_type', 30)->comment('동의 유형: terms, privacy');
$table->timestamp('agreed_at')->comment('동의 일시');
$table->timestamp('revoked_at')->nullable()->comment('철회 일시 (향후 플러그인 확장용)');
$table->string('ip_address', 45)->nullable()->comment('동의 시 IP 주소 (IPv6 대응)');
$table->timestamp('created_at')->nullable()->comment('생성 일시');
$table->index(['user_id', 'consent_type'], 'user_consents_user_type_index');
});
if (DB::getDriverName() == 'mysql') {
Schema::table('user_consents', function (Blueprint $table) {
$table->comment('사용자 약관 동의 이력');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('user_consents');
}
};
@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('modules', function (Blueprint $table) {
$table->id();
$table->string('identifier')->unique()->comment('모듈 고유 식별자 (vendor-module 형식)');
$table->string('vendor')->index()->comment('벤더/개발자명');
$table->text('name')->comment('모듈 이름 (다국어 JSON)');
$table->mediumText('description')->nullable()->comment('모듈 설명 (다국어 JSON)');
$table->string('github_url', 512)->nullable()->comment('GitHub 저장소 URL');
$table->string('github_changelog_url', 512)->nullable()->comment('GitHub 변경 내역 URL');
$table->mediumText('metadata')->nullable()->comment('추가 메타데이터');
$table->unsignedBigInteger('updated_by')->nullable()->comment('수정자 ID');
$table->string('version')->comment('모듈 버전');
$table->string('latest_version', 50)->nullable()->comment('마켓플레이스 최신 버전');
$table->enum('status', ['active', 'inactive', 'installing', 'uninstalling', 'updating'])->default('inactive')->index()->comment('상태 (active: 활성화, inactive: 비활성화, installing: 설치 중, uninstalling: 제거 중, updating: 업데이트 중)');
$table->boolean('update_available')->default(false)->comment('업데이트 가능 여부');
$table->string('update_source', 20)->nullable()->comment('업데이트 출처 (github, pending, bundled)');
$table->mediumText('config')->nullable()->comment('모듈 설정 정보');
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()->comment('모듈 생성자 ID');
$table->timestamps();
});
if (DB::getDriverName() == 'mysql') {
Schema::table('modules', function (Blueprint $table) {
$table->comment('시스템 모듈 관리');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('modules');
}
};
@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('plugins', function (Blueprint $table) {
$table->id();
$table->string('identifier')->unique()->comment('플러그인 고유 식별자 (vendor-plugin 형식)');
$table->string('vendor')->index()->comment('벤더/개발자명');
$table->text('name')->comment('플러그인 이름 (다국어 JSON)');
$table->mediumText('description')->nullable()->comment('플러그인 설명 (다국어 JSON)');
$table->string('github_url', 512)->nullable()->comment('GitHub 저장소 URL');
$table->mediumText('metadata')->nullable()->comment('추가 메타데이터');
$table->unsignedBigInteger('updated_by')->nullable()->comment('수정자 ID');
$table->string('version')->comment('플러그인 버전');
$table->string('latest_version', 50)->nullable()->comment('마켓플레이스 최신 버전');
$table->boolean('update_available')->default(false)->comment('업데이트 가능 여부');
$table->string('update_source', 20)->nullable()->comment('업데이트 출처 (github, pending, bundled)');
$table->string('github_changelog_url', 512)->nullable()->comment('GitHub 변경 내역 URL');
$table->enum('status', ['active', 'inactive', 'installing', 'uninstalling', 'updating'])->default('inactive')->index()->comment('상태 (active: 활성화, inactive: 비활성화, installing: 설치 중, uninstalling: 제거 중, updating: 업데이트 중)');
$table->mediumText('hooks')->nullable()->comment('훅 설정 정보');
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete()->comment('플러그인 생성자 ID');
$table->timestamps();
});
if (DB::getDriverName() == 'mysql') {
Schema::table('plugins', function (Blueprint $table) {
$table->comment('시스템 플러그인 관리');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('plugins');
}
};
@@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('templates', function (Blueprint $table) {
$table->id()->comment('템플릿 ID');
$table->string('identifier')->unique()->comment('템플릿 고유 식별자 (vendor-name 형식, 예: sirsoft-admin_basic)');
$table->string('vendor')->index()->comment('벤더/개발자명 (예: sirsoft)');
$table->text('name')->nullable()->comment('템플릿 이름 (다국어 JSON)');
$table->string('version')->comment('템플릿 버전 (예: 1.0.0)');
$table->string('latest_version', 50)->nullable()->comment('마켓플레이스 최신 버전');
$table->boolean('update_available')->default(false)->comment('업데이트 가능 여부');
$table->string('update_source', 20)->nullable()->comment('업데이트 출처 (github, pending, bundled)');
$table->enum('type', ['admin', 'user'])->comment('템플릿 타입 (admin: 관리자용, user: 사용자용)');
$table->enum('status', ['active', 'inactive', 'installing', 'uninstalling', 'updating'])->default('inactive')->comment('상태 (active: 활성화, inactive: 비활성화, installing: 설치 중, uninstalling: 제거 중, updating: 업데이트 중)');
$table->mediumText('description')->nullable()->comment('템플릿 설명 (다국어 JSON)');
$table->timestamp('user_modified_at')->nullable()->comment('사용자가 레이아웃을 마지막으로 수정한 시각');
$table->string('github_url')->nullable()->comment('GitHub 저장소 URL');
$table->string('github_changelog_url', 512)->nullable()->comment('GitHub 변경 내역 URL');
$table->mediumText('metadata')->nullable()->comment('추가 메타데이터 (JSON 형식)');
$table->unsignedBigInteger('created_by')->nullable()->comment('생성자 ID');
$table->unsignedBigInteger('updated_by')->nullable()->comment('수정자 ID');
$table->timestamps();
$table->index('type');
$table->index('status');
});
if (DB::getDriverName() == 'mysql') {
Schema::table('templates', function (Blueprint $table) {
$table->comment('시스템 템플릿 정보');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('templates');
}
};
@@ -0,0 +1,47 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('template_layouts', function (Blueprint $table) {
$table->id()->comment('레이아웃 ID');
$table->foreignId('template_id')->constrained('templates')->cascadeOnDelete()->comment('템플릿 ID');
$table->string('name')->comment('레이아웃 이름 (예: dashboard, users, user_edit)');
$table->longText('content')->comment('레이아웃 JSON 내용');
$table->string('extends')->nullable()->index()->comment('부모 레이아웃 이름 (예: layouts/_admin_base)');
$table->enum('source_type', ['template', 'module', 'plugin'])->default('template')->comment('레이아웃 소스 타입 (template: 템플릿 자체/오버라이드, module: 모듈 기본, plugin: 플러그인 기본)');
$table->string('source_identifier', 255)->nullable()->comment('모듈/플러그인 식별자 (source_type이 module/plugin일 때 사용)');
$table->unsignedBigInteger('created_by')->nullable()->comment('생성자 ID');
$table->unsignedBigInteger('updated_by')->nullable()->comment('수정자 ID');
$table->timestamps();
$table->softDeletes();
$table->unique(['template_id', 'name', 'source_type'], 'uk_template_layout_source');
$table->index(['source_type', 'source_identifier'], 'idx_source');
$table->index('deleted_at');
});
if (DB::getDriverName() == 'mysql') {
Schema::table('template_layouts', function (Blueprint $table) {
$table->comment('템플릿 레이아웃 정보');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('template_layouts');
}
};
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('template_layout_versions', function (Blueprint $table) {
$table->id()->comment('버전 ID');
$table->foreignId('layout_id')->constrained('template_layouts')->cascadeOnDelete()->comment('레이아웃 ID');
$table->unsignedInteger('version')->comment('버전 번호 (자동 증가)');
$table->longText('content')->comment('레이아웃 JSON 스냅샷');
$table->text('changes_summary')->nullable()->comment('변경 요약 JSON: {"added": 3, "removed": 2, "is_restored": false, "restored_from": null}');
$table->unsignedBigInteger('created_by')->nullable()->comment('저장자 ID');
$table->timestamp('created_at')->nullable()->comment('생성 일시');
$table->unique(['layout_id', 'version'], 'uk_layout_version');
$table->index(['layout_id', 'created_at'], 'idx_layout_versions');
});
if (DB::getDriverName() == 'mysql') {
Schema::table('template_layout_versions', function (Blueprint $table) {
$table->comment('템플릿 레이아웃 버전 이력');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('template_layout_versions');
}
};
@@ -0,0 +1,48 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('template_layout_extensions', function (Blueprint $table) {
$table->id()->comment('확장 ID');
$table->foreignId('template_id')->constrained('templates')->cascadeOnDelete()->comment('템플릿 ID (g7_templates 참조)');
$table->string('extension_type', 50)->comment('확장 타입: extension_point=확장점 방식, overlay=ID 기반 오버레이 방식');
$table->string('target_name')->comment('타겟 이름 (extension_point: 확장점명, overlay: 레이아웃명)');
$table->string('source_type', 50)->comment('출처 타입: template=템플릿(오버라이드용), module=모듈, plugin=플러그인');
$table->string('source_identifier', 255)->comment('출처 식별자 (예: sirsoft-ecommerce). 템플릿 오버라이드의 경우 오버라이드 대상 모듈/플러그인 식별자');
$table->string('override_target', 255)->nullable()->comment('오버라이드 대상 (source_type=template일 때만 사용). 모듈/플러그인 식별자');
$table->longText('content')->comment('확장 정의 JSON (components, injections, data_sources 포함)');
$table->integer('priority')->default(100)->comment('우선순위 (낮을수록 먼저 적용, 기본값: 100)');
$table->boolean('is_active')->default(true)->comment('활성 상태 (true=활성, false=비활성)');
$table->timestamps();
$table->softDeletes()->comment('삭제 일시 (soft delete)');
$table->index(['template_id', 'extension_type', 'target_name', 'is_active'], 'idx_target');
$table->index(['source_type', 'source_identifier'], 'idx_source');
$table->index(['override_target', 'template_id', 'extension_type', 'target_name'], 'idx_override');
});
if (DB::getDriverName() == 'mysql') {
Schema::table('template_layout_extensions', function (Blueprint $table) {
$table->comment('템플릿 레이아웃 확장 정보');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('template_layout_extensions');
}
};
@@ -0,0 +1,55 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('attachments', function (Blueprint $table) {
$table->id()->comment('첨부파일 ID');
$table->string('attachmentable_type', 255)->nullable()->comment('첨부 대상 모델 타입 (예: App\\Models\\Post)');
$table->unsignedBigInteger('attachmentable_id')->nullable()->comment('첨부 대상 모델 ID');
$table->enum('source_type', ['core', 'module', 'plugin'])->default('core')->comment('소스 타입: core(코어), module(모듈), plugin(플러그인)');
$table->string('source_identifier', 255)->nullable()->comment('모듈/플러그인 식별자 (예: sirsoft-board)');
$table->string('hash', 12)->unique()->comment('URL용 고유 해시 (12자)');
$table->string('original_filename', 255)->comment('원본 파일명');
$table->string('stored_filename', 255)->comment('저장된 파일명 (UUID 기반)');
$table->string('disk', 50)->default('local')->comment('스토리지 디스크 (local, s3 등)');
$table->string('path', 500)->comment('저장 경로 (디스크 기준 상대 경로)');
$table->string('mime_type', 100)->comment('MIME 타입 (예: image/jpeg, application/pdf)');
$table->unsignedBigInteger('size')->comment('파일 크기 (바이트)');
$table->string('collection', 100)->default('default')->comment('첨부파일 컬렉션/그룹명');
$table->unsignedInteger('order')->default(0)->comment('정렬 순서');
$table->text('meta')->nullable()->comment('추가 메타데이터 JSON');
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->softDeletes();
$table->index(['attachmentable_type', 'attachmentable_id'], 'idx_attachmentable');
$table->index(['source_type', 'source_identifier'], 'idx_source');
$table->index('collection', 'idx_collection');
$table->index('deleted_at', 'idx_deleted_at');
});
if (DB::getDriverName() == 'mysql') {
Schema::table('attachments', function (Blueprint $table) {
$table->comment('첨부파일 정보');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('attachments');
}
};
@@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('schedules', function (Blueprint $table) {
$table->id()->comment('스케줄 ID');
$table->string('name', 255)->comment('작업명');
$table->text('description')->nullable()->comment('설명');
$table->enum('type', ['artisan', 'shell', 'url'])->comment('작업 유형: artisan(Artisan 커맨드), shell(쉘 명령), url(URL 호출)');
$table->text('command')->comment('명령어 또는 URL');
$table->string('expression', 100)->comment('Cron 표현식');
$table->enum('frequency', ['everyMinute', 'hourly', 'daily', 'weekly', 'monthly', 'custom'])->default('custom')->comment('실행 주기: everyMinute(매분), hourly(매시간), daily(매일), weekly(매주), monthly(매월), custom(사용자 정의)');
$table->boolean('without_overlapping')->default(false)->comment('중복 실행 방지 여부: 0(허용), 1(방지)');
$table->boolean('run_in_maintenance')->default(false)->comment('점검 모드 실행 여부: 0(비실행), 1(실행)');
$table->unsignedInteger('timeout')->nullable()->comment('실행 제한 시간 (초)');
$table->boolean('is_active')->default(true)->comment('활성화 여부: 0(비활성), 1(활성)');
$table->enum('last_result', ['success', 'failed', 'running', 'never'])->default('never')->comment('마지막 실행 결과: success(성공), failed(실패), running(실행중), never(미실행)');
$table->timestamp('last_run_at')->nullable()->comment('마지막 실행 시간');
$table->timestamp('next_run_at')->nullable()->comment('다음 실행 예정 시간');
$table->string('extension_type', 20)->nullable()->comment('확장 소유 타입: core(코어), module(모듈), plugin(플러그인), NULL(사용자 정의)');
$table->string('extension_identifier', 255)->nullable()->comment('확장 식별자 (예: core, sirsoft-board, sirsoft-payment)');
$table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->index('type', 'idx_type');
$table->index('frequency', 'idx_frequency');
$table->index('is_active', 'idx_is_active');
$table->index('last_result', 'idx_last_result');
$table->index('next_run_at', 'idx_next_run_at');
$table->index(['extension_type', 'extension_identifier'], 'idx_schedules_extension');
});
if (DB::getDriverName() == 'mysql') {
Schema::table('schedules', function (Blueprint $table) {
$table->comment('스케줄 작업 관리');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('schedules');
}
};
@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('schedule_histories', function (Blueprint $table) {
$table->id()->comment('이력 ID');
$table->foreignId('schedule_id')->constrained('schedules')->cascadeOnDelete();
$table->timestamp('started_at')->comment('실행 시작 시간');
$table->timestamp('ended_at')->nullable()->comment('실행 종료 시간');
$table->unsignedInteger('duration')->nullable()->comment('실행 시간 (초)');
$table->enum('status', ['success', 'failed', 'running'])->default('running')->comment('실행 상태: success(성공), failed(실패), running(실행중)');
$table->tinyInteger('exit_code')->nullable()->comment('종료 코드 (0: 성공)');
$table->unsignedInteger('memory_usage')->nullable()->comment('메모리 사용량 (bytes)');
$table->longText('output')->nullable()->comment('표준 출력');
$table->longText('error_output')->nullable()->comment('에러 출력');
$table->enum('trigger_type', ['scheduled', 'manual'])->default('scheduled')->comment('트리거 유형: scheduled(예약 실행), manual(수동 실행)');
$table->foreignId('triggered_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
$table->index('started_at', 'idx_started_at');
$table->index('status', 'idx_status');
$table->index('trigger_type', 'idx_trigger_type');
});
if (DB::getDriverName() == 'mysql') {
Schema::table('schedule_histories', function (Blueprint $table) {
$table->comment('스케줄 실행 이력');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('schedule_histories');
}
};
@@ -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
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('activity_logs', function (Blueprint $table) {
$table->id()->comment('활동 로그 ID');
$table->string('log_type', 20)->index()->comment('로그 유형 (admin: 관리자, user: 사용자, system: 시스템)');
$table->nullableMorphs('loggable');
$table->foreignId('user_id')->nullable()->index()->constrained('users')->nullOnDelete()->comment('사용자 ID');
$table->string('action', 50)->index()->comment('액션 유형 (created, updated, deleted, login, export 등)');
$table->text('description')->comment('액션 상세 설명');
$table->mediumText('properties')->nullable()->comment('변경 상세 데이터 (old/new 값)');
$table->string('ip_address', 45)->nullable()->comment('IP 주소 (IPv6 대응)');
$table->string('user_agent', 500)->nullable()->comment('User Agent');
$table->timestamp('created_at')->useCurrent()->index()->comment('생성일시');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('activity_logs');
}
};
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('mail_templates', function (Blueprint $table) {
$table->id()->comment('ID');
$table->string('type', 100)->unique()->comment('템플릿 유형 (welcome, reset_password 등)');
$table->text('subject')->comment('다국어 제목 ({"ko": "...", "en": "..."})');
$table->mediumText('body')->comment('다국어 HTML 본문 ({"ko": "...", "en": "..."})');
$table->text('variables')->nullable()->comment('사용 가능 변수 메타데이터 ([{key, description}])');
$table->boolean('is_active')->default(true)->comment('활성 여부 (false = 해당 유형 메일 발송 중단)');
$table->boolean('is_default')->default(true)->comment('시더 생성 항목 여부');
$table->text('user_overrides')->nullable()->comment('사용자가 수정한 필드명 목록 (코어 업데이트 시 보존)');
$table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
});
if (DB::getDriverName() == 'mysql') {
Schema::table('mail_templates', function (Blueprint $table) {
$table->comment('코어 메일 템플릿');
});
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('mail_templates');
}
};
@@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('mail_send_logs', function (Blueprint $table) {
$table->id()->comment('ID');
$table->string('extension_type', 20)->default('core')->comment('확장 타입 (core, module, plugin)');
$table->string('extension_identifier', 100)->default('core')->comment('확장 식별자');
$table->string('sender_email', 255)->nullable()->comment('발송자 이메일');
$table->string('sender_name', 255)->nullable()->comment('발송자 이름');
$table->string('recipient_email', 255)->comment('수신자 이메일');
$table->string('recipient_name', 255)->nullable()->comment('수신자 이름');
$table->string('subject', 500)->nullable()->comment('이메일 제목');
$table->longText('body')->nullable()->comment('이메일 본문 (HTML)');
$table->string('template_type', 100)->nullable()->comment('템플릿 유형 (welcome, new_comment 등)');
$table->string('source', 100)->nullable()->comment('발송 출처 (notification, test_mail 등)');
$table->string('status', 20)->default('sent')->comment('발송 상태 (sent, failed)');
$table->text('error_message')->nullable()->comment('실패 시 에러 메시지');
$table->timestamp('sent_at')->useCurrent()->comment('발송 시각');
$table->timestamps();
$table->index('recipient_email', 'idx_mail_send_logs_recipient');
$table->index('template_type', 'idx_mail_send_logs_template');
$table->index('sent_at', 'idx_mail_send_logs_sent_at');
$table->index(['extension_type', 'extension_identifier'], 'idx_mail_send_logs_extension');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('mail_send_logs');
}
};
@@ -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
{
/**
* 사용자 테이블에 UUID 컬럼을 추가합니다.
*
* 외부 API 응답에서 정수 ID 노출을 방지하기 위해
* UUID v7 기반 고유 식별자를 추가합니다.
*
* - 신규 설치: User 모델 boot() creating 이벤트에서 UUID 자동 생성
* - 버전 업그레이드: 업그레이드 스크립트에서 기존 레코드 백필 + NOT NULL 변환
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->uuid('uuid')->nullable()->after('id')->unique()->comment('외부 노출용 UUID v7');
});
}
/**
* UUID 컬럼을 제거합니다.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropUnique(['uuid']);
$table->dropColumn('uuid');
});
}
};
@@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* activity_logs 테이블 i18n 필드 변경.
*
* - description 컬럼 삭제 (번역된 텍스트 저장 → i18n 키 기반으로 전환)
* - description_key, description_params 컬럼 추가 (실시간 다국어 번역)
* - changes 컬럼 추가 (구조화된 변경 이력)
* - 복합 인덱스 추가 (loggable + created_at, log_type + action + created_at)
*/
return new class extends Migration
{
public function up(): void
{
// SQLite 호환: 컬럼 삭제와 추가를 별도 블록으로 분리
Schema::table('activity_logs', function (Blueprint $table) {
$table->dropColumn('description');
});
Schema::table('activity_logs', function (Blueprint $table) {
$table->string('description_key', 150)->nullable()->after('action')
->comment('다국어 번역 키 (예: activity_log.description.user_create)');
$table->text('description_params')->nullable()->after('description_key')
->comment('다국어 번역 파라미터 (예: {"user_id": "abc-123"})');
$table->mediumText('changes')->nullable()->after('properties')
->comment('구조화된 변경 이력 (필드별 label_key, old, new, type)');
$table->index(
['loggable_type', 'loggable_id', 'created_at'],
'idx_activity_logs_loggable'
);
$table->index(
['log_type', 'action', 'created_at'],
'idx_activity_logs_type_action_date'
);
});
}
public function down(): void
{
Schema::table('activity_logs', function (Blueprint $table) {
$table->dropIndex('idx_activity_logs_loggable');
$table->dropIndex('idx_activity_logs_type_action_date');
$table->dropColumn(['description_key', 'description_params', 'changes']);
});
Schema::table('activity_logs', function (Blueprint $table) {
$table->text('description')->nullable()->after('action')
->comment('활동 설명');
});
}
};
@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('template_layout_previews', function (Blueprint $table) {
$table->id()->comment('ID');
$table->char('token', 36)->unique()->comment('미리보기 URL 토큰 (UUID)');
$table->foreignId('template_id')->constrained('templates')->cascadeOnDelete()->comment('템플릿 ID');
$table->string('layout_name', 255)->comment('미리보기 대상 레이아웃 이름');
$table->longText('content')->comment('편집 중인 레이아웃 JSON');
$table->unsignedBigInteger('admin_id')->comment('미리보기 생성 관리자 ID');
$table->timestamp('expires_at')->comment('만료 시각');
$table->timestamp('created_at')->useCurrent()->comment('생성 시각');
$table->index('expires_at', 'idx_layout_previews_expires_at');
$table->index(['template_id', 'layout_name', 'admin_id'], 'idx_layout_previews_template_layout_admin');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('template_layout_previews');
}
};
@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* activity_logs 테이블 인덱스 추가.
*
* - description_key: WhereIn 기반 다국어 검색
*/
return new class extends Migration
{
public function up(): void
{
$existingIndexes = array_column(Schema::getIndexes('activity_logs'), 'name');
if (! in_array('idx_activity_logs_description_key', $existingIndexes)) {
Schema::table('activity_logs', function (Blueprint $table) {
$table->index('description_key', 'idx_activity_logs_description_key');
});
}
}
public function down(): void
{
if (! Schema::hasTable('activity_logs')) {
return;
}
$existingIndexes = array_column(Schema::getIndexes('activity_logs'), 'name');
if (in_array('idx_activity_logs_description_key', $existingIndexes)) {
Schema::table('activity_logs', function (Blueprint $table) {
$table->dropIndex('idx_activity_logs_description_key');
});
}
}
};
@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* users 테이블 인덱스 추가.
*
* - created_at: 날짜 범위 필터, 통계, 정렬
*/
return new class extends Migration
{
public function up(): void
{
$existingIndexes = array_column(Schema::getIndexes('users'), 'name');
if (! in_array('idx_users_created_at', $existingIndexes)) {
Schema::table('users', function (Blueprint $table) {
$table->index('created_at', 'idx_users_created_at');
});
}
}
public function down(): void
{
if (! Schema::hasTable('users')) {
return;
}
$existingIndexes = array_column(Schema::getIndexes('users'), 'name');
if (in_array('idx_users_created_at', $existingIndexes)) {
Schema::table('users', function (Blueprint $table) {
$table->dropIndex('idx_users_created_at');
});
}
}
};
@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* mail_send_logs 테이블 인덱스 추가.
*
* - status: sent/failed 상태 필터
*/
return new class extends Migration
{
public function up(): void
{
$existingIndexes = array_column(Schema::getIndexes('mail_send_logs'), 'name');
if (! in_array('idx_mail_send_logs_status', $existingIndexes)) {
Schema::table('mail_send_logs', function (Blueprint $table) {
$table->index('status', 'idx_mail_send_logs_status');
});
}
}
public function down(): void
{
if (! Schema::hasTable('mail_send_logs')) {
return;
}
$existingIndexes = array_column(Schema::getIndexes('mail_send_logs'), 'name');
if (in_array('idx_mail_send_logs_status', $existingIndexes)) {
Schema::table('mail_send_logs', function (Blueprint $table) {
$table->dropIndex('idx_mail_send_logs_status');
});
}
}
};
@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* template_layouts 테이블 인덱스 추가.
*
* - template_id: 템플릿별 레이아웃 조회
*/
return new class extends Migration
{
public function up(): void
{
$existingIndexes = array_column(Schema::getIndexes('template_layouts'), 'name');
if (! in_array('idx_template_layouts_template_id', $existingIndexes)) {
Schema::table('template_layouts', function (Blueprint $table) {
$table->index('template_id', 'idx_template_layouts_template_id');
});
}
}
public function down(): void
{
if (! Schema::hasTable('template_layouts')) {
return;
}
$existingIndexes = array_column(Schema::getIndexes('template_layouts'), 'name');
if (in_array('idx_template_layouts_template_id', $existingIndexes)) {
Schema::table('template_layouts', function (Blueprint $table) {
$table->dropIndex('idx_template_layouts_template_id');
});
}
}
};
@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* schedules 테이블 인덱스 추가.
*
* - created_at: 날짜 범위 필터, 정렬
*/
return new class extends Migration
{
public function up(): void
{
$existingIndexes = array_column(Schema::getIndexes('schedules'), 'name');
if (! in_array('idx_schedules_created_at', $existingIndexes)) {
Schema::table('schedules', function (Blueprint $table) {
$table->index('created_at', 'idx_schedules_created_at');
});
}
}
public function down(): void
{
if (! Schema::hasTable('schedules')) {
return;
}
$existingIndexes = array_column(Schema::getIndexes('schedules'), 'name');
if (in_array('idx_schedules_created_at', $existingIndexes)) {
Schema::table('schedules', function (Blueprint $table) {
$table->dropIndex('idx_schedules_created_at');
});
}
}
};
@@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('template_layouts', function (Blueprint $table) {
$table->string('original_content_hash', 64)
->nullable()
->after('content')
->comment('원본 레이아웃 콘텐츠 SHA-256 해시 (수정 감지용)');
$table->unsignedInteger('original_content_size')
->nullable()
->after('original_content_hash')
->comment('원본 레이아웃 콘텐츠 바이트 크기 (변화량 표시용)');
});
// 기존 데이터 hash 백필 (현재 content = 원본으로 간주)
DB::table('template_layouts')
->whereNull('original_content_hash')
->orderBy('id')
->chunk(100, function ($layouts) {
foreach ($layouts as $layout) {
$content = json_decode($layout->content, true);
$normalized = json_encode($content, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$hash = hash('sha256', $normalized);
$size = strlen($normalized);
DB::table('template_layouts')
->where('id', $layout->id)
->update([
'original_content_hash' => $hash,
'original_content_size' => $size,
]);
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('template_layouts', function (Blueprint $table) {
$table->dropColumn(['original_content_hash', 'original_content_size']);
});
}
};
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
class AdminUserSeeder extends Seeder
{
private array $defaultAdminUser;
public function __construct()
{
$name = getenv('INSTALLER_ADMIN_NAME');
$email = getenv('INSTALLER_ADMIN_EMAIL');
$password = getenv('INSTALLER_ADMIN_PASSWORD');
if (! $email || ! $password) {
throw new \RuntimeException(
'관리자 계정 환경변수가 설정되지 않았습니다. '
. 'INSTALLER_ADMIN_EMAIL, INSTALLER_ADMIN_PASSWORD 환경변수를 설정해주세요.'
);
}
$this->defaultAdminUser = [
'name' => $name ?: 'Administrator',
'email' => $email,
'password' => $password,
'email_verified_at' => now(),
'timezone' => 'Asia/Seoul',
'language' => getenv('INSTALLER_ADMIN_LANGUAGE') ?: 'ko',
];
}
/**
* Run the database seeds.
*/
public function run(): void
{
$this->command->info('기본 관리자 사용자 생성을 시작합니다.');
// 기존 데이터 삭제
$this->deleteExistingUsers();
// 새 데이터 생성
$this->createAdminUser($this->defaultAdminUser);
$this->command->info('기본 관리자 사용자가 성공적으로 생성되었습니다.');
}
/**
* 기존 관리자 사용자를 삭제합니다.
*/
private function deleteExistingUsers(): void
{
$deletedCount = User::query()->delete();
if ($deletedCount > 0) {
$this->command->info("기존 관리자 사용자 {$deletedCount}개가 삭제되었습니다.");
}
}
/**
* 기본 관리자 사용자를 생성합니다.
*
* 첫 관리자는 슈퍼 관리자로 생성됩니다 (is_super = true).
* 슈퍼 관리자는 삭제할 수 없으며, 다른 관리자의 권한을 관리할 수 있습니다.
*
* @param array $userData 사용자 데이터
*/
private function createAdminUser(array $userData): void
{
User::create([
'name' => $userData['name'],
'email' => $userData['email'],
'password' => Hash::make($userData['password']),
'email_verified_at' => $userData['email_verified_at'],
'timezone' => $userData['timezone'],
'language' => $userData['language'],
'is_super' => true, // 첫 관리자는 슈퍼 관리자로 설정
]);
}
}
+124
View File
@@ -0,0 +1,124 @@
<?php
namespace Database\Seeders;
use App\Enums\ExtensionOwnerType;
use App\Enums\MenuPermissionType;
use App\Models\Menu;
use App\Models\Role;
use App\Models\User;
use Illuminate\Database\Seeder;
class CoreAdminMenuSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$this->command->info('코어 관리자 메뉴 생성을 시작합니다.');
// 기존 데이터 삭제
$this->deleteExistingCoreMenus();
// 관리자 사용자 찾기 (첫 번째 사용자를 관리자로 가정)
$admin = User::first();
// 새 데이터 생성
$this->createCoreMenus($admin);
$this->command->info('코어 관리자 메뉴가 성공적으로 생성되었습니다.');
}
/**
* 기존 코어 메뉴를 삭제합니다.
*/
private function deleteExistingCoreMenus(): void
{
$deletedCount = Menu::where('extension_type', ExtensionOwnerType::Core)->delete();
if ($deletedCount > 0) {
$this->command->info("기존 코어 메뉴 {$deletedCount}개가 삭제되었습니다.");
}
}
/**
* 코어 관리자 메뉴를 생성합니다.
* 정의는 config/core.php의 menus에서 읽습니다.
*
* @param User|null $admin 관리자 사용자
*/
private function createCoreMenus(?User $admin): void
{
$coreMenus = config('core.menus');
$createdMenus = [];
foreach ($coreMenus as $menuData) {
$createdMenus[] = Menu::create(array_merge($menuData, [
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'created_by' => null,
]));
}
// 관리자 역할에 모든 코어 메뉴 read 권한 부여
$this->grantAdminRoleToMenus($createdMenus);
// 매니저 역할에 대시보드 메뉴 read 권한 부여
$this->grantManagerDashboardMenu($createdMenus);
}
/**
* 관리자 역할에 모든 코어 메뉴의 read 권한을 부여합니다.
*
* @param array<Menu> $menus 생성된 메뉴 목록
*/
private function grantAdminRoleToMenus(array $menus): void
{
$adminRole = Role::where('identifier', 'admin')->first();
if (! $adminRole) {
$this->command->warn('관리자 역할(admin)을 찾을 수 없어 메뉴 권한 부여를 건너뜁니다.');
return;
}
foreach ($menus as $menu) {
$adminRole->menus()->attach($menu->id, [
'permission_type' => MenuPermissionType::Read->value,
]);
}
$this->command->info('관리자 역할에 '.count($menus).'개의 코어 메뉴 권한을 부여했습니다.');
}
/**
* 매니저 역할에 대시보드 메뉴의 read 권한을 부여합니다.
*
* @param array<Menu> $menus 생성된 메뉴 목록
*/
private function grantManagerDashboardMenu(array $menus): void
{
$managerRole = Role::where('identifier', 'manager')->first();
if (! $managerRole) {
$this->command->warn('매니저 역할(manager)을 찾을 수 없어 대시보드 메뉴 권한 부여를 건너뜁니다.');
return;
}
$dashboardMenu = collect($menus)->first(fn (Menu $menu) => $menu->slug === 'admin-dashboard');
if (! $dashboardMenu) {
$this->command->warn('대시보드 메뉴(admin-dashboard)를 찾을 수 없어 매니저 메뉴 권한 부여를 건너뜁니다.');
return;
}
$managerRole->menus()->attach($dashboardMenu->id, [
'permission_type' => MenuPermissionType::Read->value,
]);
$this->command->info('매니저 역할에 대시보드 메뉴 권한을 부여했습니다.');
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace Database\Seeders;
use App\Traits\HasSampleSeeders;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
use HasSampleSeeders;
/**
* Seed the application's database.
*/
public function run(): void
{
// 설치 필수 시더 (항상 실행)
$this->call([
AdminUserSeeder::class,
RolePermissionSeeder::class,
CoreAdminMenuSeeder::class,
MailTemplateSeeder::class,
]);
// 샘플 시더 (--sample 옵션 시에만 실행)
if ($this->shouldIncludeSample()) {
$this->command->info('');
$this->command->info('=== 코어 샘플 시더 실행 시작 ===');
$this->call([
Sample\DummyUserSeeder::class,
Sample\MailSendLogSeeder::class,
Sample\TemplateSeeder::class,
Sample\TemplateLayoutVersionSeeder::class,
Sample\ActivityLogSampleSeeder::class,
]);
$this->command->info('=== 코어 샘플 시더 실행 완료 ===');
}
}
}
+51
View File
@@ -0,0 +1,51 @@
<?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', []);
}
}
+213
View File
@@ -0,0 +1,213 @@
<?php
namespace Database\Seeders;
use App\Enums\ExtensionOwnerType;
use App\Enums\PermissionType;
use App\Enums\ScopeType;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class RolePermissionSeeder extends Seeder
{
/**
* 기본 권한과 역할을 생성합니다.
*/
public function run(): void
{
$this->command->info('기본 권한과 역할 생성을 시작합니다.');
DB::transaction(function () {
// 기존 데이터 삭제
$this->deleteExistingData();
// 계층형 권한 생성
$this->createHierarchicalPermissions();
// 기본 역할 생성
$this->createRoles();
// 관리자 계정에 admin 역할 부여
$this->assignAdminRole();
});
$this->command->info('기본 권한과 역할이 성공적으로 생성되었습니다.');
}
/**
* 기존 코어 권한 데이터를 삭제합니다.
*
* 주의: 역할(Role)은 삭제하지 않습니다. 역할 삭제 시 role_menus, role_users 등
* cascade 연결된 피벗 데이터가 함께 삭제되므로, 역할은 updateOrCreate로 보존합니다.
*/
private function deleteExistingData(): void
{
// 기존 코어 권한만 삭제 (role_permissions 피벗은 cascade 삭제됨 → 이후 재연결)
$deletedPermissions = Permission::where('extension_type', ExtensionOwnerType::Core)->delete();
if ($deletedPermissions > 0) {
$this->command->info("기존 코어 권한 {$deletedPermissions}개가 삭제되었습니다.");
}
}
/**
* 계층형 권한을 생성합니다.
* 구조: 모듈(1레벨) → 카테고리(2레벨) → 개별 권한(3레벨)
* 정의는 config/core.php의 permissions에서 읽습니다.
*/
private function createHierarchicalPermissions(): void
{
$permConfig = config('core.permissions');
$moduleConfig = $permConfig['module'];
// 1레벨: 코어 모듈
$coreModule = Permission::create([
'identifier' => $moduleConfig['identifier'],
'name' => $moduleConfig['name'],
'description' => $moduleConfig['description'],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => PermissionType::Admin,
'order' => $moduleConfig['order'],
'parent_id' => null,
]);
// 2레벨: 카테고리들
$categories = $permConfig['categories'];
foreach ($categories as $categoryData) {
// 2레벨: 카테고리 생성
$category = Permission::create([
'identifier' => $categoryData['identifier'],
'name' => $categoryData['name'],
'description' => $categoryData['description'],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => PermissionType::Admin,
'order' => $categoryData['order'],
'parent_id' => $coreModule->id,
]);
// 3레벨: 개별 권한 생성
foreach ($categoryData['permissions'] as $permData) {
$permissionData = [
'identifier' => $permData['identifier'],
'name' => $permData['name'],
'description' => $permData['description'],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => PermissionType::Admin,
'order' => $permData['order'],
'parent_id' => $category->id,
];
// scope 관련 컬럼 (resource_route_key, owner_key) 추가
if (isset($permData['resource_route_key'])) {
$permissionData['resource_route_key'] = $permData['resource_route_key'];
}
if (isset($permData['owner_key'])) {
$permissionData['owner_key'] = $permData['owner_key'];
}
Permission::create($permissionData);
}
}
$this->command->info('계층형 권한이 생성되었습니다.');
}
/**
* 역할을 생성(또는 업데이트)하고 권한을 할당합니다.
* 정의는 config/core.php의 roles에서 읽습니다.
*
* updateOrCreate를 사용하여 역할 ID를 보존합니다.
* 역할 삭제 시 role_menus, role_users 등 cascade 피벗이 함께 삭제되므로,
* 역할은 절대 삭제하지 않고 속성만 업데이트합니다.
*/
private function createRoles(): void
{
$roles = config('core.roles');
foreach ($roles as $roleDef) {
$role = Role::updateOrCreate(
['identifier' => $roleDef['identifier']],
[
'name' => $roleDef['name'],
'description' => $roleDef['description'],
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'is_active' => $roleDef['attributes']['is_active'] ?? true,
]
);
// 권한별 스코프 맵 (identifier => scope_type value)
$scopeMap = $roleDef['permission_scopes'] ?? [];
// 권한 할당
if ($roleDef['permissions'] === 'all_leaf') {
// 리프 노드 코어 권한만 할당 (parent_id가 있고, 자식이 없는 권한)
$leafPermissions = Permission::where('extension_type', ExtensionOwnerType::Core)
->whereNotNull('parent_id')
->whereDoesntHave('children')
->get();
// 코어 권한만 sync하고, 모듈/플러그인이 부여한 권한은 보존
$nonCorePermissionIds = $role->permissions()
->where('extension_type', '!=', ExtensionOwnerType::Core)
->pluck('permissions.id')
->toArray();
$allPermissionIds = array_merge($leafPermissions->pluck('id')->toArray(), $nonCorePermissionIds);
$role->permissions()->sync($allPermissionIds);
} elseif (is_array($roleDef['permissions']) && ! empty($roleDef['permissions'])) {
$permissions = Permission::whereIn('identifier', $roleDef['permissions'])->get();
// scope_type 매핑 (permission_scopes 맵에서 스코프 타입 해석)
$coreSyncData = $permissions->mapWithKeys(function ($permission) use ($scopeMap) {
$pivotData = [
'scope_type' => isset($scopeMap[$permission->identifier])
? ScopeType::from($scopeMap[$permission->identifier])
: null,
];
return [$permission->id => $pivotData];
})->toArray();
// 모듈/플러그인이 부여한 권한은 보존
$nonCorePermissions = $role->permissions()
->where('extension_type', '!=', ExtensionOwnerType::Core)
->get()
->mapWithKeys(fn ($p) => [$p->id => ['scope_type' => $p->pivot->scope_type ?? null]])
->toArray();
$role->permissions()->sync(array_replace($coreSyncData, $nonCorePermissions));
}
}
$this->command->info('역할이 생성/업데이트되었습니다.');
}
/**
* 관리자 계정에 admin 역할을 부여하고 슈퍼 관리자로 설정합니다.
*/
private function assignAdminRole(): void
{
$adminUser = User::where('is_super', true)->first();
$adminRole = Role::where('identifier', 'admin')->first();
if ($adminUser && $adminRole) {
$adminUser->roles()->syncWithoutDetaching([$adminRole->id => [
'assigned_at' => now(),
'assigned_by' => $adminUser->id,
]]);
// 기본 관리자를 슈퍼 관리자로 설정
$adminUser->update(['is_super' => true]);
$this->command->info('관리자 계정에 admin 역할이 부여되고 슈퍼 관리자로 설정되었습니다.');
}
}
}
File diff suppressed because it is too large. Load diff
+266
View File
@@ -0,0 +1,266 @@
<?php
namespace Database\Seeders\Sample;
use App\Models\Role;
use App\Models\User;
use App\Traits\HasSeederCounts;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class DummyUserSeeder extends Seeder
{
use HasSeederCounts;
/**
* 영문 이름 목록 (이메일 생성용)
*
* @var array<string>
*/
private array $englishFirstNames = [
'james', 'john', 'michael', 'david', 'william',
'emma', 'olivia', 'sophia', 'isabella', 'mia',
'robert', 'joseph', 'thomas', 'charles', 'daniel',
'emily', 'elizabeth', 'madison', 'abigail', 'charlotte',
];
/**
* 영문 성 목록 (이메일 생성용)
*
* @var array<string>
*/
private array $englishLastNames = [
'smith', 'johnson', 'williams', 'brown', 'jones',
'garcia', 'miller', 'davis', 'rodriguez', 'martinez',
'wilson', 'anderson', 'taylor', 'thomas', 'moore',
];
/**
* 더미 사용자를 생성합니다.
*
* --count=users=N 옵션으로 생성 수를 지정할 수 있습니다.
* 500건 초과 시 배치 삽입으로 성능을 최적화합니다.
*/
public function run(): void
{
$count = $this->getSeederCount('users', 100);
$this->command->info("더미 사용자 {$count}개 생성을 시작합니다.");
// 역할 조회
$roles = Role::whereIn('identifier', ['admin', 'manager', 'user'])->get()->keyBy('identifier');
if ($roles->isEmpty()) {
$this->command->error('역할이 존재하지 않습니다. RolePermissionSeeder를 먼저 실행해주세요.');
return;
}
$timezones = [
'Asia/Seoul',
'Asia/Tokyo',
'America/New_York',
'America/Los_Angeles',
'Europe/London',
'Europe/Paris',
];
$languages = ['ko', 'en'];
$statuses = ['active', 'inactive', 'blocked'];
$countries = ['KR', 'JP', 'US', 'GB', 'FR', 'DE'];
$useBatchInsert = $count > 500;
$batchSize = 500;
$batch = [];
$roleBatch = [];
$createdCount = 0;
$hashedPassword = Hash::make('password123');
$progressBar = $this->command->getOutput()->createProgressBar($count);
$progressBar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s%');
for ($i = 1; $i <= $count; $i++) {
$firstName = $this->getRandomFirstName();
$lastName = $this->getRandomLastName();
$name = "{$firstName} {$lastName}";
// 이메일은 영문 소문자와 숫자만 사용
$emailFirstName = $this->englishFirstNames[array_rand($this->englishFirstNames)];
$emailLastName = $this->englishLastNames[array_rand($this->englishLastNames)];
$email = $emailFirstName.'.'.$emailLastName.$i.'@example.com';
$userData = [
'name' => $name,
'nickname' => $firstName.$i,
'email' => $email,
'password' => $hashedPassword,
'email_verified_at' => rand(0, 1) ? now()->subDays(rand(1, 365)) : null,
'language' => $languages[array_rand($languages)],
'timezone' => $timezones[array_rand($timezones)],
'country' => $countries[array_rand($countries)],
'status' => $statuses[array_rand($statuses)],
'mobile' => '010-'.rand(1000, 9999).'-'.rand(1000, 9999),
'bio' => rand(0, 1) ? $this->getRandomBio() : null,
'ip_address' => rand(1, 255).'.'.rand(0, 255).'.'.rand(0, 255).'.'.rand(1, 254),
'last_login_at' => rand(0, 1) ? now()->subDays(rand(0, 30)) : null,
];
// 역할 부여 (90% user, 8% manager, 2% admin)
$roleIdentifier = $this->getRandomRole();
if ($useBatchInsert) {
$userData['created_at'] = now();
$userData['updated_at'] = now();
$batch[] = $userData;
$roleBatch[] = $roleIdentifier;
// 배치 크기에 도달하면 일괄 삽입
if (count($batch) >= $batchSize) {
$this->insertBatch($batch, $roleBatch, $roles);
$batch = [];
$roleBatch = [];
}
} else {
$user = User::create($userData);
$role = $roles->get($roleIdentifier);
if ($role) {
$user->roles()->attach($role->id, [
'assigned_at' => now(),
'assigned_by' => null,
]);
}
}
$createdCount++;
$progressBar->advance();
// 100건마다 메모리 정리
if ($i % 100 === 0) {
gc_collect_cycles();
}
}
// 남은 배치 삽입
if ($useBatchInsert && ! empty($batch)) {
$this->insertBatch($batch, $roleBatch, $roles);
}
$progressBar->finish();
$this->command->newLine();
$this->command->info("더미 사용자 {$createdCount}개가 성공적으로 생성되었습니다.");
}
/**
* 사용자 배치 삽입 및 역할 부여
*
* @param array $batch 사용자 데이터 배열
* @param array $roleBatch 역할 식별자 배열
* @param \Illuminate\Support\Collection $roles 역할 컬렉션
* @return void
*/
private function insertBatch(array $batch, array $roleBatch, $roles): void
{
// 배치 삽입 전 이메일 목록 저장
$emails = array_column($batch, 'email');
// 배치 삽입
User::insert($batch);
// 삽입된 사용자 조회하여 역할 부여
$insertedUsers = User::whereIn('email', $emails)->get()->keyBy('email');
$roleAttachments = [];
foreach ($batch as $index => $userData) {
$user = $insertedUsers->get($userData['email']);
if ($user) {
$role = $roles->get($roleBatch[$index]);
if ($role) {
$roleAttachments[] = [
'user_id' => $user->id,
'role_id' => $role->id,
'assigned_at' => now(),
'assigned_by' => null,
];
}
}
}
if (! empty($roleAttachments)) {
\Illuminate\Support\Facades\DB::table('user_roles')->insert($roleAttachments);
}
}
/**
* 랜덤 역할 식별자를 반환합니다.
* 90% user, 8% manager, 2% admin 비율
*
* @return string
*/
private function getRandomRole(): string
{
$rand = rand(1, 100);
if ($rand <= 2) {
return 'admin';
}
if ($rand <= 10) {
return 'manager';
}
return 'user';
}
/**
* 랜덤 이름(First Name)을 반환합니다.
*/
private function getRandomFirstName(): string
{
$names = [
'민준', '서준', '도윤', '예준', '시우',
'하준', '지호', '주원', '지후', '준우',
'서연', '서윤', '지우', '서현', '민서',
'하은', '하윤', '윤서', '지민', '지유',
'James', 'John', 'Michael', 'David', 'William',
'Emma', 'Olivia', 'Sophia', 'Isabella', 'Mia',
];
return $names[array_rand($names)];
}
/**
* 랜덤 성(Last Name)을 반환합니다.
*/
private function getRandomLastName(): string
{
$names = [
'김', '이', '박', '최', '정',
'강', '조', '윤', '장', '임',
'Smith', 'Johnson', 'Williams', 'Brown', 'Jones',
'Garcia', 'Miller', 'Davis', 'Rodriguez', 'Martinez',
];
return $names[array_rand($names)];
}
/**
* 랜덤 자기소개를 반환합니다.
*/
private function getRandomBio(): string
{
$bios = [
'안녕하세요! 반갑습니다.',
'개발을 좋아하는 사람입니다.',
'Hello! Nice to meet you.',
'새로운 것을 배우는 것을 좋아합니다.',
'Software developer passionate about technology.',
'항상 배우고 성장하려고 노력합니다.',
'Love coding and problem solving.',
'커피와 코딩을 좋아합니다.',
];
return $bios[array_rand($bios)];
}
}
@@ -0,0 +1,150 @@
<?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,117 @@
<?php
namespace Database\Seeders\Sample;
use App\Models\TemplateLayout;
use App\Models\TemplateLayoutVersion;
use App\Models\User;
use Illuminate\Database\Seeder;
class TemplateLayoutVersionSeeder extends Seeder
{
/**
* 샘플 버전 데이터
*/
private array $sampleVersions = [
[
'version' => '1.0.0',
'changes_summary' => [
'added' => 0,
'removed' => 0,
'is_restored' => false,
'restored_from' => null,
],
],
[
'version' => '1.1.0',
'changes_summary' => [
'added' => 10,
'removed' => 5,
'is_restored' => false,
'restored_from' => null,
],
],
[
'version' => '2.0.0',
'changes_summary' => [
'added' => 25,
'removed' => 15,
'is_restored' => false,
'restored_from' => null,
],
],
];
/**
* 시더 실행
*/
public function run(): void
{
$this->command->info('템플릿 레이아웃 버전 시더 생성을 시작합니다.');
// 기존 버전 데이터 삭제
$this->deleteExistingVersions();
// 레이아웃 조회
$layouts = TemplateLayout::all();
if ($layouts->isEmpty()) {
$this->command->warn('레이아웃이 없습니다. TemplateSeeder를 먼저 실행해주세요.');
return;
}
// 관리자 사용자 조회
$adminUser = User::where('is_super', true)->first();
if (!$adminUser) {
$this->command->warn('관리자 사용자가 없습니다. AdminUserSeeder를 먼저 실행해주세요.');
return;
}
// 각 레이아웃에 대해 버전 생성
foreach ($layouts as $layout) {
$this->createVersionsForLayout($layout, $adminUser);
}
$this->command->info('템플릿 레이아웃 버전 시더가 성공적으로 생성되었습니다.');
}
/**
* 기존 버전 데이터 삭제
*/
private function deleteExistingVersions(): void
{
$deletedCount = TemplateLayoutVersion::query()->delete();
if ($deletedCount > 0) {
$this->command->warn("기존 레이아웃 버전 {$deletedCount}건을 삭제했습니다.");
}
}
/**
* 특정 레이아웃에 대한 버전 생성
*/
private function createVersionsForLayout(TemplateLayout $layout, User $adminUser): void
{
foreach ($this->sampleVersions as $index => $versionData) {
// 레이아웃 content를 복사하여 버전별로 약간 수정
$content = $layout->content;
$content['version'] = $versionData['version'];
// 버전별로 컴포넌트 수정 시뮬레이션
if ($index > 0) {
$content['meta']['description'] = "Version {$versionData['version']} - Updated layout";
}
$version = TemplateLayoutVersion::create([
'layout_id' => $layout->id,
'version' => $versionData['version'],
'content' => $content,
'changes_summary' => $versionData['changes_summary'],
'created_by' => $adminUser->id,
]);
$this->command->info("레이아웃 '{$layout->name}'의 버전 {$version->version}이 생성되었습니다.");
}
}
}
+139
View File
@@ -0,0 +1,139 @@
<?php
namespace Database\Seeders\Sample;
use App\Models\Template;
use App\Models\TemplateLayout;
use Illuminate\Database\Seeder;
class TemplateSeeder extends Seeder
{
/**
* 기본 템플릿 정보
*/
private array $defaultTemplate = [
'identifier' => 'sirsoft-admin_basic',
'vendor' => 'sirsoft',
'name' => [
'ko' => 'Admin Basic',
'en' => 'Admin Basic',
],
'version' => '0.1.1',
'type' => 'admin',
'description' => [
'ko' => '그누보드7 기본 관리자 템플릿',
'en' => 'Gnuboard7 default admin template',
],
'status' => 'inactive',
];
/**
* 기본 레이아웃 정보
*/
private array $defaultLayout = [
'name' => '_admin_base',
'content' => [
'version' => '0.1.1',
'layout_name' => '_admin_base',
'meta' => [
'title' => '$t:common.admin',
'description' => 'Admin base layout',
],
'components' => [
[
'id' => 'root',
'type' => 'layout',
'name' => 'Container',
'props' => [
'layout' => 'flex',
'direction' => 'column',
],
'children' => [
[
'id' => 'header',
'type' => 'composite',
'name' => 'AdminHeader',
'props' => [],
],
[
'id' => 'content',
'type' => 'layout',
'name' => 'Slot',
'props' => [
'name' => 'content',
],
],
[
'id' => 'footer',
'type' => 'composite',
'name' => 'AdminFooter',
'props' => [],
],
],
],
],
],
];
/**
* 시더 실행
*/
public function run(): void
{
$this->command->info('템플릿 시더 생성을 시작합니다.');
// 기존 데이터 삭제
$this->deleteExistingTemplates();
// 기본 템플릿 생성
$template = $this->createDefaultTemplate();
// 기본 레이아웃 생성
$this->createTemplateLayouts($template);
$this->command->info('템플릿 시더가 성공적으로 생성되었습니다.');
}
/**
* 기존 템플릿 삭제
*/
private function deleteExistingTemplates(): void
{
$deletedCount = Template::where('identifier', $this->defaultTemplate['identifier'])->forceDelete();
if ($deletedCount > 0) {
$this->command->warn("기존 템플릿 {$deletedCount}건을 삭제했습니다.");
}
}
/**
* 기본 템플릿 생성
*/
private function createDefaultTemplate(): Template
{
// JSON 구조 검증
if (! is_array($this->defaultTemplate['name']) || ! is_array($this->defaultTemplate['description'])) {
throw new \Exception('Template name and description must be arrays');
}
$template = Template::create($this->defaultTemplate);
$this->command->info("템플릿이 생성되었습니다: {$template->identifier}");
return $template;
}
/**
* 기본 레이아웃 생성
*/
private function createTemplateLayouts(Template $template): void
{
$layout = TemplateLayout::create([
'template_id' => $template->id,
'name' => $this->defaultLayout['name'],
'content' => $this->defaultLayout['content'],
]);
$this->command->info("레이아웃이 생성되었습니다: {$layout->name}");
}
}