Files
Gnuboard7/app/Repositories/LanguagePackRepository.php
T
HeuJung 7f457a05b3 fix(core,board,page,ecommerce,basic,pay): FULLTEXT 게이트·검색 실패 표면화와 페이지네이션/접두사 결함 정비
- 공개 : MATCH 는 커버 인덱스가 있을 때만 조립 — 부재 시 LIKE 폴백 + 1회 경고,
 카테고리 검색 예외를 categories_failed/search_failed 로 표면화하고 basic 템플릿이 오류 안내 렌더
- 공개 동근원: paginate page 명시 전달 (언어팩 check-updates, 상품 문의 목록)
- 공개 동근원: raw SQL 접두사/별칭 하드코딩 정리 (board 시더, 7.0.6 업그레이드 스텝,
 결제 3플러그인 컨트롤러 51지점 모델 파생 전환)
- audit 룰 3종 신설 + repository-raw-hardcoded-table 컨트롤러 확대, 확장 TestCase
 오토로더 중복 선언 가드 16지점, ja 언어팩 동기
2026-08-17 03:02:26 +09:00

412 lines
16 KiB
PHP

<?php
namespace App\Repositories;
use App\Contracts\Repositories\LanguagePackRepositoryInterface;
use App\Enums\LanguagePackScope;
use App\Enums\LanguagePackSourceType;
use App\Enums\LanguagePackStatus;
use App\Enums\TextDirection;
use App\Models\LanguagePack;
use App\Repositories\Concerns\HasMultipleSearchFilters;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
/**
* 언어팩 Repository 구현체.
*/
class LanguagePackRepository implements LanguagePackRepositoryInterface
{
use HasMultipleSearchFilters;
/**
* 식별자로 언어팩을 조회합니다.
*
* @param string $identifier 언어팩 식별자
* @return LanguagePack|null 언어팩 또는 null
*/
public function findByIdentifier(string $identifier): ?LanguagePack
{
return LanguagePack::query()->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<int, LanguagePack> 활성 언어팩 컬렉션
*/
public function getActivePacks(): Collection
{
// audit:allow query-unbounded-get reason: 언어팩은 설치된 팩 × 로케일 수만큼만 존재한다 (사용량과 무관)
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<int, LanguagePack> 후보 언어팩 컬렉션
*/
public function getPacksForSlot(
string $scope,
?string $targetIdentifier,
string $locale
): Collection {
// audit:allow query-unbounded-get reason: 언어팩은 설치된 팩 × 로케일 수만큼만 존재한다 (사용량과 무관)
return LanguagePack::query()
->where('scope', $scope)
->where('target_identifier', $targetIdentifier)
->where('locale', $locale)
->orderByDesc('activated_at')
->orderByDesc('installed_at')
->get();
}
/**
* 활성 코어 언어팩이 있는 모든 로케일을 반환합니다.
*
* @return array<int, string> 로케일 문자열 배열
*/
public function getActiveCoreLocales(): array
{
// audit:allow query-unbounded-get reason: 언어팩은 설치된 팩 × 로케일 수만큼만 존재한다 (사용량과 무관)
return LanguagePack::query()
->where('scope', LanguagePackScope::Core->value)
->where('status', LanguagePackStatus::Active->value)
->pluck('locale')
->unique()
->values()
->all();
}
/**
* 페이지네이션 + 필터링된 언어팩 목록을 조회합니다 (관리자 목록 전용).
*
* @param array<string, mixed> $filters 필터 (scope, target_identifier, locale, status, vendor)
* @param int $perPage 페이지당 건수
* @param int|null $page 페이지 번호 (null 이면 요청 파라미터에서 해석)
* @return LengthAwarePaginator 페이지네이션 결과
*/
public function paginate(array $filters = [], int $perPage = 20, ?int $page = null): LengthAwarePaginator
{
// audit:allow repository-paginate-column-pruning reason: 언어팩 정의 테이블 — 설치된 팩 수만큼만 존재하고 넓은 컬럼이 없다
return $this->buildFilteredQuery($filters)->paginate($perPage, ['*'], 'page', $page);
}
/**
* 업데이트 확인용 전체 언어팩 컬렉션을 조회합니다.
*
* 전량 순회 의도를 `paginate(큰 값)` 으로 흉내 내면 page 인자 암묵 해석 때문에
* HTTP `?page=2` 가 순회 범위를 비워 버린다 (공개 이슈 #102 동형). 순회는 이
* 메서드로만 한다.
*
* @return Collection<int, LanguagePack> 설치된 전체 언어팩
*/
public function allForUpdateCheck(): Collection
{
// audit:allow query-unbounded-get reason: 언어팩은 운영자가 설치한 팩 수만큼만 존재하는
// 설정성 테이블이다 (사용량과 무관) — CLAUDE.md pagination 예외 조항
return LanguagePack::query()->orderBy('identifier')->get();
}
/**
* 필터링된 언어팩 컬렉션을 페이지네이션 없이 조회합니다.
*
* @param array<string, mixed> $filters 필터 (scope, target_identifier, locale, status, vendor, search)
* @return Collection<int, LanguagePack> 필터링된 언어팩 컬렉션
*/
public function getFilteredCollection(array $filters = []): Collection
{
return $this->buildFilteredQuery($filters)->get();
}
/**
* 공통 필터 쿼리 빌더 — `paginate()` 와 `getFilteredCollection()` 가 동일 로직을 공유합니다.
*
* `status=uninstalled` 필터는 가상 상태이므로 DB 쿼리에서 결과를 0건으로 강제합니다
* (실제 미설치 번들은 Service 계층에서 합쳐집니다).
*
* @param array<string, mixed> $filters 필터 조건
* @return Builder<LanguagePack> 정렬까지 적용된 쿼리 빌더
*/
private function buildFilteredQuery(array $filters): 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) {
// 결과 없음 — 빈 whereIn 은 `0 = 1` 로 컴파일된다(raw 불필요)
$query->whereIn($query->getModel()->getKeyName(), []);
} 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')
// 전순서 보장 — 위 세 컬럼 조합이 유니크하더라도 정렬 계약을 키로 닫아 둔다
->orderBy('id');
}
/**
* 언어팩을 생성합니다.
*
* @param array<string, mixed> $data 생성 데이터
* @return LanguagePack 생성된 언어팩
*/
public function create(array $data): LanguagePack
{
return LanguagePack::query()->create($data);
}
/**
* 언어팩을 갱신합니다.
*
* @param LanguagePack $pack 대상 언어팩
* @param array<string, mixed> $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<int, LanguagePack> 언어팩 컬렉션
*/
public function getPacksForTarget(string $scope, string $targetIdentifier): Collection
{
// audit:allow query-unbounded-get reason: 언어팩은 설치된 팩 × 로케일 수만큼만 존재한다 (사용량과 무관)
return LanguagePack::query()
->where('scope', $scope)
->where('target_identifier', $targetIdentifier)
->get();
}
/**
* 특정 로케일에 속하는 모든 언어팩을 조회합니다.
*
* @param string $locale 로케일
* @return Collection<int, LanguagePack> 언어팩 컬렉션
*/
public function getPacksForLocale(string $locale): Collection
{
// audit:allow query-unbounded-get reason: 언어팩은 설치된 팩 × 로케일 수만큼만 존재한다 (사용량과 무관)
return LanguagePack::query()->where('locale', $locale)->get();
}
/**
* 번들 manifest 로부터 가상 LanguagePack 인스턴스를 합성합니다 (DB 미저장).
*
* Model 인스턴스 생성 책임을 Repository 가 보유 — Service 는 본 메서드를 호출만 합니다.
* exists=false 로 표시되어 영속성 동작에서 제외되며, `bundled_identifier` 가상 속성을
* 함께 채워 Resource 가 행 액션에 노출할 수 있게 합니다.
*
* @param array<string, mixed> $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 = LanguagePackStatus::Uninstalled->value;
$pack->is_protected = false; // lang-packs/_bundled/ 패키지는 사용자가 install/uninstall 자유 (요구사항 #3)
$pack->manifest = $manifest;
$pack->source_type = 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 할 수 없습니다 (요구사항 #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 = TextDirection::Ltr->value;
$pack->version = $version;
$pack->status = LanguagePackStatus::Active->value;
$pack->is_protected = true;
$pack->source_type = 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']);
}
}