From 809409f3cf0b62adc68621c2e353af17712915c4 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Fri, 10 Jul 2026 19:25:04 +0900 Subject: [PATCH] =?UTF-8?q?fix(api-docs):=20=EB=AC=B8=EC=84=9C=20=EC=83=98?= =?UTF-8?q?=ED=94=8C=20=EC=96=B8=EC=96=B4=ED=8C=A9=EC=9D=B4=20=EC=A0=84?= =?UTF-8?q?=EC=97=AD=20=EC=A7=80=EC=9B=90=20=EB=A1=9C=EC=BC=80=EC=9D=BC?= =?UTF-8?q?=EC=9D=84=20=EC=98=A4=EC=97=BC=EC=8B=9C=ED=82=A4=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api:docgen --seed 가 생성하는 샘플 언어팩이 scope=core + status=active 로 만들어져 getActiveCoreLocales 승격 쿼리에 걸렸다. 그 결과 시스템 전역 supported_locales/translatable_locales 가 넓어지고, 그 아래에서 시드된 다국어 데이터에 실재하지 않는 로케일 키가 박혔다. 샘플을 비활성화하면 그 키가 TranslatableField 검증을 통과하지 못해 저장이 막힌다. 문서용 샘플은 조회 예시일 뿐 시스템 동작을 바꿀 자격이 없으므로, 승격 경로를 벗어난 조합(module 스코프 + installed 상태 + 실재 번들 로케일) 으로 생성하도록 바꾸고 audit 룰로 재발을 차단한다. README 재생성 시 사람이 덧붙인 헤딩 섹션이 통째로 소실되던 사전 결함도 함께 수정했다(경계를 임의의 ## 이 아닌 확장 목차 헤딩으로 특정). --- app/Support/ApiDoc/ApiDocSampleService.php | 27 ++++++++---- app/Support/ApiDoc/ApiDocScaffolder.php | 10 ++++- docs/backend/api-documentation.md | 21 ++++++++++ .../Console/ApiDocSampleServiceTest.php | 41 +++++++++++++++++++ 4 files changed, 89 insertions(+), 10 deletions(-) diff --git a/app/Support/ApiDoc/ApiDocSampleService.php b/app/Support/ApiDoc/ApiDocSampleService.php index 4123206a..4d871642 100644 --- a/app/Support/ApiDoc/ApiDocSampleService.php +++ b/app/Support/ApiDoc/ApiDocSampleService.php @@ -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, ]); } diff --git a/app/Support/ApiDoc/ApiDocScaffolder.php b/app/Support/ApiDoc/ApiDocScaffolder.php index d1200043..000a2d1f 100644 --- a/app/Support/ApiDoc/ApiDocScaffolder.php +++ b/app/Support/ApiDoc/ApiDocScaffolder.php @@ -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); diff --git a/docs/backend/api-documentation.md b/docs/backend/api-documentation.md index 7e7427e7..bf3752f5 100644 --- a/docs/backend/api-documentation.md +++ b/docs/backend/api-documentation.md @@ -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-*`) 파라미터/응답 필드 표의 `` 셀 중 **도메인 무관 공통 필드**(페이지네이션·정렬·검색· diff --git a/tests/Feature/Console/ApiDocSampleServiceTest.php b/tests/Feature/Console/ApiDocSampleServiceTest.php index 86897ce3..11c287d9 100644 --- a/tests/Feature/Console/ApiDocSampleServiceTest.php +++ b/tests/Feature/Console/ApiDocSampleServiceTest.php @@ -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', + '문서 샘플이 전역 활성 코어 언어팩으로 생성되었다 — 시스템 지원 로케일이 오염된다' + ); + } }