Files
Gnuboard7/app/Repositories/LanguagePackTranslationRepository.php
T
HeuJung e7fa1b5535 feat(message_bizppurio): 알림 템플릿 편집 통합·검수 의견·관리탭 개선 및 언어팩 번역 보존 결함군 수정
비즈뿌리오 알림톡 UX 개편 후속:
- 코어/게시판/이커머스 알림 템플릿 [편집] 모달에 확장 채널 편집 영역
 (extension_point 2종 + hidden_template_editor 채널 메타)을 신설하고,
 알림톡 템플릿·문자(SMS)·수신자 규칙을 한 창에서 통합 저장하도록 개정
 (행 하단은 상태 요약 전용, 승인 여부 2단 배지)
- 검수 신청에 검수자 전달 의견(comment) 동봉 — kapi request 로만 전달,
 행에는 저장하지 않음 (FormRequest 신설 + API 문서 백필)
- 관리 탭 모바일 카드뷰·도구줄 코어 관례 통일, 사이트맵 알림 정의
 다국어 라벨 결측 수정 + 코어 정의 라벨 패리티 테스트 신설

언어팩 번역 소실·보존 결함군 (보완 실측 → 커밋 전 점검에서 연쇄 확정,
전부 실패 테스트 선행 후 수정):
- [기본값 복원]이 활성 언어팩 번역(ja)을 영구 소실 → 복원 기본값에
 시딩과 같은 주입기(SSoT)로 팩 로케일 병합 (notification·identity)
- 코어 알림/본인인증 시더의 언어팩 주입 전면 불능 → 주입기가 연관/리스트
 양형태 수용 + identity 시더의 config 복합 키 보존
- 실패한 언어팩 업데이트가 active 팩을 installed 로 방치 → 상태 복원 확장
- 병합·제거의 사용자 수정 보존 판정 사문(연관 전제 isset) → dot-path 판정
 교정, 팩 주입 저장의 user_overrides 오염 차단(시딩 바인딩), 템플릿
 2모델 translatableTrackableFields 선언(beta.4 설계 누락), 같은 프로세스
 활성화 시 시더 번역 필터 stale(캡처 인스턴스 캐시) 교정
- 7.0.9 업그레이드 스텝 신설 — 활성 팩 seed 재동기화로 기설치본의 소실
 번역 자동 복구 (멱등·운영자 수정 보존·팩별 실패 격리)
2026-08-24 14:51:37 +09:00

518 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Repositories;
use App\Contracts\Repositories\LanguagePackTranslationRepositoryInterface;
use App\Enums\ExtensionOwnerType;
use App\Enums\LanguagePackScope;
use App\Models\IdentityMessageDefinition;
use App\Models\IdentityMessageTemplate;
use App\Models\LanguagePack;
use App\Models\Menu;
use App\Models\Module;
use App\Models\NotificationDefinition;
use App\Models\NotificationTemplate;
use App\Models\Permission;
use App\Models\Plugin;
use App\Models\Role;
use App\Models\Template;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
/**
* 언어팩 활성/비활성 시 DB JSON 다국어 컬럼을 일괄 동기화하는 집계 Repository.
*
* 10 개 모델 (Permission/Role/Menu/Module/Plugin/Template/NotificationDefinition/
* NotificationTemplate/IdentityMessageDefinition/IdentityMessageTemplate) 에 대한 영속
* 책임을 단일 진입점으로 캡슐화. Listener/Service 는 본 Repository 만 호출.
*
* 동작 정책은 인터페이스 PHPDoc 참조.
*
* @since 7.0.0-beta.4
*/
class LanguagePackTranslationRepository implements LanguagePackTranslationRepositoryInterface
{
/**
* 언어팩의 seed/*.json 을 DB JSON 컬럼에 병합합니다.
*
* @param LanguagePack $pack 활성화된 언어팩
* @param array<string, array<string, mixed>> $seedBundle 엔티티별 seed 데이터
* @return array<int, array<string, mixed>> 감사 로그 항목
*/
public function applySeedFromPack(LanguagePack $pack, array $seedBundle): array
{
// 팩 주입은 시딩 컨텍스트다 — 바인딩 없이 저장하면 UserOverridesAwareBuilder 가
// 주입 자체를 "사용자 수정"으로 오인해 user_overrides 를 오염시키고, 그 선언이
// 이후 시더 재실행의 컬럼 갱신을 영구히 얼린다 (#597 에서 실측·교정).
app()->instance('user_overrides.seeding', true);
try {
$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;
} finally {
app()->forgetInstance('user_overrides.seeding');
}
}
/**
* 언어팩의 locale 키를 DB JSON 컬럼에서 제거합니다 (user_overrides 컬럼은 보존).
*
* @param LanguagePack $pack 비활성화된 언어팩
* @return array<int, array<string, mixed>> 감사 로그 항목
*/
public function stripLocaleFromPack(LanguagePack $pack): array
{
// applySeedFromPack 과 동일 — 제거도 시딩 컨텍스트 (user_overrides 오염 차단)
app()->instance('user_overrides.seeding', true);
try {
return $this->stripLocaleFromPackBody($pack);
} finally {
app()->forgetInstance('user_overrides.seeding');
}
}
/**
* stripLocaleFromPack 의 본문 (시딩 바인딩 하에서 실행).
*
* @param LanguagePack $pack 비활성화된 언어팩
* @return array<int, array<string, mixed>> 감사 로그 항목
*/
private function stripLocaleFromPackBody(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<Model> $modelClass
* @param array<string, array<string, mixed>> $seed
* @param array<int, string> $columns
* @param array<int, array<string, mixed>> $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<string, array<string, mixed>> $seed
* @param array<int, array<string, mixed>> $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<string, array<string, mixed>> $seed
* @param array<int, array<string, mixed>> $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<string, mixed> $seed
* @param array<int, array<string, mixed>> $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<int, array<string, mixed>> $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<string, mixed> $entry
* @param array<int, string> $columns
* @param array<int, array<string, mixed>> $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 = $this->isColumnLocaleOverridden($overrides, $column, $locale);
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<int, string> $columns
* @param array<int, array<string, mixed>> $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 ($this->isColumnLocaleOverridden($overrides, $column, $locale)) {
$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();
}
}
/**
* 해당 컬럼·로케일이 운영자 수정(user_overrides)으로 보존 대상인지 판정합니다.
*
* 운영자 수정의 실제 기록 형식은 dot-path 리스트(`"{column}.{locale}"` —
* HasUserOverrides 의 updating 이벤트가 기록, beta.4 가 레거시 항목을 dot-path 로
* 전환)이므로 그 형식만 본다. 종전의 `isset($overrides[$column])` 판정은 어떤
* 생산자도 쓰지 않는 연관 형식을 전제해 항상 false 였고, 그 결과 활성화/비활성화가
* 운영자 수정 번역을 오류 없이 덮어쓰거나 제거했다 (#597 에서 발견·교정).
*
* 컬럼 전체 항목(`"subject"` 등)은 의도적으로 보존 사유로 보지 않는다 — 그 형식은
* 시더 재실행(syncFromUpgrade)의 컬럼 보호 선언이며, 팩 병합까지 차단하면 팩
* 업데이트가 자기 로케일 번역을 갱신하지 못하게 된다 (종전에도 차단된 적 없음).
*
* @param array<int, string> $overrides user_overrides 리스트
* @param string $column 다국어 JSON 컬럼명
* @param string $locale 대상 로케일
* @return bool 보존 대상 여부
*/
protected function isColumnLocaleOverridden(array $overrides, string $column, string $locale): bool
{
return in_array($column.'.'.$locale, $overrides, true);
}
}