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 옵션 추가
This commit is contained in:
1 parent
1a6fca0d6b
commit
1629684156
34 files changed
+2027
-23
No files matched your search
@@ -0,0 +1,174 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user