feat(seo): 사이트맵 스트리밍 생성 + SEO 캐시 설정 정합화
대용량 사이트에서 사이트맵 생성이 메모리 부족으로 반복 실패하던 문제(공개 )의 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)에 따라 마감 단계에서 일괄 수행한다.
This commit is contained in:
1 parent
fcaacad8d1
commit
21f666e4b5
75 files changed
+4767
-951
No files matched your search
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Contracts\Repositories;
|
||||
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* SEO 캐시 통계 Repository 인터페이스
|
||||
*/
|
||||
interface SeoCacheStatRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* 캐시 통계 레코드를 기록합니다.
|
||||
*
|
||||
* @param array<string, mixed> $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<int, array{group: string|null, total: int, hits: int, misses: int, avg_response_time_ms: float|null}> 그룹별 집계 결과
|
||||
*/
|
||||
public function aggregateGrouped(string $groupBy, ?Carbon $since = null): array;
|
||||
|
||||
/**
|
||||
* 기준 시점보다 오래된 통계 레코드를 삭제합니다.
|
||||
*
|
||||
* @param Carbon $cutoff 기준 시점
|
||||
* @return int 삭제된 레코드 수
|
||||
*/
|
||||
public function deleteOlderThan(Carbon $cutoff): int;
|
||||
}
|
||||
@@ -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, [
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repositories;
|
||||
|
||||
use App\Contracts\Repositories\SeoCacheStatRepositoryInterface;
|
||||
use App\Models\SeoCacheStat;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Database\Query\Expression;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* SEO 캐시 통계 Repository
|
||||
*
|
||||
* SEO 캐시 히트/미스 통계의 데이터 접근 계층을 담당합니다.
|
||||
*/
|
||||
class SeoCacheStatRepository implements SeoCacheStatRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* 그룹 집계에 허용된 컬럼 목록
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const GROUPABLE_COLUMNS = ['layout_name', 'module_identifier'];
|
||||
|
||||
/**
|
||||
* 캐시 통계 레코드를 기록합니다.
|
||||
*
|
||||
* @param array<string, mixed> $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<int, array{group: string|null, total: int, hits: int, misses: int, avg_response_time_ms: float|null}> 그룹별 집계 결과
|
||||
*
|
||||
* @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<int, Expression> 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"),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Seo;
|
||||
|
||||
/**
|
||||
* SEO 캐시 설정 해석기 (D19 우선순위 규칙의 단일 출처)
|
||||
*
|
||||
* 캐시 설정은 두 곳에서 지정할 수 있다:
|
||||
*
|
||||
* - 고급 탭 (`cache.*`) — 모든 캐시 설정의 메인(기준값)
|
||||
* - SEO 탭 (`seo.*`) — 해당 영역에 별도 지정이 있으면 그것이 오버라이드
|
||||
*
|
||||
* "별도 지정" 판정은 **null 여부**로 한다 — SEO 탭 값이 null(미설정)이면 고급 탭 값을 따르고,
|
||||
* 값이 있으면(false / 0 포함) 그 값이 이긴다. `Config::get` 은 키가 존재하면 값이 null 이어도
|
||||
* 기본값으로 대체하지 않으므로 null 이 "미설정" 신호로 그대로 전달된다.
|
||||
*
|
||||
* 이 규칙이 호출처마다 흩어지면 도메인별로 우선순위가 갈라진다(과거 결함의 원인).
|
||||
* SEO 캐시 설정을 읽는 모든 지점은 이 클래스를 경유한다.
|
||||
*/
|
||||
final class SeoCacheSettings
|
||||
{
|
||||
/**
|
||||
* SEO 페이지 캐시 사용 여부
|
||||
*
|
||||
* @return bool 캐시 사용 여부
|
||||
*/
|
||||
public static function pageCacheEnabled(): bool
|
||||
{
|
||||
return (bool) self::resolve('seo.cache_enabled', 'cache.seo_enabled', true);
|
||||
}
|
||||
|
||||
/**
|
||||
* SEO 페이지 캐시 유지 시간 (초)
|
||||
*
|
||||
* @return int 캐시 TTL
|
||||
*/
|
||||
public static function pageCacheTtl(): int
|
||||
{
|
||||
return (int) self::resolve('seo.cache_ttl', 'cache.seo_ttl', 7200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sitemap 캐시 유지 시간 (초)
|
||||
*
|
||||
* @return int 캐시 TTL
|
||||
*/
|
||||
public static function sitemapCacheTtl(): int
|
||||
{
|
||||
return (int) self::resolve('seo.sitemap_cache_ttl', 'cache.seo_sitemap_ttl', 86400);
|
||||
}
|
||||
|
||||
/**
|
||||
* 오버라이드 → 메인 → 기본값 순으로 설정값을 해석합니다.
|
||||
*
|
||||
* @param string $overrideKey SEO 탭 키 (별도 지정 — null 이면 미설정)
|
||||
* @param string $mainKey 고급 탭 키 (메인)
|
||||
* @param mixed $fallback 둘 다 없을 때의 최종 기본값
|
||||
* @return mixed 해석된 설정값
|
||||
*/
|
||||
private static function resolve(string $overrideKey, string $mainKey, mixed $fallback): mixed
|
||||
{
|
||||
return g7_core_settings($overrideKey) ?? g7_core_settings($mainKey, $fallback);
|
||||
}
|
||||
}
|
||||
+115
-127
@@ -2,26 +2,34 @@
|
||||
|
||||
namespace App\Seo;
|
||||
|
||||
use App\Models\SeoCacheStat;
|
||||
use App\Contracts\Repositories\SeoCacheStatRepositoryInterface;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* SEO 캐시 통계 서비스
|
||||
*
|
||||
* 캐시 히트/미스를 기록하고 통계 데이터를 제공합니다.
|
||||
* 데이터 접근은 Repository 인터페이스에 위임합니다.
|
||||
*/
|
||||
class SeoCacheStatsService
|
||||
{
|
||||
/**
|
||||
* SeoCacheStatsService 생성자
|
||||
*
|
||||
* @param SeoCacheStatRepositoryInterface $statRepository SEO 캐시 통계 Repository
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly SeoCacheStatRepositoryInterface $statRepository,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 캐시 히트를 기록합니다.
|
||||
*
|
||||
* @param string $url 요청 URL
|
||||
* @param string $locale 로케일
|
||||
* @param string|null $layoutName 레이아웃명
|
||||
* @param string|null $moduleIdentifier 모듈 식별자
|
||||
* @return void
|
||||
* @param string $url 요청 URL
|
||||
* @param string $locale 로케일
|
||||
* @param string|null $layoutName 레이아웃명
|
||||
* @param string|null $moduleIdentifier 모듈 식별자
|
||||
*/
|
||||
public function recordHit(
|
||||
string $url,
|
||||
@@ -29,31 +37,23 @@ class SeoCacheStatsService
|
||||
?string $layoutName = null,
|
||||
?string $moduleIdentifier = null
|
||||
): void {
|
||||
try {
|
||||
SeoCacheStat::create([
|
||||
'url' => $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<int, array{layout_name: string|null, total: int, hits: int, misses: int, hit_rate: float, avg_response_time_ms: float|null}>
|
||||
* @param Carbon|null $since 조회 시작 시점 (null이면 전체 기간)
|
||||
* @return array<int, array{layout_name: string|null, total: int, hits: int, misses: int, hit_rate: float, avg_response_time_ms: float|null}> 레이아웃별 캐시 통계
|
||||
*/
|
||||
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<int, array{module_identifier: string|null, total: int, hits: int, misses: int, hit_rate: float, avg_response_time_ms: float|null}>
|
||||
* @param Carbon|null $since 조회 시작 시점 (null이면 전체 기간)
|
||||
* @return array<int, array{module_identifier: string|null, total: int, hits: int, misses: int, hit_rate: float, avg_response_time_ms: float|null}> 모듈별 캐시 통계
|
||||
*/
|
||||
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<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
<?php
|
||||
|
||||
namespace App\Seo;
|
||||
|
||||
use App\Contracts\Extension\StorageInterface;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
* Sitemap 파일 저장소 (읽기측)
|
||||
*
|
||||
* SitemapWriter 가 비공개 디스크에 커밋한 sitemap 인덱스/자식 파일을 조회하고
|
||||
* 스트리밍 응답으로 반환합니다. public/storage 심볼릭 링크에 의존하지 않으며,
|
||||
* 자식 파일(최대 50MB)을 메모리에 적재하지 않고 스트리밍합니다.
|
||||
*
|
||||
* 디스크 레이아웃(카테고리 cache 하위):
|
||||
* sitemap/manifest.json 커밋 마커 + 자식 목록 메타
|
||||
* sitemap/sitemap.xml sitemapindex
|
||||
* sitemap/sitemap-{n}.xml 자식 sitemap (gzip 시 .xml.gz)
|
||||
* sitemap/_tmp/ 생성 중 임시 디렉토리 (커밋 시 정리)
|
||||
*/
|
||||
class SitemapFileStore
|
||||
{
|
||||
/**
|
||||
* StorageInterface 카테고리
|
||||
*/
|
||||
public const CATEGORY = 'cache';
|
||||
|
||||
/**
|
||||
* 커밋된 sitemap 파일 디렉토리 (카테고리 하위 상대 경로)
|
||||
*/
|
||||
public const LIVE_DIR = 'sitemap';
|
||||
|
||||
/**
|
||||
* 생성 중 임시 디렉토리 (카테고리 하위 상대 경로)
|
||||
*/
|
||||
public const TMP_DIR = 'sitemap/_tmp';
|
||||
|
||||
/**
|
||||
* 커밋 마커 파일명
|
||||
*/
|
||||
public const MANIFEST_FILE = 'manifest.json';
|
||||
|
||||
/**
|
||||
* sitemapindex 파일명
|
||||
*/
|
||||
public const INDEX_FILE = 'sitemap.xml';
|
||||
|
||||
/**
|
||||
* SitemapFileStore 생성자
|
||||
*
|
||||
* @param StorageInterface $storage 비공개 디스크 스토리지 드라이버
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly StorageInterface $storage,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 자식 sitemap 파일명을 반환합니다.
|
||||
*
|
||||
* 파일명 규칙의 단일 출처이며, SitemapWriter(기록)와 web 라우트(서빙)가 공유합니다.
|
||||
*
|
||||
* @param int $n 자식 번호 (1부터)
|
||||
* @param bool $gzip gzip 압축 여부
|
||||
* @return string 자식 파일명 (예: sitemap-1.xml, sitemap-1.xml.gz)
|
||||
*/
|
||||
public static function childFilename(int $n, bool $gzip = false): string
|
||||
{
|
||||
return 'sitemap-'.$n.'.xml'.($gzip ? '.gz' : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 자식 sitemap 의 공개 절대 URL 을 반환합니다.
|
||||
*
|
||||
* sitemapindex 의 <loc> 값이며, 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;
|
||||
}
|
||||
}
|
||||
+107
-77
@@ -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<array<string, mixed>> URL 항목 순회자
|
||||
*/
|
||||
private function drain(SitemapContributorInterface $contributor): iterable
|
||||
{
|
||||
return $contributor->getUrls();
|
||||
}
|
||||
|
||||
/**
|
||||
* 기여자 URL 항목의 'url' 키를 'loc'(절대 URL)으로 변환합니다.
|
||||
*
|
||||
* @param array<string, mixed> $entry URL 항목
|
||||
* @return array<string, mixed> 변환된 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 항목 하나를 <url> 블록 XML로 렌더링합니다.
|
||||
*
|
||||
* XML 이스케이프의 단일 출처는 SitemapXmlRenderer 이며 이 메서드는 위임합니다.
|
||||
*
|
||||
* @param array{loc?: string, lastmod?: string, changefreq?: string, priority?: float} $entry URL 항목
|
||||
* @return string <url> 블록 문자열
|
||||
*/
|
||||
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 = '<?xml version="1.0" encoding="UTF-8"?>'."\n";
|
||||
|
||||
if ($isMultilingual) {
|
||||
$xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"'."\n";
|
||||
$xml .= ' xmlns:xhtml="http://www.w3.org/1999/xhtml">'."\n";
|
||||
} else {
|
||||
$xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'."\n";
|
||||
}
|
||||
$xml = $renderer->urlsetHeader();
|
||||
|
||||
foreach ($urls as $entry) {
|
||||
$baseLoc = $entry['loc'];
|
||||
|
||||
if ($isMultilingual) {
|
||||
// 각 로케일별 <url> 항목 생성
|
||||
foreach ($supportedLocales as $locale) {
|
||||
$localeLoc = $locale === $defaultLocale
|
||||
? $baseLoc
|
||||
: $baseLoc.'?locale='.$locale;
|
||||
|
||||
$xml .= ' <url>'."\n";
|
||||
$xml .= ' <loc>'.htmlspecialchars($localeLoc, ENT_XML1, 'UTF-8').'</loc>'."\n";
|
||||
|
||||
if (! empty($entry['lastmod'])) {
|
||||
$xml .= ' <lastmod>'.htmlspecialchars($entry['lastmod'], ENT_XML1, 'UTF-8').'</lastmod>'."\n";
|
||||
}
|
||||
|
||||
if (! empty($entry['changefreq'])) {
|
||||
$xml .= ' <changefreq>'.htmlspecialchars($entry['changefreq'], ENT_XML1, 'UTF-8').'</changefreq>'."\n";
|
||||
}
|
||||
|
||||
if (isset($entry['priority'])) {
|
||||
$xml .= ' <priority>'.number_format((float) $entry['priority'], 1).'</priority>'."\n";
|
||||
}
|
||||
|
||||
// 모든 로케일의 hreflang alternate 링크
|
||||
foreach ($supportedLocales as $altLocale) {
|
||||
$altHref = $altLocale === $defaultLocale
|
||||
? $baseLoc
|
||||
: $baseLoc.'?locale='.$altLocale;
|
||||
$xml .= ' <xhtml:link rel="alternate" hreflang="'.htmlspecialchars($altLocale, ENT_XML1, 'UTF-8').'" href="'.htmlspecialchars($altHref, ENT_XML1, 'UTF-8').'"/>'."\n";
|
||||
}
|
||||
|
||||
// x-default = 기본 로케일 URL
|
||||
$xml .= ' <xhtml:link rel="alternate" hreflang="x-default" href="'.htmlspecialchars($baseLoc, ENT_XML1, 'UTF-8').'"/>'."\n";
|
||||
|
||||
$xml .= ' </url>'."\n";
|
||||
}
|
||||
} else {
|
||||
// 단일 언어: 기존 형식 유지
|
||||
$xml .= ' <url>'."\n";
|
||||
$xml .= ' <loc>'.htmlspecialchars($baseLoc, ENT_XML1, 'UTF-8').'</loc>'."\n";
|
||||
|
||||
if (! empty($entry['lastmod'])) {
|
||||
$xml .= ' <lastmod>'.htmlspecialchars($entry['lastmod'], ENT_XML1, 'UTF-8').'</lastmod>'."\n";
|
||||
}
|
||||
|
||||
if (! empty($entry['changefreq'])) {
|
||||
$xml .= ' <changefreq>'.htmlspecialchars($entry['changefreq'], ENT_XML1, 'UTF-8').'</changefreq>'."\n";
|
||||
}
|
||||
|
||||
if (isset($entry['priority'])) {
|
||||
$xml .= ' <priority>'.number_format((float) $entry['priority'], 1).'</priority>'."\n";
|
||||
}
|
||||
|
||||
$xml .= ' </url>'."\n";
|
||||
}
|
||||
$xml .= $renderer->urlBlock($entry);
|
||||
}
|
||||
|
||||
$xml .= '</urlset>';
|
||||
$xml .= $renderer->urlsetFooter();
|
||||
|
||||
return $xml;
|
||||
}
|
||||
|
||||
@@ -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<string, mixed>}
|
||||
* 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 을 기록합니다.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
<?php
|
||||
|
||||
namespace App\Seo;
|
||||
|
||||
use App\Contracts\Extension\StorageInterface;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Sitemap 파일 기록기 (스트리밍 분할 writer)
|
||||
*
|
||||
* URL 을 한 건씩 받아 자식 sitemap 파일로 분할 기록합니다.
|
||||
* sitemaps.org 프로토콜(파일당 50,000 URL / 50MB)을 준수하며,
|
||||
* 전체 URL 을 메모리에 적재하지 않습니다.
|
||||
*
|
||||
* StorageInterface 는 append 스트림을 제공하지 않고 put() 이 매 호출 파일 전체를
|
||||
* 덮어쓰므로, 자식 파일 하나 분량만 메모리 버퍼에 쌓았다가 임계 도달 시 flush 합니다.
|
||||
* 따라서 최대 메모리 사용량은 "자식 파일 1개 크기"로 제한됩니다.
|
||||
*
|
||||
* 커밋은 임시 디렉토리(_tmp)에 전량 기록한 뒤 live 로 재배치하고,
|
||||
* manifest.json 을 마지막에 기록하는 방식으로 수행합니다(manifest = 커밋 마커).
|
||||
*/
|
||||
class SitemapWriter
|
||||
{
|
||||
/**
|
||||
* 파일당 URL 수 상한 (sitemaps.org 프로토콜)
|
||||
*/
|
||||
public const HARD_URL_CAP = 50000;
|
||||
|
||||
/**
|
||||
* 파일당 바이트 상한 (프로토콜 50MB 에 대한 안전 여유)
|
||||
*/
|
||||
public const MAX_FILE_BYTES = 45 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* 현재 자식 파일 버퍼
|
||||
*/
|
||||
private string $buffer = '';
|
||||
|
||||
/**
|
||||
* 현재 자식 파일에 기록된 <url> 블록 수
|
||||
*/
|
||||
private int $currentUrlCount = 0;
|
||||
|
||||
/**
|
||||
* 현재 자식 파일 번호 (1부터)
|
||||
*/
|
||||
private int $childIndex = 0;
|
||||
|
||||
/**
|
||||
* 현재 자식 파일이 열려 있는지 여부
|
||||
*/
|
||||
private bool $childOpen = false;
|
||||
|
||||
/**
|
||||
* 현재 자식 파일의 최신 lastmod
|
||||
*/
|
||||
private ?string $childLastmod = null;
|
||||
|
||||
/**
|
||||
* 기록된 자식 파일 목록
|
||||
*
|
||||
* @var array<int, array{file: string, loc: string, lastmod: ?string, url_count: int, size_bytes: int}>
|
||||
*/
|
||||
private array $children = [];
|
||||
|
||||
/**
|
||||
* 전체 <url> 블록 수
|
||||
*/
|
||||
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<int, string> 파일명 배열 (디렉토리 제외)
|
||||
*/
|
||||
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<int, string> $staleFiles 커밋 전 live 파일명 목록
|
||||
* @param array<int, string> $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]));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
<?php
|
||||
|
||||
namespace App\Seo;
|
||||
|
||||
/**
|
||||
* Sitemap XML 조각 렌더러
|
||||
*
|
||||
* urlset 헤더/푸터, URL 단위 <url> 블록, sitemapindex 를 생성합니다.
|
||||
* Sitemap XML 의 이스케이프 로직을 이 클래스 한 곳에서만 수행하며(단일 출처),
|
||||
* SitemapGenerator(수집측)와 SitemapWriter(기록측)가 이 렌더러를 공유합니다.
|
||||
*
|
||||
* 다국어(supported_locales 2개 이상)면 URL 하나당 로케일별 <url> 블록을 생성하고
|
||||
* 각 블록에 xhtml:link hreflang alternate 를 포함합니다.
|
||||
*/
|
||||
final class SitemapXmlRenderer
|
||||
{
|
||||
/**
|
||||
* 다국어 여부 (지원 로케일 2개 이상)
|
||||
*/
|
||||
private readonly bool $multilingual;
|
||||
|
||||
/**
|
||||
* SitemapXmlRenderer 생성자
|
||||
*
|
||||
* @param array<int, string> $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> 블록 개수를 반환합니다.
|
||||
*
|
||||
* 분할 임계(파일당 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 = '<?xml version="1.0" encoding="UTF-8"?>'."\n";
|
||||
|
||||
if ($this->multilingual) {
|
||||
$xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"'."\n";
|
||||
$xml .= ' xmlns:xhtml="http://www.w3.org/1999/xhtml">'."\n";
|
||||
} else {
|
||||
$xml .= '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'."\n";
|
||||
}
|
||||
|
||||
return $xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* urlset 닫는 태그를 반환합니다.
|
||||
*
|
||||
* @return string urlset 푸터 문자열
|
||||
*/
|
||||
public function urlsetFooter(): string
|
||||
{
|
||||
return '</urlset>';
|
||||
}
|
||||
|
||||
/**
|
||||
* URL 항목 하나를 <url> 블록으로 렌더링합니다.
|
||||
*
|
||||
* 다국어면 로케일 수만큼의 <url> 블록을 이어붙여 반환합니다.
|
||||
*
|
||||
* @param array{loc?: string, lastmod?: string, changefreq?: string, priority?: float} $entry URL 항목 (loc 은 절대 URL)
|
||||
* @return string <url> 블록 문자열 (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<int, array{loc: string, lastmod?: ?string}> $children 자식 sitemap 목록
|
||||
* @return string sitemapindex XML 문자열
|
||||
*/
|
||||
public function sitemapIndex(array $children): string
|
||||
{
|
||||
$xml = '<?xml version="1.0" encoding="UTF-8"?>'."\n";
|
||||
$xml .= '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'."\n";
|
||||
|
||||
foreach ($children as $child) {
|
||||
$xml .= ' <sitemap>'."\n";
|
||||
$xml .= ' <loc>'.$this->escape($child['loc']).'</loc>'."\n";
|
||||
|
||||
if (! empty($child['lastmod'])) {
|
||||
$xml .= ' <lastmod>'.$this->escape($child['lastmod']).'</lastmod>'."\n";
|
||||
}
|
||||
|
||||
$xml .= ' </sitemap>'."\n";
|
||||
}
|
||||
|
||||
$xml .= '</sitemapindex>';
|
||||
|
||||
return $xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 언어 <url> 블록을 렌더링합니다.
|
||||
*
|
||||
* @param string $loc 절대 URL
|
||||
* @param array $entry URL 항목
|
||||
* @return string <url> 블록 문자열
|
||||
*/
|
||||
private function renderSingleUrl(string $loc, array $entry): string
|
||||
{
|
||||
$xml = ' <url>'."\n";
|
||||
$xml .= ' <loc>'.$this->escape($loc).'</loc>'."\n";
|
||||
$xml .= $this->renderMetaFields($entry);
|
||||
$xml .= ' </url>'."\n";
|
||||
|
||||
return $xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 로케일의 <url> 블록(hreflang alternate 포함)을 렌더링합니다.
|
||||
*
|
||||
* @param string $baseLoc 기본 로케일 절대 URL
|
||||
* @param array $entry URL 항목
|
||||
* @param string $locale 대상 로케일
|
||||
* @return string <url> 블록 문자열
|
||||
*/
|
||||
private function renderLocalizedUrl(string $baseLoc, array $entry, string $locale): string
|
||||
{
|
||||
$xml = ' <url>'."\n";
|
||||
$xml .= ' <loc>'.$this->escape($this->localizedLoc($baseLoc, $locale)).'</loc>'."\n";
|
||||
$xml .= $this->renderMetaFields($entry);
|
||||
|
||||
// 모든 로케일의 hreflang alternate 링크
|
||||
foreach ($this->supportedLocales as $altLocale) {
|
||||
$altHref = $this->localizedLoc($baseLoc, (string) $altLocale);
|
||||
$xml .= ' <xhtml:link rel="alternate" hreflang="'.$this->escape((string) $altLocale).'" href="'.$this->escape($altHref).'"/>'."\n";
|
||||
}
|
||||
|
||||
// x-default = 기본 로케일 URL
|
||||
$xml .= ' <xhtml:link rel="alternate" hreflang="x-default" href="'.$this->escape($baseLoc).'"/>'."\n";
|
||||
|
||||
$xml .= ' </url>'."\n";
|
||||
|
||||
return $xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* lastmod / changefreq / priority 필드를 렌더링합니다.
|
||||
*
|
||||
* @param array $entry URL 항목
|
||||
* @return string 메타 필드 문자열
|
||||
*/
|
||||
private function renderMetaFields(array $entry): string
|
||||
{
|
||||
$xml = '';
|
||||
|
||||
if (! empty($entry['lastmod'])) {
|
||||
$xml .= ' <lastmod>'.$this->escape((string) $entry['lastmod']).'</lastmod>'."\n";
|
||||
}
|
||||
|
||||
if (! empty($entry['changefreq'])) {
|
||||
$xml .= ' <changefreq>'.$this->escape((string) $entry['changefreq']).'</changefreq>'."\n";
|
||||
}
|
||||
|
||||
if (isset($entry['priority'])) {
|
||||
$xml .= ' <priority>'.number_format((float) $entry['priority'], 1).'</priority>'."\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');
|
||||
}
|
||||
}
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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 이 아니면) 그 값이 우선합니다 |
|
||||
|
||||
**응답 예시**
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
];
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "生成中...",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
];
|
||||
@@ -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',
|
||||
],
|
||||
];
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"ko": "게시판",
|
||||
"en": "Board"
|
||||
},
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "게시판 관리를 위한 모듈",
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-board",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.3",
|
||||
"description": "그누보드7 게시판 모듈 프론트엔드 에셋",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<Board>
|
||||
* @return Builder<Board>
|
||||
*/
|
||||
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<Board> 활성 게시판 순회자 (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<string, mixed> $data 업데이트할 데이터
|
||||
* @param bool $applyAll 전체 적용 여부
|
||||
* @param array<int> $boardIds 특정 게시판 ID 목록 (applyAll=false일 때 사용)
|
||||
* @param array<string, mixed> $data 업데이트할 데이터
|
||||
* @param bool $applyAll 전체 적용 여부
|
||||
* @param array<int> $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'])
|
||||
|
||||
+36
-23
@@ -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<Board>
|
||||
* @return Builder<Board>
|
||||
*/
|
||||
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<Board> 활성 게시판 순회자 (id, slug, updated_at 만 조회)
|
||||
*/
|
||||
public function streamActiveForSitemap(int $chunkSize = 500): iterable;
|
||||
|
||||
/**
|
||||
* 필터용 전체 활성 게시판 목록을 반환합니다.
|
||||
*
|
||||
@@ -179,9 +192,9 @@ interface BoardRepositoryInterface
|
||||
/**
|
||||
* 게시판을 일괄 업데이트합니다.
|
||||
*
|
||||
* @param array<string, mixed> $data 업데이트할 데이터
|
||||
* @param bool $applyAll 전체 적용 여부
|
||||
* @param array<int> $boardIds 특정 게시판 ID 목록 (applyAll=false일 때 사용)
|
||||
* @param array<string, mixed> $data 업데이트할 데이터
|
||||
* @param bool $applyAll 전체 적용 여부
|
||||
* @param array<int> $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<int> $boardIds 게시판 ID 목록
|
||||
* @param array<int> $boardIds 게시판 ID 목록
|
||||
* @return array<int, int> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<Post> 공개 게시글 순회자 (id, updated_at 만 조회)
|
||||
*/
|
||||
public function streamPublishedForSitemap(int $boardId, int $chunkSize = 500): iterable;
|
||||
|
||||
/**
|
||||
* 게시판 ID 기준으로 게시글을 일괄 소프트 삭제합니다.
|
||||
*
|
||||
|
||||
@@ -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<Post> 공개 게시글 순회자 (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 기준으로 게시글을 일괄 소프트 삭제합니다.
|
||||
*
|
||||
|
||||
@@ -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<int, array<string, mixed>> $urls 누적 URL 배열 (참조)
|
||||
* @param array<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Board\Tests\Unit\Repositories;
|
||||
|
||||
require_once __DIR__.'/../../ModuleTestCase.php';
|
||||
|
||||
use Illuminate\Support\LazyCollection;
|
||||
use Modules\Sirsoft\Board\Models\Board;
|
||||
use Modules\Sirsoft\Board\Repositories\Contracts\BoardRepositoryInterface;
|
||||
use Modules\Sirsoft\Board\Tests\BoardTestCase;
|
||||
|
||||
/**
|
||||
* BoardRepository::streamActiveForSitemap 단위 테스트
|
||||
*
|
||||
* 검증 목적:
|
||||
* - 순회자(LazyCollection) 를 반환한다 (전체 적재 금지)
|
||||
* - sitemap 에 필요한 컬럼만 적재한다 (전컬럼 적재 회귀 차단)
|
||||
* - 활성 게시판만 반환한다
|
||||
*
|
||||
* @group board
|
||||
* @group unit
|
||||
* @group seo
|
||||
*/
|
||||
class BoardRepositorySitemapTest extends BoardTestCase
|
||||
{
|
||||
private BoardRepositoryInterface $repository;
|
||||
|
||||
protected function getTestBoardSlug(): string
|
||||
{
|
||||
return 'repo-sitemap-test';
|
||||
}
|
||||
|
||||
protected function getDefaultBoardAttributes(string $slug): array
|
||||
{
|
||||
return [
|
||||
'slug' => $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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 항목이 반드시 포함된다
|
||||
*/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"ko": "이커머스",
|
||||
"en": "Ecommerce"
|
||||
},
|
||||
"version": "1.0.4",
|
||||
"version": "1.0.5",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "그누보드7 이커머스 모듈 - 상품, 주문, 결제 관리",
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-ecommerce",
|
||||
"version": "1.0.4",
|
||||
"version": "1.0.5",
|
||||
"description": "그누보드7 이커머스 모듈 프론트엔드 에셋",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -15,6 +15,24 @@ class CategoryRepository implements CategoryRepositoryInterface
|
||||
protected Category $model
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Sitemap 용으로 활성 카테고리를 스트리밍 조회합니다.
|
||||
*
|
||||
* lazyById 는 id 기준 키셋 페이징으로 청크를 순차 조회하므로,
|
||||
* 결과셋 전체가 메모리(및 DB 드라이버 버퍼)에 적재되지 않습니다.
|
||||
*
|
||||
* @param int $chunkSize 청크 크기
|
||||
* @return iterable<Category> 활성 카테고리 순회자 (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();
|
||||
}
|
||||
|
||||
|
||||
+38
-28
@@ -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<Category> 활성 카테고리 순회자 (id, slug, updated_at 만 조회)
|
||||
*/
|
||||
public function streamActiveForSitemap(int $chunkSize = 500): iterable;
|
||||
}
|
||||
+10
@@ -29,6 +29,16 @@ interface ProductRepositoryInterface
|
||||
*/
|
||||
public function existsAny(): bool;
|
||||
|
||||
/**
|
||||
* Sitemap 용으로 전시 중인 상품을 스트리밍 조회합니다.
|
||||
*
|
||||
* 전체 적재를 피하기 위해 id 기준으로 청크 단위 지연 조회합니다.
|
||||
*
|
||||
* @param int $chunkSize 청크 크기
|
||||
* @return iterable<Product> 전시 중인 상품 순회자 (id, updated_at 만 조회)
|
||||
*/
|
||||
public function streamVisibleForSitemap(int $chunkSize = 500): iterable;
|
||||
|
||||
/**
|
||||
* 필터링된 상품 목록 조회 (페이지네이션)
|
||||
*
|
||||
|
||||
@@ -32,6 +32,24 @@ class ProductRepository implements ProductRepositoryInterface
|
||||
return $this->model->newQuery()->withTrashed()->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sitemap 용으로 전시 중인 상품을 스트리밍 조회합니다.
|
||||
*
|
||||
* lazyById 는 id 기준 키셋 페이징으로 청크를 순차 조회하므로,
|
||||
* 결과셋 전체가 메모리(및 DB 드라이버 버퍼)에 적재되지 않습니다.
|
||||
*
|
||||
* @param int $chunkSize 청크 크기
|
||||
* @return iterable<Product> 전시 중인 상품 순회자 (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}
|
||||
*/
|
||||
|
||||
@@ -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<int, array<string, mixed>> $urls 누적 URL 배열 (참조)
|
||||
* @param array<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
+101
-1
@@ -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이 모두 포함된다 (비파괴 회귀)
|
||||
*/
|
||||
|
||||
@@ -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",
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"ko": "페이지",
|
||||
"en": "Page"
|
||||
},
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"license": "MIT",
|
||||
"description": {
|
||||
"ko": "정적 페이지(정보/정책/안내) 관리 모듈",
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@g7/sirsoft-page",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"description": "그누보드7 페이지 모듈 프론트엔드 에셋",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
+14
-2
@@ -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<Page> 발행된 페이지 순회자 (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;
|
||||
|
||||
|
||||
@@ -72,6 +72,24 @@ class PageRepository implements PageRepositoryInterface
|
||||
return Page::where('slug', $slug)->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sitemap 용으로 발행된 페이지를 스트리밍 조회합니다.
|
||||
*
|
||||
* lazyById 는 id 기준 키셋 페이징으로 청크를 순차 조회하므로,
|
||||
* 결과셋 전체가 메모리(및 DB 드라이버 버퍼)에 적재되지 않습니다.
|
||||
*
|
||||
* @param int $chunkSize 청크 크기
|
||||
* @return iterable<Page> 발행된 페이지 순회자 (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로 페이지를 조회하며, 없으면 예외를 발생시킵니다.
|
||||
*
|
||||
|
||||
@@ -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<int, array<string, mixed>> $urls 누적 URL 배열 (참조)
|
||||
* @param array<string, mixed> $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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Sirsoft\Page\Tests\Unit\Seo;
|
||||
|
||||
require_once __DIR__.'/../../ModuleTestCase.php';
|
||||
|
||||
use App\Seo\Contracts\SitemapContributorInterface;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Modules\Sirsoft\Page\Models\Page;
|
||||
use Modules\Sirsoft\Page\Seo\PageSitemapContributor;
|
||||
use Modules\Sirsoft\Page\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* PageSitemapContributor 단위 테스트
|
||||
*
|
||||
* 검증 목적:
|
||||
* - getIdentifier: 'sirsoft-page' 반환
|
||||
* - getUrls: 발행된 페이지 URL 포함
|
||||
* - getUrls: 미발행 페이지 URL 미포함
|
||||
* - getUrls: URL 항목 구조 (url/lastmod/changefreq/priority)
|
||||
* - getUrls: 기여자당 URL 안전 상한 초과 시 절단
|
||||
*
|
||||
* @group page
|
||||
* @group unit
|
||||
* @group seo
|
||||
*/
|
||||
class PageSitemapContributorTest extends ModuleTestCase
|
||||
{
|
||||
private PageSitemapContributor $contributor;
|
||||
|
||||
/**
|
||||
* 테스트 초기화 - 기여자를 컨테이너로 해석합니다.
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Repository 주입이 필요하므로 컨테이너로 해석합니다.
|
||||
$this->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());
|
||||
}
|
||||
}
|
||||
@@ -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...",
|
||||
|
||||
@@ -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": "생성 중...",
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Seo;
|
||||
|
||||
use App\Seo\Contracts\SitemapContributorInterface;
|
||||
use Mockery;
|
||||
use Modules\Sirsoft\Board\Enums\PostStatus;
|
||||
use Modules\Sirsoft\Board\Models\Board;
|
||||
use Modules\Sirsoft\Board\Models\Post;
|
||||
use Modules\Sirsoft\Board\Seo\BoardSitemapContributor;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* BoardSitemapContributor 단위 테스트
|
||||
*
|
||||
* Board 모듈의 Sitemap 기여자가 올바른 식별자를 반환하고,
|
||||
* 게시판/게시글 URL을 올바르게 생성하는지 검증합니다.
|
||||
*/
|
||||
class BoardSitemapContributorTest extends TestCase
|
||||
{
|
||||
private BoardSitemapContributor $contributor;
|
||||
|
||||
/**
|
||||
* 테스트 초기화 - BoardSitemapContributor 인스턴스를 준비합니다.
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Seo;
|
||||
|
||||
use App\Seo\Contracts\SitemapContributorInterface;
|
||||
use Mockery;
|
||||
use Modules\Sirsoft\Ecommerce\Enums\ProductDisplayStatus;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Category;
|
||||
use Modules\Sirsoft\Ecommerce\Models\Product;
|
||||
use Modules\Sirsoft\Ecommerce\Seo\EcommerceSitemapContributor;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* EcommerceSitemapContributor 단위 테스트
|
||||
*
|
||||
* Ecommerce 모듈의 Sitemap 기여자가 올바른 식별자를 반환하고,
|
||||
* 상품/카테고리 URL을 올바르게 생성하는지 검증합니다.
|
||||
*/
|
||||
class EcommerceSitemapContributorTest extends TestCase
|
||||
{
|
||||
private EcommerceSitemapContributor $contributor;
|
||||
|
||||
/**
|
||||
* 테스트 초기화 - EcommerceSitemapContributor 인스턴스를 준비합니다.
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Seo;
|
||||
|
||||
use App\Seo\Contracts\SitemapContributorInterface;
|
||||
use Mockery;
|
||||
use Modules\Sirsoft\Page\Models\Page;
|
||||
use Modules\Sirsoft\Page\Seo\PageSitemapContributor;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* PageSitemapContributor 단위 테스트
|
||||
*
|
||||
* Page 모듈의 Sitemap 기여자가 올바른 식별자를 반환하고,
|
||||
* 발행된 페이지 URL을 올바르게 생성하는지 검증합니다.
|
||||
*/
|
||||
class PageSitemapContributorTest extends TestCase
|
||||
{
|
||||
private PageSitemapContributor $contributor;
|
||||
|
||||
/**
|
||||
* 테스트 초기화 - PageSitemapContributor 인스턴스를 준비합니다.
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->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']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Seo;
|
||||
|
||||
use App\Seo\SeoCacheSettings;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* SeoCacheSettings 단위 테스트 (결정 D19)
|
||||
*
|
||||
* 우선순위 규칙:
|
||||
* 고급 탭(`cache.*`) = 메인 / SEO 탭(`seo.*`)에 별도 지정이 있으면 오버라이드.
|
||||
* "별도 지정" 판정은 null 여부 — null 이면 미설정으로 보고 고급 탭 값을 따른다.
|
||||
*
|
||||
* 회귀 배경: 과거에는 `cache.*` 가 **항상** 이겨 SEO 탭 입력이 조용히 무시되었다.
|
||||
* 이 저장소의 `seo.json` 에 `cache_ttl=3600` 이 저장돼 있었으나 실제로는
|
||||
* `cache.seo_ttl=7200` 으로 동작하던 것이 실증 사례다.
|
||||
*/
|
||||
class SeoCacheSettingsTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* 테스트 초기화 - 두 카테고리를 모두 미설정 상태로 비웁니다.
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Config::set('g7_settings.core.seo', []);
|
||||
Config::set('g7_settings.core.cache', []);
|
||||
}
|
||||
|
||||
/**
|
||||
* pageCacheTtl: SEO 탭이 미설정이면 고급 탭 값을 따른다
|
||||
*/
|
||||
public function test_page_cache_ttl_follows_advanced_tab_when_seo_is_unset(): void
|
||||
{
|
||||
Config::set('g7_settings.core.cache.seo_ttl', 7200);
|
||||
Config::set('g7_settings.core.seo.cache_ttl', null);
|
||||
|
||||
$this->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());
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,8 @@ class SeoCacheStatsServiceTest extends TestCase
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->statsService = new SeoCacheStatsService;
|
||||
// Repository 주입이 필요하므로 컨테이너로 해석합니다.
|
||||
$this->statsService = $this->app->make(SeoCacheStatsService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Seo;
|
||||
|
||||
use App\Extension\Storage\CoreStorageDriver;
|
||||
use App\Seo\SitemapFileStore;
|
||||
use App\Seo\SitemapWriter;
|
||||
use App\Seo\SitemapXmlRenderer;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* SitemapFileStore 단위 테스트
|
||||
*
|
||||
* 읽기측 API(exists/getManifest/indexResponse/childResponse/lastGeneratedAt)와
|
||||
* 경로 SSoT(childFilename/childUrl)를 검증합니다. 이 API 는 sitemap 라우트가
|
||||
* 그대로 소비하는 표면이므로 단위 수준에서 계약을 고정합니다.
|
||||
*/
|
||||
class SitemapFileStoreTest extends TestCase
|
||||
{
|
||||
private CoreStorageDriver $storage;
|
||||
|
||||
private SitemapFileStore $store;
|
||||
|
||||
/**
|
||||
* 테스트 초기화 - 가짜 로컬 디스크를 준비합니다.
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Config::set('app.locale', 'ko');
|
||||
Config::set('app.supported_locales', ['ko']);
|
||||
|
||||
Storage::fake('local');
|
||||
|
||||
$this->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('<sitemapindex', $body);
|
||||
$this->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('<urlset', $body);
|
||||
$this->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());
|
||||
}
|
||||
}
|
||||
@@ -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, '<url>'));
|
||||
}
|
||||
|
||||
// ─── renderUrlBlock / urlset 헤더·푸터 ──────────────────
|
||||
|
||||
/**
|
||||
* renderUrlBlock()이 단일 언어 <url> 블록을 생성하는지 확인합니다.
|
||||
*/
|
||||
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('<loc>https://example.test/a</loc>', $block);
|
||||
$this->assertStringContainsString('<changefreq>daily</changefreq>', $block);
|
||||
$this->assertStringContainsString('<priority>0.8</priority>', $block);
|
||||
$this->assertStringNotContainsString('xhtml:link', $block);
|
||||
$this->assertSame(1, substr_count($block, '<url>'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, '<url>'));
|
||||
$this->assertStringContainsString('<loc>https://example.test/a</loc>', $block);
|
||||
$this->assertStringContainsString('<loc>https://example.test/a?locale=en</loc>', $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('<loc>https://example.test/a?x=1&y=2</loc>', $block);
|
||||
// 이스케이프되지 않은 원본 & 가 남아 있으면 XML 파싱이 깨집니다.
|
||||
$this->assertNotFalse(simplexml_load_string('<urlset>'.$block.'</urlset>'));
|
||||
}
|
||||
|
||||
/**
|
||||
* urlset 헤더/푸터가 로케일 수에 따라 분기되는지 확인합니다.
|
||||
*/
|
||||
public function test_urlset_header_switches_on_multilingual(): void
|
||||
{
|
||||
$this->assertStringNotContainsString('xmlns:xhtml', $this->generator->buildUrlsetHeader());
|
||||
$this->assertSame('</urlset>', $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<int, array<string, mixed>>
|
||||
*/
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Seo;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Contracts\Repositories\ConfigRepositoryInterface;
|
||||
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\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* SitemapManager::regenerate 단위 테스트
|
||||
*
|
||||
* regenerate() 는 S1 의 4대 산출물 중 하나이며 S2 의 Controller 가 반환 메타를 소비한다.
|
||||
* 기존 테스트(SeoCacheControllerTest / GenerateSitemapJobTest)는 SitemapManager 를 통째로
|
||||
* mock 하므로 본문이 한 번도 실행되지 않는다 — 이 테스트가 그 공백을 메운다.
|
||||
*
|
||||
* 검증 목적:
|
||||
* - sitemap_enabled=false 시 disabled 반환 + 훅 미발화
|
||||
* - 성공 시 반환 메타 5키 · 캐시(구 XML 키 정리 + 메타 저장) · last_updated_at 기록 · after_regenerate 훅
|
||||
* - 실패 시 failed 반환 + after_regenerate_failed 훅 발화(페이로드 포함)
|
||||
* - writer 가 설정(urls_per_file / gzip)을 반영
|
||||
* - ttl 폴백 사슬
|
||||
*/
|
||||
class SitemapManagerTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* 이 테스트가 등록·검증하는 sitemap 훅 목록
|
||||
*/
|
||||
private const SITEMAP_HOOKS = [
|
||||
'core.seo.sitemap.before_regenerate',
|
||||
'core.seo.sitemap.after_regenerate',
|
||||
'core.seo.sitemap.after_regenerate_failed',
|
||||
];
|
||||
|
||||
private CoreStorageDriver $storage;
|
||||
|
||||
/**
|
||||
* 테스트 초기화 - 가짜 디스크와 훅 레지스트리를 준비합니다.
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
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->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<int, string> $urls 상대 경로 목록
|
||||
* @return SitemapContributorInterface 가짜 기여자
|
||||
*/
|
||||
private function fakeContributor(array $urls): SitemapContributorInterface
|
||||
{
|
||||
return new class($urls) implements SitemapContributorInterface
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $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<int, string> $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']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Seo;
|
||||
|
||||
use App\Contracts\Extension\StorageInterface;
|
||||
use App\Extension\Storage\CoreStorageDriver;
|
||||
use App\Seo\SitemapFileStore;
|
||||
use App\Seo\SitemapWriter;
|
||||
use App\Seo\SitemapXmlRenderer;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* SitemapWriter 단위 테스트
|
||||
*
|
||||
* 임계 분할, sitemapindex 생성, gzip, atomic commit(manifest 마지막 기록),
|
||||
* stale 파일 정리, 대용량 시 bounded memory 를 검증합니다.
|
||||
*/
|
||||
class SitemapWriterTest extends TestCase
|
||||
{
|
||||
private RecordingStorage $storage;
|
||||
|
||||
/**
|
||||
* 테스트 초기화 - 가짜 로컬 디스크와 put 순서 기록 스토리지를 준비합니다.
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Config::set('app.locale', 'ko');
|
||||
Config::set('app.supported_locales', ['ko']);
|
||||
|
||||
Storage::fake('local');
|
||||
|
||||
$this->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 version="1.0" encoding="UTF-8"?>', $xml);
|
||||
$this->assertStringEndsWith('</urlset>', $xml);
|
||||
$this->assertLessThanOrEqual(SitemapWriter::HARD_URL_CAP, substr_count($xml, '<loc>'));
|
||||
$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('<sitemapindex', $index);
|
||||
$this->assertSame(3, substr_count($index, '<sitemap>'));
|
||||
$this->assertStringContainsString('<loc>'.url('/sitemap-1.xml').'</loc>', $index);
|
||||
$this->assertStringContainsString('<loc>'.url('/sitemap-3.xml').'</loc>', $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('<url>', $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 하나가 로케일 수만큼의 <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('</urlset>', $decoded);
|
||||
|
||||
// 인덱스는 비압축이며 .gz 자식을 가리킵니다.
|
||||
$index = $this->liveContent(SitemapFileStore::INDEX_FILE);
|
||||
$this->assertStringContainsString('<loc>'.url('/sitemap-1.xml.gz').'</loc>', $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 version="1.0" encoding="UTF-8"?>', $xml);
|
||||
$this->assertStringContainsString('</urlset>', $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<int, string>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Seo;
|
||||
|
||||
use App\Seo\SitemapXmlRenderer;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* SitemapXmlRenderer 단위 테스트
|
||||
*
|
||||
* D14 로 추출된 XML 조각 렌더러이며 sitemap 이스케이프의 단일 출처다.
|
||||
* SitemapGenerator(수집측)와 SitemapWriter(기록측)가 이 클래스를 공유하므로,
|
||||
* 여기서 깨지면 양쪽이 함께 깨진다.
|
||||
*
|
||||
* S3 ⑮(hreflang N² 해소)의 실제 편집 대상이 이 클래스이므로, 그 작업의
|
||||
* 회귀 기준선으로서 현재 동작을 고정한다.
|
||||
*
|
||||
* 검증 목적:
|
||||
* - 이스케이프: loc/lastmod/changefreq/hreflang/sitemapindex 전 경로
|
||||
* - 단일 언어 vs 다국어 분기 (헤더 xmlns / urlBlockCount / <url> 블록 수)
|
||||
* - hreflang alternate 구조 (현재: 로케일 수 × (로케일 수 + x-default))
|
||||
* - 로케일 URL 규칙 (기본 로케일은 원본, 그 외 ?locale=)
|
||||
* - sitemapindex 구조
|
||||
*/
|
||||
class SitemapXmlRendererTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* 단일 언어 렌더러를 만듭니다.
|
||||
*
|
||||
* @return SitemapXmlRenderer 렌더러
|
||||
*/
|
||||
private function singleLocale(): SitemapXmlRenderer
|
||||
{
|
||||
return new SitemapXmlRenderer(['ko'], 'ko');
|
||||
}
|
||||
|
||||
/**
|
||||
* 다국어 렌더러를 만듭니다.
|
||||
*
|
||||
* @param array<int, string> $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('<?xml version="1.0" encoding="UTF-8"?>', $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('</urlset>', $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: 단일 언어는 <url> 블록 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, '<url>'));
|
||||
$this->assertStringContainsString('<loc>https://example.test/a</loc>', $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('<lastmod>2026-07-17T00:00:00+09:00</lastmod>', $xml);
|
||||
$this->assertStringContainsString('<changefreq>daily</changefreq>', $xml);
|
||||
$this->assertStringContainsString('<priority>0.8</priority>', $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('<priority>1.0</priority>', $xml);
|
||||
}
|
||||
|
||||
/**
|
||||
* urlBlock: 메타 필드가 없으면 해당 태그를 생략한다
|
||||
*/
|
||||
public function test_url_block_omits_absent_meta_fields(): void
|
||||
{
|
||||
$xml = $this->singleLocale()->urlBlock(['loc' => 'https://example.test/a']);
|
||||
|
||||
$this->assertStringNotContainsString('<lastmod>', $xml);
|
||||
$this->assertStringNotContainsString('<changefreq>', $xml);
|
||||
$this->assertStringNotContainsString('<priority>', $xml);
|
||||
}
|
||||
|
||||
/**
|
||||
* urlBlock: 다국어는 로케일 수만큼 <url> 블록을 만든다
|
||||
*/
|
||||
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, '<url>'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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('<loc>https://example.test/a</loc>', $xml);
|
||||
$this->assertStringContainsString('<loc>https://example.test/a?locale=en</loc>', $xml);
|
||||
$this->assertStringContainsString('<loc>https://example.test/a?locale=ja</loc>', $xml);
|
||||
}
|
||||
|
||||
/**
|
||||
* urlBlock: hreflang alternate 가 현재 N² 구조임을 고정한다 (S3 ⑮ 회귀 기준선)
|
||||
*
|
||||
* 현재 구현은 <url> 블록마다 모든 로케일의 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(
|
||||
'<xhtml:link rel="alternate" hreflang="x-default" href="https://example.test/a"/>',
|
||||
$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<z>']);
|
||||
|
||||
$this->assertStringContainsString('<loc>https://example.test/a?x=1&y=2<z></loc>', $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' => 'da<ily',
|
||||
]);
|
||||
|
||||
$this->assertStringContainsString('<lastmod>2026 & later</lastmod>', $xml);
|
||||
$this->assertStringContainsString('<changefreq>da<ily</changefreq>', $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 version="1.0" encoding="UTF-8"?>', $xml);
|
||||
$this->assertStringContainsString('<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">', $xml);
|
||||
$this->assertSame(2, substr_count($xml, '<sitemap>'));
|
||||
$this->assertStringContainsString('<loc>https://example.test/sitemap-1.xml</loc>', $xml);
|
||||
// lastmod 가 null 인 자식은 태그를 생략한다
|
||||
$this->assertSame(1, substr_count($xml, '<lastmod>'));
|
||||
$this->assertStringEndsWith('</sitemapindex>', $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('<sitemap>', $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('<loc>https://example.test/sitemap-1.xml?v=1&t=2</loc>', $xml);
|
||||
$this->assertStringContainsString('<lastmod>2026 & later</lastmod>', $xml);
|
||||
$this->assertNotFalse(simplexml_load_string($xml));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Upgrades;
|
||||
|
||||
require_once __DIR__.'/../../../upgrades/data/7.1.0/migrations/01_NullifyUntouchedSeoCacheOverrides.php';
|
||||
|
||||
use App\Extension\UpgradeContext;
|
||||
use App\Upgrades\Data\V7_1_0\Migrations\NullifyUntouchedSeoCacheOverrides;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* 7.1.0 업그레이드 스텝 — SEO 캐시 오버라이드 이행 (결정 D19)
|
||||
*
|
||||
* 판정 규칙:
|
||||
* 옛 기본값과 같으면 "안 건드린 것" → null(미설정)로 비움
|
||||
* 옛 기본값과 다르면 "의도적으로 바꾼 것" → 보존 (이제부터 오버라이드로 발동)
|
||||
*
|
||||
* 이 판정이 틀리면 기존 사이트의 캐시 동작이 의도치 않게 바뀐다.
|
||||
*/
|
||||
class NullifyUntouchedSeoCacheOverridesTest extends TestCase
|
||||
{
|
||||
private NullifyUntouchedSeoCacheOverrides $migration;
|
||||
|
||||
/**
|
||||
* 테스트 초기화 - settings 디스크를 가짜로 대체합니다.
|
||||
*/
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Storage::fake('settings');
|
||||
$this->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<string, mixed> $settings 설정 배열
|
||||
*/
|
||||
private function writeSeoJson(array $settings): void
|
||||
{
|
||||
Storage::disk('settings')->put('seo.json', json_encode($settings));
|
||||
}
|
||||
|
||||
/**
|
||||
* seo.json 을 읽습니다.
|
||||
*
|
||||
* @return array<string, mixed> 설정 배열
|
||||
*/
|
||||
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']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Upgrades;
|
||||
|
||||
use App\Extension\AbstractUpgradeStep;
|
||||
|
||||
/**
|
||||
* 코어 7.1.0 업그레이드 스텝
|
||||
*
|
||||
* 모든 비즈니스 로직은 본 클래스 파일이 아닌 `upgrades/data/7.1.0/` 안에 격리된다:
|
||||
*
|
||||
* - migrations/
|
||||
* 01_NullifyUntouchedSeoCacheOverrides.php
|
||||
* SEO 캐시 설정을 "미설정(null) = 고급 탭 값 사용" 의미로 이행 (결정 D19).
|
||||
* 옛 기본값 그대로인 키는 null 로 비우고, 다른 값이면 오버라이드로 보존한다.
|
||||
*
|
||||
* 본 클래스는 `AbstractUpgradeStep` 의 default `run()` 에 위임 — 별도 override 없음.
|
||||
*
|
||||
* @upgrade-path 7.0.x → 7.1.0
|
||||
*
|
||||
* 의존성 제약: 본 스텝은 변환/핫픽스를 `data/7.1.0/migrations/` 의 버전 namespace
|
||||
* 클래스에 위임한다. 미래 버전에서 *그 디렉토리는 동결* (수정 금지) 되어 "각 스텝별 동작
|
||||
* 100% 동일 보장" invariant 가 성립.
|
||||
*
|
||||
* 상세: docs/extension/upgrade-step-guide.md §13 "버전별 데이터 스냅샷"
|
||||
*/
|
||||
class Upgrade_7_1_0 extends AbstractUpgradeStep
|
||||
{
|
||||
// 모든 로직 위임 — data/7.1.0/ 가 SSoT.
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
namespace App\Upgrades\Data\V7_1_0\Migrations;
|
||||
|
||||
use App\Extension\Upgrade\DataMigration;
|
||||
use App\Extension\UpgradeContext;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* SEO 캐시 설정을 "미설정(null) = 고급 탭 값 사용" 의미로 이행 (결정 D19).
|
||||
*
|
||||
* 배경:
|
||||
* 캐시 설정은 `cache.*`(고급 탭)로 이관되었으나 SEO 탭의 옛 칸(`seo.cache_enabled`,
|
||||
* `seo.cache_ttl`, `seo.sitemap_cache_ttl`)이 제거되지 않았고, 코드가
|
||||
* `g7_core_settings('cache.X', g7_core_settings('seo.Y', 기본값))` 형태로 cache 를
|
||||
* **항상** 우선시켜 SEO 탭 입력이 조용히 무시되었다(운영자가 저장해도 무효).
|
||||
*
|
||||
* 7.1.0 의 새 의미:
|
||||
* 고급 탭 = 메인, SEO 탭에 **별도 지정이 있으면** 그것이 오버라이드.
|
||||
* "별도 지정" 판정은 null 여부로 하므로 미설정 상태를 표현할 수 있어야 한다.
|
||||
*
|
||||
* 본 마이그레이션의 동작 (옛 기본값 기준 추정):
|
||||
*
|
||||
* | 저장된 seo.* 값 | 판정 | 동작 |
|
||||
* |------------------|-----------------------|--------------------------|
|
||||
* | 키 부재 | 미설정 | no-op |
|
||||
* | 이미 null | 미설정 | no-op (멱등) |
|
||||
* | 옛 기본값과 동일 | 안 건드린 것으로 추정 | null 로 비움 |
|
||||
* | 옛 기본값과 다름 | 의도적으로 바꾼 것 | 보존 → 오버라이드로 발동 |
|
||||
*
|
||||
* 즉 값을 실제로 바꿔둔 운영자의 의도만 살아난다. 그 결과 **업그레이드 후 동작이
|
||||
* 바뀔 수 있다** — 예: `seo.cache_ttl=3600` 을 설정해 둔 사이트는 그동안 무시된 채
|
||||
* `cache.seo_ttl`(7200)로 돌았으나 이제 3600 이 발동한다. 이것이 원래 의도였으므로
|
||||
* 의도된 변경이며 CHANGELOG 에 고지한다.
|
||||
*
|
||||
* 멱등: 재실행 시 이미 null 인 키는 건드리지 않는다.
|
||||
*
|
||||
* 실패 정책: 설정 정리 실패가 업그레이드 전체를 막지 않는다(경고 후 계속). 비우지 못하면
|
||||
* 해당 키는 옛 기본값을 유지한 채 오버라이드로 동작하는데, 그 값이 곧 고급 탭 기본값과
|
||||
* 같으므로 관측되는 동작은 변하지 않는다.
|
||||
*
|
||||
* 격리 원칙 (docs/extension/upgrade-step-guide.md §13):
|
||||
* `SeoCacheSettings` 등 신규 코어 클래스를 참조하지 않는다. 옛 기본값 목록과 설정
|
||||
* 읽기/쓰기를 본 클래스 안에 로컬로 인라인 구현해 V-1 안전을 확보한다.
|
||||
*/
|
||||
final class NullifyUntouchedSeoCacheOverrides implements DataMigration
|
||||
{
|
||||
/**
|
||||
* 설정 카테고리 파일 (settings 디스크 기준 상대 경로)
|
||||
*/
|
||||
private const SEO_SETTINGS_FILE = 'seo.json';
|
||||
|
||||
/**
|
||||
* 7.0.x 시점의 seo 카테고리 캐시 키 기본값.
|
||||
*
|
||||
* 저장된 값이 이 값과 같으면 "운영자가 안 건드린 것" 으로 추정해 null 로 비운다.
|
||||
* 격리를 위해 defaults.json 을 읽지 않고 로컬 상수로 고정한다 — 미래 버전에서
|
||||
* defaults.json 이 바뀌어도 본 스텝의 판정 기준은 동결되어야 한다.
|
||||
*/
|
||||
private const LEGACY_DEFAULTS = [
|
||||
'cache_enabled' => 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<string, mixed>|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<string, mixed> $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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user