> $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(); } } }