route:cache 는 새 앱을 부팅해 라우트를 수집하고, 그 부팅의 확장 라우트 프로바이더는 DB 가 아니라 캐시된 활성 확장 목록을 읽는다. 그래서 rebuild 가 invalidate*StatusCache 보다 앞서면 방금 바뀐 상태가 빠진 채 라우트가 박제되고, 라우트 캐시에는 스캔 폴백이 없어 오류도 로그도 없이 404 가 된다. 무효화를 굽기 직전이 아니라 DB 상태 쓰기 직후로 올려, 같은 목록을 읽는 훅 매핑 캐시까지 함께 바로잡았다. update 경로는 Updating 전이 직후에 비우면 그 창의 오토로드 갱신이 확장을 비활성으로 판정하므로, 상태 복원 직후에 비운 뒤 훅 캐시를 다시 굽는다. 플러그인 라우트 프로바이더에는 활성 게이트가 없어 비활성 플러그인의 API 가 계속 응답했다. 화면·메뉴만 사라지고 기능은 살아 있는 상태였다. 모듈과 같은 기준을 적용했다. 실패 사유가 하위 계층에서 버려져 관리자 화면에 :error 자리표시자가 그대로 노출되던 문제도 고쳤다. 반환 경로를 깨지 않도록 배열 키 reason 과 뒤에 붙인 선택적 out 파라미터로 사유를 실어 올리고, 확장이 수명주기 훅에서 사유를 남길 수 있는 통로를 추가했다. 설치 경로의 광역 RuntimeException catch 는 도메인 예외로 좁혀 원본 키와 파라미터를 응답에 싣는다 — 상태코드 422 는 유지해 사용자 계약을 함께 바꾸지 않는다. 언어팩 화면은 프로덕션에서 예외 원문을 싣지 않는 것이 확정된 계약이므로, 자리를 일반 문구로 채우는 대신 치환 자리 자체를 제거했다. 원문은 종전대로 errors 통로를 거쳐 디버그 모드에서 도달한다.
1467 lines
54 KiB
PHP
1467 lines
54 KiB
PHP
<?php
|
|
|
|
namespace App\Extension;
|
|
|
|
use App\Contracts\Extension\CacheableExtensionInterface;
|
|
use App\Contracts\Extension\CacheInterface;
|
|
use App\Contracts\Extension\ModuleInterface;
|
|
use App\Contracts\Extension\StorageInterface;
|
|
use App\Contracts\Extension\UpgradeStepInterface;
|
|
use App\Extension\Cache\ModuleCacheDriver;
|
|
use App\Extension\Storage\ModuleStorageDriver;
|
|
use App\Extension\Traits\ReportsLifecycleFailure;
|
|
use Illuminate\Database\Seeder;
|
|
use ReflectionClass;
|
|
|
|
/**
|
|
* 모듈 추상 클래스
|
|
*
|
|
* 모듈 개발자는 이 클래스를 상속받아 module.json만 작성하면 됩니다.
|
|
* getIdentifier(), getVendor()는 디렉토리명에서 자동 추론됩니다.
|
|
* getName(), getVersion(), getDescription()은 module.json에서 자동 파싱됩니다.
|
|
*/
|
|
abstract class AbstractModule implements CacheableExtensionInterface, ModuleInterface
|
|
{
|
|
use ReportsLifecycleFailure;
|
|
|
|
/**
|
|
* 모듈 디렉토리 경로 (캐시)
|
|
*/
|
|
private ?string $modulePath = null;
|
|
|
|
/**
|
|
* 모듈 식별자 (캐시)
|
|
*/
|
|
private ?string $identifier = null;
|
|
|
|
/**
|
|
* 스토리지 드라이버 인스턴스 (캐시)
|
|
*/
|
|
private ?StorageInterface $storage = null;
|
|
|
|
/**
|
|
* 캐시 드라이버 인스턴스 (캐시)
|
|
*/
|
|
private ?CacheInterface $cache = null;
|
|
|
|
/**
|
|
* manifest JSON 캐시
|
|
*/
|
|
private ?array $manifest = null;
|
|
|
|
/**
|
|
* module.json 매니페스트를 파싱하여 캐싱합니다.
|
|
*
|
|
* @return array 매니페스트 배열 (파일 미존재 시 빈 배열)
|
|
*/
|
|
protected function loadManifest(): array
|
|
{
|
|
if ($this->manifest === null) {
|
|
$manifestPath = $this->getModulePath().'/module.json';
|
|
|
|
if (file_exists($manifestPath)) {
|
|
$this->manifest = json_decode(file_get_contents($manifestPath), true) ?? [];
|
|
} else {
|
|
$this->manifest = [];
|
|
}
|
|
}
|
|
|
|
return $this->manifest;
|
|
}
|
|
|
|
/**
|
|
* 모듈명 반환 (다국어 지원)
|
|
*
|
|
* module.json의 name 필드에서 읽습니다. 오버라이드 가능합니다.
|
|
*
|
|
* @return string|array 문자열 또는 다국어 배열 ['ko' => '...', 'en' => '...']
|
|
*/
|
|
public function getName(): string|array
|
|
{
|
|
return $this->loadManifest()['name'] ?? $this->getIdentifier();
|
|
}
|
|
|
|
/**
|
|
* 모듈 버전 반환
|
|
*
|
|
* module.json의 version 필드에서 읽습니다. 오버라이드 가능합니다.
|
|
*
|
|
* @return string 모듈 버전
|
|
*/
|
|
public function getVersion(): string
|
|
{
|
|
return $this->loadManifest()['version'] ?? '0.0.0';
|
|
}
|
|
|
|
/**
|
|
* 모듈 설명 반환 (다국어 지원)
|
|
*
|
|
* module.json의 description 필드에서 읽습니다. 오버라이드 가능합니다.
|
|
*
|
|
* @return string|array 문자열 또는 다국어 배열 ['ko' => '...', 'en' => '...']
|
|
*/
|
|
public function getDescription(): string|array
|
|
{
|
|
return $this->loadManifest()['description'] ?? '';
|
|
}
|
|
|
|
/**
|
|
* 모듈 디렉토리 경로 반환
|
|
*/
|
|
protected function getModulePath(): string
|
|
{
|
|
if ($this->modulePath === null) {
|
|
$reflection = new ReflectionClass($this);
|
|
$this->modulePath = dirname($reflection->getFileName());
|
|
}
|
|
|
|
return $this->modulePath;
|
|
}
|
|
|
|
/**
|
|
* 모듈 식별자 반환 (디렉토리명에서 자동 추론)
|
|
*
|
|
* 디렉토리명이 'sirsoft-sample'이면 식별자도 'sirsoft-sample'
|
|
*/
|
|
final public function getIdentifier(): string
|
|
{
|
|
if ($this->identifier === null) {
|
|
$this->identifier = basename($this->getModulePath());
|
|
}
|
|
|
|
return $this->identifier;
|
|
}
|
|
|
|
/**
|
|
* 벤더명 반환
|
|
*
|
|
* module.json 의 vendor 필드를 우선 사용합니다.
|
|
* 값이 없으면 디렉토리명의 첫 단어(예: 'sirsoft-sample' → 'sirsoft')로 폴백합니다.
|
|
*
|
|
* @return string 사람이 읽는 벤더/개발자명 또는 폴백으로 얻은 식별자 prefix
|
|
*/
|
|
final public function getVendor(): string
|
|
{
|
|
$manifestVendor = $this->loadManifest()['vendor'] ?? null;
|
|
|
|
if (is_string($manifestVendor) && $manifestVendor !== '') {
|
|
return $manifestVendor;
|
|
}
|
|
|
|
$parts = explode('-', $this->getIdentifier());
|
|
|
|
return $parts[0];
|
|
}
|
|
|
|
/**
|
|
* 모듈 설치
|
|
*
|
|
* 모듈 개발자가 설치 시 추가 작업이 필요한 경우 오버라이드
|
|
*
|
|
* @return bool 성공 여부
|
|
*/
|
|
public function install(): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 모듈 제거
|
|
*
|
|
* 모듈 개발자가 제거 시 추가 작업이 필요한 경우 오버라이드
|
|
*
|
|
* @return bool 성공 여부
|
|
*/
|
|
public function uninstall(): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 모듈이 런타임에 동적으로 생성한 테이블 목록을 반환합니다.
|
|
*
|
|
* 반환된 테이블들은 ModuleManager가 일괄 삭제합니다.
|
|
* 마이그레이션 롤백 전에 호출되므로 메타 테이블이 아직 존재합니다.
|
|
*
|
|
* 모듈 개발자가 동적 테이블이 있는 경우 오버라이드하세요.
|
|
*
|
|
* @return array<string> 삭제할 테이블명 배열
|
|
*/
|
|
public function getDynamicTables(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* 모듈 활성화
|
|
*
|
|
* 모듈 개발자가 활성화 시 추가 작업이 필요한 경우 오버라이드
|
|
*
|
|
* @return bool 성공 여부
|
|
*/
|
|
public function activate(): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 모듈 비활성화
|
|
*
|
|
* 모듈 개발자가 비활성화 시 추가 작업이 필요한 경우 오버라이드
|
|
*
|
|
* @return bool 성공 여부
|
|
*/
|
|
public function deactivate(): bool
|
|
{
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* 버전별 업그레이드 스텝 반환
|
|
*
|
|
* 기본 구현: upgrades/ 디렉토리를 자동 스캔하여 UpgradeStepInterface 구현체를 수집합니다.
|
|
* 모듈 개발자가 인라인 클로저를 사용하려면 오버라이드하세요.
|
|
*
|
|
* @return array<string, callable|UpgradeStepInterface> 버전 => 스텝 매핑
|
|
*/
|
|
public function upgrades(): array
|
|
{
|
|
return $this->discoverUpgradeSteps();
|
|
}
|
|
|
|
/**
|
|
* upgrades/ 디렉토리에서 업그레이드 스텝을 자동 발견합니다.
|
|
*
|
|
* 파일명 규칙: Upgrade_1_1_0.php → 버전 '1.1.0'
|
|
* 클래스는 UpgradeStepInterface를 구현해야 합니다.
|
|
*
|
|
* @return array<string, UpgradeStepInterface> 버전 => 스텝 매핑
|
|
*/
|
|
protected function discoverUpgradeSteps(): array
|
|
{
|
|
$upgradesPath = $this->getModulePath().'/upgrades';
|
|
|
|
if (! is_dir($upgradesPath)) {
|
|
return [];
|
|
}
|
|
|
|
$steps = [];
|
|
$files = glob($upgradesPath.'/Upgrade_*.php');
|
|
|
|
if (! $files) {
|
|
return [];
|
|
}
|
|
|
|
foreach ($files as $file) {
|
|
$filename = pathinfo($file, PATHINFO_FILENAME);
|
|
|
|
// Upgrade_1_1_0 → 1.1.0, Upgrade_1_0_0_beta_1 → 1.0.0-beta.1
|
|
if (! preg_match('/^Upgrade_(\d+)_(\d+)_(\d+)(?:_([a-zA-Z]\w*(?:_\d+)*))?$/', $filename, $matches)) {
|
|
continue;
|
|
}
|
|
|
|
$version = "{$matches[1]}.{$matches[2]}.{$matches[3]}";
|
|
|
|
if (! empty($matches[4])) {
|
|
$version .= '-'.str_replace('_', '.', $matches[4]);
|
|
}
|
|
|
|
require_once $file;
|
|
|
|
// 네임스페이스 추론: 모듈 네임스페이스 + Upgrades\ClassName
|
|
$namespacePart = ExtensionManager::directoryToNamespace($this->getIdentifier());
|
|
$namespace = 'Modules\\'.$namespacePart.'\\Upgrades\\'.$filename;
|
|
|
|
if (class_exists($namespace) && is_subclass_of($namespace, UpgradeStepInterface::class)) {
|
|
$steps[$version] = new $namespace;
|
|
}
|
|
}
|
|
|
|
ksort($steps, SORT_NATURAL);
|
|
|
|
return $steps;
|
|
}
|
|
|
|
/**
|
|
* 모듈 라우트 파일 경로 목록 반환
|
|
*
|
|
* 기본적으로 src/routes/api.php, src/routes/web.php를 반환
|
|
* 파일이 존재하는 경우에만 포함
|
|
*
|
|
* @return array<string, string> 라우트 키 => 파일 경로 매핑
|
|
*/
|
|
public function getRoutes(): array
|
|
{
|
|
$routes = [];
|
|
$basePath = $this->getModulePath();
|
|
|
|
$apiRoute = $basePath.'/src/routes/api.php';
|
|
$webRoute = $basePath.'/src/routes/web.php';
|
|
|
|
if (file_exists($apiRoute)) {
|
|
$routes['api'] = $apiRoute;
|
|
}
|
|
|
|
if (file_exists($webRoute)) {
|
|
$routes['web'] = $webRoute;
|
|
}
|
|
|
|
return $routes;
|
|
}
|
|
|
|
/**
|
|
* 모듈 마이그레이션 경로 반환
|
|
*
|
|
* 기본적으로 database/migrations 디렉토리를 반환
|
|
* 디렉토리가 존재하는 경우에만 포함
|
|
*
|
|
* @return array<string> 마이그레이션 디렉토리 경로 배열
|
|
*/
|
|
public function getMigrations(): array
|
|
{
|
|
$migrationsPath = $this->getModulePath().'/database/migrations';
|
|
|
|
if (is_dir($migrationsPath)) {
|
|
return [$migrationsPath];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* 모듈 뷰 파일 목록 반환
|
|
*
|
|
* 기본적으로 빈 배열 반환
|
|
* 모듈 개발자가 뷰가 필요한 경우 오버라이드
|
|
*
|
|
* @return array<string> 뷰 디렉토리 경로 배열
|
|
*/
|
|
public function getViews(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* 모듈 역할 목록 반환
|
|
*
|
|
* 기본적으로 빈 배열 반환
|
|
* 모듈 개발자가 역할이 필요한 경우 오버라이드
|
|
*
|
|
* @return array 역할 정의 배열
|
|
* [
|
|
* [
|
|
* 'identifier' => 'vendor-module.role-name',
|
|
* 'name' => ['ko' => '...', 'en' => '...'],
|
|
* 'description' => ['ko' => '...', 'en' => '...'],
|
|
* ],
|
|
* ]
|
|
*/
|
|
public function getRoles(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* 모듈 권한 목록 반환 (계층형 구조, 다국어 지원)
|
|
*
|
|
* 구조: 모듈(1레벨) → 카테고리(2레벨) → 개별 권한(3레벨)
|
|
* identifier는 자동 생성됨: {module}.{category}.{action}
|
|
*
|
|
* 기본적으로 빈 배열 반환
|
|
* 모듈 개발자가 권한이 필요한 경우 오버라이드
|
|
*
|
|
* @return array 권한 정의 배열
|
|
* [
|
|
* 'name' => ['ko' => '...', 'en' => '...'],
|
|
* 'description' => ['ko' => '...', 'en' => '...'],
|
|
* 'categories' => [
|
|
* [
|
|
* 'identifier' => 'products',
|
|
* 'name' => ['ko' => '...', 'en' => '...'],
|
|
* 'permissions' => [
|
|
* [
|
|
* 'action' => 'read',
|
|
* 'name' => ['ko' => '...', 'en' => '...'],
|
|
* 'type' => 'admin', // admin 또는 user (기본값: admin)
|
|
* 'roles' => ['admin', 'manager'],
|
|
* ],
|
|
* ],
|
|
* ],
|
|
* ],
|
|
* ]
|
|
*/
|
|
public function getPermissions(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* 런타임에 동적으로 생성되는 권한 식별자 목록을 반환합니다.
|
|
*
|
|
* `getPermissions()` 는 모듈 정의 시점의 **정적** 권한 구조를 반환하지만,
|
|
* 일부 모듈(예: sirsoft-board — 게시판 slug 당 권한 세트)은 런타임에 권한을
|
|
* 동적으로 생성합니다. 이런 권한은 저장 시 `extension_type=module` +
|
|
* `extension_identifier={module}` 로 기록되므로, `ModuleManager::cleanupStaleModuleEntries()`
|
|
* 가 **정적 정의에 없다는 이유로 전부 stale 로 오판해 삭제** 하는 회귀가 일어납니다.
|
|
*
|
|
* 동적 권한을 보유한 모듈은 본 메서드를 override 해 현재 DB/설정에 존재해야 하는
|
|
* 동적 권한 식별자(카테고리 + 액션 전체)를 flat 배열로 반환하세요. 반환값은
|
|
* cleanup 대상에서 자동 제외됩니다.
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
public function getDynamicPermissionIdentifiers(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* 런타임에 동적으로 생성되는 역할 식별자 목록을 반환합니다.
|
|
*
|
|
* `getRoles()` 정적 정의 외에 런타임에 추가되는 역할(예: 게시판 별 manager/step)이
|
|
* 있을 때 override 하세요. 반환된 식별자는 stale cleanup 대상에서 제외됩니다.
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
public function getDynamicRoleIdentifiers(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* 런타임에 동적으로 생성되는 메뉴 slug 목록을 반환합니다.
|
|
*
|
|
* `getAdminMenus()` 정적 정의 외에 런타임에 추가되는 메뉴(예: 게시판 별 메뉴)가
|
|
* 있을 때 override 하세요. 반환된 slug 는 stale cleanup 대상에서 제외됩니다.
|
|
*
|
|
* @return array<int, string>
|
|
*/
|
|
public function getDynamicMenuSlugs(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* 모듈 설정 정보 반환
|
|
*
|
|
* 기본적으로 빈 배열 반환
|
|
* 모듈 개발자가 설정이 필요한 경우 오버라이드
|
|
*
|
|
* @return array 모듈 설정 배열
|
|
*/
|
|
public function getConfig(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* 관리자 메뉴 목록 반환
|
|
*
|
|
* 기본적으로 빈 배열 반환
|
|
* 모듈 개발자가 관리자 메뉴가 필요한 경우 오버라이드
|
|
*
|
|
* @return array 관리자 메뉴 정의 배열
|
|
*/
|
|
public function getAdminMenus(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* 모듈 커스텀 메뉴 목록 반환
|
|
*
|
|
* 모듈이 제공하는 관리자 사이드바 메뉴 정의
|
|
* 기본적으로 빈 배열 반환
|
|
* 모듈 개발자가 커스텀 메뉴가 필요한 경우 오버라이드
|
|
*
|
|
* @return array 메뉴 정의 배열
|
|
* [
|
|
* [
|
|
* 'code' => 'menu_code', // 메뉴 고유 코드
|
|
* 'name' => ['ko' => '...', 'en' => '...'], // 다국어 메뉴명
|
|
* 'url' => '/admin/path', // 메뉴 URL
|
|
* 'icon' => 'icon-name', // 아이콘 이름
|
|
* 'sort_order' => 10, // 정렬 순서 (낮을수록 상위)
|
|
* 'permission' => 'vendor-module.permission', // 필요 권한 (선택)
|
|
* 'children' => [...], // 하위 메뉴 배열 (선택)
|
|
* ],
|
|
* ]
|
|
*/
|
|
public function getCustomMenus(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* 훅 리스너 목록 반환
|
|
*
|
|
* 기본적으로 빈 배열 반환
|
|
* 모듈 개발자가 훅 리스너가 필요한 경우 오버라이드
|
|
*
|
|
* @return array 훅 리스너 정의 배열
|
|
*/
|
|
public function getHookListeners(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* 브로드캐스트 채널 정의를 반환합니다.
|
|
*
|
|
* 모듈에서 WebSocket 실시간 채널이 필요한 경우 오버라이드합니다.
|
|
* 반환된 채널은 ModuleManager가 자동으로 Broadcast::channel()에 등록합니다.
|
|
*
|
|
* 네이밍 규칙: module.{identifier}.{resource}.{param}
|
|
*
|
|
* @return array<string, array{permission?: string, type?: string}>
|
|
* [
|
|
* 'module.vendor-module.orders.{id}' => [
|
|
* 'permission' => 'vendor-module.orders.read', // 권한 체크 (선택)
|
|
* 'type' => 'private', // 채널 타입 (기본: private)
|
|
* ],
|
|
* ]
|
|
*/
|
|
public function getChannels(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* 스케줄 작업 목록 반환
|
|
*
|
|
* 모듈에서 등록하는 스케줄 작업 목록입니다.
|
|
* 코어에서 이 메서드를 호출하여 모듈 스케줄러를 등록합니다.
|
|
*
|
|
* 기본적으로 빈 배열 반환
|
|
* 모듈 개발자가 스케줄 작업이 필요한 경우 오버라이드
|
|
*
|
|
* @return array 스케줄 작업 배열
|
|
* [
|
|
* [
|
|
* 'command' => 'artisan:command',
|
|
* 'schedule' => 'daily' | 'hourly' | 'everyMinute' | 'weekly' | cron expression,
|
|
* 'description' => '작업 설명 (선택)',
|
|
* 'enabled_config' => 'setting.key' (선택, module_setting()으로 조회하여 활성화 여부 결정),
|
|
* ],
|
|
* ]
|
|
*
|
|
* enabled_config 형식:
|
|
* - 'order_settings.auto_cancel_expired' → module_setting($identifier, 'order_settings.auto_cancel_expired')
|
|
* - 'sirsoft-ecommerce.order_settings.auto_cancel_expired' → identifier 접두사 자동 제거 후 동일하게 조회
|
|
*/
|
|
public function getSchedules(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* 이 모듈이 등록할 HTTP 미들웨어 선언을 반환합니다.
|
|
*
|
|
* 모듈이 web/api 그룹에 미들웨어를 직접 넣는 대신, 코어의 self-gate 게이트
|
|
* (`ExtensionMiddlewareGate`)가 요청 시점에 라우트 이름·URI 를 각 선언의 `targets`
|
|
* 패턴과 대조해 매칭될 때만 해당 미들웨어를 실행합니다. 코어 IDV 정책의 라우트명
|
|
* 인덱스 조회 모델과 동일합니다. 미들웨어 클래스 자체는 게이트 로직을 갖지 않아도
|
|
* 됩니다 (순수하게 유지).
|
|
*
|
|
* 기본적으로 빈 배열 반환. 미들웨어가 필요한 모듈만 오버라이드합니다.
|
|
*
|
|
* @return array<int, array{class: class-string, groups: array<int, string>, timing?: string, targets: array<int, string>}>
|
|
* [
|
|
* [
|
|
* 'class' => VerifyGuestOrderToken::class, // 미들웨어 FQCN (class_exists 검증)
|
|
* 'groups' => ['api'], // 등록 그룹 배열: ['web'] | ['api'] | ['web','api']
|
|
* 'timing' => 'after_core', // 'after_core'(기본, 코어 그룹 미들웨어 뒤) | 'before_core'(코어 전처리보다 먼저)
|
|
* 'targets' => ['self'], // 라우트명/URI 패턴 배열. 'self' = 자기 확장 prefix 자동 치환
|
|
* ],
|
|
* ]
|
|
*
|
|
* targets 카탈로그: 'self'(자기 라우트) | 'all_extensions'(모든 확장, 코어 제외) |
|
|
* 'core'(코어만) | 'everything'·'*'(전부) | 'module:{id}' | 'plugin:{id}' |
|
|
* 원시 라우트명 glob·brace(`api.modules.x.*`, `{a,b}`) | '/' 로 시작하는 URI 패턴(무명 라우트용).
|
|
* targets 누락/빈배열 시 등록 거부. 상세: docs/backend/middleware.md "확장 미들웨어 선언".
|
|
*/
|
|
public function getMiddleware(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Declarative i18n getter family — 모듈이 선언하는 다국어/SSoT 데이터 4종.
|
|
*
|
|
* 모두 default `[]` 반환 (override 미선택 시 무영향). 각 메서드 결과는 `ModuleManager` 가
|
|
* activate/update 시 자동 동기화하며, lang pack 활성 시 다국어 키 보강 필터를 통해 ja/en 등 추가
|
|
* 로케일이 자동 주입됩니다 (audit 룰 `seeder-translation-filter` 가 hook 호출 발화 보장).
|
|
*
|
|
* | 메서드 | 도메인 | 동기화 위치 | lang pack 필터 |
|
|
* |--------------------------------|---------------|---------------------------------|--------------------------------------------------|
|
|
* | `getNotificationDefinitions()` | 알림 정의 | `notification_definitions` | `seed.{id}.notifications.translations` |
|
|
* | `getIdentityMessages()` | IDV 메일 | `identity_message_definitions` | `seed.{id}.identity_messages.translations` |
|
|
* | `getIdentityPolicies()` | IDV 정책 | `identity_policies` | (lang pack seed 대상 외 — 다국어 필드 부재) |
|
|
* | `getIdentityPurposes()` | IDV 목적 | 메모리 레지스트리 (DB 없음) | (lang pack seed 대상 외 — `label_key` 참조) |
|
|
*
|
|
* 신규 i18n SSoT 도메인 추가 시 동일 패턴(meta 4-요소: declarative getter + Manager sync +
|
|
* applyFilters + Injector 메서드) 을 따라야 하며, audit 룰 `core-config-lang-pack-seed-coverage`
|
|
* 와 `module-getter-lang-pack-coverage` 가 정합성을 자동 검증합니다.
|
|
*
|
|
* @see getIdentityPolicies()
|
|
* @see getIdentityPurposes()
|
|
* @see getIdentityMessages()
|
|
* @see getNotificationDefinitions()
|
|
*/
|
|
|
|
/**
|
|
* 이 모듈이 등록할 IDV(본인인증) 정책 선언을 반환합니다.
|
|
*
|
|
* 반환된 정책은 `ModuleManager` 가 activate/update 시
|
|
* `IdentityPolicySyncHelper::syncPolicy()` 로 DB(identity_policies) 에 동기화하며,
|
|
* deactivate/uninstall 시 `cleanupStalePolicies()` 로 정리합니다.
|
|
*
|
|
* `source_type` / `source_identifier` 는 Manager 가 자동 주입하므로 반환 배열에 포함하지 않습니다.
|
|
* 운영자가 관리자 UI 에서 수정한 필드(`enabled` / `grace_minutes` / `provider_id` / `fail_mode`)
|
|
* 는 `user_overrides` JSON 으로 보존됩니다.
|
|
*
|
|
* @return array<int, array{
|
|
* key: string,
|
|
* scope: string,
|
|
* target: string,
|
|
* purpose: string,
|
|
* provider_id?: string|null,
|
|
* grace_minutes?: int,
|
|
* enabled?: bool,
|
|
* priority?: int,
|
|
* applies_to?: string,
|
|
* fail_mode?: string,
|
|
* conditions?: array<string, mixed>
|
|
* }>
|
|
*/
|
|
public function getIdentityPolicies(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* 이 모듈이 등록할 IDV(본인인증) 목적(purpose) 선언을 반환합니다.
|
|
*
|
|
* DB 에 저장되지 않는 **코드 계약** 입니다. 활성화된 모듈의 getter 결과를
|
|
* `IdentityVerificationManager` 가 부팅 시 런타임 레지스트리에 병합하며,
|
|
* `core.identity.purposes` filter 훅으로도 서드파티 동적 등록을 수용합니다.
|
|
*
|
|
* 새 purpose 는 이를 지원하는 Provider 와 challenge 로직이 함께 제공되어야 동작합니다.
|
|
* Provider 없이 purpose 만 선언하면 관리자 UI 에는 노출되나 실제 challenge 는 실패합니다.
|
|
*
|
|
* @return array<string, array{
|
|
* label: string|array,
|
|
* description?: string|array,
|
|
* default_provider?: string|null,
|
|
* allowed_channels?: string[]
|
|
* }>
|
|
*/
|
|
public function getIdentityPurposes(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* 이 모듈이 등록할 IDV(본인인증) 메시지 정의/템플릿 선언을 반환합니다.
|
|
*
|
|
* `getIdentityPolicies()` / `getIdentityPurposes()` 와 동일한 패턴으로
|
|
* `ModuleManager` 가 activate/update 시 `IdentityMessageSyncHelper` 를 통해
|
|
* `identity_message_definitions` / `identity_message_templates` 테이블에 동기화하며,
|
|
* uninstall(deleteData=true) 시 자동 정리됩니다.
|
|
*
|
|
* 운영자가 관리자 UI 에서 수정한 필드(name/description/subject/body 등) 는
|
|
* `user_overrides` JSON 으로 보존됩니다.
|
|
*
|
|
* `extension_type='module'`, `extension_identifier=$this->getIdentifier()` 는
|
|
* Manager 가 자동 주입하므로 반환 배열에 포함하지 않습니다.
|
|
*
|
|
* 반환 형식 예:
|
|
* ```php
|
|
* return [
|
|
* [
|
|
* 'provider_id' => 'g7:core.mail',
|
|
* 'scope_type' => IdentityMessageDefinition::SCOPE_PURPOSE,
|
|
* 'scope_value' => 'checkout_verification',
|
|
* 'name' => ['ko' => '결제 시 본인 확인', 'en' => 'Checkout Verification'],
|
|
* 'description' => ['ko' => '...', 'en' => '...'],
|
|
* 'channels' => ['mail'],
|
|
* 'variables' => [['key' => 'code', 'description' => '인증 코드']],
|
|
* 'templates' => [
|
|
* [
|
|
* 'channel' => 'mail',
|
|
* 'subject' => ['ko' => '...', 'en' => '...'],
|
|
* 'body' => ['ko' => '...', 'en' => '...'],
|
|
* ],
|
|
* ],
|
|
* ],
|
|
* ];
|
|
* ```
|
|
*
|
|
* scope_type 권장:
|
|
* - `IdentityMessageDefinition::SCOPE_PURPOSE` — purpose 단위 메시지 (해당 purpose 트리거 시 우선)
|
|
* - `IdentityMessageDefinition::SCOPE_POLICY` — 특정 policy_key 전용 메시지 (가장 구체적, purpose 보다 우선)
|
|
*
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function getIdentityMessages(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* 이 모듈이 등록할 알림 정의/템플릿 선언을 반환합니다.
|
|
*
|
|
* `getIdentityMessages()` 와 동일한 패턴으로 `ModuleManager` 가 activate/update 시
|
|
* `NotificationSyncHelper::syncDefinition()` + `syncTemplate()` 으로 upsert 하고,
|
|
* 현재 선언에 없는 기존 정의는 `cleanupStaleDefinitions()` 로 정리합니다.
|
|
* uninstall(deleteData=true) 시에도 자동 정리됩니다.
|
|
*
|
|
* 운영자가 관리자 UI 에서 수정한 필드(name/description/subject/body/recipients 등) 는
|
|
* `user_overrides` JSON 으로 보존됩니다.
|
|
*
|
|
* `extension_type='module'`, `extension_identifier=$this->getIdentifier()` 는
|
|
* Manager 가 자동 주입하므로 반환 배열에 포함하지 않습니다 (포함되어 있으면 덮어씀).
|
|
*
|
|
* 반환 형식 예:
|
|
* ```php
|
|
* return [
|
|
* [
|
|
* 'type' => 'order_confirmed',
|
|
* 'hook_prefix' => 'sirsoft-ecommerce',
|
|
* 'name' => ['ko' => '주문 확인', 'en' => 'Order Confirmed'],
|
|
* 'description' => ['ko' => '...', 'en' => '...'],
|
|
* 'channels' => ['mail', 'database'],
|
|
* 'hooks' => ['sirsoft-ecommerce.order.after_confirm'],
|
|
* 'variables' => [['key' => 'order_number', 'description' => '주문번호']],
|
|
* 'templates' => [
|
|
* [
|
|
* 'channel' => 'mail',
|
|
* 'recipients' => [['type' => 'trigger_user']],
|
|
* 'subject' => ['ko' => '...', 'en' => '...'],
|
|
* 'body' => ['ko' => '...', 'en' => '...'],
|
|
* ],
|
|
* ],
|
|
* ],
|
|
* ];
|
|
* ```
|
|
*
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
public function getNotificationDefinitions(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* 성능 계측 프로파일 정의를 반환합니다.
|
|
*
|
|
* 이 모듈이 소유한 목록/화면/저장 경로/배치 중 성능을 재고 싶은 대상을 선언합니다.
|
|
* `g7:bench` 커맨드가 코어 `config/benchmark.php` 선언과 함께 수집합니다
|
|
* (`App\Benchmark\BenchmarkProfileRegistry`). 계측 대상을 코어 커맨드에 하드코딩하지
|
|
* 않는 이유는, 확장이 설치·제거되는 설치본마다 실제로 존재하는 대상이 다르기
|
|
* 때문입니다. 키는 모듈 내부에서만 고유하면 되고, 다른 확장과 겹치면 커맨드가
|
|
* `{식별자}/{키}` 로 지목합니다.
|
|
*
|
|
* `write` 축의 `callback` 은 클로저를 쓸 수 없습니다 — 코어 선언과 스키마를 공유하고
|
|
* 코어 쪽은 `config:cache` 대상이므로, 형식을 `'Fqcn'`(invokable) 또는
|
|
* `['Fqcn', 'method']` 로 통일합니다.
|
|
*
|
|
* @return array<string, array<string, mixed>> 프로파일 키 → 정의
|
|
* [
|
|
* 'orders' => [
|
|
* 'type' => 'list', // list | screen | write | batch
|
|
* 'label' => '주문 목록',
|
|
* 'table' => 'ecommerce_orders',
|
|
* 'columns' => ['id', 'order_number', ...],
|
|
* 'order' => [['ordered_at', 'desc']],
|
|
* 'filters' => ['order_status' => 'paid'],
|
|
* 'soft_delete' => true,
|
|
* ],
|
|
* ]
|
|
*/
|
|
public function getBenchmarkProfiles(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* 모듈 설치 시 실행할 시더 클래스 목록 반환
|
|
*
|
|
* 빈 배열 반환 시 database/seeders/ 디렉토리의 모든 시더를 자동 검색합니다. (역호환)
|
|
* 오버라이드하여 실행할 시더와 순서를 명시적으로 정의하세요.
|
|
*
|
|
* @return array<class-string<Seeder>> 시더 클래스명 배열 (FQCN)
|
|
*/
|
|
public function getSeeders(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* 모듈 의존성 반환
|
|
*
|
|
* module.json 의 dependencies 필드를 반환합니다.
|
|
* 중첩 구조 형식: ['modules' => [identifier => version, ...], 'plugins' => [...]]
|
|
*
|
|
* 기본 구현은 manifest JSON 파싱 결과를 그대로 반환하므로 모듈 개발자는
|
|
* module.json 에 의존성을 정의하면 되고 PHP 오버라이드는 권장하지 않습니다.
|
|
*
|
|
* @return array 중첩 구조 의존성 배열
|
|
*/
|
|
public function getDependencies(): array
|
|
{
|
|
$dependencies = $this->loadManifest()['dependencies'] ?? [];
|
|
|
|
return is_array($dependencies) ? $dependencies : [];
|
|
}
|
|
|
|
/**
|
|
* 모듈 설정 기본값 파일 경로 반환
|
|
*
|
|
* 기본적으로 config/settings/defaults.json 파일 경로 반환
|
|
* 모듈 개발자가 다른 경로를 사용하는 경우 오버라이드
|
|
*
|
|
* @return string|null defaults.json 파일의 절대 경로, 없으면 null
|
|
*/
|
|
public function getSettingsDefaultsPath(): ?string
|
|
{
|
|
$path = $this->getModulePath().'/config/settings/defaults.json';
|
|
|
|
return file_exists($path) ? $path : null;
|
|
}
|
|
|
|
/**
|
|
* 모듈에 환경설정이 있는지 확인
|
|
*
|
|
* @return bool 환경설정 존재 여부
|
|
*/
|
|
public function hasSettings(): bool
|
|
{
|
|
return $this->getSettingsDefaultsPath() !== null;
|
|
}
|
|
|
|
/**
|
|
* 모듈 설정 저장 경로 반환
|
|
*
|
|
* @deprecated 향후 제거 예정. getStorage()->getBasePath('settings') 사용 권장
|
|
*
|
|
* @return string 설정 파일 저장 디렉토리 경로
|
|
*/
|
|
public function getSettingsStoragePath(): string
|
|
{
|
|
return $this->getStorage()->getBasePath('settings');
|
|
}
|
|
|
|
/**
|
|
* 모듈 설정 기본값 반환
|
|
*
|
|
* 기본적으로 빈 배열 반환
|
|
* 모듈 개발자가 기본 설정값이 필요한 경우 오버라이드
|
|
*
|
|
* @return array 설정 기본값 배열
|
|
*/
|
|
public function getConfigValues(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* 모듈 설정 스키마 반환
|
|
*
|
|
* 민감한 필드(sensitive: true) 정보 등을 포함합니다.
|
|
* 기본적으로 빈 배열 반환
|
|
* 모듈 개발자가 민감한 필드가 있는 경우 오버라이드
|
|
*
|
|
* @return array 설정 스키마 배열
|
|
*/
|
|
public function getSettingsSchema(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* GitHub URL 반환
|
|
*
|
|
* module.json의 github_url 필드에서 읽습니다. 오버라이드 가능합니다.
|
|
*
|
|
* @return string|null GitHub URL 또는 null
|
|
*/
|
|
public function getGithubUrl(): ?string
|
|
{
|
|
return $this->loadManifest()['github_url'] ?? null;
|
|
}
|
|
|
|
/**
|
|
* 라이선스 반환
|
|
*
|
|
* module.json의 license 필드에서 읽습니다. 오버라이드 가능합니다.
|
|
*
|
|
* @return string|null 라이선스 또는 null
|
|
*/
|
|
public function getLicense(): ?string
|
|
{
|
|
return $this->loadManifest()['license'] ?? null;
|
|
}
|
|
|
|
/**
|
|
* 관리자 UI 에서 숨김 여부 반환
|
|
*
|
|
* module.json 의 hidden 필드가 true 면 관리자 모듈 목록(/api/admin/modules) 에서 기본 제외됩니다.
|
|
* artisan CLI, 설치/제거, 업데이트 감지는 영향을 받지 않습니다.
|
|
* 학습용 샘플 모듈, 내부 운영용 모듈 등에 사용합니다.
|
|
*
|
|
* @return bool 숨김 여부 (기본값: false)
|
|
*/
|
|
public function isHidden(): bool
|
|
{
|
|
return (bool) ($this->loadManifest()['hidden'] ?? false);
|
|
}
|
|
|
|
/**
|
|
* 모듈 메타데이터 반환
|
|
*
|
|
* 기본적으로 빈 배열 반환
|
|
* 모듈 개발자가 메타데이터가 필요한 경우 오버라이드
|
|
*
|
|
* @return array 메타데이터 배열
|
|
*/
|
|
public function getMetadata(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* 신뢰하는 외부 스크립트 호스트 목록을 반환합니다.
|
|
*
|
|
* module.json 의 `trusted_script_hosts` 배열에서 읽습니다. 이 모듈이 레이아웃
|
|
* `scripts[].src` 로 로드하는 외부 CDN 호스트를 선언합니다. 코어는 이 목록을
|
|
* 집계(AbstractModule/AbstractPlugin → TrustedScriptHosts)해 런타임 스크립트 로더·
|
|
* 저장측 검증·정적 검사가 same-origin 이 아닌 스크립트 중 **선언된 호스트만** 허용하도록
|
|
* 합니다 (KVE-2026-1915 신뢰 출처 허용목록).
|
|
*
|
|
* @return array<int, string> 신뢰 호스트명 목록 (예: ['cdn.example.com'])
|
|
*/
|
|
public function getTrustedScriptHosts(): array
|
|
{
|
|
$hosts = $this->loadManifest()['trusted_script_hosts'] ?? [];
|
|
|
|
if (! is_array($hosts)) {
|
|
return [];
|
|
}
|
|
|
|
return array_values(array_filter(
|
|
array_map(fn ($host) => is_string($host) ? trim($host) : '', $hosts),
|
|
fn ($host) => $host !== ''
|
|
));
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 확장 파일 경로 반환
|
|
*
|
|
* @return string extensions 디렉토리 경로
|
|
*/
|
|
public function getExtensionsPath(): string
|
|
{
|
|
return $this->getModulePath().'/resources/extensions';
|
|
}
|
|
|
|
/**
|
|
* 레이아웃 확장 파일 목록 반환
|
|
*
|
|
* @return array<string> JSON 파일 경로 목록
|
|
*/
|
|
public function getLayoutExtensions(): array
|
|
{
|
|
$path = $this->getExtensionsPath();
|
|
|
|
if (! is_dir($path)) {
|
|
return [];
|
|
}
|
|
|
|
return glob($path.'/*.json') ?: [];
|
|
}
|
|
|
|
/**
|
|
* SEO config 파일 경로를 반환합니다.
|
|
*
|
|
* @return string seo-config.json 파일 경로
|
|
*/
|
|
public function getSeoConfigPath(): string
|
|
{
|
|
return $this->getModulePath().'/resources/seo-config.json';
|
|
}
|
|
|
|
/**
|
|
* SEO config를 로드하여 반환합니다.
|
|
*
|
|
* @return array SEO 설정 배열 (파일 미존재 시 빈 배열)
|
|
*/
|
|
public function getSeoConfig(): array
|
|
{
|
|
$path = $this->getSeoConfigPath();
|
|
|
|
if (! file_exists($path)) {
|
|
return [];
|
|
}
|
|
|
|
$config = json_decode(file_get_contents($path), true);
|
|
|
|
return is_array($config) ? $config : [];
|
|
}
|
|
|
|
/**
|
|
* SEO 변수 메타데이터를 반환합니다.
|
|
*
|
|
* 모듈이 SEO 렌더링에 제공하는 변수를 page_type별로 선언합니다.
|
|
* SeoRenderer가 이 메서드를 호출하여 변수를 수집하고 자동 해석합니다.
|
|
*
|
|
* 각 변수는 source 타입에 따라 해석 방식이 결정됩니다:
|
|
* - setting: 모듈 환경설정 값 (엔진 자동 해석)
|
|
* - core_setting: 코어 설정 값 (엔진 자동 해석)
|
|
* - query: URL 쿼리 파라미터 (엔진 자동 해석)
|
|
* - route: 라우트 파라미터 (엔진 자동 해석)
|
|
* - data: 데이터소스 응답 필드 (템플릿 개발자가 vars에서 매핑)
|
|
*
|
|
* 기본적으로 빈 배열 반환.
|
|
* 모듈 개발자가 SEO 변수가 필요한 경우 오버라이드하세요.
|
|
*
|
|
* @return array page_type별 변수 정의 배열
|
|
* [
|
|
* 'product' => [
|
|
* 'product_name' => [
|
|
* 'description' => '상품명',
|
|
* 'source' => 'data',
|
|
* 'required' => true,
|
|
* ],
|
|
* 'commerce_name' => [
|
|
* 'description' => '쇼핑몰명',
|
|
* 'source' => 'setting',
|
|
* 'key' => 'basic_info.shop_name',
|
|
* ],
|
|
* ],
|
|
* ]
|
|
*/
|
|
public function seoVariables(): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* 페이지 타입별 OG 메타태그 기본값 선언
|
|
*
|
|
* 모듈이 자기 도메인 데이터로부터 og:image, og:image:width/height,
|
|
* og:type, og:product:price 같은 도메인별 OG 태그를 직접 만들어 제공합니다.
|
|
* 레이아웃 meta.seo.og 가 같은 키를 선언하면 그쪽이 우선 (override).
|
|
*
|
|
* @param string $pageType 레이아웃 meta.seo.page_type (예: 'product', 'category', 'post')
|
|
* @param array $context DataSourceResolver 결과 + _seo 주입된 컨텍스트
|
|
* @param array $routeParams URL 라우트 파라미터
|
|
* @return array OG 데이터 (type, image, image_width, image_height, image_secure_url,
|
|
* image_type, image_alt, site_name, locale, extra)
|
|
*/
|
|
public function seoOgDefaults(string $pageType, array $context, array $routeParams = []): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* 페이지 타입별 Twitter 카드 기본값 선언
|
|
*
|
|
* @param string $pageType 페이지 타입
|
|
* @param array $context 컨텍스트
|
|
* @param array $routeParams 라우트 파라미터
|
|
* @return array Twitter 카드 데이터 (card, site, creator, title, description, image, image_alt, extra)
|
|
*/
|
|
public function seoTwitterDefaults(string $pageType, array $context, array $routeParams = []): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* 페이지 타입별 JSON-LD 구조화 데이터 선언
|
|
*
|
|
* 모듈이 자기 도메인 스키마(Product/Article/Event 등 Schema.org 타입)를
|
|
* 직접 owned. 레이아웃 meta.seo.structured_data 가 비어있을 때 적용.
|
|
*
|
|
* @param string $pageType 페이지 타입
|
|
* @param array $context 컨텍스트
|
|
* @param array $routeParams 라우트 파라미터
|
|
* @return array Schema.org 형식 (@type 필수). 빈 배열 반환 시 미적용.
|
|
*/
|
|
public function seoStructuredData(string $pageType, array $context, array $routeParams = []): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* OG 기본값 키별 데이터 출처(연결 칩) 메타 선언 — 편집기 전용
|
|
*
|
|
* `seoOgDefaults()` 가 반환하는 평문값은 운영 렌더링에 그대로 쓰이지만, 어느 데이터에서
|
|
* 왔는지(`{{product.data.name}}`)는 평문으로 resolve 되며 정보가 소실됩니다. 편집기
|
|
* [검색엔진] 탭은 자동값을 "상품 이름" 같은 **연결 칩**으로 보여주고 사용자가 다른
|
|
* 데이터로 교체할 수 있어야 하므로, 본 메서드가 키별 데이터 경로(표현식)와
|
|
* 사용자용 라벨을 함께 제공합니다.
|
|
*
|
|
* 운영 렌더링(`SeoRenderer`)은 본 메서드를 호출하지 않습니다 — 편집기 미리보기
|
|
* (`SeoOgPreviewService`)만 소비합니다. 미오버라이드(빈 배열)면 편집기는 종전대로
|
|
* resolve 된 평문을 보여줍니다(하위호환·평문 폴백).
|
|
*
|
|
* `label` 은 **번역 키 문자열**(`'sirsoft-ecommerce::seo.auto_value.product_name'`)로 선언하는 것을
|
|
* 권장합니다 — 편집기가 `__()` 로 해석하므로 모듈 lang 파일(+번들 언어팩)이 그 키를 번역하면
|
|
* 추가 언어(ja 등)에 자동 대응합니다. 인라인 다국어 맵(`['ko' => ..., 'en' => ...]`)도 허용하나
|
|
* 그 외 로케일은 en 폴백이라 언어팩에 대응하지 못합니다(하위호환용).
|
|
*
|
|
* @param string $pageType 레이아웃 meta.seo.page_type
|
|
* @return array<string, array{expr: string, label: string|array<string, string>}>
|
|
* 키별 데이터 경로 메타 — 예:
|
|
* [
|
|
* 'image' => ['expr' => '{{product.data.thumbnail_url}}', 'label' => 'vendor-module::seo.auto_value.product_image'],
|
|
* 'image_alt' => ['expr' => '{{product.data.name}}', 'label' => 'vendor-module::seo.auto_value.product_name'],
|
|
* ]
|
|
*/
|
|
public function seoOgDefaultMeta(string $pageType): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Twitter 카드 기본값 키별 데이터 출처(연결 칩) 메타 선언 — 편집기 전용
|
|
*
|
|
* @param string $pageType 페이지 타입
|
|
* @return array<string, array{expr: string, label: string|array<string, string>}> 키별 데이터 경로 메타
|
|
* (label = 번역 키 권장 — seoOgDefaultMeta 참조)
|
|
*/
|
|
public function seoTwitterDefaultMeta(string $pageType): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* 구조화 데이터 속성별 데이터 출처(연결 칩) 메타 선언 — 편집기 전용
|
|
*
|
|
* `seoStructuredData()` 의 중첩 객체를 점 경로 키로 평탄화한 기준으로 선언합니다
|
|
* (예: `offers.price`). 편집기가 자동 블록을 평탄 행으로 보여줄 때 각 값을 연결 칩으로
|
|
* 표시하는 근거입니다.
|
|
*
|
|
* @param string $pageType 페이지 타입
|
|
* @return array<string, array{expr: string, label: string|array<string, string>}>
|
|
* 점 경로 키별 데이터 경로 메타 (label = 번역 키 권장 — seoOgDefaultMeta 참조) — 예:
|
|
* [
|
|
* 'name' => ['expr' => '{{product.data.name}}', 'label' => 'vendor-module::seo.auto_value.product_name'],
|
|
* 'offers.price' => ['expr' => '{{product.data.selling_price}}', 'label' => 'vendor-module::seo.auto_value.product_price'],
|
|
* ]
|
|
*/
|
|
public function seoStructuredDataMeta(string $pageType): array
|
|
{
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* 그누보드7 코어 요구 버전 제약 반환
|
|
*
|
|
* module.json의 g7_version 필드에서 읽습니다. 오버라이드 가능합니다.
|
|
* null 반환 시 버전 검증 건너뜀 (역호환성)
|
|
*
|
|
* @return string|null 버전 제약 문자열 또는 null
|
|
*/
|
|
public function getRequiredCoreVersion(): ?string
|
|
{
|
|
return $this->loadManifest()['g7_version'] ?? null;
|
|
}
|
|
|
|
/**
|
|
* 모듈 프론트엔드 에셋 정보 반환
|
|
*
|
|
* module.json의 assets 섹션에서 정보를 읽어 반환합니다.
|
|
* 빌드된 JS/CSS 파일 경로와 외부 스크립트 정보를 포함합니다.
|
|
*
|
|
* @return array 에셋 정보 배열
|
|
* [
|
|
* 'js' => ['entry' => 'resources/js/index.ts', 'output' => 'dist/js/module.iife.js'],
|
|
* 'css' => ['entry' => 'resources/css/main.css', 'output' => 'dist/css/module.css'],
|
|
* 'handlers' => true,
|
|
* 'static' => 'resources/assets/',
|
|
* 'external' => [...],
|
|
* ]
|
|
*/
|
|
public function getAssets(): array
|
|
{
|
|
return $this->loadManifest()['assets'] ?? [];
|
|
}
|
|
|
|
/**
|
|
* 모듈 에셋 로딩 설정 반환
|
|
*
|
|
* module.json의 loading 섹션에서 정보를 읽어 반환합니다.
|
|
*
|
|
* @return array 로딩 설정 배열
|
|
* [
|
|
* 'strategy' => 'global' | 'layout' | 'lazy',
|
|
* 'priority' => 100,
|
|
* 'dependencies' => [],
|
|
* ]
|
|
*/
|
|
public function getAssetLoadingConfig(): array
|
|
{
|
|
$loading = $this->loadManifest()['loading'] ?? [];
|
|
|
|
return [
|
|
'strategy' => $loading['strategy'] ?? 'global',
|
|
'priority' => $loading['priority'] ?? 100,
|
|
'dependencies' => $loading['dependencies'] ?? [],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 프론트엔드 에셋 빌드가 가능한지 확인합니다.
|
|
*
|
|
* hasAssets()와 달리 빌드 결과물이 아닌 소스 엔트리포인트 정의 여부로 판단합니다.
|
|
* 빌드 커맨드에서 빌드 대상 필터링에 사용됩니다.
|
|
*
|
|
* @return bool 빌드 가능 여부
|
|
*/
|
|
public function canBuild(): bool
|
|
{
|
|
$assets = $this->getAssets();
|
|
|
|
return ! empty($assets['js']['entry']) || ! empty($assets['css']['entry']);
|
|
}
|
|
|
|
/**
|
|
* 모듈에 프론트엔드 에셋이 있는지 확인
|
|
*
|
|
* @return bool 에셋 존재 여부
|
|
*/
|
|
public function hasAssets(): bool
|
|
{
|
|
$assets = $this->getAssets();
|
|
|
|
// js 또는 css output이 정의되어 있고 파일이 존재하는지 확인
|
|
if (! empty($assets['js']['output'])) {
|
|
$jsPath = $this->getModulePath().'/'.$assets['js']['output'];
|
|
if (file_exists($jsPath)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
if (! empty($assets['css']['output'])) {
|
|
$cssPath = $this->getModulePath().'/'.$assets['css']['output'];
|
|
if (file_exists($cssPath)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* 빌드된 에셋 파일 경로 반환
|
|
*
|
|
* @return array 빌드된 에셋 경로 배열 ['js' => '...', 'css' => '...']
|
|
*/
|
|
public function getBuiltAssetPaths(): array
|
|
{
|
|
$assets = $this->getAssets();
|
|
$result = [];
|
|
|
|
if (! empty($assets['js']['output'])) {
|
|
$jsPath = $this->getModulePath().'/'.$assets['js']['output'];
|
|
if (file_exists($jsPath)) {
|
|
$result['js'] = $assets['js']['output'];
|
|
}
|
|
}
|
|
|
|
if (! empty($assets['css']['output'])) {
|
|
$cssPath = $this->getModulePath().'/'.$assets['css']['output'];
|
|
if (file_exists($cssPath)) {
|
|
$result['css'] = $assets['css']['output'];
|
|
}
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* 빌드된 에셋의 절대 파일 경로를 반환합니다.
|
|
*
|
|
* `getBuiltAssetPaths()` 는 module.json 의 상대 output 경로를 돌려주므로
|
|
* 파일을 실제로 읽으려면 모듈 루트(`getModulePath()`: 활성 dir 또는 `_bundled`
|
|
* 실제 위치)를 앞에 붙여야 한다. 서버측 번들 병합(ExtensionBundleService)이
|
|
* `getAssetFilePath()` 의 `base_path("modules/{id}/...")` 하드코딩을 복제하지
|
|
* 않고 `_bundled` 확장에서도 정확한 경로를 얻도록 이 게터를 SSoT 로 쓴다.
|
|
*
|
|
* @return array 빌드된 에셋 절대 경로 배열 ['js' => '...', 'css' => '...']
|
|
*/
|
|
public function getBuiltAssetAbsolutePaths(): array
|
|
{
|
|
$relative = $this->getBuiltAssetPaths();
|
|
$result = [];
|
|
|
|
foreach ($relative as $kind => $output) {
|
|
$result[$kind] = $this->getModulePath().'/'.$output;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* 모듈 스토리지 드라이버 인스턴스 반환
|
|
*
|
|
* 모듈별로 격리된 파일 저장소를 제공합니다.
|
|
* 카테고리별로 파일을 분리하여 저장합니다 (settings, attachments, images, cache, temp).
|
|
*
|
|
* @return StorageInterface 스토리지 드라이버 인스턴스
|
|
*/
|
|
public function getStorage(): StorageInterface
|
|
{
|
|
if ($this->storage === null) {
|
|
$this->storage = new ModuleStorageDriver(
|
|
$this->getIdentifier(),
|
|
$this->getStorageDisk()
|
|
);
|
|
}
|
|
|
|
return $this->storage;
|
|
}
|
|
|
|
/**
|
|
* 모듈에서 사용할 스토리지 디스크 이름 반환
|
|
*
|
|
* 기본값은 'modules'이며, 모듈 개발자가 다른 디스크를 사용하려면 오버라이드합니다.
|
|
* 예: config('module-name.disk', 'modules')
|
|
*
|
|
* @return string 디스크 이름 (modules, public, s3 등)
|
|
*/
|
|
public function getStorageDisk(): string
|
|
{
|
|
return 'modules';
|
|
}
|
|
|
|
/**
|
|
* 카테고리별 스토리지 인스턴스 캐시 (디스크명 키 memoize)
|
|
*
|
|
* @var array<string, StorageInterface>
|
|
*/
|
|
private array $storageByDisk = [];
|
|
|
|
/**
|
|
* 카테고리별 스토리지 디스크 이름 반환
|
|
*
|
|
* 기본값은 getStorageDisk() 와 동일 (현행 동작 100% 보존).
|
|
* 특정 카테고리(예: 'images')만 다른 디스크를 쓰려면 모듈이 오버라이드합니다.
|
|
*
|
|
* 주의: 오버라이드 구현은 'settings' 카테고리에서 모듈 설정을 조회하면 안 됩니다 —
|
|
* 모듈 설정 로드가 getStorage()->get('settings', ...) 를 경유하므로 재귀 고리가 생깁니다.
|
|
* 모듈 설정 조회는 'images' 등 설정 저장과 무관한 카테고리에서만 수행합니다.
|
|
*
|
|
* @param string $category 카테고리 (settings, attachments, images, cache, temp)
|
|
* @return string 디스크 이름
|
|
*/
|
|
public function getStorageDiskFor(string $category): string
|
|
{
|
|
return $this->getStorageDisk();
|
|
}
|
|
|
|
/**
|
|
* 카테고리별 스토리지 드라이버 인스턴스 반환
|
|
*
|
|
* getStorageDiskFor() 가 결정한 디스크의 드라이버를 디스크 단위로 memoize 하여 반환합니다.
|
|
* 기본 디스크와 동일하면 getStorage() 인스턴스를 그대로 재사용합니다.
|
|
*
|
|
* @param string $category 카테고리
|
|
* @return StorageInterface 스토리지 드라이버 인스턴스
|
|
*/
|
|
public function getStorageFor(string $category): StorageInterface
|
|
{
|
|
$disk = $this->getStorageDiskFor($category);
|
|
|
|
if (! isset($this->storageByDisk[$disk])) {
|
|
$base = $this->getStorage();
|
|
$this->storageByDisk[$disk] = ($disk === $base->getDisk()) ? $base : $base->withDisk($disk);
|
|
}
|
|
|
|
return $this->storageByDisk[$disk];
|
|
}
|
|
|
|
/**
|
|
* 공개 자산 디스크 설정값을 해석합니다.
|
|
*
|
|
* 우선순위: 확장 개별 설정(override) > 코어 전역 설정(core.storage.public_asset_disk).
|
|
* 미설정('')/'none'/config 에 존재하지 않는 디스크(고아 플러그인 디스크)는 null 로
|
|
* 해석되어 호출측이 기존 디스크(스트리밍)로 폴백합니다.
|
|
*
|
|
* @param string|null $override 확장 개별 설정값 (''/null 이면 코어 전역 설정 사용)
|
|
* @return string|null 사용할 디스크 이름 (스트리밍 유지면 null)
|
|
*/
|
|
protected function resolvePublicAssetDisk(?string $override = null): ?string
|
|
{
|
|
$disk = ($override !== null && $override !== '')
|
|
? $override
|
|
: (string) config('core.storage.public_asset_disk', '');
|
|
|
|
if ($disk === '' || $disk === 'none' || config("filesystems.disks.{$disk}") === null) {
|
|
return null;
|
|
}
|
|
|
|
return $disk;
|
|
}
|
|
|
|
/**
|
|
* 모듈 캐시 드라이버 인스턴스 반환
|
|
*
|
|
* 모듈별로 격리된 캐시를 제공합니다.
|
|
* 접두사 패턴: g7:module.{identifier}:{key}
|
|
*
|
|
* @return CacheInterface 캐시 드라이버 인스턴스
|
|
*/
|
|
public function getCache(): CacheInterface
|
|
{
|
|
if ($this->cache === null) {
|
|
$this->cache = new ModuleCacheDriver(
|
|
$this->getIdentifier(),
|
|
$this->getCacheStore()
|
|
);
|
|
}
|
|
|
|
return $this->cache;
|
|
}
|
|
|
|
/**
|
|
* 모듈에서 사용할 캐시 스토어 이름 반환
|
|
*
|
|
* 기본값은 환경설정 캐시 드라이버이며, 모듈 개발자가 다른 스토어를 사용하려면 오버라이드합니다.
|
|
*
|
|
* @return string 캐시 스토어 이름
|
|
*/
|
|
public function getCacheStore(): string
|
|
{
|
|
return config('cache.default');
|
|
}
|
|
|
|
/**
|
|
* 카테고리별 스토리지 기본 경로 반환
|
|
*
|
|
* @param string $category 카테고리 (settings, attachments, images, cache, temp)
|
|
* @return string 전체 파일 시스템 경로
|
|
*/
|
|
public function getStorageBasePath(string $category): string
|
|
{
|
|
return $this->getStorage()->getBasePath($category);
|
|
}
|
|
|
|
/**
|
|
* 파일의 공개 URL 반환
|
|
*
|
|
* public disk인 경우 직접 URL을 반환하고,
|
|
* private disk인 경우 null을 반환합니다 (별도 API 엔드포인트 사용).
|
|
*
|
|
* @param string $category 카테고리
|
|
* @param string $path 파일 경로
|
|
* @return string|null 파일 URL (private disk인 경우 null)
|
|
*/
|
|
public function getStorageUrl(string $category, string $path): ?string
|
|
{
|
|
return $this->getStorage()->url($category, $path);
|
|
}
|
|
}
|