v7.0.0-beta.1 release
This commit is contained in:
@@ -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
@@ -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=
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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)를 참조하세요.
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
<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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user