Files
Gnuboard7/app/Repositories/SitemapUrlRepository.php
T
HeuJung 1629684156 feat(seo): 사이트맵 증분 저장소 + 리소스 리스너 재배선
사이트맵을 리소스 단위로 영속화(sitemap_urls)해 진정한 증분 갱신과
도메인 재쿼리 없는 파일 재작성을 구현했다. 리소스 공개/비공개/삭제 시
해당 항목만 색인이 갱신되고, 관리자=full·스케줄러=auto·리소스 변경=증분으로 동작한다.

- sitemap_urls 마이그레이션 + SitemapUrl 모델. loc(2048) 를 unique 에 직접 넣으면
 InnoDB 키 길이를 초과하므로 loc_hash(sha256) 컬럼을 identity 로 사용
- SitemapUrlRepository(멱등 upsert / remove / 스트리밍) + SitemapIndexer
 (+ 제3자용 sitemap.index.collect_for_resource 필터 훅)
- 4 리스너 재배선: 공개→색인 / 비공개·삭제→색인 해제 + 재생성 잡 디바운스 디스패치.
 3 기여자가 resource_type/resource_id 를 함께 emit 해 전체·증분 identity 를 일치
- 재생성 모드를 SitemapGenerationMode Backed Enum 으로 통일(Command→Job→Manager)
- seo:generate-sitemap 에 --rebuild / --mode 옵션 추가
2026-07-18 16:19:49 +09:00

175 lines
5.4 KiB
PHP

<?php
namespace App\Repositories;
use App\Contracts\Repositories\SitemapUrlRepositoryInterface;
use App\Models\SitemapUrl;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
/**
* 사이트맵 URL 저장소 구현체
*
* sitemap_urls 테이블을 통해 리소스 단위 증분 갱신과 스트리밍 조회를 수행합니다.
* loc 은 2048자라 unique 인덱스에 직접 넣지 못하므로 sha256 해시(loc_hash)를
* identity 로 사용합니다. lastmod 는 삽입 전 타임스탬프 문자열로 정규화합니다.
*/
class SitemapUrlRepository implements SitemapUrlRepositoryInterface
{
/**
* 청크 삽입 크기 (전체 재생성 시 유계 메모리 보장)
*/
private const INSERT_CHUNK = 1000;
/**
* 스트리밍 조회 청크 크기 (lazyById 키셋 페이징)
*/
private const STREAM_CHUNK = 1000;
/**
* {@inheritDoc}
*/
public function upsertForResource(string $type, string $id, array $entries): void
{
DB::transaction(function () use ($type, $id, $entries): void {
SitemapUrl::query()
->where('resource_type', $type)
->where('resource_id', $id)
->delete();
$now = now();
$rows = [];
foreach ($entries as $entry) {
$rows[] = $this->toRow(
$type,
$id,
(string) ($entry['contributor'] ?? ''),
$entry,
$now,
);
}
if ($rows !== []) {
SitemapUrl::insert($rows);
}
});
}
/**
* {@inheritDoc}
*/
public function removeForResource(string $type, string $id): void
{
SitemapUrl::query()
->where('resource_type', $type)
->where('resource_id', $id)
->delete();
}
/**
* {@inheritDoc}
*/
public function streamVisible(?string $contributor = null): iterable
{
$query = SitemapUrl::query()->where('is_visible', true);
if ($contributor !== null) {
$query->where('contributor', $contributor);
}
// lazyById 는 id 키셋 페이징이라 결과셋 전체를 메모리/드라이버 버퍼에 적재하지 않습니다.
return $query->lazyById(self::STREAM_CHUNK);
}
/**
* {@inheritDoc}
*/
public function countVisible(): int
{
return SitemapUrl::query()->where('is_visible', true)->count();
}
/**
* {@inheritDoc}
*/
public function replaceAllForContributor(string $contributor, iterable $entries): void
{
// 기여자 스코프 전량 제거 후 청크 삽입. 대용량(수백만) 삽입을 단일 트랜잭션으로
// 감싸면 락/메모리 부담이 크므로, 삭제 후 청크별 삽입으로 유계 메모리를 유지합니다.
SitemapUrl::query()->where('contributor', $contributor)->delete();
$now = now();
$batch = [];
foreach ($entries as $entry) {
$batch[] = $this->toRow(
(string) ($entry['resource_type'] ?? 'unknown'),
isset($entry['resource_id']) && $entry['resource_id'] !== null
? (string) $entry['resource_id']
: null,
$contributor,
$entry,
$now,
);
if (count($batch) >= self::INSERT_CHUNK) {
SitemapUrl::insert($batch);
$batch = [];
}
}
if ($batch !== []) {
SitemapUrl::insert($batch);
}
}
/**
* URL 항목을 sitemap_urls 삽입용 행으로 변환합니다.
*
* @param string $type 리소스 유형
* @param string|null $id 리소스 PK (문자열, 없으면 null)
* @param string $contributor 기여자 식별자
* @param array<string, mixed> $entry URL 항목 (loc/lastmod/changefreq/priority)
* @param Carbon $now 타임스탬프 기준값
* @return array<string, mixed> 삽입용 행
*/
private function toRow(string $type, ?string $id, string $contributor, array $entry, Carbon $now): array
{
$loc = (string) ($entry['loc'] ?? '');
return [
'resource_type' => $type,
'resource_id' => $id,
'loc' => $loc,
'loc_hash' => hash('sha256', $loc),
'lastmod' => $this->normalizeLastmod($entry['lastmod'] ?? null),
'changefreq' => $entry['changefreq'] ?? null,
'priority' => $entry['priority'] ?? null,
'contributor' => $contributor,
'is_visible' => true,
'created_at' => $now,
'updated_at' => $now,
];
}
/**
* lastmod 값을 DB 저장용 타임스탬프 문자열로 정규화합니다.
*
* insert() 는 모델 캐스팅을 우회하므로 W3C 문자열/Carbon 을 직접 파싱합니다.
*
* @param mixed $lastmod 원본 lastmod (문자열/Carbon/null)
* @return string|null 정규화된 'Y-m-d H:i:s' 또는 null
*/
private function normalizeLastmod(mixed $lastmod): ?string
{
if ($lastmod === null || $lastmod === '') {
return null;
}
try {
return Carbon::parse($lastmod)->format('Y-m-d H:i:s');
} catch (\Throwable) {
return null;
}
}
}