v7.0.0-beta.1 release

This commit is contained in:
HeuJung
2026-04-01 10:30:52 +09:00
commit 6595fd0eb5
3967 changed files with 1875383 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[docker-compose.yml]
indent_size = 4
+105
View File
@@ -0,0 +1,105 @@
APP_NAME=그누보드7
APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.0-beta.1
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
APP_FAKER_LOCALE=ko_KR
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
PHP_CLI_SERVER_WORKERS=4
PHP_BINARY=php
COMPOSER_BINARY=
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mysql
# Write Database Connection (Primary)
DB_WRITE_HOST=127.0.0.1
DB_WRITE_PORT=3306
DB_WRITE_DATABASE=g7
DB_WRITE_USERNAME=root
DB_WRITE_PASSWORD=
# Database Table Prefix (optional)
DB_PREFIX=g7_
# Read Database Connection (Optional - falls back to WRITE if not set)
DB_READ_HOST=
DB_READ_PORT=
DB_READ_DATABASE=
DB_READ_USERNAME=
DB_READ_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
SESSION_COOKIE=g7-session
BROADCAST_CONNECTION=reverb
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
# Laravel Scout (검색엔진)
SCOUT_DRIVER=mysql-fulltext
# Laravel Reverb (WebSocket)
REVERB_APP_ID=
REVERB_APP_KEY=
REVERB_APP_SECRET=
REVERB_HOST="localhost"
REVERB_PORT=8080
REVERB_SCHEME=https
REVERB_VERIFY_SSL=true
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
VITE_REVERB_HOST="${REVERB_HOST}"
VITE_REVERB_PORT="${REVERB_PORT}"
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
CACHE_STORE=database
CACHE_PREFIX=g7-cache-
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PREFIX=g7-database-
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# 코어 업데이트
G7_UPDATE_GITHUB_URL=https://github.com/gnuboard/g7
G7_UPDATE_GITHUB_TOKEN=
G7_UPDATE_PENDING_PATH=
+94
View File
@@ -0,0 +1,94 @@
APP_NAME="그누보드7"
APP_ENV=testing
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.0-alpha.18
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
APP_FAKER_LOCALE=ko_KR
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mysql
# Write Database Connection (Primary)
DB_WRITE_HOST=127.0.0.1
DB_WRITE_PORT=3306
DB_WRITE_DATABASE=g7_testing
DB_WRITE_USERNAME=root
DB_WRITE_PASSWORD=
# Database Table Prefix (optional)
DB_PREFIX=g7_
# Read Database Connection (테스트에서는 WRITE와 동일하게 설정)
# database.php의 read 기본값이 WRITE와 다르므로, 명시 설정 필수
DB_READ_HOST=127.0.0.1
DB_READ_PORT=3306
DB_READ_DATABASE=g7_testing
DB_READ_USERNAME=root
DB_READ_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
SESSION_COOKIE=g7-session
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=file
CACHE_PREFIX=g7-cache-
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PREFIX=g7-database-
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# Installer Admin Account (Used by AdminUserSeeder)
INSTALLER_ADMIN_NAME="관리자"
INSTALLER_ADMIN_EMAIL="admin@example.com"
INSTALLER_ADMIN_PASSWORD=
# Installation Status
INSTALLER_COMPLETED=true
# Telescope Configuration
TELESCOPE_ENABLED=false
TELESCOPE_DB_CONNECTION=mysql
+10
View File
@@ -0,0 +1,10 @@
* text=auto eol=lf
*.blade.php diff=html
*.css diff=css
*.html diff=html
*.md diff=markdown
*.php diff=php
/.github export-ignore
.styleci.yml export-ignore
+105
View File
@@ -0,0 +1,105 @@
*.log
.DS_Store
.env
.env.backup
.phpactor.json
.phpunit.result.cache
/.fleet
/.idea
/.nova
/.phpunit.cache
/.vscode
/.zed
/auth.json
/node_modules/public/hot
/public/storage
/storage/*.key
/storage/pail
/storage/composer
/vendor/*
!/vendor/.gitkeep
/.composer
Homestead.json
Homestead.yaml
Thumbs.db
# Logs
logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
dev-debug.log
# Dependency directories
node_modules/
# Environment variables
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# OS specific
# Task files
# tasks.json
# tasks/
# Settings JSON files (환경별 설정)
/storage/app/settings/
# Bootstrap cache (auto-generated)
/bootstrap/cache/*
!/bootstrap/cache/.gitkeep
# ===== 확장 시스템 =====
# 활성 확장 디렉토리 (설치된 복사본 — Git 제외)
modules/*/
!modules/_bundled/
plugins/*/
!plugins/_bundled/
templates/*/
!templates/_bundled/
# _bundled 내부 의존성 디렉토리 제외
modules/_bundled/*/vendor/
modules/_bundled/*/node_modules/
plugins/_bundled/*/vendor/
plugins/_bundled/*/node_modules/
templates/_bundled/*/node_modules/
# _pending 디렉토리 (외부 다운로드 임시 영역 — 구조만 버전관리)
!modules/_pending/
!plugins/_pending/
!templates/_pending/
# DevTools debug dump
storage/debug-dump/
# 개발 전용 파일
.api-test/
.serena/
.claudeignore
.mcp.json
boost.json
cypress/
cypress.config.js
.env.testing
# ===== 보안: 민감 파일 방어 패턴 =====
*.pem
*.p12
*.pfx
*.jks
*.key
!storage/*.key
credentials.json
service-account.json
id_rsa
id_ed25519
.htpasswd
*.sql
*.sqlite
*.db
+790
View File
@@ -0,0 +1,790 @@
# 그누보드7 Development Guide
> 이 문서는 그누보드7 오픈소스 CMS 프로젝트의 개발 가이드입니다. AI 에이전트 및 외부 기여자를 위한 참고 자료입니다.
## 빠른 참조 - 상세 가이드 문서
<!-- AUTO-GENERATED-START: docs-quick-reference -->
### 백엔드 [backend/](docs/backend/) (19개)
| 문서 | 설명 | TL;DR 핵심 |
|------|------|-----------|
| [activity-log-hooks.md](docs/backend/activity-log-hooks.md) | 활동 로그 훅 레퍼런스 (Activity Log Hooks Reference) | 코어 66훅 + 이커머스 92훅 + 게시판 32훅 + 페이지 8훅 = 총 198훅 |
| [activity-log.md](docs/backend/activity-log.md) | 활동 로그 시스템 (Activity Log System) | Monolog 기반: Service 훅 → Listener → Log::channel('activity... |
| [api-resources.md](docs/backend/api-resources.md) | API 리소스 | BaseApiResource 상속 필수 |
| [authentication.md](docs/backend/authentication.md) | 인증 및 세션 처리 | Laravel Sanctum 토큰 전용 인증 (Bearer 토큰만 사용) |
| [broadcasting.md](docs/backend/broadcasting.md) | Broadcasting (실시간 이벤트) | Laravel Reverb 사용 (WebSocket) |
| [controllers.md](docs/backend/controllers.md) | 컨트롤러 계층 구조 | AdminBaseController / AuthBaseController / PublicBaseCont... |
| [core-config.md](docs/backend/core-config.md) | 코어 설정 (config/core.php) | config/core.php = 코어 권한/역할/메뉴/메일템플릿의 SSoT (Single Source ... |
| [core-update-system.md](docs/backend/core-update-system.md) | 코어 업데이트 시스템 (Core Update System) | 코어 업그레이드 스텝: upgrades/ 디렉토리 (프로젝트 루트), 네임스페이스 App\Upgrades |
| [enum.md](docs/backend/enum.md) | Enum 사용 규칙 | 상태/타입/분류 = Enum 필수 (PHP 8.1+ Backed Enum) |
| [exceptions.md](docs/backend/exceptions.md) | Custom Exception 다국어 처리 | 예외 메시지 하드코딩 금지 → __() 함수 필수 |
| [middleware.md](docs/backend/middleware.md) | 미들웨어 등록 규칙 | 인증 필요 미들웨어 → 전역 등록 금지! |
| [notification-system.md](docs/backend/notification-system.md) | 알림 시스템 (Notification System) | 모든 알림은 BaseNotification 상속 필수 (via() 보일러플레이트 제거) |
| [response-helper.md](docs/backend/response-helper.md) | API 응답 규칙 (ResponseHelper) | 모든 API 응답은 ResponseHelper 사용 |
| [routing.md](docs/backend/routing.md) | 라우트 네이밍 및 경로 | 모든 라우트는 name() 필수: ->name('api.users.index') |
| [search-system.md](docs/backend/search-system.md) | Scout 검색 엔진 시스템 (Search System) | Laravel Scout + DatabaseFulltextEngine: MySQL FULLTEXT + ... |
| [seo-system.md](docs/backend/seo-system.md) | SEO 페이지 생성기 시스템 (SEO Page Generator) | SeoMiddleware: 봇 요청 감지 → ?locale= 파라미터 해석 → SeoRenderer가 ... |
| [service-provider.md](docs/backend/service-provider.md) | 서비스 프로바이더 안전성 | DB 접근 전 .env 파일 존재 확인 필수 |
| [service-repository.md](docs/backend/service-repository.md) | Service-Repository 패턴 | RepositoryInterface 주입 필수 (구체 클래스 직접 주입 금지) |
| [validation.md](docs/backend/validation.md) | 검증 (Validation) | 필수: FormRequest에서 검증 (Service에 검증 로직 배치 금지) |
### 프론트엔드 [frontend/](docs/frontend/) (48개)
| 문서 | 설명 | TL;DR 핵심 |
|------|------|-----------|
| [actions-g7core-api.md](docs/frontend/actions-g7core-api.md) | 액션 시스템 - G7Core API (React 컴포넌트용) | - |
| [actions-handlers-navigation.md](docs/frontend/actions-handlers-navigation.md) | 액션 핸들러 - 네비게이션 | - |
| [actions-handlers-state.md](docs/frontend/actions-handlers-state.md) | 액션 핸들러 - 상태 관리 | - |
| [actions-handlers-ui.md](docs/frontend/actions-handlers-ui.md) | 액션 핸들러 - UI 인터랙션 | - |
| [actions-handlers.md](docs/frontend/actions-handlers.md) | 액션 핸들러 - 핸들러별 상세 사용법 | navigate: 페이지 이동 (path, query, mergeQuery 옵션) |
| [actions.md](docs/frontend/actions.md) | 액션 핸들러 가이드 | 구조: type 또는 event(이벤트), handler(핸들러명), params(옵션) |
| [auth-system.md](docs/frontend/auth-system.md) | 인증 시스템 (AuthManager) | AuthManager: 싱글톤 인증 상태 관리 클래스 |
| [component-props-composite.md](docs/frontend/component-props-composite.md) | 컴포넌트 Props 레퍼런스 - Composite | FileUploader: autoUpload, uploadTriggerEvent, imageCompre... |
| [component-props.md](docs/frontend/component-props.md) | 컴포넌트 Props 레퍼런스 | - |
| [components-advanced.md](docs/frontend/components-advanced.md) | 컴포넌트 고급 기능 | - |
| [components-patterns.md](docs/frontend/components-patterns.md) | 컴포넌트 패턴 및 다국어 | - |
| [components-types.md](docs/frontend/components-types.md) | 컴포넌트 타입별 개발 규칙 | - |
| [components.md](docs/frontend/components.md) | 컴포넌트 개발 규칙 | HTML 태그 직접 사용 금지 (<div> → Div, <button> → Button) |
| [dark-mode.md](docs/frontend/dark-mode.md) | 다크 모드 지원 (engine-v1.1.0+) | Tailwind dark: variant 사용 (예: bg-white dark:bg-gray-800) |
| [data-binding-i18n.md](docs/frontend/data-binding-i18n.md) | 데이터 바인딩 - 다국어 처리 | - |
| [data-binding.md](docs/frontend/data-binding.md) | 데이터 바인딩 및 표현식 | API 데이터: {{user.name}}, URL 파라미터: {{route.id}} |
| [data-sources-advanced.md](docs/frontend/data-sources-advanced.md) | 데이터 소스 - 고급 기능 | - |
| [data-sources.md](docs/frontend/data-sources.md) | 데이터 소스 (Data Sources) | data_sources 배열에 API 정의: id, endpoint, method |
| [editors.md](docs/frontend/editors.md) | 에디터 컴포넌트 가이드 | HtmlEditor: HTML/텍스트 편집, 게시판/상품 설명 등 사용 |
| [g7core-api-advanced.md](docs/frontend/g7core-api-advanced.md) | G7Core 전역 API 레퍼런스 - 고급 | - |
| [g7core-api.md](docs/frontend/g7core-api.md) | G7Core 전역 API 레퍼런스 | G7Core.state: get/set/subscribe 전역 상태 관리 |
| [g7core-helpers.md](docs/frontend/g7core-helpers.md) | G7Core 헬퍼 API | - |
| [layout-json-components-loading.md](docs/frontend/layout-json-components-loading.md) | 레이아웃 JSON - 데이터 로딩 및 생명주기 | - |
| [layout-json-components-rendering.md](docs/frontend/layout-json-components-rendering.md) | 레이아웃 JSON - 조건부/반복 렌더링 | - |
| [layout-json-components-slots.md](docs/frontend/layout-json-components-slots.md) | 레이아웃 JSON - 슬롯 시스템 | - |
| [layout-json-components.md](docs/frontend/layout-json-components.md) | 레이아웃 JSON - 컴포넌트 (반복 렌더링, Blur, 생명주기, 슬롯) | if: 조건부 렌더링 (type: "conditional" 사용 금지!) |
| [layout-json-features-actions.md](docs/frontend/layout-json-features-actions.md) | 레이아웃 JSON - 초기화, 모달, 액션, 스크립트 | - |
| [layout-json-features-error.md](docs/frontend/layout-json-features-error.md) | 레이아웃 JSON - 에러 핸들링 | - |
| [layout-json-features-styling.md](docs/frontend/layout-json-features-styling.md) | 레이아웃 JSON - 스타일 및 계산된 값 | - |
| [layout-json-features.md](docs/frontend/layout-json-features.md) | 레이아웃 JSON - 기능 (에러 핸들링, 초기화, 모달, 액션) | classMap: 조건부 CSS 클래스 (key → variants 매핑) |
| [layout-json-inheritance.md](docs/frontend/layout-json-inheritance.md) | 레이아웃 JSON - 상속 (Extends, Partial, 병합) | extends: 베이스 레이아웃 상속 (type: "slot" 위치에 삽입) |
| [layout-json.md](docs/frontend/layout-json.md) | 레이아웃 JSON 스키마 | HTML 태그 직접 사용 금지 → 기본 컴포넌트 사용 (Div, Button, Span) |
| [layout-testing.md](docs/frontend/layout-testing.md) | 그누보드7 레이아웃 파일 렌더링 테스트 가이드 | createLayoutTest()로 테스트 헬퍼 생성, mockApi()로 API 응답 모킹 |
| [modal-usage.md](docs/frontend/modal-usage.md) | Modal 컴포넌트 사용 가이드 | modals 섹션 모달은 openModal 핸들러로 열고, closeModal 핸들러로 닫음 |
| [responsive-layout.md](docs/frontend/responsive-layout.md) | 반응형 레이아웃 개발 (engine-v1.1.0+) | responsive 속성: 컴포넌트 레벨 breakpoint 오버라이드 (권장) |
| [security.md](docs/frontend/security.md) | 보안 및 검증 | 레이아웃 JSON: FormRequest + Custom Rule 10종 검증 (서버 사전 차단) |
| [state-management-advanced.md](docs/frontend/state-management-advanced.md) | 상태 관리 - 고급 기능 | - |
| [state-management-forms.md](docs/frontend/state-management-forms.md) | 상태 관리 - 폼 자동 바인딩 및 setState | - |
| [state-management.md](docs/frontend/state-management.md) | 전역 상태 관리 | 전역 상태: _global.속성명 (앱 전체 공유, 페이지 이동 시 유지) |
| [tailwind-safelist.md](docs/frontend/tailwind-safelist.md) | Tailwind Safelist 가이드 | Tailwind는 빌드 시 사용된 클래스만 CSS에 포함 |
| [template-development.md](docs/frontend/template-development.md) | 템플릿 개발 가이드라인 | 디렉토리: templates/[vendor-template]/ (예: sirsoft-admin_basic) |
| [template-handlers.md](docs/frontend/template-handlers.md) | 템플릿 전용 핸들러 | setLocale: 앱 언어 변경 — 엔진 빌트인 (ActionDispatcher) |
| [components.md](docs/frontend/templates/sirsoft-admin_basic/components.md) | sirsoft-admin_basic 컴포넌트 | Basic 37개: HTML 래핑 (Div, Button, Input, Select, Form, A, ... |
| [handlers.md](docs/frontend/templates/sirsoft-admin_basic/handlers.md) | sirsoft-admin_basic 핸들러 | setLocale: 앱 언어 변경 (locale 파라미터) |
| [layouts.md](docs/frontend/templates/sirsoft-admin_basic/layouts.md) | sirsoft-admin_basic 레이아웃 | 베이스: _admin_base.json (사이드바 + 헤더 + 콘텐츠 슬롯) |
| [components.md](docs/frontend/templates/sirsoft-basic/components.md) | sirsoft-basic 컴포넌트 | Basic 26개: HTML 래핑 (Div, Button, Input, Select, Form, A, ... |
| [handlers.md](docs/frontend/templates/sirsoft-basic/handlers.md) | sirsoft-basic 핸들러 | setTheme/initTheme: 다크/라이트 모드 전환 (admin과 동일 키 공유) |
| [layouts.md](docs/frontend/templates/sirsoft-basic/layouts.md) | sirsoft-basic 레이아웃 | 베이스: _user_base.json (헤더 + 푸터 + 모바일 네비 + 콘텐츠 슬롯) |
### 확장 시스템 [extension/](docs/extension/) (22개)
| 문서 | 설명 | TL;DR 핵심 |
|------|------|-----------|
| [changelog-rules.md](docs/extension/changelog-rules.md) | Changelog 규칙 (Changelog Rules) | 확장/코어 버전 업 시 CHANGELOG.md에 변경사항 기록 필수 (미기록 시 버전 업 불가) |
| [extension-manager.md](docs/extension/extension-manager.md) | ExtensionManager (확장 관리자) | composer.json 수정 없음 - 런타임 오토로드 방식 사용 |
| [extension-update-system.md](docs/extension/extension-update-system.md) | 확장 업데이트 시스템 (Extension Update System) | 업데이트 감지 우선순위: GitHub > _bundled (2단계, _pending 미참여) |
| [hooks.md](docs/extension/hooks.md) | 훅 시스템 (Hook System) | Action 훅: doAction() - 부가 작업 (로그, 알림, 캐시) |
| [layout-extensions.md](docs/extension/layout-extensions.md) | 레이아웃 확장 시스템 (Layout Extensions) | - |
| [menus.md](docs/extension/menus.md) | 메뉴 시스템 | 구조: User → Role → role_menus 피벗 → Menu |
| [module-assets.md](docs/extension/module-assets.md) | 모듈 프론트엔드 에셋 시스템 | module.json에 에셋 매니페스트 정의 (js, css, loading strategy) |
| [module-basics.md](docs/extension/module-basics.md) | 모듈 개발 기초 | 디렉토리: vendor-module (예: sirsoft-ecommerce) |
| [module-commands.md](docs/extension/module-commands.md) | 모듈 Artisan 커맨드 | 목록: php artisan module:list |
| [module-i18n.md](docs/extension/module-i18n.md) | 모듈 다국어 시스템 | 백엔드: /lang/{locale}/*.php → __('vendor-module::key') |
| [module-layouts.md](docs/extension/module-layouts.md) | 모듈 레이아웃 시스템 | 위치: modules/_bundled/vendor-module/resources/layouts/admi... |
| [module-routing.md](docs/extension/module-routing.md) | 모듈 라우트 규칙 | URL prefix 자동: /api/admin/[vendor-module]/... |
| [module-settings.md](docs/extension/module-settings.md) | 모듈 환경설정 시스템 개발 가이드 | - |
| [permissions.md](docs/extension/permissions.md) | 권한 시스템 | 구조: User → Role → Permission (기능 레벨) |
| [plugin-development.md](docs/extension/plugin-development.md) | 플러그인 개발 가이드 | 디렉토리: plugins/vendor-plugin (예: sirsoft-payment) |
| [storage-driver.md](docs/extension/storage-driver.md) | 스토리지 드라이버 시스템 (StorageInterface) | 모든 파일 저장은 StorageInterface 사용 (Storage::disk() 직접 호출 금지) |
| [template-basics.md](docs/extension/template-basics.md) | 템플릿 시스템 기초 | 타입: Admin (관리자용), User (일반사용자용) |
| [template-caching.md](docs/extension/template-caching.md) | 템플릿 캐싱 전략 | - |
| [template-commands.md](docs/extension/template-commands.md) | 템플릿 Artisan 커맨드 | 목록: php artisan template:list |
| [template-routing.md](docs/extension/template-routing.md) | 템플릿 라우트/언어 파일 규칙 | - |
| [template-security.md](docs/extension/template-security.md) | 템플릿 보안 정책 | - |
| [template-workflow.md](docs/extension/template-workflow.md) | 템플릿 개발 워크플로우 | 필수 파일: template.json, routes.json, _base.json, errors/{40... |
### 공통 (5개)
| 문서 | 설명 | TL;DR 핵심 |
|------|------|-----------|
| [cheatsheet.md](docs/cheatsheet.md) | 그누보드7 자주 쓰는 명령어 치트시트 | _bundled에서 레이아웃 JSON만 수정 → 확장 업데이트(--force)만 실행 (빌드 불필요) |
| [database-guide.md](docs/database-guide.md) | 그누보드7 데이터베이스 개발 가이드 | 마이그레이션: 한국어 comment 필수, down() 구현 필수 |
| [requirements.md](docs/requirements.md) | 그누보드7 시스템 요구사항 (System Requirements) | PHP 8.2+ 필수 |
| [testing-guide.md](docs/testing-guide.md) | 그누보드7 테스트 가이드 | 테스트 통과 = 작업 완료 (작성만으로 불충분!) |
| [auto-document.md](.claude/docs/auto-document.md) | 자동 문서화 (auto-document) | - |
<!-- AUTO-GENERATED-END: docs-quick-reference -->
---
## 프로젝트 개요
**프로젝트명**: 그누보드7
**목적**: 오픈소스 CMS 플랫폼
**설계 원칙**: 코어 수정 최소화, 모듈화, 플러그인 시스템, 템플릿 시스템, 동적 로딩
---
## CRITICAL RULES - 절대 금지 패턴 (DO NOT)
### API/핸들러 호출
| 금지 | 올바른 사용 |
|------|------------|
| `G7Core.actions.execute` | `G7Core.dispatch` |
| `G7Core.api.call` | `G7Core.dispatch({ handler: 'apiCall', ... })` |
| `handler: "api"` | `handler: "apiCall"` |
| `handler: "nav"` | `handler: "navigate"` |
| `handler: "setLocalState"` | `handler: "setState"` + `target: "local"` |
| `navigate` + `replace: true` (URL만 변경 시) | `handler: "replaceUrl"` |
### 데이터 바인딩
| 금지 | 올바른 사용 |
|------|------------|
| `{{products.data}}` | `{{products?.data?.data}}` (배열 경로 확인) |
| `{{value}}` | `{{value ?? ''}}` (fallback 필수) |
| `{{error.data}}` | `{{error.errors}}` (API 응답 구조) |
| `{{error.data?.errors ?? {}}}` | `{{error.errors}}` (`{}}}` 파서 모호성 회피) |
| `$value` (이벤트 값) | `$event.target.value` |
| `{{props.xxx}}` (Partial) | data_sources ID 직접 참조 |
| `{{$response.xxx}}` (onSuccess) | `{{response.xxx}}` ($ 접두사 없음) |
### iteration/반복 렌더링
| 금지 | 올바른 사용 |
|------|------------|
| `"item"`, `"index"` | `"item_var"`, `"index_var"` |
| iteration 내 if 순서 무시 | if가 iteration보다 먼저 평가됨 |
### 컴포넌트 Props
| 금지 | 올바른 사용 |
|------|------------|
| `Icon className="w-4 h-4"` | `Icon size="sm"` 또는 `className="text-sm"` |
| `Select valueKey/labelKey` | computed로 `{ value, label }` 변환 |
| Form 내 `Button` type 없음 | `type="button"` 명시 (submit 방지) |
| `options={{options}}` | `options={{options ?? []}}` (fallback) |
### 상태 관리
| 금지 | 올바른 사용 |
|------|------------|
| 스냅샷 기반 setState | 함수형 업데이트 또는 `stateRef.current` |
| closeModal 후 setState | setState 후 closeModal (순서 중요) |
| sortable 내 폼 자동바인딩 | `parentFormContextProp={undefined}` |
| await 후 캡처된 상태 사용 | await 후 `G7Core.state.getLocal()` 재조회 |
| setState params 키에 `{{}}` 사용 | 키는 정적 경로만, 배열 조작은 `.map()`/`.filter()` |
### 핸들러 정의
| 금지 | 올바른 사용 |
|------|------------|
| `{{handler()}}` (표현식에서 호출) | `actions: [{ handler: "xxx" }]` |
### globalHeaders 사용 (engine-v1.16.0+)
| 금지 | 올바른 사용 |
|------|------------|
| `"globalHeaders": { "X-Key": "value" }` | `"globalHeaders": [{ "pattern": "*", "headers": {...} }]` |
| 모든 API에 개별 headers 설정 | globalHeaders로 공통 헤더 정의 |
| pattern 없이 헤더 정의 | pattern 필수 (`*`, `/api/shop/*` 등) |
---
## 템플릿 엔진 내부 버전 (engine-v1.x.x)
코드와 문서에서 `engine-v1.x.x` 형태의 버전은 **템플릿 엔진의 내부 개발 이력**입니다.
그누보드7 공식 버전(`config/app.php`)과는 무관합니다.
- **CHANGELOG**: `resources/js/core/template-engine/CHANGELOG.md`
- **표기법**: `engine-v1.X.Y` (engine- 접두사 필수, `v1.X.Y` 단독 사용 금지)
- **사용처**: @since JSDoc, 인라인 주석, 규정 문서
### 엔진 CHANGELOG 반영 규칙
| 트리거 | 필수 작업 |
|--------|----------|
| `resources/js/core/template-engine/**` 기능 추가 시 | 마이너 버전 업 (engine-v1.X+1.0) + CHANGELOG 기록 |
| `resources/js/core/template-engine/**` 버그 수정 시 | 패치 버전 업 (engine-v1.X.Y+1) + CHANGELOG 기록 |
| `resources/js/core/*.ts` (TemplateApp, G7CoreGlobals 등) 버그 수정 시 | CHANGELOG `[Unreleased]` 또는 해당 버전에 기록 |
| 코드에 `@since` 추가 시 | CHANGELOG 해당 버전에 항목 추가 |
| 규정 문서에 엔진 버전 표기 시 | `engine-v1.X.Y` 형식 사용 |
### 엔진 CHANGELOG 대상 범위
엔진 CHANGELOG에 기록하는 대상은 **엔진 코어 코드**의 변경사항입니다:
| 포함 (엔진 코드) | 제외 (비엔진 코드) |
|------------------|---------------------|
| `resources/js/core/template-engine/**` | `templates/**/src/components/**` (템플릿 컴포넌트) |
| `resources/js/core/TemplateApp.ts` | `modules/**/resources/layouts/**` (모듈 레이아웃) |
| `resources/js/core/G7CoreGlobals.ts` | `resources/layouts/**` (코어 레이아웃 JSON) |
| `resources/js/core/template-engine.ts` | `docs/**` (규정 문서) |
| `resources/js/core/types/` (엔진 타입) | 백엔드 PHP 코드 |
### 엔진 CHANGELOG 작성 형식
Keep a Changelog 표준:
- `### Added` — 새 기능
- `### Fixed` — 버그 수정
- `### Changed` — 기존 기능 변경
- `### Deprecated` — 곧 제거될 기능
- `### Removed` — 제거된 기능
### CHANGELOG 항목 작성 규칙
엔진 코드 수정 후 CHANGELOG 미기록 시 작업 미완료로 간주합니다.
**항목 형식**: `- 수정 내용 요약 (수정 파일명)`
```markdown
# 좋은 예
- setState dot notation 멀티 키 병합 시 이전 키 변경 유실 방지 (ActionDispatcher)
- blocking 데이터소스 + errorHandling 데드락 — fallback 동기 적용, 에러핸들러 비동기 실행
# 나쁜 예
- 버그 수정 ← 무엇을 수정했는지 불명확
- ActionDispatcher.ts 수정 ← 파일명만으로는 변경 내용 파악 불가
```
**패치 버전 항목 형식**: `- (engine-v1.X.Y) 수정 내용 (파일명)`
```markdown
- (engine-v1.17.5) dataKey 자동 바인딩 컴포넌트에서 setState 호출 시 stale 값 방지
```
**버전 결정 기준**:
| 상황 | 버전 처리 |
|------|----------|
| 새 기능 추가 (핸들러, 속성, API) | 마이너 버전 업: `engine-v1.X+1.0` |
| 기존 기능 버그 수정 | 패치 버전 업: `engine-v1.X.Y+1` |
| 특정 버전에 귀속 불가한 수정 | `[Unreleased]` 섹션에 기록 |
| 릴리스 시 | `[Unreleased]` → `[engine-v1.X.0]`으로 이동 |
**대규모 Fixed 섹션 카테고리 분류** (항목 10개 초과 시):
```markdown
### Fixed
#### 상태 동기화
- 항목 1
- 항목 2
#### 캐시
- 항목 3
```
---
## 레이아웃 JSON 구현 규칙
```
1. 새로운 기능 사용 전 → 반드시 해당 규정 문서에서 지원 여부 확인
2. 지원되지 않는 문법 사용 금지 → 추측/가정으로 구현하지 않음
3. 불확실한 경우 → 기존 레이아웃 패턴 참조
4. 규정 문서에 없는 기능 → 절대 사용 금지
```
### 레이아웃 작성 체크리스트
```
□ 레이아웃 구조가 layout-json.md 스키마와 일치하는가?
□ 사용할 컴포넌트가 components.md에 정의되어 있는가?
□ 컴포넌트 props가 component-props.md에 정의된 것만 사용하는가?
□ 사용할 핸들러가 actions.md에 정의되어 있는가?
□ 핸들러의 params 구조가 actions-handlers.md와 일치하는가?
□ 데이터 바인딩 문법이 data-binding.md에 정의된 형식인가?
□ 다크 모드 클래스가 dark-mode.md 규칙을 따르는가?
□ 기존 유사 레이아웃에서 동일 패턴이 사용되고 있는가?
```
### 주의 사항
```text
필수: 규정 문서에 정의된 핸들러/props/바인딩 문법만 사용 (API 응답 구조도 확인 후 바인딩)
필수: Partial은 컴포넌트 치환만 수행 (computed, data_sources, modals, state 미지원)
필수: data_sources ID 고유성 유지, 조건부 렌더링은 if 속성만 사용 (type: "conditional" 미지원)
```
---
## 테스트 프로토콜
```text
기능 구현 = 테스트 코드 작성 필수
테스트 통과 = 작업 완료 (작성만으로 불충분!)
기존 테스트 있음 → 변경사항 반영하여 수정 후 실행
기능 구현 시 관련된 모든 계층(백엔드+프론트엔드+레이아웃 렌더링) 테스트 필수
주의: 모듈/플러그인 프론트엔드 테스트는 독립 vitest.config.ts 사용 (루트 config 포함 금지)
```
### 그누보드7 레이아웃 렌더링 테스트
```text
그누보드7 레이아웃 테스트는 브라우저 기반 E2E가 아님!
Vitest + createLayoutTest() 유틸리티 사용 → 추가 인프라 불필요
"인프라 부족" 이유로 레이아웃 테스트 건너뛰기 절대 금지
레이아웃 테스트는 해당 레이아웃이 속한 확장 디렉토리에 작성
모듈 테스트: modules/_bundled/{id}/resources/js/__tests__/layouts/
템플릿 테스트: templates/_bundled/{id}/__tests__/layouts/
코어 테스트: resources/js/core/template-engine/__tests__/layouts/
```
| 특성 | 설명 |
|------|------|
| **테스트 환경** | Vitest (jsdom) - 브라우저 불필요 |
| **렌더링** | DynamicRenderer를 통한 실제 React 렌더링 |
| **유틸리티** | `createLayoutTest()` - 이미 구축됨 |
| **API 모킹** | `mockApi()` - fetch 자동 모킹 |
| **상태 관리** | `getState()`, `setState()` - 즉시 사용 가능 |
| **액션 트리거** | `triggerAction()` - 핸들러 실행 |
```typescript
import { createLayoutTest, screen } from '../utils/layoutTestUtils';
const testUtils = createLayoutTest(layoutJson);
testUtils.mockApi('products', { response: { data: [] } });
await testUtils.render();
expect(screen.getByTestId('element')).toBeInTheDocument();
testUtils.cleanup();
```
### 테스트 작성 트리거
| 수정 대상 | 테스트 파일 위치 | 테스트 유형 |
|----------|-----------------|-------------|
| `app/Models/*.php` | `tests/Unit/Models/*Test.php` | 모델 메서드, 관계, 스코프 |
| `app/Services/*.php` | `tests/Unit/Services/*Test.php` | 비즈니스 로직 |
| `app/Enums/*.php` | `tests/Unit/Enums/*Test.php` | Enum 메서드 |
| `app/Http/Controllers/**/*.php` | `tests/Feature/**/*Test.php` | API 엔드포인트 |
| `database/migrations/*.php` | 해당 모델/서비스 테스트에서 검증 | 스키마 변경 |
| `templates/**/src/components/**/*.tsx` | `templates/**/__tests__/*.test.tsx` | 컴포넌트 |
| `resources/js/core/**/*.ts` | `resources/js/core/__tests__/*.test.ts` | 템플릿 엔진 |
| `resources/layouts/**/*.json` | `resources/js/core/template-engine/__tests__/layouts/*.test.tsx` | 코어 레이아웃 렌더링 |
| `modules/**/resources/layouts/**/*.json` | `modules/_bundled/{id}/resources/js/__tests__/layouts/*.test.tsx` | 모듈 레이아웃 렌더링 |
| `templates/**/layouts/**/*.json` | `templates/_bundled/{id}/__tests__/layouts/*.test.tsx` | 템플릿 레이아웃 렌더링 |
### 기능 구현 시 전 계층 테스트
| 작업 유형 | 백엔드 (PHPUnit) | 프론트엔드 (Vitest) | 레이아웃 렌더링 (Vitest) |
| ---------- | ----------------- | ------------------- | ---------------------- |
| 새 화면 구현 | API 엔드포인트 테스트 | 컴포넌트 테스트 | 레이아웃 JSON 렌더링 테스트 |
| 기존 화면 수정 | 변경된 API 테스트 | 변경된 컴포넌트 테스트 | 레이아웃 렌더링 회귀 테스트 |
| 데이터 흐름 변경 | Service/Repository 테스트 | 상태 관리 테스트 | 데이터 바인딩 렌더링 테스트 |
### Windows 환경 테스트 규칙
```text
프론트엔드 (npm/Vitest) → PowerShell 래퍼 필수
백엔드 (PHPUnit/Laravel) → Bash 직접 실행
```
**프론트엔드 (템플릿 디렉토리에서 실행 권장)**:
```bash
# 템플릿 디렉토리에서 실행 (해당 템플릿만 테스트)
cd templates/sirsoft-admin_basic
powershell -Command "npm run test:run" # 전체
powershell -Command "npm run test:run -- DataGrid" # 특정 테스트
# 루트에서 실행 (모든 테스트)
powershell -Command "npm run test:run"
powershell -Command "npm run test:run -- template-engine" # 코어 테스트
```
**백엔드**:
```bash
php artisan test
php artisan test --filter=TestName
```
**_bundled 확장 테스트 (활성 디렉토리 복사 불필요)**:
```bash
# _bundled 모듈 테스트 직접 실행
php vendor/bin/phpunit modules/_bundled/sirsoft-ecommerce/tests
php vendor/bin/phpunit --filter=ShippingPolicyControllerTest modules/_bundled/sirsoft-ecommerce/tests
# _bundled 모듈 프론트엔드 테스트
cd modules/_bundled/sirsoft-ecommerce
powershell -Command "npm run test:run"
```
### 필수 준수 사항
```text
필수: 기능 구현 시 모든 계층(백엔드+프론트엔드+레이아웃) 테스트 포함
필수: 테스트 통과 확인 후 완료 선언 (기존 테스트 유지 — 삭제/skip 금지)
필수: createLayoutTest() 유틸리티 활용 (추가 인프라 불필요)
```
> 상세: [testing-guide.md](docs/testing-guide.md) | [layout-testing.md](docs/frontend/layout-testing.md)
---
## 핵심 원칙
### 1. 동적 로딩
```
절대 금지: composer.json에 모듈/플러그인 하드코딩
필수: /modules와 /plugins 디렉토리 스캔으로 자동 발견
```
### 2. 코어 수정 최소화
- 모든 확장은 모듈/플러그인으로 구현
- 훅 시스템을 통한 기능 추가
- 서비스 계층에서 훅 실행
### 3. 계층 분리
```
Controller → Request → Service → RepositoryInterface → Repository → Model
```
### 4. Repository 인터페이스
```
절대 금지: Repository 구체 클래스 직접 타입힌트
필수: Repository 인터페이스를 통한 DI
필수: CoreServiceProvider에서 인터페이스-구현체 바인딩
```
---
## 기술 스택
### 백엔드
- **PHP**: 8.2+
- **Laravel**: 12.x
- **데이터베이스**: MySQL 8.0
- **인증**: Laravel Sanctum 4.x
- **테스트**: PHPUnit 11.x
- **코드 스타일**: Laravel Pint (PSR-12)
---
## 아키텍처 패턴
### 디렉토리 구조 개요
```text
/
├── /app # 코어 애플리케이션
├── /modules # 모듈 디렉토리
│ ├── _bundled/ # 선탑재 확장 소스 (Git 추적)
│ ├── _pending/ # 외부 다운로드 대기소 (Git 제외)
│ └── vendor-module/ # 활성 설치 디렉토리 (Git 제외)
├── /plugins # 플러그인 디렉토리 (동일 구조)
├── /templates # 템플릿 디렉토리 (동일 구조)
├── /resources/js/core/ # 코어 렌더링 엔진
└── /public/build/ # Vite 빌드 결과
```
### 네이밍 규칙
| 항목 | 디렉토리명 | 네임스페이스 |
|------|-----------|-------------|
| 모듈 | `sirsoft-ecommerce` | `Modules\Sirsoft\Ecommerce\` |
| 플러그인 | `sirsoft-payment` | `Plugins\Sirsoft\Payment\` |
| 템플릿 | `sirsoft-admin_basic` | - |
---
## 백엔드 개발 - 핵심 요약
> 상세: [docs/backend/](docs/backend/) | [database-guide.md](docs/database-guide.md)
```text
절대 금지: Service 클래스에 검증 로직 구현 → FormRequest + Custom Rule 사용
절대 금지: FormRequest authorize()에서 인증/권한 로직 → permission 미들웨어 사용
필수: __() 함수를 사용한 다국어 처리
필수: 상태/타입/분류는 Enum으로 정의
절대 금지: 인증 필요 미들웨어를 append()로 전역 등록 → appendToGroup('api') 사용
절대 금지: DB CASCADE에 의존한 삭제 → Service에서 명시적 삭제 (훅/파일/로깅 보장)
절대 금지: 로케일 하드코딩 → config('app.supported_locales') 사용
필수: 마이그레이션 한국어 comment 필수, down() 구현 필수
주의: ResponseHelper::success($messageKey, $data) — 메시지가 첫 번째 인수
```
> 상세 규칙 (API 리소스, ServiceProvider, validation, 인증, 활동 로그 등): [docs/backend/](docs/backend/) 각 문서 참조
### 컨트롤러 계층
```text
BaseApiController (최상위)
├── AdminBaseController (관리자 전용)
├── AuthBaseController (인증된 사용자)
└── PublicBaseController (공개 API)
```
### 파사드 사용
```text
✅ use Illuminate\Support\Facades\Log; → Log::info()
❌ \Log::info(), auth()->user() 금지
```
---
## 프론트엔드/템플릿 시스템
> 상세: [docs/frontend/](docs/frontend/)
```text
필수: 기본 컴포넌트만 사용 (Div, Button, H2 등 — HTML 태그 직접 사용 금지)
필수: 집합 컴포넌트 재사용 우선
필수: 다크 모드 light/dark variant 함께 지정
필수: HtmlEditor 사용 (RichTextEditor 미구현)
```
---
## 확장 시스템 빠른 참조
> 상세: [docs/extension/](docs/extension/)
```text
필수: 모든 확장 작업은 _bundled 디렉토리에서만 수행 (활성 디렉토리 직접 수정 금지)
필수: 프로덕션 반영은 update 커맨드로만 수행 (_bundled → 활성 디렉토리)
필수: 확장 코드 변경 시 manifest 버전 업 (미변경 시 업데이트 감지 불가)
필수: 버전 업 시 CHANGELOG.md 기록 — Keep a Changelog 표준 (미기록 시 버전 업 불가)
필수: StorageInterface 사용 (Storage::disk() 직접 호출 금지)
필수: 코어 레이아웃에 모듈 UI 주입은 layout_extensions만 사용
필수: 모든 확장 작업은 Artisan 커맨드로 수행
```
> 상세 규칙 (플러그인 의존성, 훅 시스템, 버전 동기화, 업그레이드 스텝 등): [docs/extension/](docs/extension/) 각 문서 참조
### 확장 타입 요약
| 타입 | 네이밍 | 네임스페이스 | 예시 |
|------|--------|-------------|------|
| 모듈 | vendor-module | Modules\Vendor\Module\ | sirsoft-ecommerce |
| 플러그인 | vendor-plugin | Plugins\Vendor\Plugin\ | sirsoft-payment |
| 템플릿 | vendor-template | - | sirsoft-admin_basic |
---
## 한국어 사용 규칙
```
한국어: 사용자 대상 텍스트, 주석, 문서, 커밋 메시지, DB comment
영어: 변수명, 함수명, 클래스명
Laravel 기본 메서드 주석은 영어 유지 (up(), down() 등)
```
---
## 코드 품질
### Laravel Pint
```bash
vendor/bin/pint --dirty
```
### PHPDoc
```php
/**
* 상품을 생성합니다.
*
* @param array $data 상품 생성 데이터
* @return Product 생성된 상품 모델
* @throws \Exception 생성 실패 시
*/
public function createProduct(array $data): Product
```
---
## 빌드 vs 확장 업데이트
| 수정 파일 유형 | 필요한 작업 |
|---------------|-------------|
| `*.json` (레이아웃만) | `{type}:update {id} --force` 실행 |
| `*.tsx`, `*.ts` + `*.json` | `{type}:build` + `{type}:update {id} --force` |
| `*.tsx`, `*.ts`만 | `{type}:build` + `{type}:update {id} --force` |
```bash
# 확장 업데이트 (_bundled → 활성 반영)
php artisan template:update sirsoft-admin_basic --force
php artisan module:update sirsoft-ecommerce --force
php artisan plugin:update sirsoft-payment --force
```
### 빌드 명령어 (Artisan)
```bash
# 코어 템플릿 엔진 (resources/js/core/template-engine/**)
php artisan core:build # 기본: 템플릿 엔진만 빌드
php artisan core:build --full # 전체 빌드 (npm run build)
php artisan core:build --watch # 파일 감시 모드
# 모듈 빌드 (기본: _bundled 디렉토리)
php artisan module:build sirsoft-ecommerce # _bundled에서 빌드
php artisan module:build --all # 모든 _bundled 모듈 빌드
php artisan module:build sirsoft-ecommerce --watch # 활성 디렉토리에서 watch
php artisan module:build sirsoft-ecommerce --active # 활성 디렉토리에서 빌드
# 템플릿 빌드 (기본: _bundled 디렉토리)
php artisan template:build sirsoft-admin_basic # _bundled에서 빌드
php artisan template:build --all # 모든 _bundled 템플릿 빌드
php artisan template:build sirsoft-admin_basic --watch # 활성 디렉토리에서 watch
php artisan template:build sirsoft-admin_basic --active # 활성 디렉토리에서 빌드
# 플러그인 빌드 (기본: _bundled 디렉토리)
php artisan plugin:build sirsoft-payment # _bundled에서 빌드
php artisan plugin:build --all # 모든 _bundled 플러그인 빌드
php artisan plugin:build sirsoft-payment --watch # 활성 디렉토리에서 watch
php artisan plugin:build sirsoft-payment --active # 활성 디렉토리에서 빌드
```
> **빌드 원칙**: 기본값은 `_bundled` 디렉토리. 빌드 결과물은 빌드 경로 내에만 남음.
> 활성 디렉토리 반영은 `update` 커맨드로만 수행. `--watch` 모드는 실시간 개발용으로 활성 디렉토리를 자동 사용.
---
## 확장 시스템 Artisan 명령어
```bash
# 코어 업데이트
php artisan core:check-updates # 코어 업데이트 확인
php artisan core:update [--force] [--no-backup] [--no-maintenance] # 코어 업데이트 실행
# 모듈
php artisan module:list
php artisan module:install [identifier]
php artisan module:activate [identifier]
php artisan module:deactivate [identifier]
php artisan module:uninstall [identifier]
php artisan module:composer-install [identifier?] [--all]
php artisan module:cache-clear [identifier?]
php artisan module:seed [identifier] [--sample] [--count=key=value]
php artisan module:check-updates [identifier?]
php artisan module:update [identifier] [--force]
# 플러그인
php artisan plugin:list
php artisan plugin:install [identifier]
php artisan plugin:activate [identifier]
php artisan plugin:deactivate [identifier]
php artisan plugin:uninstall [identifier]
php artisan plugin:composer-install [identifier?] [--all]
php artisan plugin:cache-clear [identifier?]
php artisan plugin:seed [identifier] [--sample] [--count=key=value]
php artisan plugin:check-updates [identifier?]
php artisan plugin:update [identifier] [--force]
# 템플릿
php artisan template:list
php artisan template:install [identifier]
php artisan template:activate [identifier]
php artisan template:deactivate [identifier]
php artisan template:uninstall [identifier]
php artisan template:cache-clear
php artisan template:check-updates [identifier?]
php artisan template:update [identifier] [--layout-strategy=overwrite] [--force]
# Composer 의존성 (모듈/플러그인별 독립 vendor/)
php artisan extension:composer-install
# 오토로드
php artisan extension:update-autoload
```
---
## SEO Artisan 커맨드
```bash
php artisan seo:warmup [--layout=]
php artisan seo:clear [--layout=]
php artisan seo:stats
php artisan seo:generate-sitemap [--sync]
```
---
## 코드 스타일/마이그레이션 명령어
```bash
# 코드 스타일 (Laravel Pint)
vendor/bin/pint --dirty
# 마이그레이션
php artisan make:migration create_[table]_table
php artisan migrate
php artisan migrate:rollback
```
---
## 파일 유형별 규정 확인
파일 수정 **전** 해당 규정 파일을 먼저 확인합니다:
| 수정 대상 파일 패턴 | 작업 전 필수 참조 |
| ------------------- | ------------------ |
| `app/Http/Controllers/**` | [controllers.md](docs/backend/controllers.md) |
| `app/Services/**` | [service-repository.md](docs/backend/service-repository.md) |
| `app/Http/Requests/**` | [validation.md](docs/backend/validation.md) |
| `app/Repositories/**` | [service-repository.md](docs/backend/service-repository.md) |
| `app/Http/Resources/**` | [api-resources.md](docs/backend/api-resources.md) |
| `database/migrations/**` | [database-guide.md](docs/database-guide.md) |
| `database/seeders/**` | [database-guide.md](docs/database-guide.md) |
| `resources/layouts/**/*.json` | [layout-json.md](docs/frontend/layout-json.md) |
| `templates/**/layouts/**/*.json` | [layout-json.md](docs/frontend/layout-json.md) |
| `templates/**/src/components/**/*.tsx` | [components.md](docs/frontend/components.md) |
| `modules/**/Listeners/**` | [hooks.md](docs/extension/hooks.md) |
| `plugins/**/Listeners/**` | [hooks.md](docs/extension/hooks.md) |
| `lang/**` | [database-guide.md](docs/database-guide.md) (다국어 섹션) |
| `routes/**` | [routing.md](docs/backend/routing.md) |
| `app/Seo/**` | [seo-system.md](docs/backend/seo-system.md) |
---
## 참고 파일 위치
- **AbstractModule**: `app/Extension/AbstractModule.php`
- **HookManager**: `app/Extension/HookManager.php`
- **ModuleManager**: `app/Extension/ModuleManager.php`
- **PluginManager**: `app/Extension/PluginManager.php`
- **TemplateManager**: `app/Extension/TemplateManager.php`
- **CoreStorageDriver**: `app/Extension/Storage/CoreStorageDriver.php`
- **ResponseHelper**: `app/Helpers/ResponseHelper.php`
- **ExtensionStatusGuard**: `app/Extension/Helpers/ExtensionStatusGuard.php`
- **ExtensionBackupHelper**: `app/Extension/Helpers/ExtensionBackupHelper.php`
- **ExtensionPendingHelper**: `app/Extension/Helpers/ExtensionPendingHelper.php`
- **ExtensionRoleSyncHelper**: `app/Extension/Helpers/ExtensionRoleSyncHelper.php`
- **ExtensionMenuSyncHelper**: `app/Extension/Helpers/ExtensionMenuSyncHelper.php`
- **SettingsMigrator**: `app/Extension/Helpers/SettingsMigrator.php`
- **UpgradeStepInterface**: `app/Contracts/Extension/UpgradeStepInterface.php`
- **UpgradeContext**: `app/Extension/UpgradeContext.php`
- **SeoRenderer**: `app/Seo/SeoRenderer.php`
- **SeoMiddleware**: `app/Seo/SeoMiddleware.php`
- **SeoCacheManager**: `app/Seo/SeoCacheManager.php`
- **SeoServiceProvider**: `app/Seo/SeoServiceProvider.php`
- **SitemapContributorInterface**: `app/Seo/Contracts/SitemapContributorInterface.php`
- **SitemapGenerator**: `app/Seo/SitemapGenerator.php`
- **ActivityLogChannel**: `app/ActivityLog/ActivityLogChannel.php`
- **ActivityLogHandler**: `app/ActivityLog/ActivityLogHandler.php`
- **ActivityLogProcessor**: `app/ActivityLog/ActivityLogProcessor.php`
- **ResolvesActivityLogType**: `app/ActivityLog/Traits/ResolvesActivityLogType.php`
- **ChangeDetector**: `app/ActivityLog/ChangeDetector.php`
- **CoreActivityLogListener**: `app/Listeners/CoreActivityLogListener.php`
+829
View File
@@ -0,0 +1,829 @@
# Changelog
이 프로젝트의 모든 주요 변경사항을 기록합니다.
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [Unreleased]
## [7.0.0-beta.1] - 2026-04-01
### Changed
- 오픈 베타 릴리즈
## [7.0.0-alpha.21] - 2026-03-30
### Added
- 인스톨러 PHP CLI/Composer 필수 검증 기능 — 기본 `php` 미감지 시 CLI 설정 필수 전환, Composer 실행 확인 및 미설치 시 설치 안내, 둘 다 검증 완료 전 다음 단계 진행 차단
- 템플릿 레이아웃 수정 감지 시스템 — `original_content_hash`/`original_content_size` 컬럼 추가, SHA-256 해시 기반 수정 감지로 `updated_by` 방식 대체 (TemplateManager, LayoutRepository, 마이그레이션)
- 관리자 로그인 화면 개선 — 비밀번호 찾기/재설정 플로우 추가, 테마 Segmented Control, 언어 셀렉트 확대 (PasswordResetController, PasswordResetNotification, ForgotPasswordRequest, ResetPasswordRequest)
- `{{raw:expression}}` 바인딩 번역 면제 마커 시스템 — `$t:` 자동 번역 대상에서 특정 바인딩을 제외하는 마커 도입 (rawMarkers.ts)
- 레이아웃 프리뷰 모드 — 관리자 레이아웃 편집기에서 실시간 미리보기 지원 (LayoutPreviewController, LayoutPreviewService, 마이그레이션)
- 활동 로그 이력 조회 메뉴 신설 — ActivityLogController, ActivityLogResource, ActivityLogService, ActivityLogRepository, config/core.php 메뉴/권한 등록, 관리자 레이아웃 추가
### Changed
- `window.G7Config` 설정 노출 최소화 Phase 1 — 프론트엔드 미참조 설정의 브라우저 소스 노출 차단
- SettingsService: 필드 레벨 `expose: false` 지원 추가 (formatCategorySettings)
- FiltersFrontendSchema: `fields: {}` (빈 객체) 시 전체 차단 안전 기본값 적용
- View Composers: TemplateComposer/UserTemplateComposer에 ModuleSettingsService 주입 — frontend_schema 기반 필터링 적용 (기존 config() 직접 참조 우회 수정)
- 코어 defaults.json: general 4개 필드, security 전체, seo 전체, advanced 9개 필드(debug_mode 제외), upload 2개 필드, drivers 전체를 `expose: false` 처리
- 캐시 무효화 로직 정비 — 016862cb 이전의 단순한 방식으로 복귀 (버전 증가 + TTL 자연 만료), `extension_cache_previous_versions` 추적 및 `getCacheVersionsToInvalidate()`/`getCacheVersionsForLayoutInvalidation()` 제거, Cache Tags 드라이버 분기 제거로 캐시 드라이버 비의존성 확보
### Fixed
- MariaDB를 `DB_CONNECTION=mysql` 드라이버로 연결 시 `WITH PARSER ngram` 에러로 인스톨러 설치 실패 수정 — `isMariaDb()` 메서드 추가하여 서버 버전 문자열 기반 실제 DBMS 감지 (DatabaseFulltextEngine)
- 캐시 무효화 버전 키 누락 수정 — `warmTemplateCache()`가 버전 포함 키(`.v{version}`)로 캐시를 생성하나 `clearTemplateCache()`가 레거시 키만 삭제하던 문제, routes/language 캐시에 버전 포함 키 삭제 추가
- 캐시 버전 0 무효화 누락 수정 — `array_filter()`가 버전 `0`을 falsy로 제거하여 `.v0` 캐시 키가 삭제 대상에서 누락되던 버그 (ClearsTemplateCaches, InvalidatesLayoutCache)
- 테스트 활성 디렉토리 보호 강화 — `ProtectsExtensionDirectories`에 `moveDirectory` spy 추가, `copyToActive()` 원자적 교체가 활성 디렉토리를 rename으로 파괴하는 것 방지
- 활동 로그 하위 호환 — `ActivityLogResource`에서 기존 DB 레코드(`type: 'text'`)의 enum 필드를 모델의 현재 `$activityLogFields` 기준으로 동적 번역 + 일괄 변경 `:count` 미치환 보정
- 활동 로그 enum 미번역 전수 수정 — 14개 모델 필드의 `$activityLogFields` `type: 'enum'` 전환 + 14개 Enum 클래스에 `labelKey()` 메서드 추가 (User, Order, OrderOption, Product, Coupon, Post, Comment, Board)
- 활동 로그 삭제/일괄삭제 `description_key` 및 샘플 시더 누락 추가 — 코어 시더 `activity_log_descriptions` 테이블에 delete/bulk_delete 키 보충 + 다국어 파일 동기화
- 관리자 다크모드 품질 전수 조사 및 수정 — 레이아웃 JSON ~546건, TSX ~37건, CSS 5건의 누락된 `dark:` variant 추가 (이커머스 101파일, 게시판 19파일, 페이지 2파일, 마케팅 1파일, 템플릿 컴포넌트 ~20파일)
- 레이아웃 편집 캐시 무효화 및 버전 히스토리 수정 — LayoutService의 저장/복원 시 PublicLayoutController 서빙 캐시 무효화 + `extension_cache_version` 증가 누락 수정
- 사용자 관리에서 관리자/슈퍼관리자 삭제 시 토스트 메시지 및 어빌리티 처리 — CannotDeleteAdminException 추가, UserResource에 `can_delete` 어빌리티 반영
- ActivityLog 핸들러 `$result` 타입 불일치 수정 — CoreActivityLogListener에서 `bool` → `array` 반환 타입 정합성 보정
- 환경설정 사이트 로고 저장 시 Attachment 객체 정수 검증 오류 수정 — SaveSettingsRequest에서 Attachment JSON 객체를 정수로 검증하던 문제 수정
- 관리자 SPA 네비게이션 로고 깜빡임 수정 — extends base 컴포넌트 불필요 remount 방지 (`_fromBase` 마킹 기반 stable key 패턴)
- 인스톨러 다크모드 적색 배경 가독성 개선 — `alert-title`, `alert-message`, `permission-badge`, `test-result` 다크모드 색상 override 추가
- 인스톨러 `BASE_PATH` 심볼릭 링크 미해석 — `realpath()` 적용 + 절대경로/상대경로 병기로 호스팅 환경 대응
- 인스톨러 403 에러 페이지 다크모드 미지원 — `prefers-color-scheme: dark` 미디어쿼리 추가
- 템플릿 업데이트 수정 감지 실패 — `LayoutRepository::update()`가 `updated_by`를 설정하지 않아 `hasModifiedLayouts()`가 항상 "수정 없음" 반환하던 문제 수정 (hash 비교 방식으로 전환)
- 템플릿 업데이트 "수정 유지" 전략 미작동 — `layoutStrategy === 'keep'` 시 `refreshTemplateLayouts()` 미호출 → 양쪽 전략 모두 호출하되 `preserveModified` 플래그로 분기
- 템플릿 업데이트 API 응답 키 불일치 — 백엔드 `has_modified` → 프론트엔드 기대 `has_modified_layouts`/`modified_count` 키 정렬
- 템플릿/모듈/플러그인 설치 시 `incrementExtensionCacheVersion()` 누락 수정 — TemplateManager(install/activate/deactivate/uninstall) + ModuleManager(install) + PluginManager(install) 총 7곳 추가
- 캐시 버전 변경 시 프론트엔드 다국어 미갱신 수정 — TemplateApp.ts에서 캐시 버전 변경 감지 시 TranslationEngine 재로드 추가
- `warmTemplateCache()` 다국어 캐시 워밍 시 `$partial` 디렉티브 미해석 수정 — `json_decode`만 수행하던 코드를 `TemplateService::getLanguageDataWithModules()` 호출로 교체하여 fragment 해석 및 모듈/플러그인 다국어 병합 정상화
- `ActivateTemplateCommand` 의존성 미충족 시 성공으로 보고하는 버그 수정 — `if ($result)` → `if ($result['success'])` 변경 및 의존성 경고 메시지 출력, `--force` 옵션 추가
- `ActivateModuleCommand`/`ActivatePluginCommand` 의존성 미충족 시 경고 메시지 미출력 수정 — 의존성 경고 표시 및 `--force` 옵션 전달 추가
## [7.0.0-alpha.20] - 2026-03-30
### Added
- Laravel Scout 통합 및 MySQL FULLTEXT(ngram) 검색엔진 드라이버 확장 시스템 도입 — 커스텀 `DatabaseFulltextEngine`으로 `MATCH...AGAINST IN BOOLEAN MODE` 지원, `core.search.engine_drivers` 필터 훅으로 Meilisearch/Elasticsearch 등 외부 엔진 플러그인 등록 가능
- `DatabaseFulltextEngine` 다중 DBMS 호환 — FULLTEXT 미지원 DBMS(PostgreSQL, SQLite)에서 LIKE fallback 자동 전환, `whereFulltext()` 정적 헬퍼(관계 검색용), `addFulltextIndex()` 마이그레이션 헬퍼(DBMS별 조건부 DDL)
- `config/scout.php` 설정 파일 추가 — 기본 드라이버 `mysql-fulltext`, `SCOUT_DRIVER` 환경변수로 전환 가능
- `FulltextSearchable` 인터페이스 — FULLTEXT 검색 대상 컬럼 및 가중치 정의 계약
- `AsUnicodeJson` 커스텀 캐스트 — JSON 컬럼에 한글을 `\uXXXX` 이스케이프 없이 실제 UTF-8로 저장하여 FULLTEXT ngram 토크나이저 정상 동작 보장
- 환경설정 > 드라이버 탭에 검색엔진 설정 카드 추가 — 기본 MySQL FULLTEXT(ngram) 드라이버 표시, 플러그인 설치 시 추가 드라이버 자동 표시
- `SaveSettingsRequest`에 검색엔진 드라이버 동적 validation 추가 — `core.search.engine_drivers` 필터 훅 기반 허용 목록
## [7.0.0-alpha.19] - 2026-03-29
### Added
- 검색/필터/정렬 성능 향상을 위한 누락 인덱스 일괄 추가 — activity_logs(description_key), users(created_at), mail_send_logs(status), template_layouts(template_id), schedules(created_at)
## [7.0.0-alpha.18] - 2026-03-26
### Added
- SEO ExpressionEvaluator 산술 연산자 확장 — `*`, `/`, `%` 추가 (기존 `+`, `-`만 지원)
- SEO PipeRegistry 파이프 함수 엔진 구현 — 프론트엔드 PipeRegistry.ts 빌트인 파이프 15종 PHP 미러링 (date, datetime, relativeTime, number, truncate, uppercase, lowercase, stripHtml, default, fallback, first, last, join, length, filterBy, keys, values, json, localized)
- ActivityLog `description_params` ID→이름 변환 필터 훅 (`core.activity_log.filter_description_params`) — `ActivityLog::getLocalizedDescriptionAttribute()`에서 실행
- 코어 모델 `$activityLogFields` 정의 — `User`, `Role`, `Menu`, `Schedule`, `MailTemplate` (5개 모델)
- ActivityLog ChangeDetector 필드 라벨 다국어 키 추가 (`lang/ko/activity_log.php`, `lang/en/activity_log.php` — `fields` 섹션)
- `module_helpers.php` — `getModuleSetting()` 헬퍼 함수 추가
- CoreActivityLogListener: bulk_update per-User/per-Schedule 전환 + bulk_delete per-Schedule 전환 (건별 loggable_id 기록)
- ActivityLogHandler: 삭제된 엔티티용 loggable_type/loggable_id 직접 지정 fallback 지원
- 메뉴 관리 크로스 depth 이동 지원 — `UpdateMenuOrderRequest`에 `moved_items` 검증 추가, `MenuRepository`에 크로스 depth reorder 로직 구현
- `NotCircularParent` 검증 규칙 추가 — 메뉴 순환 참조 방지
### Changed
- ActivityLog 규정 문서 대폭 보강 (`docs/backend/activity-log.md`) — `description_params` 저장 정책, `ActivityLogDescriptionResolver` 패턴, Bulk Update ChangeDetector 패턴, 개발자 체크리스트 추가
## [7.0.0-alpha.17] - 2026-03-26
### Added
- ActivityLog `description_params` ID→이름 변환 필터 훅 (`core.activity_log.filter_description_params`) — `ActivityLog::getLocalizedDescriptionAttribute()`에서 실행
- 코어 모델 `$activityLogFields` 정의 — `User`, `Role`, `Menu`, `Schedule`, `MailTemplate` (5개 모델)
- ActivityLog ChangeDetector 필드 라벨 다국어 키 추가 (`lang/ko/activity_log.php`, `lang/en/activity_log.php` — `fields` 섹션)
- `module_helpers.php` — `getModuleSetting()` 헬퍼 함수 추가
- CoreActivityLogListener: bulk_update per-User/per-Schedule 전환 + bulk_delete per-Schedule 전환 (건별 loggable_id 기록)
- ActivityLogHandler: 삭제된 엔티티용 loggable_type/loggable_id 직접 지정 fallback 지원
### Changed
- ActivityLog 규정 문서 대폭 보강 (`docs/backend/activity-log.md`) — `description_params` 저장 정책, `ActivityLogDescriptionResolver` 패턴, Bulk Update ChangeDetector 패턴, 개발자 체크리스트 추가
## [7.0.0-alpha.17] - 2026-03-26
### Added
- Monolog 기반 ActivityLog 아키텍처: `Log::channel('activity')` → `ActivityLogHandler` → DB (3단계)
- `ActivityLogChannel` (커스텀 Monolog 채널), `ActivityLogHandler`, `ActivityLogProcessor` 신규
- ActivityLog i18n 지원: `description_key` + `description_params` 기반 실시간 다국어 번역
- ActivityLog 구조화된 변경 이력: `changes` JSON 컬럼 (필드별 `label_key`, `old`/`new`, `type` 포함)
- `ChangeDetector` 유틸리티 (모델 스냅샷 비교 → 구조화된 변경 이력 생성)
- `CoreActivityLogListener` 전면 확장: 모든 코어 Service 훅 구독 (User/Role/Menu/Settings/Schedule/Auth/Module/Plugin/Template/Layout/MailTemplate/Attachment — 66개 훅)
- 활동 로그 다국어 키 105개 정의 (`lang/ko/activity_log.php`, `lang/en/activity_log.php`)
- `config/logging.php`에 `activity` 채널 추가
- `config/activity_log.php` 전용 설정 파일 신규
- `activity_logs` 테이블 복합 인덱스 추가 (`loggable_type`+`loggable_id`+`created_at`, `log_type`+`action`+`created_at`)
### Fixed
- `resolveLogType()` 사용자 역할 기반 → 요청 경로 기반으로 변경: 관리자가 사용자 화면에서 수행한 액션이 `admin`으로 기록되던 문제 수정 (ResolvesActivityLogType)
### Changed
- `ActivityLog` 모델: `description` 컬럼 삭제 → `description_key`/`description_params` 기반 다국어 전환
- `ActivityLogService`: 기록 메서드 전면 제거 → 조회 전용으로 축소
- `ActivityLogResource`: `description` → `localized_description` (실시간 번역)
- 모든 Controller에서 `logAdminActivity()` 호출 전면 제거 → Listener 경로로 전환
### Removed
- `activity_logs.description` 컬럼 (DB 삭제)
- `ActivityLogManager`, `ActivityLogDriverInterface`, `DatabaseActivityLogDriver`, `NullActivityLogDriver`
- `ActivityLogListener` (이중 훅 계층 — Monolog Handler로 대체)
- `ActivityLogService.log`/`logAdmin`/`logUser`/`logSystem` (Monolog 채널로 대체)
- `AdminBaseController.logAdminActivity()`, `generateActivityDescription()`, `flattenDataForTranslation()`
### Fixed
- 마이페이지 프로필 저장 시 국가·언어 미선택 상태에서 오류가 발생하던 문제 수정 — 해당 필드를 선택 사항으로 변경
- 인스톨러 Step 2 .env 복사 명령어 안내 수정 — `.env.example.production` → `.env.example` (functions.php 2곳, installer.js 4곳)
## [7.0.0-alpha.16] - 2026-03-23
### Fixed
- 코어 업데이트 롤백 시 vendor 디렉토리 복원 불가 수정 — 백업 targets에 vendor 포함, excludes에서 vendor 제거
- 코어 백업 복원 시 개별 target 실패가 전체 복원을 중단하는 문제 수정 — 개별 try-catch로 나머지 target 복원 계속 진행
- 코어 업데이트 롤백 실패 시 수동 복구 안내 미출력 수정 — composer install 등 복구 단계 안내 추가
- 코어 업데이트 완전 복원 성공 시 유지보수 모드 자동 해제 추가
- 코어 업데이트 시 vendor 디렉토리 이중 처리로 인한 마이그레이션 실패 수정 — `backup_only` 설정 분리 (applyUpdate 제외, 백업/복원 전용)
## [7.0.0-alpha.15] - 2026-03-23
### Added
- Users UUID 전환 — 외부 노출 ID를 UUID v7으로 전환, 정수 `id`는 API 응답에서 숨김
- UniqueIdService 코어 서비스 추가 (UUID v7 + NanoID 생성)
- 코어 업그레이드 스텝 추가 (Upgrade_7_0_0_beta_15)
### Changed
- User 모델: `getRouteKeyName()` → 'uuid', `$hidden`에 'id' 추가
- 공개 프로필 API: Route Model Binding 전환 (`{userId}` → `{user}`)
- 사용자 벌크 상태변경: 정수 ID → UUID 기반
- API Resource: user.id → user.uuid 전환 (UserResource, UserCollection 외 7개)
- Activity Log 메타데이터: user_id → uuid 전환
- FormRequest: 정수 검증 → UUID 검증 전환
### Fixed
- `_global.currentUser?.id` → `?.uuid` 전환 (sirsoft-basic 템플릿 12개 파일)
- 글쓰기 버튼 abilities 비활성화 누락 수정
- 코어 업그레이드 스텝 raw SQL 테이블 프리픽스 미적용 수정 — `UpgradeContext::table()` 헬퍼 추가 (Upgrade_7_0_0_beta_15)
## [7.0.0-alpha.14] - 2026-03-20
### Fixed
- 확장 업데이트 시 임시 디렉토리(`_updating_*`, `_old_*`) 오토로드 오염 방지 — 임시 디렉토리를 `_pending/` 하위에 생성하여 IDE 잠금 등으로 잔존 시에도 Fatal Error 방지 (ExtensionPendingHelper)
### Added
- Windows 파일잠금 감지/해제 기능 — 확장 업데이트 시 IDE 등이 파일 핸들을 보유한 경우 자동 감지 및 해제 시도 (FileHandleHelper, ExtensionPendingHelper)
- SEO 렌더러 훅 시스템: `core.seo.filter_context`, `core.seo.filter_meta`, `core.seo.filter_view_data` — 확장이 SEO 렌더링 파이프라인에 런타임 데이터 변환으로 개입 가능
- `seo.blade.php` 확장 슬롯: `extraHeadTags` (`</head>` 직전), `extraBodyEnd` (`</body>` 직전) — `filter_view_data` 훅을 통해 커스텀 스크립트/스타일 주입
- `ComponentHtmlMapper` pagination 렌더 모드 — Pagination 컴포넌트에서 SEO용 페이지 링크 자동 생성 (currentPage/totalPages props 기반)
- `ComponentHtmlMapper` text_format dot notation 지원 — `{author.nickname}` 형태로 객체 prop의 중첩 필드 접근
- `ExpressionEvaluator::evaluateRaw()` — 표현식 결과를 원본 타입(배열 등)으로 반환하는 메서드
- `SeoRenderer` SEO 컨텍스트에 `_global`/`_local` 빈 객체 추가 — 프론트엔드 전용 상태 참조 시 null 대신 빈 객체 제공
- `ComponentHtmlMapper` fields 렌더 모드 — 컴포지트 컴포넌트(ProductCard 등)의 객체 prop에서 SEO용 HTML 필드 자동 생성 (조건부/반복/속성 기반)
- `SeoRenderer` seoVars 주입 — `meta.seo.vars` 선언을 해석하여 ComponentHtmlMapper format 모드에서 `{key}` 플레이스홀더 치환
- `ExpressionEvaluator` 리터럴 값 감지 — 숫자, 문자열, boolean, null/undefined 리터럴을 경로 해석 없이 직접 반환
- `ExpressionEvaluator` $t: 파라미터 `{{}}` 표현식 해석 — 번역 키 파라미터 값에 포함된 바인딩 표현식을 컨텍스트에서 평가
- `TemplateManager` seo-config 검증에 `fields` 타입 추가
- `ExpressionEvaluator` seo_overrides — seo-config.json에서 `_local`/`_global` 상태 오버라이드 선언 (와일드카드 매칭으로 접혀있는 콘텐츠 SEO 강제 펼침)
- `TemplateManager` seo-config 검증에 `pagination` 타입 및 `seo_overrides` 검증 추가
- `SeoConfigMerger` — 모듈/플러그인/템플릿의 seo-config.json을 수집·병합하는 동적 확장 시스템 (우선순위: 모듈 → 플러그인 → 템플릿, 24시간 TTL 캐싱)
- `SeoRenderer` `_global` 컨텍스트 주입 — SettingsService/PluginSettingsService 프론트엔드 설정을 `_global`에 주입 + `initGlobal` 매핑으로 데이터소스 응답을 `_global` 경로에 바인딩
- `AbstractModule`/`AbstractPlugin` SEO 기여 메서드 — `getSeoConfig()`, `getSeoDataSources()` 인터페이스 추가
- `ModuleManager`/`PluginManager`/`TemplateManager` install/activate/update 시 훅 발행 추가 — Artisan 커맨드에서도 SEO 캐시 자동 무효화 보장
- SEO Artisan 커맨드 다국어 파일 추가 (`lang/ko/seo.php`, `lang/en/seo.php`)
- `ComponentHtmlMapper` fields 모드 `$all_props` source — 모든 props 표현식을 해석하여 데이터 객체로 사용 (Header/Footer 등 다수 props 컴포넌트용)
- `ComponentHtmlMapper` fields 모드 `$t:` 번역 키 지원 — content 패턴에서 `$t:key` → 다국어 텍스트 렌더링
- `ComponentHtmlMapper` fields iterate `item_attrs` — 아이템별 동적 HTML 속성 (예: `{ "href": "/board/{slug}" }`)
- `ExpressionEvaluator` `evaluateRaw()` `??` null coalescing 지원 — 원본 타입(배열/객체) 유지하면서 null coalescing 수행
- 인스톨러 Windows 환경 명령어 대응 — `chmod`/`chown` 스킵, 미존재 디렉토리 안내 메시지 추가
- `ExpressionEvaluator` 삼항 연산자 (`a ? b : c`) — JS 우선순위 준수, `?.`/`??` 자동 구분, 중첩 우측 결합
- `ExpressionEvaluator` `$t()` 함수 호출 구문 — 삼항 내부에서 `$t('key')` 형태로 번역 키 사용 가능 (기존 `$t:key` 방식 확장)
- `ExpressionEvaluator` `$localized()` 전역 함수 — 다국어 객체에서 현재 로케일 값 추출 (`{ko: "상품", en: "Product"}` → `"상품"`)
- `ExpressionEvaluator` 객체 리터럴 파서 — `{key: value, ...obj, [dynamicKey]: value}` 구문 지원
- `ExpressionEvaluator` 스프레드 연산자 — 배열 `[...arr, item]` 및 객체 `{...obj, key: value}` 스프레드 지원
- `SeoRenderer` computed 속성 해석 — 레이아웃 `computed` 섹션을 `_computed`/`$computed`에 저장 (문자열 표현식 + `$switch` 형식)
- `ComponentHtmlMapper` classMap 지원 — `base`/`variants`/`key`/`default`로 조건부 CSS 클래스 선언적 적용
### Fixed
- Redis 캐시 DB가 환경설정 값(`REDIS_CACHE_DB`)을 따르도록 수정 — `config/database.php` cache 연결 DB 반영
- `ExpressionEvaluator` 배열 리터럴 파싱 지원 — `['gallery','card'].includes(...)` 표현식에서 배열 리터럴을 PHP 배열로 변환하여 `includes` 등 배열 메서드 정상 동작 (SEO 게시판 타입 분기 조건 중복 렌더링 수정)
- `ExpressionEvaluator` null 비교 JavaScript 시맨틱 적용 — `null !== value` → `true`, `null === null` → `true` (SEO 컨텍스트에서 `_global` 미존재 경로 비교 시 빈 문자열 대신 올바른 boolean 반환)
- `ExpressionEvaluator` $t: 번역에서 `{{param}}` 형식 파라미터 치환 지원 — 템플릿 번역 파일의 `{{param}}` + Laravel 표준 `:param` 형식 모두 처리
- `ExpressionEvaluator` 비교 연산 좌측 optional chaining 경로 타입 보존 — `?.` 포함 경로가 `evaluateExpression`으로 불필요 라우팅되어 boolean 타입이 문자열로 변환되던 문제 수정
## [7.0.0-alpha.13] - 2026-03-18
### Added
- `SeoCacheRegenerator` — 단건 URL 캐시 즉시 재생성 서비스 (다국어 로케일별 렌더링 + 캐시 저장)
- `SeoSettingsCacheListener` — 코어 SEO 설정 변경 시 전체 SEO 캐시 + 사이트맵 삭제
### Fixed
- `SeoMiddleware`에서 `put()` 대신 `putWithLayout()` 사용 — `invalidateByLayout()`이 레이아웃명 미저장으로 항상 0건 매칭되던 근본 버그 수정
- `SeoRenderer`에서 레이아웃명을 request attribute로 저장 — `putWithLayout()` 연동
## [7.0.0-alpha.12] - 2026-03-17
### Changed
- API 인증을 토큰 전용으로 전환 — 세션 기반 인증 의존 제거, Bearer 토큰 단일 방식으로 통일
- README 업데이트 — 표기 통일, 섹션 정비, 문서 링크 연결
## [7.0.0-alpha.11] - 2026-03-16
### Added
- 역할(Role) 상태 토글 API 추가 — `PATCH /api/admin/roles/{role}/toggle-status` 엔드포인트, 훅 지원 (`core.role.before_toggle_status`, `core.role.after_toggle_status`)
- RoleResource에 `can_toggle_status` ability 추가 — 역할별 토글 권한 제어
- RoleResource에 `extension_name` 필드 추가 — 확장 출처별 로케일 이름 표시
- 역할 생성 시 `identifier` 직접 입력 기능 추가 — 미입력 시 name 기반 자동 생성 유지
- 역할 관련 다국어 키 추가 (ko/en) — identifier 검증 메시지
- `replaceUrl` 핸들러 추가 — refetch 없이 URL만 변경 (페이지네이션 등 브라우저 히스토리 관리용)
- 사용자 검색 API에 `id` 파라미터 지원 추가 — 특정 사용자 ID로 직접 조회 가능
- `TimezoneHelper` 유틸리티 클래스 추가 — 사용자/서버 타임존 간 변환 헬퍼
- `HasSampleSeeders` 트레이트 추가 — 모듈/플러그인 시더에서 `--sample` 옵션으로 샘플 데이터만 분리 실행
- `ComponentRegistry.getComponentNames()` 메서드 추가 — 등록된 컴포넌트 이름 목록 조회
### Fixed
- 템플릿 에러 페이지에서 `:identifier`가 리터럴로 표시되는 버그 수정 — Blade `@` 이스케이프 처리
- ActionDispatcher onChange raw value fallback 제거 — 마운트/리렌더 시 의도치 않은 setState 실행으로 상품 폼 회귀 유발
- Form 자동 바인딩 setState 경합 수정 — 자동 바인딩과 수동 setState가 동시 실행 시 stale 값으로 덮어쓰이는 문제 해결
- Form 자동 바인딩 bindingType 메타데이터 기반 boolean 바인딩 수정 — Toggle/Checkbox 등 boolean 컴포넌트에서 문자열 변환 대신 boolean 값 유지
- SPA 네비게이션 시 `_global._local`에 이전 페이지 상태가 잔존하는 버그 수정 — 페이지 전환 시 `_local` 초기화 처리
- DynamicRenderer `_computed` 참조 prop에서 캐시된 stale 값이 사용되는 버그 수정 — `_computed`/`$computed` 참조 시 `skipCache: true` 적용
### Changed
- 마이그레이션 통합 — 증분 마이그레이션을 테이블당 1개 create 마이그레이션으로 정리
- 시더 디렉토리 분리 — 설치 시더와 샘플 시더를 `Sample/` 하위로 분리, `--sample` 옵션 추가
- 라이선스 프로그램 명칭 정비
- 일정 관리 메뉴 기본 비활성화 (`config/core.php`)
- Composer 의존성 업데이트 — Laravel Framework v12.54.1, Reverb v1.8.0, Symfony v7.4.6~7 등
## [7.0.0-alpha.9] - 2026-03-13
### Added
- 그누보드7 커스텀 에러 페이지 도입 — Laravel 기본 에러 페이지(401, 403, 404, 500, 503)를 그누보드7 스타일로 교체, 다크 모드 지원, 접근 경로 기반 홈 링크 분기 (admin → `/admin`, 기타 → `/`)
- 환경설정 > 고급 디버그 모드 활성화 시 개발 대시보드(`/dev`) 바로가기 버튼 추가
- 루트 LICENSE 파일 생성 (MIT 라이선스, 한국어 번역 + 영문 원문)
- 코어 라이선스/Changelog API 엔드포인트 추가 (`GET /api/admin/license`, `GET /api/admin/changelog`)
- 확장 라이선스 API 엔드포인트 추가 (`GET /api/admin/modules/{id}/license`, `GET /api/admin/plugins/{id}/license`, `GET /api/admin/templates/{id}/license`)
- Admin 푸터 copyright 클릭 → 코어 라이선스 모달, 버전 클릭 → Changelog 모달 표시 기능
- 확장 상세 모달에서 라이선스 클릭 시 전문 모달 표시 기능 (모듈/플러그인/템플릿)
- 각 번들 확장에 LICENSE 파일 및 manifest `license` 필드 추가
### Changed
- 설치 화면 라이선스를 루트 LICENSE 파일로 통합 (`public/install/lang/license-ko.txt`, `license-en.txt` 삭제)
- `/dev` 라우트 뷰 이름을 `dev-dashboard`로 변경
## [7.0.0-alpha.8] - 2026-03-13
### Changed
- `.env.example.develop`과 `.env.example.production`을 `.env.example`로 통합 — 설치형 솔루션에 환경별 분리 불필요, Laravel/Vite 표준 준수
- 인스톨러에서 `.env.production` 백업 파일 생성 로직 제거 — Vite mode 기반 로딩 충돌 방지
### Improved
- 코어/확장 업데이트 시 composer install 스킵 최적화 — composer.json/composer.lock 미변경 시 composer install 및 vendor 디렉토리 교체를 건너뛰어 업데이트 시간 단축
### Fixed
- 확장 수동 설치 모달에서 설치 실패 시 상세 에러 사유(`errors.error`) 미표시 문제 수정 — 3개 모달에 상세 에러 P 요소 추가
- `checkDependencies()` 복수 의존성 에러 수집 — 첫 번째 미충족 의존성에서 즉시 throw 대신 전체 수집 후 줄바꿈 연결 (ModuleManager, PluginManager, TemplateManager)
- ModuleController/PluginController 에러 반환 형식을 TemplateController와 통일 — `['error' => $e->getMessage()]` 형태
- 확장 설치 실패 시 `_pending/{identifier}` 디렉토리 자동 정리 — ModuleService, PluginService, TemplateService에 try-catch 추가
## [7.0.0-alpha.7] - 2026-03-13
### Added
- 템플릿 엔진 `multipart/form-data` 지원 — apiCall 핸들러 및 DataSourceManager에서 `contentType: "multipart/form-data"` 설정 시 params를 FormData로 자동 변환
- ActionDispatcher: `fetchWithOptions()`에 FormData 변환 로직 추가, Content-Type 헤더 자동 생략 (브라우저 boundary 설정)
- DataSourceManager: `toFormData()` 메서드 추가, 인증/비인증 경로 모두 multipart 지원
- File/Blob 원본 유지, null/undefined 제외, 객체/배열 JSON.stringify 변환
- `deepMergeWithState()` non-plain 객체(File/Blob/Date) 보호 — spread 복사로 인한 내부 데이터 소실 방지
- `resolveParams()` non-plain 객체 재귀 해석 스킵 — File 객체가 빈 객체로 변환되는 문제 방지
- 컴포넌트 onChange raw value fallback — FileInput, Toggle 등 Event가 아닌 값을 전달하는 컴포넌트 지원
### Fixed
- DataSourceManager `isMultipart` 변수 TDZ(Temporal Dead Zone) 버그 수정 — 선언 전 참조로 인한 ReferenceError 해결
## [7.0.0-alpha.6] - 2026-03-13
### Fixed
- 플러그인 환경설정 페이지 진입 시 404 오류 수정 — `registerPluginLayouts()` admin/user 분기 도입 후 루트 `settings.json`이 스킵되던 문제
- 플러그인 설정 레이아웃을 `resources/layouts/settings.json` → `resources/layouts/admin/plugin_settings.json`으로 이동하여 모듈과 동일한 구조로 통일
- `AbstractPlugin::getSettingsLayout()` 경로 변경
- `PluginSettingsService` 오버라이드 경로 및 주석 수정
- 영향받는 플러그인: sirsoft-daum_postcode, sirsoft-marketing, sirsoft-tosspayments
### Changed
- 플러그인 설정 레이아웃 규정 문서(`plugin-development.md`) 경로/설명 업데이트
## [7.0.0-alpha.5] - 2026-03-12
### Added
- 확장(모듈/플러그인/템플릿) changelog GitHub 원격 소스 지원 — `source=github` 시 GitHub에서 CHANGELOG.md 조회, 실패 시 bundled 폴백
- `ChangelogParser::parseFromString()`, `getVersionRangeFromString()` 문자열 기반 파싱 메서드 추가
- `ChangelogRequest` validation 에러 다국어 메시지 추가 (source, version format, required_with)
- 플러그인 GitHub/ZIP 설치 기능 추가 — 모듈/템플릿에는 있지만 플러그인에 누락되어 있던 기능 신규 구현
- `PluginService::installFromGithub()`, `installFromZipFile()`, `findPluginJson()` 메서드 추가
- `PluginController::installFromFile()`, `installFromGithub()` 엔드포인트 추가
- `InstallPluginFromGithubRequest`, `InstallPluginFromFileRequest` FormRequest 추가
- `install-from-file`, `install-from-github` API 라우트 추가
- `ExtensionManager::hasComposerDependenciesAt(string $path)` 메서드 추가 — 임의 경로의 Composer 의존성 확인
- 모듈/플러그인 설치 시 `_pending` Composer 선행 설치 로직 추가 — 활성 디렉토리 이관 전 의존성 설치
### Changed
- 확장 수동 설치 모달 3개(모듈/플러그인/템플릿) UI 통일 — TabNavigation underline, 에러 배너, 필드별 적색 테두리
- 확장 GitHub/ZIP 설치 공통 로직을 `GithubHelper`, `ZipInstallHelper`로 추출하여 3개 Service 중복 제거
- `CoreUpdateService` GitHub 관련 protected 메서드를 `GithubHelper` 위임으로 리팩토링
- 확장 목록 PageHeader에서 새로고침 버튼 제거, 업데이트 확인 버튼으로 통일
- 코어 업데이트 `core_pending` 고정 경로 → `core_{Ymd_His}` 타임스탬프 기반 격리 디렉토리로 변경
- 확장(모듈/플러그인/템플릿) 업데이트에 `_pending/{identifier}_{timestamp}/` 스테이징 패턴 도입
- 확장 업데이트 시 스테이징 내에서 composer install 실행 (활성 디렉토리 무영향)
- 확장 GitHub 다운로드 코드를 코어와 동일한 패턴으로 통합 (인증 헤더, 폴백 체인, 타임아웃)
- GitHub 다운로드 공용 로직을 `ExtensionManager`로 추출하여 3개 Manager 중복 제거
- `config/app.php`에서 `preserves` 설정 키 제거 (타임스탬프 격리로 불필요)
- 모듈/플러그인 GitHub/ZIP 설치 흐름을 `temp → _pending → composer install → 활성 디렉토리` 패턴으로 통일
### Fixed
- 코어 업데이트 결과 모달에서 from/to 버전 파라미터가 전달되지 않는 버그 수정 — `params.params` → `params.query`
- 플러그인에 GitHub/ZIP 설치 기능이 누락되어 있던 결함 수정
- 파일 복사 시 퍼미션/소유자/소유그룹 미보존 문제 수정 — `File::copy()` → `FilePermissionHelper::copyFile()` 교체 (6개 위치)
- Windows 환경에서 확장/코어 업데이트 후 `_pending` 하위에 빈 디렉토리가 잔존하는 문제 수정 — `cleanupStaging()`에 3단계 retry 로직 추가
- 코어 업데이트 후 vendor 교체 시 stale `packages.php`/`services.php`로 인한 500 오류 수정 — `clearAllCaches()`에 컴파일 캐시 삭제 + `package:discover` + `extension:update-autoload` 추가
- 코어 업데이트 시 `bootstrap/cache` 디렉토리가 소스에서 덮어씌워지는 문제 수정 — excludes에 `bootstrap/cache` 추가
## [7.0.0-alpha.4] - 2026-03-12
### Added
- 코어 업그레이드 스텝 검증용 샘플 마이그레이션 및 업그레이드 스텝 추가
- 코어 업그레이드 스텝 경로를 `database/upgrades/` → `upgrades/`로 변경
- 업그레이드 스텝 실행을 프로그레스바 별도 단계로 분리 및 터미널 피드백 추가
### Changed
- `--source` 모드에서 원본 소스 디렉토리를 `_pending`으로 복제 후 작업 (원본 보호)
- Step 8: 운영 디렉토리 `composer install` 재실행 → `_pending/vendor/` 복사로 변경 (효율화)
### Fixed
- `--source` 모드에서 소스 버전 감지 시 현재 `env()` 대신 `config/app.php` default 값 파싱
- 코어 업데이트 targets에 `upgrades` 디렉토리 누락 수정
## [7.0.0-alpha.3] - 2026-03-12
### Fixed
- `.gitattributes`의 `CHANGELOG.md export-ignore`가 모든 CHANGELOG 파일을 릴리스 아카이브에서 제외하던 문제 수정
- PharData(tar ustar) 100바이트 경로 제한으로 324개 파일이 누락되어 orphan 삭제가 발생하던 버그 수정
### Removed
- PharData 아카이브 추출 전략 제거 — tar ustar 형식의 100바이트 경로 제한은 근본적 해결 불가
### Added
- `core:update --source=` 옵션 추가 — ZipArchive/unzip 불가 환경에서 수동 업데이트 지원
- 상대경로, 절대경로, Windows 경로 모두 지원
- 소스 디렉토리의 그누보드7 프로젝트 유효성 검증 (`config/app.php` + `version` 키)
- 업데이트 안내 모달에 수동 업데이트 가이드 섹션 추가
- 시스템 요구사항 미충족 시 `--source` 옵션 안내 메시지 추가
## [7.0.0-alpha.2] - 2026-03-12
### Fixed
- 코어 업데이트 확인 시 업데이트할 버전이 없으면 피드백 없던 문제 수정
- `openModal` 호출 형식 수정 (`params.id` → `target`)
- 모달 데이터 바인딩 경로 수정 (`_local` → `$parent._local`)
- 코어 업데이트 명령어 에러 미출력 수정
- `.env` 예제 파일에서 `G7_UPDATE_TARGETS` 하드코딩 제거
### Added
- 코어 업데이트 targets 확장 및 orphan 삭제 로직 추가
- 코어 업데이트 `--local` 옵션 및 설정 기반 제외/보존 추가
- 환경설정 고급 탭 코어 업데이트 설정 섹션 추가
## [7.0.0-alpha.1] - 2026-03-07
### Added
#### 코어 아키텍처
- Laravel 12 기반 CMS 플랫폼 초기 구조 설계 및 구현
- Service-Repository 패턴 기반 계층 분리 아키텍처 구축
- CoreServiceProvider를 통한 인터페이스-구현체 바인딩 시스템
- ResponseHelper를 통한 통일된 API 응답 형식 (success/error/paginated)
- AdminBaseController / AuthBaseController / PublicBaseController 컨트롤러 계층 구조
- FormRequest + Custom Rule 기반 검증 시스템
- BaseApiResource 상속 기반 API 리소스 패턴
- PHP 8.2+ Backed Enum 기반 상태/타입/분류 관리
#### 확장 시스템 (Extension System)
- 모듈(Module) 시스템: 디렉토리 스캔 기반 자동 발견, 설치/활성화/비활성화/삭제 관리
- 플러그인(Plugin) 시스템: 모듈 의존 기반 기능 확장, 설정 UI(settings.json) 지원
- 템플릿(Template) 시스템: Admin/User 타입 분리, JSON 기반 레이아웃, 컴포넌트 레지스트리
- ExtensionManager: 모듈/플러그인/템플릿 통합 관리 (설치, 업데이트, 삭제)
- HookManager: Action/Filter 훅 시스템 (doAction, applyFilters, HookListenerInterface)
- 확장 업데이트 시스템: _bundled/_pending 디렉토리 구조, GitHub/로컬 업데이트 감지 및 적용
- 확장 백업/복원 시스템 (ExtensionBackupHelper)
- 확장 상태 가드 (ExtensionStatusGuard): Installing/Updating/Uninstalled 상태 관리
- 확장 권한 동기화 (ExtensionRoleSyncHelper): 설치/삭제 시 역할-권한 자동 동기화
- 확장 메뉴 동기화 (ExtensionMenuSyncHelper): 모듈 메뉴 자동 등록/해제
- 확장 오토로드 시스템: 런타임 Composer 오토로드 (composer.json 수정 불필요)
- 확장 Composer 의존성 관리 (extension:composer-install 커맨드)
- 확장 업그레이드 스텝 시스템 (UpgradeStepInterface, UpgradeContext)
- 확장 설정 시스템 (SettingsMigrator): 모듈/플러그인별 독립 설정 관리
- 확장 소유권 시스템: extension_type/extension_identifier 기반 리소스 귀속
- 확장 에셋 시스템: module.json 매니페스트 기반 JS/CSS 자동 로딩
- 확장 Changelog 시스템: ChangelogParser 헬퍼 + API 엔드포인트 + 관리 화면 인라인 표시
- 확장 빌드 시스템: Artisan 커맨드 기반 (module:build, template:build, plugin:build)
- 확장 캐시 관리: 확장별 독립 캐시 + 일괄 클리어 커맨드
#### 템플릿 엔진 (Template Engine)
- DynamicRenderer: JSON 레이아웃 기반 React 컴포넌트 동적 렌더링
- DataBindingEngine: `{{expression}}` 문법, Optional Chaining(`?.`), Nullish Coalescing(`??`) 지원
- ActionDispatcher: 20+ 내장 핸들러 (navigate, apiCall, setState, openModal, closeModal, sequence, condition, replaceUrl, scrollTo, copyToClipboard, downloadFile, debounce, emit, showToast, confirm, validate, submit, reset, filter, sort 등)
- ComponentRegistry: 컴포넌트 등록/검색/해석 시스템 (기본/집합/레이아웃 타입)
- TranslationEngine: `$t:key` 즉시 평가 / `$t:defer:key` 지연 평가 다국어 바인딩
- LayoutLoader: JSON 레이아웃 로딩, 캐싱, ETag 기반 조건부 요청
- Router: SPA 라우팅, 동적 경로 파라미터(`{{route.id}}`), 쿼리스트링 관리
- 레이아웃 상속 시스템: `extends` 기반 베이스 상속 + `type: "slot"` 위치에 컨텐츠 삽입
- Partial 시스템: 레이아웃 모듈화, 컴포넌트 치환 (data_sources/computed/modals/state 미지원)
- 조건부 렌더링: `if` 속성 기반 표현식 평가 (type: "conditional" 미지원)
- 반복 렌더링: `iteration` 설정 (source, item_var, index_var, key)
- 반응형 레이아웃: `responsive` 속성 기반 breakpoint 오버라이드 (portable/compact/wide)
- 다크 모드: Tailwind `dark:` variant 기반 자동 전환
- classMap: 조건부 CSS 클래스 매핑 (key → variants)
- computed: 계산된 속성 시스템 (의존성 추적, 자동 재계산)
- 모달 시스템: `modals` 섹션 + openModal/closeModal 핸들러 (`_global.modalStack` 기반)
- init_actions: 레이아웃 초기화 시 자동 실행 액션 (루트 레이아웃 레벨)
- Named Actions: 액션 재사용 시스템 (DRY 패턴)
- errorHandling: 전역/데이터소스별 에러 핸들링 설정
- scripts: 레이아웃 레벨 커스텀 스크립트 로딩
- globalHeaders: API 호출 공통 헤더 설정 (pattern 기반 매칭)
- blur_until_loaded: 데이터 로딩 전 블러 처리
- lifecycle: 컴포넌트 생명주기 훅 (onMount, onUnmount)
- slots: 컴포넌트 슬롯 시스템
- layout_extensions: 모듈/플러그인의 동적 UI 주입 포인트
- isolatedState: 컴포넌트 상태 격리
- 데이터소스 조건부 로딩: `if` 표현식 기반 활성화/비활성화
#### 컴포넌트 시스템
- Basic 컴포넌트 (27+): Div, Button, Input, Select, Form, A, H1~H6, Span, P, Img, Label, Textarea, Table, Thead, Tbody, Tr, Th, Td, Ul, Ol, Li, Hr, Strong, Em, Small, Pre, Code, Blockquote, Nav, Header, Footer, Main, Section, Article, Aside, Figure, FigCaption
- Composite 컴포넌트: DataGrid, CardGrid, Pagination, Modal, SearchBar, Tabs, TabPanel, Accordion, Badge, Breadcrumb, Card, Checkbox, CheckboxGroup, DatePicker, Dropdown, FileUpload, Icon, Notification, Radio, RadioGroup, RangeSlider, Rating, Select (enhanced), Sidebar, Stepper, Switch, Tag, Timeline, Toast, Tooltip, PasswordInput, DynamicFieldList, SortableList, ColorPicker, NumberInput, TreeView, DateRangePicker
- Layout 컴포넌트: FlexLayout, GridLayout, ScrollLayout, StickyLayout, Spacer, Container, AspectRatio
- HtmlEditor: TinyMCE 기반 HTML 에디터 컴포넌트
- Icon 컴포넌트: Font Awesome 6.4.x Free 아이콘 지원 (Solid 1,390개 / Regular 163개 / Brands 472개)
- Alert, EmptyState, LoadingSpinner, Skeleton, StatusBadge, CopyButton 유틸리티 컴포넌트
#### 상태 관리
- 전역 상태 (`_global`): 앱 전체 공유, 페이지 이동 시 유지
- 로컬 상태 (`_local`): 레이아웃 단위 격리
- 계산된 상태 (`_computed`): 의존성 기반 자동 재계산
- 폼 자동 바인딩: Form 컴포넌트 `stateKey` 기반 자동 상태 연동
- setState 핸들러: target(global/local), 함수형 업데이트, 배열 조작 (push/filter/map)
- 상태 구독: `G7Core.state.subscribe` 기반 반응형 업데이트
- initGlobal: 전역 상태 초기값 선언
- 모달 스코프 상태: `$parent._local` 스냅샷 기반 데이터 전달
#### 인증 시스템
- Laravel Sanctum 하이브리드 인증 (세션 + Bearer 토큰)
- AuthManager: 싱글톤 인증 상태 관리
- 자동 토큰 갱신: 401 응답 시 자동 리프레시 후 재시도
- OptionalSanctumMiddleware: 선택적 인증 지원 (비인증 사용자 허용)
- 로그인/로그아웃 3단계 프로토콜 (토큰 삭제 → 세션 무효화 → Auth::logout)
- 비밀번호 재설정: 이메일 인증 기반 토큰 발급 및 검증
- 회원가입: 약관 동의, 이메일 인증 지원
#### 권한 시스템
- Role 기반 권한 관리 (User → Role → Permission 3계층)
- permission 미들웨어 체인 기반 접근 제어 (FormRequest authorize() 사용 금지)
- 확장별 권한 자동 등록/해제
- 역할별 메뉴 접근 제어 (role_menus 피벗 테이블)
- 슈퍼 관리자 (superadmin) 전체 권한 자동 부여
#### 메뉴 시스템
- 계층형 메뉴 구조 (parent_id 기반)
- 역할별 메뉴 가시성 제어
- 모듈 메뉴 자동 등록 (getAdminMenus 인터페이스)
- 메뉴 순서 관리 (드래그 앤 드롭 SortableList)
- 다국어 메뉴명 지원 (JSON 배열 형식)
#### 데이터 소스 (Data Sources)
- API 엔드포인트 선언적 정의 (id, endpoint, method, params)
- loading_strategy: immediate/lazy/manual 3가지 로딩 전략
- 데이터소스 의존성: depends_on 기반 연쇄 로딩
- 폴링: poll_interval 기반 주기적 갱신
- 조건부 로딩: `if` 표현식 기반 활성화
- transform: 응답 데이터 변환 함수
- cache_duration: 응답 캐싱
#### 관리자 기능 (Admin)
- 대시보드: 시스템 정보, 통계 위젯, 최근 활동
- 사용자 관리: CRUD, 역할 할당, 상태 관리 (활성/비활성/차단)
- 역할 관리: CRUD, 권한 할당, 다국어 역할명
- 권한 관리: 카테고리별 권한 목록, 역할별 권한 할당
- 메뉴 관리: 계층형 메뉴 편집, 순서 변경, 역할별 가시성 설정
- 모듈 관리: 설치/활성화/비활성화/삭제, 업데이트 확인, 상세 정보 모달, changelog 표시
- 플러그인 관리: 설치/활성화/비활성화/삭제, 설정 UI, 업데이트 확인, changelog 표시
- 템플릿 관리: 설치/활성화/비활성화/삭제, 업데이트 확인, changelog 표시
- 환경설정: 사이트 기본 정보, SEO 설정, 메일 설정, 보안 설정, 탭 레이아웃
- 일정 관리: 스케줄 CRUD, 캘린더 뷰, 카테고리 분류
- 메일 템플릿 관리: DB 기반 메일 템플릿 CRUD, 변수 치환, 미리보기 기능
- 메일 발송 로그: 발송 이력 조회, 상태 추적, 상세 정보 모달
- 시스템 정보: PHP/Laravel/DB 버전, 디스크 사용량, 확장 현황 표시
- 코어 업데이트: 버전 확인, 업데이트 가이드, changelog 인라인 표시, 백업 생성
#### 사용자 기능 (User/Public)
- 로그인/회원가입/비밀번호 재설정 페이지
- 마이페이지: 프로필 수정, 비밀번호 변경
- 통합 검색 기능
- 게시판 뷰: 목록/상세/작성/수정 (board 모듈 연동)
- 에러 페이지: 403, 404, 500, 503 커스텀 에러 페이지
- 점검 모드(maintenance) 페이지
#### 설치 프로그램 (Installer)
- 다단계 웹 설치 마법사 (환경 체크 → DB 설정 → 관리자 생성 → 완료)
- SSE(Server-Sent Events) 기반 실시간 설치 진행 상태 표시
- 설치 롤백 기능: 실패 시 자동 복원 (마이그레이션/시더 롤백)
- 언어 선택 지원 (한국어/영어)
- 다크 모드 지원
- 환경 요구사항 자동 검증 (PHP 버전, 확장, 디렉토리 권한)
#### 모듈: sirsoft-board (게시판)
- 게시판 관리: CRUD, 카테고리, 스킨 설정, 권한 설정
- 게시글 관리: CRUD, 검색, 정렬, 페이지네이션
- 댓글 시스템: CRUD, 대댓글 (계층형), 답글 알림
- 신고 시스템: 게시글/댓글 신고, 관리자 처리 (승인/거절)
- 첨부파일: 이미지/파일 업로드, 다운로드, 인라인 표시
- 비밀글: 작성자/관리자만 열람 가능
- 블라인드/복원: 관리자 블라인드 처리 및 복원 기능
- 카드/갤러리 레이아웃: 다양한 목록 표시 형태 지원
- 게시판 권한: 읽기/쓰기/댓글/관리 권한 분리
- 인기글/최신글 위젯
- SEO 메타 태그 자동 생성
#### 모듈: sirsoft-ecommerce (이커머스)
- 상품 관리: CRUD, 옵션(사이즈/색상), 라벨, SEO 메타, 이미지 갤러리
- 상품 카테고리: 계층형 카테고리, 순서 관리, TreeView 편집
- 브랜드 관리: CRUD, 로고, 설명
- 주문 관리: 주문 목록, 상태 변경, 상세 정보, 주문 타임라인
- 쿠폰 시스템: 정액/정률 할인, 사용 조건 (최소 금액, 특정 상품), 유효기간, 사용 횟수 제한
- 배송 정책: 무료/유료/조건부 배송, 지역별 요금 설정
- 공통 정보 관리: 배송/교환/환불 안내, 판매자 정보
- 장바구니: 추가/수량 변경/삭제, 옵션별 관리, 품절 상품 알림
- 체크아웃: 주소 입력 (다음 우편번호 연동), 배송지 저장 체크박스, 결제수단 선택, 쿠폰 적용
- 주문 완료: 주문 번호, 결제 정보 요약, 장바구니 자동 비우기
- 상품 상세 페이지: 이미지 갤러리, 옵션 선택, 수량 입력, 장바구니 담기
- 상품 목록: 필터링 (카테고리/브랜드/가격), 정렬, 페이지네이션, 카드 그리드
- 위시리스트: 찜하기/해제 기능
- 상품 검색: 키워드 검색, 카테고리 필터 연동
#### 모듈: sirsoft-page (페이지 관리)
- CMS 페이지: CRUD, 슬러그 기반 URL 매핑
- 페이지 버전 관리: 버전 이력 조회, 이전 버전 복원
- 첨부파일: 이미지/파일 업로드 지원
- 검색 통합: 페이지 내용 통합 검색 지원
- SEO: 메타 태그, Open Graph 설정
#### 플러그인: sirsoft-daum_postcode (다음 우편번호)
- 다음 우편번호 검색 API 연동
- 주소 선택 후 폼 자동 입력
- 체크아웃 배송지 입력 연동
#### 플러그인: sirsoft-tosspayments (토스페이먼츠)
- 토스페이먼츠 결제 API 연동
- 카드/계좌이체/가상계좌/무통장입금 결제 지원
- 결제 확인 (confirm) 프로세스
- 결제 성공/실패 콜백 처리
- 관리자 설정 UI (API 키 관리)
#### 플러그인: sirsoft-marketing (마케팅 동의)
- 마케팅 동의 관리: 이메일 구독, 마케팅 동의, 제3자 제공 동의
- MarketingConsent / MarketingConsentHistory 모델 및 서비스
- MarketingConsentListener 훅 리스너 (회원가입/프로필 연동)
- 사용자 관리 화면 레이아웃 확장 (마케팅 동의 상세/폼/프로필)
- 회원가입 폼 마케팅 동의 항목 확장
- 플러그인 설정 UI
- 역할 기반 접근 제어 (마케팅 관리자)
- 다국어 지원 (ko, en)
#### 플러그인: sirsoft-verification (본인인증)
- 휴대폰 인증, 아이핀 인증 등 본인인증 기능 제공
- 사용자 관리 화면 레이아웃 확장 (본인인증 상세/폼)
- 역할 기반 접근 제어 (본인인증 관리자)
- 다국어 지원 (ko, en)
#### 템플릿: sirsoft-admin_basic (관리자)
- 관리자 대시보드 레이아웃
- 사이드바 네비게이션: 접기/펼치기, 계층형 메뉴, 활성 상태 표시
- 상단바: 사용자 정보, 알림 벨, 다크 모드 전환 토글
- 반응형 레이아웃 (데스크톱/태블릿/모바일)
- 관리자 전용 컴포넌트: DataGrid, CardGrid, SearchBar, 필터 패널 등
- CRUD 화면 표준 레이아웃 (목록/생성/수정/상세)
- 모달 기반 상세 보기/수정 기능
- 토스트 알림 시스템
- 확인 다이얼로그 (삭제 확인 등)
- 환경설정 탭 레이아웃
- 모듈/플러그인/템플릿 관리 화면 (설치/업데이트/상세 모달)
- 사용자/역할/권한 관리 화면
- 메뉴 관리 화면 (드래그 앤 드롭 순서 변경)
- 일정 관리 캘린더 화면
- 메일 템플릿 관리 화면
- 메일 발송 로그 화면
- 시스템 정보 화면
- 코어 업데이트 가이드/결과 모달
#### 템플릿: sirsoft-basic (사용자)
- 사용자 메인 페이지 레이아웃
- 헤더/푸터 공통 레이아웃 (반응형)
- 로그인/회원가입/비밀번호 재설정 화면
- 마이페이지 레이아웃
- 게시판 목록/상세/작성/수정 화면
- 상품 목록/상세 화면
- 장바구니/체크아웃/주문완료 화면
- 검색 결과 화면
- CMS 페이지 표시 화면
- 에러 페이지 (403, 404, 500, 503)
- 점검 모드(maintenance) 전용 페이지
- 반응형 레이아웃 (데스크톱/태블릿/모바일)
#### 다국어 시스템 (i18n)
- 백엔드 다국어: `__()` 함수 기반, `lang/{locale}/*.php` 파일 구조
- 프론트엔드 다국어: `$t:key` 즉시 평가 바인딩, `$t:defer:key` 지연 평가 바인딩
- 컴포넌트 다국어: `G7Core.t()` API 제공
- 모듈 다국어: 모듈별 독립 언어 파일, 네임스페이스 분리 (`__('vendor-module::key')`)
- 템플릿 다국어: partial 언어 파일 분할 (admin.json, errors.json 등)
- DB 다국어 필드: JSON 배열 형식 (`{"ko": "...", "en": "..."}`)
- 지원 로케일: 한국어(ko), 영어(en)
- TranslatableField 트레이트: 모델 다국어 필드 자동 해석
#### 보안 (Security)
- ValidLayoutStructure: JSON 레이아웃 구조 검증 Custom Rule
- WhitelistedEndpoint: API 엔드포인트 화이트리스트 검증 Custom Rule
- NoExternalUrls: 외부 URL 차단 검증 Custom Rule
- ComponentExists: 컴포넌트 존재 여부 검증 Custom Rule
- CSRF 보호: Laravel 기본 CSRF 토큰 검증
- XSS 방지: 출력 이스케이프, HTML sanitize 처리
- SQL Injection 방지: Eloquent ORM, 파라미터 바인딩 사용
- Rate Limiting: API 요청 제한 미들웨어
#### 메일 시스템
- DB 기반 메일 템플릿: 제목/본문 DB 관리, 변수 치환 (Blade 문법)
- Notification + Mailable 통합 패턴: BaseNotification 상속으로 보일러플레이트 제거
- 메일 발송 로그: 발송 이력 DB 기록, 수신자/상태/발송 시간 추적
- 메일 템플릿 사용자 오버라이드 추적 (user_overrides 필드)
#### 알림 시스템 (Notification)
- BaseNotification: 모든 알림의 베이스 클래스 (via() 보일러플레이트 제거)
- 알림 채널: 메일, 데이터베이스, 브로드캐스트 지원
- 실시간 알림: Laravel Reverb (WebSocket) 기반 Broadcasting
#### 스토리지 시스템
- StorageInterface: 모든 파일 저장의 추상화 인터페이스 (Storage::disk() 직접 호출 금지)
- CoreStorageDriver: 코어 스토리지 구현체
- 확장별 독립 스토리지 공간 할당
#### 코어 업데이트 시스템
- CoreUpdateService: GitHub API 기반 코어 버전 확인 및 업데이트 감지
- CoreUpdateController: 업데이트 상태 확인, 가이드 표시 API 엔드포인트
- CoreBackupHelper: 업데이트 전 코어 파일 백업 생성
- FilePermissionHelper: 파일/디렉토리 쓰기 권한 사전 검증
- MaintenanceModePage 미들웨어: 점검 모드 시 전용 페이지 표시
- 업데이트 가이드 모달: changelog 인라인 표시, 단계별 안내
#### 그누보드7 DevTools
- MCP 서버: 20+ 디버깅 도구 제공
- 기본 도구: g7-state, g7-actions, g7-cache, g7-diagnose, g7-lifecycle, g7-network, g7-form, g7-expressions, g7-logs
- 고급 분석: g7-datasources, g7-handlers, g7-events, g7-performance, g7-conditionals, g7-websocket
- 상태 계층/스타일: g7-renders, g7-state-hierarchy, g7-context-flow, g7-styles, g7-auth, g7-tailwind, g7-layout
- Phase 8 심화: g7-computed, g7-nested-context, g7-modal-state, g7-sequence, g7-stale-closure, g7-change-detection
- 브라우저 상태 덤프: 런타임 상태 캡처 및 MCP 서버 전송
- UI 패널: 브라우저 내 DevTools 패널 (실시간 상태/액션/로그 확인)
- 페이지네이션: offset/limit 기반 대용량 데이터 조회 지원
#### WYSIWYG 에디터
- 비주얼 레이아웃 에디터: 드래그 앤 드롭 기반 레이아웃 편집
- PropertyPanel: 컴포넌트 속성 편집 UI
- 컴포넌트 팔레트: 사용 가능한 컴포넌트 드래그 목록
- 실시간 미리보기: 편집 즉시 렌더링 결과 확인
#### 성능 최적화
- 레이아웃 캐싱: 파싱된 레이아웃 JSON + 상속 병합 결과 캐시
- 확장 캐싱: 모듈/플러그인/템플릿 목록 및 메타데이터 캐시
- 번역 캐싱: 언어 파일 파싱 결과 캐시
- ETag 지원: API 응답 조건부 캐시 검증 (304 Not Modified)
- Gzip 압축: API 응답 자동 압축
- Debounce 액션: 연속 이벤트 디바운스 처리 핸들러
- Lazy 로딩: 데이터소스 지연 로딩 (스크롤/이벤트 트리거)
- 순환 참조 방지 메커니즘: 레이아웃 상속 무한 루프 감지
#### 데이터베이스
- 코어 테이블: users, roles, permissions, role_has_permissions, role_menus, menus, settings, mail_templates, mail_send_logs, schedules, modules, plugins, templates, template_layouts, template_layout_versions
- 모든 컬럼 한국어 comment 필수 규칙 적용
- down() 메서드 완전 롤백 구현 필수 규칙 적용
- 다국어 필드 JSON 배열 형식 표준
- MariaDB 호환성 지원
- boolean/enum 컬럼 값 설명 comment 포함 규칙
#### 테스트 인프라
- PHPUnit 11.x: 백엔드 단위 테스트 (Unit) 및 기능 테스트 (Feature)
- Vitest: 프론트엔드 컴포넌트/템플릿 엔진 테스트
- createLayoutTest(): 레이아웃 JSON 렌더링 테스트 유틸리티 (Vitest + jsdom)
- mockApi(): API 응답 모킹 유틸리티 (fetch 자동 모킹)
- 트러블슈팅 회귀 테스트: 해결된 사례별 자동 검증
- 테스트 커버리지: 모델, 서비스, 컨트롤러, 컴포넌트, 레이아웃 렌더링
#### 빌드 시스템
- Vite 기반 프론트엔드 빌드 (코드 분할, 트리 쉐이킹)
- Artisan 커맨드 기반 빌드 관리:
- `core:build`: 코어 템플릿 엔진 빌드 (--full, --watch 옵션)
- `module:build`: 모듈 프론트엔드 빌드 (--all, --watch, --active 옵션)
- `template:build`: 템플릿 빌드 (--all, --watch, --active 옵션)
- `plugin:build`: 플러그인 빌드 (--all, --watch, --active 옵션)
- _bundled 디렉토리 기본 빌드, --active 옵션으로 활성 디렉토리 빌드
- --watch 모드: 파일 감시 기반 실시간 빌드 (활성 디렉토리 자동 사용)
#### Artisan 커맨드
- 모듈 관리: module:list, module:install, module:activate, module:deactivate, module:uninstall, module:composer-install, module:cache-clear, module:seed, module:check-updates, module:update, module:build
- 플러그인 관리: plugin:list, plugin:install, plugin:activate, plugin:deactivate, plugin:uninstall, plugin:composer-install, plugin:cache-clear, plugin:seed, plugin:check-updates, plugin:update, plugin:build
- 템플릿 관리: template:list, template:install, template:activate, template:deactivate, template:uninstall, template:cache-clear, template:check-updates, template:update, template:build
- 확장 공통: extension:composer-install, extension:update-autoload
- 코어: core:build
#### 개발 도구 및 문서
- AI 에이전트 개발 가이드 (AGENTS.md): 핵심 원칙, 코딩 규칙, 디버깅 프로토콜
- 규정 문서 체계 (docs/): 백엔드 14개, 프론트엔드 59개, 확장 23개, 공통 4개 (총 100개)
- AI 에이전트 자동화 도구: 검증/분석/구현 스킬 30+개, 인덱스 자동 생성 스크립트
- 트러블슈팅 가이드: 상태 관리, 캐시, 컴포넌트, 백엔드 문제 해결 사례집
- 코드 스타일: Laravel Pint (PSR-12) 자동 적용
+225
View File
@@ -0,0 +1,225 @@
# 그누보드7 설치 가이드
그누보드7(G7)을 설치하는 방법을 안내합니다.
---
## 시스템 요구사항
| 항목 | 요구사항 |
|------|---------|
| **PHP** | 8.2 이상 (필수 확장 30개 포함) |
| **데이터베이스** | MySQL 8.0+ 또는 MariaDB 10.3+ (utf8mb4) |
| **Composer** | 2.x |
| **Redis** | 6.0+ (프로덕션 권장, 선택) |
> 상세 요구사항은 [docs/requirements.md](docs/requirements.md)를 참조하세요.
---
## 방법 1: 웹 서버에서 바로 구동
Apache, Nginx 등 웹 서버가 이미 구동 중인 환경에서 설치합니다.
### 1단계: 소스 코드 다운로드
웹 서버의 루트 디렉토리(또는 원하는 위치)에서 실행합니다.
```bash
git clone https://github.com/gnuboard/g7.git
```
### 2단계: 웹 서버 설정
웹 서버의 DocumentRoot(또는 Virtual Host)를 `g7/public` 디렉토리로 설정합니다.
**Apache 예시** (Virtual Host):
```apache
<VirtualHost *:80>
ServerName example.com
DocumentRoot /var/www/g7/public
<Directory /var/www/g7/public>
AllowOverride All
Require all granted
</Directory>
</VirtualHost>
```
**Nginx 예시**:
```nginx
server {
listen 80;
server_name example.com;
root /var/www/g7/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/var/run/php/php8.2-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
}
```
### 3단계: 설치 마법사 실행
브라우저에서 접속합니다.
```
http://도메인/install
```
설치 마법사의 안내에 따라 DB 정보 입력 및 관리자 계정을 생성합니다.
---
## 방법 2: 로컬 개발 서버 (PHP 내장 서버)
로컬 환경에서 개발/테스트 목적으로 빠르게 구동합니다.
### 1단계: 소스 코드 다운로드
```bash
git clone https://github.com/gnuboard/g7.git
```
### 2단계: 프로젝트 디렉토리로 이동
```bash
cd g7
```
### 3단계: Composer 의존성 설치
```bash
composer install
```
### 4단계: 환경 설정 파일 생성
```bash
cp .env.example .env
```
### 5단계: 개발 서버 실행
```bash
php artisan serve
```
### 6단계: 설치 마법사 실행
브라우저에서 접속합니다.
```
http://localhost:8000/install
```
설치 마법사의 안내에 따라 DB 정보 입력 및 관리자 계정을 생성합니다.
---
## 방법 3: ZIP 파일 다운로드
Git이 설치되지 않은 환경에서 설치합니다.
### 1단계: GitHub 접속
브라우저에서 아래 주소로 접속합니다.
```
https://github.com/gnuboard/g7
```
### 2단계: 릴리스 다운로드
1. 페이지 우측의 **Releases** 섹션을 클릭합니다.
2. 최신 릴리스를 선택합니다.
3. 하단의 **Source code (zip)** 을 다운로드합니다.
### 3단계: 압축 해제
다운로드한 ZIP 파일을 원하는 위치에 압축 해제합니다.
### 4단계: Composer 의존성 설치
터미널에서 압축 해제된 디렉토리로 이동한 후 실행합니다.
```bash
cd g7-버전명
composer install
```
### 5단계: 환경 설정 파일 생성
```bash
cp .env.example .env
```
### 6단계: 설치 진행
환경에 따라 선택합니다.
**웹 서버가 있는 경우:**
- DocumentRoot를 `public` 디렉토리로 설정한 후 브라우저에서 `http://도메인/install` 접속
**로컬에서 구동하는 경우:**
```bash
php artisan serve
```
브라우저에서 `http://localhost:8000/install` 접속
설치 마법사의 안내에 따라 DB 정보 입력 및 관리자 계정을 생성합니다.
---
## 설치 후 확인
설치가 완료되면 아래 페이지에 접근할 수 있습니다.
| 페이지 | URL | 비고 |
|--------|-----|------|
| **관리자 페이지** | `http://도메인/admin` | |
| **사용자 페이지** | `http://도메인/` | 사용자 템플릿 설치 필수 |
> 사용자 페이지는 사용자 템플릿이 설치되어 있어야 접근할 수 있습니다. 인스톨러에서 사용자 템플릿을 함께 설치하거나, 관리자 페이지에서 템플릿을 먼저 설치해 주세요.
---
## 프로덕션 환경 추가 설정
프로덕션 환경에서는 아래 항목을 추가로 설정하는 것을 권장합니다.
### HTTPS 설정
프로덕션 환경에서는 HTTPS를 사용해야 합니다. `.env` 파일에서 `APP_URL`을 `https://`로 설정하세요.
### 데몬 프로세스
상시 실행이 필요한 프로세스입니다. Supervisor 등을 사용하여 관리합니다.
| 프로세스 | 명령어 | 용도 |
|---------|--------|------|
| 큐 워커 | `php artisan queue:work` | 비동기 작업 처리 |
| WebSocket | `php artisan reverb:start` | 실시간 알림 |
### 스케줄러
cron에 아래 항목을 등록합니다.
```bash
* * * * * cd /path/to/g7 && php artisan schedule:run >> /dev/null 2>&1
```
> 상세 내용은 [docs/requirements.md](docs/requirements.md)를 참조하세요.
+55
View File
@@ -0,0 +1,55 @@
프로그램 명칭 : 그누보드7 (Gnuboard7)
저작자 : (주)에스아이알소프트
라이선스 (License)
번역문 아래에 원문이 있습니다.
주의)
1. 번역문과 원문의 내용상 차이가 있는 경우 원문의 내용을 우선으로 따릅니다.
2. 이 라이선스 파일 및 내용은 저작자를 제외한 어느 누구도 추가, 수정, 삭제할 수 없습니다.
----- MIT 라이선스 (한국어 번역) --------------------------------------------------------
MIT 라이선스
Copyright (c) 2026 (주)에스아이알소프트
이 소프트웨어와 관련 문서 파일(이하 "소프트웨어")의 복사본을 취득하는 모든 사람에게
소프트웨어를 제한 없이 사용, 복사, 수정, 병합, 출판, 배포, 서브라이선스 허여 및/또는
판매할 수 있는 권리를 무상으로 부여합니다. 다만, 소프트웨어를 제공받은 사람은 다음
조건을 따라야 합니다:
위 저작권 고지와 본 허가 고지는 소프트웨어의 모든 복사본 또는 상당 부분에 포함되어야
합니다.
소프트웨어는 "있는 그대로" 제공되며, 명시적이든 묵시적이든 어떠한 종류의 보증도 하지
않습니다. 여기에는 상품성, 특정 목적에의 적합성 및 비침해에 대한 보증이 포함되나 이에
국한되지 않습니다. 어떠한 경우에도 저작자 또는 저작권자는 소프트웨어나 소프트웨어의
사용 또는 기타 거래로 인해 발생하는 계약, 불법행위 또는 기타 청구, 손해 또는 기타
책임에 대해 책임을 지지 않습니다.
----- MIT License (English Original) --------------------------------------------------------
The MIT License (MIT)
Copyright (c) 2026 SIRSOFT
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+365
View File
@@ -0,0 +1,365 @@
<p align="center">
<img src="https://img.shields.io/badge/그누보드7-Gnuboard7-000000?style=for-the-badge&labelColor=0066FF&logoColor=white" height="200" alt="그누보드7 (Gnuboard7)">
</p>
<p align="center">
<strong>모던 아키텍처로 다시 태어난 대한민국 대표 오픈소스 CMS</strong><br>
A modern, extensible CMS platform built with Laravel + React
</p>
<p align="center">
<a href="#"><img src="https://img.shields.io/badge/version-7.0.0--beta.1-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>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="License"></a>
<a href="#"><img src="https://img.shields.io/badge/status-Open%20Beta-orange" alt="Status"></a>
</p>
---
[소개](#그누보드7-소개) · [주요 기능](#주요-기능) · [기술 스택](#기술-스택) · [아키텍처](#아키텍처) · [빠른 시작](#빠른-시작) · [기본 제공 확장](#기본-제공-확장) · [비즈니스 모델](#비즈니스-모델) · [기존 사용자](#기존-그누보드-사용자) · [문서](#문서) · [기여하기](#기여하기) · [만든 사람들](#만든-사람들) · [커뮤니티](#커뮤니티) · [변경 기록](#변경-기록) · [라이선스](#라이선스)
---
## 그누보드7 소개
**그누보드7 (Gnuboard7)** 은 23년간 대한민국에서 가장 널리 사용된 오픈소스 CMS인 그누보드를, 현대적 기술 스택으로 **완전히 새로 설계**한 차세대 웹 플랫폼입니다.
Laravel과 React를 기반으로, 보안부터 아키텍처까지 처음부터 다시 만들었습니다.
- **JSON 레이아웃 엔진**: React를 몰라도 JSON만으로 React 기반 UI를 선언적으로 정의. 모듈/플러그인이 프론트엔드 빌드 없이 JSON만으로 UI를 동적으로 주입/확장. 고도화된 UI가 필요한 경우 커스텀 React 컴포넌트를 개발하여 등록 가능
- **하나의 플랫폼, 다양한 비즈니스**: 커뮤니티, 쇼핑몰, 구독, 예약 — 비즈니스 모델에 맞게 확장
- **정교한 권한 관리**: 역할(Role) + 권한(Permission) + 스코프(Scope) 3단계 접근 제어로, 서비스 규모가 커져도 통제력 유지
- **글로벌 레디**: 다국어(i18n) 네이티브 지원, 로케일 기반 UI, 다중 통화 대응
- **확장 시스템**: 모듈 + 플러그인 + 템플릿 3중 구조로 코어 수정 없이 기능 확장
---
## 주요 기능
현대적인 웹 플랫폼에 필요한 핵심 기능을 갖추었습니다.
| 영역 | 설명 |
|------|------|
| **모듈 아키텍처** | 모듈 + 플러그인 + 템플릿 3중 확장 구조. 코어 수정 없이 독립적 모듈(게시판, 커머스 등) 개발이 가능합니다. Hook 기반 기능 주입으로 Service-Repository 패턴의 명확한 계층 분리를 유지합니다 |
| **현지화** | 백엔드부터 프론트엔드까지 일관된 다국어 개발 환경을 제공합니다. 로케일 기반 UI, 확장 가능한 언어 팩을 지원합니다 |
| **해외 결제** | 로컬 비즈니스를 넘어 글로벌 커머스로 도약하기 위한 기반을 제공합니다 `정식버전에서 지원예정` |
| **권한 제어** | 역할별 메뉴와 기능, 데이터 범위까지 제어할 수 있습니다. 역할(Role) + 권한(Permission) + 스코프(Scope) 3단계 접근 제어로 조직 구조에 맞는 유연한 접근 관리를 제공합니다 |
| **보안** | 입력값 자동 검증과 토큰 기반 인증을 제공합니다. 설계부터 보안을 고려한 다층 방어 구조(CSRF/XSS/SQL Injection)를 구현합니다 |
| **유연한 화면 구성** | 화면 구조를 정의하면 즉시 반영할 수 있습니다. 프론트엔드 인프라 없이 JSON 선언만으로 웹앱 수준의 동적 화면 구현이 가능합니다 |
| **레이아웃 편집기** | 위지윅 기반 레이아웃 편집 기능으로 화면 블록을 직접 배치하고 수정 결과를 바로 확인할 수 있습니다 `정식버전에서 지원예정` |
| **검증된 기반** | Laravel + React 기반을 제공합니다. 글로벌 기업이 채택한 기술 스택으로 높은 확장성과 유연한 UI 구현이 가능합니다 |
| **캐싱** | 화면 구조와 API 응답, 권한 정보를 다층으로 캐싱합니다. 불필요한 재처리 없이 빠른 응답 속도를 유지합니다 |
| **활동 로그** | 관리자·사용자 활동 이력을 자동으로 기록하고 조회할 수 있습니다. Monolog 기반 구조로 확장이 용이합니다 |
| **검색** | Laravel Scout 기반 전문 검색을 지원합니다. 상품, 게시글 등 주요 콘텐츠를 대상으로 검색 기능을 제공합니다 |
---
## 기술 스택
| 구분 | 기술 |
|------|------|
| **백엔드** | PHP 8.2+, Laravel 12.x, MySQL 8.0+, Redis 6.0+ |
| **프론트엔드** | React 19, Vite, Tailwind CSS 4 (다크 모드 지원) |
| **인증** | Laravel Sanctum (Bearer 토큰) |
| **테스트** | PHPUnit 11.x, Vitest |
| **코드 품질** | Laravel Pint (PSR-12) |
---
## 아키텍처
```
Gnuboard7
├── Core (Laravel 12)
│ ├── Controller → FormRequest → Service → Repository → Model
│ ├── Hook System (Action / Filter)
│ ├── Permission (Role → Permission → Scope)
│ └── SEO (Bot Detection → Static HTML → Cache → Sitemap)
│
├── Extensions
│ ├── Modules — 게시판, 쇼핑몰, 페이지 ...
│ ├── Plugins — 결제, 인증, 마케팅 ...
│ └── Templates — 관리자 UI, 사용자 UI
│
└── Template Engine
├── JSON Layout → React Components
└── Dynamic Rendering + Data Binding
```
### 템플릿 엔진 동작 흐름
그누보드7의 템플릿 엔진은 **JSON으로 UI 구조를 선언**하면, 엔진이 이를 해석하여 React 컴포넌트로 렌더링합니다.
#### 현재 지원
- JSON 선언만으로 React 기반 UI 구성 — React 전문 지식 없이도 화면 개발 가능
- 모듈/플러그인이 프론트엔드 빌드 없이 JSON만으로 UI를 동적으로 주입/확장
- 고도화된 UI가 필요한 경우 커스텀 React 컴포넌트를 개발하여 등록 가능
#### 지원 예정
- UI가 코드가 아닌 데이터(JSON)로 정의되는 구조를 활용하여, **드래그 앤 드롭 방식의 비주얼 에디터**를 통해 비개발자도 화면을 직접 구성할 수 있도록 지원할 계획입니다
```mermaid
flowchart TB
subgraph Backend ["🔧 Backend — Laravel"]
A["📄 JSON 레이아웃 파일"] --> B["⚙️ LayoutService"]
B --> |"상속 해석<br/>extends / partial"| B
M["📦 모듈 레이아웃"] -.-> |"layout_extensions<br/>extension_point 주입"| B
P["🔌 플러그인 레이아웃"] -.-> |"layout_extensions<br/>extension_point 주입"| B
B --> C["🔒 권한 필터링<br/>사용자별 컴포넌트 제거"]
C --> D["📨 병합된 JSON 응답<br/>캐싱 · 1시간 TTL"]
end
subgraph Frontend ["⚛️ Frontend — React"]
D --> E["📥 LayoutLoader<br/>레이아웃 JSON 수신"]
E --> F["💾 상태 초기화<br/>_global · _local · _computed"]
E --> G["🌐 데이터 소스 로딩<br/>API 병렬 호출"]
F & G --> H["🎨 DynamicRenderer"]
H --> I{"❓ 조건 평가<br/>if 표현식"}
I --> |"✅ true"| J["🗂️ ComponentRegistry<br/>name → React 컴포넌트"]
I --> |"❌ false"| K["⏭️ 렌더링 스킵"]
J --> L["🔗 데이터 바인딩<br/>표현식 → 실제 값"]
L --> N["🖱️ 이벤트 바인딩<br/>onClick → ActionDispatcher"]
N --> O["✨ React 렌더링"]
end
subgraph Actions ["👆 사용자 인터랙션"]
O --> |"클릭 · 입력"| Q["🎯 ActionDispatcher"]
Q --> R["🧭 navigate — 페이지 이동"]
Q --> S["📡 apiCall — API 호출"]
Q --> T["🔄 setState — 상태 변경"]
Q --> U["📋 openModal — 모달 열기"]
S --> |"onSuccess · onError"| Q
T --> |"상태 변경 → 리렌더링"| H
end
style Backend fill:#dbeafe,stroke:#2563eb,stroke-width:2px,color:#1e3a5f
style Frontend fill:#d1fae5,stroke:#059669,stroke-width:2px,color:#064e3b
style Actions fill:#fce7f3,stroke:#db2777,stroke-width:2px,color:#831843
style A fill:#2563eb,stroke:#1d4ed8,color:#fff
style B fill:#2563eb,stroke:#1d4ed8,color:#fff
style M fill:#7c3aed,stroke:#6d28d9,color:#fff
style P fill:#7c3aed,stroke:#6d28d9,color:#fff
style C fill:#dc2626,stroke:#b91c1c,color:#fff
style D fill:#059669,stroke:#047857,color:#fff
style E fill:#059669,stroke:#047857,color:#fff
style F fill:#0891b2,stroke:#0e7490,color:#fff
style G fill:#0891b2,stroke:#0e7490,color:#fff
style H fill:#d97706,stroke:#b45309,color:#fff
style I fill:#d97706,stroke:#b45309,color:#fff
style J fill:#2563eb,stroke:#1d4ed8,color:#fff
style K fill:#6b7280,stroke:#4b5563,color:#fff
style L fill:#7c3aed,stroke:#6d28d9,color:#fff
style N fill:#7c3aed,stroke:#6d28d9,color:#fff
style O fill:#059669,stroke:#047857,color:#fff
style Q fill:#e11d48,stroke:#be123c,color:#fff
style R fill:#be185d,stroke:#9d174d,color:#fff
style S fill:#be185d,stroke:#9d174d,color:#fff
style T fill:#be185d,stroke:#9d174d,color:#fff
style U fill:#be185d,stroke:#9d174d,color:#fff
linkStyle default stroke:#374151,stroke-width:2px
```
**JSON 레이아웃 예시** — 아래 JSON이 실제 React UI로 렌더링됩니다:
```json
{
"data_sources": [
{ "id": "products", "endpoint": "/api/products", "method": "GET" }
],
"layout": {
"type": "basic", "name": "Div",
"children": [
{ "type": "basic", "name": "H1", "text": "$t:product_list" },
{
"type": "basic", "name": "Div",
"iteration": { "source": "{{products?.data?.data}}", "item_var": "$item" },
"children": [
{ "type": "basic", "name": "Span", "text": "{{$item.name}}" }
]
},
{
"type": "basic", "name": "Button", "text": "$t:add",
"if": "{{products?.data?.abilities?.can_create}}",
"actions": [{
"event": "onClick",
"handler": "navigate",
"params": { "path": "/products/create" }
}]
}
]
}
}
```
모듈/플러그인을 활성화하면 해당 UI와 컴포넌트가 자동으로 주입됩니다.
개발자는 JSON만으로 UI를 추가하거나 변경할 수 있어 별도의 프론트엔드 빌드가 필요 없으며, 권한(abilities)에 따라 UI 요소가 자동으로 표시/숨김 처리됩니다.
**확장 시스템 3원칙**
1. **코어 수정 최소화** — 모든 비즈니스 로직은 모듈/플러그인으로 구현
2. **동적 로딩** — composer.json 하드코딩 없이 디렉토리 스캔으로 자동 발견
3. **Hook 기반 확장** — 서비스 계층에서 Action/Filter 훅으로 기능 주입
---
## 빠른 시작
### 시스템 요구사항
- PHP 8.2+ (필수 확장 30개 포함)
- MySQL 8.0+ 또는 MariaDB 10.3+ (utf8mb4)
- Node.js 20+ (빌드 시에만 필요)
- Composer 2.x
### 설치
```bash
# 1. 프로젝트 클론
git clone https://github.com/gnuboard/g7.git
cd g7
# 2. 환경 설정 파일 복사
cp .env.example .env
# 3. 브라우저에서 /install 접속 → 설치 마법사 진행
```
> 상세 설치 가이드는 [INSTALL.md](INSTALL.md)를 참조하세요.
---
## 기본 제공 확장
### 모듈
| 모듈 | 설명 |
|------|------|
| **sirsoft-board** | 게시판 — 다중 게시판, 댓글, 파일 첨부 |
| **sirsoft-ecommerce** | 쇼핑몰 — 상품, 주문, 결제, 배송, 쿠폰, 상품 문의 |
| **sirsoft-page** | 페이지 — 정적 콘텐츠 관리 |
### 플러그인
| 플러그인 | 설명 |
|---------|------|
| **sirsoft-tosspayments** | 토스페이먼츠 결제 연동 |
| **sirsoft-verification** | 본인인증 |
| **sirsoft-daum_postcode** | 다음 우편번호 검색 |
| **sirsoft-marketing** | 마케팅 도구 |
### 템플릿
| 템플릿 | 설명 |
|--------|------|
| **sirsoft-admin_basic** | 관리자 기본 템플릿 |
| **sirsoft-basic** | 사용자 기본 템플릿 |
---
## 비즈니스 모델
그누보드7 하나로 다양한 비즈니스를 운영할 수 있습니다.
| 모델 | 설명 | 상태 |
|------|------|------|
| **커뮤니티** | 게시판, 댓글, 회원 관리 | Beta |
| **커머스** | 상품 등록, 주문, 결제, 배송 관리 | Beta |
---
## 기존 그누보드 사용자
기존 그누보드5에서 그누보드7으로 전환할 수 있도록, 회원·게시글·상품 등 주요 데이터의 **마이그레이션 툴을 제공할 예정**입니다.
---
## 문서
| 문서 | 링크 |
|------|------|
| 설치 가이드 | [INSTALL.md](INSTALL.md) |
| 전체 문서 | [docs/README.md](docs/README.md) |
| 시스템 요구사항 | [docs/requirements.md](docs/requirements.md) |
| 백엔드 개발 | [docs/backend/README.md](docs/backend/README.md) |
| 프론트엔드 개발 | [docs/frontend/README.md](docs/frontend/README.md) |
| 데이터베이스 | [docs/database-guide.md](docs/database-guide.md) |
| 확장 시스템 | [docs/extension/README.md](docs/extension/README.md) |
| 모듈 개발 | [docs/extension/module-basics.md](docs/extension/module-basics.md) |
| 플러그인 개발 | [docs/extension/plugin-development.md](docs/extension/plugin-development.md) |
| 템플릿 개발 | [docs/extension/template-basics.md](docs/extension/template-basics.md) |
| 테스트 | [docs/testing-guide.md](docs/testing-guide.md) |
| API 레퍼런스 | 준비 중 |
---
## 기여하기
그누보드7은 오픈소스 프로젝트입니다. 모든 형태의 기여를 환영합니다.
- 버그 리포트 및 기능 제안: [GitHub Issues](https://github.com/gnuboard/g7/issues)
- 코드 스타일: Laravel Pint (PSR-12)
- 테스트: PHPUnit (백엔드) + Vitest (프론트엔드)
- AI 협업: AI 에이전트용 개발 규칙 명세(AGENTS.md)와 MCP 디버깅 도구를 내장하고 있어, AI 도구와 자연스럽게 협업할 수 있습니다
---
## 만든 사람들
**[SIRSOFT](https://sir.kr)** 에서 개발하고 있습니다.
### Core Team
<p>
<a href="https://github.com/HeuJung"><img src="https://github.com/HeuJung.png" width="60" alt="HeuJung"></a>&nbsp;&nbsp;
<a href="https://github.com/chym1217"><img src="https://github.com/chym1217.png" width="60" alt="chym1217"></a>
</p>
### Contributors
커뮤니티 기여자 목록은 [GitHub Contributors](https://github.com/gnuboard/g7/graphs/contributors)에서 확인할 수 있습니다.
---
## 커뮤니티
| 채널 | 링크 |
|------|------|
| GitHub | [github.com/gnuboard/g7](https://github.com/gnuboard/g7) |
| SIR 커뮤니티 | [sir.kr](https://sir.kr) |
| 문의 | minsup@sir.kr |
---
## 변경 기록
최근 변경된 사항에 대한 자세한 내용은 [CHANGELOG](CHANGELOG.md)를 참고해 주세요.
---
## 보안 취약점
보안 취약점을 발견하셨다면 [GitHub Issues](https://github.com/gnuboard/g7/issues)에 보고해 주세요.
---
## 라이선스
그누보드7은 [MIT 라이선스](LICENSE)에 따라 배포되는 오픈소스 소프트웨어입니다.
Copyright (c) 2026 SIRSOFT
---
<p align="center">
Made by <a href="https://sir.kr">SIRSOFT</a>
</p>
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\ActivityLog;
use Monolog\Logger;
/**
* Monolog 커스텀 채널 팩토리.
*
* config/logging.php의 'activity' 채널에서 via 클래스로 사용됩니다.
* Laravel의 custom 드라이버가 __invoke($config)를 호출하여 Monolog Logger를 반환합니다.
*/
class ActivityLogChannel
{
/**
* 활동 로그 전용 Monolog Logger 인스턴스를 생성합니다.
*
* @param array $config 채널 설정
* @return Logger
*/
public function __invoke(array $config): Logger
{
$logger = new Logger('activity');
$logger->pushProcessor(new ActivityLogProcessor);
$logger->pushHandler(new ActivityLogHandler);
return $logger;
}
}
+81
View File
@@ -0,0 +1,81 @@
<?php
namespace App\ActivityLog;
use App\Enums\ActivityLogType;
use App\Models\ActivityLog;
use Illuminate\Database\Eloquent\Model;
use Monolog\Handler\AbstractProcessingHandler;
use Monolog\Level;
use Monolog\LogRecord;
/**
* 활동 로그 전용 Monolog Handler.
*
* Monolog context에서 구조화 데이터를 추출하여 activity_logs 테이블에 저장합니다.
* ActivityLogChannel에서 등록되어 Log::channel('activity') 호출 시 동작합니다.
*/
class ActivityLogHandler extends AbstractProcessingHandler
{
/**
* @param Level $level 최소 로그 레벨
* @param bool $bubble 버블링 여부
*/
public function __construct(Level $level = Level::Debug, bool $bubble = true)
{
parent::__construct($level, $bubble);
}
/**
* 로그 레코드를 activity_logs 테이블에 저장합니다.
*
* @param LogRecord $record Monolog 로그 레코드
* @return void
*/
protected function write(LogRecord $record): void
{
if (! config('activity_log.enabled', true)) {
return;
}
$context = $record->context;
$data = [
'log_type' => $context['log_type'] ?? ActivityLogType::System,
'action' => $record->message,
'description_key' => $context['description_key'] ?? null,
'description_params' => $context['description_params'] ?? null,
'properties' => $context['properties'] ?? null,
'changes' => $context['changes'] ?? null,
'user_id' => $context['user_id'] ?? null,
'ip_address' => $context['ip_address'] ?? null,
'user_agent' => $this->truncateUserAgent($context['user_agent'] ?? null),
];
$loggable = $context['loggable'] ?? null;
if ($loggable instanceof Model) {
$data['loggable_type'] = $loggable->getMorphClass();
$data['loggable_id'] = $loggable->getKey();
} elseif (isset($context['loggable_type'], $context['loggable_id'])) {
$data['loggable_type'] = $context['loggable_type'];
$data['loggable_id'] = $context['loggable_id'];
}
ActivityLog::create($data);
}
/**
* User-Agent 문자열을 최대 500자로 자릅니다.
*
* @param string|null $userAgent User-Agent 문자열
* @return string|null 잘린 User-Agent
*/
private function truncateUserAgent(?string $userAgent): ?string
{
if ($userAgent === null) {
return null;
}
return mb_substr($userAgent, 0, 500);
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\ActivityLog;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Request;
use Monolog\LogRecord;
use Monolog\Processor\ProcessorInterface;
/**
* 활동 로그 전용 Monolog Processor.
*
* 요청별 공통 데이터(user_id, ip_address, user_agent)를 자동 주입합니다.
* 리스너에서 명시적으로 전달하지 않은 경우에만 주입됩니다.
*/
class ActivityLogProcessor implements ProcessorInterface
{
/**
* 로그 레코드에 요청 컨텍스트 정보를 자동 주입합니다.
*
* @param LogRecord $record Monolog 로그 레코드
* @return LogRecord 컨텍스트가 주입된 로그 레코드
*/
public function __invoke(LogRecord $record): LogRecord
{
$context = $record->context;
// 리스너에서 명시적으로 전달하지 않은 경우에만 자동 주입
$context['user_id'] ??= Auth::id();
$context['ip_address'] ??= Request::ip();
$context['user_agent'] ??= Request::userAgent();
return $record->with(context: $context);
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
namespace App\ActivityLog;
use Illuminate\Database\Eloquent\Model;
/**
* 모델 변경 감지 유틸리티.
*
* 모델의 $activityLogFields 메타데이터와 스냅샷을 비교하여
* 구조화된 변경 이력(changes JSON)을 생성합니다.
*/
class ChangeDetector
{
/**
* 모델의 현재 상태와 스냅샷을 비교하여 변경 사항을 감지합니다.
*
* @param Model $model 현재 모델 상태
* @param array|null $snapshot 변경 전 스냅샷 (toArray() 결과)
* @return array|null 변경 사항 배열 또는 변경 없으면 null
*/
public static function detect(Model $model, ?array $snapshot): ?array
{
if ($snapshot === null) {
return null;
}
$fields = static::getActivityLogFields($model);
if (empty($fields)) {
return null;
}
$changes = [];
foreach ($fields as $field => $meta) {
$old = $snapshot[$field] ?? null;
$new = $model->getAttribute($field);
// Enum 객체를 값으로 변환
if ($old instanceof \BackedEnum) {
$old = $old->value;
}
if ($new instanceof \BackedEnum) {
$new = $new->value;
}
if ($old !== $new) {
$change = [
'field' => $field,
'label_key' => $meta['label_key'],
'old' => $old,
'new' => $new,
'type' => $meta['type'] ?? 'text',
];
// enum 타입이면 라벨 키 추가
if (($meta['type'] ?? 'text') === 'enum' && isset($meta['enum'])) {
$enumClass = $meta['enum'];
$change['old_label_key'] = $old !== null ? $enumClass::tryFrom($old)?->labelKey() : null;
$change['new_label_key'] = $new !== null ? $enumClass::tryFrom($new)?->labelKey() : null;
}
$changes[] = $change;
}
}
return ! empty($changes) ? $changes : null;
}
/**
* 모델의 $activityLogFields 메타데이터를 반환합니다.
*
* @param Model $model 대상 모델
* @return array 필드 메타데이터 배열
*/
private static function getActivityLogFields(Model $model): array
{
if (property_exists($model, 'activityLogFields')) {
return $model::$activityLogFields;
}
return [];
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\ActivityLog\Traits;
use App\Enums\ActivityLogType;
use Illuminate\Support\Facades\Log;
/**
* 활동 로그 기록 및 로그 타입 자동 결정 트레이트
*
* Service 훅이 관리자/사용자/시스템 등 다양한 컨텍스트에서 호출될 수 있으므로
* 요청 경로를 기반으로 log_type을 동적으로 결정합니다.
*
* - /api/admin/* 경로 → ActivityLogType::Admin
* - 그 외 HTTP 요청 → ActivityLogType::User
* - CLI/스케줄러(request 없음) → ActivityLogType::System
*/
trait ResolvesActivityLogType
{
/**
* 요청 경로를 기반으로 로그 타입을 결정합니다.
*
* @return ActivityLogType 결정된 로그 타입
*/
protected function resolveLogType(): ActivityLogType
{
$request = request();
if ($request && $request->path() !== '/') {
return $request->is('api/admin/*')
? ActivityLogType::Admin
: ActivityLogType::User;
}
return ActivityLogType::System;
}
/**
* 활동 로그를 기록합니다.
*
* context에 log_type이 명시되지 않으면 resolveLogType()으로 자동 결정합니다.
*
* @param string $action 액션명 (예: 'user.create')
* @param array $context Monolog context 배열
*/
protected function logActivity(string $action, array $context): void
{
$context['log_type'] ??= $this->resolveLogType();
try {
Log::channel('activity')->info($action, $context);
} catch (\Exception $e) {
Log::error('Failed to record activity log', [
'action' => $action,
'error' => $e->getMessage(),
]);
}
}
}
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace App\Casts;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
/**
* Unicode 이스케이프 없이 JSON을 저장하는 커스텀 캐스트
*
* Laravel 기본 'array' 캐스트는 json_encode()로 한글을 \uXXXX 이스케이프합니다.
* 이 캐스트는 JSON_UNESCAPED_UNICODE 플래그를 사용하여 실제 UTF-8 문자로 저장합니다.
*
* FULLTEXT 인덱스(ngram)가 한글 토큰을 올바르게 생성하려면
* 실제 UTF-8 문자가 저장되어야 합니다.
*/
class AsUnicodeJson implements CastsAttributes
{
/**
* 데이터베이스에서 값을 읽을 때 변환합니다.
*
* @param Model $model 모델 인스턴스
* @param string $key 속성명
* @param mixed $value 원본 값
* @param array<string, mixed> $attributes 전체 속성
* @return array|null
*/
public function get(Model $model, string $key, mixed $value, array $attributes): ?array
{
if ($value === null) {
return null;
}
return json_decode($value, true);
}
/**
* 데이터베이스에 값을 저장할 때 변환합니다.
*
* JSON_UNESCAPED_UNICODE 플래그를 사용하여 한글 등 멀티바이트 문자를
* \uXXXX 이스케이프 없이 실제 UTF-8로 저장합니다.
*
* @param Model $model 모델 인스턴스
* @param string $key 속성명
* @param mixed $value 저장할 값
* @param array<string, mixed> $attributes 전체 속성
* @return string|null
*/
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
{
if ($value === null) {
return null;
}
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
}
@@ -0,0 +1,396 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
/**
* 레이아웃 파일에 permissions 필드를 일괄 추가하는 커맨드
*
* 관리자 레이아웃에 접근 제어를 위한 permissions 필드를 추가합니다.
*/
class AddLayoutPermissionsCommand extends Command
{
/**
* 커맨드 시그니처
*
* @var string
*/
protected $signature = 'layout:add-permissions
{--dry-run : 실제 파일 수정 없이 시뮬레이션만 수행}
{--layout= : 특정 레이아웃만 처리 (레이아웃 이름)}
{--force : 이미 permissions가 있는 경우에도 덮어쓰기}';
/**
* 커맨드 설명
*
* @var string
*/
protected $description = '레이아웃 파일에 permissions 필드를 일괄 추가합니다';
/**
* 레이아웃별 권한 매핑
*
* @var array<string, array<string>>
*/
protected array $permissionMappings = [
// sirsoft-admin_basic 템플릿
'admin_dashboard' => ['core.dashboard.read'],
'admin_user_list' => ['core.users.read'],
'admin_user_form' => ['core.users.read'],
'admin_user_detail' => ['core.users.read'],
'admin_role_list' => ['core.permissions.read'],
'admin_role_form' => ['core.permissions.read'],
'admin_menu_list' => ['core.menus.read'],
'admin_schedule_list' => ['core.schedules.read'],
'admin_module_list' => ['core.modules.read'],
'admin_plugin_list' => ['core.plugins.read'],
'admin_template_list' => ['core.templates.read'],
'admin_template_layout_edit' => ['core.templates.read'],
'admin_settings' => ['core.settings.read'],
// sirsoft-ecommerce 모듈
'admin_ecommerce_product_list' => ['sirsoft-ecommerce.products.read'],
'admin_ecommerce_product_form' => ['sirsoft-ecommerce.products.read'],
'admin_ecommerce_brand_index' => ['sirsoft-ecommerce.brands.read'],
'admin_ecommerce_category_index' => ['sirsoft-ecommerce.categories.read'],
'admin_ecommerce_order_index' => ['sirsoft-ecommerce.orders.read'],
'admin_ecommerce_order_list' => ['sirsoft-ecommerce.orders.read'],
'admin_ecommerce_order_detail' => ['sirsoft-ecommerce.orders.read'],
'admin_ecommerce_settings' => ['sirsoft-ecommerce.settings.read'],
'admin_ecommerce_order_settings' => ['sirsoft-ecommerce.settings.read'],
'admin_ecommerce_mileage_deposit_settings' => ['sirsoft-ecommerce.settings.read'],
'admin_ecommerce_promotion_coupon_list' => ['sirsoft-ecommerce.promotion-coupon.read'],
'admin_ecommerce_promotion_coupon_form' => ['sirsoft-ecommerce.promotion-coupon.read'],
'admin_ecommerce_promotion_coupon_create' => ['sirsoft-ecommerce.promotion-coupon.read'],
'admin_ecommerce_product_notice_index' => ['sirsoft-ecommerce.product-notice-templates.read'],
// sirsoft-board 모듈
'admin_board_index' => ['sirsoft-board.boards.read'],
'admin_board_form' => ['sirsoft-board.boards.read'],
'admin_board_posts_index' => ['sirsoft-board.boards.read'],
'admin_board_post_form' => ['sirsoft-board.boards.read'],
'admin_board_post_detail' => ['sirsoft-board.boards.read'],
'admin_board_reports_index' => ['sirsoft-board.reports.view'],
'admin_board_reports_detail' => ['sirsoft-board.reports.view'],
// sirsoft-sample 모듈
'admin_sample_index' => ['sirsoft-sample.items.view'],
'admin_sample_edit' => ['sirsoft-sample.items.view'],
// sirsoft-daum_postcode 플러그인
'plugin_settings' => ['core.plugins.update'],
];
/**
* 공개 레이아웃 목록 (권한 추가 제외)
*
* @var array<string>
*/
protected array $publicLayouts = [
// Base 레이아웃
'_admin_base',
'_user_base',
// 인증 페이지
'admin_login',
'login',
'register',
'forgot_password',
'reset_password',
// 에러 페이지
'403',
'404',
'500',
'503',
// 테스트용
'template_partial_test',
'module_partial_test',
];
/**
* 처리 통계
*
* @var array<string, int>
*/
protected array $stats = [
'scanned' => 0,
'updated' => 0,
'skipped' => 0,
'errors' => 0,
];
/**
* 커맨드 실행
*/
public function handle(): int
{
$isDryRun = $this->option('dry-run');
$targetLayout = $this->option('layout');
$force = $this->option('force');
if ($isDryRun) {
$this->info('🔍 시뮬레이션 모드로 실행합니다. (실제 파일 수정 없음)');
$this->newLine();
}
// 템플릿 레이아웃 처리
$this->info('📁 템플릿 레이아웃 스캔 중...');
$this->processTemplateLayouts($targetLayout, $isDryRun, $force);
// 모듈 레이아웃 처리
$this->newLine();
$this->info('📁 모듈 레이아웃 스캔 중...');
$this->processModuleLayouts($targetLayout, $isDryRun, $force);
// 플러그인 레이아웃 처리
$this->newLine();
$this->info('📁 플러그인 레이아웃 스캔 중...');
$this->processPluginLayouts($targetLayout, $isDryRun, $force);
// 결과 출력
$this->newLine();
$this->info('📊 처리 결과:');
$this->table(
['항목', '개수'],
[
['스캔된 레이아웃', $this->stats['scanned']],
['업데이트됨', $this->stats['updated']],
['건너뜀', $this->stats['skipped']],
['오류', $this->stats['errors']],
]
);
if ($isDryRun && $this->stats['updated'] > 0) {
$this->newLine();
$this->warn('⚠️ --dry-run 옵션을 제거하면 실제로 파일이 수정됩니다.');
}
return self::SUCCESS;
}
/**
* 템플릿 레이아웃 처리
*/
protected function processTemplateLayouts(?string $targetLayout, bool $isDryRun, bool $force): void
{
$templatesPath = base_path('templates');
if (! File::exists($templatesPath)) {
return;
}
foreach (File::directories($templatesPath) as $templateDir) {
$layoutsPath = $templateDir . '/layouts';
if (! File::exists($layoutsPath)) {
continue;
}
$this->processLayoutDirectory($layoutsPath, $targetLayout, $isDryRun, $force);
}
}
/**
* 모듈 레이아웃 처리
*/
protected function processModuleLayouts(?string $targetLayout, bool $isDryRun, bool $force): void
{
$modulesPath = base_path('modules');
if (! File::exists($modulesPath)) {
return;
}
foreach (File::directories($modulesPath) as $moduleDir) {
$layoutsPath = $moduleDir . '/resources/layouts';
if (! File::exists($layoutsPath)) {
continue;
}
$this->processLayoutDirectory($layoutsPath, $targetLayout, $isDryRun, $force);
}
}
/**
* 플러그인 레이아웃 처리
*/
protected function processPluginLayouts(?string $targetLayout, bool $isDryRun, bool $force): void
{
$pluginsPath = base_path('plugins');
if (! File::exists($pluginsPath)) {
return;
}
foreach (File::directories($pluginsPath) as $pluginDir) {
$layoutsPath = $pluginDir . '/resources/layouts';
if (! File::exists($layoutsPath)) {
continue;
}
$this->processLayoutDirectory($layoutsPath, $targetLayout, $isDryRun, $force);
}
}
/**
* 레이아웃 디렉토리 내 JSON 파일 처리
*/
protected function processLayoutDirectory(string $path, ?string $targetLayout, bool $isDryRun, bool $force): void
{
// 재귀적으로 모든 JSON 파일 검색
$files = File::allFiles($path);
foreach ($files as $file) {
if ($file->getExtension() !== 'json') {
continue;
}
// partials 디렉토리는 건너뜀
if (str_contains($file->getPathname(), '/partials/') || str_contains($file->getPathname(), '\\partials\\')) {
continue;
}
$this->processLayoutFile($file->getPathname(), $targetLayout, $isDryRun, $force);
}
}
/**
* 개별 레이아웃 파일 처리
*/
protected function processLayoutFile(string $filePath, ?string $targetLayout, bool $isDryRun, bool $force): void
{
$this->stats['scanned']++;
// JSON 파일 로드
$content = File::get($filePath);
$layout = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->error(" ❌ JSON 파싱 실패: {$filePath}");
$this->stats['errors']++;
return;
}
$layoutName = $layout['layout_name'] ?? pathinfo($filePath, PATHINFO_FILENAME);
// 특정 레이아웃만 처리하는 경우
if ($targetLayout !== null && $layoutName !== $targetLayout) {
return;
}
// 공개 레이아웃인 경우 건너뜀
if ($this->isPublicLayout($layoutName)) {
$this->line(" ⏭️ 건너뜀 (공개 레이아웃): {$layoutName}");
$this->stats['skipped']++;
return;
}
// 이미 permissions가 있는 경우 (멱등성 보장)
if (isset($layout['permissions']) && ! $force) {
$this->line(" ⏭️ 건너뜀 (이미 존재): {$layoutName}");
$this->stats['skipped']++;
return;
}
// permissions 결정: 매핑이 있으면 해당 권한, 없으면 빈 배열
$permissions = $this->permissionMappings[$layoutName] ?? [];
// 로그 메시지 생성 (빈 배열/권한 있음 구분)
$permissionDisplay = empty($permissions) ? '[] (공개)' : json_encode($permissions);
if ($isDryRun) {
$this->info(" ✅ 업데이트 예정: {$layoutName} → {$permissionDisplay}");
$this->stats['updated']++;
return;
}
// 실제 파일 수정
$layout['permissions'] = $permissions;
// version 다음에 permissions 배치하기 위해 순서 재정렬
$orderedLayout = $this->reorderLayoutKeys($layout);
$newContent = json_encode($orderedLayout, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
File::put($filePath, $newContent . "\n");
$this->info(" ✅ 업데이트됨: {$layoutName} → {$permissionDisplay}");
$this->stats['updated']++;
}
/**
* 공개 레이아웃인지 확인
*/
protected function isPublicLayout(string $layoutName): bool
{
// 정확한 일치 확인
if (in_array($layoutName, $this->publicLayouts, true)) {
return true;
}
// _로 시작하는 베이스 레이아웃
if (str_starts_with($layoutName, '_')) {
return true;
}
// auth/ 경로
if (str_starts_with($layoutName, 'auth/') || str_starts_with($layoutName, 'auth_')) {
return true;
}
// errors/ 경로
if (str_starts_with($layoutName, 'errors/') || str_starts_with($layoutName, 'errors_')) {
return true;
}
return false;
}
/**
* 레이아웃 키 순서 재정렬 (permissions를 version 다음에 배치)
*/
protected function reorderLayoutKeys(array $layout): array
{
$orderedKeys = [
'version',
'layout_name',
'permissions',
'extends',
'endpoint',
'meta',
'state',
'init_state',
'computed',
'data_sources',
'defines',
'init_actions',
'components',
'slots',
'modals',
'scripts',
];
$ordered = [];
// 정의된 순서대로 키 배치
foreach ($orderedKeys as $key) {
if (array_key_exists($key, $layout)) {
$ordered[$key] = $layout[$key];
}
}
// 나머지 키 추가 (정의되지 않은 키)
foreach ($layout as $key => $value) {
if (! array_key_exists($key, $ordered)) {
$ordered[$key] = $value;
}
}
return $ordered;
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Console\Commands;
use App\Events\Dashboard\DashboardUpdated;
use App\Services\DashboardService;
use Illuminate\Console\Command;
/**
* 시스템 리소스 정보를 주기적으로 브로드캐스트하는 커맨드
*
* 스케줄러를 통해 주기적으로 실행되어 대시보드에 실시간 시스템 리소스 정보를 전달합니다.
*/
class BroadcastDashboardResources extends Command
{
/**
* 콘솔 명령어 시그니처
*
* @var string
*/
protected $signature = 'dashboard:broadcast-resources';
/**
* 콘솔 명령어 설명
*
* @var string
*/
protected $description = '시스템 리소스 정보를 브로드캐스트합니다';
/**
* 커맨드를 실행합니다.
*
* @param DashboardService $dashboardService 대시보드 서비스
* @return int
*/
public function handle(DashboardService $dashboardService): int
{
broadcast(new DashboardUpdated('resources', $dashboardService->getSystemResources()));
$this->info('시스템 리소스 정보가 브로드캐스트되었습니다.');
return Command::SUCCESS;
}
}
@@ -0,0 +1,37 @@
<?php
namespace App\Console\Commands;
use App\Services\LayoutPreviewService;
use Illuminate\Console\Command;
/**
* 만료된 레이아웃 미리보기를 정리하는 커맨드
*/
class CleanupLayoutPreviewsCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'layout-previews:cleanup';
/**
* The console command description.
*/
protected $description = '만료된 레이아웃 미리보기 데이터를 삭제합니다';
/**
* Execute the console command.
*
* @param LayoutPreviewService $service 미리보기 서비스
* @return int 명령 실행 결과 코드
*/
public function handle(LayoutPreviewService $service): int
{
$deleted = $service->cleanupExpired();
$this->info("만료된 미리보기 {$deleted}건이 삭제되었습니다.");
return self::SUCCESS;
}
}
@@ -0,0 +1,197 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
/**
* JSON 컬럼의 \uXXXX 유니코드 이스케이프를 실제 UTF-8 문자로 변환합니다.
*
* PHP json_encode() 기본 동작은 멀티바이트 문자를 \uXXXX로 인코딩합니다.
* MySQL FULLTEXT(ngram)은 실제 UTF-8 바이트만 토큰화하므로,
* 기존 데이터를 JSON_UNESCAPED_UNICODE 형식으로 변환해야 합니다.
*
* 사용 예:
* php artisan json:convert-unicode --dry-run # 변환 대상 건수 확인
* php artisan json:convert-unicode # 실제 변환 실행
*/
class ConvertJsonUnicodeEscapes extends Command
{
/**
* 콘솔 커맨드 시그니처
*
* @var string
*/
protected $signature = 'json:convert-unicode
{--dry-run : 실제 변환 없이 대상 건수만 표시}
{--table= : 특정 테이블만 변환 (예: ecommerce_products)}
{--chunk=500 : 청크 크기}';
/**
* 콘솔 커맨드 설명
*
* @var string
*/
protected $description = 'JSON 컬럼의 \\uXXXX 유니코드 이스케이프를 실제 UTF-8 문자로 변환합니다.';
/**
* 변환 대상 테이블 및 컬럼 정의
*
* @var array<string, string[]>
*/
private const TARGETS = [
'ecommerce_products' => ['name', 'description'],
'ecommerce_categories' => ['name', 'description'],
'ecommerce_brands' => ['name'],
'ecommerce_promotion_coupons' => ['name', 'description'],
'ecommerce_product_common_infos' => ['name', 'content'],
'boards' => ['name', 'description'],
'boards_report_logs' => ['snapshot'],
'pages' => ['title', 'content'],
];
/**
* 커맨드를 실행합니다.
*
* @return int 종료 코드
*/
public function handle(): int
{
$dryRun = $this->option('dry-run');
$targetTable = $this->option('table');
$chunkSize = (int) $this->option('chunk');
if ($dryRun) {
$this->components->info('Dry-run 모드: 실제 변환 없이 대상 건수만 표시합니다.');
}
$targets = self::TARGETS;
if ($targetTable) {
if (! isset($targets[$targetTable])) {
$this->components->error("알 수 없는 테이블: {$targetTable}");
$this->components->info('사용 가능한 테이블: '.implode(', ', array_keys($targets)));
return self::FAILURE;
}
$targets = [$targetTable => $targets[$targetTable]];
}
$totalConverted = 0;
$totalSkipped = 0;
foreach ($targets as $table => $columns) {
$prefix = DB::getTablePrefix();
if (! $this->tableExists($prefix.$table)) {
$this->components->warn("{$table} 테이블이 존재하지 않습니다. 스킵합니다.");
continue;
}
foreach ($columns as $column) {
[$converted, $skipped] = $this->processColumn($table, $column, $chunkSize, $dryRun);
$totalConverted += $converted;
$totalSkipped += $skipped;
}
}
$this->newLine();
if ($dryRun) {
$this->components->info("변환 대상: {$totalConverted}건, 스킵(이미 UTF-8): {$totalSkipped}건");
} else {
$this->components->info("변환 완료: {$totalConverted}건, 스킵(이미 UTF-8): {$totalSkipped}건");
}
return self::SUCCESS;
}
/**
* 테이블 존재 여부를 확인합니다.
*
* @param string $table 테이블명 (프리픽스 포함)
* @return bool 존재 여부
*/
private function tableExists(string $table): bool
{
try {
DB::select("SELECT 1 FROM `{$table}` LIMIT 1");
return true;
} catch (\Exception) {
return false;
}
}
/**
* 특정 테이블의 특정 컬럼을 변환합니다.
*
* @param string $table 테이블명
* @param string $column 컬럼명
* @param int $chunkSize 청크 크기
* @param bool $dryRun 드라이런 모드
* @return array{int, int} [변환 건수, 스킵 건수]
*/
private function processColumn(string $table, string $column, int $chunkSize, bool $dryRun): array
{
$converted = 0;
$skipped = 0;
// \uXXXX 패턴이 포함된 행만 조회 (이미 변환된 행은 스킵)
$query = DB::table($table)
->whereNotNull($column)
->where($column, 'LIKE', '%\\\\u%');
$total = $query->count();
if ($total === 0) {
$this->components->twoColumnDetail("{$table}.{$column}", '<fg=green>변환 대상 없음</>');
return [0, 0];
}
$this->components->twoColumnDetail("{$table}.{$column}", "대상 {$total}건 처리 중...");
DB::table($table)
->whereNotNull($column)
->where($column, 'LIKE', '%\\\\u%')
->orderBy('id')
->chunk($chunkSize, function ($rows) use ($table, $column, $dryRun, &$converted, &$skipped) {
foreach ($rows as $row) {
$original = $row->{$column};
$decoded = json_decode($original, true);
if ($decoded === null) {
$skipped++;
continue;
}
$reencoded = json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
// 변환 전후가 동일하면 스킵
if ($reencoded === $original) {
$skipped++;
continue;
}
if (! $dryRun) {
DB::table($table)
->where('id', $row->id)
->update([$column => $reencoded]);
}
$converted++;
}
});
$label = $dryRun ? '변환 대상' : '변환 완료';
$this->components->twoColumnDetail(
" └ {$label}",
"<fg=yellow>{$converted}건</> (스킵: {$skipped}건)"
);
return [$converted, $skipped];
}
}
@@ -0,0 +1,218 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
/**
* PHPUnit Doc-Comment Annotations를 PHP 8 Attributes로 변환하는 Artisan 커맨드
*
* @package App\Console\Commands
* @author sirsoft
*/
class ConvertPHPUnitAnnotations extends Command
{
/**
* 커맨드 시그니처
*
* @var string
*/
protected $signature = 'phpunit:convert-annotations
{--dry-run : 변환 미리보기 (파일 수정 안 함)}
{--backup : 변환 전 백업 생성 (.bak 파일)}
{--path= : 특정 경로만 변환}
{--stats-only : 통계만 출력}
{--rollback : 백업에서 복원}';
/**
* 커맨드 설명
*
* @var string
*/
protected $description = 'PHPUnit doc-comment annotations를 PHP 8 attributes로 변환합니다';
/**
* 변환기 인스턴스
*
* @var mixed
*/
private $converter;
/**
* 커맨드를 실행합니다.
*
* @return int 종료 코드
*/
public function handle(): int
{
// 스크립트 파일 로드
$scriptPath = base_path('scripts/convert-phpunit-annotations.php');
if (!file_exists($scriptPath)) {
$this->error("스크립트 파일을 찾을 수 없습니다: {$scriptPath}");
return Command::FAILURE;
}
require_once $scriptPath;
// 롤백 모드
if ($this->option('rollback')) {
return $this->handleRollback();
}
// 통계만 확인
if ($this->option('stats-only')) {
return $this->handleStatsOnly();
}
// 변환 실행
return $this->handleConversion();
}
/**
* 통계만 확인합니다.
*
* @return int 종료 코드
*/
private function handleStatsOnly(): int
{
$this->info('PHPUnit Annotation 통계 확인 중...');
$this->newLine();
$converter = new \PHPUnitAnnotationConverter(false, false, false);
$basePath = base_path();
$specificPath = $this->option('path');
$stats = $converter->getStats($basePath, $specificPath);
$this->line('PHPUnit Annotation Conversion Statistics');
$this->line('=========================================');
$this->line("총 테스트 파일: {$stats['total_files']}");
$this->line("Annotations이 있는 파일: {$stats['files_with_annotations']}");
$this->newLine();
$this->line('Annotation 분류:');
$this->line("- @test: {$stats['test']}");
$this->line("- @dataProvider: {$stats['dataProvider']}");
$this->line("- @group: {$stats['group']}");
$this->line("- @depends: {$stats['depends']}");
$this->newLine();
if (!empty($stats['files'])) {
$this->line('파일별 상세:');
foreach ($stats['files'] as $fileInfo) {
$relativePath = str_replace(base_path() . DIRECTORY_SEPARATOR, '', $fileInfo['path']);
$this->line(" {$relativePath}");
foreach ($fileInfo['annotations'] as $annotation) {
$this->line(" - {$annotation}");
}
}
}
return Command::SUCCESS;
}
/**
* 변환을 실행합니다.
*
* @return int 종료 코드
*/
private function handleConversion(): int
{
$dryRun = $this->option('dry-run');
$backup = $this->option('backup');
$verbose = $this->option('verbose');
if ($dryRun) {
$this->warn('[DRY RUN] 변환 미리보기 모드 - 실제 파일은 수정되지 않습니다');
$this->newLine();
}
if ($backup && !$dryRun) {
$this->info('백업 모드 활성화 - 변환 전 .bak 파일이 생성됩니다');
$this->newLine();
}
$this->converter = new \PHPUnitAnnotationConverter($dryRun, $backup, $verbose);
$basePath = base_path();
$specificPath = $this->option('path');
// 파일 스캔
$this->info('테스트 파일 스캔 중...');
$files = $this->converter->scanTestFiles($basePath, $specificPath);
if (empty($files)) {
$this->warn('변환할 테스트 파일을 찾을 수 없습니다.');
return Command::SUCCESS;
}
$this->info('총 ' . count($files) . '개의 테스트 파일을 발견했습니다.');
$this->newLine();
// 프로그레스 바 생성
$bar = $this->output->createProgressBar(count($files));
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %message%');
$bar->setMessage('변환 시작...');
if (!$verbose) {
$bar->start();
}
$convertedCount = 0;
foreach ($files as $file) {
$relativePath = str_replace(base_path() . DIRECTORY_SEPARATOR, '', $file);
if ($verbose) {
$this->line("처리 중: {$relativePath}");
} else {
$bar->setMessage($relativePath);
}
if ($this->converter->convertFile($file)) {
$convertedCount++;
}
if (!$verbose) {
$bar->advance();
}
}
if (!$verbose) {
$bar->finish();
$this->newLine(2);
}
// 결과 출력
$report = $this->converter->generateReport();
$this->line($report);
if ($dryRun) {
$this->info('Dry-run 완료 - 실제 파일은 변경되지 않았습니다.');
$this->info('실제 변환을 수행하려면 --dry-run 옵션 없이 실행하세요.');
} else {
$this->info('변환 완료!');
if ($backup) {
$this->info('.bak 백업 파일이 생성되었습니다.');
$this->warn('테스트 실행 후 문제가 없으면 백업 파일을 삭제하세요:');
$this->line(' find tests modules plugins -name "*.bak" -delete');
}
}
return Command::SUCCESS;
}
/**
* 백업에서 복원합니다.
*
* @return int 종료 코드
*/
private function handleRollback(): int
{
$this->warn('백업 복원 기능은 현재 구현되지 않았습니다.');
$this->info('수동으로 .bak 파일을 복원하세요:');
$this->line(' for file in $(find . -name "*.bak"); do mv "$file" "${file%.bak}"; done');
return Command::SUCCESS;
}
}
@@ -0,0 +1,283 @@
<?php
namespace App\Console\Commands\Core;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Process\Process;
class BuildCoreCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'core:build
{--watch : 파일 변경 감시 모드}
{--production : 프로덕션 빌드}
{--full : 전체 빌드 (npm run build)}';
/**
* The console command description.
*/
protected $description = '그누보드7 코어 프론트엔드 에셋을 빌드합니다';
/**
* Execute the console command.
*
* @return int 명령 실행 결과 코드
*/
public function handle(): int
{
$watchMode = $this->option('watch');
$productionMode = $this->option('production');
$full = $this->option('full');
try {
$projectPath = base_path();
$packageJsonPath = $projectPath.'/package.json';
// package.json 존재 확인
if (! file_exists($packageJsonPath)) {
$this->error('❌ package.json 파일이 없습니다.');
return Command::FAILURE;
}
// node_modules 확인 및 설치
if (! is_dir($projectPath.'/node_modules')) {
$this->info('📦 의존성 설치 중...');
$installResult = $this->runNpmCommand(['npm', 'install'], $projectPath);
if ($installResult !== Command::SUCCESS) {
$this->error('❌ npm install 실패');
return Command::FAILURE;
}
}
// 빌드 유형 결정: 기본은 템플릿 엔진만 빌드
if ($full) {
return $this->buildFull($projectPath, $watchMode, $productionMode);
}
return $this->buildEngineOnly($projectPath, $watchMode, $productionMode);
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
Log::error('코어 빌드 실패', [
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
/**
* 템플릿 엔진만 빌드 (build:core)
*
* @param string $projectPath 프로젝트 경로
* @param bool $watchMode 파일 감시 모드
* @param bool $productionMode 프로덕션 빌드
* @return int 명령 실행 결과 코드
*/
private function buildEngineOnly(string $projectPath, bool $watchMode, bool $productionMode): int
{
$buildCommand = ['npm', 'run'];
if ($watchMode) {
// build:core는 watch 모드가 없으므로 dev 사용
$buildCommand[] = 'dev';
$this->info('👀 파일 감시 모드로 코어 빌드 시작 (템플릿 엔진)');
$this->line(' Ctrl+C로 종료할 수 있습니다.');
} else {
$buildCommand[] = 'build:core';
$this->info('🔨 코어 빌드 시작 (템플릿 엔진)'.($productionMode ? ' (프로덕션)' : ''));
}
$result = $this->runNpmCommand($buildCommand, $projectPath, ! $watchMode);
if ($result === Command::SUCCESS && ! $watchMode) {
$this->info('✅ 코어 빌드 완료 (템플릿 엔진)');
$this->showEngineBuildResults($projectPath);
}
return $result;
}
/**
* 전체 빌드
*
* @param string $projectPath 프로젝트 경로
* @param bool $watchMode 파일 감시 모드
* @param bool $productionMode 프로덕션 빌드
* @return int 명령 실행 결과 코드
*/
private function buildFull(string $projectPath, bool $watchMode, bool $productionMode): int
{
$buildCommand = ['npm', 'run'];
if ($watchMode) {
$buildCommand[] = 'dev';
$this->info('👀 파일 감시 모드로 코어 빌드 시작 (전체)');
$this->line(' Ctrl+C로 종료할 수 있습니다.');
} else {
$buildCommand[] = 'build';
$this->info('🔨 코어 빌드 시작 (전체)'.($productionMode ? ' (프로덕션)' : ''));
}
$result = $this->runNpmCommand($buildCommand, $projectPath, ! $watchMode);
if ($result === Command::SUCCESS && ! $watchMode) {
$this->info('✅ 코어 빌드 완료 (전체)');
$this->showFullBuildResults($projectPath);
}
return $result;
}
/**
* 템플릿 엔진 빌드 결과 출력
*
* @param string $projectPath 프로젝트 경로
*/
private function showEngineBuildResults(string $projectPath): void
{
$corePath = $projectPath.'/public/build/core';
if (! is_dir($corePath)) {
return;
}
$this->line(' 빌드 결과:');
// template-engine.min.js 확인
$engineFile = $corePath.'/template-engine.min.js';
if (file_exists($engineFile)) {
$fileSize = number_format(filesize($engineFile) / 1024, 2);
$this->line(" - template-engine.min.js ({$fileSize} KB)");
}
// lang 파일 확인
$langPath = $corePath.'/lang';
if (is_dir($langPath)) {
$langFiles = glob($langPath.'/*.json');
foreach ($langFiles as $langFile) {
$fileName = 'lang/'.basename($langFile);
$fileSize = number_format(filesize($langFile) / 1024, 2);
$this->line(" - {$fileName} ({$fileSize} KB)");
}
}
}
/**
* 전체 빌드 결과 출력
*
* @param string $projectPath 프로젝트 경로
*/
private function showFullBuildResults(string $projectPath): void
{
$buildPath = $projectPath.'/public/build';
if (! is_dir($buildPath)) {
return;
}
$this->line(' 빌드 결과:');
// manifest.json 확인
$manifestPath = $buildPath.'/manifest.json';
if (file_exists($manifestPath)) {
$manifest = json_decode(file_get_contents($manifestPath), true);
if ($manifest) {
foreach ($manifest as $source => $info) {
if (is_array($info) && isset($info['file'])) {
$filePath = $buildPath.'/'.$info['file'];
if (file_exists($filePath)) {
$fileName = $info['file'];
$fileSize = number_format(filesize($filePath) / 1024, 2);
$this->line(" - {$fileName} ({$fileSize} KB)");
}
}
}
}
} else {
// manifest가 없으면 직접 파일 탐색
$this->scanBuildFiles($buildPath, 'assets');
}
}
/**
* 빌드 파일 스캔 및 출력
*
* @param string $buildPath 빌드 경로
* @param string $subDir 하위 디렉토리
*/
private function scanBuildFiles(string $buildPath, string $subDir): void
{
$assetsPath = $buildPath.'/'.$subDir;
if (! is_dir($assetsPath)) {
return;
}
$files = scandir($assetsPath);
foreach ($files as $file) {
if ($file === '.' || $file === '..') {
continue;
}
$filePath = $assetsPath.'/'.$file;
if (is_file($filePath)) {
$fileName = $subDir.'/'.$file;
$fileSize = number_format(filesize($filePath) / 1024, 2);
$this->line(" - {$fileName} ({$fileSize} KB)");
}
}
}
/**
* npm 명령 실행
*
* @param array $command 실행할 명령
* @param string $cwd 작업 디렉토리
* @param bool $waitForCompletion 완료 대기 여부
* @return int 명령 실행 결과 코드
*/
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true): int
{
// Windows 환경에서는 cmd /c 사용
if (PHP_OS_FAMILY === 'Windows') {
$command = array_merge(['cmd', '/c'], $command);
}
$process = new Process($command);
$process->setWorkingDirectory($cwd);
$process->setTimeout(null); // 타임아웃 없음
if ($waitForCompletion) {
$process->run(function ($type, $buffer) {
// 출력 표시
if ($type === Process::ERR) {
// stderr이지만 npm은 정상 출력도 stderr로 보내므로 그냥 표시
$this->output->write($buffer);
} else {
$this->output->write($buffer);
}
});
return $process->isSuccessful() ? Command::SUCCESS : Command::FAILURE;
}
// 감시 모드: 인터럽트까지 실행
$process->start(function ($type, $buffer) {
$this->output->write($buffer);
});
// 프로세스가 실행 중인 동안 대기
while ($process->isRunning()) {
usleep(100000); // 100ms
}
return Command::SUCCESS;
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Console\Commands\Core;
use App\Services\CoreUpdateService;
use Illuminate\Console\Command;
class CheckCoreUpdatesCommand extends Command
{
protected $signature = 'core:check-updates';
protected $description = '그누보드7 코어의 최신 업데이트를 확인합니다';
/**
* 커맨드를 실행합니다.
*
* @param CoreUpdateService $service 코어 업데이트 서비스
* @return int 종료 코드
*/
public function handle(CoreUpdateService $service): int
{
$this->info('코어 업데이트를 확인 중...');
$result = $service->checkForUpdates();
$this->newLine();
$this->info("현재 버전: {$result['current_version']}");
$this->info("최신 버전: {$result['latest_version']}");
if (! empty($result['check_failed'])) {
$this->newLine();
$this->error('업데이트 확인 실패: '.($result['error'] ?? '알 수 없는 오류'));
return Command::FAILURE;
}
if ($result['update_available']) {
$this->newLine();
$this->warn('새로운 업데이트가 있습니다!');
$this->info('업데이트하려면: php artisan core:update');
} else {
$this->newLine();
$this->info('현재 최신 버전입니다.');
}
return Command::SUCCESS;
}
}
@@ -0,0 +1,414 @@
<?php
namespace App\Console\Commands\Core;
use App\Extension\CoreVersionChecker;
use App\Extension\Helpers\CoreBackupHelper;
use App\Services\CoreUpdateService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class CoreUpdateCommand extends Command
{
protected $signature = 'core:update
{--force : 버전 비교 없이 강제 업데이트}
{--no-backup : 백업 생성 건너뛰기}
{--no-maintenance : 유지보수 모드 활성화 건너뛰기}
{--local : 로컬 코드베이스를 업데이트 소스로 사용 (GitHub 스킵)}
{--source= : 수동 업데이트용 소스 디렉토리 경로 (GitHub 다운로드 대신 지정 디렉토리 사용)}';
protected $description = '그누보드7 코어를 최신 버전으로 업데이트합니다';
private const TOTAL_STEPS = 11;
/**
* 커맨드를 실행합니다.
*
* @param CoreUpdateService $service 코어 업데이트 서비스
* @return int 종료 코드
*/
public function handle(CoreUpdateService $service): int
{
$backupPath = null;
$maintenanceEnabled = false;
$fromVersion = CoreVersionChecker::getCoreVersion();
$toVersion = $fromVersion;
$secret = null;
$logEntries = [];
$log = function (string $message) use (&$logEntries) {
$logEntries[] = '['.date('H:i:s').'] '.$message;
};
$sourceDir = $this->option('source');
try {
// ── 시스템 요구사항 검증 (--source / --local 모드에서는 스킵) ──
if (! $sourceDir && ! $this->option('local')) {
$requirements = $service->checkSystemRequirements();
if (! $requirements['valid']) {
$this->error(__('settings.core_update.system_requirements_failed'));
foreach ($requirements['errors'] as $error) {
$this->error(" - {$error}");
}
$this->newLine();
$this->info(__('settings.core_update.manual_update_guide'));
return Command::FAILURE;
}
$log('사용 가능한 추출 방법: '.implode(', ', $requirements['available_methods']));
}
// --source 옵션 검증
if ($sourceDir) {
$sourceDir = realpath($sourceDir);
if (! $sourceDir || ! is_dir($sourceDir)) {
$this->error('지정된 소스 디렉토리가 존재하지 않습니다: '.($this->option('source')));
return Command::FAILURE;
}
$log("수동 업데이트 모드: 소스 디렉토리 = {$sourceDir}");
}
// ── Step 1: 업데이트 확인 (프로그레스바 없이) ──
$this->info(__('settings.core_update.step_check').'...');
$log('업데이트 확인 시작');
if ($sourceDir || $this->option('local')) {
// --source / --local 모드: GitHub 스킵, 소스의 버전 읽기 또는 시뮬레이션
if ($sourceDir) {
$sourceConfigPath = $sourceDir.DIRECTORY_SEPARATOR.'config'.DIRECTORY_SEPARATOR.'app.php';
if (file_exists($sourceConfigPath)) {
// env() 호출을 우회하여 소스의 default 버전값을 직접 파싱
$configContent = file_get_contents($sourceConfigPath);
if (preg_match("/['\"]version['\"]\s*=>\s*env\s*\(\s*['\"]APP_VERSION['\"]\s*,\s*['\"]([^'\"]+)['\"]\s*\)/", $configContent, $versionMatch)) {
$toVersion = $versionMatch[1];
} else {
$sourceConfig = include $sourceConfigPath;
$toVersion = $sourceConfig['version'] ?? $fromVersion;
}
}
$log("수동 업데이트 모드: {$fromVersion} → {$toVersion}");
} else {
$parts = explode('.', $fromVersion);
$parts[count($parts) - 1] = (int) end($parts) + 1;
$toVersion = implode('.', $parts);
$log("로컬 모드: {$fromVersion} → {$toVersion} 시뮬레이션");
}
} else {
$updateInfo = $service->checkForUpdates();
$toVersion = $updateInfo['latest_version'];
if (! empty($updateInfo['check_failed'])) {
$this->error('업데이트 확인 실패: '.($updateInfo['error'] ?? __('settings.core_update.unknown_error')));
return Command::FAILURE;
}
if (! $updateInfo['update_available'] && ! $this->option('force')) {
$this->info("현재 최신 버전입니다: {$fromVersion}");
return Command::SUCCESS;
}
}
// 사용자 확인
$this->newLine();
$this->info("현재 버전: {$fromVersion}");
$this->info("업데이트 버전: {$toVersion}");
$this->newLine();
if (! $this->confirm('코어를 업데이트하시겠습니까?')) {
return Command::SUCCESS;
}
// Step 2~10 프로그레스바 (9단계)
$remainingSteps = self::TOTAL_STEPS - 1;
$bar = $this->output->createProgressBar($remainingSteps);
$bar->setFormat(' %current%/%max% [%bar%] %message%');
$bar->start();
$onProgress = function (?string $step, ?string $detail) use ($bar) {
if ($detail) {
$bar->setMessage($detail);
}
$bar->display();
};
// ── Step 2: _pending 경로 검증 ──
$bar->setMessage(__('settings.core_update.step_validate_pending'));
$bar->advance();
$log('_pending 경로 검증');
$validation = $service->validatePendingPath();
if (! $validation['valid']) {
$bar->finish();
$this->newLine(2);
$this->error('_pending 디렉토리 문제:');
foreach ($validation['errors'] as $error) {
$this->error(" - {$error}");
}
$this->info("경로: {$validation['path']}");
$this->info("소유자: {$validation['owner']}, 그룹: {$validation['group']}, 퍼미션: {$validation['permissions']}");
return Command::FAILURE;
}
// ── Step 3: Maintenance 모드 ──
$bar->setMessage(__('settings.core_update.step_maintenance'));
$bar->advance();
if (! $this->option('no-maintenance')) {
$secret = $service->enableMaintenanceMode();
$maintenanceEnabled = true;
$log("유지보수 모드 활성화 (secret: {$secret})");
}
// ── Step 4: 다운로드 ──
if ($sourceDir) {
$bar->setMessage('소스 디렉토리 검증 중...');
} elseif ($this->option('local')) {
$bar->setMessage('로컬 소스 복제 중...');
} else {
$bar->setMessage(__('settings.core_update.step_download'));
}
$bar->advance();
if ($sourceDir) {
$log('수동 업데이트: 소스 디렉토리 검증');
$service->validatePendingUpdate($sourceDir);
// 원본 소스를 _pending으로 복제 (원본 보호)
$pendingPath = $service->copySourceToPending($sourceDir, $onProgress);
$log("소스 디렉토리를 _pending으로 복제 완료: {$pendingPath}");
} elseif ($this->option('local')) {
$log('로컬 소스 복제 시작');
$pendingPath = $service->prepareLocalSource($onProgress);
$log('로컬 소스 복제 완료');
} else {
$log("버전 {$toVersion} 다운로드 시작");
$pendingPath = $service->downloadUpdate($toVersion, $onProgress);
$log('다운로드 및 검증 완료');
}
// ── Step 5: 백업 ──
$bar->setMessage(__('settings.core_update.step_backup'));
$bar->advance();
if (! $this->option('no-backup')) {
$backupPath = $service->createBackup($onProgress);
$log("백업 생성 완료: {$backupPath}");
}
// ── Step 6: _pending에서 Composer Install ──
$composerSkipped = $service->isComposerUnchangedForCore($pendingPath);
if ($composerSkipped) {
$bar->setMessage('Composer 의존성 변경 없음 — 스킵');
$bar->advance();
$log('composer.json/lock 변경 없음, composer install 스킵');
} else {
$bar->setMessage(__('settings.core_update.step_composer'));
$bar->advance();
$log('_pending에서 composer install 시작');
$service->runComposerInstallInPending($pendingPath, $onProgress);
$log('_pending에서 composer install 완료');
}
// ── Step 7: 파일 적용 ──
$bar->setMessage(__('settings.core_update.step_apply'));
$bar->advance();
$log('코어 파일 덮어쓰기 시작');
$service->applyUpdate($pendingPath, $onProgress);
$log('코어 파일 덮어쓰기 완료');
// ── Step 8: vendor 디렉토리 복사 (_pending → 운영) ──
if ($composerSkipped) {
$bar->setMessage('vendor 복사 스킵');
$bar->advance();
$log('composer 스킵 → vendor 디렉토리 복사 불필요');
} else {
$bar->setMessage(__('settings.core_update.step_composer_prod'));
$bar->advance();
$log('vendor 디렉토리 복사 시작');
$service->copyVendorFromPending($pendingPath, $onProgress);
$log('vendor 디렉토리 복사 완료');
}
// ── Step 9: Migration + 역할/메뉴 동기화 ──
$bar->setMessage(__('settings.core_update.step_migration'));
$bar->advance();
$log('마이그레이션, 역할/메뉴/메일템플릿 동기화 실행');
$service->runMigrations();
$service->syncCoreRolesAndPermissions();
$service->syncCoreMenus();
$service->syncCoreMailTemplates();
$log('마이그레이션, 역할/메뉴/메일템플릿 동기화 완료');
// ── Step 10: Upgrade Steps ──
$bar->setMessage(__('settings.core_update.step_upgrade'));
$bar->advance();
$log('업그레이드 스텝 실행');
$service->runUpgradeSteps($fromVersion, $toVersion, function (string $version) use ($bar, $log) {
$bar->setMessage(__('settings.core_update.step_upgrade')." ({$version})");
$bar->display();
$log("업그레이드 스텝 실행: {$version}");
});
$log('업그레이드 스텝 완료');
// ── Step 11: Cleanup ──
$bar->setMessage(__('settings.core_update.step_cleanup'));
$bar->advance();
$service->updateVersionInEnv($toVersion);
$service->clearAllCaches();
$service->cleanupPending($pendingPath);
if ($backupPath) {
CoreBackupHelper::deleteBackup($backupPath);
}
if ($maintenanceEnabled) {
$service->disableMaintenanceMode();
$maintenanceEnabled = false;
}
$log('정리 완료');
$bar->finish();
$this->newLine(2);
// 설치 로그 저장
$this->saveUpdateLog($logEntries, $fromVersion, $toVersion, true);
$this->info("그누보드7 코어가 {$toVersion} 버전으로 업데이트되었습니다!");
$this->newLine();
$this->warn('_bundled 확장이 업데이트되었습니다. 활성 확장에 반영하려면 다음 커맨드를 실행하세요:');
$this->line(' php artisan module:update <identifier> --force');
$this->line(' php artisan plugin:update <identifier> --force');
$this->line(' php artisan template:update <identifier> --force');
return Command::SUCCESS;
} catch (\Throwable $e) {
$bar->finish();
$this->newLine(2);
$log("오류 발생: {$e->getMessage()}");
// 롤백
$restoreSuccess = false;
$failedTargets = [];
if ($backupPath) {
$this->warn('백업에서 복원 중...');
$log('백업 복원 시작');
try {
$failedTargets = $service->restoreFromBackup($backupPath, $onProgress);
if (empty($failedTargets)) {
$log('백업 복원 완료');
$this->info('백업에서 복원되었습니다.');
$restoreSuccess = true;
} else {
$log('백업 부분 복원 완료 (실패: '.implode(', ', $failedTargets).')');
$this->warn('백업에서 부분 복원되었습니다.');
$this->error('복원 실패 항목: '.implode(', ', $failedTargets));
}
} catch (\Throwable $restoreError) {
$log("백업 복원 실패: {$restoreError->getMessage()}");
$this->error("백업 복원 실패: {$restoreError->getMessage()}");
}
}
// _pending 정리 (실패 시에도)
if (! empty($pendingPath)) {
$service->cleanupPending($pendingPath);
}
// 실패 리포트
$reportPath = $service->generateFailureReport($e, $fromVersion, $toVersion);
// 설치 로그 저장
$this->saveUpdateLog($logEntries, $fromVersion, $toVersion, false);
$this->error("코어 업데이트 실패: {$e->getMessage()}");
$this->info("실패 리포트: {$reportPath}");
if ($maintenanceEnabled) {
$this->newLine();
if ($restoreSuccess) {
// 완전 복원 성공 → 자동 유지보수 해제
try {
$service->disableMaintenanceMode();
$maintenanceEnabled = false;
$this->info('복원 완료: 유지보수 모드가 해제되었습니다.');
} catch (\Throwable) {
$this->warn('유지보수 모드 해제 실패. 수동으로 해제하세요: php artisan up');
}
} else {
// 복원 실패 또는 부분 복원 → 수동 복구 안내
$this->warn('유지보수 모드가 유지됩니다.');
if (! empty($failedTargets) || ! $backupPath) {
$this->newLine();
$this->error('수동 복구가 필요합니다:');
if ($backupPath) {
$this->line(" 1. 백업에서 수동 복원: cp -r {$backupPath}/* ".base_path().'/');
}
$this->line(' '.($backupPath ? '2' : '1').'. Composer 재설치: composer install --no-dev --optimize-autoloader');
$this->line(' '.($backupPath ? '3' : '2').'. 유지보수 해제: php artisan up');
} else {
$this->info('이전 버전으로 사이트를 운영하려면: php artisan up');
}
if ($secret) {
$this->info("관리자 접근: {$secret}");
}
}
}
return Command::FAILURE;
}
}
/**
* 업데이트 로그를 파일로 저장합니다.
*
* @param array $entries 로그 엔트리 목록
* @param string $fromVersion 시작 버전
* @param string $toVersion 종료 버전
* @param bool $success 성공 여부
*/
private function saveUpdateLog(array $entries, string $fromVersion, string $toVersion, bool $success): void
{
$timestamp = date('Ymd_His');
$status = $success ? 'success' : 'failed';
$logPath = storage_path("logs/core_update_{$status}_{$timestamp}.log");
$header = implode("\n", [
'=== 그누보드7 코어 업데이트 로그 ===',
'상태: '.($success ? '성공' : '실패'),
'날짜: '.date('Y-m-d H:i:s'),
"시작 버전: {$fromVersion}",
"대상 버전: {$toVersion}",
'',
'=== 실행 로그 ===',
]);
$content = $header."\n".implode("\n", $entries)."\n";
file_put_contents($logPath, $content);
Log::info("코어 업데이트 로그 저장: {$logPath}");
}
}
@@ -0,0 +1,43 @@
<?php
namespace App\Console\Commands\Extension;
use App\Extension\CoreVersionChecker;
use Illuminate\Console\Command;
/**
* 확장 버전 검증 캐시 삭제 커맨드
*
* 확장(모듈, 플러그인, 템플릿)의 코어 버전 호환성 검증 캐시를 삭제합니다.
* 코어 버전 업데이트 후 또는 수동으로 버전 검증을 다시 수행해야 할 때 사용합니다.
*/
class ClearVersionCacheCommand extends Command
{
/**
* 커맨드 시그니처
*
* @var string
*/
protected $signature = 'extension:clear-version-cache';
/**
* 커맨드 설명
*
* @var string
*/
protected $description = '확장 버전 검증 캐시를 삭제합니다';
/**
* 커맨드 실행
*
* @return int 실행 결과 코드
*/
public function handle(): int
{
CoreVersionChecker::clearCache();
$this->info(__('extensions.commands.clear_cache_success'));
return Command::SUCCESS;
}
}
@@ -0,0 +1,57 @@
<?php
namespace App\Console\Commands\Extension;
use Illuminate\Console\Command;
class ComposerInstallAllCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'extension:composer-install
{--no-dev : dev 의존성 제외}';
/**
* The console command description.
*/
protected $description = '모든 모듈과 플러그인의 Composer 의존성을 설치합니다';
/**
* Execute the console command.
*/
public function handle(): int
{
$noDev = $this->option('no-dev');
$noDevOption = $noDev ? ' --no-dev' : '';
$this->info('📦 모든 확장의 Composer 의존성 설치 시작');
$this->line('');
// 모듈 Composer 설치
$this->info('=== 모듈 ===');
$moduleResult = $this->call('module:composer-install', [
'--all' => true,
'--no-dev' => $noDev,
]);
$this->line('');
// 플러그인 Composer 설치
$this->info('=== 플러그인 ===');
$pluginResult = $this->call('plugin:composer-install', [
'--all' => true,
'--no-dev' => $noDev,
]);
$this->line('');
if ($moduleResult === Command::SUCCESS && $pluginResult === Command::SUCCESS) {
$this->info('✅ 모든 확장의 Composer 의존성 설치 완료');
return Command::SUCCESS;
}
$this->warn('⚠️ 일부 확장의 Composer 의존성 설치에 실패했습니다.');
return Command::FAILURE;
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Console\Commands\Extension;
use App\Extension\ExtensionManager;
use Illuminate\Console\Command;
class UpdateAutoloadCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'extension:update-autoload';
/**
* The console command description.
*
* @var string
*/
protected $description = '모듈과 플러그인의 오토로드 캐시 파일을 생성합니다 (bootstrap/cache/autoload-extensions.php)';
/**
* Execute the console command.
*/
public function handle(ExtensionManager $extensionManager): int
{
$this->info('확장 오토로드 파일을 생성합니다...');
try {
$extensionManager->generateAutoloadFile();
$this->info('오토로드 파일이 성공적으로 생성되었습니다.');
$this->line(' → bootstrap/cache/autoload-extensions.php');
return Command::SUCCESS;
} catch (\Exception $e) {
$this->error('오토로드 파일 생성 중 오류가 발생했습니다: '.$e->getMessage());
return Command::FAILURE;
}
}
}
@@ -0,0 +1,101 @@
<?php
namespace App\Console\Commands;
use App\Extension\Traits\ValidatesLayoutFiles;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
/**
* 레이아웃 테스트 fixtures 생성 커맨드
*
* 레이아웃 JSON 파일을 로드하고 partial을 병합하여
* 테스트용 fixture 파일로 저장합니다.
*/
class GenerateLayoutTestFixture extends Command
{
use ValidatesLayoutFiles;
/**
* 커맨드 시그니처
*
* @var string
*/
protected $signature = 'layout:generate-fixture
{layout : 레이아웃 파일 경로 (modules/sirsoft-ecommerce/resources/layouts/admin/admin_ecommerce_promotion_coupon_list.json)}
{--output= : 출력 파일명 (기본: 레이아웃 이름)}
{--dir= : 출력 디렉토리 (기본: resources/js/core/template-engine/__tests__/fixtures)}';
/**
* 커맨드 설명
*
* @var string
*/
protected $description = '레이아웃 JSON을 partial 병합하여 테스트 fixture로 저장합니다';
/**
* 커맨드 실행
*/
public function handle(): int
{
$layoutPath = $this->argument('layout');
// 절대 경로로 변환
if (! str_starts_with($layoutPath, '/') && ! preg_match('/^[A-Z]:/', $layoutPath)) {
$layoutPath = base_path($layoutPath);
}
// 파일 존재 확인
if (! File::exists($layoutPath)) {
$this->error("레이아웃 파일을 찾을 수 없습니다: {$layoutPath}");
return self::FAILURE;
}
$this->info("레이아웃 로드 중: {$layoutPath}");
// JSON 로드
$jsonContent = File::get($layoutPath);
$layoutData = json_decode($jsonContent, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->error('JSON 파싱 실패: ' . json_last_error_msg());
return self::FAILURE;
}
// Partial 병합
$basePath = dirname($layoutPath);
$dataSources = $layoutData['data_sources'] ?? [];
try {
$mergedLayout = $this->resolveAllPartials($layoutData, $basePath, 0, $dataSources);
} catch (\Exception $e) {
$this->error('Partial 병합 실패: ' . $e->getMessage());
return self::FAILURE;
}
// 출력 디렉토리 결정
$outputDir = $this->option('dir')
?: base_path('resources/js/core/template-engine/__tests__/fixtures');
// 디렉토리 생성
if (! File::exists($outputDir)) {
File::makeDirectory($outputDir, 0755, true);
$this->info("디렉토리 생성: {$outputDir}");
}
// 출력 파일명 결정
$outputName = $this->option('output')
?: ($mergedLayout['layout_name'] ?? pathinfo($layoutPath, PATHINFO_FILENAME));
$outputPath = $outputDir . '/' . $outputName . '.json';
// JSON 저장
$jsonOutput = json_encode($mergedLayout, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
File::put($outputPath, $jsonOutput);
$this->info("Fixture 생성 완료: {$outputPath}");
$this->info("파일 크기: " . number_format(strlen($jsonOutput)) . " bytes");
return self::SUCCESS;
}
}
@@ -0,0 +1,136 @@
<?php
namespace App\Console\Commands;
use App\Contracts\Repositories\ConfigRepositoryInterface;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
/**
* 환경설정 설치/초기화 커맨드
*
* 그누보드7 설치 시 또는 설정 초기화가 필요할 때 사용합니다.
* config/settings/defaults.json의 기본값을 storage/app/settings/에 복사합니다.
*/
class InstallSettingsCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'settings:install
{--force : 기존 설정 파일을 덮어씁니다}
{--merge : 기존 설정과 병합합니다 (새 키만 추가)}';
/**
* The console command description.
*
* @var string
*/
protected $description = '환경설정 기본값을 설치합니다 (config/settings/defaults.json → storage/app/settings/)';
public function __construct(
private ConfigRepositoryInterface $configRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$force = $this->option('force');
$merge = $this->option('merge');
$defaultsPath = config_path('settings/defaults.json');
// defaults.json 파일 존재 확인
if (! File::exists($defaultsPath)) {
$this->error("기본 설정 파일을 찾을 수 없습니다: {$defaultsPath}");
$this->error('config/settings/defaults.json 파일이 존재하는지 확인하세요.');
return Command::FAILURE;
}
// defaults.json 로드
$content = File::get($defaultsPath);
$data = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$this->error('기본 설정 파일 JSON 파싱 실패: '.json_last_error_msg());
return Command::FAILURE;
}
$defaults = $data['defaults'] ?? [];
$categories = $data['_meta']['categories'] ?? [];
if (empty($defaults)) {
$this->error('기본 설정 파일에 defaults 섹션이 없습니다.');
return Command::FAILURE;
}
$this->info('환경설정 설치를 시작합니다...');
$this->newLine();
$installedCount = 0;
$skippedCount = 0;
$mergedCount = 0;
foreach ($categories as $category) {
$categoryDefaults = $defaults[$category] ?? [];
if (empty($categoryDefaults)) {
$this->warn(" [{$category}] 기본값이 없습니다. 건너뜁니다.");
continue;
}
$settingsPath = storage_path("app/settings/{$category}.json");
$exists = File::exists($settingsPath);
if ($exists && ! $force && ! $merge) {
$this->line(" <comment>[{$category}]</comment> 이미 존재합니다. 건너뜁니다. (--force 또는 --merge 옵션 사용)");
$skippedCount++;
continue;
}
if ($exists && $merge) {
// 병합 모드: 기존 설정과 병합 (기존 값 유지, 새 키만 추가)
$existingSettings = $this->configRepository->getCategory($category);
$mergedSettings = array_merge($categoryDefaults, $existingSettings);
if ($this->configRepository->saveCategory($category, $mergedSettings)) {
$newKeysCount = count(array_diff_key($categoryDefaults, $existingSettings));
$this->info(" <info>[{$category}]</info> 병합 완료 ({$newKeysCount}개 새 키 추가)");
$mergedCount++;
} else {
$this->error(" [{$category}] 병합 실패");
}
continue;
}
// 새로 설치 또는 덮어쓰기
if ($this->configRepository->saveCategory($category, $categoryDefaults)) {
$action = $exists ? '덮어씀' : '설치됨';
$this->info(" <info>[{$category}]</info> {$action}");
$installedCount++;
} else {
$this->error(" [{$category}] 설치 실패");
}
}
$this->newLine();
$this->info('환경설정 설치 완료!');
$this->line(" - 설치됨: {$installedCount}개");
$this->line(" - 병합됨: {$mergedCount}개");
$this->line(" - 건너뜀: {$skippedCount}개");
return Command::SUCCESS;
}
}
@@ -0,0 +1,214 @@
<?php
namespace App\Console\Commands;
use App\Models\SystemConfig;
use App\Repositories\JsonConfigRepository;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Schema;
/**
* 기존 DB 설정을 JSON 파일로 마이그레이션하는 커맨드
*/
class MigrateSettingsToJsonCommand extends Command
{
/**
* 커맨드 시그니처
*
* @var string
*/
protected $signature = 'settings:migrate-to-json
{--force : 기존 JSON 파일이 있어도 덮어쓰기}';
/**
* 커맨드 설명
*
* @var string
*/
protected $description = 'DB의 system_configs 테이블 데이터를 JSON 파일로 마이그레이션합니다.';
/**
* 설정 키와 카테고리 매핑
*
* @var array<string, string>
*/
private array $categoryMap = [
// general
'site_name' => 'general',
'site_url' => 'general',
'site_description' => 'general',
'admin_email' => 'general',
'timezone' => 'general',
'language' => 'general',
'currency' => 'general',
'maintenance_mode' => 'general',
'description' => 'general',
'keywords' => 'general',
// security
'force_https' => 'security',
'login_attempt_enabled' => 'security',
'auth_token_lifetime' => 'security',
'max_login_attempts' => 'security',
'login_lockout_time' => 'security',
'two_factor_auth' => 'security',
'password_min_length' => 'security',
'require_password_special_char' => 'security',
// mail
'mailer' => 'mail',
'mail_host' => 'mail',
'mail_port' => 'mail',
'mail_username' => 'mail',
'mail_password' => 'mail',
'mail_encryption' => 'mail',
'mail_from_address' => 'mail',
'mail_from_name' => 'mail',
// upload
'max_file_size' => 'upload',
'allowed_extensions' => 'upload',
'image_max_width' => 'upload',
'image_max_height' => 'upload',
'image_quality' => 'upload',
// seo
'meta_title_suffix' => 'seo',
'meta_description' => 'seo',
'meta_keywords' => 'seo',
'google_analytics_id' => 'seo',
'google_site_verification' => 'seo',
'naver_site_verification' => 'seo',
// cache
'cache_enabled' => 'cache',
'layout_cache_enabled' => 'cache',
'layout_cache_ttl' => 'cache',
'stats_cache_enabled' => 'cache',
'stats_cache_ttl' => 'cache',
'seo_cache_enabled' => 'cache',
'seo_cache_ttl' => 'cache',
// debug
'debug_mode' => 'debug',
'sql_query_log' => 'debug',
'log_level' => 'debug',
];
/**
* 키 이름 변환 매핑 (DB 키 -> JSON 키)
*
* @var array<string, string>
*/
private array $keyMapping = [
'mail_host' => 'host',
'mail_port' => 'port',
'mail_username' => 'username',
'mail_password' => 'password',
'mail_encryption' => 'encryption',
'mail_from_address' => 'from_address',
'mail_from_name' => 'from_name',
'cache_enabled' => 'enabled',
'layout_cache_enabled' => 'layout_enabled',
'layout_cache_ttl' => 'layout_ttl',
'stats_cache_enabled' => 'stats_enabled',
'stats_cache_ttl' => 'stats_ttl',
'seo_cache_enabled' => 'seo_enabled',
'seo_cache_ttl' => 'seo_ttl',
'debug_mode' => 'mode',
];
/**
* 커맨드를 실행합니다.
*
* DI 컨테이너 바인딩 시점 문제로 직접 인스턴스화합니다.
*
* @return int
*/
public function handle(): int
{
$this->info('설정 마이그레이션을 시작합니다...');
// system_configs 테이블 존재 확인
if (! Schema::hasTable('system_configs')) {
$this->warn('system_configs 테이블이 존재하지 않습니다. 기본값으로 초기화합니다.');
$configRepository = new JsonConfigRepository();
$configRepository->initialize();
$this->info('기본 설정 파일이 생성되었습니다.');
return Command::SUCCESS;
}
// JsonConfigRepository 직접 인스턴스화 (DI 컨테이너 바인딩 전 실행 가능)
$configRepository = new JsonConfigRepository();
// 기존 JSON 파일 존재 확인
if (file_exists(storage_path('app/settings/general.json')) && ! $this->option('force')) {
if (! $this->confirm('기존 JSON 설정 파일이 존재합니다. 덮어쓰시겠습니까?')) {
$this->info('마이그레이션이 취소되었습니다.');
return Command::SUCCESS;
}
}
// 기존 설정 조회
$configs = SystemConfig::all();
if ($configs->isEmpty()) {
$this->warn('마이그레이션할 설정이 없습니다. 기본값으로 초기화합니다.');
$configRepository->initialize();
$this->info('기본 설정 파일이 생성되었습니다.');
return Command::SUCCESS;
}
$this->info("총 {$configs->count()}개의 설정을 발견했습니다.");
// 카테고리별로 그룹화
$grouped = [];
foreach ($configs as $config) {
$category = $this->categoryMap[$config->key] ?? 'general';
$jsonKey = $this->keyMapping[$config->key] ?? $config->key;
$grouped[$category][$jsonKey] = $this->parseValue($config->value, $config->type);
}
// JSON 파일로 저장
$defaults = $configRepository->getDefaults();
foreach ($defaults as $category => $defaultSettings) {
$categorySettings = array_merge(
$defaultSettings,
$grouped[$category] ?? []
);
$configRepository->saveCategory($category, $categorySettings);
$this->info(" ✓ {$category} 카테고리 저장 완료");
}
$this->newLine();
$this->info('마이그레이션이 완료되었습니다!');
$this->info('설정 파일 위치: storage/app/settings/');
return Command::SUCCESS;
}
/**
* 문자열 값을 원래 타입으로 파싱합니다.
*
* @param string $value
* @param string $type
* @return mixed
*/
private function parseValue(string $value, string $type): mixed
{
return match ($type) {
'boolean' => filter_var($value, FILTER_VALIDATE_BOOLEAN),
'integer' => (int) $value,
'float' => (float) $value,
'json', 'array' => json_decode($value, true) ?: [],
default => $value,
};
}
}
@@ -0,0 +1,109 @@
<?php
namespace App\Console\Commands\Module;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Enums\ExtensionStatus;
use App\Extension\ModuleManager;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class ActivateModuleCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'module:activate {identifier : 활성화할 모듈 식별자} {--force : 의존성 미충족 시 강제 활성화}';
/**
* The console command description.
*/
protected $description = '모듈을 활성화합니다';
/**
* 모듈 관리자 및 리포지토리
*/
public function __construct(
private ModuleManager $moduleManager,
private ModuleRepositoryInterface $moduleRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$identifier = $this->argument('identifier');
try {
// 모듈 디렉토리 스캔 및 로드
$this->moduleManager->loadModules();
// 모듈이 설치되어 있는지 확인
$moduleRecord = $this->moduleRepository->findByIdentifier($identifier);
if (! $moduleRecord) {
$this->error('❌ '.__('modules.commands.activate.not_installed', ['module' => $identifier]));
return Command::FAILURE;
}
// 이미 활성화된 모듈인지 확인
if ($moduleRecord->status === ExtensionStatus::Active->value) {
$this->warn('⚠️ '.__('modules.commands.activate.already_active', ['module' => $identifier]));
return Command::FAILURE;
}
// 모듈 활성화
$force = $this->option('force');
$result = $this->moduleManager->activateModule($identifier, $force);
// 경고 응답인 경우 (의존성 미충족)
if (isset($result['warning']) && $result['warning'] === true) {
$this->warn('⚠️ '.$result['message']);
$this->line('');
if (! empty($result['missing_modules'])) {
$this->info('필요한 모듈:');
foreach ($result['missing_modules'] as $module) {
$statusLabel = $module['status'] === 'not_installed' ? '미설치' : '비활성';
$this->line(" - {$module['identifier']} ({$module['name']}) [{$statusLabel}]");
}
}
if (! empty($result['missing_plugins'])) {
$this->info('필요한 플러그인:');
foreach ($result['missing_plugins'] as $plugin) {
$statusLabel = $plugin['status'] === 'not_installed' ? '미설치' : '비활성';
$this->line(" - {$plugin['identifier']} ({$plugin['name']}) [{$statusLabel}]");
}
}
$this->line('');
$this->info('강제로 활성화하려면 --force 옵션을 사용하세요.');
return Command::FAILURE;
}
if ($result['success']) {
$this->info('✅ '.__('modules.commands.activate.success', ['module' => $identifier]));
$this->info(' - '.__('modules.commands.activate.layouts_registered', ['count' => $result['layouts_registered']]));
Log::info(__('modules.commands.activate.success', ['module' => $identifier]));
return Command::SUCCESS;
}
return Command::FAILURE;
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
Log::error('모듈 활성화 실패', [
'module' => $identifier,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -0,0 +1,370 @@
<?php
namespace App\Console\Commands\Module;
use App\Extension\ModuleManager;
use App\Extension\Traits\ClearsTemplateCaches;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Process\Process;
class BuildModuleCommand extends Command
{
use ClearsTemplateCaches;
/**
* The name and signature of the console command.
*/
protected $signature = 'module:build
{identifier? : 빌드할 모듈 식별자 (생략 시 --all 필요)}
{--all : 모든 모듈 빌드}
{--watch : 파일 변경 감시 모드}
{--production : 프로덕션 빌드}
{--active : 활성 디렉토리에서 빌드}';
/**
* The console command description.
*/
protected $description = '모듈의 프론트엔드 에셋을 빌드합니다 (기본: _bundled 디렉토리)';
/**
* 모듈 관리자
*/
public function __construct(
private ModuleManager $moduleManager
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$identifier = $this->argument('identifier');
$buildAll = $this->option('all');
$watchMode = $this->option('watch');
$productionMode = $this->option('production');
if (! $identifier && ! $buildAll) {
$this->error('❌ 모듈 식별자를 지정하거나 --all 옵션을 사용하세요.');
$this->line('');
$this->line('사용법:');
$this->line(' php artisan module:build sirsoft-ecommerce');
$this->line(' php artisan module:build --all');
$this->line(' php artisan module:build sirsoft-ecommerce --watch');
$this->line(' php artisan module:build --all --production');
$this->line(' php artisan module:build sirsoft-ecommerce --active');
return Command::FAILURE;
}
try {
// 모듈 로드
$this->moduleManager->loadModules();
if ($buildAll) {
return $this->buildAllModules($productionMode);
}
return $this->buildModule($identifier, $watchMode, $productionMode);
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
Log::error('모듈 빌드 실패', [
'module' => $identifier ?? 'all',
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
/**
* 빌드 대상 경로를 결정합니다.
*
* 기본값: _bundled 디렉토리. --active 옵션 시 활성 디렉토리.
* --watch 모드에서는 활성 디렉토리를 사용합니다 (실시간 개발용).
*
* @param string $identifier 모듈 식별자
* @return array|null ['path' => string, 'source' => 'bundled'|'active'] 또는 null
*/
private function resolveBuildPath(string $identifier): ?array
{
$bundledPath = base_path("modules/_bundled/{$identifier}");
$activePath = base_path("modules/{$identifier}");
// --active 명시 → 활성 디렉토리
if ($this->option('active')) {
return is_dir($activePath)
? ['path' => $activePath, 'source' => 'active']
: null;
}
// --watch 모드 → 활성 디렉토리 (실시간 개발용)
if ($this->option('watch')) {
if (is_dir($activePath)) {
return ['path' => $activePath, 'source' => 'active'];
}
}
// 기본값: _bundled 우선
if (is_dir($bundledPath)) {
return ['path' => $bundledPath, 'source' => 'bundled'];
}
// _bundled에 없으면 활성 디렉토리 폴백
if (is_dir($activePath)) {
return ['path' => $activePath, 'source' => 'active'];
}
return null;
}
/**
* 단일 모듈 빌드
*
* @param string $identifier 모듈 식별자
* @param bool $watchMode 파일 감시 모드
* @param bool $productionMode 프로덕션 빌드
* @return int 명령 실행 결과 코드
*/
private function buildModule(string $identifier, bool $watchMode, bool $productionMode): int
{
// 경로 결정
$resolved = $this->resolveBuildPath($identifier);
if (! $resolved) {
$this->error("❌ 모듈을 찾을 수 없습니다: {$identifier}");
$this->line(' _bundled 및 활성 디렉토리 모두 존재하지 않습니다.');
return Command::FAILURE;
}
$buildPath = $resolved['path'];
$source = $resolved['source'];
// 소스 표시
$sourceLabel = $source === 'bundled' ? '_bundled' : '활성';
$this->info("📂 빌드 소스: {$sourceLabel} ({$buildPath})");
// package.json 존재 확인
$packageJsonPath = $buildPath.'/package.json';
if (! file_exists($packageJsonPath)) {
$this->error('❌ package.json 파일이 없습니다: '.$packageJsonPath);
$this->line(' 모듈 프론트엔드 구조를 먼저 생성하세요.');
return Command::FAILURE;
}
// 에셋 빌드 가능 여부 확인 (활성 모듈 인스턴스가 있는 경우)
$module = $this->moduleManager->getModule($identifier);
if ($module && ! $module->canBuild() && ! $module->hasAssets()) {
$this->warn('⚠️ 모듈에 빌드할 에셋이 없습니다: '.$identifier);
return Command::SUCCESS;
}
// node_modules 확인 및 설치
if (! is_dir($buildPath.'/node_modules')) {
$this->info('📦 의존성 설치 중...');
$installResult = $this->runNpmCommand(['npm', 'install'], $buildPath);
if ($installResult !== Command::SUCCESS) {
$this->error('❌ npm install 실패');
return Command::FAILURE;
}
}
// 빌드 명령 결정
$buildCommand = ['npm', 'run'];
if ($watchMode) {
$buildCommand[] = 'dev';
$this->info("👀 파일 감시 모드로 빌드 시작: {$identifier}");
$this->line(' Ctrl+C로 종료할 수 있습니다.');
} else {
$buildCommand[] = 'build';
$this->info("🔨 빌드 시작: {$identifier}".($productionMode ? ' (프로덕션)' : ''));
}
// 빌드 실행
$result = $this->runNpmCommand($buildCommand, $buildPath, ! $watchMode);
if ($result === Command::SUCCESS && ! $watchMode) {
$this->info("✅ 빌드 완료: {$identifier}");
// 빌드 결과 파일 확인
$this->displayBuildResults($buildPath, $identifier);
// 캐시 버전 증가 (브라우저 캐시 무효화)
$this->incrementExtensionCacheVersion();
$this->line(' - 캐시 버전 갱신됨');
// _bundled 빌드 시 활성 반영 안내
if ($source === 'bundled') {
$this->line('');
$this->info("💡 활성 디렉토리에 반영하려면: php artisan module:update {$identifier}");
}
}
return $result;
}
/**
* 빌드 결과 파일 정보를 출력합니다.
*
* @param string $buildPath 빌드된 경로
* @param string $identifier 모듈 식별자
*/
private function displayBuildResults(string $buildPath, string $identifier): void
{
// 활성 모듈 인스턴스가 있으면 getBuiltAssetPaths 활용
$module = $this->moduleManager->getModule($identifier);
if ($module) {
$builtPaths = $module->getBuiltAssetPaths();
if (! empty($builtPaths['js'])) {
$jsPath = $buildPath.'/'.$builtPaths['js'];
if (file_exists($jsPath)) {
$this->line(' - JS: '.$builtPaths['js'].' ('.number_format(filesize($jsPath) / 1024, 2).' KB)');
}
}
if (! empty($builtPaths['css'])) {
$cssPath = $buildPath.'/'.$builtPaths['css'];
if (file_exists($cssPath)) {
$this->line(' - CSS: '.$builtPaths['css'].' ('.number_format(filesize($cssPath) / 1024, 2).' KB)');
}
}
return;
}
// 활성 인스턴스 없으면 manifest에서 직접 확인
$manifestPath = $buildPath.'/module.json';
if (file_exists($manifestPath)) {
$manifest = json_decode(file_get_contents($manifestPath), true);
$assets = $manifest['assets'] ?? [];
if (! empty($assets['js']['output'])) {
$jsPath = $buildPath.'/'.$assets['js']['output'];
if (file_exists($jsPath)) {
$this->line(' - JS: '.$assets['js']['output'].' ('.number_format(filesize($jsPath) / 1024, 2).' KB)');
}
}
if (! empty($assets['css']['output'])) {
$cssPath = $buildPath.'/'.$assets['css']['output'];
if (file_exists($cssPath)) {
$this->line(' - CSS: '.$assets['css']['output'].' ('.number_format(filesize($cssPath) / 1024, 2).' KB)');
}
}
}
}
/**
* 모든 모듈 빌드
*
* @param bool $productionMode 프로덕션 빌드
* @return int 명령 실행 결과 코드
*/
private function buildAllModules(bool $productionMode): int
{
$buildTargets = [];
if ($this->option('active')) {
// --active: 활성 디렉토리만 대상
$activeModules = $this->moduleManager->getAllModules();
foreach ($activeModules as $identifier => $module) {
$activePath = base_path("modules/{$identifier}");
if (file_exists($activePath.'/package.json')
&& ($module->canBuild() || $module->hasAssets())) {
$buildTargets[$identifier] = true;
}
}
} else {
// 기본값: _bundled 디렉토리 스캔
$bundledModules = $this->moduleManager->getBundledModules();
foreach ($bundledModules as $identifier => $metadata) {
$bundledPath = $metadata['source_path'];
if (file_exists($bundledPath.'/package.json')) {
$assets = $metadata['assets'] ?? [];
if (! empty($assets['js']['entry']) || ! empty($assets['css']['entry']) || ! empty($assets)) {
$buildTargets[$identifier] = true;
}
}
}
}
if (empty($buildTargets)) {
$this->warn('⚠️ 빌드할 모듈이 없습니다.');
return Command::SUCCESS;
}
$sourceLabel = $this->option('active') ? '활성' : '_bundled';
$this->info("🔨 모든 모듈 빌드 시작 ({$sourceLabel})".($productionMode ? ' (프로덕션)' : ''));
$this->line(' 대상 모듈: '.implode(', ', array_keys($buildTargets)));
$this->line('');
$successCount = 0;
$failCount = 0;
foreach ($buildTargets as $identifier => $value) {
$this->line(" [{$identifier}]");
$result = $this->buildModule($identifier, false, $productionMode);
if ($result === Command::SUCCESS) {
$successCount++;
} else {
$failCount++;
}
$this->line('');
}
$this->info("📊 빌드 결과: 성공 {$successCount}개, 실패 {$failCount}개");
return $failCount > 0 ? Command::FAILURE : Command::SUCCESS;
}
/**
* npm 명령 실행
*
* @param array $command 실행할 명령
* @param string $cwd 작업 디렉토리
* @param bool $waitForCompletion 완료 대기 여부
* @return int 명령 실행 결과 코드
*/
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true): int
{
// Windows 환경에서는 cmd /c 사용
if (PHP_OS_FAMILY === 'Windows') {
$command = array_merge(['cmd', '/c'], $command);
}
$process = new Process($command);
$process->setWorkingDirectory($cwd);
$process->setTimeout(null); // 타임아웃 없음
if ($waitForCompletion) {
$process->run(function ($type, $buffer) {
$this->output->write($buffer);
});
return $process->isSuccessful() ? Command::SUCCESS : Command::FAILURE;
}
// 감시 모드: 인터럽트까지 실행
$process->start(function ($type, $buffer) {
$this->output->write($buffer);
});
// 프로세스가 실행 중인 동안 대기
while ($process->isRunning()) {
usleep(100000); // 100ms
}
return Command::SUCCESS;
}
}
@@ -0,0 +1,152 @@
<?php
namespace App\Console\Commands\Module;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Extension\ModuleManager;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class CheckModuleUpdatesCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'module:check-updates {identifier? : 특정 모듈만 확인 (선택)}';
/**
* The console command description.
*/
protected $description = '모듈 업데이트를 확인합니다';
/**
* 모듈 관리자 및 리포지토리
*/
public function __construct(
private ModuleManager $moduleManager,
private ModuleRepositoryInterface $moduleRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$identifier = $this->argument('identifier');
try {
$this->moduleManager->loadModules();
if ($identifier) {
return $this->checkSingle($identifier);
}
return $this->checkAll();
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
Log::error('모듈 업데이트 확인 실패', [
'module' => $identifier,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
/**
* 단일 모듈 업데이트를 확인합니다.
*
* @param string $identifier 모듈 식별자
*/
private function checkSingle(string $identifier): int
{
$module = $this->moduleRepository->findByIdentifier($identifier);
if (! $module) {
$this->error('❌ '.__('modules.commands.check_updates.not_installed', ['module' => $identifier]));
return Command::FAILURE;
}
$result = $this->moduleManager->checkModuleUpdate($identifier);
// 단일 체크는 DB 갱신이 안 되므로 커맨드에서 직접 갱신
$this->moduleRepository->updateByIdentifier($identifier, [
'update_available' => $result['update_available'],
'latest_version' => $result['latest_version'],
'update_source' => $result['update_source'],
]);
if ($result['update_available']) {
$this->info('🔄 '.__('modules.commands.check_updates.single_update_available', [
'module' => $identifier,
'current' => $result['current_version'],
'latest' => $result['latest_version'],
'source' => $result['update_source'],
]));
} else {
$this->info('✅ '.__('modules.commands.check_updates.single_up_to_date', [
'module' => $identifier,
'version' => $result['current_version'],
]));
}
return Command::SUCCESS;
}
/**
* 모든 설치된 모듈의 업데이트를 확인합니다.
*/
private function checkAll(): int
{
$installedModules = $this->moduleManager->getInstalledModulesWithDetails();
if (empty($installedModules)) {
$this->info(__('modules.commands.check_updates.no_installed'));
return Command::SUCCESS;
}
// checkAllModulesForUpdates는 내부에서 DB 갱신 포함
$result = $this->moduleManager->checkAllModulesForUpdates();
$tableData = [];
$updateCount = 0;
foreach ($result['details'] as $detail) {
$isUpdate = $detail['update_available'] ?? false;
if ($isUpdate) {
$updateCount++;
}
$tableData[] = [
$detail['identifier'],
$detail['current_version'] ?? '-',
$detail['latest_version'] ?? '-',
$detail['update_source'] ?? '-',
$isUpdate
? '🔄 '.__('modules.commands.check_updates.update_available')
: '✅ '.__('modules.commands.check_updates.up_to_date'),
];
}
$headers = [
__('modules.commands.check_updates.headers.identifier'),
__('modules.commands.check_updates.headers.current_version'),
__('modules.commands.check_updates.headers.latest_version'),
__('modules.commands.check_updates.headers.source'),
__('modules.commands.check_updates.headers.status'),
];
$this->table($headers, $tableData);
$this->newLine();
$this->info(__('modules.commands.check_updates.summary', [
'total' => count($result['details']),
'updates' => $updateCount,
]));
return Command::SUCCESS;
}
}
@@ -0,0 +1,132 @@
<?php
namespace App\Console\Commands\Module;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Extension\ModuleManager;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class ClearModuleCacheCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'module:cache-clear
{identifier? : 특정 모듈의 캐시만 삭제 (생략 시 모든 모듈)}';
/**
* The console command description.
*/
protected $description = '모듈 캐시를 삭제합니다';
/**
* 모듈 관리자 및 리포지토리
*/
public function __construct(
private ModuleManager $moduleManager,
private ModuleRepositoryInterface $moduleRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$identifier = $this->argument('identifier');
try {
// 모듈 디렉토리 스캔 및 로드
$this->moduleManager->loadModules();
$clearedCount = 0;
if ($identifier) {
// 특정 모듈 캐시 삭제
$this->info(__('modules.commands.cache_clear.clearing_single', ['module' => $identifier]));
// 모듈이 존재하는지 확인
$module = $this->moduleManager->getModule($identifier);
if (! $module) {
$this->error('❌ '.__('modules.not_found', ['module' => $identifier]));
return Command::FAILURE;
}
$clearedCount = $this->clearModuleCache($identifier);
$this->info('✅ '.__('modules.commands.cache_clear.success_single', [
'module' => $identifier,
'count' => $clearedCount,
]));
} else {
// 모든 모듈 캐시 삭제
$this->info(__('modules.commands.cache_clear.clearing_all'));
// 전체 모듈 캐시 키 삭제
$cacheKeys = [
'modules.all',
'modules.active',
'modules.installed',
];
foreach ($cacheKeys as $key) {
if (Cache::forget($key)) {
$clearedCount++;
}
}
// 각 모듈별 캐시 삭제
$allModules = $this->moduleManager->getAllModules();
foreach ($allModules as $moduleName => $module) {
$clearedCount += $this->clearModuleCache($module->getIdentifier());
}
$this->info('✅ '.__('modules.commands.cache_clear.success_all', ['count' => $clearedCount]));
}
Log::info('모듈 캐시 삭제 완료', [
'module' => $identifier ?? 'all',
'count' => $clearedCount,
]);
return Command::SUCCESS;
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
Log::error('모듈 캐시 삭제 실패', [
'module' => $identifier ?? 'all',
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
/**
* 특정 모듈의 캐시를 삭제합니다.
*/
private function clearModuleCache(string $identifier): int
{
$clearedCount = 0;
// 모듈별 캐시 키
$cacheKeys = [
"module.config.{$identifier}",
"module.info.{$identifier}",
"module.routes.{$identifier}",
"module.permissions.{$identifier}",
"module.menus.{$identifier}",
];
foreach ($cacheKeys as $key) {
if (Cache::forget($key)) {
$clearedCount++;
}
}
return $clearedCount;
}
}
@@ -0,0 +1,184 @@
<?php
namespace App\Console\Commands\Module;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Extension\ExtensionManager;
use App\Extension\ModuleManager;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class ComposerInstallModuleCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'module:composer-install
{identifier? : Composer 의존성을 설치할 모듈 식별자 (생략 시 --all 필요)}
{--all : 모든 모듈의 Composer 의존성 설치}
{--no-dev : dev 의존성 제외}';
/**
* The console command description.
*/
protected $description = '모듈의 Composer 의존성을 설치합니다';
/**
* 모듈 관리자 및 리포지토리
*/
public function __construct(
private ModuleManager $moduleManager,
private ExtensionManager $extensionManager,
private ModuleRepositoryInterface $moduleRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$identifier = $this->argument('identifier');
$installAll = $this->option('all');
$noDev = $this->option('no-dev');
if (! $identifier && ! $installAll) {
$this->error('❌ 모듈 식별자를 지정하거나 --all 옵션을 사용하세요.');
$this->line('');
$this->line('사용법:');
$this->line(' php artisan module:composer-install sirsoft-ecommerce');
$this->line(' php artisan module:composer-install --all');
$this->line(' php artisan module:composer-install --all --no-dev');
return Command::FAILURE;
}
try {
$this->moduleManager->loadModules();
if ($installAll) {
return $this->installAllModules($noDev);
}
return $this->installModule($identifier, $noDev);
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
Log::error('모듈 Composer 의존성 설치 실패', [
'module' => $identifier ?? 'all',
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
/**
* 단일 모듈의 Composer 의존성 설치
*
* @param string $identifier 모듈 식별자
* @param bool $noDev dev 의존성 제외 여부
* @return int 커맨드 결과 코드
*/
private function installModule(string $identifier, bool $noDev): int
{
$module = $this->moduleManager->getModule($identifier);
if (! $module) {
$this->error('❌ '.__('modules.errors.not_found', ['module' => $identifier]));
return Command::FAILURE;
}
if (! $this->extensionManager->hasComposerDependencies('modules', $identifier)) {
$this->info('ℹ️ '.__('modules.composer_install.no_dependencies', ['module' => $identifier]));
return Command::SUCCESS;
}
$deps = $this->extensionManager->getComposerDependencies('modules', $identifier);
$this->info('📦 '.__('modules.composer_install.start', ['module' => $identifier]));
$this->line(' '.implode(', ', array_keys($deps)));
$result = $this->extensionManager->runComposerInstall('modules', $identifier, $noDev, $this);
if ($result) {
$this->info('✅ '.__('modules.composer_install.success', ['module' => $identifier]));
// 오토로드 갱신
$this->extensionManager->updateComposerAutoload();
$this->line(' 오토로드 캐시 갱신 완료');
return Command::SUCCESS;
}
$this->error('❌ '.__('modules.composer_install.failed', ['module' => $identifier]));
return Command::FAILURE;
}
/**
* 모든 모듈의 Composer 의존성 설치
*
* @param bool $noDev dev 의존성 제외 여부
* @return int 커맨드 결과 코드
*/
private function installAllModules(bool $noDev): int
{
$modules = $this->moduleManager->getAllModules();
if (empty($modules)) {
$this->warn('⚠️ 설치된 모듈이 없습니다.');
return Command::SUCCESS;
}
// 중복 패키지 감지
$duplicates = $this->extensionManager->detectDuplicatePackages();
if (! empty($duplicates)) {
$this->warn('⚠️ 중복 패키지 감지:');
foreach ($duplicates as $package => $users) {
$this->warn(" - {$package}: ".implode(', ', $users));
}
$this->line('');
}
$this->info('📦 모든 모듈의 Composer 의존성 설치 시작');
$successCount = 0;
$skipCount = 0;
$failCount = 0;
foreach ($modules as $identifier => $module) {
if (! $this->extensionManager->hasComposerDependencies('modules', $identifier)) {
$skipCount++;
continue;
}
$this->line(" [{$identifier}]");
$result = $this->extensionManager->runComposerInstall('modules', $identifier, $noDev, $this);
if ($result) {
$successCount++;
$this->info(" ✅ 완료: {$identifier}");
} else {
$failCount++;
$this->error(" ❌ 실패: {$identifier}");
}
$this->line('');
}
// 오토로드 갱신
$this->extensionManager->updateComposerAutoload();
$this->info(__('modules.composer_install.summary', [
'success' => $successCount,
'skip' => $skipCount,
'fail' => $failCount,
]));
return $failCount > 0 ? Command::FAILURE : Command::SUCCESS;
}
}
@@ -0,0 +1,97 @@
<?php
namespace App\Console\Commands\Module;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Enums\ExtensionStatus;
use App\Extension\ModuleManager;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class DeactivateModuleCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'module:deactivate {identifier : 비활성화할 모듈 식별자} {--force : 의존 템플릿이 있어도 강제 비활성화}';
/**
* The console command description.
*/
protected $description = '모듈을 비활성화합니다';
/**
* 모듈 관리자 및 리포지토리
*/
public function __construct(
private ModuleManager $moduleManager,
private ModuleRepositoryInterface $moduleRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$identifier = $this->argument('identifier');
try {
// 모듈 디렉토리 스캔 및 로드
$this->moduleManager->loadModules();
// 모듈이 설치되어 있는지 확인
$moduleRecord = $this->moduleRepository->findByIdentifier($identifier);
if (! $moduleRecord) {
$this->error('❌ '.__('modules.commands.deactivate.not_installed', ['module' => $identifier]));
return Command::FAILURE;
}
// 모듈이 활성화되어 있는지 확인
if ($moduleRecord->status !== ExtensionStatus::Active->value) {
$this->warn('⚠️ '.__('modules.commands.deactivate.not_active', ['module' => $identifier]));
return Command::FAILURE;
}
// 모듈 비활성화
$force = $this->option('force');
$result = $this->moduleManager->deactivateModule($identifier, $force);
// 경고 응답인 경우 (의존 템플릿 존재)
if (isset($result['warning']) && $result['warning'] === true) {
$this->warn('⚠️ '.$result['message']);
$this->line('');
$this->info('의존하는 템플릿 목록:');
foreach ($result['dependent_templates'] as $template) {
$this->line(" - {$template['identifier']} ({$template['name']})");
}
$this->line('');
$this->info('강제로 비활성화하려면 --force 옵션을 사용하세요.');
return Command::FAILURE;
}
if ($result['success']) {
$this->info('✅ '.__('modules.commands.deactivate.success', ['module' => $identifier]));
$this->info(' - '.__('modules.commands.deactivate.layouts_deleted', ['count' => $result['layouts_deleted']]));
$this->warn('⚠️ '.__('modules.commands.deactivate.warning'));
Log::info(__('modules.commands.deactivate.success', ['module' => $identifier]));
return Command::SUCCESS;
}
return Command::FAILURE;
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
Log::error('모듈 비활성화 실패', [
'module' => $identifier,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -0,0 +1,120 @@
<?php
namespace App\Console\Commands\Module;
use App\Console\Commands\Traits\HasProgressBar;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Enums\ExtensionOwnerType;
use App\Extension\ModuleManager;
use App\Models\Menu;
use App\Models\Permission;
use App\Rules\ValidExtensionIdentifier;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
class InstallModuleCommand extends Command
{
use HasProgressBar;
/**
* The name and signature of the console command.
*/
protected $signature = 'module:install {identifier : 설치할 모듈 식별자}';
/**
* The console command description.
*/
protected $description = '모듈을 설치합니다';
/**
* 모듈 관리자 및 리포지토리
*/
public function __construct(
private ModuleManager $moduleManager,
private ModuleRepositoryInterface $moduleRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$identifier = $this->argument('identifier');
// 식별자 형식 검증
$validator = Validator::make(
['identifier' => $identifier],
['identifier' => [new ValidExtensionIdentifier]]
);
if ($validator->fails()) {
$this->error('❌ '.$validator->errors()->first('identifier'));
return Command::FAILURE;
}
try {
// 모듈 디렉토리 스캔 및 로드
$this->moduleManager->loadModules();
// 이미 설치된 모듈인지 확인
$existingModule = $this->moduleRepository->findByIdentifier($identifier);
if ($existingModule) {
$this->warn('⚠️ '.__('modules.commands.install.already_installed', ['module' => $identifier]));
return Command::FAILURE;
}
// 모듈 설치
$onProgress = $this->createProgressCallback(ModuleManager::INSTALL_STEPS);
try {
$result = $this->moduleManager->installModule($identifier, $onProgress);
$this->finishProgress();
} catch (\Exception $e) {
$this->finishProgress();
throw $e;
}
if ($result) {
// 설치된 모듈 정보 조회
$module = $this->moduleRepository->findByIdentifier($identifier);
// 모듈 인스턴스에서 생성된 role 개수 조회
$moduleInstance = $this->moduleManager->getModule($identifier);
$rolesCount = 0;
if ($moduleInstance && method_exists($moduleInstance, 'getRoles')) {
$rolesCount = count($moduleInstance->getRoles());
}
// 권한 및 메뉴 개수 조회
$permissionsCount = Permission::byExtension(ExtensionOwnerType::Module, $identifier)->count();
$menusCount = Menu::byExtension(ExtensionOwnerType::Module, $identifier)->count();
// 성공 메시지
$this->info('✅ '.__('modules.commands.install.success', ['module' => $identifier]));
$this->info(' - '.__('modules.commands.install.vendor', ['vendor' => $module->vendor]));
$this->info(' - '.__('modules.commands.install.version', ['version' => $module->version]));
$this->info(' - '.__('modules.commands.install.roles_created', ['count' => $rolesCount]));
$this->info(' - '.__('modules.commands.install.permissions_created', ['count' => $permissionsCount]));
$this->info(' - '.__('modules.commands.install.menus_created', ['count' => $menusCount]));
Log::info(__('modules.commands.install.success', ['module' => $identifier]));
return Command::SUCCESS;
}
return Command::FAILURE;
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
Log::error('모듈 설치 실패', [
'module' => $identifier,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -0,0 +1,150 @@
<?php
namespace App\Console\Commands\Module;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Enums\ExtensionStatus;
use App\Extension\ModuleManager;
use Illuminate\Console\Command;
class ListModuleCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'module:list
{--status= : 상태로 필터 (installed, uninstalled, active, inactive)}';
/**
* The console command description.
*/
protected $description = '모듈 목록을 조회합니다';
/**
* 모듈 관리자 및 리포지토리
*/
public function __construct(
private ModuleManager $moduleManager,
private ModuleRepositoryInterface $moduleRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
// 모듈 디렉토리 스캔 및 로드
$this->moduleManager->loadModules();
$statusFilter = $this->option('status');
// 상태 필터 검증
if ($statusFilter && ! in_array($statusFilter, ['installed', 'uninstalled', 'active', 'inactive'])) {
$this->error('❌ '.__('modules.commands.list.invalid_status'));
return Command::FAILURE;
}
// 설치된 모듈 정보
$installedModules = $this->moduleManager->getInstalledModulesWithDetails();
// 미설치 모듈 정보
$uninstalledModules = $this->moduleManager->getUninstalledModules();
// 테이블 데이터 준비
$tableData = [];
// 설치된 모듈 추가
foreach ($installedModules as $identifier => $module) {
// 상태 필터
if ($statusFilter) {
if ($statusFilter === 'uninstalled') {
continue;
}
if ($statusFilter === 'active' && $module['status'] !== ExtensionStatus::Active->value) {
continue;
}
if ($statusFilter === 'inactive' && $module['status'] !== ExtensionStatus::Inactive->value) {
continue;
}
if ($statusFilter === 'installed' && ! in_array($module['status'], [ExtensionStatus::Active->value, ExtensionStatus::Inactive->value])) {
continue;
}
}
$tableData[] = [
'identifier' => $identifier,
'name' => $module['name'],
'vendor' => $module['vendor'],
'version' => $module['version'],
'status' => $this->formatStatus($module['status']),
];
}
// 미설치 모듈 추가
if (! $statusFilter || $statusFilter === 'uninstalled') {
foreach ($uninstalledModules as $identifier => $module) {
// 상태 필터 (uninstalled 또는 필터 없음)
if ($statusFilter && $statusFilter !== 'uninstalled') {
continue;
}
$tableData[] = [
'identifier' => $identifier,
'name' => $module['name'],
'vendor' => $module['vendor'],
'version' => $module['version'],
'status' => $this->formatStatus('uninstalled'),
];
}
}
// 모듈이 없는 경우
if (empty($tableData)) {
$this->info(__('modules.commands.list.no_modules'));
return Command::SUCCESS;
}
// 테이블 헤더
$headers = [
__('modules.commands.list.headers.identifier'),
__('modules.commands.list.headers.name'),
__('modules.commands.list.headers.vendor'),
__('modules.commands.list.headers.version'),
__('modules.commands.list.headers.status'),
];
// 테이블 출력
$this->table($headers, $tableData);
// 요약 정보
$totalCount = count($tableData);
$activeCount = count(array_filter($tableData, fn ($m) => str_contains($m['status'], __('modules.commands.list.status.active'))));
$installedCount = count(array_filter($tableData, fn ($m) => ! str_contains($m['status'], __('modules.commands.list.status.uninstalled'))));
$this->newLine();
$this->info(__('modules.commands.list.summary', [
'total' => $totalCount,
'installed' => $installedCount,
'active' => $activeCount,
]));
return Command::SUCCESS;
}
/**
* 상태 포맷팅
*/
private function formatStatus(string $status): string
{
return match ($status) {
ExtensionStatus::Active->value => '✅ '.__('modules.commands.list.status.active'),
ExtensionStatus::Inactive->value => '⏸️ '.__('modules.commands.list.status.inactive'),
'uninstalled' => '📦 '.__('modules.commands.list.status.uninstalled'),
default => $status,
};
}
}
@@ -0,0 +1,126 @@
<?php
namespace App\Console\Commands\Module;
use App\Models\Module;
use App\Services\ModuleService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
/**
* 모듈 레이아웃 갱신 커맨드
*
* 레이아웃 JSON 파일만 수정된 경우, 빌드 없이 레이아웃만 갱신합니다.
*/
class RefreshModuleLayoutCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'module:refresh-layout
{identifier? : 특정 모듈의 식별자 (생략 시 모든 활성 모듈)}';
/**
* The console command description.
*/
protected $description = '모듈 레이아웃을 갱신합니다 (빌드 없이 JSON만 DB에 동기화)';
public function __construct(
private ModuleService $moduleService
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$identifier = $this->argument('identifier');
try {
if ($identifier) {
return $this->refreshSingle($identifier);
}
return $this->refreshAll();
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
Log::error('모듈 레이아웃 갱신 실패', [
'module' => $identifier ?? 'all',
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
/**
* 특정 모듈의 레이아웃을 갱신합니다.
*/
private function refreshSingle(string $identifier): int
{
$this->info("모듈 '{$identifier}' 레이아웃 갱신 중...");
$result = $this->moduleService->refreshModuleLayouts($identifier);
if ($result) {
$this->info("✅ 모듈 '{$identifier}' 레이아웃 갱신 완료");
Log::info('모듈 레이아웃 갱신 완료', ['module' => $identifier]);
return Command::SUCCESS;
}
$this->error("❌ 모듈 '{$identifier}' 레이아웃 갱신 실패");
return Command::FAILURE;
}
/**
* 모든 활성 모듈의 레이아웃을 갱신합니다.
*/
private function refreshAll(): int
{
$modules = Module::where('status', 'active')->pluck('identifier')->toArray();
if (empty($modules)) {
$this->line('활성화된 모듈이 없습니다.');
return Command::SUCCESS;
}
$this->info('모든 활성 모듈 레이아웃 갱신 중...');
$successCount = 0;
$failCount = 0;
foreach ($modules as $moduleIdentifier) {
$this->line(" {$moduleIdentifier} 갱신 중...");
try {
$result = $this->moduleService->refreshModuleLayouts($moduleIdentifier);
if ($result) {
$this->info(" ✅ {$moduleIdentifier} 완료");
$successCount++;
} else {
$this->warn(" ⚠️ {$moduleIdentifier} 실패");
$failCount++;
}
} catch (\Exception $e) {
$this->warn(" ⚠️ {$moduleIdentifier}: ".$e->getMessage());
$failCount++;
}
}
$this->newLine();
$this->info("✅ 총 {$successCount}개 성공, {$failCount}개 실패");
Log::info('모듈 레이아웃 일괄 갱신 완료', [
'success' => $successCount,
'failed' => $failCount,
]);
return $failCount > 0 ? Command::FAILURE : Command::SUCCESS;
}
}
@@ -0,0 +1,257 @@
<?php
namespace App\Console\Commands\Module;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Enums\ExtensionStatus;
use App\Extension\ExtensionManager;
use App\Extension\ModuleManager;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class SeedModuleCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'module:seed
{identifier? : 시더를 실행할 모듈 식별자 (생략 시 모든 활성 모듈)}
{--class= : 실행할 특정 시더 클래스명}
{--count=* : 시더에 전달할 카운트 옵션 (형식: key=value, 예: --count=products=1000)}
{--sample : 샘플 데이터 시더도 함께 실행}
{--force : 프로덕션 환경에서도 강제 실행}';
/**
* The console command description.
*/
protected $description = '모듈의 데이터베이스 시더를 실행합니다';
/**
* 모듈 관리자 및 리포지토리
*/
public function __construct(
private ModuleManager $moduleManager,
private ModuleRepositoryInterface $moduleRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$identifier = $this->argument('identifier');
$seederClass = $this->option('class');
$force = $this->option('force');
// 프로덕션 환경 확인
if (app()->environment('production') && ! $force) {
$this->error('❌ 프로덕션 환경에서는 --force 옵션이 필요합니다.');
return Command::FAILURE;
}
try {
// 모듈 로드
$this->moduleManager->loadModules();
if ($identifier) {
return $this->seedModule($identifier, $seederClass);
}
return $this->seedAllActiveModules($seederClass);
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
Log::error('모듈 시더 실행 실패', [
'module' => $identifier ?? 'all',
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
/**
* 단일 모듈 시더 실행
*
* @param string $identifier 모듈 식별자
* @param string|null $seederClass 특정 시더 클래스명
* @return int 명령 실행 결과
*/
private function seedModule(string $identifier, ?string $seederClass): int
{
// 모듈 존재 여부 확인
$module = $this->moduleManager->getModule($identifier);
if (! $module) {
$this->error('❌ '.__('modules.errors.not_found', ['module' => $identifier]));
return Command::FAILURE;
}
// 활성화 상태 확인
$moduleRecord = $this->moduleRepository->findByIdentifier($identifier);
if (! $moduleRecord || $moduleRecord->status !== ExtensionStatus::Active->value) {
$this->error('❌ 모듈이 활성화되어 있지 않습니다: '.$identifier);
$this->line(' 먼저 php artisan module:activate '.$identifier.' 명령을 실행하세요.');
return Command::FAILURE;
}
// 시더 경로 확인
$modulePath = base_path("modules/{$identifier}");
$seederPath = $modulePath.'/database/seeders';
if (! is_dir($seederPath)) {
$this->warn('⚠️ 모듈에 시더 디렉토리가 없습니다: '.$identifier);
return Command::SUCCESS;
}
// 시더 클래스 결정
$seederClassName = $this->resolveSeederClass($identifier, $seederClass);
if (! class_exists($seederClassName)) {
$this->error('❌ 시더 클래스를 찾을 수 없습니다: '.$seederClassName);
return Command::FAILURE;
}
$this->info("🌱 시더 실행 시작: {$identifier}");
$this->line(' 클래스: '.$seederClassName);
$this->line('');
// 시더 실행
$seeder = app($seederClassName);
$seeder->setCommand($this);
// --sample 옵션 전파
if (method_exists($seeder, 'setIncludeSample')) {
$seeder->setIncludeSample((bool) $this->option('sample'));
}
// --count 옵션 전파
$counts = $this->parseCountOptions();
if (! empty($counts) && method_exists($seeder, 'setSeederCounts')) {
$seeder->setSeederCounts($counts);
}
$seeder->run();
$this->line('');
$this->info("✅ 시더 실행 완료: {$identifier}");
return Command::SUCCESS;
}
/**
* 모든 활성 모듈 시더 실행
*
* @param string|null $seederClass 특정 시더 클래스명
* @return int 명령 실행 결과
*/
private function seedAllActiveModules(?string $seederClass): int
{
$activeModules = $this->moduleManager->getActiveModules();
if (empty($activeModules)) {
$this->warn('⚠️ 활성화된 모듈이 없습니다.');
return Command::SUCCESS;
}
// 시더가 있는 모듈만 필터링
$seedableModules = [];
foreach ($activeModules as $identifier => $module) {
$seederPath = base_path("modules/{$identifier}/database/seeders");
$seederClassName = $this->resolveSeederClass($identifier, $seederClass);
if (is_dir($seederPath) && class_exists($seederClassName)) {
$seedableModules[$identifier] = $module;
}
}
if (empty($seedableModules)) {
$this->warn('⚠️ 시더가 있는 활성 모듈이 없습니다.');
return Command::SUCCESS;
}
$this->info('🌱 모든 활성 모듈 시더 실행 시작');
$this->line(' 대상 모듈: '.implode(', ', array_keys($seedableModules)));
$this->line('');
$successCount = 0;
$failCount = 0;
foreach ($seedableModules as $identifier => $module) {
$this->line("━━━ [{$identifier}] ━━━");
$result = $this->seedModule($identifier, $seederClass);
if ($result === Command::SUCCESS) {
$successCount++;
} else {
$failCount++;
}
$this->line('');
}
$this->info("📊 시더 실행 결과: 성공 {$successCount}개, 실패 {$failCount}개");
return $failCount > 0 ? Command::FAILURE : Command::SUCCESS;
}
/**
* --count 옵션을 파싱하여 연관 배열로 반환합니다.
*
* 입력: ['products=1000', 'orders=500']
* 출력: ['products' => 1000, 'orders' => 500]
*
* @return array<string, int>
*/
public function parseCountOptions(): array
{
$countOptions = $this->option('count');
$counts = [];
foreach ($countOptions as $option) {
if (str_contains($option, '=')) {
[$key, $value] = explode('=', $option, 2);
$key = trim($key);
if ($key !== '') {
$counts[$key] = (int) trim($value);
}
}
}
return $counts;
}
/**
* 시더 클래스명 해석
*
* @param string $identifier 모듈 식별자
* @param string|null $seederClass 사용자 지정 시더 클래스명
* @return string 완전한 시더 클래스명
*/
private function resolveSeederClass(string $identifier, ?string $seederClass): string
{
$namespace = ExtensionManager::moduleIdentifierToNamespace($identifier);
if ($seederClass) {
// 사용자가 지정한 클래스명 사용
if (str_contains($seederClass, '\\')) {
return $seederClass;
}
return $namespace.'Database\\Seeders\\'.$seederClass;
}
// 기본 DatabaseSeeder 사용
return $namespace.'Database\\Seeders\\DatabaseSeeder';
}
}
@@ -0,0 +1,125 @@
<?php
namespace App\Console\Commands\Module;
use App\Console\Commands\Traits\HasProgressBar;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Enums\ExtensionOwnerType;
use App\Extension\ModuleManager;
use App\Models\Menu;
use App\Models\Permission;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class UninstallModuleCommand extends Command
{
use HasProgressBar;
/**
* The name and signature of the console command.
*/
protected $signature = 'module:uninstall
{identifier : 제거할 모듈 식별자}
{--force : 확인 없이 삭제}
{--delete-data : 모듈 데이터(테이블, 환경설정) 함께 삭제}';
/**
* The console command description.
*/
protected $description = '모듈을 제거합니다';
/**
* 모듈 관리자 및 리포지토리
*/
public function __construct(
private ModuleManager $moduleManager,
private ModuleRepositoryInterface $moduleRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$identifier = $this->argument('identifier');
try {
// 모듈 디렉토리 스캔 및 로드
$this->moduleManager->loadModules();
// 모듈이 설치되어 있는지 확인
$moduleRecord = $this->moduleRepository->findByIdentifier($identifier);
if (! $moduleRecord) {
$this->error('❌ '.__('modules.commands.uninstall.not_installed', ['module' => $identifier]));
return Command::FAILURE;
}
// 모듈 인스턴스에서 role 개수 조회
$moduleInstance = $this->moduleManager->getModule($identifier);
$rolesCount = 0;
if ($moduleInstance && method_exists($moduleInstance, 'getRoles')) {
$rolesCount = count($moduleInstance->getRoles());
}
// 권한, 메뉴, 레이아웃 개수 조회
$permissionsCount = Permission::byExtension(ExtensionOwnerType::Module, $identifier)->count();
$menusCount = Menu::byExtension(ExtensionOwnerType::Module, $identifier)->count();
$layoutsCount = $this->moduleManager->getModuleLayoutsCount($identifier);
// 확인 프롬프트 (--force 옵션이 없는 경우)
if (! $this->option('force')) {
$this->warn(__('modules.commands.uninstall.confirm_prompt', ['module' => $identifier]));
$this->line(__('modules.commands.uninstall.confirm_details.roles', ['count' => $rolesCount]));
$this->line(__('modules.commands.uninstall.confirm_details.permissions', ['count' => $permissionsCount]));
$this->line(__('modules.commands.uninstall.confirm_details.menus', ['count' => $menusCount]));
$this->line(__('modules.commands.uninstall.confirm_details.layouts', ['count' => $layoutsCount]));
$this->line(__('modules.commands.uninstall.confirm_details.data'));
if ($this->option('delete-data')) {
$this->warn('⚠️ --delete-data: 마이그레이션 롤백 및 환경설정 파일이 함께 삭제됩니다.');
}
$this->newLine();
if (! $this->confirm(__('modules.commands.uninstall.confirm_question'), false)) {
$this->info(__('modules.commands.uninstall.aborted'));
return Command::SUCCESS;
}
}
// 모듈 제거
$deleteData = $this->option('delete-data');
$onProgress = $this->createProgressCallback(ModuleManager::UNINSTALL_STEPS);
try {
$result = $this->moduleManager->uninstallModule($identifier, $deleteData, $onProgress);
$this->finishProgress();
} catch (\Exception $e) {
$this->finishProgress();
throw $e;
}
if ($result) {
$this->info('✅ '.__('modules.commands.uninstall.success', ['module' => $identifier]));
$this->info(' - '.__('modules.commands.uninstall.roles_deleted', ['count' => $rolesCount]));
$this->info(' - '.__('modules.commands.uninstall.permissions_deleted', ['count' => $permissionsCount]));
$this->info(' - '.__('modules.commands.uninstall.menus_deleted', ['count' => $menusCount]));
$this->info(' - '.__('modules.commands.uninstall.layouts_deleted', ['count' => $layoutsCount]));
Log::info(__('modules.commands.uninstall.success', ['module' => $identifier]));
return Command::SUCCESS;
}
return Command::FAILURE;
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
Log::error('모듈 제거 실패', [
'module' => $identifier,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -0,0 +1,125 @@
<?php
namespace App\Console\Commands\Module;
use App\Console\Commands\Traits\HasProgressBar;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Extension\ModuleManager;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class UpdateModuleCommand extends Command
{
use HasProgressBar;
/**
* The name and signature of the console command.
*/
protected $signature = 'module:update
{identifier : 업데이트할 모듈 식별자}
{--force : 버전 비교 없이 강제 업데이트}';
/**
* The console command description.
*/
protected $description = '모듈을 최신 버전으로 업데이트합니다';
/**
* 모듈 관리자 및 리포지토리
*/
public function __construct(
private ModuleManager $moduleManager,
private ModuleRepositoryInterface $moduleRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$identifier = $this->argument('identifier');
$force = $this->option('force');
try {
$this->moduleManager->loadModules();
// 모듈 존재 확인
$module = $this->moduleRepository->findByIdentifier($identifier);
if (! $module) {
$this->error('❌ '.__('modules.commands.update.not_installed', ['module' => $identifier]));
return Command::FAILURE;
}
// 업데이트 확인
$checkResult = $this->moduleManager->checkModuleUpdate($identifier);
if (! $checkResult['update_available'] && ! $force) {
$this->info('✅ '.__('modules.commands.update.no_update', ['module' => $identifier]));
return Command::SUCCESS;
}
// 업데이트 정보 표시
$this->info(__('modules.commands.update.current_version', ['version' => $checkResult['current_version']]));
if ($force && ! $checkResult['update_available']) {
$this->warn('⚠️ '.__('modules.commands.update.force_mode'));
} else {
$this->info(__('modules.commands.update.latest_version', ['version' => $checkResult['latest_version']]));
$this->info(__('modules.commands.update.update_source', ['source' => $checkResult['update_source']]));
}
$this->newLine();
// 확인 프롬프트 (--force 시 건너뜀)
if (! $force && ! $this->confirm(__('modules.commands.update.confirm_question'), false)) {
$this->info(__('modules.commands.update.aborted'));
return Command::SUCCESS;
}
// 업데이트 실행
$onProgress = $this->createProgressCallback(ModuleManager::UPDATE_STEPS);
try {
$updateResult = $this->moduleManager->updateModule($identifier, $force, $onProgress);
$this->finishProgress();
} catch (\Exception $e) {
$this->finishProgress();
throw $e;
}
if ($updateResult['success']) {
$this->newLine();
$this->info('✅ '.__('modules.commands.update.success', ['module' => $identifier]));
$this->info(' '.__('modules.commands.update.version_change', [
'from' => $updateResult['from_version'],
'to' => $updateResult['to_version'],
]));
Log::info('모듈 업데이트 완료', [
'module' => $identifier,
'from' => $updateResult['from_version'],
'to' => $updateResult['to_version'],
]);
return Command::SUCCESS;
}
return Command::FAILURE;
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
$this->warn('💡 '.__('modules.commands.update.backup_restored'));
Log::error('모듈 업데이트 실패', [
'module' => $identifier,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -0,0 +1,109 @@
<?php
namespace App\Console\Commands\Plugin;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Enums\ExtensionStatus;
use App\Extension\PluginManager;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class ActivatePluginCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'plugin:activate {identifier : 활성화할 플러그인 식별자} {--force : 의존성 미충족 시 강제 활성화}';
/**
* The console command description.
*/
protected $description = '플러그인을 활성화합니다';
/**
* 플러그인 관리자 및 리포지토리
*/
public function __construct(
private PluginManager $pluginManager,
private PluginRepositoryInterface $pluginRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$identifier = $this->argument('identifier');
try {
// 플러그인 디렉토리 스캔 및 로드
$this->pluginManager->loadPlugins();
// 플러그인이 설치되어 있는지 확인
$pluginRecord = $this->pluginRepository->findByIdentifier($identifier);
if (! $pluginRecord) {
$this->error('❌ '.__('plugins.commands.activate.not_installed', ['plugin' => $identifier]));
return Command::FAILURE;
}
// 이미 활성화된 플러그인인지 확인
if ($pluginRecord->status === ExtensionStatus::Active->value) {
$this->warn('⚠️ '.__('plugins.commands.activate.already_active', ['plugin' => $identifier]));
return Command::FAILURE;
}
// 플러그인 활성화
$force = $this->option('force');
$result = $this->pluginManager->activatePlugin($identifier, $force);
// 경고 응답인 경우 (의존성 미충족)
if (isset($result['warning']) && $result['warning'] === true) {
$this->warn('⚠️ '.$result['message']);
$this->line('');
if (! empty($result['missing_modules'])) {
$this->info('필요한 모듈:');
foreach ($result['missing_modules'] as $module) {
$statusLabel = $module['status'] === 'not_installed' ? '미설치' : '비활성';
$this->line(" - {$module['identifier']} ({$module['name']}) [{$statusLabel}]");
}
}
if (! empty($result['missing_plugins'])) {
$this->info('필요한 플러그인:');
foreach ($result['missing_plugins'] as $plugin) {
$statusLabel = $plugin['status'] === 'not_installed' ? '미설치' : '비활성';
$this->line(" - {$plugin['identifier']} ({$plugin['name']}) [{$statusLabel}]");
}
}
$this->line('');
$this->info('강제로 활성화하려면 --force 옵션을 사용하세요.');
return Command::FAILURE;
}
if ($result['success']) {
$this->info('✅ '.__('plugins.commands.activate.success', ['plugin' => $identifier]));
$this->info(' - '.__('plugins.commands.activate.layouts_registered', ['count' => $result['layouts_registered']]));
Log::info(__('plugins.commands.activate.success', ['plugin' => $identifier]));
return Command::SUCCESS;
}
return Command::FAILURE;
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
Log::error('플러그인 활성화 실패', [
'plugin' => $identifier,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -0,0 +1,370 @@
<?php
namespace App\Console\Commands\Plugin;
use App\Extension\PluginManager;
use App\Extension\Traits\ClearsTemplateCaches;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Process\Process;
class BuildPluginCommand extends Command
{
use ClearsTemplateCaches;
/**
* The name and signature of the console command.
*/
protected $signature = 'plugin:build
{identifier? : 빌드할 플러그인 식별자 (생략 시 --all 필요)}
{--all : 모든 플러그인 빌드}
{--watch : 파일 변경 감시 모드}
{--production : 프로덕션 빌드}
{--active : 활성 디렉토리에서 빌드}';
/**
* The console command description.
*/
protected $description = '플러그인의 프론트엔드 에셋을 빌드합니다 (기본: _bundled 디렉토리)';
/**
* 플러그인 관리자
*/
public function __construct(
private PluginManager $pluginManager
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$identifier = $this->argument('identifier');
$buildAll = $this->option('all');
$watchMode = $this->option('watch');
$productionMode = $this->option('production');
if (! $identifier && ! $buildAll) {
$this->error('❌ 플러그인 식별자를 지정하거나 --all 옵션을 사용하세요.');
$this->line('');
$this->line('사용법:');
$this->line(' php artisan plugin:build sirsoft-payment');
$this->line(' php artisan plugin:build --all');
$this->line(' php artisan plugin:build sirsoft-payment --watch');
$this->line(' php artisan plugin:build --all --production');
$this->line(' php artisan plugin:build sirsoft-payment --active');
return Command::FAILURE;
}
try {
// 플러그인 로드
$this->pluginManager->loadPlugins();
if ($buildAll) {
return $this->buildAllPlugins($productionMode);
}
return $this->buildPlugin($identifier, $watchMode, $productionMode);
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
Log::error('플러그인 빌드 실패', [
'plugin' => $identifier ?? 'all',
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
/**
* 빌드 대상 경로를 결정합니다.
*
* 기본값: _bundled 디렉토리. --active 옵션 시 활성 디렉토리.
* --watch 모드에서는 활성 디렉토리를 사용합니다 (실시간 개발용).
*
* @param string $identifier 플러그인 식별자
* @return array|null ['path' => string, 'source' => 'bundled'|'active'] 또는 null
*/
private function resolveBuildPath(string $identifier): ?array
{
$bundledPath = base_path("plugins/_bundled/{$identifier}");
$activePath = base_path("plugins/{$identifier}");
// --active 명시 → 활성 디렉토리
if ($this->option('active')) {
return is_dir($activePath)
? ['path' => $activePath, 'source' => 'active']
: null;
}
// --watch 모드 → 활성 디렉토리 (실시간 개발용)
if ($this->option('watch')) {
if (is_dir($activePath)) {
return ['path' => $activePath, 'source' => 'active'];
}
}
// 기본값: _bundled 우선
if (is_dir($bundledPath)) {
return ['path' => $bundledPath, 'source' => 'bundled'];
}
// _bundled에 없으면 활성 디렉토리 폴백
if (is_dir($activePath)) {
return ['path' => $activePath, 'source' => 'active'];
}
return null;
}
/**
* 단일 플러그인 빌드
*
* @param string $identifier 플러그인 식별자
* @param bool $watchMode 파일 감시 모드
* @param bool $productionMode 프로덕션 빌드
* @return int 명령 실행 결과 코드
*/
private function buildPlugin(string $identifier, bool $watchMode, bool $productionMode): int
{
// 경로 결정
$resolved = $this->resolveBuildPath($identifier);
if (! $resolved) {
$this->error("❌ 플러그인을 찾을 수 없습니다: {$identifier}");
$this->line(' _bundled 및 활성 디렉토리 모두 존재하지 않습니다.');
return Command::FAILURE;
}
$buildPath = $resolved['path'];
$source = $resolved['source'];
// 소스 표시
$sourceLabel = $source === 'bundled' ? '_bundled' : '활성';
$this->info("📂 빌드 소스: {$sourceLabel} ({$buildPath})");
// package.json 존재 확인
$packageJsonPath = $buildPath.'/package.json';
if (! file_exists($packageJsonPath)) {
$this->error('❌ package.json 파일이 없습니다: '.$packageJsonPath);
$this->line(' 플러그인 프론트엔드 구조를 먼저 생성하세요.');
return Command::FAILURE;
}
// 에셋 빌드 가능 여부 확인 (활성 플러그인 인스턴스가 있는 경우)
$plugin = $this->pluginManager->getPlugin($identifier);
if ($plugin && ! $plugin->canBuild() && ! $plugin->hasAssets()) {
$this->warn('⚠️ 플러그인에 빌드할 에셋이 없습니다: '.$identifier);
return Command::SUCCESS;
}
// node_modules 확인 및 설치
if (! is_dir($buildPath.'/node_modules')) {
$this->info('📦 의존성 설치 중...');
$installResult = $this->runNpmCommand(['npm', 'install'], $buildPath);
if ($installResult !== Command::SUCCESS) {
$this->error('❌ npm install 실패');
return Command::FAILURE;
}
}
// 빌드 명령 결정
$buildCommand = ['npm', 'run'];
if ($watchMode) {
$buildCommand[] = 'dev';
$this->info("👀 파일 감시 모드로 빌드 시작: {$identifier}");
$this->line(' Ctrl+C로 종료할 수 있습니다.');
} else {
$buildCommand[] = 'build';
$this->info("🔨 빌드 시작: {$identifier}".($productionMode ? ' (프로덕션)' : ''));
}
// 빌드 실행
$result = $this->runNpmCommand($buildCommand, $buildPath, ! $watchMode);
if ($result === Command::SUCCESS && ! $watchMode) {
$this->info("✅ 빌드 완료: {$identifier}");
// 빌드 결과 파일 확인
$this->displayBuildResults($buildPath, $identifier);
// 캐시 버전 증가 (브라우저 캐시 무효화)
$this->incrementExtensionCacheVersion();
$this->line(' - 캐시 버전 갱신됨');
// _bundled 빌드 시 활성 반영 안내
if ($source === 'bundled') {
$this->line('');
$this->info("💡 활성 디렉토리에 반영하려면: php artisan plugin:update {$identifier}");
}
}
return $result;
}
/**
* 빌드 결과 파일 정보를 출력합니다.
*
* @param string $buildPath 빌드된 경로
* @param string $identifier 플러그인 식별자
*/
private function displayBuildResults(string $buildPath, string $identifier): void
{
// 활성 플러그인 인스턴스가 있으면 getBuiltAssetPaths 활용
$plugin = $this->pluginManager->getPlugin($identifier);
if ($plugin) {
$builtPaths = $plugin->getBuiltAssetPaths();
if (! empty($builtPaths['js'])) {
$jsPath = $buildPath.'/'.$builtPaths['js'];
if (file_exists($jsPath)) {
$this->line(' - JS: '.$builtPaths['js'].' ('.number_format(filesize($jsPath) / 1024, 2).' KB)');
}
}
if (! empty($builtPaths['css'])) {
$cssPath = $buildPath.'/'.$builtPaths['css'];
if (file_exists($cssPath)) {
$this->line(' - CSS: '.$builtPaths['css'].' ('.number_format(filesize($cssPath) / 1024, 2).' KB)');
}
}
return;
}
// 활성 인스턴스 없으면 manifest에서 직접 확인
$manifestPath = $buildPath.'/plugin.json';
if (file_exists($manifestPath)) {
$manifest = json_decode(file_get_contents($manifestPath), true);
$assets = $manifest['assets'] ?? [];
if (! empty($assets['js']['output'])) {
$jsPath = $buildPath.'/'.$assets['js']['output'];
if (file_exists($jsPath)) {
$this->line(' - JS: '.$assets['js']['output'].' ('.number_format(filesize($jsPath) / 1024, 2).' KB)');
}
}
if (! empty($assets['css']['output'])) {
$cssPath = $buildPath.'/'.$assets['css']['output'];
if (file_exists($cssPath)) {
$this->line(' - CSS: '.$assets['css']['output'].' ('.number_format(filesize($cssPath) / 1024, 2).' KB)');
}
}
}
}
/**
* 모든 플러그인 빌드
*
* @param bool $productionMode 프로덕션 빌드
* @return int 명령 실행 결과 코드
*/
private function buildAllPlugins(bool $productionMode): int
{
$buildTargets = [];
if ($this->option('active')) {
// --active: 활성 디렉토리만 대상
$activePlugins = $this->pluginManager->getAllPlugins();
foreach ($activePlugins as $identifier => $plugin) {
$activePath = base_path("plugins/{$identifier}");
if (file_exists($activePath.'/package.json')
&& ($plugin->canBuild() || $plugin->hasAssets())) {
$buildTargets[$identifier] = true;
}
}
} else {
// 기본값: _bundled 디렉토리 스캔
$bundledPlugins = $this->pluginManager->getBundledPlugins();
foreach ($bundledPlugins as $identifier => $metadata) {
$bundledPath = $metadata['source_path'];
if (file_exists($bundledPath.'/package.json')) {
$assets = $metadata['assets'] ?? [];
if (! empty($assets['js']['entry']) || ! empty($assets['css']['entry']) || ! empty($assets)) {
$buildTargets[$identifier] = true;
}
}
}
}
if (empty($buildTargets)) {
$this->warn('⚠️ 빌드할 플러그인이 없습니다.');
return Command::SUCCESS;
}
$sourceLabel = $this->option('active') ? '활성' : '_bundled';
$this->info("🔨 모든 플러그인 빌드 시작 ({$sourceLabel})".($productionMode ? ' (프로덕션)' : ''));
$this->line(' 대상 플러그인: '.implode(', ', array_keys($buildTargets)));
$this->line('');
$successCount = 0;
$failCount = 0;
foreach ($buildTargets as $identifier => $value) {
$this->line(" [{$identifier}]");
$result = $this->buildPlugin($identifier, false, $productionMode);
if ($result === Command::SUCCESS) {
$successCount++;
} else {
$failCount++;
}
$this->line('');
}
$this->info("📊 빌드 결과: 성공 {$successCount}개, 실패 {$failCount}개");
return $failCount > 0 ? Command::FAILURE : Command::SUCCESS;
}
/**
* npm 명령 실행
*
* @param array $command 실행할 명령
* @param string $cwd 작업 디렉토리
* @param bool $waitForCompletion 완료 대기 여부
* @return int 명령 실행 결과 코드
*/
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true): int
{
// Windows 환경에서는 cmd /c 사용
if (PHP_OS_FAMILY === 'Windows') {
$command = array_merge(['cmd', '/c'], $command);
}
$process = new Process($command);
$process->setWorkingDirectory($cwd);
$process->setTimeout(null); // 타임아웃 없음
if ($waitForCompletion) {
$process->run(function ($type, $buffer) {
$this->output->write($buffer);
});
return $process->isSuccessful() ? Command::SUCCESS : Command::FAILURE;
}
// 감시 모드: 인터럽트까지 실행
$process->start(function ($type, $buffer) {
$this->output->write($buffer);
});
// 프로세스가 실행 중인 동안 대기
while ($process->isRunning()) {
usleep(100000); // 100ms
}
return Command::SUCCESS;
}
}
@@ -0,0 +1,152 @@
<?php
namespace App\Console\Commands\Plugin;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Extension\PluginManager;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class CheckPluginUpdatesCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'plugin:check-updates {identifier? : 특정 플러그인만 확인 (선택)}';
/**
* The console command description.
*/
protected $description = '플러그인 업데이트를 확인합니다';
/**
* 플러그인 관리자 및 리포지토리
*/
public function __construct(
private PluginManager $pluginManager,
private PluginRepositoryInterface $pluginRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$identifier = $this->argument('identifier');
try {
$this->pluginManager->loadPlugins();
if ($identifier) {
return $this->checkSingle($identifier);
}
return $this->checkAll();
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
Log::error('플러그인 업데이트 확인 실패', [
'plugin' => $identifier,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
/**
* 단일 플러그인 업데이트를 확인합니다.
*
* @param string $identifier 플러그인 식별자
*/
private function checkSingle(string $identifier): int
{
$plugin = $this->pluginRepository->findByIdentifier($identifier);
if (! $plugin) {
$this->error('❌ '.__('plugins.commands.check_updates.not_installed', ['plugin' => $identifier]));
return Command::FAILURE;
}
$result = $this->pluginManager->checkPluginUpdate($identifier);
// 단일 체크는 DB 갱신이 안 되므로 커맨드에서 직접 갱신
$this->pluginRepository->updateByIdentifier($identifier, [
'update_available' => $result['update_available'],
'latest_version' => $result['latest_version'],
'update_source' => $result['update_source'],
]);
if ($result['update_available']) {
$this->info('🔄 '.__('plugins.commands.check_updates.single_update_available', [
'plugin' => $identifier,
'current' => $result['current_version'],
'latest' => $result['latest_version'],
'source' => $result['update_source'],
]));
} else {
$this->info('✅ '.__('plugins.commands.check_updates.single_up_to_date', [
'plugin' => $identifier,
'version' => $result['current_version'],
]));
}
return Command::SUCCESS;
}
/**
* 모든 설치된 플러그인의 업데이트를 확인합니다.
*/
private function checkAll(): int
{
$installedPlugins = $this->pluginManager->getInstalledPluginsWithDetails();
if (empty($installedPlugins)) {
$this->info(__('plugins.commands.check_updates.no_installed'));
return Command::SUCCESS;
}
// checkAllPluginsForUpdates는 내부에서 DB 갱신 포함
$result = $this->pluginManager->checkAllPluginsForUpdates();
$tableData = [];
$updateCount = 0;
foreach ($result['details'] as $detail) {
$isUpdate = $detail['update_available'] ?? false;
if ($isUpdate) {
$updateCount++;
}
$tableData[] = [
$detail['identifier'],
$detail['current_version'] ?? '-',
$detail['latest_version'] ?? '-',
$detail['update_source'] ?? '-',
$isUpdate
? '🔄 '.__('plugins.commands.check_updates.update_available')
: '✅ '.__('plugins.commands.check_updates.up_to_date'),
];
}
$headers = [
__('plugins.commands.check_updates.headers.identifier'),
__('plugins.commands.check_updates.headers.current_version'),
__('plugins.commands.check_updates.headers.latest_version'),
__('plugins.commands.check_updates.headers.source'),
__('plugins.commands.check_updates.headers.status'),
];
$this->table($headers, $tableData);
$this->newLine();
$this->info(__('plugins.commands.check_updates.summary', [
'total' => count($result['details']),
'updates' => $updateCount,
]));
return Command::SUCCESS;
}
}
@@ -0,0 +1,131 @@
<?php
namespace App\Console\Commands\Plugin;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Extension\PluginManager;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class ClearPluginCacheCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'plugin:cache-clear
{identifier? : 특정 플러그인의 캐시만 삭제 (생략 시 모든 플러그인)}';
/**
* The console command description.
*/
protected $description = '플러그인 캐시를 삭제합니다';
/**
* 플러그인 관리자 및 리포지토리
*/
public function __construct(
private PluginManager $pluginManager,
private PluginRepositoryInterface $pluginRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$identifier = $this->argument('identifier');
try {
// 플러그인 디렉토리 스캔 및 로드
$this->pluginManager->loadPlugins();
$clearedCount = 0;
if ($identifier) {
// 특정 플러그인 캐시 삭제
$this->info(__('plugins.commands.cache_clear.clearing_single', ['plugin' => $identifier]));
// 플러그인이 존재하는지 확인
$plugin = $this->pluginManager->getPlugin($identifier);
if (! $plugin) {
$this->error('❌ '.__('plugins.not_found', ['plugin' => $identifier]));
return Command::FAILURE;
}
$clearedCount = $this->clearPluginCache($identifier);
$this->info('✅ '.__('plugins.commands.cache_clear.success_single', [
'plugin' => $identifier,
'count' => $clearedCount,
]));
} else {
// 모든 플러그인 캐시 삭제
$this->info(__('plugins.commands.cache_clear.clearing_all'));
// 전체 플러그인 캐시 키 삭제
$cacheKeys = [
'plugins.all',
'plugins.active',
'plugins.installed',
];
foreach ($cacheKeys as $key) {
if (Cache::forget($key)) {
$clearedCount++;
}
}
// 각 플러그인별 캐시 삭제
$allPlugins = $this->pluginManager->getAllPlugins();
foreach ($allPlugins as $pluginName => $plugin) {
$clearedCount += $this->clearPluginCache($plugin->getIdentifier());
}
$this->info('✅ '.__('plugins.commands.cache_clear.success_all', ['count' => $clearedCount]));
}
Log::info('플러그인 캐시 삭제 완료', [
'plugin' => $identifier ?? 'all',
'count' => $clearedCount,
]);
return Command::SUCCESS;
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
Log::error('플러그인 캐시 삭제 실패', [
'plugin' => $identifier ?? 'all',
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
/**
* 특정 플러그인의 캐시를 삭제합니다.
*/
private function clearPluginCache(string $identifier): int
{
$clearedCount = 0;
// 플러그인별 캐시 키 (메뉴 캐시 없음)
$cacheKeys = [
"plugin.config.{$identifier}",
"plugin.info.{$identifier}",
"plugin.routes.{$identifier}",
"plugin.permissions.{$identifier}",
];
foreach ($cacheKeys as $key) {
if (Cache::forget($key)) {
$clearedCount++;
}
}
return $clearedCount;
}
}
@@ -0,0 +1,184 @@
<?php
namespace App\Console\Commands\Plugin;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Extension\ExtensionManager;
use App\Extension\PluginManager;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class ComposerInstallPluginCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'plugin:composer-install
{identifier? : Composer 의존성을 설치할 플러그인 식별자 (생략 시 --all 필요)}
{--all : 모든 플러그인의 Composer 의존성 설치}
{--no-dev : dev 의존성 제외}';
/**
* The console command description.
*/
protected $description = '플러그인의 Composer 의존성을 설치합니다';
/**
* 플러그인 관리자 및 리포지토리
*/
public function __construct(
private PluginManager $pluginManager,
private ExtensionManager $extensionManager,
private PluginRepositoryInterface $pluginRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$identifier = $this->argument('identifier');
$installAll = $this->option('all');
$noDev = $this->option('no-dev');
if (! $identifier && ! $installAll) {
$this->error('❌ 플러그인 식별자를 지정하거나 --all 옵션을 사용하세요.');
$this->line('');
$this->line('사용법:');
$this->line(' php artisan plugin:composer-install sirsoft-payment');
$this->line(' php artisan plugin:composer-install --all');
$this->line(' php artisan plugin:composer-install --all --no-dev');
return Command::FAILURE;
}
try {
$this->pluginManager->loadPlugins();
if ($installAll) {
return $this->installAllPlugins($noDev);
}
return $this->installPlugin($identifier, $noDev);
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
Log::error('플러그인 Composer 의존성 설치 실패', [
'plugin' => $identifier ?? 'all',
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
/**
* 단일 플러그인의 Composer 의존성 설치
*
* @param string $identifier 플러그인 식별자
* @param bool $noDev dev 의존성 제외 여부
* @return int 커맨드 결과 코드
*/
private function installPlugin(string $identifier, bool $noDev): int
{
$plugin = $this->pluginManager->getPlugin($identifier);
if (! $plugin) {
$this->error('❌ '.__('plugins.errors.not_found', ['plugin' => $identifier]));
return Command::FAILURE;
}
if (! $this->extensionManager->hasComposerDependencies('plugins', $identifier)) {
$this->info('ℹ️ '.__('plugins.composer_install.no_dependencies', ['plugin' => $identifier]));
return Command::SUCCESS;
}
$deps = $this->extensionManager->getComposerDependencies('plugins', $identifier);
$this->info('📦 '.__('plugins.composer_install.start', ['plugin' => $identifier]));
$this->line(' '.implode(', ', array_keys($deps)));
$result = $this->extensionManager->runComposerInstall('plugins', $identifier, $noDev, $this);
if ($result) {
$this->info('✅ '.__('plugins.composer_install.success', ['plugin' => $identifier]));
// 오토로드 갱신
$this->extensionManager->updateComposerAutoload();
$this->line(' 오토로드 캐시 갱신 완료');
return Command::SUCCESS;
}
$this->error('❌ '.__('plugins.composer_install.failed', ['plugin' => $identifier]));
return Command::FAILURE;
}
/**
* 모든 플러그인의 Composer 의존성 설치
*
* @param bool $noDev dev 의존성 제외 여부
* @return int 커맨드 결과 코드
*/
private function installAllPlugins(bool $noDev): int
{
$plugins = $this->pluginManager->getAllPlugins();
if (empty($plugins)) {
$this->warn('⚠️ 설치된 플러그인이 없습니다.');
return Command::SUCCESS;
}
// 중복 패키지 감지
$duplicates = $this->extensionManager->detectDuplicatePackages();
if (! empty($duplicates)) {
$this->warn('⚠️ 중복 패키지 감지:');
foreach ($duplicates as $package => $users) {
$this->warn(" - {$package}: ".implode(', ', $users));
}
$this->line('');
}
$this->info('📦 모든 플러그인의 Composer 의존성 설치 시작');
$successCount = 0;
$skipCount = 0;
$failCount = 0;
foreach ($plugins as $identifier => $plugin) {
if (! $this->extensionManager->hasComposerDependencies('plugins', $identifier)) {
$skipCount++;
continue;
}
$this->line(" [{$identifier}]");
$result = $this->extensionManager->runComposerInstall('plugins', $identifier, $noDev, $this);
if ($result) {
$successCount++;
$this->info(" ✅ 완료: {$identifier}");
} else {
$failCount++;
$this->error(" ❌ 실패: {$identifier}");
}
$this->line('');
}
// 오토로드 갱신
$this->extensionManager->updateComposerAutoload();
$this->info(__('plugins.composer_install.summary', [
'success' => $successCount,
'skip' => $skipCount,
'fail' => $failCount,
]));
return $failCount > 0 ? Command::FAILURE : Command::SUCCESS;
}
}
@@ -0,0 +1,118 @@
<?php
namespace App\Console\Commands\Plugin;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Enums\ExtensionStatus;
use App\Extension\PluginManager;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class DeactivatePluginCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'plugin:deactivate {identifier : 비활성화할 플러그인 식별자} {--force : 의존 확장이 있어도 강제 비활성화}';
/**
* The console command description.
*/
protected $description = '플러그인을 비활성화합니다';
/**
* 플러그인 관리자 및 리포지토리
*/
public function __construct(
private PluginManager $pluginManager,
private PluginRepositoryInterface $pluginRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$identifier = $this->argument('identifier');
try {
// 플러그인 디렉토리 스캔 및 로드
$this->pluginManager->loadPlugins();
// 플러그인이 설치되어 있는지 확인
$pluginRecord = $this->pluginRepository->findByIdentifier($identifier);
if (! $pluginRecord) {
$this->error('❌ '.__('plugins.commands.deactivate.not_installed', ['plugin' => $identifier]));
return Command::FAILURE;
}
// 플러그인이 활성화되어 있는지 확인
if ($pluginRecord->status !== ExtensionStatus::Active->value) {
$this->warn('⚠️ '.__('plugins.commands.deactivate.not_active', ['plugin' => $identifier]));
return Command::FAILURE;
}
// 플러그인 비활성화
$force = $this->option('force');
$result = $this->pluginManager->deactivatePlugin($identifier, $force);
// 경고 응답인 경우 (의존 확장 존재)
if (isset($result['warning']) && $result['warning'] === true) {
$this->warn('⚠️ '.$result['message']);
$this->line('');
// 의존하는 템플릿 목록
if (! empty($result['dependent_templates'])) {
$this->info('의존하는 템플릿 목록:');
foreach ($result['dependent_templates'] as $template) {
$this->line(" - {$template['identifier']} ({$template['name']})");
}
}
// 의존하는 모듈 목록
if (! empty($result['dependent_modules'])) {
$this->info('의존하는 모듈 목록:');
foreach ($result['dependent_modules'] as $module) {
$this->line(" - {$module['identifier']} ({$module['name']})");
}
}
// 의존하는 플러그인 목록
if (! empty($result['dependent_plugins'])) {
$this->info('의존하는 플러그인 목록:');
foreach ($result['dependent_plugins'] as $plugin) {
$this->line(" - {$plugin['identifier']} ({$plugin['name']})");
}
}
$this->line('');
$this->info('강제로 비활성화하려면 --force 옵션을 사용하세요.');
return Command::FAILURE;
}
if ($result['success']) {
$this->info('✅ '.__('plugins.commands.deactivate.success', ['plugin' => $identifier]));
$this->info(' - '.__('plugins.commands.deactivate.layouts_deleted', ['count' => $result['layouts_deleted']]));
$this->warn('⚠️ '.__('plugins.commands.deactivate.warning'));
Log::info(__('plugins.commands.deactivate.success', ['plugin' => $identifier]));
return Command::SUCCESS;
}
return Command::FAILURE;
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
Log::error('플러그인 비활성화 실패', [
'plugin' => $identifier,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -0,0 +1,117 @@
<?php
namespace App\Console\Commands\Plugin;
use App\Console\Commands\Traits\HasProgressBar;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Enums\ExtensionOwnerType;
use App\Extension\PluginManager;
use App\Models\Permission;
use App\Rules\ValidExtensionIdentifier;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
class InstallPluginCommand extends Command
{
use HasProgressBar;
/**
* The name and signature of the console command.
*/
protected $signature = 'plugin:install {identifier : 설치할 플러그인 식별자}';
/**
* The console command description.
*/
protected $description = '플러그인을 설치합니다';
/**
* 플러그인 관리자 및 리포지토리
*/
public function __construct(
private PluginManager $pluginManager,
private PluginRepositoryInterface $pluginRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$identifier = $this->argument('identifier');
// 식별자 형식 검증
$validator = Validator::make(
['identifier' => $identifier],
['identifier' => [new ValidExtensionIdentifier]]
);
if ($validator->fails()) {
$this->error('❌ '.$validator->errors()->first('identifier'));
return Command::FAILURE;
}
try {
// 플러그인 디렉토리 스캔 및 로드
$this->pluginManager->loadPlugins();
// 이미 설치된 플러그인인지 확인
$existingPlugin = $this->pluginRepository->findByIdentifier($identifier);
if ($existingPlugin) {
$this->warn('⚠️ '.__('plugins.commands.install.already_installed', ['plugin' => $identifier]));
return Command::FAILURE;
}
// 플러그인 설치
$onProgress = $this->createProgressCallback(PluginManager::INSTALL_STEPS);
try {
$result = $this->pluginManager->installPlugin($identifier, $onProgress);
$this->finishProgress();
} catch (\Exception $e) {
$this->finishProgress();
throw $e;
}
if ($result) {
// 설치된 플러그인 정보 조회
$plugin = $this->pluginRepository->findByIdentifier($identifier);
// 플러그인 인스턴스에서 생성된 role 개수 조회
$pluginInstance = $this->pluginManager->getPlugin($identifier);
$rolesCount = 0;
if ($pluginInstance && method_exists($pluginInstance, 'getRoles')) {
$rolesCount = count($pluginInstance->getRoles());
}
// 권한 개수 조회
$permissionsCount = Permission::byExtension(ExtensionOwnerType::Plugin, $identifier)->count();
// 성공 메시지
$this->info('✅ '.__('plugins.commands.install.success', ['plugin' => $identifier]));
$this->info(' - '.__('plugins.commands.install.vendor', ['vendor' => $plugin->vendor]));
$this->info(' - '.__('plugins.commands.install.version', ['version' => $plugin->version]));
$this->info(' - '.__('plugins.commands.install.roles_created', ['count' => $rolesCount]));
$this->info(' - '.__('plugins.commands.install.permissions_created', ['count' => $permissionsCount]));
Log::info(__('plugins.commands.install.success', ['plugin' => $identifier]));
return Command::SUCCESS;
}
return Command::FAILURE;
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
Log::error('플러그인 설치 실패', [
'plugin' => $identifier,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -0,0 +1,150 @@
<?php
namespace App\Console\Commands\Plugin;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Enums\ExtensionStatus;
use App\Extension\PluginManager;
use Illuminate\Console\Command;
class ListPluginCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'plugin:list
{--status= : 상태로 필터 (installed, uninstalled, active, inactive)}';
/**
* The console command description.
*/
protected $description = '플러그인 목록을 조회합니다';
/**
* 플러그인 관리자 및 리포지토리
*/
public function __construct(
private PluginManager $pluginManager,
private PluginRepositoryInterface $pluginRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
// 플러그인 디렉토리 스캔 및 로드
$this->pluginManager->loadPlugins();
$statusFilter = $this->option('status');
// 상태 필터 검증
if ($statusFilter && ! in_array($statusFilter, ['installed', 'uninstalled', 'active', 'inactive'])) {
$this->error('❌ '.__('plugins.commands.list.invalid_status'));
return Command::FAILURE;
}
// 설치된 플러그인 정보
$installedPlugins = $this->pluginManager->getInstalledPluginsWithDetails();
// 미설치 플러그인 정보
$uninstalledPlugins = $this->pluginManager->getUninstalledPlugins();
// 테이블 데이터 준비
$tableData = [];
// 설치된 플러그인 추가
foreach ($installedPlugins as $identifier => $plugin) {
// 상태 필터
if ($statusFilter) {
if ($statusFilter === 'uninstalled') {
continue;
}
if ($statusFilter === 'active' && $plugin['status'] !== ExtensionStatus::Active->value) {
continue;
}
if ($statusFilter === 'inactive' && $plugin['status'] !== ExtensionStatus::Inactive->value) {
continue;
}
if ($statusFilter === 'installed' && ! in_array($plugin['status'], [ExtensionStatus::Active->value, ExtensionStatus::Inactive->value])) {
continue;
}
}
$tableData[] = [
'identifier' => $identifier,
'name' => $plugin['name'],
'vendor' => $plugin['vendor'],
'version' => $plugin['version'],
'status' => $this->formatStatus($plugin['status']),
];
}
// 미설치 플러그인 추가
if (! $statusFilter || $statusFilter === 'uninstalled') {
foreach ($uninstalledPlugins as $identifier => $plugin) {
// 상태 필터 (uninstalled 또는 필터 없음)
if ($statusFilter && $statusFilter !== 'uninstalled') {
continue;
}
$tableData[] = [
'identifier' => $identifier,
'name' => $plugin['name'],
'vendor' => $plugin['vendor'],
'version' => $plugin['version'],
'status' => $this->formatStatus('uninstalled'),
];
}
}
// 플러그인이 없는 경우
if (empty($tableData)) {
$this->info(__('plugins.commands.list.no_plugins'));
return Command::SUCCESS;
}
// 테이블 헤더
$headers = [
__('plugins.commands.list.headers.identifier'),
__('plugins.commands.list.headers.name'),
__('plugins.commands.list.headers.vendor'),
__('plugins.commands.list.headers.version'),
__('plugins.commands.list.headers.status'),
];
// 테이블 출력
$this->table($headers, $tableData);
// 요약 정보
$totalCount = count($tableData);
$activeCount = count(array_filter($tableData, fn ($p) => str_contains($p['status'], __('plugins.commands.list.status.active'))));
$installedCount = count(array_filter($tableData, fn ($p) => ! str_contains($p['status'], __('plugins.commands.list.status.uninstalled'))));
$this->newLine();
$this->info(__('plugins.commands.list.summary', [
'total' => $totalCount,
'installed' => $installedCount,
'active' => $activeCount,
]));
return Command::SUCCESS;
}
/**
* 상태 포맷팅
*/
private function formatStatus(string $status): string
{
return match ($status) {
ExtensionStatus::Active->value => '✅ '.__('plugins.commands.list.status.active'),
ExtensionStatus::Inactive->value => '⏸️ '.__('plugins.commands.list.status.inactive'),
'uninstalled' => '📦 '.__('plugins.commands.list.status.uninstalled'),
default => $status,
};
}
}
@@ -0,0 +1,130 @@
<?php
namespace App\Console\Commands\Plugin;
use App\Models\Plugin;
use App\Services\PluginService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
/**
* 플러그인 레이아웃 갱신 커맨드
*
* 레이아웃 JSON 파일만 수정된 경우, 빌드 없이 레이아웃만 갱신합니다.
*/
class RefreshPluginLayoutCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'plugin:refresh-layout
{identifier? : 특정 플러그인의 식별자 (생략 시 모든 활성 플러그인)}';
/**
* The console command description.
*/
protected $description = '플러그인 레이아웃을 갱신합니다 (빌드 없이 JSON만 DB에 동기화)';
public function __construct(
private PluginService $pluginService
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$identifier = $this->argument('identifier');
try {
if ($identifier) {
return $this->refreshSingle($identifier);
}
return $this->refreshAll();
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
Log::error('플러그인 레이아웃 갱신 실패', [
'plugin' => $identifier ?? 'all',
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
/**
* 특정 플러그인의 레이아웃을 갱신합니다.
*/
private function refreshSingle(string $identifier): int
{
$this->info("플러그인 '{$identifier}' 레이아웃 갱신 중...");
$result = $this->pluginService->refreshPluginLayouts($identifier);
if ($result['success'] ?? false) {
$this->info("✅ 플러그인 '{$identifier}' 레이아웃 갱신 완료");
$this->line(" 생성: {$result['created']}, 수정: {$result['updated']}, 삭제: {$result['deleted']}");
Log::info('플러그인 레이아웃 갱신 완료', [
'plugin' => $identifier,
'stats' => $result,
]);
return Command::SUCCESS;
}
$this->error("❌ 플러그인 '{$identifier}' 레이아웃 갱신 실패");
return Command::FAILURE;
}
/**
* 모든 활성 플러그인의 레이아웃을 갱신합니다.
*/
private function refreshAll(): int
{
$plugins = Plugin::where('status', 'active')->pluck('identifier')->toArray();
if (empty($plugins)) {
$this->line('활성화된 플러그인이 없습니다.');
return Command::SUCCESS;
}
$this->info('모든 활성 플러그인 레이아웃 갱신 중...');
$successCount = 0;
$failCount = 0;
foreach ($plugins as $pluginIdentifier) {
$this->line(" {$pluginIdentifier} 갱신 중...");
try {
$result = $this->pluginService->refreshPluginLayouts($pluginIdentifier);
if ($result['success'] ?? false) {
$this->info(" ✅ {$pluginIdentifier} 완료");
$successCount++;
} else {
$this->warn(" ⚠️ {$pluginIdentifier} 실패");
$failCount++;
}
} catch (\Exception $e) {
$this->warn(" ⚠️ {$pluginIdentifier}: ".$e->getMessage());
$failCount++;
}
}
$this->newLine();
$this->info("✅ 총 {$successCount}개 성공, {$failCount}개 실패");
Log::info('플러그인 레이아웃 일괄 갱신 완료', [
'success' => $successCount,
'failed' => $failCount,
]);
return $failCount > 0 ? Command::FAILURE : Command::SUCCESS;
}
}
@@ -0,0 +1,257 @@
<?php
namespace App\Console\Commands\Plugin;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Enums\ExtensionStatus;
use App\Extension\ExtensionManager;
use App\Extension\PluginManager;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class SeedPluginCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'plugin:seed
{identifier? : 시더를 실행할 플러그인 식별자 (생략 시 모든 활성 플러그인)}
{--class= : 실행할 특정 시더 클래스명}
{--count=* : 시더에 전달할 카운트 옵션 (형식: key=value, 예: --count=products=1000)}
{--sample : 샘플 데이터 시더도 함께 실행}
{--force : 프로덕션 환경에서도 강제 실행}';
/**
* The console command description.
*/
protected $description = '플러그인의 데이터베이스 시더를 실행합니다';
/**
* 플러그인 관리자 및 리포지토리
*/
public function __construct(
private PluginManager $pluginManager,
private PluginRepositoryInterface $pluginRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$identifier = $this->argument('identifier');
$seederClass = $this->option('class');
$force = $this->option('force');
// 프로덕션 환경 확인
if (app()->environment('production') && ! $force) {
$this->error('❌ 프로덕션 환경에서는 --force 옵션이 필요합니다.');
return Command::FAILURE;
}
try {
// 플러그인 로드
$this->pluginManager->loadPlugins();
if ($identifier) {
return $this->seedPlugin($identifier, $seederClass);
}
return $this->seedAllActivePlugins($seederClass);
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
Log::error('플러그인 시더 실행 실패', [
'plugin' => $identifier ?? 'all',
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
/**
* 단일 플러그인 시더 실행
*
* @param string $identifier 플러그인 식별자
* @param string|null $seederClass 특정 시더 클래스명
* @return int 명령 실행 결과
*/
private function seedPlugin(string $identifier, ?string $seederClass): int
{
// 플러그인 존재 여부 확인
$plugin = $this->pluginManager->getPlugin($identifier);
if (! $plugin) {
$this->error('❌ '.__('plugins.not_found', ['plugin' => $identifier]));
return Command::FAILURE;
}
// 활성화 상태 확인
$pluginRecord = $this->pluginRepository->findByIdentifier($identifier);
if (! $pluginRecord || $pluginRecord->status !== ExtensionStatus::Active->value) {
$this->error('❌ 플러그인이 활성화되어 있지 않습니다: '.$identifier);
$this->line(' 먼저 php artisan plugin:activate '.$identifier.' 명령을 실행하세요.');
return Command::FAILURE;
}
// 시더 경로 확인
$pluginPath = base_path("plugins/{$identifier}");
$seederPath = $pluginPath.'/database/seeders';
if (! is_dir($seederPath)) {
$this->warn('⚠️ 플러그인에 시더 디렉토리가 없습니다: '.$identifier);
return Command::SUCCESS;
}
// 시더 클래스 결정
$seederClassName = $this->resolveSeederClass($identifier, $seederClass);
if (! class_exists($seederClassName)) {
$this->error('❌ 시더 클래스를 찾을 수 없습니다: '.$seederClassName);
return Command::FAILURE;
}
$this->info("🌱 시더 실행 시작: {$identifier}");
$this->line(' 클래스: '.$seederClassName);
$this->line('');
// 시더 실행
$seeder = app($seederClassName);
$seeder->setCommand($this);
// --sample 옵션 전파
if (method_exists($seeder, 'setIncludeSample')) {
$seeder->setIncludeSample((bool) $this->option('sample'));
}
// --count 옵션 전파
$counts = $this->parseCountOptions();
if (! empty($counts) && method_exists($seeder, 'setSeederCounts')) {
$seeder->setSeederCounts($counts);
}
$seeder->run();
$this->line('');
$this->info("✅ 시더 실행 완료: {$identifier}");
return Command::SUCCESS;
}
/**
* 모든 활성 플러그인 시더 실행
*
* @param string|null $seederClass 특정 시더 클래스명
* @return int 명령 실행 결과
*/
private function seedAllActivePlugins(?string $seederClass): int
{
$activePlugins = $this->pluginManager->getActivePlugins();
if (empty($activePlugins)) {
$this->warn('⚠️ 활성화된 플러그인이 없습니다.');
return Command::SUCCESS;
}
// 시더가 있는 플러그인만 필터링
$seedablePlugins = [];
foreach ($activePlugins as $identifier => $plugin) {
$seederPath = base_path("plugins/{$identifier}/database/seeders");
$seederClassName = $this->resolveSeederClass($identifier, $seederClass);
if (is_dir($seederPath) && class_exists($seederClassName)) {
$seedablePlugins[$identifier] = $plugin;
}
}
if (empty($seedablePlugins)) {
$this->warn('⚠️ 시더가 있는 활성 플러그인이 없습니다.');
return Command::SUCCESS;
}
$this->info('🌱 모든 활성 플러그인 시더 실행 시작');
$this->line(' 대상 플러그인: '.implode(', ', array_keys($seedablePlugins)));
$this->line('');
$successCount = 0;
$failCount = 0;
foreach ($seedablePlugins as $identifier => $plugin) {
$this->line("━━━ [{$identifier}] ━━━");
$result = $this->seedPlugin($identifier, $seederClass);
if ($result === Command::SUCCESS) {
$successCount++;
} else {
$failCount++;
}
$this->line('');
}
$this->info("📊 시더 실행 결과: 성공 {$successCount}개, 실패 {$failCount}개");
return $failCount > 0 ? Command::FAILURE : Command::SUCCESS;
}
/**
* --count 옵션을 파싱하여 연관 배열로 반환합니다.
*
* 입력: ['products=1000', 'orders=500']
* 출력: ['products' => 1000, 'orders' => 500]
*
* @return array<string, int>
*/
public function parseCountOptions(): array
{
$countOptions = $this->option('count');
$counts = [];
foreach ($countOptions as $option) {
if (str_contains($option, '=')) {
[$key, $value] = explode('=', $option, 2);
$key = trim($key);
if ($key !== '') {
$counts[$key] = (int) trim($value);
}
}
}
return $counts;
}
/**
* 시더 클래스명 해석
*
* @param string $identifier 플러그인 식별자
* @param string|null $seederClass 사용자 지정 시더 클래스명
* @return string 완전한 시더 클래스명
*/
private function resolveSeederClass(string $identifier, ?string $seederClass): string
{
$namespace = ExtensionManager::pluginIdentifierToNamespace($identifier);
if ($seederClass) {
// 사용자가 지정한 클래스명 사용
if (str_contains($seederClass, '\\')) {
return $seederClass;
}
return $namespace.'Database\\Seeders\\'.$seederClass;
}
// 기본 DatabaseSeeder 사용
return $namespace.'Database\\Seeders\\DatabaseSeeder';
}
}
@@ -0,0 +1,121 @@
<?php
namespace App\Console\Commands\Plugin;
use App\Console\Commands\Traits\HasProgressBar;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Enums\ExtensionOwnerType;
use App\Extension\PluginManager;
use App\Models\Permission;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class UninstallPluginCommand extends Command
{
use HasProgressBar;
/**
* The name and signature of the console command.
*/
protected $signature = 'plugin:uninstall
{identifier : 제거할 플러그인 식별자}
{--force : 확인 없이 삭제}
{--delete-data : 플러그인 데이터(테이블, 환경설정) 함께 삭제}';
/**
* The console command description.
*/
protected $description = '플러그인을 제거합니다';
/**
* 플러그인 관리자 및 리포지토리
*/
public function __construct(
private PluginManager $pluginManager,
private PluginRepositoryInterface $pluginRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$identifier = $this->argument('identifier');
try {
// 플러그인 디렉토리 스캔 및 로드
$this->pluginManager->loadPlugins();
// 플러그인이 설치되어 있는지 확인
$pluginRecord = $this->pluginRepository->findByIdentifier($identifier);
if (! $pluginRecord) {
$this->error('❌ '.__('plugins.commands.uninstall.not_installed', ['plugin' => $identifier]));
return Command::FAILURE;
}
// 플러그인 인스턴스에서 role 개수 조회
$pluginInstance = $this->pluginManager->getPlugin($identifier);
$rolesCount = 0;
if ($pluginInstance && method_exists($pluginInstance, 'getRoles')) {
$rolesCount = count($pluginInstance->getRoles());
}
// 권한, 레이아웃 개수 조회
$permissionsCount = Permission::byExtension(ExtensionOwnerType::Plugin, $identifier)->count();
$layoutsCount = $this->pluginManager->getPluginLayoutsCount($identifier);
// 확인 프롬프트 (--force 옵션이 없는 경우)
if (! $this->option('force')) {
$this->warn(__('plugins.commands.uninstall.confirm_prompt', ['plugin' => $identifier]));
$this->line(__('plugins.commands.uninstall.confirm_details.roles', ['count' => $rolesCount]));
$this->line(__('plugins.commands.uninstall.confirm_details.permissions', ['count' => $permissionsCount]));
$this->line(__('plugins.commands.uninstall.confirm_details.layouts', ['count' => $layoutsCount]));
$this->line(__('plugins.commands.uninstall.confirm_details.data'));
if ($this->option('delete-data')) {
$this->warn('⚠️ --delete-data: 마이그레이션 롤백 및 환경설정 파일이 함께 삭제됩니다.');
}
$this->newLine();
if (! $this->confirm(__('plugins.commands.uninstall.confirm_question'), false)) {
$this->info(__('plugins.commands.uninstall.aborted'));
return Command::SUCCESS;
}
}
// 플러그인 제거
$deleteData = $this->option('delete-data');
$onProgress = $this->createProgressCallback(PluginManager::UNINSTALL_STEPS);
try {
$result = $this->pluginManager->uninstallPlugin($identifier, $deleteData, $onProgress);
$this->finishProgress();
} catch (\Exception $e) {
$this->finishProgress();
throw $e;
}
if ($result) {
$this->info('✅ '.__('plugins.commands.uninstall.success', ['plugin' => $identifier]));
$this->info(' - '.__('plugins.commands.uninstall.roles_deleted', ['count' => $rolesCount]));
$this->info(' - '.__('plugins.commands.uninstall.permissions_deleted', ['count' => $permissionsCount]));
$this->info(' - '.__('plugins.commands.uninstall.layouts_deleted', ['count' => $layoutsCount]));
Log::info(__('plugins.commands.uninstall.success', ['plugin' => $identifier]));
return Command::SUCCESS;
}
return Command::FAILURE;
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
Log::error('플러그인 제거 실패', [
'plugin' => $identifier,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -0,0 +1,125 @@
<?php
namespace App\Console\Commands\Plugin;
use App\Console\Commands\Traits\HasProgressBar;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Extension\PluginManager;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class UpdatePluginCommand extends Command
{
use HasProgressBar;
/**
* The name and signature of the console command.
*/
protected $signature = 'plugin:update
{identifier : 업데이트할 플러그인 식별자}
{--force : 버전 비교 없이 강제 업데이트}';
/**
* The console command description.
*/
protected $description = '플러그인을 최신 버전으로 업데이트합니다';
/**
* 플러그인 관리자 및 리포지토리
*/
public function __construct(
private PluginManager $pluginManager,
private PluginRepositoryInterface $pluginRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$identifier = $this->argument('identifier');
$force = $this->option('force');
try {
$this->pluginManager->loadPlugins();
// 플러그인 존재 확인
$plugin = $this->pluginRepository->findByIdentifier($identifier);
if (! $plugin) {
$this->error('❌ '.__('plugins.commands.update.not_installed', ['plugin' => $identifier]));
return Command::FAILURE;
}
// 업데이트 확인
$checkResult = $this->pluginManager->checkPluginUpdate($identifier);
if (! $checkResult['update_available'] && ! $force) {
$this->info('✅ '.__('plugins.commands.update.no_update', ['plugin' => $identifier]));
return Command::SUCCESS;
}
// 업데이트 정보 표시
$this->info(__('plugins.commands.update.current_version', ['version' => $checkResult['current_version']]));
if ($force && ! $checkResult['update_available']) {
$this->warn('⚠️ '.__('plugins.commands.update.force_mode'));
} else {
$this->info(__('plugins.commands.update.latest_version', ['version' => $checkResult['latest_version']]));
$this->info(__('plugins.commands.update.update_source', ['source' => $checkResult['update_source']]));
}
$this->newLine();
// 확인 프롬프트 (--force 시 건너뜀)
if (! $force && ! $this->confirm(__('plugins.commands.update.confirm_question'), false)) {
$this->info(__('plugins.commands.update.aborted'));
return Command::SUCCESS;
}
// 업데이트 실행
$onProgress = $this->createProgressCallback(PluginManager::UPDATE_STEPS);
try {
$updateResult = $this->pluginManager->updatePlugin($identifier, $force, $onProgress);
$this->finishProgress();
} catch (\Exception $e) {
$this->finishProgress();
throw $e;
}
if ($updateResult['success']) {
$this->newLine();
$this->info('✅ '.__('plugins.commands.update.success', ['plugin' => $identifier]));
$this->info(' '.__('plugins.commands.update.version_change', [
'from' => $updateResult['from_version'],
'to' => $updateResult['to_version'],
]));
Log::info('플러그인 업데이트 완료', [
'plugin' => $identifier,
'from' => $updateResult['from_version'],
'to' => $updateResult['to_version'],
]);
return Command::SUCCESS;
}
return Command::FAILURE;
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
$this->warn('💡 '.__('plugins.commands.update.backup_restored'));
Log::error('플러그인 업데이트 실패', [
'plugin' => $identifier,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace App\Console\Commands;
use Illuminate\Database\Console\Seeds\SeedCommand as BaseSeedCommand;
use Symfony\Component\Console\Input\InputOption;
/**
* db:seed 커맨드 확장
*
* Laravel 기본 db:seed 커맨드에 --count 옵션을 추가하여
* 시더에 데이터 개수를 전달할 수 있게 합니다.
*
* 사용 예시:
* php artisan db:seed --class=SomeSeeder --count=items=50000
*/
class SeedCommand extends BaseSeedCommand
{
/**
* 시더 인스턴스를 컨테이너에서 생성합니다.
*
* 부모 메서드를 호출한 뒤, --count 옵션이 있으면
* 시더에 setSeederCounts()로 전달합니다.
*
* @return \Illuminate\Database\Seeder
*/
protected function getSeeder()
{
$seeder = parent::getSeeder();
// --sample 옵션 전파
if (method_exists($seeder, 'setIncludeSample')) {
$seeder->setIncludeSample((bool) $this->option('sample'));
}
// --count 옵션 전파
$counts = $this->parseCountOptions();
if (! empty($counts) && method_exists($seeder, 'setSeederCounts')) {
$seeder->setSeederCounts($counts);
}
return $seeder;
}
/**
* --count 옵션을 파싱하여 연관 배열로 반환합니다.
*
* 입력: ['products=1000', 'orders=500']
* 출력: ['products' => 1000, 'orders' => 500]
*
* @return array<string, int>
*/
public function parseCountOptions(): array
{
$countOptions = $this->option('count');
$counts = [];
foreach ($countOptions as $option) {
if (str_contains($option, '=')) {
[$key, $value] = explode('=', $option, 2);
$key = trim($key);
if ($key !== '') {
$counts[$key] = (int) trim($value);
}
}
}
return $counts;
}
/**
* 콘솔 커맨드 옵션을 정의합니다.
*
* 부모 옵션에 --count 옵션을 추가합니다.
*
* @return array
*/
protected function getOptions()
{
return array_merge(parent::getOptions(), [
['count', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, '시더에 전달할 카운트 옵션 (형식: key=value, 예: --count=products=1000)', []],
['sample', null, InputOption::VALUE_NONE, '샘플 데이터 시더도 함께 실행'],
]);
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Console\Commands;
use App\Seo\Contracts\SeoCacheManagerInterface;
use Illuminate\Console\Command;
/**
* SEO 캐시 삭제 Artisan 커맨드
*
* 전체 또는 특정 레이아웃의 SEO 캐시를 삭제합니다.
*/
class SeoCacheClearCommand extends Command
{
/**
* @var string 커맨드 시그니처
*/
protected $signature = 'seo:clear {--layout= : 특정 레이아웃의 캐시만 삭제}';
/**
* @var string 커맨드 설명
*/
protected $description = 'SEO 캐시 삭제';
/**
* 커맨드를 실행합니다.
*
* @param SeoCacheManagerInterface $cacheManager SEO 캐시 매니저
* @return int 종료 코드
*/
public function handle(SeoCacheManagerInterface $cacheManager): int
{
$layout = $this->option('layout');
if ($layout) {
$count = $cacheManager->invalidateByLayout($layout);
$this->info(__('seo.cache_cleared_layout', [
'layout' => $layout,
'count' => $count,
]));
} else {
$cacheManager->clearAll();
$this->info(__('seo.cache_cleared_all'));
}
return Command::SUCCESS;
}
}
@@ -0,0 +1,94 @@
<?php
namespace App\Console\Commands;
use App\Seo\SeoCacheStatsService;
use Carbon\Carbon;
use Illuminate\Console\Command;
/**
* SEO 캐시 통계 출력 Artisan 커맨드
*
* 지정된 기간 동안의 캐시 히트/미스 통계를 테이블 형식으로 출력합니다.
*/
class SeoCacheStatsCommand extends Command
{
/**
* @var string 커맨드 시그니처
*/
protected $signature = 'seo:stats {--days=7 : 통계 기간 (일)}';
/**
* @var string 커맨드 설명
*/
protected $description = 'SEO 캐시 통계 출력';
/**
* 커맨드를 실행합니다.
*
* @param SeoCacheStatsService $statsService SEO 캐시 통계 서비스
* @return int 종료 코드
*/
public function handle(SeoCacheStatsService $statsService): int
{
$days = (int) $this->option('days');
$since = Carbon::now()->subDays($days);
$this->info(__('seo.stats_period', ['days' => $days]));
$this->newLine();
// 전체 통계
$overall = $statsService->getStats($since);
$this->info('=== ' . __('seo.stats_overall') . ' ===');
$this->table(
[__('seo.stats_metric'), __('seo.stats_value')],
[
[__('seo.stats_total_entries'), $overall['total_entries']],
[__('seo.stats_hits'), $overall['hits']],
[__('seo.stats_misses'), $overall['misses']],
[__('seo.stats_hit_rate'), $overall['hit_rate'] . '%'],
[__('seo.stats_avg_response_time'), $overall['avg_response_time_ms'] !== null ? $overall['avg_response_time_ms'] . 'ms' : 'N/A'],
]
);
$this->newLine();
// 레이아웃별 통계
$byLayout = $statsService->getStatsByLayout($since);
if (! empty($byLayout)) {
$this->info('=== ' . __('seo.stats_by_layout') . ' ===');
$this->table(
[__('seo.stats_layout_name'), __('seo.stats_total'), __('seo.stats_hits'), __('seo.stats_misses'), __('seo.stats_hit_rate'), __('seo.stats_avg_response_time')],
array_map(fn ($row) => [
$row['layout_name'] ?? 'N/A',
$row['total'],
$row['hits'],
$row['misses'],
$row['hit_rate'] . '%',
$row['avg_response_time_ms'] !== null ? $row['avg_response_time_ms'] . 'ms' : 'N/A',
], $byLayout)
);
}
$this->newLine();
// 모듈별 통계
$byModule = $statsService->getStatsByModule($since);
if (! empty($byModule)) {
$this->info('=== ' . __('seo.stats_by_module') . ' ===');
$this->table(
[__('seo.stats_module_identifier'), __('seo.stats_total'), __('seo.stats_hits'), __('seo.stats_misses'), __('seo.stats_hit_rate'), __('seo.stats_avg_response_time')],
array_map(fn ($row) => [
$row['module_identifier'] ?? 'N/A',
$row['total'],
$row['hits'],
$row['misses'],
$row['hit_rate'] . '%',
$row['avg_response_time_ms'] !== null ? $row['avg_response_time_ms'] . 'ms' : 'N/A',
], $byModule)
);
}
return Command::SUCCESS;
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
/**
* SEO 캐시 워밍업 Artisan 커맨드
*
* 모든 SEO 레이아웃을 사전 렌더링하여 캐시를 준비합니다.
* Phase 5의 SeoDeclarationCollector 구현 후 실제 워밍업 로직이 추가됩니다.
*/
class SeoCacheWarmupCommand extends Command
{
/**
* @var string 커맨드 시그니처
*/
protected $signature = 'seo:warmup {--layout= : 특정 레이아웃만 워밍업}';
/**
* @var string 커맨드 설명
*/
protected $description = 'SEO 캐시 워밍업 - 모든 SEO 레이아웃을 사전 렌더링합니다';
/**
* 커맨드를 실행합니다.
*
* @return int 종료 코드
*/
public function handle(): int
{
$layout = $this->option('layout');
if ($layout) {
$this->info(__('seo.warmup_started_layout', ['layout' => $layout]));
} else {
$this->info(__('seo.warmup_started'));
}
// Phase 5의 SeoDeclarationCollector 구현 후 실제 워밍업 로직 추가 예정
$this->info(__('seo.warmup_started'));
return Command::SUCCESS;
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Console\Commands;
use App\Jobs\GenerateSitemapJob;
use Illuminate\Console\Command;
/**
* Sitemap XML 생성 Artisan 커맨드
*
* 큐를 통한 비동기 실행 또는 --sync 옵션으로 동기 실행을 지원합니다.
*/
class SeoGenerateSitemapCommand extends Command
{
/**
* @var string 커맨드 시그니처
*/
protected $signature = 'seo:generate-sitemap {--sync : 동기 실행}';
/**
* @var string 커맨드 설명
*/
protected $description = 'Sitemap XML을 생성합니다';
/**
* 커맨드를 실행합니다.
*
* @return int 종료 코드
*/
public function handle(): int
{
if ($this->option('sync')) {
GenerateSitemapJob::dispatchSync();
$this->info('Sitemap이 생성되었습니다.');
} else {
GenerateSitemapJob::dispatch();
$this->info('Sitemap 생성이 큐에 디스패치되었습니다.');
}
return Command::SUCCESS;
}
}
@@ -0,0 +1,114 @@
<?php
namespace App\Console\Commands\Template;
use App\Contracts\Repositories\TemplateRepositoryInterface;
use App\Extension\TemplateManager;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class ActivateTemplateCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'template:activate {identifier : 템플릿 식별자} {--force : 의존성 미충족 시 강제 활성화}';
/**
* The console command description.
*/
protected $description = '템플릿을 활성화합니다';
/**
* 템플릿 관리자 및 리포지토리
*/
public function __construct(
private TemplateManager $templateManager,
private TemplateRepositoryInterface $templateRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$identifier = $this->argument('identifier');
try {
// 템플릿 디렉토리 스캔 및 로드
$this->templateManager->loadTemplates();
// 템플릿 존재 확인
$template = $this->templateRepository->findByIdentifier($identifier);
if (! $template) {
$this->error('❌ '.__('templates.commands.activate.not_installed', ['template' => $identifier]));
return Command::FAILURE;
}
// 기존 활성 템플릿 조회
$previousActive = $this->templateRepository->findActiveByType($template->type);
// 템플릿 활성화
$force = $this->option('force');
$result = $this->templateManager->activateTemplate($identifier, $force);
// 경고 응답인 경우 (의존성 미충족)
if (isset($result['warning']) && $result['warning'] === true) {
$this->warn('⚠️ '.$result['message']);
$this->line('');
if (! empty($result['missing_modules'])) {
$this->info('필요한 모듈:');
foreach ($result['missing_modules'] as $module) {
$statusLabel = $module['status'] === 'not_installed' ? '미설치' : '비활성';
$this->line(" - {$module['identifier']} ({$module['name']}) [{$statusLabel}]");
}
}
if (! empty($result['missing_plugins'])) {
$this->info('필요한 플러그인:');
foreach ($result['missing_plugins'] as $plugin) {
$statusLabel = $plugin['status'] === 'not_installed' ? '미설치' : '비활성';
$this->line(" - {$plugin['identifier']} ({$plugin['name']}) [{$statusLabel}]");
}
}
$this->line('');
$this->info('강제로 활성화하려면 --force 옵션을 사용하세요.');
return Command::FAILURE;
}
if ($result['success']) {
// 기존 템플릿 비활성화 메시지
if ($previousActive && $previousActive->identifier !== $identifier) {
$this->warn('ℹ️ '.__('templates.commands.activate.deactivated_previous', [
'type' => __('templates.types.'.$template->type),
'previous' => $previousActive->identifier,
]));
}
// 성공 메시지
$this->info('✅ '.__('templates.commands.activate.success', ['template' => $identifier]));
Log::info(__('templates.commands.activate.success', ['template' => $identifier]));
return Command::SUCCESS;
}
return Command::FAILURE;
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
Log::error('템플릿 활성화 실패', [
'template' => $identifier,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -0,0 +1,327 @@
<?php
namespace App\Console\Commands\Template;
use App\Extension\TemplateManager;
use App\Extension\Traits\ClearsTemplateCaches;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Process\Process;
class BuildTemplateCommand extends Command
{
use ClearsTemplateCaches;
/**
* The name and signature of the console command.
*/
protected $signature = 'template:build
{identifier? : 빌드할 템플릿 식별자 (생략 시 --all 필요)}
{--all : 모든 템플릿 빌드}
{--watch : 파일 변경 감시 모드}
{--production : 프로덕션 빌드}
{--active : 활성 디렉토리에서 빌드}';
/**
* The console command description.
*/
protected $description = '템플릿의 프론트엔드 에셋을 빌드합니다 (기본: _bundled 디렉토리)';
/**
* 템플릿 관리자
*/
public function __construct(
private TemplateManager $templateManager
) {
parent::__construct();
}
/**
* Execute the console command.
*
* @return int 명령 실행 결과 코드
*/
public function handle(): int
{
$identifier = $this->argument('identifier');
$buildAll = $this->option('all');
$watchMode = $this->option('watch');
$productionMode = $this->option('production');
if (! $identifier && ! $buildAll) {
$this->error('❌ 템플릿 식별자를 지정하거나 --all 옵션을 사용하세요.');
$this->line('');
$this->line('사용법:');
$this->line(' php artisan template:build sirsoft-admin_basic');
$this->line(' php artisan template:build --all');
$this->line(' php artisan template:build sirsoft-admin_basic --watch');
$this->line(' php artisan template:build --all --production');
$this->line(' php artisan template:build sirsoft-admin_basic --active');
return Command::FAILURE;
}
try {
// 템플릿 로드
$this->templateManager->loadTemplates();
if ($buildAll) {
return $this->buildAllTemplates($productionMode);
}
return $this->buildTemplate($identifier, $watchMode, $productionMode);
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
Log::error('템플릿 빌드 실패', [
'template' => $identifier ?? 'all',
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
/**
* 빌드 대상 경로를 결정합니다.
*
* 기본값: _bundled 디렉토리. --active 옵션 시 활성 디렉토리.
* --watch 모드에서는 활성 디렉토리를 사용합니다 (실시간 개발용).
*
* @param string $identifier 템플릿 식별자
* @return array|null ['path' => string, 'source' => 'bundled'|'active'] 또는 null
*/
private function resolveBuildPath(string $identifier): ?array
{
$bundledPath = base_path("templates/_bundled/{$identifier}");
$activePath = base_path("templates/{$identifier}");
// --active 명시 → 활성 디렉토리
if ($this->option('active')) {
return is_dir($activePath)
? ['path' => $activePath, 'source' => 'active']
: null;
}
// --watch 모드 → 활성 디렉토리 (실시간 개발용)
if ($this->option('watch')) {
if (is_dir($activePath)) {
return ['path' => $activePath, 'source' => 'active'];
}
}
// 기본값: _bundled 우선
if (is_dir($bundledPath)) {
return ['path' => $bundledPath, 'source' => 'bundled'];
}
// _bundled에 없으면 활성 디렉토리 폴백
if (is_dir($activePath)) {
return ['path' => $activePath, 'source' => 'active'];
}
return null;
}
/**
* 단일 템플릿 빌드
*
* @param string $identifier 템플릿 식별자
* @param bool $watchMode 파일 감시 모드
* @param bool $productionMode 프로덕션 빌드
* @return int 명령 실행 결과 코드
*/
private function buildTemplate(string $identifier, bool $watchMode, bool $productionMode): int
{
// 경로 결정
$resolved = $this->resolveBuildPath($identifier);
if (! $resolved) {
$this->error("❌ 템플릿을 찾을 수 없습니다: {$identifier}");
$this->line(' _bundled 및 활성 디렉토리 모두 존재하지 않습니다.');
return Command::FAILURE;
}
$buildPath = $resolved['path'];
$source = $resolved['source'];
// 소스 표시
$sourceLabel = $source === 'bundled' ? '_bundled' : '활성';
$this->info("📂 빌드 소스: {$sourceLabel} ({$buildPath})");
// package.json 존재 확인
$packageJsonPath = $buildPath.'/package.json';
if (! file_exists($packageJsonPath)) {
$this->error('❌ package.json 파일이 없습니다: '.$packageJsonPath);
$this->line(' 템플릿 프론트엔드 구조를 먼저 생성하세요.');
return Command::FAILURE;
}
// node_modules 확인 및 설치
if (! is_dir($buildPath.'/node_modules')) {
$this->info('📦 의존성 설치 중...');
$installResult = $this->runNpmCommand(['npm', 'install'], $buildPath);
if ($installResult !== Command::SUCCESS) {
$this->error('❌ npm install 실패');
return Command::FAILURE;
}
}
// 빌드 명령 결정
$buildCommand = ['npm', 'run'];
if ($watchMode) {
$buildCommand[] = 'dev';
$this->info("👀 파일 감시 모드로 빌드 시작: {$identifier}");
$this->line(' Ctrl+C로 종료할 수 있습니다.');
} else {
$buildCommand[] = 'build';
$this->info("🔨 빌드 시작: {$identifier}".($productionMode ? ' (프로덕션)' : ''));
}
// 빌드 실행
$result = $this->runNpmCommand($buildCommand, $buildPath, ! $watchMode);
if ($result === Command::SUCCESS && ! $watchMode) {
$this->info("✅ 빌드 완료: {$identifier}");
// 빌드 결과 파일 확인
$distPath = $buildPath.'/dist';
if (is_dir($distPath)) {
$this->line(' 빌드 결과:');
// JS 파일 확인
$jsFiles = glob($distPath.'/*.js');
foreach ($jsFiles as $jsFile) {
$fileName = basename($jsFile);
$fileSize = number_format(filesize($jsFile) / 1024, 2);
$this->line(" - {$fileName} ({$fileSize} KB)");
}
// CSS 파일 확인
$cssFiles = glob($distPath.'/*.css');
foreach ($cssFiles as $cssFile) {
$fileName = basename($cssFile);
$fileSize = number_format(filesize($cssFile) / 1024, 2);
$this->line(" - {$fileName} ({$fileSize} KB)");
}
}
// 캐시 버전 증가 (브라우저 캐시 무효화)
$this->incrementExtensionCacheVersion();
$this->line(' - 캐시 버전 갱신됨');
// _bundled 빌드 시 활성 반영 안내
if ($source === 'bundled') {
$this->line('');
$this->info("💡 활성 디렉토리에 반영하려면: php artisan template:update {$identifier}");
}
}
return $result;
}
/**
* 모든 템플릿 빌드
*
* @param bool $productionMode 프로덕션 빌드
* @return int 명령 실행 결과 코드
*/
private function buildAllTemplates(bool $productionMode): int
{
$buildTargets = [];
if ($this->option('active')) {
// --active: 활성 디렉토리만 대상
$templates = $this->templateManager->getAllTemplates();
foreach ($templates as $identifier => $template) {
$templatePath = base_path("templates/{$identifier}");
if (file_exists($templatePath.'/package.json')) {
$buildTargets[$identifier] = true;
}
}
} else {
// 기본값: _bundled 디렉토리 스캔
$bundledTemplates = $this->templateManager->getBundledTemplates();
foreach ($bundledTemplates as $identifier => $metadata) {
$bundledPath = $metadata['source_path'];
if (file_exists($bundledPath.'/package.json')) {
$buildTargets[$identifier] = true;
}
}
}
if (empty($buildTargets)) {
$this->warn('⚠️ 빌드할 템플릿이 없습니다.');
return Command::SUCCESS;
}
$sourceLabel = $this->option('active') ? '활성' : '_bundled';
$this->info("🔨 모든 템플릿 빌드 시작 ({$sourceLabel})".($productionMode ? ' (프로덕션)' : ''));
$this->line(' 대상 템플릿: '.implode(', ', array_keys($buildTargets)));
$this->line('');
$successCount = 0;
$failCount = 0;
foreach ($buildTargets as $identifier => $value) {
$this->line(" [{$identifier}]");
$result = $this->buildTemplate($identifier, false, $productionMode);
if ($result === Command::SUCCESS) {
$successCount++;
} else {
$failCount++;
}
$this->line('');
}
$this->info("📊 빌드 결과: 성공 {$successCount}개, 실패 {$failCount}개");
return $failCount > 0 ? Command::FAILURE : Command::SUCCESS;
}
/**
* npm 명령 실행
*
* @param array $command 실행할 명령
* @param string $cwd 작업 디렉토리
* @param bool $waitForCompletion 완료 대기 여부
* @return int 명령 실행 결과 코드
*/
private function runNpmCommand(array $command, string $cwd, bool $waitForCompletion = true): int
{
// Windows 환경에서는 cmd /c 사용
if (PHP_OS_FAMILY === 'Windows') {
$command = array_merge(['cmd', '/c'], $command);
}
$process = new Process($command);
$process->setWorkingDirectory($cwd);
$process->setTimeout(null); // 타임아웃 없음
if ($waitForCompletion) {
$process->run(function ($type, $buffer) {
$this->output->write($buffer);
});
return $process->isSuccessful() ? Command::SUCCESS : Command::FAILURE;
}
// 감시 모드: 인터럽트까지 실행
$process->start(function ($type, $buffer) {
$this->output->write($buffer);
});
// 프로세스가 실행 중인 동안 대기
while ($process->isRunning()) {
usleep(100000); // 100ms
}
return Command::SUCCESS;
}
}
@@ -0,0 +1,152 @@
<?php
namespace App\Console\Commands\Template;
use App\Contracts\Repositories\TemplateRepositoryInterface;
use App\Extension\TemplateManager;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class CheckTemplateUpdatesCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'template:check-updates {identifier? : 특정 템플릿만 확인 (선택)}';
/**
* The console command description.
*/
protected $description = '템플릿 업데이트를 확인합니다';
/**
* 템플릿 관리자 및 리포지토리
*/
public function __construct(
private TemplateManager $templateManager,
private TemplateRepositoryInterface $templateRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$identifier = $this->argument('identifier');
try {
$this->templateManager->loadTemplates();
if ($identifier) {
return $this->checkSingle($identifier);
}
return $this->checkAll();
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
Log::error('템플릿 업데이트 확인 실패', [
'template' => $identifier,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
/**
* 단일 템플릿 업데이트를 확인합니다.
*
* @param string $identifier 템플릿 식별자
*/
private function checkSingle(string $identifier): int
{
$template = $this->templateRepository->findByIdentifier($identifier);
if (! $template) {
$this->error('❌ '.__('templates.commands.check_updates.not_installed', ['template' => $identifier]));
return Command::FAILURE;
}
$result = $this->templateManager->checkTemplateUpdate($identifier);
// 단일 체크는 DB 갱신이 안 되므로 커맨드에서 직접 갱신
$this->templateRepository->updateByIdentifier($identifier, [
'update_available' => $result['update_available'],
'latest_version' => $result['latest_version'],
'update_source' => $result['update_source'],
]);
if ($result['update_available']) {
$this->info('🔄 '.__('templates.commands.check_updates.single_update_available', [
'template' => $identifier,
'current' => $result['current_version'],
'latest' => $result['latest_version'],
'source' => $result['update_source'],
]));
} else {
$this->info('✅ '.__('templates.commands.check_updates.single_up_to_date', [
'template' => $identifier,
'version' => $result['current_version'],
]));
}
return Command::SUCCESS;
}
/**
* 모든 설치된 템플릿의 업데이트를 확인합니다.
*/
private function checkAll(): int
{
$installedTemplates = $this->templateManager->getInstalledTemplatesWithDetails();
if (empty($installedTemplates)) {
$this->info(__('templates.commands.check_updates.no_installed'));
return Command::SUCCESS;
}
// checkAllTemplatesForUpdates는 내부에서 DB 갱신 포함
$result = $this->templateManager->checkAllTemplatesForUpdates();
$tableData = [];
$updateCount = 0;
foreach ($result['details'] as $detail) {
$isUpdate = $detail['update_available'] ?? false;
if ($isUpdate) {
$updateCount++;
}
$tableData[] = [
$detail['identifier'],
$detail['current_version'] ?? '-',
$detail['latest_version'] ?? '-',
$detail['update_source'] ?? '-',
$isUpdate
? '🔄 '.__('templates.commands.check_updates.update_available')
: '✅ '.__('templates.commands.check_updates.up_to_date'),
];
}
$headers = [
__('templates.commands.check_updates.headers.identifier'),
__('templates.commands.check_updates.headers.current_version'),
__('templates.commands.check_updates.headers.latest_version'),
__('templates.commands.check_updates.headers.source'),
__('templates.commands.check_updates.headers.status'),
];
$this->table($headers, $tableData);
$this->newLine();
$this->info(__('templates.commands.check_updates.summary', [
'total' => count($result['details']),
'updates' => $updateCount,
]));
return Command::SUCCESS;
}
}
@@ -0,0 +1,163 @@
<?php
namespace App\Console\Commands\Template;
use App\Extension\TemplateManager;
use App\Models\Template;
use App\Models\TemplateLayout;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
class ClearTemplateCacheCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'template:cache-clear
{identifier? : 특정 템플릿의 캐시만 삭제 (생략 시 모든 템플릿)}';
/**
* The console command description.
*/
protected $description = '템플릿 관련 캐시를 삭제합니다';
/**
* 템플릿 관리자
*/
public function __construct(
private TemplateManager $templateManager
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$identifier = $this->argument('identifier');
try {
// 템플릿 디렉토리 스캔 및 로드
$this->templateManager->loadTemplates();
if ($identifier) {
// 특정 템플릿 캐시만 삭제
$this->clearSingleTemplateCache($identifier);
} else {
// 모든 템플릿 캐시 삭제
$this->clearAllTemplateCache();
}
return Command::SUCCESS;
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
Log::error('템플릿 캐시 삭제 실패', [
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
/**
* 특정 템플릿의 캐시 삭제
*/
private function clearSingleTemplateCache(string $identifier): void
{
// 템플릿 존재 확인
$template = $this->templateManager->getTemplate($identifier);
if (! $template) {
throw new \Exception(__('templates.errors.not_found', ['template' => $identifier]));
}
$this->info(__('templates.commands.cache_clear.clearing_single', ['template' => $identifier]));
$clearedCount = 0;
// 1. 레이아웃 캐시 삭제
$templateRecord = Template::where('identifier', $identifier)->first();
if ($templateRecord) {
$layouts = TemplateLayout::where('template_id', $templateRecord->id)->get();
foreach ($layouts as $layout) {
Cache::forget("layout.{$identifier}.{$layout->name}");
$clearedCount++;
}
}
// 2. Routes 캐시 삭제
Cache::forget("template.routes.{$identifier}");
$clearedCount++;
// 3. 다국어 파일 캐시 삭제
$supportedLocales = config('app.supported_locales', ['ko', 'en']);
foreach ($supportedLocales as $locale) {
Cache::forget("template.language.{$identifier}.{$locale}");
$clearedCount++;
}
// 4. 활성 템플릿 타입 캐시 삭제
if ($templateRecord) {
Cache::forget("templates.active.{$templateRecord->type}");
$clearedCount++;
}
$this->info('✅ '.__('templates.commands.cache_clear.success_single', [
'template' => $identifier,
'count' => $clearedCount,
]));
Log::info(__('templates.commands.cache_clear.success_single', [
'template' => $identifier,
'count' => $clearedCount,
]));
}
/**
* 모든 템플릿 캐시 삭제
*/
private function clearAllTemplateCache(): void
{
$this->info(__('templates.commands.cache_clear.clearing_all'));
$clearedCount = 0;
$supportedLocales = config('app.supported_locales', ['ko', 'en']);
// 모든 설치된 템플릿의 캐시 삭제
$templates = Template::all();
foreach ($templates as $templateRecord) {
// 1. 레이아웃 캐시 삭제
$layouts = TemplateLayout::where('template_id', $templateRecord->id)->get();
foreach ($layouts as $layout) {
Cache::forget("layout.{$templateRecord->identifier}.{$layout->name}");
$clearedCount++;
}
// 2. Routes 캐시 삭제
Cache::forget("template.routes.{$templateRecord->identifier}");
$clearedCount++;
// 3. 다국어 파일 캐시 삭제
foreach ($supportedLocales as $locale) {
Cache::forget("template.language.{$templateRecord->identifier}.{$locale}");
$clearedCount++;
}
}
// 4. 활성 템플릿 타입 캐시 삭제
Cache::forget('templates.active.admin');
Cache::forget('templates.active.user');
$clearedCount += 2;
$this->info('✅ '.__('templates.commands.cache_clear.success_all', [
'count' => $clearedCount,
]));
Log::info(__('templates.commands.cache_clear.success_all', [
'count' => $clearedCount,
]));
}
}
@@ -0,0 +1,87 @@
<?php
namespace App\Console\Commands\Template;
use App\Contracts\Repositories\TemplateRepositoryInterface;
use App\Extension\TemplateManager;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class DeactivateTemplateCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'template:deactivate {identifier : 템플릿 식별자}';
/**
* The console command description.
*/
protected $description = '템플릿을 비활성화합니다';
/**
* 템플릿 관리자 및 리포지토리
*/
public function __construct(
private TemplateManager $templateManager,
private TemplateRepositoryInterface $templateRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$identifier = $this->argument('identifier');
try {
// 템플릿 디렉토리 스캔 및 로드
$this->templateManager->loadTemplates();
// 템플릿 존재 확인
$template = $this->templateRepository->findByIdentifier($identifier);
if (! $template) {
$this->error('❌ '.__('templates.commands.activate.not_installed', ['template' => $identifier]));
return Command::FAILURE;
}
// 템플릿이 활성화되어 있는지 확인
if ($template->status !== 'active') {
$this->warn('⚠️ '.__('templates.commands.deactivate.not_active', ['template' => $identifier]));
return Command::FAILURE;
}
// 템플릿 비활성화
$result = $this->templateManager->deactivateTemplate($identifier);
if ($result) {
// 성공 메시지
$this->info('✅ '.__('templates.commands.deactivate.success', ['template' => $identifier]));
// 경고 메시지
$this->warn('⚠️ '.__('templates.commands.deactivate.no_active_warning', [
'type' => __('templates.types.'.$template->type),
]));
Log::info(__('templates.commands.deactivate.success', ['template' => $identifier]));
return Command::SUCCESS;
}
return Command::FAILURE;
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
Log::error('템플릿 비활성화 실패', [
'template' => $identifier,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -0,0 +1,96 @@
<?php
namespace App\Console\Commands\Template;
use App\Console\Commands\Traits\HasProgressBar;
use App\Contracts\Repositories\TemplateRepositoryInterface;
use App\Extension\TemplateManager;
use App\Rules\ValidExtensionIdentifier;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
class InstallTemplateCommand extends Command
{
use HasProgressBar;
/**
* The name and signature of the console command.
*/
protected $signature = 'template:install {identifier : 템플릿 식별자 (디렉토리명)}';
/**
* The console command description.
*/
protected $description = '템플릿을 설치합니다';
/**
* 템플릿 관리자 및 리포지토리
*/
public function __construct(
private TemplateManager $templateManager,
private TemplateRepositoryInterface $templateRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$identifier = $this->argument('identifier');
// 식별자 형식 검증
$validator = Validator::make(
['identifier' => $identifier],
['identifier' => [new ValidExtensionIdentifier]]
);
if ($validator->fails()) {
$this->error('❌ '.$validator->errors()->first('identifier'));
return Command::FAILURE;
}
try {
// 템플릿 디렉토리 스캔 및 로드
$this->templateManager->loadTemplates();
// 템플릿 설치
$onProgress = $this->createProgressCallback(TemplateManager::INSTALL_STEPS);
try {
$result = $this->templateManager->installTemplate($identifier, $onProgress);
$this->finishProgress();
} catch (\Exception $e) {
$this->finishProgress();
throw $e;
}
if ($result) {
// 설치된 템플릿 정보 조회
$template = $this->templateRepository->findByIdentifier($identifier);
// 성공 메시지
$this->info('✅ '.__('templates.commands.install.success', ['template' => $identifier]));
$this->info(' - '.__('templates.commands.install.type', ['type' => __('templates.types.'.$template->type)]));
$this->info(' - '.__('templates.commands.install.version', ['version' => $template->version]));
$this->info(' - '.__('templates.commands.install.layouts_created', ['count' => $template->layouts()->count()]));
Log::info(__('templates.commands.install.success', ['template' => $identifier]));
return Command::SUCCESS;
}
return Command::FAILURE;
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
Log::error('템플릿 설치 실패', [
'template' => $identifier,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -0,0 +1,168 @@
<?php
namespace App\Console\Commands\Template;
use App\Contracts\Repositories\TemplateRepositoryInterface;
use App\Extension\TemplateManager;
use Illuminate\Console\Command;
class ListTemplateCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'template:list
{--type= : 템플릿 타입으로 필터 (admin, user)}
{--status= : 상태로 필터 (installed, uninstalled, active, inactive)}';
/**
* The console command description.
*/
protected $description = '설치된 템플릿 목록을 조회합니다';
/**
* 템플릿 관리자 및 리포지토리
*/
public function __construct(
private TemplateManager $templateManager,
private TemplateRepositoryInterface $templateRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
// 템플릿 디렉토리 스캔 및 로드
$this->templateManager->loadTemplates();
$typeFilter = $this->option('type');
$statusFilter = $this->option('status');
// 타입 필터 검증
if ($typeFilter && ! in_array($typeFilter, ['admin', 'user'])) {
$this->error('❌ '.__('templates.commands.list.invalid_type'));
return Command::FAILURE;
}
// 상태 필터 검증
if ($statusFilter && ! in_array($statusFilter, ['installed', 'uninstalled', 'active', 'inactive'])) {
$this->error('❌ '.__('templates.commands.list.invalid_status'));
return Command::FAILURE;
}
// 설치된 템플릿 정보
$installedTemplates = $this->templateManager->getInstalledTemplatesWithDetails();
// 미설치 템플릿 정보
$uninstalledTemplates = $this->templateManager->getUninstalledTemplates();
// 테이블 데이터 준비
$tableData = [];
// 설치된 템플릿 추가
foreach ($installedTemplates as $identifier => $template) {
// 타입 필터
if ($typeFilter && $template['type'] !== $typeFilter) {
continue;
}
// 상태 필터
if ($statusFilter) {
if ($statusFilter === 'uninstalled') {
continue;
}
if ($statusFilter === 'active' && $template['status'] !== 'active') {
continue;
}
if ($statusFilter === 'inactive' && $template['status'] !== 'inactive') {
continue;
}
if ($statusFilter === 'installed' && ! in_array($template['status'], ['active', 'inactive'])) {
continue;
}
}
$tableData[] = [
'identifier' => $identifier,
'name' => $template['name'],
'type' => __('templates.types.'.$template['type']),
'version' => $template['version'],
'status' => $this->formatStatus($template['status']),
];
}
// 미설치 템플릿 추가
if (! $statusFilter || $statusFilter === 'uninstalled') {
foreach ($uninstalledTemplates as $identifier => $template) {
// 타입 필터
if ($typeFilter && $template['type'] !== $typeFilter) {
continue;
}
// 상태 필터 (uninstalled 또는 필터 없음)
if ($statusFilter && $statusFilter !== 'uninstalled') {
continue;
}
$tableData[] = [
'identifier' => $identifier,
'name' => $template['name'],
'type' => __('templates.types.'.$template['type']),
'version' => $template['version'],
'status' => $this->formatStatus('uninstalled'),
];
}
}
// 템플릿이 없는 경우
if (empty($tableData)) {
$this->info(__('templates.commands.list.no_templates'));
return Command::SUCCESS;
}
// 테이블 헤더
$headers = [
__('templates.commands.list.headers.identifier'),
__('templates.commands.list.headers.name'),
__('templates.commands.list.headers.type'),
__('templates.commands.list.headers.version'),
__('templates.commands.list.headers.status'),
];
// 테이블 출력
$this->table($headers, $tableData);
// 요약 정보
$totalCount = count($tableData);
$activeCount = count(array_filter($tableData, fn ($t) => str_contains($t['status'], __('templates.status.active'))));
$installedCount = count(array_filter($tableData, fn ($t) => ! str_contains($t['status'], __('templates.commands.list.status.uninstalled'))));
$this->newLine();
$this->info(__('templates.commands.list.summary', [
'total' => $totalCount,
'installed' => $installedCount,
'active' => $activeCount,
]));
return Command::SUCCESS;
}
/**
* 상태 포맷팅
*/
private function formatStatus(string $status): string
{
return match ($status) {
'active' => '✅ '.__('templates.status.active'),
'inactive' => '⏸️ '.__('templates.status.inactive'),
'uninstalled' => '📦 '.__('templates.commands.list.status.uninstalled'),
default => $status,
};
}
}
@@ -0,0 +1,126 @@
<?php
namespace App\Console\Commands\Template;
use App\Models\Template;
use App\Services\TemplateService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
/**
* 템플릿 레이아웃 갱신 커맨드
*
* 레이아웃 JSON 파일만 수정된 경우, 빌드 없이 레이아웃만 갱신합니다.
*/
class RefreshTemplateLayoutCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'template:refresh-layout
{identifier? : 특정 템플릿의 식별자 (생략 시 모든 활성 템플릿)}';
/**
* The console command description.
*/
protected $description = '템플릿 레이아웃을 갱신합니다 (빌드 없이 JSON만 DB에 동기화)';
public function __construct(
private TemplateService $templateService
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$identifier = $this->argument('identifier');
try {
if ($identifier) {
return $this->refreshSingle($identifier);
}
return $this->refreshAll();
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
Log::error('템플릿 레이아웃 갱신 실패', [
'template' => $identifier ?? 'all',
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
/**
* 특정 템플릿의 레이아웃을 갱신합니다.
*/
private function refreshSingle(string $identifier): int
{
$this->info("템플릿 '{$identifier}' 레이아웃 갱신 중...");
$result = $this->templateService->refreshTemplateLayouts($identifier);
if ($result) {
$this->info("✅ 템플릿 '{$identifier}' 레이아웃 갱신 완료");
Log::info('템플릿 레이아웃 갱신 완료', ['template' => $identifier]);
return Command::SUCCESS;
}
$this->error("❌ 템플릿 '{$identifier}' 레이아웃 갱신 실패");
return Command::FAILURE;
}
/**
* 모든 활성 템플릿의 레이아웃을 갱신합니다.
*/
private function refreshAll(): int
{
$templates = Template::where('status', 'active')->pluck('identifier')->toArray();
if (empty($templates)) {
$this->line('활성화된 템플릿이 없습니다.');
return Command::SUCCESS;
}
$this->info('모든 활성 템플릿 레이아웃 갱신 중...');
$successCount = 0;
$failCount = 0;
foreach ($templates as $templateIdentifier) {
$this->line(" {$templateIdentifier} 갱신 중...");
try {
$result = $this->templateService->refreshTemplateLayouts($templateIdentifier);
if ($result) {
$this->info(" ✅ {$templateIdentifier} 완료");
$successCount++;
} else {
$this->warn(" ⚠️ {$templateIdentifier} 실패");
$failCount++;
}
} catch (\Exception $e) {
$this->warn(" ⚠️ {$templateIdentifier}: ".$e->getMessage());
$failCount++;
}
}
$this->newLine();
$this->info("✅ 총 {$successCount}개 성공, {$failCount}개 실패");
Log::info('템플릿 레이아웃 일괄 갱신 완료', [
'success' => $successCount,
'failed' => $failCount,
]);
return $failCount > 0 ? Command::FAILURE : Command::SUCCESS;
}
}
@@ -0,0 +1,100 @@
<?php
namespace App\Console\Commands\Template;
use App\Console\Commands\Traits\HasProgressBar;
use App\Contracts\Repositories\TemplateRepositoryInterface;
use App\Extension\TemplateManager;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class UninstallTemplateCommand extends Command
{
use HasProgressBar;
/**
* The name and signature of the console command.
*/
protected $signature = 'template:uninstall {identifier : 템플릿 식별자}';
/**
* The console command description.
*/
protected $description = '템플릿을 삭제합니다';
/**
* 템플릿 관리자 및 리포지토리
*/
public function __construct(
private TemplateManager $templateManager,
private TemplateRepositoryInterface $templateRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$identifier = $this->argument('identifier');
try {
// 템플릿 디렉토리 스캔 및 로드
$this->templateManager->loadTemplates();
// 템플릿 존재 확인
$template = $this->templateRepository->findByIdentifier($identifier);
if (! $template) {
$this->error('❌ '.__('templates.commands.activate.not_installed', ['template' => $identifier]));
return Command::FAILURE;
}
// 삭제 확인 프롬프트
$this->warn(__('templates.commands.uninstall.confirm_prompt', ['template' => $identifier]));
$this->warn(__('templates.commands.uninstall.confirm_details.layouts', ['count' => $template->layouts()->count()]));
$this->warn(__('templates.commands.uninstall.confirm_details.versions'));
if (! $this->confirm(__('templates.commands.uninstall.confirm_question'), false)) {
$this->info(__('templates.commands.uninstall.aborted'));
return Command::SUCCESS;
}
// 레이아웃 및 버전 개수 조회
$layoutsCount = $template->layouts()->count();
// 템플릿 삭제
$onProgress = $this->createProgressCallback(TemplateManager::UNINSTALL_STEPS);
try {
$result = $this->templateManager->uninstallTemplate($identifier, $onProgress);
$this->finishProgress();
} catch (\Exception $e) {
$this->finishProgress();
throw $e;
}
if ($result) {
// 성공 메시지
$this->info('✅ '.__('templates.commands.uninstall.success', ['template' => $identifier]));
$this->info(' - '.__('templates.commands.uninstall.layouts_deleted', ['count' => $layoutsCount]));
Log::info(__('templates.commands.uninstall.success', ['template' => $identifier]));
return Command::SUCCESS;
}
return Command::FAILURE;
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
Log::error('템플릿 삭제 실패', [
'template' => $identifier,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -0,0 +1,155 @@
<?php
namespace App\Console\Commands\Template;
use App\Console\Commands\Traits\HasProgressBar;
use App\Contracts\Repositories\TemplateRepositoryInterface;
use App\Extension\TemplateManager;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class UpdateTemplateCommand extends Command
{
use HasProgressBar;
/**
* The name and signature of the console command.
*/
protected $signature = 'template:update
{identifier : 업데이트할 템플릿 식별자}
{--layout-strategy=overwrite : 레이아웃 전략 (overwrite|keep)}
{--force : 버전 비교 없이 강제 업데이트}';
/**
* The console command description.
*/
protected $description = '템플릿을 최신 버전으로 업데이트합니다';
/**
* 템플릿 관리자 및 리포지토리
*/
public function __construct(
private TemplateManager $templateManager,
private TemplateRepositoryInterface $templateRepository
) {
parent::__construct();
}
/**
* Execute the console command.
*/
public function handle(): int
{
$identifier = $this->argument('identifier');
$layoutStrategy = $this->option('layout-strategy');
$force = $this->option('force');
// 레이아웃 전략 검증
if (! in_array($layoutStrategy, ['overwrite', 'keep'])) {
$this->error('❌ '.__('templates.commands.update.invalid_strategy'));
return Command::FAILURE;
}
try {
$this->templateManager->loadTemplates();
// 템플릿 존재 확인
$template = $this->templateRepository->findByIdentifier($identifier);
if (! $template) {
$this->error('❌ '.__('templates.commands.update.not_installed', ['template' => $identifier]));
return Command::FAILURE;
}
// 업데이트 확인
$checkResult = $this->templateManager->checkTemplateUpdate($identifier);
if (! $checkResult['update_available'] && ! $force) {
$this->info('✅ '.__('templates.commands.update.no_update', ['template' => $identifier]));
return Command::SUCCESS;
}
// 업데이트 정보 표시
$this->info(__('templates.commands.update.current_version', ['version' => $checkResult['current_version']]));
if ($force && ! $checkResult['update_available']) {
$this->warn('⚠️ '.__('templates.commands.update.force_mode'));
} else {
$this->info(__('templates.commands.update.latest_version', ['version' => $checkResult['latest_version']]));
$this->info(__('templates.commands.update.update_source', ['source' => $checkResult['update_source']]));
}
$this->info(__('templates.commands.update.layout_strategy', ['strategy' => $layoutStrategy]));
// overwrite 전략일 때 수정된 레이아웃 경고
if ($layoutStrategy === 'overwrite') {
$modifiedResult = $this->templateManager->hasModifiedLayouts($identifier);
if ($modifiedResult['has_modified_layouts']) {
$this->newLine();
$this->warn('⚠️ '.__('templates.commands.update.modified_layouts_warning', [
'count' => $modifiedResult['modified_count'],
]));
foreach ($modifiedResult['modified_layouts'] as $layout) {
$this->warn(__('templates.commands.update.modified_layout_item', [
'name' => $layout['name'],
'date' => $layout['updated_at'],
]));
}
}
}
$this->newLine();
// 확인 프롬프트 (--force 시 건너뜀)
if (! $force && ! $this->confirm(__('templates.commands.update.confirm_question'), false)) {
$this->info(__('templates.commands.update.aborted'));
return Command::SUCCESS;
}
// 업데이트 실행
$onProgress = $this->createProgressCallback(TemplateManager::UPDATE_STEPS);
try {
$updateResult = $this->templateManager->updateTemplate($identifier, $layoutStrategy, $force, $onProgress);
$this->finishProgress();
} catch (\Exception $e) {
$this->finishProgress();
throw $e;
}
if ($updateResult['success']) {
$this->newLine();
$this->info('✅ '.__('templates.commands.update.success', ['template' => $identifier]));
$this->info(' '.__('templates.commands.update.version_change', [
'from' => $updateResult['from_version'],
'to' => $updateResult['to_version'],
]));
Log::info('템플릿 업데이트 완료', [
'template' => $identifier,
'from' => $updateResult['from_version'],
'to' => $updateResult['to_version'],
]);
return Command::SUCCESS;
}
return Command::FAILURE;
} catch (\Exception $e) {
$this->error('❌ '.$e->getMessage());
$this->warn('💡 '.__('templates.commands.update.backup_restored'));
Log::error('템플릿 업데이트 실패', [
'template' => $identifier,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -0,0 +1,57 @@
<?php
namespace App\Console\Commands\Traits;
use Symfony\Component\Console\Helper\ProgressBar;
/**
* 확장 커맨드용 프로그레스바 트레이트.
*
* 2단계 Closure 콜백 패턴으로 동작:
* 1. 단계 전환 ($step 지정): bar advance + 메시지 갱신
* 2. 상세 표시 ($step = null): 파일명 전광판 표시
*/
trait HasProgressBar
{
protected ?ProgressBar $progressBar = null;
/**
* 프로그레스 콜백을 생성합니다.
*
* @param int $steps 총 단계 수
* @return \Closure 콜백 (?string $step, string $message)
*/
protected function createProgressCallback(int $steps): \Closure
{
$this->progressBar = $this->output->createProgressBar($steps);
$this->progressBar->setFormat(" %current%/%max% [%bar%] %message%\n %detail%");
$this->progressBar->setMessage('시작 중...');
$this->progressBar->setMessage('', 'detail');
$this->progressBar->start();
return function (?string $step, string $message): void {
if ($step !== null) {
// 단계 전환: bar advance + 상세 초기화
$this->progressBar->setMessage($message);
$this->progressBar->setMessage('', 'detail');
$this->progressBar->advance();
} else {
// 상세 갱신: 현재 파일명 전광판 표시
$this->progressBar->setMessage("\u{2192} ".$message, 'detail');
$this->progressBar->display();
}
};
}
/**
* 프로그레스바를 완료합니다.
*/
protected function finishProgress(): void
{
if ($this->progressBar) {
$this->progressBar->setMessage('', 'detail');
$this->progressBar->finish();
$this->newLine();
}
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Contracts\Extension;
interface HookListenerInterface
{
/**
* 구독할 훅과 메서드 매핑을 반환합니다.
*
* @return array [
* 'hook.name' => [
* 'method' => 'methodName',
* 'priority' => 10
* ]
* ]
*/
public static function getSubscribedHooks(): array;
/**
* 훅 이벤트를 처리합니다.
*
* @param mixed ...$args 훅에서 전달된 인수들
* @return void
*/
public function handle(...$args): void;
}
@@ -0,0 +1,77 @@
<?php
namespace App\Contracts\Extension;
interface HookManagerInterface
{
/**
* Hook 이벤트를 발생시켜 등록된 콜백들을 실행합니다.
*
* @param string $hookName Hook 이름
* @param mixed ...$args Hook에 전달할 인수들
* @return void
*/
public static function doAction(string $hookName, ...$args): void;
/**
* Filter를 적용하여 데이터를 변환합니다.
*
* @param string $filterName Filter 이름
* @param mixed $value 변환할 원본 값
* @param mixed ...$args Filter에 전달할 추가 인수들
* @return mixed 변환된 값
*/
public static function applyFilters(string $filterName, $value, ...$args);
/**
* Hook 이벤트에 콜백 함수를 등록합니다.
*
* @param string $hookName Hook 이름
* @param callable $callback 실행할 콜백 함수
* @param int $priority 실행 우선순위 (기본값: 10)
* @return void
*/
public static function addAction(string $hookName, callable $callback, int $priority = 10): void;
/**
* Filter에 콜백 함수를 등록합니다.
*
* @param string $filterName Filter 이름
* @param callable $callback 실행할 콜백 함수
* @param int $priority 실행 우선순위 (기본값: 10)
* @return void
*/
public static function addFilter(string $filterName, callable $callback, int $priority = 10): void;
/**
* Hook 이벤트에서 콜백 함수를 제거합니다.
*
* @param string $hookName Hook 이름
* @param callable $callback 제거할 콜백 함수
* @return void
*/
public static function removeAction(string $hookName, callable $callback): void;
/**
* Filter에서 콜백 함수를 제거합니다.
*
* @param string $filterName Filter 이름
* @param callable $callback 제거할 콜백 함수
* @return void
*/
public static function removeFilter(string $filterName, callable $callback): void;
/**
* 등록된 모든 Hook 목록을 조회합니다.
*
* @return array Hook 목록 배열
*/
public static function getHooks(): array;
/**
* 등록된 모든 Filter 목록을 조회합니다.
*
* @return array Filter 목록 배열
*/
public static function getFilters(): array;
}
+269
View File
@@ -0,0 +1,269 @@
<?php
namespace App\Contracts\Extension;
interface ModuleInterface
{
/**
* 모듈명 반환 (표시용)
*
* @return string|array 문자열 또는 다국어 배열 ['ko' => '...', 'en' => '...']
*/
public function getName(): string|array;
/**
* 모듈의 버전을 반환합니다.
*
* @return string 모듈 버전
*/
public function getVersion(): string;
/**
* 모듈 설명 반환
*
* @return string|array 문자열 또는 다국어 배열 ['ko' => '...', 'en' => '...']
*/
public function getDescription(): string|array;
/**
* 모듈을 설치합니다.
*
* @return bool 설치 성공 여부
*/
public function install(): bool;
/**
* 모듈을 제거합니다.
*
* @return bool 제거 성공 여부
*/
public function uninstall(): bool;
/**
* 모듈이 런타임에 동적으로 생성한 테이블 목록을 반환합니다.
*
* 모듈 언인스톨 시 $deleteData=true이면 마이그레이션 롤백 전에 호출됩니다.
* 반환된 테이블들은 ModuleManager가 일괄 삭제합니다.
*
* 주의:
* - 마이그레이션 롤백 전에 호출되므로 메타 테이블(정적 테이블)이 아직 존재합니다.
* - 개별 테이블 삭제 실패 시에도 언인스톨은 계속 진행됩니다.
*
* @return array<string> 삭제할 테이블명 배열
*/
public function getDynamicTables(): array;
/**
* 모듈을 활성화합니다.
*
* @return bool 활성화 성공 여부
*/
public function activate(): bool;
/**
* 모듈을 비활성화합니다.
*
* @return bool 비활성화 성공 여부
*/
public function deactivate(): bool;
/**
* 버전별 업그레이드 스텝을 반환합니다.
*
* 반환 형식: ['1.1.0' => callable|UpgradeStepInterface, ...]
* 시스템이 fromVersion 초과 ~ toVersion 이하의 스텝을 자동 필터링 후 순차 실행합니다.
*
* @return array<string, callable|\App\Contracts\Extension\UpgradeStepInterface> 버전 => 스텝 매핑
*/
public function upgrades(): array;
/**
* 모듈이 제공하는 라우트 정보를 반환합니다.
*
* @return array 라우트 정보 배열
*/
public function getRoutes(): array;
/**
* 모듈의 마이그레이션 파일 목록을 반환합니다.
*
* @return array 마이그레이션 파일 경로 배열
*/
public function getMigrations(): array;
/**
* 모듈의 뷰 파일 목록을 반환합니다.
*
* @return array 뷰 파일 경로 배열
*/
public function getViews(): array;
/**
* 모듈의 의존성 정보를 반환합니다.
*
* @return array 의존성 목록 배열
*/
public function getDependencies(): array;
/**
* 모듈이 사용하는 권한 목록을 반환합니다.
*
* @return array 권한 목록 배열
*/
public function getPermissions(): array;
/**
* 모듈이 정의하는 역할 목록을 반환합니다.
*
* 역할 배열의 각 항목은 다음 구조를 따라야 합니다:
* - identifier: 역할 고유 식별자 (vendor-module.rolename 형식)
* - name: 다국어 배열 ['ko' => '역할명', 'en' => 'Role Name']
* - description: 다국어 배열 ['ko' => '설명', 'en' => 'Description']
*
* @return array 역할 정보 배열
*/
public function getRoles(): array;
/**
* 모듈의 설정 정보를 반환합니다.
*
* @return array 설정 정보 배열
*/
public function getConfig(): array;
/**
* 모듈이 추가하는 관리자 메뉴 목록을 반환합니다.
*
* 메뉴 배열의 각 항목은 다음 구조를 따라야 합니다:
* - name: 다국어 배열 ['ko' => '메뉴명', 'en' => 'Menu Name'] 또는 문자열 (역호환성)
* - slug: 메뉴 고유 식별자 (string)
* - url: 메뉴 URL (string)
* - icon: 아이콘 클래스 (string, optional)
* - order: 메뉴 순서 (int, optional)
* - children: 하위 메뉴 배열 (array, optional) - 동일한 구조를 따름
*
* @return array 메뉴 정보 배열
*/
public function getAdminMenus(): array;
/**
* 모듈의 훅 리스너 목록을 반환합니다.
*
* @return array 훅 리스너 클래스 목록 배열
*/
public function getHookListeners(): array;
/**
* 모듈의 스케줄 작업 목록을 반환합니다.
*
* 스케줄 배열의 각 항목은 다음 구조를 따라야 합니다:
* - command: Artisan 커맨드 이름
* - schedule: 스케줄 주기 ('daily', 'hourly', 'everyMinute', 'weekly' 또는 cron 표현식)
* - description: 작업 설명 (선택)
* - enabled_config: 설정 키 (선택, 설정에 따라 활성화 여부 결정)
*
* @return array 스케줄 작업 배열
*/
public function getSchedules(): array;
/**
* 모듈 설치 시 실행할 시더 클래스 목록을 반환합니다.
*
* 배열 순서대로 실행됩니다.
* 빈 배열 반환 시 database/seeders/ 디렉토리의 모든 시더를 자동 검색합니다. (역호환)
*
* @return array<class-string<\Illuminate\Database\Seeder>> 시더 클래스명 배열 (FQCN)
*/
public function getSeeders(): array;
/**
* 모듈의 고유 식별자를 반환합니다 (vendor-module 형식).
*
* @return string 모듈 식별자
*/
public function getIdentifier(): string;
/**
* 모듈의 벤더명을 반환합니다.
*
* @return string 벤더명
*/
public function getVendor(): string;
/**
* 모듈의 GitHub 저장소 URL을 반환합니다.
*
* @return string|null GitHub URL 또는 null
*/
public function getGithubUrl(): ?string;
/**
* 모듈의 라이선스를 반환합니다.
*
* @return string|null 라이선스 또는 null
*/
public function getLicense(): ?string;
/**
* 모듈의 메타데이터를 반환합니다.
*
* @return array 메타데이터 배열
*/
public function getMetadata(): array;
/**
* 레이아웃 확장 파일 경로를 반환합니다.
*
* @return string extensions 디렉토리 경로
*/
public function getExtensionsPath(): string;
/**
* 레이아웃 확장 파일 목록을 반환합니다.
*
* resources/extensions 디렉토리에서 JSON 파일을 검색합니다.
*
* @return array<string> JSON 파일 경로 목록
*/
public function getLayoutExtensions(): array;
/**
* 그누보드7 코어 요구 버전 제약을 반환합니다.
*
* Semantic Versioning 제약 문자열 반환 (예: ">=1.0.0", "^1.0", "~1.2.0")
* null 반환 시 버전 검증을 건너뜁니다 (역호환성 보장).
*
* @return string|null 버전 제약 문자열 또는 null
*/
public function getRequiredCoreVersion(): ?string;
/**
* 모듈 설정 기본값 파일(defaults.json) 경로를 반환합니다.
*
* @return string|null defaults.json 파일의 절대 경로, 없으면 null
*/
public function getSettingsDefaultsPath(): ?string;
/**
* 모듈에 환경설정이 있는지 확인합니다.
*
* @return bool 환경설정 존재 여부
*/
public function hasSettings(): bool;
/**
* 모듈 설정 기본값을 반환합니다.
*
* @return array 설정 기본값 배열
*/
public function getConfigValues(): array;
/**
* 모듈 설정 스키마를 반환합니다.
*
* 민감한 필드(sensitive: true) 정보 등을 포함합니다.
*
* @return array 설정 스키마 배열
*/
public function getSettingsSchema(): array;
}
@@ -0,0 +1,113 @@
<?php
namespace App\Contracts\Extension;
interface ModuleManagerInterface
{
/**
* 모든 모듈을 로드하고 초기화합니다.
*/
public function loadModules(): void;
/**
* 활성화된 모듈들만 반환합니다.
*
* @return array 활성화된 모듈 배열
*/
public function getActiveModules(): array;
/**
* 지정된 모듈을 시스템에 설치합니다.
*
* @param string $moduleName 설치할 모듈명
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
* @return bool 설치 성공 여부
*
* @throws \Exception 모듈을 찾을 수 없거나 의존성 문제 시
*/
public function installModule(string $moduleName, ?\Closure $onProgress = null): bool;
/**
* 지정된 모듈을 활성화합니다.
*
* @param string $moduleName 활성화할 모듈명
* @return array{success: bool, layouts_registered: int} 활성화 결과 및 등록된 레이아웃 개수
*/
public function activateModule(string $moduleName): array;
/**
* 지정된 모듈을 비활성화합니다.
*
* @param string $moduleName 비활성화할 모듈명
* @param bool $force 의존 템플릿이 있어도 강제 비활성화 여부
* @return array{success: bool, layouts_deleted: int, warning?: bool, dependent_templates?: array, message?: string} 비활성화 결과 및 삭제된 레이아웃 개수
*/
public function deactivateModule(string $moduleName, bool $force = false): array;
/**
* 지정된 모듈을 시스템에서 제거합니다.
*
* @param string $moduleName 제거할 모듈명
* @param bool $deleteData 모듈 데이터(테이블) 삭제 여부
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
* @return bool 제거 성공 여부
*
* @throws \Exception 모듈을 찾을 수 없을 때
*/
public function uninstallModule(string $moduleName, bool $deleteData = false, ?\Closure $onProgress = null): bool;
/**
* 지정된 이름의 모듈 인스턴스를 반환합니다.
*
* @param string $moduleName 모듈명
* @return ModuleInterface|null 모듈 인스턴스 또는 null
*/
public function getModule(string $moduleName): ?ModuleInterface;
/**
* 로드된 모든 모듈 인스턴스들을 반환합니다.
*
* @return array 모든 모듈 배열
*/
public function getAllModules(): array;
/**
* 설치되지 않은 모듈들을 반환합니다.
*
* @return array 미설치 모듈 배열
*/
public function getUninstalledModules(): array;
/**
* 설치된 모듈 정보를 데이터베이스 레코드와 함께 반환합니다.
*
* @return array 설치된 모듈 배열
*/
public function getInstalledModulesWithDetails(): array;
/**
* 특정 모듈의 정보를 반환합니다 (설치 여부와 관계없이).
*
* @param string $moduleName 모듈명
* @return array|null 모듈 정보 배열 또는 null
*/
public function getModuleInfo(string $moduleName): ?array;
/**
* 모듈의 레이아웃을 파일에서 다시 읽어 DB에 갱신합니다.
*
* @param string $moduleName 모듈명
* @return array{success: bool, layouts_refreshed: int} 갱신 결과 및 갱신된 레이아웃 개수
*
* @throws \Exception 모듈을 찾을 수 없거나 레이아웃 갱신 실패 시
*/
public function refreshModuleLayouts(string $moduleName): array;
/**
* 모듈 삭제 시 삭제될 데이터 정보를 반환합니다.
*
* @param string $moduleName 모듈명
* @return array|null 삭제 정보 (테이블 목록, 스토리지 디렉토리 목록, 용량) 또는 null
*/
public function getModuleUninstallInfo(string $moduleName): ?array;
}
@@ -0,0 +1,69 @@
<?php
namespace App\Contracts\Extension;
/**
* 모듈 환경설정 인터페이스
*
* 모듈별 환경설정 시스템을 구현하기 위한 계약입니다.
* 환경설정이 필요한 모듈은 이 인터페이스를 구현해야 합니다.
*/
interface ModuleSettingsInterface
{
/**
* 모듈 설정 기본값 파일 경로 반환
*
* @return string|null defaults.json 파일의 절대 경로, 없으면 null
*/
public function getSettingsDefaultsPath(): ?string;
/**
* 설정값 조회
*
* @param string $key 설정 키 (예: 'category.field' 또는 'field')
* @param mixed $default 기본값
* @return mixed 설정값
*/
public function getSetting(string $key, mixed $default = null): mixed;
/**
* 설정값 저장
*
* @param string $key 설정 키
* @param mixed $value 저장할 값
* @return bool 성공 여부
*/
public function setSetting(string $key, mixed $value): bool;
/**
* 전체 설정 조회
*
* @return array 모든 카테고리의 설정값
*/
public function getAllSettings(): array;
/**
* 카테고리별 설정 조회
*
* @param string $category 카테고리명
* @return array 카테고리의 설정값
*/
public function getSettings(string $category): array;
/**
* 설정 저장
*
* @param array $settings 저장할 설정 배열
* @return bool 성공 여부
*/
public function saveSettings(array $settings): bool;
/**
* 프론트엔드용 설정 조회 (민감정보 제외)
*
* frontend_schema에 따라 민감하지 않은 설정만 반환합니다.
*
* @return array 프론트엔드에 노출 가능한 설정값
*/
public function getFrontendSettings(): array;
}
+300
View File
@@ -0,0 +1,300 @@
<?php
namespace App\Contracts\Extension;
interface PluginInterface
{
/**
* 플러그인의 고유 식별자를 반환합니다 (vendor-plugin 형식).
*/
public function getIdentifier(): string;
/**
* 플러그인의 벤더/개발자명을 반환합니다.
*/
public function getVendor(): string;
/**
* 플러그인의 이름을 반환합니다.
*
* @return string|array 문자열 또는 다국어 배열 ['ko' => '...', 'en' => '...']
*/
public function getName(): string|array;
/**
* 플러그인의 버전을 반환합니다.
*
* @return string 플러그인 버전
*/
public function getVersion(): string;
/**
* 플러그인의 설명을 반환합니다.
*
* @return string|array 문자열 또는 다국어 배열 ['ko' => '...', 'en' => '...']
*/
public function getDescription(): string|array;
/**
* 플러그인의 GitHub 저장소 URL을 반환합니다.
*/
public function getGithubUrl(): ?string;
/**
* 플러그인의 라이선스를 반환합니다.
*
* @return string|null 라이선스 또는 null
*/
public function getLicense(): ?string;
/**
* 플러그인의 추가 메타데이터를 반환합니다.
*
* @return array 메타데이터 배열
*/
public function getMetadata(): array;
/**
* 플러그인을 설치합니다.
*
* @return bool 설치 성공 여부
*/
public function install(): bool;
/**
* 플러그인을 제거합니다.
*
* @return bool 제거 성공 여부
*/
public function uninstall(): bool;
/**
* 플러그인이 런타임에 동적으로 생성한 테이블 목록을 반환합니다.
*
* 플러그인 언인스톨 시 $deleteData=true이면 마이그레이션 롤백 전에 호출됩니다.
* 반환된 테이블들은 PluginManager가 일괄 삭제합니다.
*
* 주의:
* - 마이그레이션 롤백 전에 호출되므로 메타 테이블(정적 테이블)이 아직 존재합니다.
* - 개별 테이블 삭제 실패 시에도 언인스톨은 계속 진행됩니다.
*
* @return array<string> 삭제할 테이블명 배열
*/
public function getDynamicTables(): array;
/**
* 플러그인을 활성화합니다.
*
* @return bool 활성화 성공 여부
*/
public function activate(): bool;
/**
* 플러그인을 비활성화합니다.
*
* @return bool 비활성화 성공 여부
*/
public function deactivate(): bool;
/**
* 버전별 업그레이드 스텝을 반환합니다.
*
* 반환 형식: ['1.1.0' => callable|UpgradeStepInterface, ...]
* 시스템이 fromVersion 초과 ~ toVersion 이하의 스텝을 자동 필터링 후 순차 실행합니다.
*
* @return array<string, callable|\App\Contracts\Extension\UpgradeStepInterface> 버전 => 스텝 매핑
*/
public function upgrades(): array;
/**
* 플러그인이 제공하는 라우트 정보를 반환합니다.
*
* @return array 라우트 정보 배열
*/
public function getRoutes(): array;
/**
* 플러그인의 마이그레이션 파일 목록을 반환합니다.
*
* @return array 마이그레이션 파일 경로 배열
*/
public function getMigrations(): array;
/**
* 플러그인의 뷰 파일 목록을 반환합니다.
*
* @return array 뷰 파일 경로 배열
*/
public function getViews(): array;
/**
* 플러그인의 의존성 정보를 반환합니다.
*
* @return array 의존성 목록 배열
*/
public function getDependencies(): array;
/**
* 플러그인이 사용하는 권한 목록을 반환합니다.
*
* @return array 권한 목록 배열
*/
public function getPermissions(): array;
/**
* 플러그인의 설정 정보를 반환합니다.
*
* @return array 설정 정보 배열
*/
public function getConfig(): array;
/**
* 플러그인이 제공하는 훅 정보를 반환합니다.
*
* @return array 훅 정보 배열
*/
public function getHooks(): array;
/**
* 플러그인의 훅 리스너 목록을 반환합니다.
*
* @return array 훅 리스너 클래스 목록 배열
*/
public function getHookListeners(): array;
/**
* 플러그인의 스케줄 작업 목록을 반환합니다.
*
* 스케줄 배열의 각 항목은 다음 구조를 따라야 합니다:
* - command: Artisan 커맨드 이름
* - schedule: 스케줄 주기 ('daily', 'hourly', 'everyMinute', 'weekly' 또는 cron 표현식)
* - description: 작업 설명 (선택)
* - enabled_config: 설정 키 (선택, 설정에 따라 활성화 여부 결정)
*
* @return array 스케줄 작업 배열
*/
public function getSchedules(): array;
/**
* 플러그인 설치 시 실행할 시더 클래스 목록을 반환합니다.
*
* 배열 순서대로 실행됩니다.
* 빈 배열 반환 시 database/seeders/ 디렉토리의 모든 시더를 자동 검색합니다. (역호환)
*
* @return array<class-string<\Illuminate\Database\Seeder>> 시더 클래스명 배열 (FQCN)
*/
public function getSeeders(): array;
/**
* 플러그인이 정의하는 역할 목록을 반환합니다.
*
* 역할 배열의 각 항목은 다음 구조를 따라야 합니다:
* - identifier: 역할 고유 식별자 (vendor-plugin.rolename 형식)
* - name: 다국어 배열 ['ko' => '역할명', 'en' => 'Role Name']
* - description: 다국어 배열 ['ko' => '설명', 'en' => 'Description']
*
* @return array 역할 정보 배열
*/
public function getRoles(): array;
/**
* 레이아웃 확장 파일 경로를 반환합니다.
*
* @return string extensions 디렉토리 경로
*/
public function getExtensionsPath(): string;
/**
* 레이아웃 확장 파일 목록을 반환합니다.
*
* resources/extensions 디렉토리에서 JSON 파일을 검색합니다.
*
* @return array<string> JSON 파일 경로 목록
*/
public function getLayoutExtensions(): array;
/**
* 그누보드7 코어 요구 버전 제약을 반환합니다.
*
* Semantic Versioning 제약 문자열 반환 (예: ">=1.0.0", "^1.0", "~1.2.0")
* null 반환 시 버전 검증을 건너뜁니다 (역호환성 보장).
*
* @return string|null 버전 제약 문자열 또는 null
*/
public function getRequiredCoreVersion(): ?string;
/**
* 플러그인이 설정 페이지를 가지고 있는지 확인합니다.
*
* @return bool 설정 페이지 존재 여부
*/
public function hasSettings(): bool;
/**
* 플러그인 설정 스키마를 반환합니다.
*
* 설정 스키마는 설정 필드의 타입, 라벨, 기본값, 유효성 검사 규칙 등을 정의합니다.
* FormRequest에서 동적 유효성 검사 규칙 생성에 사용됩니다.
*
* @return array 설정 스키마 배열
*
* @example
* ```php
* return [
* 'display_mode' => [
* 'type' => 'enum',
* 'options' => ['popup', 'layer'],
* 'default' => 'layer',
* 'label' => ['ko' => '표시 방식', 'en' => 'Display Mode'],
* 'required' => false,
* ],
* 'api_key' => [
* 'type' => 'string',
* 'label' => ['ko' => 'API 키', 'en' => 'API Key'],
* 'sensitive' => true, // 암호화 저장
* 'required' => true,
* ],
* ];
* ```
*/
public function getSettingsSchema(): array;
/**
* 플러그인 설정 페이지 레이아웃 경로를 반환합니다.
*
* 설정 페이지의 JSON 레이아웃 파일 경로를 반환합니다.
* null 반환 시 설정 페이지가 없는 것으로 간주합니다.
*
* @return string|null 레이아웃 파일 절대 경로 또는 null
*/
public function getSettingsLayout(): ?string;
/**
* 플러그인 설정 페이지 라우트 경로를 반환합니다.
*
* 플러그인 목록에서 설정 버튼 클릭 시 이동할 경로입니다.
* null 반환 시 설정 페이지가 없는 것으로 간주합니다.
*
* @return string|null 설정 페이지 라우트 (예: '/admin/plugins/{identifier}/settings')
*/
public function getSettingsRoute(): ?string;
/**
* 플러그인 설정 값을 반환합니다.
*
* 현재 저장된 설정 값을 반환합니다. (DB 조회용)
*
* @return array 설정 값 배열
*/
public function getConfigValues(): array;
/**
* 플러그인 설정 기본값 파일 경로를 반환합니다.
*
* config/settings/defaults.json 파일이 존재하면 해당 경로를 반환합니다.
* 이 파일에는 defaults(기본값)와 frontend_schema(프론트엔드 노출 스키마)가 정의됩니다.
*
* @return string|null defaults.json 파일 절대 경로 또는 null
*/
public function getSettingsDefaultsPath(): ?string;
}
@@ -0,0 +1,113 @@
<?php
namespace App\Contracts\Extension;
interface PluginManagerInterface
{
/**
* 모든 플러그인을 로드하고 초기화합니다.
*/
public function loadPlugins(): void;
/**
* 활성화된 플러그인들만 반환합니다.
*
* @return array 활성화된 플러그인 배열
*/
public function getActivePlugins(): array;
/**
* 지정된 플러그인을 시스템에 설치합니다.
*
* @param string $pluginName 설치할 플러그인명
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
* @return bool 설치 성공 여부
*
* @throws \Exception 플러그인을 찾을 수 없거나 의존성 문제 시
*/
public function installPlugin(string $pluginName, ?\Closure $onProgress = null): bool;
/**
* 지정된 플러그인을 활성화합니다.
*
* @param string $pluginName 활성화할 플러그인명
* @return array{success: bool, layouts_registered: int} 활성화 결과
*/
public function activatePlugin(string $pluginName): array;
/**
* 지정된 플러그인을 비활성화합니다.
*
* @param string $pluginName 비활성화할 플러그인명
* @param bool $force 의존 템플릿이 있어도 강제 비활성화 여부
* @return array{success: bool, layouts_deleted: int, warning?: bool, dependent_templates?: array, message?: string} 비활성화 결과
*/
public function deactivatePlugin(string $pluginName, bool $force = false): array;
/**
* 지정된 플러그인을 시스템에서 제거합니다.
*
* @param string $pluginName 제거할 플러그인명
* @param bool $deleteData 데이터 삭제 여부 (마이그레이션 롤백)
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
* @return bool 제거 성공 여부
*
* @throws \Exception 플러그인을 찾을 수 없을 때
*/
public function uninstallPlugin(string $pluginName, bool $deleteData = false, ?\Closure $onProgress = null): bool;
/**
* 지정된 이름의 플러그인 인스턴스를 반환합니다.
*
* @param string $pluginName 플러그인명
* @return PluginInterface|null 플러그인 인스턴스 또는 null
*/
public function getPlugin(string $pluginName): ?PluginInterface;
/**
* 로드된 모든 플러그인 인스턴스들을 반환합니다.
*
* @return array 모든 플러그인 배열
*/
public function getAllPlugins(): array;
/**
* 설치되지 않은 플러그인들을 반환합니다.
*
* @return array 미설치 플러그인 배열
*/
public function getUninstalledPlugins(): array;
/**
* 설치된 플러그인 정보를 데이터베이스 레코드와 함께 반환합니다.
*
* @return array 설치된 플러그인 배열
*/
public function getInstalledPluginsWithDetails(): array;
/**
* 특정 플러그인의 정보를 반환합니다 (설치 여부와 관계없이).
*
* @param string $pluginName 플러그인명
* @return array|null 플러그인 정보 배열 또는 null
*/
public function getPluginInfo(string $pluginName): ?array;
/**
* 플러그인의 레이아웃을 파일에서 다시 읽어 DB에 갱신합니다.
*
* @param string $pluginName 플러그인명
* @return array{success: bool, layouts_refreshed: int} 갱신 결과 및 갱신된 레이아웃 개수
*
* @throws \Exception 플러그인을 찾을 수 없거나 레이아웃 갱신 실패 시
*/
public function refreshPluginLayouts(string $pluginName): array;
/**
* 플러그인 삭제 시 삭제될 데이터 정보를 반환합니다.
*
* @param string $pluginName 플러그인명
* @return array|null 삭제 정보 (테이블 목록, 스토리지 디렉토리 목록, 용량) 또는 null
*/
public function getPluginUninstallInfo(string $pluginName): ?array;
}
@@ -0,0 +1,138 @@
<?php
namespace App\Contracts\Extension;
/**
* 확장(모듈/플러그인) 스토리지 인터페이스
*
* 모듈과 플러그인에서 파일을 저장하고 관리하기 위한 표준화된 인터페이스입니다.
* 카테고리별로 파일을 분리하여 저장하며, 다양한 스토리지 백엔드를 지원합니다.
*/
interface StorageInterface
{
/**
* 파일을 저장합니다.
*
* @param string $category 카테고리 (settings, attachments, images, cache, temp)
* @param string $path 파일 경로 (카테고리 하위 상대 경로)
* @param mixed $content 파일 내용 (string|resource)
* @return bool 저장 성공 여부
*/
public function put(string $category, string $path, mixed $content): bool;
/**
* 파일 내용을 가져옵니다.
*
* @param string $category 카테고리
* @param string $path 파일 경로
* @return string|null 파일 내용 (파일이 없으면 null)
*/
public function get(string $category, string $path): ?string;
/**
* 파일이 존재하는지 확인합니다.
*
* @param string $category 카테고리
* @param string $path 파일 경로
* @return bool 파일 존재 여부
*/
public function exists(string $category, string $path): bool;
/**
* 파일을 삭제합니다.
*
* @param string $category 카테고리
* @param string $path 파일 경로
* @return bool 삭제 성공 여부
*/
public function delete(string $category, string $path): bool;
/**
* 파일의 공개 URL을 반환합니다.
*
* public disk인 경우 직접 URL을 반환하고,
* private disk인 경우 null을 반환합니다 (별도 API 엔드포인트 사용).
*
* @param string $category 카테고리
* @param string $path 파일 경로
* @return string|null 파일 URL (private disk인 경우 null)
*/
public function url(string $category, string $path): ?string;
/**
* 디렉토리 내 모든 파일 목록을 반환합니다.
*
* @param string $category 카테고리
* @param string $directory 디렉토리 경로 (카테고리 하위 상대 경로, 빈 문자열이면 카테고리 루트)
* @return array 파일 경로 배열
*/
public function files(string $category, string $directory = ''): array;
/**
* 디렉토리와 그 하위의 모든 파일을 삭제합니다.
*
* @param string $category 카테고리
* @param string $directory 디렉토리 경로 (카테고리 하위 상대 경로, 빈 문자열이면 카테고리 루트)
* @return bool 삭제 성공 여부
*/
public function deleteDirectory(string $category, string $directory = ''): bool;
/**
* 카테고리의 전체 파일 시스템 경로를 반환합니다.
*
* @param string $category 카테고리
* @return string 전체 경로 (예: storage/app/modules/{identifier}/{category})
*/
public function getBasePath(string $category): string;
/**
* 사용 중인 디스크 이름을 반환합니다.
*
* @return string 디스크 이름 (local, public, s3 등)
*/
public function getDisk(): string;
/**
* 카테고리의 모든 파일을 삭제합니다.
*
* @param string $category 카테고리
* @return bool 삭제 성공 여부
*/
public function deleteAll(string $category): bool;
/**
* 파일을 스트리밍 응답으로 반환합니다.
*
* 파일을 다운로드하거나 브라우저에 표시할 수 있도록
* StreamedResponse를 생성합니다.
*
* @param string $category 카테고리
* @param string $path 파일 경로
* @param string $filename 다운로드 시 표시될 파일명
* @param array $headers 추가 HTTP 헤더
* @return \Symfony\Component\HttpFoundation\StreamedResponse|null 파일 스트림 (파일이 없으면 null)
*/
public function response(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 사용할 디스크를 변경한 새 인스턴스를 반환합니다.
*
* 기존 인스턴스는 변경하지 않고, 새 디스크를 사용하는 복제된 인스턴스를 반환합니다.
* 첨부파일 등 레코드별로 다른 디스크를 사용하는 경우에 활용합니다.
*
* @param string $disk 디스크 이름 (local, public, s3 등)
* @return static 새 디스크를 사용하는 인스턴스
*/
public function withDisk(string $disk): static;
/**
* 파일을 다운로드 응답으로 반환합니다.
*
* @param string $category 카테고리
* @param string $path 파일 경로
* @param string $filename 다운로드 시 표시될 파일명
* @param array $headers 추가 HTTP 헤더
* @return \Symfony\Component\HttpFoundation\StreamedResponse|null 다운로드 응답 (파일이 없으면 null)
*/
public function download(string $category, string $path, string $filename, array $headers = []): ?\Symfony\Component\HttpFoundation\StreamedResponse;
}
@@ -0,0 +1,139 @@
<?php
namespace App\Contracts\Extension;
interface TemplateInterface
{
/**
* 템플릿의 고유 식별자를 반환합니다 (vendor-name 형식).
*
* @return string 템플릿 식별자 (예: sirsoft-admin_basic)
*/
public function getIdentifier(): string;
/**
* 템플릿의 벤더/개발자명을 반환합니다.
*
* @return string 벤더명 (예: sirsoft)
*/
public function getVendor(): string;
/**
* 템플릿의 이름을 반환합니다 (표시용).
*
* @return string 템플릿 이름 (예: Admin Basic)
*/
public function getName(): string;
/**
* 템플릿의 버전을 반환합니다.
*
* @return string 템플릿 버전 (시맨틱 버전, 예: 1.0.0)
*/
public function getVersion(): string;
/**
* 템플릿의 설명을 반환합니다.
*
* @return string 템플릿 설명
*/
public function getDescription(): string;
/**
* 템플릿의 타입을 반환합니다.
*
* @return string 템플릿 타입 (admin: 관리자용, user: 사용자용)
*/
public function getType(): string;
/**
* 템플릿을 설치합니다.
*
* @return bool 설치 성공 여부
*/
public function install(): bool;
/**
* 템플릿을 제거합니다.
*
* @return bool 제거 성공 여부
*/
public function uninstall(): bool;
/**
* 템플릿을 활성화합니다.
*
* @return bool 활성화 성공 여부
*/
public function activate(): bool;
/**
* 템플릿을 비활성화합니다.
*
* @return bool 비활성화 성공 여부
*/
public function deactivate(): bool;
/**
* 템플릿의 의존성 정보를 반환합니다.
*
* @return array 의존성 목록 배열 (모듈/플러그인 identifier => 버전)
*/
public function getDependencies(): array;
/**
* 템플릿이 제공하는 기본 레이아웃 목록을 반환합니다.
*
* @return array 레이아웃 파일 경로 배열
*/
public function getDefaultLayouts(): array;
/**
* 템플릿의 컴포넌트 매니페스트 경로를 반환합니다.
*
* @return string 컴포넌트 매니페스트 파일 경로 (components.json)
*/
public function getComponentsManifestPath(): string;
/**
* 템플릿의 라우트 정의 파일 경로를 반환합니다.
*
* @return string 라우트 정의 파일 경로 (routes.json)
*/
public function getRoutesPath(): string;
/**
* 템플릿의 빌드된 컴포넌트 번들 경로를 반환합니다.
*
* @return string 컴포넌트 번들 파일 경로 (dist/components.js)
*/
public function getComponentsBundlePath(): string;
/**
* 템플릿의 에셋 디렉토리 경로를 반환합니다.
*
* @return string 에셋 디렉토리 경로 (assets/)
*/
public function getAssetsPath(): string;
/**
* 템플릿의 다국어 파일 디렉토리 경로를 반환합니다.
*
* @return string 다국어 파일 디렉토리 경로 (lang/)
*/
public function getLangPath(): string;
/**
* 템플릿의 메타데이터를 반환합니다.
*
* @return array 메타데이터 배열 (template.json 내용)
*/
public function getMetadata(): array;
/**
* 템플릿의 GitHub 저장소 URL을 반환합니다.
*
* @return string|null GitHub URL 또는 null
*/
public function getGithubUrl(): ?string;
}
@@ -0,0 +1,183 @@
<?php
namespace App\Contracts\Extension;
interface TemplateManagerInterface
{
/**
* 모든 템플릿을 로드하고 초기화합니다.
*/
public function loadTemplates(): void;
/**
* /templates 디렉토리를 스캔하여 사용 가능한 템플릿을 발견합니다.
*
* @return array 발견된 템플릿 배열 (identifier => path)
*/
public function scanTemplates(): array;
/**
* 로드된 모든 템플릿 인스턴스들을 반환합니다.
*
* @return array 모든 템플릿 배열
*/
public function getAllTemplates(): array;
/**
* 활성화된 템플릿을 반환합니다.
*
* @param string $type 템플릿 타입 (admin 또는 user)
* @return array|null 활성화된 템플릿 데이터 또는 null
*/
public function getActiveTemplate(string $type): ?array;
/**
* 지정된 식별자의 템플릿 데이터를 반환합니다.
*
* @param string $identifier 템플릿 식별자 (vendor-name 형식)
* @return array|null 템플릿 데이터 또는 null
*/
public function getTemplate(string $identifier): ?array;
/**
* 지정된 템플릿을 시스템에 설치합니다.
*
* @param string $identifier 설치할 템플릿 식별자
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
* @return bool 설치 성공 여부
*
* @throws \Exception 템플릿을 찾을 수 없거나 의존성 문제 시
*/
public function installTemplate(string $identifier, ?\Closure $onProgress = null): bool;
/**
* 지정된 템플릿을 제거합니다.
*
* @param string $identifier 제거할 템플릿 식별자
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
* @return bool 제거 성공 여부
*
* @throws \Exception 템플릿을 찾을 수 없을 때
*/
public function uninstallTemplate(string $identifier, ?\Closure $onProgress = null): bool;
/**
* 지정된 템플릿을 활성화합니다.
*
* @param string $identifier 활성화할 템플릿 식별자
* @param bool $force 의존성 미충족 시에도 강제 활성화 여부
* @return array{success: bool, warning?: bool, missing_modules?: array, missing_plugins?: array, message?: string} 활성화 결과
*/
public function activateTemplate(string $identifier, bool $force = false): array;
/**
* 지정된 템플릿을 비활성화합니다.
*
* @param string $identifier 비활성화할 템플릿 식별자
* @return bool 비활성화 성공 여부
*/
public function deactivateTemplate(string $identifier): bool;
/**
* 템플릿의 의존성을 검증합니다.
*
* @param string $identifier 검증할 템플릿 식별자
* @return bool 의존성 충족 여부
*
* @throws \Exception 의존성이 충족되지 않을 때
*/
public function validateTemplate(string $identifier): bool;
/**
* 설치되지 않은 템플릿들을 반환합니다.
*
* @return array 미설치 템플릿 배열
*/
public function getUninstalledTemplates(): array;
/**
* 설치된 템플릿 정보를 데이터베이스 레코드와 함께 반환합니다.
*
* @return array 설치된 템플릿 배열
*/
public function getInstalledTemplatesWithDetails(): array;
/**
* 특정 템플릿의 정보를 반환합니다 (설치 여부와 관계없이).
*
* @param string $identifier 템플릿 식별자
* @return array|null 템플릿 정보 배열 또는 null
*/
public function getTemplateInfo(string $identifier): ?array;
/**
* 타입별 템플릿 목록을 반환합니다.
*
* @param string $type 템플릿 타입 (admin 또는 user)
* @return array 해당 타입의 템플릿 배열
*/
public function getTemplatesByType(string $type): array;
/**
* 템플릿의 레이아웃을 파일에서 다시 읽어 DB에 갱신합니다.
*
* @param string $identifier 템플릿 식별자
* @return array{success: bool, layouts_refreshed: int} 갱신 결과 및 갱신된 레이아웃 개수
*
* @throws \Exception 템플릿을 찾을 수 없거나 레이아웃 갱신 실패 시
*/
public function refreshTemplateLayouts(string $identifier): array;
/**
* 템플릿의 의존성 충족 상태를 확인합니다.
*
* template.json의 dependencies를 기반으로 모든 모듈/플러그인의
* 활성화 상태 및 버전 요구사항 충족 여부를 확인합니다.
*
* 각 의존성 항목 구조:
* - identifier: 모듈/플러그인 식별자
* - name: 로케일화된 이름 (미설치 시 identifier 사용)
* - required_version: 요구 버전 제약조건
* - installed_version: 설치된 버전 (null이면 미설치)
* - is_active: 활성화 여부
* - version_met: 버전 요구사항 충족 여부
* - met: 전체 요구사항 충족 여부
*
* @param string $identifier 템플릿 식별자
* @return array{met: bool, modules: array, plugins: array} 의존성 상태
*/
public function checkDependenciesStatus(string $identifier): array;
/**
* 템플릿의 미충족 의존성 목록을 반환합니다.
*
* checkDependenciesStatus()를 활용하여 충족되지 않은 의존성만 필터링합니다.
* 각 항목에는 identifier, name, required_version 등이 포함됩니다.
*
* @param string $identifier 템플릿 식별자
* @return array{modules: array, plugins: array} 미충족 의존성 목록
*/
public function getUnmetDependencies(string $identifier): array;
/**
* 특정 모듈에 의존하는 활성 템플릿 목록을 반환합니다.
*
* 모든 활성화된 템플릿을 조회하여 해당 모듈을 dependencies.modules에
* 포함하고 있는 템플릿의 identifier 목록을 반환합니다.
*
* @param string $moduleIdentifier 모듈 식별자
* @return array 의존하는 템플릿 identifier 배열
*/
public function getTemplatesDependingOnModule(string $moduleIdentifier): array;
/**
* 특정 플러그인에 의존하는 활성 템플릿 목록을 반환합니다.
*
* 모든 활성화된 템플릿을 조회하여 해당 플러그인을 dependencies.plugins에
* 포함하고 있는 템플릿의 identifier 목록을 반환합니다.
*
* @param string $pluginIdentifier 플러그인 식별자
* @return array 의존하는 템플릿 identifier 배열
*/
public function getTemplatesDependingOnPlugin(string $pluginIdentifier): array;
}
@@ -0,0 +1,36 @@
<?php
namespace App\Contracts\Extension;
use App\Extension\UpgradeContext;
/**
* 버전별 업그레이드 스텝 인터페이스
*
* 모듈/플러그인의 버전별 업그레이드 로직을 정의합니다.
* 각 스텝은 upgrades/ 디렉토리에 위치하며, 파일명에서 버전을 추출합니다.
*
* 주요 사용 사례:
* - DB 스키마/데이터 마이그레이션
* - 설정 구조 변경
* - 정적 메뉴/권한 제거 (cleanupStaleMenus/cleanupStalePermissions 활용)
*
* ⚠️ 동적 데이터 정리:
* 확장이 런타임에 생성한 동적 메뉴/권한/역할은 자동으로 정리되지 않습니다.
* 새 버전에서 동적 데이터의 형식이 변경되거나, 기존 정적 메뉴/권한을 제거해야 하는 경우
* UpgradeStep에서 ExtensionMenuSyncHelper::cleanupStaleMenus() 또는
* ExtensionRoleSyncHelper::cleanupStalePermissions()를 명시적으로 호출하세요.
*
* @example Upgrade_1_1_0.php → 버전 1.1.0 업그레이드 스텝
*/
interface UpgradeStepInterface
{
/**
* 업그레이드 스텝을 실행합니다.
*
* @param UpgradeContext $context 업그레이드 컨텍스트 (버전 정보, 로거 등)
*
* @throws \Exception 업그레이드 실패 시 예외 발생 (상위에서 백업 복원 처리)
*/
public function run(UpgradeContext $context): void;
}
@@ -0,0 +1,55 @@
<?php
namespace App\Contracts\Repositories;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
/**
* 활동 로그 Repository 인터페이스
*/
interface ActivityLogRepositoryInterface
{
/**
* 특정 모델의 활동 로그를 페이지네이션하여 조회합니다.
*
* @param Model $model 대상 모델
* @param array $filters 필터 조건
* @return LengthAwarePaginator 페이지네이션된 로그 목록
*/
public function getPaginatedForModel(Model $model, array $filters = []): LengthAwarePaginator;
/**
* 활동 로그 목록을 페이지네이션하여 조회합니다.
*
* @param array $filters 필터 조건
* @return LengthAwarePaginator 페이지네이션된 로그 목록
*/
public function getPaginated(array $filters = []): LengthAwarePaginator;
/**
* 활동 로그를 삭제합니다.
*
* @param int $id 삭제할 활동 로그 ID
* @return bool 삭제 성공 여부
*/
public function delete(int $id): bool;
/**
* 여러 활동 로그를 일괄 삭제합니다.
*
* @param array<int> $ids 삭제할 활동 로그 ID 목록
* @return int 삭제된 건수
*/
public function deleteMany(array $ids): int;
/**
* 최근 활동 로그를 스코프 권한 적용하여 조회합니다.
*
* @param string $permission 권한 식별자
* @param int $limit 조회할 활동 수
* @return Collection 활동 로그 컬렉션
*/
public function getRecent(string $permission, int $limit = 5): Collection;
}
@@ -0,0 +1,148 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\Attachment;
use Illuminate\Database\Eloquent\Collection;
/**
* 첨부파일 Repository 인터페이스
*/
interface AttachmentRepositoryInterface
{
/**
* ID로 첨부파일 조회
*
* @param int $id 첨부파일 ID
* @return Attachment|null 첨부파일 또는 null
*/
public function findById(int $id): ?Attachment;
/**
* 여러 ID로 첨부파일 조회 (order 정렬)
*
* @param array<int> $ids 첨부파일 ID 배열
* @return Collection<int, Attachment>
*/
public function findByIds(array $ids): Collection;
/**
* 해시로 첨부파일 조회
*
* @param string $hash 첨부파일 해시
* @return Attachment|null 첨부파일 또는 null
*/
public function findByHash(string $hash): ?Attachment;
/**
* 첨부 대상별 첨부파일 조회
*
* @param string $type attachmentable_type
* @param int $id attachmentable_id
* @param string|null $collection 컬렉션 필터 (null이면 전체)
* @return Collection<int, Attachment>
*/
public function getByAttachmentable(string $type, int $id, ?string $collection = null): Collection;
/**
* 첨부파일 생성
*
* @param array<string, mixed> $data 생성 데이터
* @return Attachment 생성된 첨부파일
*/
public function create(array $data): Attachment;
/**
* 첨부파일 업데이트
*
* @param int $id 첨부파일 ID
* @param array<string, mixed> $data 업데이트 데이터
* @return Attachment 업데이트된 첨부파일
*/
public function update(int $id, array $data): Attachment;
/**
* 첨부파일 삭제 (soft delete)
*
* @param int $id 첨부파일 ID
* @return bool 삭제 성공 여부
*/
public function delete(int $id): bool;
/**
* 첨부파일 영구 삭제
*
* @param int $id 첨부파일 ID
* @return bool 삭제 성공 여부
*/
public function forceDelete(int $id): bool;
/**
* 순서 변경
*
* @param array<int, array{id: int, order: int}> $orderData 순서 데이터
*/
public function reorder(array $orderData): void;
/**
* 특정 소스 식별자의 첨부파일 목록 조회
*
* @param string $identifier 소스 식별자
* @return Collection 첨부파일 컬렉션
*/
public function getBySourceIdentifier(string $identifier): Collection;
/**
* 특정 소스 식별자의 첨부파일 일괄 삭제
*
* @param string $identifier 소스 식별자
* @return int 삭제된 개수
*/
public function deleteBySourceIdentifier(string $identifier): int;
/**
* 컬렉션 내 최대 order 값 조회
*
* @param string $type attachmentable_type
* @param int $id attachmentable_id
* @param string $collection 컬렉션명
* @return int 최대 order 값
*/
public function getMaxOrder(string $type, int $id, string $collection): int;
/**
* 컬렉션명으로만 최대 order 값 조회 (attachmentable 없는 경우)
*
* @param string $collection 컬렉션명
* @return int 최대 order 값
*/
public function getMaxOrderByCollection(string $collection): int;
/**
* 컬렉션명으로 첨부파일 조회 (attachmentable 없는 경우)
*
* @param string $collection 컬렉션명
* @return Collection<int, Attachment>
*/
public function getByCollection(string $collection): Collection;
/**
* 미연결 첨부파일을 특정 엔티티에 연결합니다.
*
* @param string $collection 컬렉션명
* @param string $attachmentableType 연결할 모델 클래스명
* @param int $attachmentableId 연결할 모델 ID
* @return int 연결된 첨부파일 수
*/
public function linkUnattachedFiles(string $collection, string $attachmentableType, int $attachmentableId): int;
/**
* 특정 엔티티의 첨부파일 순서를 재정렬합니다.
* 삭제 등으로 중간에 빈 순서가 생긴 경우 1부터 연속된 순서로 재정렬합니다.
*
* @param string $attachmentableType 첨부 대상 타입
* @param int $attachmentableId 첨부 대상 ID
* @param string $collection 컬렉션명
*/
public function reorderAfterDelete(string $attachmentableType, int $attachmentableId, string $collection): void;
}
@@ -0,0 +1,131 @@
<?php
namespace App\Contracts\Repositories;
/**
* 설정 저장소 인터페이스
*
* JSON 파일 기반 설정 관리를 위한 Repository 인터페이스입니다.
*/
interface ConfigRepositoryInterface
{
/**
* 모든 카테고리의 설정을 조회합니다.
*
* @return array<string, array<string, mixed>> 카테고리별 설정 배열
*/
public function all(): array;
/**
* 특정 카테고리의 설정을 조회합니다.
*
* @param string $category 카테고리명 (예: 'general', 'mail')
* @return array<string, mixed> 설정 배열
*/
public function getCategory(string $category): array;
/**
* 도트 노테이션으로 특정 설정값을 조회합니다.
*
* @param string $key 설정 키 (예: 'mail.host', 'general.site_name')
* @param mixed $default 기본값
* @return mixed 설정값
*/
public function get(string $key, mixed $default = null): mixed;
/**
* 도트 노테이션으로 특정 설정값을 저장합니다.
*
* @param string $key 설정 키
* @param mixed $value 저장할 값
* @return bool 저장 성공 여부
*/
public function set(string $key, mixed $value): bool;
/**
* 여러 설정을 일괄 저장합니다.
*
* @param array<string, mixed> $settings 설정 배열
* @return bool 저장 성공 여부
*/
public function setMany(array $settings): bool;
/**
* 특정 카테고리의 설정을 저장합니다.
*
* @param string $category 카테고리명
* @param array<string, mixed> $settings 설정 배열
* @return bool 저장 성공 여부
*/
public function saveCategory(string $category, array $settings): bool;
/**
* 설정 키 존재 여부를 확인합니다.
*
* @param string $key 설정 키
* @return bool 존재 여부
*/
public function has(string $key): bool;
/**
* 특정 설정을 삭제합니다.
*
* @param string $key 설정 키
* @return bool 삭제 성공 여부
*/
public function delete(string $key): bool;
/**
* 사용 가능한 카테고리 목록을 반환합니다.
*
* @return array<string> 카테고리 목록
*/
public function getCategories(): array;
/**
* 카테고리 존재 여부를 확인합니다.
*
* @param string $category 카테고리명
* @return bool 존재 여부
*/
public function categoryExists(string $category): bool;
/**
* 설정 파일을 초기화합니다.
*
* @param array<string, array<string, mixed>> $settings 초기 설정값
* @return bool 초기화 성공 여부
*/
public function initialize(array $settings = []): bool;
/**
* 설정을 백업합니다.
*
* @return string 백업 파일 경로
*/
public function backup(): string;
/**
* 백업에서 설정을 복원합니다.
*
* @param string $backupPath 백업 파일 경로
* @return bool 복원 성공 여부
*/
public function restore(string $backupPath): bool;
/**
* 기본 설정값을 반환합니다.
*
* @return array<string, array<string, mixed>> 카테고리별 기본 설정
*/
public function getDefaults(): array;
/**
* 프론트엔드 스키마를 반환합니다.
*
* 프론트엔드에 노출할 설정 필드와 타입 캐스팅 규칙을 정의합니다.
*
* @return array<string, array<string, mixed>> 카테고리별 스키마
*/
public function getFrontendSchema(): array;
}
@@ -0,0 +1,149 @@
<?php
namespace App\Contracts\Repositories;
use App\Enums\LayoutSourceType;
use App\Models\LayoutExtension;
use Illuminate\Support\Collection;
/**
* 레이아웃 확장 리포지토리 인터페이스
*/
interface LayoutExtensionRepositoryInterface
{
/**
* 특정 확장점에 등록된 확장 목록 조회
*
* @param int $templateId 템플릿 ID
* @param string $extensionPointName 확장점 이름
* @return Collection<int, LayoutExtension>
*/
public function getByExtensionPoint(int $templateId, string $extensionPointName): Collection;
/**
* 특정 레이아웃을 타겟으로 하는 오버레이 목록 조회
*
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름
* @return Collection<int, LayoutExtension>
*/
public function getOverlaysByLayout(int $templateId, string $layoutName): Collection;
/**
* 확장 등록
*
* @param array $data 확장 데이터
* @return LayoutExtension
*/
public function create(array $data): LayoutExtension;
/**
* 확장 등록 또는 업데이트 (upsert)
*
* 동일한 조건의 확장이 존재하면 업데이트하고, 없으면 생성합니다.
*
* @param array $attributes 조회 조건 (template_id, extension_type, target_name, source_type, source_identifier)
* @param array $values 생성/업데이트할 값
* @return LayoutExtension
*/
public function updateOrCreate(array $attributes, array $values): LayoutExtension;
/**
* 출처별 확장 삭제 (soft delete)
*
* @param LayoutSourceType $sourceType 출처 타입
* @param string $identifier 출처 식별자
* @return int 삭제된 레코드 수
*/
public function softDeleteBySource(LayoutSourceType $sourceType, string $identifier): int;
/**
* 출처별 확장 복원
*
* @param LayoutSourceType $sourceType 출처 타입
* @param string $identifier 출처 식별자
* @return int 복원된 레코드 수
*/
public function restoreBySource(LayoutSourceType $sourceType, string $identifier): int;
/**
* 출처별 확장 영구 삭제
*
* @param LayoutSourceType $sourceType 출처 타입
* @param string $identifier 출처 식별자
* @return int 삭제된 레코드 수
*/
public function forceDeleteBySource(LayoutSourceType $sourceType, string $identifier): int;
/**
* 템플릿 오버라이드 확인 (Extension Point용)
*
* 특정 extension_point에 대해 템플릿이 오버라이드를 정의했는지 확인합니다.
*
* @param int $templateId 템플릿 ID
* @param string $extensionPointName 확장점 이름
* @param string $moduleIdentifier 모듈/플러그인 식별자
* @return LayoutExtension|null 템플릿 오버라이드 또는 null
*/
public function findTemplateOverrideForExtensionPoint(
int $templateId,
string $extensionPointName,
string $moduleIdentifier
): ?LayoutExtension;
/**
* 템플릿 오버라이드 확인 (Overlay용)
*
* 특정 target_layout에 대해 템플릿이 오버라이드를 정의했는지 확인합니다.
*
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름
* @param string $moduleIdentifier 모듈/플러그인 식별자
* @return LayoutExtension|null 템플릿 오버라이드 또는 null
*/
public function findTemplateOverrideForOverlay(
int $templateId,
string $layoutName,
string $moduleIdentifier
): ?LayoutExtension;
/**
* 오버라이드를 고려한 Extension Point 조회
*
* 템플릿 오버라이드가 있는 모듈 확장은 제외하고,
* 오버라이드된 버전과 원본 모듈 확장을 함께 반환합니다.
*
* @param int $templateId 템플릿 ID
* @param string $extensionPointName 확장점 이름
* @return Collection<int, LayoutExtension>
*/
public function getResolvedExtensionPoints(int $templateId, string $extensionPointName): Collection;
/**
* 오버라이드를 고려한 Overlay 조회
*
* 템플릿 오버라이드가 있는 모듈 확장은 제외하고,
* 오버라이드된 버전과 원본 모듈 확장을 함께 반환합니다.
*
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름
* @return Collection<int, LayoutExtension>
*/
public function getResolvedOverlays(int $templateId, string $layoutName): Collection;
/**
* 특정 템플릿의 모든 확장 조회
*
* @param int $templateId 템플릿 ID
* @return Collection<int, LayoutExtension>
*/
public function getByTemplateId(int $templateId): Collection;
/**
* 특정 템플릿의 모든 확장 삭제
*
* @param int $templateId 템플릿 ID
* @return int 삭제된 레코드 수
*/
public function deleteByTemplateId(int $templateId): int;
}
@@ -0,0 +1,49 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\TemplateLayoutPreview;
interface LayoutPreviewRepositoryInterface
{
/**
* 미리보기를 생성합니다.
*
* @param array $data 생성 데이터
* @return TemplateLayoutPreview 생성된 미리보기 모델
*/
public function create(array $data): TemplateLayoutPreview;
/**
* 토큰으로 미리보기를 조회합니다.
*
* @param string $token 미리보기 토큰
* @return TemplateLayoutPreview|null 찾은 미리보기 모델 또는 null
*/
public function findByToken(string $token): ?TemplateLayoutPreview;
/**
* 토큰으로 미리보기를 삭제합니다.
*
* @param string $token 미리보기 토큰
* @return bool 삭제 성공 여부
*/
public function deleteByToken(string $token): bool;
/**
* 만료된 미리보기를 일괄 삭제합니다.
*
* @return int 삭제된 행 수
*/
public function deleteExpired(): int;
/**
* 특정 관리자의 특정 레이아웃 미리보기를 삭제합니다.
*
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름
* @param int $adminId 관리자 ID
* @return int 삭제된 행 수
*/
public function deleteByLayoutAndAdmin(int $templateId, string $layoutName, int $adminId): int;
}
@@ -0,0 +1,278 @@
<?php
namespace App\Contracts\Repositories;
use App\Enums\LayoutSourceType;
use App\Models\TemplateLayout;
use App\Models\TemplateLayoutVersion;
use Illuminate\Database\Eloquent\Collection;
interface LayoutRepositoryInterface
{
/**
* 특정 템플릿의 모든 레이아웃 조회
*
* @param int $templateId 템플릿 ID
* @return Collection 레이아웃 컬렉션
*/
public function getByTemplateId(int $templateId): Collection;
/**
* 특정 레이아웃 조회 (템플릿 ID와 이름으로)
*
* @param int $templateId 템플릿 ID
* @param string $name 레이아웃 이름
* @return TemplateLayout|null 찾은 레이아웃 모델 또는 null
*/
public function findByName(int $templateId, string $name): ?TemplateLayout;
/**
* ID로 레이아웃 조회
*
* @param int $id 레이아웃 ID
* @return TemplateLayout|null 찾은 레이아웃 모델 또는 null
*/
public function findById(int $id): ?TemplateLayout;
/**
* 레이아웃이 존재하는지 확인
*
* @param int $templateId 템플릿 ID
* @param string $name 레이아웃 이름
* @return bool 존재 여부
*/
public function exists(int $templateId, string $name): bool;
/**
* extends를 가진 자식 레이아웃 조회
*
* @param int $templateId 템플릿 ID
* @param string $extendsName extends 이름
* @return Collection 자식 레이아웃 컬렉션
*/
public function getChildrenByExtends(int $templateId, string $extendsName): Collection;
/**
* 레이아웃 업데이트
*
* @param int $id 레이아웃 ID
* @param array $data 업데이트할 데이터
* @return TemplateLayout 업데이트된 레이아웃 모델
*/
public function update(int $id, array $data): TemplateLayout;
/**
* 특정 레이아웃의 모든 버전 조회
*
* @param int $layoutId 레이아웃 ID
* @return Collection 버전 컬렉션
*/
public function getVersionsByLayoutId(int $layoutId): Collection;
/**
* 특정 버전 조회
*
* @param int $layoutId 레이아웃 ID
* @param int $version 버전 번호
* @return TemplateLayoutVersion|null 찾은 버전 모델 또는 null
*/
public function findVersionByNumber(int $layoutId, int $version): ?TemplateLayoutVersion;
/**
* 템플릿 오버라이드 레이아웃 찾기
*
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름
* @return TemplateLayout|null 찾은 레이아웃 모델 또는 null
*/
public function findTemplateOverride(int $templateId, string $layoutName): ?TemplateLayout;
/**
* 모듈 기본 레이아웃 찾기
*
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름
* @return TemplateLayout|null 찾은 레이아웃 모델 또는 null
*/
public function findModuleLayout(int $templateId, string $layoutName): ?TemplateLayout;
/**
* 특정 템플릿의 모든 레이아웃 이름 조회
*
* @param int $templateId 템플릿 ID
* @return \Illuminate\Support\Collection<int, string> 레이아웃 이름 컬렉션
*/
public function getLayoutNamesByTemplateId(int $templateId): \Illuminate\Support\Collection;
/**
* 특정 모듈의 모든 레이아웃 조회
*
* @param string $moduleIdentifier 모듈 식별자
* @return Collection 레이아웃 컬렉션
*/
public function getLayoutsByModule(string $moduleIdentifier): Collection;
/**
* 특정 템플릿에서 오버라이드된 모든 레이아웃 조회
*
* @param int $templateId 템플릿 ID
* @return Collection 오버라이드 레이아웃 컬렉션
*/
public function getOverriddenLayouts(int $templateId): Collection;
/**
* 특정 모듈의 레이아웃 중 템플릿에서 오버라이드된 것들 조회
*
* @param string $moduleIdentifier 모듈 식별자
* @param int $templateId 템플릿 ID
* @return Collection 오버라이드 레이아웃 컬렉션
*/
public function getModuleLayoutOverrides(string $moduleIdentifier, int $templateId): Collection;
/**
* 우선순위에 따라 레이아웃 조회 (오버라이드 우선)
*
* @param int $templateId 템플릿 ID
* @param string $name 레이아웃 이름
* @return TemplateLayout|null 찾은 레이아웃 모델 또는 null
*/
public function findByNameWithOverride(int $templateId, string $name): ?TemplateLayout;
/**
* 특정 템플릿의 모든 모듈 레이아웃 조회
*
* @param int $templateId 템플릿 ID
* @param string|null $moduleIdentifier 특정 모듈만 조회 (선택)
* @return Collection 모듈 레이아웃 컬렉션
*/
public function findModuleLayouts(int $templateId, ?string $moduleIdentifier = null): Collection;
/**
* 특정 템플릿의 모든 레이아웃 조회 (source_type 필터 옵션 포함)
*
* @param int $templateId 템플릿 ID
* @param string|null $sourceType 소스 타입 필터
* @param string|null $sourceIdentifier 소스 식별자 필터
* @return Collection 레이아웃 컬렉션
*/
public function getByTemplateIdWithFilter(
int $templateId,
?string $sourceType = null,
?string $sourceIdentifier = null
): Collection;
/**
* 특정 플러그인의 모든 레이아웃 조회
*
* @param string $pluginIdentifier 플러그인 식별자
* @return Collection 레이아웃 컬렉션
*/
public function getLayoutsByPlugin(string $pluginIdentifier): Collection;
/**
* 특정 템플릿의 모든 플러그인 레이아웃 조회
*
* @param int $templateId 템플릿 ID
* @param string|null $pluginIdentifier 특정 플러그인만 조회 (선택)
* @return Collection 플러그인 레이아웃 컬렉션
*/
public function findPluginLayouts(int $templateId, ?string $pluginIdentifier = null): Collection;
/**
* 레이아웃을 생성하거나 업데이트합니다.
*
* @param array $attributes 조회 조건
* @param array $values 생성/업데이트할 데이터
* @return TemplateLayout 생성 또는 업데이트된 레이아웃 모델
*/
public function updateOrCreate(array $attributes, array $values): TemplateLayout;
/**
* 특정 모듈의 soft delete된 레이아웃을 조회합니다.
*
* @param string $moduleIdentifier 모듈 식별자
* @return Collection soft delete된 레이아웃 컬렉션
*/
public function getTrashedByModule(string $moduleIdentifier): Collection;
/**
* 특정 모듈의 레이아웃을 soft delete합니다.
*
* @param string $moduleIdentifier 모듈 식별자
* @return int soft delete된 레코드 수
*/
public function softDeleteByModule(string $moduleIdentifier): int;
/**
* 특정 모듈의 레이아웃을 영구 삭제합니다 (soft delete 포함).
*
* @param string $moduleIdentifier 모듈 식별자
* @return int 삭제된 레코드 수
*/
public function forceDeleteByModule(string $moduleIdentifier): int;
/**
* 특정 모듈의 레이아웃 개수를 반환합니다 (soft delete 포함).
*
* @param string $moduleIdentifier 모듈 식별자
* @return int 레이아웃 개수
*/
public function countByModule(string $moduleIdentifier): int;
/**
* 특정 모듈의 soft delete된 레이아웃을 복원합니다.
*
* @param string $moduleIdentifier 모듈 식별자
* @return int 복원된 레코드 수
*/
public function restoreByModule(string $moduleIdentifier): int;
/**
* 특정 모듈의 레이아웃들을 조회합니다 (soft delete 제외).
*
* @param string $moduleIdentifier 모듈 식별자
* @return Collection 레이아웃 컬렉션
*/
public function getByModuleIdentifier(string $moduleIdentifier): Collection;
/**
* 특정 확장(모듈 또는 플러그인)의 레이아웃들을 소스 타입과 함께 조회합니다.
*
* @param string $sourceIdentifier 확장 식별자
* @param LayoutSourceType $sourceType 소스 타입 (Module 또는 Plugin)
* @return Collection 레이아웃 컬렉션
*/
public function getBySourceIdentifier(string $sourceIdentifier, LayoutSourceType $sourceType): Collection;
/**
* 특정 템플릿의 모든 레이아웃을 삭제합니다.
*
* @param int $templateId 템플릿 ID
* @return int 삭제된 레코드 수
*/
public function deleteByTemplateId(int $templateId): int;
/**
* 특정 템플릿의 레이아웃 개수를 조회합니다.
*
* @param int $templateId 템플릿 ID
* @return int 레이아웃 개수
*/
public function countByTemplateId(int $templateId): int;
/**
* 특정 템플릿의 오버라이드 레이아웃들을 조회합니다.
*
* @param int $templateId 템플릿 ID
* @return Collection 오버라이드 레이아웃 컬렉션
*/
public function getOverridesByTemplateId(int $templateId): Collection;
/**
* 특정 소스 식별자의 레이아웃을 모두 삭제합니다.
*
* @param string $sourceIdentifier 소스 식별자 (모듈/템플릿 식별자)
* @return int 삭제된 레코드 수
*/
public function deleteBySourceIdentifier(string $sourceIdentifier): int;
}
@@ -0,0 +1,63 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\TemplateLayoutVersion;
use Illuminate\Database\Eloquent\Collection;
interface LayoutVersionRepositoryInterface
{
/**
* 버전 저장 (자동 증가)
*
* @param int $layoutId 레이아웃 ID
* @param array $oldContent 이전 콘텐츠
* @param array|null $newContent 새 콘텐츠 (null이면 현재 레이아웃 content 사용)
* @return TemplateLayoutVersion 생성된 버전 모델
*/
public function saveVersion(int $layoutId, array $oldContent, ?array $newContent = null): TemplateLayoutVersion;
/**
* 특정 레이아웃의 모든 버전 조회 (최신순)
*
* @param int $layoutId 레이아웃 ID
* @return Collection 버전 컬렉션
*/
public function getVersions(int $layoutId): Collection;
/**
* 특정 버전 조회
*
* @param int $versionId 버전 ID
* @return TemplateLayoutVersion|null 찾은 버전 모델 또는 null
*/
public function getVersion(int $versionId): ?TemplateLayoutVersion;
/**
* 다음 버전 번호 계산
*
* @param int $layoutId 레이아웃 ID
* @return int 다음 버전 번호
*/
public function getNextVersion(int $layoutId): int;
/**
* JSON content 변경사항 계산
*
* @param array $oldContent 이전 콘텐츠
* @param array $newContent 새 콘텐츠
* @return array 변경사항 (added, removed, modified)
*/
public function calculateChanges(array $oldContent, array $newContent): array;
/**
* 버전 복원
*
* @param int $layoutId 레이아웃 ID
* @param int $versionId 복원할 버전 ID
* @return TemplateLayoutVersion 복원 후 생성된 새 버전 모델
*
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException 버전을 찾을 수 없는 경우
*/
public function restoreVersion(int $layoutId, int $versionId): TemplateLayoutVersion;
}
@@ -0,0 +1,52 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\MailSendLog;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
/**
* 메일 발송 이력 리포지토리 인터페이스
*/
interface MailSendLogRepositoryInterface
{
/**
* 발송 이력을 생성합니다.
*
* @param array $data 생성 데이터
* @return MailSendLog 생성된 발송 이력 모델
*/
public function create(array $data): MailSendLog;
/**
* 발송 이력 목록을 페이지네이션하여 조회합니다.
*
* @param array $filters 필터 조건 (module, template_type, status, search, date_from, date_to)
* @param int $perPage 페이지 당 항목 수
* @return LengthAwarePaginator 페이지네이션 결과
*/
public function getPaginated(array $filters = [], int $perPage = 20): LengthAwarePaginator;
/**
* 발송 통계를 조회합니다.
*
* @return array{total: int, sent: int, failed: int, today: int} 통계 정보
*/
public function getStatistics(): array;
/**
* 발송 이력을 삭제합니다.
*
* @param int $id 삭제할 발송 이력 ID
* @return bool 삭제 성공 여부
*/
public function delete(int $id): bool;
/**
* 여러 발송 이력을 일괄 삭제합니다.
*
* @param array<int> $ids 삭제할 발송 이력 ID 목록
* @return int 삭제된 건수
*/
public function deleteMany(array $ids): int;
}
@@ -0,0 +1,59 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\MailTemplate;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
interface MailTemplateRepositoryInterface
{
/**
* ID로 메일 템플릿을 찾습니다.
*
* @param int $id 메일 템플릿 ID
* @return MailTemplate|null 찾은 모델 또는 null
*/
public function findById(int $id): ?MailTemplate;
/**
* 유형으로 메일 템플릿을 찾습니다.
*
* @param string $type 템플릿 유형
* @return MailTemplate|null 찾은 모델 또는 null
*/
public function findByType(string $type): ?MailTemplate;
/**
* 활성 상태인 특정 유형 템플릿을 찾습니다.
*
* @param string $type 템플릿 유형
* @return MailTemplate|null 활성 템플릿 또는 null
*/
public function getActiveByType(string $type): ?MailTemplate;
/**
* 모든 메일 템플릿 목록을 반환합니다.
*
* @return Collection 메일 템플릿 컬렉션
*/
public function getAllTemplates(): Collection;
/**
* 메일 템플릿을 수정합니다.
*
* @param MailTemplate $template 수정 대상
* @param array $data 수정 데이터
* @return bool 수정 성공 여부
*/
public function update(MailTemplate $template, array $data): bool;
/**
* 메일 템플릿 목록을 페이지네이션하여 조회합니다.
*
* @param array $filters 필터 조건
* @param int $perPage 페이지 당 항목 수
* @return LengthAwarePaginator 페이지네이션 결과
*/
public function getPaginated(array $filters = [], int $perPage = 20): LengthAwarePaginator;
}
@@ -0,0 +1,161 @@
<?php
namespace App\Contracts\Repositories;
use App\Enums\ExtensionOwnerType;
use App\Models\Menu;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
interface MenuRepositoryInterface
{
/**
* 모든 메뉴를 조회합니다.
*
* @return Collection 메뉴 컬렉션 (관계 데이터 포함)
*/
public function getAll(): Collection;
/**
* 최상위 메뉴들을 조회합니다.
*
* @return Collection 최상위 메뉴 컬렉션 (활성화된 것만)
*/
public function getTopLevelMenus(): Collection;
public function getTopLevelMenusForManagement(array $activeModuleIdentifiers = [], array $activePluginIdentifiers = [], ?User $user = null): Collection;
/**
* 활성화된 메뉴들만 조회합니다.
*
* @return Collection 활성화된 메뉴 컬렉션
*/
public function getActiveMenus(): Collection;
/**
* ID로 메뉴를 찾습니다.
*
* @param int $id 메뉴 ID
* @return Menu|null 찾은 메뉴 모델 또는 null
*/
public function findById(int $id): ?Menu;
/**
* 슬러그로 메뉴를 찾습니다.
*
* @param string $slug 메뉴 슬러그
* @return Menu|null 찾은 메뉴 모델 또는 null
*/
public function findBySlug(string $slug): ?Menu;
/**
* 새로운 메뉴를 생성합니다.
*
* @param array $data 메뉴 생성 데이터
* @return Menu 생성된 메뉴 모델
*/
public function create(array $data): Menu;
/**
* 기존 메뉴를 업데이트합니다.
*
* @param Menu $menu 업데이트할 메뉴 모델
* @param array $data 업데이트할 데이터
* @return bool 업데이트 성공 여부
*/
public function update(Menu $menu, array $data): bool;
/**
* 메뉴를 삭제합니다.
*
* @param Menu $menu 삭제할 메뉴 모델
* @return bool 삭제 성공 여부
*/
public function delete(Menu $menu): bool;
/**
* 메뉴의 순서를 업데이트합니다.
*
* @param array $menuOrders 메뉴 ID와 순서 매핑 배열
* @return bool 업데이트 성공 여부
*/
public function updateOrder(array $menuOrders): bool;
/**
* 계층 구조를 고려한 메뉴 순서를 업데이트합니다.
*
* @param array $orderData 계층 구조 순서 데이터
* @return bool 업데이트 성공 여부
*/
public function updateOrderWithHierarchy(array $orderData): bool;
/**
* 특정 확장에 속한 메뉴들을 조회합니다.
*
* @param ExtensionOwnerType $type 확장 타입
* @param string $identifier 확장 식별자
* @return Collection 확장에 속한 메뉴 컬렉션
*/
public function getMenusByExtension(ExtensionOwnerType $type, string $identifier): Collection;
/**
* 부모 메뉴의 자식 메뉴들을 조회합니다.
*
* @param int $parentId 부모 메뉴 ID
* @return Collection 자식 메뉴 컬렉션
*/
public function getChildrenByParent(int $parentId): Collection;
/**
* 네비게이션용 활성화된 메뉴들을 자식 메뉴와 함께 조회합니다.
*
* @return Collection 활성화된 메뉴 컬렉션 (자식 메뉴 포함)
*/
public function getActiveMenusWithChildren(): Collection;
/**
* 사용자가 접근 가능한 네비게이션용 메뉴들을 조회합니다.
*
* @param User $user 접근 권한을 확인할 사용자
* @return Collection 사용자가 접근 가능한 메뉴 컬렉션
*/
public function getAccessibleNavigationMenus(User $user): Collection;
/**
* 메뉴를 생성하거나 업데이트합니다.
*
* @param array $attributes 조회 조건
* @param array $values 생성/업데이트할 데이터
* @return Menu 생성 또는 업데이트된 메뉴 모델
*/
public function updateOrCreate(array $attributes, array $values): Menu;
/**
* slug와 확장 정보로 메뉴를 찾습니다.
*
* @param string $slug 메뉴 슬러그
* @param ExtensionOwnerType $extensionType 확장 타입
* @param string $extensionIdentifier 확장 식별자
* @return Menu|null 찾은 메뉴 모델 또는 null
*/
public function findBySlugAndExtension(string $slug, ExtensionOwnerType $extensionType, string $extensionIdentifier): ?Menu;
/**
* 특정 확장의 모든 메뉴를 삭제합니다.
*
* @param ExtensionOwnerType $type 확장 타입
* @param string $identifier 확장 식별자
* @return int 삭제된 레코드 수
*/
public function deleteByExtension(ExtensionOwnerType $type, string $identifier): int;
/**
* 같은 부모 내에서 최대 순서 값을 조회합니다.
*
* @param int|null $parentId 부모 메뉴 ID (null이면 최상위)
* @return int 최대 순서 값 (없으면 0)
*/
public function getMaxOrder(?int $parentId = null): int;
public function getFilteredTopLevelMenus(array $filters, array $activeModuleIdentifiers = [], array $activePluginIdentifiers = [], ?User $user = null): Collection;
}
@@ -0,0 +1,196 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\Module;
use Illuminate\Database\Eloquent\Collection;
interface ModuleRepositoryInterface
{
/**
* 모든 모듈을 조회합니다.
*
* @return Collection 모듈 컬렉션
*/
public function getAll(): Collection;
/**
* 모든 모듈을 identifier로 키잉하여 조회합니다.
*
* @return Collection identifier를 키로 하는 모듈 컬렉션
*/
public function getAllKeyedByIdentifier(): Collection;
/**
* 설치된 모든 모듈의 identifier 목록을 조회합니다.
*
* @return array 설치된 모듈 identifier 배열
*/
public function getInstalledIdentifiers(): array;
/**
* 이름으로 모듈을 찾습니다.
*
* @param string $name 찾을 모듈 이름
* @return Module|null 찾은 모듈 모델 또는 null
*/
public function findByName(string $name): ?Module;
/**
* 활성화된 모듈들을 조회합니다.
*
* @return Collection 활성화된 모듈 컬렉션
*/
public function getActive(): Collection;
/**
* 새로운 모듈을 생성합니다.
*
* @param array $data 모듈 생성 데이터
* @return Module 생성된 모듈 모델
*/
public function create(array $data): Module;
/**
* 모듈을 생성하거나 업데이트합니다.
*
* @param array $attributes 조회 조건
* @param array $values 생성/업데이트할 데이터
* @return Module 생성 또는 업데이트된 모듈 모델
*/
public function updateOrCreate(array $attributes, array $values): Module;
/**
* 기존 모듈을 업데이트합니다.
*
* @param Module $module 업데이트할 모듈 모델
* @param array $data 업데이트할 데이터
* @return bool 업데이트 성공 여부
*/
public function update(Module $module, array $data): bool;
/**
* 식별자로 모듈 상태를 업데이트합니다.
*
* @param string $identifier 모듈 식별자
* @param array $data 업데이트할 데이터
* @return int 업데이트된 레코드 수
*/
public function updateByIdentifier(string $identifier, array $data): int;
/**
* 모듈을 삭제합니다.
*
* @param Module $module 삭제할 모듈 모델
* @return bool 삭제 성공 여부
*/
public function delete(Module $module): bool;
/**
* 식별자로 모듈을 삭제합니다.
*
* @param string $identifier 모듈 식별자
* @return int 삭제된 레코드 수
*/
public function deleteByIdentifier(string $identifier): int;
/**
* 모듈을 활성화합니다.
*
* @param Module $module 활성화할 모듈 모델
* @return bool 활성화 성공 여부
*/
public function activate(Module $module): bool;
/**
* 모듈을 비활성화합니다.
*
* @param Module $module 비활성화할 모듈 모델
* @return bool 비활성화 성공 여부
*/
public function deactivate(Module $module): bool;
/**
* 모듈의 설치 상태를 업데이트합니다.
*
* @param Module $module 대상 모듈 모델
* @param bool $installed 설치 상태 (기본값: true)
* @return bool 업데이트 성공 여부
*/
public function setInstalled(Module $module, bool $installed = true): bool;
/**
* 설치된 모듈들을 조회합니다.
*
* @return Collection 설치된 모듈 컬렉션
*/
public function getInstalled(): Collection;
/**
* 마켓플레이스용 모듈들을 조회합니다.
*
* @return Collection 공개된 모듈 컬렉션
*/
public function getForMarketplace(): Collection;
/**
* 의존성 정보가 포함된 모든 모듈을 조회합니다.
*
* @return Collection 의존성 정보를 포함한 모듈 컬렉션
*/
public function getAllWithDependencies(): Collection;
/**
* 슬러그로 모듈을 찾습니다.
*
* @param string $slug 모듈 슬러그
* @return Module|null 찾은 모듈 모델 또는 null
*/
public function findBySlug(string $slug): ?Module;
/**
* 식별자로 모듈을 찾습니다.
*
* @param string $identifier 모듈 식별자
* @return Module|null 찾은 모듈 모델 또는 null
*/
public function findByIdentifier(string $identifier): ?Module;
/**
* 활성화된 모듈들의 ID 목록을 반환합니다.
*
* @return array 활성화된 모듈 ID 배열
*/
public function getActiveModuleIds(): array;
/**
* 활성화된 모듈들의 identifier 목록을 반환합니다.
*
* @return array 활성화된 모듈 identifier 배열
*/
public function getActiveModuleIdentifiers(): array;
/**
* 식별자로 활성화된 모듈을 찾습니다.
*
* @param string $identifier 모듈 식별자
* @return Module|null 찾은 모듈 모델 또는 null
*/
public function findActiveByIdentifier(string $identifier): ?Module;
/**
* 특정 모듈에 의존하는 활성 모듈을 조회합니다.
*
* @param string $moduleIdentifier 의존 대상 모듈 식별자
* @return Collection 해당 모듈에 의존하는 활성 모듈 컬렉션
*/
public function findActiveByModuleDependency(string $moduleIdentifier): Collection;
/**
* 특정 플러그인에 의존하는 활성 모듈을 조회합니다.
*
* @param string $pluginIdentifier 의존 대상 플러그인 식별자
* @return Collection 해당 플러그인에 의존하는 활성 모듈 컬렉션
*/
public function findActiveByPluginDependency(string $pluginIdentifier): Collection;
}
@@ -0,0 +1,36 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\PasswordResetToken;
/**
* 비밀번호 재설정 토큰 Repository 인터페이스
*/
interface PasswordResetTokenRepositoryInterface
{
/**
* 이메일로 비밀번호 재설정 토큰을 조회합니다.
*
* @param string $email 사용자 이메일
* @return PasswordResetToken|null 토큰 레코드 또는 null
*/
public function findByEmail(string $email): ?PasswordResetToken;
/**
* 비밀번호 재설정 토큰을 생성하거나 업데이트합니다.
*
* @param string $email 사용자 이메일
* @param array $data 토큰 데이터
* @return PasswordResetToken 생성 또는 업데이트된 토큰
*/
public function updateOrCreateByEmail(string $email, array $data): PasswordResetToken;
/**
* 비밀번호 재설정 토큰을 삭제합니다.
*
* @param PasswordResetToken $token 삭제할 토큰
* @return bool 삭제 성공 여부
*/
public function delete(PasswordResetToken $token): bool;
}
@@ -0,0 +1,106 @@
<?php
namespace App\Contracts\Repositories;
use App\Enums\ExtensionOwnerType;
use App\Models\Permission;
use Illuminate\Database\Eloquent\Collection;
interface PermissionRepositoryInterface
{
/**
* 모든 권한을 조회합니다.
*
* @return Collection 권한 컬렉션
*/
public function getAll(): Collection;
/**
* ID로 권한을 찾습니다.
*
* @param int $id 권한 ID
* @return Permission|null 찾은 권한 모델 또는 null
*/
public function findById(int $id): ?Permission;
/**
* 식별자로 권한을 찾습니다.
*
* @param string $identifier 권한 식별자
* @return Permission|null 찾은 권한 모델 또는 null
*/
public function findByIdentifier(string $identifier): ?Permission;
/**
* 새로운 권한을 생성합니다.
*
* @param array $data 권한 생성 데이터
* @return Permission 생성된 권한 모델
*/
public function create(array $data): Permission;
/**
* 권한을 생성하거나 업데이트합니다.
*
* @param array $attributes 조회 조건
* @param array $values 생성/업데이트할 데이터
* @return Permission 생성 또는 업데이트된 권한 모델
*/
public function updateOrCreate(array $attributes, array $values): Permission;
/**
* 기존 권한을 업데이트합니다.
*
* @param Permission $permission 업데이트할 권한 모델
* @param array $data 업데이트할 데이터
* @return bool 업데이트 성공 여부
*/
public function update(Permission $permission, array $data): bool;
/**
* 권한을 삭제합니다.
*
* @param Permission $permission 삭제할 권한 모델
* @return bool 삭제 성공 여부
*/
public function delete(Permission $permission): bool;
/**
* 특정 확장의 모든 권한을 조회합니다.
*
* @param ExtensionOwnerType $type 확장 타입
* @param string|null $identifier 확장 식별자
* @return Collection 확장에 속한 권한 컬렉션
*/
public function getByExtension(ExtensionOwnerType $type, ?string $identifier = null): Collection;
/**
* 특정 확장의 모든 권한을 삭제합니다.
*
* @param ExtensionOwnerType $type 확장 타입
* @param string|null $identifier 확장 식별자
* @return int 삭제된 레코드 수
*/
public function deleteByExtension(ExtensionOwnerType $type, ?string $identifier = null): int;
/**
* 코어 권한들을 조회합니다.
*
* @return Collection 코어 권한 컬렉션
*/
public function getCorePermissions(): Collection;
/**
* 최상위 권한(루트)을 모든 자식과 함께 조회합니다.
*
* @return Collection 루트 권한 컬렉션 (allChildren 관계 포함)
*/
public function getRootsWithChildren(): Collection;
/**
* 할당 가능한 권한 ID 목록을 반환합니다. (리프 노드만)
*
* @return array 할당 가능한 권한 ID 배열
*/
public function getAssignableIds(): array;
}
@@ -0,0 +1,160 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\Plugin;
use Illuminate\Database\Eloquent\Collection;
interface PluginRepositoryInterface
{
/**
* 모든 플러그인을 조회합니다.
*
* @return Collection 플러그인 컬렉션
*/
public function getAll(): Collection;
/**
* 이름으로 플러그인을 찾습니다.
*
* @param string $name 찾을 플러그인 이름
* @return Plugin|null 찾은 플러그인 모델 또는 null
*/
public function findByName(string $name): ?Plugin;
/**
* 활성화된 플러그인들을 조회합니다.
*
* @return Collection 활성화된 플러그인 컬렉션
*/
public function getActive(): Collection;
/**
* 새로운 플러그인을 생성합니다.
*
* @param array $data 플러그인 생성 데이터
* @return Plugin 생성된 플러그인 모델
*/
public function create(array $data): Plugin;
/**
* 기존 플러그인을 업데이트합니다.
*
* @param Plugin $plugin 업데이트할 플러그인 모델
* @param array $data 업데이트할 데이터
* @return bool 업데이트 성공 여부
*/
public function update(Plugin $plugin, array $data): bool;
/**
* 플러그인을 삭제합니다.
*
* @param Plugin $plugin 삭제할 플러그인 모델
* @return bool 삭제 성공 여부
*/
public function delete(Plugin $plugin): bool;
/**
* 플러그인을 활성화합니다.
*
* @param Plugin $plugin 활성화할 플러그인 모델
* @return bool 활성화 성공 여부
*/
public function activate(Plugin $plugin): bool;
/**
* 플러그인을 비활성화합니다.
*
* @param Plugin $plugin 비활성화할 플러그인 모델
* @return bool 비활성화 성공 여부
*/
public function deactivate(Plugin $plugin): bool;
/**
* 플러그인의 설치 상태를 업데이트합니다.
*
* @param Plugin $plugin 대상 플러그인 모델
* @param bool $installed 설치 상태 (기본값: true)
* @return bool 업데이트 성공 여부
*/
public function setInstalled(Plugin $plugin, bool $installed = true): bool;
/**
* 식별자로 플러그인을 찾습니다.
*
* @param string $identifier 플러그인 식별자
* @return Plugin|null 찾은 플러그인 모델 또는 null
*/
public function findByIdentifier(string $identifier): ?Plugin;
/**
* 식별자로 활성화된 플러그인을 찾습니다.
*
* @param string $identifier 플러그인 식별자
* @return Plugin|null 찾은 플러그인 모델 또는 null
*/
public function findActiveByIdentifier(string $identifier): ?Plugin;
/**
* 플러그인을 생성하거나 업데이트합니다.
*
* @param array $attributes 조회 조건
* @param array $values 생성/업데이트할 데이터
* @return Plugin 생성 또는 업데이트된 플러그인 모델
*/
public function updateOrCreate(array $attributes, array $values): Plugin;
/**
* 식별자로 플러그인 상태를 업데이트합니다.
*
* @param string $identifier 플러그인 식별자
* @param array $data 업데이트할 데이터
* @return int 업데이트된 레코드 수
*/
public function updateByIdentifier(string $identifier, array $data): int;
/**
* 식별자로 플러그인을 삭제합니다.
*
* @param string $identifier 플러그인 식별자
* @return int 삭제된 레코드 수
*/
public function deleteByIdentifier(string $identifier): int;
/**
* 모든 플러그인을 identifier로 키잉하여 조회합니다.
*
* @return Collection identifier를 키로 하는 플러그인 컬렉션
*/
public function getAllKeyedByIdentifier(): Collection;
/**
* 설치된 모든 플러그인의 identifier 목록을 조회합니다.
*
* @return array 설치된 플러그인 identifier 배열
*/
public function getInstalledIdentifiers(): array;
/**
* 특정 모듈에 의존하는 활성 플러그인을 조회합니다.
*
* @param string $moduleIdentifier 의존 대상 모듈 식별자
* @return Collection 해당 모듈에 의존하는 활성 플러그인 컬렉션
*/
public function findActiveByModuleDependency(string $moduleIdentifier): Collection;
/**
* 특정 플러그인에 의존하는 활성 플러그인을 조회합니다.
*
* @param string $pluginIdentifier 의존 대상 플러그인 식별자
* @return Collection 해당 플러그인에 의존하는 활성 플러그인 컬렉션
*/
public function findActiveByPluginDependency(string $pluginIdentifier): Collection;
/**
* 활성화된 플러그인들의 identifier 목록을 반환합니다.
*
* @return array 활성화된 플러그인 identifier 배열
*/
public function getActivePluginIdentifiers(): array;
}
@@ -0,0 +1,128 @@
<?php
namespace App\Contracts\Repositories;
use App\Enums\ExtensionOwnerType;
use App\Models\Role;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
interface RoleRepositoryInterface
{
/**
* 모든 역할을 조회합니다.
*
* @return Collection 역할 컬렉션
*/
public function getAll(): Collection;
/**
* 활성화된 역할들만 조회합니다.
*
* @return Collection 활성화된 역할 컬렉션
*/
public function getActiveRoles(): Collection;
/**
* ID로 역할을 찾습니다.
*
* @param int $id 역할 ID
* @return Role|null 찾은 역할 모델 또는 null
*/
public function findById(int $id): ?Role;
/**
* 식별자로 역할을 찾습니다.
*
* @param string $identifier 역할 식별자
* @return Role|null 찾은 역할 모델 또는 null
*/
public function findByIdentifier(string $identifier): ?Role;
/**
* 새로운 역할을 생성합니다.
*
* @param array $data 역할 생성 데이터
* @return Role 생성된 역할 모델
*/
public function create(array $data): Role;
/**
* 역할을 생성하거나 업데이트합니다.
*
* @param array $attributes 조회 조건
* @param array $values 생성/업데이트할 데이터
* @return Role 생성 또는 업데이트된 역할 모델
*/
public function updateOrCreate(array $attributes, array $values): Role;
/**
* 기존 역할을 업데이트합니다.
*
* @param Role $role 업데이트할 역할 모델
* @param array $data 업데이트할 데이터
* @return bool 업데이트 성공 여부
*/
public function update(Role $role, array $data): bool;
/**
* 역할을 삭제합니다.
*
* @param Role $role 삭제할 역할 모델
* @return bool 삭제 성공 여부
*/
public function delete(Role $role): bool;
/**
* 확장이 소유한 역할을 식별자로 찾습니다.
*
* @param string $identifier 역할 식별자
* @param ExtensionOwnerType $extensionType 확장 타입
* @param string $extensionIdentifier 확장 식별자
* @return Role|null 찾은 역할 모델 또는 null
*/
public function findExtensionRoleByIdentifier(string $identifier, ExtensionOwnerType $extensionType, string $extensionIdentifier): ?Role;
/**
* 역할에 권한을 할당합니다.
*
* @param Role $role 역할 모델
* @param int $permissionId 권한 ID
* @param array $pivotData 피벗 테이블에 저장할 추가 데이터
*/
public function attachPermission(Role $role, int $permissionId, array $pivotData = []): void;
/**
* 역할에서 권한을 해제합니다.
*
* @param Role $role 역할 모델
* @param int $permissionId 권한 ID
* @return int 해제된 권한 수
*/
public function detachPermission(Role $role, int $permissionId): int;
/**
* 역할의 모든 권한을 해제합니다.
*
* @param Role $role 역할 모델
* @return int 해제된 권한 수
*/
public function detachAllPermissions(Role $role): int;
/**
* 역할 목록을 페이지네이션하여 조회합니다.
*
* @param array $filters 필터 조건
* @param int $perPage 페이지당 항목 수
* @return LengthAwarePaginator 페이지네이션된 역할 목록
*/
public function getPaginated(array $filters = [], int $perPage = 20): LengthAwarePaginator;
/**
* 역할에 할당된 권한 개수를 반환합니다.
*
* @param Role $role 역할 모델
* @return int 권한 개수
*/
public function getPermissionCount(Role $role): int;
}
@@ -0,0 +1,85 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\ScheduleHistory;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
interface ScheduleHistoryRepositoryInterface
{
/**
* ID로 실행 이력을 찾습니다.
*
* @param int $id 이력 ID
* @return ScheduleHistory|null 찾은 이력 모델 또는 null
*/
public function findById(int $id): ?ScheduleHistory;
/**
* 새로운 실행 이력을 생성합니다.
*
* @param array $data 이력 생성 데이터
* @return ScheduleHistory 생성된 이력 모델
*/
public function create(array $data): ScheduleHistory;
/**
* 기존 실행 이력을 업데이트합니다.
*
* @param ScheduleHistory $history 업데이트할 이력 모델
* @param array $data 업데이트할 데이터
* @return bool 업데이트 성공 여부
*/
public function update(ScheduleHistory $history, array $data): bool;
/**
* 실행 이력을 삭제합니다.
*
* @param ScheduleHistory $history 삭제할 이력 모델
* @return bool 삭제 성공 여부
*/
public function delete(ScheduleHistory $history): bool;
/**
* 특정 스케줄의 실행 이력을 페이지네이션하여 조회합니다.
*
* @param int $scheduleId 스케줄 ID
* @param array $filters 필터 조건 배열
* @return LengthAwarePaginator 페이지네이션된 이력 목록
*/
public function getPaginatedByScheduleId(int $scheduleId, array $filters = []): LengthAwarePaginator;
/**
* 특정 스케줄의 최근 실행 이력을 조회합니다.
*
* @param int $scheduleId 스케줄 ID
* @param int $limit 조회 개수
* @return Collection 최근 이력 컬렉션
*/
public function getRecentByScheduleId(int $scheduleId, int $limit = 10): Collection;
/**
* 여러 이력을 일괄 삭제합니다.
*
* @param array $ids 이력 ID 배열
* @return int 삭제된 레코드 수
*/
public function bulkDelete(array $ids): int;
/**
* 특정 스케줄의 모든 이력을 삭제합니다.
*
* @param int $scheduleId 스케줄 ID
* @return int 삭제된 레코드 수
*/
public function deleteByScheduleId(int $scheduleId): int;
/**
* 특정 기간 이전의 이력을 삭제합니다.
*
* @param int $days 보관 기간 (일)
* @return int 삭제된 레코드 수
*/
public function deleteOlderThan(int $days): int;
}
@@ -0,0 +1,104 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\Schedule;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
interface ScheduleRepositoryInterface
{
/**
* ID로 스케줄을 찾습니다.
*
* @param int $id 스케줄 ID
* @return Schedule|null 찾은 스케줄 모델 또는 null
*/
public function findById(int $id): ?Schedule;
/**
* 새로운 스케줄을 생성합니다.
*
* @param array $data 스케줄 생성 데이터
* @return Schedule 생성된 스케줄 모델
*/
public function create(array $data): Schedule;
/**
* 기존 스케줄을 업데이트합니다.
*
* @param Schedule $schedule 업데이트할 스케줄 모델
* @param array $data 업데이트할 데이터
* @return bool 업데이트 성공 여부
*/
public function update(Schedule $schedule, array $data): bool;
/**
* 스케줄을 삭제합니다.
*
* @param Schedule $schedule 삭제할 스케줄 모델
* @return bool 삭제 성공 여부
*/
public function delete(Schedule $schedule): bool;
/**
* 모든 스케줄을 조회합니다.
*
* @return Collection 스케줄 컬렉션
*/
public function getAll(): Collection;
/**
* 필터링 및 페이지네이션이 적용된 스케줄 목록을 조회합니다.
*
* @param array $filters 필터 조건 배열
* @return LengthAwarePaginator 페이지네이션된 스케줄 목록
*/
public function getPaginatedSchedules(array $filters = []): LengthAwarePaginator;
/**
* 스케줄 관련 통계 정보를 조회합니다.
*
* @return array 스케줄 통계 데이터 배열
*/
public function getStatistics(): array;
/**
* 활성화된 스케줄들을 조회합니다.
*
* @return Collection 활성화된 스케줄 컬렉션
*/
public function getActiveSchedules(): Collection;
/**
* 실행 대기 중인 스케줄들을 조회합니다.
*
* @return Collection 실행 대기 중인 스케줄 컬렉션
*/
public function getDueSchedules(): Collection;
/**
* 여러 스케줄의 상태를 일괄 업데이트합니다.
*
* @param array $ids 스케줄 ID 배열
* @param bool $isActive 활성화 여부
* @return int 업데이트된 레코드 수
*/
public function bulkUpdateStatus(array $ids, bool $isActive): int;
/**
* 여러 스케줄을 일괄 삭제합니다.
*
* @param array $ids 스케줄 ID 배열
* @return int 삭제된 레코드 수
*/
public function bulkDelete(array $ids): int;
/**
* 스케줄을 복제합니다.
*
* @param Schedule $schedule 복제할 스케줄
* @return Schedule 복제된 스케줄
*/
public function duplicate(Schedule $schedule): Schedule;
}
@@ -0,0 +1,65 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\SystemConfig;
use Illuminate\Database\Eloquent\Collection;
interface SystemConfigRepositoryInterface
{
/**
* 모든 시스템 설정을 조회합니다.
*
* @return Collection 시스템 설정 컬렉션
*/
public function getAll(): Collection;
/**
* 키로 시스템 설정을 찾습니다.
*
* @param string $key 설정 키
* @return SystemConfig|null 찾은 설정 모델 또는 null
*/
public function findByKey(string $key): ?SystemConfig;
/**
* 새로운 시스템 설정을 생성합니다.
*
* @param array $data 설정 생성 데이터
* @return SystemConfig 생성된 설정 모델
*/
public function create(array $data): SystemConfig;
/**
* 기존 시스템 설정을 업데이트합니다.
*
* @param SystemConfig $config 업데이트할 설정 모델
* @param array $data 업데이트할 데이터
* @return bool 업데이트 성공 여부
*/
public function update(SystemConfig $config, array $data): bool;
/**
* 시스템 설정을 삭제합니다.
*
* @param SystemConfig $config 삭제할 설정 모델
* @return bool 삭제 성공 여부
*/
public function delete(SystemConfig $config): bool;
/**
* 키로 시스템 설정을 삭제합니다.
*
* @param string $key 삭제할 설정 키
* @return bool 삭제 성공 여부
*/
public function deleteByKey(string $key): bool;
/**
* 주어진 키의 시스템 설정 존재 여부를 확인합니다.
*
* @param string $key 확인할 설정 키
* @return bool 설정 존재 여부
*/
public function exists(string $key): bool;
}
@@ -0,0 +1,129 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\Template;
use Illuminate\Database\Eloquent\Collection;
interface TemplateRepositoryInterface
{
/**
* 모든 템플릿 조회
*
* @param string|null $type 템플릿 타입 필터
* @return Collection 템플릿 컬렉션
*/
public function getAll(?string $type = null): Collection;
/**
* ID로 템플릿 조회
*
* @param int $id 템플릿 ID
* @return Template|null 찾은 템플릿 모델 또는 null
*/
public function findById(int $id): ?Template;
/**
* identifier로 템플릿 조회
*
* @param string $identifier 템플릿 식별자
* @return Template|null 찾은 템플릿 모델 또는 null
*/
public function findByIdentifier(string $identifier): ?Template;
/**
* 타입별 활성화된 템플릿 조회
*
* @param string $type 템플릿 타입
* @return Template|null 찾은 템플릿 모델 또는 null
*/
public function findActiveByType(string $type): ?Template;
/**
* 템플릿 업데이트
*
* @param int $id 템플릿 ID
* @param array $data 업데이트할 데이터
* @return Template 업데이트된 템플릿 모델
*/
public function update(int $id, array $data): Template;
/**
* 템플릿 삭제 (Soft Delete)
*
* @param int $id 템플릿 ID
* @return bool 삭제 성공 여부
*/
public function delete(int $id): bool;
/**
* 특정 타입의 활성화된 모든 템플릿을 조회합니다.
*
* @param string $type 템플릿 타입 (admin, user 등)
* @return Collection 활성화된 템플릿 컬렉션
*/
public function getActiveByType(string $type): Collection;
/**
* 모든 활성화된 템플릿을 조회합니다.
*
* @return Collection 모든 활성화된 템플릿 컬렉션
*/
public function getActive(): Collection;
/**
* 모든 템플릿을 identifier로 키잉하여 조회합니다.
*
* @return Collection identifier를 키로 하는 템플릿 컬렉션
*/
public function getAllKeyedByIdentifier(): Collection;
/**
* 설치된 모든 템플릿의 identifier 목록을 조회합니다.
*
* @return array 설치된 템플릿 identifier 배열
*/
public function getInstalledIdentifiers(): array;
/**
* 템플릿을 생성하거나 업데이트합니다.
*
* @param array $attributes 조회 조건
* @param array $values 생성/업데이트할 데이터
* @return Template 생성 또는 업데이트된 템플릿 모델
*/
public function updateOrCreate(array $attributes, array $values): Template;
/**
* 식별자로 템플릿 상태를 업데이트합니다.
*
* @param string $identifier 템플릿 식별자
* @param array $data 업데이트할 데이터
* @return int 업데이트된 레코드 수
*/
public function updateByIdentifier(string $identifier, array $data): int;
/**
* 식별자로 템플릿을 삭제합니다.
*
* @param string $identifier 템플릿 식별자
* @return int 삭제된 레코드 수
*/
public function deleteByIdentifier(string $identifier): int;
/**
* 특정 모듈에 의존하는 활성 템플릿을 조회합니다.
*
* @param string $moduleIdentifier 모듈 식별자
* @return Collection 해당 모듈에 의존하는 활성 템플릿 컬렉션
*/
public function findActiveByModuleDependency(string $moduleIdentifier): Collection;
/**
* 특정 플러그인에 의존하는 활성 템플릿을 조회합니다.
*
* @param string $pluginIdentifier 플러그인 식별자
* @return Collection 해당 플러그인에 의존하는 활성 템플릿 컬렉션
*/
public function findActiveByPluginDependency(string $pluginIdentifier): Collection;
}

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