Merge pull request from gnuboard:HeuJung/issue481

HeuJung/issue481
This commit is contained in:
정정홍
2026-07-19 23:29:11 +09:00
committed by GitHub
224 changed files with 13751 additions and 3622 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.5
APP_VERSION=7.0.6
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
+1 -1
View File
@@ -3,7 +3,7 @@ APP_ENV=testing
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.5
APP_VERSION=7.0.6
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
+17
View File
@@ -4,6 +4,23 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [7.0.6] - 2026-07-19
### Added
- 게시글·상품이 수백만 건으로 늘어나도 사이트맵을 안정적으로 생성하도록 개선했습니다. 예전에는 사이트맵을 만들 때 전체 데이터를 한꺼번에 메모리에 올리다가 데이터가 많은 사이트에서 생성이 실패했지만, 이제 나눠 읽어 처리하므로 메모리 부족 없이 완성됩니다. (#79 @jiwonpapa 님께서 제보해주셨습니다.)
- 사이트맵을 검색엔진 규격(파일당 5만 URL / 50MB)에 맞춰 여러 파일로 자동 분할하고, 이를 묶는 색인 파일을 함께 만듭니다. 한 파일에 담을 URL 수는 SEO 설정에서 조정할 수 있습니다.
- 게시글·상품·페이지를 공개/비공개로 바꾸거나 삭제하면 사이트맵에서 해당 항목만 자동으로 더하거나 빼도록 바꿨습니다. 전체를 다시 만들지 않고 바뀐 부분만 반영하므로 항상 최신 상태가 빠르게 유지됩니다.
- 검색엔진 봇이 사이트맵을 요청하면 미리 만들어 둔 파일을 그대로 내보내고, 요청을 받은 그 자리에서 새로 만들지 않도록 바꿨습니다. 아직 만들어진 사이트맵이 없으면 잠시 후 다시 요청하도록 안내하고 백그라운드에서 생성합니다.
- 관리자 화면의 사이트맵 전체 재생성을 백그라운드 작업으로 처리하도록 바꾸고, 진행 상황(대기·생성 중·파일 작성·완료·실패)과 처리한 주소 수를 화면에서 확인할 수 있게 했습니다. 실시간 연결이 켜져 있으면 즉시 갱신되고, 꺼져 있으면 주기적으로 확인합니다.
- 다국어 사이트를 위한 대체 언어 링크(hreflang)를 사이트맵에 넣을지 켜고 끌 수 있으며, 언어 수가 많을 때 링크가 과도하게 늘어나지 않도록 상한을 두었습니다.
- 사이트맵의 갱신 빈도(changefreq) 값을 검색엔진 표준 어휘(always/hourly/daily/weekly/monthly/yearly/never)로 맞춰, 규격에 없는 값은 사이트맵에 출력되지 않도록 했습니다.
- 관리자가 사이트맵 전체 재생성을 실행하면, 완료되거나 실패했을 때 실행한 관리자에게 알림을 보냅니다. 재생성이 오래 걸려 화면을 떠나 있어도 결과를 놓치지 않습니다. (기본은 앱 내 알림이며 관리자 알림 설정에서 이메일도 켤 수 있습니다. 매일 자동 생성이나 글·상품 변경에 따른 재생성은 알림을 보내지 않습니다.)
### Fixed
- SEO 설정의 캐시 항목(SEO 캐시 사용·유지 시간, Sitemap 캐시 유지 시간)이 저장해도 적용되지 않던 문제를 수정했습니다. 이제 고급 설정의 캐시 값이 기준이 되고, SEO 설정에서 값을 지정하면 그 값이 우선하며, 비워두면 고급 설정을 따릅니다. 업그레이드 시 기존에 기본값과 다르게 지정해 두셨던 값은 그대로 살아나 이제부터 실제로 적용되므로 SEO 캐시 동작이 달라질 수 있습니다. 기본값 그대로였던 항목은 "지정 안 함"으로 정리되어 동작이 바뀌지 않습니다.
## [7.0.5] - 2026-07-16
### Added
+1 -1
View File
@@ -246,7 +246,7 @@ unzip g7-release.zip
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
ls -la
# (필요 시) mv g7-7.0.5 g7
# (필요 시) mv g7-7.0.6 g7
# ZIP 파일 정리 (선택)
rm g7-release.zip
+1 -1
View File
@@ -8,7 +8,7 @@
</p>
<p align="center">
<a href="#"><img src="https://img.shields.io/badge/version-7.0.5-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/version-7.0.6-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
@@ -2,6 +2,7 @@
namespace App\Console\Commands;
use App\Enums\SitemapGenerationMode;
use App\Jobs\GenerateSitemapJob;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Config;
@@ -17,7 +18,10 @@ class SeoGenerateSitemapCommand extends Command
/**
* @var string 커맨드 시그니처
*/
protected $signature = 'seo:generate-sitemap {--sync : 큐 드라이버를 무시하고 동기 실행}';
protected $signature = 'seo:generate-sitemap
{--sync : 큐 드라이버를 무시하고 동기 실행}
{--rebuild : 저장소 상태와 무관하게 전체 재생성 (--mode=full 과 동일)}
{--mode=auto : 재생성 모드 (full|auto|incremental)}';
/**
* @var string 커맨드 설명
@@ -31,6 +35,14 @@ class SeoGenerateSitemapCommand extends Command
*/
public function handle(): int
{
$mode = $this->resolveMode();
if ($mode === null) {
$allowed = implode(', ', array_map(fn (SitemapGenerationMode $m): string => $m->value, SitemapGenerationMode::cases()));
$this->error('잘못된 모드입니다. 허용: '.$allowed);
return Command::INVALID;
}
$forceSync = (bool) $this->option('sync');
// queue.default 는 SettingsServiceProvider 가 drivers.queue_driver 와 동기화하되,
// testing 환경에서는 phpunit.xml 값을 보존하므로 격리가 유지된다.
@@ -38,13 +50,29 @@ class SeoGenerateSitemapCommand extends Command
$isSyncDriver = $connection === 'sync';
if ($forceSync || $isSyncDriver) {
GenerateSitemapJob::dispatchSync();
$this->info('Sitemap이 생성되었습니다.');
GenerateSitemapJob::dispatchSync($mode);
$this->info("Sitemap이 생성되었습니다. (모드: {$mode->value})");
} else {
GenerateSitemapJob::dispatch();
$this->info('Sitemap 생성이 큐에 디스패치되었습니다.');
GenerateSitemapJob::dispatch($mode);
$this->info("Sitemap 생성이 큐에 디스패치되었습니다. (모드: {$mode->value})");
}
return Command::SUCCESS;
}
/**
* --rebuild / --mode 옵션으로부터 재생성 모드를 해석합니다.
*
* --rebuild 는 --mode 보다 우선하며 Full 로 강제합니다.
*
* @return SitemapGenerationMode|null 재생성 모드 (유효하지 않으면 null)
*/
private function resolveMode(): ?SitemapGenerationMode
{
if ((bool) $this->option('rebuild')) {
return SitemapGenerationMode::Full;
}
return SitemapGenerationMode::tryFrom((string) $this->option('mode'));
}
}
@@ -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;
}
@@ -0,0 +1,62 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\SitemapUrl;
/**
* 사이트맵 URL 저장소 인터페이스
*
* sitemap_urls 테이블에 대한 리소스 단위 증분 갱신과 스트리밍 조회를 제공합니다.
* 리스너는 SitemapIndexer 를 경유해 이 저장소를 사용하며(직접 Model/DB 접근 금지),
* 전체 재생성/증분 파일 작성은 이 저장소의 스트림을 소비합니다.
*/
interface SitemapUrlRepositoryInterface
{
/**
* 한 리소스의 사이트맵 URL 행을 현재 상태로 대체합니다(멱등).
*
* (resource_type, resource_id) 로 기존 행을 제거한 뒤 전달된 항목을 삽입하므로,
* 같은 입력으로 여러 번 호출해도 테이블 내용이 동일합니다(loc 변경/슬러그 변경 흡수).
*
* @param string $type 리소스 유형
* @param string $id 리소스 PK (문자열)
* @param array<int, array{loc: string, contributor: string, lastmod?: mixed, changefreq?: ?string, priority?: ?float}> $entries URL 항목
*/
public function upsertForResource(string $type, string $id, array $entries): void;
/**
* 한 리소스의 사이트맵 URL 행을 모두 제거합니다(비공개/삭제 시).
*
* @param string $type 리소스 유형
* @param string $id 리소스 PK (문자열)
*/
public function removeForResource(string $type, string $id): void;
/**
* 공개(is_visible) URL 을 id 순으로 스트리밍합니다(유계 메모리).
*
* @param string|null $contributor 기여자 식별자로 스코핑 (null = 전체)
* @return iterable<int, SitemapUrl> URL 순회자
*/
public function streamVisible(?string $contributor = null): iterable;
/**
* 공개(is_visible) URL 총 개수를 반환합니다.
*
* @return int 공개 URL 수
*/
public function countVisible(): int;
/**
* 한 기여자의 모든 사이트맵 URL 행을 전량 대체합니다(전체 재생성).
*
* 기여자 스코프로 기존 행을 제거한 뒤 스트림을 청크 삽입하여
* 대용량에서도 메모리를 유계로 유지합니다.
*
* @param string $contributor 기여자 식별자
* @param iterable<int, array{loc: string, resource_type?: string, resource_id?: mixed, lastmod?: mixed, changefreq?: ?string, priority?: ?float}> $entries URL 항목 스트림
* @return int 삽입된 행 수 (진행상황 누적 URL 표기용 — count 쿼리 없이 스트림 누적)
*/
public function replaceAllForContributor(string $contributor, iterable $entries): int;
}
+70
View File
@@ -0,0 +1,70 @@
<?php
namespace App\Enums;
/**
* 사이트맵 <changefreq> 값 Enum (sitemaps.org 폐쇄 어휘)
*
* https://www.sitemaps.org/protocol.html 의 <changefreq> 는 크롤러에게 페이지 갱신
* 빈도를 알려주는 힌트로, 허용 값이 7개로 고정된 폐쇄 어휘다. 이 Enum 이 그 SSoT 이며,
* 잘못된 문자열이 사이트맵 XML 에 유입되는 것을 저장(SitemapIndexer)과 렌더
* (SitemapXmlRenderer) 두 경계에서 차단한다.
*
* 기여자(getUrlsLazy)·리스너·정적 URL 은 리터럴 문자열 대신 이 Enum 의 case 를 사용해
* 오타를 작성 시점에 잡는다.
*/
enum SitemapChangeFreq: string
{
/**
* 접근할 때마다 변경 (예: 실시간 시세)
*/
case Always = 'always';
/**
* 시간 단위 변경
*/
case Hourly = 'hourly';
/**
* 일 단위 변경
*/
case Daily = 'daily';
/**
* 주 단위 변경
*/
case Weekly = 'weekly';
/**
* 월 단위 변경
*/
case Monthly = 'monthly';
/**
* 연 단위 변경
*/
case Yearly = 'yearly';
/**
* 사실상 변경 없음 (예: 보관 URL)
*/
case Never = 'never';
/**
* 임의 문자열을 유효한 changefreq 값으로 정규화합니다.
*
* 대소문자·앞뒤 공백을 흡수하고, 폐쇄 어휘에 없는 값은 null 로 떨어뜨려
* 사이트맵 XML 에 비표준 값이 출력되는 것을 방지합니다.
*
* @param string|null $value 검증할 원본 값
* @return string|null 유효하면 정규화된 값, 아니면 null
*/
public static function normalize(?string $value): ?string
{
if ($value === null || trim($value) === '') {
return null;
}
return self::tryFrom(strtolower(trim($value)))?->value;
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Enums;
/**
* 사이트맵 재생성 모드 Enum
*
* SitemapManager::regenerate 와 GenerateSitemapJob, seo:generate-sitemap 커맨드가
* 공유하는 재생성 모드 도메인입니다.
*
* - Full: 저장소 상태와 무관하게 각 기여자를 스트리밍해 sitemap_urls 를 전량 대체한 뒤 파일 재작성.
* - Auto: 저장소가 비어 있으면 Full, 아니면 Incremental 로 동작(스케줄러 기본).
* - Incremental: 기여자 재쿼리 없이 저장소의 현재 델타만으로 파일 재작성.
*/
enum SitemapGenerationMode: string
{
/**
* 전체 재생성 (관리자 수동 = 항상 Full)
*/
case Full = 'full';
/**
* 자동 판정 (빈 저장소=Full / 채워짐=Incremental)
*/
case Auto = 'auto';
/**
* 증분 재생성 (저장소 델타만 파일로 반영)
*/
case Incremental = 'incremental';
/**
* 저장소 상태를 반영해 실제 실행 모드(Full 또는 Incremental)로 해석합니다.
*
* Auto 는 저장소가 비어 있으면 Full, 아니면 Incremental 로 해석됩니다.
*
* @param int $visibleCount 저장소의 공개 URL 수
* @return self 실제 실행 모드 (Full | Incremental)
*/
public function resolve(int $visibleCount): self
{
return match ($this) {
self::Full => self::Full,
self::Incremental => self::Incremental,
self::Auto => $visibleCount === 0 ? self::Full : self::Incremental,
};
}
}
@@ -2,13 +2,17 @@
namespace App\Http\Controllers\Api\Admin;
use App\Enums\SitemapGenerationMode;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Admin\SeoCacheClearRequest;
use App\Jobs\GenerateSitemapJob;
use App\Seo\Contracts\SeoCacheManagerInterface;
use App\Seo\SeoCacheStatsService;
use App\Seo\SitemapManager;
use App\Seo\SitemapProgress;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
/**
* SEO 캐시 관리 컨트롤러
@@ -20,7 +24,8 @@ class SeoCacheController extends AdminBaseController
public function __construct(
private SeoCacheStatsService $statsService,
private SeoCacheManagerInterface $cacheManager,
private SitemapManager $sitemapManager
private SitemapManager $sitemapManager,
private SitemapProgress $sitemapProgress
) {
parent::__construct();
}
@@ -54,7 +59,7 @@ class SeoCacheController extends AdminBaseController
*
* 레이아웃 또는 모듈 지정 시 해당 캐시만, 미지정 시 전체 캐시를 삭제합니다.
*
* @param SeoCacheClearRequest $request 캐시 삭제 요청
* @param SeoCacheClearRequest $request 캐시 삭제 요청
* @return JsonResponse 삭제 결과를 포함한 JSON 응답
*/
public function clearCache(SeoCacheClearRequest $request): JsonResponse
@@ -97,28 +102,39 @@ class SeoCacheController extends AdminBaseController
}
/**
* Sitemap XML 을 즉시 재생성합니다.
* Sitemap XML 재생성을 큐에 예약합니다.
*
* 큐 드라이버와 무관하게 동기 실행되며, 생성 완료 후 last_updated_at 을 갱신합니다.
* 대용량(수백만 URL) 사이트에서 요청 스레드 동기 생성은 메모리/타임아웃 붕괴를 일으키므로
* 큐 잡으로 위임합니다. 응답은 예약 시점의 상태이며, 실제 완료 시각은 이후 갱신됩니다.
*
* @return JsonResponse 재생성 결과 JSON 응답
* @return JsonResponse 예약 결과 JSON 응답
*/
public function regenerateSitemap(): JsonResponse
{
$result = $this->sitemapManager->regenerate();
if ($result['success']) {
return $this->success('seo.sitemap_regenerated', $result['data'] ?? null);
if (! (bool) g7_core_settings('seo.sitemap_enabled', true)) {
return $this->error('seo.sitemap_disabled', 400);
}
$messageKey = match ($result['status']) {
'disabled' => 'seo.sitemap_disabled',
default => 'seo.sitemap_regenerate_failed',
};
// 관리자 수동 재생성은 항상 전체(Full) — 현 생성 상태와 무관하게 전량 재생성 (D7)
// 실행한 관리자 ID 를 함께 실어, 완료/실패 시 그 관리자에게만 알림이 발송되게 한다.
GenerateSitemapJob::dispatch(SitemapGenerationMode::Full, Auth::id());
$statusCode = $result['status'] === 'disabled' ? 400 : 500;
// 큐 대기 중에도 UI 가 'queued' 를 표시하도록 즉시 기록
$this->sitemapProgress->start(SitemapGenerationMode::Full->value);
return $this->error($messageKey, $statusCode, $result['message'] ?? null);
return $this->success('seo.sitemap_regenerate_dispatched', $this->sitemapManager->getStatus());
}
/**
* Sitemap 재생성 진행상황과 실시간 연결 가능 여부를 조회합니다.
*
* SEO 탭 진입 시 초기 로드용이며, 폴링(Reverb OFF)일 때 주기적으로 재조회됩니다.
*
* @return JsonResponse 진행상황(progress) + last_updated_at + realtime_enabled 를 포함한 JSON 응답
*/
public function sitemapStatus(): JsonResponse
{
return $this->success('messages.success', $this->sitemapManager->getStatus());
}
/**
@@ -4,42 +4,92 @@ namespace App\Http\Controllers\Api\Public;
use App\Contracts\Extension\CacheInterface;
use App\Http\Controllers\Controller;
use App\Seo\SitemapGenerator;
use Illuminate\Http\Response;
use App\Jobs\GenerateSitemapJob;
use App\Seo\SitemapFileStore;
use App\Seo\SitemapManager;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* Sitemap XML 컨트롤러
*
* sitemap.xml 요청을 처리하여 캐시된 또는 실시간 생성된
* Sitemap XML을 반환합니다.
* 비공개 디스크에 커밋된 sitemap 인덱스/자식 파일을 스트리밍으로 서빙합니다.
* 봇 요청 스레드에서 sitemap 을 생성하지 않으며(대용량에서 OOM/타임아웃 원인),
* 신선도가 만료되면 생성 잡만 예약하고 기존 세트(stale)를 그대로 내보냅니다.
*/
class SitemapController extends Controller
{
/**
* sitemap.xml을 반환합니다.
*
* 캐시가 있으면 캐시된 XML, 없으면 실시간 생성 후 캐시 저장.
*
* @param SitemapGenerator $generator Sitemap 생성기
* @param CacheInterface $cache 코어 캐시 드라이버
* 세트가 없어 503 을 반환할 때 봇에 안내할 재시도 간격 (초)
*/
public function index(SitemapGenerator $generator, CacheInterface $cache): Response
private const RETRY_AFTER_SECONDS = 120;
/**
* sitemapindex 를 반환합니다.
*
* 커밋된 세트가 있으면 스트리밍하고, 신선도(메타 캐시)가 만료되었으면
* 생성 잡을 예약합니다. 세트가 전혀 없으면 503 + Retry-After 를 반환합니다.
*
* @param SitemapFileStore $store sitemap 파일 저장소 (읽기측)
* @param CacheInterface $cache 코어 캐시 드라이버
* @return StreamedResponse sitemapindex 스트림
*/
public function index(SitemapFileStore $store, CacheInterface $cache): StreamedResponse
{
$enabled = (bool) g7_core_settings('seo.sitemap_enabled', true);
if (! $enabled) {
$this->assertSitemapEnabled();
$isFresh = $cache->get(SitemapManager::META_CACHE_KEY) !== null;
if ($isFresh) {
$response = $store->indexResponse();
if ($response !== null) {
return $response;
}
}
// 신선도 만료 또는 세트 부재 — 생성은 큐에 맡긴다 (ShouldBeUnique 로 중복 디스패치 방지).
GenerateSitemapJob::dispatch();
$serveStale = (bool) g7_core_settings('seo.sitemap_serve_stale_on_miss', true);
if ($serveStale) {
$response = $store->indexResponse();
if ($response !== null) {
return $response;
}
}
abort(503, __('seo.sitemap_not_ready'), ['Retry-After' => (string) self::RETRY_AFTER_SECONDS]);
}
/**
* 자식 sitemap 을 반환합니다.
*
* gzip 여부는 manifest 가 결정하므로 `.xml` / `.xml.gz` 요청을 같은 액션에서 처리합니다.
*
* @param int $n 자식 번호 (1부터)
* @param SitemapFileStore $store sitemap 파일 저장소 (읽기측)
* @return StreamedResponse 자식 sitemap 스트림
*/
public function child(int $n, SitemapFileStore $store): StreamedResponse
{
$this->assertSitemapEnabled();
$response = $store->childResponse($n);
if ($response === null) {
abort(404);
}
$xml = $cache->get('seo.sitemap');
return $response;
}
if (! $xml) {
$xml = $generator->generate();
$ttl = (int) g7_core_settings('cache.seo_sitemap_ttl', g7_core_settings('seo.sitemap_cache_ttl', 86400));
$cache->put('seo.sitemap', $xml, $ttl);
/**
* Sitemap 기능이 비활성화되어 있으면 404 로 중단합니다.
*/
private function assertSitemapEnabled(): void
{
if (! (bool) g7_core_settings('seo.sitemap_enabled', true)) {
abort(404);
}
return response($xml, 200, [
'Content-Type' => 'application/xml',
]);
}
}
@@ -220,6 +220,12 @@ class SaveSettingsRequest extends FormRequest
'seo.cache_ttl' => ['nullable', 'integer', 'min:60', 'max:86400'],
'seo.sitemap_enabled' => ['nullable', 'boolean'],
'seo.sitemap_cache_ttl' => ['nullable', 'integer', 'min:3600', 'max:604800'],
// 분할 기준. 상한 50000 은 sitemaps.org 프로토콜 제한이며 SitemapWriter 가 동일 값으로 클램프합니다.
'seo.sitemap_urls_per_file' => ['nullable', 'integer', 'min:1000', 'max:50000'],
'seo.sitemap_gzip' => ['nullable', 'boolean'],
'seo.sitemap_serve_stale_on_miss' => ['nullable', 'boolean'],
'seo.sitemap_max_urls_per_contributor' => ['nullable', 'integer', 'min:0'],
'seo.sitemap_hreflang_enabled' => ['nullable', 'boolean'],
'seo.sitemap_schedule' => ['nullable', 'string', Rule::in(['hourly', 'daily', 'weekly'])],
'seo.sitemap_schedule_time' => ['nullable', 'string', 'regex:/^\d{2}:\d{2}$/'],
'seo.generator_enabled' => ['nullable', 'boolean'],
@@ -242,6 +248,9 @@ class SaveSettingsRequest extends FormRequest
'advanced.stats_cache_ttl' => $this->getAdvancedTabCacheTtlRules($tab),
'advanced.seo_cache_enabled' => $this->getTabRules($tab, 'advanced', 'boolean'),
'advanced.seo_cache_ttl' => $this->getAdvancedTabCacheTtlRules($tab),
// 신규 설정이므로 미전송(기존 클라이언트)을 허용한다 — 미전송/미지정 시 코드 기본값 유지.
// 범위는 SEO 탭의 오버라이드 칸(seo.sitemap_cache_ttl)과 동일하게 맞춘다.
'advanced.seo_sitemap_cache_ttl' => ['nullable', 'integer', 'min:3600', 'max:604800'],
// 디버그 설정 (advanced 탭)
'advanced.debug_mode' => $this->getTabRules($tab, 'advanced', 'boolean'),
@@ -605,6 +614,14 @@ class SaveSettingsRequest extends FormRequest
'seo.sitemap_cache_ttl.integer' => __('validation.settings.sitemap_cache_ttl_integer'),
'seo.sitemap_cache_ttl.min' => __('validation.settings.sitemap_cache_ttl_min'),
'seo.sitemap_cache_ttl.max' => __('validation.settings.sitemap_cache_ttl_max'),
'seo.sitemap_urls_per_file.integer' => __('validation.settings.sitemap_urls_per_file_integer'),
'seo.sitemap_urls_per_file.min' => __('validation.settings.sitemap_urls_per_file_min'),
'seo.sitemap_urls_per_file.max' => __('validation.settings.sitemap_urls_per_file_max'),
'seo.sitemap_gzip.boolean' => __('validation.settings.sitemap_gzip_boolean'),
'seo.sitemap_serve_stale_on_miss.boolean' => __('validation.settings.sitemap_serve_stale_on_miss_boolean'),
'seo.sitemap_max_urls_per_contributor.integer' => __('validation.settings.sitemap_max_urls_per_contributor_integer'),
'seo.sitemap_max_urls_per_contributor.min' => __('validation.settings.sitemap_max_urls_per_contributor_min'),
'seo.sitemap_hreflang_enabled.boolean' => __('validation.settings.sitemap_hreflang_enabled_boolean'),
'seo.sitemap_schedule.in' => __('validation.settings.sitemap_schedule_invalid'),
'seo.sitemap_schedule_time.regex' => __('validation.settings.sitemap_schedule_time_invalid'),
@@ -639,12 +656,15 @@ class SaveSettingsRequest extends FormRequest
'advanced.stats_cache_ttl.integer' => __('validation.settings.stats_cache_ttl_integer'),
'advanced.stats_cache_ttl.min' => __('validation.settings.stats_cache_ttl_min'),
'advanced.stats_cache_ttl.max' => __('validation.settings.stats_cache_ttl_max'),
'advanced.seo_cache_enabled.required' => __('validation.settings.seo_cache_enabled_required'),
'advanced.seo_cache_enabled.boolean' => __('validation.settings.seo_cache_enabled_boolean'),
'advanced.seo_cache_ttl.required' => __('validation.settings.seo_cache_ttl_required'),
'advanced.seo_cache_ttl.integer' => __('validation.settings.seo_cache_ttl_integer'),
'advanced.seo_cache_ttl.min' => __('validation.settings.seo_cache_ttl_min'),
'advanced.seo_cache_ttl.max' => __('validation.settings.seo_cache_ttl_max'),
'advanced.seo_cache_enabled.required' => __('validation.settings.advanced_seo_cache_enabled_required'),
'advanced.seo_cache_enabled.boolean' => __('validation.settings.advanced_seo_cache_enabled_boolean'),
'advanced.seo_cache_ttl.required' => __('validation.settings.advanced_seo_cache_ttl_required'),
'advanced.seo_cache_ttl.integer' => __('validation.settings.advanced_seo_cache_ttl_integer'),
'advanced.seo_cache_ttl.min' => __('validation.settings.advanced_seo_cache_ttl_min'),
'advanced.seo_cache_ttl.max' => __('validation.settings.advanced_seo_cache_ttl_max'),
'advanced.seo_sitemap_cache_ttl.integer' => __('validation.settings.seo_sitemap_cache_ttl_integer'),
'advanced.seo_sitemap_cache_ttl.min' => __('validation.settings.seo_sitemap_cache_ttl_min'),
'advanced.seo_sitemap_cache_ttl.max' => __('validation.settings.seo_sitemap_cache_ttl_max'),
// 디버그 설정
'advanced.debug_mode.required' => __('validation.settings.debug_mode_required'),
+81 -5
View File
@@ -2,8 +2,12 @@
namespace App\Jobs;
use App\Enums\SitemapGenerationMode;
use App\Extension\HookManager;
use App\Seo\SitemapManager;
use App\Seo\SitemapProgress;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
@@ -13,22 +17,82 @@ use Illuminate\Support\Facades\Log;
/**
* Sitemap XML 생성 큐 잡
*
* 스케줄러 또는 Artisan 커맨드에서 디스패치되며,
* 스케줄러, Artisan 커맨드, 관리자 수동 재생성, 봇 요청 캐시 미스에서 디스패치되며,
* 실제 생성 로직은 SitemapManager 서비스에 위임합니다.
*
* 대용량(수백만 URL) 생성은 수 분~수십 분이 걸리므로 동시 실행을 막고(ShouldBeUnique)
* 타임아웃/재시도 한계를 넉넉히 잡되, 재시도가 무한히 누적되지 않도록 retryUntil 로 제한합니다.
*/
class GenerateSitemapJob implements ShouldQueue
class GenerateSitemapJob implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* @var string 전용 큐 이름 — 대용량·장시간 사이트맵 생성을 default 큐에서 분리한다.
*
* 이 큐에는 반드시 워커를 1개만 배치한다(동시 중복 생성 방지). 그러면
* 긴 생성 작업이 default 큐의 방송/알림을 막지 않아 진행상황이 실시간으로 흐르고,
* 워커가 1개뿐이라 잡이 동시에 두 번 실행되지 않는다.
*/
public const QUEUE = 'sitemap';
/**
* @var int 최대 재시도 횟수
*/
public int $tries = 3;
/**
* @var int 타임아웃 (초)
* @var int 타임아웃 (초) — 대용량 스트리밍 생성 대응
*/
public int $timeout = 300;
public int $timeout = 1800;
/**
* @var int 재시도 대기 시간 (초)
*/
public int $backoff = 120;
/**
* @var int 유니크 락 유지 시간 (초) — 잡이 비정상 종료해도 이 시간 후 자동 해제
*/
public int $uniqueFor = 900;
/**
* GenerateSitemapJob 생성자
*
* @param SitemapGenerationMode $mode 재생성 모드 — SitemapManager 로 전달
* @param int|null $triggeredBy 관리자 수동 재생성을 실행한 사용자 ID(스케줄러/증분/봇 = null).
* 완료/실패 알림의 수신자(수동 실행자) 판정에 사용됩니다.
*/
public function __construct(
public SitemapGenerationMode $mode = SitemapGenerationMode::Auto,
public ?int $triggeredBy = null,
) {
$this->onQueue(self::QUEUE);
}
/**
* 유니크 락 식별자를 반환합니다.
*
* 봇 요청 캐시 미스마다 디스패치되어도 큐에는 한 건만 남도록 고정 키를 사용합니다.
*
* @return string 유니크 락 키
*/
public function uniqueId(): string
{
return 'seo-sitemap';
}
/**
* 재시도 마감 시각을 반환합니다.
*
* timeout(1800초) × tries(3) 만큼 재시도가 누적되는 것을 막습니다.
*
* @return \DateTime 재시도 마감 시각
*/
public function retryUntil(): \DateTime
{
return now()->addMinutes(30)->toDateTime();
}
/**
* Sitemap 을 생성하고 캐시에 저장합니다.
@@ -37,7 +101,7 @@ class GenerateSitemapJob implements ShouldQueue
*/
public function handle(SitemapManager $manager): void
{
$result = $manager->regenerate();
$result = $manager->regenerate($this->mode, $this->triggeredBy);
if (($result['status'] ?? null) === 'disabled') {
Log::info('[SEO] Sitemap generation skipped (disabled)');
@@ -62,6 +126,18 @@ class GenerateSitemapJob implements ShouldQueue
*/
public function failed(\Throwable $exception): void
{
// 잡 최종 실패(재시도 소진/타임아웃) 시 진행상황을 'failed' 로 고정 — 무한 running 방지
app(SitemapProgress::class)->fail($exception->getMessage());
// 최종 실패 전용 훅 — 매 시도마다 발화하는 core.seo.sitemap.after_regenerate_failed 와 달리
// 재시도가 모두 소진된 뒤 딱 한 번만 발화한다. 재시도 중 성공하면 발화하지 않으므로
// 실패 알림이 중복되거나(각 시도) 결국 성공했는데도 발송되는 일이 없다.
HookManager::doAction('core.seo.sitemap.regenerate_failed_final', [
'success' => false,
'status' => 'failed',
'message' => $exception->getMessage(),
], $this->triggeredBy);
Log::error('[SEO] Sitemap generation failed', [
'error' => $exception->getMessage(),
]);
@@ -0,0 +1,133 @@
<?php
namespace App\Listeners;
use App\Contracts\Extension\HookListenerInterface;
/**
* SEO 알림 데이터 필터 리스너
*
* notification_definitions 의 `core.seo.notification.extract_data` 필터를 처리하여
* 사이트맵 재생성 완료/실패 알림 발송에 필요한 데이터와 컨텍스트를 제공합니다.
*
* 정책: 사이트맵 재생성은 스케줄러/증분/봇에 의해서도 완료되므로, 관리자가 직접
* 실행(수동 재생성)한 경우에만 알림을 보냅니다. 수동 실행 여부는 훅에 함께 전달된
* 사용자 ID(triggered_by)로 판정하며, 없으면 context.skip 으로 발송을 중단시킵니다.
* 수신자 결정은 notification_definitions.recipients(trigger_user) 설정에 위임합니다.
*/
class SeoNotificationDataListener implements HookListenerInterface
{
/**
* 구독할 훅 목록을 반환합니다.
*
* @return array 훅 이름 → 메서드/우선순위/타입 매핑
*/
public static function getSubscribedHooks(): array
{
return [
'core.seo.notification.extract_data' => [
'method' => 'extractData',
'priority' => 20,
'type' => 'filter',
],
];
}
/**
* 훅 이벤트를 처리합니다(HookListenerInterface 필수 메서드).
*
* @param mixed ...$args 훅에서 전달된 인수들
*/
public function handle(...$args): void {}
/**
* 알림 유형에 따라 데이터와 컨텍스트를 추출합니다.
*
* @param array $default 기본 extract_data 구조
* @param string $type 알림 정의 유형
* @param array $args 훅에서 전달된 원본 인수 [$result, $triggeredBy]
* @return array{notifiable: null, notifiables: null, data: array, context: array}
*/
public function extractData(array $default, string $type, array $args): array
{
return match ($type) {
'sitemap_regenerated' => $this->extractRegenerated($args),
'sitemap_regenerate_failed' => $this->extractRegenerateFailed($args),
default => $default,
};
}
/**
* 사이트맵 재생성 완료 알림 데이터를 추출합니다.
*
* @param array $args 훅 인수 [$result, $triggeredBy]
* @return array
*/
private function extractRegenerated(array $args): array
{
$triggeredBy = $args[1] ?? null;
if (! is_int($triggeredBy)) {
return $this->skip();
}
$result = is_array($args[0] ?? null) ? $args[0] : [];
$data = $result['data'] ?? [];
$baseUrl = config('app.url');
return [
'notifiable' => null,
'notifiables' => null,
'data' => [
'app_name' => config('app.name'),
'url_count' => (string) ($data['url_count'] ?? 0),
'child_count' => (string) ($data['child_count'] ?? 1),
'action_url' => "{$baseUrl}/admin/settings?tab=seo",
'site_url' => $baseUrl,
],
'context' => [
'trigger_user_id' => $triggeredBy,
],
];
}
/**
* 사이트맵 재생성 실패 알림 데이터를 추출합니다.
*
* @param array $args 훅 인수 [$result, $triggeredBy]
* @return array
*/
private function extractRegenerateFailed(array $args): array
{
$triggeredBy = $args[1] ?? null;
if (! is_int($triggeredBy)) {
return $this->skip();
}
$result = is_array($args[0] ?? null) ? $args[0] : [];
$baseUrl = config('app.url');
return [
'notifiable' => null,
'notifiables' => null,
'data' => [
'app_name' => config('app.name'),
'error' => (string) ($result['message'] ?? ''),
'action_url' => "{$baseUrl}/admin/settings?tab=seo",
'site_url' => $baseUrl,
],
'context' => [
'trigger_user_id' => $triggeredBy,
],
];
}
/**
* 발송을 건너뛰도록 지시하는 결과를 반환합니다(비수동 재생성 = 유발 관리자 없음).
*
* @return array
*/
private function skip(): array
{
return ['notifiable' => null, 'notifiables' => null, 'data' => [], 'context' => ['skip' => true]];
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/**
* 사이트맵 URL 영속 모델
*
* sitemap_urls 테이블의 한 행 = 사이트맵에 노출되는 하나의 리소스 URL.
* 리소스 단위 증분 갱신(공개=upsert / 비공개=remove)과 도메인 재쿼리 없는
* 파일 재작성의 저장소입니다. 데이터 접근은 SitemapUrlRepository 를 경유합니다.
*
* @property int $id
* @property string $resource_type
* @property string|null $resource_id
* @property string $loc
* @property string $loc_hash
* @property Carbon|null $lastmod
* @property string|null $changefreq
* @property float|null $priority
* @property string $contributor
* @property bool $is_visible
*/
class SitemapUrl extends Model
{
/**
* @var string 테이블명
*/
protected $table = 'sitemap_urls';
/**
* @var list<string> 대량 할당 가능 컬럼
*/
protected $fillable = [
'resource_type',
'resource_id',
'loc',
'loc_hash',
'lastmod',
'changefreq',
'priority',
'contributor',
'is_visible',
];
/**
* 속성 캐스팅을 반환합니다.
*
* @return array<string, string> 캐스팅 맵
*/
protected function casts(): array
{
return [
'lastmod' => 'datetime',
'priority' => 'float',
'is_visible' => 'boolean',
];
}
}
+5
View File
@@ -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);
+135
View File
@@ -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"),
];
}
}
+179
View File
@@ -0,0 +1,179 @@
<?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): int
{
// 기여자 스코프 전량 제거 후 청크 삽입. 대용량(수백만) 삽입을 단일 트랜잭션으로
// 감싸면 락/메모리 부담이 크므로, 삭제 후 청크별 삽입으로 유계 메모리를 유지합니다.
SitemapUrl::query()->where('contributor', $contributor)->delete();
$now = now();
$batch = [];
$inserted = 0;
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);
$inserted += count($batch);
$batch = [];
}
}
if ($batch !== []) {
SitemapUrl::insert($batch);
$inserted += count($batch);
}
return $inserted;
}
/**
* 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;
}
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace App\Seo;
use App\Seo\Contracts\SitemapContributorInterface;
/**
* Sitemap 기여자 지연 스트리밍 브리지 base
*
* SitemapContributorInterface 는 손대지 않은 채(제3자 raw 구현체 무파손),
* 구현체가 getUrls()(배열) 또는 getUrlsLazy()(제너레이터) 중 하나만 구현하면
* 나머지 한쪽을 자동으로 제공하는 브리지를 놓습니다.
*
* - 대용량 기여자는 getUrlsLazy() 를 오버라이드해 URL 을 한 건씩 yield 하여
* 전체 배열을 메모리에 적재하지 않습니다(1.4M OOM 방지).
* - getUrls() 만 오버라이드한 소량 기여자는 getUrlsLazy() 가 그 배열을 순회합니다.
*
* SitemapGenerator 는 이 base 를 상속했거나 getUrlsLazy() 를 가진 기여자를
* 지연 경로로 소진합니다(capability 감지). base 를 상속하지 않은 raw 구현체는
* 기존 getUrls() 경로로 그대로 동작합니다.
*/
abstract class AbstractSitemapContributor implements SitemapContributorInterface
{
/**
* 확장 식별자를 반환합니다.
*
* @return string 확장 식별자 (예: 'sirsoft-ecommerce')
*/
abstract public function getIdentifier(): string;
/**
* Sitemap URL 항목 배열을 반환합니다.
*
* 지연 스트림을 배열로 실체화합니다. 대용량 기여자는 이 메서드 대신
* SitemapGenerator 가 소비하는 getUrlsLazy() 로 스트리밍되므로,
* 이 경로는 소규모/하위호환 용도입니다.
*
* @return array<int, array{loc?: string, url?: string, lastmod?: string, changefreq?: string, priority?: float}>
*/
public function getUrls(): array
{
return iterator_to_array($this->getUrlsLazy(), false);
}
/**
* Sitemap URL 항목을 한 건씩 지연 순회합니다.
*
* 기본 구현은 getUrls() 배열을 순회합니다. 대용량 기여자는 이 메서드를
* 오버라이드해 Repository 스트림을 직접 yield 하십시오.
*
* @return iterable<int, array{loc?: string, url?: string, lastmod?: string, changefreq?: string, priority?: float}>
*/
public function getUrlsLazy(): iterable
{
yield from $this->getUrls();
}
}
+4 -4
View File
@@ -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();
}
/**
+64
View File
@@ -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
View File
@@ -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;
}
}
+16
View File
@@ -2,6 +2,10 @@
namespace App\Seo;
use App\Contracts\Extension\StorageInterface;
use App\Contracts\Repositories\SitemapUrlRepositoryInterface;
use App\Extension\Storage\CoreStorageDriver;
use App\Repositories\SitemapUrlRepository;
use App\Seo\Contracts\SeoCacheManagerInterface;
use App\Seo\Contracts\SeoRendererInterface;
use Illuminate\Support\ServiceProvider;
@@ -16,6 +20,7 @@ class SeoServiceProvider extends ServiceProvider
// 인터페이스 → 구현체 바인딩
$this->app->singleton(SeoCacheManagerInterface::class, SeoCacheManager::class);
$this->app->singleton(SeoRendererInterface::class, SeoRenderer::class);
$this->app->singleton(SitemapUrlRepositoryInterface::class, SitemapUrlRepository::class);
// 싱글톤 등록
$this->app->singleton(SeoCacheRegenerator::class);
@@ -28,6 +33,17 @@ class SeoServiceProvider extends ServiceProvider
$this->app->singleton(SeoMetaResolver::class);
$this->app->singleton(SitemapGenerator::class);
$this->app->singleton(SeoConfigMerger::class);
$this->app->singleton(SitemapFileStore::class);
$this->app->singleton(SitemapIndexer::class);
$this->app->singleton(SitemapProgress::class);
// Sitemap 파일 입출력은 비공개 디스크(local = storage/app/private, serve=false)를 사용합니다.
// public/storage 심볼릭 링크에 의존하지 않고 컨트롤러 스트리밍으로 서빙합니다.
foreach ([SitemapManager::class, SitemapFileStore::class] as $sitemapConsumer) {
$this->app->when($sitemapConsumer)
->needs(StorageInterface::class)
->give(fn () => new CoreStorageDriver('local'));
}
}
/**
+207
View File
@@ -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;
}
}
+162 -78
View File
@@ -2,6 +2,7 @@
namespace App\Seo;
use App\Enums\SitemapChangeFreq;
use App\Extension\TemplateManager;
use App\Seo\Contracts\SitemapContributorInterface;
use App\Services\TemplateService;
@@ -40,9 +41,161 @@ 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 항목(사이트맵 저장소 스트림 등)을 writer 로 스트리밍하여 파일을 생성합니다.
*
* 정적 라우트를 먼저 기록하고, 이어서 전달된 항목을 한 건씩 writer 에 넘깁니다.
* 증분/전체 재생성 모두 이 경로로 파일을 작성하며, 항목의 출처(도메인 재쿼리 vs 테이블 스트림)는
* 호출자가 결정합니다.
*
* @param SitemapWriter $writer 분할 기록기
* @param iterable<array{loc?: string, lastmod?: string, changefreq?: string, priority?: float}> $entries URL 항목 스트림
* @return array{generated_at: string, child_count: int, url_count: int, size_bytes: int, gzip: bool, children: array}
* 커밋된 sitemap 메타데이터
*/
public function generateToWriterFromEntries(SitemapWriter $writer, iterable $entries): array
{
$writer->open();
foreach ($this->collectStaticRoutes() as $entry) {
$writer->addUrl($entry);
}
foreach ($entries as $entry) {
$writer->addUrl($this->normalizeEntry($entry));
}
$writer->close();
return $writer->commit();
}
/**
* 한 기여자의 URL 항목을 절대 URL(loc)로 정규화하여 지연 스트리밍합니다.
*
* 전체 재생성 시 사이트맵 저장소에 적재할 항목의 단일 출처이며,
* 기여자가 emit 한 resource_type/resource_id 등 부가 키는 그대로 통과시킵니다.
*
* @param SitemapContributorInterface $contributor Sitemap 기여자
* @return iterable<array<string, mixed>> 정규화된 URL 항목 순회자
*/
public function streamContributorEntries(SitemapContributorInterface $contributor): iterable
{
foreach ($this->drain($contributor) as $entry) {
yield $this->normalizeEntry($entry);
}
}
/**
* 기여자로부터 URL 항목을 순회 가능한 형태로 가져옵니다.
*
* 지연 스트리밍 capability(AbstractSitemapContributor 상속 또는 getUrlsLazy() 보유)를
* 감지해, 있으면 한 건씩 yield 되는 제너레이터를 사용합니다. 없으면 기존 getUrls()
* 배열을 그대로 순회합니다(제3자 raw 구현체 하위호환).
*
* @param SitemapContributorInterface $contributor Sitemap 기여자
* @return iterable<array<string, mixed>> URL 항목 순회자
*/
private function drain(SitemapContributorInterface $contributor): iterable
{
if ($contributor instanceof AbstractSitemapContributor || method_exists($contributor, 'getUrlsLazy')) {
return $contributor->getUrlsLazy();
}
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 +208,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) {
@@ -143,7 +291,7 @@ class SitemapGenerator
$urls[] = [
'loc' => url($routePath),
'changefreq' => 'weekly',
'changefreq' => SitemapChangeFreq::Weekly->value,
'priority' => 0.5,
];
}
@@ -170,79 +318,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;
}
+114
View File
@@ -0,0 +1,114 @@
<?php
namespace App\Seo;
use App\Contracts\Repositories\SitemapUrlRepositoryInterface;
use App\Enums\SitemapChangeFreq;
use App\Extension\HookManager;
/**
* 사이트맵 리소스 색인기
*
* 리소스 변경 리스너가 Model/DB 에 직접 접근하지 않고(계층 위반 방지) 이 서비스를 경유해
* 사이트맵 URL 델타를 반영합니다. 공개 리소스는 색인(upsert), 비공개/삭제 리소스는 색인 해제(remove)합니다.
*
* 리스너는 리소스의 URL 규칙(예: /board/{slug}/{id})으로 만든 항목을 넘기며,
* 제3자 확장은 filter 훅(sitemap.index.collect_for_resource)으로 항목을 가공/추가할 수 있습니다.
*/
class SitemapIndexer
{
/**
* 리소스 항목 가공 filter 훅 이름 (제3자 확장용)
*/
public const COLLECT_FILTER = 'sitemap.index.collect_for_resource';
/**
* SitemapIndexer 생성자
*
* @param SitemapUrlRepositoryInterface $repository 사이트맵 URL 저장소
*/
public function __construct(
private readonly SitemapUrlRepositoryInterface $repository,
) {}
/**
* 공개 리소스의 사이트맵 URL 을 색인합니다(멱등).
*
* @param string $type 리소스 유형 (예: product, board_post, page)
* @param string|int $id 리소스 PK
* @param string $contributor 기여자 식별자 (예: sirsoft-ecommerce)
* @param array<int, array{loc?: string, url?: string, lastmod?: mixed, changefreq?: ?string, priority?: ?float}> $entries URL 항목
*/
public function indexResource(string $type, string|int $id, string $contributor, array $entries): void
{
$entries = HookManager::applyFilters(self::COLLECT_FILTER, $entries, $type, $id, $contributor);
if (! is_array($entries) || $entries === []) {
// 색인할 항목이 없으면(모두 제외됨) 기존 색인을 제거해 정합을 유지합니다.
$this->repository->removeForResource($type, (string) $id);
return;
}
$normalized = [];
foreach ($entries as $entry) {
if (! is_array($entry)) {
continue;
}
$loc = $this->resolveLoc($entry);
if ($loc === null) {
continue;
}
$normalized[] = [
'loc' => $loc,
'lastmod' => $entry['lastmod'] ?? null,
// 폐쇄 어휘로 정규화 — 비표준 값이 sitemap_urls 에 저장되는 것을 차단.
'changefreq' => SitemapChangeFreq::normalize($entry['changefreq'] ?? null),
'priority' => $entry['priority'] ?? null,
'contributor' => $contributor,
];
}
if ($normalized === []) {
$this->repository->removeForResource($type, (string) $id);
return;
}
$this->repository->upsertForResource($type, (string) $id, $normalized);
}
/**
* 비공개/삭제된 리소스의 사이트맵 URL 색인을 제거합니다.
*
* @param string $type 리소스 유형
* @param string|int $id 리소스 PK
*/
public function deindexResource(string $type, string|int $id): void
{
$this->repository->removeForResource($type, (string) $id);
}
/**
* 항목의 절대 URL(loc)을 해석합니다.
*
* 'loc'(절대 URL)이 있으면 그대로, 없으면 'url'(상대 경로)을 url() 로 절대화합니다.
*
* @param array<string, mixed> $entry URL 항목
* @return string|null 절대 URL (해석 불가 시 null)
*/
private function resolveLoc(array $entry): ?string
{
if (isset($entry['loc']) && is_string($entry['loc']) && $entry['loc'] !== '') {
return $entry['loc'];
}
if (isset($entry['url']) && is_string($entry['url']) && $entry['url'] !== '') {
return url($entry['url']);
}
return null;
}
}
+131 -17
View File
@@ -3,7 +3,10 @@
namespace App\Seo;
use App\Contracts\Extension\CacheInterface;
use App\Contracts\Extension\StorageInterface;
use App\Contracts\Repositories\ConfigRepositoryInterface;
use App\Contracts\Repositories\SitemapUrlRepositoryInterface;
use App\Enums\SitemapGenerationMode;
use App\Extension\HookManager;
use Illuminate\Support\Facades\Log;
@@ -11,56 +14,98 @@ 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,
private SitemapUrlRepositoryInterface $repository,
private SitemapProgress $progress,
) {}
/**
* Sitemap 을 즉시 생성하여 캐시에 저장하고 last_updated_at 을 기록합니다.
* Sitemap 을 즉시 생성하여 디스크에 커밋하고 last_updated_at 을 기록합니다.
*
* 모드에 따라 사이트맵 저장소(sitemap_urls) 활용 방식이 달라집니다:
* - full: 각 기여자를 스트리밍해 저장소를 전량 대체한 뒤 저장소 → 파일 재작성.
* - incremental: 저장소의 현재 상태(리스너가 반영한 델타)를 그대로 파일로 재작성.
* - auto: 저장소가 비어 있으면 full, 아니면 incremental.
*
* @param SitemapGenerationMode $mode 재생성 모드
* @param int|null $triggeredBy 이 재생성을 유발한 관리자 사용자 ID(관리자 수동 재생성만 지정, 스케줄러/증분/봇 = null).
* 완료/실패 훅에 함께 전달되어 알림 발송 대상(수동 실행자) 판정에 쓰입니다.
* @return array{success: bool, status: string, message?: string, data?: array<string, mixed>}
* status: 'updated' | 'disabled' | 'failed'
* status: 'updated' | 'disabled' | 'failed'
*/
public function regenerate(): array
public function regenerate(SitemapGenerationMode $mode = SitemapGenerationMode::Auto, ?int $triggeredBy = null): array
{
$enabled = (bool) g7_core_settings('seo.sitemap_enabled', true);
if (! $enabled) {
return [
'success' => false,
'status' => 'disabled',
'message' => 'Sitemap 생성이 비활성화되어 있습니다.',
'message' => __('seo.sitemap_disabled'),
];
}
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);
$effectiveMode = $mode->resolve($this->repository->countVisible());
$lastUpdatedAt = now()->toIso8601String();
if ($effectiveMode === SitemapGenerationMode::Full) {
$this->rebuildStore();
}
$this->progress->writing($this->repository->countVisible());
$meta = $this->generator->generateToWriterFromEntries(
$this->makeWriter(),
$this->streamStoreEntries(),
);
// 고급 탭(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 = [
'success' => true,
'status' => 'updated',
'message' => 'Sitemap 생성이 완료되었습니다.',
'message' => __('seo.sitemap_regenerated'),
'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,
],
];
HookManager::doAction('core.seo.sitemap.after_regenerate', $result);
$this->progress->complete($meta);
HookManager::doAction('core.seo.sitemap.after_regenerate', $result, $triggeredBy);
return $result;
} catch (\Throwable $e) {
@@ -68,22 +113,28 @@ class SitemapManager
'error' => $e->getMessage(),
]);
$this->progress->fail($e->getMessage());
$result = [
'success' => false,
'status' => 'failed',
'message' => 'Sitemap 생성에 실패했습니다: '.$e->getMessage(),
'message' => __('seo.sitemap_generate_failed', ['error' => $e->getMessage()]),
];
HookManager::doAction('core.seo.sitemap.after_regenerate_failed', $result);
HookManager::doAction('core.seo.sitemap.after_regenerate_failed', $result, $triggeredBy);
return $result;
}
}
/**
* 현재 sitemap 의 메타데이터를 반환합니다.
* 현재 sitemap 의 메타데이터와 진행상황을 반환합니다.
*
* @return array{last_updated_at: ?string}
* realtime_enabled 는 설정 SSoT(drivers.websocket_enabled)와 실제 적용 config
* (broadcasting.default) 를 함께 확인해 판정합니다 — 프론트가 실시간(구독)/폴링을
* 분기하는 근거입니다.
*
* @return array{last_updated_at: ?string, progress: ?array<string, mixed>, realtime_enabled: bool}
*/
public function getStatus(): array
{
@@ -91,9 +142,72 @@ class SitemapManager
return [
'last_updated_at' => $lastUpdatedAt !== '' ? $lastUpdatedAt : null,
'progress' => $this->progress->get(),
'realtime_enabled' => (bool) g7_core_settings('drivers.websocket_enabled', false)
&& config('broadcasting.default') !== 'null',
];
}
/**
* 각 기여자를 스트리밍하여 사이트맵 저장소를 전량 대체합니다(전체 재생성).
*
* 기여자별로 격리하여, 한 기여자가 실패해도 나머지 기여자의 재적재는 계속됩니다.
*/
private function rebuildStore(): void
{
// 스트림 삽입 건수를 누적해 running 단계의 "누적 URL 수" 를 표기합니다(D4).
// countVisible() 같은 사전 count 쿼리 없이 삽입 결과만 합산하므로 1.4M 에서도 부담이 없습니다.
$total = 0;
foreach ($this->generator->getContributors() as $contributor) {
try {
// 현재 기여자 진입 시점의 누적치(직전 기여자들까지)를 표시합니다.
$this->progress->phase($contributor->getIdentifier(), $total);
$total += $this->repository->replaceAllForContributor(
$contributor->getIdentifier(),
$this->generator->streamContributorEntries($contributor),
);
} catch (\Throwable $e) {
Log::warning('[SEO] Sitemap 저장소 재적재 실패', [
'contributor' => $contributor->getIdentifier(),
'error' => $e->getMessage(),
]);
}
}
}
/**
* 사이트맵 저장소의 공개 URL 을 writer 항목 형태로 지연 스트리밍합니다.
*
* @return iterable<array{loc: string, lastmod: ?string, changefreq: ?string, priority: ?float}> writer 항목 순회자
*/
private function streamStoreEntries(): iterable
{
foreach ($this->repository->streamVisible() as $row) {
yield [
'loc' => (string) $row->loc,
'lastmod' => $row->lastmod?->toW3cString(),
'changefreq' => $row->changefreq,
'priority' => $row->priority !== null ? (float) $row->priority : null,
];
}
}
/**
* 설정값을 반영한 SitemapWriter 를 생성합니다.
*
* @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 을 기록합니다.
*
+228
View File
@@ -0,0 +1,228 @@
<?php
namespace App\Seo;
use App\Contracts\Extension\CacheInterface;
use App\Extension\HookManager;
/**
* Sitemap 재생성 진행상황 스토어.
*
* 재생성 잡/커맨드가 단계(phase)를 기록하면 캐시(seo.sitemap.progress)에 저장하고,
* Reverb 가 활성일 때는 관리자 채널로 방송해 SEO 탭이 실시간으로 갱신되게 합니다.
* Reverb OFF 시 방송은 HookManager 가 자동으로 건너뛰며(분기 불필요), 캐시는 항상
* 기록되므로 프론트가 폴링으로 폴백할 수 있습니다.
*
* 상태 전이: queued → running(기여자별) → writing → completed | failed
* (진행바는 퍼센트가 아닌 단계 기반 — 사전 count 쿼리가 1.4M 에서 부담이므로.)
*/
final class SitemapProgress
{
/**
* 진행상황 캐시 키
*/
public const KEY = 'seo.sitemap.progress';
/**
* 방송 채널명 (관리자 전용)
*/
public const CHANNEL = 'core.admin.seo.sitemap';
/**
* 방송 이벤트명 (클라이언트는 `.` 접두로 수신)
*/
public const EVENT = 'sitemap.progress.updated';
/**
* 진행상황 캐시 TTL (초) — 잡이 하드 kill 로 죽어도 이 시간 후 자동 만료(무한 running 방지)
*/
private const TTL = 3600;
/**
* URL 누적 방송 간격 — 같은 단계에서 urls 만 증가할 때 이 간격마다만 방송(방송 폭주 방지)
*/
private const BROADCAST_URL_INTERVAL = 5000;
/**
* 마지막으로 방송한 시점의 누적 URL 수
*/
private int $lastBroadcastUrls = 0;
public function __construct(private CacheInterface $cache) {}
/**
* 재생성 예약을 기록합니다(큐 대기 중에도 UI 가 'queued' 를 표시).
*
* @param string $mode 재생성 모드(full|auto|incremental)
*/
public function start(string $mode): void
{
$this->lastBroadcastUrls = 0;
$this->persist([
'status' => 'queued',
'mode' => $mode,
'started_at' => now()->toIso8601String(),
'finished_at' => null,
'phase' => null,
'urls' => 0,
'url_count' => null,
'child_count' => null,
'message' => null,
], broadcast: true);
}
/**
* 현재 처리 중인 기여자 단계를 기록합니다.
*
* 단계가 바뀌면 즉시 방송하고, 같은 단계에서 urls 만 증가하면 간격 스로틀을 적용합니다.
* 캐시는 폴링 정확도를 위해 항상 기록합니다.
*
* @param string $phase 단계 식별자(기여자 identifier)
* @param int|null $urls 누적 URL 수(선택)
*/
public function phase(string $phase, ?int $urls = null): void
{
$state = $this->current();
$phaseChanged = ($state['phase'] ?? null) !== $phase;
$state['status'] = 'running';
$state['phase'] = $phase;
if ($urls !== null) {
$state['urls'] = $urls;
}
$broadcast = $phaseChanged
|| ($urls !== null && ($urls - $this->lastBroadcastUrls) >= self::BROADCAST_URL_INTERVAL);
$this->persist($state, broadcast: $broadcast);
if ($broadcast) {
$this->lastBroadcastUrls = (int) ($state['urls'] ?? 0);
}
}
/**
* 파일 작성 단계를 기록합니다.
*
* @param int|null $urls 작성 대상 누적 URL 수(선택)
*/
public function writing(?int $urls = null): void
{
$state = $this->current();
$state['status'] = 'writing';
$state['phase'] = null;
if ($urls !== null) {
$state['urls'] = $urls;
}
$this->persist($state, broadcast: true);
}
/**
* 재생성 완료를 기록합니다.
*
* @param array<string, mixed> $meta 생성 메타(url_count, child_count 등)
*/
public function complete(array $meta): void
{
$state = $this->current();
$state['status'] = 'completed';
$state['phase'] = null;
$state['finished_at'] = now()->toIso8601String();
$state['url_count'] = $meta['url_count'] ?? null;
$state['child_count'] = $meta['child_count'] ?? null;
$state['urls'] = $meta['url_count'] ?? ($state['urls'] ?? 0);
$state['message'] = null;
// 완료 시점엔 방금 커밋된 새 생성 시각을 실어 "마지막 생성"이 실시간 갱신되게 한다.
$this->persist($state, broadcast: true, lastUpdatedAt: $meta['generated_at'] ?? null);
}
/**
* 재생성 실패를 기록합니다(무한 running 방지).
*
* @param string $message 실패 메시지
*/
public function fail(string $message): void
{
$state = $this->current();
$state['status'] = 'failed';
$state['phase'] = null;
$state['finished_at'] = now()->toIso8601String();
$state['message'] = $message;
$this->persist($state, broadcast: true);
}
/**
* 현재 진행상황을 반환합니다(폴링/초기 로드용).
*
* @return array<string, mixed>|null 진행상황 또는 아직 실행 이력이 없으면 null
*/
public function get(): ?array
{
$state = $this->cache->get(self::KEY);
return is_array($state) ? $state : null;
}
/**
* 현재 진행상황을 반환하되, 없으면 기본 골격을 돌려줍니다(부분 갱신 병합용).
*
* @return array<string, mixed> 진행상황 골격
*/
private function current(): array
{
return $this->get() ?? [
'status' => 'idle',
'mode' => null,
'started_at' => null,
'finished_at' => null,
'phase' => null,
'urls' => 0,
'url_count' => null,
'child_count' => null,
'message' => null,
];
}
/**
* 진행상황을 캐시에 기록하고, 필요 시 관리자 채널로 방송합니다.
*
* 방송 payload 는 상태 API 응답 봉투와 동형입니다 — 최상위 `data` 봉투 안에
* `progress`/`last_updated_at`/`realtime_enabled` 를 담습니다(대시보드 방송과 동일 규약).
* websocket 데이터소스가 `target_source` 로 대상 소스의 저장값을 통째 교체하므로, 봉투
* 형태여야 교체 후에도 템플릿의 `sitemap_status.data.*` 바인딩이 유지됩니다(D27).
*
* @param array<string, mixed> $state 진행상황 상태
* @param bool $broadcast 방송 여부(스로틀 판정 결과)
* @param string|null $lastUpdatedAt 이 방송에 실을 마지막 생성 시각(완료 시 새 시각, 그 외 직전 값)
*/
private function persist(array $state, bool $broadcast, ?string $lastUpdatedAt = null): void
{
$this->cache->put(self::KEY, $state, self::TTL);
if (! $broadcast) {
return;
}
// 미지정(진행 중 단계)이면 직전 완료 시각(settings)을 싣는다. target_source 가 대상 소스
// data 를 통째 교체하므로 매 방송마다 형제 필드를 온전히 실어야 "마지막 생성"이 유지된다.
if ($lastUpdatedAt === null) {
$stored = (string) g7_core_settings('seo.sitemap_last_updated_at', '');
$lastUpdatedAt = $stored !== '' ? $stored : null;
}
// 방송 payload 는 상태 API 응답 봉투와 동형이어야 한다(대시보드 방송과 동일 규약).
// 최상위 data 봉투 안에 progress/last_updated_at/realtime_enabled 를 담아야
// target_source 교체 후에도 템플릿의 `sitemap_status.data.*` 바인딩이 유지된다.
HookManager::broadcast(self::CHANNEL, self::EVENT, [
'data' => [
'last_updated_at' => $lastUpdatedAt,
'progress' => $state,
'realtime_enabled' => true,
],
]);
}
}
+338
View File
@@ -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]));
}
}
}
+311
View File
@@ -0,0 +1,311 @@
<?php
namespace App\Seo;
use App\Enums\SitemapChangeFreq;
use Illuminate\Support\Facades\Log;
/**
* Sitemap XML 조각 렌더러
*
* urlset 헤더/푸터, URL 단위 <url> 블록, sitemapindex 를 생성합니다.
* Sitemap XML 의 이스케이프 로직을 이 클래스 한 곳에서만 수행하며(단일 출처),
* SitemapGenerator(수집측)와 SitemapWriter(기록측)가 이 렌더러를 공유합니다.
*
* 다국어(supported_locales 2개 이상)면 URL 하나당 로케일별 <url> 블록을 생성하고
* 각 블록에 xhtml:link hreflang alternate 를 포함합니다. alternate 링크 집합은
* base URL 당 한 번만 계산해 로케일별 <url> 에 재사용하므로, alternate 생성 비용은
* 로케일 수의 제곱이 아니라 선형입니다.
*/
final class SitemapXmlRenderer
{
/**
* hreflang alternate 를 방출하는 지원 로케일 수 상한
*
* 로케일 수가 이 값을 넘으면 URL 하나당 alternate 링크가 과도하게 팽창하므로
* alternate 를 생략합니다(로케일별 <url> 자체는 유지). 정상 다국어 구성(2~수개)은
* 이 상한에 걸리지 않습니다.
*/
public const MAX_HREFLANG = 50;
/**
* 다국어 여부 (지원 로케일 2개 이상)
*/
private readonly bool $multilingual;
/**
* hreflang alternate 를 방출하는지 여부
*
* 다국어이면서 hreflang 이 켜져 있고 로케일 수가 상한 이하일 때만 true.
*/
private readonly bool $hreflangActive;
/**
* SitemapXmlRenderer 생성자
*
* @param array<int, string> $supportedLocales 지원 로케일 목록
* @param string $defaultLocale 기본 로케일
* @param bool $hreflangEnabled hreflang alternate 방출 여부 (기본 true)
* @param int $maxHreflang hreflang 을 방출할 로케일 수 상한
*/
public function __construct(
private readonly array $supportedLocales,
private readonly string $defaultLocale,
bool $hreflangEnabled = true,
int $maxHreflang = self::MAX_HREFLANG,
) {
$this->multilingual = count($this->supportedLocales) > 1;
$this->hreflangActive = $this->multilingual
&& $hreflangEnabled
&& count($this->supportedLocales) <= $maxHreflang;
}
/**
* 애플리케이션 로케일 설정으로 렌더러를 생성합니다.
*
* hreflang 방출 여부는 seo.sitemap_hreflang_enabled 설정(기본 true)을 따릅니다.
* 로케일 수가 상한을 초과하면 alternate 를 생략하고 경고를 남깁니다.
*
* @return self 설정 기반 렌더러 인스턴스
*/
public static function fromConfig(): self
{
$defaultLocale = (string) config('app.locale');
$supportedLocales = (array) config('app.supported_locales', [$defaultLocale]);
$hreflangEnabled = (bool) g7_core_settings('seo.sitemap_hreflang_enabled', true);
if ($hreflangEnabled && count($supportedLocales) > self::MAX_HREFLANG) {
Log::warning('[SEO] Sitemap hreflang alternate 링크가 로케일 수 상한을 초과하여 생략됩니다.', [
'locale_count' => count($supportedLocales),
'max_hreflang' => self::MAX_HREFLANG,
]);
}
return new self($supportedLocales, $defaultLocale, $hreflangEnabled);
}
/**
* 다국어 모드 여부를 반환합니다.
*
* @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->hreflangActive) {
$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);
}
// hreflang alternate 집합은 base URL 당 한 번만 계산해 로케일별 <url> 에 재사용합니다.
// (로케일별로 다시 계산하면 alternate 생성이 로케일 수의 제곱이 됩니다.)
$alternateLinks = $this->hreflangActive ? $this->buildAlternateLinks($baseLoc) : '';
$xml = '';
foreach ($this->supportedLocales as $locale) {
$xml .= $this->renderLocalizedUrl($baseLoc, $entry, (string) $locale, $alternateLinks);
}
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 링크 문자열은 호출부에서 base URL 당 한 번 계산해 전달합니다.
*
* @param string $baseLoc 기본 로케일 절대 URL
* @param array $entry URL 항목
* @param string $locale 대상 로케일
* @param string $alternateLinks 미리 계산된 hreflang alternate 링크 문자열 (비활성 시 빈 문자열)
* @return string <url> 블록 문자열
*/
private function renderLocalizedUrl(string $baseLoc, array $entry, string $locale, string $alternateLinks): string
{
$xml = ' <url>'."\n";
$xml .= ' <loc>'.$this->escape($this->localizedLoc($baseLoc, $locale)).'</loc>'."\n";
$xml .= $this->renderMetaFields($entry);
$xml .= $alternateLinks;
$xml .= ' </url>'."\n";
return $xml;
}
/**
* base URL 하나에 대한 hreflang alternate 링크 집합을 렌더링합니다.
*
* 모든 지원 로케일의 alternate + x-default 를 포함하며, 이 문자열은 base URL 의
* 모든 로케일 <url> 블록에 동일하게 재사용됩니다.
*
* @param string $baseLoc 기본 로케일 절대 URL
* @return string xhtml:link alternate 링크 문자열
*/
private function buildAlternateLinks(string $baseLoc): string
{
$xml = '';
// 모든 로케일의 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";
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";
}
// changefreq 는 sitemaps.org 폐쇄 어휘 — Enum 으로 정규화해 비표준 값은 출력하지 않는다.
$changefreq = SitemapChangeFreq::normalize($entry['changefreq'] ?? null);
if ($changefreq !== null) {
$xml .= ' <changefreq>'.$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');
}
}
+1 -1
View File
@@ -563,7 +563,7 @@ class SettingsService
// 각 카테고리에 속하는 원본 필드명 목록
// (frontend_key → 원본 키 역변환 후의 키 기준)
$categoryFieldMap = [
'cache' => ['enabled', 'layout_enabled', 'layout_ttl', 'stats_enabled', 'stats_ttl', 'seo_enabled', 'seo_ttl'],
'cache' => ['enabled', 'layout_enabled', 'layout_ttl', 'stats_enabled', 'stats_ttl', 'seo_enabled', 'seo_ttl', 'seo_sitemap_ttl'],
'debug' => ['mode', 'sql_query_log', 'log_level'],
'core_update' => ['github_url', 'github_token'],
'geoip' => ['feature_enabled', 'license_key', 'auto_update_enabled', 'last_updated_at'],
+1 -1
View File
@@ -231,7 +231,7 @@ return [
|
*/
'version' => env('APP_VERSION', '7.0.5'),
'version' => env('APP_VERSION', '7.0.6'),
/*
|--------------------------------------------------------------------------
+94
View File
@@ -586,6 +586,100 @@ return [
],
],
],
// 사이트맵 재생성 완료 — 관리자 수동 재생성(SEO 탭 "지금 생성")에 한해, 실행한 관리자에게 발송.
// 스케줄러/증분/봇 재생성은 SeoNotificationDataListener 가 context.skip 으로 제외.
// 기본 활성 채널은 database(앱 내 알림)만. mail 템플릿은 존재하되 비활성(운영자가 필요 시 활성화).
'sitemap_regenerated' => [
'hook_prefix' => 'core.seo',
'name' => ['ko' => '사이트맵 재생성 완료', 'en' => 'Sitemap Regeneration Complete'],
'description' => ['ko' => '관리자가 수동으로 실행한 사이트맵 재생성이 완료되면 실행한 관리자에게 발송되는 알림', 'en' => 'Notification sent to the admin who manually triggered sitemap regeneration when it completes'],
'channels' => ['database'],
'hooks' => ['core.seo.sitemap.after_regenerate'],
'variables' => [
['key' => 'app_name', 'description' => '사이트명'],
['key' => 'url_count', 'description' => '생성된 총 URL 수'],
['key' => 'child_count', 'description' => '생성된 사이트맵 파일 수'],
['key' => 'action_url', 'description' => 'SEO 설정 페이지 URL'],
['key' => 'site_url', 'description' => '사이트 URL'],
],
'templates' => [
[
'channel' => 'mail',
'is_active' => false,
'recipients' => [['type' => 'trigger_user']],
'subject' => [
'ko' => '[{app_name}] 사이트맵 재생성이 완료되었습니다',
'en' => '[{app_name}] Sitemap Regeneration Complete',
],
'body' => [
'ko' => '<h1>사이트맵 재생성 완료</h1>'
.'<p>요청하신 사이트맵 재생성이 완료되었습니다.</p>'
.'<p>총 <strong>{url_count}</strong>개의 URL 이 <strong>{child_count}</strong>개의 파일로 생성되었습니다.</p>'
.'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 24px 0;"><tr><td align="center"><a href="{action_url}" style="display: inline-block; padding: 12px 32px; background-color: #2d3748; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 600; font-size: 14px;">SEO 설정 보기</a></td></tr></table>'
.'<p>감사합니다,<br><a href="{site_url}">{app_name}</a></p>',
'en' => '<h1>Sitemap Regeneration Complete</h1>'
.'<p>The sitemap regeneration you requested has completed.</p>'
.'<p>A total of <strong>{url_count}</strong> URLs were generated across <strong>{child_count}</strong> file(s).</p>'
.'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 24px 0;"><tr><td align="center"><a href="{action_url}" style="display: inline-block; padding: 12px 32px; background-color: #2d3748; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 600; font-size: 14px;">View SEO Settings</a></td></tr></table>'
.'<p>Thank you,<br><a href="{site_url}">{app_name}</a></p>',
],
],
[
'channel' => 'database',
'recipients' => [['type' => 'trigger_user']],
'subject' => ['ko' => '사이트맵 재생성 완료', 'en' => 'Sitemap regeneration complete'],
'body' => ['ko' => '요청하신 사이트맵 재생성이 완료되었습니다. 총 {url_count}개 URL, {child_count}개 파일이 생성되었습니다.', 'en' => 'Your sitemap regeneration is complete. {url_count} URLs across {child_count} file(s) were generated.'],
'click_url' => '/admin/settings?tab=seo',
],
],
],
// 사이트맵 재생성 실패 — 관리자 수동 재생성이 실패하면 실행한 관리자에게 발송.
'sitemap_regenerate_failed' => [
'hook_prefix' => 'core.seo',
'name' => ['ko' => '사이트맵 재생성 실패', 'en' => 'Sitemap Regeneration Failed'],
'description' => ['ko' => '관리자가 수동으로 실행한 사이트맵 재생성이 최종 실패(재시도 소진)하면 실행한 관리자에게 발송되는 알림', 'en' => 'Notification sent to the admin who manually triggered sitemap regeneration when it finally fails after retries'],
'channels' => ['database'],
// 재시도 소진 시 1회만 발화하는 최종 실패 훅에 구독 (매 시도 발화하는 after_regenerate_failed 아님)
'hooks' => ['core.seo.sitemap.regenerate_failed_final'],
'variables' => [
['key' => 'app_name', 'description' => '사이트명'],
['key' => 'error', 'description' => '실패 사유 메시지'],
['key' => 'action_url', 'description' => 'SEO 설정 페이지 URL'],
['key' => 'site_url', 'description' => '사이트 URL'],
],
'templates' => [
[
'channel' => 'mail',
'is_active' => false,
'recipients' => [['type' => 'trigger_user']],
'subject' => [
'ko' => '[{app_name}] 사이트맵 재생성이 실패했습니다',
'en' => '[{app_name}] Sitemap Regeneration Failed',
],
'body' => [
'ko' => '<h1>사이트맵 재생성 실패</h1>'
.'<p>요청하신 사이트맵 재생성이 실패했습니다.</p>'
.'<p>오류: <strong>{error}</strong></p>'
.'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 24px 0;"><tr><td align="center"><a href="{action_url}" style="display: inline-block; padding: 12px 32px; background-color: #2d3748; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 600; font-size: 14px;">SEO 설정 보기</a></td></tr></table>'
.'<p>감사합니다,<br><a href="{site_url}">{app_name}</a></p>',
'en' => '<h1>Sitemap Regeneration Failed</h1>'
.'<p>The sitemap regeneration you requested has failed.</p>'
.'<p>Error: <strong>{error}</strong></p>'
.'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 24px 0;"><tr><td align="center"><a href="{action_url}" style="display: inline-block; padding: 12px 32px; background-color: #2d3748; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 600; font-size: 14px;">View SEO Settings</a></td></tr></table>'
.'<p>Thank you,<br><a href="{site_url}">{app_name}</a></p>',
],
],
[
'channel' => 'database',
'recipients' => [['type' => 'trigger_user']],
'subject' => ['ko' => '사이트맵 재생성 실패', 'en' => 'Sitemap regeneration failed'],
'body' => ['ko' => '요청하신 사이트맵 재생성이 실패했습니다. 오류: {error}', 'en' => 'Your sitemap regeneration failed. Error: {error}'],
'click_url' => '/admin/settings?tab=seo',
],
],
],
],
/*
+13 -3
View File
@@ -65,10 +65,15 @@
"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_urls_per_file": 50000,
"sitemap_gzip": false,
"sitemap_serve_stale_on_miss": true,
"sitemap_max_urls_per_contributor": 0,
"sitemap_hreflang_enabled": true,
"sitemap_schedule": "daily",
"sitemap_schedule_time": "02:00",
"sitemap_last_updated_at": "",
@@ -213,6 +218,11 @@
"cache_ttl": { "type": "integer", "sensitive": false },
"sitemap_enabled": { "type": "boolean", "sensitive": false },
"sitemap_cache_ttl": { "type": "integer", "sensitive": false },
"sitemap_urls_per_file": { "type": "integer", "sensitive": false },
"sitemap_gzip": { "type": "boolean", "sensitive": false },
"sitemap_serve_stale_on_miss": { "type": "boolean", "sensitive": false },
"sitemap_max_urls_per_contributor": { "type": "integer", "sensitive": false },
"sitemap_hreflang_enabled": { "type": "boolean", "sensitive": false },
"sitemap_schedule": { "type": "string", "sensitive": false },
"sitemap_schedule_time": { "type": "string", "sensitive": false },
"sitemap_last_updated_at": { "type": "string", "sensitive": false },
@@ -0,0 +1,53 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* sitemap_urls 테이블을 생성합니다.
*
* 사이트맵 URL 을 리소스 단위로 영속화하여 진정한 증분 갱신(공개=upsert / 비공개=remove)과
* 도메인 재쿼리 없는 파일 재작성을 가능하게 합니다. 리소스 변경 리스너가 이 테이블에
* 델타를 반영하고, 파일 생성은 이 테이블을 스트리밍하여 수행합니다.
*/
public function up(): void
{
if (Schema::hasTable('sitemap_urls')) {
return;
}
Schema::create('sitemap_urls', function (Blueprint $table) {
$table->bigIncrements('id')->comment('사이트맵 URL 행 ID');
$table->string('resource_type', 64)->index()->comment('리소스 유형 (page, board_post, product, category, board, board_index, shop_index 등)');
$table->string('resource_id', 64)->nullable()->comment('리소스 PK (uuid/int 문자열). 인덱스/컬렉션 URL 은 null');
$table->string('loc', 2048)->comment('절대 base URL (로케일 무관 — 렌더 시점에 로케일별 확장)');
// loc 는 2048 자라 unique 인덱스에 직접 넣으면 InnoDB 키 길이(3072바이트) 를 초과하므로
// sha256 해시(64자 hex)를 identity 로 사용합니다. ascii 컬럼이라 인덱스 비용이 작습니다.
$table->char('loc_hash', 64)->charset('ascii')->comment('loc 의 sha256 해시 (upsert/delete identity)');
$table->timestamp('lastmod')->nullable()->comment('최종 수정 시각 (<lastmod>)');
$table->string('changefreq', 16)->nullable()->comment('변경 빈도 (<changefreq>)');
$table->decimal('priority', 2, 1)->nullable()->comment('우선순위 0.0~1.0 (<priority>)');
$table->string('contributor', 64)->comment('기여자 식별자 (getIdentifier — 샤드/리빌드 스코핑)');
$table->boolean('is_visible')->default(true)->comment('공개 여부 (true 만 사이트맵에 노출)');
$table->timestamps();
// 리소스별 URL identity — upsert/delete 대상 식별
$table->unique(['resource_type', 'resource_id', 'loc_hash'], 'sitemap_urls_identity_unique');
// 리빌드/증분 스트리밍 커서 (contributor 스코핑 + 가시성 + id 순서)
$table->index(['contributor', 'is_visible', 'id'], 'sitemap_urls_stream_index');
});
}
/**
* sitemap_urls 테이블을 제거합니다.
*/
public function down(): void
{
if (Schema::hasTable('sitemap_urls')) {
Schema::drop('sitemap_urls');
}
}
};
+107 -9
View File
@@ -148,13 +148,13 @@ Authorization: Bearer {YOUR_TOKEN}
**응답 필드** (`data` 내부)
_단건 응답: `data` 객체의 필드._
_단건 응답: `data` 객체의 필드 — `GET /api/admin/seo/sitemap/status` 와 동일한 스키마입니다._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| last_updated_at | string | `2026-07-08T03:14:49+00:00` | last updated 일시 |
| size_bytes | integer | `25489` | 크기 (바이트) |
| ttl | integer | `86400` | 캐시 유효 시간 (Time To Live, 초) |
| last_updated_at | string\|null | `2026-07-08T03:14:49+00:00` | **예약 시점 기준** 마지막 생성 일시. 이번 재생성의 결과가 아니라 직전 생성 시각이며, 잡 완료 후 갱신됩니다 (아직 한 번도 생성되지 않았으면 null) |
| progress | object\|null | `{"status":"queued","mode":"full",...}` | 재생성 진행상황. 예약 직후 `status=queued`, `mode=full` 로 즉시 기록됩니다. 필드 상세는 아래 상태 조회 API 참조 (진행 이력이 없으면 null) |
| realtime_enabled | boolean | `false` | 실시간 연결(WebSocket) 가능 여부. true 면 프론트가 진행상황 채널을 구독하고, false 면 상태 API 를 주기적으로 폴링합니다 |
**응답 예시**
@@ -165,11 +165,107 @@ HTTP/1.1 200
```json
{
"success": true,
"message": "Sitemap 재생성이 완료되었습니다.",
"message": "Sitemap 재생성을 시작했습니다. 완료까지 시간이 걸릴 수 있습니다.",
"data": {
"last_updated_at": "2026-07-08T03:14:49+00:00",
"size_bytes": 25489,
"ttl": 86400
"progress": {
"status": "queued",
"mode": "full",
"started_at": "2026-07-08T05:20:00+00:00",
"finished_at": null,
"phase": null,
"urls": 0,
"url_count": null,
"child_count": null,
"message": null
},
"realtime_enabled": false
}
}
```
**에러 응답**
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 400 | Bad Request | SEO 설정에서 sitemap 생성이 비활성(`seo.sitemap_enabled=false`)인 경우 (`seo.sitemap_disabled`) |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
<!-- @generated:end -->
**설명**
sitemap.xml 재생성을 큐에 예약합니다. 관리자 수동 재생성은 현 생성 상태와 무관하게 **항상 전체(full) 재생성**입니다. 응답은 예약 접수 결과이며 생성 완료를 의미하지 않습니다 — 실제 생성은 큐 워커가 `GenerateSitemapJob` 을 처리할 때 수행되고, 완료 후 마지막 생성 시각이 갱신됩니다.
요청 스레드에서 동기 생성하지 않는 이유는 대용량(수백만 URL) 사이트에서 생성이 수 분~수십 분 걸려 관리자 요청이 메모리 초과/타임아웃으로 실패하기 때문입니다. 잡은 유니크 락(`seo-sitemap`)을 사용하므로 재생성을 연속으로 요청해도 동시에 여러 건이 실행되지 않고 한 건만 큐에 남습니다.
예약 직후 진행상황이 `queued` 로 기록되며, 이후 진행 단계는 `GET /api/admin/seo/sitemap/status` 로 조회합니다.
이 엔드포인트로 접수된 **관리자 수동 재생성**은 실행한 관리자의 ID 를 잡에 실어, 생성 완료 시 `sitemap_regenerated`, 실패 시 `sitemap_regenerate_failed` 알림이 **그 관리자에게만** 발송됩니다(기본 채널: 앱 내 알림). 스케줄러·리소스 변경(증분)·봇 캐시 미스로 유발된 재생성은 실행 관리자가 없어 알림을 보내지 않습니다. 알림 정의는 `config/core.php` 의 `notification_definitions` 에 있으며 관리자 알림 설정에서 채널을 조정할 수 있습니다.
### GET /api/admin/seo/sitemap/status
<!-- @generated:start:api.admin.seo.sitemap.status -->
- **라우트명**: `api.admin.seo.sitemap.status`
- **컨트롤러**: `App\Http\Controllers\Api\Admin\SeoCacheController@sitemapStatus`
- **인증/권한**: `auth:sanctum` + `permission:core.settings.read`
**요청 파라미터**
_요청 파라미터 없음._
**요청 예시**
```http
GET /api/admin/seo/sitemap/status HTTP/1.1
Host: api.example.com
Accept: application/json
Authorization: Bearer {YOUR_TOKEN}
```
**응답 필드** (`data` 내부)
_단건 응답: `data` 객체의 필드._
| 필드 | 타입 | 실측 예시값 | 용도/설명 |
| --- | --- | --- | --- |
| last_updated_at | string\|null | `2026-07-08T03:14:49+00:00` | 마지막 생성 일시 (아직 한 번도 생성되지 않았으면 null) |
| progress | object\|null | `{"status":"running",...}` | 재생성 진행상황. 진행 이력이 없으면 null |
| progress.status | string | `running` | `queued`(예약) / `running`(기여자 처리 중) / `writing`(파일 작성 중) / `completed`(완료) / `failed`(실패) |
| progress.mode | string\|null | `full` | 재생성 모드 (`full` / `auto` / `incremental`) |
| progress.phase | string\|null | `sirsoft-board` | 현재 처리 중인 기여자 식별자 (running 단계에서만) |
| progress.urls | integer | `12000` | 누적 처리 URL 수 |
| progress.url_count | integer\|null | `5000` | 완료 시 총 URL 수 |
| progress.child_count | integer\|null | `2` | 완료 시 분할 파일 수 |
| progress.started_at | string\|null | `2026-07-08T05:20:00+00:00` | 예약 시각 |
| progress.finished_at | string\|null | `2026-07-08T05:21:30+00:00` | 완료/실패 시각 |
| progress.message | string\|null | `null` | 실패 시 오류 메시지 |
| realtime_enabled | boolean | `false` | 실시간 연결(WebSocket) 가능 여부. true 면 프론트가 `core.admin.seo.sitemap` 채널을 구독하고, false 면 이 API 를 주기적으로 폴링합니다 |
**응답 예시**
```http
HTTP/1.1 200
```
```json
{
"success": true,
"message": "성공적으로 처리되었습니다.",
"data": {
"last_updated_at": "2026-07-08T03:14:49+00:00",
"progress": {
"status": "running",
"mode": "full",
"started_at": "2026-07-08T05:20:00+00:00",
"finished_at": null,
"phase": "sirsoft-board",
"urls": 12000,
"url_count": null,
"child_count": null,
"message": null
},
"realtime_enabled": false
}
}
```
@@ -179,13 +275,15 @@ HTTP/1.1 200
| 상태코드 | 의미 | 발생 조건 |
| --- | --- | --- |
| 401 | Unauthenticated | 유효한 Bearer 토큰이 없거나 만료된 경우 |
| 403 | Forbidden | 요구 권한(`core.settings.update`)이 없는 경우 |
| 403 | Forbidden | 요구 권한(`core.settings.read`)이 없는 경우 |
<!-- @generated:end -->
**설명**
sitemap.xml 을 즉시 재생성합니다. SitemapManager 에 위임하며 큐 드라이버와 무관하게 동기(즉시) 실행되고, 완료 후 마지막 생성 시각을 갱신합니다. SEO 설정에서 sitemap 기능이 비활성인 경우 400(`seo.sitemap_disabled`), 생성 실패 시 500 을 반환합니다. 관리자가 콘텐츠 변경 후 검색엔진에 노출할 sitemap 을 스케줄 대기 없이 즉시 갱신할 때 사용합니다.
Sitemap 재생성 진행상황과 실시간 연결 가능 여부를 조회합니다. SEO 탭 진입 시 초기 로드에 사용되며, `realtime_enabled` 가 false(웹소켓 미사용)일 때는 프론트가 진행 중(`queued`/`running`/`writing`) 동안 이 API 를 3초 간격으로 폴링합니다. 실시간 연결이 켜져 있으면 폴링 대신 `core.admin.seo.sitemap` 채널의 `sitemap.progress.updated` 이벤트로 즉시 갱신됩니다.
진행상황은 캐시(TTL 3600초)에 저장되므로, 잡이 비정상 종료해도 시간이 지나면 만료됩니다. 잡 최종 실패 시에는 `progress.status=failed` 로 고정됩니다.
### GET /api/admin/seo/stats
+5
View File
@@ -148,6 +148,11 @@ HTTP/1.1 200
"cache_ttl": 7200,
"sitemap_enabled": true,
"sitemap_cache_ttl": 86400,
"sitemap_urls_per_file": 50000,
"sitemap_gzip": false,
"sitemap_serve_stale_on_miss": true,
"sitemap_max_urls_per_contributor": 0,
"sitemap_hreflang_enabled": true,
"sitemap_schedule": "daily",
"sitemap_schedule_time": "02:00",
"sitemap_last_updated_at": "2026-07-08T03:14:49+00:00",
+15 -1
View File
@@ -211,10 +211,19 @@ HookManager::broadcast("core.user.notifications.{$user->uuid}", 'notification.re
| 소스 | 패턴 | 예시 |
|------|------|------|
| 코어 | `core.*` | `core.admin.dashboard`, `core.user.notifications.{id}` |
| 코어 | `core.*` | `core.admin.dashboard`, `core.admin.seo.sitemap`, `core.user.notifications.{id}` |
| 모듈 | `module.{identifier}.*` | `module.sirsoft-ecommerce.orders.{id}` |
| 플러그인 | `plugin.{identifier}.*` | `plugin.sirsoft-payment.status.{id}` |
### 코어 방송 채널 목록
| 채널 | 이벤트 | 인증 권한 | 페이로드 |
|------|--------|----------|----------|
| `core.admin.dashboard` | `dashboard.stats.updated` | `core.dashboard.read` | `{type, data}` |
| `core.admin.seo.sitemap` | `sitemap.progress.updated` | `core.settings.read` | `{realtime_enabled, progress}` (상태 API `data` 와 동형) |
> `core.admin.seo.sitemap` 은 Sitemap 생성 진행상황을 실시간 전달합니다. Reverb OFF 면 방송이 자동 skip 되고 프론트가 상태 API 폴링으로 폴백합니다. payload 가 상태 API 응답 `data` 와 동형이라 websocket 데이터소스 `target_source` 교체 후에도 UI 바인딩이 유지됩니다.
---
## 모듈/플러그인 채널 등록
@@ -314,6 +323,11 @@ Broadcast::channel('core.admin.dashboard', function ($user) {
return $user->hasPermission('core.dashboard.read');
});
// 관리자 Sitemap 생성 진행상황 채널 - SEO 설정 읽기 권한
Broadcast::channel('core.admin.seo.sitemap', function ($user) {
return $user->hasPermission('core.settings.read');
});
// 모듈 리소스 채널 (모듈의 getChannels()로 자동 등록 — 참고용 예시)
// Broadcast::channel('module.sirsoft-ecommerce.orders.{orderId}', function ($user, $orderId) {
// return $user->hasPermission('sirsoft-ecommerce.orders.read');
+112 -7
View File
@@ -12,7 +12,8 @@
5. seo-config.json: 텍스트 추출(text_props), 속성 매핑(attr_map), 허용 속성(allowed_attrs), 컴포넌트→HTML 매핑 — 모두 템플릿 선언
6. 훅 시스템: core.seo.filter_context/filter_og_data/filter_twitter_data/filter_structured_data/filter_meta/filter_view_data + 봇 감지 훅 core.seo.resolve_is_bot
7. 도메인 ownership: 모듈/플러그인이 `seoOgDefaults`/`seoTwitterDefaults`/`seoStructuredData` 메서드로 자기 도메인 OG/Twitter/JSON-LD 를 owned (이커머스 Product, 게시판 Article 등)
7. Artisan: seo:warmup, seo:clear, seo:stats, seo:generate-sitemap
8. Sitemap: 스트리밍 writer(유계 메모리)로 비공개 디스크에 분할 커밋(sitemapindex + sitemap-{n}.xml) 후 서빙 — 요청 스레드 생성 없음, 미스 시 잡 디스패치 + stale/503. 증분 저장소(sitemap_urls) + 재생성 모드(SitemapGenerationMode full/auto/incremental) + 진행상황(SitemapProgress, Reverb 실시간/OFF 폴링)
9. Artisan: seo:warmup, seo:clear, seo:stats, seo:generate-sitemap(--rebuild/--mode)
```
## 아키텍처 개요
@@ -70,7 +71,14 @@ Request → web.php catch-all → SeoMiddleware (봇 감지)
| SeoMetaResolver | 3계층 캐스케이드 메타 해석 |
| SeoMiddleware | 미들웨어 (봇 감지 → 렌더링) |
| SeoServiceProvider | DI 바인딩 |
| SitemapGenerator | Sitemap 수집기 |
| SitemapGenerator | Sitemap 수집기 (contributor 드레인 → writer 스트리밍) |
| SitemapManager | Sitemap 재생성 오케스트레이션 (모드 분기 + 진행상황 + 훅) |
| SitemapWriter | 자식파일당 in-memory 버퍼 flush + 분할 + atomic commit |
| SitemapFileStore | 디스크 세트 읽기/서빙 (manifest·index·child 응답) |
| SitemapXmlRenderer | XML escape/`<url>` 블록/hreflang 단일 출처 |
| SitemapIndexer | 리소스→sitemap_urls index/deindex (리스너 경유) |
| SitemapProgress | 진행상황 스토어 (phase 기반 + 방송) |
| AbstractSitemapContributor | contributor 브리지 base (getUrls↔getUrlsLazy) |
| SeoInvalidationRegistry | 무효화 규칙 레지스트리 |
| SeoDeclarationCollector | 레이아웃 SEO 선언 수집 |
| SeoCacheStatsService | 캐시 통계 집계 |
@@ -83,6 +91,8 @@ Request → web.php catch-all → SeoMiddleware (봇 감지)
| /api/admin/seo/clear-cache | POST | api.admin.seo.clear-cache | 캐시 삭제 |
| /api/admin/seo/warmup | POST | api.admin.seo.warmup | 워밍업 |
| /api/admin/seo/cached-urls | GET | api.admin.seo.cached-urls | 캐시 URL 목록 |
| /api/admin/seo/sitemap/regenerate | POST | api.admin.seo.sitemap.regenerate | Sitemap 전체 재생성 (큐 예약, mode=full) |
| /api/admin/seo/sitemap/status | GET | api.admin.seo.sitemap.status | Sitemap 생성 진행상황 + realtime_enabled |
## SeoMiddleware 동작 규칙
@@ -577,17 +587,35 @@ class EcommerceModule extends AbstractModule
**레이아웃 활성화 조건**: 모듈 declaration 이 호출되려면 레이아웃 `meta.seo.extensions` 에 `[{ "type": "module", "id": "sirsoft-ecommerce" }]` 선언 + `meta.seo.page_type` 명시 필요.
### SitemapContributorInterface 구현
### SitemapContributorInterface 구현 (지연 스트리밍 권장)
대용량 도메인은 `AbstractSitemapContributor` 를 상속해 `getUrlsLazy()`(제너레이터)를 구현합니다. base 가 `getUrls()↔getUrlsLazy()` 를 양방향 브리지하므로 둘 중 하나만 구현하면 됩니다. 인터페이스 자체는 변경되지 않아 base 를 상속하지 않은 제3자 raw 구현체(`implements SitemapContributorInterface` + `getUrls()`)도 그대로 동작합니다.
```php
// modules/_bundled/[module]/src/Seo/[Module]SitemapContributor.php
class EcommerceSitemapContributor implements SitemapContributorInterface
class EcommerceSitemapContributor extends AbstractSitemapContributor
{
public function getUrls(): array { /* 상품/카테고리 URL 반환 */ }
public function getIdentifier(): string { return 'sirsoft-ecommerce'; }
// 한 건씩 yield — 1.4M 배열을 메모리에 만들지 않음. 쿼리는 Repository 의
// stream*ForSitemap() 위임(lazyById, service-direct-data-access 규율).
// 리소스 단위 증분(SitemapIndexer) 매칭을 위해 resource_type/resource_id 를 함께 emit.
public function getUrlsLazy(): iterable
{
foreach ($this->products->streamVisibleForSitemap() as $p) {
yield ['loc' => url("/shop/{$p->id}"), 'lastmod' => $p->updated_at?->toW3cString(),
'resource_type' => 'product', 'resource_id' => (string) $p->id];
}
}
}
```
`SitemapGenerator::drain()` 이 `instanceof AbstractSitemapContributor || method_exists($c, 'getUrlsLazy')` 로 capability 를 감지해 지연 경로를 우선합니다.
#### changefreq 는 `App\Enums\SitemapChangeFreq` (폐쇄 어휘)
entry 의 `changefreq` 는 sitemaps.org 의 폐쇄 어휘(`always`/`hourly`/`daily`/`weekly`/`monthly`/`yearly`/`never`)로, `App\Enums\SitemapChangeFreq` 가 SSoT 입니다. 기여자·리스너·정적 URL 은 리터럴 문자열 대신 `SitemapChangeFreq::Weekly->value` 처럼 case 값을 사용해 오타를 작성 시점에 잡습니다. 규격에 없는 값은 저장 경계(`SitemapIndexer`)와 렌더 경계(`SitemapXmlRenderer`)에서 `SitemapChangeFreq::normalize()` 로 정규화되어 `null` 로 떨어지므로 사이트맵 XML 에 비표준 값이 출력되지 않습니다(대소문자·앞뒤 공백은 흡수). 제3자 raw 구현체가 임의 문자열을 emit 해도 이 두 경계에서 안전하게 걸러집니다.
### ServiceProvider 등록
```php
@@ -628,10 +656,82 @@ php artisan seo:warmup --layout=shop/show # 특정 레이아웃만
php artisan seo:clear # 전체 SEO 캐시 삭제
php artisan seo:clear --layout=home # 특정 레이아웃만
php artisan seo:stats # 캐시 통계 출력
php artisan seo:generate-sitemap # Sitemap 생성 (큐 디스패치)
php artisan seo:generate-sitemap # Sitemap 생성 (큐 디스패치, mode=auto)
php artisan seo:generate-sitemap --sync # Sitemap 동기 생성
php artisan seo:generate-sitemap --rebuild # 전체 재생성 (mode=full 상당)
php artisan seo:generate-sitemap --mode=full|auto|incremental # 재생성 모드 지정
```
- `--mode=full`: 도메인 전량을 다시 읽어 `sitemap_urls` 를 전면 replace 후 파일 재작성(자식 균등 재분배).
- `--mode=incremental`: 도메인 재쿼리 없이 `sitemap_urls` 스트림만으로 파일 재작성(리스너가 반영한 델타 그대로).
- `--mode=auto`(기본): 저장소가 비어 있으면 full, 채워져 있으면 incremental.
- `--rebuild` 은 `--mode=full` 의 별칭입니다.
## Sitemap 분할 생성과 서빙
Sitemap 은 비공개 디스크(StorageInterface, `cache` 카테고리)에 분할 파일로 커밋되고, 컨트롤러가 스트리밍으로 서빙합니다. `public/storage` 심볼릭 링크에 의존하지 않으며 자식 파일을 메모리에 적재하지 않습니다.
**디스크 레이아웃** (SSoT: `SitemapFileStore` 상수):
```text
sitemap/manifest.json 커밋 마커 + 자식 목록 메타 (이 파일 존재 = 서빙 가능)
sitemap/sitemap.xml sitemapindex (항상 비압축)
sitemap/sitemap-{n}.xml 자식 sitemap (gzip 시 .xml.gz)
sitemap/_tmp/ 생성 중 임시 디렉토리 (커밋 시 정리)
```
**라우트**: `/sitemap.xml`(인덱스) · `/sitemap-{n}.xml`, `/sitemap-{n}.xml.gz`(자식). 자식 경로는 `SitemapFileStore::childUrl()` 이 인덱스 `<loc>` 에 기록하는 값과 일치해야 합니다. gzip 여부는 manifest 가 결정하므로 두 경로를 같은 액션이 처리합니다.
**서빙 시맨틱** (요청 스레드에서 생성하지 않음):
| 상태 | 동작 |
|------|------|
| 메타 캐시 신선 + 세트 존재 | 디스크 세트 스트리밍 |
| 메타 캐시 만료 | `GenerateSitemapJob` 디스패치 후 기존 세트 서빙 (`sitemap_serve_stale_on_miss=true`) |
| 메타 캐시 만료 + stale 서빙 off | 잡 디스패치 후 503 + `Retry-After: 120` |
| 세트 전무 (신규 설치) | 잡 디스패치 후 503 + `Retry-After: 120` |
봇 요청 스레드에서 동기 생성하면 대용량(수백만 URL)에서 메모리 초과·타임아웃이 발생하므로 생성은 항상 큐가 담당합니다. 잡은 유니크 락(`seo-sitemap`)을 쓰므로 캐시 미스가 몰려도 동시에 여러 건이 실행되지 않습니다.
**전용 큐(`GenerateSitemapJob::QUEUE = 'sitemap'`)**: 사이트맵 생성 잡은 `sitemap` 전용 큐로 라우팅됩니다. 배포 시 이 큐 워커를 별도로 띄우되 **워커를 1개만** 배치해야 합니다(`php artisan queue:work --queue=sitemap` — supervisor 프로그램을 기본 큐와 분리해 등록). 이유: ①긴 생성 작업이 기본(`default`) 큐의 방송·알림을 막지 않아 진행상황이 실시간으로 흐르고, ②워커가 1개뿐이라 잡이 동시에 두 번 실행되지 않습니다(큐 `retry_after` 가 잡 실행보다 짧아도 단일 워커라 재예약 중복 실행이 발생하지 않음). `sitemap` 큐 워커가 없으면 재생성 잡이 처리되지 않으니 주의합니다.
**완료/실패 알림**: 관리자 수동 재생성(위 API)은 실행한 관리자 ID 를 잡에 실어, 완료 시 `sitemap_regenerated`, **최종 실패(재시도 소진, `Job::failed()`) 시** `sitemap_regenerate_failed` 알림을 그 관리자에게 발송합니다(기본 채널: 앱 내 알림). 매 시도 실패마다 발화하는 `core.seo.sitemap.after_regenerate_failed` 훅이 아니라 최종 실패 시 1회만 발화하는 `core.seo.sitemap.regenerate_failed_final` 훅에 알림이 연결되어, 재시도 중 실패가 반복되거나 결국 성공해도 실패 알림이 중복/오발송되지 않습니다. 스케줄러·증분·봇 재생성은 실행 관리자가 없어 알림을 보내지 않습니다.
**분할 기준**: `sitemap_urls_per_file`(기본 50000) 또는 파일 크기 임계(`SitemapWriter::MAX_FILE_BYTES`, 45MB) 중 먼저 도달하는 쪽. 둘 다 sitemaps.org 프로토콜 제한(50,000 URL / 50MB)을 지키기 위한 것입니다.
**관리자 수동 재생성**: `POST /api/admin/seo/sitemap/regenerate` 는 큐에 `mode=full` 로 예약만 하고 즉시 진행상황(`getStatus()`)을 응답합니다. 완료 여부는 진행상황(아래) 또는 `sitemap_last_updated_at` 갱신으로 확인합니다.
## 증분 저장소 (sitemap_urls)
리소스 단위 공개/비공개/삭제를 사이트맵에 반영하기 위해 URL 을 `sitemap_urls` 테이블에 지속합니다(주안점 1·3). 전체 재생성 없이 바뀐 리소스 행만 upsert/remove 합니다.
- **테이블**: `resource_type`/`resource_id`/`loc`/`loc_hash`(=`sha256(loc)`)/`lastmod`/`changefreq`/`priority`/`contributor`/`is_visible`. unique 는 `(resource_type, resource_id, loc_hash)` — `loc string(2048)` utf8mb4 는 InnoDB 키 길이(3072 byte)를 초과하므로 ascii 64 해시로 identity 를 유지합니다(상세: docs/database-guide.md).
- **Repository**: `SitemapUrlRepositoryInterface`(`upsertForResource`=delete-후-insert 멱등 / `removeForResource` / `streamVisible`(lazyById) / `countVisible` / `replaceAllForContributor`). `SeoServiceProvider` 가 singleton 바인딩.
- **인덱서**: `SitemapIndexer::indexResource($type,$model,$entries)` / `deindexResource($type,$model)` — 리스너가 직접 모델/DB 를 만지지 않고 이 서비스를 경유합니다(`service-direct-data-access` 규율). 제3자 확장은 filter 훅 `sitemap.index.collect_for_resource` 로 리소스→entries 를 가공/추가할 수 있습니다(docs/extension/hooks.md).
- **리스너**: 모듈 SEO 리스너가 `after_create/update/delete` 에서 공개상태를 판정해 index/deindex 하고 `GenerateSitemapJob::dispatch()`(유니크 락 디바운스)를 겁니다. 리소스 단위 증분에서 자식 파일 URL 수가 임계 미만으로 줄어드는 것(5000→4999)은 감안하며, 균등 재분배(리밸런싱)는 full 재생성 때만 합니다.
## 재생성 모드 (SitemapGenerationMode)
`App\Enums\SitemapGenerationMode`(Backed Enum: `full`/`auto`/`incremental`)가 재생성 정책을 캡슐화합니다. `resolve(int $visibleCount)` 가 `auto` 를 저장소 상태로 full/incremental 판정합니다.
| 트리거 | 모드 | 동작 |
|--------|------|------|
| 관리자 수동 재생성 | 항상 `full` | 도메인 전량 → 테이블 replace → 파일 전량 재작성 |
| 스케줄러 | `auto` | 테이블 비었으면 full, 아니면 incremental |
| 리소스 변경(리스너) | 해당 리소스만 | 행 upsert/remove 후 잡 디스패치 |
`Command → GenerateSitemapJob → SitemapManager::regenerate(SitemapGenerationMode)` 전 경로가 enum 시그니처를 공유합니다(잡 직렬화는 enum 프로퍼티 그대로).
## 진행상황 가시화 (Reverb 실시간 / OFF 폴링)
`SitemapProgress`(캐시 키 `seo.sitemap.progress`, TTL 3600)가 phase 기반 진행상황을 기록합니다: `queued → running(기여자별 phase + 누적 URL) → writing → completed/failed`. 사전 count 쿼리 없이 스트림 누적치를 표기합니다(1.4M 에서 count 자체가 부담).
- **상태 API**: `GET /api/admin/seo/sitemap/status`(`core.settings.read`)가 `{last_updated_at, progress, realtime_enabled}` 를 반환합니다. `realtime_enabled = drivers.websocket_enabled 설정 && config('broadcasting.default') !== 'null'`(설정 SSoT + 실제 적용 config 양쪽 확인).
- **방송**: `SitemapProgress` 가 `core.admin.seo.sitemap` 채널로 `sitemap.progress.updated` 를 방송합니다(payload 는 상태 API `data` 와 동형). Reverb OFF 면 `HookManager::broadcast` 가 자동 skip 하고 캐시만 기록되어 폴링 폴백이 성립합니다. 방송은 N URL(5000) 간격으로 스로틀합니다.
- **채널 인증**: `routes/channels.php` 에 `core.admin.seo.sitemap` → `core.settings.read`(docs/backend/broadcasting.md).
- **잡 실패**: `GenerateSitemapJob::failed()` 가 `SitemapProgress::fail()` 을 호출해 무한 running 을 방지합니다(TTL 만료 시 idle 복귀).
- **프론트**: SEO 탭이 상태 API 초기 로드 후 `realtime_enabled` 로 분기합니다 — true 면 websocket 데이터소스(`target_source`)로 갱신, false 면 `startInterval`/`stopInterval` 로 3초 폴링(완료/실패 전이 시 중단).
## 설정값 (코어 seo.*)
| 키 | 타입 | 기본값 | 설명 |
@@ -641,7 +741,12 @@ php artisan seo:generate-sitemap --sync # Sitemap 동기 생성
| cache_enabled | boolean | true | SEO 캐시 ON/OFF |
| cache_ttl | integer | 7200 | 캐시 TTL (초) |
| sitemap_enabled | boolean | true | sitemap.xml 생성 ON/OFF |
| sitemap_cache_ttl | integer | 86400 | Sitemap 캐시 TTL (초) |
| sitemap_cache_ttl | integer\|null | null | Sitemap 캐시 TTL 오버라이드 (초). null=고급 탭 `cache.seo_sitemap_ttl` 을 따름 |
| sitemap_urls_per_file | integer | 50000 | 자식 파일당 URL 수 (분할 기준). 1000~50000, 상한은 sitemaps.org 프로토콜 제한 |
| sitemap_gzip | boolean | false | 자식 파일 gzip 압축. 인덱스 파일은 항상 비압축 |
| sitemap_serve_stale_on_miss | boolean | true | 신선도 만료 시 기존 세트를 그대로 서빙(true) / 503 반환(false) |
| sitemap_max_urls_per_contributor | integer | 0 | 수집기당 URL 상한 (0=무제한). 초과 시 경고 로그 + truncate |
| sitemap_hreflang_enabled | boolean | true | 다국어 alternate(hreflang) 링크 출력 ON/OFF. 로케일 수가 `SitemapXmlRenderer::MAX_HREFLANG`(50) 초과 시 alternate 생략 |
| sitemap_schedule | string | "daily" | 생성 주기 (hourly/daily/weekly) |
| sitemap_schedule_time | string | "02:00" | 생성 시각 |
| og_default_site_name | string | "" | og:site_name 기본값. 비면 `general.site_name` fallback |
+4 -2
View File
@@ -249,8 +249,10 @@ php artisan seo:warmup --layout=shop/show # 특정 레이아웃만
php artisan seo:clear # 전체 SEO 캐시 삭제
php artisan seo:clear --layout=home # 특정 레이아웃만
php artisan seo:stats # 캐시 통계 출력
php artisan seo:generate-sitemap # Sitemap 생성 (큐 디스패치)
php artisan seo:generate-sitemap --sync # Sitemap 동기 생성
php artisan seo:generate-sitemap # Sitemap 생성 (큐 디스패치, mode=auto)
php artisan seo:generate-sitemap --sync # Sitemap 동기 생성
php artisan seo:generate-sitemap --rebuild # 전체 재생성 (mode=full)
php artisan seo:generate-sitemap --mode=full|auto|incremental # 재생성 모드 지정
```
### API 문서 Artisan 커맨드
+20
View File
@@ -204,6 +204,26 @@ php artisan migrate --pretend --path=database/migrations/2026_05_05_172526_add_l
---
### 긴 문자열 컬럼의 unique — 해시 컬럼 패턴 (sitemap_urls 사례)
utf8mb4(4 byte/char) 긴 문자열 컬럼을 unique 인덱스에 직접 넣으면 InnoDB 키 길이 제한(3072 byte)을 넘겨 마이그레이션이 실패한다. 예: `sitemap_urls` 의 identity 는 `(resource_type, resource_id, loc)` 인데 `loc string(2048)` 을 넣으면 `256+256+8192 = 8704 byte` 로 초과한다.
해결: **identity 문자열의 고정 길이 해시 컬럼**을 두고 그것을 unique 에 넣는다.
```php
$table->string('loc', 2048); // 실제 값 (utf8mb4)
$table->char('loc_hash', 64)->charset('ascii'); // sha256(loc) — ascii 64 byte
$table->unique(['resource_type', 'resource_id', 'loc_hash']); // 64+256+256 = 576 byte
```
- 해시는 app 레벨(`hash('sha256', $loc)`)에서 계산한다 — raw SQL/DB 함수 비의존(다국어·DB 이식성 규율).
- `loc` 는 실제 서빙/표시에 쓰고, `loc_hash` 는 오직 identity 매칭용이다.
- upsert 는 delete-후-insert 로 멱등을 보장하고, 이 unique 가 중복 삽입(1062)을 차단한다.
`sitemap_urls` 스키마: `resource_type(64,index)` · `resource_id(64,nullable)` · `loc(2048)` · `loc_hash(64,ascii)` · `lastmod` · `changefreq(16)` · `priority(2,1)` · `contributor(64,index)` · `is_visible(bool,index)` + `unique(resource_type, resource_id, loc_hash)` + `index(contributor, is_visible, id)`(리빌드 스트리밍 커서). 모든 컬럼 한국어 `->comment()`, `down()` 구현, `Schema::hasTable` 멱등 가드.
---
### 모듈/플러그인 마이그레이션 down() 메서드
```
+10
View File
@@ -157,8 +157,18 @@ core.seo.filter_view_data # View 직전 ($viewData) — extraHeadTags / ex
# SEO 봇 감지 훅 (Filter) — null 반환 시 라이브러리 평가로 fallthrough
core.seo.resolve_is_bot
# Sitemap 증분 인덱싱 훅 (Filter) — 리소스→sitemap 항목 가공/추가
sitemap.index.collect_for_resource # SitemapIndexer 가 리소스 index 시 발화 — applyFilters($entries, $type, $id, $contributor)
# Sitemap 재생성 훅 (Action)
core.seo.sitemap.before_regenerate # 재생성 시작 직전
core.seo.sitemap.after_regenerate # 재생성 성공 후 ($meta)
core.seo.sitemap.after_regenerate_failed # 재생성 실패 시 (['status'=>'failed', 'success'=>false, ...])
```
> `sitemap.index.collect_for_resource` 는 제3자 확장이 리소스 하나에 대한 sitemap 항목을 추가/보정할 때 씁니다(filter — `'type' => 'filter'` 명시 필수). `core.seo.sitemap.after_regenerate_failed` 는 재생성 잡이 실패했을 때 확장이 알림/복구를 걸 수 있는 action 훅입니다.
---
## 리스너 구현
+21
View File
@@ -1058,6 +1058,27 @@ $response = $this->storage->response(
- null 체크 후 404 응답 처리 필요
- 권한 체크는 컨트롤러/서비스에서 별도 구현
### 파일 서빙 안티패턴 — 전체 메모리 적재 금지
파일 본문을 응답으로 내려줄 때는 항상 `response()` / `download()` 스트리밍(내부 `readStream`)을 사용한다. 아래처럼 파일 전체를 문자열로 읽어 `echo` 하는 패턴은 대용량 파일에서 워커 메모리를 붕괴시킨다.
```php
// ❌ 금지 — streamDownload 로 감쌌지만 콜백 안에서 파일 전체를 메모리에 적재
return response()->streamDownload(function () use ($image) {
echo Storage::disk($image->disk)->get($image->path); // get() = 전체 문자열 로드
}, $image->original_filename, ['Content-Type' => $image->mime_type]);
// ✅ 올바름 — StorageInterface::response() 로 청크 스트리밍 (파일 전체 미적재)
$response = $this->storage->response('images', $image->path, $image->original_filename, [
'Content-Type' => $image->mime_type,
'Cache-Control' => 'public, max-age=31536000',
]);
return $response ?? ResponseHelper::notFound(...);
```
`StorageInterface::get()` 은 소형 메타 파일(manifest.json 등)의 전체 읽기 전용이며, 서빙 본문에는 사용하지 않는다.
---
## 트러블슈팅
+73
View File
@@ -14,12 +14,14 @@
5. 테스트 실행 중 발견한 무관 에러도 같은 세션에서 함께 처리 (stale test 수정 or 로직 수정)
6. 릴리스 전 composer test-smoke 통과 필수 (Installation 스위트)
7. 백엔드: php artisan test --filter=TestName / _bundled 확장은 _bundled에서 직접 실행
8. settings 디스크는 TestCase 가 전역 페이크 — 실제 설정 파일 오염 차단. 저장 직후 값 검증은 g7_core_settings() 말고 ConfigRepository::getCategory()
```
---
## 목차
- [실제 환경 파일 보호 (settings 디스크 격리)](#실제-환경-파일-보호-settings-디스크-격리)
- [기능 단위 시나리오 매트릭스](#기능-단위-시나리오-매트릭스)
- [도메인별 테스트 전략 매트릭스](#도메인별-테스트-전략-매트릭스)
- [Pre-release Smoke Suite](#pre-release-smoke-suite)
@@ -211,6 +213,41 @@ beforeRefreshingDatabase()는 RefreshDatabase 트레이트가 부모 클래스
---
## 실제 환경 파일 보호 (settings 디스크 격리)
`settings` 디스크의 root 는 `storage/app/settings` — 개발/운영이 실제로 사용하는 설정 파일 그 자체다. 설정 저장 경로를 타는 테스트(설정 API 호출, `ConfigRepository::saveCategory()`)는 이 파일을 그대로 덮어쓰며, `RefreshDatabase` 는 DB 만 되돌리므로 오염이 잔류한다.
`Tests\TestCase` 가 앱 부팅 직후 `Storage::fake('settings')` 로 전역 격리한다. 테스트에서 별도 조치는 필요 없다.
```text
✅ 설정 저장 테스트 = 추가 조치 불필요 (TestCase 가 이미 격리)
❌ Storage::fake('settings') 를 테스트마다 중복 호출 — 불필요
❌ 격리를 우회해 storage/app/settings 를 직접 읽고/쓰기 — 실제 환경 오염
```
### 저장 직후 값 검증
`g7_core_settings()` / `config('g7_settings.*')` 는 **부팅 시점에 적재된 값**이라 같은 프로세스에서 저장 직후에는 갱신되지 않는다. 저장 결과를 검증하려면 저장소를 직접 읽는다.
```php
// ❌ 저장했는데 부팅 시점 값이 나온다
$this->assertSame(20000, g7_core_settings('seo.sitemap_urls_per_file'));
// ✅ 저장소 실제 값 조회
$seo = app(ConfigRepositoryInterface::class)->getCategory('seo');
$this->assertSame(20000, $seo['sitemap_urls_per_file']);
```
### 배경 (회귀 사례)
격리 도입 전에는 `Storage::fake('settings')` 를 쓰는 테스트가 1개뿐이라, 설정 저장 테스트를 돌릴 때마다 개발 환경의 실제 설정이 테스트 값으로 대체됐다. 실측 피해: `general.json` 의 사이트명이 `"Test"`, 언어가 `ja` 로 바뀌어 **개발 사이트 언어가 일본어로 전환**되어 있었고, SEO/캐시 TTL 도 테스트 값으로 덮여 운영자가 지정한 값이 사라졌다. 테스트는 계속 green 이라 조용히 누적됐다.
회귀 가드: `tests/Feature/Settings/SettingsDiskIsolationTest.php` (격리 해제 시 FAIL).
`Storage::fake()` 는 **해석된 디스크 인스턴스만 교체**하고 `config('filesystems.disks.settings.root')` 는 실경로로 남는다. 격리 여부를 판정할 때는 `Storage::disk('settings')->path('')` 를 봐야 한다.
---
## 기능 단위 시나리오 매트릭스
```text
@@ -312,6 +349,42 @@ audit rule `test-scenario-coverage` 가 매니페스트 cross product 와 effect
2. **Pairwise (all-pairs)** 전략 허용: 매니페스트에 `coverage_strategy: pairwise` 명시 시 모든 axis 쌍의 모든 조합만 커버 (n축 → O(n²) 케이스). 단순 분기형 axis(true/false) 가 많을 때 유효
3. 그래도 폭발하면 **상위 axis 분리**: 매니페스트를 도메인별로 분할 (예: `order_payment_pg_toss_member.yaml`, `order_payment_pg_toss_guest.yaml`)
### 대규모 시나리오 축(scale)
큐/스케줄러/배치 생성처럼 데이터가 누적되면 붕괴할 수 있는 기능은 대용량(scale) 축을 매니페스트에 선언하고, 규모 의존 결함(유계 메모리·분할 정확성·전량 적재 회피 등)을 회귀 가드로 고정한다. 배경: 사이트맵 생성 잡이 1.4M 게시글을 전량 in-memory 적재해 반복 OOM 붕괴한 사례(#79).
**스키마** (`scale` = block-array of flow objects — 내장 YAML fallback 파서가 flow 객체만 중첩 파싱하므로 `- { ... }` 형태):
```yaml
scale:
- { n: 1500000, dataset: posts+products, assert: [bounded_peak_memory, child_count_correct, no_full_table_load] }
```
- `n`: 데이터셋 규모(건수). 10만 이상이어야 scale 축의 의미가 있다.
- `dataset`: 대용량 데이터셋 설명(선택).
- `assert`: 이 규모에서 검증할 단언(snake_case). 실 대용량 DB 대신 합성 iterator·계측(예: `memory_get_peak_usage`, `DB::listen`, 방송 스로틀 카운트)으로 성질을 고정해도 된다.
**테스트 마킹** — `effects` 처럼 테스트 docblock `@scale` 마킹으로 각 assert 를 커버:
```php
/**
* @scale n=1500000 asserts=bounded_peak_memory, child_count_correct
*/
public function test_large_volume_splits_and_keeps_memory_bounded(): void { ... }
```
TypeScript 테스트는 `// @scale ...` 라인 주석 동일 사용.
**강제 룰 2종** (자동 차단):
| 룰 | 대상 | 검출 |
|----|------|------|
| `scenario-scale-axis-required` | `tests/scenarios/**/*.yaml` | batch/generation 매니페스트(test_files 에 `/Jobs/`·`Generator` 또는 tags `[batch]`/`[generation]`)인데 scale 축 없음 / n<100000 / assert 미커버 |
| `job-generator-needs-scale-test` | `app/Jobs/**`·`**/*Generator.php` | 소스 변경 시 그 클래스를 언급하고 scale 축을 가진 매니페스트가 없음 |
- `*Command.php` 는 흔하고 볼륨 비의존이라 `job-generator-needs-scale-test` 대상에서 제외한다. 배치 커맨드는 매니페스트 `tags: [batch]` + `scenario-scale-axis-required` 로 커버한다.
- 면제: 매니페스트 헤더 `# audit:allow scenario-scale-axis-required reason: ...` / 소스 헤더 `// audit:allow job-generator-needs-scale-test <사유>`.
### 작성 절차
1. 영향 변수 카탈로그 식별
@@ -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
- 사이트맵 생성이 실패했을 때 표시되는 안내 문구 일본어 번역 추가 (`exceptions.seo.*`) — 사이트맵 파일을 저장하지 못하거나 압축에 실패한 경우의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- 사이트맵 재생성 예약·준비 중·생성 실패 안내 문구 일본어 번역 추가 (`seo.sitemap_*`) — 재생성을 예약했을 때, 아직 준비 중일 때, 생성에 실패했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- SEO 설정의 사이트맵 분할 기준·압축·수집기 상한과 SEO/사이트맵 캐시 유지 시간 항목의 검증 안내 문구 일본어 번역 추가 (`validation.settings.*`) — 잘못된 값을 입력했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- SEO 설정의 사이트맵 다국어 대체 링크(hreflang) 항목의 검증 안내 문구 일본어 번역 추가 (`validation.settings.sitemap_hreflang_enabled_boolean`) — 잘못된 값을 입력했을 때의 안내가 일본어 로케일에서 자연스럽게 표시됩니다.
- 관리자 사이트맵 수동 재생성 완료·실패 알림 템플릿 일본어 번역 추가 (`sitemap_regenerated`, `sitemap_regenerate_failed`) — 재생성을 실행한 관리자가 받는 앱 내 알림·메일이 일본어 로케일에서 자연스럽게 표시됩니다.
## [1.0.4] - 2026-07-16
### Added
@@ -4,12 +4,12 @@ return [
'fetch_success' => 'アクティビティログ情報を正常に取得しました。',
'fetch_failed' => 'アクティビティログ情報の取得に失敗しました。',
'delete_success' => 'アクティビティログが削除されました。',
'bulk_delete_success' => '選択したアクティビティログが削除されました。',
'bulk_delete_success' => '選択されたアクティビティログが削除されました。',
'delete_failed' => 'アクティビティログの削除に失敗しました。',
'validation' => [
'ids_required' => '削除するアクティビティログを選択してください。',
'ids_min' => '削除するアクティビティログを1つ以上選択してください。',
'id_not_found' => '選択したアクティビティログが見つかりません。',
'id_not_found' => '選択されたアクティビティログが見つかりません。',
],
'type' => [
'admin' => '管理者',
@@ -28,13 +28,13 @@ return [
'login_failed' => 'ログイン失敗',
'account_locked' => 'アカウントロック',
'request' => 'リクエスト',
'expired' => '期限切れ',
'expired' => '有効期限切れ',
'verify' => '本人認証',
'verify_failed' => '本人認証失敗',
'export' => 'エクスポート',
'import' => 'インポート',
'index' => 'リスト表示',
'show' => '詳細表示',
'index' => 'リスト閲覧',
'show' => '詳細閲覧',
'search' => '検索',
'install' => 'インストール',
'activate' => '有効化',
@@ -62,131 +62,133 @@ return [
'withdraw' => '退会',
'update_order' => '順序変更',
'refresh_layouts' => 'レイアウト更新',
'version_update' => 'バージョン更新',
'version_update' => 'バージョンアップデート',
'version_restore' => 'バージョン復元',
'reset' => '初期化',
],
'description' => [
'user_index' => 'ユーザー一覧閲覧',
'user_create' => 'ユーザー作成(ID::user_id)',
'user_show' => 'ユーザー詳細閲覧(ID::user_id)',
'user_update' => 'ユーザー編集(ID::user_id)',
'user_delete' => 'ユーザー削除(ID::user_id)',
'user_withdraw' => 'ユーザー退会(ID::user_id)',
'user_index' => 'ユーザーリスト閲覧',
'user_create' => 'ユーザー作成 (ID: :user_id)',
'user_show' => 'ユーザー詳細閲覧 (ID: :user_id)',
'user_update' => 'ユーザー編集 (ID: :user_id)',
'user_delete' => 'ユーザー削除 (ID: :user_id)',
'user_withdraw' => 'ユーザー退会 (ID: :user_id)',
'user_statistics' => 'ユーザー統計閲覧',
'user_recent' => '最近のユーザー閲覧',
'user_recent' => '最新ユーザー閲覧',
'user_search' => 'ユーザー検索',
'user_check_email' => 'メールアドレス重複確認',
'user_update_language' => 'ユーザー言語設定変更',
'user_bulk_update_status' => 'ユーザー一括ステータス変更(:count件)',
'role_index' => 'ロール一覧閲覧',
'role_active' => '有効ロール一覧閲覧',
'role_show' => 'ロール詳細閲覧(ID::role_id)',
'role_create' => 'ロール作成(ID::role_id)',
'role_update' => 'ロール編集(ID::role_id)',
'role_delete' => 'ロール削除(ID::role_id)',
'role_toggle_status' => 'ロールステータス切り替え(ID::role_id)',
'role_sync_permissions' => 'ロール権限同期(ID::role_id)',
'permission_index' => '権限一覧閲覧',
'menu_index' => 'メニュー一覧閲覧',
'menu_hierarchy' => 'メニュー階層閲覧',
'menu_active' => '有効メニュー一覧閲覧',
'menu_show' => 'メニュー詳細閲覧(ID::menu_id)',
'menu_create' => 'メニュー作成(ID::menu_id)',
'menu_update' => 'メニュー編集(ID::menu_id)',
'menu_delete' => 'メニュー削除(ID::menu_id)',
'menu_update_order' => 'メニュー順序変更',
'menu_toggle_status' => 'メニューステータス切り替え(ID::menu_id)',
'menu_sync_roles' => 'メニュー役割同期(ID::menu_id)',
'menu_get_by_extension' => '拡張別メニュー閲覧(:extension_type::extension_identifier)',
'settings_index' => '設定一覧閲覧',
'settings_save' => '設定保存',
'settings_show' => '設定閲覧',
'settings_update' => '設定編集',
'settings_system_info' => 'システム情報閲覧',
'settings_clear_cache' => 'キャッシュ初期化',
'user_bulk_update_status' => 'ユーザー一括ステータス変更 (:count件)',
'role_index' => 'ロールリスト閲覧',
'role_active' => '有効なロールリスト閲覧',
'role_show' => 'ロール詳細閲覧 (ID: :role_id)',
'role_create' => 'ロール作成 (ID: :role_id)',
'role_update' => 'ロール編集 (ID: :role_id)',
'role_delete' => 'ロール削除 (ID: :role_id)',
'role_toggle_status' => 'ロールステータス変更 (ID: :role_id)',
'role_sync_permissions' => 'ロール権限同期 (ID: :role_id)',
'permission_index' => '権限リスト閲覧',
'menu_index' => 'メニュー一覧の閲覧',
'menu_hierarchy' => 'メニュー階層の閲覧',
'menu_active' => '有効なメニュー一覧の閲覧',
'menu_show' => 'メニュー詳細の閲覧 (ID: :menu_id)',
'menu_create' => 'メニューの作成 (ID: :menu_id)',
'menu_update' => 'メニューの編集 (ID: :menu_id)',
'menu_delete' => 'メニューの削除 (ID: :menu_id)',
'menu_update_order' => 'メニューの順序変更',
'menu_toggle_status' => 'メニュー状態の切り替え (ID: :menu_id)',
'menu_sync_roles' => 'メニューロールの同期 (ID: :menu_id)',
'menu_get_by_extension' => '拡張別メニューの閲覧 (:extension_type: :extension_identifier)',
'settings_index' => '設定一覧の閲覧',
'settings_save' => '設定の保存',
'settings_show' => '設定の閲覧',
'settings_update' => '設定の編集',
'settings_system_info' => 'システム情報の閲覧',
'settings_clear_cache' => 'キャッシュの初期化',
'settings_optimize_system' => 'システム最適化',
'settings_backup_database' => 'データベースバックアップ',
'settings_get_app_key' => 'アプリキー閲覧',
'settings_app_key_regenerated' => 'アプリキー再生成',
'settings_backup' => 'バックアップ作成',
'settings_restore' => 'バックアップ復元',
'settings_backup_database' => 'データベースのバックアップ',
'settings_get_app_key' => 'アプリケーションキーの閲覧',
'settings_app_key_regenerated' => 'アプリケーションキーの再生成',
'settings_backup' => 'バックアップの作成',
'settings_restore' => 'バックアップの復元',
'settings_test_mail' => 'メール送信テスト',
'settings_test_driver_connection' => 'ドライバ接続テスト',
'settings_test_driver_connection' => 'ドライバー接続テスト',
'auth_login' => '管理者ログイン',
'auth_logout' => '管理者ログアウト',
'auth_register' => '会員登録',
'auth_forgot_password' => 'パスワード再設定申請',
'auth_reset_password' => 'パスワード再設定',
'auth_record_consents' => '利用規約同意履歴',
'schedule_index' => 'スケジュール一覧閲覧',
'schedule_show' => 'スケジュール詳細閲覧(ID::schedule_id)',
'schedule_create' => 'スケジュール作成(ID::schedule_id)',
'schedule_update' => 'スケジュール編集(ID::schedule_id)',
'schedule_delete' => 'スケジュール削除(ID::schedule_id)',
'schedule_run' => 'スケジュール手動実行(ID::schedule_id)',
'schedule_duplicate' => 'スケジュール複製',
'schedule_bulk_update_status' => 'スケジュール一括ステータス変更(:count件)',
'schedule_bulk_delete' => 'スケジュール一括削除(:count件)',
'schedule_statistics' => 'スケジュール統計閲覧',
'schedule_history' => 'スケジュール実行履歴閲覧(ID::schedule_id)',
'schedule_delete_history' => 'スケジュール履歴削除',
'dashboard_stats' => 'ダッシュボード統計閲覧',
'dashboard_resources' => 'ダッシュボードリソース閲覧',
'dashboard_activities' => 'ダッシュボードアクティビティ閲覧',
'dashboard_alerts' => 'ダッシュボード通知閲覧',
'auth_forgot_password' => 'パスワード忘却リクエスト',
'auth_reset_password' => 'パスワードリセット',
'auth_record_consents' => '利用規約同意記録',
'auth_login_failed' => 'ログイン失敗 (:email)',
'auth_account_locked' => 'アカウントロック (:attempts回失敗、:minutes分ブロック)',
'schedule_index' => 'スケジュール一覧の閲覧',
'schedule_show' => 'スケジュール詳細の閲覧 (ID: :schedule_id)',
'schedule_create' => 'スケジュールの作成 (ID: :schedule_id)',
'schedule_update' => 'スケジュールの編集 (ID: :schedule_id)',
'schedule_delete' => 'スケジュールの削除 (ID: :schedule_id)',
'schedule_run' => 'スケジュールの手動実行 (ID: :schedule_id)',
'schedule_duplicate' => 'スケジュールの複製',
'schedule_bulk_update_status' => 'スケジュール一括ステータス変更 (:count件)',
'schedule_bulk_delete' => 'スケジュール一括削除 (:count件)',
'schedule_statistics' => 'スケジュール統計の閲覧',
'schedule_history' => 'スケジュール実行履歴の閲覧 (ID: :schedule_id)',
'schedule_delete_history' => 'スケジュール履歴の削除',
'dashboard_stats' => 'ダッシュボード統計の閲覧',
'dashboard_resources' => 'ダッシュボードリソースの閲覧',
'dashboard_activities' => 'ダッシュボードアクティビティの閲覧',
'dashboard_alerts' => 'ダッシュボード通知の閲覧',
'attachment_upload' => 'ファイルアップロード',
'attachment_upload_batch' => 'ファイル一括アップロード',
'attachment_delete' => 'ファイル削除',
'attachment_delete' => 'ファイルの削除',
'attachment_bulk_delete' => 'ファイル一括削除',
'attachment_reorder' => 'ファイル順序変更',
'layout_index' => 'レイアウト一覧取得',
'layout_show' => 'レイアウト詳細取得 (:layout_path)',
'layout_update' => 'レイアウト編集 (:layout_path)',
'layout_versions_index' => 'レイアウトバージョン一覧取得 (:layout_path)',
'layout_version_show' => 'レイアウトバージョン詳細取得 (:layout_path)',
'layout_version_restore' => 'レイアウトバージョン復元 (:layout_path)',
'mail_template_update' => 'メールテンプレート編集 (:template_name)',
'mail_template_toggle_active' => 'メールテンプレート有効状態変更 (:template_name)',
'module_index' => 'モジュール一覧取得',
'module_installed' => 'インストール済みモジュール一覧取得',
'module_uninstalled' => '未インストールモジュール一覧取得',
'module_show' => 'モジュール詳細取得 (:module_name)',
'module_install' => 'モジュールインストール (:module_name)',
'module_activate' => 'モジュール有効化 (:module_name)',
'module_deactivate' => 'モジュール無効化 (:module_name)',
'module_uninstall' => 'モジュール削除 (:module_name)',
'module_uninstall_info' => 'モジュール削除情報取得 (モジュール: :module_name)',
'module_check_updates' => 'モジュールアップデート確認',
'module_update' => 'モジュールアップデート (:module_name)',
'module_refresh_layouts' => 'モジュールレイアウト更新 (:module_name)',
'module_dependent_templates' => 'モジュール依存テンプレート取得',
'attachment_reorder' => 'ファイルの順序変更',
'layout_index' => 'レイアウト一覧の閲覧',
'layout_show' => 'レイアウト詳細の閲覧 (:layout_path)',
'layout_update' => 'レイアウトの編集 (:layout_path)',
'layout_versions_index' => 'レイアウトバージョン一覧の閲覧 (:layout_path)',
'layout_version_show' => 'レイアウトバージョン詳細の閲覧 (:layout_path)',
'layout_version_restore' => 'レイアウトバージョンの復元 (:layout_path)',
'mail_template_update' => 'メールテンプレートの編集 (:template_name)',
'mail_template_toggle_active' => 'メールテンプレートの有効状態変更 (:template_name)',
'module_index' => 'モジュール一覧の閲覧',
'module_installed' => 'インストール済みモジュール一覧の閲覧',
'module_uninstalled' => '未インストールモジュール一覧の閲覧',
'module_show' => 'モジュール詳細の閲覧 (:module_name)',
'module_install' => 'モジュールのインストール (:module_name)',
'module_activate' => 'モジュールの有効化 (:module_name)',
'module_deactivate' => 'モジュールの無効化 (:module_name)',
'module_uninstall' => 'モジュールの削除 (:module_name)',
'module_uninstall_info' => 'モジュール削除情報の閲覧 (モジュール: :module_name)',
'module_check_updates' => 'モジュールアップデートの確認',
'module_update' => 'モジュールのアップデート (:module_name)',
'module_refresh_layouts' => 'モジュールレイアウトの更新 (:module_name)',
'module_dependent_templates' => 'モジュール依存テンプレートの閲覧',
'module_install_from_file' => 'ファイルからモジュールをインストール',
'module_install_from_github' => 'GitHubからモジュールをインストール',
'module_settings_save' => 'モジュール設定保存 (:module_name)',
'module_settings_reset' => 'モジュール設定初期化 (:module_name)',
'plugin_index' => 'プラグイン一覧取得',
'plugin_installed' => 'インストール済みプラグイン一覧取得',
'plugin_show' => 'プラグイン詳細取得 (:plugin_name)',
'module_settings_save' => 'モジュール設定の保存 (:module_name)',
'module_settings_reset' => 'モジュール設定の初期化 (:module_name)',
'plugin_index' => 'プラグイン一覧の閲覧',
'plugin_installed' => 'インストール済みプラグイン一覧の閲覧',
'plugin_show' => 'プラグイン詳細閲覧 (:plugin_name)',
'plugin_install' => 'プラグインインストール (:plugin_name)',
'plugin_activate' => 'プラグイン有効化 (:plugin_name)',
'plugin_deactivate' => 'プラグイン無効化 (:plugin_name)',
'plugin_uninstall' => 'プラグイン削除 (:plugin_name)',
'plugin_uninstall_info' => 'プラグイン削除情報取得 (プラグイン: :plugin_name)',
'plugin_uninstall_info' => 'プラグイン削除情報閲覧 (プラグイン: :plugin_name)',
'plugin_check_updates' => 'プラグインアップデート確認',
'plugin_update' => 'プラグインアップデート (:plugin_name)',
'plugin_refresh_layouts' => 'プラグインレイアウト更新 (:plugin_name)',
'plugin_dependent_templates' => 'プラグイン依存テンプレート取得',
'plugin_dependent_templates' => 'プラグイン依存テンプレート閲覧',
'plugin_settings_save' => 'プラグイン設定保存 (:plugin_name)',
'plugin_settings_reset' => 'プラグイン設定初期化 (:plugin_name)',
'template_index' => 'テンプレート一覧取得',
'template_show' => 'テンプレート詳細取得 (:template_name)',
'template_index' => 'テンプレート一覧の閲覧',
'template_show' => 'テンプレート詳細閲覧 (:template_name)',
'template_install' => 'テンプレートインストール (:template_name)',
'template_activate' => 'テンプレート有効化 (:template_name)',
'template_deactivate' => 'テンプレート無効化 (:template_name)',
'template_uninstall' => 'テンプレート削除 (:template_name)',
'template_check_updates' => 'テンプレートアップデート確認',
'template_check_modified_layouts' => 'テンプレート編集済みレイアウト確認 (:template_name)',
'template_check_modified_layouts' => 'テンプレート修正されたレイアウト確認 (:template_name)',
'template_update' => 'テンプレートアップデート (:template_name)',
'template_version_update' => 'テンプレートバージョンアップデート (:template_name)',
'template_install_from_file' => 'ファイルからテンプレートをインストール',
@@ -194,11 +196,9 @@ return [
'template_refresh_layouts' => 'テンプレートレイアウト更新 (:template_name)',
'core_update_check' => 'コアアップデート確認',
'core_update_update' => 'コアアップデート実行',
'activity_log_index' => 'アクティビティログ一覧取得',
'activity_log_index' => 'アクティビティログ一覧の閲覧',
'activity_log_delete' => 'アクティビティログ削除 (ID: :log_id)',
'activity_log_bulk_delete' => 'アクティビティログ一括削除 (:count件)',
'auth_login_failed' => 'ログイン失敗 (:email)',
'auth_account_locked' => 'アカウントロック (:attempts回の失敗、:minutes分間ブロック)',
],
'fields' => [
'name' => '名前',
@@ -208,7 +208,7 @@ return [
'timezone' => 'タイムゾーン',
'country' => '国',
'status' => 'ステータス',
'is_super' => 'スーパー管理者',
'is_super' => '最高管理者',
'homepage' => 'ホームページ',
'mobile' => '携帯電話',
'phone' => '電話番号',
@@ -218,10 +218,10 @@ return [
'bio' => '紹介',
'admin_memo' => '管理者メモ',
'identifier' => '識別子',
'is_active' => '有効化',
'is_active' => '有効性',
'url' => 'URL',
'icon' => 'アイコン',
'parent_id' => '親項目',
'parent_id' => '上位項目',
'order' => '順序',
'command' => 'コマンド',
'expression' => '実行周期',
@@ -19,60 +19,60 @@ return [
'success' => 'admin_layout.json 検証成功',
'failed' => 'admin_layout.json 検証失敗',
'exception' => 'admin_layout.json 検証中に例外が発生しました',
'version_error' => 'スキーマバージョン情報の取得に失敗しました',
'version_error' => 'スキーマバージョン情報取得失敗',
],
],
'loader' => [
'validation_failed' => 'モジュール :module のレイアウト検証に失敗しました: :errors',
'file_not_found' => 'レイアウトファイルが見つかりません: :path',
'json_parse_error' => 'JSON パースエラー (:file): :error',
'file_load_error' => 'ファイル読み込み中にエラーが発生しました (:path): :error',
'file_load_error' => 'ファイルロード中にエラーが発生しました (:path): :error',
'log' => [
'cache_hit' => 'キャッシュからレイアウトデータを読み込みました',
'cache_hit' => 'キャッシュからレイアウトデータをロード',
'cache_invalidated' => 'キャッシュが無効化されました',
'loaded' => 'レイアウトファイル読み込み完了',
'cached' => 'レイアウトデータをキャッシュに保存しました',
'cache_manually_invalidated' => 'キャッシュを手動で無効化しました',
'all_cache_invalidated' => 'すべてのキャッシュを無効化しました',
'file_load_error' => 'ファイル読み込みエラー',
'loaded' => 'レイアウトファイルのロード完了',
'cached' => 'レイアウトデータをキャッシュに保存',
'cache_manually_invalidated' => 'キャッシュ手動無効化',
'all_cache_invalidated' => 'すべてのキャッシュを無効化',
'file_load_error' => 'ファイルロードエラー',
],
],
'command' => [
'starting' => 'admin_layout.json ファイルの検証を開始します',
'starting' => 'admin_layout.json ファイル検証を開始します',
'validating_module' => 'モジュール :module を検証中...',
'validating_all_modules' => ':count 個のモジュール検証を開始します',
'module_layout_not_found' => 'モジュール :module の admin_layout.json ファイルが見つかりません',
'module_valid' => 'モジュール :module の検証成功',
'module_invalid' => 'モジュール :module の検証失敗: :error',
'module_valid' => 'モジュール :module 検証成功',
'module_invalid' => 'モジュール :module 検証失敗: :error',
'unexpected_error' => 'モジュール :module で予期しないエラーが発生しました: :error',
'modules_directory_not_found' => 'modules ディレクトリが見つかりません',
'no_modules_found' => '検証するモジュールがありません',
'module_summary' => 'モジュール :module の概要',
'module_summary' => 'モジュール :module サマリー',
'menu_name' => 'メニュー名',
'menu_route' => 'ルート',
'data_sources_count' => 'データソース数',
'layout_type' => 'レイアウトタイプ',
'validation_summary' => '検証結果の概要',
'validation_summary' => '検証結果サマリー',
'valid_modules_count' => '有効なモジュール',
'invalid_modules_count' => '無効なモジュール',
'missing_layouts_count' => 'レイアウトファイルなし',
'invalid_modules_details' => '無効なモジュールの詳細:',
'invalid_modules_details' => '無効なモジュール詳細:',
'missing_layouts_details' => 'レイアウトファイルがないモジュール:',
'cache_stats' => 'キャッシュ統計',
'total_cached_modules' => 'キャッシュされたモジュール数',
'cache_expiration' => 'キャッシュ有効期限',
'cached_modules_details' => 'キャッシュされたモジュールの詳細:',
'cached_modules_details' => 'キャッシュされたモジュール詳細:',
'clearing_cache' => 'キャッシュを無効化中...',
'cache_cleared' => 'キャッシュが無効化されました',
],
'success' => [
'loaded' => 'admin_layout.json の読み込みに成功しました',
'loaded' => 'admin_layout.json を正常に読み込みました',
'cached' => 'レイアウトデータがキャッシュに保存されました',
],
'error' => [
'module_not_found' => '要求されたモジュールが見つかりません',
'json_parse_error' => 'JSON パース中にエラーが発生しました',
'file_not_readable' => 'ファイルを読めません',
'file_not_readable' => 'ファイルを読み込めません',
'json_parse_failed' => 'JSON パースに失敗しました',
'validation_failed' => 'レイアウト検証に失敗しました',
'server_error' => 'サーバー内部エラーが発生しました',
@@ -80,14 +80,14 @@ return [
'log' => [
'cache_hit' => 'キャッシュからレイアウトデータを取得しました',
'cache_updated' => 'キャッシュが更新されました',
'layout_loaded' => 'レイアウトが正常に読み込まれました',
'layout_loaded' => 'レイアウトが正常にロードされました',
'validation_completed' => 'レイアウト検証が完了しました',
'layout_load_error' => 'レイアウト読み込み中にエラーが発生しました',
'layout_load_error' => 'レイアウトロード中にエラーが発生しました',
'validation_error' => 'レイアウト検証中にエラーが発生しました',
'service_cache_invalidated' => 'サービスでキャッシュを無効化しました',
'service_all_cache_invalidated' => 'サービスですべてのキャッシュを無効化しました',
'service_cache_invalidated' => 'サービスからキャッシュを無効化しました',
'service_all_cache_invalidated' => 'サービスからすべてのキャッシュを無効化しました',
'cache_invalidated' => 'キャッシュが無効化されました',
'all_cache_invalidated' => 'すべてのキャッシュが無効化されました',
'file_read_error' => 'ファイル読み取り中にエラーが発生しました',
'file_read_error' => 'ファイル読み込み中にエラーが発生しました',
],
];
@@ -13,18 +13,18 @@ return [
],
'default_policy' => [
'public' => '公開 (すべての認証ユーザー)',
'owner_only' => 'アップローダー/管理者のみ',
'owner_only' => 'アップロード者/管理者のみ',
'read_only' => '読み取り専用',
],
'validation' => [
'file_required' => 'ファイルを選択してください。',
'file_invalid' => '有効なファイルではありません。',
'file_max' => 'ファイルサイズは :maxMB を超えることはできません。',
'file_max' => 'ファイルサイズは :maxMB を超えることができません。',
'files_required' => 'ファイルを選択してください。',
'files_array' => 'ファイル形式が正しくありません。',
'files_min' => '最低1つ以上のファイルを選択してください。',
'type_invalid' => '添付対象タイプが正しくありません。',
'id_invalid' => '添付対象IDが正しくありません。',
'type_invalid' => '添付対象のタイプが正しくありません。',
'id_invalid' => '添付対象のIDが正しくありません。',
'role_ids_required' => 'ロールを選択してください。',
'role_ids_invalid' => 'ロール形式が正しくありません。',
'role_id_integer' => 'ロールIDは整数である必要があります。',
@@ -41,17 +41,17 @@ return [
],
'upload_success' => 'ファイルがアップロードされました。',
'upload_batch_success' => 'ファイルが一括アップロードされました。',
'upload_failed' => 'ファイルアップロードに失敗しました。',
'upload_failed' => 'ファイルのアップロードに失敗しました。',
'delete_success' => 'ファイルが削除されました。',
'delete_failed' => 'ファイル削除に失敗しました。',
'reorder_success' => 'ファイル順序が変更されました。',
'reorder_failed' => 'ファイル順序の変更に失敗しました。',
'delete_failed' => 'ファイルの削除に失敗しました。',
'reorder_success' => 'ファイルの順序が変更されました。',
'reorder_failed' => 'ファイルの順序変更に失敗しました。',
'sync_roles_success' => 'ロールが同期されました。',
'sync_roles_failed' => 'ロール同期に失敗しました。',
'sync_permissions_success' => '権限が同期されました。',
'sync_permissions_failed' => '権限同期に失敗しました。',
'not_found' => '添付ファイルが見つかりません。',
'access_denied' => 'この添付ファイルへのアクセス権限がありません。',
'update_denied' => 'この添付ファイルの編集権限がありません。',
'delete_denied' => 'この添付ファイルの削除権限がありません。',
'update_denied' => 'この添付ファイルへの編集権限がありません。',
'delete_denied' => 'この添付ファイルへの削除権限がありません。',
];
@@ -1,16 +1,16 @@
<?php
return [
'login_success' => 'ログインに成功しました。',
'login_success' => 'ログインが成功しました。',
'login_failed' => '認証に失敗しました。',
'register_success' => '会員登録に成功しました。',
'register_success' => '会員登録が成功しました。',
'register_failed' => '会員登録に失敗しました。',
'logout_success' => 'ログアウトに成功しました。',
'logout_success' => 'ログアウトが成功しました。',
'logout_all_devices_success' => 'すべてのデバイスからログアウトされました。',
'password_reset_success' => 'パスワードが正常にリセットされました。',
'password_reset_failed' => 'パスワードのリセットに失敗しました。',
'invalid_credentials' => '無効な認証情報です。',
'admin_login_success' => '管理者ログインに成功しました。',
'admin_login_success' => '管理者ログインが成功しました。',
'admin_required' => '管理者権限が必要です。',
'unauthenticated' => '認証が必要です。',
'permission_denied' => '該当する権限がありません。',
@@ -23,17 +23,17 @@ return [
'scope_denied' => 'このリソースへのアクセス権限がありません。',
'scope_type_all' => 'すべて',
'scope_type_self' => '本人のみ',
'scope_type_role' => '所有者ロール',
'scope_type_role' => '所有ロール',
'invalid_token' => '無効な認証トークンです。',
'token_expired' => '認証トークンが期限切れです。もう一度ログインしてください。',
'token_expired' => '認証トークンが有効期限切れです。再度ログインしてください。',
'user_not_found' => 'ユーザーが見つかりません。',
'account_inactive' => '無効化されたアカウントです。管理者にお問い合わせください。',
'account_blocked' => 'ブロックされたアカウントです。管理者にお問い合わせください。',
'account_withdrawn' => '退会済みのアカウントです。',
'account_pending_verification' => '本人確認が完了していないアカウントです。メール認証を完了してください。',
'reset_token_invalid' => '無効なパスワードリセットトークンです。',
'reset_token_expired' => 'パスワードリセットトークンが期限切れです。再度リクエストしてください。',
'password_reset_email_sent' => 'パスワードリセットメールが送信されました。',
'email_send_failed' => 'メール送信に失敗しました。しばらく後にもう一度お試しください。',
'account_withdrawn' => '退会したアカウントです。',
'account_pending_verification' => '本人認証が完了していないアカウントです。メール認証を完了してください。',
'account_locked' => 'ログイン試行回数の超過によりアカウントがロックされました。:minutes分後に再度お試しください。',
'reset_token_invalid' => '無効なパスワードリセットトークンです。',
'reset_token_expired' => 'パスワードリセットトークンが有効期限切れです。再度リクエストしてください。',
'password_reset_email_sent' => 'パスワードリセットメールが送信されました。',
'email_send_failed' => 'メール送信に失敗しました。しばらく後に再度お試しください。',
];
@@ -17,17 +17,17 @@ return [
'unknown' => '不明',
'errors' => [
'github_url_empty' => 'GitHub URLが空です。',
'github_url_invalid' => '無効なGitHub URL形式です。',
'github_url_invalid' => '無効なGitHub URLフォーマットです。',
'github_api_failed' => 'GitHub APIの呼び出しに失敗しました。',
'github_download_failed' => 'GitHubからのダウンロードに失敗しました。',
'github_archive_download_failed' => 'アーカイブのダウンロードに失敗しました。(:url)',
'zip_file_not_found' => 'ZIPファイルが見つかりません。',
'zip_open_failed' => 'ZIPファイルを開くことができません。',
],
'validation_error' => '入力値バリデーションエラー',
'validation_error' => '入力値のバリデーションエラー',
'changelog_validation' => [
'source_in' => 'ソースはactive、bundled、githubのいずれかである必要があります。',
'version_format' => ':attribute形式が正しくありません。(例: 1.0.0、1.0.0-beta.1)',
'version_format' => ':attributeのフォーマットが正しくありません。(例:1.0.0、1.0.0-beta.1)',
'to_version_required' => '開始バージョンが指定されている場合、終了バージョンも必須です。',
],
];
@@ -1,14 +1,16 @@
<?php
return [
'stats_loaded' => 'ダッシュボード統計を正常に閲覧しました。',
'stats_failed' => 'ダッシュボード統計の閲覧に失敗しました。',
'resources_loaded' => 'システムリソース情報を正常に閲覧しました。',
'resources_failed' => 'システムリソース情報の閲覧に失敗しました。',
'activities_loaded' => '最近のアクティビティログを正常に閲覧しました。',
'activities_failed' => '最近のアクティビティログの閲覧に失敗しました。',
'alerts_loaded' => 'システム通知を正常に閲覧しました。',
'alerts_failed' => 'システム通知の閲覧に失敗しました。',
'stats_loaded' => 'ダッシュボード統計の照会に成功しました。',
'stats_failed' => 'ダッシュボード統計の照会に失敗しました。',
'resources_loaded' => 'システムリソース情報の照会に成功しました。',
'resources_failed' => 'システムリソース情報の照会に失敗しました。',
'activities_loaded' => '最近のアクティビティの照会に成功しました。',
'activities_failed' => '最近のアクティビティの照会に失敗しました。',
'alerts_loaded' => 'システム通知の照会に成功しました。',
'alerts_failed' => 'システム通知の照会に失敗しました。',
'recent_notifications_loaded' => '最近の通知の照会に成功しました。',
'recent_notifications_failed' => '最近の通知の照会に失敗しました。',
'stats' => [
'status_normal' => '正常',
'status_warning' => '警告',
@@ -16,6 +18,4 @@ return [
'activities' => [
'user_registered' => ':name さんが会員登録しました。',
],
'recent_notifications_loaded' => '最近の通知を正常に閲覧しました。',
'recent_notifications_failed' => '最近の通知の閲覧に失敗しました。',
];
@@ -11,16 +11,16 @@ return [
],
'404' => [
'title' => 'ページが見つかりません',
'message' => 'リクエストされたページが存在しないか、移動されています。',
'message' => '要求されたページが存在しないか、移動した可能性があります。',
],
'500' => [
'title' => 'サーバーエラーが発生しました',
'message' => 'リクエストを処理する際に問題が発生しました。しばらく後に再度試してください。',
'message' => 'リクエストを処理する際に問題が発生しました。しばらく後に再度お試しください。',
],
'503' => [
'title' => 'サービス一時中断',
'message' => '現在、このサービスは利用できません。しばらく後に再度試してください。',
'unmet_dependencies' => '未解決の依存関係リスト',
'title' => 'サービス一時停止',
'message' => '現在サービスをご利用いただけません。しばらく後に再度お試しください。',
'unmet_dependencies' => '未充足の依存性リスト',
'template' => 'テンプレート',
'modules' => 'モジュール',
'plugins' => 'プラグイン',
@@ -29,7 +29,7 @@ return [
'back_home' => 'ホームに戻る',
'bootstrap' => [
'title' => '画面を読み込めませんでした',
'message' => 'ネットワーク接続が不安定な可能性があります。更新後にもう一度お試しください。',
'message' => 'ネットワーク接続が不安定な可能性があります。ページを更新してからもう一度お試しください。',
'reload' => '更新',
],
];
@@ -3,22 +3,23 @@
return [
'cannot_delete_super_admin' => 'スーパー管理者は削除できません。',
'circular_reference' => 'レイアウト循環参照を検出しました: :trace',
'max_depth_exceeded' => 'レイアウトのネスト深度が最大許容深度(:max)を超えています。',
'max_depth_exceeded' => 'レイアウトネストの深さが最大許容深度(:max)を超過しました。',
'template_file_copy_failed' => 'テンプレートファイルのコピーに失敗しました: :source → :destination',
'template_build_directory_creation_failed' => 'テンプレートビルドディレクトリの作成に失敗しました: :path',
'template_build_directory_creation_failed' => 'テンプレートビルドディレクトリの生成に失敗しました: :path',
'template_dist_directory_not_found' => 'テンプレートdistディレクトリが見つかりません: :path',
'template_not_found' => 'テンプレートが見つかりません: :identifier',
'template_not_active' => 'テンプレートが有効化されていません: :identifier (ステータス: :status)',
'concurrent_modification' => '別のユーザーが先に保存しました。 (リソース: :resource、現在のバージョン: :current、送信されたバージョン: :expected)',
'layout' => [
'duplicate_data_source_id' => 'data_sources IDが重複しています: :id',
'duplicate_data_source_id_in_file' => 'レイアウトファイル内のdata_sources IDが重複しています: :ids (ファイル: :file)',
'duplicate_data_source_id_extends' => 'extends継承関係でdata_sources IDが重複しています: :ids (子: :child, 親: :parent)',
'duplicate_data_source_id_extends' => 'extends継承関係のdata_sources IDが重複しています: :ids (子: :child、親: :parent)',
'not_found' => 'レイアウトが見つかりません: :name',
'parent_not_found' => '親レイアウトが見つかりません: :parent (リクエストレイアウト: :child)',
'parent_not_found' => '親レイアウトが見つかりません: :parent (要求レイアウト: :child)',
'include_file_not_found' => 'includeファイルが見つかりません: :path (解決されたパス: :resolved)',
'invalid_include_json' => 'includeファイルのJSON形式が正しくありません: :path (エラー: :error)',
'circular_include' => 'include循環参照が検出されました: :trace',
'max_include_depth_exceeded' => 'includeの最大深度を超えています (最大: :max段階)',
'max_include_depth_exceeded' => 'include最大深度を超過しました (最大: :max段階)',
'include_outside_directory' => 'includeパスが許可されたディレクトリ外です: :path (許可: :allowed_dir)',
],
'layout_version' => [
@@ -26,41 +27,46 @@ return [
'save_failed_unexpected' => 'レイアウトバージョン保存中に予期しないエラーが発生しました。',
],
'settings' => [
'backup_creation_failed' => '設定バックアップファイルの作成に失敗しました。',
'backup_creation_failed' => '設定バックアップファイルの生成に失敗しました。',
'restore_failed' => '設定の復元に失敗しました。',
'category_not_found' => '設定カテゴリが見つかりません: :category',
'save_failed' => '設定の保存に失敗しました: :category',
'json_parse_failed' => 'JSON ファイルのパースに失敗しました: :path (:error)',
'add_category_module_only' => 'addCategory はモジュールでのみ使用できます。',
'json_parse_failed' => 'JSONファイルのパースに失敗しました: :path (:error)',
'add_category_module_only' => 'addCategoryはモジュールからのみ使用できます。',
],
'core_update' => [
'handoff' => 'アップグレードハンドオフ: :after_version まで完了 — :reason (再実行: :resume_command)',
],
'vendor' => [
'composer_not_available' => 'Composerを実行できない環境です。バンドルモードを使用してください。',
'composer_not_available_for_build' => 'vendor-bundleビルドはComposer実行が必要です。開発環境でComposerをインストール·設定した後、もう一度お試しください。',
'composer_not_available_for_build' => 'vendor-bundleビルドはComposer実行が必要です。開発環境でComposerをインストール/設定した後、再度実行してください。',
'bundle_build_composer_failed' => 'vendor-bundleビルド中にcomposer installの実行に失敗しました (exit :exit): :message',
'bundle_build_promote_failed' => 'vendor-bundleビルド結果を最終パスに反映できませんでした (:from → :to)',
'composer_execution_failed' => 'Composerの実行に失敗しました: :message',
'bundle_zip_missing' => 'vendor-bundle.zipファイルが見つかりません: :path',
'bundle_manifest_missing' => 'vendor-bundle.jsonファイルが見つかりません: :path',
'bundle_manifest_invalid' => 'vendor-bundle.jsonファイルの読み込みに失敗しました: :error',
'bundle_integrity_failed' => 'バンドルファイルの整合性検証に失敗しました: :details',
'bundle_manifest_invalid' => 'vendor-bundle.jsonファイルを読み込めません: :error',
'bundle_integrity_failed' => 'バンドルファイルの完全性検証に失敗しました: :details',
'bundle_schema_unsupported' => 'サポートされていないvendor-bundle.jsonスキーマバージョンです: :version',
'zip_archive_not_available' => 'PHP ZipArchive拡張がインストールされていません。',
'zip_hash_mismatch' => 'vendor-bundle.zipのハッシュが一致しません (期待値: :expected, 実際: :actual)。',
'zip_hash_mismatch' => 'vendor-bundle.zipのハッシュが一致しません (期待値: :expected、実際: :actual)。',
'composer_json_sha_mismatch' => 'composer.jsonがバンドルビルド時点以降に変更されました。vendor-bundle:buildで再ビルドしてください。',
'composer_lock_sha_mismatch' => 'composer.lockがバンドルビルド時点以降に変更されました。vendor-bundle:buildで再ビルドしてください。',
'bundle_contains_unsafe_path' => 'バンドルzipに安全でないファイルパスが含まれています: :path',
'extraction_failed' => 'バンドルzipの抽出に失敗しました: :message',
'no_vendor_strategy_available' => 'バンドルのインストール戦略を決定できません。Composerとバンドルzipの両方が利用不可です。',
'no_vendor_strategy_available' => 'Vendorインストール戦略を決定できません。Composerとバンドルzipのどちらも使用できません。',
'source_dir_not_found' => 'インストールソースディレクトリが見つかりません: :path',
'target_not_writable' => '対象ディレクトリに書き込み権限がありません: :path',
'target_not_writable_owner_hint' => '実行ユーザー(:current_user)とディレクトリ所有者(:owner)が異なります。\'chown -R :current_user_name :path\'を実行後に再試行するか、所有者(:owner_name)でSSHログイン後に実行してください。',
'target_not_writable' => '対象ディレクトリへの書き込み権限がありません: :path',
'target_not_writable_owner_hint' => '実行ユーザー(:current_user)とディレクトリ所有者(:owner)が異なります。\'chown -R :current_user_name :path\'を実行後に再度試すか、所有者(:owner_name)でSSHログイン後に実行してください。',
'composer_json_not_found' => 'composer.jsonファイルが見つかりません: :path',
'no_composer_lock' => 'composer.lockファイルが必要です。まずcomposer installを実行してください: :path',
'vendor_dir_not_found' => 'vendor/ディレクトリが存在しません。まずcomposer installを実行してください: :path',
'no_composer_lock' => 'composer.lockファイルが必要です。最初にcomposer installを実行してください: :path',
'vendor_dir_not_found' => 'vendor/ディレクトリが存在しません。最初にcomposer installを実行してください: :path',
'composer_binary_not_found' => 'Composerバイナリが見つかりません。COMPOSER_BINARY環境変数を設定するか、composerをPATHに追加してください。',
'bundle_build_promote_failed' => 'vendor-bundle ビルド結果を最終パスに反映できませんでした (:from → :to)',
],
'concurrent_modification' => '別のユーザーが先に保存しました。(リソース: :resource、現在のバージョン: :current、送信されたバージョン: :expected)',
'seo' => [
'sitemap_write_failed' => 'Sitemapファイルの記録に失敗しました: :path',
'sitemap_gzip_failed' => 'Sitemap子ファイルの圧縮に失敗しました: :file',
'sitemap_temp_file_unreadable' => 'Sitemap一時ファイルを読み込めません: :file',
'sitemap_stat_group_column_unsupported' => 'サポートされていないグループ基準列です: :column',
],
];
@@ -7,51 +7,51 @@ return [
'template' => 'テンプレート',
],
'errors' => [
'core_version_mismatch' => ':extension (:type)はグヌボード7 コアバージョン :required 以上が必要です。(現在: :installed)',
'core_version_mismatch' => ':extension (:type)はグヌボード7コアバージョン :required 以上が必要です。(現在: :installed)',
'version_check_failed' => 'バージョン検証に失敗しました。',
'operation_in_progress' => '":name"に対して実行中の処理(:status)があるため、リクエストを処理できません。',
'operation_in_progress' => '":name"に対して進行中の処理(:status)があるため、リクエストを処理できません。',
'zip_missing_manifest' => 'ZIP内から :file マニフェストが見つかりません: :zip',
'zip_invalid_manifest' => 'ZIP内の :file マニフェストをJSONとして解析できません。',
'zip_identifier_mismatch' => 'ZIPマニフェストの識別子がターゲット拡張と一致しません。(期待値: :expected、実際: :actual)',
'zip_missing_version' => 'ZIP内の :file マニフェストに version フィールドがありません。',
'zip_identifier_mismatch' => 'ZIPマニフェストの識別子が対象拡張と一致しません。(期待値: :expected、実際: :actual)',
'zip_missing_version' => 'ZIP内の :file マニフェストにversionフィールドがありません。',
'not_found' => '拡張(:identifier)が見つかりません。',
'cascade_dependency_failed' => '付属インストール対象の :type (:identifier)のインストールに失敗しました: :message',
'cascade_dependency_failed' => '同梱インストール対象の :type (:identifier)のインストールに失敗しました: :message',
'invalid_type' => '無効な拡張タイプです。',
'not_auto_deactivated' => 'この拡張はコアバージョンの互換性の問題により自動無効化された状態ではありません。',
'hidden_extension' => '内部用(hidden)拡張はユーザーに公開されません。',
'not_auto_deactivated' => 'この拡張はコアバージョン互換性の問題により自動無効化された状態ではありません。',
'hidden_extension' => '内部用(非表示)拡張はユーザーに非公開です。',
],
'warnings' => [
'auto_deactivated' => ':type ":identifier"はコアバージョン互換性の問題により自動的に無効化されました。',
'auto_deactivated' => ':type ":identifier"がコアバージョン互換性の問題により自動無効化されました。',
],
'alerts' => [
'incompatible_deactivated' => ':type ":name"自動無効化',
'incompatible_message' => '必須バージョン: :required、現在インストール済み: :installed',
'recovered_title' => ':type「:name」再び互換性があります',
'incompatible_message' => '必要バージョン: :required、現在インストール済み: :installed',
'recovered_title' => ':type ":name"互換性を再取得',
'recovered_body' => 'コアアップグレード後に互換性があります(以前の要件: :previously_required)。再度有効化できます。',
'recovered_success' => '拡張が再び有効化されました。',
'recovered_success' => '拡張が再度有効化されました。',
'dismissed' => '通知を閉じました。',
'auto_deactivated_listed' => '自動無効化された拡張のリストです。',
'auto_deactivated_listed' => '自動無効化された拡張の一覧です。',
'recover_action' => '再度有効化',
'dismiss_action' => '通知を閉じる',
],
'commands' => [
'clear_cache_success' => '拡張バージョン検証キャッシュが削除されました。',
],
'badges' => [
'incompatible' => 'コアアップグレードが必要です',
'incompatible_tooltip' => 'コア:required以上が必要です(現在: :installed)',
'incompatible_sr' => ':nameはコア:required以上が必要ですが、現在:installedがインストールされているため更新できません。',
'incompatible' => 'コアアップグレード必要',
'incompatible_tooltip' => 'コア :required 以上が必要です(現在: :installed)',
'incompatible_sr' => ':name はコア :required 以上が必要ですが、現在 :installed がインストールされているため、アップデートできません。',
],
'banner' => [
'title' => 'コア互換性の問題により自動無効化された拡張があります',
'item_required' => '必須バージョン: :required',
'item_required' => '必要バージョン: :required',
'guide_link' => 'コアアップグレードガイド',
'dismiss' => '閉じる',
],
'update_modal' => [
'compat_warning_title' => 'コアバージョン互換性警告',
'compat_warning_message' => 'この:typeはコア:required以上が必要です。(現在: :installed)',
'compat_warning_message' => 'この :type はコア :required 以上が必要です。(現在: :installed)',
'compat_guide_link' => 'コアアップグレードガイドを表示',
'force_label' => '警告を無視して強制更新します(推奨されません)',
'force_label' => '警告を無視して強制アップデート(非推奨)',
],
'commands' => [
'clear_cache_success' => '拡張バージョン検証キャッシュが削除されました。',
],
];
@@ -1,7 +1,7 @@
<?php
return [
'cache_generated' => '静的フック マッピング キャッシュを作成しました。(コア :core / モジュール :modules / プラグイン :plugins) → :path',
'cache_generated' => '静的フック マッピング キャッシュを生成しました。(コア :core / モジュール :modules / プラグイン :plugins) → :path',
'cache_cleared' => '静的フック マッピング キャッシュを削除しました。',
'cache_absent' => '削除する静的フック マッピング キャッシュがありません。',
];
@@ -6,9 +6,9 @@ return [
'label' => 'メール',
'settings' => [
'code_length' => '認証コード長',
'code_length_help' => '送信される数字コードのけた数(デフォルト6、最小4、最大10)。',
'from_address' => '送信者アドレス',
'from_address_help' => '空の場合、システムデフォルトの送信者が使用されます。',
'code_length_help' => '送信される数字コードの桁数 (デフォルト 6、最小 4、最大 10)。',
'from_address' => '差出人アドレス',
'from_address_help' => '空の場合、システムのデフォルト差出人を使用します。',
],
],
],
@@ -18,48 +18,48 @@ return [
'wrong_provider' => 'この認証リクエストは別のプロバイダーで処理する必要があります。',
'invalid_state' => '既に処理された認証リクエストです。',
'expired' => '認証時間が期限切れになりました。もう一度お試しください。',
'max_attempts' => '試行回数を超えました。再度リクエストしてください。',
'max_attempts' => '試行回数を超過しました。もう一度リクエストしてください。',
'invalid_code' => '認証コードが正しくありません。',
'invalid_verification_token' => '無効な本人確認トークンです。',
'missing_target' => '認証対象(メール·電話番号)が必要です。',
'target_mismatch' => '認証した対象と要求した対象が一致しません。',
'invalid_verification_token' => '無効な本人認証トークンです。',
'missing_target' => '認証対象 (メール·電話番号) が必要です。',
'target_mismatch' => '認証された対象とリクエストされた対象が一致しません。',
'purpose_not_supported' => '選択されたプロバイダーはこの目的をサポートしていません。',
'provider_unavailable' => '本人確認プロバイダーを使用できません。',
'generic' => '本人確認に失敗しました。',
'missing_scope_or_target' => 'ポリシー照会にはscopeとtargetの両方が必要です。',
'provider_unavailable' => '本人認証プロバイダーを使用できません。',
'generic' => '本人認証に失敗しました。',
'missing_scope_or_target' => 'ポリシー照会には scope と target の両方が必要です。',
'admin_policy_has_no_default' => '管理者が直接作成したポリシーには宣言デフォルト値がありません。',
'reset_field_failed' => '宣言デフォルト値の復元に失敗しました。フィールドが有効であることを確認してください。',
],
'messages' => [
'challenge_requested' => '本人確認コードを送信しました。',
'challenge_requested' => '本人認証コードを送信しました。',
'challenge_verified' => '本人確認が完了しました。',
'challenge_cancelled' => '本人確認リクエストがキャンセルされました。',
'challenge_cancelled' => '本人認証リクエストがキャンセルされました。',
],
'logs' => [
'activity' => [
'requested' => ':emailに本人確認コードを送信しました。',
'requested' => ':email に本人認証コードを送信しました。',
'verified' => '本人確認が完了しました。',
'failed' => '本人確認に失敗しました。',
'expired' => '本人確認時間が期限切れになりました。',
'cancelled' => '本人確認リクエストをキャンセルしました。',
'failed' => '本人認証に失敗しました。',
'expired' => '本人認証時間が期限切れになりました。',
'cancelled' => '本人認証リクエストをキャンセルしました。',
],
],
'purposes' => [
'signup' => [
'label' => '会員登録認証',
'description' => '新規登録者のメール/電話番号所有確認。',
'description' => '新規ユーザーのメール/電話番号所有確認。',
],
'password_reset' => [
'label' => 'パスワード再設定',
'description' => 'パスワードを忘れたユーザーが本人確認後に再設定します。',
'description' => 'パスワードを忘れたユーザーが本人確認後に再設定。',
],
'self_update' => [
'label' => '自己情報変更',
'description' => 'ログインユーザーがメール/電話などの本人情報を変更する場合。',
'label' => '自分の情報を変更',
'description' => 'ログインユーザーがメール/電話など本人情報を変更する場合。',
],
'sensitive_action' => [
'label' => '機密作業',
'description' => 'アカウント削除·管理者作業など再認証が必要な時点。',
'label' => '機密操作',
'description' => 'アカウント削除·管理者操作など再認証が必要な時点。',
],
],
'channels' => [
@@ -70,7 +70,7 @@ return [
'hook' => 'フック',
'policy' => 'ポリシー',
'middleware' => 'ミドルウェア',
'api' => 'API直接呼び出し',
'api' => 'API 直接呼び出し',
'custom' => 'カスタム',
'system' => 'システム',
],
@@ -81,7 +81,7 @@ return [
'custom' => 'カスタム',
],
'fail_mode' => [
'block' => 'ブロック(HTTP 428)',
'block' => 'ブロック (HTTP 428)',
'log_only' => 'ログのみ記録',
],
'applies_to' => [
@@ -1,27 +1,27 @@
<?php
return [
'definition_list_success' => 'IDVメッセージ定義リストを取得しました。',
'definition_list_failed' => 'IDVメッセージ定義リスト取得に失敗しました。',
'definition_show_success' => 'IDVメッセージ定義詳細を取得しました。',
'definition_show_failed' => 'IDVメッセージ定義詳細取得に失敗しました。',
'definition_created' => 'IDVメッセージ定義が作成されました。',
'definition_create_failed' => 'IDVメッセージ定義作成に失敗しました。',
'definition_updated' => 'IDVメッセージ定義が編集されました。',
'definition_update_failed' => 'IDVメッセージ定義編集に失敗しました。',
'definition_toggled' => 'IDVメッセージ定義の有効状態が変更されました。',
'definition_toggle_failed' => 'IDVメッセージ定義有効状態変更に失敗しました。',
'definition_reset' => 'IDVメッセージ定義がデフォルト値に復元されました。',
'definition_reset_failed' => 'IDVメッセージ定義デフォルト値復元に失敗しました。',
'definition_deleted' => 'IDVメッセージ定義が削除されました。',
'definition_delete_failed' => 'IDVメッセージ定義削除に失敗しました。',
'definition_delete_forbidden' => 'シード済みのデフォルトメッセージ定義は削除できません。',
'template_updated' => 'IDVメッセージテンプレートが編集されました。',
'template_update_failed' => 'IDVメッセージテンプレート編集に失敗しました。',
'template_toggled' => 'IDVメッセージテンプレートの有効状態が変更されました。',
'template_toggle_failed' => 'IDVメッセージテンプレート有効状態変更に失敗しました。',
'template_reset' => 'IDVメッセージテンプレートがデフォルト値に復元されました。',
'template_reset_failed' => 'IDVメッセージテンプレートデフォルト値復元に失敗しました。',
'template_preview_success' => 'IDVメッセージテンプレートプレビューを生成しました。',
'template_preview_failed' => 'IDVメッセージテンプレートプレビュー生成に失敗しました。',
'definition_list_success' => 'IDV メッセージ定義リストを取得しました。',
'definition_list_failed' => 'IDV メッセージ定義リスト取得に失敗しました。',
'definition_show_success' => 'IDV メッセージ定義の詳細を取得しました。',
'definition_show_failed' => 'IDV メッセージ定義の詳細取得に失敗しました。',
'definition_created' => 'IDV メッセージ定義が作成されました。',
'definition_create_failed' => 'IDV メッセージ定義の作成に失敗しました。',
'definition_updated' => 'IDV メッセージ定義が編集されました。',
'definition_update_failed' => 'IDV メッセージ定義の編集に失敗しました。',
'definition_toggled' => 'IDV メッセージ定義の有効状態が変更されました。',
'definition_toggle_failed' => 'IDV メッセージ定義の有効状態変更に失敗しました。',
'definition_reset' => 'IDV メッセージ定義がデフォルト値に復元されました。',
'definition_reset_failed' => 'IDV メッセージ定義のデフォルト値復元に失敗しました。',
'definition_deleted' => 'IDV メッセージ定義が削除されました。',
'definition_delete_failed' => 'IDV メッセージ定義の削除に失敗しました。',
'definition_delete_forbidden' => 'シードされたデフォルトメッセージ定義は削除できません。',
'template_updated' => 'IDV メッセージテンプレートが編集されました。',
'template_update_failed' => 'IDV メッセージテンプレートの編集に失敗しました。',
'template_toggled' => 'IDV メッセージテンプレートの有効状態が変更されました。',
'template_toggle_failed' => 'IDV メッセージテンプレートの有効状態変更に失敗しました。',
'template_reset' => 'IDV メッセージテンプレートがデフォルト値に復元されました。',
'template_reset_failed' => 'IDV メッセージテンプレートのデフォルト値復元に失敗しました。',
'template_preview_success' => 'IDV メッセージテンプレートのプレビューが作成されました。',
'template_preview_failed' => 'IDV メッセージテンプレートのプレビュー作成に失敗しました。',
];
@@ -2,61 +2,61 @@
return [
'fetch_success' => '言語パックリストを取得しました。',
'fetch_failed' => '言語パックリストを読み込めませんでした。',
'fetch_failed' => '言語パックリストを読み込めませんでした: :error',
'not_found' => '言語パックが見つかりません。',
'install_success' => '言語パックをインストールしました。',
'install_failed' => '言語パックのインストールに失敗しました。',
'install_failed' => '言語パックのインストールに失敗しました: :error',
'activate_success' => '言語パックを有効化しました。',
'activate_failed' => '言語パックの有効化に失敗しました。',
'activate_failed' => '言語パックの有効化に失敗しました: :error',
'deactivate_success' => '言語パックを無効化しました。',
'deactivate_failed' => '言語パックの無効化に失敗しました。',
'deactivate_failed' => '言語パックの無効化に失敗しました: :error',
'uninstall_success' => '言語パックを削除しました。',
'uninstall_failed' => '言語パックの削除に失敗しました。',
'uninstall_failed' => '言語パックの削除に失敗しました: :error',
'manifest_invalid' => 'language-pack.json の検証に失敗しました。',
'check_updates_success' => 'アップデート確認を完了しました。',
'check_updates_failed' => 'アップデート確認に失敗しました。',
'check_updates_success' => 'アップデート確認が完了しました。',
'check_updates_failed' => 'アップデート確認に失敗しました: :error',
'update_success' => '言語パックをアップデートしました。',
'update_failed' => '言語パックのアップデートに失敗しました。',
'update_failed' => '言語パックのアップデートに失敗しました: :error',
'refresh_cache_success' => '言語パックキャッシュを更新しました。',
'refresh_cache_failed' => '言語パックキャッシュの更新に失敗しました。',
'preview_success' => 'manifestプレビューを完了しました。',
'preview_failed' => 'manifestプレビューに失敗しました。',
'refresh_cache_failed' => '言語パックキャッシュの更新に失敗しました: :error',
'preview_success' => 'manifest プレビューが完了しました。',
'preview_failed' => 'manifest プレビューに失敗しました: :error',
'errors' => [
'manifest_not_found' => 'ZIP内でlanguage-pack.jsonファイルが見つかりません。',
'manifest_invalid_json' => 'language-pack.json のJSON形式が正しくありません。',
'php_outside_backend' => 'backend/ディレクトリ外にPHPファイルが含まれています (:file)。',
'unsafe_php_pattern' => 'PHPファイルに許可されていないコードパターンが含まれています (:file)。',
'manifest_not_found' => 'ZIP内に language-pack.json ファイルが見つかりません。',
'manifest_invalid_json' => 'language-pack.json の JSON 形式が正しくありません。',
'php_outside_backend' => 'backend/ ディレクトリ外に PHP ファイルが含まれています (:file)。',
'unsafe_php_pattern' => 'PHP ファイルに許可されていないコードパターンが含まれています (:file)。',
'core_locale_missing' => 'コア言語パック(:locale)を先に有効化する必要があります。',
'target_not_installed' => '対象:scope (:target)がインストールされていません。',
'target_inactive' => '対象:scope (:target)が無効状態です。先に有効化してください。',
'target_version_too_old' => '対象:scope (:target)バージョンが要件(:constraint)を満たしていません。',
'target_not_installed' => '対象 :scope (:target) がインストールされていません。',
'target_inactive' => '対象 :scope (:target) は無効化されています。先に有効化してください。',
'target_version_too_old' => '対象 :scope (:target) のバージョンが要件(:constraint)を満たしていません。',
'downgrade_blocked' => 'ダウングレードがブロックされました (:from → :to)。',
'protected_pack' => '保護された言語パックは無効化·削除できません。',
'download_failed' => 'URL(:url)から言語パックをダウンロードできませんでした。',
'download_failed' => 'URL(:url) から言語パックをダウンロードできませんでした: :error',
'download_url_not_public' => '内部ネットワークアドレス(プライベートIP·localhostなど)からは言語パックをダウンロードできません。外部からアクセス可能なhttpsアドレスを使用してください。',
'checksum_mismatch' => 'チェックサムが一致しません。',
'update_no_source' => 'アップデートソース情報がありません (GitHubソース言語パックのみアップデート可能)。',
'update_already_latest' => 'すでに最新バージョンです。',
'preview_invalid_manifest' => 'manifestファイルが無効なためプレビューに失敗しました。',
'operation_in_progress' => '言語パック(:identifier)で別の処理が進行中です。完了後に再試行してください。',
'update_no_source' => 'アップデートソース情報がありません(GitHubソース言語パックのみアップデート可能)。',
'update_already_latest' => '既に最新バージョンです。',
'preview_invalid_manifest' => 'manifestファイルが有効でなく、プレビューに失敗しました。',
'operation_in_progress' => '言語パック(:identifier)で別のタスクが処理中です。完了後に再度お試しください。',
'bundled_not_found' => 'バンドルディレクトリ(lang-packs/_bundled/:identifier)が存在しません。',
'slot_conflict' => '同じスロットにすでにアクティブな言語パック(:current)があります。(:target)に置き換えるには確認が必要です。',
'directory_not_writable' => '言語パックインストールディレクトリ(:path)への書き込み権限がありません。Webサーバーユーザー(例: www-data)に書き込み権限を付与してください。(例: sudo chgrp www-data :path && sudo chmod g+w :path)',
'identifier_not_found' => '言語パックが見つかりません (:identifier)。',
'slot_conflict' => '同じスロットに既にアクティブな言語パック(:current)があります。(:target)に置き換えるには確認が必要です。',
'directory_not_writable' => '言語パックインストールディレクトリ(:path)に書き込み権限がありません。Webサーバーユーザー(例: www-data)に書き込み権限を付与してください。(例: sudo chgrp www-data :path && sudo chmod g+w :path)',
'identifier_not_found' => '言語パックが見つかりません(:identifier)。',
'unsupported_source' => 'サポートされていないソース(:source) — bundled、github、urlのいずれかを使用してください。',
'download_url_not_public' => '内部ネットワークアドレス(プライベートIP·localhost等)では言語パックをダウンロードできません。外部からアクセス可能なhttpsアドレスを使用してください。',
],
'validation' => [
'file_required' => '言語パックZIPファイルをアップロードしてください。',
'file_invalid' => 'アップロードされたファイルが有効ではありません。',
'file_too_large' => 'ファイルサイズは10MBを超えることはできません。',
'file_not_zip' => 'ZIP形式のファイルのみアップロードできます。',
'github_url_required' => 'GitHubURLを入力してください。',
'github_url_required' => 'GitHubのURLを入力してください。',
'github_url_invalid' => '正しいURLを入力してください。',
'github_url_format' => 'GitHubリポジトリURL形式である必要があります (例: https://github.com/owner/repo)。',
'github_url_format' => 'GitHubリポジトリURLの形式である必要があります(例: https://github.com/owner/repo)。',
'url_required' => 'ダウンロードURLを入力してください。',
'url_invalid' => '正しいURLを入力してください。',
'checksum_invalid' => 'SHA-256チェックサムは64文字の16進数である必要があります。',
'identifier_required' => '言語パック識別子を入力してください。',
'identifier_invalid' => '識別子は英数字·ピリオド·アンダースコア·ハイフンのみ許可されます。',
'identifier_invalid' => '識別子は英文/数字/ドット/アンダースコア/ハイフンのみが許可されます。',
],
];
@@ -2,5 +2,5 @@
return [
'version_warning_title' => '互換性警告',
'version_incompatible' => 'オーバーライド":source"がモジュールバージョンと互換性がありません (要求: :constraint、現在: :current_version)',
'version_incompatible' => 'オーバーライド":source"がモジュールバージョンと互換性がありません(必須: :constraint、現在: :current_version)',
];
@@ -2,6 +2,6 @@
return [
'title' => 'システムメンテナンス中',
'message' => '現在システムアップデートが進行中です。',
'description' => 'しばらく後に再度お試しください。ご不便をおかけして申し訳ございません。',
'message' => '現在、システムアップデートが実施中です。',
'description' => 'しばらく経ってからもう一度お試しください。ご不便をおかけして申し訳ございません。',
];
@@ -13,16 +13,16 @@ return [
'delete_success' => 'メニューが正常に削除されました。',
'delete_failed' => 'メニューの削除に失敗しました。',
'delete_error' => 'メニュー削除中にエラーが発生しました。',
'order_update_success' => 'メニュー順序が正常にアップデートされました。',
'order_update_failed' => 'メニュー順序のアップデートに失敗しました。',
'order_update_success' => 'メニューの順序が正常にアップデートされました。',
'order_update_failed' => 'メニューの順序アップデートに失敗しました。',
'slug_already_exists' => '既に使用中のスラッグです。',
'parent_menu_not_found' => '存在しない親メニューです。',
'cannot_set_self_as_parent' => '自分自身を親として設定することはできません。',
'cannot_delete_menu_with_children' => '子メニューがあるメニューは削除できません。',
'cannot_set_self_as_parent' => '自身を親として設定することはできません。',
'cannot_delete_menu_with_children' => '子メニューを持つメニューは削除できません。',
'validation' => [
'is_active_boolean' => '有効化ステータスはtrue/false値である必要があります。',
'sort_by_invalid' => '並べ替え基準が有効ではありません。',
'sort_order_invalid' => '並べ替え順序が有効ではありません。',
'sort_by_invalid' => 'ソート基準が有効ではありません。',
'sort_order_invalid' => 'ソート順序が有効ではありません。',
'filters_array' => 'フィルターは配列形式である必要があります。',
'filters_max' => 'フィルターは最大10個まで指定できます。',
'filter_field_required' => 'フィルターフィールドは必須です。',
@@ -2,25 +2,27 @@
return [
'not_found' => 'モジュール :module が見つかりません。',
'dependency_not_active' => '依存関係モジュール :dependency がインストールされていないか、有効化されていません。',
'dependency_not_active' => '依存モジュール :dependency がインストールされていないか、有効化されていません。',
'fetch_success' => 'モジュールを正常にインポートしました。',
'fetch_failed' => 'モジュールのインポートに失敗しました。',
'fetch_failed' => 'モジュールのインポートに失敗しました: :error',
'install_success' => 'モジュールが正常にインストールされました。',
'install_failed' => 'モジュールのインストールに失敗しました。',
'preview_success' => 'マニフェストのプレビューが完了しました。',
'preview_failed' => 'マニフェストのプレビューに失敗しました。',
'install_failed' => 'モジュールのインストールに失敗しました: :error',
'preview_success' => 'manifestのプレビューが完了しました。',
'preview_failed' => 'manifestのプレビューに失敗しました: :error',
'uninstall_success' => 'モジュールが正常に削除されました。',
'uninstall_failed' => 'モジュールの削除に失敗しました。',
'uninstall_failed' => 'モジュールの削除に失敗しました: :error',
'activate_success' => 'モジュールが正常に有効化されました。',
'activate_failed' => 'モジュールの有効化に失敗しました。',
'activate_failed' => 'モジュールの有効化に失敗しました: :error',
'deactivate_success' => 'モジュールが正常に無効化されました。',
'deactivate_failed' => 'モジュールの無効化に失敗しました。',
'deactivate_failed' => 'モジュールの無効化に失敗しました: :error',
'update_success' => 'モジュールが正常にアップデートされました。',
'update_failed' => 'モジュールのアップデートに失敗しました。',
'update_failed' => 'モジュールのアップデートに失敗しました: :error',
'refresh_layouts_success' => 'モジュールレイアウトが正常に更新されました。',
'refresh_layouts_failed' => 'モジュールレイアウトの更新に失敗しました。',
'refresh_layouts_failed' => 'モジュールレイアウトの更新に失敗しました: :error',
'uninstall_info_success' => 'モジュール削除情報を正常に取得しました。',
'uninstall_info_failed' => 'モジュール削除情報の取得に失敗しました。',
'uninstall_info_failed' => 'モジュール削除情報の取得に失敗しました: :error',
'dependent_templates_success' => '依存テンプレート情報を正常に取得しました。',
'dependent_templates_failed' => '依存テンプレート情報の取得に失敗しました: :error',
'license_not_found' => 'モジュールライセンスファイルが見つかりません。',
'status' => [
'active' => '有効',
@@ -30,12 +32,10 @@ return [
],
'errors' => [
'module_class_not_found' => 'モジュールクラスが見つかりません。',
'migration_failed' => 'マイグレーション実行に失敗しました。',
'dependency_check_failed' => '依存関係の確認に失敗しました。',
'migration_failed' => 'マイグレーション実行に失敗しました: :error',
'dependency_check_failed' => '依存性確認に失敗しました: :error',
'database_error' => 'データベースエラーが発生しました。',
'module_not_found' => 'モジュール :name が見つかりません。',
'module_not_active' => 'モジュール :name は有効化されていません。',
],
'dependent_templates_success' => '依存テンプレート情報を正常に取得しました。',
'dependent_templates_failed' => '依存テンプレート情報の取得に失敗しました: :error',
];
@@ -23,58 +23,58 @@ return [
'uninstalled' => '未インストール',
],
'no_modules' => 'モジュールがありません。',
'summary' => '合計 :total個 モジュール (インストール済み: :installed、有効化: :active)',
'summary' => '合計 :total 個のモジュール (インストール済み: :installed、有効化: :active)',
'invalid_status' => '無効なステータスです。(installed、uninstalled、active、inactive)',
],
'install' => [
'success' => 'モジュール ":module" インストール完了',
'success' => 'モジュール ":module" のインストールが完了しました',
'vendor' => 'ベンダー: :vendor',
'version' => 'バージョン: :version',
'roles_created' => ':count個 ロール 作成されました',
'permissions_created' => ':count個 権限 作成されました',
'menus_created' => ':count個 メニュー 作成されました',
'roles_created' => ':count 個のロールが作成されました',
'permissions_created' => ':count 個の権限が作成されました',
'menus_created' => ':count 個のメニューが作成されました',
'already_installed' => 'モジュール ":module" は既にインストールされています。',
'force_reinstall' => 'モジュール ":module" を強制的に再インストールします (アクティブディレクトリを上書き)...',
],
'activate' => [
'success' => 'モジュール ":module" 有効化完了',
'not_installed' => 'モジュール ":module" はインストールされていません。',
'success' => 'モジュール ":module" の有効化が完了しました',
'not_installed' => 'モジュール ":module" がインストールされていません。',
'already_active' => 'モジュール ":module" は既に有効化されています。',
'layouts_registered' => ':count個 レイアウト 登録されました',
'layouts_registered' => ':count 個のレイアウトが登録されました',
],
'deactivate' => [
'success' => 'モジュール ":module" 無効化完了',
'not_installed' => 'モジュール ":module" はインストールされていません。',
'success' => 'モジュール ":module" の無効化が完了しました',
'not_installed' => 'モジュール ":module" がインストールされていません。',
'not_active' => 'モジュール ":module" は有効化されていません。',
'warning' => 'モジュールのルートと機能が無効化されました。',
'layouts_deleted' => ':count個 レイアウト 削除されました',
'layouts_deleted' => ':count 個のレイアウトが削除されました',
],
'uninstall' => [
'success' => 'モジュール ":module" 削除完了',
'roles_deleted' => ':count個 ロール 削除されました',
'permissions_deleted' => ':count個 権限 削除されました',
'menus_deleted' => ':count個 メニュー 削除されました',
'layouts_deleted' => ':count個 レイアウト 削除されました',
'confirm_prompt' => 'モジュール ":module" を削除しますか?',
'success' => 'モジュール ":module" の削除が完了しました',
'roles_deleted' => ':count 個のロールが削除されました',
'permissions_deleted' => ':count 個の権限が削除されました',
'menus_deleted' => ':count 個のメニューが削除されました',
'layouts_deleted' => ':count 個のレイアウトが削除されました',
'confirm_prompt' => 'モジュール ":module" を削除してもよろしいですか?',
'confirm_details' => [
'roles' => '- :count個 のロールが削除されます。',
'permissions' => '- :count個 の権限が削除されます。',
'menus' => '- :count個 のメニューが削除されます。',
'layouts' => '- :count個 のレイアウトが削除されます。',
'roles' => '- :count 個のロールが削除されます。',
'permissions' => '- :count 個の権限が削除されます。',
'menus' => '- :count 個のメニューが削除されます。',
'layouts' => '- :count 個のレイアウトが削除されます。',
'data' => '- すべてのモジュールデータが削除されます。',
],
'confirm_question' => '本当に削除しますか?',
'aborted' => 'モジュール削除がキャンセルされました。',
'not_installed' => 'モジュール ":module" はインストールされていません。',
'confirm_question' => '本当に削除してもよろしいですか?',
'aborted' => 'モジュールの削除がキャンセルされました。',
'not_installed' => 'モジュール ":module" がインストールされていません。',
],
'cache_clear' => [
'clearing_all' => 'すべてのモジュールキャッシュを削除します...',
'clearing_single' => 'モジュール ":module" キャッシュを削除します...',
'success_all' => 'モジュールキャッシュ削除完了 (:count個 項目)',
'success_single' => 'モジュール ":module" キャッシュ削除完了 (:count個 項目)',
'clearing_single' => 'モジュール ":module" のキャッシュを削除します...',
'success_all' => 'モジュールキャッシュの削除が完了しました (:count 個の項目)',
'success_single' => 'モジュール ":module" のキャッシュ削除が完了しました (:count 個の項目)',
],
'check_updates' => [
'not_installed' => 'モジュール ":module" はインストールされていません。',
'not_installed' => 'モジュール ":module" がインストールされていません。',
'no_installed' => 'インストール済みモジュールがありません。',
'update_available' => 'アップデート可能',
'up_to_date' => '最新',
@@ -85,122 +85,122 @@ return [
'source' => 'ソース',
'status' => 'ステータス',
],
'summary' => '合計 :total個 確認 (アップデート可能: :updates個)',
'summary' => '合計 :total 個を確認 (アップデート可能: :updates 個)',
'single_up_to_date' => 'モジュール ":module" は最新バージョンです。(v:version)',
'single_update_available' => 'モジュール ":module" アップデート可能: v:current → v:latest (:source)',
'single_update_available' => 'モジュール ":module" はアップデート可能です: v:current → v:latest (:source)',
],
'update' => [
'success' => 'モジュール ":module" アップデート完了',
'success' => 'モジュール ":module" のアップデートが完了しました',
'version_change' => 'v:from → v:to',
'no_update' => 'モジュール ":module" に利用可能なアップデートはありません。',
'not_installed' => 'モジュール ":module" はインストールされていません。',
'not_installed' => 'モジュール ":module" がインストールされていません。',
'current_version' => '現在のバージョン: :version',
'latest_version' => '最新バージョン: :version',
'update_source' => 'アップデートソース: :source',
'confirm_question' => 'アップデートを実行しますか?',
'confirm_question' => 'アップデートを進めてもよろしいですか?',
'aborted' => 'アップデートがキャンセルされました。',
'backup_restored' => 'バックアップから以前のバージョンが復元されました。',
'force_mode' => '強制アップデートモード: バージョン比較をスキップして再インストールします。',
'layout_strategy' => 'レイアウト戦略: :strategy',
'modified_extensions_warning' => '管理者が編集したレイアウト拡張 :count件が上書きされます。',
'modified_extension_item' => ' - :target (出典: :source)',
'modified_extensions_warning' => '管理者が編集したレイアウト拡張 :count 件が上書きされます。',
'modified_extension_item' => ' - :target (ソース: :source)',
],
],
'composer_install' => [
'start' => 'モジュール ":module" の Composer 依存関係をインストールします...',
'success' => 'モジュール ":module" の Composer 依存関係 インストール完了',
'success' => 'モジュール ":module" の Composer 依存関係のインストールが完了しました',
'no_dependencies' => 'モジュール ":module" にインストールする Composer 依存関係はありません。',
'failed' => 'モジュール ":module" の Composer 依存関係 インストールに失敗しました。',
'failed' => 'モジュール ":module" の Composer 依存関係のインストールに失敗しました。',
'summary' => '📊 結果: 成功 :success個、スキップ :skip個、失敗 :fail個',
],
'update_success' => 'モジュール ":module" が :version バージョンにアップデートされました。',
'update_failed' => 'モジュールアップデートに失敗しました: :error',
'update_hook_failed' => 'モジュール アップデート フック実行に失敗しました。',
'update_success' => 'モジュール ":module"が :version バージョンにアップデートされました。',
'update_failed' => 'モジュールのアップデートに失敗しました: :error',
'update_hook_failed' => 'モジュールアップデートフック実行に失敗しました: :error',
'no_update_available' => 'アップデートする内容がありません。',
'check_updates_success' => 'アップデート確認が完了しました。',
'check_updates_failed' => 'アップデート確認に失敗しました。',
'check_modified_layouts_success' => '修正されたレイアウト確認が完了しました。',
'check_modified_layouts_failed' => '修正されたレイアウト確認に失敗しました: :error',
'not_installed' => 'モジュール":module"はインストールされていません。',
'pending_not_found' => 'インストール待ちリストでモジュール":module"が見つかりません。',
'already_exists' => 'モジュール":module"は既に存在します。',
'move_failed' => 'モジュール":module"の移動に失敗しました。',
'deactivate_warning' => 'このモジュールに依存する有効なテンプレートがあります。',
'installation_failed' => 'モジュール インストールに失敗しました: :error',
'activation_failed' => 'モジュール 有効化に失敗しました: :error',
'deactivation_failed' => 'モジュール 無効化に失敗しました: :error',
'uninstallation_failed' => 'モジュール 削除に失敗しました: :error',
'refresh_layouts_failed' => 'レイアウト 更新に失敗しました: :error',
'check_updates_failed' => 'アップデート確認に失敗しました: :error',
'check_modified_layouts_success' => '変更されたレイアウト確認が完了しました。',
'check_modified_layouts_failed' => '変更されたレイアウト確認に失敗しました: :error',
'not_installed' => 'モジュール ":module"はインストールされていません。',
'pending_not_found' => 'インストール待機リストでモジュール ":module"が見つかりません。',
'already_exists' => 'モジュール ":module"は既に存在します。',
'move_failed' => 'モジュール ":module"の移動に失敗しました: :error',
'deactivate_warning' => 'このモジュールに依存するアクティブなテンプレートがあります。',
'installation_failed' => 'モジュールのインストールに失敗しました: :error',
'activation_failed' => 'モジュールの有効化に失敗しました: :error',
'deactivation_failed' => 'モジュールの無効化に失敗しました: :error',
'uninstallation_failed' => 'モジュールの削除に失敗しました: :error',
'refresh_layouts_failed' => 'レイアウト更新に失敗しました: :error',
'warnings' => [
'has_dependent_templates' => 'このモジュールに依存する有効化されたテンプレートがあります。無効化するには強制オプションを使用してください。',
'has_dependents' => 'このモジュールに依存する有効化された拡張があります。',
'deactivation_warning' => '無効化すると次のテンプレートが影響を受けます:',
'deactivation_warning' => '無効化すると、次のテンプレートが影響を受けます:',
'confirm_deactivation' => 'それでも無効化しますか?',
'missing_dependencies' => 'このモジュールを有効化するには次の依存性が必要です。',
'missing_dependencies' => 'このモジュールを有効化するには、次の依存関係が必要です。',
],
'activate_warning' => 'モジュール 有効化に必要な依存性が満たされていません。',
'deactivate_warning_extended' => 'このモジュールに依存する有効化された拡張(テンプレート、モジュール、プラグイン)があります。',
'activate_warning' => 'モジュール有効化に必要な依存関係が満たされていません。',
'deactivate_warning_extended' => 'このモジュールに依存する有効化された拡張 (テンプレート、モジュール、プラグイン) があります。',
'errors' => [
'force_update_no_source' => ':moduleを強制アップデートするソースが見つかりません。バンドルおよびGitHub URLの両方がありません。',
'downgrade_blocked' => 'ダウングレードがブロックされました(:from → :to)。意図的なダウングレードは--forceオプションを使用してください。',
'zip_open_failed' => 'ZIPファイルを開くことができません。',
'module_json_not_found' => 'module.jsonファイルが見つかりません。',
'module_json_invalid' => 'module.jsonファイル形式が正しくありません。',
'identifier_missing' => 'モジュール識別子が不足しています。',
'force_update_no_source' => ':module を強制アップデートするソースが見つかりません。バンドルと GitHub URL の両方がありません。',
'downgrade_blocked' => 'ダウングレードがブロックされました (:from → :to)。意図的なダウングレードは --force オプションを使用してください。',
'zip_open_failed' => 'ZIP ファイルを開くことができません: :error',
'module_json_not_found' => 'module.json ファイルが見つかりません。',
'module_json_invalid' => 'module.json ファイル形式が正しくありません。',
'identifier_missing' => 'モジュール識別子がありません。',
'already_installed' => '既にインストールされているモジュールです。',
'install_failed' => 'モジュール インストールに失敗しました。',
'github_url_invalid' => '無効なGitHub URLの形式です。',
'github_download_failed' => 'GitHubからのダウンロードに失敗しました。',
'install_failed' => 'モジュールのインストールに失敗しました: :error',
'github_url_invalid' => '無効な GitHub URL 形式です。',
'github_download_failed' => 'GitHub からのダウンロードに失敗しました: :error',
'github_repo_not_found' => 'GitHub リポジトリが見つかりません。',
'module_not_found' => 'モジュール:nameが見つかりません。',
'module_not_active' => 'モジュール:nameは有効化されていません。',
'not_found' => 'モジュール:moduleが見つかりません。',
'module_not_found' => 'モジュール :name が見つかりません。',
'module_not_active' => 'モジュール :name は有効化されていません。',
'not_found' => 'モジュール :module が見つかりません。',
'file_not_found' => 'ファイルが見つかりません。',
'file_type_not_allowed' => '許可されていないファイル形式です。',
'unknown_error' => '不明なエラーが発生しました。',
'invalid_permission_structure' => 'モジュール":identifier"の権限構造が正しくありません: :reason',
'invalid_translation_path' => 'モジュール":identifier"の言語パック ファイルパスが正しくありません。":wrong_path"の代わりに":correct_path"を使用する必要があります。',
'seo_variable_conflict' => 'モジュール":identifier"のSEO変数名が既存の拡張と競合します: :reason',
'layout_validation_json_error' => 'レイアウト ファイル:fileのJSON解析に失敗しました: :error',
'layout_validation_missing_name' => 'レイアウト ファイル:fileにlayout_nameが不足しています。',
'layout_validation_partial_error' => 'レイアウト ファイル:fileのpartial処理に失敗しました: :error (partial: :partial)',
'layout_validation_validation_failed' => 'モジュール:identifierのレイアウト バリデーションに失敗しました。:count個のエラーが見つかりました。',
'operation_in_progress' => 'モジュール":name"は現在:statusの状態です。処理が完了するまでお待ちください。',
'github_api_failed' => 'GitHub API呼び出しに失敗しました。',
'invalid_github_url' => '無効なGitHub URLです。',
'zip_url_not_found' => 'ZIPダウンロード URLが見つかりません。',
'download_failed' => 'モジュール":module":versionバージョンのダウンロードに失敗しました。',
'zip_extract_failed' => 'ZIP展開に失敗しました。',
'extracted_dir_not_found' => '展開されたディレクトリが見つかりません。',
'reload_failed' => 'モジュール 再ロードに失敗しました。',
'delete_directory_failed' => 'モジュール ディレクトリの削除に失敗しました。',
'update_failed' => 'モジュール":module"のアップデートに失敗しました: :error',
'invalid_layout_strategy' => '無効なレイアウト戦略です。(overwriteまたはkeepのみ許可)',
'invalid_vendor_mode' => '無効なVendor インストール モードです。(auto、composer、bundledのみ許可)',
'invalid_permission_structure' => 'モジュール ":identifier"の権限構造が正しくありません: :reason',
'invalid_translation_path' => 'モジュール ":identifier"の言語ファイルパスが正しくありません。":wrong_path" の代わりに ":correct_path"を使用する必要があります。',
'seo_variable_conflict' => 'モジュール ":identifier"の SEO 変数名が既存の拡張と競合しています: :reason',
'layout_validation_json_error' => 'レイアウトファイル :file の JSON パースに失敗しました: :error',
'layout_validation_missing_name' => 'レイアウトファイル :file に layout_name がありません。',
'layout_validation_partial_error' => 'レイアウトファイル :file の partial 処理に失敗しました: :error (partial: :partial)',
'layout_validation_validation_failed' => 'モジュール :identifier のレイアウト検証に失敗しました。:count 個のエラーが見つかりました: :error',
'operation_in_progress' => 'モジュール ":name"は現在 :status 状態です。処理が完了するまでお待ちください。',
'github_api_failed' => 'GitHub API 呼び出しに失敗しました: :error',
'invalid_github_url' => '無効な GitHub URL です。',
'zip_url_not_found' => 'ZIP ダウンロード URL が見つかりません。',
'download_failed' => 'モジュール ":module" :version バージョンのダウンロードに失敗しました: :error',
'zip_extract_failed' => 'ZIP 解凍に失敗しました: :error',
'extracted_dir_not_found' => '解凍されたディレクトリが見つかりません。',
'reload_failed' => 'モジュールの再読み込みに失敗しました: :error',
'delete_directory_failed' => 'モジュールディレクトリの削除に失敗しました: :error',
'update_failed' => 'モジュール ":module"のアップデートに失敗しました: :error',
'invalid_layout_strategy' => '無効なレイアウト戦略です。(overwrite または keep のみ許可)',
'invalid_vendor_mode' => '無効な Vendor インストールモードです。(auto、composer、bundled のみ許可)',
],
'validation' => [
'name_required' => 'モジュール名は必須です。',
'name_string' => 'モジュール名は文字列である必要があります。',
'name_max' => 'モジュール名は255字を超えることはできません。',
'search_max' => '検索語は255字を超えることはできません。',
'filters_max' => '検索条件は最大10個までのみ指定できます。',
'name_max' => 'モジュール名は 255 文字を超えることができません。',
'search_max' => '検索語は 255 文字を超えることができません。',
'filters_max' => '検索条件は最大 10 個までのみ指定できます。',
'filter_field_required' => '検索フィールドは必須です。',
'filter_field_invalid' => '無効な検索フィールドです。',
'filter_value_required' => '検索値は必須です。',
'filter_value_max' => '検索値は255字を超えることはできません。',
'filter_value_max' => '検索値は 255 文字を超えることができません。',
'filter_operator_invalid' => '無効な検索演算子です。',
'status_invalid' => '無効なステータスです。',
'per_page_min' => 'ページあたりの項目数は最小1です。',
'per_page_max' => 'ページあたりの項目数は最大100です。',
'page_min' => 'ページ番号は最小1です。',
'file_required' => 'モジュール ファイルは必須です。',
'status_invalid' => '無効な状態です。',
'per_page_min' => '1 ページあたりの項目数は最小 1 個です。',
'per_page_max' => '1 ページあたりの項目数は最大 100 個です。',
'page_min' => 'ページ番号は最小 1 です。',
'file_required' => 'モジュールファイルは必須です。',
'file_invalid' => '有効なファイルではありません。',
'file_must_be_zip' => 'モジュール ファイルはZIP形式である必要があります。',
'file_max_size' => 'モジュール ファイル サイズは:sizeMBを超えることはできません。',
'file_must_be_zip' => 'モジュールファイルは ZIP 形式である必要があります。',
'file_max_size' => 'モジュールファイルサイズは :sizeMB を超えることができません。',
'github_url_required' => 'GitHub URLは必須です。',
'github_url_invalid' => '有効なURL形式ではありません。',
'github_url_format' => 'GitHub リポジトリURL形式が正しくありません。',
'with_max' => 'withパラメータは最大10個まで指定できます。',
'with_invalid' => '無効なwith関係です。',
'with_max' => 'with パラメータは最大10個まで指定できます。',
'with_invalid' => '有効でないwith 関係です。',
],
];
@@ -1,49 +1,50 @@
<?php
return [
'definition_list_success' => '通知定義一覧を取得しました。',
'definition_list_failed' => '通知定義一覧の取得に失敗しました。',
'definition_list_success' => '通知定義リストを取得しました。',
'definition_list_failed' => '通知定義リストの取得に失敗しました。',
'definition_show_success' => '通知定義を取得しました。',
'definition_show_failed' => '通知定義の取得に失敗しました。',
'definition_updated' => '通知定義が編集されました。',
'definition_update_failed' => '通知定義の編集に失敗しました。',
'definition_toggled' => '通知定義のアクティブ状態が変更されました。',
'definition_toggle_failed' => '通知定義のアクティブ状態の変更に失敗しました。',
'definition_toggled' => '通知定義の有効状態が変更されました。',
'definition_toggle_failed' => '通知定義の有効状態変更に失敗しました。',
'definition_not_found' => '通知定義が見つかりません。',
'definition_reset' => '通知定義のすべてのテンプレートがデフォルト値に復元されました。',
'definition_reset_failed' => '通知定義の初期化に失敗しました。',
'channel_skipped_no_template' => ':channel チャネルに :type タイプのアクティブなテンプレートがないため、配信をスキップしました。',
'channel_disabled_by_extension' => '拡張設定でそのチャネルが無効化されているため、配信をスキップしました。',
'channel_skipped_no_template' => ':channel チャネルに :type タイプのアクティブテンプレートがないため、送信をスキップしました。',
'channel_disabled_by_extension' => '拡張環境設定でそのチャネルが無効化されているため、送信をスキップしました。',
'channel_guest_not_allowed' => 'そのチャネルは非会員送信を許可していないため、送信をスキップしました。',
'template_updated' => '通知テンプレートが編集されました。',
'template_update_failed' => '通知テンプレートの編集に失敗しました。',
'template_toggled' => '通知テンプレートのアクティブ状態が変更されました。',
'template_toggle_failed' => '通知テンプレートのアクティブ状態の変更に失敗しました。',
'template_toggled' => '通知テンプレートの有効状態が変更されました。',
'template_toggle_failed' => '通知テンプレートの有効状態変更に失敗しました。',
'template_reset' => '通知テンプレートがデフォルト値に復元されました。',
'template_reset_failed' => '通知テンプレートのデフォルト値復元に失敗しました。',
'template_inactive' => '該当チャネルの通知テンプレートは無効化状態です。',
'template_inactive' => '該当チャネルの通知テンプレートは無効状態です。',
'template_not_found' => '該当チャネルの通知テンプレートが見つかりません。',
'default_data_not_found' => 'デフォルトテンプレートデータが見つかりません。',
'preview_success' => 'プレビューを生成しました。',
'preview_failed' => 'プレビューの生成に失敗しました。',
'channels_success' => '通知チャネル一覧を取得しました。',
'channels_failed' => '通知チャネル一覧の取得に失敗しました。',
'preview_success' => 'プレビューを作成しました。',
'preview_failed' => 'プレビューの作成に失敗しました。',
'channels_success' => '通知チャネルリストを取得しました。',
'channels_failed' => '通知チャネルリストの取得に失敗しました。',
'definition' => '通知定義',
'subject' => '件名',
'body' => '本文',
'readiness' => [
'mail_from_address_not_configured' => '送信者メールアドレスが設定されていません。',
'mail_smtp_host_empty' => 'SMTPホストが設定されていません。',
'mail_smtp_port_empty' => 'SMTPポートが設定されていません。',
'mail_mailgun_domain_empty' => 'Mailgunドメインが設定されていません。',
'mail_mailgun_secret_empty' => 'Mailgunシークレットが設定されていません。',
'mail_ses_key_empty' => 'SESキーが設定されていません。',
'mail_ses_secret_empty' => 'SESシークレットが設定されていません。',
'mail_smtp_host_empty' => 'SMTP ホストが設定されていません。',
'mail_smtp_port_empty' => 'SMTP ポートが設定されていません。',
'mail_mailgun_domain_empty' => 'Mailgun ドメインが設定されていません。',
'mail_mailgun_secret_empty' => 'Mailgun シークレットが設定されていません。',
'mail_ses_key_empty' => 'SES キーが設定されていません。',
'mail_ses_secret_empty' => 'SES シークレットが設定されていません。',
'database_table_missing' => '通知テーブルが存在しません。',
'unknown' => 'チャネル設定が完了していません。',
],
'user' => [
'list_success' => '通知一覧を取得しました。',
'list_failed' => '通知一覧の取得に失敗しました。',
'list_success' => '通知リストを取得しました。',
'list_failed' => '通知リストの取得に失敗しました。',
'unread_count_success' => '未読通知数を取得しました。',
'unread_count_failed' => '未読通知数の取得に失敗しました。',
'read_success' => '通知を既読にしました。',
@@ -53,19 +54,18 @@ return [
'delete_success' => '通知が削除されました。',
'delete_failed' => '通知の削除に失敗しました。',
'delete_all_success' => 'すべての通知が削除されました。',
'delete_all_failed' => '全件削除に失敗しました。',
'delete_all_failed' => '全件通知削除に失敗しました。',
'not_found' => '通知が見つかりません。',
],
'channels' => [
'core_default' => 'コアの基本チャネル',
'core_default' => 'コアデフォルトチャネル',
'mail' => [
'name' => 'メール',
'description' => 'メールで通知を送信',
],
'database' => [
'name' => 'サイト内通知',
'description' => 'サイト内の通知センターに表示',
'description' => 'サイト内通知センターに表示',
],
],
'channel_guest_not_allowed' => '該当チャネルは非会員発送を許可していないため、発送をスキップしました。',
];
@@ -1,8 +1,8 @@
<?php
return [
'list_success' => '通知発送履歴を閲覧しました。',
'list_failed' => '通知発送履歴の閲覧に失敗しました。',
'list_success' => '通知発送履歴を取得しました。',
'list_failed' => '通知発送履歴の取得に失敗しました。',
'delete_success' => '通知発送履歴が削除されました。',
'delete_failed' => '通知発送履歴の削除に失敗しました。',
'bulk_delete_success' => '選択された通知発送履歴が削除されました。',
@@ -1,119 +1,121 @@
<?php
return [
'list_success' => 'プラグインリストの取得に成功しました。',
'list_failed' => 'プラグインリストの取得に失敗しました。',
'fetch_success' => 'プラグイン情報の取得に成功しました。',
'fetch_failed' => 'プラグイン情報の取得に失敗しました。',
'list_success' => 'プラグインのリストを正常に取得しました。',
'list_failed' => 'プラグインのリストの取得に失敗しました: :error',
'fetch_success' => 'プラグインの情報を正常に取得しました。',
'fetch_failed' => 'プラグインの情報の取得に失敗しました: :error',
'not_found' => 'プラグイン :plugin が見つかりません。',
'install_success' => 'プラグインが正常にインストールされました。',
'install_failed' => 'プラグインのインストールに失敗しました。',
'preview_success' => 'マニフェストプレビューが完了しました。',
'preview_failed' => 'マニフェストプレビューに失敗しました。',
'install_validation_failed' => 'プラグインのインストール検証に失敗しました。',
'install_success' => 'プラグインは正常にインストールされました。',
'install_failed' => 'プラグインのインストールに失敗しました: :error',
'preview_success' => 'manifest プレビューが完了しました。',
'preview_failed' => 'manifest プレビューに失敗しました: :error',
'install_validation_failed' => 'プラグイン インストール バリデーションに失敗しました: :error',
'install_error' => 'プラグインのインストール中にエラーが発生しました。',
'uninstall_success' => 'プラグインが正常に削除されました。',
'uninstall_failed' => 'プラグインの削除に失敗しました。',
'uninstall_validation_failed' => 'プラグインの削除検証に失敗しました。',
'uninstall_success' => 'プラグインは正常に削除されました。',
'uninstall_failed' => 'プラグインの削除に失敗しました: :error',
'uninstall_validation_failed' => 'プラグイン削除 バリデーションに失敗しました: :error',
'uninstall_error' => 'プラグインの削除中にエラーが発生しました。',
'activate_success' => 'プラグインが正常に有効化されました。',
'activate_failed' => 'プラグインの有効化に失敗しました。',
'activate_validation_failed' => 'プラグインの有効化検証に失敗しました。',
'activate_success' => 'プラグインは正常に有効化されました。',
'activate_failed' => 'プラグインの有効化に失敗しました: :error',
'activate_validation_failed' => 'プラグイン有効化 バリデーションに失敗しました: :error',
'activate_error' => 'プラグインの有効化中にエラーが発生しました。',
'deactivate_success' => 'プラグインが正常に無効化されました。',
'deactivate_failed' => 'プラグインの無効化に失敗しました。',
'deactivate_validation_failed' => 'プラグインの無効化検証に失敗しました。',
'deactivate_success' => 'プラグインは正常に無効化されました。',
'deactivate_failed' => 'プラグインの無効化に失敗しました: :error',
'deactivate_validation_failed' => 'プラグイン無効化 バリデーションに失敗しました: :error',
'deactivate_error' => 'プラグインの無効化中にエラーが発生しました。',
'refresh_layouts_success' => 'プラグインレイアウトが正常に更新されました。',
'refresh_layouts_failed' => 'プラグインレイアウトの更新に失敗しました: :error',
'uninstall_info_success' => 'プラグイン削除情報の閲覧に成功しました。',
'uninstall_info_failed' => 'プラグイン削除情報の閲覧に失敗しました。',
'uninstall_info_success' => 'プラグイン削除情報を正常に取得しました。',
'uninstall_info_failed' => 'プラグイン削除情報の取得に失敗しました: :error',
'dependent_templates_success' => '依存テンプレート情報を正常に取得しました。',
'dependent_templates_failed' => '依存テンプレート情報の取得に失敗しました: :error',
'license_not_found' => 'プラグインライセンスファイルが見つかりません。',
'refresh_layouts_validation_failed' => 'プラグインレイアウト更新検証に失敗しました。',
'refresh_layouts_validation_failed' => 'プラグインレイアウト更新 バリデーションに失敗しました: :error',
'refresh_layouts_error' => 'プラグインレイアウトの更新中にエラーが発生しました。',
'status' => [
'active' => '有効',
'inactive' => '無効',
'updating' => 'アップデート中',
'active' => '有効化',
'inactive' => '無効化',
'updating' => '更新中',
],
'update_success' => 'プラグイン ":plugin" は :version にアップデートされました。',
'update_success' => 'プラグイン ":plugin" は :version バージョンにアップデートされました。',
'update_failed' => 'プラグインのアップデートに失敗しました: :error',
'update_hook_failed' => 'プラグイン更新フックの実行に失敗しました。',
'no_update_available' => '更新する内容がありません。',
'check_updates_success' => '更新確認が完了しました。',
'check_updates_failed' => '更新確認に失敗しました。',
'check_modified_layouts_success' => '変更されたレイアウト確認が完了しました。',
'check_modified_layouts_failed' => '変更されたレイアウト確認に失敗しました: :error',
'update_hook_failed' => 'プラグイン アップデート フック実行に失敗しました: :error',
'no_update_available' => 'アップデートする内容がありません。',
'check_updates_success' => 'アップデート確認が完了しました。',
'check_updates_failed' => 'アップデート確認に失敗しました: :error',
'check_modified_layouts_success' => '更新されたレイアウト確認が完了しました。',
'check_modified_layouts_failed' => '更新されたレイアウト確認に失敗しました: :error',
'not_installed' => 'プラグイン ":plugin" はインストールされていません。',
'pending_not_found' => 'インストール待機リストにプラグイン ":plugin" が見つかりません。',
'already_exists' => 'プラグイン ":plugin" は既に存在します。',
'move_failed' => 'プラグイン ":plugin" の移動に失敗しました。',
'dependency_not_active' => '依存関係 ":dependency" がインストールされていないか、無効化されています。',
'move_failed' => 'プラグイン ":plugin" の移動に失敗しました: :error',
'dependency_not_active' => '依存 ":dependency" はインストールされていないか、無効化されています。',
'installation_failed' => 'プラグインのインストールに失敗しました: :error',
'activation_failed' => 'プラグインの有効化に失敗しました: :error',
'deactivation_failed' => 'プラグインの無効化に失敗しました: :error',
'uninstallation_failed' => 'プラグインの削除に失敗しました: :error',
'warnings' => [
'has_dependent_templates' => 'このプラグインに依存する有効なテンプレートがあります。無効化するには強制オプションを使用してください。',
'has_dependents' => 'このプラグインに依存する有効な拡張があります。',
'deactivation_warning' => '無効化すると、次のテンプレートが影響を受けます:',
'has_dependent_templates' => 'このプラグインに依存する有効化されたテンプレートがあります。無効化するには強制オプションを使用してください。',
'has_dependents' => 'このプラグインに依存する有効化された拡張があります。',
'deactivation_warning' => '無効化すると、以下のテンプレートが影響を受けます:',
'confirm_deactivation' => 'それでも無効化しますか?',
'missing_dependencies' => 'このプラグインを有効化するには、次の依存関係が必要です。',
'missing_dependencies' => 'このプラグインを有効化するには、以下の依存が必要です。',
],
'activate_warning' => 'プラグインの有効化に必要な依存関係が満たされていません。',
'deactivate_warning' => 'このプラグインに依存する有効な拡張があります。',
'deactivate_warning_extended' => 'このプラグインに依存する有効な拡張(テンプレート、モジュール、プラグイン)があります。',
'activate_warning' => 'プラグイン有効化に必要な依存が満たされていません。',
'deactivate_warning' => 'このプラグインに依存する有効化された拡張があります。',
'deactivate_warning_extended' => 'このプラグインに依存する有効化された拡張(テンプレート、モジュール、プラグイン)があります。',
'errors' => [
'force_update_no_source' => ':plugin を強制アップデートするソースが見つかりません。バンドルとGitHub URLがどちらもありません。',
'downgrade_blocked' => 'ダウングレードはブロックされています (:from → :to)。意図的なダウングレードの場合は --force オプションを使用してください。',
'force_update_no_source' => ':plugin を強制アップデートするソースが見つかりません。バンドルと GitHub URL の両方がありません。',
'downgrade_blocked' => 'ダウングレードがブロックされました (:from → :to)。意図的なダウングレードは --force オプションを使用してください。',
'invalid_permission_structure' => 'プラグイン ":identifier" の権限構造が正しくありません: :reason',
'invalid_translation_path' => 'プラグイン ":identifier" の言語ファイルパスが正しくありません。":wrong_path" の代わりに ":correct_path" を使用する必要があります。',
'seo_variable_conflict' => 'プラグイン ":identifier" のSEO変数名が既存の拡張と競合しています: :reason',
'invalid_translation_path' => 'プラグイン ":identifier" の言語ファイルパスが正しくありません。":wrong_path" ではなく ":correct_path" を使用してください。',
'seo_variable_conflict' => 'プラグイン ":identifier" の SEO 変数名が既存の拡張と競合します: :reason',
'plugin_not_found' => 'プラグイン ":name" が見つかりません。',
'plugin_not_active' => 'プラグイン ":name" は有効化されていません。',
'not_found' => 'プラグイン :plugin が見つかりません。',
'file_not_found' => 'ファイルが見つかりません。',
'file_type_not_allowed' => '許可されていないファイル形式です。',
'unknown_error' => '不明なエラーが発生しました。',
'layout_validation_json_error' => 'レイアウトファイル :file のJSON解析に失敗しました: :error',
'layout_validation_missing_name' => 'レイアウトファイル :file に layout_name が不足しています。',
'layout_validation_partial_error' => 'レイアウトファイル :file の部分テンプレート処理に失敗しました: :error (partial: :partial)',
'layout_validation_validation_failed' => 'プラグイン :identifier のレイアウト検証に失敗しました。:count 個のエラーが見つかりました。',
'operation_in_progress' => 'プラグイン ":name" は現在 :status 状態です。作業が完了するまで待ってください。',
'github_api_failed' => 'GitHub API呼び出しに失敗しました。',
'invalid_github_url' => '無効なGitHub URLです。',
'zip_url_not_found' => 'ZIPダウンロードURLが見つかりません。',
'download_failed' => 'プラグイン ":plugin" :version バージョンのダウンロードに失敗しました。',
'zip_extract_failed' => 'ZIP解凍に失敗しました。',
'layout_validation_json_error' => 'レイアウトファイル :file の JSON パース に失敗しました: :error',
'layout_validation_missing_name' => 'レイアウトファイル :file に layout_name がありません。',
'layout_validation_partial_error' => 'レイアウトファイル :file の partial 処理に失敗しました: :error (partial: :partial)',
'layout_validation_validation_failed' => 'プラグイン :identifier のレイアウト バリデーションに失敗しました。:count 個のエラーが見つかりました: :error',
'operation_in_progress' => 'プラグイン ":name" は現在 :status 状態です。作業が完了するまでお待ちください。',
'github_api_failed' => 'GitHub API 呼び出しに失敗しました: :error',
'invalid_github_url' => '無効な GitHub URL です。',
'zip_url_not_found' => 'ZIP ダウンロード URL が見つかりません。',
'download_failed' => 'プラグイン ":plugin" :version バージョンのダウンロードに失敗しました: :error',
'zip_extract_failed' => 'ZIP 解凍に失敗しました: :error',
'extracted_dir_not_found' => '解凍されたディレクトリが見つかりません。',
'reload_failed' => 'プラグインの再読み込みに失敗しました。',
'delete_directory_failed' => 'プラグインディレクトリの削除に失敗しました。',
'update_failed' => 'プラグイン ":plugin" のアップデートに失敗しました: :error',
'reload_failed' => 'プラグイン再読み込みに失敗しました: :error',
'delete_directory_failed' => 'プラグイン ディレクトリ削除に失敗しました: :error',
'update_failed' => 'プラグイン ":plugin" アップデートに失敗しました: :error',
'invalid_layout_strategy' => '無効なレイアウト戦略です。(overwrite または keep のみ許可)',
'invalid_vendor_mode' => '無効な Vendor インストールモードです。(auto、composer、bundled のみ許可)',
'github_url_invalid' => '無効な GitHub URL 形式です。',
'github_download_failed' => 'GitHub からのダウンロードに失敗しました。',
'github_download_failed' => 'GitHub からのダウンロードに失敗しました: :error',
'github_repo_not_found' => 'GitHub リポジトリが見つかりません。',
'zip_open_failed' => 'ZIP ファイルを開くことができません。',
'zip_open_failed' => 'ZIP ファイルを開くことができません: :error',
'plugin_json_not_found' => 'plugin.json ファイルが見つかりません。',
'plugin_json_invalid' => 'plugin.json ファイル形式が正しくありません。',
'identifier_missing' => 'プラグイン識別子が見つかりません。',
'identifier_missing' => 'プラグイン識別子が不足しています。',
'already_installed' => 'プラグイン ":plugin" は既にインストールされています。',
'install_failed' => 'プラグインのインストールに失敗しました。',
'install_failed' => 'プラグイン インストールに失敗しました: :error',
],
'validation' => [
'plugin_name_required' => 'プラグイン名は必須です。',
'plugin_name_string' => 'プラグイン名は文字列である必要があります。',
'plugin_name_max' => 'プラグイン名は最大 :max 文字まで入力できます。',
'plugin_name_max' => 'プラグイン名は最大 :max 文字までです。',
'name_required' => 'プラグイン名は必須です。',
'name_string' => 'プラグイン名は文字列である必要があります。',
'name_max' => 'プラグイン名は最大 255 文字まで入力できます。',
'search_max' => '検索キーワードは最大 255 文字まで入力できます。',
'name_max' => 'プラグイン名は最大 255 文字までです。',
'search_max' => '検索ワードは最大 255 文字までです。',
'filters_max' => '検索条件は最大 10 個まで設定できます。',
'filter_field_required' => '検索フィールドは必須です。',
'filter_field_invalid' => '無効な検索フィールドです。',
'filter_value_required' => '検索値は必須です。',
'filter_value_max' => '検索値は最大 255 文字まで入力できます。',
'filter_value_max' => '検索値は最大 255 文字までです。',
'filter_operator_invalid' => '無効な検索演算子です。',
'status_invalid' => '無効なステータス値です。',
'per_page_min' => 'ページあたりの項目数は最小 1 個以上である必要があります。',
@@ -130,42 +132,42 @@ return [
'status' => 'ステータス',
],
'status' => [
'active' => '有効',
'inactive' => '無効',
'active' => '有効化',
'inactive' => '無効化',
'uninstalled' => '未インストール',
],
'no_plugins' => 'プラグインがありません。',
'summary' => '合計 :total 個のプラグイン (インストール済み: :installed、有効: :active)',
'summary' => '合計 :total 個のプラグイン (インストール済み: :installed、有効化: :active)',
'invalid_status' => '無効なステータスです。(installed、uninstalled、active、inactive)',
],
'install' => [
'success' => 'プラグイン ":plugin" のインストールが完了しました',
'success' => 'プラグイン ":plugin" インストール完了',
'vendor' => 'ベンダー: :vendor',
'version' => 'バージョン: :version',
'roles_created' => ':count 個のロールが作成されました',
'permissions_created' => ':count 個の権限が作成されました',
'already_installed' => 'プラグイン ":plugin" は既にインストールされています。',
'force_reinstall' => 'プラグイン ":plugin" を強制的に再インストールします (有効ディレクトリを上書き)...',
'force_reinstall' => 'プラグイン ":plugin" を強制再インストールします (有効化ディレクトリを上書き)...',
],
'activate' => [
'success' => 'プラグイン ":plugin" の有効化が完了しました',
'success' => 'プラグイン ":plugin" 有効化完了',
'not_installed' => 'プラグイン ":plugin" はインストールされていません。',
'already_active' => 'プラグイン ":plugin" は既に有効化されています。',
'layouts_registered' => ':count 個のレイアウトが登録されました',
],
'deactivate' => [
'success' => 'プラグイン ":plugin" の無効化が完了しました',
'success' => 'プラグイン ":plugin" 無効化完了',
'not_installed' => 'プラグイン ":plugin" はインストールされていません。',
'not_active' => 'プラグイン ":plugin" は有効化されていません。',
'warning' => 'プラグインのルートと機能が無効化されました。',
'layouts_deleted' => ':count 個のレイアウトが削除されました',
],
'uninstall' => [
'success' => 'プラグイン ":plugin" の削除が完了しました',
'success' => 'プラグイン ":plugin" 削除完了',
'roles_deleted' => ':count 個のロールが削除されました',
'permissions_deleted' => ':count 個の権限が削除されました',
'layouts_deleted' => ':count 個のレイアウトが削除されました',
'confirm_prompt' => 'プラグイン ":plugin" を削除しますか?',
'confirm_prompt' => 'プラグイン ":plugin" を削除してもよろしいですか?',
'confirm_details' => [
'roles' => '- :count 個のロールが削除されます。',
'permissions' => '- :count 個の権限が削除されます。',
@@ -173,18 +175,18 @@ return [
'data' => '- すべてのプラグインデータが削除されます。',
],
'confirm_question' => '本当に削除してもよろしいですか?',
'aborted' => 'プラグインの削除がキャンセルされました。',
'aborted' => 'プラグイン削除がキャンセルされました。',
'not_installed' => 'プラグイン ":plugin" はインストールされていません。',
],
'cache_clear' => [
'clearing_all' => 'すべてのプラグインキャッシュを削除します...',
'clearing_single' => 'プラグイン ":plugin" のキャッシュを削除します...',
'success_all' => 'プラグインキャッシュの削除が完了しました (:count 個の項目)',
'success_single' => 'プラグイン ":plugin" のキャッシュ削除が完了しました (:count 個の項目)',
'clearing_single' => 'プラグイン ":plugin" キャッシュを削除します...',
'success_all' => 'プラグインキャッシュ削除完了 (:count 個の項目)',
'success_single' => 'プラグイン ":plugin" キャッシュ削除完了 (:count 個の項目)',
],
'check_updates' => [
'not_installed' => 'プラグイン ":plugin" はインストールされていません。',
'no_installed' => 'インストール済みのプラグインがありません。',
'no_installed' => 'インストールされたプラグインがありません。',
'update_available' => 'アップデート利用可能',
'up_to_date' => '最新',
'headers' => [
@@ -194,38 +196,36 @@ return [
'source' => 'ソース',
'status' => 'ステータス',
],
'summary' => '合計 :total個を確認しました(アップデート可能: :updates個)',
'single_up_to_date' => 'プラグイン":plugin"は最新バージョンです。(v:version)',
'single_update_available' => 'プラグイン":plugin"がアップデート可能です: v:current → v:latest(:source)',
'summary' => '合計 :total個を確認しました (アップデート可能: :updates個)',
'single_up_to_date' => 'プラグイン ":plugin" は最新バージョンです。(v:version)',
'single_update_available' => 'プラグイン ":plugin" のアップデートが利用可能です: v:current → v:latest (:source)',
],
'update' => [
'success' => 'プラグイン":plugin"のアップデートが完了しました',
'success' => 'プラグイン ":plugin" のアップデートが完了しました',
'version_change' => 'v:from → v:to',
'no_update' => 'プラグイン":plugin"に利用可能なアップデートがありません。',
'not_installed' => 'プラグイン":plugin"はインストールされていません。',
'no_update' => 'プラグイン ":plugin" に利用可能なアップデートはありません。',
'not_installed' => 'プラグイン ":plugin" はインストールされていません。',
'current_version' => '現在のバージョン: :version',
'latest_version' => '最新バージョン: :version',
'update_source' => 'アップデートソース: :source',
'confirm_question' => 'アップデートを実行しますか?',
'confirm_question' => 'アップデートを実行しますか?',
'aborted' => 'アップデートがキャンセルされました。',
'backup_restored' => 'バックアップから前のバージョンが復元されました。',
'force_mode' => '強制アップデートモード: バージョン比較をスキップして再インストールします。',
'layout_strategy' => 'レイアウト戦略: :strategy',
'modified_extensions_warning' => '管理者が編集したレイアウト拡張 :count件が上書きされます。',
'modified_extension_item' => ' - :target (出典: :source)',
'modified_extension_item' => ' - :target (ソース: :source)',
],
],
'composer_install' => [
'start' => 'プラグイン":plugin"のComposer依存関係をインストール中です...',
'success' => 'プラグイン":plugin"のComposer依存関係のインストールが完了しました',
'no_dependencies' => 'プラグイン":plugin"にインストールするComposer依存関係がありません。',
'failed' => 'プラグイン":plugin"のComposer依存関係のインストールに失敗しました。',
'start' => 'プラグイン ":plugin" の Composer 依存関係をインストールしています...',
'success' => 'プラグイン ":plugin" の Composer 依存関係のインストールが完了しました',
'no_dependencies' => 'プラグイン ":plugin" にインストールする Composer 依存関係はありません。',
'failed' => 'プラグイン ":plugin" の Composer 依存関係のインストールに失敗しました。',
'summary' => '📊 結果: 成功 :success個、スキップ :skip個、失敗 :fail個',
],
'settings' => [
'update_failed' => 'プラグイン設定のアップデートに失敗しました。',
'update_failed' => 'プラグイン設定のアップデートに失敗しました: :error',
'updated' => 'プラグイン設定が正常にアップデートされました。',
],
'dependent_templates_success' => '依存テンプレート情報を正常に取得しました。',
'dependent_templates_failed' => '依存テンプレート情報の取得に失敗しました: :error',
];
@@ -14,7 +14,7 @@ return [
'name_required' => 'ロール名は必須です。',
'identifier_required' => '識別子は必須です。',
'identifier_format' => '識別子は小文字で始まり、小文字、数字、アンダースコア(_)のみ使用できます。',
'identifier_unique' => '既に使用中の識別子です。',
'identifier_unique' => 'すでに使用中の識別子です。',
'identifier_max' => '識別子は最大100文字まで入力できます。',
'permission_ids_array' => '権限リストは配列形式である必要があります。',
'permission_ids_exists' => '選択した権限の中に無効な権限があります。',
@@ -1,36 +1,39 @@
<?php
return [
'fetch_success' => 'スケジュール一覧を取得しました。',
'fetch_failed' => 'スケジュール一覧の取得に失敗しました。',
'fetch_success' => 'スケジュールリストを閲覧しました。',
'fetch_failed' => 'スケジュールリスト閲覧に失敗しました。',
'create_success' => 'スケジュールが作成されました。',
'create_failed' => 'スケジュールの作成に失敗しました。',
'create_failed' => 'スケジュール作成に失敗しました。',
'update_success' => 'スケジュールが編集されました。',
'update_failed' => 'スケジュールの編集に失敗しました。',
'update_failed' => 'スケジュール編集に失敗しました。',
'delete_success' => 'スケジュールが削除されました。',
'delete_failed' => 'スケジュールの削除に失敗しました。',
'delete_failed' => 'スケジュール削除に失敗しました。',
'run_success' => 'スケジュールが実行されました。',
'run_failed' => 'スケジュールの実行に失敗しました。',
'run_failed' => 'スケジュール実行に失敗しました。',
'duplicate_success' => 'スケジュールが複製されました。',
'duplicate_failed' => 'スケジュールの複製に失敗しました。',
'duplicate_failed' => 'スケジュール複製に失敗しました。',
'bulk_status_updated' => '選択されたスケジュールのステータスが変更されました。',
'bulk_update_status_failed' => 'スケジュール一括ステータス変更に失敗しました。',
'bulk_update_status_failed' => 'スケジュールステータス一括変更に失敗しました。',
'bulk_delete_success' => '選択されたスケジュールが削除されました。',
'bulk_delete_failed' => 'スケジュール一括削除に失敗しました。',
'statistics_success' => 'スケジュール統計を取得しました。',
'statistics_failed' => 'スケジュール統計の取得に失敗しました。',
'history_fetch_success' => '実行履歴を取得しました。',
'history_fetch_failed' => '実行履歴の取得に失敗しました。',
'statistics_success' => 'スケジュール統計を閲覧しました。',
'statistics_failed' => 'スケジュール統計閲覧に失敗しました。',
'history_fetch_success' => '実行履歴を閲覧しました。',
'history_fetch_failed' => '実行履歴閲覧に失敗しました。',
'history_delete_success' => '実行履歴が削除されました。',
'history_delete_failed' => '実行履歴の削除に失敗しました。',
'history_delete_failed' => '実行履歴削除に失敗しました。',
'history_not_found' => '実行履歴が見つかりません。',
'copy' => 'コピー',
'shell_command_failed' => 'シェルコマンドの実行に失敗しました。',
'http_request_failed' => 'HTTPリクエスト失敗(ステータス::status)',
'shell_command_failed' => 'シェルコマンド実行に失敗しました。',
'http_request_failed' => 'HTTP リクエスト失敗(ステータス::status)',
'url_not_public' => '内部ネットワークアドレス(プライベート IP·localhost など)は呼び出せません。社内アドレスを呼び出す必要がある場合は、環境設定 > セキュリティで「内部ネットワークアドレス呼び出し許可」をオンにしてください。',
'shell_not_allowed' => '許可されていないシェルコマンドです。サーバーに登録された実行ファイルのみスケジュール実行できます。',
'artisan_not_allowed' => 'この Artisan コマンドはセキュリティ上の理由からスケジュール実行できません。',
'type' => [
'artisan' => 'Artisanコマンド',
'artisan' => 'Artisan コマンド',
'shell' => 'シェルコマンド',
'url' => 'URL呼び出し',
'url' => 'URL 呼び出し',
],
'frequency' => [
'everyMinute' => '毎分',
@@ -47,7 +50,7 @@ return [
'never' => '未実行',
],
'trigger_type' => [
'scheduled' => 'スケジュール実行',
'scheduled' => '予約実行',
'manual' => '手動実行',
],
'duration' => [
@@ -56,51 +59,48 @@ return [
],
'validation' => [
'page_integer' => 'ページ番号は整数である必要があります。',
'page_min' => 'ページ番号は1以上である必要があります。',
'page_min' => 'ページ番号は 1 以上である必要があります。',
'per_page_integer' => 'ページあたりの項目数は整数である必要があります。',
'per_page_min' => 'ページあたりの項目数は1以上である必要があります。',
'per_page_max' => 'ページあたりの項目数は100以下である必要があります。',
'per_page_min' => 'ページあたりの項目数は 1 以上である必要があります。',
'per_page_max' => 'ページあたりの項目数は 100 以下である必要があります。',
'type_invalid' => '無効なタスクタイプです。',
'frequency_invalid' => '無効な実行周期です。',
'status_invalid' => '無効なステータスです。',
'last_result_invalid' => '無効な実行結果です。',
'history_status_invalid' => '無効な履歴ステータスです。',
'trigger_type_invalid' => '無効なトリガータイプです。',
'created_from_invalid' => '開始日のフォーマットが正しくありません。',
'created_to_invalid' => '終了日のフォーマットが正しくありません。',
'created_from_invalid' => '開始日の形式が正しくありません。',
'created_to_invalid' => '終了日の形式が正しくありません。',
'created_to_after_from' => '終了日は開始日以降である必要があります。',
'started_from_invalid' => '開始日のフォーマットが正しくありません。',
'started_to_invalid' => '終了日のフォーマットが正しくありません。',
'started_from_invalid' => '開始日の形式が正しくありません。',
'started_to_invalid' => '終了日の形式が正しくありません。',
'started_to_after_from' => '終了日は開始日以降である必要があります。',
'sort_by_invalid' => '無効なソート基準です。',
'sort_order_invalid' => '無効なソート順序です。',
'name_required' => 'タスク名は必須です。',
'name_max' => 'タスク名は255文字以下である必要があります。',
'name_max' => 'タスク名は 255 文字以下である必要があります。',
'type_required' => 'タスクタイプは必須です。',
'command_required' => 'コマンドは必須です。',
'command_max' => 'コマンドは2000文字以下である必要があります。',
'expression_required' => 'Cron式は必須です。',
'expression_max' => 'Cron式は100文字以下である必要があります。',
'command_max' => 'コマンドは 2000 文字以下である必要があります。',
'expression_required' => 'Cron 式は必須です。',
'expression_max' => 'Cron 式は 100 文字以下である必要があります。',
'frequency_required' => '実行周期は必須です。',
'timeout_integer' => 'タイムアウト制限は整数である必要があります。',
'timeout_min' => 'タイムアウト制限は1秒以上である必要があります。',
'timeout_max' => 'タイムアウト制限は86400秒(24時間)以下である必要があります。',
'ids_required' => 'IDリストは必須です。',
'ids_array' => 'IDリストは配列である必要があります。',
'ids_min' => '最低1つ以上のIDが必要です。',
'id_integer' => 'IDは整数である必要があります。',
'id_exists' => '存在しないスケジュールです。',
'is_active_required' => '有効化は必須です。',
'is_active_boolean' => '有効化はtrue/falseである必要があります。',
'timeout_integer' => 'タイムアウトは整数である必要があります。',
'timeout_min' => 'タイムアウトは 1 秒以上である必要があります。',
'timeout_max' => 'タイムアウトは 86400 秒(24 時間)以下である必要があります。',
'ids_required' => 'ID リストは必須です。',
'ids_array' => 'ID リストは配列である必要があります。',
'ids_min' => '最小 1 個以上の ID が必要です。',
'id_integer' => 'ID は整数である必要があります。',
'id_exists' => 'スケジュールが存在しません。',
'is_active_required' => '有効化の有無は必須です。',
'is_active_boolean' => '有効化の有無は真/偽である必要があります。',
],
'permissions' => [
'read' => 'スケジュール調査',
'read' => 'スケジュール閲覧',
'create' => 'スケジュール作成',
'update' => 'スケジュール編集',
'delete' => 'スケジュール削除',
'run' => 'スケジュール実行',
],
'url_not_public' => '内部ネットワークアドレス(プライベートIP·localhost等)は呼び出せません。社内アドレスを呼び出す必要がある場合は、環境設定 > セキュリティで「内部ネットワークアドレス呼び出し許可」をオンにしてください。',
'shell_not_allowed' => '許可されていないシェルコマンドです。サーバーに登録された実行ファイルのみスケジュールで実行できます。',
'artisan_not_allowed' => 'このArtisanコマンドはセキュリティ上の理由によりスケジュールで実行できません。',
];
@@ -6,7 +6,7 @@ return [
'no_results' => '検索結果がありません。',
'view_more' => 'もっと見る',
'validation' => [
'q_min' => '検索語は2文字以上入力してください。',
'q_min' => '検索語は2文字以上で入力してください。',
'q_max' => '検索語は最大200文字まで入力可能です。',
'page_integer' => 'ページ番号は数字である必要があります。',
'page_min' => 'ページ番号は1以上である必要があります。',
@@ -1,21 +1,24 @@
<?php
return [
'cache_cleared_all' => 'SEOキャッシュがすべて削除されました。',
'cache_cleared_layout' => ':layout レイアウトのSEOキャッシュ :count件が削除されました。',
'warmup_started' => 'SEOキャッシュウォーミングを開始します。',
'warmup_started_layout' => ':layout レイアウトのSEOキャッシュウォーミングを開始します。',
'warmup_dispatched' => 'SEOキャッシュウォーミングが開始されました。',
'sitemap_regenerated' => 'サイトマップの再生成が完了しました。',
'sitemap_regenerate_failed' => 'サイトマップの再生成に失敗しました。',
'sitemap_disabled' => 'サイトマップ生成が無効化されています。',
'stats_period' => '最近 :days 日間のSEOキャッシュ統計',
'cache_cleared_all' => 'SEO キャッシュがすべて削除されました。',
'cache_cleared_layout' => ':layout レイアウトの SEO キャッシュ :count 件が削除されました。',
'warmup_started' => 'SEO キャッシュ ウォーミングアップを開始します。',
'warmup_started_layout' => ':layout レイアウトの SEO キャッシュ ウォーミングアップを開始します。',
'warmup_dispatched' => 'SEO キャッシュ ウォーミングアップが開始されました。',
'sitemap_regenerated' => 'Sitemap 再生成が完了しました。',
'sitemap_regenerate_dispatched' => 'Sitemap 再生成を開始しました。完了まで時間がかかる場合があります。',
'sitemap_regenerate_failed' => 'Sitemap 再生成に失敗しました。',
'sitemap_disabled' => 'Sitemap 生成が無効化されています。',
'sitemap_not_ready' => 'Sitemap を準備中です。しばらく後に再度お試しください。',
'sitemap_generate_failed' => 'Sitemap 生成に失敗しました: :error',
'stats_period' => '最近 :days 日間の SEO キャッシュ統計',
'stats_overall' => '全体統計',
'stats_metric' => '項目',
'stats_value' => '値',
'stats_total_entries' => '総キャッシュ項目',
'stats_hits' => 'キャッシュヒット',
'stats_misses' => 'キャッシュミス',
'stats_hits' => 'キャッシュ ヒット',
'stats_misses' => 'キャッシュ ミス',
'stats_hit_rate' => 'ヒット率',
'stats_avg_response_time' => '平均応答時間',
'stats_by_layout' => 'レイアウト別統計',
@@ -5,12 +5,12 @@ return [
'fetch_failed' => '設定のインポートに失敗しました。',
'save_success' => '設定が正常に保存されました。',
'save_failed' => '設定の保存に失敗しました。',
'update_success' => '設定が正常に更新されました。',
'update_failed' => '設定の更新に失敗しました。',
'update_success' => '設定が正常にアップデートされました。',
'update_failed' => '設定のアップデートに失敗しました。',
'delete_success' => '設定が正常に削除されました。',
'delete_failed' => '設定の削除に失敗しました。',
'save_error' => '設定の保存中にエラーが発生しました。',
'update_error' => '設定の更新中にエラーが発生しました。',
'update_error' => '設定のアップデート中にエラーが発生しました。',
'cache_clear_success' => 'キャッシュが正常にクリアされました。',
'cache_clear_failed' => 'キャッシュのクリアに失敗しました。',
'cache_clear_error' => 'キャッシュのクリア中にエラーが発生しました。',
@@ -20,7 +20,11 @@ return [
'backup_success' => 'データベースバックアップが正常に開始されました。',
'backup_failed' => 'データベースバックアップに失敗しました。',
'backup_error' => 'データベースバックアップ中にエラーが発生しました。',
'save_individual_failed' => '設定の保存に失敗しました::error',
'backup_path_required' => 'バックアップパスを入力してください。',
'restore_success' => '設定が正常に復元されました。',
'restore_failed' => '設定の復元に失敗しました。',
'restore_error' => '設定の復元中にエラーが発生しました。',
'save_individual_failed' => '設定の保存に失敗しました: :error',
'invalid_password' => 'パスワードが一致しません。',
'password_required' => 'パスワードを入力してください。',
'app_key_regenerated' => 'アプリケーションキーが正常に再生成されました。',
@@ -59,10 +63,10 @@ return [
'ses' => 'SES (Amazon)',
],
],
'driver_test_success' => 'すべてのドライバー接続テストが成功しました。',
'driver_test_partial' => '一部のドライバー接続テストが失敗しました。',
'driver_test_error' => 'ドライバー接続テスト中にエラーが発生しました。',
'unknown_driver' => '不明なドライバーです。',
'driver_test_success' => 'すべてのドライバ接続テストが成功しました。',
'driver_test_partial' => '一部のドライバ接続テストが失敗しました。',
'driver_test_error' => 'ドライバ接続テスト中にエラーが発生しました。',
'unknown_driver' => '不明なドライバです。',
's3_test_success' => 'S3バケットに正常に接続されました。',
's3_test_failed' => 'S3バケットへの接続に失敗しました。',
's3_missing_config' => 'S3設定が不足しています。(バケット、リージョン、アクセスキー、シークレットキー)',
@@ -82,10 +86,11 @@ return [
'memcached_connection_failed' => 'Memcachedサーバーに接続できません。',
'websocket_test_success' => 'Websocketサーバーに正常に接続されました。',
'websocket_test_failed' => 'Websocketサーバーへの接続に失敗しました。',
'websocket_connection_refused' => 'Websocketサーバーに接続できません。サーバーが実行されているか確認してください。',
'websocket_invalid_host' => 'Websocketホスト設定が正しくありません。ホストにはアドレスのみを入力し、アカウント情報(@)またはhttp/https以外のプロトコルは使用できません。',
'websocket_connection_refused' => 'Websocketサーバーに接続できません。サーバーが実行中であるか確認してください。',
'invalid_email' => '無効なメールアドレスです。',
'test_mail_subject' => ':app_name テストメール',
'test_mail_body' => 'これはグヌボード7から送信されたテストメールです。このメールを受け取った場合、メール設定が正しく構成されています。',
'test_mail_body' => 'これはGnuboard 7から送信されたテストメールです。このメールを受け取られた場合、メール設定が正しく構成されています。',
'test_mail_sent' => 'テストメールが正常に送信されました。',
'test_mail_failed' => 'テストメールの送信に失敗しました。',
'test_mail_error' => 'テストメール送信中にエラーが発生しました。',
@@ -93,65 +98,60 @@ return [
'check_success' => 'アップデート確認が完了しました。',
'check_failed' => 'アップデート確認に失敗しました。',
'update_available' => '新しいアップデートがあります。',
'no_update' => '現在は最新バージョンです。',
'maintenance_mode_active' => 'システム保守中です。しばらく後に再度お試しください。',
'no_update' => '現在、最新バージョンです。',
'maintenance_mode_active' => 'システムメンテナンス中です。しばらく後に再度お試しください。',
'invalid_github_url' => 'GitHubリポジトリURLが無効です。',
'download_failed' => ':version バージョンのダウンロードに失敗しました。',
'zip_extract_failed' => 'ZIPファイルの解凍に失敗しました。',
'zip_extract_failed' => 'ZIPファイルの展開に失敗しました。',
'invalid_package' => 'ダウンロードされたパッケージが無効です。',
'invalid_package_not_g7' => '指定されたディレクトリはグヌボード7プロジェクトではありません。config/app.phpファイルとversionの設定が必要です。',
'composer_failed' => 'composer installの実行に失敗しました。',
'pending_path_create_failed' => '_pendingディレクトリ(:path)の作成に失敗しました::error',
'pending_path_not_writable' => '_pendingディレクトリ(:path)への書き込み権限がありません。',
'invalid_package_not_g7' => '指定されたディレクトリはGnuboard 7プロジェクトではありません。config/app.phpファイルとversion設定が必要です。',
'composer_failed' => 'composer install の実行に失敗しました。',
'pending_path_create_failed' => '_pending ディレクトリ(:path) の作成に失敗しました: :error',
'pending_path_not_writable' => '_pending ディレクトリ(:path) への書き込み権限がありません。',
'downloading' => 'アップデートをダウンロード中...',
'extracting' => '解凍中...',
'validating' => 'パッケージを検証中...',
'running_composer' => 'composer installを実行中...',
'running_composer' => 'composer install を実行中...',
'step_check' => 'アップデートを確認中...',
'step_validate_pending' => '環境を検証中...',
'step_maintenance' => 'メンテナンスモードを有効化中...',
'step_download' => 'ダウンロード中...',
'step_backup' => 'バックアップを作成中...',
'step_apply' => 'ファイルを適用中...',
'step_composer' => 'composer installを実行中...',
'step_migration' => 'マイグレーションを実行中...',
'step_composer' => 'composer install 中...',
'step_migration' => 'マイグレーション を実行中...',
'step_upgrade' => 'アップグレードステップを実行中...',
'step_composer_prod' => '運用ディレクトリのcomposer installを実行中...',
'step_composer_prod' => '運用ディレクトリの composer install 中...',
'step_cleanup' => 'クリーンアップ中...',
'github_url_not_configured' => 'GitHubリポジトリのURLが設定されていません。',
'github_api_failed' => 'GitHub APIに接続できません。',
'github_token_required' => 'プライベートリポジトリです。GitHubアクセストークンを設定してください。',
'github_token_invalid' => 'GitHubアクセストークンが無効であるか、権限が不足しています。(HTTP :status — :message)',
'github_repo_not_found' => 'GitHubリポジトリが見つかりません。(HTTP :status — :message)',
'github_repo_not_found_no_token' => 'GitHubリポジトリが見つかりません。プライベートリポジトリの場合はアクセストークンを設定してください。(HTTP :status — :message)',
'github_api_error' => 'GitHub APIエラーが発生しました。(HTTP :status — :message)',
'no_releases_found' => 'GitHubリポジトリにリリースがありません。(HTTP :status — :message)',
'log_api_call_failed' => 'コアアップデート:GitHub APIの呼び出しに失敗しました',
'log_auth_failed' => 'コアアップデート:GitHub認証に失敗しました',
'log_not_found' => 'コアアップデート:GitHubリポジトリ/リリースが見つかりません',
'log_unexpected_status' => 'コアアップデート:予期しないHTTPステータスコード',
'log_version_check_error' => 'コアアップデート:最新バージョン確認中にエラーが発生しました',
'unknown_error' => '予期しないエラーが発生しました。',
'apply_mode_incremental' => 'ファイル適用: コアが実際に変更したファイルのみを適用しました (新規 :added 個、変更 :changed 個)。その他のファイルは現在の状態のまま保持されました。',
'apply_mode_incremental_prune_hint' => '新バージョンで削除されたファイルをあわせてクリーンアップするには、`--prune` オプションで再実行するか、`php artisan hotfix:rollback-stale-files --prune` を使用してください。',
'apply_mode_prune' => 'ファイル適用: 全体上書き + 削除ファイルのクリーンアップを実行しました (--prune)。',
'apply_mode_fallback' => 'ファイル適用: バックアップがないため、増分適用をスキップして全体上書きを実行しました。ユーザーのカスタムファイル保持をご希望の場合は、次回以降はバックアップを保持したままアップデートしてください。',
'github_url_not_configured' => 'GitHub リポジトリ URL が設定されていません。',
'github_api_failed' => 'GitHub API に接続できません。',
'github_token_required' => 'プライベートリポジトリです。GitHub アクセストークンを設定してください。',
'github_token_invalid' => 'GitHub アクセストークンが無効であるか、権限が不足しています。(HTTP :status — :message)',
'github_repo_not_found' => 'GitHub リポジトリが見つかりません。(HTTP :status — :message)',
'github_repo_not_found_no_token' => 'GitHub リポジトリが見つかりません。プライベートリポジトリの場合はアクセストークンを設定してください。(HTTP :status — :message)',
'github_api_error' => 'GitHub API エラーが発生しました。(HTTP :status — :message)',
'no_releases_found' => 'GitHub リポジトリにリリースがありません。(HTTP :status — :message)',
'log_api_call_failed' => 'コアアップデート: GitHub API 呼び出し失敗',
'log_auth_failed' => 'コアアップデート: GitHub 認証失敗',
'log_not_found' => 'コアアップデート: GitHub リポジトリ/リリース未検出',
'log_unexpected_status' => 'コアアップデート: 予期しない HTTP ステータスコード',
'log_version_check_error' => 'コアアップデート: 最新バージョン確認時にエラーが発生',
'unknown_error' => '不明なエラーが発生しました。',
'system_requirements_failed' => 'システム要件が満たされていません:',
'no_extract_method_available' => '利用可能なアーカイブ抽出方法がありません。PHP zip拡張機能(ZipArchive)またはunzipコマンド(Linux)のいずれかが必要です。または--sourceオプションで手動アップデートできます。',
'manual_update_guide' => '手動アップデート:GitHubからリリースZIPをダウンロードして解凍し、php artisan core:update --source=/解凍パス コマンドを実行してください。',
'archive_url_not_found' => ':typeアーカイブURLが見つかりません。次の方法を試みます。',
'extracting_with' => ':method方式で解凍中...',
'no_extract_method_available' => '利用可能なアーカイブ抽出方法がありません。PHP zip 拡張 (ZipArchive) または unzip コマンド (Linux) のいずれかが必要です。または --source オプションで手動アップデートできます。',
'manual_update_guide' => '手動アップデート: GitHub からリリース ZIP をダウンロードして解凍後、php artisan core:update --source=/解凍/パス コマンドを実行してください。',
'archive_url_not_found' => ':type アーカイブ URL が見つかりません。次の方法を試します。',
'extracting_with' => ':method 方式で解凍中...',
'extract_empty' => '解凍されたディレクトリが空です。',
'extract_fallback' => ':method方式に失敗しました::error — 次の方法を試みます。',
'all_extract_methods_failed' => 'すべてのアーカイブ抽出方法が失敗しました。--sourceオプションで手動アップデートを試みてください。',
'unzip_command_failed' => 'unzipコマンド実行に失敗しました(終了コード::code)。:output',
'zip_file_not_found' => '指定されたZIPファイルが見つかりません::path',
'source_vendor_missing' => 'ソースディレクトリにvendorがありません。composer installが実行されていない可能性があります。',
'composer_failed_with_output' => 'composer installの実行に失敗しました。:output',
'apply_mode_incremental' => 'ファイル適用: コアが実際に変更したファイルのみ適用しました (新規 :added個、変更 :changed個)。その他のファイルは現在の状態のまま保存されました。',
'apply_mode_incremental_prune_hint' => '新しいバージョンで削除されたファイルを一緒に整理する場合は、`--prune` オプションで再実行するか、`php artisan hotfix:rollback-stale-files --prune` を使用してください。',
'apply_mode_prune' => 'ファイル適用: 完全な上書き + 削除されたファイルの整理を実行しました (--prune)。',
'apply_mode_fallback' => 'ファイル適用: バックアップがないため増分適用をスキップし、完全な上書きを実行しました。ユーザーカスタムファイルの保存を希望する場合は、次回からはバックアップを維持したまま更新してください。',
'extract_fallback' => ':method 方式失敗: :error — 次の方法を試します。',
'all_extract_methods_failed' => 'すべてのアーカイブ抽出方法が失敗しました。--source オプションで手動アップデートを試してください。',
'unzip_command_failed' => 'unzip コマンド実行失敗 (終了コード: :code)。:output',
'zip_file_not_found' => '指定された ZIP ファイルが見つかりません: :path',
'source_vendor_missing' => 'ソースディレクトリに vendor がありません。composer install が実行されていない可能性があります。',
'composer_failed_with_output' => 'composer install 実行に失敗しました。:output',
],
'backup_path_required' => 'バックアップパスを入力してください。',
'restore_success' => '設定が正常に復元されました。',
'restore_failed' => '設定の復元に失敗しました。',
'restore_error' => '設定の復元中にエラーが発生しました。',
'websocket_invalid_host' => 'Websocketホスト設定が正しくありません。ホストにはアドレスのみを入力し、アカウント情報(@)やhttp/https以外のプロトコルは使用できません。',
];
@@ -4,54 +4,54 @@ return [
'title' => 'テンプレート管理',
'errors' => [
'force_update_no_source' => ':template を強制アップデートするソースが見つかりません。バンドルおよび GitHub URL の両方がありません。',
'downgrade_blocked' => 'ダウングレードがブロックされました (:from → :to)。意図的なダウングレードは --force オプションを使用してください。',
'downgrade_blocked' => 'ダウングレードがブロックされています (:from → :to)。意図的なダウングレードは --force オプションを使用してください。',
'not_found' => 'テンプレートが見つかりません。(:template)',
'not_installed' => 'テンプレートがインストールされていません。(:template)',
'already_active' => '既に有効化されているテンプレートです。',
'invalid_format' => '無効なテンプレート形式です。',
'activation_failed' => 'テンプレート有効化に失敗しました。',
'file_copy_failed' => 'ファイルコピーに失敗しました。',
'dist_directory_not_found' => 'テンプレートビルド結果物(dist)が見つかりません。',
'activation_failed' => 'テンプレートの有効化に失敗しました: :error',
'file_copy_failed' => 'ファイルのコピーに失敗しました: :error',
'dist_directory_not_found' => 'テンプレートビルド成果物(dist)が見つかりません。',
'invalid_directory_name' => '無効なディレクトリ名です。vendor-name 形式を使用してください。',
'dependency_not_met' => '必須の依存関係 :dependency (:type)が満たされていません。',
'dependency_not_met' => '必須依存関係 :dependency (:type) が満たされていません。',
'version_mismatch' => ':dependency のバージョンが一致しません。(必須: :required、インストール済み: :installed)',
'deactivation_failed' => 'テンプレート無効化に失敗しました。',
'installation_failed' => 'テンプレートインストールに失敗しました。',
'uninstallation_failed' => 'テンプレート削除に失敗しました。',
'deactivation_failed' => 'テンプレートの無効化に失敗しました: :error',
'installation_failed' => 'テンプレートのインストールに失敗しました: :error',
'uninstallation_failed' => 'テンプレートの削除に失敗しました: :error',
'template_json_not_found' => 'template.json ファイルが見つかりません。',
'template_json_invalid' => 'template.json ファイルが無効です。',
'missing_required_fields' => '必須フィールドが不足しています。',
'invalid_type' => '無効なテンプレートタイプです。(admin または user のみ許可)',
'copy_rollback_failed' => 'ファイルコピーロールバックに失敗しました。',
'copy_rollback_failed' => 'ファイルコピーのロールバックに失敗しました: :error',
'invalid_layout_json' => 'レイアウト JSON ファイルが無効です。',
'missing_layout_name' => 'レイアウト JSON に layout_name フィールドが不足しています。',
'layout_registration_failed' => 'レイアウト登録に失敗しました。',
'layout_validation_failed' => ':template テンプレートのレイアウト検証に失敗しました。(:count 個のエラー)',
'layout_registration_failed' => 'レイアウト登録に失敗しました: :error',
'layout_validation_failed' => ':template テンプレートのレイアウト検証に失敗しました。(:count 件のエラー)',
'layout_validation_json_error' => ':file - JSON パース エラー: :error',
'layout_validation_missing_name' => ':file - layout_name フィールドが不足しています。',
'layout_validation_partial_error' => ':file - Partial 処理エラー (:partial): :error',
'override_layout_registration_failed' => 'オーバーライドレイアウト登録に失敗しました。',
'override_layout_registration_failed' => 'オーバーライドレイアウト登録に失敗しました: :error',
'file_not_found' => 'ファイルが見つかりません。',
'file_type_not_allowed' => '許可されていないファイルタイプです。',
'components_not_found' => 'コンポーネント定義ファイルが見つかりません。',
'locale_not_supported' => ':template テンプレートは :locale 言語をサポートしていません。',
'language_file_not_found' => ':locale 言語ファイルが見つかりません。',
'invalid_json' => '無効な JSON 形式です。',
'invalid_cache_data' => 'キャッシュデータが破損しました。もう一度お試しください。',
'invalid_cache_data' => 'キャッシュデータが破損しています。もう一度お試しください。',
'unknown_error' => '不明なエラーが発生しました。',
'zip_open_failed' => 'ZIP ファイルを開くことができません。',
'zip_open_failed' => 'ZIP ファイルを開くことができません: :error',
'identifier_missing' => 'テンプレート識別子が不足しています。',
'already_installed' => '既にインストールされているテンプレートです。',
'install_failed' => 'テンプレートインストールに失敗しました。',
'already_installed' => '既にインストール済みのテンプレートです。',
'install_failed' => 'テンプレートのインストールに失敗しました: :error',
'github_url_invalid' => '無効な GitHub URL 形式です。',
'github_download_failed' => 'GitHub からのダウンロードに失敗しました。',
'github_download_failed' => 'GitHub からのダウンロードに失敗しました: :error',
'github_repo_not_found' => 'GitHub リポジトリが見つかりません。',
'refresh_layouts_failed' => 'レイアウト更新に失敗しました。',
'refresh_layouts_failed' => 'レイアウト更新に失敗しました: :error',
'routes_not_found' => 'ルートファイルが見つかりません。',
'no_active_template' => '有効化されているテンプレートがありません',
'layout_load_failed' => 'レイアウトの読み込みに失敗しました',
'component_load_failed' => 'コンポーネントの読み込みに失敗しました',
'translation_load_failed' => '多言語ファイルの読み込みに失敗しました',
'no_active_template' => '有効化されたテンプレートがありません',
'layout_load_failed' => 'レイアウトの読み込みに失敗しました: :error',
'component_load_failed' => 'コンポーネントの読み込みに失敗しました: :error',
'translation_load_failed' => '多言語ファイルの読み込みに失敗しました: :error',
'template_init_error' => 'テンプレートエンジンの初期化中にエラーが発生しました',
'contact_admin_for_template' => 'システム管理者に問い合わせてテンプレートを有効化してください。',
'missing_error_config' => 'テンプレートに error_config セクションがありません。エラーページレイアウト設定が必要です。',
@@ -61,17 +61,17 @@ return [
'template_not_active' => 'テンプレートが有効化されていません: :identifier',
'dependencies_not_met' => 'テンプレートの必須依存関係が満たされていません。',
'service_unavailable' => 'サービスは一時的に利用できません。',
'contact_admin' => '管理者にお問い合わせください。',
'operation_in_progress' => 'テンプレート「:name」は現在 :status 状態です。処理が完了するまでお待ちください。',
'github_api_failed' => 'GitHub API 呼び出しに失敗しました。',
'contact_admin' => '管理者に問い合わせてください。',
'operation_in_progress' => 'テンプレート ":name" は現在 :status 状態です。処理が完了するまでお待ちください。',
'github_api_failed' => 'GitHub API 呼び出しに失敗しました: :error',
'invalid_github_url' => '無効な GitHub URL です。',
'zip_url_not_found' => 'ZIP ダウンロード URL が見つかりません。',
'download_failed' => 'テンプレート「:template」:version バージョンのダウンロードに失敗しました。',
'zip_extract_failed' => 'ZIP 解凍に失敗しました。',
'extracted_dir_not_found' => '解凍されたディレクトリが見つかりません。',
'reload_failed' => 'テンプレート再読み込みに失敗しました。',
'delete_directory_failed' => 'テンプレートディレクトリの削除に失敗しました。',
'update_failed' => 'テンプレート「:template」のアップデートに失敗しました: :error',
'download_failed' => 'テンプレート ":template" :version バージョンのダウンロードに失敗しました: :error',
'zip_extract_failed' => 'ZIP 圧縮解除に失敗しました: :error',
'extracted_dir_not_found' => '圧縮解除されたディレクトリが見つかりません。',
'reload_failed' => 'テンプレート再読み込みに失敗しました: :error',
'delete_directory_failed' => 'テンプレートディレクトリの削除に失敗しました: :error',
'update_failed' => 'テンプレート ":template" のアップデートに失敗しました: :error',
'invalid_layout_strategy' => '無効なレイアウト戦略です。(overwrite または keep のみ許可)',
],
'messages' => [
@@ -79,43 +79,43 @@ return [
'deactivated' => 'テンプレートが無効化されました。',
'installed' => 'テンプレートがインストールされました。',
'uninstalled' => 'テンプレートが削除されました。',
'previous_template_deactivated' => '前のアクティブテンプレートが無効化されました。',
'previous_template_deactivated' => '前の有効なテンプレートが無効化されました。',
'files_copied' => 'テンプレートファイルがコピーされました。',
'files_removed' => 'テンプレートファイルが削除されました。',
'routes_retrieved' => 'テンプレートルート情報を取得しました。',
'routes_retrieved' => 'テンプレートルート情報が照会されました。',
'routes_not_found' => 'テンプレートルートが見つかりません。',
'layout_served' => 'レイアウトが提供されました。',
'config_retrieved' => 'テンプレート設定情報を照会しました。',
'config_retrieved' => 'テンプレート設定情報を閲覧しました。',
'template_activated' => 'テンプレートが有効化されました。',
'template_deactivated' => 'テンプレートが無効化されました。',
'template_uninstalled' => 'テンプレートが削除されました。',
'editor_spec_empty' => 'エディタースペックが空です。',
'editor_assets_retrieved' => 'エディターアセット情報を取得しました。',
'editor_assets_missing' => 'エディターアセットマニフェストがありません(ビルドされていません)。',
'language_retrieved' => '多言語データを取得しました。',
'editor_spec_retrieved' => 'エディタースペックを閲覧しました。',
'editor_assets_retrieved' => 'エディター資産情報を閲覧しました。',
'editor_assets_missing' => 'エディター資産マニフェストがありません(ビルドされていません)。',
'language_retrieved' => '多言語データを閲覧しました。',
'language_empty' => '多言語データが空です。',
'editor_spec_retrieved' => 'エディタースペックを取得しました。',
],
'not_found' => 'テンプレート :template が見つかりません。',
'layout_not_found' => 'レイアウトが見つかりません。',
'not_found' => 'テンプレート :template を見つけることができません。',
'layout_not_found' => 'レイアウトを見つけることができません。',
'info' => [
'scanning' => 'テンプレートディレクトリをスキャン中です。',
'loading' => 'テンプレートを読み込み中です。',
'validating_dependencies' => '依存関係を検証中です。',
'copying_files' => 'ファイルをコピー中です。',
'removing_files' => 'ファイルを削除中です。',
'template_found' => 'テンプレートが見つかりました。',
'no_templates_found' => 'テンプレートが見つかりませんでした。',
'public_directory_cleaned' => 'publicディレクトリが整理されました。',
'scanning' => 'テンプレートディレクトリをスキャン中...',
'loading' => 'テンプレートをロード中...',
'validating_dependencies' => '依存性を検証中...',
'copying_files' => 'ファイルをコピー中...',
'removing_files' => 'ファイルを削除中...',
'template_found' => 'テンプレートを発見しました。',
'no_templates_found' => 'テンプレートを発見できませんでした。',
'public_directory_cleaned' => 'public ディレクトリがクリーンアップされました。',
'no_layouts_directory' => 'レイアウトディレクトリが存在しません。',
'no_layout_files' => 'レイアウトJSONファイルが存在しません。',
'no_layout_files' => 'レイアウト JSON ファイルが存在しません。',
'layout_registered' => 'レイアウトが登録されました。',
'layouts_deleted' => 'レイアウトが削除されました。',
'cache_cleared' => 'テンプレートキャッシュが削除されました。',
'cache_warmed' => 'テンプレートキャッシュが作成されました。',
'no_overrides_directory' => 'オーバーライドディレクトリが存在しません。',
'no_override_modules' => 'オーバーライド対象のモジュールディレクトリが存在しません。',
'no_override_layouts_for_module' => 'このモジュール用のオーバーライドレイアウトがありません。',
'no_override_layouts_for_module' => '該当モジュールにオーバーライドレイアウトがありません。',
'override_layout_registered' => 'オーバーライドレイアウトが登録されました。',
'override_layouts_registered' => 'オーバーライドレイアウトが登録されました。',
'override_layouts_cache_invalidated' => 'オーバーライドレイアウトキャッシュが無効化されました。',
@@ -129,17 +129,17 @@ return [
'updating' => 'アップデート中',
],
'types' => [
'admin' => '管理者用',
'user' => 'ユーザー用',
'admin' => '管理者向け',
'user' => 'ユーザー向け',
],
'dependencies' => [
'section_title' => '依存関係',
'section_title' => '依存性',
'modules' => '必須モジュール',
'plugins' => '必須プラグイン',
'status_met' => '充足',
'status_unmet' => '未充足',
'no_dependencies' => '依存関係なし',
'version_required' => '必要バージョン: :version',
'status_met' => '満たしている',
'status_unmet' => '満たしていない',
'no_dependencies' => '依存性なし',
'version_required' => '必要なバージョン: :version',
'version_installed' => 'インストール済みバージョン: :version',
'not_installed' => '未インストール',
'not_active' => '無効',
@@ -150,111 +150,111 @@ return [
'name_max' => 'テンプレート名は255文字を超えることはできません。',
'file_required' => 'ファイルは必須です。',
'file_invalid' => '有効なファイルではありません。',
'file_must_be_zip' => 'ZIPファイルのみアップロード可能です。',
'file_must_be_zip' => 'ZIP ファイルのみアップロード可能です。',
'file_max_size' => 'ファイルサイズは :sizeMB を超えることはできません。',
'github_url_required' => 'GitHub URLは必須です。',
'github_url_invalid' => 'GitHub URLが正しくありません。',
'github_url_format' => 'GitHub URLの形式が正しくありません。(例: https://github.com/owner/repo)',
'type_in' => 'テンプレートタイプはユーザーまたは管理者のみ許可されます。',
'github_url_required' => 'GitHub URL は必須です。',
'github_url_invalid' => 'GitHub URL が正しくありません。',
'github_url_format' => 'GitHub URL の形式が正しくありません。(例:https://github.com/owner/repo)',
'type_in' => 'テンプレートタイプは user または admin のみ許可されます。',
'search_max' => '検索キーワードは255文字を超えることはできません。',
'filters_max' => '検索フィルターは最大10個まで許可されます。',
'filter_field_required' => 'フィルターフィールドは必須です。',
'filter_field_invalid' => '許可されていないフィルターフィールドです。',
'filter_field_invalid' => '許可されないフィルターフィールドです。',
'filter_value_required' => 'フィルター値は必須です。',
'filter_value_max' => 'フィルター値は255文字を超えることはできません。',
'filter_operator_invalid' => '許可されていないフィルター演算子です。(like、eq、starts_with、ends_withのみ許可)',
'status_invalid' => '許可されていないステータス値です。(installed、not_installed、active、inactiveのみ許可)',
'per_page_min' => 'ページあたりの項目数は最小1以上である必要があります。',
'per_page_max' => 'ページあたりの項目数は最大100個まで許可されます。',
'filter_operator_invalid' => '許可されないフィルター演算子です。(like、eq、starts_with、ends_with のみ許可)',
'status_invalid' => '許可されない状態値です。(installed、not_installed、active、inactive のみ許可)',
'per_page_min' => 'ページ当たりのアイテム数は最小1つ以上である必要があります。',
'per_page_max' => 'ページ当たりのアイテム数は最大100個まで許可されます。',
'page_min' => 'ページ番号は最小1以上である必要があります。',
],
'install_success' => 'テンプレートが正常にインストールされました。',
'install_failed' => 'テンプレートのインストールに失敗しました。',
'preview_success' => 'manifestプレビューが完了しました。',
'preview_failed' => 'manifestプレビューに失敗しました。',
'install_failed' => 'テンプレートのインストールに失敗しました: :error',
'preview_success' => 'manifest プレビューが完了しました。',
'preview_failed' => 'manifest プレビューに失敗しました: :error',
'uninstall_success' => 'テンプレートが正常に削除されました。',
'uninstall_failed' => 'テンプレートの削除に失敗しました。',
'uninstall_failed' => 'テンプレートの削除に失敗しました: :error',
'activate_success' => 'テンプレートが有効化されました。',
'activate_failed' => 'テンプレートの有効化に失敗しました。',
'activate_warning' => 'テンプレートの有効化に必要な依存関係が充足されていません。',
'activate_failed' => 'テンプレートの有効化に失敗しました: :error',
'activate_warning' => 'テンプレートの有効化に必要な依存関係が満たされていません。',
'warnings' => [
'missing_dependencies' => 'このテンプレートを有効化するには、以下の依存関係が必要です。',
],
'deactivate_success' => 'テンプレートが無効化されました。',
'deactivate_failed' => 'テンプレートの無効化に失敗しました。',
'deactivate_failed' => 'テンプレートの無効化に失敗しました: :error',
'fetch_success' => 'テンプレート情報を正常に取得しました。',
'license_not_found' => 'テンプレートライセンスファイルが見つかりません。',
'refresh_layouts_success' => 'テンプレートレイアウトが正常に更新されました。',
'refresh_layouts_failed' => 'テンプレートレイアウトの更新に失敗しました。',
'update_success' => 'テンプレート「:template」が :version バージョンにアップデートされました。',
'refresh_layouts_failed' => 'テンプレートレイアウトの更新に失敗しました: :error',
'update_success' => 'テンプレート「:template」がバージョン:versionにアップデートされました。',
'update_failed' => 'テンプレートのアップデートに失敗しました: :error',
'update_hook_failed' => 'テンプレートアップデートフックの実行に失敗しました。',
'update_hook_failed' => 'テンプレートアップデートフックの実行に失敗しました: :error',
'no_update_available' => 'アップデートする内容がありません。',
'check_updates_success' => 'アップデート確認が完了しました。',
'check_updates_failed' => 'アップデート確認に失敗しました。',
'not_installed' => 'テンプレート「:template」がインストールされていません。',
'check_modified_layouts_success' => '編集済みレイアウトの確認が完了しました。',
'check_modified_layouts_failed' => '編集済みレイアウトの確認に失敗しました。',
'pending_not_found' => 'インストール待機リストからテンプレート「:template」が見つかりません。',
'check_updates_failed' => 'アップデート確認に失敗しました: :error',
'not_installed' => 'テンプレート「:template」はインストールされていません。',
'check_modified_layouts_success' => '変更されたレイアウト確認が完了しました。',
'check_modified_layouts_failed' => '変更されたレイアウト確認に失敗しました: :error',
'pending_not_found' => 'インストール待機リストにテンプレート「:template」が見つかりません。',
'already_exists' => 'テンプレート「:template」は既に存在します。',
'move_failed' => 'テンプレート「:template」の移動に失敗しました。',
'move_failed' => 'テンプレート「:template」の移動に失敗しました: :error',
'commands' => [
'install' => [
'success' => 'テンプレート「:template」のインストール完了',
'already_installed' => 'テンプレート「:template」は既にインストールされています。',
'force_reinstall' => 'テンプレート「:template」を強制再インストールします(アクティブディレクトリを上書き)...',
'layouts_created' => 'レイアウト :count 個が作成されました',
'force_reinstall' => 'テンプレート「:template」を強制再インストールしています(アクティブディレクトリを上書き)...',
'layouts_created' => 'レイアウト:count個を作成しました',
'type' => 'タイプ: :type',
'version' => 'バージョン: :version',
'directory_not_found' => 'テンプレートディレクトリが見つかりません: :path',
'config_not_found' => 'template.json ファイルが見つかりません',
'config_not_found' => 'template.jsonファイルが見つかりません',
],
'activate' => [
'success' => 'テンプレート「:template」の有効化完了',
'not_installed' => 'テンプレート「:template」がインストールされていません。',
'deactivated_previous' => '既存の :type テンプレート「:previous」を無効化',
'not_installed' => 'テンプレート「:template」はインストールされていません。',
'deactivated_previous' => '既存の:typeテンプレート「:previous」を無効化',
],
'deactivate' => [
'success' => 'テンプレート「:template」の無効化完了',
'not_active' => 'テンプレート「:template」が有効化されていません。',
'no_active_warning' => '現在有効化されている :type テンプレートがありません。',
'not_active' => 'テンプレート「:template」は有効化されていません。',
'no_active_warning' => '現在有効化されている:typeテンプレートがありません。',
],
'uninstall' => [
'success' => 'テンプレート「:template」の削除完了',
'confirm_prompt' => 'テンプレート「:template」を削除してもよろしいですか?',
'confirm_prompt' => 'テンプレート「:template」を削除しますか?',
'confirm_details' => [
'layouts' => '- レイアウト :count 個が削除されます',
'versions' => '- user タイプテンプレートの場合はバージョン履歴も削除されます',
'layouts' => '- レイアウト:count個が削除されます',
'versions' => '- userタイプテンプレートの場合、バージョン履歴も削除されます',
],
'confirm_question' => '続けてもよろしいですか?',
'layouts_deleted' => 'レイアウト :count 個が削除されました',
'versions_deleted' => 'バージョン履歴 :count 個が削除されました',
'confirm_question' => '続行しますか?',
'layouts_deleted' => 'レイアウト:count個を削除しました',
'versions_deleted' => 'バージョン履歴:count個を削除しました',
'aborted' => '削除がキャンセルされました。',
],
'list' => [
'no_templates' => '登録されたテンプレートがありません。',
'invalid_type' => '無効なタイプです。admin または user のみ使用可能です。',
'invalid_status' => '無効なステータスです。installed、uninstalled、active、inactive のみ使用可能です。',
'invalid_type' => '無効なタイプです。adminまたはuserのみ使用できます。',
'invalid_status' => '無効な状態です。installed、uninstalled、active、inactiveのみ使用できます。',
'headers' => [
'identifier' => '識別子',
'name' => '名前',
'type' => 'タイプ',
'version' => 'バージョン',
'status' => 'ステータス',
'status' => '状態',
],
'status' => [
'uninstalled' => '未インストール',
],
'summary' => '合計 :total 個のテンプレート(インストール済み: :installed 個、有効: :active 個)',
'summary' => '合計:total個のテンプレート(インストール済み: :installed個、有効: :active個)',
],
'cache_clear' => [
'clearing_single' => 'テンプレート「:template」のキャッシュを削除中...',
'clearing_all' => 'すべてのテンプレートキャッシュを削除中...',
'success_single' => 'テンプレート「:template」のキャッシュ :count 個の削除完了',
'success_all' => '全テンプレートキャッシュ :count 個の削除完了',
'success_single' => 'テンプレート「:template」のキャッシュ:count個を削除しました',
'success_all' => 'テンプレート全体のキャッシュ:count個を削除しました',
],
'check_updates' => [
'not_installed' => 'テンプレート「:template」がインストールされていません。',
'not_installed' => 'テンプレート「:template」はインストールされていません。',
'no_installed' => 'インストールされたテンプレートがありません。',
'update_available' => 'アップデート可能',
'up_to_date' => '最新',
@@ -263,27 +263,27 @@ return [
'current_version' => '現在のバージョン',
'latest_version' => '最新バージョン',
'source' => 'ソース',
'status' => 'ステータス',
'status' => '状態',
],
'summary' => '合計 :total 個を確認(アップデート可能: :updates 個)',
'summary' => '合計:total個を確認(アップデート可能: :updates個)',
'single_up_to_date' => 'テンプレート「:template」は最新バージョンです。(v:version)',
'single_update_available' => 'テンプレート「:template」がアップデート可能です: v:current → v:latest(:source)',
'single_update_available' => 'テンプレート「:template」アップデート可能: v:current → v:latest(:source)',
],
'update' => [
'success' => 'テンプレート「:template」のアップデート完了',
'version_change' => 'v:from → v:to',
'no_update' => 'テンプレート「:template」に利用可能なアップデートがありません。',
'not_installed' => 'テンプレート「:template」がインストールされていません。',
'invalid_strategy' => '無効なレイアウト戦略です。overwrite または keep のみ使用可能です。',
'not_installed' => 'テンプレート「:template」はインストールされていません。',
'invalid_strategy' => '無効なレイアウト戦略です。overwriteまたはkeepのみ使用できます。',
'current_version' => '現在のバージョン: :version',
'latest_version' => '最新バージョン: :version',
'update_source' => 'アップデートソース: :source',
'layout_strategy' => 'レイアウト戦略: :strategy',
'modified_layouts_warning' => '編集済みレイアウト :count 個が上書きされます:',
'modified_layout_item' => ' - :name(編集日: :date)',
'confirm_question' => 'アップデートを進めてもよろしいですか?',
'modified_layouts_warning' => '変更されたレイアウト:count個が上書きされます:',
'modified_layout_item' => ' - :name(変更日: :date)',
'confirm_question' => 'アップデートを実行しますか?',
'aborted' => 'アップデートがキャンセルされました。',
'backup_restored' => 'バックアップから前のバージョンが復元されました。',
'backup_restored' => 'バックアップから以前のバージョンが復元されました。',
'force_mode' => '強制アップデートモード: バージョン比較をスキップして再インストールします。',
],
],
@@ -303,8 +303,8 @@ return [
'file_invalid' => '正しいファイルではありません。',
'file_image' => '画像ファイルのみアップロードできます。',
'file_mimes' => 'サポートされている画像形式ではありません (jpg, jpeg, png, gif, webp, svg)。',
'file_max' => 'ファイルサイズは最大 :max MBまで許可されています。',
'layout_name_max' => 'レイアウト名は最大150文字まで許可されています。',
'file_max' => 'ファイルサイズは最大 :max MB までです。',
'layout_name_max' => 'レイアウト名は最大 150 文字までです。',
],
],
];
@@ -4,13 +4,13 @@ return [
'not_found' => 'テーマが見つかりません: :theme',
'name_required' => 'テーマ名は必須です。',
'name_string' => 'テーマ名は文字列である必要があります。',
'name_max' => 'テーマ名は255文字を超えることができません。',
'name_max' => 'テーマ名は255文字を超えることはできません。',
'type_required' => 'テーマタイプは必須です。',
'type_invalid' => 'テーマタイプはcoreまたはmoduleである必要があります。',
'target_required' => 'テーマターゲットは必須です。',
'target_invalid' => 'テーマターゲットはadminまたはuserである必要があります。',
'module_name_required' => 'モジュールテーマの場合、モジュール名が必要です。',
'module_name_string' => 'モジュール名は文字列である必要があります。',
'module_name_max' => 'モジュール名は255文字を超えることができません。',
'module_name_max' => 'モジュール名は255文字を超えることはできません。',
'cannot_uninstall_active' => '有効化されたテーマは削除できません。',
];
@@ -3,7 +3,7 @@
return [
'status' => [
'active' => '有効',
'inactive' => '無効化',
'inactive' => '無効',
'blocked' => 'ブロック',
'withdrawn' => '退会',
],
@@ -13,35 +13,35 @@ return [
],
'not_found' => 'ユーザーが見つかりません。',
'withdrawn_user' => '退会したユーザー',
'fetch_success' => 'ユーザー情報の取得に成功しました。',
'fetch_success' => 'ユーザー情報を正常に取得しました。',
'fetch_failed' => 'ユーザー情報の取得に失敗しました。',
'create_success' => 'ユーザーが正常に作成されました。',
'create_failed' => 'ユーザーの作成に失敗しました: :error',
'create_failed' => 'ユーザー作成に失敗しました: :error',
'update_success' => 'ユーザー情報が正常に更新されました。',
'update_failed' => 'ユーザー情報の更新に失敗しました: :error',
'delete_success' => 'ユーザーが正常に削除されました。',
'delete_failed' => 'ユーザーの削除に失敗しました: :error',
'statistics_success' => 'ユーザー統計の取得に成功しました。',
'delete_failed' => 'ユーザー削除に失敗しました: :error',
'statistics_success' => 'ユーザー統計を正常に取得しました。',
'statistics_failed' => 'ユーザー統計の取得に失敗しました。',
'search_success' => 'ユーザー検索が正常に完了しました。',
'search_failed' => 'ユーザー検索に失敗しました。',
'search_name_required' => '検索する名前を入力してください。',
'search_keyword_required' => '検索キーワードを入力してください。',
'email_available' => '使用可能なメールアドレスです。',
'email_unavailable' => 'すでに使用されているメールアドレスです。',
'email_unavailable' => '既に使用中のメールアドレスです。',
'email_required' => 'メールアドレスを入力してください。',
'email_check_failed' => 'メールアドレスの重複確認に失敗しました。',
'profile_success' => 'プロフィール情報の取得に成功しました。',
'email_check_failed' => 'メールアドレス重複確認に失敗しました。',
'profile_success' => 'プロフィール情報を正常に取得しました。',
'profile_failed' => 'プロフィール情報の取得に失敗しました。',
'language_update_success' => '言語設定が正常に変更されました。',
'language_update_failed' => '言語設定の変更に失敗しました。',
'invalid_language' => 'サポートされていない言語です。',
'activity_log_success' => 'アクティビティログの取得に成功しました。',
'activity_log_success' => 'アクティビティログを正常に取得しました。',
'activity_log_failed' => 'アクティビティログの取得に失敗しました。',
'bulk_status_updated' => ':count名のユーザーステータスが変更されました。',
'bulk_update_status_failed' => 'ユーザーステータスの一括変更に失敗しました。',
'last_admin_role_cannot_remove' => 'システムに管理者ロールを持つユーザーが本人のみのため、管理者ロールを削除できません。',
'avatar_upload_success' => 'プロフィール画像のアップロードに成功しました。',
'bulk_update_status_failed' => 'ユーザーステータス一括変更に失敗しました。',
'last_admin_role_cannot_remove' => 'システムに管理者ロールを持つユーザーが本人のみであるため、管理者ロールを削除することはできません。',
'avatar_upload_success' => 'プロフィール画像が正常にアップロードされました。',
'avatar_upload_failed' => 'プロフィール画像のアップロードに失敗しました。',
'avatar_delete_success' => 'プロフィール画像が正常に削除されました。',
'avatar_delete_failed' => 'プロフィール画像の削除に失敗しました。',
@@ -52,7 +52,7 @@ return [
'password_incorrect' => 'パスワードが一致しません。',
'password_verify_failed' => 'パスワード確認に失敗しました。',
'password_change_success' => 'パスワードが正常に変更されました。',
'password_change_failed' => 'パスワードの変更に失敗しました。',
'password_change_failed' => 'パスワード変更に失敗しました。',
'withdraw_success' => '会員退会が完了しました。',
'withdraw_failed' => '会員退会に失敗しました: :error',
'withdraw_admin_forbidden' => '管理者アカウントは退会できません。',
@@ -62,12 +62,12 @@ return [
'per_page_integer' => 'ページあたりの項目数は数字である必要があります。',
'per_page_min' => 'ページあたりの項目数は1以上である必要があります。',
'per_page_max' => 'ページあたりの項目数は100以下である必要があります。',
'search_string' => '検索語は文字列である必要があります。',
'search_max' => '検索語は255文字以下である必要があります。',
'search_string' => '検索キーワードは文字列である必要があります。',
'search_max' => '検索キーワードは255文字以下である必要があります。',
'search_field_invalid' => '無効な検索フィールドです。',
'start_date_invalid' => '開始日の形式が正しくありません。',
'end_date_invalid' => '終了日の形式が正しくありません。',
'end_date_after_start' => '終了日は開始日より遅い必要があります。',
'start_date_invalid' => '開始日付の形式が正しくありません。',
'end_date_invalid' => '終了日付の形式が正しくありません。',
'end_date_after_start' => '終了日付は開始日付より後である必要があります。',
'date_filter_invalid' => '無効な日付フィルターです。',
'sort_by_invalid' => '無効な並べ替えフィールドです。',
'sort_order_invalid' => '無効な並べ替え順序です。',
@@ -78,34 +78,34 @@ return [
'nickname_max' => 'ニックネームは50文字以下である必要があります。',
'email_required' => 'メールアドレスは必須です。',
'email_invalid' => '正しいメールアドレス形式ではありません。',
'email_unique' => 'すでに使用されているメールアドレスです。',
'email_unique' => '既に使用中のメールアドレスです。',
'email_max' => 'メールアドレスは255文字以下である必要があります。',
'password_required' => 'パスワードは必須です。',
'password_confirmed' => 'パスワード確認が一致しません。',
'password_min' => 'パスワードは最低8文字以上である必要があります。',
'password_min' => 'パスワードは最小8文字以上である必要があります。',
'current_password_required' => '現在のパスワードを入力してください。',
'current_password_invalid' => '現在のパスワードが正しくありません。',
'password_confirmation_required' => 'パスワード確認を入力してください。',
'language_required' => '言語を選択してください。',
'language_invalid' => 'サポートされていない言語です。',
'country_required' => '国を選択してください。',
'country_size' => '国コードは2文字である必要があります。',
'country_alpha' => '国コードは英字のみ使用可能です。',
'country_size' => '国コードは2桁である必要があります。',
'country_alpha' => '国コードは英文字のみ使用可能です。',
'timezone_invalid' => '有効なタイムゾーンを選択してください。',
'status_invalid' => '無効なアカウント状態です。',
'homepage_url' => 'URLの形式が正しくありません。',
'homepage_url' => '正しいURL形式ではありません。',
'homepage_max' => 'ホームページアドレスは255文字以下である必要があります。',
'mobile_max' => '携帯電話番号は20文字以下である必要があります。',
'mobile_regex' => '携帯電話番号は数字、ハイフン、空白のみ入力可能です。',
'mobile_regex' => '携帯電話番号は数字、ハイフン、スペースのみ入力可能です。',
'phone_max' => '電話番号は20文字以下である必要があります。',
'phone_regex' => '電話番号は数字、ハイフン、空白のみ入力可能です。',
'phone_regex' => '電話番号は数字、ハイフン、スペースのみ入力可能です。',
'avatar_required' => 'プロフィール画像を選択してください。',
'avatar_image' => 'プロフィール画像は画像ファイルである必要があります。',
'avatar_mimes' => 'プロフィール画像はJPG、PNG、GIF、WEBP形式のみ対応しています。',
'avatar_mimes' => 'プロフィール画像はJPG、PNG、GIF、WEBP形式のみ可能です。',
'avatar_max' => 'プロフィール画像は2MB以下である必要があります。',
'zipcode_max' => '郵便番号は10文字以下である必要があります。',
'address_max' => '基本アドレスは255文字以下である必要があります。',
'address_detail_max' => '詳細アドレスは255文字以下である必要があります。',
'address_max' => '基本住所は255文字以下である必要があります。',
'address_detail_max' => '詳細住所は255文字以下である必要があります。',
'signature_max' => '署名は1000文字以下である必要があります。',
'bio_max' => '自己紹介は5000文字以下である必要があります。',
'admin_memo_max' => '管理者メモは5000文字以下である必要があります。',
File diff suppressed because it is too large Load Diff
@@ -3,18 +3,18 @@
return [
'mode' => [
'auto' => '自動 (推奨)',
'composer' => 'Composer を実行',
'bundled' => 'バンドル Vendor を使用',
'composer' => 'Composer 実行',
'bundled' => 'バンドル Vendor 使用',
],
'installer' => [
'checking_bundle' => 'Vendor バンドルを検証中...',
'extracting_bundle' => 'Vendor バンドルを抽出中 ({current}/{total})',
'running_composer' => 'Composer install を実行中...',
'checking_bundle' => 'Vendor バンドル検証中...',
'extracting_bundle' => 'Vendor バンドル抽出中 ({current}/{total})',
'running_composer' => 'Composer install 実行中...',
'mode_label' => 'Vendor インストール方式',
'mode_hint' => 'Composer が使用できない環境ではバンドルモードを選択してください。',
],
'build' => [
'start' => '{target} をビルド中...',
'start' => '{target} ビルド中...',
'success' => '{target} ビルド完了 ({size}, {packages} packages)',
'skipped_no_deps' => '{target} スキップ (外部 composer 依存関係なし)',
'skipped_not_installed' => '{target} スキップ (拡張がインストールされていません)',
File diff suppressed because it is too large Load Diff
@@ -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,
@@ -1,13 +1,13 @@
{
"mail.provider_default": {
"definition": {
"name": "メール本人確認 (デフォルト)",
"description": "特定の目的がマッチしない場合に使用されるデフォルトのメールテンプレート"
"name": "メール本人確認(基本)",
"description": "特定の目的がマッチしない場合に使用されるデフォルトメールテンプレート"
},
"templates": {
"mail": {
"subject": "[{app_name}] 本人確認認証コード",
"body": "<p>こんにちは。</p><p>下記の認証コードを入力して本人確認を完了してください。</p><p style=\"font-size:24px; font-weight:bold; letter-spacing:4px; text-align:center; padding:16px; background:#f4f6f8; border-radius:6px;\">{code}</p><p>このコードは<strong>{expire_minutes}分</strong>後に有効期限切れになります。</p><p>本人が申請していない場合は、このメールを無視してください。</p><p>ご利用ありがとうございます。<br><a href=\"{site_url}\">{app_name}</a></p>"
"body": "<p>こんにちは。</p><p>下記の認証コードを入力して本人確認を完了してください。</p><p style=\"font-size:24px; font-weight:bold; letter-spacing:4px; text-align:center; padding:16px; background:#f4f6f8; border-radius:6px;\">{code}</p><p>このコードは<strong>{expire_minutes}分</strong>後に失効します。</p><p>本人が要求していない場合は、このメールを無視してください。</p><p>ありがとうございます。<br><a href=\"{site_url}\">{app_name}</a></p>"
}
}
},
@@ -19,19 +19,19 @@
"templates": {
"mail": {
"subject": "[{app_name}] 会員登録認証コード",
"body": "<h1>{app_name} 会員登録認証</h1><p>下記の認証コードを会員登録画面に入力してください。</p><p style=\"font-size:28px; font-weight:bold; letter-spacing:4px; text-align:center; padding:16px; background:#f4f6f8; border-radius:6px;\">{code}</p><p>このコードは<strong>{expire_minutes}分</strong>後に有効期限切れになります。</p><p>本人が登録を試行していない場合は、このメールを無視してください。</p><p>ご利用ありがとうございます。<br><a href=\"{site_url}\">{app_name}</a></p>"
"body": "<h1>{app_name} 会員登録認証</h1><p>下記の認証コードを会員登録画面に入力してください。</p><p style=\"font-size:28px; font-weight:bold; letter-spacing:4px; text-align:center; padding:16px; background:#f4f6f8; border-radius:6px;\">{code}</p><p>このコードは<strong>{expire_minutes}分</strong>後に失効します。</p><p>本人が登録を試みていない場合は、このメールを無視してください。</p><p>ありがとうございます。<br><a href=\"{site_url}\">{app_name}</a></p>"
}
}
},
"mail.purpose.password_reset": {
"definition": {
"name": "パスワード再設定リンク",
"description": "パスワード再設定リクエスト時に送信される署名付きリンクメール (link フロー)"
"description": "パスワード再設定要求時に送信される署名付きリンクメール(link フロー)"
},
"templates": {
"mail": {
"subject": "[{app_name}] パスワード再設定のご案内",
"body": "<h1>パスワード再設定</h1><p>パスワード再設定のリクエストを受け取りました。下記のボタンをクリックしてパスワードを再設定してください。</p><p>このリンクは<strong>{expire_minutes}分</strong>後に有効期限切れになります。</p><table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin:24px 0;\"><tr><td align=\"center\"><a href=\"{action_url}\" style=\"display:inline-block; padding:12px 32px; background-color:#2d3748; color:#ffffff; text-decoration:none; border-radius:4px; font-weight:600; font-size:14px;\">パスワード再設定</a></td></tr></table><p>本人が申請していない場合は、このメールを無視してください。</p><p>ご利用ありがとうございます。<br><a href=\"{site_url}\">{app_name}</a></p>"
"subject": "[{app_name}] パスワード再設定のお知らせ",
"body": "<h1>パスワード再設定</h1><p>パスワード再設定のリクエストを受け取りました。下記のボタンをクリックしてパスワードを再設定してください。</p><p>このリンクは<strong>{expire_minutes}分</strong>後に失効します。</p><table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin:24px 0;\"><tr><td align=\"center\"><a href=\"{action_url}\" style=\"display:inline-block; padding:12px 32px; background-color:#2d3748; color:#ffffff; text-decoration:none; border-radius:4px; font-weight:600; font-size:14px;\">パスワードを再設定</a></td></tr></table><p>本人が要求していない場合は、このメールを無視してください。</p><p>ありがとうございます。<br><a href=\"{site_url}\">{app_name}</a></p>"
}
}
},
@@ -43,19 +43,19 @@
"templates": {
"mail": {
"subject": "[{app_name}] アカウント情報変更認証コード",
"body": "<h1>アカウント情報変更認証</h1><p>{purpose_label}の処理のために下記の認証コードを入力してください。</p><p style=\"font-size:28px; font-weight:bold; letter-spacing:4px; text-align:center; padding:16px; background:#f4f6f8; border-radius:6px;\">{code}</p><p>このコードは<strong>{expire_minutes}分</strong>後に有効期限切れになります。</p><p><strong>本人が変更を試行していない場合は、直ちにパスワードを変更してください。</strong></p><p>ご利用ありがとうございます。<br><a href=\"{site_url}\">{app_name}</a></p>"
"body": "<h1>アカウント情報変更認証</h1><p>{purpose_label}の処理のため、下記の認証コードを入力してください。</p><p style=\"font-size:28px; font-weight:bold; letter-spacing:4px; text-align:center; padding:16px; background:#f4f6f8; border-radius:6px;\">{code}</p><p>このコードは<strong>{expire_minutes}分</strong>後に失効します。</p><p><strong>本人が変更を試みていない場合は、直ちにパスワードを変更してください。</strong></p><p>ありがとうございます。<br><a href=\"{site_url}\">{app_name}</a></p>"
}
}
},
"mail.purpose.sensitive_action": {
"definition": {
"name": "重要な作業認証",
"description": "決済、重要な設定変更など重要な作業時の本人確認コードメール"
"description": "決済や主要な設定変更など重要な作業時の本人確認コードメール"
},
"templates": {
"mail": {
"subject": "[{app_name}] 重要な作業認証コード",
"body": "<h1>重要な作業認証</h1><p>{purpose_label}の進行のために下記の認証コードを入力してください。</p><p style=\"font-size:28px; font-weight:bold; letter-spacing:4px; text-align:center; padding:16px; background:#f4f6f8; border-radius:6px;\">{code}</p><p>このコードは<strong>{expire_minutes}分</strong>後に有効期限切れになります。</p><p><strong>本人が試行していない場合は、直ちにカスタマーサポートチームにお問い合わせください。</strong></p><p>ご利用ありがとうございます。<br><a href=\"{site_url}\">{app_name}</a></p>"
"body": "<h1>重要な作業認証</h1><p>{purpose_label}の進行のため、下記の認証コードを入力してください。</p><p style=\"font-size:28px; font-weight:bold; letter-spacing:4px; text-align:center; padding:16px; background:#f4f6f8; border-radius:6px;\">{code}</p><p>このコードは<strong>{expire_minutes}分</strong>後に失効します。</p><p><strong>本人が試みていない場合は、直ちにカスタマーサポートにお問い合わせください。</strong></p><p>ありがとうございます。<br><a href=\"{site_url}\">{app_name}</a></p>"
}
}
}
@@ -1,33 +1,33 @@
{
"welcome": {
"definition": {
"name": "会員登録 ウェルカム",
"name": "会員登録ウェルカム",
"description": "会員登録完了時に送信されるウェルカム通知"
},
"templates": {
"mail": {
"subject": "[{app_name}] 会員登録をお待ちしておりました",
"body": "<h1>{name}様、ようこそ!</h1><p>{app_name}にご登録いただきありがとうございます。</p><p>これで全てのサービスをご利用いただけます。以下のボタンをクリックしてログインしてください。</p><table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin: 24px 0;\"><tr><td align=\"center\"><a href=\"{action_url}\" style=\"display: inline-block; padding: 12px 32px; background-color: #2d3748; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 600; font-size: 14px;\">ログインする</a></td></tr></table><p>ありがとうございます。<br><a href=\"{site_url}\">{app_name}</a></p>"
"subject": "[{app_name}] 会員登録をお待ちしていました",
"body": "<h1>{name}様、ようこそ!</h1><p>{app_name}へのご登録ありがとうございます。</p><p>これですべてのサービスをご利用いただけます。下のボタンをクリックしてログインしてください。</p><table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin: 24px 0;\"><tr><td align=\"center\"><a href=\"{action_url}\" style=\"display: inline-block; padding: 12px 32px; background-color: #2d3748; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 600; font-size: 14px;\">ログインする</a></td></tr></table><p>ありがとうございます。<br><a href=\"{site_url}\">{app_name}</a></p>"
},
"database": {
"subject": "会員登録をお待ちしておりました",
"body": "{name}様、{app_name}にご登録いただきありがとうございます。"
"subject": "会員登録をお待ちしていました",
"body": "{name}様、{app_name}へのご登録ありがとうございます。"
}
}
},
"reset_password": {
"definition": {
"name": "パスワード再設定",
"description": "パスワード再設定要求時に送信される通知"
"name": "パスワードリセット",
"description": "パスワードリセット要求時に送信される通知"
},
"templates": {
"mail": {
"subject": "[{app_name}] パスワード再設定のご案内",
"body": "<h1>{name}様、こんにちは。</h1><p>パスワード再設定のご要望を承りました。以下のボタンをクリックしてパスワードを再設定してください。</p><p>このリンクは<strong>{expire_minutes}分</strong>後に期限切れになります。</p><table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin: 24px 0;\"><tr><td align=\"center\"><a href=\"{action_url}\" style=\"display: inline-block; padding: 12px 32px; background-color: #2d3748; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 600; font-size: 14px;\">パスワード再設定</a></td></tr></table><p>パスワード再設定をご要望でなかった場合は、このメールを無視してください。</p><p>ありがとうございます。<br><a href=\"{site_url}\">{app_name}</a></p>"
"subject": "[{app_name}] パスワードリセットのご案内",
"body": "<h1>{name}様、こんにちは。</h1><p>パスワードリセットのご要求をお受けしました。下のボタンをクリックしてパスワードをリセットしてください。</p><p>このリンクは<strong>{expire_minutes}分</strong>後に有効期限切れになります。</p><table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin: 24px 0;\"><tr><td align=\"center\"><a href=\"{action_url}\" style=\"display: inline-block; padding: 12px 32px; background-color: #2d3748; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 600; font-size: 14px;\">パスワードをリセットする</a></td></tr></table><p>パスワードリセットのご要求をされていない場合は、このメールを無視してください。</p><p>ありがとうございます。<br><a href=\"{site_url}\">{app_name}</a></p>"
},
"database": {
"subject": "パスワード再設定のご案内",
"body": "{name}様、パスワード再設定が要求されました。ご本人様による操作でない場合は、この通知を無視してください。"
"subject": "パスワードリセットのご案内",
"body": "{name}様、パスワードリセットが要求されました。ご自身で要求されていない場合は、この通知を無視してください。"
}
}
},
@@ -39,11 +39,43 @@
"templates": {
"mail": {
"subject": "[{app_name}] パスワードが変更されました",
"body": "<h1>{name}様、こんにちは。</h1><p>アカウントのパスワードが正常に変更されました。</p><p><strong>ご本人様による操作でない場合は、直ちにカスタマーサポートまでお問い合わせください。</strong></p><table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin: 24px 0;\"><tr><td align=\"center\"><a href=\"{action_url}\" style=\"display: inline-block; padding: 12px 32px; background-color: #2d3748; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 600; font-size: 14px;\">ログインする</a></td></tr></table><p>ありがとうございます。<br><a href=\"{site_url}\">{app_name}</a></p>"
"body": "<h1>{name}様、こんにちは。</h1><p>アカウントのパスワードが正常に変更されました。</p><p><strong>ご自身で変更されていない場合は、すぐにカスタマーサポートにお問い合わせください。</strong></p><table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin: 24px 0;\"><tr><td align=\"center\"><a href=\"{action_url}\" style=\"display: inline-block; padding: 12px 32px; background-color: #2d3748; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 600; font-size: 14px;\">ログインする</a></td></tr></table><p>ありがとうございます。<br><a href=\"{site_url}\">{app_name}</a></p>"
},
"database": {
"subject": "パスワードが変更されました",
"body": "{name}様、パスワードが変更されました。ご本人様による操作でない場合は、直ちにカスタマーサポートまでお問い合わせください。"
"body": "{name}様、パスワードが変更されました。ご自身で変更されていない場合は、すぐにカスタマーサポートにお問い合わせください。"
}
}
},
"sitemap_regenerated": {
"definition": {
"name": "サイトマップ再生成完了",
"description": "管理者が手動で実行したサイトマップ再生成が完了した際に、実行した管理者へ送信される通知"
},
"templates": {
"mail": {
"subject": "[{app_name}] サイトマップの再生成が完了しました",
"body": "<h1>サイトマップ再生成完了</h1><p>ご依頼いただいたサイトマップの再生成が完了しました。</p><p>合計<strong>{url_count}</strong>件のURLが<strong>{child_count}</strong>個のファイルに生成されました。</p><table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin: 24px 0;\"><tr><td align=\"center\"><a href=\"{action_url}\" style=\"display: inline-block; padding: 12px 32px; background-color: #2d3748; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 600; font-size: 14px;\">SEO設定を表示</a></td></tr></table><p>ありがとうございます。<br><a href=\"{site_url}\">{app_name}</a></p>"
},
"database": {
"subject": "サイトマップ再生成完了",
"body": "ご依頼いただいたサイトマップの再生成が完了しました。合計{url_count}件のURL、{child_count}個のファイルが生成されました。"
}
}
},
"sitemap_regenerate_failed": {
"definition": {
"name": "サイトマップ再生成失敗",
"description": "管理者が手動で実行したサイトマップ再生成が失敗した際に、実行した管理者へ送信される通知"
},
"templates": {
"mail": {
"subject": "[{app_name}] サイトマップの再生成に失敗しました",
"body": "<h1>サイトマップ再生成失敗</h1><p>ご依頼いただいたサイトマップの再生成に失敗しました。</p><p>エラー: <strong>{error}</strong></p><table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"margin: 24px 0;\"><tr><td align=\"center\"><a href=\"{action_url}\" style=\"display: inline-block; padding: 12px 32px; background-color: #2d3748; color: #ffffff; text-decoration: none; border-radius: 4px; font-weight: 600; font-size: 14px;\">SEO設定を表示</a></td></tr></table><p>ありがとうございます。<br><a href=\"{site_url}\">{app_name}</a></p>"
},
"database": {
"subject": "サイトマップ再生成失敗",
"body": "ご依頼いただいたサイトマップの再生成に失敗しました。エラー: {error}"
}
}
}
@@ -8,8 +8,8 @@
"description": "ユーザー管理権限"
},
"core.users.read": {
"name": "ユーザー조회",
"description": "ユーザー一覧および詳細情報を閲覧できます。"
"name": "ユーザー閲覧",
"description": "ユーザーリストと詳細情報を閲覧できます。"
},
"core.users.create": {
"name": "ユーザー作成",
@@ -28,8 +28,8 @@
"description": "メニュー管理権限"
},
"core.menus.read": {
"name": "メニュー조회",
"description": "メニュー一覧を閲覧できます。"
"name": "メニュー閲覧",
"description": "メニューリストを閲覧できます。"
},
"core.menus.create": {
"name": "メニュー作成",
@@ -48,8 +48,8 @@
"description": "モジュール管理権限"
},
"core.modules.read": {
"name": "モジュール조회",
"description": "モジュール一覧を閲覧できます。"
"name": "モジュール閲覧",
"description": "モジュールリストを閲覧できます。"
},
"core.modules.install": {
"name": "モジュールインストール",
@@ -68,8 +68,8 @@
"description": "プラグイン管理権限"
},
"core.plugins.read": {
"name": "プラグイン조회",
"description": "プラグイン一覧を閲覧できます。"
"name": "プラグイン閲覧",
"description": "プラグインリストを閲覧できます。"
},
"core.plugins.install": {
"name": "プラグインインストール",
@@ -92,8 +92,8 @@
"description": "テンプレート管理権限"
},
"core.templates.read": {
"name": "テンプレート조회",
"description": "テンプレート一覧を閲覧できます。"
"name": "テンプレート閲覧",
"description": "テンプレートリストを閲覧できます。"
},
"core.templates.install": {
"name": "テンプレートインストール",
@@ -116,8 +116,8 @@
"description": "ロールおよび権限管理権限"
},
"core.permissions.read": {
"name": "権限조회",
"description": "ロールおよび権限一覧を閲覧できます。"
"name": "権限閲覧",
"description": "ロールおよび権限リストを閲覧できます。"
},
"core.permissions.create": {
"name": "ロール作成",
@@ -125,7 +125,7 @@
},
"core.permissions.update": {
"name": "ロール編集",
"description": "ロール情報および権限を編集できます。"
"description": "ロール情報と権限を編集できます。"
},
"core.permissions.delete": {
"name": "ロール削除",
@@ -136,7 +136,7 @@
"description": "通知発送履歴管理権限"
},
"core.notification-logs.read": {
"name": "発送履歴조회",
"name": "発送履歴閲覧",
"description": "通知発送履歴を閲覧できます。"
},
"core.notification-logs.delete": {
@@ -145,11 +145,11 @@
},
"core.notifications": {
"name": "通知(管理者)",
"description": "管理者用通知管理権限(管理者画面で使用)"
"description": "管理者用通知管理権限(管理画面で使用)"
},
"core.notifications.read": {
"name": "通知조회",
"description": "通知一覧および未読数を閲覧できます。"
"name": "通知閲覧",
"description": "通知リストと未読数を閲覧できます。"
},
"core.notifications.update": {
"name": "通知既読処理",
@@ -161,11 +161,11 @@
},
"core.user-notifications": {
"name": "通知 (ユーザー)",
"description": "ユーザー用通知権限 (ユーザー画面で本人通知管理)"
"description": "ユーザー向け通知権限 (ユーザー画面で本人の通知を管理)"
},
"core.user-notifications.read": {
"name": "通知閲覧",
"description": "本人の通知リスト及び未読数を閲覧できます。"
"description": "本人の通知一覧および未読数を閲覧できます。"
},
"core.user-notifications.update": {
"name": "通知読了処理",
@@ -176,56 +176,56 @@
"description": "本人の通知を削除できます。"
},
"core.identity": {
"name": "本人確認 (ユーザー)",
"description": "ログインユーザーの本人確認チャレンジリクエスト/検証/キャンセル権限"
"name": "本人認証 (ユーザー)",
"description": "ログインユーザーの本人認証チャレンジ要求/検証/キャンセル権限"
},
"core.identity.request": {
"name": "IDVリクエスト",
"description": "本人確認チャレンジをリクエストできます。"
"name": "IDV要求",
"description": "本人認証チャレンジを要求できます。"
},
"core.identity.verify": {
"name": "IDV検証",
"description": "本人確認チャレンジを検証できます。"
"description": "本人の本人認証チャレンジを検証できます。"
},
"core.identity.cancel": {
"name": "IDVキャンセル",
"description": "本人の本人確認チャレンジをキャンセルできます。"
"description": "本人の本人認証チャレンジをキャンセルできます。"
},
"core.admin.identity": {
"name": "本人確認管理 (管理者)",
"name": "本人認証管理 (管理者)",
"description": "管理者用IDVプロバイダ/ポリシー/ログ管理権限"
},
"core.admin.identity.providers.read": {
"name": "プロバイダ設定閲覧",
"description": "本人確認プロバイダ設定を閲覧できます。"
"description": "本人認証プロバイダ設定を閲覧できます。"
},
"core.admin.identity.providers.update": {
"name": "プロバイダ設定編集",
"description": "本人確認プロバイダ設定を編集できます。"
"description": "本人認証プロバイダ設定を編集できます。"
},
"core.admin.identity.policies.read": {
"name": "ポリシー閲覧",
"description": "本人確認ポリシー(ルート/フック別)を閲覧できます。"
"description": "本人認証ポリシー(ルート/フック別)を閲覧できます。"
},
"core.admin.identity.policies.update": {
"name": "ポリシー編集",
"description": "本人確認ポリシー(ルート/フック別)をCRUD できます。"
"description": "本人認証ポリシー(ルート/フック別)をCRUD できます。"
},
"core.admin.identity.logs.read": {
"name": "ログ閲覧",
"description": "本人確認履歴を閲覧できます。"
"description": "本人認証履歴を閲覧できます。"
},
"core.admin.identity.logs.purge": {
"name": "ログ破棄",
"description": "本人確認履歴を破棄(保管期間外)できます。"
"name": "ログ削除",
"description": "本人認証履歴を削除(保管周期外)できます。"
},
"core.admin.identity.messages.read": {
"name": "メッセージテンプレート閲覧",
"description": "本人確認メッセージ定義/テンプレートを閲覧できます。"
"description": "本人認証メッセージ定義/テンプレートを閲覧できます。"
},
"core.admin.identity.messages.update": {
"name": "メッセージテンプレート編集",
"description": "本人確認メッセージ定義/テンプレートを編集できます。"
"description": "本人認証メッセージ定義/テンプレートを編集できます。"
},
"core.settings": {
"name": "環境設定",
@@ -245,19 +245,19 @@
},
"core.dashboard.read": {
"name": "ダッシュボード閲覧",
"description": "ダッシュボード統計及び情報を閲覧できます。"
"description": "ダッシュボード統計および情報を閲覧できます。"
},
"core.dashboard.system-status": {
"name": "システム状態",
"description": "システム状態情報を閲覧できます。"
"name": "システムステータス",
"description": "システムステータス情報を閲覧できます。"
},
"core.dashboard.resources": {
"name": "システムリソース",
"description": "CPU、メモリ、ディスク使用量を閲覧できます。"
},
"core.dashboard.activities": {
"name": "最近の活動",
"description": "最近の活動履歴を閲覧できます。"
"name": "最近のアクティビティ",
"description": "最近のアクティビティ履歴を閲覧できます。"
},
"core.dashboard.alerts": {
"name": "システム通知",
@@ -285,7 +285,7 @@
},
"core.attachments.update": {
"name": "添付ファイル編集",
"description": "添付ファイル情報及び順序を編集できます。"
"description": "添付ファイル情報および順序を編集できます。"
},
"core.attachments.delete": {
"name": "添付ファイル削除",
@@ -297,7 +297,7 @@
},
"core.schedules.read": {
"name": "スケジュール閲覧",
"description": "スケジュールリスト及び詳細情報を閲覧できます。"
"description": "スケジュール一覧および詳細情報を閲覧できます。"
},
"core.schedules.create": {
"name": "スケジュール作成",
@@ -317,11 +317,11 @@
},
"core.language_packs": {
"name": "言語パック管理",
"description": "言語パックインストール/削除/有効化権限"
"description": "言語パックインストール/アンインストール/有効化権限"
},
"core.language_packs.read": {
"name": "言語パック閲覧",
"description": "インストール済み言語パックリスト及び詳細情報を閲覧できます。"
"description": "インストールされた言語パックの一覧と詳細情報を閲覧できます。"
},
"core.language_packs.install": {
"name": "言語パックインストール",
@@ -332,7 +332,7 @@
"description": "言語パックを有効化/無効化/削除できます。"
},
"core.language_packs.update": {
"name": "言語パック アップデート",
"name": "言語パックアップデート",
"description": "インストールされた言語パックのアップデートを確認して実行できます。"
}
}
@@ -9,10 +9,10 @@
},
"user": {
"name": "一般ユーザー",
"description": "基本的なユーザーロールです。"
"description": "基本ユーザーロールです。"
},
"guest": {
"name": "非会員",
"description": "認証されていないユーザーロールです。管理者が権限を付与することができます。"
"description": "認証されていないユーザーロールです。管理者が権限を付与できます。"
}
}
@@ -4,6 +4,12 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.5] - 2026-07-19
### Removed
- 더 이상 사용하지 않는 장바구니 옵션 조합 검증 문구의 일본어 번역을 정리했습니다.
## [1.0.4] - 2026-07-16
### Added
@@ -845,7 +845,6 @@ return [
'item_not_found' => 'カート商品が見つかりません。',
'items_required' => 'カートに追加する商品を選択してください。',
'items_min' => '最低1つ以上の商品を選択してください。',
'option_values_not_found' => '該当するオプション組み合わせが見つかりません。',
],
'wishlist' => [
'product_id_required' => '商品を選択してください。',
@@ -12,7 +12,7 @@
"en": "G7 module (sirsoft-ecommerce) Japanese language pack (bundled)",
"ja": "G7 モジュール (sirsoft-ecommerce) 日本語 言語パック(バンドル)"
},
"version": "1.0.4",
"version": "1.0.5",
"license": "MIT",
"scope": "module",
"target_identifier": "sirsoft-ecommerce",
@@ -4,6 +4,19 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.0.4] - 2026-07-17
### Added
- 환경설정 > SEO 화면에 추가된 Sitemap 분할 기준(파일당 URL 수)·Sitemap 압축 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다.
- 환경설정 > SEO 화면에 추가된 Sitemap 다국어 대체 링크(hreflang) 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다.
- 고급 설정에 추가된 Sitemap 캐시 유지 시간 항목의 이름·설명 일본어 번역 추가 — 새 항목이 일본어 로케일에서도 올바르게 표시됩니다.
- 환경설정 > SEO 화면의 Sitemap 생성 진행 상황 표시(대기·생성 중·파일 작성 중·완료·실패 단계, 현재 처리 항목, 누적 URL 수, 실시간/주기 확인 안내) 일본어 번역 추가 — 진행 상황 화면이 일본어 로케일에서도 올바르게 표시됩니다.
### 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,12 +1424,29 @@
"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": "生成中...",
"sitemap_update_success": "Sitemap 生成が完了しました。",
"sitemap_update_failed": "Sitemap 生成に失敗しました。"
"sitemap_update_failed": "Sitemap 生成に失敗しました。",
"sitemap_urls_per_file": "Sitemap ファイルあたりの URL 数",
"sitemap_urls_per_file_hint": "URL がこの数を超えると sitemap ファイルが複数に分割され、インデックスファイルが一緒に作成されます — 最小 1000 個、最大 50000 個(検索エンジン仕様上限)",
"sitemap_gzip": "Sitemap 圧縮(gzip)",
"sitemap_gzip_desc": "分割された sitemap ファイルを圧縮して送信量を削減します。インデックスファイルは圧縮しません。",
"sitemap_hreflang_enabled": "多言語代替リンク (hreflang)",
"sitemap_hreflang_enabled_desc": "多言語サイトでURLごとに各言語バージョンを指す hreflang 代替リンクを挿入します。オフにするとリンクを省略してサイトマップサイズを削減します。",
"sitemap_regenerating": "作成中…",
"sitemap_progress": "作成進行状況",
"sitemap_status_queued": "待機中",
"sitemap_status_running": "作成中",
"sitemap_status_writing": "ファイル作成中",
"sitemap_status_completed": "完了",
"sitemap_status_failed": "失敗",
"sitemap_progress_phase": "現在処理中: {{phase}}",
"sitemap_progress_urls": "累積URL: {{count}}個",
"sitemap_realtime_connected": "リアルタイムで接続されています。",
"sitemap_polling": "進行状況を定期的に確認しています。"
},
"security": {
"title": "セキュリティ 設定",
@@ -1736,7 +1753,9 @@
"geoip_manual_update": "今すぐ更新",
"geoip_updating": "更新中...",
"geoip_update_success": "GeoIP DBの更新が完了しました。",
"geoip_update_failed": "GeoIP DBの更新に失敗しました。"
"geoip_update_failed": "GeoIP DBの更新に失敗しました。",
"seo_sitemap_cache": "Sitemap キャッシュ",
"seo_sitemap_cache_desc": "sitemap.xml をキャッシュします。"
},
"notification_definitions": {
"title": "通知設定",
@@ -1575,7 +1575,9 @@
"tokenValidation": "トークン検証",
"user": "ユーザー",
"users": "ユーザーリスト",
"dashboard_recent_notifications": "ダッシュボード最近の通知"
"dashboard_recent_notifications": "ダッシュボード最近の通知",
"sitemap_status": "サイトマップ生成ステータス",
"sitemap_progress_ws": "サイトマップ生成進行中(リアルタイム)"
},
"array_group": {
"chart_labels": "項目ラベル",
@@ -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",
+8
View File
@@ -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',
],
];
+5
View File
@@ -12,9 +12,14 @@ return [
// Sitemap manual regeneration
'sitemap_regenerated' => 'Sitemap regeneration completed.',
'sitemap_regenerate_dispatched' => 'Sitemap regeneration has started. It may take a while to finish.',
'sitemap_regenerate_failed' => 'Sitemap regeneration failed.',
'sitemap_disabled' => 'Sitemap generation is disabled.',
// Sitemap generation/serving
'sitemap_not_ready' => 'The sitemap is being prepared. Please try again shortly.',
'sitemap_generate_failed' => 'Sitemap generation failed: :error',
// Cache stats
'stats_period' => 'SEO cache statistics for the last :days days',
'stats_overall' => 'Overall Statistics',
+18 -6
View File
@@ -865,12 +865,16 @@ return [
'stats_cache_ttl_integer' => 'Stats cache TTL must be an integer.',
'stats_cache_ttl_min' => 'Stats cache TTL must be at least 0 seconds.',
'stats_cache_ttl_max' => 'Stats cache TTL may not be greater than 14400 seconds (4 hours).',
'seo_cache_enabled_required' => 'Please select the SEO cache setting.',
'seo_cache_enabled_boolean' => 'SEO cache must be true or false.',
'seo_cache_ttl_required' => 'Please enter the SEO cache TTL.',
'seo_cache_ttl_integer' => 'SEO cache TTL must be an integer.',
'seo_cache_ttl_min' => 'SEO cache TTL must be at least 0 seconds.',
'seo_cache_ttl_max' => 'SEO cache TTL may not be greater than 14400 seconds (4 hours).',
// Advanced tab only — the SEO tab override field (seo_cache_*) allows a different range.
'advanced_seo_cache_enabled_required' => 'Please select the SEO cache setting.',
'advanced_seo_cache_enabled_boolean' => 'SEO cache must be true or false.',
'advanced_seo_cache_ttl_required' => 'Please enter the SEO cache TTL.',
'advanced_seo_cache_ttl_integer' => 'SEO cache TTL must be an integer.',
'advanced_seo_cache_ttl_min' => 'SEO cache TTL must be at least 0 seconds.',
'advanced_seo_cache_ttl_max' => 'SEO cache TTL may not be greater than 14400 seconds (4 hours).',
'seo_sitemap_cache_ttl_integer' => 'Sitemap cache TTL must be an integer.',
'seo_sitemap_cache_ttl_min' => 'Sitemap cache TTL must be at least 3600 seconds (1 hour).',
'seo_sitemap_cache_ttl_max' => 'Sitemap cache TTL may not be greater than 604800 seconds (7 days).',
// Debug settings
'debug_mode_required' => 'Please select the debug mode setting.',
@@ -954,6 +958,14 @@ return [
'sitemap_cache_ttl_integer' => 'Sitemap cache TTL must be an integer.',
'sitemap_cache_ttl_min' => 'Sitemap cache TTL must be at least 3600 seconds (1 hour).',
'sitemap_cache_ttl_max' => 'Sitemap cache TTL may not be greater than 604800 seconds (7 days).',
'sitemap_urls_per_file_integer' => 'URLs per sitemap file must be an integer.',
'sitemap_urls_per_file_min' => 'URLs per sitemap file must be at least 1000.',
'sitemap_urls_per_file_max' => 'URLs per sitemap file may not be greater than 50000.',
'sitemap_gzip_boolean' => 'Sitemap compression setting must be true or false.',
'sitemap_serve_stale_on_miss_boolean' => 'Serving the previous sitemap setting must be true or false.',
'sitemap_max_urls_per_contributor_integer' => 'Maximum URLs per contributor must be an integer.',
'sitemap_max_urls_per_contributor_min' => 'Maximum URLs per contributor must be at least 0.',
'sitemap_hreflang_enabled_boolean' => 'The sitemap hreflang alternate links setting must be true or false.',
'sitemap_schedule_invalid' => 'Please select a valid sitemap generation schedule.',
'sitemap_schedule_time_invalid' => 'Sitemap generation time must be in HH:mm format.',
+8
View File
@@ -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',
],
];
+5
View File
@@ -12,9 +12,14 @@ return [
// Sitemap 수동 재생성
'sitemap_regenerated' => 'Sitemap 재생성이 완료되었습니다.',
'sitemap_regenerate_dispatched' => 'Sitemap 재생성을 시작했습니다. 완료까지 시간이 걸릴 수 있습니다.',
'sitemap_regenerate_failed' => 'Sitemap 재생성에 실패했습니다.',
'sitemap_disabled' => 'Sitemap 생성이 비활성화되어 있습니다.',
// Sitemap 생성/서빙
'sitemap_not_ready' => 'Sitemap 을 준비하는 중입니다. 잠시 후 다시 시도해주세요.',
'sitemap_generate_failed' => 'Sitemap 생성에 실패했습니다: :error',
// 캐시 통계
'stats_period' => '최근 :days일간 SEO 캐시 통계',
'stats_overall' => '전체 통계',
+18 -6
View File
@@ -903,6 +903,14 @@ return [
'sitemap_cache_ttl_integer' => 'Sitemap 캐시 TTL은 정수여야 합니다.',
'sitemap_cache_ttl_min' => 'Sitemap 캐시 TTL은 최소 3600초(1시간) 이상이어야 합니다.',
'sitemap_cache_ttl_max' => 'Sitemap 캐시 TTL은 최대 604800초(7일)를 초과할 수 없습니다.',
'sitemap_urls_per_file_integer' => 'Sitemap 파일당 URL 수는 정수여야 합니다.',
'sitemap_urls_per_file_min' => 'Sitemap 파일당 URL 수는 최소 1000개 이상이어야 합니다.',
'sitemap_urls_per_file_max' => 'Sitemap 파일당 URL 수는 최대 50000개를 초과할 수 없습니다.',
'sitemap_gzip_boolean' => 'Sitemap 압축 설정은 true 또는 false 값이어야 합니다.',
'sitemap_serve_stale_on_miss_boolean' => '이전 Sitemap 제공 설정은 true 또는 false 값이어야 합니다.',
'sitemap_max_urls_per_contributor_integer' => '수집기당 최대 URL 수는 정수여야 합니다.',
'sitemap_max_urls_per_contributor_min' => '수집기당 최대 URL 수는 0 이상이어야 합니다.',
'sitemap_hreflang_enabled_boolean' => 'Sitemap 다국어 대체 링크(hreflang) 설정은 true 또는 false 값이어야 합니다.',
'sitemap_schedule_invalid' => '유효한 Sitemap 생성 주기를 선택해주세요.',
'sitemap_schedule_time_invalid' => 'Sitemap 생성 시각은 HH:mm 형식이어야 합니다.',
@@ -938,12 +946,16 @@ return [
'stats_cache_ttl_integer' => '통계 캐시 만료 시간은 정수여야 합니다.',
'stats_cache_ttl_min' => '통계 캐시 만료 시간은 최소 0초여야 합니다.',
'stats_cache_ttl_max' => '통계 캐시 만료 시간은 최대 14400초(4시간)를 초과할 수 없습니다.',
'seo_cache_enabled_required' => 'SEO 캐시 설정을 선택해주세요.',
'seo_cache_enabled_boolean' => 'SEO 캐시는 true 또는 false 값이어야 합니다.',
'seo_cache_ttl_required' => 'SEO 캐시 만료 시간을 입력해주세요.',
'seo_cache_ttl_integer' => 'SEO 캐시 만료 시간은 정수여야 합니다.',
'seo_cache_ttl_min' => 'SEO 캐시 만료 시간은 최소 0초여야 합니다.',
'seo_cache_ttl_max' => 'SEO 캐시 만료 시간은 최대 14400초(4시간)를 초과할 수 없습니다.',
// 고급 탭 전용 — SEO 탭의 오버라이드 칸(seo_cache_*)과 허용 범위가 다르므로 키를 분리한다.
'advanced_seo_cache_enabled_required' => 'SEO 캐시 설정을 선택해주세요.',
'advanced_seo_cache_enabled_boolean' => 'SEO 캐시는 true 또는 false 값이어야 합니다.',
'advanced_seo_cache_ttl_required' => 'SEO 캐시 만료 시간을 입력해주세요.',
'advanced_seo_cache_ttl_integer' => 'SEO 캐시 만료 시간은 정수여야 합니다.',
'advanced_seo_cache_ttl_min' => 'SEO 캐시 만료 시간은 최소 0초여야 합니다.',
'advanced_seo_cache_ttl_max' => 'SEO 캐시 만료 시간은 최대 14400초(4시간)를 초과할 수 없습니다.',
'seo_sitemap_cache_ttl_integer' => 'Sitemap 캐시 만료 시간은 정수여야 합니다.',
'seo_sitemap_cache_ttl_min' => 'Sitemap 캐시 만료 시간은 최소 3600초(1시간) 이상이어야 합니다.',
'seo_sitemap_cache_ttl_max' => 'Sitemap 캐시 만료 시간은 최대 604800초(7일)를 초과할 수 없습니다.',
// 디버그 설정
'debug_mode_required' => '디버그 모드 설정을 선택해주세요.',
@@ -4,6 +4,18 @@
형식은 [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
- 사이트맵을 만들 때 게시판과 게시글을 한 번에 모두 메모리에 올리지 않고 나눠서 읽도록 바꿨습니다. 게시글이 많은 사이트에서 사이트맵 생성 중 메모리가 부족해 실패하던 문제가 줄어듭니다.
- 개선된 사이트맵 생성 기능을 사용하기 위해 이 모듈은 이제 코어 7.0.6 이상이 필요합니다.
## [1.0.2] - 2026-07-14
### Fixed
+1 -1
View File
@@ -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": {
+2 -2
View File
@@ -5,13 +5,13 @@
"ko": "게시판",
"en": "Board"
},
"version": "1.0.2",
"version": "1.0.3",
"license": "MIT",
"description": {
"ko": "게시판 관리를 위한 모듈",
"en": "Module for board management"
},
"g7_version": ">=7.0.0",
"g7_version": ">=7.0.6",
"dependencies": {
"modules": {},
"plugins": {}
+2 -2
View File
@@ -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",

Some files were not shown because too many files have changed in this diff Show More