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 삭제할 테이블명 배열 */ 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 버전 => 스텝 매핑 */ public function upgrades(): array { return $this->discoverUpgradeSteps(); } /** * upgrades/ 디렉토리에서 업그레이드 스텝을 자동 발견합니다. * * 파일명 규칙: Upgrade_1_1_0.php → 버전 '1.1.0' * 클래스는 UpgradeStepInterface를 구현해야 합니다. * * @return array 버전 => 스텝 매핑 */ 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 라우트 키 => 파일 경로 매핑 */ 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 마이그레이션 디렉토리 경로 배열 */ public function getMigrations(): array { $migrationsPath = $this->getModulePath().'/database/migrations'; if (is_dir($migrationsPath)) { return [$migrationsPath]; } return []; } /** * 모듈 뷰 파일 목록 반환 * * 기본적으로 빈 배열 반환 * 모듈 개발자가 뷰가 필요한 경우 오버라이드 * * @return array 뷰 디렉토리 경로 배열 */ 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 */ public function getDynamicPermissionIdentifiers(): array { return []; } /** * 런타임에 동적으로 생성되는 역할 식별자 목록을 반환합니다. * * `getRoles()` 정적 정의 외에 런타임에 추가되는 역할(예: 게시판 별 manager/step)이 * 있을 때 override 하세요. 반환된 식별자는 stale cleanup 대상에서 제외됩니다. * * @return array */ public function getDynamicRoleIdentifiers(): array { return []; } /** * 런타임에 동적으로 생성되는 메뉴 slug 목록을 반환합니다. * * `getAdminMenus()` 정적 정의 외에 런타임에 추가되는 메뉴(예: 게시판 별 메뉴)가 * 있을 때 override 하세요. 반환된 slug 는 stale cleanup 대상에서 제외됩니다. * * @return array */ 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 * [ * '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, timing?: string, targets: array}> * [ * [ * '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 * }> */ public function getIdentityPolicies(): array { return []; } /** * 이 모듈이 등록할 IDV(본인인증) 목적(purpose) 선언을 반환합니다. * * DB 에 저장되지 않는 **코드 계약** 입니다. 활성화된 모듈의 getter 결과를 * `IdentityVerificationManager` 가 부팅 시 런타임 레지스트리에 병합하며, * `core.identity.purposes` filter 훅으로도 서드파티 동적 등록을 수용합니다. * * 새 purpose 는 이를 지원하는 Provider 와 challenge 로직이 함께 제공되어야 동작합니다. * Provider 없이 purpose 만 선언하면 관리자 UI 에는 노출되나 실제 challenge 는 실패합니다. * * @return array */ 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> */ 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> */ public function getNotificationDefinitions(): array { return []; } /** * 성능 계측 프로파일 정의를 반환합니다. * * 이 모듈이 소유한 목록/화면/저장 경로/배치 중 성능을 재고 싶은 대상을 선언합니다. * `g7:bench` 커맨드가 코어 `config/benchmark.php` 선언과 함께 수집합니다 * (`App\Benchmark\BenchmarkProfileRegistry`). 계측 대상을 코어 커맨드에 하드코딩하지 * 않는 이유는, 확장이 설치·제거되는 설치본마다 실제로 존재하는 대상이 다르기 * 때문입니다. 키는 모듈 내부에서만 고유하면 되고, 다른 확장과 겹치면 커맨드가 * `{식별자}/{키}` 로 지목합니다. * * `write` 축의 `callback` 은 클로저를 쓸 수 없습니다 — 코어 선언과 스키마를 공유하고 * 코어 쪽은 `config:cache` 대상이므로, 형식을 `'Fqcn'`(invokable) 또는 * `['Fqcn', 'method']` 로 통일합니다. * * @return array> 프로파일 키 → 정의 * [ * '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> 시더 클래스명 배열 (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 신뢰 호스트명 목록 (예: ['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 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}> * 키별 데이터 경로 메타 — 예: * [ * '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}> 키별 데이터 경로 메타 * (label = 번역 키 권장 — seoOgDefaultMeta 참조) */ public function seoTwitterDefaultMeta(string $pageType): array { return []; } /** * 구조화 데이터 속성별 데이터 출처(연결 칩) 메타 선언 — 편집기 전용 * * `seoStructuredData()` 의 중첩 객체를 점 경로 키로 평탄화한 기준으로 선언합니다 * (예: `offers.price`). 편집기가 자동 블록을 평탄 행으로 보여줄 때 각 값을 연결 칩으로 * 표시하는 근거입니다. * * @param string $pageType 페이지 타입 * @return array}> * 점 경로 키별 데이터 경로 메타 (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; } /** * 매니페스트가 **선언한** 프론트엔드 자산의 절대 경로를 반환합니다 (파일 존재 여부 무관). * * `getBuiltAssetAbsolutePaths()` 는 `file_exists()` 게이트라 소실된 산출물이 목록에서 * 사라진다. 배포 중 `dist` 가 잠깐 비는 상태를 "선언은 있는데 파일이 없다" 로 세려면 * 선언 축을 그대로 돌려주는 통로가 필요하다 — 이 메서드가 그 축이다. * * @return array kind('js'|'css') => 절대 경로 (선언된 kind 만) */ public function getDeclaredAssetAbsolutePaths(): array { $assets = $this->getAssets(); $result = []; foreach (['js', 'css'] as $kind) { if (! empty($assets[$kind]['output'])) { $result[$kind] = $this->getModulePath().'/'.$assets[$kind]['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 */ 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 { return PublicAssetDisk::resolve($override); } /** * 모듈 캐시 드라이버 인스턴스 반환 * * 모듈별로 격리된 캐시를 제공합니다. * 접두사 패턴: 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'); } /** * 카테고리별 스토리지 기본 경로 반환 * * 카테고리가 다른 디스크로 배선돼 있으면(getStorageDiskFor 오버라이드) 그 디스크 * 기준 경로를 돌려줍니다. 기본 디스크를 보면 배선한 카테고리의 경로가 어긋납니다. * * @param string $category 카테고리 (settings, attachments, images, cache, temp) * @return string 전체 파일 시스템 경로 */ public function getStorageBasePath(string $category): string { return $this->getStorageFor($category)->getBasePath($category); } /** * 파일의 공개 URL 반환 * * 카테고리에 배선된 디스크(getStorageDiskFor)가 직접 URL 을 지원하면 그 URL 을, * 아니면 null 을 반환합니다 (별도 API 엔드포인트 사용). 기본 디스크를 보면 * 공개 자산 디스크로 옮긴 카테고리가 항상 null 을 받습니다. * * @param string $category 카테고리 * @param string $path 파일 경로 * @return string|null 파일 URL (직접 URL 불가 디스크인 경우 null) */ public function getStorageUrl(string $category, string $path): ?string { return $this->getStorageFor($category)->url($category, $path); } }