From 1db039ff34132442426827599379f89ce3dfc319 Mon Sep 17 00:00:00 2001
From: HeuJung
Date: Mon, 11 May 2026 11:29:41 +0900
Subject: [PATCH] v7.0.0-beta.4 release
---
.env.example | 2 +-
.env.testing.example | 2 +-
.gitignore | 16 +
AGENTS.md | 58 +-
CHANGELOG.md | 224 ++
INSTALL.md | 10 +-
README.md | 92 +-
.../Traits/ResolvesActivityLogType.php | 11 +
app/Concerns/Seeder/HasTranslatableSeeder.php | 42 +
.../Concerns/BundledExtensionUpdatePrompt.php | 241 +-
.../Commands/Core/CoreUpdateCommand.php | 87 +-
.../Core/ExecuteBundledUpdatesCommand.php | 177 ++
.../Core/ExecuteUpgradeStepsCommand.php | 45 +
.../ActivateLanguagePackCommand.php | 66 +
.../CacheClearLanguagePackCommand.php | 57 +
.../CheckLanguagePackUpdatesCommand.php | 86 +
.../DeactivateLanguagePackCommand.php | 64 +
.../InstallLanguagePackCommand.php | 75 +
.../LanguagePack/ListLanguagePackCommand.php | 75 +
.../UninstallLanguagePackCommand.php | 77 +
.../UpdateLanguagePackCommand.php | 87 +
.../Commands/MigrateSettingsToJsonCommand.php | 15 +-
.../Commands/Module/ListModuleCommand.php | 14 +-
.../Module/UninstallModuleCommand.php | 4 +-
.../Commands/Module/UpdateModuleCommand.php | 4 +-
.../Commands/Plugin/ListPluginCommand.php | 14 +-
.../Plugin/UninstallPluginCommand.php | 4 +-
.../Commands/Plugin/UpdatePluginCommand.php | 4 +-
.../Commands/SeoGenerateSitemapCommand.php | 14 +-
.../Commands/Template/ListTemplateCommand.php | 14 +-
.../Template/UninstallTemplateCommand.php | 4 +-
.../Template/UpdateTemplateCommand.php | 4 +-
.../Commands/Traits/HasUnifiedConfirm.php | 50 +
app/Console/Helpers/ConsoleConfirm.php | 106 +
.../IdentityVerificationInterface.php | 93 +
app/Contracts/Extension/ModuleInterface.php | 10 +
.../Extension/ModuleManagerInterface.php | 11 +-
app/Contracts/Extension/PluginInterface.php | 10 +
.../Extension/PluginManagerInterface.php | 11 +-
.../Extension/TemplateManagerInterface.php | 10 +-
.../ActivityLogRepositoryInterface.php | 10 +
...tyMessageDefinitionRepositoryInterface.php | 97 +
...tityMessageTemplateRepositoryInterface.php | 69 +
.../IdentityPolicyRepositoryInterface.php | 129 +
...tityVerificationLogRepositoryInterface.php | 101 +
.../LanguagePackRepositoryInterface.php | 182 ++
...uagePackTranslationRepositoryInterface.php | 41 +
.../ModuleRepositoryInterface.php | 9 +
.../PluginRepositoryInterface.php | 10 +
.../ScheduleRepositoryInterface.php | 10 +
.../TemplateRepositoryInterface.php | 9 +
.../Repositories/UserRepositoryInterface.php | 53 +
.../Seeder/TranslatableSeederInterface.php | 45 +
...actIdentityVerificationLogSampleSeeder.php | 295 ++
.../AbstractNotificationLogSampleSeeder.php | 273 ++
app/Enums/DeactivationReason.php | 65 +
app/Enums/IdentityMessageScopeType.php | 45 +
app/Enums/IdentityOriginType.php | 55 +
app/Enums/IdentityPolicyAppliesTo.php | 43 +
app/Enums/IdentityPolicyFailMode.php | 40 +
app/Enums/IdentityPolicyScope.php | 43 +
app/Enums/IdentityPolicySourceType.php | 47 +
app/Enums/IdentityVerificationChannel.php | 53 +
app/Enums/IdentityVerificationPurpose.php | 59 +
app/Enums/IdentityVerificationStatus.php | 42 +
app/Enums/LanguagePackAbility.php | 68 +
app/Enums/LanguagePackErrorCode.php | 82 +
app/Enums/LanguagePackOrigin.php | 88 +
app/Enums/LanguagePackScope.php | 75 +
app/Enums/LanguagePackSourceType.php | 107 +
app/Enums/LanguagePackStatus.php | 66 +
app/Enums/TextDirection.php | 53 +
app/Enums/UserStatus.php | 6 +
.../Auth/AccountLockedException.php | 34 +
app/Exceptions/CannotDeleteAdminException.php | 22 -
.../CoreUpdateOperationException.php | 29 +
.../CoreVersionMismatchException.php | 64 +
.../IdentityVerificationRequiredException.php | 59 +
.../LanguagePackOperationException.php | 29 +
.../LanguagePackSlotConflictException.php | 24 +
app/Exceptions/ModuleOperationException.php | 27 +
app/Exceptions/PluginOperationException.php | 27 +
app/Exceptions/TemplateOperationException.php | 29 +
app/Extension/AbstractModule.php | 250 ++
app/Extension/AbstractPlugin.php | 199 ++
.../ResolvesExtensionSharedRecords.php | 105 +
app/Extension/CoreVersionChecker.php | 15 +-
app/Extension/ExtensionManager.php | 89 +-
.../Helpers/ExtensionMenuSyncHelper.php | 45 +-
.../Helpers/FilePermissionHelper.php | 141 +-
.../Helpers/IdentityMessageSyncHelper.php | 151 +
.../Helpers/IdentityPolicySyncHelper.php | 91 +
app/Extension/Helpers/SettingsMigrator.php | 15 +-
app/Extension/HookArgumentSerializer.php | 76 +-
app/Extension/HookListenerRegistrar.php | 37 +
app/Extension/HookManager.php | 49 +-
.../DTO/VerificationChallenge.php | 58 +
.../DTO/VerificationResult.php | 85 +
.../IdentityVerificationManager.php | 323 +++
.../Providers/MailIdentityProvider.php | 447 +++
app/Extension/ModuleManager.php | 637 ++++-
app/Extension/PluginManager.php | 603 +++-
app/Extension/Storage/ModuleStorageDriver.php | 21 +
app/Extension/Storage/PluginStorageDriver.php | 21 +
app/Extension/TemplateManager.php | 156 +-
app/Helpers/ResponseHelper.php | 67 +-
app/Helpers/locale_helpers.php | 184 ++
app/Helpers/settings_helpers.php | 28 +
.../Controllers/Api/Admin/AuthController.php | 6 +
.../Api/Admin/ExtensionRecoveryController.php | 224 ++
.../Identity/AdminIdentityLogController.php | 84 +
...minIdentityMessageDefinitionController.php | 221 ++
...AdminIdentityMessageTemplateController.php | 127 +
.../AdminIdentityPolicyController.php | 184 ++
.../AdminIdentityProviderController.php | 53 +
.../Api/Admin/LanguagePackController.php | 416 +++
.../Api/Admin/ModuleController.php | 131 +-
.../Api/Admin/PluginController.php | 123 +-
.../Api/Admin/SeoCacheController.php | 29 +-
.../Api/Admin/TemplateController.php | 123 +-
.../Controllers/Api/Admin/UserController.php | 3 -
.../Controllers/Api/Auth/AuthController.php | 6 +
.../Api/Auth/ProfileController.php | 4 +-
.../IdentityVerificationController.php | 357 +++
.../Api/Public/LocaleController.php | 36 +
.../InjectsExtensionLanguagePacks.php | 45 +
.../Concerns/OrchestratesCascadeInstall.php | 132 +
app/Http/Middleware/CheckUserStatus.php | 1 +
app/Http/Middleware/EnforceIdentityPolicy.php | 217 ++
app/Http/Middleware/EnsureTokenIsValid.php | 4 +-
app/Http/Middleware/MaintenanceModePage.php | 9 +-
app/Http/Middleware/PermissionMiddleware.php | 14 +
...nIdentityMessageDefinitionIndexRequest.php | 70 +
.../PreviewIdentityMessageTemplateRequest.php | 40 +
.../StoreIdentityMessageDefinitionRequest.php | 138 +
...UpdateIdentityMessageDefinitionRequest.php | 49 +
.../UpdateIdentityMessageTemplateRequest.php | 47 +
.../Extension/AutoDeactivatedListRequest.php | 33 +
.../Extension/DismissAlertRequest.php | 33 +
.../Requests/Extension/RecoverRequest.php | 33 +
.../Identity/AdminIdentityLogIndexRequest.php | 69 +
.../Identity/AdminIdentityLogPurgeRequest.php | 35 +
.../AdminIdentityPolicyIndexRequest.php | 44 +
.../AdminIdentityPolicyResetFieldRequest.php | 36 +
.../AdminIdentityPolicyStoreRequest.php | 108 +
.../AdminIdentityPolicyUpdateRequest.php | 98 +
.../Identity/CancelChallengeRequest.php | 37 +
.../Identity/IdentityCallbackRequest.php | 64 +
.../Identity/ProvidersIndexRequest.php | 33 +
.../Identity/PurposesIndexRequest.php | 33 +
.../Identity/RequestChallengeRequest.php | 44 +
.../Identity/ResolvePolicyRequest.php | 36 +
.../Identity/ShowChallengeRequest.php | 34 +
.../Identity/VerifyChallengeRequest.php | 42 +
.../LanguagePack/BulkActivateRequest.php | 39 +
.../LanguagePack/IndexLanguagePackRequest.php | 44 +
.../InstallFromBundledRequest.php | 55 +
.../LanguagePack/InstallFromFileRequest.php | 49 +
.../LanguagePack/InstallFromGithubRequest.php | 52 +
.../LanguagePack/InstallFromUrlRequest.php | 49 +
.../LanguagePack/ManifestPreviewRequest.php | 35 +
.../UninstallLanguagePackRequest.php | 36 +
.../Requests/Module/IndexModuleRequest.php | 3 +
.../Requests/Module/InstallModuleRequest.php | 11 +
.../Module/PerformModuleUpdateRequest.php | 6 +-
.../Module/PreviewModuleManifestRequest.php | 54 +
.../Requests/Plugin/IndexPluginRequest.php | 3 +
.../Requests/Plugin/InstallPluginRequest.php | 13 +-
.../Plugin/PerformPluginUpdateRequest.php | 6 +-
.../Plugin/PreviewPluginManifestRequest.php | 54 +
.../Requests/Settings/SaveSettingsRequest.php | 63 +-
.../Template/IndexTemplateRequest.php | 3 +
.../Template/InstallTemplateRequest.php | 11 +
.../Template/PerformTemplateUpdateRequest.php | 6 +-
.../PreviewTemplateManifestRequest.php | 54 +
.../IdentityMessageDefinitionCollection.php | 50 +
.../IdentityMessageDefinitionResource.php | 70 +
.../IdentityMessageTemplateResource.php | 46 +
app/Http/Resources/BaseApiResource.php | 2 +-
.../Resources/Identity/ChallengeResource.php | 40 +
.../Resources/Identity/PolicyCollection.php | 46 +
.../Resources/Identity/PolicyResource.php | 57 +
.../Resources/Identity/ProviderResource.php | 37 +
app/Http/Resources/IdentityLogResource.php | 44 +
app/Http/Resources/LanguagePackCollection.php | 64 +
app/Http/Resources/LanguagePackResource.php | 283 ++
app/Http/Resources/ModuleResource.php | 24 +
app/Http/Resources/PluginResource.php | 27 +
app/Http/Resources/TemplateResource.php | 24 +
app/Http/Resources/UserResource.php | 6 +-
app/Jobs/GenerateSitemapJob.php | 28 +-
.../BroadcastNotificationListener.php | 2 +
app/Listeners/CoreActivityLogListener.php | 158 +-
.../ExtensionCompatibilityAlertListener.php | 210 +-
.../ActivateUserOnIdentityVerified.php | 77 +
.../AssertIdentityVerifiedBeforeRegister.php | 92 +
.../EnforceIdentityPolicyListener.php | 228 ++
...InitiateIdentityChallengeAfterRegister.php | 88 +
.../InjectIdvRuleIntoRegisterValidation.php | 73 +
.../RejectPasswordResetForPendingUser.php | 70 +
.../VerifyIdentityBeforePasswordReset.php | 73 +
.../LanguagePack/MergeFrontendLanguage.php | 230 ++
.../RunSeedersOnLanguagePackLifecycle.php | 245 ++
.../LanguagePack/SyncDatabaseTranslations.php | 130 +
app/Listeners/MenuUserOverridesListener.php | 20 +-
app/Listeners/NotificationHookListener.php | 9 +
app/Listeners/RoleUserOverridesListener.php | 24 +-
.../UserLogin/HandleFailedLoginListener.php | 90 +
.../HandleSuccessfulLoginListener.php | 56 +
app/Mail/IdentityMessageMail.php | 65 +
app/Models/ActivityLog.php | 59 +-
app/Models/Concerns/HasUserOverrides.php | 154 +-
.../IdentityMessageContentBehavior.php | 128 +
.../Concerns/NotificationContentBehavior.php | 4 +-
app/Models/IdentityMessageDefinition.php | 213 ++
app/Models/IdentityMessageTemplate.php | 114 +
app/Models/IdentityPolicy.php | 120 +
app/Models/IdentityVerificationLog.php | 112 +
app/Models/LanguagePack.php | 248 ++
app/Models/Menu.php | 7 +
app/Models/Module.php | 6 +
app/Models/NotificationDefinition.php | 11 +-
app/Models/Permission.php | 13 +
app/Models/Plugin.php | 6 +
app/Models/Role.php | 10 +
app/Models/Template.php | 6 +
app/Models/User.php | 13 +
app/Providers/AppServiceProvider.php | 28 +
app/Providers/CoreServiceProvider.php | 151 +-
.../InstallerRuntimeServiceProvider.php | 117 +
app/Providers/LanguagePackServiceProvider.php | 516 ++++
app/Repositories/ActivityLogRepository.php | 11 +
.../IdentityMessageDefinitionRepository.php | 189 ++
.../IdentityMessageTemplateRepository.php | 98 +
app/Repositories/IdentityPolicyRepository.php | 283 ++
.../IdentityVerificationLogRepository.php | 223 ++
app/Repositories/LanguagePackRepository.php | 382 +++
.../LanguagePackTranslationRepository.php | 467 +++
app/Repositories/ModuleRepository.php | 18 +
app/Repositories/PluginRepository.php | 20 +
app/Repositories/ScheduleRepository.php | 11 +
app/Repositories/TemplateRepository.php | 37 +
app/Repositories/UserRepository.php | 88 +
app/Rules/IdvTokenRule.php | 38 +
app/Seo/BotDetector.php | 45 +-
app/Seo/BotDetectorCustomProvider.php | 77 +
app/Seo/Concerns/LocalizesSeoValues.php | 58 +
app/Seo/Concerns/SubstitutesSeoVariables.php | 67 +
.../Contracts/SeoCacheManagerInterface.php | 12 +
app/Seo/ExpressionEvaluator.php | 12 +-
app/Seo/SeoMetaResolver.php | 397 ++-
app/Seo/SeoMiddleware.php | 21 +
app/Seo/SeoRenderer.php | 254 +-
app/Seo/SitemapManager.php | 112 +
app/Services/AttachmentService.php | 3 +
app/Services/AuthService.php | 77 +-
app/Services/CoreUpdateService.php | 704 ++++-
app/Services/DashboardService.php | 33 +-
app/Services/DriverRegistryService.php | 95 +-
.../ExtensionInstallPreviewBuilder.php | 201 ++
.../ExtensionCompatibilityAlertService.php | 99 +
app/Services/IdentityLogService.php | 55 +
.../IdentityMessageDefinitionService.php | 287 ++
app/Services/IdentityMessageDispatcher.php | 124 +
app/Services/IdentityMessageResolver.php | 71 +
.../IdentityMessageTemplateService.php | 307 ++
app/Services/IdentityPolicyService.php | 523 ++++
app/Services/IdentityVerificationService.php | 208 ++
.../LanguagePack/LanguagePackBaseLocales.php | 42 +
.../LanguagePackBundledRegistrar.php | 365 +++
.../LanguagePackManifestValidator.php | 292 ++
.../LanguagePack/LanguagePackRegistry.php | 160 ++
.../LanguagePack/LanguagePackSeedInjector.php | 690 +++++
.../LanguagePack/LanguagePackTranslator.php | 165 ++
app/Services/LanguagePackService.php | 1758 ++++++++++++
app/Services/LayoutPreviewService.php | 10 +-
app/Services/LayoutService.php | 7 +
app/Services/MenuService.php | 16 +
app/Services/ModuleService.php | 97 +-
app/Services/NotificationChannelService.php | 15 +-
.../NotificationRecipientResolver.php | 34 +
app/Services/NotificationTemplateService.php | 12 +-
app/Services/PluginService.php | 138 +-
app/Services/SettingsService.php | 145 +-
app/Services/TemplateService.php | 170 +-
app/Services/UserService.php | 14 +-
.../AssertsIdentityPolicyDeclaration.php | 143 +
bootstrap/app.php | 28 +
bootstrap/providers.php | 2 +
composer.json | 2 +
composer.lock | 54 +-
config/app.php | 65 +-
config/core.php | 557 +++-
config/database.php | 10 +-
config/notification.php | 13 +-
config/settings/defaults.json | 46 +-
database/factories/MenuFactory.php | 12 +-
.../TemplateLayoutVersionFactory.php | 15 +-
database/factories/UserFactory.php | 6 +
...00_add_identity_columns_to_users_table.php | 65 +
...reate_identity_verification_logs_table.php | 82 +
..._000102_create_identity_policies_table.php | 47 +
..._27_000001_create_language_packs_table.php | 68 +
..._comment_to_identity_verification_logs.php | 49 +
...ate_identity_message_definitions_table.php | 57 +
...reate_identity_message_templates_table.php | 53 +
...d_login_attempt_columns_to_users_table.php | 47 +
...dd_deactivated_reason_to_plugins_table.php | 62 +
...dd_deactivated_reason_to_modules_table.php | 62 +
..._deactivated_reason_to_templates_table.php | 62 +
.../LoadsConfigSeedWithLangPackFilter.php | 37 +
database/seeders/CoreAdminMenuSeeder.php | 4 +
database/seeders/DatabaseSeeder.php | 3 +
.../IdentityMessageDefinitionSeeder.php | 113 +
database/seeders/IdentityPolicySeeder.php | 52 +
.../seeders/NotificationDefinitionSeeder.php | 233 +-
database/seeders/RolePermissionSeeder.php | 12 +
.../Sample/IdentityVerificationLogSeeder.php | 54 +
.../seeders/Sample/NotificationLogSeeder.php | 127 +-
docs/README.md | 29 +-
docs/ai-tools/README.md | 2 +-
docs/ai-tools/agents/README.md | 2 +-
.../agents/src/coordinator/Coordinator.ts | 4 +-
.../agents/src/mcp/g7-tools-server.ts | 4 +-
.../{validate-code.ts => validate-backend.ts} | 0
.../agents/src/workflows/pr-review.ts | 2 +-
docs/ai-tools/skills/create-module.md | 10 +
docs/ai-tools/skills/create-plugin.md | 10 +
docs/ai-tools/skills/create-template.md | 10 +
.../{validate-code.md => validate-backend.md} | 10 +-
docs/ai-tools/skills/validate-frontend.md | 13 +-
docs/ai-tools/skills/validate-hook.md | 4 +-
docs/ai-tools/skills/validate-i18n.md | 25 +-
docs/ai-tools/skills/validate-migration.md | 4 +-
docs/backend/README.md | 9 +
docs/backend/activity-log.md | 17 +
docs/backend/admin-settings-access.md | 109 +
docs/backend/api-resources.md | 1 +
docs/backend/console-confirm.md | 279 ++
docs/backend/core-update-system.md | 14 +-
docs/backend/data-sync-helpers.md | 31 +-
docs/backend/dto.md | 255 ++
docs/backend/identity-messages.md | 287 ++
docs/backend/identity-policies.md | 284 ++
docs/backend/identity-providers.md | 189 ++
docs/backend/language-pack-service.md | 172 ++
docs/backend/notification-system.md | 179 +-
docs/backend/seo-system.md | 210 +-
docs/backend/service-repository.md | 102 +
.../settings-multilingual-enrichment.md | 140 +
docs/backend/translatable-seeders.md | 109 +
docs/backend/user-overrides.md | 43 +-
docs/cheatsheet.md | 29 +
docs/database-guide.md | 38 +
docs/extension/README.md | 8 +-
docs/extension/extension-manager.md | 86 +-
docs/extension/hooks.md | 95 +-
docs/extension/language-packs.md | 457 +++
docs/extension/menus.md | 4 +-
docs/extension/module-basics.md | 101 +
docs/extension/module-identity-settings.md | 388 +++
docs/extension/plugin-development.md | 10 +
docs/extension/sample-extensions.md | 290 ++
docs/extension/template-idv-bootstrap.md | 251 ++
docs/extension/template-workflow.md | 10 +
docs/extension/upgrade-step-guide.md | 112 +-
docs/frontend/README.md | 7 +
docs/frontend/auth-system.md | 88 +
docs/frontend/component-props-composite.md | 17 +
docs/frontend/components-types.md | 2 +-
docs/frontend/data-binding.md | 30 +
docs/frontend/identity-guard-interceptor.md | 159 ++
docs/frontend/identity-verification-ui.md | 302 ++
docs/frontend/layout-json-features-actions.md | 40 +
docs/frontend/layout-json-inheritance.md | 48 +
docs/frontend/modal-usage.md | 108 +-
docs/frontend/template-development.md | 39 +
docs/requirements.md | 22 +-
docs/testing-guide.md | 143 +-
lang-packs/_bundled/g7-core-ja/CHANGELOG.md | 11 +
.../g7-core-ja/backend/ja/activity_log.php | 233 ++
.../g7-core-ja/backend/ja/admin_layout.php | 93 +
.../g7-core-ja/backend/ja/attachment.php | 57 +
.../_bundled/g7-core-ja/backend/ja/auth.php | 39 +
.../_bundled/g7-core-ja/backend/ja/common.php | 33 +
.../g7-core-ja/backend/ja/dashboard.php | 19 +
.../_bundled/g7-core-ja/backend/ja/errors.php | 30 +
.../g7-core-ja/backend/ja/exceptions.php | 64 +
.../backend/ja/extension_owner_type.php | 7 +
.../g7-core-ja/backend/ja/extensions.php | 57 +
.../g7-core-ja/backend/ja/identity.php | 106 +
.../backend/ja/identity_message.php | 27 +
.../g7-core-ja/backend/ja/language_packs.php | 61 +
.../backend/ja/layout_extension.php | 6 +
.../g7-core-ja/backend/ja/layouts.php | 11 +
.../g7-core-ja/backend/ja/maintenance.php | 7 +
.../_bundled/g7-core-ja/backend/ja/menu.php | 34 +
.../_bundled/g7-core-ja/backend/ja/module.php | 41 +
.../g7-core-ja/backend/ja/modules.php | 203 ++
.../_bundled/g7-core-ja/backend/ja/nav.php | 16 +
.../g7-core-ja/backend/ja/notification.php | 72 +
.../backend/ja/notification_log.php | 11 +
.../g7-core-ja/backend/ja/permission.php | 10 +
.../g7-core-ja/backend/ja/plugins.php | 228 ++
.../_bundled/g7-core-ja/backend/ja/role.php | 27 +
.../g7-core-ja/backend/ja/schedule.php | 103 +
.../_bundled/g7-core-ja/backend/ja/search.php | 17 +
.../_bundled/g7-core-ja/backend/ja/seo.php | 26 +
.../g7-core-ja/backend/ja/settings.php | 148 +
.../g7-core-ja/backend/ja/templates.php | 284 ++
.../_bundled/g7-core-ja/backend/ja/theme.php | 26 +
.../_bundled/g7-core-ja/backend/ja/themes.php | 16 +
.../_bundled/g7-core-ja/backend/ja/user.php | 126 +
.../g7-core-ja/backend/ja/validation.php | 819 ++++++
.../_bundled/g7-core-ja/backend/ja/vendor.php | 24 +
.../_bundled/g7-core-ja/language-pack.json | 30 +
.../g7-core-ja/seed/identity_messages.json | 62 +
.../_bundled/g7-core-ja/seed/menus.json | 38 +
.../g7-core-ja/seed/notifications.json | 50 +
.../_bundled/g7-core-ja/seed/permissions.json | 330 +++
.../_bundled/g7-core-ja/seed/roles.json | 18 +
.../CHANGELOG.md | 11 +
.../backend/ja/messages.php | 15 +
.../backend/ja/validation.php | 11 +
.../language-pack.json | 30 +
.../seed/manifest.json | 4 +
.../seed/menus.json | 5 +
.../seed/permissions.json | 26 +
.../g7-module-sirsoft-board-ja/CHANGELOG.md | 11 +
.../backend/ja/activity_log.php | 83 +
.../backend/ja/admin.php | 10 +
.../backend/ja/enums.php | 53 +
.../backend/ja/messages.php | 254 ++
.../backend/ja/notification.php | 62 +
.../backend/ja/validation.php | 470 +++
.../frontend/ja.json | 49 +
.../frontend/partial/admin.json | 1230 ++++++++
.../frontend/partial/admin/board.json | 51 +
.../frontend/partial/admin/board_types.json | 19 +
.../partial/admin/ecommerce_settings.json | 15 +
.../frontend/partial/admin/form.json | 222 ++
.../frontend/partial/admin/modals.json | 26 +
.../frontend/partial/admin/posts.json | 284 ++
.../frontend/partial/admin/reports.json | 243 ++
.../frontend/partial/admin/settings.json | 440 +++
.../frontend/partial/admin/users.json | 13 +
.../frontend/partial/attributes.json | 84 +
.../frontend/partial/board.json | 55 +
.../frontend/partial/common.json | 24 +
.../frontend/partial/enums.json | 29 +
.../frontend/partial/messages.json | 70 +
.../frontend/partial/report_types.json | 10 +
.../frontend/partial/validation.json | 124 +
.../language-pack.json | 30 +
.../seed/board_types.json | 11 +
.../seed/manifest.json | 4 +
.../seed/menus.json | 14 +
.../seed/notifications.json | 114 +
.../seed/permissions.json | 62 +
.../CHANGELOG.md | 11 +
.../backend/ja/activity_log.php | 234 ++
.../backend/ja/enums.php | 239 ++
.../backend/ja/exceptions.php | 54 +
.../backend/ja/identity.php | 10 +
.../backend/ja/messages.php | 1073 +++++++
.../backend/ja/review.php | 5 +
.../backend/ja/settings.php | 110 +
.../backend/ja/validation.php | 1458 ++++++++++
.../frontend/ja.json | 100 +
.../frontend/partial/admin/brand.json | 72 +
.../frontend/partial/admin/category.json | 87 +
.../frontend/partial/admin/common_info.json | 89 +
.../frontend/partial/admin/deposit.json | 124 +
.../partial/admin/excel_download.json | 69 +
.../partial/admin/extra_fee_template.json | 119 +
.../frontend/partial/admin/locale.json | 3 +
.../frontend/partial/admin/main_banner.json | 97 +
.../admin/mileage_deposit_settings.json | 84 +
.../frontend/partial/admin/order.json | 763 +++++
.../partial/admin/order_settings.json | 51 +
.../admin/payment_failure_history.json | 98 +
.../partial/admin/personal_payment.json | 77 +
.../admin/personal_payment_create.json | 61 +
.../admin/personal_payment_detail.json | 66 +
.../frontend/partial/admin/product.json | 1181 ++++++++
.../admin/product_notice_template.json | 111 +
.../partial/admin/product_review.json | 161 ++
.../partial/admin/promotion_coupon.json | 364 +++
.../admin/promotion_discount_code.json | 172 ++
.../frontend/partial/admin/settings.json | 520 ++++
.../partial/admin/shipping_policy.json | 326 +++
.../frontend/partial/category.json | 5 +
.../frontend/partial/common.json | 51 +
.../frontend/partial/currency.json | 3 +
.../frontend/partial/enums.json | 195 ++
.../frontend/partial/exceptions.json | 9 +
.../frontend/partial/messages.json | 85 +
.../frontend/partial/order.json | 16 +
.../frontend/partial/product.json | 13 +
.../frontend/partial/shop.json | 241 ++
.../frontend/partial/user.json | 35 +
.../frontend/partial/validation.json | 238 ++
.../language-pack.json | 30 +
.../seed/claim_reasons.json | 23 +
.../seed/identity_messages.json | 14 +
.../seed/manifest.json | 4 +
.../seed/menus.json | 35 +
.../seed/notifications.json | 114 +
.../seed/permissions.json | 262 ++
.../seed/roles.json | 6 +
.../seed/shipping_carriers.json | 38 +
.../seed/shipping_types.json | 35 +
.../g7-module-sirsoft-page-ja/CHANGELOG.md | 11 +
.../backend/ja/activity_log.php | 32 +
.../backend/ja/messages.php | 39 +
.../backend/ja/validation.php | 65 +
.../frontend/ja.json | 5 +
.../frontend/partial/admin.json | 166 ++
.../language-pack.json | 30 +
.../seed/manifest.json | 4 +
.../g7-module-sirsoft-page-ja/seed/menus.json | 5 +
.../seed/permissions.json | 26 +
.../CHANGELOG.md | 11 +
.../backend/ja/messages.php | 16 +
.../language-pack.json | 30 +
.../seed/manifest.json | 4 +
.../CHANGELOG.md | 11 +
.../backend/ja/channels.php | 7 +
.../backend/ja/messages.php | 9 +
.../language-pack.json | 30 +
.../seed/manifest.json | 4 +
.../CHANGELOG.md | 11 +
.../backend/ja/messages.php | 8 +
.../backend/ja/provider.php | 6 +
.../language-pack.json | 30 +
.../seed/manifest.json | 4 +
.../CHANGELOG.md | 11 +
.../frontend/ja.json | 43 +
.../language-pack.json | 30 +
.../seed/manifest.json | 4 +
.../CHANGELOG.md | 11 +
.../frontend/ja.json | 52 +
.../language-pack.json | 30 +
.../seed/manifest.json | 4 +
.../CHANGELOG.md | 11 +
.../frontend/ja.json | 26 +
.../frontend/partial/admin.json | 2536 +++++++++++++++++
.../frontend/partial/attachment.json | 16 +
.../frontend/partial/auth.json | 45 +
.../frontend/partial/common.json | 78 +
.../frontend/partial/countries.json | 50 +
.../frontend/partial/errors.json | 33 +
.../frontend/partial/extensions.json | 35 +
.../frontend/partial/nav.json | 3 +
.../language-pack.json | 30 +
.../seed/manifest.json | 4 +
.../g7-template-sirsoft-basic-ja/CHANGELOG.md | 11 +
.../frontend/ja.json | 62 +
.../frontend/partial/attachment.json | 13 +
.../frontend/partial/auth.json | 145 +
.../frontend/partial/board.json | 231 ++
.../frontend/partial/common.json | 57 +
.../frontend/partial/countries.json | 50 +
.../frontend/partial/editor.json | 18 +
.../frontend/partial/error.json | 36 +
.../frontend/partial/footer.json | 14 +
.../frontend/partial/home.json | 38 +
.../frontend/partial/languages.json | 4 +
.../frontend/partial/mypage.json | 443 +++
.../frontend/partial/nav.json | 13 +
.../frontend/partial/policy.json | 142 +
.../frontend/partial/search.json | 51 +
.../frontend/partial/shop.json | 539 ++++
.../frontend/partial/sirsoft-basic.json | 7 +
.../frontend/partial/timezones.json | 9 +
.../frontend/partial/upload.json | 8 +
.../frontend/partial/user.json | 92 +
.../frontend/partial/userinfo.json | 7 +
.../language-pack.json | 30 +
.../seed/manifest.json | 4 +
lang-packs/_pending/.gitignore | 3 +
lang-packs/_pending/.gitkeep | 0
lang/en/activity_log.php | 43 +-
lang/en/auth.php | 2 +
lang/en/common.php | 1 +
lang/en/dashboard.php | 8 -
lang/en/exceptions.php | 3 +
lang/en/extensions.php | 32 +
lang/en/identity.php | 114 +
lang/en/identity_message.php | 30 +
lang/en/language_packs.php | 63 +
lang/en/module.php | 24 +-
lang/en/modules.php | 25 +-
lang/en/notification.php | 13 +
lang/en/plugins.php | 55 +-
lang/en/seo.php | 5 +
lang/en/settings.php | 36 +
lang/en/templates.php | 55 +-
lang/en/user.php | 1 -
lang/en/validation.php | 80 +
lang/ko/activity_log.php | 43 +-
lang/ko/auth.php | 2 +
lang/ko/common.php | 1 +
lang/ko/dashboard.php | 8 -
lang/ko/exceptions.php | 3 +
lang/ko/extensions.php | 32 +
lang/ko/identity.php | 114 +
lang/ko/identity_message.php | 30 +
lang/ko/language_packs.php | 63 +
lang/ko/module.php | 24 +-
lang/ko/modules.php | 25 +-
lang/ko/notification.php | 13 +
lang/ko/plugins.php | 55 +-
lang/ko/seo.php | 5 +
lang/ko/settings.php | 36 +
lang/ko/templates.php | 61 +-
lang/ko/user.php | 1 -
lang/ko/validation.php | 80 +
.../gnuboard7-hello_module/CHANGELOG.md | 15 +
.../gnuboard7-hello_module/composer.json | 23 +
.../config/settings/defaults.json | 18 +
.../database/factories/MemoFactory.php | 36 +
...ate_gnuboard7_hello_module_memos_table.php | 41 +
.../database/seeders/DatabaseSeeder.php | 44 +
.../database/seeders/MemoSeeder.php | 42 +
.../seeders/Sample/MemoSampleSeeder.php | 22 +
.../gnuboard7-hello_module/module.json | 24 +
.../gnuboard7-hello_module/module.php | 163 ++
.../resources/lang/en.json | 29 +
.../resources/lang/ko.json | 29 +
.../layouts/admin/admin_memo_form.json | 216 ++
.../layouts/admin/admin_memo_list.json | 136 +
.../layouts/user/user_memo_list.json | 97 +
.../resources/routes.json | 37 +
.../Http/Controllers/Admin/MemoController.php | 139 +
.../Http/Controllers/Api/MemoController.php | 72 +
.../Http/Requests/Admin/StoreMemoRequest.php | 52 +
.../Http/Requests/Admin/UpdateMemoRequest.php | 50 +
.../src/Http/Resources/MemoCollection.php | 56 +
.../src/Http/Resources/MemoResource.php | 44 +
.../src/Listeners/LogMemoCreatedListener.php | 63 +
.../src/Models/Memo.php | 57 +
.../Providers/HelloModuleServiceProvider.php | 31 +
.../Contracts/MemoRepositoryInterface.php | 63 +
.../src/Repositories/MemoRepository.php | 86 +
.../src/Services/MemoService.php | 89 +
.../src/lang/en/messages.php | 15 +
.../src/lang/en/validation.php | 11 +
.../src/lang/ko/messages.php | 21 +
.../src/lang/ko/validation.php | 17 +
.../gnuboard7-hello_module/src/routes/api.php | 23 +
.../gnuboard7-hello_module/src/routes/web.php | 43 +
.../Feature/Admin/MemoControllerTest.php | 147 +
.../tests/Feature/HookIntegrationTest.php | 75 +
.../tests/FeatureTestCase.php | 22 +
.../tests/ModuleTestCase.php | 294 ++
.../tests/Unit/Services/MemoServiceTest.php | 81 +
modules/_bundled/sirsoft-board/CHANGELOG.md | 37 +
modules/_bundled/sirsoft-board/composer.json | 2 +-
.../config/settings/defaults.json | 4 +-
.../BoardNotificationDefinitionSeeder.php | 483 ----
.../database/seeders/BoardTypeSeeder.php | 59 +-
.../database/seeders/DatabaseSeeder.php | 24 +-
.../database/seeders/InstallSeeder.php | 25 -
.../Sample/IdentityVerificationLogSeeder.php | 54 +
.../seeders/Sample/NotificationLogSeeder.php | 88 +
modules/_bundled/sirsoft-board/module.json | 4 +-
modules/_bundled/sirsoft-board/module.php | 714 ++++-
.../_bundled/sirsoft-board/package-lock.json | 4 +-
modules/_bundled/sirsoft-board/package.json | 2 +-
...ard-post-detail-count-consistency.test.tsx | 116 +
...admin-board-post-form-attachments.test.tsx | 5 +-
.../admin-board-settings-modal-perf.test.tsx | 114 +
.../layouts/admin-board-settings.test.tsx | 164 +-
.../board-index-category-filter.test.tsx | 1095 ++-----
.../layouts/board-index-empty-states.test.tsx | 857 +-----
.../layouts/board-locale-fallback.test.ts | 36 +
.../layouts/boardUserAbilitiesLayout.test.ts | 12 -
.../layouts/identityPolicyDeleteModal.test.ts | 81 +
.../identityPolicySourceIdentifier.test.ts | 54 +
.../lang/partial/en/admin/settings.json | 49 +-
.../lang/partial/ko/admin/settings.json | 49 +-
.../admin/admin_board_post_detail.json | 4 +-
.../layouts/admin/admin_board_settings.json | 55 +
.../_board_type_manage_modal.json | 8 +-
.../partials/admin_board_form/_tab_basic.json | 2 +-
.../admin_board_post_detail/_comments.json | 6 +-
.../_post_card_content.json | 348 +--
.../_reply_card_content.json | 4 +-
.../_modal_identity_policy_delete.json | 140 +
.../_modal_identity_policy_form.json | 662 +++++
.../_modal_notification_template_edit.json | 264 +-
.../_modal_notification_template_preview.json | 4 +-
.../_tab_basic_defaults.json | 2 +-
.../_tab_board_settings_attachment.json | 12 +-
.../_tab_board_settings_basic.json | 90 +-
.../_tab_board_settings_comment.json | 15 +-
.../_tab_board_settings_list.json | 88 +-
.../_tab_board_settings_notification.json | 44 +-
.../_tab_board_settings_permissions.json | 120 +-
.../_tab_board_settings_post.json | 18 +-
.../_tab_board_settings_reply.json | 44 +-
.../_tab_identity_policies.json | 1144 ++++++++
.../_tab_notification_definitions.json | 41 +-
.../_tab_report_policy.json | 55 +
.../Admin/StoreBoardSettingsRequest.php | 4 +
.../src/Http/Resources/PostResource.php | 1 +
.../ActivityLogDescriptionResolver.php | 14 +-
.../Listeners/BoardActivityLogListener.php | 10 +-
.../BoardCommentsCountSyncListener.php | 22 +-
.../BoardNotificationDataListener.php | 26 +-
.../Listeners/BoardPostsCountSyncListener.php | 24 +-
.../Listeners/CommentReplySyncListener.php | 22 +-
.../PostAttachmentCountSyncListener.php | 20 +-
.../src/Listeners/PostCountSyncListener.php | 22 +-
.../src/Listeners/PostReplySyncListener.php | 24 +-
.../sirsoft-board/src/Models/Board.php | 6 +
.../sirsoft-board/src/Models/BoardType.php | 7 +
.../sirsoft-board/src/Models/Comment.php | 2 +
.../sirsoft-board/src/Models/Post.php | 6 +
.../src/Repositories/BoardRepository.php | 28 +
.../src/Repositories/CommentRepository.php | 14 +
.../Contracts/BoardRepositoryInterface.php | 18 +
.../Contracts/CommentRepositoryInterface.php | 10 +
.../Contracts/PostRepositoryInterface.php | 26 +
.../Contracts/ReportRepositoryInterface.php | 8 +
.../src/Repositories/PostRepository.php | 57 +-
.../src/Repositories/ReportRepository.php | 15 +
.../src/lang/en/activity_log.php | 18 +
.../src/lang/ko/activity_log.php | 18 +
.../Feature/ActivityLogActionLabelTest.php | 55 +
.../Admin/BoardSettingsControllerTest.php | 65 +
.../BoardPermissionScopeServiceTest.php | 10 +-
.../BoardIdentityPolicyDeclarationTest.php | 328 +++
.../BoardLanguagePackSeederTriggerTest.php | 139 +
.../PostRepositoryUserActivitiesKeyTest.php | 86 +
.../tests/Feature/ReportNotificationTest.php | 47 +-
.../Seo/PostSeoOgImageRenderingTest.php | 133 +
.../User/AttachmentPreviewThrottleTest.php | 36 +-
.../Feature/User/BoardUserAbilitiesTest.php | 32 +-
.../Feature/User/PostNavigationApiTest.php | 13 +-
.../Feature/User/UserActivityApiTest.php | 16 +-
.../Feature/User/UserPublicPostsApiTest.php | 26 +-
.../sirsoft-board/tests/ModuleTestCase.php | 132 +-
.../tests/Unit/AttachmentServiceTest.php | 8 +
.../tests/Unit/BoardModuleSeoTest.php | 62 +
.../tests/Unit/BoardSettingsServiceTest.php | 12 +-
.../IdentityVerificationLogSeederTest.php | 95 +
.../Sample/NotificationLogSeederTest.php | 98 +
.../BoardActivityLogListenerTest.php | 2 +-
.../BoardNotificationChannelListenerTest.php | 2 +-
.../BoardNotificationDataListenerTest.php | 12 +-
.../Unit/Listeners/CountSyncListenerTest.php | 28 +-
.../Unit/Models/CountColumnsCastTest.php | 160 ++
.../tests/Unit/ReportRepositoryTest.php | 6 +
.../PostResourceAttachmentCountTest.php | 124 +
.../Unit/Traits/FormatsBoardDateTest.php | 14 +-
.../BoardUserOverridesSubKeyMigrationTest.php | 92 +
.../upgrades/Upgrade_1_0_0_beta_2.php | 50 +-
.../upgrades/Upgrade_1_0_0_beta_4.php | 119 +
.../_bundled/sirsoft-ecommerce/CHANGELOG.md | 33 +
.../_bundled/sirsoft-ecommerce/composer.json | 2 +-
.../database/seeders/ClaimReasonSeeder.php | 114 +-
.../database/seeders/DatabaseSeeder.php | 7 +-
.../EcommerceNotificationDefinitionSeeder.php | 580 ----
.../Sample/IdentityVerificationLogSeeder.php | 54 +
.../seeders/Sample/NotificationLogSeeder.php | 91 +
.../database/seeders/SequenceSeeder.php | 9 +-
.../seeders/ShippingCarrierSeeder.php | 95 +-
.../database/seeders/ShippingTypeSeeder.php | 157 +-
.../database/seeders/TestingSeeder.php | 32 +-
.../sirsoft-ecommerce/dist/js/module.iife.js | 2 +-
.../dist/js/module.iife.js.map | 2 +-
.../_bundled/sirsoft-ecommerce/module.json | 4 +-
modules/_bundled/sirsoft-ecommerce/module.php | 837 +++++-
.../sirsoft-ecommerce/package-lock.json | 8 +-
.../_bundled/sirsoft-ecommerce/package.json | 2 +-
.../handlers/userReviewHandlers.test.ts | 11 +-
...min-ecommerce-settings-modal-perf.test.tsx | 114 +
.../adminEcommerceSettingsOrder.test.tsx | 266 +-
.../adminEcommerceSettingsSavePerTab.test.tsx | 90 +-
.../admin_ecommerce_product_notice.test.tsx | 23 +-
...in_ecommerce_product_review_index.test.tsx | 3 +-
...min_ecommerce_settings_review_tab.test.tsx | 14 +-
...in_ecommerce_shipping_policy_list.test.tsx | 9 +-
.../layouts/cancelOrderModal.test.tsx | 189 +-
.../__tests__/layouts/cartAddedModal.test.tsx | 9 +-
.../layouts/catalogLangPackFallback.test.tsx | 121 +
.../checkoutExclusiveCouponLayouts.test.tsx | 4 +-
.../layouts/checkoutGuestLayouts.test.tsx | 4 +-
.../layouts/checkoutItemsLayouts.test.tsx | 4 +-
.../layouts/checkoutLayouts.test.tsx | 4 +-
.../layouts/checkoutSummaryLayouts.test.tsx | 4 +-
.../__tests__/layouts/couponDownload.test.tsx | 12 +-
.../layouts/couponFormLayouts.test.tsx | 59 +-
.../layouts/ecommerceLocaleFallback.test.ts | 58 +
.../layouts/ecommerceSettingsSeoTab.test.tsx | 22 +-
.../layouts/identityPolicyDeleteModal.test.ts | 85 +
.../identityPolicySourceIdentifier.test.ts | 59 +
.../marketing-extension-point.test.tsx | 36 +-
.../__tests__/layouts/mypageOrders.test.tsx | 36 +-
.../layouts/mypageUserAbilities.test.tsx | 24 +-
.../layouts/orderDetailLayouts.test.tsx | 42 +-
.../layouts/orderListLayouts.test.tsx | 8 +-
.../layouts/productFormLayouts.test.tsx | 171 +-
.../layouts/productGridLayout.test.tsx | 2 +-
.../layouts/productListLayout.test.tsx | 4 +-
.../layouts/shippingCarrierSection.test.tsx | 20 +-
.../shippingPolicyFormLayouts.test.tsx | 25 +-
.../shippingPolicyListLayouts.test.tsx | 26 +-
.../shippingPolicySettingsTab.test.tsx | 147 +-
.../layouts/showInitActions.test.tsx | 2 +-
.../layouts/tabDetailLayout.test.tsx | 18 +-
.../js/__tests__/layouts/wishlist.test.tsx | 224 +-
.../handlers/__tests__/labelHandlers.test.ts | 212 +-
.../handlers/__tests__/optionHandlers.test.ts | 1 -
.../__tests__/orderDetailHandlers.test.ts | 3 +-
.../js/handlers/descriptionLocaleHandlers.ts | 7 +-
.../lang/partial/en/admin/settings.json | 40 +-
.../lang/partial/ko/admin/settings.json | 40 +-
.../admin/admin_ecommerce_order_list.json | 6 +-
.../admin/admin_ecommerce_settings.json | 55 +
.../admin_ecommerce_shipping_policy_form.json | 2 +-
.../_partial_banner_detail.json | 67 +-
.../_partial_banner_form.json | 67 +-
.../_partial_preview_slider.json | 9 +-
.../_partial_common_info_detail.json | 29 +-
.../_partial_common_info_form.json | 61 +-
.../_partial_product_datagrid.json | 9 +-
.../_currency_exchange_cards.json | 30 +-
.../_currency_exchange_table.json | 30 +-
.../_modal_identity_policy_delete.json | 140 +
.../_modal_identity_policy_form.json | 662 +++++
.../_modal_notification_template_edit.json | 264 +-
.../_modal_notification_template_preview.json | 4 +-
.../_tab_identity_policies.json | 1144 ++++++++
.../_tab_language_currency.json | 21 +-
.../_tab_notification_definitions.json | 1384 ++++-----
.../_partial_filter.json | 2 +-
.../Http/Controllers/User/OrderController.php | 2 +-
.../Admin/StoreShippingPolicyRequest.php | 22 +-
.../src/Http/Resources/OrderListResource.php | 2 +-
.../Http/Resources/OrderOptionResource.php | 27 +-
.../Http/Resources/OrderShippingResource.php | 2 +-
.../Http/Resources/ProductInquiryResource.php | 2 +-
.../Http/Resources/ProductReviewResource.php | 2 +-
.../Http/Resources/PublicProductResource.php | 2 +-
.../Resources/ShippingPolicyCollection.php | 2 +-
.../ShippingPolicyCountrySettingResource.php | 2 +-
.../ActivityLogDescriptionResolver.php | 6 +-
.../Listeners/CouponActivityLogListener.php | 10 +-
.../EcommerceAdminActivityLogListener.php | 20 +-
.../EcommerceNotificationDataListener.php | 18 +-
.../EcommerceUserActivityLogListener.php | 17 +-
.../Listeners/OrderActivityLogListener.php | 21 +-
.../Listeners/ProductActivityLogListener.php | 14 +-
.../Listeners/SyncOptionGroupsListener.php | 19 +-
.../SyncProductFromOptionListener.php | 39 +-
.../sirsoft-ecommerce/src/Models/Brand.php | 19 +-
.../sirsoft-ecommerce/src/Models/Category.php | 2 +-
.../src/Models/CategoryImage.php | 19 +-
.../src/Models/ClaimReason.php | 9 +-
.../sirsoft-ecommerce/src/Models/Product.php | 79 +-
.../src/Models/ProductAdditionalOption.php | 2 +-
.../src/Models/ProductCommonInfo.php | 4 +-
.../src/Models/ProductImage.php | 22 +-
.../src/Models/ProductLabel.php | 2 +-
.../src/Models/ProductNoticeTemplate.php | 2 +-
.../src/Models/ProductOption.php | 10 +-
.../src/Models/ShippingCarrier.php | 30 +-
.../src/Models/ShippingPolicy.php | 48 +-
.../src/Models/ShippingType.php | 9 +-
.../Contracts/CouponRepositoryInterface.php | 8 +
.../ExtraFeeTemplateRepositoryInterface.php | 8 +
.../OrderOptionRepositoryInterface.php | 8 +
.../Contracts/OrderRepositoryInterface.php | 8 +
.../ProductOptionRepositoryInterface.php | 39 +
.../Contracts/ProductRepositoryInterface.php | 17 +
.../ShippingPolicyRepositoryInterface.php | 8 +
.../src/Repositories/CouponRepository.php | 15 +
.../ExtraFeeTemplateRepository.php | 15 +
.../Repositories/OrderOptionRepository.php | 15 +
.../src/Repositories/OrderRepository.php | 15 +
.../ProductNoticeTemplateRepository.php | 66 +-
.../Repositories/ProductOptionRepository.php | 63 +
.../src/Repositories/ProductRepository.php | 27 +
.../Repositories/ShippingPolicyRepository.php | 15 +
.../src/Services/CheckoutDataService.php | 10 +-
.../src/Services/EcommerceSettingsService.php | 54 +-
.../src/Services/OrderProcessingService.php | 4 +-
.../src/Services/PaymentService.php | 26 +-
.../src/Services/ProductInquiryService.php | 2 +-
.../src/Services/ProductService.php | 104 +-
.../src/Services/StockService.php | 8 +
.../src/Services/UserAddressService.php | 28 +-
.../src/lang/en/activity_log.php | 42 +
.../src/lang/en/identity.php | 10 +
.../src/lang/en/messages.php | 3 +
.../src/lang/en/settings.php | 86 +
.../src/lang/ko/activity_log.php | 43 +
.../src/lang/ko/identity.php | 10 +
.../src/lang/ko/messages.php | 3 +
.../src/lang/ko/settings.php | 85 +
.../Feature/ActivityLogActionLabelTest.php | 69 +
.../AdminProductInquiryControllerTest.php | 22 +-
.../Admin/CouponIssuesListTest.php | 3 +-
.../Admin/CouponListCreatorTest.php | 10 +-
.../Admin/OrderCancellationControllerTest.php | 36 +-
.../Admin/ProductCommonInfoControllerTest.php | 3 +
.../ProductNoticeTemplateControllerTest.php | 14 +-
.../Admin/ShippingPolicyControllerTest.php | 3 +
.../Public/PublicProductControllerTest.php | 37 +-
.../PublicProductInquiryControllerTest.php | 20 +-
.../User/OrderCancellationControllerTest.php | 35 +-
.../User/UserCouponControllerTest.php | 8 +-
.../User/UserMileageControllerTest.php | 6 +-
.../User/UserOrderControllerTest.php | 8 +-
.../User/UserProductInquiryControllerTest.php | 26 +-
.../CheckoutVerificationGuardTest.php | 122 +
...EcommerceIdentityPolicyDeclarationTest.php | 387 +++
...EcommerceLanguagePackSeederTriggerTest.php | 197 ++
.../Search/ProductSearchIntegrationTest.php | 110 +-
.../tests/ModuleTestCase.php | 131 +
.../CancelPendingPaymentOrdersCommandTest.php | 16 +
.../IdentityVerificationLogSeederTest.php | 104 +
.../Sample/NotificationLogSeederTest.php | 135 +
.../Database/Seeders/SequenceSeederTest.php | 41 +-
.../tests/Unit/EcommerceModuleSeoTest.php | 97 +
.../CategoryActivityLogListenerTest.php | 11 +-
.../CouponActivityLogListenerTest.php | 12 +-
.../EcommerceAdminActivityLogListenerTest.php | 34 +-
.../EcommerceUserActivityLogListenerTest.php | 2 +-
.../OrderActivityLogListenerTest.php | 2 +-
.../ProductActivityLogListenerTest.php | 2 +-
.../SyncOptionGroupsListenerTest.php | 2 +-
.../SyncProductFromOptionListenerTest.php | 2 +-
.../tests/Unit/Models/OrderOptionTest.php | 3 +-
.../tests/Unit/Models/OrderPaymentTest.php | 4 +-
.../EcommerceNotificationDefinitionsTest.php} | 89 +-
.../Unit/Requests/OrderListRequestTest.php | 2 +-
.../ShippingPolicyListRequestTest.php | 14 +-
.../StoreEcommerceSettingsRequestTest.php | 19 +-
.../Unit/Requests/StoreProductRequestTest.php | 143 +-
.../tests/Unit/Services/CartServiceTest.php | 24 +-
.../CurrencyConversionServiceTest.php | 12 +-
...ulationServiceMultiCurrencyComplexTest.php | 7 +
...derCalculationServiceMultiCurrencyTest.php | 8 +
.../tests/Unit/Services/OrderServiceTest.php | 4 +
.../Unit/Services/ProductImageServiceTest.php | 11 +
.../Unit/Services/ProductLabelServiceTest.php | 32 +-
.../Services/ProductReviewServiceTest.php | 4 +
.../Services/ProductServiceDeleteTest.php | 24 +-
.../Services/ShippingPolicyServiceTest.php | 4 +
.../Services/SnapshotRecalculationTest.php | 7 +
.../Unit/Services/TempOrderServiceTest.php | 44 +-
.../Unit/Settings/CatalogLangPackTest.php | 116 +
...mmerceUserOverridesSubKeyMigrationTest.php | 116 +
.../upgrades/Upgrade_1_0_0_beta_2.php | 56 +-
.../upgrades/Upgrade_1_0_0_beta_3.php | 130 +
.../sirsoft-ecommerce/vendor-bundle.json | 12 +-
.../sirsoft-ecommerce/vendor-bundle.zip | Bin 435554 -> 435558 bytes
modules/_bundled/sirsoft-page/CHANGELOG.md | 12 +
modules/_bundled/sirsoft-page/composer.json | 2 +-
modules/_bundled/sirsoft-page/module.json | 2 +-
.../_bundled/sirsoft-page/package-lock.json | 4 +-
modules/_bundled/sirsoft-page/package.json | 2 +-
.../layouts/admin-page-layouts.test.ts | 95 +
.../layouts/admin/admin_page_detail.json | 12 +-
.../layouts/admin/admin_page_form.json | 21 +-
.../layouts/admin/admin_page_list.json | 19 +-
.../src/Http/Requests/UpdatePageRequest.php | 14 +-
.../ActivityLogDescriptionResolver.php | 11 +-
.../src/Repositories/PageRepository.php | 47 +-
.../sirsoft-page/src/lang/en/activity_log.php | 10 +
.../sirsoft-page/src/lang/ko/activity_log.php | 10 +
.../Feature/ActivityLogActionLabelTest.php | 53 +
.../Feature/Admin/PageControllerTest.php | 228 ++
.../Repositories/PageRepositorySearchTest.php | 94 +-
.../sirsoft-page/tests/ModuleTestCase.php | 165 +-
.../Listeners/SeoPageCacheListenerTest.php | 160 +-
phpunit.xml | 9 +
.../gnuboard7-hello_plugin/CHANGELOG.md | 15 +
.../gnuboard7-hello_plugin/composer.json | 14 +
.../config/settings/defaults.json | 12 +
.../gnuboard7-hello_plugin/plugin.json | 22 +
.../gnuboard7-hello_plugin/plugin.php | 90 +
.../resources/lang/en.json | 14 +
.../resources/lang/ko.json | 14 +
.../layouts/admin/plugin_settings.json | 245 ++
.../src/Listeners/FilterMemoTitleListener.php | 66 +
.../src/Listeners/LogMemoCreatedListener.php | 89 +
.../Providers/HelloPluginServiceProvider.php | 37 +
.../src/Services/HelloLogService.php | 65 +
.../gnuboard7-hello_plugin/src/routes/web.php | 8 +
.../tests/Feature/MemoCreatedHookTest.php | 176 ++
.../tests/PluginTestCase.php | 202 ++
.../tests/Unit/HelloLogServiceTest.php | 62 +
.../_bundled/sirsoft-ckeditor5/CHANGELOG.md | 6 +
.../_bundled/sirsoft-ckeditor5/composer.json | 2 +-
.../_bundled/sirsoft-ckeditor5/package.json | 2 +-
.../_bundled/sirsoft-ckeditor5/plugin.json | 2 +-
.../src/Services/ImageUploadService.php | 9 +-
.../tests/PluginTestCase.php | 64 +-
.../_bundled/sirsoft-marketing/CHANGELOG.md | 7 +
.../_bundled/sirsoft-marketing/composer.json | 2 +-
.../sirsoft-marketing/lang/en/channels.php | 14 +
.../sirsoft-marketing/lang/ko/channels.php | 13 +
.../sirsoft-marketing/package-lock.json | 4 +-
.../_bundled/sirsoft-marketing/package.json | 2 +-
.../_bundled/sirsoft-marketing/plugin.json | 4 +-
.../MarketingSettingsController.php | 2 +-
.../src/Services/MarketingConsentService.php | 14 +-
.../tests/PluginTestCase.php | 68 +-
.../Services/MarketingConsentServiceTest.php | 28 +
.../sirsoft-tosspayments/CHANGELOG.md | 10 +
.../sirsoft-tosspayments/composer.json | 2 +-
.../sirsoft-tosspayments/lang/en/provider.php | 12 +
.../sirsoft-tosspayments/lang/ko/provider.php | 12 +
.../sirsoft-tosspayments/package.json | 2 +-
.../_bundled/sirsoft-tosspayments/plugin.json | 4 +-
.../Listeners/RegisterPgProviderListener.php | 3 +-
.../src/Services/TossPaymentsApiService.php | 9 +-
.../tests/PluginTestCase.php | 65 +-
.../Listeners/PaymentRefundListenerTest.php | 6 +-
.../RegisterPgProviderListenerTest.php | 16 +-
.../scenarios/example_pg_payment.yaml.example | 70 +
public/build/core/template-engine.min.js | 82 +-
public/build/core/template-engine.min.js.map | 2 +-
public/install/api/_guard.php | 81 +
public/install/api/check-configuration.php | 99 +-
public/install/api/check-env.php | 2 +
public/install/api/finalize-env.php | 103 +
public/install/api/install-process.php | 43 +-
public/install/api/install-worker.php | 54 +-
public/install/api/rollback-functions.php | 25 +-
public/install/api/save-extensions.php | 32 +
public/install/api/scan-extensions.php | 192 +-
public/install/api/sse-probe.php | 70 +
public/install/assets/css/installer.css | 103 +
.../install/assets/js/installation-monitor.js | 33 +
public/install/assets/js/installer.js | 507 +++-
public/install/includes/config.php | 263 +-
public/install/includes/functions.php | 83 +-
public/install/includes/installer-runtime.php | 273 ++
public/install/includes/installer-state.php | 210 +-
public/install/includes/progress-emitter.php | 106 +-
public/install/includes/task-runner.php | 413 ++-
.../includes/vendor-bundle-installer.php | 29 +
public/install/index.php | 6 +-
public/install/lang/en.php | 52 +-
public/install/lang/ko.php | 52 +-
public/install/views/3-configuration.php | 36 +-
.../install/views/4-extension-selection.php | 55 +-
public/install/views/5-installation.php | 17 +-
resources/js/core/TemplateApp.ts | 64 +-
.../AuthManager.loginRedirect.test.ts | 81 +
.../js/core/__tests__/G7CoreGlobals.test.ts | 27 +-
.../TemplateApp.unauthorized.test.ts | 325 +++
.../js/core/__tests__/template-engine.test.ts | 14 +-
resources/js/core/auth/AuthManager.ts | 39 +-
.../core/auth/__tests__/AuthManager.test.ts | 6 +-
resources/js/core/devtools/G7DevToolsCore.ts | 8 +-
.../core/identity/IdentityGuardInterceptor.ts | 321 +++
resources/js/core/identity/types.ts | 94 +
resources/js/core/template-engine.ts | 15 +-
.../core/template-engine/ActionDispatcher.ts | 249 +-
.../js/core/template-engine/CHANGELOG.md | 101 +
.../core/template-engine/DataBindingEngine.ts | 29 +-
.../core/template-engine/DynamicRenderer.tsx | 15 +-
.../js/core/template-engine/G7CoreGlobals.ts | 50 +
.../js/core/template-engine/LayoutLoader.ts | 19 +-
.../core/template-engine/TranslationEngine.ts | 10 +-
.../ActionDispatcher.intervals.test.ts | 172 ++
...ispatcher.resolveIdentityChallenge.test.ts | 157 +
.../__tests__/ActionDispatcher.test.ts | 287 +-
.../__tests__/DataBindingEngine.test.ts | 48 +-
.../DynamicRenderer.responsive.test.tsx | 3 +-
.../__tests__/DynamicRenderer.test.tsx | 2 +-
.../IdentityGuardInterceptor.test.ts | 298 ++
.../__tests__/LayoutLoader.test.ts | 89 +
.../__tests__/TranslationEngine.test.ts | 24 +
.../sequence-custom-handler-sync.test.ts | 2 +-
.../__tests__/troubleshooting-cache.test.ts | 2 +-
.../troubleshooting-components.test.ts | 117 +-
.../troubleshooting-regression.test.ts | 2 +-
.../troubleshooting-state-advanced.test.ts | 2 +-
.../troubleshooting-state-closure.test.ts | 124 +-
.../troubleshooting-state-global.test.ts | 2 +-
.../troubleshooting-state-setstate.test.ts | 529 +++-
.../utils/layoutTestUtils.validation.test.ts | 11 +-
resources/views/admin.blade.php | 1 +
resources/views/app.blade.php | 1 +
resources/views/dev-dashboard.blade.php | 172 ++
resources/views/seo.blade.php | 4 +
routes/api.php | 213 +-
routes/console.php | 6 +
.../CHANGELOG.md | 16 +
.../gnuboard7-hello_admin_template/LICENSE | 21 +
.../__tests__/components/Div.test.tsx | 29 +
.../__tests__/layouts/dashboard.test.tsx | 85 +
.../components.json | 39 +
.../lang/en.json | 43 +
.../lang/ko.json | 43 +
.../layouts/_admin_base.json | 85 +
.../layouts/admin_dashboard.json | 54 +
.../layouts/errors/401.json | 51 +
.../layouts/errors/403.json | 51 +
.../layouts/errors/404.json | 51 +
.../layouts/errors/500.json | 51 +
.../layouts/errors/503.json | 51 +
.../layouts/errors/maintenance.json | 41 +
.../package.json | 44 +
.../routes.json | 13 +
.../src/components/basic/A.tsx | 18 +
.../src/components/basic/Button.tsx | 26 +
.../src/components/basic/Div.tsx | 26 +
.../src/components/basic/H1.tsx | 18 +
.../src/components/basic/H2.tsx | 18 +
.../src/components/basic/H3.tsx | 18 +
.../src/components/basic/Img.tsx | 14 +
.../src/components/basic/Span.tsx | 18 +
.../src/components/basic/index.ts | 12 +
.../src/index.ts | 76 +
.../template.json | 48 +
.../tsconfig.json | 25 +
.../vite.config.ts | 57 +
.../vitest.config.ts | 37 +
.../CHANGELOG.md | 18 +
.../gnuboard7-hello_user_template/LICENSE | 25 +
.../__tests__/components/Div.test.tsx | 38 +
.../__tests__/layouts/home.test.tsx | 237 ++
.../components.json | 130 +
.../lang/en.json | 52 +
.../lang/ko.json | 52 +
.../layouts/_user_base.json | 103 +
.../layouts/errors/401.json | 70 +
.../layouts/errors/403.json | 70 +
.../layouts/errors/404.json | 70 +
.../layouts/errors/500.json | 70 +
.../layouts/errors/503.json | 55 +
.../layouts/errors/maintenance.json | 47 +
.../layouts/home.json | 163 ++
.../package.json | 46 +
.../gnuboard7-hello_user_template/routes.json | 15 +
.../src/components/basic/A.tsx | 21 +
.../src/components/basic/Button.tsx | 24 +
.../src/components/basic/Div.tsx | 26 +
.../src/components/basic/H1.tsx | 21 +
.../src/components/basic/H2.tsx | 21 +
.../src/components/basic/H3.tsx | 21 +
.../src/components/basic/Img.tsx | 20 +
.../src/components/basic/Span.tsx | 21 +
.../src/index.ts | 49 +
.../template.json | 59 +
.../tsconfig.json | 34 +
.../vite.config.ts | 63 +
.../vitest.config.ts | 37 +
.../_bundled/sirsoft-admin_basic/CHANGELOG.md | 45 +
.../admin-base-transition-overlay.test.tsx | 4 +-
.../admin-identity-challenge-modal.test.tsx | 138 +
.../layouts/admin-identity-logs.test.tsx | 317 +++
...dentity-message-definition-modals.test.tsx | 225 ++
...ntity-message-template-form-modal.test.tsx | 208 ++
.../admin-identity-messages-tab.test.tsx | 258 ++
.../layouts/admin-language-pack-list.test.tsx | 535 ++++
...admin-login-session-expired-toast.test.tsx | 98 +
.../admin-module-language-packs.test.tsx | 131 +
...admin-notification-channel-labels.test.tsx | 84 +
...min-settings-identity-policy-list.test.tsx | 209 ++
...in-settings-identity-policy-modal.test.tsx | 164 ++
...tings-notification-template-modal.test.tsx | 147 +
.../admin-template-layout-edit.test.tsx | 132 +-
.../layouts/admin_dashboard.alerts.test.tsx | 321 +++
.../layouts/admin_dashboard.recovery.test.tsx | 304 ++
...n_plugin_list._modal_update.force.test.tsx | 362 +++
.../layouts/admin_plugin_list.banner.test.tsx | 114 +
...admin_plugin_list.incompatibility.test.tsx | 136 +
.../admin_template_list.i18n_keys.test.tsx | 64 +
.../layouts/changelog-display.test.tsx | 20 +-
.../layouts/identity-challenge-modal.test.tsx | 285 ++
.../dist/css/components.css | 2 +-
.../dist/js/components.iife.js | 132 +-
.../dist/js/components.iife.js.map | 2 +-
.../dist/src/handlers/identityLauncher.d.ts | 17 +
.../_bundled/sirsoft-admin_basic/lang/en.json | 3 +
.../_bundled/sirsoft-admin_basic/lang/ko.json | 3 +
.../lang/partial/en/admin.json | 633 +++-
.../lang/partial/en/auth.json | 3 +-
.../lang/partial/en/extensions.json | 35 +
.../lang/partial/ko/admin.json | 639 ++++-
.../lang/partial/ko/auth.json | 3 +-
.../lang/partial/ko/extensions.json | 35 +
.../layouts/_admin_base.json | 5 +-
.../layouts/admin_dashboard.json | 153 +-
.../layouts/admin_identity_logs.json | 465 +++
.../layouts/admin_language_pack_list.json | 122 +
.../layouts/admin_language_packs.json | 13 +
.../admin_language_packs_install_modal.json | 21 +
.../layouts/admin_login.json | 22 +
.../layouts/admin_module_language_packs.json | 13 +
.../layouts/admin_module_list.json | 279 +-
.../layouts/admin_notification_log_list.json | 12 +-
.../layouts/admin_plugin_language_packs.json | 13 +
.../layouts/admin_plugin_list.json | 288 +-
.../layouts/admin_settings.json | 200 +-
.../admin_template_language_packs.json | 13 +
.../layouts/admin_template_list.json | 234 +-
.../layouts/auth/identity_challenge.json | 404 +++
.../partials/_identity_challenge_modal.json | 391 +++
.../_content.json | 87 +
.../_modal_log_detail.json | 340 +++
.../_modal_purge_confirm.json | 159 ++
.../_partial_datagrid.json | 338 +++
.../admin_identity_logs/_partial_filter.json | 755 +++++
.../admin_language_pack_list/_content.json | 786 +++++
.../_drawer_manifest_preview.json | 140 +
.../_modal_detail.json | 376 +++
.../_modal_install.json | 352 +++
.../_modal_install_bundled.json | 185 ++
.../_modal_refresh_cache.json | 60 +
.../_modal_slot_conflict.json | 153 +
.../_modal_uninstall.json | 149 +
.../_modal_update.json | 111 +
.../_drawer_manifest_preview.json | 140 +
.../admin_module_list/_modal_detail.json | 77 +-
.../admin_module_list/_modal_install.json | 268 +-
.../_modal_manual_install.json | 22 +
.../_modal_reactivate_language_packs.json | 197 ++
.../admin_module_list/_modal_uninstall.json | 70 +
.../admin_module_list/_modal_update.json | 125 +-
.../_drawer_manifest_preview.json | 140 +
.../admin_plugin_list/_modal_detail.json | 77 +-
.../admin_plugin_list/_modal_install.json | 270 +-
.../_modal_manual_install.json | 22 +
.../_modal_reactivate_language_packs.json | 197 ++
.../admin_plugin_list/_modal_uninstall.json | 70 +
.../admin_plugin_list/_modal_update.json | 125 +-
...modal_identity_message_definition_add.json | 461 +++
...al_identity_message_definition_delete.json | 177 ++
...dal_identity_message_definition_reset.json | 177 ++
..._modal_identity_message_template_form.json | 419 +++
...dal_identity_message_template_preview.json | 90 +
.../_modal_identity_policy_delete.json | 140 +
.../_modal_identity_policy_form.json | 660 +++++
.../_modal_notification_template_form.json | 186 +-
.../_modal_notification_template_preview.json | 4 +-
.../partials/admin_settings/_tab_general.json | 5 +-
.../admin_settings/_tab_identity.json | 54 +
.../admin_settings/_tab_identity_basic.json | 202 ++
.../_tab_identity_messages.json | 590 ++++
.../_tab_identity_policies.json | 1144 ++++++++
.../_tab_identity_providers.json | 223 ++
.../partials/admin_settings/_tab_info.json | 2 +-
.../admin_settings/_tab_language_packs.json | 20 +
.../_tab_notification_definitions.json | 1384 ++++-----
.../partials/admin_settings/_tab_seo.json | 513 +++-
.../_drawer_manifest_preview.json | 140 +
.../admin_template_list/_modal_activate.json | 26 +-
.../admin_template_list/_modal_detail.json | 77 +-
.../admin_template_list/_modal_install.json | 249 +-
.../_modal_manual_install.json | 22 +
.../_modal_reactivate_language_packs.json | 197 ++
.../admin_template_list/_modal_update.json | 125 +-
.../admin_template_list/_tab_admin.json | 51 +-
.../admin_template_list/_tab_user.json | 51 +-
.../sirsoft-admin_basic/package-lock.json | 4 +-
.../_bundled/sirsoft-admin_basic/package.json | 2 +-
.../_bundled/sirsoft-admin_basic/routes.json | 49 +
.../LayoutEditor.performance.test.tsx | 12 +-
.../src/components/basic/Select.tsx | 9 +-
.../components/composite/LanguageSelector.tsx | 9 +-
.../composite/SearchableDropdown.tsx | 22 +-
.../src/components/composite/VersionList.tsx | 122 +-
.../composite/__tests__/Toggle.test.tsx | 14 +-
.../__tests__/ThreeColumnLayout.test.tsx | 1 +
.../__tests__/scrollToSectionHandler.test.ts | 55 +-
.../src/handlers/identityLauncher.ts | 227 ++
.../src/handlers/setLocaleHandler.ts | 7 +-
.../_bundled/sirsoft-admin_basic/src/index.ts | 6 +
.../sirsoft-admin_basic/template.json | 4 +-
templates/_bundled/sirsoft-basic/CHANGELOG.md | 23 +
.../board-show-count-consistency.test.tsx | 145 +
.../layouts/identity-challenge-modal.test.tsx | 361 +++
.../login-session-expired-toast.test.tsx | 59 +
.../layouts/user-base-mobile-search.test.tsx | 2 +-
.../sirsoft-basic/dist/css/components.css | 2 +-
.../sirsoft-basic/dist/js/components.iife.js | 26 +-
.../dist/js/components.iife.js.map | 2 +-
.../dist/src/handlers/identityLauncher.d.ts | 26 +
.../sirsoft-basic/lang/partial/en/auth.json | 3 +-
.../sirsoft-basic/lang/partial/en/user.json | 23 +
.../sirsoft-basic/lang/partial/ko/auth.json | 3 +-
.../sirsoft-basic/lang/partial/ko/user.json | 23 +
.../sirsoft-basic/layouts/_user_base.json | 3 +
.../layouts/auth/identity_challenge.json | 536 ++++
.../sirsoft-basic/layouts/auth/login.json | 10 +
.../sirsoft-basic/layouts/board/popular.json | 18 +
.../sirsoft-basic/layouts/board/show.json | 3 +-
.../partials/_identity_challenge_modal.json | 394 +++
.../layouts/partials/auth/_register_form.json | 2 +-
.../partials/board/show/_comment_section.json | 4 +-
.../board/show/_post_attachments.json | 4 +-
.../partials/board/show/_reply_section.json | 2 +-
.../partials/board/types/basic/show.json | 2 +-
.../partials/mypage/board/_my_posts.json | 2 +-
.../layouts/shop/order_complete.json | 20 +-
.../sirsoft-basic/layouts/shop/show.json | 21 +-
.../sirsoft-basic/layouts/users/posts.json | 15 +-
.../sirsoft-basic/layouts/users/show.json | 6 +-
templates/_bundled/sirsoft-basic/package.json | 2 +-
templates/_bundled/sirsoft-basic/routes.json | 8 +
.../__tests__/layouts/order_complete.test.tsx | 108 +-
.../src/components/basic/Select.tsx | 9 +-
.../src/components/composite/Header.tsx | 2 +-
.../__tests__/ExpandableContent.test.tsx | 13 +-
.../composite/__tests__/UserInfo.test.tsx | 11 +-
.../__tests__/storageHandlers.test.ts | 76 +-
.../src/handlers/identityLauncher.ts | 293 ++
templates/_bundled/sirsoft-basic/src/index.ts | 6 +
.../_bundled/sirsoft-basic/template.json | 10 +-
.../ModuleActionLabelIsolationTest.php | 105 +
tests/Feature/Api/Admin/AdminAuthTest.php | 14 +
.../Api/Admin/ExtensionRecoveryTest.php | 308 ++
.../Identity/AdminIdentityLogIndexTest.php | 286 ++
.../AdminIdentityMessageControllerTest.php | 208 ++
...IdentityMessageDefinitionAdminCrudTest.php | 273 ++
.../Identity/AdminIdentityPolicyCrudTest.php | 411 +++
.../AdminIdentityPolicyResetOverrideTest.php | 154 +
.../Api/Admin/LanguagePackControllerTest.php | 565 ++++
.../Api/Admin/LayoutControllerTest.php | 2 +
.../Admin/LayoutVersionIntegrationTest.php | 75 +-
.../Api/Admin/ModuleControllerTest.php | 7 +-
.../Api/Admin/ModuleInstallPreviewTest.php | 141 +
.../Admin/ModuleUpdateVersionCheckTest.php | 132 +
.../Api/Admin/PluginControllerTest.php | 7 +-
.../Admin/PluginUpdateVersionCheckTest.php | 167 ++
.../Api/Admin/ResourceAbilitiesTest.php | 38 +-
.../Api/Admin/SeoCacheControllerTest.php | 94 +
.../Api/Admin/SettingsControllerTest.php | 168 ++
.../Admin/TemplateUpdateVersionCheckTest.php | 131 +
.../Api/Admin/UserControllerDeleteTest.php | 95 +-
.../UserControllerMultipleSearchTest.php | 3 +-
.../UserControllerUpdateLanguageTest.php | 3 +-
.../Auth/ForgotPasswordViaIdentityTest.php | 104 +
.../Api/Auth/NotificationControllerTest.php | 7 +-
.../Auth/PasswordResetPolicyDrivenTest.php | 147 +
.../Auth/RegisterPolicyDrivenSignupTest.php | 290 ++
.../Api/Auth/UserAuthControllerTest.php | 26 +-
.../AdminIdentityPolicySourceFilterTest.php | 138 +
.../Api/Identity/IdentityCallbackTest.php | 180 ++
.../Identity/IdentityChallengeFlowTest.php | 161 ++
.../Identity/IdentityChallengeShowTest.php | 93 +
.../Identity/IdentityPurposesEndpointTest.php | 87 +
.../Api/Identity/PermissionGuardTest.php | 240 ++
.../Feature/Api/Public/LayoutServingTest.php | 19 +-
.../Api/Public/SearchControllerTest.php | 14 +-
.../Feature/Api/Public/TemplateRouteTest.php | 21 +-
tests/Feature/Auth/LoginThrottleTest.php | 210 ++
tests/Feature/Auth/UserStatusAccessTest.php | 14 +-
.../Module/ModuleArtisanCommandsTest.php | 24 +-
.../Plugin/PluginArtisanCommandsTest.php | 7 +-
.../Template/TemplateArtisanCommandsTest.php | 23 +-
.../Console/ConvertPHPUnitAnnotationsTest.php | 363 ---
.../ExecuteBundledUpdatesCommandTest.php | 88 +
.../Console/UnifiedConfirmRegressionTest.php | 138 +
.../ExtensionCompatibilityAlertTest.php | 138 +
.../IdentityRequiredResponseTest.php | 60 +
.../Extension/ExtensionPendingBundledTest.php | 1 +
.../ExtensionScheduleRegistrationTest.php | 90 +-
.../ExtensionSharedRecordsResolverTest.php | 132 +
tests/Feature/Extension/HiddenFlagTest.php | 367 +++
.../ModuleManagerIdentityMessageSyncTest.php | 221 ++
.../ModuleManagerIdentityPolicySyncTest.php | 191 ++
...eManagerNotificationDefinitionSyncTest.php | 261 ++
.../ModuleManagerSyncCleanupGuardTest.php | 395 +++
.../CoreIdentityPolicyDeclarationTest.php | 195 ++
.../CoreIdentityPolicyEnforceMatrixTest.php | 337 +++
.../CoreIdentityPolicyLifecycleTest.php | 343 +++
.../EnforceIdentityPolicyTokenBypassTest.php | 264 ++
.../Identity/IdentityMessageResolverTest.php | 133 +
.../Identity/IdentityMessageSeederTest.php | 99 +
.../IdentityPolicyMailFakeSmokeTest.php | 106 +
.../MailIdentityProviderDispatchTest.php | 114 +
.../Identity/RouteScopeAutoMappingTest.php | 224 ++
...IdentityVerificationMigrationSmokeTest.php | 63 +
.../Installer/BootstrapCacheCleanupTest.php | 91 +
.../CollectBundledLangPackUpdatesTest.php | 142 +
.../LanguagePack/GetPacksForExtensionTest.php | 81 +
.../HardcodedLocaleRegressionTest.php | 238 ++
.../IdentitySeedInjectionTest.php | 114 +
.../LanguagePack/ManagerSyncFilterTest.php | 219 ++
.../NamespaceMergeRegressionTest.php | 139 +
.../NotificationSeedInjectionTest.php | 110 +
.../LanguagePack/PolicyAlignmentTest.php | 243 ++
.../RegistryPayloadLocalizationTest.php | 103 +
.../LanguagePack/SlotMultiVendorTest.php | 165 ++
.../CoreActivityLogListenerIdentityTest.php | 123 +
.../RejectPasswordResetForPendingUserTest.php | 85 +
.../Feature/Menu/MenuI18nIntegrationTest.php | 24 +-
.../Middleware/AdminMiddlewareTest.php | 15 +
.../Middleware/EnforceIdentityPolicyTest.php | 126 +
.../Middleware/SetLocaleMiddlewareTest.php | 11 +-
.../Middleware/ThrottleMiddlewareTest.php | 77 +-
tests/Feature/Module/ModuleMenuSyncTest.php | 25 +-
.../Module/ModuleRolePermissionTest.php | 39 +-
tests/Feature/Rules/ComponentExistsTest.php | 5 +-
.../Seeders/IdentityPolicySeederTest.php | 87 +
.../IdentityVerificationLogSeederTest.php | 178 ++
.../Seeders/NotificationLogSeederTest.php | 136 +
.../Seo/JaLocaleSeoIntegrationTest.php | 185 ++
.../Seo/SaveSettingsRequestSeoTest.php | 42 +
tests/Feature/Seo/SeoMiddlewareTest.php | 112 +-
tests/Feature/Seo/SeoPageRenderingTest.php | 85 +-
.../LayoutExtensionIntegrationTest.php | 16 +
.../TemplateServiceActivationTest.php | 12 +-
.../TemplateServiceInstallationTest.php | 4 +-
.../TemplateServiceIntegrationTest.php | 4 +-
.../TemplateServiceRoutesFilterTest.php | 15 +-
.../EcommerceSettingsLocalizationTest.php | 99 +
.../Template/LayoutJsonValidationTest.php | 33 +-
.../Template/PublicLayoutControllerTest.php | 45 +-
.../Template/PublicTemplateControllerTest.php | 8 +-
.../Template/TemplateAssetServingTest.php | 11 +-
.../Template/TemplateCacheManagementTest.php | 21 +-
.../Template/TemplateLanguageServingTest.php | 11 +-
.../Feature/TemplateSystemIntegrationTest.php | 3 +-
...ensionDeclarativeArtifactsRecoveryTest.php | 365 +++
.../Upgrades/LangPacksRecoveryTest.php | 208 ++
.../UserOverridesSubKeyMigrationTest.php | 199 ++
.../View/AdminBladeTemplateLoadingTest.php | 10 +-
tests/Feature/View/TemplateComposerTest.php | 5 +
.../Identity/PolicyLifecycleTestHelpers.php | 212 ++
.../Support/Identity/TestIdentityProvider.php | 166 ++
.../ResolvesActivityLogTypeOriginTest.php | 148 +
.../Seeder/HasTranslatableSeederTest.php | 161 ++
.../Config/DatabaseConfigDeprecationTest.php | 94 +
.../CheckLanguagePackUpdatesCommandTest.php | 79 +
.../BroadcastDashboardResourcesTest.php | 8 +
.../SeoGenerateSitemapCommandTest.php | 37 +-
.../Commands/Traits/HasUnifiedConfirmTest.php | 163 ++
.../Console/Helpers/ConsoleConfirmTest.php | 173 ++
...ntityVerificationInterfaceContractTest.php | 62 +
tests/Unit/Enums/IdentityDomainEnumsTest.php | 169 ++
tests/Unit/Enums/LanguagePackOriginTest.php | 83 +
...Test.php => NotificationLogStatusTest.php} | 32 +-
tests/Unit/Extension/CoreBackupHelperTest.php | 21 +-
.../Unit/Extension/DeactivationReasonTest.php | 49 +
.../ExtensionManagerResolveByFqcnTest.php | 76 +
.../Extension/ExtensionMenuSyncHelperTest.php | 162 ++
.../Extension/Helpers/GithubHelperTest.php | 12 +-
.../Helpers/IdentityPolicySyncHelperTest.php | 224 ++
.../Extension/HookArgumentSerializerTest.php | 120 +
.../Extension/HookListenerRegistrarTest.php | 99 +
.../IdentityVerificationManagerTest.php | 183 ++
.../Providers/MailIdentityProviderTest.php | 144 +
...VerificationManagerPurposeRegistryTest.php | 96 +
...tyVerificationManagerPurposeSourceTest.php | 97 +
.../ModuleCleanupStaleEntriesTest.php | 15 +-
.../ModuleManagerVersionCheckTest.php | 66 +
tests/Unit/Extension/ModuleUpdateI18nTest.php | 1 +
.../PluginManagerVersionCheckTest.php | 89 +
.../Storage/ModuleStorageDriverTest.php | 37 +
.../TemplateManagerVersionCheckTest.php | 60 +
.../Unit/Helpers/FilePermissionHelperTest.php | 175 ++
.../Helpers/IdentityMessageSyncHelperTest.php | 130 +
tests/Unit/Helpers/LocalizedLabelTest.php | 117 +
tests/Unit/Helpers/MetaGeneratorTagTest.php | 85 +
tests/Unit/Helpers/ResponseHelperTest.php | 36 +
.../Helpers/SettingsMigratorOwnershipTest.php | 129 +
.../OrchestratesCascadeInstallTest.php | 121 +
.../ActivityLogIndexRequestTest.php | 4 +-
.../Resources/ActivityLogResourceTest.php | 9 +-
.../Installer/ComposerPathValidationTest.php | 149 +
tests/Unit/Installer/DeleteDirectoryTest.php | 6 +-
.../Installer/ExistingDbActionGuardTest.php | 200 ++
tests/Unit/Installer/InstallerGuardTest.php | 118 +
.../Installer/InstallerRuntimeHelperTest.php | 372 +++
.../InstallerSecurityHardeningTest.php | 294 ++
.../Installer/InstallerStateSchemaTest.php | 136 +
.../InstallerWindowsCommandsTest.php | 15 +-
.../Installer/PollingResponseFlushTest.php | 207 ++
.../Unit/Installer/ScanLanguagePacksTest.php | 330 +++
tests/Unit/Installer/WorkerLockGuardTest.php | 179 ++
tests/Unit/Installer/stubs/lang_stub.php | 15 +
tests/Unit/Jobs/GenerateSitemapJobTest.php | 96 +-
.../CoreActivityLogListenerSignatureTest.php | 71 +
.../Listeners/CoreActivityLogListenerTest.php | 10 +-
.../Dashboard/DashboardModuleListenerTest.php | 5 +
.../Dashboard/DashboardStatsListenerTest.php | 8 +
.../MergeFrontendLanguageTest.php | 142 +
.../SyncDatabaseTranslationsTest.php | 297 ++
.../MenuUserOverridesListenerTest.php | 8 +-
.../RoleUserOverridesListenerTest.php | 20 +-
.../Models/ActivityLogActionLabelTest.php | 154 +
tests/Unit/Models/BoardModelTest.php | 50 +-
.../HasUserOverridesMassUpdateTest.php | 9 +-
.../Concerns/HasUserOverridesSubKeyTest.php | 209 ++
.../Models/IdentityMessageTemplateTest.php | 161 ++
.../Models/ProductLabelAssignmentTest.php | 13 +-
.../Notifications/GenericNotificationTest.php | 25 +-
.../InstallerRuntimeServiceProviderTest.php | 237 ++
...rceSettingsRequestCustomValidationTest.php | 6 +
.../Resources/BaseApiResourceMetaTest.php | 21 +-
tests/Unit/Resources/PerItemAbilitiesTest.php | 12 +-
tests/Unit/Rules/ExcludeCurrentUserTest.php | 44 +-
tests/Unit/Seo/BotDetectorTest.php | 334 ++-
tests/Unit/Seo/SeoMetaResolverTest.php | 416 ++-
tests/Unit/Seo/SeoRendererTest.php | 138 +
tests/Unit/Seo/SitemapGeneratorTest.php | 6 +
.../Unit/Services/ActivityLogServiceTest.php | 400 +--
...pdateServicePermissionPreservationTest.php | 333 +++
.../CoreUpdateServiceReloadResyncTest.php | 128 +
tests/Unit/Services/CoreUpdateServiceTest.php | 207 +-
tests/Unit/Services/DashboardServiceTest.php | 38 +-
.../ExtensionInstallPreviewBuilderTest.php | 118 +
tests/Unit/Services/GeoIpServiceTest.php | 27 +-
.../IdentityMessageDispatcherTest.php | 162 ++
.../Services/IdentityPolicyServiceTest.php | 321 +++
.../LanguagePack/BundledJapanesePacksTest.php | 166 ++
.../LanguagePackBundledRegistrarTest.php | 179 ++
.../LanguagePackManifestValidatorTest.php | 174 ++
.../LanguagePack/LanguagePackRegistryTest.php | 140 +
.../LanguagePackRegressionTest.php | 73 +
.../LanguagePack/LanguagePackSecurityTest.php | 133 +
.../LanguagePackSeedInjectorIdentityTest.php | 220 ++
.../LanguagePackSeedInjectorTest.php | 302 ++
.../LanguagePackServiceParityTest.php | 240 ++
.../LanguagePack/LanguagePackServiceTest.php | 689 +++++
.../LanguagePackTranslatorTest.php | 92 +
.../LayoutExtensionServiceVersionTest.php | 41 +
tests/Unit/Services/UserServiceTest.php | 49 +-
.../activity-log-action-label-namespace.yaml | 63 +
tests/scenarios/auth-login-throttle.yaml | 63 +
tests/scenarios/idv-permission-guard.yaml | 71 +
tests/scenarios/installer-entry-guard.yaml | 95 +
.../installer-language-pack-cards.yaml | 119 +
.../installer-runtime-env-finalize.yaml | 127 +
.../installer-security-hardening.yaml | 89 +
tests/scenarios/permission-preservation.yaml | 77 +
tests/scenarios/seo-meta-generator-tag.yaml | 61 +
tests/scenarios/seo-meta.yaml | 88 +
upgrades/Upgrade_7_0_0_beta_4.php | 744 +++++
vendor-bundle.json | 19 +-
vendor-bundle.zip | Bin 14372946 -> 14414054 bytes
vitest.config.ts | 10 +
1545 files changed, 136783 insertions(+), 11634 deletions(-)
create mode 100644 app/Concerns/Seeder/HasTranslatableSeeder.php
create mode 100644 app/Console/Commands/Core/ExecuteBundledUpdatesCommand.php
create mode 100644 app/Console/Commands/LanguagePack/ActivateLanguagePackCommand.php
create mode 100644 app/Console/Commands/LanguagePack/CacheClearLanguagePackCommand.php
create mode 100644 app/Console/Commands/LanguagePack/CheckLanguagePackUpdatesCommand.php
create mode 100644 app/Console/Commands/LanguagePack/DeactivateLanguagePackCommand.php
create mode 100644 app/Console/Commands/LanguagePack/InstallLanguagePackCommand.php
create mode 100644 app/Console/Commands/LanguagePack/ListLanguagePackCommand.php
create mode 100644 app/Console/Commands/LanguagePack/UninstallLanguagePackCommand.php
create mode 100644 app/Console/Commands/LanguagePack/UpdateLanguagePackCommand.php
create mode 100644 app/Console/Commands/Traits/HasUnifiedConfirm.php
create mode 100644 app/Console/Helpers/ConsoleConfirm.php
create mode 100644 app/Contracts/Extension/IdentityVerificationInterface.php
create mode 100644 app/Contracts/Repositories/IdentityMessageDefinitionRepositoryInterface.php
create mode 100644 app/Contracts/Repositories/IdentityMessageTemplateRepositoryInterface.php
create mode 100644 app/Contracts/Repositories/IdentityPolicyRepositoryInterface.php
create mode 100644 app/Contracts/Repositories/IdentityVerificationLogRepositoryInterface.php
create mode 100644 app/Contracts/Repositories/LanguagePackRepositoryInterface.php
create mode 100644 app/Contracts/Repositories/LanguagePackTranslationRepositoryInterface.php
create mode 100644 app/Contracts/Seeder/TranslatableSeederInterface.php
create mode 100644 app/Database/Sample/AbstractIdentityVerificationLogSampleSeeder.php
create mode 100644 app/Database/Sample/AbstractNotificationLogSampleSeeder.php
create mode 100644 app/Enums/DeactivationReason.php
create mode 100644 app/Enums/IdentityMessageScopeType.php
create mode 100644 app/Enums/IdentityOriginType.php
create mode 100644 app/Enums/IdentityPolicyAppliesTo.php
create mode 100644 app/Enums/IdentityPolicyFailMode.php
create mode 100644 app/Enums/IdentityPolicyScope.php
create mode 100644 app/Enums/IdentityPolicySourceType.php
create mode 100644 app/Enums/IdentityVerificationChannel.php
create mode 100644 app/Enums/IdentityVerificationPurpose.php
create mode 100644 app/Enums/IdentityVerificationStatus.php
create mode 100644 app/Enums/LanguagePackAbility.php
create mode 100644 app/Enums/LanguagePackErrorCode.php
create mode 100644 app/Enums/LanguagePackOrigin.php
create mode 100644 app/Enums/LanguagePackScope.php
create mode 100644 app/Enums/LanguagePackSourceType.php
create mode 100644 app/Enums/LanguagePackStatus.php
create mode 100644 app/Enums/TextDirection.php
create mode 100644 app/Exceptions/Auth/AccountLockedException.php
delete mode 100644 app/Exceptions/CannotDeleteAdminException.php
create mode 100644 app/Exceptions/CoreUpdateOperationException.php
create mode 100644 app/Exceptions/CoreVersionMismatchException.php
create mode 100644 app/Exceptions/IdentityVerificationRequiredException.php
create mode 100644 app/Exceptions/LanguagePackOperationException.php
create mode 100644 app/Exceptions/LanguagePackSlotConflictException.php
create mode 100644 app/Exceptions/ModuleOperationException.php
create mode 100644 app/Exceptions/PluginOperationException.php
create mode 100644 app/Exceptions/TemplateOperationException.php
create mode 100644 app/Extension/Concerns/ResolvesExtensionSharedRecords.php
create mode 100644 app/Extension/Helpers/IdentityMessageSyncHelper.php
create mode 100644 app/Extension/Helpers/IdentityPolicySyncHelper.php
create mode 100644 app/Extension/IdentityVerification/DTO/VerificationChallenge.php
create mode 100644 app/Extension/IdentityVerification/DTO/VerificationResult.php
create mode 100644 app/Extension/IdentityVerification/IdentityVerificationManager.php
create mode 100644 app/Extension/IdentityVerification/Providers/MailIdentityProvider.php
create mode 100644 app/Helpers/locale_helpers.php
create mode 100644 app/Http/Controllers/Api/Admin/ExtensionRecoveryController.php
create mode 100644 app/Http/Controllers/Api/Admin/Identity/AdminIdentityLogController.php
create mode 100644 app/Http/Controllers/Api/Admin/Identity/AdminIdentityMessageDefinitionController.php
create mode 100644 app/Http/Controllers/Api/Admin/Identity/AdminIdentityMessageTemplateController.php
create mode 100644 app/Http/Controllers/Api/Admin/Identity/AdminIdentityPolicyController.php
create mode 100644 app/Http/Controllers/Api/Admin/Identity/AdminIdentityProviderController.php
create mode 100644 app/Http/Controllers/Api/Admin/LanguagePackController.php
create mode 100644 app/Http/Controllers/Api/Identity/IdentityVerificationController.php
create mode 100644 app/Http/Controllers/Api/Public/LocaleController.php
create mode 100644 app/Http/Controllers/Concerns/InjectsExtensionLanguagePacks.php
create mode 100644 app/Http/Controllers/Concerns/OrchestratesCascadeInstall.php
create mode 100644 app/Http/Middleware/EnforceIdentityPolicy.php
create mode 100644 app/Http/Requests/Admin/Identity/AdminIdentityMessageDefinitionIndexRequest.php
create mode 100644 app/Http/Requests/Admin/Identity/PreviewIdentityMessageTemplateRequest.php
create mode 100644 app/Http/Requests/Admin/Identity/StoreIdentityMessageDefinitionRequest.php
create mode 100644 app/Http/Requests/Admin/Identity/UpdateIdentityMessageDefinitionRequest.php
create mode 100644 app/Http/Requests/Admin/Identity/UpdateIdentityMessageTemplateRequest.php
create mode 100644 app/Http/Requests/Extension/AutoDeactivatedListRequest.php
create mode 100644 app/Http/Requests/Extension/DismissAlertRequest.php
create mode 100644 app/Http/Requests/Extension/RecoverRequest.php
create mode 100644 app/Http/Requests/Identity/AdminIdentityLogIndexRequest.php
create mode 100644 app/Http/Requests/Identity/AdminIdentityLogPurgeRequest.php
create mode 100644 app/Http/Requests/Identity/AdminIdentityPolicyIndexRequest.php
create mode 100644 app/Http/Requests/Identity/AdminIdentityPolicyResetFieldRequest.php
create mode 100644 app/Http/Requests/Identity/AdminIdentityPolicyStoreRequest.php
create mode 100644 app/Http/Requests/Identity/AdminIdentityPolicyUpdateRequest.php
create mode 100644 app/Http/Requests/Identity/CancelChallengeRequest.php
create mode 100644 app/Http/Requests/Identity/IdentityCallbackRequest.php
create mode 100644 app/Http/Requests/Identity/ProvidersIndexRequest.php
create mode 100644 app/Http/Requests/Identity/PurposesIndexRequest.php
create mode 100644 app/Http/Requests/Identity/RequestChallengeRequest.php
create mode 100644 app/Http/Requests/Identity/ResolvePolicyRequest.php
create mode 100644 app/Http/Requests/Identity/ShowChallengeRequest.php
create mode 100644 app/Http/Requests/Identity/VerifyChallengeRequest.php
create mode 100644 app/Http/Requests/LanguagePack/BulkActivateRequest.php
create mode 100644 app/Http/Requests/LanguagePack/IndexLanguagePackRequest.php
create mode 100644 app/Http/Requests/LanguagePack/InstallFromBundledRequest.php
create mode 100644 app/Http/Requests/LanguagePack/InstallFromFileRequest.php
create mode 100644 app/Http/Requests/LanguagePack/InstallFromGithubRequest.php
create mode 100644 app/Http/Requests/LanguagePack/InstallFromUrlRequest.php
create mode 100644 app/Http/Requests/LanguagePack/ManifestPreviewRequest.php
create mode 100644 app/Http/Requests/LanguagePack/UninstallLanguagePackRequest.php
create mode 100644 app/Http/Requests/Module/PreviewModuleManifestRequest.php
create mode 100644 app/Http/Requests/Plugin/PreviewPluginManifestRequest.php
create mode 100644 app/Http/Requests/Template/PreviewTemplateManifestRequest.php
create mode 100644 app/Http/Resources/Admin/Identity/IdentityMessageDefinitionCollection.php
create mode 100644 app/Http/Resources/Admin/Identity/IdentityMessageDefinitionResource.php
create mode 100644 app/Http/Resources/Admin/Identity/IdentityMessageTemplateResource.php
create mode 100644 app/Http/Resources/Identity/ChallengeResource.php
create mode 100644 app/Http/Resources/Identity/PolicyCollection.php
create mode 100644 app/Http/Resources/Identity/PolicyResource.php
create mode 100644 app/Http/Resources/Identity/ProviderResource.php
create mode 100644 app/Http/Resources/IdentityLogResource.php
create mode 100644 app/Http/Resources/LanguagePackCollection.php
create mode 100644 app/Http/Resources/LanguagePackResource.php
create mode 100644 app/Listeners/Identity/ActivateUserOnIdentityVerified.php
create mode 100644 app/Listeners/Identity/AssertIdentityVerifiedBeforeRegister.php
create mode 100644 app/Listeners/Identity/EnforceIdentityPolicyListener.php
create mode 100644 app/Listeners/Identity/InitiateIdentityChallengeAfterRegister.php
create mode 100644 app/Listeners/Identity/InjectIdvRuleIntoRegisterValidation.php
create mode 100644 app/Listeners/Identity/RejectPasswordResetForPendingUser.php
create mode 100644 app/Listeners/Identity/VerifyIdentityBeforePasswordReset.php
create mode 100644 app/Listeners/LanguagePack/MergeFrontendLanguage.php
create mode 100644 app/Listeners/LanguagePack/RunSeedersOnLanguagePackLifecycle.php
create mode 100644 app/Listeners/LanguagePack/SyncDatabaseTranslations.php
create mode 100644 app/Listeners/UserLogin/HandleFailedLoginListener.php
create mode 100644 app/Listeners/UserLogin/HandleSuccessfulLoginListener.php
create mode 100644 app/Mail/IdentityMessageMail.php
create mode 100644 app/Models/Concerns/IdentityMessageContentBehavior.php
create mode 100644 app/Models/IdentityMessageDefinition.php
create mode 100644 app/Models/IdentityMessageTemplate.php
create mode 100644 app/Models/IdentityPolicy.php
create mode 100644 app/Models/IdentityVerificationLog.php
create mode 100644 app/Models/LanguagePack.php
create mode 100644 app/Providers/InstallerRuntimeServiceProvider.php
create mode 100644 app/Providers/LanguagePackServiceProvider.php
create mode 100644 app/Repositories/IdentityMessageDefinitionRepository.php
create mode 100644 app/Repositories/IdentityMessageTemplateRepository.php
create mode 100644 app/Repositories/IdentityPolicyRepository.php
create mode 100644 app/Repositories/IdentityVerificationLogRepository.php
create mode 100644 app/Repositories/LanguagePackRepository.php
create mode 100644 app/Repositories/LanguagePackTranslationRepository.php
create mode 100644 app/Rules/IdvTokenRule.php
create mode 100644 app/Seo/BotDetectorCustomProvider.php
create mode 100644 app/Seo/Concerns/LocalizesSeoValues.php
create mode 100644 app/Seo/Concerns/SubstitutesSeoVariables.php
create mode 100644 app/Seo/SitemapManager.php
create mode 100644 app/Services/Extension/ExtensionInstallPreviewBuilder.php
create mode 100644 app/Services/ExtensionCompatibilityAlertService.php
create mode 100644 app/Services/IdentityLogService.php
create mode 100644 app/Services/IdentityMessageDefinitionService.php
create mode 100644 app/Services/IdentityMessageDispatcher.php
create mode 100644 app/Services/IdentityMessageResolver.php
create mode 100644 app/Services/IdentityMessageTemplateService.php
create mode 100644 app/Services/IdentityPolicyService.php
create mode 100644 app/Services/IdentityVerificationService.php
create mode 100644 app/Services/LanguagePack/LanguagePackBaseLocales.php
create mode 100644 app/Services/LanguagePack/LanguagePackBundledRegistrar.php
create mode 100644 app/Services/LanguagePack/LanguagePackManifestValidator.php
create mode 100644 app/Services/LanguagePack/LanguagePackRegistry.php
create mode 100644 app/Services/LanguagePack/LanguagePackSeedInjector.php
create mode 100644 app/Services/LanguagePack/LanguagePackTranslator.php
create mode 100644 app/Services/LanguagePackService.php
create mode 100644 app/Testing/Concerns/AssertsIdentityPolicyDeclaration.php
create mode 100644 database/migrations/2026_04_23_000100_add_identity_columns_to_users_table.php
create mode 100644 database/migrations/2026_04_23_000101_create_identity_verification_logs_table.php
create mode 100644 database/migrations/2026_04_23_000102_create_identity_policies_table.php
create mode 100644 database/migrations/2026_04_27_000001_create_language_packs_table.php
create mode 100644 database/migrations/2026_04_27_204909_add_processing_status_comment_to_identity_verification_logs.php
create mode 100644 database/migrations/2026_04_28_000001_create_identity_message_definitions_table.php
create mode 100644 database/migrations/2026_04_28_000002_create_identity_message_templates_table.php
create mode 100644 database/migrations/2026_05_05_172526_add_login_attempt_columns_to_users_table.php
create mode 100644 database/migrations/2026_05_06_000001_add_deactivated_reason_to_plugins_table.php
create mode 100644 database/migrations/2026_05_06_000002_add_deactivated_reason_to_modules_table.php
create mode 100644 database/migrations/2026_05_06_000003_add_deactivated_reason_to_templates_table.php
create mode 100644 database/seeders/Concerns/LoadsConfigSeedWithLangPackFilter.php
create mode 100644 database/seeders/IdentityMessageDefinitionSeeder.php
create mode 100644 database/seeders/IdentityPolicySeeder.php
create mode 100644 database/seeders/Sample/IdentityVerificationLogSeeder.php
rename docs/ai-tools/agents/src/mcp/tools/{validate-code.ts => validate-backend.ts} (100%)
rename docs/ai-tools/skills/{validate-code.md => validate-backend.md} (95%)
create mode 100644 docs/backend/admin-settings-access.md
create mode 100644 docs/backend/console-confirm.md
create mode 100644 docs/backend/dto.md
create mode 100644 docs/backend/identity-messages.md
create mode 100644 docs/backend/identity-policies.md
create mode 100644 docs/backend/identity-providers.md
create mode 100644 docs/backend/language-pack-service.md
create mode 100644 docs/backend/settings-multilingual-enrichment.md
create mode 100644 docs/backend/translatable-seeders.md
create mode 100644 docs/extension/language-packs.md
create mode 100644 docs/extension/module-identity-settings.md
create mode 100644 docs/extension/sample-extensions.md
create mode 100644 docs/extension/template-idv-bootstrap.md
create mode 100644 docs/frontend/identity-guard-interceptor.md
create mode 100644 docs/frontend/identity-verification-ui.md
create mode 100644 lang-packs/_bundled/g7-core-ja/CHANGELOG.md
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/activity_log.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/admin_layout.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/attachment.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/auth.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/common.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/dashboard.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/errors.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/exceptions.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/extension_owner_type.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/extensions.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/identity.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/identity_message.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/language_packs.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/layout_extension.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/layouts.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/maintenance.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/menu.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/module.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/modules.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/nav.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/notification.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/notification_log.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/permission.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/plugins.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/role.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/schedule.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/search.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/seo.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/settings.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/templates.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/theme.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/themes.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/user.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/validation.php
create mode 100644 lang-packs/_bundled/g7-core-ja/backend/ja/vendor.php
create mode 100644 lang-packs/_bundled/g7-core-ja/language-pack.json
create mode 100644 lang-packs/_bundled/g7-core-ja/seed/identity_messages.json
create mode 100644 lang-packs/_bundled/g7-core-ja/seed/menus.json
create mode 100644 lang-packs/_bundled/g7-core-ja/seed/notifications.json
create mode 100644 lang-packs/_bundled/g7-core-ja/seed/permissions.json
create mode 100644 lang-packs/_bundled/g7-core-ja/seed/roles.json
create mode 100644 lang-packs/_bundled/g7-module-gnuboard7-hello_module-ja/CHANGELOG.md
create mode 100644 lang-packs/_bundled/g7-module-gnuboard7-hello_module-ja/backend/ja/messages.php
create mode 100644 lang-packs/_bundled/g7-module-gnuboard7-hello_module-ja/backend/ja/validation.php
create mode 100644 lang-packs/_bundled/g7-module-gnuboard7-hello_module-ja/language-pack.json
create mode 100644 lang-packs/_bundled/g7-module-gnuboard7-hello_module-ja/seed/manifest.json
create mode 100644 lang-packs/_bundled/g7-module-gnuboard7-hello_module-ja/seed/menus.json
create mode 100644 lang-packs/_bundled/g7-module-gnuboard7-hello_module-ja/seed/permissions.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-board-ja/CHANGELOG.md
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/activity_log.php
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/admin.php
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/enums.php
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/messages.php
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/notification.php
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-board-ja/backend/ja/validation.php
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/ja.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/board.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/board_types.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/ecommerce_settings.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/form.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/modals.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/posts.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/reports.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/settings.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/admin/users.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/attributes.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/board.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/common.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/enums.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/messages.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/report_types.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-board-ja/frontend/partial/validation.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-board-ja/language-pack.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-board-ja/seed/board_types.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-board-ja/seed/manifest.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-board-ja/seed/menus.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-board-ja/seed/notifications.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-board-ja/seed/permissions.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/activity_log.php
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/enums.php
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/exceptions.php
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/identity.php
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/messages.php
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/review.php
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/settings.php
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/ja.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/brand.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/category.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/common_info.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/deposit.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/excel_download.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/extra_fee_template.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/locale.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/main_banner.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/mileage_deposit_settings.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/order.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/order_settings.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/payment_failure_history.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/personal_payment.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/personal_payment_create.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/personal_payment_detail.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/product.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/product_notice_template.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/product_review.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/promotion_coupon.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/promotion_discount_code.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/settings.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/admin/shipping_policy.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/category.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/common.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/currency.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/enums.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/exceptions.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/messages.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/order.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/product.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/shop.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/user.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/frontend/partial/validation.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/seed/claim_reasons.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/seed/identity_messages.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/seed/manifest.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/seed/menus.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/seed/notifications.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/seed/permissions.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/seed/roles.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/seed/shipping_carriers.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/seed/shipping_types.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-page-ja/CHANGELOG.md
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-page-ja/backend/ja/activity_log.php
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-page-ja/backend/ja/messages.php
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-page-ja/backend/ja/validation.php
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-page-ja/frontend/ja.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-page-ja/frontend/partial/admin.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-page-ja/language-pack.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-page-ja/seed/manifest.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-page-ja/seed/menus.json
create mode 100644 lang-packs/_bundled/g7-module-sirsoft-page-ja/seed/permissions.json
create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/CHANGELOG.md
create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/backend/ja/messages.php
create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/language-pack.json
create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-ckeditor5-ja/seed/manifest.json
create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-marketing-ja/CHANGELOG.md
create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-marketing-ja/backend/ja/channels.php
create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-marketing-ja/backend/ja/messages.php
create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-marketing-ja/language-pack.json
create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-marketing-ja/seed/manifest.json
create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/CHANGELOG.md
create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/backend/ja/messages.php
create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/backend/ja/provider.php
create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/language-pack.json
create mode 100644 lang-packs/_bundled/g7-plugin-sirsoft-tosspayments-ja/seed/manifest.json
create mode 100644 lang-packs/_bundled/g7-template-gnuboard7-hello_admin_template-ja/CHANGELOG.md
create mode 100644 lang-packs/_bundled/g7-template-gnuboard7-hello_admin_template-ja/frontend/ja.json
create mode 100644 lang-packs/_bundled/g7-template-gnuboard7-hello_admin_template-ja/language-pack.json
create mode 100644 lang-packs/_bundled/g7-template-gnuboard7-hello_admin_template-ja/seed/manifest.json
create mode 100644 lang-packs/_bundled/g7-template-gnuboard7-hello_user_template-ja/CHANGELOG.md
create mode 100644 lang-packs/_bundled/g7-template-gnuboard7-hello_user_template-ja/frontend/ja.json
create mode 100644 lang-packs/_bundled/g7-template-gnuboard7-hello_user_template-ja/language-pack.json
create mode 100644 lang-packs/_bundled/g7-template-gnuboard7-hello_user_template-ja/seed/manifest.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/ja.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/attachment.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/auth.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/common.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/countries.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/errors.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/extensions.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/nav.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/seed/manifest.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-basic-ja/CHANGELOG.md
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/ja.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/attachment.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/auth.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/board.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/common.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/countries.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/editor.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/error.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/footer.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/home.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/languages.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/mypage.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/nav.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/policy.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/search.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/shop.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/sirsoft-basic.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/timezones.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/upload.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/user.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-basic-ja/frontend/partial/userinfo.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-basic-ja/language-pack.json
create mode 100644 lang-packs/_bundled/g7-template-sirsoft-basic-ja/seed/manifest.json
create mode 100644 lang-packs/_pending/.gitignore
create mode 100644 lang-packs/_pending/.gitkeep
create mode 100644 lang/en/identity.php
create mode 100644 lang/en/identity_message.php
create mode 100644 lang/en/language_packs.php
create mode 100644 lang/ko/identity.php
create mode 100644 lang/ko/identity_message.php
create mode 100644 lang/ko/language_packs.php
create mode 100644 modules/_bundled/gnuboard7-hello_module/CHANGELOG.md
create mode 100644 modules/_bundled/gnuboard7-hello_module/composer.json
create mode 100644 modules/_bundled/gnuboard7-hello_module/config/settings/defaults.json
create mode 100644 modules/_bundled/gnuboard7-hello_module/database/factories/MemoFactory.php
create mode 100644 modules/_bundled/gnuboard7-hello_module/database/migrations/2026_04_21_000001_create_gnuboard7_hello_module_memos_table.php
create mode 100644 modules/_bundled/gnuboard7-hello_module/database/seeders/DatabaseSeeder.php
create mode 100644 modules/_bundled/gnuboard7-hello_module/database/seeders/MemoSeeder.php
create mode 100644 modules/_bundled/gnuboard7-hello_module/database/seeders/Sample/MemoSampleSeeder.php
create mode 100644 modules/_bundled/gnuboard7-hello_module/module.json
create mode 100644 modules/_bundled/gnuboard7-hello_module/module.php
create mode 100644 modules/_bundled/gnuboard7-hello_module/resources/lang/en.json
create mode 100644 modules/_bundled/gnuboard7-hello_module/resources/lang/ko.json
create mode 100644 modules/_bundled/gnuboard7-hello_module/resources/layouts/admin/admin_memo_form.json
create mode 100644 modules/_bundled/gnuboard7-hello_module/resources/layouts/admin/admin_memo_list.json
create mode 100644 modules/_bundled/gnuboard7-hello_module/resources/layouts/user/user_memo_list.json
create mode 100644 modules/_bundled/gnuboard7-hello_module/resources/routes.json
create mode 100644 modules/_bundled/gnuboard7-hello_module/src/Http/Controllers/Admin/MemoController.php
create mode 100644 modules/_bundled/gnuboard7-hello_module/src/Http/Controllers/Api/MemoController.php
create mode 100644 modules/_bundled/gnuboard7-hello_module/src/Http/Requests/Admin/StoreMemoRequest.php
create mode 100644 modules/_bundled/gnuboard7-hello_module/src/Http/Requests/Admin/UpdateMemoRequest.php
create mode 100644 modules/_bundled/gnuboard7-hello_module/src/Http/Resources/MemoCollection.php
create mode 100644 modules/_bundled/gnuboard7-hello_module/src/Http/Resources/MemoResource.php
create mode 100644 modules/_bundled/gnuboard7-hello_module/src/Listeners/LogMemoCreatedListener.php
create mode 100644 modules/_bundled/gnuboard7-hello_module/src/Models/Memo.php
create mode 100644 modules/_bundled/gnuboard7-hello_module/src/Providers/HelloModuleServiceProvider.php
create mode 100644 modules/_bundled/gnuboard7-hello_module/src/Repositories/Contracts/MemoRepositoryInterface.php
create mode 100644 modules/_bundled/gnuboard7-hello_module/src/Repositories/MemoRepository.php
create mode 100644 modules/_bundled/gnuboard7-hello_module/src/Services/MemoService.php
create mode 100644 modules/_bundled/gnuboard7-hello_module/src/lang/en/messages.php
create mode 100644 modules/_bundled/gnuboard7-hello_module/src/lang/en/validation.php
create mode 100644 modules/_bundled/gnuboard7-hello_module/src/lang/ko/messages.php
create mode 100644 modules/_bundled/gnuboard7-hello_module/src/lang/ko/validation.php
create mode 100644 modules/_bundled/gnuboard7-hello_module/src/routes/api.php
create mode 100644 modules/_bundled/gnuboard7-hello_module/src/routes/web.php
create mode 100644 modules/_bundled/gnuboard7-hello_module/tests/Feature/Admin/MemoControllerTest.php
create mode 100644 modules/_bundled/gnuboard7-hello_module/tests/Feature/HookIntegrationTest.php
create mode 100644 modules/_bundled/gnuboard7-hello_module/tests/FeatureTestCase.php
create mode 100644 modules/_bundled/gnuboard7-hello_module/tests/ModuleTestCase.php
create mode 100644 modules/_bundled/gnuboard7-hello_module/tests/Unit/Services/MemoServiceTest.php
delete mode 100644 modules/_bundled/sirsoft-board/database/seeders/BoardNotificationDefinitionSeeder.php
delete mode 100644 modules/_bundled/sirsoft-board/database/seeders/InstallSeeder.php
create mode 100644 modules/_bundled/sirsoft-board/database/seeders/Sample/IdentityVerificationLogSeeder.php
create mode 100644 modules/_bundled/sirsoft-board/database/seeders/Sample/NotificationLogSeeder.php
create mode 100644 modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-post-detail-count-consistency.test.tsx
create mode 100644 modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/admin-board-settings-modal-perf.test.tsx
create mode 100644 modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/board-locale-fallback.test.ts
create mode 100644 modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/identityPolicyDeleteModal.test.ts
create mode 100644 modules/_bundled/sirsoft-board/resources/js/__tests__/layouts/identityPolicySourceIdentifier.test.ts
create mode 100644 modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_modal_identity_policy_delete.json
create mode 100644 modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_modal_identity_policy_form.json
create mode 100644 modules/_bundled/sirsoft-board/resources/layouts/admin/partials/admin_board_settings/_tab_identity_policies.json
create mode 100644 modules/_bundled/sirsoft-board/tests/Feature/ActivityLogActionLabelTest.php
create mode 100644 modules/_bundled/sirsoft-board/tests/Feature/Identity/BoardIdentityPolicyDeclarationTest.php
create mode 100644 modules/_bundled/sirsoft-board/tests/Feature/LanguagePack/BoardLanguagePackSeederTriggerTest.php
create mode 100644 modules/_bundled/sirsoft-board/tests/Feature/PostRepositoryUserActivitiesKeyTest.php
create mode 100644 modules/_bundled/sirsoft-board/tests/Feature/Seo/PostSeoOgImageRenderingTest.php
create mode 100644 modules/_bundled/sirsoft-board/tests/Unit/BoardModuleSeoTest.php
create mode 100644 modules/_bundled/sirsoft-board/tests/Unit/Database/Seeders/Sample/IdentityVerificationLogSeederTest.php
create mode 100644 modules/_bundled/sirsoft-board/tests/Unit/Database/Seeders/Sample/NotificationLogSeederTest.php
create mode 100644 modules/_bundled/sirsoft-board/tests/Unit/Models/CountColumnsCastTest.php
create mode 100644 modules/_bundled/sirsoft-board/tests/Unit/Resources/PostResourceAttachmentCountTest.php
create mode 100644 modules/_bundled/sirsoft-board/tests/Unit/Upgrades/BoardUserOverridesSubKeyMigrationTest.php
create mode 100644 modules/_bundled/sirsoft-board/upgrades/Upgrade_1_0_0_beta_4.php
delete mode 100644 modules/_bundled/sirsoft-ecommerce/database/seeders/EcommerceNotificationDefinitionSeeder.php
create mode 100644 modules/_bundled/sirsoft-ecommerce/database/seeders/Sample/IdentityVerificationLogSeeder.php
create mode 100644 modules/_bundled/sirsoft-ecommerce/database/seeders/Sample/NotificationLogSeeder.php
create mode 100644 modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/admin-ecommerce-settings-modal-perf.test.tsx
create mode 100644 modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/catalogLangPackFallback.test.tsx
create mode 100644 modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/ecommerceLocaleFallback.test.ts
create mode 100644 modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/identityPolicyDeleteModal.test.ts
create mode 100644 modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/identityPolicySourceIdentifier.test.ts
create mode 100644 modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_modal_identity_policy_delete.json
create mode 100644 modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_modal_identity_policy_form.json
create mode 100644 modules/_bundled/sirsoft-ecommerce/resources/layouts/admin/partials/admin_ecommerce_settings/_tab_identity_policies.json
create mode 100644 modules/_bundled/sirsoft-ecommerce/src/lang/en/identity.php
create mode 100644 modules/_bundled/sirsoft-ecommerce/src/lang/en/settings.php
create mode 100644 modules/_bundled/sirsoft-ecommerce/src/lang/ko/identity.php
create mode 100644 modules/_bundled/sirsoft-ecommerce/src/lang/ko/settings.php
create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Feature/ActivityLogActionLabelTest.php
create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Feature/Identity/CheckoutVerificationGuardTest.php
create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Feature/Identity/EcommerceIdentityPolicyDeclarationTest.php
create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Feature/LanguagePack/EcommerceLanguagePackSeederTriggerTest.php
create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Unit/Database/Seeders/Sample/IdentityVerificationLogSeederTest.php
create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Unit/Database/Seeders/Sample/NotificationLogSeederTest.php
create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Unit/EcommerceModuleSeoTest.php
rename modules/_bundled/sirsoft-ecommerce/tests/Unit/{Seeders/EcommerceNotificationDefinitionSeederTest.php => Notifications/EcommerceNotificationDefinitionsTest.php} (57%)
create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Unit/Settings/CatalogLangPackTest.php
create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Unit/Upgrades/EcommerceUserOverridesSubKeyMigrationTest.php
create mode 100644 modules/_bundled/sirsoft-ecommerce/upgrades/Upgrade_1_0_0_beta_3.php
create mode 100644 modules/_bundled/sirsoft-page/tests/Feature/ActivityLogActionLabelTest.php
rename modules/_bundled/sirsoft-page/tests/{Unit => Feature}/Repositories/PageRepositorySearchTest.php (51%)
create mode 100644 plugins/_bundled/gnuboard7-hello_plugin/CHANGELOG.md
create mode 100644 plugins/_bundled/gnuboard7-hello_plugin/composer.json
create mode 100644 plugins/_bundled/gnuboard7-hello_plugin/config/settings/defaults.json
create mode 100644 plugins/_bundled/gnuboard7-hello_plugin/plugin.json
create mode 100644 plugins/_bundled/gnuboard7-hello_plugin/plugin.php
create mode 100644 plugins/_bundled/gnuboard7-hello_plugin/resources/lang/en.json
create mode 100644 plugins/_bundled/gnuboard7-hello_plugin/resources/lang/ko.json
create mode 100644 plugins/_bundled/gnuboard7-hello_plugin/resources/layouts/admin/plugin_settings.json
create mode 100644 plugins/_bundled/gnuboard7-hello_plugin/src/Listeners/FilterMemoTitleListener.php
create mode 100644 plugins/_bundled/gnuboard7-hello_plugin/src/Listeners/LogMemoCreatedListener.php
create mode 100644 plugins/_bundled/gnuboard7-hello_plugin/src/Providers/HelloPluginServiceProvider.php
create mode 100644 plugins/_bundled/gnuboard7-hello_plugin/src/Services/HelloLogService.php
create mode 100644 plugins/_bundled/gnuboard7-hello_plugin/src/routes/web.php
create mode 100644 plugins/_bundled/gnuboard7-hello_plugin/tests/Feature/MemoCreatedHookTest.php
create mode 100644 plugins/_bundled/gnuboard7-hello_plugin/tests/PluginTestCase.php
create mode 100644 plugins/_bundled/gnuboard7-hello_plugin/tests/Unit/HelloLogServiceTest.php
create mode 100644 plugins/_bundled/sirsoft-marketing/lang/en/channels.php
create mode 100644 plugins/_bundled/sirsoft-marketing/lang/ko/channels.php
create mode 100644 plugins/_bundled/sirsoft-tosspayments/lang/en/provider.php
create mode 100644 plugins/_bundled/sirsoft-tosspayments/lang/ko/provider.php
create mode 100644 plugins/_bundled/sirsoft-tosspayments/tests/scenarios/example_pg_payment.yaml.example
create mode 100644 public/install/api/_guard.php
create mode 100644 public/install/api/finalize-env.php
create mode 100644 public/install/api/sse-probe.php
create mode 100644 public/install/includes/installer-runtime.php
create mode 100644 resources/js/core/__tests__/TemplateApp.unauthorized.test.ts
create mode 100644 resources/js/core/identity/IdentityGuardInterceptor.ts
create mode 100644 resources/js/core/identity/types.ts
create mode 100644 resources/js/core/template-engine/__tests__/ActionDispatcher.intervals.test.ts
create mode 100644 resources/js/core/template-engine/__tests__/ActionDispatcher.resolveIdentityChallenge.test.ts
create mode 100644 resources/js/core/template-engine/__tests__/IdentityGuardInterceptor.test.ts
create mode 100644 templates/_bundled/gnuboard7-hello_admin_template/CHANGELOG.md
create mode 100644 templates/_bundled/gnuboard7-hello_admin_template/LICENSE
create mode 100644 templates/_bundled/gnuboard7-hello_admin_template/__tests__/components/Div.test.tsx
create mode 100644 templates/_bundled/gnuboard7-hello_admin_template/__tests__/layouts/dashboard.test.tsx
create mode 100644 templates/_bundled/gnuboard7-hello_admin_template/components.json
create mode 100644 templates/_bundled/gnuboard7-hello_admin_template/lang/en.json
create mode 100644 templates/_bundled/gnuboard7-hello_admin_template/lang/ko.json
create mode 100644 templates/_bundled/gnuboard7-hello_admin_template/layouts/_admin_base.json
create mode 100644 templates/_bundled/gnuboard7-hello_admin_template/layouts/admin_dashboard.json
create mode 100644 templates/_bundled/gnuboard7-hello_admin_template/layouts/errors/401.json
create mode 100644 templates/_bundled/gnuboard7-hello_admin_template/layouts/errors/403.json
create mode 100644 templates/_bundled/gnuboard7-hello_admin_template/layouts/errors/404.json
create mode 100644 templates/_bundled/gnuboard7-hello_admin_template/layouts/errors/500.json
create mode 100644 templates/_bundled/gnuboard7-hello_admin_template/layouts/errors/503.json
create mode 100644 templates/_bundled/gnuboard7-hello_admin_template/layouts/errors/maintenance.json
create mode 100644 templates/_bundled/gnuboard7-hello_admin_template/package.json
create mode 100644 templates/_bundled/gnuboard7-hello_admin_template/routes.json
create mode 100644 templates/_bundled/gnuboard7-hello_admin_template/src/components/basic/A.tsx
create mode 100644 templates/_bundled/gnuboard7-hello_admin_template/src/components/basic/Button.tsx
create mode 100644 templates/_bundled/gnuboard7-hello_admin_template/src/components/basic/Div.tsx
create mode 100644 templates/_bundled/gnuboard7-hello_admin_template/src/components/basic/H1.tsx
create mode 100644 templates/_bundled/gnuboard7-hello_admin_template/src/components/basic/H2.tsx
create mode 100644 templates/_bundled/gnuboard7-hello_admin_template/src/components/basic/H3.tsx
create mode 100644 templates/_bundled/gnuboard7-hello_admin_template/src/components/basic/Img.tsx
create mode 100644 templates/_bundled/gnuboard7-hello_admin_template/src/components/basic/Span.tsx
create mode 100644 templates/_bundled/gnuboard7-hello_admin_template/src/components/basic/index.ts
create mode 100644 templates/_bundled/gnuboard7-hello_admin_template/src/index.ts
create mode 100644 templates/_bundled/gnuboard7-hello_admin_template/template.json
create mode 100644 templates/_bundled/gnuboard7-hello_admin_template/tsconfig.json
create mode 100644 templates/_bundled/gnuboard7-hello_admin_template/vite.config.ts
create mode 100644 templates/_bundled/gnuboard7-hello_admin_template/vitest.config.ts
create mode 100644 templates/_bundled/gnuboard7-hello_user_template/CHANGELOG.md
create mode 100644 templates/_bundled/gnuboard7-hello_user_template/LICENSE
create mode 100644 templates/_bundled/gnuboard7-hello_user_template/__tests__/components/Div.test.tsx
create mode 100644 templates/_bundled/gnuboard7-hello_user_template/__tests__/layouts/home.test.tsx
create mode 100644 templates/_bundled/gnuboard7-hello_user_template/components.json
create mode 100644 templates/_bundled/gnuboard7-hello_user_template/lang/en.json
create mode 100644 templates/_bundled/gnuboard7-hello_user_template/lang/ko.json
create mode 100644 templates/_bundled/gnuboard7-hello_user_template/layouts/_user_base.json
create mode 100644 templates/_bundled/gnuboard7-hello_user_template/layouts/errors/401.json
create mode 100644 templates/_bundled/gnuboard7-hello_user_template/layouts/errors/403.json
create mode 100644 templates/_bundled/gnuboard7-hello_user_template/layouts/errors/404.json
create mode 100644 templates/_bundled/gnuboard7-hello_user_template/layouts/errors/500.json
create mode 100644 templates/_bundled/gnuboard7-hello_user_template/layouts/errors/503.json
create mode 100644 templates/_bundled/gnuboard7-hello_user_template/layouts/errors/maintenance.json
create mode 100644 templates/_bundled/gnuboard7-hello_user_template/layouts/home.json
create mode 100644 templates/_bundled/gnuboard7-hello_user_template/package.json
create mode 100644 templates/_bundled/gnuboard7-hello_user_template/routes.json
create mode 100644 templates/_bundled/gnuboard7-hello_user_template/src/components/basic/A.tsx
create mode 100644 templates/_bundled/gnuboard7-hello_user_template/src/components/basic/Button.tsx
create mode 100644 templates/_bundled/gnuboard7-hello_user_template/src/components/basic/Div.tsx
create mode 100644 templates/_bundled/gnuboard7-hello_user_template/src/components/basic/H1.tsx
create mode 100644 templates/_bundled/gnuboard7-hello_user_template/src/components/basic/H2.tsx
create mode 100644 templates/_bundled/gnuboard7-hello_user_template/src/components/basic/H3.tsx
create mode 100644 templates/_bundled/gnuboard7-hello_user_template/src/components/basic/Img.tsx
create mode 100644 templates/_bundled/gnuboard7-hello_user_template/src/components/basic/Span.tsx
create mode 100644 templates/_bundled/gnuboard7-hello_user_template/src/index.ts
create mode 100644 templates/_bundled/gnuboard7-hello_user_template/template.json
create mode 100644 templates/_bundled/gnuboard7-hello_user_template/tsconfig.json
create mode 100644 templates/_bundled/gnuboard7-hello_user_template/vite.config.ts
create mode 100644 templates/_bundled/gnuboard7-hello_user_template/vitest.config.ts
create mode 100644 templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-identity-challenge-modal.test.tsx
create mode 100644 templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-identity-logs.test.tsx
create mode 100644 templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-identity-message-definition-modals.test.tsx
create mode 100644 templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-identity-message-template-form-modal.test.tsx
create mode 100644 templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-identity-messages-tab.test.tsx
create mode 100644 templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-language-pack-list.test.tsx
create mode 100644 templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-login-session-expired-toast.test.tsx
create mode 100644 templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-module-language-packs.test.tsx
create mode 100644 templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-notification-channel-labels.test.tsx
create mode 100644 templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-identity-policy-list.test.tsx
create mode 100644 templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-identity-policy-modal.test.tsx
create mode 100644 templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-notification-template-modal.test.tsx
create mode 100644 templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin_dashboard.alerts.test.tsx
create mode 100644 templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin_dashboard.recovery.test.tsx
create mode 100644 templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin_plugin_list._modal_update.force.test.tsx
create mode 100644 templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin_plugin_list.banner.test.tsx
create mode 100644 templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin_plugin_list.incompatibility.test.tsx
create mode 100644 templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin_template_list.i18n_keys.test.tsx
create mode 100644 templates/_bundled/sirsoft-admin_basic/__tests__/layouts/identity-challenge-modal.test.tsx
create mode 100644 templates/_bundled/sirsoft-admin_basic/dist/src/handlers/identityLauncher.d.ts
create mode 100644 templates/_bundled/sirsoft-admin_basic/lang/partial/en/extensions.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/lang/partial/ko/extensions.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/admin_identity_logs.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/admin_language_pack_list.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/admin_language_packs.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/admin_language_packs_install_modal.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/admin_module_language_packs.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/admin_plugin_language_packs.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/admin_template_language_packs.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/auth/identity_challenge.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/_identity_challenge_modal.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_extension_language_packs/_content.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_identity_logs/_modal_log_detail.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_identity_logs/_modal_purge_confirm.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_identity_logs/_partial_datagrid.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_identity_logs/_partial_filter.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_language_pack_list/_content.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_language_pack_list/_drawer_manifest_preview.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_language_pack_list/_modal_detail.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_language_pack_list/_modal_install.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_language_pack_list/_modal_install_bundled.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_language_pack_list/_modal_refresh_cache.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_language_pack_list/_modal_slot_conflict.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_language_pack_list/_modal_uninstall.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_language_pack_list/_modal_update.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_module_list/_drawer_manifest_preview.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_module_list/_modal_reactivate_language_packs.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_plugin_list/_drawer_manifest_preview.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_plugin_list/_modal_reactivate_language_packs.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_identity_message_definition_add.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_identity_message_definition_delete.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_identity_message_definition_reset.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_identity_message_template_form.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_identity_message_template_preview.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_identity_policy_delete.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_modal_identity_policy_form.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_identity.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_identity_basic.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_identity_messages.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_identity_policies.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_identity_providers.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_language_packs.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_template_list/_drawer_manifest_preview.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_template_list/_modal_reactivate_language_packs.json
create mode 100644 templates/_bundled/sirsoft-admin_basic/src/handlers/identityLauncher.ts
create mode 100644 templates/_bundled/sirsoft-basic/__tests__/layouts/board-show-count-consistency.test.tsx
create mode 100644 templates/_bundled/sirsoft-basic/__tests__/layouts/identity-challenge-modal.test.tsx
create mode 100644 templates/_bundled/sirsoft-basic/__tests__/layouts/login-session-expired-toast.test.tsx
create mode 100644 templates/_bundled/sirsoft-basic/dist/src/handlers/identityLauncher.d.ts
create mode 100644 templates/_bundled/sirsoft-basic/layouts/auth/identity_challenge.json
create mode 100644 templates/_bundled/sirsoft-basic/layouts/partials/_identity_challenge_modal.json
create mode 100644 templates/_bundled/sirsoft-basic/src/handlers/identityLauncher.ts
create mode 100644 tests/Feature/ActivityLog/ModuleActionLabelIsolationTest.php
create mode 100644 tests/Feature/Api/Admin/ExtensionRecoveryTest.php
create mode 100644 tests/Feature/Api/Admin/Identity/AdminIdentityLogIndexTest.php
create mode 100644 tests/Feature/Api/Admin/Identity/AdminIdentityMessageControllerTest.php
create mode 100644 tests/Feature/Api/Admin/Identity/AdminIdentityMessageDefinitionAdminCrudTest.php
create mode 100644 tests/Feature/Api/Admin/Identity/AdminIdentityPolicyCrudTest.php
create mode 100644 tests/Feature/Api/Admin/Identity/AdminIdentityPolicyResetOverrideTest.php
create mode 100644 tests/Feature/Api/Admin/LanguagePackControllerTest.php
create mode 100644 tests/Feature/Api/Admin/ModuleInstallPreviewTest.php
create mode 100644 tests/Feature/Api/Admin/ModuleUpdateVersionCheckTest.php
create mode 100644 tests/Feature/Api/Admin/PluginUpdateVersionCheckTest.php
create mode 100644 tests/Feature/Api/Admin/TemplateUpdateVersionCheckTest.php
create mode 100644 tests/Feature/Api/Auth/ForgotPasswordViaIdentityTest.php
create mode 100644 tests/Feature/Api/Auth/PasswordResetPolicyDrivenTest.php
create mode 100644 tests/Feature/Api/Auth/RegisterPolicyDrivenSignupTest.php
create mode 100644 tests/Feature/Api/Identity/AdminIdentityPolicySourceFilterTest.php
create mode 100644 tests/Feature/Api/Identity/IdentityCallbackTest.php
create mode 100644 tests/Feature/Api/Identity/IdentityChallengeFlowTest.php
create mode 100644 tests/Feature/Api/Identity/IdentityChallengeShowTest.php
create mode 100644 tests/Feature/Api/Identity/IdentityPurposesEndpointTest.php
create mode 100644 tests/Feature/Api/Identity/PermissionGuardTest.php
create mode 100644 tests/Feature/Auth/LoginThrottleTest.php
delete mode 100644 tests/Feature/Console/ConvertPHPUnitAnnotationsTest.php
create mode 100644 tests/Feature/Console/ExecuteBundledUpdatesCommandTest.php
create mode 100644 tests/Feature/Console/UnifiedConfirmRegressionTest.php
create mode 100644 tests/Feature/Dashboard/ExtensionCompatibilityAlertTest.php
create mode 100644 tests/Feature/Exceptions/IdentityRequiredResponseTest.php
create mode 100644 tests/Feature/Extension/ExtensionSharedRecordsResolverTest.php
create mode 100644 tests/Feature/Extension/HiddenFlagTest.php
create mode 100644 tests/Feature/Extension/ModuleManagerIdentityMessageSyncTest.php
create mode 100644 tests/Feature/Extension/ModuleManagerIdentityPolicySyncTest.php
create mode 100644 tests/Feature/Extension/ModuleManagerNotificationDefinitionSyncTest.php
create mode 100644 tests/Feature/Extension/ModuleManagerSyncCleanupGuardTest.php
create mode 100644 tests/Feature/Identity/CoreIdentityPolicyDeclarationTest.php
create mode 100644 tests/Feature/Identity/CoreIdentityPolicyEnforceMatrixTest.php
create mode 100644 tests/Feature/Identity/CoreIdentityPolicyLifecycleTest.php
create mode 100644 tests/Feature/Identity/EnforceIdentityPolicyTokenBypassTest.php
create mode 100644 tests/Feature/Identity/IdentityMessageResolverTest.php
create mode 100644 tests/Feature/Identity/IdentityMessageSeederTest.php
create mode 100644 tests/Feature/Identity/IdentityPolicyMailFakeSmokeTest.php
create mode 100644 tests/Feature/Identity/MailIdentityProviderDispatchTest.php
create mode 100644 tests/Feature/Identity/RouteScopeAutoMappingTest.php
create mode 100644 tests/Feature/Installation/IdentityVerificationMigrationSmokeTest.php
create mode 100644 tests/Feature/Installer/BootstrapCacheCleanupTest.php
create mode 100644 tests/Feature/LanguagePack/CollectBundledLangPackUpdatesTest.php
create mode 100644 tests/Feature/LanguagePack/GetPacksForExtensionTest.php
create mode 100644 tests/Feature/LanguagePack/HardcodedLocaleRegressionTest.php
create mode 100644 tests/Feature/LanguagePack/IdentitySeedInjectionTest.php
create mode 100644 tests/Feature/LanguagePack/ManagerSyncFilterTest.php
create mode 100644 tests/Feature/LanguagePack/NamespaceMergeRegressionTest.php
create mode 100644 tests/Feature/LanguagePack/NotificationSeedInjectionTest.php
create mode 100644 tests/Feature/LanguagePack/PolicyAlignmentTest.php
create mode 100644 tests/Feature/LanguagePack/RegistryPayloadLocalizationTest.php
create mode 100644 tests/Feature/LanguagePack/SlotMultiVendorTest.php
create mode 100644 tests/Feature/Listeners/CoreActivityLogListenerIdentityTest.php
create mode 100644 tests/Feature/Listeners/Identity/RejectPasswordResetForPendingUserTest.php
create mode 100644 tests/Feature/Middleware/EnforceIdentityPolicyTest.php
create mode 100644 tests/Feature/Seeders/IdentityPolicySeederTest.php
create mode 100644 tests/Feature/Seeders/IdentityVerificationLogSeederTest.php
create mode 100644 tests/Feature/Seeders/NotificationLogSeederTest.php
create mode 100644 tests/Feature/Seo/JaLocaleSeoIntegrationTest.php
create mode 100644 tests/Feature/Settings/EcommerceSettingsLocalizationTest.php
create mode 100644 tests/Feature/Upgrades/BundledExtensionDeclarativeArtifactsRecoveryTest.php
create mode 100644 tests/Feature/Upgrades/LangPacksRecoveryTest.php
create mode 100644 tests/Feature/Upgrades/UserOverridesSubKeyMigrationTest.php
create mode 100644 tests/Support/Identity/PolicyLifecycleTestHelpers.php
create mode 100644 tests/Support/Identity/TestIdentityProvider.php
create mode 100644 tests/Unit/ActivityLog/ResolvesActivityLogTypeOriginTest.php
create mode 100644 tests/Unit/Concerns/Seeder/HasTranslatableSeederTest.php
create mode 100644 tests/Unit/Config/DatabaseConfigDeprecationTest.php
create mode 100644 tests/Unit/Console/CheckLanguagePackUpdatesCommandTest.php
create mode 100644 tests/Unit/Console/Commands/Traits/HasUnifiedConfirmTest.php
create mode 100644 tests/Unit/Console/Helpers/ConsoleConfirmTest.php
create mode 100644 tests/Unit/Contracts/IdentityVerificationInterfaceContractTest.php
create mode 100644 tests/Unit/Enums/IdentityDomainEnumsTest.php
create mode 100644 tests/Unit/Enums/LanguagePackOriginTest.php
rename tests/Unit/Enums/{MailSendStatusTest.php => NotificationLogStatusTest.php} (53%)
create mode 100644 tests/Unit/Extension/DeactivationReasonTest.php
create mode 100644 tests/Unit/Extension/ExtensionManagerResolveByFqcnTest.php
create mode 100644 tests/Unit/Extension/Helpers/IdentityPolicySyncHelperTest.php
create mode 100644 tests/Unit/Extension/IdentityVerification/IdentityVerificationManagerTest.php
create mode 100644 tests/Unit/Extension/IdentityVerification/Providers/MailIdentityProviderTest.php
create mode 100644 tests/Unit/Extension/IdentityVerificationManagerPurposeRegistryTest.php
create mode 100644 tests/Unit/Extension/IdentityVerificationManagerPurposeSourceTest.php
create mode 100644 tests/Unit/Extension/ModuleManagerVersionCheckTest.php
create mode 100644 tests/Unit/Extension/PluginManagerVersionCheckTest.php
create mode 100644 tests/Unit/Extension/TemplateManagerVersionCheckTest.php
create mode 100644 tests/Unit/Helpers/IdentityMessageSyncHelperTest.php
create mode 100644 tests/Unit/Helpers/LocalizedLabelTest.php
create mode 100644 tests/Unit/Helpers/MetaGeneratorTagTest.php
create mode 100644 tests/Unit/Helpers/SettingsMigratorOwnershipTest.php
create mode 100644 tests/Unit/Http/Controllers/Concerns/OrchestratesCascadeInstallTest.php
create mode 100644 tests/Unit/Installer/ComposerPathValidationTest.php
create mode 100644 tests/Unit/Installer/ExistingDbActionGuardTest.php
create mode 100644 tests/Unit/Installer/InstallerGuardTest.php
create mode 100644 tests/Unit/Installer/InstallerRuntimeHelperTest.php
create mode 100644 tests/Unit/Installer/InstallerSecurityHardeningTest.php
create mode 100644 tests/Unit/Installer/InstallerStateSchemaTest.php
create mode 100644 tests/Unit/Installer/PollingResponseFlushTest.php
create mode 100644 tests/Unit/Installer/ScanLanguagePacksTest.php
create mode 100644 tests/Unit/Installer/WorkerLockGuardTest.php
create mode 100644 tests/Unit/Installer/stubs/lang_stub.php
create mode 100644 tests/Unit/Listeners/CoreActivityLogListenerSignatureTest.php
create mode 100644 tests/Unit/Listeners/LanguagePack/MergeFrontendLanguageTest.php
create mode 100644 tests/Unit/Listeners/LanguagePack/SyncDatabaseTranslationsTest.php
create mode 100644 tests/Unit/Models/ActivityLogActionLabelTest.php
create mode 100644 tests/Unit/Models/Concerns/HasUserOverridesSubKeyTest.php
create mode 100644 tests/Unit/Models/IdentityMessageTemplateTest.php
create mode 100644 tests/Unit/Providers/InstallerRuntimeServiceProviderTest.php
create mode 100644 tests/Unit/Services/CoreUpdateServicePermissionPreservationTest.php
create mode 100644 tests/Unit/Services/CoreUpdateServiceReloadResyncTest.php
create mode 100644 tests/Unit/Services/Extension/ExtensionInstallPreviewBuilderTest.php
create mode 100644 tests/Unit/Services/IdentityMessageDispatcherTest.php
create mode 100644 tests/Unit/Services/IdentityPolicyServiceTest.php
create mode 100644 tests/Unit/Services/LanguagePack/BundledJapanesePacksTest.php
create mode 100644 tests/Unit/Services/LanguagePack/LanguagePackBundledRegistrarTest.php
create mode 100644 tests/Unit/Services/LanguagePack/LanguagePackManifestValidatorTest.php
create mode 100644 tests/Unit/Services/LanguagePack/LanguagePackRegistryTest.php
create mode 100644 tests/Unit/Services/LanguagePack/LanguagePackRegressionTest.php
create mode 100644 tests/Unit/Services/LanguagePack/LanguagePackSecurityTest.php
create mode 100644 tests/Unit/Services/LanguagePack/LanguagePackSeedInjectorIdentityTest.php
create mode 100644 tests/Unit/Services/LanguagePack/LanguagePackSeedInjectorTest.php
create mode 100644 tests/Unit/Services/LanguagePack/LanguagePackServiceParityTest.php
create mode 100644 tests/Unit/Services/LanguagePack/LanguagePackServiceTest.php
create mode 100644 tests/Unit/Services/LanguagePack/LanguagePackTranslatorTest.php
create mode 100644 tests/scenarios/activity-log-action-label-namespace.yaml
create mode 100644 tests/scenarios/auth-login-throttle.yaml
create mode 100644 tests/scenarios/idv-permission-guard.yaml
create mode 100644 tests/scenarios/installer-entry-guard.yaml
create mode 100644 tests/scenarios/installer-language-pack-cards.yaml
create mode 100644 tests/scenarios/installer-runtime-env-finalize.yaml
create mode 100644 tests/scenarios/installer-security-hardening.yaml
create mode 100644 tests/scenarios/permission-preservation.yaml
create mode 100644 tests/scenarios/seo-meta-generator-tag.yaml
create mode 100644 tests/scenarios/seo-meta.yaml
create mode 100644 upgrades/Upgrade_7_0_0_beta_4.php
diff --git a/.env.example b/.env.example
index db8b5847..f2656413 100644
--- a/.env.example
+++ b/.env.example
@@ -3,7 +3,7 @@ APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
-APP_VERSION=7.0.0-beta.3
+APP_VERSION=7.0.0-beta.4
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
diff --git a/.env.testing.example b/.env.testing.example
index 812b54a5..658d51a8 100644
--- a/.env.testing.example
+++ b/.env.testing.example
@@ -3,7 +3,7 @@ APP_ENV=testing
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
-APP_VERSION=7.0.0-beta.3
+APP_VERSION=7.0.0-beta.4
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
diff --git a/.gitignore b/.gitignore
index 0212738e..20557b8c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -75,12 +75,26 @@ templates/_bundled/*/node_modules/
!plugins/_pending/
!templates/_pending/
+# ===== 언어팩 시스템 =====
+
+# 활성 언어팩 디렉토리 (설치된 복사본 — Git 제외)
+lang-packs/*/
+!lang-packs/_bundled/
+
+# _pending 디렉토리 (외부 다운로드 임시 영역)
+lang-packs/_bundled/_pending/
+!lang-packs/_pending/
+
# DevTools debug dump
storage/debug-dump/
# MaxMind GeoLite2 DB (재배포 금지 — 각 환경에서 직접 다운로드)
storage/app/geoip/
+# 인스톨러 런타임 산출물 — 각 환경에서 자동 생성/소비
+storage/installer-state.json
+storage/installer/
+
# 개발 전용 파일
.api-test/
.serena/
@@ -106,3 +120,5 @@ id_ed25519
*.sql
*.sqlite
*.db
+
+.claude/tmp/
diff --git a/AGENTS.md b/AGENTS.md
index d3870016..ded1df82 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -6,22 +6,29 @@
-### 백엔드 [backend/](docs/backend/) (22개)
+### 백엔드 [backend/](docs/backend/) (31개)
| 문서 | 설명 | TL;DR 핵심 |
|------|------|-----------|
| [activity-log-hooks.md](docs/backend/activity-log-hooks.md) | 활동 로그 훅 레퍼런스 (Activity Log Hooks Reference) | 코어 66훅 + 이커머스 92훅 + 게시판 32훅 + 페이지 8훅 = 총 198훅 |
| [activity-log.md](docs/backend/activity-log.md) | 활동 로그 시스템 (Activity Log System) | Monolog 기반: Service 훅 → Listener → Log::channel('activity... |
+| [admin-settings-access.md](docs/backend/admin-settings-access.md) | Admin 환경설정 값 접근 (`g7_core_settings` vs `config()`) | 동기화 SSoT: storage/app/settings/*.json → SettingsServicePr... |
| [api-resources.md](docs/backend/api-resources.md) | API 리소스 | Resource: BaseApiResource 상속 필수 / Collection: BaseApiColl... |
| [authentication.md](docs/backend/authentication.md) | 인증 및 세션 처리 | Laravel Sanctum 토큰 전용 인증 (Bearer 토큰만 사용) |
| [broadcasting.md](docs/backend/broadcasting.md) | Broadcasting (실시간 이벤트) | Laravel Reverb 사용 (WebSocket) |
+| [console-confirm.md](docs/backend/console-confirm.md) | 콘솔 yes/no 프롬프트 (ConsoleConfirm) | 콘솔 커맨드의 yes/no 프롬프트는 $this->unifiedConfirm() 사용 — Laravel... |
| [controllers.md](docs/backend/controllers.md) | 컨트롤러 계층 구조 | AdminBaseController / AuthBaseController / PublicBaseCont... |
| [core-config.md](docs/backend/core-config.md) | 코어 설정 (config/core.php) | config/core.php = 코어 권한/역할/메뉴/메일템플릿의 SSoT (Single Source ... |
| [core-update-system.md](docs/backend/core-update-system.md) | 코어 업데이트 시스템 (Core Update System) | 코어 업그레이드 스텝: upgrades/ 디렉토리 (프로젝트 루트), 네임스페이스 App\Upgrades |
| [data-sync-helpers.md](docs/backend/data-sync-helpers.md) | 데이터 동기화 Helper (Data Sync Helpers) | 모든 데이터 동기화는 Service/Seeder 가 Helper 를 호출해 수행 (직접 Model 조작... |
+| [dto.md](docs/backend/dto.md) | DTO (Data Transfer Object) 사용 규칙 | DTO 두 패턴 — Value Object(불변 1회 전달) vs Data Carrier(다단계 변형/... |
| [enum.md](docs/backend/enum.md) | Enum 사용 규칙 | 상태/타입/분류 = Enum 필수 (PHP 8.1+ Backed Enum) |
| [exceptions.md](docs/backend/exceptions.md) | Custom Exception 다국어 처리 | 예외 메시지 하드코딩 금지 → __() 함수 필수 |
| [geoip.md](docs/backend/geoip.md) | GeoIP 시스템 (MaxMind GeoLite2) | MaxMind GeoLite2-City DB 기반 IP → 타임존 감지 (SetTimezone 미들웨어... |
+| [identity-messages.md](docs/backend/identity-messages.md) | 본인인증 메시지 템플릿 시스템 (Identity Messages) | 알림 시스템(notification_*)과 완전 분리된 IDV 전용 템플릿 인프라 |
+| [identity-policies.md](docs/backend/identity-policies.md) | 본인인증 정책 시스템 (Identity Policies) | - |
+| [identity-providers.md](docs/backend/identity-providers.md) | IDV Provider 작성 가이드 (Identity Verification Providers) | VerificationProviderInterface 구현 + IdentityProviderManage... |
+| [language-pack-service.md](docs/backend/language-pack-service.md) | LanguagePackService (백엔드 Service 레이어) | LanguagePackService 가 install/activate/deactivate/uninsta... |
| [middleware.md](docs/backend/middleware.md) | 미들웨어 등록 규칙 | 인증 필요 미들웨어 → 전역 등록 금지! |
| [notification-system.md](docs/backend/notification-system.md) | 알림 시스템 (Notification System) | GenericNotification 범용 클래스 1개로 모든 알림 처리 (개별 클래스 불필요) |
| [response-helper.md](docs/backend/response-helper.md) | API 응답 규칙 (ResponseHelper) | 모든 API 응답은 ResponseHelper 사용 |
@@ -30,10 +37,12 @@
| [seo-system.md](docs/backend/seo-system.md) | SEO 페이지 생성기 시스템 (SEO Page Generator) | SeoMiddleware: 봇 요청 감지 → ?locale= 파라미터 해석 → SeoRenderer가 ... |
| [service-provider.md](docs/backend/service-provider.md) | 서비스 프로바이더 안전성 | DB 접근 전 .env 파일 존재 확인 필수 |
| [service-repository.md](docs/backend/service-repository.md) | Service-Repository 패턴 | RepositoryInterface 주입 필수 (구체 클래스 직접 주입 금지) |
+| [settings-multilingual-enrichment.md](docs/backend/settings-multilingual-enrichment.md) | Settings 카탈로그 다국어 자동 보강 | settings JSON 의 다국어 카탈로그 라벨(_cached_name 등)은 카탈로그 빌드 시점에 보강 |
+| [translatable-seeders.md](docs/backend/translatable-seeders.md) | 다국어 시더 인터페이스 (Translatable Seeders) | 다국어 JSON 컬럼(name 등)을 시드하는 확장 entity 시더는 TranslatableSeede... |
| [user-overrides.md](docs/backend/user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) | 모델에 `use HasUserOverrides;` + `protected array $trackable... |
| [validation.md](docs/backend/validation.md) | 검증 (Validation) | 필수: FormRequest에서 검증 (Service에 검증 로직 배치 금지) |
-### 프론트엔드 [frontend/](docs/frontend/) (48개)
+### 프론트엔드 [frontend/](docs/frontend/) (50개)
| 문서 | 설명 | TL;DR 핵심 |
|------|------|-----------|
@@ -59,6 +68,8 @@
| [g7core-api-advanced.md](docs/frontend/g7core-api-advanced.md) | G7Core 전역 API 레퍼런스 - 고급 | - |
| [g7core-api.md](docs/frontend/g7core-api.md) | G7Core 전역 API 레퍼런스 | G7Core.state: get/set/subscribe 전역 상태 관리 |
| [g7core-helpers.md](docs/frontend/g7core-helpers.md) | G7Core 헬퍼 API | - |
+| [identity-guard-interceptor.md](docs/frontend/identity-guard-interceptor.md) | IdentityGuardInterceptor — 코어 본인인증 인터셉터 레퍼런스 | ActionDispatcher.handleApiCall 응답 후처리에서 isIdentityRequire... |
+| [identity-verification-ui.md](docs/frontend/identity-verification-ui.md) | 본인인증(IDV) 공통 UI 가이드 | 모든 IDV 강제 지점은 동일한 428 응답 형식을 공유 (코어 9 + 게시판 4 + 이커머스 4 + N) |
| [layout-json-components-loading.md](docs/frontend/layout-json-components-loading.md) | 레이아웃 JSON - 데이터 로딩 및 생명주기 | - |
| [layout-json-components-rendering.md](docs/frontend/layout-json-components-rendering.md) | 레이아웃 JSON - 조건부/반복 렌더링 | - |
| [layout-json-components-slots.md](docs/frontend/layout-json-components-slots.md) | 레이아웃 JSON - 슬롯 시스템 | - |
@@ -86,7 +97,7 @@
| [handlers.md](docs/frontend/templates/sirsoft-basic/handlers.md) | sirsoft-basic 핸들러 | setTheme/initTheme: 다크/라이트 모드 전환 (admin과 동일 키 공유) |
| [layouts.md](docs/frontend/templates/sirsoft-basic/layouts.md) | sirsoft-basic 레이아웃 | 베이스: _user_base.json (헤더 + 푸터 + 모바일 네비 + 콘텐츠 슬롯) |
-### 확장 시스템 [extension/](docs/extension/) (25개)
+### 확장 시스템 [extension/](docs/extension/) (29개)
| 문서 | 설명 | TL;DR 핵심 |
|------|------|-----------|
@@ -95,28 +106,32 @@
| [extension-manager.md](docs/extension/extension-manager.md) | ExtensionManager (확장 관리자) | composer.json 수정 없음 - 런타임 오토로드 방식 사용 |
| [extension-update-system.md](docs/extension/extension-update-system.md) | 확장 업데이트 시스템 (Extension Update System) | 업데이트 감지 우선순위: GitHub > _bundled (2단계, _pending 미참여) |
| [hooks.md](docs/extension/hooks.md) | 훅 시스템 (Hook System) | Action 훅: doAction() - 부가 작업 (로그, 알림, 캐시) |
+| [language-packs.md](docs/extension/language-packs.md) | 언어팩 시스템 (Language Packs) | 코어/번들 확장의 lang/{ko,en}/ 는 가상 보호 행으로 자동 노출 (DB 없이 항상 activ... |
| [layout-extensions.md](docs/extension/layout-extensions.md) | 레이아웃 확장 시스템 (Layout Extensions) | - |
| [menus.md](docs/extension/menus.md) | 메뉴 시스템 | 구조: User → Role → role_menus 피벗 → Menu |
| [module-assets.md](docs/extension/module-assets.md) | 모듈 프론트엔드 에셋 시스템 | module.json에 에셋 매니페스트 정의 (js, css, loading strategy) |
| [module-basics.md](docs/extension/module-basics.md) | 모듈 개발 기초 | 디렉토리: vendor-module (예: sirsoft-ecommerce) |
| [module-commands.md](docs/extension/module-commands.md) | 모듈 Artisan 커맨드 | 목록: php artisan module:list |
| [module-i18n.md](docs/extension/module-i18n.md) | 모듈 다국어 시스템 | 백엔드: /lang/{locale}/*.php → __('vendor-module::key') |
+| [module-identity-settings.md](docs/extension/module-identity-settings.md) | 모듈/플러그인 본인인증(IDV) 설정 통합 가이드 | 정책/목적/메시지: module.php::getIdentity{Policies,Purposes,Mess... |
| [module-layouts.md](docs/extension/module-layouts.md) | 모듈 레이아웃 시스템 | 위치: modules/_bundled/vendor-module/resources/layouts/admi... |
| [module-routing.md](docs/extension/module-routing.md) | 모듈 라우트 규칙 | URL prefix 자동: /api/admin/[vendor-module]/... |
| [module-settings.md](docs/extension/module-settings.md) | 모듈 환경설정 시스템 개발 가이드 | - |
| [permissions.md](docs/extension/permissions.md) | 권한 시스템 | 구조: User → Role → Permission (기능 레벨) |
| [plugin-development.md](docs/extension/plugin-development.md) | 플러그인 개발 가이드 | 디렉토리: plugins/vendor-plugin (예: sirsoft-payment) |
+| [sample-extensions.md](docs/extension/sample-extensions.md) | 학습용 샘플 확장 (Sample Extensions) | 샘플 확장 4종: gnuboard7-hello_module / _plugin / _admin_templ... |
| [storage-driver.md](docs/extension/storage-driver.md) | 스토리지 드라이버 시스템 (StorageInterface) | 모든 파일 저장은 StorageInterface 사용 (Storage::disk() 직접 호출 금지) |
| [template-basics.md](docs/extension/template-basics.md) | 템플릿 시스템 기초 | 타입: Admin (관리자용), User (일반사용자용) |
| [template-caching.md](docs/extension/template-caching.md) | 템플릿 캐싱 전략 | - |
| [template-commands.md](docs/extension/template-commands.md) | 템플릿 Artisan 커맨드 | 목록: php artisan template:list |
+| [template-idv-bootstrap.md](docs/extension/template-idv-bootstrap.md) | 템플릿 IDV launcher 등록 가이드 | 템플릿 부트스트랩(initTemplate)에서 window.G7Core.identity.setLaunc... |
| [template-routing.md](docs/extension/template-routing.md) | 템플릿 라우트/언어 파일 규칙 | - |
| [template-security.md](docs/extension/template-security.md) | 템플릿 보안 정책 | - |
| [template-workflow.md](docs/extension/template-workflow.md) | 템플릿 개발 워크플로우 | 필수 파일: template.json, routes.json, _base.json, errors/{40... |
| [upgrade-step-guide.md](docs/extension/upgrade-step-guide.md) | 업그레이드 스텝 작성 가이드 (Upgrade Step Guide) | upgrade step 이 실행되는 환경은 경로에 따라 다르다 — 섹션 9 "업그레이드 경로" 먼저 읽기 |
| [vendor-bundle.md](docs/extension/vendor-bundle.md) | Vendor 번들 시스템 (Vendor Bundle System) | - |
-### 공통 (5개)
+### 공통 (4개)
| 문서 | 설명 | TL;DR 핵심 |
|------|------|-----------|
@@ -124,7 +139,6 @@
| [database-guide.md](docs/database-guide.md) | 그누보드7 데이터베이스 개발 가이드 | 마이그레이션: 한국어 comment 필수, down() 구현 필수 |
| [requirements.md](docs/requirements.md) | 그누보드7 시스템 요구사항 (System Requirements) | PHP 8.2+ 필수 |
| [testing-guide.md](docs/testing-guide.md) | 그누보드7 테스트 가이드 | 테스트 통과 = 작업 완료 (작성만으로 불충분!) |
-| [auto-document.md](.claude/docs/auto-document.md) | 자동 문서화 (auto-document) | - |
@@ -176,6 +190,11 @@
| `handler: "nav"` | `handler: "navigate"` |
| `handler: "setLocalState"` | `handler: "setState"` + `target: "local"` |
| `navigate` + `replace: true` (URL만 변경 시) | `handler: "replaceUrl"` |
+| apiCall `params.target` (params 내부) | `target` 은 액션 top-level. params 내부 위치 시 URL 미해석 |
+| apiCall `params.onSuccess` / `params.onError` (params 내부) | 액션 top-level. params 내부면 무시됨 |
+| `refetchDataSource` `params.id` | `params.dataSourceId` 사용 |
+| `handler: "showToast"` | `handler: "toast"` |
+| 모달 안에서 부모 `_local.*` 참조 | 데이터소스 응답 필드 또는 `_global` 사용 (모달은 별도 컨텍스트) |
### 데이터 바인딩
@@ -229,6 +248,29 @@
| 모든 API에 개별 headers 설정 | globalHeaders로 공통 헤더 정의 |
| pattern 없이 헤더 정의 | pattern 필수 (`*`, `/api/shop/*` 등) |
+### 인증/리다이렉트 규칙 (engine-v1.47.0+)
+
+| 금지 | 올바른 사용 |
+|------|------------|
+| 모듈/플러그인에서 `AuthManager.updateConfig()` 호출 | 템플릿 부트스트랩(`initTemplate`)에서만 호출 |
+| `AuthManager.updateConfig({ loginPath: 'https://...' })` (외부 origin) | `loginPath` 는 `/` 로 시작하는 동일 origin path-only |
+| `AuthManager.updateConfig({ loginPath: '//evil.com/...' })` (protocol-relative) | `//` 시작 금지 (open redirect 방지) |
+| 401 에러 페이지(`errors/401.json`)에서 직접 로그인 리다이렉트 구현 | 코어 `TemplateApp.showRouteError` 가드에 위임 (자동 처리) |
+
+### Listener 데이터 접근
+
+| 금지 | 올바른 사용 |
+|------|------------|
+| Listener 에서 `Model::query/find/where/create` 직접 호출 | Repository 인터페이스 주입 후 위임 |
+| Listener 에서 `DB::table()->update(...)` | Repository 의 도메인 의도 메서드 (recalculate*/anonymize* 등) |
+| Listener 에서 `$row->save()` / `saveQuietly()` / `delete()` | Repository 의 update/save/delete 호출 |
+| Listener 생성자에 구체 Repository 직접 주입 | Repository Interface 주입 |
+| Listener 에서 `request()` / `$_POST` 직접 접근 | Service 가 검증 후 도메인 객체로 전달 받기 |
+| Filter 훅에 `'type' => 'filter'` 누락 | type 명시 필수 (반환값 무시 회귀 차단) |
+| Listener 가 `HookListenerInterface` 미구현 (auto-discovery 대상) | implements + `getSubscribedHooks()` 정적 메서드 |
+
+> 상세: [hooks.md "Listener 데이터 접근 규정"](docs/extension/hooks.md), [service-repository.md](docs/backend/service-repository.md)
+
---
## 템플릿 엔진 내부 버전 (engine-v1.x.x)
@@ -395,17 +437,18 @@ Added/Changed/Fixed 내 항목이 10개를 초과하면 `####` 서브 헤딩으
```text
기능 구현 = 테스트 코드 작성 필수
+신규 기능 / 도메인 표면 변경 = 시나리오 매니페스트(tests/scenarios/.yaml) 작성 의무 — 입력 axis cross product + 후속 효과 체인 전수 커버
테스트 통과 = 작업 완료 (작성만으로 불충분!)
기존 테스트 있음 → 변경사항 반영하여 수정 후 실행
기능 구현 시 관련된 모든 계층(백엔드+프론트엔드+레이아웃 렌더링) 테스트 필수
주의: 모듈/플러그인 프론트엔드 테스트는 독립 vitest.config.ts 사용 (루트 config 포함 금지)
-필수: 도메인 매트릭스로 테스트 유형 분류 (Pure Logic/CRUD/Hook/Migration)
+필수: 도메인 매트릭스 = 테스트 위치/형식 가이드. 입력 조합 망라 의무는 시나리오 매니페스트가 SSoT
필수: 버그 수정은 먼저 실패하는 회귀 테스트 → fail 확인 → 수정 → green 4단계
필수: 테스트 중 발견한 무관 에러도 같은 세션에서 처리 (stale test 또는 로직 수정)
필수: 릴리스 전 composer test-smoke 통과
```
-> 상세: [docs/testing-guide.md](docs/testing-guide.md) — 도메인 매트릭스, Pre-release Smoke Suite, 회귀 테스트 4단계, 무관 에러 처리
+> 상세: [docs/testing-guide.md](docs/testing-guide.md) — 기능 단위 시나리오 매트릭스, 도메인 매트릭스, Pre-release Smoke Suite, 회귀 테스트 4단계, 무관 에러 처리
### 그누보드7 레이아웃 렌더링 테스트
@@ -824,6 +867,7 @@ php artisan migrate:rollback
| `app/Http/Requests/**` | [validation.md](docs/backend/validation.md) |
| `app/Repositories/**` | [service-repository.md](docs/backend/service-repository.md) |
| `app/Http/Resources/**` | [api-resources.md](docs/backend/api-resources.md) |
+| `app/**/DTO/**`, `modules/**/src/DTO/**`, `plugins/**/src/DTO/**` | [dto.md](docs/backend/dto.md) |
| `database/migrations/**` | [database-guide.md](docs/database-guide.md) |
| `database/seeders/**` | [database-guide.md](docs/database-guide.md) |
| `resources/layouts/**/*.json` | [layout-json.md](docs/frontend/layout-json.md) |
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e3de0e47..0afc88b2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,230 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
+## [7.0.0-beta.4] - 2026-05-11
+
+### Changed
+
+#### Breaking
+
+- 레이아웃 `navigate` 액션의 기본 스크롤 동작이 페이지 이동 후 최상단 이동으로 변경됨 — 일반 하이퍼링크 이동 UX 와 일치. 이전처럼 스크롤 위치를 유지하려면 `scroll: "preserve"` 명시 필요. 검색 필터/페이지네이션 등 위치 유지가 필요한 화면에서 회귀가 의심되면 해당 옵션 추가 (engine-v1.45.0)
+
+#### 언어팩 매니페스트 정합화
+
+- 언어팩 매니페스트(`language-pack.json`) 를 모듈/플러그인/템플릿 매니페스트와 동일한 필드 구조로 정렬 — 외부 작성자가 다른 확장과 동일한 표준으로 언어팩을 만들 수 있도록 개선
+- 언어팩 매니페스트에 GitHub 저장소 필드 추가로 GitHub 기반 업데이트 경로 지원 (모듈/플러그인 매니페스트와 동일한 동작)
+- 관리자 환경설정의 언어팩 카드와 상세 모달이 모듈/플러그인/템플릿 카드와 동일한 형식으로 다국어 이름 · 설명 · GitHub 링크를 노출하도록 변경
+
+### Added
+
+#### Generator 메타 태그
+
+- 관리자 환경설정 → SEO 탭에 Generator 메타 태그 카드 추가 — 토글로 노출 여부를 제어하고 내용 입력으로 W3Techs 등 CMS 시장 점유율 측정 도구가 인식하는 `` 태그를 SEO 봇 페이지·SPA·관리자 셸 모두에 출력. 내용 미입력 시 "GnuBoard7 {버전}" 자동 적용, 운영자가 버전 노출을 원치 않으면 "GnuBoard7" 만 입력 가능
+
+#### 웹 인스톨러 — 번들 언어팩 동반 선택 · 설치
+
+- 인스톨러 4단계 확장 선택 화면에 "언어팩" 카드 신설 — 번들 언어팩을 locale 별 서브헤딩으로 노출하고, 사용자가 모듈/플러그인/템플릿을 선택하면 그 확장에 종속된 번들 언어팩 카드가 즉시 활성화. 종속 확장을 해제하면 그 언어팩 카드는 자동으로 비활성화 + 선택 해제
+- 5단계 설치 진행 시 모든 확장의 install/activate 가 끝난 뒤 선택된 번들 언어팩을 일괄 설치 — 1건 실패는 best-effort 처리로 전체 설치를 중단하지 않음
+- 코어/확장당 다수 locale(일본어 + 중국어 등)이 동시에 번들된 시나리오 대응 — 각 locale 은 독립된 서브헤딩 + 카드로 노출
+
+#### 인스톨러 SSE 호환성 자동 감지
+
+- 인스톨러 시작 시 SSE 호환성을 사전 점검하여 환경에 맞는 모드(SSE 또는 폴링)로 단방향 진입 — 워커 동시 실행 race(테이블 / unique 키 중복 에러) 차단
+- SSE 비호환 환경 사용자에게 폴링 모드 진행 여부를 명시적 다이얼로그로 확인 후 시작
+
+#### ActivityLog 다국어 영역 분리
+
+- 활동 로그의 액션 라벨이 모듈/플러그인 자체 다국어 파일에서 우선 해석되도록 변경 — 그동안 코어에 일괄 등록되어야 했던 모듈 origin 라벨(이커머스 주문·상품·마일리지, 게시판 게시물·댓글·신고, 페이지 등) 이 각 영역으로 이전되어 모듈/플러그인이 자기 도메인 라벨을 자기 영역에서 자기설명. 미정의 시 코어 라벨로 자동 fallback 하여 회귀 없음
+- 모듈/플러그인이 발화하는 활동 로그가 호출자/대상 모델의 영역을 자동으로 인식하여 자기 다국어 파일로 라우팅 — 새 활동 로그를 추가할 때 별도 분기 코드 없이 자기 lang 만 채우면 정합
+
+#### 다국어 시더 인프라
+
+- 확장 entity 시더가 활성 언어팩의 다국어 데이터를 자동 머지하도록 다국어 시더 인터페이스 도입 — 신규 시더 작성 시 회귀를 자동 차단
+- 번들 일본어 언어팩 빌드 스크립트가 시더 메타데이터를 일관된 경로로 조회하도록 단순화
+
+#### 다국어 라벨 helper + Provider/Registry 페이로드 정합화
+
+- 다국어 라벨 표시 시 활성 언어팩의 lang key fallback 을 자동으로 처리하는 다국어 라벨 보강 helper 추가 — Provider/Registry 등록 페이로드(알림 채널·결제 PG 등) 와 settings JSON 다국어 데이터를 단일 시그니처로 처리
+- 알림 채널(`config/notification.php` 의 `default_channels`) 의 라벨이 활성 언어팩(일본어 등) 으로 자동 보강되도록 변경 — 일본어 활성 시 한국어 fallback 으로 노출되던 회귀 차단
+- 토스페이먼츠 PG 프로바이더 등록 페이로드가 lang key 기반으로 라벨을 선언하도록 변경 — 활성 언어팩으로 자동 보강
+- Provider/Registry 등록 페이로드가 다국어 JSON(`['ko' => ..., 'en' => ...]`) 을 직접 보유하지 않도록 audit 룰 신설 (회귀 자동 차단)
+- settings JSON 다국어 entry 의 식별 키(code/id/key) 보유 검증 audit 룰 신설 (lang pack fallback 키 조립 가능성 보장)
+- 프론트엔드 `$localized()` 표현식이 두 번째 인수로 lang key fallback 을 받도록 확장 — 활성 로케일 라벨 부재 시 모듈/플러그인 언어팩의 키로 자동 보강
+- 이커머스 환경설정 카탈로그(배송 가능 국가 / 통화 / 결제수단) 표시 시 활성 언어팩의 라벨이 자동 적용되도록 변경 — 일본어 활성 시 카탈로그 라벨이 한국어로 노출되던 회귀 차단 (다음 일본어 언어팩 빌드 시 자동 적용)
+- audit 룰이 `getDefault*Channels/Providers/Methods` 등 registry 기본값 반환 메서드의 다국어 JSON 직접 보유도 감지하도록 강화
+
+#### Settings 카탈로그 다국어 자동 보강
+
+- 환경설정 카탈로그(결제수단·통화·배송 가능 국가 등) 의 다국어 라벨이 카탈로그 빌드 시점에 활성 언어팩으로 자동 보강되도록 변경 — 사용자 체크아웃 + 관리자 환경설정 양쪽 모두 일본어 등 활성 시 한국어 fallback 으로 노출되던 회귀 차단
+- 모듈/플러그인 개발자가 카탈로그를 추가할 때 누락하지 않도록 audit 룰이 helper 호출 누락 검출
+- [docs/backend/settings-multilingual-enrichment.md](docs/backend/settings-multilingual-enrichment.md) 에 단순 helper 사용 패턴 문서화
+
+#### 확장 시스템
+
+- manifest `hidden: true` 플래그 추가 — 관리자 UI 목록에서 학습용/내부용 확장을 숨김 (CLI 는 정상 노출). 관리자 UI 에 슈퍼관리자 전용 "숨김 포함" 토글, artisan `module:list` / `plugin:list` / `template:list` 에 `--hidden` 플래그, 관리자 API `/api/admin/{modules,plugins,templates}` 에 `include_hidden` 쿼리 파라미터 지원
+- 학습용 최소 샘플 확장 4종 번들 추가
+ - 모듈: `gnuboard7-hello_module` (Memo CRUD + 훅 발행 시연)
+ - 플러그인: `gnuboard7-hello_plugin` (Action/Filter 훅 구독 시연)
+ - Admin 템플릿: `gnuboard7-hello_admin_template` (Basic 컴포넌트 최소 셋)
+ - User 템플릿: `gnuboard7-hello_user_template` (홈 + Memo 리스트 연동)
+- 모듈/플러그인/템플릿 정보 모달에 "지원 언어" 섹션 추가 — 코어/번들/사용자설치 출처 배지로 한눈에 확인
+- 모듈/플러그인/템플릿 인스톨러에 의존 확장 + 동반 번들 언어팩 동반선택 UI 추가 — 미선택 의존성에 종속된 언어팩은 자동 비활성화
+- 인스톨러 요구사항 검증 단계에 언어팩 디렉토리 쓰기 권한 점검 + 권한 부여 안내 추가
+- 사용자 수동 비활성화와 코어 버전 호환성으로 인한 자동 비활성화를 DB 수준에서 구분 — 자동 비활성화된 확장만 재호환 감지/원클릭 복구 대상이 되도록 분리
+- 코어 업그레이드 후 자동 비활성화 확장이 다시 호환되면 관리자 대시보드에 "다시 활성화" 알림 표시 + 원클릭 복구 버튼 제공 (자동 재활성화는 하지 않음 — 운영자가 명시적으로 복구)
+- 모듈/플러그인/템플릿 목록 화면 상단에 자동 비활성화 확장 안내 배너 추가 (코어 업그레이드 가이드 링크 동반)
+- 업데이트 모달에 코어 버전 호환성 안내 + "위험을 이해하고 강제로 진행" 체크박스 추가 — 운영자가 위험을 인지하면 비호환 확장도 강제 설치 가능
+- 관리자 대시보드 "시스템 알림" 카드 노출 — 코어 호환성 자동 비활성화/재호환 알림이 분기 렌더되며 개별 dismiss 지원
+
+#### 코어 업데이트 가시성
+
+- 코어 업데이트 마무리 단계에서 sudo 환경 결함(파일시스템 ACL · immutable 비트 · NFS 권한 거부 등) 으로 일부 경로의 소유권/그룹 쓰기 권한을 정상화하지 못한 경우 즉시 콘솔에 실패 경로 + 운영자 수동 복구 명령(`sudo chown -R …` / `sudo chmod -R g+w …`) 안내 — 이전에는 silent fail 로 묻혀 운영자가 후속 권한 거부 발생 후에야 인지하던 문제 해소
+
+#### 알림 시스템
+
+- 권한 기반 수신자 타입 추가 — `permission` 타입으로 특정 권한을 가진 모든 사용자에게 알림 발송 가능 (예: 게시판 신고 알림은 신고 관리 권한자에게 자동 발송)
+- 모듈 환경설정에서 알림이 비활성화된 경우 발송 자체를 사전 차단하는 정책 게이트 도입 — 이전에는 수신자 해석 단계가 정책을 우회하여 발송되던 문제 해소
+- 게시판 환경설정 → 신고 정책 탭에 신고 알림 채널 선택 UI 추가 (이메일 / 사이트 알림 다중 선택)
+- 모듈/플러그인이 자기가 발송하는 알림 정의를 manifest 에서 직접 선언하도록 통일 — 활성화/업데이트 시 운영자 편집값을 보존하면서 자동 등록되고, 제거 시 함께 정리됨. 권한·메뉴·본인인증과 동일한 declarative getter 패턴
+- 코어 기본 알림(회원가입 환영·비밀번호 재설정·비밀번호 변경) 의 다국어 제목/본문/변수/채널 정의를 `config/core.php` 로 통합 — 운영자가 코드 수정 없이도 향후 표면 변경을 추적할 수 있는 단일 소스 확보
+
+#### 본인인증
+
+- 본인인증(IdentityVerification) 인프라 도입 — 코어에 범용 IDV 프로바이더 계약을 마련하고, 기본 메일 프로바이더를 내장. 플러그인이 KCP·이니시스·SMS 등 다른 경로를 동일 계약으로 붙일 수 있도록 확장점 제공
+- 외부 본인인증 provider (KCP·PortOne·토스인증·Stripe Identity 등) 가 G7 표준 Extension Point 패턴으로 자기 SDK UI 를 주입할 수 있는 슬롯 도입
+- 비동기 검증 흐름을 위한 백엔드 폴링/콜백 엔드포인트 추가 — `GET /api/identity/challenges/{id}` (상태 폴링), `POST /api/identity/callback/{providerId}` (외부 redirect 콜백 수신)
+- 본인인증 정책 시스템 신설 — 회원가입·비밀번호 재설정·민감 작업에 적용되는 모든 본인인증 시점을 라우트/훅 단위로 선언형 정책으로 통합 관리. 관리자 화면에서 정책 활성/유예 시간/프로바이더/실패 모드/단계(가입 제출 전 vs 가입 후 활성화 전)·적용 대상(self/admin/both)을 조정할 수 있으며, 운영자 수정값은 업데이트 재시딩 시 보존됨
+- 본인인증 정책의 enable 토글이 라우트 코드 수정 없이 즉시 적용 — 모든 API 라우트가 정책 DB 와 자동 매칭되어 운영자가 admin UI 에서 정책을 켜는 즉시 본인인증이 강제됨. 코어/모듈/플러그인 어떤 라우트의 응답 처리 패턴에서도 본인인증 흐름이 일관되게 모달까지 도달하도록 처리
+- 회원가입 단계 정책 2종 시드 — 가입 제출 전 동기 검증, 가입 후 활성화 전 비동기 challenge (기본값 비활성, 운영자 opt-in)
+- 본인인증 정책이 활성화된 모든 강제 지점(회원가입·비밀번호 재설정·민감 작업·게시판/이커머스 정책 등)에서 사용자/관리자 화면에 동일한 모달 UX 가 자동으로 표시되도록 코어 인터셉터와 공통 모달 인프라 도입
+- 전역 프론트엔드 인터셉트 — 서버가 HTTP 428 본인인증 요구 응답을 반환하면 자동으로 인증 모달을 띄우고 사용자가 인증에 성공하면 원 요청을 자동 재실행
+- 본인인증 가드가 모듈/플러그인이 선언한 훅에도 자동 적용되도록 동적 구독 도입 — 결제 직전·민감 액션 직전 등 도메인 특화 가드를 운영자 토글 한 번으로 활성화
+- 관리자 화면에 본인인증 정책 관리 페이지 추가 (정책 목록 DataGrid + 편집)
+- 관리자 화면에 본인인증 이력 페이지 추가 — 알림 발송 이력과 동일한 수준의 UI 제공: 인증 수단(Provider) 탭(활성화된 프로바이더에 따라 자동 갱신), 통합 검색(자동 감지/사용자 ID/대상 식별자/IP/정책 키), 상태·인증 목적·채널·발생 유형 다중선택 OR 필터, 날짜 범위 + 고급 검색(발생 유형·출처·정책 키) 토글 영역, 정렬·페이지 사이즈 선택, 행 펼침으로 인라인 상세 표시, 모바일 반응형, 사용자 타임존 기준 시각, 보관주기(180일) 일괄 파기, 인증 수단/발생 위치 다국어 라벨 표시
+- 본인인증 목적(purpose) 의 출처(source) 추적 — 코어/모듈/플러그인이 선언한 목적을 구분해 환경설정 화면에서 분리 표시하며, 코어 정책 화면 상단에 "코어가 제공하는 본인인증 목적" 칩 섹션(목적 코드/라벨 + 설명·허용 채널 툴팁) 추가
+- 모듈 환경설정의 본인인증 정책 탭을 코어 정책 관리 화면과 동일한 UI/UX 로 통일 — 검색·필터(강제 시점/출처)·페이지네이션·정책 추가/편집 모달 직접 호출(코어 화면으로 이동 X)·이 모듈이 등록한 본인인증 목적 칩 섹션(없으면 안내 메시지). 정책 목록의 "인증 목적" 컬럼이 코드명 대신 사람이 읽을 수 있는 라벨로 표시
+- 모듈/플러그인이 자기 컨텍스트의 본인인증 정책·목적·메시지 정의를 manifest 에서 직접 선언하도록 통일 — `module.php::getIdentityPolicies()` / `getIdentityPurposes()` / `getIdentityMessages()` 선언만으로 코어 정책 관리에 자동 연동되며, 활성화/업데이트 시 운영자 편집값을 보존하면서 자동 등록·정리됨. 권한·메뉴·알림과 동일한 declarative getter 패턴
+- 본인인증 정책/이력 목록 API 가 source 컨텍스트별 필터를 받아 모듈 환경설정 탭이 자기 정책/이력만 조회 가능하며, 운영자 정의 정책을 특정 모듈/플러그인 컨텍스트에 귀속시켜 추가할 수 있도록 source_identifier 입력 허용 (`admin` / `module:{id}` / `plugin:{id}`)
+- 본인인증 메일 메시지 템플릿 시스템 도입 — 프로바이더와 (목적/정책)별로 다국어 제목/본문을 개별 정의 가능하며, 메시지 발송 시 정책 → 목적 → 프로바이더 기본값 순서로 fallback 해석. 회원가입/계정 변경/중요 작업/비밀번호 재설정 + 프로바이더 기본값 등 5종 메일 템플릿이 한국어/영어로 시드되어 즉시 발송 가능
+- 본인인증 challenge 발급/검증/취소 라우트에 권한 미들웨어 + scope=self 가드 적용 — 게스트는 `core.identity.{request,verify,cancel}` 권한으로 비로그인 가입(Mode B) 흐름 진입, 로그인 사용자는 본인 challenge 만 다룰 수 있으며 관리자는 임의 challenge 도 처리 가능. 모달 취소 시 서버 cancel API 를 호출해 challenge 가 audit log 에 cancelled 상태로 즉시 기록되도록 정합화
+- 환경설정 → 본인인증 탭에 "메시지 템플릿" 서브탭 신설 — 알림 템플릿 관리와 동일한 UX (채널 서브탭·페이지당 항목 수 셀렉터·카드 펼침 본문 미리보기·활성/기본 배지·페이지네이션) 로 정의 목록·활성 토글·다국어 편집(변수 가이드 + 기본값 복원) 제공. 운영자가 추가한 정책에 전용 메일 메시지 정의를 화면에서 직접 등록·삭제 가능 (시드 기본값 보호 + 정책 키 매칭 검증). 외부 본인인증 프로바이더 플러그인이 자기 메시지 기본값을 코어 복원 로직에 기여할 수 있는 필터 훅 노출
+- 알림 발송 이력 / 본인인증 이력 샘플 시더를 코어/모듈별로 분리 — 코어 시더는 코어 정의·정책만, 각 모듈 시더는 자기 영역만 채우도록 영역 격리. 모듈은 `module:seed {id} --sample` 으로 자기 영역 이력만 독립 생성 가능. 샘플 데이터는 실제 등록된 사용자·정의·정책·프로바이더 기반으로 생성되어 운영 데이터와 동일한 스키마/분포 유지
+- 모듈/플러그인 제거 시 코어 공유 테이블에 적재된 해당 확장의 데이터(권한·관리자 메뉴·알림 정의·본인인증 정책·본인인증 메시지 정의·본인인증 목적) 가 함께 정리되며, 제거 모달의 "삭제될 데이터" 에도 항목별 건수가 표시되도록 개선
+- IDV 도메인 분류 데이터(목적·채널·트리거 출처·정책 범위·정책 실패 모드·정책 적용 대상·정책 출처·메시지 스코프) 8종을 PHP Backed Enum 으로 정의하여 정책 등록/수정 시 일관된 검증 적용
+
+#### 인스톨러
+
+- 인스톨러 설치 완료 화면에 설치된 코어 버전 표시 — 사용자가 어떤 버전이 설치되었는지 즉시 확인할 수 있도록 개선
+
+#### 언어팩 시스템
+
+- 새 언어(일본어/중국어 등)를 코어 수정 없이 추가할 수 있는 언어팩(Language Pack) 시스템 도입
+- ZIP 업로드 또는 GitHub URL로 언어팩 설치/제거/활성화 지원
+- 동일 언어 슬롯에 여러 벤더의 언어팩 공존 가능 — 라디오 전환으로 즉시 활성 변경
+- 코어/모듈/플러그인/템플릿 별도 적용 — 모듈 언어팩은 해당 코어 언어팩이 활성일 때만 활성화 가능
+- 관리자 메뉴: 환경설정 > 언어팩 관리(통합) + 모듈/플러그인/템플릿별 진입점 추가
+- 사용자가 직접 수정한 다국어 키는 언어팩이 덮어쓰지 않도록 보존 정책 적용
+- 보안: 언어 번역 외의 PHP 실행 코드 포함 시 설치 차단
+- 언어팩 관리 화면을 모듈 관리와 동일 수준의 운영 도구로 보강 — 검색(식별자/벤더/언어), 업데이트 확인, 캐시 갱신, 다중 선택 일괄 제거 지원
+- 활성/비활성 상태를 토글 스위치로 일원화 (보호된 팩은 비활성화된 토글로 표시)
+- 업데이트 가능 항목에 "업데이트 가능" 배지와 행 단위 업데이트 실행 버튼 노출
+- 정보 모달을 모듈 정보 모달과 동일한 4섹션 구조(기본정보 / 호환성 / 소스 정보 / 변경로그)로 제공하며, CHANGELOG.md 를 자동 파싱하여 버전별 카테고리로 표시
+- 설치 모달에서 ZIP 업로드 전에 manifest 와 검증 결과를 사전 확인할 수 있는 미리보기 제공
+- 모듈/플러그인/템플릿별 언어팩 페이지 진입 시 대상 확장 안내 배너와 환경설정 탭으로 회귀하는 링크 노출
+- 언어팩 업데이트 권한을 별도 권한 키로 분리하여 설치/관리 권한과 독립적으로 부여 가능
+- 언어팩 운영 풀 패리티 — 모듈/플러그인/템플릿 관리와 동일한 안전 장치/확장성 적용
+- 공식 일본어(ja) 번들 언어팩 12종 추가 — 코어, 주요 모듈(전자상거래/게시판/페이지), 주요 플러그인(CKEditor5/마케팅/토스페이먼츠), 기본 템플릿(admin/user)을 일본어로 즉시 사용 가능
+- 언어팩 관리 화면에서 번들 언어팩을 모듈/플러그인 관리와 동일하게 "미설치" 상태로 노출 — 행별 "설치" 버튼으로 즉시 설치 가능
+- 언어팩 목록 필터에 "미설치 (번들)" 상태 옵션 추가
+- 본인인증 메일 메시지 정의의 다국어 키를 언어팩으로 주입할 수 있도록 확장 — 코어 수정 없이 언어팩 ZIP 만으로 IDV 메일 본문/제목을 다국어화 가능
+- 모듈/플러그인이 선언한 알림/본인인증 메시지 정의의 다국어 키도 언어팩으로 주입되도록 정합 — 이전에는 hook 발화 누락으로 코어 정의에만 적용되던 동작 정상화
+ - 업데이트 시 자동 백업 + 실패 시 직전 버전으로 자동 복구
+ - 동시성 가드 — 진행 중인 작업 동안 활성/비활성/제거/재업데이트 진입 차단
+ - 번들 디렉토리(`lang-packs/_bundled/{identifier}`) 에서 외부 다운로드 없이 (재)설치하는 경로 추가 — 코어 언어팩 복구/재배포 단순화
+ - 라이프사이클 훅 명명을 모듈/플러그인/템플릿과 통일 — `core.language_packs.{installed|updated|uninstalled|activated|deactivated}` 발행 (확장 가능성 확대)
+ - Artisan 커맨드 신규 — `language-pack:list`, `language-pack:install`, `language-pack:update`, `language-pack:uninstall`
+- 코어와 번들 확장(모듈/플러그인/템플릿)에 내장된 한국어/영어를 가상 보호 언어팩으로 자동 노출 — 별도 설치 없이 언어팩 관리자에서 항상 활성/보호 상태로 확인 가능, 수정/제거 차단
+- 호스트 확장 비활성화 시 그에 종속된 언어팩이 함께 비활성화되며, 재활성화 시 "다음 언어팩도 활성화하시겠습니까" 모달로 사용자 의사 확인 후 일괄 활성화
+- 여러 언어팩을 한 번에 활성화하는 `POST /api/admin/language-packs/bulk-activate` API 추가 (의존성/버전 호환성 자동 검사)
+- 언어팩 활성화 시 의존성 + 호스트 확장 버전 호환성 검사 자동 수행 — 호스트 확장이 비활성/미설치이거나 버전 미달이면 활성화 차단
+- 언어팩 업데이트 우선순위를 모듈/플러그인 패턴으로 정합화 — GitHub 1순위 + bundled 폴백, 강제 업데이트 시 bundled 우선
+- 언어팩 상세 모달에서 코어/번들 확장 내장 항목 클릭 시 "별도 언어팩이 아닌 내장 번역" 안내 배너 노출
+- 모듈/플러그인/템플릿 상세 모달의 닫기 버튼이 콘텐츠 길이와 무관하게 모달 하단에 항상 보이도록 sticky 처리
+
+#### SEO
+
+- 관리자 환경설정 > SEO 탭의 Sitemap 카드에서 sitemap 을 즉시 재생성하고 마지막 생성 시각을 확인할 수 있는 "지금 생성" 버튼 추가 — 큐 드라이버와 무관하게 동기 실행되며 결과를 즉시 표시
+
+#### SEO 봇 감지
+
+- 봇 감지 엔진을 `jaybizzle/crawler-detect` 라이브러리로 교체. 기본 약 1,000종의 봇(검색엔진·링크 미리보기·AI 검색 등)이 자동 감지됨 — 링크를 슬랙·페이스북·LinkedIn·트위터·디스코드·텔레그램 등에 붙여넣으면 제목·설명·이미지 미리보기가 즉시 동작
+- 라이브러리가 놓치는 봇 3종(`kakaotalk-scrap`·`Meta-ExternalAgent`·`ChatGPT-User`) 을 G7 보강 패턴으로 기본 포함
+- 봇 감지 확장 훅 `core.seo.resolve_is_bot` 신설 — 플러그인이 IP 범위 검증·역방향 DNS·Cloudflare 봇 점수 등을 주입할 수 있는 슬롯
+- "봇 라이브러리 사용" 관리자 토글 추가 (기본 on, 비활성 시 운영자 커스텀 목록만 사용하는 레거시 모드)
+
+#### SEO OG / Twitter 카드 / 도메인 ownership
+
+- 슬랙·페이스북 링크 미리보기에 이미지·카드가 표시되도록 OG 보강 태그를 자동 출력하도록 개선 — og:site_name, og:image:width, og:image:height, og:image:secure_url, og:image:type, og:image:alt, og:locale 추가
+- Twitter 카드 메타태그(twitter:card / twitter:site / twitter:title / twitter:image 등) 출력 신설 — 슬랙 unfurl 폴백 경로 정상화
+- 운영자 환경설정 SEO 탭에 "OG / Twitter 카드 기본값" 카드 추가 — 사이트 이름·이미지 기본 가로/세로·Twitter 카드 타입·Twitter 사이트 핸들 5개 입력
+- 모듈·플러그인이 자기 도메인의 OG/Twitter/JSON-LD 를 직접 선언하도록 SEO declaration API 신설 — 이커머스 상품(Product/Offer/AggregateRating)·게시판 게시글(Article) 등 도메인 스키마가 레이아웃에서 확장 코드로 owned 되어 데이터에 따라 정확한 부속 태그 생성
+- SEO 메타 확장 훅을 분기별·통합 모두 제공 — OG·Twitter·구조화 데이터 각각의 hook 슬롯과 통합 hook 모두 지원하여 확장이 원하는 단계에서 선택 변경 가능
+- 모듈 설정 타이틀 템플릿에서 변수가 비어있을 때 인접 구분자(- – · |) 가 자동 정리되도록 개선 — 옵셔널 그룹 `[ ... ]` 표기도 지원하여 페이지별 구성 명시 가능
+
+### Changed
+
+- 콘솔 confirm 입력 처리 통일 — yes/y, no/n 외 입력 시 안내 메시지 출력 후 재질문, empty 입력 시 default 사용. 코어 업데이트·매니저 커맨드(module/plugin/template install·update·uninstall)·설정 마이그레이션의 모든 yes/no 프롬프트에 동일 규칙 적용
+- 비밀번호 재설정 정책 기본값을 비활성으로 변경 — 본인인증 인프라가 미구성된 사이트에서도 기본 동작이 영향받지 않도록 운영자 opt-in 으로 전환
+- 본인인증 정책의 인증 조건(`conditions`) 운영자 편집 허용 — 회원가입 단계 등 정책 조건을 코드 수정 없이 관리자 화면에서 조정 가능. 모듈 업데이트 시 운영자 수정값 보존
+- 토큰 만료 등으로 권한 없는 레이아웃 진입 시 "페이지 로딩 실패" 에러 화면 대신 로그인 페이지로 자동 이동 — 로그인 화면에서 "세션이 만료되었습니다. 다시 로그인해 주세요." 토스트로 사용자에게 안내. 템플릿이 자체 로그인 경로를 사용하는 경우 부트스트랩에서 인증 설정을 커스터마이즈할 수 있는 공개 API 도 함께 제공
+- 템플릿 다국어 데이터 로딩 시 활성 언어팩의 다국어가 가장 높은 우선순위로 병합되도록 변경
+- 권한·역할·메뉴·알림 등 코어 기본 데이터와 배송유형·클레임 사유·게시판 유형 등 모듈 기본 데이터에 활성 언어팩의 다국어가 자동 반영되도록 개선
+- 사용자 수정 보존(user_overrides) 정책을 다국어 JSON 컬럼은 sub-key 단위(`name.ko` 등) 로 기록하도록 개선 — 운영자가 한 언어 라벨만 수정해도 그 언어만 보존되며, 신규 활성 언어팩(예: 일본어 추가) 의 라벨은 자동 동기화됨. 기존 컬럼 단위(`name`) 기록은 업그레이드 시 활성 locale dot-path 로 자동 변환
+- 언어팩 활성/비활성 시점에 영향받는 모듈/플러그인의 entity 시더가 자동 재실행되어 신규 언어 라벨이 즉시 DB 에 반영되도록 라이프사이클 통합 — scope 별 라우팅 (코어 언어팩 → 모든 활성 확장, 모듈 언어팩 → 해당 모듈만, 플러그인 언어팩 → 해당 플러그인만)
+- 환경설정 → SEO → "추가 봇 패턴" 필드의 역할 변경 — 기존에는 유일한 봇 매칭 소스(기본 5종)였으나, 이제는 라이브러리가 놓치는 조직별 커스텀 봇만 추가하는 보강 레이어로 동작. 기존 설치의 운영자 커스텀 값은 모두 보존되며, 신규 설치 기본값은 jaybizzle 미커버 3종으로 변경
+
+### Security
+
+- 회원가입·비밀번호 재설정 라우트에 본인인증 정책 강제 미들웨어 부착 — 정책이 활성화된 경우 미인증 요청을 라우트 단계에서 차단
+- 플러그인이 정책 해석 필터 훅에서 잘못된 타입을 반환해도 원본 정책이 유지되도록 우회 차단 강화
+- 웹 인스톨러의 Composer/PHP 바이너리 경로 검증에서 사용자 입력이 그대로 shell 명령으로 실행될 수 있던 문제 수정 — 입력은 실행 가능한 단일 파일 경로로만 허용하고 모든 분기에서 인자 escape 강제. 설치 워커가 동일한 입력을 사용하던 내부 헬퍼도 같은 정책으로 정렬
+- 설치 단계 4 의 확장 기능 선택 API 가 사용자가 보낸 모듈/플러그인/템플릿/언어팩 식별자에 셸 메타문자 검증을 적용하도록 강화 — 부적절한 식별자는 400 응답으로 거부되어 이후 설치 명령에 도달하지 않음
+- 인스톨러의 코어 업데이트 _pending 경로 검증이 `..` 등 부모 디렉토리 우회 시도를 거부하고 응답 메시지를 단일화하여 임의 디렉토리 enumeration 신호 차단
+- 설치 시 `.env` 작성 헬퍼가 입력값에 포함된 개행 문자를 제거하도록 변경 — DB 비밀번호 등 사용자 입력으로 새로운 환경 변수 라인이 주입되는 시나리오 차단
+- 데이터베이스 연결 정보의 host/port/database 값에 DSN 키-밸류 구분자(`;`, `=`) 또는 NUL/CRLF 가 포함되면 연결을 거부하도록 추가 검증
+- 설치가 완료된 시스템에서 `public/install/` 하위 모든 엔드포인트가 비즈니스 로직 진입 전 HTTP 410 으로 차단되도록 공통 가드 도입 — 운영 환경에서 인스톨러 노출형 결함의 공격 표면 제거. 운영자가 인스톨러를 다시 사용해야 하는 경우 설치 완료 마커(`storage/app/g7_installed`) 와 `.env` 의 `INSTALLER_COMPLETED` 를 모두 제거
+
+### Fixed
+
+- 코어 업데이트 후 일부 환경에서 모듈/플러그인이 저장한 사용자 데이터(상품 이미지·첨부파일 등)에 PHP 가 접근하지 못해 "찾을 수 없음" 오류가 발생하던 문제 수정 — 업데이트 종료 시점 권한 복원 범위를 PHP 쓰기 영역(storage/logs·storage/framework·bootstrap/cache·storage/app/core_pending)으로 한정하고 항목별 정확 복원으로 정합화. 이전 버전에서 본 릴리즈로 업그레이드한 환경에서는 업그레이드 시점에 storage/app 디렉토리/파일 권한을 PHP 가 접근 가능한 형태로 자동 정상화함. 향후 업데이트에서는 사용자 데이터 디렉토리에 자동 생성되는 보존 마커로 권한이 영구 보호됨
+- PHP 8.5 환경에서 모든 페이지 응답이 손상되어 Firefox 에서는 "Content Encoding Error", Edge/Chromium 에서는 빈 화면으로 표시되던 문제 수정
+- `php artisan serve` 환경에서 인스톨러 진행 중 .env 파일이 변경되면 개발 서버가 워커를 재시작하면서 설치 단계가 중도에 끊기던 문제 수정 — 설치 진행 중에는 .env 를 건드리지 않고 완료 화면 노출 후 한 번에 반영하도록 변경
+- 관리자 환경설정 "시스템 사양" 카드의 메모리 항목이 서버 물리 메모리가 아닌 현재 PHP 프로세스가 사용 중인 메모리(수 MB 단위)로 표시되던 문제 수정 — 디스크 사용량과 동일한 형식(사용량/전체/백분율)으로 실제 서버 RAM 을 표시하도록 개선
+- 관리자 환경설정 "시스템 사양" 카드의 CPU 항목이 Windows 11 / Windows Server 2025 에서 "operable program or batch file." 로 표시되던 문제 수정 — 해당 OS 에서 제거된 wmic 의존을 걷어내고 PowerShell 기반 조회로 전환, 구형 Windows 환경에서는 기존 방식으로 자동 폴백
+- 관리자 환경설정 "정보" 탭을 한 번 조회한 뒤 다른 탭으로 전환할 때마다 수 초 지연이 반복되던 문제 수정 — 시스템 정보 조회 결과를 1시간 캐싱하여 탭 전환 시 대기 시간 제거 (시스템 캐시 초기화 시 함께 무효화)
+- 큐 워커가 훅 페이로드의 enum 값(주문 상태 등)을 복원하지 못해 활동 로그·알림 등 일부 후속 처리가 실패하던 문제 수정
+- 항목 삭제 후 큐 워커가 처리하는 후속 훅에서 대상 데이터를 복원하지 못해 처리가 중단되던 문제 수정 — 소프트 삭제 페이지·첨부파일, 하드 삭제 주문 옵션 등 포함
+- 페이지 키워드 검색 결과의 전체 건수가 발행된 모든 페이지 수로 부풀려지던 문제 수정
+- 사용자가 댓글을 단 게시글 활동 조회 시 500 에러가 발생하던 문제 수정
+- 사용자 활동 통계에 삭제된 게시글의 댓글 수가 포함되던 문제 수정
+- 일부 주문에서 옵션 정보 직렬화 시 500 에러가 발생하던 문제 수정
+- 상품 옵션 삭제 검증 시 런타임 에러가 발생하던 문제 수정
+- 설치 직후 또는 활성 모듈 디렉토리 부재 시 이커머스 환경설정 기본값이 비어있던 문제 수정
+- 검색 가능 드롭다운(SearchableDropdown)에서 빠른 모달 전환 시 race condition 가능성 차단
+- 반응형 설정과 반복 렌더링이 결합된 레이아웃에서 무한 재귀가 발생할 수 있던 엔진 결함 수정 (engine-v1.43.1)
+- 슬롯 치환 시 텍스트 노드를 컴포넌트로 가정하여 발생할 수 있던 레이아웃 렌더링 오류 차단
+- 인스톨러 실행 시 보안 키 생성 단계에서 개발용 패키지 ServiceProvider 를 찾지 못해 설치가 중단되던 문제 수정 — 이전 환경에서 남은 컴파일 캐시를 vendor 교체 직후와 보안 키 생성 직전에 자동 정리하도록 개선
+- 폴링 모드 인스톨러가 큰 확장(레이아웃·테스트 수천 파일) 설치 도중 멈추던 문제 수정 — 명령 출력을 파일 기반으로 처리하여 OS 파이프 버퍼 한도와 무관하게 동작
+- PHP 8.5 + Apache + mod_fcgid 환경에서 폴링 모드 진행 상황이 실시간 반영되지 않고 설치 완료 시점에 일괄 표시되던 문제 수정 — 어느 모드로도 진행 상황이 즉시 표시되도록 보정. Apache 환경별 권장 설정은 INSTALL.md 와 시스템 요구사항 문서에 명시
+- 설치 완료 후 임시 파일이 자동 정리되지 않던 문제 수정
+- 알림 템플릿 편집 모달의 입력 필드에서 글자를 입력할 때마다 화면이 심하게 버벅이던 문제 수정
+- 슈퍼관리자가 다른 관리자 계정을 삭제할 수 없던 문제 수정 — 관리자 계정 삭제 가능 여부를 역할/권한/스코프 설정에 따라 판단하도록 개선 (슈퍼관리자 본인 보호는 유지)
+- `seo:generate-sitemap` 커맨드가 큐 드라이버 설정과 무관하게 항상 "큐에 디스패치" 안내를 출력하던 문제 수정 — 동기 드라이버에서는 즉시 생성으로 동작하고 그에 맞는 안내를 표시하도록 변경
+- 관리자 템플릿을 활성화해도 즉시 반영되지 않아 사용자가 직접 새로고침해야 하던 문제 수정 — 관리자 템플릿 활성화 시 자동으로 페이지를 갱신하여 새 템플릿이 즉시 적용되도록 개선
+- 보안 환경설정의 "최대 로그인 시도 횟수 / 차단 시간" 설정이 실제로 적용되지 않아 무제한 로그인 시도가 가능하던 문제 수정 — 임계 도달 시 계정 잠금(HTTP 423), 잠금 해제 시각 안내 토스트, per-IP 백업 throttle, 활동 로그 기록까지 통합 구현
+- 게시판 글쓰기 화면을 URL 로 직접 진입하거나 강제 새로고침했을 때 업로드한 첨부파일이 게시글에 연결되지 않던 문제 수정 (engine-v1.49.2)
+- 코어 업그레이드 후 새 버전에서 추가된 권한·메뉴·알림 정의가 등록되지 않아 관리자 화면에서 "해당 권한이 없습니다" 가 반복 표시되거나 신규 메일 템플릿이 비어있던 문제 수정 — 업그레이드 시 새 버전 설정 파일을 정확히 인식하도록 보정. 본 릴리즈로 업그레이드하면 누락분이 자동 등록됨
+
## [7.0.0-beta.3] - 2026-04-23
### Fixed
diff --git a/INSTALL.md b/INSTALL.md
index febc011e..67c7cb01 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -47,6 +47,14 @@ git clone https://github.com/gnuboard/g7.git
```
+**Apache + mod_fcgid 환경 추가 설정** (PHP 8.5 NTS Windows 등 mod_php 미제공 빌드):
+
+`fcgid.conf` 에 다음 1줄을 추가 후 Apache 재시작. 미설정 시 mod_fcgid 의 default 64KB 출력 버퍼가 인스톨러 SSE 스트림과 폴링 응답을 스크립트 종료 시점까지 보관하여 설치 진행 상황이 화면에 실시간 반영되지 않는다.
+
+```apache
+FcgidOutputBufferSize 0
+```
+
**Nginx 예시**:
```nginx
@@ -238,7 +246,7 @@ unzip g7-release.zip
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
ls -la
-# (필요 시) mv g7-7.0.0-beta.3 g7
+# (필요 시) mv g7-7.0.0-beta.4 g7
# ZIP 파일 정리 (선택)
rm g7-release.zip
diff --git a/README.md b/README.md
index 0eed119e..5d18f416 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
-
+
@@ -43,16 +43,19 @@ Laravel과 React를 기반으로, 보안부터 아키텍처까지 처음부터
| 영역 | 설명 |
|------|------|
| **모듈 아키텍처** | 모듈 + 플러그인 + 템플릿 3중 확장 구조. 코어 수정 없이 독립적 모듈(게시판, 커머스 등) 개발이 가능합니다. Hook 기반 기능 주입으로 Service-Repository 패턴의 명확한 계층 분리를 유지합니다 |
-| **현지화** | 백엔드부터 프론트엔드까지 일관된 다국어 개발 환경을 제공합니다. 로케일 기반 UI, 확장 가능한 언어 팩을 지원합니다 |
+| **언어팩 시스템** | 새 언어를 코어 수정 없이 ZIP 또는 GitHub URL 로 설치할 수 있습니다. 일본어 등 공식 번들 언어팩을 즉시 사용할 수 있고, 운영자가 직접 수정한 라벨은 언어팩이 덮어쓰지 않도록 sub-key 단위로 보존합니다. 모듈/플러그인/템플릿 단위로 별도 적용 가능 |
+| **현지화** | 백엔드부터 프론트엔드까지 일관된 다국어 개발 환경을 제공합니다. 활성 언어팩이 알림 채널 라벨, Provider/Registry 페이로드, 환경설정 카탈로그(결제수단·통화·배송 가능 국가)까지 자동 보강되며, 모듈/플러그인이 자기 도메인 라벨을 자기 영역에서 자기설명하도록 활동 로그·메시지 영역도 분리되어 있습니다 |
| **해외 결제** | 로컬 비즈니스를 넘어 글로벌 커머스로 도약하기 위한 기반을 제공합니다 `정식버전에서 지원예정` |
| **권한 제어** | 역할별 메뉴와 기능, 데이터 범위까지 제어할 수 있습니다. 역할(Role) + 권한(Permission) + 스코프(Scope) 3단계 접근 제어로 조직 구조에 맞는 유연한 접근 관리를 제공합니다 |
-| **보안** | 입력값 자동 검증과 토큰 기반 인증을 제공합니다. 설계부터 보안을 고려한 다층 방어 구조(CSRF/XSS/SQL Injection)를 구현합니다 |
+| **본인인증 (IDV)** | 회원가입·비밀번호 재설정·민감 작업 등 모든 본인인증 시점을 라우트/훅 단위 선언형 정책으로 통합 관리합니다. 코어가 메일 프로바이더를 기본 내장하고, 외부 KCP·이니시스·SMS·PortOne·Stripe Identity 등은 동일한 Provider 계약으로 붙일 수 있는 확장점을 제공합니다. 서버가 HTTP 428 응답을 반환하면 프론트엔드 인터셉터가 자동으로 인증 모달을 띄우고 인증 성공 시 원 요청을 재실행합니다 |
+| **보안** | 입력값 자동 검증과 토큰 기반 인증을 제공합니다. 설계부터 보안을 고려한 다층 방어 구조(CSRF/XSS/SQL Injection), 로그인 시도 제한·계정 잠금(HTTP 423) 실제 구현, 설치 완료 후 인스톨러 엔드포인트 자동 차단(HTTP 410) 까지 다층 방어를 구성합니다 |
| **유연한 화면 구성** | 화면 구조를 정의하면 즉시 반영할 수 있습니다. 프론트엔드 인프라 없이 JSON 선언만으로 웹앱 수준의 동적 화면 구현이 가능합니다 |
| **레이아웃 편집기** | 위지윅 기반 레이아웃 편집 기능으로 화면 블록을 직접 배치하고 수정 결과를 바로 확인할 수 있습니다 `정식버전에서 지원예정` |
| **검증된 기반** | Laravel + React 기반을 제공합니다. 글로벌 기업이 채택한 기술 스택으로 높은 확장성과 유연한 UI 구현이 가능합니다 |
| **공통 캐시 시스템** | `CacheInterface` 와 코어/모듈/플러그인 3종 드라이버로 키 접두사(`g7:core:`, `g7:module.{id}:`, `g7:plugin.{id}:`) 를 자동 격리합니다. 태그 기반 자동 무효화와 `g7_core_settings('cache.*_ttl')` 중앙 관리로 하드코딩 없이 운영할 수 있습니다 |
-| **알림 시스템** | 알림 정의(Definition) × 템플릿(Template) × 수신자(Recipients) 3계층 구조로 메일/DB/실시간 브로드캐스트(Reverb) 다채널 독립 발송을 지원합니다. 작성자·역할·특정 사용자 단위 타겟팅과 훅 기반 발송으로 모듈이 자체 알림을 자유롭게 등록할 수 있습니다 |
-| **활동 로그** | 관리자·사용자 활동 이력을 자동으로 기록하고 조회할 수 있습니다. Monolog 기반 구조로 확장이 용이합니다 |
+| **알림 시스템** | 알림 정의(Definition) × 템플릿(Template) × 수신자(Recipients) 3계층 구조로 메일/DB/실시간 브로드캐스트(Reverb) 다채널 독립 발송을 지원합니다. 작성자·역할·특정 사용자·권한 보유자 단위 타겟팅과 훅 기반 발송으로 모듈이 자체 알림을 자유롭게 등록할 수 있습니다 |
+| **SEO** | `jaybizzle/crawler-detect` 기반으로 약 1,000종 봇(검색엔진·SNS unfurl·AI 검색)을 자동 감지하여 봇 요청에는 정적 HTML 을, 일반 사용자에게는 SPA 를 응답합니다. OG/Twitter 카드 메타와 모듈이 선언한 도메인 스키마(Article/Product/Offer/AggregateRating), Sitemap 자동·수동 생성, Generator 메타 태그까지 표준 SEO 표면을 코어에서 제공합니다 |
+| **활동 로그** | 관리자·사용자 활동 이력을 자동으로 기록하고 조회할 수 있습니다. Monolog 기반 구조로 확장이 용이하며, 액션 라벨이 모듈/플러그인 자체 다국어 파일에서 우선 해석되어 도메인별 자기설명이 가능합니다 |
| **검색** | Laravel Scout 기반 전문 검색을 지원합니다. 상품, 게시글 등 주요 콘텐츠를 대상으로 검색 기능을 제공합니다 |
---
@@ -77,12 +80,16 @@ Gnuboard7
│ ├── Controller → FormRequest → Service → Repository → Model
│ ├── Hook System (Action / Filter)
│ ├── Permission (Role → Permission → Scope)
+│ ├── Identity Verification (Policy × Purpose × Provider × Message)
+│ ├── Language Pack (가상 보호 행 + ZIP/GitHub 설치 + sub-key 보존)
+│ ├── Notification (Definition × Template × Recipients)
│ └── SEO (Bot Detection → Static HTML → Cache → Sitemap)
│
├── Extensions
│ ├── Modules — 게시판, 쇼핑몰, 페이지 ...
│ ├── Plugins — 결제, 인증, 마케팅 ...
-│ └── Templates — 관리자 UI, 사용자 UI
+│ ├── Templates — 관리자 UI, 사용자 UI
+│ └── LanguagePacks — 일본어 등 공식/외부 언어팩
│
└── Template Engine
├── JSON Layout → React Components
@@ -294,6 +301,48 @@ HookManager::doAction('sirsoft-ecommerce.order.after_confirm', $order);
- 실시간 브로드캐스트는 Laravel Reverb (WebSocket) 기반. Reverb 미구성 환경에서는 graceful skip 으로 오류 없이 동작
- `GenericNotification` 단일 클래스가 모든 알림을 처리 — 신규 알림 타입 추가 시 개별 Notification 클래스 작성 불필요
+#### 5. 언어팩 시스템
+
+새 언어를 코어 수정 없이 추가할 수 있는 운영 도구로, 모듈/플러그인/템플릿 관리와 동일한 라이프사이클(설치 → 활성화 → 업데이트 → 제거 + 자동 백업/롤백) 을 제공합니다.
+
+| 영역 | 동작 |
+| --- | --- |
+| 설치 경로 | ZIP 업로드 / GitHub URL / `lang-packs/_bundled` 번들 디렉토리 (코어 업데이트 시 일괄 동기화) |
+| 적용 범위 | 코어, 모듈, 플러그인, 템플릿 별도 적용 — 모듈 언어팩은 해당 코어 언어팩이 활성일 때만 활성화 |
+| 사용자 수정 보존 | 다국어 JSON 컬럼은 sub-key 단위 (`name.ko` / `name.ja`) 로 user override 기록 — 한 언어 라벨만 수정해도 그 언어만 보존, 신규 언어는 자동 동기화 |
+| 활성화 시점 | 활성/비활성 시 영향받는 모듈/플러그인의 entity 시더가 자동 재실행 → 메뉴·권한·역할·매니페스트·알림 라벨 즉시 DB 반영 |
+| 가상 보호 행 | 코어/번들 확장에 내장된 한국어/영어는 별도 설치 없이 항상 활성/보호 상태로 노출 (수정/제거 차단) |
+| 보안 | 언어 번역 외의 PHP 실행 코드 포함 시 설치 차단 |
+
+공식 일본어(ja) 번들 12종(코어 + 주요 모듈/플러그인/템플릿) 이 즉시 사용 가능하며, 인스톨러 4단계에서 모듈/플러그인/템플릿 선택과 종속된 언어팩 카드가 자동 연동되어 함께 설치할 수 있습니다.
+
+> 상세: [docs/extension/language-packs.md](docs/extension/language-packs.md)
+
+#### 6. 본인인증 (Identity Verification)
+
+회원가입·비밀번호 재설정·민감 작업·결제 직전 등 모든 본인인증 시점을 라우트/훅 단위 선언형 정책으로 통합 관리합니다.
+
+```text
+┌────────────────────┐ ┌─────────────────────┐ ┌──────────────────────┐
+│ Policy │ │ Purpose │ │ Provider │
+│ (강제 시점·실패 모드│ │ (인증 목적·허용 채널│ │ (메일·KCP·이니시스 │
+│ ·단계·conditions) │ ◀▶ │ ·source 추적) │ ◀▶ │ ·SMS·외부 IDV ...) │
+└────────────────────┘ └─────────────────────┘ └──────────────────────┘
+ │ │
+ └──────────▶ Message Template (정책×목적 매핑) ◀───┘
+ │
+ GenericNotification
+```
+
+- **정책 SSoT** — 정책 enable 토글이 라우트 코드 수정 없이 즉시 적용. 모든 API 라우트가 정책 DB 와 자동 매칭
+- **428 인터셉터** — 서버가 HTTP 428 응답을 반환하면 프론트엔드가 자동으로 인증 모달을 띄우고 인증 성공 시 원 요청을 자동 재실행
+- **선언형 등록** — 모듈/플러그인은 `module.php::getIdentityPolicies()` / `getIdentityPurposes()` / `getIdentityMessages()` 만 선언하면 활성화/업데이트 시 자동 등록되며 운영자 편집값 보존
+- **메시지 템플릿** — 프로바이더와 (목적/정책)별로 다국어 제목/본문을 개별 정의. 정책 → 목적 → 프로바이더 기본값 순서로 fallback
+- **외부 Provider 슬롯** — 플러그인이 KCP·PortOne·토스인증·Stripe Identity 등을 G7 표준 Extension Point 패턴으로 자기 SDK UI 를 주입 가능
+- **이력 관리** — 관리자 화면에서 인증 수단 탭, 통합 검색, 상태/목적/채널/IP 멀티 필터, 보관주기(180일) 일괄 파기 제공
+
+> 상세: [docs/backend/identity-policies.md](docs/backend/identity-policies.md), [docs/backend/identity-providers.md](docs/backend/identity-providers.md), [docs/backend/identity-messages.md](docs/backend/identity-messages.md)
+
---
## 빠른 시작
@@ -349,6 +398,35 @@ cp .env.example .env
| **sirsoft-admin_basic** | 관리자 기본 템플릿 |
| **sirsoft-basic** | 사용자 기본 템플릿 |
+### 번들 언어팩
+
+설치 시 함께 동반 설치할 수 있는 공식 언어팩입니다. 코어 + 주요 모듈/플러그인/템플릿이 일관된 번역으로 즉시 사용 가능합니다.
+
+| 식별자 | 설명 |
+| ------ | ---- |
+| **g7-core-ja** | 코어 일본어 |
+| **g7-module-sirsoft-board-ja** | 게시판 모듈 일본어 |
+| **g7-module-sirsoft-ecommerce-ja** | 이커머스 모듈 일본어 |
+| **g7-module-sirsoft-page-ja** | 페이지 모듈 일본어 |
+| **g7-plugin-sirsoft-ckeditor5-ja** | CKEditor5 플러그인 일본어 |
+| **g7-plugin-sirsoft-marketing-ja** | 마케팅 플러그인 일본어 |
+| **g7-plugin-sirsoft-tosspayments-ja** | 토스페이먼츠 플러그인 일본어 |
+| **g7-template-sirsoft-admin_basic-ja** | 관리자 기본 템플릿 일본어 |
+| **g7-template-sirsoft-basic-ja** | 사용자 기본 템플릿 일본어 |
+
+> 한국어/영어는 코어/번들 확장에 내장되어 있으며 설치 없이 항상 활성 상태로 동작합니다. 새 언어는 ZIP 또는 GitHub URL 로 자유롭게 추가할 수 있습니다.
+
+### 학습용 샘플 확장
+
+확장 시스템 학습을 위한 최소 구현 샘플입니다. 관리자 UI 에서 "숨김 포함" 토글로 노출되며 CLI 에서는 항상 보입니다.
+
+| 식별자 | 종류 | 설명 |
+| ------ | ---- | ---- |
+| **gnuboard7-hello_module** | 모듈 | Memo CRUD + 훅 발행 시연 |
+| **gnuboard7-hello_plugin** | 플러그인 | Action/Filter 훅 구독 시연 |
+| **gnuboard7-hello_admin_template** | Admin 템플릿 | Basic 컴포넌트 최소 셋 |
+| **gnuboard7-hello_user_template** | User 템플릿 | 홈 + Memo 리스트 연동 |
+
---
## 비즈니스 모델
@@ -433,7 +511,7 @@ cp .env.example .env
## 보안 취약점
-보안 취약점을 발견하셨다면 [GitHub Issues](https://github.com/gnuboard/g7/issues)에 보고해 주세요.
+보안 취약점을 발견하셨다면 [SIR 문의게시판](https://sir.kr/boards/co_qa)에 비밀글로 제보해 주세요.
---
diff --git a/app/ActivityLog/Traits/ResolvesActivityLogType.php b/app/ActivityLog/Traits/ResolvesActivityLogType.php
index 5c951884..d0268296 100644
--- a/app/ActivityLog/Traits/ResolvesActivityLogType.php
+++ b/app/ActivityLog/Traits/ResolvesActivityLogType.php
@@ -3,6 +3,7 @@
namespace App\ActivityLog\Traits;
use App\Enums\ActivityLogType;
+use App\Extension\ExtensionManager;
use Illuminate\Support\Facades\Log;
/**
@@ -39,6 +40,9 @@ trait ResolvesActivityLogType
* 활동 로그를 기록합니다.
*
* context에 log_type이 명시되지 않으면 resolveLogType()으로 자동 결정합니다.
+ * 호출 클래스 FQCN 으로부터 모듈/플러그인 origin 을 추론하여
+ * properties.extension_origin 에 자동 주입합니다 (loggable 미지정 케이스의
+ * action 라벨 namespace fallback 용).
*
* @param string $action 액션명 (예: 'user.create')
* @param array $context Monolog context 배열
@@ -47,6 +51,13 @@ trait ResolvesActivityLogType
{
$context['log_type'] ??= $this->resolveLogType();
+ $origin = ExtensionManager::resolveExtensionByFqcn(static::class);
+ if ($origin !== null) {
+ $properties = $context['properties'] ?? [];
+ $properties['extension_origin'] ??= $origin;
+ $context['properties'] = $properties;
+ }
+
try {
Log::channel('activity')->info($action, $context);
} catch (\Exception $e) {
diff --git a/app/Concerns/Seeder/HasTranslatableSeeder.php b/app/Concerns/Seeder/HasTranslatableSeeder.php
new file mode 100644
index 00000000..1bfd5d51
--- /dev/null
+++ b/app/Concerns/Seeder/HasTranslatableSeeder.php
@@ -0,0 +1,42 @@
+resolveTranslatedDefaults() 호출 시
+ * 활성 언어팩의 ja/en 등 locale 키가 자동 머지된 entry 배열을 반환.
+ *
+ * @since 7.0.0-beta.5
+ */
+trait HasTranslatableSeeder
+{
+ /**
+ * 활성 언어팩의 다국어 데이터로 머지된 시드 entry 를 반환합니다.
+ *
+ * @return array>
+ */
+ protected function resolveTranslatedDefaults(): array
+ {
+ return HookManager::applyFilters(
+ $this->resolveTranslationFilterName(),
+ $this->getDefaults(),
+ );
+ }
+
+ /**
+ * `seed.{ext}.{entity}.translations` 필터 키를 조립합니다.
+ */
+ protected function resolveTranslationFilterName(): string
+ {
+ $extension = $this->getExtensionIdentifier();
+ $entity = $this->getTranslatableEntity();
+
+ return $extension !== ''
+ ? "seed.{$extension}.{$entity}.translations"
+ : "seed.{$entity}.translations";
+ }
+}
diff --git a/app/Console/Commands/Core/Concerns/BundledExtensionUpdatePrompt.php b/app/Console/Commands/Core/Concerns/BundledExtensionUpdatePrompt.php
index 6d923e79..eb42c6a9 100644
--- a/app/Console/Commands/Core/Concerns/BundledExtensionUpdatePrompt.php
+++ b/app/Console/Commands/Core/Concerns/BundledExtensionUpdatePrompt.php
@@ -2,10 +2,14 @@
namespace App\Console\Commands\Core\Concerns;
+use App\Console\Commands\Core\ExecuteBundledUpdatesCommand;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Extension\Vendor\VendorMode;
+use App\Services\CoreUpdateService;
+use App\Services\LanguagePackService;
+use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
/**
@@ -21,13 +25,16 @@ use Illuminate\Support\Facades\Log;
* 6) 결과 요약 출력
*
* --force 옵션이 지정된 경우: 프롬프트 스킵 + 전역 전략 'overwrite' 로 즉시 실행.
+ *
+ * 본 트레이트를 사용하는 Command 는 HasUnifiedConfirm 트레이트도 함께 사용해야 한다
+ * (yes/no 입력 정규화 및 재질문 루프 제공).
*/
trait BundledExtensionUpdatePrompt
{
/**
* 번들 업데이트 목록 수집.
*
- * @return array{modules: array, plugins: array, templates: array}
+ * @return array{modules: array, plugins: array, templates: array, lang_packs: array}
*/
protected function collectBundledUpdates(
ModuleManager $moduleManager,
@@ -37,7 +44,12 @@ trait BundledExtensionUpdatePrompt
// CoreUpdateService::collectBundledExtensionUpdates() 를 통해 _bundled manifest 버전을
// DB 현재 버전과 직접 비교한다. Manager::checkXxxUpdate() 의 "GitHub 엄격 우선" 정책을
// 우회하여 GitHub 미릴리스 상태에서도 _bundled 신버전을 정확히 감지.
- return app(\App\Services\CoreUpdateService::class)->collectBundledExtensionUpdates();
+ $extUpdates = app(CoreUpdateService::class)->collectBundledExtensionUpdates();
+
+ // 언어팩도 동일 패턴으로 _bundled vs DB 직접 비교 (LanguagePackService::collectBundledLangPackUpdates).
+ $extUpdates['lang_packs'] = app(LanguagePackService::class)->collectBundledLangPackUpdates();
+
+ return $extUpdates;
}
/**
@@ -52,7 +64,8 @@ trait BundledExtensionUpdatePrompt
bool $force,
): array {
$updates = $this->collectBundledUpdates($moduleManager, $pluginManager, $templateManager);
- $total = count($updates['modules']) + count($updates['plugins']) + count($updates['templates']);
+ $updates['lang_packs'] = $updates['lang_packs'] ?? [];
+ $total = count($updates['modules']) + count($updates['plugins']) + count($updates['templates']) + count($updates['lang_packs']);
if ($total === 0) {
$this->info('활성 확장이 최신 번들과 일치합니다.');
@@ -71,9 +84,12 @@ trait BundledExtensionUpdatePrompt
foreach ($updates['templates'] as $t) {
$this->line(" [템플릿] {$t['identifier']} {$t['current_version']} → {$t['latest_version']}");
}
+ foreach ($updates['lang_packs'] as $lp) {
+ $this->line(" [언어팩] {$lp['identifier']} {$lp['current_version']} → {$lp['latest_version']}");
+ }
$this->newLine();
- if (! $force && ! $this->confirm('일괄 업데이트를 진행하시겠습니까?', true)) {
+ if (! $force && ! $this->unifiedConfirm('일괄 업데이트를 진행하시겠습니까?', true)) {
$this->info('일괄 업데이트를 건너뜁니다.');
return ['success' => 0, 'failed' => 0, 'skipped' => $total, 'has_updates' => true];
@@ -107,10 +123,12 @@ trait BundledExtensionUpdatePrompt
* 확장별 전략 오버라이드 수집.
*
* @param array $updates collectBundledUpdates() 반환값
- * @return array key: "{type}:{identifier}", value: strategy
+ * @return array key: "{type}:{identifier}", value: strategy
*/
private function collectPerExtensionStrategies(array $updates, string $globalStrategy, bool $force): array
{
+ // lang_packs 는 layout 이 없어 overwrite/keep strategy 가 의미 없으므로 의도적으로 제외.
+ // 매니페스트에는 strategy 없이 그대로 전달되어 ExecuteBundledUpdatesCommand 가 일괄 처리.
$strategies = [];
foreach (['modules', 'plugins', 'templates'] as $type) {
foreach ($updates[$type] as $ext) {
@@ -122,7 +140,7 @@ trait BundledExtensionUpdatePrompt
return $strategies;
}
- if (! $this->confirm('전역 전략과 다르게 적용할 확장이 있습니까?', false)) {
+ if (! $this->unifiedConfirm('전역 전략과 다르게 적용할 확장이 있습니까?', false)) {
return $strategies;
}
@@ -167,6 +185,13 @@ trait BundledExtensionUpdatePrompt
/**
* 실제 일괄 업데이트 실행.
*
+ * 구조 fix (beta.4 도입): 부모(`core:update`) 프로세스의 stale memory 가 신버전 sync
+ * 메서드를 호출하지 못하던 결함의 영구 차단. 사용자 선택을 매니페스트로 직렬화한 후
+ * `core:execute-bundled-updates` 를 별도 PHP 프로세스에서 spawn 하여 실행한다 (자식은
+ * 디스크의 fresh 코어 코드 로드).
+ *
+ * proc_open 미지원 / 실패 환경에서는 in-process fallback 으로 안전하게 전환 (기존 흐름).
+ *
* @return array{success: int, failed: int, skipped: int, has_updates: bool}
*/
private function executeBulkUpdate(
@@ -176,12 +201,194 @@ trait BundledExtensionUpdatePrompt
array $updates,
array $strategies,
): array {
- $success = 0;
- $failed = 0;
+ $manifest = $this->buildBundledUpdateManifest($updates, $strategies);
+ if ($this->bundledManifestIsEmpty($manifest)) {
+ return ['success' => 0, 'failed' => 0, 'skipped' => 0, 'has_updates' => false];
+ }
$this->newLine();
$this->info('── 일괄 업데이트 실행 ──');
+ $spawnResult = $this->spawnBundledUpdates($manifest);
+ if ($spawnResult !== null) {
+ return [
+ 'success' => $spawnResult['success'],
+ 'failed' => $spawnResult['failed'],
+ 'skipped' => 0,
+ 'has_updates' => true,
+ ];
+ }
+
+ // proc_open 미지원 / spawn 실패 — in-process fallback
+ $this->warn('별도 프로세스 spawn 실패 — in-process fallback 으로 전환합니다.');
+
+ return $this->executeBulkUpdateInProcess(
+ $moduleManager,
+ $pluginManager,
+ $templateManager,
+ $updates,
+ $strategies,
+ );
+ }
+
+ /**
+ * 사용자 선택을 spawn 자식에 전달할 매니페스트 형식으로 직렬화.
+ *
+ * @return array{modules: array, plugins: array, templates: array, lang_packs: array}
+ */
+ private function buildBundledUpdateManifest(array $updates, array $strategies): array
+ {
+ $manifest = [
+ 'modules' => [],
+ 'plugins' => [],
+ 'templates' => [],
+ 'lang_packs' => [],
+ ];
+
+ foreach ($updates['modules'] ?? [] as $m) {
+ $id = $m['identifier'];
+ $manifest['modules'][] = [
+ 'identifier' => $id,
+ 'strategy' => $strategies["modules:{$id}"] ?? 'overwrite',
+ ];
+ }
+ foreach ($updates['plugins'] ?? [] as $p) {
+ $id = $p['identifier'];
+ $manifest['plugins'][] = [
+ 'identifier' => $id,
+ 'strategy' => $strategies["plugins:{$id}"] ?? 'overwrite',
+ ];
+ }
+ foreach ($updates['templates'] ?? [] as $t) {
+ $id = $t['identifier'];
+ $manifest['templates'][] = [
+ 'identifier' => $id,
+ 'strategy' => $strategies["templates:{$id}"] ?? 'overwrite',
+ ];
+ }
+ foreach ($updates['lang_packs'] ?? [] as $lp) {
+ $manifest['lang_packs'][] = ['identifier' => $lp['identifier']];
+ }
+
+ return $manifest;
+ }
+
+ private function bundledManifestIsEmpty(array $manifest): bool
+ {
+ return empty($manifest['modules'])
+ && empty($manifest['plugins'])
+ && empty($manifest['templates'])
+ && empty($manifest['lang_packs']);
+ }
+
+ /**
+ * `core:execute-bundled-updates` 를 별도 PHP 프로세스에서 실행.
+ *
+ * 자식 stdout 을 부모 콘솔로 실시간 전달 (단 `[BUNDLED-RESULT]` prefix 라인은
+ * 결과 페이로드로 보관 후 부모 콘솔로 노출하지 않음). 종료 후 페이로드 파싱.
+ *
+ * @return array{success: int, failed: int}|null spawn 성공 시 결과, 실패/미지원 시 null
+ */
+ private function spawnBundledUpdates(array $manifest): ?array
+ {
+ if (! function_exists('proc_open')) {
+ return null;
+ }
+
+ $manifestPath = storage_path('app/core_pending'.DIRECTORY_SEPARATOR.'bundled-updates-manifest_'.uniqid().'.json');
+ File::ensureDirectoryExists(dirname($manifestPath));
+ File::put($manifestPath, json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
+
+ try {
+ $phpBinary = config('process.php_binary', PHP_BINARY);
+ $artisan = base_path('artisan');
+
+ $command = [
+ $phpBinary,
+ $artisan,
+ 'core:execute-bundled-updates',
+ '--manifest='.$manifestPath,
+ ];
+ $commandLine = implode(' ', array_map('escapeshellarg', $command)).' 2>&1';
+
+ $descriptors = [
+ 0 => ['pipe', 'r'],
+ 1 => ['pipe', 'w'],
+ 2 => ['pipe', 'w'],
+ ];
+
+ // ENV 합집합 (G7_UPDATE_IN_PROGRESS 등 핵심 플래그 자식에 전달)
+ $env = array_merge(getenv(), $_ENV);
+
+ $process = proc_open($commandLine, $descriptors, $pipes, base_path(), $env);
+ if (! is_resource($process)) {
+ return null;
+ }
+
+ fclose($pipes[0]);
+
+ $resultPayload = null;
+ $resultPrefix = ExecuteBundledUpdatesCommand::RESULT_PREFIX;
+
+ while (! feof($pipes[1])) {
+ $line = fgets($pipes[1]);
+ if ($line === false) {
+ continue;
+ }
+ $trimmed = rtrim($line);
+
+ if (str_starts_with($trimmed, $resultPrefix)) {
+ $json = substr($trimmed, strlen($resultPrefix));
+ $decoded = json_decode($json, true);
+ if (is_array($decoded)) {
+ $resultPayload = $decoded;
+ }
+
+ continue; // 부모 콘솔에 노출 안 함
+ }
+
+ $this->line($trimmed);
+ }
+
+ fclose($pipes[1]);
+ fclose($pipes[2]);
+ proc_close($process);
+
+ if ($resultPayload === null) {
+ Log::warning('번들 spawn 자식이 결과 페이로드를 출력하지 않음 — 카운트 0 으로 처리');
+
+ return ['success' => 0, 'failed' => 0];
+ }
+
+ return [
+ 'success' => (int) ($resultPayload['success'] ?? 0),
+ 'failed' => (int) ($resultPayload['failed'] ?? 0),
+ ];
+ } finally {
+ if (File::exists($manifestPath)) {
+ File::delete($manifestPath);
+ }
+ }
+ }
+
+ /**
+ * in-process fallback — proc_open 미지원 환경 전용.
+ *
+ * 기존 (beta.3) 흐름의 직접 호출 패턴 보존. 단 부모 메모리의 stale 코드로 인해
+ * sync 메서드 누락 결함이 재현될 수 있으므로 spawn 가능 환경에서는 사용되지 않는다.
+ *
+ * @return array{success: int, failed: int, skipped: int, has_updates: bool}
+ */
+ private function executeBulkUpdateInProcess(
+ ModuleManager $moduleManager,
+ PluginManager $pluginManager,
+ TemplateManager $templateManager,
+ array $updates,
+ array $strategies,
+ ): array {
+ $success = 0;
+ $failed = 0;
+
foreach ($updates['modules'] as $m) {
$id = $m['identifier'];
$strategy = $strategies["modules:{$id}"] ?? 'overwrite';
@@ -224,6 +431,24 @@ trait BundledExtensionUpdatePrompt
}
}
+ $langPackService = app(LanguagePackService::class);
+ foreach ($updates['lang_packs'] ?? [] as $lp) {
+ $id = $lp['identifier'];
+ $this->line("→ [언어팩] {$id}");
+ try {
+ $pack = $langPackService->findByIdentifier($id);
+ if (! $pack) {
+ throw new \RuntimeException(__('language_packs.errors.identifier_not_found', ['identifier' => $id]));
+ }
+ $langPackService->performUpdate($pack, true);
+ $success++;
+ } catch (\Throwable $e) {
+ $failed++;
+ $this->warn(" 실패: {$e->getMessage()}");
+ Log::error('번들 일괄 업데이트 실패', ['type' => 'lang_pack', 'id' => $id, 'error' => $e->getMessage()]);
+ }
+ }
+
$this->newLine();
$this->info("업데이트 완료: 성공 {$success}, 실패 {$failed}");
diff --git a/app/Console/Commands/Core/CoreUpdateCommand.php b/app/Console/Commands/Core/CoreUpdateCommand.php
index 42d32503..f13f6890 100644
--- a/app/Console/Commands/Core/CoreUpdateCommand.php
+++ b/app/Console/Commands/Core/CoreUpdateCommand.php
@@ -4,6 +4,7 @@ namespace App\Console\Commands\Core;
use App\Console\Commands\Core\Concerns\BundledExtensionUpdatePrompt;
use App\Exceptions\UpgradeHandoffException;
+use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Extension\CoreVersionChecker;
use App\Extension\Helpers\CoreBackupHelper;
use App\Extension\ModuleManager;
@@ -18,6 +19,7 @@ use Illuminate\Support\Facades\Log;
class CoreUpdateCommand extends Command
{
use BundledExtensionUpdatePrompt;
+ use HasUnifiedConfirm;
protected $signature = 'core:update
{--force : 버전 비교 없이 강제 업데이트}
@@ -180,7 +182,7 @@ class CoreUpdateCommand extends Command
}
$this->newLine();
- if (! $this->confirm('코어를 업데이트하시겠습니까?')) {
+ if (! $this->unifiedConfirm('코어를 업데이트하시겠습니까?', false)) {
return Command::SUCCESS;
}
@@ -285,6 +287,22 @@ class CoreUpdateCommand extends Command
$log('원본 소유권 스냅샷 수집: '.implode(',', array_keys($ownershipSnapshot)));
}
+ // PHP-FPM 쓰기 영역의 항목별 정확 스냅샷 (owner/group/perms). Stage 4.
+ // sudo update 가 root 로 만들 수 있는 좁은 영역(storage/logs, storage/framework,
+ // storage/app/core_pending, bootstrap/cache) 의 모든 하위 항목을 재귀 stat 하여
+ // Step 11/12 의 restoreOwnership 이 항목별 정확 복원하도록 전달한다.
+ // 사용자 데이터 영역(storage/app/{modules,plugins,attachments,public,settings})
+ // 은 본 스냅샷 대상이 아니며 chown 자체가 빠지므로 시드/업로드 owner 가 보존된다.
+ $detailedOwnershipSnapshot = $service->snapshotOwnershipDetailed([
+ 'storage/logs',
+ 'storage/framework',
+ 'storage/app/core_pending',
+ 'bootstrap/cache',
+ ]);
+ if (! empty($detailedOwnershipSnapshot)) {
+ $log('항목별 정확 스냅샷 수집: '.count($detailedOwnershipSnapshot).'개 항목 (PHP-FPM 쓰기 영역)');
+ }
+
// ── Step 6: _pending에서 Vendor 설치 (composer 또는 bundled) ──
$vendorMode = VendorMode::fromStringOrAuto((string) $this->option('vendor-mode'));
$composerSkipped = $vendorMode !== VendorMode::Bundled
@@ -337,13 +355,20 @@ class CoreUpdateCommand extends Command
}
// ── Step 9: Migration + 역할/메뉴 동기화 ──
+ //
+ // 동기화는 반드시 reloadCoreConfigAndResync() 로 호출한다. 본 메서드는 부모
+ // 프로세스가 부팅 시점에 캐시한 stale config 를 우회해 디스크의 fresh
+ // config/core.php 를 require → Config Repository 에 재주입한 뒤 syncCore* 를
+ // 호출한다. 디스크는 Step 7(applyUpdate) 에서 이미 신버전으로 교체되어 있다.
+ //
+ // syncCoreRolesAndPermissions / syncCoreMenus 직접 호출 금지 — 부모 메모리의
+ // 구버전 config 로 sync 가 돌면 신규 권한/메뉴가 누락된다 (#326 회귀).
$bar->setMessage(__('settings.core_update.step_migration'));
$bar->advance();
$log('마이그레이션, 역할/메뉴 동기화 실행');
$service->runMigrations();
- $service->syncCoreRolesAndPermissions();
- $service->syncCoreMenus();
+ $service->reloadCoreConfigAndResync();
$log('마이그레이션, 역할/메뉴 동기화 완료');
// ── Step 10: Upgrade Steps ──
@@ -395,8 +420,11 @@ class CoreUpdateCommand extends Command
$service->clearAllCaches();
// sudo 실행 시 composer 등 외부 프로세스가 root 로 생성한 파일의 소유권을
- // 백업 직후 수집한 원본 스냅샷 기준으로 복원 (각 경로 고유 소유자 유지)
- $service->restoreOwnership($ownershipSnapshot, $onProgress);
+ // 백업 직후 수집한 원본 스냅샷 기준으로 복원 (각 경로 고유 소유자 유지).
+ // detailedSnapshot 동시 전달 — PHP-FPM 쓰기 영역의 owner/group/perms 를 항목별
+ // 정확 복원하여 #282 (sudo update 후 traversal 비트 손실) 회귀 차단.
+ $service->restoreOwnership($ownershipSnapshot, $onProgress, $detailedOwnershipSnapshot);
+ $this->surfacePermissionWarnings($service, $log);
$log('업데이트 경로 소유권 복원 완료');
$service->cleanupPending($pendingPath);
@@ -438,10 +466,13 @@ class CoreUpdateCommand extends Command
if (($promptResult['success'] ?? 0) > 0) {
$this->newLine();
$this->info('일괄 업데이트로 생성된 파일의 소유권을 복원하는 중...');
- $service->restoreOwnership($ownershipSnapshot, $onProgress);
+ // 일괄 확장 update 가 sudo 컨텍스트에서 root 로 만들 수 있는 PHP-FPM 쓰기
+ // 영역의 owner/group/perms 를 항목별 정확 복원 (Stage 4).
+ $service->restoreOwnership($ownershipSnapshot, $onProgress, $detailedOwnershipSnapshot);
// restoreOwnership 의 진행 표시($onProgress → $bar->display())가
// 개행 없이 끝나므로 다음 셸 프롬프트가 같은 줄에 붙는 것을 방지.
$this->newLine(2);
+ $this->surfacePermissionWarnings($service, $log);
$log('일괄 확장 업데이트 후 소유권 재복원 완료');
}
@@ -479,7 +510,9 @@ class CoreUpdateCommand extends Command
try {
$service->updateVersionInEnv($toVersion);
$service->clearAllCaches();
- $service->restoreOwnership($ownershipSnapshot, $onProgress);
+ // Stage 4 — handoff cleanup 도 detailed snapshot 으로 정확 복원
+ $service->restoreOwnership($ownershipSnapshot, $onProgress, $detailedOwnershipSnapshot);
+ $this->surfacePermissionWarnings($service, $log);
$log('핸드오프 cleanup 완료 (버전 toVersion 고정 + 캐시 clear + 소유권 복원)');
if (! empty($pendingPath)) {
@@ -822,4 +855,44 @@ class CoreUpdateCommand extends Command
'owner_user' => $ownerUser,
]);
}
+
+ /**
+ * `restoreOwnership()` 직후 누적된 권한 정상화 실패 경고를 콘솔/로그에 즉시 노출합니다.
+ *
+ * 운영자가 sudo 환경 결함(파일시스템 ACL, immutable 비트, NFS 권한 거부 등) 으로
+ * 일부 경로 chown / chmod 실패 시 그 경로와 운영자 수동 복구 명령을 즉시 보여준다.
+ * 본 메서드 호출 후 service 의 `lastPermissionWarnings` 가 다음 호출 시 초기화되므로
+ * 매 `restoreOwnership` 직후 1회 호출 패턴이 정합.
+ *
+ * @param CoreUpdateService $service
+ * @param callable $log 내부 로그 누적 콜백 (`saveUpdateLog` 입력용)
+ * @return void
+ */
+ private function surfacePermissionWarnings(CoreUpdateService $service, callable $log): void
+ {
+ $warnings = $service->getLastPermissionWarnings();
+ if (empty($warnings)) {
+ return;
+ }
+
+ $this->newLine();
+ $this->warn('⚠ 권한 정상화 실패 — 일부 경로의 소유권/그룹 쓰기 권한을 복원하지 못했습니다.');
+ foreach ($warnings as $w) {
+ $kind = $w['kind'] === 'chown' ? '소유권' : '그룹 쓰기';
+ $this->warn(sprintf(' - %s [%s]: %d 건 실패', $w['target'], $kind, $w['failed']));
+ foreach (array_slice($w['failed_paths'], 0, 5) as $p) {
+ $this->line(" · {$p}");
+ }
+ if (count($w['failed_paths']) > 5) {
+ $this->line(sprintf(' · … (총 %d건, 상위 5건만 표시)', $w['failed']));
+ }
+ }
+ $this->newLine();
+ $this->line(' 복구 예시:');
+ $this->line(' sudo chown -R : ');
+ $this->line(' sudo chmod -R g+w ');
+ $this->newLine();
+
+ $log(sprintf('권한 정상화 실패 %d 건 — 운영자 수동 복구 필요', count($warnings)));
+ }
}
diff --git a/app/Console/Commands/Core/ExecuteBundledUpdatesCommand.php b/app/Console/Commands/Core/ExecuteBundledUpdatesCommand.php
new file mode 100644
index 00000000..d48df1ff
--- /dev/null
+++ b/app/Console/Commands/Core/ExecuteBundledUpdatesCommand.php
@@ -0,0 +1,177 @@
+option('manifest');
+ if ($manifestPath === '' || ! File::isFile($manifestPath)) {
+ $this->error('--manifest 옵션이 필수이며 존재하는 파일이어야 합니다.');
+
+ return self::INVALID;
+ }
+
+ // spawn 자식 진입 시 활성 모듈/플러그인의 PSR-4 매핑을 fresh 등록 — 자세한 배경은
+ // ExecuteUpgradeStepsCommand::handle 의 동일 호출 주석 참조.
+ // (Artisan::call 대신 직접 메서드 호출 — nested Artisan::call 이 outer 명령의
+ // output buffer 를 덮어쓰는 Laravel 동작 회피)
+ try {
+ app(\App\Extension\ExtensionManager::class)->updateComposerAutoload();
+ } catch (\Throwable $e) {
+ Log::warning('bundled update spawn 자식: updateComposerAutoload 호출 실패', [
+ 'error' => $e->getMessage(),
+ ]);
+ }
+
+ $manifest = json_decode(File::get($manifestPath), true);
+ if (! is_array($manifest)) {
+ $this->error('매니페스트 JSON 파싱 실패: '.$manifestPath);
+
+ return self::FAILURE;
+ }
+
+ $modules = $manifest['modules'] ?? [];
+ $plugins = $manifest['plugins'] ?? [];
+ $templates = $manifest['templates'] ?? [];
+ $langPacks = $manifest['lang_packs'] ?? [];
+
+ $moduleManager = app(ModuleManager::class);
+ $pluginManager = app(PluginManager::class);
+ $templateManager = app(TemplateManager::class);
+ $langPackService = app(LanguagePackService::class);
+
+ $success = 0;
+ $failed = 0;
+
+ $this->info('── 번들 일괄 업데이트 실행 (spawn child) ──');
+
+ foreach ($modules as $entry) {
+ $id = (string) ($entry['identifier'] ?? '');
+ $strategy = (string) ($entry['strategy'] ?? 'overwrite');
+ if ($id === '') {
+ continue;
+ }
+ $this->line("→ [모듈] {$id} ({$strategy})");
+ try {
+ $moduleManager->updateModule($id, true, null, VendorMode::Auto, $strategy, null, 'bundled');
+ $success++;
+ } catch (\Throwable $e) {
+ $failed++;
+ $this->warn(" 실패: {$e->getMessage()}");
+ Log::error('번들 일괄 업데이트 실패 (spawn)', ['type' => 'module', 'id' => $id, 'error' => $e->getMessage()]);
+ }
+ }
+
+ foreach ($plugins as $entry) {
+ $id = (string) ($entry['identifier'] ?? '');
+ $strategy = (string) ($entry['strategy'] ?? 'overwrite');
+ if ($id === '') {
+ continue;
+ }
+ $this->line("→ [플러그인] {$id} ({$strategy})");
+ try {
+ $pluginManager->updatePlugin($id, true, null, VendorMode::Auto, $strategy, null, 'bundled');
+ $success++;
+ } catch (\Throwable $e) {
+ $failed++;
+ $this->warn(" 실패: {$e->getMessage()}");
+ Log::error('번들 일괄 업데이트 실패 (spawn)', ['type' => 'plugin', 'id' => $id, 'error' => $e->getMessage()]);
+ }
+ }
+
+ foreach ($templates as $entry) {
+ $id = (string) ($entry['identifier'] ?? '');
+ $strategy = (string) ($entry['strategy'] ?? 'overwrite');
+ if ($id === '') {
+ continue;
+ }
+ $this->line("→ [템플릿] {$id} ({$strategy})");
+ try {
+ $templateManager->updateTemplate($id, true, null, $strategy, 'bundled');
+ $success++;
+ } catch (\Throwable $e) {
+ $failed++;
+ $this->warn(" 실패: {$e->getMessage()}");
+ Log::error('번들 일괄 업데이트 실패 (spawn)', ['type' => 'template', 'id' => $id, 'error' => $e->getMessage()]);
+ }
+ }
+
+ foreach ($langPacks as $entry) {
+ $id = (string) ($entry['identifier'] ?? '');
+ if ($id === '') {
+ continue;
+ }
+ $this->line("→ [언어팩] {$id}");
+ try {
+ $pack = $langPackService->findByIdentifier($id);
+ if (! $pack) {
+ throw new \RuntimeException(__('language_packs.errors.identifier_not_found', ['identifier' => $id]));
+ }
+ $langPackService->performUpdate($pack, true);
+ $success++;
+ } catch (\Throwable $e) {
+ $failed++;
+ $this->warn(" 실패: {$e->getMessage()}");
+ Log::error('번들 일괄 업데이트 실패 (spawn)', ['type' => 'lang_pack', 'id' => $id, 'error' => $e->getMessage()]);
+ }
+ }
+
+ $this->newLine();
+ $this->info("업데이트 완료: 성공 {$success}, 실패 {$failed}");
+
+ // 부모 프로세스가 결과를 복원할 수 있도록 표식 라인으로 페이로드 출력
+ $this->line(self::RESULT_PREFIX.json_encode([
+ 'success' => $success,
+ 'failed' => $failed,
+ ], JSON_UNESCAPED_UNICODE));
+
+ return $failed > 0 ? self::FAILURE : self::SUCCESS;
+ }
+}
diff --git a/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php b/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php
index bf0e9a32..a734977d 100644
--- a/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php
+++ b/app/Console/Commands/Core/ExecuteUpgradeStepsCommand.php
@@ -46,6 +46,51 @@ class ExecuteUpgradeStepsCommand extends Command
return self::INVALID;
}
+ // spawn 자식 진입 시 활성 모듈/플러그인의 PSR-4 매핑을 fresh 등록.
+ // 부모 프로세스의 autoload-extensions.php 가 stale 한 경우 upgrade step 안에서
+ // ModuleManager / PluginManager 호출 → declaration 메서드가 Models/Services 등
+ // 다른 클래스 lazy load 시 "Class not found" 발생. 진입 직후 1회 호출로 모든 후속
+ // upgrade step (현재 + 미래) 이 fresh autoload 환경에서 실행됨을 보장.
+ //
+ // 본 커맨드 자체가 spawn 자식 (proc_open 으로 fork 된 별개 PHP 프로세스) 의 진입점이라
+ // 디스크의 fresh ExtensionManager(beta.X+1) 클래스를 메모리에 로드한 상태. 따라서
+ // `app(ExtensionManager::class)->updateComposerAutoload()` 직접 호출은 stale 가능성
+ // 없음. (Artisan::call 대신 직접 호출 — nested Artisan::call 이 outer 명령의
+ // output buffer 를 덮어쓰는 Laravel 동작 회피)
+ try {
+ app(\App\Extension\ExtensionManager::class)->updateComposerAutoload();
+ } catch (\Throwable $e) {
+ \Illuminate\Support\Facades\Log::warning('upgrade step spawn 자식: updateComposerAutoload 호출 실패', [
+ 'error' => $e->getMessage(),
+ ]);
+ }
+
+ // spawn 자식 진입 시 활성 디렉토리의 쓰기 권한 디렉토리를 멱등적으로 보장.
+ // fresh 디스크 config (`app.update.restore_ownership_group_writable`) 를 읽어 처리하므로
+ // 미래 release 가 새 쓰기 권한 디렉토리를 도입할 때 본 호출은 자동으로 신규 항목을 처리한다.
+ // upgrade step 에 mkdir/chown 코드를 매번 하드코딩할 필요 없음.
+ //
+ // 한계: 부모(이전 버전) 만 알고 있던 신규 디렉토리는 처리 불가 — 그 일회성 케이스는
+ // 해당 release 의 upgrade step 단발 처리. (예: beta.3→beta.4 의 lang-packs/* 보정)
+ try {
+ $writablePaths = (array) config('app.update.restore_ownership_group_writable', []);
+ if (! empty($writablePaths)) {
+ $service->ensureWritableDirectories(
+ $writablePaths,
+ function (string $level, string $msg): void {
+ // 콘솔 + upgrade 로그 채널 동시 출력 — PO 가 단일 파일(upgrade.log)에서
+ // spawn 자식의 권한 정상화 진행을 추적할 수 있도록 양쪽 모두 누적.
+ $this->{$level === 'warning' ? 'warn' : 'info'}($msg);
+ \Illuminate\Support\Facades\Log::channel('upgrade')->$level('[spawn] '.$msg);
+ },
+ );
+ }
+ } catch (\Throwable $e) {
+ \Illuminate\Support\Facades\Log::warning('upgrade step spawn 자식: ensureWritableDirectories 호출 실패', [
+ 'error' => $e->getMessage(),
+ ]);
+ }
+
try {
$service->runUpgradeSteps(
$from,
diff --git a/app/Console/Commands/LanguagePack/ActivateLanguagePackCommand.php b/app/Console/Commands/LanguagePack/ActivateLanguagePackCommand.php
new file mode 100644
index 00000000..c4a48ac5
--- /dev/null
+++ b/app/Console/Commands/LanguagePack/ActivateLanguagePackCommand.php
@@ -0,0 +1,66 @@
+argument('identifier');
+ $force = (bool) $this->option('force');
+
+ $pack = $this->service->findByIdentifier($identifier);
+ if (! $pack) {
+ $this->error("언어팩을 찾을 수 없습니다: {$identifier}");
+
+ return self::FAILURE;
+ }
+
+ try {
+ $this->service->activate($pack, $force);
+ $this->info("언어팩 활성화 완료: {$identifier}");
+
+ return self::SUCCESS;
+ } catch (Throwable $e) {
+ $this->error('언어팩 활성화 실패: '.$e->getMessage());
+
+ return self::FAILURE;
+ }
+ }
+}
diff --git a/app/Console/Commands/LanguagePack/CacheClearLanguagePackCommand.php b/app/Console/Commands/LanguagePack/CacheClearLanguagePackCommand.php
new file mode 100644
index 00000000..079d319d
--- /dev/null
+++ b/app/Console/Commands/LanguagePack/CacheClearLanguagePackCommand.php
@@ -0,0 +1,57 @@
+service->refreshCache();
+ $this->info('언어팩 캐시 정리 완료:');
+ foreach ($result as $key => $ok) {
+ $this->line(' - '.$key.': '.($ok ? 'ok' : 'failed'));
+ }
+
+ return collect($result)->every(fn ($v) => $v === true) ? self::SUCCESS : self::FAILURE;
+ } catch (Throwable $e) {
+ $this->error('언어팩 캐시 정리 실패: '.$e->getMessage());
+
+ return self::FAILURE;
+ }
+ }
+}
diff --git a/app/Console/Commands/LanguagePack/CheckLanguagePackUpdatesCommand.php b/app/Console/Commands/LanguagePack/CheckLanguagePackUpdatesCommand.php
new file mode 100644
index 00000000..8051f7cd
--- /dev/null
+++ b/app/Console/Commands/LanguagePack/CheckLanguagePackUpdatesCommand.php
@@ -0,0 +1,86 @@
+option('identifier');
+
+ try {
+ $result = $this->service->checkUpdates($identifier);
+
+ if ($result['checked'] === 0) {
+ $this->info('확인할 GitHub 기반 언어팩이 없습니다.');
+
+ return Command::SUCCESS;
+ }
+
+ foreach ($result['details'] as $entry) {
+ if ($entry['error']) {
+ $this->warn(sprintf(' [%s] 조회 실패: %s', $entry['identifier'], $entry['error']));
+
+ continue;
+ }
+
+ $line = sprintf(
+ ' [%s] %s → %s%s',
+ $entry['identifier'],
+ $entry['current'],
+ $entry['latest'] ?? '?',
+ $entry['has_update'] ? ' (업데이트 가능)' : '',
+ );
+ $this->line($line);
+ }
+
+ $this->info(sprintf('확인 완료: %d 건 검사, %d 건 업데이트 가능.', $result['checked'], $result['updates']));
+
+ return Command::SUCCESS;
+ } catch (Throwable $e) {
+ $this->error('업데이트 확인 실패: '.$e->getMessage());
+ Log::error('language-pack:check-updates 실패', [
+ 'identifier' => $identifier,
+ 'error' => $e->getMessage(),
+ ]);
+
+ return Command::FAILURE;
+ }
+ }
+}
diff --git a/app/Console/Commands/LanguagePack/DeactivateLanguagePackCommand.php b/app/Console/Commands/LanguagePack/DeactivateLanguagePackCommand.php
new file mode 100644
index 00000000..34604144
--- /dev/null
+++ b/app/Console/Commands/LanguagePack/DeactivateLanguagePackCommand.php
@@ -0,0 +1,64 @@
+argument('identifier');
+
+ $pack = $this->service->findByIdentifier($identifier);
+ if (! $pack) {
+ $this->error("언어팩을 찾을 수 없습니다: {$identifier}");
+
+ return self::FAILURE;
+ }
+
+ try {
+ $this->service->deactivate($pack);
+ $this->info("언어팩 비활성화 완료: {$identifier}");
+
+ return self::SUCCESS;
+ } catch (Throwable $e) {
+ $this->error('언어팩 비활성화 실패: '.$e->getMessage());
+
+ return self::FAILURE;
+ }
+ }
+}
diff --git a/app/Console/Commands/LanguagePack/InstallLanguagePackCommand.php b/app/Console/Commands/LanguagePack/InstallLanguagePackCommand.php
new file mode 100644
index 00000000..7c548a87
--- /dev/null
+++ b/app/Console/Commands/LanguagePack/InstallLanguagePackCommand.php
@@ -0,0 +1,75 @@
+argument('identifier');
+ $source = (string) $this->option('source');
+ $autoActivate = ! (bool) $this->option('no-activate');
+
+ try {
+ $pack = match ($source) {
+ 'bundled' => $this->service->installFromBundled($identifier, $autoActivate),
+ 'github' => $this->service->installFromGithub($identifier, $autoActivate),
+ 'url' => $this->service->installFromUrl($identifier, null, $autoActivate),
+ default => throw new \InvalidArgumentException(
+ __('language_packs.errors.unsupported_source', ['source' => $source])
+ ),
+ };
+
+ $this->info(sprintf(
+ '언어팩 설치 완료: %s v%s (status=%s)',
+ $pack->identifier,
+ $pack->version,
+ $pack->status,
+ ));
+
+ return self::SUCCESS;
+ } catch (Throwable $e) {
+ $this->error('언어팩 설치 실패: '.$e->getMessage());
+
+ return self::FAILURE;
+ }
+ }
+}
diff --git a/app/Console/Commands/LanguagePack/ListLanguagePackCommand.php b/app/Console/Commands/LanguagePack/ListLanguagePackCommand.php
new file mode 100644
index 00000000..7e82a798
--- /dev/null
+++ b/app/Console/Commands/LanguagePack/ListLanguagePackCommand.php
@@ -0,0 +1,75 @@
+option('scope')) {
+ $filters['scope'] = $scope;
+ }
+
+ $paginator = $this->repository->paginate($filters, 100);
+ $packs = $paginator->items();
+
+ if (empty($packs)) {
+ $this->info('설치된 언어팩이 없습니다.');
+
+ return self::SUCCESS;
+ }
+
+ $rows = [];
+ foreach ($packs as $pack) {
+ $rows[] = [
+ $pack->identifier,
+ $pack->scope,
+ $pack->target_identifier ?? '-',
+ $pack->locale,
+ $pack->vendor,
+ $pack->version,
+ $pack->status,
+ ];
+ }
+
+ $this->table(
+ ['Identifier', 'Scope', 'Target', 'Locale', 'Vendor', 'Version', 'Status'],
+ $rows
+ );
+
+ return self::SUCCESS;
+ }
+}
diff --git a/app/Console/Commands/LanguagePack/UninstallLanguagePackCommand.php b/app/Console/Commands/LanguagePack/UninstallLanguagePackCommand.php
new file mode 100644
index 00000000..12ffcd28
--- /dev/null
+++ b/app/Console/Commands/LanguagePack/UninstallLanguagePackCommand.php
@@ -0,0 +1,77 @@
+argument('identifier');
+ $cascade = (bool) $this->option('cascade');
+ $force = (bool) $this->option('force');
+
+ $pack = $this->service->findByIdentifier($identifier);
+ if (! $pack) {
+ $this->error("언어팩을 찾을 수 없습니다: {$identifier}");
+
+ return self::FAILURE;
+ }
+
+ if (! $force && ! $this->unifiedConfirm("언어팩 '{$identifier}' 을(를) 제거하시겠습니까?", false)) {
+ $this->info('취소되었습니다.');
+
+ return self::SUCCESS;
+ }
+
+ try {
+ $this->service->uninstall($pack, $cascade);
+ $this->info("언어팩 제거 완료: {$identifier}");
+
+ return self::SUCCESS;
+ } catch (Throwable $e) {
+ $this->error('언어팩 제거 실패: '.$e->getMessage());
+
+ return self::FAILURE;
+ }
+ }
+}
diff --git a/app/Console/Commands/LanguagePack/UpdateLanguagePackCommand.php b/app/Console/Commands/LanguagePack/UpdateLanguagePackCommand.php
new file mode 100644
index 00000000..03007236
--- /dev/null
+++ b/app/Console/Commands/LanguagePack/UpdateLanguagePackCommand.php
@@ -0,0 +1,87 @@
+argument('identifier');
+ $force = (bool) $this->option('force');
+ $source = (string) $this->option('source');
+
+ if (! in_array($source, ['auto', 'bundled', 'github'], true)) {
+ $this->error("알 수 없는 --source 값: {$source} (auto|bundled|github 중 선택)");
+
+ return self::FAILURE;
+ }
+
+ $pack = $this->service->findByIdentifier($identifier);
+ if (! $pack) {
+ $this->error("언어팩을 찾을 수 없습니다: {$identifier}");
+
+ return self::FAILURE;
+ }
+
+ try {
+ $fromVersion = $pack->version;
+ // source=bundled 는 force 와 동등 (bundled 1순위 강제). source=github 는 force 무시.
+ $effectiveForce = $force || $source === 'bundled';
+ $updated = $this->service->performUpdate($pack, $effectiveForce);
+
+ $this->info(sprintf(
+ '언어팩 업데이트 완료: %s (%s → %s)',
+ $updated->identifier,
+ $fromVersion,
+ $updated->version,
+ ));
+
+ return self::SUCCESS;
+ } catch (Throwable $e) {
+ $this->error('언어팩 업데이트 실패: '.$e->getMessage());
+
+ return self::FAILURE;
+ }
+ }
+}
diff --git a/app/Console/Commands/MigrateSettingsToJsonCommand.php b/app/Console/Commands/MigrateSettingsToJsonCommand.php
index aa0efcf3..925d969a 100644
--- a/app/Console/Commands/MigrateSettingsToJsonCommand.php
+++ b/app/Console/Commands/MigrateSettingsToJsonCommand.php
@@ -2,6 +2,7 @@
namespace App\Console\Commands;
+use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Models\SystemConfig;
use App\Repositories\JsonConfigRepository;
use Illuminate\Console\Command;
@@ -12,6 +13,8 @@ use Illuminate\Support\Facades\Schema;
*/
class MigrateSettingsToJsonCommand extends Command
{
+ use HasUnifiedConfirm;
+
/**
* 커맨드 시그니처
*
@@ -122,8 +125,6 @@ class MigrateSettingsToJsonCommand extends Command
* 커맨드를 실행합니다.
*
* DI 컨테이너 바인딩 시점 문제로 직접 인스턴스화합니다.
- *
- * @return int
*/
public function handle(): int
{
@@ -133,7 +134,7 @@ class MigrateSettingsToJsonCommand extends Command
if (! Schema::hasTable('system_configs')) {
$this->warn('system_configs 테이블이 존재하지 않습니다. 기본값으로 초기화합니다.');
- $configRepository = new JsonConfigRepository();
+ $configRepository = new JsonConfigRepository;
$configRepository->initialize();
$this->info('기본 설정 파일이 생성되었습니다.');
@@ -142,11 +143,11 @@ class MigrateSettingsToJsonCommand extends Command
}
// JsonConfigRepository 직접 인스턴스화 (DI 컨테이너 바인딩 전 실행 가능)
- $configRepository = new JsonConfigRepository();
+ $configRepository = new JsonConfigRepository;
// 기존 JSON 파일 존재 확인
if (file_exists(storage_path('app/settings/general.json')) && ! $this->option('force')) {
- if (! $this->confirm('기존 JSON 설정 파일이 존재합니다. 덮어쓰시겠습니까?')) {
+ if (! $this->unifiedConfirm('기존 JSON 설정 파일이 존재합니다. 덮어쓰시겠습니까?', false)) {
$this->info('마이그레이션이 취소되었습니다.');
return Command::SUCCESS;
@@ -196,10 +197,6 @@ class MigrateSettingsToJsonCommand extends Command
/**
* 문자열 값을 원래 타입으로 파싱합니다.
- *
- * @param string $value
- * @param string $type
- * @return mixed
*/
private function parseValue(string $value, string $type): mixed
{
diff --git a/app/Console/Commands/Module/ListModuleCommand.php b/app/Console/Commands/Module/ListModuleCommand.php
index 04c23c56..964457ae 100644
--- a/app/Console/Commands/Module/ListModuleCommand.php
+++ b/app/Console/Commands/Module/ListModuleCommand.php
@@ -13,7 +13,8 @@ class ListModuleCommand extends Command
* The name and signature of the console command.
*/
protected $signature = 'module:list
- {--status= : 상태로 필터 (installed, uninstalled, active, inactive)}';
+ {--status= : 상태로 필터 (installed, uninstalled, active, inactive)}
+ {--hidden : 숨김(hidden=true) 모듈도 함께 출력}';
/**
* The console command description.
@@ -39,6 +40,7 @@ class ListModuleCommand extends Command
$this->moduleManager->loadModules();
$statusFilter = $this->option('status');
+ $includeHidden = (bool) $this->option('hidden');
// 상태 필터 검증
if ($statusFilter && ! in_array($statusFilter, ['installed', 'uninstalled', 'active', 'inactive'])) {
@@ -58,6 +60,11 @@ class ListModuleCommand extends Command
// 설치된 모듈 추가
foreach ($installedModules as $identifier => $module) {
+ // 숨김 필터: --hidden 미지정 시 hidden=true 모듈 제외
+ if (! $includeHidden && ! empty($module['hidden'])) {
+ continue;
+ }
+
// 상태 필터
if ($statusFilter) {
if ($statusFilter === 'uninstalled') {
@@ -86,6 +93,11 @@ class ListModuleCommand extends Command
// 미설치 모듈 추가
if (! $statusFilter || $statusFilter === 'uninstalled') {
foreach ($uninstalledModules as $identifier => $module) {
+ // 숨김 필터: --hidden 미지정 시 hidden=true 모듈 제외
+ if (! $includeHidden && ! empty($module['hidden'])) {
+ continue;
+ }
+
// 상태 필터 (uninstalled 또는 필터 없음)
if ($statusFilter && $statusFilter !== 'uninstalled') {
continue;
diff --git a/app/Console/Commands/Module/UninstallModuleCommand.php b/app/Console/Commands/Module/UninstallModuleCommand.php
index de8256b8..10edf49f 100644
--- a/app/Console/Commands/Module/UninstallModuleCommand.php
+++ b/app/Console/Commands/Module/UninstallModuleCommand.php
@@ -3,6 +3,7 @@
namespace App\Console\Commands\Module;
use App\Console\Commands\Traits\HasProgressBar;
+use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Enums\ExtensionOwnerType;
use App\Extension\ModuleManager;
@@ -14,6 +15,7 @@ use Illuminate\Support\Facades\Log;
class UninstallModuleCommand extends Command
{
use HasProgressBar;
+ use HasUnifiedConfirm;
/**
* The name and signature of the console command.
@@ -82,7 +84,7 @@ class UninstallModuleCommand extends Command
}
$this->newLine();
- if (! $this->confirm(__('modules.commands.uninstall.confirm_question'), false)) {
+ if (! $this->unifiedConfirm(__('modules.commands.uninstall.confirm_question'), false)) {
$this->info(__('modules.commands.uninstall.aborted'));
return Command::SUCCESS;
diff --git a/app/Console/Commands/Module/UpdateModuleCommand.php b/app/Console/Commands/Module/UpdateModuleCommand.php
index 985fe9ca..e0c866d2 100644
--- a/app/Console/Commands/Module/UpdateModuleCommand.php
+++ b/app/Console/Commands/Module/UpdateModuleCommand.php
@@ -3,6 +3,7 @@
namespace App\Console\Commands\Module;
use App\Console\Commands\Traits\HasProgressBar;
+use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Extension\ModuleManager;
use App\Extension\Vendor\VendorMode;
@@ -12,6 +13,7 @@ use Illuminate\Support\Facades\Log;
class UpdateModuleCommand extends Command
{
use HasProgressBar;
+ use HasUnifiedConfirm;
/**
* The name and signature of the console command.
@@ -121,7 +123,7 @@ class UpdateModuleCommand extends Command
$this->newLine();
// 확인 프롬프트 (--force 시 건너뜀)
- if (! $force && ! $this->confirm(__('modules.commands.update.confirm_question'), false)) {
+ if (! $force && ! $this->unifiedConfirm(__('modules.commands.update.confirm_question'), false)) {
$this->info(__('modules.commands.update.aborted'));
return Command::SUCCESS;
diff --git a/app/Console/Commands/Plugin/ListPluginCommand.php b/app/Console/Commands/Plugin/ListPluginCommand.php
index 88201e3f..2dc2f8ab 100644
--- a/app/Console/Commands/Plugin/ListPluginCommand.php
+++ b/app/Console/Commands/Plugin/ListPluginCommand.php
@@ -13,7 +13,8 @@ class ListPluginCommand extends Command
* The name and signature of the console command.
*/
protected $signature = 'plugin:list
- {--status= : 상태로 필터 (installed, uninstalled, active, inactive)}';
+ {--status= : 상태로 필터 (installed, uninstalled, active, inactive)}
+ {--hidden : 숨김(hidden=true) 플러그인도 함께 출력}';
/**
* The console command description.
@@ -39,6 +40,7 @@ class ListPluginCommand extends Command
$this->pluginManager->loadPlugins();
$statusFilter = $this->option('status');
+ $includeHidden = (bool) $this->option('hidden');
// 상태 필터 검증
if ($statusFilter && ! in_array($statusFilter, ['installed', 'uninstalled', 'active', 'inactive'])) {
@@ -58,6 +60,11 @@ class ListPluginCommand extends Command
// 설치된 플러그인 추가
foreach ($installedPlugins as $identifier => $plugin) {
+ // 숨김 필터: --hidden 미지정 시 hidden=true 플러그인 제외
+ if (! $includeHidden && ! empty($plugin['hidden'])) {
+ continue;
+ }
+
// 상태 필터
if ($statusFilter) {
if ($statusFilter === 'uninstalled') {
@@ -86,6 +93,11 @@ class ListPluginCommand extends Command
// 미설치 플러그인 추가
if (! $statusFilter || $statusFilter === 'uninstalled') {
foreach ($uninstalledPlugins as $identifier => $plugin) {
+ // 숨김 필터: --hidden 미지정 시 hidden=true 플러그인 제외
+ if (! $includeHidden && ! empty($plugin['hidden'])) {
+ continue;
+ }
+
// 상태 필터 (uninstalled 또는 필터 없음)
if ($statusFilter && $statusFilter !== 'uninstalled') {
continue;
diff --git a/app/Console/Commands/Plugin/UninstallPluginCommand.php b/app/Console/Commands/Plugin/UninstallPluginCommand.php
index d5e96dce..20cf7e24 100644
--- a/app/Console/Commands/Plugin/UninstallPluginCommand.php
+++ b/app/Console/Commands/Plugin/UninstallPluginCommand.php
@@ -3,6 +3,7 @@
namespace App\Console\Commands\Plugin;
use App\Console\Commands\Traits\HasProgressBar;
+use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Enums\ExtensionOwnerType;
use App\Extension\PluginManager;
@@ -13,6 +14,7 @@ use Illuminate\Support\Facades\Log;
class UninstallPluginCommand extends Command
{
use HasProgressBar;
+ use HasUnifiedConfirm;
/**
* The name and signature of the console command.
@@ -79,7 +81,7 @@ class UninstallPluginCommand extends Command
}
$this->newLine();
- if (! $this->confirm(__('plugins.commands.uninstall.confirm_question'), false)) {
+ if (! $this->unifiedConfirm(__('plugins.commands.uninstall.confirm_question'), false)) {
$this->info(__('plugins.commands.uninstall.aborted'));
return Command::SUCCESS;
diff --git a/app/Console/Commands/Plugin/UpdatePluginCommand.php b/app/Console/Commands/Plugin/UpdatePluginCommand.php
index 8d0629b2..0bea351c 100644
--- a/app/Console/Commands/Plugin/UpdatePluginCommand.php
+++ b/app/Console/Commands/Plugin/UpdatePluginCommand.php
@@ -3,6 +3,7 @@
namespace App\Console\Commands\Plugin;
use App\Console\Commands\Traits\HasProgressBar;
+use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Extension\PluginManager;
use App\Extension\Vendor\VendorMode;
@@ -12,6 +13,7 @@ use Illuminate\Support\Facades\Log;
class UpdatePluginCommand extends Command
{
use HasProgressBar;
+ use HasUnifiedConfirm;
/**
* The name and signature of the console command.
@@ -120,7 +122,7 @@ class UpdatePluginCommand extends Command
$this->newLine();
// 확인 프롬프트 (--force 시 건너뜀)
- if (! $force && ! $this->confirm(__('plugins.commands.update.confirm_question'), false)) {
+ if (! $force && ! $this->unifiedConfirm(__('plugins.commands.update.confirm_question'), false)) {
$this->info(__('plugins.commands.update.aborted'));
return Command::SUCCESS;
diff --git a/app/Console/Commands/SeoGenerateSitemapCommand.php b/app/Console/Commands/SeoGenerateSitemapCommand.php
index b5014a05..1198e680 100644
--- a/app/Console/Commands/SeoGenerateSitemapCommand.php
+++ b/app/Console/Commands/SeoGenerateSitemapCommand.php
@@ -4,18 +4,20 @@ namespace App\Console\Commands;
use App\Jobs\GenerateSitemapJob;
use Illuminate\Console\Command;
+use Illuminate\Support\Facades\Config;
/**
* Sitemap XML 생성 Artisan 커맨드
*
- * 큐를 통한 비동기 실행 또는 --sync 옵션으로 동기 실행을 지원합니다.
+ * 큐 드라이버 설정에 따라 비동기/동기 실행을 자동 선택합니다.
+ * --sync 옵션을 명시하면 큐 드라이버와 무관하게 동기 실행합니다.
*/
class SeoGenerateSitemapCommand extends Command
{
/**
* @var string 커맨드 시그니처
*/
- protected $signature = 'seo:generate-sitemap {--sync : 동기 실행}';
+ protected $signature = 'seo:generate-sitemap {--sync : 큐 드라이버를 무시하고 동기 실행}';
/**
* @var string 커맨드 설명
@@ -29,7 +31,13 @@ class SeoGenerateSitemapCommand extends Command
*/
public function handle(): int
{
- if ($this->option('sync')) {
+ $forceSync = (bool) $this->option('sync');
+ // queue.default 는 SettingsServiceProvider 가 drivers.queue_driver 와 동기화하되,
+ // testing 환경에서는 phpunit.xml 값을 보존하므로 격리가 유지된다.
+ $connection = (string) Config::get('queue.default', 'sync');
+ $isSyncDriver = $connection === 'sync';
+
+ if ($forceSync || $isSyncDriver) {
GenerateSitemapJob::dispatchSync();
$this->info('Sitemap이 생성되었습니다.');
} else {
diff --git a/app/Console/Commands/Template/ListTemplateCommand.php b/app/Console/Commands/Template/ListTemplateCommand.php
index cd161a23..3be1c4c8 100644
--- a/app/Console/Commands/Template/ListTemplateCommand.php
+++ b/app/Console/Commands/Template/ListTemplateCommand.php
@@ -13,7 +13,8 @@ class ListTemplateCommand extends Command
*/
protected $signature = 'template:list
{--type= : 템플릿 타입으로 필터 (admin, user)}
- {--status= : 상태로 필터 (installed, uninstalled, active, inactive)}';
+ {--status= : 상태로 필터 (installed, uninstalled, active, inactive)}
+ {--hidden : 숨김(hidden=true) 템플릿도 함께 출력}';
/**
* The console command description.
@@ -40,6 +41,7 @@ class ListTemplateCommand extends Command
$typeFilter = $this->option('type');
$statusFilter = $this->option('status');
+ $includeHidden = (bool) $this->option('hidden');
// 타입 필터 검증
if ($typeFilter && ! in_array($typeFilter, ['admin', 'user'])) {
@@ -66,6 +68,11 @@ class ListTemplateCommand extends Command
// 설치된 템플릿 추가
foreach ($installedTemplates as $identifier => $template) {
+ // 숨김 필터: --hidden 미지정 시 hidden=true 템플릿 제외
+ if (! $includeHidden && ! empty($template['hidden'])) {
+ continue;
+ }
+
// 타입 필터
if ($typeFilter && $template['type'] !== $typeFilter) {
continue;
@@ -99,6 +106,11 @@ class ListTemplateCommand extends Command
// 미설치 템플릿 추가
if (! $statusFilter || $statusFilter === 'uninstalled') {
foreach ($uninstalledTemplates as $identifier => $template) {
+ // 숨김 필터: --hidden 미지정 시 hidden=true 템플릿 제외
+ if (! $includeHidden && ! empty($template['hidden'])) {
+ continue;
+ }
+
// 타입 필터
if ($typeFilter && $template['type'] !== $typeFilter) {
continue;
diff --git a/app/Console/Commands/Template/UninstallTemplateCommand.php b/app/Console/Commands/Template/UninstallTemplateCommand.php
index 5471f9b0..5813468f 100644
--- a/app/Console/Commands/Template/UninstallTemplateCommand.php
+++ b/app/Console/Commands/Template/UninstallTemplateCommand.php
@@ -3,6 +3,7 @@
namespace App\Console\Commands\Template;
use App\Console\Commands\Traits\HasProgressBar;
+use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Contracts\Repositories\TemplateRepositoryInterface;
use App\Extension\TemplateManager;
use Illuminate\Console\Command;
@@ -11,6 +12,7 @@ use Illuminate\Support\Facades\Log;
class UninstallTemplateCommand extends Command
{
use HasProgressBar;
+ use HasUnifiedConfirm;
/**
* The name and signature of the console command.
@@ -57,7 +59,7 @@ class UninstallTemplateCommand extends Command
$this->warn(__('templates.commands.uninstall.confirm_details.layouts', ['count' => $template->layouts()->count()]));
$this->warn(__('templates.commands.uninstall.confirm_details.versions'));
- if (! $this->confirm(__('templates.commands.uninstall.confirm_question'), false)) {
+ if (! $this->unifiedConfirm(__('templates.commands.uninstall.confirm_question'), false)) {
$this->info(__('templates.commands.uninstall.aborted'));
return Command::SUCCESS;
diff --git a/app/Console/Commands/Template/UpdateTemplateCommand.php b/app/Console/Commands/Template/UpdateTemplateCommand.php
index 61210133..b6bd6d9c 100644
--- a/app/Console/Commands/Template/UpdateTemplateCommand.php
+++ b/app/Console/Commands/Template/UpdateTemplateCommand.php
@@ -3,6 +3,7 @@
namespace App\Console\Commands\Template;
use App\Console\Commands\Traits\HasProgressBar;
+use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Contracts\Repositories\TemplateRepositoryInterface;
use App\Extension\TemplateManager;
use Illuminate\Console\Command;
@@ -11,6 +12,7 @@ use Illuminate\Support\Facades\Log;
class UpdateTemplateCommand extends Command
{
use HasProgressBar;
+ use HasUnifiedConfirm;
/**
* The name and signature of the console command.
@@ -140,7 +142,7 @@ class UpdateTemplateCommand extends Command
$this->newLine();
// 확인 프롬프트 (--force 시 건너뜀)
- if (! $force && ! $this->confirm(__('templates.commands.update.confirm_question'), false)) {
+ if (! $force && ! $this->unifiedConfirm(__('templates.commands.update.confirm_question'), false)) {
$this->info(__('templates.commands.update.aborted'));
return Command::SUCCESS;
diff --git a/app/Console/Commands/Traits/HasUnifiedConfirm.php b/app/Console/Commands/Traits/HasUnifiedConfirm.php
new file mode 100644
index 00000000..bac68bf4
--- /dev/null
+++ b/app/Console/Commands/Traits/HasUnifiedConfirm.php
@@ -0,0 +1,50 @@
+input->isInteractive()) {
+ return $default;
+ }
+
+ $hint = $default ? '[yes]' : '[no]';
+ $prompt = "{$question} (yes/no) {$hint}";
+
+ while (true) {
+ // Symfony QuestionHelper 의 default 표시(`[default]`)를 회피하기 위해 default 를
+ // null 로 넘긴다. empty 입력 시 Symfony 가 null 반환 → ConsoleConfirm::parse 가
+ // empty 로 처리하여 자체 default 적용.
+ $raw = (string) $this->ask($prompt);
+ $parsed = ConsoleConfirm::parse($raw, $default);
+
+ if ($parsed !== null) {
+ return $parsed;
+ }
+
+ $this->warn(' yes, y, no, n 중 하나로 입력해 주세요.');
+ }
+ }
+}
diff --git a/app/Console/Helpers/ConsoleConfirm.php b/app/Console/Helpers/ConsoleConfirm.php
new file mode 100644
index 00000000..b0d6fb09
--- /dev/null
+++ b/app/Console/Helpers/ConsoleConfirm.php
@@ -0,0 +1,106 @@
+ print $text;
+
+ $useStdin = $stdin !== null;
+
+ if (! $useStdin && ! self::isTty()) {
+ return $default;
+ }
+
+ $hint = $default ? '[yes]' : '[no]';
+ $stream = $useStdin ? $stdin : (defined('STDIN') ? STDIN : null);
+
+ if ($stream === null) {
+ return $default;
+ }
+
+ while (true) {
+ $writer("{$question} (yes/no) {$hint}: ");
+
+ $raw = fgets($stream);
+ if ($raw === false) {
+ return $default;
+ }
+
+ $parsed = self::parse($raw, $default);
+ if ($parsed !== null) {
+ return $parsed;
+ }
+
+ $writer(" yes, y, no, n 중 하나로 입력해 주세요.\n");
+ }
+ }
+
+ /**
+ * 입력 문자열을 yes/no/null 로 정규화한다.
+ *
+ * @param string $raw 사용자 입력 원문 (개행 포함 가능)
+ * @param bool $default empty 입력 시 반환할 값
+ * @return bool|null true=yes, false=no, null=재질문 필요
+ */
+ public static function parse(string $raw, bool $default): ?bool
+ {
+ $answer = strtolower(trim($raw));
+
+ if ($answer === '') {
+ return $default;
+ }
+ if ($answer === 'yes' || $answer === 'y') {
+ return true;
+ }
+ if ($answer === 'no' || $answer === 'n') {
+ return false;
+ }
+
+ return null;
+ }
+
+ /**
+ * STDIN 이 TTY 인지 검사한다.
+ */
+ private static function isTty(): bool
+ {
+ if (! defined('STDIN')) {
+ return false;
+ }
+ if (function_exists('stream_isatty')) {
+ return @stream_isatty(STDIN);
+ }
+ if (function_exists('posix_isatty')) {
+ return @posix_isatty(STDIN);
+ }
+
+ return false;
+ }
+}
diff --git a/app/Contracts/Extension/IdentityVerificationInterface.php b/app/Contracts/Extension/IdentityVerificationInterface.php
new file mode 100644
index 00000000..810498f7
--- /dev/null
+++ b/app/Contracts/Extension/IdentityVerificationInterface.php
@@ -0,0 +1,93 @@
+
+ */
+ public function getChannels(): array;
+
+ /**
+ * 프론트가 challenge 를 렌더하는 방법 힌트.
+ *
+ * - text_code : 숫자 코드 입력 UI
+ * - link : 메일/SMS 링크 클릭 유도
+ * - external_redirect: 외부 인증 페이지 이동
+ */
+ public function getRenderHint(): string;
+
+ /**
+ * 주어진 목적을 지원하는지 여부.
+ *
+ * purpose 는 최소 signup / password_reset / self_update / sensitive_action 을
+ * 포함하며, 플러그인은 `core.identity.purposes` 필터 훅으로 커스텀 purpose 를 추가할 수 있습니다.
+ */
+ public function supportsPurpose(string $purpose): bool;
+
+ /**
+ * 런타임 설정(API 키/시크릿 등) 기준으로 실제 사용 가능한지 여부.
+ */
+ public function isAvailable(): bool;
+
+ /**
+ * Challenge 를 발행합니다.
+ *
+ * @param User|array $target 대상 사용자(로그인 상태) 또는 이메일·전화 배열(가입 전)
+ * @param array $context origin_type / origin_identifier / origin_policy_key / purpose / ip / user_agent 등
+ */
+ public function requestChallenge(User|array $target, array $context = []): VerificationChallenge;
+
+ /**
+ * Challenge 를 검증합니다.
+ *
+ * @param string $challengeId requestChallenge 가 반환한 id
+ * @param array $input 프로바이더별 입력 (코드, 토큰, 외부 CB 페이로드 등)
+ * @param array $context origin 정보, 요청 ip/ua 등
+ */
+ public function verify(string $challengeId, array $input, array $context = []): VerificationResult;
+
+ /**
+ * Challenge 를 취소합니다.
+ */
+ public function cancel(string $challengeId): bool;
+
+ /**
+ * 관리자 환경설정 UI 가 반복 렌더하기 위한 설정 스키마.
+ *
+ * @return array, help?: string}>
+ */
+ public function getSettingsSchema(): array;
+
+ /**
+ * 설정값을 주입한 새 인스턴스를 반환합니다. (withStore/withDisk 불변 복제 패턴 준용)
+ */
+ public function withConfig(array $config): static;
+}
diff --git a/app/Contracts/Extension/ModuleInterface.php b/app/Contracts/Extension/ModuleInterface.php
index 4562a09f..e0ef7f6b 100644
--- a/app/Contracts/Extension/ModuleInterface.php
+++ b/app/Contracts/Extension/ModuleInterface.php
@@ -204,6 +204,16 @@ interface ModuleInterface
*/
public function getLicense(): ?string;
+ /**
+ * 관리자 UI 에서 숨김 여부를 반환합니다.
+ *
+ * true 반환 시 관리자 모듈 목록 응답에서 기본 제외됩니다.
+ * CLI 명령, 설치/제거, 업데이트 감지는 영향 받지 않습니다.
+ *
+ * @return bool 숨김 여부
+ */
+ public function isHidden(): bool;
+
/**
* 모듈의 메타데이터를 반환합니다.
*
diff --git a/app/Contracts/Extension/ModuleManagerInterface.php b/app/Contracts/Extension/ModuleManagerInterface.php
index 3449e5f9..81b2a1fe 100644
--- a/app/Contracts/Extension/ModuleManagerInterface.php
+++ b/app/Contracts/Extension/ModuleManagerInterface.php
@@ -2,6 +2,8 @@
namespace App\Contracts\Extension;
+use App\Enums\DeactivationReason;
+
interface ModuleManagerInterface
{
/**
@@ -40,9 +42,16 @@ interface ModuleManagerInterface
*
* @param string $moduleName 비활성화할 모듈명
* @param bool $force 의존 템플릿이 있어도 강제 비활성화 여부
+ * @param string $reason 비활성화 사유 (DeactivationReason enum value: manual|incompatible_core)
+ * @param string|null $incompatibleRequiredVersion incompatible_core 사유 시 요구된 코어 버전 제약
* @return array{success: bool, layouts_deleted: int, warning?: bool, dependent_templates?: array, message?: string} 비활성화 결과 및 삭제된 레이아웃 개수
*/
- public function deactivateModule(string $moduleName, bool $force = false): array;
+ public function deactivateModule(
+ string $moduleName,
+ bool $force = false,
+ string $reason = DeactivationReason::Manual->value,
+ ?string $incompatibleRequiredVersion = null,
+ ): array;
/**
* 지정된 모듈을 시스템에서 제거합니다.
diff --git a/app/Contracts/Extension/PluginInterface.php b/app/Contracts/Extension/PluginInterface.php
index b141fd76..637ccbf9 100644
--- a/app/Contracts/Extension/PluginInterface.php
+++ b/app/Contracts/Extension/PluginInterface.php
@@ -47,6 +47,16 @@ interface PluginInterface
*/
public function getLicense(): ?string;
+ /**
+ * 관리자 UI 에서 숨김 여부를 반환합니다.
+ *
+ * true 반환 시 관리자 플러그인 목록 응답에서 기본 제외됩니다.
+ * CLI 명령, 설치/제거, 업데이트 감지는 영향 받지 않습니다.
+ *
+ * @return bool 숨김 여부
+ */
+ public function isHidden(): bool;
+
/**
* 플러그인의 추가 메타데이터를 반환합니다.
*
diff --git a/app/Contracts/Extension/PluginManagerInterface.php b/app/Contracts/Extension/PluginManagerInterface.php
index 5890aed0..4a00095a 100644
--- a/app/Contracts/Extension/PluginManagerInterface.php
+++ b/app/Contracts/Extension/PluginManagerInterface.php
@@ -2,6 +2,8 @@
namespace App\Contracts\Extension;
+use App\Enums\DeactivationReason;
+
interface PluginManagerInterface
{
/**
@@ -40,9 +42,16 @@ interface PluginManagerInterface
*
* @param string $pluginName 비활성화할 플러그인명
* @param bool $force 의존 템플릿이 있어도 강제 비활성화 여부
+ * @param string $reason 비활성화 사유 (DeactivationReason enum value: manual|incompatible_core)
+ * @param string|null $incompatibleRequiredVersion incompatible_core 사유 시 요구된 코어 버전 제약
* @return array{success: bool, layouts_deleted: int, warning?: bool, dependent_templates?: array, message?: string} 비활성화 결과
*/
- public function deactivatePlugin(string $pluginName, bool $force = false): array;
+ public function deactivatePlugin(
+ string $pluginName,
+ bool $force = false,
+ string $reason = DeactivationReason::Manual->value,
+ ?string $incompatibleRequiredVersion = null,
+ ): array;
/**
* 지정된 플러그인을 시스템에서 제거합니다.
diff --git a/app/Contracts/Extension/TemplateManagerInterface.php b/app/Contracts/Extension/TemplateManagerInterface.php
index 91481a92..44faaacc 100644
--- a/app/Contracts/Extension/TemplateManagerInterface.php
+++ b/app/Contracts/Extension/TemplateManagerInterface.php
@@ -2,6 +2,8 @@
namespace App\Contracts\Extension;
+use App\Enums\DeactivationReason;
+
interface TemplateManagerInterface
{
/**
@@ -74,9 +76,15 @@ interface TemplateManagerInterface
* 지정된 템플릿을 비활성화합니다.
*
* @param string $identifier 비활성화할 템플릿 식별자
+ * @param string $reason 비활성화 사유 (DeactivationReason enum value: manual|incompatible_core)
+ * @param string|null $incompatibleRequiredVersion incompatible_core 사유 시 요구된 코어 버전 제약
* @return bool 비활성화 성공 여부
*/
- public function deactivateTemplate(string $identifier): bool;
+ public function deactivateTemplate(
+ string $identifier,
+ string $reason = DeactivationReason::Manual->value,
+ ?string $incompatibleRequiredVersion = null,
+ ): bool;
/**
* 템플릿의 의존성을 검증합니다.
diff --git a/app/Contracts/Repositories/ActivityLogRepositoryInterface.php b/app/Contracts/Repositories/ActivityLogRepositoryInterface.php
index 40ac317f..8464ee92 100644
--- a/app/Contracts/Repositories/ActivityLogRepositoryInterface.php
+++ b/app/Contracts/Repositories/ActivityLogRepositoryInterface.php
@@ -52,4 +52,14 @@ interface ActivityLogRepositoryInterface
* @return Collection 활동 로그 컬렉션
*/
public function getRecent(string $permission, int $limit = 5): Collection;
+
+ /**
+ * 사용자 삭제 시 해당 사용자의 모든 activity_logs.user_id 컬럼을 NULL 로 익명화합니다.
+ *
+ * 외래키가 제거된 파티셔닝 호환 스키마에서 직접 익명화 책임을 갖습니다.
+ *
+ * @param int $userId 익명화 대상 사용자 ID
+ * @return int 익명화된 row 수
+ */
+ public function anonymizeUserId(int $userId): int;
}
diff --git a/app/Contracts/Repositories/IdentityMessageDefinitionRepositoryInterface.php b/app/Contracts/Repositories/IdentityMessageDefinitionRepositoryInterface.php
new file mode 100644
index 00000000..3b3f812c
--- /dev/null
+++ b/app/Contracts/Repositories/IdentityMessageDefinitionRepositoryInterface.php
@@ -0,0 +1,97 @@
+
+ */
+ public function getLabelMap(?string $locale = null): array;
+
+ /**
+ * 전체 메시지 정의 조회.
+ *
+ * @return Collection
+ */
+ public function getAll(): Collection;
+
+ /**
+ * 특정 확장의 메시지 정의 목록 조회.
+ *
+ * @param string $extensionType
+ * @param string $extensionIdentifier
+ * @return Collection
+ */
+ public function getByExtension(string $extensionType, string $extensionIdentifier): Collection;
+
+ /**
+ * 메시지 정의 신규 생성.
+ *
+ * @param array $data
+ * @return IdentityMessageDefinition
+ */
+ public function store(array $data): IdentityMessageDefinition;
+
+ /**
+ * 메시지 정의 수정.
+ *
+ * @param IdentityMessageDefinition $definition
+ * @param array $data
+ * @return IdentityMessageDefinition
+ */
+ public function update(IdentityMessageDefinition $definition, array $data): IdentityMessageDefinition;
+
+ /**
+ * 페이지네이션 목록 조회.
+ *
+ * @param array $filters
+ * @param int $perPage
+ * @return LengthAwarePaginator
+ */
+ public function getPaginated(array $filters = [], int $perPage = 20): LengthAwarePaginator;
+}
diff --git a/app/Contracts/Repositories/IdentityMessageTemplateRepositoryInterface.php b/app/Contracts/Repositories/IdentityMessageTemplateRepositoryInterface.php
new file mode 100644
index 00000000..3d1daf7e
--- /dev/null
+++ b/app/Contracts/Repositories/IdentityMessageTemplateRepositoryInterface.php
@@ -0,0 +1,69 @@
+
+ */
+ public function resolveByScopeTarget(string $scope, string $target): Collection;
+
+ /**
+ * key 존재 시 업데이트, 없으면 생성합니다. Seeder/SyncHelper 가 사용하는 upsert 경로.
+ *
+ * @param array $attributes 정책 속성
+ * @return IdentityPolicy upsert 된 정책
+ */
+ public function upsertByKey(array $attributes): IdentityPolicy;
+
+ /**
+ * key 기준 업데이트 (운영자 UI 편집 경로).
+ *
+ * @param string $key 정책 키
+ * @param array $attributes 변경할 속성
+ * @param array $overridesFields user_overrides 에 append 할 필드명들
+ * @return bool 업데이트 성공 여부
+ */
+ public function updateByKey(string $key, array $attributes, array $overridesFields = []): bool;
+
+ /**
+ * key 기준 삭제 (source_type=admin 인 정책만 허용).
+ *
+ * @param string $key 정책 키
+ * @return bool 삭제 성공 여부
+ */
+ public function deleteByKey(string $key): bool;
+
+ /**
+ * source_type+source_identifier 에 속하지 않은 stale 정책을 제거합니다.
+ *
+ * @param string $sourceType 'core' | 'module' | 'plugin' | 'admin'
+ * @param string $sourceIdentifier vendor 식별자 (예: sirsoft-ecommerce, core)
+ * @param array $currentKeys 현재 선언된 key 목록
+ * @return int 삭제된 행 수
+ */
+ public function cleanupStale(string $sourceType, string $sourceIdentifier, array $currentKeys): int;
+
+ /**
+ * 특정 source(확장) 가 등록한 정책 개수를 반환합니다.
+ *
+ * 모듈/플러그인 uninstall 모달의 "삭제될 데이터" 표시에 사용.
+ *
+ * @param string $sourceType 'core' | 'module' | 'plugin' | 'admin'
+ * @param string $sourceIdentifier 확장 식별자
+ * @return int
+ */
+ public function countBySource(string $sourceType, string $sourceIdentifier): int;
+
+ /**
+ * 목록 조회 (관리자 S1d DataGrid).
+ *
+ * @param array $filters 필터 조건
+ * @param int $perPage 페이지 크기
+ * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
+ */
+ public function search(array $filters, int $perPage = 20);
+
+ /**
+ * 전체 활성 정책을 반환합니다.
+ *
+ * @return Collection
+ */
+ public function allEnabled(): Collection;
+
+ /**
+ * scope='route' 활성 정책을 [target => Collection] 맵으로 반환합니다.
+ *
+ * EnforceIdentityPolicy 미들웨어의 자동 매핑 lookup 진입점으로 사용. 부팅 시 1회 캐싱되며
+ * IdentityPolicy 모델 saved/deleted 이벤트가 캐시를 즉시 invalidate 합니다.
+ *
+ * brace expansion 지원: target 'api.admin.{modules,plugins}.uninstall' 같은 표현은
+ * 두 개의 라우트명으로 펼쳐 동일 정책 인스턴스를 양쪽에 매핑합니다.
+ *
+ * @return array> route name → 매칭 정책 컬렉션
+ */
+ public function getRouteScopeIndex(): array;
+
+ /**
+ * scope='hook' 활성 정책의 target 목록(중복 제거)을 반환합니다.
+ *
+ * EnforceIdentityPolicyListener::loadDynamicHookTargets() 가 부팅 시 동적 훅 구독을 위해
+ * 호출하는 단일 진입점입니다. identity_policies 테이블이 존재하지 않거나 DB 미연결 환경
+ * (마이그레이션 전 부팅) 에서는 빈 배열을 반환해 부팅을 보호합니다.
+ *
+ * @return list 동적 hook target 목록
+ */
+ public function listHookTargets(): array;
+}
diff --git a/app/Contracts/Repositories/IdentityVerificationLogRepositoryInterface.php b/app/Contracts/Repositories/IdentityVerificationLogRepositoryInterface.php
new file mode 100644
index 00000000..4502083b
--- /dev/null
+++ b/app/Contracts/Repositories/IdentityVerificationLogRepositoryInterface.php
@@ -0,0 +1,101 @@
+ $attributes 로그 속성
+ * @return IdentityVerificationLog 생성된 로그
+ */
+ public function create(array $attributes): IdentityVerificationLog;
+
+ /**
+ * id(UUID) 로 조회합니다.
+ *
+ * @param string $id 로그 UUID
+ * @return IdentityVerificationLog|null
+ */
+ public function findById(string $id): ?IdentityVerificationLog;
+
+ /**
+ * id 기준 업데이트.
+ *
+ * @param string $id 로그 UUID
+ * @param array $attributes 변경할 속성
+ * @return bool 1건 이상 업데이트되었는지 여부
+ */
+ public function updateById(string $id, array $attributes): bool;
+
+ /**
+ * target_hash + purpose 조합으로 최근 성공한 challenge 를 조회합니다.
+ * grace_minutes 내 재사용 가능 여부 판정에 사용합니다.
+ *
+ * @param string $purpose IDV 목적
+ * @param int|null $userId 로그인 상태에서는 user_id 우선 매칭
+ * @param string|null $targetHash user_id 가 null 일 때 대신 매칭하는 sha256 해시
+ * @param int $withinMinutes grace_minutes (이 분 내 verified 만 매칭)
+ * @return IdentityVerificationLog|null 매칭 로그 또는 null
+ */
+ public function findRecentVerified(
+ string $purpose,
+ ?int $userId,
+ ?string $targetHash,
+ int $withinMinutes,
+ ): ?IdentityVerificationLog;
+
+ /**
+ * 특정 토큰(verification_token) 으로 verified 상태의 challenge 를 찾습니다.
+ * IdvTokenRule 이 register 검증 시 사용합니다.
+ *
+ * @param string $token verification_token
+ * @param string $purpose IDV 목적 (예: signup)
+ * @return IdentityVerificationLog|null 매칭 로그 또는 null
+ */
+ public function findVerifiedForToken(string $token, string $purpose): ?IdentityVerificationLog;
+
+ /**
+ * 만료 경과 challenge 를 일괄 expire 처리합니다.
+ *
+ * @return int 처리된 행 수
+ */
+ public function expirePastDue(): int;
+
+ /**
+ * 보관주기 경과 로그를 일괄 삭제합니다.
+ *
+ * @param int $days 보관 일수 (이보다 오래된 로그가 삭제됨)
+ * @return int 삭제된 행 수
+ */
+ public function purgeOlderThan(int $days): int;
+
+ /**
+ * 목록 조회 (관리자 인증 이력 화면).
+ *
+ * @param array $filters provider_id/purpose/status/user_id/date_from/date_to 등
+ * @param int $perPage 페이지 크기
+ * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
+ */
+ public function search(array $filters, int $perPage = 20);
+
+ /**
+ * user_id 백필 (signup 흐름에서 pre-signup challenge → 가입 성공 후 user_id 채움).
+ *
+ * @param string $id 로그 UUID
+ * @param int $userId 채울 user_id
+ * @return bool 백필 성공 여부 (이미 user_id 가 있으면 false)
+ */
+ public function backfillUserId(string $id, int $userId): bool;
+}
diff --git a/app/Contracts/Repositories/LanguagePackRepositoryInterface.php b/app/Contracts/Repositories/LanguagePackRepositoryInterface.php
new file mode 100644
index 00000000..accb74c2
--- /dev/null
+++ b/app/Contracts/Repositories/LanguagePackRepositoryInterface.php
@@ -0,0 +1,182 @@
+ 활성 언어팩 컬렉션
+ */
+ public function getActivePacks(): Collection;
+
+ /**
+ * 특정 슬롯의 활성 언어팩을 조회합니다.
+ *
+ * @param string $scope 스코프
+ * @param string|null $targetIdentifier 대상 확장 식별자
+ * @param string $locale 로케일
+ * @param int|null $excludeId 결과에서 제외할 언어팩 id (재설치 시 자기 자신 제외용)
+ * @return LanguagePack|null 활성 언어팩 또는 null
+ */
+ public function findActiveForSlot(
+ string $scope,
+ ?string $targetIdentifier,
+ string $locale,
+ ?int $excludeId = null
+ ): ?LanguagePack;
+
+ /**
+ * 특정 슬롯의 모든 후보 언어팩을 조회합니다 (벤더별).
+ *
+ * @param string $scope 스코프
+ * @param string|null $targetIdentifier 대상 확장 식별자
+ * @param string $locale 로케일
+ * @return Collection 후보 언어팩 컬렉션
+ */
+ public function getPacksForSlot(
+ string $scope,
+ ?string $targetIdentifier,
+ string $locale
+ ): Collection;
+
+ /**
+ * 활성 코어 언어팩이 있는 모든 로케일을 반환합니다.
+ *
+ * @return array 로케일 문자열 배열
+ */
+ public function getActiveCoreLocales(): array;
+
+ /**
+ * 페이지네이션 + 필터링된 언어팩 목록을 조회합니다.
+ *
+ * @param array $filters 필터 (scope, target_identifier, locale, status, vendor)
+ * @param int $perPage 페이지당 건수
+ * @return LengthAwarePaginator 페이지네이션 결과
+ */
+ public function paginate(array $filters = [], int $perPage = 20): LengthAwarePaginator;
+
+ /**
+ * 필터링된 언어팩 컬렉션을 페이지네이션 없이 조회합니다.
+ *
+ * 미설치 번들 가상 레코드(`lang-packs/_bundled/{identifier}`)와 병합한 뒤
+ * Service 계층에서 수동 페이지네이션을 수행하기 위해 사용됩니다.
+ *
+ * @param array $filters 필터 (scope, target_identifier, locale, status, vendor, search)
+ * @return Collection 필터링된 언어팩 컬렉션
+ */
+ public function getFilteredCollection(array $filters = []): Collection;
+
+ /**
+ * 언어팩을 생성합니다.
+ *
+ * @param array $data 생성 데이터
+ * @return LanguagePack 생성된 언어팩
+ */
+ public function create(array $data): LanguagePack;
+
+ /**
+ * 언어팩을 갱신합니다.
+ *
+ * @param LanguagePack $pack 대상 언어팩
+ * @param array $data 갱신 데이터
+ * @return LanguagePack 갱신된 언어팩
+ */
+ public function update(LanguagePack $pack, array $data): LanguagePack;
+
+ /**
+ * 언어팩을 삭제합니다.
+ *
+ * @param LanguagePack $pack 대상 언어팩
+ * @return bool 삭제 성공 여부
+ */
+ public function delete(LanguagePack $pack): bool;
+
+ /**
+ * 특정 확장(scope, target_identifier)에 연결된 언어팩 전체를 조회합니다.
+ *
+ * @param string $scope 스코프
+ * @param string $targetIdentifier 대상 확장 식별자
+ * @return Collection 언어팩 컬렉션
+ */
+ public function getPacksForTarget(string $scope, string $targetIdentifier): Collection;
+
+ /**
+ * 특정 로케일에 속하는 모든 언어팩을 조회합니다 (cascade 삭제 시 사용).
+ *
+ * @param string $locale 로케일
+ * @return Collection 언어팩 컬렉션
+ */
+ public function getPacksForLocale(string $locale): Collection;
+
+ /**
+ * 번들 manifest 로부터 가상 LanguagePack 인스턴스를 합성합니다 (DB 미저장).
+ *
+ * 미설치(uninstalled) 번들의 가상 행 합성 책임을 Repository 로 일원화 — Service 가
+ * Model 을 직접 인스턴스화하지 않도록 합니다. exists=false 로 표시되며, `bundled_identifier`
+ * 가상 속성을 함께 채워 Resource 가 행 액션에 노출할 수 있게 합니다.
+ *
+ * @param array $manifest 번들 manifest 데이터
+ * @param string $bundledIdentifier `lang-packs/_bundled/{이 값}` 디렉토리명
+ * @return LanguagePack 가상 LanguagePack 인스턴스 (DB 미저장)
+ */
+ public function buildVirtualFromManifest(array $manifest, string $bundledIdentifier): LanguagePack;
+
+ /**
+ * 코어/번들 확장의 lang/{ko,en}/ 디렉토리로부터 가상 보호 LanguagePack 인스턴스를 합성합니다.
+ *
+ * 항상 active+protected 로 표시되며 사용자가 install/uninstall/activate/deactivate 할 수 없습니다.
+ *
+ * @param string $scope 스코프 (core/module/plugin/template)
+ * @param string|null $targetIdentifier 대상 확장 식별자 (core 일 때 null)
+ * @param string $locale 로케일 (예: 'ko', 'en')
+ * @param string $vendor 벤더 (확장 manifest.vendor 또는 'g7')
+ * @param string $version 버전
+ * @param string $langPathRelative lang 디렉토리 상대 경로
+ * @return LanguagePack 가상 LanguagePack 인스턴스
+ */
+ public function buildVirtualBuiltInPack(
+ string $scope,
+ ?string $targetIdentifier,
+ string $locale,
+ string $vendor,
+ string $version,
+ string $langPathRelative,
+ ): LanguagePack;
+
+ /**
+ * 호스트 확장(modules/plugins/templates)의 status + version 행을 조회합니다.
+ *
+ * `language_packs.requires.target_version` 검사 등 cross-domain 조회를 Repository
+ * 경계 안으로 캡슐화하기 위한 메서드입니다. Service 가 DB facade 를 직접 호출하지 않도록 합니다.
+ *
+ * @param string $scope 스코프 (module/plugin/template). core 또는 알 수 없는 값은 null 반환.
+ * @param string $identifier 호스트 확장 식별자
+ * @return object|null `{status, version}` 객체 또는 행 부재/스코프 미지원 시 null
+ */
+ public function findHostExtensionRow(string $scope, string $identifier): ?object;
+}
diff --git a/app/Contracts/Repositories/LanguagePackTranslationRepositoryInterface.php b/app/Contracts/Repositories/LanguagePackTranslationRepositoryInterface.php
new file mode 100644
index 00000000..27f66dc6
--- /dev/null
+++ b/app/Contracts/Repositories/LanguagePackTranslationRepositoryInterface.php
@@ -0,0 +1,41 @@
+> $seedBundle 엔티티별 seed 데이터 (loadSeed 결과 묶음)
+ * @return array> 감사 로그 항목 (skipped/applied 결정)
+ */
+ public function applySeedFromPack(LanguagePack $pack, array $seedBundle): array;
+
+ /**
+ * 언어팩의 locale 키를 DB JSON 컬럼에서 제거합니다 (user_overrides 컬럼은 보존).
+ *
+ * @param LanguagePack $pack 비활성화된 언어팩
+ * @return array> 감사 로그 항목 (preserved/stripped 결정)
+ */
+ public function stripLocaleFromPack(LanguagePack $pack): array;
+}
diff --git a/app/Contracts/Repositories/ModuleRepositoryInterface.php b/app/Contracts/Repositories/ModuleRepositoryInterface.php
index 48cd6b02..9323ea8a 100644
--- a/app/Contracts/Repositories/ModuleRepositoryInterface.php
+++ b/app/Contracts/Repositories/ModuleRepositoryInterface.php
@@ -193,4 +193,13 @@ interface ModuleRepositoryInterface
* @return Collection 해당 플러그인에 의존하는 활성 모듈 컬렉션
*/
public function findActiveByPluginDependency(string $pluginIdentifier): Collection;
+
+ /**
+ * 코어 버전 비호환으로 자동 비활성화된 모듈을 조회합니다.
+ *
+ * `deactivated_reason = 'incompatible_core'` 인 레코드만 반환합니다.
+ *
+ * @return Collection 자동 비활성화된 모듈 컬렉션
+ */
+ public function findAutoDeactivated(): Collection;
}
diff --git a/app/Contracts/Repositories/PluginRepositoryInterface.php b/app/Contracts/Repositories/PluginRepositoryInterface.php
index 7a8a65d6..7b3cbbae 100644
--- a/app/Contracts/Repositories/PluginRepositoryInterface.php
+++ b/app/Contracts/Repositories/PluginRepositoryInterface.php
@@ -157,4 +157,14 @@ interface PluginRepositoryInterface
* @return array 활성화된 플러그인 identifier 배열
*/
public function getActivePluginIdentifiers(): array;
+
+ /**
+ * 코어 버전 비호환으로 자동 비활성화된 플러그인을 조회합니다.
+ *
+ * `deactivated_reason = 'incompatible_core'` 인 레코드만 반환합니다.
+ * 알림 영속화 + 재호환 판정 + 상단 배너 데이터 소스로 사용됩니다.
+ *
+ * @return Collection 자동 비활성화된 플러그인 컬렉션
+ */
+ public function findAutoDeactivated(): Collection;
}
diff --git a/app/Contracts/Repositories/ScheduleRepositoryInterface.php b/app/Contracts/Repositories/ScheduleRepositoryInterface.php
index eb654f69..3ae26672 100644
--- a/app/Contracts/Repositories/ScheduleRepositoryInterface.php
+++ b/app/Contracts/Repositories/ScheduleRepositoryInterface.php
@@ -101,4 +101,14 @@ interface ScheduleRepositoryInterface
* @return Schedule 복제된 스케줄
*/
public function duplicate(Schedule $schedule): Schedule;
+
+ /**
+ * ID 목록으로 스케줄들을 조회하고 ID 키 맵으로 반환합니다.
+ *
+ * Bulk activity log 처리 시 N+1 회피용 단일 쿼리 진입점.
+ *
+ * @param array $ids 스케줄 ID 목록
+ * @return Collection id => Schedule 매핑
+ */
+ public function findManyByIdsKeyed(array $ids): Collection;
}
diff --git a/app/Contracts/Repositories/TemplateRepositoryInterface.php b/app/Contracts/Repositories/TemplateRepositoryInterface.php
index df607734..d050dd19 100644
--- a/app/Contracts/Repositories/TemplateRepositoryInterface.php
+++ b/app/Contracts/Repositories/TemplateRepositoryInterface.php
@@ -126,4 +126,13 @@ interface TemplateRepositoryInterface
* @return Collection 해당 플러그인에 의존하는 활성 템플릿 컬렉션
*/
public function findActiveByPluginDependency(string $pluginIdentifier): Collection;
+
+ /**
+ * 코어 버전 비호환으로 자동 비활성화된 템플릿을 조회합니다.
+ *
+ * `deactivated_reason = 'incompatible_core'` 인 레코드만 반환합니다.
+ *
+ * @return Collection 자동 비활성화된 템플릿 컬렉션
+ */
+ public function findAutoDeactivated(): Collection;
}
diff --git a/app/Contracts/Repositories/UserRepositoryInterface.php b/app/Contracts/Repositories/UserRepositoryInterface.php
index 39496b8a..8f0fece2 100644
--- a/app/Contracts/Repositories/UserRepositoryInterface.php
+++ b/app/Contracts/Repositories/UserRepositoryInterface.php
@@ -93,4 +93,57 @@ interface UserRepositoryInterface
* @return array 언어별 사용자 수 배열
*/
public function getUsersByLanguage(): array;
+
+ /**
+ * UUID 목록으로 사용자들을 조회하고 UUID 키 맵으로 반환합니다.
+ *
+ * Bulk activity log 처리 시 N+1 회피용 단일 쿼리 진입점.
+ *
+ * @param array $uuids 사용자 UUID 목록
+ * @return Collection uuid => User 매핑
+ */
+ public function findManyByUuidsKeyed(array $uuids): Collection;
+
+ /**
+ * 사용자의 연속 로그인 실패 카운터를 1 증가시킵니다.
+ *
+ * `last_failed_login_at` 도 현재 시각으로 갱신하며 새 카운트를 반환합니다.
+ *
+ * @param User $user 대상 사용자
+ * @return int 증가 후 카운트
+ */
+ public function incrementFailedAttempts(User $user): int;
+
+ /**
+ * 사용자의 계정을 지정된 분만큼 잠급니다.
+ *
+ * `locked_until` 을 현재 시각 + $minutes 로 설정하고 `failed_login_attempts` 를
+ * 0 으로 리셋합니다 (다음 잠금 윈도우 시작점). 잠금 해제 시각을 반환합니다.
+ *
+ * @param User $user 잠글 사용자
+ * @param int $minutes 잠금 유지 시간(분)
+ * @return \Illuminate\Support\Carbon 잠금 해제 시각
+ */
+ public function lockAccount(User $user, int $minutes): \Illuminate\Support\Carbon;
+
+ /**
+ * 사용자의 모든 로그인 시도 추적 컬럼을 초기화합니다.
+ *
+ * 정상 로그인 성공 시 호출됩니다 (`failed_login_attempts=0`,
+ * `locked_until=null`, `last_failed_login_at=null`).
+ *
+ * @param User $user 대상 사용자
+ * @return void
+ */
+ public function resetLoginAttempts(User $user): void;
+
+ /**
+ * 사용자의 계정이 현재 시점에 잠금 상태인지 판정합니다.
+ *
+ * `locked_until` 이 NULL 이거나 현재 시각보다 과거이면 false 를 반환합니다.
+ *
+ * @param User $user 대상 사용자
+ * @return bool 잠금 여부
+ */
+ public function isLocked(User $user): bool;
}
\ No newline at end of file
diff --git a/app/Contracts/Seeder/TranslatableSeederInterface.php b/app/Contracts/Seeder/TranslatableSeederInterface.php
new file mode 100644
index 00000000..52ff7f4f
--- /dev/null
+++ b/app/Contracts/Seeder/TranslatableSeederInterface.php
@@ -0,0 +1,45 @@
+ slug > key > identifier > id.
+ */
+ public function getMatchKey(): string;
+
+ /**
+ * 기본 데이터 (활성 언어팩 머지 전 원본).
+ *
+ * `applyFilters` 호출 후 ja 등 활성 locale 키가 자동 보강된 결과를 시더가 사용.
+ *
+ * @return array>
+ */
+ public function getDefaults(): array;
+}
diff --git a/app/Database/Sample/AbstractIdentityVerificationLogSampleSeeder.php b/app/Database/Sample/AbstractIdentityVerificationLogSampleSeeder.php
new file mode 100644
index 00000000..0afb10c4
--- /dev/null
+++ b/app/Database/Sample/AbstractIdentityVerificationLogSampleSeeder.php
@@ -0,0 +1,295 @@
+
+ */
+ protected array $statusBuckets;
+
+ /**
+ * 한국/해외 IP 풀.
+ *
+ * @var array
+ */
+ protected array $ips = [
+ '121.78.45.12', '211.234.111.5', '125.142.88.91', '210.94.0.74',
+ '203.241.185.20', '180.182.50.7', '175.223.18.143', '218.236.42.61',
+ '14.45.110.222', '112.184.99.180', '61.43.232.18', '59.16.7.205',
+ '110.45.234.12', '106.247.83.190', '220.86.55.121',
+ '203.0.113.42', '198.51.100.7', '172.217.27.142',
+ '8.8.8.8', '1.1.1.1',
+ ];
+
+ /**
+ * 데스크톱/모바일 UA 풀.
+ *
+ * @var array
+ */
+ protected array $userAgents = [
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0',
+ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15',
+ 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
+ 'Mozilla/5.0 (Linux; Android 14; SM-S921N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36',
+ 'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:130.0) Gecko/20100101 Firefox/130.0',
+ ];
+
+ public function __construct()
+ {
+ $this->statusBuckets = [
+ [IdentityVerificationStatus::Verified, 55],
+ [IdentityVerificationStatus::Expired, 15],
+ [IdentityVerificationStatus::Failed, 12],
+ [IdentityVerificationStatus::Sent, 7],
+ [IdentityVerificationStatus::Cancelled, 5],
+ [IdentityVerificationStatus::Requested, 3],
+ [IdentityVerificationStatus::PolicyViolationLogged, 3],
+ ];
+ }
+
+ /**
+ * IdentityPolicy 쿼리에 영역 필터를 적용한다.
+ *
+ * @param Builder $query IdentityPolicy 쿼리
+ * @return Builder 영역 필터가 적용된 쿼리
+ */
+ abstract protected function applyPolicyScope(Builder $query): Builder;
+
+ /**
+ * 카운트 옵션 키.
+ *
+ * @return string 카운트 옵션 키
+ */
+ abstract protected function countKey(): string;
+
+ /**
+ * 기본 생성 건수.
+ *
+ * @return int 기본 건수
+ */
+ abstract protected function defaultCount(): int;
+
+ /**
+ * 콘솔 메시지에 사용할 영역 라벨.
+ *
+ * @return string 영역 라벨
+ */
+ abstract protected function scopeLabel(): string;
+
+ /**
+ * 시더 실행.
+ */
+ public function run(): void
+ {
+ $count = $this->getSeederCount($this->countKey(), $this->defaultCount());
+ $label = $this->scopeLabel();
+
+ $users = User::query()->get(['id', 'name', 'email']);
+ if ($users->isEmpty()) {
+ $this->command->warn("사용자 데이터가 없어 {$label} 본인인증 이력 시더를 건너뜁니다.");
+
+ return;
+ }
+
+ $policies = $this->applyPolicyScope(IdentityPolicy::query())
+ ->get(['key', 'purpose', 'source_type', 'source_identifier', 'provider_id']);
+ if ($policies->isEmpty()) {
+ $this->command->warn("{$label} 영역 IdentityPolicy 가 없어 시더를 건너뜁니다.");
+
+ return;
+ }
+
+ $manager = app(IdentityVerificationManager::class);
+ $providerIds = array_keys($manager->all());
+ if (empty($providerIds)) {
+ $this->command->warn("등록된 본인인증 프로바이더가 없어 {$label} 시더를 건너뜁니다.");
+
+ return;
+ }
+
+ $this->command->info("{$label} 본인인증 이력 시딩 시작... ({$count}건)");
+
+ $ttlMinutes = (int) config('settings.identity.challenge_ttl_minutes', 15);
+ $maxAttempts = (int) config('settings.identity.max_attempts', 5);
+ $now = Carbon::now();
+ $batch = [];
+
+ for ($i = 0; $i < $count; $i++) {
+ $user = $users->random();
+ $policy = $policies->random();
+ $providerId = $policy->provider_id ?: $providerIds[array_rand($providerIds)];
+ $status = $this->pickStatus();
+ $renderHint = mt_rand(1, 100) <= 70 ? 'text_code' : 'email_link';
+
+ $createdAt = $this->randomCreatedAt($now);
+ [$expiresAt, $verifiedAt, $consumedAt, $attempts] = $this->buildLifecycle(
+ $status,
+ $createdAt,
+ $ttlMinutes,
+ $maxAttempts,
+ );
+
+ $properties = $renderHint === 'text_code'
+ ? ['code_length' => 6]
+ : ['link_hint' => 'email_link'];
+
+ $metadata = $status === IdentityVerificationStatus::PolicyViolationLogged
+ ? ['violation_reason' => 'fail_mode_log_only']
+ : ['hint_used' => $renderHint];
+
+ $batch[] = [
+ 'id' => (string) Str::uuid(),
+ 'provider_id' => $providerId,
+ 'purpose' => $policy->purpose,
+ 'channel' => 'email',
+ 'user_id' => $user->id,
+ 'target_hash' => hash('sha256', mb_strtolower($user->email)),
+ 'status' => $status->value,
+ 'render_hint' => $renderHint,
+ 'attempts' => $attempts,
+ 'max_attempts' => $maxAttempts,
+ 'ip_address' => $this->ips[array_rand($this->ips)],
+ 'user_agent' => $this->userAgents[array_rand($this->userAgents)],
+ // 본 시더의 모든 challenge 는 IdentityPolicy enforce 경로를 통한 것이므로
+ // origin_type 은 'policy' 로 분류한다 (이전 버전에서는 source_type 을 잘못 매핑).
+ 'origin_type' => IdentityOriginType::Policy->value,
+ 'origin_identifier' => $policy->source_identifier,
+ 'origin_policy_key' => $policy->key,
+ 'properties' => json_encode($properties, JSON_UNESCAPED_UNICODE),
+ 'metadata' => json_encode($metadata, JSON_UNESCAPED_UNICODE),
+ 'verification_token' => $status === IdentityVerificationStatus::Verified
+ ? bin2hex(random_bytes(32))
+ : null,
+ 'expires_at' => $expiresAt,
+ 'verified_at' => $verifiedAt,
+ 'consumed_at' => $consumedAt,
+ 'created_at' => $createdAt,
+ 'updated_at' => $verifiedAt ?? $createdAt,
+ ];
+ }
+
+ foreach (array_chunk($batch, 100) as $chunk) {
+ IdentityVerificationLog::insert($chunk);
+ }
+
+ $this->command->info("{$label} 본인인증 이력 시딩 완료 ({$count}건)");
+ }
+
+ /**
+ * 가중치 기반 상태 선택.
+ *
+ * @return IdentityVerificationStatus 선택된 상태
+ */
+ protected function pickStatus(): IdentityVerificationStatus
+ {
+ $r = mt_rand(1, 100);
+ $acc = 0;
+ foreach ($this->statusBuckets as [$status, $weight]) {
+ $acc += $weight;
+ if ($r <= $acc) {
+ return $status;
+ }
+ }
+
+ return IdentityVerificationStatus::Verified;
+ }
+
+ /**
+ * 상태별 라이프사이클 일관성 있게 구성.
+ *
+ * @param IdentityVerificationStatus $status Challenge 상태
+ * @param Carbon $createdAt 생성 시각
+ * @param int $ttlMinutes TTL (분)
+ * @param int $maxAttempts 최대 시도 횟수
+ * @return array{0: Carbon|null, 1: Carbon|null, 2: Carbon|null, 3: int} [expires_at, verified_at, consumed_at, attempts]
+ */
+ protected function buildLifecycle(
+ IdentityVerificationStatus $status,
+ Carbon $createdAt,
+ int $ttlMinutes,
+ int $maxAttempts,
+ ): array {
+ $expiresAt = (clone $createdAt)->addMinutes($ttlMinutes);
+ $verifiedAt = null;
+ $consumedAt = null;
+ $attempts = 0;
+
+ switch ($status) {
+ case IdentityVerificationStatus::Verified:
+ $attempts = mt_rand(1, 3);
+ $verifiedAt = (clone $createdAt)->addSeconds(mt_rand(20, 600));
+ if (mt_rand(0, 1)) {
+ $consumedAt = (clone $verifiedAt)->addSeconds(mt_rand(1, 30));
+ }
+ break;
+ case IdentityVerificationStatus::Expired:
+ $attempts = mt_rand(0, 2);
+ break;
+ case IdentityVerificationStatus::Failed:
+ $attempts = $maxAttempts;
+ break;
+ case IdentityVerificationStatus::Cancelled:
+ $attempts = mt_rand(0, 2);
+ break;
+ case IdentityVerificationStatus::Sent:
+ case IdentityVerificationStatus::Requested:
+ $attempts = 0;
+ break;
+ case IdentityVerificationStatus::PolicyViolationLogged:
+ $expiresAt = null;
+ $attempts = 0;
+ break;
+ }
+
+ return [$expiresAt, $verifiedAt, $consumedAt, $attempts];
+ }
+
+ /**
+ * 최근 60일 내 임의 생성 시각.
+ *
+ * @param Carbon $now 기준 시각
+ * @return Carbon Challenge 생성 시각
+ */
+ protected function randomCreatedAt(Carbon $now): Carbon
+ {
+ return (clone $now)
+ ->subDays(mt_rand(0, 60))
+ ->subHours(mt_rand(0, 23))
+ ->subMinutes(mt_rand(0, 59))
+ ->subSeconds(mt_rand(0, 59));
+ }
+}
diff --git a/app/Database/Sample/AbstractNotificationLogSampleSeeder.php b/app/Database/Sample/AbstractNotificationLogSampleSeeder.php
new file mode 100644
index 00000000..03078c26
--- /dev/null
+++ b/app/Database/Sample/AbstractNotificationLogSampleSeeder.php
@@ -0,0 +1,273 @@
+
+ */
+ protected array $errorMessages = [
+ 'SMTP connection refused: smtp.gmail.com:587',
+ 'Connection timed out after 10s',
+ 'Mailbox unavailable: 550 5.1.1 user unknown',
+ 'TLS handshake failed',
+ 'Rate limit exceeded (provider quota)',
+ 'Recipient address rejected: domain not found',
+ 'Authentication failed: invalid credentials',
+ 'Greylisted, retry later (450 4.2.0)',
+ ];
+
+ /**
+ * 발송 건너뜀 사유.
+ *
+ * @var array
+ */
+ protected array $skipReasons = [
+ 'Template inactive',
+ 'User opted out',
+ 'Channel disabled by user preference',
+ 'Quiet hours policy applied',
+ 'Duplicate suppression window',
+ ];
+
+ /**
+ * 알림 정의 쿼리에 영역 필터를 적용한다 (예: extension_type='core' 또는 extension_identifier='sirsoft-board').
+ *
+ * @param Builder $query NotificationDefinition 쿼리
+ * @return Builder 영역 필터가 적용된 쿼리
+ */
+ abstract protected function applyDefinitionScope(Builder $query): Builder;
+
+ /**
+ * 카운트 옵션 키 (예: 'core_notification_logs', 'ecommerce_notification_logs').
+ *
+ * @return string 카운트 옵션 키
+ */
+ abstract protected function countKey(): string;
+
+ /**
+ * 기본 생성 건수.
+ *
+ * @return int 기본 건수
+ */
+ abstract protected function defaultCount(): int;
+
+ /**
+ * 콘솔 메시지에 사용할 영역 라벨 (예: '코어', '이커머스 모듈').
+ *
+ * @return string 영역 라벨
+ */
+ abstract protected function scopeLabel(): string;
+
+ /**
+ * 알림 타입별 한국어 제목 맵.
+ *
+ * @return array [type => subject]
+ */
+ abstract protected function subjectMap(): array;
+
+ /**
+ * 알림 타입별 한국어 본문 빌더 맵.
+ *
+ * @return array [type => fn($recipient, $sentAt) => string]
+ */
+ abstract protected function bodyMap(): array;
+
+ /**
+ * 시더 실행.
+ */
+ public function run(): void
+ {
+ $count = $this->getSeederCount($this->countKey(), $this->defaultCount());
+ $label = $this->scopeLabel();
+
+ $users = User::query()->get(['id', 'name', 'email']);
+ if ($users->isEmpty()) {
+ $this->command->warn("사용자 데이터가 없어 {$label} 알림 발송 이력 시더를 건너뜁니다.");
+
+ return;
+ }
+
+ $definitions = $this->applyDefinitionScope(
+ NotificationDefinition::query()->where('is_active', true)
+ )->get(['type', 'extension_type', 'extension_identifier', 'channels']);
+
+ if ($definitions->isEmpty()) {
+ $this->command->warn("{$label} 영역 활성 알림 정의가 없어 시더를 건너뜁니다.");
+
+ return;
+ }
+
+ $admin = User::query()
+ ->whereHas('roles', fn ($q) => $q->where('identifier', 'admin'))
+ ->first();
+ $adminId = $admin?->id;
+
+ $this->command->info("{$label} 알림 발송 이력 시딩 시작... ({$count}건)");
+
+ $now = Carbon::now();
+ $batch = [];
+
+ for ($i = 0; $i < $count; $i++) {
+ $definition = $definitions->random();
+ $channel = $this->pickChannel($definition->channels ?? ['mail']);
+ $recipient = $users->random();
+ [$status, $error] = $this->randomStatusAndError();
+ $sentAt = $this->randomSentAt($now);
+
+ $batch[] = [
+ 'channel' => $channel,
+ 'notification_type' => $definition->type,
+ 'extension_type' => $definition->extension_type,
+ 'extension_identifier' => $definition->extension_identifier,
+ 'recipient_user_id' => $recipient->id,
+ 'recipient_identifier' => $channel === 'mail'
+ ? $recipient->email
+ : (string) $recipient->id,
+ 'recipient_name' => $recipient->name,
+ 'sender_user_id' => $this->isAdminBoundType($definition->type) ? null : $adminId,
+ 'subject' => $this->renderSubject($definition->type),
+ 'body' => $this->renderBody($definition->type, $recipient, $sentAt),
+ 'status' => $status->value,
+ 'error_message' => $error,
+ 'source' => 'notification',
+ 'sent_at' => $sentAt,
+ 'created_at' => $sentAt,
+ 'updated_at' => $sentAt,
+ ];
+ }
+
+ foreach (array_chunk($batch, 100) as $chunk) {
+ NotificationLog::insert($chunk);
+ }
+
+ $this->command->info("{$label} 알림 발송 이력 시딩 완료 ({$count}건)");
+ }
+
+ /**
+ * 알림 정의에 등록된 채널 중 하나를 선택한다.
+ *
+ * @param array $channels 활성 채널 배열
+ * @return string 선택된 채널
+ */
+ protected function pickChannel(array $channels): string
+ {
+ if (empty($channels)) {
+ return 'mail';
+ }
+
+ if (in_array('mail', $channels, true) && in_array('database', $channels, true)) {
+ return mt_rand(1, 100) <= 60 ? 'mail' : 'database';
+ }
+
+ return $channels[array_rand($channels)];
+ }
+
+ /**
+ * 가중치 기반 상태/에러 메시지 페어를 반환한다.
+ *
+ * @return array{0: NotificationLogStatus, 1: string|null}
+ */
+ protected function randomStatusAndError(): array
+ {
+ $rand = mt_rand(1, 100);
+
+ if ($rand <= 80) {
+ return [NotificationLogStatus::Sent, null];
+ }
+
+ if ($rand <= 93) {
+ return [
+ NotificationLogStatus::Failed,
+ $this->errorMessages[array_rand($this->errorMessages)],
+ ];
+ }
+
+ return [
+ NotificationLogStatus::Skipped,
+ $this->skipReasons[array_rand($this->skipReasons)],
+ ];
+ }
+
+ /**
+ * 최근 60일 내 임의 발송 시각을 반환한다.
+ *
+ * @param Carbon $now 기준 시각
+ * @return Carbon 발송 시각
+ */
+ protected function randomSentAt(Carbon $now): Carbon
+ {
+ return (clone $now)
+ ->subDays(mt_rand(0, 60))
+ ->subHours(mt_rand(0, 23))
+ ->subMinutes(mt_rand(0, 59))
+ ->subSeconds(mt_rand(0, 59));
+ }
+
+ /**
+ * 알림 타입이 관리자 수신용인지 (sender_user_id null 처리).
+ *
+ * @param string $type 알림 타입
+ * @return bool 관리자 수신용이면 true
+ */
+ protected function isAdminBoundType(string $type): bool
+ {
+ return str_ends_with($type, '_admin');
+ }
+
+ /**
+ * 알림 타입별 제목 (서브클래스 subjectMap 우선, fallback 은 generic).
+ *
+ * @param string $type 알림 타입
+ * @return string 제목
+ */
+ protected function renderSubject(string $type): string
+ {
+ return $this->subjectMap()[$type] ?? "[G7] {$type} 알림";
+ }
+
+ /**
+ * 알림 타입별 본문 (서브클래스 bodyMap 우선, fallback 은 generic).
+ *
+ * @param string $type 알림 타입
+ * @param User $recipient 수신자
+ * @param Carbon $sentAt 발송 시각
+ * @return string 본문
+ */
+ protected function renderBody(string $type, User $recipient, Carbon $sentAt): string
+ {
+ $builder = $this->bodyMap()[$type] ?? null;
+
+ if ($builder === null) {
+ return "안녕하세요 {$recipient->name}님,\n\n{$type} 알림이 도착했습니다.";
+ }
+
+ return $builder($recipient, $sentAt);
+ }
+}
diff --git a/app/Enums/DeactivationReason.php b/app/Enums/DeactivationReason.php
new file mode 100644
index 00000000..5a5f354e
--- /dev/null
+++ b/app/Enums/DeactivationReason.php
@@ -0,0 +1,65 @@
+ '사용자 수동 비활성화',
+ self::IncompatibleCore => '코어 버전 호환성',
+ };
+ }
+
+ /**
+ * 시스템(자동) 트리거 여부
+ *
+ * true 인 경우 알림 영속화 + 원클릭 복구 UX 대상이 됩니다.
+ */
+ public function isSystemTriggered(): bool
+ {
+ return match ($this) {
+ self::Manual => false,
+ self::IncompatibleCore => true,
+ };
+ }
+
+ /**
+ * 모든 값 배열
+ *
+ * @return array
+ */
+ public static function values(): array
+ {
+ return array_column(self::cases(), 'value');
+ }
+
+ /**
+ * 유효한 값인지 확인
+ */
+ public static function isValid(string $value): bool
+ {
+ return in_array($value, self::values(), true);
+ }
+}
diff --git a/app/Enums/IdentityMessageScopeType.php b/app/Enums/IdentityMessageScopeType.php
new file mode 100644
index 00000000..ff7a3c2a
--- /dev/null
+++ b/app/Enums/IdentityMessageScopeType.php
@@ -0,0 +1,45 @@
+
+ */
+ public static function values(): array
+ {
+ return array_column(self::cases(), 'value');
+ }
+
+ /**
+ * 다국어 라벨을 반환합니다.
+ *
+ * @return string 번역된 scope_type 라벨
+ */
+ public function label(): string
+ {
+ return __('identity.message.scope_type.'.$this->value);
+ }
+}
diff --git a/app/Enums/IdentityOriginType.php b/app/Enums/IdentityOriginType.php
new file mode 100644
index 00000000..ebce3bef
--- /dev/null
+++ b/app/Enums/IdentityOriginType.php
@@ -0,0 +1,55 @@
+
+ */
+ public static function values(): array
+ {
+ return array_column(self::cases(), 'value');
+ }
+
+ /**
+ * 다국어 라벨을 반환합니다.
+ *
+ * @return string 번역된 origin_type 라벨
+ */
+ public function label(): string
+ {
+ return __('identity.origin_types.'.$this->value);
+ }
+}
diff --git a/app/Enums/IdentityPolicyAppliesTo.php b/app/Enums/IdentityPolicyAppliesTo.php
new file mode 100644
index 00000000..8038546d
--- /dev/null
+++ b/app/Enums/IdentityPolicyAppliesTo.php
@@ -0,0 +1,43 @@
+
+ */
+ public static function values(): array
+ {
+ return array_column(self::cases(), 'value');
+ }
+
+ /**
+ * 다국어 라벨을 반환합니다.
+ *
+ * @return string 번역된 applies_to 라벨
+ */
+ public function label(): string
+ {
+ return __('identity.policy.applies_to.'.$this->value);
+ }
+}
diff --git a/app/Enums/IdentityPolicyFailMode.php b/app/Enums/IdentityPolicyFailMode.php
new file mode 100644
index 00000000..cec1af3b
--- /dev/null
+++ b/app/Enums/IdentityPolicyFailMode.php
@@ -0,0 +1,40 @@
+
+ */
+ public static function values(): array
+ {
+ return array_column(self::cases(), 'value');
+ }
+
+ /**
+ * 다국어 라벨을 반환합니다.
+ *
+ * @return string 번역된 fail_mode 라벨
+ */
+ public function label(): string
+ {
+ return __('identity.policy.fail_mode.'.$this->value);
+ }
+}
diff --git a/app/Enums/IdentityPolicyScope.php b/app/Enums/IdentityPolicyScope.php
new file mode 100644
index 00000000..d5f62926
--- /dev/null
+++ b/app/Enums/IdentityPolicyScope.php
@@ -0,0 +1,43 @@
+
+ */
+ public static function values(): array
+ {
+ return array_column(self::cases(), 'value');
+ }
+
+ /**
+ * 다국어 라벨을 반환합니다.
+ *
+ * @return string 번역된 scope 라벨
+ */
+ public function label(): string
+ {
+ return __('identity.policy.scope.'.$this->value);
+ }
+}
diff --git a/app/Enums/IdentityPolicySourceType.php b/app/Enums/IdentityPolicySourceType.php
new file mode 100644
index 00000000..13a34a2e
--- /dev/null
+++ b/app/Enums/IdentityPolicySourceType.php
@@ -0,0 +1,47 @@
+
+ */
+ public static function values(): array
+ {
+ return array_column(self::cases(), 'value');
+ }
+
+ /**
+ * 다국어 라벨을 반환합니다.
+ *
+ * @return string 번역된 source_type 라벨
+ */
+ public function label(): string
+ {
+ return __('identity.policy.source_type.'.$this->value);
+ }
+}
diff --git a/app/Enums/IdentityVerificationChannel.php b/app/Enums/IdentityVerificationChannel.php
new file mode 100644
index 00000000..0efc3fc8
--- /dev/null
+++ b/app/Enums/IdentityVerificationChannel.php
@@ -0,0 +1,53 @@
+
+ */
+ public static function values(): array
+ {
+ return array_column(self::cases(), 'value');
+ }
+
+ /**
+ * 코어 채널 여부.
+ *
+ * @param string $value 검증할 채널 값
+ * @return bool 코어 채널 여부
+ */
+ public static function isCore(string $value): bool
+ {
+ return in_array($value, self::values(), true);
+ }
+
+ /**
+ * 다국어 라벨을 반환합니다.
+ *
+ * @return string 번역된 채널 라벨
+ */
+ public function label(): string
+ {
+ return __('identity.channels.'.$this->value);
+ }
+}
diff --git a/app/Enums/IdentityVerificationPurpose.php b/app/Enums/IdentityVerificationPurpose.php
new file mode 100644
index 00000000..32fc9a21
--- /dev/null
+++ b/app/Enums/IdentityVerificationPurpose.php
@@ -0,0 +1,59 @@
+
+ */
+ public static function values(): array
+ {
+ return array_column(self::cases(), 'value');
+ }
+
+ /**
+ * 코어 정의 purpose 인지 확인합니다 (모듈 declared 는 false).
+ *
+ * @param string $value 검증할 purpose 값
+ * @return bool 코어 purpose 여부
+ */
+ public static function isCore(string $value): bool
+ {
+ return in_array($value, self::values(), true);
+ }
+
+ /**
+ * 다국어 라벨을 반환합니다.
+ *
+ * @return string 번역된 purpose 라벨
+ */
+ public function label(): string
+ {
+ return __('identity.purposes.'.$this->value.'.label');
+ }
+}
diff --git a/app/Enums/IdentityVerificationStatus.php b/app/Enums/IdentityVerificationStatus.php
new file mode 100644
index 00000000..994b37b9
--- /dev/null
+++ b/app/Enums/IdentityVerificationStatus.php
@@ -0,0 +1,42 @@
+ version).
+ */
+ case CanUpdate = 'can_update';
+
+ /**
+ * 다국어 라벨을 반환합니다.
+ *
+ * @return string 라벨 (lang/{locale}/language_packs.php 의 ability 키)
+ */
+ public function label(): string
+ {
+ return __('language_packs.ability.'.$this->value);
+ }
+
+ /**
+ * 모든 ability 키를 문자열 배열로 반환합니다.
+ *
+ * @return array ability 키 배열
+ */
+ public static function values(): array
+ {
+ return array_column(self::cases(), 'value');
+ }
+
+ /**
+ * 주어진 문자열이 유효한 ability 키인지 확인합니다.
+ *
+ * @param string $value 검사할 ability 키
+ * @return bool 유효 여부
+ */
+ public static function isValid(string $value): bool
+ {
+ return in_array($value, self::values(), true);
+ }
+}
diff --git a/app/Enums/LanguagePackErrorCode.php b/app/Enums/LanguagePackErrorCode.php
new file mode 100644
index 00000000..478d2462
--- /dev/null
+++ b/app/Enums/LanguagePackErrorCode.php
@@ -0,0 +1,82 @@
+value);
+ }
+
+ /**
+ * 모든 에러 코드 값을 문자열 배열로 반환합니다.
+ *
+ * @return array 에러 코드 문자열 배열
+ */
+ public static function values(): array
+ {
+ return array_column(self::cases(), 'value');
+ }
+
+ /**
+ * 주어진 문자열이 유효한 에러 코드 값인지 확인합니다.
+ *
+ * @param string $value 검사할 에러 코드 문자열
+ * @return bool 유효 여부
+ */
+ public static function isValid(string $value): bool
+ {
+ return in_array($value, self::values(), true);
+ }
+}
diff --git a/app/Enums/LanguagePackOrigin.php b/app/Enums/LanguagePackOrigin.php
new file mode 100644
index 00000000..c5659cec
--- /dev/null
+++ b/app/Enums/LanguagePackOrigin.php
@@ -0,0 +1,88 @@
+ self::BuiltIn,
+ LanguagePackSourceType::Bundled => self::Bundled,
+ LanguagePackSourceType::Github,
+ LanguagePackSourceType::Url,
+ LanguagePackSourceType::Upload,
+ LanguagePackSourceType::Zip => self::UserInstalled,
+ };
+ }
+
+ /**
+ * 문자열 source_type 값으로부터 origin 을 결정합니다 (직렬화 단계 편의 메서드).
+ *
+ * @param string|null $sourceType source_type 컬럼 값
+ * @return self|null 매칭된 origin (유효하지 않은 값은 null)
+ */
+ public static function fromSourceTypeValue(?string $sourceType): ?self
+ {
+ if ($sourceType === null || ! LanguagePackSourceType::isValid($sourceType)) {
+ return null;
+ }
+
+ return self::fromSourceType(LanguagePackSourceType::from($sourceType));
+ }
+
+ /**
+ * 모든 origin 값을 문자열 배열로 반환합니다.
+ *
+ * @return array origin 문자열 배열
+ */
+ public static function values(): array
+ {
+ return array_column(self::cases(), 'value');
+ }
+
+ /**
+ * 다국어 라벨을 반환합니다.
+ *
+ * @return string 라벨 (lang/{locale}/language_packs.php 의 origin 키)
+ */
+ public function label(): string
+ {
+ return __('language_packs.origin.'.$this->value);
+ }
+}
diff --git a/app/Enums/LanguagePackScope.php b/app/Enums/LanguagePackScope.php
new file mode 100644
index 00000000..124de322
--- /dev/null
+++ b/app/Enums/LanguagePackScope.php
@@ -0,0 +1,75 @@
+ 스코프 문자열 배열
+ */
+ public static function values(): array
+ {
+ return array_column(self::cases(), 'value');
+ }
+
+ /**
+ * 주어진 문자열이 유효한 스코프 값인지 확인합니다.
+ *
+ * @param string $value 검사할 스코프 문자열
+ * @return bool 유효 여부
+ */
+ public static function isValid(string $value): bool
+ {
+ return in_array($value, self::values(), true);
+ }
+
+ /**
+ * 현재 스코프가 코어인지 확인합니다.
+ *
+ * @return bool 코어이면 true
+ */
+ public function isCore(): bool
+ {
+ return $this === self::Core;
+ }
+
+ /**
+ * 현재 스코프가 target_identifier 를 필요로 하는지 확인합니다.
+ *
+ * 코어 외 스코프(module/plugin/template)는 대상 확장 식별자가 필수입니다.
+ *
+ * @return bool target_identifier 필요 여부
+ */
+ public function requiresTarget(): bool
+ {
+ return ! $this->isCore();
+ }
+}
diff --git a/app/Enums/LanguagePackSourceType.php b/app/Enums/LanguagePackSourceType.php
new file mode 100644
index 00000000..0ad71142
--- /dev/null
+++ b/app/Enums/LanguagePackSourceType.php
@@ -0,0 +1,107 @@
+ 소스 타입 문자열 배열
+ */
+ public static function values(): array
+ {
+ return array_column(self::cases(), 'value');
+ }
+
+ /**
+ * 주어진 문자열이 유효한 소스 타입 값인지 확인합니다.
+ *
+ * @param string $value 검사할 소스 타입 문자열
+ * @return bool 유효 여부
+ */
+ public static function isValid(string $value): bool
+ {
+ return in_array($value, self::values(), true);
+ }
+
+ /**
+ * 다국어 라벨을 반환합니다.
+ *
+ * @return string 라벨 (lang/{locale}/language_packs.php 의 source_type 키)
+ */
+ public function label(): string
+ {
+ return __('language_packs.source_type.'.$this->value);
+ }
+
+ /**
+ * 본 소스 타입이 사용자 직접 설치 출처인지 (ZIP/GitHub/URL/Upload) 판정합니다.
+ *
+ * @return bool 사용자 외부 설치 출처면 true
+ */
+ public function isExternal(): bool
+ {
+ return match ($this) {
+ self::Github, self::Url, self::Upload, self::Zip => true,
+ default => false,
+ };
+ }
+
+ /**
+ * 본 소스 타입이 보호 대상 (수정/제거 차단) 인지 판정합니다.
+ *
+ * `BundledWithExtension` 과 `BuiltIn` 은 확장 본체에 종속되므로 보호.
+ * `Bundled` 은 사용자가 install/uninstall 자유.
+ *
+ * @return bool 보호 대상이면 true
+ */
+ public function isProtectedByDefault(): bool
+ {
+ return match ($this) {
+ self::BundledWithExtension, self::BuiltIn => true,
+ default => false,
+ };
+ }
+}
diff --git a/app/Enums/LanguagePackStatus.php b/app/Enums/LanguagePackStatus.php
new file mode 100644
index 00000000..ce3df7a8
--- /dev/null
+++ b/app/Enums/LanguagePackStatus.php
@@ -0,0 +1,66 @@
+ 상태 문자열 배열
+ */
+ public static function values(): array
+ {
+ return array_column(self::cases(), 'value');
+ }
+
+ /**
+ * 주어진 문자열이 유효한 상태 값인지 확인합니다.
+ *
+ * @param string $value 검사할 상태 문자열
+ * @return bool 유효 여부
+ */
+ public static function isValid(string $value): bool
+ {
+ return in_array($value, self::values(), true);
+ }
+}
diff --git a/app/Enums/TextDirection.php b/app/Enums/TextDirection.php
new file mode 100644
index 00000000..9b114205
--- /dev/null
+++ b/app/Enums/TextDirection.php
@@ -0,0 +1,53 @@
+value);
+ }
+
+ /**
+ * 모든 방향 값을 문자열 배열로 반환합니다.
+ *
+ * @return array 방향 문자열 배열
+ */
+ public static function values(): array
+ {
+ return array_column(self::cases(), 'value');
+ }
+
+ /**
+ * 주어진 문자열이 유효한 방향 값인지 확인합니다.
+ *
+ * @param string $value 검사할 방향 문자열
+ * @return bool 유효 여부
+ */
+ public static function isValid(string $value): bool
+ {
+ return in_array($value, self::values(), true);
+ }
+}
diff --git a/app/Enums/UserStatus.php b/app/Enums/UserStatus.php
index 13870a1f..a99e21ea 100644
--- a/app/Enums/UserStatus.php
+++ b/app/Enums/UserStatus.php
@@ -29,6 +29,11 @@ enum UserStatus: string
*/
case Withdrawn = 'withdrawn';
+ /**
+ * 본인인증 대기 (Mode C — 가입 직후 비활성, IDV 통과 후 활성화)
+ */
+ case PendingVerification = 'pending_verification';
+
/**
* 모든 상태 값을 문자열 배열로 반환합니다.
*
@@ -82,6 +87,7 @@ enum UserStatus: string
self::Inactive => 'secondary',
self::Blocked => 'danger',
self::Withdrawn => 'warning',
+ self::PendingVerification => 'info',
};
}
}
diff --git a/app/Exceptions/Auth/AccountLockedException.php b/app/Exceptions/Auth/AccountLockedException.php
new file mode 100644
index 00000000..71ba0fec
--- /dev/null
+++ b/app/Exceptions/Auth/AccountLockedException.php
@@ -0,0 +1,34 @@
+ max(1, $remainingMinutes * 60)]
+ );
+ }
+}
diff --git a/app/Exceptions/CannotDeleteAdminException.php b/app/Exceptions/CannotDeleteAdminException.php
deleted file mode 100644
index 45d05387..00000000
--- a/app/Exceptions/CannotDeleteAdminException.php
+++ /dev/null
@@ -1,22 +0,0 @@
- $params 메시지 파라미터
+ * @param Throwable|null $previous 원인 예외
+ */
+ public function __construct(
+ public readonly string $errorKey,
+ public readonly array $params = [],
+ ?Throwable $previous = null,
+ ) {
+ parent::__construct(__($errorKey, $params), 0, $previous);
+ }
+}
diff --git a/app/Exceptions/CoreVersionMismatchException.php b/app/Exceptions/CoreVersionMismatchException.php
new file mode 100644
index 00000000..32c66223
--- /dev/null
+++ b/app/Exceptions/CoreVersionMismatchException.php
@@ -0,0 +1,64 @@
+ $identifier,
+ 'type' => __('extensions.types.'.$extensionType),
+ 'required' => $requiredCoreVersion,
+ 'installed' => $currentCoreVersion,
+ ]));
+ }
+
+ /**
+ * 응답 payload 빌더.
+ *
+ * @return array{extension_type: string, identifier: string, required_core_version: string, current_core_version: string, guide_url: string}
+ */
+ public function getPayload(): array
+ {
+ return [
+ 'extension_type' => $this->extensionType,
+ 'identifier' => $this->identifier,
+ 'required_core_version' => $this->requiredCoreVersion,
+ 'current_core_version' => $this->currentCoreVersion,
+ 'guide_url' => '/admin/core/update',
+ ];
+ }
+}
diff --git a/app/Exceptions/IdentityVerificationRequiredException.php b/app/Exceptions/IdentityVerificationRequiredException.php
new file mode 100644
index 00000000..2523d3ac
--- /dev/null
+++ b/app/Exceptions/IdentityVerificationRequiredException.php
@@ -0,0 +1,59 @@
+ $this->policyKey,
+ 'purpose' => $this->purpose,
+ 'provider_id' => $this->providerId,
+ 'render_hint' => $this->renderHint,
+ 'challenge_start_url' => '/api/identity/challenges',
+ 'return_request' => $this->returnRequest,
+ ];
+ }
+}
diff --git a/app/Exceptions/LanguagePackOperationException.php b/app/Exceptions/LanguagePackOperationException.php
new file mode 100644
index 00000000..3adbf4db
--- /dev/null
+++ b/app/Exceptions/LanguagePackOperationException.php
@@ -0,0 +1,29 @@
+ $params 메시지 파라미터
+ * @param Throwable|null $previous 원인 예외
+ */
+ public function __construct(
+ public readonly string $errorKey,
+ public readonly array $params = [],
+ ?Throwable $previous = null,
+ ) {
+ parent::__construct(__($errorKey, $params), 0, $previous);
+ }
+}
diff --git a/app/Exceptions/LanguagePackSlotConflictException.php b/app/Exceptions/LanguagePackSlotConflictException.php
new file mode 100644
index 00000000..223a5bc7
--- /dev/null
+++ b/app/Exceptions/LanguagePackSlotConflictException.php
@@ -0,0 +1,24 @@
+ $current->identifier,
+ 'target' => $target->identifier,
+ ]));
+ }
+}
diff --git a/app/Exceptions/ModuleOperationException.php b/app/Exceptions/ModuleOperationException.php
new file mode 100644
index 00000000..44c48373
--- /dev/null
+++ b/app/Exceptions/ModuleOperationException.php
@@ -0,0 +1,27 @@
+ $params 메시지 파라미터
+ * @param Throwable|null $previous 원인 예외
+ */
+ public function __construct(
+ public readonly string $errorKey,
+ public readonly array $params = [],
+ ?Throwable $previous = null,
+ ) {
+ parent::__construct(__($errorKey, $params), 0, $previous);
+ }
+}
diff --git a/app/Exceptions/PluginOperationException.php b/app/Exceptions/PluginOperationException.php
new file mode 100644
index 00000000..60451d77
--- /dev/null
+++ b/app/Exceptions/PluginOperationException.php
@@ -0,0 +1,27 @@
+ $params 메시지 파라미터
+ * @param Throwable|null $previous 원인 예외
+ */
+ public function __construct(
+ public readonly string $errorKey,
+ public readonly array $params = [],
+ ?Throwable $previous = null,
+ ) {
+ parent::__construct(__($errorKey, $params), 0, $previous);
+ }
+}
diff --git a/app/Exceptions/TemplateOperationException.php b/app/Exceptions/TemplateOperationException.php
new file mode 100644
index 00000000..7cfde9c7
--- /dev/null
+++ b/app/Exceptions/TemplateOperationException.php
@@ -0,0 +1,29 @@
+ $params 메시지 파라미터
+ * @param Throwable|null $previous 원인 예외
+ */
+ public function __construct(
+ public readonly string $errorKey,
+ public readonly array $params = [],
+ ?Throwable $previous = null,
+ ) {
+ parent::__construct(__($errorKey, $params), 0, $previous);
+ }
+}
diff --git a/app/Extension/AbstractModule.php b/app/Extension/AbstractModule.php
index 1cf3176c..e7051f40 100644
--- a/app/Extension/AbstractModule.php
+++ b/app/Extension/AbstractModule.php
@@ -153,6 +153,8 @@ abstract class AbstractModule implements ModuleInterface
* 모듈 설치
*
* 모듈 개발자가 설치 시 추가 작업이 필요한 경우 오버라이드
+ *
+ * @return bool 성공 여부
*/
public function install(): bool
{
@@ -163,6 +165,8 @@ abstract class AbstractModule implements ModuleInterface
* 모듈 제거
*
* 모듈 개발자가 제거 시 추가 작업이 필요한 경우 오버라이드
+ *
+ * @return bool 성공 여부
*/
public function uninstall(): bool
{
@@ -188,6 +192,8 @@ abstract class AbstractModule implements ModuleInterface
* 모듈 활성화
*
* 모듈 개발자가 활성화 시 추가 작업이 필요한 경우 오버라이드
+ *
+ * @return bool 성공 여부
*/
public function activate(): bool
{
@@ -198,6 +204,8 @@ abstract class AbstractModule implements ModuleInterface
* 모듈 비활성화
*
* 모듈 개발자가 비활성화 시 추가 작업이 필요한 경우 오버라이드
+ *
+ * @return bool 성공 여부
*/
public function deactivate(): bool
{
@@ -275,6 +283,8 @@ abstract class AbstractModule implements ModuleInterface
*
* 기본적으로 src/routes/api.php, src/routes/web.php를 반환
* 파일이 존재하는 경우에만 포함
+ *
+ * @return array 라우트 키 => 파일 경로 매핑
*/
public function getRoutes(): array
{
@@ -300,6 +310,8 @@ abstract class AbstractModule implements ModuleInterface
*
* 기본적으로 database/migrations 디렉토리를 반환
* 디렉토리가 존재하는 경우에만 포함
+ *
+ * @return array 마이그레이션 디렉토리 경로 배열
*/
public function getMigrations(): array
{
@@ -317,6 +329,8 @@ abstract class AbstractModule implements ModuleInterface
*
* 기본적으로 빈 배열 반환
* 모듈 개발자가 뷰가 필요한 경우 오버라이드
+ *
+ * @return array 뷰 디렉토리 경로 배열
*/
public function getViews(): array
{
@@ -428,6 +442,8 @@ abstract class AbstractModule implements ModuleInterface
*
* 기본적으로 빈 배열 반환
* 모듈 개발자가 설정이 필요한 경우 오버라이드
+ *
+ * @return array 모듈 설정 배열
*/
public function getConfig(): array
{
@@ -439,6 +455,8 @@ abstract class AbstractModule implements ModuleInterface
*
* 기본적으로 빈 배열 반환
* 모듈 개발자가 관리자 메뉴가 필요한 경우 오버라이드
+ *
+ * @return array 관리자 메뉴 정의 배열
*/
public function getAdminMenus(): array
{
@@ -475,6 +493,8 @@ abstract class AbstractModule implements ModuleInterface
*
* 기본적으로 빈 배열 반환
* 모듈 개발자가 훅 리스너가 필요한 경우 오버라이드
+ *
+ * @return array 훅 리스너 정의 배열
*/
public function getHookListeners(): array
{
@@ -530,6 +550,173 @@ abstract class AbstractModule implements ModuleInterface
return [];
}
+ /**
+ * Declarative i18n getter family — 모듈이 선언하는 다국어/SSoT 데이터 4종.
+ *
+ * 모두 default `[]` 반환 (override 미선택 시 무영향). 각 메서드 결과는 `ModuleManager` 가
+ * activate/update 시 자동 동기화하며, lang pack 활성 시 다국어 키 보강 필터를 통해 ja/en 등 추가
+ * 로케일이 자동 주입됩니다 (audit 룰 `seeder-translation-filter` 가 hook 호출 발화 보장).
+ *
+ * | 메서드 | 도메인 | 동기화 위치 | lang pack 필터 |
+ * |--------------------------------|---------------|---------------------------------|--------------------------------------------------|
+ * | `getNotificationDefinitions()` | 알림 정의 | `notification_definitions` | `seed.{id}.notifications.translations` |
+ * | `getIdentityMessages()` | IDV 메일 | `identity_message_definitions` | `seed.{id}.identity_messages.translations` |
+ * | `getIdentityPolicies()` | IDV 정책 | `identity_policies` | (lang pack seed 대상 외 — 다국어 필드 부재) |
+ * | `getIdentityPurposes()` | IDV 목적 | 메모리 레지스트리 (DB 없음) | (lang pack seed 대상 외 — `label_key` 참조) |
+ *
+ * 신규 i18n SSoT 도메인 추가 시 동일 패턴(meta 4-요소: declarative getter + Manager sync +
+ * applyFilters + Injector 메서드) 을 따라야 하며, audit 룰 `core-config-lang-pack-seed-coverage`
+ * 와 `module-getter-lang-pack-coverage` 가 정합성을 자동 검증합니다.
+ *
+ * @see getIdentityPolicies()
+ * @see getIdentityPurposes()
+ * @see getIdentityMessages()
+ * @see getNotificationDefinitions()
+ */
+
+ /**
+ * 이 모듈이 등록할 IDV(본인인증) 정책 선언을 반환합니다.
+ *
+ * 반환된 정책은 `ModuleManager` 가 activate/update 시
+ * `IdentityPolicySyncHelper::syncPolicy()` 로 DB(identity_policies) 에 동기화하며,
+ * deactivate/uninstall 시 `cleanupStalePolicies()` 로 정리합니다.
+ *
+ * `source_type` / `source_identifier` 는 Manager 가 자동 주입하므로 반환 배열에 포함하지 않습니다.
+ * 운영자가 관리자 UI 에서 수정한 필드(`enabled` / `grace_minutes` / `provider_id` / `fail_mode`)
+ * 는 `user_overrides` JSON 으로 보존됩니다.
+ *
+ * @return array
+ * }>
+ */
+ public function getIdentityPolicies(): array
+ {
+ return [];
+ }
+
+ /**
+ * 이 모듈이 등록할 IDV(본인인증) 목적(purpose) 선언을 반환합니다.
+ *
+ * DB 에 저장되지 않는 **코드 계약** 입니다. 활성화된 모듈의 getter 결과를
+ * `IdentityVerificationManager` 가 부팅 시 런타임 레지스트리에 병합하며,
+ * `core.identity.purposes` filter 훅으로도 서드파티 동적 등록을 수용합니다.
+ *
+ * 새 purpose 는 이를 지원하는 Provider 와 challenge 로직이 함께 제공되어야 동작합니다.
+ * Provider 없이 purpose 만 선언하면 관리자 UI 에는 노출되나 실제 challenge 는 실패합니다.
+ *
+ * @return array
+ */
+ public function getIdentityPurposes(): array
+ {
+ return [];
+ }
+
+ /**
+ * 이 모듈이 등록할 IDV(본인인증) 메시지 정의/템플릿 선언을 반환합니다.
+ *
+ * `getIdentityPolicies()` / `getIdentityPurposes()` 와 동일한 패턴으로
+ * `ModuleManager` 가 activate/update 시 `IdentityMessageSyncHelper` 를 통해
+ * `identity_message_definitions` / `identity_message_templates` 테이블에 동기화하며,
+ * uninstall(deleteData=true) 시 자동 정리됩니다.
+ *
+ * 운영자가 관리자 UI 에서 수정한 필드(name/description/subject/body 등) 는
+ * `user_overrides` JSON 으로 보존됩니다.
+ *
+ * `extension_type='module'`, `extension_identifier=$this->getIdentifier()` 는
+ * Manager 가 자동 주입하므로 반환 배열에 포함하지 않습니다.
+ *
+ * 반환 형식 예:
+ * ```php
+ * return [
+ * [
+ * 'provider_id' => 'g7:core.mail',
+ * 'scope_type' => IdentityMessageDefinition::SCOPE_PURPOSE,
+ * 'scope_value' => 'checkout_verification',
+ * 'name' => ['ko' => '결제 시 본인 확인', 'en' => 'Checkout Verification'],
+ * 'description' => ['ko' => '...', 'en' => '...'],
+ * 'channels' => ['mail'],
+ * 'variables' => [['key' => 'code', 'description' => '인증 코드']],
+ * 'templates' => [
+ * [
+ * 'channel' => 'mail',
+ * 'subject' => ['ko' => '...', 'en' => '...'],
+ * 'body' => ['ko' => '...', 'en' => '...'],
+ * ],
+ * ],
+ * ],
+ * ];
+ * ```
+ *
+ * scope_type 권장:
+ * - `IdentityMessageDefinition::SCOPE_PURPOSE` — purpose 단위 메시지 (해당 purpose 트리거 시 우선)
+ * - `IdentityMessageDefinition::SCOPE_POLICY` — 특정 policy_key 전용 메시지 (가장 구체적, purpose 보다 우선)
+ *
+ * @return array>
+ */
+ public function getIdentityMessages(): array
+ {
+ return [];
+ }
+
+ /**
+ * 이 모듈이 등록할 알림 정의/템플릿 선언을 반환합니다.
+ *
+ * `getIdentityMessages()` 와 동일한 패턴으로 `ModuleManager` 가 activate/update 시
+ * `NotificationSyncHelper::syncDefinition()` + `syncTemplate()` 으로 upsert 하고,
+ * 현재 선언에 없는 기존 정의는 `cleanupStaleDefinitions()` 로 정리합니다.
+ * uninstall(deleteData=true) 시에도 자동 정리됩니다.
+ *
+ * 운영자가 관리자 UI 에서 수정한 필드(name/description/subject/body/recipients 등) 는
+ * `user_overrides` JSON 으로 보존됩니다.
+ *
+ * `extension_type='module'`, `extension_identifier=$this->getIdentifier()` 는
+ * Manager 가 자동 주입하므로 반환 배열에 포함하지 않습니다 (포함되어 있으면 덮어씀).
+ *
+ * 반환 형식 예:
+ * ```php
+ * return [
+ * [
+ * 'type' => 'order_confirmed',
+ * 'hook_prefix' => 'sirsoft-ecommerce',
+ * 'name' => ['ko' => '주문 확인', 'en' => 'Order Confirmed'],
+ * 'description' => ['ko' => '...', 'en' => '...'],
+ * 'channels' => ['mail', 'database'],
+ * 'hooks' => ['sirsoft-ecommerce.order.after_confirm'],
+ * 'variables' => [['key' => 'order_number', 'description' => '주문번호']],
+ * 'templates' => [
+ * [
+ * 'channel' => 'mail',
+ * 'recipients' => [['type' => 'trigger_user']],
+ * 'subject' => ['ko' => '...', 'en' => '...'],
+ * 'body' => ['ko' => '...', 'en' => '...'],
+ * ],
+ * ],
+ * ],
+ * ];
+ * ```
+ *
+ * @return array>
+ */
+ public function getNotificationDefinitions(): array
+ {
+ return [];
+ }
+
/**
* 모듈 설치 시 실행할 시더 클래스 목록 반환
*
@@ -649,11 +836,27 @@ abstract class AbstractModule implements ModuleInterface
return $this->loadManifest()['license'] ?? null;
}
+ /**
+ * 관리자 UI 에서 숨김 여부 반환
+ *
+ * module.json 의 hidden 필드가 true 면 관리자 모듈 목록(/api/admin/modules) 에서 기본 제외됩니다.
+ * artisan CLI, 설치/제거, 업데이트 감지는 영향을 받지 않습니다.
+ * 학습용 샘플 모듈, 내부 운영용 모듈 등에 사용합니다.
+ *
+ * @return bool 숨김 여부 (기본값: false)
+ */
+ public function isHidden(): bool
+ {
+ return (bool) ($this->loadManifest()['hidden'] ?? false);
+ }
+
/**
* 모듈 메타데이터 반환
*
* 기본적으로 빈 배열 반환
* 모듈 개발자가 메타데이터가 필요한 경우 오버라이드
+ *
+ * @return array 메타데이터 배열
*/
public function getMetadata(): array
{
@@ -751,6 +954,53 @@ abstract class AbstractModule implements ModuleInterface
return [];
}
+ /**
+ * 페이지 타입별 OG 메타태그 기본값 선언
+ *
+ * 모듈이 자기 도메인 데이터로부터 og:image, og:image:width/height,
+ * og:type, og:product:price 같은 도메인별 OG 태그를 직접 만들어 제공합니다.
+ * 레이아웃 meta.seo.og 가 같은 키를 선언하면 그쪽이 우선 (override).
+ *
+ * @param string $pageType 레이아웃 meta.seo.page_type (예: 'product', 'category', 'post')
+ * @param array $context DataSourceResolver 결과 + _seo 주입된 컨텍스트
+ * @param array $routeParams URL 라우트 파라미터
+ * @return array OG 데이터 (type, image, image_width, image_height, image_secure_url,
+ * image_type, image_alt, site_name, locale, extra)
+ */
+ public function seoOgDefaults(string $pageType, array $context, array $routeParams = []): array
+ {
+ return [];
+ }
+
+ /**
+ * 페이지 타입별 Twitter 카드 기본값 선언
+ *
+ * @param string $pageType 페이지 타입
+ * @param array $context 컨텍스트
+ * @param array $routeParams 라우트 파라미터
+ * @return array Twitter 카드 데이터 (card, site, creator, title, description, image, image_alt, extra)
+ */
+ public function seoTwitterDefaults(string $pageType, array $context, array $routeParams = []): array
+ {
+ return [];
+ }
+
+ /**
+ * 페이지 타입별 JSON-LD 구조화 데이터 선언
+ *
+ * 모듈이 자기 도메인 스키마(Product/Article/Event 등 Schema.org 타입)를
+ * 직접 owned. 레이아웃 meta.seo.structured_data 가 비어있을 때 적용.
+ *
+ * @param string $pageType 페이지 타입
+ * @param array $context 컨텍스트
+ * @param array $routeParams 라우트 파라미터
+ * @return array Schema.org 형식 (@type 필수). 빈 배열 반환 시 미적용.
+ */
+ public function seoStructuredData(string $pageType, array $context, array $routeParams = []): array
+ {
+ return [];
+ }
+
/**
* 그누보드7 코어 요구 버전 제약 반환
*
diff --git a/app/Extension/AbstractPlugin.php b/app/Extension/AbstractPlugin.php
index e6541a9b..297a7522 100644
--- a/app/Extension/AbstractPlugin.php
+++ b/app/Extension/AbstractPlugin.php
@@ -155,6 +155,8 @@ abstract class AbstractPlugin implements PluginInterface
* 플러그인 설치
*
* 플러그인 개발자가 설치 시 추가 작업이 필요한 경우 오버라이드
+ *
+ * @return bool 성공 여부
*/
public function install(): bool
{
@@ -165,6 +167,8 @@ abstract class AbstractPlugin implements PluginInterface
* 플러그인 제거
*
* 플러그인 개발자가 제거 시 추가 작업이 필요한 경우 오버라이드
+ *
+ * @return bool 성공 여부
*/
public function uninstall(): bool
{
@@ -190,6 +194,8 @@ abstract class AbstractPlugin implements PluginInterface
* 플러그인 활성화
*
* 플러그인 개발자가 활성화 시 추가 작업이 필요한 경우 오버라이드
+ *
+ * @return bool 성공 여부
*/
public function activate(): bool
{
@@ -200,6 +206,8 @@ abstract class AbstractPlugin implements PluginInterface
* 플러그인 비활성화
*
* 플러그인 개발자가 비활성화 시 추가 작업이 필요한 경우 오버라이드
+ *
+ * @return bool 성공 여부
*/
public function deactivate(): bool
{
@@ -277,6 +285,8 @@ abstract class AbstractPlugin implements PluginInterface
*
* 기본적으로 src/routes/api.php, src/routes/web.php를 반환
* 파일이 존재하는 경우에만 포함
+ *
+ * @return array 라우트 키 => 파일 경로 매핑
*/
public function getRoutes(): array
{
@@ -302,6 +312,8 @@ abstract class AbstractPlugin implements PluginInterface
*
* 기본적으로 database/migrations 디렉토리를 반환
* 디렉토리가 존재하는 경우에만 포함
+ *
+ * @return array 마이그레이션 디렉토리 경로 배열
*/
public function getMigrations(): array
{
@@ -319,6 +331,8 @@ abstract class AbstractPlugin implements PluginInterface
*
* 기본적으로 빈 배열 반환
* 플러그인 개발자가 뷰가 필요한 경우 오버라이드
+ *
+ * @return array 뷰 디렉토리 경로 배열
*/
public function getViews(): array
{
@@ -423,6 +437,8 @@ abstract class AbstractPlugin implements PluginInterface
*
* 기본적으로 빈 배열 반환
* 플러그인 개발자가 훅이 필요한 경우 오버라이드
+ *
+ * @return array 훅 정의 배열
*/
public function getHooks(): array
{
@@ -434,6 +450,8 @@ abstract class AbstractPlugin implements PluginInterface
*
* 기본적으로 빈 배열 반환
* 플러그인 개발자가 훅 리스너가 필요한 경우 오버라이드
+ *
+ * @return array 훅 리스너 정의 배열
*/
public function getHookListeners(): array
{
@@ -489,6 +507,125 @@ abstract class AbstractPlugin implements PluginInterface
return [];
}
+ /**
+ * 이 플러그인이 등록할 IDV(본인인증) 정책 선언을 반환합니다.
+ *
+ * 반환된 정책은 `PluginManager` 가 activate/update 시
+ * `IdentityPolicySyncHelper::syncPolicy()` 로 DB(identity_policies) 에 동기화하며,
+ * deactivate/uninstall 시 `cleanupStalePolicies()` 로 정리합니다.
+ *
+ * `source_type` / `source_identifier` 는 Manager 가 자동 주입하므로 반환 배열에 포함하지 않습니다.
+ * 운영자가 관리자 UI 에서 수정한 필드(`enabled` / `grace_minutes` / `provider_id` / `fail_mode`)
+ * 는 `user_overrides` JSON 으로 보존됩니다.
+ *
+ * @return array
+ * }>
+ */
+ public function getIdentityPolicies(): array
+ {
+ return [];
+ }
+
+ /**
+ * 이 플러그인이 등록할 IDV(본인인증) 목적(purpose) 선언을 반환합니다.
+ *
+ * DB 에 저장되지 않는 **코드 계약** 입니다. 활성화된 플러그인의 getter 결과를
+ * `IdentityVerificationManager` 가 부팅 시 런타임 레지스트리에 병합하며,
+ * `core.identity.purposes` filter 훅으로도 서드파티 동적 등록을 수용합니다.
+ *
+ * 새 purpose 는 이를 지원하는 Provider 와 challenge 로직이 함께 제공되어야 동작합니다.
+ * Provider 없이 purpose 만 선언하면 관리자 UI 에는 노출되나 실제 challenge 는 실패합니다.
+ *
+ * @return array
+ */
+ public function getIdentityPurposes(): array
+ {
+ return [];
+ }
+
+ /**
+ * 이 플러그인이 등록할 IDV(본인인증) 메시지 정의/템플릿 선언을 반환합니다.
+ *
+ * `getIdentityPolicies()` / `getIdentityPurposes()` 와 동일한 패턴으로
+ * `PluginManager` 가 activate/update 시 `IdentityMessageSyncHelper` 를 통해
+ * `identity_message_definitions` / `identity_message_templates` 테이블에 동기화하며,
+ * uninstall(deleteData=true) 시 자동 정리됩니다.
+ *
+ * 운영자가 관리자 UI 에서 수정한 필드(name/description/subject/body 등) 는
+ * `user_overrides` JSON 으로 보존됩니다.
+ *
+ * `extension_type='plugin'`, `extension_identifier=$this->getIdentifier()` 는
+ * Manager 가 자동 주입하므로 반환 배열에 포함하지 않습니다.
+ *
+ * 반환 형식: AbstractModule::getIdentityMessages() 와 동일.
+ *
+ * @return array>
+ */
+ public function getIdentityMessages(): array
+ {
+ return [];
+ }
+
+ /**
+ * 이 플러그인이 등록할 알림 정의/템플릿 선언을 반환합니다.
+ *
+ * `getIdentityMessages()` 와 동일한 패턴으로 `PluginManager` 가 activate/update 시
+ * `NotificationSyncHelper::syncDefinition()` + `syncTemplate()` 으로 upsert 하고,
+ * 현재 선언에 없는 기존 정의는 `cleanupStaleDefinitions()` 로 정리합니다.
+ * uninstall(deleteData=true) 시에도 자동 정리됩니다.
+ *
+ * 운영자가 관리자 UI 에서 수정한 필드(name/description/subject/body/recipients 등) 는
+ * `user_overrides` JSON 으로 보존됩니다.
+ *
+ * `extension_type='plugin'`, `extension_identifier=$this->getIdentifier()` 는
+ * Manager 가 자동 주입하므로 반환 배열에 포함하지 않습니다 (포함되어 있으면 덮어씀).
+ *
+ * 반환 형식 예:
+ * ```php
+ * return [
+ * [
+ * 'type' => 'plugin_event',
+ * 'hook_prefix' => 'sirsoft-payment',
+ * 'name' => ['ko' => '결제 알림', 'en' => 'Payment'],
+ * 'description' => ['ko' => '...', 'en' => '...'],
+ * 'channels' => ['mail', 'database'],
+ * 'hooks' => ['sirsoft-payment.after_charge'],
+ * 'variables' => [['key' => 'amount', 'description' => '결제 금액']],
+ * 'templates' => [
+ * [
+ * 'channel' => 'mail',
+ * 'recipients' => [['type' => 'trigger_user']],
+ * 'subject' => ['ko' => '...', 'en' => '...'],
+ * 'body' => ['ko' => '...', 'en' => '...'],
+ * ],
+ * ],
+ * ],
+ * ];
+ * ```
+ *
+ * @return array>
+ */
+ public function getNotificationDefinitions(): array
+ {
+ return [];
+ }
+
/**
* 플러그인 설치 시 실행할 시더 클래스 목록 반환
*
@@ -544,11 +681,27 @@ abstract class AbstractPlugin implements PluginInterface
return $this->loadManifest()['license'] ?? null;
}
+ /**
+ * 관리자 UI 에서 숨김 여부 반환
+ *
+ * plugin.json 의 hidden 필드가 true 면 관리자 플러그인 목록(/api/admin/plugins) 에서 기본 제외됩니다.
+ * artisan CLI, 설치/제거, 업데이트 감지는 영향을 받지 않습니다.
+ * 학습용 샘플 플러그인, 내부 운영용 플러그인 등에 사용합니다.
+ *
+ * @return bool 숨김 여부 (기본값: false)
+ */
+ public function isHidden(): bool
+ {
+ return (bool) ($this->loadManifest()['hidden'] ?? false);
+ }
+
/**
* 플러그인 메타데이터 반환
*
* 기본적으로 빈 배열 반환
* 플러그인 개발자가 메타데이터가 필요한 경우 오버라이드
+ *
+ * @return array 메타데이터 배열
*/
public function getMetadata(): array
{
@@ -646,6 +799,52 @@ abstract class AbstractPlugin implements PluginInterface
return [];
}
+ /**
+ * 페이지 타입별 OG 메타태그 기본값 선언
+ *
+ * 플러그인이 자기 도메인 데이터로부터 og:image, og:type 등 도메인별 OG 태그를
+ * 직접 만들어 제공합니다. 레이아웃 meta.seo.og 가 같은 키를 선언하면 그쪽이 우선.
+ *
+ * @param string $pageType 레이아웃 meta.seo.page_type
+ * @param array $context 컨텍스트 (DataSourceResolver 결과 + _seo)
+ * @param array $routeParams 라우트 파라미터
+ * @return array OG 데이터 (type, image, image_width, image_height, image_secure_url,
+ * image_type, image_alt, site_name, locale, extra)
+ */
+ public function seoOgDefaults(string $pageType, array $context, array $routeParams = []): array
+ {
+ return [];
+ }
+
+ /**
+ * 페이지 타입별 Twitter 카드 기본값 선언
+ *
+ * @param string $pageType 페이지 타입
+ * @param array $context 컨텍스트
+ * @param array $routeParams 라우트 파라미터
+ * @return array Twitter 카드 데이터 (card, site, creator, title, description, image, image_alt, extra)
+ */
+ public function seoTwitterDefaults(string $pageType, array $context, array $routeParams = []): array
+ {
+ return [];
+ }
+
+ /**
+ * 페이지 타입별 JSON-LD 구조화 데이터 선언
+ *
+ * 플러그인이 자기 도메인 스키마(Schema.org @type) 를 직접 owned.
+ * 레이아웃 meta.seo.structured_data 가 비어있을 때 적용.
+ *
+ * @param string $pageType 페이지 타입
+ * @param array $context 컨텍스트
+ * @param array $routeParams 라우트 파라미터
+ * @return array Schema.org 형식 (@type 필수). 빈 배열 반환 시 미적용.
+ */
+ public function seoStructuredData(string $pageType, array $context, array $routeParams = []): array
+ {
+ return [];
+ }
+
/**
* 그누보드7 코어 요구 버전 제약 반환
*
diff --git a/app/Extension/Concerns/ResolvesExtensionSharedRecords.php b/app/Extension/Concerns/ResolvesExtensionSharedRecords.php
new file mode 100644
index 00000000..bcfbc7f2
--- /dev/null
+++ b/app/Extension/Concerns/ResolvesExtensionSharedRecords.php
@@ -0,0 +1,105 @@
+
+ */
+ protected function sharedRecordResolvers(): array
+ {
+ return [
+ ['permissions', fn (string $type, string $id): int => app(PermissionRepositoryInterface::class)
+ ->getByExtension(ExtensionOwnerType::from($type), $id)
+ ->count(),
+ ],
+ ['menus', fn (string $type, string $id): int => app(MenuRepositoryInterface::class)
+ ->getMenusByExtension(ExtensionOwnerType::from($type), $id)
+ ->count(),
+ ],
+ ['notification_definitions', fn (string $type, string $id): int => app(NotificationDefinitionRepositoryInterface::class)
+ ->getByExtension($type, $id)
+ ->count(),
+ ],
+ ['identity_policies', fn (string $type, string $id): int => app(IdentityPolicyRepositoryInterface::class)
+ ->countBySource($type, $id),
+ ],
+ ['identity_message_definitions', fn (string $type, string $id): int => app(IdentityMessageDefinitionRepositoryInterface::class)
+ ->getByExtension($type, $id)
+ ->count(),
+ ],
+ ];
+ }
+
+ /**
+ * 코어 공유 테이블에 적재된 확장 영역 레코드 정보를 조회합니다.
+ *
+ * 0건인 항목은 결과에서 제외 (모달에서 빈 항목 노이즈 회피).
+ * Repository/Schema 예외는 경고 로그만 남기고 skip — uninstall 모달이 부분적으로라도
+ * 동작하도록 보장 (예: 마이그레이션 미실행 환경).
+ *
+ * @param string $extensionType 'module' 또는 'plugin'
+ * @param string $extensionIdentifier 확장 식별자
+ * @return array
+ */
+ protected function resolveExtensionSharedRecords(string $extensionType, string $extensionIdentifier): array
+ {
+ $records = [];
+
+ foreach ($this->sharedRecordResolvers() as [$labelKey, $resolver]) {
+ try {
+ $count = (int) $resolver($extensionType, $extensionIdentifier);
+ } catch (\Throwable $e) {
+ Log::warning('확장 공유 레코드 조회 실패', [
+ 'label' => $labelKey,
+ 'extension' => "{$extensionType}/{$extensionIdentifier}",
+ 'error' => $e->getMessage(),
+ ]);
+
+ continue;
+ }
+ if ($count === 0) {
+ continue;
+ }
+ $records[] = [
+ 'table' => $labelKey,
+ 'label_key' => $labelKey,
+ 'count' => $count,
+ ];
+ }
+
+ return $records;
+ }
+}
diff --git a/app/Extension/CoreVersionChecker.php b/app/Extension/CoreVersionChecker.php
index 1e8ca316..e7b679d1 100644
--- a/app/Extension/CoreVersionChecker.php
+++ b/app/Extension/CoreVersionChecker.php
@@ -3,6 +3,7 @@
namespace App\Extension;
use App\Contracts\Extension\CacheInterface;
+use App\Exceptions\CoreVersionMismatchException;
use App\Extension\Cache\CoreCacheDriver;
use Composer\Semver\Semver;
use Exception;
@@ -49,6 +50,8 @@ class CoreVersionChecker
*
* 규정 예외: "env() 는 config 파일에서만 사용" 규칙의 본문 예외. 정당성은 버전 판정이
* config cache 우회를 요구하기 때문이다.
+ *
+ * @return string 코어 버전 문자열 (예: "7.0.0-beta.4")
*/
public static function getCoreVersion(): string
{
@@ -100,12 +103,12 @@ class CoreVersionChecker
}
if (! self::satisfies($requiredVersion)) {
- throw new Exception(__('extensions.errors.core_version_mismatch', [
- 'extension' => $identifier,
- 'type' => __('extensions.types.'.$type),
- 'required' => $requiredVersion,
- 'installed' => self::getCoreVersion(),
- ]));
+ throw new CoreVersionMismatchException(
+ $type,
+ $identifier,
+ $requiredVersion,
+ self::getCoreVersion(),
+ );
}
return true;
diff --git a/app/Extension/ExtensionManager.php b/app/Extension/ExtensionManager.php
index 8ef6958f..0311b3d6 100644
--- a/app/Extension/ExtensionManager.php
+++ b/app/Extension/ExtensionManager.php
@@ -540,6 +540,91 @@ PHP;
return implode('\\', $namespace);
}
+ /**
+ * FQCN 으로부터 등록된 확장(모듈/플러그인) 식별자를 추론합니다.
+ *
+ * `directoryToNamespace()` 의 역변환. PSR-4 prefix `Modules\` / `Plugins\` 의
+ * Vendor\Name 두 세그먼트를 kebab-case 식별자로 환원합니다.
+ *
+ * 예시:
+ * - 'Modules\Sirsoft\Ecommerce\Models\Order' → 'sirsoft-ecommerce'
+ * - 'Plugins\Sirsoft\Payment\Services\PaymentService' → 'sirsoft-payment'
+ * - 'Modules\Sirsoft\DaumPostcode\Models\Address' → 'sirsoft-daum_postcode'
+ * - 'App\Models\User' → null (코어)
+ *
+ * 등록 여부는 검증하지 않습니다 — 호출 측이 lang 파일 존재 여부로 fallback 처리합니다.
+ *
+ * @param string $fqcn 클래스 FQCN
+ * @return string|null 모듈/플러그인 identifier, 코어/미해석 시 null
+ */
+ public static function resolveExtensionByFqcn(string $fqcn): ?string
+ {
+ static $cache = [];
+
+ $key = ltrim($fqcn, '\\');
+ if (array_key_exists($key, $cache)) {
+ return $cache[$key];
+ }
+
+ $cache[$key] = self::doResolveExtensionByFqcn($key);
+
+ return $cache[$key];
+ }
+
+ /**
+ * resolveExtensionByFqcn 의 캐시되지 않은 본 구현.
+ *
+ * @param string $fqcn ltrim 된 FQCN
+ * @return string|null 식별자 또는 null
+ */
+ protected static function doResolveExtensionByFqcn(string $fqcn): ?string
+ {
+ if ($fqcn === '') {
+ return null;
+ }
+
+ if (str_starts_with($fqcn, 'Modules\\')) {
+ return self::namespaceTailToIdentifier(substr($fqcn, strlen('Modules\\')));
+ }
+
+ if (str_starts_with($fqcn, 'Plugins\\')) {
+ return self::namespaceTailToIdentifier(substr($fqcn, strlen('Plugins\\')));
+ }
+
+ return null;
+ }
+
+ /**
+ * `Vendor\Name\...` 꼬리에서 `vendor-name` 식별자를 추출합니다.
+ *
+ * @param string $tail 접두 (Modules\ / Plugins\) 제거 후의 FQCN 꼬리
+ * @return string|null 식별자 또는 null
+ */
+ protected static function namespaceTailToIdentifier(string $tail): ?string
+ {
+ $parts = explode('\\', $tail);
+ if (count($parts) < 2 || $parts[0] === '' || $parts[1] === '') {
+ return null;
+ }
+
+ return self::pascalToKebabSegment($parts[0]).'-'.self::pascalToKebabSegment($parts[1]);
+ }
+
+ /**
+ * 단일 PascalCase 세그먼트를 snake_case (단어 경계 `_` 사용) 로 변환합니다.
+ *
+ * `directoryToNamespace()` 의 역연산:
+ * - 'Ecommerce' → 'ecommerce'
+ * - 'DaumPostcode' → 'daum_postcode'
+ *
+ * @param string $pascal PascalCase 단일 세그먼트
+ * @return string snake_case 단일 세그먼트
+ */
+ protected static function pascalToKebabSegment(string $pascal): string
+ {
+ return strtolower((string) preg_replace('/(?menuRepository->findBySlugAndExtension($slug, $extensionType, $extensionIdentifier);
if (! $existing) {
- // 신규 생성
+ // 신규 생성 — 정의의 is_active 값을 그대로 채택 (기본 true)
$menu = $this->menuRepository->updateOrCreate(
[
'slug' => $slug,
@@ -67,7 +68,7 @@ class ExtensionMenuSyncHelper
'icon' => $newAttributes['icon'] ?? null,
'order' => $newAttributes['order'] ?? 0,
'parent_id' => $parentId,
- 'is_active' => true,
+ 'is_active' => (bool) ($newAttributes['is_active'] ?? true),
]
);
@@ -77,27 +78,51 @@ class ExtensionMenuSyncHelper
return $menu;
}
- // 기존 메뉴 업데이트: user_overrides에 없는 필드만 갱신
+ // 기존 메뉴 업데이트: user_overrides 에 없는 필드만 갱신.
+ //
+ // user_overrides 형식 호환: 컬럼명 단위(`'name'`, legacy) + dot-path sub-key 단위
+ // (`'name.ko'`, `'name.en'`, beta.4 도입). 다국어 컬럼은 어느 형태로든 마킹되어
+ // 있으면 컬럼 전체를 보존 대상으로 간주한다 (간소화 — sub-key 단위 부분 보존은
+ // 별도 도메인 helper 에서 처리).
$userOverrides = $existing->user_overrides ?? [];
+ $isFieldOverridden = static function (string $column) use ($userOverrides): bool {
+ if (in_array($column, $userOverrides, true)) {
+ return true;
+ }
+ // dot-path 마킹 (`name.ko`, `name.en` 등) 도 컬럼 전체 보존으로 인정
+ $prefix = $column.'.';
+ foreach ($userOverrides as $entry) {
+ if (is_string($entry) && str_starts_with($entry, $prefix)) {
+ return true;
+ }
+ }
+
+ return false;
+ };
+
$updateData = [
'parent_id' => $parentId,
- 'is_active' => true,
];
- if (! in_array('name', $userOverrides, true)) {
+ // is_active 는 운영자가 user_overrides 로 마킹한 경우 보존, 아니면 정의값으로 갱신
+ if (! $isFieldOverridden('is_active')) {
+ $updateData['is_active'] = (bool) ($newAttributes['is_active'] ?? true);
+ }
+
+ if (! $isFieldOverridden('name')) {
$updateData['name'] = $newAttributes['name'] ?? [];
}
- if (! in_array('icon', $userOverrides, true)) {
+ if (! $isFieldOverridden('icon')) {
$updateData['icon'] = $newAttributes['icon'] ?? null;
}
- if (! in_array('order', $userOverrides, true)) {
+ if (! $isFieldOverridden('order')) {
$updateData['order'] = $newAttributes['order'] ?? 0;
}
- if (! in_array('url', $userOverrides, true)) {
+ if (! $isFieldOverridden('url')) {
$updateData['url'] = $newAttributes['url'] ?? null;
}
@@ -147,6 +172,8 @@ class ExtensionMenuSyncHelper
'icon' => $menuData['icon'] ?? null,
'order' => $menuData['order'] ?? 0,
'url' => $menuData['url'] ?? null,
+ // 정의의 is_active 값을 명시 전달 (기본 true). 미전달 시 syncMenu 가 true 로 폴백.
+ 'is_active' => $menuData['is_active'] ?? true,
],
parentId: $parentId,
);
diff --git a/app/Extension/Helpers/FilePermissionHelper.php b/app/Extension/Helpers/FilePermissionHelper.php
index a5720c56..33139869 100644
--- a/app/Extension/Helpers/FilePermissionHelper.php
+++ b/app/Extension/Helpers/FilePermissionHelper.php
@@ -198,10 +198,13 @@ class FilePermissionHelper
/**
* 부모 디렉토리의 소유자·그룹을 대상 경로에 상속합니다.
*
- * @param string $path 소유권을 상속받을 파일 또는 디렉토리
+ * sudo 컨텍스트에서 root 가 만든 파일을 부모(보통 PHP-FPM owner) 로 정합화하기 위해
+ * 외부 호출처(예: `SettingsMigrator::writeJsonFile`) 가 직접 호출 가능하도록 public.
+ *
+ * @param string $path 소유권을 상속받을 파일 또는 디렉토리
* @return void
*/
- protected static function inheritOwnershipFromParent(string $path): void
+ public static function inheritOwnershipFromParent(string $path): void
{
$parentDir = dirname($path);
if (! File::isDirectory($parentDir)) {
@@ -288,15 +291,36 @@ class FilePermissionHelper
* @return int 실제 소유권을 변경한 항목 수
*/
public static function chownRecursive(string $path, int $owner, int|false $group): int
+ {
+ return self::chownRecursiveDetailed($path, $owner, $group)['changed'];
+ }
+
+ /**
+ * `chownRecursive` 의 상세 결과 변형. 실패 경로를 누적하여 반환한다.
+ *
+ * 코어/확장 업데이트 흐름이 운영자에게 권한 정상화 실패 경로를 노출할 수 있도록
+ * 누적 결과를 구조화 반환한다. 실패 경로 수가 많을 때 로그 폭주를 막기 위해
+ * `failed_paths` 는 최대 50개로 잘라낸다 (전체 카운트는 `failed` 에 보존).
+ *
+ * `$respectPreservationMarker = true` 일 때 (트랙 2-A): 트리 순회 중 디렉토리에
+ * `.preserve-ownership` 파일이 발견되면 해당 서브트리 전체를 chown 비대상으로 skip.
+ * `ModuleStorageDriver` / `PluginStorageDriver` 가 자동 작성하는 마커로 사용자 데이터
+ * (storage/app/{modules,plugins}/{id}/) 의 시드 시점 owner/perms 영구 보존.
+ *
+ * @param string $path 대상 경로
+ * @param int $owner 기준 소유자 UID
+ * @param int|false $group 기준 그룹 GID (false = 그룹 유지)
+ * @param bool $respectPreservationMarker `.preserve-ownership` 마커가 있는 서브트리 skip 여부
+ * @return array{changed:int, failed:int, failed_paths:array, supported:bool, skipped_subtrees:int}
+ */
+ public static function chownRecursiveDetailed(string $path, int $owner, int|false $group, bool $respectPreservationMarker = false): array
{
if (! function_exists('chown')) {
- return 0;
+ return ['changed' => 0, 'failed' => 0, 'failed_paths' => [], 'supported' => false, 'skipped_subtrees' => 0];
}
- // 재귀 전체 기간 동안 실패/성공을 집계하고 종료 시 요약 로그를 남긴다.
- // 경로당 개별 로그는 재귀가 깊어지면 로그 폭주 유발 → 최초 실패 1건만 즉시 로깅.
- $report = ['changed' => 0, 'failed' => 0, 'first_failure' => null];
- self::chownRecursiveInternal($path, $owner, $group, $report);
+ $report = ['changed' => 0, 'failed' => 0, 'failed_paths' => [], 'first_failure' => null, 'skipped_subtrees' => 0];
+ self::chownRecursiveInternal($path, $owner, $group, $report, $respectPreservationMarker);
if ($report['failed'] > 0) {
Log::warning('chownRecursive: 부분 실패', [
@@ -309,7 +333,15 @@ class FilePermissionHelper
]);
}
- return $report['changed'];
+ // 마커 skip 카운트는 호출자(restoreOwnership 등) 의 종합 로그에 포함되므로 별도 info 미출력.
+
+ return [
+ 'changed' => $report['changed'],
+ 'failed' => $report['failed'],
+ 'failed_paths' => array_slice($report['failed_paths'], 0, 50),
+ 'supported' => true,
+ 'skipped_subtrees' => $report['skipped_subtrees'],
+ ];
}
/**
@@ -332,22 +364,47 @@ class FilePermissionHelper
* @return int 실제 chmod 한 항목 수
*/
public static function syncGroupWritability(string $root): int
+ {
+ return self::syncGroupWritabilityDetailed($root)['changed'];
+ }
+
+ /**
+ * `syncGroupWritability` 의 상세 결과 변형. 실패 경로를 누적하여 반환한다.
+ *
+ * `skipped` 는 루트가 g-w 정책 보존으로 no-op 되었거나 chmod 미지원 환경에서 true.
+ * 코어/확장 업데이트가 운영자에게 권한 정상화 실패 경로를 즉시 노출할 때 사용.
+ *
+ * `$force=true` 시 루트가 g-w 라도 강제로 g+w 부여 후 하위 정상화. sudo root 가 0755 로
+ * 신규 디렉토리를 생성한 케이스(권한 정상화가 가장 필요한 시나리오) 에서 운영자 정책 보존
+ * 분기로 silent no-op 되던 결함을 차단할 때 사용. 일반 호출은 force=false (기존 동작 유지).
+ *
+ * @param string $root 대상 루트
+ * @param bool $force 루트 g-w 정책 강제 우회
+ * @return array{changed:int, failed:int, failed_paths:array, supported:bool, skipped:bool}
+ */
+ public static function syncGroupWritabilityDetailed(string $root, bool $force = false): array
{
if (! function_exists('chmod') || ! is_dir($root)) {
- return 0;
+ return ['changed' => 0, 'failed' => 0, 'failed_paths' => [], 'supported' => function_exists('chmod'), 'skipped' => true];
}
$rootPerms = @fileperms($root);
if ($rootPerms === false) {
- return 0;
+ return ['changed' => 0, 'failed' => 0, 'failed_paths' => [], 'supported' => true, 'skipped' => true];
}
- // 루트가 g+w 가 아니면 정책 보존 (no-op)
if (($rootPerms & 0020) === 0) {
- return 0;
+ if (! $force) {
+ return ['changed' => 0, 'failed' => 0, 'failed_paths' => [], 'supported' => true, 'skipped' => true];
+ }
+ // force 모드: 루트에 g+w 강제 부여 → 이후 하위 정상화로 진행. sudo root 가 0755 로
+ // 신규 디렉토리를 생성한 시나리오에서 운영자 정책 보존 분기로 silent no-op 되던 결함 차단.
+ if (! @chmod($root, $rootPerms | 0020)) {
+ return ['changed' => 0, 'failed' => 1, 'failed_paths' => [$root], 'supported' => true, 'skipped' => false];
+ }
}
- $report = ['changed' => 0];
+ $report = ['changed' => 0, 'failed' => 0, 'failed_paths' => []];
self::syncGroupWritabilityInternal($root, $report, true);
if ($report['changed'] > 0) {
@@ -356,8 +413,22 @@ class FilePermissionHelper
'changed' => $report['changed'],
]);
}
+ if ($report['failed'] > 0) {
+ Log::warning('syncGroupWritability: 부분 실패', [
+ 'root' => $root,
+ 'changed' => $report['changed'],
+ 'failed' => $report['failed'],
+ 'first_failure' => $report['failed_paths'][0] ?? null,
+ ]);
+ }
- return $report['changed'];
+ return [
+ 'changed' => $report['changed'],
+ 'failed' => $report['failed'],
+ 'failed_paths' => array_slice($report['failed_paths'], 0, 50),
+ 'supported' => true,
+ 'skipped' => false,
+ ];
}
/**
@@ -380,6 +451,15 @@ class FilePermissionHelper
// g+w 만 추가, 다른 비트 무변경
if (@chmod($path, $perms | 0020)) {
$report['changed']++;
+ } else {
+ if (! isset($report['failed'])) {
+ $report['failed'] = 0;
+ $report['failed_paths'] = [];
+ }
+ $report['failed']++;
+ if (count($report['failed_paths']) < 50) {
+ $report['failed_paths'][] = $path;
+ }
}
}
}
@@ -400,10 +480,22 @@ class FilePermissionHelper
* @param string $path 대상 경로
* @param int $owner 기준 소유자 UID
* @param int|false $group 기준 그룹 GID
- * @param array{changed:int, failed:int, first_failure:string|null} $report 집계 구조 (참조)
+ * @param array{changed:int, failed:int, first_failure:string|null, skipped_subtrees:int} $report 집계 구조 (참조)
+ * @param bool $respectPreservationMarker `.preserve-ownership` 마커가 있는 디렉토리 서브트리 skip 여부
*/
- private static function chownRecursiveInternal(string $path, int $owner, int|false $group, array &$report): void
+ private static function chownRecursiveInternal(string $path, int $owner, int|false $group, array &$report, bool $respectPreservationMarker = false): void
{
+ // 트랙 2-A — 디렉토리에 .preserve-ownership 마커가 있으면 서브트리 전체 skip (자기 자신 + 하위)
+ // ModuleStorageDriver / PluginStorageDriver 가 자동 작성하는 마커로 사용자 데이터 영구 보존.
+ if ($respectPreservationMarker && is_dir($path) && ! is_link($path)) {
+ $markerPath = $path.DIRECTORY_SEPARATOR.'.preserve-ownership';
+ if (@file_exists($markerPath)) {
+ $report['skipped_subtrees']++;
+
+ return; // 자기 자신 + 하위 모두 chown 비대상
+ }
+ }
+
$currentOwner = @fileowner($path);
if ($currentOwner !== false && $currentOwner !== $owner) {
if (@chown($path, $owner)) {
@@ -414,8 +506,21 @@ class FilePermissionHelper
Log::warning('chown 최초 실패', ['path' => $path, 'owner' => $owner]);
}
$report['failed']++;
+ if (! isset($report['failed_paths'])) {
+ $report['failed_paths'] = [];
+ }
+ if (count($report['failed_paths']) < 50) {
+ $report['failed_paths'][] = $path;
+ }
}
- if ($group !== false && function_exists('chgrp')) {
+ }
+
+ // chgrp 는 owner 일치 여부와 무관하게 별도 판정 (이전: chown 분기 안에 있어 owner 일치 시 chgrp 도 스킵되던 결함).
+ // 운영자 환경에서 lang-packs 가 base_path owner 와 동일하지만 그룹은 root 등 다른 그룹으로 잔존하는 케이스에서
+ // 그룹 변경이 영구히 누락되던 silent fail 차단.
+ if ($group !== false && function_exists('chgrp')) {
+ $currentGroup = @filegroup($path);
+ if ($currentGroup !== false && $currentGroup !== $group) {
@chgrp($path, $group);
}
}
@@ -426,7 +531,7 @@ class FilePermissionHelper
$items = new \FilesystemIterator($path, \FilesystemIterator::SKIP_DOTS);
foreach ($items as $item) {
- self::chownRecursiveInternal($item->getPathname(), $owner, $group, $report);
+ self::chownRecursiveInternal($item->getPathname(), $owner, $group, $report, $respectPreservationMarker);
}
}
}
diff --git a/app/Extension/Helpers/IdentityMessageSyncHelper.php b/app/Extension/Helpers/IdentityMessageSyncHelper.php
new file mode 100644
index 00000000..7327ea66
--- /dev/null
+++ b/app/Extension/Helpers/IdentityMessageSyncHelper.php
@@ -0,0 +1,151 @@
+ $data definition 데이터 (provider_id, scope_type, scope_value,
+ * extension_type, extension_identifier, name, description,
+ * channels, variables, is_active, is_default, templates 포함 가능)
+ * @return IdentityMessageDefinition
+ */
+ public function syncDefinition(array $data): IdentityMessageDefinition
+ {
+ $scopeValue = (string) ($data['scope_value'] ?? '');
+
+ return IdentityMessageDefinition::syncOrCreateFromUpgrade(
+ [
+ 'provider_id' => $data['provider_id'],
+ 'scope_type' => $data['scope_type'],
+ 'scope_value' => $scopeValue,
+ ],
+ [
+ 'extension_type' => $data['extension_type'],
+ 'extension_identifier' => $data['extension_identifier'],
+ 'name' => $data['name'],
+ 'description' => $data['description'] ?? null,
+ 'channels' => $data['channels'] ?? ['mail'],
+ 'variables' => $data['variables'] ?? [],
+ 'is_active' => $data['is_active'] ?? true,
+ 'is_default' => $data['is_default'] ?? true,
+ ]
+ );
+ }
+
+ /**
+ * 메시지 템플릿을 동기화합니다 (user_overrides 보존 upsert).
+ *
+ * @param int $definitionId
+ * @param array $data template 데이터 (channel, subject, body, is_active, is_default)
+ * @return IdentityMessageTemplate
+ */
+ public function syncTemplate(int $definitionId, array $data): IdentityMessageTemplate
+ {
+ return IdentityMessageTemplate::syncOrCreateFromUpgrade(
+ ['definition_id' => $definitionId, 'channel' => $data['channel']],
+ [
+ 'subject' => $data['subject'] ?? null,
+ 'body' => $data['body'],
+ 'is_active' => $data['is_active'] ?? true,
+ 'is_default' => $data['is_default'] ?? true,
+ ]
+ );
+ }
+
+ /**
+ * stale 메시지 정의를 삭제합니다 (완전 동기화 원칙).
+ *
+ * 정책: `user_overrides` 무관 — config/seeder 에 없는 정의는 삭제.
+ * FK cascade 로 연관 templates 도 자동 정리됩니다.
+ *
+ * @param string $extensionType
+ * @param string $extensionIdentifier
+ * @param array $currentScopes
+ * @return int 삭제된 definition 수
+ */
+ public function cleanupStaleDefinitions(
+ string $extensionType,
+ string $extensionIdentifier,
+ array $currentScopes,
+ ): int {
+ $currentKeys = array_map(
+ fn (array $s) => $s['provider_id'].'|'.$s['scope_type'].'|'.((string) ($s['scope_value'] ?? '')),
+ $currentScopes
+ );
+
+ $query = IdentityMessageDefinition::query()
+ ->where('extension_type', $extensionType)
+ ->where('extension_identifier', $extensionIdentifier);
+
+ $targets = $query->get(['id', 'provider_id', 'scope_type', 'scope_value']);
+
+ $stale = $targets->filter(function (IdentityMessageDefinition $def) use ($currentKeys) {
+ $key = $def->provider_id.'|'.$def->scope_type->value.'|'.((string) $def->scope_value);
+
+ return ! in_array($key, $currentKeys, true);
+ });
+
+ foreach ($stale as $def) {
+ $def->delete();
+ }
+
+ $count = $stale->count();
+ if ($count > 0) {
+ Log::info('stale IDV 메시지 정의 정리 완료', [
+ 'extension_type' => $extensionType,
+ 'extension_identifier' => $extensionIdentifier,
+ 'deleted' => $count,
+ 'scopes' => $stale->map(fn ($d) => $d->provider_id.'|'.$d->scope_type->value.'|'.$d->scope_value)->all(),
+ ]);
+ }
+
+ return $count;
+ }
+
+ /**
+ * 주어진 definition 의 channel 목록 기준으로 stale template 을 삭제합니다.
+ *
+ * @param int $definitionId
+ * @param array $currentChannels
+ * @return int 삭제된 template 수
+ */
+ public function cleanupStaleTemplates(int $definitionId, array $currentChannels): int
+ {
+ $query = IdentityMessageTemplate::query()
+ ->where('definition_id', $definitionId)
+ ->whereNotIn('channel', $currentChannels);
+
+ $targets = $query->get(['id', 'channel']);
+ foreach ($targets as $template) {
+ $template->delete();
+ }
+
+ $count = $targets->count();
+ if ($count > 0) {
+ Log::info('stale IDV 메시지 템플릿 정리 완료', [
+ 'definition_id' => $definitionId,
+ 'deleted' => $count,
+ 'channels' => $targets->pluck('channel')->all(),
+ ]);
+ }
+
+ return $count;
+ }
+}
diff --git a/app/Extension/Helpers/IdentityPolicySyncHelper.php b/app/Extension/Helpers/IdentityPolicySyncHelper.php
new file mode 100644
index 00000000..5cb5bc98
--- /dev/null
+++ b/app/Extension/Helpers/IdentityPolicySyncHelper.php
@@ -0,0 +1,91 @@
+ $data 정책 데이터 (key/scope/target/purpose 등)
+ * @return IdentityPolicy 동기화된 정책
+ */
+ public function syncPolicy(array $data): IdentityPolicy
+ {
+ return IdentityPolicy::syncOrCreateFromUpgrade(
+ ['key' => $data['key']],
+ [
+ 'scope' => $data['scope'] ?? 'route',
+ 'target' => $data['target'] ?? $data['key'],
+ 'purpose' => $data['purpose'] ?? 'sensitive_action',
+ 'provider_id' => $data['provider_id'] ?? null,
+ 'grace_minutes' => (int) ($data['grace_minutes'] ?? 0),
+ 'enabled' => (bool) ($data['enabled'] ?? true),
+ 'priority' => (int) ($data['priority'] ?? 100),
+ 'conditions' => $data['conditions'] ?? null,
+ 'source_type' => $data['source_type'] ?? 'core',
+ 'source_identifier' => $data['source_identifier'] ?? 'core',
+ 'applies_to' => $data['applies_to'] ?? 'both',
+ 'fail_mode' => $data['fail_mode'] ?? 'block',
+ ]
+ );
+ }
+
+ /**
+ * seed/정의에 없는 stale 정책을 삭제합니다 (완전 동기화 원칙).
+ *
+ * 운영자가 S1d 에서 직접 생성한 정책(source_type='admin')은 영향받지 않습니다.
+ *
+ * @param string $sourceType 확장 타입 (core|module|plugin)
+ * @param string $sourceIdentifier 확장 식별자
+ * @param array $currentKeys 현재 유효한 정책 key 목록
+ * @return int 삭제된 정책 수
+ */
+ public function cleanupStalePolicies(
+ string $sourceType,
+ string $sourceIdentifier,
+ array $currentKeys,
+ ): int {
+ $query = IdentityPolicy::query()
+ ->where('source_type', $sourceType)
+ ->where('source_identifier', $sourceIdentifier);
+
+ if (! empty($currentKeys)) {
+ $query->whereNotIn('key', $currentKeys);
+ }
+
+ $targets = $query->get(['id', 'key']);
+ foreach ($targets as $policy) {
+ $policy->delete();
+ }
+
+ $count = $targets->count();
+ if ($count > 0) {
+ Log::info('IdentityPolicySyncHelper: stale 정책 정리', [
+ 'source_type' => $sourceType,
+ 'source_identifier' => $sourceIdentifier,
+ 'deleted_count' => $count,
+ 'deleted_keys' => $targets->pluck('key')->all(),
+ ]);
+ }
+
+ return $count;
+ }
+}
diff --git a/app/Extension/Helpers/SettingsMigrator.php b/app/Extension/Helpers/SettingsMigrator.php
index 6a056dda..0b5e94bc 100644
--- a/app/Extension/Helpers/SettingsMigrator.php
+++ b/app/Extension/Helpers/SettingsMigrator.php
@@ -342,7 +342,7 @@ class SettingsMigrator
private function executeAddCategory(array $args): bool
{
if ($this->type === 'plugin') {
- throw new \LogicException('addCategory는 모듈에서만 사용할 수 있습니다.');
+ throw new \LogicException(__('exceptions.settings.add_category_module_only'));
}
$category = $args['category'];
@@ -427,7 +427,10 @@ class SettingsMigrator
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \RuntimeException(
- "JSON 파싱 실패: {$filePath} - ".json_last_error_msg()
+ __('exceptions.settings.json_parse_failed', [
+ 'path' => $filePath,
+ 'error' => json_last_error_msg(),
+ ])
);
}
@@ -437,6 +440,11 @@ class SettingsMigrator
/**
* 배열 데이터를 JSON 파일로 저장합니다.
*
+ * sudo update 흐름에서 모듈/플러그인 upgrade step 이 root 로 실행될 때 root 소유로
+ * 파일이 만들어지지 않도록, 작성 직후 부모 디렉토리(예: storage/app/modules/{id}/settings/)
+ * 의 owner/group 을 상속한다. 부모 owner 가 PHP-FPM 이라면 후속 PHP-FPM 의 update 시도
+ * 가 쓰기 실패하지 않는다.
+ *
* @param string $filePath 파일 절대 경로
* @param array $data 저장할 데이터
* @return void
@@ -445,6 +453,9 @@ class SettingsMigrator
{
$content = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
File::put($filePath, $content);
+
+ // sudo 컨텍스트 root → 부모 owner 정합화. 자기 자신 owner 면 멱등 (no-op).
+ FilePermissionHelper::inheritOwnershipFromParent($filePath);
}
/**
diff --git a/app/Extension/HookArgumentSerializer.php b/app/Extension/HookArgumentSerializer.php
index d2237d59..51aa9d16 100644
--- a/app/Extension/HookArgumentSerializer.php
+++ b/app/Extension/HookArgumentSerializer.php
@@ -3,6 +3,7 @@
namespace App\Extension;
use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
@@ -24,6 +25,11 @@ class HookArgumentSerializer
*/
private const COLLECTION_MARKER = '__hook_collection__';
+ /**
+ * Enum 직렬화 마커 키
+ */
+ private const ENUM_MARKER = '__hook_enum__';
+
/**
* 인자 배열을 직렬화 안전한 형태로 변환합니다.
*
@@ -54,12 +60,33 @@ class HookArgumentSerializer
*/
private static function serializeValue(mixed $value): mixed
{
- // Eloquent Model → 클래스명 + PK
+ // Eloquent Model → 클래스명 + PK + attributes 스냅샷
+ // attributes 는 hard-delete 모델의 after_delete 훅 페이로드 복원용 fallback.
+ // 큐 워커가 find($id) 로 조회 못 하더라도 attributes 로 in-memory 모델 재생성 가능.
if ($value instanceof Model) {
return [
self::MODEL_MARKER => true,
'class' => get_class($value),
'id' => $value->getKey(),
+ 'attributes' => $value->getAttributes(),
+ ];
+ }
+
+ // Backed Enum → 클래스명 + value (역직렬화 시 from() 사용)
+ if ($value instanceof \BackedEnum) {
+ return [
+ self::ENUM_MARKER => true,
+ 'class' => get_class($value),
+ 'value' => $value->value,
+ ];
+ }
+
+ // Pure (Unit) Enum → 클래스명 + name (역직렬화 시 constant() 사용)
+ if ($value instanceof \UnitEnum) {
+ return [
+ self::ENUM_MARKER => true,
+ 'class' => get_class($value),
+ 'name' => $value->name,
];
}
@@ -107,10 +134,28 @@ class HookArgumentSerializer
$id = $value['id'];
if (class_exists($class) && is_subclass_of($class, Model::class)) {
- return $class::find($id);
+ // 1차: DB 조회로 복원 (살아있는 레코드 또는 SoftDeletes 의 trashed)
+ $found = in_array(SoftDeletes::class, class_uses_recursive($class), true)
+ ? $class::withTrashed()->find($id)
+ : $class::find($id);
+
+ if ($found !== null) {
+ return $found;
+ }
+
+ // 2차: hard-delete 된 모델은 직렬화 시점의 attributes 스냅샷으로 in-memory 재생성
+ // (after_delete 훅의 큐 워커가 listener 호출 시 null 대신 살아있을 때 상태 전달)
+ if (isset($value['attributes']) && is_array($value['attributes'])) {
+ /** @var Model $instance */
+ $instance = new $class();
+ $instance->setRawAttributes($value['attributes']);
+ $instance->exists = false; // in-memory only — save() 가 update 시도 못 하도록
+
+ return $instance;
+ }
}
- Log::warning('훅 인자 역직렬화 실패: 모델 클래스를 찾을 수 없습니다.', [
+ Log::warning('훅 인자 역직렬화 실패: 모델 클래스를 찾을 수 없거나 attributes 부재.', [
'class' => $class,
'id' => $id,
]);
@@ -123,6 +168,31 @@ class HookArgumentSerializer
return collect(self::deserialize($value['items']));
}
+ // Enum 복원
+ if (! empty($value[self::ENUM_MARKER])) {
+ $class = $value['class'];
+
+ if (! enum_exists($class)) {
+ Log::warning('훅 인자 역직렬화 실패: enum 클래스를 찾을 수 없습니다.', [
+ 'class' => $class,
+ ]);
+
+ return null;
+ }
+
+ // BackedEnum: value 키로 from()
+ if (array_key_exists('value', $value)) {
+ return $class::from($value['value']);
+ }
+
+ // UnitEnum: name 키로 constant()
+ if (array_key_exists('name', $value)) {
+ return constant($class.'::'.$value['name']);
+ }
+
+ return null;
+ }
+
// 일반 배열 → 재귀 역직렬화
return array_map([self::class, 'deserializeValue'], $value);
}
diff --git a/app/Extension/HookListenerRegistrar.php b/app/Extension/HookListenerRegistrar.php
index c5631a87..40ba5980 100644
--- a/app/Extension/HookListenerRegistrar.php
+++ b/app/Extension/HookListenerRegistrar.php
@@ -18,18 +18,42 @@ use Illuminate\Support\Facades\Log;
*/
class HookListenerRegistrar
{
+ /**
+ * 등록 이력 캐시 (process-wide idempotency).
+ *
+ * Laravel ServiceProvider boot 가 PHPUnit 테스트 환경에서 매 setUp 마다 다시
+ * 호출되며 listener 가 누적 등록되어 hook 카운트 폭증으로 hang 을 유발하던
+ * 문제 차단. production 환경은 boot 가 1회만 호출되므로 무영향.
+ *
+ * 모듈 install/uninstall 시나리오에서 재등록이 필요하면 clear() 사용.
+ *
+ * @var array key: "{source}::{listenerClass}"
+ */
+ private static array $registered = [];
+
/**
* 리스너 클래스를 HookManager에 등록합니다.
*
+ * 동일 source + listenerClass 조합이 이미 등록된 경우 skip (idempotent).
+ *
* @param string $listenerClass HookListenerInterface 구현 클래스의 FQCN
* @param string|null $source 등록 출처 (로그용: 'core', 모듈/플러그인 식별자)
* @return void
*/
public static function register(string $listenerClass, ?string $source = null): void
{
+ $key = ($source ?? 'unknown').'::'.$listenerClass;
+ if (isset(self::$registered[$key])) {
+ return; // 동일 PHP process 내 중복 등록 방지
+ }
+ self::$registered[$key] = true;
+
try {
$subscribedHooks = $listenerClass::getSubscribedHooks();
} catch (\Throwable $e) {
+ // 실패 시 캐시 롤백하여 재시도 가능 상태 유지
+ unset(self::$registered[$key]);
+
Log::error('훅 리스너 등록 실패: getSubscribedHooks() 오류', [
'listener' => $listenerClass,
'source' => $source,
@@ -81,4 +105,17 @@ class HookListenerRegistrar
]);
}
}
+
+ /**
+ * 등록 이력 캐시를 비웁니다.
+ *
+ * 모듈 install/uninstall 시나리오 또는 테스트 격리가 필요할 때 호출.
+ * 캐시 비운 후 register() 호출하면 listener 가 다시 HookManager 에 추가됨.
+ *
+ * @return void
+ */
+ public static function clear(): void
+ {
+ self::$registered = [];
+ }
}
diff --git a/app/Extension/HookManager.php b/app/Extension/HookManager.php
index b386356c..6af97a4b 100644
--- a/app/Extension/HookManager.php
+++ b/app/Extension/HookManager.php
@@ -19,6 +19,14 @@ class HookManager implements HookManagerInterface
private static array $dispatching = [];
+ /**
+ * 현재 실행 중인 훅 이름 스택 — 정책 Listener 등 "어느 훅에서 호출되었는지" 알아야 하는
+ * 단일 핸들러 패턴에 사용됩니다. (내부 전용)
+ *
+ * @var array
+ */
+ private static array $runningHookStack = [];
+
/**
* Hook 이벤트를 발생시켜 등록된 콜백들을 실행합니다.
*
@@ -29,25 +37,42 @@ class HookManager implements HookManagerInterface
{
// 가드 플래그 설정 (addAction으로 등록된 콜백의 Event::listen 중복 실행 방지)
self::$dispatching[$hookName] = true;
+ self::$runningHookStack[] = $hookName;
- // 등록된 Hook이 있는지 확인
- if (isset(self::$hooks[$hookName])) {
- // Hook들을 우선순위에 따라 정렬
- $hooks = self::$hooks[$hookName];
- ksort($hooks);
+ try {
+ // 등록된 Hook이 있는지 확인
+ if (isset(self::$hooks[$hookName])) {
+ // Hook들을 우선순위에 따라 정렬
+ $hooks = self::$hooks[$hookName];
+ ksort($hooks);
- // 각 Hook을 순차적으로 실행
- foreach ($hooks as $priority => $callbacks) {
- foreach ($callbacks as $callback) {
- call_user_func_array($callback, $args);
+ // 각 Hook을 순차적으로 실행
+ foreach ($hooks as $priority => $callbacks) {
+ foreach ($callbacks as $callback) {
+ call_user_func_array($callback, $args);
+ }
}
}
+
+ // Laravel 이벤트 시스템에도 전달 (직접 Event::listen으로 등록한 외부 리스너용)
+ Event::dispatch("hook.{$hookName}", $args);
+ } finally {
+ array_pop(self::$runningHookStack);
+ unset(self::$dispatching[$hookName]);
}
+ }
- // Laravel 이벤트 시스템에도 전달 (직접 Event::listen으로 등록한 외부 리스너용)
- Event::dispatch("hook.{$hookName}", $args);
+ /**
+ * 현재 실행 중인 (가장 내부의) 훅 이름을 반환합니다.
+ * 단일 handler 메서드가 여러 훅에 구독되어 "어느 훅에서 호출되었는지" 구분이 필요할 때 사용.
+ *
+ * @return string|null 실행 중 훅 이름 또는 null (최상위 컨텍스트)
+ */
+ public static function getRunningHook(): ?string
+ {
+ $count = count(self::$runningHookStack);
- unset(self::$dispatching[$hookName]);
+ return $count > 0 ? self::$runningHookStack[$count - 1] : null;
}
/**
diff --git a/app/Extension/IdentityVerification/DTO/VerificationChallenge.php b/app/Extension/IdentityVerification/DTO/VerificationChallenge.php
new file mode 100644
index 00000000..13cbe842
--- /dev/null
+++ b/app/Extension/IdentityVerification/DTO/VerificationChallenge.php
@@ -0,0 +1,58 @@
+ $this->id,
+ 'provider_id' => $this->providerId,
+ 'purpose' => $this->purpose,
+ 'channel' => $this->channel,
+ 'render_hint' => $this->renderHint,
+ 'redirect_url' => $this->redirectUrl,
+ 'expires_at' => $this->expiresAt->toIso8601String(),
+ 'public_payload' => $this->publicPayload,
+ ];
+ }
+}
diff --git a/app/Extension/IdentityVerification/DTO/VerificationResult.php b/app/Extension/IdentityVerification/DTO/VerificationResult.php
new file mode 100644
index 00000000..72f4a144
--- /dev/null
+++ b/app/Extension/IdentityVerification/DTO/VerificationResult.php
@@ -0,0 +1,85 @@
+
+ */
+ protected array $providers = [];
+
+ /**
+ * 확장(모듈/플러그인) 이 선언한 purpose 레지스트리.
+ *
+ * `AbstractModule::getIdentityPurposes()` / `AbstractPlugin::getIdentityPurposes()`
+ * 반환값을 `ModuleManager` / `PluginManager` 가 부팅 시 여기에 병합합니다.
+ * DB 에 저장되지 않는 **코드 계약** 입니다.
+ *
+ * @var array>
+ */
+ protected array $declaredPurposes = [];
+
+ /**
+ * 코어 기본 purpose 목록.
+ *
+ * `signup` / `password_reset` / `self_update` / `sensitive_action` — 이 4종은
+ * 코어가 계약으로 보장하며 `MailIdentityProvider` 가 모두 지원합니다.
+ *
+ * @var array>
+ */
+ protected array $corePurposes = [
+ IdentityVerificationPurpose::Signup->value => [
+ 'label' => 'identity.purposes.signup.label',
+ 'description' => 'identity.purposes.signup.description',
+ 'default_provider' => null,
+ 'allowed_channels' => [IdentityVerificationChannel::Email->value],
+ 'source_type' => IdentityPolicySourceType::Core->value,
+ 'source_identifier' => 'core',
+ ],
+ IdentityVerificationPurpose::PasswordReset->value => [
+ 'label' => 'identity.purposes.password_reset.label',
+ 'description' => 'identity.purposes.password_reset.description',
+ 'default_provider' => null,
+ 'allowed_channels' => [IdentityVerificationChannel::Email->value],
+ 'source_type' => IdentityPolicySourceType::Core->value,
+ 'source_identifier' => 'core',
+ ],
+ IdentityVerificationPurpose::SelfUpdate->value => [
+ 'label' => 'identity.purposes.self_update.label',
+ 'description' => 'identity.purposes.self_update.description',
+ 'default_provider' => null,
+ 'allowed_channels' => [IdentityVerificationChannel::Email->value],
+ 'source_type' => IdentityPolicySourceType::Core->value,
+ 'source_identifier' => 'core',
+ ],
+ IdentityVerificationPurpose::SensitiveAction->value => [
+ 'label' => 'identity.purposes.sensitive_action.label',
+ 'description' => 'identity.purposes.sensitive_action.description',
+ 'default_provider' => null,
+ 'allowed_channels' => [IdentityVerificationChannel::Email->value],
+ 'source_type' => IdentityPolicySourceType::Core->value,
+ 'source_identifier' => 'core',
+ ],
+ ];
+
+ /**
+ * 기본 프로바이더 id (설정에 의해 덮어쓰기 가능).
+ */
+ protected string $defaultId = 'g7:core.mail';
+
+ /**
+ * 프로바이더를 등록합니다.
+ *
+ * @param IdentityVerificationInterface $provider IDV 프로바이더 인스턴스
+ * @return void
+ */
+ public function register(IdentityVerificationInterface $provider): void
+ {
+ $this->providers[$provider->getId()] = $provider;
+ }
+
+ /**
+ * 프로바이더 등록을 해제합니다.
+ *
+ * @param string $id 프로바이더 식별자 (예: g7:core.mail)
+ * @return void
+ */
+ public function unregister(string $id): void
+ {
+ unset($this->providers[$id]);
+ }
+
+ /**
+ * 특정 id 의 프로바이더가 등록되어 있는지 확인합니다.
+ *
+ * @param string $id 프로바이더 식별자
+ * @return bool 등록 여부
+ */
+ public function has(string $id): bool
+ {
+ return isset($this->all()[$id]);
+ }
+
+ /**
+ * 특정 id 의 프로바이더를 반환합니다.
+ *
+ * @param string $id 프로바이더 식별자
+ * @return IdentityVerificationInterface 등록된 provider
+ *
+ * @throws InvalidArgumentException 프로바이더 미등록 시
+ */
+ public function get(string $id): IdentityVerificationInterface
+ {
+ $providers = $this->all();
+ if (! isset($providers[$id])) {
+ throw new InvalidArgumentException("Identity verification provider not found: {$id}");
+ }
+
+ return $providers[$id];
+ }
+
+ /**
+ * 등록된 전체 프로바이더 목록 (필터 훅 통과 후).
+ *
+ * @return array
+ */
+ public function all(): array
+ {
+ $merged = HookManager::applyFilters('core.identity.registered_providers', $this->providers);
+
+ if (! is_array($merged)) {
+ return $this->providers;
+ }
+
+ $valid = [];
+ foreach ($merged as $key => $provider) {
+ if ($provider instanceof IdentityVerificationInterface) {
+ $valid[$provider->getId()] = $provider;
+ }
+ }
+
+ return $valid;
+ }
+
+ /**
+ * 기본 프로바이더를 반환합니다.
+ *
+ * 우선순위: settings.identity.default_provider → 코어 기본 (g7:core.mail) → 등록된 첫 provider.
+ *
+ * @return IdentityVerificationInterface 기본 provider
+ *
+ * @throws InvalidArgumentException 등록된 provider 가 하나도 없을 때
+ */
+ public function default(): IdentityVerificationInterface
+ {
+ $configured = (string) config('settings.identity.default_provider', $this->defaultId);
+ $providers = $this->all();
+
+ if (isset($providers[$configured])) {
+ return $providers[$configured];
+ }
+
+ if (isset($providers[$this->defaultId])) {
+ return $providers[$this->defaultId];
+ }
+
+ if (empty($providers)) {
+ throw new InvalidArgumentException('No identity verification provider is registered.');
+ }
+
+ return $providers[array_key_first($providers)];
+ }
+
+ /**
+ * 특정 purpose 에 사용할 프로바이더를 해석합니다.
+ *
+ * 해석 순서:
+ * 1. `settings.identity.purpose_providers.{purpose}` 에 명시적 지정 + 해당 프로바이더가 purpose 지원 → 사용
+ * 2. 기본 프로바이더가 purpose 지원 → 사용
+ * 3. 등록된 프로바이더 중 purpose 지원하는 첫 번째 → 사용
+ * 4. 없으면 mail (코어 기본) 반환 — mail 은 모든 purpose 지원 계약
+ *
+ * 정책의 provider_id 가 우선되므로 (IdentityPolicyService::resolveRenderHint 참조), 본 메서드는
+ * 정책에 provider_id 가 명시되지 않은 경우의 fallback 으로 사용됩니다.
+ *
+ * @param string $purpose IDV 목적 (signup, password_reset, sensitive_action 등)
+ * @return IdentityVerificationInterface 해석된 provider
+ */
+ public function resolveForPurpose(string $purpose): IdentityVerificationInterface
+ {
+ $providers = $this->all();
+
+ $explicitId = (string) config("settings.identity.purpose_providers.{$purpose}", '');
+ if ($explicitId !== '' && isset($providers[$explicitId]) && $providers[$explicitId]->supportsPurpose($purpose)) {
+ return $providers[$explicitId];
+ }
+
+ $default = $this->default();
+ if ($default->supportsPurpose($purpose)) {
+ return $default;
+ }
+
+ foreach ($providers as $provider) {
+ if ($provider->supportsPurpose($purpose)) {
+ return $provider;
+ }
+ }
+
+ if (isset($providers[$this->defaultId])) {
+ return $providers[$this->defaultId];
+ }
+
+ throw new InvalidArgumentException("No identity verification provider supports purpose: {$purpose}");
+ }
+
+ /**
+ * 확장(모듈/플러그인) 이 선언한 purpose 들을 레지스트리에 등록합니다.
+ *
+ * `ModuleManager::bootModules()` / `PluginManager::bootPlugins()` 가
+ * 활성화된 확장의 `getIdentityPurposes()` 결과를 순회하며 호출합니다.
+ *
+ * 같은 key 로 중복 등록되면 나중 호출이 이전 값을 덮어씁니다
+ * (확장 로드 순서 결정성 보장은 ExtensionManager 책임).
+ *
+ * @param array> $purposes key => metadata 매핑
+ * @param string|null $sourceType 'module' | 'plugin' | 'admin' (미명시 시 'admin' 으로 마킹)
+ * @param string|null $sourceIdentifier source 식별자 (module/plugin id; 미명시 시 'admin')
+ * @return void
+ */
+ public function registerDeclaredPurposes(array $purposes, ?string $sourceType = null, ?string $sourceIdentifier = null): void
+ {
+ $resolvedType = $sourceType ?? 'admin';
+ $resolvedIdentifier = $sourceIdentifier ?? 'admin';
+
+ foreach ($purposes as $key => $meta) {
+ if (! is_string($key) || $key === '' || ! is_array($meta)) {
+ continue;
+ }
+
+ // legacy `label_key` / `description_key` 명명을 표준 `label` / `description` 으로 정규화.
+ // controller 의 resolvePurposeText 는 `label` / `description` 만 인식하므로, 미정규화
+ // meta 가 등록되면 응답에서 라벨이 raw 키로 노출되는 회귀 발생.
+ if (! isset($meta['label']) && isset($meta['label_key'])) {
+ $meta['label'] = $meta['label_key'];
+ }
+ if (! isset($meta['description']) && isset($meta['description_key'])) {
+ $meta['description'] = $meta['description_key'];
+ }
+
+ $meta['source_type'] = $resolvedType;
+ $meta['source_identifier'] = $resolvedIdentifier;
+ $this->declaredPurposes[$key] = $meta;
+ }
+ }
+
+ /**
+ * 확장이 선언한 purpose 를 한 개 등록합니다.
+ *
+ * @param string $key purpose 식별자
+ * @param array $meta label/description/allowed_channels 등 메타데이터
+ * @param string|null $sourceType 'module' | 'plugin' | 'admin' (미명시 시 'admin')
+ * @param string|null $sourceIdentifier source 식별자 (미명시 시 'admin')
+ * @return void
+ */
+ public function registerPurpose(string $key, array $meta, ?string $sourceType = null, ?string $sourceIdentifier = null): void
+ {
+ $meta['source_type'] = $sourceType ?? 'admin';
+ $meta['source_identifier'] = $sourceIdentifier ?? 'admin';
+ $this->declaredPurposes[$key] = $meta;
+ }
+
+ /**
+ * 등록된 전체 purpose 목록을 반환합니다.
+ *
+ * 병합 순서: 코어 기본 4종 → 확장 getter 선언분 → `core.identity.purposes` filter 훅.
+ * 같은 key 가 충돌하면 나중 소스가 이전을 덮어씁니다 (filter 훅이 최종 결정권).
+ *
+ * @return array>
+ */
+ public function getAllPurposes(): array
+ {
+ $merged = $this->corePurposes;
+
+ foreach ($this->declaredPurposes as $key => $meta) {
+ $merged[$key] = $meta;
+ }
+
+ $filtered = HookManager::applyFilters('core.identity.purposes', $merged);
+ if (! is_array($filtered)) {
+ return $merged;
+ }
+
+ return $filtered;
+ }
+
+ /**
+ * 특정 purpose 가 등록되어 있는지 확인합니다 (코어·확장·filter 훅 모두 포함).
+ *
+ * @param string $key purpose 식별자
+ * @return bool 존재 여부
+ */
+ public function hasPurpose(string $key): bool
+ {
+ $all = $this->getAllPurposes();
+
+ return isset($all[$key]);
+ }
+}
diff --git a/app/Extension/IdentityVerification/Providers/MailIdentityProvider.php b/app/Extension/IdentityVerification/Providers/MailIdentityProvider.php
new file mode 100644
index 00000000..ece43c28
--- /dev/null
+++ b/app/Extension/IdentityVerification/Providers/MailIdentityProvider.php
@@ -0,0 +1,447 @@
+ 채널 키 배열
+ */
+ public function getChannels(): array
+ {
+ return ['email'];
+ }
+
+ /**
+ * 기본 렌더 힌트를 반환합니다.
+ *
+ * @return string 렌더 힌트 (text_code, link 등)
+ */
+ public function getRenderHint(): string
+ {
+ // 기본값 — purpose 별 분기는 requestChallenge 내부에서 수행
+ return 'text_code';
+ }
+
+ /**
+ * 지정된 purpose 를 지원하는지 반환합니다.
+ *
+ * @param string $purpose 본인인증 목적
+ * @return bool 지원 여부
+ */
+ public function supportsPurpose(string $purpose): bool
+ {
+ // 메일 프로바이더는 모든 코어·플러그인 purpose 를 범용 지원
+ return true;
+ }
+
+ /**
+ * 프로바이더 사용 가능 여부를 반환합니다.
+ *
+ * @return bool 메일러 설정 존재 시 true
+ */
+ public function isAvailable(): bool
+ {
+ $mailer = (string) config('mail.default', '');
+
+ return $mailer !== '';
+ }
+
+ /**
+ * 인증 챌린지를 발급하고 메일을 발송합니다.
+ *
+ * @param User|array $target 대상 사용자 또는 식별 정보
+ * @param array $context 요청 컨텍스트 (purpose, ip_address 등)
+ * @return VerificationChallenge 발급된 챌린지
+ *
+ * @throws \InvalidArgumentException 이메일이 비어있는 경우
+ */
+ public function requestChallenge(User|array $target, array $context = []): VerificationChallenge
+ {
+ $email = $target instanceof User
+ ? $target->email
+ : (string) ($target['email'] ?? '');
+
+ if ($email === '') {
+ throw new \InvalidArgumentException('MailIdentityProvider requires an email target.');
+ }
+
+ $purpose = (string) ($context['purpose'] ?? 'sensitive_action');
+ $renderHint = $this->resolveRenderHint($purpose);
+ $ttlMinutes = (int) config('settings.identity.challenge_ttl_minutes', 15);
+ $maxAttempts = (int) config('settings.identity.max_attempts', 5);
+ $targetHash = hash('sha256', mb_strtolower($email));
+
+ $metadata = [];
+ $publicPayload = [];
+ $code = null;
+ $linkToken = null;
+
+ if ($renderHint === 'text_code') {
+ $code = $this->generateNumericCode((int) ($this->config['code_length'] ?? 6));
+ $metadata['code_hash'] = Hash::make($code);
+ $publicPayload['code_length'] = strlen($code);
+ } else {
+ // link 흐름 — 수신자에게는 서명 링크 전달, 서버는 해시만 저장
+ $linkToken = Str::random(64);
+ $metadata['link_token_hash'] = Hash::make($linkToken);
+ $publicPayload['link_hint'] = 'email_link';
+ }
+
+ $expiresAt = Carbon::now()->add(CarbonInterval::minutes($ttlMinutes));
+
+ $log = $this->logRepository->create([
+ 'provider_id' => self::ID,
+ 'purpose' => $purpose,
+ 'channel' => 'email',
+ 'user_id' => $target instanceof User ? $target->id : null,
+ 'target_hash' => $targetHash,
+ 'status' => IdentityVerificationStatus::Requested->value,
+ 'render_hint' => $renderHint,
+ 'attempts' => 0,
+ 'max_attempts' => $maxAttempts,
+ 'ip_address' => $context['ip_address'] ?? null,
+ 'user_agent' => $context['user_agent'] ?? null,
+ 'origin_type' => $context['origin_type'] ?? null,
+ 'origin_identifier' => $context['origin_identifier'] ?? null,
+ 'origin_policy_key' => $context['origin_policy_key'] ?? null,
+ 'properties' => $context['properties'] ?? null,
+ 'metadata' => $metadata,
+ 'expires_at' => $expiresAt,
+ ]);
+
+ $sent = $this->dispatchMessage(
+ email: $email,
+ purpose: $purpose,
+ renderHint: $renderHint,
+ challengeId: $log->id,
+ policyKey: $context['origin_policy_key'] ?? null,
+ code: $code,
+ linkToken: $linkToken,
+ ttlMinutes: $ttlMinutes,
+ expiresAt: $expiresAt,
+ );
+
+ $this->logRepository->updateById($log->id, [
+ 'status' => $sent ? IdentityVerificationStatus::Sent->value : IdentityVerificationStatus::Failed->value,
+ ]);
+
+ return new VerificationChallenge(
+ id: $log->id,
+ providerId: self::ID,
+ purpose: $purpose,
+ channel: 'email',
+ targetHash: $targetHash,
+ expiresAt: $expiresAt,
+ renderHint: $renderHint,
+ publicPayload: $publicPayload,
+ metadata: [],
+ );
+ }
+
+ /**
+ * 사용자가 제출한 코드/토큰을 검증합니다.
+ *
+ * @param string $challengeId 챌린지 ID
+ * @param array $input 사용자 입력 (code 또는 token)
+ * @param array $context 검증 컨텍스트
+ * @return VerificationResult 검증 결과
+ */
+ public function verify(string $challengeId, array $input, array $context = []): VerificationResult
+ {
+ $log = $this->logRepository->findById($challengeId);
+
+ if (! $log) {
+ return VerificationResult::failure($challengeId, self::ID, 'NOT_FOUND', 'identity.errors.challenge_not_found');
+ }
+
+ if ($log->provider_id !== self::ID) {
+ return VerificationResult::failure($challengeId, self::ID, 'WRONG_PROVIDER', 'identity.errors.wrong_provider');
+ }
+
+ if (in_array($log->status, [
+ IdentityVerificationStatus::Verified->value,
+ IdentityVerificationStatus::Expired->value,
+ IdentityVerificationStatus::Cancelled->value,
+ ], true)) {
+ return VerificationResult::failure($challengeId, self::ID, 'INVALID_STATE', 'identity.errors.invalid_state');
+ }
+
+ if ($log->isExpired()) {
+ $this->logRepository->updateById($log->id, [
+ 'status' => IdentityVerificationStatus::Expired->value,
+ ]);
+
+ return VerificationResult::failure($challengeId, self::ID, 'EXPIRED', 'identity.errors.expired');
+ }
+
+ if ($log->attempts >= $log->max_attempts) {
+ return VerificationResult::failure($challengeId, self::ID, 'MAX_ATTEMPTS', 'identity.errors.max_attempts');
+ }
+
+ $storedHash = $log->metadata['code_hash'] ?? $log->metadata['link_token_hash'] ?? null;
+ $provided = (string) ($input['code'] ?? $input['token'] ?? '');
+
+ $this->logRepository->updateById($log->id, [
+ 'attempts' => $log->attempts + 1,
+ ]);
+
+ if ($storedHash === null || ! Hash::check($provided, $storedHash)) {
+ if (($log->attempts + 1) >= $log->max_attempts) {
+ $this->logRepository->updateById($log->id, [
+ 'status' => IdentityVerificationStatus::Failed->value,
+ ]);
+ }
+
+ return VerificationResult::failure($challengeId, self::ID, 'INVALID_CODE', 'identity.errors.invalid_code');
+ }
+
+ $verifiedAt = Carbon::now();
+ $verificationToken = $this->generateVerificationToken($log->purpose, $log->target_hash);
+
+ $this->logRepository->updateById($log->id, [
+ 'status' => IdentityVerificationStatus::Verified->value,
+ 'verified_at' => $verifiedAt,
+ 'verification_token' => $verificationToken,
+ ]);
+
+ return VerificationResult::success(
+ challengeId: $challengeId,
+ providerId: self::ID,
+ verifiedAt: $verifiedAt,
+ identityHash: null, // 메일 프로바이더는 PII 정규화 식별자 없음 (KCP/이니시스에서만 반환)
+ claims: ['verification_token' => $verificationToken],
+ );
+ }
+
+ /**
+ * 챌린지를 취소 상태로 전환합니다.
+ *
+ * @param string $challengeId 챌린지 ID
+ * @return bool 성공 여부
+ */
+ public function cancel(string $challengeId): bool
+ {
+ return $this->logRepository->updateById($challengeId, [
+ 'status' => IdentityVerificationStatus::Cancelled->value,
+ ]);
+ }
+
+ /**
+ * 프로바이더 설정 스키마를 반환합니다.
+ *
+ * @return array 설정 필드 정의 배열
+ */
+ public function getSettingsSchema(): array
+ {
+ return [
+ 'code_length' => [
+ 'label' => __('identity.providers.mail.settings.code_length'),
+ 'type' => 'integer',
+ 'default' => 6,
+ 'help' => __('identity.providers.mail.settings.code_length_help'),
+ ],
+ 'from_address' => [
+ 'label' => __('identity.providers.mail.settings.from_address'),
+ 'type' => 'string',
+ 'default' => null,
+ 'help' => __('identity.providers.mail.settings.from_address_help'),
+ ],
+ ];
+ }
+
+ /**
+ * 런타임 설정을 병합한 프로바이더 인스턴스를 반환합니다.
+ *
+ * @param array $config 병합할 설정 배열
+ * @return static 설정이 병합된 새 인스턴스
+ */
+ public function withConfig(array $config): static
+ {
+ $clone = clone $this;
+ $clone->config = array_merge($this->config, $config);
+
+ return $clone;
+ }
+
+ protected function resolveRenderHint(string $purpose): string
+ {
+ return $purpose === 'password_reset' ? 'link' : 'text_code';
+ }
+
+ protected function generateNumericCode(int $length): string
+ {
+ $length = max(4, min(10, $length));
+ $code = '';
+ for ($i = 0; $i < $length; $i++) {
+ $code .= (string) random_int(0, 9);
+ }
+
+ return $code;
+ }
+
+ protected function generateVerificationToken(string $purpose, string $targetHash): string
+ {
+ return hash_hmac(
+ 'sha256',
+ $purpose.'|'.$targetHash.'|'.Str::uuid()->toString(),
+ (string) config('app.key', 'fallback-secret')
+ );
+ }
+
+ /**
+ * IDV 전용 메시지 디스패처를 통해 메일을 발송합니다.
+ *
+ * @param string $email
+ * @param string $purpose
+ * @param string $renderHint text_code | link
+ * @param string $challengeId
+ * @param string|null $policyKey
+ * @param string|null $code text_code 흐름 시 평문 인증 코드
+ * @param string|null $linkToken link 흐름 시 서명 링크용 raw 토큰
+ * @param int $ttlMinutes
+ * @param Carbon $expiresAt
+ * @return bool 발송 성공 여부
+ */
+ protected function dispatchMessage(
+ string $email,
+ string $purpose,
+ string $renderHint,
+ string $challengeId,
+ ?string $policyKey,
+ ?string $code,
+ ?string $linkToken,
+ int $ttlMinutes,
+ Carbon $expiresAt,
+ ): bool {
+ try {
+ $actionUrl = $linkToken !== null
+ ? $this->buildSignedLink($challengeId, $linkToken, $expiresAt)
+ : null;
+
+ return app(IdentityMessageDispatcher::class)->dispatch(
+ providerId: self::ID,
+ purpose: $purpose,
+ policyKey: $policyKey,
+ renderHint: $renderHint,
+ channel: 'mail',
+ target: $email,
+ data: [
+ 'code' => $code,
+ 'action_url' => $actionUrl,
+ 'expire_minutes' => $ttlMinutes,
+ 'purpose_label' => $this->resolvePurposeLabel($purpose),
+ 'app_name' => (string) config('app.name'),
+ 'site_url' => (string) config('app.url'),
+ 'recipient_email' => $email,
+ ],
+ context: [
+ 'challenge_id' => $challengeId,
+ 'render_hint' => $renderHint,
+ ],
+ );
+ } catch (\Throwable $e) {
+ Log::warning('[IDV] Mail dispatch failed', [
+ 'email' => $email,
+ 'purpose' => $purpose,
+ 'policy_key' => $policyKey,
+ 'message' => $e->getMessage(),
+ ]);
+
+ return false;
+ }
+ }
+
+ /**
+ * link 흐름용 서명 링크를 생성합니다.
+ *
+ * @param string $challengeId
+ * @param string $linkToken
+ * @param Carbon $expiresAt
+ * @return string
+ */
+ protected function buildSignedLink(string $challengeId, string $linkToken, Carbon $expiresAt): string
+ {
+ try {
+ return URL::temporarySignedRoute(
+ 'api.identity.challenges.verify',
+ $expiresAt,
+ ['challenge_id' => $challengeId, 'token' => $linkToken],
+ );
+ } catch (\Throwable) {
+ // 라우트 미존재 환경(테스트 등)에서는 경로 + query 로 fallback
+ return rtrim((string) config('app.url'), '/').'/identity/verify?challenge='.$challengeId.'&token='.$linkToken;
+ }
+ }
+
+ /**
+ * purpose 라벨(다국어)을 현재 로케일 문자열로 해석합니다.
+ *
+ * @param string $purpose
+ * @return string
+ */
+ protected function resolvePurposeLabel(string $purpose): string
+ {
+ $key = 'identity.purposes.'.$purpose.'.label';
+ $translated = __($key);
+
+ return is_string($translated) && $translated !== $key ? $translated : $purpose;
+ }
+}
diff --git a/app/Extension/ModuleManager.php b/app/Extension/ModuleManager.php
index 7dc43a75..5be10932 100644
--- a/app/Extension/ModuleManager.php
+++ b/app/Extension/ModuleManager.php
@@ -13,6 +13,7 @@ use App\Contracts\Repositories\PermissionRepositoryInterface;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Contracts\Repositories\RoleRepositoryInterface;
use App\Contracts\Repositories\TemplateRepositoryInterface;
+use App\Enums\DeactivationReason;
use App\Enums\ExtensionOwnerType;
use App\Enums\ExtensionStatus;
use App\Enums\LayoutSourceType;
@@ -24,6 +25,11 @@ use App\Extension\Helpers\ExtensionPendingHelper;
use App\Extension\Helpers\ExtensionRoleSyncHelper;
use App\Extension\Helpers\ExtensionStatusGuard;
use App\Extension\Helpers\GithubHelper;
+use App\Providers\CoreServiceProvider;
+use App\Extension\Concerns\ResolvesExtensionSharedRecords;
+use App\Extension\Helpers\IdentityMessageSyncHelper;
+use App\Extension\Helpers\IdentityPolicySyncHelper;
+use App\Extension\Helpers\NotificationSyncHelper;
use App\Extension\Vendor\Exceptions\VendorInstallException;
use App\Extension\Vendor\VendorInstallContext;
use App\Extension\Vendor\VendorInstallResult;
@@ -45,6 +51,7 @@ use Illuminate\Support\Facades\Schema;
class ModuleManager implements ModuleManagerInterface
{
+ use ResolvesExtensionSharedRecords;
use Traits\CachesModuleStatus;
use Traits\ClearsTemplateCaches;
use Traits\ComputesLayoutContentHash;
@@ -297,6 +304,8 @@ class ModuleManager implements ModuleManagerInterface
*
* @param string $moduleName 설치할 모듈명
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
+ * @param VendorMode $vendorMode vendor 디렉토리 처리 모드
+ * @param bool $force 강제 설치 여부
* @return bool 설치 성공 여부
*
* @throws \Exception 모듈을 찾을 수 없거나 의존성 문제 시
@@ -449,6 +458,15 @@ class ModuleManager implements ModuleManagerInterface
// 관리자 메뉴 자동 생성
$this->createModuleMenus($module);
+ // IDV 정책 자동 동기화 (identity_policies 테이블)
+ $this->syncModuleIdentityPolicies($module);
+
+ // IDV 메시지 정의/템플릿 자동 동기화 (identity_message_definitions / identity_message_templates)
+ $this->syncModuleIdentityMessages($module);
+
+ // 알림 정의/템플릿 자동 동기화 (notification_definitions / notification_templates)
+ $this->syncModuleNotificationDefinitions($module);
+
DB::commit();
} catch (\Exception $e) {
@@ -517,6 +535,15 @@ class ModuleManager implements ModuleManagerInterface
);
}
+ // 코어 버전 호환성 사전 검증 (#306 sync 훅보다 앞쪽)
+ if (! $force && ! CoreServiceProvider::isCoreUpdateInProgress()) {
+ CoreVersionChecker::validateExtension(
+ $module->getRequiredCoreVersion(),
+ $module->getIdentifier(),
+ 'module'
+ );
+ }
+
// 의존성 검증: 필요한 모듈/플러그인이 활성화되어 있는지 확인
// 중첩 구조 ['modules' => [...], 'plugins' => [...]] 를 순회
$missingModules = [];
@@ -577,6 +604,9 @@ class ModuleManager implements ModuleManagerInterface
if ($result) {
$this->moduleRepository->updateByIdentifier($module->getIdentifier(), [
'status' => ExtensionStatus::Active->value,
+ 'deactivated_reason' => null,
+ 'deactivated_at' => null,
+ 'incompatible_required_version' => null,
'updated_by' => Auth::id(),
'updated_at' => now(),
]);
@@ -646,10 +676,16 @@ class ModuleManager implements ModuleManagerInterface
*
* @param string $moduleName 비활성화할 모듈명
* @param bool $force 의존 확장이 있어도 강제 비활성화 여부
+ * @param string $reason 비활성화 사유 (DeactivationReason enum value: manual|incompatible_core)
+ * @param string|null $incompatibleRequiredVersion incompatible_core 사유 시 요구된 코어 버전 제약
* @return array{success: bool, layouts_deleted: int, warning?: bool, dependent_templates?: array, dependent_modules?: array, dependent_plugins?: array, message?: string} 비활성화 결과 및 삭제된 레이아웃 개수
*/
- public function deactivateModule(string $moduleName, bool $force = false): array
- {
+ public function deactivateModule(
+ string $moduleName,
+ bool $force = false,
+ string $reason = DeactivationReason::Manual->value,
+ ?string $incompatibleRequiredVersion = null,
+ ): array {
$module = $this->getModule($moduleName);
if (! $module) {
return ['success' => false, 'layouts_deleted' => 0];
@@ -708,6 +744,9 @@ class ModuleManager implements ModuleManagerInterface
if ($result) {
$this->moduleRepository->updateByIdentifier($module->getIdentifier(), [
'status' => ExtensionStatus::Inactive->value,
+ 'deactivated_reason' => $reason,
+ 'deactivated_at' => now(),
+ 'incompatible_required_version' => $incompatibleRequiredVersion,
'updated_by' => Auth::id(),
'updated_at' => now(),
]);
@@ -732,6 +771,9 @@ class ModuleManager implements ModuleManagerInterface
// 모듈 상태 캐시 무효화
self::invalidateModuleStatusCache();
+
+ // PO #6: 비활성화 후 훅 발행 — 언어팩 cascade 등 후속 처리
+ HookManager::doAction('core.modules.after_deactivate', $module->getIdentifier());
}
return ['success' => $result, 'layouts_deleted' => $layoutsDeleted];
@@ -828,6 +870,45 @@ class ModuleManager implements ModuleManagerInterface
// PO 정책: "동적 권한/메뉴는 '데이터도 함께 삭제' 옵션 체크 시에만 삭제"
if ($deleteData) {
$this->removeModulePermissionsAndMenus($module);
+
+ // IDV 정책도 data 옵션 선택 시 제거 (동일 정책 — 재설치 시 user_overrides 손실 허용)
+ if (Schema::hasTable('identity_policies')) {
+ try {
+ app(IdentityPolicySyncHelper::class)
+ ->cleanupStalePolicies('module', $module->getIdentifier(), []);
+ } catch (\Throwable $e) {
+ Log::warning('IDV 정책 정리 실패 (uninstall)', [
+ 'module' => $module->getIdentifier(),
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ // IDV 메시지 정의/템플릿도 data 옵션 선택 시 제거 (FK cascade 로 templates 자동 정리)
+ if (Schema::hasTable('identity_message_definitions')) {
+ try {
+ app(IdentityMessageSyncHelper::class)
+ ->cleanupStaleDefinitions('module', $module->getIdentifier(), []);
+ } catch (\Throwable $e) {
+ Log::warning('IDV 메시지 정리 실패 (uninstall)', [
+ 'module' => $module->getIdentifier(),
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ // 알림 정의/템플릿도 data 옵션 선택 시 제거 (FK cascade 로 templates 자동 정리)
+ if (Schema::hasTable('notification_definitions')) {
+ try {
+ app(NotificationSyncHelper::class)
+ ->cleanupStaleDefinitions('module', $module->getIdentifier(), []);
+ } catch (\Throwable $e) {
+ Log::warning('알림 정의 정리 실패 (uninstall)', [
+ 'module' => $module->getIdentifier(),
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
}
// 모듈 레이아웃 영구 삭제
@@ -970,6 +1051,7 @@ class ModuleManager implements ModuleManagerInterface
'status' => 'uninstalled',
'is_pending' => false,
'is_bundled' => false,
+ 'hidden' => $module->isHidden(),
'assets' => $assets,
];
}
@@ -988,6 +1070,7 @@ class ModuleManager implements ModuleManagerInterface
'status' => 'uninstalled',
'is_pending' => true,
'is_bundled' => false,
+ 'hidden' => (bool) ($metadata['hidden'] ?? false),
'assets' => null,
];
}
@@ -1006,6 +1089,7 @@ class ModuleManager implements ModuleManagerInterface
'status' => 'uninstalled',
'is_pending' => false,
'is_bundled' => true,
+ 'hidden' => (bool) ($metadata['hidden'] ?? false),
'assets' => null,
];
}
@@ -1062,12 +1146,15 @@ class ModuleManager implements ModuleManagerInterface
$latestVersion = $bundledVersion ?? $fileVersion;
}
+ // 설치된 모듈은 DB row 의 다국어 컬럼 우선 (applyExtensionManifests 가 ja 등 주입).
+ $nameJson = $record->name ?: $module->getName();
+ $descriptionJson = $record->description ?: $module->getDescription();
$installedModules[$name] = [
'identifier' => $identifier,
'vendor' => $module->getVendor(),
- 'name' => $this->getLocalizedValue($module->getName(), $locale),
+ 'name' => $this->getLocalizedValue($nameJson, $locale),
'version' => $record->version,
- 'description' => $this->getLocalizedValue($module->getDescription(), $locale),
+ 'description' => $this->getLocalizedValue($descriptionJson, $locale),
'dependencies' => $this->enrichDependencies($module->getDependencies()),
'status' => $record->status,
'update_available' => $updateAvailable,
@@ -1076,6 +1163,7 @@ class ModuleManager implements ModuleManagerInterface
'update_source' => $record->update_source ?? null,
'github_url' => $module->getGithubUrl(),
'github_changelog_url' => $record->github_changelog_url ?? null,
+ 'hidden' => $module->isHidden(),
'assets' => $assets,
];
}
@@ -1155,6 +1243,12 @@ class ModuleManager implements ModuleManagerInterface
return $result;
}
+ /**
+ * 모듈 상세 정보를 반환합니다.
+ *
+ * @param string $moduleName 모듈명
+ * @return array|null 모듈 정보 배열 또는 null
+ */
public function getModuleInfo(string $moduleName): ?array
{
$module = $this->getModule($moduleName);
@@ -1185,12 +1279,17 @@ class ModuleManager implements ModuleManagerInterface
}
}
+ // 다국어 필드는 DB row(applyExtensionManifests 가 활성 언어팩 ja 등을 주입) 우선,
+ // 미설치 시 module.json 폴백.
+ $nameJson = $moduleRecord?->name ?: $module->getName();
+ $descriptionJson = $moduleRecord?->description ?: $module->getDescription();
+
return [
'identifier' => $identifier,
'vendor' => $module->getVendor(),
- 'name' => $this->getLocalizedValue($module->getName(), $locale),
+ 'name' => $this->getLocalizedValue($nameJson, $locale),
'version' => $module->getVersion(),
- 'description' => $this->getLocalizedValue($module->getDescription(), $locale),
+ 'description' => $this->getLocalizedValue($descriptionJson, $locale),
'github_url' => $module->getGithubUrl(),
'metadata' => $metadata,
'requires_core' => $module->getRequiredCoreVersion(),
@@ -1776,11 +1875,15 @@ class ModuleManager implements ModuleManagerInterface
$totalTableSize = array_sum(array_column($tablesInfo, 'size_bytes'));
$totalStorageSize = array_sum(array_column($storageInfo, 'size_bytes'));
+ // 8. 코어 공유 테이블에 적재된 이 모듈의 데이터 (deleteData=true 시 정리 대상)
+ $sharedRecords = $this->resolveExtensionSharedRecords('module', $identifier);
+
return [
'tables' => $tablesInfo,
'storage_directories' => $storageInfo,
'vendor_directory' => $vendorInfo,
'extension_directory' => $extensionDirInfo,
+ 'shared_records' => $sharedRecords,
'total_table_size_bytes' => $totalTableSize,
'total_table_size_formatted' => $this->formatBytes($totalTableSize),
'total_storage_size_bytes' => $totalStorageSize,
@@ -1788,6 +1891,7 @@ class ModuleManager implements ModuleManagerInterface
];
}
+
/**
* 단일 마이그레이션 파일을 롤백합니다.
*
@@ -1867,6 +1971,14 @@ class ModuleManager implements ModuleManagerInterface
}
$roles = $module->getRoles();
+
+ // 활성 언어팩이 module 의 roles 다국어 필드(name/description)에 추가 locale 을
+ // 주입할 수 있도록 필터 훅 적용 (LanguagePackSeedInjector::injectExtensionRoles 결선).
+ $roles = HookManager::applyFilters(
+ "module.{$module->getIdentifier()}.roles.translations",
+ $roles,
+ );
+
$syncHelper = $this->getRoleSyncHelper();
foreach ($roles as $role) {
@@ -1895,6 +2007,13 @@ class ModuleManager implements ModuleManagerInterface
$permissionConfig = $module->getPermissions();
$moduleIdentifier = $module->getIdentifier();
+ // 활성 언어팩이 권한 트리(module/categories/permissions 의 name/description) 에 추가
+ // locale 을 주입할 수 있도록 필터 훅 적용 (LanguagePackSeedInjector 결선).
+ $permissionConfig = HookManager::applyFilters(
+ "module.{$moduleIdentifier}.permissions.translations",
+ $permissionConfig,
+ );
+
// 계층형 구조 여부 확인
if (! isset($permissionConfig['categories'])) {
return;
@@ -1909,10 +2028,10 @@ class ModuleManager implements ModuleManagerInterface
// 이름/설명이 문자열인 경우 배열로 변환 (역호환)
if (is_string($permName)) {
- $permName = ['ko' => $permName, 'en' => $permName];
+ $permName = array_fill_keys(config('app.translatable_locales', ['ko', 'en']), $permName);
}
if (is_string($permDesc)) {
- $permDesc = ['ko' => $permDesc, 'en' => $permDesc];
+ $permDesc = array_fill_keys(config('app.translatable_locales', ['ko', 'en']), $permDesc);
}
$moduleNode = $syncHelper->syncPermission(
@@ -1939,10 +2058,10 @@ class ModuleManager implements ModuleManagerInterface
$catName = $categoryData['name'];
$catDesc = $categoryData['description'];
if (is_string($catName)) {
- $catName = ['ko' => $catName, 'en' => $catName];
+ $catName = array_fill_keys(config('app.translatable_locales', ['ko', 'en']), $catName);
}
if (is_string($catDesc)) {
- $catDesc = ['ko' => $catDesc, 'en' => $catDesc];
+ $catDesc = array_fill_keys(config('app.translatable_locales', ['ko', 'en']), $catDesc);
}
// 2레벨: 카테고리 권한 노드
@@ -1983,10 +2102,10 @@ class ModuleManager implements ModuleManagerInterface
$pName = $permData['name'];
$pDesc = $permData['description'];
if (is_string($pName)) {
- $pName = ['ko' => $pName, 'en' => $pName];
+ $pName = array_fill_keys(config('app.translatable_locales', ['ko', 'en']), $pName);
}
if (is_string($pDesc)) {
- $pDesc = ['ko' => $pDesc, 'en' => $pDesc];
+ $pDesc = array_fill_keys(config('app.translatable_locales', ['ko', 'en']), $pDesc);
}
// 모듈 정의에서 type을 읽거나, 없으면 admin 기본값
@@ -2087,6 +2206,14 @@ class ModuleManager implements ModuleManagerInterface
}
$menus = $module->getAdminMenus();
+
+ // 활성 언어팩이 module 의 admin_menus 다국어 필드(name 등)에 추가 locale 을
+ // 주입할 수 있도록 필터 훅을 적용한다 (LanguagePackSeedInjector 가 결선).
+ $menus = HookManager::applyFilters(
+ "module.{$module->getIdentifier()}.admin_menus.translations",
+ $menus,
+ );
+
$helper = $this->getMenuSyncHelper();
foreach ($menus as $menuData) {
@@ -2099,6 +2226,361 @@ class ModuleManager implements ModuleManagerInterface
}
+ /**
+ * 모듈이 선언한 모든 선언형 산출물을 DB 에 동기화합니다.
+ *
+ * 동기화 대상 (모듈 manifest 가 정의하는 모든 declarative 영역):
+ * 1. 역할 (`getRoles`)
+ * 2. 권한 (`getPermissions`)
+ * 3. 역할-권한 매핑 (`getRolePermissions`)
+ * 4. 관리자 메뉴 (`getAdminMenus` / `getMenus`)
+ * 5. 위 4종에서 stale 항목 제거 (현재 선언에 없는 기존 레코드)
+ * 6. IDV 정책 (`getIdentityPolicies`)
+ * 7. IDV 메시지 정의/템플릿 (`getIdentityMessages`)
+ * 8. 알림 정의/템플릿 (`getNotificationDefinitions`)
+ *
+ * `installModule` / `updateModule` 트랜잭션 내부에서 호출되어 모듈 디스크 상태와 DB 를
+ * 정합 상태로 유지한다. 외부 호출 진입점으로도 노출되어 코어 업그레이드 사후 보정
+ * (`Upgrade_7_0_0_beta_4` 등) 이나 운영자 수동 재시드 도구가 사용 가능.
+ *
+ * 각 sync 메서드는 helper 내부의 user_overrides 보존 패턴을 따르므로 정상 환경 재호출
+ * 무해 (멱등).
+ *
+ * @param ModuleInterface $module 대상 모듈 인스턴스
+ */
+ public function syncDeclarativeArtifacts(ModuleInterface $module): void
+ {
+ $this->createModuleRoles($module);
+ $this->createModulePermissions($module);
+ $this->assignPermissionsToRoles($module);
+ $this->createModuleMenus($module);
+ $this->cleanupStaleModuleEntries($module);
+ $this->syncModuleIdentityPolicies($module);
+ $this->syncModuleIdentityMessages($module);
+ $this->syncModuleNotificationDefinitions($module);
+ }
+
+ /**
+ * 활성 모듈 전체를 _bundled 디렉토리에서 fresh-load 하여 declarative sync 를 일괄 호출.
+ *
+ * 코어 업그레이드 transition 사후 보정용. 이전 코어 버전(예: beta.3)의 활성 dir 에는
+ * NEW declaration 인프라(IDV/메시지/알림 등) 가 아직 도입되지 않았으므로 활성 dir
+ * fresh-load 는 declaration count 0 을 반환해 시드가 발동하지 않는다. 본 메서드는
+ * 새 코어 버전이 _bundled 로 출하한 NEW 코드를 기준으로 declaration 을 재시드해
+ * 인프라 초기화 결함을 차단한다.
+ *
+ * 신규 인프라 도입 시점의 활성 dir 에는 사용자가 거부할 수 있는 OLD declaration 자체가
+ * 없으므로 본 보정은 사용자 의지(전역 yes/no/strategy) 와 양립한다. 미래 release 에서
+ * 사용자가 모듈 업데이트를 거부한 케이스는 정상 흐름(ExecuteBundledUpdatesCommand →
+ * updateModule → syncDeclarativeArtifacts) 이 자동 처리하므로 본 보정의 추가 호출은
+ * 무해 (멱등) 하다.
+ *
+ * _bundled 에 module.php 가 부재한 외부 설치 모듈(GitHub 직접 설치 등) 은 skip.
+ *
+ * @return array{synced: array, skipped: array, failed: array}
+ *
+ * @since 7.0.0-beta.4
+ */
+ public function resyncAllActiveDeclarativeArtifacts(): array
+ {
+ $synced = [];
+ $skipped = [];
+ $failed = [];
+
+ foreach (self::getActiveModuleIdentifiers() as $identifier) {
+ $moduleDir = $this->bundledModulesPath.DIRECTORY_SEPARATOR.$identifier;
+ $moduleFile = $moduleDir.DIRECTORY_SEPARATOR.'module.php';
+
+ // _bundled 에 진입점이 없는 외부 설치 모듈(GitHub 등) 은 skip.
+ if (! File::exists($moduleFile)) {
+ $skipped[] = $identifier;
+
+ continue;
+ }
+
+ try {
+ $namespace = $this->convertDirectoryToNamespace($identifier);
+ $moduleClass = "Modules\\{$namespace}\\Module";
+
+ if (class_exists($moduleClass, false)) {
+ $module = $this->evalFreshModule($moduleFile, $moduleClass, $moduleDir);
+ } else {
+ require_once $moduleFile;
+ $module = class_exists($moduleClass) ? new $moduleClass : null;
+ }
+
+ if (! $module instanceof ModuleInterface) {
+ $failed[$identifier] = 'fresh-load 실패 (클래스 미생성)';
+
+ continue;
+ }
+
+ $this->syncDeclarativeArtifacts($module);
+ $synced[] = $identifier;
+ } catch (\Throwable $e) {
+ $failed[$identifier] = $e->getMessage();
+ Log::channel('upgrade')->warning('[resyncAllActiveDeclarativeArtifacts] sync 실패', [
+ 'module' => $identifier,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ return ['synced' => $synced, 'skipped' => $skipped, 'failed' => $failed];
+ }
+
+ /**
+ * 모듈이 선언한 IDV 정책을 `identity_policies` 테이블에 동기화합니다.
+ *
+ * `AbstractModule::getIdentityPolicies()` 결과를 순회하며
+ * `IdentityPolicySyncHelper::syncPolicy()` 로 upsert 하고,
+ * 현재 선언에 없는 기존 정책은 `cleanupStalePolicies()` 로 제거합니다.
+ *
+ * 운영자가 관리자 UI 에서 수정한 필드(`enabled` / `grace_minutes` / `provider_id` / `fail_mode`)
+ * 는 `user_overrides` JSON 으로 보존됩니다.
+ *
+ * @param ModuleInterface $module IDV 정책을 동기화할 모듈 인스턴스
+ */
+ protected function syncModuleIdentityPolicies(ModuleInterface $module): void
+ {
+ if (! method_exists($module, 'getIdentityPolicies')) {
+ return;
+ }
+
+ if (! Schema::hasTable('identity_policies')) {
+ return; // 마이그레이션 미실행 환경 (예: 초기 설치) 보호
+ }
+
+ $policies = $module->getIdentityPolicies();
+
+ // 데이터 손실 방어막 — declaration 이 빈 배열인 경우 cleanup 호출 자체를 차단.
+ // declaration 은 모듈 코드 계약(getIdentityPolicies) 의 결과이므로 빈 배열 = "이 모듈은
+ // IDV 정책을 사용하지 않음". 이 경우 첫 install 시점부터 DB row 가 없을 것이라는 전제가
+ // 성립한다. DB 에 기존 row 가 존재하면 declaration 이 환경 결함(spawn 자식 PSR-4 stale,
+ // fresh-load 의존성 누락, trait 부분 로드 등) 으로 빈 배열을 반환했을 가능성이 높으므로,
+ // cleanup 을 건너뛰고 warning 로그만 남긴다 (silent 데이터 손실 차단).
+ if (empty($policies)) {
+ $existingCount = DB::table('identity_policies')
+ ->where('source_type', 'module')
+ ->where('source_identifier', $module->getIdentifier())
+ ->count();
+ if ($existingCount > 0) {
+ Log::warning('IDV 정책 cleanup 차단 — declaration 빈 배열인데 DB row 존재 (데이터 손실 방어)', [
+ 'module' => $module->getIdentifier(),
+ ]);
+ }
+
+ return;
+ }
+
+ $helper = app(IdentityPolicySyncHelper::class);
+ $definedKeys = [];
+
+ foreach ($policies as $data) {
+ $data['source_type'] = 'module';
+ $data['source_identifier'] = $module->getIdentifier();
+
+ $helper->syncPolicy($data);
+ $definedKeys[] = $data['key'];
+ }
+
+ $helper->cleanupStalePolicies('module', $module->getIdentifier(), $definedKeys);
+ }
+
+ /**
+ * 모듈이 선언한 IDV 메시지 정의/템플릿을 동기화합니다.
+ *
+ * `AbstractModule::getIdentityMessages()` 결과를 순회하며
+ * `IdentityMessageSyncHelper::syncDefinition()` + `syncTemplate()` 으로 upsert 하고,
+ * 현재 선언에 없는 기존 정의는 `cleanupStaleDefinitions()` 로 제거합니다.
+ *
+ * `extension_type='module'`, `extension_identifier=$module->getIdentifier()` 는 자동 주입.
+ * 운영자 user_overrides 보존은 helper 내부 trait 가 처리.
+ *
+ * @param ModuleInterface $module IDV 메시지를 동기화할 모듈 인스턴스
+ */
+ protected function syncModuleIdentityMessages(ModuleInterface $module): void
+ {
+ if (! method_exists($module, 'getIdentityMessages')) {
+ return;
+ }
+
+ if (! Schema::hasTable('identity_message_definitions')) {
+ return; // 마이그레이션 미실행 환경 보호
+ }
+
+ $messages = $module->getIdentityMessages();
+
+ // 언어팩 시스템: 활성 모듈 언어팩의 seed/identity_messages.json 으로 다국어 키 병합.
+ $messages = HookManager::applyFilters(
+ "seed.{$module->getIdentifier()}.identity_messages.translations",
+ $messages
+ );
+
+ // 데이터 손실 방어막 — declaration 빈 배열 시 cleanup 호출 자체를 차단.
+ // 자세한 배경은 syncModuleIdentityPolicies 의 동일 가드 주석 참조.
+ if (empty($messages)) {
+ if ($this->hasExistingIdentityMessageDefinitions('module', $module->getIdentifier())) {
+ Log::warning('IDV 메시지 cleanup 차단 — declaration 빈 배열인데 DB row 존재 (데이터 손실 방어)', [
+ 'module' => $module->getIdentifier(),
+ ]);
+ }
+
+ return;
+ }
+
+ $helper = app(IdentityMessageSyncHelper::class);
+ $definedScopes = [];
+
+ foreach ($messages as $data) {
+ $data['extension_type'] = 'module';
+ $data['extension_identifier'] = $module->getIdentifier();
+
+ $definition = $helper->syncDefinition($data);
+ $definedScopes[] = [
+ 'provider_id' => $definition->provider_id,
+ 'scope_type' => $definition->scope_type->value,
+ 'scope_value' => $definition->scope_value,
+ ];
+
+ $definedChannels = [];
+ foreach ($data['templates'] ?? [] as $template) {
+ $helper->syncTemplate($definition->id, $template);
+ $definedChannels[] = $template['channel'];
+ }
+ $helper->cleanupStaleTemplates($definition->id, $definedChannels);
+ }
+
+ $helper->cleanupStaleDefinitions('module', $module->getIdentifier(), $definedScopes);
+ }
+
+ /**
+ * 모듈이 선언한 알림 정의/템플릿을 `notification_definitions` / `notification_templates` 에 동기화합니다.
+ *
+ * `AbstractModule::getNotificationDefinitions()` 결과를 순회하며
+ * `NotificationSyncHelper::syncDefinition()` + `syncTemplate()` 으로 upsert 하고,
+ * 현재 선언에 없는 기존 정의는 `cleanupStaleDefinitions()` 로 제거합니다.
+ *
+ * `extension_type='module'`, `extension_identifier=$module->getIdentifier()` 는 자동 주입.
+ * 운영자 user_overrides 보존은 helper 내부 trait 가 처리.
+ *
+ * @param ModuleInterface $module 알림 정의를 동기화할 모듈 인스턴스
+ */
+ protected function syncModuleNotificationDefinitions(ModuleInterface $module): void
+ {
+ if (! method_exists($module, 'getNotificationDefinitions')) {
+ return;
+ }
+
+ if (! Schema::hasTable('notification_definitions')) {
+ return; // 마이그레이션 미실행 환경 보호
+ }
+
+ $definitions = $module->getNotificationDefinitions();
+
+ // 언어팩 시스템: 활성 모듈 언어팩의 seed/notifications.json 으로 다국어 키 병합.
+ $definitions = HookManager::applyFilters(
+ "seed.{$module->getIdentifier()}.notifications.translations",
+ $definitions
+ );
+
+ // 데이터 손실 방어막 — declaration 빈 배열 시 cleanup 호출 자체를 차단.
+ // 자세한 배경은 syncModuleIdentityPolicies 의 동일 가드 주석 참조.
+ if (empty($definitions)) {
+ if ($this->hasExistingNotificationDefinitions('module', $module->getIdentifier())) {
+ Log::warning('알림 정의 cleanup 차단 — declaration 빈 배열인데 DB row 존재 (데이터 손실 방어)', [
+ 'module' => $module->getIdentifier(),
+ ]);
+ }
+
+ return;
+ }
+
+ $helper = app(NotificationSyncHelper::class);
+ $definedTypes = [];
+
+ foreach ($definitions as $data) {
+ $data['extension_type'] = 'module';
+ $data['extension_identifier'] = $module->getIdentifier();
+
+ $definition = $helper->syncDefinition($data);
+ $definedTypes[] = $definition->type;
+
+ $definedChannels = [];
+ foreach ($data['templates'] ?? [] as $template) {
+ $helper->syncTemplate($definition->id, $template);
+ $definedChannels[] = $template['channel'];
+ }
+ $helper->cleanupStaleTemplates($definition->id, $definedChannels);
+ }
+
+ $helper->cleanupStaleDefinitions('module', $module->getIdentifier(), $definedTypes);
+ }
+
+ /**
+ * 해당 source 가 기존에 등록한 IDV 정책이 있는지 확인합니다.
+ *
+ * @param string $sourceType
+ * @param string $sourceIdentifier
+ * @return bool
+ */
+ protected function hasExistingIdentityPolicies(string $sourceType, string $sourceIdentifier): bool
+ {
+ try {
+ return DB::table('identity_policies')
+ ->where('source_type', $sourceType)
+ ->where('source_identifier', $sourceIdentifier)
+ ->exists();
+ } catch (\Throwable) {
+ return false;
+ }
+ }
+
+ /**
+ * 해당 source 가 기존에 등록한 IDV 메시지 정의가 있는지 확인합니다 (데이터 손실 방어용).
+ */
+ protected function hasExistingIdentityMessageDefinitions(string $extensionType, string $extensionIdentifier): bool
+ {
+ try {
+ return DB::table('identity_message_definitions')
+ ->where('extension_type', $extensionType)
+ ->where('extension_identifier', $extensionIdentifier)
+ ->exists();
+ } catch (\Throwable) {
+ return false;
+ }
+ }
+
+ /**
+ * 해당 source 가 기존에 등록한 알림 정의가 있는지 확인합니다 (데이터 손실 방어용).
+ */
+ protected function hasExistingNotificationDefinitions(string $extensionType, string $extensionIdentifier): bool
+ {
+ try {
+ return DB::table('notification_definitions')
+ ->where('extension_type', $extensionType)
+ ->where('extension_identifier', $extensionIdentifier)
+ ->exists();
+ } catch (\Throwable) {
+ return false;
+ }
+ }
+
+ /**
+ * 해당 모듈이 기존에 등록한 메뉴가 있는지 확인합니다 (데이터 손실 방어용).
+ */
+ protected function hasExistingModuleMenus(string $moduleIdentifier): bool
+ {
+ try {
+ return DB::table('menus')
+ ->where('extension_type', ExtensionOwnerType::Module->value)
+ ->where('extension_identifier', $moduleIdentifier)
+ ->exists();
+ } catch (\Throwable) {
+ return false;
+ }
+ }
+
/**
* 모듈 정의 기준으로 stale 권한·메뉴·역할을 정리합니다 (완전 동기화 원칙).
*
@@ -2150,16 +2632,26 @@ class ModuleManager implements ModuleManagerInterface
}
// 2. 메뉴 stale 정리 (정적 + 동적 slug 병합)
+ // 데이터 손실 방어: declaration 이 빈 배열인데 DB 에 모듈 메뉴 row 가 존재하면 cleanup 차단.
+ // declaration 빈 배열은 환경 결함(spawn 자식 PSR-4 stale, fresh-load 의존성 누락 등) 으로
+ // 인한 silent 빈 반환일 가능성이 높다. 모듈이 진짜 메뉴를 비웠다면 첫 install 시점부터
+ // DB row 가 없을 것이라는 전제가 성립. (역할/권한 cleanup 의 동일 가드와 일관)
if (method_exists($module, 'getAdminMenus')) {
$currentSlugs = $menuSyncHelper->collectSlugsRecursive($module->getAdminMenus());
if (method_exists($module, 'getDynamicMenuSlugs')) {
$currentSlugs = array_merge($currentSlugs, $module->getDynamicMenuSlugs());
}
- $menuSyncHelper->cleanupStaleMenus(
- ExtensionOwnerType::Module,
- $module->getIdentifier(),
- $currentSlugs,
- );
+ if (! empty($currentSlugs)) {
+ $menuSyncHelper->cleanupStaleMenus(
+ ExtensionOwnerType::Module,
+ $module->getIdentifier(),
+ $currentSlugs,
+ );
+ } elseif ($this->hasExistingModuleMenus($module->getIdentifier())) {
+ Log::warning('모듈 메뉴 cleanup 차단 — declaration 빈 배열인데 DB row 존재 (데이터 손실 방어)', [
+ 'module' => $module->getIdentifier(),
+ ]);
+ }
}
// 3. 역할 stale 정리 (정적 getRoles + 동적 역할 병합)
@@ -3292,16 +3784,12 @@ class ModuleManager implements ModuleManagerInterface
{
$record = $this->moduleRepository->findByIdentifier($identifier);
if (! $record) {
- return [
- 'update_available' => false,
- 'update_source' => null,
- 'latest_version' => null,
- 'current_version' => null,
- ];
+ return $this->buildModuleUpdateResponse(false, null, null, null, null);
}
$currentVersion = $record->version;
$module = $this->getModule($identifier);
+ $activeRequiredCoreVersion = $module ? $module->getRequiredCoreVersion() : null;
// 1. GitHub URL이 있으면 GitHub에서 최신 버전 확인 (조회 성공 시 GitHub만 신뢰)
if ($module && $module->getGithubUrl()) {
@@ -3317,62 +3805,75 @@ class ModuleManager implements ModuleManagerInterface
}
if ($latestVersion !== null) {
- // GitHub 조회 성공 → GitHub 결과만 신뢰 (bundled 폴백 없음)
if (version_compare($latestVersion, $currentVersion, '>')) {
- return [
- 'update_available' => true,
- 'update_source' => 'github',
- 'latest_version' => $latestVersion,
- 'current_version' => $currentVersion,
- ];
+ return $this->buildModuleUpdateResponse(
+ true, 'github', $latestVersion, $currentVersion, $activeRequiredCoreVersion
+ );
}
- return [
- 'update_available' => false,
- 'update_source' => null,
- 'latest_version' => $currentVersion,
- 'current_version' => $currentVersion,
- ];
+ return $this->buildModuleUpdateResponse(
+ false, null, $currentVersion, $currentVersion, $activeRequiredCoreVersion
+ );
}
- // GitHub 조회 실패 → _bundled 폴백 안내
Log::info('모듈 업데이트 확인: GitHub 조회 실패로 bundled 폴백', [
'module' => $identifier,
]);
}
- // 2. _bundled에서 업데이트 확인 (GitHub URL 없음 OR GitHub 조회 실패)
+ // 2. _bundled에서 업데이트 확인
if (isset($this->bundledModules[$identifier])) {
$bundledVersion = $this->bundledModules[$identifier]['version'] ?? null;
+ $bundledRequired = $this->bundledModules[$identifier]['g7_version'] ?? $activeRequiredCoreVersion;
if ($bundledVersion && version_compare($bundledVersion, $currentVersion, '>')) {
- return [
- 'update_available' => true,
- 'update_source' => 'bundled',
- 'latest_version' => $bundledVersion,
- 'current_version' => $currentVersion,
- ];
+ return $this->buildModuleUpdateResponse(
+ true, 'bundled', $bundledVersion, $currentVersion, $bundledRequired
+ );
}
} else {
$bundledMeta = ExtensionPendingHelper::loadBundledExtensions($this->modulesPath, 'module.json');
if (isset($bundledMeta[$identifier])) {
$bundledVersion = $bundledMeta[$identifier]['version'] ?? null;
+ $bundledRequired = $bundledMeta[$identifier]['g7_version'] ?? $activeRequiredCoreVersion;
if ($bundledVersion && version_compare($bundledVersion, $currentVersion, '>')) {
- return [
- 'update_available' => true,
- 'update_source' => 'bundled',
- 'latest_version' => $bundledVersion,
- 'current_version' => $currentVersion,
- ];
+ return $this->buildModuleUpdateResponse(
+ true, 'bundled', $bundledVersion, $currentVersion, $bundledRequired
+ );
}
}
}
// 4. 업데이트 없음
+ return $this->buildModuleUpdateResponse(
+ false, null, $currentVersion, $currentVersion, $activeRequiredCoreVersion
+ );
+ }
+
+ /**
+ * checkModuleUpdate 응답 페이로드 빌더 (호환성 메타 부착).
+ *
+ * @param bool $updateAvailable 업데이트 가용 여부
+ * @param string|null $updateSource 업데이트 소스 (github|bundled|null)
+ * @param string|null $latestVersion 최신 버전
+ * @param string|null $currentVersion 현재 설치된 버전
+ * @param string|null $requiredCoreVersion 요구 코어 버전 제약
+ * @return array{update_available: bool, update_source: ?string, latest_version: ?string, current_version: ?string, required_core_version: ?string, is_compatible: bool, current_core_version: string}
+ */
+ protected function buildModuleUpdateResponse(
+ bool $updateAvailable,
+ ?string $updateSource,
+ ?string $latestVersion,
+ ?string $currentVersion,
+ ?string $requiredCoreVersion,
+ ): array {
return [
- 'update_available' => false,
- 'update_source' => null,
- 'latest_version' => $currentVersion,
+ 'update_available' => $updateAvailable,
+ 'update_source' => $updateSource,
+ 'latest_version' => $latestVersion,
'current_version' => $currentVersion,
+ 'required_core_version' => $requiredCoreVersion,
+ 'is_compatible' => CoreVersionChecker::isCompatible($requiredCoreVersion),
+ 'current_core_version' => CoreVersionChecker::getCoreVersion(),
];
}
@@ -3615,6 +4116,8 @@ class ModuleManager implements ModuleManagerInterface
* @param VendorMode $vendorMode vendor 설치 모드
* @param string $layoutStrategy 레이아웃 전략 ('overwrite' 또는 'keep')
* @param \Closure|null $onUpgradeStep upgrade step 실행 콜백 (인자: 버전 문자열)
+ * @param string|null $sourceOverride 업데이트 소스 강제 지정 (auto|bundled|github)
+ * @param string|null $zipPath 사전 다운로드된 zip 파일 경로
* @return array{success: bool, from_version: string|null, to_version: string|null, message: string}
*
* @throws \RuntimeException 업데이트 실패 시
@@ -3710,6 +4213,16 @@ class ModuleManager implements ModuleManagerInterface
$toVersion = $updateInfo['latest_version'];
$updateSource = $updateInfo['update_source'];
}
+
+ // 다운그레이드 차단 — fromVersion > toVersion 인 경우 force=false 면 차단.
+ // force=true 는 의도적 다운그레이드 (장애 롤백 등) 허용. lang pack 정책과 일관.
+ if ($fromVersion && version_compare($toVersion, $fromVersion, '<') && ! $force) {
+ throw new \RuntimeException(__('modules.errors.downgrade_blocked', [
+ 'from' => $fromVersion,
+ 'to' => $toVersion,
+ ]));
+ }
+
$backupPath = null;
try {
@@ -3740,6 +4253,16 @@ class ModuleManager implements ModuleManagerInterface
ExtensionPendingHelper::stageForUpdate($zipExtractedDir, $stagingPath, $onProgress);
}
+ // 3.4. 코어 버전 호환성 사전 검증 (staging manifest 기준)
+ if ($stagingPath && ! $force && ! CoreServiceProvider::isCoreUpdateInProgress()) {
+ $stagedManifest = (new Vendor\VendorIntegrityChecker)->readManifest($stagingPath);
+ CoreVersionChecker::validateExtension(
+ $stagedManifest['g7_version'] ?? null,
+ $identifier,
+ 'module'
+ );
+ }
+
// 3.5. Vendor 설치 (의존성 있는 경우만, 변경 시에만)
$resolvedVendorMode = $vendorMode;
if ($stagingPath && $this->extensionManager->hasComposerDependenciesAt($stagingPath)) {
@@ -3828,13 +4351,9 @@ class ModuleManager implements ModuleManagerInterface
'updated_at' => now(),
]);
- // Role/Permission/Menu 동기화 (있으면 업데이트) + 완전 동기화 (stale cleanup)
+ // 선언형 산출물 (역할/권한/메뉴/IDV/알림) 일괄 동기화 + stale cleanup
if ($module) {
- $this->createModuleRoles($module);
- $this->createModulePermissions($module);
- $this->assignPermissionsToRoles($module);
- $this->createModuleMenus($module);
- $this->cleanupStaleModuleEntries($module);
+ $this->syncDeclarativeArtifacts($module);
}
DB::commit();
diff --git a/app/Extension/PluginManager.php b/app/Extension/PluginManager.php
index cb1b38d4..d593c483 100644
--- a/app/Extension/PluginManager.php
+++ b/app/Extension/PluginManager.php
@@ -12,6 +12,7 @@ use App\Contracts\Repositories\PermissionRepositoryInterface;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Contracts\Repositories\RoleRepositoryInterface;
use App\Contracts\Repositories\TemplateRepositoryInterface;
+use App\Enums\DeactivationReason;
use App\Enums\ExtensionOwnerType;
use App\Enums\ExtensionStatus;
use App\Enums\LayoutSourceType;
@@ -21,8 +22,13 @@ use App\Extension\Helpers\DependencyEnricher;
use App\Extension\Helpers\ExtensionBackupHelper;
use App\Extension\Helpers\ExtensionPendingHelper;
use App\Extension\Helpers\ExtensionRoleSyncHelper;
+use App\Extension\Concerns\ResolvesExtensionSharedRecords;
+use App\Extension\Helpers\IdentityMessageSyncHelper;
+use App\Extension\Helpers\IdentityPolicySyncHelper;
+use App\Extension\Helpers\NotificationSyncHelper;
use App\Extension\Helpers\ExtensionStatusGuard;
use App\Extension\Helpers\GithubHelper;
+use App\Providers\CoreServiceProvider;
use App\Extension\Vendor\Exceptions\VendorInstallException;
use App\Extension\Vendor\VendorInstallContext;
use App\Extension\Vendor\VendorInstallResult;
@@ -45,6 +51,7 @@ use Illuminate\Support\Facades\Schema;
class PluginManager implements PluginManagerInterface
{
+ use ResolvesExtensionSharedRecords;
use Traits\CachesPluginStatus;
use Traits\ClearsTemplateCaches;
use Traits\ComputesLayoutContentHash;
@@ -281,6 +288,8 @@ class PluginManager implements PluginManagerInterface
*
* @param string $pluginName 설치할 플러그인명
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
+ * @param VendorMode $vendorMode vendor 디렉토리 처리 모드
+ * @param bool $force 강제 설치 여부
* @return bool 설치 성공 여부
*
* @throws \Exception 플러그인을 찾을 수 없거나 의존성 문제 시
@@ -427,6 +436,15 @@ class PluginManager implements PluginManagerInterface
// 권한-Role 연결
$this->assignPermissionsToRoles($plugin);
+ // IDV 정책 자동 동기화 (identity_policies 테이블)
+ $this->syncPluginIdentityPolicies($plugin);
+
+ // IDV 메시지 정의/템플릿 자동 동기화
+ $this->syncPluginIdentityMessages($plugin);
+
+ // 알림 정의/템플릿 자동 동기화 (notification_definitions / notification_templates)
+ $this->syncPluginNotificationDefinitions($plugin);
+
DB::commit();
} catch (\Exception $e) {
@@ -495,6 +513,18 @@ class PluginManager implements PluginManagerInterface
);
}
+ // 코어 버전 호환성 사전 검증 (#306 sync 훅보다 앞쪽)
+ // - force=true 시 우회 (CLI/웹 모두)
+ // - 코어 업데이트 spawn 컨텍스트에서는 매니페스트와 코어 버전이 일시적으로
+ // 어긋날 수 있어 자동 비활성화 가드와 동일 정책으로 스킵
+ if (! $force && ! CoreServiceProvider::isCoreUpdateInProgress()) {
+ CoreVersionChecker::validateExtension(
+ $plugin->getRequiredCoreVersion(),
+ $plugin->getIdentifier(),
+ 'plugin'
+ );
+ }
+
// 의존성 검증: 필요한 모듈/플러그인이 활성화되어 있는지 확인
// 중첩 구조 ['modules' => [...], 'plugins' => [...]] 를 순회
$missingModules = [];
@@ -555,6 +585,9 @@ class PluginManager implements PluginManagerInterface
if ($result) {
$this->pluginRepository->updateByIdentifier($plugin->getIdentifier(), [
'status' => ExtensionStatus::Active->value,
+ 'deactivated_reason' => null,
+ 'deactivated_at' => null,
+ 'incompatible_required_version' => null,
'updated_by' => Auth::id(),
'updated_at' => now(),
]);
@@ -624,10 +657,16 @@ class PluginManager implements PluginManagerInterface
*
* @param string $pluginName 비활성화할 플러그인명
* @param bool $force 의존 확장이 있어도 강제 비활성화 여부
+ * @param string $reason 비활성화 사유 (DeactivationReason enum value: manual|incompatible_core)
+ * @param string|null $incompatibleRequiredVersion incompatible_core 사유 시 요구된 코어 버전 제약
* @return array{success: bool, layouts_deleted: int, warning?: bool, dependent_templates?: array, dependent_modules?: array, dependent_plugins?: array, message?: string} 비활성화 결과 및 삭제된 레이아웃 개수
*/
- public function deactivatePlugin(string $pluginName, bool $force = false): array
- {
+ public function deactivatePlugin(
+ string $pluginName,
+ bool $force = false,
+ string $reason = DeactivationReason::Manual->value,
+ ?string $incompatibleRequiredVersion = null,
+ ): array {
$plugin = $this->getPlugin($pluginName);
if (! $plugin) {
return ['success' => false, 'layouts_deleted' => 0];
@@ -689,6 +728,9 @@ class PluginManager implements PluginManagerInterface
if ($result) {
$this->pluginRepository->updateByIdentifier($plugin->getIdentifier(), [
'status' => ExtensionStatus::Inactive->value,
+ 'deactivated_reason' => $reason,
+ 'deactivated_at' => now(),
+ 'incompatible_required_version' => $incompatibleRequiredVersion,
'updated_by' => Auth::id(),
'updated_at' => now(),
]);
@@ -713,6 +755,9 @@ class PluginManager implements PluginManagerInterface
// 플러그인 상태 캐시 무효화
self::invalidatePluginStatusCache();
+
+ // PO #6: 비활성화 후 훅 발행 — 언어팩 cascade 등 후속 처리
+ HookManager::doAction('core.plugins.after_deactivate', $plugin->getIdentifier());
}
$response = ['success' => $result, 'layouts_deleted' => $layoutsDeleted];
@@ -845,6 +890,45 @@ class PluginManager implements PluginManagerInterface
// PO 정책: "동적 권한은 '데이터도 함께 삭제' 옵션 체크 시에만 삭제"
if ($deleteData) {
$this->removePluginPermissions($plugin);
+
+ // IDV 정책도 data 옵션 선택 시 제거 (user_overrides 손실 허용)
+ if (\Illuminate\Support\Facades\Schema::hasTable('identity_policies')) {
+ try {
+ app(IdentityPolicySyncHelper::class)
+ ->cleanupStalePolicies('plugin', $plugin->getIdentifier(), []);
+ } catch (\Throwable $e) {
+ Log::warning('IDV 정책 정리 실패 (uninstall plugin)', [
+ 'plugin' => $plugin->getIdentifier(),
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ // IDV 메시지 정의/템플릿도 data 옵션 선택 시 제거 (FK cascade 로 templates 자동 정리)
+ if (\Illuminate\Support\Facades\Schema::hasTable('identity_message_definitions')) {
+ try {
+ app(IdentityMessageSyncHelper::class)
+ ->cleanupStaleDefinitions('plugin', $plugin->getIdentifier(), []);
+ } catch (\Throwable $e) {
+ Log::warning('IDV 메시지 정리 실패 (uninstall plugin)', [
+ 'plugin' => $plugin->getIdentifier(),
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ // 알림 정의/템플릿도 data 옵션 선택 시 제거 (FK cascade 로 templates 자동 정리)
+ if (\Illuminate\Support\Facades\Schema::hasTable('notification_definitions')) {
+ try {
+ app(NotificationSyncHelper::class)
+ ->cleanupStaleDefinitions('plugin', $plugin->getIdentifier(), []);
+ } catch (\Throwable $e) {
+ Log::warning('알림 정의 정리 실패 (uninstall plugin)', [
+ 'plugin' => $plugin->getIdentifier(),
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
}
// 플러그인 레이아웃 영구 삭제
@@ -995,6 +1079,7 @@ class PluginManager implements PluginManagerInterface
'status' => 'uninstalled',
'is_pending' => false,
'is_bundled' => false,
+ 'hidden' => $plugin->isHidden(),
'has_settings' => $plugin->hasSettings(),
'settings_route' => $plugin->getSettingsRoute(),
'assets' => $assets,
@@ -1015,6 +1100,7 @@ class PluginManager implements PluginManagerInterface
'status' => 'uninstalled',
'is_pending' => true,
'is_bundled' => false,
+ 'hidden' => (bool) ($metadata['hidden'] ?? false),
'has_settings' => false,
'settings_route' => null,
'assets' => null,
@@ -1035,6 +1121,7 @@ class PluginManager implements PluginManagerInterface
'status' => 'uninstalled',
'is_pending' => false,
'is_bundled' => true,
+ 'hidden' => (bool) ($metadata['hidden'] ?? false),
'has_settings' => false,
'settings_route' => null,
'assets' => null,
@@ -1093,12 +1180,14 @@ class PluginManager implements PluginManagerInterface
$latestVersion = $bundledVersion ?? $fileVersion;
}
+ $nameJson = $record->name ?: $plugin->getName();
+ $descriptionJson = $record->description ?: $plugin->getDescription();
$installedPlugins[$name] = [
'identifier' => $identifier,
'vendor' => $plugin->getVendor(),
- 'name' => $this->getLocalizedValue($plugin->getName(), $locale),
+ 'name' => $this->getLocalizedValue($nameJson, $locale),
'version' => $record->version,
- 'description' => $this->getLocalizedValue($plugin->getDescription(), $locale),
+ 'description' => $this->getLocalizedValue($descriptionJson, $locale),
'dependencies' => $this->enrichDependencies($plugin->getDependencies()),
'status' => $record->status,
'update_available' => $updateAvailable,
@@ -1107,6 +1196,7 @@ class PluginManager implements PluginManagerInterface
'update_source' => $record->update_source ?? null,
'github_url' => $plugin->getGithubUrl(),
'github_changelog_url' => $record->github_changelog_url ?? null,
+ 'hidden' => $plugin->isHidden(),
'has_settings' => $plugin->hasSettings(),
'settings_route' => $plugin->getSettingsRoute(),
'assets' => $assets,
@@ -1117,6 +1207,12 @@ class PluginManager implements PluginManagerInterface
return $installedPlugins;
}
+ /**
+ * 플러그인 상세 정보를 반환합니다.
+ *
+ * @param string $pluginName 플러그인명
+ * @return array|null 플러그인 정보 배열 또는 null
+ */
public function getPluginInfo(string $pluginName): ?array
{
$plugin = $this->getPlugin($pluginName);
@@ -1147,12 +1243,18 @@ class PluginManager implements PluginManagerInterface
}
}
+ // 다국어 필드는 DB row(applyExtensionManifests 가 활성 언어팩 ja 등을 주입) 우선,
+ // 미설치 시 plugin.json 폴백.
+ $pluginRecord = $this->pluginRepository->findByIdentifier($identifier);
+ $nameJson = $pluginRecord?->name ?: $plugin->getName();
+ $descriptionJson = $pluginRecord?->description ?: $plugin->getDescription();
+
return [
'identifier' => $identifier,
'vendor' => $plugin->getVendor(),
- 'name' => $this->getLocalizedValue($plugin->getName(), $locale),
+ 'name' => $this->getLocalizedValue($nameJson, $locale),
'version' => $plugin->getVersion(),
- 'description' => $this->getLocalizedValue($plugin->getDescription(), $locale),
+ 'description' => $this->getLocalizedValue($descriptionJson, $locale),
'github_url' => $plugin->getGithubUrl(),
'metadata' => $metadata,
'requires_core' => $plugin->getRequiredCoreVersion(),
@@ -1656,6 +1758,14 @@ class PluginManager implements PluginManagerInterface
protected function createPluginRoles(PluginInterface $plugin): void
{
$roles = $plugin->getRoles();
+
+ // 활성 언어팩이 plugin 의 roles 다국어 필드(name/description)에 추가 locale 을
+ // 주입할 수 있도록 필터 훅 적용 (LanguagePackSeedInjector::injectExtensionRoles 결선).
+ $roles = HookManager::applyFilters(
+ "plugin.{$plugin->getIdentifier()}.roles.translations",
+ $roles,
+ );
+
$syncHelper = $this->getRoleSyncHelper();
foreach ($roles as $role) {
@@ -1682,6 +1792,14 @@ class PluginManager implements PluginManagerInterface
{
$permissionConfig = $plugin->getPermissions();
$pluginIdentifier = $plugin->getIdentifier();
+
+ // 활성 언어팩이 권한 트리(plugin/categories/permissions 의 name/description) 에 추가
+ // locale 을 주입할 수 있도록 필터 훅 적용 (LanguagePackSeedInjector 결선).
+ $permissionConfig = HookManager::applyFilters(
+ "plugin.{$pluginIdentifier}.permissions.translations",
+ $permissionConfig,
+ );
+
$syncHelper = $this->getRoleSyncHelper();
$allIdentifiers = [];
@@ -1695,10 +1813,10 @@ class PluginManager implements PluginManagerInterface
$pluginDesc = $permissionConfig['description'] ?? $plugin->getDescription();
if (is_string($pluginName)) {
- $pluginName = ['ko' => $pluginName, 'en' => $pluginName];
+ $pluginName = array_fill_keys(config('app.translatable_locales', ['ko', 'en']), $pluginName);
}
if (is_string($pluginDesc)) {
- $pluginDesc = ['ko' => $pluginDesc, 'en' => $pluginDesc];
+ $pluginDesc = array_fill_keys(config('app.translatable_locales', ['ko', 'en']), $pluginDesc);
}
$pluginNode = $syncHelper->syncPermission(
@@ -1725,10 +1843,10 @@ class PluginManager implements PluginManagerInterface
$catName = $categoryData['name'];
$catDesc = $categoryData['description'] ?? $catName;
if (is_string($catName)) {
- $catName = ['ko' => $catName, 'en' => $catName];
+ $catName = array_fill_keys(config('app.translatable_locales', ['ko', 'en']), $catName);
}
if (is_string($catDesc)) {
- $catDesc = ['ko' => $catDesc, 'en' => $catDesc];
+ $catDesc = array_fill_keys(config('app.translatable_locales', ['ko', 'en']), $catDesc);
}
// 카테고리 type을 하위 권한의 type에서 자동 결정
@@ -1767,10 +1885,10 @@ class PluginManager implements PluginManagerInterface
$pName = $permData['name'];
$pDesc = $permData['description'] ?? $pName;
if (is_string($pName)) {
- $pName = ['ko' => $pName, 'en' => $pName];
+ $pName = array_fill_keys(config('app.translatable_locales', ['ko', 'en']), $pName);
}
if (is_string($pDesc)) {
- $pDesc = ['ko' => $pDesc, 'en' => $pDesc];
+ $pDesc = array_fill_keys(config('app.translatable_locales', ['ko', 'en']), $pDesc);
}
$permissionType = isset($permData['type'])
@@ -1894,6 +2012,330 @@ class PluginManager implements PluginManagerInterface
}
}
+ /**
+ * 플러그인이 선언한 모든 선언형 산출물을 DB 에 동기화합니다.
+ *
+ * `installPlugin` / `updatePlugin` 트랜잭션 내부에서 호출되어 플러그인 디스크 상태와 DB
+ * 를 정합 상태로 유지한다. 외부 진입점으로도 노출되어 코어 업그레이드 사후 보정
+ * (`Upgrade_7_0_0_beta_4` 등) 이나 운영자 수동 재시드 도구가 사용 가능.
+ *
+ * 동기화 대상 (플러그인은 메뉴 미지원 — getAdminMenus 부재):
+ * 1. 역할 (`getRoles`)
+ * 2. 권한 (`getPermissions`)
+ * 3. 역할-권한 매핑 (`getRolePermissions`)
+ * 4. stale cleanup (현재 선언에 없는 기존 레코드 제거)
+ * 5. IDV 정책 (`getIdentityPolicies`)
+ * 6. IDV 메시지 정의/템플릿 (`getIdentityMessages`)
+ * 7. 알림 정의/템플릿 (`getNotificationDefinitions`)
+ *
+ * 각 sync 메서드는 helper 내부의 user_overrides 보존 패턴을 따르므로 정상 환경 재호출
+ * 무해 (멱등).
+ *
+ * @param PluginInterface $plugin 대상 플러그인 인스턴스
+ */
+ public function syncDeclarativeArtifacts(PluginInterface $plugin): void
+ {
+ $this->createPluginRoles($plugin);
+ $this->createPluginPermissions($plugin);
+ $this->assignPermissionsToRoles($plugin);
+ $this->cleanupStalePluginEntries($plugin);
+ $this->syncPluginIdentityPolicies($plugin);
+ $this->syncPluginIdentityMessages($plugin);
+ $this->syncPluginNotificationDefinitions($plugin);
+ }
+
+ /**
+ * 활성 플러그인 전체를 _bundled 디렉토리에서 fresh-load 하여 declarative sync 를 일괄 호출.
+ *
+ * 코어 업그레이드 transition 사후 보정용. 이전 코어 버전(예: beta.3)의 활성 dir 에는
+ * NEW declaration 인프라(IDV/메시지/알림 등) 가 아직 도입되지 않았으므로 활성 dir
+ * fresh-load 는 declaration count 0 을 반환해 시드가 발동하지 않는다. 본 메서드는
+ * 새 코어 버전이 _bundled 로 출하한 NEW 코드를 기준으로 declaration 을 재시드해
+ * 인프라 초기화 결함을 차단한다.
+ *
+ * 신규 인프라 도입 시점의 활성 dir 에는 사용자가 거부할 수 있는 OLD declaration 자체가
+ * 없으므로 본 보정은 사용자 의지(전역 yes/no/strategy) 와 양립한다. 미래 release 에서
+ * 사용자가 플러그인 업데이트를 거부한 케이스는 정상 흐름(ExecuteBundledUpdatesCommand →
+ * updatePlugin → syncDeclarativeArtifacts) 이 자동 처리하므로 본 보정의 추가 호출은
+ * 무해 (멱등) 하다.
+ *
+ * _bundled 에 plugin.php 가 부재한 외부 설치 플러그인(GitHub 직접 설치 등) 은 skip.
+ *
+ * @return array{synced: array, skipped: array, failed: array}
+ *
+ * @since 7.0.0-beta.4
+ */
+ public function resyncAllActiveDeclarativeArtifacts(): array
+ {
+ $synced = [];
+ $skipped = [];
+ $failed = [];
+
+ foreach (self::getActivePluginIdentifiers() as $identifier) {
+ $pluginDir = $this->bundledPluginsPath.DIRECTORY_SEPARATOR.$identifier;
+ $pluginFile = $pluginDir.DIRECTORY_SEPARATOR.'plugin.php';
+
+ // _bundled 에 진입점이 없는 외부 설치 플러그인(GitHub 등) 은 skip.
+ if (! File::exists($pluginFile)) {
+ $skipped[] = $identifier;
+
+ continue;
+ }
+
+ try {
+ $namespace = $this->convertDirectoryToNamespace($identifier);
+ $pluginClass = "Plugins\\{$namespace}\\Plugin";
+
+ if (class_exists($pluginClass, false)) {
+ $plugin = $this->evalFreshPlugin($pluginFile, $pluginClass, $pluginDir);
+ } else {
+ require_once $pluginFile;
+ $plugin = class_exists($pluginClass) ? new $pluginClass : null;
+ }
+
+ if (! $plugin instanceof PluginInterface) {
+ $failed[$identifier] = 'fresh-load 실패 (클래스 미생성)';
+
+ continue;
+ }
+
+ $this->syncDeclarativeArtifacts($plugin);
+ $synced[] = $identifier;
+ } catch (\Throwable $e) {
+ $failed[$identifier] = $e->getMessage();
+ Log::channel('upgrade')->warning('[resyncAllActiveDeclarativeArtifacts] sync 실패', [
+ 'plugin' => $identifier,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ return ['synced' => $synced, 'skipped' => $skipped, 'failed' => $failed];
+ }
+
+ /**
+ * 플러그인이 선언한 IDV 정책을 `identity_policies` 테이블에 동기화합니다.
+ *
+ * `AbstractPlugin::getIdentityPolicies()` 결과를 순회하며
+ * `IdentityPolicySyncHelper::syncPolicy()` 로 upsert 하고,
+ * 현재 선언에 없는 기존 정책은 `cleanupStalePolicies()` 로 제거합니다.
+ *
+ * 운영자가 관리자 UI 에서 수정한 필드(`enabled` / `grace_minutes` / `provider_id` / `fail_mode`)
+ * 는 `user_overrides` JSON 으로 보존됩니다.
+ *
+ * @param PluginInterface $plugin IDV 정책을 동기화할 플러그인 인스턴스
+ */
+ protected function syncPluginIdentityPolicies(PluginInterface $plugin): void
+ {
+ if (! method_exists($plugin, 'getIdentityPolicies')) {
+ return;
+ }
+
+ if (! \Illuminate\Support\Facades\Schema::hasTable('identity_policies')) {
+ return; // 마이그레이션 미실행 환경 보호
+ }
+
+ $policies = $plugin->getIdentityPolicies();
+
+ // 데이터 손실 방어막 — 자세한 배경은 ModuleManager::syncModuleIdentityPolicies 의 동일 가드 주석 참조.
+ if (empty($policies)) {
+ if ($this->hasExistingIdentityPolicies('plugin', $plugin->getIdentifier())) {
+ Log::warning('IDV 정책 cleanup 차단 — declaration 빈 배열인데 DB row 존재 (데이터 손실 방어)', [
+ 'plugin' => $plugin->getIdentifier(),
+ ]);
+ }
+
+ return;
+ }
+
+ $helper = app(IdentityPolicySyncHelper::class);
+ $definedKeys = [];
+
+ foreach ($policies as $data) {
+ $data['source_type'] = 'plugin';
+ $data['source_identifier'] = $plugin->getIdentifier();
+
+ $helper->syncPolicy($data);
+ $definedKeys[] = $data['key'];
+ }
+
+ $helper->cleanupStalePolicies('plugin', $plugin->getIdentifier(), $definedKeys);
+ }
+
+ /**
+ * 플러그인이 선언한 IDV 메시지 정의/템플릿을 동기화합니다.
+ *
+ * `AbstractPlugin::getIdentityMessages()` 결과를 순회하며
+ * `IdentityMessageSyncHelper::syncDefinition()` + `syncTemplate()` 으로 upsert 하고,
+ * 현재 선언에 없는 기존 정의는 `cleanupStaleDefinitions()` 로 제거합니다.
+ *
+ * @param PluginInterface $plugin IDV 메시지를 동기화할 플러그인 인스턴스
+ */
+ protected function syncPluginIdentityMessages(PluginInterface $plugin): void
+ {
+ if (! method_exists($plugin, 'getIdentityMessages')) {
+ return;
+ }
+
+ if (! \Illuminate\Support\Facades\Schema::hasTable('identity_message_definitions')) {
+ return; // 마이그레이션 미실행 환경 보호
+ }
+
+ $messages = $plugin->getIdentityMessages();
+
+ // 언어팩 시스템: 활성 플러그인 언어팩의 seed/identity_messages.json 으로 다국어 키 병합.
+ $messages = HookManager::applyFilters(
+ "seed.{$plugin->getIdentifier()}.identity_messages.translations",
+ $messages
+ );
+
+ // 데이터 손실 방어막 — 자세한 배경은 ModuleManager::syncModuleIdentityPolicies 의 동일 가드 주석 참조.
+ if (empty($messages)) {
+ if ($this->hasExistingIdentityMessageDefinitions('plugin', $plugin->getIdentifier())) {
+ Log::warning('IDV 메시지 cleanup 차단 — declaration 빈 배열인데 DB row 존재 (데이터 손실 방어)', [
+ 'plugin' => $plugin->getIdentifier(),
+ ]);
+ }
+
+ return;
+ }
+
+ $helper = app(IdentityMessageSyncHelper::class);
+ $definedScopes = [];
+
+ foreach ($messages as $data) {
+ $data['extension_type'] = 'plugin';
+ $data['extension_identifier'] = $plugin->getIdentifier();
+
+ $definition = $helper->syncDefinition($data);
+ $definedScopes[] = [
+ 'provider_id' => $definition->provider_id,
+ 'scope_type' => $definition->scope_type->value,
+ 'scope_value' => $definition->scope_value,
+ ];
+
+ $definedChannels = [];
+ foreach ($data['templates'] ?? [] as $template) {
+ $helper->syncTemplate($definition->id, $template);
+ $definedChannels[] = $template['channel'];
+ }
+ $helper->cleanupStaleTemplates($definition->id, $definedChannels);
+ }
+
+ $helper->cleanupStaleDefinitions('plugin', $plugin->getIdentifier(), $definedScopes);
+ }
+
+ /**
+ * 플러그인이 선언한 알림 정의/템플릿을 `notification_definitions` / `notification_templates` 에 동기화합니다.
+ *
+ * `AbstractPlugin::getNotificationDefinitions()` 결과를 순회하며
+ * `NotificationSyncHelper::syncDefinition()` + `syncTemplate()` 으로 upsert 하고,
+ * 현재 선언에 없는 기존 정의는 `cleanupStaleDefinitions()` 로 제거합니다.
+ *
+ * `extension_type='plugin'`, `extension_identifier=$plugin->getIdentifier()` 는 자동 주입.
+ * 운영자 user_overrides 보존은 helper 내부 trait 가 처리.
+ *
+ * @param PluginInterface $plugin 알림 정의를 동기화할 플러그인 인스턴스
+ */
+ protected function syncPluginNotificationDefinitions(PluginInterface $plugin): void
+ {
+ if (! method_exists($plugin, 'getNotificationDefinitions')) {
+ return;
+ }
+
+ if (! \Illuminate\Support\Facades\Schema::hasTable('notification_definitions')) {
+ return; // 마이그레이션 미실행 환경 보호
+ }
+
+ $definitions = $plugin->getNotificationDefinitions();
+
+ // 언어팩 시스템: 활성 플러그인 언어팩의 seed/notifications.json 으로 다국어 키 병합.
+ $definitions = HookManager::applyFilters(
+ "seed.{$plugin->getIdentifier()}.notifications.translations",
+ $definitions
+ );
+
+ // 데이터 손실 방어막 — 자세한 배경은 ModuleManager::syncModuleIdentityPolicies 의 동일 가드 주석 참조.
+ if (empty($definitions)) {
+ if ($this->hasExistingNotificationDefinitions('plugin', $plugin->getIdentifier())) {
+ Log::warning('알림 정의 cleanup 차단 — declaration 빈 배열인데 DB row 존재 (데이터 손실 방어)', [
+ 'plugin' => $plugin->getIdentifier(),
+ ]);
+ }
+
+ return;
+ }
+
+ $helper = app(NotificationSyncHelper::class);
+ $definedTypes = [];
+
+ foreach ($definitions as $data) {
+ $data['extension_type'] = 'plugin';
+ $data['extension_identifier'] = $plugin->getIdentifier();
+
+ $definition = $helper->syncDefinition($data);
+ $definedTypes[] = $definition->type;
+
+ $definedChannels = [];
+ foreach ($data['templates'] ?? [] as $template) {
+ $helper->syncTemplate($definition->id, $template);
+ $definedChannels[] = $template['channel'];
+ }
+ $helper->cleanupStaleTemplates($definition->id, $definedChannels);
+ }
+
+ $helper->cleanupStaleDefinitions('plugin', $plugin->getIdentifier(), $definedTypes);
+ }
+
+ /**
+ * 해당 source 가 기존에 등록한 IDV 정책이 있는지 확인합니다.
+ *
+ * @param string $sourceType
+ * @param string $sourceIdentifier
+ * @return bool
+ */
+ protected function hasExistingIdentityPolicies(string $sourceType, string $sourceIdentifier): bool
+ {
+ try {
+ return \Illuminate\Support\Facades\DB::table('identity_policies')
+ ->where('source_type', $sourceType)
+ ->where('source_identifier', $sourceIdentifier)
+ ->exists();
+ } catch (\Throwable) {
+ return false;
+ }
+ }
+
+ /**
+ * 해당 source 가 기존에 등록한 IDV 메시지 정의가 있는지 확인합니다 (데이터 손실 방어용).
+ */
+ protected function hasExistingIdentityMessageDefinitions(string $extensionType, string $extensionIdentifier): bool
+ {
+ try {
+ return \Illuminate\Support\Facades\DB::table('identity_message_definitions')
+ ->where('extension_type', $extensionType)
+ ->where('extension_identifier', $extensionIdentifier)
+ ->exists();
+ } catch (\Throwable) {
+ return false;
+ }
+ }
+
+ /**
+ * 해당 source 가 기존에 등록한 알림 정의가 있는지 확인합니다 (데이터 손실 방어용).
+ */
+ protected function hasExistingNotificationDefinitions(string $extensionType, string $extensionIdentifier): bool
+ {
+ try {
+ return \Illuminate\Support\Facades\DB::table('notification_definitions')
+ ->where('extension_type', $extensionType)
+ ->where('extension_identifier', $extensionIdentifier)
+ ->exists();
+ } catch (\Throwable) {
+ return false;
+ }
+ }
+
/**
* 플러그인의 시더를 실행합니다.
*
@@ -2155,11 +2597,15 @@ class PluginManager implements PluginManagerInterface
$totalTableSize = array_sum(array_column($tablesInfo, 'size_bytes'));
$totalStorageSize = array_sum(array_column($storageInfo, 'size_bytes'));
+ // 코어 공유 테이블에 적재된 이 플러그인의 데이터 (deleteData=true 시 정리 대상)
+ $sharedRecords = $this->resolveExtensionSharedRecords('plugin', $identifier);
+
return [
'tables' => $tablesInfo,
'storage_directories' => $storageInfo,
'vendor_directory' => $vendorInfo,
'extension_directory' => $extensionDirInfo,
+ 'shared_records' => $sharedRecords,
'total_table_size_bytes' => $totalTableSize,
'total_table_size_formatted' => $this->formatBytes($totalTableSize),
'total_storage_size_bytes' => $totalStorageSize,
@@ -3493,16 +3939,13 @@ class PluginManager implements PluginManagerInterface
{
$record = $this->pluginRepository->findByIdentifier($identifier);
if (! $record) {
- return [
- 'update_available' => false,
- 'update_source' => null,
- 'latest_version' => null,
- 'current_version' => null,
- ];
+ return $this->buildPluginUpdateResponse(false, null, null, null, null);
}
$currentVersion = $record->version;
$plugin = $this->getPlugin($identifier);
+ // 활성 manifest 의 g7_version (github 경로 베스트 이펙트 + 폴백)
+ $activeRequiredCoreVersion = $plugin ? $plugin->getRequiredCoreVersion() : null;
// 1. GitHub URL이 있으면 GitHub에서 최신 버전 확인 (조회 성공 시 GitHub만 신뢰)
if ($plugin && $plugin->getGithubUrl()) {
@@ -3519,21 +3962,25 @@ class PluginManager implements PluginManagerInterface
if ($latestVersion !== null) {
// GitHub 조회 성공 → GitHub 결과만 신뢰 (bundled 폴백 없음)
+ // GitHub 경로는 release manifest 미다운로드 → 현재 활성 manifest 의 g7_version 으로
+ // 베스트 이펙트 호환성 판정. 정확한 판정은 staging 단계 (A.1) 에서 수행됨.
if (version_compare($latestVersion, $currentVersion, '>')) {
- return [
- 'update_available' => true,
- 'update_source' => 'github',
- 'latest_version' => $latestVersion,
- 'current_version' => $currentVersion,
- ];
+ return $this->buildPluginUpdateResponse(
+ true,
+ 'github',
+ $latestVersion,
+ $currentVersion,
+ $activeRequiredCoreVersion
+ );
}
- return [
- 'update_available' => false,
- 'update_source' => null,
- 'latest_version' => $currentVersion,
- 'current_version' => $currentVersion,
- ];
+ return $this->buildPluginUpdateResponse(
+ false,
+ null,
+ $currentVersion,
+ $currentVersion,
+ $activeRequiredCoreVersion
+ );
}
// GitHub 조회 실패 → _bundled 폴백 안내
@@ -3545,35 +3992,70 @@ class PluginManager implements PluginManagerInterface
// 2. _bundled에서 업데이트 확인 (GitHub URL 없음 OR GitHub 조회 실패)
if (isset($this->bundledPlugins[$identifier])) {
$bundledVersion = $this->bundledPlugins[$identifier]['version'] ?? null;
+ $bundledRequired = $this->bundledPlugins[$identifier]['g7_version'] ?? $activeRequiredCoreVersion;
if ($bundledVersion && version_compare($bundledVersion, $currentVersion, '>')) {
- return [
- 'update_available' => true,
- 'update_source' => 'bundled',
- 'latest_version' => $bundledVersion,
- 'current_version' => $currentVersion,
- ];
+ return $this->buildPluginUpdateResponse(
+ true,
+ 'bundled',
+ $bundledVersion,
+ $currentVersion,
+ $bundledRequired
+ );
}
} else {
$bundledMeta = ExtensionPendingHelper::loadBundledExtensions($this->pluginsPath, 'plugin.json');
if (isset($bundledMeta[$identifier])) {
$bundledVersion = $bundledMeta[$identifier]['version'] ?? null;
+ $bundledRequired = $bundledMeta[$identifier]['g7_version'] ?? $activeRequiredCoreVersion;
if ($bundledVersion && version_compare($bundledVersion, $currentVersion, '>')) {
- return [
- 'update_available' => true,
- 'update_source' => 'bundled',
- 'latest_version' => $bundledVersion,
- 'current_version' => $currentVersion,
- ];
+ return $this->buildPluginUpdateResponse(
+ true,
+ 'bundled',
+ $bundledVersion,
+ $currentVersion,
+ $bundledRequired
+ );
}
}
}
// 4. 업데이트 없음
+ return $this->buildPluginUpdateResponse(
+ false,
+ null,
+ $currentVersion,
+ $currentVersion,
+ $activeRequiredCoreVersion
+ );
+ }
+
+ /**
+ * checkPluginUpdate 응답 페이로드 빌더.
+ *
+ * 호환성 메타 (required_core_version / is_compatible / current_core_version) 을 일관되게 부착합니다.
+ *
+ * @param bool $updateAvailable 업데이트 가용 여부
+ * @param string|null $updateSource 업데이트 소스 (github|bundled|null)
+ * @param string|null $latestVersion 최신 버전
+ * @param string|null $currentVersion 현재 설치된 버전
+ * @param string|null $requiredCoreVersion 요구 코어 버전 제약 (null 이면 호환으로 간주)
+ * @return array{update_available: bool, update_source: ?string, latest_version: ?string, current_version: ?string, required_core_version: ?string, is_compatible: bool, current_core_version: string}
+ */
+ protected function buildPluginUpdateResponse(
+ bool $updateAvailable,
+ ?string $updateSource,
+ ?string $latestVersion,
+ ?string $currentVersion,
+ ?string $requiredCoreVersion,
+ ): array {
return [
- 'update_available' => false,
- 'update_source' => null,
- 'latest_version' => $currentVersion,
+ 'update_available' => $updateAvailable,
+ 'update_source' => $updateSource,
+ 'latest_version' => $latestVersion,
'current_version' => $currentVersion,
+ 'required_core_version' => $requiredCoreVersion,
+ 'is_compatible' => CoreVersionChecker::isCompatible($requiredCoreVersion),
+ 'current_core_version' => CoreVersionChecker::getCoreVersion(),
];
}
@@ -3813,6 +4295,8 @@ class PluginManager implements PluginManagerInterface
* @param VendorMode $vendorMode vendor 설치 모드
* @param string $layoutStrategy 레이아웃 전략 ('overwrite' 또는 'keep')
* @param \Closure|null $onUpgradeStep upgrade step 실행 콜백 (인자: 버전 문자열)
+ * @param string|null $sourceOverride 업데이트 소스 강제 지정 (auto|bundled|github)
+ * @param string|null $zipPath 사전 다운로드된 zip 파일 경로
* @return array{success: bool, from_version: string|null, to_version: string|null, message: string}
*
* @throws \RuntimeException 업데이트 실패 시
@@ -3906,6 +4390,16 @@ class PluginManager implements PluginManagerInterface
$toVersion = $updateInfo['latest_version'];
$updateSource = $updateInfo['update_source'];
}
+
+ // 다운그레이드 차단 — fromVersion > toVersion 인 경우 force=false 면 차단.
+ // force=true 는 의도적 다운그레이드 (장애 롤백 등) 허용. lang pack/모듈과 일관.
+ if ($fromVersion && version_compare($toVersion, $fromVersion, '<') && ! $force) {
+ throw new \RuntimeException(__('plugins.errors.downgrade_blocked', [
+ 'from' => $fromVersion,
+ 'to' => $toVersion,
+ ]));
+ }
+
$backupPath = null;
try {
@@ -3936,6 +4430,19 @@ class PluginManager implements PluginManagerInterface
ExtensionPendingHelper::stageForUpdate($zipExtractedDir, $stagingPath, $onProgress);
}
+ // 3.4. 코어 버전 호환성 사전 검증 (staging manifest 기준)
+ // - 백업은 이미 생성됐으므로 비호환 시 finally 에서 staging 정리됨
+ // - 외부 ZIP / GitHub / _bundled 모두 staging manifest 가 SSoT
+ // - force=true 또는 코어 업데이트 spawn 컨텍스트에서는 스킵
+ if ($stagingPath && ! $force && ! CoreServiceProvider::isCoreUpdateInProgress()) {
+ $stagedManifest = (new Vendor\VendorIntegrityChecker)->readManifest($stagingPath);
+ CoreVersionChecker::validateExtension(
+ $stagedManifest['g7_version'] ?? null,
+ $identifier,
+ 'plugin'
+ );
+ }
+
// 3.5. Vendor 설치 (의존성 있는 경우만, 변경 시에만)
$resolvedVendorMode = $vendorMode;
if ($stagingPath && $this->extensionManager->hasComposerDependenciesAt($stagingPath)) {
@@ -4024,11 +4531,9 @@ class PluginManager implements PluginManagerInterface
]);
// Role/Permission 동기화 (있으면 업데이트) + 완전 동기화 (stale cleanup)
+ // 선언형 산출물 (역할/권한/IDV/알림) 일괄 동기화 + stale cleanup
if ($plugin) {
- $this->createPluginRoles($plugin);
- $this->createPluginPermissions($plugin);
- $this->assignPermissionsToRoles($plugin);
- $this->cleanupStalePluginEntries($plugin);
+ $this->syncDeclarativeArtifacts($plugin);
}
DB::commit();
diff --git a/app/Extension/Storage/ModuleStorageDriver.php b/app/Extension/Storage/ModuleStorageDriver.php
index 39af9462..17c80b82 100644
--- a/app/Extension/Storage/ModuleStorageDriver.php
+++ b/app/Extension/Storage/ModuleStorageDriver.php
@@ -53,12 +53,33 @@ class ModuleStorageDriver implements StorageInterface
return "{$basePath}/{$path}";
}
+ /**
+ * 모듈 storage 루트에 `.preserve-ownership` 마커가 없으면 작성합니다 (멱등).
+ *
+ * 코어 update 의 `FilePermissionHelper::chownRecursiveDetailed` 가
+ * `respectPreservationMarker=true` 로 호출되면 본 마커가 있는 디렉토리 서브트리
+ * 전체를 chown 비대상으로 자동 skip → 시드 시점 owner/perms 영구 보존.
+ */
+ private function ensurePreservationMarker(): void
+ {
+ $markerPath = "{$this->identifier}/.preserve-ownership";
+ if (Storage::disk($this->disk)->exists($markerPath)) {
+ return;
+ }
+
+ Storage::disk($this->disk)->put(
+ $markerPath,
+ "# G7 preservation marker\n# 코어 update 의 chownRecursive 가 본 디렉토리 트리를 자동 skip 합니다.\n"
+ );
+ }
+
/**
* {@inheritDoc}
*/
public function put(string $category, string $path, mixed $content): bool
{
$fullPath = $this->resolvePath($category, $path);
+ $this->ensurePreservationMarker();
return Storage::disk($this->disk)->put($fullPath, $content);
}
diff --git a/app/Extension/Storage/PluginStorageDriver.php b/app/Extension/Storage/PluginStorageDriver.php
index 7e6b943d..4a892b48 100644
--- a/app/Extension/Storage/PluginStorageDriver.php
+++ b/app/Extension/Storage/PluginStorageDriver.php
@@ -53,12 +53,33 @@ class PluginStorageDriver implements StorageInterface
return "{$basePath}/{$path}";
}
+ /**
+ * 플러그인 storage 루트에 `.preserve-ownership` 마커가 없으면 작성합니다 (멱등).
+ *
+ * 코어 update 의 `FilePermissionHelper::chownRecursiveDetailed` 가
+ * `respectPreservationMarker=true` 로 호출되면 본 마커가 있는 디렉토리 서브트리
+ * 전체를 chown 비대상으로 자동 skip → 시드 시점 owner/perms 영구 보존.
+ */
+ private function ensurePreservationMarker(): void
+ {
+ $markerPath = "{$this->identifier}/.preserve-ownership";
+ if (Storage::disk($this->disk)->exists($markerPath)) {
+ return;
+ }
+
+ Storage::disk($this->disk)->put(
+ $markerPath,
+ "# G7 preservation marker\n# 코어 update 의 chownRecursive 가 본 디렉토리 트리를 자동 skip 합니다.\n"
+ );
+ }
+
/**
* {@inheritDoc}
*/
public function put(string $category, string $path, mixed $content): bool
{
$fullPath = $this->resolvePath($category, $path);
+ $this->ensurePreservationMarker();
return Storage::disk($this->disk)->put($fullPath, $content);
}
diff --git a/app/Extension/TemplateManager.php b/app/Extension/TemplateManager.php
index 02c930cd..04c2233f 100644
--- a/app/Extension/TemplateManager.php
+++ b/app/Extension/TemplateManager.php
@@ -7,12 +7,14 @@ use App\Contracts\Repositories\LayoutRepositoryInterface;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Contracts\Repositories\TemplateRepositoryInterface;
+use App\Enums\DeactivationReason;
use App\Enums\ExtensionStatus;
use App\Enums\LayoutSourceType;
use App\Extension\Helpers\ExtensionBackupHelper;
use App\Extension\Helpers\ExtensionPendingHelper;
use App\Extension\Helpers\ExtensionStatusGuard;
use App\Extension\Helpers\GithubHelper;
+use App\Providers\CoreServiceProvider;
use App\Services\LayoutExtensionService;
use App\Services\LayoutService;
use App\Services\TemplateService;
@@ -374,6 +376,7 @@ class TemplateManager implements TemplateManagerInterface
*
* @param string $templateName 설치할 템플릿명 (identifier)
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
+ * @param bool $force Updating/Failed 등 진행 중 상태도 무시하고 강제 설치 여부
* @return bool 설치 성공 여부
*
* @throws \Exception 템플릿을 찾을 수 없거나 의존성 문제 시
@@ -495,6 +498,15 @@ class TemplateManager implements TemplateManagerInterface
throw new \Exception(__('templates.errors.already_active'));
}
+ // 코어 버전 호환성 사전 검증
+ if (! $force && ! CoreServiceProvider::isCoreUpdateInProgress()) {
+ CoreVersionChecker::validateExtension(
+ $template['g7_version'] ?? null,
+ $templateName,
+ 'template'
+ );
+ }
+
// 의존성 검증: 필요한 모듈/플러그인이 활성화되어 있는지 확인
$missingModules = [];
$missingPlugins = [];
@@ -560,6 +572,9 @@ class TemplateManager implements TemplateManagerInterface
// 데이터베이스 상태 업데이트
$this->templateRepository->updateByIdentifier($templateName, [
'status' => ExtensionStatus::Active->value,
+ 'deactivated_reason' => null,
+ 'deactivated_at' => null,
+ 'incompatible_required_version' => null,
'updated_at' => now(),
]);
@@ -617,10 +632,15 @@ class TemplateManager implements TemplateManagerInterface
* 지정된 템플릿을 비활성화합니다.
*
* @param string $templateName 비활성화할 템플릿명 (identifier)
+ * @param string $reason 비활성화 사유 (DeactivationReason enum value: manual|incompatible_core)
+ * @param string|null $incompatibleRequiredVersion incompatible_core 사유 시 요구된 코어 버전 제약
* @return bool 비활성화 성공 여부
*/
- public function deactivateTemplate(string $templateName): bool
- {
+ public function deactivateTemplate(
+ string $templateName,
+ string $reason = DeactivationReason::Manual->value,
+ ?string $incompatibleRequiredVersion = null,
+ ): bool {
$template = $this->getTemplate($templateName);
if (! $template) {
return false;
@@ -640,6 +660,9 @@ class TemplateManager implements TemplateManagerInterface
$this->templateRepository->updateByIdentifier($templateName, [
'status' => ExtensionStatus::Inactive->value,
+ 'deactivated_reason' => $reason,
+ 'deactivated_at' => now(),
+ 'incompatible_required_version' => $incompatibleRequiredVersion,
'updated_at' => now(),
]);
@@ -649,6 +672,9 @@ class TemplateManager implements TemplateManagerInterface
// 확장 캐시 버전 증가 (프론트엔드가 새로운 캐시로 요청하도록)
$this->incrementExtensionCacheVersion();
+ // PO #6: 비활성화 후 훅 발행 — 언어팩 cascade 등 후속 처리
+ HookManager::doAction('core.templates.after_deactivate', $templateName);
+
Log::info(__('templates.messages.template_deactivated'), [
'template' => $templateName,
]);
@@ -790,6 +816,7 @@ class TemplateManager implements TemplateManagerInterface
'dependencies' => $template['dependencies'] ?? [],
'status' => 'uninstalled',
'source' => 'active',
+ 'hidden' => (bool) ($template['hidden'] ?? false),
];
}
}
@@ -809,6 +836,7 @@ class TemplateManager implements TemplateManagerInterface
'dependencies' => $metadata['dependencies'] ?? [],
'status' => 'uninstalled',
'source' => 'pending',
+ 'hidden' => (bool) ($metadata['hidden'] ?? false),
];
}
}
@@ -828,6 +856,7 @@ class TemplateManager implements TemplateManagerInterface
'dependencies' => $metadata['dependencies'] ?? [],
'status' => 'uninstalled',
'source' => 'bundled',
+ 'hidden' => (bool) ($metadata['hidden'] ?? false),
];
}
}
@@ -882,6 +911,7 @@ class TemplateManager implements TemplateManagerInterface
'update_source' => $record->update_source ?? null,
'github_url' => $template['github_url'] ?? ($record->github_url ?? null),
'github_changelog_url' => $record->github_changelog_url ?? ($template['github_changelog_url'] ?? null),
+ 'hidden' => (bool) ($template['hidden'] ?? false),
'user_modified_at' => $record->user_modified_at,
'created_at' => $record->created_at,
'updated_at' => $record->updated_at,
@@ -891,7 +921,14 @@ class TemplateManager implements TemplateManagerInterface
return $installedTemplates;
}
-
+ /**
+ * 템플릿 식별자에 대한 메타데이터(다국어 치환 + 활성 상태 포함)를 조회합니다.
+ *
+ * 활성 디렉토리에 존재하지 않으면 pending/bundled 메타데이터로 폴백합니다.
+ *
+ * @param string $templateName 템플릿 식별자
+ * @return array|null 템플릿 메타데이터 (이름·설명·버전·상태 등) 또는 부재 시 null
+ */
public function getTemplateInfo(string $templateName): ?array
{
$template = $this->getTemplate($templateName);
@@ -913,15 +950,20 @@ class TemplateManager implements TemplateManagerInterface
// 컴포넌트 정보 조회
$components = $this->getTemplateComponents($templateName);
+ // 다국어 필드는 DB row(applyExtensionManifests 가 활성 언어팩 ja 등을 주입) 우선,
+ // 미설치 시 template.json 폴백.
+ $nameJson = $templateRecord?->name ?: $template['name'];
+ $descriptionJson = $templateRecord?->description ?: ($template['description'] ?? '');
+
return [
'identifier' => $template['identifier'],
'vendor' => $template['vendor'],
- 'name' => $this->getLocalizedValue($template['name'], $locale),
+ 'name' => $this->getLocalizedValue($nameJson, $locale),
'version' => $template['version'],
'latest_version' => $template['latest_version'] ?? null,
'update_available' => $template['update_available'] ?? false,
'type' => $template['type'],
- 'description' => $this->getLocalizedValue($template['description'] ?? '', $locale),
+ 'description' => $this->getLocalizedValue($descriptionJson, $locale),
'github_url' => $template['github_url'] ?? null,
'github_changelog_url' => $template['github_changelog_url'] ?? null,
'requires_core' => $template['g7_version'] ?? null,
@@ -1911,6 +1953,7 @@ class TemplateManager implements TemplateManagerInterface
* DB에 저장된 레이아웃을 최신 파일 내용으로 갱신합니다.
*
* @param string $identifier 템플릿 식별자
+ * @param bool $preserveModified 운영자가 관리자 UI 에서 수정한 레이아웃은 덮어쓰지 않을지 여부 (true 면 user_overrides 보존)
* @return array{success: bool, layouts_refreshed: int} 갱신 결과 및 갱신된 레이아웃 개수
*
* @throws \Exception 템플릿을 찾을 수 없거나 레이아웃 갱신 실패 시
@@ -2525,16 +2568,12 @@ class TemplateManager implements TemplateManagerInterface
{
$record = $this->templateRepository->findByIdentifier($identifier);
if (! $record) {
- return [
- 'update_available' => false,
- 'update_source' => null,
- 'latest_version' => null,
- 'current_version' => null,
- ];
+ return $this->buildTemplateUpdateResponse(false, null, null, null, null);
}
$currentVersion = $record->version;
$template = $this->getTemplate($identifier);
+ $activeRequiredCoreVersion = $template['g7_version'] ?? null;
// 1. GitHub URL이 있으면 GitHub에서 최신 버전 확인 (조회 성공 시 GitHub만 신뢰)
$githubUrl = $template['github_url'] ?? ($record->github_url ?? null);
@@ -2551,61 +2590,74 @@ class TemplateManager implements TemplateManagerInterface
}
if ($latestVersion !== null) {
- // GitHub 조회 성공 → GitHub 결과만 신뢰 (bundled 폴백 없음)
if (version_compare($latestVersion, $currentVersion, '>')) {
- return [
- 'update_available' => true,
- 'update_source' => 'github',
- 'latest_version' => $latestVersion,
- 'current_version' => $currentVersion,
- ];
+ return $this->buildTemplateUpdateResponse(
+ true, 'github', $latestVersion, $currentVersion, $activeRequiredCoreVersion
+ );
}
- return [
- 'update_available' => false,
- 'update_source' => null,
- 'latest_version' => $currentVersion,
- 'current_version' => $currentVersion,
- ];
+ return $this->buildTemplateUpdateResponse(
+ false, null, $currentVersion, $currentVersion, $activeRequiredCoreVersion
+ );
}
- // GitHub 조회 실패 → _bundled 폴백 안내
Log::info('템플릿 업데이트 확인: GitHub 조회 실패로 bundled 폴백', [
'template' => $identifier,
]);
}
- // 2. _bundled에서 업데이트 확인 (GitHub URL 없음 OR GitHub 조회 실패)
+ // 2. _bundled에서 업데이트 확인
if (isset($this->bundledTemplates[$identifier])) {
$bundledVersion = $this->bundledTemplates[$identifier]['version'] ?? null;
+ $bundledRequired = $this->bundledTemplates[$identifier]['g7_version'] ?? $activeRequiredCoreVersion;
if ($bundledVersion && version_compare($bundledVersion, $currentVersion, '>')) {
- return [
- 'update_available' => true,
- 'update_source' => 'bundled',
- 'latest_version' => $bundledVersion,
- 'current_version' => $currentVersion,
- ];
+ return $this->buildTemplateUpdateResponse(
+ true, 'bundled', $bundledVersion, $currentVersion, $bundledRequired
+ );
}
} else {
$bundledMeta = ExtensionPendingHelper::loadBundledExtensions($this->templatesPath, 'template.json');
if (isset($bundledMeta[$identifier])) {
$bundledVersion = $bundledMeta[$identifier]['version'] ?? null;
+ $bundledRequired = $bundledMeta[$identifier]['g7_version'] ?? $activeRequiredCoreVersion;
if ($bundledVersion && version_compare($bundledVersion, $currentVersion, '>')) {
- return [
- 'update_available' => true,
- 'update_source' => 'bundled',
- 'latest_version' => $bundledVersion,
- 'current_version' => $currentVersion,
- ];
+ return $this->buildTemplateUpdateResponse(
+ true, 'bundled', $bundledVersion, $currentVersion, $bundledRequired
+ );
}
}
}
+ return $this->buildTemplateUpdateResponse(
+ false, null, $currentVersion, $currentVersion, $activeRequiredCoreVersion
+ );
+ }
+
+ /**
+ * checkTemplateUpdate 응답 페이로드 빌더 (호환성 메타 부착).
+ *
+ * @param bool $updateAvailable 업데이트 가용 여부
+ * @param string|null $updateSource 업데이트 소스 (github|bundled|null)
+ * @param string|null $latestVersion 최신 버전
+ * @param string|null $currentVersion 현재 설치된 버전
+ * @param string|null $requiredCoreVersion 요구 코어 버전 제약
+ * @return array{update_available: bool, update_source: ?string, latest_version: ?string, current_version: ?string, required_core_version: ?string, is_compatible: bool, current_core_version: string}
+ */
+ protected function buildTemplateUpdateResponse(
+ bool $updateAvailable,
+ ?string $updateSource,
+ ?string $latestVersion,
+ ?string $currentVersion,
+ ?string $requiredCoreVersion,
+ ): array {
return [
- 'update_available' => false,
- 'update_source' => null,
- 'latest_version' => $currentVersion,
+ 'update_available' => $updateAvailable,
+ 'update_source' => $updateSource,
+ 'latest_version' => $latestVersion,
'current_version' => $currentVersion,
+ 'required_core_version' => $requiredCoreVersion,
+ 'is_compatible' => CoreVersionChecker::isCompatible($requiredCoreVersion),
+ 'current_core_version' => CoreVersionChecker::getCoreVersion(),
];
}
@@ -2788,6 +2840,8 @@ class TemplateManager implements TemplateManagerInterface
* @param bool $force 버전 비교 없이 강제 업데이트
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
* @param string $layoutStrategy 레이아웃 전략 ('overwrite' 또는 'keep')
+ * @param string|null $sourceOverride 소스 강제 지정 ('bundled' | 'github'). null 이면 자동 감지
+ * @param string|null $zipPath 사용자가 업로드한 ZIP 경로 (sourceOverride 가 'zip' 일 때 필수)
* @return array{success: bool, from_version: string|null, to_version: string|null, message: string}
*
* @throws \RuntimeException 업데이트 실패 시
@@ -2869,6 +2923,16 @@ class TemplateManager implements TemplateManagerInterface
$toVersion = $updateInfo['latest_version'];
$updateSource = $updateInfo['update_source'];
}
+
+ // 다운그레이드 차단 — fromVersion > toVersion 인 경우 force=false 면 차단.
+ // force=true 는 의도적 다운그레이드 (장애 롤백 등) 허용. lang pack/모듈/플러그인과 일관.
+ if ($fromVersion && version_compare($toVersion, $fromVersion, '<') && ! $force) {
+ throw new \RuntimeException(__('templates.errors.downgrade_blocked', [
+ 'from' => $fromVersion,
+ 'to' => $toVersion,
+ ]));
+ }
+
$backupPath = null;
try {
@@ -2901,6 +2965,16 @@ class TemplateManager implements TemplateManagerInterface
ExtensionPendingHelper::stageForUpdate($zipExtractedDir, $stagingPath, $onProgress);
}
+ // 3.5. 코어 버전 호환성 사전 검증 (staging manifest 기준)
+ if ($stagingPath && ! $force && ! CoreServiceProvider::isCoreUpdateInProgress()) {
+ $stagedManifest = (new Vendor\VendorIntegrityChecker)->readManifest($stagingPath);
+ CoreVersionChecker::validateExtension(
+ $stagedManifest['g7_version'] ?? null,
+ $identifier,
+ 'template'
+ );
+ }
+
// 4. 원자적 적용 (스테이징 → 활성 디렉토리)
$onProgress?->__invoke('files', '파일 교체 중...');
if ($stagingPath) {
diff --git a/app/Helpers/ResponseHelper.php b/app/Helpers/ResponseHelper.php
index 44e97e7c..e7114513 100644
--- a/app/Helpers/ResponseHelper.php
+++ b/app/Helpers/ResponseHelper.php
@@ -6,7 +6,6 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Support\Facades\App;
-use Illuminate\Support\Facades\Auth;
class ResponseHelper
{
@@ -208,47 +207,19 @@ class ResponseHelper
}
/**
- * 사용자의 언어 설정을 가져옵니다.
+ * 응답에 사용할 언어 코드를 반환합니다.
*
- * @return string 사용자 언어 코드 (ko, en)
+ * SetLocale 미들웨어가 이미 사용자 언어 우선순위
+ * (1) 인증 사용자의 users.language → (2) Accept-Language 헤더(localStorage.g7_locale 포함)
+ * → (3) config('app.locale') fallback
+ * 를 적용해 App::setLocale() 한 결과를 신뢰합니다. supported_locales 화이트리스트
+ * (활성 코어 언어팩 기반) 검증도 SetLocale 에서 처리되므로 본 메서드는 그 결과만 반환합니다.
+ *
+ * @return string 현재 요청에 적용된 언어 코드
*/
private static function getUserLocale(): string
{
- // 인증된 사용자의 언어 설정 확인
- if (Auth::check() && Auth::user()->language) {
- return Auth::user()->language;
- }
-
- // 헤더에서 언어 설정 확인
- $acceptLanguage = request()->header('Accept-Language');
- if ($acceptLanguage) {
- $locale = self::parseAcceptLanguage($acceptLanguage);
- if (in_array($locale, ['ko', 'en'])) {
- return $locale;
- }
- }
-
- // 기본값 반환
- return config('app.locale', 'ko');
- }
-
- /**
- * Accept-Language HTTP 헤더를 파싱합니다.
- *
- * @param string $acceptLanguage Accept-Language 헤더 값
- * @return string 파싱된 언어 코드
- */
- private static function parseAcceptLanguage(string $acceptLanguage): string
- {
- $languages = explode(',', $acceptLanguage);
- $firstLanguage = trim($languages[0]);
-
- // 언어-지역 형태에서 언어만 추출 (예: ko-KR -> ko)
- if (strpos($firstLanguage, '-') !== false) {
- return explode('-', $firstLanguage)[0];
- }
-
- return $firstLanguage;
+ return App::getLocale();
}
/**
@@ -455,4 +426,24 @@ class ResponseHelper
return response()->json($response);
}
+
+ /**
+ * 본인인증 요구 응답을 생성합니다 (HTTP 428 Precondition Required).
+ *
+ * IDV 정책 미들웨어/Listener 가 IdentityVerificationRequiredException 을 던지면
+ * Handler 가 이 메서드로 응답을 만듭니다. 프론트 ErrorHandlingResolver 가 이 payload 로
+ * Challenge 모달을 자동 오픈하고 verify 성공 시 return_request 를 재실행합니다.
+ *
+ * @param array $verification policy_key/purpose/provider_id/render_hint/return_request 등
+ * @return JsonResponse 428 응답
+ */
+ public static function identityRequired(array $verification): JsonResponse
+ {
+ return response()->json([
+ 'success' => false,
+ 'error_code' => 'identity_verification_required',
+ 'message' => self::trans('identity.errors.verification_required', [], 'core'),
+ 'verification' => $verification,
+ ], 428);
+ }
}
diff --git a/app/Helpers/locale_helpers.php b/app/Helpers/locale_helpers.php
new file mode 100644
index 00000000..f956c217
--- /dev/null
+++ b/app/Helpers/locale_helpers.php
@@ -0,0 +1,184 @@
+..., 'en'=>...]` + `fallbackKey: 'sirsoft-ecommerce::settings.countries.KR.name'`
+ * - registry payload (시스템 정의): `nameKey: 'notification.channels.mail.name'`
+ *
+ * @since 7.0.0-beta.4
+ */
+
+if (! function_exists('localized_label')) {
+ /**
+ * 다국어 라벨을 활성 locale 기준으로 해석합니다.
+ *
+ * 우선순위: nameKey __() > value[locale] > fallbackKey __() > value[fallback_locale] > value 첫 키
+ *
+ * @param array|null $value ['ko' => ..., 'en' => ...] 형태 (settings JSON)
+ * @param string|null $nameKey lang key 직접 선언 (registry payload)
+ * @param string|null $fallbackKey $value 의 활성 locale 키 부재 시 __() 호출 키
+ * @param string|null $locale 명시 locale (기본: app()->getLocale())
+ */
+ function localized_label(
+ ?array $value = null,
+ ?string $nameKey = null,
+ ?string $fallbackKey = null,
+ ?string $locale = null,
+ ): string {
+ // Laravel app 미초기화 환경 (PHPUnit\Framework\TestCase 직접 상속 등) 도 안전하게 처리.
+ // 명시 인자 → app locale → fallback_locale → 'ko'
+ $hasLaravelApp = function (): bool {
+ try {
+ return function_exists('app') && app() instanceof \Illuminate\Contracts\Foundation\Application;
+ } catch (\Throwable) {
+ return false;
+ }
+ };
+
+ if ($locale === null) {
+ if ($hasLaravelApp()) {
+ try {
+ $locale = app()->getLocale();
+ } catch (\Throwable) {
+ // 무시
+ }
+ if ($locale === null) {
+ try {
+ $locale = config('app.fallback_locale', 'ko');
+ } catch (\Throwable) {
+ $locale = 'ko';
+ }
+ }
+ } else {
+ $locale = 'ko';
+ }
+ }
+
+ $tryTranslate = static function (string $key) use ($hasLaravelApp, $locale): ?string {
+ if (! $hasLaravelApp()) {
+ return null;
+ }
+ try {
+ $translated = __($key, [], $locale);
+
+ return is_string($translated) ? $translated : null;
+ } catch (\Throwable) {
+ return null;
+ }
+ };
+
+ // 1. nameKey 우선 (registry payload — 데이터에 다국어 JSON 없음)
+ if ($nameKey !== null && $nameKey !== '') {
+ $translated = $tryTranslate($nameKey);
+ if ($translated !== null && $translated !== $nameKey) {
+ return $translated;
+ }
+ }
+
+ // 2. value 의 활성 locale 키 (settings JSON)
+ if ($value !== null && isset($value[$locale]) && $value[$locale] !== '') {
+ return $value[$locale];
+ }
+
+ // 3. fallbackKey __()
+ if ($fallbackKey !== null && $fallbackKey !== '') {
+ $translated = $tryTranslate($fallbackKey);
+ if ($translated !== null && $translated !== $fallbackKey) {
+ return $translated;
+ }
+ }
+
+ // 4. value 의 fallback_locale → 첫 키
+ if ($value !== null) {
+ $fallback = 'ko';
+ if ($hasLaravelApp()) {
+ try {
+ $fallback = config('app.fallback_locale', 'ko');
+ } catch (\Throwable) {
+ // 'ko' 유지
+ }
+ }
+ if (isset($value[$fallback]) && $value[$fallback] !== '') {
+ return $value[$fallback];
+ }
+ $first = reset($value);
+
+ return is_string($first) ? $first : '';
+ }
+
+ return '';
+ }
+}
+
+if (! function_exists('localize_catalog_field')) {
+ /**
+ * Settings 카탈로그 다국어 JSON 필드에 활성 언어팩의 모든 활성 locale 키를 채워줍니다.
+ *
+ * 단일 string 반환이 아니라 **다국어 배열 자체** 를 반환 — settings 응답에 다국어 JSON 으로
+ * 그대로 노출되어야 하므로. (운영자가 admin UI 에서 모든 locale 편집 가능해야 함)
+ *
+ * 운영자 편집값(비어있지 않은 값) 은 보존, 부재한 locale 만 lang pack 에서 자동 채움.
+ *
+ * 사용 예시:
+ * ```php
+ * // EcommerceSettingsService::getBuiltinPaymentMethods() 안에서
+ * $cachedName = localize_catalog_field(
+ * $method['_cached_name'] ?? ['ko' => $id, 'en' => $id],
+ * "sirsoft-ecommerce::settings.payment_methods.{$id}.name",
+ * );
+ * ```
+ *
+ * @param array $field ['ko' => '...', 'en' => '...'] 다국어 JSON
+ * @param string $langKey 완전한 lang key (네임스페이스 prefix 포함)
+ * @return array 보강된 다국어 JSON
+ *
+ * @since 7.0.0-beta.4
+ */
+ function localize_catalog_field(array $field, string $langKey): array
+ {
+ $locales = config('app.translatable_locales', config('app.supported_locales', ['ko', 'en']));
+ if (! is_array($locales) || empty($locales)) {
+ $locales = ['ko', 'en'];
+ }
+
+ foreach ($locales as $locale) {
+ // 운영자 편집값 보존 — 키가 존재하고 비어있지 않으면 skip
+ if (isset($field[$locale]) && $field[$locale] !== '') {
+ continue;
+ }
+ try {
+ $translated = __($langKey, [], $locale);
+ if (is_string($translated) && $translated !== $langKey) {
+ $field[$locale] = $translated;
+ }
+ } catch (\Throwable) {
+ // Laravel app 미초기화 환경 — skip
+ }
+ }
+
+ return $field;
+ }
+}
+
+if (! function_exists('localized_payload')) {
+ /**
+ * Registry payload 단축 helper — entry 의 {field} (다국어 JSON) 와 {field}_key (lang key) 를 자동 처리.
+ *
+ * @param array $entry ['name' => [...], 'name_key' => '...']
+ * @param string $field 처리할 필드명 (기본: 'name')
+ * @param string|null $locale
+ */
+ function localized_payload(array $entry, string $field = 'name', ?string $locale = null): string
+ {
+ $value = $entry[$field] ?? null;
+ $nameKey = $entry["{$field}_key"] ?? null;
+
+ return localized_label(
+ value: is_array($value) ? $value : null,
+ nameKey: is_string($nameKey) ? $nameKey : null,
+ locale: $locale,
+ );
+ }
+}
diff --git a/app/Helpers/settings_helpers.php b/app/Helpers/settings_helpers.php
index e8636720..b6b00000 100644
--- a/app/Helpers/settings_helpers.php
+++ b/app/Helpers/settings_helpers.php
@@ -118,3 +118,31 @@ if (! function_exists('g7_plugin_settings')) {
return Config::get("g7_settings.plugins.{$identifier}.{$key}", $default);
}
}
+
+if (! function_exists('g7_meta_generator_tag')) {
+ /**
+ * `` 태그 HTML을 생성합니다.
+ *
+ * 코어 SEO 설정(`seo.generator_enabled`, `seo.generator_content`)에 따라
+ * 메타 태그를 출력합니다. SEO 봇 페이지·SPA 셸·Admin 셸에서 공통 호출됩니다.
+ *
+ * - `seo.generator_enabled` 가 false 이면 빈 문자열 반환
+ * - `seo.generator_content` 가 빈 값이면 `"GnuBoard7 {버전}"` 자동 적용
+ * - content 는 e() 로 escape 처리되어 XSS 방어됨
+ *
+ * @return string `` 또는 빈 문자열
+ */
+ function g7_meta_generator_tag(): string
+ {
+ if (! g7_core_settings('seo.generator_enabled', true)) {
+ return '';
+ }
+
+ $content = trim((string) g7_core_settings('seo.generator_content', ''));
+ if ($content === '') {
+ $content = trim('GnuBoard7 '.config('app.version', ''));
+ }
+
+ return '';
+ }
+}
diff --git a/app/Http/Controllers/Api/Admin/AuthController.php b/app/Http/Controllers/Api/Admin/AuthController.php
index 9bcc5f96..90fbbec6 100644
--- a/app/Http/Controllers/Api/Admin/AuthController.php
+++ b/app/Http/Controllers/Api/Admin/AuthController.php
@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\Admin;
+use App\Exceptions\Auth\AccountLockedException;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Auth\LoginRequest;
use App\Http\Resources\UserResource;
@@ -43,6 +44,11 @@ class AuthController extends AdminBaseController
$data['user'] = new UserResource($user);
return $this->success('auth.admin_login_success', $data);
+ } catch (AccountLockedException $e) {
+ return $this->error('auth.account_locked', 423, [
+ 'locked_until' => $e->lockedUntil->toIso8601String(),
+ 'retry_after_seconds' => $e->remainingMinutes * 60,
+ ], ['minutes' => $e->remainingMinutes]);
} catch (ValidationException $e) {
return $this->unauthorized('auth.login_failed');
}
diff --git a/app/Http/Controllers/Api/Admin/ExtensionRecoveryController.php b/app/Http/Controllers/Api/Admin/ExtensionRecoveryController.php
new file mode 100644
index 00000000..b9daae2a
--- /dev/null
+++ b/app/Http/Controllers/Api/Admin/ExtensionRecoveryController.php
@@ -0,0 +1,224 @@
+ [...], 'modules' => [...], 'templates' => [...]]
+ */
+ public function autoDeactivated(AutoDeactivatedListRequest $request): JsonResponse
+ {
+ $repos = [
+ 'plugins' => app(PluginRepositoryInterface::class),
+ 'modules' => app(ModuleRepositoryInterface::class),
+ 'templates' => app(TemplateRepositoryInterface::class),
+ ];
+
+ // 사용자별 dismiss 이력 (Listener 와 동일 SSoT — Service 가 단일 진입점)
+ $dismissedIds = $this->alertService->getDismissedAlertIds($request->user()?->id);
+
+ $result = [];
+ foreach ($repos as $type => $repo) {
+ $singular = rtrim($type, 's');
+ $result[$type] = $repo->findAutoDeactivated()
+ ->reject(fn ($record) => $this->isHiddenExtension($singular, $record->identifier))
+ ->reject(fn ($record) => in_array("compat_{$type}_{$record->identifier}", $dismissedIds, true))
+ ->map(function ($record) {
+ return [
+ 'identifier' => $record->identifier,
+ 'incompatible_required_version' => $record->incompatible_required_version,
+ 'deactivated_at' => $record->deactivated_at,
+ ];
+ })->values();
+ }
+
+ return ResponseHelper::success('extensions.alerts.auto_deactivated_listed', [
+ 'items' => $result,
+ 'current_core_version' => CoreVersionChecker::getCoreVersion(),
+ ]);
+ }
+
+ /**
+ * 비호환으로 자동 비활성화된 확장의 원클릭 복구 (재활성화).
+ *
+ * @param RecoverRequest $request 요청
+ * @param string $type 확장 타입 (module|plugin|template)
+ * @param string $identifier 확장 식별자
+ * @return JsonResponse 복구 결과
+ */
+ public function recover(RecoverRequest $request, string $type, string $identifier): JsonResponse
+ {
+ $repo = $this->resolveRepository($type);
+ if (! $repo) {
+ return ResponseHelper::error('extensions.errors.invalid_type', 422);
+ }
+
+ $record = $repo->findByIdentifier($identifier);
+ if (! $record) {
+ return ResponseHelper::error('extensions.errors.not_found', 404);
+ }
+
+ // hidden 확장은 사용자 노출 대상 아님 — 직접 endpoint 호출로 우회 차단
+ if ($this->isHiddenExtension($type, $identifier)) {
+ return ResponseHelper::error('extensions.errors.hidden_extension', 422, [
+ 'error_code' => 'hidden_extension',
+ ]);
+ }
+
+ $reasonValue = $record->deactivated_reason instanceof \BackedEnum
+ ? $record->deactivated_reason->value
+ : $record->deactivated_reason;
+
+ if ($reasonValue !== DeactivationReason::IncompatibleCore->value) {
+ return ResponseHelper::error('extensions.errors.not_auto_deactivated', 422, [
+ 'error_code' => 'not_auto_deactivated',
+ ]);
+ }
+
+ // 재호환 재검증 (글로벌 핸들러가 비호환 시 422 + core_version_mismatch 자동 변환)
+ CoreVersionChecker::validateExtension(
+ $record->incompatible_required_version,
+ $identifier,
+ $type,
+ );
+
+ // 활성화 호출 (force=false — 검증 이미 통과했으므로 재검증 의도)
+ $manager = $this->resolveManager($type);
+ match ($type) {
+ 'plugin' => $manager->activatePlugin($identifier),
+ 'module' => $manager->activateModule($identifier),
+ 'template' => $manager->activateTemplate($identifier),
+ };
+
+ return ResponseHelper::success('extensions.alerts.recovered_success', [
+ 'extension_type' => $type,
+ 'identifier' => $identifier,
+ ]);
+ }
+
+ /**
+ * 호환성 알림 dismiss (사용자별).
+ *
+ * @param DismissAlertRequest $request 요청
+ * @param string $type 확장 타입 (module|plugin|template)
+ * @param string $identifier 확장 식별자
+ * @return JsonResponse dismiss 결과
+ */
+ public function dismiss(DismissAlertRequest $request, string $type, string $identifier): JsonResponse
+ {
+ $userId = $request->user()?->id;
+ $alertId = "compat_{$type}s_{$identifier}";
+ $this->alertService->dismissAlert($alertId, $userId);
+
+ // 재호환 알림도 함께 dismiss (해당 alert 라면 즉시 사라지나 캐시 만료/감지 갱신 시 재노출)
+ $this->alertService->dismissAlert("recover_{$type}s_{$identifier}", $userId);
+
+ return ResponseHelper::success('extensions.alerts.dismissed', [
+ 'alert_id' => $alertId,
+ ]);
+ }
+
+ /**
+ * 타입에 해당하는 Repository 를 반환합니다.
+ *
+ * @param string $type 확장 타입
+ * @return mixed|null Repository 또는 null
+ */
+ protected function resolveRepository(string $type): mixed
+ {
+ return match ($type) {
+ 'plugin' => app(PluginRepositoryInterface::class),
+ 'module' => app(ModuleRepositoryInterface::class),
+ 'template' => app(TemplateRepositoryInterface::class),
+ default => null,
+ };
+ }
+
+ /**
+ * 타입에 해당하는 Manager 를 반환합니다.
+ *
+ * @param string $type 확장 타입
+ * @return mixed Manager 인스턴스
+ */
+ protected function resolveManager(string $type): mixed
+ {
+ return match ($type) {
+ 'plugin' => app(PluginManagerInterface::class),
+ 'module' => app(ModuleManagerInterface::class),
+ 'template' => app(TemplateManagerInterface::class),
+ };
+ }
+
+ /**
+ * 확장이 hidden(학습용 샘플 등) manifest 플래그를 가졌는지 판정합니다.
+ *
+ * Listener 의 동일 로직과 정합 — 자동 비활성화/재호환 알림을 사용자에게 노출하지 않는 정책.
+ *
+ * @param string $singularType module|plugin|template
+ * @param string $identifier 확장 식별자
+ * @return bool
+ */
+ protected function isHiddenExtension(string $singularType, string $identifier): bool
+ {
+ try {
+ $manager = $this->resolveManager($singularType);
+ if ($manager === null) {
+ return false;
+ }
+
+ $extension = match ($singularType) {
+ 'plugin' => $manager->getPlugin($identifier),
+ 'module' => $manager->getModule($identifier),
+ 'template' => $manager->getTemplate($identifier),
+ };
+
+ if (is_array($extension)) {
+ return ! empty($extension['hidden']);
+ }
+ if (is_object($extension) && method_exists($extension, 'isHidden')) {
+ return (bool) $extension->isHidden();
+ }
+ } catch (\Throwable $e) {
+ // 판정 실패 시 안전 측 — hidden 아닌 것으로 간주 (기존 노출 정책 유지)
+ }
+
+ return false;
+ }
+}
diff --git a/app/Http/Controllers/Api/Admin/Identity/AdminIdentityLogController.php b/app/Http/Controllers/Api/Admin/Identity/AdminIdentityLogController.php
new file mode 100644
index 00000000..59c00b67
--- /dev/null
+++ b/app/Http/Controllers/Api/Admin/Identity/AdminIdentityLogController.php
@@ -0,0 +1,84 @@
+validated();
+ $filters = array_filter(
+ array_intersect_key($validated, array_flip([
+ 'provider_id', 'purpose', 'status', 'channel', 'origin_type',
+ 'provider_ids', 'purposes', 'statuses', 'channels', 'origin_types',
+ 'source_type', 'source_identifier',
+ 'user_id', 'target_hash', 'search', 'search_type',
+ 'sort_by', 'sort_order', 'date_from', 'date_to',
+ ])),
+ fn ($v) => $v !== null && $v !== '' && $v !== [],
+ );
+
+ $paginated = $this->logService->search($filters, (int) ($validated['per_page'] ?? 20));
+
+ return $this->success('messages.success', [
+ 'data' => IdentityLogResource::collection(collect($paginated->items()))->resolve(),
+ 'pagination' => [
+ 'current_page' => $paginated->currentPage(),
+ 'last_page' => $paginated->lastPage(),
+ 'per_page' => $paginated->perPage(),
+ 'total' => $paginated->total(),
+ 'from' => $paginated->firstItem(),
+ 'to' => $paginated->lastItem(),
+ 'has_more_pages' => $paginated->hasMorePages(),
+ ],
+ 'abilities' => [
+ 'can_purge' => $request->user()?->hasPermission(
+ 'core.admin.identity.logs.purge',
+ \App\Enums\PermissionType::Admin,
+ ) ?? false,
+ ],
+ ]);
+ }
+
+ /**
+ * 보관주기 경과 이력을 파기합니다.
+ *
+ * @param AdminIdentityLogPurgeRequest $request 검증된 요청
+ * @return JsonResponse 삭제된 행 수
+ */
+ public function purge(AdminIdentityLogPurgeRequest $request): JsonResponse
+ {
+ $days = (int) ($request->validated()['older_than_days'] ?? 180);
+ $count = $this->logService->purge($days);
+
+ return $this->success('messages.success', [
+ 'purged_count' => $count,
+ 'older_than_days' => $days,
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Api/Admin/Identity/AdminIdentityMessageDefinitionController.php b/app/Http/Controllers/Api/Admin/Identity/AdminIdentityMessageDefinitionController.php
new file mode 100644
index 00000000..0283a79e
--- /dev/null
+++ b/app/Http/Controllers/Api/Admin/Identity/AdminIdentityMessageDefinitionController.php
@@ -0,0 +1,221 @@
+validated();
+ $perPage = (int) ($filters['per_page'] ?? 20);
+
+ $definitions = $this->definitionService->getDefinitions($filters, $perPage);
+ $collection = new IdentityMessageDefinitionCollection($definitions);
+
+ return $this->success(
+ __('identity_message.definition_list_success'),
+ $collection->toArray($request)
+ );
+ } catch (\Exception $e) {
+ Log::error('IDV 메시지 정의 목록 조회 실패', ['error' => $e->getMessage()]);
+
+ return $this->error(__('identity_message.definition_list_failed'), 500);
+ }
+ }
+
+ /**
+ * 메시지 정의 신규 생성 (정책 매핑 — admin 운영자 전용).
+ *
+ * scope_type='policy' + scope_value=admin policy.key 매칭만 허용.
+ * FormRequest 가 검증 처리.
+ *
+ * @param StoreIdentityMessageDefinitionRequest $request
+ * @return JsonResponse
+ */
+ public function store(StoreIdentityMessageDefinitionRequest $request): JsonResponse
+ {
+ try {
+ $definition = $this->definitionService->createAdminDefinition($request->validated());
+
+ return $this->success(
+ __('identity_message.definition_created'),
+ new IdentityMessageDefinitionResource($definition->load('templates')),
+ 201,
+ );
+ } catch (\Exception $e) {
+ Log::error('IDV 메시지 정의 생성 실패', ['error' => $e->getMessage()]);
+
+ return $this->error(__('identity_message.definition_create_failed'), 500);
+ }
+ }
+
+ /**
+ * 메시지 정의 상세 조회.
+ *
+ * @param IdentityMessageDefinition $definition
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function show(IdentityMessageDefinition $definition)
+ {
+ try {
+ $definition->load('templates');
+
+ return $this->success(
+ __('identity_message.definition_show_success'),
+ new IdentityMessageDefinitionResource($definition)
+ );
+ } catch (\Exception $e) {
+ Log::error('IDV 메시지 정의 상세 조회 실패', [
+ 'definition_id' => $definition->id,
+ 'error' => $e->getMessage(),
+ ]);
+
+ return $this->error(__('identity_message.definition_show_failed'), 500);
+ }
+ }
+
+ /**
+ * 메시지 정의 수정 (name, description, channels, is_active).
+ *
+ * @param UpdateIdentityMessageDefinitionRequest $request
+ * @param IdentityMessageDefinition $definition
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function update(UpdateIdentityMessageDefinitionRequest $request, IdentityMessageDefinition $definition)
+ {
+ try {
+ $updated = $this->definitionService->updateDefinition($definition, $request->validated());
+
+ return $this->success(
+ __('identity_message.definition_updated'),
+ new IdentityMessageDefinitionResource($updated->load('templates'))
+ );
+ } catch (\Exception $e) {
+ Log::error('IDV 메시지 정의 수정 실패', [
+ 'definition_id' => $definition->id,
+ 'error' => $e->getMessage(),
+ ]);
+
+ return $this->error(__('identity_message.definition_update_failed'), 500);
+ }
+ }
+
+ /**
+ * 활성/비활성 토글.
+ *
+ * @param IdentityMessageDefinition $definition
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function toggleActive(IdentityMessageDefinition $definition)
+ {
+ try {
+ $updated = $this->definitionService->toggleActive($definition);
+
+ return $this->success(
+ __('identity_message.definition_toggled'),
+ new IdentityMessageDefinitionResource($updated)
+ );
+ } catch (\Exception $e) {
+ Log::error('IDV 메시지 정의 활성 토글 실패', [
+ 'definition_id' => $definition->id,
+ 'error' => $e->getMessage(),
+ ]);
+
+ return $this->error(__('identity_message.definition_toggle_failed'), 500);
+ }
+ }
+
+ /**
+ * 정의의 모든 채널 템플릿을 기본값으로 일괄 복원합니다.
+ *
+ * @param IdentityMessageDefinition $definition
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function reset(IdentityMessageDefinition $definition)
+ {
+ try {
+ $definition->load('templates');
+
+ foreach ($definition->templates as $template) {
+ $this->templateService->resetToDefault($template);
+ }
+
+ $this->definitionService->markAsDefault($definition);
+ $definition->load('templates');
+
+ return $this->success(
+ __('identity_message.definition_reset'),
+ new IdentityMessageDefinitionResource($definition)
+ );
+ } catch (\Exception $e) {
+ Log::error('IDV 메시지 정의 초기화 실패', [
+ 'definition_id' => $definition->id,
+ 'error' => $e->getMessage(),
+ ]);
+
+ return $this->error(__('identity_message.definition_reset_failed'), 500);
+ }
+ }
+
+ /**
+ * 운영자 추가 메시지 정의 삭제.
+ *
+ * is_default=true 인 시드 정의는 삭제 거부 (선언형 보호).
+ * FK cascadeOnDelete 로 자식 templates 자동 삭제.
+ *
+ * @param IdentityMessageDefinition $definition
+ * @return JsonResponse
+ */
+ public function destroy(IdentityMessageDefinition $definition): JsonResponse
+ {
+ if ($definition->is_default) {
+ return $this->forbidden(__('identity_message.definition_delete_forbidden'));
+ }
+
+ try {
+ $this->definitionService->deleteAdminDefinition($definition);
+
+ return $this->success(__('identity_message.definition_deleted'));
+ } catch (\Exception $e) {
+ Log::error('IDV 메시지 정의 삭제 실패', [
+ 'definition_id' => $definition->id,
+ 'error' => $e->getMessage(),
+ ]);
+
+ return $this->error(__('identity_message.definition_delete_failed'), 500);
+ }
+ }
+}
diff --git a/app/Http/Controllers/Api/Admin/Identity/AdminIdentityMessageTemplateController.php b/app/Http/Controllers/Api/Admin/Identity/AdminIdentityMessageTemplateController.php
new file mode 100644
index 00000000..f4071a48
--- /dev/null
+++ b/app/Http/Controllers/Api/Admin/Identity/AdminIdentityMessageTemplateController.php
@@ -0,0 +1,127 @@
+templateService->updateTemplate($template, $request->validated());
+
+ return $this->success(
+ __('identity_message.template_updated'),
+ new IdentityMessageTemplateResource($updated)
+ );
+ } catch (\Exception $e) {
+ Log::error('IDV 메시지 템플릿 수정 실패', [
+ 'template_id' => $template->id,
+ 'error' => $e->getMessage(),
+ ]);
+
+ return $this->error(__('identity_message.template_update_failed'), 500);
+ }
+ }
+
+ /**
+ * 활성/비활성 토글.
+ *
+ * @param IdentityMessageTemplate $template
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function toggleActive(IdentityMessageTemplate $template)
+ {
+ try {
+ $updated = $this->templateService->toggleActive($template);
+
+ return $this->success(
+ __('identity_message.template_toggled'),
+ new IdentityMessageTemplateResource($updated)
+ );
+ } catch (\Exception $e) {
+ Log::error('IDV 메시지 템플릿 활성 토글 실패', [
+ 'template_id' => $template->id,
+ 'error' => $e->getMessage(),
+ ]);
+
+ return $this->error(__('identity_message.template_toggle_failed'), 500);
+ }
+ }
+
+ /**
+ * 변수 치환 미리보기.
+ *
+ * @param PreviewIdentityMessageTemplateRequest $request
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function preview(PreviewIdentityMessageTemplateRequest $request)
+ {
+ try {
+ $payload = $request->validated();
+ $template = IdentityMessageTemplate::findOrFail($payload['template_id']);
+ $rendered = $this->templateService->getPreview(
+ $template,
+ $payload['data'] ?? [],
+ $payload['locale'] ?? null,
+ );
+
+ return $this->success(__('identity_message.preview_success'), $rendered);
+ } catch (\Exception $e) {
+ Log::error('IDV 메시지 템플릿 미리보기 실패', ['error' => $e->getMessage()]);
+
+ return $this->error(__('identity_message.preview_failed'), 500);
+ }
+ }
+
+ /**
+ * 템플릿을 시더 기본값으로 복원.
+ *
+ * @param IdentityMessageTemplate $template
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function reset(IdentityMessageTemplate $template)
+ {
+ try {
+ $updated = $this->templateService->resetToDefault($template);
+
+ return $this->success(
+ __('identity_message.template_reset'),
+ new IdentityMessageTemplateResource($updated)
+ );
+ } catch (\Exception $e) {
+ Log::error('IDV 메시지 템플릿 초기화 실패', [
+ 'template_id' => $template->id,
+ 'error' => $e->getMessage(),
+ ]);
+
+ return $this->error(__('identity_message.template_reset_failed'), 500);
+ }
+ }
+}
diff --git a/app/Http/Controllers/Api/Admin/Identity/AdminIdentityPolicyController.php b/app/Http/Controllers/Api/Admin/Identity/AdminIdentityPolicyController.php
new file mode 100644
index 00000000..c92f1fc3
--- /dev/null
+++ b/app/Http/Controllers/Api/Admin/Identity/AdminIdentityPolicyController.php
@@ -0,0 +1,184 @@
+
+ */
+ protected const LIMITED_EDITABLE_FIELDS = ['enabled', 'grace_minutes', 'provider_id', 'fail_mode', 'conditions'];
+
+ /**
+ * @param IdentityPolicyService $policyService 정책 유스케이스 Service
+ */
+ public function __construct(
+ protected IdentityPolicyService $policyService,
+ ) {}
+
+ /**
+ * 정책 목록을 조회합니다.
+ *
+ * @param AdminIdentityPolicyIndexRequest $request 검증된 요청 (필터: scope/purpose/source_type/enabled/search)
+ * @return JsonResponse 정책 컬렉션 (페이지네이션 포함)
+ */
+ public function index(AdminIdentityPolicyIndexRequest $request): JsonResponse
+ {
+ $validated = $request->validated();
+ $filters = array_filter(
+ array_intersect_key($validated, array_flip(['scope', 'purpose', 'source_type', 'source_identifier', 'applies_to', 'fail_mode', 'enabled', 'search'])),
+ fn ($v) => $v !== null,
+ );
+ $perPage = (int) ($validated['per_page'] ?? 20);
+
+ $paginated = $this->policyService->search($filters, $perPage);
+ $collection = (new PolicyCollection($paginated))->toArray($request);
+
+ return $this->success('messages.success', [
+ 'data' => $collection['data'],
+ 'abilities' => $collection['abilities'] ?? [],
+ 'meta' => [
+ 'current_page' => $paginated->currentPage(),
+ 'per_page' => $paginated->perPage(),
+ 'total' => $paginated->total(),
+ 'last_page' => $paginated->lastPage(),
+ ],
+ ]);
+ }
+
+ /**
+ * 정책을 신규 생성합니다 (source_type='admin' 고정).
+ *
+ * @param AdminIdentityPolicyStoreRequest $request 검증된 요청
+ * @return JsonResponse 생성된 정책 리소스
+ */
+ public function store(AdminIdentityPolicyStoreRequest $request): JsonResponse
+ {
+ $policy = $this->policyService->createAdminPolicy($request->validated());
+
+ return $this->success(
+ 'messages.created',
+ (new PolicyResource($policy))->toArray($request),
+ 201,
+ );
+ }
+
+ /**
+ * 정책을 수정합니다. source_type != 'admin' 일 경우 제한 필드만 허용됩니다.
+ *
+ * @param AdminIdentityPolicyUpdateRequest $request 검증된 요청
+ * @param int $id 정책 ID
+ * @return JsonResponse 수정된 정책 리소스
+ */
+ public function update(AdminIdentityPolicyUpdateRequest $request, int $id): JsonResponse
+ {
+ $policy = $this->policyService->findById($id);
+
+ if (! $policy) {
+ return $this->error('messages.not_found', 404);
+ }
+
+ $validated = $request->validated();
+
+ // source_type != 'admin' 이면 제한 필드만 허용
+ if ($policy->source_type !== IdentityPolicySourceType::Admin) {
+ $validated = array_intersect_key(
+ $validated,
+ array_flip(self::LIMITED_EDITABLE_FIELDS),
+ );
+ }
+
+ if (empty($validated)) {
+ return $this->error('validation.nothing_to_update', 422);
+ }
+
+ if (! $this->policyService->updatePolicy($policy, $validated)) {
+ return $this->error('messages.failed', 500);
+ }
+
+ $policy->refresh();
+
+ return $this->success(
+ 'messages.updated',
+ (new PolicyResource($policy))->toArray($request),
+ );
+ }
+
+ /**
+ * 정책을 삭제합니다 (source_type='admin' 정책만 가능, 선언형 정책은 비활성화로 대체).
+ *
+ * @param Request $request HTTP 요청
+ * @param int $id 정책 ID
+ * @return JsonResponse
+ */
+ public function destroy(Request $request, int $id): JsonResponse
+ {
+ $policy = $this->policyService->findById($id);
+
+ if (! $policy) {
+ return $this->error('messages.not_found', 404);
+ }
+
+ if ($policy->source_type !== IdentityPolicySourceType::Admin) {
+ return $this->forbidden('messages.cannot_delete_system_resource');
+ }
+
+ return $this->policyService->deleteAdminPolicy($policy)
+ ? $this->success('messages.deleted')
+ : $this->error('messages.failed', 500);
+ }
+
+ /**
+ * 특정 필드의 user_overrides 를 해제하고 선언 기본값으로 즉시 복원합니다.
+ *
+ * S1d "↺ 기본값으로 되돌리기" 버튼이 호출하는 엔드포인트입니다.
+ * `source_type='admin'` 정책은 선언 기본값이 없어 false 를 반환하며,
+ * 선언형 정책(core/module/plugin) 에 대해서만 의미가 있습니다.
+ *
+ * @param AdminIdentityPolicyResetFieldRequest $request 검증된 요청 (field 필수)
+ * @param int $id 정책 ID
+ * @return JsonResponse 복원된 최신 정책 리소스 또는 오류
+ */
+ public function resetField(AdminIdentityPolicyResetFieldRequest $request, int $id): JsonResponse
+ {
+ $policy = $this->policyService->findById($id);
+
+ if (! $policy) {
+ return $this->error('messages.not_found', 404);
+ }
+
+ if ($policy->source_type === IdentityPolicySourceType::Admin) {
+ return $this->forbidden('identity.errors.admin_policy_has_no_default');
+ }
+
+ $field = (string) $request->validated()['field'];
+ $ok = $this->policyService->resetFieldOverride($policy, $field);
+
+ if (! $ok) {
+ return $this->error('identity.errors.reset_field_failed', 422);
+ }
+
+ return $this->successWithResource('messages.success', new PolicyResource($policy->fresh()));
+ }
+}
diff --git a/app/Http/Controllers/Api/Admin/Identity/AdminIdentityProviderController.php b/app/Http/Controllers/Api/Admin/Identity/AdminIdentityProviderController.php
new file mode 100644
index 00000000..42fbe090
--- /dev/null
+++ b/app/Http/Controllers/Api/Admin/Identity/AdminIdentityProviderController.php
@@ -0,0 +1,53 @@
+manager->all());
+
+ $rows = array_map(function ($provider) use ($request) {
+ $resource = (new ProviderResource($provider))->toArray($request);
+
+ // 설정 스키마 포함 (관리자 UI 반복 렌더용)
+ $schema = HookManager::applyFilters(
+ 'core.identity.settings_schema',
+ $provider->getSettingsSchema(),
+ $provider->getId(),
+ );
+
+ return $resource + ['settings_schema' => is_array($schema) ? $schema : []];
+ }, $providers);
+
+ return $this->success('messages.success', $rows);
+ }
+}
diff --git a/app/Http/Controllers/Api/Admin/LanguagePackController.php b/app/Http/Controllers/Api/Admin/LanguagePackController.php
new file mode 100644
index 00000000..700857c4
--- /dev/null
+++ b/app/Http/Controllers/Api/Admin/LanguagePackController.php
@@ -0,0 +1,416 @@
+validated();
+ $perPage = (int) ($validated['per_page'] ?? 10);
+
+ $paginator = $this->service->list($validated, $perPage);
+
+ $collection = new LanguagePackCollection(collect($paginator->items()));
+ $payload = [
+ 'data' => $collection->toArray($request)['data'],
+ 'meta' => array_merge(
+ $collection->with($request)['meta'] ?? [],
+ [
+ 'current_page' => $paginator->currentPage(),
+ 'last_page' => $paginator->lastPage(),
+ 'per_page' => $paginator->perPage(),
+ 'total' => $paginator->total(),
+ ]
+ ),
+ 'abilities' => $collection->resolveCollectionAbilities($request),
+ ];
+
+ return $this->success('language_packs.fetch_success', $payload);
+ } catch (\Throwable $e) {
+ return $this->error('language_packs.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
+ }
+ }
+
+ /**
+ * 언어팩 상세 정보를 조회합니다.
+ *
+ * `{id}` 가 정수면 DB 레코드를, 문자열(번들 식별자)이면 `lang-packs/_bundled/{id}` 의
+ * manifest 로부터 합성된 가상 행을 반환합니다 (미설치 번들의 모달 열람용).
+ *
+ * @param Request $request HTTP 요청
+ * @param string $id 언어팩 ID 또는 번들 식별자
+ * @return JsonResponse 언어팩 상세 정보
+ */
+ // audit:allow controller-base-request-injection reason: GET 상세 조회. 입력 검증 없음 — Resource::toDetailArray($request) 의 Request 인자 전달 목적
+ public function show(Request $request, string $id): JsonResponse
+ {
+ $pack = $this->service->findOrBundled($id);
+ if (! $pack) {
+ return $this->notFound('language_packs.not_found');
+ }
+
+ $resource = new LanguagePackResource($pack);
+
+ return $this->success('language_packs.fetch_success', $resource->toDetailArray($request));
+ }
+
+ /**
+ * 업로드된 ZIP 파일에서 언어팩을 설치합니다.
+ *
+ * @param InstallFromFileRequest $request 설치 요청
+ * @return JsonResponse 설치 결과
+ */
+ public function installFromFile(InstallFromFileRequest $request): JsonResponse
+ {
+ try {
+ $validated = $request->validated();
+ $pack = $this->service->installFromFile(
+ $request->file('file'),
+ (bool) ($validated['auto_activate'] ?? false),
+ $this->getCurrentUser()?->id
+ );
+
+ return $this->success(
+ 'language_packs.install_success',
+ (new LanguagePackResource($pack))->toArray($request),
+ 201
+ );
+ } catch (ValidationException $e) {
+ return $this->error('language_packs.manifest_invalid', 422, $e->errors());
+ } catch (\Throwable $e) {
+ return $this->error('language_packs.install_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
+ }
+ }
+
+ /**
+ * GitHub URL 에서 언어팩을 설치합니다.
+ *
+ * @param InstallFromGithubRequest $request 설치 요청
+ * @return JsonResponse 설치 결과
+ */
+ public function installFromGithub(InstallFromGithubRequest $request): JsonResponse
+ {
+ try {
+ $validated = $request->validated();
+ $pack = $this->service->installFromGithub(
+ $validated['github_url'],
+ (bool) ($validated['auto_activate'] ?? false),
+ $this->getCurrentUser()?->id
+ );
+
+ return $this->success(
+ 'language_packs.install_success',
+ (new LanguagePackResource($pack))->toArray($request),
+ 201
+ );
+ } catch (ValidationException $e) {
+ return $this->error('language_packs.manifest_invalid', 422, $e->errors());
+ } catch (\Throwable $e) {
+ return $this->error('language_packs.install_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
+ }
+ }
+
+ /**
+ * `lang-packs/_bundled/{identifier}` 디렉토리의 번들 소스에서 언어팩을 설치(또는 재설치)합니다.
+ *
+ * @param InstallFromBundledRequest $request 설치 요청
+ * @return JsonResponse 설치 결과
+ */
+ public function installFromBundled(InstallFromBundledRequest $request): JsonResponse
+ {
+ try {
+ $validated = $request->validated();
+ $pack = $this->service->installFromBundled(
+ $validated['identifier'],
+ (bool) ($validated['auto_activate'] ?? false),
+ $this->getCurrentUser()?->id
+ );
+
+ return $this->success(
+ 'language_packs.install_success',
+ (new LanguagePackResource($pack))->toArray($request),
+ 201
+ );
+ } catch (ValidationException $e) {
+ return $this->error('language_packs.manifest_invalid', 422, $e->errors());
+ } catch (\Throwable $e) {
+ return $this->error('language_packs.install_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
+ }
+ }
+
+ /**
+ * 임의 URL 에서 언어팩을 설치합니다.
+ *
+ * @param InstallFromUrlRequest $request 설치 요청
+ * @return JsonResponse 설치 결과
+ */
+ public function installFromUrl(InstallFromUrlRequest $request): JsonResponse
+ {
+ try {
+ $validated = $request->validated();
+ $pack = $this->service->installFromUrl(
+ $validated['url'],
+ $validated['checksum'] ?? null,
+ (bool) ($validated['auto_activate'] ?? false),
+ $this->getCurrentUser()?->id
+ );
+
+ return $this->success(
+ 'language_packs.install_success',
+ (new LanguagePackResource($pack))->toArray($request),
+ 201
+ );
+ } catch (ValidationException $e) {
+ return $this->error('language_packs.manifest_invalid', 422, $e->errors());
+ } catch (\Throwable $e) {
+ return $this->error('language_packs.install_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
+ }
+ }
+
+ /**
+ * 언어팩을 활성화합니다 (슬롯 스위칭).
+ *
+ * @param Request $request HTTP 요청
+ * @param int $id 언어팩 ID
+ * @return JsonResponse 활성화 결과
+ */
+ // audit:allow controller-base-request-injection reason: force 플래그(boolean) 단순 토글. FormRequest 분리 시 빈 rules() 만 제공 — 검증 가치 없음
+ public function activate(Request $request, int $id): JsonResponse
+ {
+ $pack = $this->service->find($id);
+ if (! $pack) {
+ return $this->notFound('language_packs.not_found');
+ }
+
+ $force = (bool) $request->boolean('force', false);
+
+ try {
+ $pack = $this->service->activate($pack, $force);
+
+ return $this->success(
+ 'language_packs.activate_success',
+ (new LanguagePackResource($pack))->toArray($request)
+ );
+ } catch (LanguagePackSlotConflictException $e) {
+ // 동일 슬롯에 다른 활성 팩 존재 — 프론트가 모달로 사용자 확인 후 force=true 로 재호출
+ return $this->error('language_packs.errors.slot_conflict', 409, [
+ 'current' => (new LanguagePackResource($e->current))->toArray($request),
+ 'target' => (new LanguagePackResource($e->target))->toArray($request),
+ ]);
+ } catch (\Throwable $e) {
+ return $this->error('language_packs.activate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
+ }
+ }
+
+ /**
+ * 여러 언어팩을 일괄 활성화합니다 (PO #7 reactivate 모달 → "활성화" 버튼).
+ *
+ * @param \App\Http\Requests\LanguagePack\BulkActivateRequest $request 요청
+ * @return JsonResponse 결과 (succeeded/failed 분리)
+ */
+ public function bulkActivate(\App\Http\Requests\LanguagePack\BulkActivateRequest $request): JsonResponse
+ {
+ $ids = $request->validated('ids');
+ $result = $this->service->bulkActivate($ids);
+
+ return $this->success('language_packs.bulk_activate_success', $result);
+ }
+
+ /**
+ * 언어팩을 비활성화합니다.
+ *
+ * @param Request $request HTTP 요청
+ * @param int $id 언어팩 ID
+ * @return JsonResponse 비활성화 결과
+ */
+ // audit:allow controller-base-request-injection reason: 라우트 파라미터만 사용. Request 는 Resource::toArray($request) 전달용
+ public function deactivate(Request $request, int $id): JsonResponse
+ {
+ $pack = $this->service->find($id);
+ if (! $pack) {
+ return $this->notFound('language_packs.not_found');
+ }
+
+ try {
+ $pack = $this->service->deactivate($pack);
+
+ return $this->success(
+ 'language_packs.deactivate_success',
+ (new LanguagePackResource($pack))->toArray($request)
+ );
+ } catch (\Throwable $e) {
+ return $this->error('language_packs.deactivate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
+ }
+ }
+
+ /**
+ * 언어팩을 제거합니다.
+ *
+ * @param UninstallLanguagePackRequest $request 제거 요청
+ * @param int $id 언어팩 ID
+ * @return JsonResponse 제거 결과
+ */
+ public function uninstall(UninstallLanguagePackRequest $request, int $id): JsonResponse
+ {
+ $pack = $this->service->find($id);
+ if (! $pack) {
+ return $this->notFound('language_packs.not_found');
+ }
+
+ try {
+ $cascade = (bool) ($request->validated()['cascade'] ?? false);
+ $this->service->uninstall($pack, $cascade);
+
+ return $this->success('language_packs.uninstall_success');
+ } catch (\Throwable $e) {
+ return $this->error('language_packs.uninstall_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
+ }
+ }
+
+ /**
+ * GitHub 소스 언어팩의 업데이트 가능 여부를 확인합니다.
+ *
+ * @return JsonResponse 검사 결과 (checked, updates, details)
+ */
+ public function checkUpdates(): JsonResponse
+ {
+ try {
+ $result = $this->service->checkUpdates();
+
+ return $this->success('language_packs.check_updates_success', $result);
+ } catch (\Throwable $e) {
+ return $this->error('language_packs.check_updates_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
+ }
+ }
+
+ /**
+ * 언어팩을 신버전으로 업데이트합니다 (GitHub 재다운로드 + 적용).
+ *
+ * @param Request $request HTTP 요청
+ * @param int $id 언어팩 ID
+ * @return JsonResponse 업데이트된 언어팩
+ */
+ // audit:allow controller-base-request-injection reason: 입력 검증 없음. Request 는 Resource::toArray($request) 전달용
+ public function performUpdate(Request $request, int $id): JsonResponse
+ {
+ $pack = $this->service->find($id);
+ if (! $pack) {
+ return $this->notFound('language_packs.not_found');
+ }
+
+ try {
+ $updated = $this->service->performUpdate($pack);
+
+ return $this->success(
+ 'language_packs.update_success',
+ (new LanguagePackResource($updated))->toArray($request)
+ );
+ } catch (\Throwable $e) {
+ return $this->error('language_packs.update_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
+ }
+ }
+
+ /**
+ * 번역/레지스트리/템플릿 언어 캐시를 무효화합니다.
+ *
+ * @return JsonResponse 캐시 갱신 결과
+ */
+ public function refreshCache(): JsonResponse
+ {
+ try {
+ $result = $this->service->refreshCache();
+
+ return $this->success('language_packs.refresh_cache_success', $result);
+ } catch (\Throwable $e) {
+ return $this->error('language_packs.refresh_cache_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
+ }
+ }
+
+ /**
+ * 설치 전 ZIP 의 manifest 와 검증 결과만 미리 조회합니다 (실제 설치 X).
+ *
+ * @param ManifestPreviewRequest $request 미리보기 요청
+ * @return JsonResponse manifest + validation 결과
+ */
+ public function manifestPreview(ManifestPreviewRequest $request): JsonResponse
+ {
+ try {
+ $result = $this->service->previewManifest($request->file('file'));
+
+ return $this->success('language_packs.preview_success', $result);
+ } catch (\Throwable $e) {
+ return $this->error('language_packs.preview_failed', 422, $e->getMessage(), ['error' => $e->getMessage()]);
+ }
+ }
+
+ /**
+ * 언어팩의 CHANGELOG.md 파일 내용을 반환합니다.
+ *
+ * `{id}` 가 정수면 DB 레코드를, 문자열(번들 식별자)이면 가상 행을 사용합니다.
+ *
+ * @param string $id 언어팩 ID 또는 번들 식별자
+ * @return JsonResponse CHANGELOG 문자열
+ */
+ public function changelog(string $id): JsonResponse
+ {
+ $pack = $this->service->findOrBundled($id);
+ if (! $pack) {
+ return $this->notFound('language_packs.not_found');
+ }
+
+ try {
+ $directory = $pack->resolveDirectory();
+ $changelogPath = $directory.DIRECTORY_SEPARATOR.'CHANGELOG.md';
+ $entries = is_file($changelogPath) ? ChangelogParser::parse($changelogPath) : [];
+ $rawContent = is_file($changelogPath) ? (string) file_get_contents($changelogPath) : '';
+
+ return $this->success('language_packs.fetch_success', [
+ 'identifier' => $pack->identifier,
+ 'entries' => $entries,
+ 'changelog' => $rawContent,
+ 'has_changelog' => $entries !== [] || $rawContent !== '',
+ ]);
+ } catch (\Throwable $e) {
+ return $this->error('language_packs.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
+ }
+ }
+}
diff --git a/app/Http/Controllers/Api/Admin/ModuleController.php b/app/Http/Controllers/Api/Admin/ModuleController.php
index 685b0253..c4df455b 100644
--- a/app/Http/Controllers/Api/Admin/ModuleController.php
+++ b/app/Http/Controllers/Api/Admin/ModuleController.php
@@ -2,7 +2,10 @@
namespace App\Http\Controllers\Api\Admin;
+use App\Enums\LanguagePackScope;
use App\Http\Controllers\Api\Base\AdminBaseController;
+use App\Http\Controllers\Concerns\InjectsExtensionLanguagePacks;
+use App\Http\Controllers\Concerns\OrchestratesCascadeInstall;
use App\Http\Requests\Module\ActivateModuleRequest;
use App\Http\Requests\Module\DeactivateModuleRequest;
use App\Http\Requests\Module\IndexModuleRequest;
@@ -10,11 +13,13 @@ use App\Http\Requests\Module\InstallModuleFromFileRequest;
use App\Http\Requests\Module\InstallModuleFromGithubRequest;
use App\Http\Requests\Module\InstallModuleRequest;
use App\Http\Requests\Module\PerformModuleUpdateRequest;
+use App\Http\Requests\Module\PreviewModuleManifestRequest;
use App\Http\Requests\Module\RefreshModuleLayoutsRequest;
use App\Http\Requests\Module\UninstallModuleRequest;
use App\Http\Requests\Extension\ChangelogRequest;
use App\Http\Resources\ModuleCollection;
use App\Http\Resources\ModuleResource;
+use App\Services\Extension\ExtensionInstallPreviewBuilder;
use App\Services\LicenseService;
use App\Services\ModuleService;
use App\Services\TemplateService;
@@ -29,6 +34,9 @@ use Illuminate\Validation\ValidationException;
*/
class ModuleController extends AdminBaseController
{
+ use InjectsExtensionLanguagePacks;
+ use OrchestratesCascadeInstall;
+
public function __construct(
private ModuleService $moduleService,
private TemplateService $templateService,
@@ -66,7 +74,7 @@ class ModuleController extends AdminBaseController
return $this->success('module.fetch_success', $responseData);
} catch (\Exception $e) {
- return $this->error('module.fetch_failed', 500, $e->getMessage());
+ return $this->error('module.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
@@ -76,6 +84,7 @@ class ModuleController extends AdminBaseController
* @param Request $request HTTP 요청 객체
* @return JsonResponse 설치된 모듈 목록을 포함한 JSON 응답
*/
+ // audit:allow controller-base-request-injection reason: GET 목록 조회. ModuleCollection::toArray($request)/with($request) 전달용
public function installed(Request $request): JsonResponse
{
try {
@@ -88,7 +97,7 @@ class ModuleController extends AdminBaseController
'meta' => $collection->with($request)['meta'],
]);
} catch (\Exception $e) {
- return $this->error('module.fetch_failed', 500, $e->getMessage());
+ return $this->error('module.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
@@ -98,6 +107,7 @@ class ModuleController extends AdminBaseController
* @param Request $request HTTP 요청 객체
* @return JsonResponse 미설치 모듈 목록을 포함한 JSON 응답
*/
+ // audit:allow controller-base-request-injection reason: GET 목록 조회. ModuleCollection::toArray($request)/with($request) 전달용
public function uninstalled(Request $request): JsonResponse
{
try {
@@ -110,17 +120,19 @@ class ModuleController extends AdminBaseController
'meta' => $collection->with($request)['meta'],
]);
} catch (\Exception $e) {
- return $this->error('module.fetch_failed', 500, $e->getMessage());
+ return $this->error('module.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
/**
* 특정 모듈의 상세 정보를 조회합니다.
*
- * @param string $moduleName 모듈명
+ * @param Request $request HTTP 요청 (attachLanguagePacks 의 Request 인자 전달용)
+ * @param string $moduleName 모듈 식별자
* @return JsonResponse 모듈 정보를 포함한 JSON 응답
*/
- public function show(string $moduleName): JsonResponse
+ // audit:allow controller-base-request-injection reason: GET 상세 조회. attachLanguagePacks($detail, scope, name, $request) 전달용
+ public function show(Request $request, string $moduleName): JsonResponse
{
try {
$moduleInfo = $this->moduleService->getModuleInfo($moduleName);
@@ -129,12 +141,39 @@ class ModuleController extends AdminBaseController
return $this->error('module.not_found', 404, null, ['module' => $moduleName]);
}
- // 상세 정보는 toDetailArray() 메서드 사용
+ // 상세 정보는 toDetailArray() 메서드 사용 + 지원 언어팩 주입
$resource = new ModuleResource($moduleInfo);
+ $detail = $this->attachLanguagePacks(
+ $resource->toDetailArray(),
+ LanguagePackScope::Module,
+ $moduleName,
+ $request,
+ );
- return $this->success('module.fetch_success', $resource->toDetailArray());
+ return $this->success('module.fetch_success', $detail);
} catch (\Exception $e) {
- return $this->error('module.fetch_failed', 500, $e->getMessage());
+ return $this->error('module.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
+ }
+ }
+
+ /**
+ * 모듈 설치 cascade 프리뷰를 반환합니다 (의존 확장 + 동반 가능 번들 언어팩).
+ *
+ * 인스톨 모달 오픈 시 호출되어 사용자가 선택할 cascade 후보 트리를 노출합니다.
+ * `manifest-preview` (POST + ZIP 업로드) 와는 다른 목적의 GET 식별자 기반 API.
+ *
+ * @param string $moduleName 모듈 식별자
+ * @param ExtensionInstallPreviewBuilder $builder 프리뷰 빌더
+ * @return JsonResponse cascade 프리뷰 응답
+ */
+ public function installPreview(string $moduleName, ExtensionInstallPreviewBuilder $builder): JsonResponse
+ {
+ try {
+ $preview = $builder->build(\App\Enums\LanguagePackScope::Module, $moduleName);
+
+ return $this->success('module.fetch_success', $preview);
+ } catch (\Exception $e) {
+ return $this->error('module.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
@@ -152,14 +191,20 @@ class ModuleController extends AdminBaseController
$vendorMode = \App\Extension\Vendor\VendorMode::fromStringOrAuto(
$validated['vendor_mode'] ?? null
);
+
+ // cascade 1단계: 사용자가 선택한 의존 확장 사전 설치 (실패 시 abort)
+ $this->installSelectedDependencies($validated['dependencies'] ?? []);
+
$module = $this->moduleService->installModule($moduleName, $vendorMode);
if ($module) {
- return $this->successWithResource(
- 'module.install_success',
- new ModuleResource($module),
- 201
- );
+ // cascade 2단계: 동반 번들 언어팩 best-effort 설치
+ $lpFailures = $this->installSelectedLanguagePacks($validated['language_packs'] ?? []);
+
+ $payload = (new ModuleResource($module))->toArray($request);
+ $payload['language_pack_failures'] = $lpFailures;
+
+ return $this->success('module.install_success', $payload, 201);
} else {
return $this->error('module.install_failed');
}
@@ -207,21 +252,27 @@ class ModuleController extends AdminBaseController
if ($result['success']) {
$moduleInfo = $result['module_info'] ?? null;
+ // PO #7: 재활성화 시 cascade 비활성화됐던 언어팩 목록 응답에 포함 (PO #8: 빈 배열이면 모달 표시 안 함)
+ $pendingLanguagePacks = app(\App\Services\LanguagePack\LanguagePackBundledRegistrar::class)
+ ->getPendingForReactivation('module', $moduleName);
+
if ($moduleInfo) {
- return $this->successWithResource(
- 'module.activate_success',
- new ModuleResource($moduleInfo)
- );
+ return $this->success('module.activate_success', [
+ 'module' => (new ModuleResource($moduleInfo))->resolve(),
+ 'pending_language_packs' => $pendingLanguagePacks,
+ ]);
}
- return $this->success('module.activate_success', $result);
+ return $this->success('module.activate_success', array_merge($result, [
+ 'pending_language_packs' => $pendingLanguagePacks,
+ ]));
} else {
return $this->error('module.activate_failed');
}
} catch (ValidationException $e) {
return $this->error('module.activate_failed', 422, $e->errors());
} catch (\Exception $e) {
- return $this->error('module.activate_failed', 500, $e->getMessage());
+ return $this->error('module.activate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
@@ -270,7 +321,7 @@ class ModuleController extends AdminBaseController
} catch (ValidationException $e) {
return $this->error('module.deactivate_failed', 422, $e->errors());
} catch (\Exception $e) {
- return $this->error('module.deactivate_failed', 500, $e->getMessage());
+ return $this->error('module.deactivate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
@@ -290,7 +341,7 @@ class ModuleController extends AdminBaseController
'total' => count($dependentTemplates),
]);
} catch (\Exception $e) {
- return $this->error('module.dependent_templates_failed', 500, $e->getMessage());
+ return $this->error('module.dependent_templates_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
@@ -311,7 +362,7 @@ class ModuleController extends AdminBaseController
return $this->success('module.uninstall_info_success', $uninstallInfo);
} catch (\Exception $e) {
- return $this->error('module.uninstall_info_failed', 500, $e->getMessage());
+ return $this->error('module.uninstall_info_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
@@ -338,7 +389,26 @@ class ModuleController extends AdminBaseController
} catch (ValidationException $e) {
return $this->error('module.uninstall_failed', 422, $e->errors());
} catch (\Exception $e) {
- return $this->error('module.uninstall_failed', 500, $e->getMessage());
+ return $this->error('module.uninstall_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
+ }
+ }
+
+ /**
+ * 업로드된 ZIP 의 manifest 와 검증 결과만 추출합니다 (실제 설치 X).
+ *
+ * 사용자가 모듈 설치 전 module.json 검증 실패 사유를 미리 확인할 수 있도록 합니다.
+ *
+ * @param PreviewModuleManifestRequest $request 미리보기 요청
+ * @return JsonResponse manifest + validation 결과
+ */
+ public function manifestPreview(PreviewModuleManifestRequest $request): JsonResponse
+ {
+ try {
+ $result = $this->moduleService->previewManifest($request->file('file'));
+
+ return $this->success('module.preview_success', $result);
+ } catch (\Throwable $e) {
+ return $this->error('module.preview_failed', 422, null, ['error' => $e->getMessage()]);
}
}
@@ -362,7 +432,7 @@ class ModuleController extends AdminBaseController
} catch (\RuntimeException $e) {
return $this->error($e->getMessage(), 422);
} catch (\Exception $e) {
- return $this->error('module.install_failed', 500, ['error' => $e->getMessage()]);
+ return $this->error('module.install_failed', 500, null, ['error' => $e->getMessage()]);
}
}
@@ -386,7 +456,7 @@ class ModuleController extends AdminBaseController
} catch (\RuntimeException $e) {
return $this->error($e->getMessage(), 422);
} catch (\Exception $e) {
- return $this->error('module.install_failed', 500, ['error' => $e->getMessage()]);
+ return $this->error('module.install_failed', 500, null, ['error' => $e->getMessage()]);
}
}
@@ -404,7 +474,7 @@ class ModuleController extends AdminBaseController
} catch (ValidationException $e) {
return $this->error('modules.check_updates_failed', 422, $e->errors());
} catch (\Exception $e) {
- return $this->error('modules.check_updates_failed', 500, $e->getMessage());
+ return $this->error('modules.check_updates_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
@@ -426,7 +496,7 @@ class ModuleController extends AdminBaseController
} catch (ValidationException $e) {
return $this->error('modules.check_modified_layouts_failed', 422, $e->errors());
} catch (\Exception $e) {
- return $this->error('modules.check_modified_layouts_failed', 500, $e->getMessage());
+ return $this->error('modules.check_modified_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
@@ -449,7 +519,8 @@ class ModuleController extends AdminBaseController
$validated['vendor_mode'] ?? null
);
$layoutStrategy = $validated['layout_strategy'] ?? 'overwrite';
- $result = $this->moduleService->updateModule($moduleName, $vendorMode, $layoutStrategy);
+ $force = (bool) ($validated['force'] ?? false);
+ $result = $this->moduleService->updateModule($moduleName, $vendorMode, $layoutStrategy, $force);
$moduleInfo = $result['module_info'] ?? null;
@@ -499,7 +570,7 @@ class ModuleController extends AdminBaseController
} catch (ValidationException $e) {
return $this->error('module.refresh_layouts_failed', 422, $e->errors());
} catch (\Exception $e) {
- return $this->error('module.refresh_layouts_failed', 500, $e->getMessage());
+ return $this->error('module.refresh_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
@@ -523,7 +594,7 @@ class ModuleController extends AdminBaseController
return $this->success('module.fetch_success', ['changelog' => $changelog]);
} catch (\Exception $e) {
- return $this->error('module.fetch_failed', 500, $e->getMessage());
+ return $this->error('module.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
diff --git a/app/Http/Controllers/Api/Admin/PluginController.php b/app/Http/Controllers/Api/Admin/PluginController.php
index b662c0b5..b874c688 100644
--- a/app/Http/Controllers/Api/Admin/PluginController.php
+++ b/app/Http/Controllers/Api/Admin/PluginController.php
@@ -2,12 +2,16 @@
namespace App\Http\Controllers\Api\Admin;
+use App\Enums\LanguagePackScope;
use App\Helpers\PermissionHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
+use App\Http\Controllers\Concerns\InjectsExtensionLanguagePacks;
+use App\Http\Controllers\Concerns\OrchestratesCascadeInstall;
use App\Http\Requests\Plugin\ActivatePluginRequest;
use App\Http\Requests\Plugin\DeactivatePluginRequest;
use App\Http\Requests\Plugin\IndexPluginRequest;
use App\Http\Requests\Plugin\InstallPluginFromFileRequest;
+use App\Http\Requests\Plugin\PreviewPluginManifestRequest;
use App\Http\Requests\Plugin\InstallPluginFromGithubRequest;
use App\Http\Requests\Plugin\InstallPluginRequest;
use App\Http\Requests\Plugin\PerformPluginUpdateRequest;
@@ -16,6 +20,7 @@ use App\Http\Requests\Plugin\UninstallPluginRequest;
use App\Http\Requests\Extension\ChangelogRequest;
use App\Http\Resources\PluginCollection;
use App\Http\Resources\PluginResource;
+use App\Services\Extension\ExtensionInstallPreviewBuilder;
use App\Services\LicenseService;
use App\Services\PluginService;
use App\Services\TemplateService;
@@ -30,6 +35,9 @@ use Illuminate\Validation\ValidationException;
*/
class PluginController extends AdminBaseController
{
+ use InjectsExtensionLanguagePacks;
+ use OrchestratesCascadeInstall;
+
public function __construct(
private PluginService $pluginService,
private TemplateService $templateService,
@@ -56,6 +64,7 @@ class PluginController extends AdminBaseController
'search' => $validated['search'] ?? null,
'filters' => $validated['filters'] ?? [],
'status' => $validated['status'] ?? null,
+ 'include_hidden' => (bool) ($validated['include_hidden'] ?? false),
];
$perPage = (int) ($validated['per_page'] ?? 12);
$page = (int) ($validated['page'] ?? 1);
@@ -80,7 +89,7 @@ class PluginController extends AdminBaseController
],
]);
} catch (\Exception $e) {
- return $this->error('plugins.list_failed', 500, $e->getMessage());
+ return $this->error('plugins.list_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
@@ -90,6 +99,7 @@ class PluginController extends AdminBaseController
* @param Request $request HTTP 요청 객체
* @return JsonResponse 설치된 플러그인 목록을 포함한 JSON 응답
*/
+ // audit:allow controller-base-request-injection reason: GET 목록 조회. PluginCollection::toArray($request)/with($request) 전달용
public function installed(Request $request): JsonResponse
{
try {
@@ -102,17 +112,19 @@ class PluginController extends AdminBaseController
'meta' => $collection->with($request)['meta'],
]);
} catch (\Exception $e) {
- return $this->error('plugins.list_failed', 500, $e->getMessage());
+ return $this->error('plugins.list_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
/**
* 특정 플러그인의 상세 정보를 조회합니다.
*
- * @param string $pluginName 플러그인명
+ * @param Request $request HTTP 요청 (attachLanguagePacks 의 Request 인자 전달용)
+ * @param string $pluginName 플러그인 식별자
* @return JsonResponse 플러그인 정보를 포함한 JSON 응답
*/
- public function show(string $pluginName): JsonResponse
+ // audit:allow controller-base-request-injection reason: GET 상세 조회. attachLanguagePacks($detail, scope, name, $request) 전달용
+ public function show(Request $request, string $pluginName): JsonResponse
{
try {
$pluginInfo = $this->pluginService->getPluginInfo($pluginName);
@@ -121,12 +133,36 @@ class PluginController extends AdminBaseController
return $this->error('plugins.not_found', 404, null, ['plugin' => $pluginName]);
}
- // 상세 정보는 toDetailArray() 메서드 사용
+ // 상세 정보는 toDetailArray() 메서드 사용 + 지원 언어팩 주입
$resource = new PluginResource($pluginInfo);
+ $detail = $this->attachLanguagePacks(
+ $resource->toDetailArray(),
+ LanguagePackScope::Plugin,
+ $pluginName,
+ $request,
+ );
- return $this->success('plugins.fetch_success', $resource->toDetailArray());
+ return $this->success('plugins.fetch_success', $detail);
} catch (\Exception $e) {
- return $this->error('plugins.fetch_failed', 500, $e->getMessage());
+ return $this->error('plugins.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
+ }
+ }
+
+ /**
+ * 플러그인 설치 cascade 프리뷰를 반환합니다 (의존 확장 + 동반 가능 번들 언어팩).
+ *
+ * @param string $pluginName 플러그인 식별자
+ * @param ExtensionInstallPreviewBuilder $builder 프리뷰 빌더
+ * @return JsonResponse cascade 프리뷰 응답
+ */
+ public function installPreview(string $pluginName, ExtensionInstallPreviewBuilder $builder): JsonResponse
+ {
+ try {
+ $preview = $builder->build(LanguagePackScope::Plugin, $pluginName);
+
+ return $this->success('plugins.fetch_success', $preview);
+ } catch (\Exception $e) {
+ return $this->error('plugins.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
@@ -144,13 +180,20 @@ class PluginController extends AdminBaseController
$vendorMode = \App\Extension\Vendor\VendorMode::fromStringOrAuto(
$validated['vendor_mode'] ?? null
);
+
+ // cascade 1단계: 사용자가 선택한 의존 확장 사전 설치 (실패 시 abort)
+ $this->installSelectedDependencies($validated['dependencies'] ?? []);
+
$pluginInfo = $this->pluginService->installPlugin($pluginName, $vendorMode);
if ($pluginInfo) {
- return $this->successWithResource(
- 'plugins.install_success',
- new PluginResource($pluginInfo)
- );
+ // cascade 2단계: 동반 번들 언어팩 best-effort 설치
+ $lpFailures = $this->installSelectedLanguagePacks($validated['language_packs'] ?? []);
+
+ $payload = (new PluginResource($pluginInfo))->toArray($request);
+ $payload['language_pack_failures'] = $lpFailures;
+
+ return $this->success('plugins.install_success', $payload);
} else {
return $this->error('plugins.install_failed');
}
@@ -198,14 +241,20 @@ class PluginController extends AdminBaseController
if ($result['success']) {
$pluginInfo = $result['plugin_info'] ?? null;
+ // PO #7: 재활성화 시 cascade 비활성화됐던 언어팩 목록 응답에 포함
+ $pendingLanguagePacks = app(\App\Services\LanguagePack\LanguagePackBundledRegistrar::class)
+ ->getPendingForReactivation('plugin', $pluginName);
+
if ($pluginInfo) {
- return $this->successWithResource(
- 'plugins.activate_success',
- new PluginResource($pluginInfo)
- );
+ return $this->success('plugins.activate_success', [
+ 'plugin' => (new PluginResource($pluginInfo))->resolve(),
+ 'pending_language_packs' => $pendingLanguagePacks,
+ ]);
}
- return $this->success('plugins.activate_success', $result);
+ return $this->success('plugins.activate_success', array_merge($result, [
+ 'pending_language_packs' => $pendingLanguagePacks,
+ ]));
} else {
return $this->error('plugins.activate_failed');
}
@@ -292,12 +341,12 @@ class PluginController extends AdminBaseController
try {
$dependentTemplates = $this->templateService->getTemplatesDependingOnPlugin($identifier);
- return $this->success('plugin.dependent_templates_success', [
+ return $this->success('plugins.dependent_templates_success', [
'data' => $dependentTemplates,
'total' => count($dependentTemplates),
]);
} catch (\Exception $e) {
- return $this->error('plugin.dependent_templates_failed', 500, $e->getMessage());
+ return $this->error('plugins.dependent_templates_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
@@ -318,7 +367,7 @@ class PluginController extends AdminBaseController
return $this->success('plugins.uninstall_info_success', $uninstallInfo);
} catch (\Exception $e) {
- return $this->error('plugins.uninstall_info_failed', 500, $e->getMessage());
+ return $this->error('plugins.uninstall_info_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
@@ -357,6 +406,23 @@ class PluginController extends AdminBaseController
}
}
+ /**
+ * 업로드된 ZIP 의 manifest 와 검증 결과만 추출합니다 (실제 설치 X).
+ *
+ * @param PreviewPluginManifestRequest $request 미리보기 요청
+ * @return JsonResponse manifest + validation 결과
+ */
+ public function manifestPreview(PreviewPluginManifestRequest $request): JsonResponse
+ {
+ try {
+ $result = $this->pluginService->previewManifest($request->file('file'));
+
+ return $this->success('plugins.preview_success', $result);
+ } catch (\Throwable $e) {
+ return $this->error('plugins.preview_failed', 422, null, ['error' => $e->getMessage()]);
+ }
+ }
+
/**
* ZIP 파일에서 플러그인을 설치합니다.
*
@@ -370,14 +436,14 @@ class PluginController extends AdminBaseController
$plugin = $this->pluginService->installFromZipFile($file);
return $this->successWithResource(
- 'plugin.install_success',
+ 'plugins.install_success',
new PluginResource($plugin),
201
);
} catch (\RuntimeException $e) {
return $this->error($e->getMessage(), 422);
} catch (\Exception $e) {
- return $this->error('plugin.install_failed', 500, ['error' => $e->getMessage()]);
+ return $this->error('plugins.install_failed', 500, null, ['error' => $e->getMessage()]);
}
}
@@ -394,14 +460,14 @@ class PluginController extends AdminBaseController
$plugin = $this->pluginService->installFromGithub($githubUrl);
return $this->successWithResource(
- 'plugin.install_success',
+ 'plugins.install_success',
new PluginResource($plugin),
201
);
} catch (\RuntimeException $e) {
return $this->error($e->getMessage(), 422);
} catch (\Exception $e) {
- return $this->error('plugin.install_failed', 500, ['error' => $e->getMessage()]);
+ return $this->error('plugins.install_failed', 500, null, ['error' => $e->getMessage()]);
}
}
@@ -419,7 +485,7 @@ class PluginController extends AdminBaseController
} catch (ValidationException $e) {
return $this->error('plugins.check_updates_failed', 422, $e->errors());
} catch (\Exception $e) {
- return $this->error('plugins.check_updates_failed', 500, $e->getMessage());
+ return $this->error('plugins.check_updates_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
@@ -438,7 +504,7 @@ class PluginController extends AdminBaseController
} catch (ValidationException $e) {
return $this->error('plugins.check_modified_layouts_failed', 422, $e->errors());
} catch (\Exception $e) {
- return $this->error('plugins.check_modified_layouts_failed', 500, $e->getMessage());
+ return $this->error('plugins.check_modified_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
@@ -461,7 +527,8 @@ class PluginController extends AdminBaseController
$validated['vendor_mode'] ?? null
);
$layoutStrategy = $validated['layout_strategy'] ?? 'overwrite';
- $result = $this->pluginService->updatePlugin($pluginName, $vendorMode, $layoutStrategy);
+ $force = (bool) ($validated['force'] ?? false);
+ $result = $this->pluginService->updatePlugin($pluginName, $vendorMode, $layoutStrategy, $force);
$pluginInfo = $result['plugin_info'] ?? null;
@@ -547,9 +614,9 @@ class PluginController extends AdminBaseController
$validated['to_version'] ?? null,
);
- return $this->success('plugin.fetch_success', ['changelog' => $changelog]);
+ return $this->success('plugins.fetch_success', ['changelog' => $changelog]);
} catch (\Exception $e) {
- return $this->error('plugin.fetch_failed', 500, $e->getMessage());
+ return $this->error('plugins.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
diff --git a/app/Http/Controllers/Api/Admin/SeoCacheController.php b/app/Http/Controllers/Api/Admin/SeoCacheController.php
index 001a1c75..c449ab55 100644
--- a/app/Http/Controllers/Api/Admin/SeoCacheController.php
+++ b/app/Http/Controllers/Api/Admin/SeoCacheController.php
@@ -6,6 +6,7 @@ use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Admin\SeoCacheClearRequest;
use App\Seo\Contracts\SeoCacheManagerInterface;
use App\Seo\SeoCacheStatsService;
+use App\Seo\SitemapManager;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
@@ -18,7 +19,8 @@ class SeoCacheController extends AdminBaseController
{
public function __construct(
private SeoCacheStatsService $statsService,
- private SeoCacheManagerInterface $cacheManager
+ private SeoCacheManagerInterface $cacheManager,
+ private SitemapManager $sitemapManager
) {
parent::__construct();
}
@@ -94,6 +96,31 @@ class SeoCacheController extends AdminBaseController
}
}
+ /**
+ * Sitemap XML 을 즉시 재생성합니다.
+ *
+ * 큐 드라이버와 무관하게 동기 실행되며, 생성 완료 후 last_updated_at 을 갱신합니다.
+ *
+ * @return JsonResponse 재생성 결과 JSON 응답
+ */
+ public function regenerateSitemap(): JsonResponse
+ {
+ $result = $this->sitemapManager->regenerate();
+
+ if ($result['success']) {
+ return $this->success('seo.sitemap_regenerated', $result['data'] ?? null);
+ }
+
+ $messageKey = match ($result['status']) {
+ 'disabled' => 'seo.sitemap_disabled',
+ default => 'seo.sitemap_regenerate_failed',
+ };
+
+ $statusCode = $result['status'] === 'disabled' ? 400 : 500;
+
+ return $this->error($messageKey, $statusCode, $result['message'] ?? null);
+ }
+
/**
* 캐시된 URL 목록을 조회합니다.
*
diff --git a/app/Http/Controllers/Api/Admin/TemplateController.php b/app/Http/Controllers/Api/Admin/TemplateController.php
index e2d00849..483eb421 100644
--- a/app/Http/Controllers/Api/Admin/TemplateController.php
+++ b/app/Http/Controllers/Api/Admin/TemplateController.php
@@ -2,12 +2,16 @@
namespace App\Http\Controllers\Api\Admin;
+use App\Enums\LanguagePackScope;
use App\Helpers\PermissionHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
+use App\Http\Controllers\Concerns\InjectsExtensionLanguagePacks;
+use App\Http\Controllers\Concerns\OrchestratesCascadeInstall;
use App\Http\Requests\Template\ActivateTemplateRequest;
use App\Http\Requests\Template\DeactivateTemplateRequest;
use App\Http\Requests\Template\IndexTemplateRequest;
use App\Http\Requests\Template\InstallTemplateFromFileRequest;
+use App\Http\Requests\Template\PreviewTemplateManifestRequest;
use App\Http\Requests\Template\InstallTemplateFromGithubRequest;
use App\Http\Requests\Template\InstallTemplateRequest;
use App\Http\Requests\Template\PerformTemplateUpdateRequest;
@@ -16,6 +20,7 @@ use App\Http\Requests\Template\UninstallTemplateRequest;
use App\Http\Requests\Extension\ChangelogRequest;
use App\Http\Resources\TemplateCollection;
use App\Http\Resources\TemplateResource;
+use App\Services\Extension\ExtensionInstallPreviewBuilder;
use App\Services\LicenseService;
use App\Services\TemplateService;
use Illuminate\Http\JsonResponse;
@@ -29,6 +34,9 @@ use Illuminate\Validation\ValidationException;
*/
class TemplateController extends AdminBaseController
{
+ use InjectsExtensionLanguagePacks;
+ use OrchestratesCascadeInstall;
+
public function __construct(
private TemplateService $templateService,
private LicenseService $licenseService
@@ -55,6 +63,7 @@ class TemplateController extends AdminBaseController
'filters' => $validated['filters'] ?? [],
'status' => $validated['status'] ?? null,
'type' => $validated['type'] ?? null,
+ 'include_hidden' => (bool) ($validated['include_hidden'] ?? false),
];
$perPage = (int) ($validated['per_page'] ?? 12);
$page = (int) ($validated['page'] ?? 1);
@@ -79,17 +88,19 @@ class TemplateController extends AdminBaseController
],
]);
} catch (\Exception $e) {
- return $this->error('templates.fetch_failed', 500, $e->getMessage());
+ return $this->error('templates.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
/**
* 특정 템플릿의 상세 정보를 조회합니다.
*
+ * @param Request $request HTTP 요청 (attachLanguagePacks 의 Request 인자 전달용)
* @param string $templateName 템플릿 식별자
* @return JsonResponse 템플릿 정보를 포함한 JSON 응답
*/
- public function show(string $templateName): JsonResponse
+ // audit:allow controller-base-request-injection reason: GET 상세 조회. attachLanguagePacks($detail, scope, name, $request) 전달용
+ public function show(Request $request, string $templateName): JsonResponse
{
try {
$templateInfo = $this->templateService->getTemplateInfo($templateName);
@@ -98,12 +109,36 @@ class TemplateController extends AdminBaseController
return $this->error('templates.not_found', 404, null, ['template' => $templateName]);
}
- // 상세 정보는 toDetailArray() 메서드 사용
+ // 상세 정보는 toDetailArray() 메서드 사용 + 지원 언어팩 주입
$resource = new TemplateResource($templateInfo);
+ $detail = $this->attachLanguagePacks(
+ $resource->toDetailArray(),
+ LanguagePackScope::Template,
+ $templateName,
+ $request,
+ );
- return $this->success('templates.fetch_success', $resource->toDetailArray());
+ return $this->success('templates.fetch_success', $detail);
} catch (\Exception $e) {
- return $this->error('templates.fetch_failed', 500, $e->getMessage());
+ return $this->error('templates.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
+ }
+ }
+
+ /**
+ * 템플릿 설치 cascade 프리뷰를 반환합니다 (의존 확장 + 동반 가능 번들 언어팩).
+ *
+ * @param string $templateName 템플릿 식별자
+ * @param ExtensionInstallPreviewBuilder $builder 프리뷰 빌더
+ * @return JsonResponse cascade 프리뷰 응답
+ */
+ public function installPreview(string $templateName, ExtensionInstallPreviewBuilder $builder): JsonResponse
+ {
+ try {
+ $preview = $builder->build(LanguagePackScope::Template, $templateName);
+
+ return $this->success('templates.fetch_success', $preview);
+ } catch (\Exception $e) {
+ return $this->error('templates.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
@@ -116,15 +151,22 @@ class TemplateController extends AdminBaseController
public function install(InstallTemplateRequest $request): JsonResponse
{
try {
- $templateName = $request->validated()['template_name'];
+ $validated = $request->validated();
+ $templateName = $validated['template_name'];
+
+ // cascade 1단계: 사용자가 선택한 의존 확장 사전 설치 (실패 시 abort)
+ $this->installSelectedDependencies($validated['dependencies'] ?? []);
+
$template = $this->templateService->installTemplate($templateName);
if ($template) {
- return $this->successWithResource(
- 'templates.install_success',
- new TemplateResource($template),
- 201
- );
+ // cascade 2단계: 동반 번들 언어팩 best-effort 설치
+ $lpFailures = $this->installSelectedLanguagePacks($validated['language_packs'] ?? []);
+
+ $payload = (new TemplateResource($template))->toArray($request);
+ $payload['language_pack_failures'] = $lpFailures;
+
+ return $this->success('templates.install_success', $payload, 201);
} else {
return $this->error('templates.install_failed');
}
@@ -172,21 +214,27 @@ class TemplateController extends AdminBaseController
if ($result['success']) {
$templateInfo = $result['template_info'] ?? null;
+ // PO #7: 재활성화 시 cascade 비활성화됐던 언어팩 목록 응답에 포함
+ $pendingLanguagePacks = app(\App\Services\LanguagePack\LanguagePackBundledRegistrar::class)
+ ->getPendingForReactivation('template', $templateName);
+
if ($templateInfo) {
- return $this->successWithResource(
- 'templates.activate_success',
- new TemplateResource($templateInfo)
- );
+ return $this->success('templates.activate_success', [
+ 'template' => (new TemplateResource($templateInfo))->resolve(),
+ 'pending_language_packs' => $pendingLanguagePacks,
+ ]);
}
- return $this->success('templates.activate_success', $result);
+ return $this->success('templates.activate_success', array_merge($result, [
+ 'pending_language_packs' => $pendingLanguagePacks,
+ ]));
} else {
return $this->error('templates.activate_failed');
}
} catch (ValidationException $e) {
return $this->error('templates.activate_failed', 422, $e->errors());
} catch (\Exception $e) {
- return $this->error('templates.activate_failed', 500, $e->getMessage());
+ return $this->error('templates.activate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
@@ -213,7 +261,7 @@ class TemplateController extends AdminBaseController
} catch (ValidationException $e) {
return $this->error('templates.deactivate_failed', 422, $e->errors());
} catch (\Exception $e) {
- return $this->error('templates.deactivate_failed', 500, $e->getMessage());
+ return $this->error('templates.deactivate_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
@@ -240,7 +288,7 @@ class TemplateController extends AdminBaseController
} catch (ValidationException $e) {
return $this->error('templates.uninstall_failed', 422, $e->errors());
} catch (\Exception $e) {
- return $this->error('templates.uninstall_failed', 500, $e->getMessage());
+ return $this->error('templates.uninstall_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
@@ -261,7 +309,24 @@ class TemplateController extends AdminBaseController
return $this->success('templates.uninstall_info_success', $uninstallInfo);
} catch (\Exception $e) {
- return $this->error('templates.uninstall_info_failed', 500, $e->getMessage());
+ return $this->error('templates.uninstall_info_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
+ }
+ }
+
+ /**
+ * 업로드된 ZIP 의 manifest 와 검증 결과만 추출합니다 (실제 설치 X).
+ *
+ * @param PreviewTemplateManifestRequest $request 미리보기 요청
+ * @return JsonResponse manifest + validation 결과
+ */
+ public function manifestPreview(PreviewTemplateManifestRequest $request): JsonResponse
+ {
+ try {
+ $result = $this->templateService->previewManifest($request->file('file'));
+
+ return $this->success('templates.preview_success', $result);
+ } catch (\Throwable $e) {
+ return $this->error('templates.preview_failed', 422, null, ['error' => $e->getMessage()]);
}
}
@@ -285,7 +350,7 @@ class TemplateController extends AdminBaseController
} catch (\RuntimeException $e) {
return $this->error($e->getMessage(), 422);
} catch (\Exception $e) {
- return $this->error('templates.install_failed', 500, ['error' => $e->getMessage()]);
+ return $this->error('templates.install_failed', 500, null, ['error' => $e->getMessage()]);
}
}
@@ -309,7 +374,7 @@ class TemplateController extends AdminBaseController
} catch (\RuntimeException $e) {
return $this->error($e->getMessage(), 422);
} catch (\Exception $e) {
- return $this->error('templates.install_failed', 500, ['error' => $e->getMessage()]);
+ return $this->error('templates.install_failed', 500, null, ['error' => $e->getMessage()]);
}
}
@@ -336,7 +401,7 @@ class TemplateController extends AdminBaseController
} catch (ValidationException $e) {
return $this->error('templates.refresh_layouts_failed', 422, $e->errors());
} catch (\Exception $e) {
- return $this->error('templates.refresh_layouts_failed', 500, $e->getMessage());
+ return $this->error('templates.refresh_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
@@ -354,7 +419,7 @@ class TemplateController extends AdminBaseController
} catch (ValidationException $e) {
return $this->error('templates.check_updates_failed', 422, $e->errors());
} catch (\Exception $e) {
- return $this->error('templates.check_updates_failed', 500, $e->getMessage());
+ return $this->error('templates.check_updates_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
@@ -376,7 +441,7 @@ class TemplateController extends AdminBaseController
} catch (ValidationException $e) {
return $this->error('templates.check_modified_layouts_failed', 422, $e->errors());
} catch (\Exception $e) {
- return $this->error('templates.check_modified_layouts_failed', 500, $e->getMessage());
+ return $this->error('templates.check_modified_layouts_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
@@ -394,8 +459,10 @@ class TemplateController extends AdminBaseController
public function performUpdate(PerformTemplateUpdateRequest $request, string $templateName): JsonResponse
{
try {
- $layoutStrategy = $request->validated()['layout_strategy'] ?? 'overwrite';
- $result = $this->templateService->performVersionUpdate($templateName, $layoutStrategy);
+ $validated = $request->validated();
+ $layoutStrategy = $validated['layout_strategy'] ?? 'overwrite';
+ $force = (bool) ($validated['force'] ?? false);
+ $result = $this->templateService->performVersionUpdate($templateName, $layoutStrategy, $force);
$templateInfo = $result['template_info'] ?? null;
@@ -442,7 +509,7 @@ class TemplateController extends AdminBaseController
return $this->success('template.fetch_success', ['changelog' => $changelog]);
} catch (\Exception $e) {
- return $this->error('template.fetch_failed', 500, $e->getMessage());
+ return $this->error('template.fetch_failed', 500, $e->getMessage(), ['error' => $e->getMessage()]);
}
}
diff --git a/app/Http/Controllers/Api/Admin/UserController.php b/app/Http/Controllers/Api/Admin/UserController.php
index 907546f7..b231d9c2 100644
--- a/app/Http/Controllers/Api/Admin/UserController.php
+++ b/app/Http/Controllers/Api/Admin/UserController.php
@@ -2,7 +2,6 @@
namespace App\Http\Controllers\Api\Admin;
-use App\Exceptions\CannotDeleteAdminException;
use App\Exceptions\CannotDeleteSuperAdminException;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\User\BulkUpdateUserStatusRequest;
@@ -149,8 +148,6 @@ class UserController extends AdminBaseController
}
} catch (CannotDeleteSuperAdminException $e) {
return $this->error('exceptions.cannot_delete_super_admin', 422);
- } catch (CannotDeleteAdminException $e) {
- return $this->error('user.delete_admin_forbidden', 422);
} catch (ValidationException $e) {
return $this->error('user.delete_failed', 422, $e->errors());
} catch (Exception $e) {
diff --git a/app/Http/Controllers/Api/Auth/AuthController.php b/app/Http/Controllers/Api/Auth/AuthController.php
index fa50fe45..99417a4d 100644
--- a/app/Http/Controllers/Api/Auth/AuthController.php
+++ b/app/Http/Controllers/Api/Auth/AuthController.php
@@ -8,6 +8,7 @@ use App\Http\Requests\Auth\LoginRequest;
use App\Http\Requests\Auth\RegisterRequest;
use App\Http\Requests\Auth\ResetPasswordRequest;
use App\Http\Requests\Auth\ValidateResetTokenRequest;
+use App\Exceptions\Auth\AccountLockedException;
use App\Http\Resources\UserResource;
use App\Services\AuthService;
use Illuminate\Http\JsonResponse;
@@ -50,6 +51,11 @@ class AuthController extends AuthBaseController
$data['user'] = new UserResource($data['user']);
return $this->success('auth.login_success', $data);
+ } catch (AccountLockedException $e) {
+ return $this->error('auth.account_locked', 423, [
+ 'locked_until' => $e->lockedUntil->toIso8601String(),
+ 'retry_after_seconds' => $e->remainingMinutes * 60,
+ ], ['minutes' => $e->remainingMinutes]);
} catch (ValidationException $e) {
return $this->unauthorized('auth.login_failed');
}
diff --git a/app/Http/Controllers/Api/Auth/ProfileController.php b/app/Http/Controllers/Api/Auth/ProfileController.php
index d466ba71..c311fdb8 100644
--- a/app/Http/Controllers/Api/Auth/ProfileController.php
+++ b/app/Http/Controllers/Api/Auth/ProfileController.php
@@ -100,7 +100,7 @@ class ProfileController extends AuthBaseController
return $this->unauthorized('auth.unauthenticated');
}
- if (! in_array($language, ['ko', 'en'])) {
+ if (! in_array($language, config('app.supported_locales', ['ko', 'en']), true)) {
return $this->error('user.invalid_language', 400);
}
@@ -282,6 +282,8 @@ class ProfileController extends AuthBaseController
} catch (ValidationException $e) {
return $this->validationError($e->errors(), 'user.password_change_failed');
} catch (\Exception $e) {
+ // 주의: IdentityVerificationRequiredException 은 \Error 자식이라 이 catch 에 잡히지 않음.
+ // 글로벌 핸들러가 자동으로 428 매핑 — 모든 컨트롤러 (코어/모듈/플러그인) 에 동일 보장.
return $this->error('user.password_change_failed', 500, null, ['error' => $e->getMessage()]);
}
}
diff --git a/app/Http/Controllers/Api/Identity/IdentityVerificationController.php b/app/Http/Controllers/Api/Identity/IdentityVerificationController.php
new file mode 100644
index 00000000..8c56fec9
--- /dev/null
+++ b/app/Http/Controllers/Api/Identity/IdentityVerificationController.php
@@ -0,0 +1,357 @@
+validated();
+ $user = $request->user();
+
+ $target = $user ?: ($validated['target'] ?? []);
+ if (! ($user instanceof \App\Models\User) && empty($target['email']) && empty($target['phone'])) {
+ return $this->error('identity.errors.missing_target', 422);
+ }
+
+ $challenge = $this->service->start(
+ purpose: (string) $validated['purpose'],
+ target: $target,
+ context: [
+ 'ip_address' => $request->ip(),
+ 'user_agent' => substr((string) $request->userAgent(), 0, 512),
+ 'origin_type' => \App\Enums\IdentityOriginType::Api->value,
+ 'origin_identifier' => '/api/identity/challenges',
+ ],
+ );
+
+ return $this->success(
+ 'identity.messages.challenge_requested',
+ (new ChallengeResource($challenge))->toArray($request),
+ 201,
+ );
+ }
+
+ /**
+ * Challenge 를 검증합니다. POST /api/identity/challenges/{challenge}/verify
+ *
+ * 라우트는 `permission:user,core.identity.verify` 미들웨어 + Route::model('challenge') 바인딩으로 보호됩니다.
+ * 로그인 사용자는 PermissionMiddleware 의 scope=self 가드가 challenge.user_id 일치를 자동 검증합니다.
+ * 비로그인 게스트는 guest 역할 권한만 통과하면 진입합니다 (Mode B 가입 흐름).
+ *
+ * @param VerifyChallengeRequest $request 검증된 요청 (code 또는 token 포함)
+ * @param IdentityVerificationLog $challenge 라우트 모델 바인딩으로 resolve 된 challenge 로그
+ * @return JsonResponse 검증 결과 (verification_token 포함)
+ */
+ public function verify(VerifyChallengeRequest $request, IdentityVerificationLog $challenge): JsonResponse
+ {
+ $result = $this->service->verify(
+ challengeId: $challenge->id,
+ input: $request->validated(),
+ context: [
+ 'ip_address' => $request->ip(),
+ 'user_agent' => substr((string) $request->userAgent(), 0, 512),
+ ],
+ );
+
+ if (! $result->success) {
+ return $this->error(
+ $result->failureReason ?: 'identity.errors.generic',
+ 422,
+ ['failure_code' => $result->failureCode],
+ );
+ }
+
+ return $this->success('identity.messages.challenge_verified', [
+ 'challenge_id' => $result->challengeId,
+ 'provider_id' => $result->providerId,
+ 'verified_at' => $result->verifiedAt?->toIso8601String(),
+ 'verification_token' => $result->claims['verification_token'] ?? null,
+ ]);
+ }
+
+ /**
+ * Challenge 를 취소합니다. POST /api/identity/challenges/{challenge}/cancel
+ *
+ * 라우트는 `permission:user,core.identity.cancel` 미들웨어 + Route::model('challenge') 바인딩으로 보호됩니다.
+ * 로그인 사용자는 PermissionMiddleware 의 scope=self 가드가 challenge.user_id 일치를 자동 검증합니다.
+ * 비로그인 게스트는 guest 역할 권한만 통과하면 진입합니다 (모달 취소 시 audit trail 정합용).
+ *
+ * @param CancelChallengeRequest $request 검증된 요청
+ * @param IdentityVerificationLog $challenge 라우트 모델 바인딩으로 resolve 된 challenge 로그
+ * @return JsonResponse
+ */
+ public function cancel(CancelChallengeRequest $request, IdentityVerificationLog $challenge): JsonResponse
+ {
+ $ok = $this->service->cancel($challenge->id);
+
+ if (! $ok) {
+ return $this->error('identity.errors.challenge_not_found', 404);
+ }
+
+ return $this->success('identity.messages.challenge_cancelled');
+ }
+
+ /**
+ * Challenge 의 공개 상태를 폴링합니다. GET /api/identity/challenges/{challenge}
+ *
+ * 비동기 검증 흐름(Stripe Identity / 토스인증 push / 외부 redirect 콜백 대기) 에서 클라이언트가
+ * verify 즉시 응답을 받지 못할 때 상태를 추적하기 위한 엔드포인트.
+ *
+ * 노출 필드는 공개 안전 항목만 (시도 횟수·코드 본체·metadata 노출 금지) — Service::getStatus 참조.
+ *
+ * @param ShowChallengeRequest $request 검증된 요청
+ * @param IdentityVerificationLog $challenge 라우트 모델 바인딩으로 resolve 된 challenge 로그
+ * @return JsonResponse
+ * @since engine-v1.46.0
+ */
+ public function show(ShowChallengeRequest $request, IdentityVerificationLog $challenge): JsonResponse
+ {
+ $status = $this->service->getStatus($challenge->id);
+
+ if ($status === null) {
+ return $this->error('identity.errors.challenge_not_found', 404);
+ }
+
+ return $this->success('messages.success', $status);
+ }
+
+ /**
+ * 외부 IDV provider 의 redirect 콜백을 수신합니다. POST /api/identity/callback/{providerId}
+ *
+ * 외부 본인인증 SDK / OAuth-style provider 가 사용자 브라우저를 우리 서버로 다시 보내는 진입점.
+ * body/query 에서 challenge_id 를 추출 → Service::handleProviderCallback 위임.
+ *
+ * 응답 정책 — 클라이언트가 stash 한 페이지(`return` query) 가 있으면 redirect, 없으면 JSON 응답:
+ * - 성공 + return 있음: 302 → `{return}?verification_token=...`
+ * - 성공 + return 없음: 200 JSON `{ verification_token }`
+ * - 실패 + return 있음: 302 → `{return}?identity_error={failure_code}`
+ * - 실패 + return 없음: 422 JSON
+ *
+ * @param IdentityCallbackRequest $request 검증된 요청
+ * @param string $providerId 콜백을 보낸 provider 식별자
+ * @return JsonResponse|RedirectResponse
+ * @since engine-v1.46.0
+ */
+ public function callback(IdentityCallbackRequest $request, string $providerId)
+ {
+ $validated = $request->validated();
+ $challengeId = (string) $validated['challenge_id'];
+ $returnUrl = (string) $request->query('return', '');
+
+ $result = $this->service->handleProviderCallback(
+ providerId: $providerId,
+ challengeId: $challengeId,
+ input: $validated,
+ context: [
+ 'ip_address' => $request->ip(),
+ 'user_agent' => substr((string) $request->userAgent(), 0, 512),
+ ],
+ );
+
+ if (! $result->success) {
+ if ($returnUrl !== '' && $this->isSafeReturnUrl($returnUrl)) {
+ $sep = str_contains($returnUrl, '?') ? '&' : '?';
+
+ return redirect()->away(
+ $returnUrl.$sep.'identity_error='.urlencode($result->failureCode ?? 'UNKNOWN'),
+ );
+ }
+
+ return $this->error(
+ $result->failureReason ?: 'identity.errors.generic',
+ 422,
+ ['failure_code' => $result->failureCode],
+ );
+ }
+
+ $token = $result->claims['verification_token'] ?? '';
+
+ if ($returnUrl !== '' && $this->isSafeReturnUrl($returnUrl)) {
+ $sep = str_contains($returnUrl, '?') ? '&' : '?';
+
+ return redirect()->away(
+ $returnUrl.$sep.'verification_token='.urlencode((string) $token).'&challenge_id='.urlencode($challengeId),
+ );
+ }
+
+ return $this->success('identity.messages.challenge_verified', [
+ 'challenge_id' => $result->challengeId,
+ 'provider_id' => $result->providerId,
+ 'verified_at' => $result->verifiedAt?->toIso8601String(),
+ 'verification_token' => $token,
+ ]);
+ }
+
+ /**
+ * `return` 쿼리 URL 이 같은 origin 인지 검증 — open redirect 차단.
+ *
+ * 절대 URL 이면 host 가 현재 앱 host 와 일치해야 통과, 상대 경로(`/...`) 는 통과.
+ */
+ private function isSafeReturnUrl(string $url): bool
+ {
+ if ($url === '' || $url[0] === '/') {
+ return ! str_starts_with($url, '//'); // protocol-relative 차단
+ }
+
+ $appHost = parse_url((string) config('app.url'), PHP_URL_HOST);
+ $urlHost = parse_url($url, PHP_URL_HOST);
+
+ return $appHost !== null && $urlHost !== null && $appHost === $urlHost;
+ }
+
+ /**
+ * 등록된 프로바이더 목록을 반환합니다. GET /api/identity/providers
+ *
+ * @param ProvidersIndexRequest $request 검증된 요청
+ * @return JsonResponse 프로바이더 공개 메타데이터 목록
+ */
+ public function providers(ProvidersIndexRequest $request): JsonResponse
+ {
+ $providers = array_values($this->manager->all());
+ $data = array_map(
+ fn ($p) => (new ProviderResource($p))->toArray($request),
+ $providers,
+ );
+
+ return $this->success('messages.success', $data);
+ }
+
+ /**
+ * 등록된 purpose 목록을 반환합니다 (core.identity.purposes 필터 훅 통과).
+ * GET /api/identity/purposes
+ *
+ * @param PurposesIndexRequest $request 검증된 요청
+ * @return JsonResponse purpose 키 => 메타 매핑
+ */
+ public function purposes(PurposesIndexRequest $request): JsonResponse
+ {
+ // 코어 기본 4종 + 활성 모듈/플러그인 `getIdentityPurposes()` 선언 +
+ // `core.identity.purposes` filter 훅 (서드파티 동적 확장) 을 모두 병합
+ $purposes = $this->manager->getAllPurposes();
+
+ $data = [];
+ foreach ($purposes as $key => $meta) {
+ $data[] = [
+ 'id' => $key,
+ 'label' => $this->resolvePurposeText($meta['label'] ?? $key),
+ 'description' => $this->resolvePurposeText($meta['description'] ?? ''),
+ 'default_provider' => $meta['default_provider'] ?? null,
+ 'allowed_channels' => $meta['allowed_channels'] ?? [],
+ 'source_type' => $meta['source_type'] ?? 'core',
+ 'source_identifier' => $meta['source_identifier'] ?? 'core',
+ ];
+ }
+
+ return $this->success('messages.success', $data);
+ }
+
+ /**
+ * purpose meta 의 label/description 값을 현재 로케일 문자열로 정규화합니다.
+ *
+ * 입력 형태 3가지 지원:
+ * - i18n 키 문자열 (예: `identity.purposes.signup.label`) → `__()` 로 풀이
+ * - 다국어 배열 (`['ko' => ..., 'en' => ...]`) → 현재 로케일 우선, en 폴백
+ * - 일반 문자열 → 그대로 반환
+ *
+ * @param mixed $value
+ */
+ private function resolvePurposeText($value): string
+ {
+ if (is_array($value)) {
+ $locale = app()->getLocale();
+
+ return (string) ($value[$locale] ?? $value['en'] ?? reset($value) ?: '');
+ }
+
+ if (! is_string($value) || $value === '') {
+ return '';
+ }
+
+ // i18n 키처럼 보이는 경우 (`identity.*` 또는 `*.purposes.*` 등)
+ if (str_contains($value, '.')) {
+ $translated = __($value);
+
+ return is_string($translated) ? $translated : $value;
+ }
+
+ return $value;
+ }
+
+ /**
+ * 지정된 scope+target 조합에 대한 정책을 조회합니다 (프론트엔드 프리페치용).
+ *
+ * GET /api/identity/policies/resolve?scope=route&target=api.me.password.update
+ * → 레이아웃 마운트 시 "이 페이지에서 IDV 가 요구될 수 있는 API" 를 미리 파악해
+ * UI 힌트(버튼 배지 "확인 필요" 등) 를 표시하기 위한 엔드포인트.
+ *
+ * @param ResolvePolicyRequest $request 검증된 요청 (scope+target query)
+ * @return JsonResponse 매칭 정책 요약 또는 null
+ */
+ public function resolvePolicy(ResolvePolicyRequest $request): JsonResponse
+ {
+ $validated = $request->validated();
+ $scope = (string) $validated['scope'];
+ $target = (string) $validated['target'];
+
+ $policy = $this->policyService->resolve($scope, $target);
+ if (! $policy || ! $policy->enabled) {
+ return $this->success('messages.success', null);
+ }
+
+ // 민감 필드는 노출하지 않고 UI 힌트에 필요한 최소 필드만 반환
+ return $this->success('messages.success', [
+ 'policy_key' => $policy->key,
+ 'scope' => $policy->scope,
+ 'target' => $policy->target,
+ 'purpose' => $policy->purpose,
+ 'provider_id' => $policy->provider_id,
+ 'grace_minutes' => $policy->grace_minutes,
+ 'applies_to' => $policy->applies_to,
+ 'fail_mode' => $policy->fail_mode,
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Api/Public/LocaleController.php b/app/Http/Controllers/Api/Public/LocaleController.php
new file mode 100644
index 00000000..5113d47d
--- /dev/null
+++ b/app/Http/Controllers/Api/Public/LocaleController.php
@@ -0,0 +1,36 @@
+success('locales.fetched', [
+ 'locales' => $this->languagePackService->getActiveLocales(),
+ 'locale_names' => (array) config('app.locale_names', []),
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Concerns/InjectsExtensionLanguagePacks.php b/app/Http/Controllers/Concerns/InjectsExtensionLanguagePacks.php
new file mode 100644
index 00000000..671d8b57
--- /dev/null
+++ b/app/Http/Controllers/Concerns/InjectsExtensionLanguagePacks.php
@@ -0,0 +1,45 @@
+ $detail Resource toDetailArray 결과
+ * @param LanguagePackScope $scope 대상 스코프
+ * @param string|null $targetIdentifier 대상 식별자 (코어는 null)
+ * @param Request $request HTTP 요청
+ * @return array language_packs 가 추가된 detail 배열
+ */
+ protected function attachLanguagePacks(
+ array $detail,
+ LanguagePackScope $scope,
+ ?string $targetIdentifier,
+ Request $request,
+ ): array {
+ /** @var LanguagePackService $service */
+ $service = app(LanguagePackService::class);
+
+ $packs = $service->getPacksForExtension($scope, $targetIdentifier);
+
+ $detail['language_packs'] = $packs
+ ->map(fn ($pack) => (new LanguagePackResource($pack))->toArray($request))
+ ->all();
+
+ return $detail;
+ }
+}
diff --git a/app/Http/Controllers/Concerns/OrchestratesCascadeInstall.php b/app/Http/Controllers/Concerns/OrchestratesCascadeInstall.php
new file mode 100644
index 00000000..87e3f4a7
--- /dev/null
+++ b/app/Http/Controllers/Concerns/OrchestratesCascadeInstall.php
@@ -0,0 +1,132 @@
+ $dependencies
+ * @return void
+ *
+ * @throws RuntimeException 의존 확장 설치 또는 활성화 실패 시
+ */
+ protected function installSelectedDependencies(array $dependencies): void
+ {
+ if (empty($dependencies)) {
+ return;
+ }
+
+ /** @var ModuleService $moduleService */
+ $moduleService = app(ModuleService::class);
+ /** @var PluginService $pluginService */
+ $pluginService = app(PluginService::class);
+
+ foreach ($dependencies as $dep) {
+ $type = $dep['type'] ?? '';
+ $identifier = $dep['identifier'] ?? '';
+ if ($identifier === '') {
+ continue;
+ }
+
+ try {
+ if ($type === 'module') {
+ $info = $moduleService->getModuleInfo($identifier);
+ $isInstalled = (bool) ($info['is_installed'] ?? false);
+ $isActive = ($info['status'] ?? null) === \App\Enums\ExtensionStatus::Active->value;
+
+ if ($isActive) {
+ continue;
+ }
+ if (! $isInstalled) {
+ $moduleService->installModule($identifier, VendorMode::Auto);
+ }
+ $moduleService->activateModule($identifier);
+ } elseif ($type === 'plugin') {
+ $info = $pluginService->getPluginInfo($identifier);
+ $isInstalled = (bool) ($info['is_installed'] ?? false);
+ $isActive = ($info['status'] ?? null) === \App\Enums\ExtensionStatus::Active->value;
+
+ if ($isActive) {
+ continue;
+ }
+ if (! $isInstalled) {
+ $pluginService->installPlugin($identifier, VendorMode::Auto);
+ }
+ $pluginService->activatePlugin($identifier);
+ }
+ } catch (\Throwable $e) {
+ throw new RuntimeException(__('extensions.errors.cascade_dependency_failed', [
+ 'type' => $type,
+ 'identifier' => $identifier,
+ 'message' => $e->getMessage(),
+ ]));
+ }
+ }
+ }
+
+ /**
+ * 동반 선택된 번들 언어팩을 설치 + 자동 활성화 합니다 (best-effort).
+ *
+ * @param array $bundledIdentifiers 번들 언어팩 식별자 목록
+ * @return array 실패 항목 reason 배열
+ */
+ protected function installSelectedLanguagePacks(array $bundledIdentifiers): array
+ {
+ if (empty($bundledIdentifiers)) {
+ return [];
+ }
+
+ /** @var LanguagePackService $service */
+ $service = app(LanguagePackService::class);
+ $failures = [];
+
+ foreach ($bundledIdentifiers as $identifier) {
+ try {
+ $service->installFromBundled(
+ $identifier,
+ autoActivate: true,
+ installedBy: auth()->id(),
+ );
+ } catch (\Throwable $e) {
+ Log::warning('cascade language pack install failed', [
+ 'identifier' => $identifier,
+ 'error' => $e->getMessage(),
+ ]);
+ $failures[] = [
+ 'identifier' => $identifier,
+ 'reason' => $e->getMessage(),
+ ];
+ }
+ }
+
+ return $failures;
+ }
+}
diff --git a/app/Http/Middleware/CheckUserStatus.php b/app/Http/Middleware/CheckUserStatus.php
index 273daef2..8fcdc52b 100644
--- a/app/Http/Middleware/CheckUserStatus.php
+++ b/app/Http/Middleware/CheckUserStatus.php
@@ -36,6 +36,7 @@ class CheckUserStatus
UserStatus::Inactive->value => 'auth.account_inactive',
UserStatus::Blocked->value => 'auth.account_blocked',
UserStatus::Withdrawn->value => 'auth.account_withdrawn',
+ UserStatus::PendingVerification->value => 'auth.account_pending_verification',
default => 'auth.permission_denied',
};
diff --git a/app/Http/Middleware/EnforceIdentityPolicy.php b/app/Http/Middleware/EnforceIdentityPolicy.php
new file mode 100644
index 00000000..334c28ae
--- /dev/null
+++ b/app/Http/Middleware/EnforceIdentityPolicy.php
@@ -0,0 +1,217 @@
+middleware('identity.policy:core.profile.password_change');
+ *
+ * 어느 모드든 단일 정책 enforce 절차는 동일:
+ * - 요청의 verification_token 이 verified+미소비+purpose 일치+target_hash 일치 → 통과
+ * (IdentityGuardInterceptor 의 verify 직후 재시도 흐름)
+ * - 토큰 미동봉/무효 → IdentityPolicyService::enforce() 가 grace_minutes 윈도우 검사 후
+ * 통과 또는 IdentityVerificationRequiredException throw
+ *
+ * @since 7.0.0-beta.4
+ */
+class EnforceIdentityPolicy
+{
+ /**
+ * @param IdentityPolicyService $policyService 정책 유스케이스 Service
+ * @param IdentityPolicyRepositoryInterface $policyRepository 정책 Repository
+ */
+ public function __construct(
+ protected IdentityPolicyService $policyService,
+ protected IdentityPolicyRepositoryInterface $policyRepository,
+ protected IdentityVerificationLogRepositoryInterface $logRepository,
+ ) {}
+
+ /**
+ * 미들웨어 진입점.
+ *
+ * @param Request $request HTTP 요청
+ * @param Closure $next 다음 파이프라인
+ * @param string|null $policyKey 정책 키 (identity_policies.key)
+ * @return Response
+ *
+ * @throws \App\Exceptions\IdentityVerificationRequiredException 정책 위반 시
+ */
+ public function handle(Request $request, Closure $next, ?string $policyKey = null): Response
+ {
+ $policies = $this->resolvePolicies($request, $policyKey);
+
+ foreach ($policies as $policy) {
+ $this->enforcePolicy($policy, $request);
+ }
+
+ return $next($request);
+ }
+
+ /**
+ * 강제 대상 정책 목록을 결정합니다.
+ *
+ * - 명시 모드: $policyKey 로 단일 정책 조회 (enabled 만 통과)
+ * - 자동 매핑 모드: 라우트 이름으로 캐시된 인덱스에서 매칭 정책 컬렉션 조회
+ *
+ * 라우트 이름이 없는 요청 (예: 404 / health check) 은 빈 배열 반환 → 미들웨어 즉시 통과.
+ *
+ * @return iterable<\App\Models\IdentityPolicy>
+ */
+ protected function resolvePolicies(Request $request, ?string $policyKey): iterable
+ {
+ if ($policyKey !== null && $policyKey !== '') {
+ $policy = $this->policyRepository->findByKey($policyKey);
+
+ return ($policy && $policy->enabled) ? [$policy] : [];
+ }
+
+ $routeName = $request->route()?->getName();
+ if (! is_string($routeName) || $routeName === '') {
+ return [];
+ }
+
+ $index = $this->policyRepository->getRouteScopeIndex();
+
+ return $index[$routeName] ?? [];
+ }
+
+ /**
+ * 단일 정책에 대해 enforce 절차를 수행합니다 (token bypass → grace 윈도우 → exception).
+ *
+ * @throws \App\Exceptions\IdentityVerificationRequiredException 정책 위반 시
+ */
+ protected function enforcePolicy(\App\Models\IdentityPolicy $policy, Request $request): void
+ {
+ // verification_token 우회 — IdentityGuardInterceptor 가 verify 직후 토큰을 query/body 에 부착해
+ // 원 요청을 재실행할 때 grace_minutes 윈도우와 무관하게 통과시킨다.
+ // 검사 순서: ① 토큰이 verified + 미소비 + purpose 일치 → ② target_hash 매칭 (요청 email vs 토큰 발급 시 email).
+ $token = (string) $request->input('verification_token', '');
+ if ($token !== '') {
+ $verifiedLog = $this->logRepository->findVerifiedForToken($token, $policy->purpose);
+ if ($verifiedLog !== null && $this->tokenTargetMatches($verifiedLog, $request)) {
+ return;
+ }
+ }
+
+ $context = [
+ 'http_method' => $request->getMethod(),
+ 'user_roles' => $this->collectUserRoles($request),
+ 'user_is_admin' => $this->resolveUserIsAdmin($request),
+ 'target_email' => $this->resolveTargetEmail($request),
+ 'origin_type' => IdentityOriginType::Route->value,
+ 'origin_identifier' => $request->route()?->getName() ?: $request->path(),
+ 'origin_policy_key' => $policy->key,
+ 'return_request' => [
+ 'method' => $request->getMethod(),
+ 'url' => $request->fullUrl(),
+ ],
+ ];
+
+ $this->policyService->enforce($policy, $request->user(), $context);
+ }
+
+ /**
+ * 토큰이 가리키는 challenge 의 target_hash 가 현재 요청의 식별자(이메일)와 일치하는지 확인합니다.
+ *
+ * 인증 사용자: user.email 우선
+ * 게스트: 요청 body 의 email
+ *
+ * 요청에 식별자가 전혀 없으면(예: 별도 정책에서 email 외 식별자 사용) 검사를 건너뛰고 통과시킵니다 —
+ * 이 경우 다운스트림 listener 가 자기 도메인의 식별자로 매칭을 강제해야 합니다.
+ *
+ * @param \App\Models\IdentityVerificationLog $log 토큰이 가리키는 verified 로그
+ * @param Request $request HTTP 요청
+ * @return bool target_hash 일치 여부 (식별자 미존재 시 true)
+ */
+ protected function tokenTargetMatches(\App\Models\IdentityVerificationLog $log, Request $request): bool
+ {
+ $user = $request->user();
+ $email = $user instanceof User && $user->email
+ ? $user->email
+ : (string) $request->input('email', '');
+
+ if ($email === '') {
+ return true;
+ }
+
+ return $log->target_hash === hash('sha256', mb_strtolower($email));
+ }
+
+ /**
+ * 현재 요청 사용자의 역할 식별자 목록을 수집합니다.
+ *
+ * @param Request $request HTTP 요청
+ * @return array 역할 식별자 배열
+ */
+ protected function collectUserRoles(Request $request): array
+ {
+ $user = $request->user();
+ if (! $user instanceof User) {
+ return [];
+ }
+
+ if (method_exists($user, 'roles')) {
+ return $user->roles()->pluck('identifier')->all();
+ }
+
+ return [];
+ }
+
+ /**
+ * 현재 요청 사용자의 admin 여부를 permission 기반으로 판정합니다 (User::isAdmin() 위임).
+ * 게스트는 항상 false.
+ *
+ * @param Request $request HTTP 요청
+ * @return bool admin 여부
+ */
+ protected function resolveUserIsAdmin(Request $request): bool
+ {
+ $user = $request->user();
+ if (! $user instanceof User) {
+ return false;
+ }
+
+ try {
+ return (bool) $user->isAdmin();
+ } catch (\Throwable) {
+ return false;
+ }
+ }
+
+ /**
+ * 게스트 라우트(register/forgot/reset)에서 폼 입력 email 을 정책 컨텍스트에 노출합니다.
+ * 인증 사용자의 경우는 IdentityPolicyService::resolveTargetHash() 가 user->email 을 우선 사용합니다.
+ *
+ * @param Request $request HTTP 요청
+ * @return string|null 요청 input.email 또는 null
+ */
+ protected function resolveTargetEmail(Request $request): ?string
+ {
+ $email = $request->input('email');
+
+ if (is_string($email) && $email !== '') {
+ return $email;
+ }
+
+ return null;
+ }
+}
diff --git a/app/Http/Middleware/EnsureTokenIsValid.php b/app/Http/Middleware/EnsureTokenIsValid.php
index ff08f549..820921b1 100644
--- a/app/Http/Middleware/EnsureTokenIsValid.php
+++ b/app/Http/Middleware/EnsureTokenIsValid.php
@@ -17,10 +17,10 @@ class EnsureTokenIsValid
*/
public function handle(Request $request, Closure $next): Response
{
- if (!$request->user()) {
+ if (! $request->user()) {
return response()->json([
'success' => false,
- 'message' => 'Unauthenticated'
+ 'message' => __('auth.unauthenticated'),
], 401);
}
diff --git a/app/Http/Middleware/MaintenanceModePage.php b/app/Http/Middleware/MaintenanceModePage.php
index 9c4e0048..3c036c80 100644
--- a/app/Http/Middleware/MaintenanceModePage.php
+++ b/app/Http/Middleware/MaintenanceModePage.php
@@ -31,6 +31,11 @@ class MaintenanceModePage
return $next($request);
}
+ // 로케일 감지 (SetLocale 미들웨어가 실행되지 않으므로 자체 감지)
+ // API/HTML 분기보다 먼저 적용해야 JSON 응답 메시지도 ja 등 활성 언어팩 로케일을 반영한다
+ $locale = $this->detectLocale($request);
+ app()->setLocale($locale);
+
// API 요청은 JSON 응답
if ($request->expectsJson() || $request->is('api/*')) {
return response()->json([
@@ -39,10 +44,6 @@ class MaintenanceModePage
], 503);
}
- // 로케일 감지 (SetLocale 미들웨어가 실행되지 않으므로 자체 감지)
- $locale = $this->detectLocale($request);
- app()->setLocale($locale);
-
// 정적 메인터넌스 페이지 렌더링 (DB/API/JS 무의존)
return response()->view('maintenance', [], 503);
}
diff --git a/app/Http/Middleware/PermissionMiddleware.php b/app/Http/Middleware/PermissionMiddleware.php
index 189ace25..32a75e38 100644
--- a/app/Http/Middleware/PermissionMiddleware.php
+++ b/app/Http/Middleware/PermissionMiddleware.php
@@ -147,6 +147,20 @@ class PermissionMiddleware
return self::$guestRoleCache;
}
+ /**
+ * guest role 캐시를 무효화합니다.
+ *
+ * RolePermissionSeeder 실행 후 자동 호출 — 시드 직후 권한 변경이 즉시 반영되도록 보장.
+ * 운영 환경에서는 권한 재시드 (코어 업데이트 / 확장 install) 시점에 캐시 정합 유지.
+ * 테스트 환경에서는 RefreshDatabase 트랜잭션 rollback 후 새 시드의 stale id 회귀 방지.
+ *
+ * @return void
+ */
+ public static function clearGuestRoleCache(): void
+ {
+ self::$guestRoleCache = null;
+ }
+
/**
* 권한 식별자의 동적 파라미터를 URL 파라미터 값으로 치환합니다.
*
diff --git a/app/Http/Requests/Admin/Identity/AdminIdentityMessageDefinitionIndexRequest.php b/app/Http/Requests/Admin/Identity/AdminIdentityMessageDefinitionIndexRequest.php
new file mode 100644
index 00000000..3d8ed7ec
--- /dev/null
+++ b/app/Http/Requests/Admin/Identity/AdminIdentityMessageDefinitionIndexRequest.php
@@ -0,0 +1,70 @@
+ ['nullable', 'string', 'max:255'],
+ 'provider_id' => ['nullable', 'string', 'max:64'],
+ 'scope_type' => ['nullable', 'string', Rule::in([
+ IdentityMessageDefinition::SCOPE_PROVIDER_DEFAULT,
+ IdentityMessageDefinition::SCOPE_PURPOSE,
+ IdentityMessageDefinition::SCOPE_POLICY,
+ ])],
+ 'scope_value' => ['nullable', 'string', 'max:120'],
+ 'extension_type' => ['nullable', 'string', Rule::in(['core', 'module', 'plugin'])],
+ 'extension_identifier' => ['nullable', 'string', 'max:100'],
+ 'channel' => ['nullable', 'string', 'max:20'],
+ 'is_active' => ['nullable', 'boolean'],
+ 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
+ 'sort_by' => ['nullable', 'string', Rule::in([
+ 'id', 'provider_id', 'scope_type', 'scope_value', 'is_active', 'created_at', 'updated_at',
+ ])],
+ 'sort_order' => ['nullable', 'string', Rule::in(['asc', 'desc'])],
+ ];
+
+ return HookManager::applyFilters(
+ 'core.identity.message_definition.filter_index_rules',
+ $rules
+ );
+ }
+
+ /**
+ * 검증 메시지.
+ *
+ * @return array
+ */
+ public function messages(): array
+ {
+ return [
+ 'per_page.min' => __('validation.min.numeric', ['attribute' => 'per_page', 'min' => 1]),
+ 'per_page.max' => __('validation.max.numeric', ['attribute' => 'per_page', 'max' => 100]),
+ ];
+ }
+}
diff --git a/app/Http/Requests/Admin/Identity/PreviewIdentityMessageTemplateRequest.php b/app/Http/Requests/Admin/Identity/PreviewIdentityMessageTemplateRequest.php
new file mode 100644
index 00000000..1084b30c
--- /dev/null
+++ b/app/Http/Requests/Admin/Identity/PreviewIdentityMessageTemplateRequest.php
@@ -0,0 +1,40 @@
+ ['required', 'integer', Rule::exists(IdentityMessageTemplate::class, 'id')],
+ 'data' => ['sometimes', 'array'],
+ 'data.*' => ['nullable'],
+ 'locale' => ['sometimes', 'nullable', 'string', 'max:10'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Admin/Identity/StoreIdentityMessageDefinitionRequest.php b/app/Http/Requests/Admin/Identity/StoreIdentityMessageDefinitionRequest.php
new file mode 100644
index 00000000..0134c70d
--- /dev/null
+++ b/app/Http/Requests/Admin/Identity/StoreIdentityMessageDefinitionRequest.php
@@ -0,0 +1,138 @@
+ ['required', 'string', 'max:64', $this->providerExistsRule()],
+ 'scope_type' => ['required', Rule::in([IdentityMessageDefinition::SCOPE_POLICY])],
+ 'scope_value' => [
+ 'required',
+ 'string',
+ 'max:120',
+ $this->adminPolicyKeyRule(),
+ $this->scopeUniqueRule(),
+ ],
+ 'name' => ['required', 'array', new LocaleRequiredTranslatable(maxLength: 200)],
+ 'description' => ['nullable', 'array', new TranslatableField(maxLength: 1000)],
+ 'channels' => ['required', 'array', 'min:1'],
+ 'channels.*' => ['string', Rule::in(['mail'])],
+ 'variables' => ['nullable', 'array'],
+ 'variables.*.key' => ['required_with:variables', 'string', 'max:64', 'regex:/^[a-z][a-z0-9_]*$/i'],
+ 'variables.*.description' => ['nullable', 'string', 'max:200'],
+ 'templates' => ['required', 'array', 'min:1'],
+ 'templates.*.channel' => ['required', 'string', 'max:20'],
+ 'templates.*.subject' => ['required', 'array', new LocaleRequiredTranslatable(maxLength: 500)],
+ 'templates.*.body' => ['required', 'array', new LocaleRequiredTranslatable(maxLength: 65535)],
+ ];
+
+ return HookManager::applyFilters(
+ 'core.identity.message_definition.filter_store_rules',
+ $rules,
+ );
+ }
+
+ /**
+ * provider_id 가 등록된 IDV 프로바이더인지 확인하는 closure 룰.
+ *
+ * @return Closure
+ */
+ protected function providerExistsRule(): Closure
+ {
+ return function (string $attribute, mixed $value, Closure $fail): void {
+ if (! is_string($value)) {
+ return;
+ }
+
+ $manager = app(IdentityVerificationManager::class);
+ if (! $manager->has($value)) {
+ $fail(__('validation.identity_message.provider_not_registered'));
+ }
+ };
+ }
+
+ /**
+ * scope_value 가 source_type='admin' 인 IdentityPolicy.key 와 일치하는지 확인.
+ *
+ * @return Closure
+ */
+ protected function adminPolicyKeyRule(): Closure
+ {
+ return function (string $attribute, mixed $value, Closure $fail): void {
+ if (! is_string($value)) {
+ return;
+ }
+
+ $exists = IdentityPolicy::where('key', $value)
+ ->where('source_type', 'admin')
+ ->exists();
+
+ if (! $exists) {
+ $fail(__('validation.identity_message.scope_value_not_admin_policy'));
+ }
+ };
+ }
+
+ /**
+ * (provider_id, scope_type, scope_value) 조합 중복 검사.
+ *
+ * @return Closure
+ */
+ protected function scopeUniqueRule(): Closure
+ {
+ return function (string $attribute, mixed $value, Closure $fail): void {
+ $providerId = $this->input('provider_id');
+ $scopeType = $this->input('scope_type');
+
+ if (! is_string($providerId) || ! is_string($scopeType) || ! is_string($value)) {
+ return;
+ }
+
+ $exists = IdentityMessageDefinition::where('provider_id', $providerId)
+ ->where('scope_type', $scopeType)
+ ->where('scope_value', $value)
+ ->exists();
+
+ if ($exists) {
+ $fail(__('validation.identity_message.definition_already_exists'));
+ }
+ };
+ }
+}
diff --git a/app/Http/Requests/Admin/Identity/UpdateIdentityMessageDefinitionRequest.php b/app/Http/Requests/Admin/Identity/UpdateIdentityMessageDefinitionRequest.php
new file mode 100644
index 00000000..05b343bb
--- /dev/null
+++ b/app/Http/Requests/Admin/Identity/UpdateIdentityMessageDefinitionRequest.php
@@ -0,0 +1,49 @@
+ ['sometimes', 'array', new LocaleRequiredTranslatable(maxLength: 200)],
+ 'description' => ['sometimes', 'nullable', 'array', new TranslatableField(maxLength: 1000)],
+ 'channels' => ['sometimes', 'array', 'min:1'],
+ 'channels.*' => ['string', 'max:20'],
+ 'is_active' => ['sometimes', 'boolean'],
+ ];
+
+ return HookManager::applyFilters(
+ 'core.identity.message_definition.filter_update_rules',
+ $rules,
+ $this->route('definition')
+ );
+ }
+}
diff --git a/app/Http/Requests/Admin/Identity/UpdateIdentityMessageTemplateRequest.php b/app/Http/Requests/Admin/Identity/UpdateIdentityMessageTemplateRequest.php
new file mode 100644
index 00000000..8330b484
--- /dev/null
+++ b/app/Http/Requests/Admin/Identity/UpdateIdentityMessageTemplateRequest.php
@@ -0,0 +1,47 @@
+ ['sometimes', 'nullable', 'array', new TranslatableField(maxLength: 500)],
+ 'body' => ['required', 'array', new LocaleRequiredTranslatable(maxLength: 65535)],
+ 'is_active' => ['sometimes', 'boolean'],
+ ];
+
+ return HookManager::applyFilters(
+ 'core.identity.message_template.filter_update_rules',
+ $rules,
+ $this->route('template')
+ );
+ }
+}
diff --git a/app/Http/Requests/Extension/AutoDeactivatedListRequest.php b/app/Http/Requests/Extension/AutoDeactivatedListRequest.php
new file mode 100644
index 00000000..dcfb1d8d
--- /dev/null
+++ b/app/Http/Requests/Extension/AutoDeactivatedListRequest.php
@@ -0,0 +1,33 @@
+
+ */
+ public function rules(): array
+ {
+ return [];
+ }
+}
diff --git a/app/Http/Requests/Extension/DismissAlertRequest.php b/app/Http/Requests/Extension/DismissAlertRequest.php
new file mode 100644
index 00000000..00ff6e10
--- /dev/null
+++ b/app/Http/Requests/Extension/DismissAlertRequest.php
@@ -0,0 +1,33 @@
+
+ */
+ public function rules(): array
+ {
+ return [];
+ }
+}
diff --git a/app/Http/Requests/Extension/RecoverRequest.php b/app/Http/Requests/Extension/RecoverRequest.php
new file mode 100644
index 00000000..2a3524cf
--- /dev/null
+++ b/app/Http/Requests/Extension/RecoverRequest.php
@@ -0,0 +1,33 @@
+
+ */
+ public function rules(): array
+ {
+ return [];
+ }
+}
diff --git a/app/Http/Requests/Identity/AdminIdentityLogIndexRequest.php b/app/Http/Requests/Identity/AdminIdentityLogIndexRequest.php
new file mode 100644
index 00000000..3a1c2c8c
--- /dev/null
+++ b/app/Http/Requests/Identity/AdminIdentityLogIndexRequest.php
@@ -0,0 +1,69 @@
+> 검증 규칙
+ */
+ public function rules(): array
+ {
+ return [
+ // 단일값 호환 — 외부 링크/북마크 (?status=verified) 회귀 차단
+ 'provider_id' => ['nullable', 'string', 'max:64'],
+ 'purpose' => ['nullable', 'string', 'max:64'],
+ 'status' => ['nullable', Rule::enum(IdentityVerificationStatus::class)],
+ 'channel' => ['nullable', 'string', 'max:16'],
+ 'origin_type' => ['nullable', Rule::enum(IdentityOriginType::class)],
+ 'source_type' => ['nullable', Rule::enum(IdentityPolicySourceType::class)],
+ 'source_identifier' => ['nullable', 'string', 'max:100'],
+
+ // 다중값 array — 신규 다중선택 필터
+ 'provider_ids' => ['nullable', 'array'],
+ 'provider_ids.*' => ['string', 'max:64'],
+ 'purposes' => ['nullable', 'array'],
+ 'purposes.*' => ['string', 'max:64'],
+ 'statuses' => ['nullable', 'array'],
+ 'statuses.*' => [Rule::enum(IdentityVerificationStatus::class)],
+ 'channels' => ['nullable', 'array'],
+ 'channels.*' => ['string', 'max:16'],
+ 'origin_types' => ['nullable', 'array'],
+ 'origin_types.*' => [Rule::enum(IdentityOriginType::class)],
+
+ // 감사 로그 — 삭제된 user_id 도 조회 가능해야 하므로 exists 검증 사용 안 함.
+ 'user_id' => ['nullable', 'integer', 'min:1'],
+ 'target_hash' => ['nullable', 'string', 'size:64'],
+ 'search' => ['nullable', 'string', 'max:64'],
+ 'search_type' => ['nullable', 'string', 'in:auto,user_id,target_hash,ip_address,policy_key'],
+ 'sort_by' => ['nullable', 'string', 'in:created_at,attempts'],
+ 'sort_order' => ['nullable', 'string', 'in:asc,desc'],
+ 'date_from' => ['nullable', 'date'],
+ 'date_to' => ['nullable', 'date', 'after_or_equal:date_from'],
+ 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Identity/AdminIdentityLogPurgeRequest.php b/app/Http/Requests/Identity/AdminIdentityLogPurgeRequest.php
new file mode 100644
index 00000000..917cd06e
--- /dev/null
+++ b/app/Http/Requests/Identity/AdminIdentityLogPurgeRequest.php
@@ -0,0 +1,35 @@
+> 검증 규칙
+ */
+ public function rules(): array
+ {
+ return [
+ 'older_than_days' => ['nullable', 'integer', 'min:1', 'max:3650'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Identity/AdminIdentityPolicyIndexRequest.php b/app/Http/Requests/Identity/AdminIdentityPolicyIndexRequest.php
new file mode 100644
index 00000000..9d20d85c
--- /dev/null
+++ b/app/Http/Requests/Identity/AdminIdentityPolicyIndexRequest.php
@@ -0,0 +1,44 @@
+> 검증 규칙
+ */
+ public function rules(): array
+ {
+ return [
+ 'scope' => ['nullable', Rule::in(['route', 'hook', 'custom'])],
+ 'purpose' => ['nullable', 'string', 'max:64'],
+ 'source_type' => ['nullable', Rule::in(['core', 'module', 'plugin', 'admin'])],
+ 'source_identifier' => ['nullable', 'string', 'max:100'],
+ 'applies_to' => ['nullable', Rule::in(['self', 'admin', 'both'])],
+ 'fail_mode' => ['nullable', Rule::in(['block', 'log_only'])],
+ 'enabled' => ['nullable', 'boolean'],
+ 'search' => ['nullable', 'string', 'max:255'],
+ 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Identity/AdminIdentityPolicyResetFieldRequest.php b/app/Http/Requests/Identity/AdminIdentityPolicyResetFieldRequest.php
new file mode 100644
index 00000000..6e3d1475
--- /dev/null
+++ b/app/Http/Requests/Identity/AdminIdentityPolicyResetFieldRequest.php
@@ -0,0 +1,36 @@
+>
+ */
+ public function rules(): array
+ {
+ return [
+ 'field' => ['required', 'string', 'in:enabled,grace_minutes,provider_id,fail_mode,conditions'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Identity/AdminIdentityPolicyStoreRequest.php b/app/Http/Requests/Identity/AdminIdentityPolicyStoreRequest.php
new file mode 100644
index 00000000..d0a7493e
--- /dev/null
+++ b/app/Http/Requests/Identity/AdminIdentityPolicyStoreRequest.php
@@ -0,0 +1,108 @@
+> 검증 규칙
+ */
+ public function rules(): array
+ {
+ return [
+ 'key' => ['required', 'string', 'max:120', Rule::unique(IdentityPolicy::class, 'key')],
+ 'scope' => ['required', Rule::enum(IdentityPolicyScope::class)],
+ 'target' => ['required', 'string', 'max:255'],
+ 'purpose' => ['required', 'string', 'max:64'],
+ 'provider_id' => ['nullable', 'string', 'max:64'],
+ 'grace_minutes' => ['required', 'integer', 'min:0', 'max:43200'],
+ 'enabled' => ['boolean'],
+ 'priority' => ['integer', 'min:0', 'max:65535'],
+ 'conditions' => ['nullable', 'array'],
+ 'applies_to' => ['required', Rule::enum(IdentityPolicyAppliesTo::class)],
+ 'fail_mode' => ['required', Rule::enum(IdentityPolicyFailMode::class)],
+ // source_identifier — 운영자 자유 정책의 컨텍스트 귀속.
+ // 'admin' (기본, 어느 확장에도 귀속 안 됨) | 'core' | 모듈/플러그인 raw identifier (예: 'sirsoft-ecommerce').
+ // 모듈/플러그인 sync 경로 및 목록 필터가 모두 raw identifier 컨벤션을 사용하므로 동일하게 통일.
+ 'source_identifier' => ['nullable', 'string', 'max:100', 'regex:/^[a-z][a-z0-9_\-]*$/'],
+ ];
+ }
+
+ /**
+ * 사용자 정의 검증 메시지.
+ *
+ * @return array
+ */
+ public function messages(): array
+ {
+ return [
+ 'key.required' => __('validation.identity_policy.key_required'),
+ 'key.max' => __('validation.identity_policy.key_max'),
+ 'key.unique' => __('validation.identity_policy.key_unique'),
+ 'scope.required' => __('validation.identity_policy.scope_required'),
+ 'scope.enum' => __('validation.identity_policy.scope_invalid'),
+ 'target.required' => __('validation.identity_policy.target_required'),
+ 'target.max' => __('validation.identity_policy.target_max'),
+ 'purpose.required' => __('validation.identity_policy.purpose_required'),
+ 'purpose.max' => __('validation.identity_policy.purpose_max'),
+ 'provider_id.max' => __('validation.identity_policy.provider_id_max'),
+ 'grace_minutes.required' => __('validation.identity_policy.grace_minutes_required'),
+ 'grace_minutes.integer' => __('validation.identity_policy.grace_minutes_integer'),
+ 'grace_minutes.min' => __('validation.identity_policy.grace_minutes_min'),
+ 'grace_minutes.max' => __('validation.identity_policy.grace_minutes_max'),
+ 'enabled.boolean' => __('validation.identity_policy.enabled_boolean'),
+ 'priority.integer' => __('validation.identity_policy.priority_integer'),
+ 'priority.min' => __('validation.identity_policy.priority_min'),
+ 'priority.max' => __('validation.identity_policy.priority_max'),
+ 'conditions.array' => __('validation.identity_policy.conditions_array'),
+ 'applies_to.required' => __('validation.identity_policy.applies_to_required'),
+ 'applies_to.enum' => __('validation.identity_policy.applies_to_invalid'),
+ 'fail_mode.required' => __('validation.identity_policy.fail_mode_required'),
+ 'fail_mode.enum' => __('validation.identity_policy.fail_mode_invalid'),
+ ];
+ }
+
+ /**
+ * 검증 속성명 (validation.attributes).
+ *
+ * @return array
+ */
+ public function attributes(): array
+ {
+ return [
+ 'key' => __('validation.attributes.identity_policy_key'),
+ 'scope' => __('validation.attributes.identity_policy_scope'),
+ 'target' => __('validation.attributes.identity_policy_target'),
+ 'purpose' => __('validation.attributes.identity_policy_purpose'),
+ 'provider_id' => __('validation.attributes.identity_policy_provider_id'),
+ 'grace_minutes' => __('validation.attributes.identity_policy_grace_minutes'),
+ 'enabled' => __('validation.attributes.identity_policy_enabled'),
+ 'applies_to' => __('validation.attributes.identity_policy_applies_to'),
+ 'fail_mode' => __('validation.attributes.identity_policy_fail_mode'),
+ ];
+ }
+}
diff --git a/app/Http/Requests/Identity/AdminIdentityPolicyUpdateRequest.php b/app/Http/Requests/Identity/AdminIdentityPolicyUpdateRequest.php
new file mode 100644
index 00000000..50998205
--- /dev/null
+++ b/app/Http/Requests/Identity/AdminIdentityPolicyUpdateRequest.php
@@ -0,0 +1,98 @@
+> 검증 규칙
+ */
+ public function rules(): array
+ {
+ return [
+ 'enabled' => ['sometimes', 'boolean'],
+ 'grace_minutes' => ['sometimes', 'integer', 'min:0', 'max:43200'],
+ 'provider_id' => ['sometimes', 'nullable', 'string', 'max:64'],
+ 'fail_mode' => ['sometimes', Rule::enum(IdentityPolicyFailMode::class)],
+
+ // 아래 필드는 source_type=admin 일 때만 Controller 에서 적용
+ 'key' => ['sometimes', 'string', 'max:120'],
+ 'scope' => ['sometimes', Rule::enum(IdentityPolicyScope::class)],
+ 'target' => ['sometimes', 'string', 'max:255'],
+ 'purpose' => ['sometimes', 'string', 'max:64'],
+ 'priority' => ['sometimes', 'integer', 'min:0', 'max:65535'],
+ 'conditions' => ['sometimes', 'nullable', 'array'],
+ 'applies_to' => ['sometimes', Rule::enum(IdentityPolicyAppliesTo::class)],
+ ];
+ }
+
+ /**
+ * 사용자 정의 검증 메시지.
+ *
+ * @return array
+ */
+ public function messages(): array
+ {
+ return [
+ 'key.max' => __('validation.identity_policy.key_max'),
+ 'scope.enum' => __('validation.identity_policy.scope_invalid'),
+ 'target.max' => __('validation.identity_policy.target_max'),
+ 'purpose.max' => __('validation.identity_policy.purpose_max'),
+ 'provider_id.max' => __('validation.identity_policy.provider_id_max'),
+ 'grace_minutes.integer' => __('validation.identity_policy.grace_minutes_integer'),
+ 'grace_minutes.min' => __('validation.identity_policy.grace_minutes_min'),
+ 'grace_minutes.max' => __('validation.identity_policy.grace_minutes_max'),
+ 'enabled.boolean' => __('validation.identity_policy.enabled_boolean'),
+ 'priority.integer' => __('validation.identity_policy.priority_integer'),
+ 'priority.min' => __('validation.identity_policy.priority_min'),
+ 'priority.max' => __('validation.identity_policy.priority_max'),
+ 'conditions.array' => __('validation.identity_policy.conditions_array'),
+ 'applies_to.enum' => __('validation.identity_policy.applies_to_invalid'),
+ 'fail_mode.enum' => __('validation.identity_policy.fail_mode_invalid'),
+ ];
+ }
+
+ /**
+ * 검증 속성명 (validation.attributes).
+ *
+ * @return array
+ */
+ public function attributes(): array
+ {
+ return [
+ 'key' => __('validation.attributes.identity_policy_key'),
+ 'scope' => __('validation.attributes.identity_policy_scope'),
+ 'target' => __('validation.attributes.identity_policy_target'),
+ 'purpose' => __('validation.attributes.identity_policy_purpose'),
+ 'provider_id' => __('validation.attributes.identity_policy_provider_id'),
+ 'grace_minutes' => __('validation.attributes.identity_policy_grace_minutes'),
+ 'enabled' => __('validation.attributes.identity_policy_enabled'),
+ 'applies_to' => __('validation.attributes.identity_policy_applies_to'),
+ 'fail_mode' => __('validation.attributes.identity_policy_fail_mode'),
+ ];
+ }
+}
diff --git a/app/Http/Requests/Identity/CancelChallengeRequest.php b/app/Http/Requests/Identity/CancelChallengeRequest.php
new file mode 100644
index 00000000..ce06a13d
--- /dev/null
+++ b/app/Http/Requests/Identity/CancelChallengeRequest.php
@@ -0,0 +1,37 @@
+> 검증 규칙
+ */
+ public function rules(): array
+ {
+ return [];
+ }
+}
diff --git a/app/Http/Requests/Identity/IdentityCallbackRequest.php b/app/Http/Requests/Identity/IdentityCallbackRequest.php
new file mode 100644
index 00000000..0baa0137
--- /dev/null
+++ b/app/Http/Requests/Identity/IdentityCallbackRequest.php
@@ -0,0 +1,64 @@
+verify 내부에서 추가 검증).
+ *
+ * @return array>
+ */
+ public function rules(): array
+ {
+ return [
+ 'challenge_id' => ['required', 'string', 'max:64'],
+ // 일반적으로 사용되는 필드 — 명시적으로 nullable 로 허용해 검증 통과
+ 'code' => ['nullable', 'string', 'max:512'],
+ 'token' => ['nullable', 'string', 'max:1024'],
+ 'state' => ['nullable', 'string', 'max:512'],
+ 'redirect_url' => ['nullable', 'string', 'max:2048'],
+ ];
+ }
+
+ /**
+ * body / query 양쪽에서 challenge_id 를 합쳐 검증 대상에 포함시킵니다.
+ */
+ protected function prepareForValidation(): void
+ {
+ if (! $this->filled('challenge_id') && $this->query('challenge_id')) {
+ $this->merge(['challenge_id' => (string) $this->query('challenge_id')]);
+ }
+ }
+}
diff --git a/app/Http/Requests/Identity/ProvidersIndexRequest.php b/app/Http/Requests/Identity/ProvidersIndexRequest.php
new file mode 100644
index 00000000..ca4c9553
--- /dev/null
+++ b/app/Http/Requests/Identity/ProvidersIndexRequest.php
@@ -0,0 +1,33 @@
+> 검증 규칙
+ */
+ public function rules(): array
+ {
+ return [];
+ }
+}
diff --git a/app/Http/Requests/Identity/PurposesIndexRequest.php b/app/Http/Requests/Identity/PurposesIndexRequest.php
new file mode 100644
index 00000000..f17ff486
--- /dev/null
+++ b/app/Http/Requests/Identity/PurposesIndexRequest.php
@@ -0,0 +1,33 @@
+> 검증 규칙
+ */
+ public function rules(): array
+ {
+ return [];
+ }
+}
diff --git a/app/Http/Requests/Identity/RequestChallengeRequest.php b/app/Http/Requests/Identity/RequestChallengeRequest.php
new file mode 100644
index 00000000..85e4019d
--- /dev/null
+++ b/app/Http/Requests/Identity/RequestChallengeRequest.php
@@ -0,0 +1,44 @@
+> 검증 규칙
+ */
+ public function rules(): array
+ {
+ $rules = [
+ 'purpose' => ['required', 'string', 'max:64'],
+ 'target' => ['nullable', 'array'],
+ 'target.email' => ['nullable', 'email', 'max:255'],
+ 'target.phone' => ['nullable', 'string', 'max:32'],
+ 'provider_id' => ['nullable', 'string', 'max:64'],
+ ];
+
+ // 모듈/플러그인이 IDV challenge 요청 검증 규칙을 동적으로 확장 가능 (예: 도메인 특화 메타데이터)
+ return HookManager::applyFilters('core.identity.request_validation_rules', $rules, $this);
+ }
+}
diff --git a/app/Http/Requests/Identity/ResolvePolicyRequest.php b/app/Http/Requests/Identity/ResolvePolicyRequest.php
new file mode 100644
index 00000000..6383a9db
--- /dev/null
+++ b/app/Http/Requests/Identity/ResolvePolicyRequest.php
@@ -0,0 +1,36 @@
+> 검증 규칙
+ */
+ public function rules(): array
+ {
+ return [
+ 'scope' => ['required', 'string', 'max:32'],
+ 'target' => ['required', 'string', 'max:255'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Identity/ShowChallengeRequest.php b/app/Http/Requests/Identity/ShowChallengeRequest.php
new file mode 100644
index 00000000..599405e8
--- /dev/null
+++ b/app/Http/Requests/Identity/ShowChallengeRequest.php
@@ -0,0 +1,34 @@
+> 검증 규칙
+ */
+ public function rules(): array
+ {
+ return [];
+ }
+}
diff --git a/app/Http/Requests/Identity/VerifyChallengeRequest.php b/app/Http/Requests/Identity/VerifyChallengeRequest.php
new file mode 100644
index 00000000..469f0e2f
--- /dev/null
+++ b/app/Http/Requests/Identity/VerifyChallengeRequest.php
@@ -0,0 +1,42 @@
+> 검증 규칙
+ */
+ public function rules(): array
+ {
+ $rules = [
+ 'code' => ['nullable', 'string', 'max:16'],
+ 'token' => ['nullable', 'string', 'max:256'],
+ ];
+
+ // 모듈/플러그인이 IDV challenge 검증 입력 규칙을 동적으로 확장 가능 (예: 외부 provider SDK payload 필드)
+ return HookManager::applyFilters('core.identity.verify_validation_rules', $rules, $this);
+ }
+}
diff --git a/app/Http/Requests/LanguagePack/BulkActivateRequest.php b/app/Http/Requests/LanguagePack/BulkActivateRequest.php
new file mode 100644
index 00000000..64506dd3
--- /dev/null
+++ b/app/Http/Requests/LanguagePack/BulkActivateRequest.php
@@ -0,0 +1,39 @@
+ 검증 규칙
+ */
+ public function rules(): array
+ {
+ return [
+ 'ids' => ['required', 'array', 'min:1'],
+ 'ids.*' => ['required', 'integer', Rule::exists(LanguagePack::class, 'id')],
+ ];
+ }
+}
diff --git a/app/Http/Requests/LanguagePack/IndexLanguagePackRequest.php b/app/Http/Requests/LanguagePack/IndexLanguagePackRequest.php
new file mode 100644
index 00000000..0d667543
--- /dev/null
+++ b/app/Http/Requests/LanguagePack/IndexLanguagePackRequest.php
@@ -0,0 +1,44 @@
+ 검증 규칙
+ */
+ public function rules(): array
+ {
+ return [
+ 'scope' => ['nullable', 'string', Rule::in(LanguagePackScope::values())],
+ 'target_identifier' => ['nullable', 'string', 'max:150'],
+ 'locale' => ['nullable', 'string', 'max:20'],
+ 'status' => ['nullable', 'string', Rule::in(LanguagePackStatus::values())],
+ 'vendor' => ['nullable', 'string', 'max:100'],
+ 'search' => ['nullable', 'string', 'max:150'],
+ 'exclude_protected' => ['nullable', 'boolean'],
+ 'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
+ 'page' => ['nullable', 'integer', 'min:1'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/LanguagePack/InstallFromBundledRequest.php b/app/Http/Requests/LanguagePack/InstallFromBundledRequest.php
new file mode 100644
index 00000000..73ce0a83
--- /dev/null
+++ b/app/Http/Requests/LanguagePack/InstallFromBundledRequest.php
@@ -0,0 +1,55 @@
+ 검증 규칙
+ */
+ public function rules(): array
+ {
+ return [
+ 'identifier' => [
+ 'required',
+ 'string',
+ 'max:200',
+ 'regex:/^[a-zA-Z0-9._\-]+$/',
+ ],
+ 'auto_activate' => ['nullable', 'boolean'],
+ ];
+ }
+
+ /**
+ * 검증 메시지를 정의합니다.
+ *
+ * @return array 검증 메시지
+ */
+ public function messages(): array
+ {
+ return [
+ 'identifier.required' => __('language_packs.validation.identifier_required'),
+ 'identifier.regex' => __('language_packs.validation.identifier_invalid'),
+ ];
+ }
+}
diff --git a/app/Http/Requests/LanguagePack/InstallFromFileRequest.php b/app/Http/Requests/LanguagePack/InstallFromFileRequest.php
new file mode 100644
index 00000000..3545275f
--- /dev/null
+++ b/app/Http/Requests/LanguagePack/InstallFromFileRequest.php
@@ -0,0 +1,49 @@
+ 검증 규칙
+ */
+ public function rules(): array
+ {
+ return [
+ 'file' => ['required', 'file', 'max:10240', 'mimetypes:application/zip,application/x-zip-compressed,application/octet-stream'],
+ 'auto_activate' => ['nullable', 'boolean'],
+ ];
+ }
+
+ /**
+ * 검증 메시지를 정의합니다.
+ *
+ * @return array 검증 메시지
+ */
+ public function messages(): array
+ {
+ return [
+ 'file.required' => __('language_packs.validation.file_required'),
+ 'file.file' => __('language_packs.validation.file_invalid'),
+ 'file.max' => __('language_packs.validation.file_too_large'),
+ 'file.mimetypes' => __('language_packs.validation.file_not_zip'),
+ ];
+ }
+}
diff --git a/app/Http/Requests/LanguagePack/InstallFromGithubRequest.php b/app/Http/Requests/LanguagePack/InstallFromGithubRequest.php
new file mode 100644
index 00000000..fd0b3b1e
--- /dev/null
+++ b/app/Http/Requests/LanguagePack/InstallFromGithubRequest.php
@@ -0,0 +1,52 @@
+ 검증 규칙
+ */
+ public function rules(): array
+ {
+ return [
+ 'github_url' => [
+ 'required',
+ 'url',
+ 'regex:/^https?:\/\/(www\.)?github\.com\/[a-zA-Z0-9\-_]+\/[a-zA-Z0-9\-_]+\/?$/',
+ ],
+ 'auto_activate' => ['nullable', 'boolean'],
+ ];
+ }
+
+ /**
+ * 검증 메시지를 정의합니다.
+ *
+ * @return array 검증 메시지
+ */
+ public function messages(): array
+ {
+ return [
+ 'github_url.required' => __('language_packs.validation.github_url_required'),
+ 'github_url.url' => __('language_packs.validation.github_url_invalid'),
+ 'github_url.regex' => __('language_packs.validation.github_url_format'),
+ ];
+ }
+}
diff --git a/app/Http/Requests/LanguagePack/InstallFromUrlRequest.php b/app/Http/Requests/LanguagePack/InstallFromUrlRequest.php
new file mode 100644
index 00000000..8a4b0364
--- /dev/null
+++ b/app/Http/Requests/LanguagePack/InstallFromUrlRequest.php
@@ -0,0 +1,49 @@
+ 검증 규칙
+ */
+ public function rules(): array
+ {
+ return [
+ 'url' => ['required', 'url', 'max:500'],
+ 'checksum' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/i'],
+ 'auto_activate' => ['nullable', 'boolean'],
+ ];
+ }
+
+ /**
+ * 검증 메시지를 정의합니다.
+ *
+ * @return array 검증 메시지
+ */
+ public function messages(): array
+ {
+ return [
+ 'url.required' => __('language_packs.validation.url_required'),
+ 'url.url' => __('language_packs.validation.url_invalid'),
+ 'checksum.regex' => __('language_packs.validation.checksum_invalid'),
+ ];
+ }
+}
diff --git a/app/Http/Requests/LanguagePack/ManifestPreviewRequest.php b/app/Http/Requests/LanguagePack/ManifestPreviewRequest.php
new file mode 100644
index 00000000..abba53d0
--- /dev/null
+++ b/app/Http/Requests/LanguagePack/ManifestPreviewRequest.php
@@ -0,0 +1,35 @@
+ 검증 규칙
+ */
+ public function rules(): array
+ {
+ return [
+ 'file' => ['required', 'file', 'mimes:zip', 'max:5120'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/LanguagePack/UninstallLanguagePackRequest.php b/app/Http/Requests/LanguagePack/UninstallLanguagePackRequest.php
new file mode 100644
index 00000000..3919e710
--- /dev/null
+++ b/app/Http/Requests/LanguagePack/UninstallLanguagePackRequest.php
@@ -0,0 +1,36 @@
+ 검증 규칙
+ */
+ public function rules(): array
+ {
+ return [
+ 'cascade' => ['nullable', 'boolean'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/Module/IndexModuleRequest.php b/app/Http/Requests/Module/IndexModuleRequest.php
index c867426e..cd0b1c37 100644
--- a/app/Http/Requests/Module/IndexModuleRequest.php
+++ b/app/Http/Requests/Module/IndexModuleRequest.php
@@ -63,6 +63,9 @@ class IndexModuleRequest extends FormRequest
// 페이지네이션
'per_page' => 'nullable|integer|min:1|max:100',
'page' => 'nullable|integer|min:1',
+
+ // 숨김 항목 포함 여부 (manifest hidden=true 확장)
+ 'include_hidden' => 'nullable|boolean',
];
// 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
diff --git a/app/Http/Requests/Module/InstallModuleRequest.php b/app/Http/Requests/Module/InstallModuleRequest.php
index a35d8e51..235bcf08 100644
--- a/app/Http/Requests/Module/InstallModuleRequest.php
+++ b/app/Http/Requests/Module/InstallModuleRequest.php
@@ -10,6 +10,11 @@ class InstallModuleRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
+ *
+ * 권한 검사는 라우트 미들웨어 (`permission:modules.install`) 가 담당하므로
+ * FormRequest 레벨은 항상 통과시킵니다.
+ *
+ * @return bool 항상 true
*/
public function authorize(): bool
{
@@ -26,6 +31,12 @@ class InstallModuleRequest extends FormRequest
$rules = [
'module_name' => ['required', 'string', 'max:255', new ValidExtensionIdentifier],
'vendor_mode' => ['nullable', 'string', 'in:auto,composer,bundled'],
+ // cascade 동반 설치 — install-preview 응답을 바탕으로 사용자가 선택한 항목
+ 'dependencies' => ['nullable', 'array'],
+ 'dependencies.*.type' => ['required_with:dependencies', 'string', 'in:module,plugin'],
+ 'dependencies.*.identifier' => ['required_with:dependencies', 'string', 'max:255'],
+ 'language_packs' => ['nullable', 'array'],
+ 'language_packs.*' => ['string', 'max:255'],
];
// 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
diff --git a/app/Http/Requests/Module/PerformModuleUpdateRequest.php b/app/Http/Requests/Module/PerformModuleUpdateRequest.php
index bf4836d1..72dba137 100644
--- a/app/Http/Requests/Module/PerformModuleUpdateRequest.php
+++ b/app/Http/Requests/Module/PerformModuleUpdateRequest.php
@@ -17,6 +17,8 @@ class PerformModuleUpdateRequest extends FormRequest
* 사용자가 이 요청을 수행할 권한이 있는지 확인
*
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
+ *
+ * @return bool 항상 true (권한은 미들웨어 체인에서 검증)
*/
public function authorize(): bool
{
@@ -33,9 +35,11 @@ class PerformModuleUpdateRequest extends FormRequest
$rules = [
'layout_strategy' => ['nullable', 'string', 'in:overwrite,keep'],
'vendor_mode' => ['nullable', 'string', 'in:auto,composer,bundled'],
+ // 코어 버전 비호환 강제 우회 플래그
+ 'force' => ['nullable', 'boolean'],
];
- return HookManager::applyFilters('core.module.perform_update_rules', $rules);
+ return HookManager::applyFilters('core.module.perform_update_validation_rules', $rules, $this);
}
/**
diff --git a/app/Http/Requests/Module/PreviewModuleManifestRequest.php b/app/Http/Requests/Module/PreviewModuleManifestRequest.php
new file mode 100644
index 00000000..dcee3db2
--- /dev/null
+++ b/app/Http/Requests/Module/PreviewModuleManifestRequest.php
@@ -0,0 +1,54 @@
+ 검증 규칙
+ */
+ public function rules(): array
+ {
+ $maxSize = config('module.upload_max_size', 50) * 1024;
+
+ return [
+ 'file' => ['required', 'file', 'mimes:zip', 'max:'.$maxSize],
+ ];
+ }
+
+ /**
+ * 검증 실패 메시지.
+ *
+ * @return array
+ */
+ public function messages(): array
+ {
+ $maxSize = config('module.upload_max_size', 50);
+
+ return [
+ 'file.required' => __('modules.validation.file_required'),
+ 'file.file' => __('modules.validation.file_invalid'),
+ 'file.mimes' => __('modules.validation.file_must_be_zip'),
+ 'file.max' => __('modules.validation.file_max_size', ['size' => $maxSize]),
+ ];
+ }
+}
diff --git a/app/Http/Requests/Plugin/IndexPluginRequest.php b/app/Http/Requests/Plugin/IndexPluginRequest.php
index 24548069..3dc9e945 100644
--- a/app/Http/Requests/Plugin/IndexPluginRequest.php
+++ b/app/Http/Requests/Plugin/IndexPluginRequest.php
@@ -52,6 +52,9 @@ class IndexPluginRequest extends FormRequest
// 페이지네이션
'per_page' => 'nullable|integer|min:1|max:100',
'page' => 'nullable|integer|min:1',
+
+ // 숨김 항목 포함 여부 (manifest hidden=true 확장)
+ 'include_hidden' => 'nullable|boolean',
];
// 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
diff --git a/app/Http/Requests/Plugin/InstallPluginRequest.php b/app/Http/Requests/Plugin/InstallPluginRequest.php
index a63897f7..99f68b29 100644
--- a/app/Http/Requests/Plugin/InstallPluginRequest.php
+++ b/app/Http/Requests/Plugin/InstallPluginRequest.php
@@ -14,9 +14,12 @@ use Illuminate\Foundation\Http\FormRequest;
class InstallPluginRequest extends FormRequest
{
/**
- * 사용자가 이 요청을 수행할 권한이 있는지 확인
+ * 사용자가 이 요청을 수행할 권한이 있는지 확인합니다.
*
- * 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
+ * 권한 체크는 라우트의 permission 미들웨어에서 수행되므로
+ * FormRequest 레벨은 항상 통과시킵니다.
+ *
+ * @return bool 항상 true
*/
public function authorize(): bool
{
@@ -33,6 +36,12 @@ class InstallPluginRequest extends FormRequest
$rules = [
'plugin_name' => ['required', 'string', 'max:255', new ValidExtensionIdentifier],
'vendor_mode' => ['nullable', 'string', 'in:auto,composer,bundled'],
+ // cascade 동반 설치 — install-preview 응답을 바탕으로 사용자가 선택한 항목
+ 'dependencies' => ['nullable', 'array'],
+ 'dependencies.*.type' => ['required_with:dependencies', 'string', 'in:module,plugin'],
+ 'dependencies.*.identifier' => ['required_with:dependencies', 'string', 'max:255'],
+ 'language_packs' => ['nullable', 'array'],
+ 'language_packs.*' => ['string', 'max:255'],
];
// 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
diff --git a/app/Http/Requests/Plugin/PerformPluginUpdateRequest.php b/app/Http/Requests/Plugin/PerformPluginUpdateRequest.php
index 116e0997..572ef5b4 100644
--- a/app/Http/Requests/Plugin/PerformPluginUpdateRequest.php
+++ b/app/Http/Requests/Plugin/PerformPluginUpdateRequest.php
@@ -17,6 +17,8 @@ class PerformPluginUpdateRequest extends FormRequest
* 사용자가 이 요청을 수행할 권한이 있는지 확인
*
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
+ *
+ * @return bool 항상 true (권한은 미들웨어 체인에서 검증)
*/
public function authorize(): bool
{
@@ -33,9 +35,11 @@ class PerformPluginUpdateRequest extends FormRequest
$rules = [
'layout_strategy' => ['nullable', 'string', 'in:overwrite,keep'],
'vendor_mode' => ['nullable', 'string', 'in:auto,composer,bundled'],
+ // 코어 버전 비호환 강제 우회 플래그 (위험 인지 후 사용자 명시 필요)
+ 'force' => ['nullable', 'boolean'],
];
- return HookManager::applyFilters('core.plugin.perform_update_rules', $rules);
+ return HookManager::applyFilters('core.plugin.perform_update_validation_rules', $rules, $this);
}
/**
diff --git a/app/Http/Requests/Plugin/PreviewPluginManifestRequest.php b/app/Http/Requests/Plugin/PreviewPluginManifestRequest.php
new file mode 100644
index 00000000..c98f9d1b
--- /dev/null
+++ b/app/Http/Requests/Plugin/PreviewPluginManifestRequest.php
@@ -0,0 +1,54 @@
+ 검증 규칙
+ */
+ public function rules(): array
+ {
+ $maxSize = config('plugin.upload_max_size', 50) * 1024;
+
+ return [
+ 'file' => ['required', 'file', 'mimes:zip', 'max:'.$maxSize],
+ ];
+ }
+
+ /**
+ * 검증 실패 메시지.
+ *
+ * @return array
+ */
+ public function messages(): array
+ {
+ $maxSize = config('plugin.upload_max_size', 50);
+
+ return [
+ 'file.required' => __('plugins.validation.file_required'),
+ 'file.file' => __('plugins.validation.file_invalid'),
+ 'file.mimes' => __('plugins.validation.file_must_be_zip'),
+ 'file.max' => __('plugins.validation.file_max_size', ['size' => $maxSize]),
+ ];
+ }
+}
diff --git a/app/Http/Requests/Settings/SaveSettingsRequest.php b/app/Http/Requests/Settings/SaveSettingsRequest.php
index 3f9dbf93..a978fc46 100644
--- a/app/Http/Requests/Settings/SaveSettingsRequest.php
+++ b/app/Http/Requests/Settings/SaveSettingsRequest.php
@@ -11,11 +11,6 @@ use Illuminate\Validation\Rule;
class SaveSettingsRequest extends FormRequest
{
- /**
- * 지원되는 언어 목록
- */
- private const SUPPORTED_LANGUAGES = ['ko', 'en'];
-
/**
* 지원되는 메일러 목록
*/
@@ -73,6 +68,8 @@ class SaveSettingsRequest extends FormRequest
/**
* Determine if the user is authorized to make this request.
+ *
+ * @return bool
*/
public function authorize(): bool
{
@@ -150,7 +147,7 @@ class SaveSettingsRequest extends FormRequest
$rules = [
// 탭 식별자
- '_tab' => ['nullable', 'string', Rule::in(['general', 'mail', 'upload', 'seo', 'security', 'drivers', 'advanced', 'notifications'])],
+ '_tab' => ['nullable', 'string', Rule::in(['general', 'mail', 'upload', 'seo', 'security', 'drivers', 'advanced', 'notifications', 'identity'])],
// 각 탭의 컨테이너
'general' => ['sometimes', 'array'],
@@ -161,6 +158,7 @@ class SaveSettingsRequest extends FormRequest
'drivers' => ['sometimes', 'array'],
'advanced' => ['sometimes', 'array'],
'notifications' => ['sometimes', 'array'],
+ 'identity' => ['sometimes', 'array'],
'notifications.channels' => ['sometimes', 'array'],
'notifications.channels.*.id' => ['required_with:notifications.channels', 'string', 'max:50'],
'notifications.channels.*.is_active' => ['required_with:notifications.channels', 'boolean'],
@@ -172,7 +170,7 @@ class SaveSettingsRequest extends FormRequest
'general.site_description' => ['nullable', 'string', 'max:500'],
'general.admin_email' => $this->getTabRules($tab, 'general', 'email|max:255'),
'general.timezone' => $this->getTabRules($tab, 'general', ['timezone']),
- 'general.language' => $this->getTabRules($tab, 'general', [Rule::in(self::SUPPORTED_LANGUAGES)]),
+ 'general.language' => $this->getTabRules($tab, 'general', [Rule::in(config('app.supported_locales', ['ko', 'en']))]),
'general.currency' => ['nullable', 'string', 'max:10'],
'general.maintenance_mode' => ['nullable', 'boolean'],
'general.site_logo' => ['nullable', 'array'],
@@ -211,12 +209,20 @@ class SaveSettingsRequest extends FormRequest
'seo.bot_user_agents' => ['nullable', 'array'],
'seo.bot_user_agents.*' => ['string', 'max:100'],
'seo.bot_detection_enabled' => ['nullable', 'boolean'],
+ 'seo.bot_detection_library_enabled' => ['nullable', 'boolean'],
+ 'seo.og_default_site_name' => ['nullable', 'string', 'max:200'],
+ 'seo.og_image_default_width' => ['nullable', 'integer', 'min:0', 'max:8000'],
+ 'seo.og_image_default_height' => ['nullable', 'integer', 'min:0', 'max:8000'],
+ 'seo.twitter_default_card' => ['nullable', 'string', Rule::in(['summary', 'summary_large_image', 'app', 'player', ''])],
+ 'seo.twitter_default_site' => ['nullable', 'string', 'max:50'],
'seo.cache_enabled' => ['nullable', 'boolean'],
'seo.cache_ttl' => ['nullable', 'integer', 'min:60', 'max:86400'],
'seo.sitemap_enabled' => ['nullable', 'boolean'],
'seo.sitemap_cache_ttl' => ['nullable', 'integer', 'min:3600', 'max:604800'],
'seo.sitemap_schedule' => ['nullable', 'string', Rule::in(['hourly', 'daily', 'weekly'])],
'seo.sitemap_schedule_time' => ['nullable', 'string', 'regex:/^\d{2}:\d{2}$/'],
+ 'seo.generator_enabled' => ['nullable', 'boolean'],
+ 'seo.generator_content' => ['nullable', 'string', 'max:200'],
// 보안 설정
'security.force_https' => $this->getTabRules($tab, 'security', 'boolean'),
@@ -281,6 +287,13 @@ class SaveSettingsRequest extends FormRequest
'drivers.log_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_LOG_DRIVERS)]),
'drivers.log_level' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_LOG_LEVELS)]),
'drivers.log_days' => ['nullable', 'integer', 'min:1', 'max:365'],
+
+ // 본인인증(IDV) provider 기술 파라미터 — 정책 분기는 IdentityPolicy 로 흡수됨
+ 'identity.default_provider' => ['nullable', 'string', 'max:100'],
+ 'identity.purpose_providers' => ['sometimes', 'array'],
+ 'identity.purpose_providers.*' => ['nullable', 'string', 'max:100'],
+ 'identity.challenge_ttl_minutes' => $this->getTabRules($tab, 'identity', 'integer|min:1|max:1440'),
+ 'identity.max_attempts' => $this->getTabRules($tab, 'identity', 'integer|min:1|max:20'),
];
// 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
@@ -495,10 +508,26 @@ class SaveSettingsRequest extends FormRequest
'seo.google_analytics_id.max' => __('validation.settings.google_analytics_id_max'),
'seo.google_site_verification.max' => __('validation.settings.google_site_verification_max'),
'seo.naver_site_verification.max' => __('validation.settings.naver_site_verification_max'),
+ 'seo.generator_enabled.boolean' => __('validation.settings.generator_enabled_boolean'),
+ 'seo.generator_content.string' => __('validation.settings.generator_content_string'),
+ 'seo.generator_content.max' => __('validation.settings.generator_content_max'),
'seo.bot_user_agents.array' => __('validation.settings.bot_user_agents_array'),
'seo.bot_user_agents.*.string' => __('validation.settings.bot_user_agents_item_string'),
'seo.bot_user_agents.*.max' => __('validation.settings.bot_user_agents_item_max'),
'seo.bot_detection_enabled.boolean' => __('validation.settings.bot_detection_enabled_boolean'),
+ 'seo.bot_detection_library_enabled.boolean' => __('validation.settings.bot_detection_library_enabled_boolean'),
+ 'seo.og_default_site_name.string' => __('validation.settings.og_default_site_name_string'),
+ 'seo.og_default_site_name.max' => __('validation.settings.og_default_site_name_max'),
+ 'seo.og_image_default_width.integer' => __('validation.settings.og_image_default_width_integer'),
+ 'seo.og_image_default_width.min' => __('validation.settings.og_image_default_width_min'),
+ 'seo.og_image_default_width.max' => __('validation.settings.og_image_default_width_max'),
+ 'seo.og_image_default_height.integer' => __('validation.settings.og_image_default_height_integer'),
+ 'seo.og_image_default_height.min' => __('validation.settings.og_image_default_height_min'),
+ 'seo.og_image_default_height.max' => __('validation.settings.og_image_default_height_max'),
+ 'seo.twitter_default_card.string' => __('validation.settings.twitter_default_card_string'),
+ 'seo.twitter_default_card.in' => __('validation.settings.twitter_default_card_in'),
+ 'seo.twitter_default_site.string' => __('validation.settings.twitter_default_site_string'),
+ 'seo.twitter_default_site.max' => __('validation.settings.twitter_default_site_max'),
'seo.cache_enabled.boolean' => __('validation.settings.seo_cache_enabled_boolean'),
'seo.cache_ttl.integer' => __('validation.settings.seo_cache_ttl_integer'),
'seo.cache_ttl.min' => __('validation.settings.seo_cache_ttl_min'),
@@ -614,6 +643,21 @@ class SaveSettingsRequest extends FormRequest
'drivers.log_days.integer' => __('validation.settings.log_days_integer'),
'drivers.log_days.min' => __('validation.settings.log_days_min'),
'drivers.log_days.max' => __('validation.settings.log_days_max'),
+
+ // 본인인증(IDV) 설정
+ 'identity.default_provider.string' => __('validation.settings.identity_default_provider_string'),
+ 'identity.default_provider.max' => __('validation.settings.identity_default_provider_max'),
+ 'identity.purpose_providers.array' => __('validation.settings.identity_purpose_providers_array'),
+ 'identity.purpose_providers.*.string' => __('validation.settings.identity_purpose_provider_string'),
+ 'identity.purpose_providers.*.max' => __('validation.settings.identity_purpose_provider_max'),
+ 'identity.challenge_ttl_minutes.required' => __('validation.settings.identity_challenge_ttl_required'),
+ 'identity.challenge_ttl_minutes.integer' => __('validation.settings.identity_challenge_ttl_integer'),
+ 'identity.challenge_ttl_minutes.min' => __('validation.settings.identity_challenge_ttl_min'),
+ 'identity.challenge_ttl_minutes.max' => __('validation.settings.identity_challenge_ttl_max'),
+ 'identity.max_attempts.required' => __('validation.settings.identity_max_attempts_required'),
+ 'identity.max_attempts.integer' => __('validation.settings.identity_max_attempts_integer'),
+ 'identity.max_attempts.min' => __('validation.settings.identity_max_attempts_min'),
+ 'identity.max_attempts.max' => __('validation.settings.identity_max_attempts_max'),
];
}
@@ -633,6 +677,11 @@ class SaveSettingsRequest extends FormRequest
'general.admin_email' => __('validation.attributes.admin_email'),
'general.timezone' => __('validation.attributes.timezone'),
'general.language' => __('validation.attributes.language'),
+ // 본인인증(IDV) 필드
+ 'identity.default_provider' => __('validation.attributes.identity_default_provider'),
+ 'identity.purpose_providers' => __('validation.attributes.identity_purpose_providers'),
+ 'identity.challenge_ttl_minutes' => __('validation.attributes.identity_challenge_ttl_minutes'),
+ 'identity.max_attempts' => __('validation.attributes.identity_max_attempts'),
];
}
}
diff --git a/app/Http/Requests/Template/IndexTemplateRequest.php b/app/Http/Requests/Template/IndexTemplateRequest.php
index c65d850e..ab9abcc4 100644
--- a/app/Http/Requests/Template/IndexTemplateRequest.php
+++ b/app/Http/Requests/Template/IndexTemplateRequest.php
@@ -58,6 +58,9 @@ class IndexTemplateRequest extends FormRequest
// 페이지네이션
'per_page' => 'nullable|integer|min:1|max:100',
'page' => 'nullable|integer|min:1',
+
+ // 숨김 항목 포함 여부 (manifest hidden=true 확장)
+ 'include_hidden' => 'nullable|boolean',
];
// 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
diff --git a/app/Http/Requests/Template/InstallTemplateRequest.php b/app/Http/Requests/Template/InstallTemplateRequest.php
index e68c66b3..ed25e236 100644
--- a/app/Http/Requests/Template/InstallTemplateRequest.php
+++ b/app/Http/Requests/Template/InstallTemplateRequest.php
@@ -13,6 +13,11 @@ class InstallTemplateRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
+ *
+ * 권한 체크는 라우트의 permission 미들웨어에서 수행되므로
+ * FormRequest 레벨은 항상 통과시킵니다.
+ *
+ * @return bool 항상 true
*/
public function authorize(): bool
{
@@ -28,6 +33,12 @@ class InstallTemplateRequest extends FormRequest
{
$rules = [
'template_name' => ['required', 'string', 'max:255', new ValidExtensionIdentifier],
+ // cascade 동반 설치 — install-preview 응답을 바탕으로 사용자가 선택한 항목
+ 'dependencies' => ['nullable', 'array'],
+ 'dependencies.*.type' => ['required_with:dependencies', 'string', 'in:module,plugin'],
+ 'dependencies.*.identifier' => ['required_with:dependencies', 'string', 'max:255'],
+ 'language_packs' => ['nullable', 'array'],
+ 'language_packs.*' => ['string', 'max:255'],
];
// 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
diff --git a/app/Http/Requests/Template/PerformTemplateUpdateRequest.php b/app/Http/Requests/Template/PerformTemplateUpdateRequest.php
index c95b2121..17ae2451 100644
--- a/app/Http/Requests/Template/PerformTemplateUpdateRequest.php
+++ b/app/Http/Requests/Template/PerformTemplateUpdateRequest.php
@@ -16,6 +16,8 @@ class PerformTemplateUpdateRequest extends FormRequest
* 사용자가 이 요청을 수행할 권한이 있는지 확인
*
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
+ *
+ * @return bool 항상 true (권한은 미들웨어 체인에서 검증)
*/
public function authorize(): bool
{
@@ -31,9 +33,11 @@ class PerformTemplateUpdateRequest extends FormRequest
{
$rules = [
'layout_strategy' => ['nullable', 'string', 'in:overwrite,keep'],
+ // 코어 버전 비호환 강제 우회 플래그
+ 'force' => ['nullable', 'boolean'],
];
- return HookManager::applyFilters('core.template.perform_update_rules', $rules);
+ return HookManager::applyFilters('core.template.perform_update_validation_rules', $rules, $this);
}
/**
diff --git a/app/Http/Requests/Template/PreviewTemplateManifestRequest.php b/app/Http/Requests/Template/PreviewTemplateManifestRequest.php
new file mode 100644
index 00000000..67915e43
--- /dev/null
+++ b/app/Http/Requests/Template/PreviewTemplateManifestRequest.php
@@ -0,0 +1,54 @@
+ 검증 규칙
+ */
+ public function rules(): array
+ {
+ $maxSize = config('template.upload_max_size', 50) * 1024;
+
+ return [
+ 'file' => ['required', 'file', 'mimes:zip', 'max:'.$maxSize],
+ ];
+ }
+
+ /**
+ * 검증 실패 메시지.
+ *
+ * @return array
+ */
+ public function messages(): array
+ {
+ $maxSize = config('template.upload_max_size', 50);
+
+ return [
+ 'file.required' => __('templates.validation.file_required'),
+ 'file.file' => __('templates.validation.file_invalid'),
+ 'file.mimes' => __('templates.validation.file_must_be_zip'),
+ 'file.max' => __('templates.validation.file_max_size', ['size' => $maxSize]),
+ ];
+ }
+}
diff --git a/app/Http/Resources/Admin/Identity/IdentityMessageDefinitionCollection.php b/app/Http/Resources/Admin/Identity/IdentityMessageDefinitionCollection.php
new file mode 100644
index 00000000..826b47ee
--- /dev/null
+++ b/app/Http/Resources/Admin/Identity/IdentityMessageDefinitionCollection.php
@@ -0,0 +1,50 @@
+ 'core.admin.identity.messages.update',
+ ];
+ }
+
+ /**
+ * 컬렉션을 배열로 변환합니다.
+ *
+ * @param Request $request
+ * @return array
+ */
+ public function toArray(Request $request): array
+ {
+ $sortOrder = $request->input('sort_order', 'asc');
+ $abilities = $this->resolveCollectionAbilities($request);
+
+ return [
+ 'data' => $this->mapWithRowNumber(function ($definition) use ($request) {
+ return (new IdentityMessageDefinitionResource($definition))->toArray($request);
+ }, $sortOrder),
+ 'pagination' => [
+ 'current_page' => $this->currentPage(),
+ 'last_page' => $this->lastPage(),
+ 'per_page' => $this->perPage(),
+ 'total' => $this->total(),
+ 'from' => $this->firstItem(),
+ 'to' => $this->lastItem(),
+ 'has_more_pages' => $this->hasMorePages(),
+ ],
+ ...($abilities ? ['abilities' => $abilities] : []),
+ ];
+ }
+}
diff --git a/app/Http/Resources/Admin/Identity/IdentityMessageDefinitionResource.php b/app/Http/Resources/Admin/Identity/IdentityMessageDefinitionResource.php
new file mode 100644
index 00000000..e82e5469
--- /dev/null
+++ b/app/Http/Resources/Admin/Identity/IdentityMessageDefinitionResource.php
@@ -0,0 +1,70 @@
+ 'core.admin.identity.messages.update',
+ 'can_delete' => 'core.admin.identity.messages.update',
+ ];
+ }
+
+ /**
+ * abilities 동적 후처리 — 시드 정의(is_default=true)는 삭제 거부.
+ *
+ * @param Request $request
+ * @return array
+ */
+ protected function resolveAbilities(Request $request): array
+ {
+ $abilities = parent::resolveAbilities($request);
+
+ if (isset($abilities['can_delete']) && $this->getValue('is_default')) {
+ $abilities['can_delete'] = false;
+ }
+
+ return $abilities;
+ }
+
+ /**
+ * 리소스를 배열로 변환합니다.
+ *
+ * @param Request $request
+ * @return array
+ */
+ public function toArray(Request $request): array
+ {
+ return [
+ 'id' => $this->getValue('id'),
+ 'provider_id' => $this->getValue('provider_id'),
+ 'scope_type' => $this->getValue('scope_type'),
+ 'scope_value' => $this->getValue('scope_value'),
+ 'name' => $this->getValue('name'),
+ 'description' => $this->getValue('description'),
+ 'channels' => $this->getValue('channels'),
+ 'variables' => $this->getValue('variables'),
+ 'extension_type' => $this->getValue('extension_type'),
+ 'extension_identifier' => $this->getValue('extension_identifier'),
+ 'is_active' => (bool) $this->getValue('is_active'),
+ 'is_default' => (bool) $this->getValue('is_default'),
+ 'user_overrides' => $this->getValue('user_overrides'),
+ 'templates' => $this->relationLoaded('templates')
+ ? IdentityMessageTemplateResource::collection($this->templates)
+ : null,
+ ...$this->formatTimestamps(),
+ ...$this->resourceMeta($request),
+ ];
+ }
+}
diff --git a/app/Http/Resources/Admin/Identity/IdentityMessageTemplateResource.php b/app/Http/Resources/Admin/Identity/IdentityMessageTemplateResource.php
new file mode 100644
index 00000000..adf0121e
--- /dev/null
+++ b/app/Http/Resources/Admin/Identity/IdentityMessageTemplateResource.php
@@ -0,0 +1,46 @@
+ 'core.admin.identity.messages.update',
+ 'can_delete' => 'core.admin.identity.messages.update',
+ ];
+ }
+
+ /**
+ * 리소스를 배열로 변환합니다.
+ *
+ * @param Request $request
+ * @return array
+ */
+ public function toArray(Request $request): array
+ {
+ return [
+ 'id' => $this->getValue('id'),
+ 'definition_id' => $this->getValue('definition_id'),
+ 'channel' => $this->getValue('channel'),
+ 'subject' => $this->getValue('subject'),
+ 'body' => $this->getValue('body'),
+ 'is_active' => (bool) $this->getValue('is_active'),
+ 'is_default' => (bool) $this->getValue('is_default'),
+ 'user_overrides' => $this->getValue('user_overrides'),
+ 'updated_by' => $this->resource->updater?->uuid ?? null,
+ ...$this->formatTimestamps(),
+ ...$this->resourceMeta($request),
+ ];
+ }
+}
diff --git a/app/Http/Resources/BaseApiResource.php b/app/Http/Resources/BaseApiResource.php
index 030a1e57..4217539f 100644
--- a/app/Http/Resources/BaseApiResource.php
+++ b/app/Http/Resources/BaseApiResource.php
@@ -230,7 +230,7 @@ class BaseApiResource extends JsonResource
if (is_array($value)) {
$locale = App::getLocale();
- return $value[$locale] ?? $value['ko'] ?? $value['en'] ?? reset($value) ?: null;
+ return $value[$locale] ?? $value[config('app.fallback_locale', 'ko')] ?? reset($value) ?: null;
}
// Model의 getLocalizedName() 등의 메서드가 있으면 사용
diff --git a/app/Http/Resources/Identity/ChallengeResource.php b/app/Http/Resources/Identity/ChallengeResource.php
new file mode 100644
index 00000000..c0d23751
--- /dev/null
+++ b/app/Http/Resources/Identity/ChallengeResource.php
@@ -0,0 +1,40 @@
+ 직렬화된 Challenge 페이로드
+ */
+ public function toArray(Request $request): array
+ {
+ /** @var VerificationChallenge $c */
+ $c = $this->resource;
+
+ return [
+ 'id' => $c->id,
+ 'provider_id' => $c->providerId,
+ 'purpose' => $c->purpose,
+ 'channel' => $c->channel,
+ 'render_hint' => $c->renderHint,
+ 'redirect_url' => $c->redirectUrl,
+ 'expires_at' => $c->expiresAt->toIso8601String(),
+ 'public_payload' => $c->publicPayload,
+ ...$this->resourceMeta($request),
+ ];
+ }
+}
diff --git a/app/Http/Resources/Identity/PolicyCollection.php b/app/Http/Resources/Identity/PolicyCollection.php
new file mode 100644
index 00000000..135a33cd
--- /dev/null
+++ b/app/Http/Resources/Identity/PolicyCollection.php
@@ -0,0 +1,46 @@
+ 능력 키 => 권한 식별자 매핑
+ */
+ protected function abilityMap(): array
+ {
+ return [
+ 'can_create' => 'core.admin.identity.policies.manage',
+ 'can_update' => 'core.admin.identity.policies.manage',
+ 'can_delete' => 'core.admin.identity.policies.manage',
+ ];
+ }
+
+ /**
+ * 컬렉션을 배열로 변환합니다.
+ *
+ * @param Request $request HTTP 요청 객체
+ * @return array 데이터 + abilities
+ */
+ public function toArray(Request $request): array
+ {
+ return [
+ 'data' => $this->collection->map(fn ($policy) => new PolicyResource($policy)),
+ 'abilities' => $this->resolveAbilitiesFromMap($this->abilityMap(), $request->user()),
+ ];
+ }
+}
diff --git a/app/Http/Resources/Identity/PolicyResource.php b/app/Http/Resources/Identity/PolicyResource.php
new file mode 100644
index 00000000..0fcb2e65
--- /dev/null
+++ b/app/Http/Resources/Identity/PolicyResource.php
@@ -0,0 +1,57 @@
+
+ */
+ public function abilityMap(): array
+ {
+ return [
+ 'can_update' => 'core.admin.identity.policies.manage',
+ 'can_delete' => 'core.admin.identity.policies.manage',
+ ];
+ }
+
+ /**
+ * 리소스를 배열로 변환합니다.
+ *
+ * @param Request $request HTTP 요청 객체
+ * @return array 직렬화된 정책 데이터
+ */
+ public function toArray(Request $request): array
+ {
+ return [
+ 'id' => $this->id,
+ 'key' => $this->key,
+ 'scope' => $this->scope,
+ 'target' => $this->target,
+ 'purpose' => $this->purpose,
+ 'provider_id' => $this->provider_id,
+ 'grace_minutes' => (int) $this->grace_minutes,
+ 'enabled' => (bool) $this->enabled,
+ 'priority' => (int) $this->priority,
+ 'conditions' => $this->conditions,
+ 'source_type' => $this->source_type,
+ 'source_identifier' => $this->source_identifier,
+ 'applies_to' => $this->applies_to,
+ 'fail_mode' => $this->fail_mode,
+ 'user_overrides' => $this->user_overrides ?? [],
+ 'created_at' => $this->created_at?->toIso8601String(),
+ 'updated_at' => $this->updated_at?->toIso8601String(),
+ ...$this->resourceMeta($request),
+ ];
+ }
+}
diff --git a/app/Http/Resources/Identity/ProviderResource.php b/app/Http/Resources/Identity/ProviderResource.php
new file mode 100644
index 00000000..d1f10a5c
--- /dev/null
+++ b/app/Http/Resources/Identity/ProviderResource.php
@@ -0,0 +1,37 @@
+ 프로바이더 공개 메타데이터
+ */
+ public function toArray(Request $request): array
+ {
+ /** @var IdentityVerificationInterface $p */
+ $p = $this->resource;
+
+ return [
+ 'id' => $p->getId(),
+ 'label' => $p->getLabel(),
+ 'channels' => $p->getChannels(),
+ 'render_hint' => $p->getRenderHint(),
+ 'is_available' => $p->isAvailable(),
+ ...$this->resourceMeta($request),
+ ];
+ }
+}
diff --git a/app/Http/Resources/IdentityLogResource.php b/app/Http/Resources/IdentityLogResource.php
new file mode 100644
index 00000000..357105d5
--- /dev/null
+++ b/app/Http/Resources/IdentityLogResource.php
@@ -0,0 +1,44 @@
+ 변환된 배열
+ */
+ public function toArray(Request $request): array
+ {
+ return [
+ 'id' => $this->getValue('id'),
+ 'provider_id' => $this->getValue('provider_id'),
+ 'purpose' => $this->getValue('purpose'),
+ 'channel' => $this->getValue('channel'),
+ 'user_id' => $this->getValue('user_id'),
+ 'target_hash' => $this->getValue('target_hash'),
+ 'status' => $this->getValue('status'),
+ 'attempts' => $this->getValue('attempts'),
+ 'max_attempts' => $this->getValue('max_attempts'),
+ 'ip_address' => $this->getValue('ip_address'),
+ 'user_agent' => $this->getValue('user_agent'),
+ 'origin_type' => $this->getValue('origin_type'),
+ 'origin_identifier' => $this->getValue('origin_identifier'),
+ 'origin_policy_key' => $this->getValue('origin_policy_key'),
+ 'properties' => $this->getValue('properties'),
+ 'metadata' => $this->getValue('metadata'),
+ 'created_at' => $this->formatDateTimeStringForUser($this->resource->created_at),
+ 'verified_at' => $this->formatDateTimeStringForUser($this->resource->verified_at),
+ 'expires_at' => $this->formatDateTimeStringForUser($this->resource->expires_at),
+ ];
+ }
+}
diff --git a/app/Http/Resources/LanguagePackCollection.php b/app/Http/Resources/LanguagePackCollection.php
new file mode 100644
index 00000000..78e85d90
--- /dev/null
+++ b/app/Http/Resources/LanguagePackCollection.php
@@ -0,0 +1,64 @@
+
+ */
+ protected function abilityMap(): array
+ {
+ return [
+ 'can_install' => 'core.language_packs.install',
+ 'can_activate' => 'core.language_packs.manage',
+ 'can_deactivate' => 'core.language_packs.manage',
+ 'can_uninstall' => 'core.language_packs.manage',
+ 'can_refresh_cache' => 'core.language_packs.manage',
+ 'can_check_updates' => 'core.language_packs.update',
+ 'can_update' => 'core.language_packs.update',
+ ];
+ }
+
+ /**
+ * 컬렉션을 배열로 변환합니다.
+ *
+ * @param Request $request HTTP 요청 객체
+ * @return array 변환된 데이터 배열
+ */
+ public function toArray(Request $request): array
+ {
+ return [
+ 'data' => $this->collection->map(function ($pack) {
+ return new LanguagePackResource($pack);
+ }),
+ ];
+ }
+
+ /**
+ * 응답 메타데이터를 추가합니다.
+ *
+ * @param Request $request HTTP 요청 객체
+ * @return array 메타데이터
+ */
+ public function with(Request $request): array
+ {
+ return [
+ 'meta' => [
+ 'total' => $this->collection->count(),
+ 'active' => $this->collection->where('status', 'active')->count(),
+ 'installed' => $this->collection->where('status', 'installed')->count(),
+ 'inactive' => $this->collection->where('status', 'inactive')->count(),
+ 'error' => $this->collection->where('status', 'error')->count(),
+ 'uninstalled' => $this->collection->where('status', 'uninstalled')->count(),
+ ],
+ ];
+ }
+}
diff --git a/app/Http/Resources/LanguagePackResource.php b/app/Http/Resources/LanguagePackResource.php
new file mode 100644
index 00000000..08826f3a
--- /dev/null
+++ b/app/Http/Resources/LanguagePackResource.php
@@ -0,0 +1,283 @@
+ 변환된 언어팩 데이터
+ */
+ public function toArray(Request $request): array
+ {
+ return [
+ 'id' => $this->getValue('id'),
+ 'identifier' => $this->getValue('identifier'),
+ 'vendor' => $this->getValue('vendor'),
+ 'scope' => $this->getValue('scope'),
+ 'target_identifier' => $this->getValue('target_identifier'),
+ 'locale' => $this->getValue('locale'),
+ 'locale_name' => $this->getValue('locale_name'),
+ 'locale_native_name' => $this->getValue('locale_native_name'),
+ 'text_direction' => $this->getValue('text_direction'),
+ 'version' => $this->getValue('version'),
+ 'latest_version' => $this->getValue('latest_version'),
+ 'target_version_constraint' => $this->getValue('target_version_constraint'),
+ 'target_version_mismatch' => (bool) $this->getValue('target_version_mismatch', false),
+ 'name' => $this->resolveLocalizedManifestField('name'),
+ 'license' => $this->getValue('license'),
+ 'description' => $this->getLocalizedField('description'),
+ 'status' => $this->getValue('status'),
+ 'is_protected' => (bool) $this->getValue('is_protected', false),
+ 'source_type' => $this->getValue('source_type'),
+ 'origin' => LanguagePackOrigin::fromSourceTypeValue($this->getValue('source_type'))?->value,
+ 'source_url' => $this->getValue('source_url'),
+ 'github_url' => $this->resolveManifestField('github_url'),
+ 'github_changelog_url' => $this->resolveManifestField('github_changelog_url'),
+ 'bundled_identifier' => $this->getValue('bundled_identifier'),
+ 'install_blocked_reason' => $this->getValue('install_blocked_reason'),
+ 'target_name' => $this->resolveTargetName(),
+ 'installed_at' => $this->formatTimestamp('installed_at'),
+ 'activated_at' => $this->formatTimestamp('activated_at'),
+ 'created_at' => $this->formatTimestamp('created_at'),
+ 'updated_at' => $this->formatTimestamp('updated_at'),
+ 'has_update' => $this->resolveHasUpdate(),
+ ...$this->resourceMeta($request),
+ ];
+ }
+
+ /**
+ * 매니페스트 스냅샷의 다국어 필드를 현재 로케일 기준으로 해석합니다.
+ *
+ * 모듈/플러그인의 `name` 컬럼이 다국어 JSON 인 패턴과 일관 — 현재 로케일 → fallback locale → ko → 첫 값.
+ * 매니페스트에 단일 문자열로 들어있으면 그대로 반환 (구버전 호환).
+ *
+ * @param string $key 매니페스트 최상위 키 (예: 'name', 'description')
+ * @return string|null 해석된 문자열 또는 null
+ */
+ private function resolveLocalizedManifestField(string $key): ?string
+ {
+ $manifest = $this->getValue('manifest');
+ if (! is_array($manifest) || ! array_key_exists($key, $manifest)) {
+ return null;
+ }
+
+ $value = $manifest[$key];
+
+ if (is_string($value)) {
+ return $value !== '' ? $value : null;
+ }
+
+ if (! is_array($value) || $value === []) {
+ return null;
+ }
+
+ $locale = \Illuminate\Support\Facades\App::getLocale();
+ $fallback = (string) config('app.fallback_locale', 'ko');
+
+ foreach ([$locale, $fallback, 'ko', 'en'] as $candidate) {
+ if (isset($value[$candidate]) && is_string($value[$candidate]) && $value[$candidate] !== '') {
+ return $value[$candidate];
+ }
+ }
+
+ $first = reset($value);
+
+ return is_string($first) && $first !== '' ? $first : null;
+ }
+
+ /**
+ * 매니페스트 스냅샷에서 단일 문자열 필드를 추출합니다.
+ *
+ * 모듈/플러그인/템플릿의 `github_url` 노출 패턴(매니페스트 SSoT)과 일관되게,
+ * 언어팩도 DB `manifest` JSON 컬럼에 저장된 매니페스트에서 직접 읽습니다.
+ *
+ * @param string $key 매니페스트 최상위 키
+ * @return string|null 문자열 값 또는 null
+ */
+ private function resolveManifestField(string $key): ?string
+ {
+ $manifest = $this->getValue('manifest');
+ if (! is_array($manifest)) {
+ return null;
+ }
+
+ $value = $manifest[$key] ?? null;
+
+ return is_string($value) && $value !== '' ? $value : null;
+ }
+
+ /**
+ * 업데이트 가능 여부를 계산합니다 (latest_version > version).
+ *
+ * @return bool 업데이트 가능 시 true
+ */
+ private function resolveHasUpdate(): bool
+ {
+ $latest = $this->getValue('latest_version');
+ $current = $this->getValue('version');
+
+ if (! $latest || ! $current) {
+ return false;
+ }
+
+ return version_compare((string) $latest, (string) $current, '>');
+ }
+
+ /**
+ * 상세(manifest 포함) 응답을 반환합니다.
+ *
+ * @param Request $request HTTP 요청 객체
+ * @return array manifest 가 포함된 상세 응답
+ */
+ public function toDetailArray(Request $request): array
+ {
+ $manifest = $this->getValue('manifest');
+ $resource = $this->resource;
+ $directoryPath = null;
+ $changelogEntries = [];
+
+ if ($resource && method_exists($resource, 'resolveDirectory')) {
+ try {
+ $directory = $resource->resolveDirectory();
+ $directoryPath = str_replace(base_path().DIRECTORY_SEPARATOR, '', $directory);
+ $changelogPath = $directory.DIRECTORY_SEPARATOR.'CHANGELOG.md';
+ if (is_file($changelogPath)) {
+ $changelogEntries = \App\Extension\Helpers\ChangelogParser::parse($changelogPath);
+ }
+ } catch (\Throwable $e) {
+ $directoryPath = null;
+ }
+ }
+
+ return array_merge($this->toArray($request), [
+ 'manifest' => $manifest,
+ 'validation_summary' => [
+ 'target_version_mismatch' => (bool) $this->getValue('target_version_mismatch', false),
+ 'depends_on_core_locale' => is_array($manifest)
+ ? ($manifest['requires']['depends_on_core_locale'] ?? null)
+ : null,
+ ],
+ 'source_meta' => [
+ 'type' => $this->getValue('source_type'),
+ 'url' => $this->getValue('source_url'),
+ 'installed_by' => $this->getValue('installed_by'),
+ 'latest_version' => $this->getValue('latest_version'),
+ 'directory_path' => $directoryPath,
+ ],
+ 'changelog_entries' => $changelogEntries,
+ ]);
+ }
+
+ /**
+ * 리소스 권한 매핑.
+ *
+ * 미설치 번들 가상 행(`status === uninstalled`)은 활성/비활성/제거 액션이
+ * 무의미하므로 `can_install` 만 노출합니다. 모듈/플러그인 행 액션과 동일 패턴.
+ *
+ * @return array
+ */
+ protected function abilityMap(): array
+ {
+ if ($this->getValue('status') === 'uninstalled') {
+ return [
+ 'can_install' => 'core.language_packs.install',
+ ];
+ }
+
+ return [
+ 'can_activate' => 'core.language_packs.manage',
+ 'can_deactivate' => 'core.language_packs.manage',
+ 'can_uninstall' => 'core.language_packs.manage',
+ ];
+ }
+
+ /**
+ * resourceMeta 후처리 — 의존성 미충족 시 can_install 을 false 로 강제.
+ *
+ * 권한이 있어도 코어 locale/대상 확장 의존성이 미충족이면 설치할 수 없으므로,
+ * UI 가 단일 플래그로 행/모달 버튼 disabled 상태를 결정할 수 있도록 정렬합니다.
+ *
+ * @param Request $request HTTP 요청
+ * @return array 권한 메타
+ */
+ protected function resourceMeta(Request $request): array
+ {
+ $meta = parent::resourceMeta($request);
+ $blocked = $this->getValue('install_blocked_reason');
+ if ($blocked && isset($meta['abilities']['can_install'])) {
+ $meta['abilities']['can_install'] = false;
+ }
+
+ return $meta;
+ }
+
+ /**
+ * 대상 확장의 현재 로케일 이름을 반환합니다.
+ *
+ * 모듈/플러그인/템플릿의 `name` 컬럼은 다국어 JSON 텍스트입니다 — 활성 행을 조회하여
+ * 현재 로케일 키, ko, en, 첫 값 순서로 폴백합니다. 코어/타깃 없음 행은 null.
+ *
+ * @return string|null 다국어 명칭 또는 null
+ */
+ private function resolveTargetName(): ?string
+ {
+ $scope = $this->getValue('scope');
+ $target = $this->getValue('target_identifier');
+ if (! $target) {
+ return null;
+ }
+
+ $tableMap = [
+ 'module' => 'modules',
+ 'plugin' => 'plugins',
+ 'template' => 'templates',
+ ];
+ $table = $tableMap[$scope] ?? null;
+ if (! $table) {
+ return null;
+ }
+
+ $row = \Illuminate\Support\Facades\DB::table($table)
+ ->where('identifier', $target)
+ ->first(['name']);
+ if (! $row || ! $row->name) {
+ return null;
+ }
+
+ $decoded = is_string($row->name) ? json_decode($row->name, true) : $row->name;
+ if (! is_array($decoded)) {
+ return is_string($row->name) ? $row->name : null;
+ }
+
+ $locale = \Illuminate\Support\Facades\App::getLocale();
+
+ return $decoded[$locale] ?? $decoded[config('app.fallback_locale', 'ko')] ?? (reset($decoded) ?: null);
+ }
+
+ /**
+ * 타임스탬프 컬럼을 사용자 타임존 기준 문자열로 변환합니다.
+ *
+ * @param string $key 컬럼 키
+ * @return string|null 포맷된 문자열 또는 null
+ */
+ private function formatTimestamp(string $key): ?string
+ {
+ $value = $this->getValue($key);
+ if (! $value) {
+ return null;
+ }
+
+ return TimezoneHelper::toUserDateTimeString(Carbon::parse($value));
+ }
+}
diff --git a/app/Http/Resources/ModuleResource.php b/app/Http/Resources/ModuleResource.php
index 6b73ee5b..8f21f495 100644
--- a/app/Http/Resources/ModuleResource.php
+++ b/app/Http/Resources/ModuleResource.php
@@ -35,10 +35,30 @@ class ModuleResource extends BaseApiResource
// pending/bundled 상태
'is_pending' => $this->getValue('is_pending', false),
'is_bundled' => $this->getValue('is_bundled', false),
+ // 비활성화 메타 (코어 버전 호환성)
+ 'deactivated_reason' => $this->getDeactivatedReasonValue(),
+ 'deactivated_at' => $this->getValue('deactivated_at'),
+ 'incompatible_required_version' => $this->getValue('incompatible_required_version'),
...$this->resourceMeta($request),
];
}
+ /**
+ * deactivated_reason 을 항상 string|null 로 직렬화합니다.
+ *
+ * @return string|null
+ */
+ protected function getDeactivatedReasonValue(): ?string
+ {
+ $value = $this->getValue('deactivated_reason');
+
+ if ($value instanceof \BackedEnum) {
+ return $value->value;
+ }
+
+ return $value;
+ }
+
/**
* 리소스별 권한 매핑을 반환합니다.
*
@@ -88,6 +108,10 @@ class ModuleResource extends BaseApiResource
// pending/bundled 상태
'is_pending' => $this->getValue('is_pending', false),
'is_bundled' => $this->getValue('is_bundled', false),
+ // 비활성화 메타 (코어 버전 호환성)
+ 'deactivated_reason' => $this->getDeactivatedReasonValue(),
+ 'deactivated_at' => $this->getValue('deactivated_at'),
+ 'incompatible_required_version' => $this->getValue('incompatible_required_version'),
// 타임스탬프
'created_at' => $this->getValue('created_at')
? TimezoneHelper::toUserDateTimeString(Carbon::parse($this->getValue('created_at')))
diff --git a/app/Http/Resources/PluginResource.php b/app/Http/Resources/PluginResource.php
index dec94781..9744e456 100644
--- a/app/Http/Resources/PluginResource.php
+++ b/app/Http/Resources/PluginResource.php
@@ -41,6 +41,10 @@ class PluginResource extends BaseApiResource
// pending/bundled 상태
'is_pending' => $this->getValue('is_pending', false),
'is_bundled' => $this->getValue('is_bundled', false),
+ // 비활성화 메타 (코어 버전 호환성)
+ 'deactivated_reason' => $this->getDeactivatedReasonValue(),
+ 'deactivated_at' => $this->getValue('deactivated_at'),
+ 'incompatible_required_version' => $this->getValue('incompatible_required_version'),
...$this->formatTimestamps(),
...$this->resourceMeta($request),
@@ -116,6 +120,10 @@ class PluginResource extends BaseApiResource
// pending/bundled 상태
'is_pending' => $this->getValue('is_pending', false),
'is_bundled' => $this->getValue('is_bundled', false),
+ // 비활성화 메타 (코어 버전 호환성)
+ 'deactivated_reason' => $this->getDeactivatedReasonValue(),
+ 'deactivated_at' => $this->getValue('deactivated_at'),
+ 'incompatible_required_version' => $this->getValue('incompatible_required_version'),
// 타임스탬프
'created_at' => $this->getValue('created_at'),
'updated_at' => $this->getValue('updated_at'),
@@ -151,6 +159,25 @@ class PluginResource extends BaseApiResource
]);
}
+ /**
+ * deactivated_reason 을 항상 string|null 로 직렬화합니다.
+ *
+ * Eloquent enum cast 가 적용되어 있어도 배열 합성 모델은 raw value 를 줄 수 있어
+ * 두 경우 모두 처리합니다.
+ *
+ * @return string|null
+ */
+ protected function getDeactivatedReasonValue(): ?string
+ {
+ $value = $this->getValue('deactivated_reason');
+
+ if ($value instanceof \BackedEnum) {
+ return $value->value;
+ }
+
+ return $value;
+ }
+
/**
* 의존성 상태를 확인하여 반환합니다.
*
diff --git a/app/Http/Resources/TemplateResource.php b/app/Http/Resources/TemplateResource.php
index f14cf4bf..681d9ac0 100644
--- a/app/Http/Resources/TemplateResource.php
+++ b/app/Http/Resources/TemplateResource.php
@@ -43,10 +43,30 @@ class TemplateResource extends BaseApiResource
// pending/bundled 상태
'is_pending' => $this->getValue('is_pending', false),
'is_bundled' => $this->getValue('is_bundled', false),
+ // 비활성화 메타 (코어 버전 호환성)
+ 'deactivated_reason' => $this->getDeactivatedReasonValue(),
+ 'deactivated_at' => $this->getValue('deactivated_at'),
+ 'incompatible_required_version' => $this->getValue('incompatible_required_version'),
...$this->resourceMeta($request),
];
}
+ /**
+ * deactivated_reason 을 항상 string|null 로 직렬화합니다.
+ *
+ * @return string|null
+ */
+ protected function getDeactivatedReasonValue(): ?string
+ {
+ $value = $this->getValue('deactivated_reason');
+
+ if ($value instanceof \BackedEnum) {
+ return $value->value;
+ }
+
+ return $value;
+ }
+
/**
* 리소스별 권한 매핑을 반환합니다.
*
@@ -87,6 +107,10 @@ class TemplateResource extends BaseApiResource
// pending/bundled 상태
'is_pending' => $this->getValue('is_pending', false),
'is_bundled' => $this->getValue('is_bundled', false),
+ // 비활성화 메타 (코어 버전 호환성)
+ 'deactivated_reason' => $this->getDeactivatedReasonValue(),
+ 'deactivated_at' => $this->getValue('deactivated_at'),
+ 'incompatible_required_version' => $this->getValue('incompatible_required_version'),
// 상세 정보
'locales' => $this->getValue('locales', []),
'layouts_count' => $this->getValue('layouts_count', 0),
diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php
index 6b9f0f2a..f9ea0590 100644
--- a/app/Http/Resources/UserResource.php
+++ b/app/Http/Resources/UserResource.php
@@ -187,7 +187,9 @@ class UserResource extends BaseApiResource
/**
* 리소스별 권한을 해석합니다.
*
- * 슈퍼관리자 및 관리자 계정은 삭제할 수 없으므로 can_delete를 강제 false로 설정합니다.
+ * 슈퍼관리자 계정은 시스템 불변식상 삭제 불가하므로 can_delete=false 로 강제합니다.
+ * 그 외 사용자(관리자 포함)의 삭제 가능 여부는 G7 역할/퍼미션/스코프 시스템
+ * (PermissionHelper::checkScopeAccess) 이 평가합니다.
*
* @param Request $request HTTP 요청 객체
* @return array 권한 배열
@@ -196,7 +198,7 @@ class UserResource extends BaseApiResource
{
$abilities = parent::resolveAbilities($request);
- if ($this->resource->isSuperAdmin() || $this->resource->isAdmin()) {
+ if ($this->resource->isSuperAdmin()) {
$abilities['can_delete'] = false;
}
diff --git a/app/Jobs/GenerateSitemapJob.php b/app/Jobs/GenerateSitemapJob.php
index 0e74f0e8..df419daf 100644
--- a/app/Jobs/GenerateSitemapJob.php
+++ b/app/Jobs/GenerateSitemapJob.php
@@ -2,8 +2,7 @@
namespace App\Jobs;
-use App\Contracts\Extension\CacheInterface;
-use App\Seo\SitemapGenerator;
+use App\Seo\SitemapManager;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
@@ -14,8 +13,8 @@ use Illuminate\Support\Facades\Log;
/**
* Sitemap XML 생성 큐 잡
*
- * Sitemap을 비동기로 생성하여 캐시에 저장합니다.
- * 스케줄러 또는 Artisan 커맨드에서 디스패치됩니다.
+ * 스케줄러 또는 Artisan 커맨드에서 디스패치되며,
+ * 실제 생성 로직은 SitemapManager 서비스에 위임합니다.
*/
class GenerateSitemapJob implements ShouldQueue
{
@@ -32,26 +31,27 @@ class GenerateSitemapJob implements ShouldQueue
public int $timeout = 300;
/**
- * Sitemap을 생성하고 캐시에 저장합니다.
+ * Sitemap 을 생성하고 캐시에 저장합니다.
*
- * @param SitemapGenerator $generator Sitemap 생성기
+ * @param SitemapManager $manager Sitemap 매니저 서비스
*/
- public function handle(SitemapGenerator $generator, CacheInterface $cache): void
+ public function handle(SitemapManager $manager): void
{
- $enabled = (bool) g7_core_settings('seo.sitemap_enabled', true);
- if (! $enabled) {
+ $result = $manager->regenerate();
+
+ if (($result['status'] ?? null) === 'disabled') {
Log::info('[SEO] Sitemap generation skipped (disabled)');
return;
}
- $xml = $generator->generate();
- $ttl = (int) g7_core_settings('cache.seo_sitemap_ttl', g7_core_settings('seo.sitemap_cache_ttl', 86400));
- $cache->put('seo.sitemap', $xml, $ttl);
+ if (! ($result['success'] ?? false)) {
+ throw new \RuntimeException($result['message'] ?? 'Sitemap regeneration failed');
+ }
Log::info('[SEO] Sitemap generated and cached', [
- 'size' => strlen($xml),
- 'ttl' => $ttl,
+ 'size' => $result['data']['size_bytes'] ?? null,
+ 'ttl' => $result['data']['ttl'] ?? null,
]);
}
diff --git a/app/Listeners/BroadcastNotificationListener.php b/app/Listeners/BroadcastNotificationListener.php
index 9b8fb9a5..d42ea897 100644
--- a/app/Listeners/BroadcastNotificationListener.php
+++ b/app/Listeners/BroadcastNotificationListener.php
@@ -19,6 +19,8 @@ class BroadcastNotificationListener implements HookListenerInterface
{
/**
* 구독할 훅 목록.
+ *
+ * @return array> 훅 매핑 배열
*/
public static function getSubscribedHooks(): array
{
diff --git a/app/Listeners/CoreActivityLogListener.php b/app/Listeners/CoreActivityLogListener.php
index 4c942de9..4717b492 100644
--- a/app/Listeners/CoreActivityLogListener.php
+++ b/app/Listeners/CoreActivityLogListener.php
@@ -5,6 +5,9 @@ namespace App\Listeners;
use App\ActivityLog\ChangeDetector;
use App\ActivityLog\Traits\ResolvesActivityLogType;
use App\Contracts\Extension\HookListenerInterface;
+use App\Contracts\Repositories\ActivityLogRepositoryInterface;
+use App\Contracts\Repositories\ScheduleRepositoryInterface;
+use App\Contracts\Repositories\UserRepositoryInterface;
use App\Models\ActivityLog;
use App\Models\Attachment;
use App\Models\Schedule;
@@ -24,6 +27,17 @@ class CoreActivityLogListener implements HookListenerInterface
{
use ResolvesActivityLogType;
+ /**
+ * @param ActivityLogRepositoryInterface $activityLogRepository 활동 로그 Repository
+ * @param UserRepositoryInterface $userRepository 사용자 Repository (bulk lookup)
+ * @param ScheduleRepositoryInterface $scheduleRepository 스케줄 Repository (bulk lookup)
+ */
+ public function __construct(
+ protected ActivityLogRepositoryInterface $activityLogRepository,
+ protected UserRepositoryInterface $userRepository,
+ protected ScheduleRepositoryInterface $scheduleRepository,
+ ) {}
+
/**
* 구독할 훅과 메서드 매핑 반환
*
@@ -49,6 +63,15 @@ class CoreActivityLogListener implements HookListenerInterface
'core.auth.forgot_password' => ['method' => 'handleAuthForgotPassword', 'priority' => 20],
'core.auth.reset_password' => ['method' => 'handleAuthResetPassword', 'priority' => 20],
'core.auth.record_consents' => ['method' => 'handleAuthRecordConsents', 'priority' => 20],
+ 'core.auth.login_failed' => ['method' => 'handleAuthLoginFailed', 'priority' => 20],
+ 'core.auth.account_locked' => ['method' => 'handleAuthAccountLocked', 'priority' => 20],
+
+ // ─── IdentityVerification (IDV) ───
+ // DTO(VerificationChallenge/VerificationResult) 를 인자로 받으므로 sync 실행 필수
+ // (큐 직렬화 대상에 POPO 는 포함되지 않아 queue 시 null 전달됨)
+ 'core.identity.after_request' => ['method' => 'handleIdentityRequested', 'priority' => 20, 'sync' => true],
+ 'core.identity.after_verify' => ['method' => 'handleIdentityVerified', 'priority' => 20, 'sync' => true],
+ 'core.identity.challenge_expired' => ['method' => 'handleIdentityExpired', 'priority' => 20, 'sync' => true],
// ─── Role ───
'core.role.after_create' => ['method' => 'handleRoleAfterCreate', 'priority' => 20],
@@ -177,7 +200,7 @@ class CoreActivityLogListener implements HookListenerInterface
{
// user 삭제 시 activity_logs.user_id를 NULL 처리 (FK 제거됨 — 파티셔닝 호환)
if (isset($userData['id'])) {
- ActivityLog::where('user_id', $userData['id'])->update(['user_id' => null]);
+ $this->activityLogRepository->anonymizeUserId((int) $userData['id']);
}
$this->logActivity('user.delete', [
@@ -248,10 +271,11 @@ class CoreActivityLogListener implements HookListenerInterface
* @param array $uuids 대상 UUID 목록
* @param string $status 변경된 상태
* @param int $updatedCount 변경된 수
+ * @param array $snapshots 변경 전 스냅샷 (uuid => snapshot)
*/
public function handleUserAfterBulkUpdate(array $uuids, string $status, int $updatedCount, array $snapshots = []): void
{
- $users = User::whereIn('uuid', $uuids)->get()->keyBy('uuid');
+ $users = $this->userRepository->findManyByUuidsKeyed($uuids);
foreach ($uuids as $uuid) {
$user = $users->get($uuid);
@@ -353,10 +377,10 @@ class CoreActivityLogListener implements HookListenerInterface
*
* @param User $user 동의한 사용자
* @param array $data 동의 데이터
- * @param string $agreedAt 동의 시각
- * @param string $ip IP 주소
+ * @param string|null $agreedAt 동의 시각 (DispatchHookListenerJob 역직렬화 과정에서 null 가능)
+ * @param string|null $ip IP 주소
*/
- public function handleAuthRecordConsents(User $user, array $data, string $agreedAt, string $ip): void
+ public function handleAuthRecordConsents(User $user, array $data, ?string $agreedAt = null, ?string $ip = null): void
{
$this->logActivity('auth.record_consents', [
'loggable' => $user,
@@ -366,6 +390,41 @@ class CoreActivityLogListener implements HookListenerInterface
]);
}
+ /**
+ * 로그인 실패 로그 기록 (사용자 존재 여부와 무관)
+ *
+ * @param string $email 시도된 이메일
+ * @param array $context IP/UA/시각 등 부가 정보
+ */
+ public function handleAuthLoginFailed(string $email, array $context = []): void
+ {
+ $this->logActivity('auth.login_failed', [
+ 'description_key' => 'activity_log.description.auth_login_failed',
+ 'description_params' => ['email' => $email],
+ 'ip_address' => $context['ip_address'] ?? null,
+ ]);
+ }
+
+ /**
+ * 계정 잠금 로그 기록
+ *
+ * @param User $user 잠긴 사용자
+ * @param array $context attempts/locked_until/lockout_minutes/IP
+ */
+ public function handleAuthAccountLocked(User $user, array $context = []): void
+ {
+ $this->logActivity('auth.account_locked', [
+ 'loggable' => $user,
+ 'description_key' => 'activity_log.description.auth_account_locked',
+ 'description_params' => [
+ 'attempts' => $context['attempts'] ?? null,
+ 'minutes' => $context['lockout_minutes'] ?? null,
+ ],
+ 'user_id' => $user->id,
+ 'ip_address' => $context['ip_address'] ?? null,
+ ]);
+ }
+
// ═══════════════════════════════════════════
// Role 핸들러
// ═══════════════════════════════════════════
@@ -653,10 +712,11 @@ class CoreActivityLogListener implements HookListenerInterface
* @param array $ids 대상 ID 목록
* @param bool $isActive 활성화 여부
* @param int $updatedCount 변경된 수
+ * @param array $snapshots 변경 전 스냅샷 (id => snapshot)
*/
public function handleScheduleAfterBulkUpdate(array $ids, bool $isActive, int $updatedCount, array $snapshots = []): void
{
- $schedules = Schedule::whereIn('id', $ids)->get()->keyBy('id');
+ $schedules = $this->scheduleRepository->findManyByIdsKeyed($ids);
foreach ($ids as $id) {
$schedule = $schedules->get($id);
@@ -682,6 +742,7 @@ class CoreActivityLogListener implements HookListenerInterface
*
* @param array $ids 대상 ID 목록
* @param int $deletedCount 삭제된 수
+ * @param array $snapshots 삭제 전 스냅샷 (id => snapshot)
*/
public function handleScheduleAfterBulkDelete(array $ids, int $deletedCount, array $snapshots = []): void
{
@@ -721,9 +782,9 @@ class CoreActivityLogListener implements HookListenerInterface
/**
* 첨부파일 삭제 후 로그 기록
*
- * @param Model $attachment 삭제된 첨부파일
+ * @param Model|null $attachment 삭제된 첨부파일 (DispatchHookListenerJob 역직렬화 과정에서 null 가능)
*/
- public function handleAttachmentAfterDelete(Model $attachment): void
+ public function handleAttachmentAfterDelete(?Model $attachment = null): void
{
$this->logActivity('attachment.delete', [
'description_key' => 'activity_log.description.attachment_delete',
@@ -794,9 +855,8 @@ class CoreActivityLogListener implements HookListenerInterface
* 모듈 비활성화 후 로그 기록
*
* @param string $moduleName 모듈 식별자
- * @param array $moduleInfo 모듈 정보
*/
- public function handleModuleAfterDeactivate(string $moduleName, array $moduleInfo): void
+ public function handleModuleAfterDeactivate(string $moduleName): void
{
$this->logActivity('module.deactivate', [
'description_key' => 'activity_log.description.module_deactivate',
@@ -887,9 +947,8 @@ class CoreActivityLogListener implements HookListenerInterface
* 플러그인 비활성화 후 로그 기록
*
* @param string $pluginName 플러그인 식별자
- * @param array $pluginInfo 플러그인 정보
*/
- public function handlePluginAfterDeactivate(string $pluginName, array $pluginInfo): void
+ public function handlePluginAfterDeactivate(string $pluginName): void
{
$this->logActivity('plugin.deactivate', [
'description_key' => 'activity_log.description.plugin_deactivate',
@@ -964,13 +1023,13 @@ class CoreActivityLogListener implements HookListenerInterface
/**
* 템플릿 비활성화 후 로그 기록
*
- * @param array $templateInfo 템플릿 정보
+ * @param string $identifier 템플릿 식별자
*/
- public function handleTemplateAfterDeactivate(array $templateInfo): void
+ public function handleTemplateAfterDeactivate(string $identifier): void
{
$this->logActivity('template.deactivate', [
'description_key' => 'activity_log.description.template_deactivate',
- 'description_params' => ['template_name' => $templateInfo['identifier'] ?? ''],
+ 'description_params' => ['template_name' => $identifier],
]);
}
@@ -1124,4 +1183,73 @@ class CoreActivityLogListener implements HookListenerInterface
]);
}
+ // ─────────────────────────────────────────────
+ // IdentityVerification (IDV) 핸들러
+ // ─────────────────────────────────────────────
+
+ /**
+ * 본인인증 challenge 요청 후 로그 기록.
+ *
+ * @param \App\Extension\IdentityVerification\DTO\VerificationChallenge $challenge
+ * @param string $purpose
+ * @param \App\Models\User|array $target
+ * @param array $context
+ */
+ public function handleIdentityRequested($challenge, string $purpose, $target, array $context = []): void
+ {
+ $this->logActivity('identity.request', [
+ 'description_key' => 'identity.logs.activity.requested',
+ 'description_params' => [
+ 'email' => is_object($target) ? ($target->email ?? '') : (string) ($target['email'] ?? ''),
+ ],
+ 'properties' => [
+ 'provider_id' => $challenge->providerId ?? null,
+ 'purpose' => $purpose,
+ 'render_hint' => $challenge->renderHint ?? null,
+ ],
+ ]);
+ }
+
+ /**
+ * 본인인증 검증 후 로그 기록 (성공·실패 공통 디스패치).
+ *
+ * @param \App\Extension\IdentityVerification\DTO\VerificationResult $result
+ * @param \App\Models\IdentityVerificationLog|null $log
+ * @param array $context
+ */
+ public function handleIdentityVerified($result, $log, array $context = []): void
+ {
+ $action = $result->success ? 'identity.verify' : 'identity.verify_failed';
+ $key = $result->success
+ ? 'identity.logs.activity.verified'
+ : 'identity.logs.activity.failed';
+
+ $this->logActivity($action, [
+ 'description_key' => $key,
+ 'description_params' => [],
+ 'properties' => [
+ 'provider_id' => $result->providerId ?? null,
+ 'challenge_id' => $result->challengeId ?? null,
+ 'failure_code' => $result->failureCode ?? null,
+ ],
+ ]);
+ }
+
+ /**
+ * 본인인증 challenge 만료 로그 기록.
+ *
+ * @param \App\Models\IdentityVerificationLog $log
+ */
+ public function handleIdentityExpired($log): void
+ {
+ $this->logActivity('identity.expired', [
+ 'description_key' => 'identity.logs.activity.expired',
+ 'description_params' => [],
+ 'properties' => [
+ 'provider_id' => $log->provider_id ?? null,
+ 'purpose' => $log->purpose ?? null,
+ ],
+ ]);
+ }
+
}
diff --git a/app/Listeners/ExtensionCompatibilityAlertListener.php b/app/Listeners/ExtensionCompatibilityAlertListener.php
index 6eac24c6..17cd0459 100644
--- a/app/Listeners/ExtensionCompatibilityAlertListener.php
+++ b/app/Listeners/ExtensionCompatibilityAlertListener.php
@@ -4,22 +4,45 @@ namespace App\Listeners;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Extension\HookListenerInterface;
+use App\Contracts\Repositories\ModuleRepositoryInterface;
+use App\Contracts\Repositories\PluginRepositoryInterface;
+use App\Contracts\Repositories\TemplateRepositoryInterface;
use App\Extension\Cache\CoreCacheDriver;
+use App\Extension\CoreVersionChecker;
use App\Helpers\TimezoneHelper;
+use App\Services\ExtensionCompatibilityAlertService;
use Carbon\Carbon;
/**
* 확장 호환성 알림 리스너
*
- * 코어 버전 호환성 문제로 자동 비활성화된 확장에 대한
- * 알림을 관리자 대시보드에 표시합니다.
+ * (1) 자동 비활성화된 확장 — `deactivated_reason='incompatible_core'` DB 쿼리 기반 (영속).
+ * (2) 코어 업그레이드 후 재호환된 확장 — `ext.recovery_check.*` 캐시 기반.
+ *
+ * 두 종류의 알림을 관리자 대시보드에 표시합니다. 자동 비활성화 알림은 DB 컬럼이 살아있는
+ * 동안 영속적으로 노출되며, 재호환 알림은 사용자가 "다시 활성화" 버튼으로 복구할 때까지
+ * 표시됩니다.
+ *
+ * @since 7.0.0-beta.4
*/
class ExtensionCompatibilityAlertListener implements HookListenerInterface
{
- private const CACHE_KEY = 'ext.compatibility_alerts';
+ /**
+ * 재호환 감지 결과 캐시 키 prefix (CoreServiceProvider::detectRecoveredExtensions 와 공유).
+ */
+ public const RECOVERY_CACHE_PREFIX = 'ext.recovery_check.';
/**
- * 구독할 훅과 메서드 매핑 반환
+ * @param ExtensionCompatibilityAlertService|null $alertService dismiss 상태 관리 서비스 (DI 주입)
+ */
+ public function __construct(
+ private ?ExtensionCompatibilityAlertService $alertService = null,
+ ) {
+ $this->alertService = $alertService ?? ExtensionCompatibilityAlertService::fallback();
+ }
+
+ /**
+ * 구독할 훅과 메서드 매핑 반환.
*
* @return array 훅 매핑 배열
*/
@@ -35,7 +58,7 @@ class ExtensionCompatibilityAlertListener implements HookListenerInterface
}
/**
- * 훅 이벤트 처리 (기본 핸들러)
+ * 훅 이벤트 처리 (기본 핸들러).
*
* @param mixed ...$args 훅에서 전달된 인수들
*/
@@ -52,23 +75,65 @@ class ExtensionCompatibilityAlertListener implements HookListenerInterface
*/
public function addCompatibilityAlerts(array $alerts): array
{
- $compatibilityAlerts = self::resolveCache()->get(self::CACHE_KEY, []);
+ $dismissedIds = $this->alertService->getDismissedAlertIds(auth()->id());
+
+ // 1) 자동 비활성화된 확장 → 경고 알림 (DB 기반, 영속)
+ foreach ($this->resolveAutoDeactivated() as $type => $records) {
+ foreach ($records as $record) {
+ $alertId = "compat_{$type}_{$record['identifier']}";
+ if (in_array($alertId, $dismissedIds, true)) {
+ continue;
+ }
- foreach ($compatibilityAlerts as $type => $data) {
- foreach ($data['deactivated'] as $extension) {
$alerts[] = [
- 'id' => 'compat_'.$extension['identifier'],
+ 'id' => $alertId,
'type' => 'warning',
+ 'subtype' => 'incompatible_core',
'icon' => 'exclamation-triangle',
'title' => __('extensions.alerts.incompatible_deactivated', [
'type' => __('extensions.types.'.rtrim($type, 's')),
- 'name' => $extension['identifier'],
+ 'name' => $record['identifier'],
]),
'message' => __('extensions.alerts.incompatible_message', [
- 'required' => $extension['required'],
- 'installed' => $data['core_version'],
+ 'required' => $record['incompatible_required_version'] ?? '?',
+ 'installed' => CoreVersionChecker::getCoreVersion(),
]),
- 'time' => TimezoneHelper::toUserCarbon(Carbon::parse($data['timestamp']))?->diffForHumans(),
+ 'extension_type' => rtrim($type, 's'),
+ 'identifier' => $record['identifier'],
+ 'time' => $record['deactivated_at']
+ ? TimezoneHelper::toUserCarbon(Carbon::parse($record['deactivated_at']))?->diffForHumans()
+ : null,
+ 'read' => false,
+ ];
+ }
+ }
+
+ // 2) 재호환된 확장 → 복구 가능 알림 (캐시 기반)
+ foreach ($this->resolveRecovered() as $type => $entries) {
+ foreach ($entries as $entry) {
+ $alertId = "recover_{$type}_{$entry['identifier']}";
+ if (in_array($alertId, $dismissedIds, true)) {
+ continue;
+ }
+
+ $alerts[] = [
+ 'id' => $alertId,
+ 'type' => 'info',
+ 'subtype' => 'recovery_available',
+ 'icon' => 'check-circle',
+ 'title' => __('extensions.alerts.recovered_title', [
+ 'type' => __('extensions.types.'.rtrim($type, 's')),
+ 'name' => $entry['identifier'],
+ ]),
+ 'message' => __('extensions.alerts.recovered_body', [
+ 'previously_required' => $entry['previously_required'] ?? '?',
+ ]),
+ 'extension_type' => rtrim($type, 's'),
+ 'identifier' => $entry['identifier'],
+ 'recover_endpoint' => "/api/admin/extensions/".rtrim($type, 's')."/{$entry['identifier']}/recover",
+ 'time' => $entry['deactivated_at']
+ ? TimezoneHelper::toUserCarbon(Carbon::parse($entry['deactivated_at']))?->diffForHumans()
+ : null,
'read' => false,
];
}
@@ -78,45 +143,120 @@ class ExtensionCompatibilityAlertListener implements HookListenerInterface
}
/**
- * 특정 확장의 호환성 알림을 제거합니다.
+ * 자동 비활성화된 확장 목록을 DB 에서 조회 + hidden 확장 제외.
*
- * @param string $type 확장 타입 (modules, plugins, templates)
- * @param string $identifier 확장 식별자
+ * @return array> ['plugins' => [...], 'modules' => [...], 'templates' => [...]]
*/
- public static function dismissAlert(string $type, string $identifier): void
+ protected function resolveAutoDeactivated(): array
{
- $cache = self::resolveCache();
- $alerts = $cache->get(self::CACHE_KEY, []);
+ $result = [];
- if (isset($alerts[$type]['deactivated'])) {
- $alerts[$type]['deactivated'] = array_filter(
- $alerts[$type]['deactivated'],
- fn ($ext) => $ext['identifier'] !== $identifier
- );
+ $repos = [
+ 'plugins' => app(PluginRepositoryInterface::class),
+ 'modules' => app(ModuleRepositoryInterface::class),
+ 'templates' => app(TemplateRepositoryInterface::class),
+ ];
- // 해당 타입에 비활성화된 확장이 없으면 타입 자체를 제거
- if (empty($alerts[$type]['deactivated'])) {
- unset($alerts[$type]);
+ foreach ($repos as $type => $repo) {
+ $records = $repo->findAutoDeactivated();
+ $items = [];
+
+ foreach ($records as $record) {
+ if ($this->isHiddenExtension(rtrim($type, 's'), $record->identifier)) {
+ continue;
+ }
+
+ $items[] = [
+ 'identifier' => $record->identifier,
+ 'incompatible_required_version' => $record->incompatible_required_version,
+ 'deactivated_at' => $record->deactivated_at,
+ ];
}
- if (empty($alerts)) {
- $cache->forget(self::CACHE_KEY);
- } else {
- $cache->put(self::CACHE_KEY, $alerts, 86400);
+ if ($items !== []) {
+ $result[$type] = $items;
}
}
+
+ return $result;
}
/**
- * 모든 호환성 알림을 제거합니다.
+ * 재호환 감지 결과를 캐시에서 조회 + hidden 확장 제외.
+ *
+ * @return array>
*/
- public static function dismissAllAlerts(): void
+ protected function resolveRecovered(): array
{
- self::resolveCache()->forget(self::CACHE_KEY);
+ $cache = self::resolveCache();
+ $coreVersion = CoreVersionChecker::getCoreVersion();
+ $result = [];
+
+ foreach (['plugins', 'modules', 'templates'] as $type) {
+ $key = self::RECOVERY_CACHE_PREFIX.$type.'.'.$coreVersion;
+ $entries = $cache->get($key, []);
+
+ $filtered = [];
+ foreach ($entries as $entry) {
+ if (! is_array($entry) || ! isset($entry['identifier'])) {
+ continue;
+ }
+ if ($this->isHiddenExtension(rtrim($type, 's'), $entry['identifier'])) {
+ continue;
+ }
+ $filtered[] = $entry;
+ }
+
+ if ($filtered !== []) {
+ $result[$type] = $filtered;
+ }
+ }
+
+ return $result;
}
/**
- * CacheInterface 인스턴스를 lazy 조회합니다.
+ * 확장이 hidden 플래그(학습용 샘플 등) 인지 판정.
+ *
+ * @param string $singularType module|plugin|template
+ * @param string $identifier 확장 식별자
+ */
+ protected function isHiddenExtension(string $singularType, string $identifier): bool
+ {
+ try {
+ $manager = match ($singularType) {
+ 'plugin' => app(\App\Contracts\Extension\PluginManagerInterface::class),
+ 'module' => app(\App\Contracts\Extension\ModuleManagerInterface::class),
+ 'template' => app(\App\Contracts\Extension\TemplateManagerInterface::class),
+ default => null,
+ };
+
+ if ($manager === null) {
+ return false;
+ }
+
+ $extension = match ($singularType) {
+ 'plugin' => $manager->getPlugin($identifier),
+ 'module' => $manager->getModule($identifier),
+ 'template' => $manager->getTemplate($identifier),
+ };
+
+ // ModuleInterface/PluginInterface 는 isHidden() 제공, Template 는 array
+ if (is_array($extension)) {
+ return ! empty($extension['hidden']);
+ }
+ if (is_object($extension) && method_exists($extension, 'isHidden')) {
+ return (bool) $extension->isHidden();
+ }
+ } catch (\Throwable $e) {
+ // 알림 표시 실패가 페이지 전체 장애로 이어지지 않도록 fallback
+ }
+
+ return false;
+ }
+
+ /**
+ * CacheInterface 인스턴스를 lazy 조회합니다 (재호환 캐시 읽기 전용).
*/
private static function resolveCache(): CacheInterface
{
diff --git a/app/Listeners/Identity/ActivateUserOnIdentityVerified.php b/app/Listeners/Identity/ActivateUserOnIdentityVerified.php
new file mode 100644
index 00000000..1ba97416
--- /dev/null
+++ b/app/Listeners/Identity/ActivateUserOnIdentityVerified.php
@@ -0,0 +1,77 @@
+>
+ */
+ public static function getSubscribedHooks(): array
+ {
+ return [
+ 'core.identity.after_verify' => [
+ 'method' => 'handle',
+ 'priority' => 15,
+ 'sync' => true, // VerificationResult DTO 를 받으므로 큐 직렬화 회피
+ ],
+ ];
+ }
+
+ /**
+ * core.identity.after_verify 훅 핸들러.
+ *
+ * @param mixed ...$args [0]=VerificationResult $result, [1]=IdentityVerificationLog $log
+ * @return void
+ */
+ public function handle(...$args): void
+ {
+ $result = $args[0] ?? null;
+ $log = $args[1] ?? null;
+
+ if (! is_object($result) || ! ($result->success ?? false)) {
+ return;
+ }
+
+ if (! $log instanceof IdentityVerificationLog) {
+ return;
+ }
+
+ if ($log->purpose !== 'signup' || $log->user_id === null) {
+ return;
+ }
+
+ $user = $this->userRepository->findById((int) $log->user_id);
+ if (! $user || $user->status !== UserStatus::PendingVerification->value) {
+ return;
+ }
+
+ $this->userRepository->update($user, ['status' => UserStatus::Active->value]);
+
+ // 마케팅/분석 연동용 보조 훅
+ HookManager::doAction('core.auth.after_register_activated', $user);
+ }
+}
diff --git a/app/Listeners/Identity/AssertIdentityVerifiedBeforeRegister.php b/app/Listeners/Identity/AssertIdentityVerifiedBeforeRegister.php
new file mode 100644
index 00000000..92967549
--- /dev/null
+++ b/app/Listeners/Identity/AssertIdentityVerifiedBeforeRegister.php
@@ -0,0 +1,92 @@
+>
+ */
+ public static function getSubscribedHooks(): array
+ {
+ return [
+ 'core.auth.before_register' => [
+ 'method' => 'handle',
+ 'priority' => 10,
+ 'sync' => true, // 인라인 가드 — 실패 시 예외로 가입을 즉시 중단해야 함
+ ],
+ ];
+ }
+
+ /**
+ * core.auth.before_register 훅 핸들러.
+ *
+ * @param mixed ...$args [0]=array $data 가입 요청 데이터, [1]=array $context (signup_stage 등)
+ * @return void
+ *
+ * @throws AuthorizationException 정책 enabled 이고 토큰 무효/타겟 불일치 시
+ */
+ public function handle(...$args): void
+ {
+ $data = $args[0] ?? [];
+ $context = is_array($args[1] ?? null) ? $args[1] : [];
+
+ $policy = $this->policyService->resolve(
+ scope: 'route',
+ target: 'api.auth.register',
+ context: array_merge($context, ['signup_stage' => 'before_submit']),
+ );
+
+ if (! $policy || ! $policy->enabled) {
+ return;
+ }
+
+ $token = (string) ($data['verification_token'] ?? '');
+ if ($token === '') {
+ throw new AuthorizationException(__('identity.errors.invalid_verification_token'));
+ }
+
+ // 토큰의 이메일 타겟 일치 여부 검증 — consumed 여부와 무관하게 verified 상태 확인
+ $log = $this->logRepository->findVerifiedForToken($token, 'signup');
+
+ if (! $log) {
+ throw new AuthorizationException(__('identity.errors.invalid_verification_token'));
+ }
+
+ // 타겟 일치 확인 (하이재킹 방지)
+ $email = (string) ($data['email'] ?? '');
+ if ($email !== '' && $log->target_hash !== hash('sha256', mb_strtolower($email))) {
+ throw new AuthorizationException(__('identity.errors.target_mismatch'));
+ }
+
+ // 재사용 방지 — 검증된 토큰 consume (idempotent: 이미 consume 되어 있어도 OK)
+ if ($log->consumed_at === null) {
+ $this->logRepository->updateById($log->id, [
+ 'consumed_at' => now(),
+ ]);
+ }
+ }
+}
diff --git a/app/Listeners/Identity/EnforceIdentityPolicyListener.php b/app/Listeners/Identity/EnforceIdentityPolicyListener.php
new file mode 100644
index 00000000..9387d442
--- /dev/null
+++ b/app/Listeners/Identity/EnforceIdentityPolicyListener.php
@@ -0,0 +1,228 @@
+>
+ */
+ public static function getSubscribedHooks(): array
+ {
+ // 코어가 보장하는 before_* 훅 (마이그레이션 전 부팅에도 안전).
+ $coreHooks = [
+ 'core.auth.before_reset_password',
+ 'core.user.before_update',
+ 'core.user.before_delete',
+ 'core.user.before_withdraw',
+ 'core.attachment.before_delete',
+ 'core.activity_log.before_delete',
+ 'core.activity_log.before_delete_many',
+ 'core.menu.before_update_order',
+ 'core.dashboard.before_stats',
+ 'core.dashboard.before_resources',
+ 'core.layout_preview.before_generate',
+ 'core.attachment.before_download_action',
+ ];
+
+ // 모듈/플러그인이 declarative getter 로 등록한 hook scope 정책의 target 을 동적 구독.
+ // 부팅 시점에 identity_policies 테이블이 이미 sync 되어 있으므로 자동 작동.
+ $dynamicHooks = static::loadDynamicHookTargets();
+
+ $hookNames = array_values(array_unique(array_merge($coreHooks, $dynamicHooks)));
+
+ return array_fill_keys($hookNames, [
+ 'method' => 'handle',
+ 'priority' => 15, // 먼저 실행되는 가드보다 뒤, Notification 등 부작용보다 앞
+ 'sync' => true,
+ ]);
+ }
+
+ /**
+ * identity_policies 테이블에서 scope='hook' 정책의 target 목록을 추출합니다.
+ *
+ * boot context (static getSubscribedHooks 호출 시점) 에서 동작해야 하므로 컨테이너에서
+ * Repository 를 즉석 해석합니다. 마이그레이션 전이거나 DB 미연결 환경에서 Repository 가
+ * 빈 배열을 반환하도록 보장합니다 (IdentityPolicyRepository::listHookTargets).
+ *
+ * @return list 동적 hook target 목록
+ */
+ protected static function loadDynamicHookTargets(): array
+ {
+ try {
+ return app(\App\Contracts\Repositories\IdentityPolicyRepositoryInterface::class)->listHookTargets();
+ } catch (\Throwable) {
+ return [];
+ }
+ }
+
+ /**
+ * before_* 훅 핸들러. 현재 실행 중인 훅 이름과 매칭되는 hook scope 정책을 enforce 합니다.
+ *
+ * @param mixed ...$args 훅별로 다양한 인자 (첫 인자는 보통 모델/payload)
+ * @return void
+ */
+ public function handle(...$args): void
+ {
+ $hookName = $this->resolveCurrentHook();
+ if ($hookName === null) {
+ return;
+ }
+
+ $policies = $this->policyRepository->resolveByScopeTarget('hook', $hookName);
+ if ($policies->isEmpty()) {
+ return;
+ }
+
+ $context = [
+ 'origin_type' => IdentityOriginType::Hook->value,
+ 'origin_identifier' => $hookName,
+ 'changed_fields' => $this->extractChangedFields($args),
+ // verify 직후 retry 흐름: IdentityGuardInterceptor 가 원 요청 body 에 부착한
+ // verification_token 을 enforce() 의 우회 검사로 전달 (grace_minutes=0 정책 무한 루프 차단).
+ 'verification_token' => $this->resolveVerificationToken(),
+ // 428 응답에 원 요청 정보 포함 — IdentityGuardInterceptor 가 verify 성공 시
+ // return_request.url 에 token 을 부착해 재실행한다. 누락 시 인터셉터가 재시도를
+ // 시작하지 못해 사용자가 인증을 마쳐도 본인확인 토스트가 반복되는 회귀 발생.
+ 'return_request' => $this->resolveReturnRequest(),
+ ];
+
+ foreach ($policies as $policy) {
+ $context['origin_policy_key'] = $policy->key;
+ $this->policyService->enforce($policy, $this->resolveUser($args), $context);
+ }
+ }
+
+ /**
+ * 현재 실행 중인 훅 이름을 HookManager 의 runtime stack 에서 조회합니다.
+ *
+ * @return string|null 훅 이름 또는 null
+ */
+ protected function resolveCurrentHook(): ?string
+ {
+ return \App\Extension\HookManager::getRunningHook();
+ }
+
+ /**
+ * 현재 행위자(actor) 를 추출합니다. IDV 의 "verify 해야 할 주체" 는 행위자이므로
+ * 인증된 Auth::user() 를 우선합니다. 게스트 흐름(예: 비로그인 비밀번호 재설정 요청)
+ * 에서만 훅 인자에 담긴 대상 User 로 폴백합니다.
+ *
+ * 회귀 차단: 관리자가 다른 사용자를 삭제하는 흐름에서 args[0] 의 target 사용자
+ * (일반 유저)를 반환하면 applies_to=admin 정책이 isAdminContext(target)=false 로
+ * 평가돼 우회되던 회귀.
+ *
+ * @param array $args 훅 호출 시 전달된 가변 인자
+ * @return User|null 추출된 행위자 또는 null
+ */
+ protected function resolveUser(array $args): ?User
+ {
+ $authUser = Auth::user();
+ if ($authUser instanceof User) {
+ return $authUser;
+ }
+
+ foreach ($args as $arg) {
+ if ($arg instanceof User) {
+ return $arg;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * 현재 HTTP 요청의 verification_token 을 조회합니다 (없거나 비-HTTP 컨텍스트면 빈 문자열).
+ *
+ * IdentityGuardInterceptor 가 IDV verify 직후 원 요청을 재실행할 때 body/query 에 부착하는
+ * 토큰을 enforce() 의 우회 검사 키로 전달하기 위함. CLI/큐 흐름에서는 request() 바인딩이 없을
+ * 수 있으므로 안전하게 캐치한다.
+ *
+ * @return string verification_token 또는 빈 문자열
+ */
+ protected function resolveVerificationToken(): string
+ {
+ try {
+ $request = app('request');
+ if ($request instanceof \Illuminate\Http\Request) {
+ return (string) $request->input('verification_token', '');
+ }
+ } catch (\Throwable) {
+ // CLI/큐 컨텍스트 — request 바인딩 부재
+ }
+
+ return '';
+ }
+
+ /**
+ * 현재 HTTP 요청의 method/url 을 return_request 형태로 반환합니다.
+ *
+ * 428 응답에 포함되어 IdentityGuardInterceptor 가 verify 성공 후 원 요청을 재실행할 때
+ * 사용. CLI/큐 컨텍스트에서는 null.
+ *
+ * @return array{method: string, url: string}|null
+ */
+ protected function resolveReturnRequest(): ?array
+ {
+ try {
+ $request = app('request');
+ if ($request instanceof \Illuminate\Http\Request) {
+ return [
+ 'method' => $request->getMethod(),
+ 'url' => $request->fullUrl(),
+ ];
+ }
+ } catch (\Throwable) {
+ // CLI/큐 컨텍스트 — request 바인딩 부재
+ }
+
+ return null;
+ }
+
+ /**
+ * 훅 인자에서 changed_fields 를 추출합니다 (정책 conditions.changed_fields 매칭용).
+ *
+ * @param array $args 훅 인자
+ * @return array 변경 필드명 배열
+ */
+ protected function extractChangedFields(array $args): array
+ {
+ foreach ($args as $arg) {
+ if (is_array($arg) && isset($arg['changed_fields'])) {
+ return (array) $arg['changed_fields'];
+ }
+ if (is_object($arg) && method_exists($arg, 'getDirty')) {
+ return array_keys($arg->getDirty());
+ }
+ }
+
+ return [];
+ }
+}
diff --git a/app/Listeners/Identity/InitiateIdentityChallengeAfterRegister.php b/app/Listeners/Identity/InitiateIdentityChallengeAfterRegister.php
new file mode 100644
index 00000000..6a12fa71
--- /dev/null
+++ b/app/Listeners/Identity/InitiateIdentityChallengeAfterRegister.php
@@ -0,0 +1,88 @@
+>
+ */
+ public static function getSubscribedHooks(): array
+ {
+ return [
+ 'core.auth.after_register' => [
+ 'method' => 'handle',
+ 'priority' => 5, // 다른 after_register listener 보다 먼저 실행
+ 'sync' => true,
+ ],
+ ];
+ }
+
+ /**
+ * core.auth.after_register 훅 핸들러.
+ *
+ * @param mixed ...$args [0]=User $user, [1]=array $context (signup_stage, ip_address 등)
+ * @return void
+ */
+ public function handle(...$args): void
+ {
+ $user = $args[0] ?? null;
+ $context = is_array($args[1] ?? null) ? $args[1] : [];
+
+ if (! $user instanceof User) {
+ return;
+ }
+
+ $policy = $this->policyService->resolve(
+ scope: 'hook',
+ target: 'core.auth.after_register',
+ context: array_merge($context, ['signup_stage' => 'after_create']),
+ );
+
+ if (! $policy || ! $policy->enabled) {
+ return;
+ }
+
+ try {
+ $this->service->start(
+ purpose: 'signup',
+ target: $user,
+ context: [
+ 'ip_address' => $context['ip_address'] ?? null,
+ 'user_agent' => $context['user_agent'] ?? null,
+ 'origin_type' => IdentityOriginType::System->value,
+ 'origin_identifier' => 'core.auth.after_register',
+ 'origin_policy_key' => $policy->key,
+ ],
+ );
+ } catch (\Throwable $e) {
+ Log::warning('[IDV] signup_after_create challenge 발행 실패', [
+ 'user_id' => $user->id,
+ 'policy_key' => $policy->key,
+ 'message' => $e->getMessage(),
+ ]);
+ }
+ }
+}
diff --git a/app/Listeners/Identity/InjectIdvRuleIntoRegisterValidation.php b/app/Listeners/Identity/InjectIdvRuleIntoRegisterValidation.php
new file mode 100644
index 00000000..ec84144c
--- /dev/null
+++ b/app/Listeners/Identity/InjectIdvRuleIntoRegisterValidation.php
@@ -0,0 +1,73 @@
+>
+ */
+ public static function getSubscribedHooks(): array
+ {
+ return [
+ 'core.auth.register_validation_rules' => [
+ 'method' => 'filter',
+ 'priority' => 10,
+ 'type' => 'filter',
+ ],
+ ];
+ }
+
+ /**
+ * filter 전용 listener — handle 은 호출되지 않습니다.
+ *
+ * @param mixed ...$args
+ * @return void
+ */
+ public function handle(...$args): void
+ {
+ // filter 전용 — 기본 핸들러는 미사용
+ }
+
+ /**
+ * core.auth.signup_before_submit 정책 enabled 시 verification_token 룰을 추가합니다.
+ *
+ * @param array $rules 기존 검증 규칙
+ * @param mixed ...$args 추가 컨텍스트 (현재 사용 안 함)
+ * @return array 정책 매칭 시 verification_token 추가, 아니면 원본
+ */
+ public function filter(array $rules = [], ...$args): array
+ {
+ $policy = $this->policyService->resolve(
+ scope: 'route',
+ target: 'api.auth.register',
+ context: ['signup_stage' => 'before_submit', 'http_method' => 'POST'],
+ );
+
+ if (! $policy || ! $policy->enabled) {
+ return $rules;
+ }
+
+ $rules['verification_token'] = ['required', 'string', new IdvTokenRule('signup')];
+
+ return $rules;
+ }
+}
diff --git a/app/Listeners/Identity/RejectPasswordResetForPendingUser.php b/app/Listeners/Identity/RejectPasswordResetForPendingUser.php
new file mode 100644
index 00000000..155cefea
--- /dev/null
+++ b/app/Listeners/Identity/RejectPasswordResetForPendingUser.php
@@ -0,0 +1,70 @@
+>
+ */
+ public static function getSubscribedHooks(): array
+ {
+ return [
+ 'core.auth.before_reset_password' => [
+ 'method' => 'handle',
+ 'priority' => 5, // VerifyIdentityBeforePasswordReset 보다 먼저
+ 'sync' => true,
+ ],
+ ];
+ }
+
+ /**
+ * core.auth.before_reset_password 훅 핸들러.
+ *
+ * @param mixed ...$args [0]=User|array{email: string} payload
+ * @return void
+ *
+ * @throws AuthorizationException 사용자가 PendingVerification 상태일 때
+ */
+ public function handle(...$args): void
+ {
+ $first = $args[0] ?? null;
+
+ $user = null;
+ if ($first instanceof User) {
+ $user = $first;
+ } elseif (is_array($first) && ! empty($first['email'])) {
+ $user = $this->userRepository->findByEmail($first['email']);
+ }
+
+ if (! $user) {
+ return;
+ }
+
+ if ($user->status === UserStatus::PendingVerification->value) {
+ throw new AuthorizationException(__('auth.account_pending_verification'));
+ }
+ }
+}
diff --git a/app/Listeners/Identity/VerifyIdentityBeforePasswordReset.php b/app/Listeners/Identity/VerifyIdentityBeforePasswordReset.php
new file mode 100644
index 00000000..0a9e14c2
--- /dev/null
+++ b/app/Listeners/Identity/VerifyIdentityBeforePasswordReset.php
@@ -0,0 +1,73 @@
+>
+ */
+ public static function getSubscribedHooks(): array
+ {
+ return [
+ 'core.auth.before_reset_password' => [
+ 'method' => 'handle',
+ 'priority' => 10,
+ 'sync' => true, // 인라인 가드 — 실패 시 재설정 중단
+ ],
+ ];
+ }
+
+ /**
+ * core.auth.before_reset_password 훅 핸들러.
+ *
+ * @param mixed ...$args [0]=array{token: string, email?: string} 형태의 payload
+ * @return void
+ *
+ * @throws \RuntimeException IDV 로그는 있으나 verified 상태가 아닐 때
+ */
+ public function handle(...$args): void
+ {
+ $payload = $args[0] ?? [];
+ if (! is_array($payload)) {
+ return;
+ }
+
+ $token = (string) ($payload['token'] ?? '');
+ if ($token === '') {
+ return;
+ }
+
+ $log = $this->logRepository->findVerifiedForToken($token, 'password_reset');
+
+ // IDV 로그가 없으면 레거시 password_reset_tokens 경로를 그대로 통과
+ if (! $log) {
+ return;
+ }
+
+ if ($log->status !== IdentityVerificationStatus::Verified->value) {
+ throw new \RuntimeException(__('identity.errors.invalid_verification_token'));
+ }
+ }
+}
diff --git a/app/Listeners/LanguagePack/MergeFrontendLanguage.php b/app/Listeners/LanguagePack/MergeFrontendLanguage.php
new file mode 100644
index 00000000..e599b484
--- /dev/null
+++ b/app/Listeners/LanguagePack/MergeFrontendLanguage.php
@@ -0,0 +1,230 @@
+ $data 병합 누적 데이터
+ * @param string $templateIdentifier 현재 렌더링 템플릿 식별자
+ * @param string $locale 로케일
+ * @return array 병합된 다국어 데이터
+ */
+ public function __invoke(array $data, string $templateIdentifier, string $locale): array
+ {
+ $packs = $this->collectRelevantPacks($templateIdentifier, $locale);
+
+ foreach ($packs as $pack) {
+ $frontend = $this->loadFrontendData($pack);
+ if (empty($frontend)) {
+ continue;
+ }
+
+ // module / plugin 팩은 target_identifier 를 root 키로 wrap 한다.
+ // TemplateService::loadActiveModulesLanguageData() 가 ko/en 데이터를
+ // `[$moduleIdentifier => $data]` 형태로 노출하므로, 동일 구조로 병합되어야
+ // 프론트엔드의 `{{$t:sirsoft-ecommerce.admin.settings.basic_info}}` 표현식이
+ // ja 활성 시에도 정확한 경로로 해석된다.
+ // core / template 팩은 root 에 평탄 병합 (TemplateService 가 동일 구조 사용).
+ if ($pack->target_identifier
+ && in_array($pack->scope, [LanguagePackScope::Module->value, LanguagePackScope::Plugin->value], true)) {
+ $frontend = [$pack->target_identifier => $frontend];
+ }
+
+ $data = $this->mergeRecursive($data, $frontend);
+ }
+
+ return $data;
+ }
+
+ /**
+ * 현재 locale + 템플릿 컨텍스트에 적용할 활성 언어팩을 수집합니다.
+ *
+ * 우선순위 (병합 순서):
+ * 1. core 언어팩
+ * 2. 활성 모듈 언어팩
+ * 3. 활성 플러그인 언어팩
+ * 4. 현재 템플릿 언어팩 (가장 마지막 → 최고 우선)
+ *
+ * @param string $templateIdentifier 렌더링 템플릿 식별자
+ * @param string $locale 로케일
+ * @return array 정렬된 언어팩 목록
+ */
+ private function collectRelevantPacks(string $templateIdentifier, string $locale): array
+ {
+ $allActive = $this->registry->getActivePacks()->filter(
+ fn (LanguagePack $pack) => $pack->locale === $locale
+ );
+
+ $ordered = [];
+
+ foreach ($allActive as $pack) {
+ if ($pack->scope === LanguagePackScope::Core->value) {
+ $ordered[0][] = $pack;
+ }
+ }
+
+ foreach ($allActive as $pack) {
+ if ($pack->scope === LanguagePackScope::Module->value) {
+ $ordered[1][] = $pack;
+ }
+ }
+
+ foreach ($allActive as $pack) {
+ if ($pack->scope === LanguagePackScope::Plugin->value) {
+ $ordered[2][] = $pack;
+ }
+ }
+
+ foreach ($allActive as $pack) {
+ if ($pack->scope === LanguagePackScope::Template->value
+ && $pack->target_identifier === $templateIdentifier) {
+ $ordered[3][] = $pack;
+ }
+ }
+
+ ksort($ordered);
+
+ $flat = [];
+ foreach ($ordered as $bucket) {
+ foreach ($bucket as $pack) {
+ $flat[] = $pack;
+ }
+ }
+
+ return $flat;
+ }
+
+ /**
+ * 언어팩 디렉토리의 frontend 데이터를 로드해 단일 배열로 병합합니다.
+ *
+ * 로드 우선순위:
+ * 1. `frontend/partial/{name}.json` — 파일 basename 을 1단계 키로 사용 (낮은 우선순위)
+ * 2. `frontend/{locale}.json` 등 루트 *.json 파일 — `$partial` 디렉티브 해석 후 병합 (덮어씀)
+ *
+ * 1단계가 있는 이유: 번들 언어팩의 루트 `{locale}.json` 이 잘못된 `$partial` 경로
+ * (예: `partial/ko/auth.json`) 를 가리키더라도 partial 디렉토리 내용을 안전하게
+ * 로드해 키 누락을 회피한다. partial 파일이 없으면 1단계는 건너뛴다.
+ *
+ * @param LanguagePack $pack 대상 언어팩
+ * @return array 병합된 frontend 데이터
+ */
+ private function loadFrontendData(LanguagePack $pack): array
+ {
+ $directory = $pack->resolveDirectory().DIRECTORY_SEPARATOR.'frontend';
+ if (! File::isDirectory($directory)) {
+ return [];
+ }
+
+ $merged = [];
+
+ // 1단계: frontend/partial/*.json 직접 로드 (basename → 키)
+ $partialDir = $directory.DIRECTORY_SEPARATOR.'partial';
+ if (File::isDirectory($partialDir)) {
+ foreach (File::files($partialDir) as $file) {
+ if ($file->getExtension() !== 'json') {
+ continue;
+ }
+
+ $decoded = json_decode(File::get($file->getRealPath()), true);
+ if (! is_array($decoded)) {
+ Log::warning('language-pack frontend partial JSON invalid', [
+ 'pack' => $pack->identifier,
+ 'file' => $file->getFilename(),
+ ]);
+
+ continue;
+ }
+
+ $key = $file->getFilenameWithoutExtension();
+ $existing = $merged[$key] ?? [];
+ $merged[$key] = $this->mergeRecursive(is_array($existing) ? $existing : [], $decoded);
+ }
+ }
+
+ // 2단계: frontend 루트 *.json 파일 — $partial 해석 후 병합
+ foreach (File::files($directory) as $file) {
+ if ($file->getExtension() !== 'json') {
+ continue;
+ }
+
+ $decoded = json_decode(File::get($file->getRealPath()), true);
+ if (! is_array($decoded)) {
+ Log::warning('language-pack frontend JSON invalid', [
+ 'pack' => $pack->identifier,
+ 'file' => $file->getFilename(),
+ ]);
+
+ continue;
+ }
+
+ try {
+ $this->resetFragmentStack();
+ $resolved = $this->resolveLanguageFragments($decoded, $directory);
+ } catch (\RuntimeException $e) {
+ // $partial 경로 오류(예: partial/ko/auth.json 같은 잘못된 참조)는
+ // 1단계 partial 직접 로드로 보완되므로 경고만 남기고 루트 파일은 스킵.
+ Log::warning('language-pack frontend $partial resolution failed', [
+ 'pack' => $pack->identifier,
+ 'file' => $file->getFilename(),
+ 'error' => $e->getMessage(),
+ ]);
+
+ continue;
+ }
+
+ $merged = $this->mergeRecursive($merged, $resolved);
+ }
+
+ return $merged;
+ }
+
+ /**
+ * 재귀 병합 (later wins on scalar conflicts).
+ *
+ * @param array $base 기본 배열
+ * @param array $override 덮어쓸 배열
+ * @return array 병합 결과
+ */
+ private function mergeRecursive(array $base, array $override): array
+ {
+ foreach ($override as $key => $value) {
+ if (is_array($value) && isset($base[$key]) && is_array($base[$key])) {
+ $base[$key] = $this->mergeRecursive($base[$key], $value);
+ } else {
+ $base[$key] = $value;
+ }
+ }
+
+ return $base;
+ }
+}
diff --git a/app/Listeners/LanguagePack/RunSeedersOnLanguagePackLifecycle.php b/app/Listeners/LanguagePack/RunSeedersOnLanguagePackLifecycle.php
new file mode 100644
index 00000000..2b39ecce
--- /dev/null
+++ b/app/Listeners/LanguagePack/RunSeedersOnLanguagePackLifecycle.php
@@ -0,0 +1,245 @@
+ 코어 entity 시더 — translation 필터를 사용하는 시더만 등록 */
+ private const CORE_ENTITY_SEEDERS = [
+ NotificationDefinitionSeeder::class,
+ IdentityMessageDefinitionSeeder::class,
+ IdentityPolicySeeder::class,
+ ];
+
+ /**
+ * @param Application $app Laravel application instance (Artisan call 용)
+ * @param ModuleRepositoryInterface $moduleRepository 활성 모듈 식별자 조회용 Repository
+ * @param PluginRepositoryInterface $pluginRepository 활성 플러그인 식별자 조회용 Repository
+ */
+ public function __construct(
+ private Application $app,
+ private ModuleRepositoryInterface $moduleRepository,
+ private PluginRepositoryInterface $pluginRepository,
+ ) {}
+
+ /**
+ * 언어팩 활성화 시 호출됩니다.
+ *
+ * @param LanguagePack $pack 활성화된 언어팩
+ */
+ public function handleActivated(LanguagePack $pack): void
+ {
+ $this->reseedForScope($pack);
+ }
+
+ /**
+ * 언어팩 비활성화 시에도 시더 재실행하여 비활성 locale 키가 다른 활성 팩 기준으로 정리되도록 합니다.
+ *
+ * 정책: DB 데이터는 보존하되, 활성 locale 변경 시점에 시더가 다시 한 번 활성 locale 머지를
+ * 수행하여 일관된 상태를 유지합니다.
+ *
+ * @param LanguagePack $pack 비활성화된 언어팩
+ */
+ public function handleDeactivated(LanguagePack $pack): void
+ {
+ $this->reseedForScope($pack);
+ }
+
+ /**
+ * 언어팩의 scope 에 따라 적절한 시더를 재실행합니다.
+ */
+ private function reseedForScope(LanguagePack $pack): void
+ {
+ try {
+ // 1. 캐시 무효화 + supported_locales 갱신 (이미 LanguagePackService 가 처리하지만 안전망)
+ $this->refreshRegistry();
+
+ // 2. 신규 활성 언어팩의 seed/{entity}.json 필터를 동적 등록 (boot 시점에 미등록된 신규 팩 보완)
+ $this->refreshExtensionSeedFilters();
+
+ // 3. scope 별 시더 재실행
+ match ($pack->scope) {
+ LanguagePackScope::Core->value => $this->reseedCoreScope(),
+ LanguagePackScope::Module->value => $this->reseedExtensionTarget('module', $pack->target_identifier),
+ LanguagePackScope::Plugin->value => $this->reseedExtensionTarget('plugin', $pack->target_identifier),
+ default => null,
+ };
+ } catch (Throwable $e) {
+ // 시더 실패가 언어팩 활성화 트랜잭션을 깨뜨리지 않도록 로그만 남김
+ Log::error('[language-pack] 시더 재실행 실패', [
+ 'pack' => $pack->identifier,
+ 'scope' => $pack->scope,
+ 'target' => $pack->target_identifier,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ /**
+ * LanguagePackRegistry 의 인스턴스 캐시를 무효화하고 supported_locales 를 갱신합니다.
+ */
+ private function refreshRegistry(): void
+ {
+ // Singleton 캐시된 registry/injector 가 stale 활성 팩 목록을 들고 있을 수 있으므로 forget
+ $this->app->forgetInstance(LanguagePackRegistry::class);
+ $this->app->forgetInstance(LanguagePackSeedInjector::class);
+
+ $registry = $this->app->make(LanguagePackRegistry::class);
+ config([
+ 'app.supported_locales' => $registry->getActiveCoreLocales(),
+ 'app.locale_names' => $registry->getLocaleNames(),
+ 'app.translatable_locales' => $registry->getActiveCoreLocales(),
+ ]);
+ }
+
+ /**
+ * 활성 모듈/플러그인 언어팩의 seed/*.json 필터를 동적으로 재등록합니다.
+ *
+ * boot 시점 등록된 generic filter 가 활성 팩 기준으로 결정되므로,
+ * 활성화 직후의 신규 팩은 자체 entity 시드 필터가 누락된 상태. 본 호출로 보완.
+ *
+ * 멱등성: 동일 키에 클로저를 추가 등록해도 LanguagePackSeedInjector 는 활성 팩에서
+ * 한 번만 sub-key 머지하므로 결과 동일.
+ */
+ private function refreshExtensionSeedFilters(): void
+ {
+ /** @var LanguagePackServiceProvider|null $provider */
+ $provider = $this->app->getProvider(LanguagePackServiceProvider::class);
+ if ($provider === null) {
+ return;
+ }
+ $injector = $this->app->make(LanguagePackSeedInjector::class);
+ $provider->registerExtensionSeedFilters($injector);
+ }
+
+ /**
+ * 코어 entity 시더 + 모든 활성 모듈/플러그인 시더를 재실행합니다.
+ */
+ private function reseedCoreScope(): void
+ {
+ $this->runCoreEntitySeeders();
+ $this->runAllActiveModuleSeeders();
+ $this->runAllActivePluginSeeders();
+ }
+
+ /**
+ * 코어 entity 시더 (translation 필터를 사용하는 것) 만 재실행.
+ */
+ private function runCoreEntitySeeders(): void
+ {
+ foreach (self::CORE_ENTITY_SEEDERS as $seederClass) {
+ try {
+ $seeder = $this->app->make($seederClass);
+ if (method_exists($seeder, 'run')) {
+ $seeder->run();
+ }
+ } catch (Throwable $e) {
+ Log::warning("[language-pack] 코어 시더 실행 실패: {$seederClass}", [
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+ }
+
+ /**
+ * 모든 활성 모듈의 시더를 재실행합니다 (module:seed 명령).
+ */
+ private function runAllActiveModuleSeeders(): void
+ {
+ foreach ($this->moduleRepository->getActiveModuleIdentifiers() as $identifier) {
+ $this->runModuleSeeder($identifier);
+ }
+ }
+
+ /**
+ * 모든 활성 플러그인의 시더를 재실행합니다 (plugin:seed 명령).
+ */
+ private function runAllActivePluginSeeders(): void
+ {
+ foreach ($this->pluginRepository->getActivePluginIdentifiers() as $identifier) {
+ $this->runPluginSeeder($identifier);
+ }
+ }
+
+ /**
+ * 단일 확장의 시더를 재실행합니다.
+ */
+ private function reseedExtensionTarget(string $type, ?string $targetIdentifier): void
+ {
+ if ($targetIdentifier === null || $targetIdentifier === '') {
+ return;
+ }
+
+ if ($type === 'module') {
+ $this->runModuleSeeder($targetIdentifier);
+ } elseif ($type === 'plugin') {
+ $this->runPluginSeeder($targetIdentifier);
+ }
+ }
+
+ /**
+ * 모듈 시더를 안전하게 호출합니다.
+ */
+ private function runModuleSeeder(string $identifier): void
+ {
+ try {
+ Artisan::call('module:seed', [
+ 'identifier' => $identifier,
+ '--force' => true,
+ ]);
+ } catch (Throwable $e) {
+ Log::warning("[language-pack] module:seed 실패: {$identifier}", [
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ /**
+ * 플러그인 시더를 안전하게 호출합니다.
+ */
+ private function runPluginSeeder(string $identifier): void
+ {
+ try {
+ Artisan::call('plugin:seed', [
+ 'identifier' => $identifier,
+ '--force' => true,
+ ]);
+ } catch (Throwable $e) {
+ Log::warning("[language-pack] plugin:seed 실패: {$identifier}", [
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+}
diff --git a/app/Listeners/LanguagePack/SyncDatabaseTranslations.php b/app/Listeners/LanguagePack/SyncDatabaseTranslations.php
new file mode 100644
index 00000000..2ec5bd7c
--- /dev/null
+++ b/app/Listeners/LanguagePack/SyncDatabaseTranslations.php
@@ -0,0 +1,130 @@
+loadSeedBundle($pack, [
+ 'permissions',
+ 'roles',
+ 'menus',
+ 'notifications',
+ 'identity_messages',
+ 'manifest',
+ ]);
+
+ $audit = DB::transaction(fn () => $this->translationRepository->applySeedFromPack($pack, $seedBundle));
+
+ $this->emitAudit('activated', $pack, $audit);
+ }
+
+ /**
+ * 비활성화 훅 처리 — 해당 locale 키를 JSON 에서 제거 (user_overrides 등록 컬럼은 보존).
+ *
+ * @param LanguagePack $pack 비활성화된 언어팩
+ * @return void
+ */
+ public function handleDeactivated(LanguagePack $pack): void
+ {
+ $audit = DB::transaction(fn () => $this->translationRepository->stripLocaleFromPack($pack));
+
+ $this->emitAudit('deactivated', $pack, $audit);
+ }
+
+ /**
+ * 언어팩 디렉토리의 seed/{entity}.json 묶음을 로드합니다.
+ *
+ * @param array $entities 로드할 엔티티 이름 목록
+ * @return array> 엔티티별 seed 데이터 (없으면 미포함)
+ */
+ private function loadSeedBundle(LanguagePack $pack, array $entities): array
+ {
+ $bundle = [];
+ foreach ($entities as $entity) {
+ $seed = $this->loadSeed($pack, $entity);
+ if ($seed !== null) {
+ $bundle[$entity] = $seed;
+ }
+ }
+
+ return $bundle;
+ }
+
+ /**
+ * 단일 seed 파일을 로드합니다.
+ *
+ * @param string $entity 엔티티 이름 (permissions/roles/menus/notifications/identity_messages/manifest)
+ * @return array|null seed 데이터 또는 null
+ */
+ private function loadSeed(LanguagePack $pack, string $entity): ?array
+ {
+ $seedFile = $pack->resolveDirectory().DIRECTORY_SEPARATOR.'seed'.DIRECTORY_SEPARATOR.$entity.'.json';
+ if (! File::isFile($seedFile)) {
+ return null;
+ }
+ $decoded = json_decode(File::get($seedFile), true);
+
+ return is_array($decoded) ? $decoded : null;
+ }
+
+ /**
+ * Repository 가 누적한 감사 항목을 language_pack 채널 로그로 라우팅합니다.
+ *
+ * @param string $action 최상위 동작 (activated/deactivated)
+ * @param array> $audit Repository 가 반환한 감사 항목 배열
+ * @return void
+ */
+ private function emitAudit(string $action, LanguagePack $pack, array $audit): void
+ {
+ $channel = config('logging.channels.language_pack') ? 'language_pack' : 'stack';
+ $base = [
+ 'pack_id' => $pack->id,
+ 'identifier' => $pack->identifier,
+ 'scope' => $pack->scope,
+ 'target' => $pack->target_identifier,
+ ];
+
+ Log::channel($channel)->info('[lang-pack] '.$action, array_merge($base, ['locale' => $pack->locale]));
+
+ foreach ($audit as $entry) {
+ $sub = $entry['action'] ?? 'detail';
+ unset($entry['action']);
+ Log::channel($channel)->info('[lang-pack] '.$sub, array_merge($base, $entry));
+ }
+ }
+}
diff --git a/app/Listeners/MenuUserOverridesListener.php b/app/Listeners/MenuUserOverridesListener.php
index 61054560..cd287319 100644
--- a/app/Listeners/MenuUserOverridesListener.php
+++ b/app/Listeners/MenuUserOverridesListener.php
@@ -47,21 +47,11 @@ class MenuUserOverridesListener implements HookListenerInterface
*/
public function handleBeforeUpdate(Menu $menu, array $data): void
{
- $userOverrides = $menu->user_overrides ?? [];
- $changed = false;
- $trackableFields = ['name', 'icon', 'order', 'url'];
-
- foreach ($trackableFields as $field) {
- if (array_key_exists($field, $data) && $data[$field] !== $menu->{$field}) {
- if (! in_array($field, $userOverrides, true)) {
- $userOverrides[] = $field;
- $changed = true;
- }
- }
- }
-
- if ($changed) {
- $this->menuRepository->update($menu, ['user_overrides' => $userOverrides]);
+ // Trait 의 calculateUserOverridesFor 가 trackableFields + translatableTrackableFields 를
+ // 모두 인지하여 다국어 JSON 컬럼은 sub-key dot-path 단위로 user_overrides 를 누적함.
+ $newOverrides = $menu->calculateUserOverridesFor($data);
+ if ($newOverrides !== ($menu->user_overrides ?? [])) {
+ $this->menuRepository->update($menu, ['user_overrides' => $newOverrides]);
}
}
diff --git a/app/Listeners/NotificationHookListener.php b/app/Listeners/NotificationHookListener.php
index 3a34962e..54630793 100644
--- a/app/Listeners/NotificationHookListener.php
+++ b/app/Listeners/NotificationHookListener.php
@@ -27,6 +27,8 @@ class NotificationHookListener implements HookListenerInterface
*
* DB 기반 동적 구독이므로 정적 메서드에서는 빈 배열을 반환하고,
* boot 시점에 registerDynamicHooks()로 동적 구독합니다.
+ *
+ * @return array> 빈 배열 (동적 등록)
*/
public static function getSubscribedHooks(): array
{
@@ -95,6 +97,13 @@ class NotificationHookListener implements HookListenerInterface
$data = $extracted['data'] ?? [];
$context = $extracted['context'] ?? [];
+ // Listener 가 명시적으로 skip 을 요청한 경우 발송 중단
+ // (e.g. 모듈 환경설정의 notify_* 플래그가 OFF 일 때, 템플릿 recipients 기반 해석이
+ // Listener 의 정책 gate 를 우회하는 문제 해결 — 2026-04-24 sirsoft-board report_policy)
+ if (! empty($context['skip'])) {
+ return;
+ }
+
// 활성 템플릿을 순회하며 채널별 독립 발송
$templates = $definition->templates()->where('is_active', true)->get();
if ($templates->isEmpty()) {
diff --git a/app/Listeners/RoleUserOverridesListener.php b/app/Listeners/RoleUserOverridesListener.php
index 4cfa2f16..06610755 100644
--- a/app/Listeners/RoleUserOverridesListener.php
+++ b/app/Listeners/RoleUserOverridesListener.php
@@ -50,25 +50,11 @@ class RoleUserOverridesListener implements HookListenerInterface
*/
public function handleBeforeUpdate(Role $role, array $data): void
{
- $userOverrides = $role->user_overrides ?? [];
- $changed = false;
-
- if (array_key_exists('name', $data) && $data['name'] !== $role->name) {
- if (! in_array('name', $userOverrides, true)) {
- $userOverrides[] = 'name';
- $changed = true;
- }
- }
-
- if (array_key_exists('description', $data) && $data['description'] !== $role->description) {
- if (! in_array('description', $userOverrides, true)) {
- $userOverrides[] = 'description';
- $changed = true;
- }
- }
-
- if ($changed) {
- $this->roleRepository->update($role, ['user_overrides' => $userOverrides]);
+ // Trait 의 calculateUserOverridesFor 가 trackableFields + translatableTrackableFields 를
+ // 모두 인지하여 다국어 JSON 컬럼은 sub-key dot-path 단위로 user_overrides 를 누적함.
+ $newOverrides = $role->calculateUserOverridesFor($data);
+ if ($newOverrides !== ($role->user_overrides ?? [])) {
+ $this->roleRepository->update($role, ['user_overrides' => $newOverrides]);
}
}
diff --git a/app/Listeners/UserLogin/HandleFailedLoginListener.php b/app/Listeners/UserLogin/HandleFailedLoginListener.php
new file mode 100644
index 00000000..d761a733
--- /dev/null
+++ b/app/Listeners/UserLogin/HandleFailedLoginListener.php
@@ -0,0 +1,90 @@
+ ['method' => 'handleFailed', 'priority' => 10],
+ ];
+ }
+
+ /**
+ * 기본 핸들러 — 사용하지 않음.
+ *
+ * @param mixed ...$args 훅에서 전달된 인수들
+ */
+ public function handle(...$args): void
+ {
+ // no-op
+ }
+
+ /**
+ * 로그인 실패 시 카운트 증가 및 임계 도달 시 잠금 처리.
+ *
+ * @param string $email 실패한 로그인 이메일
+ * @param array $context IP/UA/시각 등 부가 정보
+ */
+ public function handleFailed(string $email, array $context = []): void
+ {
+ if (! (bool) g7_core_settings('security.login_attempt_enabled', true)) {
+ return;
+ }
+
+ $user = $this->userRepository->findByEmail($email);
+ if ($user === null) {
+ // 존재하지 않는 이메일은 IP 기반 throttle 미들웨어가 차단
+ return;
+ }
+
+ $newCount = $this->userRepository->incrementFailedAttempts($user);
+
+ $maxAttempts = (int) HookManager::applyFilters(
+ 'core.auth.max_login_attempts',
+ (int) g7_core_settings('security.max_login_attempts', 5),
+ $user
+ );
+
+ if ($maxAttempts <= 0 || $newCount < $maxAttempts) {
+ return;
+ }
+
+ $lockoutMinutes = (int) HookManager::applyFilters(
+ 'core.auth.lockout_minutes',
+ (int) g7_core_settings('security.login_lockout_time', 5),
+ $user
+ );
+
+ $lockedUntil = $this->userRepository->lockAccount($user, $lockoutMinutes);
+
+ HookManager::doAction('core.auth.account_locked', $user, array_merge($context, [
+ 'attempts' => $newCount,
+ 'locked_until' => $lockedUntil,
+ 'lockout_minutes' => $lockoutMinutes,
+ ]));
+ }
+}
diff --git a/app/Listeners/UserLogin/HandleSuccessfulLoginListener.php b/app/Listeners/UserLogin/HandleSuccessfulLoginListener.php
new file mode 100644
index 00000000..8475ee2f
--- /dev/null
+++ b/app/Listeners/UserLogin/HandleSuccessfulLoginListener.php
@@ -0,0 +1,56 @@
+ ['method' => 'handleLogin', 'priority' => 20],
+ ];
+ }
+
+ /**
+ * 기본 핸들러 — 사용하지 않음.
+ *
+ * @param mixed ...$args 훅에서 전달된 인수들
+ */
+ public function handle(...$args): void
+ {
+ // no-op
+ }
+
+ /**
+ * 로그인 성공 시 잠금 카운터 리셋.
+ *
+ * Repository 메서드는 멱등 — 모든 컬럼이 이미 초기 상태면 UPDATE 미발행.
+ *
+ * @param User $user 로그인한 사용자
+ */
+ public function handleLogin(User $user): void
+ {
+ $this->userRepository->resetLoginAttempts($user);
+ }
+}
diff --git a/app/Mail/IdentityMessageMail.php b/app/Mail/IdentityMessageMail.php
new file mode 100644
index 00000000..5272f2a0
--- /dev/null
+++ b/app/Mail/IdentityMessageMail.php
@@ -0,0 +1,65 @@
+buildTemplateType($providerId, $scopeType, $scopeValue),
+ extensionType: ExtensionOwnerType::Core,
+ extensionIdentifier: 'core',
+ source: 'identity_message',
+ recipientName: $recipientName,
+ );
+ }
+
+ /**
+ * 템플릿 식별자(`identity:{provider}|{scope_type}[|{scope_value}]`)를 조립합니다.
+ *
+ * @param string $providerId
+ * @param string $scopeType
+ * @param string $scopeValue
+ * @return string
+ */
+ private function buildTemplateType(string $providerId, string $scopeType, string $scopeValue): string
+ {
+ $key = 'identity:'.$providerId.'|'.$scopeType;
+
+ if ($scopeValue !== '') {
+ $key .= '|'.$scopeValue;
+ }
+
+ return $key;
+ }
+}
diff --git a/app/Models/ActivityLog.php b/app/Models/ActivityLog.php
index 3128c11e..344219e2 100644
--- a/app/Models/ActivityLog.php
+++ b/app/Models/ActivityLog.php
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Enums\ActivityLogType;
+use App\Extension\ExtensionManager;
use App\Extension\HookManager;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
@@ -155,31 +156,79 @@ class ActivityLog extends Model
/**
* 액션 라벨을 반환합니다.
*
- * 3단계 조회: 전체 키 → 마지막 세그먼트 → raw 문자열 fallback
+ * 5단계 조회: 모듈 lang 전체 키 → 모듈 lang 마지막 세그먼트
+ * → 코어 lang 전체 키 → 코어 lang 마지막 세그먼트 → raw 문자열 fallback
+ *
+ * 모듈/플러그인 origin 라벨은 자체 lang 파일에서 우선 조회되어 G7 의 영역 분리
+ * 설계 의도와 일치합니다. 모듈 lang 미정의 시 코어 lang fallback (하위 호환).
+ *
+ * Origin 식별 우선순위:
+ * 1) loggable_type FQCN (가장 정확)
+ * 2) properties.extension_origin (loggable 없는 케이스 — 호출 시점 trait 자동 주입)
*
* @return string
*/
public function getActionLabelAttribute(): string
{
- // 1단계: 전체 액션 키 (예: activity_log.action.user.create)
+ $namespace = $this->resolveOriginNamespace();
+ $lastSegment = last(explode('.', $this->action));
+
+ if ($namespace !== null) {
+ // 1단계: 모듈 lang 의 전체 액션 키
+ $moduleFullKey = "{$namespace}::activity_log.action.{$this->action}";
+ $translated = __($moduleFullKey);
+ if ($translated !== $moduleFullKey) {
+ return $translated;
+ }
+
+ // 2단계: 모듈 lang 의 마지막 세그먼트
+ $moduleSegmentKey = "{$namespace}::activity_log.action.{$lastSegment}";
+ $translated = __($moduleSegmentKey);
+ if ($translated !== $moduleSegmentKey) {
+ return $translated;
+ }
+ }
+
+ // 3단계: 코어 lang 의 전체 액션 키
$fullKey = "activity_log.action.{$this->action}";
$translated = __($fullKey);
if ($translated !== $fullKey) {
return $translated;
}
- // 2단계: 마지막 세그먼트만 (예: activity_log.action.create)
- $lastSegment = last(explode('.', $this->action));
+ // 4단계: 코어 lang 의 마지막 세그먼트
$segmentKey = "activity_log.action.{$lastSegment}";
$translated = __($segmentKey);
if ($translated !== $segmentKey) {
return $translated;
}
- // 3단계: raw action 문자열 fallback
+ // 5단계: raw action 문자열 fallback
return $this->action;
}
+ /**
+ * 본 활동 로그의 origin 모듈/플러그인 lang 네임스페이스를 추론합니다.
+ *
+ * @return string|null 모듈/플러그인 식별자 (네임스페이스), 코어 origin 또는 미해석 시 null
+ */
+ protected function resolveOriginNamespace(): ?string
+ {
+ if ($this->loggable_type !== null) {
+ $resolved = ExtensionManager::resolveExtensionByFqcn($this->loggable_type);
+ if ($resolved !== null) {
+ return $resolved;
+ }
+ }
+
+ $origin = $this->properties['extension_origin'] ?? null;
+ if (is_string($origin) && $origin !== '') {
+ return $origin;
+ }
+
+ return null;
+ }
+
/**
* 행위자 이름을 반환합니다.
*
diff --git a/app/Models/Concerns/HasUserOverrides.php b/app/Models/Concerns/HasUserOverrides.php
index e09c9aaf..5a4eaf4e 100644
--- a/app/Models/Concerns/HasUserOverrides.php
+++ b/app/Models/Concerns/HasUserOverrides.php
@@ -15,15 +15,20 @@ use Illuminate\Database\Eloquent\Model;
* use HasUserOverrides;
*
* protected array $trackableFields = ['subject', 'body', 'is_active'];
+ *
+ * // 다국어 JSON 컬럼은 sub-key dot-path 단위 보존
+ * protected array $translatableTrackableFields = ['subject', 'body'];
* }
* ```
*
* 동작:
* - 사용자가 trackable 필드를 수정 → user_overrides 에 자동 기록
+ * - 일반 컬럼: `['is_active']` (컬럼명)
+ * - 다국어 JSON 컬럼: `['name.ko']` (사용자가 ko 만 수정한 경우 sub-key 단위)
* - 사용자 경로가 Eloquent 인스턴스 `->update()`, `->save()` 또는 mass update
* `Model::where(...)->update()` 중 어느 것이든 **모두 자동 추적**
- * (mass update 는 커스텀 Builder 가 per-row 로 분해하여 `updating` 이벤트 발화)
* - 시더/업그레이드 스텝에서 syncFromUpgrade() 호출 → 기록된 필드 보존
+ * 다국어 컬럼은 보존 대상 locale 만 이전 값 유지, 나머지 locale 은 신규 값으로 갱신
*
* 컨테이너 플래그 `user_overrides.seeding` 으로 시더/사용자 컨텍스트를 구분합니다.
*
@@ -31,6 +36,7 @@ use Illuminate\Database\Eloquent\Model;
* `DB::table('...')->update(...)` (Eloquent 우회) 사용을 권장합니다.
*
* @since 7.0.0-beta.2
+ * @since 7.0.0-beta.4 다국어 JSON 컬럼 sub-key dot-path 단위 보존 (translatableTrackableFields)
*/
trait HasUserOverrides
{
@@ -57,9 +63,25 @@ trait HasUserOverrides
$userOverrides = $model->user_overrides ?? [];
$original = $userOverrides;
+ $translatable = $model->getTranslatableTrackableFields();
foreach ($model->getTrackableFields() as $field) {
- if ($model->isDirty($field) && ! in_array($field, $userOverrides, true)) {
+ if (! $model->isDirty($field)) {
+ continue;
+ }
+
+ if (in_array($field, $translatable, true)) {
+ $userOverrides = $model->mergeTranslatableOverrides(
+ $userOverrides,
+ $field,
+ $model->getOriginal($field),
+ $model->getAttribute($field),
+ );
+
+ continue;
+ }
+
+ if (! in_array($field, $userOverrides, true)) {
$userOverrides[] = $field;
}
}
@@ -82,6 +104,52 @@ trait HasUserOverrides
return property_exists($this, 'trackableFields') ? $this->trackableFields : [];
}
+ /**
+ * 다국어 JSON 컬럼인 trackable 필드 목록을 반환합니다.
+ *
+ * 모델에서 `protected array $translatableTrackableFields = [...]` 로 오버라이드합니다.
+ * 여기 등록된 필드는 sub-key dot-path 단위로 user_overrides 에 기록되며
+ * (예: `['name.ko', 'name.en']`), syncFromUpgrade 시 sub-key 단위로 보존됩니다.
+ *
+ * trackableFields 와의 관계: translatableTrackableFields ⊂ trackableFields 이어야 합니다.
+ *
+ * @return array
+ */
+ public function getTranslatableTrackableFields(): array
+ {
+ return property_exists($this, 'translatableTrackableFields') ? $this->translatableTrackableFields : [];
+ }
+
+ /**
+ * 다국어 필드 변경 비교 후 user_overrides 에 dot-path 항목을 누적 추가합니다.
+ *
+ * @param array $userOverrides 현재 user_overrides 배열
+ * @param string $field 컬럼명 (예: 'name')
+ * @param mixed $original 변경 전 컬럼 값 (array 또는 null)
+ * @param mixed $current 변경 후 컬럼 값 (array 또는 null)
+ * @return array 갱신된 user_overrides 배열
+ */
+ protected function mergeTranslatableOverrides(array $userOverrides, string $field, mixed $original, mixed $current): array
+ {
+ $originalArr = is_array($original) ? $original : [];
+ $currentArr = is_array($current) ? $current : [];
+ $allLocales = array_unique(array_merge(array_keys($originalArr), array_keys($currentArr)));
+
+ foreach ($allLocales as $locale) {
+ $before = $originalArr[$locale] ?? null;
+ $after = $currentArr[$locale] ?? null;
+ if ($before === $after) {
+ continue;
+ }
+ $entry = "{$field}.{$locale}";
+ if (! in_array($entry, $userOverrides, true)) {
+ $userOverrides[] = $entry;
+ }
+ }
+
+ return $userOverrides;
+ }
+
/**
* 현재 모델 상태와 신규 입력 값을 비교하여 user_overrides 에 추가될 필드 집합을 계산합니다.
*
@@ -95,12 +163,28 @@ trait HasUserOverrides
public function calculateUserOverridesFor(array $incomingAttributes): array
{
$current = $this->user_overrides ?? [];
+ $translatable = $this->getTranslatableTrackableFields();
foreach ($this->getTrackableFields() as $field) {
if (! array_key_exists($field, $incomingAttributes)) {
continue;
}
$incoming = $incomingAttributes[$field];
+
+ if (in_array($field, $translatable, true)) {
+ $incomingArr = $this->normalizeTranslatableValue($incoming);
+ if ($incomingArr === null) {
+ if ($this->{$field} != $incoming && ! in_array($field, $current, true)) {
+ $current[] = $field;
+ }
+
+ continue;
+ }
+ $current = $this->mergeTranslatableOverrides($current, $field, $this->{$field}, $incomingArr);
+
+ continue;
+ }
+
if ($this->{$field} != $incoming && ! in_array($field, $current, true)) {
$current[] = $field;
}
@@ -109,10 +193,35 @@ trait HasUserOverrides
return $current;
}
+ /**
+ * 다국어 값으로 정규화 — array 이면 그대로, JSON 문자열이면 디코드, 그 외 null.
+ *
+ * @param mixed $value
+ * @return array|null
+ */
+ protected function normalizeTranslatableValue(mixed $value): ?array
+ {
+ if (is_array($value)) {
+ return $value;
+ }
+ if (is_string($value) && $value !== '') {
+ $decoded = json_decode($value, true);
+ if (is_array($decoded)) {
+ return $decoded;
+ }
+ }
+
+ return null;
+ }
+
/**
* 업그레이드 스텝/시더에서 호출 — user_overrides 보존하며 갱신합니다.
*
- * trackable 필드 + user_overrides 에 등록된 필드는 갱신을 건너뜁니다.
+ * 일반 trackable 필드: user_overrides 에 컬럼명이 있으면 갱신 SKIP.
+ * 다국어 trackable 필드 (translatableTrackableFields): sub-key 단위 머지.
+ * - user_overrides 에 `field.{locale}` 이 있으면 해당 locale 키만 기존 값 유지
+ * - 나머지 locale 키는 신규 값으로 갱신 (활성 언어팩 자동 동기화)
+ * - 호환성: user_overrides 에 컬럼명(`field`) 만 있는 legacy row 는 컬럼 전체 보존 (기존 동작)
*
* @param array $newAttributes 시더 정의 값
*/
@@ -120,12 +229,49 @@ trait HasUserOverrides
{
$userOverrides = $this->user_overrides ?? [];
$trackable = $this->getTrackableFields();
+ $translatable = $this->getTranslatableTrackableFields();
$updateData = [];
foreach ($newAttributes as $field => $value) {
- if (in_array($field, $trackable, true) && in_array($field, $userOverrides, true)) {
+ $isTrackable = in_array($field, $trackable, true);
+ $isTranslatable = in_array($field, $translatable, true);
+
+ // legacy 컬럼명 보존 (전체 컬럼 갱신 SKIP)
+ if ($isTrackable && in_array($field, $userOverrides, true)) {
continue;
}
+
+ if ($isTranslatable) {
+ $newArr = $this->normalizeTranslatableValue($value);
+ if ($newArr === null) {
+ $updateData[$field] = $value;
+
+ continue;
+ }
+
+ $currentArr = $this->normalizeTranslatableValue($this->getAttribute($field)) ?? [];
+ $merged = $newArr;
+ foreach ($newArr as $locale => $newVal) {
+ $entry = "{$field}.{$locale}";
+ if (in_array($entry, $userOverrides, true) && array_key_exists($locale, $currentArr)) {
+ $merged[$locale] = $currentArr[$locale];
+ }
+ }
+ // 신규 값에 누락된 locale 키 중 사용자가 보존한 키는 유지
+ foreach ($currentArr as $locale => $currentVal) {
+ if (array_key_exists($locale, $merged)) {
+ continue;
+ }
+ if (in_array("{$field}.{$locale}", $userOverrides, true)) {
+ $merged[$locale] = $currentVal;
+ }
+ }
+
+ $updateData[$field] = $merged;
+
+ continue;
+ }
+
$updateData[$field] = $value;
}
diff --git a/app/Models/Concerns/IdentityMessageContentBehavior.php b/app/Models/Concerns/IdentityMessageContentBehavior.php
new file mode 100644
index 00000000..a4ca4856
--- /dev/null
+++ b/app/Models/Concerns/IdentityMessageContentBehavior.php
@@ -0,0 +1,128 @@
+mergeCasts([
+ 'subject' => 'array',
+ 'body' => 'array',
+ 'is_active' => 'boolean',
+ 'is_default' => 'boolean',
+ ]);
+ }
+
+ /**
+ * 활성 템플릿만 조회합니다.
+ *
+ * @param Builder $query
+ * @return Builder
+ */
+ public function scopeActive(Builder $query): Builder
+ {
+ return $query->where('is_active', true);
+ }
+
+ /**
+ * 특정 채널의 템플릿을 조회합니다.
+ *
+ * @param Builder $query
+ * @param string $channel
+ * @return Builder
+ */
+ public function scopeByChannel(Builder $query, string $channel): Builder
+ {
+ return $query->where('channel', $channel);
+ }
+
+ /**
+ * 현재 로케일의 제목을 반환합니다.
+ *
+ * @param string|null $locale
+ * @return string
+ */
+ public function getLocalizedSubject(?string $locale = null): string
+ {
+ $locale = $locale ?? app()->getLocale();
+ $subject = $this->subject ?? [];
+
+ return $subject[$locale] ?? $subject['ko'] ?? $subject['en'] ?? '';
+ }
+
+ /**
+ * 현재 로케일의 본문을 반환합니다.
+ *
+ * @param string|null $locale
+ * @return string
+ */
+ public function getLocalizedBody(?string $locale = null): string
+ {
+ $locale = $locale ?? app()->getLocale();
+ $body = $this->body ?? [];
+
+ return $body[$locale] ?? $body['ko'] ?? $body['en'] ?? '';
+ }
+
+ /**
+ * 변수를 치환하여 제목과 본문을 반환합니다.
+ *
+ * {key} 형식의 변수를 실제 값으로 치환합니다.
+ *
+ * @param array $data key => value 변수 맵
+ * @param string|null $locale
+ * @return array{subject: string, body: string}
+ */
+ public function replaceVariables(array $data, ?string $locale = null): array
+ {
+ $subject = $this->getLocalizedSubject($locale);
+ $body = $this->getLocalizedBody($locale);
+
+ $replacements = [];
+ foreach ($data as $key => $value) {
+ if ($value === null || is_array($value) || is_object($value)) {
+ continue;
+ }
+ $replacements['{'.$key.'}'] = (string) $value;
+ }
+
+ return [
+ 'subject' => strtr($subject, $replacements),
+ 'body' => strtr($body, $replacements),
+ ];
+ }
+
+ /**
+ * 단일 문자열의 변수를 치환합니다.
+ *
+ * @param string $template
+ * @param array $data
+ * @return string
+ */
+ public function replaceVariablesInString(string $template, array $data): string
+ {
+ $replacements = [];
+ foreach ($data as $key => $value) {
+ if (is_string($value) || is_numeric($value)) {
+ $replacements['{'.$key.'}'] = (string) $value;
+ }
+ }
+
+ return strtr($template, $replacements);
+ }
+}
diff --git a/app/Models/Concerns/NotificationContentBehavior.php b/app/Models/Concerns/NotificationContentBehavior.php
index 32b686b7..95224702 100644
--- a/app/Models/Concerns/NotificationContentBehavior.php
+++ b/app/Models/Concerns/NotificationContentBehavior.php
@@ -60,7 +60,7 @@ trait NotificationContentBehavior
$locale = $locale ?? app()->getLocale();
$subject = $this->subject ?? [];
- return $subject[$locale] ?? $subject['ko'] ?? $subject['en'] ?? '';
+ return $subject[$locale] ?? $subject[config('app.fallback_locale', 'ko')] ?? '';
}
/**
@@ -74,7 +74,7 @@ trait NotificationContentBehavior
$locale = $locale ?? app()->getLocale();
$body = $this->body ?? [];
- return $body[$locale] ?? $body['ko'] ?? $body['en'] ?? '';
+ return $body[$locale] ?? $body[config('app.fallback_locale', 'ko')] ?? '';
}
/**
diff --git a/app/Models/IdentityMessageDefinition.php b/app/Models/IdentityMessageDefinition.php
new file mode 100644
index 00000000..d3a25213
--- /dev/null
+++ b/app/Models/IdentityMessageDefinition.php
@@ -0,0 +1,213 @@
+
+ */
+ protected array $trackableFields = ['name', 'is_active'];
+
+ /**
+ * 다국어 JSON 컬럼 — sub-key dot-path 단위 user_overrides 보존.
+ *
+ * @var array
+ */
+ protected array $translatableTrackableFields = ['name'];
+
+ /**
+ * 활동 로그 추적 필드.
+ *
+ * @var array>
+ */
+ public static array $activityLogFields = [
+ 'name' => ['label_key' => 'activity_log.fields.name', 'type' => 'text'],
+ 'channels' => ['label_key' => 'activity_log.fields.channels', 'type' => 'text'],
+ 'is_active' => ['label_key' => 'activity_log.fields.is_active', 'type' => 'boolean'],
+ ];
+
+ /**
+ * @var string
+ */
+ protected $table = 'identity_message_definitions';
+
+ /**
+ * @var array
+ */
+ protected $attributes = [
+ 'channels' => '["mail"]',
+ 'variables' => '[]',
+ 'scope_value' => '',
+ ];
+
+ /**
+ * @var array
+ */
+ protected $fillable = [
+ 'provider_id',
+ 'scope_type',
+ 'scope_value',
+ 'name',
+ 'description',
+ 'channels',
+ 'variables',
+ 'extension_type',
+ 'extension_identifier',
+ 'is_active',
+ 'is_default',
+ 'user_overrides',
+ ];
+
+ /**
+ * 모델 이벤트 등록 — 모든 변경 시 정의 캐시 자동 삭제.
+ */
+ protected static function booted(): void
+ {
+ $invalidate = function () {
+ try {
+ app(IdentityMessageDefinitionService::class)->invalidateAllCache();
+ } catch (\Throwable) {
+ // 테스트/마이그레이션 환경에서 서비스 미등록 시 무시
+ }
+ };
+
+ static::saved($invalidate);
+ static::deleted($invalidate);
+ }
+
+ /**
+ * 캐스팅 정의.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'name' => 'array',
+ 'description' => 'array',
+ 'channels' => 'array',
+ 'variables' => 'array',
+ 'scope_type' => IdentityMessageScopeType::class,
+ 'is_active' => 'boolean',
+ 'is_default' => 'boolean',
+ 'user_overrides' => 'array',
+ ];
+ }
+
+ /**
+ * 메시지 템플릿 관계.
+ *
+ * @return HasMany
+ */
+ public function templates(): HasMany
+ {
+ return $this->hasMany(IdentityMessageTemplate::class, 'definition_id');
+ }
+
+ /**
+ * 활성 정의만 조회.
+ *
+ * @param Builder $query
+ * @return Builder
+ */
+ public function scopeActive(Builder $query): Builder
+ {
+ return $query->where('is_active', true);
+ }
+
+ /**
+ * 특정 프로바이더 정의 조회.
+ *
+ * @param Builder $query
+ * @param string $providerId
+ * @return Builder
+ */
+ public function scopeByProvider(Builder $query, string $providerId): Builder
+ {
+ return $query->where('provider_id', $providerId);
+ }
+
+ /**
+ * 특정 scope 정의 조회.
+ *
+ * @param Builder $query
+ * @param string $scopeType
+ * @param string|null $scopeValue
+ * @return Builder
+ */
+ public function scopeByScope(Builder $query, string $scopeType, ?string $scopeValue = null): Builder
+ {
+ return $query->where('scope_type', $scopeType)
+ ->where('scope_value', $scopeValue ?? '');
+ }
+
+ /**
+ * 특정 확장 정의 조회.
+ *
+ * @param Builder $query
+ * @param string $extensionType
+ * @param string $extensionIdentifier
+ * @return Builder
+ */
+ public function scopeByExtension(Builder $query, string $extensionType, string $extensionIdentifier): Builder
+ {
+ return $query->where('extension_type', $extensionType)
+ ->where('extension_identifier', $extensionIdentifier);
+ }
+
+ /**
+ * 현재 로케일의 이름 반환.
+ *
+ * @param string|null $locale
+ * @return string
+ */
+ public function getLocalizedName(?string $locale = null): string
+ {
+ $locale = $locale ?? app()->getLocale();
+ $name = $this->name ?? [];
+
+ return $name[$locale] ?? $name['ko'] ?? $name['en'] ?? '';
+ }
+
+ /**
+ * 현재 로케일의 설명 반환.
+ *
+ * @param string|null $locale
+ * @return string
+ */
+ public function getLocalizedDescription(?string $locale = null): string
+ {
+ $locale = $locale ?? app()->getLocale();
+ $description = $this->description ?? [];
+
+ return $description[$locale] ?? $description['ko'] ?? $description['en'] ?? '';
+ }
+}
diff --git a/app/Models/IdentityMessageTemplate.php b/app/Models/IdentityMessageTemplate.php
new file mode 100644
index 00000000..4190b7fe
--- /dev/null
+++ b/app/Models/IdentityMessageTemplate.php
@@ -0,0 +1,114 @@
+
+ */
+ protected array $trackableFields = [
+ 'subject',
+ 'body',
+ 'is_active',
+ ];
+
+ /**
+ * 활동 로그 추적 필드.
+ *
+ * @var array>
+ */
+ public static array $activityLogFields = [
+ 'subject' => ['label_key' => 'activity_log.fields.subject', 'type' => 'text'],
+ 'body' => ['label_key' => 'activity_log.fields.body', 'type' => 'text'],
+ 'is_active' => ['label_key' => 'activity_log.fields.is_active', 'type' => 'boolean'],
+ ];
+
+ /**
+ * @var string
+ */
+ protected $table = 'identity_message_templates';
+
+ /**
+ * @var array
+ */
+ protected $fillable = [
+ 'definition_id',
+ 'channel',
+ 'subject',
+ 'body',
+ 'is_active',
+ 'is_default',
+ 'user_overrides',
+ 'updated_by',
+ ];
+
+ /**
+ * 모델 이벤트 등록 — 변경 시 정의/템플릿 캐시 자동 삭제.
+ */
+ protected static function booted(): void
+ {
+ $invalidate = function () {
+ try {
+ app(IdentityMessageDefinitionService::class)->invalidateAllCache();
+ } catch (\Throwable) {
+ // 테스트/마이그레이션 환경에서 서비스 미등록 시 무시
+ }
+ };
+
+ static::saved($invalidate);
+ static::deleted($invalidate);
+ }
+
+ /**
+ * 캐스팅 정의.
+ *
+ * IdentityMessageContentBehavior trait 가 subject/body/is_active/is_default 를 처리하므로
+ * 여기서는 user_overrides 만 추가합니다.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'user_overrides' => 'array',
+ ];
+ }
+
+ /**
+ * 메시지 정의 관계.
+ *
+ * @return BelongsTo
+ */
+ public function definition(): BelongsTo
+ {
+ return $this->belongsTo(IdentityMessageDefinition::class, 'definition_id');
+ }
+
+ /**
+ * 수정자 관계.
+ *
+ * @return BelongsTo
+ */
+ public function updater(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'updated_by');
+ }
+}
diff --git a/app/Models/IdentityPolicy.php b/app/Models/IdentityPolicy.php
new file mode 100644
index 00000000..af143e97
--- /dev/null
+++ b/app/Models/IdentityPolicy.php
@@ -0,0 +1,120 @@
+ 'boolean',
+ 'grace_minutes' => 'integer',
+ 'priority' => 'integer',
+ 'scope' => IdentityPolicyScope::class,
+ 'fail_mode' => IdentityPolicyFailMode::class,
+ 'applies_to' => IdentityPolicyAppliesTo::class,
+ 'source_type' => IdentityPolicySourceType::class,
+ 'conditions' => 'array',
+ 'user_overrides' => 'array',
+ ];
+ }
+
+ /**
+ * Route scope 자동 매핑 캐시 키 — Repository::getRouteScopeIndex 가 사용.
+ * 정책 CRUD 시 saved/deleted 이벤트로 즉시 invalidate 되어 다음 요청부터 새 정책 반영.
+ *
+ * 캐시 백엔드는 CoreCacheDriver(접두사 g7:core:) 를 통해 G7 표준 격리 적용.
+ */
+ public const ROUTE_SCOPE_CACHE_KEY = 'identity_policies.route_scope_index';
+
+ /**
+ * 캐시 무효화 태그 — Repository::getRouteScopeIndex 가 같은 태그로 등록하므로
+ * flushTags 한 번에 일괄 정리됨 (단일 키 forget 보다 향후 다중 캐시 키 확장에 안전).
+ */
+ public const ROUTE_SCOPE_CACHE_TAG = 'identity_policy';
+
+ protected static function booted(): void
+ {
+ static::saved(static::flushRouteScopeCache(...));
+ static::deleted(static::flushRouteScopeCache(...));
+ }
+
+ public static function flushRouteScopeCache(): void
+ {
+ $cache = app(\App\Contracts\Extension\CacheInterface::class);
+ if ($cache->supportsTags()) {
+ $cache->flushTags([self::ROUTE_SCOPE_CACHE_TAG]);
+
+ return;
+ }
+ $cache->forget(self::ROUTE_SCOPE_CACHE_KEY);
+ }
+}
diff --git a/app/Models/IdentityVerificationLog.php b/app/Models/IdentityVerificationLog.php
new file mode 100644
index 00000000..cd973a35
--- /dev/null
+++ b/app/Models/IdentityVerificationLog.php
@@ -0,0 +1,112 @@
+ 'integer',
+ 'max_attempts' => 'integer',
+ 'status' => IdentityVerificationStatus::class,
+ 'origin_type' => IdentityOriginType::class,
+ 'properties' => 'array',
+ 'metadata' => 'array',
+ 'expires_at' => 'datetime',
+ 'verified_at' => 'datetime',
+ 'consumed_at' => 'datetime',
+ ];
+ }
+
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+
+ public function isExpired(): bool
+ {
+ return $this->expires_at !== null && $this->expires_at->isPast();
+ }
+
+ public function isVerified(): bool
+ {
+ return $this->status === IdentityVerificationStatus::Verified;
+ }
+}
diff --git a/app/Models/LanguagePack.php b/app/Models/LanguagePack.php
new file mode 100644
index 00000000..af281e42
--- /dev/null
+++ b/app/Models/LanguagePack.php
@@ -0,0 +1,248 @@
+|null $description
+ * @property string $status
+ * @property bool $is_protected
+ * @property array $manifest
+ * @property string|null $source_type
+ * @property string|null $source_url
+ * @property int|null $installed_by
+ * @property \Illuminate\Support\Carbon|null $installed_at
+ * @property \Illuminate\Support\Carbon|null $activated_at
+ */
+class LanguagePack extends Model
+{
+ use HasFactory;
+
+ /**
+ * 테이블명.
+ *
+ * @var string
+ */
+ protected $table = 'language_packs';
+
+ /**
+ * 기본키.
+ *
+ * @var string
+ */
+ protected $primaryKey = 'id';
+
+ /**
+ * 타임스탬프 사용 여부.
+ *
+ * @var bool
+ */
+ public $timestamps = true;
+
+ /**
+ * 대량 할당 허용 컬럼.
+ *
+ * @var array
+ */
+ protected $fillable = [
+ 'identifier',
+ 'vendor',
+ 'scope',
+ 'target_identifier',
+ 'locale',
+ 'locale_name',
+ 'locale_native_name',
+ 'text_direction',
+ 'version',
+ 'latest_version',
+ 'target_version_constraint',
+ 'target_version_mismatch',
+ 'license',
+ 'description',
+ 'status',
+ 'is_protected',
+ 'manifest',
+ 'source_type',
+ 'source_url',
+ 'installed_by',
+ 'installed_at',
+ 'activated_at',
+ ];
+
+ /**
+ * 캐스팅 정의.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'description' => 'array',
+ 'manifest' => 'array',
+ 'is_protected' => 'boolean',
+ 'target_version_mismatch' => 'boolean',
+ 'installed_at' => 'datetime',
+ 'activated_at' => 'datetime',
+ ];
+ }
+
+ /**
+ * 설치자(User) 와의 관계를 정의합니다.
+ *
+ * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+ */
+ public function installer(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'installed_by');
+ }
+
+ /**
+ * 활성 상태 여부를 확인합니다.
+ *
+ * @return bool 활성 여부
+ */
+ public function isActive(): bool
+ {
+ return $this->status === LanguagePackStatus::Active->value;
+ }
+
+ /**
+ * 코어 스코프 여부를 확인합니다.
+ *
+ * @return bool 코어 여부
+ */
+ public function isCoreScope(): bool
+ {
+ return $this->scope === LanguagePackScope::Core->value;
+ }
+
+ /**
+ * 번들 (수정 보호) 언어팩 여부를 확인합니다.
+ *
+ * @return bool 번들 여부
+ */
+ public function isProtected(): bool
+ {
+ return (bool) $this->is_protected;
+ }
+
+ /**
+ * UI 출처(origin) 분류를 반환합니다.
+ *
+ * `source_type` 컬럼을 3그룹(built_in / bundled / user_installed) 으로 매핑한 값.
+ * 가상 미설치 번들 행도 source_type=bundled 이므로 동일 매핑.
+ *
+ * @return string|null origin 값 (source_type 누락 시 null)
+ */
+ public function getOriginAttribute(): ?string
+ {
+ return LanguagePackOrigin::fromSourceTypeValue($this->source_type)?->value;
+ }
+
+ /**
+ * 슬롯 키를 반환합니다 ({scope}|{target_identifier}|{locale}).
+ *
+ * @return string 슬롯 키
+ */
+ public function slotKey(): string
+ {
+ return sprintf(
+ '%s|%s|%s',
+ $this->scope,
+ $this->target_identifier ?? '',
+ $this->locale
+ );
+ }
+
+ /**
+ * 활성 언어팩만 조회하는 스코프.
+ *
+ * @param \Illuminate\Database\Eloquent\Builder $query
+ * @return \Illuminate\Database\Eloquent\Builder
+ */
+ public function scopeActive(Builder $query): Builder
+ {
+ return $query->where('status', LanguagePackStatus::Active->value);
+ }
+
+ /**
+ * 특정 스코프 언어팩만 조회하는 스코프.
+ *
+ * @param \Illuminate\Database\Eloquent\Builder $query
+ * @param string $scope 스코프 문자열
+ * @return \Illuminate\Database\Eloquent\Builder
+ */
+ public function scopeOfScope(Builder $query, string $scope): Builder
+ {
+ return $query->where('scope', $scope);
+ }
+
+ /**
+ * 특정 슬롯의 언어팩만 조회하는 스코프.
+ *
+ * @param \Illuminate\Database\Eloquent\Builder $query
+ * @param string $scope 스코프 문자열
+ * @param string|null $targetIdentifier 대상 확장 식별자
+ * @param string $locale 로케일
+ * @return \Illuminate\Database\Eloquent\Builder
+ */
+ public function scopeForSlot(
+ Builder $query,
+ string $scope,
+ ?string $targetIdentifier,
+ string $locale
+ ): Builder {
+ return $query
+ ->where('scope', $scope)
+ ->where('target_identifier', $targetIdentifier)
+ ->where('locale', $locale);
+ }
+
+ /**
+ * 활성 디렉토리 절대 경로를 반환합니다.
+ *
+ * source_type=bundled_with_extension 인 가상 레코드는 확장 디렉토리 경로를 반환하고,
+ * 그 외에는 lang-packs/{identifier}/ 경로를 반환합니다.
+ *
+ * @return string 디렉토리 절대 경로
+ */
+ public function resolveDirectory(): string
+ {
+ if ($this->source_type === 'bundled_with_extension' && $this->source_url) {
+ return base_path($this->source_url);
+ }
+
+ // uninstalled 가상 행(미설치 번들)은 활성 디렉토리가 없으므로 _bundled 원본을 가리킴
+ if ($this->status === LanguagePackStatus::Uninstalled->value) {
+ return base_path('lang-packs/_bundled/'.$this->identifier);
+ }
+
+ return base_path('lang-packs/'.$this->identifier);
+ }
+}
diff --git a/app/Models/Menu.php b/app/Models/Menu.php
index 9ea7ac28..16546605 100644
--- a/app/Models/Menu.php
+++ b/app/Models/Menu.php
@@ -36,6 +36,13 @@ class Menu extends Model
*/
protected array $trackableFields = ['name', 'icon', 'order', 'url'];
+ /**
+ * 다국어 JSON 컬럼 — sub-key dot-path 단위 user_overrides 보존.
+ *
+ * @var array
+ */
+ protected array $translatableTrackableFields = ['name'];
+
/** @var array 활동 로그 추적 필드 */
public static array $activityLogFields = [
'url' => ['label_key' => 'activity_log.fields.url', 'type' => 'text'],
diff --git a/app/Models/Module.php b/app/Models/Module.php
index 49dcb07b..19f539a3 100644
--- a/app/Models/Module.php
+++ b/app/Models/Module.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use App\Enums\DeactivationReason;
use App\Enums\ExtensionStatus;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -39,6 +40,9 @@ class Module extends Model
'version',
'latest_version',
'status',
+ 'deactivated_reason',
+ 'deactivated_at',
+ 'incompatible_required_version',
'update_available',
'description',
'github_url',
@@ -63,6 +67,8 @@ class Module extends Model
'metadata' => 'array',
'is_active' => 'boolean',
'update_available' => 'boolean',
+ 'deactivated_reason' => DeactivationReason::class,
+ 'deactivated_at' => 'datetime',
];
}
diff --git a/app/Models/NotificationDefinition.php b/app/Models/NotificationDefinition.php
index f5b99905..a8eb4117 100644
--- a/app/Models/NotificationDefinition.php
+++ b/app/Models/NotificationDefinition.php
@@ -20,6 +20,13 @@ class NotificationDefinition extends Model
*/
protected array $trackableFields = ['name', 'is_active'];
+ /**
+ * 다국어 JSON 컬럼 — sub-key dot-path 단위 user_overrides 보존.
+ *
+ * @var array
+ */
+ protected array $translatableTrackableFields = ['name'];
+
/**
* 모델 이벤트 등록 — 모든 변경 시 알림 정의 캐시 자동 삭제.
*/
@@ -158,7 +165,7 @@ class NotificationDefinition extends Model
$locale = $locale ?? app()->getLocale();
$name = $this->name ?? [];
- return $name[$locale] ?? $name['ko'] ?? $name['en'] ?? '';
+ return $name[$locale] ?? $name[config('app.fallback_locale', 'ko')] ?? '';
}
/**
@@ -172,6 +179,6 @@ class NotificationDefinition extends Model
$locale = $locale ?? app()->getLocale();
$description = $this->description ?? [];
- return $description[$locale] ?? $description['ko'] ?? $description['en'] ?? '';
+ return $description[$locale] ?? $description[config('app.fallback_locale', 'ko')] ?? '';
}
}
diff --git a/app/Models/Permission.php b/app/Models/Permission.php
index f6f937fe..a76d8f42 100644
--- a/app/Models/Permission.php
+++ b/app/Models/Permission.php
@@ -49,6 +49,19 @@ class Permission extends Model
'owner_key',
];
+ /**
+ * 속성 기본값
+ *
+ * type 컬럼은 마이그레이션에서 NOT NULL 이지만 default 가 없어
+ * create 시 명시 제공 없을 경우 DB 레벨 에러 발생. 대다수 권한이 admin 이므로
+ * 모델 레벨 default 를 `admin` 으로 지정하여 호출부 누락을 방어.
+ *
+ * @var array
+ */
+ protected $attributes = [
+ 'type' => 'admin',
+ ];
+
/**
* 속성 캐스팅
*
diff --git a/app/Models/Plugin.php b/app/Models/Plugin.php
index e36a64ea..f5c0c6c1 100644
--- a/app/Models/Plugin.php
+++ b/app/Models/Plugin.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use App\Enums\DeactivationReason;
use App\Enums\ExtensionStatus;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -40,6 +41,9 @@ class Plugin extends Model
'latest_version',
'description',
'status',
+ 'deactivated_reason',
+ 'deactivated_at',
+ 'incompatible_required_version',
'update_available',
'hooks',
'github_url',
@@ -62,6 +66,8 @@ class Plugin extends Model
'hooks' => 'array',
'metadata' => 'array',
'update_available' => 'boolean',
+ 'deactivated_reason' => DeactivationReason::class,
+ 'deactivated_at' => 'datetime',
];
}
diff --git a/app/Models/Role.php b/app/Models/Role.php
index 2fd18256..dae95a67 100644
--- a/app/Models/Role.php
+++ b/app/Models/Role.php
@@ -30,6 +30,16 @@ class Role extends Model
*/
protected array $trackableFields = ['name', 'description'];
+ /**
+ * 다국어 JSON 컬럼 — sub-key dot-path 단위로 user_overrides 보존.
+ *
+ * 사용자가 ko 라벨만 수정하면 user_overrides=['name.ko'] 로 기록되어
+ * 언어팩 활성/시더 재실행 시 ja/en 키는 자동 동기화 가능.
+ *
+ * @var array
+ */
+ protected array $translatableTrackableFields = ['name', 'description'];
+
/** @var array 활동 로그 추적 필드 */
public static array $activityLogFields = [
'identifier' => ['label_key' => 'activity_log.fields.identifier', 'type' => 'text'],
diff --git a/app/Models/Template.php b/app/Models/Template.php
index 849b4d17..4a3471a0 100644
--- a/app/Models/Template.php
+++ b/app/Models/Template.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use App\Enums\DeactivationReason;
use App\Enums\ExtensionStatus;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -32,6 +33,9 @@ class Template extends Model
'update_available',
'type',
'status',
+ 'deactivated_reason',
+ 'deactivated_at',
+ 'incompatible_required_version',
'description',
'user_modified_at',
'github_url',
@@ -54,6 +58,8 @@ class Template extends Model
'metadata' => 'array',
'user_modified_at' => 'datetime',
'update_available' => 'boolean',
+ 'deactivated_reason' => DeactivationReason::class,
+ 'deactivated_at' => 'datetime',
];
}
diff --git a/app/Models/User.php b/app/Models/User.php
index fc2aa259..1ee927a3 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -100,6 +100,14 @@ class User extends Authenticatable
'last_login_at',
'withdrawn_at',
'blocked_at',
+ 'identity_verified_at',
+ 'identity_verified_provider',
+ 'identity_verified_purpose_last',
+ 'identity_hash',
+ 'mobile_verified_at',
+ 'failed_login_attempts',
+ 'locked_until',
+ 'last_failed_login_at',
];
/**
@@ -126,7 +134,12 @@ class User extends Authenticatable
'last_login_at' => 'datetime',
'withdrawn_at' => 'datetime',
'blocked_at' => 'datetime',
+ 'identity_verified_at' => 'datetime',
+ 'mobile_verified_at' => 'datetime',
'is_super' => 'boolean',
+ 'failed_login_attempts' => 'integer',
+ 'locked_until' => 'datetime',
+ 'last_failed_login_at' => 'datetime',
];
}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index faae7ed4..625fb2c0 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -6,9 +6,12 @@ use App\Extension\HookManager;
use App\Http\View\Composers\TemplateComposer;
use App\Http\View\Composers\UserTemplateComposer;
use App\Listeners\ExtensionCompatibilityAlertListener;
+use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Database\Events\QueryExecuted;
+use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;
@@ -81,6 +84,31 @@ class AppServiceProvider extends ServiceProvider
// SQL 쿼리 로그 설정
$this->configureSqlQueryLogging();
+
+ // 로그인 라우트 per-IP 백업 throttle — 보안 환경설정의 per-account 잠금과 2중 방어.
+ // 존재하지 않는 계정에 대한 brute-force / 동일 IP 의 다른 계정 시도까지 차단.
+ $this->configureLoginRateLimiter();
+ }
+
+ /**
+ * 로그인 엔드포인트(`/api/auth/login`, `/api/auth/admin/login`) 의 per-IP RateLimiter 를 등록합니다.
+ *
+ * 보안 환경설정 `security.max_login_attempts` 에 비례하여 분당 허용량을 산출하되
+ * 최소 30 회/분 을 보장 (정상 사용자 오타/타이핑 실수에 대비). 설정 조회 실패 시
+ * 기본값 60 회/분 으로 폴백 — 부팅 안전성 (마이그레이션 전 진입) 확보.
+ */
+ private function configureLoginRateLimiter(): void
+ {
+ RateLimiter::for('auth-login', function (Request $request) {
+ try {
+ $perAccount = (int) g7_core_settings('security.max_login_attempts', 5);
+ $maxPerMinute = max(30, $perAccount * 6);
+ } catch (\Throwable $e) {
+ $maxPerMinute = 60;
+ }
+
+ return Limit::perMinute($maxPerMinute)->by($request->ip());
+ });
}
/**
diff --git a/app/Providers/CoreServiceProvider.php b/app/Providers/CoreServiceProvider.php
index f9c1b493..5962eda7 100644
--- a/app/Providers/CoreServiceProvider.php
+++ b/app/Providers/CoreServiceProvider.php
@@ -3,13 +3,19 @@
namespace App\Providers;
use App\Contracts\Extension\CacheInterface;
+use App\Enums\DeactivationReason;
use App\Contracts\Extension\HookListenerInterface;
use App\Contracts\Extension\ModuleSettingsInterface;
use App\Contracts\Extension\StorageInterface;
use App\Contracts\Extension\TemplateManagerInterface;
+use App\Contracts\Extension\IdentityVerificationInterface;
use App\Contracts\Repositories\ActivityLogRepositoryInterface;
use App\Contracts\Repositories\AttachmentRepositoryInterface;
use App\Contracts\Repositories\ConfigRepositoryInterface;
+use App\Contracts\Repositories\IdentityPolicyRepositoryInterface;
+use App\Contracts\Repositories\IdentityMessageDefinitionRepositoryInterface;
+use App\Contracts\Repositories\IdentityMessageTemplateRepositoryInterface;
+use App\Contracts\Repositories\IdentityVerificationLogRepositoryInterface;
use App\Contracts\Repositories\LayoutExtensionRepositoryInterface;
use App\Contracts\Repositories\LayoutPreviewRepositoryInterface;
use App\Contracts\Repositories\LayoutRepositoryInterface;
@@ -35,6 +41,8 @@ use App\Extension\CoreVersionChecker;
use App\Extension\ExtensionManager;
use App\Extension\HookListenerRegistrar;
use App\Extension\HookManager;
+use App\Extension\IdentityVerification\IdentityVerificationManager;
+use App\Extension\IdentityVerification\Providers\MailIdentityProvider;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Extension\Cache\CoreCacheDriver;
@@ -42,6 +50,10 @@ use App\Extension\Storage\CoreStorageDriver;
use App\Extension\TemplateManager;
use App\Repositories\ActivityLogRepository;
use App\Repositories\AttachmentRepository;
+use App\Repositories\IdentityMessageDefinitionRepository;
+use App\Repositories\IdentityMessageTemplateRepository;
+use App\Repositories\IdentityPolicyRepository;
+use App\Repositories\IdentityVerificationLogRepository;
use App\Repositories\JsonConfigRepository;
use App\Repositories\LayoutExtensionRepository;
use App\Repositories\LayoutPreviewRepository;
@@ -181,6 +193,20 @@ class CoreServiceProvider extends ServiceProvider
$this->app->bind(NotificationRepositoryInterface::class, NotificationRepository::class);
$this->app->bind(NotificationTemplateRepositoryInterface::class, NotificationTemplateRepository::class);
+ // IdentityVerification Repository 바인딩
+ $this->app->bind(IdentityVerificationLogRepositoryInterface::class, IdentityVerificationLogRepository::class);
+ $this->app->bind(IdentityPolicyRepositoryInterface::class, IdentityPolicyRepository::class);
+ $this->app->bind(IdentityMessageDefinitionRepositoryInterface::class, IdentityMessageDefinitionRepository::class);
+ $this->app->bind(IdentityMessageTemplateRepositoryInterface::class, IdentityMessageTemplateRepository::class);
+
+ // IdentityVerification Manager + 기본 MailProvider 등록
+ $this->app->singleton(IdentityVerificationManager::class, function ($app) {
+ $manager = new IdentityVerificationManager();
+ $manager->register($app->make(MailIdentityProvider::class));
+
+ return $manager;
+ });
+
// UniqueIdService 바인딩
$this->app->singleton(UniqueIdServiceInterface::class, UniqueIdService::class);
@@ -289,6 +315,8 @@ class CoreServiceProvider extends ServiceProvider
$moduleManager = $this->app->make(ModuleManager::class);
$moduleManager->loadModules();
$this->validateAndDeactivateIncompatibleExtensions($moduleManager, 'modules');
+ // 코어 업그레이드 후 재호환된 모듈 감지 (자동 재활성화 없음, 알림만 저장)
+ $this->detectRecoveredExtensions('modules');
// 모듈 환경설정 로딩 (활성화된 모듈만)
$this->loadModuleSettingsToConfig($moduleManager);
@@ -297,6 +325,7 @@ class CoreServiceProvider extends ServiceProvider
$pluginManager = $this->app->make(PluginManager::class);
$pluginManager->loadPlugins();
$this->validateAndDeactivateIncompatibleExtensions($pluginManager, 'plugins');
+ $this->detectRecoveredExtensions('plugins');
// 플러그인 환경설정 로딩 (활성화된 플러그인만)
$this->loadPluginSettingsToConfig($pluginManager);
@@ -309,10 +338,65 @@ class CoreServiceProvider extends ServiceProvider
$templateManager = $this->app->make(TemplateManager::class);
$templateManager->loadTemplates();
$this->validateAndDeactivateIncompatibleTemplates($templateManager);
+ $this->detectRecoveredExtensions('templates');
}
// 동적 훅 리스너 일괄 실행 (registerDynamicHooks 메서드를 가진 리스너)
$this->registerDeferredDynamicHooks();
+
+ // 활성 모듈/플러그인이 선언한 IDV purpose 를 Manager 레지스트리에 수집
+ // (DB 저장 없음 — 런타임 계약)
+ $this->collectDeclaredIdentityPurposes($moduleManager, $pluginManager);
+ }
+
+ /**
+ * 활성 모듈·플러그인이 `getIdentityPurposes()` 로 선언한 purpose 들을
+ * `IdentityVerificationManager` 에 일괄 등록합니다.
+ *
+ * DB 에 저장되지 않으며, 매 요청 부팅 시 수집됩니다 (코드 계약).
+ *
+ * @param ModuleManager $moduleManager
+ * @param PluginManager $pluginManager
+ */
+ private function collectDeclaredIdentityPurposes(ModuleManager $moduleManager, PluginManager $pluginManager): void
+ {
+ try {
+ $manager = $this->app->make(IdentityVerificationManager::class);
+ } catch (\Throwable) {
+ return;
+ }
+
+ // 코어 purpose 메타데이터: config/core.php 가 SSoT
+ $corePurposes = config('core.identity_purposes', []);
+ if (is_array($corePurposes) && ! empty($corePurposes)) {
+ $manager->registerDeclaredPurposes($corePurposes, 'core', 'core');
+ }
+
+ foreach ($moduleManager->getActiveModules() as $module) {
+ if (method_exists($module, 'getIdentityPurposes')) {
+ $purposes = $module->getIdentityPurposes();
+ if (is_array($purposes) && ! empty($purposes)) {
+ $manager->registerDeclaredPurposes(
+ $purposes,
+ 'module',
+ method_exists($module, 'getIdentifier') ? $module->getIdentifier() : null,
+ );
+ }
+ }
+ }
+
+ foreach ($pluginManager->getActivePlugins() as $plugin) {
+ if (method_exists($plugin, 'getIdentityPurposes')) {
+ $purposes = $plugin->getIdentityPurposes();
+ if (is_array($purposes) && ! empty($purposes)) {
+ $manager->registerDeclaredPurposes(
+ $purposes,
+ 'plugin',
+ method_exists($plugin, 'getIdentifier') ? $plugin->getIdentifier() : null,
+ );
+ }
+ }
+ }
}
/**
@@ -395,7 +479,14 @@ class CoreServiceProvider extends ServiceProvider
$requiredVersion = $extension->getRequiredCoreVersion();
if (! CoreVersionChecker::isCompatible($requiredVersion)) {
- $manager->$deactivateMethod($identifier);
+ // 자동 비활성화: reason='incompatible_core' + 요구 버전 전달
+ // (수동 비활성화와 DB 레벨 구분 → UI 라벨링 / 알림 영속화 / 재호환 복구 판정)
+ $manager->$deactivateMethod(
+ $identifier,
+ false,
+ DeactivationReason::IncompatibleCore->value,
+ $requiredVersion
+ );
$deactivated[] = [
'identifier' => $identifier,
'required' => $requiredVersion,
@@ -418,6 +509,57 @@ class CoreServiceProvider extends ServiceProvider
$cache->put($cacheKey, true, CoreVersionChecker::getCacheTtl());
}
+ /**
+ * 코어 업그레이드 후 재호환된 자동 비활성화 확장을 감지합니다.
+ *
+ * 자동 재활성화는 수행하지 않습니다 — 사용자 명시적 복구 (recover 엔드포인트) 만 허용.
+ * 결과는 `ext.recovery_check.{type}.{coreVersion}` 캐시에 저장되어
+ * `ExtensionCompatibilityAlertListener` 가 대시보드 알림으로 표시합니다.
+ *
+ * @param string $type 확장 타입 (modules|plugins|templates)
+ */
+ protected function detectRecoveredExtensions(string $type): void
+ {
+ // 코어 업데이트 진행 중에는 스킵 (validateAndDeactivate 와 동일 정책)
+ if (self::isCoreUpdateInProgress()) {
+ return;
+ }
+
+ $cache = $this->app->make(CacheInterface::class);
+ $cacheKey = \App\Listeners\ExtensionCompatibilityAlertListener::RECOVERY_CACHE_PREFIX
+ .$type.'.'.CoreVersionChecker::getCoreVersion();
+
+ // 이미 감지된 결과가 있으면 재계산 스킵 (TTL 1시간 + 코어 버전 변경 시 키 자체가 바뀜)
+ if ($cache->has($cacheKey)) {
+ return;
+ }
+
+ $repo = match ($type) {
+ 'modules' => $this->app->make(\App\Contracts\Repositories\ModuleRepositoryInterface::class),
+ 'plugins' => $this->app->make(\App\Contracts\Repositories\PluginRepositoryInterface::class),
+ 'templates' => $this->app->make(\App\Contracts\Repositories\TemplateRepositoryInterface::class),
+ default => null,
+ };
+
+ if (! $repo) {
+ return;
+ }
+
+ $recovered = [];
+ foreach ($repo->findAutoDeactivated() as $record) {
+ $required = $record->incompatible_required_version;
+ if ($required && CoreVersionChecker::isCompatible($required)) {
+ $recovered[] = [
+ 'identifier' => $record->identifier,
+ 'previously_required' => $required,
+ 'deactivated_at' => $record->deactivated_at,
+ ];
+ }
+ }
+
+ $cache->put($cacheKey, $recovered, CoreVersionChecker::getCacheTtl());
+ }
+
/**
* 호환되지 않는 템플릿을 자동 비활성화합니다.
*
@@ -478,7 +620,12 @@ class CoreServiceProvider extends ServiceProvider
$requiredVersion = $template['g7_version'] ?? null;
if (! CoreVersionChecker::isCompatible($requiredVersion)) {
- $templateManager->deactivateTemplate($identifier);
+ // 자동 비활성화: reason='incompatible_core' + 요구 버전 전달
+ $templateManager->deactivateTemplate(
+ $identifier,
+ DeactivationReason::IncompatibleCore->value,
+ $requiredVersion
+ );
$deactivated[] = [
'identifier' => $identifier,
'required' => $requiredVersion,
diff --git a/app/Providers/InstallerRuntimeServiceProvider.php b/app/Providers/InstallerRuntimeServiceProvider.php
new file mode 100644
index 00000000..02c80949
--- /dev/null
+++ b/app/Providers/InstallerRuntimeServiceProvider.php
@@ -0,0 +1,117 @@
+app->environment('testing')) {
+ return;
+ }
+
+ $runtimePath = base_path(self::RUNTIME_PATH_RELATIVE);
+
+ if (! is_file($runtimePath)) {
+ return;
+ }
+
+ $runtime = @include $runtimePath;
+
+ if (! is_array($runtime)) {
+ return;
+ }
+
+ $this->applyDatabaseConfig($runtime);
+ $this->applyAppKey($runtime);
+ }
+
+ /**
+ * runtime 배열의 DB 자격증명을 config('database.connections.mysql.*') 에 주입.
+ *
+ * @param array $runtime
+ */
+ protected function applyDatabaseConfig(array $runtime): void
+ {
+ $write = $runtime['db']['write'] ?? null;
+
+ if (! is_array($write)) {
+ return;
+ }
+
+ $prefix = $runtime['db']['prefix'] ?? '';
+
+ // mysql 커넥션의 read/write 구조에 맞춰 주입
+ // config/database.php:48-65 가 이 키 구조를 사용
+ if (! empty($write['host'])) {
+ Config::set('database.connections.mysql.write.host', [$write['host']]);
+ Config::set('database.connections.mysql.write.port', $write['port'] ?? '3306');
+ Config::set('database.connections.mysql.write.database', $write['database'] ?? '');
+ Config::set('database.connections.mysql.write.username', $write['username'] ?? '');
+ Config::set('database.connections.mysql.write.password', $write['password'] ?? '');
+ }
+
+ $read = $runtime['db']['read'] ?? null;
+ if (is_array($read) && ! empty($read['host'])) {
+ Config::set('database.connections.mysql.read.host', [$read['host']]);
+ Config::set('database.connections.mysql.read.port', $read['port'] ?? '3306');
+ Config::set('database.connections.mysql.read.database', $read['database'] ?? '');
+ Config::set('database.connections.mysql.read.username', $read['username'] ?? '');
+ Config::set('database.connections.mysql.read.password', $read['password'] ?? '');
+ } else {
+ // read 가 별도 지정되지 않은 경우 write 값으로 동기화 (단일 DB 시나리오)
+ Config::set('database.connections.mysql.read.host', [$write['host']]);
+ Config::set('database.connections.mysql.read.port', $write['port'] ?? '3306');
+ Config::set('database.connections.mysql.read.database', $write['database'] ?? '');
+ Config::set('database.connections.mysql.read.username', $write['username'] ?? '');
+ Config::set('database.connections.mysql.read.password', $write['password'] ?? '');
+ }
+
+ Config::set('database.connections.mysql.prefix', $prefix);
+ }
+
+ /**
+ * runtime 배열의 APP_KEY 를 config('app.key') 에 주입.
+ *
+ * Encrypter 가 lazy resolve 이므로 register 단계 변경으로 충분.
+ *
+ * @param array $runtime
+ */
+ protected function applyAppKey(array $runtime): void
+ {
+ $key = $runtime['app']['key'] ?? null;
+
+ if (! is_string($key) || $key === '') {
+ return;
+ }
+
+ Config::set('app.key', $key);
+ }
+}
diff --git a/app/Providers/LanguagePackServiceProvider.php b/app/Providers/LanguagePackServiceProvider.php
new file mode 100644
index 00000000..8bfd67be
--- /dev/null
+++ b/app/Providers/LanguagePackServiceProvider.php
@@ -0,0 +1,516 @@
+app->bind(
+ LanguagePackRepositoryInterface::class,
+ LanguagePackRepository::class
+ );
+
+ $this->app->bind(
+ LanguagePackTranslationRepositoryInterface::class,
+ LanguagePackTranslationRepository::class
+ );
+
+ $this->app->singleton(LanguagePackRegistry::class, function (Application $app) {
+ return new LanguagePackRegistry(
+ $app->make(LanguagePackRepositoryInterface::class)
+ );
+ });
+
+ $this->app->singleton(LanguagePackSeedInjector::class, function (Application $app) {
+ return new LanguagePackSeedInjector(
+ $app->make(LanguagePackRegistry::class)
+ );
+ });
+
+ $this->app->singleton(LanguagePackBundledRegistrar::class, function (Application $app) {
+ return new LanguagePackBundledRegistrar(
+ $app->make(LanguagePackRepositoryInterface::class),
+ $app->make(LanguagePackRegistry::class),
+ $app->make(\App\Contracts\Extension\CacheInterface::class),
+ );
+ });
+
+ $this->registerTranslatorOverride();
+ }
+
+ /**
+ * 부팅 시 활성 언어팩의 번역 경로를 Translator 에 등록합니다.
+ *
+ * DB 가 준비되지 않았거나(설치 전) language_packs 테이블이 없으면 조용히 건너뜁니다.
+ *
+ * @return void
+ */
+ public function boot(): void
+ {
+ $this->app->booted(function () {
+ if (! $this->isRegistryReady()) {
+ return;
+ }
+
+ try {
+ /** @var LanguagePackRegistry $registry */
+ $registry = $this->app->make(LanguagePackRegistry::class);
+
+ foreach ($registry->getActivePacks() as $pack) {
+ $this->registerActivePack($pack);
+ }
+
+ $this->refreshSupportedLocales($registry);
+ $this->registerSeedFilters();
+ $this->registerEventListeners();
+ } catch (Throwable $e) {
+ report($e);
+ }
+ });
+ }
+
+ /**
+ * HookManager 필터에 LanguagePackSeedInjector 의 메서드를 등록합니다.
+ *
+ * 시더가 applyFilters() 를 호출하면 활성 코어 언어팩의 seed/*.json 으로 다국어 키가 보강됩니다.
+ *
+ * @return void
+ */
+ private function registerSeedFilters(): void
+ {
+ $injector = $this->app->make(LanguagePackSeedInjector::class);
+
+ HookManager::addFilter('core.permissions.config', function ($config) use ($injector) {
+ return $injector->injectCorePermissions(is_array($config) ? $config : []);
+ });
+
+ HookManager::addFilter('core.roles.config', function ($roles) use ($injector) {
+ return $injector->injectCoreRoles(is_array($roles) ? $roles : []);
+ });
+
+ HookManager::addFilter('core.menus.config', function ($menus) use ($injector) {
+ return $injector->injectCoreMenus(is_array($menus) ? $menus : []);
+ });
+
+ HookManager::addFilter('seed.notifications.translations', function ($definitions) use ($injector) {
+ return $injector->injectNotifications(is_array($definitions) ? $definitions : []);
+ });
+
+ HookManager::addFilter('seed.identity_messages.translations', function ($definitions) use ($injector) {
+ return $injector->injectIdentityMessages(is_array($definitions) ? $definitions : []);
+ });
+
+ $this->registerExtensionSeedFilters($injector);
+
+ // 프론트엔드 다국어 데이터 병합 (TemplateService::getLanguageDataWithModules 의 마지막 단계)
+ $merger = $this->app->make(MergeFrontendLanguage::class);
+ HookManager::addFilter('template.language.merge', function ($data, $templateIdentifier = '', $locale = 'ko') use ($merger) {
+ return $merger(is_array($data) ? $data : [], (string) $templateIdentifier, (string) $locale);
+ });
+ }
+
+ /**
+ * 활성 모듈/플러그인 언어팩의 seed/*.json 마다 ext entity 시드 필터(`seed.{target}.{entity}.translations`)에
+ * LanguagePackSeedInjector::injectExtensionEntity() 를 자동 결선합니다.
+ *
+ * 시더가 매칭 키(code/slug/key/identifier) 중 하나로 entry 를 식별하므로, entries[0] 에서 매칭 키 후보를
+ * 우선순위로 자동 감지합니다. 후보 미발견 시 'code' 를 기본값으로 사용합니다.
+ *
+ * @param LanguagePackSeedInjector $injector 주입기
+ * @return void
+ */
+ public function registerExtensionSeedFilters(LanguagePackSeedInjector $injector): void
+ {
+ $registry = $this->app->make(LanguagePackRegistry::class);
+ $candidates = collect()
+ ->merge($registry->getActivePacks(LanguagePackScope::Module->value))
+ ->merge($registry->getActivePacks(LanguagePackScope::Plugin->value));
+
+ foreach ($candidates as $pack) {
+ $seedDir = $pack->resolveDirectory().DIRECTORY_SEPARATOR.'seed';
+ if (! is_dir($seedDir)) {
+ continue;
+ }
+ $target = (string) $pack->target_identifier;
+ if ($target === '') {
+ continue;
+ }
+ foreach (glob($seedDir.DIRECTORY_SEPARATOR.'*.json') as $seedFile) {
+ $entity = pathinfo($seedFile, PATHINFO_FILENAME);
+ if ($entity === 'notifications') {
+ HookManager::addFilter("seed.{$target}.notifications.translations", function ($definitions) use ($injector, $target) {
+ return $injector->injectExtensionNotifications(is_array($definitions) ? $definitions : [], $target);
+ });
+
+ continue;
+ }
+ if ($entity === 'identity_messages') {
+ HookManager::addFilter("seed.{$target}.identity_messages.translations", function ($definitions) use ($injector, $target) {
+ return $injector->injectExtensionIdentityMessages(is_array($definitions) ? $definitions : [], $target);
+ });
+
+ continue;
+ }
+ if ($entity === 'menus') {
+ // 모듈 admin_menus 동기화 시 ModuleManager 가 발행하는 필터에 결선 (모듈 전용).
+ HookManager::addFilter("module.{$target}.admin_menus.translations", function ($menus) use ($injector, $target) {
+ return $injector->injectExtensionMenus(is_array($menus) ? $menus : [], $target);
+ });
+
+ continue;
+ }
+ if ($entity === 'roles') {
+ // 모듈/플러그인 roles 동기화 시 발행되는 필터에 결선.
+ $scopeStr = $pack->scope;
+ HookManager::addFilter("{$scopeStr}.{$target}.roles.translations", function ($roles) use ($injector, $target, $scopeStr) {
+ return $injector->injectExtensionRoles(is_array($roles) ? $roles : [], $target, $scopeStr);
+ });
+
+ continue;
+ }
+ if ($entity === 'permissions') {
+ // 모듈/플러그인 permissions 트리 동기화 시 발행되는 필터에 결선.
+ $scopeStr = $pack->scope;
+ HookManager::addFilter("{$scopeStr}.{$target}.permissions.translations", function ($config) use ($injector, $target, $scopeStr) {
+ return $injector->injectExtensionPermissions(is_array($config) ? $config : [], $target, $scopeStr);
+ });
+
+ continue;
+ }
+ HookManager::addFilter("seed.{$target}.{$entity}.translations", function ($entries) use ($injector, $target, $entity) {
+ if (! is_array($entries) || empty($entries)) {
+ return $entries;
+ }
+ $matchKey = $this->detectMatchKey($entries[0] ?? []);
+
+ return $injector->injectExtensionEntity($entries, $target, $entity, $matchKey);
+ });
+ }
+ }
+ }
+
+ /**
+ * 시드 entry 에서 매칭 키 컬럼을 자동 감지합니다.
+ *
+ * 우선순위: code > slug > key > identifier > id. 미발견 시 'code'.
+ *
+ * @param array $entry 시드 entry
+ * @return string 매칭 키 컬럼명
+ */
+ private function detectMatchKey(array $entry): string
+ {
+ foreach (['code', 'slug', 'key', 'identifier', 'id'] as $key) {
+ if (array_key_exists($key, $entry)) {
+ return $key;
+ }
+ }
+
+ return 'code';
+ }
+
+ /**
+ * `core.language_packs.activated` / `core.language_packs.deactivated` 액션 훅에
+ * SyncDatabaseTranslations 리스너를 연결합니다.
+ *
+ * G7 표준 훅 메커니즘(HookManager::doAction → addAction) 으로 일원화 — 별도 Event 클래스 사용 안 함.
+ * 훅 명명은 모듈/플러그인/템플릿 (`core.{type}.activated/deactivated/installed/updated/uninstalled`) 와 동일.
+ *
+ * @return void
+ */
+ private function registerEventListeners(): void
+ {
+ $listener = $this->app->make(SyncDatabaseTranslations::class);
+
+ HookManager::addAction('core.language_packs.activated', function ($pack) use ($listener) {
+ $listener->handleActivated($pack);
+ });
+
+ HookManager::addAction('core.language_packs.deactivated', function ($pack) use ($listener) {
+ $listener->handleDeactivated($pack);
+ });
+
+ // entity 시더 자동 재실행 (board_types/claim_reasons 등 다국어 JSON 데이터의 활성 locale 동기화)
+ $reseeder = $this->app->make(RunSeedersOnLanguagePackLifecycle::class);
+
+ HookManager::addAction('core.language_packs.activated', function ($pack) use ($reseeder) {
+ $reseeder->handleActivated($pack);
+ });
+
+ HookManager::addAction('core.language_packs.deactivated', function ($pack) use ($reseeder) {
+ $reseeder->handleDeactivated($pack);
+ });
+
+ $this->registerBundledRegistrarHooks();
+ }
+
+ /**
+ * 확장(모듈/플러그인/템플릿) 설치/제거/업데이트 후크에 가상 등록 리스너를 연결합니다.
+ *
+ * 모듈 설치 후 모듈의 lang 디렉토리를 스캔하여 `bundled_with_extension` 가상 레코드를
+ * `language_packs` 테이블에 자동 등록합니다 (계획서 §3.6).
+ *
+ * @return void
+ */
+ private function registerBundledRegistrarHooks(): void
+ {
+ $registrar = $this->app->make(LanguagePackBundledRegistrar::class);
+
+ $syncFor = function (string $scope, string $identifier, ?array $info, string $relativeBase) use ($registrar) {
+ $vendor = $this->resolveVendor($identifier, $info);
+ $version = (string) ($info['version'] ?? '1.0.0');
+ $langDir = $relativeBase.'/'.$identifier.'/lang';
+ if (! \Illuminate\Support\Facades\File::isDirectory(base_path($langDir))) {
+ $langDir = $relativeBase.'/'.$identifier.'/resources/lang';
+ }
+ $registrar->syncFromExtension($scope, $identifier, $vendor, $version, $langDir);
+ };
+
+ // 모듈
+ HookManager::addAction('core.modules.after_install', function ($identifier, $info = null) use ($syncFor) {
+ $syncFor('module', (string) $identifier, is_array($info) ? $info : null, 'modules');
+ });
+ HookManager::addAction('core.modules.after_update', function ($identifier, $result = null, $info = null) use ($syncFor) {
+ $syncFor('module', (string) $identifier, is_array($info) ? $info : null, 'modules');
+ });
+ HookManager::addAction('core.modules.after_uninstall', function ($identifier) use ($registrar) {
+ $registrar->cleanupForExtension('module', (string) $identifier);
+ });
+
+ // 플러그인
+ HookManager::addAction('core.plugins.after_install', function ($identifier, $info = null) use ($syncFor) {
+ $syncFor('plugin', (string) $identifier, is_array($info) ? $info : null, 'plugins');
+ });
+ HookManager::addAction('core.plugins.after_update', function ($identifier, $result = null, $info = null) use ($syncFor) {
+ $syncFor('plugin', (string) $identifier, is_array($info) ? $info : null, 'plugins');
+ });
+ HookManager::addAction('core.plugins.after_uninstall', function ($identifier) use ($registrar) {
+ $registrar->cleanupForExtension('plugin', (string) $identifier);
+ });
+
+ // 템플릿
+ HookManager::addAction('core.templates.after_install', function ($identifier, $info = null) use ($syncFor) {
+ $syncFor('template', (string) $identifier, is_array($info) ? $info : null, 'templates');
+ });
+ HookManager::addAction('core.templates.after_update', function ($templateOrId, $data = null) use ($syncFor) {
+ $identifier = is_object($templateOrId) ? ($templateOrId->identifier ?? '') : (string) $templateOrId;
+ if ($identifier !== '') {
+ $syncFor('template', $identifier, is_array($data) ? $data : null, 'templates');
+ }
+ });
+ HookManager::addAction('core.templates.after_uninstall', function ($identifier) use ($registrar) {
+ $registrar->cleanupForExtension('template', (string) $identifier);
+ });
+
+ // PO #6: 호스트 확장 비활성화 시 종속 언어팩 cascade 비활성화
+ HookManager::addAction('core.modules.after_deactivate', function ($identifier) use ($registrar) {
+ $registrar->deactivateForExtension('module', (string) $identifier);
+ });
+ HookManager::addAction('core.plugins.after_deactivate', function ($identifier) use ($registrar) {
+ $registrar->deactivateForExtension('plugin', (string) $identifier);
+ });
+ HookManager::addAction('core.templates.after_deactivate', function ($identifier) use ($registrar) {
+ $registrar->deactivateForExtension('template', (string) $identifier);
+ });
+ }
+
+ /**
+ * 확장 manifest 또는 identifier 로부터 vendor 를 추출합니다.
+ *
+ * @param string $identifier 확장 식별자
+ * @param array|null $info manifest 정보
+ * @return string vendor 문자열
+ */
+ private function resolveVendor(string $identifier, ?array $info): string
+ {
+ if (is_array($info) && ! empty($info['vendor'])) {
+ return (string) $info['vendor'];
+ }
+
+ $segments = explode('-', $identifier);
+
+ return $segments[0] ?? 'unknown';
+ }
+
+ /**
+ * Laravel 의 Translator 바인딩을 LanguagePackTranslator 로 교체합니다.
+ *
+ * Illuminate\Translation\TranslationServiceProvider 가 'translator' 와
+ * 'translation.loader' 를 등록하는 시점 이후에 본 메서드가 호출되어야 하므로,
+ * 부트스트랩 순서상 본 ServiceProvider 는 TranslationServiceProvider 다음에 등록됩니다.
+ *
+ * @return void
+ */
+ private function registerTranslatorOverride(): void
+ {
+ $this->app->extend('translator', function ($translator, Application $app) {
+ $loader = $app->make('translation.loader');
+ $locale = $app->getLocale();
+
+ $decorated = new LanguagePackTranslator($loader, $locale);
+ $decorated->setFallback($app['config']->get('app.fallback_locale'));
+
+ return $decorated;
+ });
+ }
+
+ /**
+ * 활성 언어팩 1건을 Translator 에 등록합니다.
+ *
+ * @param LanguagePack $pack 활성 언어팩
+ * @return void
+ */
+ private function registerActivePack(LanguagePack $pack): void
+ {
+ $directory = $pack->resolveDirectory().DIRECTORY_SEPARATOR.'backend';
+ if (! File::isDirectory($directory)) {
+ return;
+ }
+
+ if ($pack->scope === LanguagePackScope::Core->value) {
+ $translator = $this->app->make('translator');
+ if ($translator instanceof LanguagePackTranslator) {
+ $localeDir = $directory.DIRECTORY_SEPARATOR.$pack->locale;
+ if (File::isDirectory($localeDir)) {
+ $translator->addCoreFallbackPath($pack->locale, $localeDir);
+ } else {
+ // backend 직속에 PHP 배열을 두는 평탄형 구조도 지원
+ $translator->addCoreFallbackPath($pack->locale, $directory);
+ }
+ }
+
+ return;
+ }
+
+ // module/plugin/template 은 네임스페이스 기반 등록.
+ //
+ // Laravel FileLoader::addNamespace 는 단일 hint 만 보유하며 덮어쓰는 구조이므로,
+ // loadTranslationsFrom() 으로 직접 등록하면 모듈 자체 src/lang 의 ko/en 등록을
+ // 덮어써 ko 가 raw key 로 떨어지는 회귀가 발생한다. 따라서 LanguagePackTranslator
+ // 의 namespace fallback 메커니즘에 등록해 표준 hint 를 유지한 채 ja 등 추가
+ // locale 만 보완한다.
+ if (! empty($pack->target_identifier)) {
+ $translator = $this->app->make('translator');
+ if ($translator instanceof LanguagePackTranslator) {
+ $localeDir = $directory.DIRECTORY_SEPARATOR.$pack->locale;
+ $fallbackDir = File::isDirectory($localeDir) ? $localeDir : $directory;
+ $translator->addNamespaceFallbackPath(
+ namespace: (string) $pack->target_identifier,
+ locale: $pack->locale,
+ path: $fallbackDir,
+ );
+ }
+ }
+ }
+
+ /**
+ * config('app.supported_locales') / locale_names / translatable_locales 를 갱신합니다.
+ *
+ * @param LanguagePackRegistry $registry 레지스트리
+ * @return void
+ */
+ private function refreshSupportedLocales(LanguagePackRegistry $registry): void
+ {
+ $activeLocales = $registry->getActiveCoreLocales();
+
+ // translatable_locales 도 함께 갱신 — 활성 코어 언어팩의 locale 이 데이터 입력
+ // 화이트리스트(LocaleRequiredTranslatable / TranslatableField Rule)에 포함되지 않으면
+ // 모듈/플러그인의 다국어 폼이 ja 등을 거부하는 회귀가 발생한다.
+ config([
+ 'app.supported_locales' => $activeLocales,
+ 'app.locale_names' => $registry->getLocaleNames(),
+ 'app.translatable_locales' => $activeLocales,
+ ]);
+ }
+
+ /**
+ * Registry 사용 가능 여부를 확인합니다.
+ *
+ * 설치 완료 플래그(config('app.installer_completed'))가 true 인 환경에서는 hasTable 호출을 생략하여
+ * 부팅 비용을 줄입니다. 마이그레이션 명령 실행 중이거나 .env 가 부재하면 안전하게 스킵합니다.
+ *
+ * @return bool 준비 여부
+ */
+ private function isRegistryReady(): bool
+ {
+ if (! file_exists(base_path('.env'))) {
+ return false;
+ }
+
+ if ($this->app->runningInConsole()
+ && in_array($_SERVER['argv'][1] ?? null, ['migrate', 'migrate:fresh', 'migrate:rollback', 'migrate:reset', 'db:wipe'], true)) {
+ return false;
+ }
+
+ if (config('app.installer_completed') === true) {
+ return true;
+ }
+
+ try {
+ return Schema::hasTable('language_packs');
+ } catch (QueryException $e) {
+ return false;
+ } catch (Throwable $e) {
+ return false;
+ }
+ }
+
+ /**
+ * 의존하는 다른 ServiceProvider 가 먼저 등록되어야 함을 명시합니다.
+ *
+ * @return array
+ */
+ public function provides(): array
+ {
+ return [
+ LanguagePackRepositoryInterface::class,
+ LanguagePackRegistry::class,
+ ];
+ }
+
+ /**
+ * Laravel 기본 TranslationServiceProvider 클래스 참조 (참고용).
+ *
+ * @return string
+ */
+ public static function dependsOn(): string
+ {
+ return TranslationServiceProvider::class;
+ }
+}
diff --git a/app/Repositories/ActivityLogRepository.php b/app/Repositories/ActivityLogRepository.php
index b18d8c3b..513573b7 100644
--- a/app/Repositories/ActivityLogRepository.php
+++ b/app/Repositories/ActivityLogRepository.php
@@ -237,4 +237,15 @@ class ActivityLogRepository implements ActivityLogRepositoryInterface
->limit($limit)
->get();
}
+
+ /**
+ * 사용자 삭제 시 해당 사용자의 모든 activity_logs.user_id 컬럼을 NULL 로 익명화합니다.
+ *
+ * @param int $userId 익명화 대상 사용자 ID
+ * @return int 익명화된 row 수
+ */
+ public function anonymizeUserId(int $userId): int
+ {
+ return ActivityLog::where('user_id', $userId)->update(['user_id' => null]);
+ }
}
diff --git a/app/Repositories/IdentityMessageDefinitionRepository.php b/app/Repositories/IdentityMessageDefinitionRepository.php
new file mode 100644
index 00000000..da177b78
--- /dev/null
+++ b/app/Repositories/IdentityMessageDefinitionRepository.php
@@ -0,0 +1,189 @@
+byScope($scopeType, $scopeValue)
+ ->first();
+ }
+
+ /**
+ * 활성 상태인 (provider, scope_type, scope_value) 메시지 정의 조회.
+ *
+ * @param string $providerId
+ * @param string $scopeType
+ * @param string|null $scopeValue
+ * @return IdentityMessageDefinition|null
+ */
+ public function getActiveByScope(string $providerId, string $scopeType, ?string $scopeValue = null): ?IdentityMessageDefinition
+ {
+ return IdentityMessageDefinition::active()
+ ->byProvider($providerId)
+ ->byScope($scopeType, $scopeValue)
+ ->first();
+ }
+
+ /**
+ * 모든 활성 메시지 정의 조회.
+ *
+ * @return Collection
+ */
+ public function getAllActive(): Collection
+ {
+ return IdentityMessageDefinition::active()->get();
+ }
+
+ /**
+ * 활성 메시지 정의의 로케일별 라벨 맵.
+ *
+ * 키: "{provider_id}|{scope_type}|{scope_value}", 값: 다국어 라벨
+ *
+ * @param string|null $locale
+ * @return array
+ */
+ public function getLabelMap(?string $locale = null): array
+ {
+ $locale = $locale ?? app()->getLocale();
+
+ return IdentityMessageDefinition::active()
+ ->get(['id', 'provider_id', 'scope_type', 'scope_value', 'name'])
+ ->mapWithKeys(fn (IdentityMessageDefinition $def) => [
+ "{$def->provider_id}|{$def->scope_type->value}|{$def->scope_value}" => $def->getLocalizedName($locale),
+ ])
+ ->all();
+ }
+
+ /**
+ * 전체 메시지 정의 조회.
+ *
+ * @return Collection
+ */
+ public function getAll(): Collection
+ {
+ return IdentityMessageDefinition::all();
+ }
+
+ /**
+ * 특정 확장의 메시지 정의 목록 조회.
+ *
+ * @param string $extensionType
+ * @param string $extensionIdentifier
+ * @return Collection
+ */
+ public function getByExtension(string $extensionType, string $extensionIdentifier): Collection
+ {
+ return IdentityMessageDefinition::byExtension($extensionType, $extensionIdentifier)->get();
+ }
+
+ /**
+ * 메시지 정의 신규 생성.
+ *
+ * @param array $data
+ * @return IdentityMessageDefinition
+ */
+ public function store(array $data): IdentityMessageDefinition
+ {
+ return IdentityMessageDefinition::create($data)->fresh();
+ }
+
+ /**
+ * 메시지 정의 수정.
+ *
+ * @param IdentityMessageDefinition $definition
+ * @param array $data
+ * @return IdentityMessageDefinition
+ */
+ public function update(IdentityMessageDefinition $definition, array $data): IdentityMessageDefinition
+ {
+ $definition->update($data);
+
+ return $definition->fresh();
+ }
+
+ /**
+ * 페이지네이션 목록 조회.
+ *
+ * @param array $filters
+ * @param int $perPage
+ * @return LengthAwarePaginator
+ */
+ public function getPaginated(array $filters = [], int $perPage = 20): LengthAwarePaginator
+ {
+ $query = IdentityMessageDefinition::with('templates');
+
+ if (! empty($filters['provider_id'])) {
+ $query->where('provider_id', $filters['provider_id']);
+ }
+
+ if (! empty($filters['scope_type'])) {
+ $query->where('scope_type', $filters['scope_type']);
+ }
+
+ if (! empty($filters['scope_value'])) {
+ $query->where('scope_value', $filters['scope_value']);
+ }
+
+ if (! empty($filters['extension_type'])) {
+ $query->where('extension_type', $filters['extension_type']);
+ }
+
+ if (! empty($filters['extension_identifier'])) {
+ $query->where('extension_identifier', $filters['extension_identifier']);
+ }
+
+ if (isset($filters['is_active'])) {
+ $query->where('is_active', $filters['is_active']);
+ }
+
+ if (! empty($filters['channel'])) {
+ $query->whereJsonContains('channels', $filters['channel']);
+ }
+
+ if (! empty($filters['search'])) {
+ $search = $filters['search'];
+ $locales = config('app.supported_locales', ['ko', 'en']);
+
+ $query->where(function ($q) use ($search, $locales) {
+ $q->where('provider_id', 'like', "%{$search}%")
+ ->orWhere('scope_value', 'like', "%{$search}%");
+ foreach ($locales as $locale) {
+ $q->orWhere("name->{$locale}", 'like', "%{$search}%");
+ }
+ });
+ }
+
+ $sortBy = $filters['sort_by'] ?? 'id';
+ $sortOrder = $filters['sort_order'] ?? 'asc';
+ $query->orderBy($sortBy, $sortOrder);
+
+ return $query->paginate($perPage);
+ }
+}
diff --git a/app/Repositories/IdentityMessageTemplateRepository.php b/app/Repositories/IdentityMessageTemplateRepository.php
new file mode 100644
index 00000000..143bddd1
--- /dev/null
+++ b/app/Repositories/IdentityMessageTemplateRepository.php
@@ -0,0 +1,98 @@
+byChannel($channel)
+ ->first();
+ }
+
+ /**
+ * 활성 (정의 ID, 채널) 템플릿 조회.
+ *
+ * @param int $definitionId
+ * @param string $channel
+ * @return IdentityMessageTemplate|null
+ */
+ public function getActiveByDefinitionAndChannel(int $definitionId, string $channel): ?IdentityMessageTemplate
+ {
+ return IdentityMessageTemplate::active()
+ ->where('definition_id', $definitionId)
+ ->byChannel($channel)
+ ->first();
+ }
+
+ /**
+ * 특정 정의의 전체 템플릿 조회.
+ *
+ * @param int $definitionId
+ * @return Collection
+ */
+ public function getByDefinitionId(int $definitionId): Collection
+ {
+ return IdentityMessageTemplate::where('definition_id', $definitionId)->get();
+ }
+
+ /**
+ * 템플릿 수정.
+ *
+ * @param IdentityMessageTemplate $template
+ * @param array $data
+ * @return IdentityMessageTemplate
+ */
+ public function update(IdentityMessageTemplate $template, array $data): IdentityMessageTemplate
+ {
+ $template->update($data);
+
+ return $template->fresh();
+ }
+
+ /**
+ * 템플릿 생성 또는 수정 (idempotent upsert).
+ *
+ * @param array $attributes
+ * @param array $values
+ * @return IdentityMessageTemplate
+ */
+ public function updateOrCreate(array $attributes, array $values): IdentityMessageTemplate
+ {
+ return IdentityMessageTemplate::updateOrCreate($attributes, $values);
+ }
+
+ /**
+ * 템플릿 신규 생성.
+ *
+ * @param array $data
+ * @return IdentityMessageTemplate
+ */
+ public function create(array $data): IdentityMessageTemplate
+ {
+ return IdentityMessageTemplate::create($data);
+ }
+}
diff --git a/app/Repositories/IdentityPolicyRepository.php b/app/Repositories/IdentityPolicyRepository.php
new file mode 100644
index 00000000..4c190ca9
--- /dev/null
+++ b/app/Repositories/IdentityPolicyRepository.php
@@ -0,0 +1,283 @@
+where('key', $key)->first();
+ }
+
+ /**
+ * ID로 정책을 조회합니다.
+ *
+ * @param int $id 정책 ID
+ * @return IdentityPolicy|null 조회된 정책 또는 null
+ */
+ public function findById(int $id): ?IdentityPolicy
+ {
+ return IdentityPolicy::find($id);
+ }
+
+ /**
+ * scope/target 기준으로 활성화된 정책 컬렉션을 우선순위 내림차순으로 반환합니다.
+ *
+ * @param string $scope 정책 scope
+ * @param string $target 정책 target
+ * @return Collection 정책 컬렉션
+ */
+ public function resolveByScopeTarget(string $scope, string $target): Collection
+ {
+ return IdentityPolicy::query()
+ ->where('scope', $scope)
+ ->where('target', $target)
+ ->where('enabled', true)
+ ->orderByDesc('priority')
+ ->get();
+ }
+
+ /**
+ * 정책 키로 upsert 합니다 (존재 시 업데이트, 미존재 시 생성).
+ *
+ * @param array $attributes 정책 속성 (key 포함)
+ * @return IdentityPolicy upsert 된 정책
+ */
+ public function upsertByKey(array $attributes): IdentityPolicy
+ {
+ $key = (string) ($attributes['key'] ?? '');
+ $model = IdentityPolicy::query()->where('key', $key)->first();
+
+ if ($model) {
+ $model->fill($attributes);
+ $model->save();
+
+ return $model;
+ }
+
+ return IdentityPolicy::create($attributes);
+ }
+
+ /**
+ * 정책 키로 정책을 업데이트합니다.
+ *
+ * @param string $key 정책 키
+ * @param array $attributes 업데이트할 속성
+ * @param array $overridesFields user_overrides 에 추가할 필드 목록
+ * @return bool 성공 여부
+ */
+ public function updateByKey(string $key, array $attributes, array $overridesFields = []): bool
+ {
+ $model = $this->findByKey($key);
+ if (! $model) {
+ return false;
+ }
+
+ $model->fill($attributes);
+
+ if (! empty($overridesFields)) {
+ $current = $model->user_overrides ?? [];
+ $merged = array_values(array_unique(array_merge($current, $overridesFields)));
+ $model->user_overrides = $merged;
+ }
+
+ return $model->save();
+ }
+
+ /**
+ * admin source 정책을 키 기준으로 삭제합니다.
+ *
+ * @param string $key 정책 키
+ * @return bool 성공 여부
+ */
+ public function deleteByKey(string $key): bool
+ {
+ $model = IdentityPolicy::query()
+ ->where('key', $key)
+ ->where('source_type', 'admin')
+ ->first();
+
+ return $model ? (bool) $model->delete() : false;
+ }
+
+ /**
+ * 특정 소스의 정책 개수를 반환합니다.
+ *
+ * @param string $sourceType 소스 타입
+ * @param string $sourceIdentifier 소스 식별자
+ * @return int 정책 개수
+ */
+ public function countBySource(string $sourceType, string $sourceIdentifier): int
+ {
+ return IdentityPolicy::query()
+ ->where('source_type', $sourceType)
+ ->where('source_identifier', $sourceIdentifier)
+ ->count();
+ }
+
+ /**
+ * 현재 키 목록에 없는 stale 정책을 일괄 삭제합니다.
+ *
+ * @param string $sourceType 소스 타입
+ * @param string $sourceIdentifier 소스 식별자
+ * @param array $currentKeys 유지할 키 목록
+ * @return int 삭제된 행 수
+ */
+ public function cleanupStale(string $sourceType, string $sourceIdentifier, array $currentKeys): int
+ {
+ $query = IdentityPolicy::query()
+ ->where('source_type', $sourceType)
+ ->where('source_identifier', $sourceIdentifier);
+
+ if (! empty($currentKeys)) {
+ $query->whereNotIn('key', $currentKeys);
+ }
+
+ return (int) $query->delete();
+ }
+
+ /**
+ * 필터 기반 정책 페이지네이션 결과를 반환합니다.
+ *
+ * @param array $filters 검색 필터
+ * @param int $perPage 페이지당 항목 수
+ * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 페이지네이터
+ */
+ public function search(array $filters, int $perPage = 20)
+ {
+ $query = IdentityPolicy::query();
+
+ foreach (['scope', 'purpose', 'source_type', 'source_identifier', 'applies_to', 'fail_mode'] as $exact) {
+ if (! empty($filters[$exact])) {
+ $query->where($exact, $filters[$exact]);
+ }
+ }
+
+ if (isset($filters['enabled']) && $filters['enabled'] !== '') {
+ $query->where('enabled', (bool) $filters['enabled']);
+ }
+
+ if (! empty($filters['search'])) {
+ $term = '%'.$filters['search'].'%';
+ $query->where(function ($q) use ($term) {
+ $q->where('key', 'like', $term)
+ ->orWhere('target', 'like', $term);
+ });
+ }
+
+ return $query->orderByDesc('created_at')->orderByDesc('id')->paginate($perPage);
+ }
+
+ /**
+ * 활성화된 모든 정책을 반환합니다.
+ *
+ * @return Collection 활성 정책 컬렉션
+ */
+ public function allEnabled(): Collection
+ {
+ return IdentityPolicy::query()->where('enabled', true)->get();
+ }
+
+ /**
+ * route scope 정책의 라우트명 인덱스를 캐시 기반으로 반환합니다.
+ *
+ * @return array 라우트명 => 정책 컬렉션 매핑
+ */
+ public function getRouteScopeIndex(): array
+ {
+ $ttl = (int) g7_core_settings('cache.identity_policy_ttl', 3600);
+
+ return $this->cache->remember(
+ IdentityPolicy::ROUTE_SCOPE_CACHE_KEY,
+ function (): array {
+ $policies = IdentityPolicy::query()
+ ->where('scope', 'route')
+ ->where('enabled', true)
+ ->orderByDesc('priority')
+ ->get();
+
+ $index = [];
+ foreach ($policies as $policy) {
+ foreach ($this->expandTargetBraces((string) $policy->target) as $routeName) {
+ if ($routeName === '') {
+ continue;
+ }
+ $bucket = $index[$routeName] ?? new Collection;
+ $bucket->push($policy);
+ $index[$routeName] = $bucket;
+ }
+ }
+
+ return $index;
+ },
+ $ttl,
+ [IdentityPolicy::ROUTE_SCOPE_CACHE_TAG],
+ );
+ }
+
+ /**
+ * brace expansion — 'api.admin.{modules,plugins}.uninstall' → ['api.admin.modules.uninstall', 'api.admin.plugins.uninstall'].
+ * 단일 그룹만 지원 (중첩/다중 그룹은 정책 키를 분리해서 정의하는 편이 명확).
+ *
+ * @param string $target
+ * @return list
+ */
+ protected function expandTargetBraces(string $target): array
+ {
+ if (! preg_match('/\{([^{}]+)\}/', $target, $matches)) {
+ return [$target];
+ }
+ $options = array_map('trim', explode(',', $matches[1]));
+
+ return array_map(
+ static fn (string $opt) => preg_replace('/\{[^{}]+\}/', $opt, $target, 1),
+ $options,
+ );
+ }
+
+ /**
+ * scope='hook' 활성 정책의 target 목록(중복 제거)을 반환합니다.
+ *
+ * 마이그레이션 전이거나 DB 미연결 환경에서는 빈 배열을 반환해 부팅을 보호합니다.
+ *
+ * @return list 동적 hook target 목록
+ */
+ public function listHookTargets(): array
+ {
+ try {
+ if (! \Illuminate\Support\Facades\Schema::hasTable('identity_policies')) {
+ return [];
+ }
+
+ return IdentityPolicy::query()
+ ->where('scope', 'hook')
+ ->distinct()
+ ->pluck('target')
+ ->filter(fn ($t) => is_string($t) && $t !== '')
+ ->values()
+ ->all();
+ } catch (\Throwable) {
+ return [];
+ }
+ }
+}
diff --git a/app/Repositories/IdentityVerificationLogRepository.php b/app/Repositories/IdentityVerificationLogRepository.php
new file mode 100644
index 00000000..325222c4
--- /dev/null
+++ b/app/Repositories/IdentityVerificationLogRepository.php
@@ -0,0 +1,223 @@
+update($attributes) > 0;
+ }
+
+ /**
+ * 최근에 검증 완료된 로그를 조회합니다.
+ *
+ * @param string $purpose 본인인증 목적
+ * @param int|null $userId 사용자 ID (null 가능)
+ * @param string|null $targetHash 대상 해시 (null 가능)
+ * @param int $withinMinutes 조회 범위 (분)
+ * @return IdentityVerificationLog|null 가장 최근 검증 로그 또는 null
+ */
+ public function findRecentVerified(
+ string $purpose,
+ ?int $userId,
+ ?string $targetHash,
+ int $withinMinutes,
+ ): ?IdentityVerificationLog {
+ $query = IdentityVerificationLog::query()
+ ->where('purpose', $purpose)
+ ->where('status', IdentityVerificationStatus::Verified->value)
+ ->where('verified_at', '>=', Carbon::now()->subMinutes(max(0, $withinMinutes)));
+
+ if ($userId !== null) {
+ $query->where('user_id', $userId);
+ } elseif ($targetHash !== null) {
+ $query->where('target_hash', $targetHash);
+ } else {
+ return null;
+ }
+
+ return $query->orderByDesc('verified_at')->first();
+ }
+
+ /**
+ * 미소비된 검증 토큰으로 로그를 조회합니다.
+ *
+ * @param string $token 검증 토큰
+ * @param string $purpose 본인인증 목적
+ * @return IdentityVerificationLog|null 조회된 로그 또는 null
+ */
+ public function findVerifiedForToken(string $token, string $purpose): ?IdentityVerificationLog
+ {
+ return IdentityVerificationLog::query()
+ ->where('verification_token', $token)
+ ->where('purpose', $purpose)
+ ->where('status', IdentityVerificationStatus::Verified->value)
+ ->whereNull('consumed_at')
+ ->first();
+ }
+
+ /**
+ * 만료 시각이 지난 챌린지를 일괄 만료 처리합니다.
+ *
+ * @return int 만료 처리된 행 수
+ */
+ public function expirePastDue(): int
+ {
+ return IdentityVerificationLog::query()
+ ->whereIn('status', [
+ IdentityVerificationStatus::Requested->value,
+ IdentityVerificationStatus::Sent->value,
+ ])
+ ->where('expires_at', '<', Carbon::now())
+ ->update(['status' => IdentityVerificationStatus::Expired->value]);
+ }
+
+ /**
+ * 지정된 일수 이전의 로그를 일괄 삭제합니다.
+ *
+ * @param int $days 보존 일수
+ * @return int 삭제된 행 수
+ */
+ public function purgeOlderThan(int $days): int
+ {
+ return IdentityVerificationLog::query()
+ ->where('created_at', '<', Carbon::now()->subDays(max(1, $days)))
+ ->delete();
+ }
+
+ /**
+ * 필터 기반 검증 로그 페이지네이션 결과를 반환합니다.
+ *
+ * @param array $filters 검색 필터
+ * @param int $perPage 페이지당 항목 수
+ * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator 페이지네이터
+ */
+ public function search(array $filters, int $perPage = 20)
+ {
+ $query = IdentityVerificationLog::query();
+
+ // 단일값 + 다중값 — 다중값(*s) 우선, 없으면 단일값 fallback (외부 링크 호환)
+ $columnMap = [
+ 'provider_id' => 'provider_ids',
+ 'purpose' => 'purposes',
+ 'status' => 'statuses',
+ 'channel' => 'channels',
+ 'origin_type' => 'origin_types',
+ ];
+ foreach ($columnMap as $singleKey => $multiKey) {
+ $multi = $filters[$multiKey] ?? null;
+ if (is_array($multi) && $multi !== []) {
+ $query->whereIn($singleKey, $multi);
+ } elseif (! empty($filters[$singleKey])) {
+ $query->where($singleKey, $filters[$singleKey]);
+ }
+ }
+
+ // source_type / source_identifier — identity_policies 의 source 컨텍스트로 이력 필터링.
+ // 직접 컬럼이 아니라 origin_policy_key ∈ (해당 source 의 정책 키 목록) 으로 매칭한다.
+ if (! empty($filters['source_type'])) {
+ $query->whereIn('origin_policy_key', function ($q) use ($filters) {
+ $q->select('key')
+ ->from('identity_policies')
+ ->where('source_type', $filters['source_type']);
+ if (! empty($filters['source_identifier'])) {
+ $q->where('source_identifier', $filters['source_identifier']);
+ }
+ });
+ }
+
+ if (! empty($filters['user_id'])) {
+ $query->where('user_id', (int) $filters['user_id']);
+ }
+
+ if (! empty($filters['target_hash'])) {
+ $query->where('target_hash', $filters['target_hash']);
+ }
+
+ // search + search_type: auto/user_id/target_hash/ip_address/policy_key 통합 검색.
+ // auto: 입력이 모두 숫자이면 user_id, 그 외(64자 hex 등)는 target_hash 로 라우팅.
+ if (! empty($filters['search'])) {
+ $term = (string) $filters['search'];
+ $type = $filters['search_type'] ?? 'auto';
+ if ($type === 'user_id' || ($type === 'auto' && ctype_digit($term))) {
+ $query->where('user_id', (int) $term);
+ } elseif ($type === 'ip_address') {
+ $query->where('ip_address', $term);
+ } elseif ($type === 'policy_key') {
+ $query->where('origin_policy_key', 'like', $term.'%');
+ } else {
+ $query->where('target_hash', $term);
+ }
+ }
+
+ if (! empty($filters['date_from'])) {
+ $query->where('created_at', '>=', $filters['date_from']);
+ }
+
+ if (! empty($filters['date_to'])) {
+ $query->where('created_at', '<=', $filters['date_to']);
+ }
+
+ $sortBy = in_array($filters['sort_by'] ?? null, ['created_at', 'attempts'], true)
+ ? $filters['sort_by']
+ : 'created_at';
+ $sortOrder = ($filters['sort_order'] ?? 'desc') === 'asc' ? 'asc' : 'desc';
+ $query->orderBy($sortBy, $sortOrder);
+
+ return $query->paginate($perPage);
+ }
+
+ /**
+ * 비회원 검증 로그에 사용자 ID 를 채워넣습니다.
+ *
+ * @param string $id 로그 ID
+ * @param int $userId 사용자 ID
+ * @return bool 성공 여부 (이미 user_id 가 있으면 false)
+ */
+ public function backfillUserId(string $id, int $userId): bool
+ {
+ return IdentityVerificationLog::whereKey($id)
+ ->whereNull('user_id')
+ ->update(['user_id' => $userId]) > 0;
+ }
+}
diff --git a/app/Repositories/LanguagePackRepository.php b/app/Repositories/LanguagePackRepository.php
new file mode 100644
index 00000000..afb0b152
--- /dev/null
+++ b/app/Repositories/LanguagePackRepository.php
@@ -0,0 +1,382 @@
+where('identifier', $identifier)->first();
+ }
+
+ /**
+ * ID 로 언어팩을 조회합니다.
+ *
+ * @param int $id 언어팩 ID
+ * @return LanguagePack|null 언어팩 또는 null
+ */
+ public function findById(int $id): ?LanguagePack
+ {
+ return LanguagePack::query()->find($id);
+ }
+
+ /**
+ * 모든 활성 언어팩을 조회합니다.
+ *
+ * @return Collection 활성 언어팩 컬렉션
+ */
+ public function getActivePacks(): Collection
+ {
+ return LanguagePack::query()
+ ->where('status', LanguagePackStatus::Active->value)
+ ->get();
+ }
+
+ /**
+ * 특정 슬롯의 활성 언어팩을 조회합니다.
+ *
+ * @param string $scope 스코프
+ * @param string|null $targetIdentifier 대상 확장 식별자
+ * @param string $locale 로케일
+ * @param int|null $excludeId 결과에서 제외할 언어팩 id (재설치 시 자기 자신 제외용)
+ * @return LanguagePack|null 활성 언어팩 또는 null
+ */
+ public function findActiveForSlot(
+ string $scope,
+ ?string $targetIdentifier,
+ string $locale,
+ ?int $excludeId = null
+ ): ?LanguagePack {
+ $query = LanguagePack::query()
+ ->where('scope', $scope)
+ ->where('target_identifier', $targetIdentifier)
+ ->where('locale', $locale)
+ ->where('status', LanguagePackStatus::Active->value);
+
+ if ($excludeId !== null) {
+ $query->where('id', '!=', $excludeId);
+ }
+
+ return $query->first();
+ }
+
+ /**
+ * 특정 슬롯의 모든 후보 언어팩을 조회합니다 (벤더별).
+ *
+ * @param string $scope 스코프
+ * @param string|null $targetIdentifier 대상 확장 식별자
+ * @param string $locale 로케일
+ * @return Collection 후보 언어팩 컬렉션
+ */
+ public function getPacksForSlot(
+ string $scope,
+ ?string $targetIdentifier,
+ string $locale
+ ): Collection {
+ return LanguagePack::query()
+ ->where('scope', $scope)
+ ->where('target_identifier', $targetIdentifier)
+ ->where('locale', $locale)
+ ->orderByDesc('activated_at')
+ ->orderByDesc('installed_at')
+ ->get();
+ }
+
+ /**
+ * 활성 코어 언어팩이 있는 모든 로케일을 반환합니다.
+ *
+ * @return array 로케일 문자열 배열
+ */
+ public function getActiveCoreLocales(): array
+ {
+ return LanguagePack::query()
+ ->where('scope', LanguagePackScope::Core->value)
+ ->where('status', LanguagePackStatus::Active->value)
+ ->pluck('locale')
+ ->unique()
+ ->values()
+ ->all();
+ }
+
+ /**
+ * 페이지네이션 + 필터링된 언어팩 목록을 조회합니다.
+ *
+ * @param array $filters 필터 (scope, target_identifier, locale, status, vendor)
+ * @param int $perPage 페이지당 건수
+ * @return LengthAwarePaginator 페이지네이션 결과
+ */
+ public function paginate(array $filters = [], int $perPage = 20): LengthAwarePaginator
+ {
+ return $this->buildFilteredQuery($filters)->paginate($perPage);
+ }
+
+ /**
+ * 필터링된 언어팩 컬렉션을 페이지네이션 없이 조회합니다.
+ *
+ * @param array $filters 필터 (scope, target_identifier, locale, status, vendor, search)
+ * @return Collection 필터링된 언어팩 컬렉션
+ */
+ public function getFilteredCollection(array $filters = []): Collection
+ {
+ return $this->buildFilteredQuery($filters)->get();
+ }
+
+ /**
+ * 공통 필터 쿼리 빌더 — `paginate()` 와 `getFilteredCollection()` 가 동일 로직을 공유합니다.
+ *
+ * `status=uninstalled` 필터는 가상 상태이므로 DB 쿼리에서 결과를 0건으로 강제합니다
+ * (실제 미설치 번들은 Service 계층에서 합쳐집니다).
+ *
+ * @param array $filters 필터 조건
+ * @return \Illuminate\Database\Eloquent\Builder 정렬까지 적용된 쿼리 빌더
+ */
+ private function buildFilteredQuery(array $filters): \Illuminate\Database\Eloquent\Builder
+ {
+ $query = LanguagePack::query();
+
+ if (! empty($filters['scope'])) {
+ $query->where('scope', $filters['scope']);
+ }
+ if (array_key_exists('target_identifier', $filters)) {
+ $query->where('target_identifier', $filters['target_identifier']);
+ }
+ if (! empty($filters['locale'])) {
+ $query->where('locale', $filters['locale']);
+ }
+ if (! empty($filters['status'])) {
+ if ($filters['status'] === LanguagePackStatus::Uninstalled->value) {
+ $query->whereRaw('1 = 0');
+ } else {
+ $query->where('status', $filters['status']);
+ }
+ }
+ if (! empty($filters['vendor'])) {
+ $query->where('vendor', $filters['vendor']);
+ }
+ if (! empty($filters['search'])) {
+ $this->applyOrSearchAcrossFields(
+ $query,
+ (string) $filters['search'],
+ ['identifier', 'vendor', 'locale_native_name', 'locale_name']
+ );
+ }
+ if (! empty($filters['exclude_protected'])) {
+ $query->where('is_protected', false);
+ }
+
+ // 정렬은 안정 키만 사용 (scope/target_identifier/locale).
+ // status 우선 정렬은 설치/활성화 직후 항목이 페이지 위치를 이탈해
+ // 사용자가 다시 찾아야 하는 회귀를 만들어 제거함 (#263 의 active-first UX 폐기).
+ return $query
+ ->orderBy('scope')
+ ->orderBy('target_identifier')
+ ->orderBy('locale');
+ }
+
+ /**
+ * 언어팩을 생성합니다.
+ *
+ * @param array $data 생성 데이터
+ * @return LanguagePack 생성된 언어팩
+ */
+ public function create(array $data): LanguagePack
+ {
+ return LanguagePack::query()->create($data);
+ }
+
+ /**
+ * 언어팩을 갱신합니다.
+ *
+ * @param LanguagePack $pack 대상 언어팩
+ * @param array $data 갱신 데이터
+ * @return LanguagePack 갱신된 언어팩
+ */
+ public function update(LanguagePack $pack, array $data): LanguagePack
+ {
+ $pack->fill($data);
+ $pack->save();
+
+ return $pack->fresh() ?? $pack;
+ }
+
+ /**
+ * 언어팩을 삭제합니다.
+ *
+ * @param LanguagePack $pack 대상 언어팩
+ * @return bool 삭제 성공 여부
+ */
+ public function delete(LanguagePack $pack): bool
+ {
+ return (bool) $pack->delete();
+ }
+
+ /**
+ * 특정 확장(scope, target_identifier)에 연결된 언어팩 전체를 조회합니다.
+ *
+ * @param string $scope 스코프
+ * @param string $targetIdentifier 대상 확장 식별자
+ * @return Collection 언어팩 컬렉션
+ */
+ public function getPacksForTarget(string $scope, string $targetIdentifier): Collection
+ {
+ return LanguagePack::query()
+ ->where('scope', $scope)
+ ->where('target_identifier', $targetIdentifier)
+ ->get();
+ }
+
+ /**
+ * 특정 로케일에 속하는 모든 언어팩을 조회합니다.
+ *
+ * @param string $locale 로케일
+ * @return Collection 언어팩 컬렉션
+ */
+ public function getPacksForLocale(string $locale): Collection
+ {
+ return LanguagePack::query()->where('locale', $locale)->get();
+ }
+
+ /**
+ * 번들 manifest 로부터 가상 LanguagePack 인스턴스를 합성합니다 (DB 미저장).
+ *
+ * Model 인스턴스 생성 책임을 Repository 가 보유 — Service 는 본 메서드를 호출만 합니다.
+ * exists=false 로 표시되어 영속성 동작에서 제외되며, `bundled_identifier` 가상 속성을
+ * 함께 채워 Resource 가 행 액션에 노출할 수 있게 합니다.
+ *
+ * @param array $manifest 번들 manifest 데이터
+ * @param string $bundledIdentifier `lang-packs/_bundled/{이 값}` 디렉토리명
+ * @return LanguagePack 가상 LanguagePack 인스턴스 (DB 미저장)
+ */
+ public function buildVirtualFromManifest(array $manifest, string $bundledIdentifier): LanguagePack
+ {
+ $pack = new LanguagePack;
+ $pack->id = null;
+ $pack->identifier = (string) $manifest['identifier'];
+ $pack->vendor = (string) ($manifest['vendor'] ?? '');
+ $pack->scope = (string) ($manifest['scope'] ?? 'core');
+ $pack->target_identifier = $manifest['target_identifier'] ?? null;
+ $pack->locale = (string) ($manifest['locale'] ?? '');
+ $pack->locale_name = (string) ($manifest['locale_name'] ?? ($manifest['locale'] ?? ''));
+ $pack->locale_native_name = (string) ($manifest['locale_native_name'] ?? ($manifest['locale'] ?? ''));
+ $pack->text_direction = (string) ($manifest['text_direction'] ?? 'ltr');
+ $pack->version = (string) ($manifest['version'] ?? '0.0.0');
+ $pack->license = $manifest['license'] ?? null;
+ $pack->description = is_array($manifest['description'] ?? null) ? $manifest['description'] : null;
+ $pack->status = \App\Enums\LanguagePackStatus::Uninstalled->value;
+ $pack->is_protected = false; // lang-packs/_bundled/ 패키지는 사용자가 install/uninstall 자유 (PO #3)
+ $pack->manifest = $manifest;
+ $pack->source_type = \App\Enums\LanguagePackSourceType::Bundled->value;
+ $pack->source_url = $bundledIdentifier;
+
+ $pack->setAttribute('bundled_identifier', $bundledIdentifier);
+ $pack->exists = false;
+
+ return $pack;
+ }
+
+ /**
+ * 코어/번들 확장의 lang/{ko,en}/ 디렉토리로부터 가상 보호 LanguagePack 인스턴스를 합성합니다.
+ *
+ * `built_in` 가상 행은 DB 행이 없는 상태로 항상 active+protected 표시되며, 사용자가
+ * install/uninstall/activate/deactivate 할 수 없습니다 (PO #1, #2).
+ *
+ * @param string $scope 스코프 (core/module/plugin/template)
+ * @param string|null $targetIdentifier 대상 확장 식별자 (core 일 때 null)
+ * @param string $locale 로케일 (예: 'ko', 'en')
+ * @param string $vendor 벤더 (확장 manifest.vendor 또는 'g7')
+ * @param string $version 버전
+ * @param string $langPathRelative lang 디렉토리 상대 경로
+ * @return LanguagePack 가상 LanguagePack 인스턴스
+ */
+ public function buildVirtualBuiltInPack(
+ string $scope,
+ ?string $targetIdentifier,
+ string $locale,
+ string $vendor,
+ string $version,
+ string $langPathRelative,
+ ): LanguagePack {
+ $identifier = $targetIdentifier !== null
+ ? sprintf('%s-%s-%s-%s', $vendor, $scope, $targetIdentifier, $locale)
+ : sprintf('%s-%s-%s', $vendor, $scope, $locale);
+
+ $nativeName = match ($locale) {
+ 'ko' => '한국어',
+ 'en' => 'English',
+ default => strtoupper($locale),
+ };
+
+ $pack = new LanguagePack;
+ $pack->id = null;
+ $pack->identifier = $identifier;
+ $pack->vendor = $vendor;
+ $pack->scope = $scope;
+ $pack->target_identifier = $targetIdentifier;
+ $pack->locale = $locale;
+ $pack->locale_name = strtoupper($locale);
+ $pack->locale_native_name = $nativeName;
+ $pack->text_direction = \App\Enums\TextDirection::Ltr->value;
+ $pack->version = $version;
+ $pack->status = \App\Enums\LanguagePackStatus::Active->value;
+ $pack->is_protected = true;
+ $pack->source_type = \App\Enums\LanguagePackSourceType::BuiltIn->value;
+ $pack->source_url = $langPathRelative;
+ $pack->manifest = [
+ 'identifier' => $identifier,
+ 'vendor' => $vendor,
+ 'scope' => $scope,
+ 'target_identifier' => $targetIdentifier,
+ 'locale' => $locale,
+ 'version' => $version,
+ 'built_in' => true,
+ ];
+
+ $pack->exists = false;
+
+ return $pack;
+ }
+
+ /**
+ * 호스트 확장(modules/plugins/templates)의 status + version 행을 조회합니다.
+ *
+ * @param string $scope 스코프 (module/plugin/template). 그 외는 null 반환.
+ * @param string $identifier 호스트 확장 식별자
+ * @return object|null `{status, version}` 객체 또는 null
+ */
+ public function findHostExtensionRow(string $scope, string $identifier): ?object
+ {
+ $tableMap = [
+ LanguagePackScope::Module->value => 'modules',
+ LanguagePackScope::Plugin->value => 'plugins',
+ LanguagePackScope::Template->value => 'templates',
+ ];
+ $table = $tableMap[$scope] ?? null;
+ if (! $table) {
+ return null;
+ }
+
+ return DB::table($table)
+ ->where('identifier', $identifier)
+ ->first(['status', 'version']);
+ }
+}
diff --git a/app/Repositories/LanguagePackTranslationRepository.php b/app/Repositories/LanguagePackTranslationRepository.php
new file mode 100644
index 00000000..6ba7b7ec
--- /dev/null
+++ b/app/Repositories/LanguagePackTranslationRepository.php
@@ -0,0 +1,467 @@
+> $seedBundle 엔티티별 seed 데이터
+ * @return array> 감사 로그 항목
+ */
+ public function applySeedFromPack(LanguagePack $pack, array $seedBundle): array
+ {
+ $audit = [];
+ $locale = $pack->locale;
+
+ if (! empty($seedBundle['permissions'])) {
+ $this->applyByIdentifier(Permission::class, $pack, $seedBundle['permissions'], $locale, ['name', 'description'], 'identifier', $audit);
+ }
+ if (! empty($seedBundle['roles'])) {
+ $this->applyByIdentifier(Role::class, $pack, $seedBundle['roles'], $locale, ['name', 'description'], 'identifier', $audit);
+ }
+ if (! empty($seedBundle['menus']) && $pack->scope !== LanguagePackScope::Plugin->value) {
+ $this->applyByIdentifier(Menu::class, $pack, $seedBundle['menus'], $locale, ['name'], 'slug', $audit);
+ }
+ if (! empty($seedBundle['notifications'])) {
+ $this->applyNotifications($pack, $seedBundle['notifications'], $locale, $audit);
+ }
+ if (! empty($seedBundle['identity_messages'])) {
+ $this->applyIdentityMessages($pack, $seedBundle['identity_messages'], $locale, $audit);
+ }
+ if (! empty($seedBundle['manifest'])) {
+ $this->applyManifest($pack, $seedBundle['manifest'], $locale, $audit);
+ }
+
+ return $audit;
+ }
+
+ /**
+ * 언어팩의 locale 키를 DB JSON 컬럼에서 제거합니다 (user_overrides 컬럼은 보존).
+ *
+ * @param LanguagePack $pack 비활성화된 언어팩
+ * @return array> 감사 로그 항목
+ */
+ public function stripLocaleFromPack(LanguagePack $pack): array
+ {
+ $audit = [];
+ $locale = $pack->locale;
+
+ $modelMaps = [
+ LanguagePackScope::Core->value => [
+ Permission::class => ['name', 'description'],
+ Role::class => ['name', 'description'],
+ Menu::class => ['name'],
+ NotificationDefinition::class => ['name', 'description'],
+ NotificationTemplate::class => ['subject', 'body'],
+ ],
+ LanguagePackScope::Module->value => [Module::class => ['name', 'description']],
+ LanguagePackScope::Plugin->value => [Plugin::class => ['name', 'description']],
+ LanguagePackScope::Template->value => [Template::class => ['name', 'description']],
+ ];
+
+ $models = $modelMaps[$pack->scope] ?? [];
+
+ foreach ($models as $modelClass => $columns) {
+ if (! class_exists($modelClass)) {
+ continue;
+ }
+
+ $query = $modelClass::query();
+ if (in_array($pack->scope, [
+ LanguagePackScope::Module->value,
+ LanguagePackScope::Plugin->value,
+ LanguagePackScope::Template->value,
+ ], true) && $pack->target_identifier) {
+ $query->where('identifier', $pack->target_identifier);
+ }
+
+ $query->get()->each(function (Model $row) use ($columns, $locale, &$audit) {
+ $this->stripLocaleColumns($row, $locale, $columns, $audit);
+ });
+ }
+
+ $this->stripIdentityMessages($pack, $locale, $audit);
+
+ return $audit;
+ }
+
+ /**
+ * 식별자 컬럼 (identifier/slug) 기반 단순 매칭 모델의 JSON 컬럼에 locale 키를 병합합니다.
+ *
+ * @param class-string $modelClass
+ * @param array> $seed
+ * @param array $columns
+ * @param array> $audit
+ */
+ protected function applyByIdentifier(
+ string $modelClass,
+ LanguagePack $pack,
+ array $seed,
+ string $locale,
+ array $columns,
+ string $matchColumn,
+ array &$audit,
+ ): void {
+ $query = $modelClass::query()->whereIn($matchColumn, array_keys($seed));
+ $this->scopeOwnership($query, $pack);
+
+ $query->get()->each(function (Model $row) use ($seed, $locale, $columns, $matchColumn, &$audit) {
+ $key = $row->{$matchColumn};
+ $entry = $seed[$key] ?? null;
+ if (! $entry) {
+ return;
+ }
+ $this->mergeLocaleColumns($row, $entry, $locale, $columns, $audit);
+ });
+ }
+
+ /**
+ * 알림 Definition × Template 의 다국어 컬럼을 병합합니다.
+ *
+ * @param array> $seed
+ * @param array> $audit
+ */
+ protected function applyNotifications(LanguagePack $pack, array $seed, string $locale, array &$audit): void
+ {
+ if (! in_array($pack->scope, [
+ LanguagePackScope::Core->value,
+ LanguagePackScope::Module->value,
+ LanguagePackScope::Plugin->value,
+ ], true)) {
+ return;
+ }
+
+ $defQuery = NotificationDefinition::query()->whereIn('type', array_keys($seed));
+ if ($pack->target_identifier) {
+ $extType = match ($pack->scope) {
+ LanguagePackScope::Module->value => ExtensionOwnerType::Module->value,
+ LanguagePackScope::Plugin->value => ExtensionOwnerType::Plugin->value,
+ default => null,
+ };
+ if ($extType) {
+ $defQuery->where('extension_type', $extType)
+ ->where('extension_identifier', $pack->target_identifier);
+ }
+ }
+
+ $defQuery->get()->each(function (NotificationDefinition $def) use ($seed, $locale, &$audit) {
+ $entry = $seed[$def->type] ?? null;
+ if (! $entry) {
+ return;
+ }
+
+ if (isset($entry['definition'])) {
+ $this->mergeLocaleColumns($def, $entry['definition'], $locale, ['name', 'description'], $audit);
+ }
+
+ $templates = $entry['templates'] ?? [];
+ if (empty($templates)) {
+ return;
+ }
+
+ NotificationTemplate::query()
+ ->where('definition_id', $def->id)
+ ->whereIn('channel', array_keys($templates))
+ ->get()
+ ->each(function (NotificationTemplate $tpl) use ($templates, $locale, &$audit) {
+ $tplSeed = $templates[$tpl->channel] ?? null;
+ if (! $tplSeed) {
+ return;
+ }
+ $this->mergeLocaleColumns($tpl, $tplSeed, $locale, ['subject', 'body'], $audit);
+ });
+ });
+ }
+
+ /**
+ * IDV 메시지 Definition × Template 의 다국어 컬럼을 병합합니다.
+ *
+ * @param array> $seed
+ * @param array> $audit
+ */
+ protected function applyIdentityMessages(LanguagePack $pack, array $seed, string $locale, array &$audit): void
+ {
+ if (! in_array($pack->scope, [
+ LanguagePackScope::Core->value,
+ LanguagePackScope::Module->value,
+ LanguagePackScope::Plugin->value,
+ ], true)) {
+ return;
+ }
+
+ $defQuery = IdentityMessageDefinition::query();
+ if ($pack->scope === LanguagePackScope::Core->value) {
+ $defQuery->where('extension_type', 'core')->where('extension_identifier', 'core');
+ } else {
+ $extType = $pack->scope === LanguagePackScope::Module->value ? 'module' : 'plugin';
+ $defQuery->where('extension_type', $extType)
+ ->where('extension_identifier', $pack->target_identifier);
+ }
+
+ $defQuery->get()->each(function (IdentityMessageDefinition $def) use ($seed, $locale, &$audit) {
+ $compositeKey = $this->identityMessageCompositeKey($def);
+ $entry = ($compositeKey && isset($seed[$compositeKey])) ? $seed[$compositeKey] : null;
+ if (! $entry) {
+ return;
+ }
+
+ if (isset($entry['definition'])) {
+ $this->mergeLocaleColumns($def, $entry['definition'], $locale, ['name', 'description'], $audit);
+ }
+
+ $templates = $entry['templates'] ?? [];
+ if (empty($templates)) {
+ return;
+ }
+
+ IdentityMessageTemplate::query()
+ ->where('definition_id', $def->id)
+ ->whereIn('channel', array_keys($templates))
+ ->get()
+ ->each(function (IdentityMessageTemplate $tpl) use ($templates, $locale, &$audit) {
+ $tplSeed = $templates[$tpl->channel] ?? null;
+ if (! $tplSeed) {
+ return;
+ }
+ $this->mergeLocaleColumns($tpl, $tplSeed, $locale, ['subject', 'body'], $audit);
+ });
+ });
+ }
+
+ /**
+ * 확장 manifest 의 name/description JSON 컬럼에 locale 키를 병합합니다.
+ *
+ * @param array $seed
+ * @param array> $audit
+ */
+ protected function applyManifest(LanguagePack $pack, array $seed, string $locale, array &$audit): void
+ {
+ if (! in_array($pack->scope, [
+ LanguagePackScope::Module->value,
+ LanguagePackScope::Plugin->value,
+ LanguagePackScope::Template->value,
+ ], true) || ! $pack->target_identifier) {
+ return;
+ }
+
+ $modelClass = match ($pack->scope) {
+ LanguagePackScope::Module->value => Module::class,
+ LanguagePackScope::Plugin->value => Plugin::class,
+ LanguagePackScope::Template->value => Template::class,
+ default => null,
+ };
+
+ if (! $modelClass || ! class_exists($modelClass)) {
+ return;
+ }
+
+ $row = $modelClass::query()->where('identifier', $pack->target_identifier)->first();
+ if (! $row) {
+ return;
+ }
+
+ $this->mergeLocaleColumns($row, $seed, $locale, ['name', 'description'], $audit);
+ }
+
+ /**
+ * 비활성화 시 IDV 메시지 Definition × Template 의 locale 키를 제거합니다.
+ *
+ * @param array> $audit
+ */
+ protected function stripIdentityMessages(LanguagePack $pack, string $locale, array &$audit): void
+ {
+ if (! in_array($pack->scope, [
+ LanguagePackScope::Core->value,
+ LanguagePackScope::Module->value,
+ LanguagePackScope::Plugin->value,
+ ], true)) {
+ return;
+ }
+
+ $defQuery = IdentityMessageDefinition::query();
+ if ($pack->scope === LanguagePackScope::Core->value) {
+ $defQuery->where('extension_type', 'core')->where('extension_identifier', 'core');
+ } else {
+ $extType = $pack->scope === LanguagePackScope::Module->value ? 'module' : 'plugin';
+ $defQuery->where('extension_type', $extType)
+ ->where('extension_identifier', $pack->target_identifier);
+ }
+
+ $defQuery->get()->each(function (IdentityMessageDefinition $def) use ($locale, &$audit) {
+ $this->stripLocaleColumns($def, $locale, ['name', 'description'], $audit);
+
+ IdentityMessageTemplate::query()
+ ->where('definition_id', $def->id)
+ ->get()
+ ->each(function (IdentityMessageTemplate $tpl) use ($locale, &$audit) {
+ $this->stripLocaleColumns($tpl, $locale, ['subject', 'body'], $audit);
+ });
+ });
+ }
+
+ /**
+ * 쿼리 빌더에 언어팩 scope 별 소유권 필터를 적용합니다.
+ */
+ protected function scopeOwnership(Builder $query, LanguagePack $pack): void
+ {
+ if ($pack->scope === LanguagePackScope::Core->value) {
+ $query->where(function ($q) {
+ $q->where('extension_type', ExtensionOwnerType::Core->value)
+ ->orWhereNull('extension_type');
+ });
+
+ return;
+ }
+
+ $type = match ($pack->scope) {
+ LanguagePackScope::Module->value => ExtensionOwnerType::Module->value,
+ LanguagePackScope::Plugin->value => ExtensionOwnerType::Plugin->value,
+ default => null,
+ };
+
+ if ($type === null || empty($pack->target_identifier)) {
+ $query->whereRaw('1 = 0');
+
+ return;
+ }
+
+ $query->where('extension_type', $type)
+ ->where('extension_identifier', $pack->target_identifier);
+ }
+
+ /**
+ * IdentityMessageDefinition 의 provider_id+scope_type+scope_value 를 seed 키 형식으로 합성합니다.
+ *
+ * LanguagePackSeedInjector::identityMessageCompositeKey 와 동일 규칙.
+ */
+ protected function identityMessageCompositeKey(IdentityMessageDefinition $def): ?string
+ {
+ $provider = $def->provider_id ?? null;
+ $scopeType = is_object($def->scope_type) ? $def->scope_type->value : $def->scope_type;
+ $scopeValue = $def->scope_value ?? '';
+ if (! $provider || ! $scopeType) {
+ return null;
+ }
+ $channel = str_contains($provider, '.mail') ? 'mail' : 'sms';
+ $suffix = $scopeValue !== '' ? "{$scopeType}.{$scopeValue}" : $scopeType;
+
+ return "{$channel}.{$suffix}";
+ }
+
+ /**
+ * 단일 row 의 다국어 컬럼들에 locale 키를 병합하고 audit 항목을 누적합니다.
+ *
+ * @param array $entry
+ * @param array $columns
+ * @param array> $audit
+ */
+ protected function mergeLocaleColumns(Model $row, array $entry, string $locale, array $columns, array &$audit): void
+ {
+ $overrides = (array) ($row->user_overrides ?? []);
+ $changed = false;
+
+ foreach ($columns as $column) {
+ if (! array_key_exists($column, $entry)) {
+ continue;
+ }
+
+ $current = $row->{$column};
+ if (! is_array($current)) {
+ $current = [];
+ }
+
+ $hasLocaleKey = array_key_exists($locale, $current);
+ $isOverridden = isset($overrides[$column]);
+
+ if ($hasLocaleKey && $isOverridden) {
+ $audit[] = [
+ 'action' => 'skipped',
+ 'table' => $row->getTable(),
+ 'id' => $row->getKey(),
+ 'column' => $column,
+ 'locale' => $locale,
+ 'reason' => 'user_overrides',
+ ];
+ continue;
+ }
+
+ $current[$locale] = $entry[$column];
+ $row->{$column} = $current;
+ $changed = true;
+ }
+
+ if ($changed) {
+ $row->saveQuietly();
+ }
+ }
+
+ /**
+ * 단일 row 의 다국어 컬럼들에서 locale 키를 제거하고 audit 항목을 누적합니다.
+ *
+ * @param array $columns
+ * @param array> $audit
+ */
+ protected function stripLocaleColumns(Model $row, string $locale, array $columns, array &$audit): void
+ {
+ $overrides = (array) ($row->user_overrides ?? []);
+ $changed = false;
+
+ foreach ($columns as $column) {
+ $current = $row->{$column};
+ if (! is_array($current) || ! array_key_exists($locale, $current)) {
+ continue;
+ }
+
+ if (isset($overrides[$column])) {
+ $audit[] = [
+ 'action' => 'preserved',
+ 'table' => $row->getTable(),
+ 'id' => $row->getKey(),
+ 'column' => $column,
+ 'locale' => $locale,
+ 'reason' => 'user_overrides',
+ ];
+ continue;
+ }
+
+ unset($current[$locale]);
+ $row->{$column} = $current;
+ $changed = true;
+ }
+
+ if ($changed) {
+ $row->saveQuietly();
+ }
+ }
+}
diff --git a/app/Repositories/ModuleRepository.php b/app/Repositories/ModuleRepository.php
index 86554dd3..132a818c 100644
--- a/app/Repositories/ModuleRepository.php
+++ b/app/Repositories/ModuleRepository.php
@@ -3,6 +3,7 @@
namespace App\Repositories;
use App\Contracts\Repositories\ModuleRepositoryInterface;
+use App\Enums\DeactivationReason;
use App\Enums\ExtensionStatus;
use App\Models\Module;
use Illuminate\Database\Eloquent\Collection;
@@ -326,4 +327,21 @@ class ModuleRepository implements ModuleRepositoryInterface
return in_array($pluginIdentifier, $pluginDependencies);
})->values();
}
+
+ /**
+ * 코어 버전 비호환으로 자동 비활성화된 모듈을 조회합니다.
+ *
+ * @return Collection 자동 비활성화된 모듈 컬렉션
+ */
+ public function findAutoDeactivated(): Collection
+ {
+ if (! \Illuminate\Support\Facades\Schema::hasColumn('modules', 'deactivated_reason')) {
+ return new Collection;
+ }
+
+ return Module::where('status', ExtensionStatus::Inactive->value)
+ ->where('deactivated_reason', DeactivationReason::IncompatibleCore->value)
+ ->orderByDesc('deactivated_at')
+ ->get();
+ }
}
diff --git a/app/Repositories/PluginRepository.php b/app/Repositories/PluginRepository.php
index f5ac109e..7f409728 100644
--- a/app/Repositories/PluginRepository.php
+++ b/app/Repositories/PluginRepository.php
@@ -3,6 +3,7 @@
namespace App\Repositories;
use App\Contracts\Repositories\PluginRepositoryInterface;
+use App\Enums\DeactivationReason;
use App\Enums\ExtensionStatus;
use App\Models\Plugin;
use Illuminate\Database\Eloquent\Collection;
@@ -275,4 +276,23 @@ class PluginRepository implements PluginRepositoryInterface
return in_array($pluginIdentifier, $dependencies);
})->values();
}
+
+ /**
+ * 코어 버전 비호환으로 자동 비활성화된 플러그인을 조회합니다.
+ *
+ * @return Collection 자동 비활성화된 플러그인 컬렉션
+ */
+ public function findAutoDeactivated(): Collection
+ {
+ // 마이그레이션이 아직 적용되지 않은 부팅 단계(예: migrate 직전 boot)에서는
+ // 컬럼 부재로 SQLSTATE 42S22 가 발생하므로 정적으로 가드한다.
+ if (! \Illuminate\Support\Facades\Schema::hasColumn('plugins', 'deactivated_reason')) {
+ return new Collection;
+ }
+
+ return Plugin::where('status', ExtensionStatus::Inactive->value)
+ ->where('deactivated_reason', DeactivationReason::IncompatibleCore->value)
+ ->orderByDesc('deactivated_at')
+ ->get();
+ }
}
diff --git a/app/Repositories/ScheduleRepository.php b/app/Repositories/ScheduleRepository.php
index 86cbb991..dfe718b3 100644
--- a/app/Repositories/ScheduleRepository.php
+++ b/app/Repositories/ScheduleRepository.php
@@ -270,4 +270,15 @@ class ScheduleRepository implements ScheduleRepositoryInterface
return $newSchedule;
}
+
+ /**
+ * ID 목록으로 스케줄들을 조회하고 ID 키 맵으로 반환합니다.
+ *
+ * @param array $ids 스케줄 ID 목록
+ * @return Collection id => Schedule 매핑
+ */
+ public function findManyByIdsKeyed(array $ids): Collection
+ {
+ return Schedule::whereIn('id', $ids)->get()->keyBy('id');
+ }
}
diff --git a/app/Repositories/TemplateRepository.php b/app/Repositories/TemplateRepository.php
index b1d3add6..d078f0e4 100644
--- a/app/Repositories/TemplateRepository.php
+++ b/app/Repositories/TemplateRepository.php
@@ -3,6 +3,7 @@
namespace App\Repositories;
use App\Contracts\Repositories\TemplateRepositoryInterface;
+use App\Enums\DeactivationReason;
use App\Enums\ExtensionStatus;
use App\Models\Template;
use Illuminate\Database\Eloquent\Collection;
@@ -11,6 +12,9 @@ class TemplateRepository implements TemplateRepositoryInterface
{
/**
* 모든 템플릿 조회
+ *
+ * @param string|null $type 필터링할 템플릿 타입 (admin, user 등). null 이면 전체 반환
+ * @return Collection 템플릿 컬렉션 (created_at 내림차순)
*/
public function getAll(?string $type = null): Collection
{
@@ -26,6 +30,9 @@ class TemplateRepository implements TemplateRepositoryInterface
/**
* ID로 템플릿 조회
+ *
+ * @param int $id 템플릿 ID
+ * @return Template|null 매칭된 템플릿 또는 null
*/
public function findById(int $id): ?Template
{
@@ -34,6 +41,9 @@ class TemplateRepository implements TemplateRepositoryInterface
/**
* identifier로 템플릿 조회
+ *
+ * @param string $identifier 템플릿 식별자 (vendor-template 형식)
+ * @return Template|null 매칭된 템플릿 또는 null
*/
public function findByIdentifier(string $identifier): ?Template
{
@@ -42,6 +52,9 @@ class TemplateRepository implements TemplateRepositoryInterface
/**
* 타입별 활성화된 템플릿 조회
+ *
+ * @param string $type 템플릿 타입 (admin, user 등)
+ * @return Template|null 활성 상태인 템플릿 또는 null
*/
public function findActiveByType(string $type): ?Template
{
@@ -52,6 +65,10 @@ class TemplateRepository implements TemplateRepositoryInterface
/**
* 템플릿 업데이트
+ *
+ * @param int $id 템플릿 ID
+ * @param array $data 업데이트할 컬럼 페이로드
+ * @return Template 갱신된 템플릿 (fresh() 재조회)
*/
public function update(int $id, array $data): Template
{
@@ -63,6 +80,9 @@ class TemplateRepository implements TemplateRepositoryInterface
/**
* 템플릿 삭제 (Soft Delete)
+ *
+ * @param int $id 템플릿 ID
+ * @return bool 삭제 성공 여부
*/
public function delete(int $id): bool
{
@@ -222,4 +242,21 @@ class TemplateRepository implements TemplateRepositoryInterface
return array_key_exists($pluginIdentifier, $pluginDependencies);
})->values();
}
+
+ /**
+ * 코어 버전 비호환으로 자동 비활성화된 템플릿을 조회합니다.
+ *
+ * @return Collection 자동 비활성화된 템플릿 컬렉션
+ */
+ public function findAutoDeactivated(): Collection
+ {
+ if (! \Illuminate\Support\Facades\Schema::hasColumn('templates', 'deactivated_reason')) {
+ return new Collection;
+ }
+
+ return Template::where('status', ExtensionStatus::Inactive->value)
+ ->where('deactivated_reason', DeactivationReason::IncompatibleCore->value)
+ ->orderByDesc('deactivated_at')
+ ->get();
+ }
}
diff --git a/app/Repositories/UserRepository.php b/app/Repositories/UserRepository.php
index 2d3be672..dca4f00d 100644
--- a/app/Repositories/UserRepository.php
+++ b/app/Repositories/UserRepository.php
@@ -220,4 +220,92 @@ class UserRepository implements UserRepositoryInterface
->pluck('count', 'language')
->toArray();
}
+
+ /**
+ * UUID 목록으로 사용자들을 조회하고 UUID 키 맵으로 반환합니다.
+ *
+ * @param array $uuids 사용자 UUID 목록
+ * @return Collection uuid => User 매핑
+ */
+ public function findManyByUuidsKeyed(array $uuids): Collection
+ {
+ return User::whereIn('uuid', $uuids)->get()->keyBy('uuid');
+ }
+
+ /**
+ * 사용자의 연속 로그인 실패 카운터를 1 증가시킵니다.
+ *
+ * @param User $user 대상 사용자
+ * @return int 증가 후 카운트
+ */
+ public function incrementFailedAttempts(User $user): int
+ {
+ $next = (int) ($user->failed_login_attempts ?? 0) + 1;
+
+ $user->forceFill([
+ 'failed_login_attempts' => $next,
+ 'last_failed_login_at' => now(),
+ ])->save();
+
+ return $next;
+ }
+
+ /**
+ * 사용자의 계정을 지정된 분만큼 잠급니다.
+ *
+ * @param User $user 잠글 사용자
+ * @param int $minutes 잠금 유지 시간(분)
+ * @return \Illuminate\Support\Carbon 잠금 해제 시각
+ */
+ public function lockAccount(User $user, int $minutes): \Illuminate\Support\Carbon
+ {
+ $lockedUntil = now()->addMinutes(max(1, $minutes));
+
+ $user->forceFill([
+ 'locked_until' => $lockedUntil,
+ 'failed_login_attempts' => 0,
+ ])->save();
+
+ return $lockedUntil;
+ }
+
+ /**
+ * 사용자의 모든 로그인 시도 추적 컬럼을 초기화합니다.
+ *
+ * 멱등 — 모든 컬럼이 이미 초기 상태면 UPDATE 를 발행하지 않습니다.
+ *
+ * @param User $user 대상 사용자
+ * @return void
+ */
+ public function resetLoginAttempts(User $user): void
+ {
+ $needsReset = ($user->failed_login_attempts ?? 0) > 0
+ || $user->locked_until !== null
+ || $user->last_failed_login_at !== null;
+
+ if (! $needsReset) {
+ return;
+ }
+
+ $user->forceFill([
+ 'failed_login_attempts' => 0,
+ 'locked_until' => null,
+ 'last_failed_login_at' => null,
+ ])->save();
+ }
+
+ /**
+ * 사용자의 계정이 현재 시점에 잠금 상태인지 판정합니다.
+ *
+ * @param User $user 대상 사용자
+ * @return bool 잠금 여부
+ */
+ public function isLocked(User $user): bool
+ {
+ if ($user->locked_until === null) {
+ return false;
+ }
+
+ return $user->locked_until->isFuture();
+ }
}
diff --git a/app/Rules/IdvTokenRule.php b/app/Rules/IdvTokenRule.php
new file mode 100644
index 00000000..ee565c10
--- /dev/null
+++ b/app/Rules/IdvTokenRule.php
@@ -0,0 +1,38 @@
+findVerifiedForToken($value, $this->purpose);
+
+ if (! $log) {
+ $fail(__('identity.errors.invalid_verification_token'));
+ }
+ }
+}
diff --git a/app/Seo/BotDetector.php b/app/Seo/BotDetector.php
index 8cd50421..ae94e51a 100644
--- a/app/Seo/BotDetector.php
+++ b/app/Seo/BotDetector.php
@@ -2,8 +2,22 @@
namespace App\Seo;
+use App\Extension\HookManager;
use Illuminate\Http\Request;
+/**
+ * 검색/링크 프리뷰/AI 봇 감지기.
+ *
+ * 평가 체인:
+ * 1. seo.bot_detection_enabled = false → false
+ * 2. _escaped_fragment_ 쿼리 → true (구형 크롤러 호환)
+ * 3. UA 빈 문자열 → false
+ * 4. core.seo.resolve_is_bot 훅 결과(non-null) → 즉시 결정 (확장 슬롯)
+ * 5. seo.bot_detection_library_enabled = true → jaybizzle/crawler-detect
+ * + G7 보강 패턴(미커버 3종) + 운영자 커스텀 패턴
+ * 6. 라이브러리 비활성 → 운영자 커스텀 패턴 stripos 매칭만 (레거시 모드)
+ * 7. fallthrough → false
+ */
class BotDetector
{
/**
@@ -14,12 +28,10 @@ class BotDetector
*/
public function isBot(Request $request): bool
{
- // 봇 감지 비활성화 시 항상 false
if (! g7_core_settings('seo.bot_detection_enabled', true)) {
return false;
}
- // _escaped_fragment_ 파라미터 지원 (구형 크롤러 호환)
if ($request->has('_escaped_fragment_')) {
return true;
}
@@ -29,12 +41,33 @@ class BotDetector
return false;
}
- $botPatterns = g7_core_settings('seo.bot_user_agents', []);
- if (empty($botPatterns)) {
- return false;
+ $hookResult = HookManager::applyFilters('core.seo.resolve_is_bot', null, [
+ 'request' => $request,
+ 'userAgent' => $userAgent,
+ ]);
+ if ($hookResult !== null) {
+ return (bool) $hookResult;
}
- foreach ($botPatterns as $pattern) {
+ $libraryEnabled = (bool) g7_core_settings('seo.bot_detection_library_enabled', true);
+ $userPatterns = (array) g7_core_settings('seo.bot_user_agents', []);
+
+ if ($libraryEnabled) {
+ // 라이브러리 1차: jaybizzle 약 1,000종 + G7 보강 패턴.
+ // 라이브러리는 isCrawler() 내부에서 Exclusions 패턴(Firefox/Mozilla/Chrome/Safari 등 일반 브라우저 식별자)
+ // 을 UA 에서 strip 한 후 매칭하므로, 운영자가 "Firefox" 등을 봇으로 지정해도 라이브러리 경로로는 잡히지 않음.
+ if ((new BotDetectorCustomProvider($userPatterns))->isCrawler($userAgent)) {
+ return true;
+ }
+
+ // 라이브러리 2차: 운영자 커스텀 패턴은 raw UA 에 stripos 직접 매칭 — Exclusions 우회.
+ // 라이브러리와 함께 작동.
+ }
+
+ foreach ($userPatterns as $pattern) {
+ if (! is_string($pattern) || $pattern === '') {
+ continue;
+ }
if (stripos($userAgent, $pattern) !== false) {
return true;
}
diff --git a/app/Seo/BotDetectorCustomProvider.php b/app/Seo/BotDetectorCustomProvider.php
new file mode 100644
index 00000000..14604773
--- /dev/null
+++ b/app/Seo/BotDetectorCustomProvider.php
@@ -0,0 +1,77 @@
+crawlers` 와 `$this->compiledRegex` 를 교체한다.
+ */
+class BotDetectorCustomProvider extends CrawlerDetect
+{
+ /**
+ * jaybizzle 1.3.9 기준 라이브러리가 놓치는 봇.
+ * 상류에 PR 후 커버되면 단계적 제거.
+ */
+ private const BUILTIN_EXTRA_PATTERNS = [
+ 'kakaotalk-scrap',
+ 'Meta-ExternalAgent',
+ 'ChatGPT-User',
+ ];
+
+ /**
+ * @param array $userPatterns 운영자가 관리자 UI 에서 추가한 커스텀 패턴
+ */
+ public function __construct(array $userPatterns = [])
+ {
+ parent::__construct();
+
+ $this->crawlers = $this->makeCrawlers($userPatterns);
+ $this->compiledRegex = $this->compileRegex($this->crawlers->getAll());
+ }
+
+ /**
+ * 라이브러리 기본 패턴 + G7 보강 + 사용자 패턴을 병합한 Crawlers fixture.
+ *
+ * @param array $userPatterns
+ */
+ private function makeCrawlers(array $userPatterns): Crawlers
+ {
+ return new class($userPatterns) extends Crawlers
+ {
+ /**
+ * @param array $userPatterns
+ */
+ public function __construct(array $userPatterns)
+ {
+ $this->data = array_merge(
+ $this->data,
+ BotDetectorCustomProvider::extraPatterns(),
+ );
+
+ foreach ($userPatterns as $pattern) {
+ $pattern = is_string($pattern) ? trim($pattern) : '';
+ if ($pattern === '') {
+ continue;
+ }
+
+ // 운영자 입력은 정규식 메타문자를 리터럴로 처리.
+ $this->data[] = preg_quote($pattern, '/');
+ }
+ }
+ };
+ }
+
+ /**
+ * 익명 자식 클래스가 const 에 접근하기 위한 정적 헬퍼.
+ *
+ * @return array
+ */
+ public static function extraPatterns(): array
+ {
+ return self::BUILTIN_EXTRA_PATTERNS;
+ }
+}
diff --git a/app/Seo/Concerns/LocalizesSeoValues.php b/app/Seo/Concerns/LocalizesSeoValues.php
new file mode 100644
index 00000000..967cb16a
--- /dev/null
+++ b/app/Seo/Concerns/LocalizesSeoValues.php
@@ -0,0 +1,58 @@
+getLocale();
+ if (isset($value[$locale])) {
+ return (string) $value[$locale];
+ }
+ $fallbackLocale = config('app.fallback_locale', 'en');
+ if (isset($value[$fallbackLocale])) {
+ return (string) $value[$fallbackLocale];
+ }
+
+ return '';
+ }
+
+ return (string) ($value ?? '');
+ }
+
+ /**
+ * array 가 다국어 형태(string 키)인지 판별.
+ */
+ protected function isLocalizedArray(array $value): bool
+ {
+ foreach (array_keys($value) as $key) {
+ if (is_string($key)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/app/Seo/Concerns/SubstitutesSeoVariables.php b/app/Seo/Concerns/SubstitutesSeoVariables.php
new file mode 100644
index 00000000..b354c2cf
--- /dev/null
+++ b/app/Seo/Concerns/SubstitutesSeoVariables.php
@@ -0,0 +1,67 @@
+resolvePath($fallback, $context);
}
-
+ /**
+ * SEO 메타 표현식을 컨텍스트와 함께 평가하여 최종 문자열을 반환합니다.
+ *
+ * `$t:` 번역 키 prefix, `{{path | pipe(arg)}}` 바인딩, 파이프 체인을 모두 지원합니다.
+ *
+ * @param string $expression 평가 대상 표현식 (예: `'$t:seo.title'`, `'{{post.title | upper}}'`)
+ * @param array $context 바인딩 컨텍스트 (route/post/locale 등)
+ * @return string 평가 결과 문자열 (해석 실패 시 원본 표현식 또는 빈 문자열)
+ */
public function evaluate(string $expression, array $context): string
{
// $t: 번역 키 처리 (전체가 $t:로 시작하는 경우)
@@ -1984,7 +1992,7 @@ class ExpressionEvaluator
$locale = app()->getLocale();
- return $value[$locale] ?? $value['ko'] ?? ($value ? reset($value) : '');
+ return $value[$locale] ?? $value[config('app.fallback_locale', 'ko')] ?? ($value ? reset($value) : '');
}
/**
diff --git a/app/Seo/SeoMetaResolver.php b/app/Seo/SeoMetaResolver.php
index 3846d0dd..8cb12d66 100644
--- a/app/Seo/SeoMetaResolver.php
+++ b/app/Seo/SeoMetaResolver.php
@@ -2,8 +2,14 @@
namespace App\Seo;
+use App\Seo\Concerns\LocalizesSeoValues;
+use App\Seo\Concerns\SubstitutesSeoVariables;
+
class SeoMetaResolver
{
+ use LocalizesSeoValues;
+ use SubstitutesSeoVariables;
+
public function __construct(
private readonly ExpressionEvaluator $evaluator,
) {}
@@ -51,19 +57,28 @@ class SeoMetaResolver
$title = $title ?? '';
$description = $description ?? '';
- // OG 태그 해석
- $ogTags = $this->resolveOgTags($seoConfig, $context, $title, $description);
-
- // 구조화 데이터 (JSON-LD) 해석
- $jsonLd = $this->resolveStructuredData($seoConfig, $context);
+ // OG / Twitter / Structured Data 배열 해석 (HTML 렌더 지연)
+ $og = $this->resolveOgData($seoConfig, $context, $title, $description);
+ $twitter = $this->resolveTwitterData($seoConfig, $context, $og);
+ $structuredData = $this->resolveStructuredDataArray($seoConfig, $context);
return [
'title' => $title,
'titleSuffix' => $titleSuffix,
'description' => $description,
'keywords' => $keywords,
- 'ogTags' => $ogTags,
- 'jsonLd' => $jsonLd,
+
+ // 신설: 배열 형태 (확장이 hook 으로 수정 가능)
+ 'og' => $og,
+ 'twitter' => $twitter,
+ 'structured_data' => $structuredData,
+ 'extraMetaTags' => [],
+
+ // 후방 호환: HTML/JSON 문자열도 함께 채움 (filter_meta 후 SeoRenderer 가 재계산)
+ 'ogTags' => $this->renderOgHtml($og),
+ 'twitterTags' => $this->renderTwitterHtml($twitter),
+ 'jsonLd' => $this->renderStructuredJson($structuredData),
+
'googleAnalyticsId' => g7_core_settings('seo.google_analytics_id', ''),
'googleVerification' => g7_core_settings('seo.google_site_verification', ''),
'naverVerification' => g7_core_settings('seo.naver_site_verification', ''),
@@ -182,6 +197,12 @@ class SeoMetaResolver
$keywords = data_get($dsData, 'data.meta_keywords');
if (! empty($keywords)) {
if (is_array($keywords)) {
+ // 다국어 JSON ({"ko": "...", "en": "..."}) 분기 — locale 추출
+ if ($this->isLocalizedArray($keywords)) {
+ return $this->resolveLocalizedValue($keywords);
+ }
+
+ // 키워드 list 인 경우 (정수 키 배열)
return implode(',', $keywords);
}
@@ -210,7 +231,7 @@ class SeoMetaResolver
foreach ($context as $dsData) {
$value = data_get($dsData, "data.{$field}");
if ($value !== null && $value !== '') {
- return (string) $value;
+ return $this->resolveLocalizedValue($value);
}
}
@@ -229,7 +250,7 @@ class SeoMetaResolver
foreach ($context as $dsData) {
$value = data_get($dsData, "data.seo_meta.{$field}");
if ($value !== null && $value !== '') {
- return (string) $value;
+ return $this->resolveLocalizedValue($value);
}
}
return '';
@@ -346,11 +367,12 @@ class SeoMetaResolver
private function resolveVarExpression(string $expr, array $context, ?string $moduleIdentifier, ?string $pluginIdentifier = null): string
{
// $module_settings:key 또는 $module_settings:module-id:key
+ // 다국어 JSON 배열 설정값도 안전 처리 (resolveLocalizedValue)
if (str_starts_with($expr, '$module_settings:')) {
$rest = substr($expr, strlen('$module_settings:'));
[$effectiveId, $key] = $this->parseExtensionSettingsKey($rest, $moduleIdentifier);
if ($effectiveId) {
- return (string) g7_module_settings($effectiveId, $key, '');
+ return $this->resolveLocalizedValue(g7_module_settings($effectiveId, $key, ''));
}
}
@@ -359,7 +381,7 @@ class SeoMetaResolver
$rest = substr($expr, strlen('$plugin_settings:'));
[$effectiveId, $key] = $this->parseExtensionSettingsKey($rest, $pluginIdentifier);
if ($effectiveId) {
- return (string) g7_plugin_settings($effectiveId, $key, '');
+ return $this->resolveLocalizedValue(g7_plugin_settings($effectiveId, $key, ''));
}
}
@@ -367,14 +389,14 @@ class SeoMetaResolver
if (str_starts_with($expr, '$core_settings:')) {
$key = substr($expr, strlen('$core_settings:'));
- return (string) g7_core_settings($key, '');
+ return $this->resolveLocalizedValue(g7_core_settings($key, ''));
}
// $query:key
if (str_starts_with($expr, '$query:')) {
$key = substr($expr, strlen('$query:'));
- return (string) request()->query($key, '');
+ return $this->resolveLocalizedValue(request()->query($key, ''));
}
// {{expression}} → ExpressionEvaluator
@@ -406,23 +428,6 @@ class SeoMetaResolver
return [$contextIdentifier, $rest];
}
- /**
- * 해석된 변수로 템플릿 문자열을 치환합니다.
- *
- * @param string $template 템플릿 문자열 ({var_name} 플레이스홀더 포함)
- * @param array $resolvedVars 해석된 변수 (키 → 값)
- * @return string 치환된 문자열
- */
- private function substituteVars(string $template, array $resolvedVars): string
- {
- $replacements = [];
- foreach ($resolvedVars as $key => $value) {
- $replacements['{'.$key.'}'] = $value;
- }
-
- return str_replace(array_keys($replacements), array_values($replacements), $template);
- }
-
/**
* 레이아웃 meta의 title을 해석합니다 (fallback용).
*
@@ -432,10 +437,10 @@ class SeoMetaResolver
*/
private function resolveLayoutMetaTitle(array $seoConfig, array $context): string
{
- // meta.seo.og.title이 있으면 사용
+ // meta.seo.og.title이 있으면 사용. 다국어 array literal 도 허용 (safeEval).
$ogTitle = data_get($seoConfig, 'og.title', '');
- if ($ogTitle !== '') {
- return $this->evaluator->evaluate($ogTitle, $context);
+ if ($ogTitle !== '' && $ogTitle !== null && $ogTitle !== []) {
+ return $this->safeEval($ogTitle, $context);
}
return '';
@@ -450,77 +455,305 @@ class SeoMetaResolver
*/
private function resolveLayoutMetaDescription(array $seoConfig, array $context): string
{
+ // 다국어 array literal 도 허용 (safeEval).
$ogDescription = data_get($seoConfig, 'og.description', '');
- if ($ogDescription !== '') {
- return $this->stripHtml($this->evaluator->evaluate($ogDescription, $context));
+ if ($ogDescription !== '' && $ogDescription !== null && $ogDescription !== []) {
+ return $this->stripHtml($this->safeEval($ogDescription, $context));
}
return '';
}
/**
- * OG 태그를 생성합니다.
+ * OG 메타태그를 배열 형태로 해석합니다 (HTML 렌더 지연).
+ *
+ * 레이아웃 meta.seo.og 선언 + 코어 설정 fallback 으로 og 데이터를 구성.
+ * SeoRenderer 가 모듈 declaration 과 deep-merge 한 뒤 hook 적용 가능하도록 배열 반환.
*
* @param array $seoConfig SEO 설정
* @param array $context 데이터 컨텍스트
* @param string $fallbackTitle fallback 타이틀
* @param string $fallbackDescription fallback 설명
- * @return string OG 메타태그 HTML
+ * @return array OG 데이터 배열 (type, title, description, image, image_*, site_name, locale, extra)
*/
- private function resolveOgTags(array $seoConfig, array $context, string $fallbackTitle, string $fallbackDescription): string
+ public function resolveOgData(array $seoConfig, array $context, string $fallbackTitle, string $fallbackDescription): array
{
$og = $seoConfig['og'] ?? [];
- if (empty($og)) {
- return '';
+
+ $title = $this->stripHtml($this->safeEval($og['title'] ?? '', $context)) ?: $fallbackTitle;
+ $description = $this->stripHtml($this->safeEval($og['description'] ?? '', $context)) ?: $fallbackDescription;
+ $image = $this->absoluteUrl($this->safeEval($og['image'] ?? '', $context));
+ $secureUrl = isset($og['image_secure_url'])
+ ? $this->absoluteUrl($this->safeEval($og['image_secure_url'], $context))
+ : ($image !== '' && str_starts_with($image, 'https://') ? $image : '');
+
+ // site_name fallback: 코어 설정이 다국어 array 일 수 있으므로 resolveLocalizedValue 통과
+ $explicitSiteName = $this->stripHtml($this->safeEval($og['site_name'] ?? '', $context));
+ $siteNameFallback = g7_core_settings('seo.og_default_site_name');
+ if ($siteNameFallback === null || $siteNameFallback === '') {
+ $siteNameFallback = g7_core_settings('general.site_name', '');
}
- $tags = '';
- $ogType = $this->evaluator->evaluate($og['type'] ?? 'website', $context);
- $ogTitle = $this->stripHtml($this->evaluator->evaluate($og['title'] ?? '', $context)) ?: $fallbackTitle;
- $ogDescription = $this->stripHtml($this->evaluator->evaluate($og['description'] ?? '', $context)) ?: $fallbackDescription;
- $ogImage = $this->evaluator->evaluate($og['image'] ?? '', $context);
-
- $tags .= ''."\n";
-
- if ($ogTitle !== '') {
- $tags .= ' '."\n";
- }
-
- if ($ogDescription !== '') {
- $tags .= ' '."\n";
- }
-
- if ($ogImage !== '') {
- $absoluteImage = str_starts_with($ogImage, 'http') ? $ogImage : url($ogImage);
- $tags .= ' '."\n";
- }
-
- return $tags;
+ return [
+ 'type' => $this->safeEval($og['type'] ?? 'website', $context),
+ 'title' => $title,
+ 'description' => $description,
+ 'image' => $image,
+ 'image_secure_url' => $secureUrl,
+ 'image_width' => $this->resolveIntOrSetting($og['image_width'] ?? null, 'seo.og_image_default_width', $context),
+ 'image_height' => $this->resolveIntOrSetting($og['image_height'] ?? null, 'seo.og_image_default_height', $context),
+ 'image_type' => $this->safeEval($og['image_type'] ?? '', $context),
+ 'image_alt' => $this->stripHtml($this->safeEval($og['image_alt'] ?? $og['title'] ?? '', $context)),
+ 'site_name' => $explicitSiteName !== '' ? $explicitSiteName : $this->resolveLocalizedValue($siteNameFallback),
+ 'locale' => $this->safeEval($og['locale'] ?? '', $context) ?: app()->getLocale(),
+ 'extra' => is_array($og['extra'] ?? null) ? $og['extra'] : [],
+ ];
}
/**
- * 구조화 데이터 (JSON-LD)를 생성합니다.
+ * Twitter 카드 메타태그를 배열 형태로 해석합니다.
+ *
+ * 미선언 필드는 OG 데이터를 fallback 으로 사용 (Slack 등이 트위터 카드를 폴백 경로로 활용).
*
* @param array $seoConfig SEO 설정
* @param array $context 데이터 컨텍스트
- * @return string|null JSON-LD 문자열 또는 null
+ * @param array $ogData resolveOgData 결과 (fallback 용)
+ * @return array Twitter 카드 데이터
*/
- private function resolveStructuredData(array $seoConfig, array $context): ?string
+ public function resolveTwitterData(array $seoConfig, array $context, array $ogData): array
+ {
+ $tw = $seoConfig['twitter'] ?? [];
+ $hasImage = ($ogData['image'] ?? '') !== '';
+
+ // 코어 설정이 다국어 array 일 가능성 대비 — resolveLocalizedValue 통과
+ $cardFallback = g7_core_settings('seo.twitter_default_card', $hasImage ? 'summary_large_image' : 'summary');
+ $siteFallback = g7_core_settings('seo.twitter_default_site', '');
+
+ return [
+ 'card' => isset($tw['card']) ? $this->safeEval((string) $tw['card'], $context) : $this->resolveLocalizedValue($cardFallback),
+ 'site' => isset($tw['site']) ? $this->safeEval((string) $tw['site'], $context) : $this->resolveLocalizedValue($siteFallback),
+ 'creator' => $this->safeEval($tw['creator'] ?? '', $context),
+ 'title' => isset($tw['title'])
+ ? $this->stripHtml($this->safeEval($tw['title'], $context))
+ : (string) ($ogData['title'] ?? ''),
+ 'description' => isset($tw['description'])
+ ? $this->stripHtml($this->safeEval($tw['description'], $context))
+ : (string) ($ogData['description'] ?? ''),
+ 'image' => isset($tw['image'])
+ ? $this->absoluteUrl($this->safeEval($tw['image'], $context))
+ : (string) ($ogData['image'] ?? ''),
+ 'image_alt' => isset($tw['image_alt'])
+ ? $this->stripHtml($this->safeEval($tw['image_alt'], $context))
+ : (string) ($ogData['image_alt'] ?? ''),
+ 'extra' => is_array($tw['extra'] ?? null) ? $tw['extra'] : [],
+ ];
+ }
+
+ /**
+ * 구조화 데이터 (JSON-LD)를 배열 형태로 해석합니다.
+ *
+ * 표현식 평가만 수행하고 JSON 직렬화는 renderStructuredJson 에서.
+ *
+ * @param array $seoConfig SEO 설정
+ * @param array $context 데이터 컨텍스트
+ * @return array|null 평가된 스키마 배열 또는 null
+ */
+ public function resolveStructuredDataArray(array $seoConfig, array $context): ?array
{
$structuredData = $seoConfig['structured_data'] ?? null;
if (empty($structuredData)) {
return null;
}
- // 구조화 데이터 내 표현식 재귀 평가
- $resolved = $this->resolveStructuredDataRecursive($structuredData, $context);
+ return $this->resolveStructuredDataRecursive($structuredData, $context);
+ }
- // @context 추가
- $resolved = array_merge(['@context' => 'https://schema.org'], $resolved);
+ /**
+ * OG 데이터 배열을 HTML 메타태그로 렌더합니다.
+ *
+ * @param array $og OG 데이터 배열
+ * @return string HTML 메타태그
+ */
+ public function renderOgHtml(array $og): string
+ {
+ $tags = '';
+ $type = (string) ($og['type'] ?? '');
+ if ($type === '') {
+ return '';
+ }
+
+ $tags .= ''."\n";
+
+ $title = (string) ($og['title'] ?? '');
+ if ($title !== '') {
+ $tags .= ' '."\n";
+ }
+ $description = (string) ($og['description'] ?? '');
+ if ($description !== '') {
+ $tags .= ' '."\n";
+ }
+
+ $image = (string) ($og['image'] ?? '');
+ if ($image !== '') {
+ $tags .= ' '."\n";
+
+ $secure = (string) ($og['image_secure_url'] ?? '');
+ if ($secure !== '' && str_starts_with($secure, 'https://')) {
+ $tags .= ' '."\n";
+ }
+ $imageType = (string) ($og['image_type'] ?? '');
+ if ($imageType !== '') {
+ $tags .= ' '."\n";
+ }
+ $width = $og['image_width'] ?? null;
+ if (is_numeric($width) && (int) $width > 0) {
+ $tags .= ' '."\n";
+ }
+ $height = $og['image_height'] ?? null;
+ if (is_numeric($height) && (int) $height > 0) {
+ $tags .= ' '."\n";
+ }
+ $imageAlt = (string) ($og['image_alt'] ?? '');
+ if ($imageAlt !== '') {
+ $tags .= ' '."\n";
+ }
+ }
+
+ $siteName = (string) ($og['site_name'] ?? '');
+ if ($siteName !== '') {
+ $tags .= ' '."\n";
+ }
+
+ foreach ((array) ($og['extra'] ?? []) as $entry) {
+ if (! is_array($entry)) {
+ continue;
+ }
+ $prop = (string) ($entry['property'] ?? '');
+ $content = (string) ($entry['content'] ?? '');
+ if ($prop !== '' && $content !== '') {
+ $tags .= ' '."\n";
+ }
+ }
+
+ return $tags;
+ }
+
+ /**
+ * Twitter 데이터 배열을 HTML 메타태그로 렌더합니다.
+ *
+ * @param array $tw Twitter 데이터 배열
+ * @return string HTML 메타태그
+ */
+ public function renderTwitterHtml(array $tw): string
+ {
+ $card = (string) ($tw['card'] ?? '');
+ if ($card === '') {
+ return '';
+ }
+
+ $tags = ''."\n";
+
+ foreach (['site', 'creator', 'title', 'description', 'image'] as $key) {
+ $val = (string) ($tw[$key] ?? '');
+ if ($val !== '') {
+ $tags .= ' '."\n";
+ }
+ }
+ $imageAlt = (string) ($tw['image_alt'] ?? '');
+ if ($imageAlt !== '') {
+ $tags .= ' '."\n";
+ }
+ foreach ((array) ($tw['extra'] ?? []) as $entry) {
+ if (! is_array($entry)) {
+ continue;
+ }
+ $name = (string) ($entry['name'] ?? '');
+ $content = (string) ($entry['content'] ?? '');
+ if ($name !== '' && $content !== '') {
+ $tags .= ' '."\n";
+ }
+ }
+
+ return $tags;
+ }
+
+ /**
+ * 구조화 데이터 배열을 JSON-LD 문자열로 렌더합니다.
+ *
+ * @param array|null $structuredData 구조화 데이터 배열
+ * @return string|null JSON-LD 문자열 또는 null
+ */
+ public function renderStructuredJson(?array $structuredData): ?string
+ {
+ if (empty($structuredData)) {
+ return null;
+ }
+
+ $resolved = array_merge(['@context' => 'https://schema.org'], $structuredData);
return json_encode($resolved, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
}
+ /**
+ * 표현식을 평가하여 항상 string 으로 반환.
+ *
+ * - string + {{...}} 표현식 → ExpressionEvaluator 로 평가 (string 보장)
+ * - 다국어 array (`["ko" => "...", "en" => "..."]`) → 현재 로케일 → fallback locale 순으로 추출
+ * - 그 외 (numeric, bool, null, 기타 array) → resolveLocalizedValue 로 string 변환
+ *
+ * 'Array to string conversion' / TypeError 회귀 방지 — resolveOgData/resolveTwitterData 에서
+ * 모든 표현식 평가 지점은 본 헬퍼를 거쳐야 함.
+ */
+ private function safeEval(mixed $expr, array $context): string
+ {
+ if (is_string($expr)) {
+ return $this->evaluator->evaluate($expr, $context);
+ }
+
+ return $this->resolveLocalizedValue($expr);
+ }
+
+ /**
+ * 상대 경로 URL 을 절대 URL 로 변환.
+ */
+ private function absoluteUrl(string $url): string
+ {
+ if ($url === '') {
+ return '';
+ }
+
+ return str_starts_with($url, 'http') ? $url : url($url);
+ }
+
+ /**
+ * 정수 값 해석 — 명시값 우선, 없으면 코어 설정 fallback.
+ *
+ * @param mixed $value 레이아웃 선언 값 (int|string expr|null)
+ * @param string $settingKey 코어 설정 키
+ * @param array $context 데이터 컨텍스트 (표현식 평가용)
+ * @return int|null 정수 또는 null
+ */
+ private function resolveIntOrSetting(mixed $value, string $settingKey, array $context): ?int
+ {
+ if ($value === null || $value === '') {
+ $default = g7_core_settings($settingKey);
+
+ return is_numeric($default) ? (int) $default : null;
+ }
+
+ if (is_int($value)) {
+ return $value;
+ }
+
+ if (is_string($value)) {
+ $evaluated = $this->evaluator->evaluate($value, $context);
+
+ return is_numeric($evaluated) ? (int) $evaluated : null;
+ }
+
+ return is_numeric($value) ? (int) $value : null;
+ }
+
/**
* 구조화 데이터를 재귀적으로 표현식 평가합니다.
*
@@ -582,32 +815,6 @@ class SeoMetaResolver
return false;
}
- /**
- * 다국어 객체에서 현재 로케일 값을 추출합니다.
- *
- * @param mixed $value 다국어 객체 또는 문자열
- * @return string 현재 로케일 값
- */
- private function resolveLocalizedValue(mixed $value): string
- {
- if (is_string($value)) {
- return $value;
- }
-
- if (is_array($value)) {
- $locale = app()->getLocale();
- if (isset($value[$locale])) {
- return (string) $value[$locale];
- }
- $fallbackLocale = config('app.fallback_locale', 'en');
- if (isset($value[$fallbackLocale])) {
- return (string) $value[$fallbackLocale];
- }
- }
-
- return (string) ($value ?? '');
- }
-
/**
* HTML 태그를 제거하고 공백을 정규화합니다.
*
diff --git a/app/Seo/SeoMiddleware.php b/app/Seo/SeoMiddleware.php
index e320eda6..0a203c38 100644
--- a/app/Seo/SeoMiddleware.php
+++ b/app/Seo/SeoMiddleware.php
@@ -72,9 +72,30 @@ class SeoMiddleware
try {
$html = $this->renderer->render($request);
} catch (\Throwable $e) {
+ // 정확한 throw 지점 진단을 위한 상세 정보 — file/line/exception class/trace 첫 10프레임
+ $traceFrames = array_slice(
+ array_map(static function ($frame) {
+ $file = $frame['file'] ?? '?';
+ $line = $frame['line'] ?? '?';
+ $class = $frame['class'] ?? '';
+ $type = $frame['type'] ?? '';
+ $function = $frame['function'] ?? '?';
+
+ return $file.':'.$line.' '.$class.$type.$function;
+ }, $e->getTrace()),
+ 0,
+ 10
+ );
+
Log::error('[SEO] Rendering failed, falling back to SPA', [
'url' => $cacheUrl,
'error' => $e->getMessage(),
+ 'exception_class' => get_class($e),
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ 'user_agent' => $request->userAgent(),
+ 'locale' => $locale,
+ 'trace' => $traceFrames,
]);
return $next($request);
diff --git a/app/Seo/SeoRenderer.php b/app/Seo/SeoRenderer.php
index 5ddf5342..26cd2dc9 100644
--- a/app/Seo/SeoRenderer.php
+++ b/app/Seo/SeoRenderer.php
@@ -5,6 +5,8 @@ namespace App\Seo;
use App\Contracts\Extension\ModuleManagerInterface;
use App\Contracts\Extension\PluginManagerInterface;
use App\Extension\HookManager;
+use App\Seo\Concerns\LocalizesSeoValues;
+use App\Seo\Concerns\SubstitutesSeoVariables;
use App\Seo\Contracts\SeoRendererInterface;
use App\Services\LayoutService;
use App\Services\PluginSettingsService;
@@ -16,6 +18,9 @@ use Illuminate\Support\Facades\View;
class SeoRenderer implements SeoRendererInterface
{
+ use LocalizesSeoValues;
+ use SubstitutesSeoVariables;
+
public function __construct(
private readonly TemplateRouteResolver $routeResolver,
private readonly LayoutService $layoutService,
@@ -32,7 +37,10 @@ class SeoRenderer implements SeoRendererInterface
) {}
/**
- * {@inheritdoc}
+ * 요청 URL에 매핑된 SEO HTML 을 렌더링합니다.
+ *
+ * @param Request $request 유입된 HTTP 요청
+ * @return string|null 렌더된 HTML, SEO 비활성/매핑 없음/예외 발생 시 null
*/
public function render(Request $request): ?string
{
@@ -217,18 +225,130 @@ class SeoRenderer implements SeoRendererInterface
// 설정 템플릿(meta_{page_type}_title/description)에 적용한 결과를 _seo.{page_type}에 주입
$this->resolveSeoContext($seoConfig, $context, $routeParams, $resolvedVars ?? []);
- // 6. SeoMetaResolver로 3계층 캐스케이드 메타 해석
+ // 6. SeoMetaResolver로 3계층 캐스케이드 메타 해석 (배열 형태)
$meta = $this->metaResolver->resolve($seoConfig, $context, $moduleIdentifier, $pluginIdentifier, $routeParams);
- // 6.1. 훅: 확장이 메타 태그를 동적으로 수정할 수 있는 필터
- // 유즈케이스: SEO 플러그인이 title suffix 변경, 리뷰 플러그인이 JSON-LD에 review 배열 주입
- $meta = HookManager::applyFilters('core.seo.filter_meta', $meta, [
+ // 6.05. $meta 가 신구 양식 모두 처리 가능하도록 og/twitter/structured_data 키 정규화
+ // (구버전 Mock/Stub 호환 — 키 없으면 빈 배열로 보강)
+ $meta['og'] = is_array($meta['og'] ?? null) ? $meta['og'] : [];
+ $meta['twitter'] = is_array($meta['twitter'] ?? null) ? $meta['twitter'] : [];
+ $meta['structured_data'] = $meta['structured_data'] ?? null;
+
+ // 6.06. 모듈/플러그인 declaration 캐스케이드:
+ // 코어설정 < 모듈/플러그인 declaration < 레이아웃 override < hook
+ // resolveOgData 는 이미 (코어설정 + 레이아웃) 을 처리한 결과를 반환했으므로,
+ // 모듈 declaration 은 "레이아웃에서 비어있는 키" 만 채우는 fallback 으로 적용한다.
+ $pageType = $seoConfig['page_type'] ?? null;
+ $extensions = $seoConfig['extensions'] ?? [];
+ if ($pageType && ! empty($extensions)) {
+ $extOg = [];
+ $extTwitter = [];
+ $extStructured = null;
+
+ foreach ($extensions as $extDef) {
+ $extType = $extDef['type'] ?? null;
+ $extId = $extDef['id'] ?? null;
+ if (! $extType || ! $extId) {
+ continue;
+ }
+ $extInstance = $this->getExtensionInstance($extType, $extId);
+ if (! $extInstance) {
+ continue;
+ }
+
+ // 원천봉쇄: 한 모듈/플러그인의 declaration throw 가 전체 SEO 렌더를 망치지 않도록 격리.
+ // 다국어 JSON array 캐스팅 같은 모듈 내부 회귀가 SPA fallback 까지 가지 않고 부분 누락만 발생.
+ $extOg = $this->mergeOgData($extOg, $this->safeInvokeExtensionMethod(
+ $extInstance, 'seoOgDefaults', [$pageType, $context, $routeParams], $extType, $extId
+ ));
+ $extTwitter = $this->mergeTwitterData($extTwitter, $this->safeInvokeExtensionMethod(
+ $extInstance, 'seoTwitterDefaults', [$pageType, $context, $routeParams], $extType, $extId
+ ));
+
+ $declared = $this->safeInvokeExtensionMethod(
+ $extInstance, 'seoStructuredData', [$pageType, $context, $routeParams], $extType, $extId
+ );
+ if (! empty($declared)) {
+ $extStructured = $declared; // 마지막 확장이 우선 (배열 보유 시)
+ }
+ }
+
+ // 레이아웃 비어있는 og 필드만 모듈 declaration 으로 채움 (레이아웃 override 우선)
+ $cascadeChanged = false;
+ if (! empty($extOg)) {
+ $meta['og'] = $this->fillEmptyKeys($meta['og'], $extOg);
+ $cascadeChanged = true;
+ }
+ if (! empty($extTwitter)) {
+ $meta['twitter'] = $this->fillEmptyKeys($meta['twitter'], $extTwitter);
+ $cascadeChanged = true;
+ }
+ // structured_data: 레이아웃 미선언 시 모듈 declaration 사용
+ if ($meta['structured_data'] === null && $extStructured !== null) {
+ $meta['structured_data'] = $extStructured;
+ $cascadeChanged = true;
+ }
+
+ // 회귀: SeoMetaResolver.resolve() 가 layout-only og 로 미리 만든 ogTags/twitterTags/jsonLd 가
+ // 모듈 declaration cascade 결과를 반영 못해 og:image 등이 누락됨 → cascade 후 즉시 재렌더.
+ if ($cascadeChanged) {
+ $meta['ogTags'] = $this->metaResolver->renderOgHtml($meta['og']);
+ $meta['twitterTags'] = $this->metaResolver->renderTwitterHtml($meta['twitter']);
+ $meta['jsonLd'] = $this->metaResolver->renderStructuredJson($meta['structured_data']);
+ }
+ }
+
+ $hookCtx = [
'layoutName' => $layoutName,
'moduleIdentifier' => $moduleIdentifier,
'pluginIdentifier' => $pluginIdentifier,
'context' => $context,
'locale' => $locale,
- ]);
+ 'pageType' => $pageType,
+ ];
+
+ // 6.1. 분기별 훅: og / twitter / structured_data 각각 가로채서 수정 가능
+ // 빈 배열/null 인 경우 hook 으로 청취자가 새 데이터 주입 가능하도록 호출은 항상 수행.
+ $ogBefore = $meta['og'];
+ $twitterBefore = $meta['twitter'];
+ $structuredBefore = $meta['structured_data'];
+
+ $meta['og'] = HookManager::applyFilters('core.seo.filter_og_data', $meta['og'], $hookCtx);
+ $meta['twitter'] = HookManager::applyFilters('core.seo.filter_twitter_data', $meta['twitter'], $hookCtx);
+ $meta['structured_data'] = HookManager::applyFilters('core.seo.filter_structured_data', $meta['structured_data'], $hookCtx);
+
+ // 6.15. og/twitter/structured 가 hook 으로 변경되었거나 원본이 비어있지 않을 때만 재렌더.
+ // (mock 테스트 호환: 비어있고 hook 도 변경 안 했으면 기존 ogTags/jsonLd 문자열 유지)
+ if (! empty($meta['og']) && $meta['og'] !== $ogBefore) {
+ $meta['ogTags'] = $this->metaResolver->renderOgHtml($meta['og']);
+ } elseif (! empty($meta['og']) && ! isset($meta['ogTags'])) {
+ $meta['ogTags'] = $this->metaResolver->renderOgHtml($meta['og']);
+ }
+ if (! empty($meta['twitter']) && $meta['twitter'] !== $twitterBefore) {
+ $meta['twitterTags'] = $this->metaResolver->renderTwitterHtml($meta['twitter']);
+ } elseif (! empty($meta['twitter']) && ! isset($meta['twitterTags'])) {
+ $meta['twitterTags'] = $this->metaResolver->renderTwitterHtml($meta['twitter']);
+ }
+ if ($meta['structured_data'] !== null && $meta['structured_data'] !== $structuredBefore) {
+ $meta['jsonLd'] = $this->metaResolver->renderStructuredJson($meta['structured_data']);
+ } elseif ($meta['structured_data'] !== null && ! isset($meta['jsonLd'])) {
+ $meta['jsonLd'] = $this->metaResolver->renderStructuredJson($meta['structured_data']);
+ }
+
+ // 6.2. 통합 훅: 모든 분기 결합 후 최종 메타 수정
+ $metaBeforeFilter = $meta;
+ $meta = HookManager::applyFilters('core.seo.filter_meta', $meta, $hookCtx);
+
+ // 6.25. filter_meta 가 og/twitter/structured 배열을 수정했을 수 있으므로 변경된 것만 재렌더
+ if (is_array($meta['og'] ?? null) && ! empty($meta['og']) && $meta['og'] !== ($metaBeforeFilter['og'] ?? null)) {
+ $meta['ogTags'] = $this->metaResolver->renderOgHtml($meta['og']);
+ }
+ if (is_array($meta['twitter'] ?? null) && ! empty($meta['twitter']) && $meta['twitter'] !== ($metaBeforeFilter['twitter'] ?? null)) {
+ $meta['twitterTags'] = $this->metaResolver->renderTwitterHtml($meta['twitter']);
+ }
+ if (array_key_exists('structured_data', $meta) && $meta['structured_data'] !== ($metaBeforeFilter['structured_data'] ?? null)) {
+ $meta['jsonLd'] = $this->metaResolver->renderStructuredJson($meta['structured_data']);
+ }
// 6.5. 레이아웃명을 request attribute로 저장 (SeoMiddleware에서 putWithLayout에 사용)
$request->attributes->set('seo_layout_name', $layoutName);
@@ -273,6 +393,7 @@ class SeoRenderer implements SeoRendererInterface
'canonicalUrl' => $canonicalUrl,
'hreflangTags' => $hreflangTags,
'ogTags' => $meta['ogTags'].' '.$ogUrl,
+ 'twitterTags' => $meta['twitterTags'] ?? '',
'jsonLd' => $meta['jsonLd'],
'bodyHtml' => $bodyHtml,
'googleAnalyticsId' => $meta['googleAnalyticsId'],
@@ -282,6 +403,7 @@ class SeoRenderer implements SeoRendererInterface
'stylesheets' => $allStylesheets,
'extraHeadTags' => '',
'extraBodyEnd' => '',
+ 'generatorTag' => g7_meta_generator_tag(),
];
// 8.1. 훅: 확장이 View 변수를 추가/수정할 수 있는 필터
@@ -315,11 +437,12 @@ class SeoRenderer implements SeoRendererInterface
foreach ($varsDecl as $name => $expr) {
$expr = (string) $expr;
+ // 설정값이 다국어 JSON 배열일 수 있으므로 resolveLocalizedValue 헬퍼 통과
if (str_starts_with($expr, '$module_settings:')) {
$rest = substr($expr, strlen('$module_settings:'));
[$effectiveModuleId, $key] = $this->parseExtensionSettingsKey($rest, $moduleIdentifier);
if ($effectiveModuleId) {
- $resolved[$name] = (string) g7_module_settings($effectiveModuleId, $key, '');
+ $resolved[$name] = $this->resolveLocalizedValue(g7_module_settings($effectiveModuleId, $key, ''));
} else {
$resolved[$name] = $this->evaluator->evaluate($expr, $context);
}
@@ -327,16 +450,16 @@ class SeoRenderer implements SeoRendererInterface
$rest = substr($expr, strlen('$plugin_settings:'));
[$effectivePluginId, $key] = $this->parseExtensionSettingsKey($rest, $pluginIdentifier);
if ($effectivePluginId) {
- $resolved[$name] = (string) g7_plugin_settings($effectivePluginId, $key, '');
+ $resolved[$name] = $this->resolveLocalizedValue(g7_plugin_settings($effectivePluginId, $key, ''));
} else {
$resolved[$name] = $this->evaluator->evaluate($expr, $context);
}
} elseif (str_starts_with($expr, '$core_settings:')) {
$key = substr($expr, strlen('$core_settings:'));
- $resolved[$name] = (string) g7_core_settings($key, '');
+ $resolved[$name] = $this->resolveLocalizedValue(g7_core_settings($key, ''));
} elseif (str_starts_with($expr, '$query:')) {
$key = substr($expr, strlen('$query:'));
- $resolved[$name] = (string) request()->query($key, '');
+ $resolved[$name] = $this->resolveLocalizedValue(request()->query($key, ''));
} else {
$resolved[$name] = $this->evaluator->evaluate($expr, $context);
}
@@ -856,8 +979,8 @@ class SeoRenderer implements SeoRendererInterface
$resolved = match ($source) {
'setting' => $this->resolveSettingVar($extType, $extId, $key),
- 'core_setting' => (string) g7_core_settings($key, ''),
- 'query' => (string) request()->query($key, ''),
+ 'core_setting' => $this->resolveLocalizedValue(g7_core_settings($key, '')),
+ 'query' => $this->resolveLocalizedValue(request()->query($key, '')),
'route' => (string) ($routeParams[$key] ?? ''),
'data' => $resolvedVars[$varName] ?? '',
default => '',
@@ -891,8 +1014,9 @@ class SeoRenderer implements SeoRendererInterface
*/
private function applySettingsTemplate(string $extType, string $extId, string $pageType, array $vars, array &$context): void
{
- $titleTemplate = (string) ($this->getExtensionSetting($extType, $extId, "seo.meta_{$pageType}_title") ?? '');
- $descTemplate = (string) ($this->getExtensionSetting($extType, $extId, "seo.meta_{$pageType}_description") ?? '');
+ // 다국어 JSON array 설정값 안전 변환 — 다국어 입력 환경에서 회귀 방지
+ $titleTemplate = $this->resolveLocalizedValue($this->getExtensionSetting($extType, $extId, "seo.meta_{$pageType}_title"));
+ $descTemplate = $this->resolveLocalizedValue($this->getExtensionSetting($extType, $extId, "seo.meta_{$pageType}_description"));
$title = $this->substituteVars($titleTemplate, $vars);
$description = $this->substituteVars($descTemplate, $vars);
@@ -915,7 +1039,7 @@ class SeoRenderer implements SeoRendererInterface
*/
private function resolveSettingVar(string $extType, string $extId, string $key): string
{
- return (string) $this->getExtensionSetting($extType, $extId, $key);
+ return $this->resolveLocalizedValue($this->getExtensionSetting($extType, $extId, $key));
}
/**
@@ -954,20 +1078,100 @@ class SeoRenderer implements SeoRendererInterface
}
/**
- * 템플릿 문자열 내 {변수명} 플레이스홀더를 치환합니다.
+ * 두 OG 데이터 배열을 병합합니다 (확장 declaration 누적용).
*
- * @param string $template 템플릿 문자열 (예: "{commerce_name} - {product_name}")
- * @param array $vars 변수 맵 (키 → 값)
- * @return string 치환된 문자열
+ * 후속 데이터의 비어있지 않은 키만 덮어쓰기. extra 배열은 concat.
+ *
+ * @param array $base 기존 데이터
+ * @param array $additions 추가 데이터
+ * @return array 병합 결과
*/
- private function substituteVars(string $template, array $vars): string
+ private function mergeOgData(array $base, array $additions): array
{
- if ($template === '') {
- return '';
+ foreach ($additions as $key => $value) {
+ if ($key === 'extra' && is_array($value)) {
+ $base['extra'] = array_merge((array) ($base['extra'] ?? []), $value);
+ continue;
+ }
+ if ($value === null || $value === '') {
+ continue;
+ }
+ $base[$key] = $value;
}
- return (string) preg_replace_callback('/\{(\w+)\}/', function ($matches) use ($vars) {
- return $vars[$matches[1]] ?? $matches[0];
- }, $template);
+ return $base;
+ }
+
+ /**
+ * 두 Twitter 데이터 배열을 병합합니다.
+ */
+ private function mergeTwitterData(array $base, array $additions): array
+ {
+ return $this->mergeOgData($base, $additions);
+ }
+
+ /**
+ * target 배열의 비어있는 키를 source 값으로 채웁니다 (target 우선).
+ *
+ * 모듈 declaration 을 fallback 으로 적용할 때 사용 — 레이아웃 override 가 우선.
+ * 정수 0 / int 값은 비어있지 않은 것으로 간주.
+ *
+ * @param array $target 채울 대상 (레이아웃 결과)
+ * @param array $source fallback 소스 (모듈 declaration)
+ * @return array
+ */
+ private function fillEmptyKeys(array $target, array $source): array
+ {
+ foreach ($source as $key => $value) {
+ if ($key === 'extra' && is_array($value)) {
+ $target['extra'] = array_merge($value, (array) ($target['extra'] ?? []));
+ continue;
+ }
+ $current = $target[$key] ?? null;
+ $isEmpty = ($current === null || $current === '' || $current === []);
+ if ($isEmpty && $value !== null && $value !== '' && $value !== []) {
+ $target[$key] = $value;
+ }
+ }
+
+ return $target;
+ }
+
+ /**
+ * 확장 declaration 메서드를 안전하게 호출.
+ *
+ * 모듈/플러그인의 seoOgDefaults / seoTwitterDefaults / seoStructuredData 가 throw 해도
+ * 전체 SEO 렌더 파이프라인을 죽이지 않도록 try/catch 로 격리.
+ * throw 시 빈 배열 반환 + 경고 로그 — 한 확장 회귀가 SPA fallback 으로 이어지는 회귀 차단.
+ *
+ * @param object $instance 확장 인스턴스 (Module/Plugin)
+ * @param string $method 메서드명
+ * @param array $args 메서드 인자
+ * @param string $extType 로깅용 확장 타입
+ * @param string $extId 로깅용 확장 식별자
+ * @return array 메서드 결과 또는 빈 배열
+ */
+ private function safeInvokeExtensionMethod(
+ object $instance,
+ string $method,
+ array $args,
+ string $extType,
+ string $extId,
+ ): array {
+ try {
+ $result = $instance->{$method}(...$args);
+
+ return is_array($result) ? $result : [];
+ } catch (\Throwable $e) {
+ Log::warning("[SEO] {$extType} {$extId}::{$method}() threw — declaration 무시, SEO 부분 누락", [
+ 'extension' => $extId,
+ 'method' => $method,
+ 'error' => $e->getMessage(),
+ 'file' => $e->getFile(),
+ 'line' => $e->getLine(),
+ ]);
+
+ return [];
+ }
}
}
diff --git a/app/Seo/SitemapManager.php b/app/Seo/SitemapManager.php
new file mode 100644
index 00000000..10a34530
--- /dev/null
+++ b/app/Seo/SitemapManager.php
@@ -0,0 +1,112 @@
+}
+ * status: 'updated' | 'disabled' | 'failed'
+ */
+ public function regenerate(): array
+ {
+ $enabled = (bool) g7_core_settings('seo.sitemap_enabled', true);
+ if (! $enabled) {
+ return [
+ 'success' => false,
+ 'status' => 'disabled',
+ 'message' => 'Sitemap 생성이 비활성화되어 있습니다.',
+ ];
+ }
+
+ HookManager::doAction('core.seo.sitemap.before_regenerate');
+
+ try {
+ $xml = $this->generator->generate();
+ $ttl = (int) g7_core_settings('cache.seo_sitemap_ttl', g7_core_settings('seo.sitemap_cache_ttl', 86400));
+ $this->cache->put('seo.sitemap', $xml, $ttl);
+
+ $lastUpdatedAt = now()->toIso8601String();
+ $this->updateLastUpdatedAt($lastUpdatedAt);
+
+ $result = [
+ 'success' => true,
+ 'status' => 'updated',
+ 'message' => 'Sitemap 생성이 완료되었습니다.',
+ 'data' => [
+ 'last_updated_at' => $lastUpdatedAt,
+ 'size_bytes' => strlen($xml),
+ 'ttl' => $ttl,
+ ],
+ ];
+
+ HookManager::doAction('core.seo.sitemap.after_regenerate', $result);
+
+ return $result;
+ } catch (\Throwable $e) {
+ Log::error('[SEO] Sitemap regeneration failed', [
+ 'error' => $e->getMessage(),
+ ]);
+
+ $result = [
+ 'success' => false,
+ 'status' => 'failed',
+ 'message' => 'Sitemap 생성에 실패했습니다: '.$e->getMessage(),
+ ];
+
+ HookManager::doAction('core.seo.sitemap.after_regenerate_failed', $result);
+
+ return $result;
+ }
+ }
+
+ /**
+ * 현재 sitemap 의 메타데이터를 반환합니다.
+ *
+ * @return array{last_updated_at: ?string}
+ */
+ public function getStatus(): array
+ {
+ $lastUpdatedAt = (string) g7_core_settings('seo.sitemap_last_updated_at', '');
+
+ return [
+ 'last_updated_at' => $lastUpdatedAt !== '' ? $lastUpdatedAt : null,
+ ];
+ }
+
+ /**
+ * settings 의 seo 카테고리에 sitemap_last_updated_at 을 기록합니다.
+ *
+ * @param string $iso8601 ISO8601 형식 타임스탬프
+ */
+ private function updateLastUpdatedAt(string $iso8601): void
+ {
+ try {
+ $current = $this->configRepository->getCategory('seo');
+ $current['sitemap_last_updated_at'] = $iso8601;
+ $this->configRepository->saveCategory('seo', $current);
+ } catch (\Throwable $e) {
+ Log::warning('Sitemap last_updated_at 갱신 실패', ['error' => $e->getMessage()]);
+ }
+ }
+}
diff --git a/app/Services/AttachmentService.php b/app/Services/AttachmentService.php
index b348ae88..3ac265c0 100644
--- a/app/Services/AttachmentService.php
+++ b/app/Services/AttachmentService.php
@@ -235,6 +235,9 @@ class AttachmentService
// → 미매핑 시 모든 사용자 허용
HookManager::checkHookPermission('core.attachment.download', $user);
+ // 액션 훅 - IDV 정책 가드 지점 (filter 훅과 병행)
+ HookManager::doAction('core.attachment.before_download_action', $attachment, $user);
+
// 필터 훅 - 다운로드 전 처리 (다운로드 카운트 증가 등)
$attachment = HookManager::applyFilters('core.attachment.before_download', $attachment, $user);
diff --git a/app/Services/AuthService.php b/app/Services/AuthService.php
index ad3211f1..0e1d2b8e 100644
--- a/app/Services/AuthService.php
+++ b/app/Services/AuthService.php
@@ -23,7 +23,8 @@ class AuthService
private UserRepositoryInterface $userRepository,
private RoleRepositoryInterface $roleRepository,
private UserConsentRepositoryInterface $userConsentRepository,
- private PasswordResetTokenRepositoryInterface $passwordResetTokenRepository
+ private PasswordResetTokenRepositoryInterface $passwordResetTokenRepository,
+ private IdentityPolicyService $policyService,
) {}
/**
@@ -63,17 +64,9 @@ class AuthService
return $matches[2];
}
- // 언어 코드만 있는 경우 (ko, en, ja 등) → 기본 국가 매핑
+ // 언어 코드만 있는 경우 (ko, en, ja 등) → 기본 국가 매핑 (config 기반)
if (preg_match('/^([a-z]{2})/', $acceptLanguage, $matches)) {
- $languageToCountry = [
- 'ko' => 'KR',
- 'en' => 'US',
- 'ja' => 'JP',
- 'zh' => 'CN',
- 'de' => 'DE',
- 'fr' => 'FR',
- 'es' => 'ES',
- ];
+ $languageToCountry = config('app.locale_country_fallback', []);
return $languageToCountry[$matches[1]] ?? null;
}
@@ -84,15 +77,43 @@ class AuthService
/**
* 사용자를 로그인시키고 인증 토큰을 발급합니다.
*
+ * 보안 환경설정 `security.login_attempt_enabled` 가 켜져 있으면
+ * `Auth::attempt()` 직전에 계정 잠금 상태를 검사합니다. 실제 카운트
+ * 증감/리셋은 Laravel 의 `Auth\Events\Failed` / `Auth\Events\Login`
+ * 이벤트를 구독하는 Listener (`HandleFailedLoginListener` /
+ * `HandleSuccessfulLoginListener`) 가 Repository 를 통해 처리합니다.
+ *
* @param string $email 사용자 이메일
* @param string $password 사용자 비밀번호
* @return array 사용자 정보와 토큰을 포함한 배열
*
* @throws ValidationException 인증 정보가 올바르지 않을 때
+ * @throws \App\Exceptions\Auth\AccountLockedException 계정이 잠겨 있을 때
*/
public function login(string $email, string $password): array
{
+ // 사전 잠금 체크 — 잠긴 계정은 Auth::attempt 자체를 시도하지 않는다.
+ // (실패 카운트가 0 으로 리셋된 잠금 상태에서 Failed 이벤트가 다시
+ // 카운트를 올려 재잠금 시각을 갱신하는 부작용 방지)
+ if ((bool) g7_core_settings('security.login_attempt_enabled', true)) {
+ $candidate = $this->userRepository->findByEmail($email);
+ if ($candidate !== null && $this->userRepository->isLocked($candidate)) {
+ $remaining = max(1, (int) ceil(now()->diffInSeconds($candidate->locked_until, false) / 60));
+ throw new \App\Exceptions\Auth\AccountLockedException(
+ lockedUntil: $candidate->locked_until,
+ remainingMinutes: $remaining,
+ );
+ }
+ }
+
if (! Auth::attempt(['email' => $email, 'password' => $password])) {
+ // 실패 카운트 증가/잠금 처리는 HandleFailedLoginListener 에서 담당
+ HookManager::doAction('core.auth.login_failed', $email, [
+ 'ip_address' => request()->ip(),
+ 'user_agent' => request()->userAgent(),
+ 'attempted_at' => now(),
+ ]);
+
throw ValidationException::withMessages([
'email' => [__('auth.invalid_credentials')],
]);
@@ -142,13 +163,33 @@ class AuthService
/**
* 새로운 사용자를 등록하고 인증 토큰을 발급합니다.
*
- * @param array $data 사용자 등록 데이터
- * @return array 사용자 정보와 토큰을 포함한 배열
+ * 가입 단계 정책 매칭:
+ * - core.auth.signup_before_submit (route): RegisterRequest 검증 단계에서 평가
+ * - core.auth.signup_after_create (hook): 가입 직후 PendingVerification 으로 둘지 결정
+ *
+ * @param array $data RegisterRequest 가 검증한 가입 데이터
+ * @return array{user: User, token: string, token_type: string} 사용자 + 토큰
*/
public function register(array $data): array
{
$now = now();
+ // 사전 검증 훅: AssertIdentityVerifiedBeforeRegister 가 정책 기반으로 verification_token 검증
+ HookManager::doAction('core.auth.before_register', $data, [
+ 'signup_stage' => 'before_submit',
+ 'http_method' => 'POST',
+ ]);
+
+ // signup_after_create 정책 매칭 시 PendingVerification, 아니면 Active
+ $modeCPolicy = $this->policyService->resolve(
+ scope: 'hook',
+ target: 'core.auth.after_register',
+ context: ['signup_stage' => 'after_create'],
+ );
+ $status = ($modeCPolicy && $modeCPolicy->enabled)
+ ? UserStatus::PendingVerification->value
+ : UserStatus::Active->value;
+
$userData = [
'name' => $data['name'],
'nickname' => $data['nickname'] ?? null,
@@ -157,6 +198,7 @@ class AuthService
'language' => $data['language'] ?? 'ko',
'country' => $this->detectCountryFromAcceptLanguage(),
'ip_address' => request()->ip(),
+ 'status' => $status,
];
$user = $this->userRepository->create($userData);
@@ -172,11 +214,14 @@ class AuthService
$token = $user->createToken('auth-token', ['*'], $this->getTokenExpiresAt())->plainTextToken;
- // Hook 발생 (회원가입 완료) — 알림 발송은 NotificationHookListener가 처리
+ // Hook 발생 (회원가입 완료) — 알림 발송은 NotificationHookListener,
+ // signup_after_create 정책이 enabled 면 InitiateIdentityChallengeAfterRegister 가 challenge 발행.
HookManager::doAction('core.auth.after_register', $user, [
'registration_time' => now(),
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
+ 'signup_stage' => 'after_create',
+ 'verification_token' => $data['verification_token'] ?? null,
]);
return [
@@ -459,6 +504,8 @@ class AuthService
}
// 플러그인 확장 동의 처리 (마케팅 등 추가 동의)
- HookManager::doAction('core.auth.record_consents', $user, $data, $agreedAt, $ip);
+ // HookArgumentSerializer 는 Carbon 을 직렬화하지 못해 Queue 실행 시 null 로 대체되므로
+ // 미리 ISO8601 문자열로 변환해 listener 시그니처(string)와 일치시킵니다.
+ HookManager::doAction('core.auth.record_consents', $user, $data, $agreedAt->toIso8601String(), $ip);
}
}
diff --git a/app/Services/CoreUpdateService.php b/app/Services/CoreUpdateService.php
index a1a75850..7a0e2634 100644
--- a/app/Services/CoreUpdateService.php
+++ b/app/Services/CoreUpdateService.php
@@ -5,6 +5,7 @@ namespace App\Services;
use App\Contracts\Extension\UpgradeStepInterface;
use App\Enums\ExtensionOwnerType;
use App\Enums\PermissionType;
+use App\Exceptions\CoreUpdateOperationException;
use App\Extension\CoreVersionChecker;
use App\Extension\Helpers\ChangelogParser;
use App\Extension\Helpers\CoreBackupHelper;
@@ -18,6 +19,9 @@ use App\Extension\Vendor\VendorInstallContext;
use App\Extension\Vendor\VendorInstallResult;
use App\Extension\Vendor\VendorMode;
use App\Extension\Vendor\VendorResolver;
+use Database\Seeders\IdentityMessageDefinitionSeeder;
+use Database\Seeders\IdentityPolicySeeder;
+use Database\Seeders\NotificationDefinitionSeeder;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Artisan;
@@ -25,10 +29,209 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
class CoreUpdateService
{
+ /**
+ * 마지막 `restoreOwnership()` 실행에서 누적된 권한 정상화 경고.
+ *
+ * 각 항목은 `['target' => string, 'kind' => 'chown'|'group_writable', 'failed' => int, 'failed_paths' => string[]]`.
+ * 콘솔/로그가 운영자에게 즉시 노출하기 위한 side-channel — 호출 후 `getLastPermissionWarnings()` 로 조회.
+ *
+ * @var array}>
+ */
+ protected array $lastPermissionWarnings = [];
+
+ /**
+ * 마지막 restoreOwnership 호출에서 발생한 권한 정상화 경고를 조회합니다.
+ *
+ * @return array}>
+ */
+ public function getLastPermissionWarnings(): array
+ {
+ return $this->lastPermissionWarnings;
+ }
+
+ /**
+ * 쓰기 권한이 필요한 디렉토리를 멱등적으로 보장합니다.
+ *
+ * 코어 업그레이드 spawn 자식이 fresh 코드/config 환경에서 진입 직후 1회 호출하여
+ * 활성 디렉토리의 ownership / 그룹 쓰기 권한을 정합 상태로 강제. 미래 release 가
+ * 새 쓰기 권한 디렉토리를 도입할 때 본 메서드 호출만으로 자동 처리되며, 해당 release
+ * 의 upgrade step 에 mkdir/chown 코드를 하드코딩할 필요 없음.
+ *
+ * 처리:
+ * - 디렉토리 부재 시 modules/ → plugins/ → templates/ → base_path() stat 기준으로 mkdir
+ * - chown by extension reference owner/group (chownRecursiveDetailed)
+ * - 강제 g+w 모드 (syncGroupWritabilityDetailed force=true) — sudo 결함으로 root 가
+ * 0755 로 생성한 케이스에서 silent no-op 되던 결함 차단
+ *
+ * 한계: spawn 자식이 fresh 디스크 config 를 읽는 시점에만 작동. 부모(이전 버전) 만
+ * 알고 있던 신규 디렉토리는 처리 못 함 — 그런 일회성 케이스는 해당 release 의 upgrade
+ * step 이 단발 처리 (예: beta.3→beta.4 의 lang-packs/* 보정).
+ *
+ * @param array $relativePaths base_path() 기준 상대 경로 배열
+ * @param callable|object|null $logger Monolog 호환 logger ($logger->info(...)) 또는 callable($level, $message)
+ * @return array{
+ * reference: array{owner:int|false, group:int|false, perms:int|null, source:string},
+ * processed: array,
+ * warnings: array,
+ * }
+ */
+ public function ensureWritableDirectories(array $relativePaths, $logger = null): array
+ {
+ $reference = $this->resolveExtensionDirReferenceStat();
+ $owner = $reference['owner'];
+ $group = $reference['group'];
+ $perms = $reference['perms'] ?? 0775;
+
+ if ($owner === false) {
+ [$owner, $group, $_] = FilePermissionHelper::inferWebServerOwnership();
+ }
+
+ $log = function (string $level, string $message) use ($logger): void {
+ if ($logger === null) {
+ Log::$level($message);
+
+ return;
+ }
+ if (is_callable($logger)) {
+ $logger($level, $message);
+
+ return;
+ }
+ if (is_object($logger) && method_exists($logger, $level)) {
+ $logger->$level($message);
+
+ return;
+ }
+ Log::$level($message);
+ };
+
+ $log('info', sprintf(
+ '[ensureWritableDirectories] 기준 — owner=%s group=%s perms=%s source=%s',
+ $owner === false ? 'unresolved' : (string) $owner,
+ $group === false ? 'unresolved' : (string) $group,
+ sprintf('0%o', $perms),
+ $reference['source'],
+ ));
+
+ $processed = [];
+ $warnings = [];
+
+ foreach ($relativePaths as $relative) {
+ $relative = trim((string) $relative);
+ if ($relative === '') {
+ continue;
+ }
+ $path = base_path($relative);
+ $entry = ['path' => $relative, 'mkdir' => false, 'chown_changed' => 0, 'chown_failed' => 0, 'gw_changed' => 0, 'gw_failed' => 0];
+
+ if (! File::isDirectory($path)) {
+ if (! @mkdir($path, $perms, true) && ! File::isDirectory($path)) {
+ $msg = sprintf('[ensureWritableDirectories] mkdir 실패 — %s', $relative);
+ $log('warning', $msg);
+ $warnings[] = $msg;
+ $processed[] = $entry;
+
+ continue;
+ }
+ @chmod($path, $perms);
+ $entry['mkdir'] = true;
+ $log('info', sprintf('[ensureWritableDirectories] mkdir + perms 0%o — %s', $perms, $relative));
+ }
+
+ if ($owner !== false) {
+ $report = FilePermissionHelper::chownRecursiveDetailed($path, $owner, $group);
+ $entry['chown_changed'] = $report['changed'];
+ $entry['chown_failed'] = $report['failed'];
+ if ($report['failed'] > 0) {
+ $msg = sprintf(
+ '[ensureWritableDirectories] chown 실패 %d 건 (%s) — 첫 실패: %s. 수동: sudo chown -R %d:%d %s',
+ $report['failed'],
+ $relative,
+ $report['failed_paths'][0] ?? '?',
+ $owner,
+ (int) ($group ?: 0),
+ $path,
+ );
+ $log('warning', $msg);
+ $warnings[] = $msg;
+ } elseif ($report['changed'] > 0) {
+ $log('info', sprintf('[ensureWritableDirectories] chown changed=%d — %s', $report['changed'], $relative));
+ }
+ }
+
+ // perms 정상화 (루트가 g-w 등으로 생성됐을 때 reference perms 로 강제)
+ $currentPerms = @fileperms($path) & 0777;
+ if ($currentPerms !== false && $currentPerms !== $perms) {
+ if (! @chmod($path, $perms)) {
+ $msg = sprintf('[ensureWritableDirectories] chmod 실패 — %s. 수동: sudo chmod %o %s', $relative, $perms, $path);
+ $log('warning', $msg);
+ $warnings[] = $msg;
+ } else {
+ $log('info', sprintf('[ensureWritableDirectories] perms 보정 %o → %o — %s', $currentPerms, $perms, $relative));
+ }
+ }
+
+ // force=true: 루트 g-w 정책 보존 우회 (sudo 결함 보정)
+ $gwReport = FilePermissionHelper::syncGroupWritabilityDetailed($path, true);
+ $entry['gw_changed'] = $gwReport['changed'];
+ $entry['gw_failed'] = $gwReport['failed'];
+ if ($gwReport['failed'] > 0) {
+ $msg = sprintf('[ensureWritableDirectories] g+w 실패 %d 건 — %s', $gwReport['failed'], $relative);
+ $log('warning', $msg);
+ $warnings[] = $msg;
+ } elseif ($gwReport['changed'] > 0) {
+ $log('info', sprintf('[ensureWritableDirectories] g+w changed=%d — %s', $gwReport['changed'], $relative));
+ }
+
+ $processed[] = $entry;
+ }
+
+ return ['reference' => $reference, 'processed' => $processed, 'warnings' => $warnings];
+ }
+
+ /**
+ * 확장 디렉토리(modules/ → plugins/ → templates/ → base_path()) 의 stat 을 권한 기준으로 반환합니다.
+ *
+ * @return array{owner:int|false, group:int|false, perms:int|null, source:string}
+ */
+ public function resolveExtensionDirReferenceStat(): array
+ {
+ foreach (['modules', 'plugins', 'templates'] as $dir) {
+ $path = base_path($dir);
+ if (! File::isDirectory($path)) {
+ continue;
+ }
+ $stat = @stat($path);
+ if (! is_array($stat)) {
+ continue;
+ }
+
+ return [
+ 'owner' => (int) $stat['uid'],
+ 'group' => (int) $stat['gid'],
+ 'perms' => ($stat['mode'] & 0777) ?: null,
+ 'source' => $dir,
+ ];
+ }
+
+ $stat = @stat(base_path());
+ if (! is_array($stat)) {
+ return ['owner' => false, 'group' => false, 'perms' => null, 'source' => 'unresolved'];
+ }
+
+ return [
+ 'owner' => (int) $stat['uid'],
+ 'group' => (int) $stat['gid'],
+ 'perms' => ($stat['mode'] & 0777) ?: null,
+ 'source' => 'base_path',
+ ];
+ }
+
/**
* GitHub API에서 최신 코어 릴리스를 확인합니다.
*
@@ -350,7 +553,7 @@ class CoreUpdateService
$githubUrl = config('app.update.github_url');
if (! preg_match('#github\.com[/:]([^/]+)/([^/\.]+)#', $githubUrl, $matches)) {
- throw new \RuntimeException(__('settings.core_update.invalid_github_url'));
+ throw new CoreUpdateOperationException('settings.core_update.invalid_github_url');
}
$owner = $matches[1];
@@ -400,7 +603,7 @@ class CoreUpdateService
// GitHub 아카이브는 owner-repo-hash/ 형태로 압축해제됨
$extractedDirs = File::directories($extractDir);
if (empty($extractedDirs)) {
- throw new \RuntimeException(__('settings.core_update.extract_empty'));
+ throw new CoreUpdateOperationException('settings.core_update.extract_empty');
}
$sourcePath = $extractedDirs[0];
@@ -433,10 +636,10 @@ class CoreUpdateService
}
// 모든 전략 실패
- throw new \RuntimeException(
- __('settings.core_update.all_extract_methods_failed'),
- 0,
- $lastError
+ throw new CoreUpdateOperationException(
+ 'settings.core_update.all_extract_methods_failed',
+ [],
+ $lastError,
);
}
@@ -480,7 +683,7 @@ class CoreUpdateService
{
$zip = new \ZipArchive;
if ($zip->open($zipPath) !== true) {
- throw new \RuntimeException(__('settings.core_update.zip_extract_failed'));
+ throw new CoreUpdateOperationException('settings.core_update.zip_extract_failed');
}
$zip->extractTo($extractDir);
@@ -501,10 +704,10 @@ class CoreUpdateService
exec("unzip -o {$escapedZip} -d {$escapedDir} 2>&1", $output, $exitCode);
if ($exitCode !== 0) {
- throw new \RuntimeException(__('settings.core_update.unzip_command_failed', [
+ throw new CoreUpdateOperationException('settings.core_update.unzip_command_failed', [
'code' => $exitCode,
'output' => implode("\n", array_slice($output, -5)),
- ]));
+ ]);
}
}
@@ -523,27 +726,27 @@ class CoreUpdateService
*
* @param string $pendingPath 검증할 경로
*
- * @throws \RuntimeException 검증 실패 시
+ * @throws CoreUpdateOperationException 검증 실패 시
*/
public function validatePendingUpdate(string $pendingPath): void
{
if (! File::exists($pendingPath.DIRECTORY_SEPARATOR.'composer.json')) {
- throw new \RuntimeException(__('settings.core_update.invalid_package'));
+ throw new CoreUpdateOperationException('settings.core_update.invalid_package');
}
if (! File::isDirectory($pendingPath.DIRECTORY_SEPARATOR.'app')) {
- throw new \RuntimeException(__('settings.core_update.invalid_package'));
+ throw new CoreUpdateOperationException('settings.core_update.invalid_package');
}
// 그누보드7 프로젝트인지 확인 (config/app.php의 version 키 존재 여부)
$configPath = $pendingPath.DIRECTORY_SEPARATOR.'config'.DIRECTORY_SEPARATOR.'app.php';
if (! File::exists($configPath)) {
- throw new \RuntimeException(__('settings.core_update.invalid_package_not_g7'));
+ throw new CoreUpdateOperationException('settings.core_update.invalid_package_not_g7');
}
$config = include $configPath;
if (! is_array($config) || ! isset($config['version'])) {
- throw new \RuntimeException(__('settings.core_update.invalid_package_not_g7'));
+ throw new CoreUpdateOperationException('settings.core_update.invalid_package_not_g7');
}
}
@@ -582,12 +785,12 @@ class CoreUpdateService
* @param \Closure|null $onProgress 진행 콜백
* @return string _pending 내 추출된 소스 경로 (래퍼 감지 후)
*
- * @throws \RuntimeException ZIP 미존재 / 추출 실패 / 패키지 검증 실패 시
+ * @throws CoreUpdateOperationException ZIP 미존재 / 추출 실패 / 패키지 검증 실패 시
*/
public function extractZipToPending(string $zipPath, ?\Closure $onProgress = null): string
{
if (! File::exists($zipPath)) {
- throw new \RuntimeException(__('settings.core_update.zip_file_not_found', ['path' => $zipPath]));
+ throw new CoreUpdateOperationException('settings.core_update.zip_file_not_found', ['path' => $zipPath]);
}
$pendingPath = $this->createPendingDirectory();
@@ -596,7 +799,7 @@ class CoreUpdateService
$strategies = $this->buildExtractionStrategies();
if (empty($strategies)) {
- throw new \RuntimeException(__('settings.core_update.no_extract_method_available'));
+ throw new CoreUpdateOperationException('settings.core_update.no_extract_method_available');
}
$lastError = null;
@@ -630,10 +833,10 @@ class CoreUpdateService
}
}
- throw new \RuntimeException(
- __('settings.core_update.all_extract_methods_failed'),
- 0,
- $lastError
+ throw new CoreUpdateOperationException(
+ 'settings.core_update.all_extract_methods_failed',
+ [],
+ $lastError,
);
}
@@ -692,6 +895,8 @@ class CoreUpdateService
$targets = config('app.update.targets', []);
$excludes = config('app.update.excludes', []);
+ $applied = [];
+
foreach ($targets as $target) {
$src = $sourcePath.DIRECTORY_SEPARATOR.$target;
$dest = base_path($target);
@@ -708,7 +913,102 @@ class CoreUpdateService
File::ensureDirectoryExists(dirname($dest));
FilePermissionHelper::copyFile($src, $dest);
}
+
+ $applied[$this->normalizeRelativePath($target)] = true;
}
+
+ // 자동 발견 폴백 — 부모 프로세스(구버전) 의 stale `app.update.targets` 가
+ // 신버전이 도입한 최상위 디렉토리(예: beta.4 의 `lang-packs/`) 를 인식하지 못하는
+ // 결함을 안전망으로 차단한다.
+ //
+ // source 디렉토리의 최상위 항목 중 targets 에 누락되었고 PROTECTED 에도 포함되지
+ // 않은 항목은 동일 흐름으로 복사하며 경고 로그를 남긴다. 다음 코어 업데이트의
+ // applyUpdate 는 이미 디스크에 반영된 신버전 config 의 targets 로 정상 처리한다.
+ $this->applyDiscoveredTopLevelPaths($sourcePath, $applied, $excludes, $onProgress);
+ }
+
+ /**
+ * source 디렉토리의 최상위 항목 중 targets allowlist 에 누락된 신규 항목을 자동으로 적용합니다.
+ *
+ * @param string $sourcePath _pending 추출 소스 루트
+ * @param array $applied 이미 처리된 normalize 된 상대 경로 맵
+ * @param array $excludes copyDirectory 내부 제외 목록
+ * @param \Closure|null $onProgress 진행 콜백
+ */
+ private function applyDiscoveredTopLevelPaths(string $sourcePath, array $applied, array $excludes, ?\Closure $onProgress): void
+ {
+ if (! File::isDirectory($sourcePath)) {
+ return;
+ }
+
+ $protected = array_flip(array_map([$this, 'normalizeRelativePath'], (array) config('app.update.protected_paths', [])));
+ $userExcludes = array_flip(array_map([$this, 'normalizeRelativePath'], $excludes));
+
+ $entries = array_merge(File::directories($sourcePath), File::files($sourcePath));
+
+ foreach ($entries as $absPath) {
+ $name = basename($absPath);
+ $normalized = $this->normalizeRelativePath($name);
+
+ if ($name === '.' || $name === '..') {
+ continue;
+ }
+ if (isset($applied[$normalized])) {
+ continue;
+ }
+ // 부모 디렉토리 단위로 이미 적용된 경우 (예: targets 에 'modules/_bundled' 가 있고 source 에 'modules' 디렉토리만 보일 때)
+ // 자식 디렉토리를 통째로 다시 복사하지 않도록 검사
+ if ($this->isCoveredByApplied($normalized, $applied)) {
+ continue;
+ }
+ if (isset($protected[$normalized])) {
+ continue;
+ }
+ if (isset($userExcludes[$normalized])) {
+ continue;
+ }
+
+ $dest = base_path($name);
+ Log::warning('[core-update] targets allowlist 누락 신규 항목 자동 적용', [
+ 'name' => $name,
+ 'reason' => 'parent process targets list did not include this path; falling back to source auto-discovery',
+ ]);
+ $onProgress?->__invoke('apply', $name.' (auto)');
+
+ if (File::isDirectory($absPath)) {
+ FilePermissionHelper::copyDirectory($absPath, $dest, $onProgress, $excludes, removeOrphans: true);
+ } else {
+ File::ensureDirectoryExists(dirname($dest));
+ FilePermissionHelper::copyFile($absPath, $dest);
+ }
+ }
+ }
+
+ /**
+ * 상대 경로를 normalize 합니다 (선행/후행 슬래시 제거 + DIRECTORY_SEPARATOR 통일).
+ */
+ private function normalizeRelativePath(string $path): string
+ {
+ return trim(str_replace(['\\', '/'], '/', $path), '/');
+ }
+
+ /**
+ * 정규화된 path 가 이미 처리된 상위 path 의 하위 항목인지 검사합니다.
+ *
+ * @param array $applied
+ */
+ private function isCoveredByApplied(string $path, array $applied): bool
+ {
+ $segments = explode('/', $path);
+ $accumulated = '';
+ foreach ($segments as $segment) {
+ $accumulated = $accumulated === '' ? $segment : $accumulated.'/'.$segment;
+ if (isset($applied[$accumulated])) {
+ return true;
+ }
+ }
+
+ return false;
}
/**
@@ -717,7 +1017,7 @@ class CoreUpdateService
* @param string $pendingPath _pending 내 소스 경로
* @param \Closure|null $onProgress 진행 콜백
*
- * @throws \RuntimeException 실행 실패 시
+ * @throws CoreUpdateOperationException 실행 실패 시
*/
public function runComposerInstallInPending(string $pendingPath, ?\Closure $onProgress = null): void
{
@@ -738,8 +1038,9 @@ class CoreUpdateService
* @param string $pendingPath _pending 내 소스 경로
* @param VendorMode $mode 요청된 vendor 설치 모드
* @param \Closure|null $onProgress 진행 콜백
+ * @return VendorInstallResult 설치 결과 컨텍스트 (vendor 경로/모드 포함)
*
- * @throws \RuntimeException 실행 실패 시
+ * @throws CoreUpdateOperationException 실행 실패 시
*/
public function runVendorInstallInPending(
string $pendingPath,
@@ -827,7 +1128,7 @@ class CoreUpdateService
*
* @param \Closure|null $onProgress 진행 콜백
*
- * @throws \RuntimeException 실행 실패 시
+ * @throws CoreUpdateOperationException 실행 실패 시
*/
public function runComposerInstall(?\Closure $onProgress = null): void
{
@@ -843,7 +1144,7 @@ class CoreUpdateService
* @param string $pendingPath _pending 또는 소스 디렉토리 경로
* @param \Closure|null $onProgress 진행 콜백
*
- * @throws \RuntimeException vendor 디렉토리가 없을 경우
+ * @throws CoreUpdateOperationException vendor 디렉토리가 없을 경우
*/
public function copyVendorFromPending(string $pendingPath, ?\Closure $onProgress = null): void
{
@@ -851,7 +1152,7 @@ class CoreUpdateService
$destVendor = base_path('vendor');
if (! File::isDirectory($sourceVendor)) {
- throw new \RuntimeException('소스 디렉토리에 vendor가 없습니다. composer install이 실행되지 않았을 수 있습니다.');
+ throw new CoreUpdateOperationException('settings.core_update.source_vendor_missing');
}
$onProgress?->__invoke('vendor', 'vendor 디렉토리 복사 중...');
@@ -876,7 +1177,7 @@ class CoreUpdateService
* @param \Closure|null $onProgress 진행 콜백
* @param bool $noScripts post-autoload-dump 등 스크립트 건너뛰기 (_pending용)
*
- * @throws \RuntimeException 실행 실패 시
+ * @throws CoreUpdateOperationException 실행 실패 시
*/
protected function executeComposerInstall(string $workingDir, ?\Closure $onProgress = null, bool $noScripts = false): void
{
@@ -909,7 +1210,7 @@ class CoreUpdateService
$process = proc_open($command, $descriptors, $pipes, $workingDir);
if (! is_resource($process)) {
- throw new \RuntimeException(__('settings.core_update.composer_failed'));
+ throw new CoreUpdateOperationException('settings.core_update.composer_failed');
}
fclose($pipes[0]);
@@ -926,7 +1227,7 @@ class CoreUpdateService
]);
if ($exitCode !== 0) {
- throw new \RuntimeException(__('settings.core_update.composer_failed')."\n".$output);
+ throw new CoreUpdateOperationException('settings.core_update.composer_failed_with_output', ['output' => "\n".$output]);
}
}
@@ -1132,16 +1433,32 @@ class CoreUpdateService
}
/**
- * 디스크의 config/core.php 를 재로드하고 코어 권한/메뉴를 재동기화합니다.
+ * 디스크의 config/core.php 를 재로드하고 config-driven 코어 도메인 데이터를 재동기화합니다.
*
* Laravel 은 프로세스 시작 시점에 로드한 config 를 재로드하지 않으므로,
* 업데이트로 config/core.php 가 교체되어도 현재 프로세스의 `config('core.*')`
* 는 이전 값을 반환한다. 본 메서드는 디스크 값을 다시 require 하여 Config
- * Repository 에 주입한 뒤 syncCoreRolesAndPermissions/syncCoreMenus 를
- * 재호출하여 신규 권한·메뉴를 DB 에 반영한다.
+ * Repository 에 주입한 뒤 다음 도메인 동기화를 일괄 수행한다:
*
- * 주 사용처: CoreUpdateCommand Step 10 에서 별도 프로세스 spawn 이
- * 실패했을 때의 in-process fallback. 수동 복구 도구로도 사용 가능.
+ * - syncCoreRolesAndPermissions / syncCoreMenus ← config('core.permissions|roles|menus')
+ * - NotificationDefinitionSeeder ← config('core.notification_definitions')
+ * - IdentityPolicySeeder ← config('core.identity_policies')
+ * - IdentityMessageDefinitionSeeder ← config('core.identity_messages')
+ *
+ * 모든 시더는 멱등 upsert + user_overrides 보존 패턴이라 정상 환경에서 재실행해도 무해.
+ * 각 도메인은 독립 try/catch + Log::warning 으로 격리 — 한 도메인 실패가 다른 도메인을
+ * 막지 않는다. 각 시더는 호출 전 테이블 존재 가드로 마이그레이션 미실행 환경에서도 안전.
+ *
+ * 주 사용처:
+ * 1. CoreUpdateCommand Step 9 의 정상 경로 — applyUpdate(Step 7) 로 디스크가 교체된 직후
+ * 부모 프로세스가 fresh config 로 sync 를 수행하기 위한 표준 진입점. syncCoreRolesAndPermissions
+ * / syncCoreMenus 직접 호출 금지 — 부모 메모리의 stale config 로 sync 가 돌면 신규
+ * 권한·메뉴가 누락된다 (회귀 차단은 audit 룰 `core-update-command-direct-sync` 가 자동 적발).
+ * 2. CoreUpdateCommand Step 10 의 spawn fallback — proc_open 실패 시 in-process 로
+ * upgrade step 실행 후 config 재주입 + 재동기화.
+ * 3. 코어 upgrade step 의 박제 보정 호출 — 이전 버전 CoreUpdateCommand 의 stale 부모
+ * sync 결함을 spawn 자식의 fresh config 로 사후 보정. 멱등이라 정상 환경 무해.
+ * 4. 수동 복구 도구 — 운영자가 코어 권한·메뉴·알림·IDV 정의 누락 의심 시 호출.
*
* ⚠ 경로 A(beta.1 → beta.2) 에서는 직접 호출 금지. beta.1 메모리에는
* 본 메서드가 존재하지 않으므로 Fatal 발생. 해당 경로의 upgrade step 은
@@ -1176,6 +1493,30 @@ class CoreUpdateService
} catch (\Throwable $e) {
Log::warning('reloadCoreConfigAndResync: 메뉴 재동기화 실패', ['error' => $e->getMessage()]);
}
+
+ try {
+ if (Schema::hasTable('notification_definitions')) {
+ (new NotificationDefinitionSeeder())->run();
+ }
+ } catch (\Throwable $e) {
+ Log::warning('reloadCoreConfigAndResync: 알림 정의 재시딩 실패', ['error' => $e->getMessage()]);
+ }
+
+ try {
+ if (Schema::hasTable('identity_policies')) {
+ (new IdentityPolicySeeder())->run();
+ }
+ } catch (\Throwable $e) {
+ Log::warning('reloadCoreConfigAndResync: IDV 정책 재시딩 실패', ['error' => $e->getMessage()]);
+ }
+
+ try {
+ if (Schema::hasTable('identity_message_definitions') && Schema::hasTable('identity_message_templates')) {
+ (new IdentityMessageDefinitionSeeder())->run();
+ }
+ } catch (\Throwable $e) {
+ Log::warning('reloadCoreConfigAndResync: IDV 메시지 정의 재시딩 실패', ['error' => $e->getMessage()]);
+ }
}
/**
@@ -1421,6 +1762,16 @@ class CoreUpdateService
// 4. 확장 오토로드 재생성 (코어 업데이트로 _bundled 변경 가능)
Artisan::call('extension:update-autoload');
+
+ // 5. 디스크 상태 캐시 + opcache 초기화.
+ // Step 7 applyUpdate 가 신규 lang 파일 (예: beta.4 의 `lang/ko/identity.php`) 을
+ // 디스크에 깔아도, 부모 프로세스의 PHP file-stat 캐시 / opcache 가 그 파일을
+ // "부재" 로 캐싱한 상태일 수 있음. 후속 `__()` 호출이 raw key 를 반환하여
+ // 관리자 UI 에 i18n 키 그대로 노출되는 회귀 차단.
+ clearstatcache(true);
+ if (function_exists('opcache_reset')) {
+ @opcache_reset();
+ }
}
/**
@@ -1469,6 +1820,7 @@ class CoreUpdateService
}
$results = [];
+ // audit:allow service-direct-data-access reason: 3개 확장 테이블(modules/plugins/templates)을 동적 테이블명으로 일괄 스캔하는 generic 업데이트 감지 헬퍼. Repository 별 타입 분리 시 동일 로직이 3중 복제되며 manifest 비교 분기를 분산시켜 회귀 위험 증가
foreach (DB::table($tableAndDir)->get(['identifier', 'version']) as $record) {
$identifier = (string) $record->identifier;
$current = (string) $record->version;
@@ -1551,6 +1903,126 @@ class CoreUpdateService
return $snapshot;
}
+ /**
+ * 좁힌 영역의 항목별 owner/group/perms 를 재귀 스냅샷합니다 (Stage 4 정합화).
+ *
+ * `snapshotOwnership()` 가 target 의 **루트만** stat 하는 한계를 보완. PHP-FPM 쓰기
+ * 영역(`storage/logs`, `storage/framework`, `storage/app/core_pending`, `bootstrap/cache`)
+ * 처럼 좁고 항목 수가 적은 영역에 한해 재귀 스냅샷 후 `restoreOwnership` 의 detailed
+ * 인자로 전달하면 항목별 정확 복원이 가능하다.
+ *
+ * 결과 형식: `[$absolutePath => ['owner', 'group', 'perms', 'is_dir', 'is_link']]`
+ * 키는 절대 경로 (base_path 적용 후 또는 입력이 이미 절대면 그대로).
+ *
+ * 안전 한계:
+ * - 50,000 항목 초과 시 warning 로그 후 첫 50,000 만 직렬화 (스레드 폭주 방어)
+ * - chown 미지원 환경(Windows 등) 은 빈 배열 반환
+ * - symbolic link 는 lstat 으로 처리하여 대상 따라가지 않음 (은닉 cycle 방어)
+ *
+ * @param array $paths base_path 상대 또는 절대 경로 목록
+ * @return array
+ */
+ public function snapshotOwnershipDetailed(array $paths): array
+ {
+ if (! function_exists('chown')) {
+ return [];
+ }
+
+ $snapshot = [];
+ $maxItems = 50000;
+ $truncated = false;
+
+ foreach ($paths as $rawPath) {
+ $rawPath = trim((string) $rawPath);
+ if ($rawPath === '') {
+ continue;
+ }
+
+ $absolute = $this->resolveAbsolutePath($rawPath);
+ if (! File::exists($absolute) && ! is_link($absolute)) {
+ continue;
+ }
+
+ $this->collectStatRecursively($absolute, $snapshot, $maxItems, $truncated);
+
+ if ($truncated) {
+ break;
+ }
+ }
+
+ if ($truncated) {
+ Log::warning('snapshotOwnershipDetailed: 50000 항목 초과 — 첫 50000 항목만 스냅샷', [
+ 'collected' => count($snapshot),
+ 'paths' => $paths,
+ ]);
+ }
+
+ return $snapshot;
+ }
+
+ /**
+ * 입력 경로를 절대 경로로 정규화합니다.
+ *
+ * 절대 경로(/ 또는 Windows 드라이브) 는 그대로, 상대 경로는 base_path 적용.
+ */
+ private function resolveAbsolutePath(string $path): string
+ {
+ if ($path === '') {
+ return base_path();
+ }
+ if ($path[0] === '/' || $path[0] === DIRECTORY_SEPARATOR) {
+ return $path;
+ }
+ // Windows 드라이브 접두사 (예: C:\ 또는 C:/)
+ if (strlen($path) >= 2 && ctype_alpha($path[0]) && $path[1] === ':') {
+ return $path;
+ }
+
+ return base_path($path);
+ }
+
+ /**
+ * 트리를 재귀 stat 하여 snapshot 배열에 누적합니다.
+ *
+ * @param array $snapshot
+ */
+ private function collectStatRecursively(string $path, array &$snapshot, int $maxItems, bool &$truncated): void
+ {
+ if ($truncated || count($snapshot) >= $maxItems) {
+ $truncated = true;
+
+ return;
+ }
+
+ $isLink = is_link($path);
+ // symbolic link 는 lstat — 대상 추적 금지
+ $stat = $isLink ? @lstat($path) : @stat($path);
+ if (! is_array($stat)) {
+ return;
+ }
+
+ $snapshot[$path] = [
+ 'owner' => isset($stat['uid']) ? (int) $stat['uid'] : false,
+ 'group' => isset($stat['gid']) ? (int) $stat['gid'] : false,
+ 'perms' => isset($stat['mode']) ? ($stat['mode'] & 0777) : null,
+ 'is_dir' => is_dir($path) && ! $isLink,
+ 'is_link' => $isLink,
+ ];
+
+ // symbolic link 는 대상 추적 금지 + 디렉토리만 재귀
+ if ($isLink || ! is_dir($path)) {
+ return;
+ }
+
+ $items = new \FilesystemIterator($path, \FilesystemIterator::SKIP_DOTS);
+ foreach ($items as $item) {
+ $this->collectStatRecursively($item->getPathname(), $snapshot, $maxItems, $truncated);
+ if ($truncated) {
+ return;
+ }
+ }
+ }
+
/**
* 업데이트 경로의 소유권을 스냅샷 기준으로 복원합니다.
*
@@ -1568,12 +2040,22 @@ class CoreUpdateService
* - @chown/@chgrp suppress 로 권한 부족 시 silent fail
* - 대상 경로 목록은 config('app.update.restore_ownership') 기준
*
+ * Stage 4 (`$detailedSnapshot` 인자) — 항목별 정확 복원:
+ * - `snapshotOwnershipDetailed()` 결과를 전달하면 좁힌 영역의 owner/group/perms 를
+ * 항목별로 정확 복원 (디렉토리 traversal 비트 손실 같은 회귀 차단)
+ * - 빈 배열이면 기존 chownRecursive 만 동작 (호환성 유지)
+ * - 두 메커니즘은 독립 — `$snapshot` 에는 거시 chown 대상, `$detailedSnapshot` 에는
+ * PHP-FPM 쓰기 영역의 정확 복원 대상을 따로 전달
+ *
* @param array $snapshot snapshotOwnership() 결과
* @param \Closure|null $onProgress 진행 콜백
+ * @param array $detailedSnapshot snapshotOwnershipDetailed() 결과 (선택)
* @return void
*/
- public function restoreOwnership(array $snapshot, ?\Closure $onProgress = null): void
+ public function restoreOwnership(array $snapshot, ?\Closure $onProgress = null, array $detailedSnapshot = []): void
{
+ $this->lastPermissionWarnings = [];
+
if (! function_exists('chown')) {
return;
}
@@ -1614,18 +2096,35 @@ class CoreUpdateService
}
$onProgress?->__invoke('ownership', $target);
- $changed = FilePermissionHelper::chownRecursive($path, $owner, $group);
+ // 트랙 2-A — `.preserve-ownership` 마커가 있는 서브트리(사용자 데이터 영역) 자동 skip.
+ // ModuleStorageDriver/PluginStorageDriver 가 자동 작성하는 마커로 시드 시점 owner 영구 보존.
+ $report = FilePermissionHelper::chownRecursiveDetailed($path, $owner, $group, respectPreservationMarker: true);
- if ($changed > 0) {
+ if ($report['changed'] > 0) {
Log::info('코어 업데이트: 소유권 복원', [
'target' => $target,
'owner' => $owner,
'group' => $group,
'source' => $source,
- 'changed_entries' => $changed,
+ 'changed_entries' => $report['changed'],
]);
- $restoredCount += $changed;
+ $restoredCount += $report['changed'];
}
+
+ if ($report['failed'] > 0) {
+ $this->lastPermissionWarnings[] = [
+ 'target' => $target,
+ 'kind' => 'chown',
+ 'failed' => $report['failed'],
+ 'failed_paths' => $report['failed_paths'],
+ ];
+ }
+ }
+
+ // Stage 4 — detailed snapshot 기반 항목별 정확 복원 (chown + chgrp + chmod).
+ // 좁힌 영역(PHP-FPM 쓰기 경로)의 owner/group/perms 를 원본과 100% 일치 복원.
+ if (! empty($detailedSnapshot)) {
+ $this->restoreFromDetailedSnapshot($detailedSnapshot, $onProgress);
}
// 7.0.0-beta.3+: Laravel 런타임 쓰기 경로(storage/, bootstrap/cache/) 에 한해
@@ -1647,7 +2146,17 @@ class CoreUpdateService
}
$onProgress?->__invoke('group_writable', $target);
- $groupWritableChanged += FilePermissionHelper::syncGroupWritability($path);
+ $report = FilePermissionHelper::syncGroupWritabilityDetailed($path);
+ $groupWritableChanged += $report['changed'];
+
+ if ($report['failed'] > 0) {
+ $this->lastPermissionWarnings[] = [
+ 'target' => $target,
+ 'kind' => 'group_writable',
+ 'failed' => $report['failed'],
+ 'failed_paths' => $report['failed_paths'],
+ ];
+ }
}
if ($groupWritableChanged > 0) {
@@ -1665,6 +2174,121 @@ class CoreUpdateService
}
}
+ /**
+ * `snapshotOwnershipDetailed()` 결과를 항목별로 정확 복원합니다.
+ *
+ * 동작:
+ * - 각 항목의 현재 stat 을 읽어 snapshot 과 비교
+ * - owner/group/perms 가 다르면 해당 비트만 변경 (chown/chgrp/chmod)
+ * - symbolic link 는 lchown 시도 (없으면 skip), perms 무변경
+ * - silent fail — 권한 부족·chmod 미지원 환경에서도 예외 미발생
+ * - 실패 항목 누적 → `lastPermissionWarnings` 에 'kind' => 'detailed' 로 기록
+ *
+ * @param array $detailedSnapshot
+ * @param \Closure|null $onProgress
+ */
+ private function restoreFromDetailedSnapshot(array $detailedSnapshot, ?\Closure $onProgress = null): void
+ {
+ $changed = 0;
+ $failed = 0;
+ $failedPaths = [];
+ $supportsLchown = function_exists('lchown');
+ $supportsLchgrp = function_exists('lchgrp');
+
+ foreach ($detailedSnapshot as $absolutePath => $meta) {
+ if (! file_exists($absolutePath) && ! is_link($absolutePath)) {
+ continue;
+ }
+
+ $isLink = $meta['is_link'] ?? is_link($absolutePath);
+ $targetOwner = $meta['owner'] ?? false;
+ $targetGroup = $meta['group'] ?? false;
+ $targetPerms = $meta['perms'] ?? null;
+ $itemFailed = false;
+
+ // owner 복원
+ if ($targetOwner !== false) {
+ $currentOwner = $isLink ? @lstat($absolutePath)['uid'] ?? false : @fileowner($absolutePath);
+ if ($currentOwner !== false && $currentOwner !== $targetOwner) {
+ if ($isLink) {
+ if ($supportsLchown && @lchown($absolutePath, $targetOwner)) {
+ $changed++;
+ } else {
+ $itemFailed = true;
+ }
+ } else {
+ if (@chown($absolutePath, $targetOwner)) {
+ $changed++;
+ } else {
+ $itemFailed = true;
+ }
+ }
+ }
+ }
+
+ // group 복원
+ if ($targetGroup !== false) {
+ $currentGroup = $isLink ? @lstat($absolutePath)['gid'] ?? false : @filegroup($absolutePath);
+ if ($currentGroup !== false && $currentGroup !== $targetGroup) {
+ if ($isLink) {
+ if ($supportsLchgrp && @lchgrp($absolutePath, $targetGroup)) {
+ $changed++;
+ } else {
+ $itemFailed = true;
+ }
+ } else {
+ if (@chgrp($absolutePath, $targetGroup)) {
+ $changed++;
+ } else {
+ $itemFailed = true;
+ }
+ }
+ }
+ }
+
+ // perms 복원 — symbolic link 는 perms 무변경 (대부분 OS 가 link perms 무시)
+ if (! $isLink && $targetPerms !== null) {
+ $currentPerms = @fileperms($absolutePath);
+ if ($currentPerms !== false && ($currentPerms & 0777) !== $targetPerms) {
+ if (@chmod($absolutePath, $targetPerms)) {
+ $changed++;
+ } else {
+ $itemFailed = true;
+ }
+ }
+ }
+
+ if ($itemFailed) {
+ $failed++;
+ if (count($failedPaths) < 50) {
+ $failedPaths[] = $absolutePath;
+ }
+ }
+
+ $onProgress?->__invoke('detailed_restore', $absolutePath);
+ }
+
+ if ($changed > 0) {
+ Log::info('코어 업데이트: 항목별 정확 복원 완료', [
+ 'snapshot_items' => count($detailedSnapshot),
+ 'changed_attributes' => $changed,
+ ]);
+ }
+
+ if ($failed > 0) {
+ $this->lastPermissionWarnings[] = [
+ 'target' => 'detailed_snapshot',
+ 'kind' => 'detailed',
+ 'failed' => $failed,
+ 'failed_paths' => $failedPaths,
+ ];
+ Log::warning('코어 업데이트: 항목별 복원 부분 실패', [
+ 'failed_count' => $failed,
+ 'first_failed' => $failedPaths[0] ?? null,
+ ]);
+ }
+ }
+
/**
* 업데이트 실패 리포트를 생성합니다.
*
diff --git a/app/Services/DashboardService.php b/app/Services/DashboardService.php
index fefc90b3..defece33 100644
--- a/app/Services/DashboardService.php
+++ b/app/Services/DashboardService.php
@@ -9,7 +9,6 @@ use App\Contracts\Repositories\UserRepositoryInterface;
use App\Extension\HookManager;
use App\Helpers\TimezoneHelper;
use App\Models\ActivityLog;
-use Carbon\Carbon;
/**
* 대시보드 서비스
@@ -32,6 +31,9 @@ class DashboardService
*/
public function getStats(): array
{
+ // 훅: 대시보드 통계 조회 전 (IDV 정책 가드 지점)
+ HookManager::doAction('core.dashboard.before_stats');
+
$stats = [
'total_users' => $this->getUserStats(),
'installed_modules' => $this->getModuleStats(),
@@ -148,6 +150,9 @@ class DashboardService
*/
public function getSystemResources(): array
{
+ // IDV 정책 가드 지점 (계획서 #297 — 민감 관리자 조회 훅 커버리지)
+ HookManager::doAction('core.dashboard.before_resources');
+
$resources = [
'cpu' => $this->getCpuUsage(),
'memory' => $this->getMemoryUsage(),
@@ -433,30 +438,6 @@ class DashboardService
*/
public function getSystemAlerts(): array
{
- // TODO: 실제 알림 시스템 연동 예정
- // 현재는 더미 데이터 반환
- $alerts = [
- [
- 'id' => 1,
- 'type' => 'info',
- 'icon' => 'info-circle',
- 'title' => __('dashboard.alerts.system_update_available'),
- 'message' => __('dashboard.alerts.system_update_message'),
- 'time' => TimezoneHelper::toUserCarbon(Carbon::now()->subHours(2))?->diffForHumans(),
- 'read' => false,
- ],
- [
- 'id' => 2,
- 'type' => 'warning',
- 'icon' => 'exclamation-triangle',
- 'title' => __('dashboard.alerts.disk_space_low'),
- 'message' => __('dashboard.alerts.disk_space_message'),
- 'time' => TimezoneHelper::toUserCarbon(Carbon::now()->subDay())?->diffForHumans(),
- 'read' => true,
- ],
- ];
-
- // 훅을 통한 알림 확장 지원
- return HookManager::applyFilters('core.dashboard.alerts', $alerts);
+ return HookManager::applyFilters('core.dashboard.alerts', []);
}
}
diff --git a/app/Services/DriverRegistryService.php b/app/Services/DriverRegistryService.php
index 3929ee98..36cc9a11 100644
--- a/app/Services/DriverRegistryService.php
+++ b/app/Services/DriverRegistryService.php
@@ -4,6 +4,7 @@ namespace App\Services;
use App\Extension\HookManager;
use App\Repositories\JsonConfigRepository;
+use Illuminate\Support\Facades\Lang;
use Illuminate\Support\Facades\Log;
/**
@@ -16,41 +17,21 @@ use Illuminate\Support\Facades\Log;
class DriverRegistryService
{
/**
- * 카테고리별 코어 드라이버 목록
+ * 카테고리별 코어 드라이버 ID 목록
*
- * @var array>
+ * 라벨은 활성 translatable_locales 별로 lang/{locale}/settings.php 의
+ * 'drivers.{category}.{id}' 키에서 동적 조회됩니다.
+ *
+ * @var array>
*/
- private const CORE_DRIVERS = [
- 'storage' => [
- ['id' => 'local', 'label' => ['ko' => '로컬', 'en' => 'Local']],
- ['id' => 's3', 'label' => ['ko' => 'Amazon S3', 'en' => 'Amazon S3']],
- ],
- 'cache' => [
- ['id' => 'file', 'label' => ['ko' => '파일', 'en' => 'File']],
- ['id' => 'redis', 'label' => ['ko' => 'Redis', 'en' => 'Redis']],
- ],
- 'session' => [
- ['id' => 'file', 'label' => ['ko' => '파일', 'en' => 'File']],
- ['id' => 'database', 'label' => ['ko' => '데이터베이스', 'en' => 'Database']],
- ['id' => 'redis', 'label' => ['ko' => 'Redis', 'en' => 'Redis']],
- ],
- 'queue' => [
- ['id' => 'sync', 'label' => ['ko' => '동기', 'en' => 'Sync']],
- ['id' => 'database', 'label' => ['ko' => '데이터베이스', 'en' => 'Database']],
- ['id' => 'redis', 'label' => ['ko' => 'Redis', 'en' => 'Redis']],
- ],
- 'log' => [
- ['id' => 'single', 'label' => ['ko' => '단일 파일', 'en' => 'Single File']],
- ['id' => 'daily', 'label' => ['ko' => '일별 파일', 'en' => 'Daily File']],
- ],
- 'websocket' => [
- ['id' => 'reverb', 'label' => ['ko' => 'Laravel Reverb', 'en' => 'Laravel Reverb']],
- ],
- 'mail' => [
- ['id' => 'smtp', 'label' => ['ko' => 'SMTP', 'en' => 'SMTP']],
- ['id' => 'mailgun', 'label' => ['ko' => 'Mailgun', 'en' => 'Mailgun']],
- ['id' => 'ses', 'label' => ['ko' => 'SES (Amazon)', 'en' => 'SES (Amazon)']],
- ],
+ private const CORE_DRIVER_IDS = [
+ 'storage' => ['local', 's3'],
+ 'cache' => ['file', 'redis'],
+ 'session' => ['file', 'database', 'redis'],
+ 'queue' => ['sync', 'database', 'redis'],
+ 'log' => ['single', 'daily'],
+ 'websocket' => ['reverb'],
+ 'mail' => ['smtp', 'mailgun', 'ses'],
];
/**
@@ -112,13 +93,15 @@ class DriverRegistryService
* 특정 카테고리의 사용 가능한 드라이버 목록을 반환합니다.
*
* 코어 드라이버 + 플러그인 필터 훅으로 추가된 드라이버를 병합합니다.
+ * 라벨은 활성 translatable_locales 전 로케일별로 lang/{locale}/settings.php 의
+ * 'drivers.{category}.{id}' 키에서 조회되어 JSON 으로 반환됩니다.
*
* @param string $category 드라이버 카테고리 (storage, cache, session, queue, log, websocket, mail)
- * @return array 사용 가능한 드라이버 배열
+ * @return array, provider?: string}> 사용 가능한 드라이버 배열
*/
public function getAvailableDrivers(string $category): array
{
- $coreDrivers = self::CORE_DRIVERS[$category] ?? [];
+ $coreDrivers = $this->buildCoreDrivers($category);
$hookName = self::HOOK_PREFIX.$category.self::HOOK_SUFFIX;
@@ -128,19 +111,45 @@ class DriverRegistryService
/**
* 모든 카테고리의 사용 가능한 드라이버 목록을 반환합니다.
*
- * @return array>
+ * @return array, provider?: string}>>
*/
public function getAllAvailableDrivers(): array
{
$result = [];
- foreach (array_keys(self::CORE_DRIVERS) as $category) {
+ foreach (array_keys(self::CORE_DRIVER_IDS) as $category) {
$result[$category] = $this->getAvailableDrivers($category);
}
return $result;
}
+ /**
+ * 카테고리의 코어 드라이버 배열을 동적으로 빌드합니다.
+ *
+ * 활성 translatable_locales 전 로케일에 대해 lang/{locale}/settings.drivers.{category}.{id}
+ * 키를 조회하여 JSON 라벨을 구성합니다. 로케일별 키가 없으면 ID 자체로 폴백합니다.
+ *
+ * @param string $category 드라이버 카테고리
+ * @return array}>
+ */
+ private function buildCoreDrivers(string $category): array
+ {
+ $ids = self::CORE_DRIVER_IDS[$category] ?? [];
+ $locales = config('app.translatable_locales', ['ko', 'en']);
+
+ $drivers = [];
+ foreach ($ids as $id) {
+ $label = [];
+ foreach ($locales as $locale) {
+ $label[$locale] = Lang::get("settings.drivers.{$category}.{$id}", [], $locale) ?: $id;
+ }
+ $drivers[] = ['id' => $id, 'label' => $label];
+ }
+
+ return $drivers;
+ }
+
/**
* 주어진 드라이버가 코어 드라이버인지 확인합니다.
*
@@ -150,15 +159,7 @@ class DriverRegistryService
*/
public function isCoreDriver(string $category, string $driverId): bool
{
- $coreDrivers = self::CORE_DRIVERS[$category] ?? [];
-
- foreach ($coreDrivers as $driver) {
- if ($driver['id'] === $driverId) {
- return true;
- }
- }
-
- return false;
+ return in_array($driverId, self::CORE_DRIVER_IDS[$category] ?? [], true);
}
/**
@@ -243,7 +244,7 @@ class DriverRegistryService
*/
public function getCategories(): array
{
- return array_keys(self::CORE_DRIVERS);
+ return array_keys(self::CORE_DRIVER_IDS);
}
/**
diff --git a/app/Services/Extension/ExtensionInstallPreviewBuilder.php b/app/Services/Extension/ExtensionInstallPreviewBuilder.php
new file mode 100644
index 00000000..718478b7
--- /dev/null
+++ b/app/Services/Extension/ExtensionInstallPreviewBuilder.php
@@ -0,0 +1,201 @@
+ {target, dependencies[], language_packs[]}
+ *
+ * @throws RuntimeException 대상 확장을 찾을 수 없을 때
+ */
+ public function build(LanguagePackScope $scope, string $identifier): array
+ {
+ $info = $this->resolveExtensionInfo($scope, $identifier);
+ if (! $info) {
+ throw new RuntimeException(__('extensions.errors.not_found', ['identifier' => $identifier]));
+ }
+
+ // 모듈/플러그인은 ModuleService/PluginService 가 이미 enriched 형태(평면 배열)로 반환하지만,
+ // 템플릿은 TemplateService 가 manifest 의 raw `{modules, plugins}` shape 를 그대로 반환한다
+ // (TemplateResource 가 raw 를 기대해서 바꾸기 어려움). 여기서 템플릿 한정으로 enrich.
+ $rawDependencies = $info['dependencies'] ?? [];
+ if ($scope === LanguagePackScope::Template
+ && (isset($rawDependencies['modules']) || isset($rawDependencies['plugins']))) {
+ $rawDependencies = DependencyEnricher::enrich($rawDependencies);
+ }
+ $dependencies = $this->buildDependencies($rawDependencies);
+ $languagePacks = $this->buildLanguagePacks($scope, $identifier, $dependencies);
+
+ return [
+ 'target' => [
+ 'identifier' => $info['identifier'] ?? $identifier,
+ 'name' => $info['name'] ?? null,
+ 'version' => $info['version'] ?? null,
+ ],
+ 'dependencies' => $dependencies,
+ 'language_packs' => $languagePacks,
+ ];
+ }
+
+ /**
+ * 스코프별 Service 에 위임하여 대상 확장 메타데이터를 조회합니다.
+ *
+ * @param LanguagePackScope $scope
+ * @param string $identifier
+ * @return array|null
+ */
+ private function resolveExtensionInfo(LanguagePackScope $scope, string $identifier): ?array
+ {
+ return match ($scope) {
+ LanguagePackScope::Module => $this->moduleService->getModuleInfo($identifier),
+ LanguagePackScope::Plugin => $this->pluginService->getPluginInfo($identifier),
+ LanguagePackScope::Template => $this->templateService->getTemplateInfo($identifier),
+ default => null,
+ };
+ }
+
+ /**
+ * 의존성 enrichment 결과를 cascade 선택 UI 용 메타로 변환합니다.
+ *
+ * `DependencyEnricher` 가 생산하는 enriched 항목(identifier/name/type/required_version/
+ * installed_version/is_active/is_met) 를 받아 `is_installed`, `default_selected`,
+ * `available` 필드를 추가합니다.
+ *
+ * @param array> $enriched enriched 의존성 목록
+ * @return array>
+ */
+ private function buildDependencies(array $enriched): array
+ {
+ $result = [];
+ foreach ($enriched as $dep) {
+ $isInstalled = ! empty($dep['installed_version']);
+ $isMet = (bool) ($dep['is_met'] ?? false);
+
+ $result[] = [
+ 'type' => $dep['type'] ?? 'module',
+ 'identifier' => $dep['identifier'] ?? '',
+ 'name' => $dep['name'] ?? null,
+ 'required_version' => $dep['required_version'] ?? null,
+ 'installed_version' => $dep['installed_version'] ?? null,
+ 'is_installed' => $isInstalled,
+ 'is_active' => (bool) ($dep['is_active'] ?? false),
+ 'is_met' => $isMet,
+ // 미충족 의존성만 cascade 후보 — 충족 의존성은 추가 설치 불필요
+ 'available' => ! $isMet,
+ // 미충족 + 미설치 항목은 기본 선택 (체크리스트 prefill)
+ 'default_selected' => ! $isMet && ! $isInstalled,
+ ];
+ }
+
+ return $result;
+ }
+
+ /**
+ * 본 확장 + 의존 확장에 귀속된 미설치 번들 언어팩 후보를 수집합니다.
+ *
+ * `lang-packs/_bundled/{identifier}/language-pack.json` manifest 를 직접 스캔하여
+ * (a) 본 확장용 (b) 의존 확장용 항목을 모두 포함합니다. DB 에 이미 설치된 슬롯은
+ * 제외 (LanguagePackService::getUninstalledBundledPacks 의 슬롯 머지 로직 활용).
+ *
+ * @param LanguagePackScope $scope 본 확장 스코프
+ * @param string $identifier 본 확장 식별자
+ * @param array> $dependencies 의존성 메타
+ * @return array>
+ */
+ private function buildLanguagePacks(LanguagePackScope $scope, string $identifier, array $dependencies): array
+ {
+ $bundledRoot = base_path('lang-packs/_bundled');
+ if (! File::isDirectory($bundledRoot)) {
+ return [];
+ }
+
+ $allUninstalled = $this->languagePackService->getUninstalledBundledPacks([]);
+
+ $depIndex = [];
+ foreach ($dependencies as $dep) {
+ $depIndex[$dep['identifier']] = $dep['type'];
+ }
+
+ $result = [];
+ foreach ($allUninstalled as $pack) {
+ $packScope = $pack->scope;
+ $packTarget = $pack->target_identifier;
+
+ $matchesSelf = $packScope === $scope->value && $packTarget === $identifier;
+ $matchesDep = $packTarget !== null
+ && isset($depIndex[$packTarget])
+ && $packScope === $depIndex[$packTarget];
+
+ if (! $matchesSelf && ! $matchesDep) {
+ continue;
+ }
+
+ $dependsOn = $matchesSelf ? null : $packTarget;
+
+ $result[] = [
+ 'bundled_identifier' => $pack->identifier,
+ 'locale' => $pack->locale,
+ 'locale_native_name' => $pack->locale_native_name,
+ 'locale_name' => $pack->locale_name,
+ 'version' => $pack->version,
+ 'depends_on_extension' => $dependsOn,
+ 'available' => true,
+ 'default_selected' => true,
+ ];
+ }
+
+ // 정렬: 자기 확장 우선 → 의존성 식별자 → locale
+ usort($result, function (array $a, array $b) {
+ $selfA = $a['depends_on_extension'] === null ? 0 : 1;
+ $selfB = $b['depends_on_extension'] === null ? 0 : 1;
+ if ($selfA !== $selfB) {
+ return $selfA <=> $selfB;
+ }
+ $depCmp = strcmp((string) $a['depends_on_extension'], (string) $b['depends_on_extension']);
+ if ($depCmp !== 0) {
+ return $depCmp;
+ }
+
+ return strcmp((string) $a['locale'], (string) $b['locale']);
+ });
+
+ return $result;
+ }
+}
diff --git a/app/Services/ExtensionCompatibilityAlertService.php b/app/Services/ExtensionCompatibilityAlertService.php
new file mode 100644
index 00000000..2ef7c96d
--- /dev/null
+++ b/app/Services/ExtensionCompatibilityAlertService.php
@@ -0,0 +1,99 @@
+ dismiss 된 alertId 목록
+ */
+ public function getDismissedAlertIds(?int $userId): array
+ {
+ if (! $userId) {
+ return [];
+ }
+
+ $cached = $this->cache->get(self::DISMISS_CACHE_PREFIX.$userId, []);
+
+ if (! is_array($cached)) {
+ return [];
+ }
+
+ return array_values(array_filter($cached, 'is_string'));
+ }
+
+ /**
+ * 알림을 사용자별로 dismiss 합니다.
+ *
+ * 자동 비활성화 알림 (DB 영속) 은 다른 관리자에게는 계속 표시되고,
+ * 재호환 알림 (캐시 기반) 은 캐시 만료/감지 갱신 시 재노출됩니다.
+ *
+ * @param string $alertId 알림 ID (compat_{type}_{identifier} 또는 recover_{type}_{identifier})
+ * @param int|null $userId 사용자 ID (null/0 이면 무시)
+ */
+ public function dismissAlert(string $alertId, ?int $userId): void
+ {
+ if (! $userId) {
+ return;
+ }
+
+ $key = self::DISMISS_CACHE_PREFIX.$userId;
+ $dismissed = $this->cache->get($key, []);
+
+ if (! is_array($dismissed)) {
+ $dismissed = [];
+ }
+
+ if (! in_array($alertId, $dismissed, true)) {
+ $dismissed[] = $alertId;
+ $this->cache->put($key, $dismissed, self::DISMISS_CACHE_TTL);
+ }
+ }
+
+ /**
+ * 컨테이너 바인딩 실패 시의 fallback 인스턴스 (CacheInterface 미바인딩 환경 보호).
+ *
+ * @return self
+ */
+ public static function fallback(): self
+ {
+ return new self(new CoreCacheDriver(config('cache.default', 'array')));
+ }
+}
diff --git a/app/Services/IdentityLogService.php b/app/Services/IdentityLogService.php
new file mode 100644
index 00000000..79675589
--- /dev/null
+++ b/app/Services/IdentityLogService.php
@@ -0,0 +1,55 @@
+ $filters 필터 (provider_id/purpose/status/user_id/date_from/date_to 등)
+ * @param int $perPage 페이지 크기
+ * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
+ */
+ public function search(array $filters, int $perPage = 20)
+ {
+ return $this->logRepository->search($filters, $perPage);
+ }
+
+ /**
+ * 보관주기 경과 로그를 파기합니다.
+ *
+ * @param int $days 보관 일수 (기본 180)
+ * @return int 삭제된 행 수
+ */
+ public function purge(int $days = 180): int
+ {
+ return $this->logRepository->purgeOlderThan(max(1, $days));
+ }
+
+ /**
+ * 만료 상태로 전환할 pending challenge 를 일괄 expire 처리합니다.
+ *
+ * @return int 처리된 행 수
+ */
+ public function expirePastDue(): int
+ {
+ return $this->logRepository->expirePastDue();
+ }
+}
diff --git a/app/Services/IdentityMessageDefinitionService.php b/app/Services/IdentityMessageDefinitionService.php
new file mode 100644
index 00000000..c099acd9
--- /dev/null
+++ b/app/Services/IdentityMessageDefinitionService.php
@@ -0,0 +1,287 @@
+cache->remember(
+ $this->getCacheKey($providerId, $scopeType, $scopeValue),
+ fn () => $this->repository->getActiveByScope($providerId, $scopeType, $scopeValue),
+ $this->getCacheTtl(),
+ [$this->cacheTag]
+ );
+ }
+
+ /**
+ * 모든 활성 정의 조회 (캐싱).
+ *
+ * @return Collection
+ */
+ public function getAllActive(): Collection
+ {
+ return $this->cache->remember(
+ $this->cachePrefix.'all_active',
+ fn () => $this->repository->getAllActive(),
+ $this->getCacheTtl(),
+ [$this->cacheTag]
+ );
+ }
+
+ /**
+ * 특정 확장의 정의 목록 조회.
+ *
+ * @param string $extensionType
+ * @param string $extensionIdentifier
+ * @return Collection
+ */
+ public function getByExtension(string $extensionType, string $extensionIdentifier): Collection
+ {
+ return $this->repository->getByExtension($extensionType, $extensionIdentifier);
+ }
+
+ /**
+ * 정의 수정.
+ *
+ * @param IdentityMessageDefinition $definition
+ * @param array $data
+ * @return IdentityMessageDefinition
+ */
+ public function updateDefinition(IdentityMessageDefinition $definition, array $data): IdentityMessageDefinition
+ {
+ HookManager::doAction('core.identity.message_definition.before_update', $definition, $data);
+
+ $data = HookManager::applyFilters(
+ 'core.identity.message_definition.filter_update_data',
+ $data,
+ $definition
+ );
+
+ $updated = $this->repository->update($definition, $data);
+
+ $this->invalidateAllCache();
+
+ HookManager::doAction('core.identity.message_definition.after_update', $updated, $data);
+
+ return $updated;
+ }
+
+ /**
+ * 활성/비활성 토글.
+ *
+ * @param IdentityMessageDefinition $definition
+ * @return IdentityMessageDefinition
+ */
+ public function toggleActive(IdentityMessageDefinition $definition): IdentityMessageDefinition
+ {
+ HookManager::doAction('core.identity.message_definition.before_toggle_active', $definition);
+
+ $updated = $this->repository->update($definition, [
+ 'is_active' => ! $definition->is_active,
+ ]);
+
+ $this->invalidateAllCache();
+
+ HookManager::doAction('core.identity.message_definition.after_toggle_active', $updated);
+
+ return $updated;
+ }
+
+ /**
+ * 운영자가 정책 매핑 메시지 정의를 신규 생성합니다.
+ *
+ * extension_type='core', extension_identifier='admin', is_default=false 강제.
+ * templates 항목별 자식 행을 같은 트랜잭션에서 생성합니다.
+ *
+ * @param array $data FormRequest validated payload (provider_id, scope_type, scope_value, name, description, channels, variables, templates[])
+ * @return IdentityMessageDefinition
+ */
+ public function createAdminDefinition(array $data): IdentityMessageDefinition
+ {
+ HookManager::doAction('core.identity.message_definition.before_create', $data);
+
+ $data = HookManager::applyFilters(
+ 'core.identity.message_definition.filter_create_data',
+ $data,
+ );
+
+ $definitionData = [
+ 'provider_id' => $data['provider_id'],
+ 'scope_type' => $data['scope_type'],
+ 'scope_value' => $data['scope_value'],
+ 'name' => $data['name'],
+ 'description' => $data['description'] ?? null,
+ 'channels' => $data['channels'],
+ 'variables' => $data['variables'] ?? [],
+ 'extension_type' => 'core',
+ 'extension_identifier' => 'admin',
+ 'is_active' => true,
+ 'is_default' => false,
+ ];
+
+ $templatesData = $data['templates'] ?? [];
+
+ $definition = DB::transaction(function () use ($definitionData, $templatesData) {
+ $definition = $this->repository->store($definitionData);
+
+ foreach ($templatesData as $templateData) {
+ $this->templateRepository->create([
+ 'definition_id' => $definition->id,
+ 'channel' => $templateData['channel'],
+ 'subject' => $templateData['subject'],
+ 'body' => $templateData['body'],
+ 'is_active' => true,
+ 'is_default' => false,
+ ]);
+ }
+
+ return $definition->fresh('templates');
+ });
+
+ $this->invalidateAllCache();
+
+ HookManager::doAction('core.identity.message_definition.after_create', $definition);
+
+ return $definition;
+ }
+
+ /**
+ * 운영자가 추가한 정책 매핑 메시지 정의를 삭제합니다.
+ *
+ * is_default=true 인 시드 정의는 삭제 거부 (Service 레벨 이중 가드).
+ * FK cascadeOnDelete 로 자식 templates 가 자동 정리됩니다.
+ *
+ * @param IdentityMessageDefinition $definition
+ * @return bool 삭제 성공 여부
+ *
+ * @throws RuntimeException is_default 정의 삭제 시도 시
+ */
+ public function deleteAdminDefinition(IdentityMessageDefinition $definition): bool
+ {
+ if ($definition->is_default) {
+ throw new RuntimeException('Cannot delete default (seeded) message definition.');
+ }
+
+ HookManager::doAction('core.identity.message_definition.before_delete', $definition);
+
+ $deleted = (bool) $definition->delete();
+
+ $this->invalidateAllCache();
+
+ HookManager::doAction('core.identity.message_definition.after_delete', $definition);
+
+ return $deleted;
+ }
+
+ /**
+ * 정의를 기본 상태로 마킹합니다 (모든 템플릿 리셋 후 호출).
+ *
+ * @param IdentityMessageDefinition $definition
+ * @return IdentityMessageDefinition
+ */
+ public function markAsDefault(IdentityMessageDefinition $definition): IdentityMessageDefinition
+ {
+ if ($definition->is_default) {
+ return $definition;
+ }
+
+ $updated = $this->repository->update($definition, ['is_default' => true]);
+
+ $this->invalidateAllCache();
+
+ return $updated;
+ }
+
+ /**
+ * 페이지네이션 목록 조회.
+ *
+ * @param array $filters
+ * @param int $perPage
+ * @return LengthAwarePaginator
+ */
+ public function getDefinitions(array $filters = [], int $perPage = 20): LengthAwarePaginator
+ {
+ return $this->repository->getPaginated($filters, $perPage);
+ }
+
+ /**
+ * 전체 캐시 무효화 (정의/템플릿 변경 시 자동 호출).
+ *
+ * @return void
+ */
+ public function invalidateAllCache(): void
+ {
+ $this->cache->flushTags([$this->cacheTag]);
+ }
+
+ /**
+ * 캐시 키 생성.
+ *
+ * @param string $providerId
+ * @param string $scopeType
+ * @param string|null $scopeValue
+ * @return string
+ */
+ private function getCacheKey(string $providerId, string $scopeType, ?string $scopeValue): string
+ {
+ return $this->cachePrefix.$providerId.'.'.$scopeType.'.'.($scopeValue ?? '');
+ }
+}
diff --git a/app/Services/IdentityMessageDispatcher.php b/app/Services/IdentityMessageDispatcher.php
new file mode 100644
index 00000000..1342d296
--- /dev/null
+++ b/app/Services/IdentityMessageDispatcher.php
@@ -0,0 +1,124 @@
+resolver->resolve($providerId, $purpose, $policyKey, $channel);
+
+ $hookContext = array_merge($context, [
+ 'provider_id' => $providerId,
+ 'purpose' => $purpose,
+ 'policy_key' => $policyKey,
+ 'render_hint' => $renderHint,
+ 'channel' => $channel,
+ 'target' => $target,
+ 'data' => $data,
+ ]);
+
+ if ($resolved === null) {
+ HookManager::doAction('core.identity.message.resolve_failed', $hookContext);
+ Log::warning('[IDV] 메시지 정의/템플릿 미해석으로 발송 건너뜀', [
+ 'provider_id' => $providerId,
+ 'purpose' => $purpose,
+ 'policy_key' => $policyKey,
+ 'render_hint' => $renderHint,
+ 'channel' => $channel,
+ ]);
+
+ return false;
+ }
+
+ $template = $resolved['template'];
+ $rendered = $template->replaceVariables($data);
+
+ $hookContext['definition_id'] = $resolved['definition']->id;
+ $hookContext['template_id'] = $template->id;
+ $hookContext['rendered'] = $rendered;
+
+ HookManager::doAction('core.identity.message.before_send', $hookContext);
+
+ try {
+ if ($channel === 'mail') {
+ $mailable = new IdentityMessageMail(
+ renderedSubject: $rendered['subject'],
+ renderedBody: $rendered['body'],
+ recipientEmail: $target,
+ providerId: $providerId,
+ scopeType: $resolved['definition']->scope_type->value,
+ scopeValue: (string) $resolved['definition']->scope_value,
+ );
+
+ Mail::to($target)->send($mailable);
+ } else {
+ // 미래 채널 확장 — 현재는 mail 만 지원
+ HookManager::applyFilters(
+ 'core.identity.message.send_'.$channel,
+ null,
+ $hookContext
+ );
+ }
+
+ HookManager::doAction('core.identity.message.after_send', $hookContext);
+
+ return true;
+ } catch (\Throwable $e) {
+ HookManager::doAction('core.identity.message.send_failed', array_merge($hookContext, [
+ 'error_message' => $e->getMessage(),
+ ]));
+
+ Log::warning('[IDV] 메시지 발송 실패', [
+ 'provider_id' => $providerId,
+ 'purpose' => $purpose,
+ 'policy_key' => $policyKey,
+ 'render_hint' => $renderHint,
+ 'channel' => $channel,
+ 'error' => $e->getMessage(),
+ ]);
+
+ return false;
+ }
+ }
+}
diff --git a/app/Services/IdentityMessageResolver.php b/app/Services/IdentityMessageResolver.php
new file mode 100644
index 00000000..1ee19891
--- /dev/null
+++ b/app/Services/IdentityMessageResolver.php
@@ -0,0 +1,71 @@
+definitionService->resolve($providerId, $scopeType, $scopeValue);
+
+ if (! $definition instanceof IdentityMessageDefinition) {
+ continue;
+ }
+
+ $template = $this->templateService->resolve($definition->id, $channel);
+
+ if (! $template instanceof IdentityMessageTemplate) {
+ continue;
+ }
+
+ return [
+ 'definition' => $definition,
+ 'template' => $template,
+ ];
+ }
+
+ return null;
+ }
+}
diff --git a/app/Services/IdentityMessageTemplateService.php b/app/Services/IdentityMessageTemplateService.php
new file mode 100644
index 00000000..93407cb8
--- /dev/null
+++ b/app/Services/IdentityMessageTemplateService.php
@@ -0,0 +1,307 @@
+cache->remember(
+ $this->getCacheKey($definitionId, $channel),
+ fn () => $this->repository->getActiveByDefinitionAndChannel($definitionId, $channel),
+ $this->getCacheTtl(),
+ [$this->cacheTag]
+ );
+ }
+
+ /**
+ * 템플릿 수정.
+ *
+ * @param IdentityMessageTemplate $template
+ * @param array $data
+ * @return IdentityMessageTemplate
+ */
+ public function updateTemplate(IdentityMessageTemplate $template, array $data): IdentityMessageTemplate
+ {
+ HookManager::doAction('core.identity.message_template.before_update', $template, $data);
+
+ $data = HookManager::applyFilters(
+ 'core.identity.message_template.filter_update_data',
+ $data,
+ $template
+ );
+
+ if (! array_key_exists('updated_by', $data) && Auth::id()) {
+ $data['updated_by'] = Auth::id();
+ }
+
+ $data['is_default'] = false;
+
+ $updated = $this->repository->update($template, $data);
+
+ if ($updated->definition && $updated->definition->is_default) {
+ $this->definitionService->updateDefinition($updated->definition, ['is_default' => false]);
+ }
+
+ $this->definitionService->invalidateAllCache();
+
+ HookManager::doAction('core.identity.message_template.after_update', $updated, $data);
+
+ return $updated;
+ }
+
+ /**
+ * 활성/비활성 토글.
+ *
+ * @param IdentityMessageTemplate $template
+ * @return IdentityMessageTemplate
+ */
+ public function toggleActive(IdentityMessageTemplate $template): IdentityMessageTemplate
+ {
+ HookManager::doAction('core.identity.message_template.before_toggle_active', $template);
+
+ $updated = $this->repository->update($template, [
+ 'is_active' => ! $template->is_active,
+ ]);
+
+ $this->definitionService->invalidateAllCache();
+
+ HookManager::doAction('core.identity.message_template.after_toggle_active', $updated);
+
+ return $updated;
+ }
+
+ /**
+ * 템플릿을 시더 기본값으로 복원합니다.
+ *
+ * @param IdentityMessageTemplate $template
+ * @return IdentityMessageTemplate
+ */
+ public function resetToDefault(IdentityMessageTemplate $template): IdentityMessageTemplate
+ {
+ HookManager::doAction('core.identity.message_template.before_reset', $template);
+
+ $defaultData = $this->getDefaultTemplateData($template);
+
+ if ($defaultData === null) {
+ HookManager::doAction('core.identity.message_template.reset_no_default', $template);
+
+ return $template;
+ }
+
+ // user_overrides 추적 회피 — reset 은 의도적 복원이므로 trackable 필드 변경을
+ // user_overrides 에 다시 추가하면 안 됨. HasUserOverrides 의 시더 플래그 재사용.
+ $previousFlag = app()->bound('user_overrides.seeding') ? app('user_overrides.seeding') : null;
+ app()->instance('user_overrides.seeding', true);
+
+ try {
+ $updated = $this->repository->update($template, [
+ 'subject' => $defaultData['subject'] ?? null,
+ 'body' => $defaultData['body'] ?? '',
+ 'is_active' => $defaultData['is_active'] ?? true,
+ 'is_default' => true,
+ 'user_overrides' => [],
+ ]);
+ } finally {
+ if ($previousFlag === null) {
+ app()->forgetInstance('user_overrides.seeding');
+ } else {
+ app()->instance('user_overrides.seeding', $previousFlag);
+ }
+ }
+
+ $this->definitionService->invalidateAllCache();
+
+ HookManager::doAction('core.identity.message_template.after_reset', $updated);
+
+ return $updated;
+ }
+
+ /**
+ * 변수 치환 미리보기.
+ *
+ * @param IdentityMessageTemplate $template
+ * @param array $data
+ * @param string|null $locale
+ * @return array{subject: string, body: string}
+ */
+ public function getPreview(IdentityMessageTemplate $template, array $data = [], ?string $locale = null): array
+ {
+ return $template->replaceVariables($data, $locale);
+ }
+
+ /**
+ * 시더가 정의한 기본 템플릿 데이터를 반환합니다.
+ *
+ * @param IdentityMessageTemplate $template
+ * @return array|null
+ */
+ protected function getDefaultTemplateData(IdentityMessageTemplate $template): ?array
+ {
+ $definition = $template->definition;
+
+ if (! $definition instanceof IdentityMessageDefinition) {
+ return null;
+ }
+
+ $allDefaults = $this->collectDefaultDefinitions();
+
+ foreach ($allDefaults as $defaultDefinition) {
+ if (! $this->matchesDefinition($defaultDefinition, $definition)) {
+ continue;
+ }
+
+ foreach ($defaultDefinition['templates'] ?? [] as $defaultTemplate) {
+ if (($defaultTemplate['channel'] ?? null) === $template->channel) {
+ return $defaultTemplate;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * 코어 + 확장의 기본 정의를 모두 수집합니다 (filter 훅 통합).
+ *
+ * 코어 정의는 `config/core.php` 의 `identity_messages` 블록을 SSoT 로 직독합니다.
+ * 확장(모듈/플러그인)은 `core.identity.filter_default_message_definitions` 훅으로 자체 정의를 추가합니다.
+ *
+ * @return array
+ */
+ protected function collectDefaultDefinitions(): array
+ {
+ $coreDefinitions = $this->loadCoreMessageDefinitions();
+
+ return HookManager::applyFilters(
+ 'core.identity.filter_default_message_definitions',
+ $coreDefinitions,
+ []
+ );
+ }
+
+ /**
+ * config/core.php 의 identity_messages 블록을 정규화하여 반환합니다.
+ *
+ * `'variables' => '__common__'` 마커는 commonVariables() 로 expand 하며,
+ * extension_type/extension_identifier 를 'core' 로 자동 주입합니다.
+ *
+ * @return array
+ */
+ protected function loadCoreMessageDefinitions(): array
+ {
+ $messages = config('core.identity_messages', []);
+ $common = $this->commonVariables();
+ $result = [];
+
+ foreach ($messages as $data) {
+ if (($data['variables'] ?? null) === '__common__') {
+ $data['variables'] = $common;
+ }
+ $data['extension_type'] = 'core';
+ $data['extension_identifier'] = 'core';
+ $result[] = $data;
+ }
+
+ return $result;
+ }
+
+ /**
+ * 표준 변수 메타데이터 (모든 mail 정의 공통).
+ *
+ * config/core.php 의 identity_messages 블록에서 `'variables' => '__common__'` 마커로 참조됩니다.
+ *
+ * @return array
+ */
+ protected function commonVariables(): array
+ {
+ return [
+ ['key' => 'code', 'description' => '인증 코드 (text_code 흐름)'],
+ ['key' => 'action_url', 'description' => '검증 링크 URL (link 흐름)'],
+ ['key' => 'expire_minutes', 'description' => '만료까지 남은 분'],
+ ['key' => 'purpose_label', 'description' => '인증 목적 라벨 (다국어)'],
+ ['key' => 'app_name', 'description' => '사이트명'],
+ ['key' => 'site_url', 'description' => '사이트 URL'],
+ ['key' => 'recipient_email', 'description' => '수신자 이메일'],
+ ];
+ }
+
+ /**
+ * 시더 데이터가 특정 정의와 매칭되는지 확인합니다.
+ *
+ * @param array $defaultDefinition
+ * @param IdentityMessageDefinition $definition
+ * @return bool
+ */
+ protected function matchesDefinition(array $defaultDefinition, IdentityMessageDefinition $definition): bool
+ {
+ return ($defaultDefinition['provider_id'] ?? null) === $definition->provider_id
+ && ($defaultDefinition['scope_type'] ?? null) === $definition->scope_type->value
+ && ((string) ($defaultDefinition['scope_value'] ?? '')) === (string) $definition->scope_value;
+ }
+
+ /**
+ * 캐시 키 생성.
+ *
+ * @param int $definitionId
+ * @param string $channel
+ * @return string
+ */
+ private function getCacheKey(int $definitionId, string $channel): string
+ {
+ return $this->cachePrefix.$definitionId.'.'.$channel;
+ }
+}
diff --git a/app/Services/IdentityPolicyService.php b/app/Services/IdentityPolicyService.php
new file mode 100644
index 00000000..168a5739
--- /dev/null
+++ b/app/Services/IdentityPolicyService.php
@@ -0,0 +1,523 @@
+ $context 매칭 컨텍스트 (http_method, signup_stage, user_roles 등)
+ * @return IdentityPolicy|null 매칭 정책 또는 null
+ */
+ public function resolve(string $scope, string $target, array $context = []): ?IdentityPolicy
+ {
+ $policies = $this->policyRepository->resolveByScopeTarget($scope, $target);
+ $policy = $this->selectMatchingPolicy($policies, $context);
+
+ $filtered = HookManager::applyFilters(
+ 'core.identity.resolve_policy',
+ $policy,
+ $scope,
+ $target,
+ $context,
+ );
+
+ // 보안: filter 훅이 IdentityPolicy 외 타입(null 등)을 반환해도 원본 정책 유지.
+ // 의도적 정책 변경은 IdentityPolicy 인스턴스로 반환할 것.
+ if (! $filtered instanceof IdentityPolicy) {
+ return $policy;
+ }
+
+ return $filtered;
+ }
+
+ /**
+ * 정책을 강제합니다. grace_minutes 내 verified 가 있으면 통과, 없으면 예외.
+ *
+ * 분기 순서:
+ * 1. policy.enabled=false → no-op
+ * 2. applies_to 와 사용자(admin/일반) 매칭 안 되면 no-op
+ * 3. grace_minutes 내 verified 로그 있으면 통과
+ * 4. fail_mode=log_only 이면 감사 로그 남기고 통과, 아니면 예외 throw
+ *
+ * @param IdentityPolicy $policy 강제 대상 정책
+ * @param User|null $user 현재 사용자 (게스트 가입 흐름에서는 null)
+ * @param array $context 요청 컨텍스트 (target_email, user_roles, return_request 등)
+ * @return void
+ *
+ * @throws IdentityVerificationRequiredException grace 내 verified 없고 fail_mode != log_only 일 때
+ */
+ public function enforce(IdentityPolicy $policy, ?User $user, array $context = []): void
+ {
+ if (! $policy->enabled) {
+ return;
+ }
+
+ // policy.conditions 와 요청 context 매칭 — 안전망. 호출자(미들웨어/리스너/직접호출)가
+ // selectMatchingPolicy 를 거치지 않고 enforce 만 호출해도 conditions 가 평가되도록 보장한다.
+ // 예: contact_change 정책(changed_fields=['email','phone','mobile']) 이 비밀번호 변경 같은
+ // 무관 user update 에서 발화하던 회귀 차단.
+ if (! $this->policyMatchesContext($policy, $context)) {
+ return;
+ }
+
+ // applies_to: self → admin 제외, admin → admin 만, both → 모두 enforce
+ $appliesTo = $policy->applies_to ?? IdentityPolicyAppliesTo::Both;
+ if ($appliesTo !== IdentityPolicyAppliesTo::Both) {
+ $isAdmin = $this->isAdminContext($user, $context);
+ if ($appliesTo === IdentityPolicyAppliesTo::Self_ && $isAdmin) {
+ return;
+ }
+ if ($appliesTo === IdentityPolicyAppliesTo::Admin && ! $isAdmin) {
+ return;
+ }
+ }
+
+ $targetHash = $this->resolveTargetHash($user, $context);
+ $userId = $user?->id;
+
+ // verification_token 우회 — IdentityGuardInterceptor 가 verify 직후 토큰을 부착해
+ // 원 요청을 재실행할 때 grace_minutes 윈도우와 무관하게 통과시킨다.
+ // 모든 enforce 진입점(미들웨어/리스너/직접 호출) 에 동일 우회가 적용되도록 Service 단계에서 처리.
+ // 회귀 차단: hook scope 정책 (예: core.admin.user_delete, grace_minutes=0) 에서 미들웨어만
+ // 토큰을 알고 listener 는 모르던 결함으로 인해 verify 후 retry 시 재차 428 이 발생하던 무한 루프.
+ $token = (string) ($context['verification_token'] ?? '');
+ if ($token !== '') {
+ $verifiedLog = $this->logRepository->findVerifiedForToken($token, $policy->purpose);
+ if ($verifiedLog !== null
+ && ($targetHash === null || $verifiedLog->target_hash === $targetHash)) {
+ return;
+ }
+ }
+
+ $recent = $this->logRepository->findRecentVerified(
+ purpose: $policy->purpose,
+ userId: $userId,
+ targetHash: $targetHash,
+ withinMinutes: max(0, $policy->grace_minutes),
+ );
+
+ if ($recent !== null) {
+ return;
+ }
+
+ // fail_mode=log_only → 감사 로그만 남기고 요청 통과
+ if ($policy->fail_mode === IdentityPolicyFailMode::LogOnly) {
+ $this->logPolicyViolation($policy, $user, $context);
+
+ return;
+ }
+
+ throw new IdentityVerificationRequiredException(
+ policyKey: $policy->key,
+ purpose: $policy->purpose,
+ providerId: $policy->provider_id,
+ renderHint: $this->resolveRenderHint($policy),
+ returnRequest: $context['return_request'] ?? null,
+ );
+ }
+
+ /**
+ * 플러그인이 런타임에 정책을 추가로 등록할 때 사용 (DB 저장 없이).
+ * 현재 구현은 로그에만 남기고, 필터 훅으로 소비되도록 설계.
+ *
+ * @param IdentityPolicy $policy 임시 정책 인스턴스
+ * @return void
+ */
+ public function registerRuntime(IdentityPolicy $policy): void
+ {
+ HookManager::doAction('core.identity.runtime_policy_registered', $policy);
+ }
+
+ /**
+ * 정책 목록을 페이지네이션과 함께 조회합니다 (관리자 S1d DataGrid).
+ *
+ * @param array $filters 필터 조건
+ * @param int $perPage 페이지 크기
+ * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
+ */
+ public function search(array $filters, int $perPage = 20)
+ {
+ return $this->policyRepository->search($filters, $perPage);
+ }
+
+ /**
+ * 정책 id 로 조회합니다.
+ *
+ * @param int $id 정책 ID
+ * @return IdentityPolicy|null
+ */
+ public function findById(int $id): ?IdentityPolicy
+ {
+ return $this->policyRepository->findById($id);
+ }
+
+ /**
+ * 신규 정책을 생성합니다 (source_type='admin' 고정).
+ *
+ * @param array $data 정책 데이터
+ * @return IdentityPolicy
+ */
+ public function createAdminPolicy(array $data): IdentityPolicy
+ {
+ $data['source_type'] = 'admin';
+ // source_identifier 는 FormRequest 가 형식 검증(admin|module:{id}|plugin:{id}) 후 전달.
+ // 미지정 시 'admin' (운영자 자유 정책, 어느 확장에도 귀속 안 됨).
+ $data['source_identifier'] = $data['source_identifier'] ?? 'admin';
+
+ return $this->policyRepository->upsertByKey($data);
+ }
+
+ /**
+ * 정책을 업데이트합니다. source_type != 'admin' 일 경우 제한 필드만 허용.
+ *
+ * @param IdentityPolicy $policy 수정 대상 정책
+ * @param array $attributes 수정할 필드
+ * @return bool 수정 성공 여부
+ */
+ public function updatePolicy(IdentityPolicy $policy, array $attributes): bool
+ {
+ $overrides = $policy->source_type !== IdentityPolicySourceType::Admin ? array_keys($attributes) : [];
+
+ return $this->policyRepository->updateByKey($policy->key, $attributes, $overrides);
+ }
+
+ /**
+ * 관리자 생성 정책을 삭제합니다. 선언형 정책은 false 반환.
+ *
+ * @param IdentityPolicy $policy 삭제 대상
+ * @return bool 삭제 성공 여부
+ */
+ public function deleteAdminPolicy(IdentityPolicy $policy): bool
+ {
+ if ($policy->source_type !== IdentityPolicySourceType::Admin) {
+ return false;
+ }
+
+ return $this->policyRepository->deleteByKey($policy->key);
+ }
+
+ /**
+ * 단일 필드의 user_overrides 를 해제하고 선언 기본값으로 즉시 복원합니다.
+ *
+ * 동작:
+ * 1. user_overrides 배열에서 해당 필드명 제거
+ * 2. 선언 기본값(core: config/core.php, module/plugin: 해당 확장의 getIdentityPolicies()) 을
+ * 현재 정책 레코드에 즉시 반영 (다음 Seeder 실행 기다림 없이)
+ * 3. source_type='admin' 정책은 선언 기본값이 없으므로 false 반환
+ *
+ * S1d 관리자 UI 의 "↺ 기본값으로 되돌리기" 버튼이 이 메서드를 호출합니다.
+ *
+ * @param IdentityPolicy $policy 대상 정책
+ * @param string $field 복원할 필드명 (enabled|grace_minutes|provider_id|fail_mode|conditions 중 하나)
+ * @return bool 성공 여부 (field 미지원 또는 선언 기본값 부재 시 false)
+ */
+ public function resetFieldOverride(IdentityPolicy $policy, string $field): bool
+ {
+ $allowed = ['enabled', 'grace_minutes', 'provider_id', 'fail_mode', 'conditions'];
+ if (! in_array($field, $allowed, true)) {
+ return false;
+ }
+
+ $declared = $this->findDeclaredDefault($policy);
+ if ($declared === null || ! array_key_exists($field, $declared)) {
+ return false;
+ }
+
+ $overrides = array_values(array_filter(
+ $policy->user_overrides ?? [],
+ fn (string $name): bool => $name !== $field,
+ ));
+
+ $policy->{$field} = $declared[$field];
+ $policy->user_overrides = $overrides;
+
+ // HasUserOverrides trait 의 auto-record 우회 — 이 저장은 운영자 수정이 아니라
+ // 기본값 복원이므로 trackable 필드가 변경되어도 user_overrides 에 재추가되면 안 됨.
+ return $this->withUserOverridesBypass(fn () => $policy->save());
+ }
+
+ /**
+ * `user_overrides.seeding` 플래그를 켠 상태로 callback 을 실행합니다.
+ * HasUserOverrides trait 의 updating 이벤트가 이 플래그를 보면 auto-record 를 스킵합니다.
+ *
+ * @param callable $callback 내부에서 실행할 저장 콜백 (true/false 반환)
+ * @return bool 콜백 결과를 bool 캐스팅한 값
+ */
+ protected function withUserOverridesBypass(callable $callback): bool
+ {
+ $app = app();
+ $previouslyBound = $app->bound('user_overrides.seeding');
+ $previousValue = $previouslyBound ? $app->make('user_overrides.seeding') : null;
+
+ $app->instance('user_overrides.seeding', true);
+ try {
+ return (bool) $callback();
+ } finally {
+ if ($previouslyBound) {
+ $app->instance('user_overrides.seeding', $previousValue);
+ } else {
+ // Laravel container 에는 unbind 공식 API 가 없으므로 false 로 덮어씀.
+ $app->instance('user_overrides.seeding', false);
+ }
+ }
+ }
+
+ /**
+ * 정책의 source 에 해당하는 선언 기본값을 반환합니다.
+ *
+ * - core: config('core.identity_policies.{key}')
+ * - module/plugin: 해당 확장의 getIdentityPolicies() 결과 중 key 일치 항목
+ * - admin: null (선언 기본값 없음)
+ *
+ * @param IdentityPolicy $policy 대상 정책
+ * @return array|null 선언 기본값 배열 또는 null
+ */
+ protected function findDeclaredDefault(IdentityPolicy $policy): ?array
+ {
+ if ($policy->source_type === IdentityPolicySourceType::Core) {
+ // 주의: policy key 에 dot 가 포함되므로 config() 의 dot-notation 을 쓰면 안 됨.
+ // 전체 블록을 가져와 배열 키로 조회.
+ $block = (array) config('core.identity_policies', []);
+ $declared = $block[$policy->key] ?? null;
+
+ return is_array($declared) ? $declared : null;
+ }
+
+ if ($policy->source_type === IdentityPolicySourceType::Module) {
+ try {
+ $manager = app(\App\Extension\ModuleManager::class);
+ $module = $manager->getModuleByIdentifier($policy->source_identifier)
+ ?? $manager->getModule($policy->source_identifier);
+ if ($module && method_exists($module, 'getIdentityPolicies')) {
+ foreach ($module->getIdentityPolicies() as $data) {
+ if (($data['key'] ?? null) === $policy->key) {
+ return $data;
+ }
+ }
+ }
+ } catch (\Throwable) {
+ return null;
+ }
+
+ return null;
+ }
+
+ if ($policy->source_type === IdentityPolicySourceType::Plugin) {
+ try {
+ $manager = app(\App\Extension\PluginManager::class);
+ $plugin = $manager->getPlugin($policy->source_identifier);
+ if ($plugin && method_exists($plugin, 'getIdentityPolicies')) {
+ foreach ($plugin->getIdentityPolicies() as $data) {
+ if (($data['key'] ?? null) === $policy->key) {
+ return $data;
+ }
+ }
+ }
+ } catch (\Throwable) {
+ return null;
+ }
+
+ return null;
+ }
+
+ // source_type=admin 은 선언 기본값 없음
+ return null;
+ }
+
+ /**
+ * 우선순위 정렬된 정책 목록 중 context 와 매칭되는 첫 정책을 반환합니다.
+ *
+ * @param iterable $policies Repository 가 priority 내림차순으로 반환한 정책 목록
+ * @param array $context 매칭 컨텍스트
+ * @return IdentityPolicy|null 매칭 정책 또는 null
+ */
+ protected function selectMatchingPolicy($policies, array $context): ?IdentityPolicy
+ {
+ foreach ($policies as $policy) {
+ if ($this->policyMatchesContext($policy, $context)) {
+ return $policy;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * policy.conditions 와 요청 context 를 매칭합니다.
+ * 지원 키: http_method / changed_fields / user_role / signup_stage.
+ * 모든 명시 조건이 통과해야 true.
+ *
+ * @param IdentityPolicy $policy 검사 대상 정책
+ * @param array $context 요청 컨텍스트
+ * @return bool 매칭 여부
+ */
+ protected function policyMatchesContext(IdentityPolicy $policy, array $context): bool
+ {
+ $conditions = $policy->conditions ?? [];
+
+ if (! empty($conditions['http_method']) && isset($context['http_method'])) {
+ $methods = (array) $conditions['http_method'];
+ if (! in_array(strtoupper((string) $context['http_method']), array_map('strtoupper', $methods), true)) {
+ return false;
+ }
+ }
+
+ if (! empty($conditions['changed_fields']) && isset($context['changed_fields'])) {
+ $required = (array) $conditions['changed_fields'];
+ $changed = (array) $context['changed_fields'];
+ if (empty(array_intersect($required, $changed))) {
+ return false;
+ }
+ }
+
+ if (! empty($conditions['user_role']) && isset($context['user_roles'])) {
+ $required = (array) $conditions['user_role'];
+ $userRoles = (array) $context['user_roles'];
+ if (empty(array_intersect($required, $userRoles))) {
+ return false;
+ }
+ }
+
+ if (! empty($conditions['signup_stage']) && isset($context['signup_stage'])) {
+ $allowed = (array) $conditions['signup_stage'];
+ if (! in_array((string) $context['signup_stage'], $allowed, true)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * permission 기반 admin 여부 판정 (role identifier 'admin' 직접 가정 금지).
+ *
+ * 판정 우선순위:
+ * 1. context['user_is_admin'] 가 명시되어 있으면 그 값 (미들웨어 fast path — User::isAdmin() 결과 캐시)
+ * 2. User 모델의 `isAdmin()` — type='admin' 권한을 보유한 역할이 1개라도 있으면 true
+ *
+ * `context['user_roles']` 는 정책 conditions.user_role 매칭 전용이며, admin 판정 입력으로는
+ * 사용하지 않습니다 (role identifier 와 권한 보유는 별개의 개념이므로 의미 혼재 방지).
+ *
+ * @param User|null $user 현재 사용자 (게스트 흐름에서는 null)
+ * @param array $context 요청 컨텍스트
+ * @return bool admin 여부
+ */
+ protected function isAdminContext(?User $user, array $context): bool
+ {
+ if (array_key_exists('user_is_admin', $context)) {
+ return (bool) $context['user_is_admin'];
+ }
+
+ if ($user && method_exists($user, 'isAdmin')) {
+ try {
+ return (bool) $user->isAdmin();
+ } catch (\Throwable) {
+ return false;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * 사용자 이메일 또는 context.target_email 을 sha256 해시로 변환합니다.
+ * 인증 로그 조회 시 PII 보호용 키로 사용됩니다.
+ *
+ * @param User|null $user 사용자 (있으면 email 우선 사용)
+ * @param array $context 요청 컨텍스트 (`target_email` 키 폴백)
+ * @return string|null sha256(소문자 email) 또는 null
+ */
+ protected function resolveTargetHash(?User $user, array $context): ?string
+ {
+ if ($user && $user->email) {
+ return hash('sha256', mb_strtolower($user->email));
+ }
+
+ $email = (string) ($context['target_email'] ?? '');
+ if ($email !== '') {
+ return hash('sha256', mb_strtolower($email));
+ }
+
+ return null;
+ }
+
+ /**
+ * 정책의 provider 가 제공하는 렌더 힌트를 반환합니다.
+ * provider_id 가 명시되어 있으면 우선 사용, 미명시 시 purpose 기반 fallback.
+ *
+ * @param IdentityPolicy $policy 대상 정책
+ * @return string|null Provider 의 렌더 힌트 (UI 분기용) 또는 null
+ */
+ protected function resolveRenderHint(IdentityPolicy $policy): ?string
+ {
+ try {
+ $providerId = $policy->provider_id;
+ if ($providerId && $this->manager->has($providerId)) {
+ return $this->manager->get($providerId)->getRenderHint();
+ }
+
+ return $this->manager->resolveForPurpose($policy->purpose)->getRenderHint();
+ } catch (\Throwable) {
+ return null;
+ }
+ }
+
+ /**
+ * fail_mode=log_only 정책 위반을 감사 로그로 기록합니다 (요청은 통과).
+ *
+ * @param IdentityPolicy $policy 위반된 정책
+ * @param User|null $user 현재 사용자
+ * @param array $context 요청 컨텍스트 (origin_type, origin_identifier 등)
+ * @return void
+ */
+ protected function logPolicyViolation(IdentityPolicy $policy, ?User $user, array $context): void
+ {
+ $this->logRepository->create([
+ 'provider_id' => $policy->provider_id ?? 'g7:core.mail',
+ 'purpose' => $policy->purpose,
+ 'channel' => 'policy',
+ 'user_id' => $user?->id,
+ 'target_hash' => $this->resolveTargetHash($user, $context) ?? str_repeat('0', 64),
+ 'status' => \App\Enums\IdentityVerificationStatus::PolicyViolationLogged->value,
+ 'origin_type' => $context['origin_type'] ?? IdentityOriginType::Policy->value,
+ 'origin_identifier' => $context['origin_identifier'] ?? null,
+ 'origin_policy_key' => $policy->key,
+ 'metadata' => ['policy_id' => $policy->id],
+ ]);
+ }
+}
diff --git a/app/Services/IdentityVerificationService.php b/app/Services/IdentityVerificationService.php
new file mode 100644
index 00000000..1551c20e
--- /dev/null
+++ b/app/Services/IdentityVerificationService.php
@@ -0,0 +1,208 @@
+ $target 로그인 사용자(User) 또는 ['email' => '...'] 배열
+ * @param array $context origin_type / origin_identifier / origin_policy_key / ip_address / user_agent
+ * @return VerificationChallenge 발행된 challenge DTO
+ */
+ public function start(string $purpose, User|array $target, array $context = []): VerificationChallenge
+ {
+ $provider = $this->manager->resolveForPurpose($purpose);
+
+ $context['purpose'] = $purpose;
+
+ HookManager::doAction('core.identity.before_request', $purpose, $target, $context);
+
+ $challenge = $provider->requestChallenge($target, $context);
+
+ HookManager::doAction('core.identity.after_request', $challenge, $purpose, $target, $context);
+
+ return $challenge;
+ }
+
+ /**
+ * Challenge 를 검증합니다.
+ *
+ * @param string $challengeId Challenge UUID
+ * @param array $input 프로바이더별 입력 (코드, 토큰 등)
+ * @param array $context origin 정보 (origin_type/origin_identifier 등)
+ * @return VerificationResult 검증 결과 DTO (success/실패 사유 포함)
+ */
+ public function verify(string $challengeId, array $input, array $context = []): VerificationResult
+ {
+ $log = $this->logRepository->findById($challengeId);
+
+ if (! $log) {
+ HookManager::doAction('core.identity.before_verify', $challengeId, null, $context);
+ $result = VerificationResult::failure($challengeId, 'unknown', 'NOT_FOUND', 'identity.errors.challenge_not_found');
+ HookManager::doAction('core.identity.after_verify', $result, null, $context);
+
+ return $result;
+ }
+
+ $provider = $this->manager->get($log->provider_id);
+
+ HookManager::doAction('core.identity.before_verify', $challengeId, $log, $context);
+
+ $result = $provider->verify($challengeId, $input, $context);
+
+ if ($result->success && $log->user_id !== null) {
+ $user = $this->userRepository->findById($log->user_id);
+ if ($user !== null) {
+ $this->userRepository->update($user, [
+ 'identity_verified_at' => $result->verifiedAt,
+ 'identity_verified_provider' => $result->providerId,
+ 'identity_verified_purpose_last' => $log->purpose,
+ 'identity_hash' => $result->identityHash ?: null,
+ ]);
+ }
+ }
+
+ HookManager::doAction('core.identity.after_verify', $result, $log, $context);
+
+ return $result;
+ }
+
+ /**
+ * Challenge 를 취소합니다.
+ *
+ * @param string $challengeId Challenge UUID
+ * @return bool 취소 성공 여부 (대상 challenge 가 없으면 false)
+ */
+ public function cancel(string $challengeId): bool
+ {
+ $log = $this->logRepository->findById($challengeId);
+
+ if (! $log) {
+ return false;
+ }
+
+ return $this->manager->get($log->provider_id)->cancel($challengeId);
+ }
+
+ /**
+ * verification_token 을 소비(consume)합니다 — signup_before_submit 정책 통과 시 재사용 방지.
+ *
+ * @param string $token IDV 발행 verification_token
+ * @return bool consume 성공 여부 (verified 로그 부재 시 false)
+ */
+ public function consumeToken(string $token): bool
+ {
+ $log = $this->logRepository->findVerifiedForToken($token, 'signup');
+
+ if (! $log) {
+ return false;
+ }
+
+ return $this->logRepository->updateById($log->id, [
+ 'consumed_at' => now(),
+ ]);
+ }
+
+ /**
+ * Challenge 의 공개 상태를 조회합니다 (폴링용).
+ *
+ * 비동기 검증 흐름(Stripe Identity / 토스인증 push / 외부 redirect 콜백 대기) 에서 클라이언트가
+ * `GET /api/identity/challenges/{id}` 로 상태를 폴링할 때 사용합니다.
+ *
+ * 반환 필드는 시도 횟수·코드 본체·내부 metadata 를 제외한 공개 안전 필드만:
+ * - id / status / render_hint / expires_at / public_payload (요청 폴링에 필요한 최소집합)
+ *
+ * @param string $challengeId Challenge UUID
+ * @return array|null 공개 상태 또는 null (없는 경우)
+ * @since engine-v1.46.0
+ */
+ public function getStatus(string $challengeId): ?array
+ {
+ $log = $this->logRepository->findById($challengeId);
+ if (! $log) {
+ return null;
+ }
+
+ $publicPayload = [];
+ if (is_array($log->metadata) && isset($log->metadata['public_payload'])) {
+ $publicPayload = $log->metadata['public_payload'];
+ } elseif (is_array($log->properties) && isset($log->properties['public_payload'])) {
+ $publicPayload = $log->properties['public_payload'];
+ }
+
+ return [
+ 'id' => $log->id,
+ 'status' => $log->status->value,
+ 'render_hint' => $log->render_hint,
+ 'expires_at' => optional($log->expires_at)->toIso8601String(),
+ 'public_payload' => $publicPayload,
+ ];
+ }
+
+ /**
+ * 외부 IDV provider 의 redirect 콜백을 처리합니다.
+ *
+ * `POST /api/identity/callback/{providerId}` 진입 후 컨트롤러가 호출합니다.
+ * provider 의 `verify($challengeId, $input, $context)` 위임 — 이는 Mode B verify 와 동일한 경로.
+ *
+ * @param string $providerId 콜백을 보낸 provider 식별자
+ * @param string $challengeId body/query 에서 추출한 Challenge UUID
+ * @param array $input provider 가 보낸 페이로드 (code/token/state 등)
+ * @param array $context origin 정보 (origin_type=callback)
+ * @return VerificationResult provider 의 검증 결과 (challenge mismatch 시 failure)
+ * @since engine-v1.46.0
+ */
+ public function handleProviderCallback(string $providerId, string $challengeId, array $input, array $context = []): VerificationResult
+ {
+ $log = $this->logRepository->findById($challengeId);
+
+ if (! $log) {
+ return VerificationResult::failure($challengeId, $providerId, 'NOT_FOUND', 'identity.errors.challenge_not_found');
+ }
+
+ // provider 식별자 불일치 — 다른 provider 의 콜백이 잘못 라우팅된 경우 차단
+ if ($log->provider_id !== $providerId) {
+ return VerificationResult::failure($challengeId, $providerId, 'WRONG_PROVIDER', 'identity.errors.wrong_provider');
+ }
+
+ // 일반 verify 경로와 동일하게 위임 — verifyToken 발행, after_verify 훅 등 일관성 유지
+ $context['origin_type'] = $context['origin_type'] ?? 'callback';
+ $context['origin_identifier'] = $context['origin_identifier'] ?? "/api/identity/callback/{$providerId}";
+
+ return $this->verify($challengeId, $input, $context);
+ }
+}
diff --git a/app/Services/LanguagePack/LanguagePackBaseLocales.php b/app/Services/LanguagePack/LanguagePackBaseLocales.php
new file mode 100644
index 00000000..328143fb
--- /dev/null
+++ b/app/Services/LanguagePack/LanguagePackBaseLocales.php
@@ -0,0 +1,42 @@
+
+ */
+ private const BASE_LOCALES = ['ko', 'en'];
+
+ /**
+ * 주어진 locale 이 base locale 인지 판정합니다.
+ *
+ * @param string $locale locale 코드 (예: 'ko', 'en', 'ja')
+ * @return bool base locale 이면 true
+ */
+ public static function isBaseLocale(string $locale): bool
+ {
+ return in_array($locale, self::BASE_LOCALES, true);
+ }
+
+ /**
+ * 모든 base locale 목록을 반환합니다.
+ *
+ * @return array
+ */
+ public static function all(): array
+ {
+ return self::BASE_LOCALES;
+ }
+}
diff --git a/app/Services/LanguagePack/LanguagePackBundledRegistrar.php b/app/Services/LanguagePack/LanguagePackBundledRegistrar.php
new file mode 100644
index 00000000..a6bc495c
--- /dev/null
+++ b/app/Services/LanguagePack/LanguagePackBundledRegistrar.php
@@ -0,0 +1,365 @@
+value) {
+ return;
+ }
+
+ $absolute = base_path($langDirectoryRelative);
+ $foundLocales = $this->scanLocales($absolute);
+
+ $existingByLocale = $this->repository
+ ->getPacksForTarget($scope, $targetIdentifier)
+ ->where('source_type', 'bundled_with_extension')
+ ->keyBy('locale');
+
+ foreach ($foundLocales as $locale) {
+ if ($existingByLocale->has($locale)) {
+ $pack = $existingByLocale->get($locale);
+ $this->repository->update($pack, [
+ 'version' => $version,
+ 'source_url' => $langDirectoryRelative,
+ ]);
+ $existingByLocale->forget($locale);
+
+ continue;
+ }
+
+ $this->createBundledRecord(
+ scope: $scope,
+ targetIdentifier: $targetIdentifier,
+ vendor: $vendor,
+ version: $version,
+ locale: $locale,
+ langDirectoryRelative: $langDirectoryRelative,
+ );
+ }
+
+ // 새 스캔 결과에 없는 locale 의 가상 레코드는 삭제 (locale 제거 케이스)
+ foreach ($existingByLocale as $stale) {
+ $this->repository->delete($stale);
+ }
+
+ $this->registry->invalidate();
+ }
+
+ /**
+ * 확장 제거 후 호출되어 해당 target_identifier 의 가상 레코드를 모두 삭제하고
+ * 외부 벤더 언어팩은 error 상태로 전환합니다.
+ *
+ * @param string $scope 스코프
+ * @param string $targetIdentifier 대상 확장 식별자
+ * @return void
+ */
+ public function cleanupForExtension(string $scope, string $targetIdentifier): void
+ {
+ if (! LanguagePackScope::isValid($scope) || $scope === LanguagePackScope::Core->value) {
+ return;
+ }
+
+ $packs = $this->repository->getPacksForTarget($scope, $targetIdentifier);
+
+ foreach ($packs as $pack) {
+ if ($pack->source_type === 'bundled_with_extension') {
+ $this->repository->delete($pack);
+
+ continue;
+ }
+
+ // 외부 벤더 언어팩 — 대상 확장 없음 상태로 error 전환 (자동 삭제 안 함)
+ $this->repository->update($pack, [
+ 'status' => LanguagePackStatus::Error->value,
+ ]);
+ }
+
+ $this->registry->invalidate();
+ }
+
+ /**
+ * 호스트 확장 비활성화 시 해당 확장에 종속된 언어팩을 cascade 비활성화합니다 (PO #6).
+ *
+ * 비활성화된 active 팩 ID 목록을 cache 에 stash 하여, 재활성화 시 §6 모달이
+ * 표시할 후보로 사용됩니다.
+ *
+ * @param string $scope 스코프 (module/plugin/template)
+ * @param string $targetIdentifier 대상 확장 식별자
+ * @return array 비활성화된 LanguagePack ID 배열
+ */
+ public function deactivateForExtension(string $scope, string $targetIdentifier): array
+ {
+ if (! LanguagePackScope::isValid($scope) || $scope === LanguagePackScope::Core->value) {
+ return [];
+ }
+
+ $deactivated = [];
+ $packs = $this->repository->getPacksForTarget($scope, $targetIdentifier);
+ $service = app(\App\Services\LanguagePackService::class);
+
+ foreach ($packs as $pack) {
+ if ($pack->status !== LanguagePackStatus::Active->value) {
+ continue;
+ }
+
+ try {
+ $service->deactivate($pack, cascadeFromHost: true);
+ $deactivated[] = $pack->id;
+ } catch (Throwable $e) {
+ Log::warning('language-pack cascade deactivate 실패', [
+ 'pack_id' => $pack->id,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+ }
+
+ if (! empty($deactivated)) {
+ $this->stashDeactivatedForReactivation($scope, $targetIdentifier, $deactivated);
+ }
+
+ $this->registry->invalidate();
+
+ return $deactivated;
+ }
+
+ /**
+ * 비활성화된 팩 ID 목록을 cache 에 보관합니다 (재활성화 모달용).
+ *
+ * @param string $scope 스코프
+ * @param string $targetIdentifier 대상 확장 식별자
+ * @param array $packIds 팩 ID 배열
+ * @return void
+ */
+ public function stashDeactivatedForReactivation(string $scope, string $targetIdentifier, array $packIds): void
+ {
+ $key = $this->reactivationCacheKey($scope, $targetIdentifier);
+ $this->cache->put($key, $packIds, 30 * 24 * 60 * 60);
+ }
+
+ /**
+ * 호스트 확장 재활성화 시 모달에 표시할 pending 언어팩 목록을 반환합니다 (PO #7).
+ *
+ * stash 된 ID 중 현재도 DB 에 존재하고 inactive 상태인 팩만 필터링합니다.
+ * 빈 배열을 반환하면 프론트엔드는 모달을 띄우지 않습니다 (PO #8).
+ *
+ * @param string $scope 스코프
+ * @param string $targetIdentifier 대상 확장 식별자
+ * @return array