fix(api-docs): 문서 샘플 언어팩이 전역 지원 로케일을 오염시키는 문제 수정
api:docgen --seed 가 생성하는 샘플 언어팩이 scope=core + status=active 로 만들어져 getActiveCoreLocales 승격 쿼리에 걸렸다. 그 결과 시스템 전역 supported_locales/translatable_locales 가 넓어지고, 그 아래에서 시드된 다국어 데이터에 실재하지 않는 로케일 키가 박혔다. 샘플을 비활성화하면 그 키가 TranslatableField 검증을 통과하지 못해 저장이 막힌다. 문서용 샘플은 조회 예시일 뿐 시스템 동작을 바꿀 자격이 없으므로, 승격 경로를 벗어난 조합(module 스코프 + installed 상태 + 실재 번들 로케일) 으로 생성하도록 바꾸고 audit 룰로 재발을 차단한다. README 재생성 시 사람이 덧붙인 헤딩 섹션이 통째로 소실되던 사전 결함도 함께 수정했다(경계를 임의의 ## 이 아닌 확장 목차 헤딩으로 특정).
This commit is contained in:
@@ -252,6 +252,17 @@ class ApiDocSampleService implements ApiDocSampleSeeder
|
||||
/**
|
||||
* 완전한 언어팩 샘플(manifest 채움)을 생성합니다.
|
||||
*
|
||||
* `scope=core` + `status=active` 조합은 그 locale 을 시스템 전역 지원 로케일로 승격시킨다
|
||||
* (LanguagePackRepository::getActiveCoreLocales → refreshSupportedLocales →
|
||||
* config('app.supported_locales'/'app.translatable_locales')).
|
||||
*
|
||||
* 문서용 샘플은 조회 예시를 만들기 위한 것이지 시스템 동작을 바꿀 자격이 없다. 샘플이 그
|
||||
* 경로를 타면 이후 시드/저장되는 모든 다국어 데이터에 실재하지 않는 로케일 키가 박히고,
|
||||
* 샘플을 비활성화한 뒤에는 그 키가 검증(TranslatableField)을 통과하지 못해 저장이 막힌다.
|
||||
*
|
||||
* 따라서 승격 경로를 벗어난 조합(module 스코프 + installed 상태)으로 생성하며, locale 도
|
||||
* 실재하는 번들 로케일만 사용한다.
|
||||
*
|
||||
* @return array{model: class-string, key: string, value: string} 대표 레코드 정보
|
||||
*/
|
||||
private function seedLanguagePack(): array
|
||||
@@ -264,27 +275,27 @@ class ApiDocSampleService implements ApiDocSampleSeeder
|
||||
$pack = LanguagePack::create([
|
||||
'identifier' => 'apidoc-sample-lang',
|
||||
'vendor' => 'apidoc',
|
||||
'scope' => 'core',
|
||||
'target_identifier' => null,
|
||||
'locale' => 'fr',
|
||||
'locale_name' => 'French',
|
||||
'locale_native_name' => 'Français',
|
||||
'scope' => 'module',
|
||||
'target_identifier' => 'apidoc-sample-module',
|
||||
'locale' => 'ja',
|
||||
'locale_name' => 'Japanese',
|
||||
'locale_native_name' => '日本語',
|
||||
'text_direction' => 'ltr',
|
||||
'version' => '1.0.0',
|
||||
'latest_version' => '1.0.0',
|
||||
'license' => 'MIT',
|
||||
'description' => ['ko' => '문서 실측용 언어팩', 'en' => 'Sample language pack'],
|
||||
'status' => 'active',
|
||||
'status' => 'installed',
|
||||
'is_protected' => false,
|
||||
'manifest' => [
|
||||
'name' => ['ko' => 'API 문서 샘플 언어팩', 'en' => 'API Doc Sample Pack'],
|
||||
'version' => '1.0.0',
|
||||
'locale' => 'fr',
|
||||
'locale' => 'ja',
|
||||
],
|
||||
'source_type' => 'bundled',
|
||||
'installed_by' => $installer?->id,
|
||||
'installed_at' => now()->subDays(3),
|
||||
'activated_at' => now()->subDays(3),
|
||||
'activated_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +126,11 @@ class ApiDocScaffolder
|
||||
*/
|
||||
private const GEN_KEY_INDEX = 'api-readme-index';
|
||||
|
||||
/**
|
||||
* @var string 코어 README 의 확장 API 목차 섹션 헤딩 (사람 서술 보존 경계)
|
||||
*/
|
||||
private const EXTENSIONS_HEADING = '확장 API 레퍼런스';
|
||||
|
||||
/**
|
||||
* 대상(코어/확장)의 API 문서 디렉토리 목차(README.md)를 생성합니다.
|
||||
*
|
||||
@@ -245,7 +250,7 @@ class ApiDocScaffolder
|
||||
});
|
||||
|
||||
$lines = [];
|
||||
$lines[] = '## 확장 API 레퍼런스';
|
||||
$lines[] = '## '.self::EXTENSIONS_HEADING;
|
||||
$lines[] = '';
|
||||
$lines[] = '> 각 확장이 자신의 API 문서를 소유합니다. 아래 표는 자동 생성됩니다.';
|
||||
$lines[] = '';
|
||||
@@ -995,7 +1000,8 @@ class ApiDocScaffolder
|
||||
$afterGen = substr($existing, $endPos + strlen(self::GEN_END));
|
||||
|
||||
// 확장 목차 섹션(`## 확장 API 레퍼런스`)부터는 생성 블록 소관이므로 제외한다.
|
||||
$nextSection = preg_match('/\n## /', $afterGen, $m, PREG_OFFSET_CAPTURE)
|
||||
// 임의의 `## ` 헤딩에서 자르면 사람이 덧붙인 헤딩 섹션이 재생성 때마다 통째로 소실된다.
|
||||
$nextSection = preg_match('/\n## '.preg_quote(self::EXTENSIONS_HEADING, '/').'/', $afterGen, $m, PREG_OFFSET_CAPTURE)
|
||||
? $m[0][1]
|
||||
: strlen($afterGen);
|
||||
|
||||
|
||||
@@ -289,6 +289,27 @@ php artisan api:docgen --scope=core --dry-run
|
||||
- 확장에 새 PHP 클래스를 추가했으므로 `_bundled` 작업 후 `{type}:update {id} --force` 로 활성 디렉토리에
|
||||
반영해야 오토로드된다.
|
||||
|
||||
#### 샘플은 시스템 동작을 바꾸지 않는다
|
||||
|
||||
`--seed` 로 만든 샘플 레코드는 개발 DB 에 영구 잔존한다. 조회 예시를 만들기 위한 데이터일 뿐이므로,
|
||||
전역 설정을 읽는 쿼리에 걸리는 조합으로 생성해서는 안 된다.
|
||||
|
||||
| ❌ 금지 | ✅ 올바른 사용 |
|
||||
| --- | --- |
|
||||
| `LanguagePack::create(['scope' => 'core', 'status' => 'active', ...])` | `['scope' => 'module', 'target_identifier' => '...', 'status' => 'installed']` |
|
||||
| 실재하지 않는 로케일/통화/국가 코드를 샘플 값으로 사용 | 실재하는 번들 값만 사용 (`ja` 등) |
|
||||
|
||||
`scope=core` + `status=active` 조합은 `LanguagePackRepository::getActiveCoreLocales()` 의 승격 쿼리와
|
||||
일치한다. 걸리면 그 locale 이 `config('app.supported_locales')` / `config('app.translatable_locales')` 로
|
||||
올라가 시스템 전역이 바뀐다. 그 아래에서 시드·저장되는 모든 다국어 데이터에 실재하지 않는 로케일 키가
|
||||
박히고, 샘플을 비활성화한 뒤에는 그 키가 `TranslatableField` 검증을 통과하지 못해 해당 엔티티의 저장이
|
||||
막힌다. 오염 시점과 증상 발현 시점이 떨어져 있어 원인 추적이 어렵다.
|
||||
|
||||
같은 원리로 `is_default` / `status=active` 같은 승격·활성 플래그를 샘플에 붙이지 않는다. 샘플 레코드를
|
||||
만들기 전에 "이 조합이 전역 설정을 읽는 쿼리에 걸리는가" 를 확인한다.
|
||||
|
||||
자동 차단: audit 룰 `apidoc-sample-no-global-active-language-pack` (severity: error).
|
||||
|
||||
### 설명 백필 커맨드 (`api:docgen-backfill-*`)
|
||||
|
||||
파라미터/응답 필드 표의 `<!-- TODO: 설명 -->` 셀 중 **도메인 무관 공통 필드**(페이지네이션·정렬·검색·
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
namespace Tests\Feature\Console;
|
||||
|
||||
use App\Contracts\Repositories\LanguagePackRepositoryInterface;
|
||||
use App\Models\LanguagePack;
|
||||
use App\Models\User;
|
||||
use App\Services\LanguagePack\LanguagePackRegistry;
|
||||
use App\Support\ApiDoc\ApiDocSampleService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
@@ -13,6 +16,9 @@ use Tests\TestCase;
|
||||
*
|
||||
* 도메인별 완전 샘플이 생성되고, 재실행 시 중복 없이 멱등하며,
|
||||
* 완전 사용자 샘플의 프로필 필드가 채워지는지 검증한다.
|
||||
*
|
||||
* 문서용 샘플은 조회 예시를 만들기 위한 것이지 시스템 동작을 바꿀 자격이 없다.
|
||||
* 샘플 언어팩이 전역 지원 로케일 목록을 넓히지 않는지 함께 검증한다.
|
||||
*/
|
||||
class ApiDocSampleServiceTest extends TestCase
|
||||
{
|
||||
@@ -64,4 +70,39 @@ class ApiDocSampleServiceTest extends TestCase
|
||||
$this->assertSame(1, $countAfterFirst);
|
||||
$this->assertSame(1, $countAfterSecond);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 샘플_언어팩은_활성_코어_로케일_목록을_넓히지_않는다(): void
|
||||
{
|
||||
// 활성(active) 상태의 코어(core) 언어팩은 그 locale 이 전역 지원 로케일로 승격된다.
|
||||
// 문서용 샘플이 이 경로를 타면 시스템 전체의 번역 가능 로케일이 바뀌고,
|
||||
// 그 상태에서 시드된 다국어 데이터에 존재하지 않는 로케일 키가 박힌다.
|
||||
$before = app(LanguagePackRegistry::class)->getActiveCoreLocales();
|
||||
|
||||
(new ApiDocSampleService)->seed();
|
||||
|
||||
// 레지스트리는 요청 스코프 캐시를 들고 있으므로 새 인스턴스로 재조회
|
||||
$after = (new LanguagePackRegistry(app(LanguagePackRepositoryInterface::class)))
|
||||
->getActiveCoreLocales();
|
||||
|
||||
sort($before);
|
||||
sort($after);
|
||||
|
||||
$this->assertSame($before, $after, '샘플 시드가 전역 활성 코어 로케일 목록을 변경했다');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function 샘플_언어팩은_전역_활성_코어_팩으로_생성되지_않는다(): void
|
||||
{
|
||||
(new ApiDocSampleService)->seed();
|
||||
|
||||
$pack = LanguagePack::where('identifier', 'apidoc-sample-lang')->first();
|
||||
$this->assertNotNull($pack, '언어팩 도메인의 문서 샘플은 존재해야 한다');
|
||||
|
||||
// scope=core + status=active 조합만이 전역 로케일 승격 경로다.
|
||||
$this->assertFalse(
|
||||
$pack->scope === 'core' && $pack->status === 'active',
|
||||
'문서 샘플이 전역 활성 코어 언어팩으로 생성되었다 — 시스템 지원 로케일이 오염된다'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user