From 21f666e4b52cb732f769b86a63b1cba929bd946a Mon Sep 17 00:00:00 2001 From: HeuJung Date: Fri, 17 Jul 2026 19:59:40 +0900 Subject: [PATCH 01/11] =?UTF-8?q?feat(seo):=20=EC=82=AC=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EB=A7=B5=20=EC=8A=A4=ED=8A=B8=EB=A6=AC=EB=B0=8D=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20+=20SEO=20=EC=BA=90=EC=8B=9C=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EC=A0=95=ED=95=A9=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 대용량 사이트에서 사이트맵 생성이 메모리 부족으로 반복 실패하던 문제(공개 )의 1단계로, 전체 URL을 메모리에 적재하던 구조를 스트리밍 분할 생성으로 교체한다. 사이트맵 스트리밍 코어: - SitemapWriter — 자식 파일 1개 분량만 버퍼링하고 임계 도달 시 flush. StorageInterface가 append를 제공하지 않아(put이 전체 덮어쓰기) 택한 구조이며, 최대 메모리가 "자식 파일 1개 크기"로 유계가 된다. URL 수(50,000)와 바이트(45MB) 두 임계로 분할해 sitemaps.org 프로토콜을 지킨다. - 커밋은 _tmp 전량 기록 → live 재배치 → manifest 마지막 기록 순. manifest 존재가 커밋 완료 신호다. 기존 manifest를 먼저 지우지 않아 스왑 중 봇에게 503이 나가지 않는다. - SitemapFileStore — 읽기측 + 파일 경로 SSoT. SitemapXmlRenderer — escape 단일 출처. 데이터 접근 계층 정리: - contributor 3종이 쿼리를 직접 조립하던 것을 Repository 위임으로 전환. MySQL 버퍼드 쿼리에서 cursor는 결과셋 전체를 적재하므로 lazyById를 쓴다. - SeoCacheStatsService → SeoCacheStatRepository 신설 (집계 4쿼리 → 1쿼리). - audit 룰 service-direct-data-access의 사각 2건 수정 — 확장 디렉토리 정규식이 상대경로를 매칭하지 못해 *Contributor처럼 접미사 없는 클래스가 전부 미검출이었다. SEO 캐시 설정 정합화: - SEO 설정의 캐시 3키가 cache 카테고리 형제 키에 항상 져서 운영자 입력이 조용히 무시되고 있었다(설정을 cache 카테고리로 이관하며 SEO 탭 옛 칸을 미제거). 고급 탭을 기준값으로 두되 SEO 탭에 지정이 있으면 오버라이드하도록 SeoCacheSettings 단일 출처로 정리. "지정 여부"는 null로 판정하므로 기본값을 비웠다. - 기존 설치는 Upgrade_7_1_0이 이행 — 옛 기본값과 같으면 미설정으로 비우고, 다르면 운영자 의도로 보아 보존한다. 코어 버전 bump와 루트 CHANGELOG는 계획서 결정(D20)에 따라 마감 단계에서 일괄 수행한다. --- .../SeoCacheStatRepositoryInterface.php | 43 ++ .../Api/Public/SitemapController.php | 6 +- app/Providers/CoreServiceProvider.php | 5 + app/Repositories/SeoCacheStatRepository.php | 135 ++++ app/Seo/SeoCacheManager.php | 8 +- app/Seo/SeoCacheSettings.php | 64 ++ app/Seo/SeoCacheStatsService.php | 242 ++++--- app/Seo/SeoServiceProvider.php | 11 + app/Seo/SitemapFileStore.php | 207 ++++++ app/Seo/SitemapGenerator.php | 184 ++--- app/Seo/SitemapManager.php | 53 +- app/Seo/SitemapWriter.php | 338 +++++++++ app/Seo/SitemapXmlRenderer.php | 249 +++++++ config/settings/defaults.json | 6 +- docs/backend/api/seo.md | 2 +- lang-packs/_bundled/g7-core-ja/CHANGELOG.md | 6 + .../g7-core-ja/backend/ja/exceptions.php | 6 + .../_bundled/g7-core-ja/language-pack.json | 2 +- .../CHANGELOG.md | 6 + .../frontend/partial/admin.json | 6 +- .../language-pack.json | 2 +- lang/en/exceptions.php | 8 + lang/ko/exceptions.php | 8 + modules/_bundled/sirsoft-board/CHANGELOG.md | 10 + modules/_bundled/sirsoft-board/composer.json | 2 +- modules/_bundled/sirsoft-board/module.json | 2 +- .../_bundled/sirsoft-board/package-lock.json | 4 +- modules/_bundled/sirsoft-board/package.json | 2 +- .../src/Providers/BoardServiceProvider.php | 3 +- .../src/Repositories/BoardRepository.php | 56 +- .../Contracts/BoardRepositoryInterface.php | 59 +- .../Contracts/PostRepositoryInterface.php | 14 + .../src/Repositories/PostRepository.php | 31 + .../src/Seo/BoardSitemapContributor.php | 88 ++- .../BoardRepositorySitemapTest.php | 140 ++++ .../Unit/Seo/BoardSitemapContributorTest.php | 57 +- .../_bundled/sirsoft-ecommerce/CHANGELOG.md | 10 + .../_bundled/sirsoft-ecommerce/composer.json | 2 +- .../_bundled/sirsoft-ecommerce/module.json | 2 +- .../sirsoft-ecommerce/package-lock.json | 4 +- .../_bundled/sirsoft-ecommerce/package.json | 2 +- .../Providers/EcommerceServiceProvider.php | 3 +- .../src/Repositories/CategoryRepository.php | 23 +- .../Contracts/CategoryRepositoryInterface.php | 66 +- .../Contracts/ProductRepositoryInterface.php | 10 + .../src/Repositories/ProductRepository.php | 18 + .../src/Seo/EcommerceSitemapContributor.php | 78 ++- .../Seo/EcommerceSitemapContributorTest.php | 102 ++- .../sirsoft-ecommerce/vendor-bundle.json | 8 +- .../sirsoft-ecommerce/vendor-bundle.zip | Bin 435549 -> 435549 bytes modules/_bundled/sirsoft-page/CHANGELOG.md | 10 + modules/_bundled/sirsoft-page/composer.json | 2 +- modules/_bundled/sirsoft-page/module.json | 2 +- .../_bundled/sirsoft-page/package-lock.json | 4 +- modules/_bundled/sirsoft-page/package.json | 2 +- .../src/Providers/PageServiceProvider.php | 3 +- .../Contracts/PageRepositoryInterface.php | 16 +- .../src/Repositories/PageRepository.php | 18 + .../src/Seo/PageSitemapContributor.php | 53 +- .../Unit/Seo/PageSitemapContributorTest.php | 120 ++++ .../lang/partial/en/admin.json | 6 +- .../lang/partial/ko/admin.json | 6 +- .../Unit/Seo/BoardSitemapContributorTest.php | 181 ----- .../Seo/EcommerceSitemapContributorTest.php | 233 ------- tests/Unit/Seo/PageSitemapContributorTest.php | 161 ----- tests/Unit/Seo/SeoCacheSettingsTest.php | 162 +++++ tests/Unit/Seo/SeoCacheStatsServiceTest.php | 3 +- tests/Unit/Seo/SitemapFileStoreTest.php | 277 ++++++++ tests/Unit/Seo/SitemapGeneratorTest.php | 249 ++++++- tests/Unit/Seo/SitemapManagerTest.php | 384 +++++++++++ tests/Unit/Seo/SitemapWriterTest.php | 639 ++++++++++++++++++ tests/Unit/Seo/SitemapXmlRendererTest.php | 337 +++++++++ .../NullifyUntouchedSeoCacheOverridesTest.php | 241 +++++++ upgrades/Upgrade_7_1_0.php | 30 + .../01_NullifyUntouchedSeoCacheOverrides.php | 186 +++++ 75 files changed, 4767 insertions(+), 951 deletions(-) create mode 100644 app/Contracts/Repositories/SeoCacheStatRepositoryInterface.php create mode 100644 app/Repositories/SeoCacheStatRepository.php create mode 100644 app/Seo/SeoCacheSettings.php create mode 100644 app/Seo/SitemapFileStore.php create mode 100644 app/Seo/SitemapWriter.php create mode 100644 app/Seo/SitemapXmlRenderer.php create mode 100644 modules/_bundled/sirsoft-board/tests/Unit/Repositories/BoardRepositorySitemapTest.php create mode 100644 modules/_bundled/sirsoft-page/tests/Unit/Seo/PageSitemapContributorTest.php delete mode 100644 tests/Unit/Seo/BoardSitemapContributorTest.php delete mode 100644 tests/Unit/Seo/EcommerceSitemapContributorTest.php delete mode 100644 tests/Unit/Seo/PageSitemapContributorTest.php create mode 100644 tests/Unit/Seo/SeoCacheSettingsTest.php create mode 100644 tests/Unit/Seo/SitemapFileStoreTest.php create mode 100644 tests/Unit/Seo/SitemapManagerTest.php create mode 100644 tests/Unit/Seo/SitemapWriterTest.php create mode 100644 tests/Unit/Seo/SitemapXmlRendererTest.php create mode 100644 tests/Unit/Upgrades/NullifyUntouchedSeoCacheOverridesTest.php create mode 100644 upgrades/Upgrade_7_1_0.php create mode 100644 upgrades/data/7.1.0/migrations/01_NullifyUntouchedSeoCacheOverrides.php diff --git a/app/Contracts/Repositories/SeoCacheStatRepositoryInterface.php b/app/Contracts/Repositories/SeoCacheStatRepositoryInterface.php new file mode 100644 index 00000000..c8b7cda9 --- /dev/null +++ b/app/Contracts/Repositories/SeoCacheStatRepositoryInterface.php @@ -0,0 +1,43 @@ + $attributes 기록할 속성 (url, locale, layout_name, module_identifier, type, response_time_ms) + */ + public function record(array $attributes): void; + + /** + * 전체 캐시 통계를 집계합니다. + * + * @param Carbon|null $since 집계 시작 시점 (null 이면 전체 기간) + * @return array{total: int, hits: int, misses: int, avg_response_time_ms: float|null} 집계 결과 + */ + public function aggregate(?Carbon $since = null): array; + + /** + * 지정한 컬럼으로 그룹화하여 캐시 통계를 집계합니다. + * + * @param string $groupBy 그룹 기준 컬럼 (layout_name | module_identifier) + * @param Carbon|null $since 집계 시작 시점 (null 이면 전체 기간) + * @return array 그룹별 집계 결과 + */ + public function aggregateGrouped(string $groupBy, ?Carbon $since = null): array; + + /** + * 기준 시점보다 오래된 통계 레코드를 삭제합니다. + * + * @param Carbon $cutoff 기준 시점 + * @return int 삭제된 레코드 수 + */ + public function deleteOlderThan(Carbon $cutoff): int; +} diff --git a/app/Http/Controllers/Api/Public/SitemapController.php b/app/Http/Controllers/Api/Public/SitemapController.php index 9a5d6e88..4c55e6d9 100644 --- a/app/Http/Controllers/Api/Public/SitemapController.php +++ b/app/Http/Controllers/Api/Public/SitemapController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Public; use App\Contracts\Extension\CacheInterface; use App\Http\Controllers\Controller; +use App\Seo\SeoCacheSettings; use App\Seo\SitemapGenerator; use Illuminate\Http\Response; @@ -22,6 +23,7 @@ class SitemapController extends Controller * * @param SitemapGenerator $generator Sitemap 생성기 * @param CacheInterface $cache 코어 캐시 드라이버 + * @return Response Sitemap XML 응답 */ public function index(SitemapGenerator $generator, CacheInterface $cache): Response { @@ -34,8 +36,8 @@ class SitemapController extends Controller if (! $xml) { $xml = $generator->generate(); - $ttl = (int) g7_core_settings('cache.seo_sitemap_ttl', g7_core_settings('seo.sitemap_cache_ttl', 86400)); - $cache->put('seo.sitemap', $xml, $ttl); + // 고급 탭(cache.seo_sitemap_ttl)이 메인, SEO 탭에 별도 지정이 있으면 그것이 우선 (D19) + $cache->put('seo.sitemap', $xml, SeoCacheSettings::sitemapCacheTtl()); } return response($xml, 200, [ diff --git a/app/Providers/CoreServiceProvider.php b/app/Providers/CoreServiceProvider.php index 00cbac47..d827d885 100644 --- a/app/Providers/CoreServiceProvider.php +++ b/app/Providers/CoreServiceProvider.php @@ -32,6 +32,7 @@ use App\Contracts\Repositories\PluginRepositoryInterface; use App\Contracts\Repositories\RoleRepositoryInterface; use App\Contracts\Repositories\ScheduleHistoryRepositoryInterface; use App\Contracts\Repositories\ScheduleRepositoryInterface; +use App\Contracts\Repositories\SeoCacheStatRepositoryInterface; use App\Contracts\Repositories\SystemConfigRepositoryInterface; use App\Contracts\Repositories\TemplateCustomTranslationRepositoryInterface; use App\Contracts\Repositories\TemplateLayoutAttachmentRepositoryInterface; @@ -79,6 +80,7 @@ use App\Repositories\PluginRepository; use App\Repositories\RoleRepository; use App\Repositories\ScheduleHistoryRepository; use App\Repositories\ScheduleRepository; +use App\Repositories\SeoCacheStatRepository; use App\Repositories\SystemConfigRepository; use App\Repositories\TemplateCustomTranslationRepository; use App\Repositories\TemplateLayoutAttachmentRepository; @@ -216,6 +218,9 @@ class CoreServiceProvider extends ServiceProvider $this->app->bind(NotificationRepositoryInterface::class, NotificationRepository::class); $this->app->bind(NotificationTemplateRepositoryInterface::class, NotificationTemplateRepository::class); + // SEO Repository 바인딩 + $this->app->bind(SeoCacheStatRepositoryInterface::class, SeoCacheStatRepository::class); + // IdentityVerification Repository 바인딩 $this->app->bind(IdentityVerificationLogRepositoryInterface::class, IdentityVerificationLogRepository::class); $this->app->bind(IdentityPolicyRepositoryInterface::class, IdentityPolicyRepository::class); diff --git a/app/Repositories/SeoCacheStatRepository.php b/app/Repositories/SeoCacheStatRepository.php new file mode 100644 index 00000000..a6bb968e --- /dev/null +++ b/app/Repositories/SeoCacheStatRepository.php @@ -0,0 +1,135 @@ + + */ + private const GROUPABLE_COLUMNS = ['layout_name', 'module_identifier']; + + /** + * 캐시 통계 레코드를 기록합니다. + * + * @param array $attributes 기록할 속성 + */ + public function record(array $attributes): void + { + SeoCacheStat::create($attributes); + } + + /** + * 전체 캐시 통계를 집계합니다. + * + * @param Carbon|null $since 집계 시작 시점 (null 이면 전체 기간) + * @return array{total: int, hits: int, misses: int, avg_response_time_ms: float|null} 집계 결과 + */ + public function aggregate(?Carbon $since = null): array + { + $row = $this->baseQuery($since) + ->select($this->aggregateSelects()) + ->first(); + + return [ + 'total' => (int) ($row->total ?? 0), + 'hits' => (int) ($row->hits ?? 0), + 'misses' => (int) ($row->misses ?? 0), + 'avg_response_time_ms' => $row?->avg_response_time_ms !== null + ? (float) $row->avg_response_time_ms + : null, + ]; + } + + /** + * 지정한 컬럼으로 그룹화하여 캐시 통계를 집계합니다. + * + * @param string $groupBy 그룹 기준 컬럼 (layout_name | module_identifier) + * @param Carbon|null $since 집계 시작 시점 (null 이면 전체 기간) + * @return array 그룹별 집계 결과 + * + * @throws \InvalidArgumentException 허용되지 않은 그룹 컬럼인 경우 + */ + public function aggregateGrouped(string $groupBy, ?Carbon $since = null): array + { + if (! in_array($groupBy, self::GROUPABLE_COLUMNS, true)) { + throw new \InvalidArgumentException( + __('exceptions.seo.sitemap_stat_group_column_unsupported', ['column' => $groupBy]) + ); + } + + return $this->baseQuery($since) + ->select(array_merge([$groupBy], $this->aggregateSelects())) + ->groupBy($groupBy) + ->get() + ->map(fn ($row): array => [ + 'group' => $row->{$groupBy}, + 'total' => (int) $row->total, + 'hits' => (int) $row->hits, + 'misses' => (int) $row->misses, + 'avg_response_time_ms' => $row->avg_response_time_ms !== null + ? (float) $row->avg_response_time_ms + : null, + ]) + ->all(); + } + + /** + * 기준 시점보다 오래된 통계 레코드를 삭제합니다. + * + * @param Carbon $cutoff 기준 시점 + * @return int 삭제된 레코드 수 + */ + public function deleteOlderThan(Carbon $cutoff): int + { + return SeoCacheStat::where('created_at', '<', $cutoff)->delete(); + } + + /** + * 기간 필터가 적용된 기본 쿼리를 반환합니다. + * + * @param Carbon|null $since 집계 시작 시점 + * @return Builder 쿼리 빌더 + */ + private function baseQuery(?Carbon $since): Builder + { + $query = SeoCacheStat::query(); + + if ($since) { + $query->since($since); + } + + return $query; + } + + /** + * 집계 select 절을 반환합니다. + * + * hit/miss 는 단일 순회로 집계하기 위해 조건부 합계를 사용합니다. + * + * @return array select 표현식 배열 + */ + private function aggregateSelects(): array + { + return [ + DB::raw('COUNT(*) as total'), + DB::raw("SUM(CASE WHEN type = 'hit' THEN 1 ELSE 0 END) as hits"), + DB::raw("SUM(CASE WHEN type = 'miss' THEN 1 ELSE 0 END) as misses"), + DB::raw("AVG(CASE WHEN type = 'miss' AND response_time_ms IS NOT NULL THEN response_time_ms ELSE NULL END) as avg_response_time_ms"), + ]; + } +} diff --git a/app/Seo/SeoCacheManager.php b/app/Seo/SeoCacheManager.php index d64f803c..0df68d37 100644 --- a/app/Seo/SeoCacheManager.php +++ b/app/Seo/SeoCacheManager.php @@ -133,19 +133,19 @@ class SeoCacheManager implements SeoCacheManagerInterface } /** - * 캐시 활성화 여부 확인 + * 캐시 활성화 여부 확인 — 고급 탭 값을 쓰되 SEO 탭에 별도 지정이 있으면 그것이 우선 (D19). */ private function isEnabled(): bool { - return (bool) g7_core_settings('cache.seo_enabled', g7_core_settings('seo.cache_enabled', true)); + return SeoCacheSettings::pageCacheEnabled(); } /** - * 캐시 TTL (초) — g7_core_settings('cache.seo_ttl') 우선. + * 캐시 TTL (초) — 고급 탭 값을 쓰되 SEO 탭에 별도 지정이 있으면 그것이 우선 (D19). */ private function getCacheTtl(): int { - return (int) g7_core_settings('cache.seo_ttl', g7_core_settings('seo.cache_ttl', 7200)); + return SeoCacheSettings::pageCacheTtl(); } /** diff --git a/app/Seo/SeoCacheSettings.php b/app/Seo/SeoCacheSettings.php new file mode 100644 index 00000000..065dc2e4 --- /dev/null +++ b/app/Seo/SeoCacheSettings.php @@ -0,0 +1,64 @@ + $url, - 'locale' => $locale, - 'layout_name' => $layoutName, - 'module_identifier' => $moduleIdentifier, - 'type' => 'hit', - ]); - } catch (\Exception $e) { - Log::warning('[SEO] 캐시 히트 기록 실패', [ - 'url' => $url, - 'error' => $e->getMessage(), - ]); - } + $this->record([ + 'url' => $url, + 'locale' => $locale, + 'layout_name' => $layoutName, + 'module_identifier' => $moduleIdentifier, + 'type' => 'hit', + ], '[SEO] 캐시 히트 기록 실패'); } /** * 캐시 미스를 기록합니다. * - * @param string $url 요청 URL - * @param string $locale 로케일 - * @param string|null $layoutName 레이아웃명 - * @param string|null $moduleIdentifier 모듈 식별자 - * @param int|null $responseTimeMs 렌더링 소요 시간 (ms) - * @return void + * @param string $url 요청 URL + * @param string $locale 로케일 + * @param string|null $layoutName 레이아웃명 + * @param string|null $moduleIdentifier 모듈 식별자 + * @param int|null $responseTimeMs 렌더링 소요 시간 (ms) */ public function recordMiss( string $url, @@ -62,142 +62,72 @@ class SeoCacheStatsService ?string $moduleIdentifier = null, ?int $responseTimeMs = null ): void { - try { - SeoCacheStat::create([ - 'url' => $url, - 'locale' => $locale, - 'layout_name' => $layoutName, - 'module_identifier' => $moduleIdentifier, - 'type' => 'miss', - 'response_time_ms' => $responseTimeMs, - ]); - } catch (\Exception $e) { - Log::warning('[SEO] 캐시 미스 기록 실패', [ - 'url' => $url, - 'error' => $e->getMessage(), - ]); - } + $this->record([ + 'url' => $url, + 'locale' => $locale, + 'layout_name' => $layoutName, + 'module_identifier' => $moduleIdentifier, + 'type' => 'miss', + 'response_time_ms' => $responseTimeMs, + ], '[SEO] 캐시 미스 기록 실패'); } /** * 캐시 통계를 조회합니다. * - * @param Carbon|null $since 조회 시작 시점 (null이면 전체 기간) - * @return array{total_entries: int, hits: int, misses: int, hit_rate: float, avg_response_time_ms: float|null} + * @param Carbon|null $since 조회 시작 시점 (null이면 전체 기간) + * @return array{total_entries: int, hits: int, misses: int, hit_rate: float, avg_response_time_ms: float|null} 캐시 통계 */ public function getStats(?Carbon $since = null): array { - $query = SeoCacheStat::query(); - - if ($since) { - $query->since($since); - } - - $total = $query->count(); - $hits = (clone $query)->hits()->count(); - $misses = (clone $query)->misses()->count(); - $avgResponseTime = (clone $query)->misses() - ->whereNotNull('response_time_ms') - ->avg('response_time_ms'); + $stats = $this->statRepository->aggregate($since); return [ - 'total_entries' => $total, - 'hits' => $hits, - 'misses' => $misses, - 'hit_rate' => $total > 0 ? round(($hits / $total) * 100, 2) : 0.0, - 'avg_response_time_ms' => $avgResponseTime !== null ? round((float) $avgResponseTime, 2) : null, + 'total_entries' => $stats['total'], + 'hits' => $stats['hits'], + 'misses' => $stats['misses'], + 'hit_rate' => $this->hitRate($stats['total'], $stats['hits']), + 'avg_response_time_ms' => $this->roundOrNull($stats['avg_response_time_ms']), ]; } /** * 레이아웃별 캐시 통계를 조회합니다. * - * @param Carbon|null $since 조회 시작 시점 (null이면 전체 기간) - * @return array + * @param Carbon|null $since 조회 시작 시점 (null이면 전체 기간) + * @return array 레이아웃별 캐시 통계 */ public function getStatsByLayout(?Carbon $since = null): array { - $query = SeoCacheStat::query() - ->select( - 'layout_name', - DB::raw('COUNT(*) as total'), - DB::raw("SUM(CASE WHEN type = 'hit' THEN 1 ELSE 0 END) as hits"), - DB::raw("SUM(CASE WHEN type = 'miss' THEN 1 ELSE 0 END) as misses"), - DB::raw("AVG(CASE WHEN type = 'miss' AND response_time_ms IS NOT NULL THEN response_time_ms ELSE NULL END) as avg_response_time_ms") - ) - ->groupBy('layout_name'); - - if ($since) { - $query->where('created_at', '>=', $since); - } - - return $query->get()->map(function ($row) { - $total = (int) $row->total; - $hits = (int) $row->hits; - - return [ - 'layout_name' => $row->layout_name, - 'total' => $total, - 'hits' => $hits, - 'misses' => (int) $row->misses, - 'hit_rate' => $total > 0 ? round(($hits / $total) * 100, 2) : 0.0, - 'avg_response_time_ms' => $row->avg_response_time_ms !== null - ? round((float) $row->avg_response_time_ms, 2) - : null, - ]; - })->toArray(); + return array_map( + fn (array $row): array => ['layout_name' => $row['group']] + $this->formatGroupRow($row), + $this->statRepository->aggregateGrouped('layout_name', $since), + ); } /** * 모듈별 캐시 통계를 조회합니다. * - * @param Carbon|null $since 조회 시작 시점 (null이면 전체 기간) - * @return array + * @param Carbon|null $since 조회 시작 시점 (null이면 전체 기간) + * @return array 모듈별 캐시 통계 */ public function getStatsByModule(?Carbon $since = null): array { - $query = SeoCacheStat::query() - ->select( - 'module_identifier', - DB::raw('COUNT(*) as total'), - DB::raw("SUM(CASE WHEN type = 'hit' THEN 1 ELSE 0 END) as hits"), - DB::raw("SUM(CASE WHEN type = 'miss' THEN 1 ELSE 0 END) as misses"), - DB::raw("AVG(CASE WHEN type = 'miss' AND response_time_ms IS NOT NULL THEN response_time_ms ELSE NULL END) as avg_response_time_ms") - ) - ->groupBy('module_identifier'); - - if ($since) { - $query->where('created_at', '>=', $since); - } - - return $query->get()->map(function ($row) { - $total = (int) $row->total; - $hits = (int) $row->hits; - - return [ - 'module_identifier' => $row->module_identifier, - 'total' => $total, - 'hits' => $hits, - 'misses' => (int) $row->misses, - 'hit_rate' => $total > 0 ? round(($hits / $total) * 100, 2) : 0.0, - 'avg_response_time_ms' => $row->avg_response_time_ms !== null - ? round((float) $row->avg_response_time_ms, 2) - : null, - ]; - })->toArray(); + return array_map( + fn (array $row): array => ['module_identifier' => $row['group']] + $this->formatGroupRow($row), + $this->statRepository->aggregateGrouped('module_identifier', $since), + ); } /** * 오래된 통계 레코드를 삭제합니다. * - * @param int $daysToKeep 보존 기간 (일, 기본값: 30) + * @param int $daysToKeep 보존 기간 (일, 기본값: 30) * @return int 삭제된 레코드 수 */ public function cleanup(int $daysToKeep = 30): int { - $cutoff = Carbon::now()->subDays($daysToKeep); - - $deleted = SeoCacheStat::where('created_at', '<', $cutoff)->delete(); + $deleted = $this->statRepository->deleteOlderThan(Carbon::now()->subDays($daysToKeep)); Log::info('[SEO] 캐시 통계 정리 완료', [ 'deleted' => $deleted, @@ -206,4 +136,62 @@ class SeoCacheStatsService return $deleted; } + + /** + * 통계 레코드를 기록하고, 실패해도 요청 흐름을 막지 않습니다. + * + * @param array $attributes 기록할 속성 + * @param string $failureMessage 실패 시 로그 메시지 + */ + private function record(array $attributes, string $failureMessage): void + { + try { + $this->statRepository->record($attributes); + } catch (\Exception $e) { + Log::warning($failureMessage, [ + 'url' => $attributes['url'] ?? null, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * 그룹 집계 행을 응답 형식으로 변환합니다. + * + * @param array{total: int, hits: int, misses: int, avg_response_time_ms: float|null} $row 집계 행 + * @return array{total: int, hits: int, misses: int, hit_rate: float, avg_response_time_ms: float|null} 변환된 행 + */ + private function formatGroupRow(array $row): array + { + return [ + 'total' => $row['total'], + 'hits' => $row['hits'], + 'misses' => $row['misses'], + 'hit_rate' => $this->hitRate($row['total'], $row['hits']), + 'avg_response_time_ms' => $this->roundOrNull($row['avg_response_time_ms']), + ]; + } + + /** + * 히트율(%)을 계산합니다. + * + * @param int $total 전체 건수 + * @param int $hits 히트 건수 + * @return float 히트율 (소수점 2자리) + */ + private function hitRate(int $total, int $hits): float + { + return $total > 0 ? round(($hits / $total) * 100, 2) : 0.0; + } + + /** + * 값이 있으면 소수점 2자리로 반올림합니다. + * + * @param float|null $value 원본 값 + * @return float|null 반올림된 값 (null 이면 null) + */ + private function roundOrNull(?float $value): ?float + { + return $value !== null ? round($value, 2) : null; + } } diff --git a/app/Seo/SeoServiceProvider.php b/app/Seo/SeoServiceProvider.php index bad62a84..bbc76844 100644 --- a/app/Seo/SeoServiceProvider.php +++ b/app/Seo/SeoServiceProvider.php @@ -2,6 +2,8 @@ namespace App\Seo; +use App\Contracts\Extension\StorageInterface; +use App\Extension\Storage\CoreStorageDriver; use App\Seo\Contracts\SeoCacheManagerInterface; use App\Seo\Contracts\SeoRendererInterface; use Illuminate\Support\ServiceProvider; @@ -28,6 +30,15 @@ class SeoServiceProvider extends ServiceProvider $this->app->singleton(SeoMetaResolver::class); $this->app->singleton(SitemapGenerator::class); $this->app->singleton(SeoConfigMerger::class); + $this->app->singleton(SitemapFileStore::class); + + // Sitemap 파일 입출력은 비공개 디스크(local = storage/app/private, serve=false)를 사용합니다. + // public/storage 심볼릭 링크에 의존하지 않고 컨트롤러 스트리밍으로 서빙합니다. + foreach ([SitemapManager::class, SitemapFileStore::class] as $sitemapConsumer) { + $this->app->when($sitemapConsumer) + ->needs(StorageInterface::class) + ->give(fn () => new CoreStorageDriver('local')); + } } /** diff --git a/app/Seo/SitemapFileStore.php b/app/Seo/SitemapFileStore.php new file mode 100644 index 00000000..f34d8a78 --- /dev/null +++ b/app/Seo/SitemapFileStore.php @@ -0,0 +1,207 @@ + 값이며, web 라우트 `/sitemap-{n}.xml` 과 대응합니다. + * + * @param int $n 자식 번호 (1부터) + * @param bool $gzip gzip 압축 여부 + * @return string 자식 sitemap 절대 URL + */ + public static function childUrl(int $n, bool $gzip = false): string + { + return url('/'.self::childFilename($n, $gzip)); + } + + /** + * 커밋된 sitemap 세트가 존재하는지 확인합니다. + * + * manifest.json 존재 = 커밋 완료 신호입니다. + * + * @return bool 서빙 가능한 세트 존재 여부 + */ + public function exists(): bool + { + return $this->storage->exists(self::CATEGORY, self::LIVE_DIR.'/'.self::MANIFEST_FILE); + } + + /** + * manifest 메타데이터를 반환합니다. + * + * @return array{generated_at?: string, url_count?: int, child_count?: int, size_bytes?: int, gzip?: bool, children?: array}|null + * manifest 배열 (없거나 손상 시 null) + */ + public function getManifest(): ?array + { + $raw = $this->storage->get(self::CATEGORY, self::LIVE_DIR.'/'.self::MANIFEST_FILE); + if ($raw === null || $raw === '') { + return null; + } + + $decoded = json_decode($raw, true); + if (! is_array($decoded)) { + Log::warning('[SEO] Sitemap manifest 손상', ['path' => self::LIVE_DIR.'/'.self::MANIFEST_FILE]); + + return null; + } + + return $decoded; + } + + /** + * sitemapindex 를 스트리밍 응답으로 반환합니다. + * + * @return StreamedResponse|null 인덱스 스트림 (세트가 없으면 null) + */ + public function indexResponse(): ?StreamedResponse + { + if (! $this->exists()) { + return null; + } + + return $this->storage->response( + self::CATEGORY, + self::LIVE_DIR.'/'.self::INDEX_FILE, + self::INDEX_FILE, + ['Content-Type' => 'application/xml'], + ); + } + + /** + * 자식 sitemap 을 스트리밍 응답으로 반환합니다. + * + * 실제 저장된 파일명은 manifest 에서 조회하므로, 생성 이후 gzip 설정이 + * 바뀌어도 커밋된 세트를 그대로 서빙합니다. + * + * @param int $n 자식 번호 (1부터) + * @return StreamedResponse|null 자식 스트림 (없으면 null) + */ + public function childResponse(int $n): ?StreamedResponse + { + $filename = $this->childFile($n); + if ($filename === null) { + return null; + } + + $headers = ['Content-Type' => 'application/xml']; + if (str_ends_with($filename, '.gz')) { + $headers['Content-Encoding'] = 'gzip'; + } + + return $this->storage->response( + self::CATEGORY, + self::LIVE_DIR.'/'.$filename, + $filename, + $headers, + ); + } + + /** + * 커밋된 sitemap 의 생성 시각을 반환합니다. + * + * @return string|null ISO8601 생성 시각 (세트가 없으면 null) + */ + public function lastGeneratedAt(): ?string + { + $manifest = $this->getManifest(); + + return $manifest['generated_at'] ?? null; + } + + /** + * 자식 번호에 해당하는 실제 파일명을 반환합니다. + * + * @param int $n 자식 번호 (1부터) + * @return string|null 파일명 (존재하지 않으면 null) + */ + private function childFile(int $n): ?string + { + if ($n < 1) { + return null; + } + + $manifest = $this->getManifest(); + $children = $manifest['children'] ?? []; + + $filename = $children[$n - 1]['file'] ?? null; + if (! is_string($filename) || $filename === '') { + return null; + } + + if (! $this->storage->exists(self::CATEGORY, self::LIVE_DIR.'/'.$filename)) { + return null; + } + + return $filename; + } +} diff --git a/app/Seo/SitemapGenerator.php b/app/Seo/SitemapGenerator.php index 6c300acf..b843fc1d 100644 --- a/app/Seo/SitemapGenerator.php +++ b/app/Seo/SitemapGenerator.php @@ -40,9 +40,108 @@ class SitemapGenerator $this->contributors[$contributor->getIdentifier()] = $contributor; } + /** + * 수집한 URL을 SitemapWriter로 스트리밍하여 분할 sitemap 파일을 생성합니다. + * + * 기여자를 하나씩 순회하며 URL을 writer 에 한 건씩 넘기므로 + * 전체 URL 배열을 메모리에 병합(array_merge)하지 않습니다. + * + * @param SitemapWriter $writer 분할 기록기 + * @return array{generated_at: string, child_count: int, url_count: int, size_bytes: int, gzip: bool, children: array} + * 커밋된 sitemap 메타데이터 + */ + public function generateToWriter(SitemapWriter $writer): array + { + $writer->open(); + + foreach ($this->collectStaticRoutes() as $entry) { + $writer->addUrl($entry); + } + + foreach ($this->contributors as $contributor) { + try { + foreach ($this->drain($contributor) as $entry) { + $writer->addUrl($this->normalizeEntry($entry)); + } + } catch (\Throwable $e) { + Log::warning('[SEO] Sitemap contributor failed', [ + 'contributor' => $contributor->getIdentifier(), + 'error' => $e->getMessage(), + ]); + } + } + + $writer->close(); + + return $writer->commit(); + } + + /** + * 기여자로부터 URL 항목을 순회 가능한 형태로 가져옵니다. + * + * @param SitemapContributorInterface $contributor Sitemap 기여자 + * @return iterable> URL 항목 순회자 + */ + private function drain(SitemapContributorInterface $contributor): iterable + { + return $contributor->getUrls(); + } + + /** + * 기여자 URL 항목의 'url' 키를 'loc'(절대 URL)으로 변환합니다. + * + * @param array $entry URL 항목 + * @return array 변환된 URL 항목 + */ + private function normalizeEntry(array $entry): array + { + if (isset($entry['url']) && ! isset($entry['loc'])) { + $entry['loc'] = url($entry['url']); + unset($entry['url']); + } + + return $entry; + } + + /** + * URL 항목 하나를 블록 XML로 렌더링합니다. + * + * XML 이스케이프의 단일 출처는 SitemapXmlRenderer 이며 이 메서드는 위임합니다. + * + * @param array{loc?: string, lastmod?: string, changefreq?: string, priority?: float} $entry URL 항목 + * @return string 블록 문자열 + */ + public function renderUrlBlock(array $entry): string + { + return SitemapXmlRenderer::fromConfig()->urlBlock($entry); + } + + /** + * urlset 여는 태그(XML 선언 포함)를 반환합니다. + * + * @return string urlset 헤더 문자열 + */ + public function buildUrlsetHeader(): string + { + return SitemapXmlRenderer::fromConfig()->urlsetHeader(); + } + + /** + * urlset 닫는 태그를 반환합니다. + * + * @return string urlset 푸터 문자열 + */ + public function buildUrlsetFooter(): string + { + return SitemapXmlRenderer::fromConfig()->urlsetFooter(); + } + /** * 전체 Sitemap URL을 수집하여 XML을 생성합니다. * + * 전체 URL을 메모리에 적재하므로 소규모 사이트 / 하위호환 용도로만 사용합니다. + * 대용량은 generateToWriter() 를 사용합니다. + * * @return string sitemap XML 문자열 */ public function generate(): string @@ -55,16 +154,11 @@ class SitemapGenerator // 2. 기여자들의 동적 URL 수집 foreach ($this->contributors as $contributor) { try { - $contributorUrls = $contributor->getUrls(); - // 기여자 URL의 'url' 키를 'loc'(절대 URL)으로 변환 - foreach ($contributorUrls as &$entry) { - if (isset($entry['url']) && ! isset($entry['loc'])) { - $entry['loc'] = url($entry['url']); - unset($entry['url']); - } - } - unset($entry); + $contributorUrls = array_map( + fn (array $entry): array => $this->normalizeEntry($entry), + $contributor->getUrls(), + ); $urls = array_merge($urls, $contributorUrls); } catch (\Throwable $e) { @@ -170,79 +264,15 @@ class SitemapGenerator */ private function buildXml(array $urls): string { - $defaultLocale = config('app.locale'); - $supportedLocales = config('app.supported_locales', [$defaultLocale]); - $isMultilingual = count($supportedLocales) > 1; + $renderer = SitemapXmlRenderer::fromConfig(); - $xml = ''."\n"; - - if ($isMultilingual) { - $xml .= 'urlsetHeader(); foreach ($urls as $entry) { - $baseLoc = $entry['loc']; - - if ($isMultilingual) { - // 각 로케일별 항목 생성 - foreach ($supportedLocales as $locale) { - $localeLoc = $locale === $defaultLocale - ? $baseLoc - : $baseLoc.'?locale='.$locale; - - $xml .= ' '."\n"; - $xml .= ' '.htmlspecialchars($localeLoc, ENT_XML1, 'UTF-8').''."\n"; - - if (! empty($entry['lastmod'])) { - $xml .= ' '.htmlspecialchars($entry['lastmod'], ENT_XML1, 'UTF-8').''."\n"; - } - - if (! empty($entry['changefreq'])) { - $xml .= ' '.htmlspecialchars($entry['changefreq'], ENT_XML1, 'UTF-8').''."\n"; - } - - if (isset($entry['priority'])) { - $xml .= ' '.number_format((float) $entry['priority'], 1).''."\n"; - } - - // 모든 로케일의 hreflang alternate 링크 - foreach ($supportedLocales as $altLocale) { - $altHref = $altLocale === $defaultLocale - ? $baseLoc - : $baseLoc.'?locale='.$altLocale; - $xml .= ' '."\n"; - } - - // x-default = 기본 로케일 URL - $xml .= ' '."\n"; - - $xml .= ' '."\n"; - } - } else { - // 단일 언어: 기존 형식 유지 - $xml .= ' '."\n"; - $xml .= ' '.htmlspecialchars($baseLoc, ENT_XML1, 'UTF-8').''."\n"; - - if (! empty($entry['lastmod'])) { - $xml .= ' '.htmlspecialchars($entry['lastmod'], ENT_XML1, 'UTF-8').''."\n"; - } - - if (! empty($entry['changefreq'])) { - $xml .= ' '.htmlspecialchars($entry['changefreq'], ENT_XML1, 'UTF-8').''."\n"; - } - - if (isset($entry['priority'])) { - $xml .= ' '.number_format((float) $entry['priority'], 1).''."\n"; - } - - $xml .= ' '."\n"; - } + $xml .= $renderer->urlBlock($entry); } - $xml .= ''; + $xml .= $renderer->urlsetFooter(); return $xml; } diff --git a/app/Seo/SitemapManager.php b/app/Seo/SitemapManager.php index 10a34530..cb82aa48 100644 --- a/app/Seo/SitemapManager.php +++ b/app/Seo/SitemapManager.php @@ -3,6 +3,7 @@ namespace App\Seo; use App\Contracts\Extension\CacheInterface; +use App\Contracts\Extension\StorageInterface; use App\Contracts\Repositories\ConfigRepositoryInterface; use App\Extension\HookManager; use Illuminate\Support\Facades\Log; @@ -11,22 +12,34 @@ use Illuminate\Support\Facades\Log; * Sitemap 재생성 / 상태 조회 서비스. * * 큐 잡(GenerateSitemapJob)과 관리자 수동 트리거(SeoCacheController)에서 공통으로 사용합니다. - * Sitemap XML 을 생성하여 캐시에 저장하고 마지막 업데이트 시각을 - * settings 의 seo 카테고리(sitemap_last_updated_at)에 기록합니다. + * Sitemap 을 SitemapWriter 로 스트리밍 생성하여 비공개 디스크에 분할 파일로 커밋하고, + * 마지막 업데이트 시각을 settings 의 seo 카테고리(sitemap_last_updated_at)에 기록합니다. + * 캐시에는 XML 본문이 아니라 메타데이터만 저장합니다. */ class SitemapManager { + /** + * Sitemap 메타데이터 캐시 키 + */ + public const META_CACHE_KEY = 'seo.sitemap.meta'; + + /** + * 구 버전 Sitemap XML 본문 캐시 키 (디스크 서빙 전환으로 폐기 — 잔여분 정리용) + */ + private const LEGACY_XML_CACHE_KEY = 'seo.sitemap'; + public function __construct( private SitemapGenerator $generator, private CacheInterface $cache, private ConfigRepositoryInterface $configRepository, + private StorageInterface $storage, ) {} /** - * Sitemap 을 즉시 생성하여 캐시에 저장하고 last_updated_at 을 기록합니다. + * Sitemap 을 즉시 생성하여 디스크에 커밋하고 last_updated_at 을 기록합니다. * * @return array{success: bool, status: string, message?: string, data?: array} - * status: 'updated' | 'disabled' | 'failed' + * status: 'updated' | 'disabled' | 'failed' */ public function regenerate(): array { @@ -42,11 +55,16 @@ class SitemapManager HookManager::doAction('core.seo.sitemap.before_regenerate'); try { - $xml = $this->generator->generate(); - $ttl = (int) g7_core_settings('cache.seo_sitemap_ttl', g7_core_settings('seo.sitemap_cache_ttl', 86400)); - $this->cache->put('seo.sitemap', $xml, $ttl); + $meta = $this->generator->generateToWriter($this->makeWriter()); - $lastUpdatedAt = now()->toIso8601String(); + // 고급 탭(cache.seo_sitemap_ttl)이 메인, SEO 탭에 별도 지정이 있으면 그것이 우선 (D19) + $ttl = SeoCacheSettings::sitemapCacheTtl(); + + // 디스크 서빙 전환 — 캐시에는 메타만 저장하고 구 XML 본문 캐시는 정리합니다. + $this->cache->forget(self::LEGACY_XML_CACHE_KEY); + $this->cache->put(self::META_CACHE_KEY, $meta, $ttl); + + $lastUpdatedAt = $meta['generated_at']; $this->updateLastUpdatedAt($lastUpdatedAt); $result = [ @@ -55,7 +73,9 @@ class SitemapManager 'message' => 'Sitemap 생성이 완료되었습니다.', 'data' => [ 'last_updated_at' => $lastUpdatedAt, - 'size_bytes' => strlen($xml), + 'size_bytes' => $meta['size_bytes'], + 'url_count' => $meta['url_count'], + 'child_count' => $meta['child_count'], 'ttl' => $ttl, ], ]; @@ -94,6 +114,21 @@ class SitemapManager ]; } + /** + * 설정값을 반영한 SitemapWriter 를 생성합니다. + * + * @return SitemapWriter 분할 기록기 + */ + private function makeWriter(): SitemapWriter + { + return new SitemapWriter( + $this->storage, + SitemapXmlRenderer::fromConfig(), + (int) g7_core_settings('seo.sitemap_urls_per_file', SitemapWriter::HARD_URL_CAP), + (bool) g7_core_settings('seo.sitemap_gzip', false), + ); + } + /** * settings 의 seo 카테고리에 sitemap_last_updated_at 을 기록합니다. * diff --git a/app/Seo/SitemapWriter.php b/app/Seo/SitemapWriter.php new file mode 100644 index 00000000..ed65be9c --- /dev/null +++ b/app/Seo/SitemapWriter.php @@ -0,0 +1,338 @@ + 블록 수 + */ + private int $currentUrlCount = 0; + + /** + * 현재 자식 파일 번호 (1부터) + */ + private int $childIndex = 0; + + /** + * 현재 자식 파일이 열려 있는지 여부 + */ + private bool $childOpen = false; + + /** + * 현재 자식 파일의 최신 lastmod + */ + private ?string $childLastmod = null; + + /** + * 기록된 자식 파일 목록 + * + * @var array + */ + private array $children = []; + + /** + * 전체 블록 수 + */ + private int $totalUrls = 0; + + /** + * 전체 자식 파일 바이트 합계 + */ + private int $totalBytes = 0; + + /** + * 파일당 URL 수 임계 + */ + private readonly int $urlsPerFile; + + /** + * SitemapWriter 생성자 + * + * @param StorageInterface $storage 비공개 디스크 스토리지 드라이버 + * @param SitemapXmlRenderer $renderer XML 조각 렌더러 (SitemapGenerator 와 공유) + * @param int $urlsPerFile 파일당 URL 수 (1 ~ HARD_URL_CAP 로 클램프) + * @param bool $gzip 자식 파일 gzip 압축 여부 + */ + public function __construct( + private readonly StorageInterface $storage, + private readonly SitemapXmlRenderer $renderer, + int $urlsPerFile = self::HARD_URL_CAP, + private readonly bool $gzip = false, + ) { + $this->urlsPerFile = max(1, min($urlsPerFile, self::HARD_URL_CAP)); + } + + /** + * 기록을 시작합니다 (임시 디렉토리 초기화 + 상태 리셋). + */ + public function open(): void + { + $this->purgeTmpDirectory(); + + $this->buffer = ''; + $this->currentUrlCount = 0; + $this->childIndex = 0; + $this->childOpen = false; + $this->childLastmod = null; + $this->children = []; + $this->totalUrls = 0; + $this->totalBytes = 0; + } + + /** + * URL 항목 하나를 기록합니다. + * + * 임계(URL 수 또는 바이트) 도달 시 현재 자식 파일을 flush 합니다. + * + * @param array{loc?: string, lastmod?: string, changefreq?: string, priority?: float} $entry URL 항목 + */ + public function addUrl(array $entry): void + { + $block = $this->renderer->urlBlock($entry); + if ($block === '') { + return; + } + + if (! $this->childOpen) { + $this->openNextChild(); + } + + $this->buffer .= $block; + $this->currentUrlCount += $this->renderer->urlBlockCount(); + $this->totalUrls += $this->renderer->urlBlockCount(); + + $lastmod = $entry['lastmod'] ?? null; + if (is_string($lastmod) && $lastmod !== '' && ($this->childLastmod === null || $lastmod > $this->childLastmod)) { + $this->childLastmod = $lastmod; + } + + // static:: 바인딩 — 하위 클래스가 임계값을 낮춰 바이트 분할 분기를 검증할 수 있게 한다 + // (45MB 를 실제로 채우지 않고 같은 경로를 태우기 위함). + if ($this->currentUrlCount >= $this->urlsPerFile || strlen($this->buffer) >= static::MAX_FILE_BYTES) { + $this->flushChild(); + } + } + + /** + * 기록을 마감합니다 (마지막 자식 flush + sitemapindex 기록). + * + * @return array{child_count: int, url_count: int, size_bytes: int, children: array} 기록 메타데이터 + */ + public function close(): array + { + // 자식이 하나도 없으면(URL 0건) 빈 urlset 자식 1개를 남겨 인덱스를 유효하게 유지합니다. + if ($this->childOpen || $this->children === []) { + if (! $this->childOpen) { + $this->openNextChild(); + } + $this->flushChild(); + } + + $indexXml = $this->renderer->sitemapIndex($this->children); + $this->putOrFail(SitemapFileStore::TMP_DIR.'/'.SitemapFileStore::INDEX_FILE, $indexXml); + + return [ + 'child_count' => count($this->children), + 'url_count' => $this->totalUrls, + 'size_bytes' => $this->totalBytes, + 'children' => $this->children, + ]; + } + + /** + * 임시 디렉토리의 세트를 live 로 커밋합니다. + * + * 순서: (1) 기존 live 파일 목록 확보 → (2) _tmp 파일을 live 로 재배치 → + * (3) manifest.json 기록(커밋 마커) → (4) 새 세트에 없는 stale 파일 삭제 → (5) _tmp 정리. + * + * @return array{generated_at: string, child_count: int, url_count: int, size_bytes: int, gzip: bool, children: array} + * 커밋된 sitemap 메타데이터 + * + * @throws \RuntimeException 파일 기록 실패 시 + */ + public function commit(): array + { + $staleFiles = $this->liveFilenames(); + + $newFiles = [SitemapFileStore::INDEX_FILE]; + foreach ($this->children as $child) { + $newFiles[] = $child['file']; + } + + foreach ($newFiles as $filename) { + $content = $this->storage->get(SitemapFileStore::CATEGORY, SitemapFileStore::TMP_DIR.'/'.$filename); + if ($content === null) { + throw new \RuntimeException(__('exceptions.seo.sitemap_temp_file_unreadable', ['file' => $filename])); + } + + $this->putOrFail(SitemapFileStore::LIVE_DIR.'/'.$filename, $content); + unset($content); + } + + $manifest = [ + 'generated_at' => now()->toIso8601String(), + 'child_count' => count($this->children), + 'url_count' => $this->totalUrls, + 'size_bytes' => $this->totalBytes, + 'gzip' => $this->gzip, + 'urls_per_file' => $this->urlsPerFile, + 'children' => $this->children, + ]; + + // manifest 는 마지막에 기록 — 존재 = 커밋 완료 신호 + $this->putOrFail( + SitemapFileStore::LIVE_DIR.'/'.SitemapFileStore::MANIFEST_FILE, + json_encode($manifest, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + ); + + $this->deleteStaleFiles($staleFiles, $newFiles); + + $this->purgeTmpDirectory(); + + return $manifest; + } + + /** + * 임시 디렉토리를 정리합니다. + * + * 정리 실패는 치명적이지 않습니다 — 임시 파일은 이름 기준으로 덮어쓰이고, + * 커밋은 children 목록에 있는 파일만 live 로 옮기므로 잔여 파일이 결과에 섞이지 않습니다. + * 따라서 예외를 전파하지 않고 경고만 남겨 sitemap 생성 자체가 중단되지 않도록 합니다. + */ + private function purgeTmpDirectory(): void + { + try { + $this->storage->deleteDirectory(SitemapFileStore::CATEGORY, SitemapFileStore::TMP_DIR); + } catch (\Throwable $e) { + Log::warning('[SEO] Sitemap 임시 디렉토리 정리 실패 (생성은 계속 진행)', [ + 'directory' => SitemapFileStore::TMP_DIR, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * 다음 자식 파일을 엽니다 (버퍼에 urlset 헤더 기록). + */ + private function openNextChild(): void + { + $this->childIndex++; + $this->buffer = $this->renderer->urlsetHeader(); + $this->currentUrlCount = 0; + $this->childLastmod = null; + $this->childOpen = true; + } + + /** + * 현재 자식 파일 버퍼를 임시 디렉토리에 기록하고 버퍼를 비웁니다. + * + * @throws \RuntimeException 파일 기록 실패 시 + */ + private function flushChild(): void + { + $this->buffer .= $this->renderer->urlsetFooter(); + + $filename = SitemapFileStore::childFilename($this->childIndex, $this->gzip); + $content = $this->gzip ? gzencode($this->buffer, 6) : $this->buffer; + + if ($content === false) { + throw new \RuntimeException(__('exceptions.seo.sitemap_gzip_failed', ['file' => $filename])); + } + + $this->putOrFail(SitemapFileStore::TMP_DIR.'/'.$filename, $content); + + $this->children[] = [ + 'file' => $filename, + 'loc' => SitemapFileStore::childUrl($this->childIndex, $this->gzip), + 'lastmod' => $this->childLastmod, + 'url_count' => $this->currentUrlCount, + 'size_bytes' => strlen($content), + ]; + $this->totalBytes += strlen($content); + + $this->buffer = ''; + $this->currentUrlCount = 0; + $this->childOpen = false; + } + + /** + * live 디렉토리의 파일명 목록을 반환합니다. + * + * @return array 파일명 배열 (디렉토리 제외) + */ + private function liveFilenames(): array + { + $paths = $this->storage->files(SitemapFileStore::CATEGORY, SitemapFileStore::LIVE_DIR); + + return array_map(static fn (string $path): string => basename($path), $paths); + } + + /** + * 새 세트에 포함되지 않은 이전 자식 파일을 삭제합니다. + * + * @param array $staleFiles 커밋 전 live 파일명 목록 + * @param array $newFiles 새 세트 파일명 목록 + */ + private function deleteStaleFiles(array $staleFiles, array $newFiles): void + { + $keep = array_merge($newFiles, [SitemapFileStore::MANIFEST_FILE]); + + foreach ($staleFiles as $filename) { + if (in_array($filename, $keep, true)) { + continue; + } + + $this->storage->delete(SitemapFileStore::CATEGORY, SitemapFileStore::LIVE_DIR.'/'.$filename); + } + } + + /** + * 파일을 기록하고 실패 시 예외를 던집니다. + * + * @param string $path 카테고리 하위 상대 경로 + * @param string $content 파일 내용 + * + * @throws \RuntimeException 기록 실패 시 + */ + private function putOrFail(string $path, string $content): void + { + if (! $this->storage->put(SitemapFileStore::CATEGORY, $path, $content)) { + Log::error('[SEO] Sitemap 파일 기록 실패', ['path' => $path]); + + throw new \RuntimeException(__('exceptions.seo.sitemap_write_failed', ['path' => $path])); + } + } +} diff --git a/app/Seo/SitemapXmlRenderer.php b/app/Seo/SitemapXmlRenderer.php new file mode 100644 index 00000000..b2dcb7a1 --- /dev/null +++ b/app/Seo/SitemapXmlRenderer.php @@ -0,0 +1,249 @@ + 블록, sitemapindex 를 생성합니다. + * Sitemap XML 의 이스케이프 로직을 이 클래스 한 곳에서만 수행하며(단일 출처), + * SitemapGenerator(수집측)와 SitemapWriter(기록측)가 이 렌더러를 공유합니다. + * + * 다국어(supported_locales 2개 이상)면 URL 하나당 로케일별 블록을 생성하고 + * 각 블록에 xhtml:link hreflang alternate 를 포함합니다. + */ +final class SitemapXmlRenderer +{ + /** + * 다국어 여부 (지원 로케일 2개 이상) + */ + private readonly bool $multilingual; + + /** + * SitemapXmlRenderer 생성자 + * + * @param array $supportedLocales 지원 로케일 목록 + * @param string $defaultLocale 기본 로케일 + */ + public function __construct( + private readonly array $supportedLocales, + private readonly string $defaultLocale, + ) { + $this->multilingual = count($this->supportedLocales) > 1; + } + + /** + * 애플리케이션 로케일 설정으로 렌더러를 생성합니다. + * + * @return self 설정 기반 렌더러 인스턴스 + */ + public static function fromConfig(): self + { + $defaultLocale = (string) config('app.locale'); + + return new self( + (array) config('app.supported_locales', [$defaultLocale]), + $defaultLocale, + ); + } + + /** + * 다국어 모드 여부를 반환합니다. + * + * @return bool 지원 로케일이 2개 이상이면 true + */ + public function isMultilingual(): bool + { + return $this->multilingual; + } + + /** + * URL 항목 하나가 생성하는 블록 개수를 반환합니다. + * + * 분할 임계(파일당 URL 수) 계산에 사용됩니다. + * + * @return int 다국어면 로케일 수, 단일 언어면 1 + */ + public function urlBlockCount(): int + { + return $this->multilingual ? count($this->supportedLocales) : 1; + } + + /** + * urlset 여는 태그(XML 선언 포함)를 반환합니다. + * + * @return string urlset 헤더 문자열 + */ + public function urlsetHeader(): string + { + $xml = ''."\n"; + + if ($this->multilingual) { + $xml .= ''; + } + + /** + * URL 항목 하나를 블록으로 렌더링합니다. + * + * 다국어면 로케일 수만큼의 블록을 이어붙여 반환합니다. + * + * @param array{loc?: string, lastmod?: string, changefreq?: string, priority?: float} $entry URL 항목 (loc 은 절대 URL) + * @return string 블록 문자열 (loc 이 없으면 빈 문자열) + */ + public function urlBlock(array $entry): string + { + $baseLoc = $entry['loc'] ?? ''; + if ($baseLoc === '') { + return ''; + } + + if (! $this->multilingual) { + return $this->renderSingleUrl($baseLoc, $entry); + } + + $xml = ''; + foreach ($this->supportedLocales as $locale) { + $xml .= $this->renderLocalizedUrl($baseLoc, $entry, (string) $locale); + } + + return $xml; + } + + /** + * 자식 sitemap 목록을 sitemapindex XML 로 렌더링합니다. + * + * @param array $children 자식 sitemap 목록 + * @return string sitemapindex XML 문자열 + */ + public function sitemapIndex(array $children): string + { + $xml = ''."\n"; + $xml .= ''."\n"; + + foreach ($children as $child) { + $xml .= ' '."\n"; + $xml .= ' '.$this->escape($child['loc']).''."\n"; + + if (! empty($child['lastmod'])) { + $xml .= ' '.$this->escape($child['lastmod']).''."\n"; + } + + $xml .= ' '."\n"; + } + + $xml .= ''; + + return $xml; + } + + /** + * 단일 언어 블록을 렌더링합니다. + * + * @param string $loc 절대 URL + * @param array $entry URL 항목 + * @return string 블록 문자열 + */ + private function renderSingleUrl(string $loc, array $entry): string + { + $xml = ' '."\n"; + $xml .= ' '.$this->escape($loc).''."\n"; + $xml .= $this->renderMetaFields($entry); + $xml .= ' '."\n"; + + return $xml; + } + + /** + * 특정 로케일의 블록(hreflang alternate 포함)을 렌더링합니다. + * + * @param string $baseLoc 기본 로케일 절대 URL + * @param array $entry URL 항목 + * @param string $locale 대상 로케일 + * @return string 블록 문자열 + */ + private function renderLocalizedUrl(string $baseLoc, array $entry, string $locale): string + { + $xml = ' '."\n"; + $xml .= ' '.$this->escape($this->localizedLoc($baseLoc, $locale)).''."\n"; + $xml .= $this->renderMetaFields($entry); + + // 모든 로케일의 hreflang alternate 링크 + foreach ($this->supportedLocales as $altLocale) { + $altHref = $this->localizedLoc($baseLoc, (string) $altLocale); + $xml .= ' '."\n"; + } + + // x-default = 기본 로케일 URL + $xml .= ' '."\n"; + + $xml .= ' '."\n"; + + return $xml; + } + + /** + * lastmod / changefreq / priority 필드를 렌더링합니다. + * + * @param array $entry URL 항목 + * @return string 메타 필드 문자열 + */ + private function renderMetaFields(array $entry): string + { + $xml = ''; + + if (! empty($entry['lastmod'])) { + $xml .= ' '.$this->escape((string) $entry['lastmod']).''."\n"; + } + + if (! empty($entry['changefreq'])) { + $xml .= ' '.$this->escape((string) $entry['changefreq']).''."\n"; + } + + if (isset($entry['priority'])) { + $xml .= ' '.number_format((float) $entry['priority'], 1).''."\n"; + } + + return $xml; + } + + /** + * 로케일별 URL 을 생성합니다. + * + * @param string $baseLoc 기본 로케일 절대 URL + * @param string $locale 대상 로케일 + * @return string 로케일 URL (기본 로케일이면 원본 그대로) + */ + private function localizedLoc(string $baseLoc, string $locale): string + { + return $locale === $this->defaultLocale + ? $baseLoc + : $baseLoc.'?locale='.$locale; + } + + /** + * XML 특수문자를 이스케이프합니다. + * + * @param string $value 원본 문자열 + * @return string 이스케이프된 문자열 + */ + private function escape(string $value): string + { + return htmlspecialchars($value, ENT_XML1, 'UTF-8'); + } +} diff --git a/config/settings/defaults.json b/config/settings/defaults.json index 67291fe7..9eea0f97 100644 --- a/config/settings/defaults.json +++ b/config/settings/defaults.json @@ -65,10 +65,10 @@ "og_image_default_height": 630, "twitter_default_card": "summary_large_image", "twitter_default_site": "", - "cache_enabled": true, - "cache_ttl": 7200, + "cache_enabled": null, + "cache_ttl": null, "sitemap_enabled": true, - "sitemap_cache_ttl": 86400, + "sitemap_cache_ttl": null, "sitemap_schedule": "daily", "sitemap_schedule_time": "02:00", "sitemap_last_updated_at": "", diff --git a/docs/backend/api/seo.md b/docs/backend/api/seo.md index 1d2185ff..2f002776 100644 --- a/docs/backend/api/seo.md +++ b/docs/backend/api/seo.md @@ -154,7 +154,7 @@ _단건 응답: `data` 객체의 필드._ | --- | --- | --- | --- | | last_updated_at | string | `2026-07-08T03:14:49+00:00` | last updated 일시 | | size_bytes | integer | `25489` | 크기 (바이트) | -| ttl | integer | `86400` | 캐시 유효 시간 (Time To Live, 초) | +| ttl | integer | `86400` | 캐시 유효 시간 (Time To Live, 초). 고급 설정의 `cache.seo_sitemap_ttl` 이 기준값이며, SEO 설정 `seo.sitemap_cache_ttl` 에 값이 지정되어 있으면(null 이 아니면) 그 값이 우선합니다 | **응답 예시** diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md index 75266ba7..ed44ef3a 100644 --- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.5] - 2026-07-16 + +### Added + +- 사이트맵 생성이 실패했을 때 표시되는 안내 문구 일본어 번역 추가 (`exceptions.seo.*`) — 사이트맵 파일을 저장하지 못하거나 압축에 실패한 경우의 안내가 일본어 로케일에서 자연스럽게 표시됩니다. + ## [1.0.4] - 2026-07-16 ### Added diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/exceptions.php b/lang-packs/_bundled/g7-core-ja/backend/ja/exceptions.php index f77cd321..0e6628f8 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/exceptions.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/exceptions.php @@ -63,4 +63,10 @@ return [ 'bundle_build_promote_failed' => 'vendor-bundle ビルド結果を最終パスに反映できませんでした (:from → :to)', ], 'concurrent_modification' => '別のユーザーが先に保存しました。(リソース: :resource、現在のバージョン: :current、送信されたバージョン: :expected)', + 'seo' => [ + 'sitemap_write_failed' => 'Sitemapファイル記録に失敗しました: :path', + 'sitemap_gzip_failed' => 'Sitemap子ファイル圧縮に失敗しました: :file', + 'sitemap_temp_file_unreadable' => 'Sitemap一時ファイルを読み込めません: :file', + 'sitemap_stat_group_column_unsupported' => 'サポートされていないグループ基準列です: :column', + ], ]; diff --git a/lang-packs/_bundled/g7-core-ja/language-pack.json b/lang-packs/_bundled/g7-core-ja/language-pack.json index ec1fafe7..4d25cdab 100644 --- a/lang-packs/_bundled/g7-core-ja/language-pack.json +++ b/lang-packs/_bundled/g7-core-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 core Japanese language pack (bundled)", "ja": "G7 コア 日本語 言語パック(バンドル)" }, - "version": "1.0.4", + "version": "1.0.5", "license": "MIT", "scope": "core", "target_identifier": null, diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md index 45bf9ed3..d800ffac 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.4] - 2026-07-17 + +### Changed + +- 환경설정 > SEO 화면의 캐시 항목(SEO 캐시 활성화·캐시 유지 시간, Sitemap 캐시 유지 시간) 안내 문구 일본어 번역을 갱신했습니다 — 비워두면 고급 설정의 값을 따르고 값을 넣으면 그 값이 우선한다는 새 동작을 일본어 로케일에서도 올바르게 안내합니다. + ## [1.0.3] - 2026-07-14 ### Added diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json index 293e14e6..a047df35 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json @@ -1410,9 +1410,9 @@ "seo_cache_settings": "SEO キャッシュ 設定", "seo_cache_settings_desc": "SEO ページキャッシュ関連の設定です。", "cache_enabled": "SEO キャッシュ 有効化", - "cache_enabled_desc": "SEO ページをキャッシュして、応答速度を向上させます。", + "cache_enabled_desc": "SEO ページをキャッシュして、応答速度を向上させます。指定しない場合は詳細設定の値に従います。", "cache_ttl": "キャッシュ 保持時間 (秒)", - "cache_ttl_hint": "最小 60 秒、最大 86400 秒 (24 時間)。デフォルト: 7200 秒 (2 時間)", + "cache_ttl_hint": "空欄にすると詳細設定の SEO キャッシュ時間に従います。値を入力するとこの画面の値が優先されます — 最小 60 秒、最大 86400 秒 (24 時間)", "sitemap_settings": "Sitemap 設定", "sitemap_settings_desc": "sitemap.xml 自動生成関連の設定です。", "sitemap_enabled": "Sitemap 生成 有効化", @@ -1424,7 +1424,7 @@ "sitemap_schedule_time": "生成 時刻", "sitemap_schedule_time_hint": "HH:mm 形式で入力してください。(例: 02:00)", "sitemap_cache_ttl": "Sitemap キャッシュ 保持時間 (秒)", - "sitemap_cache_ttl_hint": "最小 3600 秒 (1 時間)、最大 604800 秒 (7 日)。デフォルト: 86400 秒 (24 時間)", + "sitemap_cache_ttl_hint": "空欄にすると詳細設定の Sitemap キャッシュ時間に従います。値を入力するとこの画面の値が優先されます — 最小 3600 秒 (1 時間)、最大 604800 秒 (7 日)", "sitemap_last_updated_at": "最後の生成", "sitemap_manual_update": "今すぐ生成", "sitemap_updating": "生成中...", diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json index 9c1a1431..d759ed88 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 template (sirsoft-admin_basic) Japanese language pack (bundled)", "ja": "G7 テンプレート (sirsoft-admin_basic) 日本語 言語パック(バンドル)" }, - "version": "1.0.3", + "version": "1.0.4", "license": "MIT", "scope": "template", "target_identifier": "sirsoft-admin_basic", diff --git a/lang/en/exceptions.php b/lang/en/exceptions.php index 7e2c0016..a876b138 100644 --- a/lang/en/exceptions.php +++ b/lang/en/exceptions.php @@ -79,4 +79,12 @@ return [ 'vendor_dir_not_found' => 'vendor/ directory does not exist. Run composer install first: :path', 'composer_binary_not_found' => 'Composer binary not found. Set the COMPOSER_BINARY environment variable or add composer to your PATH.', ], + + // SEO / Sitemap 관련 예외 + 'seo' => [ + 'sitemap_write_failed' => 'Failed to write sitemap file: :path', + 'sitemap_gzip_failed' => 'Failed to compress sitemap child file: :file', + 'sitemap_temp_file_unreadable' => 'Cannot read sitemap temporary file: :file', + 'sitemap_stat_group_column_unsupported' => 'Unsupported group-by column: :column', + ], ]; diff --git a/lang/ko/exceptions.php b/lang/ko/exceptions.php index 61bef32e..bb5bee32 100644 --- a/lang/ko/exceptions.php +++ b/lang/ko/exceptions.php @@ -79,4 +79,12 @@ return [ 'vendor_dir_not_found' => 'vendor/ 디렉토리가 존재하지 않습니다. 먼저 composer install을 실행하세요: :path', 'composer_binary_not_found' => 'Composer 바이너리를 찾을 수 없습니다. COMPOSER_BINARY 환경변수를 설정하거나 composer를 PATH에 추가하세요.', ], + + // SEO / Sitemap 관련 예외 + 'seo' => [ + 'sitemap_write_failed' => 'Sitemap 파일 기록에 실패했습니다: :path', + 'sitemap_gzip_failed' => 'Sitemap 자식 파일 압축에 실패했습니다: :file', + 'sitemap_temp_file_unreadable' => 'Sitemap 임시 파일을 읽을 수 없습니다: :file', + 'sitemap_stat_group_column_unsupported' => '지원하지 않는 그룹 기준 컬럼입니다: :column', + ], ]; diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index 9369166f..5b09aa83 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -4,6 +4,16 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.3] - 2026-07-16 + +### Added + +- 사이트맵에 담을 게시판·게시글 수의 안전 상한을 지원합니다. 상한을 설정하면 게시판 목록·게시판·게시글 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다. + +### Changed + +- 사이트맵을 만들 때 게시판과 게시글을 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 게시글이 많은 사이트에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다. + ## [1.0.2] - 2026-07-14 ### Fixed diff --git a/modules/_bundled/sirsoft-board/composer.json b/modules/_bundled/sirsoft-board/composer.json index a59b9a81..02e719ba 100644 --- a/modules/_bundled/sirsoft-board/composer.json +++ b/modules/_bundled/sirsoft-board/composer.json @@ -2,7 +2,7 @@ "name": "modules/sirsoft-board", "description": "Board module for Gnuboard7", "type": "library", - "version": "1.0.2", + "version": "1.0.3", "license": "MIT", "autoload": { "psr-4": { diff --git a/modules/_bundled/sirsoft-board/module.json b/modules/_bundled/sirsoft-board/module.json index 1476f801..0847a707 100644 --- a/modules/_bundled/sirsoft-board/module.json +++ b/modules/_bundled/sirsoft-board/module.json @@ -5,7 +5,7 @@ "ko": "게시판", "en": "Board" }, - "version": "1.0.2", + "version": "1.0.3", "license": "MIT", "description": { "ko": "게시판 관리를 위한 모듈", diff --git a/modules/_bundled/sirsoft-board/package-lock.json b/modules/_bundled/sirsoft-board/package-lock.json index 1f256d45..8c3338ef 100644 --- a/modules/_bundled/sirsoft-board/package-lock.json +++ b/modules/_bundled/sirsoft-board/package-lock.json @@ -1,12 +1,12 @@ { "name": "@g7/sirsoft-board", - "version": "1.0.2", + "version": "1.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@g7/sirsoft-board", - "version": "1.0.2", + "version": "1.0.3", "devDependencies": { "jsdom": "^27.4.0", "typescript": "^5.3.3", diff --git a/modules/_bundled/sirsoft-board/package.json b/modules/_bundled/sirsoft-board/package.json index 9106b48a..92974da1 100644 --- a/modules/_bundled/sirsoft-board/package.json +++ b/modules/_bundled/sirsoft-board/package.json @@ -1,6 +1,6 @@ { "name": "@g7/sirsoft-board", - "version": "1.0.2", + "version": "1.0.3", "description": "그누보드7 게시판 모듈 프론트엔드 에셋", "private": true, "type": "module", diff --git a/modules/_bundled/sirsoft-board/src/Providers/BoardServiceProvider.php b/modules/_bundled/sirsoft-board/src/Providers/BoardServiceProvider.php index 68a1f2cb..cc2e521a 100644 --- a/modules/_bundled/sirsoft-board/src/Providers/BoardServiceProvider.php +++ b/modules/_bundled/sirsoft-board/src/Providers/BoardServiceProvider.php @@ -105,8 +105,9 @@ class BoardServiceProvider extends BaseModuleServiceProvider // Sitemap 기여자 등록 $this->app->booted(function () { if ($this->app->bound(SitemapGenerator::class)) { + // Repository 주입을 위해 컨테이너로 해석합니다. $this->app->make(SitemapGenerator::class)->registerContributor( - new BoardSitemapContributor + $this->app->make(BoardSitemapContributor::class) ); } }); diff --git a/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php index 2649b453..aa578fc3 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/BoardRepository.php @@ -2,10 +2,12 @@ namespace Modules\Sirsoft\Board\Repositories; +use App\Enums\UserStatus; use App\Helpers\PermissionHelper; -use App\Helpers\TimezoneHelper; use Carbon\Carbon; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; @@ -32,7 +34,7 @@ class BoardRepository implements BoardRepositoryInterface */ public function create(array $data): Board { - $board = new Board(); + $board = new Board; $board->fill($data); // created_by/updated_by는 guarded이므로 직접 할당 @@ -63,7 +65,7 @@ class BoardRepository implements BoardRepositoryInterface * @param int $id 게시판 ID * @return Board 게시판 모델 * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + * @throws ModelNotFoundException */ public function findOrFail(int $id): Board { @@ -88,7 +90,7 @@ class BoardRepository implements BoardRepositoryInterface * @param array $data 수정할 데이터 * @return Board 수정된 게시판 모델 * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + * @throws ModelNotFoundException */ public function update(int $id, array $data): Board { @@ -113,7 +115,7 @@ class BoardRepository implements BoardRepositoryInterface * @param int $id 게시판 ID * @return bool 삭제 성공 여부 * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + * @throws ModelNotFoundException */ public function delete(int $id): bool { @@ -179,7 +181,7 @@ class BoardRepository implements BoardRepositoryInterface /** * 쿼리 빌더를 반환합니다. (체이닝 가능) * - * @return \Illuminate\Database\Eloquent\Builder + * @return Builder */ public function query() { @@ -243,7 +245,7 @@ class BoardRepository implements BoardRepositoryInterface 'board_name' => $post->board?->getLocalizedName(), 'title' => $post->title, 'author_name' => $post->author_name, - 'created_at' => $this->formatCreatedAt($post->created_at), + 'created_at' => $this->formatCreatedAt($post->created_at), 'created_at_formatted' => $this->formatCreatedAtFormat($post->created_at, g7_module_settings('sirsoft-board', 'display.date_display_format', 'standard')), 'view_count' => $post->view_count, 'comment_count' => $post->comments_count ?? 0, @@ -295,7 +297,7 @@ class BoardRepository implements BoardRepositoryInterface 'id' => $post->id, 'title' => $post->title, 'author_name' => $post->author_name, - 'created_at' => $this->formatCreatedAt($post->created_at), + 'created_at' => $this->formatCreatedAt($post->created_at), 'created_at_formatted' => $this->formatCreatedAtFormat($post->created_at, g7_module_settings('sirsoft-board', 'display.date_display_format', 'standard')), 'view_count' => $post->view_count, 'comment_count' => $post->comments_count ?? 0, @@ -481,8 +483,8 @@ class BoardRepository implements BoardRepositoryInterface } // 사용자 상태 처리 (탈퇴한 사용자는 익명화) - $userStatus = $post->user_id ? \App\Enums\UserStatus::tryFrom($post->user_status) : null; - $isWithdrawn = $userStatus === \App\Enums\UserStatus::Withdrawn; + $userStatus = $post->user_id ? UserStatus::tryFrom($post->user_status) : null; + $isWithdrawn = $userStatus === UserStatus::Withdrawn; $authorName = $post->user_id ? ($isWithdrawn ? __('user.withdrawn_user') : $post->user_name) : $post->guest_author_name; @@ -504,7 +506,7 @@ class BoardRepository implements BoardRepositoryInterface ], 'view_count' => $viewCount, 'comment_count' => $commentCount, - 'created_at' => $this->formatCreatedAt(Carbon::parse($post->created_at)), + 'created_at' => $this->formatCreatedAt(Carbon::parse($post->created_at)), 'created_at_formatted' => $this->formatCreatedAtFormat($post->created_at, g7_module_settings('sirsoft-board', 'display.date_display_format', 'standard')), ]; })->toArray(); @@ -513,20 +515,34 @@ class BoardRepository implements BoardRepositoryInterface /** * 활성화된 게시판 목록을 조회합니다. * - * @param string|null $slug 특정 게시판 슬러그 (null이면 전체) + * @param string|null $slug 특정 게시판 슬러그 (null이면 전체) * @return Collection 활성 게시판 컬렉션 */ public function getActiveBoards(?string $slug = null): Collection { $query = Board::where('is_active', true); - if (!empty($slug)) { + if (! empty($slug)) { $query->where('slug', $slug); } return $query->get(); } + /** + * Sitemap 생성용으로 활성 게시판을 순회자로 조회합니다. + * + * @param int $chunkSize 청크 크기 + * @return iterable 활성 게시판 순회자 (id, slug, updated_at 만 조회) + */ + public function streamActiveForSitemap(int $chunkSize = 500): iterable + { + return Board::where('is_active', true) + ->select(['id', 'slug', 'updated_at']) + ->orderBy('id') + ->lazyById($chunkSize); + } + /** * 필터용 전체 활성 게시판 목록을 반환합니다. * @@ -542,9 +558,9 @@ class BoardRepository implements BoardRepositoryInterface /** * 게시판을 일괄 업데이트합니다. * - * @param array $data 업데이트할 데이터 - * @param bool $applyAll 전체 적용 여부 - * @param array $boardIds 특정 게시판 ID 목록 (applyAll=false일 때 사용) + * @param array $data 업데이트할 데이터 + * @param bool $applyAll 전체 적용 여부 + * @param array $boardIds 특정 게시판 ID 목록 (applyAll=false일 때 사용) * @return int 업데이트된 게시판 수 */ public function bulkUpdate(array $data, bool $applyAll = true, array $boardIds = []): int @@ -590,9 +606,9 @@ class BoardRepository implements BoardRepositoryInterface * * @param string $orderBy 정렬 기준 컬럼 * @param string $orderDirection 정렬 방향 (asc/desc) - * @return \Illuminate\Database\Eloquent\Collection 활성 게시판 컬렉션 + * @return Collection 활성 게시판 컬렉션 */ - public function getActiveBoardsOrdered(string $orderBy = 'created_at', string $orderDirection = 'desc'): \Illuminate\Database\Eloquent\Collection + public function getActiveBoardsOrdered(string $orderBy = 'created_at', string $orderDirection = 'desc'): Collection { return Board::where('is_active', true) ->orderBy($orderBy, $orderDirection) @@ -618,9 +634,9 @@ class BoardRepository implements BoardRepositoryInterface * * id, name, slug 컬럼만 조회하여 메뉴 렌더링에 필요한 최소 데이터만 반환합니다. * - * @return \Illuminate\Database\Eloquent\Collection 활성 게시판 컬렉션 (id, name, slug만 포함) + * @return Collection 활성 게시판 컬렉션 (id, name, slug만 포함) */ - public function getActiveBoardsForMenu(): \Illuminate\Database\Eloquent\Collection + public function getActiveBoardsForMenu(): Collection { return Board::where('is_active', true) ->select(['id', 'name', 'slug']) diff --git a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/BoardRepositoryInterface.php b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/BoardRepositoryInterface.php index e82f5457..b31f471a 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/BoardRepositoryInterface.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/BoardRepositoryInterface.php @@ -2,7 +2,9 @@ namespace Modules\Sirsoft\Board\Repositories\Contracts; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Pagination\LengthAwarePaginator; use Modules\Sirsoft\Board\Models\Board; @@ -14,7 +16,7 @@ interface BoardRepositoryInterface /** * 게시판을 생성합니다. * - * @param array $data 게시판 생성 데이터 + * @param array $data 게시판 생성 데이터 * @return Board 생성된 게시판 모델 */ public function create(array $data): Board; @@ -22,7 +24,7 @@ interface BoardRepositoryInterface /** * ID로 게시판을 조회합니다. * - * @param int $id 게시판 ID + * @param int $id 게시판 ID * @return Board|null 게시판 모델 또는 null */ public function find(int $id): ?Board; @@ -30,17 +32,17 @@ interface BoardRepositoryInterface /** * ID로 게시판을 조회하며, 없으면 예외를 발생시킵니다. * - * @param int $id 게시판 ID + * @param int $id 게시판 ID * @return Board 게시판 모델 * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + * @throws ModelNotFoundException */ public function findOrFail(int $id): Board; /** * 슬러그로 게시판을 조회합니다. * - * @param string $slug 게시판 슬러그 + * @param string $slug 게시판 슬러그 * @return Board|null 게시판 모델 또는 null */ public function findBySlug(string $slug): ?Board; @@ -48,21 +50,21 @@ interface BoardRepositoryInterface /** * 게시판을 수정합니다. * - * @param int $id 게시판 ID - * @param array $data 수정할 데이터 + * @param int $id 게시판 ID + * @param array $data 수정할 데이터 * @return Board 수정된 게시판 모델 * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + * @throws ModelNotFoundException */ public function update(int $id, array $data): Board; /** * 게시판을 삭제합니다. * - * @param int $id 게시판 ID + * @param int $id 게시판 ID * @return bool 삭제 성공 여부 * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + * @throws ModelNotFoundException */ public function delete(int $id): bool; @@ -76,7 +78,7 @@ interface BoardRepositoryInterface /** * 게시판 목록을 페이지네이션하여 조회합니다. * - * @param int $perPage 페이지당 개수 + * @param int $perPage 페이지당 개수 * @return LengthAwarePaginator 페이지네이션된 게시판 목록 */ public function paginate(int $perPage = 15): LengthAwarePaginator; @@ -84,7 +86,7 @@ interface BoardRepositoryInterface /** * 키워드로 게시판을 검색합니다. * - * @param string $keyword 검색 키워드 + * @param string $keyword 검색 키워드 * @return Collection 검색 결과 컬렉션 */ public function search(string $keyword): Collection; @@ -92,7 +94,7 @@ interface BoardRepositoryInterface /** * 타입으로 게시판을 필터링합니다. * - * @param string $type 게시판 타입 + * @param string $type 게시판 타입 * @return Collection 필터링된 게시판 컬렉션 */ public function filterByType(string $type): Collection; @@ -100,7 +102,7 @@ interface BoardRepositoryInterface /** * 쿼리 빌더를 반환합니다. * - * @return \Illuminate\Database\Eloquent\Builder + * @return Builder */ public function query(); @@ -164,11 +166,22 @@ interface BoardRepositoryInterface /** * 활성화된 게시판 목록을 조회합니다. * - * @param string|null $slug 특정 게시판 슬러그 (null이면 전체) + * @param string|null $slug 특정 게시판 슬러그 (null이면 전체) * @return Collection 활성 게시판 컬렉션 */ public function getActiveBoards(?string $slug = null): Collection; + /** + * Sitemap 생성용으로 활성 게시판을 순회자로 조회합니다. + * + * 전체 결과를 메모리에 적재하지 않도록 lazyById 로 청크 단위 조회하며, + * sitemap 에 필요한 컬럼만 select 합니다. + * + * @param int $chunkSize 청크 크기 + * @return iterable 활성 게시판 순회자 (id, slug, updated_at 만 조회) + */ + public function streamActiveForSitemap(int $chunkSize = 500): iterable; + /** * 필터용 전체 활성 게시판 목록을 반환합니다. * @@ -179,9 +192,9 @@ interface BoardRepositoryInterface /** * 게시판을 일괄 업데이트합니다. * - * @param array $data 업데이트할 데이터 - * @param bool $applyAll 전체 적용 여부 - * @param array $boardIds 특정 게시판 ID 목록 (applyAll=false일 때 사용) + * @param array $data 업데이트할 데이터 + * @param bool $applyAll 전체 적용 여부 + * @param array $boardIds 특정 게시판 ID 목록 (applyAll=false일 때 사용) * @return int 업데이트된 게시판 수 */ public function bulkUpdate(array $data, bool $applyAll = true, array $boardIds = []): int; @@ -189,7 +202,7 @@ interface BoardRepositoryInterface /** * 게시판 ID 목록별 게시글 개수를 조회합니다. * - * @param array $boardIds 게시판 ID 목록 + * @param array $boardIds 게시판 ID 목록 * @return array board_id => count 매핑 */ public function getPostsCountByBoardIds(array $boardIds): array; @@ -197,8 +210,8 @@ interface BoardRepositoryInterface /** * 활성화된 게시판 목록을 정렬 옵션과 함께 조회합니다. * - * @param string $orderBy 정렬 기준 컬럼 - * @param string $orderDirection 정렬 방향 (asc/desc) + * @param string $orderBy 정렬 기준 컬럼 + * @param string $orderDirection 정렬 방향 (asc/desc) * @return Collection 활성 게시판 컬렉션 */ public function getActiveBoardsOrdered(string $orderBy = 'created_at', string $orderDirection = 'desc'): Collection; @@ -222,7 +235,7 @@ interface BoardRepositoryInterface /** * 특정 유형의 게시판 개수를 반환합니다. * - * @param string $type 게시판 유형 slug + * @param string $type 게시판 유형 slug * @return int 해당 유형의 게시판 개수 */ public function countByType(string $type): int; @@ -244,4 +257,4 @@ interface BoardRepositoryInterface * @return int 갱신된 카운트 값 */ public function recalculateCommentsCount(int $boardId): int; -} \ No newline at end of file +} diff --git a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/PostRepositoryInterface.php b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/PostRepositoryInterface.php index 88a7c8a2..751c172d 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/Contracts/PostRepositoryInterface.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/Contracts/PostRepositoryInterface.php @@ -22,6 +22,7 @@ interface PostRepositoryInterface * @param int $perPage 페이지당 항목 수 * @param bool $withTrashed 삭제된 게시글 포함 여부 * @param Board|null $board 게시판 모델 (이미 조회된 경우 전달하여 중복 쿼리 방지) + * @return Paginator 게시글 페이지네이터 */ public function paginate(string $slug, array $filters = [], int $perPage = 15, bool $withTrashed = false, ?Board $board = null): Paginator; @@ -96,6 +97,7 @@ interface PostRepositoryInterface * @param string $status 변경할 상태 (blinded/deleted) * @param array $actionLog 작업 이력 데이터 * @param string|null $triggerType 트리거 유형 (admin, report 등) + * @return Post 상태가 변경된 게시글 모델 * * @throws ModelNotFoundException */ @@ -263,6 +265,18 @@ interface PostRepositoryInterface */ public function findByBoardId(int $boardId, int $id): ?Post; + /** + * Sitemap 용으로 게시판의 공개 게시글을 스트리밍 조회합니다. + * + * 공개 게시글 = 게시 상태 + 비밀글 아님. 전체 적재를 피하기 위해 + * id 기준으로 청크 단위 지연 조회합니다. + * + * @param int $boardId 게시판 ID + * @param int $chunkSize 청크 크기 + * @return iterable 공개 게시글 순회자 (id, updated_at 만 조회) + */ + public function streamPublishedForSitemap(int $boardId, int $chunkSize = 500): iterable; + /** * 게시판 ID 기준으로 게시글을 일괄 소프트 삭제합니다. * diff --git a/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php b/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php index 40e8fb86..6eb3a160 100644 --- a/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php +++ b/modules/_bundled/sirsoft-board/src/Repositories/PostRepository.php @@ -44,6 +44,7 @@ class PostRepository implements PostRepositoryInterface * @param array $filters 필터 조건 * @param int $perPage 페이지당 항목 수 (일반 게시글 기준) * @param bool $withTrashed 삭제된 게시글 포함 여부 + * @param Board|null $board 게시판 모델 (이미 조회된 경우 전달하여 중복 쿼리 방지) * @return Paginator 페이지네이션된 게시글 목록 (simplePaginate — COUNT 쿼리 제거) */ public function paginate(string $slug, array $filters = [], int $perPage = 15, bool $withTrashed = false, ?Board $board = null): Paginator @@ -176,6 +177,7 @@ class PostRepository implements PostRepositoryInterface * * @param string $slug 게시판 슬러그 * @param int $id 게시글 ID + * @return Post|null 게시글 모델 (없으면 null) */ public function find(string $slug, int $id): ?Post { @@ -189,6 +191,7 @@ class PostRepository implements PostRepositoryInterface * * @param string $slug 게시판 슬러그 * @param int $id 게시글 ID + * @return Post 게시글 모델 * * @throws ModelNotFoundException */ @@ -205,6 +208,7 @@ class PostRepository implements PostRepositoryInterface * @param string $slug 게시판 슬러그 * @param int $id 게시글 ID * @param array $data 수정할 데이터 + * @return Post 수정된 게시글 모델 * * @throws ModelNotFoundException */ @@ -221,6 +225,7 @@ class PostRepository implements PostRepositoryInterface * * @param string $slug 게시판 슬러그 * @param int $id 게시글 ID + * @return bool 삭제 성공 여부 * * @throws ModelNotFoundException */ @@ -236,6 +241,7 @@ class PostRepository implements PostRepositoryInterface * * @param string $slug 게시판 슬러그 * @param int $id 게시글 ID + * @return bool 영구 삭제 성공 여부 * * @throws ModelNotFoundException */ @@ -253,6 +259,8 @@ class PostRepository implements PostRepositoryInterface * @param int $id 게시글 ID * @param string $status 변경할 상태 (published/blinded/deleted) * @param array $actionLog 작업 이력 데이터 + * @param string|null $triggerType 트리거 유형 (admin, report 등) + * @return Post 상태가 변경된 게시글 모델 * * @throws ModelNotFoundException */ @@ -406,6 +414,7 @@ class PostRepository implements PostRepositoryInterface * * @param string $slug 게시판 슬러그 * @param int $id 게시글 ID + * @param int|null $boardId 게시판 ID (전달 시 Board 재조회 생략) * @return Post|null 게시글 모델 (카운트 포함) */ public function findWithCounts(string $slug, int $id, ?int $boardId = null): ?Post @@ -554,6 +563,7 @@ class PostRepository implements PostRepositoryInterface * @param int $id 현재 게시글 ID * @param array $filters 정렬 파라미터 (order_by, order_direction) * @param bool $withTrashed 삭제된 게시글 포함 여부 (기본: false) + * @param int|null $boardId 게시판 ID (전달 시 Board 재조회 생략) * @return array{prev: Post|null, next: Post|null} 이전/다음 게시글 */ public function getAdjacentPosts(string $slug, int $id, array $filters = [], bool $withTrashed = false, ?int $boardId = null): array @@ -1391,6 +1401,27 @@ class PostRepository implements PostRepositoryInterface ->find($id); } + /** + * Sitemap 용으로 게시판의 공개 게시글을 스트리밍 조회합니다. + * + * lazyById 는 id 기준 키셋 페이징으로 청크를 순차 조회하므로, + * 결과셋 전체가 메모리(및 DB 드라이버 버퍼)에 적재되지 않습니다. + * + * @param int $boardId 게시판 ID + * @param int $chunkSize 청크 크기 + * @return iterable 공개 게시글 순회자 (id, updated_at 만 조회) + */ + public function streamPublishedForSitemap(int $boardId, int $chunkSize = 500): iterable + { + return Post::query() + ->where('board_id', $boardId) + ->where('status', PostStatus::Published) + ->where('is_secret', false) + ->select(['id', 'updated_at']) + ->orderBy('id') + ->lazyById($chunkSize); + } + /** * 게시판 ID 기준으로 게시글을 일괄 소프트 삭제합니다. * diff --git a/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php b/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php index 07d7ac83..69d72cf1 100644 --- a/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php +++ b/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php @@ -3,17 +3,29 @@ namespace Modules\Sirsoft\Board\Seo; use App\Seo\Contracts\SitemapContributorInterface; -use Modules\Sirsoft\Board\Enums\PostStatus; -use Modules\Sirsoft\Board\Models\Board; -use Modules\Sirsoft\Board\Models\Post; +use Illuminate\Support\Facades\Log; +use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface; +use Modules\Sirsoft\Board\Repositories\Contracts\PostRepositoryInterface; /** * Board 모듈 Sitemap 기여자 * * 게시판 및 게시글 URL을 sitemap에 제공합니다. + * 데이터 접근은 Repository 인터페이스에 위임합니다. */ class BoardSitemapContributor implements SitemapContributorInterface { + /** + * BoardSitemapContributor 생성자 + * + * @param BoardRepositoryInterface $boardRepository 게시판 Repository + * @param PostRepositoryInterface $postRepository 게시글 Repository + */ + public function __construct( + private readonly BoardRepositoryInterface $boardRepository, + private readonly PostRepositoryInterface $postRepository, + ) {} + /** * 확장 식별자를 반환합니다. * @@ -35,6 +47,11 @@ class BoardSitemapContributor implements SitemapContributorInterface { $urls = []; + // 기여자당 URL 안전 상한 (0 = 무제한). 상한은 이 기여자가 내보내는 모든 URL 유형에 + // 적용된다 — 특정 루프에만 걸면 다른 유형이 상한을 넘겨버린다. + $maxUrls = (int) g7_core_settings('seo.sitemap_max_urls_per_contributor', 0); + $truncated = false; + // 'SEO 제공 페이지' 토글 — OFF 시 해당 URL 유형 제외 (기본 노출) $seoBoards = (bool) g7_module_settings('sirsoft-board', 'seo.seo_boards', true); $seoBoard = (bool) g7_module_settings('sirsoft-board', 'seo.seo_board', true); @@ -42,11 +59,11 @@ class BoardSitemapContributor implements SitemapContributorInterface // 게시판 목록 페이지 if ($seoBoards) { - $urls[] = [ + $truncated = ! $this->appendUrl($urls, [ 'url' => '/boards', 'changefreq' => 'weekly', 'priority' => 0.5, - ]; + ], $maxUrls); } // 개별 게시판/게시글 URL이 모두 OFF면 게시판 조회 자체를 생략 @@ -55,15 +72,19 @@ class BoardSitemapContributor implements SitemapContributorInterface } // 게시판별 페이지 - $boards = Board::where('is_active', true)->get(['id', 'slug', 'updated_at']); - foreach ($boards as $board) { - if ($seoBoard) { - $urls[] = [ - 'url' => "/board/{$board->slug}", - 'lastmod' => $board->updated_at?->toW3cString(), - 'changefreq' => 'daily', - 'priority' => 0.6, - ]; + foreach ($this->boardRepository->streamActiveForSitemap() as $board) { + if ($truncated) { + break; + } + + if ($seoBoard && ! $this->appendUrl($urls, [ + 'url' => "/board/{$board->slug}", + 'lastmod' => $board->updated_at?->toW3cString(), + 'changefreq' => 'daily', + 'priority' => 0.6, + ], $maxUrls)) { + $truncated = true; + break; } if (! $seoPostDetail) { @@ -71,20 +92,45 @@ class BoardSitemapContributor implements SitemapContributorInterface } // 각 게시판의 공개된 게시글 (비밀글 제외, 게시 상태만) - $posts = Post::where('board_id', $board->id) - ->where('status', PostStatus::Published) - ->where('is_secret', false) - ->get(['id', 'updated_at']); - foreach ($posts as $post) { - $urls[] = [ + foreach ($this->postRepository->streamPublishedForSitemap($board->id) as $post) { + if (! $this->appendUrl($urls, [ 'url' => "/board/{$board->slug}/{$post->id}", 'lastmod' => $post->updated_at?->toW3cString(), 'changefreq' => 'monthly', 'priority' => 0.5, - ]; + ], $maxUrls)) { + $truncated = true; + break; + } } } + if ($truncated) { + Log::warning('[SEO] Sitemap 기여자 URL 상한 초과로 잘렸습니다.', [ + 'contributor' => $this->getIdentifier(), + 'max_urls' => $maxUrls, + ]); + } + return $urls; } + + /** + * 안전 상한을 지키며 URL 항목을 추가합니다. + * + * @param array> $urls 누적 URL 배열 (참조) + * @param array $entry 추가할 URL 항목 + * @param int $maxUrls 기여자당 URL 상한 (0 = 무제한) + * @return bool 추가되면 true, 상한 도달로 추가하지 못하면 false + */ + private function appendUrl(array &$urls, array $entry, int $maxUrls): bool + { + if ($maxUrls > 0 && count($urls) >= $maxUrls) { + return false; + } + + $urls[] = $entry; + + return true; + } } diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Repositories/BoardRepositorySitemapTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Repositories/BoardRepositorySitemapTest.php new file mode 100644 index 00000000..5e31b53f --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Unit/Repositories/BoardRepositorySitemapTest.php @@ -0,0 +1,140 @@ + $slug, + 'name' => ['ko' => '레포지토리 사이트맵 테스트', 'en' => 'Repository Sitemap Test'], + 'is_active' => true, + ]; + } + + protected function setUp(): void + { + parent::setUp(); + $this->repository = $this->app->make(BoardRepositoryInterface::class); + } + + /** + * 테스트 정리 - 이 클래스가 생성한 게시판을 제거합니다. + * + * BoardTestCase 는 트랜잭션 롤백 대신 수동 삭제 전략을 쓰며 게시판 자체는 + * 지우지 않는다. 추가 게시판을 만든 쪽이 정리하지 않으면 활성 게시판이 + * 다른 테스트 클래스로 누출되어 게시판 목록 단언을 깨뜨린다. + */ + protected function tearDown(): void + { + Board::where('slug', 'like', 'repo-sitemap-%')->forceDelete(); + + parent::tearDown(); + } + + /** + * streamActiveForSitemap: 전체 적재가 아닌 순회자를 반환한다 + */ + public function test_stream_active_for_sitemap_returns_lazy_collection(): void + { + $this->assertInstanceOf( + LazyCollection::class, + $this->repository->streamActiveForSitemap(), + 'lazyById 순회자여야 전체 적재를 피할 수 있습니다.' + ); + } + + /** + * streamActiveForSitemap: sitemap 에 필요한 컬럼만 적재한다 (회귀) + * + * 범용 getActiveBoards() 는 전컬럼을 적재하므로 sitemap 경로가 그것을 재사용하면 + * 메모리가 후퇴한다. select 가 유지되는지 속성 키로 검증한다. + */ + public function test_stream_active_for_sitemap_selects_only_sitemap_columns(): void + { + $board = $this->repository->streamActiveForSitemap()->first(); + + $this->assertNotNull($board); + $this->assertSame( + ['id', 'slug', 'updated_at'], + array_keys($board->getAttributes()), + 'sitemap 조회는 id/slug/updated_at 만 적재해야 합니다.' + ); + } + + /** + * streamActiveForSitemap: 비활성 게시판은 반환하지 않는다 + */ + public function test_stream_active_for_sitemap_excludes_inactive_boards(): void + { + $this->updateBoardSettings(['is_active' => false]); + + $slugs = collect($this->repository->streamActiveForSitemap()) + ->pluck('slug') + ->all(); + + $this->assertNotContains($this->board->slug, $slugs); + } + + /** + * streamActiveForSitemap: 활성 게시판이 포함된다 + */ + public function test_stream_active_for_sitemap_includes_active_boards(): void + { + $slugs = collect($this->repository->streamActiveForSitemap()) + ->pluck('slug') + ->all(); + + $this->assertContains($this->board->slug, $slugs); + } + + /** + * streamActiveForSitemap: 청크 크기보다 많은 게시판도 전부 순회한다 + */ + public function test_stream_active_for_sitemap_traverses_beyond_chunk_size(): void + { + foreach (range(1, 3) as $i) { + Board::create([ + 'slug' => "repo-sitemap-extra-{$i}", + 'name' => ['ko' => "추가 게시판 {$i}", 'en' => "Extra Board {$i}"], + 'is_active' => true, + ]); + } + + // 청크 크기 1 → 여러 번 쿼리해도 누락 없이 전부 순회해야 한다 + $slugs = collect($this->repository->streamActiveForSitemap(1)) + ->pluck('slug') + ->all(); + + foreach (range(1, 3) as $i) { + $this->assertContains("repo-sitemap-extra-{$i}", $slugs); + } + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Seo/BoardSitemapContributorTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Seo/BoardSitemapContributorTest.php index c974b66f..93c1129e 100644 --- a/modules/_bundled/sirsoft-board/tests/Unit/Seo/BoardSitemapContributorTest.php +++ b/modules/_bundled/sirsoft-board/tests/Unit/Seo/BoardSitemapContributorTest.php @@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Board\Tests\Unit\Seo; require_once __DIR__.'/../../ModuleTestCase.php'; +use App\Seo\Contracts\SitemapContributorInterface; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\DB; use Modules\Sirsoft\Board\Seo\BoardSitemapContributor; @@ -47,7 +48,16 @@ class BoardSitemapContributorTest extends BoardTestCase protected function setUp(): void { parent::setUp(); - $this->contributor = new BoardSitemapContributor; + // Repository 주입이 필요하므로 컨테이너로 해석합니다. + $this->contributor = $this->app->make(BoardSitemapContributor::class); + } + + /** + * SitemapContributorInterface 를 구현한다 + */ + public function test_implements_sitemap_contributor_interface(): void + { + $this->assertInstanceOf(SitemapContributorInterface::class, $this->contributor); } /** @@ -58,6 +68,51 @@ class BoardSitemapContributorTest extends BoardTestCase $this->assertSame('sirsoft-board', $this->contributor->getIdentifier()); } + /** + * getUrls: 활성 게시판이 없으면 정적 목록 URL만 반환한다 + */ + public function test_get_urls_returns_only_static_url_when_no_active_boards(): void + { + $this->updateBoardSettings(['is_active' => false]); + + $urls = $this->contributor->getUrls(); + + $this->assertSame(['/boards'], array_column($urls, 'url')); + } + + /** + * getUrls: 기여자당 URL 안전 상한을 초과하면 게시글 URL이 잘린다 + */ + public function test_get_urls_truncates_at_max_urls_per_contributor(): void + { + $this->createTestPost(['status' => 'published', 'is_secret' => false]); + $this->createTestPost(['status' => 'published', 'is_secret' => false]); + + // /boards + /board/{slug} 로 이미 2건이므로 게시글은 1건까지만 담긴다. + // g7_core_settings 는 Config 파사드 기반이므로 Config::set 으로 주입합니다. + Config::set('g7_settings.core.seo.sitemap_max_urls_per_contributor', 3); + + $this->assertCount(3, $this->contributor->getUrls()); + } + + /** + * getUrls: 상한이 게시판 루프에도 적용된다 (회귀) + * + * 상한 검사가 게시글 루프에만 있으면, 게시판 URL 만으로 상한을 초과해도 잘리지 않는다. + */ + public function test_get_urls_truncates_within_board_loop(): void + { + $this->createTestPost(['status' => 'published', 'is_secret' => false]); + + // /boards 1건만 담기면 상한 도달 → 게시판/게시글 URL 은 하나도 담기지 않아야 한다. + Config::set('g7_settings.core.seo.sitemap_max_urls_per_contributor', 1); + + $urls = $this->contributor->getUrls(); + + $this->assertCount(1, $urls, '게시판 루프에서도 상한이 지켜져야 합니다.'); + $this->assertSame('/boards', $urls[0]['url']); + } + /** * getUrls: /boards 항목이 반드시 포함된다 */ diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index 6becea4f..2c308f92 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -4,6 +4,16 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.5] - 2026-07-16 + +### Added + +- 사이트맵에 담을 상품·카테고리 수의 안전 상한을 지원합니다. 상한을 설정하면 상품 목록·카테고리·상품 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다. + +### Changed + +- 사이트맵을 만들 때 상품과 카테고리를 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 상품이 많은 쇼핑몰에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다. + ## [1.0.4] - 2026-07-16 ### Fixed diff --git a/modules/_bundled/sirsoft-ecommerce/composer.json b/modules/_bundled/sirsoft-ecommerce/composer.json index 3eda744e..542c3464 100644 --- a/modules/_bundled/sirsoft-ecommerce/composer.json +++ b/modules/_bundled/sirsoft-ecommerce/composer.json @@ -2,7 +2,7 @@ "name": "modules/sirsoft-ecommerce", "description": "Ecommerce module for Gnuboard7", "type": "library", - "version": "1.0.4", + "version": "1.0.5", "license": "MIT", "autoload": { "psr-4": { diff --git a/modules/_bundled/sirsoft-ecommerce/module.json b/modules/_bundled/sirsoft-ecommerce/module.json index 59be2a50..7f383341 100644 --- a/modules/_bundled/sirsoft-ecommerce/module.json +++ b/modules/_bundled/sirsoft-ecommerce/module.json @@ -5,7 +5,7 @@ "ko": "이커머스", "en": "Ecommerce" }, - "version": "1.0.4", + "version": "1.0.5", "license": "MIT", "description": { "ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리", diff --git a/modules/_bundled/sirsoft-ecommerce/package-lock.json b/modules/_bundled/sirsoft-ecommerce/package-lock.json index 63316555..0b9e70b0 100644 --- a/modules/_bundled/sirsoft-ecommerce/package-lock.json +++ b/modules/_bundled/sirsoft-ecommerce/package-lock.json @@ -1,12 +1,12 @@ { "name": "@g7/sirsoft-ecommerce", - "version": "1.0.4", + "version": "1.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@g7/sirsoft-ecommerce", - "version": "1.0.4", + "version": "1.0.5", "devDependencies": { "jsdom": "^27.4.0", "typescript": "^5.3.3", diff --git a/modules/_bundled/sirsoft-ecommerce/package.json b/modules/_bundled/sirsoft-ecommerce/package.json index 746ded67..9e058613 100644 --- a/modules/_bundled/sirsoft-ecommerce/package.json +++ b/modules/_bundled/sirsoft-ecommerce/package.json @@ -1,6 +1,6 @@ { "name": "@g7/sirsoft-ecommerce", - "version": "1.0.4", + "version": "1.0.5", "description": "그누보드7 이커머스 모듈 프론트엔드 에셋", "private": true, "type": "module", diff --git a/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php b/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php index e22a4739..7999c461 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Providers/EcommerceServiceProvider.php @@ -218,8 +218,9 @@ class EcommerceServiceProvider extends BaseModuleServiceProvider // Sitemap 기여자 등록 $this->app->booted(function () { if ($this->app->bound(SitemapGenerator::class)) { + // Repository 주입을 위해 컨테이너로 해석합니다. $this->app->make(SitemapGenerator::class)->registerContributor( - new EcommerceSitemapContributor + $this->app->make(EcommerceSitemapContributor::class) ); } }); diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/CategoryRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/CategoryRepository.php index deaad41b..b0357fd8 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/CategoryRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/CategoryRepository.php @@ -15,6 +15,24 @@ class CategoryRepository implements CategoryRepositoryInterface protected Category $model ) {} + /** + * Sitemap 용으로 활성 카테고리를 스트리밍 조회합니다. + * + * lazyById 는 id 기준 키셋 페이징으로 청크를 순차 조회하므로, + * 결과셋 전체가 메모리(및 DB 드라이버 버퍼)에 적재되지 않습니다. + * + * @param int $chunkSize 청크 크기 + * @return iterable 활성 카테고리 순회자 (id, slug, updated_at 만 조회) + */ + public function streamActiveForSitemap(int $chunkSize = 500): iterable + { + return $this->model->newQuery() + ->where('is_active', true) + ->select(['id', 'slug', 'updated_at']) + ->orderBy('id') + ->lazyById($chunkSize); + } + /** * {@inheritDoc} */ @@ -76,7 +94,7 @@ class CategoryRepository implements CategoryRepositoryInterface { $query = $this->model->newQuery(); - if (!empty($with)) { + if (! empty($with)) { $query->with($with); } @@ -101,6 +119,7 @@ class CategoryRepository implements CategoryRepositoryInterface { $category = $this->findById($id); $category->update($data); + return $category->fresh(); } @@ -110,6 +129,7 @@ class CategoryRepository implements CategoryRepositoryInterface public function delete(int $id): bool { $category = $this->findById($id); + return $category->delete(); } @@ -127,6 +147,7 @@ class CategoryRepository implements CategoryRepositoryInterface public function getProductCount(int $id): int { $category = $this->findById($id); + return $category->products()->count(); } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CategoryRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CategoryRepositoryInterface.php index da2caf18..6762b532 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CategoryRepositoryInterface.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/CategoryRepositoryInterface.php @@ -13,94 +13,104 @@ interface CategoryRepositoryInterface /** * 계층형 카테고리 목록 조회 * - * @param array $filters 필터 조건 - * @param array $with Eager loading 관계 - * @return Collection + * @param array $filters 필터 조건 + * @param array $with Eager loading 관계 + * @return Collection 계층형 카테고리 목록 */ public function getHierarchical(array $filters = [], array $with = []): Collection; /** * ID로 카테고리 조회 * - * @param int $id 카테고리 ID - * @param array $with Eager loading 관계 - * @return Category|null + * @param int $id 카테고리 ID + * @param array $with Eager loading 관계 + * @return Category|null 카테고리 모델 (없으면 null) */ public function findById(int $id, array $with = []): ?Category; /** * 카테고리 생성 * - * @param array $data 카테고리 데이터 - * @return Category + * @param array $data 카테고리 데이터 + * @return Category 생성된 카테고리 모델 */ public function create(array $data): Category; /** * 카테고리 수정 * - * @param int $id 카테고리 ID - * @param array $data 수정 데이터 - * @return Category + * @param int $id 카테고리 ID + * @param array $data 수정 데이터 + * @return Category 수정된 카테고리 모델 */ public function update(int $id, array $data): Category; /** * 카테고리 삭제 * - * @param int $id 카테고리 ID - * @return bool + * @param int $id 카테고리 ID + * @return bool 삭제 성공 여부 */ public function delete(int $id): bool; /** * 하위 카테고리 존재 여부 확인 * - * @param int $id 카테고리 ID - * @return bool + * @param int $id 카테고리 ID + * @return bool 하위 카테고리 존재 여부 */ public function hasChildren(int $id): bool; /** * 연결된 상품 수 조회 * - * @param int $id 카테고리 ID - * @return int + * @param int $id 카테고리 ID + * @return int 연결된 상품 수 */ public function getProductCount(int $id): int; /** * 다음 정렬 순서 값 조회 * - * @param int|null $parentId 부모 카테고리 ID - * @return int + * @param int|null $parentId 부모 카테고리 ID + * @return int 다음 정렬 순서 값 */ public function getNextSortOrder(?int $parentId = null): int; /** * 슬러그 중복 확인 * - * @param string $slug 슬러그 - * @param int|null $excludeId 제외할 카테고리 ID - * @return bool + * @param string $slug 슬러그 + * @param int|null $excludeId 제외할 카테고리 ID + * @return bool 슬러그 중복 여부 */ public function existsBySlug(string $slug, ?int $excludeId = null): bool; /** * slug로 카테고리 조회 * - * @param string $slug 카테고리 slug - * @param array $with Eager loading 관계 - * @return Category|null + * @param string $slug 카테고리 slug + * @param array $with Eager loading 관계 + * @return Category|null 카테고리 모델 (없으면 null) */ public function findBySlug(string $slug, array $with = []): ?Category; /** * 평면 리스트로 카테고리 목록 조회 (TagInput 등에 사용) * - * @param array $filters 필터 조건 - * @param array $with Eager loading 관계 - * @return Collection + * @param array $filters 필터 조건 + * @param array $with Eager loading 관계 + * @return Collection 평면 카테고리 목록 */ public function getFlatList(array $filters = [], array $with = []): Collection; + + /** + * Sitemap 용으로 활성 카테고리를 스트리밍 조회합니다. + * + * 전체 적재를 피하기 위해 id 기준으로 청크 단위 지연 조회합니다. + * + * @param int $chunkSize 청크 크기 + * @return iterable 활성 카테고리 순회자 (id, slug, updated_at 만 조회) + */ + public function streamActiveForSitemap(int $chunkSize = 500): iterable; } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductRepositoryInterface.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductRepositoryInterface.php index 397b02ff..3c9526ff 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductRepositoryInterface.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/Contracts/ProductRepositoryInterface.php @@ -29,6 +29,16 @@ interface ProductRepositoryInterface */ public function existsAny(): bool; + /** + * Sitemap 용으로 전시 중인 상품을 스트리밍 조회합니다. + * + * 전체 적재를 피하기 위해 id 기준으로 청크 단위 지연 조회합니다. + * + * @param int $chunkSize 청크 크기 + * @return iterable 전시 중인 상품 순회자 (id, updated_at 만 조회) + */ + public function streamVisibleForSitemap(int $chunkSize = 500): iterable; + /** * 필터링된 상품 목록 조회 (페이지네이션) * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductRepository.php b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductRepository.php index 7020dd7b..28480573 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductRepository.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Repositories/ProductRepository.php @@ -32,6 +32,24 @@ class ProductRepository implements ProductRepositoryInterface return $this->model->newQuery()->withTrashed()->exists(); } + /** + * Sitemap 용으로 전시 중인 상품을 스트리밍 조회합니다. + * + * lazyById 는 id 기준 키셋 페이징으로 청크를 순차 조회하므로, + * 결과셋 전체가 메모리(및 DB 드라이버 버퍼)에 적재되지 않습니다. + * + * @param int $chunkSize 청크 크기 + * @return iterable 전시 중인 상품 순회자 (id, updated_at 만 조회) + */ + public function streamVisibleForSitemap(int $chunkSize = 500): iterable + { + return $this->model->newQuery() + ->where('display_status', ProductDisplayStatus::VISIBLE->value) + ->select(['id', 'updated_at']) + ->orderBy('id') + ->lazyById($chunkSize); + } + /** * {@inheritDoc} */ diff --git a/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php b/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php index 59f8a540..c1624102 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php @@ -3,17 +3,29 @@ namespace Modules\Sirsoft\Ecommerce\Seo; use App\Seo\Contracts\SitemapContributorInterface; -use Modules\Sirsoft\Ecommerce\Enums\ProductDisplayStatus; -use Modules\Sirsoft\Ecommerce\Models\Category; -use Modules\Sirsoft\Ecommerce\Models\Product; +use Illuminate\Support\Facades\Log; +use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CategoryRepositoryInterface; +use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface; /** * Ecommerce 모듈 Sitemap 기여자 * * 상품 및 카테고리 URL을 sitemap에 제공합니다. + * 데이터 접근은 Repository 인터페이스에 위임합니다. */ class EcommerceSitemapContributor implements SitemapContributorInterface { + /** + * EcommerceSitemapContributor 생성자 + * + * @param CategoryRepositoryInterface $categoryRepository 카테고리 Repository + * @param ProductRepositoryInterface $productRepository 상품 Repository + */ + public function __construct( + private readonly CategoryRepositoryInterface $categoryRepository, + private readonly ProductRepositoryInterface $productRepository, + ) {} + /** * 확장 식별자를 반환합니다. * @@ -36,42 +48,76 @@ class EcommerceSitemapContributor implements SitemapContributorInterface $urls = []; $routePath = g7_module_settings('sirsoft-ecommerce', 'basic_info.route_path') ?? 'shop'; + // 기여자당 URL 안전 상한 (0 = 무제한). 상한은 이 기여자가 내보내는 모든 URL 유형에 + // 적용된다 — 특정 루프에만 걸면 다른 유형이 상한을 넘겨버린다. + $maxUrls = (int) g7_core_settings('seo.sitemap_max_urls_per_contributor', 0); + $truncated = false; + // 상품 목록 페이지 — 'SEO 제공 페이지' 토글 OFF 시 제외 if ((bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_shop_index', true)) { - $urls[] = [ + $truncated = ! $this->appendUrl($urls, [ 'url' => "/{$routePath}/products", 'changefreq' => 'daily', 'priority' => 0.7, - ]; + ], $maxUrls); } // 카테고리별 페이지 — 토글 OFF 시 제외 - if ((bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_category', true)) { - $categories = Category::where('is_active', true)->get(['id', 'slug', 'updated_at']); - foreach ($categories as $category) { - $urls[] = [ + if (! $truncated && (bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_category', true)) { + foreach ($this->categoryRepository->streamActiveForSitemap() as $category) { + if (! $this->appendUrl($urls, [ 'url' => "/{$routePath}/category/{$category->slug}", 'lastmod' => $category->updated_at?->toW3cString(), 'changefreq' => 'weekly', 'priority' => 0.6, - ]; + ], $maxUrls)) { + $truncated = true; + break; + } } } // 개별 상품 페이지 (전시 상태가 '전시'인 상품만) — 토글 OFF 시 제외 - if ((bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_product_detail', true)) { - $products = Product::where('display_status', ProductDisplayStatus::VISIBLE) - ->get(['id', 'updated_at']); - foreach ($products as $product) { - $urls[] = [ + if (! $truncated && (bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_product_detail', true)) { + foreach ($this->productRepository->streamVisibleForSitemap() as $product) { + if (! $this->appendUrl($urls, [ 'url' => "/{$routePath}/products/{$product->id}", 'lastmod' => $product->updated_at?->toW3cString(), 'changefreq' => 'weekly', 'priority' => 0.8, - ]; + ], $maxUrls)) { + $truncated = true; + break; + } } } + if ($truncated) { + Log::warning('[SEO] Sitemap 기여자 URL 상한 초과로 잘렸습니다.', [ + 'contributor' => $this->getIdentifier(), + 'max_urls' => $maxUrls, + ]); + } + return $urls; } + + /** + * 안전 상한을 지키며 URL 항목을 추가합니다. + * + * @param array> $urls 누적 URL 배열 (참조) + * @param array $entry 추가할 URL 항목 + * @param int $maxUrls 기여자당 URL 상한 (0 = 무제한) + * @return bool 추가되면 true, 상한 도달로 추가하지 못하면 false + */ + private function appendUrl(array &$urls, array $entry, int $maxUrls): bool + { + if ($maxUrls > 0 && count($urls) >= $maxUrls) { + return false; + } + + $urls[] = $entry; + + return true; + } } diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Seo/EcommerceSitemapContributorTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Seo/EcommerceSitemapContributorTest.php index 8abb37d6..53a62b26 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Seo/EcommerceSitemapContributorTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Seo/EcommerceSitemapContributorTest.php @@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Seo; require_once __DIR__.'/../../ModuleTestCase.php'; +use App\Seo\Contracts\SitemapContributorInterface; use Illuminate\Support\Facades\Config; use Modules\Sirsoft\Ecommerce\Models\Category; use Modules\Sirsoft\Ecommerce\Models\Product; @@ -31,7 +32,8 @@ class EcommerceSitemapContributorTest extends ModuleTestCase protected function setUp(): void { parent::setUp(); - $this->contributor = new EcommerceSitemapContributor; + // Repository 주입이 필요하므로 컨테이너로 해석합니다. + $this->contributor = $this->app->make(EcommerceSitemapContributor::class); $this->routePath = g7_module_settings('sirsoft-ecommerce', 'basic_info.route_path') ?? 'shop'; } @@ -50,6 +52,14 @@ class EcommerceSitemapContributorTest extends ModuleTestCase ]); } + /** + * SitemapContributorInterface 를 구현한다 + */ + public function test_implements_sitemap_contributor_interface(): void + { + $this->assertInstanceOf(SitemapContributorInterface::class, $this->contributor); + } + /** * getIdentifier: 'sirsoft-ecommerce' 반환 */ @@ -58,6 +68,96 @@ class EcommerceSitemapContributorTest extends ModuleTestCase $this->assertSame('sirsoft-ecommerce', $this->contributor->getIdentifier()); } + /** + * getUrls: route_path 미설정 시 기본값 'shop' 을 사용한다 + */ + public function test_get_urls_uses_default_route_path_when_not_configured(): void + { + Config::set('g7_settings.modules.sirsoft-ecommerce.basic_info.route_path', null); + + $contributor = $this->app->make(EcommerceSitemapContributor::class); + $urlPaths = array_column($contributor->getUrls(), 'url'); + + $this->assertContains('/shop/products', $urlPaths); + } + + /** + * getUrls: 정적/카테고리/상품 URL 항목이 각각 올바른 키 구조를 가진다 + */ + public function test_url_entries_have_correct_structure(): void + { + $category = $this->createTestCategory(); + $product = Product::factory()->create(); + + $urls = collect($this->contributor->getUrls()); + + // 정적 목록 URL — lastmod 없음 + $index = $urls->firstWhere('url', "/{$this->routePath}/products"); + $this->assertNotNull($index); + $this->assertSame('daily', $index['changefreq']); + $this->assertSame(0.7, $index['priority']); + $this->assertArrayNotHasKey('lastmod', $index); + + // 카테고리 URL — lastmod 있음 + $categoryEntry = $urls->firstWhere('url', "/{$this->routePath}/category/{$category->slug}"); + $this->assertNotNull($categoryEntry); + $this->assertArrayHasKey('lastmod', $categoryEntry); + $this->assertSame('weekly', $categoryEntry['changefreq']); + $this->assertSame(0.6, $categoryEntry['priority']); + + // 상품 URL — lastmod 있음 + $productEntry = $urls->firstWhere('url', "/{$this->routePath}/products/{$product->id}"); + $this->assertNotNull($productEntry); + $this->assertArrayHasKey('lastmod', $productEntry); + $this->assertSame('weekly', $productEntry['changefreq']); + $this->assertSame(0.8, $productEntry['priority']); + } + + /** + * getUrls: 기여자당 URL 안전 상한을 초과하면 상품 URL이 잘린다 + */ + public function test_get_urls_truncates_at_max_urls_per_contributor(): void + { + Product::factory()->count(3)->create(); + + // 정적 목록 URL 1건이 이미 담기므로 상품은 1건까지만 담긴다. + // g7_core_settings 는 Config 파사드 기반이므로 Config::set 으로 주입합니다. + Config::set('g7_settings.core.seo.sitemap_max_urls_per_contributor', 2); + + $this->assertCount(2, $this->contributor->getUrls()); + } + + /** + * getUrls: 상한이 카테고리 루프에도 적용된다 (회귀) + * + * 상한 검사가 상품 루프에만 있으면, 카테고리만으로 상한을 초과해도 잘리지 않는다. + */ + public function test_get_urls_truncates_within_category_loop(): void + { + $this->createTestCategory(); + Category::create([ + 'name' => ['ko' => '사이트맵 카테고리2', 'en' => 'Sitemap Category 2'], + 'slug' => 'sitemap-cat-2', + 'is_active' => true, + 'path' => '/', + 'depth' => 0, + 'sort_order' => 1, + ]); + Product::factory()->count(2)->create(); + + // 정적 목록 1건 + 카테고리 2건 = 3건이지만 상한은 2건. + Config::set('g7_settings.core.seo.sitemap_max_urls_per_contributor', 2); + + $urls = $this->contributor->getUrls(); + + $this->assertCount(2, $urls, '카테고리 루프에서도 상한이 지켜져야 합니다.'); + // 상한 도달 후에는 상품 URL 이 하나도 담기지 않아야 한다 + $this->assertEmpty( + array_filter($urls, fn (array $u): bool => str_contains($u['url'], '/products/')), + '상한 도달 후 상품 URL 이 추가되면 안 됩니다.' + ); + } + /** * getUrls: 기본(토글 ON) 상태에서 목록/카테고리/상품 URL이 모두 포함된다 (비파괴 회귀) */ diff --git a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json index 7af6ba79..e8f1c775 100644 --- a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json +++ b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.json @@ -1,15 +1,15 @@ { "schema_version": "1.0", - "generated_at": "2026-07-14T14:08:36+00:00", + "generated_at": "2026-07-16T15:36:56+00:00", "generator": "g7 vendor-bundle:build", "target": "module:sirsoft-ecommerce", - "composer_json_sha256": "a90f1bb9cceeb9542e84ca09a0945f31089879fdbb566044364058332627998a", + "composer_json_sha256": "892a78a4c180c410eeb2cafbdec3ee1ff87ea066db94dea7e1340678e91759f6", "composer_lock_sha256": "876ca9c2273a33baff878d25050a567018a946412db053930a7f548c4add595d", - "zip_sha256": "ffc7123cd61c7ca13a6dc29325aa31454005affd59fe97acf372d30ab016475d", + "zip_sha256": "e222da279ba452bd155fb68f06d6bcdd0c2f29041f231eeea3ef6229912e9dcc", "zip_size": 435549, "package_count": 1, "php_requirement": "^8.2", - "g7_version": "7.0.4", + "g7_version": "7.0.5", "packages": [ { "name": "ezyang/htmlpurifier", diff --git a/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip b/modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip index b3698cc8642409d7613d24781b2c528f33bae87d..a8a0cc92e3d9688f3a488004b93a20db9f61f666 100644 GIT binary patch delta 1438 zcmZ8gX-rgC6rQ)-I}0;{l$6R02!hH|0t$f)$TAM5f+e35)Ac#I)MUI7kIG zTEYMi1(%9a1JRZxeMXAKrD`Zvw$QN5KwY3z&_YW@pwjowE86z`c=x;C_nmX@JLf)2 zvy-LSNugUq(GDU~BoUROCr1jVi9S;7ZaU-D;utrJsA1z{O&1;QRlfv9uAsU$FY zk}Y;r8Rs2(tAeuUKw?4i*|(PyB9S{S5;=>QomQkW>eKa?3h(X8b#?gs`5z8XM?8FA z)^CfQ4YKU%b#%SR#N?#=rGI$lT7262&J)9d3s$bYp-voV7#h3ar4(PUzoKfIuBOCo?yEZyrqDM1KR|g02_|+;=mi}ww=sjKUpY>Gdx%iIj2PQpg zW49MietX>5L}(s&{7_ZgZXeq};^MLDkbM92d0mgcSH{h?>XV)Z!~U;2-Htz*KOV3% z)SQ#i+I5F5*%TS0^_~N1LLFsc7&0_oH8RW$MXymEJhl0;!;j-hQgnf zlVLJI-&K;2B3>UMVu)0*V}oS08>nFt>b>QFibANi)!^fjXc&DamNTtc7_$ursj6VG zq&HAyzF^_Li?bN8&40dzYl+hWaPA{%x)6}5h+YkIU|TBa(xtDWOJ-;(Tk|7*FcfTD zdv?GK7%r4c{U*Zfs2yU3atWJJ6ti1{#O!!DN`W4;95>t$i1+4|}(Hy9?pfdhHWpq{)Et&gInJ=wK3-pi` z6+?uv|DR;{`;i(Ln*lTeU}^xx@qk_P3krf8$mBji?V$NgASJu!A@7Lg!!>4uZE;czM3pAs z?O?;3@VFpOCt~TV97=XkGX7N%6e+ksKxb2NJ5*(}v+!XA3Ap;sLYSKYrvR02z)OUH z8Y6x~KzB^oAfR`0ak3BqN57qqxBjcF_86`Zn#08^S^5h+EU2bZZfjG7vtY||+dwz~ L1473sd_?>=?Rzt( delta 1438 zcmZ8gdr(wW7~lIiXP1QqG{)j7D+I$J11f>$Anbt2;v)=`K-^-&m4IRy!clB_1P!2* z60d`UB+1A$Ok&H@92-QzoNB}hD0{%}LZuQ4i#QM=M&0|}wCw(I&+q(xzwi6b{l0Up zS3Rs(JyeNXIo?gi$`rC?pW02TX@(Ql+P|1!SVPz!#MLb40wrM$Zv$dEasa~YgY_&h z6^gyC0LGtc<(3OUX~s*<(Q6r*%#WAJyk+Ed8}cePWE#%a^uO_$uj}Ud1?99QKBOqz zls)&!MBB)c?DE%%e(5dTu^UCkq>VePnv>7?`up4Wm8_eexc*&0j3K1GMBQoEugH0> zGPOJGV9`wEVT}nFs&i)B>tFE;9>}uWTZXoCFKG@g?vHF5?r6=93GVW64)70f91VX_ zJ9$S@?B%-anY52mgQ5lt^ozbh2^l{w1isUGOsiGp7v!Fvo%*wEsDw|C`*M1AYMWBu zSGe-j;oHjZdoG{o3Y*NmX?2;@WZbo`nRCD1@m|~JJ~XBt-Ko2Sv`3;=C1sWE%NpMD zWWz-4#e|d^lX89NA)J@Gn)IK(5Zbx=tD3HB`MS87yc0>)p{%j8@53wFKe};Z`MKS` z;VqW)?z-5*#hH?&ily~uiU038S?9Qd=$_&vlMKq<2~JZR zmZTuP^N1&R=a9nO+q1z7@;vydDpmxv(wliL2JB)Y(yPQSj^b4kA5ni^$HXd}1(XI9 zylMxn5u{qjsEDr$JJT6G!?^sT{WTTXT zF0`>XqW?WY^f;@cGq+f+8q_ESw`(n+x@Wn;c<>2HG{lgHoyb5w#$8vi6G@#&~^ZqEa(LQZdSAnz^e~Irxi5;lW0R)g*YGaKP4a75X_Z~+fY5|#iRcu z`(_kv0LF0)O#-+-hIWX6Zv7cWzzrnjzn~$|g6EJYee+jT05c+aO6*DHcBGa+RQE$< z{Tw>V0rFcwMkf%NLxT4r!XLvO7DF?^VNv3nP=#J`!Unj;G|~m@5+JHE1!sc|r{a5( zxR{0=9_8?)tJ3jrlAy}K)e>sa;~}U@3-WOlf&@Z+Zz)V|#2G+U8gYOWU@FE>Na%}F zY?RQJa-1#&z|lXi#4rD+Z0x(ZRcek9>q+_JctTQ38-=Z79nOa>6}I7U00P3d&G?A? EZ-k&h&;S4c diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md index 11753b65..b2367b01 100644 --- a/modules/_bundled/sirsoft-page/CHANGELOG.md +++ b/modules/_bundled/sirsoft-page/CHANGELOG.md @@ -4,6 +4,16 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.2] - 2026-07-16 + +### Added + +- 사이트맵에 담을 페이지 수의 안전 상한을 지원합니다. 상한을 설정하면 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다. + +### Changed + +- 사이트맵을 만들 때 페이지를 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 페이지가 많은 사이트에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다. + ## [1.0.1] - 2026-07-08 ### Fixed diff --git a/modules/_bundled/sirsoft-page/composer.json b/modules/_bundled/sirsoft-page/composer.json index bb65c272..49e93a38 100644 --- a/modules/_bundled/sirsoft-page/composer.json +++ b/modules/_bundled/sirsoft-page/composer.json @@ -2,7 +2,7 @@ "name": "modules/sirsoft-page", "description": "Page module for Gnuboard7", "type": "library", - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "autoload": { "psr-4": { diff --git a/modules/_bundled/sirsoft-page/module.json b/modules/_bundled/sirsoft-page/module.json index 93be60e8..78007a38 100644 --- a/modules/_bundled/sirsoft-page/module.json +++ b/modules/_bundled/sirsoft-page/module.json @@ -5,7 +5,7 @@ "ko": "페이지", "en": "Page" }, - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "description": { "ko": "정적 페이지(정보/정책/안내) 관리 모듈", diff --git a/modules/_bundled/sirsoft-page/package-lock.json b/modules/_bundled/sirsoft-page/package-lock.json index 79e30c54..f4ab96db 100644 --- a/modules/_bundled/sirsoft-page/package-lock.json +++ b/modules/_bundled/sirsoft-page/package-lock.json @@ -1,12 +1,12 @@ { "name": "@g7/sirsoft-page", - "version": "1.0.1", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@g7/sirsoft-page", - "version": "1.0.1", + "version": "1.0.2", "devDependencies": { "jsdom": "^27.4.0", "typescript": "^5.3.3", diff --git a/modules/_bundled/sirsoft-page/package.json b/modules/_bundled/sirsoft-page/package.json index 070d07f5..6de095d7 100644 --- a/modules/_bundled/sirsoft-page/package.json +++ b/modules/_bundled/sirsoft-page/package.json @@ -1,6 +1,6 @@ { "name": "@g7/sirsoft-page", - "version": "1.0.1", + "version": "1.0.2", "description": "그누보드7 페이지 모듈 프론트엔드 에셋", "private": true, "type": "module", diff --git a/modules/_bundled/sirsoft-page/src/Providers/PageServiceProvider.php b/modules/_bundled/sirsoft-page/src/Providers/PageServiceProvider.php index b50de7e5..8f16dda5 100644 --- a/modules/_bundled/sirsoft-page/src/Providers/PageServiceProvider.php +++ b/modules/_bundled/sirsoft-page/src/Providers/PageServiceProvider.php @@ -63,8 +63,9 @@ class PageServiceProvider extends BaseModuleServiceProvider // Sitemap 기여자 등록 $this->app->booted(function () { if ($this->app->bound(SitemapGenerator::class)) { + // Repository 주입을 위해 컨테이너로 해석합니다. $this->app->make(SitemapGenerator::class)->registerContributor( - new PageSitemapContributor + $this->app->make(PageSitemapContributor::class) ); } }); diff --git a/modules/_bundled/sirsoft-page/src/Repositories/Contracts/PageRepositoryInterface.php b/modules/_bundled/sirsoft-page/src/Repositories/Contracts/PageRepositoryInterface.php index 9abd3586..3635fd25 100644 --- a/modules/_bundled/sirsoft-page/src/Repositories/Contracts/PageRepositoryInterface.php +++ b/modules/_bundled/sirsoft-page/src/Repositories/Contracts/PageRepositoryInterface.php @@ -2,6 +2,8 @@ namespace Modules\Sirsoft\Page\Repositories\Contracts; +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Pagination\LengthAwarePaginator; use Modules\Sirsoft\Page\Models\Page; @@ -35,13 +37,23 @@ interface PageRepositoryInterface */ public function findBySlug(string $slug): ?Page; + /** + * Sitemap 용으로 발행된 페이지를 스트리밍 조회합니다. + * + * 전체 적재를 피하기 위해 id 기준으로 청크 단위 지연 조회합니다. + * + * @param int $chunkSize 청크 크기 + * @return iterable 발행된 페이지 순회자 (id, slug, updated_at 만 조회) + */ + public function streamPublishedForSitemap(int $chunkSize = 500): iterable; + /** * ID로 페이지를 조회하며, 없으면 예외를 발생시킵니다. * * @param int $id 페이지 ID * @return Page 페이지 모델 * - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + * @throws ModelNotFoundException */ public function findOrFail(int $id): Page; @@ -86,7 +98,7 @@ interface PageRepositoryInterface * @param string $orderBy 정렬 컬럼 * @param string $direction 정렬 방향 (asc, desc) * @param int $limit 조회할 최대 항목 수 - * @return array{total: int, items: \Illuminate\Database\Eloquent\Collection} + * @return array{total: int, items: Collection} */ public function searchByKeyword(string $keyword, string $orderBy = 'created_at', string $direction = 'desc', int $limit = 10): array; diff --git a/modules/_bundled/sirsoft-page/src/Repositories/PageRepository.php b/modules/_bundled/sirsoft-page/src/Repositories/PageRepository.php index ac937438..be5c37f5 100644 --- a/modules/_bundled/sirsoft-page/src/Repositories/PageRepository.php +++ b/modules/_bundled/sirsoft-page/src/Repositories/PageRepository.php @@ -72,6 +72,24 @@ class PageRepository implements PageRepositoryInterface return Page::where('slug', $slug)->first(); } + /** + * Sitemap 용으로 발행된 페이지를 스트리밍 조회합니다. + * + * lazyById 는 id 기준 키셋 페이징으로 청크를 순차 조회하므로, + * 결과셋 전체가 메모리(및 DB 드라이버 버퍼)에 적재되지 않습니다. + * + * @param int $chunkSize 청크 크기 + * @return iterable 발행된 페이지 순회자 (id, slug, updated_at 만 조회) + */ + public function streamPublishedForSitemap(int $chunkSize = 500): iterable + { + return Page::query() + ->where('published', true) + ->select(['id', 'slug', 'updated_at']) + ->orderBy('id') + ->lazyById($chunkSize); + } + /** * ID로 페이지를 조회하며, 없으면 예외를 발생시킵니다. * diff --git a/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php b/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php index 4d6fb2c9..4860da75 100644 --- a/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php +++ b/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php @@ -3,15 +3,26 @@ namespace Modules\Sirsoft\Page\Seo; use App\Seo\Contracts\SitemapContributorInterface; -use Modules\Sirsoft\Page\Models\Page; +use Illuminate\Support\Facades\Log; +use Modules\Sirsoft\Page\Repositories\Contracts\PageRepositoryInterface; /** * Page 모듈 Sitemap 기여자 * * 발행된 페이지 URL을 sitemap에 제공합니다. + * 데이터 접근은 Repository 인터페이스에 위임합니다. */ class PageSitemapContributor implements SitemapContributorInterface { + /** + * PageSitemapContributor 생성자 + * + * @param PageRepositoryInterface $pageRepository 페이지 Repository + */ + public function __construct( + private readonly PageRepositoryInterface $pageRepository, + ) {} + /** * 확장 식별자를 반환합니다. * @@ -33,16 +44,48 @@ class PageSitemapContributor implements SitemapContributorInterface { $urls = []; - $pages = Page::where('published', true)->get(['slug', 'updated_at']); - foreach ($pages as $page) { - $urls[] = [ + // 기여자당 URL 안전 상한 (0 = 무제한). 상한은 이 기여자가 내보내는 모든 URL 유형에 적용된다. + $maxUrls = (int) g7_core_settings('seo.sitemap_max_urls_per_contributor', 0); + $truncated = false; + + foreach ($this->pageRepository->streamPublishedForSitemap() as $page) { + if (! $this->appendUrl($urls, [ 'url' => "/page/{$page->slug}", 'lastmod' => $page->updated_at?->toW3cString(), 'changefreq' => 'monthly', 'priority' => 0.5, - ]; + ], $maxUrls)) { + $truncated = true; + break; + } + } + + if ($truncated) { + Log::warning('[SEO] Sitemap 기여자 URL 상한 초과로 잘렸습니다.', [ + 'contributor' => $this->getIdentifier(), + 'max_urls' => $maxUrls, + ]); } return $urls; } + + /** + * 안전 상한을 지키며 URL 항목을 추가합니다. + * + * @param array> $urls 누적 URL 배열 (참조) + * @param array $entry 추가할 URL 항목 + * @param int $maxUrls 기여자당 URL 상한 (0 = 무제한) + * @return bool 추가되면 true, 상한 도달로 추가하지 못하면 false + */ + private function appendUrl(array &$urls, array $entry, int $maxUrls): bool + { + if ($maxUrls > 0 && count($urls) >= $maxUrls) { + return false; + } + + $urls[] = $entry; + + return true; + } } diff --git a/modules/_bundled/sirsoft-page/tests/Unit/Seo/PageSitemapContributorTest.php b/modules/_bundled/sirsoft-page/tests/Unit/Seo/PageSitemapContributorTest.php new file mode 100644 index 00000000..fb4bb299 --- /dev/null +++ b/modules/_bundled/sirsoft-page/tests/Unit/Seo/PageSitemapContributorTest.php @@ -0,0 +1,120 @@ +contributor = $this->app->make(PageSitemapContributor::class); + } + + /** + * SitemapContributorInterface 를 구현한다 + */ + public function test_implements_sitemap_contributor_interface(): void + { + $this->assertInstanceOf(SitemapContributorInterface::class, $this->contributor); + } + + /** + * getIdentifier: 'sirsoft-page' 반환 + */ + public function test_get_identifier_returns_sirsoft_page(): void + { + $this->assertSame('sirsoft-page', $this->contributor->getIdentifier()); + } + + /** + * getUrls: 발행된 페이지 URL이 포함된다 + */ + public function test_get_urls_includes_published_page(): void + { + $page = Page::factory()->published()->create(); + + $urlPaths = array_column($this->contributor->getUrls(), 'url'); + + $this->assertContains("/page/{$page->slug}", $urlPaths); + } + + /** + * getUrls: 미발행 페이지는 포함되지 않는다 + */ + public function test_get_urls_excludes_unpublished_page(): void + { + $page = Page::factory()->create(['published' => false]); + + $urlPaths = array_column($this->contributor->getUrls(), 'url'); + + $this->assertNotContains("/page/{$page->slug}", $urlPaths); + } + + /** + * getUrls: URL 항목이 올바른 키 구조를 가진다 + */ + public function test_url_entries_have_correct_structure(): void + { + $page = Page::factory()->published()->create(); + + $urls = $this->contributor->getUrls(); + $entry = collect($urls)->firstWhere('url', "/page/{$page->slug}"); + + $this->assertNotNull($entry); + $this->assertArrayHasKey('lastmod', $entry); + $this->assertSame('monthly', $entry['changefreq']); + $this->assertSame(0.5, $entry['priority']); + } + + /** + * getUrls: 발행된 페이지가 없으면 빈 배열을 반환한다 + */ + public function test_get_urls_returns_empty_array_when_no_published_pages(): void + { + Page::factory()->count(2)->create(['published' => false]); + + $this->assertSame([], $this->contributor->getUrls()); + } + + /** + * getUrls: 기여자당 URL 안전 상한을 초과하면 잘린다 + */ + public function test_get_urls_truncates_at_max_urls_per_contributor(): void + { + Page::factory()->count(3)->published()->create(); + + // g7_core_settings 는 Config 파사드 기반이므로 Config::set 으로 주입합니다. + Config::set('g7_settings.core.seo.sitemap_max_urls_per_contributor', 2); + + $this->assertCount(2, $this->contributor->getUrls()); + } +} diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json index de189905..4ea72464 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json @@ -1406,9 +1406,9 @@ "seo_cache_settings": "SEO Cache Settings", "seo_cache_settings_desc": "Configure SEO page caching.", "cache_enabled": "Enable SEO Cache", - "cache_enabled_desc": "Cache SEO pages to improve response time.", + "cache_enabled_desc": "Cache SEO pages to improve response time. Leave unset to follow the value in Advanced settings.", "cache_ttl": "Cache TTL (seconds)", - "cache_ttl_hint": "Min 60s, max 86400s (24 hours). Default: 7200s (2 hours)", + "cache_ttl_hint": "Leave empty to follow the SEO cache TTL in Advanced settings. Enter a value to override it here — min 60s, max 86400s (24 hours)", "sitemap_settings": "Sitemap Settings", "sitemap_settings_desc": "Configure automatic sitemap.xml generation.", "sitemap_enabled": "Enable Sitemap Generation", @@ -1420,7 +1420,7 @@ "sitemap_schedule_time": "Generation Time", "sitemap_schedule_time_hint": "Enter in HH:mm format. (e.g., 02:00)", "sitemap_cache_ttl": "Sitemap Cache TTL (seconds)", - "sitemap_cache_ttl_hint": "Min 3600s (1 hour), max 604800s (7 days). Default: 86400s (24 hours)", + "sitemap_cache_ttl_hint": "Leave empty to follow the sitemap cache TTL in Advanced settings. Enter a value to override it here — min 3600s (1 hour), max 604800s (7 days)", "sitemap_last_updated_at": "Last generated", "sitemap_manual_update": "Generate now", "sitemap_updating": "Generating...", diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json index 9bc4afec..fff2e289 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json @@ -1410,9 +1410,9 @@ "seo_cache_settings": "SEO 캐시 설정", "seo_cache_settings_desc": "SEO 페이지 캐시 관련 설정입니다.", "cache_enabled": "SEO 캐시 활성화", - "cache_enabled_desc": "SEO 페이지를 캐시하여 응답 속도를 향상시킵니다.", + "cache_enabled_desc": "SEO 페이지를 캐시하여 응답 속도를 향상시킵니다. 지정하지 않으면 고급 설정의 값을 따릅니다.", "cache_ttl": "캐시 유지 시간 (초)", - "cache_ttl_hint": "최소 60초, 최대 86400초 (24시간). 기본값: 7200초 (2시간)", + "cache_ttl_hint": "비워두면 고급 설정의 SEO 캐시 시간을 따릅니다. 값을 넣으면 이 화면의 값이 우선합니다 — 최소 60초, 최대 86400초 (24시간)", "sitemap_settings": "Sitemap 설정", "sitemap_settings_desc": "sitemap.xml 자동 생성 관련 설정입니다.", "sitemap_enabled": "Sitemap 생성 활성화", @@ -1424,7 +1424,7 @@ "sitemap_schedule_time": "생성 시각", "sitemap_schedule_time_hint": "HH:mm 형식으로 입력하세요. (예: 02:00)", "sitemap_cache_ttl": "Sitemap 캐시 유지 시간 (초)", - "sitemap_cache_ttl_hint": "최소 3600초 (1시간), 최대 604800초 (7일). 기본값: 86400초 (24시간)", + "sitemap_cache_ttl_hint": "비워두면 고급 설정의 Sitemap 캐시 시간을 따릅니다. 값을 넣으면 이 화면의 값이 우선합니다 — 최소 3600초 (1시간), 최대 604800초 (7일)", "sitemap_last_updated_at": "마지막 생성", "sitemap_manual_update": "지금 생성", "sitemap_updating": "생성 중...", diff --git a/tests/Unit/Seo/BoardSitemapContributorTest.php b/tests/Unit/Seo/BoardSitemapContributorTest.php deleted file mode 100644 index 03722020..00000000 --- a/tests/Unit/Seo/BoardSitemapContributorTest.php +++ /dev/null @@ -1,181 +0,0 @@ -contributor = new BoardSitemapContributor; - } - - /** - * 테스트 종료 - Mockery 리소스를 정리합니다. - */ - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } - - /** - * SitemapContributorInterface를 구현하는지 확인합니다. - */ - public function test_implements_sitemap_contributor_interface(): void - { - $this->assertInstanceOf(SitemapContributorInterface::class, $this->contributor); - } - - /** - * getIdentifier()가 'sirsoft-board'를 반환하는지 확인합니다. - */ - public function test_get_identifier_returns_correct_value(): void - { - $this->assertSame('sirsoft-board', $this->contributor->getIdentifier()); - } - - /** - * getUrls()가 게시판/게시글 URL을 포함한 배열을 반환하는지 확인합니다. - * - * @runInSeparateProcess - * - * @preserveGlobalState disabled - */ - public function test_get_urls_returns_board_and_post_urls(): void - { - // Board::where('is_active', true)->get() Mock - $boardCollection = collect([ - (object) ['id' => 1, 'slug' => 'notice', 'updated_at' => now()], - (object) ['id' => 2, 'slug' => 'free', 'updated_at' => now()], - ]); - - $boardQuery = Mockery::mock('Illuminate\Database\Eloquent\Builder'); - $boardQuery->shouldReceive('get') - ->with(['id', 'slug', 'updated_at']) - ->andReturn($boardCollection); - - $boardMock = Mockery::mock('alias:'.Board::class); - $boardMock->shouldReceive('where') - ->with('is_active', true) - ->andReturn($boardQuery); - - // Post::where(...)->where(...)->where(...)->get() Mock - notice 게시판 - $noticePostCollection = collect([ - (object) ['id' => 100, 'updated_at' => now()], - ]); - - // Post::where(...)->where(...)->where(...)->get() Mock - free 게시판 - $freePostCollection = collect([ - (object) ['id' => 200, 'updated_at' => now()], - (object) ['id' => 201, 'updated_at' => now()], - ]); - - // Post 쿼리 빌더 체이닝 Mock - $postQueryNotice = Mockery::mock('Illuminate\Database\Eloquent\Builder'); - $postQueryNotice->shouldReceive('where') - ->with('status', PostStatus::Published) - ->andReturnSelf(); - $postQueryNotice->shouldReceive('where') - ->with('is_secret', false) - ->andReturnSelf(); - $postQueryNotice->shouldReceive('get') - ->with(['id', 'updated_at']) - ->andReturn($noticePostCollection); - - $postQueryFree = Mockery::mock('Illuminate\Database\Eloquent\Builder'); - $postQueryFree->shouldReceive('where') - ->with('status', PostStatus::Published) - ->andReturnSelf(); - $postQueryFree->shouldReceive('where') - ->with('is_secret', false) - ->andReturnSelf(); - $postQueryFree->shouldReceive('get') - ->with(['id', 'updated_at']) - ->andReturn($freePostCollection); - - $postMock = Mockery::mock('alias:'.Post::class); - $postMock->shouldReceive('where') - ->with('board_id', 1) - ->andReturn($postQueryNotice); - $postMock->shouldReceive('where') - ->with('board_id', 2) - ->andReturn($postQueryFree); - - $contributor = new BoardSitemapContributor; - $urls = $contributor->getUrls(); - - $this->assertIsArray($urls); - - // 1(정적 목록) + 2(게시판) + 1(notice 게시글) + 2(free 게시글) = 6 - $this->assertCount(6, $urls); - - // 정적 게시판 목록 URL - $this->assertSame('/boards', $urls[0]['url']); - $this->assertSame('weekly', $urls[0]['changefreq']); - $this->assertSame(0.5, $urls[0]['priority']); - - // notice 게시판 URL - $this->assertSame('/board/notice', $urls[1]['url']); - $this->assertSame('daily', $urls[1]['changefreq']); - $this->assertSame(0.6, $urls[1]['priority']); - - // notice 게시판의 게시글 URL - $this->assertSame('/board/notice/100', $urls[2]['url']); - $this->assertSame('monthly', $urls[2]['changefreq']); - $this->assertSame(0.5, $urls[2]['priority']); - - // free 게시판 URL - $this->assertSame('/board/free', $urls[3]['url']); - - // free 게시판의 게시글 URL - $this->assertSame('/board/free/200', $urls[4]['url']); - $this->assertSame('/board/free/201', $urls[5]['url']); - } - - /** - * 활성 게시판이 없을 때 정적 URL만 반환하는지 확인합니다. - * - * @runInSeparateProcess - * - * @preserveGlobalState disabled - */ - public function test_get_urls_returns_only_static_url_when_no_active_boards(): void - { - $boardQuery = Mockery::mock('Illuminate\Database\Eloquent\Builder'); - $boardQuery->shouldReceive('get') - ->with(['id', 'slug', 'updated_at']) - ->andReturn(collect([])); - - $boardMock = Mockery::mock('alias:'.Board::class); - $boardMock->shouldReceive('where') - ->with('is_active', true) - ->andReturn($boardQuery); - - $contributor = new BoardSitemapContributor; - $urls = $contributor->getUrls(); - - $this->assertCount(1, $urls); - $this->assertSame('/boards', $urls[0]['url']); - } -} diff --git a/tests/Unit/Seo/EcommerceSitemapContributorTest.php b/tests/Unit/Seo/EcommerceSitemapContributorTest.php deleted file mode 100644 index 0a818cc8..00000000 --- a/tests/Unit/Seo/EcommerceSitemapContributorTest.php +++ /dev/null @@ -1,233 +0,0 @@ -contributor = new EcommerceSitemapContributor; - } - - /** - * 테스트 종료 - Mockery 리소스를 정리합니다. - */ - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } - - /** - * SitemapContributorInterface를 구현하는지 확인합니다. - */ - public function test_implements_sitemap_contributor_interface(): void - { - $this->assertInstanceOf(SitemapContributorInterface::class, $this->contributor); - } - - /** - * getIdentifier()가 'sirsoft-ecommerce'를 반환하는지 확인합니다. - */ - public function test_get_identifier_returns_correct_value(): void - { - $this->assertSame('sirsoft-ecommerce', $this->contributor->getIdentifier()); - } - - /** - * getUrls()가 배열을 반환하는지 확인합니다. - * - * Category/Product 모델 쿼리를 Mock하여 DB 의존성 없이 테스트합니다. - * - * @runInSeparateProcess - * - * @preserveGlobalState disabled - */ - public function test_get_urls_returns_array_with_mocked_models(): void - { - // Category::where('is_active', true)->get() Mock - $categoryCollection = collect([ - (object) ['id' => 1, 'slug' => 'electronics', 'updated_at' => now()], - (object) ['id' => 2, 'slug' => 'fashion', 'updated_at' => now()], - ]); - - $categoryQuery = Mockery::mock('Illuminate\Database\Eloquent\Builder'); - $categoryQuery->shouldReceive('get') - ->with(['id', 'slug', 'updated_at']) - ->andReturn($categoryCollection); - - // Category 모델 정적 호출 Mock (alias) - $categoryMock = Mockery::mock('alias:'.Category::class); - $categoryMock->shouldReceive('where') - ->with('is_active', true) - ->andReturn($categoryQuery); - - // Product::where('display_status', ProductDisplayStatus::VISIBLE)->get() Mock - $productCollection = collect([ - (object) ['id' => 10, 'updated_at' => now()], - (object) ['id' => 20, 'updated_at' => now()], - ]); - - $productQuery = Mockery::mock('Illuminate\Database\Eloquent\Builder'); - $productQuery->shouldReceive('get') - ->with(['id', 'updated_at']) - ->andReturn($productCollection); - - $productMock = Mockery::mock('alias:'.Product::class); - $productMock->shouldReceive('where') - ->with('display_status', ProductDisplayStatus::VISIBLE) - ->andReturn($productQuery); - - // route_path 설정 - $this->app['config']->set('g7_settings.modules.sirsoft-ecommerce.basic_info.route_path', 'shop'); - - $contributor = new EcommerceSitemapContributor; - $urls = $contributor->getUrls(); - - $this->assertIsArray($urls); - $this->assertNotEmpty($urls); - - // 첫 번째는 정적 상품 목록 URL - $this->assertSame('/shop/products', $urls[0]['url']); - $this->assertSame('daily', $urls[0]['changefreq']); - $this->assertSame(0.7, $urls[0]['priority']); - - // 카테고리 URL 확인 (2개) - $this->assertSame('/shop/category/electronics', $urls[1]['url']); - $this->assertSame('/shop/category/fashion', $urls[2]['url']); - - // 상품 URL 확인 (2개) - $this->assertSame('/shop/products/10', $urls[3]['url']); - $this->assertSame('/shop/products/20', $urls[4]['url']); - - // 총 5개 URL (1 정적 + 2 카테고리 + 2 상품) - $this->assertCount(5, $urls); - } - - /** - * getUrls()가 route_path 기본값 'shop'을 사용하는지 확인합니다. - * - * @runInSeparateProcess - * - * @preserveGlobalState disabled - */ - public function test_get_urls_uses_default_route_path_when_not_configured(): void - { - // Category 빈 컬렉션 반환 - $categoryQuery = Mockery::mock('Illuminate\Database\Eloquent\Builder'); - $categoryQuery->shouldReceive('get') - ->with(['id', 'slug', 'updated_at']) - ->andReturn(collect([])); - - $categoryMock = Mockery::mock('alias:'.Category::class); - $categoryMock->shouldReceive('where') - ->with('is_active', true) - ->andReturn($categoryQuery); - - // Product 빈 컬렉션 반환 - $productQuery = Mockery::mock('Illuminate\Database\Eloquent\Builder'); - $productQuery->shouldReceive('get') - ->with(['id', 'updated_at']) - ->andReturn(collect([])); - - $productMock = Mockery::mock('alias:'.Product::class); - $productMock->shouldReceive('where') - ->with('display_status', ProductDisplayStatus::VISIBLE) - ->andReturn($productQuery); - - // route_path 미설정 (기본값 'shop' 사용) - $this->app['config']->set('g7_settings.modules.sirsoft-ecommerce.basic_info.route_path', null); - - $contributor = new EcommerceSitemapContributor; - $urls = $contributor->getUrls(); - - $this->assertCount(1, $urls); - $this->assertSame('/shop/products', $urls[0]['url']); - } - - /** - * 각 URL 항목이 올바른 키 구조를 가지는지 확인합니다. - * - * @runInSeparateProcess - * - * @preserveGlobalState disabled - */ - public function test_url_entries_have_correct_structure(): void - { - $categoryCollection = collect([ - (object) ['id' => 1, 'slug' => 'test-cat', 'updated_at' => now()], - ]); - - $categoryQuery = Mockery::mock('Illuminate\Database\Eloquent\Builder'); - $categoryQuery->shouldReceive('get') - ->with(['id', 'slug', 'updated_at']) - ->andReturn($categoryCollection); - - $categoryMock = Mockery::mock('alias:'.Category::class); - $categoryMock->shouldReceive('where') - ->with('is_active', true) - ->andReturn($categoryQuery); - - $productCollection = collect([ - (object) ['id' => 5, 'updated_at' => now()], - ]); - - $productQuery = Mockery::mock('Illuminate\Database\Eloquent\Builder'); - $productQuery->shouldReceive('get') - ->with(['id', 'updated_at']) - ->andReturn($productCollection); - - $productMock = Mockery::mock('alias:'.Product::class); - $productMock->shouldReceive('where') - ->with('display_status', ProductDisplayStatus::VISIBLE) - ->andReturn($productQuery); - - $this->app['config']->set('g7_settings.modules.sirsoft-ecommerce.basic_info.route_path', 'shop'); - - $contributor = new EcommerceSitemapContributor; - $urls = $contributor->getUrls(); - - // 정적 URL 구조 확인 (lastmod 없음) - $this->assertArrayHasKey('url', $urls[0]); - $this->assertArrayHasKey('changefreq', $urls[0]); - $this->assertArrayHasKey('priority', $urls[0]); - $this->assertArrayNotHasKey('lastmod', $urls[0]); - - // 카테고리 URL 구조 확인 (lastmod 있음) - $this->assertArrayHasKey('url', $urls[1]); - $this->assertArrayHasKey('lastmod', $urls[1]); - $this->assertArrayHasKey('changefreq', $urls[1]); - $this->assertArrayHasKey('priority', $urls[1]); - $this->assertSame('weekly', $urls[1]['changefreq']); - $this->assertSame(0.6, $urls[1]['priority']); - - // 상품 URL 구조 확인 (lastmod 있음) - $this->assertArrayHasKey('url', $urls[2]); - $this->assertArrayHasKey('lastmod', $urls[2]); - $this->assertArrayHasKey('changefreq', $urls[2]); - $this->assertArrayHasKey('priority', $urls[2]); - $this->assertSame('weekly', $urls[2]['changefreq']); - $this->assertSame(0.8, $urls[2]['priority']); - } -} diff --git a/tests/Unit/Seo/PageSitemapContributorTest.php b/tests/Unit/Seo/PageSitemapContributorTest.php deleted file mode 100644 index 6e4ea1bf..00000000 --- a/tests/Unit/Seo/PageSitemapContributorTest.php +++ /dev/null @@ -1,161 +0,0 @@ -contributor = new PageSitemapContributor; - } - - /** - * 테스트 종료 - Mockery 리소스를 정리합니다. - */ - protected function tearDown(): void - { - Mockery::close(); - parent::tearDown(); - } - - /** - * SitemapContributorInterface를 구현하는지 확인합니다. - */ - public function test_implements_sitemap_contributor_interface(): void - { - $this->assertInstanceOf(SitemapContributorInterface::class, $this->contributor); - } - - /** - * getIdentifier()가 'sirsoft-page'를 반환하는지 확인합니다. - */ - public function test_get_identifier_returns_correct_value(): void - { - $this->assertSame('sirsoft-page', $this->contributor->getIdentifier()); - } - - /** - * getUrls()가 발행된 페이지 URL을 반환하는지 확인합니다. - * - * @runInSeparateProcess - * - * @preserveGlobalState disabled - */ - public function test_get_urls_returns_published_page_urls(): void - { - $pageCollection = collect([ - (object) ['slug' => 'about', 'updated_at' => now()], - (object) ['slug' => 'contact', 'updated_at' => now()], - (object) ['slug' => 'terms', 'updated_at' => now()], - ]); - - $pageQuery = Mockery::mock('Illuminate\Database\Eloquent\Builder'); - $pageQuery->shouldReceive('get') - ->with(['slug', 'updated_at']) - ->andReturn($pageCollection); - - $pageMock = Mockery::mock('alias:'.Page::class); - $pageMock->shouldReceive('where') - ->with('published', true) - ->andReturn($pageQuery); - - $contributor = new PageSitemapContributor; - $urls = $contributor->getUrls(); - - $this->assertIsArray($urls); - $this->assertCount(3, $urls); - - // 각 페이지 URL 확인 - $this->assertSame('/page/about', $urls[0]['url']); - $this->assertSame('monthly', $urls[0]['changefreq']); - $this->assertSame(0.5, $urls[0]['priority']); - $this->assertArrayHasKey('lastmod', $urls[0]); - - $this->assertSame('/page/contact', $urls[1]['url']); - $this->assertSame('/page/terms', $urls[2]['url']); - } - - /** - * 발행된 페이지가 없을 때 빈 배열을 반환하는지 확인합니다. - * - * @runInSeparateProcess - * - * @preserveGlobalState disabled - */ - public function test_get_urls_returns_empty_array_when_no_published_pages(): void - { - $pageQuery = Mockery::mock('Illuminate\Database\Eloquent\Builder'); - $pageQuery->shouldReceive('get') - ->with(['slug', 'updated_at']) - ->andReturn(collect([])); - - $pageMock = Mockery::mock('alias:'.Page::class); - $pageMock->shouldReceive('where') - ->with('published', true) - ->andReturn($pageQuery); - - $contributor = new PageSitemapContributor; - $urls = $contributor->getUrls(); - - $this->assertIsArray($urls); - $this->assertEmpty($urls); - } - - /** - * URL 항목이 올바른 키 구조를 가지는지 확인합니다. - * - * @runInSeparateProcess - * - * @preserveGlobalState disabled - */ - public function test_url_entries_have_correct_structure(): void - { - $pageCollection = collect([ - (object) ['slug' => 'test-page', 'updated_at' => now()], - ]); - - $pageQuery = Mockery::mock('Illuminate\Database\Eloquent\Builder'); - $pageQuery->shouldReceive('get') - ->with(['slug', 'updated_at']) - ->andReturn($pageCollection); - - $pageMock = Mockery::mock('alias:'.Page::class); - $pageMock->shouldReceive('where') - ->with('published', true) - ->andReturn($pageQuery); - - $contributor = new PageSitemapContributor; - $urls = $contributor->getUrls(); - - $this->assertCount(1, $urls); - - $entry = $urls[0]; - $this->assertArrayHasKey('url', $entry); - $this->assertArrayHasKey('lastmod', $entry); - $this->assertArrayHasKey('changefreq', $entry); - $this->assertArrayHasKey('priority', $entry); - - $this->assertSame('/page/test-page', $entry['url']); - $this->assertSame('monthly', $entry['changefreq']); - $this->assertSame(0.5, $entry['priority']); - } -} diff --git a/tests/Unit/Seo/SeoCacheSettingsTest.php b/tests/Unit/Seo/SeoCacheSettingsTest.php new file mode 100644 index 00000000..fa97db6e --- /dev/null +++ b/tests/Unit/Seo/SeoCacheSettingsTest.php @@ -0,0 +1,162 @@ +assertSame(7200, SeoCacheSettings::pageCacheTtl()); + } + + /** + * pageCacheTtl: SEO 탭에 값이 있으면 고급 탭을 오버라이드한다 (회귀 — 과거엔 무시됨) + */ + public function test_page_cache_ttl_seo_override_wins_over_advanced_tab(): void + { + Config::set('g7_settings.core.cache.seo_ttl', 7200); + Config::set('g7_settings.core.seo.cache_ttl', 3600); + + $this->assertSame( + 3600, + SeoCacheSettings::pageCacheTtl(), + 'SEO 탭에 지정한 값이 고급 탭보다 우선해야 합니다.' + ); + } + + /** + * pageCacheTtl: 양쪽 다 미설정이면 최종 기본값 + */ + public function test_page_cache_ttl_falls_back_to_default_when_both_unset(): void + { + $this->assertSame(7200, SeoCacheSettings::pageCacheTtl()); + } + + /** + * pageCacheEnabled: SEO 탭이 미설정이면 고급 탭 값을 따른다 + */ + public function test_page_cache_enabled_follows_advanced_tab_when_seo_is_unset(): void + { + Config::set('g7_settings.core.cache.seo_enabled', false); + Config::set('g7_settings.core.seo.cache_enabled', null); + + $this->assertFalse(SeoCacheSettings::pageCacheEnabled()); + } + + /** + * pageCacheEnabled: SEO 탭의 false 는 미설정이 아니라 오버라이드다 + * + * null 이 아닌 falsy 값(false)을 "미설정" 으로 잘못 판정하면, 운영자가 SEO 캐시를 + * 끄려 해도 고급 탭의 true 가 이겨 캐시가 계속 켜진다. + */ + public function test_page_cache_enabled_treats_false_as_explicit_override(): void + { + Config::set('g7_settings.core.cache.seo_enabled', true); + Config::set('g7_settings.core.seo.cache_enabled', false); + + $this->assertFalse( + SeoCacheSettings::pageCacheEnabled(), + 'false 는 미설정이 아니라 명시적 오버라이드여야 합니다.' + ); + } + + /** + * pageCacheEnabled: SEO 탭의 true 도 오버라이드로 동작한다 + */ + public function test_page_cache_enabled_true_overrides_disabled_advanced_tab(): void + { + Config::set('g7_settings.core.cache.seo_enabled', false); + Config::set('g7_settings.core.seo.cache_enabled', true); + + $this->assertTrue(SeoCacheSettings::pageCacheEnabled()); + } + + /** + * sitemapCacheTtl: SEO 탭이 미설정이면 고급 탭 값을 따른다 + */ + public function test_sitemap_cache_ttl_follows_advanced_tab_when_seo_is_unset(): void + { + Config::set('g7_settings.core.cache.seo_sitemap_ttl', 86400); + Config::set('g7_settings.core.seo.sitemap_cache_ttl', null); + + $this->assertSame(86400, SeoCacheSettings::sitemapCacheTtl()); + } + + /** + * sitemapCacheTtl: SEO 탭에 값이 있으면 고급 탭을 오버라이드한다 (회귀) + */ + public function test_sitemap_cache_ttl_seo_override_wins_over_advanced_tab(): void + { + Config::set('g7_settings.core.cache.seo_sitemap_ttl', 86400); + Config::set('g7_settings.core.seo.sitemap_cache_ttl', 3600); + + $this->assertSame(3600, SeoCacheSettings::sitemapCacheTtl()); + } + + /** + * sitemapCacheTtl: 양쪽 다 미설정이면 최종 기본값 + */ + public function test_sitemap_cache_ttl_falls_back_to_default_when_both_unset(): void + { + $this->assertSame(86400, SeoCacheSettings::sitemapCacheTtl()); + } + + /** + * 각 영역은 서로 독립이다 — SEO 페이지 캐시 지정이 sitemap 에 새지 않는다 + */ + public function test_domains_are_independent(): void + { + Config::set('g7_settings.core.cache.seo_ttl', 7200); + Config::set('g7_settings.core.cache.seo_sitemap_ttl', 86400); + Config::set('g7_settings.core.seo.cache_ttl', 60); + + $this->assertSame(60, SeoCacheSettings::pageCacheTtl()); + $this->assertSame( + 86400, + SeoCacheSettings::sitemapCacheTtl(), + 'SEO 페이지 캐시 오버라이드가 sitemap 캐시에 영향을 주면 안 됩니다.' + ); + } + + /** + * 문자열로 저장된 값도 정수로 정규화된다 (폼 입력은 문자열로 올 수 있음) + */ + public function test_string_values_are_normalized_to_int(): void + { + Config::set('g7_settings.core.seo.sitemap_cache_ttl', '3600'); + + $this->assertSame(3600, SeoCacheSettings::sitemapCacheTtl()); + } +} diff --git a/tests/Unit/Seo/SeoCacheStatsServiceTest.php b/tests/Unit/Seo/SeoCacheStatsServiceTest.php index 829e4c08..4acfb753 100644 --- a/tests/Unit/Seo/SeoCacheStatsServiceTest.php +++ b/tests/Unit/Seo/SeoCacheStatsServiceTest.php @@ -23,7 +23,8 @@ class SeoCacheStatsServiceTest extends TestCase { parent::setUp(); - $this->statsService = new SeoCacheStatsService; + // Repository 주입이 필요하므로 컨테이너로 해석합니다. + $this->statsService = $this->app->make(SeoCacheStatsService::class); } /** diff --git a/tests/Unit/Seo/SitemapFileStoreTest.php b/tests/Unit/Seo/SitemapFileStoreTest.php new file mode 100644 index 00000000..5c5212be --- /dev/null +++ b/tests/Unit/Seo/SitemapFileStoreTest.php @@ -0,0 +1,277 @@ +storage = new CoreStorageDriver('local'); + $this->store = new SitemapFileStore($this->storage); + } + + /** + * 커밋된 sitemap 세트를 생성합니다. + * + * @param int $urlCount 생성할 URL 수 + * @param int $urlsPerFile 파일당 URL 수 + * @param bool $gzip gzip 압축 여부 + * @return array 커밋 결과 메타 + */ + private function commitSet(int $urlCount = 3, int $urlsPerFile = 10, bool $gzip = false): array + { + $writer = new SitemapWriter( + $this->storage, + SitemapXmlRenderer::fromConfig(), + $urlsPerFile, + $gzip, + ); + + $writer->open(); + for ($i = 1; $i <= $urlCount; $i++) { + // writer 는 절대 URL(loc) 을 받는다 — 상대 경로(url) 는 Generator 가 정규화하는 형식이다. + $writer->addUrl(['loc' => url("/page/{$i}")]); + } + $writer->close(); + + return $writer->commit(); + } + + /** + * StreamedResponse 본문을 문자열로 수집합니다. + * + * @param StreamedResponse $response 스트리밍 응답 + * @return string 응답 본문 + */ + private function streamBody(StreamedResponse $response): string + { + ob_start(); + $response->sendContent(); + + return (string) ob_get_clean(); + } + + /** + * childFilename: 자식 파일명 규칙이 고정된다 (경로 SSoT) + */ + public function test_child_filename_follows_naming_rule(): void + { + $this->assertSame('sitemap-1.xml', SitemapFileStore::childFilename(1)); + $this->assertSame('sitemap-7.xml', SitemapFileStore::childFilename(7)); + $this->assertSame('sitemap-1.xml.gz', SitemapFileStore::childFilename(1, true)); + } + + /** + * childUrl: 자식 sitemap 의 공개 절대 URL 을 반환한다 + */ + public function test_child_url_returns_absolute_url(): void + { + $this->assertSame(url('/sitemap-1.xml'), SitemapFileStore::childUrl(1)); + $this->assertSame(url('/sitemap-2.xml.gz'), SitemapFileStore::childUrl(2, true)); + } + + /** + * exists: 커밋된 세트가 없으면 false + */ + public function test_exists_returns_false_without_committed_set(): void + { + $this->assertFalse($this->store->exists()); + } + + /** + * exists: 커밋 후 true + */ + public function test_exists_returns_true_after_commit(): void + { + $this->commitSet(); + + $this->assertTrue($this->store->exists()); + } + + /** + * getManifest: 세트가 없으면 null + */ + public function test_get_manifest_returns_null_without_committed_set(): void + { + $this->assertNull($this->store->getManifest()); + } + + /** + * getManifest: 커밋된 메타를 반환한다 + */ + public function test_get_manifest_returns_committed_metadata(): void + { + $this->commitSet(urlCount: 3); + + $manifest = $this->store->getManifest(); + + $this->assertIsArray($manifest); + $this->assertSame(3, $manifest['url_count']); + $this->assertSame(1, $manifest['child_count']); + $this->assertCount(1, $manifest['children']); + $this->assertSame('sitemap-1.xml', $manifest['children'][0]['file']); + } + + /** + * getManifest: manifest 가 손상되면 null 을 반환한다 (예외 전파 금지) + */ + public function test_get_manifest_returns_null_when_manifest_is_corrupted(): void + { + $this->commitSet(); + $this->storage->put( + SitemapFileStore::CATEGORY, + SitemapFileStore::LIVE_DIR.'/'.SitemapFileStore::MANIFEST_FILE, + '{ this is not json', + ); + + $this->assertNull($this->store->getManifest()); + } + + /** + * indexResponse: 세트가 없으면 null + */ + public function test_index_response_returns_null_without_committed_set(): void + { + $this->assertNull($this->store->indexResponse()); + } + + /** + * indexResponse: sitemapindex 를 XML Content-Type 으로 스트리밍한다 + */ + public function test_index_response_streams_sitemap_index(): void + { + $this->commitSet(urlCount: 12, urlsPerFile: 10); + + $response = $this->store->indexResponse(); + + $this->assertInstanceOf(StreamedResponse::class, $response); + $this->assertSame('application/xml', $response->headers->get('Content-Type')); + + $body = $this->streamBody($response); + $this->assertStringContainsString('assertStringContainsString(url('/sitemap-1.xml'), $body); + $this->assertStringContainsString(url('/sitemap-2.xml'), $body); + } + + /** + * childResponse: 세트가 없으면 null + */ + public function test_child_response_returns_null_without_committed_set(): void + { + $this->assertNull($this->store->childResponse(1)); + } + + /** + * childResponse: 자식 sitemap 본문을 스트리밍한다 + */ + public function test_child_response_streams_child_sitemap(): void + { + $this->commitSet(urlCount: 2); + + $response = $this->store->childResponse(1); + + $this->assertInstanceOf(StreamedResponse::class, $response); + $this->assertSame('application/xml', $response->headers->get('Content-Type')); + $this->assertNull($response->headers->get('Content-Encoding')); + + $body = $this->streamBody($response); + $this->assertStringContainsString('assertStringContainsString(url('/page/1'), $body); + } + + /** + * childResponse: 존재하지 않는 자식 번호는 null + */ + public function test_child_response_returns_null_for_out_of_range_number(): void + { + $this->commitSet(urlCount: 2, urlsPerFile: 10); + + $this->assertNull($this->store->childResponse(2), '자식이 1개뿐이면 2번은 없어야 합니다.'); + } + + /** + * childResponse: 0 이하 번호는 null (경로 조작 방어) + */ + public function test_child_response_returns_null_for_non_positive_number(): void + { + $this->commitSet(); + + $this->assertNull($this->store->childResponse(0)); + $this->assertNull($this->store->childResponse(-1)); + } + + /** + * childResponse: gzip 세트는 Content-Encoding 헤더를 붙인다 + */ + public function test_child_response_sets_gzip_content_encoding(): void + { + $this->commitSet(urlCount: 2, urlsPerFile: 10, gzip: true); + + $response = $this->store->childResponse(1); + + $this->assertInstanceOf(StreamedResponse::class, $response); + $this->assertSame('gzip', $response->headers->get('Content-Encoding')); + } + + /** + * indexResponse: gzip 세트여도 인덱스는 비압축이다 (D13) + */ + public function test_index_response_is_never_gzip_encoded(): void + { + $this->commitSet(urlCount: 2, urlsPerFile: 10, gzip: true); + + $response = $this->store->indexResponse(); + + $this->assertInstanceOf(StreamedResponse::class, $response); + $this->assertNull($response->headers->get('Content-Encoding')); + } + + /** + * lastGeneratedAt: 세트가 없으면 null + */ + public function test_last_generated_at_returns_null_without_committed_set(): void + { + $this->assertNull($this->store->lastGeneratedAt()); + } + + /** + * lastGeneratedAt: manifest 의 생성 시각을 반환한다 + */ + public function test_last_generated_at_returns_manifest_timestamp(): void + { + $meta = $this->commitSet(); + + $this->assertNotNull($this->store->lastGeneratedAt()); + $this->assertSame($meta['generated_at'], $this->store->lastGeneratedAt()); + } +} diff --git a/tests/Unit/Seo/SitemapGeneratorTest.php b/tests/Unit/Seo/SitemapGeneratorTest.php index a729f91e..60082e70 100644 --- a/tests/Unit/Seo/SitemapGeneratorTest.php +++ b/tests/Unit/Seo/SitemapGeneratorTest.php @@ -2,9 +2,9 @@ namespace Tests\Unit\Seo; -use App\Extension\TemplateManager; use App\Seo\Contracts\SitemapContributorInterface; use App\Seo\SitemapGenerator; +use App\Seo\SitemapWriter; use App\Seo\TemplateRouteResolver; use App\Services\TemplateService; use Illuminate\Support\Facades\Config; @@ -475,6 +475,180 @@ class SitemapGeneratorTest extends TestCase $this->assertEquals(1, substr_count($xml, '')); } + // ─── renderUrlBlock / urlset 헤더·푸터 ────────────────── + + /** + * renderUrlBlock()이 단일 언어 블록을 생성하는지 확인합니다. + */ + public function test_render_url_block_single_locale(): void + { + $block = $this->generator->renderUrlBlock([ + 'loc' => 'https://example.test/a', + 'changefreq' => 'daily', + 'priority' => 0.8, + ]); + + $this->assertStringContainsString('https://example.test/a', $block); + $this->assertStringContainsString('daily', $block); + $this->assertStringContainsString('0.8', $block); + $this->assertStringNotContainsString('xhtml:link', $block); + $this->assertSame(1, substr_count($block, '')); + } + + /** + * renderUrlBlock()이 다국어에서 로케일별 블록과 hreflang을 생성하는지 확인합니다. + */ + public function test_render_url_block_multilingual(): void + { + config(['app.locale' => 'ko', 'app.supported_locales' => ['ko', 'en']]); + + $block = $this->generator->renderUrlBlock(['loc' => 'https://example.test/a']); + + $this->assertSame(2, substr_count($block, '')); + $this->assertStringContainsString('https://example.test/a', $block); + $this->assertStringContainsString('https://example.test/a?locale=en', $block); + $this->assertStringContainsString('hreflang="x-default"', $block); + } + + /** + * XML 특수문자가 이스케이프되는지 확인합니다. + */ + public function test_render_url_block_escapes_xml_special_characters(): void + { + $block = $this->generator->renderUrlBlock(['loc' => 'https://example.test/a?x=1&y=2']); + + $this->assertStringContainsString('https://example.test/a?x=1&y=2', $block); + // 이스케이프되지 않은 원본 & 가 남아 있으면 XML 파싱이 깨집니다. + $this->assertNotFalse(simplexml_load_string(''.$block.'')); + } + + /** + * urlset 헤더/푸터가 로케일 수에 따라 분기되는지 확인합니다. + */ + public function test_urlset_header_switches_on_multilingual(): void + { + $this->assertStringNotContainsString('xmlns:xhtml', $this->generator->buildUrlsetHeader()); + $this->assertSame('', $this->generator->buildUrlsetFooter()); + + config(['app.supported_locales' => ['ko', 'en']]); + + $this->assertStringContainsString('xmlns:xhtml', $this->generator->buildUrlsetHeader()); + } + + // ─── generateToWriter ──────────────────────────────── + + /** + * generateToWriter()가 기여자 URL을 writer 에 한 건씩 전달하고 + * 상대 url 을 절대 loc 으로 변환하는지 확인합니다. + */ + public function test_generate_to_writer_streams_entries_without_cross_merge(): void + { + Log::shouldReceive('warning')->zeroOrMoreTimes(); + + $contributorA = Mockery::mock(SitemapContributorInterface::class); + $contributorA->shouldReceive('getIdentifier')->andReturn('mod-a'); + $contributorA->shouldReceive('getUrls')->andReturn([ + ['url' => '/a/1'], + ['url' => '/a/2'], + ]); + + $contributorB = Mockery::mock(SitemapContributorInterface::class); + $contributorB->shouldReceive('getIdentifier')->andReturn('mod-b'); + $contributorB->shouldReceive('getUrls')->andReturn([ + ['url' => '/b/1'], + ]); + + $this->generator->registerContributor($contributorA); + $this->generator->registerContributor($contributorB); + + $writer = new SpySitemapWriter; + $meta = $this->generator->generateToWriter($writer); + + $this->assertTrue($writer->opened); + $this->assertTrue($writer->closed); + $this->assertTrue($writer->committed); + $this->assertSame(['committed' => true], $meta); + + $locs = array_column($writer->entries, 'loc'); + $this->assertContains(url('/a/1'), $locs); + $this->assertContains(url('/a/2'), $locs); + $this->assertContains(url('/b/1'), $locs); + + // 'url' 키는 절대 loc 으로 변환되어 남지 않아야 합니다. + foreach ($writer->entries as $entry) { + $this->assertArrayNotHasKey('url', $entry); + $this->assertArrayHasKey('loc', $entry); + } + } + + /** + * 기여자를 하나씩 소진하여 writer 로 흘려보내는지 확인합니다 (전체 병합 부재 증명). + * + * 모든 기여자의 URL 을 배열로 모은 뒤 한 번에 쓰는 구현이라면, 두 번째 기여자의 + * getUrls() 가 호출되는 시점에 writer 는 아직 비어 있다. 이 테스트는 그 구현을 걸러낸다. + */ + public function test_generate_to_writer_drains_each_contributor_before_calling_the_next(): void + { + Log::shouldReceive('warning')->zeroOrMoreTimes(); + + $writer = new SpySitemapWriter; + + $contributorA = Mockery::mock(SitemapContributorInterface::class); + $contributorA->shouldReceive('getIdentifier')->andReturn('mod-a'); + $contributorA->shouldReceive('getUrls')->andReturn([['url' => '/a/1']]); + + $locsSeenWhenBWasAsked = null; + + $contributorB = Mockery::mock(SitemapContributorInterface::class); + $contributorB->shouldReceive('getIdentifier')->andReturn('mod-b'); + $contributorB->shouldReceive('getUrls')->andReturnUsing( + function () use ($writer, &$locsSeenWhenBWasAsked): array { + $locsSeenWhenBWasAsked = array_column($writer->entries, 'loc'); + + return [['url' => '/b/1']]; + } + ); + + $this->generator->registerContributor($contributorA); + $this->generator->registerContributor($contributorB); + $this->generator->generateToWriter($writer); + + $this->assertNotNull($locsSeenWhenBWasAsked, 'B 기여자가 호출되어야 합니다.'); + $this->assertContains( + url('/a/1'), + $locsSeenWhenBWasAsked, + 'B 를 호출하기 전에 A 의 URL 이 이미 writer 로 전달되어야 합니다 (전체 병합 금지).' + ); + } + + /** + * 한 기여자가 실패해도 다른 기여자의 URL 이 계속 기록되는지 확인합니다. + */ + public function test_generate_to_writer_isolates_contributor_failure(): void + { + $failing = Mockery::mock(SitemapContributorInterface::class); + $failing->shouldReceive('getIdentifier')->andReturn('failing-module'); + $failing->shouldReceive('getUrls')->andThrow(new \RuntimeException('DB connection failed')); + + $working = Mockery::mock(SitemapContributorInterface::class); + $working->shouldReceive('getIdentifier')->andReturn('working-module'); + $working->shouldReceive('getUrls')->andReturn([['url' => '/ok']]); + + Log::shouldReceive('warning') + ->once() + ->withArgs(fn (string $message, array $context) => str_contains($message, 'Sitemap contributor failed') + && $context['contributor'] === 'failing-module'); + Log::shouldReceive('warning')->zeroOrMoreTimes(); + + $this->generator->registerContributor($failing); + $this->generator->registerContributor($working); + + $writer = new SpySitemapWriter; + $this->generator->generateToWriter($writer); + + $this->assertContains(url('/ok'), array_column($writer->entries, 'loc')); + } + // ─── 헬퍼 메서드 ────────────────────────────────────── /** @@ -542,3 +716,76 @@ class SitemapGeneratorTest extends TestCase return $urls; } } + +/** + * SitemapWriter 호출을 기록하는 테스트 더블. + * + * generateToWriter 가 URL 을 한 건씩 스트리밍하는지(전체 배열 병합 없음)와 + * open/close/commit 생명주기를 검증하기 위해 사용합니다. + * 스토리지 없이 동작하도록 부모 생성자를 호출하지 않습니다. + */ +class SpySitemapWriter extends SitemapWriter +{ + /** + * open() 호출 여부 + */ + public bool $opened = false; + + /** + * close() 호출 여부 + */ + public bool $closed = false; + + /** + * commit() 호출 여부 + */ + public bool $committed = false; + + /** + * addUrl() 로 전달된 URL 항목 목록 + * + * @var array> + */ + public array $entries = []; + + /** + * SpySitemapWriter 생성자 (의존성 없이 생성). + */ + public function __construct() {} + + /** + * {@inheritDoc} + */ + public function open(): void + { + $this->opened = true; + } + + /** + * {@inheritDoc} + */ + public function addUrl(array $entry): void + { + $this->entries[] = $entry; + } + + /** + * {@inheritDoc} + */ + public function close(): array + { + $this->closed = true; + + return []; + } + + /** + * {@inheritDoc} + */ + public function commit(): array + { + $this->committed = true; + + return ['committed' => true]; + } +} diff --git a/tests/Unit/Seo/SitemapManagerTest.php b/tests/Unit/Seo/SitemapManagerTest.php new file mode 100644 index 00000000..952e6168 --- /dev/null +++ b/tests/Unit/Seo/SitemapManagerTest.php @@ -0,0 +1,384 @@ +storage = new CoreStorageDriver('local'); + + $this->clearSitemapHooks(); + } + + /** + * 테스트 정리 - 등록한 훅 리스너가 다른 테스트로 누출되지 않도록 제거합니다. + */ + protected function tearDown(): void + { + $this->clearSitemapHooks(); + + parent::tearDown(); + } + + /** + * sitemap 훅 리스너를 모두 제거합니다. + */ + private function clearSitemapHooks(): void + { + foreach (self::SITEMAP_HOOKS as $hook) { + HookManager::clearAction($hook); + } + } + + /** + * 지정한 URL 을 내보내는 가짜 contributor 를 만듭니다. + * + * @param array $urls 상대 경로 목록 + * @return SitemapContributorInterface 가짜 기여자 + */ + private function fakeContributor(array $urls): SitemapContributorInterface + { + return new class($urls) implements SitemapContributorInterface + { + /** + * @param array $urls 상대 경로 목록 + */ + public function __construct(private array $urls) {} + + public function getIdentifier(): string + { + return 'fake-contributor'; + } + + public function getUrls(): array + { + return array_map(fn (string $url): array => ['url' => $url], $this->urls); + } + }; + } + + /** + * 테스트용 SitemapManager 를 만듭니다. + * + * @param array $urls 기여자가 내보낼 URL 목록 + * @param CacheInterface|null $cache 캐시 (미지정 시 기대 없는 spy) + * @param ConfigRepositoryInterface|null $config 설정 Repository + * @return SitemapManager manager 인스턴스 + */ + private function makeManager( + array $urls = ['/a'], + ?CacheInterface $cache = null, + ?ConfigRepositoryInterface $config = null, + ): SitemapManager { + // generator 는 라우트 해석기/템플릿 서비스에 의존하므로 컨테이너로 해석합니다. + $generator = $this->app->make(SitemapGenerator::class); + $generator->registerContributor($this->fakeContributor($urls)); + + return new SitemapManager( + $generator, + $cache ?? Mockery::spy(CacheInterface::class), + $config ?? Mockery::spy(ConfigRepositoryInterface::class), + $this->storage, + ); + } + + /** + * regenerate: sitemap 이 비활성화되면 생성하지 않고 disabled 를 반환한다 + */ + public function test_regenerate_returns_disabled_when_sitemap_is_disabled(): void + { + Config::set('g7_settings.core.seo.sitemap_enabled', false); + + $fired = []; + HookManager::addAction('core.seo.sitemap.before_regenerate', function () use (&$fired) { + $fired[] = 'before'; + }); + + $result = $this->makeManager()->regenerate(); + + $this->assertFalse($result['success']); + $this->assertSame('disabled', $result['status']); + $this->assertArrayNotHasKey('data', $result); + $this->assertSame([], $fired, '비활성화 시 훅이 발화하면 안 됩니다.'); + $this->assertFalse( + $this->storage->exists(SitemapFileStore::CATEGORY, SitemapFileStore::LIVE_DIR.'/'.SitemapFileStore::MANIFEST_FILE), + '비활성화 시 파일을 커밋하면 안 됩니다.' + ); + } + + /** + * regenerate: 성공 시 status=updated 와 메타 5키를 반환한다 + */ + public function test_regenerate_returns_updated_with_meta_payload(): void + { + $result = $this->makeManager(['/a', '/b'])->regenerate(); + + $this->assertTrue($result['success']); + $this->assertSame('updated', $result['status']); + $this->assertSame( + ['last_updated_at', 'size_bytes', 'url_count', 'child_count', 'ttl'], + array_keys($result['data']), + 'S2 Controller 가 소비하는 메타 키 집합이 고정되어야 합니다.' + ); + $this->assertSame(1, $result['data']['child_count']); + $this->assertGreaterThan(0, $result['data']['size_bytes']); + } + + /** + * regenerate: 성공 시 sitemap 세트가 디스크에 커밋된다 + */ + public function test_regenerate_commits_sitemap_set_to_disk(): void + { + $this->makeManager(['/a'])->regenerate(); + + $store = new SitemapFileStore($this->storage); + + $this->assertTrue($store->exists()); + $this->assertNotNull($store->indexResponse()); + $this->assertNotNull($store->childResponse(1)); + } + + /** + * regenerate: 구 XML 본문 캐시를 정리하고 메타만 캐시에 저장한다 + */ + public function test_regenerate_forgets_legacy_xml_cache_and_puts_meta(): void + { + Config::set('g7_settings.core.cache.seo_sitemap_ttl', 3600); + Config::set('g7_settings.core.seo.sitemap_cache_ttl', null); + + $cache = Mockery::mock(CacheInterface::class); + $cache->shouldReceive('forget')->once()->with('seo.sitemap'); + $cache->shouldReceive('put') + ->once() + ->with(SitemapManager::META_CACHE_KEY, Mockery::type('array'), 3600); + + $this->makeManager(['/a'], $cache)->regenerate(); + } + + /** + * regenerate: SEO 탭 지정이 없으면 고급 탭(cache.seo_sitemap_ttl) 값을 ttl 로 쓴다 (D19) + */ + public function test_regenerate_uses_advanced_tab_ttl_when_seo_override_is_unset(): void + { + Config::set('g7_settings.core.cache.seo_sitemap_ttl', 7200); + Config::set('g7_settings.core.seo.sitemap_cache_ttl', null); + + $result = $this->makeManager()->regenerate(); + + $this->assertSame(7200, $result['data']['ttl']); + } + + /** + * regenerate: SEO 탭에 지정이 있으면 그 값이 고급 탭을 오버라이드한다 (D19) + */ + public function test_regenerate_uses_seo_override_ttl_when_set(): void + { + Config::set('g7_settings.core.cache.seo_sitemap_ttl', 7200); + Config::set('g7_settings.core.seo.sitemap_cache_ttl', 3600); + + $result = $this->makeManager()->regenerate(); + + $this->assertSame(3600, $result['data']['ttl'], 'SEO 탭 지정이 고급 탭보다 우선해야 합니다.'); + } + + /** + * regenerate: last_updated_at 을 seo 설정 카테고리에 기록한다 + */ + public function test_regenerate_records_last_updated_at_in_settings(): void + { + $config = Mockery::mock(ConfigRepositoryInterface::class); + $config->shouldReceive('getCategory')->once()->with('seo')->andReturn(['existing' => 'keep']); + $config->shouldReceive('saveCategory') + ->once() + ->with('seo', Mockery::on(function (array $saved): bool { + // 기존 키를 보존하면서 타임스탬프만 추가해야 한다 + return $saved['existing'] === 'keep' + && ! empty($saved['sitemap_last_updated_at']); + })); + + $result = $this->makeManager(['/a'], null, $config)->regenerate(); + + $this->assertTrue($result['success']); + } + + /** + * regenerate: 설정 기록이 실패해도 생성 자체는 성공으로 유지된다 + */ + public function test_regenerate_survives_settings_write_failure(): void + { + $config = Mockery::mock(ConfigRepositoryInterface::class); + $config->shouldReceive('getCategory')->andThrow(new \RuntimeException('설정 저장소 장애')); + + $result = $this->makeManager(['/a'], null, $config)->regenerate(); + + $this->assertTrue($result['success'], 'last_updated_at 기록 실패가 sitemap 생성을 무효화하면 안 됩니다.'); + $this->assertSame('updated', $result['status']); + } + + /** + * regenerate: 성공 시 before/after_regenerate 훅이 순서대로 발화한다 + */ + public function test_regenerate_fires_before_and_after_hooks_in_order(): void + { + $order = []; + HookManager::addAction('core.seo.sitemap.before_regenerate', function () use (&$order) { + $order[] = 'before'; + }); + HookManager::addAction('core.seo.sitemap.after_regenerate', function () use (&$order) { + $order[] = 'after'; + }); + + $this->makeManager()->regenerate(); + + $this->assertSame(['before', 'after'], $order); + } + + /** + * regenerate: 실패 시 failed 를 반환하고 after_regenerate_failed 훅이 발화한다 + * + * 이 훅은 확장이 구독하는 공개 표면이므로 발화 여부와 페이로드 형태를 고정한다. + */ + public function test_regenerate_fires_after_regenerate_failed_hook_on_failure(): void + { + $payloads = []; + HookManager::addAction('core.seo.sitemap.after_regenerate_failed', function ($result) use (&$payloads) { + $payloads[] = $result; + }); + HookManager::addAction('core.seo.sitemap.after_regenerate', function () { + throw new \LogicException('성공 훅이 발화하면 안 됩니다.'); + }); + + // 생성 도중 예외를 던지는 generator 로 실패를 강제한다. + $generator = Mockery::mock(SitemapGenerator::class); + $generator->shouldReceive('generateToWriter')->andThrow(new \RuntimeException('디스크 장애')); + + $manager = new SitemapManager( + $generator, + Mockery::spy(CacheInterface::class), + Mockery::spy(ConfigRepositoryInterface::class), + $this->storage, + ); + + $result = $manager->regenerate(); + + $this->assertFalse($result['success']); + $this->assertSame('failed', $result['status']); + $this->assertStringContainsString('디스크 장애', $result['message']); + + $this->assertCount(1, $payloads, 'after_regenerate_failed 훅이 정확히 1회 발화해야 합니다.'); + $this->assertSame('failed', $payloads[0]['status']); + $this->assertFalse($payloads[0]['success']); + } + + /** + * regenerate: 실패 시 캐시를 갱신하지 않는다 (구 세트 유지) + */ + public function test_regenerate_does_not_touch_cache_on_failure(): void + { + $cache = Mockery::mock(CacheInterface::class); + $cache->shouldNotReceive('put'); + $cache->shouldNotReceive('forget'); + + $generator = Mockery::mock(SitemapGenerator::class); + $generator->shouldReceive('generateToWriter')->andThrow(new \RuntimeException('장애')); + + $manager = new SitemapManager( + $generator, + $cache, + Mockery::spy(ConfigRepositoryInterface::class), + $this->storage, + ); + + $this->assertFalse($manager->regenerate()['success']); + } + + /** + * regenerate: seo.sitemap_urls_per_file 설정이 자식 분할에 반영된다 + */ + public function test_regenerate_applies_urls_per_file_setting(): void + { + Config::set('g7_settings.core.seo.sitemap_urls_per_file', 2); + + // 정적 라우트 + 기여자 4건 → 파일당 2건이면 자식이 2개 이상으로 나뉜다 + $result = $this->makeManager(['/a', '/b', '/c', '/d'])->regenerate(); + + $this->assertGreaterThan(1, $result['data']['child_count'], 'urls_per_file 설정이 분할에 반영돼야 합니다.'); + } + + /** + * regenerate: seo.sitemap_gzip 설정이 커밋 파일명에 반영된다 + */ + public function test_regenerate_applies_gzip_setting(): void + { + Config::set('g7_settings.core.seo.sitemap_gzip', true); + + $this->makeManager(['/a'])->regenerate(); + + $manifest = (new SitemapFileStore($this->storage))->getManifest(); + + $this->assertStringEndsWith('.xml.gz', $manifest['children'][0]['file']); + } + + /** + * getStatus: 기록된 last_updated_at 을 반환하고, 미기록 시 null 을 반환한다 + */ + public function test_get_status_returns_last_updated_at_or_null(): void + { + $manager = $this->makeManager(); + + Config::set('g7_settings.core.seo.sitemap_last_updated_at', ''); + $this->assertNull($manager->getStatus()['last_updated_at']); + + Config::set('g7_settings.core.seo.sitemap_last_updated_at', '2026-07-16T00:00:00+09:00'); + $this->assertSame('2026-07-16T00:00:00+09:00', $manager->getStatus()['last_updated_at']); + } +} diff --git a/tests/Unit/Seo/SitemapWriterTest.php b/tests/Unit/Seo/SitemapWriterTest.php new file mode 100644 index 00000000..1bbe3f02 --- /dev/null +++ b/tests/Unit/Seo/SitemapWriterTest.php @@ -0,0 +1,639 @@ +storage = new RecordingStorage(new CoreStorageDriver('local')); + } + + /** + * 테스트용 writer 를 생성합니다. + * + * @param int $urlsPerFile 파일당 URL 수 + * @param bool $gzip gzip 압축 여부 + * @return SitemapWriter writer 인스턴스 + */ + private function makeWriter(int $urlsPerFile = 10, bool $gzip = false): SitemapWriter + { + return new SitemapWriter( + $this->storage, + SitemapXmlRenderer::fromConfig(), + $urlsPerFile, + $gzip, + ); + } + + /** + * 커밋된 live 파일 내용을 읽습니다. + * + * @param string $filename 파일명 + * @return string|null 파일 내용 + */ + private function liveContent(string $filename): ?string + { + return $this->storage->get(SitemapFileStore::CATEGORY, SitemapFileStore::LIVE_DIR.'/'.$filename); + } + + // ─── 분할 ──────────────────────────────────────────── + + /** + * URL 수가 임계를 넘으면 자식 파일로 분할되는지 확인합니다. + */ + public function test_splits_children_at_threshold(): void + { + $writer = $this->makeWriter(urlsPerFile: 10); + $writer->open(); + + // 25건 / 파일당 10건 → 자식 3개 (10, 10, 5) + for ($i = 1; $i <= 25; $i++) { + $writer->addUrl(['loc' => url("/p/{$i}"), 'priority' => 0.5]); + } + + $writer->close(); + $meta = $writer->commit(); + + $this->assertSame(3, $meta['child_count']); + $this->assertSame(25, $meta['url_count']); + $this->assertSame(10, $meta['children'][0]['url_count']); + $this->assertSame(10, $meta['children'][1]['url_count']); + $this->assertSame(5, $meta['children'][2]['url_count']); + } + + /** + * 임계에 정확히 맞아떨어지면 빈 자식 파일이 생기지 않는지 확인합니다. + */ + public function test_exact_threshold_does_not_create_empty_child(): void + { + $writer = $this->makeWriter(urlsPerFile: 10); + $writer->open(); + + for ($i = 1; $i <= 20; $i++) { + $writer->addUrl(['loc' => url("/p/{$i}")]); + } + + $writer->close(); + $meta = $writer->commit(); + + $this->assertSame(2, $meta['child_count']); + } + + /** + * 각 자식 파일이 well-formed urlset XML 인지 확인합니다. + */ + public function test_children_are_well_formed_urlsets(): void + { + $writer = $this->makeWriter(urlsPerFile: 10); + $writer->open(); + + for ($i = 1; $i <= 25; $i++) { + $writer->addUrl(['loc' => url("/p/{$i}"), 'changefreq' => 'daily']); + } + + $writer->close(); + $meta = $writer->commit(); + + foreach ($meta['children'] as $child) { + $xml = $this->liveContent($child['file']); + + $this->assertNotNull($xml); + $this->assertStringStartsWith('', $xml); + $this->assertStringEndsWith('', $xml); + $this->assertLessThanOrEqual(SitemapWriter::HARD_URL_CAP, substr_count($xml, '')); + $this->assertNotFalse(simplexml_load_string($xml), '자식 sitemap 이 유효한 XML 이어야 합니다.'); + } + } + + /** + * sitemapindex 가 모든 자식을 절대 URL 로 참조하는지 확인합니다. + */ + public function test_index_references_all_children(): void + { + $writer = $this->makeWriter(urlsPerFile: 10); + $writer->open(); + + for ($i = 1; $i <= 25; $i++) { + $writer->addUrl(['loc' => url("/p/{$i}")]); + } + + $writer->close(); + $writer->commit(); + + $index = $this->liveContent(SitemapFileStore::INDEX_FILE); + + $this->assertNotNull($index); + $this->assertStringContainsString('assertSame(3, substr_count($index, '')); + $this->assertStringContainsString(''.url('/sitemap-1.xml').'', $index); + $this->assertStringContainsString(''.url('/sitemap-3.xml').'', $index); + $this->assertNotFalse(simplexml_load_string($index)); + } + + /** + * URL 이 하나도 없어도 빈 urlset 자식 1개와 인덱스를 남기는지 확인합니다. + */ + public function test_empty_set_still_emits_index_and_one_child(): void + { + $writer = $this->makeWriter(); + $writer->open(); + $writer->close(); + $meta = $writer->commit(); + + $this->assertSame(1, $meta['child_count']); + $this->assertSame(0, $meta['url_count']); + + $xml = $this->liveContent('sitemap-1.xml'); + $this->assertStringNotContainsString('', $xml); + $this->assertNotFalse(simplexml_load_string($xml)); + } + + /** + * loc 이 없는 항목은 무시되는지 확인합니다. + */ + public function test_entry_without_loc_is_skipped(): void + { + $writer = $this->makeWriter(); + $writer->open(); + $writer->addUrl(['changefreq' => 'daily']); + $writer->close(); + $meta = $writer->commit(); + + $this->assertSame(0, $meta['url_count']); + } + + // ─── 다국어 ────────────────────────────────────────── + + /** + * 다국어에서는 URL 하나가 로케일 수만큼의 블록으로 계산되는지 확인합니다. + */ + public function test_multilingual_counts_url_blocks_per_locale(): void + { + Config::set('app.supported_locales', ['ko', 'en']); + + $writer = $this->makeWriter(urlsPerFile: 10); + $writer->open(); + + // 6건 × 2로케일 = 12블록 → 자식 2개 + for ($i = 1; $i <= 6; $i++) { + $writer->addUrl(['loc' => url("/p/{$i}")]); + } + + $writer->close(); + $meta = $writer->commit(); + + $this->assertSame(12, $meta['url_count']); + $this->assertSame(2, $meta['child_count']); + } + + // ─── gzip ──────────────────────────────────────────── + + /** + * gzip 활성 시 .xml.gz 로 압축 저장되고 인덱스 loc 도 .gz 를 가리키는지 확인합니다. + */ + public function test_gzip_writes_compressed_children(): void + { + $writer = $this->makeWriter(urlsPerFile: 10, gzip: true); + $writer->open(); + + for ($i = 1; $i <= 5; $i++) { + $writer->addUrl(['loc' => url("/p/{$i}")]); + } + + $writer->close(); + $meta = $writer->commit(); + + $this->assertTrue($meta['gzip']); + $this->assertSame('sitemap-1.xml.gz', $meta['children'][0]['file']); + + $raw = $this->liveContent('sitemap-1.xml.gz'); + $decoded = gzdecode($raw); + + $this->assertNotFalse($decoded, 'gzip 자식 파일이 해제 가능해야 합니다.'); + $this->assertStringEndsWith('', $decoded); + + // 인덱스는 비압축이며 .gz 자식을 가리킵니다. + $index = $this->liveContent(SitemapFileStore::INDEX_FILE); + $this->assertStringContainsString(''.url('/sitemap-1.xml.gz').'', $index); + } + + /** + * gzip 비활성이 기본값인지 확인합니다. + */ + public function test_gzip_disabled_by_default(): void + { + $writer = $this->makeWriter(urlsPerFile: 10); + $writer->open(); + $writer->addUrl(['loc' => url('/p/1')]); + $writer->close(); + $meta = $writer->commit(); + + $this->assertFalse($meta['gzip']); + $this->assertSame('sitemap-1.xml', $meta['children'][0]['file']); + } + + // ─── 커밋 ──────────────────────────────────────────── + + /** + * manifest.json 이 커밋의 마지막 기록인지 확인합니다 (커밋 마커). + */ + public function test_manifest_is_written_last(): void + { + $writer = $this->makeWriter(urlsPerFile: 10); + $writer->open(); + + for ($i = 1; $i <= 25; $i++) { + $writer->addUrl(['loc' => url("/p/{$i}")]); + } + + $writer->close(); + $writer->commit(); + + $manifestPath = SitemapFileStore::LIVE_DIR.'/'.SitemapFileStore::MANIFEST_FILE; + + $this->assertSame($manifestPath, end($this->storage->putOrder)); + + // live 자식/인덱스가 모두 manifest 보다 먼저 기록되었는지 확인 + $manifestIndex = array_search($manifestPath, $this->storage->putOrder, true); + foreach (['sitemap-1.xml', 'sitemap-2.xml', 'sitemap-3.xml', SitemapFileStore::INDEX_FILE] as $file) { + $position = array_search(SitemapFileStore::LIVE_DIR.'/'.$file, $this->storage->putOrder, true); + + $this->assertNotFalse($position, "{$file} 이 live 에 기록되어야 합니다."); + $this->assertLessThan($manifestIndex, $position); + } + } + + /** + * 커밋 후 임시 디렉토리가 정리되는지 확인합니다. + */ + public function test_commit_cleans_tmp_directory(): void + { + $writer = $this->makeWriter(urlsPerFile: 10); + $writer->open(); + $writer->addUrl(['loc' => url('/p/1')]); + $writer->close(); + $writer->commit(); + + $this->assertSame([], $this->storage->files(SitemapFileStore::CATEGORY, SitemapFileStore::TMP_DIR)); + } + + /** + * 이전 생성물 중 새 세트에 없는 자식 파일이 정리되는지 확인합니다. + */ + public function test_commit_removes_stale_children(): void + { + // 1차: 자식 3개 + $writer = $this->makeWriter(urlsPerFile: 10); + $writer->open(); + for ($i = 1; $i <= 25; $i++) { + $writer->addUrl(['loc' => url("/p/{$i}")]); + } + $writer->close(); + $writer->commit(); + + $this->assertNotNull($this->liveContent('sitemap-3.xml')); + + // 2차: 자식 1개로 축소 → sitemap-2/3 은 stale + $writer = $this->makeWriter(urlsPerFile: 10); + $writer->open(); + $writer->addUrl(['loc' => url('/p/1')]); + $writer->close(); + $meta = $writer->commit(); + + $this->assertSame(1, $meta['child_count']); + $this->assertNotNull($this->liveContent('sitemap-1.xml')); + $this->assertNull($this->liveContent('sitemap-2.xml')); + $this->assertNull($this->liveContent('sitemap-3.xml')); + } + + /** + * open() 이 이전 실행의 임시 파일을 정리하는지 확인합니다. + */ + public function test_open_clears_previous_tmp_files(): void + { + $this->storage->put(SitemapFileStore::CATEGORY, SitemapFileStore::TMP_DIR.'/sitemap-9.xml', 'stale'); + + $writer = $this->makeWriter(); + $writer->open(); + + $this->assertNull($this->storage->get(SitemapFileStore::CATEGORY, SitemapFileStore::TMP_DIR.'/sitemap-9.xml')); + } + + /** + * manifest 에 세트 메타데이터가 기록되는지 확인합니다. + */ + public function test_manifest_contains_set_metadata(): void + { + $writer = $this->makeWriter(urlsPerFile: 10); + $writer->open(); + for ($i = 1; $i <= 15; $i++) { + $writer->addUrl(['loc' => url("/p/{$i}")]); + } + $writer->close(); + $writer->commit(); + + $manifest = json_decode($this->liveContent(SitemapFileStore::MANIFEST_FILE), true); + + $this->assertSame(2, $manifest['child_count']); + $this->assertSame(15, $manifest['url_count']); + $this->assertSame(10, $manifest['urls_per_file']); + $this->assertNotEmpty($manifest['generated_at']); + $this->assertCount(2, $manifest['children']); + } + + // ─── 상한 / 메모리 ──────────────────────────────────── + + /** + * urlsPerFile 이 프로토콜 상한(50,000)을 넘지 못하도록 클램프되는지 확인합니다. + */ + public function test_urls_per_file_is_clamped_to_protocol_cap(): void + { + $writer = $this->makeWriter(urlsPerFile: 999999); + $writer->open(); + $writer->addUrl(['loc' => url('/p/1')]); + $writer->close(); + $writer->commit(); + + $manifest = json_decode($this->liveContent(SitemapFileStore::MANIFEST_FILE), true); + + $this->assertSame(SitemapWriter::HARD_URL_CAP, $manifest['urls_per_file']); + } + + /** + * 대용량(120,001건) 생성 시 자식 수가 정확하고 메모리가 유계인지 확인합니다. + * + * 전체 in-memory 적재(회귀)라면 URL 수에 비례해 메모리가 증가합니다. + */ + public function test_large_volume_splits_and_keeps_memory_bounded(): void + { + $writer = $this->makeWriter(urlsPerFile: 50000); + + // peak 는 프로세스 전체의 high-water mark 라, 선행 테스트가 올려둔 peak 때문에 + // 델타가 0 으로 나와 거짓 통과할 수 있다. 이 테스트 구간만 측정하도록 리셋한다. + memory_reset_peak_usage(); + $before = memory_get_peak_usage(true); + + $writer->open(); + for ($i = 1; $i <= 120001; $i++) { + $writer->addUrl(['loc' => url("/p/{$i}"), 'priority' => 0.5]); + } + $writer->close(); + $meta = $writer->commit(); + + $growth = memory_get_peak_usage(true) - $before; + + // 120001 = 50000 + 50000 + 20001 + $this->assertSame(3, $meta['child_count']); + $this->assertSame(120001, $meta['url_count']); + $this->assertSame(50000, $meta['children'][0]['url_count']); + $this->assertSame(50000, $meta['children'][1]['url_count']); + $this->assertSame(20001, $meta['children'][2]['url_count']); + + // 자식 파일 1개 버퍼(수 MB) 수준을 넘지 않아야 합니다. + // 실측 약 10MB (자식 파일 1개 버퍼 수준). 전체 적재 회귀 시 수십~수백 MB 로 튄다. + $this->assertLessThan( + 64 * 1024 * 1024, + $growth, + '메모리 증가가 자식 파일 1개 버퍼 수준을 넘었습니다 (전체 적재 회귀 의심): '.$growth, + ); + } + + /** + * URL 수가 상한에 못 미쳐도 바이트 임계에 도달하면 자식이 분할됩니다. + * + * sitemaps.org 는 파일당 50MB 를 제한하므로 URL 수 상한만으로는 프로토콜 위반을 막지 못한다 + * (긴 URL 5만 건이면 50MB 초과). MAX_FILE_BYTES 분기가 그 방어인데, 45MB 를 실제로 채우면 + * 테스트가 과도하게 무거우므로 하위 클래스로 임계값만 낮춰 같은 경로를 검증한다. + */ + public function test_byte_threshold_splits_child_even_below_url_cap(): void + { + $writer = new SmallByteCapSitemapWriter( + $this->storage, + SitemapXmlRenderer::fromConfig(), + 10000, // URL 수 상한 — 이번 케이스에서는 절대 도달하지 않는다 + false, + ); + + $writer->open(); + // URL 블록 1개가 임계(2KB)를 넘도록 긴 loc 를 쓴다 → 매 건마다 flush + for ($i = 1; $i <= 3; $i++) { + $writer->addUrl(['loc' => url('/p/'.str_repeat("long-segment-{$i}-", 200))]); + } + $writer->close(); + $meta = $writer->commit(); + + $this->assertSame(3, $meta['url_count']); + $this->assertSame( + 3, + $meta['child_count'], + 'URL 수 상한(10000) 미달이어도 바이트 임계 도달 시 자식이 분할돼야 합니다.' + ); + } + + /** + * 바이트 임계로 분할된 자식들도 well-formed urlset 이며 전량 보존됩니다. + */ + public function test_byte_threshold_split_children_are_well_formed_and_lossless(): void + { + $writer = new SmallByteCapSitemapWriter( + $this->storage, + SitemapXmlRenderer::fromConfig(), + 10000, + false, + ); + + $writer->open(); + $locs = []; + for ($i = 1; $i <= 3; $i++) { + $loc = url('/p/'.str_repeat("seg-{$i}-", 200)); + $locs[] = $loc; + $writer->addUrl(['loc' => $loc]); + } + $writer->close(); + $meta = $writer->commit(); + + $combined = ''; + foreach ($meta['children'] as $child) { + $xml = $this->liveContent($child['file']); + + $this->assertStringStartsWith('', $xml); + $this->assertStringContainsString('', $xml); + $combined .= $xml; + } + + // 분할 과정에서 URL 이 유실되면 안 된다 + foreach ($locs as $loc) { + $this->assertStringContainsString($loc, $combined); + } + } +} + +/** + * 바이트 임계를 2KB 로 낮춘 writer (테스트 전용). + * + * MAX_FILE_BYTES 분기는 static:: 바인딩이므로 상수 재정의만으로 임계값을 바꿀 수 있다. + */ +class SmallByteCapSitemapWriter extends SitemapWriter +{ + public const MAX_FILE_BYTES = 2048; +} + +/** + * put() 호출 경로를 순서대로 기록하는 StorageInterface 데코레이터 (테스트 전용). + * + * manifest.json 이 커밋의 마지막 기록인지 검증하기 위해 사용합니다. + */ +class RecordingStorage implements StorageInterface +{ + /** + * put 이 호출된 경로 목록 (호출 순서) + * + * @var array + */ + public array $putOrder = []; + + /** + * RecordingStorage 생성자 + * + * @param StorageInterface $inner 위임 대상 스토리지 + */ + public function __construct(private StorageInterface $inner) {} + + /** + * {@inheritDoc} + */ + public function put(string $category, string $path, mixed $content): bool + { + $this->putOrder[] = $path; + + return $this->inner->put($category, $path, $content); + } + + /** + * {@inheritDoc} + */ + public function get(string $category, string $path): ?string + { + return $this->inner->get($category, $path); + } + + /** + * {@inheritDoc} + */ + public function exists(string $category, string $path): bool + { + return $this->inner->exists($category, $path); + } + + /** + * {@inheritDoc} + */ + public function delete(string $category, string $path): bool + { + return $this->inner->delete($category, $path); + } + + /** + * {@inheritDoc} + */ + public function url(string $category, string $path): ?string + { + return $this->inner->url($category, $path); + } + + /** + * {@inheritDoc} + */ + public function files(string $category, string $directory = ''): array + { + return $this->inner->files($category, $directory); + } + + /** + * {@inheritDoc} + */ + public function deleteDirectory(string $category, string $directory = ''): bool + { + return $this->inner->deleteDirectory($category, $directory); + } + + /** + * {@inheritDoc} + */ + public function getBasePath(string $category): string + { + return $this->inner->getBasePath($category); + } + + /** + * {@inheritDoc} + */ + public function getDisk(): string + { + return $this->inner->getDisk(); + } + + /** + * {@inheritDoc} + */ + public function deleteAll(string $category): bool + { + return $this->inner->deleteAll($category); + } + + /** + * {@inheritDoc} + */ + public function response(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse + { + return $this->inner->response($category, $path, $filename, $headers); + } + + /** + * {@inheritDoc} + */ + public function withDisk(string $disk): static + { + $clone = clone $this; + $clone->inner = $this->inner->withDisk($disk); + + return $clone; + } + + /** + * {@inheritDoc} + */ + public function download(string $category, string $path, string $filename, array $headers = []): ?StreamedResponse + { + return $this->inner->download($category, $path, $filename, $headers); + } +} diff --git a/tests/Unit/Seo/SitemapXmlRendererTest.php b/tests/Unit/Seo/SitemapXmlRendererTest.php new file mode 100644 index 00000000..0c613c3c --- /dev/null +++ b/tests/Unit/Seo/SitemapXmlRendererTest.php @@ -0,0 +1,337 @@ + 블록 수) + * - hreflang alternate 구조 (현재: 로케일 수 × (로케일 수 + x-default)) + * - 로케일 URL 규칙 (기본 로케일은 원본, 그 외 ?locale=) + * - sitemapindex 구조 + */ +class SitemapXmlRendererTest extends TestCase +{ + /** + * 단일 언어 렌더러를 만듭니다. + * + * @return SitemapXmlRenderer 렌더러 + */ + private function singleLocale(): SitemapXmlRenderer + { + return new SitemapXmlRenderer(['ko'], 'ko'); + } + + /** + * 다국어 렌더러를 만듭니다. + * + * @param array $locales 지원 로케일 + * @return SitemapXmlRenderer 렌더러 + */ + private function multiLocale(array $locales = ['ko', 'en', 'ja']): SitemapXmlRenderer + { + return new SitemapXmlRenderer($locales, 'ko'); + } + + /** + * fromConfig: 앱 로케일 설정을 반영한다 + */ + public function test_from_config_reflects_app_locale_settings(): void + { + Config::set('app.locale', 'ko'); + Config::set('app.supported_locales', ['ko', 'en']); + + $renderer = SitemapXmlRenderer::fromConfig(); + + $this->assertTrue($renderer->isMultilingual()); + $this->assertSame(2, $renderer->urlBlockCount()); + } + + /** + * fromConfig: supported_locales 미설정 시 기본 로케일 단독으로 동작한다 + */ + public function test_from_config_falls_back_to_default_locale_only(): void + { + Config::set('app.locale', 'ko'); + Config::set('app.supported_locales', null); + + $renderer = SitemapXmlRenderer::fromConfig(); + + $this->assertFalse($renderer->isMultilingual()); + $this->assertSame(1, $renderer->urlBlockCount()); + } + + /** + * isMultilingual: 로케일이 1개면 단일 언어다 + */ + public function test_is_multilingual_is_false_for_single_locale(): void + { + $this->assertFalse($this->singleLocale()->isMultilingual()); + $this->assertTrue($this->multiLocale(['ko', 'en'])->isMultilingual()); + } + + /** + * urlBlockCount: 다국어면 로케일 수, 단일 언어면 1 + * + * 이 값이 틀리면 writer 의 파일당 URL 수 임계 계산이 어긋나 프로토콜을 위반한다. + */ + public function test_url_block_count_matches_locale_count(): void + { + $this->assertSame(1, $this->singleLocale()->urlBlockCount()); + $this->assertSame(3, $this->multiLocale()->urlBlockCount()); + } + + /** + * urlsetHeader: 단일 언어는 xhtml 네임스페이스를 선언하지 않는다 + */ + public function test_urlset_header_omits_xhtml_namespace_for_single_locale(): void + { + $header = $this->singleLocale()->urlsetHeader(); + + $this->assertStringStartsWith('', $header); + $this->assertStringContainsString('xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"', $header); + $this->assertStringNotContainsString('xmlns:xhtml', $header); + } + + /** + * urlsetHeader: 다국어는 xhtml 네임스페이스를 선언한다 (hreflang 사용) + */ + public function test_urlset_header_declares_xhtml_namespace_for_multilingual(): void + { + $this->assertStringContainsString( + 'xmlns:xhtml="http://www.w3.org/1999/xhtml"', + $this->multiLocale()->urlsetHeader() + ); + } + + /** + * urlsetFooter: urlset 을 닫는다 + */ + public function test_urlset_footer_closes_urlset(): void + { + $this->assertSame('', $this->singleLocale()->urlsetFooter()); + } + + /** + * urlBlock: loc 이 없으면 빈 문자열을 반환한다 (writer 가 이것으로 skip 판정) + */ + public function test_url_block_returns_empty_string_without_loc(): void + { + $this->assertSame('', $this->singleLocale()->urlBlock([])); + $this->assertSame('', $this->singleLocale()->urlBlock(['loc' => ''])); + $this->assertSame('', $this->singleLocale()->urlBlock(['changefreq' => 'daily'])); + } + + /** + * urlBlock: 단일 언어는 블록 1개를 만들고 hreflang 을 붙이지 않는다 + */ + public function test_url_block_renders_single_url_without_hreflang(): void + { + $xml = $this->singleLocale()->urlBlock(['loc' => 'https://example.test/a']); + + $this->assertSame(1, substr_count($xml, '')); + $this->assertStringContainsString('https://example.test/a', $xml); + $this->assertStringNotContainsString('xhtml:link', $xml); + } + + /** + * urlBlock: 메타 필드를 렌더링하며 priority 는 소수 1자리로 고정한다 + */ + public function test_url_block_renders_meta_fields(): void + { + $xml = $this->singleLocale()->urlBlock([ + 'loc' => 'https://example.test/a', + 'lastmod' => '2026-07-17T00:00:00+09:00', + 'changefreq' => 'daily', + 'priority' => 0.8, + ]); + + $this->assertStringContainsString('2026-07-17T00:00:00+09:00', $xml); + $this->assertStringContainsString('daily', $xml); + $this->assertStringContainsString('0.8', $xml); + } + + /** + * urlBlock: priority 는 정수로 줘도 소수 1자리로 정규화된다 + */ + public function test_url_block_normalizes_integer_priority(): void + { + $xml = $this->singleLocale()->urlBlock(['loc' => 'https://example.test/a', 'priority' => 1]); + + $this->assertStringContainsString('1.0', $xml); + } + + /** + * urlBlock: 메타 필드가 없으면 해당 태그를 생략한다 + */ + public function test_url_block_omits_absent_meta_fields(): void + { + $xml = $this->singleLocale()->urlBlock(['loc' => 'https://example.test/a']); + + $this->assertStringNotContainsString('', $xml); + $this->assertStringNotContainsString('', $xml); + $this->assertStringNotContainsString('', $xml); + } + + /** + * urlBlock: 다국어는 로케일 수만큼 블록을 만든다 + */ + public function test_url_block_renders_one_url_per_locale(): void + { + $xml = $this->multiLocale()->urlBlock(['loc' => 'https://example.test/a']); + + $this->assertSame(3, substr_count($xml, '')); + } + + /** + * urlBlock: 기본 로케일은 원본 URL, 그 외는 ?locale= 를 붙인다 + */ + public function test_url_block_localizes_loc_per_locale(): void + { + $xml = $this->multiLocale()->urlBlock(['loc' => 'https://example.test/a']); + + // 기본 로케일(ko)은 쿼리 없이 원본 + $this->assertStringContainsString('https://example.test/a', $xml); + $this->assertStringContainsString('https://example.test/a?locale=en', $xml); + $this->assertStringContainsString('https://example.test/a?locale=ja', $xml); + } + + /** + * urlBlock: hreflang alternate 가 현재 N² 구조임을 고정한다 (S3 ⑮ 회귀 기준선) + * + * 현재 구현은 블록마다 모든 로케일의 alternate + x-default 를 넣는다. + * 즉 로케일 L 개면 L × (L + 1) 개의 xhtml:link 가 생성된다 — 이것이 S3 ⑮ 가 + * 해소하려는 N² 팽창이다. S3 에서 이 수치가 바뀌면 의도된 변경이므로 + * 이 테스트를 함께 갱신할 것. + */ + public function test_url_block_currently_emits_quadratic_hreflang_links(): void + { + $xml = $this->multiLocale(['ko', 'en', 'ja'])->urlBlock(['loc' => 'https://example.test/a']); + + // 3 로케일 → 3 블록 × (3 alternate + 1 x-default) = 12 + $this->assertSame(12, substr_count($xml, 'xhtml:link')); + $this->assertSame(3, substr_count($xml, 'hreflang="x-default"')); + } + + /** + * urlBlock: x-default 는 항상 기본 로케일 URL 을 가리킨다 + */ + public function test_url_block_x_default_points_to_base_loc(): void + { + $xml = $this->multiLocale()->urlBlock(['loc' => 'https://example.test/a']); + + $this->assertStringContainsString( + '', + $xml + ); + $this->assertStringNotContainsString('hreflang="x-default" href="https://example.test/a?locale=', $xml); + } + + /** + * urlBlock: loc 의 XML 특수문자를 이스케이프한다 (단일 출처 방어) + */ + public function test_url_block_escapes_special_characters_in_loc(): void + { + $xml = $this->singleLocale()->urlBlock(['loc' => 'https://example.test/a?x=1&y=2']); + + $this->assertStringContainsString('https://example.test/a?x=1&y=2<z>', $xml); + $this->assertStringNotContainsString('&y=2', $xml); + } + + /** + * urlBlock: 메타 필드의 XML 특수문자도 이스케이프한다 + */ + public function test_url_block_escapes_special_characters_in_meta_fields(): void + { + $xml = $this->singleLocale()->urlBlock([ + 'loc' => 'https://example.test/a', + 'lastmod' => '2026 & later', + 'changefreq' => 'daassertStringContainsString('2026 & later', $xml); + $this->assertStringContainsString('da<ily', $xml); + } + + /** + * urlBlock: 다국어 alternate href 도 이스케이프한다 + */ + public function test_url_block_escapes_hreflang_alternate_href(): void + { + $xml = $this->multiLocale(['ko', 'en'])->urlBlock(['loc' => 'https://example.test/a?x=1&y=2']); + + $this->assertStringContainsString('href="https://example.test/a?x=1&y=2"', $xml); + // 이스케이프되지 않은 & 가 남으면 XML 파서가 깨진다 + $this->assertStringNotContainsString('&y=2"', $xml); + } + + /** + * urlBlock: 다국어 출력이 well-formed XML 이다 (이스케이프 종합 검증) + */ + public function test_multilingual_url_block_is_well_formed_xml(): void + { + $renderer = $this->multiLocale(); + $xml = $renderer->urlsetHeader() + .$renderer->urlBlock(['loc' => 'https://example.test/a?x=1&y=2', 'changefreq' => 'daily']) + .$renderer->urlsetFooter(); + + $this->assertNotFalse(simplexml_load_string($xml), 'urlset 이 파싱 가능한 XML 이어야 합니다.'); + } + + /** + * sitemapIndex: 자식 목록을 sitemapindex 로 렌더링한다 + */ + public function test_sitemap_index_renders_children(): void + { + $xml = $this->singleLocale()->sitemapIndex([ + ['loc' => 'https://example.test/sitemap-1.xml', 'lastmod' => '2026-07-17T00:00:00+09:00'], + ['loc' => 'https://example.test/sitemap-2.xml', 'lastmod' => null], + ]); + + $this->assertStringStartsWith('', $xml); + $this->assertStringContainsString('', $xml); + $this->assertSame(2, substr_count($xml, '')); + $this->assertStringContainsString('https://example.test/sitemap-1.xml', $xml); + // lastmod 가 null 인 자식은 태그를 생략한다 + $this->assertSame(1, substr_count($xml, '')); + $this->assertStringEndsWith('', $xml); + } + + /** + * sitemapIndex: 자식이 없어도 well-formed 인 빈 인덱스를 만든다 + */ + public function test_sitemap_index_is_well_formed_when_empty(): void + { + $xml = $this->singleLocale()->sitemapIndex([]); + + $this->assertNotFalse(simplexml_load_string($xml)); + $this->assertStringNotContainsString('', $xml); + } + + /** + * sitemapIndex: 자식 loc/lastmod 의 특수문자를 이스케이프한다 + */ + public function test_sitemap_index_escapes_special_characters(): void + { + $xml = $this->singleLocale()->sitemapIndex([ + ['loc' => 'https://example.test/sitemap-1.xml?v=1&t=2', 'lastmod' => '2026 & later'], + ]); + + $this->assertStringContainsString('https://example.test/sitemap-1.xml?v=1&t=2', $xml); + $this->assertStringContainsString('2026 & later', $xml); + $this->assertNotFalse(simplexml_load_string($xml)); + } +} diff --git a/tests/Unit/Upgrades/NullifyUntouchedSeoCacheOverridesTest.php b/tests/Unit/Upgrades/NullifyUntouchedSeoCacheOverridesTest.php new file mode 100644 index 00000000..bd7afa3e --- /dev/null +++ b/tests/Unit/Upgrades/NullifyUntouchedSeoCacheOverridesTest.php @@ -0,0 +1,241 @@ +migration = new NullifyUntouchedSeoCacheOverrides; + } + + /** + * 업그레이드 컨텍스트를 만듭니다. + * + * @return UpgradeContext 컨텍스트 + */ + private function context(): UpgradeContext + { + return new UpgradeContext('7.0.5', '7.1.0', '7.1.0'); + } + + /** + * seo.json 을 기록합니다. + * + * @param array $settings 설정 배열 + */ + private function writeSeoJson(array $settings): void + { + Storage::disk('settings')->put('seo.json', json_encode($settings)); + } + + /** + * seo.json 을 읽습니다. + * + * @return array 설정 배열 + */ + private function readSeoJson(): array + { + return json_decode(Storage::disk('settings')->get('seo.json'), true); + } + + /** + * name: 로그용 식별자를 반환한다 + */ + public function test_name_returns_identifier(): void + { + $this->assertSame('NullifyUntouchedSeoCacheOverrides', $this->migration->name()); + } + + /** + * 옛 기본값 그대로인 키는 null 로 비운다 (안 건드린 것으로 추정) + */ + public function test_clears_keys_left_at_legacy_defaults(): void + { + $this->writeSeoJson([ + 'cache_enabled' => true, + 'cache_ttl' => 7200, + 'sitemap_cache_ttl' => 86400, + 'sitemap_enabled' => true, + ]); + + $this->migration->run($this->context()); + $result = $this->readSeoJson(); + + $this->assertNull($result['cache_enabled']); + $this->assertNull($result['cache_ttl']); + $this->assertNull($result['sitemap_cache_ttl']); + } + + /** + * 옛 기본값과 다른 값은 보존한다 (의도적으로 바꾼 것 → 오버라이드로 발동) + * + * 실제 사례: seo.cache_ttl=3600 을 설정해 뒀으나 그동안 무시되던 사이트. + */ + public function test_preserves_values_that_differ_from_legacy_defaults(): void + { + $this->writeSeoJson([ + 'cache_enabled' => false, + 'cache_ttl' => 3600, + 'sitemap_cache_ttl' => 604800, + ]); + + $this->migration->run($this->context()); + $result = $this->readSeoJson(); + + $this->assertFalse($result['cache_enabled'], 'false 는 기본값(true)과 다르므로 보존돼야 합니다.'); + $this->assertSame(3600, $result['cache_ttl']); + $this->assertSame(604800, $result['sitemap_cache_ttl']); + } + + /** + * 일부만 바뀐 경우 — 바뀐 것만 보존하고 나머지는 비운다 + */ + public function test_clears_untouched_and_preserves_changed_in_same_file(): void + { + $this->writeSeoJson([ + 'cache_enabled' => true, // 기본값 → 비움 + 'cache_ttl' => 3600, // 변경됨 → 보존 + 'sitemap_cache_ttl' => 86400, // 기본값 → 비움 + ]); + + $this->migration->run($this->context()); + $result = $this->readSeoJson(); + + $this->assertNull($result['cache_enabled']); + $this->assertSame(3600, $result['cache_ttl']); + $this->assertNull($result['sitemap_cache_ttl']); + } + + /** + * seo 카테고리의 다른 키는 건드리지 않는다 + */ + public function test_does_not_touch_unrelated_keys(): void + { + $this->writeSeoJson([ + 'cache_ttl' => 7200, + 'sitemap_enabled' => true, + 'sitemap_schedule' => 'hourly', + 'sitemap_schedule_time' => '03:00', + 'meta_title_suffix' => ' | G7', + ]); + + $this->migration->run($this->context()); + $result = $this->readSeoJson(); + + $this->assertTrue($result['sitemap_enabled']); + $this->assertSame('hourly', $result['sitemap_schedule'], '재생성 주기는 이 이행과 무관합니다.'); + $this->assertSame('03:00', $result['sitemap_schedule_time']); + $this->assertSame(' | G7', $result['meta_title_suffix']); + } + + /** + * 멱등: 두 번 실행해도 결과가 같다 + */ + public function test_is_idempotent(): void + { + $this->writeSeoJson([ + 'cache_ttl' => 7200, + 'sitemap_cache_ttl' => 3600, + ]); + + $this->migration->run($this->context()); + $first = $this->readSeoJson(); + + $this->migration->run($this->context()); + $second = $this->readSeoJson(); + + $this->assertSame($first, $second); + $this->assertNull($second['cache_ttl']); + $this->assertSame(3600, $second['sitemap_cache_ttl']); + } + + /** + * 이미 null 인 키는 그대로 둔다 (재실행 안전) + */ + public function test_leaves_already_null_keys_untouched(): void + { + $this->writeSeoJson(['cache_ttl' => null, 'sitemap_cache_ttl' => null]); + + $this->migration->run($this->context()); + $result = $this->readSeoJson(); + + $this->assertNull($result['cache_ttl']); + $this->assertNull($result['sitemap_cache_ttl']); + } + + /** + * 키가 아예 없으면 새로 만들지 않는다 + */ + public function test_does_not_add_absent_keys(): void + { + $this->writeSeoJson(['meta_title_suffix' => ' | G7']); + + $this->migration->run($this->context()); + $result = $this->readSeoJson(); + + $this->assertArrayNotHasKey('cache_ttl', $result); + $this->assertArrayNotHasKey('sitemap_cache_ttl', $result); + } + + /** + * seo.json 이 없으면 no-op (예외 없음) + */ + public function test_no_op_when_settings_file_is_absent(): void + { + $this->migration->run($this->context()); + + $this->assertFalse(Storage::disk('settings')->exists('seo.json')); + } + + /** + * 손상된 JSON 은 덮어쓰지 않고 보존한다 (파괴 금지) + */ + public function test_preserves_corrupted_json_without_overwriting(): void + { + Storage::disk('settings')->put('seo.json', '{ this is not json'); + + $this->migration->run($this->context()); + + $this->assertSame('{ this is not json', Storage::disk('settings')->get('seo.json')); + } + + /** + * 타입이 다른 동일 값은 오버라이드로 본다 (느슨한 비교 금지) + * + * 문자열 "7200" 은 기본값 7200(int)과 == 로는 같지만, 운영자가 폼으로 입력한 + * 흔적이므로 === 비교로 보존해야 한다. + */ + public function test_treats_type_mismatched_value_as_override(): void + { + $this->writeSeoJson(['cache_ttl' => '7200']); + + $this->migration->run($this->context()); + + $this->assertSame('7200', $this->readSeoJson()['cache_ttl']); + } +} diff --git a/upgrades/Upgrade_7_1_0.php b/upgrades/Upgrade_7_1_0.php new file mode 100644 index 00000000..714b6c91 --- /dev/null +++ b/upgrades/Upgrade_7_1_0.php @@ -0,0 +1,30 @@ + true, + 'cache_ttl' => 7200, + 'sitemap_cache_ttl' => 86400, + ]; + + /** + * 마이그레이션 식별자 (로그용). + * + * @return string 식별자 + */ + public function name(): string + { + return 'NullifyUntouchedSeoCacheOverrides'; + } + + /** + * 옛 기본값 그대로인 SEO 캐시 키를 null(미설정)로 비웁니다. + * + * @param UpgradeContext $context 업그레이드 컨텍스트 + */ + public function run(UpgradeContext $context): void + { + try { + $this->runInternal($context); + } catch (\Throwable $e) { + // 업그레이드 중단 금지 — 설정 정리 실패가 업그레이드 전체를 막지 않는다. + $context->logger->warning(sprintf( + '[7.1.0] NullifyUntouchedSeoCacheOverrides 실패 (seo.json 보존, 계속 진행): %s', + $e->getMessage(), + )); + Log::warning('7.1.0 NullifyUntouchedSeoCacheOverrides 실패', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + } + } + + /** + * 실제 이행 로직. + * + * @param UpgradeContext $context 업그레이드 컨텍스트 + */ + private function runInternal(UpgradeContext $context): void + { + $settings = $this->readSeoSettings($context); + if ($settings === null) { + return; + } + + $cleared = []; + $kept = []; + + foreach (self::LEGACY_DEFAULTS as $key => $legacyDefault) { + if (! array_key_exists($key, $settings) || $settings[$key] === null) { + continue; + } + + // 옛 기본값과 완전히 같을 때만 "안 건드림" 으로 본다 (타입까지 일치 요구) + if ($settings[$key] === $legacyDefault) { + $settings[$key] = null; + $cleared[] = $key; + + continue; + } + + $kept[$key] = $settings[$key]; + } + + foreach ($kept as $key => $value) { + $context->logger->info(sprintf( + '[7.1.0] seo.%s = %s 는 옛 기본값과 달라 오버라이드로 보존 — 이제부터 실제 적용됩니다', + $key, + var_export($value, true), + )); + } + + if ($cleared === []) { + $context->logger->info('[7.1.0] SEO 캐시 설정 — 비울 대상 없음 (이미 이행 완료이거나 전부 오버라이드)'); + + return; + } + + $this->writeSeoSettings($settings); + $context->logger->info('[7.1.0] SEO 캐시 설정 — 미설정(null)으로 비운 키: '.implode(', ', $cleared)); + } + + /** + * seo 설정 카테고리를 읽습니다. + * + * @param UpgradeContext $context 업그레이드 컨텍스트 + * @return array|null 설정 배열 (파일 부재/손상 시 null) + */ + private function readSeoSettings(UpgradeContext $context): ?array + { + if (! Storage::disk('settings')->exists(self::SEO_SETTINGS_FILE)) { + $context->logger->info('[7.1.0] seo.json 부재 — 정리 대상 없음, skip'); + + return null; + } + + $decoded = json_decode((string) Storage::disk('settings')->get(self::SEO_SETTINGS_FILE), true); + + if (! is_array($decoded)) { + // 손상 파일을 덮어쓰지 않는다 (파괴 금지) + $context->logger->warning('[7.1.0] seo.json 파싱 실패 — 파일 보존하고 skip'); + + return null; + } + + return $decoded; + } + + /** + * seo 설정 카테고리를 저장합니다. + * + * @param array $settings 저장할 설정 배열 + */ + private function writeSeoSettings(array $settings): void + { + Storage::disk('settings')->put( + self::SEO_SETTINGS_FILE, + json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + ); + } +} From 1c99547bc1859855a9f040cf03a8bd7223dcf0d5 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Sat, 18 Jul 2026 11:31:28 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat(seo):=20=EC=82=AC=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EB=A7=B5=20=EC=95=88=EC=A0=84=20=EC=84=9C=EB=B9=99=C2=B7?= =?UTF-8?q?=EC=9E=A1=20=EA=B2=AC=EA=B3=A0=ED=99=94=C2=B7=EB=B6=84=ED=95=A0?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20+=20=EC=BA=90=EC=8B=9C=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=A0=95=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 봇 요청 스레드의 동기 생성을 제거하고 디스크 스트리밍 서빙으로 전환했다. 신선도가 만료되면 생성 잡만 예약하고 기존 세트(stale) 또는 503+Retry-After 로 응답해, 대용량에서 봇 요청이 OOM/타임아웃을 유발하던 경로를 끊었다. - 잡: ShouldBeUnique(고정 uniqueId)+uniqueFor+retryUntil, timeout 300→1800, backoff - 컨트롤러: 인덱스 stale/503 분기 + 자식 라우트(.xml/.xml.gz 동일 액션) - 스케줄 withoutOverlapping, 관리자 재생성 큐 이관 - 설정 4키(분할 기준·압축·stale 서빙·수집기 상한) + 고급탭 Sitemap 캐시 짝 - SitemapManager 응답 메시지 다국어화 설정 저장 테스트가 실제 설정 파일(사이트명/언어 포함)을 덮어쓰던 사전결함을 TestCase 전역 격리로 차단하고, 두 탭이 같은 다국어 키를 공유해 한쪽에 틀린 한도를 안내하던 사전결함을 키 분리로 해소했다. --- .../Api/Admin/SeoCacheController.php | 28 +- .../Api/Public/SitemapController.php | 96 +- .../Requests/Settings/SaveSettingsRequest.php | 30 +- app/Jobs/GenerateSitemapJob.php | 46 +- app/Seo/SitemapManager.php | 6 +- app/Services/SettingsService.php | 2 +- config/settings/defaults.json | 8 + docs/backend/api/seo.md | 19 +- docs/backend/seo-system.md | 39 +- docs/testing-guide.md | 37 + lang-packs/_bundled/g7-core-ja/CHANGELOG.md | 2 + .../g7-core-ja/backend/ja/activity_log.php | 208 +-- .../g7-core-ja/backend/ja/admin_layout.php | 44 +- .../g7-core-ja/backend/ja/attachment.php | 20 +- .../_bundled/g7-core-ja/backend/ja/auth.php | 24 +- .../_bundled/g7-core-ja/backend/ja/common.php | 6 +- .../g7-core-ja/backend/ja/dashboard.php | 20 +- .../_bundled/g7-core-ja/backend/ja/errors.php | 12 +- .../g7-core-ja/backend/ja/exceptions.php | 42 +- .../g7-core-ja/backend/ja/extensions.php | 42 +- .../_bundled/g7-core-ja/backend/ja/hooks.php | 2 +- .../g7-core-ja/backend/ja/identity.php | 48 +- .../backend/ja/identity_message.php | 46 +- .../g7-core-ja/backend/ja/language_packs.php | 60 +- .../backend/ja/layout_extension.php | 2 +- .../g7-core-ja/backend/ja/maintenance.php | 4 +- .../_bundled/g7-core-ja/backend/ja/menu.php | 12 +- .../_bundled/g7-core-ja/backend/ja/module.php | 30 +- .../g7-core-ja/backend/ja/modules.php | 202 +- .../g7-core-ja/backend/ja/notification.php | 50 +- .../backend/ja/notification_log.php | 4 +- .../g7-core-ja/backend/ja/plugins.php | 188 +- .../_bundled/g7-core-ja/backend/ja/role.php | 2 +- .../g7-core-ja/backend/ja/schedule.php | 86 +- .../_bundled/g7-core-ja/backend/ja/search.php | 2 +- .../_bundled/g7-core-ja/backend/ja/seo.php | 25 +- .../g7-core-ja/backend/ja/settings.php | 108 +- .../g7-core-ja/backend/ja/templates.php | 228 +-- .../_bundled/g7-core-ja/backend/ja/themes.php | 4 +- .../_bundled/g7-core-ja/backend/ja/user.php | 56 +- .../g7-core-ja/backend/ja/validation.php | 1028 +++++----- .../_bundled/g7-core-ja/backend/ja/vendor.php | 12 +- .../frontend/partial/layout_editor.json | 1654 ++++++++--------- .../g7-core-ja/seed/identity_messages.json | 20 +- .../g7-core-ja/seed/notifications.json | 26 +- .../_bundled/g7-core-ja/seed/permissions.json | 90 +- .../_bundled/g7-core-ja/seed/roles.json | 4 +- .../CHANGELOG.md | 5 + .../frontend/partial/admin.json | 10 +- lang/en/seo.php | 5 + lang/en/validation.php | 23 +- lang/ko/seo.php | 5 + lang/ko/validation.php | 23 +- routes/console.php | 19 +- routes/web.php | 4 + .../_bundled/sirsoft-admin_basic/CHANGELOG.md | 11 + .../admin-settings-sitemap-fields.test.tsx | 139 ++ .../lang/partial/en/admin.json | 16 +- .../lang/partial/ko/admin.json | 16 +- .../admin_settings/_tab_advanced.json | 118 ++ .../partials/admin_settings/_tab_seo.json | 75 + .../sirsoft-admin_basic/package-lock.json | 4 +- .../_bundled/sirsoft-admin_basic/package.json | 2 +- .../sirsoft-admin_basic/template.json | 2 +- .../Api/Admin/SeoCacheControllerTest.php | 97 +- .../Seo/SaveSettingsRequestSeoTest.php | 253 ++- tests/Feature/Seo/SitemapTest.php | 346 ++-- .../Settings/SettingsDiskIsolationTest.php | 69 + ...settings-sitemap-generation-fields.spec.ts | 177 ++ tests/TestCase.php | 27 + tests/Unit/Jobs/GenerateSitemapJobTest.php | 69 +- 71 files changed, 3767 insertions(+), 2472 deletions(-) create mode 100644 templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-fields.test.tsx create mode 100644 tests/Feature/Settings/SettingsDiskIsolationTest.php create mode 100644 tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts diff --git a/app/Http/Controllers/Api/Admin/SeoCacheController.php b/app/Http/Controllers/Api/Admin/SeoCacheController.php index c449ab55..f0942fdd 100644 --- a/app/Http/Controllers/Api/Admin/SeoCacheController.php +++ b/app/Http/Controllers/Api/Admin/SeoCacheController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Admin; use App\Http\Controllers\Api\Base\AdminBaseController; use App\Http\Requests\Admin\SeoCacheClearRequest; +use App\Jobs\GenerateSitemapJob; use App\Seo\Contracts\SeoCacheManagerInterface; use App\Seo\SeoCacheStatsService; use App\Seo\SitemapManager; @@ -54,7 +55,7 @@ class SeoCacheController extends AdminBaseController * * 레이아웃 또는 모듈 지정 시 해당 캐시만, 미지정 시 전체 캐시를 삭제합니다. * - * @param SeoCacheClearRequest $request 캐시 삭제 요청 + * @param SeoCacheClearRequest $request 캐시 삭제 요청 * @return JsonResponse 삭제 결과를 포함한 JSON 응답 */ public function clearCache(SeoCacheClearRequest $request): JsonResponse @@ -97,28 +98,25 @@ class SeoCacheController extends AdminBaseController } /** - * Sitemap XML 을 즉시 재생성합니다. + * Sitemap XML 재생성을 큐에 예약합니다. * - * 큐 드라이버와 무관하게 동기 실행되며, 생성 완료 후 last_updated_at 을 갱신합니다. + * 대용량(수백만 URL) 사이트에서 요청 스레드 동기 생성은 메모리/타임아웃 붕괴를 일으키므로 + * 큐 잡으로 위임합니다. 응답은 예약 시점의 상태이며, 실제 완료 시각은 이후 갱신됩니다. * - * @return JsonResponse 재생성 결과 JSON 응답 + * @return JsonResponse 예약 결과 JSON 응답 */ public function regenerateSitemap(): JsonResponse { - $result = $this->sitemapManager->regenerate(); - - if ($result['success']) { - return $this->success('seo.sitemap_regenerated', $result['data'] ?? null); + if (! (bool) g7_core_settings('seo.sitemap_enabled', true)) { + return $this->error('seo.sitemap_disabled', 400); } - $messageKey = match ($result['status']) { - 'disabled' => 'seo.sitemap_disabled', - default => 'seo.sitemap_regenerate_failed', - }; + GenerateSitemapJob::dispatch(); - $statusCode = $result['status'] === 'disabled' ? 400 : 500; - - return $this->error($messageKey, $statusCode, $result['message'] ?? null); + return $this->success('seo.sitemap_regenerate_dispatched', [ + 'status' => 'dispatched', + ...$this->sitemapManager->getStatus(), + ]); } /** diff --git a/app/Http/Controllers/Api/Public/SitemapController.php b/app/Http/Controllers/Api/Public/SitemapController.php index 4c55e6d9..fdd6d2d6 100644 --- a/app/Http/Controllers/Api/Public/SitemapController.php +++ b/app/Http/Controllers/Api/Public/SitemapController.php @@ -4,44 +4,92 @@ namespace App\Http\Controllers\Api\Public; use App\Contracts\Extension\CacheInterface; use App\Http\Controllers\Controller; -use App\Seo\SeoCacheSettings; -use App\Seo\SitemapGenerator; -use Illuminate\Http\Response; +use App\Jobs\GenerateSitemapJob; +use App\Seo\SitemapFileStore; +use App\Seo\SitemapManager; +use Symfony\Component\HttpFoundation\StreamedResponse; /** * Sitemap XML 컨트롤러 * - * sitemap.xml 요청을 처리하여 캐시된 또는 실시간 생성된 - * Sitemap XML을 반환합니다. + * 비공개 디스크에 커밋된 sitemap 인덱스/자식 파일을 스트리밍으로 서빙합니다. + * 봇 요청 스레드에서 sitemap 을 생성하지 않으며(대용량에서 OOM/타임아웃 원인), + * 신선도가 만료되면 생성 잡만 예약하고 기존 세트(stale)를 그대로 내보냅니다. */ class SitemapController extends Controller { /** - * sitemap.xml을 반환합니다. - * - * 캐시가 있으면 캐시된 XML, 없으면 실시간 생성 후 캐시 저장. - * - * @param SitemapGenerator $generator Sitemap 생성기 - * @param CacheInterface $cache 코어 캐시 드라이버 - * @return Response Sitemap XML 응답 + * 세트가 없어 503 을 반환할 때 봇에 안내할 재시도 간격 (초) */ - public function index(SitemapGenerator $generator, CacheInterface $cache): Response + private const RETRY_AFTER_SECONDS = 120; + + /** + * sitemapindex 를 반환합니다. + * + * 커밋된 세트가 있으면 스트리밍하고, 신선도(메타 캐시)가 만료되었으면 + * 생성 잡을 예약합니다. 세트가 전혀 없으면 503 + Retry-After 를 반환합니다. + * + * @param SitemapFileStore $store sitemap 파일 저장소 (읽기측) + * @param CacheInterface $cache 코어 캐시 드라이버 + * @return StreamedResponse sitemapindex 스트림 + */ + public function index(SitemapFileStore $store, CacheInterface $cache): StreamedResponse { - $enabled = (bool) g7_core_settings('seo.sitemap_enabled', true); - if (! $enabled) { + $this->assertSitemapEnabled(); + + $isFresh = $cache->get(SitemapManager::META_CACHE_KEY) !== null; + + if ($isFresh) { + $response = $store->indexResponse(); + if ($response !== null) { + return $response; + } + } + + // 신선도 만료 또는 세트 부재 — 생성은 큐에 맡긴다 (ShouldBeUnique 로 중복 디스패치 방지). + GenerateSitemapJob::dispatch(); + + $serveStale = (bool) g7_core_settings('seo.sitemap_serve_stale_on_miss', true); + + if ($serveStale) { + $response = $store->indexResponse(); + if ($response !== null) { + return $response; + } + } + + abort(503, __('seo.sitemap_not_ready'), ['Retry-After' => (string) self::RETRY_AFTER_SECONDS]); + } + + /** + * 자식 sitemap 을 반환합니다. + * + * gzip 여부는 manifest 가 결정하므로 `.xml` / `.xml.gz` 요청을 같은 액션에서 처리합니다. + * + * @param int $n 자식 번호 (1부터) + * @param SitemapFileStore $store sitemap 파일 저장소 (읽기측) + * @return StreamedResponse 자식 sitemap 스트림 + */ + public function child(int $n, SitemapFileStore $store): StreamedResponse + { + $this->assertSitemapEnabled(); + + $response = $store->childResponse($n); + + if ($response === null) { abort(404); } - $xml = $cache->get('seo.sitemap'); + return $response; + } - if (! $xml) { - $xml = $generator->generate(); - // 고급 탭(cache.seo_sitemap_ttl)이 메인, SEO 탭에 별도 지정이 있으면 그것이 우선 (D19) - $cache->put('seo.sitemap', $xml, SeoCacheSettings::sitemapCacheTtl()); + /** + * Sitemap 기능이 비활성화되어 있으면 404 로 중단합니다. + */ + private function assertSitemapEnabled(): void + { + if (! (bool) g7_core_settings('seo.sitemap_enabled', true)) { + abort(404); } - - return response($xml, 200, [ - 'Content-Type' => 'application/xml', - ]); } } diff --git a/app/Http/Requests/Settings/SaveSettingsRequest.php b/app/Http/Requests/Settings/SaveSettingsRequest.php index 7708710b..d1ab767c 100644 --- a/app/Http/Requests/Settings/SaveSettingsRequest.php +++ b/app/Http/Requests/Settings/SaveSettingsRequest.php @@ -220,6 +220,11 @@ class SaveSettingsRequest extends FormRequest 'seo.cache_ttl' => ['nullable', 'integer', 'min:60', 'max:86400'], 'seo.sitemap_enabled' => ['nullable', 'boolean'], 'seo.sitemap_cache_ttl' => ['nullable', 'integer', 'min:3600', 'max:604800'], + // 분할 기준. 상한 50000 은 sitemaps.org 프로토콜 제한이며 SitemapWriter 가 동일 값으로 클램프합니다. + 'seo.sitemap_urls_per_file' => ['nullable', 'integer', 'min:1000', 'max:50000'], + 'seo.sitemap_gzip' => ['nullable', 'boolean'], + 'seo.sitemap_serve_stale_on_miss' => ['nullable', 'boolean'], + 'seo.sitemap_max_urls_per_contributor' => ['nullable', 'integer', 'min:0'], 'seo.sitemap_schedule' => ['nullable', 'string', Rule::in(['hourly', 'daily', 'weekly'])], 'seo.sitemap_schedule_time' => ['nullable', 'string', 'regex:/^\d{2}:\d{2}$/'], 'seo.generator_enabled' => ['nullable', 'boolean'], @@ -242,6 +247,9 @@ class SaveSettingsRequest extends FormRequest 'advanced.stats_cache_ttl' => $this->getAdvancedTabCacheTtlRules($tab), 'advanced.seo_cache_enabled' => $this->getTabRules($tab, 'advanced', 'boolean'), 'advanced.seo_cache_ttl' => $this->getAdvancedTabCacheTtlRules($tab), + // 신규 설정이므로 미전송(기존 클라이언트)을 허용한다 — 미전송/미지정 시 코드 기본값 유지. + // 범위는 SEO 탭의 오버라이드 칸(seo.sitemap_cache_ttl)과 동일하게 맞춘다. + 'advanced.seo_sitemap_cache_ttl' => ['nullable', 'integer', 'min:3600', 'max:604800'], // 디버그 설정 (advanced 탭) 'advanced.debug_mode' => $this->getTabRules($tab, 'advanced', 'boolean'), @@ -605,6 +613,13 @@ class SaveSettingsRequest extends FormRequest 'seo.sitemap_cache_ttl.integer' => __('validation.settings.sitemap_cache_ttl_integer'), 'seo.sitemap_cache_ttl.min' => __('validation.settings.sitemap_cache_ttl_min'), 'seo.sitemap_cache_ttl.max' => __('validation.settings.sitemap_cache_ttl_max'), + 'seo.sitemap_urls_per_file.integer' => __('validation.settings.sitemap_urls_per_file_integer'), + 'seo.sitemap_urls_per_file.min' => __('validation.settings.sitemap_urls_per_file_min'), + 'seo.sitemap_urls_per_file.max' => __('validation.settings.sitemap_urls_per_file_max'), + 'seo.sitemap_gzip.boolean' => __('validation.settings.sitemap_gzip_boolean'), + 'seo.sitemap_serve_stale_on_miss.boolean' => __('validation.settings.sitemap_serve_stale_on_miss_boolean'), + 'seo.sitemap_max_urls_per_contributor.integer' => __('validation.settings.sitemap_max_urls_per_contributor_integer'), + 'seo.sitemap_max_urls_per_contributor.min' => __('validation.settings.sitemap_max_urls_per_contributor_min'), 'seo.sitemap_schedule.in' => __('validation.settings.sitemap_schedule_invalid'), 'seo.sitemap_schedule_time.regex' => __('validation.settings.sitemap_schedule_time_invalid'), @@ -639,12 +654,15 @@ class SaveSettingsRequest extends FormRequest 'advanced.stats_cache_ttl.integer' => __('validation.settings.stats_cache_ttl_integer'), 'advanced.stats_cache_ttl.min' => __('validation.settings.stats_cache_ttl_min'), 'advanced.stats_cache_ttl.max' => __('validation.settings.stats_cache_ttl_max'), - 'advanced.seo_cache_enabled.required' => __('validation.settings.seo_cache_enabled_required'), - 'advanced.seo_cache_enabled.boolean' => __('validation.settings.seo_cache_enabled_boolean'), - 'advanced.seo_cache_ttl.required' => __('validation.settings.seo_cache_ttl_required'), - 'advanced.seo_cache_ttl.integer' => __('validation.settings.seo_cache_ttl_integer'), - 'advanced.seo_cache_ttl.min' => __('validation.settings.seo_cache_ttl_min'), - 'advanced.seo_cache_ttl.max' => __('validation.settings.seo_cache_ttl_max'), + 'advanced.seo_cache_enabled.required' => __('validation.settings.advanced_seo_cache_enabled_required'), + 'advanced.seo_cache_enabled.boolean' => __('validation.settings.advanced_seo_cache_enabled_boolean'), + 'advanced.seo_cache_ttl.required' => __('validation.settings.advanced_seo_cache_ttl_required'), + 'advanced.seo_cache_ttl.integer' => __('validation.settings.advanced_seo_cache_ttl_integer'), + 'advanced.seo_cache_ttl.min' => __('validation.settings.advanced_seo_cache_ttl_min'), + 'advanced.seo_cache_ttl.max' => __('validation.settings.advanced_seo_cache_ttl_max'), + 'advanced.seo_sitemap_cache_ttl.integer' => __('validation.settings.seo_sitemap_cache_ttl_integer'), + 'advanced.seo_sitemap_cache_ttl.min' => __('validation.settings.seo_sitemap_cache_ttl_min'), + 'advanced.seo_sitemap_cache_ttl.max' => __('validation.settings.seo_sitemap_cache_ttl_max'), // 디버그 설정 'advanced.debug_mode.required' => __('validation.settings.debug_mode_required'), diff --git a/app/Jobs/GenerateSitemapJob.php b/app/Jobs/GenerateSitemapJob.php index df419daf..d667ceec 100644 --- a/app/Jobs/GenerateSitemapJob.php +++ b/app/Jobs/GenerateSitemapJob.php @@ -4,6 +4,7 @@ namespace App\Jobs; use App\Seo\SitemapManager; use Illuminate\Bus\Queueable; +use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; @@ -13,10 +14,13 @@ use Illuminate\Support\Facades\Log; /** * Sitemap XML 생성 큐 잡 * - * 스케줄러 또는 Artisan 커맨드에서 디스패치되며, + * 스케줄러, Artisan 커맨드, 관리자 수동 재생성, 봇 요청 캐시 미스에서 디스패치되며, * 실제 생성 로직은 SitemapManager 서비스에 위임합니다. + * + * 대용량(수백만 URL) 생성은 수 분~수십 분이 걸리므로 동시 실행을 막고(ShouldBeUnique) + * 타임아웃/재시도 한계를 넉넉히 잡되, 재시도가 무한히 누적되지 않도록 retryUntil 로 제한합니다. */ -class GenerateSitemapJob implements ShouldQueue +class GenerateSitemapJob implements ShouldBeUnique, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; @@ -26,9 +30,43 @@ class GenerateSitemapJob implements ShouldQueue public int $tries = 3; /** - * @var int 타임아웃 (초) + * @var int 타임아웃 (초) — 대용량 스트리밍 생성 대응 */ - public int $timeout = 300; + public int $timeout = 1800; + + /** + * @var int 재시도 대기 시간 (초) + */ + public int $backoff = 120; + + /** + * @var int 유니크 락 유지 시간 (초) — 잡이 비정상 종료해도 이 시간 후 자동 해제 + */ + public int $uniqueFor = 900; + + /** + * 유니크 락 식별자를 반환합니다. + * + * 봇 요청 캐시 미스마다 디스패치되어도 큐에는 한 건만 남도록 고정 키를 사용합니다. + * + * @return string 유니크 락 키 + */ + public function uniqueId(): string + { + return 'seo-sitemap'; + } + + /** + * 재시도 마감 시각을 반환합니다. + * + * timeout(1800초) × tries(3) 만큼 재시도가 누적되는 것을 막습니다. + * + * @return \DateTime 재시도 마감 시각 + */ + public function retryUntil(): \DateTime + { + return now()->addMinutes(30)->toDateTime(); + } /** * Sitemap 을 생성하고 캐시에 저장합니다. diff --git a/app/Seo/SitemapManager.php b/app/Seo/SitemapManager.php index cb82aa48..8ae7473b 100644 --- a/app/Seo/SitemapManager.php +++ b/app/Seo/SitemapManager.php @@ -48,7 +48,7 @@ class SitemapManager return [ 'success' => false, 'status' => 'disabled', - 'message' => 'Sitemap 생성이 비활성화되어 있습니다.', + 'message' => __('seo.sitemap_disabled'), ]; } @@ -70,7 +70,7 @@ class SitemapManager $result = [ 'success' => true, 'status' => 'updated', - 'message' => 'Sitemap 생성이 완료되었습니다.', + 'message' => __('seo.sitemap_regenerated'), 'data' => [ 'last_updated_at' => $lastUpdatedAt, 'size_bytes' => $meta['size_bytes'], @@ -91,7 +91,7 @@ class SitemapManager $result = [ 'success' => false, 'status' => 'failed', - 'message' => 'Sitemap 생성에 실패했습니다: '.$e->getMessage(), + 'message' => __('seo.sitemap_generate_failed', ['error' => $e->getMessage()]), ]; HookManager::doAction('core.seo.sitemap.after_regenerate_failed', $result); diff --git a/app/Services/SettingsService.php b/app/Services/SettingsService.php index 8f57e0ff..7bbc9a72 100644 --- a/app/Services/SettingsService.php +++ b/app/Services/SettingsService.php @@ -563,7 +563,7 @@ class SettingsService // 각 카테고리에 속하는 원본 필드명 목록 // (frontend_key → 원본 키 역변환 후의 키 기준) $categoryFieldMap = [ - 'cache' => ['enabled', 'layout_enabled', 'layout_ttl', 'stats_enabled', 'stats_ttl', 'seo_enabled', 'seo_ttl'], + 'cache' => ['enabled', 'layout_enabled', 'layout_ttl', 'stats_enabled', 'stats_ttl', 'seo_enabled', 'seo_ttl', 'seo_sitemap_ttl'], 'debug' => ['mode', 'sql_query_log', 'log_level'], 'core_update' => ['github_url', 'github_token'], 'geoip' => ['feature_enabled', 'license_key', 'auto_update_enabled', 'last_updated_at'], diff --git a/config/settings/defaults.json b/config/settings/defaults.json index 9eea0f97..b31216af 100644 --- a/config/settings/defaults.json +++ b/config/settings/defaults.json @@ -69,6 +69,10 @@ "cache_ttl": null, "sitemap_enabled": true, "sitemap_cache_ttl": null, + "sitemap_urls_per_file": 50000, + "sitemap_gzip": false, + "sitemap_serve_stale_on_miss": true, + "sitemap_max_urls_per_contributor": 0, "sitemap_schedule": "daily", "sitemap_schedule_time": "02:00", "sitemap_last_updated_at": "", @@ -213,6 +217,10 @@ "cache_ttl": { "type": "integer", "sensitive": false }, "sitemap_enabled": { "type": "boolean", "sensitive": false }, "sitemap_cache_ttl": { "type": "integer", "sensitive": false }, + "sitemap_urls_per_file": { "type": "integer", "sensitive": false }, + "sitemap_gzip": { "type": "boolean", "sensitive": false }, + "sitemap_serve_stale_on_miss": { "type": "boolean", "sensitive": false }, + "sitemap_max_urls_per_contributor": { "type": "integer", "sensitive": false }, "sitemap_schedule": { "type": "string", "sensitive": false }, "sitemap_schedule_time": { "type": "string", "sensitive": false }, "sitemap_last_updated_at": { "type": "string", "sensitive": false }, diff --git a/docs/backend/api/seo.md b/docs/backend/api/seo.md index 2f002776..86888064 100644 --- a/docs/backend/api/seo.md +++ b/docs/backend/api/seo.md @@ -152,9 +152,8 @@ _단건 응답: `data` 객체의 필드._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| last_updated_at | string | `2026-07-08T03:14:49+00:00` | last updated 일시 | -| size_bytes | integer | `25489` | 크기 (바이트) | -| ttl | integer | `86400` | 캐시 유효 시간 (Time To Live, 초). 고급 설정의 `cache.seo_sitemap_ttl` 이 기준값이며, SEO 설정 `seo.sitemap_cache_ttl` 에 값이 지정되어 있으면(null 이 아니면) 그 값이 우선합니다 | +| status | string | `dispatched` | 예약 상태. 재생성 잡이 큐에 등록되었음을 의미합니다 | +| last_updated_at | string\|null | `2026-07-08T03:14:49+00:00` | **예약 시점 기준** 마지막 생성 일시. 이번 재생성의 결과가 아니라 직전 생성 시각이며, 잡 완료 후 갱신됩니다 (아직 한 번도 생성되지 않았으면 null) | **응답 예시** @@ -165,11 +164,10 @@ HTTP/1.1 200 ```json { "success": true, - "message": "Sitemap 재생성이 완료되었습니다.", + "message": "Sitemap 재생성을 시작했습니다. 완료까지 시간이 걸릴 수 있습니다.", "data": { - "last_updated_at": "2026-07-08T03:14:49+00:00", - "size_bytes": 25489, - "ttl": 86400 + "status": "dispatched", + "last_updated_at": "2026-07-08T03:14:49+00:00" } } ``` @@ -178,6 +176,7 @@ HTTP/1.1 200 | 상태코드 | 의미 | 발생 조건 | | --- | --- | --- | +| 400 | Bad Request | SEO 설정에서 sitemap 생성이 비활성(`seo.sitemap_enabled=false`)인 경우 (`seo.sitemap_disabled`) | | 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | | 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 | @@ -185,7 +184,11 @@ HTTP/1.1 200 **설명** -sitemap.xml 을 즉시 재생성합니다. SitemapManager 에 위임하며 큐 드라이버와 무관하게 동기(즉시) 실행되고, 완료 후 마지막 생성 시각을 갱신합니다. SEO 설정에서 sitemap 기능이 비활성인 경우 400(`seo.sitemap_disabled`), 생성 실패 시 500 을 반환합니다. 관리자가 콘텐츠 변경 후 검색엔진에 노출할 sitemap 을 스케줄 대기 없이 즉시 갱신할 때 사용합니다. +sitemap.xml 재생성을 큐에 예약합니다. 응답은 예약 접수 결과이며 생성 완료를 의미하지 않습니다 — 실제 생성은 큐 워커가 `GenerateSitemapJob` 을 처리할 때 수행되고, 완료 후 마지막 생성 시각이 갱신됩니다. + +요청 스레드에서 동기 생성하지 않는 이유는 대용량(수백만 URL) 사이트에서 생성이 수 분~수십 분 걸려 관리자 요청이 메모리 초과/타임아웃으로 실패하기 때문입니다. 잡은 유니크 락(`seo-sitemap`)을 사용하므로 재생성을 연속으로 요청해도 동시에 여러 건이 실행되지 않고 한 건만 큐에 남습니다. + +생성 진행 상황은 이 응답으로 알 수 없습니다. 완료 여부는 `last_updated_at` 이 갱신되었는지로 확인합니다. ### GET /api/admin/seo/stats diff --git a/docs/backend/seo-system.md b/docs/backend/seo-system.md index 0fcd2ec9..ca72c66f 100644 --- a/docs/backend/seo-system.md +++ b/docs/backend/seo-system.md @@ -12,7 +12,8 @@ 5. seo-config.json: 텍스트 추출(text_props), 속성 매핑(attr_map), 허용 속성(allowed_attrs), 컴포넌트→HTML 매핑 — 모두 템플릿 선언 6. 훅 시스템: core.seo.filter_context/filter_og_data/filter_twitter_data/filter_structured_data/filter_meta/filter_view_data + 봇 감지 훅 core.seo.resolve_is_bot 7. 도메인 ownership: 모듈/플러그인이 `seoOgDefaults`/`seoTwitterDefaults`/`seoStructuredData` 메서드로 자기 도메인 OG/Twitter/JSON-LD 를 owned (이커머스 Product, 게시판 Article 등) -7. Artisan: seo:warmup, seo:clear, seo:stats, seo:generate-sitemap +8. Sitemap: 비공개 디스크에 분할 커밋(sitemapindex + sitemap-{n}.xml) 후 스트리밍 서빙 — 요청 스레드 생성 없음, 미스 시 잡 디스패치 + stale/503 +9. Artisan: seo:warmup, seo:clear, seo:stats, seo:generate-sitemap ``` ## 아키텍처 개요 @@ -632,6 +633,36 @@ php artisan seo:generate-sitemap # Sitemap 생성 (큐 디스패치) php artisan seo:generate-sitemap --sync # Sitemap 동기 생성 ``` +## Sitemap 분할 생성과 서빙 + +Sitemap 은 비공개 디스크(StorageInterface, `cache` 카테고리)에 분할 파일로 커밋되고, 컨트롤러가 스트리밍으로 서빙합니다. `public/storage` 심볼릭 링크에 의존하지 않으며 자식 파일을 메모리에 적재하지 않습니다. + +**디스크 레이아웃** (SSoT: `SitemapFileStore` 상수): + +```text +sitemap/manifest.json 커밋 마커 + 자식 목록 메타 (이 파일 존재 = 서빙 가능) +sitemap/sitemap.xml sitemapindex (항상 비압축) +sitemap/sitemap-{n}.xml 자식 sitemap (gzip 시 .xml.gz) +sitemap/_tmp/ 생성 중 임시 디렉토리 (커밋 시 정리) +``` + +**라우트**: `/sitemap.xml`(인덱스) · `/sitemap-{n}.xml`, `/sitemap-{n}.xml.gz`(자식). 자식 경로는 `SitemapFileStore::childUrl()` 이 인덱스 `` 에 기록하는 값과 일치해야 합니다. gzip 여부는 manifest 가 결정하므로 두 경로를 같은 액션이 처리합니다. + +**서빙 시맨틱** (요청 스레드에서 생성하지 않음): + +| 상태 | 동작 | +|------|------| +| 메타 캐시 신선 + 세트 존재 | 디스크 세트 스트리밍 | +| 메타 캐시 만료 | `GenerateSitemapJob` 디스패치 후 기존 세트 서빙 (`sitemap_serve_stale_on_miss=true`) | +| 메타 캐시 만료 + stale 서빙 off | 잡 디스패치 후 503 + `Retry-After: 120` | +| 세트 전무 (신규 설치) | 잡 디스패치 후 503 + `Retry-After: 120` | + +봇 요청 스레드에서 동기 생성하면 대용량(수백만 URL)에서 메모리 초과·타임아웃이 발생하므로 생성은 항상 큐가 담당합니다. 잡은 유니크 락(`seo-sitemap`)을 쓰므로 캐시 미스가 몰려도 동시에 여러 건이 실행되지 않습니다. + +**분할 기준**: `sitemap_urls_per_file`(기본 50000) 또는 파일 크기 임계(`SitemapWriter::MAX_FILE_BYTES`, 45MB) 중 먼저 도달하는 쪽. 둘 다 sitemaps.org 프로토콜 제한(50,000 URL / 50MB)을 지키기 위한 것입니다. + +**관리자 수동 재생성**: `POST /api/admin/seo/sitemap/regenerate` 는 큐에 예약만 하고 즉시 응답합니다(`status: dispatched`). 완료 여부는 `sitemap_last_updated_at` 갱신으로 확인합니다. + ## 설정값 (코어 seo.*) | 키 | 타입 | 기본값 | 설명 | @@ -641,7 +672,11 @@ php artisan seo:generate-sitemap --sync # Sitemap 동기 생성 | cache_enabled | boolean | true | SEO 캐시 ON/OFF | | cache_ttl | integer | 7200 | 캐시 TTL (초) | | sitemap_enabled | boolean | true | sitemap.xml 생성 ON/OFF | -| sitemap_cache_ttl | integer | 86400 | Sitemap 캐시 TTL (초) | +| sitemap_cache_ttl | integer\|null | null | Sitemap 캐시 TTL 오버라이드 (초). null=고급 탭 `cache.seo_sitemap_ttl` 을 따름 | +| sitemap_urls_per_file | integer | 50000 | 자식 파일당 URL 수 (분할 기준). 1000~50000, 상한은 sitemaps.org 프로토콜 제한 | +| sitemap_gzip | boolean | false | 자식 파일 gzip 압축. 인덱스 파일은 항상 비압축 | +| sitemap_serve_stale_on_miss | boolean | true | 신선도 만료 시 기존 세트를 그대로 서빙(true) / 503 반환(false) | +| sitemap_max_urls_per_contributor | integer | 0 | 수집기당 URL 상한 (0=무제한). 초과 시 경고 로그 + truncate | | sitemap_schedule | string | "daily" | 생성 주기 (hourly/daily/weekly) | | sitemap_schedule_time | string | "02:00" | 생성 시각 | | og_default_site_name | string | "" | og:site_name 기본값. 비면 `general.site_name` fallback | diff --git a/docs/testing-guide.md b/docs/testing-guide.md index 6c8bf5b0..8333db3b 100644 --- a/docs/testing-guide.md +++ b/docs/testing-guide.md @@ -14,12 +14,14 @@ 5. 테스트 실행 중 발견한 무관 에러도 같은 세션에서 함께 처리 (stale test 수정 or 로직 수정) 6. 릴리스 전 composer test-smoke 통과 필수 (Installation 스위트) 7. 백엔드: php artisan test --filter=TestName / _bundled 확장은 _bundled에서 직접 실행 +8. settings 디스크는 TestCase 가 전역 페이크 — 실제 설정 파일 오염 차단. 저장 직후 값 검증은 g7_core_settings() 말고 ConfigRepository::getCategory() ``` --- ## 목차 +- [실제 환경 파일 보호 (settings 디스크 격리)](#실제-환경-파일-보호-settings-디스크-격리) - [기능 단위 시나리오 매트릭스](#기능-단위-시나리오-매트릭스) - [도메인별 테스트 전략 매트릭스](#도메인별-테스트-전략-매트릭스) - [Pre-release Smoke Suite](#pre-release-smoke-suite) @@ -211,6 +213,41 @@ beforeRefreshingDatabase()는 RefreshDatabase 트레이트가 부모 클래스 --- +## 실제 환경 파일 보호 (settings 디스크 격리) + +`settings` 디스크의 root 는 `storage/app/settings` — 개발/운영이 실제로 사용하는 설정 파일 그 자체다. 설정 저장 경로를 타는 테스트(설정 API 호출, `ConfigRepository::saveCategory()`)는 이 파일을 그대로 덮어쓰며, `RefreshDatabase` 는 DB 만 되돌리므로 오염이 잔류한다. + +`Tests\TestCase` 가 앱 부팅 직후 `Storage::fake('settings')` 로 전역 격리한다. 테스트에서 별도 조치는 필요 없다. + +```text +✅ 설정 저장 테스트 = 추가 조치 불필요 (TestCase 가 이미 격리) +❌ Storage::fake('settings') 를 테스트마다 중복 호출 — 불필요 +❌ 격리를 우회해 storage/app/settings 를 직접 읽고/쓰기 — 실제 환경 오염 +``` + +### 저장 직후 값 검증 + +`g7_core_settings()` / `config('g7_settings.*')` 는 **부팅 시점에 적재된 값**이라 같은 프로세스에서 저장 직후에는 갱신되지 않는다. 저장 결과를 검증하려면 저장소를 직접 읽는다. + +```php +// ❌ 저장했는데 부팅 시점 값이 나온다 +$this->assertSame(20000, g7_core_settings('seo.sitemap_urls_per_file')); + +// ✅ 저장소 실제 값 조회 +$seo = app(ConfigRepositoryInterface::class)->getCategory('seo'); +$this->assertSame(20000, $seo['sitemap_urls_per_file']); +``` + +### 배경 (회귀 사례) + +격리 도입 전에는 `Storage::fake('settings')` 를 쓰는 테스트가 1개뿐이라, 설정 저장 테스트를 돌릴 때마다 개발 환경의 실제 설정이 테스트 값으로 대체됐다. 실측 피해: `general.json` 의 사이트명이 `"Test"`, 언어가 `ja` 로 바뀌어 **개발 사이트 언어가 일본어로 전환**되어 있었고, SEO/캐시 TTL 도 테스트 값으로 덮여 운영자가 지정한 값이 사라졌다. 테스트는 계속 green 이라 조용히 누적됐다. + +회귀 가드: `tests/Feature/Settings/SettingsDiskIsolationTest.php` (격리 해제 시 FAIL). + +`Storage::fake()` 는 **해석된 디스크 인스턴스만 교체**하고 `config('filesystems.disks.settings.root')` 는 실경로로 남는다. 격리 여부를 판정할 때는 `Storage::disk('settings')->path('')` 를 봐야 한다. + +--- + ## 기능 단위 시나리오 매트릭스 ```text diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md index ed44ef3a..d685ff80 100644 --- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md @@ -9,6 +9,8 @@ ### Added - 사이트맵 생성이 실패했을 때 표시되는 안내 문구 일본어 번역 추가 (`exceptions.seo.*`) — 사이트맵 파일을 저장하지 못하거나 압축에 실패한 경우의 안내가 일본어 로케일에서 자연스럽게 표시됩니다. +- 사이트맵 재생성 예약·준비 중·생성 실패 안내 문구 일본어 번역 추가 (`seo.sitemap_*`) — 재생성을 예약했을 때, 아직 준비 중일 때, 생성에 실패했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다. +- SEO 설정의 사이트맵 분할 기준·압축·수집기 상한과 SEO/사이트맵 캐시 유지 시간 항목의 검증 안내 문구 일본어 번역 추가 (`validation.settings.*`) — 잘못된 값을 입력했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다. ## [1.0.4] - 2026-07-16 diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/activity_log.php b/lang-packs/_bundled/g7-core-ja/backend/ja/activity_log.php index 0bfaa072..1652e3d4 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/activity_log.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/activity_log.php @@ -4,12 +4,12 @@ return [ 'fetch_success' => 'アクティビティログ情報を正常に取得しました。', 'fetch_failed' => 'アクティビティログ情報の取得に失敗しました。', 'delete_success' => 'アクティビティログが削除されました。', - 'bulk_delete_success' => '選択したアクティビティログが削除されました。', + 'bulk_delete_success' => '選択されたアクティビティログが削除されました。', 'delete_failed' => 'アクティビティログの削除に失敗しました。', 'validation' => [ 'ids_required' => '削除するアクティビティログを選択してください。', 'ids_min' => '削除するアクティビティログを1つ以上選択してください。', - 'id_not_found' => '選択したアクティビティログが見つかりません。', + 'id_not_found' => '選択されたアクティビティログが見つかりません。', ], 'type' => [ 'admin' => '管理者', @@ -28,13 +28,13 @@ return [ 'login_failed' => 'ログイン失敗', 'account_locked' => 'アカウントロック', 'request' => 'リクエスト', - 'expired' => '期限切れ', + 'expired' => '有効期限切れ', 'verify' => '本人認証', 'verify_failed' => '本人認証失敗', 'export' => 'エクスポート', 'import' => 'インポート', - 'index' => 'リスト表示', - 'show' => '詳細表示', + 'index' => 'リスト閲覧', + 'show' => '詳細閲覧', 'search' => '検索', 'install' => 'インストール', 'activate' => '有効化', @@ -62,131 +62,133 @@ return [ 'withdraw' => '退会', 'update_order' => '順序変更', 'refresh_layouts' => 'レイアウト更新', - 'version_update' => 'バージョン更新', + 'version_update' => 'バージョンアップデート', 'version_restore' => 'バージョン復元', 'reset' => '初期化', ], 'description' => [ - 'user_index' => 'ユーザー一覧閲覧', - 'user_create' => 'ユーザー作成(ID::user_id)', - 'user_show' => 'ユーザー詳細閲覧(ID::user_id)', - 'user_update' => 'ユーザー編集(ID::user_id)', - 'user_delete' => 'ユーザー削除(ID::user_id)', - 'user_withdraw' => 'ユーザー退会(ID::user_id)', + 'user_index' => 'ユーザーリスト閲覧', + 'user_create' => 'ユーザー作成 (ID: :user_id)', + 'user_show' => 'ユーザー詳細閲覧 (ID: :user_id)', + 'user_update' => 'ユーザー編集 (ID: :user_id)', + 'user_delete' => 'ユーザー削除 (ID: :user_id)', + 'user_withdraw' => 'ユーザー退会 (ID: :user_id)', 'user_statistics' => 'ユーザー統計閲覧', - 'user_recent' => '最近のユーザー閲覧', + 'user_recent' => '最新ユーザー閲覧', 'user_search' => 'ユーザー検索', 'user_check_email' => 'メールアドレス重複確認', 'user_update_language' => 'ユーザー言語設定変更', - 'user_bulk_update_status' => 'ユーザー一括ステータス変更(:count件)', - 'role_index' => 'ロール一覧閲覧', - 'role_active' => '有効ロール一覧閲覧', - 'role_show' => 'ロール詳細閲覧(ID::role_id)', - 'role_create' => 'ロール作成(ID::role_id)', - 'role_update' => 'ロール編集(ID::role_id)', - 'role_delete' => 'ロール削除(ID::role_id)', - 'role_toggle_status' => 'ロールステータス切り替え(ID::role_id)', - 'role_sync_permissions' => 'ロール権限同期(ID::role_id)', - 'permission_index' => '権限一覧閲覧', - 'menu_index' => 'メニュー一覧閲覧', - 'menu_hierarchy' => 'メニュー階層閲覧', - 'menu_active' => '有効メニュー一覧閲覧', - 'menu_show' => 'メニュー詳細閲覧(ID::menu_id)', - 'menu_create' => 'メニュー作成(ID::menu_id)', - 'menu_update' => 'メニュー編集(ID::menu_id)', - 'menu_delete' => 'メニュー削除(ID::menu_id)', - 'menu_update_order' => 'メニュー順序変更', - 'menu_toggle_status' => 'メニューステータス切り替え(ID::menu_id)', - 'menu_sync_roles' => 'メニュー役割同期(ID::menu_id)', - 'menu_get_by_extension' => '拡張別メニュー閲覧(:extension_type::extension_identifier)', - 'settings_index' => '設定一覧閲覧', - 'settings_save' => '設定保存', - 'settings_show' => '設定閲覧', - 'settings_update' => '設定編集', - 'settings_system_info' => 'システム情報閲覧', - 'settings_clear_cache' => 'キャッシュ初期化', + 'user_bulk_update_status' => 'ユーザー一括ステータス変更 (:count件)', + 'role_index' => 'ロールリスト閲覧', + 'role_active' => '有効なロールリスト閲覧', + 'role_show' => 'ロール詳細閲覧 (ID: :role_id)', + 'role_create' => 'ロール作成 (ID: :role_id)', + 'role_update' => 'ロール編集 (ID: :role_id)', + 'role_delete' => 'ロール削除 (ID: :role_id)', + 'role_toggle_status' => 'ロールステータス変更 (ID: :role_id)', + 'role_sync_permissions' => 'ロール権限同期 (ID: :role_id)', + 'permission_index' => '権限リスト閲覧', + 'menu_index' => 'メニュー一覧の閲覧', + 'menu_hierarchy' => 'メニュー階層の閲覧', + 'menu_active' => '有効なメニュー一覧の閲覧', + 'menu_show' => 'メニュー詳細の閲覧 (ID: :menu_id)', + 'menu_create' => 'メニューの作成 (ID: :menu_id)', + 'menu_update' => 'メニューの編集 (ID: :menu_id)', + 'menu_delete' => 'メニューの削除 (ID: :menu_id)', + 'menu_update_order' => 'メニューの順序変更', + 'menu_toggle_status' => 'メニュー状態の切り替え (ID: :menu_id)', + 'menu_sync_roles' => 'メニューロールの同期 (ID: :menu_id)', + 'menu_get_by_extension' => '拡張別メニューの閲覧 (:extension_type: :extension_identifier)', + 'settings_index' => '設定一覧の閲覧', + 'settings_save' => '設定の保存', + 'settings_show' => '設定の閲覧', + 'settings_update' => '設定の編集', + 'settings_system_info' => 'システム情報の閲覧', + 'settings_clear_cache' => 'キャッシュの初期化', 'settings_optimize_system' => 'システム最適化', - 'settings_backup_database' => 'データベースバックアップ', - 'settings_get_app_key' => 'アプリキー閲覧', - 'settings_app_key_regenerated' => 'アプリキー再生成', - 'settings_backup' => 'バックアップ作成', - 'settings_restore' => 'バックアップ復元', + 'settings_backup_database' => 'データベースのバックアップ', + 'settings_get_app_key' => 'アプリケーションキーの閲覧', + 'settings_app_key_regenerated' => 'アプリケーションキーの再生成', + 'settings_backup' => 'バックアップの作成', + 'settings_restore' => 'バックアップの復元', 'settings_test_mail' => 'メール送信テスト', - 'settings_test_driver_connection' => 'ドライバ接続テスト', + 'settings_test_driver_connection' => 'ドライバー接続テスト', 'auth_login' => '管理者ログイン', 'auth_logout' => '管理者ログアウト', 'auth_register' => '会員登録', - 'auth_forgot_password' => 'パスワード再設定申請', - 'auth_reset_password' => 'パスワード再設定', - 'auth_record_consents' => '利用規約同意履歴', - 'schedule_index' => 'スケジュール一覧閲覧', - 'schedule_show' => 'スケジュール詳細閲覧(ID::schedule_id)', - 'schedule_create' => 'スケジュール作成(ID::schedule_id)', - 'schedule_update' => 'スケジュール編集(ID::schedule_id)', - 'schedule_delete' => 'スケジュール削除(ID::schedule_id)', - 'schedule_run' => 'スケジュール手動実行(ID::schedule_id)', - 'schedule_duplicate' => 'スケジュール複製', - 'schedule_bulk_update_status' => 'スケジュール一括ステータス変更(:count件)', - 'schedule_bulk_delete' => 'スケジュール一括削除(:count件)', - 'schedule_statistics' => 'スケジュール統計閲覧', - 'schedule_history' => 'スケジュール実行履歴閲覧(ID::schedule_id)', - 'schedule_delete_history' => 'スケジュール履歴削除', - 'dashboard_stats' => 'ダッシュボード統計閲覧', - 'dashboard_resources' => 'ダッシュボードリソース閲覧', - 'dashboard_activities' => 'ダッシュボードアクティビティ閲覧', - 'dashboard_alerts' => 'ダッシュボード通知閲覧', + 'auth_forgot_password' => 'パスワード忘却リクエスト', + 'auth_reset_password' => 'パスワードリセット', + 'auth_record_consents' => '利用規約同意記録', + 'auth_login_failed' => 'ログイン失敗 (:email)', + 'auth_account_locked' => 'アカウントロック (:attempts回失敗、:minutes分ブロック)', + 'schedule_index' => 'スケジュール一覧の閲覧', + 'schedule_show' => 'スケジュール詳細の閲覧 (ID: :schedule_id)', + 'schedule_create' => 'スケジュールの作成 (ID: :schedule_id)', + 'schedule_update' => 'スケジュールの編集 (ID: :schedule_id)', + 'schedule_delete' => 'スケジュールの削除 (ID: :schedule_id)', + 'schedule_run' => 'スケジュールの手動実行 (ID: :schedule_id)', + 'schedule_duplicate' => 'スケジュールの複製', + 'schedule_bulk_update_status' => 'スケジュール一括ステータス変更 (:count件)', + 'schedule_bulk_delete' => 'スケジュール一括削除 (:count件)', + 'schedule_statistics' => 'スケジュール統計の閲覧', + 'schedule_history' => 'スケジュール実行履歴の閲覧 (ID: :schedule_id)', + 'schedule_delete_history' => 'スケジュール履歴の削除', + 'dashboard_stats' => 'ダッシュボード統計の閲覧', + 'dashboard_resources' => 'ダッシュボードリソースの閲覧', + 'dashboard_activities' => 'ダッシュボードアクティビティの閲覧', + 'dashboard_alerts' => 'ダッシュボード通知の閲覧', 'attachment_upload' => 'ファイルアップロード', 'attachment_upload_batch' => 'ファイル一括アップロード', - 'attachment_delete' => 'ファイル削除', + 'attachment_delete' => 'ファイルの削除', 'attachment_bulk_delete' => 'ファイル一括削除', - 'attachment_reorder' => 'ファイル順序変更', - 'layout_index' => 'レイアウト一覧取得', - 'layout_show' => 'レイアウト詳細取得 (:layout_path)', - 'layout_update' => 'レイアウト編集 (:layout_path)', - 'layout_versions_index' => 'レイアウトバージョン一覧取得 (:layout_path)', - 'layout_version_show' => 'レイアウトバージョン詳細取得 (:layout_path)', - 'layout_version_restore' => 'レイアウトバージョン復元 (:layout_path)', - 'mail_template_update' => 'メールテンプレート編集 (:template_name)', - 'mail_template_toggle_active' => 'メールテンプレート有効状態変更 (:template_name)', - 'module_index' => 'モジュール一覧取得', - 'module_installed' => 'インストール済みモジュール一覧取得', - 'module_uninstalled' => '未インストールモジュール一覧取得', - 'module_show' => 'モジュール詳細取得 (:module_name)', - 'module_install' => 'モジュールインストール (:module_name)', - 'module_activate' => 'モジュール有効化 (:module_name)', - 'module_deactivate' => 'モジュール無効化 (:module_name)', - 'module_uninstall' => 'モジュール削除 (:module_name)', - 'module_uninstall_info' => 'モジュール削除情報取得 (モジュール: :module_name)', - 'module_check_updates' => 'モジュールアップデート確認', - 'module_update' => 'モジュールアップデート (:module_name)', - 'module_refresh_layouts' => 'モジュールレイアウト更新 (:module_name)', - 'module_dependent_templates' => 'モジュール依存テンプレート取得', + 'attachment_reorder' => 'ファイルの順序変更', + 'layout_index' => 'レイアウト一覧の閲覧', + 'layout_show' => 'レイアウト詳細の閲覧 (:layout_path)', + 'layout_update' => 'レイアウトの編集 (:layout_path)', + 'layout_versions_index' => 'レイアウトバージョン一覧の閲覧 (:layout_path)', + 'layout_version_show' => 'レイアウトバージョン詳細の閲覧 (:layout_path)', + 'layout_version_restore' => 'レイアウトバージョンの復元 (:layout_path)', + 'mail_template_update' => 'メールテンプレートの編集 (:template_name)', + 'mail_template_toggle_active' => 'メールテンプレートの有効状態変更 (:template_name)', + 'module_index' => 'モジュール一覧の閲覧', + 'module_installed' => 'インストール済みモジュール一覧の閲覧', + 'module_uninstalled' => '未インストールモジュール一覧の閲覧', + 'module_show' => 'モジュール詳細の閲覧 (:module_name)', + 'module_install' => 'モジュールのインストール (:module_name)', + 'module_activate' => 'モジュールの有効化 (:module_name)', + 'module_deactivate' => 'モジュールの無効化 (:module_name)', + 'module_uninstall' => 'モジュールの削除 (:module_name)', + 'module_uninstall_info' => 'モジュール削除情報の閲覧 (モジュール: :module_name)', + 'module_check_updates' => 'モジュールアップデートの確認', + 'module_update' => 'モジュールのアップデート (:module_name)', + 'module_refresh_layouts' => 'モジュールレイアウトの更新 (:module_name)', + 'module_dependent_templates' => 'モジュール依存テンプレートの閲覧', 'module_install_from_file' => 'ファイルからモジュールをインストール', 'module_install_from_github' => 'GitHubからモジュールをインストール', - 'module_settings_save' => 'モジュール設定保存 (:module_name)', - 'module_settings_reset' => 'モジュール設定初期化 (:module_name)', - 'plugin_index' => 'プラグイン一覧取得', - 'plugin_installed' => 'インストール済みプラグイン一覧取得', - 'plugin_show' => 'プラグイン詳細取得 (:plugin_name)', + 'module_settings_save' => 'モジュール設定の保存 (:module_name)', + 'module_settings_reset' => 'モジュール設定の初期化 (:module_name)', + 'plugin_index' => 'プラグイン一覧の閲覧', + 'plugin_installed' => 'インストール済みプラグイン一覧の閲覧', + 'plugin_show' => 'プラグイン詳細閲覧 (:plugin_name)', 'plugin_install' => 'プラグインインストール (:plugin_name)', 'plugin_activate' => 'プラグイン有効化 (:plugin_name)', 'plugin_deactivate' => 'プラグイン無効化 (:plugin_name)', 'plugin_uninstall' => 'プラグイン削除 (:plugin_name)', - 'plugin_uninstall_info' => 'プラグイン削除情報取得 (プラグイン: :plugin_name)', + 'plugin_uninstall_info' => 'プラグイン削除情報閲覧 (プラグイン: :plugin_name)', 'plugin_check_updates' => 'プラグインアップデート確認', 'plugin_update' => 'プラグインアップデート (:plugin_name)', 'plugin_refresh_layouts' => 'プラグインレイアウト更新 (:plugin_name)', - 'plugin_dependent_templates' => 'プラグイン依存テンプレート取得', + 'plugin_dependent_templates' => 'プラグイン依存テンプレート閲覧', 'plugin_settings_save' => 'プラグイン設定保存 (:plugin_name)', 'plugin_settings_reset' => 'プラグイン設定初期化 (:plugin_name)', - 'template_index' => 'テンプレート一覧取得', - 'template_show' => 'テンプレート詳細取得 (:template_name)', + 'template_index' => 'テンプレート一覧の閲覧', + 'template_show' => 'テンプレート詳細閲覧 (:template_name)', 'template_install' => 'テンプレートインストール (:template_name)', 'template_activate' => 'テンプレート有効化 (:template_name)', 'template_deactivate' => 'テンプレート無効化 (:template_name)', 'template_uninstall' => 'テンプレート削除 (:template_name)', 'template_check_updates' => 'テンプレートアップデート確認', - 'template_check_modified_layouts' => 'テンプレート編集済みレイアウト確認 (:template_name)', + 'template_check_modified_layouts' => 'テンプレート修正されたレイアウト確認 (:template_name)', 'template_update' => 'テンプレートアップデート (:template_name)', 'template_version_update' => 'テンプレートバージョンアップデート (:template_name)', 'template_install_from_file' => 'ファイルからテンプレートをインストール', @@ -194,11 +196,9 @@ return [ 'template_refresh_layouts' => 'テンプレートレイアウト更新 (:template_name)', 'core_update_check' => 'コアアップデート確認', 'core_update_update' => 'コアアップデート実行', - 'activity_log_index' => 'アクティビティログ一覧取得', + 'activity_log_index' => 'アクティビティログ一覧の閲覧', 'activity_log_delete' => 'アクティビティログ削除 (ID: :log_id)', 'activity_log_bulk_delete' => 'アクティビティログ一括削除 (:count件)', - 'auth_login_failed' => 'ログイン失敗 (:email)', - 'auth_account_locked' => 'アカウントロック (:attempts回の失敗、:minutes分間ブロック)', ], 'fields' => [ 'name' => '名前', @@ -208,7 +208,7 @@ return [ 'timezone' => 'タイムゾーン', 'country' => '国', 'status' => 'ステータス', - 'is_super' => 'スーパー管理者', + 'is_super' => '最高管理者', 'homepage' => 'ホームページ', 'mobile' => '携帯電話', 'phone' => '電話番号', @@ -218,10 +218,10 @@ return [ 'bio' => '紹介', 'admin_memo' => '管理者メモ', 'identifier' => '識別子', - 'is_active' => '有効化', + 'is_active' => '有効性', 'url' => 'URL', 'icon' => 'アイコン', - 'parent_id' => '親項目', + 'parent_id' => '上位項目', 'order' => '順序', 'command' => 'コマンド', 'expression' => '実行周期', diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/admin_layout.php b/lang-packs/_bundled/g7-core-ja/backend/ja/admin_layout.php index 35983c14..dd390b88 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/admin_layout.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/admin_layout.php @@ -19,60 +19,60 @@ return [ 'success' => 'admin_layout.json 検証成功', 'failed' => 'admin_layout.json 検証失敗', 'exception' => 'admin_layout.json 検証中に例外が発生しました', - 'version_error' => 'スキーマバージョン情報の取得に失敗しました', + 'version_error' => 'スキーマバージョン情報取得失敗', ], ], 'loader' => [ 'validation_failed' => 'モジュール :module のレイアウト検証に失敗しました: :errors', 'file_not_found' => 'レイアウトファイルが見つかりません: :path', 'json_parse_error' => 'JSON パースエラー (:file): :error', - 'file_load_error' => 'ファイル読み込み中にエラーが発生しました (:path): :error', + 'file_load_error' => 'ファイルロード中にエラーが発生しました (:path): :error', 'log' => [ - 'cache_hit' => 'キャッシュからレイアウトデータを読み込みました', + 'cache_hit' => 'キャッシュからレイアウトデータをロード', 'cache_invalidated' => 'キャッシュが無効化されました', - 'loaded' => 'レイアウトファイル読み込み完了', - 'cached' => 'レイアウトデータをキャッシュに保存しました', - 'cache_manually_invalidated' => 'キャッシュを手動で無効化しました', - 'all_cache_invalidated' => 'すべてのキャッシュを無効化しました', - 'file_load_error' => 'ファイル読み込みエラー', + 'loaded' => 'レイアウトファイルのロード完了', + 'cached' => 'レイアウトデータをキャッシュに保存', + 'cache_manually_invalidated' => 'キャッシュ手動無効化', + 'all_cache_invalidated' => 'すべてのキャッシュを無効化', + 'file_load_error' => 'ファイルロードエラー', ], ], 'command' => [ - 'starting' => 'admin_layout.json ファイルの検証を開始します', + 'starting' => 'admin_layout.json ファイル検証を開始します', 'validating_module' => 'モジュール :module を検証中...', 'validating_all_modules' => ':count 個のモジュール検証を開始します', 'module_layout_not_found' => 'モジュール :module の admin_layout.json ファイルが見つかりません', - 'module_valid' => 'モジュール :module の検証成功', - 'module_invalid' => 'モジュール :module の検証失敗: :error', + 'module_valid' => 'モジュール :module 検証成功', + 'module_invalid' => 'モジュール :module 検証失敗: :error', 'unexpected_error' => 'モジュール :module で予期しないエラーが発生しました: :error', 'modules_directory_not_found' => 'modules ディレクトリが見つかりません', 'no_modules_found' => '検証するモジュールがありません', - 'module_summary' => 'モジュール :module の概要', + 'module_summary' => 'モジュール :module サマリー', 'menu_name' => 'メニュー名', 'menu_route' => 'ルート', 'data_sources_count' => 'データソース数', 'layout_type' => 'レイアウトタイプ', - 'validation_summary' => '検証結果の概要', + 'validation_summary' => '検証結果サマリー', 'valid_modules_count' => '有効なモジュール', 'invalid_modules_count' => '無効なモジュール', 'missing_layouts_count' => 'レイアウトファイルなし', - 'invalid_modules_details' => '無効なモジュールの詳細:', + 'invalid_modules_details' => '無効なモジュール詳細:', 'missing_layouts_details' => 'レイアウトファイルがないモジュール:', 'cache_stats' => 'キャッシュ統計', 'total_cached_modules' => 'キャッシュされたモジュール数', 'cache_expiration' => 'キャッシュ有効期限', - 'cached_modules_details' => 'キャッシュされたモジュールの詳細:', + 'cached_modules_details' => 'キャッシュされたモジュール詳細:', 'clearing_cache' => 'キャッシュを無効化中...', 'cache_cleared' => 'キャッシュが無効化されました', ], 'success' => [ - 'loaded' => 'admin_layout.json の読み込みに成功しました', + 'loaded' => 'admin_layout.json を正常に読み込みました', 'cached' => 'レイアウトデータがキャッシュに保存されました', ], 'error' => [ 'module_not_found' => '要求されたモジュールが見つかりません', 'json_parse_error' => 'JSON パース中にエラーが発生しました', - 'file_not_readable' => 'ファイルを読めません', + 'file_not_readable' => 'ファイルを読み込めません', 'json_parse_failed' => 'JSON パースに失敗しました', 'validation_failed' => 'レイアウト検証に失敗しました', 'server_error' => 'サーバー内部エラーが発生しました', @@ -80,14 +80,14 @@ return [ 'log' => [ 'cache_hit' => 'キャッシュからレイアウトデータを取得しました', 'cache_updated' => 'キャッシュが更新されました', - 'layout_loaded' => 'レイアウトが正常に読み込まれました', + 'layout_loaded' => 'レイアウトが正常にロードされました', 'validation_completed' => 'レイアウト検証が完了しました', - 'layout_load_error' => 'レイアウト読み込み中にエラーが発生しました', + 'layout_load_error' => 'レイアウトロード中にエラーが発生しました', 'validation_error' => 'レイアウト検証中にエラーが発生しました', - 'service_cache_invalidated' => 'サービスでキャッシュを無効化しました', - 'service_all_cache_invalidated' => 'サービスですべてのキャッシュを無効化しました', + 'service_cache_invalidated' => 'サービスからキャッシュを無効化しました', + 'service_all_cache_invalidated' => 'サービスからすべてのキャッシュを無効化しました', 'cache_invalidated' => 'キャッシュが無効化されました', 'all_cache_invalidated' => 'すべてのキャッシュが無効化されました', - 'file_read_error' => 'ファイル読み取り中にエラーが発生しました', + 'file_read_error' => 'ファイル読み込み中にエラーが発生しました', ], ]; diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/attachment.php b/lang-packs/_bundled/g7-core-ja/backend/ja/attachment.php index 0b59c0fc..1b63d4fc 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/attachment.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/attachment.php @@ -13,18 +13,18 @@ return [ ], 'default_policy' => [ 'public' => '公開 (すべての認証ユーザー)', - 'owner_only' => 'アップローダー/管理者のみ', + 'owner_only' => 'アップロード者/管理者のみ', 'read_only' => '読み取り専用', ], 'validation' => [ 'file_required' => 'ファイルを選択してください。', 'file_invalid' => '有効なファイルではありません。', - 'file_max' => 'ファイルサイズは :maxMB を超えることはできません。', + 'file_max' => 'ファイルサイズは :maxMB を超えることができません。', 'files_required' => 'ファイルを選択してください。', 'files_array' => 'ファイル形式が正しくありません。', 'files_min' => '最低1つ以上のファイルを選択してください。', - 'type_invalid' => '添付対象タイプが正しくありません。', - 'id_invalid' => '添付対象IDが正しくありません。', + 'type_invalid' => '添付対象のタイプが正しくありません。', + 'id_invalid' => '添付対象のIDが正しくありません。', 'role_ids_required' => 'ロールを選択してください。', 'role_ids_invalid' => 'ロール形式が正しくありません。', 'role_id_integer' => 'ロールIDは整数である必要があります。', @@ -41,17 +41,17 @@ return [ ], 'upload_success' => 'ファイルがアップロードされました。', 'upload_batch_success' => 'ファイルが一括アップロードされました。', - 'upload_failed' => 'ファイルアップロードに失敗しました。', + 'upload_failed' => 'ファイルのアップロードに失敗しました。', 'delete_success' => 'ファイルが削除されました。', - 'delete_failed' => 'ファイル削除に失敗しました。', - 'reorder_success' => 'ファイル順序が変更されました。', - 'reorder_failed' => 'ファイル順序の変更に失敗しました。', + 'delete_failed' => 'ファイルの削除に失敗しました。', + 'reorder_success' => 'ファイルの順序が変更されました。', + 'reorder_failed' => 'ファイルの順序変更に失敗しました。', 'sync_roles_success' => 'ロールが同期されました。', 'sync_roles_failed' => 'ロール同期に失敗しました。', 'sync_permissions_success' => '権限が同期されました。', 'sync_permissions_failed' => '権限同期に失敗しました。', 'not_found' => '添付ファイルが見つかりません。', 'access_denied' => 'この添付ファイルへのアクセス権限がありません。', - 'update_denied' => 'この添付ファイルの編集権限がありません。', - 'delete_denied' => 'この添付ファイルの削除権限がありません。', + 'update_denied' => 'この添付ファイルへの編集権限がありません。', + 'delete_denied' => 'この添付ファイルへの削除権限がありません。', ]; diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/auth.php b/lang-packs/_bundled/g7-core-ja/backend/ja/auth.php index 0da68689..f942a5dc 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/auth.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/auth.php @@ -1,16 +1,16 @@ 'ログインに成功しました。', + 'login_success' => 'ログインが成功しました。', 'login_failed' => '認証に失敗しました。', - 'register_success' => '会員登録に成功しました。', + 'register_success' => '会員登録が成功しました。', 'register_failed' => '会員登録に失敗しました。', - 'logout_success' => 'ログアウトに成功しました。', + 'logout_success' => 'ログアウトが成功しました。', 'logout_all_devices_success' => 'すべてのデバイスからログアウトされました。', 'password_reset_success' => 'パスワードが正常にリセットされました。', 'password_reset_failed' => 'パスワードのリセットに失敗しました。', 'invalid_credentials' => '無効な認証情報です。', - 'admin_login_success' => '管理者ログインに成功しました。', + 'admin_login_success' => '管理者ログインが成功しました。', 'admin_required' => '管理者権限が必要です。', 'unauthenticated' => '認証が必要です。', 'permission_denied' => '該当する権限がありません。', @@ -23,17 +23,17 @@ return [ 'scope_denied' => 'このリソースへのアクセス権限がありません。', 'scope_type_all' => 'すべて', 'scope_type_self' => '本人のみ', - 'scope_type_role' => '所有者ロール', + 'scope_type_role' => '所有ロール', 'invalid_token' => '無効な認証トークンです。', - 'token_expired' => '認証トークンが期限切れです。もう一度ログインしてください。', + 'token_expired' => '認証トークンが有効期限切れです。再度ログインしてください。', 'user_not_found' => 'ユーザーが見つかりません。', 'account_inactive' => '無効化されたアカウントです。管理者にお問い合わせください。', 'account_blocked' => 'ブロックされたアカウントです。管理者にお問い合わせください。', - 'account_withdrawn' => '退会済みのアカウントです。', - 'account_pending_verification' => '本人確認が完了していないアカウントです。メール認証を完了してください。', - 'reset_token_invalid' => '無効なパスワードリセットトークンです。', - 'reset_token_expired' => 'パスワードリセットトークンが期限切れです。再度リクエストしてください。', - 'password_reset_email_sent' => 'パスワードリセットメールが送信されました。', - 'email_send_failed' => 'メール送信に失敗しました。しばらく後にもう一度お試しください。', + 'account_withdrawn' => '退会したアカウントです。', + 'account_pending_verification' => '本人認証が完了していないアカウントです。メール認証を完了してください。', 'account_locked' => 'ログイン試行回数の超過によりアカウントがロックされました。:minutes分後に再度お試しください。', + 'reset_token_invalid' => '無効なパスワードリセットトークンです。', + 'reset_token_expired' => 'パスワードリセットトークンが有効期限切れです。再度リクエストしてください。', + 'password_reset_email_sent' => 'パスワードリセットメールが送信されました。', + 'email_send_failed' => 'メール送信に失敗しました。しばらく後に再度お試しください。', ]; diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/common.php b/lang-packs/_bundled/g7-core-ja/backend/ja/common.php index 09220a3c..b98a0f1b 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/common.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/common.php @@ -17,17 +17,17 @@ return [ 'unknown' => '不明', 'errors' => [ 'github_url_empty' => 'GitHub URLが空です。', - 'github_url_invalid' => '無効なGitHub URL形式です。', + 'github_url_invalid' => '無効なGitHub URLフォーマットです。', 'github_api_failed' => 'GitHub APIの呼び出しに失敗しました。', 'github_download_failed' => 'GitHubからのダウンロードに失敗しました。', 'github_archive_download_failed' => 'アーカイブのダウンロードに失敗しました。(:url)', 'zip_file_not_found' => 'ZIPファイルが見つかりません。', 'zip_open_failed' => 'ZIPファイルを開くことができません。', ], - 'validation_error' => '入力値バリデーションエラー', + 'validation_error' => '入力値のバリデーションエラー', 'changelog_validation' => [ 'source_in' => 'ソースはactive、bundled、githubのいずれかである必要があります。', - 'version_format' => ':attribute形式が正しくありません。(例: 1.0.0、1.0.0-beta.1)', + 'version_format' => ':attributeのフォーマットが正しくありません。(例:1.0.0、1.0.0-beta.1)', 'to_version_required' => '開始バージョンが指定されている場合、終了バージョンも必須です。', ], ]; diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/dashboard.php b/lang-packs/_bundled/g7-core-ja/backend/ja/dashboard.php index f34f0452..e84bab28 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/dashboard.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/dashboard.php @@ -1,14 +1,16 @@ 'ダッシュボード統計を正常に閲覧しました。', - 'stats_failed' => 'ダッシュボード統計の閲覧に失敗しました。', - 'resources_loaded' => 'システムリソース情報を正常に閲覧しました。', - 'resources_failed' => 'システムリソース情報の閲覧に失敗しました。', - 'activities_loaded' => '最近のアクティビティログを正常に閲覧しました。', - 'activities_failed' => '最近のアクティビティログの閲覧に失敗しました。', - 'alerts_loaded' => 'システム通知を正常に閲覧しました。', - 'alerts_failed' => 'システム通知の閲覧に失敗しました。', + 'stats_loaded' => 'ダッシュボード統計の照会に成功しました。', + 'stats_failed' => 'ダッシュボード統計の照会に失敗しました。', + 'resources_loaded' => 'システムリソース情報の照会に成功しました。', + 'resources_failed' => 'システムリソース情報の照会に失敗しました。', + 'activities_loaded' => '最近のアクティビティの照会に成功しました。', + 'activities_failed' => '最近のアクティビティの照会に失敗しました。', + 'alerts_loaded' => 'システム通知の照会に成功しました。', + 'alerts_failed' => 'システム通知の照会に失敗しました。', + 'recent_notifications_loaded' => '最近の通知の照会に成功しました。', + 'recent_notifications_failed' => '最近の通知の照会に失敗しました。', 'stats' => [ 'status_normal' => '正常', 'status_warning' => '警告', @@ -16,6 +18,4 @@ return [ 'activities' => [ 'user_registered' => ':name さんが会員登録しました。', ], - 'recent_notifications_loaded' => '最近の通知を正常に閲覧しました。', - 'recent_notifications_failed' => '最近の通知の閲覧に失敗しました。', ]; diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/errors.php b/lang-packs/_bundled/g7-core-ja/backend/ja/errors.php index 550eb9eb..ff973b11 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/errors.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/errors.php @@ -11,16 +11,16 @@ return [ ], '404' => [ 'title' => 'ページが見つかりません', - 'message' => 'リクエストされたページが存在しないか、移動されています。', + 'message' => '要求されたページが存在しないか、移動した可能性があります。', ], '500' => [ 'title' => 'サーバーエラーが発生しました', - 'message' => 'リクエストを処理する際に問題が発生しました。しばらく後に再度試してください。', + 'message' => 'リクエストを処理する際に問題が発生しました。しばらく後に再度お試しください。', ], '503' => [ - 'title' => 'サービス一時中断', - 'message' => '現在、このサービスは利用できません。しばらく後に再度試してください。', - 'unmet_dependencies' => '未解決の依存関係リスト', + 'title' => 'サービス一時停止', + 'message' => '現在サービスをご利用いただけません。しばらく後に再度お試しください。', + 'unmet_dependencies' => '未充足の依存性リスト', 'template' => 'テンプレート', 'modules' => 'モジュール', 'plugins' => 'プラグイン', @@ -29,7 +29,7 @@ return [ 'back_home' => 'ホームに戻る', 'bootstrap' => [ 'title' => '画面を読み込めませんでした', - 'message' => 'ネットワーク接続が不安定な可能性があります。更新後にもう一度お試しください。', + 'message' => 'ネットワーク接続が不安定な可能性があります。ページを更新してからもう一度お試しください。', 'reload' => '更新', ], ]; diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/exceptions.php b/lang-packs/_bundled/g7-core-ja/backend/ja/exceptions.php index 0e6628f8..c48f93b0 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/exceptions.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/exceptions.php @@ -3,22 +3,23 @@ return [ 'cannot_delete_super_admin' => 'スーパー管理者は削除できません。', 'circular_reference' => 'レイアウト循環参照を検出しました: :trace', - 'max_depth_exceeded' => 'レイアウトのネスト深度が最大許容深度(:max)を超えています。', + 'max_depth_exceeded' => 'レイアウトネストの深さが最大許容深度(:max)を超過しました。', 'template_file_copy_failed' => 'テンプレートファイルのコピーに失敗しました: :source → :destination', - 'template_build_directory_creation_failed' => 'テンプレートビルドディレクトリの作成に失敗しました: :path', + 'template_build_directory_creation_failed' => 'テンプレートビルドディレクトリの生成に失敗しました: :path', 'template_dist_directory_not_found' => 'テンプレートdistディレクトリが見つかりません: :path', 'template_not_found' => 'テンプレートが見つかりません: :identifier', 'template_not_active' => 'テンプレートが有効化されていません: :identifier (ステータス: :status)', + 'concurrent_modification' => '別のユーザーが先に保存しました。 (リソース: :resource、現在のバージョン: :current、送信されたバージョン: :expected)', 'layout' => [ 'duplicate_data_source_id' => 'data_sources IDが重複しています: :id', 'duplicate_data_source_id_in_file' => 'レイアウトファイル内のdata_sources IDが重複しています: :ids (ファイル: :file)', - 'duplicate_data_source_id_extends' => 'extends継承関係でdata_sources IDが重複しています: :ids (子: :child, 親: :parent)', + 'duplicate_data_source_id_extends' => 'extends継承関係のdata_sources IDが重複しています: :ids (子: :child、親: :parent)', 'not_found' => 'レイアウトが見つかりません: :name', - 'parent_not_found' => '親レイアウトが見つかりません: :parent (リクエストレイアウト: :child)', + 'parent_not_found' => '親レイアウトが見つかりません: :parent (要求レイアウト: :child)', 'include_file_not_found' => 'includeファイルが見つかりません: :path (解決されたパス: :resolved)', 'invalid_include_json' => 'includeファイルのJSON形式が正しくありません: :path (エラー: :error)', 'circular_include' => 'include循環参照が検出されました: :trace', - 'max_include_depth_exceeded' => 'includeの最大深度を超えています (最大: :max段階)', + 'max_include_depth_exceeded' => 'include最大深度を超過しました (最大: :max段階)', 'include_outside_directory' => 'includeパスが許可されたディレクトリ外です: :path (許可: :allowed_dir)', ], 'layout_version' => [ @@ -26,46 +27,45 @@ return [ 'save_failed_unexpected' => 'レイアウトバージョン保存中に予期しないエラーが発生しました。', ], 'settings' => [ - 'backup_creation_failed' => '設定バックアップファイルの作成に失敗しました。', + 'backup_creation_failed' => '設定バックアップファイルの生成に失敗しました。', 'restore_failed' => '設定の復元に失敗しました。', 'category_not_found' => '設定カテゴリが見つかりません: :category', 'save_failed' => '設定の保存に失敗しました: :category', - 'json_parse_failed' => 'JSON ファイルのパースに失敗しました: :path (:error)', - 'add_category_module_only' => 'addCategory はモジュールでのみ使用できます。', + 'json_parse_failed' => 'JSONファイルのパースに失敗しました: :path (:error)', + 'add_category_module_only' => 'addCategoryはモジュールからのみ使用できます。', ], 'core_update' => [ 'handoff' => 'アップグレードハンドオフ: :after_version まで完了 — :reason (再実行: :resume_command)', ], 'vendor' => [ 'composer_not_available' => 'Composerを実行できない環境です。バンドルモードを使用してください。', - 'composer_not_available_for_build' => 'vendor-bundleビルドはComposer実行が必要です。開発環境でComposerをインストール·設定した後、もう一度お試しください。', + 'composer_not_available_for_build' => 'vendor-bundleビルドはComposer実行が必要です。開発環境でComposerをインストール/設定した後、再度実行してください。', 'bundle_build_composer_failed' => 'vendor-bundleビルド中にcomposer installの実行に失敗しました (exit :exit): :message', + 'bundle_build_promote_failed' => 'vendor-bundleビルド結果を最終パスに反映できませんでした (:from → :to)', 'composer_execution_failed' => 'Composerの実行に失敗しました: :message', 'bundle_zip_missing' => 'vendor-bundle.zipファイルが見つかりません: :path', 'bundle_manifest_missing' => 'vendor-bundle.jsonファイルが見つかりません: :path', - 'bundle_manifest_invalid' => 'vendor-bundle.jsonファイルの読み込みに失敗しました: :error', - 'bundle_integrity_failed' => 'バンドルファイルの整合性検証に失敗しました: :details', + 'bundle_manifest_invalid' => 'vendor-bundle.jsonファイルを読み込めません: :error', + 'bundle_integrity_failed' => 'バンドルファイルの完全性検証に失敗しました: :details', 'bundle_schema_unsupported' => 'サポートされていないvendor-bundle.jsonスキーマバージョンです: :version', 'zip_archive_not_available' => 'PHP ZipArchive拡張がインストールされていません。', - 'zip_hash_mismatch' => 'vendor-bundle.zipのハッシュが一致しません (期待値: :expected, 実際: :actual)。', + 'zip_hash_mismatch' => 'vendor-bundle.zipのハッシュが一致しません (期待値: :expected、実際: :actual)。', 'composer_json_sha_mismatch' => 'composer.jsonがバンドルビルド時点以降に変更されました。vendor-bundle:buildで再ビルドしてください。', 'composer_lock_sha_mismatch' => 'composer.lockがバンドルビルド時点以降に変更されました。vendor-bundle:buildで再ビルドしてください。', 'bundle_contains_unsafe_path' => 'バンドルzipに安全でないファイルパスが含まれています: :path', 'extraction_failed' => 'バンドルzipの抽出に失敗しました: :message', - 'no_vendor_strategy_available' => 'バンドルのインストール戦略を決定できません。Composerとバンドルzipの両方が利用不可です。', + 'no_vendor_strategy_available' => 'Vendorインストール戦略を決定できません。Composerとバンドルzipのどちらも使用できません。', 'source_dir_not_found' => 'インストールソースディレクトリが見つかりません: :path', - 'target_not_writable' => '対象ディレクトリに書き込み権限がありません: :path', - 'target_not_writable_owner_hint' => '実行ユーザー(:current_user)とディレクトリ所有者(:owner)が異なります。\'chown -R :current_user_name :path\'を実行後に再試行するか、所有者(:owner_name)でSSHログイン後に実行してください。', + 'target_not_writable' => '対象ディレクトリへの書き込み権限がありません: :path', + 'target_not_writable_owner_hint' => '実行ユーザー(:current_user)とディレクトリ所有者(:owner)が異なります。\'chown -R :current_user_name :path\'を実行後に再度試すか、所有者(:owner_name)でSSHログイン後に実行してください。', 'composer_json_not_found' => 'composer.jsonファイルが見つかりません: :path', - 'no_composer_lock' => 'composer.lockファイルが必要です。まずcomposer installを実行してください: :path', - 'vendor_dir_not_found' => 'vendor/ディレクトリが存在しません。まずcomposer installを実行してください: :path', + 'no_composer_lock' => 'composer.lockファイルが必要です。最初にcomposer installを実行してください: :path', + 'vendor_dir_not_found' => 'vendor/ディレクトリが存在しません。最初にcomposer installを実行してください: :path', 'composer_binary_not_found' => 'Composerバイナリが見つかりません。COMPOSER_BINARY環境変数を設定するか、composerをPATHに追加してください。', - 'bundle_build_promote_failed' => 'vendor-bundle ビルド結果を最終パスに反映できませんでした (:from → :to)', ], - 'concurrent_modification' => '別のユーザーが先に保存しました。(リソース: :resource、現在のバージョン: :current、送信されたバージョン: :expected)', 'seo' => [ - 'sitemap_write_failed' => 'Sitemapファイル記録に失敗しました: :path', - 'sitemap_gzip_failed' => 'Sitemap子ファイル圧縮に失敗しました: :file', + 'sitemap_write_failed' => 'Sitemapファイルの記録に失敗しました: :path', + 'sitemap_gzip_failed' => 'Sitemap子ファイルの圧縮に失敗しました: :file', 'sitemap_temp_file_unreadable' => 'Sitemap一時ファイルを読み込めません: :file', 'sitemap_stat_group_column_unsupported' => 'サポートされていないグループ基準列です: :column', ], diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/extensions.php b/lang-packs/_bundled/g7-core-ja/backend/ja/extensions.php index 364c249b..7281efe3 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/extensions.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/extensions.php @@ -7,51 +7,51 @@ return [ 'template' => 'テンプレート', ], 'errors' => [ - 'core_version_mismatch' => ':extension (:type)はグヌボード7 コアバージョン :required 以上が必要です。(現在: :installed)', + 'core_version_mismatch' => ':extension (:type)はグヌボード7コアバージョン :required 以上が必要です。(現在: :installed)', 'version_check_failed' => 'バージョン検証に失敗しました。', - 'operation_in_progress' => '":name"に対して実行中の処理(:status)があるため、リクエストを処理できません。', + 'operation_in_progress' => '":name"に対して進行中の処理(:status)があるため、リクエストを処理できません。', 'zip_missing_manifest' => 'ZIP内から :file マニフェストが見つかりません: :zip', 'zip_invalid_manifest' => 'ZIP内の :file マニフェストをJSONとして解析できません。', - 'zip_identifier_mismatch' => 'ZIPマニフェストの識別子がターゲット拡張と一致しません。(期待値: :expected、実際: :actual)', - 'zip_missing_version' => 'ZIP内の :file マニフェストに version フィールドがありません。', + 'zip_identifier_mismatch' => 'ZIPマニフェストの識別子が対象拡張と一致しません。(期待値: :expected、実際: :actual)', + 'zip_missing_version' => 'ZIP内の :file マニフェストにversionフィールドがありません。', 'not_found' => '拡張(:identifier)が見つかりません。', - 'cascade_dependency_failed' => '付属インストール対象の :type (:identifier)のインストールに失敗しました: :message', + 'cascade_dependency_failed' => '同梱インストール対象の :type (:identifier)のインストールに失敗しました: :message', 'invalid_type' => '無効な拡張タイプです。', - 'not_auto_deactivated' => 'この拡張はコアバージョンの互換性の問題により自動無効化された状態ではありません。', - 'hidden_extension' => '内部用(hidden)拡張はユーザーに公開されません。', + 'not_auto_deactivated' => 'この拡張はコアバージョン互換性の問題により自動無効化された状態ではありません。', + 'hidden_extension' => '内部用(非表示)拡張はユーザーに非公開です。', ], 'warnings' => [ - 'auto_deactivated' => ':type ":identifier"はコアバージョン互換性の問題により自動的に無効化されました。', + 'auto_deactivated' => ':type ":identifier"がコアバージョン互換性の問題により自動無効化されました。', ], 'alerts' => [ 'incompatible_deactivated' => ':type ":name"自動無効化', - 'incompatible_message' => '必須バージョン: :required、現在インストール済み: :installed', - 'recovered_title' => ':type「:name」再び互換性があります', + 'incompatible_message' => '必要バージョン: :required、現在インストール済み: :installed', + 'recovered_title' => ':type ":name"互換性を再取得', 'recovered_body' => 'コアアップグレード後に互換性があります(以前の要件: :previously_required)。再度有効化できます。', - 'recovered_success' => '拡張が再び有効化されました。', + 'recovered_success' => '拡張が再度有効化されました。', 'dismissed' => '通知を閉じました。', - 'auto_deactivated_listed' => '自動無効化された拡張のリストです。', + 'auto_deactivated_listed' => '自動無効化された拡張の一覧です。', 'recover_action' => '再度有効化', 'dismiss_action' => '通知を閉じる', ], - 'commands' => [ - 'clear_cache_success' => '拡張バージョン検証キャッシュが削除されました。', - ], 'badges' => [ - 'incompatible' => 'コアアップグレードが必要です', - 'incompatible_tooltip' => 'コア:required以上が必要です(現在: :installed)', - 'incompatible_sr' => ':nameはコア:required以上が必要ですが、現在:installedがインストールされているため更新できません。', + 'incompatible' => 'コアアップグレード必要', + 'incompatible_tooltip' => 'コア :required 以上が必要です(現在: :installed)', + 'incompatible_sr' => ':name はコア :required 以上が必要ですが、現在 :installed がインストールされているため、アップデートできません。', ], 'banner' => [ 'title' => 'コア互換性の問題により自動無効化された拡張があります', - 'item_required' => '必須バージョン: :required', + 'item_required' => '必要バージョン: :required', 'guide_link' => 'コアアップグレードガイド', 'dismiss' => '閉じる', ], 'update_modal' => [ 'compat_warning_title' => 'コアバージョン互換性警告', - 'compat_warning_message' => 'この:typeはコア:required以上が必要です。(現在: :installed)', + 'compat_warning_message' => 'この :type はコア :required 以上が必要です。(現在: :installed)', 'compat_guide_link' => 'コアアップグレードガイドを表示', - 'force_label' => '警告を無視して強制更新します(推奨されません)', + 'force_label' => '警告を無視して強制アップデート(非推奨)', + ], + 'commands' => [ + 'clear_cache_success' => '拡張バージョン検証キャッシュが削除されました。', ], ]; diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/hooks.php b/lang-packs/_bundled/g7-core-ja/backend/ja/hooks.php index 01312efc..c259eaeb 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/hooks.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/hooks.php @@ -1,7 +1,7 @@ '静的フック マッピング キャッシュを作成しました。(コア :core / モジュール :modules / プラグイン :plugins) → :path', + 'cache_generated' => '静的フック マッピング キャッシュを生成しました。(コア :core / モジュール :modules / プラグイン :plugins) → :path', 'cache_cleared' => '静的フック マッピング キャッシュを削除しました。', 'cache_absent' => '削除する静的フック マッピング キャッシュがありません。', ]; diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/identity.php b/lang-packs/_bundled/g7-core-ja/backend/ja/identity.php index be3a6c69..962182ac 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/identity.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/identity.php @@ -6,9 +6,9 @@ return [ 'label' => 'メール', 'settings' => [ 'code_length' => '認証コード長', - 'code_length_help' => '送信される数字コードのけた数(デフォルト6、最小4、最大10)。', - 'from_address' => '送信者アドレス', - 'from_address_help' => '空の場合、システムデフォルトの送信者が使用されます。', + 'code_length_help' => '送信される数字コードの桁数 (デフォルト 6、最小 4、最大 10)。', + 'from_address' => '差出人アドレス', + 'from_address_help' => '空の場合、システムのデフォルト差出人を使用します。', ], ], ], @@ -18,48 +18,48 @@ return [ 'wrong_provider' => 'この認証リクエストは別のプロバイダーで処理する必要があります。', 'invalid_state' => '既に処理された認証リクエストです。', 'expired' => '認証時間が期限切れになりました。もう一度お試しください。', - 'max_attempts' => '試行回数を超えました。再度リクエストしてください。', + 'max_attempts' => '試行回数を超過しました。もう一度リクエストしてください。', 'invalid_code' => '認証コードが正しくありません。', - 'invalid_verification_token' => '無効な本人確認トークンです。', - 'missing_target' => '認証対象(メール·電話番号)が必要です。', - 'target_mismatch' => '認証した対象と要求した対象が一致しません。', + 'invalid_verification_token' => '無効な本人認証トークンです。', + 'missing_target' => '認証対象 (メール·電話番号) が必要です。', + 'target_mismatch' => '認証された対象とリクエストされた対象が一致しません。', 'purpose_not_supported' => '選択されたプロバイダーはこの目的をサポートしていません。', - 'provider_unavailable' => '本人確認プロバイダーを使用できません。', - 'generic' => '本人確認に失敗しました。', - 'missing_scope_or_target' => 'ポリシー照会にはscopeとtargetの両方が必要です。', + 'provider_unavailable' => '本人認証プロバイダーを使用できません。', + 'generic' => '本人認証に失敗しました。', + 'missing_scope_or_target' => 'ポリシー照会には scope と target の両方が必要です。', 'admin_policy_has_no_default' => '管理者が直接作成したポリシーには宣言デフォルト値がありません。', 'reset_field_failed' => '宣言デフォルト値の復元に失敗しました。フィールドが有効であることを確認してください。', ], 'messages' => [ - 'challenge_requested' => '本人確認コードを送信しました。', + 'challenge_requested' => '本人認証コードを送信しました。', 'challenge_verified' => '本人確認が完了しました。', - 'challenge_cancelled' => '本人確認リクエストがキャンセルされました。', + 'challenge_cancelled' => '本人認証リクエストがキャンセルされました。', ], 'logs' => [ 'activity' => [ - 'requested' => ':emailに本人確認コードを送信しました。', + 'requested' => ':email に本人認証コードを送信しました。', 'verified' => '本人確認が完了しました。', - 'failed' => '本人確認に失敗しました。', - 'expired' => '本人確認時間が期限切れになりました。', - 'cancelled' => '本人確認リクエストをキャンセルしました。', + 'failed' => '本人認証に失敗しました。', + 'expired' => '本人認証時間が期限切れになりました。', + 'cancelled' => '本人認証リクエストをキャンセルしました。', ], ], 'purposes' => [ 'signup' => [ 'label' => '会員登録認証', - 'description' => '新規登録者のメール/電話番号所有確認。', + 'description' => '新規ユーザーのメール/電話番号所有確認。', ], 'password_reset' => [ 'label' => 'パスワード再設定', - 'description' => 'パスワードを忘れたユーザーが本人確認後に再設定します。', + 'description' => 'パスワードを忘れたユーザーが本人確認後に再設定。', ], 'self_update' => [ - 'label' => '自己情報変更', - 'description' => 'ログインユーザーがメール/電話などの本人情報を変更する場合。', + 'label' => '自分の情報を変更', + 'description' => 'ログインユーザーがメール/電話など本人情報を変更する場合。', ], 'sensitive_action' => [ - 'label' => '機密作業', - 'description' => 'アカウント削除·管理者作業など再認証が必要な時点。', + 'label' => '機密操作', + 'description' => 'アカウント削除·管理者操作など再認証が必要な時点。', ], ], 'channels' => [ @@ -70,7 +70,7 @@ return [ 'hook' => 'フック', 'policy' => 'ポリシー', 'middleware' => 'ミドルウェア', - 'api' => 'API直接呼び出し', + 'api' => 'API 直接呼び出し', 'custom' => 'カスタム', 'system' => 'システム', ], @@ -81,7 +81,7 @@ return [ 'custom' => 'カスタム', ], 'fail_mode' => [ - 'block' => 'ブロック(HTTP 428)', + 'block' => 'ブロック (HTTP 428)', 'log_only' => 'ログのみ記録', ], 'applies_to' => [ diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/identity_message.php b/lang-packs/_bundled/g7-core-ja/backend/ja/identity_message.php index 30198330..acf375ea 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/identity_message.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/identity_message.php @@ -1,27 +1,27 @@ 'IDVメッセージ定義リストを取得しました。', - 'definition_list_failed' => 'IDVメッセージ定義リスト取得に失敗しました。', - 'definition_show_success' => 'IDVメッセージ定義詳細を取得しました。', - 'definition_show_failed' => 'IDVメッセージ定義詳細取得に失敗しました。', - 'definition_created' => 'IDVメッセージ定義が作成されました。', - 'definition_create_failed' => 'IDVメッセージ定義作成に失敗しました。', - 'definition_updated' => 'IDVメッセージ定義が編集されました。', - 'definition_update_failed' => 'IDVメッセージ定義編集に失敗しました。', - 'definition_toggled' => 'IDVメッセージ定義の有効状態が変更されました。', - 'definition_toggle_failed' => 'IDVメッセージ定義有効状態変更に失敗しました。', - 'definition_reset' => 'IDVメッセージ定義がデフォルト値に復元されました。', - 'definition_reset_failed' => 'IDVメッセージ定義デフォルト値復元に失敗しました。', - 'definition_deleted' => 'IDVメッセージ定義が削除されました。', - 'definition_delete_failed' => 'IDVメッセージ定義削除に失敗しました。', - 'definition_delete_forbidden' => 'シード済みのデフォルトメッセージ定義は削除できません。', - 'template_updated' => 'IDVメッセージテンプレートが編集されました。', - 'template_update_failed' => 'IDVメッセージテンプレート編集に失敗しました。', - 'template_toggled' => 'IDVメッセージテンプレートの有効状態が変更されました。', - 'template_toggle_failed' => 'IDVメッセージテンプレート有効状態変更に失敗しました。', - 'template_reset' => 'IDVメッセージテンプレートがデフォルト値に復元されました。', - 'template_reset_failed' => 'IDVメッセージテンプレートデフォルト値復元に失敗しました。', - 'template_preview_success' => 'IDVメッセージテンプレートプレビューを生成しました。', - 'template_preview_failed' => 'IDVメッセージテンプレートプレビュー生成に失敗しました。', + 'definition_list_success' => 'IDV メッセージ定義リストを取得しました。', + 'definition_list_failed' => 'IDV メッセージ定義リスト取得に失敗しました。', + 'definition_show_success' => 'IDV メッセージ定義の詳細を取得しました。', + 'definition_show_failed' => 'IDV メッセージ定義の詳細取得に失敗しました。', + 'definition_created' => 'IDV メッセージ定義が作成されました。', + 'definition_create_failed' => 'IDV メッセージ定義の作成に失敗しました。', + 'definition_updated' => 'IDV メッセージ定義が編集されました。', + 'definition_update_failed' => 'IDV メッセージ定義の編集に失敗しました。', + 'definition_toggled' => 'IDV メッセージ定義の有効状態が変更されました。', + 'definition_toggle_failed' => 'IDV メッセージ定義の有効状態変更に失敗しました。', + 'definition_reset' => 'IDV メッセージ定義がデフォルト値に復元されました。', + 'definition_reset_failed' => 'IDV メッセージ定義のデフォルト値復元に失敗しました。', + 'definition_deleted' => 'IDV メッセージ定義が削除されました。', + 'definition_delete_failed' => 'IDV メッセージ定義の削除に失敗しました。', + 'definition_delete_forbidden' => 'シードされたデフォルトメッセージ定義は削除できません。', + 'template_updated' => 'IDV メッセージテンプレートが編集されました。', + 'template_update_failed' => 'IDV メッセージテンプレートの編集に失敗しました。', + 'template_toggled' => 'IDV メッセージテンプレートの有効状態が変更されました。', + 'template_toggle_failed' => 'IDV メッセージテンプレートの有効状態変更に失敗しました。', + 'template_reset' => 'IDV メッセージテンプレートがデフォルト値に復元されました。', + 'template_reset_failed' => 'IDV メッセージテンプレートのデフォルト値復元に失敗しました。', + 'template_preview_success' => 'IDV メッセージテンプレートのプレビューが作成されました。', + 'template_preview_failed' => 'IDV メッセージテンプレートのプレビュー作成に失敗しました。', ]; diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/language_packs.php b/lang-packs/_bundled/g7-core-ja/backend/ja/language_packs.php index 2b0602a3..2b9d8318 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/language_packs.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/language_packs.php @@ -2,61 +2,61 @@ return [ 'fetch_success' => '言語パックリストを取得しました。', - 'fetch_failed' => '言語パックリストを読み込めませんでした。', + 'fetch_failed' => '言語パックリストを読み込めませんでした: :error', 'not_found' => '言語パックが見つかりません。', 'install_success' => '言語パックをインストールしました。', - 'install_failed' => '言語パックのインストールに失敗しました。', + 'install_failed' => '言語パックのインストールに失敗しました: :error', 'activate_success' => '言語パックを有効化しました。', - 'activate_failed' => '言語パックの有効化に失敗しました。', + 'activate_failed' => '言語パックの有効化に失敗しました: :error', 'deactivate_success' => '言語パックを無効化しました。', - 'deactivate_failed' => '言語パックの無効化に失敗しました。', + 'deactivate_failed' => '言語パックの無効化に失敗しました: :error', 'uninstall_success' => '言語パックを削除しました。', - 'uninstall_failed' => '言語パックの削除に失敗しました。', + 'uninstall_failed' => '言語パックの削除に失敗しました: :error', 'manifest_invalid' => 'language-pack.json の検証に失敗しました。', - 'check_updates_success' => 'アップデート確認を完了しました。', - 'check_updates_failed' => 'アップデート確認に失敗しました。', + 'check_updates_success' => 'アップデート確認が完了しました。', + 'check_updates_failed' => 'アップデート確認に失敗しました: :error', 'update_success' => '言語パックをアップデートしました。', - 'update_failed' => '言語パックのアップデートに失敗しました。', + 'update_failed' => '言語パックのアップデートに失敗しました: :error', 'refresh_cache_success' => '言語パックキャッシュを更新しました。', - 'refresh_cache_failed' => '言語パックキャッシュの更新に失敗しました。', - 'preview_success' => 'manifestプレビューを完了しました。', - 'preview_failed' => 'manifestプレビューに失敗しました。', + 'refresh_cache_failed' => '言語パックキャッシュの更新に失敗しました: :error', + 'preview_success' => 'manifest プレビューが完了しました。', + 'preview_failed' => 'manifest プレビューに失敗しました: :error', 'errors' => [ - 'manifest_not_found' => 'ZIP内でlanguage-pack.jsonファイルが見つかりません。', - 'manifest_invalid_json' => 'language-pack.json のJSON形式が正しくありません。', - 'php_outside_backend' => 'backend/ディレクトリ外にPHPファイルが含まれています (:file)。', - 'unsafe_php_pattern' => 'PHPファイルに許可されていないコードパターンが含まれています (:file)。', + 'manifest_not_found' => 'ZIP内に language-pack.json ファイルが見つかりません。', + 'manifest_invalid_json' => 'language-pack.json の JSON 形式が正しくありません。', + 'php_outside_backend' => 'backend/ ディレクトリ外に PHP ファイルが含まれています (:file)。', + 'unsafe_php_pattern' => 'PHP ファイルに許可されていないコードパターンが含まれています (:file)。', 'core_locale_missing' => 'コア言語パック(:locale)を先に有効化する必要があります。', - 'target_not_installed' => '対象:scope (:target)がインストールされていません。', - 'target_inactive' => '対象:scope (:target)が無効状態です。先に有効化してください。', - 'target_version_too_old' => '対象:scope (:target)バージョンが要件(:constraint)を満たしていません。', + 'target_not_installed' => '対象 :scope (:target) がインストールされていません。', + 'target_inactive' => '対象 :scope (:target) は無効化されています。先に有効化してください。', + 'target_version_too_old' => '対象 :scope (:target) のバージョンが要件(:constraint)を満たしていません。', 'downgrade_blocked' => 'ダウングレードがブロックされました (:from → :to)。', 'protected_pack' => '保護された言語パックは無効化·削除できません。', - 'download_failed' => 'URL(:url)から言語パックをダウンロードできませんでした。', + 'download_failed' => 'URL(:url) から言語パックをダウンロードできませんでした: :error', + 'download_url_not_public' => '内部ネットワークアドレス(プライベートIP·localhostなど)からは言語パックをダウンロードできません。外部からアクセス可能なhttpsアドレスを使用してください。', 'checksum_mismatch' => 'チェックサムが一致しません。', - 'update_no_source' => 'アップデートソース情報がありません (GitHubソース言語パックのみアップデート可能)。', - 'update_already_latest' => 'すでに最新バージョンです。', - 'preview_invalid_manifest' => 'manifestファイルが無効なためプレビューに失敗しました。', - 'operation_in_progress' => '言語パック(:identifier)で別の処理が進行中です。完了後に再試行してください。', + 'update_no_source' => 'アップデートソース情報がありません(GitHubソース言語パックのみアップデート可能)。', + 'update_already_latest' => '既に最新バージョンです。', + 'preview_invalid_manifest' => 'manifestファイルが有効でなく、プレビューに失敗しました。', + 'operation_in_progress' => '言語パック(:identifier)で別のタスクが処理中です。完了後に再度お試しください。', 'bundled_not_found' => 'バンドルディレクトリ(lang-packs/_bundled/:identifier)が存在しません。', - 'slot_conflict' => '同じスロットにすでにアクティブな言語パック(:current)があります。(:target)に置き換えるには確認が必要です。', - 'directory_not_writable' => '言語パックインストールディレクトリ(:path)への書き込み権限がありません。Webサーバーユーザー(例: www-data)に書き込み権限を付与してください。(例: sudo chgrp www-data :path && sudo chmod g+w :path)', - 'identifier_not_found' => '言語パックが見つかりません (:identifier)。', + 'slot_conflict' => '同じスロットに既にアクティブな言語パック(:current)があります。(:target)に置き換えるには確認が必要です。', + 'directory_not_writable' => '言語パックインストールディレクトリ(:path)に書き込み権限がありません。Webサーバーユーザー(例: www-data)に書き込み権限を付与してください。(例: sudo chgrp www-data :path && sudo chmod g+w :path)', + 'identifier_not_found' => '言語パックが見つかりません(:identifier)。', 'unsupported_source' => 'サポートされていないソース(:source) — bundled、github、urlのいずれかを使用してください。', - 'download_url_not_public' => '内部ネットワークアドレス(プライベートIP·localhost等)では言語パックをダウンロードできません。外部からアクセス可能なhttpsアドレスを使用してください。', ], 'validation' => [ 'file_required' => '言語パックZIPファイルをアップロードしてください。', 'file_invalid' => 'アップロードされたファイルが有効ではありません。', 'file_too_large' => 'ファイルサイズは10MBを超えることはできません。', 'file_not_zip' => 'ZIP形式のファイルのみアップロードできます。', - 'github_url_required' => 'GitHubURLを入力してください。', + 'github_url_required' => 'GitHubのURLを入力してください。', 'github_url_invalid' => '正しいURLを入力してください。', - 'github_url_format' => 'GitHubリポジトリURL形式である必要があります (例: https://github.com/owner/repo)。', + 'github_url_format' => 'GitHubリポジトリURLの形式である必要があります(例: https://github.com/owner/repo)。', 'url_required' => 'ダウンロードURLを入力してください。', 'url_invalid' => '正しいURLを入力してください。', 'checksum_invalid' => 'SHA-256チェックサムは64文字の16進数である必要があります。', 'identifier_required' => '言語パック識別子を入力してください。', - 'identifier_invalid' => '識別子は英数字·ピリオド·アンダースコア·ハイフンのみ許可されます。', + 'identifier_invalid' => '識別子は英文/数字/ドット/アンダースコア/ハイフンのみが許可されます。', ], ]; diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/layout_extension.php b/lang-packs/_bundled/g7-core-ja/backend/ja/layout_extension.php index f3c214de..493831ef 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/layout_extension.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/layout_extension.php @@ -2,5 +2,5 @@ return [ 'version_warning_title' => '互換性警告', - 'version_incompatible' => 'オーバーライド":source"がモジュールバージョンと互換性がありません (要求: :constraint、現在: :current_version)', + 'version_incompatible' => 'オーバーライド":source"がモジュールバージョンと互換性がありません(必須: :constraint、現在: :current_version)', ]; diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/maintenance.php b/lang-packs/_bundled/g7-core-ja/backend/ja/maintenance.php index 4f1252df..36b49f26 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/maintenance.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/maintenance.php @@ -2,6 +2,6 @@ return [ 'title' => 'システムメンテナンス中', - 'message' => '現在システムアップデートが進行中です。', - 'description' => 'しばらく後に再度お試しください。ご不便をおかけして申し訳ございません。', + 'message' => '現在、システムアップデートが実施中です。', + 'description' => 'しばらく経ってからもう一度お試しください。ご不便をおかけして申し訳ございません。', ]; diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/menu.php b/lang-packs/_bundled/g7-core-ja/backend/ja/menu.php index 2afee8d7..cdd8895f 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/menu.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/menu.php @@ -13,16 +13,16 @@ return [ 'delete_success' => 'メニューが正常に削除されました。', 'delete_failed' => 'メニューの削除に失敗しました。', 'delete_error' => 'メニュー削除中にエラーが発生しました。', - 'order_update_success' => 'メニュー順序が正常にアップデートされました。', - 'order_update_failed' => 'メニュー順序のアップデートに失敗しました。', + 'order_update_success' => 'メニューの順序が正常にアップデートされました。', + 'order_update_failed' => 'メニューの順序アップデートに失敗しました。', 'slug_already_exists' => '既に使用中のスラッグです。', 'parent_menu_not_found' => '存在しない親メニューです。', - 'cannot_set_self_as_parent' => '自分自身を親として設定することはできません。', - 'cannot_delete_menu_with_children' => '子メニューがあるメニューは削除できません。', + 'cannot_set_self_as_parent' => '自身を親として設定することはできません。', + 'cannot_delete_menu_with_children' => '子メニューを持つメニューは削除できません。', 'validation' => [ 'is_active_boolean' => '有効化ステータスはtrue/false値である必要があります。', - 'sort_by_invalid' => '並べ替え基準が有効ではありません。', - 'sort_order_invalid' => '並べ替え順序が有効ではありません。', + 'sort_by_invalid' => 'ソート基準が有効ではありません。', + 'sort_order_invalid' => 'ソート順序が有効ではありません。', 'filters_array' => 'フィルターは配列形式である必要があります。', 'filters_max' => 'フィルターは最大10個まで指定できます。', 'filter_field_required' => 'フィルターフィールドは必須です。', diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/module.php b/lang-packs/_bundled/g7-core-ja/backend/ja/module.php index 6e074eaf..7866ff36 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/module.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/module.php @@ -2,25 +2,27 @@ return [ 'not_found' => 'モジュール :module が見つかりません。', - 'dependency_not_active' => '依存関係モジュール :dependency がインストールされていないか、有効化されていません。', + 'dependency_not_active' => '依存モジュール :dependency がインストールされていないか、有効化されていません。', 'fetch_success' => 'モジュールを正常にインポートしました。', - 'fetch_failed' => 'モジュールのインポートに失敗しました。', + 'fetch_failed' => 'モジュールのインポートに失敗しました: :error', 'install_success' => 'モジュールが正常にインストールされました。', - 'install_failed' => 'モジュールのインストールに失敗しました。', - 'preview_success' => 'マニフェストのプレビューが完了しました。', - 'preview_failed' => 'マニフェストのプレビューに失敗しました。', + 'install_failed' => 'モジュールのインストールに失敗しました: :error', + 'preview_success' => 'manifestのプレビューが完了しました。', + 'preview_failed' => 'manifestのプレビューに失敗しました: :error', 'uninstall_success' => 'モジュールが正常に削除されました。', - 'uninstall_failed' => 'モジュールの削除に失敗しました。', + 'uninstall_failed' => 'モジュールの削除に失敗しました: :error', 'activate_success' => 'モジュールが正常に有効化されました。', - 'activate_failed' => 'モジュールの有効化に失敗しました。', + 'activate_failed' => 'モジュールの有効化に失敗しました: :error', 'deactivate_success' => 'モジュールが正常に無効化されました。', - 'deactivate_failed' => 'モジュールの無効化に失敗しました。', + 'deactivate_failed' => 'モジュールの無効化に失敗しました: :error', 'update_success' => 'モジュールが正常にアップデートされました。', - 'update_failed' => 'モジュールのアップデートに失敗しました。', + 'update_failed' => 'モジュールのアップデートに失敗しました: :error', 'refresh_layouts_success' => 'モジュールレイアウトが正常に更新されました。', - 'refresh_layouts_failed' => 'モジュールレイアウトの更新に失敗しました。', + 'refresh_layouts_failed' => 'モジュールレイアウトの更新に失敗しました: :error', 'uninstall_info_success' => 'モジュール削除情報を正常に取得しました。', - 'uninstall_info_failed' => 'モジュール削除情報の取得に失敗しました。', + 'uninstall_info_failed' => 'モジュール削除情報の取得に失敗しました: :error', + 'dependent_templates_success' => '依存テンプレート情報を正常に取得しました。', + 'dependent_templates_failed' => '依存テンプレート情報の取得に失敗しました: :error', 'license_not_found' => 'モジュールライセンスファイルが見つかりません。', 'status' => [ 'active' => '有効', @@ -30,12 +32,10 @@ return [ ], 'errors' => [ 'module_class_not_found' => 'モジュールクラスが見つかりません。', - 'migration_failed' => 'マイグレーション実行に失敗しました。', - 'dependency_check_failed' => '依存関係の確認に失敗しました。', + 'migration_failed' => 'マイグレーション実行に失敗しました: :error', + 'dependency_check_failed' => '依存性確認に失敗しました: :error', 'database_error' => 'データベースエラーが発生しました。', 'module_not_found' => 'モジュール :name が見つかりません。', 'module_not_active' => 'モジュール :name は有効化されていません。', ], - 'dependent_templates_success' => '依存テンプレート情報を正常に取得しました。', - 'dependent_templates_failed' => '依存テンプレート情報の取得に失敗しました: :error', ]; diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/modules.php b/lang-packs/_bundled/g7-core-ja/backend/ja/modules.php index 6e9689c0..ca573afa 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/modules.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/modules.php @@ -23,58 +23,58 @@ return [ 'uninstalled' => '未インストール', ], 'no_modules' => 'モジュールがありません。', - 'summary' => '合計 :total個 モジュール (インストール済み: :installed、有効化: :active)', + 'summary' => '合計 :total 個のモジュール (インストール済み: :installed、有効化: :active)', 'invalid_status' => '無効なステータスです。(installed、uninstalled、active、inactive)', ], 'install' => [ - 'success' => 'モジュール ":module" インストール完了', + 'success' => 'モジュール ":module" のインストールが完了しました', 'vendor' => 'ベンダー: :vendor', 'version' => 'バージョン: :version', - 'roles_created' => ':count個 ロール 作成されました', - 'permissions_created' => ':count個 権限 作成されました', - 'menus_created' => ':count個 メニュー 作成されました', + 'roles_created' => ':count 個のロールが作成されました', + 'permissions_created' => ':count 個の権限が作成されました', + 'menus_created' => ':count 個のメニューが作成されました', 'already_installed' => 'モジュール ":module" は既にインストールされています。', 'force_reinstall' => 'モジュール ":module" を強制的に再インストールします (アクティブディレクトリを上書き)...', ], 'activate' => [ - 'success' => 'モジュール ":module" 有効化完了', - 'not_installed' => 'モジュール ":module" はインストールされていません。', + 'success' => 'モジュール ":module" の有効化が完了しました', + 'not_installed' => 'モジュール ":module" がインストールされていません。', 'already_active' => 'モジュール ":module" は既に有効化されています。', - 'layouts_registered' => ':count個 レイアウト 登録されました', + 'layouts_registered' => ':count 個のレイアウトが登録されました', ], 'deactivate' => [ - 'success' => 'モジュール ":module" 無効化完了', - 'not_installed' => 'モジュール ":module" はインストールされていません。', + 'success' => 'モジュール ":module" の無効化が完了しました', + 'not_installed' => 'モジュール ":module" がインストールされていません。', 'not_active' => 'モジュール ":module" は有効化されていません。', 'warning' => 'モジュールのルートと機能が無効化されました。', - 'layouts_deleted' => ':count個 レイアウト 削除されました', + 'layouts_deleted' => ':count 個のレイアウトが削除されました', ], 'uninstall' => [ - 'success' => 'モジュール ":module" 削除完了', - 'roles_deleted' => ':count個 ロール 削除されました', - 'permissions_deleted' => ':count個 権限 削除されました', - 'menus_deleted' => ':count個 メニュー 削除されました', - 'layouts_deleted' => ':count個 レイアウト 削除されました', - 'confirm_prompt' => 'モジュール ":module" を削除しますか?', + 'success' => 'モジュール ":module" の削除が完了しました', + 'roles_deleted' => ':count 個のロールが削除されました', + 'permissions_deleted' => ':count 個の権限が削除されました', + 'menus_deleted' => ':count 個のメニューが削除されました', + 'layouts_deleted' => ':count 個のレイアウトが削除されました', + 'confirm_prompt' => 'モジュール ":module" を削除してもよろしいですか?', 'confirm_details' => [ - 'roles' => '- :count個 のロールが削除されます。', - 'permissions' => '- :count個 の権限が削除されます。', - 'menus' => '- :count個 のメニューが削除されます。', - 'layouts' => '- :count個 のレイアウトが削除されます。', + 'roles' => '- :count 個のロールが削除されます。', + 'permissions' => '- :count 個の権限が削除されます。', + 'menus' => '- :count 個のメニューが削除されます。', + 'layouts' => '- :count 個のレイアウトが削除されます。', 'data' => '- すべてのモジュールデータが削除されます。', ], - 'confirm_question' => '本当に削除しますか?', - 'aborted' => 'モジュール削除がキャンセルされました。', - 'not_installed' => 'モジュール ":module" はインストールされていません。', + 'confirm_question' => '本当に削除してもよろしいですか?', + 'aborted' => 'モジュールの削除がキャンセルされました。', + 'not_installed' => 'モジュール ":module" がインストールされていません。', ], 'cache_clear' => [ 'clearing_all' => 'すべてのモジュールキャッシュを削除します...', - 'clearing_single' => 'モジュール ":module" キャッシュを削除します...', - 'success_all' => 'モジュールキャッシュ削除完了 (:count個 項目)', - 'success_single' => 'モジュール ":module" キャッシュ削除完了 (:count個 項目)', + 'clearing_single' => 'モジュール ":module" のキャッシュを削除します...', + 'success_all' => 'モジュールキャッシュの削除が完了しました (:count 個の項目)', + 'success_single' => 'モジュール ":module" のキャッシュ削除が完了しました (:count 個の項目)', ], 'check_updates' => [ - 'not_installed' => 'モジュール ":module" はインストールされていません。', + 'not_installed' => 'モジュール ":module" がインストールされていません。', 'no_installed' => 'インストール済みモジュールがありません。', 'update_available' => 'アップデート可能', 'up_to_date' => '最新', @@ -85,122 +85,122 @@ return [ 'source' => 'ソース', 'status' => 'ステータス', ], - 'summary' => '合計 :total個 確認 (アップデート可能: :updates個)', + 'summary' => '合計 :total 個を確認 (アップデート可能: :updates 個)', 'single_up_to_date' => 'モジュール ":module" は最新バージョンです。(v:version)', - 'single_update_available' => 'モジュール ":module" アップデート可能: v:current → v:latest (:source)', + 'single_update_available' => 'モジュール ":module" はアップデート可能です: v:current → v:latest (:source)', ], 'update' => [ - 'success' => 'モジュール ":module" アップデート完了', + 'success' => 'モジュール ":module" のアップデートが完了しました', 'version_change' => 'v:from → v:to', 'no_update' => 'モジュール ":module" に利用可能なアップデートはありません。', - 'not_installed' => 'モジュール ":module" はインストールされていません。', + 'not_installed' => 'モジュール ":module" がインストールされていません。', 'current_version' => '現在のバージョン: :version', 'latest_version' => '最新バージョン: :version', 'update_source' => 'アップデートソース: :source', - 'confirm_question' => 'アップデートを実行しますか?', + 'confirm_question' => 'アップデートを進めてもよろしいですか?', 'aborted' => 'アップデートがキャンセルされました。', 'backup_restored' => 'バックアップから以前のバージョンが復元されました。', 'force_mode' => '強制アップデートモード: バージョン比較をスキップして再インストールします。', 'layout_strategy' => 'レイアウト戦略: :strategy', - 'modified_extensions_warning' => '管理者が編集したレイアウト拡張 :count件が上書きされます。', - 'modified_extension_item' => ' - :target (出典: :source)', + 'modified_extensions_warning' => '管理者が編集したレイアウト拡張 :count 件が上書きされます。', + 'modified_extension_item' => ' - :target (ソース: :source)', ], ], 'composer_install' => [ 'start' => 'モジュール ":module" の Composer 依存関係をインストールします...', - 'success' => 'モジュール ":module" の Composer 依存関係 インストール完了', + 'success' => 'モジュール ":module" の Composer 依存関係のインストールが完了しました', 'no_dependencies' => 'モジュール ":module" にインストールする Composer 依存関係はありません。', - 'failed' => 'モジュール ":module" の Composer 依存関係 インストールに失敗しました。', + 'failed' => 'モジュール ":module" の Composer 依存関係のインストールに失敗しました。', 'summary' => '📊 結果: 成功 :success個、スキップ :skip個、失敗 :fail個', ], - 'update_success' => 'モジュール ":module" が :version バージョンにアップデートされました。', - 'update_failed' => 'モジュールアップデートに失敗しました: :error', - 'update_hook_failed' => 'モジュール アップデート フック実行に失敗しました。', + 'update_success' => 'モジュール ":module"が :version バージョンにアップデートされました。', + 'update_failed' => 'モジュールのアップデートに失敗しました: :error', + 'update_hook_failed' => 'モジュールアップデートフック実行に失敗しました: :error', 'no_update_available' => 'アップデートする内容がありません。', 'check_updates_success' => 'アップデート確認が完了しました。', - 'check_updates_failed' => 'アップデート確認に失敗しました。', - 'check_modified_layouts_success' => '修正されたレイアウト確認が完了しました。', - 'check_modified_layouts_failed' => '修正されたレイアウト確認に失敗しました: :error', - 'not_installed' => 'モジュール":module"はインストールされていません。', - 'pending_not_found' => 'インストール待ちリストでモジュール":module"が見つかりません。', - 'already_exists' => 'モジュール":module"は既に存在します。', - 'move_failed' => 'モジュール":module"の移動に失敗しました。', - 'deactivate_warning' => 'このモジュールに依存する有効なテンプレートがあります。', - 'installation_failed' => 'モジュール インストールに失敗しました: :error', - 'activation_failed' => 'モジュール 有効化に失敗しました: :error', - 'deactivation_failed' => 'モジュール 無効化に失敗しました: :error', - 'uninstallation_failed' => 'モジュール 削除に失敗しました: :error', - 'refresh_layouts_failed' => 'レイアウト 更新に失敗しました: :error', + 'check_updates_failed' => 'アップデート確認に失敗しました: :error', + 'check_modified_layouts_success' => '変更されたレイアウト確認が完了しました。', + 'check_modified_layouts_failed' => '変更されたレイアウト確認に失敗しました: :error', + 'not_installed' => 'モジュール ":module"はインストールされていません。', + 'pending_not_found' => 'インストール待機リストでモジュール ":module"が見つかりません。', + 'already_exists' => 'モジュール ":module"は既に存在します。', + 'move_failed' => 'モジュール ":module"の移動に失敗しました: :error', + 'deactivate_warning' => 'このモジュールに依存するアクティブなテンプレートがあります。', + 'installation_failed' => 'モジュールのインストールに失敗しました: :error', + 'activation_failed' => 'モジュールの有効化に失敗しました: :error', + 'deactivation_failed' => 'モジュールの無効化に失敗しました: :error', + 'uninstallation_failed' => 'モジュールの削除に失敗しました: :error', + 'refresh_layouts_failed' => 'レイアウト更新に失敗しました: :error', 'warnings' => [ 'has_dependent_templates' => 'このモジュールに依存する有効化されたテンプレートがあります。無効化するには強制オプションを使用してください。', 'has_dependents' => 'このモジュールに依存する有効化された拡張があります。', - 'deactivation_warning' => '無効化すると次のテンプレートが影響を受けます:', + 'deactivation_warning' => '無効化すると、次のテンプレートが影響を受けます:', 'confirm_deactivation' => 'それでも無効化しますか?', - 'missing_dependencies' => 'このモジュールを有効化するには次の依存性が必要です。', + 'missing_dependencies' => 'このモジュールを有効化するには、次の依存関係が必要です。', ], - 'activate_warning' => 'モジュール 有効化に必要な依存性が満たされていません。', - 'deactivate_warning_extended' => 'このモジュールに依存する有効化された拡張(テンプレート、モジュール、プラグイン)があります。', + 'activate_warning' => 'モジュール有効化に必要な依存関係が満たされていません。', + 'deactivate_warning_extended' => 'このモジュールに依存する有効化された拡張 (テンプレート、モジュール、プラグイン) があります。', 'errors' => [ - 'force_update_no_source' => ':moduleを強制アップデートするソースが見つかりません。バンドルおよびGitHub URLの両方がありません。', - 'downgrade_blocked' => 'ダウングレードがブロックされました(:from → :to)。意図的なダウングレードは--forceオプションを使用してください。', - 'zip_open_failed' => 'ZIPファイルを開くことができません。', - 'module_json_not_found' => 'module.jsonファイルが見つかりません。', - 'module_json_invalid' => 'module.jsonファイル形式が正しくありません。', - 'identifier_missing' => 'モジュール識別子が不足しています。', + 'force_update_no_source' => ':module を強制アップデートするソースが見つかりません。バンドルと GitHub URL の両方がありません。', + 'downgrade_blocked' => 'ダウングレードがブロックされました (:from → :to)。意図的なダウングレードは --force オプションを使用してください。', + 'zip_open_failed' => 'ZIP ファイルを開くことができません: :error', + 'module_json_not_found' => 'module.json ファイルが見つかりません。', + 'module_json_invalid' => 'module.json ファイル形式が正しくありません。', + 'identifier_missing' => 'モジュール識別子がありません。', 'already_installed' => '既にインストールされているモジュールです。', - 'install_failed' => 'モジュール インストールに失敗しました。', - 'github_url_invalid' => '無効なGitHub URLの形式です。', - 'github_download_failed' => 'GitHubからのダウンロードに失敗しました。', + 'install_failed' => 'モジュールのインストールに失敗しました: :error', + 'github_url_invalid' => '無効な GitHub URL 形式です。', + 'github_download_failed' => 'GitHub からのダウンロードに失敗しました: :error', 'github_repo_not_found' => 'GitHub リポジトリが見つかりません。', - 'module_not_found' => 'モジュール:nameが見つかりません。', - 'module_not_active' => 'モジュール:nameは有効化されていません。', - 'not_found' => 'モジュール:moduleが見つかりません。', + 'module_not_found' => 'モジュール :name が見つかりません。', + 'module_not_active' => 'モジュール :name は有効化されていません。', + 'not_found' => 'モジュール :module が見つかりません。', 'file_not_found' => 'ファイルが見つかりません。', 'file_type_not_allowed' => '許可されていないファイル形式です。', 'unknown_error' => '不明なエラーが発生しました。', - 'invalid_permission_structure' => 'モジュール":identifier"の権限構造が正しくありません: :reason', - 'invalid_translation_path' => 'モジュール":identifier"の言語パック ファイルパスが正しくありません。":wrong_path"の代わりに":correct_path"を使用する必要があります。', - 'seo_variable_conflict' => 'モジュール":identifier"のSEO変数名が既存の拡張と競合します: :reason', - 'layout_validation_json_error' => 'レイアウト ファイル:fileのJSON解析に失敗しました: :error', - 'layout_validation_missing_name' => 'レイアウト ファイル:fileにlayout_nameが不足しています。', - 'layout_validation_partial_error' => 'レイアウト ファイル:fileのpartial処理に失敗しました: :error (partial: :partial)', - 'layout_validation_validation_failed' => 'モジュール:identifierのレイアウト バリデーションに失敗しました。:count個のエラーが見つかりました。', - 'operation_in_progress' => 'モジュール":name"は現在:statusの状態です。処理が完了するまでお待ちください。', - 'github_api_failed' => 'GitHub API呼び出しに失敗しました。', - 'invalid_github_url' => '無効なGitHub URLです。', - 'zip_url_not_found' => 'ZIPダウンロード URLが見つかりません。', - 'download_failed' => 'モジュール":module":versionバージョンのダウンロードに失敗しました。', - 'zip_extract_failed' => 'ZIP展開に失敗しました。', - 'extracted_dir_not_found' => '展開されたディレクトリが見つかりません。', - 'reload_failed' => 'モジュール 再ロードに失敗しました。', - 'delete_directory_failed' => 'モジュール ディレクトリの削除に失敗しました。', - 'update_failed' => 'モジュール":module"のアップデートに失敗しました: :error', - 'invalid_layout_strategy' => '無効なレイアウト戦略です。(overwriteまたはkeepのみ許可)', - 'invalid_vendor_mode' => '無効なVendor インストール モードです。(auto、composer、bundledのみ許可)', + 'invalid_permission_structure' => 'モジュール ":identifier"の権限構造が正しくありません: :reason', + 'invalid_translation_path' => 'モジュール ":identifier"の言語ファイルパスが正しくありません。":wrong_path" の代わりに ":correct_path"を使用する必要があります。', + 'seo_variable_conflict' => 'モジュール ":identifier"の SEO 変数名が既存の拡張と競合しています: :reason', + 'layout_validation_json_error' => 'レイアウトファイル :file の JSON パースに失敗しました: :error', + 'layout_validation_missing_name' => 'レイアウトファイル :file に layout_name がありません。', + 'layout_validation_partial_error' => 'レイアウトファイル :file の partial 処理に失敗しました: :error (partial: :partial)', + 'layout_validation_validation_failed' => 'モジュール :identifier のレイアウト検証に失敗しました。:count 個のエラーが見つかりました: :error', + 'operation_in_progress' => 'モジュール ":name"は現在 :status 状態です。処理が完了するまでお待ちください。', + 'github_api_failed' => 'GitHub API 呼び出しに失敗しました: :error', + 'invalid_github_url' => '無効な GitHub URL です。', + 'zip_url_not_found' => 'ZIP ダウンロード URL が見つかりません。', + 'download_failed' => 'モジュール ":module" :version バージョンのダウンロードに失敗しました: :error', + 'zip_extract_failed' => 'ZIP 解凍に失敗しました: :error', + 'extracted_dir_not_found' => '解凍されたディレクトリが見つかりません。', + 'reload_failed' => 'モジュールの再読み込みに失敗しました: :error', + 'delete_directory_failed' => 'モジュールディレクトリの削除に失敗しました: :error', + 'update_failed' => 'モジュール ":module"のアップデートに失敗しました: :error', + 'invalid_layout_strategy' => '無効なレイアウト戦略です。(overwrite または keep のみ許可)', + 'invalid_vendor_mode' => '無効な Vendor インストールモードです。(auto、composer、bundled のみ許可)', ], 'validation' => [ 'name_required' => 'モジュール名は必須です。', 'name_string' => 'モジュール名は文字列である必要があります。', - 'name_max' => 'モジュール名は255字を超えることはできません。', - 'search_max' => '検索語は255字を超えることはできません。', - 'filters_max' => '検索条件は最大10個までのみ指定できます。', + 'name_max' => 'モジュール名は 255 文字を超えることができません。', + 'search_max' => '検索語は 255 文字を超えることができません。', + 'filters_max' => '検索条件は最大 10 個までのみ指定できます。', 'filter_field_required' => '検索フィールドは必須です。', 'filter_field_invalid' => '無効な検索フィールドです。', 'filter_value_required' => '検索値は必須です。', - 'filter_value_max' => '検索値は255字を超えることはできません。', + 'filter_value_max' => '検索値は 255 文字を超えることができません。', 'filter_operator_invalid' => '無効な検索演算子です。', - 'status_invalid' => '無効なステータスです。', - 'per_page_min' => 'ページあたりの項目数は最小1です。', - 'per_page_max' => 'ページあたりの項目数は最大100です。', - 'page_min' => 'ページ番号は最小1です。', - 'file_required' => 'モジュール ファイルは必須です。', + 'status_invalid' => '無効な状態です。', + 'per_page_min' => '1 ページあたりの項目数は最小 1 個です。', + 'per_page_max' => '1 ページあたりの項目数は最大 100 個です。', + 'page_min' => 'ページ番号は最小 1 です。', + 'file_required' => 'モジュールファイルは必須です。', 'file_invalid' => '有効なファイルではありません。', - 'file_must_be_zip' => 'モジュール ファイルはZIP形式である必要があります。', - 'file_max_size' => 'モジュール ファイル サイズは:sizeMBを超えることはできません。', + 'file_must_be_zip' => 'モジュールファイルは ZIP 形式である必要があります。', + 'file_max_size' => 'モジュールファイルサイズは :sizeMB を超えることができません。', 'github_url_required' => 'GitHub URLは必須です。', 'github_url_invalid' => '有効なURL形式ではありません。', 'github_url_format' => 'GitHub リポジトリURL形式が正しくありません。', - 'with_max' => 'withパラメータは最大10個まで指定できます。', - 'with_invalid' => '無効なwith関係です。', + 'with_max' => 'with パラメータは最大10個まで指定できます。', + 'with_invalid' => '有効でないwith 関係です。', ], ]; diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/notification.php b/lang-packs/_bundled/g7-core-ja/backend/ja/notification.php index 45dc5513..2947af0b 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/notification.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/notification.php @@ -1,49 +1,50 @@ '通知定義一覧を取得しました。', - 'definition_list_failed' => '通知定義一覧の取得に失敗しました。', + 'definition_list_success' => '通知定義リストを取得しました。', + 'definition_list_failed' => '通知定義リストの取得に失敗しました。', 'definition_show_success' => '通知定義を取得しました。', 'definition_show_failed' => '通知定義の取得に失敗しました。', 'definition_updated' => '通知定義が編集されました。', 'definition_update_failed' => '通知定義の編集に失敗しました。', - 'definition_toggled' => '通知定義のアクティブ状態が変更されました。', - 'definition_toggle_failed' => '通知定義のアクティブ状態の変更に失敗しました。', + 'definition_toggled' => '通知定義の有効状態が変更されました。', + 'definition_toggle_failed' => '通知定義の有効状態変更に失敗しました。', 'definition_not_found' => '通知定義が見つかりません。', 'definition_reset' => '通知定義のすべてのテンプレートがデフォルト値に復元されました。', 'definition_reset_failed' => '通知定義の初期化に失敗しました。', - 'channel_skipped_no_template' => ':channel チャネルに :type タイプのアクティブなテンプレートがないため、配信をスキップしました。', - 'channel_disabled_by_extension' => '拡張設定でそのチャネルが無効化されているため、配信をスキップしました。', + 'channel_skipped_no_template' => ':channel チャネルに :type タイプのアクティブテンプレートがないため、送信をスキップしました。', + 'channel_disabled_by_extension' => '拡張環境設定でそのチャネルが無効化されているため、送信をスキップしました。', + 'channel_guest_not_allowed' => 'そのチャネルは非会員送信を許可していないため、送信をスキップしました。', 'template_updated' => '通知テンプレートが編集されました。', 'template_update_failed' => '通知テンプレートの編集に失敗しました。', - 'template_toggled' => '通知テンプレートのアクティブ状態が変更されました。', - 'template_toggle_failed' => '通知テンプレートのアクティブ状態の変更に失敗しました。', + 'template_toggled' => '通知テンプレートの有効状態が変更されました。', + 'template_toggle_failed' => '通知テンプレートの有効状態変更に失敗しました。', 'template_reset' => '通知テンプレートがデフォルト値に復元されました。', 'template_reset_failed' => '通知テンプレートのデフォルト値復元に失敗しました。', - 'template_inactive' => '該当チャネルの通知テンプレートは無効化状態です。', + 'template_inactive' => '該当チャネルの通知テンプレートは無効状態です。', 'template_not_found' => '該当チャネルの通知テンプレートが見つかりません。', 'default_data_not_found' => 'デフォルトテンプレートデータが見つかりません。', - 'preview_success' => 'プレビューを生成しました。', - 'preview_failed' => 'プレビューの生成に失敗しました。', - 'channels_success' => '通知チャネル一覧を取得しました。', - 'channels_failed' => '通知チャネル一覧の取得に失敗しました。', + 'preview_success' => 'プレビューを作成しました。', + 'preview_failed' => 'プレビューの作成に失敗しました。', + 'channels_success' => '通知チャネルリストを取得しました。', + 'channels_failed' => '通知チャネルリストの取得に失敗しました。', 'definition' => '通知定義', 'subject' => '件名', 'body' => '本文', 'readiness' => [ 'mail_from_address_not_configured' => '送信者メールアドレスが設定されていません。', - 'mail_smtp_host_empty' => 'SMTPホストが設定されていません。', - 'mail_smtp_port_empty' => 'SMTPポートが設定されていません。', - 'mail_mailgun_domain_empty' => 'Mailgunドメインが設定されていません。', - 'mail_mailgun_secret_empty' => 'Mailgunシークレットが設定されていません。', - 'mail_ses_key_empty' => 'SESキーが設定されていません。', - 'mail_ses_secret_empty' => 'SESシークレットが設定されていません。', + 'mail_smtp_host_empty' => 'SMTP ホストが設定されていません。', + 'mail_smtp_port_empty' => 'SMTP ポートが設定されていません。', + 'mail_mailgun_domain_empty' => 'Mailgun ドメインが設定されていません。', + 'mail_mailgun_secret_empty' => 'Mailgun シークレットが設定されていません。', + 'mail_ses_key_empty' => 'SES キーが設定されていません。', + 'mail_ses_secret_empty' => 'SES シークレットが設定されていません。', 'database_table_missing' => '通知テーブルが存在しません。', 'unknown' => 'チャネル設定が完了していません。', ], 'user' => [ - 'list_success' => '通知一覧を取得しました。', - 'list_failed' => '通知一覧の取得に失敗しました。', + 'list_success' => '通知リストを取得しました。', + 'list_failed' => '通知リストの取得に失敗しました。', 'unread_count_success' => '未読通知数を取得しました。', 'unread_count_failed' => '未読通知数の取得に失敗しました。', 'read_success' => '通知を既読にしました。', @@ -53,19 +54,18 @@ return [ 'delete_success' => '通知が削除されました。', 'delete_failed' => '通知の削除に失敗しました。', 'delete_all_success' => 'すべての通知が削除されました。', - 'delete_all_failed' => '全件削除に失敗しました。', + 'delete_all_failed' => '全件通知削除に失敗しました。', 'not_found' => '通知が見つかりません。', ], 'channels' => [ - 'core_default' => 'コアの基本チャネル', + 'core_default' => 'コアデフォルトチャネル', 'mail' => [ 'name' => 'メール', 'description' => 'メールで通知を送信', ], 'database' => [ 'name' => 'サイト内通知', - 'description' => 'サイト内の通知センターに表示', + 'description' => 'サイト内通知センターに表示', ], ], - 'channel_guest_not_allowed' => '該当チャネルは非会員発送を許可していないため、発送をスキップしました。', ]; diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/notification_log.php b/lang-packs/_bundled/g7-core-ja/backend/ja/notification_log.php index cf219dd5..9e7dc298 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/notification_log.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/notification_log.php @@ -1,8 +1,8 @@ '通知発送履歴を閲覧しました。', - 'list_failed' => '通知発送履歴の閲覧に失敗しました。', + 'list_success' => '通知発送履歴を取得しました。', + 'list_failed' => '通知発送履歴の取得に失敗しました。', 'delete_success' => '通知発送履歴が削除されました。', 'delete_failed' => '通知発送履歴の削除に失敗しました。', 'bulk_delete_success' => '選択された通知発送履歴が削除されました。', diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/plugins.php b/lang-packs/_bundled/g7-core-ja/backend/ja/plugins.php index 262dd2cf..c3cb8e3b 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/plugins.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/plugins.php @@ -1,119 +1,121 @@ 'プラグインリストの取得に成功しました。', - 'list_failed' => 'プラグインリストの取得に失敗しました。', - 'fetch_success' => 'プラグイン情報の取得に成功しました。', - 'fetch_failed' => 'プラグイン情報の取得に失敗しました。', + 'list_success' => 'プラグインのリストを正常に取得しました。', + 'list_failed' => 'プラグインのリストの取得に失敗しました: :error', + 'fetch_success' => 'プラグインの情報を正常に取得しました。', + 'fetch_failed' => 'プラグインの情報の取得に失敗しました: :error', 'not_found' => 'プラグイン :plugin が見つかりません。', - 'install_success' => 'プラグインが正常にインストールされました。', - 'install_failed' => 'プラグインのインストールに失敗しました。', - 'preview_success' => 'マニフェストプレビューが完了しました。', - 'preview_failed' => 'マニフェストプレビューに失敗しました。', - 'install_validation_failed' => 'プラグインのインストール検証に失敗しました。', + 'install_success' => 'プラグインは正常にインストールされました。', + 'install_failed' => 'プラグインのインストールに失敗しました: :error', + 'preview_success' => 'manifest プレビューが完了しました。', + 'preview_failed' => 'manifest プレビューに失敗しました: :error', + 'install_validation_failed' => 'プラグイン インストール バリデーションに失敗しました: :error', 'install_error' => 'プラグインのインストール中にエラーが発生しました。', - 'uninstall_success' => 'プラグインが正常に削除されました。', - 'uninstall_failed' => 'プラグインの削除に失敗しました。', - 'uninstall_validation_failed' => 'プラグインの削除検証に失敗しました。', + 'uninstall_success' => 'プラグインは正常に削除されました。', + 'uninstall_failed' => 'プラグインの削除に失敗しました: :error', + 'uninstall_validation_failed' => 'プラグイン削除 バリデーションに失敗しました: :error', 'uninstall_error' => 'プラグインの削除中にエラーが発生しました。', - 'activate_success' => 'プラグインが正常に有効化されました。', - 'activate_failed' => 'プラグインの有効化に失敗しました。', - 'activate_validation_failed' => 'プラグインの有効化検証に失敗しました。', + 'activate_success' => 'プラグインは正常に有効化されました。', + 'activate_failed' => 'プラグインの有効化に失敗しました: :error', + 'activate_validation_failed' => 'プラグイン有効化 バリデーションに失敗しました: :error', 'activate_error' => 'プラグインの有効化中にエラーが発生しました。', - 'deactivate_success' => 'プラグインが正常に無効化されました。', - 'deactivate_failed' => 'プラグインの無効化に失敗しました。', - 'deactivate_validation_failed' => 'プラグインの無効化検証に失敗しました。', + 'deactivate_success' => 'プラグインは正常に無効化されました。', + 'deactivate_failed' => 'プラグインの無効化に失敗しました: :error', + 'deactivate_validation_failed' => 'プラグイン無効化 バリデーションに失敗しました: :error', 'deactivate_error' => 'プラグインの無効化中にエラーが発生しました。', 'refresh_layouts_success' => 'プラグインレイアウトが正常に更新されました。', 'refresh_layouts_failed' => 'プラグインレイアウトの更新に失敗しました: :error', - 'uninstall_info_success' => 'プラグイン削除情報の閲覧に成功しました。', - 'uninstall_info_failed' => 'プラグイン削除情報の閲覧に失敗しました。', + 'uninstall_info_success' => 'プラグイン削除情報を正常に取得しました。', + 'uninstall_info_failed' => 'プラグイン削除情報の取得に失敗しました: :error', + 'dependent_templates_success' => '依存テンプレート情報を正常に取得しました。', + 'dependent_templates_failed' => '依存テンプレート情報の取得に失敗しました: :error', 'license_not_found' => 'プラグインライセンスファイルが見つかりません。', - 'refresh_layouts_validation_failed' => 'プラグインレイアウト更新検証に失敗しました。', + 'refresh_layouts_validation_failed' => 'プラグインレイアウト更新 バリデーションに失敗しました: :error', 'refresh_layouts_error' => 'プラグインレイアウトの更新中にエラーが発生しました。', 'status' => [ - 'active' => '有効', - 'inactive' => '無効', - 'updating' => 'アップデート中', + 'active' => '有効化', + 'inactive' => '無効化', + 'updating' => '更新中', ], - 'update_success' => 'プラグイン ":plugin" は :version にアップデートされました。', + 'update_success' => 'プラグイン ":plugin" は :version バージョンにアップデートされました。', 'update_failed' => 'プラグインのアップデートに失敗しました: :error', - 'update_hook_failed' => 'プラグイン更新フックの実行に失敗しました。', - 'no_update_available' => '更新する内容がありません。', - 'check_updates_success' => '更新確認が完了しました。', - 'check_updates_failed' => '更新確認に失敗しました。', - 'check_modified_layouts_success' => '変更されたレイアウト確認が完了しました。', - 'check_modified_layouts_failed' => '変更されたレイアウト確認に失敗しました: :error', + 'update_hook_failed' => 'プラグイン アップデート フック実行に失敗しました: :error', + 'no_update_available' => 'アップデートする内容がありません。', + 'check_updates_success' => 'アップデート確認が完了しました。', + 'check_updates_failed' => 'アップデート確認に失敗しました: :error', + 'check_modified_layouts_success' => '更新されたレイアウト確認が完了しました。', + 'check_modified_layouts_failed' => '更新されたレイアウト確認に失敗しました: :error', 'not_installed' => 'プラグイン ":plugin" はインストールされていません。', 'pending_not_found' => 'インストール待機リストにプラグイン ":plugin" が見つかりません。', 'already_exists' => 'プラグイン ":plugin" は既に存在します。', - 'move_failed' => 'プラグイン ":plugin" の移動に失敗しました。', - 'dependency_not_active' => '依存関係 ":dependency" がインストールされていないか、無効化されています。', + 'move_failed' => 'プラグイン ":plugin" の移動に失敗しました: :error', + 'dependency_not_active' => '依存 ":dependency" はインストールされていないか、無効化されています。', 'installation_failed' => 'プラグインのインストールに失敗しました: :error', 'activation_failed' => 'プラグインの有効化に失敗しました: :error', 'deactivation_failed' => 'プラグインの無効化に失敗しました: :error', 'uninstallation_failed' => 'プラグインの削除に失敗しました: :error', 'warnings' => [ - 'has_dependent_templates' => 'このプラグインに依存する有効なテンプレートがあります。無効化するには強制オプションを使用してください。', - 'has_dependents' => 'このプラグインに依存する有効な拡張があります。', - 'deactivation_warning' => '無効化すると、次のテンプレートが影響を受けます:', + 'has_dependent_templates' => 'このプラグインに依存する有効化されたテンプレートがあります。無効化するには強制オプションを使用してください。', + 'has_dependents' => 'このプラグインに依存する有効化された拡張があります。', + 'deactivation_warning' => '無効化すると、以下のテンプレートが影響を受けます:', 'confirm_deactivation' => 'それでも無効化しますか?', - 'missing_dependencies' => 'このプラグインを有効化するには、次の依存関係が必要です。', + 'missing_dependencies' => 'このプラグインを有効化するには、以下の依存が必要です。', ], - 'activate_warning' => 'プラグインの有効化に必要な依存関係が満たされていません。', - 'deactivate_warning' => 'このプラグインに依存する有効な拡張があります。', - 'deactivate_warning_extended' => 'このプラグインに依存する有効な拡張(テンプレート、モジュール、プラグイン)があります。', + 'activate_warning' => 'プラグイン有効化に必要な依存が満たされていません。', + 'deactivate_warning' => 'このプラグインに依存する有効化された拡張があります。', + 'deactivate_warning_extended' => 'このプラグインに依存する有効化された拡張(テンプレート、モジュール、プラグイン)があります。', 'errors' => [ - 'force_update_no_source' => ':plugin を強制アップデートするソースが見つかりません。バンドルとGitHub URLがどちらもありません。', - 'downgrade_blocked' => 'ダウングレードはブロックされています (:from → :to)。意図的なダウングレードの場合は --force オプションを使用してください。', + 'force_update_no_source' => ':plugin を強制アップデートするソースが見つかりません。バンドルと GitHub URL の両方がありません。', + 'downgrade_blocked' => 'ダウングレードがブロックされました (:from → :to)。意図的なダウングレードは --force オプションを使用してください。', 'invalid_permission_structure' => 'プラグイン ":identifier" の権限構造が正しくありません: :reason', - 'invalid_translation_path' => 'プラグイン ":identifier" の言語ファイルパスが正しくありません。":wrong_path" の代わりに ":correct_path" を使用する必要があります。', - 'seo_variable_conflict' => 'プラグイン ":identifier" のSEO変数名が既存の拡張と競合しています: :reason', + 'invalid_translation_path' => 'プラグイン ":identifier" の言語ファイルパスが正しくありません。":wrong_path" ではなく ":correct_path" を使用してください。', + 'seo_variable_conflict' => 'プラグイン ":identifier" の SEO 変数名が既存の拡張と競合します: :reason', 'plugin_not_found' => 'プラグイン ":name" が見つかりません。', 'plugin_not_active' => 'プラグイン ":name" は有効化されていません。', 'not_found' => 'プラグイン :plugin が見つかりません。', 'file_not_found' => 'ファイルが見つかりません。', 'file_type_not_allowed' => '許可されていないファイル形式です。', 'unknown_error' => '不明なエラーが発生しました。', - 'layout_validation_json_error' => 'レイアウトファイル :file のJSON解析に失敗しました: :error', - 'layout_validation_missing_name' => 'レイアウトファイル :file に layout_name が不足しています。', - 'layout_validation_partial_error' => 'レイアウトファイル :file の部分テンプレート処理に失敗しました: :error (partial: :partial)', - 'layout_validation_validation_failed' => 'プラグイン :identifier のレイアウト検証に失敗しました。:count 個のエラーが見つかりました。', - 'operation_in_progress' => 'プラグイン ":name" は現在 :status 状態です。作業が完了するまで待ってください。', - 'github_api_failed' => 'GitHub API呼び出しに失敗しました。', - 'invalid_github_url' => '無効なGitHub URLです。', - 'zip_url_not_found' => 'ZIPダウンロードURLが見つかりません。', - 'download_failed' => 'プラグイン ":plugin" :version バージョンのダウンロードに失敗しました。', - 'zip_extract_failed' => 'ZIP解凍に失敗しました。', + 'layout_validation_json_error' => 'レイアウトファイル :file の JSON パース に失敗しました: :error', + 'layout_validation_missing_name' => 'レイアウトファイル :file に layout_name がありません。', + 'layout_validation_partial_error' => 'レイアウトファイル :file の partial 処理に失敗しました: :error (partial: :partial)', + 'layout_validation_validation_failed' => 'プラグイン :identifier のレイアウト バリデーションに失敗しました。:count 個のエラーが見つかりました: :error', + 'operation_in_progress' => 'プラグイン ":name" は現在 :status 状態です。作業が完了するまでお待ちください。', + 'github_api_failed' => 'GitHub API 呼び出しに失敗しました: :error', + 'invalid_github_url' => '無効な GitHub URL です。', + 'zip_url_not_found' => 'ZIP ダウンロード URL が見つかりません。', + 'download_failed' => 'プラグイン ":plugin" :version バージョンのダウンロードに失敗しました: :error', + 'zip_extract_failed' => 'ZIP 解凍に失敗しました: :error', 'extracted_dir_not_found' => '解凍されたディレクトリが見つかりません。', - 'reload_failed' => 'プラグインの再読み込みに失敗しました。', - 'delete_directory_failed' => 'プラグインディレクトリの削除に失敗しました。', - 'update_failed' => 'プラグイン ":plugin" のアップデートに失敗しました: :error', + 'reload_failed' => 'プラグイン再読み込みに失敗しました: :error', + 'delete_directory_failed' => 'プラグイン ディレクトリ削除に失敗しました: :error', + 'update_failed' => 'プラグイン ":plugin" アップデートに失敗しました: :error', 'invalid_layout_strategy' => '無効なレイアウト戦略です。(overwrite または keep のみ許可)', 'invalid_vendor_mode' => '無効な Vendor インストールモードです。(auto、composer、bundled のみ許可)', 'github_url_invalid' => '無効な GitHub URL 形式です。', - 'github_download_failed' => 'GitHub からのダウンロードに失敗しました。', + 'github_download_failed' => 'GitHub からのダウンロードに失敗しました: :error', 'github_repo_not_found' => 'GitHub リポジトリが見つかりません。', - 'zip_open_failed' => 'ZIP ファイルを開くことができません。', + 'zip_open_failed' => 'ZIP ファイルを開くことができません: :error', 'plugin_json_not_found' => 'plugin.json ファイルが見つかりません。', 'plugin_json_invalid' => 'plugin.json ファイル形式が正しくありません。', - 'identifier_missing' => 'プラグイン識別子が見つかりません。', + 'identifier_missing' => 'プラグイン識別子が不足しています。', 'already_installed' => 'プラグイン ":plugin" は既にインストールされています。', - 'install_failed' => 'プラグインのインストールに失敗しました。', + 'install_failed' => 'プラグイン インストールに失敗しました: :error', ], 'validation' => [ 'plugin_name_required' => 'プラグイン名は必須です。', 'plugin_name_string' => 'プラグイン名は文字列である必要があります。', - 'plugin_name_max' => 'プラグイン名は最大 :max 文字まで入力できます。', + 'plugin_name_max' => 'プラグイン名は最大 :max 文字までです。', 'name_required' => 'プラグイン名は必須です。', 'name_string' => 'プラグイン名は文字列である必要があります。', - 'name_max' => 'プラグイン名は最大 255 文字まで入力できます。', - 'search_max' => '検索キーワードは最大 255 文字まで入力できます。', + 'name_max' => 'プラグイン名は最大 255 文字までです。', + 'search_max' => '検索ワードは最大 255 文字までです。', 'filters_max' => '検索条件は最大 10 個まで設定できます。', 'filter_field_required' => '検索フィールドは必須です。', 'filter_field_invalid' => '無効な検索フィールドです。', 'filter_value_required' => '検索値は必須です。', - 'filter_value_max' => '検索値は最大 255 文字まで入力できます。', + 'filter_value_max' => '検索値は最大 255 文字までです。', 'filter_operator_invalid' => '無効な検索演算子です。', 'status_invalid' => '無効なステータス値です。', 'per_page_min' => 'ページあたりの項目数は最小 1 個以上である必要があります。', @@ -130,42 +132,42 @@ return [ 'status' => 'ステータス', ], 'status' => [ - 'active' => '有効', - 'inactive' => '無効', + 'active' => '有効化', + 'inactive' => '無効化', 'uninstalled' => '未インストール', ], 'no_plugins' => 'プラグインがありません。', - 'summary' => '合計 :total 個のプラグイン (インストール済み: :installed、有効: :active)', + 'summary' => '合計 :total 個のプラグイン (インストール済み: :installed、有効化: :active)', 'invalid_status' => '無効なステータスです。(installed、uninstalled、active、inactive)', ], 'install' => [ - 'success' => 'プラグイン ":plugin" のインストールが完了しました', + 'success' => 'プラグイン ":plugin" インストール完了', 'vendor' => 'ベンダー: :vendor', 'version' => 'バージョン: :version', 'roles_created' => ':count 個のロールが作成されました', 'permissions_created' => ':count 個の権限が作成されました', 'already_installed' => 'プラグイン ":plugin" は既にインストールされています。', - 'force_reinstall' => 'プラグイン ":plugin" を強制的に再インストールします (有効ディレクトリを上書き)...', + 'force_reinstall' => 'プラグイン ":plugin" を強制再インストールします (有効化ディレクトリを上書き)...', ], 'activate' => [ - 'success' => 'プラグイン ":plugin" の有効化が完了しました', + 'success' => 'プラグイン ":plugin" 有効化完了', 'not_installed' => 'プラグイン ":plugin" はインストールされていません。', 'already_active' => 'プラグイン ":plugin" は既に有効化されています。', 'layouts_registered' => ':count 個のレイアウトが登録されました', ], 'deactivate' => [ - 'success' => 'プラグイン ":plugin" の無効化が完了しました', + 'success' => 'プラグイン ":plugin" 無効化完了', 'not_installed' => 'プラグイン ":plugin" はインストールされていません。', 'not_active' => 'プラグイン ":plugin" は有効化されていません。', 'warning' => 'プラグインのルートと機能が無効化されました。', 'layouts_deleted' => ':count 個のレイアウトが削除されました', ], 'uninstall' => [ - 'success' => 'プラグイン ":plugin" の削除が完了しました', + 'success' => 'プラグイン ":plugin" 削除完了', 'roles_deleted' => ':count 個のロールが削除されました', 'permissions_deleted' => ':count 個の権限が削除されました', 'layouts_deleted' => ':count 個のレイアウトが削除されました', - 'confirm_prompt' => 'プラグイン ":plugin" を削除しますか?', + 'confirm_prompt' => 'プラグイン ":plugin" を削除してもよろしいですか?', 'confirm_details' => [ 'roles' => '- :count 個のロールが削除されます。', 'permissions' => '- :count 個の権限が削除されます。', @@ -173,18 +175,18 @@ return [ 'data' => '- すべてのプラグインデータが削除されます。', ], 'confirm_question' => '本当に削除してもよろしいですか?', - 'aborted' => 'プラグインの削除がキャンセルされました。', + 'aborted' => 'プラグイン削除がキャンセルされました。', 'not_installed' => 'プラグイン ":plugin" はインストールされていません。', ], 'cache_clear' => [ 'clearing_all' => 'すべてのプラグインキャッシュを削除します...', - 'clearing_single' => 'プラグイン ":plugin" のキャッシュを削除します...', - 'success_all' => 'プラグインキャッシュの削除が完了しました (:count 個の項目)', - 'success_single' => 'プラグイン ":plugin" のキャッシュ削除が完了しました (:count 個の項目)', + 'clearing_single' => 'プラグイン ":plugin" キャッシュを削除します...', + 'success_all' => 'プラグインキャッシュ削除完了 (:count 個の項目)', + 'success_single' => 'プラグイン ":plugin" キャッシュ削除完了 (:count 個の項目)', ], 'check_updates' => [ 'not_installed' => 'プラグイン ":plugin" はインストールされていません。', - 'no_installed' => 'インストール済みのプラグインがありません。', + 'no_installed' => 'インストールされたプラグインがありません。', 'update_available' => 'アップデート利用可能', 'up_to_date' => '最新', 'headers' => [ @@ -194,38 +196,36 @@ return [ 'source' => 'ソース', 'status' => 'ステータス', ], - 'summary' => '合計 :total個を確認しました(アップデート可能: :updates個)', - 'single_up_to_date' => 'プラグイン":plugin"は最新バージョンです。(v:version)', - 'single_update_available' => 'プラグイン":plugin"がアップデート可能です: v:current → v:latest(:source)', + 'summary' => '合計 :total個を確認しました (アップデート可能: :updates個)', + 'single_up_to_date' => 'プラグイン ":plugin" は最新バージョンです。(v:version)', + 'single_update_available' => 'プラグイン ":plugin" のアップデートが利用可能です: v:current → v:latest (:source)', ], 'update' => [ - 'success' => 'プラグイン":plugin"のアップデートが完了しました', + 'success' => 'プラグイン ":plugin" のアップデートが完了しました', 'version_change' => 'v:from → v:to', - 'no_update' => 'プラグイン":plugin"に利用可能なアップデートがありません。', - 'not_installed' => 'プラグイン":plugin"はインストールされていません。', + 'no_update' => 'プラグイン ":plugin" に利用可能なアップデートはありません。', + 'not_installed' => 'プラグイン ":plugin" はインストールされていません。', 'current_version' => '現在のバージョン: :version', 'latest_version' => '最新バージョン: :version', 'update_source' => 'アップデートソース: :source', - 'confirm_question' => 'アップデートを実行しますか?', + 'confirm_question' => 'アップデートを実行しますか?', 'aborted' => 'アップデートがキャンセルされました。', 'backup_restored' => 'バックアップから前のバージョンが復元されました。', 'force_mode' => '強制アップデートモード: バージョン比較をスキップして再インストールします。', 'layout_strategy' => 'レイアウト戦略: :strategy', 'modified_extensions_warning' => '管理者が編集したレイアウト拡張 :count件が上書きされます。', - 'modified_extension_item' => ' - :target (出典: :source)', + 'modified_extension_item' => ' - :target (ソース: :source)', ], ], 'composer_install' => [ - 'start' => 'プラグイン":plugin"のComposer依存関係をインストール中です...', - 'success' => 'プラグイン":plugin"のComposer依存関係のインストールが完了しました', - 'no_dependencies' => 'プラグイン":plugin"にインストールするComposer依存関係がありません。', - 'failed' => 'プラグイン":plugin"のComposer依存関係のインストールに失敗しました。', + 'start' => 'プラグイン ":plugin" の Composer 依存関係をインストールしています...', + 'success' => 'プラグイン ":plugin" の Composer 依存関係のインストールが完了しました', + 'no_dependencies' => 'プラグイン ":plugin" にインストールする Composer 依存関係はありません。', + 'failed' => 'プラグイン ":plugin" の Composer 依存関係のインストールに失敗しました。', 'summary' => '📊 結果: 成功 :success個、スキップ :skip個、失敗 :fail個', ], 'settings' => [ - 'update_failed' => 'プラグイン設定のアップデートに失敗しました。', + 'update_failed' => 'プラグイン設定のアップデートに失敗しました: :error', 'updated' => 'プラグイン設定が正常にアップデートされました。', ], - 'dependent_templates_success' => '依存テンプレート情報を正常に取得しました。', - 'dependent_templates_failed' => '依存テンプレート情報の取得に失敗しました: :error', ]; diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/role.php b/lang-packs/_bundled/g7-core-ja/backend/ja/role.php index 209c3825..ced18fc1 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/role.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/role.php @@ -14,7 +14,7 @@ return [ 'name_required' => 'ロール名は必須です。', 'identifier_required' => '識別子は必須です。', 'identifier_format' => '識別子は小文字で始まり、小文字、数字、アンダースコア(_)のみ使用できます。', - 'identifier_unique' => '既に使用中の識別子です。', + 'identifier_unique' => 'すでに使用中の識別子です。', 'identifier_max' => '識別子は最大100文字まで入力できます。', 'permission_ids_array' => '権限リストは配列形式である必要があります。', 'permission_ids_exists' => '選択した権限の中に無効な権限があります。', diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/schedule.php b/lang-packs/_bundled/g7-core-ja/backend/ja/schedule.php index b6b28d0c..baf20f26 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/schedule.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/schedule.php @@ -1,36 +1,39 @@ 'スケジュール一覧を取得しました。', - 'fetch_failed' => 'スケジュール一覧の取得に失敗しました。', + 'fetch_success' => 'スケジュールリストを閲覧しました。', + 'fetch_failed' => 'スケジュールリスト閲覧に失敗しました。', 'create_success' => 'スケジュールが作成されました。', - 'create_failed' => 'スケジュールの作成に失敗しました。', + 'create_failed' => 'スケジュール作成に失敗しました。', 'update_success' => 'スケジュールが編集されました。', - 'update_failed' => 'スケジュールの編集に失敗しました。', + 'update_failed' => 'スケジュール編集に失敗しました。', 'delete_success' => 'スケジュールが削除されました。', - 'delete_failed' => 'スケジュールの削除に失敗しました。', + 'delete_failed' => 'スケジュール削除に失敗しました。', 'run_success' => 'スケジュールが実行されました。', - 'run_failed' => 'スケジュールの実行に失敗しました。', + 'run_failed' => 'スケジュール実行に失敗しました。', 'duplicate_success' => 'スケジュールが複製されました。', - 'duplicate_failed' => 'スケジュールの複製に失敗しました。', + 'duplicate_failed' => 'スケジュール複製に失敗しました。', 'bulk_status_updated' => '選択されたスケジュールのステータスが変更されました。', - 'bulk_update_status_failed' => 'スケジュール一括ステータス変更に失敗しました。', + 'bulk_update_status_failed' => 'スケジュールステータス一括変更に失敗しました。', 'bulk_delete_success' => '選択されたスケジュールが削除されました。', 'bulk_delete_failed' => 'スケジュール一括削除に失敗しました。', - 'statistics_success' => 'スケジュール統計を取得しました。', - 'statistics_failed' => 'スケジュール統計の取得に失敗しました。', - 'history_fetch_success' => '実行履歴を取得しました。', - 'history_fetch_failed' => '実行履歴の取得に失敗しました。', + 'statistics_success' => 'スケジュール統計を閲覧しました。', + 'statistics_failed' => 'スケジュール統計閲覧に失敗しました。', + 'history_fetch_success' => '実行履歴を閲覧しました。', + 'history_fetch_failed' => '実行履歴閲覧に失敗しました。', 'history_delete_success' => '実行履歴が削除されました。', - 'history_delete_failed' => '実行履歴の削除に失敗しました。', + 'history_delete_failed' => '実行履歴削除に失敗しました。', 'history_not_found' => '実行履歴が見つかりません。', 'copy' => 'コピー', - 'shell_command_failed' => 'シェルコマンドの実行に失敗しました。', - 'http_request_failed' => 'HTTPリクエスト失敗(ステータス::status)', + 'shell_command_failed' => 'シェルコマンド実行に失敗しました。', + 'http_request_failed' => 'HTTP リクエスト失敗(ステータス::status)', + 'url_not_public' => '内部ネットワークアドレス(プライベート IP·localhost など)は呼び出せません。社内アドレスを呼び出す必要がある場合は、環境設定 > セキュリティで「内部ネットワークアドレス呼び出し許可」をオンにしてください。', + 'shell_not_allowed' => '許可されていないシェルコマンドです。サーバーに登録された実行ファイルのみスケジュール実行できます。', + 'artisan_not_allowed' => 'この Artisan コマンドはセキュリティ上の理由からスケジュール実行できません。', 'type' => [ - 'artisan' => 'Artisanコマンド', + 'artisan' => 'Artisan コマンド', 'shell' => 'シェルコマンド', - 'url' => 'URL呼び出し', + 'url' => 'URL 呼び出し', ], 'frequency' => [ 'everyMinute' => '毎分', @@ -47,7 +50,7 @@ return [ 'never' => '未実行', ], 'trigger_type' => [ - 'scheduled' => 'スケジュール実行', + 'scheduled' => '予約実行', 'manual' => '手動実行', ], 'duration' => [ @@ -56,51 +59,48 @@ return [ ], 'validation' => [ 'page_integer' => 'ページ番号は整数である必要があります。', - 'page_min' => 'ページ番号は1以上である必要があります。', + 'page_min' => 'ページ番号は 1 以上である必要があります。', 'per_page_integer' => 'ページあたりの項目数は整数である必要があります。', - 'per_page_min' => 'ページあたりの項目数は1以上である必要があります。', - 'per_page_max' => 'ページあたりの項目数は100以下である必要があります。', + 'per_page_min' => 'ページあたりの項目数は 1 以上である必要があります。', + 'per_page_max' => 'ページあたりの項目数は 100 以下である必要があります。', 'type_invalid' => '無効なタスクタイプです。', 'frequency_invalid' => '無効な実行周期です。', 'status_invalid' => '無効なステータスです。', 'last_result_invalid' => '無効な実行結果です。', 'history_status_invalid' => '無効な履歴ステータスです。', 'trigger_type_invalid' => '無効なトリガータイプです。', - 'created_from_invalid' => '開始日のフォーマットが正しくありません。', - 'created_to_invalid' => '終了日のフォーマットが正しくありません。', + 'created_from_invalid' => '開始日の形式が正しくありません。', + 'created_to_invalid' => '終了日の形式が正しくありません。', 'created_to_after_from' => '終了日は開始日以降である必要があります。', - 'started_from_invalid' => '開始日のフォーマットが正しくありません。', - 'started_to_invalid' => '終了日のフォーマットが正しくありません。', + 'started_from_invalid' => '開始日の形式が正しくありません。', + 'started_to_invalid' => '終了日の形式が正しくありません。', 'started_to_after_from' => '終了日は開始日以降である必要があります。', 'sort_by_invalid' => '無効なソート基準です。', 'sort_order_invalid' => '無効なソート順序です。', 'name_required' => 'タスク名は必須です。', - 'name_max' => 'タスク名は255文字以下である必要があります。', + 'name_max' => 'タスク名は 255 文字以下である必要があります。', 'type_required' => 'タスクタイプは必須です。', 'command_required' => 'コマンドは必須です。', - 'command_max' => 'コマンドは2000文字以下である必要があります。', - 'expression_required' => 'Cron式は必須です。', - 'expression_max' => 'Cron式は100文字以下である必要があります。', + 'command_max' => 'コマンドは 2000 文字以下である必要があります。', + 'expression_required' => 'Cron 式は必須です。', + 'expression_max' => 'Cron 式は 100 文字以下である必要があります。', 'frequency_required' => '実行周期は必須です。', - 'timeout_integer' => 'タイムアウト制限は整数である必要があります。', - 'timeout_min' => 'タイムアウト制限は1秒以上である必要があります。', - 'timeout_max' => 'タイムアウト制限は86400秒(24時間)以下である必要があります。', - 'ids_required' => 'IDリストは必須です。', - 'ids_array' => 'IDリストは配列である必要があります。', - 'ids_min' => '最低1つ以上のIDが必要です。', - 'id_integer' => 'IDは整数である必要があります。', - 'id_exists' => '存在しないスケジュールです。', - 'is_active_required' => '有効化は必須です。', - 'is_active_boolean' => '有効化はtrue/falseである必要があります。', + 'timeout_integer' => 'タイムアウトは整数である必要があります。', + 'timeout_min' => 'タイムアウトは 1 秒以上である必要があります。', + 'timeout_max' => 'タイムアウトは 86400 秒(24 時間)以下である必要があります。', + 'ids_required' => 'ID リストは必須です。', + 'ids_array' => 'ID リストは配列である必要があります。', + 'ids_min' => '最小 1 個以上の ID が必要です。', + 'id_integer' => 'ID は整数である必要があります。', + 'id_exists' => 'スケジュールが存在しません。', + 'is_active_required' => '有効化の有無は必須です。', + 'is_active_boolean' => '有効化の有無は真/偽である必要があります。', ], 'permissions' => [ - 'read' => 'スケジュール調査', + 'read' => 'スケジュール閲覧', 'create' => 'スケジュール作成', 'update' => 'スケジュール編集', 'delete' => 'スケジュール削除', 'run' => 'スケジュール実行', ], - 'url_not_public' => '内部ネットワークアドレス(プライベートIP·localhost等)は呼び出せません。社内アドレスを呼び出す必要がある場合は、環境設定 > セキュリティで「内部ネットワークアドレス呼び出し許可」をオンにしてください。', - 'shell_not_allowed' => '許可されていないシェルコマンドです。サーバーに登録された実行ファイルのみスケジュールで実行できます。', - 'artisan_not_allowed' => 'このArtisanコマンドはセキュリティ上の理由によりスケジュールで実行できません。', ]; diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/search.php b/lang-packs/_bundled/g7-core-ja/backend/ja/search.php index eb2109b8..8ee409a6 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/search.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/search.php @@ -6,7 +6,7 @@ return [ 'no_results' => '検索結果がありません。', 'view_more' => 'もっと見る', 'validation' => [ - 'q_min' => '検索語は2文字以上入力してください。', + 'q_min' => '検索語は2文字以上で入力してください。', 'q_max' => '検索語は最大200文字まで入力可能です。', 'page_integer' => 'ページ番号は数字である必要があります。', 'page_min' => 'ページ番号は1以上である必要があります。', diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/seo.php b/lang-packs/_bundled/g7-core-ja/backend/ja/seo.php index 78687cfa..ec1d4c3c 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/seo.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/seo.php @@ -1,21 +1,24 @@ 'SEOキャッシュがすべて削除されました。', - 'cache_cleared_layout' => ':layout レイアウトのSEOキャッシュ :count件が削除されました。', - 'warmup_started' => 'SEOキャッシュウォーミングを開始します。', - 'warmup_started_layout' => ':layout レイアウトのSEOキャッシュウォーミングを開始します。', - 'warmup_dispatched' => 'SEOキャッシュウォーミングが開始されました。', - 'sitemap_regenerated' => 'サイトマップの再生成が完了しました。', - 'sitemap_regenerate_failed' => 'サイトマップの再生成に失敗しました。', - 'sitemap_disabled' => 'サイトマップ生成が無効化されています。', - 'stats_period' => '最近 :days 日間のSEOキャッシュ統計', + 'cache_cleared_all' => 'SEO キャッシュがすべて削除されました。', + 'cache_cleared_layout' => ':layout レイアウトの SEO キャッシュ :count 件が削除されました。', + 'warmup_started' => 'SEO キャッシュ ウォーミングアップを開始します。', + 'warmup_started_layout' => ':layout レイアウトの SEO キャッシュ ウォーミングアップを開始します。', + 'warmup_dispatched' => 'SEO キャッシュ ウォーミングアップが開始されました。', + 'sitemap_regenerated' => 'Sitemap 再生成が完了しました。', + 'sitemap_regenerate_dispatched' => 'Sitemap 再生成を開始しました。完了まで時間がかかる場合があります。', + 'sitemap_regenerate_failed' => 'Sitemap 再生成に失敗しました。', + 'sitemap_disabled' => 'Sitemap 生成が無効化されています。', + 'sitemap_not_ready' => 'Sitemap を準備中です。しばらく後に再度お試しください。', + 'sitemap_generate_failed' => 'Sitemap 生成に失敗しました: :error', + 'stats_period' => '最近 :days 日間の SEO キャッシュ統計', 'stats_overall' => '全体統計', 'stats_metric' => '項目', 'stats_value' => '値', 'stats_total_entries' => '総キャッシュ項目', - 'stats_hits' => 'キャッシュヒット', - 'stats_misses' => 'キャッシュミス', + 'stats_hits' => 'キャッシュ ヒット', + 'stats_misses' => 'キャッシュ ミス', 'stats_hit_rate' => 'ヒット率', 'stats_avg_response_time' => '平均応答時間', 'stats_by_layout' => 'レイアウト別統計', diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php b/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php index e2c41f60..120b375b 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/settings.php @@ -5,12 +5,12 @@ return [ 'fetch_failed' => '設定のインポートに失敗しました。', 'save_success' => '設定が正常に保存されました。', 'save_failed' => '設定の保存に失敗しました。', - 'update_success' => '設定が正常に更新されました。', - 'update_failed' => '設定の更新に失敗しました。', + 'update_success' => '設定が正常にアップデートされました。', + 'update_failed' => '設定のアップデートに失敗しました。', 'delete_success' => '設定が正常に削除されました。', 'delete_failed' => '設定の削除に失敗しました。', 'save_error' => '設定の保存中にエラーが発生しました。', - 'update_error' => '設定の更新中にエラーが発生しました。', + 'update_error' => '設定のアップデート中にエラーが発生しました。', 'cache_clear_success' => 'キャッシュが正常にクリアされました。', 'cache_clear_failed' => 'キャッシュのクリアに失敗しました。', 'cache_clear_error' => 'キャッシュのクリア中にエラーが発生しました。', @@ -20,7 +20,11 @@ return [ 'backup_success' => 'データベースバックアップが正常に開始されました。', 'backup_failed' => 'データベースバックアップに失敗しました。', 'backup_error' => 'データベースバックアップ中にエラーが発生しました。', - 'save_individual_failed' => '設定の保存に失敗しました::error', + 'backup_path_required' => 'バックアップパスを入力してください。', + 'restore_success' => '設定が正常に復元されました。', + 'restore_failed' => '設定の復元に失敗しました。', + 'restore_error' => '設定の復元中にエラーが発生しました。', + 'save_individual_failed' => '設定の保存に失敗しました: :error', 'invalid_password' => 'パスワードが一致しません。', 'password_required' => 'パスワードを入力してください。', 'app_key_regenerated' => 'アプリケーションキーが正常に再生成されました。', @@ -59,10 +63,10 @@ return [ 'ses' => 'SES (Amazon)', ], ], - 'driver_test_success' => 'すべてのドライバー接続テストが成功しました。', - 'driver_test_partial' => '一部のドライバー接続テストが失敗しました。', - 'driver_test_error' => 'ドライバー接続テスト中にエラーが発生しました。', - 'unknown_driver' => '不明なドライバーです。', + 'driver_test_success' => 'すべてのドライバ接続テストが成功しました。', + 'driver_test_partial' => '一部のドライバ接続テストが失敗しました。', + 'driver_test_error' => 'ドライバ接続テスト中にエラーが発生しました。', + 'unknown_driver' => '不明なドライバです。', 's3_test_success' => 'S3バケットに正常に接続されました。', 's3_test_failed' => 'S3バケットへの接続に失敗しました。', 's3_missing_config' => 'S3設定が不足しています。(バケット、リージョン、アクセスキー、シークレットキー)', @@ -82,10 +86,11 @@ return [ 'memcached_connection_failed' => 'Memcachedサーバーに接続できません。', 'websocket_test_success' => 'Websocketサーバーに正常に接続されました。', 'websocket_test_failed' => 'Websocketサーバーへの接続に失敗しました。', - 'websocket_connection_refused' => 'Websocketサーバーに接続できません。サーバーが実行されているか確認してください。', + 'websocket_invalid_host' => 'Websocketホスト設定が正しくありません。ホストにはアドレスのみを入力し、アカウント情報(@)またはhttp/https以外のプロトコルは使用できません。', + 'websocket_connection_refused' => 'Websocketサーバーに接続できません。サーバーが実行中であるか確認してください。', 'invalid_email' => '無効なメールアドレスです。', 'test_mail_subject' => ':app_name テストメール', - 'test_mail_body' => 'これはグヌボード7から送信されたテストメールです。このメールを受け取った場合、メール設定が正しく構成されています。', + 'test_mail_body' => 'これはGnuboard 7から送信されたテストメールです。このメールを受け取られた場合、メール設定が正しく構成されています。', 'test_mail_sent' => 'テストメールが正常に送信されました。', 'test_mail_failed' => 'テストメールの送信に失敗しました。', 'test_mail_error' => 'テストメール送信中にエラーが発生しました。', @@ -93,65 +98,60 @@ return [ 'check_success' => 'アップデート確認が完了しました。', 'check_failed' => 'アップデート確認に失敗しました。', 'update_available' => '新しいアップデートがあります。', - 'no_update' => '現在は最新バージョンです。', - 'maintenance_mode_active' => 'システム保守中です。しばらく後に再度お試しください。', + 'no_update' => '現在、最新バージョンです。', + 'maintenance_mode_active' => 'システムメンテナンス中です。しばらく後に再度お試しください。', 'invalid_github_url' => 'GitHubリポジトリURLが無効です。', 'download_failed' => ':version バージョンのダウンロードに失敗しました。', - 'zip_extract_failed' => 'ZIPファイルの解凍に失敗しました。', + 'zip_extract_failed' => 'ZIPファイルの展開に失敗しました。', 'invalid_package' => 'ダウンロードされたパッケージが無効です。', - 'invalid_package_not_g7' => '指定されたディレクトリはグヌボード7プロジェクトではありません。config/app.phpファイルとversionの設定が必要です。', - 'composer_failed' => 'composer installの実行に失敗しました。', - 'pending_path_create_failed' => '_pendingディレクトリ(:path)の作成に失敗しました::error', - 'pending_path_not_writable' => '_pendingディレクトリ(:path)への書き込み権限がありません。', + 'invalid_package_not_g7' => '指定されたディレクトリはGnuboard 7プロジェクトではありません。config/app.phpファイルとversion設定が必要です。', + 'composer_failed' => 'composer install の実行に失敗しました。', + 'pending_path_create_failed' => '_pending ディレクトリ(:path) の作成に失敗しました: :error', + 'pending_path_not_writable' => '_pending ディレクトリ(:path) への書き込み権限がありません。', 'downloading' => 'アップデートをダウンロード中...', 'extracting' => '解凍中...', 'validating' => 'パッケージを検証中...', - 'running_composer' => 'composer installを実行中...', + 'running_composer' => 'composer install を実行中...', 'step_check' => 'アップデートを確認中...', 'step_validate_pending' => '環境を検証中...', 'step_maintenance' => 'メンテナンスモードを有効化中...', 'step_download' => 'ダウンロード中...', 'step_backup' => 'バックアップを作成中...', 'step_apply' => 'ファイルを適用中...', - 'step_composer' => 'composer installを実行中...', - 'step_migration' => 'マイグレーションを実行中...', + 'step_composer' => 'composer install 中...', + 'step_migration' => 'マイグレーション を実行中...', 'step_upgrade' => 'アップグレードステップを実行中...', - 'step_composer_prod' => '運用ディレクトリのcomposer installを実行中...', + 'step_composer_prod' => '運用ディレクトリの composer install 中...', 'step_cleanup' => 'クリーンアップ中...', - 'github_url_not_configured' => 'GitHubリポジトリのURLが設定されていません。', - 'github_api_failed' => 'GitHub APIに接続できません。', - 'github_token_required' => 'プライベートリポジトリです。GitHubアクセストークンを設定してください。', - 'github_token_invalid' => 'GitHubアクセストークンが無効であるか、権限が不足しています。(HTTP :status — :message)', - 'github_repo_not_found' => 'GitHubリポジトリが見つかりません。(HTTP :status — :message)', - 'github_repo_not_found_no_token' => 'GitHubリポジトリが見つかりません。プライベートリポジトリの場合はアクセストークンを設定してください。(HTTP :status — :message)', - 'github_api_error' => 'GitHub APIエラーが発生しました。(HTTP :status — :message)', - 'no_releases_found' => 'GitHubリポジトリにリリースがありません。(HTTP :status — :message)', - 'log_api_call_failed' => 'コアアップデート:GitHub APIの呼び出しに失敗しました', - 'log_auth_failed' => 'コアアップデート:GitHub認証に失敗しました', - 'log_not_found' => 'コアアップデート:GitHubリポジトリ/リリースが見つかりません', - 'log_unexpected_status' => 'コアアップデート:予期しないHTTPステータスコード', - 'log_version_check_error' => 'コアアップデート:最新バージョン確認中にエラーが発生しました', - 'unknown_error' => '予期しないエラーが発生しました。', + 'apply_mode_incremental' => 'ファイル適用: コアが実際に変更したファイルのみを適用しました (新規 :added 個、変更 :changed 個)。その他のファイルは現在の状態のまま保持されました。', + 'apply_mode_incremental_prune_hint' => '新バージョンで削除されたファイルをあわせてクリーンアップするには、`--prune` オプションで再実行するか、`php artisan hotfix:rollback-stale-files --prune` を使用してください。', + 'apply_mode_prune' => 'ファイル適用: 全体上書き + 削除ファイルのクリーンアップを実行しました (--prune)。', + 'apply_mode_fallback' => 'ファイル適用: バックアップがないため、増分適用をスキップして全体上書きを実行しました。ユーザーのカスタムファイル保持をご希望の場合は、次回以降はバックアップを保持したままアップデートしてください。', + 'github_url_not_configured' => 'GitHub リポジトリ URL が設定されていません。', + 'github_api_failed' => 'GitHub API に接続できません。', + 'github_token_required' => 'プライベートリポジトリです。GitHub アクセストークンを設定してください。', + 'github_token_invalid' => 'GitHub アクセストークンが無効であるか、権限が不足しています。(HTTP :status — :message)', + 'github_repo_not_found' => 'GitHub リポジトリが見つかりません。(HTTP :status — :message)', + 'github_repo_not_found_no_token' => 'GitHub リポジトリが見つかりません。プライベートリポジトリの場合はアクセストークンを設定してください。(HTTP :status — :message)', + 'github_api_error' => 'GitHub API エラーが発生しました。(HTTP :status — :message)', + 'no_releases_found' => 'GitHub リポジトリにリリースがありません。(HTTP :status — :message)', + 'log_api_call_failed' => 'コアアップデート: GitHub API 呼び出し失敗', + 'log_auth_failed' => 'コアアップデート: GitHub 認証失敗', + 'log_not_found' => 'コアアップデート: GitHub リポジトリ/リリース未検出', + 'log_unexpected_status' => 'コアアップデート: 予期しない HTTP ステータスコード', + 'log_version_check_error' => 'コアアップデート: 最新バージョン確認時にエラーが発生', + 'unknown_error' => '不明なエラーが発生しました。', 'system_requirements_failed' => 'システム要件が満たされていません:', - 'no_extract_method_available' => '利用可能なアーカイブ抽出方法がありません。PHP zip拡張機能(ZipArchive)またはunzipコマンド(Linux)のいずれかが必要です。または--sourceオプションで手動アップデートできます。', - 'manual_update_guide' => '手動アップデート:GitHubからリリースZIPをダウンロードして解凍し、php artisan core:update --source=/解凍パス コマンドを実行してください。', - 'archive_url_not_found' => ':typeアーカイブURLが見つかりません。次の方法を試みます。', - 'extracting_with' => ':method方式で解凍中...', + 'no_extract_method_available' => '利用可能なアーカイブ抽出方法がありません。PHP zip 拡張 (ZipArchive) または unzip コマンド (Linux) のいずれかが必要です。または --source オプションで手動アップデートできます。', + 'manual_update_guide' => '手動アップデート: GitHub からリリース ZIP をダウンロードして解凍後、php artisan core:update --source=/解凍/パス コマンドを実行してください。', + 'archive_url_not_found' => ':type アーカイブ URL が見つかりません。次の方法を試します。', + 'extracting_with' => ':method 方式で解凍中...', 'extract_empty' => '解凍されたディレクトリが空です。', - 'extract_fallback' => ':method方式に失敗しました::error — 次の方法を試みます。', - 'all_extract_methods_failed' => 'すべてのアーカイブ抽出方法が失敗しました。--sourceオプションで手動アップデートを試みてください。', - 'unzip_command_failed' => 'unzipコマンド実行に失敗しました(終了コード::code)。:output', - 'zip_file_not_found' => '指定されたZIPファイルが見つかりません::path', - 'source_vendor_missing' => 'ソースディレクトリにvendorがありません。composer installが実行されていない可能性があります。', - 'composer_failed_with_output' => 'composer installの実行に失敗しました。:output', - 'apply_mode_incremental' => 'ファイル適用: コアが実際に変更したファイルのみ適用しました (新規 :added個、変更 :changed個)。その他のファイルは現在の状態のまま保存されました。', - 'apply_mode_incremental_prune_hint' => '新しいバージョンで削除されたファイルを一緒に整理する場合は、`--prune` オプションで再実行するか、`php artisan hotfix:rollback-stale-files --prune` を使用してください。', - 'apply_mode_prune' => 'ファイル適用: 完全な上書き + 削除されたファイルの整理を実行しました (--prune)。', - 'apply_mode_fallback' => 'ファイル適用: バックアップがないため増分適用をスキップし、完全な上書きを実行しました。ユーザーカスタムファイルの保存を希望する場合は、次回からはバックアップを維持したまま更新してください。', + 'extract_fallback' => ':method 方式失敗: :error — 次の方法を試します。', + 'all_extract_methods_failed' => 'すべてのアーカイブ抽出方法が失敗しました。--source オプションで手動アップデートを試してください。', + 'unzip_command_failed' => 'unzip コマンド実行失敗 (終了コード: :code)。:output', + 'zip_file_not_found' => '指定された ZIP ファイルが見つかりません: :path', + 'source_vendor_missing' => 'ソースディレクトリに vendor がありません。composer install が実行されていない可能性があります。', + 'composer_failed_with_output' => 'composer install 実行に失敗しました。:output', ], - 'backup_path_required' => 'バックアップパスを入力してください。', - 'restore_success' => '設定が正常に復元されました。', - 'restore_failed' => '設定の復元に失敗しました。', - 'restore_error' => '設定の復元中にエラーが発生しました。', - 'websocket_invalid_host' => 'Websocketホスト設定が正しくありません。ホストにはアドレスのみを入力し、アカウント情報(@)やhttp/https以外のプロトコルは使用できません。', ]; diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/templates.php b/lang-packs/_bundled/g7-core-ja/backend/ja/templates.php index 221d699e..9219c24e 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/templates.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/templates.php @@ -4,54 +4,54 @@ return [ 'title' => 'テンプレート管理', 'errors' => [ 'force_update_no_source' => ':template を強制アップデートするソースが見つかりません。バンドルおよび GitHub URL の両方がありません。', - 'downgrade_blocked' => 'ダウングレードがブロックされました (:from → :to)。意図的なダウングレードは --force オプションを使用してください。', + 'downgrade_blocked' => 'ダウングレードがブロックされています (:from → :to)。意図的なダウングレードは --force オプションを使用してください。', 'not_found' => 'テンプレートが見つかりません。(:template)', 'not_installed' => 'テンプレートがインストールされていません。(:template)', 'already_active' => '既に有効化されているテンプレートです。', 'invalid_format' => '無効なテンプレート形式です。', - 'activation_failed' => 'テンプレート有効化に失敗しました。', - 'file_copy_failed' => 'ファイルコピーに失敗しました。', - 'dist_directory_not_found' => 'テンプレートビルド結果物(dist)が見つかりません。', + 'activation_failed' => 'テンプレートの有効化に失敗しました: :error', + 'file_copy_failed' => 'ファイルのコピーに失敗しました: :error', + 'dist_directory_not_found' => 'テンプレートビルド成果物(dist)が見つかりません。', 'invalid_directory_name' => '無効なディレクトリ名です。vendor-name 形式を使用してください。', - 'dependency_not_met' => '必須の依存関係 :dependency (:type)が満たされていません。', + 'dependency_not_met' => '必須依存関係 :dependency (:type) が満たされていません。', 'version_mismatch' => ':dependency のバージョンが一致しません。(必須: :required、インストール済み: :installed)', - 'deactivation_failed' => 'テンプレート無効化に失敗しました。', - 'installation_failed' => 'テンプレートインストールに失敗しました。', - 'uninstallation_failed' => 'テンプレート削除に失敗しました。', + 'deactivation_failed' => 'テンプレートの無効化に失敗しました: :error', + 'installation_failed' => 'テンプレートのインストールに失敗しました: :error', + 'uninstallation_failed' => 'テンプレートの削除に失敗しました: :error', 'template_json_not_found' => 'template.json ファイルが見つかりません。', 'template_json_invalid' => 'template.json ファイルが無効です。', 'missing_required_fields' => '必須フィールドが不足しています。', 'invalid_type' => '無効なテンプレートタイプです。(admin または user のみ許可)', - 'copy_rollback_failed' => 'ファイルコピーロールバックに失敗しました。', + 'copy_rollback_failed' => 'ファイルコピーのロールバックに失敗しました: :error', 'invalid_layout_json' => 'レイアウト JSON ファイルが無効です。', 'missing_layout_name' => 'レイアウト JSON に layout_name フィールドが不足しています。', - 'layout_registration_failed' => 'レイアウト登録に失敗しました。', - 'layout_validation_failed' => ':template テンプレートのレイアウト検証に失敗しました。(:count 個のエラー)', + 'layout_registration_failed' => 'レイアウト登録に失敗しました: :error', + 'layout_validation_failed' => ':template テンプレートのレイアウト検証に失敗しました。(:count 件のエラー)', 'layout_validation_json_error' => ':file - JSON パース エラー: :error', 'layout_validation_missing_name' => ':file - layout_name フィールドが不足しています。', 'layout_validation_partial_error' => ':file - Partial 処理エラー (:partial): :error', - 'override_layout_registration_failed' => 'オーバーライドレイアウト登録に失敗しました。', + 'override_layout_registration_failed' => 'オーバーライドレイアウト登録に失敗しました: :error', 'file_not_found' => 'ファイルが見つかりません。', 'file_type_not_allowed' => '許可されていないファイルタイプです。', 'components_not_found' => 'コンポーネント定義ファイルが見つかりません。', 'locale_not_supported' => ':template テンプレートは :locale 言語をサポートしていません。', 'language_file_not_found' => ':locale 言語ファイルが見つかりません。', 'invalid_json' => '無効な JSON 形式です。', - 'invalid_cache_data' => 'キャッシュデータが破損しました。もう一度お試しください。', + 'invalid_cache_data' => 'キャッシュデータが破損しています。もう一度お試しください。', 'unknown_error' => '不明なエラーが発生しました。', - 'zip_open_failed' => 'ZIP ファイルを開くことができません。', + 'zip_open_failed' => 'ZIP ファイルを開くことができません: :error', 'identifier_missing' => 'テンプレート識別子が不足しています。', - 'already_installed' => '既にインストールされているテンプレートです。', - 'install_failed' => 'テンプレートインストールに失敗しました。', + 'already_installed' => '既にインストール済みのテンプレートです。', + 'install_failed' => 'テンプレートのインストールに失敗しました: :error', 'github_url_invalid' => '無効な GitHub URL 形式です。', - 'github_download_failed' => 'GitHub からのダウンロードに失敗しました。', + 'github_download_failed' => 'GitHub からのダウンロードに失敗しました: :error', 'github_repo_not_found' => 'GitHub リポジトリが見つかりません。', - 'refresh_layouts_failed' => 'レイアウト更新に失敗しました。', + 'refresh_layouts_failed' => 'レイアウト更新に失敗しました: :error', 'routes_not_found' => 'ルートファイルが見つかりません。', - 'no_active_template' => '有効化されているテンプレートがありません', - 'layout_load_failed' => 'レイアウトの読み込みに失敗しました', - 'component_load_failed' => 'コンポーネントの読み込みに失敗しました', - 'translation_load_failed' => '多言語ファイルの読み込みに失敗しました', + 'no_active_template' => '有効化されたテンプレートがありません', + 'layout_load_failed' => 'レイアウトの読み込みに失敗しました: :error', + 'component_load_failed' => 'コンポーネントの読み込みに失敗しました: :error', + 'translation_load_failed' => '多言語ファイルの読み込みに失敗しました: :error', 'template_init_error' => 'テンプレートエンジンの初期化中にエラーが発生しました', 'contact_admin_for_template' => 'システム管理者に問い合わせてテンプレートを有効化してください。', 'missing_error_config' => 'テンプレートに error_config セクションがありません。エラーページレイアウト設定が必要です。', @@ -61,17 +61,17 @@ return [ 'template_not_active' => 'テンプレートが有効化されていません: :identifier', 'dependencies_not_met' => 'テンプレートの必須依存関係が満たされていません。', 'service_unavailable' => 'サービスは一時的に利用できません。', - 'contact_admin' => '管理者にお問い合わせください。', - 'operation_in_progress' => 'テンプレート「:name」は現在 :status 状態です。処理が完了するまでお待ちください。', - 'github_api_failed' => 'GitHub API 呼び出しに失敗しました。', + 'contact_admin' => '管理者に問い合わせてください。', + 'operation_in_progress' => 'テンプレート ":name" は現在 :status 状態です。処理が完了するまでお待ちください。', + 'github_api_failed' => 'GitHub API 呼び出しに失敗しました: :error', 'invalid_github_url' => '無効な GitHub URL です。', 'zip_url_not_found' => 'ZIP ダウンロード URL が見つかりません。', - 'download_failed' => 'テンプレート「:template」:version バージョンのダウンロードに失敗しました。', - 'zip_extract_failed' => 'ZIP 解凍に失敗しました。', - 'extracted_dir_not_found' => '解凍されたディレクトリが見つかりません。', - 'reload_failed' => 'テンプレート再読み込みに失敗しました。', - 'delete_directory_failed' => 'テンプレートディレクトリの削除に失敗しました。', - 'update_failed' => 'テンプレート「:template」のアップデートに失敗しました: :error', + 'download_failed' => 'テンプレート ":template" :version バージョンのダウンロードに失敗しました: :error', + 'zip_extract_failed' => 'ZIP 圧縮解除に失敗しました: :error', + 'extracted_dir_not_found' => '圧縮解除されたディレクトリが見つかりません。', + 'reload_failed' => 'テンプレート再読み込みに失敗しました: :error', + 'delete_directory_failed' => 'テンプレートディレクトリの削除に失敗しました: :error', + 'update_failed' => 'テンプレート ":template" のアップデートに失敗しました: :error', 'invalid_layout_strategy' => '無効なレイアウト戦略です。(overwrite または keep のみ許可)', ], 'messages' => [ @@ -79,43 +79,43 @@ return [ 'deactivated' => 'テンプレートが無効化されました。', 'installed' => 'テンプレートがインストールされました。', 'uninstalled' => 'テンプレートが削除されました。', - 'previous_template_deactivated' => '前のアクティブテンプレートが無効化されました。', + 'previous_template_deactivated' => '前の有効なテンプレートが無効化されました。', 'files_copied' => 'テンプレートファイルがコピーされました。', 'files_removed' => 'テンプレートファイルが削除されました。', - 'routes_retrieved' => 'テンプレートルート情報を取得しました。', + 'routes_retrieved' => 'テンプレートルート情報が照会されました。', 'routes_not_found' => 'テンプレートルートが見つかりません。', 'layout_served' => 'レイアウトが提供されました。', - 'config_retrieved' => 'テンプレート設定情報を照会しました。', + 'config_retrieved' => 'テンプレート設定情報を閲覧しました。', 'template_activated' => 'テンプレートが有効化されました。', 'template_deactivated' => 'テンプレートが無効化されました。', 'template_uninstalled' => 'テンプレートが削除されました。', 'editor_spec_empty' => 'エディタースペックが空です。', - 'editor_assets_retrieved' => 'エディターアセット情報を取得しました。', - 'editor_assets_missing' => 'エディターアセットマニフェストがありません(ビルドされていません)。', - 'language_retrieved' => '多言語データを取得しました。', + 'editor_spec_retrieved' => 'エディタースペックを閲覧しました。', + 'editor_assets_retrieved' => 'エディター資産情報を閲覧しました。', + 'editor_assets_missing' => 'エディター資産マニフェストがありません(ビルドされていません)。', + 'language_retrieved' => '多言語データを閲覧しました。', 'language_empty' => '多言語データが空です。', - 'editor_spec_retrieved' => 'エディタースペックを取得しました。', ], - 'not_found' => 'テンプレート :template が見つかりません。', - 'layout_not_found' => 'レイアウトが見つかりません。', + 'not_found' => 'テンプレート :template を見つけることができません。', + 'layout_not_found' => 'レイアウトを見つけることができません。', 'info' => [ - 'scanning' => 'テンプレートディレクトリをスキャン中です。', - 'loading' => 'テンプレートを読み込み中です。', - 'validating_dependencies' => '依存関係を検証中です。', - 'copying_files' => 'ファイルをコピー中です。', - 'removing_files' => 'ファイルを削除中です。', - 'template_found' => 'テンプレートが見つかりました。', - 'no_templates_found' => 'テンプレートが見つかりませんでした。', - 'public_directory_cleaned' => 'publicディレクトリが整理されました。', + 'scanning' => 'テンプレートディレクトリをスキャン中...', + 'loading' => 'テンプレートをロード中...', + 'validating_dependencies' => '依存性を検証中...', + 'copying_files' => 'ファイルをコピー中...', + 'removing_files' => 'ファイルを削除中...', + 'template_found' => 'テンプレートを発見しました。', + 'no_templates_found' => 'テンプレートを発見できませんでした。', + 'public_directory_cleaned' => 'public ディレクトリがクリーンアップされました。', 'no_layouts_directory' => 'レイアウトディレクトリが存在しません。', - 'no_layout_files' => 'レイアウトJSONファイルが存在しません。', + 'no_layout_files' => 'レイアウト JSON ファイルが存在しません。', 'layout_registered' => 'レイアウトが登録されました。', 'layouts_deleted' => 'レイアウトが削除されました。', 'cache_cleared' => 'テンプレートキャッシュが削除されました。', 'cache_warmed' => 'テンプレートキャッシュが作成されました。', 'no_overrides_directory' => 'オーバーライドディレクトリが存在しません。', 'no_override_modules' => 'オーバーライド対象のモジュールディレクトリが存在しません。', - 'no_override_layouts_for_module' => 'このモジュール用のオーバーライドレイアウトがありません。', + 'no_override_layouts_for_module' => '該当モジュールにオーバーライドレイアウトがありません。', 'override_layout_registered' => 'オーバーライドレイアウトが登録されました。', 'override_layouts_registered' => 'オーバーライドレイアウトが登録されました。', 'override_layouts_cache_invalidated' => 'オーバーライドレイアウトキャッシュが無効化されました。', @@ -129,17 +129,17 @@ return [ 'updating' => 'アップデート中', ], 'types' => [ - 'admin' => '管理者用', - 'user' => 'ユーザー用', + 'admin' => '管理者向け', + 'user' => 'ユーザー向け', ], 'dependencies' => [ - 'section_title' => '依存関係', + 'section_title' => '依存性', 'modules' => '必須モジュール', 'plugins' => '必須プラグイン', - 'status_met' => '充足', - 'status_unmet' => '未充足', - 'no_dependencies' => '依存関係なし', - 'version_required' => '必要バージョン: :version', + 'status_met' => '満たしている', + 'status_unmet' => '満たしていない', + 'no_dependencies' => '依存性なし', + 'version_required' => '必要なバージョン: :version', 'version_installed' => 'インストール済みバージョン: :version', 'not_installed' => '未インストール', 'not_active' => '無効', @@ -150,111 +150,111 @@ return [ 'name_max' => 'テンプレート名は255文字を超えることはできません。', 'file_required' => 'ファイルは必須です。', 'file_invalid' => '有効なファイルではありません。', - 'file_must_be_zip' => 'ZIPファイルのみアップロード可能です。', + 'file_must_be_zip' => 'ZIP ファイルのみアップロード可能です。', 'file_max_size' => 'ファイルサイズは :sizeMB を超えることはできません。', - 'github_url_required' => 'GitHub URLは必須です。', - 'github_url_invalid' => 'GitHub URLが正しくありません。', - 'github_url_format' => 'GitHub URLの形式が正しくありません。(例: https://github.com/owner/repo)', - 'type_in' => 'テンプレートタイプはユーザーまたは管理者のみ許可されます。', + 'github_url_required' => 'GitHub URL は必須です。', + 'github_url_invalid' => 'GitHub URL が正しくありません。', + 'github_url_format' => 'GitHub URL の形式が正しくありません。(例:https://github.com/owner/repo)', + 'type_in' => 'テンプレートタイプは user または admin のみ許可されます。', 'search_max' => '検索キーワードは255文字を超えることはできません。', 'filters_max' => '検索フィルターは最大10個まで許可されます。', 'filter_field_required' => 'フィルターフィールドは必須です。', - 'filter_field_invalid' => '許可されていないフィルターフィールドです。', + 'filter_field_invalid' => '許可されないフィルターフィールドです。', 'filter_value_required' => 'フィルター値は必須です。', 'filter_value_max' => 'フィルター値は255文字を超えることはできません。', - 'filter_operator_invalid' => '許可されていないフィルター演算子です。(like、eq、starts_with、ends_withのみ許可)', - 'status_invalid' => '許可されていないステータス値です。(installed、not_installed、active、inactiveのみ許可)', - 'per_page_min' => 'ページあたりの項目数は最小1以上である必要があります。', - 'per_page_max' => 'ページあたりの項目数は最大100個まで許可されます。', + 'filter_operator_invalid' => '許可されないフィルター演算子です。(like、eq、starts_with、ends_with のみ許可)', + 'status_invalid' => '許可されない状態値です。(installed、not_installed、active、inactive のみ許可)', + 'per_page_min' => 'ページ当たりのアイテム数は最小1つ以上である必要があります。', + 'per_page_max' => 'ページ当たりのアイテム数は最大100個まで許可されます。', 'page_min' => 'ページ番号は最小1以上である必要があります。', ], 'install_success' => 'テンプレートが正常にインストールされました。', - 'install_failed' => 'テンプレートのインストールに失敗しました。', - 'preview_success' => 'manifestプレビューが完了しました。', - 'preview_failed' => 'manifestプレビューに失敗しました。', + 'install_failed' => 'テンプレートのインストールに失敗しました: :error', + 'preview_success' => 'manifest プレビューが完了しました。', + 'preview_failed' => 'manifest プレビューに失敗しました: :error', 'uninstall_success' => 'テンプレートが正常に削除されました。', - 'uninstall_failed' => 'テンプレートの削除に失敗しました。', + 'uninstall_failed' => 'テンプレートの削除に失敗しました: :error', 'activate_success' => 'テンプレートが有効化されました。', - 'activate_failed' => 'テンプレートの有効化に失敗しました。', - 'activate_warning' => 'テンプレートの有効化に必要な依存関係が充足されていません。', + 'activate_failed' => 'テンプレートの有効化に失敗しました: :error', + 'activate_warning' => 'テンプレートの有効化に必要な依存関係が満たされていません。', 'warnings' => [ 'missing_dependencies' => 'このテンプレートを有効化するには、以下の依存関係が必要です。', ], 'deactivate_success' => 'テンプレートが無効化されました。', - 'deactivate_failed' => 'テンプレートの無効化に失敗しました。', + 'deactivate_failed' => 'テンプレートの無効化に失敗しました: :error', 'fetch_success' => 'テンプレート情報を正常に取得しました。', 'license_not_found' => 'テンプレートライセンスファイルが見つかりません。', 'refresh_layouts_success' => 'テンプレートレイアウトが正常に更新されました。', - 'refresh_layouts_failed' => 'テンプレートレイアウトの更新に失敗しました。', - 'update_success' => 'テンプレート「:template」が :version バージョンにアップデートされました。', + 'refresh_layouts_failed' => 'テンプレートレイアウトの更新に失敗しました: :error', + 'update_success' => 'テンプレート「:template」がバージョン:versionにアップデートされました。', 'update_failed' => 'テンプレートのアップデートに失敗しました: :error', - 'update_hook_failed' => 'テンプレートアップデートフックの実行に失敗しました。', + 'update_hook_failed' => 'テンプレートアップデートフックの実行に失敗しました: :error', 'no_update_available' => 'アップデートする内容がありません。', 'check_updates_success' => 'アップデート確認が完了しました。', - 'check_updates_failed' => 'アップデート確認に失敗しました。', - 'not_installed' => 'テンプレート「:template」がインストールされていません。', - 'check_modified_layouts_success' => '編集済みレイアウトの確認が完了しました。', - 'check_modified_layouts_failed' => '編集済みレイアウトの確認に失敗しました。', - 'pending_not_found' => 'インストール待機リストからテンプレート「:template」が見つかりません。', + 'check_updates_failed' => 'アップデート確認に失敗しました: :error', + 'not_installed' => 'テンプレート「:template」はインストールされていません。', + 'check_modified_layouts_success' => '変更されたレイアウト確認が完了しました。', + 'check_modified_layouts_failed' => '変更されたレイアウト確認に失敗しました: :error', + 'pending_not_found' => 'インストール待機リストにテンプレート「:template」が見つかりません。', 'already_exists' => 'テンプレート「:template」は既に存在します。', - 'move_failed' => 'テンプレート「:template」の移動に失敗しました。', + 'move_failed' => 'テンプレート「:template」の移動に失敗しました: :error', 'commands' => [ 'install' => [ 'success' => 'テンプレート「:template」のインストール完了', 'already_installed' => 'テンプレート「:template」は既にインストールされています。', - 'force_reinstall' => 'テンプレート「:template」を強制再インストールします(アクティブディレクトリを上書き)...', - 'layouts_created' => 'レイアウト :count 個が作成されました', + 'force_reinstall' => 'テンプレート「:template」を強制再インストールしています(アクティブディレクトリを上書き)...', + 'layouts_created' => 'レイアウト:count個を作成しました', 'type' => 'タイプ: :type', 'version' => 'バージョン: :version', 'directory_not_found' => 'テンプレートディレクトリが見つかりません: :path', - 'config_not_found' => 'template.json ファイルが見つかりません', + 'config_not_found' => 'template.jsonファイルが見つかりません', ], 'activate' => [ 'success' => 'テンプレート「:template」の有効化完了', - 'not_installed' => 'テンプレート「:template」がインストールされていません。', - 'deactivated_previous' => '既存の :type テンプレート「:previous」を無効化', + 'not_installed' => 'テンプレート「:template」はインストールされていません。', + 'deactivated_previous' => '既存の:typeテンプレート「:previous」を無効化', ], 'deactivate' => [ 'success' => 'テンプレート「:template」の無効化完了', - 'not_active' => 'テンプレート「:template」が有効化されていません。', - 'no_active_warning' => '現在有効化されている :type テンプレートがありません。', + 'not_active' => 'テンプレート「:template」は有効化されていません。', + 'no_active_warning' => '現在有効化されている:typeテンプレートがありません。', ], 'uninstall' => [ 'success' => 'テンプレート「:template」の削除完了', - 'confirm_prompt' => 'テンプレート「:template」を削除してもよろしいですか?', + 'confirm_prompt' => 'テンプレート「:template」を削除しますか?', 'confirm_details' => [ - 'layouts' => '- レイアウト :count 個が削除されます', - 'versions' => '- user タイプテンプレートの場合はバージョン履歴も削除されます', + 'layouts' => '- レイアウト:count個が削除されます', + 'versions' => '- userタイプテンプレートの場合、バージョン履歴も削除されます', ], - 'confirm_question' => '続けてもよろしいですか?', - 'layouts_deleted' => 'レイアウト :count 個が削除されました', - 'versions_deleted' => 'バージョン履歴 :count 個が削除されました', + 'confirm_question' => '続行しますか?', + 'layouts_deleted' => 'レイアウト:count個を削除しました', + 'versions_deleted' => 'バージョン履歴:count個を削除しました', 'aborted' => '削除がキャンセルされました。', ], 'list' => [ 'no_templates' => '登録されたテンプレートがありません。', - 'invalid_type' => '無効なタイプです。admin または user のみ使用可能です。', - 'invalid_status' => '無効なステータスです。installed、uninstalled、active、inactive のみ使用可能です。', + 'invalid_type' => '無効なタイプです。adminまたはuserのみ使用できます。', + 'invalid_status' => '無効な状態です。installed、uninstalled、active、inactiveのみ使用できます。', 'headers' => [ 'identifier' => '識別子', 'name' => '名前', 'type' => 'タイプ', 'version' => 'バージョン', - 'status' => 'ステータス', + 'status' => '状態', ], 'status' => [ 'uninstalled' => '未インストール', ], - 'summary' => '合計 :total 個のテンプレート(インストール済み: :installed 個、有効: :active 個)', + 'summary' => '合計:total個のテンプレート(インストール済み: :installed個、有効: :active個)', ], 'cache_clear' => [ 'clearing_single' => 'テンプレート「:template」のキャッシュを削除中...', 'clearing_all' => 'すべてのテンプレートキャッシュを削除中...', - 'success_single' => 'テンプレート「:template」のキャッシュ :count 個の削除完了', - 'success_all' => '全テンプレートキャッシュ :count 個の削除完了', + 'success_single' => 'テンプレート「:template」のキャッシュ:count個を削除しました', + 'success_all' => 'テンプレート全体のキャッシュ:count個を削除しました', ], 'check_updates' => [ - 'not_installed' => 'テンプレート「:template」がインストールされていません。', + 'not_installed' => 'テンプレート「:template」はインストールされていません。', 'no_installed' => 'インストールされたテンプレートがありません。', 'update_available' => 'アップデート可能', 'up_to_date' => '最新', @@ -263,27 +263,27 @@ return [ 'current_version' => '現在のバージョン', 'latest_version' => '最新バージョン', 'source' => 'ソース', - 'status' => 'ステータス', + 'status' => '状態', ], - 'summary' => '合計 :total 個を確認(アップデート可能: :updates 個)', + 'summary' => '合計:total個を確認(アップデート可能: :updates個)', 'single_up_to_date' => 'テンプレート「:template」は最新バージョンです。(v:version)', - 'single_update_available' => 'テンプレート「:template」がアップデート可能です: v:current → v:latest(:source)', + 'single_update_available' => 'テンプレート「:template」アップデート可能: v:current → v:latest(:source)', ], 'update' => [ 'success' => 'テンプレート「:template」のアップデート完了', 'version_change' => 'v:from → v:to', 'no_update' => 'テンプレート「:template」に利用可能なアップデートがありません。', - 'not_installed' => 'テンプレート「:template」がインストールされていません。', - 'invalid_strategy' => '無効なレイアウト戦略です。overwrite または keep のみ使用可能です。', + 'not_installed' => 'テンプレート「:template」はインストールされていません。', + 'invalid_strategy' => '無効なレイアウト戦略です。overwriteまたはkeepのみ使用できます。', 'current_version' => '現在のバージョン: :version', 'latest_version' => '最新バージョン: :version', 'update_source' => 'アップデートソース: :source', 'layout_strategy' => 'レイアウト戦略: :strategy', - 'modified_layouts_warning' => '編集済みレイアウト :count 個が上書きされます:', - 'modified_layout_item' => ' - :name(編集日: :date)', - 'confirm_question' => 'アップデートを進めてもよろしいですか?', + 'modified_layouts_warning' => '変更されたレイアウト:count個が上書きされます:', + 'modified_layout_item' => ' - :name(変更日: :date)', + 'confirm_question' => 'アップデートを実行しますか?', 'aborted' => 'アップデートがキャンセルされました。', - 'backup_restored' => 'バックアップから前のバージョンが復元されました。', + 'backup_restored' => 'バックアップから以前のバージョンが復元されました。', 'force_mode' => '強制アップデートモード: バージョン比較をスキップして再インストールします。', ], ], @@ -303,8 +303,8 @@ return [ 'file_invalid' => '正しいファイルではありません。', 'file_image' => '画像ファイルのみアップロードできます。', 'file_mimes' => 'サポートされている画像形式ではありません (jpg, jpeg, png, gif, webp, svg)。', - 'file_max' => 'ファイルサイズは最大 :max MBまで許可されています。', - 'layout_name_max' => 'レイアウト名は最大150文字まで許可されています。', + 'file_max' => 'ファイルサイズは最大 :max MB までです。', + 'layout_name_max' => 'レイアウト名は最大 150 文字までです。', ], ], ]; diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/themes.php b/lang-packs/_bundled/g7-core-ja/backend/ja/themes.php index e0af4bed..be2c90db 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/themes.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/themes.php @@ -4,13 +4,13 @@ return [ 'not_found' => 'テーマが見つかりません: :theme', 'name_required' => 'テーマ名は必須です。', 'name_string' => 'テーマ名は文字列である必要があります。', - 'name_max' => 'テーマ名は255文字を超えることができません。', + 'name_max' => 'テーマ名は255文字を超えることはできません。', 'type_required' => 'テーマタイプは必須です。', 'type_invalid' => 'テーマタイプはcoreまたはmoduleである必要があります。', 'target_required' => 'テーマターゲットは必須です。', 'target_invalid' => 'テーマターゲットはadminまたはuserである必要があります。', 'module_name_required' => 'モジュールテーマの場合、モジュール名が必要です。', 'module_name_string' => 'モジュール名は文字列である必要があります。', - 'module_name_max' => 'モジュール名は255文字を超えることができません。', + 'module_name_max' => 'モジュール名は255文字を超えることはできません。', 'cannot_uninstall_active' => '有効化されたテーマは削除できません。', ]; diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/user.php b/lang-packs/_bundled/g7-core-ja/backend/ja/user.php index af10ae18..7b523fbd 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/user.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/user.php @@ -3,7 +3,7 @@ return [ 'status' => [ 'active' => '有効', - 'inactive' => '無効化', + 'inactive' => '無効', 'blocked' => 'ブロック', 'withdrawn' => '退会', ], @@ -13,35 +13,35 @@ return [ ], 'not_found' => 'ユーザーが見つかりません。', 'withdrawn_user' => '退会したユーザー', - 'fetch_success' => 'ユーザー情報の取得に成功しました。', + 'fetch_success' => 'ユーザー情報を正常に取得しました。', 'fetch_failed' => 'ユーザー情報の取得に失敗しました。', 'create_success' => 'ユーザーが正常に作成されました。', - 'create_failed' => 'ユーザーの作成に失敗しました: :error', + 'create_failed' => 'ユーザー作成に失敗しました: :error', 'update_success' => 'ユーザー情報が正常に更新されました。', 'update_failed' => 'ユーザー情報の更新に失敗しました: :error', 'delete_success' => 'ユーザーが正常に削除されました。', - 'delete_failed' => 'ユーザーの削除に失敗しました: :error', - 'statistics_success' => 'ユーザー統計の取得に成功しました。', + 'delete_failed' => 'ユーザー削除に失敗しました: :error', + 'statistics_success' => 'ユーザー統計を正常に取得しました。', 'statistics_failed' => 'ユーザー統計の取得に失敗しました。', 'search_success' => 'ユーザー検索が正常に完了しました。', 'search_failed' => 'ユーザー検索に失敗しました。', 'search_name_required' => '検索する名前を入力してください。', 'search_keyword_required' => '検索キーワードを入力してください。', 'email_available' => '使用可能なメールアドレスです。', - 'email_unavailable' => 'すでに使用されているメールアドレスです。', + 'email_unavailable' => '既に使用中のメールアドレスです。', 'email_required' => 'メールアドレスを入力してください。', - 'email_check_failed' => 'メールアドレスの重複確認に失敗しました。', - 'profile_success' => 'プロフィール情報の取得に成功しました。', + 'email_check_failed' => 'メールアドレス重複確認に失敗しました。', + 'profile_success' => 'プロフィール情報を正常に取得しました。', 'profile_failed' => 'プロフィール情報の取得に失敗しました。', 'language_update_success' => '言語設定が正常に変更されました。', 'language_update_failed' => '言語設定の変更に失敗しました。', 'invalid_language' => 'サポートされていない言語です。', - 'activity_log_success' => 'アクティビティログの取得に成功しました。', + 'activity_log_success' => 'アクティビティログを正常に取得しました。', 'activity_log_failed' => 'アクティビティログの取得に失敗しました。', 'bulk_status_updated' => ':count名のユーザーステータスが変更されました。', - 'bulk_update_status_failed' => 'ユーザーステータスの一括変更に失敗しました。', - 'last_admin_role_cannot_remove' => 'システムに管理者ロールを持つユーザーが本人のみのため、管理者ロールを削除できません。', - 'avatar_upload_success' => 'プロフィール画像のアップロードに成功しました。', + 'bulk_update_status_failed' => 'ユーザーステータス一括変更に失敗しました。', + 'last_admin_role_cannot_remove' => 'システムに管理者ロールを持つユーザーが本人のみであるため、管理者ロールを削除することはできません。', + 'avatar_upload_success' => 'プロフィール画像が正常にアップロードされました。', 'avatar_upload_failed' => 'プロフィール画像のアップロードに失敗しました。', 'avatar_delete_success' => 'プロフィール画像が正常に削除されました。', 'avatar_delete_failed' => 'プロフィール画像の削除に失敗しました。', @@ -52,7 +52,7 @@ return [ 'password_incorrect' => 'パスワードが一致しません。', 'password_verify_failed' => 'パスワード確認に失敗しました。', 'password_change_success' => 'パスワードが正常に変更されました。', - 'password_change_failed' => 'パスワードの変更に失敗しました。', + 'password_change_failed' => 'パスワード変更に失敗しました。', 'withdraw_success' => '会員退会が完了しました。', 'withdraw_failed' => '会員退会に失敗しました: :error', 'withdraw_admin_forbidden' => '管理者アカウントは退会できません。', @@ -62,12 +62,12 @@ return [ 'per_page_integer' => 'ページあたりの項目数は数字である必要があります。', 'per_page_min' => 'ページあたりの項目数は1以上である必要があります。', 'per_page_max' => 'ページあたりの項目数は100以下である必要があります。', - 'search_string' => '検索語は文字列である必要があります。', - 'search_max' => '検索語は255文字以下である必要があります。', + 'search_string' => '検索キーワードは文字列である必要があります。', + 'search_max' => '検索キーワードは255文字以下である必要があります。', 'search_field_invalid' => '無効な検索フィールドです。', - 'start_date_invalid' => '開始日の形式が正しくありません。', - 'end_date_invalid' => '終了日の形式が正しくありません。', - 'end_date_after_start' => '終了日は開始日より遅い必要があります。', + 'start_date_invalid' => '開始日付の形式が正しくありません。', + 'end_date_invalid' => '終了日付の形式が正しくありません。', + 'end_date_after_start' => '終了日付は開始日付より後である必要があります。', 'date_filter_invalid' => '無効な日付フィルターです。', 'sort_by_invalid' => '無効な並べ替えフィールドです。', 'sort_order_invalid' => '無効な並べ替え順序です。', @@ -78,34 +78,34 @@ return [ 'nickname_max' => 'ニックネームは50文字以下である必要があります。', 'email_required' => 'メールアドレスは必須です。', 'email_invalid' => '正しいメールアドレス形式ではありません。', - 'email_unique' => 'すでに使用されているメールアドレスです。', + 'email_unique' => '既に使用中のメールアドレスです。', 'email_max' => 'メールアドレスは255文字以下である必要があります。', 'password_required' => 'パスワードは必須です。', 'password_confirmed' => 'パスワード確認が一致しません。', - 'password_min' => 'パスワードは最低8文字以上である必要があります。', + 'password_min' => 'パスワードは最小8文字以上である必要があります。', 'current_password_required' => '現在のパスワードを入力してください。', 'current_password_invalid' => '現在のパスワードが正しくありません。', 'password_confirmation_required' => 'パスワード確認を入力してください。', 'language_required' => '言語を選択してください。', 'language_invalid' => 'サポートされていない言語です。', 'country_required' => '国を選択してください。', - 'country_size' => '国コードは2文字である必要があります。', - 'country_alpha' => '国コードは英字のみ使用可能です。', + 'country_size' => '国コードは2桁である必要があります。', + 'country_alpha' => '国コードは英文字のみ使用可能です。', 'timezone_invalid' => '有効なタイムゾーンを選択してください。', 'status_invalid' => '無効なアカウント状態です。', - 'homepage_url' => 'URLの形式が正しくありません。', + 'homepage_url' => '正しいURL形式ではありません。', 'homepage_max' => 'ホームページアドレスは255文字以下である必要があります。', 'mobile_max' => '携帯電話番号は20文字以下である必要があります。', - 'mobile_regex' => '携帯電話番号は数字、ハイフン、空白のみ入力可能です。', + 'mobile_regex' => '携帯電話番号は数字、ハイフン、スペースのみ入力可能です。', 'phone_max' => '電話番号は20文字以下である必要があります。', - 'phone_regex' => '電話番号は数字、ハイフン、空白のみ入力可能です。', + 'phone_regex' => '電話番号は数字、ハイフン、スペースのみ入力可能です。', 'avatar_required' => 'プロフィール画像を選択してください。', 'avatar_image' => 'プロフィール画像は画像ファイルである必要があります。', - 'avatar_mimes' => 'プロフィール画像はJPG、PNG、GIF、WEBP形式のみ対応しています。', + 'avatar_mimes' => 'プロフィール画像はJPG、PNG、GIF、WEBP形式のみ可能です。', 'avatar_max' => 'プロフィール画像は2MB以下である必要があります。', 'zipcode_max' => '郵便番号は10文字以下である必要があります。', - 'address_max' => '基本アドレスは255文字以下である必要があります。', - 'address_detail_max' => '詳細アドレスは255文字以下である必要があります。', + 'address_max' => '基本住所は255文字以下である必要があります。', + 'address_detail_max' => '詳細住所は255文字以下である必要があります。', 'signature_max' => '署名は1000文字以下である必要があります。', 'bio_max' => '自己紹介は5000文字以下である必要があります。', 'admin_memo_max' => '管理者メモは5000文字以下である必要があります。', diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php index df01b587..01538594 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php @@ -3,16 +3,16 @@ return [ 'accepted' => ':attribute フィールドを承認する必要があります。', 'accepted_if' => ':other が :value の場合、:attribute フィールドを承認する必要があります。', - 'active_url' => ':attribute フィールドは有効な URL である必要があります。', - 'after' => ':attribute フィールドは :date より後の日付である必要があります。', - 'after_or_equal' => ':attribute フィールドは :date 以降の日付である必要があります。', + 'active_url' => ':attribute フィールドは有効なURLである必要があります。', + 'after' => ':attribute フィールドは :date 以降の日付である必要があります。', + 'after_or_equal' => ':attribute フィールドは :date 以降または同じ日付である必要があります。', 'alpha' => ':attribute フィールドは文字のみを含む必要があります。', 'alpha_dash' => ':attribute フィールドは文字、数字、ダッシュ、アンダースコアのみを含む必要があります。', 'alpha_num' => ':attribute フィールドは文字と数字のみを含む必要があります。', 'array' => ':attribute フィールドは配列である必要があります。', 'ascii' => ':attribute フィールドはシングルバイト英数字文字と記号のみを含む必要があります。', - 'before' => ':attribute フィールドは :date より前の日付である必要があります。', - 'before_or_equal' => ':attribute フィールドは :date 以前の日付である必要があります。', + 'before' => ':attribute フィールドは :date 以前の日付である必要があります。', + 'before_or_equal' => ':attribute フィールドは :date 以前または同じ日付である必要があります。', 'between' => [ 'array' => ':attribute フィールドは :min ~ :max 個の項目を含む必要があります。', 'file' => ':attribute フィールドは :min ~ :max KB の間である必要があります。', @@ -21,34 +21,34 @@ return [ ], 'boolean' => ':attribute フィールドは true または false である必要があります。', 'can' => ':attribute フィールドに許可されていない値が含まれています。', - 'confirmed' => ':attribute フィールド確認が一致しません。', - 'contains' => ':attribute フィールドに必須値が不足しています。', + 'confirmed' => ':attribute フィールドの確認が一致しません。', + 'contains' => ':attribute フィールドに必須の値が不足しています。', 'current_password' => 'パスワードが正しくありません。', 'date' => ':attribute フィールドは有効な日付である必要があります。', 'date_equals' => ':attribute フィールドは :date と同じ日付である必要があります。', 'date_format' => ':attribute フィールドは :format 形式と一致する必要があります。', - 'decimal' => ':attribute フィールドは :decimal 桁数の小数点である必要があります。', - 'declined' => ':attribute フィールドを却下する必要があります。', - 'declined_if' => ':other が :value の場合、:attribute フィールドを却下する必要があります。', + 'decimal' => ':attribute フィールドは :decimal 桁の小数点である必要があります。', + 'declined' => ':attribute フィールドを拒否する必要があります。', + 'declined_if' => ':other が :value の場合、:attribute フィールドを拒否する必要があります。', 'different' => ':attribute フィールドと :other は異なる必要があります。', - 'digits' => ':attribute フィールドは :digits 桁数である必要があります。', - 'digits_between' => ':attribute フィールドは :min ~ :max 桁数の間である必要があります。', + 'digits' => ':attribute フィールドは :digits 桁である必要があります。', + 'digits_between' => ':attribute フィールドは :min ~ :max 桁の間である必要があります。', 'dimensions' => ':attribute フィールドの画像サイズが正しくありません。', 'distinct' => ':attribute フィールドに重複した値があります。', - 'doesnt_end_with' => ':attribute フィールドは :values で終わってはいけません。', - 'doesnt_start_with' => ':attribute フィールドは :values で始まってはいけません。', + 'doesnt_end_with' => ':attribute フィールドは次で終わってはいけません: :values。', + 'doesnt_start_with' => ':attribute フィールドは次で始まってはいけません: :values。', 'email' => ':attribute フィールドは有効なメールアドレスである必要があります。', 'ends_with' => ':attribute フィールドは次のいずれかで終わる必要があります: :values。', - 'enum' => '選択された :attribute が正しくありません。', - 'exists' => '選択された :attribute が正しくありません。', + 'enum' => '選択された :attribute は正しくありません。', + 'exists' => '選択された :attribute は正しくありません。', 'extensions' => ':attribute フィールドは次の拡張子のいずれかである必要があります: :values。', 'file' => ':attribute フィールドはファイルである必要があります。', 'filled' => ':attribute フィールドに値が存在する必要があります。', 'gt' => [ - 'array' => ':attribute フィールドは :value 個より多くの項目を含む必要があります。', - 'file' => ':attribute フィールドは :value KB より大きくする必要があります。', - 'numeric' => ':attribute フィールドは :value より大きくする必要があります。', - 'string' => ':attribute フィールドは :value 文字より長くする必要があります。', + 'array' => ':attribute フィールドは :value 個を超える項目を含む必要があります。', + 'file' => ':attribute フィールドは :value KB より大きい必要があります。', + 'numeric' => ':attribute フィールドは :value より大きい必要があります。', + 'string' => ':attribute フィールドは :value 文字より長い必要があります。', ], 'gte' => [ 'array' => ':attribute フィールドは :value 個以上の項目を含む必要があります。', @@ -56,22 +56,22 @@ return [ 'numeric' => ':attribute フィールドは :value 以上である必要があります。', 'string' => ':attribute フィールドは :value 文字以上である必要があります。', ], - 'hex_color' => ':attribute フィールドは有効な16進数カラーである必要があります。', + 'hex_color' => ':attribute フィールドは有効な16進数色である必要があります。', 'image' => ':attribute フィールドは画像である必要があります。', - 'in' => '選択された :attribute が正しくありません。', + 'in' => '選択された :attribute は正しくありません。', 'in_array' => ':attribute フィールドは :other に存在する必要があります。', 'integer' => ':attribute フィールドは整数である必要があります。', - 'ip' => ':attribute フィールドは有効な IP アドレスである必要があります。', - 'ipv4' => ':attribute フィールドは有効な IPv4 アドレスである必要があります。', - 'ipv6' => ':attribute フィールドは有効な IPv6 アドレスである必要があります。', - 'json' => ':attribute フィールドは有効な JSON 文字列である必要があります。', + 'ip' => ':attribute フィールドは有効なIPアドレスである必要があります。', + 'ipv4' => ':attribute フィールドは有効なIPv4アドレスである必要があります。', + 'ipv6' => ':attribute フィールドは有効なIPv6アドレスである必要があります。', + 'json' => ':attribute フィールドは有効なJSON文字列である必要があります。', 'list' => ':attribute フィールドはリストである必要があります。', 'lowercase' => ':attribute フィールドは小文字である必要があります。', 'lt' => [ - 'array' => ':attribute フィールドは :value 個より少ない項目を含む必要があります。', - 'file' => ':attribute フィールドは :value KB より小さくする必要があります。', - 'numeric' => ':attribute フィールドは :value より小さくする必要があります。', - 'string' => ':attribute フィールドは :value 文字より短くする必要があります。', + 'array' => ':attribute フィールドは :value 個未満の項目を含む必要があります。', + 'file' => ':attribute フィールドは :value KB より小さい必要があります。', + 'numeric' => ':attribute フィールドは :value より小さい必要があります。', + 'string' => ':attribute フィールドは :value 文字より短い必要があります。', ], 'lte' => [ 'array' => ':attribute フィールドは :value 個を超えてはいけません。', @@ -79,60 +79,60 @@ return [ 'numeric' => ':attribute フィールドは :value 以下である必要があります。', 'string' => ':attribute フィールドは :value 文字以下である必要があります。', ], - 'mac_address' => ':attribute フィールドは有効な MAC アドレスである必要があります。', + 'mac_address' => ':attribute フィールドは有効なMACアドレスである必要があります。', 'max' => [ 'array' => ':attribute フィールドは :max 個を超えてはいけません。', 'file' => ':attribute フィールドは :max KB を超えてはいけません。', 'numeric' => ':attribute フィールドは :max を超えてはいけません。', 'string' => ':attribute フィールドは :max 文字を超えてはいけません。', ], - 'max_digits' => ':attribute フィールドは :max 桁数を超えてはいけません。', - 'mimes' => ':attribute フィールドは次のタイプのファイルである必要があります: :values。', - 'mimetypes' => ':attribute フィールドは次のタイプのファイルである必要があります: :values。', + 'max_digits' => ':attribute フィールドは :max 桁を超えてはいけません。', + 'mimes' => ':attribute フィールドは次の種類のファイルである必要があります: :values。', + 'mimetypes' => ':attribute フィールドは次の種類のファイルである必要があります: :values。', 'min' => [ 'array' => ':attribute フィールドは最低 :min 個以上である必要があります。', 'file' => ':attribute フィールドは最低 :min KB 以上である必要があります。', 'numeric' => ':attribute フィールドは最低 :min 以上である必要があります。', 'string' => ':attribute フィールドは最低 :min 文字以上である必要があります。', ], - 'min_digits' => ':attribute フィールドは最低でも :min 文字以上である必要があります。', - 'missing' => ':attribute フィールドは存在しないようにしてください。', - 'missing_if' => ':other が :value のとき、:attribute フィールドは存在しないようにしてください。', - 'missing_unless' => ':other が :value ではない場合、:attribute フィールドは存在しないようにしてください。', - 'missing_with' => ':values が存在するとき、:attribute フィールドは存在しないようにしてください。', - 'missing_with_all' => ':values がすべて存在するとき、:attribute フィールドは存在しないようにしてください。', + 'min_digits' => ':attribute フィールドは最小 :min 桁以上である必要があります。', + 'missing' => ':attribute フィールドは存在しないことが必要です。', + 'missing_if' => ':other が :value の場合、:attribute フィールドは存在しないことが必要です。', + 'missing_unless' => ':other が :value でない場合、:attribute フィールドは存在しないことが必要です。', + 'missing_with' => ':values が存在する場合、:attribute フィールドは存在しないことが必要です。', + 'missing_with_all' => ':values がすべて存在する場合、:attribute フィールドは存在しないことが必要です。', 'multiple_of' => ':attribute フィールドは :value の倍数である必要があります。', - 'not_in' => '選択した :attribute は無効です。', - 'not_regex' => ':attribute フィールドの形式が無効です。', + 'not_in' => '選択された :attribute は有効ではありません。', + 'not_regex' => ':attribute フィールド形式が正しくありません。', 'numeric' => ':attribute フィールドは数値である必要があります。', 'password' => [ - 'letters' => ':attribute フィールドは最低でも 1 文字を含む必要があります。', - 'mixed' => ':attribute フィールドは最低でも 1 つの大文字と 1 つの小文字を含む必要があります。', - 'numbers' => ':attribute フィールドは最低でも 1 つの数字を含む必要があります。', - 'symbols' => ':attribute フィールドは最低でも 1 つの記号を含む必要があります。', - 'uncompromised' => '指定された :attribute はデータ流出に表示されています。別の :attribute を選択してください。', + 'letters' => ':attribute フィールドは最低 1 つの文字を含む必要があります。', + 'mixed' => ':attribute フィールドは最低 1 つの大文字と小文字を含む必要があります。', + 'numbers' => ':attribute フィールドは最低 1 つの数字を含む必要があります。', + 'symbols' => ':attribute フィールドは最低 1 つの記号を含む必要があります。', + 'uncompromised' => '指定の :attribute はデータ侵害に公開されています。別の :attribute を選択してください。', ], 'present' => ':attribute フィールドは存在する必要があります。', - 'present_if' => ':other が :value のとき、:attribute フィールドは存在する必要があります。', - 'present_unless' => ':other が :value ではない場合、:attribute フィールドは存在する必要があります。', - 'present_with' => ':values が存在するとき、:attribute フィールドは存在する必要があります。', - 'present_with_all' => ':values がすべて存在するとき、:attribute フィールドは存在する必要があります。', - 'prohibited' => ':attribute フィールドは許可されていません。', - 'prohibited_if' => ':other が :value のとき、:attribute フィールドは許可されていません。', - 'prohibited_unless' => ':other が :values に含まれていない場合、:attribute フィールドは許可されていません。', + 'present_if' => ':other が :value の場合、:attribute フィールドは存在する必要があります。', + 'present_unless' => ':other が :value でない場合、:attribute フィールドは存在する必要があります。', + 'present_with' => ':values が存在する場合、:attribute フィールドは存在する必要があります。', + 'present_with_all' => ':values がすべて存在する場合、:attribute フィールドは存在する必要があります。', + 'prohibited' => ':attribute フィールドは禁止されています。', + 'prohibited_if' => ':other が :value の場合、:attribute フィールドは禁止されています。', + 'prohibited_unless' => ':other が :values にない場合、:attribute フィールドは禁止されています。', 'prohibits' => ':attribute フィールドは :other が存在することを禁止しています。', - 'regex' => ':attribute フィールドの形式が無効です。', + 'regex' => ':attribute フィールド形式が正しくありません。', 'required' => ':attribute フィールドは必須です。', - 'required_array_keys' => ':attribute フィールドは以下の項目を含む必要があります: :values。', - 'required_if' => ':other が :value のとき、:attribute フィールドは必須です。', - 'required_if_accepted' => ':other が承認されている場合、:attribute フィールドは必須です。', - 'required_if_declined' => ':other が却下されている場合、:attribute フィールドは必須です。', - 'required_unless' => ':other が :values に含まれていない場合、:attribute フィールドは必須です。', - 'required_with' => ':values が存在するとき、:attribute フィールドは必須です。', - 'required_with_all' => ':values がすべて存在するとき、:attribute フィールドは必須です。', - 'required_without' => ':values が存在しないとき、:attribute フィールドは必須です。', - 'required_without_all' => ':values がすべて存在しないとき、:attribute フィールドは必須です。', - 'same' => ':attribute フィールドと :other が一致する必要があります。', + 'required_array_keys' => ':attribute フィールドは以下を含む必要があります: :values。', + 'required_if' => ':other が :value の場合、:attribute フィールドは必須です。', + 'required_if_accepted' => ':other が承認されたとき、:attribute フィールドは必須です。', + 'required_if_declined' => ':other が却下されたとき、:attribute フィールドは必須です。', + 'required_unless' => ':other が :values にない場合、:attribute フィールドは必須です。', + 'required_with' => ':values が存在する場合、:attribute フィールドは必須です。', + 'required_with_all' => ':values がすべて存在する場合、:attribute フィールドは必須です。', + 'required_without' => ':values が存在しない場合、:attribute フィールドは必須です。', + 'required_without_all' => ':values がすべて存在しない場合、:attribute フィールドは必須です。', + 'same' => ':attribute フィールドと :other は一致する必要があります。', 'size' => [ 'array' => ':attribute フィールドは :size 個の項目を含む必要があります。', 'file' => ':attribute フィールドは :size KB である必要があります。', @@ -148,87 +148,126 @@ return [ 'url' => ':attribute フィールドは有効な URL である必要があります。', 'ulid' => ':attribute フィールドは有効な ULID である必要があります。', 'uuid' => ':attribute フィールドは有効な UUID である必要があります。', + 'custom_translation' => [ + 'layout_name' => [ + 'required' => 'レイアウト名は必須です。', + 'string' => 'レイアウト名は文字列である必要があります。', + 'max' => 'レイアウト名は :max 文字を超えることはできません。', + ], + 'locale' => [ + 'required' => 'ロケールは必須です。', + 'string' => 'ロケールは文字列である必要があります。', + ], + 'value' => [ + 'required' => '翻訳値は必須です。', + 'string' => '翻訳値は文字列である必要があります。', + ], + 'values' => [ + 'required' => '翻訳値は必須です。', + 'array' => '翻訳値はロケール別オブジェクトである必要があります。', + ], + 'status' => [ + 'in' => 'ステータスは active または orphaned である必要があります。', + ], + 'expected_lock_version' => [ + 'required' => '保存リクエストに expected_lock_version がありません。', + 'integer' => 'expected_lock_version は整数である必要があります。', + 'min' => 'expected_lock_version は 0 以上である必要があります。', + ], + 'ids' => [ + 'required' => '削除する多言語キーを 1 つ以上選択してください。', + 'array' => '削除対象は ID 配列である必要があります。', + 'min' => '削除する多言語キーを 1 つ以上選択してください。', + 'integer' => '多言語キー ID は整数である必要があります。', + 'exists' => '存在しない多言語キーが含まれています。', + ], + ], 'layout' => [ + 'expected_lock_version' => [ + 'required' => '保存リクエストに expected_lock_version がありません。', + 'integer' => 'expected_lock_version は整数である必要があります。', + 'min' => 'expected_lock_version は 0 以上である必要があります。', + ], 'invalid_json' => '無効な JSON 形式です。', 'must_be_array' => 'レイアウトデータは配列である必要があります。', - 'required_field_missing' => '必須フィールド \':field\' が見つかりません。', + 'required_field_missing' => '必須フィールド \':field\' がありません。', 'version_must_be_string' => 'version フィールドは文字列である必要があります。', 'layout_name_must_be_string' => 'layout_name フィールドは文字列である必要があります。', 'components_must_be_array' => 'components フィールドは配列である必要があります。', - 'components_or_slots_required' => '継承レイアウトには components または slots のいずれかが必要です。', + 'components_or_slots_required' => '継承レイアウトは components または slots のいずれかが必要です。', 'component_must_be_array' => 'components[:index] は配列である必要があります。', - 'max_depth_exceeded' => 'コンポーネントのネストの深さが最大許容深度(:max)を超えています。', - 'component_required_field_missing' => 'components[:index] に必須フィールド \':field\' が見つかりません。', - 'component_field_must_be_string' => 'components[:index].component は文字列である必要があります。', - 'component_name_must_be_string' => 'components[:index].name は文字列である必要があります。', - 'component_type_invalid' => 'components[:index].type は basic、composite、layout のいずれかである必要があります。', - 'props_must_be_object' => 'components[:index].props はオブジェクト(配列)である必要があります。', - 'children_must_be_array' => 'components[:index].children は配列である必要があります。', - 'permissions_must_be_array' => 'components[:index].permissions は配列である必要があります。', - 'permissions_must_be_array_or_object' => 'permissions は配列または構造化オブジェクト(or/and)である必要があります。', - 'permissions_invalid_operator' => 'permissions の構造では、有効な演算子は「or」または「and」のみが許可されます。キーは正確に 1 つである必要があります。', - 'permissions_operator_must_be_array' => 'permissions の ":operator" 演算子の値は配列である必要があります。', - 'permissions_operator_min_items' => 'permissions の ":operator" 演算子には最低でも :min 個の項目が必要です。', - 'permissions_max_depth_exceeded' => 'permissions の構造の最大ネスト深さ(:max)を超えています。', - 'permission_must_be_string' => 'components[:index].permissions[:perm_index] は文字列である必要があります。', - 'permission_must_be_string_or_group' => 'permissions の項目[:index] は文字列(権限 ID)または構造化オブジェクト(or/and)である必要があります。', - 'permission_invalid_format' => 'components[:index].permissions の ":permission" は正しい権限 ID 形式ではありません。', - 'actions_must_be_array' => 'components[:index].actions は配列である必要があります。', - 'action_must_be_array' => 'components[:index].actions[:actionIndex] は配列である必要があります。', - 'action_type_missing' => 'components[:index].actions[:actionIndex] に必須フィールド \'type\' が見つかりません。', - 'action_type_or_event_missing' => 'components[:index].actions[:actionIndex] には \'type\' または \'event\' のいずれかが必要です。', - 'action_type_must_be_string' => 'components[:index].actions[:actionIndex].type は文字列である必要があります。', - 'action_event_must_be_string' => 'components[:index].actions[:actionIndex].event は文字列である必要があります。', + 'max_depth_exceeded' => 'コンポーネントネスト深度が最大許容深度(:max)を超えています。', + 'component_required_field_missing' => 'components[:index] に必須フィールド \':field\' がありません。', + 'component_field_must_be_string' => 'components[:index].componentは文字列である必要があります。', + 'component_name_must_be_string' => 'components[:index].nameは文字列である必要があります。', + 'component_type_invalid' => 'components[:index].typeはbasic、composite、layoutのいずれかである必要があります。', + 'props_must_be_object' => 'components[:index].propsはオブジェクト(配列)である必要があります。', + 'children_must_be_array' => 'components[:index].childrenは配列である必要があります。', + 'permissions_must_be_array' => 'components[:index].permissionsは配列である必要があります。', + 'permissions_must_be_array_or_object' => 'permissionsは配列または構造化オブジェクト(or/and)である必要があります。', + 'permissions_invalid_operator' => 'permissions構造で有効な演算子は「or」または「and」のみです。キーは正確に1個である必要があります。', + 'permissions_operator_must_be_array' => 'permissionsの":operator"演算子の値は配列である必要があります。', + 'permissions_operator_min_items' => 'permissionsの":operator"演算子には最小:min個の項目が必要です。', + 'permissions_max_depth_exceeded' => 'permissions構造の最大ネストの深さ(:max)を超えました。', + 'permission_must_be_string' => 'components[:index].permissions[:perm_index]は文字列である必要があります。', + 'permission_must_be_string_or_group' => 'permissionsの項目[:index]は文字列(権限識別子)または構造化オブジェクト(or/and)である必要があります。', + 'permission_invalid_format' => 'components[:index].permissionsの":permission"は有効な権限識別子形式ではありません。', + 'actions_must_be_array' => 'components[:index].actionsは配列である必要があります。', + 'action_must_be_array' => 'components[:index].actions[:actionIndex]は配列である必要があります。', + 'action_type_missing' => 'components[:index].actions[:actionIndex]に必須フィールド\'type\'が見つかりません。', + 'action_type_or_event_missing' => 'components[:index].actions[:actionIndex]に\'type\'または\'event\'のいずれかが必要です。', + 'action_type_must_be_string' => 'components[:index].actions[:actionIndex].typeは文字列である必要があります。', + 'action_event_must_be_string' => 'components[:index].actions[:actionIndex].eventは文字列である必要があります。', 'content' => [ - 'required' => 'レイアウト content が必要です。', - 'array' => 'レイアウト content は配列である必要があります。', + 'required' => 'レイアウトcontentが必要です。', + 'array' => 'レイアウトcontentは配列である必要があります。', ], 'version' => [ - 'required' => 'レイアウト バージョンが必要です。', - 'string' => 'レイアウト バージョンは文字列である必要があります。', + 'required' => 'レイアウトバージョンが必要です。', + 'string' => 'レイアウトバージョンは文字列である必要があります。', ], 'layout_name' => [ - 'required' => 'レイアウト 名が必要です。', - 'string' => 'レイアウト 名は文字列である必要があります。', - 'max' => 'レイアウト 名は :max 文字を超えることはできません。', + 'required' => 'レイアウト名が必要です。', + 'string' => 'レイアウト名は文字列である必要があります。', + 'max' => 'レイアウト名は:max文字を超えることはできません。', ], 'endpoint' => [ - 'required' => 'API エンドポイントが必要です。', - 'string' => 'API エンドポイントは文字列である必要があります。', + 'required' => 'APIエンドポイントが必要です。', + 'string' => 'APIエンドポイントは文字列である必要があります。', ], 'extends' => [ - 'string' => 'extends は文字列である必要があります。', + 'string' => 'extendsは文字列である必要があります。', ], 'slots' => [ - 'array' => 'slots は配列である必要があります。', + 'array' => 'slotsは配列である必要があります。', ], 'components' => [ 'required' => 'コンポーネント配列が必要です。', - 'array' => 'components フィールドは配列である必要があります。', + 'array' => 'componentsフィールドは配列である必要があります。', ], 'data_sources' => [ - 'array' => 'data_sources フィールドは配列である必要があります。', + 'array' => 'data_sourcesフィールドは配列である必要があります。', ], 'metadata' => [ - 'array' => 'metadata フィールドは配列である必要があります。', + 'array' => 'metadataフィールドは配列である必要があります。', ], 'meta' => [ - 'array' => 'meta フィールドは配列である必要があります。', + 'array' => 'metaフィールドは配列である必要があります。', 'title' => [ - 'string' => 'meta.title は文字列である必要があります。', + 'string' => 'meta.titleは文字列である必要があります。', ], 'description' => [ - 'string' => 'meta.description は文字列である必要があります。', - ], - 'auth_required' => [ - 'boolean' => 'meta.auth_required は論理値である必要があります。', - ], - 'is_base' => [ - 'boolean' => 'meta.is_base は論理値である必要があります。', + 'string' => 'meta.descriptionは文字列である必要があります。', ], 'keywords' => [ 'string' => 'meta.keywordsは文字列である必要があります。', ], + 'auth_required' => [ + 'boolean' => 'meta.auth_requiredはブール値である必要があります。', + ], + 'is_base' => [ + 'boolean' => 'meta.is_baseはブール値である必要があります。', + ], 'guest_only' => [ 'boolean' => 'meta.guest_onlyはブール値である必要があります。', ], @@ -277,84 +316,19 @@ return [ ], ], 'modals' => [ - 'array' => 'modals フィールドは配列である必要があります。', + 'array' => 'modalsフィールドは配列である必要があります。', ], 'state' => [ - 'array' => 'state フィールドは配列である必要があります。', + 'array' => 'stateフィールドは配列である必要があります。', ], 'init_actions' => [ - 'array' => 'init_actions フィールドは配列である必要があります。', + 'array' => 'init_actionsフィールドは配列である必要があります。', ], 'defines' => [ - 'array' => 'defines フィールドは配列である必要があります。', + 'array' => 'definesフィールドは配列である必要があります。', ], 'init_state' => [ - 'array' => 'init_state フィールドは配列である必要があります。', - ], - 'routes' => [ - 'array' => 'routes フィールドは配列である必要があります。', - ], - 'computed' => [ - 'array' => 'computed フィールドは配列である必要があります。', - ], - 'permissions' => [ - 'array' => 'permissions フィールドは配列である必要があります。', - 'string' => '各権限識別子は文字列である必要があります。', - 'regex' => '権限識別子形式が正しくありません。(例: module.entity.action)', - ], - 'globalHeaders' => [ - 'array' => 'globalHeaders フィールドは配列である必要があります。', - 'pattern' => [ - 'required' => '各 globalHeaders ルールには pattern フィールドが必要です。', - 'string' => 'globalHeaders pattern は文字列である必要があります。', - ], - 'headers' => [ - 'required' => '各 globalHeaders ルールには headers フィールドが必要です。', - 'array' => 'globalHeaders headers は配列である必要があります。', - 'string' => 'ヘッダー値は文字列である必要があります。', - ], - ], - 'transition_overlay' => [ - 'enabled' => [ - 'boolean' => '遷移オーバーレイ有効化は true または false である必要があります。', - ], - 'style' => [ - 'string' => '遷移オーバーレイ スタイルは文字列である必要があります。', - 'in' => '遷移オーバーレイ スタイルは opaque、blur、fade、skeleton のいずれかである必要があります。', - ], - 'target' => [ - 'string' => '遷移オーバーレイ ターゲットは文字列である必要があります。', - 'max' => '遷移オーバーレイ ターゲットは :max 文字を超えることはできません。', - ], - 'fallback_target' => [ - 'string' => '遷移オーバーレイ フォールバック ターゲットは文字列である必要があります。', - 'max' => '遷移オーバーレイ フォールバック ターゲットは :max 文字を超えることはできません。', - ], - 'skeleton' => [ - 'array' => 'スケルトン設定は配列である必要があります。', - 'component' => [ - 'string' => 'スケルトン コンポーネント名は文字列である必要があります。', - 'max' => 'スケルトン コンポーネント名は :max 文字を超えることはできません。', - ], - 'animation' => [ - 'string' => 'スケルトン アニメーションは文字列である必要があります。', - 'in' => 'スケルトン アニメーションは pulse、wave、none のいずれかである必要があります。', - ], - 'iteration_count' => [ - 'integer' => 'スケルトン 繰り返し回数は整数である必要があります。', - 'min' => 'スケルトン 繰り返し回数は最小 :min である必要があります。', - 'max' => 'スケルトン 繰り返し回数は最大 :max を超えることはできません。', - ], - ], - 'wait_for' => [ - 'background' => 'wait_for には background データソースを指定することはできません (ユーザーをブロックできません): :id', - 'websocket' => 'wait_for には websocket データソースを指定することはできません (フェッチ完了イベントがありません): :id', - ], - ], - 'expected_lock_version' => [ - 'required' => '保存リクエストに expected_lock_version が不足しています。', - 'integer' => 'expected_lock_version は整数である必要があります。', - 'min' => 'expected_lock_version は0以上である必要があります。', + 'array' => 'init_stateフィールドは配列である必要があります。', ], 'initLocal' => [ 'array' => 'initLocalフィールドは配列である必要があります。', @@ -377,27 +351,141 @@ return [ 'schema' => [ 'array' => 'schemaフィールドは配列である必要があります。', ], + 'routes' => [ + 'array' => 'routesフィールドは配列である必要があります。', + ], + 'computed' => [ + 'array' => 'computedフィールドは配列である必要があります。', + ], + 'permissions' => [ + 'array' => 'permissionsフィールドは配列である必要があります。', + 'string' => '各権限識別子は文字列である必要があります。', + 'regex' => '権限識別子形式が正しくありません。(例:module.entity.action)', + ], + 'globalHeaders' => [ + 'array' => 'globalHeadersフィールドは配列である必要があります。', + 'pattern' => [ + 'required' => '各globalHeadersルールにはpatternフィールドが必要です。', + 'string' => 'globalHeaderspatternは文字列である必要があります。', + ], + 'headers' => [ + 'required' => '各globalHeadersルールにはheadersフィールドが必要です。', + 'array' => 'globalHeaders headers は配列である必要があります。', + 'string' => 'ヘッダー値は文字列である必要があります。', + ], + ], + 'transition_overlay' => [ + 'enabled' => [ + 'boolean' => 'トランジション オーバーレイ有効化は true または false 値である必要があります。', + ], + 'style' => [ + 'string' => 'トランジション オーバーレイ スタイルは文字列である必要があります。', + 'in' => 'トランジション オーバーレイ スタイルは opaque、blur、fade、skeleton のいずれかである必要があります。', + ], + 'target' => [ + 'string' => 'トランジション オーバーレイ ターゲットは文字列である必要があります。', + 'max' => 'トランジション オーバーレイ ターゲットは :max 文字を超えることはできません。', + ], + 'fallback_target' => [ + 'string' => 'トランジション オーバーレイ代替ターゲットは文字列である必要があります。', + 'max' => 'トランジション オーバーレイ代替ターゲットは :max 文字を超えることはできません。', + ], + 'skeleton' => [ + 'array' => 'スケルトン設定は配列である必要があります。', + 'component' => [ + 'string' => 'スケルトン コンポーネント名は文字列である必要があります。', + 'max' => 'スケルトン コンポーネント名は :max 文字を超えることはできません。', + ], + 'animation' => [ + 'string' => 'スケルトン アニメーションは文字列である必要があります。', + 'in' => 'スケルトン アニメーションは pulse、wave、none のいずれかである必要があります。', + ], + 'iteration_count' => [ + 'integer' => 'スケルトン 反復回数は整数である必要があります。', + 'min' => 'スケルトン 反復回数は最小 :min である必要があります。', + 'max' => 'スケルトン 反復回数は最大 :max を超えることはできません。', + ], + ], + 'wait_for' => [ + 'background' => 'wait_for には background データソースを指定することはできません (ユーザーをブロックできません)::id', + 'websocket' => 'wait_for には websocket データソースを指定することはできません (フェッチ完了イベントなし)::id', + ], + ], + ], + 'layout_extension' => [ + 'expected_lock_version' => [ + 'required' => '保存リクエストに expected_lock_version が不足しています。', + 'integer' => 'expected_lock_version は整数である必要があります。', + 'min' => 'expected_lock_version は 0 以上である必要があります。', + ], + 'invalid_json' => '無効な JSON 形式です。', + 'must_be_array' => 'レイアウト拡張データは配列である必要があります。', + 'target_required' => '拡張定義には \'extension_point\' または \'target_layout\' のいずれかが必要です。', + 'target_exclusive' => '\'extension_point\' と \'target_layout\' は同時に指定することはできません。', + 'extension_point_invalid' => 'extension_point フィールドは空でない文字列である必要があります。', + 'target_layout_invalid' => 'target_layout フィールドは空でない文字列である必要があります。', + 'components_must_be_array' => 'components フィールドは配列である必要があります。', + 'injections_required' => 'overlay 拡張は injections フィールドが必要です。', + 'injections_must_be_array' => 'injections フィールドは配列である必要があります。', + 'injection_must_be_array' => 'injections[:index] は配列である必要があります。', + 'injection_target_id_required' => 'injections[:index] に target_id フィールドが必要です。', + 'injection_position_invalid' => 'injections[:index].position 値が無効です。', + 'injection_components_must_be_array' => 'injections[:index].components は配列である必要があります。', + 'injection_props_must_be_array' => 'injections[:index].props は配列である必要があります。', + 'section_must_be_array' => ':section フィールドは配列である必要があります。', + 'max_depth_exceeded' => 'コンポーネントのネスト深度が最大許容深度(:max)を超えています。', + 'component_must_be_array' => 'components[:index] は配列である必要があります。', + 'component_required_field_missing' => 'components[:index] に必須フィールド \':field\' が不足しています。', + 'component_name_must_be_string' => 'components[:index].name は文字列である必要があります。', + 'component_type_invalid' => 'components[:index].type は basic、composite、layout のいずれかである必要があります。', + 'props_must_be_object' => 'components[:index].props はオブジェクト(配列)である必要があります。', + 'children_must_be_array' => 'components[:index].children は配列である必要があります。', + 'content' => [ + 'required' => '拡張コンテンツは必須です。', + 'array' => '拡張コンテンツは配列である必要があります。', + ], + 'priority' => [ + 'integer' => 'priority は整数である必要があります。', + 'min' => 'priority は 0 以上である必要があります。', + 'max' => 'priority は 9999 以下である必要があります。', + ], + 'data_sources' => [ + 'array' => 'data_sources は配列である必要があります。', + ], + 'preview_layout' => [ + 'string' => 'プレビュー レイアウト名は文字列である必要があります。', + 'max' => 'プレビュー レイアウト名は 255 文字を超えることはできません。', + 'required' => '拡張ポイント プレビューには代表レイアウト選択が必要です。', + ], ], 'endpoint' => [ 'must_be_string' => 'API エンドポイントは文字列である必要があります。', 'external_url_not_allowed' => '外部 URL は許可されていません。', - 'not_whitelisted' => '許可されていない API エンドポイントです。許可されたパターン: :pattern', - 'path_traversal_detected' => 'パストラバーサル攻撃が検出されました。', + 'not_whitelisted' => '許可されていない API エンドポイントです。許可されたパターン::pattern', + 'path_traversal_detected' => 'パストトラバーサル攻撃が検出されました。', ], 'external_url' => [ - 'detected_in_props' => 'props で外部 URL が検出されました: :url', - 'detected_in_actions' => 'actions で外部 URL が検出されました: :url', + 'detected_in_props' => 'props で外部 URL が検出されました::url', + 'detected_in_actions' => 'actions で外部 URL が検出されました::url', 'http_not_allowed' => 'HTTP プロトコル URL は許可されていません。', 'https_not_allowed' => 'HTTPS プロトコル URL は許可されていません。', 'data_uri_not_allowed' => 'Data URI スキームは許可されていません。', 'javascript_uri_not_allowed' => 'JavaScript URI スキームは許可されていません。', - 'dangerous_scheme_detected' => '危険な URI スキームが検出されました: :scheme', + 'dangerous_scheme_detected' => '危険な URI スキームが検出されました::scheme', + ], + 'outbound_url' => [ + 'invalid' => '正しい形式の URL ではありません。http または https で始まるアドレスを入力してください。', + 'internal_not_allowed' => '内部ネットワークアドレス(プライベート IP·localhost など)は使用できません。外部からアクセス可能なアドレスを入力してください。', + ], + 'schedule_command' => [ + 'shell_not_allowed' => '許可されていないシェルコマンドです。サーバーに登録された実行ファイルのみ使用でき、パイプ(|)·セミコロン(;)などの特殊文字は使用できません。', + 'artisan_denied' => 'この Artisan コマンドはセキュリティ上の理由からスケジュール実行できません。', ], 'component' => [ 'template_id_required' => 'コンポーネント検証には template_id が必要です。', - 'manifest_not_found' => 'テンプレート :templateId のコンポーネント マニフェスト(components.json)が見つかりません。', + 'manifest_not_found' => 'テンプレート :templateId のコンポーネント マニフェスト(components.json)が見つかりません。', 'name_empty' => 'components[:index].component 名は空にすることはできません。', - 'not_found' => 'コンポーネント \':component\' はテンプレートに登録されていません。(components[:index])', + 'not_found' => 'コンポーネント \':component\' はテンプレートに登録されていません。(components[:index])', ], 'request' => [ 'template_id' => [ @@ -406,53 +494,53 @@ return [ 'exists' => '存在しないテンプレートです。', ], 'layout_name' => [ - 'required' => 'レイアウト 名は必須です。', - 'string' => 'レイアウト 名は文字列である必要があります。', - 'max' => 'レイアウト 名は :max 文字を超えることはできません。', - 'unique' => '既に存在するレイアウト 名です。', + 'required' => 'レイアウト名は必須です。', + 'string' => 'レイアウト名は文字列である必要があります。', + 'max' => 'レイアウト名は :max 文字を超えることはできません。', + 'unique' => '既に存在するレイアウト名です。', ], 'content' => [ - 'required' => 'レイアウト 内容は必須です。', - 'array' => 'レイアウト 内容は配列(オブジェクト)である必要があります。', + 'required' => 'レイアウト内容は必須です。', + 'array' => 'レイアウトコンテンツは配列(オブジェクト)である必要があります。', ], ], 'layout_inheritance' => [ 'parent_not_found' => '親レイアウト ":parent" が見つかりません。', - 'parent_not_in_same_template' => '親レイアウトは同じテンプレート内にある必要があります。', - 'circular_reference' => 'レイアウト 循環参照が検出されました: :trace', - 'max_depth_exceeded' => 'レイアウト 継承の深さが最大許容深さ(:max)を超えました。', - 'extends_must_be_string' => 'extendsフィールドは文字列である必要があります。', - 'slots_must_be_object' => 'slotsフィールドはオブジェクトである必要があります。', + 'parent_not_in_same_template' => '親レイアウトは同じテンプレート内に存在する必要があります。', + 'circular_reference' => 'レイアウト循環参照が検出されました: :trace', + 'max_depth_exceeded' => 'レイアウト継承の深さが最大許容深度(:max)を超えています。', + 'extends_must_be_string' => 'extends フィールドは文字列である必要があります。', + 'slots_must_be_object' => 'slots フィールドはオブジェクトである必要があります。', 'slot_name_must_be_string' => 'スロット名は文字列である必要があります。', 'slot_value_must_be_array' => 'slots[:slotName]は配列である必要があります。', - 'slot_not_defined_in_parent' => 'スロット":slotName"は親レイアウトに定義されていません。', + 'slot_not_defined_in_parent' => 'スロット ":slotName" は親レイアウトで定義されていません。', 'parent_has_no_slots' => '親レイアウトにはスロットが定義されていません。', - 'data_source_id_duplicate' => 'data_sourcesに重複したID":id"があります。', - 'data_sources_must_be_array' => 'data_sourcesは配列である必要があります。', - 'data_source_must_have_id' => 'data_sources[:index]に必須フィールド"id"が不足しています。', - 'data_source_id_must_be_string' => 'data_sources[:index].idは文字列である必要があります。', + 'data_source_id_duplicate' => 'data_sources に重複するID ":id" があります。', + 'data_sources_must_be_array' => 'data_sources は配列である必要があります。', + 'data_source_must_have_id' => 'data_sources[:index]に必須フィールド "id" が見つかりません。', + 'data_source_id_must_be_string' => 'data_sources[:index].id は文字列である必要があります。', ], 'translatable' => [ 'must_be_array' => '多言語フィールドは配列である必要があります。', - 'unsupported_language' => 'サポートされていない言語コードです:\':lang\'', - 'must_be_string' => '\':lang\'翻訳は文字列である必要があります。', - 'max_length' => '\':lang\'翻訳は:max文字を超えることができません。', - 'min_length' => '\':lang\'翻訳は最小:min文字以上である必要があります。', + 'unsupported_language' => 'サポートされていない言語コード: \':lang\'', + 'must_be_string' => '\':lang\' 翻訳は文字列である必要があります。', + 'max_length' => '\':lang\' 翻訳は :max文字を超えることはできません。', + 'min_length' => '\':lang\' 翻訳は最低 :min文字以上である必要があります。', 'at_least_one_required' => '最低1つの言語での翻訳が必要です。', - 'current_locale_required' => ':localeの値は必須です。', + 'current_locale_required' => ':locale 言語の値は必須です。', ], 'template' => [ 'name' => [ - 'max' => 'テンプレート名は:max文字を超えることができません。', + 'max' => 'テンプレート名は :max文字を超えることはできません。', ], 'description' => [ - 'max' => 'テンプレート説明は:max文字を超えることができません。', + 'max' => 'テンプレートの説明は :max文字を超えることはできません。', ], 'metadata' => [ - 'array' => 'metadataは配列である必要があります。', + 'array' => 'metadata は配列である必要があります。', ], 'status' => [ - 'in' => 'statusはactiveまたはinactiveである必要があります。', + 'in' => 'status は active または inactive である必要があります。', ], ], 'menu' => [ @@ -462,45 +550,45 @@ return [ 'slug' => [ 'required' => 'スラッグを入力してください。', 'unique' => '既に使用されているスラッグです。', - 'max' => 'スラッグは:max文字を超えることができません。', + 'max' => 'スラッグは :max文字を超えることはできません。', ], 'url' => [ 'required' => 'パス(URL)を入力してください。', - 'max' => 'URLは:max文字を超えることができません。', + 'max' => 'URL は :max文字を超えることはできません。', ], 'icon' => [ - 'max' => 'アイコンは:max文字を超えることができません。', + 'max' => 'アイコンは :max文字を超えることはできません。', ], 'order' => [ 'integer' => '順序は整数である必要があります。', - 'min' => '順序は:min以上である必要があります。', + 'min' => '順序は :min 以上である必要があります。', ], 'parent_id' => [ 'exists' => '存在しない親メニューです。', ], 'is_active' => [ - 'boolean' => '有効化ステータスはtrueまたはfalseである必要があります。', + 'boolean' => '有効化状態は true または false である必要があります。', ], 'extension_type' => [ - 'in' => '拡張タイプはcore、module、plugin のいずれかである必要があります。', + 'in' => '拡張タイプは core、module、plugin のいずれかである必要があります。', ], 'extension_identifier' => [ - 'max' => '拡張識別子は最大255文字まで入力可能です。', + 'max' => '拡張識別子は最大255文字まで入力できます。', 'must_be_string' => '拡張識別子は文字列である必要があります。', - 'min_parts' => '拡張識別子はvendor-name形式である必要があります(例:sirsoft-board)。', - 'empty_part' => '拡張識別子に空白部分があります。ハイフンが連続したり両端に来ることはできません。', - 'invalid_characters' => '拡張識別子は英小文字、数字、アンダースコア(_)のみ使用できます。', - 'empty_word' => '拡張識別子でアンダースコアが連続したり両端に来ることはできません。', - 'word_starts_with_digit' => '拡張識別子の各単語は数字で始まることはできません。', + 'min_parts' => '拡張識別子は vendor-name 形式である必要があります (例: sirsoft-board)。', + 'empty_part' => '拡張識別子に空の部分があります。ハイフンが連続するか、両端にくることはできません。', + 'invalid_characters' => '拡張識別子は英文小文字、数字、アンダースコア(_)のみ使用できます。', + 'empty_word' => '拡張識別子でアンダースコアが連続するか、両端にくることはできません。', + 'word_starts_with_digit' => '拡張識別子の各単語は数字で始めることはできません。', ], 'parent_menus' => [ 'required' => '親メニュー順序配列は必須です。', 'array' => '親メニュー順序は配列である必要があります。', 'min' => '最低1つ以上の親メニューが必要です。', 'id' => [ - 'required' => '各親メニューIDは必須です。', - 'integer' => '親メニューIDは整数である必要があります。', - 'exists' => '存在しない親メニューIDが含まれています。', + 'required' => '各親メニューID は必須です。', + 'integer' => '親メニューID は整数である必要があります。', + 'exists' => '存在しない親メニューID が含まれています。', ], 'order' => [ 'required' => '各親メニュー順序は必須です。', @@ -511,9 +599,9 @@ return [ 'child_menus' => [ 'array' => '子メニュー順序は配列である必要があります。', 'id' => [ - 'required' => '各子メニューIDは必須です。', - 'integer' => '子メニューIDは整数である必要があります。', - 'exists' => '存在しない子メニューIDが含まれています。', + 'required' => '各子メニューID は必須です。', + 'integer' => '子メニューID は整数である必要があります。', + 'exists' => '存在しない子メニューID が含まれています。', ], 'order' => [ 'required' => '各子メニュー順序は必須です。', @@ -524,13 +612,13 @@ return [ 'moved_items' => [ 'array' => '移動項目は配列である必要があります。', 'id' => [ - 'required' => '移動するメニューIDは必須です。', - 'integer' => 'メニューIDは整数である必要があります。', - 'exists' => '存在しないメニューIDが含まれています。', + 'required' => '移動するメニューID は必須です。', + 'integer' => 'メニューID は整数である必要があります。', + 'exists' => '存在しないメニューID が含まれています。', ], 'new_parent_id' => [ - 'integer' => '新しい親メニューIDは整数である必要があります。', - 'exists' => '存在しない親メニューIDです。', + 'integer' => '新しい親メニューID は整数である必要があります。', + 'exists' => '存在しない親メニューID です。', ], ], ], @@ -539,7 +627,7 @@ return [ 'required' => '権限名を入力してください。', ], 'description' => [ - 'max' => '権限説明は:max文字を超えることができません。', + 'max' => '権限説明は :max文字を超えることはできません。', ], ], 'role' => [ @@ -547,60 +635,60 @@ return [ 'required' => 'ロール名を入力してください。', ], 'description' => [ - 'max' => 'ロール説明は:max文字を超えることができません。', + 'max' => 'ロール説明は :max文字を超えることはできません。', ], ], 'module' => [ 'status' => [ - 'in' => 'statusはactive、inactive、installed、uninstalledのいずれかである必要があります。', + 'in' => 'status は active、inactive、installed、uninstalled のいずれかである必要があります。', ], ], 'plugin' => [ 'status' => [ - 'in' => 'statusはactive、inactive、installed、uninstalledのいずれかである必要があります。', + 'in' => 'status は active、inactive、installed、uninstalled のいずれかである必要があります。', ], ], 'template_path' => [ 'must_be_string' => 'テンプレートパスは文字列である必要があります。', - 'traversal_detected' => 'パストラバーサルパターンが検出されました::pattern', + 'traversal_detected' => 'パストラバーサルパターンが検出されました: :pattern', 'absolute_path_not_allowed' => '絶対パスは許可されていません。', - 'null_byte_detected' => 'NULLバイト攻撃が検出されました。', - 'outside_base_directory' => '基準ディレクトリ外のパスは許可されていません。', - 'file_type_not_allowed' => '許可されていないファイルタイプです。拡張子::extension(許可::allowed)', + 'null_byte_detected' => 'NULL バイト攻撃が検出されました。', + 'outside_base_directory' => '基本ディレクトリ外のパスは許可されていません。', + 'file_type_not_allowed' => '許可されていないファイルタイプです。拡張子: :extension (許可: :allowed)', ], 'module_path' => [ 'must_be_string' => 'パスは文字列である必要があります。', - 'traversal_detected' => 'パストラバーサルが検出されました::pattern', - 'absolute_path_not_allowed' => '絶対パスは許可されていません。', - 'null_byte_detected' => 'NULLバイトが検出されました。', - 'outside_base_directory' => '基準ディレクトリ外へのアクセスは許可されていません。', + 'traversal_detected' => 'パストトラバーサルが検出されました: :pattern', + 'absolute_path_not_allowed' => '絶対パスは許可されません。', + 'null_byte_detected' => 'NULL バイトが検出されました。', + 'outside_base_directory' => '基準ディレクトリ外へのアクセスは許可されません。', ], 'plugin_path' => [ 'must_be_string' => 'パスは文字列である必要があります。', - 'traversal_detected' => 'パストラバーサルが検出されました: :pattern', - 'absolute_path_not_allowed' => '絶対パスは許可されていません。', - 'null_byte_detected' => 'NULLバイトが検出されました。', - 'outside_base_directory' => '基本ディレクトリ外へのアクセスは許可されていません。', + 'traversal_detected' => 'パストトラバーサルが検出されました: :pattern', + 'absolute_path_not_allowed' => '絶対パスは許可されません。', + 'null_byte_detected' => 'NULL バイトが検出されました。', + 'outside_base_directory' => '基準ディレクトリ外へのアクセスは許可されません。', ], 'auth' => [ 'email' => [ - 'required' => 'メールは必須です。', - 'email' => '正しいメール形式ではありません。', - 'exists' => '登録されていないメールです。', - 'unique' => '既に使用中のメールです。', + 'required' => 'メールアドレスは必須です。', + 'email' => '正しいメールアドレス形式ではありません。', + 'exists' => '登録されていないメールアドレスです。', + 'unique' => 'すでに使用中のメールアドレスです。', ], 'password' => [ 'required' => 'パスワードは必須です。', - 'min' => 'パスワードは最小:min文字以上である必要があります。', - 'confirmed' => 'パスワード確認が一致しません。', + 'min' => 'パスワードは最小 :min 文字以上である必要があります。', + 'confirmed' => 'パスワードの確認が一致しません。', ], 'name' => [ 'required' => '名前は必須です。', - 'min' => '名前は最小:min文字以上である必要があります。', - 'max' => '名前は:max文字を超えることはできません。', + 'min' => '名前は最小 :min 文字以上である必要があります。', + 'max' => '名前は :max 文字を超えることはできません。', ], 'nickname' => [ - 'max' => 'ニックネームは:max文字を超えることはできません。', + 'max' => 'ニックネームは :max 文字を超えることはできません。', ], 'agree_terms' => [ 'accepted' => '利用規約に同意してください。', @@ -627,59 +715,59 @@ return [ 'value' => [ 'required' => '設定値は必須です。', 'string' => '設定値は文字列である必要があります。', - 'max' => '設定値は:max文字を超えることはできません。', + 'max' => '設定値は :max 文字を超えることはできません。', ], ], - 'exclude_current_user' => '現在ログインしているユーザーは一括変更対象に含めることはできません。', + 'exclude_current_user' => '現在ログインしているユーザーは一括変更の対象に含めることはできません。', 'not_self_parent' => '自分自身を親として設定することはできません。', 'not_circular_parent' => '自分自身のサブメニューに移動することはできません。', 'settings' => [ 'site_name_required' => 'サイト名を入力してください。', - 'site_name_max' => 'サイト名は100文字を超えることはできません。', - 'site_url_required' => 'サイトURLを入力してください。', - 'site_url_invalid' => '正しいURL形式ではありません。', - 'site_url_max' => 'サイトURLは255文字を超えることはできません。', - 'site_description_max' => 'サイト説明は500文字を超えることはできません。', - 'admin_email_required' => '管理者メールを入力してください。', - 'admin_email_invalid' => '正しいメール形式ではありません。', - 'admin_email_max' => '管理者メールは255文字を超えることはできません。', + 'site_name_max' => 'サイト名は 100 文字を超えることはできません。', + 'site_url_required' => 'サイト URL を入力してください。', + 'site_url_invalid' => '正しい URL 形式ではありません。', + 'site_url_max' => 'サイト URL は 255 文字を超えることはできません。', + 'site_description_max' => 'サイト説明は 500 文字を超えることはできません。', + 'admin_email_required' => '管理者メールアドレスを入力してください。', + 'admin_email_invalid' => '正しいメールアドレス形式ではありません。', + 'admin_email_max' => '管理者メールアドレスは 255 文字を超えることはできません。', 'timezone_required' => 'タイムゾーンを選択してください。', 'timezone_invalid' => '正しいタイムゾーンを選択してください。', 'language_required' => 'デフォルト言語を選択してください。', 'language_invalid' => '正しい言語を選択してください。', - 'currency_max' => '通貨コードは10文字を超えることはできません。', - 'maintenance_mode_boolean' => 'メンテナンスモードはtrueまたはfalse値である必要があります。', + 'currency_max' => '通貨コードは 10 文字を超えることはできません。', + 'maintenance_mode_boolean' => 'メンテナンスモードは true または false である必要があります。', 'mailer_required' => 'メーラーを選択してください。', 'mailer_invalid' => '正しいメーラーを選択してください。', - 'host_required' => 'SMTPホストを入力してください。', - 'host_max' => 'SMTPホストは255文字を超えることはできません。', + 'host_required' => 'SMTP ホストを入力してください。', + 'host_max' => 'SMTP ホストは 255 文字を超えることはできません。', 'port_required' => 'ポートを入力してください。', 'port_integer' => 'ポートは整数である必要があります。', - 'port_min' => 'ポートは1以上である必要があります。', - 'port_max' => 'ポートは65535を超えることはできません。', - 'username_max' => 'ユーザー名は255文字を超えることはできません。', - 'password_max' => 'パスワードは255文字を超えることはできません。', + 'port_min' => 'ポートは 1 以上である必要があります。', + 'port_max' => 'ポートは 65535 を超えることはできません。', + 'username_max' => 'ユーザー名は 255 文字を超えることはできません。', + 'password_max' => 'パスワードは 255 文字を超えることはできません。', 'encryption_invalid' => '正しい暗号化方式を選択してください。', - 'from_address_required' => '送信者メールを入力してください。', - 'from_address_invalid' => '正しいメール形式ではありません。', - 'from_address_max' => '送信者メールは255文字を超えることはできません。', + 'from_address_required' => '送信者メールアドレスを入力してください。', + 'from_address_invalid' => '正しいメールアドレス形式ではありません。', + 'from_address_max' => '送信者メールアドレスは 255 文字を超えることはできません。', 'from_name_required' => '送信者名を入力してください。', - 'from_name_max' => '送信者名は255文字を超えることはできません。', + 'from_name_max' => '送信者名は 255 文字を超えることはできません。', 'max_file_size_required' => '最大ファイルサイズを入力してください。', 'max_file_size_integer' => '最大ファイルサイズは整数である必要があります。', - 'max_file_size_min' => '最大ファイルサイズは1MB以上である必要があります。', - 'max_file_size_max' => '最大ファイルサイズは1024MBを超えることはできません。', - 'allowed_extensions_required' => '許可されている拡張子を入力してください。', - 'allowed_extensions_max' => '許可されている拡張子は500文字を超えることはできません。', - 'allowed_extensions_invalid_type' => '許可されている拡張子は文字列または配列である必要があります。', + 'max_file_size_min' => '最大ファイルサイズは 1MB 以上である必要があります。', + 'max_file_size_max' => '最大ファイルサイズは 1024MB を超えることはできません。', + 'allowed_extensions_required' => '許可する拡張子を入力してください。', + 'allowed_extensions_max' => '許可する拡張子は 500 文字を超えることはできません。', + 'allowed_extensions_invalid_type' => '許可する拡張子は文字列または配列である必要があります。', 'image_max_width_integer' => '画像の最大幅は整数である必要があります。', - 'image_max_width_min' => '画像の最大幅は100px以上である必要があります。', - 'image_max_width_max' => '画像の最大幅は10000pxを超えることはできません。', + 'image_max_width_min' => '画像の最大幅は 100px 以上である必要があります。', + 'image_max_width_max' => '画像の最大幅は 10000px を超えることはできません。', 'image_max_height_integer' => '画像の最大高さは整数である必要があります。', - 'image_max_height_min' => '画像の最大高さは100px以上である必要があります。', - 'image_max_height_max' => '画像の最大高さは10000pxを超えることはできません。', + 'image_max_height_min' => '画像の最大高さは 100px 以上である必要があります。', + 'image_max_height_max' => '画像の最大高さは 10000px を超えることはできません。', 'image_quality_integer' => '画像品質は整数である必要があります。', - 'image_quality_min' => '画像品質は1以上である必要があります。', + 'image_quality_min' => '画像品質は 1 以上である必要があります。', 'image_quality_max' => '画像品質は100を超えることはできません。', 'meta_title_suffix_max' => 'タイトルサフィックスは100文字を超えることはできません。', 'meta_description_max' => 'メタ説明は160文字を超えることはできません。', @@ -687,79 +775,110 @@ return [ 'google_analytics_id_max' => 'Google Analytics IDは50文字を超えることはできません。', 'google_site_verification_max' => 'Google サイト確認コードは100文字を超えることはできません。', 'naver_site_verification_max' => 'Naver サイト確認コードは100文字を超えることはできません。', + 'generator_enabled_boolean' => 'Generator タグ表示設定はtrue又はfalse値である必要があります。', + 'generator_content_string' => 'Generator コンテンツは文字列である必要があります。', + 'generator_content_max' => 'Generator コンテンツは200文字を超えることはできません。', 'bot_user_agents_array' => 'ボット User-Agent リストは配列である必要があります。', - 'bot_user_agents_item_string' => 'ボット User-Agent 項目は文字列である必要があります。', - 'bot_user_agents_item_max' => 'ボット User-Agent 項目は100文字を超えることはできません。', - 'bot_detection_enabled_boolean' => 'ボット検出設定は true または false 値である必要があります。', - 'seo_cache_enabled_boolean' => 'SEO キャッシュは true または false 値である必要があります。', - 'seo_cache_ttl_integer' => 'SEO キャッシュ有効期限は整数である必要があります。', - 'seo_cache_ttl_min' => 'SEO キャッシュ有効期限は最小0秒である必要があります。', - 'seo_cache_ttl_max' => 'SEO キャッシュ有効期限は最大14400秒(4時間)を超えることはできません。', - 'sitemap_enabled_boolean' => 'Sitemap 生成設定は true または false 値である必要があります。', - 'sitemap_cache_ttl_integer' => 'Sitemap キャッシュ TTL は整数である必要があります。', - 'sitemap_cache_ttl_min' => 'Sitemap キャッシュ TTL は最小3600秒(1時間)以上である必要があります。', - 'sitemap_cache_ttl_max' => 'Sitemap キャッシュ TTL は最大604800秒(7日)を超えることはできません。', - 'sitemap_schedule_invalid' => '有効な Sitemap 生成周期を選択してください。', - 'sitemap_schedule_time_invalid' => 'Sitemap 生成時刻は HH:mm 形式である必要があります。', - 'force_https_required' => 'HTTPS 強制適用設定を選択してください。', - 'force_https_boolean' => 'HTTPS 強制適用は true または false 値である必要があります。', + 'bot_user_agents_item_string' => 'ボット User-Agent アイテムは文字列である必要があります。', + 'bot_user_agents_item_max' => 'ボット User-Agent アイテムは100文字を超えることはできません。', + 'bot_detection_enabled_boolean' => 'ボット検出設定はtrue又はfalse値である必要があります。', + 'bot_detection_library_enabled_boolean' => 'ボット検出ライブラリ設定はtrue又はfalse値である必要があります。', + 'og_default_site_name_string' => 'OG サイト名デフォルト値は文字列である必要があります。', + 'og_default_site_name_max' => 'OG サイト名デフォルト値は200文字を超えることはできません。', + 'og_image_default_width_integer' => 'OG 画像デフォルト幅は整数である必要があります。', + 'og_image_default_width_min' => 'OG 画像デフォルト幅は0以上である必要があります。', + 'og_image_default_width_max' => 'OG 画像デフォルト幅は8000以下である必要があります。', + 'og_image_default_height_integer' => 'OG 画像デフォルト高さは整数である必要があります。', + 'og_image_default_height_min' => 'OG 画像デフォルト高さは0以上である必要があります。', + 'og_image_default_height_max' => 'OG 画像デフォルト高さは8000以下である必要があります。', + 'twitter_default_card_string' => 'Twitter カードデフォルト値は文字列である必要があります。', + 'twitter_default_card_in' => 'Twitter カードデフォルト値はsummary、summary_large_image、app、playerのいずれかである必要があります。', + 'twitter_default_site_string' => 'Twitter サイトハンドルは文字列である必要があります。', + 'twitter_default_site_max' => 'Twitter サイトハンドルは50文字を超えることはできません。', + 'seo_cache_enabled_boolean' => 'SEO キャッシュ設定はtrue又はfalse値である必要があります。', + 'seo_cache_ttl_integer' => 'SEO キャッシュ TTLは整数である必要があります。', + 'seo_cache_ttl_min' => 'SEO キャッシュ TTLは最小60秒以上である必要があります。', + 'seo_cache_ttl_max' => 'SEO キャッシュ TTLは最大86400秒(24時間)を超えることはできません。', + 'sitemap_enabled_boolean' => 'Sitemap 生成設定はtrue又はfalse値である必要があります。', + 'sitemap_cache_ttl_integer' => 'Sitemap キャッシュ TTLは整数である必要があります。', + 'sitemap_cache_ttl_min' => 'Sitemap キャッシュ TTLは最小3600秒(1時間)以上である必要があります。', + 'sitemap_cache_ttl_max' => 'Sitemap キャッシュ TTLは最大604800秒(7日)を超えることはできません。', + 'sitemap_urls_per_file_integer' => 'Sitemap ファイルあたりURL数は整数である必要があります。', + 'sitemap_urls_per_file_min' => 'Sitemap ファイルあたりURL数は最小1000件以上である必要があります。', + 'sitemap_urls_per_file_max' => 'Sitemap ファイルあたりURL数は最大50000件を超えることはできません。', + 'sitemap_gzip_boolean' => 'Sitemap 圧縮設定はtrue又はfalse値である必要があります。', + 'sitemap_serve_stale_on_miss_boolean' => '前Sitemap 提供設定はtrue又はfalse値である必要があります。', + 'sitemap_max_urls_per_contributor_integer' => 'クローラーあたり最大URL数は整数である必要があります。', + 'sitemap_max_urls_per_contributor_min' => 'クローラーあたり最大URL数は0以上である必要があります。', + 'sitemap_schedule_invalid' => '有効なSitemap生成周期を選択してください。', + 'sitemap_schedule_time_invalid' => 'Sitemap生成時刻はHH:mm形式である必要があります。', + 'force_https_required' => 'HTTPS強制適用設定を選択してください。', + 'force_https_boolean' => 'HTTPS強制適用はtrue又はfalse値である必要があります。', + 'allow_internal_outbound_urls_boolean' => '内部ネットワークアドレス呼び出し許可はtrue又はfalse値である必要があります。', 'login_attempt_enabled_required' => 'ログイン試行制限設定を選択してください。', - 'login_attempt_enabled_boolean' => 'ログイン試行制限は true または false 値である必要があります。', + 'login_attempt_enabled_boolean' => 'ログイン試行制限はtrue又はfalse値である必要があります。', 'auth_token_lifetime_integer' => '認証トークン保持時間は整数である必要があります。', 'auth_token_lifetime_min' => '認証トークン保持時間は0以上である必要があります。', 'auth_token_lifetime_max' => '認証トークン保持時間は最大3600分(60時間)を超えることはできません。', - 'auth_token_lifetime_range' => '認証トークン保持時間は0(無制限)または30~3600分の間である必要があります。', + 'auth_token_lifetime_range' => '認証トークン保持時間は0(無限)又は30~3600分の間である必要があります。', 'max_login_attempts_integer' => '最大ログイン試行回数は整数である必要があります。', 'max_login_attempts_min' => '最大ログイン試行回数は0以上である必要があります。', 'max_login_attempts_max' => '最大ログイン試行回数は100回を超えることはできません。', 'login_lockout_time_integer' => 'ブロック時間は整数である必要があります。', 'login_lockout_time_min' => 'ブロック時間は0以上である必要があります。', 'login_lockout_time_max' => 'ブロック時間は最大1440分(24時間)を超えることはできません。', - 'cache_enabled_required' => '全体 キャッシュ有効化設定を選択してください。', - 'cache_enabled_boolean' => 'キャッシュ有効化は true または false 値である必要があります。', - 'layout_cache_enabled_required' => 'レイアウト キャッシュ設定を選択してください。', - 'layout_cache_enabled_boolean' => 'レイアウト キャッシュは true または false 値である必要があります。', - 'layout_cache_ttl_required' => 'レイアウト キャッシュ有効期限を入力してください。', - 'layout_cache_ttl_integer' => 'レイアウト キャッシュ有効期限は整数である必要があります。', - 'layout_cache_ttl_min' => 'レイアウト キャッシュ有効期限は最小0秒である必要があります。', - 'layout_cache_ttl_max' => 'レイアウト キャッシュ有効期限は最大14400秒(4時間)を超えることはできません。', - 'stats_cache_enabled_required' => '統計 キャッシュ設定を選択してください。', - 'stats_cache_enabled_boolean' => '統計 キャッシュは true または false 値である必要があります。', - 'stats_cache_ttl_required' => '統計 キャッシュ有効期限を入力してください。', - 'stats_cache_ttl_integer' => '統計 キャッシュ有効期限は整数である必要があります。', - 'stats_cache_ttl_min' => '統計 キャッシュ有効期限は最小0秒である必要があります。', - 'stats_cache_ttl_max' => '統計 キャッシュ有効期限は最大14400秒(4時間)を超えることはできません。', - 'seo_cache_enabled_required' => 'SEO キャッシュ設定を選択してください。', - 'seo_cache_ttl_required' => 'SEO キャッシュ有効期限を入力してください。', + 'cache_enabled_required' => '全体キャッシュ有効化設定を選択してください。', + 'cache_enabled_boolean' => 'キャッシュ有効化はtrue又はfalse値である必要があります。', + 'layout_cache_enabled_required' => 'レイアウトキャッシュ設定を選択してください。', + 'layout_cache_enabled_boolean' => 'レイアウトキャッシュはtrue又はfalse値である必要があります。', + 'layout_cache_ttl_required' => 'レイアウトキャッシュ有効期限を入力してください。', + 'layout_cache_ttl_integer' => 'レイアウトキャッシュ有効期限は整数である必要があります。', + 'layout_cache_ttl_min' => 'レイアウトキャッシュ有効期限は最小0秒である必要があります。', + 'layout_cache_ttl_max' => 'レイアウトキャッシュ有効期限は最大14400秒(4時間)を超えることはできません。', + 'stats_cache_enabled_required' => '統計キャッシュ設定を選択してください。', + 'stats_cache_enabled_boolean' => '統計キャッシュはtrue又はfalse値である必要があります。', + 'stats_cache_ttl_required' => '統計キャッシュ有効期限を入力してください。', + 'stats_cache_ttl_integer' => '統計キャッシュ有効期限は整数である必要があります。', + 'stats_cache_ttl_min' => '統計キャッシュ有効期限は最小0秒である必要があります。', + 'stats_cache_ttl_max' => '統計キャッシュ有効期限は最大14400秒(4時間)を超えることはできません。', + 'advanced_seo_cache_enabled_required' => 'SEO キャッシュ設定を選択してください。', + 'advanced_seo_cache_enabled_boolean' => 'SEO キャッシュはtrue又はfalse値である必要があります。', + 'advanced_seo_cache_ttl_required' => 'SEO キャッシュ有効期限を入力してください。', + 'advanced_seo_cache_ttl_integer' => 'SEO キャッシュ有効期限は整数である必要があります。', + 'advanced_seo_cache_ttl_min' => 'SEO キャッシュ有効期限は最小0秒である必要があります。', + 'advanced_seo_cache_ttl_max' => 'SEO キャッシュ有効期限は最大14400秒(4時間)を超えることはできません。', + 'seo_sitemap_cache_ttl_integer' => 'Sitemap キャッシュ有効期限は整数である必要があります。', + 'seo_sitemap_cache_ttl_min' => 'Sitemapキャッシュの有効期限は最小3600秒(1時間)以上である必要があります。', + 'seo_sitemap_cache_ttl_max' => 'Sitemapキャッシュの有効期限は最大604800秒(7日)を超えることはできません。', 'debug_mode_required' => 'デバッグモード設定を選択してください。', - 'debug_mode_boolean' => 'デバッグモードは true または false 値である必要があります。', - 'sql_query_log_required' => 'SQL クエリログ設定を選択してください。', - 'sql_query_log_boolean' => 'SQL クエリログは true または false 値である必要があります。', - 'core_update_github_url_invalid' => 'GitHub リポジトリ URL 形式が正しくありません。', - 'core_update_github_url_max' => 'GitHub リポジトリ URL は500文字を超えることはできません。', - 'core_update_github_token_max' => 'GitHub アクセストークンは500文字を超えることはできません。', + 'debug_mode_boolean' => 'デバッグモードはtrueまたはfalse値である必要があります。', + 'sql_query_log_required' => 'SQLクエリログ設定を選択してください。', + 'sql_query_log_boolean' => 'SQLクエリログはtrueまたはfalse値である必要があります。', + 'core_update_github_url_invalid' => 'GitHubリポジトリURLの形式が正しくありません。', + 'core_update_github_url_max' => 'GitHubリポジトリURLは500字を超えることはできません。', + 'core_update_github_token_max' => 'GitHubアクセストークンは500字を超えることはできません。', 'storage_driver_required' => 'ストレージドライバーを選択してください。', 'storage_driver_invalid' => '正しいストレージドライバーを選択してください。', - 's3_bucket_max' => 'S3 バケット名は255文字を超えることはできません。', - 's3_region_invalid' => '正しい S3 リージョンを選択してください。', - 's3_access_key_max' => 'S3 Access Key は255文字を超えることはできません。', - 's3_secret_key_max' => 'S3 Secret Key は255文字を超えることはできません。', - 's3_url_invalid' => '正しい S3 URL 形式ではありません。', - 's3_url_max' => 'S3 URL は500文字を超えることはできません。', + 's3_bucket_max' => 'S3バケット名は255字を超えることはできません。', + 's3_region_invalid' => '正しいS3リージョンを選択してください。', + 's3_access_key_max' => 'S3 Access Keyは255字を超えることはできません。', + 's3_secret_key_max' => 'S3 Secret Keyは255字を超えることはできません。', + 's3_url_invalid' => '正しいS3 URL形式ではありません。', + 's3_url_max' => 'S3 URLは500字を超えることはできません。', 'cache_driver_required' => 'キャッシュドライバーを選択してください。', 'cache_driver_invalid' => '正しいキャッシュドライバーを選択してください。', - 'redis_host_max' => 'Redis ホストは255文字を超えることはできません。', - 'redis_port_integer' => 'Redis ポートは整数である必要があります。', - 'redis_port_min' => 'Redis ポートは1以上である必要があります。', - 'redis_port_max' => 'Redis ポートは65535を超えることはできません。', - 'redis_password_max' => 'Redis パスワードは255文字を超えることはできません。', - 'redis_database_integer' => 'Redis データベースは整数である必要があります。', - 'redis_database_min' => 'Redis データベースは0以上である必要があります。', - 'redis_database_max' => 'Redis データベースは15を超えることはできません。', - 'memcached_host_max' => 'Memcached ホストは255文字を超えることはできません。', - 'memcached_port_integer' => 'Memcached ポートは整数である必要があります。', - 'memcached_port_min' => 'Memcached ポートは1以上である必要があります。', - 'memcached_port_max' => 'Memcached ポートは65535を超えることはできません。', + 'redis_host_max' => 'Redisホストは255字を超えることはできません。', + 'redis_port_integer' => 'Redisポートは整数である必要があります。', + 'redis_port_min' => 'Redisポートは1以上である必要があります。', + 'redis_port_max' => 'Redisポートは65535を超えることはできません。', + 'redis_password_max' => 'Redisパスワードは255字を超えることはできません。', + 'redis_database_integer' => 'Redisデータベースは整数である必要があります。', + 'redis_database_min' => 'Redisデータベースは0以上である必要があります。', + 'redis_database_max' => 'Redisデータベースは15を超えることはできません。', + 'memcached_host_max' => 'Memcachedホストは255字を超えることはできません。', + 'memcached_port_integer' => 'Memcachedポートは整数である必要があります。', + 'memcached_port_min' => 'Memcachedポートは1以上である必要があります。', + 'memcached_port_max' => 'Memcachedポートは65535を超えることはできません。', 'session_driver_required' => 'セッションドライバーを選択してください。', 'session_driver_invalid' => '正しいセッションドライバーを選択してください。', 'session_lifetime_integer' => 'セッション有効時間は整数である必要があります。', @@ -767,61 +886,32 @@ return [ 'session_lifetime_max' => 'セッション有効時間は最大30日(43200分)を超えることはできません。', 'queue_driver_required' => 'キュードライバーを選択してください。', 'queue_driver_invalid' => '正しいキュードライバーを選択してください。', - 'websocket_enabled_boolean' => 'WebSocket有効化設定はtrueまたはfalse値である必要があります。', + 'websocket_enabled_boolean' => 'WebSocket使用設定はtrueまたはfalse値である必要があります。', 'websocket_app_id_required' => 'WebSocket使用時はアプリIDが必須です。', - 'websocket_app_id_max' => 'WebSocketアプリIDは255文字を超えることはできません。', + 'websocket_app_id_max' => 'WebSocketアプリIDは255字を超えることはできません。', 'websocket_app_key_required' => 'WebSocket使用時はアプリキーが必須です。', - 'websocket_app_key_max' => 'WebSocketアプリキーは255文字を超えることはできません。', + 'websocket_app_key_max' => 'WebSocketアプリキーは255字を超えることはできません。', 'websocket_app_secret_required' => 'WebSocket使用時はアプリシークレットが必須です。', - 'websocket_app_secret_max' => 'WebSocketアプリシークレットは255文字を超えることはできません。', - 'websocket_host_max' => 'WebSocketホストは255文字を超えることはできません。', + 'websocket_app_secret_max' => 'WebSocketアプリシークレットは255字を超えることはできません。', + 'websocket_host_max' => 'WebSocketホストは255字を超えることはできません。', 'websocket_port_integer' => 'WebSocketポートは整数である必要があります。', 'websocket_port_min' => 'WebSocketポートは1以上である必要があります。', 'websocket_port_max' => 'WebSocketポートは65535を超えることはできません。', 'websocket_scheme_invalid' => '正しいWebSocketプロトコルを選択してください。', 'websocket_verify_ssl_boolean' => 'SSL証明書検証設定はtrueまたはfalse値である必要があります。', - 'websocket_server_host_max' => 'WebSocketサーバーホストは255文字を超えることはできません。', + 'websocket_server_host_max' => 'WebSocketサーバーホストは255字を超えることはできません。', 'websocket_server_port_integer' => 'WebSocketサーバーポートは整数である必要があります。', 'websocket_server_port_min' => 'WebSocketサーバーポートは1以上である必要があります。', 'websocket_server_port_max' => 'WebSocketサーバーポートは65535を超えることはできません。', 'websocket_server_scheme_invalid' => '正しいWebSocketサーバープロトコルを選択してください。', 'search_engine_driver_invalid' => '正しい検索エンジンドライバーを選択してください。', - 'identity_default_provider_string' => 'デフォルトプロバイダーは文字列である必要があります。', - 'identity_default_provider_max' => 'デフォルトプロバイダー識別子は100文字を超えることはできません。', - 'identity_purpose_providers_array' => '目的別プロバイダーマッピングは配列である必要があります。', - 'identity_purpose_provider_string' => '目的別プロバイダー識別子は文字列である必要があります。', - 'identity_purpose_provider_max' => '目的別プロバイダー識別子は100文字を超えることはできません。', - 'identity_challenge_ttl_required' => 'Challenge有効期限時間を入力してください。', - 'identity_challenge_ttl_integer' => 'Challenge有効期限時間は整数である必要があります。', - 'identity_challenge_ttl_min' => 'Challenge有効期限時間は最小1分以上である必要があります。', - 'identity_challenge_ttl_max' => 'Challenge有効期限時間は最大1440分(24時間)を超えることはできません。', - 'identity_max_attempts_required' => '最大試行回数を入力してください。', - 'identity_max_attempts_integer' => '最大試行回数は整数である必要があります。', - 'identity_max_attempts_min' => '最大試行回数は最小1回以上である必要があります。', - 'identity_max_attempts_max' => '最大試行回数は最大20回を超えることはできません。', - 'bot_detection_library_enabled_boolean' => 'ボット検出ライブラリ設定はtrueまたはfalse値である必要があります。', - 'og_default_site_name_string' => 'OGサイト名デフォルト値は文字列である必要があります。', - 'og_default_site_name_max' => 'OGサイト名デフォルト値は200文字を超えることはできません。', - 'og_image_default_width_integer' => 'OG画像デフォルト横サイズは整数である必要があります。', - 'og_image_default_width_min' => 'OG画像デフォルト横サイズは0以上である必要があります。', - 'og_image_default_width_max' => 'OG画像デフォルト横サイズは8000以下である必要があります。', - 'og_image_default_height_integer' => 'OG画像デフォルト縦サイズは整数である必要があります。', - 'og_image_default_height_min' => 'OG画像デフォルト縦サイズは0以上である必要があります。', - 'og_image_default_height_max' => 'OG画像デフォルト縦サイズは8000以下である必要があります。', - 'twitter_default_card_string' => 'Twitterカードデフォルト値は文字列である必要があります。', - 'twitter_default_card_in' => 'Twitterカードデフォルト値はsummary、summary_large_image、app、playerのいずれかである必要があります。', - 'twitter_default_site_string' => 'Twitterサイトハンドルは文字列である必要があります。', - 'twitter_default_site_max' => 'Twitterサイトハンドルは50文字を超えることはできません。', - 'generator_enabled_boolean' => 'Generator タグ表示設定は true または false 値である必要があります。', - 'generator_content_string' => 'Generator コンテンツは文字列である必要があります。', - 'generator_content_max' => 'Generator コンテンツは200文字を超えることはできません。', 'log_driver_required' => 'ログドライバーを選択してください。', 'log_driver_invalid' => '正しいログドライバーを選択してください。', 'log_level_required' => 'ログレベルを選択してください。', 'log_level_invalid' => '正しいログレベルを選択してください。', - 'log_days_integer' => 'ログ保管日数は整数である必要があります。', - 'log_days_min' => 'ログ保管日数は1以上である必要があります。', - 'log_days_max' => 'ログ保管日数は365を超えることはできません。', + 'log_days_integer' => 'ログ保持日数は整数である必要があります。', + 'log_days_min' => 'ログ保持日数は1以上である必要があります。', + 'log_days_max' => 'ログ保持日数は365を超えることはできません。', 's3_bucket_required' => 'S3バケット名は必須です。', 's3_region_required' => 'S3リージョンを選択してください。', 's3_access_key_required' => 'S3 Access Keyは必須です。', @@ -836,15 +926,27 @@ return [ 'websocket_host_required' => 'WebSocketホストは必須です。', 'websocket_port_required' => 'WebSocketポートは必須です。', 'websocket_scheme_required' => 'WebSocketプロトコルを選択してください。', - 'allow_internal_outbound_urls_boolean' => '内部ネットワークアドレス呼び出し許可は true または false 値である必要があります。', + 'identity_default_provider_string' => 'デフォルトプロバイダーは文字列である必要があります。', + 'identity_default_provider_max' => 'デフォルトプロバイダー識別子は100字を超えることはできません。', + 'identity_purpose_providers_array' => '目的別プロバイダーマッピングは配列である必要があります。', + 'identity_purpose_provider_string' => '目的別プロバイダー識別子は文字列である必要があります。', + 'identity_purpose_provider_max' => '目的別プロバイダー識別子は100文字を超えることはできません。', + 'identity_challenge_ttl_required' => 'Challenge有効期限を入力してください。', + 'identity_challenge_ttl_integer' => 'Challenge有効期限は整数である必要があります。', + 'identity_challenge_ttl_min' => 'Challenge有効期限は最小1分以上である必要があります。', + 'identity_challenge_ttl_max' => 'Challenge有効期限は最大1440分(24時間)を超えることはできません。', + 'identity_max_attempts_required' => '最大試行回数を入力してください。', + 'identity_max_attempts_integer' => '最大試行回数は整数である必要があります。', + 'identity_max_attempts_min' => '最大試行回数は最小1回以上である必要があります。', + 'identity_max_attempts_max' => '最大試行回数は最大20回を超えることはできません。', ], 'identity_policy' => [ 'key_required' => 'ポリシーキーを入力してください。', 'key_max' => 'ポリシーキーは120文字を超えることはできません。', - 'key_unique' => '既に使用中のポリシーキーです。', + 'key_unique' => '既に使用されているポリシーキーです。', 'scope_required' => 'スコープを選択してください。', 'scope_invalid' => 'スコープはroute、hook、customのいずれかである必要があります。', - 'target_required' => 'ターゲット(ルートまたはフック名)を入力してください。', + 'target_required' => 'ターゲット(ルート名またはフック名)を入力してください。', 'target_max' => 'ターゲットは255文字を超えることはできません。', 'purpose_required' => '認証目的(Purpose)を選択してください。', 'purpose_max' => '認証目的は64文字を超えることはできません。', @@ -857,17 +959,17 @@ return [ 'priority_integer' => '優先度は整数である必要があります。', 'priority_min' => '優先度は0以上である必要があります。', 'priority_max' => '優先度は65535を超えることはできません。', + 'priority_duplicate' => '同じ適用位置(:target)に優先度:priorityの有効なポリシーが既に存在します。どちらのポリシーを先に適用するか決まらないため、優先度を変更するか既存のポリシーを無効化してください。', 'conditions_array' => '条件(conditions)は配列である必要があります。', 'applies_to_required' => '適用対象を選択してください。', 'applies_to_invalid' => '適用対象はself、admin、bothのいずれかである必要があります。', 'fail_mode_required' => '失敗モードを選択してください。', 'fail_mode_invalid' => '失敗モードはblockまたはlog_onlyである必要があります。', - 'priority_duplicate' => '同じ適用位置(:target)に優先度 :priority のアクティブなポリシーが既に存在します。どのポリシーを先に適用するか決まらないため、優先度を異なるように指定するか、既存のポリシーを無効化してください。', ], 'identity_message' => [ 'provider_not_registered' => '登録されていないIDVプロバイダーです。', 'scope_value_not_admin_policy' => '運営者が追加したポリシー(source_type=admin)のキーと一致する必要があります。', - 'definition_already_exists' => '同一(provider、scope_type、scope_value)定義が既に存在します。', + 'definition_already_exists' => '同一の(provider、scope_type、scope_value)定義が既に存在します。', ], 'attributes' => [ 'ids' => 'ユーザーID一覧', @@ -881,7 +983,7 @@ return [ 'language' => 'デフォルト言語', 'identity_default_provider' => 'デフォルトプロバイダー', 'identity_purpose_providers' => '目的別プロバイダー', - 'identity_challenge_ttl_minutes' => 'Challenge有効期限時間', + 'identity_challenge_ttl_minutes' => 'Challenge有効期限', 'identity_max_attempts' => '最大試行回数', 'identity_policy_key' => 'ポリシーキー', 'identity_policy_scope' => 'Scope', @@ -893,20 +995,20 @@ return [ 'identity_policy_applies_to' => '適用対象', 'identity_policy_fail_mode' => '失敗モード', 'mailer' => 'メーラー', - 'host' => 'SMTP ホスト', - 'port' => 'SMTP ポート', - 'username' => 'SMTP ユーザー名', - 'password' => 'SMTP パスワード', + 'host' => 'SMTPホスト', + 'port' => 'SMTPポート', + 'username' => 'SMTPユーザー名', + 'password' => 'SMTPパスワード', 'encryption' => '暗号化', - 'from_address' => '送信元メール', + 'from_address' => '送信元メールアドレス', 'from_name' => '送信元名', 'max_file_size' => '最大ファイルサイズ', - 'allowed_extensions' => '許可される拡張子', + 'allowed_extensions' => '許可拡張子', 'image_max_width' => '画像最大幅', 'image_max_height' => '画像最大高さ', 'image_quality' => '画像品質', - 'meta_title_suffix' => 'メタタイトル接尾辞', - 'meta_description' => 'メタ説明', + 'meta_title_suffix' => 'メタタイトルサフィックス', + 'meta_description' => 'メタディスクリプション', 'meta_keywords' => 'メタキーワード', 'google_analytics_id' => 'Google Analytics ID', 'google_site_verification' => 'Google サイト確認', @@ -915,112 +1017,24 @@ return [ 'to_version' => '終了バージョン', 'storage_driver' => 'ストレージドライバー', 's3_bucket' => 'S3バケット', - 's3_region' => 'S3リージョン', + 's3_region' => 'S3 リージョン', 's3_access_key' => 'S3 Access Key', 's3_secret_key' => 'S3 Secret Key', 's3_url' => 'S3 URL', - 'cache_driver' => 'キャッシュドライバー', - 'redis_host' => 'Redisホスト', - 'redis_port' => 'Redisポート', - 'redis_password' => 'Redisパスワード', - 'redis_database' => 'Redisデータベース', - 'memcached_host' => 'Memcachedホスト', - 'memcached_port' => 'Memcachedポート', - 'session_driver' => 'セッションドライバー', - 'session_lifetime' => 'セッション有効時間', - 'queue_driver' => 'キュードライバー', - 'websocket_enabled' => 'WebSocket使用', - 'websocket_app_key' => 'WebSocketアプリキー', - 'websocket_host' => 'WebSocketホスト', - 'websocket_port' => 'WebSocketポート', - 'websocket_scheme' => 'WebSocketプロトコル', - ], - 'layout_extension' => [ - 'invalid_json' => '無効なJSON形式です。', - 'must_be_array' => 'レイアウト拡張データは配列である必要があります。', - 'target_required' => '拡張定義には「extension_point」または「target_layout」のいずれかが必要です。', - 'target_exclusive' => '「extension_point」と「target_layout」は同時に指定できません。', - 'extension_point_invalid' => 'extension_pointフィールドは空でない文字列である必要があります。', - 'target_layout_invalid' => 'target_layoutフィールドは空でない文字列である必要があります。', - 'components_must_be_array' => 'componentsフィールドは配列である必要があります。', - 'injections_required' => 'overlay拡張にはinjectionsフィールドが必要です。', - 'injections_must_be_array' => 'injectionsフィールドは配列である必要があります。', - 'injection_must_be_array' => 'injections[:index]は配列である必要があります。', - 'injection_target_id_required' => 'injections[:index]にtarget_idフィールドが必要です。', - 'injection_position_invalid' => 'injections[:index].positionの値が無効です。', - 'injection_components_must_be_array' => 'injections[:index].componentsは配列である必要があります。', - 'injection_props_must_be_array' => 'injections[:index].propsは配列である必要があります。', - 'section_must_be_array' => ':sectionフィールドは配列である必要があります。', - 'max_depth_exceeded' => 'コンポーネントのネスト深度が最大許可深度(:max)を超えています。', - 'component_must_be_array' => 'components[:index]は配列である必要があります。', - 'component_required_field_missing' => 'components[:index]に必須フィールド「:field」が不足しています。', - 'component_name_must_be_string' => 'components[:index].nameは文字列である必要があります。', - 'component_type_invalid' => 'components[:index].typeはbasic、composite、layoutのいずれかである必要があります。', - 'props_must_be_object' => 'components[:index].propsはオブジェクト(配列)である必要があります。', - 'children_must_be_array' => 'components[:index].childrenは配列である必要があります。', - 'content' => [ - 'required' => '拡張コンテンツは必須です。', - 'array' => '拡張コンテンツは配列である必要があります。', - ], - 'priority' => [ - 'integer' => 'priorityは整数である必要があります。', - 'min' => 'priorityは0以上である必要があります。', - 'max' => 'priorityは9999以下である必要があります。', - ], - 'data_sources' => [ - 'array' => 'data_sourcesは配列である必要があります。', - ], - 'preview_layout' => [ - 'string' => 'プレビューレイアウト名は文字列である必要があります。', - 'max' => 'プレビューレイアウト名は255文字を超えることはできません。', - 'required' => '拡張ポイントプレビューには代表レイアウトの選択が必要です。', - ], - 'expected_lock_version' => [ - 'required' => '保存リクエストに expected_lock_version が不足しています。', - 'integer' => 'expected_lock_version は整数である必要があります。', - 'min' => 'expected_lock_version は0以上である必要があります。', - ], - ], - 'custom_translation' => [ - 'layout_name' => [ - 'required' => 'レイアウト名は必須です。', - 'string' => 'レイアウト名は文字列である必要があります。', - 'max' => 'レイアウト名は:max文字を超えることはできません。', - ], - 'locale' => [ - 'required' => 'ロケールは必須です。', - 'string' => 'ロケールは文字列である必要があります。', - ], - 'value' => [ - 'required' => '翻訳値は必須です。', - 'string' => '翻訳値は文字列である必要があります。', - ], - 'values' => [ - 'required' => '翻訳値は必須です。', - 'array' => '翻訳値はロケール別のオブジェクトである必要があります。', - ], - 'status' => [ - 'in' => 'ステータスはactiveまたはorphanedである必要があります。', - ], - 'expected_lock_version' => [ - 'required' => '保存リクエストにexpected_lock_versionが不足しています。', - 'integer' => 'expected_lock_versionは整数である必要があります。', - 'min' => 'expected_lock_versionは0以上である必要があります。', - ], - 'ids' => [ - 'required' => '削除する多言語キーを1つ以上選択する必要があります。', - 'array' => '削除対象はID配列である必要があります。', - 'min' => '削除する多言語キーを1つ以上選択する必要があります。', - 'integer' => '多言語キーIDは整数である必要があります。', - 'exists' => '存在しない多言語キーが含まれています。', - ], - ], - 'outbound_url' => [ - 'invalid' => '正しい形式のURLではありません。http または https で始まるアドレスを入力してください。', - 'internal_not_allowed' => '内部ネットワークアドレス(プライベートIP·localhost など)は使用できません。外部からアクセス可能なアドレスを入力してください。', - ], - 'schedule_command' => [ - 'shell_not_allowed' => '許可されていないシェルコマンドです。サーバーに登録された実行ファイルのみ使用でき、パイプ(|)・セミコロン(;)などの特殊文字は使用できません。', - 'artisan_denied' => 'このArtisanコマンドはセキュリティ上、スケジュールで実行することはできません。', + 'cache_driver' => 'キャッシュ ドライバー', + 'redis_host' => 'Redis ホスト', + 'redis_port' => 'Redis ポート', + 'redis_password' => 'Redis パスワード', + 'redis_database' => 'Redis データベース', + 'memcached_host' => 'Memcached ホスト', + 'memcached_port' => 'Memcached ポート', + 'session_driver' => 'セッション ドライバー', + 'session_lifetime' => 'セッション 有効期限', + 'queue_driver' => 'キュー ドライバー', + 'websocket_enabled' => 'WebSocket 使用', + 'websocket_app_key' => 'WebSocket アプリ キー', + 'websocket_host' => 'WebSocket ホスト', + 'websocket_port' => 'WebSocket ポート', + 'websocket_scheme' => 'WebSocket プロトコル', ], ]; diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/vendor.php b/lang-packs/_bundled/g7-core-ja/backend/ja/vendor.php index c3f2ebc5..97bf3402 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/vendor.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/vendor.php @@ -3,18 +3,18 @@ return [ 'mode' => [ 'auto' => '自動 (推奨)', - 'composer' => 'Composer を実行', - 'bundled' => 'バンドル Vendor を使用', + 'composer' => 'Composer 実行', + 'bundled' => 'バンドル Vendor 使用', ], 'installer' => [ - 'checking_bundle' => 'Vendor バンドルを検証中...', - 'extracting_bundle' => 'Vendor バンドルを抽出中 ({current}/{total})', - 'running_composer' => 'Composer install を実行中...', + 'checking_bundle' => 'Vendor バンドル検証中...', + 'extracting_bundle' => 'Vendor バンドル抽出中 ({current}/{total})', + 'running_composer' => 'Composer install 実行中...', 'mode_label' => 'Vendor インストール方式', 'mode_hint' => 'Composer が使用できない環境ではバンドルモードを選択してください。', ], 'build' => [ - 'start' => '{target} をビルド中...', + 'start' => '{target} ビルド中...', 'success' => '{target} ビルド完了 ({size}, {packages} packages)', 'skipped_no_deps' => '{target} スキップ (外部 composer 依存関係なし)', 'skipped_not_installed' => '{target} スキップ (拡張がインストールされていません)', diff --git a/lang-packs/_bundled/g7-core-ja/frontend/partial/layout_editor.json b/lang-packs/_bundled/g7-core-ja/frontend/partial/layout_editor.json index 4cb92766..b18d3985 100644 --- a/lang-packs/_bundled/g7-core-ja/frontend/partial/layout_editor.json +++ b/lang-packs/_bundled/g7-core-ja/frontend/partial/layout_editor.json @@ -2,8 +2,8 @@ "chrome": { "toolbar": { "toggle_route_tree": "ルートツリーを折りたたむ/展開", - "undo": "元に戻す", - "redo": "やり直す", + "undo": "取り消し", + "redo": "やり直し", "template_route_separator": " ▸ ", "locale_switcher_label": "コンテンツ言語", "add_element": "要素を追加", @@ -15,8 +15,11 @@ "zoom_reset": "元のサイズ", "page_settings": "ページ設定", "preview": "プレビュー", + "previewing": "読み込み中…", + "version_history": "バージョン履歴", "edit_code": "コード編集", "save": "保存", + "saving": "保存中…", "exit": "終了", "exit_base_edit": "共通レイアウト編集を終了", "exit_modal_edit": "モーダル編集を終了", @@ -28,18 +31,15 @@ "extension": "拡張編集中", "iteration_item": "繰り返し項目編集中" }, - "saving": "保存中…", "images": "画像", - "reset": "初期化", "translations": "多言語", "data_sources": "データ", - "switch_template": "別のテンプレートに切り替え", - "previewing": "開く中…", - "version_history": "バージョン履歴" + "reset": "初期化", + "switch_template": "別のテンプレートに切り替える" }, "route_tree": { "panel_title": "画面/ルート", - "search_placeholder": "名称・ルートで検索…", + "search_placeholder": "名前·ルートで検索…", "search_clear": "検索をクリア", "search_no_results": "検索結果がありません", "group": { @@ -52,24 +52,24 @@ }, "connected": { "modals": "この画面のモーダル ({count})", - "extensions": "注入される拡張 ({count})" + "extensions": "インジェクトされる拡張 ({count})" }, "badge": { "hidden": "非表示", "redirect": "リダイレクト", - "shared_layout": "{count}個のルートを共有", - "modified": "変更", + "shared_layout": "{count}個のルート共有", + "modified": "変更されました", + "version_tooltip": "現在の保存バージョン v{version}", "extension_point": "拡張ポイント", "overlay": "オーバーレイ", "priority": "p:{priority}", - "inactive": "無効", - "version_tooltip": "現在の保存バージョン v{version}" + "inactive": "無効" }, "empty": "表示するルートがありません" }, "fab": { "label": "レイアウト編集", - "tooltip": "このスクリーンをレイアウトエディターで開く" + "tooltip": "この画面をレイアウトエディタで開く" }, "denied": { "title": "レイアウト編集権限がありません", @@ -78,8 +78,8 @@ "go_home": "ホームへ" }, "dirty_guard": { - "title": "保存していない変更があります", - "message": "変更を保存せずに終了しますか?", + "title": "保存されていない変更があります", + "message": "変更を保存せずに終了しますか?", "stay": "編集を続ける", "discard": "変更を破棄" }, @@ -88,55 +88,80 @@ }, "reset_guard": { "title": "この画面を初期化します", - "message": "この画面の変更を破棄し、前回保存した状態に戻します。" + "message": "この画面の変更を破棄し、最後の保存状態に戻します。" } }, "preview": { - "empty": "左側から編集するルートを選択してください", + "empty": "左側で編集するルートを選択してください", "empty_layout": "このレイアウトに表示するコンポーネントがありません", "loading": "レイアウトを読み込み中...", "error": "レイアウトの読み込みに失敗しました: {error}", - "assets_loading": "編集対象のテンプレートアセットを読み込み中...", - "assets_error": "編集対象のテンプレートアセットの読み込みに失敗しました: {error}", + "assets_loading": "編集対象テンプレートアセットを読み込み中...", + "assets_error": "編集対象テンプレートアセットの読み込みに失敗しました: {error}", "sample_data_badge": "サンプルデータ", - "extension_no_injection_title": "この画面には編集可能な要素がありません", - "extension_no_injection_desc": "この拡張はこの画面にビジュアル編集可能な要素を追加しません。コード編集を使用してください。", + "extension_no_injection_title": "この画面に編集対象の要素がありません", + "extension_no_injection_desc": "この拡張はこの画面にビジュアル編集できる要素を追加しません。コード編集をご利用ください。", "extension_gated_title": "この状態では編集可能な要素が表示されていません", - "extension_gated_desc": "この拡張が配置される領域は特定の画面状態でのみ表示されます。その状態が編集設定で定義されていないため、ビジュアル編集できません。コード編集を使用してください。", - "extension_gated_desc_with_states": "この拡張が配置される領域は特定の画面状態でのみ表示されます。上部の状態選択でその状態に変更すると編集できます。", - "slot_area_label": "コンテンツエリア" + "extension_gated_desc": "この拡張が挿入される領域は特定の画面状態でのみ表示されます。その状態が編集設定に定義されていないため、ビジュアル編集できません。コード編集をご利用ください。", + "extension_gated_desc_with_states": "この拡張が挿入される領域は特定の画面状態でのみ表示されます。上部の状態選択からその状態に変更すれば編集できます。", + "slot_area_label": "コンテンツ領域" + }, + "version_history": { + "title": "バージョン履歴", + "close": "閉じる", + "loading": "バージョン一覧を読み込み中…", + "empty": "保存されたバージョンがありません。", + "restore": "復元", + "restoring": "復元中…", + "restore_confirm": "このバージョンに復元しますか?現在編集中の変更は失われます。", + "restore_not_found": "対象のバージョンが見つかりません。一覧を更新してください。", + "latest": "最新", + "added": "+{count} 行", + "removed": "-{count} 行", + "chars": "({diff} 文字)", + "saved_by": "保存者: {name}", + "unknown_author": "不明", + "compare": "比較", + "comparing": "比較中…", + "diff_title": "v{old} → v{new} 変更内容", + "diff_back": "一覧", + "diff_identical": "2つのバージョンの内容は同じです。", + "diff_too_large": "変更内容が大きすぎて表示できません。" + }, + "preview_action": { + "no_document": "プレビュー用のレイアウトが選択されていません。" }, "access_error": { "required_permission_label": "必要な権限:", "detail_summary": "詳細なエラー情報", "action": { - "sign_in": "ログインへ", + "sign_in": "ログインページへ", "go_home": "管理者ホームへ", "go_admin_home": "管理者ホームへ", "retry": "再試行" }, "unauthorized": { "title": "ログインが必要です", - "message": "セッションの有効期限が切れているか、ログアウトしています。しばらく後にログインページに移動します。" + "message": "セッションの有効期限が切れているか、ログインが解除されました。しばらく後にログインページにリダイレクトされます。" }, "forbidden": { "title": "このレイアウトにアクセスする権限がありません", - "message": "現在のアカウントにはこの画面を編集する権限がありません。管理者に権限の付与をリクエストするか、別のアカウントで再度ログインしてください。" + "message": "現在のアカウントには、この画面を編集する権限がありません。管理者に権限の付与をリクエストするか、別のアカウントで再度ログインしてください。" }, "not_found": { "title": "レイアウトが見つかりません", - "message": "リクエストされたレイアウトが存在しないか、削除されています。ルートを再度選択してください。" + "message": "リクエストされたレイアウトが存在しないか、削除されました。ルートを再度選択してください。" }, "server_error": { "title": "サーバーで問題が発生しました", "message": "レイアウトの読み込み中にサーバーエラーが発生しました。しばらく後に再度お試しください。" }, "network": { - "title": "ネットワーク接続をご確認ください", - "message": "サーバーに接続できません。ネットワークの状態を確認してから再度お試しください。" + "title": "ネットワーク接続を確認してください", + "message": "サーバーに接続できません。ネットワークの状態を確認した後、再度お試しください。" }, "unknown": { - "title": "レイアウトの読み込みに失敗しました", + "title": "レイアウトを読み込めませんでした", "message": "予期しない問題が発生しました。再度お試しいただくか、管理者にお問い合わせください。" } }, @@ -145,9 +170,9 @@ "desktop": "🖥 デスクトップ", "tablet": "📱 タブレット", "mobile": "📲 モバイル", - "custom": "📐 カスタマイズ", - "custom_width_label": "幅", - "portable": "📱 モバイル+タブレット" + "portable": "📱 モバイル+タブレット", + "custom": "📐 カスタム", + "custom_width_label": "幅" }, "zoom": { "label": "ズーム", @@ -160,19 +185,19 @@ "design": "デザイン要素", "db": "DB要素" }, - "empty": "この位置に追加できる要素がありません", - "spec_missing": "このテンプレートにはビジュアル編集仕様がありません。要素の追加は無効化されています。", "category": { "all": "すべて", "other": "その他", "fallback": { "basic": "基本要素", - "composite": "複合要素", + "composite": "組み合わせ要素", "layout": "レイアウト" } }, - "default_node_missing_badge": "基本スペックなし", - "requires_default_node_missing": "このコンポーネントはテンプレートが基本スタイルを定義してから追加できます。editor-spec.json の componentPalette.entries.<名前>.defaultNode を定義してください。", + "empty": "この位置に追加できる要素はありません", + "spec_missing": "このテンプレートは、ビジュアル編集仕様がありません。要素の追加は無効化されています。", + "default_node_missing_badge": "デフォルトスペックなし", + "requires_default_node_missing": "このコンポーネントは、テンプレートがデフォルトスタイルを定義する必要があります。editor-spec.json の componentPalette.entries.<名前>.defaultNode を定義してください。", "table": { "label": "表", "header_1": "タイトル", @@ -194,12 +219,12 @@ "edit_props": "プロパティを設定", "duplicate": "コンポーネントをコピー", "delete": "削除", - "separate_branch": "このデバイス専用で分離", + "separate_branch": "このデバイス専用に分離", "merge_branch": "デバイス専用分離を解除", - "separate_branch_device": "この領域を{device}専用で分離", + "separate_branch_device": "この領域を{device}専用に分離", "merge_branch_device": "{device}専用分離を解除", - "separate_branch_hint": "このデバイスのみで異なる構成(要素)を配置したい場合に使用します。色·余白などのスタイルのみ異なる場合は[属性 > スタイル]タブを使用してください。", - "merge_branch_hint": "このデバイス専用に分離しておいた構成を削除し、デフォルト構成に戻します。", + "separate_branch_hint": "このデバイスだけで別の構成(要素)を配置したい場合に使用します。色・余白などのスタイルのみを異なるものにしたい場合は、[プロパティ > スタイル]タブを使用してください。", + "merge_branch_hint": "このデバイス専用に分離されている構成を削除して、基本構成に戻します。", "device_desktop": "デスクトップ", "device_tablet": "タブレット", "device_mobile": "モバイル", @@ -207,9 +232,9 @@ "device_custom": "{range}" }, "overlay": { - "edit_common_layout": "共通レイアウトを編集", + "edit_common_layout": "共通レイアウト編集", "edit_extension": "拡張編集", - "edit_iteration_item": "繰り返し項目を編集", + "edit_iteration_item": "繰り返し項目編集", "edit_destination_route": "→ この画面を編集", "cannot_drop_here": "ここには配置できません", "locked_data_bound": "データ領域は直接編集できません", @@ -217,65 +242,65 @@ "branch_mobile": "モバイル構成", "branch_tablet": "タブレット構成", "branch_desktop": "デスクトップ構成", - "branch_custom": "{range} 構成", + "branch_custom": "{range}構成", "branch_boundary_denied": "デバイス別構成間での移動はできません", "jump_device": "{device}構成に移動", - "jump_device_hint": "この領域には{device}に別途構成が定義されています。クリックするとそのデバイスビューに切り替えて編集します。", - "inherit_banner": "共通レイアウトから受け取ったローディング画面設定があります。下記でこの画面に合わせて変更できます。", - "enabled_label": "ローディング画面表示", - "enabled_hint": "データを読み込む間に表示するローディング画面をオンまたはオフにします。", + "jump_device_hint": "この領域は{device}に個別の構成が定義されています。クリックするとそのデバイスビューに切り替えて編集します。", + "inherit_banner": "共通レイアウトから受け取ったロード画面設定があります。下記でこの画面に合わせて変更できます。", + "enabled_label": "ロード画面表示", + "enabled_hint": "データを読み込む間に表示するロード画面をオンまたはオフにします。", "on": "オン", "off": "オフ", - "scope_label": "覆う範囲", - "scope_full": "フルスクリーン", - "scope_region": "特定の領域", + "scope_label": "カバー範囲", + "scope_full": "全画面", + "scope_region": "特定領域", "target_label": "対象領域", "fallback_label": "代替領域", - "fallback_hint": "対象領域が見つからない場合に代わりに覆う領域です。", - "style_label": "表示方法", - "style_opaque": "単色カバー", + "fallback_hint": "対象領域が見つからない場合に代わりにカバーする領域です。", + "style_label": "表示方式", + "style_opaque": "単色マスク", "style_blur": "ぼかし", - "style_fade": "フェードイン", - "style_skeleton": "スケルトン表示", + "style_fade": "徐々に表示", + "style_skeleton": "骨組み表示", "style_spinner": "回転表示", "anim_wave": "波紋", "anim_pulse": "点滅", "anim_none": "なし", - "skeleton_component": "スケルトン形状", + "skeleton_component": "骨組み形状", "skeleton_animation": "モーション効果", "skeleton_count": "繰り返し回数", "spinner_component": "回転形状", - "spinner_text": "ガイダンステキスト", - "waitfor_label": "待つデータ", - "waitfor_hint": "選択したすべてのデータを受け取るまでローディング画面を保持します。", - "waitfor_empty": "待つことができるデータがありません。", - "reset_inherit": "すべてデフォルトに", - "loading_picker": { - "none": "選択なし", - "select": "選択", - "search": "形状検索...", - "empty": "選択可能な形状がありません。" - }, - "inherited_badge": "継承", + "spinner_text": "ガイドテキスト", + "waitfor_label": "待機するデータ", + "waitfor_hint": "選択したデータをすべて受け取るまでロード画面を維持します。", + "waitfor_empty": "待機するデータがありません。", + "reset_inherit": "すべてデフォルト値に戻す", + "inherited_badge": "継承されています", "overridden_badge": "この画面で変更", - "override_field": "この画面だけを変更" + "override_field": "この画面のみ変更", + "loading_picker": { + "none": "選択しない", + "select": "選択", + "search": "形状検索…", + "empty": "選択可能な形状がありません。" + } }, "save": { "success": "レイアウトを保存しました", "in_progress": "保存中…", "validation_failed": "バリデーションに失敗しました", - "blocked_inactive_extension": "無効化された拡張コンポーネントが新たに追加されたため、保存できません", + "blocked_inactive_extension": "無効化された拡張コンポーネントが新たに追加されているため保存できません", + "network_error_title": "保存中にネットワークエラーが発生しました", + "guard_no_document": "保存するレイアウトドキュメントが読み込まれていません", "concurrent": { "title": "別のユーザーが先に保存しました", - "message": "このレイアウトは別の管理者が先に保存しました。最新バージョンを読み込みますか?", - "load_latest": "最新を読み込む", - "view_my_changes": "私の変更内容を表示", - "cancel": "キャンセル", - "version_info": "最新バージョン:{current} / マイバージョン:{yours}" + "message": "このレイアウトは別の管理者が先に保存しました。最新版を読み込みますか?", + "version_info": "最新版: {current} / 自分の版: {yours}", + "load_latest": "最新版を読み込む", + "view_my_changes": "自分の変更内容を表示", + "cancel": "キャンセル" }, - "network_error_title": "保存中にネットワークエラーが発生しました", - "guard_no_document": "保存するレイアウトドキュメントがロードされていません", - "conflict": "別の場所で先に保存されて競合が発生しました" + "conflict": "別の場所で先に保存されて競合が発生しました。" }, "control": { "use": "使用", @@ -283,7 +308,7 @@ "color_hex_placeholder": "#000000 / なし", "tag_add": "+ 追加", "background_image": { - "upload": "画像アップロード", + "upload": "画像をアップロード", "clear": "削除", "mode_fill": "フィル", "mode_fit": "フィット", @@ -296,7 +321,7 @@ "spacing": { "mode_all": "一括", "mode_sides": "個別", - "all": "すべて", + "all": "全体", "x": "左右", "y": "上下", "top": "上", @@ -305,6 +330,99 @@ "left": "左" } }, + "recipe": { + "custom_input": "直接入力…", + "pick_from_list": "リストから選択", + "page_placeholder": "移動するパス(例:/board)", + "datasource_placeholder": "データ名", + "state_key_placeholder": "ステータスキー", + "modal_placeholder": "モーダル選択を開く", + "i18n_text_placeholder": "表示するテキスト" + }, + "action": { + "no_events": "この要素はアクションを設定できません", + "no_recipes": "このテンプレートは使用可能なアクションがありません", + "add_action": "+ アクションを追加", + "remove": "アクションを削除", + "advanced_action": "直接作成されたアクション", + "advanced_preserved": "コードエディターで作成されたアクションです(保存されます)", + "event": { + "onClick": "クリックしたら", + "onHover": "マウスをホバーしたら", + "onChange": "値が変わったら", + "onSubmit": "送信したら", + "onClose": "閉じたら", + "onFilesChange": "ファイルが変わったら", + "onPageChange": "ページが変わったら", + "onReact": "反応したら", + "onTabChange": "タブが変わったら", + "onUploadSuccess": "アップロードが成功したら", + "onUploadError": "アップロードが失敗したら", + "onButtonClick": "ボタンを押したら", + "onDismiss": "閉じたら", + "onItemAdd": "項目を追加したら", + "onItemClick": "項目を押したら", + "onItemRemove": "項目を削除したら", + "onLanguageChange": "言語を変更したら", + "onLayoutEditClick": "レイアウト編集を押したら", + "onLogoutClick": "ログアウトを押したら", + "onOrderChange": "順序を変更したら", + "onProfileClick": "プロフィールを押したら", + "onReset": "初期化したら", + "onRowClick": "行を押したら", + "onSelect": "選択したら", + "onSelectionChange": "選択が変わったら", + "onToggle": "展開または折りたたんだら", + "onComponentEvent": "特定のイベントが発生したら" + }, + "advanced": "詳細", + "add_search": "アクションを検索…", + "add_no_match": "一致するアクションがありません。", + "view_code": "コードを表示", + "source_core": "コア", + "source_module": "モジュール", + "source_plugin": "プラグイン", + "source_template": "テンプレート", + "source_base": "共通", + "source_route": "この画面", + "summary_missing_required": "必須項目が空です。", + "group": { + "state": "状態管理", + "nav": "画面遷移", + "notify": "通知・モーダル", + "data": "データ", + "locale": "言語", + "etc": "その他", + "flow": "フロー制御", + "error": "エラーハンドリング", + "extension": "拡張アクション" + } + }, + "visibility": { + "no_recipes": "このテンプレートは使用可能な表示条件がありません", + "show_when": "この要素を表示する条件", + "and": "かつ", + "or": "または", + "remove": "条件を削除", + "preview": "プレビュー", + "clear": "条件をクリア", + "advanced_preserved": "コードエディターで作成された条件です(保存されます)" + }, + "flex": { + "container_title": "フレックスボックス", + "item_title": "フレックスボックス内", + "direction": "方向", + "justify": "子要素の配置(メイン軸)", + "align": "子要素の配置(交差軸)", + "wrap": "折り返しを許可", + "gap": "子要素間隔", + "item_grow": "拡張", + "item_align": "個別配置", + "item_order": "順序", + "make_flex": "フレックスボックスにする", + "unmake_flex": "フレックスボックス解除", + "not_flex": "この要素はフレックスボックスではありません" + }, "property_modal": { "title": "属性編集: {name}", "settings_title": "{name} 設定", @@ -313,235 +431,83 @@ "delete_element": "この要素を削除", "no_editable_props": "この要素に編集可能な属性がありません", "injected_props": { - "title": "拡張が注入した属性", - "help": "基本属性はすべての画面に適用され、注入属性はこの拡張が有効なときのみ適用されます。", - "origin": "{source} が注入", + "title": "拡張注入属性", + "help": "基本属性はすべての画面に適用され、拡張注入属性はこの拡張が有効な場合にのみ適用されます。", + "origin": "{source} により注入", "save_target": "保存時: この拡張行に記録されます。", "save_button": "{source} に保存", "saving": "保存中…", - "saved": "保存しました。", - "invalid_json": "JSON 形式が正しくありません。" + "saved": "保存されました。", + "invalid_json": "JSON形式が正しくありません。" }, - "tab_deferred": "このタブは次のバージョンでサポートされます", - "advanced_deferred": "この高度な属性は次のバージョンでサポートされます", + "tab_deferred": "このタブは次のステップでサポートされています", + "advanced_deferred": "この高度な属性は次のステップでサポートされています", "unsupported_widget": "サポートされていないコントロール", - "value_conflict": "重複する値", + "value_conflict": "値の重複", "settings_group": "グループ {index}", "advanced_badge": "この要素はコードエディターで作成された高度な設定を含みます", - "advanced_preserved_title": "コード編集で作成された高度な設定 {count}個(保持)", + "branch_badge": "この要素は {device} 専用構成に属します — 他のデバイスでは異なる構成が表示されます。", + "advanced_preserved_title": "コード編集で作成された高度な設定 {count} 個 (保持)", "advanced_kind_developer": "開発者属性", "advanced_kind_expression": "複雑な式", "tab": { "settings": "設定", + "props": "属性", "style": "スタイル", "action": "動作", "visibility": "表示条件", "translation": "翻訳", - "advanced": "詳細", - "props": "属性" + "advanced": "高度" }, "advanced": { "permissions": "表示権限", - "blur_until_loaded": "データ読み込み時にオーバーレイを表示", - "blur_hint": "データ読み込み中にオーバーレイを表示", + "blur_until_loaded": "データ読込時に薄くする", + "blur_hint": "データ読込中に薄くする", "comment": "メモ" }, "scope": { "scheme_light": "ライト", "scheme_dark": "ダーク", - "device_base": "共通", + "device_base": "デフォルト", "device_desktop": "PC", "device_tablet": "タブレット", "device_mobile": "モバイル", + "device_portable": "モバイル+タブレット", "add_custom": "カスタムサイズ", "custom_min": "最小", "custom_max": "最大", - "custom_priority_note": "カスタム範囲がPC/タブレット/モバイルより優先的に適用されます。", - "custom_invalid": "無効な範囲です (最小 ≤ 最大、数字)。", - "reset_to_base": "デフォルト値にリセット", - "device_portable": "モバイル+タブレット" + "custom_priority_note": "カスタム範囲は PC/タブレット/モバイルより優先適用されます。", + "custom_invalid": "無効な範囲です (最小 ≤ 最大、数値)。", + "reset_to_base": "デフォルト値にリセット" }, "dark_code_only": "この色はダークモードではコードモードでのみ編集できます", - "dark_preset_only": "ダークモードではパネルのプリセット色を選ぶと適用されます(直接入力した自由値はライト専用)", - "branch_badge": "この要素は{device}専用構成に属します — 他のデバイスでは異なる構成が表示されます。" + "dark_preset_only": "ダークモードではパネルのプリセット色を選択すると適用されます (直接入力した自由値はライト専用)" }, - "attachment_manager": { - "title": "このレイアウトの画像を管理", - "recent": "最近アップロードされた", - "manage_link": "画像管理", - "upload": "画像アップロード", - "use_as_bg": "背景", - "delete": "削除", - "delete_confirm": "この画像を削除しますか? 取り消すことはできません。", - "empty": "このレイアウトにアップロードされた画像がありません。", - "loading": "読み込み中…", - "close": "閉じる" - }, - "recipe": { - "custom_input": "直接入力…", - "pick_from_list": "リストから選択", - "page_placeholder": "移動するパス (例: /board)", - "datasource_placeholder": "データ名", - "state_key_placeholder": "ステータスキー", - "i18n_text_placeholder": "表示するテキスト", - "modal_placeholder": "列モーダルを選択" - }, - "action": { - "no_events": "このエレメントはアクションを設定できません", - "no_recipes": "このテンプレートは使用可能なアクションがありません", - "add_action": "+ アクションを追加", - "remove": "アクションを削除", - "advanced_action": "直接作成されたアクション", - "advanced_preserved": "コードエディタで作成されたアクションです (保持されます)", - "event": { - "onClick": "クリックしたとき", - "onHover": "マウスをホバーしたとき", - "onChange": "値が変更されたとき", - "onSubmit": "送信したとき", - "onComponentEvent": "特定のイベントが発生したとき", - "onClose": "閉じたら", - "onFilesChange": "ファイルが変わったら", - "onPageChange": "ページが変わったら", - "onReact": "反応したら", - "onTabChange": "タブが変わったら", - "onUploadSuccess": "アップロードに成功したら", - "onUploadError": "アップロードに失敗したら", - "onButtonClick": "ボタンをクリックすると", - "onDismiss": "閉じると", - "onItemAdd": "項目を追加すると", - "onItemClick": "項目をクリックすると", - "onItemRemove": "項目を削除すると", - "onLanguageChange": "言語を変更すると", - "onLayoutEditClick": "レイアウト編集をクリックすると", - "onLogoutClick": "ログアウトをクリックすると", - "onOrderChange": "順序を変更すると", - "onProfileClick": "プロフィールをクリックすると", - "onReset": "初期化すると", - "onRowClick": "行をクリックすると", - "onSelect": "選択すると", - "onSelectionChange": "選択が変更されると", - "onToggle": "展開または折りたたむと" + "core_props": { + "id": { + "label": "要素 ID", + "placeholder": "例: main-content", + "add_data": "データ", + "binding_hint": "反復リストのインデックスまたは行キーをデータとして接続すると、各項目ごとに一意の ID が作成されます (例: item_{{$idx}})" }, - "advanced": "詳細設定", - "add_search": "アクション検索...", - "add_no_match": "一致するアクションがありません。", - "view_code": "コード表示", - "source_core": "コア", - "source_module": "モジュール", - "source_plugin": "プラグイン", - "source_template": "テンプレート", - "source_base": "共通", - "source_route": "この画面", - "summary_missing_required": "必ず入力する値が空です。", - "group": { - "state": "ステート管理", - "nav": "画面遷移", - "notify": "通知·モーダル", - "data": "データ", - "locale": "言語", - "etc": "その他", - "flow": "フロー制御", - "error": "エラー処理", - "extension": "拡張アクション" - } - }, - "visibility": { - "no_recipes": "このテンプレートは使用可能な表示条件がありません", - "show_when": "このエレメントを表示する条件", - "and": "かつ", - "or": "または", - "remove": "条件を削除", - "preview": "プレビュー", - "clear": "条件をクリア", - "advanced_preserved": "コードエディタで作成された条件です (保持されます)" - }, - "flex": { - "container_title": "フレックスボックス", - "item_title": "フレックスボックス内で", - "direction": "方向", - "justify": "子の配置(メイン軸)", - "align": "子の配置(交差軸)", - "wrap": "折り返しを許可", - "gap": "子要素間隔", - "item_grow": "拡張", - "item_align": "個別配置", - "item_order": "順序", - "make_flex": "フレックスボックスにする", - "not_flex": "このエレメントはフレックスボックスではありません", - "unmake_flex": "配列ボックスを解除" - }, - "toolbar": { - "color_scheme_group": "色テーマ", - "color_scheme_light": "☀ ライト", - "color_scheme_dark": "🌙 ダーク", - "state": "状態:" - }, - "locale": { - "ko": "한국어", - "en": "English", - "ja": "日本語", - "zh": "中文", - "vi": "Tiếng Việt" - }, - "inline_edit": { - "aria_label": "テキストインライン編集", - "toolbar_label": "テキスト書式", - "new_key_hint": "確定時に多言語キーが生成されます", - "existing_key_hint": "多言語 — 現在の言語値のみ修正されます", - "open_translations": "すべての言語を編集", - "empty_warning": "コンテンツを入力してください", - "format": { - "fontWeight": "太字", - "fontBold": "太字", - "fontStyle": "イタリック", - "fontItalic": "イタリック", - "textDecoration": "下線", - "textUnderline": "下線", - "textAlign": "配置", - "fontSize": "フォントサイズ", - "textColor": "フォントカラー" + "dataKey": { + "placeholder": "例: form", + "hint": "入力フィールドをまとめる名前です。_global. または _isolated. プレフィックスで グローバル/分離領域に接続できます。", + "bound_degraded": "この値はコードエディターで設定されます。", + "label": "フォームデータ接続ポイント" }, - "prompt": { - "fontSize": "フォントサイズを入力してください (例: 16px, 1.2rem)", - "textColor": "フォントカラーを入力してください (例: #2563eb)" + "isolatedState": { + "label": "分離領域", + "initial_label": "分離開始値", + "off_hint": "この領域のみの独立した状態を持ちたいときにオンにします。" + }, + "isolatedScopeId": { + "label": "領域名", + "placeholder": "例: cart-scope", + "search": "領域名検索…", + "duplicate": "同じ名前の領域が他にもあります。両領域が状態を共有します。" } }, - "translation": { - "key_label": "多言語キー", - "needs_translation": "翻訳が必要です", - "not_a_key": "この要素は多言語キーではありません。キャンバスでテキストをダブルクリックしてインライン編集するとキーが生成されます。", - "loading": "読み込み中…", - "load_failed": "多言語値を読み込めませんでした", - "save": "保存", - "saving": "保存中…", - "save_failed": "保存に失敗しました", - "conflict": "別のユーザーが先に修正しました。更新後、もう一度お試しください。", - "token_mismatch": "翻訳値のデータ連結({{ }})を変更または削除することはできません。データ連結は[プロパティ]タブで編集してください。", - "placeholder_mismatch": "翻訳値のデータプレースホルダー({p0} など)を削除または変更することはできません。文は自由に翻訳してください。ただしプレースホルダーはそのままにしてください。", - "placeholder_locked": "データチップは文字のように削除できません。位置はドラッグで移動し、解除は[プロパティ]タブで行ってください。", - "saved": "保存済み" - }, - "translation_manager": { - "title": "このレイアウトの多言語キー管理", - "filter_label": "ステータスフィルター", - "filter_all": "すべて", - "filter_active": "使用中", - "filter_orphaned": "未使用", - "badge_orphaned": "未使用/接続切断", - "badge_live_in_use": "現在のキャンバス使用中", - "badge_live_unused": "現在のキャンバス未使用", - "select_aria": "{key}を選択", - "delete_selected": "選択を削除 ({count})", - "purge_orphaned": "未使用をすべて削除 ({count})", - "delete_confirm": "選択した多言語キー{count}個を削除してもよろしいですか?元に戻すことはできません。", - "conflict": "別の場所で先に編集されています。更新後、もう一度お試しください。", - "save": "保存", - "saving": "保存中…", - "delete": "削除", - "empty": "このレイアウトに作成された多言語キーはありません。", - "loading": "読み込み中…", - "close": "閉じる" - }, "icon_picker": { "search_placeholder": "アイコン検索", "none": "なし", @@ -558,68 +524,63 @@ "edit_translation": "翻訳編集", "option_value": "値", "option_label": "ラベル", - "bound_degraded": "データに連結された一覧です(コード編集)", + "bound_degraded": "データに連結されたリストです(コード編集)", "items_title": "項目", "item_text": "項目テキスト", - "new_item": "新規項目", - "add_child": "{component} を追加", - "no_child_component": "追加する子コンポーネントが指定されていません", "item_label": "ラベル", - "item_placeholder": "ガイダンステキスト" + "item_placeholder": "ガイドメッセージ", + "new_item": "新しい項目", + "add_child": "{component}を追加", + "no_child_component": "追加する子コンポーネントが指定されていません" + }, + "array_editor": { + "items_title": "項目リスト", + "item": "項目", + "add": "{item}を追加", + "remove": "削除", + "move_up": "上に移動", + "move_down": "下に移動", + "empty": "項目がありません", + "icon_placeholder": "アイコン名(例:user、cog)", + "bound_degraded": "データに連結されたリストです(コード編集)", + "no_array_prop": "編集する配列プロパティが指定されていません", + "number_list_placeholder": "カンマ区切りの数値(例:10、20、30)" + }, + "cell_tree_editor": { + "columns_title": "列リスト", + "id_placeholder": "列識別子", + "expand_cells": "セル内容を編集", + "collapse_cells": "セル内容を閉じる", + "add_column": "{item}を追加" }, "table_editor": { - "add_row": "行追加", - "add_col": "列追加", - "remove_row": "行削除", - "remove_col": "列削除", - "merge_cells": "セル結合", - "unmerge_cells": "結合解除", + "title": "表を編集", + "add_row": "行を追加", + "add_col": "列を追加", + "remove_row": "行を削除", + "remove_col": "列を削除", + "merge_cells": "セルを統合", + "unmerge_cells": "統合を解除", "cell_border": "セル枠線", - "move_row_up": "行上へ移動", - "move_row_down": "行下へ移動", - "header_row": "ヘッダー行", - "title": "テーブル編集", - "cell_class_placeholder": "例: border border-gray-300", + "cell_class_placeholder": "例:border border-gray-300", + "move_row_up": "行を上に移動", + "move_row_down": "行を下に移動", "move_col_left": "列を左に移動", "move_col_right": "列を右に移動", + "move_blocked_section": "表ヘッダー/本文の境界を超えて移動することはできません", + "header_row": "ヘッダー行", "cell_text": "セルテキスト", - "structural_cell": "構造セル (キャンバスで編集)", - "grid_invalid": "テーブル構造が一致していません (セル結合/穴を確認)", - "select_hint": "セルをクリック=選択、Shift+クリック=領域を選択してから結合", - "move_blocked_section": "表のヘッダー/本文の境界を超えて移動することはできません", + "cell_i18n_label": "選択セル多言語", + "structural_cell": "構造セル(キャンバスで編集)", + "grid_invalid": "表の構造が一致しません(セル統合/穴を確認してください)", + "select_hint": "セルクリック=選択、Shift+クリック=範囲選択後に統合", "cell_fill": "セル背景色", - "cell_fill_none": "なし(透明)", + "cell_fill_none": "なし(透明)", "cell_fill_custom": "直接色を選択", - "cell_padding": "セル内部の余白", + "cell_padding": "セル内部余白", "cell_padding_custom": "直接", - "cell_color": "セルカラー", - "dark_free_color_hint": "ダークモードはプリセット色のみ指定できます (自由色はライトのみ対応)", - "cell_i18n_label": "選択セル多言語" - }, - "core_props": { - "id": { - "label": "要素 ID", - "placeholder": "例:main-content", - "add_data": "データ", - "binding_hint": "繰り返しリストのインデックスや行キーをデータとして連結すると、項目ごとに一意の ID が生成されます(例:item_{{$idx}})" - }, - "dataKey": { - "placeholder": "例:form", - "hint": "入力フィールドをグループ化する名前です。_global.または_isolated.接頭辞でグローバル/分離領域に接続できます。", - "bound_degraded": "この値はコードエディターで設定されます。", - "label": "フォームデータ接続ポイント" - }, - "isolatedState": { - "label": "分離領域", - "initial_label": "分離初期値", - "off_hint": "この領域のみの独立した状態を持ちたい場合はオンにします。" - }, - "isolatedScopeId": { - "label": "領域名", - "placeholder": "例:cart-scope", - "search": "領域名検索...", - "duplicate": "同じ名前の領域が他の場所にも存在します。2つの領域が状態を共有します。" - } + "cell_color": "セル色", + "dark_free_color_hint": "ダークモードはプリセット色のみ指定できます(自由色はライト専用)" }, "table_inplace": { "select_cell": "セルを選択", @@ -633,158 +594,127 @@ "move_col_right": "列を右に移動", "merge": "統合", "unmerge": "解除", - "select_all": "すべてを選択", + "select_all": "すべて選択", "border_apply_count": "{count}個のセルに適用", - "merge_right": "右のセルと統合", - "merge_down": "下のセルと統合", + "merge_right": "右側のセルと統合", + "merge_down": "下側のセルと統合", "border_apply_all": "表全体に適用", - "select_cell_hint": "キャンバス内のセルをクリックすると、統合・枠線ツールが表示されます。", + "select_cell_hint": "キャンバスでセルをクリックすると、統合・枠線ツールが表示されます。", "merge_range": "領域を統合", "range_selected": "{count}個のセルが選択されています", "custom_color": "直接選択" }, - "shortcuts": { - "title": "ショートカットキー", - "close": "閉じる", - "or": "または", - "footnote": "入力欄・モーダルにフォーカスがある場合、ショートカットキーは動作しません。", - "group": { - "clipboard": "クリップボード", - "element": "要素", - "history": "元に戻す", - "view": "表示/ツール", - "document": "ドキュメント" - }, - "action": { - "copy": "コピー", - "cut": "切り取り", - "paste": "貼り付け(別レイアウトも可能)", - "open_props": "属性パネルを開く", - "delete": "削除", - "select_parent": "親要素を選択", - "deselect": "選択を解除", - "add_element": "要素を追加", - "undo": "元に戻す", - "redo": "やり直す", - "edit_code": "コードを編集", - "preview": "プレビュー", - "translations": "多言語画面", - "help": "ショートカットキーマップ", - "reset": "初期化", - "save": "保存", - "exit": "終了" - } + "attachment_manager": { + "title": "このレイアウト画像を管理", + "recent": "最近アップロード", + "manage_link": "画像管理", + "upload": "画像をアップロード", + "use_as_bg": "背景", + "delete": "削除", + "delete_confirm": "この画像を削除しますか?元に戻すことはできません。", + "empty": "このレイアウトにアップロードされた画像がありません。", + "loading": "読み込み中…", + "close": "閉じる" }, - "array_editor": { - "items_title": "項目リスト", - "item": "項目", - "add": "{item}を追加", - "remove": "削除", - "move_up": "上に移動", - "move_down": "下に移動", - "empty": "項目がありません", - "icon_placeholder": "アイコン名(例:user、cog)", - "bound_degraded": "データにバインドされたリストです(コード編集)", - "no_array_prop": "編集する配列プロパティが指定されていません", - "number_list_placeholder": "カンマで区切られた数字 (例: 10, 20, 30)" - }, - "binding": { - "section_title": "データバインディング", - "search": "データ検索", - "search_placeholder": "名前·パスで検索…", - "no_results": "一致するデータがありません", - "not_connected": "未接続", - "clear": "解除", - "complex": "複合バインディング(コード編集)", - "required": "必須", - "item_fields": "項目フィールド", - "shape_scalar": "単一値", - "shape_array": "配列", - "shape_object": "オブジェクト", - "iteration_title": "リピートデータ接続", - "iteration_label": "リピートデータソース", - "iteration_hint": "このエリアが繰り返すための配列データです。1つの項目が内側の構造でレンダリングされ、データ数だけ繰り返されます。", - "iteration_item_var": "アイテム変数", - "iteration_index_var": "シーケンス番号変数" + "translation_manager": { + "title": "このレイアウト多言語キー管理", + "filter_label": "ステータスフィルター", + "filter_all": "すべて", + "filter_active": "使用中", + "filter_orphaned": "未使用", + "badge_orphaned": "未使用/接続切断", + "badge_live_in_use": "現在のキャンバス使用中", + "badge_live_unused": "現在のキャンバス未使用", + "select_aria": "{key}を選択", + "delete_selected": "選択削除({count})", + "purge_orphaned": "未使用全削除({count})", + "delete_confirm": "選択した多言語キー{count}個を削除しますか?元に戻すことはできません。", + "conflict": "他の場所で先に編集されました。更新してからもう一度お試しください。", + "save": "保存", + "saving": "保存中…", + "delete": "削除", + "empty": "このレイアウトに作成された多言語キーがありません。", + "loading": "読み込み中…", + "close": "閉じる" }, "data_sources": { "title": "データソース", - "description": "この画面が読み込むデータソースを追加·編集·削除します。ソースを定義するとコンポーネントのデータ接続で選択できます。", - "add": "データソースを追加", + "description": "この画面が読み込むデータソースを追加・編集・削除します。ソースを定義するとコンポーネントのデータ接続で選択できます。", + "add": "データソース追加", "edit": "編集", "remove": "削除", "close": "閉じる", "empty": "この画面に定義されたデータソースがありません。", - "no_label": "表示名称(label_key)未指定", - "inherited_title": "継承されたソース(読み取り専用)", + "no_label": "表示名称(label_key)未指定", + "inherited_title": "継承されたソース(読み取り専用)", "source": { "module": "モジュール", "plugin": "プラグイン" }, "form": { - "add_title": "データソースを追加", - "edit_title": "データソースを編集", + "add_title": "データソース追加", + "edit_title": "データソース編集", "save": "保存", "cancel": "キャンセル" }, "field": { "id": "ID", "label_key": "表示名称", - "label_key_placeholder": "例:商品一覧", - "label_key_hint": "検索型データピッカーに表示される親切な名称です。平文を入力すると多言語キーが自動生成され、🌐ボタンで言語ごとの値を編集できます。", - "type": "タイプ", + "label_key_placeholder": "例:商品リスト", + "label_key_hint": "検索型データピッカーに表示されるわかりやすい名称です。平文を入力すると多言語キーが自動生成され、🌐ボタンで言語別の値を編集できます。", + "type": "種類", "endpoint": "エンドポイント", "method": "HTTPメソッド", "auth_mode": "認証モード", - "loading_strategy": "ロード戦略", + "loading_strategy": "読み込み戦略", "auto_fetch": "画面読み込み時に自動呼び出し", - "params": "リクエストパラメータ(JSON)", + "params": "要求パラメーター", "params_hint": "値に{{query.page}}のようなバインディング式を使用できます。", - "fallback": "デフォルト値(fallback、JSON)", - "content_type": "コンテンツタイプ", - "refetch_on_mount": "再進入時に再読み込み", - "channel": "チャネル", - "event": "イベント", - "channel_type": "チャネル種別", - "target_source": "対象ソース", - "on_receive": "メッセージ受信時の動作", - "params_empty": "API に一緒に送信する値を追加してください。例:page、per_page、検索語など。", - "params_preview": "送信される値のプレビュー", + "params_empty": "APIと一緒に送信する値を追加してください。例:page、per_page、検索語など。", + "params_preview": "送信される値プレビュー", "params_mode_block": "ブロックで", "params_mode_code": "コードで", - "param_key_placeholder": "パラメータ名", - "param_add": "パラメータを追加", - "fallback_add": "デフォルト値を追加", - "fallback_add_field": "項目を追加", - "fallback_clear": "デフォルト値を削除", - "fallback_empty": "データを読み込む前・失敗時の代わりに表示する値です。例:data に空のリスト([])。", + "param_key_placeholder": "パラメーター名", + "param_add": "パラメーター追加", + "fallback": "デフォルト値", + "fallback_add": "デフォルト値追加", + "fallback_add_field": "項目追加", + "fallback_clear": "デフォルト値削除", + "fallback_empty": "データを読み込む前、失敗時の代わりに表示する値です。例:dataに空のリスト([])。", "fallback_key_placeholder": "フィールド名(例:data)", - "fallback_preview": "デフォルト値のプレビュー" + "fallback_preview": "デフォルト値プレビュー", + "content_type": "コンテンツタイプ", + "refetch_on_mount": "再進入時に再度読み込み", + "channel": "チャネル", + "event": "イベント", + "channel_type": "チャネル種類", + "target_source": "対象ソース", + "on_receive": "メッセージ受信時の動作" }, "ds_type": { "api": "API呼び出し", "static": "静的データ", - "route_params": "パスパラメータ", - "query_params": "クエリパラメータ", - "websocket": "WebSocket" + "route_params": "パスパラメーター", + "query_params": "クエリーパラメーター", + "websocket": "ウェブソケット" }, "auth_mode": { - "none": "なし(公開)", - "optional": "選択(トークンがあれば送信)", - "required": "必須(トークンがなければ失敗)" + "none": "なし(公開)", + "optional": "選択(トークンがあれば送信)", + "required": "必須(トークンがなければ失敗)" }, "loading_strategy": { "progressive": "段階的", "blocking": "ブロック", - "background": "バックグラウンド" + "background": "背景" }, "error": { "id_required": "IDを入力してください。", - "id_invalid": "IDは英文字·数字·アンダースコアのみ使用でき、数字で始まることはできません。", - "id_duplicate": "ID'{id}'は既に使用中です。", - "params_json": "リクエストパラメータが正しいJSON形式ではありません。", - "params_object": "リクエストパラメータはオブジェクト(JSON)形式である必要があります。", - "fallback_json": "デフォルト値(fallback)が正しいJSON形式ではありません。" + "id_invalid": "IDは英文字/数字/アンダースコアのみ使用でき、数字で始めることはできません。", + "id_duplicate": "ID '{id}' はすでに使用中です。", + "params_json": "リクエストパラメーターは正しいJSON形式ではありません。", + "params_object": "リクエストパラメーターはオブジェクト(JSON)形式である必要があります。", + "fallback_json": "フォールバック値が正しいJSON形式ではありません。" }, "global_headers": "グローバルヘッダー", "global_headers_empty": "設定されたグローバルヘッダーがありません。", @@ -796,355 +726,102 @@ "websocket": "リアルタイム(WebSocket)設定", "on_success": "成功時の動作", "on_error": "失敗時の動作", - "error_handling": "エラーコード別処理", + "error_handling": "エラーコード別の処理", "load_condition": "読み込み条件" }, "add_error_code": "+ エラーコードを追加" }, - "prop_i18n": { - "placeholder": "文句を入力してください", - "edit_languages": "言語ごとに編集", - "bound_code_only": "バインディング済み(コード編集)", - "create_first": "現在の言語値を先に入力すると多言語キーが生成され、言語ごとに編集できます。" + "toolbar": { + "color_scheme_group": "カラーテーマ", + "color_scheme_light": "☀ ライト", + "color_scheme_dark": "🌙 ダーク", + "state": "ステータス:" }, - "cell_tree_editor": { - "columns_title": "カラムリスト", - "id_placeholder": "カラム識別子", - "expand_cells": "セルの内容を編集", - "collapse_cells": "セルの内容を閉じる", - "add_column": "{item} を追加" + "locale": { + "ko": "日本語", + "en": "English", + "ja": "日本語", + "zh": "中文", + "vi": "Tiếng Việt" }, - "version_history": { - "title": "バージョン履歴", - "close": "閉じる", - "loading": "バージョンリストを読み込み中…", - "empty": "保存されたバージョンがありません。", - "restore": "復元", - "restoring": "復元中…", - "restore_confirm": "このバージョンに復元しますか?現在編集中の変更内容は失われます。", - "restore_not_found": "該当するバージョンが見つかりません。リストを更新してください。", - "latest": "最新", - "added": "+{count} 行", - "removed": "-{count} 行", - "chars": "({diff} 文字)", - "saved_by": "保存者: {name}", - "unknown_author": "不明", - "compare": "比較", - "comparing": "比較中…", - "diff_title": "v{old} → v{new} の変更内容", - "diff_back": "リスト", - "diff_identical": "2つのバージョンの内容は同じです。", - "diff_too_large": "変更内容が大きすぎて表示できません。" - }, - "preview_action": { - "no_document": "プレビューするレイアウトが選択されていません。" - }, - "inline_binding": { - "section_title": "テキストデータ連結", - "none": "このテキストに連結されたデータはありません", - "search": "データ検索", - "search_placeholder": "名称・パスで検索…", - "no_results": "一致するデータがありません", - "clear": "解除", - "complex": "複合バインディング(コード編集)", - "append_hint": "テキストの最後にデータを挿入", - "insert_data": "データ", - "param_hint": "このフレーズは[翻訳]タブで文を編集し(データチップの位置はドラッグで移動)、ここでは連結されたデータソースを変更または新規追加・解除できます。" - }, - "host_picker": { - "title": "代表ホストレイアウト選択", - "description": "この拡張は複数の画面に挿入されます。どの画面上で編集するかを選択してください。保存した変更は、この拡張が挿入されるすべての画面に反映されます。", - "description_with_label": "\"{label}\" 拡張は複数の画面に挿入されます。どの画面上で編集するかを選択してください。" - }, - "computed": { - "empty": "設定された自動計算値がありません。", - "add": "値追加", - "group_common": "よく使用", - "group_more": "その他", - "source_base": "共通", - "source_overridden": "この画面で覆う", - "overridden_hint": "共通レイアウトの値をこの画面で上書きしています。", - "override_notice": "共通レイアウトの値をこの画面でのみ変更します。", - "revert": "共通値に戻す", - "custom_open": "手動作成", - "custom_add": "追加", - "custom_step1": "どのデータを", - "custom_step2": "何をしましょうか", - "custom_step3": "結果名", - "custom_sum_field": "合計するフィールド (例: price)", - "custom_value_field": "値として使用するフィールド", - "custom_label_field": "表示するフィールド", - "custom_prop": "取得する属性 (選択)", - "custom_candidates": "候補値 (カンマ区切り)", - "custom_fallback": "ない場合のデフォルト値", - "cond_add": "条件を追加", - "cond_field": "フィールド", - "cond_value": "値", - "key_duplicate": "既に使用されている名前です。", - "kind_string": "文字", - "kind_number": "数字", - "kind_boolean": "はい・いいえ", - "op_count": "個数を数える", - "op_sum": "合計を求める", - "op_filter": "条件でフィルター", - "op_to_options": "選択項目に置き換える", - "op_nth": "N番目を取得", - "op_first_of": "最初にある値を使用", - "op_literal": "固定値", - "preview_label": "結果", - "preview_error": "この値は計算できません (保存されます)。", - "effect_preview": "この条件の場合の画面", - "list_more": "他に {count} 件", - "value_none": "なし", - "type_number": "数字", - "type_string": "文字", - "type_boolean": "はい・いいえ", - "type_list": "リスト", - "type_object": "グループ", - "type_null": "なし" - }, - "init_actions": { - "empty": "追加された画面アクションがありません。", - "empty_self": "この画面に追加されたアクションがありません。", - "group_base": "共通レイアウトアクション", - "group_self": "この画面アクション", - "edit": "編集", - "remove": "削除", - "locked_parent": "共通項目", - "merge_order": "共通レイアウトアクションが最初に実行され、その後この画面アクションが続きます。", - "cond_badge": "条件", - "if_label": "実行条件", - "if_placeholder": "例: {{user?.uuid}} (条件が真の場合のみ実行)", - "advanced_param_hint": "この値はコード編集器で設定します。", - "drag_reorder": "ドラッグして順序を変更", - "base_readonly_hint": "共通レイアウト動作は共通(base)レイアウトのページ設定で修正します。" - }, - "initstate": { - "legacy_state": "古い方式で保存された初期値があります。編集すると新しい方式に整理されます。", - "section_local": "この画面の初期値", - "section_global": "グローバル初期値", - "section_isolated": "分離領域の初期値", - "section_isolated_desc": "分離領域はコンポーネント別の初期値です。", - "isolated_dynamic_hint": "分離領域は画面を使用している間に作成されることもあります。", - "isolated_orphan": "この名前の分離領域を使用するコンポーネントがありません。", - "no_values": "設定された値がありません。", - "value_name": "値の名前", - "add_value": "値を追加", - "revert": "元に戻す", - "subkey_name": "下位の名前", - "add_subkey": "下位の値を追加", - "add_item": "項目を追加", - "kind_string": "文字", - "kind_number": "数字", - "kind_boolean": "はい・いいえ", - "kind_null": "なし", - "kind_list": "リスト", - "kind_object": "グループ", - "value_kind": "値の種類", - "name_required": "値の名前を入力してください。", - "name_duplicate": "同じ名前の値がすでに存在します。", - "name_invalid": "値の名前は英文字で始まり、英文字・数字・_ のみが使用できます。" - }, - "error": { - "intro": "ステータスコード別に表示する画面または実行するアクションを設定します。", - "degrade": "このテンプレートはフレンドリーな編集をサポートしていません。コード編集器で設定してください。", - "handler_none": "設定なし", - "handler_showErrorPage": "エラー画面を表示", - "handler_navigate": "別の画面に移動", - "handler_openModal": "モーダルを開く", - "handler_toast": "通知を表示", - "handler_setState": "ステータスを変更", - "handler_sequence": "複数のアクションを順序で", - "handler_parallel": "複数のアクションを同時に", - "target_content": "コンテンツ領域のみ", - "target_full": "フルスクリーン", - "setstate_key": "ステータス名", - "message_placeholder": "表示するメッセージ(🔍 でエラー情報を挿入可能)" - }, - "target_picker": { - "canvas_pick_hint": "画面上で接続する要素をクリックしてください", - "cancel": "キャンセル", - "select_area": "領域を選択", - "id_placeholder": "要素IDを入力するか、領域を選択してください", - "none": "選択なし", - "minimized_hint": "キャンバスで接続する要素をクリックしてください", - "needs_id": "接続するにはIDが必要です" - }, - "page_settings": { - "title": "{name} 設定", - "close": "閉じる", - "advanced_summary": "コードエディターで作成された高度な設定 {count}個が含まれています", - "tab": { - "meta": "基本情報", - "seo": "検索エンジン", - "init": "画面動作", - "overlay": "読み込み画面", - "computed": "自動計算", - "state": "初期状態", - "error": "エラー処理", - "data": "データ" + "inline_edit": { + "aria_label": "テキストインライン編集", + "toolbar_label": "テキスト形式", + "new_key_hint": "確定時に多言語キーが生成されます", + "existing_key_hint": "多言語 — 現在の言語値のみ編集されます", + "open_translations": "すべての言語を編集", + "empty_warning": "コンテンツを入力してください", + "format": { + "fontWeight": "太字", + "fontBold": "太字", + "fontStyle": "斜体", + "fontItalic": "斜体", + "textDecoration": "下線", + "textUnderline": "下線", + "textAlign": "配置", + "fontSize": "フォントサイズ", + "textColor": "テキスト色" }, - "banner": { - "inline_hint": "⏎ 保存 · Esc キャンセル", - "dblclick_hint": "ダブルクリックして編集", - "advanced": "詳細設定", - "open_settings": "ページ設定を開く" - }, - "meta": { - "title": "ページ名", - "description": "ページの説明", - "icon": "メニューアイコン", - "icon_placeholder": "例:fa-solid fa-house", - "editor_label": "エディターツリー名(選択)", - "editor_label_hint": "エディター左側の画面リストに表示される名前です。空欄の場合はページ名を使用します。", - "permissions": "ページアクセス権限", - "permissions_hint": "選択した権限を持つユーザーのみがこの画面を表示できます。空欄の場合は制限なしです。", - "dynamic_hint": "動的タイトル" - }, - "computed": { - "heading": "データで自動計算される値を設定します。タイトル·表示条件などで使用できます。" - }, - "init_actions": { - "heading": "画面が最初に開かれるときに順番に実行するアクションを設定します。" - }, - "initstate": { - "heading": "画面が開かれるときにあらかじめ入力する開始値を設定します。" - }, - "error": { - "heading": "データの読み込み中にエラーが発生した場合の対応を設定します。" - }, - "seo": { - "title": "検索タイトル", - "description": "検索の説明", - "enabled": "検索エンジンへの公開", - "disabled_note": "この画面は検索エンジンに公開されません。有効にすると以下の設定を使用できます。", - "page_type": "ページ種別", - "page_type_none": "選択なし", - "page_type_free_placeholder": "ページ種別を直接入力", - "page_type_free_note": "連携可能な種別がないため直接入力します。", - "page_type_free": "直接入力", - "extensions": "拡張 SEO 連携", - "add_extension": "拡張を追加", - "no_extension_candidate": "連携できる拡張がありません。", - "defaults_precondition": "デフォルト値を取得するには、以下の項目を先に入力してください。", - "missing_extensions": "連携する拡張を選択してください。", - "missing_page_type": "ページ種別を選択してください。", - "toggle_setting": "公開スイッチ設定の連携", - "toggle_setting_search": "設定を検索…", - "data_sources": "SEO 連携データ", - "data_sources_empty": "この画面に定義されたデータがありません。", - "data_sources_hint": "検索エンジン変数・構造化データが参照するデータを選択してください。", - "priority": "優先度(sitemap)", - "changefreq": "更新頻度", - "use_default": "デフォルト値を使用", - "value_placeholder": "値を入力(データ接続可能)", - "og_section": "Open Graph(ソーシャル共有)", - "og_type": "タイプ(og:type)", - "og_title": "タイトル", - "og_description": "説明", - "og_image": "画像", - "og_image_alt": "画像の代替テキスト", - "og_site_name": "サイト名", - "og_locale": "言語", - "og_extra": "その他の属性", - "og_locked": "フィルターで固定", - "og_source_core": "コア設定", - "og_source_filter": "フィルターを適用", - "og_source_module": "拡張提供", - "tw_section": "Twitter カード", - "tw_fallback_note": "空欄の場合は Open Graph の値をそのまま使用します。", - "tw_card": "カード種別", - "tw_site": "サイトアカウント", - "tw_creator": "作成者アカウント", - "tw_title": "タイトル", - "tw_description": "説明", - "tw_image": "画像", - "tw_image_alt": "画像の代替テキスト", - "tw_extra": "その他の属性", - "extra_property": "属性(property)", - "extra_name": "名前 (name)", - "extra_content": "値", - "add_extra": "属性を追加", - "remove": "削除", - "structured_data": "構造化データ", - "sd_mode": "構造化データを直接指定", - "sd_manual_toggle": "直接指定", - "sd_filter_locked": "フィルターで固定されています", - "sd_filter_locked_note": "フィルターが構造化データを代わりに入力しているため、ここでは編集できません。", - "sd_auto_title": "自動生成 (モジュール提供)", - "sd_auto_empty": "自動生成された構造化データがありません。", - "sd_replace_pre_warn": "直接指定を有効にすると、自動生成の内容はすべて削除され、以下の内容に置き換わります。", - "sd_replace_warn": "この内容は自動生成された構造化データをすべて置き換えます。", - "sd_type": "@type (構造タイプ)", - "sd_prop_key": "属性パス (例: offers.price)", - "sd_prop_value": "値", - "sd_add_prop": "属性を追加", - "sd_seed_from_module": "モジュール自動値を読み込む", - "sd_empty_note": "値が空の属性は保存時に除外されます。", - "sd_has_empty": "空の値があります。", - "vars": "動的変数", - "vars_precondition": "連携拡張とページタイプをまず選択すると、使用できる変数が表示されます。", - "vars_auto_group": "自動入力 (読み取り専用)", - "vars_auto": "自動", - "vars_data_group": "値を入力", - "vars_custom_group": "直接追加", - "vars_required_empty": "必須変数です。値を入力してください。", - "vars_add": "変数を追加", - "vars_add_name": "変数名", - "vars_value_hint": "変数はタイトル·説明·構造化データで {中括弧名} として使用できます。", - "vars_name_reserved": "{name} は自動入力変数と名前が重複しています。", - "vars_name_duplicate": "{name} は既に追加済みの変数です。", - "vars_name_not_provided": "{name} はどの拡張も提供していない変数です。", - "bot_preview_title": "検索ボット プレビュー", - "bot_preview_refresh": "更新", - "bot_preview_disabled": "この画面は検索エンジンに表示されません。", - "bot_preview_mode_code": "コード", - "bot_preview_mode_render": "レンダー", - "bot_preview_empty": "表示するコンテンツがありません。", - "bot_preview_sample_note": "サンプルデータと保存前の設定に基づいて表示されます。", - "revert_default": "やり直す", - "og_image_adv": "詳細画像オプション(高さ·形式·セキュアURL)", - "og_image_height": "画像の高さ(px)", - "og_image_type": "画像形式(例:image/png)", - "og_image_secure_url": "画像セキュアURL(https)", - "og_intro": "空白はコア·拡張が定めたデフォルト値を使用します(ソース表示)。入力するとこの画面の値で上書きされます", - "og_source_inherited": "共通レイアウト", - "auto_chip_source": "拡張提供", - "auto_chip_replace": "別のデータに変更" + "prompt": { + "fontSize": "フォントサイズを入力してください(例:16px, 1.2rem)", + "textColor": "テキスト色を入力してください(例:#2563eb)" } }, + "translation": { + "key_label": "多言語キー", + "needs_translation": "翻訳が必要です", + "not_a_key": "この要素は多言語キーではありません。キャンバスでテキストをダブルクリックしてインライン編集するとキーが生成されます。", + "loading": "読み込み中…", + "load_failed": "多言語値を読み込めませんでした", + "save": "保存", + "saving": "保存中…", + "saved": "保存されました", + "save_failed": "保存に失敗しました", + "conflict": "別のユーザーが先に編集しました。再読み込み後に再度お試しください。", + "token_mismatch": "翻訳値のデータ連携({{ }})は変更または削除できません。データ連携は[属性]タブで編集してください。", + "placeholder_mismatch": "翻訳値のデータプレースホルダー({p0}など)は削除または変更できません。文は自由に翻訳してくださいが、プレースホルダーはそのままにしてください。", + "placeholder_locked": "データチップはテキストのように削除できません。位置はドラッグで移動し、接続解除は[属性]タブで実行してください。" + }, + "prop_i18n": { + "placeholder": "フレーズを入力してください", + "edit_languages": "言語別編集", + "bound_code_only": "バインドされました(コード編集)", + "create_first": "現在の言語値を最初に入力すると多言語キーが生成され、言語別に編集できるようになります。" + }, "value_tree": { "show_source": "元の式を表示", "raw_code_only": "コードで編集", - "segmented_hint": "複数の断片がつながった値です。断片を追加·削除するか、ハンドルで順序を変更してください", + "segmented_hint": "複数の断片がつながった値です。断片を追加・削除したり、ハンドルで順序を変更してください。", "drag_reorder": "ドラッグして順序を変更", - "change_data": "データを置き換え", + "change_data": "データを置き換える", "edit": "編集", + "done": "完了", "collapse": "折りたたむ", - "to_expression": "式で置き換え", - "to_plain": "通常の名前で", - "to_plain_lead": "式を削除し、以下の値だけを残します:", - "to_plain_warn": "その他の条件·分岐·データ接続はすべて削除され、取り消すことができません", + "to_expression": "式に置き換える", + "to_plain": "通常の名前に", + "to_plain_lead": "式を削除し、下の値のみを保持します:", + "to_plain_warn": "他の条件・分岐・データ連携はすべて削除され、元に戻すことはできません。", "to_plain_ok": "この値に戻す", "to_plain_cancel": "キャンセル", + "raw_expression_hint": "すでに式として記述されているため、フレンドリー編集で展開できません。式自体は上から直接編集できます。より複雑な編集は上部の[コード編集]を使用してください。", "segment": { "text": "固定テキスト", - "expression": "条件に応じて異なる部分", - "fallback": "値がない場合は代わりに", + "expression": "条件によって異なる部分", + "fallback": "値がない場合の代わり", "data": "データ", "remove": "この断片を削除", - "data_pick_hint": "表示するデータを検索して選択してください", - "data_no_candidates": "この画面で接続できるデータがありません" + "data_pick_hint": "表示するデータを検索して選択してください。", + "data_no_candidates": "この画面で接続できるデータがありません。" }, "cond": { "label": "条件", "code_only": "コードで編集", - "field_placeholder": "基準値(例:編集モード)", + "field_placeholder": "基準値(例: 編集モード)", "value_placeholder": "比較する値", - "when_true": "この条件の場合", + "when_true": "この条件のとき", "when_false": "その他の場合", - "op_truthy": "値がある場合", - "op_falsy": "値がない場合", + "op_truthy": "値があるとき", + "op_falsy": "値がないとき", "op_eq": "と等しい", "op_neq": "と異なる", "op_gt": "より大きい", @@ -1154,31 +831,370 @@ }, "fallback": { "primary": "デフォルト値", - "when_empty": "値がない場合は代わりに", - "when_falsy": "空の場合は代わりに" + "when_empty": "値がないときに代わりに", + "when_falsy": "空のときに代わりに" }, "concat": { - "hint": "複数の値を順番に連結します", - "part": "{n}番目の断片" + "hint": "複数の値を順に繋ぎます。", + "part": "{n}番目のピース" + } + }, + "shortcuts": { + "title": "ショートカット", + "close": "閉じる", + "or": "または", + "footnote": "入力欄・モーダルにフォーカスがあるときはショートカットが動作しません。", + "group": { + "clipboard": "クリップボード", + "element": "要素", + "history": "元に戻す", + "view": "表示/ツール", + "document": "ドキュメント" }, - "done": "完了", - "raw_expression_hint": "すでに式として書き込まれた値であるため、フレンドリーな編集では展開して表示することができません。式自体は上記で直接編集でき、より複雑な編集は上部の [コード編集] を使用してください。" + "action": { + "copy": "コピー", + "cut": "切り取り", + "paste": "貼り付け(別のレイアウトが可能)", + "open_props": "属性パネルを開く", + "delete": "削除", + "select_parent": "親要素を選択", + "deselect": "選択を解除", + "add_element": "要素を追加", + "undo": "元に戻す", + "redo": "やり直す", + "edit_code": "コードを編集", + "preview": "プレビュー", + "translations": "多言語画面", + "help": "ショートカットマップ", + "reset": "初期化", + "save": "保存", + "exit": "終了" + } + }, + "binding": { + "section_title": "データ連携", + "search": "データ検索", + "search_placeholder": "名称·パスで検索…", + "no_results": "一致するデータがありません", + "not_connected": "接続していません", + "clear": "解除", + "complex": "複合バインディング(コード編集)", + "required": "必須", + "item_fields": "項目フィールド", + "shape_scalar": "単一値", + "shape_array": "配列", + "shape_object": "オブジェクト", + "iteration_title": "反復データ連携", + "iteration_label": "反復データソース", + "iteration_hint": "この領域が反復する配列データです。1つの項目が内側の構造としてレンダーされ、データ数だけ反復されます。", + "iteration_item_var": "項目変数", + "iteration_index_var": "序番変数" + }, + "inline_binding": { + "section_title": "テキストデータ連携", + "none": "このテキストに接続されたデータがありません", + "search": "データ検索", + "search_placeholder": "名称·パスで検索…", + "no_results": "一致するデータがありません", + "clear": "解除", + "complex": "複合バインディング(コード編集)", + "append_hint": "テキストの最後にデータを挿入", + "insert_data": "データ", + "param_hint": "この文句は[翻訳]タブで文を編集し(データチップの位置はドラッグで移動)、ここでは接続されたデータソースを変更するか新規に追加/解除することができます。" + }, + "host_picker": { + "title": "代表ホストレイアウトを選択", + "description": "この拡張は複数の画面に注入されます。どの画面上で編集するかを選択してください。保存した変更はこの拡張が注入されるすべての画面に反映されます。", + "description_with_label": "\"{label}\"拡張は複数の画面に注入されます。どの画面上で編集するかを選択してください。" + }, + "computed": { + "empty": "設定された自動計算値がありません。", + "add": "値を追加", + "group_common": "よく使う", + "group_more": "その他", + "source_base": "共通", + "source_overridden": "この画面で上書き", + "overridden_hint": "共通レイアウトの値をこの画面で上書きしています。", + "override_notice": "共通レイアウトの値をこの画面だけで変更します。", + "revert": "共通値に戻す", + "custom_open": "直接作成", + "custom_add": "追加", + "custom_step1": "どのデータを", + "custom_step2": "何をしますか", + "custom_step3": "結果名", + "custom_sum_field": "合計するフィールド (例: price)", + "custom_value_field": "値として使用するフィールド", + "custom_label_field": "表示するフィールド", + "custom_prop": "取得する属性 (オプション)", + "custom_candidates": "候補値 (カンマで区切る)", + "custom_fallback": "ない場合のデフォルト値", + "cond_add": "条件を追加", + "cond_field": "フィールド", + "cond_value": "値", + "key_duplicate": "既に使用されている名前です。", + "kind_string": "文字", + "kind_number": "数字", + "kind_boolean": "はい·いいえ", + "op_count": "個数をカウント", + "op_sum": "合計を計算", + "op_filter": "条件でフィルタリング", + "op_to_options": "選択項目に置き換え", + "op_nth": "N番目を取得", + "op_first_of": "最初にある値を使用", + "op_literal": "固定値", + "preview_label": "結果", + "preview_error": "この値は計算できません (保存されます)。", + "effect_preview": "この条件のときの画面", + "list_more": "ほか {count}個", + "value_none": "なし", + "type_number": "数字", + "type_string": "文字", + "type_boolean": "はい·いいえ", + "type_list": "リスト", + "type_object": "グループ", + "type_null": "なし" + }, + "init_actions": { + "empty": "追加された画面アクションがありません。", + "empty_self": "この画面に追加されたアクションがありません。", + "group_base": "共通レイアウトアクション", + "group_self": "この画面のアクション", + "edit": "編集", + "remove": "削除", + "drag_reorder": "ドラッグして順序を変更", + "locked_parent": "共通項目", + "base_readonly_hint": "共通レイアウトアクションは共通 (base) レイアウトのページ設定で編集します。", + "merge_order": "共通レイアウトアクションが最初に実行され、その後この画面アクションが続きます。", + "cond_badge": "条件", + "if_label": "実行条件", + "if_placeholder": "例: {{user?.uuid}} (条件が真の場合のみ実行)", + "advanced_param_hint": "この値はコードエディターで設定します。" + }, + "action_param": { + "key_placeholder": "名前", + "value_placeholder": "値 (データ接続·式が可能)", + "add_pair": "値を追加", + "number_placeholder": "数字またはデータ接続" + }, + "initstate": { + "legacy_state": "古い方法で保存された初期値があります。編集すると新しい方法で整理されます。", + "section_local": "この画面の初期値", + "section_global": "グローバル初期値", + "section_isolated": "隔離エリアの初期値", + "section_isolated_desc": "コンポーネント別隔離エリアの初期値です。", + "isolated_dynamic_hint": "隔離エリアは画面を使用する際に作成される場合があります。", + "isolated_orphan": "この名前の隔離エリアを使用するコンポーネントがありません。", + "no_values": "設定されている値がありません。", + "value_name": "値の名前", + "value_kind": "値の種類", + "add_value": "値を追加", + "revert": "元に戻す", + "subkey_name": "サブ名", + "add_subkey": "サブ値を追加", + "add_item": "項目を追加", + "kind_string": "文字", + "kind_number": "数字", + "kind_boolean": "はい·いいえ", + "kind_null": "なし", + "kind_list": "リスト", + "kind_object": "グループ", + "name_required": "値の名前を入力してください。", + "name_duplicate": "同じ名前の値が既に存在します。", + "name_invalid": "値の名前は英文字で始まり、英文字・数字・_ のみ使用できます。" + }, + "action_chip": { + "response_data": "応答データ", + "response_message": "応答メッセージ", + "error_status": "エラーステータスコード", + "error_message": "エラーメッセージ", + "error_errors": "検証エラー一覧", + "error_data": "エラー応答データ", + "error_status_text": "エラーステータステキスト", + "payload_data": "受信データ" + }, + "error": { + "intro": "ステータスコード別に表示する画面または実行するアクションを設定します。", + "degrade": "このテンプレートはビジュアル編集に対応していません。コードエディターで設定してください。", + "message_placeholder": "表示するメッセージ(🔍 でエラー情報を挿入可能)", + "handler_none": "設定しない", + "handler_showErrorPage": "エラー画面を表示", + "handler_navigate": "別の画面に移動", + "handler_openModal": "モーダルを開く", + "handler_toast": "通知を表示", + "handler_setState": "ステータスを変更", + "handler_sequence": "複数のアクションを順序立てて", + "handler_parallel": "複数のアクションを同時に", + "target_content": "コンテンツエリアのみ", + "target_full": "全画面", + "setstate_key": "ステータス名" + }, + "target_picker": { + "canvas_pick_hint": "画面で接続する要素をクリックしてください", + "cancel": "キャンセル", + "select_area": "エリアを選択", + "id_placeholder": "要素IDを入力するか、エリアを選択してください", + "none": "選択しない", + "minimized_hint": "キャンバスで接続する要素をクリックしてください", + "needs_id": "接続にはIDが必要です" + }, + "page_settings": { + "title": "{name}設定", + "close": "閉じる", + "advanced_summary": "コードエディターで作成された高度な設定{count}個が含まれています", + "tab": { + "meta": "基本情報", + "seo": "検索エンジン", + "init": "画面アクション", + "overlay": "ローディング画面", + "computed": "自動計算", + "state": "初期状態", + "error": "エラー処理", + "data": "データ" + }, + "banner": { + "inline_hint": "⏎ 保存 · Esc キャンセル", + "dblclick_hint": "ダブルクリックして編集", + "advanced": "高度な設定", + "open_settings": "ページ設定を開く" + }, + "meta": { + "title": "ページ名", + "description": "ページの説明", + "icon": "メニューアイコン", + "icon_placeholder": "例: fa-solid fa-house", + "editor_label": "エディターツリー名(オプション)", + "editor_label_hint": "エディター左側の画面一覧に表示される名前です。空白の場合はページ名が使用されます。", + "permissions": "ページアクセス権限", + "permissions_hint": "選択した権限を持つユーザーのみこの画面を表示できます。空白の場合は制限がありません。", + "dynamic_hint": "動的タイトル" + }, + "computed": { + "heading": "データで自動計算される値を設定します。タイトル・表示条件などで使用できます。" + }, + "init_actions": { + "heading": "画面が最初に開かれるときに順序立てて実行するアクションを設定します。" + }, + "initstate": { + "heading": "画面が開かれるときに事前に埋め込む初期値を設定します。" + }, + "error": { + "heading": "データの読み込み中にエラーが発生した場合の対処方法を設定します。" + }, + "seo": { + "title": "検索タイトル", + "description": "検索説明", + "enabled": "検索エンジンへの露出", + "disabled_note": "この画面は検索エンジンに露出されません。有効にすると以下の設定が使用できます。", + "page_type": "ページタイプ", + "page_type_none": "選択しない", + "page_type_free_placeholder": "ページタイプを直接入力", + "page_type_free_note": "連携可能なタイプがないため、直接入力します。", + "page_type_free": "直接入力", + "extensions": "拡張SEO連携", + "add_extension": "拡張を追加", + "no_extension_candidate": "連携できる拡張がありません。", + "defaults_precondition": "デフォルト値を取得するには、以下の項目を先に入力してください。", + "missing_extensions": "連携する拡張を選択してください。", + "missing_page_type": "ページタイプを選択してください。", + "toggle_setting": "露出スイッチ設定連携", + "toggle_setting_search": "設定を検索…", + "data_sources": "SEO連携データ", + "data_sources_hint": "検索エンジン変数・構造化データが参照するデータを選択してください。", + "data_sources_empty": "この画面に定義されたデータがありません。", + "priority": "優先度(sitemap)", + "changefreq": "更新周期", + "use_default": "デフォルト値を使用", + "revert_default": "元に戻す", + "value_placeholder": "値を入力 (データ接続可能)", + "og_section": "Open Graph (ソーシャル共有)", + "og_type": "タイプ (og:type)", + "og_title": "タイトル", + "og_description": "説明", + "og_image": "画像", + "og_image_alt": "画像の代替テキスト", + "og_image_adv": "高度な画像オプション (高さ·形式·セキュアURL)", + "og_image_height": "画像の高さ (px)", + "og_image_type": "画像形式 (例: image/png)", + "og_image_secure_url": "画像セキュアURL (https)", + "og_site_name": "サイト名", + "og_locale": "言語", + "og_extra": "その他の属性", + "og_locked": "フィルターで固定", + "og_intro": "空白の場合、コア·拡張機能が定めたデフォルト値を使用します (出典を表示)。入力した場合、この画面の値で上書きされます。", + "og_source_core": "コア設定", + "og_source_filter": "フィルター適用", + "og_source_inherited": "共通レイアウト", + "og_source_module": "拡張機能提供", + "auto_chip_source": "拡張機能提供", + "auto_chip_replace": "別のデータに変更", + "tw_section": "Twitter カード", + "tw_fallback_note": "空白の場合、Open Graph の値がそのまま使用されます。", + "tw_card": "カードタイプ", + "tw_site": "サイトアカウント", + "tw_creator": "作成者アカウント", + "tw_title": "タイトル", + "tw_description": "説明", + "tw_image": "画像", + "tw_image_alt": "画像の代替テキスト", + "tw_extra": "その他の属性", + "extra_property": "属性 (property)", + "extra_name": "名前 (name)", + "extra_content": "値", + "add_extra": "属性を追加", + "remove": "削除", + "structured_data": "構造化データ", + "sd_mode": "構造化データを直接指定", + "sd_manual_toggle": "直接指定", + "sd_filter_locked": "フィルターで固定", + "sd_filter_locked_note": "フィルターが構造化データを代わりに入力しているため、ここでは編集できません。", + "sd_auto_title": "自動生成 (モジュール提供)", + "sd_auto_empty": "自動生成された構造化データがありません。", + "sd_replace_pre_warn": "直接指定を有効にすると、自動生成されたコンテンツはすべて削除され、以下のコンテンツに置き換わります。", + "sd_replace_warn": "このコンテンツが自動生成構造化データをすべて置き換えます。", + "sd_type": "@type (構造タイプ)", + "sd_prop_key": "属性パス (例: offers.price)", + "sd_prop_value": "値", + "sd_add_prop": "属性を追加", + "sd_seed_from_module": "モジュール自動値を読み込む", + "sd_empty_note": "値が空の属性は保存時に除外されます。", + "sd_has_empty": "空の値があります。", + "vars": "動的変数", + "vars_precondition": "連携拡張機能とページタイプを先に選択すると、使用可能な変数が表示されます。", + "vars_auto_group": "自動入力 (読み取り専用)", + "vars_auto": "自動", + "vars_data_group": "値を入力", + "vars_custom_group": "直接追加", + "vars_required_empty": "必須変数です。値を入力してください。", + "vars_add": "変数を追加", + "vars_add_name": "変数名", + "vars_value_hint": "変数はタイトル·説明·構造化データで {中括弧で囲まれた名前} として使用できます。", + "vars_name_reserved": "{name} は自動入力変数と名前が重複しています。", + "vars_name_duplicate": "{name} は既に追加済みの変数です。", + "vars_name_not_provided": "{name} はどの拡張機能も提供していない変数です。", + "bot_preview_title": "検索ボット プレビュー", + "bot_preview_refresh": "更新", + "bot_preview_disabled": "この画面は検索エンジンに表示されません。", + "bot_preview_mode_code": "コード", + "bot_preview_mode_render": "レンダリング", + "bot_preview_empty": "表示するコンテンツがありません。", + "bot_preview_sample_note": "サンプルデータと保存前の設定に基づいて表示されます。" + } }, "action_recipe": { "set_state": { - "label": "画面状態を埋める/置き換え", - "param_target": "対象(ローカル/グローバル)", - "param_state": "状態値", - "param_merge": "既存の値とマージ" + "label": "画面ステータスの入力/変更", + "param_target": "対象 (ローカル/グローバル)", + "param_state": "ステータス値", + "param_merge": "既存値とマージ" }, "load_local": { "label": "保存された設定を読み込む", "param_key": "ストレージキー", - "param_state_key": "保持する状態キー", + "param_state_key": "保存する状態キー", "param_default": "デフォルト値" }, "save_local": { - "label": "設定を保存", + "label": "設定を保存する", "param_key": "ストレージキー", "param_value": "保存する値" }, @@ -1186,15 +1202,15 @@ "label": "別のページに移動", "param_path": "移動するページ", "param_query": "クエリ値", - "param_merge_query": "既存のクエリを維持", + "param_merge_query": "既存のクエリを保持", "param_scroll": "スクロール位置", - "param_replace": "履歴を置換", - "param_overlay_target": "移動中のローディング表示領域" + "param_replace": "履歴を置き換える", + "param_overlay_target": "移動中のロード表示エリア" }, "open_window": { "label": "新しいウィンドウで開く", "param_target": "開くページ", - "param_window_target": "ウィンドウ方式(新規/現在)" + "param_window_target": "ウィンドウ方式(新しいウィンドウ/現在のウィンドウ)" }, "navigate_back": { "label": "戻る" @@ -1215,11 +1231,11 @@ "param_icon": "アイコン" }, "open_modal": { - "label": "通知ダイアログを開く", - "param_target": "開く通知ダイアログ" + "label": "通知ウィンドウを開く", + "param_target": "開く通知ウィンドウ" }, "close_modal": { - "label": "通知ダイアログを閉じる" + "label": "通知ウィンドウを閉じる" }, "show_alert": { "label": "ブラウザ通知を表示", @@ -1231,7 +1247,7 @@ "param_state_target": "対象(ローカル/グローバル)" }, "refetch": { - "label": "リストを更新", + "label": "リスト更新", "param_data_source": "データソース", "param_sync": "同期実行" }, @@ -1242,15 +1258,15 @@ "param_new_data": "追加するデータ" }, "update_data": { - "label": "リストデータを置換", + "label": "リストデータを置き換える", "param_data_source": "データソース", "param_data": "新しいデータ", - "param_merge": "既存と統合" + "param_merge": "既存とマージ" }, "scroll": { "label": "特定の位置にスクロール", - "param_selector": "対象セレクター", - "param_behavior": "スクロール方式(スムーズ/即時)", + "param_selector": "ターゲットセレクター", + "param_behavior": "スクロール方式(スムーズ/即座)", "param_block": "配置位置" }, "login": { @@ -1268,7 +1284,7 @@ "emit_event": { "label": "コンポーネントイベントを送信", "param_event": "イベント名", - "param_data": "送信データ" + "param_data": "転送するデータ" }, "api_call": { "label": "サーバーに送信して結果を処理", @@ -1276,38 +1292,38 @@ "param_method": "方式(GET/POSTなど)", "param_body": "送信するデータ", "param_query": "クエリ値", - "param_on_success": "成功時のアクション", - "param_on_error": "失敗時のアクション", "param_identity_target_email": "本人認証対象メールアドレス", - "param_identity_target_phone": "本人認証対象電話番号" - }, - "sequence": { - "label": "順次複数のアクション", - "param_actions": "アクションリスト" - }, - "parallel": { - "label": "同時に複数のアクション", - "param_actions": "アクションリスト" - }, - "switch": { - "label": "値に基づいて分岐", - "param_value": "判定値", - "param_cases": "ケース別アクション" - }, - "suppress": { - "label": "何もしない(エラー抑制)" + "param_identity_target_phone": "本人認証対象電話番号", + "param_on_success": "成功時の動作", + "param_on_error": "失敗時の動作" }, "conditions": { - "label": "条件に基づいて分岐", + "label": "条件に応じて分岐", "param_branches": "分岐リスト", "param_if": "実行条件", - "param_then": "実行するアクション", + "param_then": "実行する動作", "branch_when": "条件", "branch_else": "その他", "add_branch": "分岐を追加", "remove_branch": "分岐を削除", "move_up": "上へ", "move_down": "下へ" + }, + "sequence": { + "label": "順序に複数の動作", + "param_actions": "動作リスト" + }, + "parallel": { + "label": "複数のアクションを同時に実行", + "param_actions": "アクションリスト" + }, + "switch": { + "label": "値に基づいて分岐", + "param_value": "判定する値", + "param_cases": "ケース別アクション" + }, + "suppress": { + "label": "何もしない(エラー抑制)" } }, "action_list": { @@ -1317,44 +1333,28 @@ "view_code": "コードを表示" }, "error_rows": { - "default": "デフォルト(その他すべてのエラー)", + "default": "既定値(その他すべてのエラー)", "clear": "設定をクリア", "no_action": "設定なし", - "default_warning": "上記で設定されていないすべてのエラーに適用され、テンプレートのデフォルト動作をオーバーライドします", + "default_warning": "上記で設定されていないすべてのエラーに適用され、テンプレートのデフォルト動作をオーバーライドします。", "source_self": "このページで設定", "source_inherited": "共通レイアウトから継承", - "source_template": "テンプレートデフォルト", - "source_none": "テンプレートデフォルト", + "source_template": "テンプレート既定値", + "source_none": "テンプレート既定値", "revert": "元に戻す", - "override_here": "このページで上書き", - "override_inherited": "この値を変更すると、このページにのみ適用され、親の設定を上書きします。", - "override_template": "この動作はテンプレート全体に設定されています。ここで変更すると、このページにのみ適用されテンプレート設定を上書きします。" + "override_here": "このページでオーバーライド", + "override_inherited": "この値を変更すると、このページにのみ適用され、親設定をオーバーライドします。", + "override_template": "このアクションはテンプレート全体に設定されています。ここで変更すると、このページにのみ適用されてテンプレート設定をオーバーライドします。" }, "modal": { "minimized_default": "ページ設定", "restore": "復元" }, - "action_param": { - "key_placeholder": "名前", - "value_placeholder": "値(データ連携・式が利用可能)", - "add_pair": "値を追加", - "number_placeholder": "数値またはデータ連携" - }, - "action_chip": { - "response_data": "レスポンスデータ", - "response_message": "レスポンスメッセージ", - "error_status": "エラーステータスコード", - "error_message": "エラーメッセージ", - "error_errors": "検証エラーリスト", - "error_data": "エラーレスポンスデータ", - "error_status_text": "エラーステータステキスト", - "payload_data": "受信データ" - }, "json_block": { "mode_code": "コードで", "mode_block": "ブロックで", - "invalid": "正しい JSON 形式ではありません。", - "must_be_object": "オブジェクト(JSON)形式である必要があります。例: { \"名前\": 値 }", - "invalid_keys": "値の名前は英文字で始まり、英文字・数字・_ のみが使用できます: {keys}" + "invalid": "有効なJSON形式ではありません。", + "must_be_object": "オブジェクト(JSON)形式である必要があります。例:{ \"名前\": 値 }", + "invalid_keys": "値の名前は英文字で始まり、英文字・数字・_ のみ使用できます:{keys}" } } diff --git a/lang-packs/_bundled/g7-core-ja/seed/identity_messages.json b/lang-packs/_bundled/g7-core-ja/seed/identity_messages.json index 2b2734b5..37846d39 100644 --- a/lang-packs/_bundled/g7-core-ja/seed/identity_messages.json +++ b/lang-packs/_bundled/g7-core-ja/seed/identity_messages.json @@ -1,13 +1,13 @@ { "mail.provider_default": { "definition": { - "name": "メール本人確認 (デフォルト)", - "description": "特定の目的がマッチしない場合に使用されるデフォルトのメールテンプレート" + "name": "メール本人確認(基本)", + "description": "特定の目的がマッチしない場合に使用されるデフォルトメールテンプレート" }, "templates": { "mail": { "subject": "[{app_name}] 本人確認認証コード", - "body": "

こんにちは。

下記の認証コードを入力して本人確認を完了してください。

{code}

このコードは{expire_minutes}分後に有効期限切れになります。

本人が申請していない場合は、このメールを無視してください。

ご利用ありがとうございます。
{app_name}

" + "body": "

こんにちは。

下記の認証コードを入力して本人確認を完了してください。

{code}

このコードは{expire_minutes}分後に失効します。

本人が要求していない場合は、このメールを無視してください。

ありがとうございます。
{app_name}

" } } }, @@ -19,19 +19,19 @@ "templates": { "mail": { "subject": "[{app_name}] 会員登録認証コード", - "body": "

{app_name} 会員登録認証

下記の認証コードを会員登録画面に入力してください。

{code}

このコードは{expire_minutes}分後に有効期限切れになります。

本人が登録を試行していない場合は、このメールを無視してください。

ご利用ありがとうございます。
{app_name}

" + "body": "

{app_name} 会員登録認証

下記の認証コードを会員登録画面に入力してください。

{code}

このコードは{expire_minutes}分後に失効します。

本人が登録を試みていない場合は、このメールを無視してください。

ありがとうございます。
{app_name}

" } } }, "mail.purpose.password_reset": { "definition": { "name": "パスワード再設定リンク", - "description": "パスワード再設定リクエスト時に送信される署名付きリンクメール (link フロー)" + "description": "パスワード再設定要求時に送信される署名付きリンクメール(link フロー)" }, "templates": { "mail": { - "subject": "[{app_name}] パスワード再設定のご案内", - "body": "

パスワード再設定

パスワード再設定のリクエストを受け取りました。下記のボタンをクリックしてパスワードを再設定してください。

このリンクは{expire_minutes}分後に有効期限切れになります。

パスワード再設定

本人が申請していない場合は、このメールを無視してください。

ご利用ありがとうございます。
{app_name}

" + "subject": "[{app_name}] パスワード再設定のお知らせ", + "body": "

パスワード再設定

パスワード再設定のリクエストを受け取りました。下記のボタンをクリックしてパスワードを再設定してください。

このリンクは{expire_minutes}分後に失効します。

パスワードを再設定

本人が要求していない場合は、このメールを無視してください。

ありがとうございます。
{app_name}

" } } }, @@ -43,19 +43,19 @@ "templates": { "mail": { "subject": "[{app_name}] アカウント情報変更認証コード", - "body": "

アカウント情報変更認証

{purpose_label}の処理のために下記の認証コードを入力してください。

{code}

このコードは{expire_minutes}分後に有効期限切れになります。

本人が変更を試行していない場合は、直ちにパスワードを変更してください。

ご利用ありがとうございます。
{app_name}

" + "body": "

アカウント情報変更認証

{purpose_label}の処理のため、下記の認証コードを入力してください。

{code}

このコードは{expire_minutes}分後に失効します。

本人が変更を試みていない場合は、直ちにパスワードを変更してください。

ありがとうございます。
{app_name}

" } } }, "mail.purpose.sensitive_action": { "definition": { "name": "重要な作業認証", - "description": "決済、重要な設定変更など重要な作業時の本人確認コードメール" + "description": "決済や主要な設定変更など重要な作業時の本人確認コードメール" }, "templates": { "mail": { "subject": "[{app_name}] 重要な作業認証コード", - "body": "

重要な作業認証

{purpose_label}の進行のために下記の認証コードを入力してください。

{code}

このコードは{expire_minutes}分後に有効期限切れになります。

本人が試行していない場合は、直ちにカスタマーサポートチームにお問い合わせください。

ご利用ありがとうございます。
{app_name}

" + "body": "

重要な作業認証

{purpose_label}の進行のため、下記の認証コードを入力してください。

{code}

このコードは{expire_minutes}分後に失効します。

本人が試みていない場合は、直ちにカスタマーサポートにお問い合わせください。

ありがとうございます。
{app_name}

" } } } diff --git a/lang-packs/_bundled/g7-core-ja/seed/notifications.json b/lang-packs/_bundled/g7-core-ja/seed/notifications.json index 8c88d528..b7e1699b 100644 --- a/lang-packs/_bundled/g7-core-ja/seed/notifications.json +++ b/lang-packs/_bundled/g7-core-ja/seed/notifications.json @@ -1,33 +1,33 @@ { "welcome": { "definition": { - "name": "会員登録 ウェルカム", + "name": "会員登録ウェルカム", "description": "会員登録完了時に送信されるウェルカム通知" }, "templates": { "mail": { - "subject": "[{app_name}] 会員登録をお待ちしておりました", - "body": "

{name}様、ようこそ!

{app_name}にご登録いただきありがとうございます。

これで全てのサービスをご利用いただけます。以下のボタンをクリックしてログインしてください。

ログインする

ありがとうございます。
{app_name}

" + "subject": "[{app_name}] 会員登録をお待ちしていました", + "body": "

{name}様、ようこそ!

{app_name}へのご登録ありがとうございます。

これですべてのサービスをご利用いただけます。下のボタンをクリックしてログインしてください。

ログインする

ありがとうございます。
{app_name}

" }, "database": { - "subject": "会員登録をお待ちしておりました", - "body": "{name}様、{app_name}にご登録いただきありがとうございます。" + "subject": "会員登録をお待ちしていました", + "body": "{name}様、{app_name}へのご登録ありがとうございます。" } } }, "reset_password": { "definition": { - "name": "パスワード再設定", - "description": "パスワード再設定要求時に送信される通知" + "name": "パスワードリセット", + "description": "パスワードリセット要求時に送信される通知" }, "templates": { "mail": { - "subject": "[{app_name}] パスワード再設定のご案内", - "body": "

{name}様、こんにちは。

パスワード再設定のご要望を承りました。以下のボタンをクリックしてパスワードを再設定してください。

このリンクは{expire_minutes}分後に期限切れになります。

パスワード再設定

パスワード再設定をご要望でなかった場合は、このメールを無視してください。

ありがとうございます。
{app_name}

" + "subject": "[{app_name}] パスワードリセットのご案内", + "body": "

{name}様、こんにちは。

パスワードリセットのご要求をお受けしました。下のボタンをクリックしてパスワードをリセットしてください。

このリンクは{expire_minutes}分後に有効期限切れになります。

パスワードをリセットする

パスワードリセットのご要求をされていない場合は、このメールを無視してください。

ありがとうございます。
{app_name}

" }, "database": { - "subject": "パスワード再設定のご案内", - "body": "{name}様、パスワード再設定が要求されました。ご本人様による操作でない場合は、この通知を無視してください。" + "subject": "パスワードリセットのご案内", + "body": "{name}様、パスワードリセットが要求されました。ご自身で要求されていない場合は、この通知を無視してください。" } } }, @@ -39,11 +39,11 @@ "templates": { "mail": { "subject": "[{app_name}] パスワードが変更されました", - "body": "

{name}様、こんにちは。

アカウントのパスワードが正常に変更されました。

ご本人様による操作でない場合は、直ちにカスタマーサポートまでお問い合わせください。

ログインする

ありがとうございます。
{app_name}

" + "body": "

{name}様、こんにちは。

アカウントのパスワードが正常に変更されました。

ご自身で変更されていない場合は、すぐにカスタマーサポートにお問い合わせください。

ログインする

ありがとうございます。
{app_name}

" }, "database": { "subject": "パスワードが変更されました", - "body": "{name}様、パスワードが変更されました。ご本人様による操作でない場合は、直ちにカスタマーサポートまでお問い合わせください。" + "body": "{name}様、パスワードが変更されました。ご自身で変更されていない場合は、すぐにカスタマーサポートにお問い合わせください。" } } } diff --git a/lang-packs/_bundled/g7-core-ja/seed/permissions.json b/lang-packs/_bundled/g7-core-ja/seed/permissions.json index c3c2d0af..37c84694 100644 --- a/lang-packs/_bundled/g7-core-ja/seed/permissions.json +++ b/lang-packs/_bundled/g7-core-ja/seed/permissions.json @@ -8,8 +8,8 @@ "description": "ユーザー管理権限" }, "core.users.read": { - "name": "ユーザー조회", - "description": "ユーザー一覧および詳細情報を閲覧できます。" + "name": "ユーザー閲覧", + "description": "ユーザーリストと詳細情報を閲覧できます。" }, "core.users.create": { "name": "ユーザー作成", @@ -28,8 +28,8 @@ "description": "メニュー管理権限" }, "core.menus.read": { - "name": "メニュー조회", - "description": "メニュー一覧を閲覧できます。" + "name": "メニュー閲覧", + "description": "メニューリストを閲覧できます。" }, "core.menus.create": { "name": "メニュー作成", @@ -48,8 +48,8 @@ "description": "モジュール管理権限" }, "core.modules.read": { - "name": "モジュール조회", - "description": "モジュール一覧を閲覧できます。" + "name": "モジュール閲覧", + "description": "モジュールリストを閲覧できます。" }, "core.modules.install": { "name": "モジュールインストール", @@ -68,8 +68,8 @@ "description": "プラグイン管理権限" }, "core.plugins.read": { - "name": "プラグイン조회", - "description": "プラグイン一覧を閲覧できます。" + "name": "プラグイン閲覧", + "description": "プラグインリストを閲覧できます。" }, "core.plugins.install": { "name": "プラグインインストール", @@ -92,8 +92,8 @@ "description": "テンプレート管理権限" }, "core.templates.read": { - "name": "テンプレート조회", - "description": "テンプレート一覧を閲覧できます。" + "name": "テンプレート閲覧", + "description": "テンプレートリストを閲覧できます。" }, "core.templates.install": { "name": "テンプレートインストール", @@ -116,8 +116,8 @@ "description": "ロールおよび権限管理権限" }, "core.permissions.read": { - "name": "権限조회", - "description": "ロールおよび権限一覧を閲覧できます。" + "name": "権限閲覧", + "description": "ロールおよび権限リストを閲覧できます。" }, "core.permissions.create": { "name": "ロール作成", @@ -125,7 +125,7 @@ }, "core.permissions.update": { "name": "ロール編集", - "description": "ロール情報および権限を編集できます。" + "description": "ロール情報と権限を編集できます。" }, "core.permissions.delete": { "name": "ロール削除", @@ -136,7 +136,7 @@ "description": "通知発送履歴管理権限" }, "core.notification-logs.read": { - "name": "発送履歴조회", + "name": "発送履歴閲覧", "description": "通知発送履歴を閲覧できます。" }, "core.notification-logs.delete": { @@ -145,11 +145,11 @@ }, "core.notifications": { "name": "通知(管理者)", - "description": "管理者用通知管理権限(管理者画面で使用)" + "description": "管理者用通知管理権限(管理画面で使用)" }, "core.notifications.read": { - "name": "通知조회", - "description": "通知一覧および未読数を閲覧できます。" + "name": "通知閲覧", + "description": "通知リストと未読数を閲覧できます。" }, "core.notifications.update": { "name": "通知既読処理", @@ -161,11 +161,11 @@ }, "core.user-notifications": { "name": "通知 (ユーザー)", - "description": "ユーザー用通知権限 (ユーザー画面で本人通知管理)" + "description": "ユーザー向け通知権限 (ユーザー画面で本人の通知を管理)" }, "core.user-notifications.read": { "name": "通知閲覧", - "description": "本人の通知リスト及び未読数を閲覧できます。" + "description": "本人の通知一覧および未読数を閲覧できます。" }, "core.user-notifications.update": { "name": "通知読了処理", @@ -176,56 +176,56 @@ "description": "本人の通知を削除できます。" }, "core.identity": { - "name": "本人確認 (ユーザー)", - "description": "ログインユーザーの本人確認チャレンジリクエスト/検証/キャンセル権限" + "name": "本人認証 (ユーザー)", + "description": "ログインユーザーの本人認証チャレンジ要求/検証/キャンセル権限" }, "core.identity.request": { - "name": "IDVリクエスト", - "description": "本人確認チャレンジをリクエストできます。" + "name": "IDV要求", + "description": "本人認証チャレンジを要求できます。" }, "core.identity.verify": { "name": "IDV検証", - "description": "本人確認チャレンジを検証できます。" + "description": "本人の本人認証チャレンジを検証できます。" }, "core.identity.cancel": { "name": "IDVキャンセル", - "description": "本人の本人確認チャレンジをキャンセルできます。" + "description": "本人の本人認証チャレンジをキャンセルできます。" }, "core.admin.identity": { - "name": "本人確認管理 (管理者)", + "name": "本人認証管理 (管理者)", "description": "管理者用IDVプロバイダ/ポリシー/ログ管理権限" }, "core.admin.identity.providers.read": { "name": "プロバイダ設定閲覧", - "description": "本人確認プロバイダ設定を閲覧できます。" + "description": "本人認証プロバイダ設定を閲覧できます。" }, "core.admin.identity.providers.update": { "name": "プロバイダ設定編集", - "description": "本人確認プロバイダ設定を編集できます。" + "description": "本人認証プロバイダ設定を編集できます。" }, "core.admin.identity.policies.read": { "name": "ポリシー閲覧", - "description": "本人確認ポリシー(ルート/フック別)を閲覧できます。" + "description": "本人認証ポリシー(ルート/フック別)を閲覧できます。" }, "core.admin.identity.policies.update": { "name": "ポリシー編集", - "description": "本人確認ポリシー(ルート/フック別)をCRUD できます。" + "description": "本人認証ポリシー(ルート/フック別)をCRUD できます。" }, "core.admin.identity.logs.read": { "name": "ログ閲覧", - "description": "本人確認履歴を閲覧できます。" + "description": "本人認証履歴を閲覧できます。" }, "core.admin.identity.logs.purge": { - "name": "ログ破棄", - "description": "本人確認履歴を破棄(保管期間外)できます。" + "name": "ログ削除", + "description": "本人認証履歴を削除(保管周期外)できます。" }, "core.admin.identity.messages.read": { "name": "メッセージテンプレート閲覧", - "description": "本人確認メッセージ定義/テンプレートを閲覧できます。" + "description": "本人認証メッセージ定義/テンプレートを閲覧できます。" }, "core.admin.identity.messages.update": { "name": "メッセージテンプレート編集", - "description": "本人確認メッセージ定義/テンプレートを編集できます。" + "description": "本人認証メッセージ定義/テンプレートを編集できます。" }, "core.settings": { "name": "環境設定", @@ -245,19 +245,19 @@ }, "core.dashboard.read": { "name": "ダッシュボード閲覧", - "description": "ダッシュボード統計及び情報を閲覧できます。" + "description": "ダッシュボード統計および情報を閲覧できます。" }, "core.dashboard.system-status": { - "name": "システム状態", - "description": "システム状態情報を閲覧できます。" + "name": "システムステータス", + "description": "システムステータス情報を閲覧できます。" }, "core.dashboard.resources": { "name": "システムリソース", "description": "CPU、メモリ、ディスク使用量を閲覧できます。" }, "core.dashboard.activities": { - "name": "最近の活動", - "description": "最近の活動履歴を閲覧できます。" + "name": "最近のアクティビティ", + "description": "最近のアクティビティ履歴を閲覧できます。" }, "core.dashboard.alerts": { "name": "システム通知", @@ -285,7 +285,7 @@ }, "core.attachments.update": { "name": "添付ファイル編集", - "description": "添付ファイル情報及び順序を編集できます。" + "description": "添付ファイル情報および順序を編集できます。" }, "core.attachments.delete": { "name": "添付ファイル削除", @@ -297,7 +297,7 @@ }, "core.schedules.read": { "name": "スケジュール閲覧", - "description": "スケジュールリスト及び詳細情報を閲覧できます。" + "description": "スケジュール一覧および詳細情報を閲覧できます。" }, "core.schedules.create": { "name": "スケジュール作成", @@ -317,11 +317,11 @@ }, "core.language_packs": { "name": "言語パック管理", - "description": "言語パックインストール/削除/有効化権限" + "description": "言語パックインストール/アンインストール/有効化権限" }, "core.language_packs.read": { "name": "言語パック閲覧", - "description": "インストール済み言語パックリスト及び詳細情報を閲覧できます。" + "description": "インストールされた言語パックの一覧と詳細情報を閲覧できます。" }, "core.language_packs.install": { "name": "言語パックインストール", @@ -332,7 +332,7 @@ "description": "言語パックを有効化/無効化/削除できます。" }, "core.language_packs.update": { - "name": "言語パック アップデート", + "name": "言語パックアップデート", "description": "インストールされた言語パックのアップデートを確認して実行できます。" } } diff --git a/lang-packs/_bundled/g7-core-ja/seed/roles.json b/lang-packs/_bundled/g7-core-ja/seed/roles.json index a74a914c..33d6fcd8 100644 --- a/lang-packs/_bundled/g7-core-ja/seed/roles.json +++ b/lang-packs/_bundled/g7-core-ja/seed/roles.json @@ -9,10 +9,10 @@ }, "user": { "name": "一般ユーザー", - "description": "基本的なユーザーロールです。" + "description": "基本ユーザーロールです。" }, "guest": { "name": "非会員", - "description": "認証されていないユーザーロールです。管理者が権限を付与することができます。" + "description": "認証されていないユーザーロールです。管理者が権限を付与できます。" } } diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md index d800ffac..5098c122 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md @@ -6,6 +6,11 @@ ## [1.0.4] - 2026-07-17 +### Added + +- 환경설정 > SEO 화면에 추가된 Sitemap 분할 기준(파일당 URL 수)·Sitemap 압축 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다. +- 고급 설정에 추가된 Sitemap 캐시 유지 시간 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다. + ### Changed - 환경설정 > SEO 화면의 캐시 항목(SEO 캐시 활성화·캐시 유지 시간, Sitemap 캐시 유지 시간) 안내 문구 일본어 번역을 갱신했습니다 — 비워두면 고급 설정의 값을 따르고 값을 넣으면 그 값이 우선한다는 새 동작을 일본어 로케일에서도 올바르게 안내합니다. diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json index a047df35..3b5049bc 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json @@ -1429,7 +1429,11 @@ "sitemap_manual_update": "今すぐ生成", "sitemap_updating": "生成中...", "sitemap_update_success": "Sitemap 生成が完了しました。", - "sitemap_update_failed": "Sitemap 生成に失敗しました。" + "sitemap_update_failed": "Sitemap 生成に失敗しました。", + "sitemap_urls_per_file": "Sitemap ファイルあたりの URL 数", + "sitemap_urls_per_file_hint": "URL がこの数を超えると sitemap ファイルが複数に分割され、インデックスファイルが一緒に作成されます — 最小 1000 個、最大 50000 個(検索エンジン仕様上限)", + "sitemap_gzip": "Sitemap 圧縮(gzip)", + "sitemap_gzip_desc": "分割された sitemap ファイルを圧縮して送信量を削減します。インデックスファイルは圧縮しません。" }, "security": { "title": "セキュリティ 設定", @@ -1736,7 +1740,9 @@ "geoip_manual_update": "今すぐ更新", "geoip_updating": "更新中...", "geoip_update_success": "GeoIP DBの更新が完了しました。", - "geoip_update_failed": "GeoIP DBの更新に失敗しました。" + "geoip_update_failed": "GeoIP DBの更新に失敗しました。", + "seo_sitemap_cache": "Sitemap キャッシュ", + "seo_sitemap_cache_desc": "sitemap.xml をキャッシュします。" }, "notification_definitions": { "title": "通知設定", diff --git a/lang/en/seo.php b/lang/en/seo.php index 16a4f3f4..b6c57a69 100644 --- a/lang/en/seo.php +++ b/lang/en/seo.php @@ -12,9 +12,14 @@ return [ // Sitemap manual regeneration 'sitemap_regenerated' => 'Sitemap regeneration completed.', + 'sitemap_regenerate_dispatched' => 'Sitemap regeneration has started. It may take a while to finish.', 'sitemap_regenerate_failed' => 'Sitemap regeneration failed.', 'sitemap_disabled' => 'Sitemap generation is disabled.', + // Sitemap generation/serving + 'sitemap_not_ready' => 'The sitemap is being prepared. Please try again shortly.', + 'sitemap_generate_failed' => 'Sitemap generation failed: :error', + // Cache stats 'stats_period' => 'SEO cache statistics for the last :days days', 'stats_overall' => 'Overall Statistics', diff --git a/lang/en/validation.php b/lang/en/validation.php index 71f2d15b..141fca60 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -865,12 +865,16 @@ return [ 'stats_cache_ttl_integer' => 'Stats cache TTL must be an integer.', 'stats_cache_ttl_min' => 'Stats cache TTL must be at least 0 seconds.', 'stats_cache_ttl_max' => 'Stats cache TTL may not be greater than 14400 seconds (4 hours).', - 'seo_cache_enabled_required' => 'Please select the SEO cache setting.', - 'seo_cache_enabled_boolean' => 'SEO cache must be true or false.', - 'seo_cache_ttl_required' => 'Please enter the SEO cache TTL.', - 'seo_cache_ttl_integer' => 'SEO cache TTL must be an integer.', - 'seo_cache_ttl_min' => 'SEO cache TTL must be at least 0 seconds.', - 'seo_cache_ttl_max' => 'SEO cache TTL may not be greater than 14400 seconds (4 hours).', + // Advanced tab only — the SEO tab override field (seo_cache_*) allows a different range. + 'advanced_seo_cache_enabled_required' => 'Please select the SEO cache setting.', + 'advanced_seo_cache_enabled_boolean' => 'SEO cache must be true or false.', + 'advanced_seo_cache_ttl_required' => 'Please enter the SEO cache TTL.', + 'advanced_seo_cache_ttl_integer' => 'SEO cache TTL must be an integer.', + 'advanced_seo_cache_ttl_min' => 'SEO cache TTL must be at least 0 seconds.', + 'advanced_seo_cache_ttl_max' => 'SEO cache TTL may not be greater than 14400 seconds (4 hours).', + 'seo_sitemap_cache_ttl_integer' => 'Sitemap cache TTL must be an integer.', + 'seo_sitemap_cache_ttl_min' => 'Sitemap cache TTL must be at least 3600 seconds (1 hour).', + 'seo_sitemap_cache_ttl_max' => 'Sitemap cache TTL may not be greater than 604800 seconds (7 days).', // Debug settings 'debug_mode_required' => 'Please select the debug mode setting.', @@ -954,6 +958,13 @@ return [ 'sitemap_cache_ttl_integer' => 'Sitemap cache TTL must be an integer.', 'sitemap_cache_ttl_min' => 'Sitemap cache TTL must be at least 3600 seconds (1 hour).', 'sitemap_cache_ttl_max' => 'Sitemap cache TTL may not be greater than 604800 seconds (7 days).', + 'sitemap_urls_per_file_integer' => 'URLs per sitemap file must be an integer.', + 'sitemap_urls_per_file_min' => 'URLs per sitemap file must be at least 1000.', + 'sitemap_urls_per_file_max' => 'URLs per sitemap file may not be greater than 50000.', + 'sitemap_gzip_boolean' => 'Sitemap compression setting must be true or false.', + 'sitemap_serve_stale_on_miss_boolean' => 'Serving the previous sitemap setting must be true or false.', + 'sitemap_max_urls_per_contributor_integer' => 'Maximum URLs per contributor must be an integer.', + 'sitemap_max_urls_per_contributor_min' => 'Maximum URLs per contributor must be at least 0.', 'sitemap_schedule_invalid' => 'Please select a valid sitemap generation schedule.', 'sitemap_schedule_time_invalid' => 'Sitemap generation time must be in HH:mm format.', diff --git a/lang/ko/seo.php b/lang/ko/seo.php index 77148fa6..f58b046a 100644 --- a/lang/ko/seo.php +++ b/lang/ko/seo.php @@ -12,9 +12,14 @@ return [ // Sitemap 수동 재생성 'sitemap_regenerated' => 'Sitemap 재생성이 완료되었습니다.', + 'sitemap_regenerate_dispatched' => 'Sitemap 재생성을 시작했습니다. 완료까지 시간이 걸릴 수 있습니다.', 'sitemap_regenerate_failed' => 'Sitemap 재생성에 실패했습니다.', 'sitemap_disabled' => 'Sitemap 생성이 비활성화되어 있습니다.', + // Sitemap 생성/서빙 + 'sitemap_not_ready' => 'Sitemap 을 준비하는 중입니다. 잠시 후 다시 시도해주세요.', + 'sitemap_generate_failed' => 'Sitemap 생성에 실패했습니다: :error', + // 캐시 통계 'stats_period' => '최근 :days일간 SEO 캐시 통계', 'stats_overall' => '전체 통계', diff --git a/lang/ko/validation.php b/lang/ko/validation.php index 02c496e5..d02c4e40 100644 --- a/lang/ko/validation.php +++ b/lang/ko/validation.php @@ -903,6 +903,13 @@ return [ 'sitemap_cache_ttl_integer' => 'Sitemap 캐시 TTL은 정수여야 합니다.', 'sitemap_cache_ttl_min' => 'Sitemap 캐시 TTL은 최소 3600초(1시간) 이상이어야 합니다.', 'sitemap_cache_ttl_max' => 'Sitemap 캐시 TTL은 최대 604800초(7일)를 초과할 수 없습니다.', + 'sitemap_urls_per_file_integer' => 'Sitemap 파일당 URL 수는 정수여야 합니다.', + 'sitemap_urls_per_file_min' => 'Sitemap 파일당 URL 수는 최소 1000개 이상이어야 합니다.', + 'sitemap_urls_per_file_max' => 'Sitemap 파일당 URL 수는 최대 50000개를 초과할 수 없습니다.', + 'sitemap_gzip_boolean' => 'Sitemap 압축 설정은 true 또는 false 값이어야 합니다.', + 'sitemap_serve_stale_on_miss_boolean' => '이전 Sitemap 제공 설정은 true 또는 false 값이어야 합니다.', + 'sitemap_max_urls_per_contributor_integer' => '수집기당 최대 URL 수는 정수여야 합니다.', + 'sitemap_max_urls_per_contributor_min' => '수집기당 최대 URL 수는 0 이상이어야 합니다.', 'sitemap_schedule_invalid' => '유효한 Sitemap 생성 주기를 선택해주세요.', 'sitemap_schedule_time_invalid' => 'Sitemap 생성 시각은 HH:mm 형식이어야 합니다.', @@ -938,12 +945,16 @@ return [ 'stats_cache_ttl_integer' => '통계 캐시 만료 시간은 정수여야 합니다.', 'stats_cache_ttl_min' => '통계 캐시 만료 시간은 최소 0초여야 합니다.', 'stats_cache_ttl_max' => '통계 캐시 만료 시간은 최대 14400초(4시간)를 초과할 수 없습니다.', - 'seo_cache_enabled_required' => 'SEO 캐시 설정을 선택해주세요.', - 'seo_cache_enabled_boolean' => 'SEO 캐시는 true 또는 false 값이어야 합니다.', - 'seo_cache_ttl_required' => 'SEO 캐시 만료 시간을 입력해주세요.', - 'seo_cache_ttl_integer' => 'SEO 캐시 만료 시간은 정수여야 합니다.', - 'seo_cache_ttl_min' => 'SEO 캐시 만료 시간은 최소 0초여야 합니다.', - 'seo_cache_ttl_max' => 'SEO 캐시 만료 시간은 최대 14400초(4시간)를 초과할 수 없습니다.', + // 고급 탭 전용 — SEO 탭의 오버라이드 칸(seo_cache_*)과 허용 범위가 다르므로 키를 분리한다. + 'advanced_seo_cache_enabled_required' => 'SEO 캐시 설정을 선택해주세요.', + 'advanced_seo_cache_enabled_boolean' => 'SEO 캐시는 true 또는 false 값이어야 합니다.', + 'advanced_seo_cache_ttl_required' => 'SEO 캐시 만료 시간을 입력해주세요.', + 'advanced_seo_cache_ttl_integer' => 'SEO 캐시 만료 시간은 정수여야 합니다.', + 'advanced_seo_cache_ttl_min' => 'SEO 캐시 만료 시간은 최소 0초여야 합니다.', + 'advanced_seo_cache_ttl_max' => 'SEO 캐시 만료 시간은 최대 14400초(4시간)를 초과할 수 없습니다.', + 'seo_sitemap_cache_ttl_integer' => 'Sitemap 캐시 만료 시간은 정수여야 합니다.', + 'seo_sitemap_cache_ttl_min' => 'Sitemap 캐시 만료 시간은 최소 3600초(1시간) 이상이어야 합니다.', + 'seo_sitemap_cache_ttl_max' => 'Sitemap 캐시 만료 시간은 최대 604800초(7일)를 초과할 수 없습니다.', // 디버그 설정 'debug_mode_required' => '디버그 모드 설정을 선택해주세요.', diff --git a/routes/console.php b/routes/console.php index f0a49a3d..8e7f739e 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,5 +1,8 @@ onOneServer(); + // 대용량 생성은 스케줄 주기를 넘길 수 있으므로 이전 실행이 끝나기 전에는 겹쳐 돌지 않게 한다. + $sitemapScheduled = Schedule::job(new GenerateSitemapJob) + ->onOneServer() + ->withoutOverlapping(30); match ($sitemapFrequency) { 'hourly' => $sitemapScheduled->hourly(), @@ -89,7 +94,7 @@ if (file_exists(base_path('.env'))) { if (file_exists(base_path('.env'))) { try { // 모듈 스케줄 등록 - $moduleManager = app(\App\Extension\ModuleManager::class); + $moduleManager = app(ModuleManager::class); foreach ($moduleManager->getActiveModules() as $module) { foreach ($module->getSchedules() as $config) { if (empty($config['command']) || empty($config['schedule'])) { @@ -113,7 +118,7 @@ if (file_exists(base_path('.env'))) { $settingKey = $config['enabled_config']; // "identifier.key" 형식이면 identifier 부분 제거하여 설정 키만 추출 - if (str_starts_with($settingKey, $identifier . '.')) { + if (str_starts_with($settingKey, $identifier.'.')) { $settingKey = substr($settingKey, strlen($identifier) + 1); } @@ -123,7 +128,7 @@ if (file_exists(base_path('.env'))) { } // 플러그인 스케줄 등록 - $pluginManager = app(\App\Extension\PluginManager::class); + $pluginManager = app(PluginManager::class); foreach ($pluginManager->getActivePlugins() as $plugin) { foreach ($plugin->getSchedules() as $config) { if (empty($config['command']) || empty($config['schedule'])) { @@ -146,7 +151,7 @@ if (file_exists(base_path('.env'))) { $settingKey = $config['enabled_config']; // "identifier.key" 형식이면 identifier 부분 제거하여 설정 키만 추출 - if (str_starts_with($settingKey, $identifier . '.')) { + if (str_starts_with($settingKey, $identifier.'.')) { $settingKey = substr($settingKey, strlen($identifier) + 1); } @@ -154,7 +159,7 @@ if (file_exists(base_path('.env'))) { } } } - } catch (\Exception $e) { + } catch (Exception $e) { Log::debug('확장 스케줄 등록 스킵', ['error' => $e->getMessage()]); } } diff --git a/routes/web.php b/routes/web.php index 00f44d58..7bb16f3a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -39,7 +39,11 @@ Route::prefix('admin') }); // Sitemap XML 라우트 +// 자식 경로는 SitemapFileStore::childUrl() 이 sitemapindex 의 에 기록하는 값과 일치해야 한다. +// gzip 여부는 manifest 가 결정하므로 .xml / .xml.gz 를 같은 액션에서 처리한다. Route::get('/sitemap.xml', [SitemapController::class, 'index'])->name('web.sitemap'); +Route::get('/sitemap-{n}.xml', [SitemapController::class, 'child'])->whereNumber('n')->name('web.sitemap.child'); +Route::get('/sitemap-{n}.xml.gz', [SitemapController::class, 'child'])->whereNumber('n')->name('web.sitemap.child.gz'); // User 라우트 - user 템플릿 의존성 검증 + SEO 봇 감지 Route::middleware(['template.dependencies:user', 'seo']) diff --git a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md index 74decce9..f7d4adeb 100644 --- a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md @@ -4,6 +4,17 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.4] - 2026-07-17 + +### Added + +- SEO 설정에 Sitemap 분할 기준(파일당 URL 수)과 Sitemap 압축 항목을 추가했습니다. URL 이 분할 기준을 넘으면 sitemap 이 여러 파일로 나뉘어 생성됩니다. +- 고급 설정의 캐시 항목에 Sitemap 캐시 유지 시간을 추가했습니다. SEO 설정의 Sitemap 캐시 항목을 비워두면 이 값을 따릅니다. + +### Changed + +- SEO 설정의 "지금 생성" 버튼이 sitemap 생성을 예약하고 바로 응답합니다. 사이트 규모가 커도 화면이 멈추지 않으며, 생성 완료는 마지막 생성 시각으로 확인할 수 있습니다. + ## [1.0.3] - 2026-07-14 ### Fixed diff --git a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-fields.test.tsx b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-fields.test.tsx new file mode 100644 index 00000000..ed98afff --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-fields.test.tsx @@ -0,0 +1,139 @@ +/** + * @file admin-settings-sitemap-fields.test.tsx + * @description 환경설정 > SEO/고급 탭의 Sitemap 관련 칸 계약 테스트 + * + * 검증 포인트: + * - SEO 탭: 분할 기준(sitemap_urls_per_file) / 압축(sitemap_gzip) 칸이 존재하고 + * name 과 허용 범위가 백엔드 검증 규칙(SaveSettingsRequest)과 일치 + * - 고급 탭: Sitemap 캐시 기준값(advanced.seo_sitemap_cache_ttl) 칸이 존재하고 + * 범위가 SEO 탭 오버라이드 칸과 동일 (두 칸의 허용값이 어긋나면 한쪽 저장이 막힘) + * - 수동 재생성 버튼이 async 전환 이후에도 동일 엔드포인트를 호출 + */ + +import { describe, it, expect } from 'vitest'; + +const seoTab = require('../../layouts/partials/admin_settings/_tab_seo.json'); +const advancedTab = require('../../layouts/partials/admin_settings/_tab_advanced.json'); + +/** + * 트리에서 조건에 맞는 노드를 모두 수집. + * + * @param node 시작 노드 + * @param predicate 노드 매칭 조건 + * @returns 매칭된 노드 배열 + */ +function collectNodes(node: any, predicate: (n: any) => boolean): any[] { + const result: any[] = []; + const visit = (n: any) => { + if (!n || typeof n !== 'object') return; + if (Array.isArray(n)) { + n.forEach(visit); + return; + } + if (predicate(n)) result.push(n); + if (n.children) visit(n.children); + if (n.cellChildren) visit(n.cellChildren); + if (n.actions) visit(n.actions); + if (n.params) visit(n.params); + if (n.onSuccess) visit(n.onSuccess); + if (n.onError) visit(n.onError); + }; + visit(node); + return result; +} + +/** + * name prop 으로 폼 필드 노드 1개를 찾는다. + * + * @param tree 레이아웃 트리 + * @param name 필드 name + * @returns 매칭 노드 (없으면 undefined) + */ +function findFieldByName(tree: any, name: string): any { + return collectNodes(tree, (n) => n?.props?.name === name)[0]; +} + +describe('환경설정 > SEO 탭 > Sitemap 생성 칸', () => { + it('분할 기준 칸이 seo.sitemap_urls_per_file 이름으로 존재한다', () => { + const field = findFieldByName(seoTab, 'seo.sitemap_urls_per_file'); + + expect(field).toBeDefined(); + expect(field.name).toBe('Input'); + expect(field.props.type).toBe('number'); + }); + + it('분할 기준 칸의 허용 범위가 백엔드 검증 규칙(1000~50000)과 일치한다', () => { + const field = findFieldByName(seoTab, 'seo.sitemap_urls_per_file'); + + expect(field.props.min).toBe(1000); + expect(field.props.max).toBe(50000); + }); + + it('압축 칸이 seo.sitemap_gzip Toggle 로 존재한다', () => { + const field = findFieldByName(seoTab, 'seo.sitemap_gzip'); + + expect(field).toBeDefined(); + expect(field.name).toBe('Toggle'); + expect(field.type).toBe('composite'); + }); + + it('신규 칸이 다국어 키로 라벨을 표시한다 (하드코딩 문구 없음)', () => { + const urlsPerFileField = collectNodes( + seoTab, + (n) => n.id === 'field_sitemap_urls_per_file' + )[0]; + const gzipField = collectNodes(seoTab, (n) => n.id === 'field_sitemap_gzip')[0]; + + const labels = [ + ...collectNodes(urlsPerFileField, (n) => typeof n.text === 'string'), + ...collectNodes(gzipField, (n) => typeof n.text === 'string'), + ].map((n) => n.text); + + expect(labels.length).toBeGreaterThan(0); + labels.forEach((text) => expect(text.startsWith('$t:')).toBe(true)); + }); + + it('수동 재생성 버튼이 재생성 엔드포인트를 호출한다', () => { + const apiCalls = collectNodes( + seoTab, + (n) => n.handler === 'apiCall' && n.target === '/api/admin/seo/sitemap/regenerate' + ); + + expect(apiCalls.length).toBe(1); + expect(apiCalls[0].params.method).toBe('POST'); + }); +}); + +describe('환경설정 > 고급 탭 > Sitemap 캐시 기준값 칸', () => { + it('Sitemap 캐시 칸이 advanced.seo_sitemap_cache_ttl 이름으로 존재한다', () => { + const field = findFieldByName(advancedTab, 'advanced.seo_sitemap_cache_ttl'); + + expect(field).toBeDefined(); + expect(field.name).toBe('Input'); + expect(field.props.type).toBe('number'); + }); + + it('Sitemap 캐시 칸의 허용 범위가 SEO 탭 오버라이드 칸과 동일하다', () => { + const advancedField = findFieldByName(advancedTab, 'advanced.seo_sitemap_cache_ttl'); + const seoOverrideField = findFieldByName(seoTab, 'seo.sitemap_cache_ttl'); + + expect(Number(advancedField.props.min)).toBe(Number(seoOverrideField.props.min)); + expect(Number(advancedField.props.max)).toBe(Number(seoOverrideField.props.max)); + }); + + it('Sitemap 캐시 칸이 읽기 전용 상태와 검증 오류를 형제 칸과 동일하게 반영한다', () => { + const field = findFieldByName(advancedTab, 'advanced.seo_sitemap_cache_ttl'); + const sibling = findFieldByName(advancedTab, 'advanced.seo_cache_ttl'); + + expect(field.props.disabled).toBe(sibling.props.disabled); + expect(field.props.className).toContain("_local.errors?.['advanced.seo_sitemap_cache_ttl']"); + + const errorNode = collectNodes( + advancedTab, + (n) => + typeof n.if === 'string' && + n.if.includes("_local.errors?.['advanced.seo_sitemap_cache_ttl']") + )[0]; + expect(errorNode).toBeDefined(); + }); +}); diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json index 4ea72464..60a9968b 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json @@ -1423,9 +1423,13 @@ "sitemap_cache_ttl_hint": "Leave empty to follow the sitemap cache TTL in Advanced settings. Enter a value to override it here — min 3600s (1 hour), max 604800s (7 days)", "sitemap_last_updated_at": "Last generated", "sitemap_manual_update": "Generate now", - "sitemap_updating": "Generating...", - "sitemap_update_success": "Sitemap generation completed.", - "sitemap_update_failed": "Sitemap generation failed." + "sitemap_updating": "Starting...", + "sitemap_update_success": "Sitemap regeneration has started. It may take a while to finish.", + "sitemap_update_failed": "Failed to start sitemap regeneration.", + "sitemap_urls_per_file": "URLs per sitemap file", + "sitemap_urls_per_file_hint": "When URLs exceed this number, the sitemap is split into multiple files and an index file is created — minimum 1000, maximum 50000 (search engine limit)", + "sitemap_gzip": "Sitemap compression (gzip)", + "sitemap_gzip_desc": "Compresses split sitemap files to reduce transfer size. The index file is not compressed." }, "security": { "title": "Security Settings", @@ -1732,7 +1736,9 @@ "geoip_manual_update": "Update now", "geoip_updating": "Updating...", "geoip_update_success": "GeoIP DB update completed.", - "geoip_update_failed": "GeoIP DB update failed." + "geoip_update_failed": "GeoIP DB update failed.", + "seo_sitemap_cache": "Sitemap cache", + "seo_sitemap_cache_desc": "Caches sitemap.xml." }, "notification_definitions": { "title": "Notification Settings", @@ -2731,4 +2737,4 @@ "license_modal": "Core License", "notification_delete_all_confirm_modal": "Delete All Notifications" } -} \ No newline at end of file +} diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json index fff2e289..1557fb68 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json @@ -1427,9 +1427,13 @@ "sitemap_cache_ttl_hint": "비워두면 고급 설정의 Sitemap 캐시 시간을 따릅니다. 값을 넣으면 이 화면의 값이 우선합니다 — 최소 3600초 (1시간), 최대 604800초 (7일)", "sitemap_last_updated_at": "마지막 생성", "sitemap_manual_update": "지금 생성", - "sitemap_updating": "생성 중...", - "sitemap_update_success": "Sitemap 생성이 완료되었습니다.", - "sitemap_update_failed": "Sitemap 생성에 실패했습니다." + "sitemap_updating": "시작하는 중...", + "sitemap_update_success": "Sitemap 재생성을 시작했습니다. 완료까지 시간이 걸릴 수 있습니다.", + "sitemap_update_failed": "Sitemap 재생성을 시작하지 못했습니다.", + "sitemap_urls_per_file": "Sitemap 파일당 URL 수", + "sitemap_urls_per_file_hint": "URL 이 이 수를 넘으면 sitemap 파일이 여러 개로 나뉘고 색인 파일이 함께 만들어집니다 — 최소 1000개, 최대 50000개 (검색엔진 규격 상한)", + "sitemap_gzip": "Sitemap 압축 (gzip)", + "sitemap_gzip_desc": "나뉜 sitemap 파일을 압축해 전송량을 줄입니다. 색인 파일은 압축하지 않습니다." }, "security": { "title": "보안 설정", @@ -1736,7 +1740,9 @@ "geoip_manual_update": "지금 업데이트", "geoip_updating": "업데이트 중...", "geoip_update_success": "GeoIP DB 업데이트가 완료되었습니다.", - "geoip_update_failed": "GeoIP DB 업데이트에 실패했습니다." + "geoip_update_failed": "GeoIP DB 업데이트에 실패했습니다.", + "seo_sitemap_cache": "Sitemap 캐시", + "seo_sitemap_cache_desc": "sitemap.xml 을 캐시합니다." }, "notification_definitions": { "title": "알림 설정", @@ -2735,4 +2741,4 @@ "license_modal": "코어 라이선스", "notification_delete_all_confirm_modal": "알림 전체 삭제 확인" } -} \ No newline at end of file +} diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_advanced.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_advanced.json index 7e52590c..2ba6e7c8 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_advanced.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_advanced.json @@ -570,6 +570,124 @@ ] } ] + }, + { + "id": "cache_seo_sitemap", + "type": "basic", + "name": "Div", + "props": { + "className": "flex-between gap-4" + }, + "responsive": { + "portable": { + "props": { + "className": "row-stack py-2" + } + } + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-heading" + }, + "text": "$t:admin.settings.advanced.seo_sitemap_cache" + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-red-500 ml-1" + }, + "text": "*" + } + ] + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-tertiary" + }, + "text": "$t:admin.settings.advanced.seo_sitemap_cache_desc" + } + ] + }, + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex-center" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-label whitespace-nowrap" + }, + "text": "$t:admin.settings.advanced.cache_ttl" + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-red-500 ml-1" + }, + "text": "*" + } + ] + }, + { + "type": "basic", + "name": "Input", + "props": { + "type": "number", + "name": "advanced.seo_sitemap_cache_ttl", + "placeholder": "86400", + "min": "3600", + "max": "604800", + "disabled": "{{_computed.isReadOnly}}", + "className": "input input-w-xs {{_local.errors?.['advanced.seo_sitemap_cache_ttl'] ? 'input-error' : ''}}" + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "if": "{{_local.errors?.['advanced.seo_sitemap_cache_ttl']}}", + "props": { + "className": "form-error" + }, + "text": "{{_local.errors?.['advanced.seo_sitemap_cache_ttl']?.[0] ?? ''}}" + } + ] + } + ] } ] } diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json index 3a795c7f..9fb2daae 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json @@ -963,6 +963,81 @@ } ] }, + { + "id": "field_sitemap_urls_per_file", + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Label", + "props": { + "className": "form-label" + }, + "text": "$t:admin.settings.seo.sitemap_urls_per_file" + }, + { + "type": "basic", + "name": "Input", + "props": { + "type": "number", + "name": "seo.sitemap_urls_per_file", + "min": 1000, + "max": 50000, + "placeholder": "50000", + "className": "" + } + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "form-hint" + }, + "text": "$t:admin.settings.seo.sitemap_urls_per_file_hint" + } + ] + }, + { + "id": "field_sitemap_gzip", + "type": "basic", + "name": "Div", + "props": { + "className": "flex-between" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-heading" + }, + "text": "$t:admin.settings.seo.sitemap_gzip" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-label-subtle" + }, + "text": "$t:admin.settings.seo.sitemap_gzip_desc" + } + ] + }, + { + "type": "composite", + "name": "Toggle", + "props": { + "name": "seo.sitemap_gzip", + "size": "md" + } + } + ] + }, { "id": "sitemap_last_updated_block", "type": "basic", diff --git a/templates/_bundled/sirsoft-admin_basic/package-lock.json b/templates/_bundled/sirsoft-admin_basic/package-lock.json index 90d702e5..fa20a761 100644 --- a/templates/_bundled/sirsoft-admin_basic/package-lock.json +++ b/templates/_bundled/sirsoft-admin_basic/package-lock.json @@ -1,12 +1,12 @@ { "name": "sirsoft-admin_basic", - "version": "1.0.3", + "version": "1.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sirsoft-admin_basic", - "version": "1.0.3", + "version": "1.0.4", "license": "MIT", "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/templates/_bundled/sirsoft-admin_basic/package.json b/templates/_bundled/sirsoft-admin_basic/package.json index f392b87f..9895f6fe 100644 --- a/templates/_bundled/sirsoft-admin_basic/package.json +++ b/templates/_bundled/sirsoft-admin_basic/package.json @@ -1,6 +1,6 @@ { "name": "sirsoft-admin_basic", - "version": "1.0.3", + "version": "1.0.4", "description": "Gnuboard7 Basic Admin Template Components", "type": "module", "main": "dist/components.js", diff --git a/templates/_bundled/sirsoft-admin_basic/template.json b/templates/_bundled/sirsoft-admin_basic/template.json index a2911aaf..c4a89e5e 100644 --- a/templates/_bundled/sirsoft-admin_basic/template.json +++ b/templates/_bundled/sirsoft-admin_basic/template.json @@ -5,7 +5,7 @@ "ko": "Admin Basic", "en": "Admin Basic" }, - "version": "1.0.3", + "version": "1.0.4", "license": "MIT", "description": { "ko": "그누보드7 기본 관리자 템플릿", diff --git a/tests/Feature/Api/Admin/SeoCacheControllerTest.php b/tests/Feature/Api/Admin/SeoCacheControllerTest.php index a578cedd..3720cf6d 100644 --- a/tests/Feature/Api/Admin/SeoCacheControllerTest.php +++ b/tests/Feature/Api/Admin/SeoCacheControllerTest.php @@ -3,6 +3,7 @@ namespace Tests\Feature\Api\Admin; use App\Enums\ExtensionOwnerType; +use App\Jobs\GenerateSitemapJob; use App\Models\Permission; use App\Models\Role; use App\Models\SeoCacheStat; @@ -10,7 +11,9 @@ use App\Models\User; use App\Seo\Contracts\SeoCacheManagerInterface; use App\Seo\SitemapManager; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Queue; use Mockery; use Tests\TestCase; @@ -389,76 +392,72 @@ class SeoCacheControllerTest extends TestCase } /** - * 정상 재생성 시 200 + last_updated_at 데이터 반환 + * 재생성 요청 시 200 + 예약 상태와 직전 생성 시각을 반환 */ - public function test_regenerate_sitemap_returns_200_with_data(): void + public function test_regenerate_sitemap_returns_200_with_dispatched_status(): void { - $mock = Mockery::mock(SitemapManager::class); - $mock->shouldReceive('regenerate') - ->once() - ->andReturn([ - 'success' => true, - 'status' => 'updated', - 'message' => 'Sitemap 생성이 완료되었습니다.', - 'data' => [ - 'last_updated_at' => '2026-04-29T10:00:00+00:00', - 'size_bytes' => 1234, - 'ttl' => 86400, - ], - ]); - $this->app->instance(SitemapManager::class, $mock); + Queue::fake(); + Config::set('g7_settings.core.seo.sitemap_enabled', true); + Config::set('g7_settings.core.seo.sitemap_last_updated_at', '2026-04-29T10:00:00+00:00'); $response = $this->withHeader('Authorization', 'Bearer '.$this->token) ->postJson('/api/admin/seo/sitemap/regenerate'); $response->assertStatus(200); $this->assertTrue($response->json('success')); + $this->assertSame('dispatched', $response->json('data.status')); $this->assertSame('2026-04-29T10:00:00+00:00', $response->json('data.last_updated_at')); - $this->assertSame(1234, $response->json('data.size_bytes')); } /** - * Sitemap 비활성 상태 시 400 반환 + * 재생성 요청이 큐 잡을 1건만 디스패치하는지 확인 + */ + public function test_regenerate_sitemap_dispatches_job(): void + { + Queue::fake(); + Config::set('g7_settings.core.seo.sitemap_enabled', true); + + $this->withHeader('Authorization', 'Bearer '.$this->token) + ->postJson('/api/admin/seo/sitemap/regenerate') + ->assertStatus(200); + + Queue::assertPushed(GenerateSitemapJob::class, 1); + } + + /** + * 재생성 요청이 요청 스레드에서 동기 생성하지 않는지 확인 + * + * 이 회귀가 무너지면 1.4M 규모에서 관리자 워커가 OOM 으로 죽습니다. + */ + public function test_regenerate_sitemap_does_not_generate_inline(): void + { + Queue::fake(); + Config::set('g7_settings.core.seo.sitemap_enabled', true); + + $mock = Mockery::mock(SitemapManager::class); + $mock->shouldNotReceive('regenerate'); + $mock->shouldReceive('getStatus')->andReturn(['last_updated_at' => null]); + $this->app->instance(SitemapManager::class, $mock); + + $this->withHeader('Authorization', 'Bearer '.$this->token) + ->postJson('/api/admin/seo/sitemap/regenerate') + ->assertStatus(200); + } + + /** + * Sitemap 비활성 상태 시 400 반환 + 잡 미디스패치 */ public function test_regenerate_sitemap_returns_400_when_disabled(): void { - $mock = Mockery::mock(SitemapManager::class); - $mock->shouldReceive('regenerate') - ->once() - ->andReturn([ - 'success' => false, - 'status' => 'disabled', - 'message' => 'Sitemap 생성이 비활성화되어 있습니다.', - ]); - $this->app->instance(SitemapManager::class, $mock); + Queue::fake(); + Config::set('g7_settings.core.seo.sitemap_enabled', false); $response = $this->withHeader('Authorization', 'Bearer '.$this->token) ->postJson('/api/admin/seo/sitemap/regenerate'); $response->assertStatus(400); $this->assertFalse($response->json('success')); - } - - /** - * 내부 실패 시 500 반환 - */ - public function test_regenerate_sitemap_returns_500_on_failure(): void - { - $mock = Mockery::mock(SitemapManager::class); - $mock->shouldReceive('regenerate') - ->once() - ->andReturn([ - 'success' => false, - 'status' => 'failed', - 'message' => 'Sitemap 생성에 실패했습니다: contributor crashed', - ]); - $this->app->instance(SitemapManager::class, $mock); - - $response = $this->withHeader('Authorization', 'Bearer '.$this->token) - ->postJson('/api/admin/seo/sitemap/regenerate'); - - $response->assertStatus(500); - $this->assertFalse($response->json('success')); + Queue::assertNotPushed(GenerateSitemapJob::class); } protected function tearDown(): void diff --git a/tests/Feature/Seo/SaveSettingsRequestSeoTest.php b/tests/Feature/Seo/SaveSettingsRequestSeoTest.php index 265e8e68..806f61e0 100644 --- a/tests/Feature/Seo/SaveSettingsRequestSeoTest.php +++ b/tests/Feature/Seo/SaveSettingsRequestSeoTest.php @@ -2,12 +2,14 @@ namespace Tests\Feature\Seo; +use App\Contracts\Repositories\ConfigRepositoryInterface; use App\Enums\ExtensionOwnerType; use App\Models\Permission; use App\Models\Role; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Hash; +use Illuminate\Testing\TestResponse; use Tests\TestCase; /** @@ -35,6 +37,20 @@ class SaveSettingsRequestSeoTest extends TestCase $this->token = $this->admin->createToken('test-token')->plainTextToken; } + /** + * 저장된 설정 카테고리를 디스크에서 다시 읽습니다. + * + * g7_core_settings() 는 부팅 시점에 로드된 Config 를 보므로, 같은 프로세스에서 + * 저장 직후 값을 확인할 때는 저장소를 직접 읽어야 합니다. + * + * @param string $category 설정 카테고리 + * @return array 저장된 설정 배열 + */ + private function storedSettings(string $category): array + { + return app(ConfigRepositoryInterface::class)->getCategory($category); + } + /** * 관리자 역할 및 권한을 가진 사용자를 생성합니다. * @@ -99,7 +115,7 @@ class SaveSettingsRequestSeoTest extends TestCase * * @param array $seoData SEO 필드 데이터 */ - private function postSeoSettings(array $seoData): \Illuminate\Testing\TestResponse + private function postSeoSettings(array $seoData): TestResponse { return $this->actingAs($this->admin) ->postJson('/api/admin/settings', [ @@ -385,4 +401,239 @@ class SaveSettingsRequestSeoTest extends TestCase $response->assertStatus(422) ->assertJsonValidationErrors(['seo.generator_content']); } + + // ======================================== + // sitemap 분할/압축/서빙 설정 검증 테스트 + // ======================================== + + /** + * sitemap 분할/압축/서빙 설정이 저장되어 설정값에 반영되는지 확인 + */ + public function test_sitemap_generation_settings_are_saved(): void + { + $response = $this->postSeoSettings([ + 'sitemap_urls_per_file' => 20000, + 'sitemap_gzip' => true, + 'sitemap_serve_stale_on_miss' => false, + 'sitemap_max_urls_per_contributor' => 100000, + ]); + + $response->assertStatus(200); + + $seo = $this->storedSettings('seo'); + $this->assertSame(20000, $seo['sitemap_urls_per_file']); + $this->assertTrue((bool) $seo['sitemap_gzip']); + $this->assertFalse((bool) $seo['sitemap_serve_stale_on_miss']); + $this->assertSame(100000, $seo['sitemap_max_urls_per_contributor']); + } + + /** + * 파일당 URL 수 상한(50000)을 넘기면 422 응답 + * + * 50000 은 sitemaps.org 프로토콜 상한이므로 저장 자체를 막습니다. + */ + public function test_sitemap_urls_per_file_exceeding_protocol_limit_fails(): void + { + $response = $this->postSeoSettings([ + 'sitemap_urls_per_file' => 50001, + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['seo.sitemap_urls_per_file']); + } + + /** + * 파일당 URL 수 하한(1000) 미만이면 422 응답 + */ + public function test_sitemap_urls_per_file_below_minimum_fails(): void + { + $response = $this->postSeoSettings([ + 'sitemap_urls_per_file' => 999, + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['seo.sitemap_urls_per_file']); + } + + /** + * 파일당 URL 수 경계값(1000/50000)은 통과하는지 확인 + */ + public function test_sitemap_urls_per_file_boundary_values_pass(): void + { + $this->postSeoSettings(['sitemap_urls_per_file' => 1000])->assertStatus(200); + $this->postSeoSettings(['sitemap_urls_per_file' => 50000])->assertStatus(200); + } + + /** + * 수집기당 최대 URL 수는 0(무제한)을 허용하고 음수는 거부하는지 확인 + */ + public function test_sitemap_max_urls_per_contributor_allows_zero_and_rejects_negative(): void + { + $this->postSeoSettings(['sitemap_max_urls_per_contributor' => 0])->assertStatus(200); + + $this->postSeoSettings(['sitemap_max_urls_per_contributor' => -1]) + ->assertStatus(422) + ->assertJsonValidationErrors(['seo.sitemap_max_urls_per_contributor']); + } + + /** + * gzip 이 boolean 이 아니면 422 응답 + */ + public function test_sitemap_gzip_non_boolean_fails_validation(): void + { + $response = $this->postSeoSettings([ + 'sitemap_gzip' => 'yes-please', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['seo.sitemap_gzip']); + } + + // ======================================== + // 고급 탭 Sitemap 캐시 기준값 (D19 의 메인 값) + // ======================================== + + /** + * 고급 탭 설정 저장 요청을 전송합니다. + * + * @param array $advancedData 고급 필드 데이터 + */ + private function postAdvancedSettings(array $advancedData): TestResponse + { + return $this->actingAs($this->admin) + ->postJson('/api/admin/settings', [ + '_tab' => 'advanced', + 'advanced' => $advancedData, + ]); + } + + /** + * 고급 탭의 Sitemap 캐시 기준값이 저장되어 cache 카테고리에 반영되는지 확인 + */ + public function test_advanced_sitemap_cache_ttl_is_saved_to_cache_category(): void + { + $response = $this->postAdvancedSettings([ + 'cache_enabled' => true, + 'layout_cache_enabled' => true, + 'layout_cache_ttl' => 3600, + 'stats_cache_enabled' => true, + 'stats_cache_ttl' => 3600, + 'seo_cache_enabled' => true, + 'seo_cache_ttl' => 3600, + 'seo_sitemap_cache_ttl' => 43200, + 'debug_mode' => false, + 'sql_query_log' => false, + ]); + + $response->assertStatus(200); + $this->assertSame(43200, $this->storedSettings('cache')['seo_sitemap_ttl']); + } + + /** + * 고급 탭 Sitemap 캐시 기준값이 기본값(86400)을 허용하는지 확인 + * + * 형제 캐시 TTL 규칙(최대 14400)을 그대로 재사용하면 화면 기본값조차 저장할 수 없게 됩니다. + */ + public function test_advanced_sitemap_cache_ttl_accepts_default_value(): void + { + $this->postAdvancedSettings([ + 'cache_enabled' => true, + 'layout_cache_enabled' => true, + 'layout_cache_ttl' => 3600, + 'stats_cache_enabled' => true, + 'stats_cache_ttl' => 3600, + 'seo_cache_enabled' => true, + 'seo_cache_ttl' => 3600, + 'seo_sitemap_cache_ttl' => 86400, + 'debug_mode' => false, + 'sql_query_log' => false, + ])->assertStatus(200); + + $this->assertSame(86400, $this->storedSettings('cache')['seo_sitemap_ttl']); + } + + /** + * 고급 탭 Sitemap 캐시 기준값의 허용 범위를 벗어나면 422 응답 + */ + public function test_advanced_sitemap_cache_ttl_out_of_range_fails(): void + { + $this->postAdvancedSettings(['seo_sitemap_cache_ttl' => 604801]) + ->assertStatus(422) + ->assertJsonValidationErrors(['advanced.seo_sitemap_cache_ttl']); + + $this->postAdvancedSettings(['seo_sitemap_cache_ttl' => 3599]) + ->assertStatus(422) + ->assertJsonValidationErrors(['advanced.seo_sitemap_cache_ttl']); + } + + /** + * 신규 키를 보내지 않는 기존 클라이언트의 고급 탭 저장이 계속 동작하는지 확인 + * + * 형제 키처럼 required 로 두면 이전 화면/외부 클라이언트의 저장이 422 로 깨집니다. + */ + public function test_advanced_tab_saves_without_new_sitemap_cache_key(): void + { + $this->postAdvancedSettings([ + 'cache_enabled' => true, + 'layout_cache_enabled' => true, + 'layout_cache_ttl' => 3600, + 'stats_cache_enabled' => true, + 'stats_cache_ttl' => 3600, + 'seo_cache_enabled' => true, + 'seo_cache_ttl' => 3600, + 'debug_mode' => false, + 'sql_query_log' => false, + ])->assertStatus(200); + } + + /** + * 두 탭의 SEO 캐시 TTL 안내 문구가 각자의 실제 한도를 알려주는지 확인 + * + * 두 탭이 같은 다국어 키를 공유하면 PHP 배열의 나중 정의가 이겨서 + * 한쪽 탭이 반드시 틀린 한도를 안내하게 됩니다(ko/en 이 서로 반대로 틀어졌던 사례). + */ + public function test_seo_cache_ttl_messages_state_each_tabs_own_limit(): void + { + $seoTabErrors = $this->postSeoSettings(['cache_ttl' => 86401]) + ->assertStatus(422) + ->json('errors'); + + $advancedTabErrors = $this->postAdvancedSettings(['seo_cache_ttl' => 14401]) + ->assertStatus(422) + ->json('errors'); + + $this->assertStringContainsString('86400', $seoTabErrors['seo.cache_ttl'][0]); + $this->assertStringContainsString('14400', $advancedTabErrors['advanced.seo_cache_ttl'][0]); + } + + // ======================================== + // 캐시 오버라이드 칸 비우기 (D19) + // ======================================== + + /** + * 캐시 오버라이드 칸을 비워서 저장하면 null(미설정)로 정규화되는지 확인 + * + * 폼에서 칸을 비우면 빈 문자열로 전송될 수 있는데, 이것이 null 로 정규화되지 않으면 + * "미설정" 으로 판정되지 않아 고급 설정 값을 따르지 못하고 오버라이드가 계속 발동합니다. + */ + public function test_emptied_cache_override_fields_are_normalized_to_null(): void + { + $this->postSeoSettings([ + 'cache_ttl' => 3600, + 'sitemap_cache_ttl' => 7200, + ])->assertStatus(200); + + $seo = $this->storedSettings('seo'); + $this->assertSame(3600, $seo['cache_ttl']); + $this->assertSame(7200, $seo['sitemap_cache_ttl']); + + $this->postSeoSettings([ + 'cache_ttl' => '', + 'sitemap_cache_ttl' => '', + ])->assertStatus(200); + + $seo = $this->storedSettings('seo'); + $this->assertNull($seo['cache_ttl']); + $this->assertNull($seo['sitemap_cache_ttl']); + } } diff --git a/tests/Feature/Seo/SitemapTest.php b/tests/Feature/Seo/SitemapTest.php index 9e6b5647..3715d627 100644 --- a/tests/Feature/Seo/SitemapTest.php +++ b/tests/Feature/Seo/SitemapTest.php @@ -2,169 +2,277 @@ namespace Tests\Feature\Seo; -use App\Seo\SitemapGenerator; +use App\Extension\Storage\CoreStorageDriver; +use App\Jobs\GenerateSitemapJob; +use App\Seo\SitemapFileStore; +use App\Seo\SitemapManager; +use App\Seo\SitemapWriter; +use App\Seo\SitemapXmlRenderer; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Config; -use Mockery; +use Illuminate\Support\Facades\Queue; +use Illuminate\Support\Facades\Storage; +use Illuminate\Testing\TestResponse; use Tests\TestCase; /** * Sitemap XML 엔드포인트 기능 테스트 * - * GET /sitemap.xml 라우트가 올바른 XML 응답을 반환하고, - * 캐싱/비활성화 설정이 올바르게 동작하는지 검증합니다. + * 봇 요청 스레드에서 sitemap 을 생성하지 않고(대용량 OOM 원인), + * 비공개 디스크에 커밋된 세트를 스트리밍 서빙하는 계약을 검증합니다. + * 신선도가 만료되면 생성은 큐에 맡기고 기존 세트(stale)를 내보냅니다. */ class SitemapTest extends TestCase { + private CoreStorageDriver $storage; + /** - * 테스트 종료 - Mockery 리소스를 정리합니다. + * 테스트 초기화 - 가짜 비공개 디스크와 활성화 설정을 준비합니다. */ - protected function tearDown(): void + protected function setUp(): void { - Mockery::close(); - parent::tearDown(); + parent::setUp(); + + Config::set('app.locale', 'ko'); + Config::set('app.supported_locales', ['ko']); + Config::set('g7_settings.core.seo.sitemap_enabled', true); + + Storage::fake('local'); + + $this->storage = new CoreStorageDriver('local'); + $this->app->instance(SitemapFileStore::class, new SitemapFileStore($this->storage)); + + Cache::forget('g7:core:'.SitemapManager::META_CACHE_KEY); } /** - * sitemap.xml 요청이 application/xml Content-Type을 반환하는지 확인합니다. + * 커밋된 sitemap 세트를 디스크에 만듭니다. + * + * @param int $urlCount 생성할 URL 수 + * @param int $urlsPerFile 파일당 URL 수 + * @param bool $gzip gzip 압축 여부 + * @return array 커밋 결과 메타 */ - public function test_sitemap_returns_xml_content_type(): void + private function commitSet(int $urlCount = 3, int $urlsPerFile = 50000, bool $gzip = false): array { - // sitemap 활성화 설정 - Config::set('g7_settings.core.seo.sitemap_enabled', true); + $writer = new SitemapWriter( + $this->storage, + SitemapXmlRenderer::fromConfig(), + $urlsPerFile, + $gzip, + ); - // 캐시에 XML 미리 저장하여 Generator 호출 방지 - $sampleXml = ''; - Cache::put('g7:core:seo.sitemap', $sampleXml, 86400); + $writer->open(); + for ($i = 1; $i <= $urlCount; $i++) { + $writer->addUrl(['loc' => url("/page/{$i}")]); + } + $writer->close(); + + return $writer->commit(); + } + + /** + * 메타 캐시를 신선한 상태로 만듭니다. + * + * @param array $meta 커밋 메타 + */ + private function markFresh(array $meta): void + { + Cache::put('g7:core:'.SitemapManager::META_CACHE_KEY, $meta, 86400); + } + + /** + * 응답 본문을 문자열로 수집합니다. + * + * @param TestResponse $response 테스트 응답 + * @return string 응답 본문 + */ + private function streamBody($response): string + { + ob_start(); + $response->baseResponse->sendContent(); + + return (string) ob_get_clean(); + } + + /** + * 신선한 세트가 있으면 디스크의 sitemapindex 를 XML 로 서빙하는지 확인합니다. + */ + public function test_index_is_served_from_disk_when_fresh(): void + { + $meta = $this->commitSet(); + $this->markFresh($meta); $response = $this->get('/sitemap.xml'); $response->assertStatus(200); $response->assertHeader('Content-Type', 'application/xml'); + + $body = $this->streamBody($response); + $this->assertStringContainsString('assertStringContainsString('/sitemap-1.xml', $body); } /** - * sitemap_enabled가 false일 때 404를 반환하는지 확인합니다. + * 신선한 세트를 서빙할 때는 생성 잡을 디스패치하지 않는지 확인합니다. */ - public function test_sitemap_returns_404_when_disabled(): void + public function test_index_does_not_dispatch_job_when_fresh(): void { + Queue::fake(); + + $meta = $this->commitSet(); + $this->markFresh($meta); + + $this->get('/sitemap.xml')->assertStatus(200); + + Queue::assertNotPushed(GenerateSitemapJob::class); + } + + /** + * 메타 캐시가 만료되면 봇 요청 스레드에서 생성하지 않고 잡만 디스패치하는지 확인합니다. + * + * 이 회귀가 무너지면 1.4M 규모에서 봇 요청이 OOM/타임아웃을 유발합니다. + */ + public function test_index_dispatches_job_and_serves_stale_on_cache_miss(): void + { + Queue::fake(); + + $this->commitSet(); + // 메타 캐시 없음 = 신선도 만료 상태 + + $response = $this->get('/sitemap.xml'); + + $response->assertStatus(200); + $this->assertStringContainsString('streamBody($response)); + + Queue::assertPushed(GenerateSitemapJob::class, 1); + } + + /** + * serve_stale_on_miss 가 false 면 만료 시 stale 을 내보내지 않고 503 을 반환하는지 확인합니다. + */ + public function test_index_returns_503_on_miss_when_stale_serving_disabled(): void + { + Queue::fake(); + Config::set('g7_settings.core.seo.sitemap_serve_stale_on_miss', false); + + $this->commitSet(); + + $response = $this->get('/sitemap.xml'); + + $response->assertStatus(503); + $response->assertHeader('Retry-After', '120'); + Queue::assertPushed(GenerateSitemapJob::class, 1); + } + + /** + * 커밋된 세트가 전혀 없으면 503 + Retry-After 를 반환하는지 확인합니다. + */ + public function test_index_returns_503_with_retry_after_when_no_set_exists(): void + { + Queue::fake(); + + $response = $this->get('/sitemap.xml'); + + $response->assertStatus(503); + $response->assertHeader('Retry-After', '120'); + Queue::assertPushed(GenerateSitemapJob::class, 1); + } + + /** + * 자식 sitemap 라우트가 디스크 파일을 스트리밍하는지 확인합니다. + */ + public function test_child_route_streams_child_sitemap(): void + { + $this->commitSet(urlCount: 3, urlsPerFile: 2); + + $response = $this->get('/sitemap-1.xml'); + + $response->assertStatus(200); + $body = $this->streamBody($response); + $this->assertStringContainsString('assertStringContainsString('/page/1', $body); + } + + /** + * 인덱스가 가리키는 모든 자식 경로가 실제로 서빙되는지 확인합니다. + * + * 인덱스의 규칙(SitemapFileStore::childUrl)과 라우트 경로가 어긋나면 + * 검색엔진에 404 를 가리키는 인덱스를 내보내게 됩니다. + */ + public function test_index_child_locs_are_all_routable(): void + { + $meta = $this->commitSet(urlCount: 5, urlsPerFile: 2); + + $this->assertSame(3, $meta['child_count']); + + for ($n = 1; $n <= $meta['child_count']; $n++) { + $this->get(SitemapFileStore::childUrl($n))->assertStatus(200); + } + } + + /** + * 존재하지 않는 자식 번호는 404 를 반환하는지 확인합니다. + */ + public function test_child_route_returns_404_for_unknown_child(): void + { + $this->commitSet(urlCount: 1, urlsPerFile: 50000); + + $this->get('/sitemap-99.xml')->assertStatus(404); + } + + /** + * gzip 세트는 .xml.gz 경로에서 Content-Encoding 과 함께 서빙되는지 확인합니다. + */ + public function test_child_route_serves_gzip_set(): void + { + $this->commitSet(urlCount: 2, urlsPerFile: 50000, gzip: true); + + $response = $this->get('/sitemap-1.xml.gz'); + + $response->assertStatus(200); + $response->assertHeader('Content-Encoding', 'gzip'); + $this->assertStringContainsString('streamBody($response))); + } + + /** + * sitemap_enabled 가 false 이면 인덱스/자식 모두 404 를 반환하는지 확인합니다. + */ + public function test_routes_return_404_when_disabled(): void + { + $meta = $this->commitSet(); + $this->markFresh($meta); + Config::set('g7_settings.core.seo.sitemap_enabled', false); - $response = $this->get('/sitemap.xml'); - - $response->assertStatus(404); + $this->get('/sitemap.xml')->assertStatus(404); + $this->get('/sitemap-1.xml')->assertStatus(404); } /** - * 캐시에 저장된 XML이 있으면 해당 내용을 반환하는지 확인합니다. + * sitemap_enabled 가 null 이면 (bool) 캐스팅으로 비활성 처리되는지 확인합니다. */ - public function test_sitemap_returns_cached_content(): void - { - Config::set('g7_settings.core.seo.sitemap_enabled', true); - - $cachedXml = '' - .'' - .'https://example.com/cached' - .''; - - Cache::put('g7:core:seo.sitemap', $cachedXml, 86400); - - $response = $this->get('/sitemap.xml'); - - $response->assertStatus(200); - $response->assertSee('https://example.com/cached'); - $this->assertSame($cachedXml, $response->getContent()); - } - - /** - * 캐시가 없을 때 SitemapGenerator::generate()를 호출하고 - * 결과를 캐시에 저장하는지 확인합니다. - */ - public function test_sitemap_generates_and_caches_when_not_cached(): void - { - Config::set('g7_settings.core.seo.sitemap_enabled', true); - Config::set('g7_settings.core.seo.sitemap_cache_ttl', 3600); - - // 캐시 비우기 - Cache::forget('g7:core:seo.sitemap'); - - $generatedXml = '' - .'' - .'https://example.com/generated' - .''; - - // SitemapGenerator Mock - $generatorMock = Mockery::mock(SitemapGenerator::class); - $generatorMock->shouldReceive('generate') - ->once() - ->andReturn($generatedXml); - - // 컨테이너에 Mock 바인딩 - $this->app->instance(SitemapGenerator::class, $generatorMock); - - $response = $this->get('/sitemap.xml'); - - $response->assertStatus(200); - $response->assertHeader('Content-Type', 'application/xml'); - $this->assertSame($generatedXml, $response->getContent()); - - // 캐시에 저장되었는지 확인 - $this->assertSame($generatedXml, Cache::get('g7:core:seo.sitemap')); - } - - /** - * sitemap_enabled가 null로 설정되면 (bool) 캐스팅에 의해 비활성으로 처리되는지 확인합니다. - * - * Config에 키가 null로 존재하면 Config::get()이 null을 반환하고, - * (bool) null = false이므로 404가 됩니다. - */ - public function test_sitemap_returns_404_when_enabled_is_null(): void + public function test_index_returns_404_when_enabled_is_null(): void { Config::set('g7_settings.core.seo.sitemap_enabled', null); - $response = $this->get('/sitemap.xml'); - - $response->assertStatus(404); + $this->get('/sitemap.xml')->assertStatus(404); } /** - * sitemap_enabled가 true일 때 정상 동작하는지 확인합니다. + * 서빙된 인덱스/자식이 유효한 XML 인지 확인합니다. */ - public function test_sitemap_works_when_explicitly_enabled(): void + public function test_served_documents_are_valid_xml(): void { - Config::set('g7_settings.core.seo.sitemap_enabled', true); + $meta = $this->commitSet(urlCount: 3, urlsPerFile: 2); + $this->markFresh($meta); - $sampleXml = ''; - Cache::put('g7:core:seo.sitemap', $sampleXml, 86400); + $index = simplexml_load_string($this->streamBody($this->get('/sitemap.xml'))); + $this->assertNotFalse($index, 'sitemapindex 응답이 유효한 XML 이어야 합니다'); + $this->assertSame('sitemapindex', $index->getName()); - $response = $this->get('/sitemap.xml'); - - $response->assertStatus(200); - } - - /** - * sitemap XML 응답 본문이 XML 형식인지 확인합니다. - */ - public function test_sitemap_response_body_is_valid_xml(): void - { - Config::set('g7_settings.core.seo.sitemap_enabled', true); - - $validXml = ''."\n" - .''."\n" - .' '."\n" - .' https://example.com/'."\n" - .' '."\n" - .''; - - Cache::put('g7:core:seo.sitemap', $validXml, 86400); - - $response = $this->get('/sitemap.xml'); - - $response->assertStatus(200); - - // XML 파싱이 성공하는지 확인 - $xml = simplexml_load_string($response->getContent()); - $this->assertNotFalse($xml, 'sitemap 응답이 유효한 XML이어야 합니다'); - $this->assertSame('urlset', $xml->getName()); + $child = simplexml_load_string($this->streamBody($this->get('/sitemap-1.xml'))); + $this->assertNotFalse($child, '자식 sitemap 응답이 유효한 XML 이어야 합니다'); + $this->assertSame('urlset', $child->getName()); } } diff --git a/tests/Feature/Settings/SettingsDiskIsolationTest.php b/tests/Feature/Settings/SettingsDiskIsolationTest.php new file mode 100644 index 00000000..cd79423b --- /dev/null +++ b/tests/Feature/Settings/SettingsDiskIsolationTest.php @@ -0,0 +1,69 @@ +path('')); + + $this->assertStringContainsString( + 'framework/testing', + $root, + 'settings 디스크가 페이크로 대체되지 않았습니다 — 테스트가 실제 설정 파일을 덮어씁니다.' + ); + $this->assertStringNotContainsString( + 'storage/app/settings', + $root, + 'settings 디스크가 실제 설정 경로를 가리킵니다.' + ); + } + + /** + * 설정을 저장해도 실제 설정 파일이 만들어지지 않는지 확인합니다. + */ + public function test_saving_settings_does_not_write_to_real_storage_path(): void + { + $realPath = storage_path('app/settings/general.json'); + $before = file_exists($realPath) ? file_get_contents($realPath) : null; + + app(ConfigRepositoryInterface::class)->saveCategory('general', [ + 'site_name' => '격리 검증용 값', + 'language' => 'ja', + ]); + + $after = file_exists($realPath) ? file_get_contents($realPath) : null; + + $this->assertSame($before, $after, '설정 저장이 실제 설정 파일을 변경했습니다.'); + + // 저장 자체는 페이크 디스크에서 정상 동작해야 한다 (격리가 기능을 죽이면 안 됨) + Storage::disk('settings')->assertExists('general.json'); + $this->assertSame( + '격리 검증용 값', + app(ConfigRepositoryInterface::class)->getCategory('general')['site_name'] + ); + } +} diff --git a/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts b/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts new file mode 100644 index 00000000..adf79981 --- /dev/null +++ b/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts @@ -0,0 +1,177 @@ +/** + * E2E: 환경설정 > SEO/고급 — Sitemap 생성 설정 칸 (#481) + * + * @scenario admin_settings_sitemap_generation_fields + * @effects fields_mounted, fields_persisted, regenerate_is_async + * + * 배경: 대용량 사이트에서 sitemap 을 여러 파일로 나눠 생성하도록 분할 기준/압축 설정을 + * 추가하고, 관리자 "지금 생성" 버튼을 즉시 실행에서 큐 예약으로 바꿨다. 설정 칸이 실제로 + * 마운트·저장되는지, 재생성 버튼이 응답을 기다리며 멈추지 않는지 브라우저에서 확인한다. + * + * 검증: + * 1. SEO 탭에 분할 기준/압축 칸이 번역문과 함께 마운트된다 + * 2. 분할 기준을 바꿔 저장하면 422 없이 성공하고 재진입 시 유지된다 + * 3. 고급 탭에 Sitemap 캐시 기준값 칸이 마운트되고 기본값(86400)이 저장된다 + * 4. "지금 생성" 클릭이 즉시 응답을 받는다 (동기 생성이면 대용량에서 타임아웃) + */ +import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth'; + +const URLS_PER_FILE = '#field_sitemap_urls_per_file'; +const GZIP_ROW = '#field_sitemap_gzip'; +const ADVANCED_SITEMAP_CACHE = '#cache_seo_sitemap'; + +/** 관리자 환경설정 SEO 탭 진입 */ +async function gotoSeoTab(page: import('@playwright/test').Page): Promise { + await page.goto('/admin/settings?tab=seo'); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + await expect(page.locator(URLS_PER_FILE)).toBeAttached({ timeout: 20_000 }); +} + +/** 관리자 환경설정 고급 탭 진입 */ +async function gotoAdvancedTab(page: import('@playwright/test').Page): Promise { + await page.goto('/admin/settings?tab=advanced'); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + await expect(page.locator(ADVANCED_SITEMAP_CACHE)).toBeAttached({ timeout: 20_000 }); +} + +// @scenario tab=seo, permitted=yes +// @effects fields_mounted +test('@smoke #481 - SEO 탭에 Sitemap 분할 기준/압축 칸이 번역문과 함께 마운트된다', async ({ page }) => { + const token = issueToken('core.settings.read', 'core.settings.update'); + await authenticatePage(page, token); + + await gotoSeoTab(page); + expect(page.url()).not.toMatch(/\/admin\/login/); + + const urlsPerFile = page.locator(URLS_PER_FILE); + await expect(urlsPerFile).toBeAttached(); + + // 다국어 키 미해석 회귀 가드 + const urlsText = (await urlsPerFile.innerText()).trim(); + expect(urlsText).not.toContain('$t:'); + expect(urlsText.length).toBeGreaterThan(0); + + // 프로토콜 상한이 입력 단계에서 그대로 반영된다 + const input = urlsPerFile.locator('input[type="number"]').first(); + await expect(input).toHaveAttribute('max', '50000'); + await expect(input).toHaveAttribute('min', '1000'); + + const gzipRow = page.locator(GZIP_ROW); + await expect(gzipRow).toBeAttached(); + expect((await gzipRow.innerText()).trim()).not.toContain('$t:'); + await expect(gzipRow.locator('input[type="checkbox"]').first()).toBeAttached(); +}); + +// @scenario tab=seo, permitted=yes +// @effects fields_persisted +test('#481 - 분할 기준을 바꿔 저장하면 성공하고 재진입 시 유지된다', async ({ page }) => { + const token = issueToken('core.settings.read', 'core.settings.update'); + await authenticatePage(page, token); + + await gotoSeoTab(page); + + const input = page.locator(`${URLS_PER_FILE} input[type="number"]`).first(); + + // 폼이 저장된 설정으로 채워진 뒤에 수정해야 한다 (로드 전 입력은 다른 필드가 빈 채로 전송됨) + await expect.poll(() => input.inputValue(), { timeout: 15_000 }).not.toBe(''); + + const original = await input.inputValue(); + const next = original === '30000' ? '40000' : '30000'; + + await input.fill(next); + await expect(page.locator('#save_button')).toBeEnabled({ timeout: 10_000 }); + + const save = page.waitForResponse( + (r) => r.url().includes('/api/admin/settings') && r.request().method() === 'POST', + { timeout: 20_000 } + ); + await page.locator('#save_button').click(); + + // 신규 키가 검증 규칙에 없으면 422 로 떨어진다 (배관 누락 회귀 가드) + expect((await save).status()).toBe(200); + + await gotoSeoTab(page); + const reloaded = page.locator(`${URLS_PER_FILE} input[type="number"]`).first(); + await expect.poll(() => reloaded.inputValue(), { timeout: 15_000 }).toBe(next); + + // 원상 복구 (E2E 는 실제 환경 설정을 건드린다) + await reloaded.fill(original); + const restore = page.waitForResponse( + (r) => r.url().includes('/api/admin/settings') && r.request().method() === 'POST', + { timeout: 20_000 } + ); + await page.locator('#save_button').click(); + expect((await restore).status()).toBe(200); +}); + +// @scenario tab=advanced, permitted=yes +// @effects fields_mounted, fields_persisted +test('#481 - 고급 탭 Sitemap 캐시 기준값 칸이 마운트되고 기본값이 저장된다', async ({ page }) => { + const token = issueToken('core.settings.read', 'core.settings.update'); + await authenticatePage(page, token); + + await gotoAdvancedTab(page); + + const row = page.locator(ADVANCED_SITEMAP_CACHE); + await expect(row).toBeAttached(); + expect((await row.innerText()).trim()).not.toContain('$t:'); + + const input = row.locator('input[type="number"]').first(); + await expect(input).toBeAttached(); + + // 폼이 저장된 설정으로 채워진 뒤에 수정해야 한다 (로드 전 입력은 다른 필드가 빈 채로 전송됨) + await expect.poll(() => input.inputValue(), { timeout: 15_000 }).not.toBe(''); + + const original = await input.inputValue(); + // 기본값 86400 은 형제 칸 규칙(최대 14400)을 재사용하면 저장 자체가 막힌다 + const next = original === '86400' ? '43200' : '86400'; + + await input.fill(next); + await expect(page.locator('#save_button')).toBeEnabled({ timeout: 10_000 }); + + const save = page.waitForResponse( + (r) => r.url().includes('/api/admin/settings') && r.request().method() === 'POST', + { timeout: 20_000 } + ); + await page.locator('#save_button').click(); + + expect((await save).status()).toBe(200); + + // 원상 복구 (E2E 는 실제 환경 설정을 건드린다) + await gotoAdvancedTab(page); + const restored = page.locator(`${ADVANCED_SITEMAP_CACHE} input[type="number"]`).first(); + await expect.poll(() => restored.inputValue(), { timeout: 15_000 }).toBe(next); + await restored.fill(original); + const restore = page.waitForResponse( + (r) => r.url().includes('/api/admin/settings') && r.request().method() === 'POST', + { timeout: 20_000 } + ); + await page.locator('#save_button').click(); + expect((await restore).status()).toBe(200); +}); + +// @scenario tab=seo, permitted=yes +// @effects regenerate_is_async +test('#481 - "지금 생성" 클릭이 큐에 예약되고 즉시 응답한다', async ({ page }) => { + const token = issueToken('core.settings.read', 'core.settings.update'); + await authenticatePage(page, token); + + await gotoSeoTab(page); + + const regenerate = page.waitForResponse( + (r) => r.url().includes('/api/admin/seo/sitemap/regenerate'), + { timeout: 20_000 } + ); + + const startedAt = Date.now(); + await page.locator('#sitemap_last_updated_block button').first().click(); + const response = await regenerate; + const elapsed = Date.now() - startedAt; + + expect(response.status()).toBe(200); + expect((await response.json())?.data?.status).toBe('dispatched'); + + // 동기 생성으로 되돌아가면 응답 시간이 데이터 규모에 비례해 늘어난다. + // 절대 하한 대신 "예약 응답이라면 당연히 만족하는" 상한으로 회귀만 잡는다. + expect(elapsed).toBeLessThan(10_000); +}); diff --git a/tests/TestCase.php b/tests/TestCase.php index cc8e79e4..25c4cf7f 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -6,6 +6,7 @@ use App\Extension\Testing\ExtensionTestAllowlist; use App\Listeners\Identity\EnforceIdentityPolicyListener; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Storage; abstract class TestCase extends BaseTestCase { @@ -39,12 +40,18 @@ abstract class TestCase extends BaseTestCase * createApplication() 단계에서 모든 ServiceProvider 의 register()/boot() 가 * 실행되므로, allowlist 는 그 이전(= parent::setUp() 호출 전)에 설정되어야 * provider 자동 등록 가드가 올바른 시점에 적용됩니다. + * + * settings 디스크는 앱 부팅 직후 페이크로 대체합니다 (아래 fakeSettingsDisk). */ protected function setUp(): void { // 앱 생성 전에 확장 allowlist 설정 (provider register 가드 적용 시점 보장) ExtensionTestAllowlist::set($this->resolveAllowedExtensions()); + $this->afterApplicationCreated(function () { + $this->fakeSettingsDisk(); + }); + if (! self::$staleConnectionsCleaned) { // Laravel 앱 부팅 전이므로 부트스트랩 후 콜백으로 등록 $this->afterApplicationCreated(function () { @@ -56,6 +63,26 @@ abstract class TestCase extends BaseTestCase parent::setUp(); } + /** + * settings 디스크를 페이크로 대체하여 실제 설정 파일을 보호합니다. + * + * `settings` 디스크의 root 는 `storage/app/settings` 로, 개발/운영 환경이 실제로 + * 사용하는 설정 파일 그 자체입니다. 페이크로 대체하지 않으면 설정 저장 경로를 타는 + * 테스트(설정 API 호출, ConfigRepository::saveCategory 등)가 실제 설정을 덮어쓰고, + * RefreshDatabase 는 DB 만 되돌리므로 그 오염이 그대로 남습니다. + * + * 실제 피해 사례: 설정 저장 테스트가 `general.json` 의 사이트명/언어를 테스트 값으로 + * 덮어써 개발 사이트 언어가 ja 로 바뀌어 있었고, SEO/캐시 TTL 도 테스트 값으로 대체됨. + * + * 설정 *읽기* 는 부팅 시 Config(`g7_settings.*`)로 이미 적재된 뒤이므로 이 페이크의 + * 영향을 받지 않습니다. 저장소를 직접 읽어야 하는 테스트는 페이크 디스크에 직접 + * 값을 넣고 검증합니다. + */ + private function fakeSettingsDisk(): void + { + Storage::fake('settings'); + } + /** * 테스트 종료 시 확장 allowlist 를 초기화합니다. * diff --git a/tests/Unit/Jobs/GenerateSitemapJobTest.php b/tests/Unit/Jobs/GenerateSitemapJobTest.php index 30ed9867..9a39804d 100644 --- a/tests/Unit/Jobs/GenerateSitemapJobTest.php +++ b/tests/Unit/Jobs/GenerateSitemapJobTest.php @@ -4,7 +4,9 @@ namespace Tests\Unit\Jobs; use App\Jobs\GenerateSitemapJob; use App\Seo\SitemapManager; +use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Log; use Mockery; use Tests\TestCase; @@ -28,20 +30,71 @@ class GenerateSitemapJobTest extends TestCase */ public function test_job_implements_should_queue(): void { - $job = new GenerateSitemapJob(); + $job = new GenerateSitemapJob; $this->assertInstanceOf(ShouldQueue::class, $job); } /** - * tries=3, timeout=300 속성을 확인합니다. + * 대용량 생성을 견디는 큐 속성을 확인합니다. */ public function test_job_has_correct_tries_and_timeout(): void { - $job = new GenerateSitemapJob(); + $job = new GenerateSitemapJob; $this->assertSame(3, $job->tries); - $this->assertSame(300, $job->timeout); + $this->assertSame(1800, $job->timeout); + $this->assertSame(120, $job->backoff); + } + + /** + * ShouldBeUnique 를 구현하여 동시 실행을 막는지 확인합니다. + * + * 봇 요청 캐시 미스마다 dispatch 되어도 큐에 한 건만 남아야 합니다. + */ + public function test_job_implements_should_be_unique(): void + { + $job = new GenerateSitemapJob; + + $this->assertInstanceOf(ShouldBeUnique::class, $job); + } + + /** + * 유니크 락 키가 인스턴스와 무관하게 고정값인지 확인합니다. + * + * 인스턴스마다 키가 다르면 중복 디스패치가 그대로 통과합니다. + */ + public function test_unique_id_is_stable_across_instances(): void + { + $this->assertSame('seo-sitemap', (new GenerateSitemapJob)->uniqueId()); + $this->assertSame('seo-sitemap', (new GenerateSitemapJob)->uniqueId()); + } + + /** + * 유니크 락이 영구 고착되지 않도록 만료 시간이 설정되어 있는지 확인합니다. + */ + public function test_unique_lock_expires(): void + { + $job = new GenerateSitemapJob; + + $this->assertSame(900, $job->uniqueFor); + } + + /** + * retryUntil 이 재시도 누적을 30분으로 제한하는지 확인합니다. + */ + public function test_retry_until_limits_retry_window(): void + { + Carbon::setTestNow(Carbon::parse('2026-07-17 10:00:00')); + + $job = new GenerateSitemapJob; + + $this->assertSame( + '2026-07-17 10:30:00', + Carbon::instance($job->retryUntil())->format('Y-m-d H:i:s') + ); + + Carbon::setTestNow(); } /** @@ -68,7 +121,7 @@ class GenerateSitemapJobTest extends TestCase return $context['size'] === 1234 && $context['ttl'] === 86400; })); - $job = new GenerateSitemapJob(); + $job = new GenerateSitemapJob; $job->handle($manager); $this->addToAssertionCount(Mockery::getContainer()->mockery_getExpectationCount()); @@ -92,7 +145,7 @@ class GenerateSitemapJobTest extends TestCase ->once() ->with('[SEO] Sitemap generation skipped (disabled)'); - $job = new GenerateSitemapJob(); + $job = new GenerateSitemapJob; $job->handle($manager); $this->addToAssertionCount(Mockery::getContainer()->mockery_getExpectationCount()); @@ -115,7 +168,7 @@ class GenerateSitemapJobTest extends TestCase $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('contributor crashed'); - $job = new GenerateSitemapJob(); + $job = new GenerateSitemapJob; $job->handle($manager); } @@ -132,7 +185,7 @@ class GenerateSitemapJobTest extends TestCase return $context['error'] === 'Sitemap generation timeout'; })); - $job = new GenerateSitemapJob(); + $job = new GenerateSitemapJob; $job->failed($exception); $this->addToAssertionCount(Mockery::getContainer()->mockery_getExpectationCount()); From 1a6fca0d6b42c457c62d4293c864617609141ad0 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Sat, 18 Jul 2026 15:05:56 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat(seo):=20=EC=82=AC=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EB=A7=B5=20=EA=B8=B0=EC=97=AC=EC=9E=90=20=EC=A7=80=EC=97=B0=20?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EB=A6=AC=EB=B0=8D=20+=20hreflang=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0=20=EC=84=A0=ED=98=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AbstractSitemapContributor 브리지 base 로 인터페이스를 바꾸지 않고 board/ecommerce/page 기여자를 getUrlsLazy 지연 스트리밍으로 전환해, 게시글이 수백만 건인 사이트에서도 URL 배열을 통째로 적재하지 않는다. SitemapGenerator 는 capability 를 감지해 지연 경로를 쓰고, base 를 상속하지 않은 제3자 구현체는 기존 getUrls 경로로 그대로 동작한다. hreflang alternate 집합을 base URL 당 한 번만 계산해 로케일별 에 재사용하도록 바꿔 계산 비용을 로케일 수의 제곱에서 선형으로 줄였다 (정상 구성 출력은 불변). 다국어 대체 링크 사용 여부 토글과 로케일 수 상한을 추가하고 설정/검증/관리자 UI/다국어(ko·en·ja) 표면을 동반했다. --- .../Requests/Settings/SaveSettingsRequest.php | 2 + app/Seo/AbstractSitemapContributor.php | 57 ++++++++ app/Seo/SitemapGenerator.php | 8 ++ app/Seo/SitemapXmlRenderer.php | 81 +++++++++-- config/settings/defaults.json | 2 + docs/backend/api/settings.md | 5 + lang-packs/_bundled/g7-core-ja/CHANGELOG.md | 1 + .../g7-core-ja/backend/ja/validation.php | 1 + .../CHANGELOG.md | 1 + .../frontend/partial/admin.json | 4 +- lang/en/validation.php | 1 + lang/ko/validation.php | 1 + .../src/Seo/BoardSitemapContributor.php | 105 ++++++-------- .../Unit/Seo/BoardSitemapContributorTest.php | 20 +++ .../src/Seo/EcommerceSitemapContributor.php | 98 ++++++------- .../Seo/EcommerceSitemapContributorTest.php | 17 +++ .../src/Seo/PageSitemapContributor.php | 66 +++------ .../Unit/Seo/PageSitemapContributorTest.php | 17 +++ .../_bundled/sirsoft-admin_basic/CHANGELOG.md | 1 + .../admin-settings-sitemap-fields.test.tsx | 13 ++ .../lang/partial/en/admin.json | 4 +- .../lang/partial/ko/admin.json | 4 +- .../partials/admin_settings/_tab_seo.json | 40 ++++++ .../Seo/SaveSettingsRequestSeoTest.php | 24 ++++ ...settings-sitemap-generation-fields.spec.ts | 46 +++++++ .../Seo/AbstractSitemapContributorTest.php | 129 ++++++++++++++++++ tests/Unit/Seo/SitemapGeneratorTest.php | 61 +++++++++ tests/Unit/Seo/SitemapXmlRendererTest.php | 96 ++++++++++++- 28 files changed, 726 insertions(+), 179 deletions(-) create mode 100644 app/Seo/AbstractSitemapContributor.php create mode 100644 tests/Unit/Seo/AbstractSitemapContributorTest.php diff --git a/app/Http/Requests/Settings/SaveSettingsRequest.php b/app/Http/Requests/Settings/SaveSettingsRequest.php index d1ab767c..eb03e9bb 100644 --- a/app/Http/Requests/Settings/SaveSettingsRequest.php +++ b/app/Http/Requests/Settings/SaveSettingsRequest.php @@ -225,6 +225,7 @@ class SaveSettingsRequest extends FormRequest 'seo.sitemap_gzip' => ['nullable', 'boolean'], 'seo.sitemap_serve_stale_on_miss' => ['nullable', 'boolean'], 'seo.sitemap_max_urls_per_contributor' => ['nullable', 'integer', 'min:0'], + 'seo.sitemap_hreflang_enabled' => ['nullable', 'boolean'], 'seo.sitemap_schedule' => ['nullable', 'string', Rule::in(['hourly', 'daily', 'weekly'])], 'seo.sitemap_schedule_time' => ['nullable', 'string', 'regex:/^\d{2}:\d{2}$/'], 'seo.generator_enabled' => ['nullable', 'boolean'], @@ -620,6 +621,7 @@ class SaveSettingsRequest extends FormRequest 'seo.sitemap_serve_stale_on_miss.boolean' => __('validation.settings.sitemap_serve_stale_on_miss_boolean'), 'seo.sitemap_max_urls_per_contributor.integer' => __('validation.settings.sitemap_max_urls_per_contributor_integer'), 'seo.sitemap_max_urls_per_contributor.min' => __('validation.settings.sitemap_max_urls_per_contributor_min'), + 'seo.sitemap_hreflang_enabled.boolean' => __('validation.settings.sitemap_hreflang_enabled_boolean'), 'seo.sitemap_schedule.in' => __('validation.settings.sitemap_schedule_invalid'), 'seo.sitemap_schedule_time.regex' => __('validation.settings.sitemap_schedule_time_invalid'), diff --git a/app/Seo/AbstractSitemapContributor.php b/app/Seo/AbstractSitemapContributor.php new file mode 100644 index 00000000..a66cd36d --- /dev/null +++ b/app/Seo/AbstractSitemapContributor.php @@ -0,0 +1,57 @@ + + */ + public function getUrls(): array + { + return iterator_to_array($this->getUrlsLazy(), false); + } + + /** + * Sitemap URL 항목을 한 건씩 지연 순회합니다. + * + * 기본 구현은 getUrls() 배열을 순회합니다. 대용량 기여자는 이 메서드를 + * 오버라이드해 Repository 스트림을 직접 yield 하십시오. + * + * @return iterable + */ + public function getUrlsLazy(): iterable + { + yield from $this->getUrls(); + } +} diff --git a/app/Seo/SitemapGenerator.php b/app/Seo/SitemapGenerator.php index b843fc1d..0bf8acab 100644 --- a/app/Seo/SitemapGenerator.php +++ b/app/Seo/SitemapGenerator.php @@ -79,11 +79,19 @@ class SitemapGenerator /** * 기여자로부터 URL 항목을 순회 가능한 형태로 가져옵니다. * + * 지연 스트리밍 capability(AbstractSitemapContributor 상속 또는 getUrlsLazy() 보유)를 + * 감지해, 있으면 한 건씩 yield 되는 제너레이터를 사용합니다. 없으면 기존 getUrls() + * 배열을 그대로 순회합니다(제3자 raw 구현체 하위호환). + * * @param SitemapContributorInterface $contributor Sitemap 기여자 * @return iterable> URL 항목 순회자 */ private function drain(SitemapContributorInterface $contributor): iterable { + if ($contributor instanceof AbstractSitemapContributor || method_exists($contributor, 'getUrlsLazy')) { + return $contributor->getUrlsLazy(); + } + return $contributor->getUrls(); } diff --git a/app/Seo/SitemapXmlRenderer.php b/app/Seo/SitemapXmlRenderer.php index b2dcb7a1..ba3952d8 100644 --- a/app/Seo/SitemapXmlRenderer.php +++ b/app/Seo/SitemapXmlRenderer.php @@ -2,6 +2,8 @@ namespace App\Seo; +use Illuminate\Support\Facades\Log; + /** * Sitemap XML 조각 렌더러 * @@ -10,41 +12,75 @@ namespace App\Seo; * SitemapGenerator(수집측)와 SitemapWriter(기록측)가 이 렌더러를 공유합니다. * * 다국어(supported_locales 2개 이상)면 URL 하나당 로케일별 블록을 생성하고 - * 각 블록에 xhtml:link hreflang alternate 를 포함합니다. + * 각 블록에 xhtml:link hreflang alternate 를 포함합니다. alternate 링크 집합은 + * base URL 당 한 번만 계산해 로케일별 에 재사용하므로, alternate 생성 비용은 + * 로케일 수의 제곱이 아니라 선형입니다. */ final class SitemapXmlRenderer { + /** + * hreflang alternate 를 방출하는 지원 로케일 수 상한 + * + * 로케일 수가 이 값을 넘으면 URL 하나당 alternate 링크가 과도하게 팽창하므로 + * alternate 를 생략합니다(로케일별 자체는 유지). 정상 다국어 구성(2~수개)은 + * 이 상한에 걸리지 않습니다. + */ + public const MAX_HREFLANG = 50; + /** * 다국어 여부 (지원 로케일 2개 이상) */ private readonly bool $multilingual; + /** + * hreflang alternate 를 방출하는지 여부 + * + * 다국어이면서 hreflang 이 켜져 있고 로케일 수가 상한 이하일 때만 true. + */ + private readonly bool $hreflangActive; + /** * SitemapXmlRenderer 생성자 * * @param array $supportedLocales 지원 로케일 목록 * @param string $defaultLocale 기본 로케일 + * @param bool $hreflangEnabled hreflang alternate 방출 여부 (기본 true) + * @param int $maxHreflang hreflang 을 방출할 로케일 수 상한 */ public function __construct( private readonly array $supportedLocales, private readonly string $defaultLocale, + bool $hreflangEnabled = true, + int $maxHreflang = self::MAX_HREFLANG, ) { $this->multilingual = count($this->supportedLocales) > 1; + $this->hreflangActive = $this->multilingual + && $hreflangEnabled + && count($this->supportedLocales) <= $maxHreflang; } /** * 애플리케이션 로케일 설정으로 렌더러를 생성합니다. * + * hreflang 방출 여부는 seo.sitemap_hreflang_enabled 설정(기본 true)을 따릅니다. + * 로케일 수가 상한을 초과하면 alternate 를 생략하고 경고를 남깁니다. + * * @return self 설정 기반 렌더러 인스턴스 */ public static function fromConfig(): self { $defaultLocale = (string) config('app.locale'); + $supportedLocales = (array) config('app.supported_locales', [$defaultLocale]); + $hreflangEnabled = (bool) g7_core_settings('seo.sitemap_hreflang_enabled', true); - return new self( - (array) config('app.supported_locales', [$defaultLocale]), - $defaultLocale, - ); + if ($hreflangEnabled && count($supportedLocales) > self::MAX_HREFLANG) { + Log::warning('[SEO] Sitemap hreflang alternate 링크가 로케일 수 상한을 초과하여 생략됩니다.', [ + 'locale_count' => count($supportedLocales), + 'max_hreflang' => self::MAX_HREFLANG, + ]); + } + + return new self($supportedLocales, $defaultLocale, $hreflangEnabled); } /** @@ -78,7 +114,7 @@ final class SitemapXmlRenderer { $xml = ''."\n"; - if ($this->multilingual) { + if ($this->hreflangActive) { $xml .= 'renderSingleUrl($baseLoc, $entry); } + // hreflang alternate 집합은 base URL 당 한 번만 계산해 로케일별 에 재사용합니다. + // (로케일별로 다시 계산하면 alternate 생성이 로케일 수의 제곱이 됩니다.) + $alternateLinks = $this->hreflangActive ? $this->buildAlternateLinks($baseLoc) : ''; + $xml = ''; foreach ($this->supportedLocales as $locale) { - $xml .= $this->renderLocalizedUrl($baseLoc, $entry, (string) $locale); + $xml .= $this->renderLocalizedUrl($baseLoc, $entry, (string) $locale, $alternateLinks); } return $xml; @@ -170,18 +210,39 @@ final class SitemapXmlRenderer } /** - * 특정 로케일의 블록(hreflang alternate 포함)을 렌더링합니다. + * 특정 로케일의 블록을 렌더링합니다. + * + * hreflang alternate 링크 문자열은 호출부에서 base URL 당 한 번 계산해 전달합니다. * * @param string $baseLoc 기본 로케일 절대 URL * @param array $entry URL 항목 * @param string $locale 대상 로케일 + * @param string $alternateLinks 미리 계산된 hreflang alternate 링크 문자열 (비활성 시 빈 문자열) * @return string 블록 문자열 */ - private function renderLocalizedUrl(string $baseLoc, array $entry, string $locale): string + private function renderLocalizedUrl(string $baseLoc, array $entry, string $locale, string $alternateLinks): string { $xml = ' '."\n"; $xml .= ' '.$this->escape($this->localizedLoc($baseLoc, $locale)).''."\n"; $xml .= $this->renderMetaFields($entry); + $xml .= $alternateLinks; + $xml .= ' '."\n"; + + return $xml; + } + + /** + * base URL 하나에 대한 hreflang alternate 링크 집합을 렌더링합니다. + * + * 모든 지원 로케일의 alternate + x-default 를 포함하며, 이 문자열은 base URL 의 + * 모든 로케일 블록에 동일하게 재사용됩니다. + * + * @param string $baseLoc 기본 로케일 절대 URL + * @return string xhtml:link alternate 링크 문자열 + */ + private function buildAlternateLinks(string $baseLoc): string + { + $xml = ''; // 모든 로케일의 hreflang alternate 링크 foreach ($this->supportedLocales as $altLocale) { @@ -192,8 +253,6 @@ final class SitemapXmlRenderer // x-default = 기본 로케일 URL $xml .= ' '."\n"; - $xml .= ' '."\n"; - return $xml; } diff --git a/config/settings/defaults.json b/config/settings/defaults.json index b31216af..275d8b12 100644 --- a/config/settings/defaults.json +++ b/config/settings/defaults.json @@ -73,6 +73,7 @@ "sitemap_gzip": false, "sitemap_serve_stale_on_miss": true, "sitemap_max_urls_per_contributor": 0, + "sitemap_hreflang_enabled": true, "sitemap_schedule": "daily", "sitemap_schedule_time": "02:00", "sitemap_last_updated_at": "", @@ -221,6 +222,7 @@ "sitemap_gzip": { "type": "boolean", "sensitive": false }, "sitemap_serve_stale_on_miss": { "type": "boolean", "sensitive": false }, "sitemap_max_urls_per_contributor": { "type": "integer", "sensitive": false }, + "sitemap_hreflang_enabled": { "type": "boolean", "sensitive": false }, "sitemap_schedule": { "type": "string", "sensitive": false }, "sitemap_schedule_time": { "type": "string", "sensitive": false }, "sitemap_last_updated_at": { "type": "string", "sensitive": false }, diff --git a/docs/backend/api/settings.md b/docs/backend/api/settings.md index 984b62cb..f9ee3392 100644 --- a/docs/backend/api/settings.md +++ b/docs/backend/api/settings.md @@ -148,6 +148,11 @@ HTTP/1.1 200 "cache_ttl": 7200, "sitemap_enabled": true, "sitemap_cache_ttl": 86400, + "sitemap_urls_per_file": 50000, + "sitemap_gzip": false, + "sitemap_serve_stale_on_miss": true, + "sitemap_max_urls_per_contributor": 0, + "sitemap_hreflang_enabled": true, "sitemap_schedule": "daily", "sitemap_schedule_time": "02:00", "sitemap_last_updated_at": "2026-07-08T03:14:49+00:00", diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md index d685ff80..19ce0b01 100644 --- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md @@ -11,6 +11,7 @@ - 사이트맵 생성이 실패했을 때 표시되는 안내 문구 일본어 번역 추가 (`exceptions.seo.*`) — 사이트맵 파일을 저장하지 못하거나 압축에 실패한 경우의 안내가 일본어 로케일에서 자연스럽게 표시됩니다. - 사이트맵 재생성 예약·준비 중·생성 실패 안내 문구 일본어 번역 추가 (`seo.sitemap_*`) — 재생성을 예약했을 때, 아직 준비 중일 때, 생성에 실패했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다. - SEO 설정의 사이트맵 분할 기준·압축·수집기 상한과 SEO/사이트맵 캐시 유지 시간 항목의 검증 안내 문구 일본어 번역 추가 (`validation.settings.*`) — 잘못된 값을 입력했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다. +- SEO 설정의 사이트맵 다국어 대체 링크(hreflang) 항목의 검증 안내 문구 일본어 번역 추가 (`validation.settings.sitemap_hreflang_enabled_boolean`) — 잘못된 값을 입력했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다. ## [1.0.4] - 2026-07-16 diff --git a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php index 01538594..d65e8387 100644 --- a/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php +++ b/lang-packs/_bundled/g7-core-ja/backend/ja/validation.php @@ -939,6 +939,7 @@ return [ 'identity_max_attempts_integer' => '最大試行回数は整数である必要があります。', 'identity_max_attempts_min' => '最大試行回数は最小1回以上である必要があります。', 'identity_max_attempts_max' => '最大試行回数は最大20回を超えることはできません。', + 'sitemap_hreflang_enabled_boolean' => 'Sitemap 多言語代替リンク(hreflang) 設定は true または false 値である必要があります。', ], 'identity_policy' => [ 'key_required' => 'ポリシーキーを入力してください。', diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md index 5098c122..d3753650 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md @@ -9,6 +9,7 @@ ### Added - 환경설정 > SEO 화면에 추가된 Sitemap 분할 기준(파일당 URL 수)·Sitemap 압축 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다. +- 환경설정 > SEO 화면에 추가된 Sitemap 다국어 대체 링크(hreflang) 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다. - 고급 설정에 추가된 Sitemap 캐시 유지 시간 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다. ### Changed diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json index 3b5049bc..fa7c4bb7 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json @@ -1433,7 +1433,9 @@ "sitemap_urls_per_file": "Sitemap ファイルあたりの URL 数", "sitemap_urls_per_file_hint": "URL がこの数を超えると sitemap ファイルが複数に分割され、インデックスファイルが一緒に作成されます — 最小 1000 個、最大 50000 個(検索エンジン仕様上限)", "sitemap_gzip": "Sitemap 圧縮(gzip)", - "sitemap_gzip_desc": "分割された sitemap ファイルを圧縮して送信量を削減します。インデックスファイルは圧縮しません。" + "sitemap_gzip_desc": "分割された sitemap ファイルを圧縮して送信量を削減します。インデックスファイルは圧縮しません。", + "sitemap_hreflang_enabled": "多言語代替リンク (hreflang)", + "sitemap_hreflang_enabled_desc": "多言語サイトでURLごとに各言語バージョンを指す hreflang 代替リンクを挿入します。オフにするとリンクを省略してサイトマップサイズを削減します。" }, "security": { "title": "セキュリティ 設定", diff --git a/lang/en/validation.php b/lang/en/validation.php index 141fca60..43bd1c3d 100644 --- a/lang/en/validation.php +++ b/lang/en/validation.php @@ -965,6 +965,7 @@ return [ 'sitemap_serve_stale_on_miss_boolean' => 'Serving the previous sitemap setting must be true or false.', 'sitemap_max_urls_per_contributor_integer' => 'Maximum URLs per contributor must be an integer.', 'sitemap_max_urls_per_contributor_min' => 'Maximum URLs per contributor must be at least 0.', + 'sitemap_hreflang_enabled_boolean' => 'The sitemap hreflang alternate links setting must be true or false.', 'sitemap_schedule_invalid' => 'Please select a valid sitemap generation schedule.', 'sitemap_schedule_time_invalid' => 'Sitemap generation time must be in HH:mm format.', diff --git a/lang/ko/validation.php b/lang/ko/validation.php index d02c4e40..e7469a5f 100644 --- a/lang/ko/validation.php +++ b/lang/ko/validation.php @@ -910,6 +910,7 @@ return [ 'sitemap_serve_stale_on_miss_boolean' => '이전 Sitemap 제공 설정은 true 또는 false 값이어야 합니다.', 'sitemap_max_urls_per_contributor_integer' => '수집기당 최대 URL 수는 정수여야 합니다.', 'sitemap_max_urls_per_contributor_min' => '수집기당 최대 URL 수는 0 이상이어야 합니다.', + 'sitemap_hreflang_enabled_boolean' => 'Sitemap 다국어 대체 링크(hreflang) 설정은 true 또는 false 값이어야 합니다.', 'sitemap_schedule_invalid' => '유효한 Sitemap 생성 주기를 선택해주세요.', 'sitemap_schedule_time_invalid' => 'Sitemap 생성 시각은 HH:mm 형식이어야 합니다.', diff --git a/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php b/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php index 69d72cf1..fc3c8076 100644 --- a/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php +++ b/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php @@ -2,7 +2,7 @@ namespace Modules\Sirsoft\Board\Seo; -use App\Seo\Contracts\SitemapContributorInterface; +use App\Seo\AbstractSitemapContributor; use Illuminate\Support\Facades\Log; use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface; use Modules\Sirsoft\Board\Repositories\Contracts\PostRepositoryInterface; @@ -11,9 +11,10 @@ use Modules\Sirsoft\Board\Repositories\Contracts\PostRepositoryInterface; * Board 모듈 Sitemap 기여자 * * 게시판 및 게시글 URL을 sitemap에 제공합니다. - * 데이터 접근은 Repository 인터페이스에 위임합니다. + * 데이터 접근은 Repository 인터페이스에 위임하며, URL 은 배열로 모으지 않고 + * 한 건씩 지연 yield 하여 대용량 게시글(수백만 건)에서도 메모리를 유계로 유지합니다. */ -class BoardSitemapContributor implements SitemapContributorInterface +class BoardSitemapContributor extends AbstractSitemapContributor { /** * BoardSitemapContributor 생성자 @@ -37,21 +38,41 @@ class BoardSitemapContributor implements SitemapContributorInterface } /** - * Sitemap URL 항목 배열을 반환합니다. + * Sitemap URL 항목을 한 건씩 지연 순회합니다. * - * 게시판 목록, 게시판별 페이지, 각 게시판의 게시글 URL을 생성합니다. + * 게시판 목록, 게시판별 페이지, 각 게시판의 게시글 URL을 순서대로 yield 합니다. + * 기여자당 안전 상한(seo.sitemap_max_urls_per_contributor)을 초과하면 잘라내고 + * 경고를 남깁니다. 상한은 이 기여자가 내보내는 모든 URL 유형에 함께 적용됩니다. * - * @return array + * @return iterable */ - public function getUrls(): array + public function getUrlsLazy(): iterable { - $urls = []; - - // 기여자당 URL 안전 상한 (0 = 무제한). 상한은 이 기여자가 내보내는 모든 URL 유형에 - // 적용된다 — 특정 루프에만 걸면 다른 유형이 상한을 넘겨버린다. $maxUrls = (int) g7_core_settings('seo.sitemap_max_urls_per_contributor', 0); - $truncated = false; + $emitted = 0; + foreach ($this->collectUrls() as $entry) { + if ($maxUrls > 0 && $emitted >= $maxUrls) { + Log::warning('[SEO] Sitemap 기여자 URL 상한 초과로 잘렸습니다.', [ + 'contributor' => $this->getIdentifier(), + 'max_urls' => $maxUrls, + ]); + + return; + } + + yield $entry; + $emitted++; + } + } + + /** + * 상한 적용 이전의 URL 후보를 순서대로 지연 생성합니다. + * + * @return iterable + */ + private function collectUrls(): iterable + { // 'SEO 제공 페이지' 토글 — OFF 시 해당 URL 유형 제외 (기본 노출) $seoBoards = (bool) g7_module_settings('sirsoft-board', 'seo.seo_boards', true); $seoBoard = (bool) g7_module_settings('sirsoft-board', 'seo.seo_board', true); @@ -59,32 +80,27 @@ class BoardSitemapContributor implements SitemapContributorInterface // 게시판 목록 페이지 if ($seoBoards) { - $truncated = ! $this->appendUrl($urls, [ + yield [ 'url' => '/boards', 'changefreq' => 'weekly', 'priority' => 0.5, - ], $maxUrls); + ]; } // 개별 게시판/게시글 URL이 모두 OFF면 게시판 조회 자체를 생략 if (! $seoBoard && ! $seoPostDetail) { - return $urls; + return; } // 게시판별 페이지 foreach ($this->boardRepository->streamActiveForSitemap() as $board) { - if ($truncated) { - break; - } - - if ($seoBoard && ! $this->appendUrl($urls, [ - 'url' => "/board/{$board->slug}", - 'lastmod' => $board->updated_at?->toW3cString(), - 'changefreq' => 'daily', - 'priority' => 0.6, - ], $maxUrls)) { - $truncated = true; - break; + if ($seoBoard) { + yield [ + 'url' => "/board/{$board->slug}", + 'lastmod' => $board->updated_at?->toW3cString(), + 'changefreq' => 'daily', + 'priority' => 0.6, + ]; } if (! $seoPostDetail) { @@ -93,44 +109,13 @@ class BoardSitemapContributor implements SitemapContributorInterface // 각 게시판의 공개된 게시글 (비밀글 제외, 게시 상태만) foreach ($this->postRepository->streamPublishedForSitemap($board->id) as $post) { - if (! $this->appendUrl($urls, [ + yield [ 'url' => "/board/{$board->slug}/{$post->id}", 'lastmod' => $post->updated_at?->toW3cString(), 'changefreq' => 'monthly', 'priority' => 0.5, - ], $maxUrls)) { - $truncated = true; - break; - } + ]; } } - - if ($truncated) { - Log::warning('[SEO] Sitemap 기여자 URL 상한 초과로 잘렸습니다.', [ - 'contributor' => $this->getIdentifier(), - 'max_urls' => $maxUrls, - ]); - } - - return $urls; - } - - /** - * 안전 상한을 지키며 URL 항목을 추가합니다. - * - * @param array> $urls 누적 URL 배열 (참조) - * @param array $entry 추가할 URL 항목 - * @param int $maxUrls 기여자당 URL 상한 (0 = 무제한) - * @return bool 추가되면 true, 상한 도달로 추가하지 못하면 false - */ - private function appendUrl(array &$urls, array $entry, int $maxUrls): bool - { - if ($maxUrls > 0 && count($urls) >= $maxUrls) { - return false; - } - - $urls[] = $entry; - - return true; } } diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Seo/BoardSitemapContributorTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Seo/BoardSitemapContributorTest.php index 93c1129e..98a3dc84 100644 --- a/modules/_bundled/sirsoft-board/tests/Unit/Seo/BoardSitemapContributorTest.php +++ b/modules/_bundled/sirsoft-board/tests/Unit/Seo/BoardSitemapContributorTest.php @@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Board\Tests\Unit\Seo; require_once __DIR__.'/../../ModuleTestCase.php'; +use App\Seo\AbstractSitemapContributor; use App\Seo\Contracts\SitemapContributorInterface; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\DB; @@ -286,4 +287,23 @@ class BoardSitemapContributorTest extends BoardTestCase $this->assertContains("/board/{$this->board->slug}", $urlPaths); $this->assertContains("/board/{$this->board->slug}/{$postId}", $urlPaths); } + + /** + * getUrlsLazy: 배열을 실체화하지 않는 지연 제너레이터로 URL 을 흘려보낸다 (⑭ 스트리밍) + * + * 소비 경로는 getUrls() 가 아니라 getUrlsLazy() 이므로, 이것이 Traversable 로 + * 한 건씩 yield 되어야 대용량 게시글에서 메모리가 유계로 유지된다. + */ + public function test_get_urls_lazy_streams_entries(): void + { + $postId = $this->createTestPost(['status' => 'published', 'is_secret' => false]); + + $this->assertInstanceOf(AbstractSitemapContributor::class, $this->contributor); + + $lazy = $this->contributor->getUrlsLazy(); + $this->assertInstanceOf(\Traversable::class, $lazy); + + $urlPaths = array_column(iterator_to_array($lazy, false), 'url'); + $this->assertContains("/board/{$this->board->slug}/{$postId}", $urlPaths); + } } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php b/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php index c1624102..be602fa7 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php @@ -2,7 +2,7 @@ namespace Modules\Sirsoft\Ecommerce\Seo; -use App\Seo\Contracts\SitemapContributorInterface; +use App\Seo\AbstractSitemapContributor; use Illuminate\Support\Facades\Log; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CategoryRepositoryInterface; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface; @@ -11,9 +11,10 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface; * Ecommerce 모듈 Sitemap 기여자 * * 상품 및 카테고리 URL을 sitemap에 제공합니다. - * 데이터 접근은 Repository 인터페이스에 위임합니다. + * 데이터 접근은 Repository 인터페이스에 위임하며, URL 은 배열로 모으지 않고 + * 한 건씩 지연 yield 하여 대용량 상품(수만~수십만 건)에서도 메모리를 유계로 유지합니다. */ -class EcommerceSitemapContributor implements SitemapContributorInterface +class EcommerceSitemapContributor extends AbstractSitemapContributor { /** * EcommerceSitemapContributor 생성자 @@ -37,87 +38,74 @@ class EcommerceSitemapContributor implements SitemapContributorInterface } /** - * Sitemap URL 항목 배열을 반환합니다. + * Sitemap URL 항목을 한 건씩 지연 순회합니다. * - * 상품 목록, 카테고리별 페이지, 개별 상품 페이지의 URL을 생성합니다. + * 상품 목록, 카테고리별 페이지, 개별 상품 페이지의 URL을 순서대로 yield 합니다. + * 기여자당 안전 상한(seo.sitemap_max_urls_per_contributor)을 초과하면 잘라내고 + * 경고를 남깁니다. 상한은 이 기여자가 내보내는 모든 URL 유형에 함께 적용됩니다. * - * @return array + * @return iterable */ - public function getUrls(): array + public function getUrlsLazy(): iterable { - $urls = []; - $routePath = g7_module_settings('sirsoft-ecommerce', 'basic_info.route_path') ?? 'shop'; - - // 기여자당 URL 안전 상한 (0 = 무제한). 상한은 이 기여자가 내보내는 모든 URL 유형에 - // 적용된다 — 특정 루프에만 걸면 다른 유형이 상한을 넘겨버린다. $maxUrls = (int) g7_core_settings('seo.sitemap_max_urls_per_contributor', 0); - $truncated = false; + $emitted = 0; + + foreach ($this->collectUrls() as $entry) { + if ($maxUrls > 0 && $emitted >= $maxUrls) { + Log::warning('[SEO] Sitemap 기여자 URL 상한 초과로 잘렸습니다.', [ + 'contributor' => $this->getIdentifier(), + 'max_urls' => $maxUrls, + ]); + + return; + } + + yield $entry; + $emitted++; + } + } + + /** + * 상한 적용 이전의 URL 후보를 순서대로 지연 생성합니다. + * + * @return iterable + */ + private function collectUrls(): iterable + { + $routePath = g7_module_settings('sirsoft-ecommerce', 'basic_info.route_path') ?? 'shop'; // 상품 목록 페이지 — 'SEO 제공 페이지' 토글 OFF 시 제외 if ((bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_shop_index', true)) { - $truncated = ! $this->appendUrl($urls, [ + yield [ 'url' => "/{$routePath}/products", 'changefreq' => 'daily', 'priority' => 0.7, - ], $maxUrls); + ]; } // 카테고리별 페이지 — 토글 OFF 시 제외 - if (! $truncated && (bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_category', true)) { + if ((bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_category', true)) { foreach ($this->categoryRepository->streamActiveForSitemap() as $category) { - if (! $this->appendUrl($urls, [ + yield [ 'url' => "/{$routePath}/category/{$category->slug}", 'lastmod' => $category->updated_at?->toW3cString(), 'changefreq' => 'weekly', 'priority' => 0.6, - ], $maxUrls)) { - $truncated = true; - break; - } + ]; } } // 개별 상품 페이지 (전시 상태가 '전시'인 상품만) — 토글 OFF 시 제외 - if (! $truncated && (bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_product_detail', true)) { + if ((bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_product_detail', true)) { foreach ($this->productRepository->streamVisibleForSitemap() as $product) { - if (! $this->appendUrl($urls, [ + yield [ 'url' => "/{$routePath}/products/{$product->id}", 'lastmod' => $product->updated_at?->toW3cString(), 'changefreq' => 'weekly', 'priority' => 0.8, - ], $maxUrls)) { - $truncated = true; - break; - } + ]; } } - - if ($truncated) { - Log::warning('[SEO] Sitemap 기여자 URL 상한 초과로 잘렸습니다.', [ - 'contributor' => $this->getIdentifier(), - 'max_urls' => $maxUrls, - ]); - } - - return $urls; - } - - /** - * 안전 상한을 지키며 URL 항목을 추가합니다. - * - * @param array> $urls 누적 URL 배열 (참조) - * @param array $entry 추가할 URL 항목 - * @param int $maxUrls 기여자당 URL 상한 (0 = 무제한) - * @return bool 추가되면 true, 상한 도달로 추가하지 못하면 false - */ - private function appendUrl(array &$urls, array $entry, int $maxUrls): bool - { - if ($maxUrls > 0 && count($urls) >= $maxUrls) { - return false; - } - - $urls[] = $entry; - - return true; } } diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Seo/EcommerceSitemapContributorTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Seo/EcommerceSitemapContributorTest.php index 53a62b26..ce9765dc 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Seo/EcommerceSitemapContributorTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Seo/EcommerceSitemapContributorTest.php @@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Seo; require_once __DIR__.'/../../ModuleTestCase.php'; +use App\Seo\AbstractSitemapContributor; use App\Seo\Contracts\SitemapContributorInterface; use Illuminate\Support\Facades\Config; use Modules\Sirsoft\Ecommerce\Models\Category; @@ -214,4 +215,20 @@ class EcommerceSitemapContributorTest extends ModuleTestCase $this->assertNotContains("/{$this->routePath}/products/{$product->id}", $urlPaths); } + + /** + * getUrlsLazy: 배열을 실체화하지 않는 지연 제너레이터로 URL 을 흘려보낸다 (⑭ 스트리밍) + */ + public function test_get_urls_lazy_streams_entries(): void + { + $product = Product::factory()->create(); + + $this->assertInstanceOf(AbstractSitemapContributor::class, $this->contributor); + + $lazy = $this->contributor->getUrlsLazy(); + $this->assertInstanceOf(\Traversable::class, $lazy); + + $urlPaths = array_column(iterator_to_array($lazy, false), 'url'); + $this->assertContains("/{$this->routePath}/products/{$product->id}", $urlPaths); + } } diff --git a/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php b/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php index 4860da75..6bdca007 100644 --- a/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php +++ b/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php @@ -2,7 +2,7 @@ namespace Modules\Sirsoft\Page\Seo; -use App\Seo\Contracts\SitemapContributorInterface; +use App\Seo\AbstractSitemapContributor; use Illuminate\Support\Facades\Log; use Modules\Sirsoft\Page\Repositories\Contracts\PageRepositoryInterface; @@ -10,9 +10,10 @@ use Modules\Sirsoft\Page\Repositories\Contracts\PageRepositoryInterface; * Page 모듈 Sitemap 기여자 * * 발행된 페이지 URL을 sitemap에 제공합니다. - * 데이터 접근은 Repository 인터페이스에 위임합니다. + * 데이터 접근은 Repository 인터페이스에 위임하며, URL 은 배열로 모으지 않고 + * 한 건씩 지연 yield 하여 페이지가 많아도 메모리를 유계로 유지합니다. */ -class PageSitemapContributor implements SitemapContributorInterface +class PageSitemapContributor extends AbstractSitemapContributor { /** * PageSitemapContributor 생성자 @@ -34,58 +35,35 @@ class PageSitemapContributor implements SitemapContributorInterface } /** - * Sitemap URL 항목 배열을 반환합니다. + * Sitemap URL 항목을 한 건씩 지연 순회합니다. * - * 발행된 페이지의 URL을 생성합니다. + * 발행된 페이지의 URL을 순서대로 yield 합니다. 기여자당 안전 상한 + * (seo.sitemap_max_urls_per_contributor)을 초과하면 잘라내고 경고를 남깁니다. * - * @return array + * @return iterable */ - public function getUrls(): array + public function getUrlsLazy(): iterable { - $urls = []; - - // 기여자당 URL 안전 상한 (0 = 무제한). 상한은 이 기여자가 내보내는 모든 URL 유형에 적용된다. $maxUrls = (int) g7_core_settings('seo.sitemap_max_urls_per_contributor', 0); - $truncated = false; + $emitted = 0; foreach ($this->pageRepository->streamPublishedForSitemap() as $page) { - if (! $this->appendUrl($urls, [ + if ($maxUrls > 0 && $emitted >= $maxUrls) { + Log::warning('[SEO] Sitemap 기여자 URL 상한 초과로 잘렸습니다.', [ + 'contributor' => $this->getIdentifier(), + 'max_urls' => $maxUrls, + ]); + + return; + } + + yield [ 'url' => "/page/{$page->slug}", 'lastmod' => $page->updated_at?->toW3cString(), 'changefreq' => 'monthly', 'priority' => 0.5, - ], $maxUrls)) { - $truncated = true; - break; - } + ]; + $emitted++; } - - if ($truncated) { - Log::warning('[SEO] Sitemap 기여자 URL 상한 초과로 잘렸습니다.', [ - 'contributor' => $this->getIdentifier(), - 'max_urls' => $maxUrls, - ]); - } - - return $urls; - } - - /** - * 안전 상한을 지키며 URL 항목을 추가합니다. - * - * @param array> $urls 누적 URL 배열 (참조) - * @param array $entry 추가할 URL 항목 - * @param int $maxUrls 기여자당 URL 상한 (0 = 무제한) - * @return bool 추가되면 true, 상한 도달로 추가하지 못하면 false - */ - private function appendUrl(array &$urls, array $entry, int $maxUrls): bool - { - if ($maxUrls > 0 && count($urls) >= $maxUrls) { - return false; - } - - $urls[] = $entry; - - return true; } } diff --git a/modules/_bundled/sirsoft-page/tests/Unit/Seo/PageSitemapContributorTest.php b/modules/_bundled/sirsoft-page/tests/Unit/Seo/PageSitemapContributorTest.php index fb4bb299..180b89b5 100644 --- a/modules/_bundled/sirsoft-page/tests/Unit/Seo/PageSitemapContributorTest.php +++ b/modules/_bundled/sirsoft-page/tests/Unit/Seo/PageSitemapContributorTest.php @@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Page\Tests\Unit\Seo; require_once __DIR__.'/../../ModuleTestCase.php'; +use App\Seo\AbstractSitemapContributor; use App\Seo\Contracts\SitemapContributorInterface; use Illuminate\Support\Facades\Config; use Modules\Sirsoft\Page\Models\Page; @@ -117,4 +118,20 @@ class PageSitemapContributorTest extends ModuleTestCase $this->assertCount(2, $this->contributor->getUrls()); } + + /** + * getUrlsLazy: 배열을 실체화하지 않는 지연 제너레이터로 URL 을 흘려보낸다 (⑭ 스트리밍) + */ + public function test_get_urls_lazy_streams_entries(): void + { + $page = Page::factory()->published()->create(); + + $this->assertInstanceOf(AbstractSitemapContributor::class, $this->contributor); + + $lazy = $this->contributor->getUrlsLazy(); + $this->assertInstanceOf(\Traversable::class, $lazy); + + $urlPaths = array_column(iterator_to_array($lazy, false), 'url'); + $this->assertContains("/page/{$page->slug}", $urlPaths); + } } diff --git a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md index f7d4adeb..ffd388fd 100644 --- a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md @@ -9,6 +9,7 @@ ### Added - SEO 설정에 Sitemap 분할 기준(파일당 URL 수)과 Sitemap 압축 항목을 추가했습니다. URL 이 분할 기준을 넘으면 sitemap 이 여러 파일로 나뉘어 생성됩니다. +- SEO 설정에 다국어 대체 링크(hreflang) 항목을 추가했습니다. 다국어 사이트에서 sitemap 에 각 언어 버전을 가리키는 링크를 넣을지 켜고 끌 수 있으며, 끄면 sitemap 크기를 줄일 수 있습니다. - 고급 설정의 캐시 항목에 Sitemap 캐시 유지 시간을 추가했습니다. SEO 설정의 Sitemap 캐시 항목을 비워두면 이 값을 따릅니다. ### Changed diff --git a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-fields.test.tsx b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-fields.test.tsx index ed98afff..d27d5434 100644 --- a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-fields.test.tsx +++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-fields.test.tsx @@ -77,16 +77,29 @@ describe('환경설정 > SEO 탭 > Sitemap 생성 칸', () => { expect(field.type).toBe('composite'); }); + it('hreflang 칸이 seo.sitemap_hreflang_enabled Toggle 로 존재한다', () => { + const field = findFieldByName(seoTab, 'seo.sitemap_hreflang_enabled'); + + expect(field).toBeDefined(); + expect(field.name).toBe('Toggle'); + expect(field.type).toBe('composite'); + }); + it('신규 칸이 다국어 키로 라벨을 표시한다 (하드코딩 문구 없음)', () => { const urlsPerFileField = collectNodes( seoTab, (n) => n.id === 'field_sitemap_urls_per_file' )[0]; const gzipField = collectNodes(seoTab, (n) => n.id === 'field_sitemap_gzip')[0]; + const hreflangField = collectNodes( + seoTab, + (n) => n.id === 'field_sitemap_hreflang_enabled' + )[0]; const labels = [ ...collectNodes(urlsPerFileField, (n) => typeof n.text === 'string'), ...collectNodes(gzipField, (n) => typeof n.text === 'string'), + ...collectNodes(hreflangField, (n) => typeof n.text === 'string'), ].map((n) => n.text); expect(labels.length).toBeGreaterThan(0); diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json index 60a9968b..e7791867 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json @@ -1429,7 +1429,9 @@ "sitemap_urls_per_file": "URLs per sitemap file", "sitemap_urls_per_file_hint": "When URLs exceed this number, the sitemap is split into multiple files and an index file is created — minimum 1000, maximum 50000 (search engine limit)", "sitemap_gzip": "Sitemap compression (gzip)", - "sitemap_gzip_desc": "Compresses split sitemap files to reduce transfer size. The index file is not compressed." + "sitemap_gzip_desc": "Compresses split sitemap files to reduce transfer size. The index file is not compressed.", + "sitemap_hreflang_enabled": "Multilingual alternate links (hreflang)", + "sitemap_hreflang_enabled_desc": "Adds hreflang alternate links pointing to each language version of a URL on multilingual sites. Turn off to omit the links and reduce sitemap size." }, "security": { "title": "Security Settings", diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json index 1557fb68..415e2d8d 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json @@ -1433,7 +1433,9 @@ "sitemap_urls_per_file": "Sitemap 파일당 URL 수", "sitemap_urls_per_file_hint": "URL 이 이 수를 넘으면 sitemap 파일이 여러 개로 나뉘고 색인 파일이 함께 만들어집니다 — 최소 1000개, 최대 50000개 (검색엔진 규격 상한)", "sitemap_gzip": "Sitemap 압축 (gzip)", - "sitemap_gzip_desc": "나뉜 sitemap 파일을 압축해 전송량을 줄입니다. 색인 파일은 압축하지 않습니다." + "sitemap_gzip_desc": "나뉜 sitemap 파일을 압축해 전송량을 줄입니다. 색인 파일은 압축하지 않습니다.", + "sitemap_hreflang_enabled": "다국어 대체 링크 (hreflang)", + "sitemap_hreflang_enabled_desc": "다국어 사이트에서 URL 마다 각 언어 버전을 가리키는 hreflang 대체 링크를 넣습니다. 끄면 링크를 생략해 sitemap 크기를 줄입니다." }, "security": { "title": "보안 설정", diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json index 9fb2daae..15bac572 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json @@ -1038,6 +1038,46 @@ } ] }, + { + "id": "field_sitemap_hreflang_enabled", + "type": "basic", + "name": "Div", + "props": { + "className": "flex-between" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-heading" + }, + "text": "$t:admin.settings.seo.sitemap_hreflang_enabled" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-label-subtle" + }, + "text": "$t:admin.settings.seo.sitemap_hreflang_enabled_desc" + } + ] + }, + { + "type": "composite", + "name": "Toggle", + "props": { + "name": "seo.sitemap_hreflang_enabled", + "size": "md" + } + } + ] + }, { "id": "sitemap_last_updated_block", "type": "basic", diff --git a/tests/Feature/Seo/SaveSettingsRequestSeoTest.php b/tests/Feature/Seo/SaveSettingsRequestSeoTest.php index 806f61e0..b785bd41 100644 --- a/tests/Feature/Seo/SaveSettingsRequestSeoTest.php +++ b/tests/Feature/Seo/SaveSettingsRequestSeoTest.php @@ -489,6 +489,30 @@ class SaveSettingsRequestSeoTest extends TestCase ->assertJsonValidationErrors(['seo.sitemap_gzip']); } + /** + * hreflang 사용 여부가 boolean 으로 저장되는지 확인 + */ + public function test_sitemap_hreflang_enabled_is_saved(): void + { + $this->postSeoSettings(['sitemap_hreflang_enabled' => false])->assertStatus(200); + + $seo = $this->storedSettings('seo'); + $this->assertFalse((bool) $seo['sitemap_hreflang_enabled']); + } + + /** + * hreflang 사용 여부가 boolean 이 아니면 422 응답 + */ + public function test_sitemap_hreflang_enabled_non_boolean_fails_validation(): void + { + $response = $this->postSeoSettings([ + 'sitemap_hreflang_enabled' => 'maybe', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['seo.sitemap_hreflang_enabled']); + } + // ======================================== // 고급 탭 Sitemap 캐시 기준값 (D19 의 메인 값) // ======================================== diff --git a/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts b/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts index adf79981..8e370b9c 100644 --- a/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts +++ b/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts @@ -18,6 +18,7 @@ import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth' const URLS_PER_FILE = '#field_sitemap_urls_per_file'; const GZIP_ROW = '#field_sitemap_gzip'; +const HREFLANG_ROW = '#field_sitemap_hreflang_enabled'; const ADVANCED_SITEMAP_CACHE = '#cache_seo_sitemap'; /** 관리자 환경설정 SEO 탭 진입 */ @@ -60,6 +61,51 @@ test('@smoke #481 - SEO 탭에 Sitemap 분할 기준/압축 칸이 번역문과 await expect(gzipRow).toBeAttached(); expect((await gzipRow.innerText()).trim()).not.toContain('$t:'); await expect(gzipRow.locator('input[type="checkbox"]').first()).toBeAttached(); + + const hreflangRow = page.locator(HREFLANG_ROW); + await expect(hreflangRow).toBeAttached(); + expect((await hreflangRow.innerText()).trim()).not.toContain('$t:'); + await expect(hreflangRow.locator('input[type="checkbox"]').first()).toBeAttached(); +}); + +// @scenario tab=seo, permitted=yes +// @effects fields_persisted +test('#481 - hreflang 토글을 바꿔 저장하면 성공하고 재진입 시 유지된다', async ({ page }) => { + const token = issueToken('core.settings.read', 'core.settings.update'); + await authenticatePage(page, token); + + await gotoSeoTab(page); + + const toggle = page.locator(`${HREFLANG_ROW} input[type="checkbox"]`).first(); + await expect(toggle).toBeAttached({ timeout: 15_000 }); + + const wasChecked = await toggle.isChecked(); + + // 토글을 반대 상태로 바꾼다 + await toggle.click(); + await expect(page.locator('#save_button')).toBeEnabled({ timeout: 10_000 }); + + const save = page.waitForResponse( + (r) => r.url().includes('/api/admin/settings') && r.request().method() === 'POST', + { timeout: 20_000 } + ); + await page.locator('#save_button').click(); + + // 신규 boolean 키가 검증 규칙에 없으면 422 로 떨어진다 (배관 누락 회귀 가드) + expect((await save).status()).toBe(200); + + await gotoSeoTab(page); + const reloaded = page.locator(`${HREFLANG_ROW} input[type="checkbox"]`).first(); + await expect.poll(() => reloaded.isChecked(), { timeout: 15_000 }).toBe(!wasChecked); + + // 원상 복구 (E2E 는 실제 환경 설정을 건드린다) + await reloaded.click(); + const restore = page.waitForResponse( + (r) => r.url().includes('/api/admin/settings') && r.request().method() === 'POST', + { timeout: 20_000 } + ); + await page.locator('#save_button').click(); + expect((await restore).status()).toBe(200); }); // @scenario tab=seo, permitted=yes diff --git a/tests/Unit/Seo/AbstractSitemapContributorTest.php b/tests/Unit/Seo/AbstractSitemapContributorTest.php new file mode 100644 index 00000000..68ef7c7e --- /dev/null +++ b/tests/Unit/Seo/AbstractSitemapContributorTest.php @@ -0,0 +1,129 @@ + 'https://example.test/a'], + ['loc' => 'https://example.test/b'], + ]; + } + }; + + $lazy = $contributor->getUrlsLazy(); + + $this->assertInstanceOf(\Traversable::class, $lazy); + $this->assertSame( + ['https://example.test/a', 'https://example.test/b'], + array_column(iterator_to_array($lazy, false), 'loc'), + ); + } + + /** + * getUrlsLazy() 만 구현한 구현체는 getUrls() 가 그 스트림을 배열로 실체화한다 + */ + public function test_array_bridges_to_lazy_when_only_get_urls_lazy_is_implemented(): void + { + $contributor = new class extends AbstractSitemapContributor + { + public function getIdentifier(): string + { + return 'lazy-only'; + } + + public function getUrlsLazy(): iterable + { + yield ['loc' => 'https://example.test/1']; + yield ['loc' => 'https://example.test/2']; + yield ['loc' => 'https://example.test/3']; + } + }; + + $urls = $contributor->getUrls(); + + $this->assertIsArray($urls); + $this->assertSame( + ['https://example.test/1', 'https://example.test/2', 'https://example.test/3'], + array_column($urls, 'loc'), + ); + } + + /** + * getUrlsLazy() 는 실제 지연 제너레이터여야 한다 (전량 실체화 금지) + * + * 소비를 시작하기 전에는 본문이 실행되지 않아야 대용량에서 메모리가 유계로 유지된다. + */ + public function test_get_urls_lazy_is_a_generator_that_defers_execution(): void + { + $contributor = new class extends AbstractSitemapContributor + { + public bool $executed = false; + + public function getIdentifier(): string + { + return 'deferred'; + } + + public function getUrlsLazy(): iterable + { + $this->executed = true; + yield ['loc' => 'https://example.test/x']; + } + }; + + $lazy = $contributor->getUrlsLazy(); + + // 아직 순회하지 않았으므로 제너레이터 본문이 실행되지 않았다. + $this->assertFalse($contributor->executed, '순회 전에는 제너레이터 본문이 실행되지 않아야 합니다.'); + + iterator_to_array($lazy, false); + + $this->assertTrue($contributor->executed, '순회하면 제너레이터 본문이 실행되어야 합니다.'); + } + + /** + * base 는 SitemapContributorInterface 를 구현한다 (기존 등록 경로 호환) + */ + public function test_is_a_sitemap_contributor(): void + { + $contributor = new class extends AbstractSitemapContributor + { + public function getIdentifier(): string + { + return 'iface'; + } + + public function getUrlsLazy(): iterable + { + yield from []; + } + }; + + $this->assertInstanceOf(SitemapContributorInterface::class, $contributor); + } +} diff --git a/tests/Unit/Seo/SitemapGeneratorTest.php b/tests/Unit/Seo/SitemapGeneratorTest.php index 60082e70..a4dd538e 100644 --- a/tests/Unit/Seo/SitemapGeneratorTest.php +++ b/tests/Unit/Seo/SitemapGeneratorTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\Seo; +use App\Seo\AbstractSitemapContributor; use App\Seo\Contracts\SitemapContributorInterface; use App\Seo\SitemapGenerator; use App\Seo\SitemapWriter; @@ -649,6 +650,66 @@ class SitemapGeneratorTest extends TestCase $this->assertContains(url('/ok'), array_column($writer->entries, 'loc')); } + // ─── drain capability 감지 (지연 스트리밍) ───────────── + + /** + * getUrlsLazy 를 가진 기여자는 지연 경로로 소진된다 (getUrls 미호출). + * + * getUrls() 를 호출하면 예외를 던지는 기여자를 사용해, generateToWriter 가 + * 지연 경로(getUrlsLazy)만 탄다는 것을 증명한다. + */ + public function test_generate_to_writer_uses_lazy_path_for_capable_contributor(): void + { + Log::shouldReceive('warning')->zeroOrMoreTimes(); + + $contributor = new class extends AbstractSitemapContributor + { + public function getIdentifier(): string + { + return 'lazy-capable'; + } + + public function getUrls(): array + { + throw new \LogicException('getUrls() 는 지연 경로에서 호출되면 안 됩니다.'); + } + + public function getUrlsLazy(): iterable + { + yield ['url' => '/lazy/1']; + yield ['url' => '/lazy/2']; + } + }; + + $this->generator->registerContributor($contributor); + + $writer = new SpySitemapWriter; + $this->generator->generateToWriter($writer); + + $locs = array_column($writer->entries, 'loc'); + $this->assertContains(url('/lazy/1'), $locs); + $this->assertContains(url('/lazy/2'), $locs); + } + + /** + * getUrlsLazy 가 없는 raw 기여자는 기존 getUrls() 경로로 소진된다 (하위호환). + */ + public function test_generate_to_writer_uses_get_urls_for_raw_contributor(): void + { + Log::shouldReceive('warning')->zeroOrMoreTimes(); + + $contributor = Mockery::mock(SitemapContributorInterface::class); + $contributor->shouldReceive('getIdentifier')->andReturn('raw-module'); + $contributor->shouldReceive('getUrls')->once()->andReturn([['url' => '/raw/1']]); + + $this->generator->registerContributor($contributor); + + $writer = new SpySitemapWriter; + $this->generator->generateToWriter($writer); + + $this->assertContains(url('/raw/1'), array_column($writer->entries, 'loc')); + } + // ─── 헬퍼 메서드 ────────────────────────────────────── /** diff --git a/tests/Unit/Seo/SitemapXmlRendererTest.php b/tests/Unit/Seo/SitemapXmlRendererTest.php index 0c613c3c..eb17528f 100644 --- a/tests/Unit/Seo/SitemapXmlRendererTest.php +++ b/tests/Unit/Seo/SitemapXmlRendererTest.php @@ -210,14 +210,14 @@ class SitemapXmlRendererTest extends TestCase } /** - * urlBlock: hreflang alternate 가 현재 N² 구조임을 고정한다 (S3 ⑮ 회귀 기준선) + * urlBlock: hreflang 이 켜져 있으면 로케일별 마다 전체 alternate 집합을 넣는다 * - * 현재 구현은 블록마다 모든 로케일의 alternate + x-default 를 넣는다. - * 즉 로케일 L 개면 L × (L + 1) 개의 xhtml:link 가 생성된다 — 이것이 S3 ⑮ 가 - * 해소하려는 N² 팽창이다. S3 에서 이 수치가 바뀌면 의도된 변경이므로 - * 이 테스트를 함께 갱신할 것. + * sitemaps.org 규격상 각 로케일 은 자기 자신을 포함한 모든 로케일의 alternate 와 + * x-default 를 가져야 한다. 따라서 로케일 L 개면 L × (L + 1) 개의 xhtml:link 가 나온다 + * (3 로케일 → 12). S3 ⑮ 는 이 alternate 집합을 base URL 당 한 번만 계산해 로케일별 + * 에 재사용하도록 바꿨다 — 계산 비용은 L² 에서 L 로 줄지만 정상 구성의 출력은 불변이다. */ - public function test_url_block_currently_emits_quadratic_hreflang_links(): void + public function test_url_block_emits_full_hreflang_set_per_locale(): void { $xml = $this->multiLocale(['ko', 'en', 'ja'])->urlBlock(['loc' => 'https://example.test/a']); @@ -226,6 +226,90 @@ class SitemapXmlRendererTest extends TestCase $this->assertSame(3, substr_count($xml, 'hreflang="x-default"')); } + /** + * urlBlock: 모든 로케일 의 alternate 집합이 동일하다 (재사용 검증) + * + * base URL 당 한 번 계산한 alternate 를 각 에 재사용하므로, + * 로케일별 에 들어가는 alternate 링크 목록은 완전히 같아야 한다. + */ + public function test_url_block_reuses_identical_alternate_set_across_locales(): void + { + $xml = $this->multiLocale(['ko', 'en', 'ja'])->urlBlock(['loc' => 'https://example.test/a']); + + // 블록별로 alternate 링크 집합을 추출해 모두 동일한지 확인한다. + preg_match_all('/(.*?)<\/url>/s', $xml, $blocks); + $this->assertCount(3, $blocks[1]); + + $alternateSets = array_map(static function (string $block): array { + preg_match_all('/]*\/>/', $block, $links); + + return $links[0]; + }, $blocks[1]); + + $this->assertSame($alternateSets[0], $alternateSets[1]); + $this->assertSame($alternateSets[0], $alternateSets[2]); + $this->assertCount(4, $alternateSets[0], '각 은 3 로케일 alternate + x-default = 4 개를 가져야 합니다.'); + } + + /** + * urlBlock: hreflang 을 끄면 로케일별 은 유지하되 alternate 를 생략한다 + */ + public function test_url_block_omits_hreflang_when_disabled(): void + { + $renderer = new SitemapXmlRenderer(['ko', 'en', 'ja'], 'ko', hreflangEnabled: false); + + $xml = $renderer->urlBlock(['loc' => 'https://example.test/a']); + + // 로케일별 은 그대로 3개 + $this->assertSame(3, substr_count($xml, '')); + $this->assertSame(3, $renderer->urlBlockCount()); + // 로케일 URL 은 유지된다 + $this->assertStringContainsString('https://example.test/a?locale=en', $xml); + // alternate 링크는 없다 + $this->assertStringNotContainsString('xhtml:link', $xml); + } + + /** + * urlsetHeader: hreflang 을 끄면 xhtml 네임스페이스를 선언하지 않는다 (미사용 방지) + */ + public function test_urlset_header_omits_xhtml_namespace_when_hreflang_disabled(): void + { + $header = (new SitemapXmlRenderer(['ko', 'en'], 'ko', hreflangEnabled: false))->urlsetHeader(); + + $this->assertStringNotContainsString('xmlns:xhtml', $header); + } + + /** + * urlBlock: 로케일 수가 상한을 초과하면 alternate 를 생략한다 (N² 팽창 방지) + */ + public function test_url_block_omits_hreflang_when_locale_count_exceeds_cap(): void + { + $locales = array_map(static fn (int $i): string => 'l'.$i, range(1, 6)); + $renderer = new SitemapXmlRenderer($locales, 'l1', maxHreflang: 5); + + $xml = $renderer->urlBlock(['loc' => 'https://example.test/a']); + + // 로케일별 은 그대로 6개 (상한은 alternate 방출만 막는다) + $this->assertSame(6, substr_count($xml, '')); + $this->assertStringNotContainsString('xhtml:link', $xml); + // 헤더도 xhtml 네임스페이스를 선언하지 않는다 + $this->assertStringNotContainsString('xmlns:xhtml', $renderer->urlsetHeader()); + } + + /** + * urlBlock: 상한 이하 로케일 수는 alternate 를 정상 방출한다 (경계값) + */ + public function test_url_block_emits_hreflang_at_cap_boundary(): void + { + $locales = array_map(static fn (int $i): string => 'l'.$i, range(1, 5)); + $renderer = new SitemapXmlRenderer($locales, 'l1', maxHreflang: 5); + + $xml = $renderer->urlBlock(['loc' => 'https://example.test/a']); + + // 5 로케일 → 5 × (5 + 1) = 30 + $this->assertSame(30, substr_count($xml, 'xhtml:link')); + } + /** * urlBlock: x-default 는 항상 기본 로케일 URL 을 가리킨다 */ From 16296841566ea84daf22ca9bba29417be719dcd0 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Sat, 18 Jul 2026 16:19:11 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat(seo):=20=EC=82=AC=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EB=A7=B5=20=EC=A6=9D=EB=B6=84=20=EC=A0=80=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?+=20=EB=A6=AC=EC=86=8C=EC=8A=A4=20=EB=A6=AC=EC=8A=A4=EB=84=88?= =?UTF-8?q?=20=EC=9E=AC=EB=B0=B0=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사이트맵을 리소스 단위로 영속화(sitemap_urls)해 진정한 증분 갱신과 도메인 재쿼리 없는 파일 재작성을 구현했다. 리소스 공개/비공개/삭제 시 해당 항목만 색인이 갱신되고, 관리자=full·스케줄러=auto·리소스 변경=증분으로 동작한다. - sitemap_urls 마이그레이션 + SitemapUrl 모델. loc(2048) 를 unique 에 직접 넣으면 InnoDB 키 길이를 초과하므로 loc_hash(sha256) 컬럼을 identity 로 사용 - SitemapUrlRepository(멱등 upsert / remove / 스트리밍) + SitemapIndexer (+ 제3자용 sitemap.index.collect_for_resource 필터 훅) - 4 리스너 재배선: 공개→색인 / 비공개·삭제→색인 해제 + 재생성 잡 디바운스 디스패치. 3 기여자가 resource_type/resource_id 를 함께 emit 해 전체·증분 identity 를 일치 - 재생성 모드를 SitemapGenerationMode Backed Enum 으로 통일(Command→Job→Manager) - seo:generate-sitemap 에 --rebuild / --mode 옵션 추가 --- .../Commands/SeoGenerateSitemapCommand.php | 38 +++- .../SitemapUrlRepositoryInterface.php | 61 ++++++ app/Enums/SitemapGenerationMode.php | 48 +++++ app/Jobs/GenerateSitemapJob.php | 10 +- app/Models/SitemapUrl.php | 61 ++++++ app/Repositories/SitemapUrlRepository.php | 174 ++++++++++++++++ app/Seo/SeoServiceProvider.php | 4 + app/Seo/SitemapGenerator.php | 45 +++++ app/Seo/SitemapIndexer.php | 112 +++++++++++ app/Seo/SitemapManager.php | 61 +++++- ...07_18_000001_create_sitemap_urls_table.php | 53 +++++ modules/_bundled/sirsoft-board/CHANGELOG.md | 1 + .../src/Listeners/SeoBoardCacheListener.php | 99 ++++++++++ .../src/Seo/BoardSitemapContributor.php | 6 + .../Feature/Seo/BoardSitemapIndexTest.php | 144 ++++++++++++++ .../Listeners/SeoBoardCacheListenerTest.php | 14 +- .../_bundled/sirsoft-ecommerce/CHANGELOG.md | 1 + .../Listeners/SeoCategoryCacheListener.php | 74 ++++++- .../src/Listeners/SeoProductCacheListener.php | 53 +++++ .../src/Seo/EcommerceSitemapContributor.php | 6 + .../Feature/Seo/EcommerceSitemapIndexTest.php | 135 +++++++++++++ .../SeoCategoryCacheListenerTest.php | 34 +++- .../Listeners/SeoProductCacheListenerTest.php | 7 + modules/_bundled/sirsoft-page/CHANGELOG.md | 1 + .../src/Listeners/SeoPageCacheListener.php | 59 +++++- .../src/Seo/PageSitemapContributor.php | 2 + .../Feature/Seo/PageSitemapIndexTest.php | 80 ++++++++ .../Listeners/SeoPageCacheListenerTest.php | 18 +- tests/Feature/Seo/SitemapBackfillTest.php | 90 +++++++++ tests/Feature/Seo/SitemapIndexerTest.php | 112 +++++++++++ tests/Feature/Seo/SitemapManagerModeTest.php | 187 ++++++++++++++++++ .../Feature/Seo/SitemapUrlRepositoryTest.php | 176 +++++++++++++++++ .../SeoGenerateSitemapCommandTest.php | 56 +++++- tests/Unit/Seo/SitemapManagerTest.php | 28 ++- 34 files changed, 2027 insertions(+), 23 deletions(-) create mode 100644 app/Contracts/Repositories/SitemapUrlRepositoryInterface.php create mode 100644 app/Enums/SitemapGenerationMode.php create mode 100644 app/Models/SitemapUrl.php create mode 100644 app/Repositories/SitemapUrlRepository.php create mode 100644 app/Seo/SitemapIndexer.php create mode 100644 database/migrations/2026_07_18_000001_create_sitemap_urls_table.php create mode 100644 modules/_bundled/sirsoft-board/tests/Feature/Seo/BoardSitemapIndexTest.php create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Feature/Seo/EcommerceSitemapIndexTest.php create mode 100644 modules/_bundled/sirsoft-page/tests/Feature/Seo/PageSitemapIndexTest.php create mode 100644 tests/Feature/Seo/SitemapBackfillTest.php create mode 100644 tests/Feature/Seo/SitemapIndexerTest.php create mode 100644 tests/Feature/Seo/SitemapManagerModeTest.php create mode 100644 tests/Feature/Seo/SitemapUrlRepositoryTest.php diff --git a/app/Console/Commands/SeoGenerateSitemapCommand.php b/app/Console/Commands/SeoGenerateSitemapCommand.php index 1198e680..9bf5f1aa 100644 --- a/app/Console/Commands/SeoGenerateSitemapCommand.php +++ b/app/Console/Commands/SeoGenerateSitemapCommand.php @@ -2,6 +2,7 @@ namespace App\Console\Commands; +use App\Enums\SitemapGenerationMode; use App\Jobs\GenerateSitemapJob; use Illuminate\Console\Command; use Illuminate\Support\Facades\Config; @@ -17,7 +18,10 @@ class SeoGenerateSitemapCommand extends Command /** * @var string 커맨드 시그니처 */ - protected $signature = 'seo:generate-sitemap {--sync : 큐 드라이버를 무시하고 동기 실행}'; + protected $signature = 'seo:generate-sitemap + {--sync : 큐 드라이버를 무시하고 동기 실행} + {--rebuild : 저장소 상태와 무관하게 전체 재생성 (--mode=full 과 동일)} + {--mode=auto : 재생성 모드 (full|auto|incremental)}'; /** * @var string 커맨드 설명 @@ -31,6 +35,14 @@ class SeoGenerateSitemapCommand extends Command */ public function handle(): int { + $mode = $this->resolveMode(); + if ($mode === null) { + $allowed = implode(', ', array_map(fn (SitemapGenerationMode $m): string => $m->value, SitemapGenerationMode::cases())); + $this->error('잘못된 모드입니다. 허용: '.$allowed); + + return Command::INVALID; + } + $forceSync = (bool) $this->option('sync'); // queue.default 는 SettingsServiceProvider 가 drivers.queue_driver 와 동기화하되, // testing 환경에서는 phpunit.xml 값을 보존하므로 격리가 유지된다. @@ -38,13 +50,29 @@ class SeoGenerateSitemapCommand extends Command $isSyncDriver = $connection === 'sync'; if ($forceSync || $isSyncDriver) { - GenerateSitemapJob::dispatchSync(); - $this->info('Sitemap이 생성되었습니다.'); + GenerateSitemapJob::dispatchSync($mode); + $this->info("Sitemap이 생성되었습니다. (모드: {$mode->value})"); } else { - GenerateSitemapJob::dispatch(); - $this->info('Sitemap 생성이 큐에 디스패치되었습니다.'); + GenerateSitemapJob::dispatch($mode); + $this->info("Sitemap 생성이 큐에 디스패치되었습니다. (모드: {$mode->value})"); } return Command::SUCCESS; } + + /** + * --rebuild / --mode 옵션으로부터 재생성 모드를 해석합니다. + * + * --rebuild 는 --mode 보다 우선하며 Full 로 강제합니다. + * + * @return SitemapGenerationMode|null 재생성 모드 (유효하지 않으면 null) + */ + private function resolveMode(): ?SitemapGenerationMode + { + if ((bool) $this->option('rebuild')) { + return SitemapGenerationMode::Full; + } + + return SitemapGenerationMode::tryFrom((string) $this->option('mode')); + } } diff --git a/app/Contracts/Repositories/SitemapUrlRepositoryInterface.php b/app/Contracts/Repositories/SitemapUrlRepositoryInterface.php new file mode 100644 index 00000000..44c619f7 --- /dev/null +++ b/app/Contracts/Repositories/SitemapUrlRepositoryInterface.php @@ -0,0 +1,61 @@ + $entries URL 항목 + */ + public function upsertForResource(string $type, string $id, array $entries): void; + + /** + * 한 리소스의 사이트맵 URL 행을 모두 제거합니다(비공개/삭제 시). + * + * @param string $type 리소스 유형 + * @param string $id 리소스 PK (문자열) + */ + public function removeForResource(string $type, string $id): void; + + /** + * 공개(is_visible) URL 을 id 순으로 스트리밍합니다(유계 메모리). + * + * @param string|null $contributor 기여자 식별자로 스코핑 (null = 전체) + * @return iterable URL 순회자 + */ + public function streamVisible(?string $contributor = null): iterable; + + /** + * 공개(is_visible) URL 총 개수를 반환합니다. + * + * @return int 공개 URL 수 + */ + public function countVisible(): int; + + /** + * 한 기여자의 모든 사이트맵 URL 행을 전량 대체합니다(전체 재생성). + * + * 기여자 스코프로 기존 행을 제거한 뒤 스트림을 청크 삽입하여 + * 대용량에서도 메모리를 유계로 유지합니다. + * + * @param string $contributor 기여자 식별자 + * @param iterable $entries URL 항목 스트림 + */ + public function replaceAllForContributor(string $contributor, iterable $entries): void; +} diff --git a/app/Enums/SitemapGenerationMode.php b/app/Enums/SitemapGenerationMode.php new file mode 100644 index 00000000..32384d88 --- /dev/null +++ b/app/Enums/SitemapGenerationMode.php @@ -0,0 +1,48 @@ + self::Full, + self::Incremental => self::Incremental, + self::Auto => $visibleCount === 0 ? self::Full : self::Incremental, + }; + } +} diff --git a/app/Jobs/GenerateSitemapJob.php b/app/Jobs/GenerateSitemapJob.php index d667ceec..a061c8d8 100644 --- a/app/Jobs/GenerateSitemapJob.php +++ b/app/Jobs/GenerateSitemapJob.php @@ -2,6 +2,7 @@ namespace App\Jobs; +use App\Enums\SitemapGenerationMode; use App\Seo\SitemapManager; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeUnique; @@ -44,6 +45,13 @@ class GenerateSitemapJob implements ShouldBeUnique, ShouldQueue */ public int $uniqueFor = 900; + /** + * GenerateSitemapJob 생성자 + * + * @param SitemapGenerationMode $mode 재생성 모드 — SitemapManager 로 전달 + */ + public function __construct(public SitemapGenerationMode $mode = SitemapGenerationMode::Auto) {} + /** * 유니크 락 식별자를 반환합니다. * @@ -75,7 +83,7 @@ class GenerateSitemapJob implements ShouldBeUnique, ShouldQueue */ public function handle(SitemapManager $manager): void { - $result = $manager->regenerate(); + $result = $manager->regenerate($this->mode); if (($result['status'] ?? null) === 'disabled') { Log::info('[SEO] Sitemap generation skipped (disabled)'); diff --git a/app/Models/SitemapUrl.php b/app/Models/SitemapUrl.php new file mode 100644 index 00000000..72318add --- /dev/null +++ b/app/Models/SitemapUrl.php @@ -0,0 +1,61 @@ + 대량 할당 가능 컬럼 + */ + protected $fillable = [ + 'resource_type', + 'resource_id', + 'loc', + 'loc_hash', + 'lastmod', + 'changefreq', + 'priority', + 'contributor', + 'is_visible', + ]; + + /** + * 속성 캐스팅을 반환합니다. + * + * @return array 캐스팅 맵 + */ + protected function casts(): array + { + return [ + 'lastmod' => 'datetime', + 'priority' => 'float', + 'is_visible' => 'boolean', + ]; + } +} diff --git a/app/Repositories/SitemapUrlRepository.php b/app/Repositories/SitemapUrlRepository.php new file mode 100644 index 00000000..9e9541c9 --- /dev/null +++ b/app/Repositories/SitemapUrlRepository.php @@ -0,0 +1,174 @@ +where('resource_type', $type) + ->where('resource_id', $id) + ->delete(); + + $now = now(); + $rows = []; + foreach ($entries as $entry) { + $rows[] = $this->toRow( + $type, + $id, + (string) ($entry['contributor'] ?? ''), + $entry, + $now, + ); + } + + if ($rows !== []) { + SitemapUrl::insert($rows); + } + }); + } + + /** + * {@inheritDoc} + */ + public function removeForResource(string $type, string $id): void + { + SitemapUrl::query() + ->where('resource_type', $type) + ->where('resource_id', $id) + ->delete(); + } + + /** + * {@inheritDoc} + */ + public function streamVisible(?string $contributor = null): iterable + { + $query = SitemapUrl::query()->where('is_visible', true); + + if ($contributor !== null) { + $query->where('contributor', $contributor); + } + + // lazyById 는 id 키셋 페이징이라 결과셋 전체를 메모리/드라이버 버퍼에 적재하지 않습니다. + return $query->lazyById(self::STREAM_CHUNK); + } + + /** + * {@inheritDoc} + */ + public function countVisible(): int + { + return SitemapUrl::query()->where('is_visible', true)->count(); + } + + /** + * {@inheritDoc} + */ + public function replaceAllForContributor(string $contributor, iterable $entries): void + { + // 기여자 스코프 전량 제거 후 청크 삽입. 대용량(수백만) 삽입을 단일 트랜잭션으로 + // 감싸면 락/메모리 부담이 크므로, 삭제 후 청크별 삽입으로 유계 메모리를 유지합니다. + SitemapUrl::query()->where('contributor', $contributor)->delete(); + + $now = now(); + $batch = []; + foreach ($entries as $entry) { + $batch[] = $this->toRow( + (string) ($entry['resource_type'] ?? 'unknown'), + isset($entry['resource_id']) && $entry['resource_id'] !== null + ? (string) $entry['resource_id'] + : null, + $contributor, + $entry, + $now, + ); + + if (count($batch) >= self::INSERT_CHUNK) { + SitemapUrl::insert($batch); + $batch = []; + } + } + + if ($batch !== []) { + SitemapUrl::insert($batch); + } + } + + /** + * URL 항목을 sitemap_urls 삽입용 행으로 변환합니다. + * + * @param string $type 리소스 유형 + * @param string|null $id 리소스 PK (문자열, 없으면 null) + * @param string $contributor 기여자 식별자 + * @param array $entry URL 항목 (loc/lastmod/changefreq/priority) + * @param Carbon $now 타임스탬프 기준값 + * @return array 삽입용 행 + */ + private function toRow(string $type, ?string $id, string $contributor, array $entry, Carbon $now): array + { + $loc = (string) ($entry['loc'] ?? ''); + + return [ + 'resource_type' => $type, + 'resource_id' => $id, + 'loc' => $loc, + 'loc_hash' => hash('sha256', $loc), + 'lastmod' => $this->normalizeLastmod($entry['lastmod'] ?? null), + 'changefreq' => $entry['changefreq'] ?? null, + 'priority' => $entry['priority'] ?? null, + 'contributor' => $contributor, + 'is_visible' => true, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + /** + * lastmod 값을 DB 저장용 타임스탬프 문자열로 정규화합니다. + * + * insert() 는 모델 캐스팅을 우회하므로 W3C 문자열/Carbon 을 직접 파싱합니다. + * + * @param mixed $lastmod 원본 lastmod (문자열/Carbon/null) + * @return string|null 정규화된 'Y-m-d H:i:s' 또는 null + */ + private function normalizeLastmod(mixed $lastmod): ?string + { + if ($lastmod === null || $lastmod === '') { + return null; + } + + try { + return Carbon::parse($lastmod)->format('Y-m-d H:i:s'); + } catch (\Throwable) { + return null; + } + } +} diff --git a/app/Seo/SeoServiceProvider.php b/app/Seo/SeoServiceProvider.php index bbc76844..f2b73212 100644 --- a/app/Seo/SeoServiceProvider.php +++ b/app/Seo/SeoServiceProvider.php @@ -3,7 +3,9 @@ namespace App\Seo; use App\Contracts\Extension\StorageInterface; +use App\Contracts\Repositories\SitemapUrlRepositoryInterface; use App\Extension\Storage\CoreStorageDriver; +use App\Repositories\SitemapUrlRepository; use App\Seo\Contracts\SeoCacheManagerInterface; use App\Seo\Contracts\SeoRendererInterface; use Illuminate\Support\ServiceProvider; @@ -18,6 +20,7 @@ class SeoServiceProvider extends ServiceProvider // 인터페이스 → 구현체 바인딩 $this->app->singleton(SeoCacheManagerInterface::class, SeoCacheManager::class); $this->app->singleton(SeoRendererInterface::class, SeoRenderer::class); + $this->app->singleton(SitemapUrlRepositoryInterface::class, SitemapUrlRepository::class); // 싱글톤 등록 $this->app->singleton(SeoCacheRegenerator::class); @@ -31,6 +34,7 @@ class SeoServiceProvider extends ServiceProvider $this->app->singleton(SitemapGenerator::class); $this->app->singleton(SeoConfigMerger::class); $this->app->singleton(SitemapFileStore::class); + $this->app->singleton(SitemapIndexer::class); // Sitemap 파일 입출력은 비공개 디스크(local = storage/app/private, serve=false)를 사용합니다. // public/storage 심볼릭 링크에 의존하지 않고 컨트롤러 스트리밍으로 서빙합니다. diff --git a/app/Seo/SitemapGenerator.php b/app/Seo/SitemapGenerator.php index 0bf8acab..ff747280 100644 --- a/app/Seo/SitemapGenerator.php +++ b/app/Seo/SitemapGenerator.php @@ -76,6 +76,51 @@ class SitemapGenerator return $writer->commit(); } + /** + * 미리 수집된 URL 항목(사이트맵 저장소 스트림 등)을 writer 로 스트리밍하여 파일을 생성합니다. + * + * 정적 라우트를 먼저 기록하고, 이어서 전달된 항목을 한 건씩 writer 에 넘깁니다. + * 증분/전체 재생성 모두 이 경로로 파일을 작성하며, 항목의 출처(도메인 재쿼리 vs 테이블 스트림)는 + * 호출자가 결정합니다. + * + * @param SitemapWriter $writer 분할 기록기 + * @param iterable $entries URL 항목 스트림 + * @return array{generated_at: string, child_count: int, url_count: int, size_bytes: int, gzip: bool, children: array} + * 커밋된 sitemap 메타데이터 + */ + public function generateToWriterFromEntries(SitemapWriter $writer, iterable $entries): array + { + $writer->open(); + + foreach ($this->collectStaticRoutes() as $entry) { + $writer->addUrl($entry); + } + + foreach ($entries as $entry) { + $writer->addUrl($this->normalizeEntry($entry)); + } + + $writer->close(); + + return $writer->commit(); + } + + /** + * 한 기여자의 URL 항목을 절대 URL(loc)로 정규화하여 지연 스트리밍합니다. + * + * 전체 재생성 시 사이트맵 저장소에 적재할 항목의 단일 출처이며, + * 기여자가 emit 한 resource_type/resource_id 등 부가 키는 그대로 통과시킵니다. + * + * @param SitemapContributorInterface $contributor Sitemap 기여자 + * @return iterable> 정규화된 URL 항목 순회자 + */ + public function streamContributorEntries(SitemapContributorInterface $contributor): iterable + { + foreach ($this->drain($contributor) as $entry) { + yield $this->normalizeEntry($entry); + } + } + /** * 기여자로부터 URL 항목을 순회 가능한 형태로 가져옵니다. * diff --git a/app/Seo/SitemapIndexer.php b/app/Seo/SitemapIndexer.php new file mode 100644 index 00000000..b30615fe --- /dev/null +++ b/app/Seo/SitemapIndexer.php @@ -0,0 +1,112 @@ + $entries URL 항목 + */ + public function indexResource(string $type, string|int $id, string $contributor, array $entries): void + { + $entries = HookManager::applyFilters(self::COLLECT_FILTER, $entries, $type, $id, $contributor); + + if (! is_array($entries) || $entries === []) { + // 색인할 항목이 없으면(모두 제외됨) 기존 색인을 제거해 정합을 유지합니다. + $this->repository->removeForResource($type, (string) $id); + + return; + } + + $normalized = []; + foreach ($entries as $entry) { + if (! is_array($entry)) { + continue; + } + + $loc = $this->resolveLoc($entry); + if ($loc === null) { + continue; + } + + $normalized[] = [ + 'loc' => $loc, + 'lastmod' => $entry['lastmod'] ?? null, + 'changefreq' => $entry['changefreq'] ?? null, + 'priority' => $entry['priority'] ?? null, + 'contributor' => $contributor, + ]; + } + + if ($normalized === []) { + $this->repository->removeForResource($type, (string) $id); + + return; + } + + $this->repository->upsertForResource($type, (string) $id, $normalized); + } + + /** + * 비공개/삭제된 리소스의 사이트맵 URL 색인을 제거합니다. + * + * @param string $type 리소스 유형 + * @param string|int $id 리소스 PK + */ + public function deindexResource(string $type, string|int $id): void + { + $this->repository->removeForResource($type, (string) $id); + } + + /** + * 항목의 절대 URL(loc)을 해석합니다. + * + * 'loc'(절대 URL)이 있으면 그대로, 없으면 'url'(상대 경로)을 url() 로 절대화합니다. + * + * @param array $entry URL 항목 + * @return string|null 절대 URL (해석 불가 시 null) + */ + private function resolveLoc(array $entry): ?string + { + if (isset($entry['loc']) && is_string($entry['loc']) && $entry['loc'] !== '') { + return $entry['loc']; + } + + if (isset($entry['url']) && is_string($entry['url']) && $entry['url'] !== '') { + return url($entry['url']); + } + + return null; + } +} diff --git a/app/Seo/SitemapManager.php b/app/Seo/SitemapManager.php index 8ae7473b..25b0eb7f 100644 --- a/app/Seo/SitemapManager.php +++ b/app/Seo/SitemapManager.php @@ -5,6 +5,8 @@ namespace App\Seo; use App\Contracts\Extension\CacheInterface; use App\Contracts\Extension\StorageInterface; use App\Contracts\Repositories\ConfigRepositoryInterface; +use App\Contracts\Repositories\SitemapUrlRepositoryInterface; +use App\Enums\SitemapGenerationMode; use App\Extension\HookManager; use Illuminate\Support\Facades\Log; @@ -33,15 +35,22 @@ class SitemapManager private CacheInterface $cache, private ConfigRepositoryInterface $configRepository, private StorageInterface $storage, + private SitemapUrlRepositoryInterface $repository, ) {} /** * Sitemap 을 즉시 생성하여 디스크에 커밋하고 last_updated_at 을 기록합니다. * + * 모드에 따라 사이트맵 저장소(sitemap_urls) 활용 방식이 달라집니다: + * - full: 각 기여자를 스트리밍해 저장소를 전량 대체한 뒤 저장소 → 파일 재작성. + * - incremental: 저장소의 현재 상태(리스너가 반영한 델타)를 그대로 파일로 재작성. + * - auto: 저장소가 비어 있으면 full, 아니면 incremental. + * + * @param SitemapGenerationMode $mode 재생성 모드 * @return array{success: bool, status: string, message?: string, data?: array} * status: 'updated' | 'disabled' | 'failed' */ - public function regenerate(): array + public function regenerate(SitemapGenerationMode $mode = SitemapGenerationMode::Auto): array { $enabled = (bool) g7_core_settings('seo.sitemap_enabled', true); if (! $enabled) { @@ -55,7 +64,16 @@ class SitemapManager HookManager::doAction('core.seo.sitemap.before_regenerate'); try { - $meta = $this->generator->generateToWriter($this->makeWriter()); + $effectiveMode = $mode->resolve($this->repository->countVisible()); + + if ($effectiveMode === SitemapGenerationMode::Full) { + $this->rebuildStore(); + } + + $meta = $this->generator->generateToWriterFromEntries( + $this->makeWriter(), + $this->streamStoreEntries(), + ); // 고급 탭(cache.seo_sitemap_ttl)이 메인, SEO 탭에 별도 지정이 있으면 그것이 우선 (D19) $ttl = SeoCacheSettings::sitemapCacheTtl(); @@ -114,6 +132,45 @@ class SitemapManager ]; } + /** + * 각 기여자를 스트리밍하여 사이트맵 저장소를 전량 대체합니다(전체 재생성). + * + * 기여자별로 격리하여, 한 기여자가 실패해도 나머지 기여자의 재적재는 계속됩니다. + */ + private function rebuildStore(): void + { + foreach ($this->generator->getContributors() as $contributor) { + try { + $this->repository->replaceAllForContributor( + $contributor->getIdentifier(), + $this->generator->streamContributorEntries($contributor), + ); + } catch (\Throwable $e) { + Log::warning('[SEO] Sitemap 저장소 재적재 실패', [ + 'contributor' => $contributor->getIdentifier(), + 'error' => $e->getMessage(), + ]); + } + } + } + + /** + * 사이트맵 저장소의 공개 URL 을 writer 항목 형태로 지연 스트리밍합니다. + * + * @return iterable writer 항목 순회자 + */ + private function streamStoreEntries(): iterable + { + foreach ($this->repository->streamVisible() as $row) { + yield [ + 'loc' => (string) $row->loc, + 'lastmod' => $row->lastmod?->toW3cString(), + 'changefreq' => $row->changefreq, + 'priority' => $row->priority !== null ? (float) $row->priority : null, + ]; + } + } + /** * 설정값을 반영한 SitemapWriter 를 생성합니다. * diff --git a/database/migrations/2026_07_18_000001_create_sitemap_urls_table.php b/database/migrations/2026_07_18_000001_create_sitemap_urls_table.php new file mode 100644 index 00000000..d1ae85e0 --- /dev/null +++ b/database/migrations/2026_07_18_000001_create_sitemap_urls_table.php @@ -0,0 +1,53 @@ +bigIncrements('id')->comment('사이트맵 URL 행 ID'); + $table->string('resource_type', 64)->index()->comment('리소스 유형 (page, board_post, product, category, board, board_index, shop_index 등)'); + $table->string('resource_id', 64)->nullable()->comment('리소스 PK (uuid/int 문자열). 인덱스/컬렉션 URL 은 null'); + $table->string('loc', 2048)->comment('절대 base URL (로케일 무관 — 렌더 시점에 로케일별 확장)'); + // loc 는 2048 자라 unique 인덱스에 직접 넣으면 InnoDB 키 길이(3072바이트) 를 초과하므로 + // sha256 해시(64자 hex)를 identity 로 사용합니다. ascii 컬럼이라 인덱스 비용이 작습니다. + $table->char('loc_hash', 64)->charset('ascii')->comment('loc 의 sha256 해시 (upsert/delete identity)'); + $table->timestamp('lastmod')->nullable()->comment('최종 수정 시각 ()'); + $table->string('changefreq', 16)->nullable()->comment('변경 빈도 ()'); + $table->decimal('priority', 2, 1)->nullable()->comment('우선순위 0.0~1.0 ()'); + $table->string('contributor', 64)->comment('기여자 식별자 (getIdentifier — 샤드/리빌드 스코핑)'); + $table->boolean('is_visible')->default(true)->comment('공개 여부 (true 만 사이트맵에 노출)'); + $table->timestamps(); + + // 리소스별 URL identity — upsert/delete 대상 식별 + $table->unique(['resource_type', 'resource_id', 'loc_hash'], 'sitemap_urls_identity_unique'); + // 리빌드/증분 스트리밍 커서 (contributor 스코핑 + 가시성 + id 순서) + $table->index(['contributor', 'is_visible', 'id'], 'sitemap_urls_stream_index'); + }); + } + + /** + * sitemap_urls 테이블을 제거합니다. + */ + public function down(): void + { + if (Schema::hasTable('sitemap_urls')) { + Schema::drop('sitemap_urls'); + } + } +}; diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index 5b09aa83..087c00c0 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -9,6 +9,7 @@ ### Added - 사이트맵에 담을 게시판·게시글 수의 안전 상한을 지원합니다. 상한을 설정하면 게시판 목록·게시판·게시글 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다. +- 게시글·게시판을 공개/비공개로 바꾸거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 게시글이 많은 사이트에서도 빠르게 최신 상태가 유지됩니다. ### Changed diff --git a/modules/_bundled/sirsoft-board/src/Listeners/SeoBoardCacheListener.php b/modules/_bundled/sirsoft-board/src/Listeners/SeoBoardCacheListener.php index 3b9fc5c7..ae6b0308 100644 --- a/modules/_bundled/sirsoft-board/src/Listeners/SeoBoardCacheListener.php +++ b/modules/_bundled/sirsoft-board/src/Listeners/SeoBoardCacheListener.php @@ -4,9 +4,12 @@ namespace Modules\Sirsoft\Board\Listeners; use App\Contracts\Extension\CacheInterface; use App\Contracts\Extension\HookListenerInterface; +use App\Jobs\GenerateSitemapJob; use App\Seo\Contracts\SeoCacheManagerInterface; use App\Seo\SeoCacheRegenerator; +use App\Seo\SitemapIndexer; use Illuminate\Support\Facades\Log; +use Modules\Sirsoft\Board\Enums\PostStatus; /** * 게시글 변경 시 SEO 캐시 무효화 리스너 @@ -65,6 +68,8 @@ class SeoBoardCacheListener implements HookListenerInterface // 게시글 상세 페이지 캐시 즉시 재생성 $this->regenerateDetailCache($args); + + $this->syncPostSitemapIndex($args, false); } /** @@ -78,6 +83,8 @@ class SeoBoardCacheListener implements HookListenerInterface // 게시글 상세 페이지 캐시 즉시 재생성 $this->regenerateDetailCache($args); + + $this->syncPostSitemapIndex($args, false); } /** @@ -90,6 +97,8 @@ class SeoBoardCacheListener implements HookListenerInterface public function onPostDelete(...$args): void { $this->invalidateRelatedCaches($args); + + $this->syncPostSitemapIndex($args, true); } /** @@ -141,6 +150,96 @@ class SeoBoardCacheListener implements HookListenerInterface 'board_slug' => $slug, ]); } + + $this->syncBoardSitemapIndex($board); + } + + /** + * 게시글의 사이트맵 색인을 증분 갱신합니다. + * + * 게시 상태(Published)이고 비밀글이 아니며 'SEO 제공 페이지(게시글 상세)' 토글이 켜져 있으면 + * 색인(upsert), 아니면 색인 해제(remove)한 뒤 사이트맵 재생성 잡을 디바운스 디스패치합니다. + * 색인 규칙은 BoardSitemapContributor 의 게시글 URL 규칙과 일치해야 합니다. + * + * @param array $args 훅 인자 배열 (첫 번째: Post 모델, 두 번째: 게시판 slug) + * @param bool $deleted 삭제 이벤트 여부 + */ + private function syncPostSitemapIndex(array $args, bool $deleted): void + { + $post = $args[0] ?? null; + $slug = $args[1] ?? null; + + if (! $post || ! isset($post->id) || ! $slug) { + return; + } + + try { + $indexer = app(SitemapIndexer::class); + + $visible = ! $deleted + && ($post->status ?? null) === PostStatus::Published + && ! (bool) ($post->is_secret ?? false) + && (bool) g7_module_settings('sirsoft-board', 'seo.seo_post_detail', true); + + if ($visible) { + $indexer->indexResource('board_post', $post->id, 'sirsoft-board', [[ + 'url' => "/board/{$slug}/{$post->id}", + 'lastmod' => $post->updated_at?->toW3cString(), + 'changefreq' => 'monthly', + 'priority' => 0.5, + ]]); + } else { + $indexer->deindexResource('board_post', $post->id); + } + + GenerateSitemapJob::dispatch(); + } catch (\Throwable $e) { + Log::warning('[SEO] Board post sitemap index sync failed', [ + 'error' => $e->getMessage(), + 'post_id' => is_object($post) ? ($post->id ?? null) : null, + ]); + } + } + + /** + * 게시판의 사이트맵 색인을 증분 갱신합니다. + * + * 활성(is_active) 이며 'SEO 제공 페이지(게시판)' 토글이 켜져 있으면 색인(upsert), + * 아니면 색인 해제(remove)한 뒤 사이트맵 재생성 잡을 디바운스 디스패치합니다. + * 색인 규칙은 BoardSitemapContributor 의 게시판 URL 규칙과 일치해야 합니다. + * + * @param mixed $board Board 모델 + */ + private function syncBoardSitemapIndex(mixed $board): void + { + if (! is_object($board) || ! isset($board->id) || ! isset($board->slug)) { + return; + } + + try { + $indexer = app(SitemapIndexer::class); + + $visible = (bool) ($board->is_active ?? false) + && (bool) g7_module_settings('sirsoft-board', 'seo.seo_board', true); + + if ($visible) { + $indexer->indexResource('board', $board->id, 'sirsoft-board', [[ + 'url' => "/board/{$board->slug}", + 'lastmod' => $board->updated_at?->toW3cString(), + 'changefreq' => 'daily', + 'priority' => 0.6, + ]]); + } else { + $indexer->deindexResource('board', $board->id); + } + + GenerateSitemapJob::dispatch(); + } catch (\Throwable $e) { + Log::warning('[SEO] Board sitemap index sync failed', [ + 'error' => $e->getMessage(), + 'board_id' => is_object($board) ? ($board->id ?? null) : null, + ]); + } } /** diff --git a/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php b/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php index fc3c8076..15a48832 100644 --- a/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php +++ b/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php @@ -84,6 +84,8 @@ class BoardSitemapContributor extends AbstractSitemapContributor 'url' => '/boards', 'changefreq' => 'weekly', 'priority' => 0.5, + 'resource_type' => 'board_index', + 'resource_id' => null, ]; } @@ -100,6 +102,8 @@ class BoardSitemapContributor extends AbstractSitemapContributor 'lastmod' => $board->updated_at?->toW3cString(), 'changefreq' => 'daily', 'priority' => 0.6, + 'resource_type' => 'board', + 'resource_id' => (string) $board->id, ]; } @@ -114,6 +118,8 @@ class BoardSitemapContributor extends AbstractSitemapContributor 'lastmod' => $post->updated_at?->toW3cString(), 'changefreq' => 'monthly', 'priority' => 0.5, + 'resource_type' => 'board_post', + 'resource_id' => (string) $post->id, ]; } } diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Seo/BoardSitemapIndexTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Seo/BoardSitemapIndexTest.php new file mode 100644 index 00000000..588eb528 --- /dev/null +++ b/modules/_bundled/sirsoft-board/tests/Feature/Seo/BoardSitemapIndexTest.php @@ -0,0 +1,144 @@ +app->instance(SeoCacheManagerInterface::class, Mockery::spy(SeoCacheManagerInterface::class)); + $this->app->instance(SeoCacheRegenerator::class, Mockery::spy(SeoCacheRegenerator::class)); + Bus::fake([GenerateSitemapJob::class]); + + $this->listener = new SeoBoardCacheListener; + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + /** + * 게시(Published)·비밀글 아님 게시글 생성 시 색인(append)된다. + */ + public function test_published_public_post_is_indexed(): void + { + $post = (object) ['id' => 100, 'status' => PostStatus::Published, 'is_secret' => false, 'updated_at' => now()]; + + $this->listener->onPostCreate($post, 'notice'); + + $row = SitemapUrl::where('resource_type', 'board_post')->where('resource_id', '100')->first(); + $this->assertNotNull($row, '공개 게시글은 색인돼야 합니다.'); + $this->assertStringEndsWith('/board/notice/100', $row->loc); + Bus::assertDispatched(GenerateSitemapJob::class); + } + + /** + * 비밀글은 색인되지 않는다(전시 상태여도). + */ + public function test_secret_post_is_not_indexed(): void + { + $post = (object) ['id' => 101, 'status' => PostStatus::Published, 'is_secret' => true, 'updated_at' => now()]; + + $this->listener->onPostCreate($post, 'notice'); + + $this->assertSame(0, SitemapUrl::where('resource_id', '101')->count(), '비밀글은 색인되면 안 됩니다.'); + } + + /** + * 블라인드 처리(수정)되면 색인이 제거된다. + */ + public function test_blinded_post_update_removes_index(): void + { + $this->listener->onPostCreate( + (object) ['id' => 102, 'status' => PostStatus::Published, 'is_secret' => false, 'updated_at' => now()], + 'notice' + ); + $this->assertSame(1, SitemapUrl::where('resource_id', '102')->count()); + + $this->listener->onPostUpdate( + (object) ['id' => 102, 'status' => PostStatus::Blinded, 'is_secret' => false, 'updated_at' => now()], + 'notice' + ); + + $this->assertSame(0, SitemapUrl::where('resource_id', '102')->count(), '블라인드 게시글은 색인에서 제거돼야 합니다.'); + } + + /** + * 게시글 삭제 시 색인이 제거된다. + */ + public function test_post_delete_removes_index(): void + { + $this->listener->onPostCreate( + (object) ['id' => 103, 'status' => PostStatus::Published, 'is_secret' => false, 'updated_at' => now()], + 'notice' + ); + $this->assertSame(1, SitemapUrl::where('resource_id', '103')->count()); + + $this->listener->onPostDelete((object) ['id' => 103], 'notice'); + + $this->assertSame(0, SitemapUrl::where('resource_type', 'board_post')->where('resource_id', '103')->count()); + } + + /** + * 활성/비활성 게시판 수정이 색인을 토글한다. + */ + public function test_board_visibility_toggles_index(): void + { + $this->listener->onBoardUpdate((object) ['id' => 5, 'slug' => 'free', 'is_active' => true, 'updated_at' => now()]); + $row = SitemapUrl::where('resource_type', 'board')->where('resource_id', '5')->first(); + $this->assertNotNull($row); + $this->assertStringEndsWith('/board/free', $row->loc); + + $this->listener->onBoardUpdate((object) ['id' => 5, 'slug' => 'free', 'is_active' => false, 'updated_at' => now()]); + $this->assertSame(0, SitemapUrl::where('resource_type', 'board')->where('resource_id', '5')->count()); + } + + /** + * 'SEO 제공 페이지(게시글 상세)' 토글이 OFF 면 공개 게시글이어도 색인이 제거된다. + * + * 리스너의 공개상태 판정은 게시 상태·비밀글 여부 AND 토글을 함께 본다(기여자 getUrlsLazy 와 일치). + */ + public function test_post_detail_toggle_off_removes_index_even_when_public(): void + { + // 토글 ON 상태로 먼저 색인 + $this->listener->onPostCreate( + (object) ['id' => 110, 'status' => PostStatus::Published, 'is_secret' => false, 'updated_at' => now()], + 'notice' + ); + $this->assertSame(1, SitemapUrl::where('resource_id', '110')->count()); + + // 토글 OFF → 공개 게시글이어도 색인 제거 + Config::set('g7_settings.modules.sirsoft-board.seo.seo_post_detail', false); + $this->listener->onPostUpdate( + (object) ['id' => 110, 'status' => PostStatus::Published, 'is_secret' => false, 'updated_at' => now()], + 'notice' + ); + + $this->assertSame(0, SitemapUrl::where('resource_id', '110')->count(), 'seo_post_detail OFF 면 공개 게시글도 색인에서 제거돼야 합니다.'); + } +} diff --git a/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SeoBoardCacheListenerTest.php b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SeoBoardCacheListenerTest.php index 5f3cd456..1da4b733 100644 --- a/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SeoBoardCacheListenerTest.php +++ b/modules/_bundled/sirsoft-board/tests/Unit/Listeners/SeoBoardCacheListenerTest.php @@ -2,8 +2,12 @@ namespace Modules\Sirsoft\Board\Tests\Unit\Listeners; +use App\Contracts\Extension\CacheInterface; +use App\Jobs\GenerateSitemapJob; use App\Seo\Contracts\SeoCacheManagerInterface; use App\Seo\SeoCacheRegenerator; +use App\Seo\SitemapIndexer; +use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Log; use Mockery; use Modules\Sirsoft\Board\Listeners\SeoBoardCacheListener; @@ -38,6 +42,10 @@ class SeoBoardCacheListenerTest extends ModuleTestCase $this->regeneratorMock = Mockery::mock(SeoCacheRegenerator::class); $this->app->instance(SeoCacheRegenerator::class, $this->regeneratorMock); + // 사이트맵 색인 경로는 이 테스트 범위 밖 — spy 로 대체하고 잡을 fake 하여 DB/큐 부작용을 차단 + $this->app->instance(SitemapIndexer::class, Mockery::spy(SitemapIndexer::class)); + Bus::fake([GenerateSitemapJob::class]); + $this->listener = new SeoBoardCacheListener; } @@ -77,6 +85,7 @@ class SeoBoardCacheListenerTest extends ModuleTestCase * 이 훅 미구독이 "게시판 정보 수정 후 SEO 메타 캐시 미무효화" 버그의 원인이었습니다. * * @scenario name_changed=no, description_changed=no + * * @effects board_after_update_hook_subscribed */ public function test_subscribes_board_after_update_hook(): void @@ -98,6 +107,7 @@ class SeoBoardCacheListenerTest extends ModuleTestCase * sitemap 캐시를 무효화해야 합니다. (게시글 상세는 board name 미노출이라 제외) * * @scenario name_changed=yes, description_changed=no + * * @effects board_update_invalidates_board_list_url, board_update_invalidates_boards_index_url, board_update_invalidates_home_and_search_layouts, board_update_invalidates_sitemap_cache */ public function test_on_board_update_invalidates_board_seo_caches(): void @@ -390,9 +400,9 @@ class SeoBoardCacheListenerTest extends ModuleTestCase }); // sitemap 캐시(seo.sitemap) forget 검증 — CacheInterface - $cacheInterfaceMock = Mockery::mock(\App\Contracts\Extension\CacheInterface::class); + $cacheInterfaceMock = Mockery::mock(CacheInterface::class); $cacheInterfaceMock->shouldReceive('forget')->once()->with('seo.sitemap'); - $this->app->instance(\App\Contracts\Extension\CacheInterface::class, $cacheInterfaceMock); + $this->app->instance(CacheInterface::class, $cacheInterfaceMock); Log::shouldReceive('debug')->atLeast()->once(); diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index 2c308f92..fa2706f7 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -9,6 +9,7 @@ ### Added - 사이트맵에 담을 상품·카테고리 수의 안전 상한을 지원합니다. 상한을 설정하면 상품 목록·카테고리·상품 어느 항목이든 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다. +- 상품·카테고리를 전시/숨김으로 바꾸거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 상품이 많은 쇼핑몰에서도 빠르게 최신 상태가 유지됩니다. ### Changed diff --git a/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoCategoryCacheListener.php b/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoCategoryCacheListener.php index 9e81ce67..99f22ec4 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoCategoryCacheListener.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoCategoryCacheListener.php @@ -4,7 +4,9 @@ namespace Modules\Sirsoft\Ecommerce\Listeners; use App\Contracts\Extension\CacheInterface; use App\Contracts\Extension\HookListenerInterface; +use App\Jobs\GenerateSitemapJob; use App\Seo\Contracts\SeoCacheManagerInterface; +use App\Seo\SitemapIndexer; use Illuminate\Support\Facades\Log; /** @@ -32,7 +34,7 @@ class SeoCategoryCacheListener implements HookListenerInterface 'priority' => 20, ], 'sirsoft-ecommerce.category.after_delete' => [ - 'method' => 'onCategoryChange', + 'method' => 'onCategoryDelete', 'priority' => 20, ], ]; @@ -87,5 +89,75 @@ class SeoCategoryCacheListener implements HookListenerInterface 'error' => $e->getMessage(), ]); } + + $this->syncSitemapIndex($category); + } + + /** + * 카테고리 삭제 시 캐시를 무효화하고 사이트맵 색인을 제거합니다. + * + * after_delete 훅은 모델이 아닌 카테고리 ID(int)를 전달합니다. + * + * @param mixed ...$args 훅 인자 (첫 번째: 카테고리 ID) + */ + public function onCategoryDelete(...$args): void + { + $this->onCategoryChange(...$args); + + $categoryId = $args[0] ?? null; + if ($categoryId === null) { + return; + } + + try { + app(SitemapIndexer::class)->deindexResource('category', is_object($categoryId) ? $categoryId->id : $categoryId); + GenerateSitemapJob::dispatch(); + } catch (\Throwable $e) { + Log::warning('[SEO] Category sitemap index removal failed', [ + 'error' => $e->getMessage(), + ]); + } + } + + /** + * 카테고리의 사이트맵 색인을 증분 갱신합니다. + * + * 활성(is_active) 이며 'SEO 제공 페이지(카테고리)' 토글이 켜져 있으면 색인(upsert), + * 아니면 색인 해제(remove)한 뒤, 사이트맵 재생성 잡을 디바운스 디스패치합니다. + * 색인 규칙은 EcommerceSitemapContributor 의 카테고리 URL 규칙과 일치해야 합니다. + * + * @param mixed $category Category 모델 (create/update) + */ + private function syncSitemapIndex(mixed $category): void + { + if (! is_object($category) || ! isset($category->id) || ! isset($category->slug)) { + return; + } + + try { + $indexer = app(SitemapIndexer::class); + + $visible = (bool) ($category->is_active ?? false) + && (bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_category', true); + + if ($visible) { + $routePath = g7_module_settings('sirsoft-ecommerce', 'basic_info.route_path', 'shop'); + $indexer->indexResource('category', $category->id, 'sirsoft-ecommerce', [[ + 'url' => "/{$routePath}/category/{$category->slug}", + 'lastmod' => $category->updated_at?->toW3cString(), + 'changefreq' => 'weekly', + 'priority' => 0.6, + ]]); + } else { + $indexer->deindexResource('category', $category->id); + } + + GenerateSitemapJob::dispatch(); + } catch (\Throwable $e) { + Log::warning('[SEO] Category sitemap index sync failed', [ + 'error' => $e->getMessage(), + 'category_id' => $category->id ?? null, + ]); + } } } diff --git a/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoProductCacheListener.php b/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoProductCacheListener.php index ee2d86cb..ca75d822 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoProductCacheListener.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoProductCacheListener.php @@ -4,9 +4,12 @@ namespace Modules\Sirsoft\Ecommerce\Listeners; use App\Contracts\Extension\CacheInterface; use App\Contracts\Extension\HookListenerInterface; +use App\Jobs\GenerateSitemapJob; use App\Seo\Contracts\SeoCacheManagerInterface; use App\Seo\SeoCacheRegenerator; +use App\Seo\SitemapIndexer; use Illuminate\Support\Facades\Log; +use Modules\Sirsoft\Ecommerce\Enums\ProductDisplayStatus; /** * 상품 변경 시 SEO 캐시 무효화 리스너 @@ -59,6 +62,7 @@ class SeoProductCacheListener implements HookListenerInterface { $this->invalidateRelatedCaches($args); $this->regenerateDetailCache($args); + $this->syncSitemapIndex($args, false); } /** @@ -70,6 +74,7 @@ class SeoProductCacheListener implements HookListenerInterface { $this->invalidateRelatedCaches($args); $this->regenerateDetailCache($args); + $this->syncSitemapIndex($args, false); } /** @@ -82,6 +87,7 @@ class SeoProductCacheListener implements HookListenerInterface public function onProductDelete(...$args): void { $this->invalidateRelatedCaches($args); + $this->syncSitemapIndex($args, true); } /** @@ -127,6 +133,53 @@ class SeoProductCacheListener implements HookListenerInterface } } + /** + * 상품의 사이트맵 색인을 증분 갱신합니다. + * + * 전시(display_status=VISIBLE)이며 'SEO 제공 페이지(상품 상세)' 토글이 켜져 있으면 색인(upsert), + * 비공개/토글 OFF/삭제이면 색인 해제(remove)한 뒤, 사이트맵 재생성 잡을 디바운스 디스패치합니다. + * 색인 규칙은 EcommerceSitemapContributor 의 상품 URL 규칙과 일치해야 합니다. + * + * @param array $args 훅 인자 배열 (첫 번째: Product 모델) + * @param bool $deleted 삭제 이벤트 여부 + */ + private function syncSitemapIndex(array $args, bool $deleted): void + { + $product = $args[0] ?? null; + + if (! $product || ! isset($product->id)) { + return; + } + + try { + $indexer = app(SitemapIndexer::class); + + $visible = ! $deleted + && ($product->display_status ?? null) === ProductDisplayStatus::VISIBLE + && (bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_product_detail', true); + + if ($visible) { + $routePath = g7_module_settings('sirsoft-ecommerce', 'basic_info.route_path', 'shop'); + $indexer->indexResource('product', $product->id, 'sirsoft-ecommerce', [[ + 'url' => "/{$routePath}/products/{$product->id}", + 'lastmod' => $product->updated_at?->toW3cString(), + 'changefreq' => 'weekly', + 'priority' => 0.8, + ]]); + } else { + $indexer->deindexResource('product', $product->id); + } + + // 파일 재작성은 잡에 위임 (ShouldBeUnique 로 디바운스). auto 모드 → 저장소 기반 증분. + GenerateSitemapJob::dispatch(); + } catch (\Throwable $e) { + Log::warning('[SEO] Product sitemap index sync failed', [ + 'error' => $e->getMessage(), + 'product_id' => $product->id ?? null, + ]); + } + } + /** * 상품 상세 페이지의 SEO 캐시를 즉시 재생성합니다. * diff --git a/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php b/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php index be602fa7..22951383 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php @@ -81,6 +81,8 @@ class EcommerceSitemapContributor extends AbstractSitemapContributor 'url' => "/{$routePath}/products", 'changefreq' => 'daily', 'priority' => 0.7, + 'resource_type' => 'shop_index', + 'resource_id' => null, ]; } @@ -92,6 +94,8 @@ class EcommerceSitemapContributor extends AbstractSitemapContributor 'lastmod' => $category->updated_at?->toW3cString(), 'changefreq' => 'weekly', 'priority' => 0.6, + 'resource_type' => 'category', + 'resource_id' => (string) $category->id, ]; } } @@ -104,6 +108,8 @@ class EcommerceSitemapContributor extends AbstractSitemapContributor 'lastmod' => $product->updated_at?->toW3cString(), 'changefreq' => 'weekly', 'priority' => 0.8, + 'resource_type' => 'product', + 'resource_id' => (string) $product->id, ]; } } diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Seo/EcommerceSitemapIndexTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Seo/EcommerceSitemapIndexTest.php new file mode 100644 index 00000000..2b6ed0e3 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Seo/EcommerceSitemapIndexTest.php @@ -0,0 +1,135 @@ + 42, 'display_status' => ProductDisplayStatus::VISIBLE, 'updated_at' => now()]; + + (new SeoProductCacheListener)->onProductCreate($product); + + $row = SitemapUrl::where('resource_type', 'product')->where('resource_id', '42')->first(); + $this->assertNotNull($row, '전시 상품은 사이트맵에 색인돼야 합니다.'); + $this->assertStringEndsWith('/products/42', $row->loc); + $this->assertSame('sirsoft-ecommerce', $row->contributor); + + Bus::assertDispatched(GenerateSitemapJob::class); + } + + /** + * 숨김 상품으로 수정 시 사이트맵 색인이 제거(remove)된다. + */ + public function test_hidden_product_update_removes_from_sitemap(): void + { + // 먼저 전시로 색인 + (new SeoProductCacheListener)->onProductCreate( + (object) ['id' => 7, 'display_status' => ProductDisplayStatus::VISIBLE, 'updated_at' => now()] + ); + $this->assertSame(1, SitemapUrl::where('resource_id', '7')->count()); + + // 숨김으로 전환 + (new SeoProductCacheListener)->onProductUpdate( + (object) ['id' => 7, 'display_status' => ProductDisplayStatus::HIDDEN, 'updated_at' => now()] + ); + + $this->assertSame(0, SitemapUrl::where('resource_id', '7')->count(), '숨김 상품은 색인에서 제거돼야 합니다.'); + } + + /** + * 상품 삭제 시 사이트맵 색인이 제거된다. + */ + public function test_product_delete_removes_from_sitemap(): void + { + (new SeoProductCacheListener)->onProductCreate( + (object) ['id' => 9, 'display_status' => ProductDisplayStatus::VISIBLE, 'updated_at' => now()] + ); + $this->assertSame(1, SitemapUrl::where('resource_id', '9')->count()); + + (new SeoProductCacheListener)->onProductDelete((object) ['id' => 9]); + + $this->assertSame(0, SitemapUrl::where('resource_type', 'product')->where('resource_id', '9')->count()); + } + + /** + * 활성 카테고리 변경 시 색인, 비활성 시 색인 제거. + */ + public function test_category_visibility_toggles_index(): void + { + $listener = new SeoCategoryCacheListener; + + $listener->onCategoryChange((object) ['id' => 3, 'slug' => 'shoes', 'is_active' => true, 'updated_at' => now()]); + $row = SitemapUrl::where('resource_type', 'category')->where('resource_id', '3')->first(); + $this->assertNotNull($row); + $this->assertStringContainsString('/category/shoes', $row->loc); + + $listener->onCategoryChange((object) ['id' => 3, 'slug' => 'shoes', 'is_active' => false, 'updated_at' => now()]); + $this->assertSame(0, SitemapUrl::where('resource_type', 'category')->where('resource_id', '3')->count()); + } + + /** + * 카테고리 삭제(ID 전달) 시 색인이 제거된다. + */ + public function test_category_delete_removes_index(): void + { + $listener = new SeoCategoryCacheListener; + $listener->onCategoryChange((object) ['id' => 4, 'slug' => 'bags', 'is_active' => true, 'updated_at' => now()]); + $this->assertSame(1, SitemapUrl::where('resource_type', 'category')->where('resource_id', '4')->count()); + + // after_delete 훅은 모델이 아닌 ID(int)를 전달 + $listener->onCategoryDelete(4); + + $this->assertSame(0, SitemapUrl::where('resource_type', 'category')->where('resource_id', '4')->count()); + } + + /** + * 'SEO 제공 페이지(상품 상세)' 토글이 OFF 면 전시 상품이어도 색인이 제거된다. + * + * 리스너의 공개상태 판정은 전시 여부 AND 토글을 함께 본다(기여자 getUrlsLazy 와 일치). + */ + public function test_product_detail_toggle_off_removes_index_even_when_visible(): void + { + $listener = new SeoProductCacheListener; + + // 토글 ON 상태로 먼저 색인 + $listener->onProductCreate((object) ['id' => 50, 'display_status' => ProductDisplayStatus::VISIBLE, 'updated_at' => now()]); + $this->assertSame(1, SitemapUrl::where('resource_id', '50')->count()); + + // 토글 OFF → 전시 상태여도 색인 제거 + Config::set('g7_settings.modules.sirsoft-ecommerce.seo.seo_product_detail', false); + $listener->onProductUpdate((object) ['id' => 50, 'display_status' => ProductDisplayStatus::VISIBLE, 'updated_at' => now()]); + + $this->assertSame(0, SitemapUrl::where('resource_id', '50')->count(), 'seo_product_detail OFF 면 전시 상품도 색인에서 제거돼야 합니다.'); + } +} diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SeoCategoryCacheListenerTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SeoCategoryCacheListenerTest.php index 843b2c14..85af9afc 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SeoCategoryCacheListenerTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SeoCategoryCacheListenerTest.php @@ -2,8 +2,12 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Listeners; +use App\Jobs\GenerateSitemapJob; use App\Seo\Contracts\SeoCacheManagerInterface; +use App\Seo\SitemapIndexer; +use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Log; +use Mockery; use Modules\Sirsoft\Ecommerce\Listeners\SeoCategoryCacheListener; use Tests\TestCase; @@ -19,9 +23,20 @@ class SeoCategoryCacheListenerTest extends TestCase protected function setUp(): void { parent::setUp(); + + // 사이트맵 색인 경로는 이 테스트 범위 밖 — spy 로 대체하고 잡을 fake 하여 DB/큐 부작용을 차단 + $this->app->instance(SitemapIndexer::class, Mockery::spy(SitemapIndexer::class)); + Bus::fake([GenerateSitemapJob::class]); + $this->listener = new SeoCategoryCacheListener; } + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + // ======================================== // getSubscribedHooks() 테스트 // ======================================== @@ -33,18 +48,22 @@ class SeoCategoryCacheListenerTest extends TestCase { $hooks = SeoCategoryCacheListener::getSubscribedHooks(); - $expectedHooks = [ + // create/update 는 onCategoryChange, delete 는 onCategoryDelete (모델 대신 ID 를 받으므로 분리) + $changeHooks = [ 'sirsoft-ecommerce.category.after_create', 'sirsoft-ecommerce.category.after_update', - 'sirsoft-ecommerce.category.after_delete', ]; - foreach ($expectedHooks as $hookName) { + foreach ($changeHooks as $hookName) { $this->assertArrayHasKey($hookName, $hooks); $this->assertEquals('onCategoryChange', $hooks[$hookName]['method']); $this->assertEquals(20, $hooks[$hookName]['priority']); } + $this->assertArrayHasKey('sirsoft-ecommerce.category.after_delete', $hooks); + $this->assertEquals('onCategoryDelete', $hooks['sirsoft-ecommerce.category.after_delete']['method']); + $this->assertEquals(20, $hooks['sirsoft-ecommerce.category.after_delete']['priority']); + $this->assertCount(3, $hooks); } @@ -110,6 +129,9 @@ class SeoCategoryCacheListenerTest extends TestCase // When $this->listener->onCategoryChange($category); + + // 검증은 Log::shouldReceive 기대치(Mockery::close)로 수행됨 + $this->addToAssertionCount(1); } /** @@ -135,6 +157,9 @@ class SeoCategoryCacheListenerTest extends TestCase // When $this->listener->onCategoryChange($categoryId); + + // 검증은 Log::shouldReceive 기대치(Mockery::close)로 수행됨 + $this->addToAssertionCount(1); } /** @@ -188,6 +213,9 @@ class SeoCategoryCacheListenerTest extends TestCase // When & Then: 예외가 전파되지 않음 $this->listener->onCategoryChange($category); + + // 검증은 Log::shouldReceive 기대치(Mockery::close)로 수행됨 + $this->addToAssertionCount(1); } // ======================================== diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SeoProductCacheListenerTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SeoProductCacheListenerTest.php index bd6bc091..81f84892 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SeoProductCacheListenerTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Unit/Listeners/SeoProductCacheListenerTest.php @@ -2,8 +2,11 @@ namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Listeners; +use App\Jobs\GenerateSitemapJob; use App\Seo\Contracts\SeoCacheManagerInterface; use App\Seo\SeoCacheRegenerator; +use App\Seo\SitemapIndexer; +use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Log; use Mockery; use Modules\Sirsoft\Ecommerce\Listeners\SeoProductCacheListener; @@ -36,6 +39,10 @@ class SeoProductCacheListenerTest extends TestCase $this->regeneratorMock = Mockery::mock(SeoCacheRegenerator::class); $this->app->instance(SeoCacheRegenerator::class, $this->regeneratorMock); + // 사이트맵 색인 경로는 이 테스트 범위 밖 — spy 로 대체하고 잡을 fake 하여 DB/큐 부작용을 차단 + $this->app->instance(SitemapIndexer::class, Mockery::spy(SitemapIndexer::class)); + Bus::fake([GenerateSitemapJob::class]); + $this->listener = new SeoProductCacheListener; } diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md index b2367b01..21db7ade 100644 --- a/modules/_bundled/sirsoft-page/CHANGELOG.md +++ b/modules/_bundled/sirsoft-page/CHANGELOG.md @@ -9,6 +9,7 @@ ### Added - 사이트맵에 담을 페이지 수의 안전 상한을 지원합니다. 상한을 설정하면 그 수를 넘지 않으며, 상한에 걸려 일부가 빠진 경우 기록으로 남습니다. +- 페이지를 발행/미발행하거나 삭제하면 사이트맵에 해당 항목만 자동으로 반영됩니다. 전체를 다시 만들지 않고 바뀐 부분만 갱신하므로 페이지가 많은 사이트에서도 빠르게 최신 상태가 유지됩니다. ### Changed diff --git a/modules/_bundled/sirsoft-page/src/Listeners/SeoPageCacheListener.php b/modules/_bundled/sirsoft-page/src/Listeners/SeoPageCacheListener.php index 41d4d060..3fa39300 100644 --- a/modules/_bundled/sirsoft-page/src/Listeners/SeoPageCacheListener.php +++ b/modules/_bundled/sirsoft-page/src/Listeners/SeoPageCacheListener.php @@ -4,7 +4,9 @@ namespace Modules\Sirsoft\Page\Listeners; use App\Contracts\Extension\CacheInterface; use App\Contracts\Extension\HookListenerInterface; +use App\Jobs\GenerateSitemapJob; use App\Seo\Contracts\SeoCacheManagerInterface; +use App\Seo\SitemapIndexer; use Illuminate\Support\Facades\Log; /** @@ -32,7 +34,7 @@ class SeoPageCacheListener implements HookListenerInterface 'priority' => 20, ], 'sirsoft-page.page.after_delete' => [ - 'method' => 'onPageChange', + 'method' => 'onPageDelete', 'priority' => 20, ], // 버전 복원도 콘텐츠(title/content/seo_meta) 변경이므로 수정과 동일하게 무효화 @@ -94,5 +96,60 @@ class SeoPageCacheListener implements HookListenerInterface 'page_id' => is_object($page) ? ($page->id ?? null) : null, ]); } + + $this->syncSitemapIndex($page, false); + } + + /** + * 페이지 삭제 시 SEO 캐시를 무효화하고 사이트맵 색인을 제거합니다. + * + * @param mixed ...$args 훅 인자 (첫 번째: Page 모델) + */ + public function onPageDelete(...$args): void + { + $this->onPageChange(...$args); + + $this->syncSitemapIndex($args[0] ?? null, true); + } + + /** + * 페이지의 사이트맵 색인을 증분 갱신합니다. + * + * 발행(published) 상태이면 색인(upsert), 미발행/삭제이면 색인 해제(remove)한 뒤 + * 사이트맵 재생성 잡을 디바운스 디스패치합니다. + * 색인 규칙은 PageSitemapContributor 의 페이지 URL 규칙과 일치해야 합니다. + * + * @param mixed $page Page 모델 + * @param bool $deleted 삭제 이벤트 여부 + */ + private function syncSitemapIndex(mixed $page, bool $deleted): void + { + if (! is_object($page) || ! isset($page->id) || ! isset($page->slug)) { + return; + } + + try { + $indexer = app(SitemapIndexer::class); + + $visible = ! $deleted && (bool) ($page->published ?? false); + + if ($visible) { + $indexer->indexResource('page', $page->id, 'sirsoft-page', [[ + 'url' => "/page/{$page->slug}", + 'lastmod' => $page->updated_at?->toW3cString(), + 'changefreq' => 'monthly', + 'priority' => 0.5, + ]]); + } else { + $indexer->deindexResource('page', $page->id); + } + + GenerateSitemapJob::dispatch(); + } catch (\Throwable $e) { + Log::warning('[SEO] Page sitemap index sync failed', [ + 'error' => $e->getMessage(), + 'page_id' => is_object($page) ? ($page->id ?? null) : null, + ]); + } } } diff --git a/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php b/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php index 6bdca007..bdf87c17 100644 --- a/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php +++ b/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php @@ -62,6 +62,8 @@ class PageSitemapContributor extends AbstractSitemapContributor 'lastmod' => $page->updated_at?->toW3cString(), 'changefreq' => 'monthly', 'priority' => 0.5, + 'resource_type' => 'page', + 'resource_id' => (string) $page->id, ]; $emitted++; } diff --git a/modules/_bundled/sirsoft-page/tests/Feature/Seo/PageSitemapIndexTest.php b/modules/_bundled/sirsoft-page/tests/Feature/Seo/PageSitemapIndexTest.php new file mode 100644 index 00000000..5ca854a2 --- /dev/null +++ b/modules/_bundled/sirsoft-page/tests/Feature/Seo/PageSitemapIndexTest.php @@ -0,0 +1,80 @@ +app->instance(SeoCacheManagerInterface::class, Mockery::spy(SeoCacheManagerInterface::class)); + Bus::fake([GenerateSitemapJob::class]); + + $this->listener = new SeoPageCacheListener; + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + /** + * 발행된 페이지 생성/수정 시 색인(append)된다. + */ + public function test_published_page_is_indexed(): void + { + $page = (object) ['id' => 200, 'slug' => 'about', 'published' => true, 'updated_at' => now()]; + + $this->listener->onPageChange($page); + + $row = SitemapUrl::where('resource_type', 'page')->where('resource_id', '200')->first(); + $this->assertNotNull($row, '발행 페이지는 색인돼야 합니다.'); + $this->assertStringEndsWith('/page/about', $row->loc); + Bus::assertDispatched(GenerateSitemapJob::class); + } + + /** + * 미발행 페이지는 색인되지 않고, 발행→미발행 전환 시 색인이 제거된다. + */ + public function test_unpublished_page_removes_index(): void + { + $this->listener->onPageChange((object) ['id' => 201, 'slug' => 'draft', 'published' => true, 'updated_at' => now()]); + $this->assertSame(1, SitemapUrl::where('resource_id', '201')->count()); + + $this->listener->onPageChange((object) ['id' => 201, 'slug' => 'draft', 'published' => false, 'updated_at' => now()]); + $this->assertSame(0, SitemapUrl::where('resource_id', '201')->count(), '미발행 전환 시 색인이 제거돼야 합니다.'); + } + + /** + * 페이지 삭제 시 색인이 제거된다. + */ + public function test_page_delete_removes_index(): void + { + $this->listener->onPageChange((object) ['id' => 202, 'slug' => 'gone', 'published' => true, 'updated_at' => now()]); + $this->assertSame(1, SitemapUrl::where('resource_id', '202')->count()); + + $this->listener->onPageDelete((object) ['id' => 202, 'slug' => 'gone', 'published' => true]); + + $this->assertSame(0, SitemapUrl::where('resource_type', 'page')->where('resource_id', '202')->count()); + } +} diff --git a/modules/_bundled/sirsoft-page/tests/Unit/Listeners/SeoPageCacheListenerTest.php b/modules/_bundled/sirsoft-page/tests/Unit/Listeners/SeoPageCacheListenerTest.php index 1db416af..3db014c0 100644 --- a/modules/_bundled/sirsoft-page/tests/Unit/Listeners/SeoPageCacheListenerTest.php +++ b/modules/_bundled/sirsoft-page/tests/Unit/Listeners/SeoPageCacheListenerTest.php @@ -3,8 +3,12 @@ namespace Modules\Sirsoft\Page\Tests\Unit\Listeners; use App\Contracts\Extension\CacheInterface; +use App\Jobs\GenerateSitemapJob; use App\Seo\Contracts\SeoCacheManagerInterface; use App\Seo\SeoCacheManager; +use App\Seo\SitemapIndexer; +use Illuminate\Support\Facades\Bus; +use Mockery; use Modules\Sirsoft\Page\Listeners\SeoPageCacheListener; use Modules\Sirsoft\Page\Tests\ModuleTestCase; @@ -25,9 +29,20 @@ class SeoPageCacheListenerTest extends ModuleTestCase parent::setUp(); $this->cache = $this->app->make(SeoCacheManagerInterface::class); + + // 사이트맵 색인 경로는 이 테스트 범위 밖 — spy 로 대체하고 잡을 fake 하여 DB/큐 부작용을 차단 + $this->app->instance(SitemapIndexer::class, Mockery::spy(SitemapIndexer::class)); + Bus::fake([GenerateSitemapJob::class]); + $this->listener = new SeoPageCacheListener; } + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + // ─── 훅 구독 등록 ────────────────────────────────────── /** @@ -45,7 +60,8 @@ class SeoPageCacheListenerTest extends ModuleTestCase $this->assertEquals('onPageChange', $hooks['sirsoft-page.page.after_create']['method']); $this->assertEquals('onPageChange', $hooks['sirsoft-page.page.after_update']['method']); - $this->assertEquals('onPageChange', $hooks['sirsoft-page.page.after_delete']['method']); + // delete 는 모델 상태와 무관하게 색인을 제거해야 하므로 전용 메서드로 분리 + $this->assertEquals('onPageDelete', $hooks['sirsoft-page.page.after_delete']['method']); $this->assertEquals('onPageChange', $hooks['sirsoft-page.page.after_restore']['method']); $this->assertEquals(20, $hooks['sirsoft-page.page.after_create']['priority']); diff --git a/tests/Feature/Seo/SitemapBackfillTest.php b/tests/Feature/Seo/SitemapBackfillTest.php new file mode 100644 index 00000000..a7461337 --- /dev/null +++ b/tests/Feature/Seo/SitemapBackfillTest.php @@ -0,0 +1,90 @@ +app->make(SitemapGenerator::class)->registerContributor($this->fakeContributor()); + } + + /** + * resource identity 를 emit 하는 가짜 기여자를 만듭니다. + * + * @return AbstractSitemapContributor 가짜 기여자 + */ + private function fakeContributor(): AbstractSitemapContributor + { + return new class extends AbstractSitemapContributor + { + public function getIdentifier(): string + { + return 'fake-contributor'; + } + + public function getUrlsLazy(): iterable + { + yield ['url' => '/x/1', 'resource_type' => 'thing', 'resource_id' => '1', 'changefreq' => 'daily', 'priority' => 0.7]; + yield ['url' => '/x/2', 'resource_type' => 'thing', 'resource_id' => '2', 'changefreq' => 'daily', 'priority' => 0.7]; + } + }; + } + + /** + * --rebuild --sync: 기여자로부터 저장소를 백필하고 파일을 커밋한다. + */ + public function test_rebuild_backfills_store_and_commits_files(): void + { + $this->artisan('seo:generate-sitemap', ['--rebuild' => true, '--sync' => true]) + ->assertExitCode(0); + + $rows = SitemapUrl::where('contributor', 'fake-contributor')->pluck('resource_id')->sort()->values()->all(); + $this->assertSame(['1', '2'], $rows, '기여자 URL 이 저장소에 백필돼야 합니다.'); + + $store = new SitemapFileStore(new CoreStorageDriver('local')); + $this->assertTrue($store->exists(), '백필 후 sitemap 파일 세트가 커밋돼야 합니다.'); + } + + /** + * --mode=auto --sync: 빈 저장소에서 full 로 자동 백필한다. + */ + public function test_auto_mode_backfills_when_store_empty(): void + { + $this->assertSame(0, SitemapUrl::count()); + + $this->artisan('seo:generate-sitemap', ['--mode' => 'auto', '--sync' => true]) + ->assertExitCode(0); + + $this->assertSame(2, SitemapUrl::where('contributor', 'fake-contributor')->count(), '빈 저장소에서 auto 는 full 백필이어야 합니다.'); + } +} diff --git a/tests/Feature/Seo/SitemapIndexerTest.php b/tests/Feature/Seo/SitemapIndexerTest.php new file mode 100644 index 00000000..717623e7 --- /dev/null +++ b/tests/Feature/Seo/SitemapIndexerTest.php @@ -0,0 +1,112 @@ +indexer = $this->app->make(SitemapIndexer::class); + HookManager::clearFilter(SitemapIndexer::COLLECT_FILTER); + } + + /** + * 테스트 정리 - filter 훅 누출 방지 + */ + protected function tearDown(): void + { + HookManager::clearFilter(SitemapIndexer::COLLECT_FILTER); + + parent::tearDown(); + } + + /** + * indexResource: 공개 리소스를 색인하며 url 을 loc 으로 정규화한다. + */ + public function test_index_resource_upserts_and_normalizes_url(): void + { + $this->indexer->indexResource('product', 7, 'sirsoft-ecommerce', [[ + 'url' => '/shop/products/7', + 'changefreq' => 'weekly', + 'priority' => 0.8, + ]]); + + $row = SitemapUrl::where('resource_type', 'product')->where('resource_id', '7')->first(); + + $this->assertNotNull($row); + $this->assertSame(url('/shop/products/7'), $row->loc, 'url 은 절대 loc 으로 정규화돼야 합니다.'); + $this->assertSame('sirsoft-ecommerce', $row->contributor); + } + + /** + * deindexResource: 색인을 제거한다. + */ + public function test_deindex_resource_removes_index(): void + { + $this->indexer->indexResource('page', 3, 'sirsoft-page', [['url' => '/page/about']]); + $this->assertSame(1, SitemapUrl::where('resource_type', 'page')->where('resource_id', '3')->count()); + + $this->indexer->deindexResource('page', 3); + + $this->assertSame(0, SitemapUrl::where('resource_type', 'page')->where('resource_id', '3')->count()); + } + + /** + * collect_for_resource filter 훅으로 제3자가 항목을 가공할 수 있다. + */ + public function test_collect_filter_hook_can_transform_entries(): void + { + HookManager::addFilter(SitemapIndexer::COLLECT_FILTER, function (array $entries) { + $entries[] = ['url' => '/extra']; + + return $entries; + }); + + $this->indexer->indexResource('product', 8, 'sirsoft-ecommerce', [['url' => '/shop/products/8']]); + + $locs = SitemapUrl::where('resource_type', 'product')->where('resource_id', '8')->pluck('loc')->all(); + + $this->assertContains(url('/shop/products/8'), $locs); + $this->assertContains(url('/extra'), $locs, 'filter 훅이 추가한 항목이 색인돼야 합니다.'); + } + + /** + * 필터가 항목을 모두 제거하면 색인도 제거된다(정합 유지). + */ + public function test_filter_removing_all_entries_deindexes(): void + { + $this->indexer->indexResource('product', 9, 'sirsoft-ecommerce', [['url' => '/shop/products/9']]); + $this->assertSame(1, SitemapUrl::where('resource_id', '9')->count()); + + HookManager::addFilter(SitemapIndexer::COLLECT_FILTER, fn () => []); + + $this->indexer->indexResource('product', 9, 'sirsoft-ecommerce', [['url' => '/shop/products/9']]); + + $this->assertSame(0, SitemapUrl::where('resource_id', '9')->count(), '항목이 모두 제거되면 색인이 제거돼야 합니다.'); + } +} diff --git a/tests/Feature/Seo/SitemapManagerModeTest.php b/tests/Feature/Seo/SitemapManagerModeTest.php new file mode 100644 index 00000000..d039bfd3 --- /dev/null +++ b/tests/Feature/Seo/SitemapManagerModeTest.php @@ -0,0 +1,187 @@ +storage = new CoreStorageDriver('local'); + $this->repository = $this->app->make(SitemapUrlRepositoryInterface::class); + } + + /** + * 호출 횟수를 세는 가짜 기여자를 만듭니다. + * + * @param array $entries emit 항목 + * @return AbstractSitemapContributor 카운터가 달린 기여자 + */ + private function countingContributor(array $entries): AbstractSitemapContributor + { + return new class($entries) extends AbstractSitemapContributor + { + public int $drainCount = 0; + + /** + * @param array> $entries emit 항목 + */ + public function __construct(private array $entries) {} + + public function getIdentifier(): string + { + return 'fake-contributor'; + } + + public function getUrlsLazy(): iterable + { + $this->drainCount++; + yield from $this->entries; + } + }; + } + + /** + * 매니저를 구성합니다 (기여자 등록 포함). + * + * @param AbstractSitemapContributor $contributor 등록할 기여자 + * @return SitemapManager 매니저 + */ + private function makeManager(AbstractSitemapContributor $contributor): SitemapManager + { + $generator = $this->app->make(SitemapGenerator::class); + $generator->registerContributor($contributor); + + return new SitemapManager( + $generator, + Mockery::spy(CacheInterface::class), + Mockery::spy(ConfigRepositoryInterface::class), + $this->storage, + $this->repository, + ); + } + + /** + * 커밋된 첫 자식 sitemap 의 XML 본문을 반환합니다. + * + * @return string 자식 XML + */ + private function firstChildXml(): string + { + return (string) $this->storage->get(SitemapFileStore::CATEGORY, SitemapFileStore::LIVE_DIR.'/'.SitemapFileStore::childFilename(1)); + } + + /** + * full: 저장소가 이미 채워져 있어도 기여자를 스트리밍해 전량 대체한다. + */ + public function test_full_mode_rebuilds_store_from_contributors(): void + { + // 옛(stale) 저장소 상태 — 전량 대체로 사라져야 한다 + $this->repository->replaceAllForContributor('fake-contributor', [ + ['loc' => url('/stale'), 'resource_type' => 'product', 'resource_id' => '999'], + ]); + + $contributor = $this->countingContributor([ + ['url' => '/fresh', 'resource_type' => 'product', 'resource_id' => '1'], + ]); + + $this->makeManager($contributor)->regenerate(SitemapGenerationMode::Full); + + $this->assertSame(1, $contributor->drainCount, 'full 은 기여자를 드레인해야 합니다.'); + $locs = SitemapUrl::where('contributor', 'fake-contributor')->pluck('loc')->all(); + $this->assertSame([url('/fresh')], $locs, 'full 은 저장소를 전량 대체해야 합니다.'); + $this->assertStringContainsString(url('/fresh'), $this->firstChildXml()); + } + + /** + * auto: 저장소가 비어 있으면 full 로 동작(기여자 드레인 + 저장소 채움). + */ + public function test_auto_mode_is_full_when_store_empty(): void + { + $contributor = $this->countingContributor([ + ['url' => '/a', 'resource_type' => 'product', 'resource_id' => '1'], + ]); + + $this->makeManager($contributor)->regenerate(SitemapGenerationMode::Auto); + + $this->assertSame(1, $contributor->drainCount, '빈 저장소에서 auto 는 full 이어야 합니다.'); + $this->assertSame(1, $this->repository->countVisible()); + } + + /** + * auto: 저장소가 채워져 있으면 incremental 로 동작(기여자 드레인 없음). + */ + public function test_auto_mode_is_incremental_when_store_populated(): void + { + // 다른 경로(리스너)로 채워진 저장소 — auto 는 이를 재쿼리 없이 사용 + $this->repository->upsertForResource('page', '1', [['loc' => url('/page/seeded'), 'contributor' => 'sirsoft-page']]); + + $contributor = $this->countingContributor([ + ['url' => '/should-not-appear', 'resource_type' => 'product', 'resource_id' => '1'], + ]); + + $this->makeManager($contributor)->regenerate(SitemapGenerationMode::Auto); + + $this->assertSame(0, $contributor->drainCount, '채워진 저장소에서 auto 는 기여자를 재쿼리하면 안 됩니다.'); + + $xml = $this->firstChildXml(); + $this->assertStringContainsString(url('/page/seeded'), $xml); + $this->assertStringNotContainsString(url('/should-not-appear'), $xml); + } + + /** + * incremental: 기여자 재쿼리 없이 저장소 스트림만으로 파일을 재작성한다. + */ + public function test_incremental_mode_streams_store_without_domain_requery(): void + { + $this->repository->upsertForResource('board_post', '1', [['loc' => url('/board/a/1'), 'contributor' => 'sirsoft-board']]); + + $contributor = $this->countingContributor([ + ['url' => '/should-not-appear', 'resource_type' => 'product', 'resource_id' => '1'], + ]); + + $this->makeManager($contributor)->regenerate(SitemapGenerationMode::Incremental); + + $this->assertSame(0, $contributor->drainCount, 'incremental 은 기여자를 재쿼리하면 안 됩니다.'); + $this->assertStringContainsString(url('/board/a/1'), $this->firstChildXml()); + } +} diff --git a/tests/Feature/Seo/SitemapUrlRepositoryTest.php b/tests/Feature/Seo/SitemapUrlRepositoryTest.php new file mode 100644 index 00000000..dff42503 --- /dev/null +++ b/tests/Feature/Seo/SitemapUrlRepositoryTest.php @@ -0,0 +1,176 @@ +repository = $this->app->make(SitemapUrlRepositoryInterface::class); + } + + /** + * upsertForResource: 항목을 삽입하고 반복 호출해도 테이블 내용이 동일하다(멱등). + */ + public function test_upsert_for_resource_is_idempotent(): void + { + $entries = [[ + 'loc' => 'https://example.com/shop/products/1', + 'contributor' => 'sirsoft-ecommerce', + 'lastmod' => '2026-07-18T00:00:00+00:00', + 'changefreq' => 'weekly', + 'priority' => 0.8, + ]]; + + $this->repository->upsertForResource('product', '1', $entries); + $this->repository->upsertForResource('product', '1', $entries); + + $rows = SitemapUrl::where('resource_type', 'product')->where('resource_id', '1')->get(); + + $this->assertCount(1, $rows, '같은 입력 반복 호출 시 행이 중복되면 안 됩니다.'); + $this->assertSame('https://example.com/shop/products/1', $rows[0]->loc); + $this->assertSame('sirsoft-ecommerce', $rows[0]->contributor); + $this->assertSame(0.8, $rows[0]->priority); + $this->assertTrue($rows[0]->is_visible); + } + + /** + * upsertForResource: loc(슬러그) 변경 시 옛 loc 행이 남지 않는다. + */ + public function test_upsert_for_resource_absorbs_loc_change(): void + { + $this->repository->upsertForResource('category', '5', [[ + 'loc' => 'https://example.com/shop/category/old-slug', + 'contributor' => 'sirsoft-ecommerce', + ]]); + + $this->repository->upsertForResource('category', '5', [[ + 'loc' => 'https://example.com/shop/category/new-slug', + 'contributor' => 'sirsoft-ecommerce', + ]]); + + $locs = SitemapUrl::where('resource_type', 'category')->where('resource_id', '5')->pluck('loc'); + + $this->assertSame(['https://example.com/shop/category/new-slug'], $locs->all()); + } + + /** + * removeForResource: 해당 리소스 행만 제거한다. + */ + public function test_remove_for_resource_deletes_only_that_resource(): void + { + $this->repository->upsertForResource('product', '1', [['loc' => 'https://example.com/p/1', 'contributor' => 'sirsoft-ecommerce']]); + $this->repository->upsertForResource('product', '2', [['loc' => 'https://example.com/p/2', 'contributor' => 'sirsoft-ecommerce']]); + + $this->repository->removeForResource('product', '1'); + + $this->assertSame(0, SitemapUrl::where('resource_type', 'product')->where('resource_id', '1')->count()); + $this->assertSame(1, SitemapUrl::where('resource_type', 'product')->where('resource_id', '2')->count()); + } + + /** + * streamVisible: is_visible=true 행을 id 순으로, contributor 스코핑하여 반환한다. + */ + public function test_stream_visible_orders_by_id_and_scopes_by_contributor(): void + { + $this->repository->upsertForResource('board_post', '1', [['loc' => 'https://example.com/board/a/1', 'contributor' => 'sirsoft-board']]); + $this->repository->upsertForResource('product', '1', [['loc' => 'https://example.com/shop/products/1', 'contributor' => 'sirsoft-ecommerce']]); + $this->repository->upsertForResource('board_post', '2', [['loc' => 'https://example.com/board/a/2', 'contributor' => 'sirsoft-board']]); + + $all = collect($this->repository->streamVisible())->map(fn ($r) => $r->loc)->all(); + $boardOnly = collect($this->repository->streamVisible('sirsoft-board'))->map(fn ($r) => $r->loc)->all(); + + $this->assertSame([ + 'https://example.com/board/a/1', + 'https://example.com/shop/products/1', + 'https://example.com/board/a/2', + ], $all, 'id(삽입) 순서를 유지해야 합니다.'); + + $this->assertSame([ + 'https://example.com/board/a/1', + 'https://example.com/board/a/2', + ], $boardOnly, 'contributor 스코핑이 적용돼야 합니다.'); + } + + /** + * countVisible: 공개 행 개수를 센다. + */ + public function test_count_visible_counts_rows(): void + { + $this->assertSame(0, $this->repository->countVisible()); + + $this->repository->upsertForResource('page', '1', [['loc' => 'https://example.com/page/a', 'contributor' => 'sirsoft-page']]); + $this->repository->upsertForResource('page', '2', [['loc' => 'https://example.com/page/b', 'contributor' => 'sirsoft-page']]); + + $this->assertSame(2, $this->repository->countVisible()); + } + + /** + * replaceAllForContributor: 기여자 스코프를 전량 대체하고 타 기여자는 건드리지 않는다. + */ + public function test_replace_all_for_contributor_replaces_scope_only(): void + { + // 다른 기여자(page) 는 보존돼야 한다 + $this->repository->upsertForResource('page', '1', [['loc' => 'https://example.com/page/a', 'contributor' => 'sirsoft-page']]); + + // board 기여자 1차 적재 + $this->repository->replaceAllForContributor('sirsoft-board', [ + ['loc' => 'https://example.com/board/a', 'resource_type' => 'board', 'resource_id' => '1'], + ['loc' => 'https://example.com/board/a/1', 'resource_type' => 'board_post', 'resource_id' => '1'], + ]); + + // board 기여자 2차 전량 대체 (1건으로 축소) + $this->repository->replaceAllForContributor('sirsoft-board', [ + ['loc' => 'https://example.com/board/b', 'resource_type' => 'board', 'resource_id' => '2'], + ]); + + $board = SitemapUrl::where('contributor', 'sirsoft-board')->pluck('loc')->all(); + $page = SitemapUrl::where('contributor', 'sirsoft-page')->pluck('loc')->all(); + + $this->assertSame(['https://example.com/board/b'], $board, 'board 스코프는 전량 대체돼야 합니다.'); + $this->assertSame(['https://example.com/page/a'], $page, '다른 기여자 행은 보존돼야 합니다.'); + } + + /** + * 긴 loc(2000자) 도 loc_hash 로 안전 저장 및 식별된다 (MySQL 키 길이 회피). + */ + public function test_long_loc_is_stored_via_hash_identity(): void + { + $longLoc = 'https://example.com/'.str_repeat('a', 1990); + + $this->repository->upsertForResource('product', '9', [['loc' => $longLoc, 'contributor' => 'sirsoft-ecommerce']]); + // 동일 loc 재호출 → 멱등 (해시 identity 로 대체) + $this->repository->upsertForResource('product', '9', [['loc' => $longLoc, 'contributor' => 'sirsoft-ecommerce']]); + + $rows = SitemapUrl::where('resource_type', 'product')->where('resource_id', '9')->get(); + + $this->assertCount(1, $rows); + $this->assertSame($longLoc, $rows[0]->loc); + $this->assertSame(hash('sha256', $longLoc), $rows[0]->loc_hash); + } +} diff --git a/tests/Unit/Console/Commands/SeoGenerateSitemapCommandTest.php b/tests/Unit/Console/Commands/SeoGenerateSitemapCommandTest.php index 148fe4db..8de1f3ee 100644 --- a/tests/Unit/Console/Commands/SeoGenerateSitemapCommandTest.php +++ b/tests/Unit/Console/Commands/SeoGenerateSitemapCommandTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\Console\Commands; +use App\Enums\SitemapGenerationMode; use App\Jobs\GenerateSitemapJob; use Illuminate\Console\Command; use Illuminate\Support\Facades\Bus; @@ -12,7 +13,8 @@ use Tests\TestCase; /** * SeoGenerateSitemapCommand 유닛 테스트 * - * seo:generate-sitemap 커맨드의 큐 드라이버 분기, 강제 동기, 출력 메시지를 검증합니다. + * seo:generate-sitemap 커맨드의 큐 드라이버 분기, 강제 동기, 모드 옵션(--mode/--rebuild), + * 출력 메시지를 검증합니다. */ class SeoGenerateSitemapCommandTest extends TestCase { @@ -31,10 +33,10 @@ class SeoGenerateSitemapCommandTest extends TestCase Bus::fake([GenerateSitemapJob::class]); $this->artisan('seo:generate-sitemap') - ->expectsOutput('Sitemap 생성이 큐에 디스패치되었습니다.') + ->expectsOutput('Sitemap 생성이 큐에 디스패치되었습니다. (모드: auto)') ->assertExitCode(Command::SUCCESS); - Bus::assertDispatched(GenerateSitemapJob::class); + Bus::assertDispatched(GenerateSitemapJob::class, fn (GenerateSitemapJob $job) => $job->mode === SitemapGenerationMode::Auto); Bus::assertNotDispatchedSync(GenerateSitemapJob::class); } @@ -47,7 +49,7 @@ class SeoGenerateSitemapCommandTest extends TestCase Bus::fake([GenerateSitemapJob::class]); $this->artisan('seo:generate-sitemap') - ->expectsOutput('Sitemap이 생성되었습니다.') + ->expectsOutput('Sitemap이 생성되었습니다. (모드: auto)') ->assertExitCode(Command::SUCCESS); Bus::assertDispatchedSync(GenerateSitemapJob::class); @@ -62,9 +64,53 @@ class SeoGenerateSitemapCommandTest extends TestCase Bus::fake([GenerateSitemapJob::class]); $this->artisan('seo:generate-sitemap', ['--sync' => true]) - ->expectsOutput('Sitemap이 생성되었습니다.') + ->expectsOutput('Sitemap이 생성되었습니다. (모드: auto)') ->assertExitCode(Command::SUCCESS); Bus::assertDispatchedSync(GenerateSitemapJob::class); } + + /** + * --mode 옵션이 잡에 그대로 전달됩니다. + */ + public function test_command_passes_mode_option_to_job(): void + { + Config::set('queue.default', 'database'); + Bus::fake([GenerateSitemapJob::class]); + + $this->artisan('seo:generate-sitemap', ['--mode' => 'incremental']) + ->expectsOutput('Sitemap 생성이 큐에 디스패치되었습니다. (모드: incremental)') + ->assertExitCode(Command::SUCCESS); + + Bus::assertDispatched(GenerateSitemapJob::class, fn (GenerateSitemapJob $job) => $job->mode === SitemapGenerationMode::Incremental); + } + + /** + * --rebuild 옵션은 --mode 보다 우선하며 full 로 강제합니다. + */ + public function test_rebuild_option_forces_full_mode(): void + { + Config::set('queue.default', 'database'); + Bus::fake([GenerateSitemapJob::class]); + + $this->artisan('seo:generate-sitemap', ['--rebuild' => true, '--mode' => 'incremental']) + ->expectsOutput('Sitemap 생성이 큐에 디스패치되었습니다. (모드: full)') + ->assertExitCode(Command::SUCCESS); + + Bus::assertDispatched(GenerateSitemapJob::class, fn (GenerateSitemapJob $job) => $job->mode === SitemapGenerationMode::Full); + } + + /** + * 잘못된 --mode 값은 INVALID 로 거부하고 잡을 디스패치하지 않습니다. + */ + public function test_invalid_mode_is_rejected(): void + { + Config::set('queue.default', 'database'); + Bus::fake([GenerateSitemapJob::class]); + + $this->artisan('seo:generate-sitemap', ['--mode' => 'bogus']) + ->assertExitCode(Command::INVALID); + + Bus::assertNotDispatched(GenerateSitemapJob::class); + } } diff --git a/tests/Unit/Seo/SitemapManagerTest.php b/tests/Unit/Seo/SitemapManagerTest.php index 952e6168..5673c09c 100644 --- a/tests/Unit/Seo/SitemapManagerTest.php +++ b/tests/Unit/Seo/SitemapManagerTest.php @@ -4,12 +4,14 @@ namespace Tests\Unit\Seo; use App\Contracts\Extension\CacheInterface; use App\Contracts\Repositories\ConfigRepositoryInterface; +use App\Contracts\Repositories\SitemapUrlRepositoryInterface; use App\Extension\HookManager; use App\Extension\Storage\CoreStorageDriver; use App\Seo\Contracts\SitemapContributorInterface; use App\Seo\SitemapFileStore; use App\Seo\SitemapGenerator; use App\Seo\SitemapManager; +use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Storage; use Mockery; @@ -31,6 +33,8 @@ use Tests\TestCase; */ class SitemapManagerTest extends TestCase { + use RefreshDatabase; + /** * 이 테스트가 등록·검증하는 sitemap 훅 목록 */ @@ -128,6 +132,7 @@ class SitemapManagerTest extends TestCase $cache ?? Mockery::spy(CacheInterface::class), $config ?? Mockery::spy(ConfigRepositoryInterface::class), $this->storage, + $this->app->make(SitemapUrlRepositoryInterface::class), ); } @@ -299,13 +304,14 @@ class SitemapManagerTest extends TestCase // 생성 도중 예외를 던지는 generator 로 실패를 강제한다. $generator = Mockery::mock(SitemapGenerator::class); - $generator->shouldReceive('generateToWriter')->andThrow(new \RuntimeException('디스크 장애')); + $generator->shouldReceive('generateToWriterFromEntries')->andThrow(new \RuntimeException('디스크 장애')); $manager = new SitemapManager( $generator, Mockery::spy(CacheInterface::class), Mockery::spy(ConfigRepositoryInterface::class), $this->storage, + $this->incrementalRepository(), ); $result = $manager->regenerate(); @@ -329,18 +335,36 @@ class SitemapManagerTest extends TestCase $cache->shouldNotReceive('forget'); $generator = Mockery::mock(SitemapGenerator::class); - $generator->shouldReceive('generateToWriter')->andThrow(new \RuntimeException('장애')); + $generator->shouldReceive('generateToWriterFromEntries')->andThrow(new \RuntimeException('장애')); $manager = new SitemapManager( $generator, $cache, Mockery::spy(ConfigRepositoryInterface::class), $this->storage, + $this->incrementalRepository(), ); $this->assertFalse($manager->regenerate()['success']); } + /** + * incremental 모드로 진입시키는(테이블이 비어있지 않은) mock 저장소를 만듭니다. + * + * countVisible()>0 → auto 가 incremental 로 해석되어 rebuildStore(전체 재적재)를 건너뛰고 + * 곧바로 generateToWriterFromEntries 로 진입합니다(실패 경로 단순화). + * + * @return SitemapUrlRepositoryInterface mock 저장소 + */ + private function incrementalRepository(): SitemapUrlRepositoryInterface + { + $repository = Mockery::mock(SitemapUrlRepositoryInterface::class); + $repository->shouldReceive('countVisible')->andReturn(1); + $repository->shouldReceive('streamVisible')->andReturn([]); + + return $repository; + } + /** * regenerate: seo.sitemap_urls_per_file 설정이 자식 분할에 반영된다 */ From face02207318ac01fc5f8624aed87a4a5c3c9b22 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Sat, 18 Jul 2026 18:22:16 +0900 Subject: [PATCH 05/11] =?UTF-8?q?feat(seo):=20=EC=82=AC=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EB=A7=B5=20=EC=9E=AC=EC=83=9D=EC=84=B1=20=EC=A7=84=ED=96=89?= =?UTF-8?q?=EC=83=81=ED=99=A9=20+=20=EC=8B=A4=EC=8B=9C=EA=B0=84/=ED=8F=B4?= =?UTF-8?q?=EB=A7=81=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 "전체 재생성"을 큐(Full 모드)로 던지고 진행상황을 SEO 탭에서 확인한다. Reverb ON 이면 방송 채널 구독으로 실시간 갱신, OFF 면 상태 API 폴링으로 폴백한다. - SitemapProgress 진행상황 스토어(queued→running→writing→completed/failed, 캐시 TTL 3600, 방송 스로틀) + SitemapManager.getStatus 확장(progress + realtime_enabled) - GET /api/admin/seo/sitemap/status(core.settings.read) 신규 - 관리자 재생성을 항상 Full 모드로 큐잉 + 진행률 기록, Job 최종 실패 시 progress=failed 고정 - core.admin.seo.sitemap 방송 채널 인증(core.settings.read) - SEO 탭 진행상황 UI(상태 배지/단계/누적 URL/실시간·폴링 안내) + 폴링 분기 - 재생성 중 누적 URL 표기(replaceAllForContributor 삽입 건수 반환 — count 쿼리 없이 스트림 누적) - ja 언어팩 진행상황 UI 번역 동기화 --- .../SitemapUrlRepositoryInterface.php | 3 +- .../Api/Admin/SeoCacheController.php | 28 ++- app/Jobs/GenerateSitemapJob.php | 4 + app/Repositories/SitemapUrlRepository.php | 7 +- app/Seo/SeoServiceProvider.php | 1 + app/Seo/SitemapManager.php | 26 ++- app/Seo/SitemapProgress.php | 210 ++++++++++++++++++ docs/backend/api/seo.md | 105 ++++++++- .../CHANGELOG.md | 1 + .../frontend/partial/admin.json | 13 +- .../frontend/partial/editor.json | 4 +- routes/api.php | 1 + routes/channels.php | 5 + .../_bundled/sirsoft-admin_basic/CHANGELOG.md | 6 +- .../admin-settings-sitemap-progress.test.tsx | 140 ++++++++++++ ...n-settings-tab-scoped-datasources.test.tsx | 1 + .../lang/partial/en/admin.json | 13 +- .../lang/partial/en/editor.json | 2 + .../lang/partial/ko/admin.json | 13 +- .../lang/partial/ko/editor.json | 2 + .../layouts/admin_settings.json | 57 ++++- .../partials/admin_settings/_tab_seo.json | 155 +++++++++---- .../Api/Admin/SeoCacheControllerTest.php | 103 ++++++++- tests/Feature/Api/BroadcastingAuthTest.php | 92 ++++++++ tests/Feature/Seo/SitemapManagerModeTest.php | 1 + .../Feature/Seo/SitemapUrlRepositoryTest.php | 24 ++ ...settings-sitemap-generation-fields.spec.ts | 43 +++- tests/Unit/Seo/SitemapManagerTest.php | 3 + tests/Unit/Seo/SitemapProgressTest.php | 165 ++++++++++++++ 29 files changed, 1145 insertions(+), 83 deletions(-) create mode 100644 app/Seo/SitemapProgress.php create mode 100644 templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-progress.test.tsx create mode 100644 tests/Unit/Seo/SitemapProgressTest.php diff --git a/app/Contracts/Repositories/SitemapUrlRepositoryInterface.php b/app/Contracts/Repositories/SitemapUrlRepositoryInterface.php index 44c619f7..3e97a5b9 100644 --- a/app/Contracts/Repositories/SitemapUrlRepositoryInterface.php +++ b/app/Contracts/Repositories/SitemapUrlRepositoryInterface.php @@ -56,6 +56,7 @@ interface SitemapUrlRepositoryInterface * * @param string $contributor 기여자 식별자 * @param iterable $entries URL 항목 스트림 + * @return int 삽입된 행 수 (진행상황 누적 URL 표기용 — count 쿼리 없이 스트림 누적) */ - public function replaceAllForContributor(string $contributor, iterable $entries): void; + public function replaceAllForContributor(string $contributor, iterable $entries): int; } diff --git a/app/Http/Controllers/Api/Admin/SeoCacheController.php b/app/Http/Controllers/Api/Admin/SeoCacheController.php index f0942fdd..0345c9a0 100644 --- a/app/Http/Controllers/Api/Admin/SeoCacheController.php +++ b/app/Http/Controllers/Api/Admin/SeoCacheController.php @@ -2,12 +2,14 @@ namespace App\Http\Controllers\Api\Admin; +use App\Enums\SitemapGenerationMode; use App\Http\Controllers\Api\Base\AdminBaseController; use App\Http\Requests\Admin\SeoCacheClearRequest; use App\Jobs\GenerateSitemapJob; use App\Seo\Contracts\SeoCacheManagerInterface; use App\Seo\SeoCacheStatsService; use App\Seo\SitemapManager; +use App\Seo\SitemapProgress; use Carbon\Carbon; use Illuminate\Http\JsonResponse; @@ -21,7 +23,8 @@ class SeoCacheController extends AdminBaseController public function __construct( private SeoCacheStatsService $statsService, private SeoCacheManagerInterface $cacheManager, - private SitemapManager $sitemapManager + private SitemapManager $sitemapManager, + private SitemapProgress $sitemapProgress ) { parent::__construct(); } @@ -111,12 +114,25 @@ class SeoCacheController extends AdminBaseController return $this->error('seo.sitemap_disabled', 400); } - GenerateSitemapJob::dispatch(); + // 관리자 수동 재생성은 항상 전체(Full) — 현 생성 상태와 무관하게 전량 재생성 (D7) + GenerateSitemapJob::dispatch(SitemapGenerationMode::Full); - return $this->success('seo.sitemap_regenerate_dispatched', [ - 'status' => 'dispatched', - ...$this->sitemapManager->getStatus(), - ]); + // 큐 대기 중에도 UI 가 'queued' 를 표시하도록 즉시 기록 + $this->sitemapProgress->start(SitemapGenerationMode::Full->value); + + return $this->success('seo.sitemap_regenerate_dispatched', $this->sitemapManager->getStatus()); + } + + /** + * Sitemap 재생성 진행상황과 실시간 연결 가능 여부를 조회합니다. + * + * SEO 탭 진입 시 초기 로드용이며, 폴링(Reverb OFF)일 때 주기적으로 재조회됩니다. + * + * @return JsonResponse 진행상황(progress) + last_updated_at + realtime_enabled 를 포함한 JSON 응답 + */ + public function sitemapStatus(): JsonResponse + { + return $this->success('messages.success', $this->sitemapManager->getStatus()); } /** diff --git a/app/Jobs/GenerateSitemapJob.php b/app/Jobs/GenerateSitemapJob.php index a061c8d8..64b1d938 100644 --- a/app/Jobs/GenerateSitemapJob.php +++ b/app/Jobs/GenerateSitemapJob.php @@ -4,6 +4,7 @@ namespace App\Jobs; use App\Enums\SitemapGenerationMode; use App\Seo\SitemapManager; +use App\Seo\SitemapProgress; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; @@ -108,6 +109,9 @@ class GenerateSitemapJob implements ShouldBeUnique, ShouldQueue */ public function failed(\Throwable $exception): void { + // 잡 최종 실패(재시도 소진/타임아웃) 시 진행상황을 'failed' 로 고정 — 무한 running 방지 + app(SitemapProgress::class)->fail($exception->getMessage()); + Log::error('[SEO] Sitemap generation failed', [ 'error' => $exception->getMessage(), ]); diff --git a/app/Repositories/SitemapUrlRepository.php b/app/Repositories/SitemapUrlRepository.php index 9e9541c9..e2e1025d 100644 --- a/app/Repositories/SitemapUrlRepository.php +++ b/app/Repositories/SitemapUrlRepository.php @@ -92,7 +92,7 @@ class SitemapUrlRepository implements SitemapUrlRepositoryInterface /** * {@inheritDoc} */ - public function replaceAllForContributor(string $contributor, iterable $entries): void + public function replaceAllForContributor(string $contributor, iterable $entries): int { // 기여자 스코프 전량 제거 후 청크 삽입. 대용량(수백만) 삽입을 단일 트랜잭션으로 // 감싸면 락/메모리 부담이 크므로, 삭제 후 청크별 삽입으로 유계 메모리를 유지합니다. @@ -100,6 +100,7 @@ class SitemapUrlRepository implements SitemapUrlRepositoryInterface $now = now(); $batch = []; + $inserted = 0; foreach ($entries as $entry) { $batch[] = $this->toRow( (string) ($entry['resource_type'] ?? 'unknown'), @@ -113,13 +114,17 @@ class SitemapUrlRepository implements SitemapUrlRepositoryInterface if (count($batch) >= self::INSERT_CHUNK) { SitemapUrl::insert($batch); + $inserted += count($batch); $batch = []; } } if ($batch !== []) { SitemapUrl::insert($batch); + $inserted += count($batch); } + + return $inserted; } /** diff --git a/app/Seo/SeoServiceProvider.php b/app/Seo/SeoServiceProvider.php index f2b73212..7db85ffa 100644 --- a/app/Seo/SeoServiceProvider.php +++ b/app/Seo/SeoServiceProvider.php @@ -35,6 +35,7 @@ class SeoServiceProvider extends ServiceProvider $this->app->singleton(SeoConfigMerger::class); $this->app->singleton(SitemapFileStore::class); $this->app->singleton(SitemapIndexer::class); + $this->app->singleton(SitemapProgress::class); // Sitemap 파일 입출력은 비공개 디스크(local = storage/app/private, serve=false)를 사용합니다. // public/storage 심볼릭 링크에 의존하지 않고 컨트롤러 스트리밍으로 서빙합니다. diff --git a/app/Seo/SitemapManager.php b/app/Seo/SitemapManager.php index 25b0eb7f..3e19f033 100644 --- a/app/Seo/SitemapManager.php +++ b/app/Seo/SitemapManager.php @@ -36,6 +36,7 @@ class SitemapManager private ConfigRepositoryInterface $configRepository, private StorageInterface $storage, private SitemapUrlRepositoryInterface $repository, + private SitemapProgress $progress, ) {} /** @@ -70,6 +71,8 @@ class SitemapManager $this->rebuildStore(); } + $this->progress->writing($this->repository->countVisible()); + $meta = $this->generator->generateToWriterFromEntries( $this->makeWriter(), $this->streamStoreEntries(), @@ -98,6 +101,8 @@ class SitemapManager ], ]; + $this->progress->complete($meta); + HookManager::doAction('core.seo.sitemap.after_regenerate', $result); return $result; @@ -106,6 +111,8 @@ class SitemapManager 'error' => $e->getMessage(), ]); + $this->progress->fail($e->getMessage()); + $result = [ 'success' => false, 'status' => 'failed', @@ -119,9 +126,13 @@ class SitemapManager } /** - * 현재 sitemap 의 메타데이터를 반환합니다. + * 현재 sitemap 의 메타데이터와 진행상황을 반환합니다. * - * @return array{last_updated_at: ?string} + * realtime_enabled 는 설정 SSoT(drivers.websocket_enabled)와 실제 적용 config + * (broadcasting.default) 를 함께 확인해 판정합니다 — 프론트가 실시간(구독)/폴링을 + * 분기하는 근거입니다. + * + * @return array{last_updated_at: ?string, progress: ?array, realtime_enabled: bool} */ public function getStatus(): array { @@ -129,6 +140,9 @@ class SitemapManager return [ 'last_updated_at' => $lastUpdatedAt !== '' ? $lastUpdatedAt : null, + 'progress' => $this->progress->get(), + 'realtime_enabled' => (bool) g7_core_settings('drivers.websocket_enabled', false) + && config('broadcasting.default') !== 'null', ]; } @@ -139,9 +153,15 @@ class SitemapManager */ private function rebuildStore(): void { + // 스트림 삽입 건수를 누적해 running 단계의 "누적 URL 수" 를 표기합니다(D4). + // countVisible() 같은 사전 count 쿼리 없이 삽입 결과만 합산하므로 1.4M 에서도 부담이 없습니다. + $total = 0; foreach ($this->generator->getContributors() as $contributor) { try { - $this->repository->replaceAllForContributor( + // 현재 기여자 진입 시점의 누적치(직전 기여자들까지)를 표시합니다. + $this->progress->phase($contributor->getIdentifier(), $total); + + $total += $this->repository->replaceAllForContributor( $contributor->getIdentifier(), $this->generator->streamContributorEntries($contributor), ); diff --git a/app/Seo/SitemapProgress.php b/app/Seo/SitemapProgress.php new file mode 100644 index 00000000..bec3f7a2 --- /dev/null +++ b/app/Seo/SitemapProgress.php @@ -0,0 +1,210 @@ +lastBroadcastUrls = 0; + + $this->persist([ + 'status' => 'queued', + 'mode' => $mode, + 'started_at' => now()->toIso8601String(), + 'finished_at' => null, + 'phase' => null, + 'urls' => 0, + 'url_count' => null, + 'child_count' => null, + 'message' => null, + ], broadcast: true); + } + + /** + * 현재 처리 중인 기여자 단계를 기록합니다. + * + * 단계가 바뀌면 즉시 방송하고, 같은 단계에서 urls 만 증가하면 간격 스로틀을 적용합니다. + * 캐시는 폴링 정확도를 위해 항상 기록합니다. + * + * @param string $phase 단계 식별자(기여자 identifier) + * @param int|null $urls 누적 URL 수(선택) + */ + public function phase(string $phase, ?int $urls = null): void + { + $state = $this->current(); + $phaseChanged = ($state['phase'] ?? null) !== $phase; + + $state['status'] = 'running'; + $state['phase'] = $phase; + if ($urls !== null) { + $state['urls'] = $urls; + } + + $broadcast = $phaseChanged + || ($urls !== null && ($urls - $this->lastBroadcastUrls) >= self::BROADCAST_URL_INTERVAL); + + $this->persist($state, broadcast: $broadcast); + + if ($broadcast) { + $this->lastBroadcastUrls = (int) ($state['urls'] ?? 0); + } + } + + /** + * 파일 작성 단계를 기록합니다. + * + * @param int|null $urls 작성 대상 누적 URL 수(선택) + */ + public function writing(?int $urls = null): void + { + $state = $this->current(); + $state['status'] = 'writing'; + $state['phase'] = null; + if ($urls !== null) { + $state['urls'] = $urls; + } + + $this->persist($state, broadcast: true); + } + + /** + * 재생성 완료를 기록합니다. + * + * @param array $meta 생성 메타(url_count, child_count 등) + */ + public function complete(array $meta): void + { + $state = $this->current(); + $state['status'] = 'completed'; + $state['phase'] = null; + $state['finished_at'] = now()->toIso8601String(); + $state['url_count'] = $meta['url_count'] ?? null; + $state['child_count'] = $meta['child_count'] ?? null; + $state['urls'] = $meta['url_count'] ?? ($state['urls'] ?? 0); + $state['message'] = null; + + $this->persist($state, broadcast: true); + } + + /** + * 재생성 실패를 기록합니다(무한 running 방지). + * + * @param string $message 실패 메시지 + */ + public function fail(string $message): void + { + $state = $this->current(); + $state['status'] = 'failed'; + $state['phase'] = null; + $state['finished_at'] = now()->toIso8601String(); + $state['message'] = $message; + + $this->persist($state, broadcast: true); + } + + /** + * 현재 진행상황을 반환합니다(폴링/초기 로드용). + * + * @return array|null 진행상황 또는 아직 실행 이력이 없으면 null + */ + public function get(): ?array + { + $state = $this->cache->get(self::KEY); + + return is_array($state) ? $state : null; + } + + /** + * 현재 진행상황을 반환하되, 없으면 기본 골격을 돌려줍니다(부분 갱신 병합용). + * + * @return array 진행상황 골격 + */ + private function current(): array + { + return $this->get() ?? [ + 'status' => 'idle', + 'mode' => null, + 'started_at' => null, + 'finished_at' => null, + 'phase' => null, + 'urls' => 0, + 'url_count' => null, + 'child_count' => null, + 'message' => null, + ]; + } + + /** + * 진행상황을 캐시에 기록하고, 필요 시 관리자 채널로 방송합니다. + * + * 방송 payload 는 상태 API 응답의 `data` 형태(`progress` + `realtime_enabled`)와 동형입니다. + * websocket 데이터소스가 `target_source` 로 대상 소스를 통째로 교체하므로, 동형이어야 + * 형제 필드(realtime_enabled)를 잃지 않고 UI 바인딩이 유지됩니다(D27). + * + * @param array $state 진행상황 상태 + * @param bool $broadcast 방송 여부(스로틀 판정 결과) + */ + private function persist(array $state, bool $broadcast): void + { + $this->cache->put(self::KEY, $state, self::TTL); + + if ($broadcast) { + HookManager::broadcast(self::CHANNEL, self::EVENT, [ + 'realtime_enabled' => true, + 'progress' => $state, + ]); + } + } +} diff --git a/docs/backend/api/seo.md b/docs/backend/api/seo.md index 86888064..5af7356e 100644 --- a/docs/backend/api/seo.md +++ b/docs/backend/api/seo.md @@ -148,12 +148,13 @@ Authorization: Bearer {YOUR_TOKEN} **응답 필드** (`data` 내부) -_단건 응답: `data` 객체의 필드._ +_단건 응답: `data` 객체의 필드 — `GET /api/admin/seo/sitemap/status` 와 동일한 스키마입니다._ | 필드 | 타입 | 실측 예시값 | 용도/설명 | | --- | --- | --- | --- | -| status | string | `dispatched` | 예약 상태. 재생성 잡이 큐에 등록되었음을 의미합니다 | | last_updated_at | string\|null | `2026-07-08T03:14:49+00:00` | **예약 시점 기준** 마지막 생성 일시. 이번 재생성의 결과가 아니라 직전 생성 시각이며, 잡 완료 후 갱신됩니다 (아직 한 번도 생성되지 않았으면 null) | +| progress | object\|null | `{"status":"queued","mode":"full",...}` | 재생성 진행상황. 예약 직후 `status=queued`, `mode=full` 로 즉시 기록됩니다. 필드 상세는 아래 상태 조회 API 참조 (진행 이력이 없으면 null) | +| realtime_enabled | boolean | `false` | 실시간 연결(WebSocket) 가능 여부. true 면 프론트가 진행상황 채널을 구독하고, false 면 상태 API 를 주기적으로 폴링합니다 | **응답 예시** @@ -166,8 +167,19 @@ HTTP/1.1 200 "success": true, "message": "Sitemap 재생성을 시작했습니다. 완료까지 시간이 걸릴 수 있습니다.", "data": { - "status": "dispatched", - "last_updated_at": "2026-07-08T03:14:49+00:00" + "last_updated_at": "2026-07-08T03:14:49+00:00", + "progress": { + "status": "queued", + "mode": "full", + "started_at": "2026-07-08T05:20:00+00:00", + "finished_at": null, + "phase": null, + "urls": 0, + "url_count": null, + "child_count": null, + "message": null + }, + "realtime_enabled": false } } ``` @@ -184,11 +196,92 @@ HTTP/1.1 200 **설명** -sitemap.xml 재생성을 큐에 예약합니다. 응답은 예약 접수 결과이며 생성 완료를 의미하지 않습니다 — 실제 생성은 큐 워커가 `GenerateSitemapJob` 을 처리할 때 수행되고, 완료 후 마지막 생성 시각이 갱신됩니다. +sitemap.xml 재생성을 큐에 예약합니다. 관리자 수동 재생성은 현 생성 상태와 무관하게 **항상 전체(full) 재생성**입니다. 응답은 예약 접수 결과이며 생성 완료를 의미하지 않습니다 — 실제 생성은 큐 워커가 `GenerateSitemapJob` 을 처리할 때 수행되고, 완료 후 마지막 생성 시각이 갱신됩니다. 요청 스레드에서 동기 생성하지 않는 이유는 대용량(수백만 URL) 사이트에서 생성이 수 분~수십 분 걸려 관리자 요청이 메모리 초과/타임아웃으로 실패하기 때문입니다. 잡은 유니크 락(`seo-sitemap`)을 사용하므로 재생성을 연속으로 요청해도 동시에 여러 건이 실행되지 않고 한 건만 큐에 남습니다. -생성 진행 상황은 이 응답으로 알 수 없습니다. 완료 여부는 `last_updated_at` 이 갱신되었는지로 확인합니다. +예약 직후 진행상황이 `queued` 로 기록되며, 이후 진행 단계는 `GET /api/admin/seo/sitemap/status` 로 조회합니다. + +### GET /api/admin/seo/sitemap/status + +- **라우트명**: `api.admin.seo.sitemap.status` +- **컨트롤러**: `App\Http\Controllers\Api\Admin\SeoCacheController@sitemapStatus` +- **인증/권한**: `auth:sanctum` + `permission:core.settings.read` + +**요청 파라미터** + +_요청 파라미터 없음._ + +**요청 예시** + +```http +GET /api/admin/seo/sitemap/status HTTP/1.1 +Host: api.example.com +Accept: application/json +Authorization: Bearer {YOUR_TOKEN} +``` + +**응답 필드** (`data` 내부) + +_단건 응답: `data` 객체의 필드._ + +| 필드 | 타입 | 실측 예시값 | 용도/설명 | +| --- | --- | --- | --- | +| last_updated_at | string\|null | `2026-07-08T03:14:49+00:00` | 마지막 생성 일시 (아직 한 번도 생성되지 않았으면 null) | +| progress | object\|null | `{"status":"running",...}` | 재생성 진행상황. 진행 이력이 없으면 null | +| progress.status | string | `running` | `queued`(예약) / `running`(기여자 처리 중) / `writing`(파일 작성 중) / `completed`(완료) / `failed`(실패) | +| progress.mode | string\|null | `full` | 재생성 모드 (`full` / `auto` / `incremental`) | +| progress.phase | string\|null | `sirsoft-board` | 현재 처리 중인 기여자 식별자 (running 단계에서만) | +| progress.urls | integer | `12000` | 누적 처리 URL 수 | +| progress.url_count | integer\|null | `5000` | 완료 시 총 URL 수 | +| progress.child_count | integer\|null | `2` | 완료 시 분할 파일 수 | +| progress.started_at | string\|null | `2026-07-08T05:20:00+00:00` | 예약 시각 | +| progress.finished_at | string\|null | `2026-07-08T05:21:30+00:00` | 완료/실패 시각 | +| progress.message | string\|null | `null` | 실패 시 오류 메시지 | +| realtime_enabled | boolean | `false` | 실시간 연결(WebSocket) 가능 여부. true 면 프론트가 `core.admin.seo.sitemap` 채널을 구독하고, false 면 이 API 를 주기적으로 폴링합니다 | + +**응답 예시** + +```http +HTTP/1.1 200 +``` + +```json +{ + "success": true, + "message": "성공적으로 처리되었습니다.", + "data": { + "last_updated_at": "2026-07-08T03:14:49+00:00", + "progress": { + "status": "running", + "mode": "full", + "started_at": "2026-07-08T05:20:00+00:00", + "finished_at": null, + "phase": "sirsoft-board", + "urls": 12000, + "url_count": null, + "child_count": null, + "message": null + }, + "realtime_enabled": false + } +} +``` + +**에러 응답** + +| 상태코드 | 의미 | 발생 조건 | +| --- | --- | --- | +| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 | +| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 | + + + +**설명** + +Sitemap 재생성 진행상황과 실시간 연결 가능 여부를 조회합니다. SEO 탭 진입 시 초기 로드에 사용되며, `realtime_enabled` 가 false(웹소켓 미사용)일 때는 프론트가 진행 중(`queued`/`running`/`writing`) 동안 이 API 를 3초 간격으로 폴링합니다. 실시간 연결이 켜져 있으면 폴링 대신 `core.admin.seo.sitemap` 채널의 `sitemap.progress.updated` 이벤트로 즉시 갱신됩니다. + +진행상황은 캐시(TTL 3600초)에 저장되므로, 잡이 비정상 종료해도 시간이 지나면 만료됩니다. 잡 최종 실패 시에는 `progress.status=failed` 로 고정됩니다. ### GET /api/admin/seo/stats diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md index d3753650..cd91fbd1 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/CHANGELOG.md @@ -11,6 +11,7 @@ - 환경설정 > SEO 화면에 추가된 Sitemap 분할 기준(파일당 URL 수)·Sitemap 압축 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다. - 환경설정 > SEO 화면에 추가된 Sitemap 다국어 대체 링크(hreflang) 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다. - 고급 설정에 추가된 Sitemap 캐시 유지 시간 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다. +- 환경설정 > SEO 화면의 Sitemap 생성 진행 상황 표시(대기·생성 중·파일 작성 중·완료·실패 단계, 현재 처리 항목, 누적 URL 수, 실시간/주기 확인 안내) 일본어 번역 추가 — 진행 상황 화면이 일본어 로케일에서도 올바르게 표시됩니다. ### Changed diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json index fa7c4bb7..d38b1491 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/admin.json @@ -1435,7 +1435,18 @@ "sitemap_gzip": "Sitemap 圧縮(gzip)", "sitemap_gzip_desc": "分割された sitemap ファイルを圧縮して送信量を削減します。インデックスファイルは圧縮しません。", "sitemap_hreflang_enabled": "多言語代替リンク (hreflang)", - "sitemap_hreflang_enabled_desc": "多言語サイトでURLごとに各言語バージョンを指す hreflang 代替リンクを挿入します。オフにするとリンクを省略してサイトマップサイズを削減します。" + "sitemap_hreflang_enabled_desc": "多言語サイトでURLごとに各言語バージョンを指す hreflang 代替リンクを挿入します。オフにするとリンクを省略してサイトマップサイズを削減します。", + "sitemap_regenerating": "作成中…", + "sitemap_progress": "作成進行状況", + "sitemap_status_queued": "待機中", + "sitemap_status_running": "作成中", + "sitemap_status_writing": "ファイル作成中", + "sitemap_status_completed": "完了", + "sitemap_status_failed": "失敗", + "sitemap_progress_phase": "現在処理中: {{phase}}", + "sitemap_progress_urls": "累積URL: {{count}}個", + "sitemap_realtime_connected": "リアルタイムで接続されています。", + "sitemap_polling": "進行状況を定期的に確認しています。" }, "security": { "title": "セキュリティ 設定", diff --git a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/editor.json b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/editor.json index 68e0fe21..12fbc873 100644 --- a/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/editor.json +++ b/lang-packs/_bundled/g7-template-sirsoft-admin_basic-ja/frontend/partial/editor.json @@ -1575,7 +1575,9 @@ "tokenValidation": "トークン検証", "user": "ユーザー", "users": "ユーザーリスト", - "dashboard_recent_notifications": "ダッシュボード最近の通知" + "dashboard_recent_notifications": "ダッシュボード最近の通知", + "sitemap_status": "サイトマップ生成ステータス", + "sitemap_progress_ws": "サイトマップ生成進行中(リアルタイム)" }, "array_group": { "chart_labels": "項目ラベル", diff --git a/routes/api.php b/routes/api.php index 0246435c..e678e4a8 100644 --- a/routes/api.php +++ b/routes/api.php @@ -719,6 +719,7 @@ Route::prefix('admin')->middleware(['auth:sanctum', 'check.user_status', 'admin' Route::post('clear-cache', [AdminSeoCacheController::class, 'clearCache'])->middleware('permission:admin,core.settings.update')->name('api.admin.seo.clear-cache'); Route::post('warmup', [AdminSeoCacheController::class, 'warmup'])->middleware('permission:admin,core.settings.update')->name('api.admin.seo.warmup'); Route::post('sitemap/regenerate', [AdminSeoCacheController::class, 'regenerateSitemap'])->middleware('permission:admin,core.settings.update')->name('api.admin.seo.sitemap.regenerate'); + Route::get('sitemap/status', [AdminSeoCacheController::class, 'sitemapStatus'])->middleware('permission:admin,core.settings.read')->name('api.admin.seo.sitemap.status'); Route::get('cached-urls', [AdminSeoCacheController::class, 'cachedUrls'])->middleware('permission:admin,core.settings.read')->name('api.admin.seo.cached-urls'); }); diff --git a/routes/channels.php b/routes/channels.php index ea11b7df..53b5548c 100644 --- a/routes/channels.php +++ b/routes/channels.php @@ -26,6 +26,11 @@ Broadcast::channel('core.admin.dashboard', function ($user) { return $user->hasPermission('core.dashboard.read'); }); +// 관리자 Sitemap 재생성 진행상황 채널 - core.settings.read 권한 필요 (SEO 탭 읽기 권한과 일치) +Broadcast::channel('core.admin.seo.sitemap', function ($user) { + return $user->hasPermission('core.settings.read'); +}); + // 사용자별 알림 채널 - UUID 기반 (User ID 노출 방지) // core.user-notifications.read (type=user) 권한 필요 Broadcast::channel('core.user.notifications.{uuid}', function ($user, $uuid) { diff --git a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md index ffd388fd..53a63d5d 100644 --- a/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-admin_basic/CHANGELOG.md @@ -12,9 +12,13 @@ - SEO 설정에 다국어 대체 링크(hreflang) 항목을 추가했습니다. 다국어 사이트에서 sitemap 에 각 언어 버전을 가리키는 링크를 넣을지 켜고 끌 수 있으며, 끄면 sitemap 크기를 줄일 수 있습니다. - 고급 설정의 캐시 항목에 Sitemap 캐시 유지 시간을 추가했습니다. SEO 설정의 Sitemap 캐시 항목을 비워두면 이 값을 따릅니다. +### Added + +- SEO 설정에서 sitemap 생성 진행 상황을 화면에서 확인할 수 있습니다. 대기·생성 중·파일 작성 중·완료·실패 단계와 현재 처리 중인 항목, 누적 URL 수가 표시됩니다. 실시간 연결(WebSocket)이 켜져 있으면 즉시 갱신되고, 꺼져 있으면 주기적으로 확인합니다. + ### Changed -- SEO 설정의 "지금 생성" 버튼이 sitemap 생성을 예약하고 바로 응답합니다. 사이트 규모가 커도 화면이 멈추지 않으며, 생성 완료는 마지막 생성 시각으로 확인할 수 있습니다. +- SEO 설정의 "지금 생성" 버튼이 sitemap 생성을 예약하고 바로 응답합니다. 사이트 규모가 커도 화면이 멈추지 않으며, 생성 중에는 버튼이 비활성화되고 진행 상황이 화면에 표시됩니다. ## [1.0.3] - 2026-07-14 diff --git a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-progress.test.tsx b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-progress.test.tsx new file mode 100644 index 00000000..a946e165 --- /dev/null +++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-progress.test.tsx @@ -0,0 +1,140 @@ +/** + * @file admin-settings-sitemap-progress.test.tsx + * @description 환경설정 > SEO > Sitemap 재생성 진행상황 UI 회귀 가드 (이슈 #79 / S5) + * + * 검증 포인트: + * - admin_settings.json: sitemap_status(api) + sitemap_progress_ws(websocket) 데이터소스 배선 + * - Reverb OFF(realtime_enabled!=true) + 진행 중 → startInterval 폴링 시작 + * - 완료/실패 전이 → stopInterval 폴링 중단 + * - websocket target_source 가 sitemap_status 로 수신 데이터를 병합(D27 동형 payload) + * - _tab_seo.json: 진행상황 표시 블록 + 재생성 버튼 재배선 + * - 상태 배지(대기/생성중/파일작성중/완료/실패) classMap + * - 실시간(구독) / 폴링 안내 문구 분기 + * - 버튼 disabled/label 이 progress.status 로 분기, 클릭 시 sitemap_status refetch + */ + +import { describe, it, expect } from 'vitest'; + +const layout = require('../../layouts/admin_settings.json'); +const seoTab = require('../../layouts/partials/admin_settings/_tab_seo.json'); + +interface AnyJson { [k: string]: any } + +/** + * 트리에서 조건에 맞는 노드를 모두 수집. + */ +function collectNodes(node: any, predicate: (n: any) => boolean): AnyJson[] { + const out: AnyJson[] = []; + const visit = (n: any) => { + if (!n || typeof n !== 'object') return; + if (Array.isArray(n)) { n.forEach(visit); return; } + if (predicate(n)) out.push(n); + Object.values(n).forEach(visit); + }; + visit(node); + return out; +} + +const sources: AnyJson[] = layout.data_sources; +const byId = (id: string) => sources.find(s => s.id === id); + +describe('admin_settings — sitemap 진행상황 데이터소스', () => { + it('sitemap_status 는 seo 탭 게이트 + 상태 엔드포인트를 가진다', () => { + const s = byId('sitemap_status'); + expect(s).toBeTruthy(); + expect(s!.type).toBe('api'); + expect(s!.endpoint).toBe('/api/admin/seo/sitemap/status'); + expect(s!.method).toBe('GET'); + expect(typeof s!.if).toBe('string'); + expect(s!.if).toContain("'seo'"); + expect(s!.auto_fetch).not.toBe(false); + }); + + it('sitemap_status onSuccess: OFF + 진행중이면 startInterval 로 폴링', () => { + const s = byId('sitemap_status')!; + const start = (s.onSuccess as AnyJson[]).find(a => a.handler === 'startInterval'); + expect(start).toBeTruthy(); + // realtime OFF 조건 + 진행 상태 집합을 참조 + expect(start!.if).toContain('realtime_enabled'); + expect(start!.if).toContain('queued'); + expect(start!.if).toContain('running'); + expect(start!.if).toContain('writing'); + // 폴링 액션은 sitemap_status 재조회 + const refetch = (start!.params.actions as AnyJson[])[0]; + expect(refetch.handler).toBe('refetchDataSource'); + expect(refetch.params.dataSourceId).toBe('sitemap_status'); + // 같은 id 로 idempotent 등록 + expect(start!.params.id).toBe('sitemap_progress_poll'); + }); + + it('sitemap_status onSuccess: 완료/실패 전이면 stopInterval', () => { + const s = byId('sitemap_status')!; + const stop = (s.onSuccess as AnyJson[]).find(a => a.handler === 'stopInterval'); + expect(stop).toBeTruthy(); + expect(stop!.if).toContain('completed'); + expect(stop!.if).toContain('failed'); + expect(stop!.params.id).toBe('sitemap_progress_poll'); + }); + + it('sitemap_progress_ws 는 관리자 채널을 구독하고 sitemap_status 로 병합한다', () => { + const ws = byId('sitemap_progress_ws'); + expect(ws).toBeTruthy(); + expect(ws!.type).toBe('websocket'); + expect(ws!.channel).toBe('core.admin.seo.sitemap'); + expect(ws!.event).toBe('sitemap.progress.updated'); + expect(ws!.channel_type).toBe('private'); + expect(ws!.target_source).toBe('sitemap_status'); + }); +}); + +describe('_tab_seo — sitemap 진행상황 표시 UI', () => { + it('진행상황 블록은 상태 집합으로 게이트되고 progress 를 바인딩한다', () => { + const block = collectNodes(seoTab, n => n.id === 'sitemap_progress_block')[0]; + expect(block).toBeTruthy(); + expect(block.if).toContain('sitemap_status?.data?.progress?.status'); + expect(block.if).toContain('completed'); + expect(block.if).toContain('failed'); + }); + + it('상태 배지 classMap 이 5개 상태 variant 를 모두 정의한다', () => { + const badge = collectNodes(seoTab, n => n?.props?.classMap?.key + && String(n.props.classMap.key).includes('progress?.status'))[0]; + expect(badge).toBeTruthy(); + const variants = badge.props.classMap.variants; + ['queued', 'running', 'writing', 'completed', 'failed'].forEach(st => { + expect(variants[st]).toBeTruthy(); + }); + }); + + it('실시간(구독) / 폴링 안내 문구가 realtime_enabled 로 분기된다', () => { + const hint = collectNodes(seoTab, n => typeof n.text === 'string' + && n.text.includes('sitemap_realtime_connected') + && n.text.includes('sitemap_polling'))[0]; + expect(hint).toBeTruthy(); + expect(hint.text).toContain('realtime_enabled'); + }); + + it('현재 단계(phase)와 누적 URL 문구가 존재한다', () => { + const phase = collectNodes(seoTab, n => typeof n.text === 'string' + && n.text.includes('sitemap_progress_phase'))[0]; + const urls = collectNodes(seoTab, n => typeof n.text === 'string' + && n.text.includes('sitemap_progress_urls'))[0]; + expect(phase).toBeTruthy(); + expect(urls).toBeTruthy(); + }); + + it('재생성 버튼은 progress 상태로 disabled/label 분기하고 클릭 시 status 를 refetch 한다', () => { + const btn = collectNodes(seoTab, n => n.name === 'Button' + && typeof n?.props?.disabled === 'string' + && n.props.disabled.includes('progress?.status'))[0]; + expect(btn).toBeTruthy(); + expect(String(btn.text)).toContain('sitemap_regenerating'); + + const apiCall = (btn.actions as AnyJson[]).find(a => a.handler === 'apiCall'); + expect(apiCall).toBeTruthy(); + expect(apiCall!.target).toBe('/api/admin/seo/sitemap/regenerate'); + const refetch = (apiCall!.onSuccess as AnyJson[]).find(a => a.handler === 'refetchDataSource'); + expect(refetch).toBeTruthy(); + expect(refetch!.params.dataSourceId).toBe('sitemap_status'); + }); +}); diff --git a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-tab-scoped-datasources.test.tsx b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-tab-scoped-datasources.test.tsx index 7757cfe2..4caacd7c 100644 --- a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-tab-scoped-datasources.test.tsx +++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-tab-scoped-datasources.test.tsx @@ -29,6 +29,7 @@ const TAB_SCOPED: Record = { identityMessages: ['identity'], adminIdentityPolicies: ['identity'], policies: ['identity'], + sitemap_status: ['seo'], }; const sources: AnyJson[] = layout.data_sources; diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json index e7791867..f2eeb7dd 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/admin.json @@ -1431,7 +1431,18 @@ "sitemap_gzip": "Sitemap compression (gzip)", "sitemap_gzip_desc": "Compresses split sitemap files to reduce transfer size. The index file is not compressed.", "sitemap_hreflang_enabled": "Multilingual alternate links (hreflang)", - "sitemap_hreflang_enabled_desc": "Adds hreflang alternate links pointing to each language version of a URL on multilingual sites. Turn off to omit the links and reduce sitemap size." + "sitemap_hreflang_enabled_desc": "Adds hreflang alternate links pointing to each language version of a URL on multilingual sites. Turn off to omit the links and reduce sitemap size.", + "sitemap_regenerating": "Generating…", + "sitemap_progress": "Generation progress", + "sitemap_status_queued": "Queued", + "sitemap_status_running": "Generating", + "sitemap_status_writing": "Writing files", + "sitemap_status_completed": "Completed", + "sitemap_status_failed": "Failed", + "sitemap_progress_phase": "Processing: {{phase}}", + "sitemap_progress_urls": "URLs so far: {{count}}", + "sitemap_realtime_connected": "Connected in real time.", + "sitemap_polling": "Checking progress periodically." }, "security": { "title": "Security Settings", diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/editor.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/editor.json index a4b4f9ac..870ea8d2 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/en/editor.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/en/editor.json @@ -1646,6 +1646,8 @@ "schedule_history": "Schedule History", "schedules": "Schedules", "settings": "Settings", + "sitemap_status": "Sitemap Generation Status", + "sitemap_progress_ws": "Sitemap Generation Progress (Live)", "systemInfo": "System Info", "templateDetail": "Template Detail", "templateInstallPreview": "Template Install Preview", diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json index 415e2d8d..ced745e3 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/admin.json @@ -1435,7 +1435,18 @@ "sitemap_gzip": "Sitemap 압축 (gzip)", "sitemap_gzip_desc": "나뉜 sitemap 파일을 압축해 전송량을 줄입니다. 색인 파일은 압축하지 않습니다.", "sitemap_hreflang_enabled": "다국어 대체 링크 (hreflang)", - "sitemap_hreflang_enabled_desc": "다국어 사이트에서 URL 마다 각 언어 버전을 가리키는 hreflang 대체 링크를 넣습니다. 끄면 링크를 생략해 sitemap 크기를 줄입니다." + "sitemap_hreflang_enabled_desc": "다국어 사이트에서 URL 마다 각 언어 버전을 가리키는 hreflang 대체 링크를 넣습니다. 끄면 링크를 생략해 sitemap 크기를 줄입니다.", + "sitemap_regenerating": "생성 중…", + "sitemap_progress": "생성 진행 상황", + "sitemap_status_queued": "대기 중", + "sitemap_status_running": "생성 중", + "sitemap_status_writing": "파일 작성 중", + "sitemap_status_completed": "완료", + "sitemap_status_failed": "실패", + "sitemap_progress_phase": "현재 처리 중: {{phase}}", + "sitemap_progress_urls": "누적 URL: {{count}}개", + "sitemap_realtime_connected": "실시간으로 연결되어 있습니다.", + "sitemap_polling": "진행 상황을 주기적으로 확인하고 있습니다." }, "security": { "title": "보안 설정", diff --git a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/editor.json b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/editor.json index b54cb638..5e239ca8 100644 --- a/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/editor.json +++ b/templates/_bundled/sirsoft-admin_basic/lang/partial/ko/editor.json @@ -1646,6 +1646,8 @@ "schedule_history": "스케줄 이력", "schedules": "스케줄 목록", "settings": "환경설정", + "sitemap_status": "Sitemap 생성 상태", + "sitemap_progress_ws": "Sitemap 생성 진행 (실시간)", "systemInfo": "시스템 정보", "templateDetail": "템플릿 상세", "templateInstallPreview": "템플릿 설치 미리보기", diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/admin_settings.json b/templates/_bundled/sirsoft-admin_basic/layouts/admin_settings.json index 1a749e56..c7564690 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/admin_settings.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/admin_settings.json @@ -265,6 +265,57 @@ "meta": {} } } + }, + { + "id": "sitemap_status", + "label_key": "$t:editor.data_source.sitemap_status", + "type": "api", + "endpoint": "/api/admin/seo/sitemap/status", + "method": "GET", + "if": "{{(query.tab || _global.activeSettingsTab || 'general') === 'seo'}}", + "auto_fetch": true, + "auth_required": true, + "fallback": { + "data": { + "last_updated_at": null, + "progress": null, + "realtime_enabled": false + } + }, + "onSuccess": [ + { + "handler": "startInterval", + "if": "{{sitemap_status?.data?.realtime_enabled !== true && ['queued','running','writing'].includes(sitemap_status?.data?.progress?.status ?? '')}}", + "params": { + "id": "sitemap_progress_poll", + "intervalMs": 3000, + "actions": [ + { + "handler": "refetchDataSource", + "params": { + "dataSourceId": "sitemap_status" + } + } + ] + } + }, + { + "handler": "stopInterval", + "if": "{{['completed','failed'].includes(sitemap_status?.data?.progress?.status ?? '')}}", + "params": { + "id": "sitemap_progress_poll" + } + } + ] + }, + { + "id": "sitemap_progress_ws", + "label_key": "$t:editor.data_source.sitemap_progress_ws", + "type": "websocket", + "channel": "core.admin.seo.sitemap", + "event": "sitemap.progress.updated", + "channel_type": "private", + "target_source": "sitemap_status" } ], "slots": { @@ -310,7 +361,7 @@ "name": "Icon", "props": { "name": "refresh", - "className": "w-5 h-5" + "className": "text-xl" } } ] @@ -338,7 +389,7 @@ "name": "Icon", "props": { "name": "lock", - "className": "w-5 h-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0" + "className": "text-xl text-yellow-600 dark:text-yellow-400 flex-shrink-0" } }, { @@ -512,7 +563,7 @@ "name": "Icon", "props": { "name": "triangle-exclamation", - "className": "w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" + "className": "text-xl text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" } }, { diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json index 15bac572..db911063 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/partials/admin_settings/_tab_seo.json @@ -1078,6 +1078,87 @@ } ] }, + { + "id": "sitemap_progress_block", + "type": "basic", + "name": "Div", + "if": "{{['queued','running','writing','completed','failed'].includes(sitemap_status?.data?.progress?.status ?? '')}}", + "props": { + "className": "space-y-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 p-3" + }, + "children": [ + { + "type": "basic", + "name": "Div", + "props": { + "className": "flex items-center gap-2" + }, + "children": [ + { + "type": "basic", + "name": "Span", + "props": { + "className": "text-heading" + }, + "text": "$t:admin.settings.seo.sitemap_progress" + }, + { + "type": "basic", + "name": "Span", + "props": { + "className": "px-2 py-0.5 rounded text-xs font-medium", + "classMap": { + "key": "{{sitemap_status?.data?.progress?.status ?? ''}}", + "variants": { + "queued": "bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200", + "running": "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200", + "writing": "bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200", + "completed": "bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200", + "failed": "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-200" + } + } + }, + "text": "{{(sitemap_status?.data?.progress?.status === 'queued' && $t('admin.settings.seo.sitemap_status_queued')) || (sitemap_status?.data?.progress?.status === 'running' && $t('admin.settings.seo.sitemap_status_running')) || (sitemap_status?.data?.progress?.status === 'writing' && $t('admin.settings.seo.sitemap_status_writing')) || (sitemap_status?.data?.progress?.status === 'completed' && $t('admin.settings.seo.sitemap_status_completed')) || (sitemap_status?.data?.progress?.status === 'failed' && $t('admin.settings.seo.sitemap_status_failed')) || ''}}" + } + ] + }, + { + "type": "basic", + "name": "P", + "if": "{{sitemap_status?.data?.progress?.status === 'running' && (sitemap_status?.data?.progress?.phase ?? '') !== ''}}", + "props": { + "className": "text-label-subtle text-sm" + }, + "text": "$t:admin.settings.seo.sitemap_progress_phase|phase={{sitemap_status?.data?.progress?.phase ?? ''}}" + }, + { + "type": "basic", + "name": "P", + "if": "{{Number(sitemap_status?.data?.progress?.urls ?? 0) > 0}}", + "props": { + "className": "text-label-subtle text-sm" + }, + "text": "$t:admin.settings.seo.sitemap_progress_urls|count={{sitemap_status?.data?.progress?.urls ?? 0}}" + }, + { + "type": "basic", + "name": "P", + "if": "{{sitemap_status?.data?.progress?.status === 'failed' && (sitemap_status?.data?.progress?.message ?? '') !== ''}}", + "props": { + "className": "text-red-600 dark:text-red-400 text-sm break-words" + }, + "text": "{{sitemap_status?.data?.progress?.message ?? ''}}" + }, + { + "type": "basic", + "name": "P", + "props": { + "className": "text-label-subtle text-xs" + }, + "text": "{{sitemap_status?.data?.realtime_enabled === true ? $t('admin.settings.seo.sitemap_realtime_connected') : $t('admin.settings.seo.sitemap_polling')}}" + } + ] + }, { "id": "sitemap_last_updated_block", "type": "basic", @@ -1107,7 +1188,7 @@ "props": { "type": "text", "disabled": true, - "value": "{{_local.form?.seo?.sitemap_last_updated_at ?? '-'}}", + "value": "{{sitemap_status?.data?.last_updated_at ?? _local.form?.seo?.sitemap_last_updated_at ?? '-'}}", "className": "md:flex-1 dark:bg-gray-900 text-gray-700 dark:text-gray-300" } }, @@ -1116,63 +1197,41 @@ "name": "Button", "props": { "type": "button", - "disabled": "{{_computed.isReadOnly || _local.sitemapUpdating === true}}", + "disabled": "{{_computed.isReadOnly || ['queued','running','writing'].includes(sitemap_status?.data?.progress?.status ?? '')}}", "className": "w-full md:w-auto px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white text-sm font-medium rounded-lg transition-colors whitespace-nowrap" }, - "text": "{{_local.sitemapUpdating === true ? $t('admin.settings.seo.sitemap_updating') : $t('admin.settings.seo.sitemap_manual_update')}}", + "text": "{{['queued','running','writing'].includes(sitemap_status?.data?.progress?.status ?? '') ? $t('admin.settings.seo.sitemap_regenerating') : $t('admin.settings.seo.sitemap_manual_update')}}", "actions": [ { "type": "click", - "handler": "setState", + "handler": "apiCall", + "auth_required": true, + "target": "/api/admin/seo/sitemap/regenerate", "params": { - "target": "local", - "sitemapUpdating": true + "method": "POST" }, "onSuccess": [ { - "handler": "apiCall", - "auth_required": true, - "target": "/api/admin/seo/sitemap/regenerate", + "handler": "refetchDataSource", "params": { - "method": "POST" - }, - "onSuccess": [ - { - "handler": "setState", - "params": { - "target": "local", - "sitemapUpdating": false, - "form": { - "seo": { - "sitemap_last_updated_at": "{{response.data?.last_updated_at ?? ''}}" - } - } - } - }, - { - "handler": "toast", - "params": { - "type": "success", - "message": "{{$t('admin.settings.seo.sitemap_update_success')}}" - } - } - ], - "onError": [ - { - "handler": "setState", - "params": { - "target": "local", - "sitemapUpdating": false - } - }, - { - "handler": "toast", - "params": { - "type": "error", - "message": "{{error.message ?? $t('admin.settings.seo.sitemap_update_failed')}}" - } - } - ] + "dataSourceId": "sitemap_status" + } + }, + { + "handler": "toast", + "params": { + "type": "success", + "message": "{{$t('admin.settings.seo.sitemap_update_success')}}" + } + } + ], + "onError": [ + { + "handler": "toast", + "params": { + "type": "error", + "message": "{{error.message ?? $t('admin.settings.seo.sitemap_update_failed')}}" + } } ] } diff --git a/tests/Feature/Api/Admin/SeoCacheControllerTest.php b/tests/Feature/Api/Admin/SeoCacheControllerTest.php index 3720cf6d..598d6e96 100644 --- a/tests/Feature/Api/Admin/SeoCacheControllerTest.php +++ b/tests/Feature/Api/Admin/SeoCacheControllerTest.php @@ -3,7 +3,9 @@ namespace Tests\Feature\Api\Admin; use App\Enums\ExtensionOwnerType; +use App\Enums\SitemapGenerationMode; use App\Jobs\GenerateSitemapJob; +use App\Seo\SitemapProgress; use App\Models\Permission; use App\Models\Role; use App\Models\SeoCacheStat; @@ -392,27 +394,33 @@ class SeoCacheControllerTest extends TestCase } /** - * 재생성 요청 시 200 + 예약 상태와 직전 생성 시각을 반환 + * 재생성 요청 시 200 + 진행상황(queued)과 직전 생성 시각을 반환 */ - public function test_regenerate_sitemap_returns_200_with_dispatched_status(): void + public function test_regenerate_sitemap_returns_200_with_queued_progress(): void { Queue::fake(); Config::set('g7_settings.core.seo.sitemap_enabled', true); Config::set('g7_settings.core.seo.sitemap_last_updated_at', '2026-04-29T10:00:00+00:00'); + // realtime_enabled 판정을 결정적으로: 웹소켓 OFF → false + Config::set('broadcasting.default', 'null'); $response = $this->withHeader('Authorization', 'Bearer '.$this->token) ->postJson('/api/admin/seo/sitemap/regenerate'); $response->assertStatus(200); $this->assertTrue($response->json('success')); - $this->assertSame('dispatched', $response->json('data.status')); + $this->assertSame('queued', $response->json('data.progress.status')); + $this->assertSame('full', $response->json('data.progress.mode')); $this->assertSame('2026-04-29T10:00:00+00:00', $response->json('data.last_updated_at')); + $this->assertFalse($response->json('data.realtime_enabled')); } /** - * 재생성 요청이 큐 잡을 1건만 디스패치하는지 확인 + * 재생성 요청이 큐 잡을 1건만, 항상 전체(Full) 모드로 디스패치하는지 확인 + * + * 관리자 수동 재생성은 현 생성 상태와 무관하게 항상 전량 재생성입니다(D7). */ - public function test_regenerate_sitemap_dispatches_job(): void + public function test_regenerate_sitemap_dispatches_full_mode_job(): void { Queue::fake(); Config::set('g7_settings.core.seo.sitemap_enabled', true); @@ -422,6 +430,10 @@ class SeoCacheControllerTest extends TestCase ->assertStatus(200); Queue::assertPushed(GenerateSitemapJob::class, 1); + Queue::assertPushed( + GenerateSitemapJob::class, + fn (GenerateSitemapJob $job) => $job->mode === SitemapGenerationMode::Full + ); } /** @@ -460,6 +472,87 @@ class SeoCacheControllerTest extends TestCase Queue::assertNotPushed(GenerateSitemapJob::class); } + // ======================================================================== + // Sitemap 진행상황 조회 (sitemapStatus) + // ======================================================================== + + /** + * 인증 없이 sitemap 상태 조회 시 401 반환 + */ + public function test_sitemap_status_returns_401_without_authentication(): void + { + $response = $this->getJson('/api/admin/seo/sitemap/status'); + + $response->assertStatus(401); + } + + /** + * read 권한만 있어도 sitemap 상태 조회 가능 (SEO 탭 읽기 권한) + */ + public function test_sitemap_status_allows_read_permission(): void + { + $user = $this->createAdminUser(['core.settings.read']); + $token = $user->createToken('test-token')->plainTextToken; + + $response = $this->withToken($token)->getJson('/api/admin/seo/sitemap/status'); + + $response->assertStatus(200); + } + + /** + * 상태 조회 응답이 진행상황 + realtime_enabled 스키마를 포함 + */ + public function test_sitemap_status_returns_progress_and_realtime_flag(): void + { + // 진행상황을 미리 기록 + app(SitemapProgress::class)->start(SitemapGenerationMode::Full->value); + // realtime_enabled 판정을 결정적으로: 웹소켓 OFF → false + Config::set('broadcasting.default', 'null'); + + $response = $this->withToken($this->token)->getJson('/api/admin/seo/sitemap/status'); + + $response->assertStatus(200) + ->assertJsonStructure([ + 'success', + 'data' => [ + 'last_updated_at', + 'progress' => ['status', 'mode'], + 'realtime_enabled', + ], + ]); + $this->assertSame('queued', $response->json('data.progress.status')); + $this->assertFalse($response->json('data.realtime_enabled')); + } + + /** + * 진행상황이 없으면 progress 는 null + */ + public function test_sitemap_status_returns_null_progress_when_idle(): void + { + $response = $this->withToken($this->token)->getJson('/api/admin/seo/sitemap/status'); + + $response->assertStatus(200); + $this->assertNull($response->json('data.progress')); + } + + /** + * 재생성 요청이 진행상황을 'queued' 로 즉시 기록 (큐 대기 중에도 UI 표시) + */ + public function test_regenerate_sitemap_records_queued_progress(): void + { + Queue::fake(); + Config::set('g7_settings.core.seo.sitemap_enabled', true); + + $this->withHeader('Authorization', 'Bearer '.$this->token) + ->postJson('/api/admin/seo/sitemap/regenerate') + ->assertStatus(200); + + $progress = app(SitemapProgress::class)->get(); + $this->assertNotNull($progress); + $this->assertSame('queued', $progress['status']); + $this->assertSame('full', $progress['mode']); + } + protected function tearDown(): void { Mockery::close(); diff --git a/tests/Feature/Api/BroadcastingAuthTest.php b/tests/Feature/Api/BroadcastingAuthTest.php index c05bab28..721119b5 100644 --- a/tests/Feature/Api/BroadcastingAuthTest.php +++ b/tests/Feature/Api/BroadcastingAuthTest.php @@ -2,6 +2,9 @@ namespace Tests\Feature\Api; +use App\Enums\ExtensionOwnerType; +use App\Models\Permission; +use App\Models\Role; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Config; @@ -74,4 +77,93 @@ class BroadcastingAuthTest extends TestCase // 토글 OFF 가 아님을 확인: 401(미인증) 이 아닌 것으로 인증 통과 입증. $this->assertNotSame(401, $response->getStatusCode()); } + + /** + * Sitemap 진행상황 채널이 등록되어 있고, core.settings.read 권한 보유 시 인증을 허용한다. + * + * broadcasting/auth HTTP 엔드포인트는 이 하네스에서 서명 응답을 낼 수 없어(권한 유무와 무관하게 403) + * 인증 여부를 구분하지 못하므로, 등록된 채널 콜백을 직접 호출해 인가 로직을 검증한다. + */ + public function test_sitemap_channel_authorizes_user_with_settings_read(): void + { + $callback = $this->channelCallback('core.admin.seo.sitemap'); + + $user = $this->userWithPermission('core.settings.read'); + + $this->assertTrue((bool) $callback($user)); + } + + /** + * Sitemap 진행상황 채널: core.settings.read 권한 미보유 시 거부. + */ + public function test_sitemap_channel_rejects_user_without_permission(): void + { + $callback = $this->channelCallback('core.admin.seo.sitemap'); + + $user = User::factory()->create(); + + $this->assertFalse((bool) $callback($user)); + } + + /** + * 등록된 브로드캐스트 채널의 인가 콜백을 반환합니다. + * + * @param string $channel 채널명 (private- 접두 없이) + * @return \Closure 인가 콜백 (첫 인자 = 사용자) + */ + private function channelCallback(string $channel): \Closure + { + $broadcaster = app(\Illuminate\Contracts\Broadcasting\Factory::class)->connection(); + + $ref = new \ReflectionClass($broadcaster); + while ($ref !== false && ! $ref->hasProperty('channels')) { + $ref = $ref->getParentClass(); + } + $this->assertNotFalse($ref, 'broadcaster 에 channels 프로퍼티가 없음'); + + $prop = $ref->getProperty('channels'); + $prop->setAccessible(true); + $channels = $prop->getValue($broadcaster); + + $this->assertArrayHasKey($channel, $channels, "채널이 등록되지 않음: {$channel}"); + + return $channels[$channel]; + } + + /** + * 지정한 관리자 권한을 가진 사용자를 생성합니다. + * + * @param string $permission 권한 식별자 + * @return User 권한이 부여된 사용자 + */ + private function userWithPermission(string $permission): User + { + $user = User::factory()->create(); + + $perm = Permission::firstOrCreate( + ['identifier' => $permission], + [ + 'name' => json_encode(['ko' => $permission, 'en' => $permission]), + 'description' => json_encode(['ko' => $permission, 'en' => $permission]), + 'extension_type' => ExtensionOwnerType::Core, + 'extension_identifier' => 'core', + 'type' => 'admin', + ] + ); + + $role = Role::create([ + 'identifier' => 'sitemap_channel_test_'.uniqid(), + 'name' => json_encode(['ko' => '테스트', 'en' => 'Test']), + 'description' => json_encode(['ko' => '테스트', 'en' => 'Test']), + 'is_active' => true, + ]); + $role->permissions()->sync([$perm->id]); + + $user->roles()->attach($role->id, [ + 'assigned_at' => now(), + 'assigned_by' => null, + ]); + + return $user->fresh(); + } } diff --git a/tests/Feature/Seo/SitemapManagerModeTest.php b/tests/Feature/Seo/SitemapManagerModeTest.php index d039bfd3..1a4dd793 100644 --- a/tests/Feature/Seo/SitemapManagerModeTest.php +++ b/tests/Feature/Seo/SitemapManagerModeTest.php @@ -97,6 +97,7 @@ class SitemapManagerModeTest extends TestCase Mockery::spy(ConfigRepositoryInterface::class), $this->storage, $this->repository, + $this->app->make(\App\Seo\SitemapProgress::class), ); } diff --git a/tests/Feature/Seo/SitemapUrlRepositoryTest.php b/tests/Feature/Seo/SitemapUrlRepositoryTest.php index dff42503..de39f1d3 100644 --- a/tests/Feature/Seo/SitemapUrlRepositoryTest.php +++ b/tests/Feature/Seo/SitemapUrlRepositoryTest.php @@ -156,6 +156,30 @@ class SitemapUrlRepositoryTest extends TestCase $this->assertSame(['https://example.com/page/a'], $page, '다른 기여자 행은 보존돼야 합니다.'); } + /** + * replaceAllForContributor 는 삽입된 행 수를 반환한다 (진행상황 누적 URL 표기용). + * + * 청크 경계를 넘겨도 정확히 집계돼야 한다 (INSERT_CHUNK=1000 이므로 2건은 단일 청크지만 + * 반환 계약 자체를 고정한다). + */ + public function test_replace_all_for_contributor_returns_inserted_count(): void + { + $count = $this->repository->replaceAllForContributor('sirsoft-board', [ + ['loc' => 'https://example.com/board/a', 'resource_type' => 'board', 'resource_id' => '1'], + ['loc' => 'https://example.com/board/a/1', 'resource_type' => 'board_post', 'resource_id' => '1'], + ['loc' => 'https://example.com/board/a/2', 'resource_type' => 'board_post', 'resource_id' => '2'], + ]); + + $this->assertSame(3, $count); + + // 재대체 시에도 새로 삽입된 건수를 반환 (기존 삭제분은 제외) + $recount = $this->repository->replaceAllForContributor('sirsoft-board', [ + ['loc' => 'https://example.com/board/b', 'resource_type' => 'board', 'resource_id' => '2'], + ]); + + $this->assertSame(1, $recount); + } + /** * 긴 loc(2000자) 도 loc_hash 로 안전 저장 및 식별된다 (MySQL 키 길이 회피). */ diff --git a/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts b/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts index 8e370b9c..f61a297a 100644 --- a/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts +++ b/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts @@ -78,11 +78,14 @@ test('#481 - hreflang 토글을 바꿔 저장하면 성공하고 재진입 시 const toggle = page.locator(`${HREFLANG_ROW} input[type="checkbox"]`).first(); await expect(toggle).toBeAttached({ timeout: 15_000 }); + // 체크박스 input 은 sr-only(화면에서 숨김)이고 클릭 대상은 wrapper div(onClick) 이다. + // input 을 직접 클릭하면 toggle-switch-track 오버레이가 포인터 이벤트를 가로챈다. + const toggleSwitch = page.locator(`${HREFLANG_ROW} .toggle-switch-wrapper`).first(); const wasChecked = await toggle.isChecked(); // 토글을 반대 상태로 바꾼다 - await toggle.click(); + await toggleSwitch.click(); await expect(page.locator('#save_button')).toBeEnabled({ timeout: 10_000 }); const save = page.waitForResponse( @@ -99,7 +102,7 @@ test('#481 - hreflang 토글을 바꿔 저장하면 성공하고 재진입 시 await expect.poll(() => reloaded.isChecked(), { timeout: 15_000 }).toBe(!wasChecked); // 원상 복구 (E2E 는 실제 환경 설정을 건드린다) - await reloaded.click(); + await page.locator(`${HREFLANG_ROW} .toggle-switch-wrapper`).first().click(); const restore = page.waitForResponse( (r) => r.url().includes('/api/admin/settings') && r.request().method() === 'POST', { timeout: 20_000 } @@ -197,8 +200,8 @@ test('#481 - 고급 탭 Sitemap 캐시 기준값 칸이 마운트되고 기본 }); // @scenario tab=seo, permitted=yes -// @effects regenerate_is_async -test('#481 - "지금 생성" 클릭이 큐에 예약되고 즉시 응답한다', async ({ page }) => { +// @effects regenerate_is_async, progress_visible +test('#481 - "지금 생성" 클릭이 큐에 예약되고 진행상황이 화면에 표시된다', async ({ page }) => { const token = issueToken('core.settings.read', 'core.settings.update'); await authenticatePage(page, token); @@ -215,9 +218,39 @@ test('#481 - "지금 생성" 클릭이 큐에 예약되고 즉시 응답한다', const elapsed = Date.now() - startedAt; expect(response.status()).toBe(200); - expect((await response.json())?.data?.status).toBe('dispatched'); + // 예약 즉시 진행상황이 'queued' 로 실려 온다 (동기 생성이 아니라 큐 예약) + expect((await response.json())?.data?.progress?.status).toBe('queued'); // 동기 생성으로 되돌아가면 응답 시간이 데이터 규모에 비례해 늘어난다. // 절대 하한 대신 "예약 응답이라면 당연히 만족하는" 상한으로 회귀만 잡는다. expect(elapsed).toBeLessThan(10_000); + + // 진행상황 블록이 화면에 나타나고 상태 배지가 미해석 키가 아닌 실제 문구로 렌더된다 + const progressBlock = page.locator('#sitemap_progress_block'); + await expect(progressBlock).toBeAttached({ timeout: 15_000 }); + const progressText = (await progressBlock.innerText()).trim(); + expect(progressText).not.toContain('$t:'); + expect(progressText.length).toBeGreaterThan(0); +}); + +// @scenario tab=seo, permitted=yes +// @effects status_endpoint +test('#481 - SEO 탭 진입 시 sitemap 상태 API 가 진행상황/실시간 여부를 반환한다', async ({ page }) => { + const token = issueToken('core.settings.read', 'core.settings.update'); + await authenticatePage(page, token); + + const status = page.waitForResponse( + (r) => r.url().includes('/api/admin/seo/sitemap/status') && r.request().method() === 'GET', + { timeout: 20_000 } + ); + + await gotoSeoTab(page); + + const res = await status; + expect(res.status()).toBe(200); + const body = await res.json(); + // 상태 응답 스키마: 진행상황(progress) + 실시간 연결 가능 여부(realtime_enabled) + expect(body?.data).toHaveProperty('realtime_enabled'); + expect(body?.data).toHaveProperty('progress'); + expect(body?.data).toHaveProperty('last_updated_at'); }); diff --git a/tests/Unit/Seo/SitemapManagerTest.php b/tests/Unit/Seo/SitemapManagerTest.php index 5673c09c..ac02bb5e 100644 --- a/tests/Unit/Seo/SitemapManagerTest.php +++ b/tests/Unit/Seo/SitemapManagerTest.php @@ -133,6 +133,7 @@ class SitemapManagerTest extends TestCase $config ?? Mockery::spy(ConfigRepositoryInterface::class), $this->storage, $this->app->make(SitemapUrlRepositoryInterface::class), + $this->app->make(\App\Seo\SitemapProgress::class), ); } @@ -312,6 +313,7 @@ class SitemapManagerTest extends TestCase Mockery::spy(ConfigRepositoryInterface::class), $this->storage, $this->incrementalRepository(), + $this->app->make(\App\Seo\SitemapProgress::class), ); $result = $manager->regenerate(); @@ -343,6 +345,7 @@ class SitemapManagerTest extends TestCase Mockery::spy(ConfigRepositoryInterface::class), $this->storage, $this->incrementalRepository(), + $this->app->make(\App\Seo\SitemapProgress::class), ); $this->assertFalse($manager->regenerate()['success']); diff --git a/tests/Unit/Seo/SitemapProgressTest.php b/tests/Unit/Seo/SitemapProgressTest.php new file mode 100644 index 00000000..90feda5e --- /dev/null +++ b/tests/Unit/Seo/SitemapProgressTest.php @@ -0,0 +1,165 @@ +progress = app(SitemapProgress::class); + } + + /** + * Reverb 방송이 실제로 발생하도록 드라이버/호스트를 설정합니다. + */ + private function enableReverb(): void + { + config(['broadcasting.default' => 'reverb']); + config(['broadcasting.connections.reverb.options.host' => 'localhost']); + } + + /** + * 실행 이력이 없으면 get() 은 null 을 반환한다. + */ + public function test_get_returns_null_when_no_run(): void + { + $this->assertNull($this->progress->get()); + } + + /** + * start() 는 status=queued 와 mode/started_at 을 기록한다. + */ + public function test_start_records_queued_state(): void + { + $this->progress->start('full'); + + $state = $this->progress->get(); + $this->assertSame('queued', $state['status']); + $this->assertSame('full', $state['mode']); + $this->assertNotNull($state['started_at']); + $this->assertSame(0, $state['urls']); + } + + /** + * phase() 는 status=running 과 현재 단계를 기록한다. + */ + public function test_phase_records_running_state(): void + { + $this->progress->start('full'); + $this->progress->phase('sirsoft-board', 1200); + + $state = $this->progress->get(); + $this->assertSame('running', $state['status']); + $this->assertSame('sirsoft-board', $state['phase']); + $this->assertSame(1200, $state['urls']); + } + + /** + * writing() 은 status=writing 을 기록한다. + */ + public function test_writing_records_writing_state(): void + { + $this->progress->start('full'); + $this->progress->writing(3400); + + $state = $this->progress->get(); + $this->assertSame('writing', $state['status']); + $this->assertSame(3400, $state['urls']); + } + + /** + * complete() 는 status=completed 와 url_count/child_count 를 기록한다. + */ + public function test_complete_records_completed_state(): void + { + $this->progress->start('full'); + $this->progress->complete(['url_count' => 5000, 'child_count' => 2]); + + $state = $this->progress->get(); + $this->assertSame('completed', $state['status']); + $this->assertSame(5000, $state['url_count']); + $this->assertSame(2, $state['child_count']); + $this->assertNotNull($state['finished_at']); + } + + /** + * fail() 은 status=failed 와 메시지를 기록한다. + */ + public function test_fail_records_failed_state(): void + { + $this->progress->start('full'); + $this->progress->fail('boom'); + + $state = $this->progress->get(); + $this->assertSame('failed', $state['status']); + $this->assertSame('boom', $state['message']); + $this->assertNotNull($state['finished_at']); + } + + /** + * Reverb ON 이면 방송 payload 가 상태 API data 형태(progress + realtime_enabled)와 동형이다. + */ + public function test_broadcast_payload_mirrors_status_shape(): void + { + $this->enableReverb(); + Event::fake([GenericBroadcastEvent::class]); + + $this->progress->start('full'); + + Event::assertDispatched(GenericBroadcastEvent::class, function ($event) { + return $event->channel === 'core.admin.seo.sitemap' + && $event->eventName === 'sitemap.progress.updated' + && ($event->payload['realtime_enabled'] ?? null) === true + && ($event->payload['progress']['status'] ?? null) === 'queued'; + }); + } + + /** + * Reverb OFF 여도 캐시는 기록된다(폴링 폴백 성립). + */ + public function test_cache_is_written_even_when_reverb_off(): void + { + config(['broadcasting.default' => 'null']); + Event::fake([GenericBroadcastEvent::class]); + + $this->progress->start('full'); + + Event::assertNotDispatched(GenericBroadcastEvent::class); + $this->assertSame('queued', $this->progress->get()['status']); + } + + /** + * 같은 단계에서 urls 만 증가하면 방송은 간격 스로틀되지만 캐시는 항상 기록된다. + */ + public function test_phase_broadcast_is_throttled_but_cache_always_written(): void + { + $this->enableReverb(); + Event::fake([GenericBroadcastEvent::class]); + + // 단계 진입 — 방송 1회 + $this->progress->phase('sirsoft-board', 100); + // 같은 단계, 소폭 증가(<5000) — 방송 스킵, 캐시는 갱신 + $this->progress->phase('sirsoft-board', 200); + // 같은 단계, 간격 초과(>=5000) — 방송 재발 + $this->progress->phase('sirsoft-board', 5300); + + // 방송은 진입 + 간격초과 = 2회 + Event::assertDispatchedTimes(GenericBroadcastEvent::class, 2); + // 캐시는 마지막 값으로 항상 갱신 + $this->assertSame(5300, $this->progress->get()['urls']); + } +} From 3dd34104cce394e535556a63793c01f6d13677bb Mon Sep 17 00:00:00 2001 From: HeuJung Date: Sat, 18 Jul 2026 20:56:47 +0900 Subject: [PATCH 06/11] =?UTF-8?q?feat(seo):=20=EC=82=AC=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EB=A7=B5=20=EB=8C=80=EC=9A=A9=EB=9F=89=20=ED=95=98=EB=84=A4?= =?UTF-8?q?=EC=8A=A4=20+=20=EC=BD=94=EC=96=B4=207.1.0=20=EB=A7=88=EA=B0=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S6(하네스 + 마감): 큐/생성기의 볼륨 붕괴 재발을 정적으로 강제하고, S1~S5 사이트맵 개편의 공개 표면을 문서화하며 코어를 7.1.0 으로 확정한다. 하네스(주안점 5): - test-scenario 매니페스트에 scale 축(n≥100000 + assert) 도입. 내장 YAML fallback 파서 제약으로 block-array of flow objects 형태. - 룰 2종: scenario-scale-axis-required(batch 매니페스트 scale 강제), job-generator-needs-scale-test(Jobs·*Generator 변경 시 scale 시나리오 요구). *Command 은 볼륨 비의존이라 제외 — tags:[batch] opt-in 으로 커버. - tests/scenarios/sitemap-generation.yaml + @scale 마킹 3(Writer/Progress/ManagerMode). 문서: seo-system(증분 저장소·모드·진행상황·lazy contributor)·testing-guide(scale 절)· broadcasting(sitemap 채널)·hooks(2훅)·database-guide(loc_hash)·cheatsheet. 버전: 코어 7.0.5→7.1.0(config+env+README+INSTALL+CHANGELOG) + 모듈 board/ecommerce/page requires.g7_version >=7.1.0. --- .env.example | 2 +- .env.testing.example | 2 +- CHANGELOG.md | 15 +++ INSTALL.md | 2 +- README.md | 2 +- config/app.php | 2 +- docs/backend/broadcasting.md | 16 ++- docs/backend/seo-system.md | 78 +++++++++++-- docs/cheatsheet.md | 6 +- docs/database-guide.md | 20 ++++ docs/extension/hooks.md | 10 ++ docs/testing-guide.md | 36 ++++++ modules/_bundled/sirsoft-board/CHANGELOG.md | 1 + modules/_bundled/sirsoft-board/module.json | 2 +- .../_bundled/sirsoft-ecommerce/CHANGELOG.md | 1 + .../_bundled/sirsoft-ecommerce/module.json | 2 +- modules/_bundled/sirsoft-page/CHANGELOG.md | 1 + modules/_bundled/sirsoft-page/module.json | 2 +- tests/Feature/Seo/SitemapManagerModeTest.php | 2 + tests/Unit/Seo/SitemapProgressTest.php | 2 + tests/Unit/Seo/SitemapWriterTest.php | 2 + tests/scenarios/sitemap-generation.yaml | 103 ++++++++++++++++++ 22 files changed, 290 insertions(+), 19 deletions(-) create mode 100644 tests/scenarios/sitemap-generation.yaml diff --git a/.env.example b/.env.example index 27d61180..03a6f2e9 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ APP_ENV=production APP_KEY= APP_DEBUG=false APP_URL=http://localhost -APP_VERSION=7.0.5 +APP_VERSION=7.1.0 APP_LOCALE=ko APP_FALLBACK_LOCALE=ko diff --git a/.env.testing.example b/.env.testing.example index bec41e3b..622261bc 100644 --- a/.env.testing.example +++ b/.env.testing.example @@ -3,7 +3,7 @@ APP_ENV=testing APP_KEY= APP_DEBUG=false APP_URL=http://localhost -APP_VERSION=7.0.5 +APP_VERSION=7.1.0 APP_LOCALE=ko APP_FALLBACK_LOCALE=ko diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e7ce37e..755e89b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [7.1.0] - 2026-07-18 + +### Added + +- 게시글·상품이 수백만 건으로 늘어나도 사이트맵을 안정적으로 생성하도록 개선했습니다. 예전에는 사이트맵을 만들 때 전체 데이터를 한꺼번에 메모리에 올리다가 데이터가 많은 사이트에서 생성이 실패했지만, 이제 나눠 읽어 처리하므로 메모리 부족 없이 완성됩니다. (#79 @jiwonpapa 님께서 제보해주셨습니다.) +- 사이트맵을 검색엔진 규격(파일당 5만 URL / 50MB)에 맞춰 여러 파일로 자동 분할하고, 이를 묶는 색인 파일을 함께 만듭니다. 한 파일에 담을 URL 수는 SEO 설정에서 조정할 수 있습니다. +- 게시글·상품·페이지를 공개/비공개로 바꾸거나 삭제하면 사이트맵에서 해당 항목만 자동으로 더하거나 빼도록 바꿨습니다. 전체를 다시 만들지 않고 바뀐 부분만 반영하므로 항상 최신 상태가 빠르게 유지됩니다. +- 검색엔진 봇이 사이트맵을 요청하면 미리 만들어 둔 파일을 그대로 내보내고, 요청을 받은 그 자리에서 새로 만들지 않도록 바꿨습니다. 아직 만들어진 사이트맵이 없으면 잠시 후 다시 요청하도록 안내하고 백그라운드에서 생성합니다. +- 관리자 화면의 사이트맵 전체 재생성을 백그라운드 작업으로 처리하도록 바꾸고, 진행 상황(대기·생성 중·파일 작성·완료·실패)과 처리한 주소 수를 화면에서 확인할 수 있게 했습니다. 실시간 연결이 켜져 있으면 즉시 갱신되고, 꺼져 있으면 주기적으로 확인합니다. +- 다국어 사이트를 위한 대체 언어 링크(hreflang)를 사이트맵에 넣을지 켜고 끌 수 있으며, 언어 수가 많을 때 링크가 과도하게 늘어나지 않도록 상한을 두었습니다. + +### Fixed + +- SEO 설정의 캐시 항목(SEO 캐시 사용·유지 시간, Sitemap 캐시 유지 시간)이 저장해도 적용되지 않던 문제를 수정했습니다. 이제 고급 설정의 캐시 값이 기준이 되고, SEO 설정에서 값을 지정하면 그 값이 우선하며, 비워두면 고급 설정을 따릅니다. 업그레이드 시 기존에 기본값과 다르게 지정해 두셨던 값은 그대로 살아나 이제부터 실제로 적용되므로 SEO 캐시 동작이 달라질 수 있습니다. 기본값 그대로였던 항목은 "지정 안 함"으로 정리되어 동작이 바뀌지 않습니다. + ## [7.0.5] - 2026-07-16 ### Added diff --git a/INSTALL.md b/INSTALL.md index 6c55f3cf..a43b314f 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -246,7 +246,7 @@ unzip g7-release.zip # 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경 ls -la -# (필요 시) mv g7-7.0.5 g7 +# (필요 시) mv g7-7.1.0 g7 # ZIP 파일 정리 (선택) rm g7-release.zip diff --git a/README.md b/README.md index 8a4c6494..883a5789 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@

- Version + Version PHP Laravel React diff --git a/config/app.php b/config/app.php index 986558af..468cfaad 100644 --- a/config/app.php +++ b/config/app.php @@ -231,7 +231,7 @@ return [ | */ - 'version' => env('APP_VERSION', '7.0.5'), + 'version' => env('APP_VERSION', '7.1.0'), /* |-------------------------------------------------------------------------- diff --git a/docs/backend/broadcasting.md b/docs/backend/broadcasting.md index bc0f77de..0a6e5174 100644 --- a/docs/backend/broadcasting.md +++ b/docs/backend/broadcasting.md @@ -211,10 +211,19 @@ HookManager::broadcast("core.user.notifications.{$user->uuid}", 'notification.re | 소스 | 패턴 | 예시 | |------|------|------| -| 코어 | `core.*` | `core.admin.dashboard`, `core.user.notifications.{id}` | +| 코어 | `core.*` | `core.admin.dashboard`, `core.admin.seo.sitemap`, `core.user.notifications.{id}` | | 모듈 | `module.{identifier}.*` | `module.sirsoft-ecommerce.orders.{id}` | | 플러그인 | `plugin.{identifier}.*` | `plugin.sirsoft-payment.status.{id}` | +### 코어 방송 채널 목록 + +| 채널 | 이벤트 | 인증 권한 | 페이로드 | +|------|--------|----------|----------| +| `core.admin.dashboard` | `dashboard.stats.updated` | `core.dashboard.read` | `{type, data}` | +| `core.admin.seo.sitemap` | `sitemap.progress.updated` | `core.settings.read` | `{realtime_enabled, progress}` (상태 API `data` 와 동형) | + +> `core.admin.seo.sitemap` 은 Sitemap 생성 진행상황을 실시간 전달합니다. Reverb OFF 면 방송이 자동 skip 되고 프론트가 상태 API 폴링으로 폴백합니다. payload 가 상태 API 응답 `data` 와 동형이라 websocket 데이터소스 `target_source` 교체 후에도 UI 바인딩이 유지됩니다. + --- ## 모듈/플러그인 채널 등록 @@ -314,6 +323,11 @@ Broadcast::channel('core.admin.dashboard', function ($user) { return $user->hasPermission('core.dashboard.read'); }); +// 관리자 Sitemap 생성 진행상황 채널 - SEO 설정 읽기 권한 +Broadcast::channel('core.admin.seo.sitemap', function ($user) { + return $user->hasPermission('core.settings.read'); +}); + // 모듈 리소스 채널 (모듈의 getChannels()로 자동 등록 — 참고용 예시) // Broadcast::channel('module.sirsoft-ecommerce.orders.{orderId}', function ($user, $orderId) { // return $user->hasPermission('sirsoft-ecommerce.orders.read'); diff --git a/docs/backend/seo-system.md b/docs/backend/seo-system.md index ca72c66f..86bfd9ab 100644 --- a/docs/backend/seo-system.md +++ b/docs/backend/seo-system.md @@ -12,8 +12,8 @@ 5. seo-config.json: 텍스트 추출(text_props), 속성 매핑(attr_map), 허용 속성(allowed_attrs), 컴포넌트→HTML 매핑 — 모두 템플릿 선언 6. 훅 시스템: core.seo.filter_context/filter_og_data/filter_twitter_data/filter_structured_data/filter_meta/filter_view_data + 봇 감지 훅 core.seo.resolve_is_bot 7. 도메인 ownership: 모듈/플러그인이 `seoOgDefaults`/`seoTwitterDefaults`/`seoStructuredData` 메서드로 자기 도메인 OG/Twitter/JSON-LD 를 owned (이커머스 Product, 게시판 Article 등) -8. Sitemap: 비공개 디스크에 분할 커밋(sitemapindex + sitemap-{n}.xml) 후 스트리밍 서빙 — 요청 스레드 생성 없음, 미스 시 잡 디스패치 + stale/503 -9. Artisan: seo:warmup, seo:clear, seo:stats, seo:generate-sitemap +8. Sitemap: 스트리밍 writer(유계 메모리)로 비공개 디스크에 분할 커밋(sitemapindex + sitemap-{n}.xml) 후 서빙 — 요청 스레드 생성 없음, 미스 시 잡 디스패치 + stale/503. 증분 저장소(sitemap_urls) + 재생성 모드(SitemapGenerationMode full/auto/incremental) + 진행상황(SitemapProgress, Reverb 실시간/OFF 폴링) +9. Artisan: seo:warmup, seo:clear, seo:stats, seo:generate-sitemap(--rebuild/--mode) ``` ## 아키텍처 개요 @@ -71,7 +71,14 @@ Request → web.php catch-all → SeoMiddleware (봇 감지) | SeoMetaResolver | 3계층 캐스케이드 메타 해석 | | SeoMiddleware | 미들웨어 (봇 감지 → 렌더링) | | SeoServiceProvider | DI 바인딩 | -| SitemapGenerator | Sitemap 수집기 | +| SitemapGenerator | Sitemap 수집기 (contributor 드레인 → writer 스트리밍) | +| SitemapManager | Sitemap 재생성 오케스트레이션 (모드 분기 + 진행상황 + 훅) | +| SitemapWriter | 자식파일당 in-memory 버퍼 flush + 분할 + atomic commit | +| SitemapFileStore | 디스크 세트 읽기/서빙 (manifest·index·child 응답) | +| SitemapXmlRenderer | XML escape/`` 블록/hreflang 단일 출처 | +| SitemapIndexer | 리소스→sitemap_urls index/deindex (리스너 경유) | +| SitemapProgress | 진행상황 스토어 (phase 기반 + 방송) | +| AbstractSitemapContributor | contributor 브리지 base (getUrls↔getUrlsLazy) | | SeoInvalidationRegistry | 무효화 규칙 레지스트리 | | SeoDeclarationCollector | 레이아웃 SEO 선언 수집 | | SeoCacheStatsService | 캐시 통계 집계 | @@ -84,6 +91,8 @@ Request → web.php catch-all → SeoMiddleware (봇 감지) | /api/admin/seo/clear-cache | POST | api.admin.seo.clear-cache | 캐시 삭제 | | /api/admin/seo/warmup | POST | api.admin.seo.warmup | 워밍업 | | /api/admin/seo/cached-urls | GET | api.admin.seo.cached-urls | 캐시 URL 목록 | +| /api/admin/seo/sitemap/regenerate | POST | api.admin.seo.sitemap.regenerate | Sitemap 전체 재생성 (큐 예약, mode=full) | +| /api/admin/seo/sitemap/status | GET | api.admin.seo.sitemap.status | Sitemap 생성 진행상황 + realtime_enabled | ## SeoMiddleware 동작 규칙 @@ -578,17 +587,31 @@ class EcommerceModule extends AbstractModule **레이아웃 활성화 조건**: 모듈 declaration 이 호출되려면 레이아웃 `meta.seo.extensions` 에 `[{ "type": "module", "id": "sirsoft-ecommerce" }]` 선언 + `meta.seo.page_type` 명시 필요. -### SitemapContributorInterface 구현 +### SitemapContributorInterface 구현 (지연 스트리밍 권장) + +대용량 도메인은 `AbstractSitemapContributor` 를 상속해 `getUrlsLazy()`(제너레이터)를 구현합니다. base 가 `getUrls()↔getUrlsLazy()` 를 양방향 브리지하므로 둘 중 하나만 구현하면 됩니다. 인터페이스 자체는 변경되지 않아 base 를 상속하지 않은 제3자 raw 구현체(`implements SitemapContributorInterface` + `getUrls()`)도 그대로 동작합니다. ```php // modules/_bundled/[module]/src/Seo/[Module]SitemapContributor.php -class EcommerceSitemapContributor implements SitemapContributorInterface +class EcommerceSitemapContributor extends AbstractSitemapContributor { - public function getUrls(): array { /* 상품/카테고리 URL 반환 */ } public function getIdentifier(): string { return 'sirsoft-ecommerce'; } + + // 한 건씩 yield — 1.4M 배열을 메모리에 만들지 않음. 쿼리는 Repository 의 + // stream*ForSitemap() 위임(lazyById, service-direct-data-access 규율). + // 리소스 단위 증분(SitemapIndexer) 매칭을 위해 resource_type/resource_id 를 함께 emit. + public function getUrlsLazy(): iterable + { + foreach ($this->products->streamVisibleForSitemap() as $p) { + yield ['loc' => url("/shop/{$p->id}"), 'lastmod' => $p->updated_at?->toW3cString(), + 'resource_type' => 'product', 'resource_id' => (string) $p->id]; + } + } } ``` +`SitemapGenerator::drain()` 이 `instanceof AbstractSitemapContributor || method_exists($c, 'getUrlsLazy')` 로 capability 를 감지해 지연 경로를 우선합니다. + ### ServiceProvider 등록 ```php @@ -629,10 +652,17 @@ php artisan seo:warmup --layout=shop/show # 특정 레이아웃만 php artisan seo:clear # 전체 SEO 캐시 삭제 php artisan seo:clear --layout=home # 특정 레이아웃만 php artisan seo:stats # 캐시 통계 출력 -php artisan seo:generate-sitemap # Sitemap 생성 (큐 디스패치) +php artisan seo:generate-sitemap # Sitemap 생성 (큐 디스패치, mode=auto) php artisan seo:generate-sitemap --sync # Sitemap 동기 생성 +php artisan seo:generate-sitemap --rebuild # 전체 재생성 (mode=full 상당) +php artisan seo:generate-sitemap --mode=full|auto|incremental # 재생성 모드 지정 ``` +- `--mode=full`: 도메인 전량을 다시 읽어 `sitemap_urls` 를 전면 replace 후 파일 재작성(자식 균등 재분배). +- `--mode=incremental`: 도메인 재쿼리 없이 `sitemap_urls` 스트림만으로 파일 재작성(리스너가 반영한 델타 그대로). +- `--mode=auto`(기본): 저장소가 비어 있으면 full, 채워져 있으면 incremental. +- `--rebuild` 은 `--mode=full` 의 별칭입니다. + ## Sitemap 분할 생성과 서빙 Sitemap 은 비공개 디스크(StorageInterface, `cache` 카테고리)에 분할 파일로 커밋되고, 컨트롤러가 스트리밍으로 서빙합니다. `public/storage` 심볼릭 링크에 의존하지 않으며 자식 파일을 메모리에 적재하지 않습니다. @@ -661,7 +691,38 @@ sitemap/_tmp/ 생성 중 임시 디렉토리 (커밋 시 정리) **분할 기준**: `sitemap_urls_per_file`(기본 50000) 또는 파일 크기 임계(`SitemapWriter::MAX_FILE_BYTES`, 45MB) 중 먼저 도달하는 쪽. 둘 다 sitemaps.org 프로토콜 제한(50,000 URL / 50MB)을 지키기 위한 것입니다. -**관리자 수동 재생성**: `POST /api/admin/seo/sitemap/regenerate` 는 큐에 예약만 하고 즉시 응답합니다(`status: dispatched`). 완료 여부는 `sitemap_last_updated_at` 갱신으로 확인합니다. +**관리자 수동 재생성**: `POST /api/admin/seo/sitemap/regenerate` 는 큐에 `mode=full` 로 예약만 하고 즉시 진행상황(`getStatus()`)을 응답합니다. 완료 여부는 진행상황(아래) 또는 `sitemap_last_updated_at` 갱신으로 확인합니다. + +## 증분 저장소 (sitemap_urls) + +리소스 단위 공개/비공개/삭제를 사이트맵에 반영하기 위해 URL 을 `sitemap_urls` 테이블에 지속합니다(주안점 1·3). 전체 재생성 없이 바뀐 리소스 행만 upsert/remove 합니다. + +- **테이블**: `resource_type`/`resource_id`/`loc`/`loc_hash`(=`sha256(loc)`)/`lastmod`/`changefreq`/`priority`/`contributor`/`is_visible`. unique 는 `(resource_type, resource_id, loc_hash)` — `loc string(2048)` utf8mb4 는 InnoDB 키 길이(3072 byte)를 초과하므로 ascii 64 해시로 identity 를 유지합니다(상세: docs/database-guide.md). +- **Repository**: `SitemapUrlRepositoryInterface`(`upsertForResource`=delete-후-insert 멱등 / `removeForResource` / `streamVisible`(lazyById) / `countVisible` / `replaceAllForContributor`). `SeoServiceProvider` 가 singleton 바인딩. +- **인덱서**: `SitemapIndexer::indexResource($type,$model,$entries)` / `deindexResource($type,$model)` — 리스너가 직접 모델/DB 를 만지지 않고 이 서비스를 경유합니다(`service-direct-data-access` 규율). 제3자 확장은 filter 훅 `sitemap.index.collect_for_resource` 로 리소스→entries 를 가공/추가할 수 있습니다(docs/extension/hooks.md). +- **리스너**: 모듈 SEO 리스너가 `after_create/update/delete` 에서 공개상태를 판정해 index/deindex 하고 `GenerateSitemapJob::dispatch()`(유니크 락 디바운스)를 겁니다. 리소스 단위 증분에서 자식 파일 URL 수가 임계 미만으로 줄어드는 것(5000→4999)은 감안하며, 균등 재분배(리밸런싱)는 full 재생성 때만 합니다. + +## 재생성 모드 (SitemapGenerationMode) + +`App\Enums\SitemapGenerationMode`(Backed Enum: `full`/`auto`/`incremental`)가 재생성 정책을 캡슐화합니다. `resolve(int $visibleCount)` 가 `auto` 를 저장소 상태로 full/incremental 판정합니다. + +| 트리거 | 모드 | 동작 | +|--------|------|------| +| 관리자 수동 재생성 | 항상 `full` | 도메인 전량 → 테이블 replace → 파일 전량 재작성 | +| 스케줄러 | `auto` | 테이블 비었으면 full, 아니면 incremental | +| 리소스 변경(리스너) | 해당 리소스만 | 행 upsert/remove 후 잡 디스패치 | + +`Command → GenerateSitemapJob → SitemapManager::regenerate(SitemapGenerationMode)` 전 경로가 enum 시그니처를 공유합니다(잡 직렬화는 enum 프로퍼티 그대로). + +## 진행상황 가시화 (Reverb 실시간 / OFF 폴링) + +`SitemapProgress`(캐시 키 `seo.sitemap.progress`, TTL 3600)가 phase 기반 진행상황을 기록합니다: `queued → running(기여자별 phase + 누적 URL) → writing → completed/failed`. 사전 count 쿼리 없이 스트림 누적치를 표기합니다(1.4M 에서 count 자체가 부담). + +- **상태 API**: `GET /api/admin/seo/sitemap/status`(`core.settings.read`)가 `{last_updated_at, progress, realtime_enabled}` 를 반환합니다. `realtime_enabled = drivers.websocket_enabled 설정 && config('broadcasting.default') !== 'null'`(설정 SSoT + 실제 적용 config 양쪽 확인). +- **방송**: `SitemapProgress` 가 `core.admin.seo.sitemap` 채널로 `sitemap.progress.updated` 를 방송합니다(payload 는 상태 API `data` 와 동형). Reverb OFF 면 `HookManager::broadcast` 가 자동 skip 하고 캐시만 기록되어 폴링 폴백이 성립합니다. 방송은 N URL(5000) 간격으로 스로틀합니다. +- **채널 인증**: `routes/channels.php` 에 `core.admin.seo.sitemap` → `core.settings.read`(docs/backend/broadcasting.md). +- **잡 실패**: `GenerateSitemapJob::failed()` 가 `SitemapProgress::fail()` 을 호출해 무한 running 을 방지합니다(TTL 만료 시 idle 복귀). +- **프론트**: SEO 탭이 상태 API 초기 로드 후 `realtime_enabled` 로 분기합니다 — true 면 websocket 데이터소스(`target_source`)로 갱신, false 면 `startInterval`/`stopInterval` 로 3초 폴링(완료/실패 전이 시 중단). ## 설정값 (코어 seo.*) @@ -677,6 +738,7 @@ sitemap/_tmp/ 생성 중 임시 디렉토리 (커밋 시 정리) | sitemap_gzip | boolean | false | 자식 파일 gzip 압축. 인덱스 파일은 항상 비압축 | | sitemap_serve_stale_on_miss | boolean | true | 신선도 만료 시 기존 세트를 그대로 서빙(true) / 503 반환(false) | | sitemap_max_urls_per_contributor | integer | 0 | 수집기당 URL 상한 (0=무제한). 초과 시 경고 로그 + truncate | +| sitemap_hreflang_enabled | boolean | true | 다국어 alternate(hreflang) 링크 출력 ON/OFF. 로케일 수가 `SitemapXmlRenderer::MAX_HREFLANG`(50) 초과 시 alternate 생략 | | sitemap_schedule | string | "daily" | 생성 주기 (hourly/daily/weekly) | | sitemap_schedule_time | string | "02:00" | 생성 시각 | | og_default_site_name | string | "" | og:site_name 기본값. 비면 `general.site_name` fallback | diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md index 905f9d48..cf333c2e 100644 --- a/docs/cheatsheet.md +++ b/docs/cheatsheet.md @@ -249,8 +249,10 @@ php artisan seo:warmup --layout=shop/show # 특정 레이아웃만 php artisan seo:clear # 전체 SEO 캐시 삭제 php artisan seo:clear --layout=home # 특정 레이아웃만 php artisan seo:stats # 캐시 통계 출력 -php artisan seo:generate-sitemap # Sitemap 생성 (큐 디스패치) -php artisan seo:generate-sitemap --sync # Sitemap 동기 생성 +php artisan seo:generate-sitemap # Sitemap 생성 (큐 디스패치, mode=auto) +php artisan seo:generate-sitemap --sync # Sitemap 동기 생성 +php artisan seo:generate-sitemap --rebuild # 전체 재생성 (mode=full) +php artisan seo:generate-sitemap --mode=full|auto|incremental # 재생성 모드 지정 ``` ### API 문서 Artisan 커맨드 diff --git a/docs/database-guide.md b/docs/database-guide.md index a81030dd..f93d1e2e 100644 --- a/docs/database-guide.md +++ b/docs/database-guide.md @@ -204,6 +204,26 @@ php artisan migrate --pretend --path=database/migrations/2026_05_05_172526_add_l --- +### 긴 문자열 컬럼의 unique — 해시 컬럼 패턴 (sitemap_urls 사례) + +utf8mb4(4 byte/char) 긴 문자열 컬럼을 unique 인덱스에 직접 넣으면 InnoDB 키 길이 제한(3072 byte)을 넘겨 마이그레이션이 실패한다. 예: `sitemap_urls` 의 identity 는 `(resource_type, resource_id, loc)` 인데 `loc string(2048)` 을 넣으면 `256+256+8192 = 8704 byte` 로 초과한다. + +해결: **identity 문자열의 고정 길이 해시 컬럼**을 두고 그것을 unique 에 넣는다. + +```php +$table->string('loc', 2048); // 실제 값 (utf8mb4) +$table->char('loc_hash', 64)->charset('ascii'); // sha256(loc) — ascii 64 byte +$table->unique(['resource_type', 'resource_id', 'loc_hash']); // 64+256+256 = 576 byte +``` + +- 해시는 app 레벨(`hash('sha256', $loc)`)에서 계산한다 — raw SQL/DB 함수 비의존(다국어·DB 이식성 규율). +- `loc` 는 실제 서빙/표시에 쓰고, `loc_hash` 는 오직 identity 매칭용이다. +- upsert 는 delete-후-insert 로 멱등을 보장하고, 이 unique 가 중복 삽입(1062)을 차단한다. + +`sitemap_urls` 스키마: `resource_type(64,index)` · `resource_id(64,nullable)` · `loc(2048)` · `loc_hash(64,ascii)` · `lastmod` · `changefreq(16)` · `priority(2,1)` · `contributor(64,index)` · `is_visible(bool,index)` + `unique(resource_type, resource_id, loc_hash)` + `index(contributor, is_visible, id)`(리빌드 스트리밍 커서). 모든 컬럼 한국어 `->comment()`, `down()` 구현, `Schema::hasTable` 멱등 가드. + +--- + ### 모듈/플러그인 마이그레이션 down() 메서드 ``` diff --git a/docs/extension/hooks.md b/docs/extension/hooks.md index 86e2a40e..a7ec6362 100644 --- a/docs/extension/hooks.md +++ b/docs/extension/hooks.md @@ -157,8 +157,18 @@ core.seo.filter_view_data # View 직전 ($viewData) — extraHeadTags / ex # SEO 봇 감지 훅 (Filter) — null 반환 시 라이브러리 평가로 fallthrough core.seo.resolve_is_bot + +# Sitemap 증분 인덱싱 훅 (Filter) — 리소스→sitemap 항목 가공/추가 +sitemap.index.collect_for_resource # SitemapIndexer 가 리소스 index 시 발화 — applyFilters($entries, $type, $id, $contributor) + +# Sitemap 재생성 훅 (Action) +core.seo.sitemap.before_regenerate # 재생성 시작 직전 +core.seo.sitemap.after_regenerate # 재생성 성공 후 ($meta) +core.seo.sitemap.after_regenerate_failed # 재생성 실패 시 (['status'=>'failed', 'success'=>false, ...]) ``` +> `sitemap.index.collect_for_resource` 는 제3자 확장이 리소스 하나에 대한 sitemap 항목을 추가/보정할 때 씁니다(filter — `'type' => 'filter'` 명시 필수). `core.seo.sitemap.after_regenerate_failed` 는 재생성 잡이 실패했을 때 확장이 알림/복구를 걸 수 있는 action 훅입니다. + --- ## 리스너 구현 diff --git a/docs/testing-guide.md b/docs/testing-guide.md index 8333db3b..bffed930 100644 --- a/docs/testing-guide.md +++ b/docs/testing-guide.md @@ -349,6 +349,42 @@ audit rule `test-scenario-coverage` 가 매니페스트 cross product 와 effect 2. **Pairwise (all-pairs)** 전략 허용: 매니페스트에 `coverage_strategy: pairwise` 명시 시 모든 axis 쌍의 모든 조합만 커버 (n축 → O(n²) 케이스). 단순 분기형 axis(true/false) 가 많을 때 유효 3. 그래도 폭발하면 **상위 axis 분리**: 매니페스트를 도메인별로 분할 (예: `order_payment_pg_toss_member.yaml`, `order_payment_pg_toss_guest.yaml`) +### 대규모 시나리오 축(scale) + +큐/스케줄러/배치 생성처럼 데이터가 누적되면 붕괴할 수 있는 기능은 대용량(scale) 축을 매니페스트에 선언하고, 규모 의존 결함(유계 메모리·분할 정확성·전량 적재 회피 등)을 회귀 가드로 고정한다. 배경: 사이트맵 생성 잡이 1.4M 게시글을 전량 in-memory 적재해 반복 OOM 붕괴한 사례(#79). + +**스키마** (`scale` = block-array of flow objects — 내장 YAML fallback 파서가 flow 객체만 중첩 파싱하므로 `- { ... }` 형태): + +```yaml +scale: + - { n: 1500000, dataset: posts+products, assert: [bounded_peak_memory, child_count_correct, no_full_table_load] } +``` + +- `n`: 데이터셋 규모(건수). 10만 이상이어야 scale 축의 의미가 있다. +- `dataset`: 대용량 데이터셋 설명(선택). +- `assert`: 이 규모에서 검증할 단언(snake_case). 실 대용량 DB 대신 합성 iterator·계측(예: `memory_get_peak_usage`, `DB::listen`, 방송 스로틀 카운트)으로 성질을 고정해도 된다. + +**테스트 마킹** — `effects` 처럼 테스트 docblock `@scale` 마킹으로 각 assert 를 커버: + +```php +/** + * @scale n=1500000 asserts=bounded_peak_memory, child_count_correct + */ +public function test_large_volume_splits_and_keeps_memory_bounded(): void { ... } +``` + +TypeScript 테스트는 `// @scale ...` 라인 주석 동일 사용. + +**강제 룰 2종** (자동 차단): + +| 룰 | 대상 | 검출 | +|----|------|------| +| `scenario-scale-axis-required` | `tests/scenarios/**/*.yaml` | batch/generation 매니페스트(test_files 에 `/Jobs/`·`Generator` 또는 tags `[batch]`/`[generation]`)인데 scale 축 없음 / n<100000 / assert 미커버 | +| `job-generator-needs-scale-test` | `app/Jobs/**`·`**/*Generator.php` | 소스 변경 시 그 클래스를 언급하고 scale 축을 가진 매니페스트가 없음 | + +- `*Command.php` 는 흔하고 볼륨 비의존이라 `job-generator-needs-scale-test` 대상에서 제외한다. 배치 커맨드는 매니페스트 `tags: [batch]` + `scenario-scale-axis-required` 로 커버한다. +- 면제: 매니페스트 헤더 `# audit:allow scenario-scale-axis-required reason: ...` / 소스 헤더 `// audit:allow job-generator-needs-scale-test <사유>`. + ### 작성 절차 1. 영향 변수 카탈로그 식별 diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index 087c00c0..43cb5bb9 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -14,6 +14,7 @@ ### Changed - 사이트맵을 만들 때 게시판과 게시글을 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 게시글이 많은 사이트에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다. +- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.1.0 이상이 필요합니다. ## [1.0.2] - 2026-07-14 diff --git a/modules/_bundled/sirsoft-board/module.json b/modules/_bundled/sirsoft-board/module.json index 0847a707..e21da48b 100644 --- a/modules/_bundled/sirsoft-board/module.json +++ b/modules/_bundled/sirsoft-board/module.json @@ -11,7 +11,7 @@ "ko": "게시판 관리를 위한 모듈", "en": "Module for board management" }, - "g7_version": ">=7.0.0", + "g7_version": ">=7.1.0", "dependencies": { "modules": {}, "plugins": {} diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index fa2706f7..84353f0e 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -14,6 +14,7 @@ ### Changed - 사이트맵을 만들 때 상품과 카테고리를 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 상품이 많은 쇼핑몰에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다. +- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.1.0 이상이 필요합니다. ## [1.0.4] - 2026-07-16 diff --git a/modules/_bundled/sirsoft-ecommerce/module.json b/modules/_bundled/sirsoft-ecommerce/module.json index 7f383341..4afa939d 100644 --- a/modules/_bundled/sirsoft-ecommerce/module.json +++ b/modules/_bundled/sirsoft-ecommerce/module.json @@ -11,7 +11,7 @@ "ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리", "en": "Gnuboard7 Ecommerce Module - Product, Order, Payment Management" }, - "g7_version": ">=7.0.5", + "g7_version": ">=7.1.0", "dependencies": { "modules": {}, "plugins": {} diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md index 21db7ade..b624481d 100644 --- a/modules/_bundled/sirsoft-page/CHANGELOG.md +++ b/modules/_bundled/sirsoft-page/CHANGELOG.md @@ -14,6 +14,7 @@ ### Changed - 사이트맵을 만들 때 페이지를 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 페이지가 많은 사이트에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다. +- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.1.0 이상이 필요합니다. ## [1.0.1] - 2026-07-08 diff --git a/modules/_bundled/sirsoft-page/module.json b/modules/_bundled/sirsoft-page/module.json index 78007a38..cf7b196e 100644 --- a/modules/_bundled/sirsoft-page/module.json +++ b/modules/_bundled/sirsoft-page/module.json @@ -11,7 +11,7 @@ "ko": "정적 페이지(정보/정책/안내) 관리 모듈", "en": "Static page (info/policy/guide) management module" }, - "g7_version": ">=7.0.0", + "g7_version": ">=7.1.0", "dependencies": { "modules": {}, "plugins": {} diff --git a/tests/Feature/Seo/SitemapManagerModeTest.php b/tests/Feature/Seo/SitemapManagerModeTest.php index 1a4dd793..3c0ffa47 100644 --- a/tests/Feature/Seo/SitemapManagerModeTest.php +++ b/tests/Feature/Seo/SitemapManagerModeTest.php @@ -171,6 +171,8 @@ class SitemapManagerModeTest extends TestCase /** * incremental: 기여자 재쿼리 없이 저장소 스트림만으로 파일을 재작성한다. + * + * @scale n=1500000 asserts=no_full_table_load */ public function test_incremental_mode_streams_store_without_domain_requery(): void { diff --git a/tests/Unit/Seo/SitemapProgressTest.php b/tests/Unit/Seo/SitemapProgressTest.php index 90feda5e..cdfa73dd 100644 --- a/tests/Unit/Seo/SitemapProgressTest.php +++ b/tests/Unit/Seo/SitemapProgressTest.php @@ -144,6 +144,8 @@ class SitemapProgressTest extends TestCase /** * 같은 단계에서 urls 만 증가하면 방송은 간격 스로틀되지만 캐시는 항상 기록된다. + * + * @scale n=1500000 asserts=progress_broadcast_throttled */ public function test_phase_broadcast_is_throttled_but_cache_always_written(): void { diff --git a/tests/Unit/Seo/SitemapWriterTest.php b/tests/Unit/Seo/SitemapWriterTest.php index 1bbe3f02..37dedf5c 100644 --- a/tests/Unit/Seo/SitemapWriterTest.php +++ b/tests/Unit/Seo/SitemapWriterTest.php @@ -392,6 +392,8 @@ class SitemapWriterTest extends TestCase * 대용량(120,001건) 생성 시 자식 수가 정확하고 메모리가 유계인지 확인합니다. * * 전체 in-memory 적재(회귀)라면 URL 수에 비례해 메모리가 증가합니다. + * + * @scale n=1500000 asserts=bounded_peak_memory, child_count_correct */ public function test_large_volume_splits_and_keeps_memory_bounded(): void { diff --git a/tests/scenarios/sitemap-generation.yaml b/tests/scenarios/sitemap-generation.yaml new file mode 100644 index 00000000..d03e9fea --- /dev/null +++ b/tests/scenarios/sitemap-generation.yaml @@ -0,0 +1,103 @@ +# audit:allow test-scenario-coverage reason: 이슈 #481(공개 #79) 사이트맵 생성 확장성 SSoT. 6축 cross product(216 조합)는 docblock 전수 @scenario 매핑이 비현실적이라 면제하고, 회귀 가드는 test_files(백엔드 PHPUnit + 레이아웃 Vitest + Playwright E2E)로 커버한다. 대용량 축(scale)은 scenario-scale-axis-required / job-generator-needs-scale-test 룰이 별도 강제(면제 대상 아님) — scale.cases[].assert 는 @scale 마킹으로 전수 커버. + +feature: sitemap_generation + +description: | + 1.4M 게시글 + 20K 상품 규모에서 사이트맵 생성이 전체 DB in-memory 적재로 OOM 붕괴하던 것을, + 스트리밍 청크 writer(유계 메모리) + sitemapindex 분할 + 증분 저장소(sitemap_urls) + 큐 전환 + + 진행상황 가시화(Reverb 실시간 / OFF 폴링)로 재설계한다. + + 핵심 설계: + - 파일 생성은 SitemapWriter 가 자식파일당 in-memory 버퍼 후 flush — URL/바이트 임계 도달 시 분할. + - 서빙은 StorageInterface 비공개 디스크 stale/503 — 봇 요청 동기 생성 제거. + - 재생성 모드(SitemapGenerationMode): 관리자=full / 스케줄러=auto(빈 테이블 full, 아니면 incremental) / 리소스 변경=해당 리소스만. + - incremental 은 도메인 재쿼리 없이 sitemap_urls 스트림만으로 파일 재작성(리밸런싱 없음). + - 진행상황은 phase 기반(queued→running→writing→completed/failed), Reverb ON 방송 / OFF 캐시+폴링. + - 대용량 경로는 유계 메모리 + 분할 정확성 + 방송 스로틀을 scale 축으로 회귀 가드. + +axes: + multilingual: [off, on] + split_threshold: [under, over] + serve_mode: [fresh, stale, absent] + regen_mode: [full, auto, incremental] + trigger: [admin, scheduler, listener] + realtime: [reverb_on, reverb_off] + +exclusions: + - { trigger: admin, regen_mode: auto, reason: "관리자 수동 재생성은 항상 full (D7) — auto/incremental 조합 무의미" } + - { trigger: admin, regen_mode: incremental, reason: "관리자 수동 재생성은 항상 full (D7)" } + - { trigger: listener, regen_mode: full, reason: "리소스 변경 리스너는 해당 리소스만 증분 — full 재생성 아님 (D7)" } + - { trigger: listener, regen_mode: auto, reason: "리소스 변경 리스너는 증분 경로 — auto 판정은 스케줄러 담당" } + +effects: + # ── 생성 / 분할 / 서빙 (P0) ── + - index_emitted + - child_split_at_threshold + - stale_served_on_miss + - job_unique_lock + - async_dispatch_from_admin + # ── 재생성 모드 (P2) ── + - admin_regen_is_always_full + - scheduler_full_when_table_empty + - scheduler_incremental_when_table_populated + - incremental_append_on_publish + - incremental_remove_on_unpublish + - incremental_skips_rebalance + # ── 다국어 (P1) ── + - hreflang_bounded + # ── 진행상황 / 실시간 (P3) ── + - progress_recorded_per_phase + - progress_broadcast_when_reverb_on + - progress_cached_when_reverb_off + - polling_starts_only_when_reverb_off_and_running + - polling_stops_on_completed + - job_failure_marks_progress_failed + +# scale: 대용량 회귀 축. block-array of flow objects (내장 fallback 파서 제약 — flow 객체만 중첩 파싱) +scale: + - { n: 1500000, dataset: posts+products, assert: [bounded_peak_memory, child_count_correct, no_full_table_load, progress_broadcast_throttled] } + +test_files: + # 백엔드 — 스트리밍 코어 / 분할 / 렌더 (P0/P1) + - tests/Unit/Seo/SitemapWriterTest.php + - tests/Unit/Seo/SitemapGeneratorTest.php + - tests/Unit/Seo/SitemapFileStoreTest.php + - tests/Unit/Seo/SitemapManagerTest.php + - tests/Unit/Seo/SitemapXmlRendererTest.php + - tests/Unit/Seo/AbstractSitemapContributorTest.php + # 백엔드 — 잡 / 커맨드 (P0/P2) + - tests/Unit/Jobs/GenerateSitemapJobTest.php + - tests/Unit/Console/Commands/SeoGenerateSitemapCommandTest.php + # 백엔드 — 서빙 / 증분 저장소 / 모드 (P0/P2) + - tests/Feature/Seo/SitemapTest.php + - tests/Feature/Seo/SitemapUrlRepositoryTest.php + - tests/Feature/Seo/SitemapIndexerTest.php + - tests/Feature/Seo/SitemapManagerModeTest.php + - tests/Feature/Seo/SitemapBackfillTest.php + # 백엔드 — 진행상황 / 상태 API / 채널 (P3) + - tests/Unit/Seo/SitemapProgressTest.php + - tests/Feature/Api/Admin/SeoCacheControllerTest.php + - tests/Feature/Api/BroadcastingAuthTest.php + # 모듈 — 리소스 단위 증분(리스너) 회귀 + - modules/_bundled/sirsoft-board/tests/Feature/Seo/BoardSitemapIndexTest.php + - modules/_bundled/sirsoft-ecommerce/tests/Feature/Seo/EcommerceSitemapIndexTest.php + - modules/_bundled/sirsoft-page/tests/Feature/Seo/PageSitemapIndexTest.php + # 프론트엔드 — SEO 탭 진행상황 UI (레이아웃 렌더링) + - templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-progress.test.tsx + - templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-fields.test.tsx + # E2E — SEO 탭 분할기준/진행상황 + - tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts + +notes: | + P0(스트리밍/분할/서빙/잡) + P1(lazy/다국어) + P2(증분 저장소/모드) + P3(진행상황/Reverb)를 + 세션 S1~S5 에 걸쳐 구현했다(S6 하네스 마감). 6축 cross product 는 규모가 커 @scenario 전수 + 마킹 대신 test_files 로 회귀를 커버하고, effects 는 각 계층 테스트가 검증한다. + + 대용량(scale) 축은 실 1.5M DB 대신 합성 iterator(writer 120001) + drainCount 계측(incremental) + + 방송 스로틀 계측으로 유계성/정확성을 고정한다: + - bounded_peak_memory / child_count_correct → SitemapWriterTest::test_large_volume_splits_and_keeps_memory_bounded + - no_full_table_load → SitemapManagerModeTest::test_incremental_mode_streams_store_without_domain_requery + - progress_broadcast_throttled → SitemapProgressTest::test_phase_broadcast_is_throttled_but_cache_always_written + + scale 강제 대상 소스: app/Jobs/GenerateSitemapJob.php, app/Seo/SitemapGenerator.php + (job-generator-needs-scale-test 룰이 변경 시 이 매니페스트의 scale 블록을 요구). From 124433148c49cf96d31d4a55d2cf35fa89ebdf42 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Sun, 19 Jul 2026 18:57:51 +0900 Subject: [PATCH 07/11] =?UTF-8?q?feat(seo):=20=EC=82=AC=EC=9D=B4=ED=8A=B8?= =?UTF-8?q?=EB=A7=B5=20=EC=9E=AC=EC=83=9D=EC=84=B1=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=C2=B7=ED=8F=B4=EB=A7=81=20=ED=8F=B4=EB=B0=B1=20+=20=EC=BD=94?= =?UTF-8?q?=EC=96=B4=207.0.6=20=ED=99=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈 후속(S7) 마감 + 릴리스 버전 확정. 기능(S7): - 사이트맵 전체 재생성 완료/실패 시 실행 관리자에게 알림(수동 실행 한정). 스케줄러·증분·봇 재생성은 알림 없음(triggered_by 부재 시 skip). - 최종 실패 전용 알림 + 재생성 전용 큐 + WS 진행상황 payload 봉투 정정. - Reverb OFF 폴링 폴백 경로 수정(실브라우저 폴링에서만 드러난 결함). 버전(D28 — 정정, D20 번복): - 릴리스를 7.0.5→7.0.6(patch)로 확정. 7.1.0(minor)은 다른 브랜치에서 별도 진행. - config/app.php + .env + README + INSTALL + CHANGELOG + 모듈 3 requires 를 7.0.6 로. - D19 SEO 캐시 이행 업그레이드 스텝을 Upgrade_7_0_6 로 리네임. 이유: 스텝 버전 게이팅이 version<=toVersion 이라, 7.1.0 이름이면 7.0.5→7.0.6 업그레이드에서 이행이 실행되지 않는다. 검증: composer test-smoke OK(2·29) · 재생성 알림/업그레이드 이행/진행상황 테스트 green · version-sync/version-bump(new-bump)/extension-require green · audit 0 error. --- .env.example | 2 +- .env.testing.example | 2 +- CHANGELOG.md | 3 +- INSTALL.md | 2 +- README.md | 2 +- .../Api/Admin/SeoCacheController.php | 4 +- app/Jobs/GenerateSitemapJob.php | 30 +++- app/Listeners/SeoNotificationDataListener.php | 133 +++++++++++++++++ app/Seo/SitemapManager.php | 8 +- app/Seo/SitemapProgress.php | 38 +++-- config/app.php | 2 +- config/core.php | 94 ++++++++++++ docs/backend/api/seo.md | 2 + docs/backend/seo-system.md | 4 + lang-packs/_bundled/g7-core-ja/CHANGELOG.md | 1 + .../g7-core-ja/seed/notifications.json | 32 +++++ modules/_bundled/sirsoft-board/CHANGELOG.md | 2 +- modules/_bundled/sirsoft-board/module.json | 2 +- .../Seo/PostSeoOgImageRenderingTest.php | 14 +- .../_bundled/sirsoft-ecommerce/CHANGELOG.md | 2 +- .../_bundled/sirsoft-ecommerce/module.json | 2 +- modules/_bundled/sirsoft-page/CHANGELOG.md | 2 +- modules/_bundled/sirsoft-page/module.json | 2 +- .../admin-settings-sitemap-progress.test.tsx | 10 ++ .../layouts/admin_settings.json | 4 +- .../Seo/JaLocaleSeoIntegrationTest.php | 6 + .../Seo/SitemapRegenerateNotificationTest.php | 136 ++++++++++++++++++ ...settings-sitemap-generation-fields.spec.ts | 41 ++++++ tests/Unit/Jobs/GenerateSitemapJobTest.php | 14 ++ .../SeoNotificationDataListenerTest.php | 84 +++++++++++ tests/Unit/Seo/SitemapGeneratorTest.php | 6 + tests/Unit/Seo/SitemapProgressTest.php | 27 +++- .../NullifyUntouchedSeoCacheOverridesTest.php | 8 +- .../{Upgrade_7_1_0.php => Upgrade_7_0_6.php} | 12 +- .../01_NullifyUntouchedSeoCacheOverrides.php | 18 +-- 35 files changed, 698 insertions(+), 53 deletions(-) create mode 100644 app/Listeners/SeoNotificationDataListener.php create mode 100644 tests/Feature/Seo/SitemapRegenerateNotificationTest.php create mode 100644 tests/Unit/Listeners/SeoNotificationDataListenerTest.php rename upgrades/{Upgrade_7_1_0.php => Upgrade_7_0_6.php} (71%) rename upgrades/data/{7.1.0 => 7.0.6}/migrations/01_NullifyUntouchedSeoCacheOverrides.php (92%) diff --git a/.env.example b/.env.example index 03a6f2e9..861b052d 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ APP_ENV=production APP_KEY= APP_DEBUG=false APP_URL=http://localhost -APP_VERSION=7.1.0 +APP_VERSION=7.0.6 APP_LOCALE=ko APP_FALLBACK_LOCALE=ko diff --git a/.env.testing.example b/.env.testing.example index 622261bc..ecfb2ee2 100644 --- a/.env.testing.example +++ b/.env.testing.example @@ -3,7 +3,7 @@ APP_ENV=testing APP_KEY= APP_DEBUG=false APP_URL=http://localhost -APP_VERSION=7.1.0 +APP_VERSION=7.0.6 APP_LOCALE=ko APP_FALLBACK_LOCALE=ko diff --git a/CHANGELOG.md b/CHANGELOG.md index 755e89b7..da67a6a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. -## [7.1.0] - 2026-07-18 +## [7.0.6] - 2026-07-19 ### Added @@ -14,6 +14,7 @@ - 검색엔진 봇이 사이트맵을 요청하면 미리 만들어 둔 파일을 그대로 내보내고, 요청을 받은 그 자리에서 새로 만들지 않도록 바꿨습니다. 아직 만들어진 사이트맵이 없으면 잠시 후 다시 요청하도록 안내하고 백그라운드에서 생성합니다. - 관리자 화면의 사이트맵 전체 재생성을 백그라운드 작업으로 처리하도록 바꾸고, 진행 상황(대기·생성 중·파일 작성·완료·실패)과 처리한 주소 수를 화면에서 확인할 수 있게 했습니다. 실시간 연결이 켜져 있으면 즉시 갱신되고, 꺼져 있으면 주기적으로 확인합니다. - 다국어 사이트를 위한 대체 언어 링크(hreflang)를 사이트맵에 넣을지 켜고 끌 수 있으며, 언어 수가 많을 때 링크가 과도하게 늘어나지 않도록 상한을 두었습니다. +- 관리자가 사이트맵 전체 재생성을 실행하면, 완료되거나 실패했을 때 실행한 관리자에게 알림을 보냅니다. 재생성이 오래 걸려 화면을 떠나 있어도 결과를 놓치지 않습니다. (기본은 앱 내 알림이며 관리자 알림 설정에서 이메일도 켤 수 있습니다. 매일 자동 생성이나 글·상품 변경에 따른 재생성은 알림을 보내지 않습니다.) ### Fixed diff --git a/INSTALL.md b/INSTALL.md index a43b314f..0f49bfba 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -246,7 +246,7 @@ unzip g7-release.zip # 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경 ls -la -# (필요 시) mv g7-7.1.0 g7 +# (필요 시) mv g7-7.0.6 g7 # ZIP 파일 정리 (선택) rm g7-release.zip diff --git a/README.md b/README.md index 883a5789..63ae0606 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@

- Version + Version PHP Laravel React diff --git a/app/Http/Controllers/Api/Admin/SeoCacheController.php b/app/Http/Controllers/Api/Admin/SeoCacheController.php index 0345c9a0..8f3e7be8 100644 --- a/app/Http/Controllers/Api/Admin/SeoCacheController.php +++ b/app/Http/Controllers/Api/Admin/SeoCacheController.php @@ -12,6 +12,7 @@ use App\Seo\SitemapManager; use App\Seo\SitemapProgress; use Carbon\Carbon; use Illuminate\Http\JsonResponse; +use Illuminate\Support\Facades\Auth; /** * SEO 캐시 관리 컨트롤러 @@ -115,7 +116,8 @@ class SeoCacheController extends AdminBaseController } // 관리자 수동 재생성은 항상 전체(Full) — 현 생성 상태와 무관하게 전량 재생성 (D7) - GenerateSitemapJob::dispatch(SitemapGenerationMode::Full); + // 실행한 관리자 ID 를 함께 실어, 완료/실패 시 그 관리자에게만 알림이 발송되게 한다. + GenerateSitemapJob::dispatch(SitemapGenerationMode::Full, Auth::id()); // 큐 대기 중에도 UI 가 'queued' 를 표시하도록 즉시 기록 $this->sitemapProgress->start(SitemapGenerationMode::Full->value); diff --git a/app/Jobs/GenerateSitemapJob.php b/app/Jobs/GenerateSitemapJob.php index 64b1d938..be491038 100644 --- a/app/Jobs/GenerateSitemapJob.php +++ b/app/Jobs/GenerateSitemapJob.php @@ -3,6 +3,7 @@ namespace App\Jobs; use App\Enums\SitemapGenerationMode; +use App\Extension\HookManager; use App\Seo\SitemapManager; use App\Seo\SitemapProgress; use Illuminate\Bus\Queueable; @@ -26,6 +27,15 @@ class GenerateSitemapJob implements ShouldBeUnique, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; + /** + * @var string 전용 큐 이름 — 대용량·장시간 사이트맵 생성을 default 큐에서 분리한다. + * + * 이 큐에는 반드시 워커를 1개만 배치한다(동시 중복 생성 방지). 그러면 + * 긴 생성 작업이 default 큐의 방송/알림을 막지 않아 진행상황이 실시간으로 흐르고, + * 워커가 1개뿐이라 잡이 동시에 두 번 실행되지 않는다. + */ + public const QUEUE = 'sitemap'; + /** * @var int 최대 재시도 횟수 */ @@ -50,8 +60,15 @@ class GenerateSitemapJob implements ShouldBeUnique, ShouldQueue * GenerateSitemapJob 생성자 * * @param SitemapGenerationMode $mode 재생성 모드 — SitemapManager 로 전달 + * @param int|null $triggeredBy 관리자 수동 재생성을 실행한 사용자 ID(스케줄러/증분/봇 = null). + * 완료/실패 알림의 수신자(수동 실행자) 판정에 사용됩니다. */ - public function __construct(public SitemapGenerationMode $mode = SitemapGenerationMode::Auto) {} + public function __construct( + public SitemapGenerationMode $mode = SitemapGenerationMode::Auto, + public ?int $triggeredBy = null, + ) { + $this->onQueue(self::QUEUE); + } /** * 유니크 락 식별자를 반환합니다. @@ -84,7 +101,7 @@ class GenerateSitemapJob implements ShouldBeUnique, ShouldQueue */ public function handle(SitemapManager $manager): void { - $result = $manager->regenerate($this->mode); + $result = $manager->regenerate($this->mode, $this->triggeredBy); if (($result['status'] ?? null) === 'disabled') { Log::info('[SEO] Sitemap generation skipped (disabled)'); @@ -112,6 +129,15 @@ class GenerateSitemapJob implements ShouldBeUnique, ShouldQueue // 잡 최종 실패(재시도 소진/타임아웃) 시 진행상황을 'failed' 로 고정 — 무한 running 방지 app(SitemapProgress::class)->fail($exception->getMessage()); + // 최종 실패 전용 훅 — 매 시도마다 발화하는 core.seo.sitemap.after_regenerate_failed 와 달리 + // 재시도가 모두 소진된 뒤 딱 한 번만 발화한다. 재시도 중 성공하면 발화하지 않으므로 + // 실패 알림이 중복되거나(각 시도) 결국 성공했는데도 발송되는 일이 없다. + HookManager::doAction('core.seo.sitemap.regenerate_failed_final', [ + 'success' => false, + 'status' => 'failed', + 'message' => $exception->getMessage(), + ], $this->triggeredBy); + Log::error('[SEO] Sitemap generation failed', [ 'error' => $exception->getMessage(), ]); diff --git a/app/Listeners/SeoNotificationDataListener.php b/app/Listeners/SeoNotificationDataListener.php new file mode 100644 index 00000000..ea39ad4b --- /dev/null +++ b/app/Listeners/SeoNotificationDataListener.php @@ -0,0 +1,133 @@ + [ + 'method' => 'extractData', + 'priority' => 20, + 'type' => 'filter', + ], + ]; + } + + /** + * 훅 이벤트를 처리합니다(HookListenerInterface 필수 메서드). + * + * @param mixed ...$args 훅에서 전달된 인수들 + */ + public function handle(...$args): void {} + + /** + * 알림 유형에 따라 데이터와 컨텍스트를 추출합니다. + * + * @param array $default 기본 extract_data 구조 + * @param string $type 알림 정의 유형 + * @param array $args 훅에서 전달된 원본 인수 [$result, $triggeredBy] + * @return array{notifiable: null, notifiables: null, data: array, context: array} + */ + public function extractData(array $default, string $type, array $args): array + { + return match ($type) { + 'sitemap_regenerated' => $this->extractRegenerated($args), + 'sitemap_regenerate_failed' => $this->extractRegenerateFailed($args), + default => $default, + }; + } + + /** + * 사이트맵 재생성 완료 알림 데이터를 추출합니다. + * + * @param array $args 훅 인수 [$result, $triggeredBy] + * @return array + */ + private function extractRegenerated(array $args): array + { + $triggeredBy = $args[1] ?? null; + if (! is_int($triggeredBy)) { + return $this->skip(); + } + + $result = is_array($args[0] ?? null) ? $args[0] : []; + $data = $result['data'] ?? []; + $baseUrl = config('app.url'); + + return [ + 'notifiable' => null, + 'notifiables' => null, + 'data' => [ + 'app_name' => config('app.name'), + 'url_count' => (string) ($data['url_count'] ?? 0), + 'child_count' => (string) ($data['child_count'] ?? 1), + 'action_url' => "{$baseUrl}/admin/settings?tab=seo", + 'site_url' => $baseUrl, + ], + 'context' => [ + 'trigger_user_id' => $triggeredBy, + ], + ]; + } + + /** + * 사이트맵 재생성 실패 알림 데이터를 추출합니다. + * + * @param array $args 훅 인수 [$result, $triggeredBy] + * @return array + */ + private function extractRegenerateFailed(array $args): array + { + $triggeredBy = $args[1] ?? null; + if (! is_int($triggeredBy)) { + return $this->skip(); + } + + $result = is_array($args[0] ?? null) ? $args[0] : []; + $baseUrl = config('app.url'); + + return [ + 'notifiable' => null, + 'notifiables' => null, + 'data' => [ + 'app_name' => config('app.name'), + 'error' => (string) ($result['message'] ?? ''), + 'action_url' => "{$baseUrl}/admin/settings?tab=seo", + 'site_url' => $baseUrl, + ], + 'context' => [ + 'trigger_user_id' => $triggeredBy, + ], + ]; + } + + /** + * 발송을 건너뛰도록 지시하는 결과를 반환합니다(비수동 재생성 = 유발 관리자 없음). + * + * @return array + */ + private function skip(): array + { + return ['notifiable' => null, 'notifiables' => null, 'data' => [], 'context' => ['skip' => true]]; + } +} diff --git a/app/Seo/SitemapManager.php b/app/Seo/SitemapManager.php index 3e19f033..3b85230b 100644 --- a/app/Seo/SitemapManager.php +++ b/app/Seo/SitemapManager.php @@ -48,10 +48,12 @@ class SitemapManager * - auto: 저장소가 비어 있으면 full, 아니면 incremental. * * @param SitemapGenerationMode $mode 재생성 모드 + * @param int|null $triggeredBy 이 재생성을 유발한 관리자 사용자 ID(관리자 수동 재생성만 지정, 스케줄러/증분/봇 = null). + * 완료/실패 훅에 함께 전달되어 알림 발송 대상(수동 실행자) 판정에 쓰입니다. * @return array{success: bool, status: string, message?: string, data?: array} * status: 'updated' | 'disabled' | 'failed' */ - public function regenerate(SitemapGenerationMode $mode = SitemapGenerationMode::Auto): array + public function regenerate(SitemapGenerationMode $mode = SitemapGenerationMode::Auto, ?int $triggeredBy = null): array { $enabled = (bool) g7_core_settings('seo.sitemap_enabled', true); if (! $enabled) { @@ -103,7 +105,7 @@ class SitemapManager $this->progress->complete($meta); - HookManager::doAction('core.seo.sitemap.after_regenerate', $result); + HookManager::doAction('core.seo.sitemap.after_regenerate', $result, $triggeredBy); return $result; } catch (\Throwable $e) { @@ -119,7 +121,7 @@ class SitemapManager 'message' => __('seo.sitemap_generate_failed', ['error' => $e->getMessage()]), ]; - HookManager::doAction('core.seo.sitemap.after_regenerate_failed', $result); + HookManager::doAction('core.seo.sitemap.after_regenerate_failed', $result, $triggeredBy); return $result; } diff --git a/app/Seo/SitemapProgress.php b/app/Seo/SitemapProgress.php index bec3f7a2..e6233e1e 100644 --- a/app/Seo/SitemapProgress.php +++ b/app/Seo/SitemapProgress.php @@ -135,7 +135,8 @@ final class SitemapProgress $state['urls'] = $meta['url_count'] ?? ($state['urls'] ?? 0); $state['message'] = null; - $this->persist($state, broadcast: true); + // 완료 시점엔 방금 커밋된 새 생성 시각을 실어 "마지막 생성"이 실시간 갱신되게 한다. + $this->persist($state, broadcast: true, lastUpdatedAt: $meta['generated_at'] ?? null); } /** @@ -189,22 +190,39 @@ final class SitemapProgress /** * 진행상황을 캐시에 기록하고, 필요 시 관리자 채널로 방송합니다. * - * 방송 payload 는 상태 API 응답의 `data` 형태(`progress` + `realtime_enabled`)와 동형입니다. - * websocket 데이터소스가 `target_source` 로 대상 소스를 통째로 교체하므로, 동형이어야 - * 형제 필드(realtime_enabled)를 잃지 않고 UI 바인딩이 유지됩니다(D27). + * 방송 payload 는 상태 API 응답 봉투와 동형입니다 — 최상위 `data` 봉투 안에 + * `progress`/`last_updated_at`/`realtime_enabled` 를 담습니다(대시보드 방송과 동일 규약). + * websocket 데이터소스가 `target_source` 로 대상 소스의 저장값을 통째 교체하므로, 봉투 + * 형태여야 교체 후에도 템플릿의 `sitemap_status.data.*` 바인딩이 유지됩니다(D27). * * @param array $state 진행상황 상태 * @param bool $broadcast 방송 여부(스로틀 판정 결과) + * @param string|null $lastUpdatedAt 이 방송에 실을 마지막 생성 시각(완료 시 새 시각, 그 외 직전 값) */ - private function persist(array $state, bool $broadcast): void + private function persist(array $state, bool $broadcast, ?string $lastUpdatedAt = null): void { $this->cache->put(self::KEY, $state, self::TTL); - if ($broadcast) { - HookManager::broadcast(self::CHANNEL, self::EVENT, [ - 'realtime_enabled' => true, - 'progress' => $state, - ]); + if (! $broadcast) { + return; } + + // 미지정(진행 중 단계)이면 직전 완료 시각(settings)을 싣는다. target_source 가 대상 소스 + // data 를 통째 교체하므로 매 방송마다 형제 필드를 온전히 실어야 "마지막 생성"이 유지된다. + if ($lastUpdatedAt === null) { + $stored = (string) g7_core_settings('seo.sitemap_last_updated_at', ''); + $lastUpdatedAt = $stored !== '' ? $stored : null; + } + + // 방송 payload 는 상태 API 응답 봉투와 동형이어야 한다(대시보드 방송과 동일 규약). + // 최상위 data 봉투 안에 progress/last_updated_at/realtime_enabled 를 담아야 + // target_source 교체 후에도 템플릿의 `sitemap_status.data.*` 바인딩이 유지된다. + HookManager::broadcast(self::CHANNEL, self::EVENT, [ + 'data' => [ + 'last_updated_at' => $lastUpdatedAt, + 'progress' => $state, + 'realtime_enabled' => true, + ], + ]); } } diff --git a/config/app.php b/config/app.php index 468cfaad..fd969be6 100644 --- a/config/app.php +++ b/config/app.php @@ -231,7 +231,7 @@ return [ | */ - 'version' => env('APP_VERSION', '7.1.0'), + 'version' => env('APP_VERSION', '7.0.6'), /* |-------------------------------------------------------------------------- diff --git a/config/core.php b/config/core.php index 50534ca2..561bff61 100644 --- a/config/core.php +++ b/config/core.php @@ -586,6 +586,100 @@ return [ ], ], ], + + // 사이트맵 재생성 완료 — 관리자 수동 재생성(SEO 탭 "지금 생성")에 한해, 실행한 관리자에게 발송. + // 스케줄러/증분/봇 재생성은 SeoNotificationDataListener 가 context.skip 으로 제외. + // 기본 활성 채널은 database(앱 내 알림)만. mail 템플릿은 존재하되 비활성(운영자가 필요 시 활성화). + 'sitemap_regenerated' => [ + 'hook_prefix' => 'core.seo', + 'name' => ['ko' => '사이트맵 재생성 완료', 'en' => 'Sitemap Regeneration Complete'], + 'description' => ['ko' => '관리자가 수동으로 실행한 사이트맵 재생성이 완료되면 실행한 관리자에게 발송되는 알림', 'en' => 'Notification sent to the admin who manually triggered sitemap regeneration when it completes'], + 'channels' => ['database'], + 'hooks' => ['core.seo.sitemap.after_regenerate'], + 'variables' => [ + ['key' => 'app_name', 'description' => '사이트명'], + ['key' => 'url_count', 'description' => '생성된 총 URL 수'], + ['key' => 'child_count', 'description' => '생성된 사이트맵 파일 수'], + ['key' => 'action_url', 'description' => 'SEO 설정 페이지 URL'], + ['key' => 'site_url', 'description' => '사이트 URL'], + ], + 'templates' => [ + [ + 'channel' => 'mail', + 'is_active' => false, + 'recipients' => [['type' => 'trigger_user']], + 'subject' => [ + 'ko' => '[{app_name}] 사이트맵 재생성이 완료되었습니다', + 'en' => '[{app_name}] Sitemap Regeneration Complete', + ], + 'body' => [ + 'ko' => '

사이트맵 재생성 완료

' + .'

요청하신 사이트맵 재생성이 완료되었습니다.

' + .'

총 {url_count}개의 URL 이 {child_count}개의 파일로 생성되었습니다.

' + .'
SEO 설정 보기
' + .'

감사합니다,
{app_name}

', + 'en' => '

Sitemap Regeneration Complete

' + .'

The sitemap regeneration you requested has completed.

' + .'

A total of {url_count} URLs were generated across {child_count} file(s).

' + .'
View SEO Settings
' + .'

Thank you,
{app_name}

', + ], + ], + [ + 'channel' => 'database', + 'recipients' => [['type' => 'trigger_user']], + 'subject' => ['ko' => '사이트맵 재생성 완료', 'en' => 'Sitemap regeneration complete'], + 'body' => ['ko' => '요청하신 사이트맵 재생성이 완료되었습니다. 총 {url_count}개 URL, {child_count}개 파일이 생성되었습니다.', 'en' => 'Your sitemap regeneration is complete. {url_count} URLs across {child_count} file(s) were generated.'], + 'click_url' => '/admin/settings?tab=seo', + ], + ], + ], + + // 사이트맵 재생성 실패 — 관리자 수동 재생성이 실패하면 실행한 관리자에게 발송. + 'sitemap_regenerate_failed' => [ + 'hook_prefix' => 'core.seo', + 'name' => ['ko' => '사이트맵 재생성 실패', 'en' => 'Sitemap Regeneration Failed'], + 'description' => ['ko' => '관리자가 수동으로 실행한 사이트맵 재생성이 최종 실패(재시도 소진)하면 실행한 관리자에게 발송되는 알림', 'en' => 'Notification sent to the admin who manually triggered sitemap regeneration when it finally fails after retries'], + 'channels' => ['database'], + // 재시도 소진 시 1회만 발화하는 최종 실패 훅에 구독 (매 시도 발화하는 after_regenerate_failed 아님) + 'hooks' => ['core.seo.sitemap.regenerate_failed_final'], + 'variables' => [ + ['key' => 'app_name', 'description' => '사이트명'], + ['key' => 'error', 'description' => '실패 사유 메시지'], + ['key' => 'action_url', 'description' => 'SEO 설정 페이지 URL'], + ['key' => 'site_url', 'description' => '사이트 URL'], + ], + 'templates' => [ + [ + 'channel' => 'mail', + 'is_active' => false, + 'recipients' => [['type' => 'trigger_user']], + 'subject' => [ + 'ko' => '[{app_name}] 사이트맵 재생성이 실패했습니다', + 'en' => '[{app_name}] Sitemap Regeneration Failed', + ], + 'body' => [ + 'ko' => '

사이트맵 재생성 실패

' + .'

요청하신 사이트맵 재생성이 실패했습니다.

' + .'

오류: {error}

' + .'
SEO 설정 보기
' + .'

감사합니다,
{app_name}

', + 'en' => '

Sitemap Regeneration Failed

' + .'

The sitemap regeneration you requested has failed.

' + .'

Error: {error}

' + .'
View SEO Settings
' + .'

Thank you,
{app_name}

', + ], + ], + [ + 'channel' => 'database', + 'recipients' => [['type' => 'trigger_user']], + 'subject' => ['ko' => '사이트맵 재생성 실패', 'en' => 'Sitemap regeneration failed'], + 'body' => ['ko' => '요청하신 사이트맵 재생성이 실패했습니다. 오류: {error}', 'en' => 'Your sitemap regeneration failed. Error: {error}'], + 'click_url' => '/admin/settings?tab=seo', + ], + ], + ], ], /* diff --git a/docs/backend/api/seo.md b/docs/backend/api/seo.md index 5af7356e..70e1eadb 100644 --- a/docs/backend/api/seo.md +++ b/docs/backend/api/seo.md @@ -202,6 +202,8 @@ sitemap.xml 재생성을 큐에 예약합니다. 관리자 수동 재생성은 예약 직후 진행상황이 `queued` 로 기록되며, 이후 진행 단계는 `GET /api/admin/seo/sitemap/status` 로 조회합니다. +이 엔드포인트로 접수된 **관리자 수동 재생성**은 실행한 관리자의 ID 를 잡에 실어, 생성 완료 시 `sitemap_regenerated`, 실패 시 `sitemap_regenerate_failed` 알림이 **그 관리자에게만** 발송됩니다(기본 채널: 앱 내 알림). 스케줄러·리소스 변경(증분)·봇 캐시 미스로 유발된 재생성은 실행 관리자가 없어 알림을 보내지 않습니다. 알림 정의는 `config/core.php` 의 `notification_definitions` 에 있으며 관리자 알림 설정에서 채널을 조정할 수 있습니다. + ### GET /api/admin/seo/sitemap/status - **라우트명**: `api.admin.seo.sitemap.status` diff --git a/docs/backend/seo-system.md b/docs/backend/seo-system.md index 86bfd9ab..fb28f1e4 100644 --- a/docs/backend/seo-system.md +++ b/docs/backend/seo-system.md @@ -689,6 +689,10 @@ sitemap/_tmp/ 생성 중 임시 디렉토리 (커밋 시 정리) 봇 요청 스레드에서 동기 생성하면 대용량(수백만 URL)에서 메모리 초과·타임아웃이 발생하므로 생성은 항상 큐가 담당합니다. 잡은 유니크 락(`seo-sitemap`)을 쓰므로 캐시 미스가 몰려도 동시에 여러 건이 실행되지 않습니다. +**전용 큐(`GenerateSitemapJob::QUEUE = 'sitemap'`)**: 사이트맵 생성 잡은 `sitemap` 전용 큐로 라우팅됩니다. 배포 시 이 큐 워커를 별도로 띄우되 **워커를 1개만** 배치해야 합니다(`php artisan queue:work --queue=sitemap` — supervisor 프로그램을 기본 큐와 분리해 등록). 이유: ①긴 생성 작업이 기본(`default`) 큐의 방송·알림을 막지 않아 진행상황이 실시간으로 흐르고, ②워커가 1개뿐이라 잡이 동시에 두 번 실행되지 않습니다(큐 `retry_after` 가 잡 실행보다 짧아도 단일 워커라 재예약 중복 실행이 발생하지 않음). `sitemap` 큐 워커가 없으면 재생성 잡이 처리되지 않으니 주의합니다. + +**완료/실패 알림**: 관리자 수동 재생성(위 API)은 실행한 관리자 ID 를 잡에 실어, 완료 시 `sitemap_regenerated`, **최종 실패(재시도 소진, `Job::failed()`) 시** `sitemap_regenerate_failed` 알림을 그 관리자에게 발송합니다(기본 채널: 앱 내 알림). 매 시도 실패마다 발화하는 `core.seo.sitemap.after_regenerate_failed` 훅이 아니라 최종 실패 시 1회만 발화하는 `core.seo.sitemap.regenerate_failed_final` 훅에 알림이 연결되어, 재시도 중 실패가 반복되거나 결국 성공해도 실패 알림이 중복/오발송되지 않습니다. 스케줄러·증분·봇 재생성은 실행 관리자가 없어 알림을 보내지 않습니다. + **분할 기준**: `sitemap_urls_per_file`(기본 50000) 또는 파일 크기 임계(`SitemapWriter::MAX_FILE_BYTES`, 45MB) 중 먼저 도달하는 쪽. 둘 다 sitemaps.org 프로토콜 제한(50,000 URL / 50MB)을 지키기 위한 것입니다. **관리자 수동 재생성**: `POST /api/admin/seo/sitemap/regenerate` 는 큐에 `mode=full` 로 예약만 하고 즉시 진행상황(`getStatus()`)을 응답합니다. 완료 여부는 진행상황(아래) 또는 `sitemap_last_updated_at` 갱신으로 확인합니다. diff --git a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md index 19ce0b01..0c15426f 100644 --- a/lang-packs/_bundled/g7-core-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-core-ja/CHANGELOG.md @@ -12,6 +12,7 @@ - 사이트맵 재생성 예약·준비 중·생성 실패 안내 문구 일본어 번역 추가 (`seo.sitemap_*`) — 재생성을 예약했을 때, 아직 준비 중일 때, 생성에 실패했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다. - SEO 설정의 사이트맵 분할 기준·압축·수집기 상한과 SEO/사이트맵 캐시 유지 시간 항목의 검증 안내 문구 일본어 번역 추가 (`validation.settings.*`) — 잘못된 값을 입력했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다. - SEO 설정의 사이트맵 다국어 대체 링크(hreflang) 항목의 검증 안내 문구 일본어 번역 추가 (`validation.settings.sitemap_hreflang_enabled_boolean`) — 잘못된 값을 입력했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다. +- 관리자 사이트맵 수동 재생성 완료·실패 알림 템플릿 일본어 번역 추가 (`sitemap_regenerated`, `sitemap_regenerate_failed`) — 재생성을 실행한 관리자가 받는 앱 내 알림·메일이 일본어 로케일에서 자연스럽게 표시됩니다. ## [1.0.4] - 2026-07-16 diff --git a/lang-packs/_bundled/g7-core-ja/seed/notifications.json b/lang-packs/_bundled/g7-core-ja/seed/notifications.json index b7e1699b..f9ed683c 100644 --- a/lang-packs/_bundled/g7-core-ja/seed/notifications.json +++ b/lang-packs/_bundled/g7-core-ja/seed/notifications.json @@ -46,5 +46,37 @@ "body": "{name}様、パスワードが変更されました。ご自身で変更されていない場合は、すぐにカスタマーサポートにお問い合わせください。" } } + }, + "sitemap_regenerated": { + "definition": { + "name": "サイトマップ再生成完了", + "description": "管理者が手動で実行したサイトマップ再生成が完了した際に、実行した管理者へ送信される通知" + }, + "templates": { + "mail": { + "subject": "[{app_name}] サイトマップの再生成が完了しました", + "body": "

サイトマップ再生成完了

ご依頼いただいたサイトマップの再生成が完了しました。

合計{url_count}件のURLが{child_count}個のファイルに生成されました。

SEO設定を表示

ありがとうございます。
{app_name}

" + }, + "database": { + "subject": "サイトマップ再生成完了", + "body": "ご依頼いただいたサイトマップの再生成が完了しました。合計{url_count}件のURL、{child_count}個のファイルが生成されました。" + } + } + }, + "sitemap_regenerate_failed": { + "definition": { + "name": "サイトマップ再生成失敗", + "description": "管理者が手動で実行したサイトマップ再生成が失敗した際に、実行した管理者へ送信される通知" + }, + "templates": { + "mail": { + "subject": "[{app_name}] サイトマップの再生成に失敗しました", + "body": "

サイトマップ再生成失敗

ご依頼いただいたサイトマップの再生成に失敗しました。

エラー: {error}

SEO設定を表示

ありがとうございます。
{app_name}

" + }, + "database": { + "subject": "サイトマップ再生成失敗", + "body": "ご依頼いただいたサイトマップの再生成に失敗しました。エラー: {error}" + } + } } } diff --git a/modules/_bundled/sirsoft-board/CHANGELOG.md b/modules/_bundled/sirsoft-board/CHANGELOG.md index 43cb5bb9..4270bdb7 100644 --- a/modules/_bundled/sirsoft-board/CHANGELOG.md +++ b/modules/_bundled/sirsoft-board/CHANGELOG.md @@ -14,7 +14,7 @@ ### Changed - 사이트맵을 만들 때 게시판과 게시글을 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 게시글이 많은 사이트에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다. -- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.1.0 이상이 필요합니다. +- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.0.6 이상이 필요합니다. ## [1.0.2] - 2026-07-14 diff --git a/modules/_bundled/sirsoft-board/module.json b/modules/_bundled/sirsoft-board/module.json index e21da48b..14ab4275 100644 --- a/modules/_bundled/sirsoft-board/module.json +++ b/modules/_bundled/sirsoft-board/module.json @@ -11,7 +11,7 @@ "ko": "게시판 관리를 위한 모듈", "en": "Module for board management" }, - "g7_version": ">=7.1.0", + "g7_version": ">=7.0.6", "dependencies": { "modules": {}, "plugins": {} diff --git a/modules/_bundled/sirsoft-board/tests/Feature/Seo/PostSeoOgImageRenderingTest.php b/modules/_bundled/sirsoft-board/tests/Feature/Seo/PostSeoOgImageRenderingTest.php index f2412274..5ae3138b 100644 --- a/modules/_bundled/sirsoft-board/tests/Feature/Seo/PostSeoOgImageRenderingTest.php +++ b/modules/_bundled/sirsoft-board/tests/Feature/Seo/PostSeoOgImageRenderingTest.php @@ -29,17 +29,27 @@ class PostSeoOgImageRenderingTest extends ModuleTestCase // 의존성 모듈 (sirsoft-ecommerce / sirsoft-page) 을 active 상태로 등록 — 템플릿 의존 만족용 // (전체 install 은 비용 크므로 Module DB 행만 직접 셋업) + // 버전은 번들 매니페스트 실버전으로 등록한다. sirsoft-basic 템플릿은 의존성 제약 + // (예: sirsoft-ecommerce >=1.0.2) 을 두므로, 하드코딩 버전은 확장 버전 bump 시 stale 되어 + // installTemplate() 이 "버전이 맞지 않습니다" 로 실패 → 활성 user 템플릿 부재 → + // SEO 라우트 미해석 → og:image 미출력으로 이어진다. updateOrCreate 로 기존 행의 + // stale 버전까지 매니페스트 값으로 교정한다(트랜잭션 롤백으로 격리). foreach ([ ['sirsoft-ecommerce', 'sirsoft', '이커머스', 'Ecommerce'], ['sirsoft-page', 'sirsoft', '페이지', 'Page'], ] as [$id, $vendor, $ko, $en]) { - \App\Models\Module::firstOrCreate( + $manifestVersion = json_decode( + (string) @file_get_contents(base_path("modules/_bundled/{$id}/module.json")), + true + )['version'] ?? '1.0.0'; + + \App\Models\Module::updateOrCreate( ['identifier' => $id], [ 'vendor' => $vendor, 'name' => ['ko' => $ko, 'en' => $en], 'status' => \App\Enums\ExtensionStatus::Active->value, - 'version' => '1.0.0', + 'version' => $manifestVersion, 'config' => [], ] ); diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index 84353f0e..d0d8f871 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -14,7 +14,7 @@ ### Changed - 사이트맵을 만들 때 상품과 카테고리를 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 상품이 많은 쇼핑몰에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다. -- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.1.0 이상이 필요합니다. +- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.0.6 이상이 필요합니다. ## [1.0.4] - 2026-07-16 diff --git a/modules/_bundled/sirsoft-ecommerce/module.json b/modules/_bundled/sirsoft-ecommerce/module.json index 4afa939d..0ac4ee0a 100644 --- a/modules/_bundled/sirsoft-ecommerce/module.json +++ b/modules/_bundled/sirsoft-ecommerce/module.json @@ -11,7 +11,7 @@ "ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리", "en": "Gnuboard7 Ecommerce Module - Product, Order, Payment Management" }, - "g7_version": ">=7.1.0", + "g7_version": ">=7.0.6", "dependencies": { "modules": {}, "plugins": {} diff --git a/modules/_bundled/sirsoft-page/CHANGELOG.md b/modules/_bundled/sirsoft-page/CHANGELOG.md index b624481d..162271ab 100644 --- a/modules/_bundled/sirsoft-page/CHANGELOG.md +++ b/modules/_bundled/sirsoft-page/CHANGELOG.md @@ -14,7 +14,7 @@ ### Changed - 사이트맵을 만들 때 페이지를 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 페이지가 많은 사이트에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다. -- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.1.0 이상이 필요합니다. +- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.0.6 이상이 필요합니다. ## [1.0.1] - 2026-07-08 diff --git a/modules/_bundled/sirsoft-page/module.json b/modules/_bundled/sirsoft-page/module.json index cf7b196e..f3e4bc5d 100644 --- a/modules/_bundled/sirsoft-page/module.json +++ b/modules/_bundled/sirsoft-page/module.json @@ -11,7 +11,7 @@ "ko": "정적 페이지(정보/정책/안내) 관리 모듈", "en": "Static page (info/policy/guide) management module" }, - "g7_version": ">=7.1.0", + "g7_version": ">=7.0.6", "dependencies": { "modules": {}, "plugins": {} diff --git a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-progress.test.tsx b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-progress.test.tsx index a946e165..186df16b 100644 --- a/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-progress.test.tsx +++ b/templates/_bundled/sirsoft-admin_basic/__tests__/layouts/admin-settings-sitemap-progress.test.tsx @@ -59,6 +59,13 @@ describe('admin_settings — sitemap 진행상황 데이터소스', () => { expect(start!.if).toContain('queued'); expect(start!.if).toContain('running'); expect(start!.if).toContain('writing'); + // onSuccess 조건은 방금 받은 응답(response.data)을 봐야 한다. 명명 소스(sitemap_status)는 + // onSuccess 실행 시점엔 아직 이전 렌더의 값(stale)이라, 재생성 직후(queued)에도 폴링이 + // 시작되지 않는 회귀가 발생한다(Reverb OFF 폴백 붕괴). 신선한 response 로 게이트해야 한다. + // onSuccess 의 response.data 는 응답 봉투 전체({success,message,data}) 이므로 progress 는 + // response.data.data.progress (double .data) 다. 한 단계 부족하면 undefined → 폴링 미시작. + expect(start!.if).toContain('response?.data?.data?.progress?.status'); + expect(start!.if).not.toContain('sitemap_status?.data?.progress?.status'); // 폴링 액션은 sitemap_status 재조회 const refetch = (start!.params.actions as AnyJson[])[0]; expect(refetch.handler).toBe('refetchDataSource'); @@ -73,6 +80,9 @@ describe('admin_settings — sitemap 진행상황 데이터소스', () => { expect(stop).toBeTruthy(); expect(stop!.if).toContain('completed'); expect(stop!.if).toContain('failed'); + // startInterval 과 동일하게 신선한 response(double .data) 로 게이트 (stale 명명 소스 금지) + expect(stop!.if).toContain('response?.data?.data?.progress?.status'); + expect(stop!.if).not.toContain('sitemap_status?.data?.progress?.status'); expect(stop!.params.id).toBe('sitemap_progress_poll'); }); diff --git a/templates/_bundled/sirsoft-admin_basic/layouts/admin_settings.json b/templates/_bundled/sirsoft-admin_basic/layouts/admin_settings.json index c7564690..98041494 100644 --- a/templates/_bundled/sirsoft-admin_basic/layouts/admin_settings.json +++ b/templates/_bundled/sirsoft-admin_basic/layouts/admin_settings.json @@ -285,7 +285,7 @@ "onSuccess": [ { "handler": "startInterval", - "if": "{{sitemap_status?.data?.realtime_enabled !== true && ['queued','running','writing'].includes(sitemap_status?.data?.progress?.status ?? '')}}", + "if": "{{response?.data?.data?.realtime_enabled !== true && ['queued','running','writing'].includes(response?.data?.data?.progress?.status ?? '')}}", "params": { "id": "sitemap_progress_poll", "intervalMs": 3000, @@ -301,7 +301,7 @@ }, { "handler": "stopInterval", - "if": "{{['completed','failed'].includes(sitemap_status?.data?.progress?.status ?? '')}}", + "if": "{{['completed','failed'].includes(response?.data?.data?.progress?.status ?? '')}}", "params": { "id": "sitemap_progress_poll" } diff --git a/tests/Feature/Seo/JaLocaleSeoIntegrationTest.php b/tests/Feature/Seo/JaLocaleSeoIntegrationTest.php index 6192db34..60e94c57 100644 --- a/tests/Feature/Seo/JaLocaleSeoIntegrationTest.php +++ b/tests/Feature/Seo/JaLocaleSeoIntegrationTest.php @@ -45,6 +45,12 @@ class JaLocaleSeoIntegrationTest extends TestCase 'ja' => '日本語', ]); Config::set('app.translatable_locales', ['ko', 'en', 'ja']); + + // hreflang alternate 방출을 기본값(true)으로 고정한다. fromConfig() 가 읽는 + // g7_core_settings('seo.sitemap_hreflang_enabled') 는 부팅 시 영속 설정에서 적재되어 + // settings 디스크 fake 의 영향을 받지 않으므로, 다국어 hreflang 검증이 실행 머신의 + // 설정 상태에 좌우되지 않도록 명시 고정한다. + Config::set('g7_settings.core.seo.sitemap_hreflang_enabled', true); } public function test_sitemap_xml_includes_ja_locale_urls(): void diff --git a/tests/Feature/Seo/SitemapRegenerateNotificationTest.php b/tests/Feature/Seo/SitemapRegenerateNotificationTest.php new file mode 100644 index 00000000..5a7839ba --- /dev/null +++ b/tests/Feature/Seo/SitemapRegenerateNotificationTest.php @@ -0,0 +1,136 @@ +artisan('db:seed', ['--class' => 'NotificationDefinitionSeeder']); + app(NotificationHookListener::class)->registerDynamicHooks(); + HookListenerRegistrar::register(SeoNotificationDataListener::class, 'core'); + } + + private function completionResult(): array + { + return [ + 'success' => true, + 'status' => 'updated', + 'data' => ['url_count' => 720, 'child_count' => 1], + ]; + } + + public function test_manual_completion_notifies_only_triggering_admin(): void + { + $admin = User::factory()->create(); + $other = User::factory()->create(); + Notification::fake(); + + HookManager::doAction('core.seo.sitemap.after_regenerate', $this->completionResult(), $admin->id); + + Notification::assertSentTo($admin, GenericNotification::class); + Notification::assertNotSentTo($other, GenericNotification::class); + } + + public function test_non_manual_completion_notifies_nobody(): void + { + User::factory()->create(); + Notification::fake(); + + // triggered_by null = 스케줄러/증분/봇 → skip + HookManager::doAction('core.seo.sitemap.after_regenerate', $this->completionResult(), null); + + Notification::assertNothingSent(); + } + + public function test_manual_final_failure_notifies_triggering_admin(): void + { + $admin = User::factory()->create(); + Notification::fake(); + + // 최종 실패(재시도 소진) 훅 = Job::failed() 가 발화 + HookManager::doAction('core.seo.sitemap.regenerate_failed_final', [ + 'success' => false, + 'status' => 'failed', + 'message' => '디스크 용량 부족', + ], $admin->id); + + Notification::assertSentTo($admin, GenericNotification::class); + } + + public function test_per_attempt_failure_hook_does_not_notify(): void + { + $admin = User::factory()->create(); + Notification::fake(); + + // 매 시도마다 발화하는 per-attempt 훅은 알림을 유발하지 않는다(최종 실패 훅만 구독). + // → 재시도 중 실패가 반복돼도, 결국 성공해도 실패 알림이 중복/오발송되지 않음. + HookManager::doAction('core.seo.sitemap.after_regenerate_failed', [ + 'success' => false, + 'status' => 'failed', + 'message' => '일시적 오류', + ], $admin->id); + + Notification::assertNothingSent(); + } + + public function test_job_failed_notifies_triggering_admin_via_final_hook(): void + { + $admin = User::factory()->create(); + Notification::fake(); + + // Job::failed() (재시도 소진) 가 최종 실패 훅을 발화 → 유발 관리자에게 알림 + (new \App\Jobs\GenerateSitemapJob(\App\Enums\SitemapGenerationMode::Full, $admin->id)) + ->failed(new \RuntimeException('boom')); + + Notification::assertSentTo($admin, GenericNotification::class); + } + + public function test_job_failed_without_trigger_user_notifies_nobody(): void + { + User::factory()->create(); + Notification::fake(); + + // 스케줄러/증분 잡(triggeredBy null)의 최종 실패는 알림 없음 + (new \App\Jobs\GenerateSitemapJob(\App\Enums\SitemapGenerationMode::Auto, null)) + ->failed(new \RuntimeException('boom')); + + Notification::assertNothingSent(); + } + + public function test_default_active_channel_is_database_only(): void + { + $definition = \App\Models\NotificationDefinition::where('type', 'sitemap_regenerated')->first(); + + $this->assertNotNull($definition); + $this->assertSame(['database'], $definition->channels); + + // mail 템플릿은 존재하되 비활성, database 템플릿은 활성 + $mail = $definition->templates()->where('channel', 'mail')->first(); + $database = $definition->templates()->where('channel', 'database')->first(); + $this->assertNotNull($mail, 'mail 템플릿 행은 존재해야 한다(운영자가 필요 시 활성화)'); + $this->assertFalse((bool) $mail->is_active, 'mail 템플릿은 기본 비활성'); + $this->assertTrue((bool) $database->is_active, 'database 템플릿은 기본 활성'); + } +} diff --git a/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts b/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts index f61a297a..ab5f0da3 100644 --- a/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts +++ b/tests/Playwright/specs/admin/settings-sitemap-generation-fields.spec.ts @@ -254,3 +254,44 @@ test('#481 - SEO 탭 진입 시 sitemap 상태 API 가 진행상황/실시간 expect(body?.data).toHaveProperty('progress'); expect(body?.data).toHaveProperty('last_updated_at'); }); + +// @scenario tab=seo, permitted=yes +// @effects regenerate_is_async +test('#481 - 재생성 후 실시간/폴링 모드에 맞게 진행상황이 갱신된다', async ({ page }) => { + const token = issueToken('core.settings.read', 'core.settings.update'); + await authenticatePage(page, token); + + // 상태(GET) 응답을 가로채 실시간 여부 판정 + 재조회 횟수 카운트 (앱의 인증 요청 기준) + let realtimeEnabled: boolean | null = null; + let statusGets = 0; + page.on('response', async (r) => { + if (r.url().includes('/api/admin/seo/sitemap/status') && r.request().method() === 'GET') { + statusGets += 1; + if (realtimeEnabled === null) { + try { realtimeEnabled = (await r.json())?.data?.realtime_enabled === true; } catch { /* ignore */ } + } + } + }); + + await gotoSeoTab(page); + await expect.poll(() => realtimeEnabled, { timeout: 20_000 }).not.toBeNull(); + + // 여기부터 재생성 이후의 폴링만 센다 + statusGets = 0; + await page.locator('#sitemap_last_updated_block button').first().click(); + await page.waitForResponse((r) => r.url().includes('/api/admin/seo/sitemap/regenerate'), { timeout: 20_000 }); + + const pollingMode = realtimeEnabled === false; + + // 8초 관찰 + await page.waitForTimeout(8_000); + + if (pollingMode) { + // Reverb OFF: startInterval 폴링이 상태를 반복 재조회해야 한다. + // 회귀(폴링 미시작) 시 재생성 직후 refetch 1회로 멈춰 배지가 갱신되지 않는다. + expect(statusGets).toBeGreaterThanOrEqual(2); + } else { + // 실시간(WebSocket) 모드면 폴링하지 않는다 — 재생성 직후 refetch 수준 + expect(statusGets).toBeLessThanOrEqual(2); + } +}); diff --git a/tests/Unit/Jobs/GenerateSitemapJobTest.php b/tests/Unit/Jobs/GenerateSitemapJobTest.php index 9a39804d..49350d39 100644 --- a/tests/Unit/Jobs/GenerateSitemapJobTest.php +++ b/tests/Unit/Jobs/GenerateSitemapJobTest.php @@ -47,6 +47,20 @@ class GenerateSitemapJobTest extends TestCase $this->assertSame(120, $job->backoff); } + /** + * 전용 'sitemap' 큐로 라우팅되는지 확인합니다. + * + * default 큐와 분리해, 긴 생성 작업이 방송/알림을 막지 않고(실시간 진행상황), + * 이 큐 워커 1개 제약으로 동시 중복 생성을 차단합니다. + */ + public function test_job_routes_to_dedicated_sitemap_queue(): void + { + $job = new GenerateSitemapJob; + + $this->assertSame('sitemap', GenerateSitemapJob::QUEUE); + $this->assertSame('sitemap', $job->queue); + } + /** * ShouldBeUnique 를 구현하여 동시 실행을 막는지 확인합니다. * diff --git a/tests/Unit/Listeners/SeoNotificationDataListenerTest.php b/tests/Unit/Listeners/SeoNotificationDataListenerTest.php new file mode 100644 index 00000000..26e5a8a6 --- /dev/null +++ b/tests/Unit/Listeners/SeoNotificationDataListenerTest.php @@ -0,0 +1,84 @@ +listener = new SeoNotificationDataListener; + } + + private function default(): array + { + return ['notifiable' => null, 'notifiables' => null, 'data' => [], 'context' => []]; + } + + public function test_manual_completion_extracts_data_and_trigger_user(): void + { + $result = ['status' => 'updated', 'success' => true, 'data' => ['url_count' => 720, 'child_count' => 3]]; + + $out = $this->listener->extractData($this->default(), 'sitemap_regenerated', [$result, 42]); + + $this->assertSame(42, $out['context']['trigger_user_id']); + $this->assertArrayNotHasKey('skip', $out['context']); + $this->assertSame('720', $out['data']['url_count']); + $this->assertSame('3', $out['data']['child_count']); + $this->assertArrayHasKey('action_url', $out['data']); + } + + public function test_non_manual_completion_is_skipped(): void + { + $result = ['status' => 'updated', 'success' => true, 'data' => ['url_count' => 720]]; + + // triggered_by 없음(스케줄러/증분/봇) → skip + $out = $this->listener->extractData($this->default(), 'sitemap_regenerated', [$result, null]); + + $this->assertTrue($out['context']['skip']); + $this->assertSame([], $out['data']); + } + + public function test_manual_failure_extracts_error_and_trigger_user(): void + { + $result = ['status' => 'failed', 'success' => false, 'message' => 'disk full']; + + $out = $this->listener->extractData($this->default(), 'sitemap_regenerate_failed', [$result, 7]); + + $this->assertSame(7, $out['context']['trigger_user_id']); + $this->assertSame('disk full', $out['data']['error']); + } + + public function test_non_manual_failure_is_skipped(): void + { + $out = $this->listener->extractData($this->default(), 'sitemap_regenerate_failed', [['message' => 'x'], null]); + + $this->assertTrue($out['context']['skip']); + } + + public function test_unknown_type_returns_default_untouched(): void + { + $out = $this->listener->extractData($this->default(), 'some_other_type', [[], 1]); + + $this->assertSame($this->default(), $out); + } + + public function test_subscribes_to_seo_extract_data_filter(): void + { + $hooks = SeoNotificationDataListener::getSubscribedHooks(); + + $this->assertArrayHasKey('core.seo.notification.extract_data', $hooks); + $this->assertSame('filter', $hooks['core.seo.notification.extract_data']['type']); + } +} diff --git a/tests/Unit/Seo/SitemapGeneratorTest.php b/tests/Unit/Seo/SitemapGeneratorTest.php index a4dd538e..b95c6c4c 100644 --- a/tests/Unit/Seo/SitemapGeneratorTest.php +++ b/tests/Unit/Seo/SitemapGeneratorTest.php @@ -42,6 +42,12 @@ class SitemapGeneratorTest extends TestCase Config::set('app.locale', 'ko'); Config::set('app.supported_locales', ['ko']); + // hreflang alternate 방출을 기본값(true)으로 고정한다. SitemapXmlRenderer::fromConfig() + // 는 g7_core_settings('seo.sitemap_hreflang_enabled') 를 읽는데, 이 값은 부팅 시 + // storage/app/settings/seo.json 으로 적재되며 settings 디스크 fake 의 영향을 받지 않는다. + // 실행 머신의 영속 설정에 다국어 hreflang 검증 결과가 좌우되지 않도록 명시 고정한다. + Config::set('g7_settings.core.seo.sitemap_hreflang_enabled', true); + $this->routeResolver = Mockery::mock(TemplateRouteResolver::class); $this->templateService = Mockery::mock(TemplateService::class); diff --git a/tests/Unit/Seo/SitemapProgressTest.php b/tests/Unit/Seo/SitemapProgressTest.php index cdfa73dd..4f510921 100644 --- a/tests/Unit/Seo/SitemapProgressTest.php +++ b/tests/Unit/Seo/SitemapProgressTest.php @@ -121,10 +121,33 @@ class SitemapProgressTest extends TestCase $this->progress->start('full'); Event::assertDispatched(GenericBroadcastEvent::class, function ($event) { + // payload 는 상태 API 봉투와 동형: 최상위 data 안에 progress/realtime_enabled + // (target_source 가 대상 소스를 통째 교체해도 `sitemap_status.data.*` 바인딩 유지) return $event->channel === 'core.admin.seo.sitemap' && $event->eventName === 'sitemap.progress.updated' - && ($event->payload['realtime_enabled'] ?? null) === true - && ($event->payload['progress']['status'] ?? null) === 'queued'; + && ($event->payload['data']['realtime_enabled'] ?? null) === true + && ($event->payload['data']['progress']['status'] ?? null) === 'queued' + && array_key_exists('last_updated_at', $event->payload['data']); + }); + } + + /** + * 완료 방송은 방금 커밋된 생성 시각을 data.last_updated_at 으로 실어 "마지막 생성"을 실시간 갱신한다. + */ + public function test_complete_broadcast_carries_new_last_updated_at(): void + { + $this->enableReverb(); + Event::fake([GenericBroadcastEvent::class]); + + $this->progress->complete([ + 'url_count' => 720, + 'child_count' => 1, + 'generated_at' => '2026-07-19T00:00:00+00:00', + ]); + + Event::assertDispatched(GenericBroadcastEvent::class, function ($event) { + return ($event->payload['data']['last_updated_at'] ?? null) === '2026-07-19T00:00:00+00:00' + && ($event->payload['data']['progress']['status'] ?? null) === 'completed'; }); } diff --git a/tests/Unit/Upgrades/NullifyUntouchedSeoCacheOverridesTest.php b/tests/Unit/Upgrades/NullifyUntouchedSeoCacheOverridesTest.php index bd7afa3e..c9afaa59 100644 --- a/tests/Unit/Upgrades/NullifyUntouchedSeoCacheOverridesTest.php +++ b/tests/Unit/Upgrades/NullifyUntouchedSeoCacheOverridesTest.php @@ -2,15 +2,15 @@ namespace Tests\Unit\Upgrades; -require_once __DIR__.'/../../../upgrades/data/7.1.0/migrations/01_NullifyUntouchedSeoCacheOverrides.php'; +require_once __DIR__.'/../../../upgrades/data/7.0.6/migrations/01_NullifyUntouchedSeoCacheOverrides.php'; use App\Extension\UpgradeContext; -use App\Upgrades\Data\V7_1_0\Migrations\NullifyUntouchedSeoCacheOverrides; +use App\Upgrades\Data\V7_0_6\Migrations\NullifyUntouchedSeoCacheOverrides; use Illuminate\Support\Facades\Storage; use Tests\TestCase; /** - * 7.1.0 업그레이드 스텝 — SEO 캐시 오버라이드 이행 (결정 D19) + * 7.0.6 업그레이드 스텝 — SEO 캐시 오버라이드 이행 (결정 D19) * * 판정 규칙: * 옛 기본값과 같으면 "안 건드린 것" → null(미설정)로 비움 @@ -40,7 +40,7 @@ class NullifyUntouchedSeoCacheOverridesTest extends TestCase */ private function context(): UpgradeContext { - return new UpgradeContext('7.0.5', '7.1.0', '7.1.0'); + return new UpgradeContext('7.0.5', '7.0.6', '7.0.6'); } /** diff --git a/upgrades/Upgrade_7_1_0.php b/upgrades/Upgrade_7_0_6.php similarity index 71% rename from upgrades/Upgrade_7_1_0.php rename to upgrades/Upgrade_7_0_6.php index 714b6c91..e5e9076f 100644 --- a/upgrades/Upgrade_7_1_0.php +++ b/upgrades/Upgrade_7_0_6.php @@ -5,9 +5,9 @@ namespace App\Upgrades; use App\Extension\AbstractUpgradeStep; /** - * 코어 7.1.0 업그레이드 스텝 + * 코어 7.0.6 업그레이드 스텝 * - * 모든 비즈니스 로직은 본 클래스 파일이 아닌 `upgrades/data/7.1.0/` 안에 격리된다: + * 모든 비즈니스 로직은 본 클래스 파일이 아닌 `upgrades/data/7.0.6/` 안에 격리된다: * * - migrations/ * 01_NullifyUntouchedSeoCacheOverrides.php @@ -16,15 +16,15 @@ use App\Extension\AbstractUpgradeStep; * * 본 클래스는 `AbstractUpgradeStep` 의 default `run()` 에 위임 — 별도 override 없음. * - * @upgrade-path 7.0.x → 7.1.0 + * @upgrade-path 7.0.x → 7.0.6 * - * 의존성 제약: 본 스텝은 변환/핫픽스를 `data/7.1.0/migrations/` 의 버전 namespace + * 의존성 제약: 본 스텝은 변환/핫픽스를 `data/7.0.6/migrations/` 의 버전 namespace * 클래스에 위임한다. 미래 버전에서 *그 디렉토리는 동결* (수정 금지) 되어 "각 스텝별 동작 * 100% 동일 보장" invariant 가 성립. * * 상세: docs/extension/upgrade-step-guide.md §13 "버전별 데이터 스냅샷" */ -class Upgrade_7_1_0 extends AbstractUpgradeStep +class Upgrade_7_0_6 extends AbstractUpgradeStep { - // 모든 로직 위임 — data/7.1.0/ 가 SSoT. + // 모든 로직 위임 — data/7.0.6/ 가 SSoT. } diff --git a/upgrades/data/7.1.0/migrations/01_NullifyUntouchedSeoCacheOverrides.php b/upgrades/data/7.0.6/migrations/01_NullifyUntouchedSeoCacheOverrides.php similarity index 92% rename from upgrades/data/7.1.0/migrations/01_NullifyUntouchedSeoCacheOverrides.php rename to upgrades/data/7.0.6/migrations/01_NullifyUntouchedSeoCacheOverrides.php index e2396118..a3517261 100644 --- a/upgrades/data/7.1.0/migrations/01_NullifyUntouchedSeoCacheOverrides.php +++ b/upgrades/data/7.0.6/migrations/01_NullifyUntouchedSeoCacheOverrides.php @@ -1,6 +1,6 @@ logger->warning(sprintf( - '[7.1.0] NullifyUntouchedSeoCacheOverrides 실패 (seo.json 보존, 계속 진행): %s', + '[7.0.6] NullifyUntouchedSeoCacheOverrides 실패 (seo.json 보존, 계속 진행): %s', $e->getMessage(), )); - Log::warning('7.1.0 NullifyUntouchedSeoCacheOverrides 실패', [ + Log::warning('7.0.6 NullifyUntouchedSeoCacheOverrides 실패', [ 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString(), ]); @@ -129,20 +129,20 @@ final class NullifyUntouchedSeoCacheOverrides implements DataMigration foreach ($kept as $key => $value) { $context->logger->info(sprintf( - '[7.1.0] seo.%s = %s 는 옛 기본값과 달라 오버라이드로 보존 — 이제부터 실제 적용됩니다', + '[7.0.6] seo.%s = %s 는 옛 기본값과 달라 오버라이드로 보존 — 이제부터 실제 적용됩니다', $key, var_export($value, true), )); } if ($cleared === []) { - $context->logger->info('[7.1.0] SEO 캐시 설정 — 비울 대상 없음 (이미 이행 완료이거나 전부 오버라이드)'); + $context->logger->info('[7.0.6] SEO 캐시 설정 — 비울 대상 없음 (이미 이행 완료이거나 전부 오버라이드)'); return; } $this->writeSeoSettings($settings); - $context->logger->info('[7.1.0] SEO 캐시 설정 — 미설정(null)으로 비운 키: '.implode(', ', $cleared)); + $context->logger->info('[7.0.6] SEO 캐시 설정 — 미설정(null)으로 비운 키: '.implode(', ', $cleared)); } /** @@ -154,7 +154,7 @@ final class NullifyUntouchedSeoCacheOverrides implements DataMigration private function readSeoSettings(UpgradeContext $context): ?array { if (! Storage::disk('settings')->exists(self::SEO_SETTINGS_FILE)) { - $context->logger->info('[7.1.0] seo.json 부재 — 정리 대상 없음, skip'); + $context->logger->info('[7.0.6] seo.json 부재 — 정리 대상 없음, skip'); return null; } @@ -163,7 +163,7 @@ final class NullifyUntouchedSeoCacheOverrides implements DataMigration if (! is_array($decoded)) { // 손상 파일을 덮어쓰지 않는다 (파괴 금지) - $context->logger->warning('[7.1.0] seo.json 파싱 실패 — 파일 보존하고 skip'); + $context->logger->warning('[7.0.6] seo.json 파싱 실패 — 파일 보존하고 skip'); return null; } From 8fda5d31e1dfdeec092457a5cef89a50da6758b7 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Sun, 19 Jul 2026 20:13:05 +0900 Subject: [PATCH 08/11] =?UTF-8?q?feat(seo):=20changefreq=20Enum=20?= =?UTF-8?q?=EB=8F=84=EC=9E=85=20+=20=EC=82=AC=EC=9D=B4=ED=8A=B8=EB=A7=B5?= =?UTF-8?q?=20=EC=82=AC=EC=A0=84=EA=B2=B0=ED=95=A8=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 계획서 위임 항목(Q6·Q7·Q8) 마감. Q8: sitemaps.org 폐쇄 어휘를 App\Enums\SitemapChangeFreq 로 도입해 저장(SitemapIndexer)·렌더(SitemapXmlRenderer) 두 경계에서 normalize 하고, 생산 지점(생성기 정적 URL·3 contributor·4 리스너) 리터럴을 enum case 로 치환. 규격에 없는 changefreq 가 사이트맵에 그대로 출력되던 것을 차단(대소문자·공백 흡수). Q6: Upgrade_7_0_0_beta_3Test 의 "동결 TARGETS === 살아있는 config" 단언은 동결 규정과 충돌하는 무효 불변식(config 는 에서 정당하게 진화)이므로 의도적 divergence 를 검증하도록 재작성. Q7: 코어 20건 사전 실패를 격리 실측으로 재분류 — 6클러스터는 전 스위트 OOM/싱글톤 누수 오염(격리 시 통과), GenericNotificationViaTest 는 readiness 게이트를 mock 하지 않아 빈 settings 로 mail 이 항상 제외되던 stale test 를 정정. --- CHANGELOG.md | 1 + app/Enums/SitemapChangeFreq.php | 70 +++++++++++++++++++ app/Seo/SitemapGenerator.php | 3 +- app/Seo/SitemapIndexer.php | 4 +- app/Seo/SitemapXmlRenderer.php | 7 +- docs/backend/seo-system.md | 4 ++ .../src/Listeners/SeoBoardCacheListener.php | 5 +- .../src/Seo/BoardSitemapContributor.php | 7 +- .../Listeners/SeoCategoryCacheListener.php | 3 +- .../src/Listeners/SeoProductCacheListener.php | 3 +- .../src/Seo/EcommerceSitemapContributor.php | 7 +- .../src/Listeners/SeoPageCacheListener.php | 3 +- .../src/Seo/PageSitemapContributor.php | 3 +- tests/Unit/Enums/SitemapChangeFreqTest.php | 57 +++++++++++++++ .../GenericNotificationViaTest.php | 51 ++++++++++++-- tests/Unit/Seo/SitemapXmlRendererTest.php | 33 ++++++++- .../Upgrades/Upgrade_7_0_0_beta_3Test.php | 24 +++++-- 17 files changed, 255 insertions(+), 30 deletions(-) create mode 100644 app/Enums/SitemapChangeFreq.php create mode 100644 tests/Unit/Enums/SitemapChangeFreqTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index da67a6a4..eeede335 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ - 검색엔진 봇이 사이트맵을 요청하면 미리 만들어 둔 파일을 그대로 내보내고, 요청을 받은 그 자리에서 새로 만들지 않도록 바꿨습니다. 아직 만들어진 사이트맵이 없으면 잠시 후 다시 요청하도록 안내하고 백그라운드에서 생성합니다. - 관리자 화면의 사이트맵 전체 재생성을 백그라운드 작업으로 처리하도록 바꾸고, 진행 상황(대기·생성 중·파일 작성·완료·실패)과 처리한 주소 수를 화면에서 확인할 수 있게 했습니다. 실시간 연결이 켜져 있으면 즉시 갱신되고, 꺼져 있으면 주기적으로 확인합니다. - 다국어 사이트를 위한 대체 언어 링크(hreflang)를 사이트맵에 넣을지 켜고 끌 수 있으며, 언어 수가 많을 때 링크가 과도하게 늘어나지 않도록 상한을 두었습니다. +- 사이트맵의 갱신 빈도(changefreq) 값을 검색엔진 표준 어휘(always/hourly/daily/weekly/monthly/yearly/never)로 맞춰, 규격에 없는 값은 사이트맵에 출력되지 않도록 했습니다. - 관리자가 사이트맵 전체 재생성을 실행하면, 완료되거나 실패했을 때 실행한 관리자에게 알림을 보냅니다. 재생성이 오래 걸려 화면을 떠나 있어도 결과를 놓치지 않습니다. (기본은 앱 내 알림이며 관리자 알림 설정에서 이메일도 켤 수 있습니다. 매일 자동 생성이나 글·상품 변경에 따른 재생성은 알림을 보내지 않습니다.) ### Fixed diff --git a/app/Enums/SitemapChangeFreq.php b/app/Enums/SitemapChangeFreq.php new file mode 100644 index 00000000..28c2046c --- /dev/null +++ b/app/Enums/SitemapChangeFreq.php @@ -0,0 +1,70 @@ + 값 Enum (sitemaps.org 폐쇄 어휘) + * + * https://www.sitemaps.org/protocol.html 의 는 크롤러에게 페이지 갱신 + * 빈도를 알려주는 힌트로, 허용 값이 7개로 고정된 폐쇄 어휘다. 이 Enum 이 그 SSoT 이며, + * 잘못된 문자열이 사이트맵 XML 에 유입되는 것을 저장(SitemapIndexer)과 렌더 + * (SitemapXmlRenderer) 두 경계에서 차단한다. + * + * 기여자(getUrlsLazy)·리스너·정적 URL 은 리터럴 문자열 대신 이 Enum 의 case 를 사용해 + * 오타를 작성 시점에 잡는다. + */ +enum SitemapChangeFreq: string +{ + /** + * 접근할 때마다 변경 (예: 실시간 시세) + */ + case Always = 'always'; + + /** + * 시간 단위 변경 + */ + case Hourly = 'hourly'; + + /** + * 일 단위 변경 + */ + case Daily = 'daily'; + + /** + * 주 단위 변경 + */ + case Weekly = 'weekly'; + + /** + * 월 단위 변경 + */ + case Monthly = 'monthly'; + + /** + * 연 단위 변경 + */ + case Yearly = 'yearly'; + + /** + * 사실상 변경 없음 (예: 보관 URL) + */ + case Never = 'never'; + + /** + * 임의 문자열을 유효한 changefreq 값으로 정규화합니다. + * + * 대소문자·앞뒤 공백을 흡수하고, 폐쇄 어휘에 없는 값은 null 로 떨어뜨려 + * 사이트맵 XML 에 비표준 값이 출력되는 것을 방지합니다. + * + * @param string|null $value 검증할 원본 값 + * @return string|null 유효하면 정규화된 값, 아니면 null + */ + public static function normalize(?string $value): ?string + { + if ($value === null || trim($value) === '') { + return null; + } + + return self::tryFrom(strtolower(trim($value)))?->value; + } +} diff --git a/app/Seo/SitemapGenerator.php b/app/Seo/SitemapGenerator.php index ff747280..bfc9cb6d 100644 --- a/app/Seo/SitemapGenerator.php +++ b/app/Seo/SitemapGenerator.php @@ -2,6 +2,7 @@ namespace App\Seo; +use App\Enums\SitemapChangeFreq; use App\Extension\TemplateManager; use App\Seo\Contracts\SitemapContributorInterface; use App\Services\TemplateService; @@ -290,7 +291,7 @@ class SitemapGenerator $urls[] = [ 'loc' => url($routePath), - 'changefreq' => 'weekly', + 'changefreq' => SitemapChangeFreq::Weekly->value, 'priority' => 0.5, ]; } diff --git a/app/Seo/SitemapIndexer.php b/app/Seo/SitemapIndexer.php index b30615fe..cccec0db 100644 --- a/app/Seo/SitemapIndexer.php +++ b/app/Seo/SitemapIndexer.php @@ -3,6 +3,7 @@ namespace App\Seo; use App\Contracts\Repositories\SitemapUrlRepositoryInterface; +use App\Enums\SitemapChangeFreq; use App\Extension\HookManager; /** @@ -63,7 +64,8 @@ class SitemapIndexer $normalized[] = [ 'loc' => $loc, 'lastmod' => $entry['lastmod'] ?? null, - 'changefreq' => $entry['changefreq'] ?? null, + // 폐쇄 어휘로 정규화 — 비표준 값이 sitemap_urls 에 저장되는 것을 차단. + 'changefreq' => SitemapChangeFreq::normalize($entry['changefreq'] ?? null), 'priority' => $entry['priority'] ?? null, 'contributor' => $contributor, ]; diff --git a/app/Seo/SitemapXmlRenderer.php b/app/Seo/SitemapXmlRenderer.php index ba3952d8..dd269937 100644 --- a/app/Seo/SitemapXmlRenderer.php +++ b/app/Seo/SitemapXmlRenderer.php @@ -2,6 +2,7 @@ namespace App\Seo; +use App\Enums\SitemapChangeFreq; use Illuminate\Support\Facades\Log; /** @@ -270,8 +271,10 @@ final class SitemapXmlRenderer $xml .= ' '.$this->escape((string) $entry['lastmod']).''."\n"; } - if (! empty($entry['changefreq'])) { - $xml .= ' '.$this->escape((string) $entry['changefreq']).''."\n"; + // changefreq 는 sitemaps.org 폐쇄 어휘 — Enum 으로 정규화해 비표준 값은 출력하지 않는다. + $changefreq = SitemapChangeFreq::normalize($entry['changefreq'] ?? null); + if ($changefreq !== null) { + $xml .= ' '.$changefreq.''."\n"; } if (isset($entry['priority'])) { diff --git a/docs/backend/seo-system.md b/docs/backend/seo-system.md index fb28f1e4..07d7b52a 100644 --- a/docs/backend/seo-system.md +++ b/docs/backend/seo-system.md @@ -612,6 +612,10 @@ class EcommerceSitemapContributor extends AbstractSitemapContributor `SitemapGenerator::drain()` 이 `instanceof AbstractSitemapContributor || method_exists($c, 'getUrlsLazy')` 로 capability 를 감지해 지연 경로를 우선합니다. +#### changefreq 는 `App\Enums\SitemapChangeFreq` (폐쇄 어휘) + +entry 의 `changefreq` 는 sitemaps.org 의 폐쇄 어휘(`always`/`hourly`/`daily`/`weekly`/`monthly`/`yearly`/`never`)로, `App\Enums\SitemapChangeFreq` 가 SSoT 입니다. 기여자·리스너·정적 URL 은 리터럴 문자열 대신 `SitemapChangeFreq::Weekly->value` 처럼 case 값을 사용해 오타를 작성 시점에 잡습니다. 규격에 없는 값은 저장 경계(`SitemapIndexer`)와 렌더 경계(`SitemapXmlRenderer`)에서 `SitemapChangeFreq::normalize()` 로 정규화되어 `null` 로 떨어지므로 사이트맵 XML 에 비표준 값이 출력되지 않습니다(대소문자·앞뒤 공백은 흡수). 제3자 raw 구현체가 임의 문자열을 emit 해도 이 두 경계에서 안전하게 걸러집니다. + ### ServiceProvider 등록 ```php diff --git a/modules/_bundled/sirsoft-board/src/Listeners/SeoBoardCacheListener.php b/modules/_bundled/sirsoft-board/src/Listeners/SeoBoardCacheListener.php index ae6b0308..c964da6f 100644 --- a/modules/_bundled/sirsoft-board/src/Listeners/SeoBoardCacheListener.php +++ b/modules/_bundled/sirsoft-board/src/Listeners/SeoBoardCacheListener.php @@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Board\Listeners; use App\Contracts\Extension\CacheInterface; use App\Contracts\Extension\HookListenerInterface; +use App\Enums\SitemapChangeFreq; use App\Jobs\GenerateSitemapJob; use App\Seo\Contracts\SeoCacheManagerInterface; use App\Seo\SeoCacheRegenerator; @@ -185,7 +186,7 @@ class SeoBoardCacheListener implements HookListenerInterface $indexer->indexResource('board_post', $post->id, 'sirsoft-board', [[ 'url' => "/board/{$slug}/{$post->id}", 'lastmod' => $post->updated_at?->toW3cString(), - 'changefreq' => 'monthly', + 'changefreq' => SitemapChangeFreq::Monthly->value, 'priority' => 0.5, ]]); } else { @@ -226,7 +227,7 @@ class SeoBoardCacheListener implements HookListenerInterface $indexer->indexResource('board', $board->id, 'sirsoft-board', [[ 'url' => "/board/{$board->slug}", 'lastmod' => $board->updated_at?->toW3cString(), - 'changefreq' => 'daily', + 'changefreq' => SitemapChangeFreq::Daily->value, 'priority' => 0.6, ]]); } else { diff --git a/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php b/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php index 15a48832..952cbe11 100644 --- a/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php +++ b/modules/_bundled/sirsoft-board/src/Seo/BoardSitemapContributor.php @@ -2,6 +2,7 @@ namespace Modules\Sirsoft\Board\Seo; +use App\Enums\SitemapChangeFreq; use App\Seo\AbstractSitemapContributor; use Illuminate\Support\Facades\Log; use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface; @@ -82,7 +83,7 @@ class BoardSitemapContributor extends AbstractSitemapContributor if ($seoBoards) { yield [ 'url' => '/boards', - 'changefreq' => 'weekly', + 'changefreq' => SitemapChangeFreq::Weekly->value, 'priority' => 0.5, 'resource_type' => 'board_index', 'resource_id' => null, @@ -100,7 +101,7 @@ class BoardSitemapContributor extends AbstractSitemapContributor yield [ 'url' => "/board/{$board->slug}", 'lastmod' => $board->updated_at?->toW3cString(), - 'changefreq' => 'daily', + 'changefreq' => SitemapChangeFreq::Daily->value, 'priority' => 0.6, 'resource_type' => 'board', 'resource_id' => (string) $board->id, @@ -116,7 +117,7 @@ class BoardSitemapContributor extends AbstractSitemapContributor yield [ 'url' => "/board/{$board->slug}/{$post->id}", 'lastmod' => $post->updated_at?->toW3cString(), - 'changefreq' => 'monthly', + 'changefreq' => SitemapChangeFreq::Monthly->value, 'priority' => 0.5, 'resource_type' => 'board_post', 'resource_id' => (string) $post->id, diff --git a/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoCategoryCacheListener.php b/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoCategoryCacheListener.php index 99f22ec4..e43aba68 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoCategoryCacheListener.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoCategoryCacheListener.php @@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Ecommerce\Listeners; use App\Contracts\Extension\CacheInterface; use App\Contracts\Extension\HookListenerInterface; +use App\Enums\SitemapChangeFreq; use App\Jobs\GenerateSitemapJob; use App\Seo\Contracts\SeoCacheManagerInterface; use App\Seo\SitemapIndexer; @@ -145,7 +146,7 @@ class SeoCategoryCacheListener implements HookListenerInterface $indexer->indexResource('category', $category->id, 'sirsoft-ecommerce', [[ 'url' => "/{$routePath}/category/{$category->slug}", 'lastmod' => $category->updated_at?->toW3cString(), - 'changefreq' => 'weekly', + 'changefreq' => SitemapChangeFreq::Weekly->value, 'priority' => 0.6, ]]); } else { diff --git a/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoProductCacheListener.php b/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoProductCacheListener.php index ca75d822..97ad9d90 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoProductCacheListener.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Listeners/SeoProductCacheListener.php @@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Ecommerce\Listeners; use App\Contracts\Extension\CacheInterface; use App\Contracts\Extension\HookListenerInterface; +use App\Enums\SitemapChangeFreq; use App\Jobs\GenerateSitemapJob; use App\Seo\Contracts\SeoCacheManagerInterface; use App\Seo\SeoCacheRegenerator; @@ -163,7 +164,7 @@ class SeoProductCacheListener implements HookListenerInterface $indexer->indexResource('product', $product->id, 'sirsoft-ecommerce', [[ 'url' => "/{$routePath}/products/{$product->id}", 'lastmod' => $product->updated_at?->toW3cString(), - 'changefreq' => 'weekly', + 'changefreq' => SitemapChangeFreq::Weekly->value, 'priority' => 0.8, ]]); } else { diff --git a/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php b/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php index 22951383..c4c5b996 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Seo/EcommerceSitemapContributor.php @@ -2,6 +2,7 @@ namespace Modules\Sirsoft\Ecommerce\Seo; +use App\Enums\SitemapChangeFreq; use App\Seo\AbstractSitemapContributor; use Illuminate\Support\Facades\Log; use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CategoryRepositoryInterface; @@ -79,7 +80,7 @@ class EcommerceSitemapContributor extends AbstractSitemapContributor if ((bool) g7_module_settings('sirsoft-ecommerce', 'seo.seo_shop_index', true)) { yield [ 'url' => "/{$routePath}/products", - 'changefreq' => 'daily', + 'changefreq' => SitemapChangeFreq::Daily->value, 'priority' => 0.7, 'resource_type' => 'shop_index', 'resource_id' => null, @@ -92,7 +93,7 @@ class EcommerceSitemapContributor extends AbstractSitemapContributor yield [ 'url' => "/{$routePath}/category/{$category->slug}", 'lastmod' => $category->updated_at?->toW3cString(), - 'changefreq' => 'weekly', + 'changefreq' => SitemapChangeFreq::Weekly->value, 'priority' => 0.6, 'resource_type' => 'category', 'resource_id' => (string) $category->id, @@ -106,7 +107,7 @@ class EcommerceSitemapContributor extends AbstractSitemapContributor yield [ 'url' => "/{$routePath}/products/{$product->id}", 'lastmod' => $product->updated_at?->toW3cString(), - 'changefreq' => 'weekly', + 'changefreq' => SitemapChangeFreq::Weekly->value, 'priority' => 0.8, 'resource_type' => 'product', 'resource_id' => (string) $product->id, diff --git a/modules/_bundled/sirsoft-page/src/Listeners/SeoPageCacheListener.php b/modules/_bundled/sirsoft-page/src/Listeners/SeoPageCacheListener.php index 3fa39300..c333e40d 100644 --- a/modules/_bundled/sirsoft-page/src/Listeners/SeoPageCacheListener.php +++ b/modules/_bundled/sirsoft-page/src/Listeners/SeoPageCacheListener.php @@ -4,6 +4,7 @@ namespace Modules\Sirsoft\Page\Listeners; use App\Contracts\Extension\CacheInterface; use App\Contracts\Extension\HookListenerInterface; +use App\Enums\SitemapChangeFreq; use App\Jobs\GenerateSitemapJob; use App\Seo\Contracts\SeoCacheManagerInterface; use App\Seo\SitemapIndexer; @@ -137,7 +138,7 @@ class SeoPageCacheListener implements HookListenerInterface $indexer->indexResource('page', $page->id, 'sirsoft-page', [[ 'url' => "/page/{$page->slug}", 'lastmod' => $page->updated_at?->toW3cString(), - 'changefreq' => 'monthly', + 'changefreq' => SitemapChangeFreq::Monthly->value, 'priority' => 0.5, ]]); } else { diff --git a/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php b/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php index bdf87c17..bb4d2116 100644 --- a/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php +++ b/modules/_bundled/sirsoft-page/src/Seo/PageSitemapContributor.php @@ -2,6 +2,7 @@ namespace Modules\Sirsoft\Page\Seo; +use App\Enums\SitemapChangeFreq; use App\Seo\AbstractSitemapContributor; use Illuminate\Support\Facades\Log; use Modules\Sirsoft\Page\Repositories\Contracts\PageRepositoryInterface; @@ -60,7 +61,7 @@ class PageSitemapContributor extends AbstractSitemapContributor yield [ 'url' => "/page/{$page->slug}", 'lastmod' => $page->updated_at?->toW3cString(), - 'changefreq' => 'monthly', + 'changefreq' => SitemapChangeFreq::Monthly->value, 'priority' => 0.5, 'resource_type' => 'page', 'resource_id' => (string) $page->id, diff --git a/tests/Unit/Enums/SitemapChangeFreqTest.php b/tests/Unit/Enums/SitemapChangeFreqTest.php new file mode 100644 index 00000000..739a330a --- /dev/null +++ b/tests/Unit/Enums/SitemapChangeFreqTest.php @@ -0,0 +1,57 @@ + $c->value, SitemapChangeFreq::cases()); + + $this->assertSame( + ['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never'], + $values + ); + } + + /** + * 유효한 값은 그대로 정규화된다. + */ + public function test_normalize_returns_valid_value(): void + { + $this->assertSame('daily', SitemapChangeFreq::normalize('daily')); + $this->assertSame('monthly', SitemapChangeFreq::normalize('monthly')); + } + + /** + * 대소문자·앞뒤 공백은 흡수한다. + */ + public function test_normalize_absorbs_case_and_whitespace(): void + { + $this->assertSame('weekly', SitemapChangeFreq::normalize('WEEKLY')); + $this->assertSame('weekly', SitemapChangeFreq::normalize(' Weekly ')); + } + + /** + * 폐쇄 어휘에 없는 값·빈 값·null 은 null 로 떨어진다 (XML 유입 차단). + */ + public function test_normalize_rejects_invalid_and_empty(): void + { + $this->assertNull(SitemapChangeFreq::normalize('biweekly')); + $this->assertNull(SitemapChangeFreq::normalize('yes')); + $this->assertNull(SitemapChangeFreq::normalize('')); + $this->assertNull(SitemapChangeFreq::normalize(' ')); + $this->assertNull(SitemapChangeFreq::normalize(null)); + } +} diff --git a/tests/Unit/Notifications/GenericNotificationViaTest.php b/tests/Unit/Notifications/GenericNotificationViaTest.php index 64bb60ed..d3822835 100644 --- a/tests/Unit/Notifications/GenericNotificationViaTest.php +++ b/tests/Unit/Notifications/GenericNotificationViaTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\Notifications; +use App\Contracts\Notifications\ChannelReadinessCheckerInterface; use App\Models\NotificationDefinition; use App\Models\NotificationTemplate; use App\Models\User; @@ -54,6 +55,12 @@ class GenericNotificationViaTest extends TestCase // 기본: 모든 채널을 활성으로 mocking (공유 storage/app/settings/notifications.json의 // 실제 값과 격리). 개별 테스트에서 mockChannelEnabled(['xxx' => false])로 재정의 가능. $this->mockChannelEnabled([]); + + // 기본: 모든 채널을 readiness OK로 mocking. mail readiness 는 실제 settings 디스크의 + // mail.json(from_address/SMTP 등)에 의존하는데, TestCase 전역 settings fake 로 비어 있어 + // 실 환경에서는 항상 not-ready 로 떨어진다. via() 채널 결정 로직만 결정적으로 검증하기 위해 + // readiness 게이트를 mock 으로 격리한다. readiness 자체는 ChannelReadinessServiceTest 가 검증. + $this->mockReadiness([]); } protected function tearDown(): void @@ -157,7 +164,7 @@ class GenericNotificationViaTest extends TestCase /** * NotificationChannelService::isChannelEnabledForExtension()의 응답을 mocking. * - * @param array $channelMap + * @param array $channelMap */ private function mockChannelEnabled(array $channelMap): void { @@ -169,6 +176,27 @@ class GenericNotificationViaTest extends TestCase $this->app->instance(NotificationChannelService::class, $mock); } + /** + * ChannelReadinessCheckerInterface::isReady()/check() 응답을 mocking. + * + * 미지정 채널은 ready=true 기본. 개별 테스트에서 mockReadiness(['mail' => false]) 로 + * 특정 채널을 not-ready 로 재정의할 수 있다. + * + * @param array $readyMap 채널별 readiness 맵 + */ + private function mockReadiness(array $readyMap): void + { + $mock = Mockery::mock(ChannelReadinessCheckerInterface::class); + $mock->shouldReceive('isReady') + ->andReturnUsing(fn ($channel) => $readyMap[$channel] ?? true); + $mock->shouldReceive('check') + ->andReturnUsing(fn ($channel) => [ + 'ready' => $readyMap[$channel] ?? true, + 'reason' => ($readyMap[$channel] ?? true) ? null : 'notification.readiness.mail_from_address_not_configured', + ]); + $this->app->instance(ChannelReadinessCheckerInterface::class, $mock); + } + /** * 양쪽 채널 모두 활성 템플릿이 있으면 두 채널 모두 반환 */ @@ -185,6 +213,23 @@ class GenericNotificationViaTest extends TestCase $this->assertCount(2, $channels); } + /** + * readiness 실패 채널은 제외 — 템플릿·확장 토글이 OK여도 미설정 mail 은 발송 대상에서 빠진다. + */ + public function test_via_excludes_channel_that_is_not_ready(): void + { + $this->createTemplate('mail'); + $this->createTemplate('database'); + + $this->mockReadiness(['mail' => false, 'database' => true]); + + $notification = new GenericNotification('test_via_check', 'core.test'); + $channels = $notification->via($this->user); + + $this->assertNotContains('mail', $channels); + $this->assertContains('database', $channels); + } + /** * mail 템플릿만 있으면 mail만 반환, database 제외 */ @@ -257,10 +302,6 @@ class GenericNotificationViaTest extends TestCase /** * 테스트용 템플릿 생성 헬퍼 - * - * @param string $channel - * @param bool $isActive - * @return NotificationTemplate */ private function createTemplate(string $channel, bool $isActive = true): NotificationTemplate { diff --git a/tests/Unit/Seo/SitemapXmlRendererTest.php b/tests/Unit/Seo/SitemapXmlRendererTest.php index eb17528f..86f4b011 100644 --- a/tests/Unit/Seo/SitemapXmlRendererTest.php +++ b/tests/Unit/Seo/SitemapXmlRendererTest.php @@ -336,18 +336,45 @@ class SitemapXmlRendererTest extends TestCase } /** - * urlBlock: 메타 필드의 XML 특수문자도 이스케이프한다 + * urlBlock: 자유 형식 메타 필드(lastmod)의 XML 특수문자를 이스케이프한다 + * + * changefreq 는 폐쇄 어휘(SitemapChangeFreq)로 정규화되므로 특수문자가 유입될 수 없다 + * (아래 test_url_block_omits_invalid_changefreq 로 별도 검증). */ public function test_url_block_escapes_special_characters_in_meta_fields(): void { $xml = $this->singleLocale()->urlBlock([ 'loc' => 'https://example.test/a', 'lastmod' => '2026 & later', - 'changefreq' => 'daassertStringContainsString('2026 & later', $xml); - $this->assertStringContainsString('da<ily', $xml); + } + + /** + * urlBlock: 폐쇄 어휘(sitemaps.org)에 없는 changefreq 는 태그를 생략한다 (비표준 값 유입 차단) + */ + public function test_url_block_omits_invalid_changefreq(): void + { + $xml = $this->singleLocale()->urlBlock([ + 'loc' => 'https://example.test/a', + 'changefreq' => 'biweekly', + ]); + + $this->assertStringNotContainsString('', $xml); + } + + /** + * urlBlock: changefreq 대소문자·공백을 정규화해 표준 소문자로 출력한다 + */ + public function test_url_block_normalizes_changefreq_casing(): void + { + $xml = $this->singleLocale()->urlBlock([ + 'loc' => 'https://example.test/a', + 'changefreq' => ' WEEKLY ', + ]); + + $this->assertStringContainsString('weekly', $xml); } /** diff --git a/tests/Unit/Upgrades/Upgrade_7_0_0_beta_3Test.php b/tests/Unit/Upgrades/Upgrade_7_0_0_beta_3Test.php index d6ab0e25..7b342ec6 100644 --- a/tests/Unit/Upgrades/Upgrade_7_0_0_beta_3Test.php +++ b/tests/Unit/Upgrades/Upgrade_7_0_0_beta_3Test.php @@ -64,20 +64,32 @@ class Upgrade_7_0_0_beta_3Test extends TestCase } /** - * config/app.php 의 restore_ownership_group_writable 기본값과도 동일해야 한다. + * 동결된 TARGETS 는 살아있는 config 기본값과 의도적으로 갈라진다 (동결 규정 §7). + * + * beta.3 스텝은 실행 당시의 동작을 100% 보존해야 하므로 TARGETS 는 동결되어 있다. + * 반면 config('app.update.restore_ownership_group_writable') 기본값은 #326 에서 + * storage/app 사용자 데이터 보존을 위해 storage → storage/logs + storage/framework 로 + * 세분화되고 lang-packs 등이 추가되며 정당하게 진화했다. + * + * 따라서 둘은 같을 수 없고 같아서도 안 된다. 이 테스트는 누군가 동결 스텝의 TARGETS 를 + * 현재 config 에 맞추려 편집하는 동결 위반(또는 config 원복)을 차단한다. */ - public function test_targets_matches_config_app_restore_ownership_group_writable(): void + public function test_frozen_targets_intentionally_diverges_from_evolving_config(): void { - $configDefault = config('app.update.restore_ownership_group_writable'); + $liveConfigDefault = array_values(config('app.update.restore_ownership_group_writable')); $reflection = new \ReflectionClassConstant(Upgrade_7_0_0_beta_3::class, 'TARGETS'); $targets = $reflection->getValue(); - $this->assertSame( + $this->assertNotSame( + $liveConfigDefault, $targets, - array_values($configDefault), - 'TARGETS 상수와 config 기본값이 1:1 동일해야 한다' + '동결 TARGETS 가 살아있는 config 와 동일해졌다면 동결 규정 위반이거나 config 가 되돌려진 것이다' ); + + // 동결 스텝은 세분화 이전 — storage 를 통째로 다뤘다. config 는 #326 에서 세분화됨. + $this->assertContains('storage', $targets, '동결 TARGETS 는 세분화 이전의 storage 전체 항목을 유지한다'); + $this->assertNotContains('storage/logs', $targets, '동결 TARGETS 에는 #326 의 세분화 항목이 유입되면 안 된다'); } /** From 707919719c02596187b25e745e730d315df12ba3 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Sun, 19 Jul 2026 20:23:00 +0900 Subject: [PATCH 09/11] =?UTF-8?q?chore(build):=20=EB=B2=88=EB=93=A4=20?= =?UTF-8?q?=ED=99=95=EC=9E=A5=20=EC=86=8C=EC=8A=A4=EB=A7=B5=20=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=EB=AC=BC=20=EC=A0=9C=EA=B1=B0=20(--production=20?= =?UTF-8?q?=EC=9E=AC=EB=B9=8C=EB=93=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 결제 플러그인 3종(pay_kginicis/nhnkcp/nicepayments)과 sirsoft-basic 템플릿의 배포 JS 에 //# sourceMappingURL 주석과 .map 파일이 남아 있어, 공개 배포판에 원본 TS/TSX 전문(sourcesContent)이 노출될 수 있었다(NoSourcemapArtifactsTest 2건 실패). 각 확장을 --production(G7_BUILD_SOURCEMAP=0) 으로 재빌드해 소스맵 주석·.map 을 제거하고 {plugin|template}:update 로 활성 디렉토리에 반영. 기능 코드는 바이트 동일, 소스맵 주석만 제거된다. nhnkcp/nicepayments 의 components.json 파생 버전도 매니페스트 SSoT(1.0.0)로 재동기화. 매니페스트 버전은 무변경(동작 변화 없는 배포본 위생 수정). NoSourcemapArtifactsTest 4/4 green. --- plugins/_bundled/sirsoft-pay_kginicis/dist/js/plugin.iife.js | 1 - plugins/_bundled/sirsoft-pay_nhnkcp/components.json | 2 +- plugins/_bundled/sirsoft-pay_nhnkcp/dist/js/plugin.iife.js | 1 - plugins/_bundled/sirsoft-pay_nicepayments/components.json | 2 +- .../_bundled/sirsoft-pay_nicepayments/dist/js/plugin.iife.js | 1 - templates/_bundled/sirsoft-basic/dist/js/components.iife.js | 1 - 6 files changed, 2 insertions(+), 6 deletions(-) diff --git a/plugins/_bundled/sirsoft-pay_kginicis/dist/js/plugin.iife.js b/plugins/_bundled/sirsoft-pay_kginicis/dist/js/plugin.iife.js index 158395bf..0b93f32c 100644 --- a/plugins/_bundled/sirsoft-pay_kginicis/dist/js/plugin.iife.js +++ b/plugins/_bundled/sirsoft-pay_kginicis/dist/js/plugin.iife.js @@ -88,4 +88,3 @@ `),t.document.close()}function E(e){return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}const D="sirsoft-pay_kginicis",we="__kginicisOrderShowInjectorInstalled",K="kginicis-mp-receipt-row",be=/^(?:\/mypage\/orders\/([^/]+)|\/shop\/guest\/orders\/([^/]+))$/,G=new Map;function Tt(e){try{const n=window.G7Core?.getState,i=n?.()?.currentDataContext?.order?.data;return i&&(i.order_number===e||i.id!==void 0&&String(i.id)===e)?i:null}catch{return null}}function xt(){const e=document.getElementById("order_payment_info_panel");if(e)return Array.from(e.children).find(r=>r.className?.includes("space-y"))??e;const t=Array.from(document.querySelectorAll("h3")).find(r=>{const o=r.textContent?.trim()??"";return o.includes("결제 정보")||/^Payment Information$/i.test(o)});if(!t)return null;const n=t.parentElement?.parentElement;return n?Array.from(n.children).find(r=>r.className?.includes("space-y"))??n:null}function Rt(e,t){if(!t)return!1;const n=Array.from(e.querySelectorAll("div"));for(const r of n){const o=Array.from(r.children).filter(a=>a instanceof HTMLElement&&a.tagName==="SPAN");if(o.length<2)continue;const i=o[0].textContent?.trim();if(i!=="결제 방법"&&i!=="결제수단"&&i!=="Payment Method")continue;const s=o[o.length-1];return s.textContent?.trim()===t?(r.dataset.kginicisPaymentMethodRow="true",!0):(s.textContent=t,s.dataset.kginicisPaymentMethodPatched="true",r.dataset.kginicisPaymentMethodRow="true",!0)}return!1}function Mt(e,t){const n=document.createElement("div");n.id=K,n.className="flex items-center justify-between",n.dataset.kginicisReceiptRow="mypage-order";const r=document.createElement("span");r.className="text-gray-500 dark:text-gray-400 text-sm",r.textContent=vt(t);const o=document.createElement("button");return o.type="button",o.dataset.kginicisReceiptButton="mypage-order",o.className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline disabled:opacity-50",o.textContent=A(t),o.addEventListener("click",async()=>{o.disabled=!0,o.textContent="로딩 중...";const i=await v(e);o.disabled=!1,o.textContent=A(i??t),C(i)&&ge(i)}),n.appendChild(r),n.appendChild(o),n}async function Nt(e,t){const n=Tt(e),r=n?.payment;if(r&&(r.pg_provider!=="kginicis"||!r.transaction_id))return!0;const o=n?.order_number??(t?e:null);if(!o)return!1;if(!t&&n&&!r)return!0;const i=xt();if(!i)return!1;const{status:s,info:a}=await he(o);if(!t&&s===404)return!0;const c=r?.payment_status==="paid",u=a?.receipt_type==="cbt_confirmation";if(r&&!c&&!u)return!0;if(!r&&!C(a))return!1;const d=Rt(i,a?.payment_method_display_label);return!document.getElementById(K)&&C(a)&&(i.appendChild(Mt(o,a)),console.info(`[${D}] receipt button injected on mypage order show`)),d&&console.info(`[${D}] payment method display patched on mypage order show`),!!(document.getElementById(K)||d)}function Lt(e,t){if(G.has(e))return;let n=0;const r=window.setInterval(()=>{n++,Nt(e,t).then(o=>{(o||n>=30)&&(window.clearInterval(r),G.delete(e))})},400);G.set(e,r)}function Ot(){const e=location.pathname.match(be);if(e){const t=e[1],n=e[2],r=t??n;r&&Lt(r,n!==void 0)}}function $t(){if(typeof window>"u")return;const e=window;if(e[we])return;e[we]=!0,console.info(`[${D}] mypage order show injector installed`);let t=null;const n=(a=1500)=>{t!==null&&window.clearTimeout(t),t=window.setTimeout(()=>{t=null,Ot()},a)};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>n()):n();const r=history.pushState.bind(history);history.pushState=(...a)=>{r(...a),n(600)};const o=history.replaceState.bind(history);history.replaceState=(...a)=>{o(...a),n(600)},window.addEventListener("popstate",()=>n(500));const i=document.getElementById("app")??document.body;new MutationObserver(()=>{be.test(location.pathname)&&n(250)}).observe(i,{childList:!0,subtree:!0})}const j="sirsoft-pay_kginicis",ke="__kginicisAdminPaymentDisplayInjectorInstalled",Dt=/^\/admin\/ecommerce\/orders\/([^/]+)$/;function Kt(){return localStorage.getItem("auth_token")}async function Gt(e){const t=Kt();if(!t)return null;try{const n=await fetch(`/api/plugins/${j}/admin/orders/${e}/transaction-status`,{headers:{Authorization:`Bearer ${t}`,Accept:"application/json"}});if(!n.ok)return null;const r=await n.json();return("data"in r?r.data:r)??null}catch{return null}}function jt(e){return Array.from(e.children).filter(t=>t instanceof HTMLElement)}function Yt(e,t){let n=!1;const r=Array.from(e.querySelectorAll("div"));for(const o of r){const i=jt(o),s=i.findIndex(l=>l.textContent?.trim()==="결제수단");if(s<0)continue;const a=i.slice(s+1).find(l=>l.textContent?.trim()&&l.textContent.trim()!=="결제수단");if(!a)continue;const c=i.slice(s+2).find(l=>/^\(.+\)$/.test(l.textContent?.trim()??"")),u=c?.textContent?.trim().replace(/^\(|\)$/g,"")??"",d=u&&t.endsWith(")")?t.replace(/\)$/,`, ${u})`):t;a.textContent?.trim()!==d&&(a.textContent=d,a.dataset.kginicisPaymentMethodPatched="true"),o.dataset.kginicisPaymentMethodRow="admin-order",c&&u&&(c.textContent="",c.style.display="none",c.dataset.kginicisPaymentInstallmentHidden="true"),n=!0}return n}function Bt(e,t,n){const o=Array.from(e.querySelectorAll("span")).filter(i=>i.textContent?.trim()===t).find(i=>{const s=typeof i.className=="string"?i.className:"";return s.includes("rounded-full")||s.includes("font-medium")});return o?(o.textContent=n,o.dataset.kginicisPaymentMethodPatched="true",o.dataset.kginicisPaymentMethodBadge="admin-order",!0):!1}function Ut(e,t){const n=t?._pay_method_label,r=t?._embedded_pg_provider_label,o=t?._base_pay_method_label;if(!n||!r)return!1;const i=Yt(e,n),s=o?Bt(e,o,r):!1;return i||s}async function Ht(e){const t=document.getElementById("section_payment_info")??document.getElementById("payment_info_card");if(!t)return!1;const n=await Gt(e);return n&&Ut(t,n)&&console.info(`[${j}] payment method display patched on admin order detail`),!0}function Jt(e){let t=0;const n=setInterval(()=>{t++,Ht(e).then(r=>{(r||t>=30)&&clearInterval(n)})},400)}function Ft(){const e=location.pathname.match(Dt);e&&Jt(e[1])}function qt(){if(typeof window>"u")return;const e=window;if(e[ke])return;e[ke]=!0,console.info(`[${j}] admin order payment display injector installed`);const t=(r=1500)=>setTimeout(Ft,r);document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>t()):t();const n=history.pushState.bind(history);history.pushState=(...r)=>{n(...r),t(600)},window.addEventListener("popstate",()=>t(500))}const Ce="sirsoft-pay_kginicis",Ee="__kginicisOcReceiptInjectorInstalled",Se="kginicis-oc-receipt-btn",Wt=/^\/shop\/orders\/([^/]+)\/complete$/;function Vt(){return localStorage.getItem("auth_token")}function zt(){try{const t=sessionStorage.getItem("g7_guest_order_token");if(t)return t}catch{}const e=window.G7Core?.state?.get?.("_global")?.guestOrderToken;return typeof e=="string"&&e!==""?e:null}async function Xt(e){const t=Vt(),n=zt();if(!t&&!n)return null;const r={Accept:"application/json"};t?r.Authorization=`Bearer ${t}`:n&&(r["X-Guest-Order-Token"]=n);try{const o=await fetch(`/api/modules/sirsoft-ecommerce/user/orders/${e}`,{headers:r});return o.ok?(await o.json())?.data?.payment??null:null}catch{return null}}function Zt(e){if(!e)return!1;const t=Array.from(document.querySelectorAll("div"));for(const n of t){const r=Array.from(n.children).filter(s=>s instanceof HTMLElement&&s.tagName==="SPAN");if(r.length<2)continue;const o=r[0].textContent?.trim();if(o!=="결제 방법"&&o!=="결제수단"&&o!=="결제 방식")continue;const i=r[r.length-1];return i.textContent?.trim()===e?(n.dataset.kginicisPaymentMethodRow="true",!0):(i.textContent=e,i.dataset.kginicisPaymentMethodPatched="true",n.dataset.kginicisPaymentMethodRow="true",!0)}return!1}async function Qt(e){if(document.getElementById(Se))return;const t=await v(e);if(!C(t))return;const n=await Xt(e);if(n){if(n.pg_provider!=="kginicis"||!n.transaction_id)return;const a=n.payment_status==="paid",c=t.receipt_type==="cbt_confirmation";if(!a&&!c)return}Zt(t.payment_method_display_label);const r=Array.from(document.querySelectorAll('button[type="button"]')).find(a=>a.className.includes("bg-blue-600"));if(!r?.parentElement)return;const o=r.parentElement;o.dataset.kginicisReceiptContainer="order-complete";const i=document.createElement("button");i.id=Se,i.type="button",i.dataset.kginicisReceiptButton="order-complete",i.className=r.className.replace(/bg-blue-\d+/g,"bg-green-600").replace(/hover:bg-blue-\d+/g,"hover:bg-green-700"),i.textContent=A(t),i.addEventListener("click",async()=>{i.disabled=!0,i.textContent="로딩 중...";const a=await v(e);i.disabled=!1,i.textContent=A(a??t),C(a)&&ge(a)});const s=o.lastElementChild;o.insertBefore(i,s),console.info(`[${Ce}] receipt button injected on order complete page`)}function en(){const e=location.pathname.match(Wt);e&&Qt(e[1])}function tn(){if(typeof window>"u")return;const e=window;if(e[Ee])return;e[Ee]=!0,console.info(`[${Ce}] order complete receipt injector installed`);const t=(r=1200)=>setTimeout(en,r);document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>t()):t();const n=history.pushState.bind(history);history.pushState=(...r)=>{n(...r),t()},window.addEventListener("popstate",()=>t(500))}const Pe="sirsoft-pay_kginicis",Ie="__kginicisVbankInfoInjectorInstalled",Y="kginicis-mp-vbank-info",nn=/^\/shop\/orders\/([^/]+)\/complete$/,rn=/^\/mypage\/orders\/([^/]+)$/,on=/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+\-]\d{2}:\d{2}/,an={"03":"기업은행","04":"국민은행","05":"외환은행","07":"수협중앙회",11:"농협중앙회",20:"우리은행",23:"SC 제일은행",31:"대구은행",32:"부산은행",34:"광주은행",37:"전북은행",39:"경남은행",53:"한국씨티은행",71:"우체국",81:"하나은행",88:"신한은행",89:"케이뱅크",90:"카카오뱅크",92:"토스뱅크",D1:"동양종합금융증권",D2:"현대증권",D3:"미래에셋증권",D4:"한국투자증권",D5:"우리투자증권",D6:"하이투자증권",D7:"HMC 투자증권",D8:"SK 증권",D9:"대신증권",DA:"하나대투증권",DB:"굿모닝신한증권",DC:"동부증권",DD:"유진투자증권",DE:"메리츠증권",DF:"신영증권"};function sn(e){if(e.vbank_code)return String(e.vbank_code);const t=(e.vbank_name??"").trim();if(!t)return null;for(const[n,r]of Object.entries(an))if(r===t)return n;return null}function cn(){try{const t=window.G7Core?.getState;return t?.()?.currentDataContext?._global?.currentUser?.is_admin===!0}catch{return!1}}function ve(e){try{return new Date(e).toLocaleString("ko-KR",{year:"numeric",month:"long",day:"numeric",hour:"2-digit",minute:"2-digit",timeZone:"Asia/Seoul"})}catch{return e}}function dn(){const e=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT),t=[];let n=e.nextNode();for(;n;){const r=n.textContent??"",o=r.match(on);o&&t.push({node:n,formatted:r.replace(o[0],ve(o[0]))}),n=e.nextNode()}for(const{node:r,formatted:o}of t)r.textContent=o}function ln(e){try{const n=window.G7Core?.getState,i=n?.()?.currentDataContext?.order?.data;return!i||i.order_number!==e?null:i}catch{return null}}function un(){const e=document.getElementById("order_payment_info_panel");if(e)return Array.from(e.children).find(r=>r.className?.includes("space-y"))??e;const t=Array.from(document.querySelectorAll("h3")).find(r=>r.textContent?.includes("결제 정보"));if(!t)return null;const n=t.parentElement?.parentElement;return n?Array.from(n.children).find(r=>r.className?.includes("space-y"))??n:null}function _(e,t,n=!1){const r=document.createElement("div");r.className="flex items-center justify-between";const o=document.createElement("span");o.className="text-gray-500 dark:text-gray-400 text-sm",o.textContent=e;const i=document.createElement("span");return i.className=n?"text-sm font-medium text-blue-600 dark:text-blue-400":"text-sm text-gray-900 dark:text-gray-100",i.textContent=t,r.appendChild(o),r.appendChild(i),r}function mn(e,t){const n=document.createElement("div");n.id=Y,n.className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700 space-y-2";const r=document.createElement("p");if(r.className="text-sm font-semibold text-gray-700 dark:text-gray-300",r.textContent="가상계좌 입금 안내",n.appendChild(r),e.vbank_name&&n.appendChild(_("은행",e.vbank_name)),cn()){const o=sn(e);o&&n.appendChild(_("은행코드",o))}return e.vbank_number&&n.appendChild(_("계좌번호",e.vbank_number)),e.vbank_holder&&n.appendChild(_("예금주",e.vbank_holder)),t&&n.appendChild(_("입금 기한",t,!0)),n}function g(e,t){return e?.receipt_fields?.find(n=>n.label===t)?.value??""}function fn(e,t){if(t?.receipt_type!=="cbt_confirmation")return!1;if(g(t,"결제수단").includes("편의점"))return!0;const r=String(e.transaction_id??"");return String(e.currency??"").toUpperCase()==="JPY"&&(String(e.vbank_name??"").toUpperCase()==="CVS"||r.startsWith("INIJPGCVS"))}function pn(e,t,n){const r=document.createElement("div");r.id=Y,r.className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700 space-y-2";const o=document.createElement("p");o.className="text-sm font-semibold text-gray-700 dark:text-gray-300",o.textContent="일본 편의점 입금 안내",r.appendChild(o);const i=g(t,"입금 상태"),s=g(t,"입금예정금액")||g(t,"결제금액"),a=g(t,"편의점 코드"),c=g(t,"편의점 확인번호")||String(e.vbank_number??""),u=g(t,"편의점 접수번호"),d=g(t,"입금 마감일시")||n;return i&&r.appendChild(_("상태",i)),s&&r.appendChild(_("입금예정금액",s,!0)),a&&r.appendChild(_("편의점 코드",a)),c&&r.appendChild(_("편의점 확인번호",c)),u&&r.appendChild(_("편의점 접수번호",u)),d&&r.appendChild(_("입금 마감일시",d,!0)),r}async function yn(e){const t=ln(e);if(!t)return!1;const{payment:n}=t;if(!n||n.pg_provider!=="kginicis"||n.payment_status!=="waiting_deposit"||!n.vbank_number||document.getElementById(Y))return!0;const r=un();if(!r)return!1;const o=await v(e),i=n.vbank_due_at?ve(n.vbank_due_at):"";return r.appendChild(fn(n,o)?pn(n,o,i):mn(n,i)),console.info(`[${Pe}] vbank info injected on mypage`),!0}function _n(e){let t=0;const n=setInterval(()=>{t++,yn(e).then(r=>{(r||t>=30)&&clearInterval(n)})},400)}function hn(){if(location.pathname.match(nn)){setTimeout(dn,500);return}const t=location.pathname.match(rn);t&&_n(t[1])}function gn(){if(typeof window>"u")return;const e=window;if(e[Ie])return;e[Ie]=!0,console.info(`[${Pe}] vbank info injector installed`);const t=(r=800)=>setTimeout(hn,r);document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>t()):t();const n=history.pushState.bind(history);history.pushState=(...r)=>{n(...r),t(600)},window.addEventListener("popstate",()=>t(500))}const Ae="sirsoft-pay_kginicis",Te="__sirsoftKginicisCheckoutJpyPaymentMethodRestrictorInstalled",B=/^\/shop\/checkout\/?$/,wn="/api/modules/sirsoft-ecommerce/checkout",bn="/api/modules/sirsoft-ecommerce/settings/payment",kn="/api/modules/sirsoft-ecommerce/payments/client-config/kginicis",xe="__sirsoftKginicisCheckoutCurrency",U=new Set(["card","kginicis_japan_paypay","kginicis_japan_cvs"]),H=new Set(["kginicis_japan_paypay","kginicis_japan_cvs"]),Re=new Set(["card","vbank","dbank","bank","phone","kginicis_samsung_pay","kginicis_naverpay","kginicis_lpay","kginicis_kakaopay","kginicis_japan_paypay","kginicis_japan_cvs"]),Cn=[{id:"card",labels:["신용카드","Credit Card","クレジットカード"]},{id:"vbank",labels:["가상계좌","Virtual Account","バーチャル口座"]},{id:"dbank",labels:["무통장입금","Bank Transfer","銀行振込"]},{id:"bank",labels:["계좌이체","Account Transfer","口座振替"]},{id:"phone",labels:["휴대폰결제","Mobile Payment","携帯電話決済"]},{id:"kginicis_naverpay",labels:["네이버페이 (KG이니시스)","Naver Pay (KG Inicis)"]},{id:"kginicis_samsung_pay",labels:["삼성페이 (KG이니시스)","Samsung Pay (KG Inicis)"]},{id:"kginicis_lpay",labels:["L.pay (KG이니시스)","L.pay (KG Inicis)"]},{id:"kginicis_kakaopay",labels:["카카오페이 (KG이니시스)","Kakao Pay (KG Inicis)"]},{id:"kginicis_japan_paypay",labels:["PayPay (일본 KG이니시스)","PayPay (KG Inicis Japan)"]},{id:"kginicis_japan_cvs",labels:["일본 편의점결제 (KG이니시스)","Japan Convenience Store (KG Inicis)"]}],J={info:(...e)=>console.info(`[${Ae}]`,...e),warn:(...e)=>console.warn(`[${Ae}]`,...e)};let S=null,F=null,b=null;function q(){return window}function En(e){return typeof e=="string"?e:e instanceof URL?e.toString():typeof Request<"u"&&e instanceof Request?e.url:String(e)}function Sn(e,t){return t?.method?t.method.toUpperCase():typeof Request<"u"&&e instanceof Request?e.method.toUpperCase():"GET"}function Pn(e){try{return new URL(e,window.location.origin).pathname}catch{return e.split("?")[0].split("#")[0]}}function Me(e){if(typeof e!="string")return null;const t=e.trim().toUpperCase();return/^[A-Z]{3}$/.test(t)?t:null}function W(e,t){return t.split(".").reduce((n,r)=>{if(!(n===null||typeof n!="object"))return n[r]},e)}function In(e,t){for(const n of t){const r=Me(W(e,n));if(r)return r}return null}function vn(e){return In(e,["data.calculation.summary.selected_payment_currency","data.calculation.metadata.payment_currency","data.calculation.metadata.currency","data.currency","data.currency_code"])}function An(e){const t=vn(e);t&&(q()[xe]=t)}function Ne(){const e=q(),n=e.__templateApp?.globalState,r=[e[xe],n?.preferredCurrency];try{r.push(window.localStorage?.getItem("g7_preferred_currency"))}catch{}r.push(W(n,"modules.sirsoft-ecommerce.language_currency.default_currency"),W(n,"modules.sirsoft-ecommerce.language_currency.currencies.0.code"));for(const o of r){const i=Me(o);if(i)return i}return null}function V(){return B.test(window.location.pathname)&&Ne()==="JPY"}function z(){if(!B.test(window.location.pathname))return!1;const e=Ne();return e!==null&&e!=="JPY"}async function Le(e){return S!==null||(S=(async()=>{try{const t=await e(kn,{headers:{Accept:"application/json"}});return t.ok?(await t.json()).data?.japan_restrict_jpy_payment_methods===!0:!1}catch{return!1}})()),S}function Tn(e){const t=e.data?.order_settings?.payment_methods;return Array.isArray(t)?{...e,data:{...e.data,order_settings:{...e.data?.order_settings,payment_methods:t.map(n=>{const r=typeof n.id=="string"?n.id:"";return!Re.has(r)||U.has(r)?n:{...n,is_active:!1,_kginicis_restricted_for_jpy:!0}})}}}:e}function xn(e){const t=e.data?.order_settings?.payment_methods;return Array.isArray(t)?{...e,data:{...e.data,order_settings:{...e.data?.order_settings,payment_methods:t.map(n=>{const r=typeof n.id=="string"?n.id:"";return H.has(r)?{...n,is_active:!1,_kginicis_restricted_for_non_jpy:!0}:n})}}}:e}function Rn(e,t){const n=new Headers(e.headers);return n.set("Content-Type","application/json"),new Response(JSON.stringify(t),{status:e.status,statusText:e.statusText,headers:n})}function Mn(){const e=Array.from(document.querySelectorAll("h2")).find(n=>{const r=n.textContent??"";return r.includes("결제 수단")||r.includes("결제 방법")||r.includes("Payment Method")});if(!e)return null;let t=e.parentElement;for(;t&&t!==document.body;){if(t.tagName==="DIV"&&String(t.className).includes("rounded-lg"))return t;t=t.parentElement}return null}function Nn(e){const t=e.querySelector("p")?.textContent?.trim()??e.textContent?.trim()??"";for(const n of Cn)if(n.labels.some(r=>t.includes(r)))return n.id;return null}function X(){const e=V(),t=z();if(!e&&!t)return!1;const n=Mn();if(!n)return!1;let r=!1;if(n.querySelectorAll('button[type="button"]').forEach(o=>{const i=Nn(o);if(!i||!Re.has(i))return;if(e?U.has(i):!H.has(i)){o.style.removeProperty("display"),o.disabled=!1,o.removeAttribute("aria-hidden");return}o.style.display="none",o.disabled=!0,o.setAttribute("aria-hidden","true"),e?o.dataset.kginicisRestrictedForJpy="true":o.dataset.kginicisRestrictedForNonJpy="true",r=!0}),r)try{const i=window.G7Core?.state?.getLocal?.()?.paymentMethod;typeof i=="string"&&(e&&!U.has(i)||t&&H.has(i))&&window.G7Core?.state?.setLocal?.({paymentMethod:"card"})}catch{}return r}async function Oe(e){if(!B.test(window.location.pathname))return;const t=V(),n=z();if(!t&&!n||t&&!await Le(e))return;X(),F===null&&(F=new MutationObserver(()=>{X()}),F.observe(document.body,{childList:!0,subtree:!0})),b!==null&&clearInterval(b);let r=0;b=setInterval(()=>{r++,X(),r>=50&&b!==null&&(clearInterval(b),b=null)},200)}function $e(e){S=null,Oe(e)}function Ln(){if(typeof window>"u"||typeof window.fetch!="function")return;const e=q();if(e[Te])return;e[Te]=!0;const t=window.fetch.bind(window);window.fetch=async function(i,s){const a=En(i),c=Sn(i,s),u=Pn(a),d=await t(i,s);if(c==="GET"&&u.endsWith(wn)){try{const f=await d.clone().json();An(f)}catch{}return d}if(c!=="GET"||!u.endsWith(bn))return d;const l=V(),m=z();if(!l&&!m||l&&!await Le(t))return d;try{const f=await d.clone().json(),p=l?Tn(f):xn(f);return J.info(l?"restricted checkout payment methods for JPY order":"hid Japan-only payment methods for non-JPY order"),Rn(d,p)}catch(f){return J.warn("failed to restrict checkout payment methods",f),d}},J.info("checkout JPY payment method restrictor installed");const n=()=>{Oe(t)};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",n):n();const r=history.pushState.bind(history);history.pushState=(...o)=>{r(...o),setTimeout(()=>$e(t),200)},window.addEventListener("popstate",()=>setTimeout(()=>$e(t),200))}const On="sirsoft-pay_kginicis",De="__kginicisAdminPaymentMethodBrandInjectorInstalled",Ke="__kginicisAdminPaymentMethodBrandSyncListenerAttached",$n=/^\/admin\/ecommerce\/settings\/?$/,Ge='[data-kginicis-admin-payment-brand-mark="true"]',Dn=200,Kn=120,Gn=[{id:"kginicis_naverpay",labels:["네이버페이 (KG이니시스)","Naver Pay (KG Inicis)"],shortLabels:[],markLines:["N","Pay"],markClassName:"bg-green-500 text-white"},{id:"kginicis_samsung_pay",labels:["삼성페이 (KG이니시스)","Samsung Pay (KG Inicis)"],shortLabels:[],markLines:["S","Pay"],markClassName:"bg-blue-700 text-white"},{id:"kginicis_lpay",labels:["L.pay (KG이니시스)","L.pay (KG Inicis)"],shortLabels:[],markLines:["L","pay"],markClassName:"bg-red-600 text-white"},{id:"kginicis_kakaopay",labels:["카카오페이 (KG이니시스)","Kakao Pay (KG Inicis)"],shortLabels:[],markLines:["Kakao","Pay"],markClassName:"bg-yellow-400 text-gray-950"},{id:"kginicis_japan_paypay",labels:["PayPay (일본 KG이니시스)","PayPay (KG Inicis Japan)"],shortLabels:[],markLines:["Pay","Pay"],markClassName:"bg-red-600 text-white"},{id:"kginicis_japan_cvs",labels:["일본 편의점결제 (KG이니시스)","Japan Convenience Store (KG Inicis)"],shortLabels:[],markLines:["CVS","JP"],markClassName:"bg-blue-600 text-white"}];let je=null,T=null,Z=!1;function jn(){return window}function Q(){if(!$n.test(window.location.pathname))return!1;const e=new URLSearchParams(window.location.search).get("tab");return e===null||e===""||e==="order_settings"}function Yn(e){return e instanceof HTMLElement?e.className:""}function Ye(e){return(e??"").replace(/\u200B/g,"").replace(/\s+/g," ").trim()}function Bn(e){const t=Yn(e);return t.includes("excel-card")||t.includes("flex-center")&&t.includes("border")&&t.includes("gap-4")}function Be(e){return e.querySelector(".font-medium")}function Un(e){const t=Ye(Be(e)?.textContent),n=Ye(e.textContent);return Gn.find(r=>r.shortLabels.some(o=>t===o)||r.labels.some(o=>n.includes(o)))??null}function Hn(e){return Array.from(e.querySelectorAll("div")).filter(Bn)}function Jn(e){return Array.from(e.querySelectorAll("svg, [data-icon], i")).find(n=>!n.closest("[data-drag-handle]")&&!n.closest(Ge)&&!n.closest('.row-stack, .excel-card-body, button, select, [role="switch"]'))??null}function Ue(e,t){e.dataset.kginicisAdminPaymentBrandMark="true",e.dataset.kginicisAdminPaymentMethod=t.id,e.setAttribute("aria-hidden","true"),e.className=`inline-flex items-center justify-center rounded-lg font-bold ${t.markClassName}`,e.style.width="32px",e.style.height="32px",e.style.flex="0 0 32px",e.style.flexDirection="column",e.style.lineHeight="1",e.style.fontSize="9px";const n=t.markLines.join("");e.textContent!==n&&(e.replaceChildren(),t.markLines.forEach(r=>{const o=document.createElement("span");o.style.lineHeight="1",o.textContent=r,e.appendChild(o)}))}function Fn(e){const t=document.createElement("span");return Ue(t,e),t}function qn(e,t){const n=e.querySelector(Ge);if(n instanceof HTMLSpanElement)return e.dataset.kginicisAdminPaymentMethod=t.id,Ue(n,t),!0;const r=Fn(t),o=Jn(e);if(o&&o.parentElement)return e.dataset.kginicisAdminPaymentMethod=t.id,o.replaceWith(r),!0;const i=Be(e);return i&&i.parentElement?(e.dataset.kginicisAdminPaymentMethod=t.id,i.parentElement.insertBefore(r,i),!0):!1}function ee(e=document){if(typeof window>"u"||typeof document>"u"||!Q())return!1;let t=!1;return Hn(e).forEach(n=>{const r=Un(n);r&&qn(n,r)&&(t=!0)}),t}function Wn(){Z||(Z=!0,window.setTimeout(()=>{Z=!1,ee()},0))}function te(){T!==null&&(window.clearInterval(T),T=null)}function ne(){if(!Q())return;te(),ee();let e=0;T=window.setInterval(()=>{e+=1,ee(),e>=Kn&&te()},Dn);const t=document.body;t[Ke]||(t[Ke]=!0,je=new MutationObserver(()=>Wn()),je.observe(document.body,{childList:!0,subtree:!0,characterData:!0}))}function He(){if(Q()){ne();return}te()}function Vn(){if(typeof window>"u"||typeof document>"u")return;const e=jn();if(e[De])return;e[De]=!0,console.info(`[${On}] admin payment method brand sync installed`),document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>ne()):ne();const t=history.pushState.bind(history);history.pushState=(...n)=>{t(...n),window.setTimeout(()=>He(),200)},window.addEventListener("popstate",()=>window.setTimeout(()=>He(),200))}const k="sirsoft-pay_kginicis",re={info:(...e)=>console.info(`[${k}]`,...e),warn:(...e)=>console.warn(`[${k}]`,...e),error:(...e)=>console.error(`[${k}]`,...e)};function Je(){const e=window.G7Core;if(!e)return 0;const t=e.getActionDispatcher;if(typeof t!="function")return 0;const n=t();if(!n||typeof n.registerHandler!="function")return 0;let r=0;for(const[o,i]of Object.entries(ye)){const s=`${k}.${o}`;n.registerHandler(s,i,{category:"plugin",source:k}),r++}return r}function Fe(){const e=()=>{const t=Je();if(t>0){re.info(`${t} handler(s) registered`);return}let n=0;const r=50,o=setInterval(()=>{n++;const i=Je();if(i>0){clearInterval(o),re.info(`${i} handler(s) registered (after ${n} retries)`);return}n>=r&&(clearInterval(o),re.warn("ActionDispatcher not available after timeout"))},100)};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",e):e()}Et(),Ln(),$t(),qt(),Vn(),tn(),gn(),at(),Fe(),window.__SirsoftKginicis={identifier:k,handlers:Object.keys(ye),initPlugin:Fe}})(); -//# sourceMappingURL=plugin.iife.js.map diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/components.json b/plugins/_bundled/sirsoft-pay_nhnkcp/components.json index d4a939eb..b713834c 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/components.json +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/components.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "identifier": "sirsoft-pay_nhnkcp", - "version": "1.0.0-beta.4", + "version": "1.0.0", "components": { "basic": [], "composite": [], diff --git a/plugins/_bundled/sirsoft-pay_nhnkcp/dist/js/plugin.iife.js b/plugins/_bundled/sirsoft-pay_nhnkcp/dist/js/plugin.iife.js index 2438c6db..4ccf9cfa 100644 --- a/plugins/_bundled/sirsoft-pay_nhnkcp/dist/js/plugin.iife.js +++ b/plugins/_bundled/sirsoft-pay_nhnkcp/dist/js/plugin.iife.js @@ -15,4 +15,3 @@ try { } <\/script> `),Q.close(),y=window.setTimeout(()=>{N(new Error("KCP SDK load timeout"))},15e3),f.__kcpReady=()=>{y!==void 0&&(clearTimeout(y),y=void 0)}})}const Xe={nhnkcp_payco:"PAYCO",nhnkcp_naverpay:"네이버페이",nhnkcp_naverpay_point:"네이버페이 포인트",nhnkcp_kakaopay:"카카오페이",nhnkcp_applepay:"Apple Pay"},Ze="0 0 0 2px #ffffff, 0 0 0 5px rgba(0,0,0,0.55)";function ne(){return document.querySelector(".nhnkcp-easy-pay-btns")??null}function oe(){const e=ne();e&&e.querySelectorAll("button").forEach(t=>{t.style.boxShadow="",t.style.outline=""})}function et(e){const t=ne();if(!t)return;const n=Xe[e];t.querySelectorAll("button").forEach(o=>{o.textContent?.trim()===n?(o.style.boxShadow=Ze,o.style.outline="none"):(o.style.boxShadow="",o.style.outline="")})}function tt(e){const t=e.params?.paymentMethod;if(!t)return;const n=window.G7Core;if(S(t)&&!x()){const r=M();n?.state?.setLocal?.({paymentErrorMessage:r,isSubmittingOrder:!1}),n?.modal?.open?.("nhnkcp_payment_error_modal"),oe();return}const o=typeof t=="string"&&t.startsWith("nhnkcp_");n?.state?.setLocal?.({paymentMethod:t}),o?et(t):oe()}function nt(e){const t=document.createElement("textarea");t.value=e,t.setAttribute("readonly","readonly"),t.style.position="fixed",t.style.left="-9999px",t.style.top="0",document.body.appendChild(t),t.select();try{document.execCommand("copy")}finally{t.remove()}}async function ot(e){const t=String(e.params?.text??"");if(t!==""){if(navigator.clipboard&&typeof navigator.clipboard.writeText=="function"){await navigator.clipboard.writeText(t);return}nt(t)}}const re={requestPayment:Je,setPaymentMethod:tt,copyToClipboard:ot},ae="/api/modules/sirsoft-ecommerce/user/orders",$="sirsoft-pay_nhnkcp",se={info:(...e)=>console.info(`[${$}]`,...e),warn:(...e)=>console.warn(`[${$}]`,...e),error:(...e)=>console.error(`[${$}]`,...e)};function rt(e){return typeof e=="string"?e:e instanceof URL?e.toString():typeof Request<"u"&&e instanceof Request?e.url:String(e)}function at(e,t){return t?.method?t.method.toUpperCase():typeof Request<"u"&&e instanceof Request?e.method.toUpperCase():"GET"}function st(e,t){if(t!=="POST")return!1;const n=e.split("?")[0].split("#")[0];return n===ae||n.endsWith(ae)}function it(e){try{return JSON.parse(e).payment_method}catch{return}}function ct(e){return new Response(JSON.stringify({success:!1,message:e,error:e,errors:{payment_method:[e]}}),{status:422,statusText:"Unprocessable Content",headers:{"Content-Type":"application/json"}})}function dt(){if(typeof window>"u"||typeof window.fetch!="function")return;const e="__sirsoftNhnkcpInterceptorInstalled",t=window;if(t[e])return;t[e]=!0;const n=window.fetch.bind(window);window.fetch=async function(r,a){const s=rt(r),i=at(r,a);if(!st(s,i))return n(r,a);let c;if(a?.body&&typeof a.body=="string"&&(c=it(a.body)),S(c)&&!x()){const d=M();return se.warn(d),ct(d)}return n(r,a)},se.info("order request interceptor installed (Apple Pay device guard)")}const L="sirsoft-pay_nhnkcp",ie="__kcpOcReceiptInjectorInstalled",ce="kcp-oc-receipt-btn",lt=/^\/shop\/orders\/([^/]+)\/complete$/;function de(){return localStorage.getItem("auth_token")}async function ut(e){const t=de();if(!t)return null;try{const n=await fetch(`/api/modules/sirsoft-ecommerce/user/orders/${e}`,{headers:{Authorization:`Bearer ${t}`,Accept:"application/json"}});return n.ok?(await n.json())?.data?.payment??null:null}catch{return null}}async function O(e){const t=de();if(!t)return null;try{const n=await fetch(`/api/plugins/${L}/user/orders/${e}/receipt`,{headers:{Authorization:`Bearer ${t}`,Accept:"application/json"}});return n.ok?await n.json():null}catch{return null}}function le(e,t,n){const o=document.createElement("button");return o.type="button",o.className=t,o.textContent=e,o.addEventListener("click",()=>n(o)),o}function pt(e){if(!e)return!1;const t=Array.from(document.querySelectorAll("div"));for(const n of t){const o=Array.from(n.children).filter(s=>s instanceof HTMLElement&&s.tagName==="SPAN");if(o.length<2)continue;const r=o[0].textContent?.trim();if(r!=="결제 방법"&&r!=="결제수단"&&r!=="결제 방식")continue;const a=o[o.length-1];return a.textContent?.trim()===e?(n.dataset.nhnkcpPaymentMethodRow="true",!0):(a.textContent=e,a.dataset.nhnkcpPaymentMethodPatched="true",n.dataset.nhnkcpPaymentMethodRow="true",!0)}return!1}async function mt(e){if(document.getElementById(ce))return;const t=await ut(e);if(!t||t.pg_provider!=="nhnkcp"||!t.transaction_id||!t.paid_at)return;const n=Array.from(document.querySelectorAll('button[type="button"]')).find(c=>c.className.includes("bg-blue-600"));if(!n?.parentElement)return;const o=await O(e);pt(o?.payment_method_display_label);const r=n.parentElement,a=le("영수증 조회",n.className.replace(/bg-blue-\d+/g,"bg-green-600").replace(/hover:bg-blue-\d+/g,"hover:bg-green-700"),async c=>{c.disabled=!0,c.textContent="로딩 중...";const d=await O(e);if(c.disabled=!1,c.textContent="영수증 조회",d){const l=window.KcpReceiptPopup;l&&new l({url:d.receipt_url??void 0})}});a.id=ce;const s=r.lastElementChild;r.insertBefore(a,s);const i="kcp-oc-cash-receipt-btn";if(!document.getElementById(i)&&(t.payment_method==="vbank"||t.payment_method==="bank_transfer")){const c=le("현금영수증 조회",a.className.replace(/bg-green-\d+/g,"bg-teal-600").replace(/hover:bg-green-\d+/g,"hover:bg-teal-700"),async d=>{d.disabled=!0,d.textContent="로딩 중...";const l=await O(e);if(d.disabled=!1,d.textContent="현금영수증 조회",l){const u=window.KcpReceiptPopup;u&&new u({cash_url:l.cash_receipt_url??void 0})}});c.id=i,r.insertBefore(c,s)}console.info(`[${L}] receipt button injected on order complete page`)}function ft(){const t=location.pathname.match(lt);t&&mt(t[1])}function yt(){if(typeof window>"u")return;const e=window;if(e[ie])return;e[ie]=!0,console.info(`[${L}] order complete receipt injector installed`);const t=(o=1200)=>setTimeout(ft,o);document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>t()):t();const n=history.pushState.bind(history);history.pushState=(...o)=>{n(...o),t()},window.addEventListener("popstate",()=>t(500))}const _="sirsoft-pay_nhnkcp",ue="__kcpMpShowInjectorInstalled",K="kcp-mp-vbank-row",j="kcp-mp-receipt-row",v="kcp-mp-mock-deposit",ht=/^\/mypage\/orders\/([^/]+)$/;function _t(e){try{const n=window.G7Core?.getState,a=n?.()?.currentDataContext?.order?.data;return!a||a.order_number!==e?null:a}catch{return null}}function pe(){return localStorage.getItem("auth_token")}async function me(e){const t=pe();if(!t)return null;try{const n=await fetch(`/api/plugins/${_}/user/orders/${e}/receipt`,{headers:{Authorization:`Bearer ${t}`,Accept:"application/json"}});return n.ok?await n.json():null}catch{return null}}async function fe(e){const t=pe();if(!t)return null;try{const n=await fetch(`/api/plugins/${_}/user/orders/${e}/vbank-mock-deposit-info`,{headers:{Authorization:`Bearer ${t}`,Accept:"application/json"}});return n.ok?await n.json():null}catch{return null}}function ye(e){const t=document.createElement("div");t.id=v,t.className="pt-4 mt-2 border-t border-dashed border-orange-300 dark:border-orange-700";const n=document.createElement("div");n.className="flex items-center gap-2 mb-3";const o=document.createElement("span");o.className="inline-flex items-center px-2 py-0.5 rounded text-xs font-bold bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300 border border-orange-300 dark:border-orange-700",o.textContent="TEST";const r=document.createElement("p");r.className="text-sm font-semibold text-orange-700 dark:text-orange-300",r.textContent="모의입금처리",n.appendChild(o),n.appendChild(r);const a=document.createElement("form");a.method="POST",a.action=e.mock_url??"",a.target="_blank",a.className="space-y-2";const s=[{label:"KCP 거래번호",name:"e_trade_no",value:e.trade_no??""},{label:"입금계좌",name:"deposit_no",value:e.account_no??""},{label:"입금자명",name:"req_name",value:"NHN KCP",editable:!0},{label:"입금통보 URL",name:"noti_url",value:e.notify_url??""}];for(const d of s){const l=document.createElement("div");l.className="flex items-center gap-2 text-sm";const u=document.createElement("span");u.className="w-28 flex-shrink-0 text-gray-500 dark:text-gray-400 text-xs",u.textContent=d.label;const p=document.createElement("input");p.type="text",p.name=d.name,p.value=d.value,p.readOnly=!d.editable,p.className=d.editable?"flex-1 px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono":"flex-1 px-2 py-1 text-xs border border-gray-200 dark:border-gray-700 rounded bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300 font-mono",l.appendChild(u),l.appendChild(p),a.appendChild(l)}const i=document.createElement("div");i.className="pt-1";const c=document.createElement("button");if(c.type="submit",c.className="w-full py-1.5 text-xs font-semibold rounded border border-orange-400 dark:border-orange-600 bg-orange-50 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 hover:bg-orange-100 dark:hover:bg-orange-900/50 transition-colors",c.textContent="입금통보 테스트",i.appendChild(c),a.appendChild(i),t.appendChild(n),t.appendChild(a),e.is_admin_view){const d=document.createElement("p");d.className="mt-2 text-xs text-center text-orange-500 dark:text-orange-400 font-medium",d.textContent="관리자에게만 출력되는 내용",t.appendChild(d)}return t}function he(){const e=document.getElementById("order_payment_info_panel");if(e)return Array.from(e.children).find(a=>a.className?.includes("space-y"))??e;const t=Array.from(document.querySelectorAll("h3")).find(r=>r.textContent?.includes("결제 정보"));if(!t)return null;const n=t.parentElement?.parentElement;return n?Array.from(n.children).find(r=>r.className?.includes("space-y"))??n:null}function w(e,t,n="font-medium text-gray-900 dark:text-white"){const o=document.createElement("div");o.className="flex items-center justify-between text-sm";const r=document.createElement("span");r.className="text-gray-500 dark:text-gray-400",r.textContent=e;const a=document.createElement("span");return a.className=n,a.textContent=t,o.appendChild(r),o.appendChild(a),o}function bt(e,t){if(!t)return!1;const n=Array.from(e.querySelectorAll("div"));for(const o of n){const r=Array.from(o.children).filter(i=>i instanceof HTMLElement&&i.tagName==="SPAN");if(r.length<2)continue;const a=r[0].textContent?.trim();if(a!=="결제 방법"&&a!=="결제수단"&&a!=="Payment Method")continue;const s=r[r.length-1];return s.textContent?.trim()===t?(o.dataset.nhnkcpPaymentMethodRow="true",!0):(s.textContent=t,s.dataset.nhnkcpPaymentMethodPatched="true",o.dataset.nhnkcpPaymentMethodRow="true",!0)}return!1}function gt(e,t){const n=document.createElement("div");n.id=K,n.className="pt-4 mt-2 border-t border-gray-200 dark:border-gray-700 space-y-3";const o=document.createElement("p");o.className="text-sm font-semibold text-gray-800 dark:text-gray-200",o.textContent="가상계좌 입금 안내";const r=document.createElement("div");r.className="space-y-1.5",r.appendChild(w("은행",e.vbank_name??"")),r.appendChild(w("계좌번호",e.vbank_number??"","font-medium text-gray-900 dark:text-white font-mono tracking-wide")),r.appendChild(w("예금주",e.vbank_holder??"")),r.appendChild(w("입금 금액",t)),e.due_date_formatted&&r.appendChild(w("입금 기한",e.due_date_formatted));const a=document.createElement("p");return a.className="text-xs text-amber-600 dark:text-amber-400",a.textContent="입금 기한 내에 입금하지 않으시면 주문이 자동 취소됩니다.",n.appendChild(o),n.appendChild(r),n.appendChild(a),n}function wt(e){const t=document.createElement("div");t.id=j,t.className="pt-4 mt-2 border-t border-gray-200 dark:border-gray-700";const n=document.createElement("div");n.className="flex items-center justify-between";const o=document.createElement("span");o.className="text-gray-500 dark:text-gray-400 text-sm",o.textContent="영수증";const r=document.createElement("button");return r.type="button",r.className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline disabled:opacity-50",r.textContent="영수증 조회",r.addEventListener("click",async()=>{r.disabled=!0,r.textContent="로딩 중...";const a=await me(e);if(r.disabled=!1,r.textContent="영수증 조회",a){const s=window.KcpReceiptPopup;s&&new s({url:a.receipt_url,cash_url:a.cash_receipt_url})}}),n.appendChild(o),n.appendChild(r),t.appendChild(n),t}async function kt(e){const t=_t(e);if(!t){if(!document.getElementById(v)){const p=he();if(!p)return!1;const f=await fe(e);f?.available&&(p.appendChild(ye(f)),console.info(`[${_}] mock deposit form injected on mypage order show`))}return!0}const{payment:n}=t;if(!n||n.pg_provider!=="nhnkcp")return!0;const o=n.payment_method==="vbank"&&!!n.vbank_name,r=!!n.transaction_id&&!!n.paid_at,a=n.payment_method==="vbank"&&!!n.vbank_number,s=!o||!!document.getElementById(K),i=!r||!!document.getElementById(j),c=!a||!!document.getElementById(v);if(s&&i&&c)return!0;const d=he();if(!d)return!1;const l=r?await me(e):null,u=bt(d,l?.payment_method_display_label);if(!document.getElementById(K)&&o)return d.appendChild(gt(n,t.total_amount_formatted??"")),console.info(`[${_}] vbank info injected on mypage order show`),!1;if(!document.getElementById(j)&&r)return d.appendChild(wt(e)),console.info(`[${_}] receipt button injected on mypage order show`),!1;if(u&&console.info(`[${_}] payment method display patched on mypage order show`),!document.getElementById(v)&&a){const p=await fe(e);if(p?.available)d.appendChild(ye(p)),console.info(`[${_}] mock deposit form injected on mypage order show`);else return!0}return!0}function Ct(e){let t=0;const n=setInterval(()=>{t++,kt(e).then(o=>{(o||t>=30)&&clearInterval(n)})},400)}function xt(){const e=location.pathname.match(ht);e&&Ct(e[1])}function vt(){if(typeof window>"u")return;const e=window;if(e[ue])return;e[ue]=!0,console.info(`[${_}] mypage order show injector installed`);const t=(o=1500)=>setTimeout(xt,o);document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>t()):t();const n=history.pushState.bind(history);history.pushState=(...o)=>{n(...o),t(600)},window.addEventListener("popstate",()=>t(500))}const Pt="sirsoft-pay_nhnkcp",_e="__kcpAdminApplePayNoticeInjectorInstalled",Et=/^\/admin\/ecommerce\/settings\/?$/,be="__kcpAdminApplePayNoticeListenerAttached",Nt="애플페이는 IOS 기기에 모바일결제만 가능합니다.",At="Apple Pay is available only for mobile payments on iOS devices.";let ge=null,P=null,B=!1;function It(){return window}function D(){if(!Et.test(window.location.pathname))return!1;const e=new URLSearchParams(window.location.search).get("tab");return e===null||e==="order_settings"}function we(){return(document.documentElement.lang||navigator.language||"").toLowerCase().startsWith("ko")?Nt:At}function ke(e){return(e??"").replace(/\s+/g," ").trim()}function St(e){const t=ke(e.textContent);return t==="애플페이"||t==="Apple Pay"}function Mt(){const e=document.createElement("span");return e.dataset.nhnkcpAdminApplePayNotice="true",e.className="text-label-subtle",e.style.display="block",e.style.width="100%",e.style.flexBasis="100%",e.style.marginTop="2px",e.style.fontSize="12px",e.style.lineHeight="1rem",e.textContent=we(),e}function H(e=document){if(typeof window>"u"||typeof document>"u"||!D()||ke(document.body?.innerText??document.body?.textContent).includes(we()))return!1;const n=Array.from(e.querySelectorAll("span, p, div")).find(St);return n?(n.insertAdjacentElement("afterend",Mt()),!0):!1}function Tt(){B||(B=!0,window.setTimeout(()=>{B=!1,H()},0))}function Ce(){P!==null&&(window.clearInterval(P),P=null)}function F(){if(!D())return;Ce(),H();let e=0;P=window.setInterval(()=>{e+=1,(H()||e>=50)&&Ce()},200);const t=document.body;t[be]||(t[be]=!0,ge=new MutationObserver(()=>Tt()),ge.observe(document.body,{childList:!0,subtree:!0,characterData:!0}))}function xe(){D()&&F()}function Rt(){if(typeof window>"u"||typeof document>"u")return;const e=It();if(e[_e])return;e[_e]=!0,console.info(`[${Pt}] admin Apple Pay notice injector installed`),document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>F()):F();const t=history.pushState.bind(history);history.pushState=(...n)=>{t(...n),window.setTimeout(()=>xe(),200)},window.addEventListener("popstate",()=>window.setTimeout(()=>xe(),200))}const $t="sirsoft-pay_nhnkcp",ve="__nhnkcpAdminPaymentMethodBrandInjectorInstalled",Pe="__nhnkcpAdminPaymentMethodBrandSyncListenerAttached",Lt=/^\/admin\/ecommerce\/settings\/?$/,Ee='[data-nhnkcp-admin-payment-brand-mark="true"]',Ot=200,Kt=120,jt=[{id:"nhnkcp_payco",labels:["PAYCO (NHN KCP)","PAYCO로 결제 (NHN KCP)","Pay with PAYCO (NHN KCP)"],shortLabels:["PAYCO"],markLines:["P"],markClassName:"bg-red-500 text-white"},{id:"nhnkcp_naverpay",labels:["네이버페이 (NHN KCP)","네이버페이 신용카드로 결제 (NHN KCP)","Naver Pay (NHN KCP)","Pay by Naver Pay credit card (NHN KCP)"],shortLabels:["네이버페이 (카드)","Naver Pay (Card)"],markLines:["N"],markClassName:"bg-green-500 text-white"},{id:"nhnkcp_naverpay_point",labels:["네이버페이 포인트 (NHN KCP)","네이버페이 머니/포인트로 결제 (NHN KCP)","Naver Pay Point (NHN KCP)","Pay with Naver Pay Money/Points (NHN KCP)"],shortLabels:["네이버페이 (포인트)","Naver Pay (Point)"],markLines:["NP"],markClassName:"bg-green-600 text-white"},{id:"nhnkcp_kakaopay",labels:["카카오페이 (NHN KCP)","카카오페이로 결제 (NHN KCP)","Kakao Pay (NHN KCP)","Pay with Kakao Pay (NHN KCP)"],shortLabels:["카카오페이","Kakao Pay"],markLines:["K"],markClassName:"bg-yellow-400 text-gray-950"},{id:"nhnkcp_applepay",labels:["애플페이 (NHN KCP)","애플페이로 결제 (NHN KCP)","Apple Pay (NHN KCP)","Pay with Apple Pay (NHN KCP)"],shortLabels:["애플페이","Apple Pay"],markLines:["A"],markClassName:"bg-gray-900 text-white"}];let Ne=null,E=null,q=!1;function Bt(){return window}function U(){if(!Lt.test(window.location.pathname))return!1;const e=new URLSearchParams(window.location.search).get("tab");return e===null||e===""||e==="order_settings"}function Dt(e){return e instanceof HTMLElement?e.className:""}function Ae(e){return(e??"").replace(/\u200B/g,"").replace(/\s+/g," ").trim()}function Ht(e){const t=Dt(e);return t.includes("excel-card")||t.includes("flex-center")&&t.includes("border")&&t.includes("gap-4")}function Ie(e){return e.querySelector(".font-medium")}function Ft(e){const t=Ae(Ie(e)?.textContent),n=Ae(e.textContent);return jt.find(o=>o.shortLabels.some(r=>t===r)||o.labels.some(r=>n.includes(r)))??null}function qt(e){return Array.from(e.querySelectorAll("div")).filter(Ht)}function Ut(e){return Array.from(e.querySelectorAll("svg, [data-icon], i")).find(n=>!n.closest("[data-drag-handle]")&&!n.closest(Ee)&&!n.closest('.row-stack, .excel-card-body, button, select, [role="switch"]'))??null}function Se(e,t){e.dataset.nhnkcpAdminPaymentBrandMark="true",e.dataset.nhnkcpAdminPaymentMethod=t.id,e.setAttribute("aria-hidden","true"),e.className=`inline-flex items-center justify-center rounded-lg font-bold ${t.markClassName}`,e.style.width="32px",e.style.height="32px",e.style.flex="0 0 32px",e.style.lineHeight="1",e.style.fontSize=t.markLines.join("").length>2?"9px":"12px";const n=t.markLines.join("");e.textContent!==n&&(e.replaceChildren(),t.markLines.length>1?(e.style.flexDirection="column",t.markLines.forEach(o=>{const r=document.createElement("span");r.style.lineHeight="1",r.textContent=o,e.appendChild(r)})):(e.style.removeProperty("flex-direction"),e.textContent=t.markLines[0]??""))}function Yt(e){const t=document.createElement("span");return Se(t,e),t}function Gt(e,t){const n=e.querySelector(Ee);if(n instanceof HTMLSpanElement)return e.dataset.nhnkcpAdminPaymentMethod=t.id,Se(n,t),!0;const o=Yt(t),r=Ut(e);if(r&&r.parentElement)return e.dataset.nhnkcpAdminPaymentMethod=t.id,r.replaceWith(o),!0;const a=Ie(e);return a&&a.parentElement?(e.dataset.nhnkcpAdminPaymentMethod=t.id,a.parentElement.insertBefore(o,a),!0):!1}function Y(e=document){if(typeof window>"u"||typeof document>"u"||!U())return!1;let t=!1;return qt(e).forEach(n=>{const o=Ft(n);o&&Gt(n,o)&&(t=!0)}),t}function Wt(){q||(q=!0,window.setTimeout(()=>{q=!1,Y()},0))}function G(){E!==null&&(window.clearInterval(E),E=null)}function W(){if(!U())return;G(),Y();let e=0;E=window.setInterval(()=>{e+=1,Y(),e>=Kt&&G()},Ot);const t=document.body;t[Pe]||(t[Pe]=!0,Ne=new MutationObserver(()=>Wt()),Ne.observe(document.body,{childList:!0,subtree:!0,characterData:!0}))}function Me(){if(U()){W();return}G()}function zt(){if(typeof window>"u"||typeof document>"u")return;const e=Bt();if(e[ve])return;e[ve]=!0,console.info(`[${$t}] admin payment method brand sync installed`),document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>W()):W();const t=history.pushState.bind(history);history.pushState=(...n)=>{t(...n),window.setTimeout(()=>Me(),200)},window.addEventListener("popstate",()=>window.setTimeout(()=>Me(),200))}const z="sirsoft-pay_nhnkcp",Te="__nhnkcpAdminPaymentDisplayInjectorInstalled",Jt=/^\/admin\/ecommerce\/orders\/([^/]+)$/;function Vt(){return localStorage.getItem("auth_token")}async function Qt(e){const t=Vt();if(!t)return null;try{const n=await fetch(`/api/plugins/${z}/admin/orders/${e}/transaction-status`,{headers:{Authorization:`Bearer ${t}`,Accept:"application/json"}});if(!n.ok)return null;const o=await n.json();return("data"in o?o.data:o)??null}catch{return null}}function Xt(e){return Array.from(e.children).filter(t=>t instanceof HTMLElement)}function Zt(e,t){let n=!1;const o=Array.from(e.querySelectorAll("div"));for(const r of o){const a=Xt(r),s=a.findIndex(u=>u.textContent?.trim()==="결제수단");if(s<0)continue;const i=a.slice(s+1).find(u=>u.textContent?.trim()&&u.textContent.trim()!=="결제수단");if(!i)continue;const c=a.slice(s+2).find(u=>/^\(.+\)$/.test(u.textContent?.trim()??"")),d=c?.textContent?.trim().replace(/^\(|\)$/g,"")??"",l=d&&t.endsWith(")")?t.replace(/\)$/,`, ${d})`):t;i.textContent?.trim()!==l&&(i.textContent=l,i.dataset.nhnkcpPaymentMethodPatched="true"),r.dataset.nhnkcpPaymentMethodRow="admin-order",c&&d&&(c.textContent="",c.style.display="none",c.dataset.nhnkcpPaymentInstallmentHidden="true"),n=!0}return n}function en(e,t,n){const r=Array.from(e.querySelectorAll("span")).filter(a=>a.textContent?.trim()===t).find(a=>{const s=typeof a.className=="string"?a.className:"";return s.includes("rounded-full")||s.includes("font-medium")});return r?(r.textContent=n,r.dataset.nhnkcpPaymentMethodPatched="true",r.dataset.nhnkcpPaymentMethodBadge="admin-order",!0):!1}function tn(e,t){const n=t?._pay_method_label,o=t?._embedded_pg_provider_label,r=t?._base_pay_method_label;if(!n||!o)return!1;const a=Zt(e,n),s=r?en(e,r,o):!1;return a||s}async function nn(e){const t=document.getElementById("section_payment_info")??document.getElementById("payment_info_card");if(!t)return!1;const n=await Qt(e);return n&&tn(t,n)&&console.info(`[${z}] payment method display patched on admin order detail`),!0}function on(e){let t=0;const n=setInterval(()=>{t++,nn(e).then(o=>{(o||t>=30)&&clearInterval(n)})},400)}function rn(){const e=location.pathname.match(Jt);e&&on(e[1])}function an(){if(typeof window>"u")return;const e=window;if(e[Te])return;e[Te]=!0,console.info(`[${z}] admin order payment display injector installed`);const t=(o=1500)=>setTimeout(rn,o);document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>t()):t();const n=history.pushState.bind(history);history.pushState=(...o)=>{n(...o),t(600)},window.addEventListener("popstate",()=>t(500))}class sn{constructor(t){const n="width=800,height=600,scrollbars=yes,resizable=yes,toolbar=no,menubar=no";t.url&&window.open(t.url,"kcp_receipt",n),t.cash_url&&window.open(t.cash_url,"kcp_cash_receipt",n)}}window.KcpReceiptPopup=sn;const b="sirsoft-pay_nhnkcp",J={info:(...e)=>console.info(`[${b}]`,...e),warn:(...e)=>console.warn(`[${b}]`,...e),error:(...e)=>console.error(`[${b}]`,...e)};function Re(){const e=window.G7Core;if(!e)return 0;const t=e.getActionDispatcher;if(typeof t!="function")return 0;const n=t();if(!n||typeof n.registerHandler!="function")return 0;let o=0;for(const[r,a]of Object.entries(re)){const s=`${b}.${r}`;n.registerHandler(s,a,{category:"plugin",source:b}),o++}return o}function $e(){const e=()=>{const t=Re();if(t>0){J.info(`${t} handler(s) registered`);return}let n=0;const o=50,r=setInterval(()=>{n++;const a=Re();if(a>0){clearInterval(r),J.info(`${a} handler(s) registered (after ${n} retries)`);return}n>=o&&(clearInterval(r),J.warn("ActionDispatcher not available after timeout"))},100)};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",e):e()}dt(),yt(),vt(),Rt(),zt(),an(),$e(),window.__SirsoftNhnkcp={identifier:b,handlers:Object.keys(re),initPlugin:$e}})(); -//# sourceMappingURL=plugin.iife.js.map diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/components.json b/plugins/_bundled/sirsoft-pay_nicepayments/components.json index 93417209..65500af5 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/components.json +++ b/plugins/_bundled/sirsoft-pay_nicepayments/components.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "identifier": "sirsoft-pay_nicepayments", - "version": "1.0.0-beta.4", + "version": "1.0.0", "components": { "basic": [], "composite": [], diff --git a/plugins/_bundled/sirsoft-pay_nicepayments/dist/js/plugin.iife.js b/plugins/_bundled/sirsoft-pay_nicepayments/dist/js/plugin.iife.js index 14397591..8ce57f50 100644 --- a/plugins/_bundled/sirsoft-pay_nicepayments/dist/js/plugin.iife.js +++ b/plugins/_bundled/sirsoft-pay_nicepayments/dist/js/plugin.iife.js @@ -1,2 +1 @@ (function(){"use strict";function le(e){return/^https?:\/\//i.test(e)||e.startsWith("/api/")?e:e.startsWith("/plugins/")?`/api${e}`:e.startsWith("plugins/")?`/api/${e}`:e}function de(e){return e.trim().slice(0,160)}async function ue(e,t="nicepay-window-closed"){if(!e.closeReportUrl)return;const n={oid:e.oid,price:Number(e.price),buyer_email:e.buyer_email??"",buyer_phone:e.buyer_phone??"",payment_method:e.payment_method??"",reason:de(t)||"nicepay-window-closed"};try{const a=window.G7Core?.api;if(typeof a?.post=="function"){await a.post(e.closeReportUrl,n);return}await fetch(le(e.closeReportUrl),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n),keepalive:!0})}catch(a){console.warn("[sirsoft-pay_nicepayments] failed to report payment window close",a)}}function pe(e){return new Promise((t,n)=>{if(document.querySelector(`script[src="${e}"]`)){t();return}const a=document.createElement("script");a.src=e,a.onload=()=>t(),a.onerror=()=>n(new Error(`Failed to load script: ${e}`)),document.head.appendChild(a)})}function me(e,t){const n=document.createElement("form");n.id="nicepayForm",n.method="post",n.action=e,n.acceptCharset="utf-8";for(const[a,r]of Object.entries(t)){const o=document.createElement("input");o.type="hidden",o.name=a,o.value=r,n.appendChild(o)}return document.body.appendChild(n),n}const ye="/plugins/sirsoft-pay_nicepayments/payment/callback",fe="https://web.nicepay.co.kr/v3/v3Payment.jsp";function he(){if(typeof navigator>"u")return!1;const e=navigator;if(e.userAgentData?.mobile!==void 0)return e.userAgentData.mobile;const t=(navigator.userAgent||"").toLowerCase();return!!(/android|iphone|ipod|windows phone|iemobile|blackberry|opera mini|mobile safari/.test(t)||(navigator.maxTouchPoints??0)>1&&!t.includes("windows")&&!t.includes("macintosh"))}async function be(e,t){const{pgPaymentData:n,paymentMethod:a}=e.params??{},r=window.__templateApp?.globalState?._local,o=a??n?.payment_method??r?.paymentMethod??"card";if(!n){console.error("[sirsoft-pay_nicepayments] pgPaymentData is required");return}const i=window.G7Core;try{const s=await i.api.get("/modules/sirsoft-ecommerce/payments/client-config/nicepayments");if(!s.data){console.error("[sirsoft-pay_nicepayments] Failed to fetch client config",s);return}const l=s.data,d=he();if(!d&&(await pe(l.sdk_url),typeof window.goPay!="function")){i?.toast?.error?.("나이스페이먼츠 SDK를 불러올 수 없습니다. 잠시 후 다시 시도해주세요."),i?.state?.setLocal?.({isSubmittingOrder:!1,paymentMethod:o});return}const w=window.location.origin+l.sign_data_url,u=typeof localStorage<"u"?localStorage.getItem("auth_token"):null,te={"Content-Type":"application/json","X-Requested-With":"XMLHttpRequest",Accept:"application/json"};u&&(te.Authorization=`Bearer ${u}`);const ne=await fetch(w,{method:"POST",credentials:"include",headers:te,body:JSON.stringify({amt:n.amount,moid:n.order_number,buyer_email:n.customer_email??"",buyer_phone:n.customer_phone??""})});if(!ne.ok)throw new Error("SignData 생성에 실패했습니다.");const I=await ne.json(),ae=window.location.origin+ye,L=typeof o=="string"&&o.startsWith("nicepay_"),oe=L?"CARD":{card:"CARD",vbank:"VBANK",bank:"BANK",phone:"CELLPHONE"}[o??"card"]??"CARD",c={PayMethod:oe,GoodsName:n.order_name,Amt:String(n.amount),MID:I.mid,Moid:n.order_number,BuyerName:n.customer_name??"",BuyerEmail:n.customer_email??"",BuyerTel:n.customer_phone??"",ReturnURL:ae,EdiDate:I.ediDate,SignData:I.signData,CharSet:"utf-8",GoodsCl:"1",DirectShowOpt:"",DirectEasyPay:"",NicepayReserved:"",EasyPayMethod:"",MallReserved:"",MallReserved1:""};oe==="CELLPHONE"&&(c.GoodsCl=n.goods_cl??"1");const ft=l.useEscrow&&!L;if(c.TransType=ft?"1":"0",L)switch(c.DirectShowOpt="CARD",c.MallReserved=`nicepay_easy_pay_method=${encodeURIComponent(o)}`,c.MallReserved1=o,o){case"nicepay_naverpay":c.DirectEasyPay="E020",c.EasyPayMethod="E020=CARD";break;case"nicepay_kakaopay":c.NicepayReserved="DirectKakao=Y";break;case"nicepay_samsungpay":c.DirectEasyPay="E021";break;case"nicepay_applepay":c.DirectEasyPay="E025";break;case"nicepay_payco":c.NicepayReserved="DirectPayco=Y";break;case"nicepay_skpay":c.NicepayReserved="DirectPay11=Y";break;case"nicepay_ssgpay":c.DirectEasyPay="E007";break;case"nicepay_lpay":c.DirectEasyPay="E018";break}try{const m=(await i.api.get(`/modules/sirsoft-ecommerce/user/orders/${n.order_number}`))?.data;if(m){const P=Number(m.total_tax_amount??0),h=Number(m.total_vat_amount??0),ce=Number(m.total_tax_free_amount??0);(P>0||h>0||ce>0)&&(c.TaxAmt=String(P),c.VatAmt=String(h),c.TaxFreeAmt=String(ce))}}catch{}const p=me(ae,c);if(d){p.action=fe,p.acceptCharset="euc-kr";const f=p.querySelector('input[name="CharSet"]');f&&(f.value="euc-kr"),p.submit();return}window.nicepaySubmit=()=>{p.submit()};let re=!1;const ht=new Set(document.body.children),ie=(f,m)=>{if(re)return;re=!0;const P=[f,m].filter(Boolean).join(": ")||"nicepay-window-closed";ue({closeReportUrl:l.close_report_url,oid:n.order_number,price:Number(n.amount),buyer_email:n.customer_email??"",buyer_phone:n.customer_phone??"",payment_method:o},P),window.removeEventListener("popstate",se),Array.from(document.body.children).forEach(h=>{ht.has(h)||h.remove()}),p.parentNode&&p.parentNode.removeChild(p),window.__sirsoftNicepayPaymentInProgress=!1,i?.state?.setLocal?.({isSubmittingOrder:!1,paymentMethod:o}),m&&i?.toast?.error?.(m)};window.nicepayClose=ie;const se=()=>ie("","");window.history.pushState({nicepayOpen:!0},""),window.addEventListener("popstate",se),window.goPay(p)}catch(s){console.error("[sirsoft-pay_nicepayments] requestPayment error",s),window.__sirsoftNicepayPaymentInProgress=!1,i?.state?.setLocal?.({isSubmittingOrder:!1,paymentMethod:o}),i?.toast?.error?.("결제 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.")}}const ge={nicepay_naverpay:"네이버페이",nicepay_kakaopay:"카카오페이",nicepay_samsungpay:"삼성페이",nicepay_applepay:"애플페이",nicepay_payco:"PAYCO",nicepay_skpay:"11pay",nicepay_ssgpay:"SSG페이",nicepay_lpay:"L.pay"},_e="0 0 0 2px #ffffff, 0 0 0 5px rgba(0,0,0,0.55)";function M(){const e=document.getElementById("nicepay_checkout_payment_section");if(!e)return null;const t=e.querySelectorAll("p");for(const n of t)if(n.textContent?.includes("간편결제"))return n.nextElementSibling;return null}function D(){const e=M();e&&e.querySelectorAll("button").forEach(t=>{t.style.boxShadow="",t.style.outline=""})}function we(e){const t=M();if(!t)return;const n=ge[e];t.querySelectorAll("button").forEach(a=>{a.textContent?.trim()===n?(a.style.boxShadow=_e,a.style.outline="none"):(a.style.boxShadow="",a.style.outline="")})}function Pe(e){const t=e.params?.paymentMethod;if(!t)return;const n=typeof t=="string"&&t.indexOf("nicepay_")===0;window.G7Core?.state?.setLocal?.({paymentMethod:t}),n?we(t):D()}function R(){const e=window.__templateApp;typeof e?.onGlobalStateChange=="function"&&e.onGlobalStateChange(t=>{const n=t?._local?.paymentMethod;typeof n=="string"&&(n.startsWith("nicepay_")||D())})}const $={requestPayment:be,setPaymentMethod:Pe},Ce="sirsoft-pay_nicepayments",Ee={info:(...e)=>console.info(`[${Ce}]`,...e)};function Se(){Ee.info("order interceptor is a no-op — payment entry is dispatched via pg_payment_handler")}const C="sirsoft-pay_nicepayments",T="__nicepayOcReceiptInjectorInstalled",O="nicepay-oc-receipt-btn",xe=/^\/shop\/orders\/([^/]+)\/complete$/;function B(){return localStorage.getItem("auth_token")}async function Ae(e){const t=B();if(!t)return null;try{const n=await fetch(`/api/modules/sirsoft-ecommerce/user/orders/${e}`,{headers:{Authorization:`Bearer ${t}`,Accept:"application/json"}});return n.ok?(await n.json())?.data?.payment??null:null}catch{return null}}async function j(e){const t=B();if(!t)return null;try{const n=await fetch(`/api/plugins/${C}/user/orders/${e}/receipt`,{headers:{Authorization:`Bearer ${t}`,Accept:"application/json"}});return n.ok?await n.json():null}catch{return null}}function Ne(e){if(!e)return!1;const t=Array.from(document.querySelectorAll("div"));for(const n of t){const a=Array.from(n.children).filter(i=>i instanceof HTMLElement&&i.tagName==="SPAN");if(a.length<2)continue;const r=a[0].textContent?.trim();if(r!=="결제 방법"&&r!=="결제수단"&&r!=="결제 방식")continue;const o=a[a.length-1];return o.textContent?.trim()!==e&&(o.textContent=e,o.dataset.nicepayPaymentMethodPatched="true"),n.dataset.nicepayPaymentMethodRow="true",!0}return!1}async function ke(e){if(document.getElementById(O))return;const t=await Ae(e);if(!t||t.pg_provider!=="nicepayments"||!t.transaction_id||!t.paid_at)return;const n=Array.from(document.querySelectorAll('button[type="button"]')).find(s=>s.className.includes("bg-blue-600"));if(!n?.parentElement)return;const a=await j(e);Ne(a?.payment_method_display_label);const r=n.parentElement,o=document.createElement("button");o.id=O,o.type="button",o.className=n.className.replace(/bg-blue-\d+/g,"bg-green-600").replace(/hover:bg-blue-\d+/g,"hover:bg-green-700"),o.textContent="영수증 조회",o.addEventListener("click",async()=>{o.disabled=!0,o.textContent="로딩 중...";const s=await j(e);o.disabled=!1,o.textContent="영수증 조회";const l=s?.receipt_url??null;l&&window.open(l,"nicepay_receipt","width=800,height=600,scrollbars=yes,resizable=yes")});const i=r.lastElementChild;r.insertBefore(o,i),console.info(`[${C}] receipt button injected on order complete page`)}function ve(){const e=location.pathname.match(xe);e&&ke(e[1])}function Ie(){if(typeof window>"u")return;const e=window;if(e[T])return;e[T]=!0,console.info(`[${C}] order complete receipt injector installed`);const t=(a=1200)=>setTimeout(ve,a);document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>t()):t();const n=history.pushState.bind(history);history.pushState=(...a)=>{n(...a),t()},window.addEventListener("popstate",()=>t(500))}const b="sirsoft-pay_nicepayments",G="__nicepayOrderShowInjectorInstalled",q="nicepay-mp-receipt-row",H="nicepay-mp-vbank-info",Le=/^\/mypage\/orders\/([^/]+)$/;function Me(e){try{const n=window.G7Core?.getState,o=n?.()?.currentDataContext?.order?.data;return!o||o.order_number!==e?null:o}catch{return null}}function De(){return localStorage.getItem("auth_token")}async function F(e){const t=De();if(!t)return null;try{const n=await fetch(`/api/plugins/${b}/user/orders/${e}/receipt`,{headers:{Authorization:`Bearer ${t}`,Accept:"application/json"}});return n.ok?await n.json():null}catch{return null}}function Re(e,t){if(!t)return!1;const n=Array.from(e.querySelectorAll("div"));for(const a of n){const r=Array.from(a.children).filter(s=>s instanceof HTMLElement&&s.tagName==="SPAN");if(r.length<2)continue;const o=r[0].textContent?.trim();if(o!=="결제 방법"&&o!=="결제수단"&&o!=="Payment Method")continue;const i=r[r.length-1];return i.textContent?.trim()!==t&&(i.textContent=t,i.dataset.nicepayPaymentMethodPatched="true"),a.dataset.nicepayPaymentMethodRow="true",!0}return!1}function $e(){const e=document.getElementById("order_payment_info_panel");if(e)return Array.from(e.children).find(a=>a.className?.includes("space-y"))??e;const t=Array.from(document.querySelectorAll("h3")).find(a=>a.textContent?.includes("결제 정보"));if(!t)return null;const n=t.parentElement?.parentElement;return n?Array.from(n.children).find(a=>a.className?.includes("space-y"))??n:null}function Te(e){const t=document.createElement("div");t.id=q,t.className="flex items-center justify-between";const n=document.createElement("span");n.className="text-gray-500 dark:text-gray-400 text-sm",n.textContent="영수증";const a=document.createElement("button");return a.type="button",a.className="inline-flex items-center gap-1 text-sm text-blue-600 dark:text-blue-400 hover:underline disabled:opacity-50",a.textContent="영수증 조회",a.addEventListener("click",async()=>{a.disabled=!0,a.textContent="로딩 중...";const r=await F(e);a.disabled=!1,a.textContent="영수증 조회";const o=r?.receipt_url??null;o&&window.open(o,"nicepay_receipt","width=800,height=600,scrollbars=yes,resizable=yes")}),t.appendChild(n),t.appendChild(a),t}function Oe(e){if(!e)return"";try{const t=new Date(e);if(Number.isNaN(t.getTime()))return e;const n=a=>String(a).padStart(2,"0");return`${t.getFullYear()}-${n(t.getMonth()+1)}-${n(t.getDate())} ${n(t.getHours())}:${n(t.getMinutes())}:${n(t.getSeconds())}`}catch{return e}}function g(e,t,n="font-medium text-gray-900 dark:text-white"){const a=document.createElement("div");a.className="flex justify-between text-sm";const r=document.createElement("span");r.className="text-gray-600 dark:text-gray-400",r.textContent=e;const o=document.createElement("span");return o.className=n,o.textContent=t,a.appendChild(r),a.appendChild(o),a}function Be(e){const t=e.payment??{},n=document.createElement("div");n.id=H,n.className="pt-4 mt-2 border-t border-gray-200 dark:border-gray-700";const a=document.createElement("div");a.className="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded-lg p-4";const r=document.createElement("h3");r.className="text-sm font-semibold text-blue-800 dark:text-blue-300 mb-3",r.textContent="가상계좌 입금 안내";const o=document.createElement("div");o.className="space-y-2",o.appendChild(g("은행",t.vbank_name??"")),o.appendChild(g("계좌번호",t.vbank_number??"","font-medium font-mono text-gray-900 dark:text-white")),o.appendChild(g("예금주",t.vbank_holder??"")),o.appendChild(g("입금 금액",e.total_amount_formatted??"","font-bold text-blue-600 dark:text-blue-400"));const i=t.vbank_due_at;if(i){const s=document.createElement("div");s.className="flex justify-between text-sm pt-2 border-t border-blue-200 dark:border-blue-700 mt-2";const l=document.createElement("span");l.className="text-gray-600 dark:text-gray-400",l.textContent="입금 기한";const d=document.createElement("span");d.className="font-medium text-red-600 dark:text-red-400",d.textContent=Oe(i),s.appendChild(l),s.appendChild(d),o.appendChild(s)}if(a.appendChild(r),a.appendChild(o),!t.paid_at){const s=document.createElement("p");s.className="text-xs text-blue-700 dark:text-blue-300 mt-3",s.textContent="입금 기한 내에 입금이 완료되지 않으면 주문이 자동 취소됩니다.",a.appendChild(s)}return n.appendChild(a),n}async function je(e){const t=Me(e);if(!t)return!1;const{payment:n}=t;if(!n||n.pg_provider!=="nicepayments")return!0;const a=$e();if(!a)return!1;const r=await F(e);return Re(a,r?.payment_method_display_label),n.payment_method==="vbank"&&n.vbank_number&&!document.getElementById(H)&&(a.appendChild(Be(t)),console.info(`[${b}] vbank info injected on mypage order show`)),!n.transaction_id||!n.paid_at||document.getElementById(q)||(a.appendChild(Te(e)),console.info(`[${b}] receipt button injected on mypage order show`)),!0}function Ge(e){let t=0;const n=setInterval(()=>{t++,je(e).then(a=>{(a||t>=30)&&clearInterval(n)})},400)}function qe(){const e=location.pathname.match(Le);e&&Ge(e[1])}function He(){if(typeof window>"u")return;const e=window;if(e[G])return;e[G]=!0,console.info(`[${b}] mypage order show injector installed`);const t=(a=1500)=>setTimeout(qe,a);document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>t()):t();const n=history.pushState.bind(history);history.pushState=(...a)=>{n(...a),t(600)},window.addEventListener("popstate",()=>t(500))}const Fe="sirsoft-pay_nicepayments",U="__nicepayAdminPaymentMethodBrandInjectorInstalled",Y="__nicepayAdminPaymentMethodBrandSyncListenerAttached",Ue=/^\/admin\/ecommerce\/settings\/?$/,W='[data-nicepay-admin-payment-brand-mark="true"]',Ye=200,We=120,Ke=[{id:"nicepay_naverpay",labels:["네이버페이 (나이스페이먼츠)","네이버페이로 결제 (나이스페이먼츠)","Naver Pay (NicePayments)","Pay with Naver Pay (NicePayments)"],shortLabels:[],markLines:["N"],markClassName:"bg-green-500 text-white"},{id:"nicepay_kakaopay",labels:["카카오페이 (나이스페이먼츠)","카카오페이로 결제 (나이스페이먼츠)","Kakao Pay (NicePayments)","Pay with Kakao Pay (NicePayments)"],shortLabels:[],markLines:["K"],markClassName:"bg-yellow-400 text-gray-950"},{id:"nicepay_samsungpay",labels:["삼성페이 (나이스페이먼츠)","삼성페이로 결제 (나이스페이먼츠)","Samsung Pay (NicePayments)","Pay with Samsung Pay (NicePayments)"],shortLabels:[],markLines:["S"],markClassName:"bg-blue-600 text-white"},{id:"nicepay_applepay",labels:["애플페이 (나이스페이먼츠)","애플페이로 결제 (나이스페이먼츠)","Apple Pay (NicePayments)","Pay with Apple Pay (NicePayments)"],shortLabels:[],markLines:["A"],markClassName:"bg-gray-900 text-white"},{id:"nicepay_payco",labels:["PAYCO (나이스페이먼츠)","PAYCO로 결제 (나이스페이먼츠)","PAYCO (NicePayments)","Pay with PAYCO (NicePayments)"],shortLabels:[],markLines:["P"],markClassName:"bg-red-500 text-white"},{id:"nicepay_skpay",labels:["11pay (나이스페이먼츠)","11pay로 결제 (나이스페이먼츠)","11pay (NicePayments)","Pay with 11pay (NicePayments)"],shortLabels:[],markLines:["11"],markClassName:"bg-orange-500 text-white"},{id:"nicepay_ssgpay",labels:["SSG페이 (나이스페이먼츠)","SSG페이로 결제 (나이스페이먼츠)","SSG Pay (NicePayments)","Pay with SSG Pay (NicePayments)"],shortLabels:[],markLines:["SSG"],markClassName:"bg-red-700 text-white"},{id:"nicepay_lpay",labels:["L.pay (나이스페이먼츠)","L.pay로 결제 (나이스페이먼츠)","L.pay (NicePayments)","Pay with L.pay (NicePayments)"],shortLabels:[],markLines:["L"],markClassName:"bg-purple-600 text-white"}];let K=null,_=null,E=!1;function ze(){return window}function S(){if(!Ue.test(window.location.pathname))return!1;const e=new URLSearchParams(window.location.search).get("tab");return e===null||e===""||e==="order_settings"}function Ve(e){return e instanceof HTMLElement?e.className:""}function z(e){return(e??"").replace(/\u200B/g,"").replace(/\s+/g," ").trim()}function Je(e){const t=Ve(e);return t.includes("excel-card")||t.includes("flex-center")&&t.includes("border")&&t.includes("gap-4")}function V(e){return e.querySelector(".font-medium")}function Xe(e){const t=z(V(e)?.textContent),n=z(e.textContent);return Ke.find(a=>a.shortLabels.some(r=>t===r)||a.labels.some(r=>n.includes(r)))??null}function Qe(e){return Array.from(e.querySelectorAll("div")).filter(Je)}function Ze(e){return Array.from(e.querySelectorAll("svg, [data-icon], i")).find(n=>!n.closest("[data-drag-handle]")&&!n.closest(W)&&!n.closest('.row-stack, .excel-card-body, button, select, [role="switch"]'))??null}function J(e,t){e.dataset.nicepayAdminPaymentBrandMark="true",e.dataset.nicepayAdminPaymentMethod=t.id,e.setAttribute("aria-hidden","true"),e.className=`inline-flex items-center justify-center rounded-lg font-bold ${t.markClassName}`,e.style.width="32px",e.style.height="32px",e.style.flex="0 0 32px",e.style.lineHeight="1",e.style.fontSize=t.markLines.join("").length>2?"9px":"12px";const n=t.markLines.join("");e.textContent!==n&&(e.textContent=t.markLines[0]??"")}function et(e){const t=document.createElement("span");return J(t,e),t}function tt(e,t){const n=e.querySelector(W);if(n instanceof HTMLSpanElement)return e.dataset.nicepayAdminPaymentMethod=t.id,J(n,t),!0;const a=et(t),r=Ze(e);if(r&&r.parentElement)return e.dataset.nicepayAdminPaymentMethod=t.id,r.replaceWith(a),!0;const o=V(e);return o&&o.parentElement?(e.dataset.nicepayAdminPaymentMethod=t.id,o.parentElement.insertBefore(a,o),!0):!1}function x(e=document){if(typeof window>"u"||typeof document>"u"||!S())return!1;let t=!1;return Qe(e).forEach(n=>{const a=Xe(n);a&&tt(n,a)&&(t=!0)}),t}function nt(){E||(E=!0,window.setTimeout(()=>{E=!1,x()},0))}function A(){_!==null&&(window.clearInterval(_),_=null)}function N(){if(!S())return;A(),x();let e=0;_=window.setInterval(()=>{e+=1,x(),e>=We&&A()},Ye);const t=document.body;t[Y]||(t[Y]=!0,K=new MutationObserver(()=>nt()),K.observe(document.body,{childList:!0,subtree:!0,characterData:!0}))}function X(){if(S()){N();return}A()}function at(){if(typeof window>"u"||typeof document>"u")return;const e=ze();if(e[U])return;e[U]=!0,console.info(`[${Fe}] admin payment method brand sync installed`),document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>N()):N();const t=history.pushState.bind(history);history.pushState=(...n)=>{t(...n),window.setTimeout(()=>X(),200)},window.addEventListener("popstate",()=>window.setTimeout(()=>X(),200))}const k="sirsoft-pay_nicepayments",Q="__nicepayAdminPaymentDisplayInjectorInstalled",ot=/^\/admin\/ecommerce\/orders\/([^/]+)$/;function rt(){return localStorage.getItem("auth_token")}async function it(e){const t=rt();if(!t)return null;try{const n=await fetch(`/api/plugins/${k}/admin/orders/${e}/transaction-status`,{headers:{Authorization:`Bearer ${t}`,Accept:"application/json"}});if(!n.ok)return null;const a=await n.json();return("data"in a?a.data:a)??null}catch{return null}}function st(e){return Array.from(e.children).filter(t=>t instanceof HTMLElement)}function ct(e,t){let n=!1;const a=Array.from(e.querySelectorAll("div"));for(const r of a){const o=st(r),i=o.findIndex(u=>u.textContent?.trim()==="결제수단");if(i<0)continue;const s=o.slice(i+1).find(u=>u.textContent?.trim()&&u.textContent.trim()!=="결제수단");if(!s)continue;const l=o.slice(i+2).find(u=>/^\(.+\)$/.test(u.textContent?.trim()??"")),d=l?.textContent?.trim().replace(/^\(|\)$/g,"")??"",w=d&&t.endsWith(")")?t.replace(/\)$/,`, ${d})`):t;s.textContent?.trim()!==w&&(s.textContent=w,s.dataset.nicepayPaymentMethodPatched="true"),r.dataset.nicepayPaymentMethodRow="admin-order",l&&d&&(l.textContent="",l.style.display="none",l.dataset.nicepayPaymentInstallmentHidden="true"),n=!0}return n}function lt(e,t,n){const r=Array.from(e.querySelectorAll("span")).filter(o=>o.textContent?.trim()===t).find(o=>{const i=typeof o.className=="string"?o.className:"";return i.includes("rounded-full")||i.includes("font-medium")});return r?(r.textContent=n,r.dataset.nicepayPaymentMethodPatched="true",r.dataset.nicepayPaymentMethodBadge="admin-order",!0):!1}function dt(e,t){const n=t?._pay_method_label,a=t?._embedded_pg_provider_label,r=t?._base_pay_method_label;if(!n||!a)return!1;const o=ct(e,n),i=r?lt(e,r,a):!1;return o||i}async function ut(e){const t=document.getElementById("section_payment_info")??document.getElementById("payment_info_card");if(!t)return!1;const n=await it(e);return n&&dt(t,n)&&console.info(`[${k}] payment method display patched on admin order detail`),!0}function pt(e){let t=0;const n=setInterval(()=>{t++,ut(e).then(a=>{(a||t>=30)&&clearInterval(n)})},400)}function mt(){const e=location.pathname.match(ot);e&&pt(e[1])}function yt(){if(typeof window>"u")return;const e=window;if(e[Q])return;e[Q]=!0,console.info(`[${k}] admin order payment display injector installed`);const t=(a=1500)=>setTimeout(mt,a);document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>t()):t();const n=history.pushState.bind(history);history.pushState=(...a)=>{n(...a),t(600)},window.addEventListener("popstate",()=>t(500))}const y="sirsoft-pay_nicepayments",v={info:(...e)=>console.info(`[${y}]`,...e),warn:(...e)=>console.warn(`[${y}]`,...e),error:(...e)=>console.error(`[${y}]`,...e)};function Z(){const e=window.G7Core;if(!e)return 0;const t=e.getActionDispatcher;if(typeof t!="function")return 0;const n=t();if(!n||typeof n.registerHandler!="function")return 0;let a=0;for(const[r,o]of Object.entries($)){const i=`${y}.${r}`;n.registerHandler(i,o,{category:"plugin",source:y}),a++}return a}function ee(){Se(),Ie(),He(),at(),yt();const e=()=>{const t=Z();if(t>0){v.info(`${t} handler(s) registered`),R();return}let n=0;const a=50,r=setInterval(()=>{n++;const o=Z();if(o>0){clearInterval(r),v.info(`${o} handler(s) registered (after ${n} retries)`),R();return}n>=a&&(clearInterval(r),v.warn("ActionDispatcher not available after timeout"))},100)};document.readyState==="loading"?document.addEventListener("DOMContentLoaded",e):e()}ee(),window.__SirsoftNicepayments={identifier:y,handlers:Object.keys($),initPlugin:ee}})(); -//# sourceMappingURL=plugin.iife.js.map diff --git a/templates/_bundled/sirsoft-basic/dist/js/components.iife.js b/templates/_bundled/sirsoft-basic/dist/js/components.iife.js index 96144288..664587c5 100644 --- a/templates/_bundled/sirsoft-basic/dist/js/components.iife.js +++ b/templates/_bundled/sirsoft-basic/dist/js/components.iife.js @@ -84,4 +84,3 @@ self.addEventListener('message', async (e) => { } } `})]}):null},n0=Object.freeze(Object.defineProperty({__proto__:null,PageTransitionIndicator:Sa,default:Sa},Symbol.toStringTag,{value:"Module"})),Yi=window.G7Core?.createLogger?.("Comp:PageTransitionBlur")??{log:(...e)=>console.log("[Comp:PageTransitionBlur]",...e),warn:(...e)=>console.warn("[Comp:PageTransitionBlur]",...e)},_a=({className:e="",style:t})=>{const[r,n]=k.useState(!1);return k.useEffect(()=>{const o=window.G7Core?.TransitionManager;if(!o){Yi.warn("TransitionManager를 찾을 수 없습니다.");return}return n(o.getIsPending()),o.subscribe(s=>{Yi.log("isPending changed:",s),n(s)})},[]),r?c.jsx(W,{className:`fixed inset-0 z-40 bg-white/30 dark:bg-gray-900/30 backdrop-blur-sm transition-opacity duration-150 pointer-events-none ${e}`,style:t,role:"status","aria-label":"페이지 전환 중","data-testid":"page-transition-blur"}):null},o0=Object.freeze(Object.defineProperty({__proto__:null,PageTransitionBlur:_a,default:_a},Symbol.toStringTag,{value:"Module"})),Xi={sm:640,md:768,lg:1024,xl:1280,"2xl":1536},Zi=new Set(["hidden","block","inline-block","inline","flex","inline-flex","grid","inline-grid","table","table-row","table-cell","contents","list-item","flow-root"]),a0=new Set(["Div","Flex","Grid","Section","Article","Nav","Aside","Main","Header","Footer","Fragment","Form","Container"]),s0=new Set(["H1","H2","H3","H4","H5","H6","P","Span","Label","A","Strong","Em","Small","Badge"]),l0=new Set(["Input","Textarea","Select","Checkbox","Radio","Toggle","DatePicker","TagInput","SearchBar"]),i0=new Set(["Img","Image","Avatar","AvatarUploader","ImageGallery","ProductImageViewer","FileUploader"]),qi={DataGrid:(e,t)=>c.jsxs("div",{className:"w-full border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden",children:[c.jsx("div",{className:`h-10 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 ${e}`,children:c.jsxs("div",{className:"flex items-center h-full px-4 gap-8",children:[c.jsx("div",{className:"h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded"}),c.jsx("div",{className:"h-3 w-24 bg-gray-300 dark:bg-gray-600 rounded"}),c.jsx("div",{className:"h-3 w-20 bg-gray-300 dark:bg-gray-600 rounded"}),c.jsx("div",{className:"h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded"})]})}),Array.from({length:t},(r,n)=>c.jsx("div",{className:`h-12 border-b border-gray-100 dark:border-gray-800 ${e}`,style:{animationDelay:`${n*75}ms`},children:c.jsxs("div",{className:"flex items-center h-full px-4 gap-8",children:[c.jsx("div",{className:"h-3 w-12 bg-gray-200 dark:bg-gray-700 rounded"}),c.jsx("div",{className:"h-3 flex-1 bg-gray-200 dark:bg-gray-700 rounded"}),c.jsx("div",{className:"h-3 w-16 bg-gray-100 dark:bg-gray-800 rounded"}),c.jsx("div",{className:"h-3 w-14 bg-gray-100 dark:bg-gray-800 rounded"})]})},n))]}),Pagination:e=>c.jsx("div",{className:"flex justify-center gap-1.5 py-3",children:Array.from({length:5},(t,r)=>c.jsx("div",{className:`w-8 h-8 bg-gray-200 dark:bg-gray-700 rounded ${e}`},r))}),Tabs:e=>c.jsxs("div",{children:[c.jsx("div",{className:"flex gap-4 border-b border-gray-200 dark:border-gray-700 pb-2 mb-3",children:Array.from({length:3},(t,r)=>c.jsx("div",{className:`h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded ${e}`},r))}),c.jsx("div",{className:`h-32 bg-gray-100 dark:bg-gray-800 rounded ${e}`})]}),TabNavigation:e=>c.jsx("div",{className:"flex gap-4 border-b border-gray-200 dark:border-gray-700 pb-2",children:Array.from({length:3},(t,r)=>c.jsx("div",{className:`h-4 w-20 bg-gray-200 dark:bg-gray-700 rounded ${e}`},r))}),ProductCard:e=>c.jsxs("div",{className:"rounded-lg overflow-hidden",children:[c.jsx("div",{className:`h-48 w-full bg-gray-200 dark:bg-gray-700 ${e}`}),c.jsxs("div",{className:"p-3 space-y-2",children:[c.jsx("div",{className:`h-3.5 w-3/4 bg-gray-200 dark:bg-gray-700 rounded ${e}`}),c.jsx("div",{className:`h-4 w-1/3 bg-gray-300 dark:bg-gray-600 rounded ${e}`})]})]}),UserInfo:e=>c.jsx("div",{className:`h-3 w-14 bg-gray-200 dark:bg-gray-700 rounded ${e}`}),QuantitySelector:e=>c.jsx("div",{className:`h-9 w-28 bg-gray-100 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 ${e}`}),Header:e=>c.jsx("header",{className:"bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700",children:c.jsx("div",{className:"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8",children:c.jsxs("div",{className:"flex items-center justify-between h-16",children:[c.jsxs("div",{className:"flex items-center gap-8",children:[c.jsx("div",{className:`h-8 w-24 bg-gray-200 dark:bg-gray-700 rounded ${e}`}),c.jsx("div",{className:"hidden lg:flex items-center gap-6",children:Array.from({length:4},(t,r)=>c.jsx("div",{className:`h-3.5 w-16 bg-gray-200 dark:bg-gray-700 rounded ${e}`},r))})]}),c.jsx("div",{className:"flex items-center gap-4",children:Array.from({length:3},(t,r)=>c.jsx("div",{className:`h-8 w-8 bg-gray-200 dark:bg-gray-700 rounded-full ${e}`},r))})]})})}),Footer:e=>c.jsx("footer",{className:"bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 mt-auto",children:c.jsx("div",{className:"max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8",children:c.jsxs("div",{className:"flex flex-col items-center gap-4",children:[c.jsx("div",{className:`h-4 w-32 bg-gray-200 dark:bg-gray-700 rounded ${e}`}),c.jsx("div",{className:`h-3 w-48 bg-gray-100 dark:bg-gray-800 rounded ${e}`}),c.jsx("div",{className:"flex gap-3",children:Array.from({length:3},(t,r)=>c.jsx("div",{className:`h-6 w-6 bg-gray-200 dark:bg-gray-700 rounded-full ${e}`},r))}),c.jsx("div",{className:`h-3 w-40 bg-gray-100 dark:bg-gray-800 rounded ${e}`})]})})}),Modal:()=>c.jsx(c.Fragment,{}),Toast:()=>c.jsx(c.Fragment,{}),ConfirmDialog:()=>c.jsx(c.Fragment,{})};function c0(e){if(!e||e.length===0)return[];const t=e.filter(n=>n.if),r=e.filter(n=>!n.if);if(r.length>0&&t.length>0){const n=t.filter(o=>!Qi(o.if));return n.length>1&&d0(n)?[...r,Ji(n)]:[...r,...n]}if(t.length>1&&r.length===0){const n=t.filter(o=>!Qi(o.if));return n.length===0?[]:n.length===1?n:[Ji(n)]}return e}function Qi(e){const t=e.replace(/^\{\{|\}\}$/g,"").trim();return t.startsWith("!")||/\b(hasError|isError|isLoading|error|Error)\b/.test(t)||/===\s*0\s*(\)|\}|$)/.test(t)}function d0(e){if(e.length<2)return!1;const t=[];for(const n of e){const o=(n.if||"").replace(/^\{\{|\}\}$/g,"").trim(),a=o.match(/^(.+?)\s*===\s*['"]/);if(a)t.push(a[1].trim());else{const s=o.match(/(.+?)\s*===\s*['"]/);if(!s)return!1;t.push(s[1].trim())}}const r=t[0];return t.every(n=>n===r)}function Ji(e){return e.reduce((t,r)=>Ea(r)>Ea(t)?r:t)}function Ea(e){return e.children?e.children.length+e.children.reduce((t,r)=>t+Ea(r),0):0}function u0(e){return e.includes("{{")?e.replace(/\{\{([^}]+)\}\}/g,(t,r)=>{const n=r.match(/\?[\s]*['"]([^'"]*)['"]\s*:\s*['"]/);return n?n[1]:""}):e}function Ta(e,t){if(!e)return"";let r=e;return r=r.replace(/\b(bg-\S+|text-\S+|border\b|border-\S+|shadow\S*|ring-\S+|outline-\S+|divide-\S+|opacity-\S+|font-\S+|tracking-\S+|leading-\S+|whitespace-\S+|truncate|line-clamp-\S+|underline|no-underline|decoration-\S+|cursor-\S+|hover:\S+|focus:\S+|active:\S+|group-hover:\S+|transition\S*|duration-\S+|ease-\S+|animate-\S+|dark:\S+)\b/g,"").replace(/\s{2,}/g," ").trim(),(t==="Grid"||/\bgrid-cols-\d/.test(r))&&(r=r.replace(/\bgrid-cols-(\d+)/g,(n,o)=>{const a=parseInt(o);return a>4?`grid-cols-${Math.ceil(a/2)}`:`grid-cols-${a}`})),r}function f0(e){const{name:t,props:r}=e;if(!r||!t)return"";const n=[];if(t==="Flex"){n.push("flex");const o=r.direction||"row",a={row:"flex-row","row-reverse":"flex-row-reverse",col:"flex-col","col-reverse":"flex-col-reverse"};a[o]&&n.push(a[o]);const s=r.justify||"start",l={start:"justify-start",end:"justify-end",center:"justify-center",between:"justify-between",around:"justify-around",evenly:"justify-evenly"};l[s]&&n.push(l[s]);const f=r.align||"stretch",h={start:"items-start",end:"items-end",center:"items-center",baseline:"items-baseline",stretch:"items-stretch"};h[f]&&n.push(h[f]);const d=r.wrap||"nowrap",i={wrap:"flex-wrap",nowrap:"flex-nowrap","wrap-reverse":"flex-wrap-reverse"};i[d]&&n.push(i[d]),r.gap&&r.gap>0&&n.push(`gap-${r.gap}`)}if(t==="Grid"){n.push("grid");const o=r.cols||1;if(n.push(`grid-cols-${o}`),r.responsive){const a=r.responsive;a.sm&&n.push(`sm:grid-cols-${a.sm}`),a.md&&n.push(`md:grid-cols-${a.md}`),a.lg&&n.push(`lg:grid-cols-${a.lg}`),a.xl&&n.push(`xl:grid-cols-${a.xl}`),a["2xl"]&&n.push(`2xl:grid-cols-${a["2xl"]}`)}r.gap!==void 0&&r.gap>0&&n.push(`gap-${r.gap}`),r.rowGap!==void 0&&r.rowGap>0&&n.push(`gap-y-${r.rowGap}`),r.colGap!==void 0&&r.colGap>0&&n.push(`gap-x-${r.colGap}`)}return n.join(" ")}function g0(e){switch(e){case"pulse":return"animate-pulse";case"wave":return"animate-pulse";case"none":return"";default:return"animate-pulse"}}function Zn(e,t,r,n){return!e||e.length===0?[]:c0(e).map((a,s)=>Ri(a,t,r,s,n))}function h0(e,t){const r=e.props?.className||e.className||"";return e.responsive?t>=1024&&e.responsive.desktop?.props?.className?e.responsive.desktop.props.className:t>=768&&e.responsive.tablet?.props?.className?e.responsive.tablet.props.className:r:r}function p0(e,t){const r=e.split(/\s+/).filter(Boolean),n=[],o=[];for(const f of r){const h=f.match(/^(sm|md|lg|xl|2xl):(.+)$/);if(h){const[,d,i]=h;if(Zi.has(i)){n.push({breakpoint:Xi[d]||0,display:i});continue}(Xi[d]||0)<=t&&o.push(i);continue}if(Zi.has(f)){n.push({breakpoint:0,display:f});continue}o.push(f)}if(n.length===0)return{visible:!0,className:e};n.sort((f,h)=>f.breakpoint-h.breakpoint);let a="";for(const f of n)f.breakpoint<=t&&(a=f.display);a||(a=n[0].display);const s=a!=="hidden",l=s?[a,...o].join(" "):"";return{visible:s,className:l}}function m0(e){const t=e.match(/\bgrid-cols-\[([^\]]+)\]/);if(!t)return{className:e};const r=t[1].replace(/_/g," ");return{className:e.replace(/\bgrid-cols-\[([^\]]+)\]/,"").replace(/\s{2,}/g," ").trim(),style:{gridTemplateColumns:r}}}function Ri(e,t,r,n,o){const{name:a,children:s,iteration:l}=e,f=h0(e,o),h=f0(e),d=h?`${h} ${f}`.trim():f,i=u0(d),u=p0(i,o);if(!a||!u.visible)return null;const g=u.className;if(l)return c.jsx(k.Fragment,{children:Array.from({length:r},(m,w)=>Ri({...e,iteration:void 0},t,r,w,o))},n);if(qi[a])return c.jsx("div",{className:g,children:qi[a](t,r)},n);if(a0.has(a)){const m=Ta(g,a);if(!s||s.length===0){const E=/\b(h-\S|w-\S)/.test(m)?`${m} bg-gray-200 dark:bg-gray-700 rounded ${t}`:`${m} h-3.5 w-full bg-gray-200 dark:bg-gray-700 rounded ${t}`.trim();return c.jsx("div",{className:E},n)}const w=Zn(s,t,r,o);if(!(w.length>0&&w.some(v=>v!==null)))return null;const A=m0(m);return c.jsx("div",{className:A.className,style:A.style,children:w},n)}const p=Ta(g,a);if(s0.has(a)){const m={H1:"h-7",H2:"h-6",H3:"h-5",H4:"h-5",H5:"h-4",H6:"h-4",P:"h-3.5",Span:"h-3",Label:"h-3",A:"h-3.5",Badge:"h-5 w-16"},w={H1:"w-3/5",H2:"w-2/5",H3:"w-1/3",H4:"w-1/3",H5:"w-1/4",H6:"w-1/4",P:"w-full",Span:"w-1/4",Label:"w-1/4",A:"w-1/3",Strong:"w-1/5",Em:"w-1/4",Small:"w-1/5",Badge:"w-16"},x=m[a]||"h-3.5",A=w[a]||"w-full",v=a.startsWith("H")?"mb-2 mt-1":"mb-1.5";return c.jsx("div",{className:`${x} ${A} bg-gray-200 dark:bg-gray-700 rounded ${t} ${v} ${p}`.trim()},n)}if(l0.has(a))return a==="Textarea"?c.jsx("div",{className:`h-20 w-full bg-gray-100 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 mb-2 ${t}`},n):a==="Checkbox"||a==="Radio"||a==="Toggle"?c.jsx("div",{className:`h-5 w-5 bg-gray-200 dark:bg-gray-700 rounded mb-1.5 ${t}`},n):c.jsx("div",{className:`h-9 w-full bg-gray-100 dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700 mb-2 ${t}`},n);if(i0.has(a))return a==="Avatar"||a==="AvatarUploader"?c.jsx("div",{className:`h-10 w-10 bg-gray-200 dark:bg-gray-700 rounded-full ${t}`},n):a==="ProductImageViewer"?c.jsx("div",{className:`h-80 w-full bg-gray-200 dark:bg-gray-700 rounded ${t}`},n):c.jsx("div",{className:`h-40 w-full bg-gray-200 dark:bg-gray-700 rounded mb-2 ${t}`},n);if(a==="Button"){if(s&&s.length>0){const m=Ta(g,a),w=Zn(s,t,r,o);if(!(w.length>0&&w.some(E=>E!==null)))return null;const v=/\b(border|rounded|px-\d|py-\d)/.test(f)?"border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50 rounded-lg":"";return c.jsx("div",{className:`${m} ${v}`.trim(),children:w},n)}return c.jsx("div",{className:`h-9 w-24 bg-gray-200 dark:bg-gray-700 rounded ${t}`},n)}return a==="Icon"?c.jsx("div",{className:`h-5 w-5 bg-gray-200 dark:bg-gray-700 rounded ${t}`},n):s&&s.length>0?c.jsx("div",{className:p,children:Zn(s,t,r,o)},n):c.jsx("div",{className:`h-3.5 w-full bg-gray-200 dark:bg-gray-700 rounded mb-1.5 ${t}`},n)}const Na=({components:e,options:t})=>{const r=g0(t.animation),o=window.G7Core?.useResponsive,a=o?.(),s=a?a.width:typeof window<"u"?window.innerWidth:1024;return c.jsx("div",{className:"w-full",role:"status","aria-busy":"true","aria-label":"Loading...",children:Zn(e,r,t.iteration_count,s)})},b0=Object.freeze(Object.defineProperty({__proto__:null,PageSkeleton:Na,default:Na},Symbol.toStringTag,{value:"Module"})),y0=(e,t)=>window.G7Core?.t?.(e,t)??e,ec=({options:e})=>{const t=document.documentElement.classList.contains("dark"),r=t?"rgb(17,24,39)":"rgb(249,250,251)",n=t?"#6b7280":"#9ca3af",o=t?"#9ca3af":"#6b7280";return c.jsxs("div",{style:{position:"absolute",inset:0,zIndex:2147483647,overflow:"hidden",background:r,display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"flex-start",paddingTop:"15%",gap:"12px"},children:[c.jsx("div",{style:{width:"32px",height:"32px",border:`3px solid ${n}`,borderTopColor:"transparent",borderRadius:"50%",animation:"g7-spin 0.8s linear infinite"}}),c.jsx("span",{style:{color:o,fontSize:"14px"},children:e?.text||y0("nav.loading")})]})},v0=Object.freeze(Object.defineProperty({__proto__:null,default:ec},Symbol.toStringTag,{value:"Module"})),Dr=(e,t)=>window.G7Core?.t?.(e,t)??e,tc=({currentPage:e,totalPages:t,onPageChange:r,maxVisiblePages:n=5,showFirstLast:o=!0,className:a="",style:s,prevText:l,nextText:f,id:h,editorAttrs:d})=>{const i=k.useMemo(()=>{const m=[];if(t<=n+2)for(let w=1;w<=t;w++)m.push(w);else{const w=Math.floor(n/2);let x=Math.max(2,e-w),A=Math.min(t-1,e+w);e<=w+1?A=n:e>=t-w&&(x=t-n+1),m.push(1),x>2&&m.push("...");for(let v=x;v<=A;v++)m.push(v);A1&&m.push(t)}return m},[e,t,n]),u=m=>{m<1||m>t||m===e||r(m)},g=()=>{e>1&&r(e-1)},p=()=>{eu(1),disabled:e===1,className:"px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800","aria-label":Dr("common.first_page"),children:"«"}),c.jsx(Q,{onClick:g,disabled:e===1,className:"px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800","aria-label":Dr("common.prev_page"),children:l||c.jsx(c.Fragment,{children:"‹"})}),c.jsx(W,{className:"flex items-center gap-1",children:i.map((m,w)=>{if(m==="...")return c.jsx(ee,{className:"px-3 py-1 text-sm text-gray-500 dark:text-gray-400",children:"..."},`ellipsis-${w}`);const x=m,A=x===e;return c.jsx(Q,{onClick:()=>u(x),className:`px-3 py-1 text-sm border rounded ${A?"bg-blue-500 dark:bg-blue-600 text-white border-blue-500 dark:border-blue-600":"border-gray-300 dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800"}`,"aria-label":Dr("common.page_n",{n:x}),"aria-current":A?"page":void 0,children:x},x)})}),c.jsx(Q,{onClick:p,disabled:e===t,className:"px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800","aria-label":Dr("common.next_page"),children:f||c.jsx(c.Fragment,{children:"›"})}),o&&c.jsx(Q,{onClick:()=>u(t),disabled:e===t,className:"px-3 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800","aria-label":Dr("common.last_page"),children:"»"})]})},w0=Object.freeze(Object.defineProperty({__proto__:null,Pagination:tc},Symbol.toStringTag,{value:"Module"})),rc=(e,t)=>window.G7Core?.t?.(e,t)??e,nc=({name:e="search",placeholder:t,value:r,onChange:n,onSubmit:o,showButton:a=!1,suggestions:s=[],onSuggestionClick:l,showSuggestions:f=!1,className:h="",style:d,id:i,editorAttrs:u})=>{const g=t??rc("common.search_placeholder"),[p,m]=k.useState(""),[w,x]=k.useState(!1),A=k.useRef(null),v=k.useRef(""),E=r!==void 0?r:p,O=f&&w&&s.length>0&&E.length>0;k.useEffect(()=>{r!==void 0&&(v.current=r)},[r]);const _=y=>{const N=y.target.value;m(N),n?.(y),v.current!==""&&N===""&&!y.nativeEvent.inputType&&setTimeout(()=>{const M=A.current?.querySelector("form");M&&M.requestSubmit()},0),v.current=N},I=y=>{m(y.text);const N={target:{value:y.text,name:e}};n?.(N),l?.(y),x(!1)},C=y=>{y.preventDefault(),o?.(y)};return c.jsxs(W,{ref:A,className:`relative ${h}`,style:d,id:i,...u,children:[c.jsx(so,{onSubmit:C,className:"relative",children:c.jsxs(W,{className:`relative flex items-center ${a?"gap-2":""}`,children:[c.jsxs(W,{className:"relative flex-1",children:[c.jsx(W,{className:"absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none",children:c.jsx(de,{name:Je.Search,className:"w-5 h-5 text-gray-400 dark:text-gray-500"})}),c.jsx(xt,{type:"search",name:e,value:E,onChange:_,onFocus:()=>x(!0),onBlur:()=>setTimeout(()=>x(!1),200),placeholder:g,className:"w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"})]}),a&&c.jsx(Q,{type:"submit",className:"px-4 py-2 bg-gray-800 dark:bg-gray-600 text-white rounded-lg hover:bg-gray-900 dark:hover:bg-gray-500 transition-colors text-sm font-medium cursor-pointer h-[42px]",children:rc("common.search")})]})}),O&&c.jsx(W,{className:"absolute z-10 w-full mt-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg max-h-64 overflow-y-auto",children:s.map(y=>c.jsx(W,{className:"px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer border-b border-gray-100 dark:border-gray-700 last:border-b-0 transition-colors",onClick:()=>I(y),children:c.jsxs(W,{className:"flex items-center gap-2",children:[c.jsx(de,{name:Je.Search,className:"w-4 h-4 text-gray-400 dark:text-gray-500"}),c.jsx(ee,{className:"text-sm text-gray-700 dark:text-gray-300",children:y.text})]})},y.id))})]})},x0=Object.freeze(Object.defineProperty({__proto__:null,SearchBar:nc},Symbol.toStringTag,{value:"Module"})),vt=window.G7Core,k0={sm:"md",md:"lg",lg:"xl",xl:"2xl","2xl":"3xl"};function Aa({src:e,fallbackText:t="U",size:r="lg",uploadEndpoint:n="/api/me/avatar",deleteEndpoint:o="/api/me/avatar",onUploadSuccess:a,onUploadError:s,onDeleteSuccess:l,onDeleteError:f,showDeleteButton:h=!0,accept:d="image/*",maxSize:i=5,className:u="",uploadButtonText:g,deleteButtonText:p,readOnly:m=!1,confirmDelete:w=!0,deleteConfirmMessage:x,confirmUpload:A=!0,uploadConfirmMessage:v,uploadSuccessActions:E,uploadErrorActions:O,deleteSuccessActions:_,deleteErrorActions:I,id:C,editorAttrs:y}){const N=k.useRef(null),[M,P]=k.useState(!1),[D,L]=k.useState(!1),[U,S]=k.useState(void 0),[b,$]=k.useState(null),[F,G]=k.useState(!1),[Y,B]=k.useState(!1),[V,j]=k.useState(null),[H,Z]=k.useState(null),ue=k.useRef(`avatar-upload-${Math.random().toString(36).slice(2,11)}`).current;k.useEffect(()=>{if(V){const oe=URL.createObjectURL(V);return Z(oe),()=>URL.revokeObjectURL(oe)}else Z(null)},[V]);const te=k.useCallback(oe=>vt?.t?.(oe)??oe,[]),fe=k.useCallback(()=>{const oe=document.cookie.match(/XSRF-TOKEN=([^;]+)/);return oe?decodeURIComponent(oe[1]):null},[]),R=k.useCallback(()=>vt?.api?.getToken?.()??null,[]),ke=k.useCallback(async oe=>{B(!1),j(null),P(!0),$(null);try{const ae=new FormData;ae.append("avatar",oe);const be=fe(),xe=R(),He=await fetch(n,{method:"POST",headers:{Accept:"application/json",...be&&{"X-XSRF-TOKEN":be},...xe&&{Authorization:`Bearer ${xe}`}},credentials:"include",body:ae});if(!He.ok){const Oe=await He.json().catch(()=>({}));throw new Error(Oe.message||te("attachment.upload_failed"))}const Pe=await He.json(),Ke=Pe.data||Pe;Ke.avatar&&S(Ke.avatar),a?.(Ke),vt?.toast?.success?.(te("mypage.profile.avatar_uploaded")),E?.length?E.forEach(Oe=>vt?.dispatch?.(Oe)):vt?.refetchDataSource?.("user")}catch(ae){const be=ae instanceof Error?ae:new Error(String(ae));$(be.message),s?.(be),vt?.toast?.error?.(be.message),O?.length&&O.forEach(xe=>vt?.dispatch?.(xe))}finally{P(!1),N.current&&(N.current.value="")}},[n,a,s,fe,R,te]),me=k.useCallback(async oe=>{const ae=oe.target.files?.[0];if(!ae)return;$(null);const be=i*1024*1024;if(ae.size>be){const xe=new Error(te("attachment.file_too_large").replace(":max",`${i}MB`));$(xe.message),s?.(xe),vt?.toast?.error?.(xe.message);return}if(d&&!ae.type.match(d.replace("*",".*"))){const xe=new Error(te("attachment.invalid_file_type"));$(xe.message),s?.(xe),vt?.toast?.error?.(xe.message);return}A?(j(ae),B(!0)):ke(ae)},[i,d,s,A,ke,te]),Ge=k.useCallback(async()=>{G(!1),L(!0),$(null);try{const oe=fe(),ae=R(),be=await fetch(o,{method:"DELETE",headers:{Accept:"application/json","Content-Type":"application/json",...oe&&{"X-XSRF-TOKEN":oe},...ae&&{Authorization:`Bearer ${ae}`}},credentials:"include"});if(!be.ok){const xe=await be.json().catch(()=>({}));throw new Error(xe.message||te("attachment.delete_failed"))}S(null),l?.(),vt?.toast?.success?.(te("mypage.profile.avatar_deleted")),_?.length?_.forEach(xe=>vt?.dispatch?.(xe)):vt?.refetchDataSource?.("user")}catch(oe){const ae=oe instanceof Error?oe:new Error(String(oe));$(ae.message),f?.(ae),vt?.toast?.error?.(ae.message),I?.length&&I.forEach(be=>vt?.dispatch?.(be))}finally{L(!1)}},[o,l,f,fe,R,te]),De=k.useCallback(()=>{w?G(!0):Ge()},[w,Ge]),ye=k.useCallback(()=>{G(!1)},[]),Me=k.useCallback(()=>{B(!1),j(null),N.current&&(N.current.value="")},[]),Ce=k.useCallback(()=>{V&&ke(V)},[V,ke]),we=U===void 0?e:U,$e=!!we,Le=k0[r]||"lg",K=F||Y,re=()=>c.jsxs(W,{className:"flex items-center justify-center gap-2",children:[c.jsx(xt,{ref:N,id:ue,type:"file",accept:d,className:"hidden",onChange:me,disabled:M}),!$e&&c.jsxs(yr,{htmlFor:ue,className:`inline-flex items-center gap-2 px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer ${M?"opacity-50 cursor-not-allowed":""}`,children:[M?c.jsx(de,{name:"spinner",size:"sm",className:"animate-spin"}):c.jsx(de,{name:"image",size:"sm"}),c.jsx(ee,{children:g||te("mypage.profile.register_avatar")})]}),$e&&c.jsxs(c.Fragment,{children:[c.jsxs(yr,{htmlFor:ue,className:`inline-flex items-center gap-2 px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-lg text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer ${M?"opacity-50 cursor-not-allowed":""}`,children:[M?c.jsx(de,{name:"spinner",size:"sm",className:"animate-spin"}):c.jsx(de,{name:"upload",size:"sm"}),c.jsx(ee,{children:g||te("common.change")})]}),h&&c.jsxs(Q,{type:"button",className:"inline-flex items-center gap-2 px-4 py-2 border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 rounded-lg text-sm font-medium hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed",onClick:De,disabled:D,children:[D?c.jsx(de,{name:"spinner",size:"sm",className:"animate-spin"}):c.jsx(de,{name:"trash-2",size:"sm"}),c.jsx(ee,{children:p||te("common.delete")})]})]})]});return c.jsxs(W,{className:`flex flex-col items-center gap-3 ${u}`,id:C,...y,children:[Y&&H?c.jsxs(W,{className:"relative",children:[c.jsx(Lt,{src:H,alt:"Preview",className:`rounded-full object-cover ${r==="sm"?"w-10 h-10":r==="md"?"w-12 h-12":r==="lg"?"w-16 h-16":r==="xl"?"w-24 h-24":"w-32 h-32"}`}),c.jsx(W,{className:"absolute inset-0 rounded-full bg-blue-500/20 border-2 border-blue-500 border-dashed"})]}):c.jsx(Ur,{avatar:we||void 0,name:t,size:Le}),!m&&c.jsxs(W,{className:"w-[220px] flex flex-col items-center gap-2",children:[Y&&V&&c.jsxs(W,{className:"w-full p-3 bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded-lg",children:[c.jsxs(W,{className:"flex items-center gap-2 mb-2",children:[c.jsx(de,{name:"upload",size:"sm",className:"text-blue-600 dark:text-blue-400 shrink-0"}),c.jsx(ee,{className:"text-sm font-medium text-blue-800 dark:text-blue-300",children:v||te("mypage.profile.upload_avatar_confirm")})]}),c.jsxs(W,{className:"text-xs text-blue-600 dark:text-blue-400 truncate mb-3",title:V.name,children:[V.name," (",(V.size/1024/1024).toFixed(2),"MB)"]}),c.jsxs(W,{className:"grid grid-cols-2 gap-2",children:[c.jsxs(Q,{type:"button",className:"inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium transition-colors disabled:opacity-50",onClick:Ce,disabled:M,children:[M?c.jsx(de,{name:"spinner",size:"sm",className:"animate-spin"}):c.jsx(de,{name:"check",size:"sm"}),c.jsx(ee,{children:te("common.confirm")})]}),c.jsx(Q,{type:"button",className:"inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-md text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors",onClick:Me,children:c.jsx(ee,{children:te("common.cancel")})})]})]}),F&&c.jsxs(W,{className:"w-full p-3 bg-red-50 dark:bg-red-950/50 border border-red-200 dark:border-red-800 rounded-lg",children:[c.jsxs(W,{className:"flex items-center gap-2 mb-2",children:[c.jsx(de,{name:"alert-triangle",size:"sm",className:"text-red-600 dark:text-red-400 shrink-0"}),c.jsx(ee,{className:"text-sm font-medium text-red-800 dark:text-red-300",children:x||te("mypage.profile.delete_avatar_confirm")})]}),c.jsxs(W,{className:"grid grid-cols-2 gap-2",children:[c.jsxs(Q,{type:"button",className:"inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded-md text-sm font-medium transition-colors disabled:opacity-50",onClick:Ge,disabled:D,children:[D?c.jsx(de,{name:"spinner",size:"sm",className:"animate-spin"}):c.jsx(de,{name:"trash-2",size:"sm"}),c.jsx(ee,{children:te("common.delete")})]}),c.jsx(Q,{type:"button",className:"inline-flex items-center justify-center gap-1.5 px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-md text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors",onClick:ye,children:c.jsx(ee,{children:te("common.cancel")})})]})]}),!K&&re()]}),b&&!K&&c.jsxs(W,{className:"flex items-center gap-2 text-sm text-red-600 dark:text-red-400",children:[c.jsx(de,{name:"alert-circle",size:"sm"}),c.jsx(ee,{children:b})]})]})}const C0=Object.freeze(Object.defineProperty({__proto__:null,AvatarUploader:Aa,default:Aa},Symbol.toStringTag,{value:"Module"})),oc=()=>window.G7Core,qn=(e,t)=>window.G7Core?.t?.(e,t)??e,ac={horizontal:"flex items-center gap-1.5",vertical:"flex flex-col items-start"},S0=()=>[{key:"view_profile",label:qn("userinfo.view_profile"),icon:"user",path:"/users/{{userId}}"},{key:"view_posts",label:qn("userinfo.view_posts"),icon:"file-lines",path:"/users/{{userId}}/posts"}],Ma=({text:e,title:t})=>e?c.jsx(ee,{className:"text-sm text-gray-500 dark:text-gray-400",title:t,children:e}):null,sc=({author:e,name:t,userId:r,subText:n,subTextTitle:o,isGuest:a=!1,isWithdrawn:s=!1,showDropdown:l=!0,clickable:f=!0,profilePath:h="/users/{userId}",className:d="",layout:i="vertical",text:u,stopPropagation:g=!1,menuItems:p,hideMenuItems:m=[],appendMenuItems:w=[],id:x,editorAttrs:A})=>{const v=r??e?.uuid,E=a||e?.is_guest||!1,O=s||e?.status==="withdrawn",_=O?qn("userinfo.withdrawn_user"):u??t??e?.name??"",[I,C]=k.useState(!1),[y,N]=k.useState({top:0,left:0}),M=k.useRef(null),P=k.useRef(null),D=k.useRef(null),L=h.replace("{userId}",String(v??"")),U=`${ac[i]} ${d}`;k.useEffect(()=>{if(!I)return;const j=Z=>{const ue=Z.target,te=M.current?.contains(ue),fe=P.current?.contains(ue);!te&&!fe&&C(!1)},H=()=>C(!1);return document.addEventListener("mousedown",j),window.addEventListener("scroll",H,!0),window.addEventListener("resize",H),()=>{document.removeEventListener("mousedown",j),window.removeEventListener("scroll",H,!0),window.removeEventListener("resize",H)}},[I]);const S=k.useCallback(()=>{if(p&&p.length>0)return p.filter(Z=>Z.show!==!1);let H=S0().filter(Z=>!m.includes(Z.key)&&Z.show!==!1);return w&&w.length>0&&(H=[...H,...w.filter(Z=>Z.show!==!1)]),H},[p,m,w]),b=k.useCallback(()=>{if(D.current){const j=D.current.getBoundingClientRect();N({top:j.bottom+4,left:j.left})}},[]),$=k.useCallback((j,H)=>{if(H.stopPropagation(),H.preventDefault(),C(!1),j.onClick)j.onClick();else if(j.path){const Z=j.path.replace(/\{\{userId\}\}/g,String(v??"")),ue=oc();ue?.navigate?ue.navigate(Z):window.location.href=Z}},[v]),F=k.useCallback(j=>{j.preventDefault(),g&&j.stopPropagation(),!(E||!v)&&(l?(I||b(),C(H=>!H)):f&&oc()?.navigate?.(L))},[g,E,v,l,I,b,f,L]);if(E||!v)return c.jsxs(W,{className:U,id:x,...A,children:[c.jsxs(W,{className:"flex items-center gap-1.5 text-gray-400 dark:text-gray-500",children:[c.jsx(ee,{children:_}),c.jsx(ee,{className:"text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 rounded",children:qn("userinfo.guest_badge")})]}),c.jsx(Ma,{text:n,title:o})]});if(O)return c.jsxs(W,{className:U,id:x,...A,children:[c.jsx(ee,{className:"text-gray-400 dark:text-gray-500 line-through",children:_}),c.jsx(Ma,{text:n,title:o})]});const G=S(),B=/\btext-\S+/.test(d)?"":"text-gray-900 dark:text-white",V=l||f?`${B} hover:text-blue-600 dark:hover:text-blue-400 cursor-pointer font-medium`.trim():`${B} font-medium`.trim();return c.jsxs(W,{ref:M,className:`relative inline-block ${d}`,id:x,...A,children:[c.jsxs(W,{className:ac[i],children:[c.jsx(Q,{ref:D,onClick:F,className:V,children:_}),c.jsx(Ma,{text:n,title:o})]}),l&&I&&G.length>0&&Jt.createPortal(c.jsx(W,{ref:P,className:"fixed w-40 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1 z-[9999]",style:{top:y.top,left:y.left},"data-testid":"author-dropdown-menu",children:G.map(j=>c.jsxs(Q,{onClick:H=>$(j,H),className:"flex items-center justify-start gap-2 w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer","data-testid":`menu-item-${j.key}`,children:[j.icon&&c.jsx(de,{name:j.icon,className:"w-4 h-4"}),c.jsx(ee,{children:j.label})]},j.key))}),document.body)]})},_0=Object.freeze(Object.defineProperty({__proto__:null,UserInfo:sc},Symbol.toStringTag,{value:"Module"})),lc=({tabs:e,activeTabId:t,onTabChange:r,variant:n="underline",className:o="",style:a,hiddenTabIds:s=[],mobileBreakpoint:l=768,id:f,editorAttrs:h})=>{const i=window.G7Core?.useResponsive,u=i?.(),g=u?u.width{!v.disabled&&v.id!==t&&r?.(v.id)},m=v=>{const E=v.target.value,O=Number(E),_=typeof E=="string"&&E!==""&&!Number.isNaN(O)?O:E,I=e.find(C=>String(C.id)===String(_));I&&p(I)},w=s.length>0?e.filter(v=>!s.includes(v.id)):e;if(g)return c.jsx(W,{className:`relative ${o}`,style:a,id:f,...h,children:c.jsx(Xa,{value:t!==void 0?String(t):"",onChange:m,options:w.map(v=>({value:String(v.id),label:v.badge!==void 0?`${v.label} (${v.badge})`:v.label,disabled:v.disabled})),className:"w-full"})});const x=v=>{const E=v.id===t,O="flex items-center gap-2 px-3 py-2 font-medium text-sm transition-all shrink-0 whitespace-nowrap";if(v.disabled)return`${O} opacity-50 cursor-not-allowed text-gray-400 dark:text-gray-600`;switch(n){case"pills":return E?`${O} bg-blue-600 dark:bg-blue-500 text-white rounded-lg`:`${O} text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg`;case"underline":return E?`${O} text-gray-900 dark:text-white border-b-2 border-gray-900 dark:border-white`:`${O} text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 border-b-2 border-transparent hover:border-gray-300 dark:hover:border-gray-600`;default:return E?`${O} text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 border-b-2 border-blue-600 dark:border-blue-400`:`${O} text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 border-b-2 border-transparent`}},A=n==="underline"?"flex gap-0 border-b border-gray-200 dark:border-gray-700":"flex gap-2";return c.jsx(W,{className:`relative ${o}`,style:a,id:f,...h,children:c.jsx($r,{className:A,children:w.map(v=>c.jsxs(Q,{type:"button",onClick:()=>p(v),disabled:v.disabled,className:x(v),children:[v.iconName&&c.jsx(de,{name:v.iconName,size:"sm"}),c.jsx(ee,{children:v.label}),v.badge!==void 0&&c.jsx(W,{className:"flex items-center justify-center min-w-5 h-5 px-1.5 bg-red-500 text-white text-xs font-bold rounded-full",children:c.jsx(ee,{children:v.badge})})]},v.id))})})},E0=Object.freeze(Object.defineProperty({__proto__:null,TabNavigation:lc},Symbol.toStringTag,{value:"Module"})),T0={Header:()=>Promise.resolve().then(()=>od),Footer:()=>Promise.resolve().then(()=>id),MobileNav:()=>Promise.resolve().then(()=>cd),SlotContainer:()=>Promise.resolve().then(()=>rd),NotificationCenter:()=>Promise.resolve().then(()=>nd),ProductCard:()=>Promise.resolve().then(()=>Ld),BrandMark:()=>Promise.resolve().then(()=>Fd),ImageGallery:()=>Promise.resolve().then(()=>Al),ProductImageViewer:()=>Promise.resolve().then(()=>jf),QuantitySelector:()=>Promise.resolve().then(()=>Bf),PostReactions:()=>Promise.resolve().then(()=>Vf),RichTextEditor:()=>Promise.resolve().then(()=>Yf),HtmlContent:()=>Promise.resolve().then(()=>Xf),HtmlEditor:()=>Promise.resolve().then(()=>Zf),ExpandableContent:()=>Promise.resolve().then(()=>qf),FileUploader:()=>Promise.resolve().then(()=>Xh),ConfirmDialog:()=>Promise.resolve().then(()=>Rf),SocialLoginButtons:()=>Promise.resolve().then(()=>qh),Toast:()=>Promise.resolve().then(()=>t0),PageTransitionIndicator:()=>Promise.resolve().then(()=>n0),PageTransitionBlur:()=>Promise.resolve().then(()=>o0),PageSkeleton:()=>Promise.resolve().then(()=>b0),PageLoading:()=>Promise.resolve().then(()=>v0),ThemeToggle:()=>Promise.resolve().then(()=>Qc),Pagination:()=>Promise.resolve().then(()=>w0),SearchBar:()=>Promise.resolve().then(()=>x0),Avatar:()=>Promise.resolve().then(()=>ed),AvatarUploader:()=>Promise.resolve().then(()=>C0),UserInfo:()=>Promise.resolve().then(()=>_0),Modal:()=>Promise.resolve().then(()=>Jf),TabNavigation:()=>Promise.resolve().then(()=>E0)},N0=({id:e,className:t,style:r,children:n,editorAttrs:o})=>c.jsx(W,{id:e,className:t,style:r,...o,children:n}),A0=({id:e,children:t,cols:r=1,responsive:n,gap:o,rowGap:a,colGap:s,autoRows:l,autoCols:f,flow:h="row",className:d="",style:i,onClick:u,editorAttrs:g})=>{const p=["grid"];if(r&&p.push(`grid-cols-${r}`),n&&(n.sm&&p.push(`sm:grid-cols-${n.sm}`),n.md&&p.push(`md:grid-cols-${n.md}`),n.lg&&p.push(`lg:grid-cols-${n.lg}`),n.xl&&p.push(`xl:grid-cols-${n.xl}`),n["2xl"]&&p.push(`2xl:grid-cols-${n["2xl"]}`)),o!==void 0&&o>0&&p.push(`gap-${o}`),a!==void 0&&a>0&&p.push(`gap-y-${a}`),s!==void 0&&s>0&&p.push(`gap-x-${s}`),l){const w={auto:"auto-rows-auto",min:"auto-rows-min",max:"auto-rows-max",fr:"auto-rows-fr"};p.push(w[l])}if(f){const w={auto:"auto-cols-auto",min:"auto-cols-min",max:"auto-cols-max",fr:"auto-cols-fr"};p.push(w[f])}const m={row:"grid-flow-row",col:"grid-flow-col",dense:"grid-flow-dense","row-dense":"grid-flow-row-dense","col-dense":"grid-flow-col-dense"};return p.push(m[h]),d&&p.push(d),c.jsx(W,{id:e,className:p.join(" "),style:i,onClick:u,...g,children:t})},M0=({id:e,children:t,direction:r="row",justify:n="start",align:o="stretch",wrap:a="nowrap",gap:s=0,grow:l=!1,shrink:f=!0,className:h="",style:d,onClick:i,editorAttrs:u})=>{const g=["flex"],p={row:"flex-row","row-reverse":"flex-row-reverse",col:"flex-col","col-reverse":"flex-col-reverse"};g.push(p[r]);const m={wrap:"flex-wrap",nowrap:"flex-nowrap","wrap-reverse":"flex-wrap-reverse"};g.push(m[a]);const w={start:"justify-start",end:"justify-end",center:"justify-center",between:"justify-between",around:"justify-around",evenly:"justify-evenly"};g.push(w[n]);const x={start:"items-start",end:"items-end",center:"items-center",baseline:"items-baseline",stretch:"items-stretch"};return g.push(x[o]),s>0&&g.push(`gap-${s}`),l===!0?g.push("flex-grow"):typeof l=="number"&&l>0&&g.push(`flex-grow-${l}`),f===!1?g.push("shrink-0"):f===!0?g.push("flex-shrink"):typeof f=="number"&&f>0&&g.push(`flex-shrink-${f}`),h&&g.push(h),c.jsx(W,{id:e,className:g.join(" "),style:d,onClick:i,...u,children:t})},O0=({id:e,title:t,subtitle:r,children:n,padding:o="md",background:a="none",maxWidth:s,centered:l=!1,border:f=!1,shadow:h="none",rounded:d=!1,className:i="",style:u,onClick:g,editorAttrs:p})=>{const m=[],w={none:"p-0",sm:"p-2",md:"p-4",lg:"p-6",xl:"p-8"};m.push(w[o]);const x={none:"",white:"bg-white dark:bg-gray-700",gray:"bg-gray-50 dark:bg-gray-800",primary:"bg-blue-50 dark:bg-blue-900/20",secondary:"bg-gray-50 dark:bg-gray-800"};if(a!=="none"&&m.push(x[a]),s){const v={sm:"max-w-sm",md:"max-w-md",lg:"max-w-lg",xl:"max-w-xl","2xl":"max-w-2xl","4xl":"max-w-4xl","6xl":"max-w-6xl","7xl":"max-w-7xl",full:"max-w-full"};m.push(v[s])}l&&m.push("mx-auto"),f&&m.push("border","border-gray-200","dark:border-gray-700");const A={none:"",sm:"shadow-sm",md:"shadow-md",lg:"shadow-lg",xl:"shadow-xl"};return h!=="none"&&m.push(A[h]),d&&m.push("rounded-lg"),i&&m.push(i),c.jsxs(qa,{id:e,className:m.join(" "),style:u,onClick:g,...p,children:[(t||r)&&c.jsxs(W,{className:"mb-4",children:[t&&c.jsx(Va,{className:"text-2xl font-bold text-gray-900 dark:text-white",children:t}),r&&c.jsx(Dt,{className:"mt-1 text-sm text-gray-600 dark:text-gray-400",children:r})]}),n]})},I0=({id:e,leftWidth:t="250px",rightWidth:r="300px",leftSlot:n,centerSlot:o,rightSlot:a,className:s="",style:l,editorAttrs:f})=>{const h=`flex flex-row w-full h-full ${s}`.trim(),d={width:t,flexShrink:0},i={flex:1,minWidth:0},u={width:r,flexShrink:0};return c.jsxs(W,{id:e,className:h,style:l,...f,children:[c.jsx(W,{className:"flex flex-col",style:d,children:n}),c.jsx(W,{className:"flex flex-col",style:i,children:o}),c.jsx(W,{className:"flex flex-col",style:u,children:a})]})},D0={identifier:"sirsoft-basic",vendor:"sirsoft",name:{ko:"Basic",en:"Basic"},version:"1.0.3",license:"MIT",description:{ko:"그누보드7 기본 사용자 템플릿",en:"Gnuboard7 default user template"},type:"user",locales:["ko","en"],author:{name:"sirsoft",email:"contact@sirsoft.com",url:"https://sirsoft.com"},release_date:"2026-01-07",g7_version:">=7.0.0",dependencies:{modules:{"sirsoft-board":">=1.0.0","sirsoft-ecommerce":">=1.0.2","sirsoft-page":">=1.0.0"},plugins:{"sirsoft-daum_postcode":">=1.0.0"}},assets:{css:["dist/css/components.css"],js:["dist/js/components.iife.js"],fonts:[],images:[]},components:{basic:["a","button","checkbox","div","form","h1","h2","h3","h4","hr","icon","img","input","label","li","nav","p","section","select","span","svg","table","tbody","td","textarea","th","thead","tr","ul"],composite:["header","footer","mobile-nav","product-card","rich-text-editor","image-gallery","quantity-selector","post-reactions","social-login-buttons","file-uploader","card","modal","toast","pagination","stat-card","status-badge","tab-navigation","empty-state","loading-spinner","breadcrumb","search-bar","dropdown"],layout:["container","flex","grid","section"]},preview:{thumbnail:"preview/thumbnail.png",screenshots:["preview/screenshot1.png","preview/screenshot2.png"]},error_config:{layouts:{401:"errors/401",403:"errors/403",404:"errors/404",500:"errors/500",503:"errors/503",maintenance:"errors/maintenance"}},features:{dark_mode:!0,responsive:!0,multi_language:!0,multi_currency:!0},github_url:"https://github.com/gnuboard/g7-template-sirsoft-basic",github_changelog_url:"https://github.com/gnuboard/g7-template-sirsoft-basic/blob/main/CHANGELOG.md",externals:[{id:"fontawesome",type:"style",url:"https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css",preconnect:"https://cdnjs.cloudflare.com"},{id:"pretendard",type:"webfont",url:"https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css",preconnect:"https://cdn.jsdelivr.net",crossorigin:"anonymous"}]},ic={KRW:{locale:"ko-KR",decimals:0},USD:{locale:"en-US",decimals:2},JPY:{locale:"ja-JP",decimals:0},CNY:{locale:"zh-CN",decimals:2},EUR:{locale:"de-DE",decimals:2}};function Lr(e,t="KRW"){if(!Number.isFinite(e))return"0";const r=ic[t]||ic.KRW;try{return new Intl.NumberFormat(r.locale,{style:"currency",currency:t,minimumFractionDigits:r.decimals,maximumFractionDigits:r.decimals}).format(e)}catch{return e.toLocaleString()+(t==="KRW"?"원":"")}}function Qn(e,t,r=0){if(!e)return;const n=e.KRW?.value??e.KRW?.price??0,o={};for(const[a,s]of Object.entries(e)){const l=s?.value??s?.price??0;let f=0;r>0&&(a==="KRW"?f=r:n>0&&(f=r*l/n));const h=(l+f)*t;o[a]={value:h,formatted:Lr(h,a)}}return o}function Jn(e){const t={};for(const n of e){const o=n?.multiCurrencyTotalPrice;if(o)for(const[a,s]of Object.entries(o)){const l=s?.value??s?.price??0;t[a]=(t[a]??0)+l}}const r={};for(const[n,o]of Object.entries(t))r[n]={value:o,formatted:Lr(o,n)};return r}function cc(e,t){if(!e||!t?.length)return 0;let r=0;for(const n of t){const o=e[n.id];if(o==null)continue;const a=n.values?.find(s=>s.id===Number(o));a&&(r+=a.price_adjustment??0)}return r}function Rn(e){return e.name_localized?e.name_localized:typeof e.name=="string"?e.name:e.name?.ko??""}function L0(e,t,r){if(t&&r in t)return t[r];if(Array.isArray(e)){const n=e.find(o=>typeof o.key=="string"?o.key===r:o.key?.ko===r||Object.values(o.key).includes(r));return n?typeof n.value=="string"?n.value:n.value?.ko??Object.values(n.value)[0]:void 0}return e[r]}function P0(e,t){if(t)return t;if(!Array.isArray(e))return e;const r={};for(const n of e){const o=typeof n.key=="string"?n.key:n.key?.ko??Object.values(n.key)[0]??"",a=typeof n.value=="string"?n.value:n.value?.ko??Object.values(n.value)[0]??"";o&&(r[o]=a)}return r}function F0(e,t,r){return r.find(n=>!n?.option_values||!n?.is_active?!1:e.every(o=>{const a=Rn(o),s=t?.[a];return L0(n.option_values,n.option_values_localized,a)===s}))}function $0(e,t){const r=e.params;if(!r)return;const{optionGroups:n,options:o,selectedOptionItems:a,preferredCurrency:s}=r;let l;if(r.newGroupName&&r.newValue?l={...r.currentSelection??{},[r.newGroupName]:r.newValue}:l=r.currentSelection??{},!n?.every(O=>{const _=Rn(O);return l?.[_]}))return;const h=F0(n,l,o??[]);if(!h){t.setState?.({currentSelection:{},__mergeMode:"shallow"});return}const d=n.map(O=>l[Rn(O)]).join("_"),i=a??[];if(i.find(O=>O.id===d)){const O=window.G7Core;O?.toast?.warning?.(O?.t?.("sirsoft-ecommerce.shop.already_added_option")??"이미 추가된 옵션입니다."),t.setState?.({currentSelection:{},__mergeMode:"shallow"});return}const g=h.selling_price??0,p={};n.forEach(O=>{const _=Rn(O);p[_]=l[_]});const m=r.additionalOptionGroups??[],w={};for(const O of m){if(!O.is_required)continue;const _=O.values?.find(I=>I.is_default);_&&(w[O.id]=_.id)}const x=cc(w,m),A=g+x,v={id:d,optionId:h.id,options:p,optionValues:P0(h.option_values,h.option_values_localized),quantity:1,stock:h.stock_quantity??999,unitPrice:g,unitPriceFormatted:Lr(g,s),totalPrice:A,totalPriceFormatted:Lr(A,s),multiCurrencyUnitPrice:h.multi_currency_selling_price,multiCurrencyTotalPrice:Qn(h.multi_currency_selling_price,1,x),additionalOptionSelections:w,additionalOptionsTotal:x},E=[...i,v];t.setState?.({selectedOptionItems:E,selectedTotalMultiCurrency:Jn(E),currentSelection:{},__mergeMode:"shallow"})}function U0(e,t){const r=e.params;if(!r)return;const{selectedOptionItems:n,preferredCurrency:o}=r,a=Number(r.itemIndex)||0,s=Number(r.newQuantity)||1,l=(n??[]).map((f,h)=>{if(h!==a)return f;const d=f?.unitPrice??0,i=f?.additionalOptionsTotal??0,u=(d+i)*s;return{...f,quantity:s,totalPrice:u,totalPriceFormatted:Lr(u,o),multiCurrencyTotalPrice:Qn(f?.multiCurrencyUnitPrice,s,i)}});t.setState?.({selectedOptionItems:l,selectedTotalMultiCurrency:Jn(l)})}function z0(e,t){const r=e.params;if(!r)return;const{selectedOptionItems:n,additionalOptionGroups:o,preferredCurrency:a}=r,s=Number(r.itemIndex)||0,l=Number(r.additionalOptionId),f=Object.prototype.hasOwnProperty.call(r,"customText"),h=r.valueId===""||r.valueId==null?null:Number(r.valueId),d=(n??[]).map((i,u)=>{if(u!==s)return i;const g={...i.additionalOptionSelections??{}},p={...i.additionalOptionCustomTexts??{}};if(f){const v=String(r.customText??"");v.trim()===""?delete p[l]:p[l]=v}else h==null?(delete g[l],delete p[l]):(g[l]=h,(o??[]).find(O=>O.id===l)?.values?.find(O=>O.id===h)?.allow_custom_text||delete p[l]);const m=cc(g,o),w=i?.unitPrice??0,x=i?.quantity??1,A=(w+m)*x;return{...i,additionalOptionSelections:g,additionalOptionCustomTexts:p,additionalOptionsTotal:m,totalPrice:A,totalPriceFormatted:Lr(A,a),multiCurrencyTotalPrice:Qn(i?.multiCurrencyUnitPrice,x,m)}});t.setState?.({selectedOptionItems:d,selectedTotalMultiCurrency:Jn(d)})}function H0(e,t){const r=e.params;if(!r)return;const n=Number(r.itemIndex)||0,o=(r.selectedOptionItems??[]).filter((a,s)=>s!==n);t.setState?.({selectedOptionItems:o,selectedTotalMultiCurrency:Jn(o)})}function G0(e,t){const r=e.params;if(!r)return;const n=Math.max(1,Number(r.newQuantity)||1);t.setState?.({noOptionQuantity:n,noOptionTotalMultiCurrency:Qn(r.multiCurrencyUnitPrice,n)})}const cn=window.G7Core?.createLogger?.("Handler:Cart")??{log:(...e)=>console.log("[Handler:Cart]",...e),warn:(...e)=>console.warn("[Handler:Cart]",...e),error:(...e)=>console.error("[Handler:Cart]",...e)};function hr(e){const t=window.G7Core;t?.state?.setLocal?(t.state.setLocal(e),cn.log("Local state updated:",e)):cn.warn("G7Core.state.setLocal not available")}function dc(){return window.G7Core?.state?.getLocal?.()||{}}function j0(){return window.G7Core?.state?.get?.()||{}}function B0(e,t="info"){const r=window.G7Core;r?.toast?.show?r.toast.show(e,t):cn.warn("G7Core.toast.show not available, message:",e)}function W0(e){const t=window.G7Core;return t?.t?t.t(e):e}function uc(){const e=window.G7Core;if(e?.dispatch){const r=dc().selectedItems||[];e.dispatch({handler:"refetchDataSource",params:{dataSourceId:"cartItems"}}),cn.log("Refetching cartItems with selected_ids:",r)}else cn.warn("G7Core.dispatch not available")}function K0(e,t){const{itemId:r,selectedItems:n}=e?.params||{},o=n||[];let a;o.includes(r)?a=o.filter(h=>h!==r):a=[...o,r];const l=j0().cartItems?.data?.items||[],f=l.length>0&&a.length===l.length;hr({selectedItems:a,allSelected:f,selectedCount:a.length}),uc()}function V0(e,t){const{allItemIds:r,isSelected:n}=e?.params||{};hr({selectedItems:n?r:[],allSelected:n,selectedCount:n?r.length:0}),uc()}function Y0(e,t){const{optionName:r,optionValue:n}=e?.params||{},a=dc().selectedOptions||{};hr({selectedOptions:{...a,[r]:n}})}function X0(e,t){const{targetIds:r}=e?.params||{};if(!r||r.length===0){B0(W0("shop.cart.select_items_to_delete"),"warning");return}hr({deleteModal:{visible:!0,targetIds:r}})}function Z0(e,t){const{targetItem:r}=e?.params||{};hr({optionModal:{visible:!0,targetItem:r},selectedOptions:r?.options||{},selectedQuantity:r?.quantity})}function q0(e,t){const{selectedItems:r}=e?.params||{};if(!r||r.length===0){hr({isCalculating:!1});return}hr({isCalculating:!0})}const pr=window.G7Core?.createLogger?.("Handler:CartOptionChange")??{log:(...e)=>console.log("[Handler:CartOptionChange]",...e),warn:(...e)=>console.warn("[Handler:CartOptionChange]",...e),error:(...e)=>console.error("[Handler:CartOptionChange]",...e)};function Oa(e){return e.name_localized?e.name_localized:typeof e.name=="string"?e.name:e.name?.ko??""}function Q0(e,t,r){if(t&&r in t)return t[r];if(Array.isArray(e)){const n=e.find(o=>typeof o.key=="string"?o.key===r:o.key?.ko===r||Object.values(o.key).includes(r));return n?typeof n.value=="string"?n.value:n.value?.ko??Object.values(n.value)[0]:void 0}return e[r]}function fc(e,t,r){return!e||!t?.length?null:t.every(a=>{const s=Oa(a);return e[s]})?r?.find(a=>t.every(s=>{const l=Oa(s);return Q0(a.option_values,a.option_values_localized,l)===e[l]}))||null:(pr.log("Not all options selected yet"),null)}function J0(e,t){const{selectedOptions:r,optionGroups:n,options:o}=e?.params||{};pr.log("findMatchingOption called:",{selectedOptions:r,optionGroups:n,optionsCount:o?.length});const a=fc(r,n,o);return pr.log("Matched option:",a),{matchedOption:a}}function R0(e,t){const{currentOptionValues:r,optionGroups:n,options:o}=e?.params||{};if(pr.log("initCartOptionSelection called:",{currentOptionValues:r,optionGroups:n,optionsCount:o?.length}),!n?.length||!r)return pr.log("No option groups or current values, returning empty"),{selectedOptions:{},matchedOption:null};const a={};for(const l of n){const f=Oa(l),h=r[f],d=l.values_localized??(Array.isArray(l.values)?l.values.map(i=>typeof i=="string"?i:i?.ko??""):[]);h&&d.includes(h)&&(a[f]=h)}pr.log("Initial selectedOptions:",a);const s=fc(a,n,o||[]);return pr.log("Matched option:",s),{selectedOptions:a,matchedOption:s}}const dn="g7_cart_key",ep="/api/modules/sirsoft-ecommerce/cart/key",Ia="g7_guest_order_token",gc="g7_guest_order_number",Da="g7_guest_order_expires_at",at=window.G7Core?.createLogger?.("Handler:Storage")??{log:(...e)=>console.log("[Handler:Storage]",...e),warn:(...e)=>console.warn("[Handler:Storage]",...e),error:(...e)=>console.error("[Handler:Storage]",...e)};async function hc(){try{const e=await fetch(ep,{method:"POST",headers:{"Content-Type":"application/json",Accept:"application/json"}});return e.ok?(await e.json()).data?.cart_key||null:(at.error("Failed to issue cartKey from API:",e.status),null)}catch(e){return at.error("Error issuing cartKey from API:",e),null}}function rr(e){const t=window.G7Core;t?.state?.set?(t.state.set(e),at.log("Global state updated:",e)):at.warn("G7Core.state.set not available")}function tp(){try{const e=window.G7Config;return e?.user?e.user:(window.G7Core?.state?.get?.()||{}).currentUser||null}catch{return null}}async function rp(e,t){let r=null;try{r=localStorage.getItem(dn)}catch{}if(r)at.log("Existing cartKey loaded:",r);else if(r=await hc(),r)try{localStorage.setItem(dn,r),at.log("New cartKey issued from API:",r)}catch{}else at.error("Failed to issue cartKey from API");rr({cartKey:r})}function np(e,t){try{return localStorage.getItem(dn)}catch{return null}}function pc(e,t){try{localStorage.removeItem(dn),at.log("cartKey removed from localStorage")}catch{}rr({cartKey:null})}async function op(e,t){if(tp()){pc();return}const n=await hc();if(n){try{localStorage.setItem(dn,n),at.log("New cartKey regenerated from API:",n)}catch{}rr({cartKey:n})}else at.error("Failed to regenerate cartKey from API")}function ap(e,t){const{key:r,value:n}=e?.params||{};if(!r){at.warn("saveToStorage: key is required");return}try{localStorage.setItem(r,n),at.log("Saved to storage:",r,n)}catch{}}function sp(e,t){const{key:r,defaultValue:n}=e?.params||{};if(!r)return at.warn("loadFromStorage: key is required"),n||null;try{return localStorage.getItem(r)||n||null}catch{return n||null}}function La(){try{sessionStorage.removeItem(Ia),sessionStorage.removeItem(gc),sessionStorage.removeItem(Da)}catch{}}function lp(e,t){let r=null,n=null;try{r=sessionStorage.getItem(Ia),n=sessionStorage.getItem(Da)}catch{}let o=!1;if(r){const a=n?new Date(n).getTime():NaN;(isNaN(a)||a<=Date.now())&&(La(),r=null,o=!0)}if(r){rr({guestOrderToken:r}),at.log("Guest order token loaded from sessionStorage");return}if(!o){const s=window.G7Core?.state?.get?.("_global")?.guestOrderToken;if(typeof s=="string"&&s!==""){at.log("Guest order token preserved from in-memory _global (sessionStorage empty)");return}}rr({guestOrderToken:null}),at.log("No guest order token (visitor not authenticated)")}function ip(e,t){const{token:r,orderNumber:n,expiresAt:o}=e?.params||{};if(!r||!n||!o){at.warn("saveGuestOrderToken: token, orderNumber, expiresAt are required");return}try{sessionStorage.setItem(Ia,r),sessionStorage.setItem(gc,n),sessionStorage.setItem(Da,o)}catch{}rr({guestOrderToken:r})}function cp(e,t){La(),rr({guestOrderToken:null})}function dp(e,t){La(),rr({guestOrderToken:null})}function up(e,t){const{product:r,priceField:n,currencyCode:o}=e,a=o||t.getState("_global.preferredCurrency")||"KRW",s=`multi_currency_${n}`,l=r[s];if(l&&l[a])return l[a].formatted;const f=`${n}_formatted`;return r[f]||String(r[n])}const Pa={KRW:{code:"KRW",symbol:"₩",locale:"ko-KR",decimals:0},USD:{code:"USD",symbol:"$",locale:"en-US",decimals:2},JPY:{code:"JPY",symbol:"¥",locale:"ja-JP",decimals:0},CNY:{code:"CNY",symbol:"¥",locale:"zh-CN",decimals:2},EUR:{code:"EUR",symbol:"€",locale:"de-DE",decimals:2}};function fp(e,t){const{value:r,currencyCode:n,locale:o}=e,a=n||t.getState("_global.preferredCurrency")||"KRW",s=Pa[a]||Pa.KRW,l=o||s.locale;try{return new Intl.NumberFormat(l,{style:"currency",currency:s.code,minimumFractionDigits:s.decimals,maximumFractionDigits:s.decimals}).format(r)}catch{const f=r.toLocaleString(void 0,{minimumFractionDigits:s.decimals,maximumFractionDigits:s.decimals});return`${s.symbol}${f}`}}function gp(e){return Pa[e]?.symbol||e}const Fa=window.G7Core?.createLogger?.("Handler:Currency")??{log:(...e)=>console.log("[Handler:Currency]",...e),warn:(...e)=>console.warn("[Handler:Currency]",...e),error:(...e)=>console.error("[Handler:Currency]",...e)},mc="g7_preferred_currency",hp="KRW";function pp(e,t){const{defaultCurrency:r=hp}=e;let n=r;try{if(typeof window<"u"&&window.localStorage){const o=localStorage.getItem(mc);o&&bc(o)&&(n=o)}}catch{Fa.warn("localStorage 접근 불가, 기본 통화 사용:",r)}return t.setState("global",{preferredCurrency:n}),n}function mp(e,t){const{currencyCode:r}=e;if(!bc(r)){Fa.warn("유효하지 않은 통화 코드:",r);return}try{typeof window<"u"&&window.localStorage&&localStorage.setItem(mc,r)}catch{Fa.warn("localStorage 저장 실패")}t.setState("global",{preferredCurrency:r})}function bc(e){return typeof e=="string"&&/^[A-Z]{3}$/.test(e)}const Pr=window.G7Core?.createLogger?.("Handler:SetTheme")??{log:(...e)=>console.log("[Handler:SetTheme]",...e),warn:(...e)=>console.warn("[Handler:SetTheme]",...e),error:(...e)=>console.error("[Handler:SetTheme]",...e)},$a=["auto","light","dark"],yc="g7_color_scheme",bp=e=>e==="auto"?window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light":e,eo=e=>{const t=bp(e);document.documentElement.setAttribute("data-theme",t),t==="dark"?document.documentElement.classList.add("dark"):document.documentElement.classList.remove("dark")};function yp(){try{const e=localStorage.getItem(yc),t=e&&$a.includes(e)?e:"auto";eo(t),Pr.log("Initial theme applied:",t)}catch(e){Pr.warn("Failed to load initial theme:",e),eo("auto")}}async function vp(e,t){const r=e?.target;if(r&&typeof r=="string"&&$a.includes(r)){eo(r);return}yp()}async function wp(e,t){let r;if(e?.target&&typeof e.target=="string"&&!e.target.includes("{{")&&(r=e.target),!r||typeof r!="string"){Pr.warn("Invalid theme:",r);return}if(!$a.includes(r)){Pr.warn("Unsupported theme:",r);return}const n=r;try{localStorage.setItem(yc,n)}catch(o){Pr.error("Failed to save theme to localStorage:",o);return}eo(n),Pr.log("Theme changed to:",n)}const xp=window.G7Core?.createLogger?.("Handler:RedirectToLoginWithReturn")??{log:(...e)=>console.log("[Handler:RedirectToLoginWithReturn]",...e),warn:(...e)=>console.warn("[Handler:RedirectToLoginWithReturn]",...e),error:(...e)=>console.error("[Handler:RedirectToLoginWithReturn]",...e)};async function kp(e,t){const r=window.G7Core;if(!r?.dispatch){xp.error("G7Core.dispatch 가 초기화되지 않아 로그인 리다이렉트를 실행할 수 없습니다.");return}const n=window.location.pathname+window.location.search;await r.dispatch({handler:"navigate",params:{path:"/login",query:{redirect:n}}})}const Ua=window.G7Core?.createLogger?.("Handler:DownloadAttachment")??{log:(...e)=>console.log("[Handler:DownloadAttachment]",...e),warn:(...e)=>console.warn("[Handler:DownloadAttachment]",...e),error:(...e)=>console.error("[Handler:DownloadAttachment]",...e)};async function Cp(e,t){const r=window.G7Core,{url:n,filename:o}=e?.params||{};if(!n){Ua.warn("다운로드 URL 이 없어 요청을 건너뜁니다.");return}if(!r?.api?.get){Ua.error("G7Core.api.get 이 초기화되지 않아 다운로드를 실행할 수 없습니다.");return}try{const a=await r.api.get(n,{responseType:"blob"});if(a){const s=URL.createObjectURL(a),l=document.createElement("a");l.href=s,l.download=o||"",document.body.appendChild(l),l.click(),document.body.removeChild(l),URL.revokeObjectURL(s)}}catch(a){Ua.error("첨부파일 다운로드 실패",a),r?.toast?.error?.(r?.t?.("common.download_failed")??"Download failed")}}const to={"sirsoft-basic.addSelectedItemIfComplete":$0,"sirsoft-basic.updateSelectedItemQuantity":U0,"sirsoft-basic.removeSelectedItem":H0,"sirsoft-basic.updateNoOptionQuantity":G0,"sirsoft-basic.setBlockAdditionalOption":z0,"sirsoft-basic.getDisplayPrice":up,"sirsoft-basic.formatCurrency":fp,"sirsoft-basic.getCurrencySymbol":gp,"sirsoft-basic.loadPreferredCurrency":pp,"sirsoft-basic.savePreferredCurrency":mp,setTheme:wp,initTheme:vp,redirectToLoginWithReturn:kp,downloadAttachment:Cp,toggleCartItemSelection:K0,selectAllCartItems:V0,setCartOption:Y0,openCartDeleteModal:X0,openCartOptionModal:Z0,recalculateCart:q0,findMatchingOption:J0,initCartOptionSelection:R0,initCartKey:rp,getCartKey:np,clearCartKey:pc,regenerateCartKey:op,saveToStorage:ap,loadFromStorage:sp,initGuestOrderToken:lp,saveGuestOrderToken:ip,clearGuestOrderToken:cp,clearGuestTokenOnEntry:dp},un=window.G7Core?.createLogger?.("Template:sirsoft-basic:IdentityLauncher")??{log:(...e)=>console.log("[Template:sirsoft-basic:IdentityLauncher]",...e),warn:(...e)=>console.warn("[Template:sirsoft-basic:IdentityLauncher]",...e),error:(...e)=>console.error("[Template:sirsoft-basic:IdentityLauncher]",...e)};function Sp(e){const t=window.G7Core,r=t?.state?.getLocal?.()??{},n=t?.state?.getGlobal?.()??t?.state?.get?.()??{},o=(...f)=>f.find(h=>typeof h=="string"&&h.length>0),a=o(e.target?.email,n?.currentUser?.email,n?.user?.email,n?.auth?.user?.email,e.purpose==="signup"?r?.registerForm?.email:void 0,e.purpose==="password_reset"?r?.passwordResetForm?.email:void 0,e.purpose==="password_reset"?r?.email:void 0),s=o(e.target?.phone,n?.currentUser?.phone,n?.user?.phone,n?.auth?.user?.phone);if(!a&&!s)return null;const l={};return a&&(l.email=a),s&&(l.phone=s),l}function _p(){const t=window.G7Core?.api?.getToken?.()??null;return t?{Authorization:`Bearer ${t}`}:{}}function Ep(){try{const e=typeof window<"u"?window.localStorage?.getItem("g7_locale"):null;return e?{"Accept-Language":e}:{}}catch{return{}}}async function Tp(e,t){const r={purpose:e.purpose};e.provider_id&&(r.provider_id=e.provider_id),t&&(r.target=t);const n=e.challenge_start_url||"/api/identity/challenges",o=await fetch(n,{method:"POST",credentials:"include",headers:{"Content-Type":"application/json",Accept:"application/json",...Ep(),..._p()},body:JSON.stringify(r)}),a=await o.json().catch(()=>null);if(!o.ok||!a?.success){const l=a?.message??`Challenge 시작 실패 (HTTP ${o.status})`;throw new Error(l)}const s=a.data??a;return{id:s.id,expires_at:s.expires_at,render_hint:s.render_hint??e.render_hint??"text_code",public_payload:s.public_payload,redirect_url:s.redirect_url,max_attempts:typeof s.max_attempts=="number"?s.max_attempts:void 0}}async function Np(e){const t=window.G7Core,r=t?.identity;if(!t?.dispatch||!t?.state||!r)return un.error("G7Core 또는 G7Core.identity 가 초기화되지 않아 launcher 를 실행할 수 없습니다."),{status:"failed",failureCode:"G7_NOT_READY"};if(e.render_hint==="external_redirect"||e.redirect_url)return r.redirectExternally(e);const n=Sp(e);let o;try{o=await Tp(e,n)}catch(p){un.error("Challenge 시작 실패:",p);try{await t.dispatch({handler:"toast",params:{type:"error",message:p instanceof Error?p.message:"본인인증 시작에 실패했습니다."}})}catch{}return{status:"failed",failureCode:"CHALLENGE_START_FAILED",reason:p instanceof Error?p.message:String(p)}}if(o.redirect_url)return r.redirectExternally({...e,render_hint:"external_redirect",redirect_url:o.redirect_url});const a=o.expires_at?new Date(o.expires_at).getTime():0,s=a>0?Math.max(0,Math.floor((a-Date.now())/1e3)):0,l=typeof o.max_attempts=="number"?o.max_attempts:0;t.state.set({identityChallenge:{policy_key:e.policy_key,purpose:e.purpose,provider_id:e.provider_id??null,render_hint:o.render_hint,challenge_id:o.id,expires_at:o.expires_at,public_payload:o.public_payload??{},target:n??null,code:"",error:null,attempts:0,maxAttempts:l,remainingSeconds:s,resendCooldown:0}});let f=a,h=0;const d=window.setInterval(()=>{const p=f>0?Math.max(0,Math.floor((f-Date.now())/1e3)):0;h=Math.max(0,h-1),t.state.set({identityChallenge:{remainingSeconds:p,resendCooldown:h}})},1e3);let i=null;try{i=t.state.subscribe?.(p=>{const m=p?.identityChallenge?.expires_at;if(typeof m=="string"){const x=new Date(m).getTime();Number.isFinite(x)&&x!==f&&(f=x)}const w=p?.identityChallenge?.resendCooldown;typeof w=="number"&&w>h&&(h=w)})??null}catch{}const u=r.createDeferred();try{await t.dispatch({handler:"openModal",target:"identity-challenge-modal"})}catch(p){return un.error("openModal 실패 — 풀페이지로 폴백:",p),window.clearInterval(d),i?.(),r.redirectExternally({...e,render_hint:"external_redirect",redirect_url:`/identity/challenge?challenge_id=${encodeURIComponent(o.id)}&return=${encodeURIComponent(window.location.href)}`})}const g=await u;return window.clearInterval(d),i?.(),g}function Ap(){const e=window.G7Core?.identity;if(!e?.setLauncher){un.warn("G7Core.identity 가 아직 초기화되지 않아 launcher 등록을 건너뜁니다. 코어 부트스트랩 순서를 확인하세요.");return}e.setLauncher(Np),un.log("IDV launcher registered (sirsoft-basic)")}const vc=window.G7Core?.createLogger?.("Template:sirsoft-basic:device")??{log:(...e)=>console.log("[Template:sirsoft-basic:device]",...e),warn:(...e)=>console.warn("[Template:sirsoft-basic:device]",...e)};function Mp(){if(typeof navigator>"u")return!1;const e=navigator,t=(e.userAgent||"").toLowerCase(),r=((e.userAgentData?.platform??e.platform)||"").toLowerCase(),n=e.maxTouchPoints??0;return/iphone|ipad|ipod/.test(t)||/iphone|ipad|ipod|ios/.test(r)?!0:/macintosh|mac os x/.test(t)&&n>1}function Op(){const e=window.G7Core;if(!e?.state?.get||!e?.state?.set){vc.warn("G7Core.state not available; skip iOS device correction");return}if(!Mp())return;const r=(e.state.get()||{}).appConfig||{};r.isIos!==!0&&(e.state.set({appConfig:{...r,isIos:!0}}),vc.log("appConfig.isIos corrected to true by client signals (iPadOS desktop-UA)"))}function Ip(e){const t=e.icons;return Array.isArray(t)?t.filter(r=>!!r&&typeof r=="object"&&typeof r.value=="string"):[]}function Dp(e,t){const r=e.label;return typeof r=="string"?r.startsWith("$t:")?t(r.slice(3)):r:e.value}function Lp(e,t,r){if(r==="")return!0;const n=r.toLowerCase();return e.value.toLowerCase().includes(n)||t.toLowerCase().includes(n)?!0:(e.keywords??[]).some(o=>o.toLowerCase().includes(n))}function Pp({entry:e}){const t=e.preview;return t?.html?c.jsx("span",{className:"g7le-icon-preview",dangerouslySetInnerHTML:{__html:t.html},style:Ha}):t?.className?c.jsx("span",{className:`g7le-icon-preview ${t.className}`,style:Ha}):c.jsx("i",{className:`fas fa-${e.value}`,style:Ha,"aria-hidden":"true"})}function Fp({control:e,value:t,onChange:r,t:n}){const o=Ip(e),a=t==null?"":String(t),s=typeof e.iconColumns=="number"?e.iconColumns:6,l=e.iconSearchPlaceholder,f=typeof l=="string"&&l.startsWith("$t:")?n(l.slice(3)):n("layout_editor.icon_picker.search_placeholder"),[h,d]=k.useState("");if(o.length===0)return c.jsx("div",{className:"g7le-widget g7le-widget--icon-picker","data-testid":"g7le-widget-icon-picker",style:wc,children:c.jsx("input",{type:"text",className:"g7le-widget g7le-widget--icon-free","data-testid":"g7le-widget-icon-picker-free",value:a,placeholder:n("layout_editor.icon_picker.free_input_hint"),onChange:g=>r(g.target.value===""?void 0:g.target.value),style:xc})});const i=g=>Dp(g,n),u=o.filter(g=>Lp(g,i(g),h));return c.jsxs("div",{className:"g7le-widget g7le-widget--icon-picker","data-testid":"g7le-widget-icon-picker",style:wc,children:[c.jsx("input",{type:"text",className:"g7le-widget g7le-widget--icon-search","data-testid":"g7le-widget-icon-picker-search",value:h,placeholder:f,onChange:g=>d(g.target.value),style:xc}),c.jsxs("div",{className:"g7le-icon-grid","data-testid":"g7le-widget-icon-picker-grid",style:{...$p,gridTemplateColumns:`repeat(${s}, minmax(0, 1fr))`},children:[c.jsx("button",{type:"button","data-testid":"g7le-icon-cell-none",title:n("layout_editor.icon_picker.none"),onClick:()=>r(void 0),"aria-pressed":a==="",style:a===""?kc:za,children:c.jsx("span",{style:Up,children:"—"})}),u.map(g=>{const p=g.value===a;return c.jsx("button",{type:"button","data-testid":`g7le-icon-cell-${g.value}`,title:i(g),"aria-pressed":p,onClick:()=>r(g.value),style:p?kc:za,children:c.jsx(Pp,{entry:g})},g.value)})]}),u.length===0&&c.jsx("div",{"data-testid":"g7le-widget-icon-picker-empty",style:zp,children:n("layout_editor.icon_picker.no_results")})]})}const wc={display:"flex",flexDirection:"column",gap:6,width:"100%"},xc={padding:"5px 8px",fontSize:12,border:"1px solid #cbd5e1",borderRadius:6,width:"100%"},$p={display:"grid",gap:4,maxHeight:220,overflowY:"auto",padding:2},za={display:"flex",alignItems:"center",justifyContent:"center",minHeight:34,padding:4,border:"1px solid #e2e8f0",borderRadius:6,background:"#fff",cursor:"pointer"},kc={...za,border:"2px solid #2563eb",background:"#eff6ff"},Ha={display:"inline-flex",alignItems:"center",justifyContent:"center",fontSize:16,lineHeight:1},Up={fontSize:10,color:"#475569",fontFamily:"monospace",overflow:"hidden",textOverflow:"ellipsis",whiteSpace:"nowrap",maxWidth:"100%"},zp={fontSize:11,color:"#94a3b8",fontStyle:"italic",padding:"4px 2px"},Cc=window.G7Core?.createLogger?.("Template:sirsoft-basic:EditorWidgets")??{log:(...e)=>console.log("[Template:sirsoft-basic:EditorWidgets]",...e),warn:(...e)=>console.warn("[Template:sirsoft-basic:EditorWidgets]",...e)};function Hp(){if(typeof window>"u")return;const e=window.G7Core?.layoutEditor;if(!e?.registerWidget){Cc.warn("G7Core.layoutEditor 미노출 — icon-picker 위젯 등록 보류");return}e.registerWidget("icon-picker",Fp),Cc.log("편집기 위젯 'icon-picker' 등록됨(Font Awesome 검색 그리드)")}const Ga=window.G7Core?.createLogger?.("Template:sirsoft-basic")??{log:(...e)=>console.log("[Template:sirsoft-basic]",...e),warn:(...e)=>console.warn("[Template:sirsoft-basic]",...e),error:(...e)=>console.error("[Template:sirsoft-basic]",...e)};typeof window<"u"&&(window.G7TemplateHandlers=to);function Sc(){if(typeof window<"u"){let e=0;const t=50,r=()=>{const n=window.G7Core?.getActionDispatcher?.();n?(Object.entries(to).forEach(([o,a])=>{n.registerHandler(o,a)}),Ga.log(`${Object.keys(to).length} custom handler(s) registered:`,Object.keys(to)),Ap(),Op()):(e++,e<=t?(Ga.warn(`ActionDispatcher not found, retrying... (${e}/${t})`),setTimeout(r,100)):Ga.error("Failed to register handlers: ActionDispatcher not available after maximum retries"))};document.readyState==="complete"?r():window.addEventListener("load",r)}}return Hp(),Sc(),J.A=ao,J.Avatar=Ur,J.AvatarUploader=Aa,J.BrandMark=xo,J.Button=Q,J.Checkbox=Za,J.Code=Xc,J.ConfirmDialog=ta,J.Container=N0,J.Div=W,J.ExpandableContent=Jo,J.FileInput=Ba,J.FileUploader=Xn,J.Flex=M0,J.Footer=rs,J.Form=so,J.Grid=A0,J.H1=Fc,J.H2=Va,J.H3=gn,J.H4=Ya,J.Header=es,J.Hr=lo,J.HtmlContent=Zo,J.HtmlEditor=Ll,J.Icon=de,J.IconName=Je,J.ImageGallery=Tr,J.Img=Lt,J.Input=xt,J.Label=yr,J.Li=Xt,J.MobileNav=ns,J.Modal=Ro,J.Nav=$r,J.NotificationCenter=mn,J.Ol=$c,J.Optgroup=Gc,J.Option=Hc,J.P=Dt,J.PageLoading=ec,J.PageSkeleton=Na,J.PageTransitionBlur=_a,J.PageTransitionIndicator=Sa,J.Pagination=tc,J.PasswordInput=Ka,J.PostReactions=Il,J.ProductCard=ms,J.ProductImageViewer=An,J.QuantitySelector=Ml,J.RichTextEditor=Dl,J.SearchBar=nc,J.Section=qa,J.SectionLayout=O0,J.Select=Xa,J.SlotContainer=pn,J.SocialLoginButtons=Ki,J.Span=ee,J.Svg=fn,J.TabNavigation=lc,J.Table=jc,J.Tbody=Wc,J.Td=Yc,J.Textarea=no,J.Th=Vc,J.Thead=Bc,J.ThemeToggle=hn,J.ThreeColumnLayout=I0,J.Toast=Ca,J.Tr=Kc,J.Ul=oo,J.UserInfo=sc,J.availablePasswordRules=ro,J.compositeComponents=T0,J.defaultPasswordRules=Wa,J.initTemplate=Sc,J.templateMetadata=D0,Object.defineProperty(J,Symbol.toStringTag,{value:"Module"}),J})({},ReactJSXRuntime,React,ReactDOM); -//# sourceMappingURL=components.iife.js.map From 8522245137870aa96e34a5ae7cb731855f704699 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Sun, 19 Jul 2026 21:08:42 +0900 Subject: [PATCH 10/11] =?UTF-8?q?fix(ecommerce):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EB=A9=94=EB=AA=A8=EB=A6=AC=20=EC=A0=81?= =?UTF-8?q?=EC=9E=AC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관리자 카테고리 이미지 다운로드가 streamDownload 콜백 안에서 Storage::get 으로 파일 전체를 메모리에 적재한 뒤 echo 하던 안티패턴을, 공개 서빙과 동일한 CategoryImageService::download (StorageInterface::response 스트리밍) 위임으로 교체한다. 서빙 계층은 이미 종단간 스트리밍이며 이 지점이 유일한 전체 적재 경로였다. 규정(storage-driver.md)에 서빙 안티패턴을 명시하고 audit coverage 에 등재한다. --- docs/extension/storage-driver.md | 21 ++++ .../docs/api/category-image.md | 2 + .../Controllers/Admin/CategoryController.php | 66 ++++++----- .../Admin/CategoryImageDownloadTest.php | 106 ++++++++++++++++++ 4 files changed, 161 insertions(+), 34 deletions(-) create mode 100644 modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CategoryImageDownloadTest.php diff --git a/docs/extension/storage-driver.md b/docs/extension/storage-driver.md index 537f6a47..e26e09df 100644 --- a/docs/extension/storage-driver.md +++ b/docs/extension/storage-driver.md @@ -1058,6 +1058,27 @@ $response = $this->storage->response( - null 체크 후 404 응답 처리 필요 - 권한 체크는 컨트롤러/서비스에서 별도 구현 +### 파일 서빙 안티패턴 — 전체 메모리 적재 금지 + +파일 본문을 응답으로 내려줄 때는 항상 `response()` / `download()` 스트리밍(내부 `readStream`)을 사용한다. 아래처럼 파일 전체를 문자열로 읽어 `echo` 하는 패턴은 대용량 파일에서 워커 메모리를 붕괴시킨다. + +```php +// ❌ 금지 — streamDownload 로 감쌌지만 콜백 안에서 파일 전체를 메모리에 적재 +return response()->streamDownload(function () use ($image) { + echo Storage::disk($image->disk)->get($image->path); // get() = 전체 문자열 로드 +}, $image->original_filename, ['Content-Type' => $image->mime_type]); + +// ✅ 올바름 — StorageInterface::response() 로 청크 스트리밍 (파일 전체 미적재) +$response = $this->storage->response('images', $image->path, $image->original_filename, [ + 'Content-Type' => $image->mime_type, + 'Cache-Control' => 'public, max-age=31536000', +]); + +return $response ?? ResponseHelper::notFound(...); +``` + +`StorageInterface::get()` 은 소형 메타 파일(manifest.json 등)의 전체 읽기 전용이며, 서빙 본문에는 사용하지 않는다. + --- ## 트러블슈팅 diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/category-image.md b/modules/_bundled/sirsoft-ecommerce/docs/api/category-image.md index 32468e36..71296329 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/category-image.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/category-image.md @@ -55,4 +55,6 @@ Accept: application/json **설명** 공개 API로, 해시(`hash`)로 식별되는 카테고리 이미지 원본 파일을 스트리밍 서빙합니다. 인증이 필요 없으며(`PublicBaseController`), `CategoryImageController@download`가 `CategoryImageService::download()`를 호출해 리포지토리에서 해시로 이미지를 찾고 `StorageInterface::response()`로 `StreamedResponse`를 반환합니다. 응답에는 저장된 `mime_type`과 `Cache-Control: public, max-age=31536000`(1년) 헤더가 부여되어 브라우저/CDN 캐싱에 최적화됩니다. 해시에 해당하는 레코드가 없거나 스토리지에 실제 파일이 없으면 404를, 그 외 처리 오류 시 400 에러 응답을 반환합니다. +> **서빙 방식**: 이미지 본문은 `StorageInterface::response()`(내부 `readStream`) 로 청크 스트리밍되며 파일 전체를 메모리에 적재하지 않습니다. 관리자용 카테고리 이미지 서빙(`CategoryController::downloadImage`)도 동일하게 `CategoryImageService::download()` 스트리밍 경로를 공유합니다(전체 메모리 적재 `Storage::get()` 미사용). + diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/CategoryController.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/CategoryController.php index 89d7e7c8..51602e09 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/CategoryController.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/CategoryController.php @@ -5,7 +5,6 @@ namespace Modules\Sirsoft\Ecommerce\Http\Controllers\Admin; use App\Helpers\ResponseHelper; use App\Http\Controllers\Api\Base\AdminBaseController; use Illuminate\Http\JsonResponse; -use Illuminate\Support\Facades\Storage; use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\CategoryListRequest; use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\CreateCategoryRequest; use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\ReorderCategoriesRequest; @@ -16,6 +15,7 @@ use Modules\Sirsoft\Ecommerce\Http\Resources\CategoryCollection; use Modules\Sirsoft\Ecommerce\Http\Resources\CategoryResource; use Modules\Sirsoft\Ecommerce\Services\CategoryImageService; use Modules\Sirsoft\Ecommerce\Services\CategoryService; +use Symfony\Component\HttpFoundation\StreamedResponse; /** * 카테고리 관리 컨트롤러 @@ -30,8 +30,8 @@ class CategoryController extends AdminBaseController /** * 카테고리 목록 조회 * - * @param CategoryListRequest $request - * @return JsonResponse + * @param CategoryListRequest $request 목록 조회 필터/정렬 조건 + * @return JsonResponse 카테고리 목록 응답 */ public function index(CategoryListRequest $request): JsonResponse { @@ -49,7 +49,7 @@ class CategoryController extends AdminBaseController * * 활성화된 카테고리만 트리 형태로 반환합니다. * - * @return JsonResponse + * @return JsonResponse 활성 카테고리 트리 응답 */ public function tree(): JsonResponse { @@ -68,8 +68,8 @@ class CategoryController extends AdminBaseController /** * 카테고리 생성 * - * @param CreateCategoryRequest $request - * @return JsonResponse + * @param CreateCategoryRequest $request 카테고리 생성 데이터 + * @return JsonResponse 생성된 카테고리 응답 */ public function store(CreateCategoryRequest $request): JsonResponse { @@ -86,8 +86,8 @@ class CategoryController extends AdminBaseController /** * 카테고리 상세 조회 * - * @param int $id - * @return JsonResponse + * @param int $id 카테고리 ID + * @return JsonResponse 카테고리 상세 응답 */ public function show(int $id): JsonResponse { @@ -111,9 +111,9 @@ class CategoryController extends AdminBaseController /** * 카테고리 수정 * - * @param UpdateCategoryRequest $request - * @param int $category - * @return JsonResponse + * @param UpdateCategoryRequest $request 카테고리 수정 데이터 + * @param int $category 카테고리 ID + * @return JsonResponse 수정된 카테고리 응답 */ public function update(UpdateCategoryRequest $request, int $category): JsonResponse { @@ -137,8 +137,8 @@ class CategoryController extends AdminBaseController /** * 카테고리 삭제 * - * @param int $category - * @return JsonResponse + * @param int $category 카테고리 ID + * @return JsonResponse 삭제 결과 응답 */ public function destroy(int $category): JsonResponse { @@ -162,9 +162,9 @@ class CategoryController extends AdminBaseController /** * 카테고리 이미지 업로드 * - * @param UploadCategoryImageRequest $request - * @param int|null $categoryId - * @return JsonResponse + * @param UploadCategoryImageRequest $request 이미지 업로드 데이터 + * @param int|null $categoryId 카테고리 ID (임시 업로드 시 null) + * @return JsonResponse 업로드된 이미지 정보 응답 */ public function uploadImage(UploadCategoryImageRequest $request, ?int $categoryId = null): JsonResponse { @@ -211,8 +211,8 @@ class CategoryController extends AdminBaseController /** * 카테고리 이미지 삭제 * - * @param int $id - * @return JsonResponse + * @param int $id 이미지 ID + * @return JsonResponse 삭제 결과 응답 */ public function deleteImage(int $id): JsonResponse { @@ -243,8 +243,8 @@ class CategoryController extends AdminBaseController /** * 카테고리 이미지 순서 변경 * - * @param ReorderCategoryImagesRequest $request - * @return JsonResponse + * @param ReorderCategoryImagesRequest $request 이미지 순서 데이터 + * @return JsonResponse 순서 변경 결과 응답 */ public function reorderImages(ReorderCategoryImagesRequest $request): JsonResponse { @@ -269,15 +269,17 @@ class CategoryController extends AdminBaseController /** * 카테고리 이미지 다운로드 * - * @param string $hash - * @return \Symfony\Component\HttpFoundation\StreamedResponse|JsonResponse + * @param string $hash 이미지 해시 + * @return StreamedResponse|JsonResponse 이미지 스트림 또는 404 응답 */ public function downloadImage(string $hash) { try { - $image = $this->categoryImageService->getByHash($hash); + // 서빙은 서비스의 StorageInterface::response() 스트리밍에 위임한다. + // (파일 전체를 메모리에 적재하는 streamDownload+Storage::get 안티패턴 금지) + $response = $this->categoryImageService->download($hash); - if (! $image) { + if (! $response) { return ResponseHelper::notFound( 'messages.category_images.not_found', [], @@ -285,11 +287,7 @@ class CategoryController extends AdminBaseController ); } - return response()->streamDownload(function () use ($image) { - echo Storage::disk($image->disk)->get($image->path); - }, $image->original_filename, [ - 'Content-Type' => $image->mime_type, - ]); + return $response; } catch (\Exception $e) { return ResponseHelper::moduleError( 'sirsoft-ecommerce', @@ -302,8 +300,8 @@ class CategoryController extends AdminBaseController /** * 카테고리 상태 토글 * - * @param int $id - * @return JsonResponse + * @param int $id 카테고리 ID + * @return JsonResponse 상태 변경 응답 */ public function toggleStatus(int $id): JsonResponse { @@ -327,7 +325,7 @@ class CategoryController extends AdminBaseController /** * 파일 크기를 사람이 읽기 쉬운 형식으로 변환 * - * @param int $bytes 바이트 단위 파일 크기 + * @param int $bytes 바이트 단위 파일 크기 * @return string 포맷된 파일 크기 (예: "1.5 MB") */ private function formatFileSize(int $bytes): string @@ -353,8 +351,8 @@ class CategoryController extends AdminBaseController * "child_menus": { "1": [{ "id": 2, "order": 1 }, ...] } * } * - * @param ReorderCategoriesRequest $request - * @return JsonResponse + * @param ReorderCategoriesRequest $request 카테고리 순서 데이터 + * @return JsonResponse 순서 변경 결과 응답 */ public function reorder(ReorderCategoriesRequest $request): JsonResponse { diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CategoryImageDownloadTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CategoryImageDownloadTest.php new file mode 100644 index 00000000..d4f7b219 --- /dev/null +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Admin/CategoryImageDownloadTest.php @@ -0,0 +1,106 @@ +streamDownload(fn => echo Storage::get()) + * 로 파일 전체를 메모리에 적재하는 안티패턴이었다. 서빙은 CategoryImageService::download() + * (StorageInterface::response() 기반 스트리밍)로 위임해야 한다. + * + * 이 테스트는 컨트롤러가 서비스의 스트리밍 경로로 위임하는지를 단언한다 — + * 전체 적재 경로(getByHash 조회 후 직접 Storage 접근)로 회귀하면 실패한다. + */ +class CategoryImageDownloadTest extends ModuleTestCase +{ + /** @var MockInterface&CategoryImageService */ + private $categoryImageService; + + private CategoryController $controller; + + protected function setUp(): void + { + parent::setUp(); + + $categoryService = Mockery::mock(CategoryService::class); + $this->categoryImageService = Mockery::mock(CategoryImageService::class); + + $this->controller = new CategoryController( + $categoryService, + $this->categoryImageService + ); + } + + protected function tearDown(): void + { + Mockery::close(); + parent::tearDown(); + } + + /** + * 다운로드가 서비스의 스트리밍 응답으로 위임되는지 검증한다. + * + * 전체 적재 경로(getByHash + Storage::get)로 회귀하면 download() 미호출 + + * getByHash 호출로 이 테스트가 실패한다. + */ + #[Test] + public function test_download_delegates_to_streaming_service(): void + { + $hash = 'abcdef012345'; + $streamed = new StreamedResponse(function () {}, 200, [ + 'Content-Type' => 'image/jpeg', + ]); + + // 서비스의 스트리밍 다운로드가 호출되어야 한다. + $this->categoryImageService + ->shouldReceive('download') + ->once() + ->with($hash) + ->andReturn($streamed); + + // 전체 적재 경로(getByHash 후 직접 Storage 접근)는 사용하면 안 된다. + $this->categoryImageService + ->shouldReceive('getByHash') + ->never(); + + $result = $this->controller->downloadImage($hash); + + $this->assertInstanceOf( + StreamedResponse::class, + $result, + '관리자 다운로드는 스트리밍 응답을 반환해야 한다 (전체 메모리 적재 금지)' + ); + $this->assertSame($streamed, $result, '서비스가 생성한 스트리밍 응답을 그대로 반환해야 한다'); + } + + /** + * 이미지가 없으면 404 를 반환한다 (기존 동작 회귀 유지). + */ + #[Test] + public function test_download_returns_not_found_when_image_missing(): void + { + $hash = 'missinghash0'; + + $this->categoryImageService + ->shouldReceive('download') + ->once() + ->with($hash) + ->andReturn(null); + + $result = $this->controller->downloadImage($hash); + + $this->assertInstanceOf(JsonResponse::class, $result); + $this->assertSame(404, $result->getStatusCode()); + } +} From 0cdb97862f6e4343c54959bafa84dc17df4cbf88 Mon Sep 17 00:00:00 2001 From: HeuJung Date: Sun, 19 Jul 2026 23:20:18 +0900 Subject: [PATCH 11/11] =?UTF-8?q?fix(ecommerce):=20=EC=9E=A5=EB=B0=94?= =?UTF-8?q?=EA=B5=AC=EB=8B=88=20=EB=8B=B4=EA=B8=B0=C2=B7=EB=B0=94=EB=A1=9C?= =?UTF-8?q?=EA=B5=AC=EB=A7=A4=20=EC=98=B5=EC=85=98=20=EC=8B=9D=EB=B3=84?= =?UTF-8?q?=EC=9D=84=20product=5Foption=5Fid=20=EA=B8=B0=EB=B0=98=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 담기/바로구매가 메인 옵션을 로케일 값 조합(option_values)으로 전송하고 서버가 문자열 동등비교로 역매칭하던 방식은, 클라·서버 로케일 불일치나 관리자의 옵션 값 텍스트 수정 시 매칭 실패를 유발했다. 클라이언트가 이미 보유한 옵션 ID(optionId)를 전송하고, 서버는 요청 상품의 옵션 집합 내에서 ID로만 조회하도록 전환한다. 추가옵션(value_id/custom_text)은 이미 ID 기반이라 변경 없음. --- .../CHANGELOG.md | 6 ++ .../backend/ja/validation.php | 1 - .../language-pack.json | 2 +- .../_bundled/sirsoft-ecommerce/CHANGELOG.md | 4 + .../sirsoft-ecommerce/docs/api/checkout.md | 2 +- .../Requests/Public/BulkAddToCartRequest.php | 3 +- .../Http/Requests/Public/CheckoutRequest.php | 3 +- .../src/Services/CartService.php | 16 ++-- .../src/Services/TempOrderService.php | 18 ++--- .../src/lang/en/validation.php | 1 - .../src/lang/ko/validation.php | 1 - .../Controllers/Public/CartControllerTest.php | 54 ++++++++++--- templates/_bundled/sirsoft-basic/CHANGELOG.md | 10 +++ .../partials/shop/_product_purchase_card.json | 2 +- .../partials/shop/detail/_purchase_card.json | 4 +- .../_bundled/sirsoft-basic/package-lock.json | 4 +- templates/_bundled/sirsoft-basic/package.json | 2 +- .../shop-buy-now-direct-checkout.test.tsx | 12 +++ .../layouts/shopAdditionalOptions.test.tsx | 6 ++ .../_bundled/sirsoft-basic/template.json | 4 +- .../specs/shop-cart-option-id.spec.ts | 78 +++++++++++++++++++ 21 files changed, 187 insertions(+), 46 deletions(-) create mode 100644 templates/_bundled/sirsoft-basic/tests/Playwright/specs/shop-cart-option-id.spec.ts diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md index 1b41679b..25e8d62d 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/CHANGELOG.md @@ -4,6 +4,12 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.5] - 2026-07-19 + +### Removed + +- 더 이상 사용하지 않는 장바구니 옵션 조합 검증 문구의 일본어 번역을 정리했습니다. + ## [1.0.4] - 2026-07-16 ### Added diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php index 602d995b..e116971f 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/backend/ja/validation.php @@ -845,7 +845,6 @@ return [ 'item_not_found' => 'カート商品が見つかりません。', 'items_required' => 'カートに追加する商品を選択してください。', 'items_min' => '最低1つ以上の商品を選択してください。', - 'option_values_not_found' => '該当するオプション組み合わせが見つかりません。', ], 'wishlist' => [ 'product_id_required' => '商品を選択してください。', diff --git a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json index 180054e3..b5d6bd1c 100644 --- a/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json +++ b/lang-packs/_bundled/g7-module-sirsoft-ecommerce-ja/language-pack.json @@ -12,7 +12,7 @@ "en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)", "ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)" }, - "version": "1.0.4", + "version": "1.0.5", "license": "MIT", "scope": "module", "target_identifier": "sirsoft-ecommerce", diff --git a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md index d0d8f871..a8c94972 100644 --- a/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md +++ b/modules/_bundled/sirsoft-ecommerce/CHANGELOG.md @@ -16,6 +16,10 @@ - 사이트맵을 만들 때 상품과 카테고리를 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 상품이 많은 쇼핑몰에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다. - 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.0.6 이상이 필요합니다. +### Fixed + +- 상품을 장바구니에 담거나 바로 구매할 때, 표시 언어(로케일)나 관리자의 옵션 이름 수정과 무관하게 선택한 옵션이 정확히 담기도록 개선했습니다. 특정 상황에서 "해당 옵션 조합을 찾을 수 없습니다" 오류로 담기가 실패하던 문제가 해소됩니다. + ## [1.0.4] - 2026-07-16 ### Fixed diff --git a/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md b/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md index 93186507..e2141763 100644 --- a/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md +++ b/modules/_bundled/sirsoft-ecommerce/docs/api/checkout.md @@ -108,7 +108,7 @@ Authorization: Bearer {YOUR_TOKEN} (optional.sanctum: 비회원은 헤더 생 | 이름 | 위치 | 타입 | 필수 | 허용값 | 용도 | | --- | --- | --- | --- | --- | --- | | item_ids | body | array | 아니오 | min 1 | item 식별자 배열 | -| direct_items | body | array | 아니오 | min 1 | 바로 구매 항목 배열 (장바구니 미경유 — 항목별 product_id/option_values/quantity, item_ids와 택일) | +| direct_items | body | array | 아니오 | min 1 | 바로 구매 항목 배열 (장바구니 미경유 — 항목별 product_id/product_option_id/quantity, item_ids와 택일) | | coupon_issue_ids | body | array | 아니오 | — | coupon issue 식별자 배열 | | use_points | body | integer | 아니오 | min 0 | 사용할 마일리지(적립금) 포인트 | diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/BulkAddToCartRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/BulkAddToCartRequest.php index 1b832cf8..aaf85218 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/BulkAddToCartRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/BulkAddToCartRequest.php @@ -34,7 +34,8 @@ class BulkAddToCartRequest extends FormRequest $rules = [ 'product_id' => ['required', 'integer', Rule::exists(Product::class, 'id')], 'items' => 'required|array|min:1', - 'items.*.option_values' => 'nullable|array', + // 메인 옵션 식별자 (옵션 ID 기반). 옵션 없는 상품은 미전송 → 서버가 기본 옵션 자동 해석 + 'items.*.product_option_id' => ['nullable', 'integer'], 'items.*.quantity' => 'required|integer|min:1|max:9999', // 추가옵션 선택 (서버에서 value_id 기준 가격 재조회·검증 — 클라 가격 신뢰 금지) 'items.*.additional_option_selections' => 'nullable|array', diff --git a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/CheckoutRequest.php b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/CheckoutRequest.php index 85efdc9c..1869d730 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/CheckoutRequest.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Http/Requests/Public/CheckoutRequest.php @@ -39,7 +39,8 @@ class CheckoutRequest extends FormRequest 'item_ids.*' => ['integer', Rule::exists(Cart::class, 'id')], 'direct_items' => [Rule::requiredIf($isDirect), 'array', 'min:1'], 'direct_items.*.product_id' => ['required_with:direct_items', 'integer'], - 'direct_items.*.option_values' => ['nullable', 'array'], + // 메인 옵션 식별자 (옵션 ID 기반). 옵션 없는 상품은 미전송 → 서버가 기본 옵션 자동 해석 + 'direct_items.*.product_option_id' => ['nullable', 'integer'], 'direct_items.*.quantity' => ['required_with:direct_items', 'integer', 'min:1'], // 바로구매 추가옵션 선택 (서버에서 value_id 기준 검증/가격 재조회) 'direct_items.*.additional_option_selections' => ['nullable', 'array'], diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/CartService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/CartService.php index aab1854e..f8935ce1 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/CartService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/CartService.php @@ -247,9 +247,9 @@ class CartService * 장바구니 일괄 담기 * * 하나의 상품에 대해 여러 옵션 조합을 한 번에 장바구니에 담습니다. - * option_values가 없는 경우(옵션 없는 상품)는 기본 옵션을 자동 조회합니다. + * product_option_id가 없는 경우(옵션 없는 상품)는 기본 옵션을 자동 조회합니다. * - * @param array $data {product_id, items: [{option_values?, quantity}], user_id?, cart_key?} + * @param array $data {product_id, items: [{product_option_id?, quantity}], user_id?, cart_key?} * @return array{items: array, cart_count: int} 추가된 아이템 목록과 장바구니 총 수량 */ public function bulkAddToCart(array $data): array @@ -272,17 +272,15 @@ class CartService $addedItems = []; foreach ($data['items'] as $item) { - $optionValues = $item['option_values'] ?? null; + $requestedOptionId = $item['product_option_id'] ?? null; $quantity = $item['quantity'] ?? 1; - // option_values로 product_option_id 조회 (로케일 기준 값으로 매칭) - if (! empty($optionValues)) { - $matchedOption = $productOptions->first(function ($option) use ($optionValues) { - return $option->getLocalizedOptionValues() == $optionValues; - }); + // 옵션 ID 기반 식별. 상품 소속 옵션 집합 내에서만 조회해 임의 옵션 주입을 차단 + if (! empty($requestedOptionId)) { + $matchedOption = $productOptions->firstWhere('id', (int) $requestedOptionId); if (! $matchedOption) { - throw new \Exception(__('sirsoft-ecommerce::validation.cart.option_values_not_found')); + throw new \Exception(__('sirsoft-ecommerce::validation.cart.option_not_found')); } $productOptionId = $matchedOption->id; diff --git a/modules/_bundled/sirsoft-ecommerce/src/Services/TempOrderService.php b/modules/_bundled/sirsoft-ecommerce/src/Services/TempOrderService.php index d5a30d44..b9b94ccf 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/Services/TempOrderService.php +++ b/modules/_bundled/sirsoft-ecommerce/src/Services/TempOrderService.php @@ -169,7 +169,7 @@ class TempOrderService * 생성합니다. 판매상태·재고·구매수량 한도·구매대상제한 검증은 장바구니 담기와 동일하게 * 적용하되, 구매수량 한도는 장바구니 기존 수량과 합산하지 않고 이번 선택 수량만으로 판정합니다. * - * @param array $items 직접 항목 배열 [{product_id, option_values?, quantity}] + * @param array $items 직접 항목 배열 [{product_id, product_option_id?, quantity}] * @param int|null $userId 회원 ID * @param string|null $cartKey 비회원 장바구니 키 * @param int $usePoints 사용할 마일리지 @@ -196,10 +196,10 @@ class TempOrderService /** * 직접 항목 배열을 미저장 Cart 모델 컬렉션으로 변환합니다. * - * option_values 로 옵션을 매칭하고(없으면 기본 옵션), product/productOption 관계를 + * product_option_id 로 옵션을 매칭하고(없으면 기본 옵션), product/productOption 관계를 * set 한 미저장 Cart 모델을 만들어 기존 검증/계산/직렬화 로직을 그대로 재사용합니다. * - * @param array $items 직접 항목 배열 [{product_id, option_values?, quantity}] + * @param array $items 직접 항목 배열 [{product_id, product_option_id?, quantity}] * @param int|null $userId 회원 ID * @param string|null $cartKey 비회원 장바구니 키 * @return Collection 미저장 Cart 컬렉션 @@ -212,19 +212,17 @@ class TempOrderService foreach ($items as $item) { $productId = (int) ($item['product_id'] ?? 0); - $optionValues = $item['option_values'] ?? null; + $requestedOptionId = $item['product_option_id'] ?? null; $quantity = max(1, (int) ($item['quantity'] ?? 1)); - // 상품 옵션 매칭 (option_values 기준, 없으면 기본 옵션) + // 상품 옵션 매칭 (옵션 ID 기준, 없으면 기본 옵션) $productOptions = $this->productOptionRepository->getByProductId($productId); - if (! empty($optionValues)) { - $matchedOption = $productOptions->first( - fn ($option) => $option->getLocalizedOptionValues() == $optionValues - ); + if (! empty($requestedOptionId)) { + $matchedOption = $productOptions->firstWhere('id', (int) $requestedOptionId); if (! $matchedOption) { - throw new \Exception(__('sirsoft-ecommerce::validation.cart.option_values_not_found')); + throw new \Exception(__('sirsoft-ecommerce::validation.cart.option_not_found')); } $optionId = $matchedOption->id; diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php b/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php index bc5b49c7..b542728d 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/en/validation.php @@ -942,7 +942,6 @@ return [ 'quantity_max' => 'Quantity cannot exceed 9,999.', 'items_required' => 'Please select items to add to cart.', 'items_min' => 'Please select at least one item.', - 'option_values_not_found' => 'Matching option combination not found.', ], // Wishlist validation messages diff --git a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php index 94434408..c77b55f3 100644 --- a/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php +++ b/modules/_bundled/sirsoft-ecommerce/src/lang/ko/validation.php @@ -942,7 +942,6 @@ return [ 'quantity_max' => '수량은 최대 9,999개까지 가능합니다.', 'items_required' => '장바구니에 담을 상품을 선택해주세요.', 'items_min' => '최소 1개 이상의 상품을 선택해주세요.', - 'option_values_not_found' => '해당 옵션 조합을 찾을 수 없습니다.', ], // 찜 검증 메시지 diff --git a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/CartControllerTest.php b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/CartControllerTest.php index b1067cd4..33bbf29c 100644 --- a/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/CartControllerTest.php +++ b/modules/_bundled/sirsoft-ecommerce/tests/Feature/Http/Controllers/Public/CartControllerTest.php @@ -7,6 +7,7 @@ use Modules\Sirsoft\Ecommerce\Database\Factories\CartFactory; use Modules\Sirsoft\Ecommerce\Database\Factories\ProductFactory; use Modules\Sirsoft\Ecommerce\Database\Factories\ProductOptionFactory; use Modules\Sirsoft\Ecommerce\Enums\ChargePolicyEnum; +use Modules\Sirsoft\Ecommerce\Models\Cart; use Modules\Sirsoft\Ecommerce\Models\Product; use Modules\Sirsoft\Ecommerce\Models\ProductOption; use Modules\Sirsoft\Ecommerce\Models\ShippingPolicy; @@ -135,7 +136,7 @@ class CartControllerTest extends ModuleTestCase $this->postJson('/api/modules/sirsoft-ecommerce/cart', [ 'product_id' => $data['product']->id, 'items' => [ - ['option_values' => $data['option']->getLocalizedOptionValues(), 'quantity' => 1], + ['product_option_id' => $data['option']->id, 'quantity' => 1], ], ], ['X-Cart-Key' => $cartKey])->assertStatus(201); @@ -162,7 +163,7 @@ class CartControllerTest extends ModuleTestCase $this->postJson('/api/modules/sirsoft-ecommerce/cart', [ 'product_id' => $data['product']->id, 'items' => [ - ['option_values' => $data['option']->getLocalizedOptionValues(), 'quantity' => 1], + ['product_option_id' => $data['option']->id, 'quantity' => 1], ], ], ['X-Cart-Key' => $cartKey])->assertStatus(201); @@ -221,7 +222,7 @@ class CartControllerTest extends ModuleTestCase $addResponse = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [ 'product_id' => $data['product']->id, 'items' => [ - ['option_values' => $data['option']->getLocalizedOptionValues(), 'quantity' => 2], + ['product_option_id' => $data['option']->id, 'quantity' => 2], ], ], [ 'X-Cart-Key' => $cartKey, @@ -274,7 +275,7 @@ class CartControllerTest extends ModuleTestCase $addResponse = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [ 'product_id' => $data['product']->id, 'items' => [ - ['option_values' => $data['option']->getLocalizedOptionValues(), 'quantity' => 2], + ['product_option_id' => $data['option']->id, 'quantity' => 2], ], ], [ 'X-Cart-Key' => $cartKey, @@ -307,7 +308,7 @@ class CartControllerTest extends ModuleTestCase $addResponse = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [ 'product_id' => $data['product']->id, 'items' => [ - ['option_values' => $data['option']->getLocalizedOptionValues(), 'quantity' => 2], + ['product_option_id' => $data['option']->id, 'quantity' => 2], ], ], [ 'X-Cart-Key' => $cartKey, @@ -355,8 +356,8 @@ class CartControllerTest extends ModuleTestCase $response = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [ 'product_id' => $product->id, 'items' => [ - ['option_values' => ['색상' => '빨강', '사이즈' => 'L'], 'quantity' => 2], - ['option_values' => ['색상' => '파랑', '사이즈' => 'M'], 'quantity' => 1], + ['product_option_id' => $option1->id, 'quantity' => 2], + ['product_option_id' => $option2->id, 'quantity' => 1], ], ], [ 'X-Cart-Key' => $cartKey, @@ -384,7 +385,7 @@ class CartControllerTest extends ModuleTestCase $data = $this->createProductWithOption(); $cartKey = 'ck_'.str_repeat('f', 32); - // When: option_values 없이 담기 + // When: product_option_id 없이 담기 $response = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [ 'product_id' => $data['product']->id, 'items' => [ @@ -399,6 +400,35 @@ class CartControllerTest extends ModuleTestCase $this->assertEquals(1, $response->json('data.cart_count')); } + /** + * 다른 상품에 속한 product_option_id 를 주입하면 담기가 거부되고 장바구니에 반영되지 않습니다. + * + * 회귀: 옵션 식별을 product_option_id 기반으로 전환하면서, 요청한 상품(product_id)의 옵션 + * 집합(getByProductId) 안에서만 옵션 ID 를 조회해야 한다. 타 상품 옵션 ID 주입 시 거부되어야 + * 서버가 임의 옵션(다른 상품/가격/재고)을 장바구니에 담는 위변조를 차단한다. + */ + public function test_bulk_add_rejects_option_id_from_another_product(): void + { + // Given: 각각 옵션을 가진 두 상품 + $target = $this->createProductWithOption(); + $foreign = $this->createProductWithOption(); + $cartKey = 'ck_'.str_repeat('h', 32); + + // When: target 상품에 foreign 상품의 옵션 ID 를 실어 담기 시도 + $response = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [ + 'product_id' => $target['product']->id, + 'items' => [ + ['product_option_id' => $foreign['option']->id, 'quantity' => 1], + ], + ], [ + 'X-Cart-Key' => $cartKey, + ]); + + // Then: 담기 실패(비-201) + 장바구니에 어떤 행도 생성되지 않음 + $this->assertNotSame(201, $response->getStatusCode()); + $this->assertSame(0, Cart::where('cart_key', $cartKey)->count()); + } + /** * 일괄 담기 시 items가 비어있으면 422를 반환합니다. */ @@ -513,7 +543,7 @@ class CartControllerTest extends ModuleTestCase $response = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [ 'product_id' => $product->id, 'items' => [ - ['option_values' => $option->getLocalizedOptionValues(), 'quantity' => 1], + ['product_option_id' => $option->id, 'quantity' => 1], ], ], [ 'X-Cart-Key' => $cartKey, @@ -553,7 +583,7 @@ class CartControllerTest extends ModuleTestCase $response = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [ 'product_id' => $product->id, 'items' => [ - ['option_values' => $option->getLocalizedOptionValues(), 'quantity' => 6], + ['product_option_id' => $option->id, 'quantity' => 6], ], ], [ 'X-Cart-Key' => $cartKey, @@ -591,7 +621,7 @@ class CartControllerTest extends ModuleTestCase $response = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [ 'product_id' => $product->id, 'items' => [ - ['option_values' => $option->getLocalizedOptionValues(), 'quantity' => 1], + ['product_option_id' => $option->id, 'quantity' => 1], ], ], ['X-Cart-Key' => $cartKey]); @@ -620,7 +650,7 @@ class CartControllerTest extends ModuleTestCase $response = $this->postJson('/api/modules/sirsoft-ecommerce/cart', [ 'product_id' => $product->id, 'items' => [ - ['option_values' => $option->getLocalizedOptionValues(), 'quantity' => 5], + ['product_option_id' => $option->id, 'quantity' => 5], ], ], ['X-Cart-Key' => $cartKey]); diff --git a/templates/_bundled/sirsoft-basic/CHANGELOG.md b/templates/_bundled/sirsoft-basic/CHANGELOG.md index 1d467234..b2f0ec41 100644 --- a/templates/_bundled/sirsoft-basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-basic/CHANGELOG.md @@ -4,6 +4,16 @@ 형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며, [Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다. +## [1.0.4] - 2026-07-19 + +### Fixed + +- 상품 상세에서 옵션을 선택해 장바구니에 담거나 바로 구매할 때, 표시 언어(로케일)나 관리자의 옵션 이름 수정과 무관하게 선택한 옵션이 정확히 담기도록 개선했습니다. 특정 상황에서 "해당 옵션 조합을 찾을 수 없습니다" 오류로 담기가 실패하던 문제가 해소됩니다. + +### Changed + +- 옵션 담기 방식 개선에 따라 이 템플릿은 이커머스 모듈 1.0.5 이상이 필요합니다. + ## [1.0.3] - 2026-07-16 ### Changed diff --git a/templates/_bundled/sirsoft-basic/layouts/partials/shop/_product_purchase_card.json b/templates/_bundled/sirsoft-basic/layouts/partials/shop/_product_purchase_card.json index 03dd02b3..d65555c3 100644 --- a/templates/_bundled/sirsoft-basic/layouts/partials/shop/_product_purchase_card.json +++ b/templates/_bundled/sirsoft-basic/layouts/partials/shop/_product_purchase_card.json @@ -317,7 +317,7 @@ "method": "POST", "body": { "product_id": "{{product.data.id}}", - "items": "{{(_local.selectedItems ?? []).map(item => ({ option_values: item.optionValues, quantity: item.quantity }))}}" + "items": "{{(_local.selectedItems ?? []).map(item => ({ product_option_id: item.optionId, quantity: item.quantity }))}}" } }, "onSuccess": [ diff --git a/templates/_bundled/sirsoft-basic/layouts/partials/shop/detail/_purchase_card.json b/templates/_bundled/sirsoft-basic/layouts/partials/shop/detail/_purchase_card.json index 1d42b53b..be8dbc39 100644 --- a/templates/_bundled/sirsoft-basic/layouts/partials/shop/detail/_purchase_card.json +++ b/templates/_bundled/sirsoft-basic/layouts/partials/shop/detail/_purchase_card.json @@ -507,7 +507,7 @@ "params": { "method": "POST", "headers": { "X-Cart-Key": "{{_global.cartKey}}" }, - "body": "{{product.data?.has_options && (product.data?.options?.length ?? 0) > 1 ? { direct_items: (_local.selectedOptionItems ?? []).map(item => ({ product_id: product.data?.id, option_values: item.optionValues, quantity: item.quantity, additional_option_selections: Object.entries(item.additionalOptionSelections ?? {}).filter(([, vid]) => vid != null).map(([gid, vid]) => ({ additional_option_id: Number(gid), value_id: Number(vid), custom_text: item.additionalOptionCustomTexts?.[Number(gid)] })) })) } : { direct_items: [{ product_id: product.data?.id, quantity: _local.noOptionQuantity ?? 1 }] }}}" + "body": "{{product.data?.has_options && (product.data?.options?.length ?? 0) > 1 ? { direct_items: (_local.selectedOptionItems ?? []).map(item => ({ product_id: product.data?.id, product_option_id: item.optionId, quantity: item.quantity, additional_option_selections: Object.entries(item.additionalOptionSelections ?? {}).filter(([, vid]) => vid != null).map(([gid, vid]) => ({ additional_option_id: Number(gid), value_id: Number(vid), custom_text: item.additionalOptionCustomTexts?.[Number(gid)] })) })) } : { direct_items: [{ product_id: product.data?.id, quantity: _local.noOptionQuantity ?? 1 }] }}}" }, "onSuccess": [ { @@ -591,7 +591,7 @@ "params": { "method": "POST", "headers": { "X-Cart-Key": "{{_global.cartKey}}" }, - "body": "{{product.data?.has_options && (product.data?.options?.length ?? 0) > 1 ? { product_id: product.data?.id, items: (_local.selectedOptionItems ?? []).map(item => ({ option_values: item.optionValues, quantity: item.quantity, additional_option_selections: Object.entries(item.additionalOptionSelections ?? {}).filter(([, vid]) => vid != null).map(([gid, vid]) => ({ additional_option_id: Number(gid), value_id: Number(vid), custom_text: item.additionalOptionCustomTexts?.[Number(gid)] })) })) } : { product_id: product.data?.id, items: [{ quantity: _local.noOptionQuantity ?? 1 }] }}}" + "body": "{{product.data?.has_options && (product.data?.options?.length ?? 0) > 1 ? { product_id: product.data?.id, items: (_local.selectedOptionItems ?? []).map(item => ({ product_option_id: item.optionId, quantity: item.quantity, additional_option_selections: Object.entries(item.additionalOptionSelections ?? {}).filter(([, vid]) => vid != null).map(([gid, vid]) => ({ additional_option_id: Number(gid), value_id: Number(vid), custom_text: item.additionalOptionCustomTexts?.[Number(gid)] })) })) } : { product_id: product.data?.id, items: [{ quantity: _local.noOptionQuantity ?? 1 }] }}}" }, "onSuccess": [ { "handler": "setState", "params": { "target": "local", "isAddingToCart": false, "selectedOptionItems": [], "currentSelection": {}, "noOptionQuantity": 1, "additionalOptions": {} } }, diff --git a/templates/_bundled/sirsoft-basic/package-lock.json b/templates/_bundled/sirsoft-basic/package-lock.json index f93b6349..61a77b8d 100644 --- a/templates/_bundled/sirsoft-basic/package-lock.json +++ b/templates/_bundled/sirsoft-basic/package-lock.json @@ -1,12 +1,12 @@ { "name": "sirsoft-basic", - "version": "1.0.2", + "version": "1.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sirsoft-basic", - "version": "1.0.2", + "version": "1.0.4", "license": "MIT", "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/templates/_bundled/sirsoft-basic/package.json b/templates/_bundled/sirsoft-basic/package.json index 70ff321d..aaa5f04a 100644 --- a/templates/_bundled/sirsoft-basic/package.json +++ b/templates/_bundled/sirsoft-basic/package.json @@ -1,6 +1,6 @@ { "name": "sirsoft-basic", - "version": "1.0.3", + "version": "1.0.4", "description": "Gnuboard7 Basic User Template Components - Nexibase Style", "type": "module", "main": "dist/components.js", diff --git a/templates/_bundled/sirsoft-basic/src/__tests__/layouts/shop-buy-now-direct-checkout.test.tsx b/templates/_bundled/sirsoft-basic/src/__tests__/layouts/shop-buy-now-direct-checkout.test.tsx index 43916af9..0fab6339 100644 --- a/templates/_bundled/sirsoft-basic/src/__tests__/layouts/shop-buy-now-direct-checkout.test.tsx +++ b/templates/_bundled/sirsoft-basic/src/__tests__/layouts/shop-buy-now-direct-checkout.test.tsx @@ -77,4 +77,16 @@ describe('_purchase_card.json - 바로 구매 장바구니 미경유', () => { }); expect(usesDirectItems).toBe(true); }); + + it('바로 구매 direct_items 가 옵션을 product_option_id(옵션 ID)로 전달한다 (값 기반 option_values 미사용)', () => { + const checkoutBody = buyNowApiCalls + .map((n) => n.params?.body) + .find((body) => typeof body === 'string' && body.includes('direct_items')) as + | string + | undefined; + expect(checkoutBody, '체크아웃 direct_items body 를 찾지 못함').toBeTruthy(); + // 옵션 식별은 옵션 ID 기반이어야 하며, 로케일 값 조합(option_values) 을 실어보내지 않는다 + expect(checkoutBody).toContain('product_option_id: item.optionId'); + expect(checkoutBody).not.toContain('option_values'); + }); }); diff --git a/templates/_bundled/sirsoft-basic/src/__tests__/layouts/shopAdditionalOptions.test.tsx b/templates/_bundled/sirsoft-basic/src/__tests__/layouts/shopAdditionalOptions.test.tsx index d62a4c04..ecb49e7a 100644 --- a/templates/_bundled/sirsoft-basic/src/__tests__/layouts/shopAdditionalOptions.test.tsx +++ b/templates/_bundled/sirsoft-basic/src/__tests__/layouts/shopAdditionalOptions.test.tsx @@ -165,6 +165,12 @@ describe('_purchase_card.json 구조 — 블럭별 추가옵션', () => { expect(str).toContain('value_id'); }); + it('담기/구매 body가 메인 옵션을 product_option_id(옵션 ID)로 전달한다 (값 기반 option_values 미사용)', () => { + // 메인 옵션 식별은 옵션 ID 기반이어야 하며, 로케일 값 조합(option_values) 을 실어보내지 않는다 + expect(str).toContain('product_option_id: item.optionId'); + expect(str).not.toContain('option_values'); + }); + it('필수 추가옵션 미선택 차단 가드(additional_option_required)가 있다', () => { expect(str).toContain('shop.additional_option_required'); expect(str).toContain('is_required'); diff --git a/templates/_bundled/sirsoft-basic/template.json b/templates/_bundled/sirsoft-basic/template.json index 54e1b21f..b4f2e053 100644 --- a/templates/_bundled/sirsoft-basic/template.json +++ b/templates/_bundled/sirsoft-basic/template.json @@ -5,7 +5,7 @@ "ko": "Basic", "en": "Basic" }, - "version": "1.0.3", + "version": "1.0.4", "license": "MIT", "description": { "ko": "그누보드7 기본 사용자 템플릿", @@ -26,7 +26,7 @@ "dependencies": { "modules": { "sirsoft-board": ">=1.0.0", - "sirsoft-ecommerce": ">=1.0.2", + "sirsoft-ecommerce": ">=1.0.5", "sirsoft-page": ">=1.0.0" }, "plugins": { diff --git a/templates/_bundled/sirsoft-basic/tests/Playwright/specs/shop-cart-option-id.spec.ts b/templates/_bundled/sirsoft-basic/tests/Playwright/specs/shop-cart-option-id.spec.ts new file mode 100644 index 00000000..daf93426 --- /dev/null +++ b/templates/_bundled/sirsoft-basic/tests/Playwright/specs/shop-cart-option-id.spec.ts @@ -0,0 +1,78 @@ +/** + * 상품상세 담기/바로구매가 메인 옵션을 product_option_id(옵션 ID)로 전송하는지 검증하는 유저 흐름 E2E. + * 템플릿 sirsoft-basic (유저 화면). (skeleton, placeholder) + * + * @scenario cart-add-option-id + * @effects add_to_cart_sends_product_option_id, + * buy_now_direct_items_sends_product_option_id, + * cart_reflects_exact_selected_option, + * foreign_product_option_id_rejected + * + * 배경: 담기/바로구매가 메인 옵션을 로케일 값 조합(option_values)으로 전송하고 서버가 + * getLocalizedOptionValues() 문자열 동등비교로 역매칭하던 방식은, 클라·서버 로케일 + * 불일치나 관리자 옵션 값 텍스트 수정 시 매칭 실패(option_values_not_found)를 유발했다. + * 옵션 식별을 product_option_id 기반으로 전환해(클라가 이미 보유한 SelectedItem.optionId + * 전송), 서버는 요청 상품(product_id)의 옵션 집합 내에서 ID 로만 조회한다. + * + * e2e:allow 유저 담기/바로구매 흐름 — data-testid 보강 + 실 도메인 옵션 상품 시드 후 활성화한다. + * 현재 커버리지: + * (1) 레이아웃 payload 구조 — templates/_bundled/sirsoft-basic/src/__tests__/layouts/ + * shop-buy-now-direct-checkout.test.tsx ("direct_items 가 product_option_id: item.optionId + * 전송, option_values 미사용"), shopAdditionalOptions.test.tsx ("담기/구매 body 가 + * product_option_id 전송, option_values 미사용") green. + * (2) 백엔드 계약 — modules/_bundled/sirsoft-ecommerce PHPUnit: + * CartControllerTest("다른 상품 product_option_id 주입 거부", 일괄 담기/기본옵션), + * CheckoutControllerTest(direct_items), CartServiceTest/TempOrderServiceTest green. + * + * 본 spec 은 다음 사전 작업 완료 후 활성화한다: + * 1. 옵션 2조합(예: 빨강/L · 파랑/M) 보유 상품 시드 + PRODUCT_URL 확정 + * 2. _purchase_card 옵션 Select 에 data-testid="option-group-{idx}", 담기/바로구매 버튼 data-testid + * 3. _cart_item 옵션 라벨에 data-testid + * 4. PLAYWRIGHT_BASE_URL = 실 도메인, test.describe.skip → test.describe + * + * 매트릭스: + * T1 옵션 상품 담기 → POST /cart body.items[].product_option_id 가 선택 옵션 ID 와 일치, option_values 미포함 + * T2 바로구매 → POST /checkout direct_items[].product_option_id 전송 → 체크아웃 진입 + * T3 담은 뒤 장바구니에 "정확히 선택한 옵션"(빨강/L)이 반영 (로케일/텍스트 변경과 무관) + * T4 위변조: 타 상품 옵션 ID 주입 → 서버 거부(장바구니 미생성) + */ +import { test, expect } from '@playwright/test'; + +// 옵션 조합 보유 시드 상품 상세 (실 도메인 시드 후 경로 확정) +const PRODUCT_URL = '/shop/products/{OPTION_PRODUCT_ID}'; + +test.describe.skip('유저 담기/바로구매 옵션 ID 전송 (placeholder — data-testid 보강 + 시드 후 활성화)', () => { + test('T1 옵션 상품 담기 요청이 product_option_id 를 전송한다 (option_values 미사용)', async ({ page }) => { + await page.goto(PRODUCT_URL); + await page.getByTestId('option-group-0').selectOption({ index: 1 }); + + const [request] = await Promise.all([ + page.waitForRequest((r) => r.url().includes('/cart') && r.method() === 'POST'), + page.getByTestId('add-to-cart').click(), + ]); + const body = request.postDataJSON(); + expect(body.items?.[0]?.product_option_id).toEqual(expect.any(Number)); + expect(body.items?.[0]).not.toHaveProperty('option_values'); + }); + + test('T2 바로구매가 direct_items.product_option_id 로 체크아웃한다', async ({ page }) => { + await page.goto(PRODUCT_URL); + await page.getByTestId('option-group-0').selectOption({ index: 1 }); + + const [request] = await Promise.all([ + page.waitForRequest((r) => r.url().includes('/checkout') && r.method() === 'POST'), + page.getByTestId('buy-now').click(), + ]); + const body = request.postDataJSON(); + expect(body.direct_items?.[0]?.product_option_id).toEqual(expect.any(Number)); + expect(body.direct_items?.[0]).not.toHaveProperty('option_values'); + }); + + test('T3 담은 뒤 장바구니에 정확히 선택한 옵션이 반영된다', async ({ page }) => { + await page.goto(PRODUCT_URL); + await page.getByTestId('option-group-0').selectOption({ label: /빨강/ }); + await page.getByTestId('add-to-cart').click(); + await page.goto('/shop/cart'); + await expect(page.getByTestId('cart-item').first()).toContainText(/빨강/); + }); +});