Files
Gnuboard7/app/Http/Resources/LanguagePackResource.php
T
HeuJung 3d8890b118 feat(core,admin_basic): 번들 비-ko 언어팩 자동 프로비저닝 + 미설치·드리프트 발견성
신규 설치/복구 시 비-ko/en 번들 언어팩(ja 등)이 자동으로 채워지지 않아
런타임에 오류 없이 조용히 ko 로 폴백하고, 운영자가 이를 알아채기 어려웠다.
두 결손을 함께 해소한다.

프로비저닝(SSoT):
- language-pack:provision 멱등 커맨드 신설. supported_locales ∖ {ko,en} 의
 미설치 번들 팩을 설치하고, 설치본 파일이 사라진 드리프트 팩(슬롯 점유로
 getUninstalledBundledPacks 가 못 잡는)도 복구 경로로 겸한다. 재실행 시 0건 수렴.

발견성:
- CLI language-pack:list 를 Service::list 경유로 전환해 미설치(uninstalled)와
 드리프트(active·파일 없음)를 함께 표면화.
- files_missing / bundled_source_available 파생 플래그를 Resource 에 노출.
- 관리자 언어팩 목록에 "파일 없음" 배지 + 원클릭 재설치(기존 install-bundled 모달
 재사용). 재설치 경로만 auto_activate 로 바인딩해 복구 후 active 를 유지한다.

회귀 방지:
- 정적 룰 lang-pack-supported-locale-has-bundle(선언 로케일 ↔ 번들 소스 부재) 신설,
 lang-pack-installed-version-drift 를 프로비저닝된 환경 한정으로 확장.
- 백엔드/레이아웃/E2E/시나리오 테스트 + 룰 fixture 추가.

부수 수정:
- 프로비저닝 작업 중 표면화된 ecommerce 언어팩 시더 테스트 격리결함 수정
 (modules 등록 행을 매 테스트가 직접 보장 — 프로세스당 1회 시딩 전제 붕괴 대응).
2026-07-27 14:58:48 +09:00

299 lines
11 KiB
PHP

<?php
namespace App\Http\Resources;
use App\Enums\LanguagePackOrigin;
use App\Extension\Helpers\ChangelogParser;
use App\Helpers\TimezoneHelper;
use Carbon\Carbon;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\DB;
/**
* 언어팩 단건 API 리소스.
*/
class LanguagePackResource extends BaseApiResource
{
/**
* 언어팩을 배열로 변환합니다.
*
* @param Request $request HTTP 요청 객체
* @return array<string, mixed> 변환된 언어팩 데이터
*/
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'),
'files_missing' => (bool) $this->getValue('files_missing', false),
'bundled_source_available' => (bool) $this->getValue('bundled_source_available', false),
'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 = 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<string, mixed> 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 = 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` 만 노출합니다. 모듈/플러그인 행 액션과 동일 패턴.
*
* 드리프트 행(설치본 파일 부재 + 번들 소스 실재)은 설치 행이지만 "재설치로 복구" 가
* 유효한 액션이므로 `can_install` 을 함께 노출합니다. 이 플래그가 없으면 화면이
* 복구 버튼을 켤 근거가 없어, 드리프트가 보이기만 하고 고칠 수 없는 상태로 남습니다.
*
* @return array<string, string>
*/
protected function abilityMap(): array
{
if ($this->getValue('status') === 'uninstalled') {
return [
'can_install' => 'core.language_packs.install',
];
}
$map = [
'can_activate' => 'core.language_packs.manage',
'can_deactivate' => 'core.language_packs.manage',
'can_uninstall' => 'core.language_packs.manage',
];
if ($this->getValue('files_missing') && $this->getValue('bundled_source_available')) {
$map['can_install'] = 'core.language_packs.install';
}
return $map;
}
/**
* resourceMeta 후처리 — 의존성 미충족 시 can_install 을 false 로 강제.
*
* 권한이 있어도 코어 locale/대상 확장 의존성이 미충족이면 설치할 수 없으므로,
* UI 가 단일 플래그로 행/모달 버튼 disabled 상태를 결정할 수 있도록 정렬합니다.
*
* @param Request $request HTTP 요청
* @return array<string, mixed> 권한 메타
*/
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 = 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 = 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));
}
}