v7.0.0-beta.2 release
This commit is contained in:
+1
-1
@@ -3,7 +3,7 @@ APP_ENV=production
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
APP_VERSION=7.0.0-beta.1
|
||||
APP_VERSION=7.0.0-beta.2
|
||||
|
||||
APP_LOCALE=ko
|
||||
APP_FALLBACK_LOCALE=ko
|
||||
|
||||
@@ -3,7 +3,7 @@ APP_ENV=testing
|
||||
APP_KEY=
|
||||
APP_DEBUG=false
|
||||
APP_URL=http://localhost
|
||||
APP_VERSION=7.0.0-alpha.18
|
||||
APP_VERSION=7.0.0-beta.2
|
||||
|
||||
APP_LOCALE=ko
|
||||
APP_FALLBACK_LOCALE=ko
|
||||
|
||||
@@ -78,6 +78,9 @@ templates/_bundled/*/node_modules/
|
||||
# DevTools debug dump
|
||||
storage/debug-dump/
|
||||
|
||||
# MaxMind GeoLite2 DB (재배포 금지 — 각 환경에서 직접 다운로드)
|
||||
storage/app/geoip/
|
||||
|
||||
# 개발 전용 파일
|
||||
.api-test/
|
||||
.serena/
|
||||
|
||||
@@ -6,28 +6,31 @@
|
||||
|
||||
<!-- AUTO-GENERATED-START: docs-quick-reference -->
|
||||
|
||||
### 백엔드 [backend/](docs/backend/) (19개)
|
||||
### 백엔드 [backend/](docs/backend/) (22개)
|
||||
|
||||
| 문서 | 설명 | 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 상속 필수 |
|
||||
| [api-resources.md](docs/backend/api-resources.md) | API 리소스 | Resource: BaseApiResource 상속 필수 / Collection: BaseApiColl... |
|
||||
| [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 |
|
||||
| [data-sync-helpers.md](docs/backend/data-sync-helpers.md) | 데이터 동기화 Helper (Data Sync Helpers) | 모든 데이터 동기화는 Service/Seeder 가 Helper 를 호출해 수행 (직접 Model 조작... |
|
||||
| [enum.md](docs/backend/enum.md) | Enum 사용 규칙 | 상태/타입/분류 = Enum 필수 (PHP 8.1+ Backed Enum) |
|
||||
| [exceptions.md](docs/backend/exceptions.md) | Custom Exception 다국어 처리 | 예외 메시지 하드코딩 금지 → __() 함수 필수 |
|
||||
| [geoip.md](docs/backend/geoip.md) | GeoIP 시스템 (MaxMind GeoLite2) | MaxMind GeoLite2-City DB 기반 IP → 타임존 감지 (SetTimezone 미들웨어... |
|
||||
| [middleware.md](docs/backend/middleware.md) | 미들웨어 등록 규칙 | 인증 필요 미들웨어 → 전역 등록 금지! |
|
||||
| [notification-system.md](docs/backend/notification-system.md) | 알림 시스템 (Notification System) | 모든 알림은 BaseNotification 상속 필수 (via() 보일러플레이트 제거) |
|
||||
| [notification-system.md](docs/backend/notification-system.md) | 알림 시스템 (Notification System) | GenericNotification 범용 클래스 1개로 모든 알림 처리 (개별 클래스 불필요) |
|
||||
| [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 주입 필수 (구체 클래스 직접 주입 금지) |
|
||||
| [user-overrides.md](docs/backend/user-overrides.md) | 사용자 수정 보존 (HasUserOverrides Trait) | 모델에 `use HasUserOverrides;` + `protected array $trackable... |
|
||||
| [validation.md](docs/backend/validation.md) | 검증 (Validation) | 필수: FormRequest에서 검증 (Service에 검증 로직 배치 금지) |
|
||||
|
||||
### 프론트엔드 [frontend/](docs/frontend/) (48개)
|
||||
@@ -83,10 +86,11 @@
|
||||
| [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개)
|
||||
### 확장 시스템 [extension/](docs/extension/) (25개)
|
||||
|
||||
| 문서 | 설명 | TL;DR 핵심 |
|
||||
|------|------|-----------|
|
||||
| [cache-driver.md](docs/extension/cache-driver.md) | 캐시 드라이버 시스템 (CacheInterface) | 모든 캐시 저장은 CacheInterface 사용 (Cache:: 직접 호출 금지) |
|
||||
| [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 미참여) |
|
||||
@@ -109,6 +113,8 @@
|
||||
| [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... |
|
||||
| [upgrade-step-guide.md](docs/extension/upgrade-step-guide.md) | 업그레이드 스텝 작성 가이드 (Upgrade Step Guide) | upgrade step 이 실행되는 환경은 경로에 따라 다르다 — 섹션 9 "업그레이드 경로" 먼저 읽기 |
|
||||
| [vendor-bundle.md](docs/extension/vendor-bundle.md) | Vendor 번들 시스템 (Vendor Bundle System) | - |
|
||||
|
||||
### 공통 (5개)
|
||||
|
||||
@@ -133,6 +139,31 @@
|
||||
|
||||
---
|
||||
|
||||
## 버전 동기화 의무
|
||||
|
||||
코어 또는 번들 확장의 공개 표면을 수정할 때, 그 변경의 영향 범위에 있는 다른 확장의 버전 제약(`g7_version`, `dependencies.{modules|plugins}`)을 함께 갱신한다.
|
||||
|
||||
### ① 코어 → 확장 동기화 (`requires.g7_version`)
|
||||
|
||||
- 트리거: 코어 공개 확장 표면(`app/Extension/Abstract*`, `HookManager`, `ExtensionManager`, `ModuleManager`, `PluginManager`, `TemplateManager`, `app/Contracts/Extension/**`, `app/Extension/Helpers/**`, `app/Seo/Contracts/**`, `app/ActivityLog/**` 공개 API, 루트 `CHANGELOG.md` Added/Changed/Removed) 수정
|
||||
- 조치: 영향 받는 번들 확장의 `g7_version` 상향 + 각 확장 CHANGELOG 에 변경 기재
|
||||
|
||||
### ② 확장 → 확장 동기화 (`dependencies.{modules|plugins}`)
|
||||
|
||||
- 트리거: 번들 모듈/플러그인의 공개 Service/Contract/Repository/Model/Route, 발행 훅·이벤트, CHANGELOG 수정
|
||||
- 조치: 그 확장에 의존하는 다른 번들 확장 전수 스캔 → 최소 버전 제약 상향 여부 판정
|
||||
|
||||
### 판정 순서
|
||||
|
||||
1. 기존 소비자 API 시그니처/동작을 건드렸는가 → 소비 확장 최소 버전 상향
|
||||
2. 새 공개 API 가 도입되었는가 → 후보 확장 전수 스캔 후 검토
|
||||
3. 의존 관계 B 의 공개 API 가 변경되었는가 → A 의 `dependencies.B` 상향
|
||||
4. 동기화 대상이 없다면 그 근거("순수 내부 리팩토링" 등)를 변경 이력에 기록
|
||||
|
||||
> 상세: [changelog-rules.md](docs/extension/changelog-rules.md) "코어 버전 제약 정책"
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL RULES - 절대 금지 패턴 (DO NOT)
|
||||
|
||||
### API/핸들러 호출
|
||||
@@ -287,6 +318,47 @@ Keep a Changelog 표준:
|
||||
|
||||
---
|
||||
|
||||
## 공개 CHANGELOG 작성 규칙
|
||||
|
||||
코어(`CHANGELOG.md`) 및 확장(`modules/*/CHANGELOG.md`, `plugins/*/CHANGELOG.md`, `templates/*/CHANGELOG.md`)의 릴리즈 CHANGELOG 작성 규칙입니다.
|
||||
|
||||
### 톤과 표현
|
||||
|
||||
- 사용자/개발자가 읽는 문서이므로 **사용자 관점**으로 작성
|
||||
- "~할 수 있도록 개선", "~하도록 변경", "~문제 수정" 톤 사용
|
||||
- 각 불릿은 **1~2줄**로 간결하게
|
||||
- 내부 구현 상세(클래스명, 파일 경로, 테스트 건수, 훅 체인, DI 패턴)는 포함하지 않음
|
||||
- 이슈 번호(`#123`)는 포함하지 않음
|
||||
|
||||
### 포함/제외 대상
|
||||
|
||||
| 포함 | 제외 |
|
||||
|------|------|
|
||||
| 사용자에게 보이는 기능 추가/변경 | 내부 파일 경로, 클래스/메서드명 |
|
||||
| API 변경 (엔드포인트, 파라미터) | 테스트 건수/파일명 |
|
||||
| 기존 기능의 버그 수정 | 리팩토링 세부사항 |
|
||||
| 성능 개선 (체감 가능한 것) | 내부 규정/문서 변경 |
|
||||
| Breaking Change | 이슈 번호 |
|
||||
| 엔진 버전 참조 (engine-v1.X.Y) | 코드 패턴 설명 |
|
||||
|
||||
### 신규 기능의 버그 수정 제외 규칙
|
||||
|
||||
해당 릴리즈에서 **새로 도입한 기능**의 개발 중 버그 수정은 Fixed에 기록하지 않습니다. 사용자 관점에서 그 기능은 해당 릴리즈에서 처음 제공되므로, "추가했다가 고쳤다"는 내부 개발 이력일 뿐입니다.
|
||||
|
||||
**판단 기준**: 해당 버그가 **이전 릴리즈에도 존재했던 기능**에서 발생한 것인지 확인
|
||||
|
||||
### 기능 그룹핑
|
||||
|
||||
Added/Changed/Fixed 내 항목이 10개를 초과하면 `####` 서브 헤딩으로 기능 단위 분류합니다.
|
||||
|
||||
### Keep a Changelog 형식
|
||||
|
||||
- `## [버전] - YYYY-MM-DD` 헤더 필수
|
||||
- `### Added` / `### Changed` / `### Fixed` / `### Removed` 카테고리 사용
|
||||
- 최신 버전이 파일 상단
|
||||
|
||||
---
|
||||
|
||||
## 레이아웃 JSON 구현 규칙
|
||||
|
||||
```
|
||||
@@ -677,7 +749,7 @@ 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 module:update [identifier] [--force] [--source=auto|bundled|github]
|
||||
|
||||
# 플러그인
|
||||
php artisan plugin:list
|
||||
@@ -689,7 +761,7 @@ 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 plugin:update [identifier] [--force] [--source=auto|bundled|github]
|
||||
|
||||
# 템플릿
|
||||
php artisan template:list
|
||||
@@ -699,7 +771,7 @@ 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]
|
||||
php artisan template:update [identifier] [--layout-strategy=overwrite] [--force] [--source=auto|bundled|github]
|
||||
|
||||
# Composer 의존성 (모듈/플러그인별 독립 vendor/)
|
||||
php artisan extension:composer-install
|
||||
|
||||
+154
-1
@@ -4,7 +4,160 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [Unreleased]
|
||||
## [7.0.0-beta.2] - 2026-04-20
|
||||
|
||||
### Added
|
||||
|
||||
#### 알림 시스템 재설계
|
||||
|
||||
- 알림 시스템을 Definition(정의) × Template(채널별 템플릿) × Recipients(수신자) 3계층으로 재설계
|
||||
- 채널별 독립 수신자 설정 — trigger_user, related_user, role, specific_users 4종 타입 지원
|
||||
- 채널 Readiness 검증 — 미설정 채널(SMTP 미구성 등)의 발송을 사전 차단
|
||||
- 알림 발송 공통 디스패처 — 채널별 독립 발송, 발송 전후 훅 자동 실행, 모든 채널 자동 로깅
|
||||
- 알림 클릭 URL 지원 — database 채널 알림에 클릭 시 이동할 URL 패턴 설정 가능
|
||||
- 사용자/관리자 알림 전체 삭제 API 추가
|
||||
- 비밀번호 변경 시 `password_changed` 알림 자동 발송
|
||||
- 코어 권한에 사용자 알림 전용 카테고리 분리 (관리자/사용자 권한 의미 분리)
|
||||
- 알림 정의 일괄 초기화 기능 — 커스터마이징된 알림의 모든 채널 템플릿을 기본값으로 복원 (확인 모달 + 스피너 UX 포함)
|
||||
- 모듈/플러그인이 자신의 기본 알림 정의를 코어 리셋 로직에 제공할 수 있는 필터 훅 제공
|
||||
- 템플릿 편집 시 편집 상태가 자동 추적되어 리셋 버튼이 자동 노출
|
||||
- 알림 정의/템플릿 응답에 편집/삭제 권한 정보(abilities) 포함으로 UI가 권한에 따라 자동 조정
|
||||
|
||||
#### Vendor 번들 시스템
|
||||
|
||||
- Composer 실행 불가 환경(공유 호스팅 등)을 위한 vendor 번들 시스템 추가
|
||||
- vendor-bundle Artisan 커맨드 — 빌드, 검증, 일괄 처리
|
||||
- 모듈/플러그인 설치·업데이트에 `--vendor-mode=auto|composer|bundled` 옵션 추가
|
||||
- 인스톨러 Step 3에 Vendor 설치 방식 선택 UI 추가 (환경 자동 감지)
|
||||
|
||||
#### GeoIP 및 타임존
|
||||
|
||||
- MaxMind GeoLite2 자동 다운로드 스케줄러 + 관리자 환경설정 UI 추가
|
||||
- IANA 타임존 전체(약 425개) 지원 — 기존 7개 하드코딩 화이트리스트 폐기
|
||||
- Select 컴포넌트에 `searchable` prop 추가 — 타임존 등 대량 옵션에서 검색 가능
|
||||
|
||||
#### 인스톨러 개선
|
||||
|
||||
- SSE/폴링 듀얼 모드 지원 — Nginx 프록시 + Apache 환경에서 SSE 문제 시 폴링 모드로 전환 가능
|
||||
- 확장 의존성 자동 해결 — 템플릿 선택 시 필요한 모듈/플러그인을 즉시 자동 선택, 전이적 의존성까지 해결
|
||||
- 자동 선택된 항목을 시각적으로 구분하고 요구한 확장 이름을 함께 표시
|
||||
- 다른 확장이 의존하는 항목은 선택 해제 차단 (의존 관계 안내 메시지 포함)
|
||||
- 의존성 버전 제약 사전 검증 — 버전 불일치 시 설치 진행 전 경고 (semver 비교: `>=`, `^`, `~` 등 지원)
|
||||
- 기존 DB 테이블 감지 및 안전한 재설치 지원 — 백업 안내 + 명시적 동의 후 진행
|
||||
- 권한 안내 단순화 — `chmod -R 755` 단일 명령어로 통합 (업계 표준 정렬)
|
||||
- 소유자 불일치(`ownership_mismatch`) 감지 — 전통적 Apache 환경에서 3가지 해결 옵션 제시
|
||||
- Step 5에 "설치 시작" 버튼 도입 — 모드 선택 후 사용자 클릭으로 설치 시작
|
||||
|
||||
#### 공통 캐시 시스템
|
||||
|
||||
- 코어/모듈/플러그인 격리 캐시 시스템 도입 (접두사 자동 부여)
|
||||
- 모델 변경 시 태그 기반 자동 캐시 무효화 트레이트 추가
|
||||
- 환경설정에서 캐시 TTL 중앙 관리 (7개 설정 키)
|
||||
|
||||
#### 어드민 UI
|
||||
|
||||
- 페이지 진입/탭 전환 시 로딩 spinner 표시 — 데이터 fetch 완료까지 유지
|
||||
- 목록 페이지네이션 시 DataGrid body 영역 한정 spinner (pagination 버튼 가림 방지)
|
||||
- DataGrid 컴포넌트에 `id` prop 추가
|
||||
|
||||
#### 기타
|
||||
|
||||
- HookManager 공통 큐 디스패치 — 환경설정 하나로 모든 훅 리스너 자동 비동기 실행, 큐 워커에서도 Auth/Request/Locale 컨텍스트 자동 복원
|
||||
- WebSocket 클라이언트/서버 endpoint 분리 설정 지원 — 리버스 프록시 환경 대응, SSL 검증 옵션 추가
|
||||
- SEO 변수 시스템 — 모듈/플러그인이 페이지별 SEO 변수를 제공할 수 있도록 개선
|
||||
- 코어 드라이버 확장 시스템 — 플러그인이 필터 훅으로 스토리지/캐시/세션/큐 등 드라이버를 등록 가능
|
||||
- `HasUserOverrides` 트레이트 — 시더 데이터와 사용자 수정을 자동으로 분리 추적
|
||||
- 템플릿 엔진 engine-v1.42.0 — `render: false` 선택적 리렌더 제어
|
||||
- 템플릿 엔진 engine-v1.41.0 — setLocal/dispatch debounce API + stale 값 오염 방지
|
||||
- 템플릿 엔진 engine-v1.40.0 — navigate fallback 옵션 (교차 템플릿 경로 대응)
|
||||
- 템플릿 엔진 engine-v1.30~38 — sortable wrapper 요소 지정, React.memo 자동 래핑, resolvedProps 참조 안정화, transition_overlay spinner 시스템, reloadExtensions 통합 핸들러
|
||||
|
||||
#### 확장 업데이트
|
||||
|
||||
- 코어 업데이트 완료 후 `_bundled` 에 새 버전이 있는 확장을 감지하고 일괄 업데이트 여부를 묻는 인터랙티브 프롬프트 제공
|
||||
- 일괄 업데이트 시 전역 레이아웃 전략(overwrite / keep) 1회 질의 후 확장별 예외 지정 가능
|
||||
- 모듈/플러그인 업데이트에도 `--layout-strategy=overwrite|keep` CLI 옵션 및 관리자 UI 모달 선택 지원 (기존 템플릿 전용 → 확장 전체 일관)
|
||||
- 관리자 모듈/플러그인 업데이트 모달에 사용자가 수정한 레이아웃 감지 및 목록 표시 기능 추가
|
||||
- 모듈/플러그인 업데이트 시 각 upgrade step 버전을 콘솔에 실시간 출력 (기존에는 파일 로그에만 기록)
|
||||
- 확장 업데이트 메서드 파라미터 순서를 3종(템플릿·모듈·플러그인) 공통 prefix(id, force, onProgress) 로 정렬해 일관성 확보
|
||||
- 모듈/플러그인이 런타임에 동적으로 생성한 권한·역할·메뉴(예: 게시판별 권한 세트)도 업데이트 시 보존되도록 개선
|
||||
- 모듈/플러그인/템플릿 업데이트 커맨드에 업데이트 소스 선택 옵션(`--source=auto|bundled|github`) 추가 — GitHub 원격 장애·태그 롤백 등 상황에서 번들 소스로 강제 설치 가능
|
||||
- GitHub 원격에 존재하지 않는 태그로 업데이트 시 "아카이브 추출 실패" 로 불명확한 오류가 나던 문제 개선 — 태그 존재 여부를 먼저 검증해 "업데이트 소스 없음" 안내로 명확화
|
||||
- 관리자 모듈/플러그인 업데이트 모달의 _global 상태키를 `hasModifiedTemplateLayouts`, `hasModifiedModuleLayouts`, `hasModifiedPluginLayouts` 로 통일해 네이밍 충돌 방지
|
||||
- `module:install` / `plugin:install` / `template:install` 에 `--force` 옵션 추가 — 이미 설치된 확장도 `_bundled`/`_pending` 원본으로 활성 디렉토리를 덮어써서 재설치 (불완전 설치 복구용)
|
||||
- 확장 활성 디렉토리 무결성 검사 추가 — `module.php`/`module.json` (모듈), `plugin.php`/`plugin.json` (플러그인), `template.json` (템플릿) 중 하나라도 누락된 활성 디렉토리를 로드 시 경고 로그 + `install --force` 복구 힌트 제공
|
||||
- 코어 업데이트 커맨드에 외부 ZIP 파일 직접 지정 옵션(`--zip=/path/to/g7.zip`) 추가 — GitHub 다운로드 대신 지정 ZIP 을 사용해 오프라인/수동 배포 가능
|
||||
- 모듈/플러그인/템플릿 업데이트 커맨드에 외부 ZIP 파일 직접 지정 옵션(`--zip=/path/to/ext.zip`) 추가 — manifest 식별자와 대상 확장이 일치하는지 검증 후 manifest 의 버전으로 업데이트
|
||||
- 외부 ZIP 은 GitHub 릴리스 zipball 의 래퍼 디렉토리와 평탄 루트 구조를 모두 자동 감지해 지원
|
||||
|
||||
### Changed
|
||||
|
||||
#### 알림 시스템
|
||||
|
||||
- MailTemplate 시스템을 NotificationDefinition + NotificationTemplate 체계로 전면 전환
|
||||
- 코어 알림 3종의 직접 발송을 훅 기반 발송으로 전환
|
||||
- 알림 수신자를 definition 레벨에서 template 레벨로 이동 (채널별 독립 수신자)
|
||||
- 알림 broadcast 채널을 정수 User ID에서 UUID 기반으로 변경 (보안 강화)
|
||||
- 알림 시각 표시를 ISO 8601에서 사용자 타임존 기준으로 변경
|
||||
|
||||
#### 캐시 시스템
|
||||
|
||||
- 24개 서비스의 캐시 호출을 `Cache::` 파사드에서 `CacheInterface` DI로 전환
|
||||
- 13개 서비스의 TTL을 환경설정 중앙 관리로 통일 (하드코딩 제거)
|
||||
- 캐시 키 접두사를 `g7:core:`, `g7:module.{id}:`, `g7:plugin.{id}:` 체계로 통일
|
||||
- `CacheService` 클래스 삭제 — `CacheInterface`로 완전 대체
|
||||
|
||||
#### 인스톨러
|
||||
|
||||
- 권한 검증을 비트 값 비교에서 실제 읽기/쓰기 가능 여부 기반으로 단순화
|
||||
- `.env` 생성 안내를 소유자 일치 여부에 따라 1-step으로 통합
|
||||
|
||||
#### 기타
|
||||
|
||||
- 확장 정보 모달에 의존성 정보 노출 — 코어 요구 버전(`g7_version`), 의존 모듈/플러그인 목록과 각각의 요구 버전·설치 버전·충족 상태 뱃지를 모듈/플러그인/템플릿 정보 모달에서 확인 가능
|
||||
- `locale_names` 설정 추가 — 언어 표시명을 중앙에서 관리
|
||||
- 매 요청 `Schema::hasTable()` 호출 제거 — 설치 완료 환경에서 10~14회의 불필요한 스키마 쿼리 제거로 응답 시간 단축
|
||||
- SeoRenderer에 모듈/플러그인 SEO 변수 자동 해석 기능 추가
|
||||
- ResponseHelper — 디버그 모드에서 예외 상세 자동 포함, 프로덕션에서 내부 메시지 노출 차단
|
||||
- 환경설정 드라이버 변경 시 큐 워커 자동 재시작
|
||||
- `allow_url_fopen=Off` 환경 지원 — GitHub 연동 HTTP 호출을 Laravel Http 파사드로 전면 교체
|
||||
- 코어 업데이트 완료 후 안내 메시지를 "수동 업데이트 명령 나열"에서 "일괄 업데이트 인터랙티브 프롬프트"로 변경
|
||||
- 확장 삭제 시 권한·메뉴·역할은 "데이터도 함께 삭제" 옵션을 체크했을 때만 삭제되도록 변경 — 기본 삭제에서는 보존되어 재설치 시 사용자 역할 할당이 복원
|
||||
|
||||
### Fixed
|
||||
|
||||
- WebSocket 데이터소스가 progressive 목록에 포함되어 영구 블러되던 문제 수정
|
||||
- WebSocket 채널/이벤트 표현식 미평가로 인증 실패하던 문제 수정
|
||||
- WebSocket broadcast HTTP API가 클라이언트 host로 POST되어 실패하던 문제 수정
|
||||
- WebSocket 활성화 시 필수 필드 미검증 문제 수정
|
||||
- WebSocket 비활성화 시에도 Reverb 연결을 시도하던 문제 수정
|
||||
- `--force` 업데이트 시 번들 없는 외부 확장이 업데이트 소스를 찾지 못하던 문제 수정
|
||||
- 확장 의존성 데이터 구조 불일치 수정 — manifest JSON 기반으로 통일
|
||||
- 모듈/플러그인/템플릿 install/activate/deactivate/uninstall 직후 라우트 미반영 수정
|
||||
- 다국어 번역 캐시 경합으로 raw key 노출되던 문제 수정 (engine-v1.38.1)
|
||||
- 조건부 `$t:` 표현식에서 번역 실패하던 문제 수정 (engine-v1.38.2)
|
||||
- 테스트 환경과 개발 환경이 동일한 Redis 캐시를 공유하여 상호 오염되던 문제 수정
|
||||
- 대시보드 브로드캐스트 스케줄이 무한 락에 걸리던 문제 수정
|
||||
- 환경설정 저장 후 `config:cache` 상태에서 변경값 미반영 수정
|
||||
- Windows 환경에서 파일 잠금 감지 시 30초+ hang 수정 — 0.5초로 단축
|
||||
- 개발 의존성 없이 설치된 프로덕션 환경에서 샘플 시더와 팩토리가 실행되지 않던 문제 수정 — 한국어 샘플 데이터 생성기가 자동으로 대체 동작하도록 개선
|
||||
- `sudo php artisan core:update` 실행 시 vendor 디렉토리 전체가 root 소유로 오염되던 문제 수정
|
||||
- 코어 업데이트 후 신규 권한·메뉴가 DB에 반영되지 않아 관리자 환경설정 페이지 접근이 거부되던 문제 수정
|
||||
- beta.1에서 업그레이드 시 제거된 메일 템플릿 모델 참조로 Fatal 오류가 발생하던 문제 수정
|
||||
- 업그레이드 후 `bootstrap/cache` 등 일부 디렉토리가 root 소유로 남아 확장 설치·업데이트가 거부되던 문제 수정 — 소유권 복원 범위를 설치 안내 경로 전체로 확장
|
||||
- 코어 업데이트 진행률 표시와 upgrade step 실행 로그가 같은 줄에 뒤섞이던 문제 수정 — 각 step 안내가 별도 줄로 깔끔하게 출력됨
|
||||
- 템플릿/레이아웃 공개 API가 확장 활성화 전·설치 중에 받은 "찾을 수 없음" 에러 응답을 영구 캐시하여 복구 후에도 같은 오류를 반환하던 문제 수정 — 에러 응답은 캐시에서 제외
|
||||
- `_pending`/`_bundled` 에 업데이트 중 남은 임시 디렉토리(`{id}_YYYYMMDD_HHMMSS`, `{id}_updating_*` 등) 의 원본 manifest 때문에 `install` 이 존재하지 않는 표준 경로로 접근해 실패하던 문제 수정 — 디렉토리명과 identifier 불일치 시 스캔에서 제외
|
||||
- 업그레이드 후 일부 코어 권한이 DB에 반영되지 않아 사용자 역할의 알림 기능 권한이 비어있던 문제 수정 — 별도 프로세스 실행으로 최신 로직 기반 재동기화 보장
|
||||
- 코어·확장 설정에서 제거된 메뉴·권한·역할·알림 정의·알림 템플릿·게시판 유형·클레임 사유가 업데이트 후에도 DB에 잔존하던 문제 수정 — 고아 레코드 자동 정리
|
||||
- 관리자 UI에서 메뉴·역할·알림·게시판 유형·클레임 사유·배송 유형 등을 수정해도 다음 업데이트 시 기본값으로 덮어써지던 문제 수정 — 사용자가 수정한 필드는 모든 저장 경로에서 자동 추적되어 업데이트 후에도 보존
|
||||
- 알림 정의·템플릿도 업데이트 기준으로 동기화 — 새 버전에서 제거된 알림은 DB에서도 삭제
|
||||
|
||||
### Removed
|
||||
|
||||
- MailTemplate 시스템 일괄 제거 — 모델, 컨트롤러, 서비스, 리스너, 시더, 팩토리, 다국어 파일 등. NotificationDefinition + NotificationTemplate 체계로 완전 대체
|
||||
|
||||
> 참고: beta.1에서 업그레이드하는 운영 환경의 데이터 이관은 `Upgrade_7_0_0_beta_2`가 자동 처리합니다.
|
||||
|
||||
## [7.0.0-beta.1] - 2026-04-01
|
||||
|
||||
|
||||
+133
@@ -184,6 +184,139 @@ php artisan serve
|
||||
|
||||
---
|
||||
|
||||
## 방법 4: 공유 호스팅
|
||||
|
||||
Composer 실행이 불가능한 공유 호스팅 환경에서의 설치 방법입니다. SSH + PHP CLI 사용이 가능한 요금제를 전제로 하며, [Vendor 번들 시스템](docs/extension/vendor-bundle.md)을 통해 Composer 없이 의존성을 배치합니다.
|
||||
|
||||
### Cafe24
|
||||
|
||||
#### 검증 환경
|
||||
|
||||
본 가이드는 Cafe24 [**뉴아우토반 호스팅 절약형**](https://hosting.cafe24.com/?controller=new_product_page&page=newautobahn) 요금제에서 검증되었습니다. 동일 계열(일반형/비즈니스형 등) 요금제는 더 넉넉한 리소스를 제공하므로 그대로 적용 가능합니다.
|
||||
|
||||
| 항목 | 절약형 사양 |
|
||||
|------|-------------|
|
||||
| 월 요금 | 500원 (1년 약정 시 450원) |
|
||||
| 웹 용량 | 700MB (FullSSD) |
|
||||
| 트래픽 | 1.6GB/일 |
|
||||
| DB | MariaDB 10.x (InnoDB), 서버 공간 내 무제한 |
|
||||
| PHP | 8.4 / 8.2 / 7.4 (Rocky OS 기준) |
|
||||
| 접속 | FTP / SFTP / SSH 지원 |
|
||||
|
||||
> 웹 용량 700MB는 G7 코어 + 기본 확장 설치에 충분하지만, 업로드 파일이 많아질 경우 **일반형(1.4GB) 이상**을 권장합니다.
|
||||
|
||||
#### 사전 준비
|
||||
|
||||
- SSH 접속이 허용된 Cafe24 호스팅 계정 (절약형 이상)
|
||||
- SFTP 클라이언트 (FileZilla, WinSCP, Cyberduck 등)
|
||||
- Cafe24 관리자 페이지에서 PHP 버전을 **8.2 또는 8.4**로 설정
|
||||
- Cafe24 관리자 페이지에서 생성한 MariaDB DB (utf8mb4)
|
||||
|
||||
#### 1단계: GitHub Release에서 배포 패키지 다운로드
|
||||
|
||||
[https://github.com/gnuboard/g7/releases](https://github.com/gnuboard/g7/releases) 접속하여 최신 릴리스의 **Assets** 섹션에서 배포 패키지를 다운로드합니다.
|
||||
|
||||
#### 2단계: SFTP로 ZIP 파일 업로드
|
||||
|
||||
다운로드한 ZIP 파일을 SFTP로 홈 디렉토리 바로 아래에 업로드합니다.
|
||||
|
||||
```text
|
||||
~/ ← Cafe24 계정의 홈 디렉토리
|
||||
├── www ← 기존 심볼릭 링크 또는 디렉토리 (4단계에서 갱신)
|
||||
└── g7-release.zip ← 업로드한 ZIP 파일
|
||||
```
|
||||
|
||||
#### 3단계: SSH 접속 후 압축 해제 및 심볼릭 링크 갱신
|
||||
|
||||
SSH 접속 후 홈 디렉토리에서 ZIP 압축 해제와 웹 루트 심볼릭 링크 갱신을 함께 진행합니다.
|
||||
|
||||
```bash
|
||||
cd ~
|
||||
|
||||
# 업로드한 ZIP 압축 해제 (파일명은 실제 다운로드한 이름으로 교체)
|
||||
unzip g7-release.zip
|
||||
|
||||
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
|
||||
ls -la
|
||||
# (필요 시) mv g7-7.0.0-beta.2 g7
|
||||
|
||||
# ZIP 파일 정리 (선택)
|
||||
rm g7-release.zip
|
||||
```
|
||||
|
||||
압축 해제 후 디렉토리 구조는 다음과 같아야 합니다.
|
||||
|
||||
```text
|
||||
~/
|
||||
├── www ← 아래에서 갱신할 심볼릭 링크
|
||||
└── g7/
|
||||
├── app/
|
||||
├── bootstrap/
|
||||
├── public/
|
||||
├── storage/
|
||||
├── vendor-bundle.zip
|
||||
├── .env.example
|
||||
└── ...
|
||||
```
|
||||
|
||||
Cafe24의 웹 루트는 홈 디렉토리 아래 `www`(심볼릭 링크 또는 디렉토리)이며, 이를 `g7/public/`으로 연결해야 합니다.
|
||||
|
||||
```bash
|
||||
# 기존 www 상태 확인
|
||||
ls -la www
|
||||
|
||||
# 기존 www 삭제 후 재생성
|
||||
rm www
|
||||
ln -s g7/public www
|
||||
|
||||
# 결과 확인 (아래와 같은 형태여야 합니다)
|
||||
ls -la www
|
||||
# lrwxrwxrwx 1 user user 9 ... www -> g7/public/
|
||||
```
|
||||
|
||||
기존 `www` 안에 운영 중인 사이트가 있다면, 먼저 SFTP로 백업 다운로드하거나 `mv www www.bak`으로 이름 변경한 뒤 새 심볼릭 링크를 생성하세요.
|
||||
|
||||
> 본 가이드의 모든 쉘 명령은 `cd ~` 또는 `cd ~/g7` 컨텍스트 진입 후 `./` 상대경로로 실행하므로, Cafe24 계정의 홈 디렉토리 절대경로를 몰라도 됩니다.
|
||||
|
||||
#### 4단계: 웹 인스톨러 실행
|
||||
|
||||
브라우저에서 도메인으로 접속합니다.
|
||||
|
||||
```text
|
||||
http://도메인/install
|
||||
```
|
||||
|
||||
설치 마법사의 각 단계를 진행합니다.
|
||||
|
||||
| Step | 작업 |
|
||||
|------|------|
|
||||
| 0. 환영 | 언어 선택 및 `storage` 권한 검증 — 권한 부족 시 인스톨러가 상대경로 기반 명령을 자동 안내 |
|
||||
| 1. 라이선스 | 동의 |
|
||||
| 2. 요구사항 | PHP 8.2+ 및 필수 확장 확인 |
|
||||
| 3. 환경 설정 | DB 정보 + 관리자 계정 + **Vendor 설치 방식** 선택 |
|
||||
| 4. 확장 선택 | 템플릿/모듈/플러그인 선택 (의존성 자동 해결) |
|
||||
| 5. 설치 실행 | "설치 시작" 버튼 클릭 후 진행 상황 모니터링 |
|
||||
|
||||
`.env` 파일 생성과 `storage` / `bootstrap/cache` 쓰기 권한 부여는 인스톨러가 환경(소유자 일치 여부 등)에 맞춰 Step 0 및 이후 단계에서 자동 안내하므로, 사전 SSH 작업으로 수행할 필요 없습니다.
|
||||
|
||||
**Step 3 — Vendor 설치 방식 선택:**
|
||||
|
||||
- **자동 (권장)**: 환경을 자동 감지하여 번들 모드로 폴백합니다.
|
||||
- **번들 Vendor 사용**: `vendor-bundle.zip`을 명시적으로 선택합니다 (Cafe24 권장).
|
||||
|
||||
#### Cafe24 환경 체크리스트
|
||||
|
||||
설치 중 문제가 발생하면 아래 항목을 확인합니다.
|
||||
|
||||
- [ ] `php -v` 출력이 8.2 이상인가? (Cafe24 관리자에서 PHP 버전 변경 가능)
|
||||
- [ ] `php -m` 출력에 `pdo_mysql`, `mbstring`, `openssl`, `zip`이 포함되어 있는가?
|
||||
- [ ] `~/www`가 `g7/public/`를 올바르게 가리키는가? (`ls -la ~/www` 확인)
|
||||
- [ ] `vendor-bundle.zip`이 `~/g7/` 디렉토리에 존재하는가?
|
||||
- [ ] 웹 인스톨러 Step 3에서 "Vendor 설치 방식"을 **번들 Vendor 사용** 또는 **자동**으로 선택했는가?
|
||||
- [ ] DB 접속 정보(호스트/포트/계정)가 Cafe24 관리자 정보와 일치하는가?
|
||||
|
||||
---
|
||||
|
||||
## 설치 후 확인
|
||||
|
||||
설치가 완료되면 아래 페이지에 접근할 수 있습니다.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</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/version-7.0.0--beta.2-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>
|
||||
@@ -50,7 +50,8 @@ Laravel과 React를 기반으로, 보안부터 아키텍처까지 처음부터
|
||||
| **유연한 화면 구성** | 화면 구조를 정의하면 즉시 반영할 수 있습니다. 프론트엔드 인프라 없이 JSON 선언만으로 웹앱 수준의 동적 화면 구현이 가능합니다 |
|
||||
| **레이아웃 편집기** | 위지윅 기반 레이아웃 편집 기능으로 화면 블록을 직접 배치하고 수정 결과를 바로 확인할 수 있습니다 `정식버전에서 지원예정` |
|
||||
| **검증된 기반** | Laravel + React 기반을 제공합니다. 글로벌 기업이 채택한 기술 스택으로 높은 확장성과 유연한 UI 구현이 가능합니다 |
|
||||
| **캐싱** | 화면 구조와 API 응답, 권한 정보를 다층으로 캐싱합니다. 불필요한 재처리 없이 빠른 응답 속도를 유지합니다 |
|
||||
| **공통 캐시 시스템** | `CacheInterface` 와 코어/모듈/플러그인 3종 드라이버로 키 접두사(`g7:core:`, `g7:module.{id}:`, `g7:plugin.{id}:`) 를 자동 격리합니다. 태그 기반 자동 무효화와 `g7_core_settings('cache.*_ttl')` 중앙 관리로 하드코딩 없이 운영할 수 있습니다 |
|
||||
| **알림 시스템** | 알림 정의(Definition) × 템플릿(Template) × 수신자(Recipients) 3계층 구조로 메일/DB/실시간 브로드캐스트(Reverb) 다채널 독립 발송을 지원합니다. 작성자·역할·특정 사용자 단위 타겟팅과 훅 기반 발송으로 모듈이 자체 알림을 자유롭게 등록할 수 있습니다 |
|
||||
| **활동 로그** | 관리자·사용자 활동 이력을 자동으로 기록하고 조회할 수 있습니다. Monolog 기반 구조로 확장이 용이합니다 |
|
||||
| **검색** | Laravel Scout 기반 전문 검색을 지원합니다. 상품, 게시글 등 주요 콘텐츠를 대상으로 검색 기능을 제공합니다 |
|
||||
|
||||
@@ -204,12 +205,95 @@ flowchart TB
|
||||
모듈/플러그인을 활성화하면 해당 UI와 컴포넌트가 자동으로 주입됩니다.
|
||||
개발자는 JSON만으로 UI를 추가하거나 변경할 수 있어 별도의 프론트엔드 빌드가 필요 없으며, 권한(abilities)에 따라 UI 요소가 자동으로 표시/숨김 처리됩니다.
|
||||
|
||||
**확장 시스템 3원칙**
|
||||
### 핵심 시스템
|
||||
|
||||
플랫폼을 떠받치는 네 가지 시스템이 유기적으로 동작합니다.
|
||||
|
||||
#### 1. 확장 시스템 — 3원칙
|
||||
|
||||
1. **코어 수정 최소화** — 모든 비즈니스 로직은 모듈/플러그인으로 구현
|
||||
2. **동적 로딩** — composer.json 하드코딩 없이 디렉토리 스캔으로 자동 발견
|
||||
2. **동적 로딩** — `composer.json` 하드코딩 없이 디렉토리 스캔으로 자동 발견
|
||||
3. **Hook 기반 확장** — 서비스 계층에서 Action/Filter 훅으로 기능 주입
|
||||
|
||||
#### 2. 훅 시스템 (Action / Filter)
|
||||
|
||||
Laravel 이벤트와 별개로 동작하는 가벼운 훅 시스템입니다. Action 은 부수 작업(로깅, 알림), Filter 는 값 변형 (기본값 주입, 권한 확장)에 사용됩니다.
|
||||
|
||||
```php
|
||||
// Service 계층에서 훅 발행
|
||||
HookManager::doAction('core.user.after_create', $user, $data);
|
||||
$data = HookManager::applyFilters('core.user.filter_create_data', $data);
|
||||
|
||||
// 모듈 Listener 가 훅 구독 (자동 발견)
|
||||
public static function getSubscribedHooks(): array
|
||||
{
|
||||
return [
|
||||
'core.user.after_create' => ['method' => 'onUserCreated', 'priority' => 20],
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
모듈/플러그인은 `Listeners/` 디렉토리에 클래스만 두면 `HookListenerRegistrar` 가 자동으로 구독합니다. 큐 직렬화를 통한 비동기 실행도 지원하며, 워커에서도 `Auth::user()`, `request()->ip()`, `App::getLocale()` 같은 컨텍스트가 자동 복원됩니다.
|
||||
|
||||
#### 3. 공통 캐시 시스템
|
||||
|
||||
`CacheInterface` 를 기반으로 **코어 · 모듈 · 플러그인** 이 키 충돌 없이 각자의 캐시를 관리합니다.
|
||||
|
||||
| 드라이버 | 접두사 | 용도 |
|
||||
| --- | --- | --- |
|
||||
| `CoreCacheDriver` | `g7:core:{key}` | 코어 서비스 (레이아웃, SEO, 알림, 설정 등) |
|
||||
| `ModuleCacheDriver` | `g7:module.{identifier}:{key}` | 모듈별 격리 캐시 (게시판 상품 리스트, 쿨다운 등) |
|
||||
| `PluginCacheDriver` | `g7:plugin.{identifier}:{key}` | 플러그인별 격리 캐시 |
|
||||
|
||||
```php
|
||||
// 모듈 서비스는 BaseModuleServiceProvider::$cacheServices 배열에 등록하면
|
||||
// 생성자 타입힌트만으로 자동 주입됨 (Storage 패턴과 동일)
|
||||
public function __construct(
|
||||
private BoardRepositoryInterface $repository,
|
||||
private CacheInterface $cache, // ← g7:module.sirsoft-board: 접두사 자동 적용
|
||||
) {}
|
||||
```
|
||||
|
||||
- **TTL 중앙 관리** — 모든 캐시 TTL 은 `g7_core_settings('cache.*_ttl')` 를 추종합니다. 하드코딩 금지
|
||||
- **자동 무효화** — `CacheInvalidatable` 트레이트를 모델에 적용하면 `saved` / `deleted` 시점에 태그 기반으로 관련 캐시 자동 삭제
|
||||
- **라이프사이클 연동** — 모듈 비활성화/삭제 시 `ModuleManager` 가 해당 모듈의 격리 캐시를 일괄 flush
|
||||
- **프론트엔드 캐시 버스팅** — `ext.cache_version` 증가가 응답의 `config.json` 을 통해 전파되어 `?v=` 쿼리 파라미터 기반으로 브라우저 캐시까지 무효화
|
||||
|
||||
#### 4. 알림 시스템
|
||||
|
||||
**Definition × Template × Recipients** 3계층 모델로 멀티 채널 알림을 관리합니다.
|
||||
|
||||
```text
|
||||
┌─────────────────────┐ ┌───────────────────────┐ ┌─────────────────────┐
|
||||
│ NotificationDefini- │ 1..N │ NotificationTemplate │ │ Recipients (JSON) │
|
||||
│ tion ├──────┤ (채널별 독립) ├──────┤ - trigger_user │
|
||||
│ type=order.created │ │ channel=mail|db|... │ │ - related_user │
|
||||
│ variables=[...] │ │ subject, body, │ │ - role │
|
||||
│ │ │ click_url │ │ - specific_users │
|
||||
└─────────────────────┘ └───────────────────────┘ └─────────────────────┘
|
||||
```
|
||||
|
||||
- **Definition** — 알림 종류(`type`), 지원 채널, 변수 메타데이터 정의
|
||||
- **Template** — 채널(`mail` / `database` / `broadcast`)마다 독립된 제목·본문·클릭 URL. 관리자가 다국어로 커스터마이징 가능
|
||||
- **Recipients** — 템플릿별로 수신자 규칙을 JSON 으로 정의. 템플릿 단위 독립이므로 "메일은 주문자에게, DB 알림은 역할 보유자에게" 같은 분기 구성 가능
|
||||
|
||||
```php
|
||||
// 모듈 Service 에서 훅 발행만 하면 발송 파이프라인이 자동 실행
|
||||
HookManager::doAction('sirsoft-ecommerce.order.after_confirm', $order);
|
||||
|
||||
// ↓ NotificationHookListener → NotificationDispatcher:
|
||||
// 1. order.confirmed 정의 조회
|
||||
// 2. 활성 템플릿 순회 (mail/database)
|
||||
// 3. 템플릿의 recipients JSON 해석 → 수신자 Collection
|
||||
// 4. 각 수신자에게 channel 별 발송 (GenericNotification)
|
||||
// 5. notification_logs 에 발송 이력 기록
|
||||
```
|
||||
|
||||
- 코어 기본 알림 3종: `welcome`, `reset_password`, `password_changed`
|
||||
- 이커머스 모듈 알림 7종: `order_confirmed`, `order_shipped`, `order_completed`, `order_cancelled`, `new_order_admin`, `inquiry_received`, `inquiry_replied`
|
||||
- 실시간 브로드캐스트는 Laravel Reverb (WebSocket) 기반. Reverb 미구성 환경에서는 graceful skip 으로 오류 없이 동작
|
||||
- `GenericNotification` 단일 클래스가 모든 알림을 처리 — 신규 알림 타입 추가 시 개별 Notification 클래스 작성 불필요
|
||||
|
||||
---
|
||||
|
||||
## 빠른 시작
|
||||
@@ -256,6 +340,7 @@ cp .env.example .env
|
||||
| **sirsoft-verification** | 본인인증 |
|
||||
| **sirsoft-daum_postcode** | 다음 우편번호 검색 |
|
||||
| **sirsoft-marketing** | 마케팅 도구 |
|
||||
| **sirsoft-ckeditor5** | CKEditor 5 에디터 |
|
||||
|
||||
### 템플릿
|
||||
|
||||
|
||||
@@ -31,7 +31,15 @@ class AsUnicodeJson implements CastsAttributes
|
||||
return null;
|
||||
}
|
||||
|
||||
return json_decode($value, true);
|
||||
// 이미 배열인 경우 (다시 read 시점에 배열이 들어오는 시나리오 방어)
|
||||
if (is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) $value, true);
|
||||
|
||||
// 스칼라/문자열로 디코드된 경우는 반환 타입(?array) 위반 → null 처리
|
||||
return is_array($decoded) ? $decoded : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Events\Dashboard\DashboardUpdated;
|
||||
use App\Extension\HookManager;
|
||||
use App\Services\DashboardService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
@@ -31,11 +31,13 @@ class BroadcastDashboardResources extends Command
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @param DashboardService $dashboardService 대시보드 서비스
|
||||
* @return int
|
||||
*/
|
||||
public function handle(DashboardService $dashboardService): int
|
||||
{
|
||||
broadcast(new DashboardUpdated('resources', $dashboardService->getSystemResources()));
|
||||
HookManager::broadcast('core.admin.dashboard', 'dashboard.resources.updated', [
|
||||
'type' => 'resources',
|
||||
'data' => $dashboardService->getSystemResources(),
|
||||
]);
|
||||
|
||||
$this->info('시스템 리소스 정보가 브로드캐스트되었습니다.');
|
||||
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Core\Concerns;
|
||||
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\PluginManager;
|
||||
use App\Extension\TemplateManager;
|
||||
use App\Extension\Vendor\VendorMode;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 코어 업데이트 완료 후 _bundled 번들에 신버전이 포함된
|
||||
* 모듈/플러그인/템플릿을 일괄 업데이트하는 대화형 프롬프트.
|
||||
*
|
||||
* CoreUpdateCommand 에서 사용되며, 다음 단계로 동작한다.
|
||||
* 1) 각 확장 매니저의 checkAllXxxForUpdates() 로 번들 업데이트 감지
|
||||
* 2) 감지 목록 표시 + 일괄 업데이트 진행 여부 확인
|
||||
* 3) 동의 시 전역 레이아웃 전략(overwrite|keep) 1회 질의
|
||||
* 4) 확장별 예외 변경 질의 (choice 로 다중 선택)
|
||||
* 5) 각 확장 매니저의 updateXxx() 호출
|
||||
* 6) 결과 요약 출력
|
||||
*
|
||||
* --force 옵션이 지정된 경우: 프롬프트 스킵 + 전역 전략 'overwrite' 로 즉시 실행.
|
||||
*/
|
||||
trait BundledExtensionUpdatePrompt
|
||||
{
|
||||
/**
|
||||
* 번들 업데이트 목록 수집.
|
||||
*
|
||||
* @return array{modules: array, plugins: array, templates: array}
|
||||
*/
|
||||
protected function collectBundledUpdates(
|
||||
ModuleManager $moduleManager,
|
||||
PluginManager $pluginManager,
|
||||
TemplateManager $templateManager,
|
||||
): array {
|
||||
// CoreUpdateService::collectBundledExtensionUpdates() 를 통해 _bundled manifest 버전을
|
||||
// DB 현재 버전과 직접 비교한다. Manager::checkXxxUpdate() 의 "GitHub 엄격 우선" 정책을
|
||||
// 우회하여 GitHub 미릴리스 상태에서도 _bundled 신버전을 정확히 감지.
|
||||
return app(\App\Services\CoreUpdateService::class)->collectBundledExtensionUpdates();
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 업데이트 UX 를 실행하고 결과를 반환한다.
|
||||
*
|
||||
* @return array{success: int, failed: int, skipped: int, has_updates: bool}
|
||||
*/
|
||||
protected function runBundledExtensionUpdatePrompt(
|
||||
ModuleManager $moduleManager,
|
||||
PluginManager $pluginManager,
|
||||
TemplateManager $templateManager,
|
||||
bool $force,
|
||||
): array {
|
||||
$updates = $this->collectBundledUpdates($moduleManager, $pluginManager, $templateManager);
|
||||
$total = count($updates['modules']) + count($updates['plugins']) + count($updates['templates']);
|
||||
|
||||
if ($total === 0) {
|
||||
$this->info('활성 확장이 최신 번들과 일치합니다.');
|
||||
|
||||
return ['success' => 0, 'failed' => 0, 'skipped' => 0, 'has_updates' => false];
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info('번들에 새 버전이 포함된 확장이 감지되었습니다:');
|
||||
foreach ($updates['modules'] as $m) {
|
||||
$this->line(" [모듈] {$m['identifier']} {$m['current_version']} → {$m['latest_version']}");
|
||||
}
|
||||
foreach ($updates['plugins'] as $p) {
|
||||
$this->line(" [플러그인] {$p['identifier']} {$p['current_version']} → {$p['latest_version']}");
|
||||
}
|
||||
foreach ($updates['templates'] as $t) {
|
||||
$this->line(" [템플릿] {$t['identifier']} {$t['current_version']} → {$t['latest_version']}");
|
||||
}
|
||||
$this->newLine();
|
||||
|
||||
if (! $force && ! $this->confirm('일괄 업데이트를 진행하시겠습니까?', true)) {
|
||||
$this->info('일괄 업데이트를 건너뜁니다.');
|
||||
|
||||
return ['success' => 0, 'failed' => 0, 'skipped' => $total, 'has_updates' => true];
|
||||
}
|
||||
|
||||
// 전역 레이아웃 전략
|
||||
$globalStrategy = 'overwrite';
|
||||
if (! $force) {
|
||||
$answer = $this->choice(
|
||||
'전역 레이아웃 전략을 선택하세요',
|
||||
['overwrite' => '모든 레이아웃을 번들로 덮어쓰기', 'keep' => '사용자가 수정한 레이아웃은 보존'],
|
||||
'overwrite',
|
||||
);
|
||||
$globalStrategy = $answer;
|
||||
}
|
||||
|
||||
// 확장별 전략 오버라이드
|
||||
$strategies = $this->collectPerExtensionStrategies($updates, $globalStrategy, $force);
|
||||
|
||||
// 일괄 실행
|
||||
return $this->executeBulkUpdate(
|
||||
$moduleManager,
|
||||
$pluginManager,
|
||||
$templateManager,
|
||||
$updates,
|
||||
$strategies,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장별 전략 오버라이드 수집.
|
||||
*
|
||||
* @param array $updates collectBundledUpdates() 반환값
|
||||
* @return array<string, string> key: "{type}:{identifier}", value: strategy
|
||||
*/
|
||||
private function collectPerExtensionStrategies(array $updates, string $globalStrategy, bool $force): array
|
||||
{
|
||||
$strategies = [];
|
||||
foreach (['modules', 'plugins', 'templates'] as $type) {
|
||||
foreach ($updates[$type] as $ext) {
|
||||
$strategies["{$type}:{$ext['identifier']}"] = $globalStrategy;
|
||||
}
|
||||
}
|
||||
|
||||
if ($force) {
|
||||
return $strategies;
|
||||
}
|
||||
|
||||
if (! $this->confirm('전역 전략과 다르게 적용할 확장이 있습니까?', false)) {
|
||||
return $strategies;
|
||||
}
|
||||
|
||||
$choices = [];
|
||||
foreach ($updates['modules'] as $m) {
|
||||
$choices[] = "[모듈] {$m['identifier']}";
|
||||
}
|
||||
foreach ($updates['plugins'] as $p) {
|
||||
$choices[] = "[플러그인] {$p['identifier']}";
|
||||
}
|
||||
foreach ($updates['templates'] as $t) {
|
||||
$choices[] = "[템플릿] {$t['identifier']}";
|
||||
}
|
||||
|
||||
$overrideStrategy = $globalStrategy === 'overwrite' ? 'keep' : 'overwrite';
|
||||
|
||||
$this->info("예외 확장은 '{$overrideStrategy}' 전략이 적용됩니다.");
|
||||
$selected = $this->ask('예외 확장 선택 (쉼표로 구분, 빈 값이면 건너뛰기)', '');
|
||||
|
||||
if (trim((string) $selected) === '') {
|
||||
return $strategies;
|
||||
}
|
||||
|
||||
$lookup = [];
|
||||
$idx = 0;
|
||||
foreach (['modules', 'plugins', 'templates'] as $type) {
|
||||
foreach ($updates[$type] as $ext) {
|
||||
$lookup[$choices[$idx]] = "{$type}:{$ext['identifier']}";
|
||||
$idx++;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (array_map('trim', explode(',', (string) $selected)) as $choice) {
|
||||
if (isset($lookup[$choice])) {
|
||||
$strategies[$lookup[$choice]] = $overrideStrategy;
|
||||
}
|
||||
}
|
||||
|
||||
return $strategies;
|
||||
}
|
||||
|
||||
/**
|
||||
* 실제 일괄 업데이트 실행.
|
||||
*
|
||||
* @return array{success: int, failed: int, skipped: int, has_updates: bool}
|
||||
*/
|
||||
private function executeBulkUpdate(
|
||||
ModuleManager $moduleManager,
|
||||
PluginManager $pluginManager,
|
||||
TemplateManager $templateManager,
|
||||
array $updates,
|
||||
array $strategies,
|
||||
): array {
|
||||
$success = 0;
|
||||
$failed = 0;
|
||||
|
||||
$this->newLine();
|
||||
$this->info('── 일괄 업데이트 실행 ──');
|
||||
|
||||
foreach ($updates['modules'] as $m) {
|
||||
$id = $m['identifier'];
|
||||
$strategy = $strategies["modules:{$id}"] ?? 'overwrite';
|
||||
$this->line("→ [모듈] {$id} ({$strategy})");
|
||||
try {
|
||||
$moduleManager->updateModule($id, true, null, VendorMode::Auto, $strategy, null, 'bundled');
|
||||
$success++;
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$this->warn(" 실패: {$e->getMessage()}");
|
||||
Log::error('번들 일괄 업데이트 실패', ['type' => 'module', 'id' => $id, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($updates['plugins'] as $p) {
|
||||
$id = $p['identifier'];
|
||||
$strategy = $strategies["plugins:{$id}"] ?? 'overwrite';
|
||||
$this->line("→ [플러그인] {$id} ({$strategy})");
|
||||
try {
|
||||
$pluginManager->updatePlugin($id, true, null, VendorMode::Auto, $strategy, null, 'bundled');
|
||||
$success++;
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$this->warn(" 실패: {$e->getMessage()}");
|
||||
Log::error('번들 일괄 업데이트 실패', ['type' => 'plugin', 'id' => $id, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($updates['templates'] as $t) {
|
||||
$id = $t['identifier'];
|
||||
$strategy = $strategies["templates:{$id}"] ?? 'overwrite';
|
||||
$this->line("→ [템플릿] {$id} ({$strategy})");
|
||||
try {
|
||||
$templateManager->updateTemplate($id, true, null, $strategy, 'bundled');
|
||||
$success++;
|
||||
} catch (\Throwable $e) {
|
||||
$failed++;
|
||||
$this->warn(" 실패: {$e->getMessage()}");
|
||||
Log::error('번들 일괄 업데이트 실패', ['type' => 'template', 'id' => $id, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->info("업데이트 완료: 성공 {$success}, 실패 {$failed}");
|
||||
|
||||
return [
|
||||
'success' => $success,
|
||||
'failed' => $failed,
|
||||
'skipped' => 0,
|
||||
'has_updates' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,30 @@
|
||||
|
||||
namespace App\Console\Commands\Core;
|
||||
|
||||
use App\Console\Commands\Core\Concerns\BundledExtensionUpdatePrompt;
|
||||
use App\Extension\CoreVersionChecker;
|
||||
use App\Extension\Helpers\CoreBackupHelper;
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\PluginManager;
|
||||
use App\Extension\TemplateManager;
|
||||
use App\Extension\Vendor\Exceptions\VendorInstallException;
|
||||
use App\Extension\Vendor\VendorMode;
|
||||
use App\Services\CoreUpdateService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CoreUpdateCommand extends Command
|
||||
{
|
||||
use BundledExtensionUpdatePrompt;
|
||||
|
||||
protected $signature = 'core:update
|
||||
{--force : 버전 비교 없이 강제 업데이트}
|
||||
{--no-backup : 백업 생성 건너뛰기}
|
||||
{--no-maintenance : 유지보수 모드 활성화 건너뛰기}
|
||||
{--local : 로컬 코드베이스를 업데이트 소스로 사용 (GitHub 스킵)}
|
||||
{--source= : 수동 업데이트용 소스 디렉토리 경로 (GitHub 다운로드 대신 지정 디렉토리 사용)}';
|
||||
{--source= : 수동 업데이트용 소스 디렉토리 경로 (GitHub 다운로드 대신 지정 디렉토리 사용)}
|
||||
{--zip= : 수동 업데이트용 ZIP 파일 경로 (GitHub 다운로드 대신 지정 ZIP 추출 사용)}
|
||||
{--vendor-mode=auto : vendor 설치 모드 (auto|composer|bundled)}';
|
||||
|
||||
protected $description = '그누보드7 코어를 최신 버전으로 업데이트합니다';
|
||||
|
||||
@@ -29,21 +39,56 @@ class CoreUpdateCommand extends Command
|
||||
*/
|
||||
public function handle(CoreUpdateService $service): int
|
||||
{
|
||||
// 코어 업데이트 진행 플래그: 업데이트 중에는 일시적 버전 불일치로 인한 자동 비활성화를
|
||||
// 차단해야 한다 (CoreServiceProvider::validateAndDeactivateIncompatibleExtensions 및
|
||||
// spawn 자식이 모두 이 플래그를 감지). 커맨드 종료 시 프로세스 env 도 함께 소멸하므로
|
||||
// 정리 로직 불필요.
|
||||
$_ENV['G7_UPDATE_IN_PROGRESS'] = '1';
|
||||
$_SERVER['G7_UPDATE_IN_PROGRESS'] = '1';
|
||||
putenv('G7_UPDATE_IN_PROGRESS=1');
|
||||
|
||||
$backupPath = null;
|
||||
$maintenanceEnabled = false;
|
||||
$fromVersion = CoreVersionChecker::getCoreVersion();
|
||||
$toVersion = $fromVersion;
|
||||
$secret = null;
|
||||
$logEntries = [];
|
||||
$ownershipSnapshot = [];
|
||||
|
||||
$log = function (string $message) use (&$logEntries) {
|
||||
$logEntries[] = '['.date('H:i:s').'] '.$message;
|
||||
};
|
||||
|
||||
$this->warnIfFileOwnerMismatch($log);
|
||||
|
||||
$sourceDir = $this->option('source');
|
||||
$zipPath = $this->option('zip');
|
||||
|
||||
try {
|
||||
// ── 시스템 요구사항 검증 (--source / --local 모드에서는 스킵) ──
|
||||
// --source 와 --zip 동시 지정 금지
|
||||
if ($sourceDir && $zipPath) {
|
||||
$this->error('--source 와 --zip 은 동시에 지정할 수 없습니다. 하나만 선택하세요.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
if (($sourceDir || $zipPath) && $this->option('local')) {
|
||||
$this->error('--local 은 --source / --zip 과 동시에 사용할 수 없습니다.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// --zip 옵션 검증 (zip 은 ZipArchive/unzip 추출이 필요하므로 시스템 요구사항 검증 대상)
|
||||
if ($zipPath) {
|
||||
$resolvedZip = realpath($zipPath);
|
||||
if (! $resolvedZip || ! is_file($resolvedZip)) {
|
||||
$this->error('지정된 ZIP 파일이 존재하지 않습니다: '.$zipPath);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$zipPath = $resolvedZip;
|
||||
}
|
||||
|
||||
// ── 시스템 요구사항 검증 (--source / --local 모드에서는 스킵, --zip 은 필요) ──
|
||||
if (! $sourceDir && ! $this->option('local')) {
|
||||
$requirements = $service->checkSystemRequirements();
|
||||
if (! $requirements['valid']) {
|
||||
@@ -71,12 +116,16 @@ class CoreUpdateCommand extends Command
|
||||
$log("수동 업데이트 모드: 소스 디렉토리 = {$sourceDir}");
|
||||
}
|
||||
|
||||
if ($zipPath) {
|
||||
$log("수동 업데이트 모드: ZIP 파일 = {$zipPath}");
|
||||
}
|
||||
|
||||
// ── Step 1: 업데이트 확인 (프로그레스바 없이) ──
|
||||
$this->info(__('settings.core_update.step_check').'...');
|
||||
$log('업데이트 확인 시작');
|
||||
|
||||
if ($sourceDir || $this->option('local')) {
|
||||
// --source / --local 모드: GitHub 스킵, 소스의 버전 읽기 또는 시뮬레이션
|
||||
if ($sourceDir || $this->option('local') || $zipPath) {
|
||||
// --source / --local / --zip 모드: GitHub 스킵, 소스의 버전 읽기
|
||||
if ($sourceDir) {
|
||||
$sourceConfigPath = $sourceDir.DIRECTORY_SEPARATOR.'config'.DIRECTORY_SEPARATOR.'app.php';
|
||||
if (file_exists($sourceConfigPath)) {
|
||||
@@ -90,11 +139,18 @@ class CoreUpdateCommand extends Command
|
||||
}
|
||||
}
|
||||
$log("수동 업데이트 모드: {$fromVersion} → {$toVersion}");
|
||||
} elseif ($zipPath) {
|
||||
// --zip 모드: 버전은 추출 후에만 확인 가능. 일단 fromVersion 유지하고 Step 4 다운로드 후 재판별.
|
||||
$toVersion = $fromVersion;
|
||||
$log("ZIP 업데이트 모드: 버전은 추출 후 판별 ({$zipPath})");
|
||||
} else {
|
||||
$parts = explode('.', $fromVersion);
|
||||
$parts[count($parts) - 1] = (int) end($parts) + 1;
|
||||
$toVersion = implode('.', $parts);
|
||||
$log("로컬 모드: {$fromVersion} → {$toVersion} 시뮬레이션");
|
||||
// --local 모드: 현재 코드베이스의 config/app.php에서 버전을 직접 파싱
|
||||
$localConfigPath = base_path('config'.DIRECTORY_SEPARATOR.'app.php');
|
||||
$configContent = file_get_contents($localConfigPath);
|
||||
if (preg_match("/['\"]version['\"]\s*=>\s*env\s*\(\s*['\"]APP_VERSION['\"]\s*,\s*['\"]([^'\"]+)['\"]\s*\)/", $configContent, $versionMatch)) {
|
||||
$toVersion = $versionMatch[1];
|
||||
}
|
||||
$log("로컬 모드: {$fromVersion} → {$toVersion}");
|
||||
}
|
||||
} else {
|
||||
$updateInfo = $service->checkForUpdates();
|
||||
@@ -116,7 +172,11 @@ class CoreUpdateCommand extends Command
|
||||
// 사용자 확인
|
||||
$this->newLine();
|
||||
$this->info("현재 버전: {$fromVersion}");
|
||||
$this->info("업데이트 버전: {$toVersion}");
|
||||
if ($zipPath) {
|
||||
$this->info('업데이트 버전: (ZIP 추출 후 판별)');
|
||||
} else {
|
||||
$this->info("업데이트 버전: {$toVersion}");
|
||||
}
|
||||
$this->newLine();
|
||||
|
||||
if (! $this->confirm('코어를 업데이트하시겠습니까?')) {
|
||||
@@ -168,6 +228,8 @@ class CoreUpdateCommand extends Command
|
||||
// ── Step 4: 다운로드 ──
|
||||
if ($sourceDir) {
|
||||
$bar->setMessage('소스 디렉토리 검증 중...');
|
||||
} elseif ($zipPath) {
|
||||
$bar->setMessage('ZIP 파일 추출 중...');
|
||||
} elseif ($this->option('local')) {
|
||||
$bar->setMessage('로컬 소스 복제 중...');
|
||||
} else {
|
||||
@@ -182,6 +244,20 @@ class CoreUpdateCommand extends Command
|
||||
// 원본 소스를 _pending으로 복제 (원본 보호)
|
||||
$pendingPath = $service->copySourceToPending($sourceDir, $onProgress);
|
||||
$log("소스 디렉토리를 _pending으로 복제 완료: {$pendingPath}");
|
||||
} elseif ($zipPath) {
|
||||
$log("ZIP 추출 시작: {$zipPath}");
|
||||
$pendingPath = $service->extractZipToPending($zipPath, $onProgress);
|
||||
$log("ZIP 추출 완료: {$pendingPath}");
|
||||
|
||||
// 추출된 소스의 config/app.php 에서 toVersion 갱신
|
||||
$zipConfigPath = $pendingPath.DIRECTORY_SEPARATOR.'config'.DIRECTORY_SEPARATOR.'app.php';
|
||||
if (file_exists($zipConfigPath)) {
|
||||
$zipConfigContent = file_get_contents($zipConfigPath);
|
||||
if (preg_match("/['\"]version['\"]\s*=>\s*env\s*\(\s*['\"]APP_VERSION['\"]\s*,\s*['\"]([^'\"]+)['\"]\s*\)/", $zipConfigContent, $versionMatch)) {
|
||||
$toVersion = $versionMatch[1];
|
||||
$log("ZIP 소스 버전 확인: {$toVersion}");
|
||||
}
|
||||
}
|
||||
} elseif ($this->option('local')) {
|
||||
$log('로컬 소스 복제 시작');
|
||||
$pendingPath = $service->prepareLocalSource($onProgress);
|
||||
@@ -201,20 +277,39 @@ class CoreUpdateCommand extends Command
|
||||
$log("백업 생성 완료: {$backupPath}");
|
||||
}
|
||||
|
||||
// ── Step 6: _pending에서 Composer Install ──
|
||||
$composerSkipped = $service->isComposerUnchangedForCore($pendingPath);
|
||||
// 원본 소유권 스냅샷 — 이후 Step 11 에서 composer 등이 오염시킨 소유권을
|
||||
// 각 경로의 업데이트 전 원본으로 복원하기 위해 백업 직후 시점에 수집한다.
|
||||
$ownershipSnapshot = $service->snapshotOwnership();
|
||||
if (! empty($ownershipSnapshot)) {
|
||||
$log('원본 소유권 스냅샷 수집: '.implode(',', array_keys($ownershipSnapshot)));
|
||||
}
|
||||
|
||||
// ── Step 6: _pending에서 Vendor 설치 (composer 또는 bundled) ──
|
||||
$vendorMode = VendorMode::fromStringOrAuto((string) $this->option('vendor-mode'));
|
||||
$composerSkipped = $vendorMode !== VendorMode::Bundled
|
||||
&& $service->isComposerUnchangedForCore($pendingPath);
|
||||
|
||||
if ($composerSkipped) {
|
||||
$bar->setMessage('Composer 의존성 변경 없음 — 스킵');
|
||||
$bar->advance();
|
||||
$log('composer.json/lock 변경 없음, composer install 스킵');
|
||||
$log('composer.json/lock 변경 없음, vendor 재설치 스킵');
|
||||
$vendorResult = null;
|
||||
} else {
|
||||
$bar->setMessage(__('settings.core_update.step_composer'));
|
||||
$bar->advance();
|
||||
$log('_pending에서 composer install 시작');
|
||||
$log("_pending vendor 설치 시작 (mode: {$vendorMode->value})");
|
||||
|
||||
$service->runComposerInstallInPending($pendingPath, $onProgress);
|
||||
$log('_pending에서 composer install 완료');
|
||||
try {
|
||||
$vendorResult = $service->runVendorInstallInPending($pendingPath, $vendorMode, $onProgress);
|
||||
$log(sprintf(
|
||||
'_pending vendor 설치 완료 (strategy: %s, packages: %d)',
|
||||
$vendorResult->strategy,
|
||||
$vendorResult->packageCount,
|
||||
));
|
||||
} catch (VendorInstallException $e) {
|
||||
$this->error('Vendor 설치 실패: '.$e->getMessage());
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 7: 파일 적용 ──
|
||||
@@ -235,6 +330,7 @@ class CoreUpdateCommand extends Command
|
||||
$bar->advance();
|
||||
$log('vendor 디렉토리 복사 시작');
|
||||
|
||||
// composer/bundled 모드 모두 _pending/vendor/ 가 구성되어 있으므로 공통 복사
|
||||
$service->copyVendorFromPending($pendingPath, $onProgress);
|
||||
$log('vendor 디렉토리 복사 완료');
|
||||
}
|
||||
@@ -242,26 +338,54 @@ class CoreUpdateCommand extends Command
|
||||
// ── Step 9: Migration + 역할/메뉴 동기화 ──
|
||||
$bar->setMessage(__('settings.core_update.step_migration'));
|
||||
$bar->advance();
|
||||
$log('마이그레이션, 역할/메뉴/메일템플릿 동기화 실행');
|
||||
$log('마이그레이션, 역할/메뉴 동기화 실행');
|
||||
|
||||
$service->runMigrations();
|
||||
$service->syncCoreRolesAndPermissions();
|
||||
$service->syncCoreMenus();
|
||||
$service->syncCoreMailTemplates();
|
||||
$log('마이그레이션, 역할/메뉴/메일템플릿 동기화 완료');
|
||||
$log('마이그레이션, 역할/메뉴 동기화 완료');
|
||||
|
||||
// ── Step 10: Upgrade Steps ──
|
||||
//
|
||||
// 경로 B (beta.3+): 별도 프로세스 spawn — 새 PHP 프로세스가 최신 클래스·config 를
|
||||
// 로드하므로 upgrade step 이 신규 Service/Model/Repository 등을 자유롭게 사용 가능.
|
||||
//
|
||||
// 경로 A (beta.1 → beta.2): beta.1 CoreUpdateCommand 가 in-process 로 실행되는
|
||||
// 상황에서는 본 재작성된 로직 자체가 활성화되지 않는다. 해당 경로의 후처리는
|
||||
// upgrade step 파일(Upgrade_7_0_0_beta_2) 내부 로컬 로직으로 수행된다.
|
||||
//
|
||||
// proc_open 미지원 / 실행 실패 시 in-process fallback 으로 안전하게 전환.
|
||||
$bar->setMessage(__('settings.core_update.step_upgrade'));
|
||||
$bar->advance();
|
||||
$log('업그레이드 스텝 실행');
|
||||
$log('업그레이드 스텝 별도 프로세스 실행 시도');
|
||||
|
||||
$service->runUpgradeSteps($fromVersion, $toVersion, function (string $version) use ($bar, $log) {
|
||||
$bar->setMessage(__('settings.core_update.step_upgrade')." ({$version})");
|
||||
$bar->display();
|
||||
$log("업그레이드 스텝 실행: {$version}");
|
||||
});
|
||||
// progress bar 라인과 spawn stdout / step 로그가 같은 줄에 섞이지 않도록
|
||||
// bar 를 잠시 지우고, 상세 로그를 별도 줄로 출력한 뒤 다시 표시한다.
|
||||
$bar->clear();
|
||||
$this->newLine();
|
||||
$this->info('── 업그레이드 스텝 실행 ──');
|
||||
|
||||
$spawned = $this->spawnUpgradeStepsProcess(
|
||||
$fromVersion,
|
||||
$toVersion,
|
||||
(bool) $this->option('force'),
|
||||
$log,
|
||||
);
|
||||
|
||||
if (! $spawned) {
|
||||
$log('별도 프로세스 실행 실패 — in-process fallback');
|
||||
$service->runUpgradeSteps($fromVersion, $toVersion, function (string $version) use ($log) {
|
||||
$this->line(" • upgrade step 실행: {$version}");
|
||||
$log("업그레이드 스텝 실행: {$version}");
|
||||
}, (bool) $this->option('force'));
|
||||
$service->reloadCoreConfigAndResync();
|
||||
$log('fallback 완료 (config 재로드 + 권한/메뉴 재동기화)');
|
||||
}
|
||||
$log('업그레이드 스텝 완료');
|
||||
|
||||
$this->newLine();
|
||||
$bar->display();
|
||||
|
||||
// ── Step 11: Cleanup ──
|
||||
$bar->setMessage(__('settings.core_update.step_cleanup'));
|
||||
$bar->advance();
|
||||
@@ -269,6 +393,11 @@ class CoreUpdateCommand extends Command
|
||||
$service->updateVersionInEnv($toVersion);
|
||||
$service->clearAllCaches();
|
||||
|
||||
// sudo 실행 시 composer 등 외부 프로세스가 root 로 생성한 파일의 소유권을
|
||||
// 백업 직후 수집한 원본 스냅샷 기준으로 복원 (각 경로 고유 소유자 유지)
|
||||
$service->restoreOwnership($ownershipSnapshot, $onProgress);
|
||||
$log('업데이트 경로 소유권 복원 완료');
|
||||
|
||||
$service->cleanupPending($pendingPath);
|
||||
|
||||
if ($backupPath) {
|
||||
@@ -290,10 +419,27 @@ class CoreUpdateCommand extends Command
|
||||
|
||||
$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');
|
||||
|
||||
// _bundled 확장 일괄 업데이트 프롬프트 (번들에 신버전이 있을 때만 표시).
|
||||
//
|
||||
// 주의: Step 11 restoreOwnership 이후에 실행되므로, 프롬프트 내 실제
|
||||
// 업데이트 실행 시 composer / updateComposerAutoload 등으로 생성되는
|
||||
// bootstrap/cache/autoload-extensions.php, {modules|plugins|templates}/{id}/vendor,
|
||||
// storage/app/* 등이 sudo 컨텍스트에서 root 로 오염될 수 있다.
|
||||
// 실제 업데이트가 수행된 경우 restoreOwnership 을 한 번 더 호출해 재복원한다.
|
||||
$promptResult = $this->runBundledExtensionUpdatePrompt(
|
||||
app(ModuleManager::class),
|
||||
app(PluginManager::class),
|
||||
app(TemplateManager::class),
|
||||
(bool) $this->option('force'),
|
||||
);
|
||||
|
||||
if (($promptResult['success'] ?? 0) > 0) {
|
||||
$this->newLine();
|
||||
$this->info('일괄 업데이트로 생성된 파일의 소유권을 복원하는 중...');
|
||||
$service->restoreOwnership($ownershipSnapshot, $onProgress);
|
||||
$log('일괄 확장 업데이트 후 소유권 재복원 완료');
|
||||
}
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
@@ -381,6 +527,107 @@ class CoreUpdateCommand extends Command
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 업그레이드 스텝을 별도 PHP 프로세스에서 실행합니다 (경로 B).
|
||||
*
|
||||
* proc_open 으로 `core:execute-upgrade-steps` 커맨드를 spawn 하여 새 PHP 프로세스가
|
||||
* 최신 파일 기준으로 모든 클래스·config 를 로드하게 한다. 이로써 upgrade step 이
|
||||
* 신규 Service/Repository/Controller 등을 자유롭게 참조할 수 있다.
|
||||
*
|
||||
* stdout 을 실시간으로 상위 콘솔에 전달하여 진행 상황을 보여주고, exit code === 0
|
||||
* 일 때만 성공으로 판정한다. proc_open 미지원 환경·커맨드 미존재·비정상 종료는
|
||||
* false 반환하여 호출자가 in-process fallback 으로 전환할 수 있게 한다.
|
||||
*
|
||||
* @param string $fromVersion 시작 버전
|
||||
* @param string $toVersion 대상 버전
|
||||
* @param bool $force 동일 버전 강제 실행 여부
|
||||
* @param \Closure $log 로그 엔트리 수집 콜백
|
||||
* @return bool spawn 성공 여부 (false 면 fallback 실행 필요)
|
||||
*/
|
||||
private function spawnUpgradeStepsProcess(string $fromVersion, string $toVersion, bool $force, \Closure $log): bool
|
||||
{
|
||||
if (! function_exists('proc_open')) {
|
||||
$log('proc_open 비활성 — spawn 스킵, in-process fallback 진행');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$phpBinary = config('process.php_binary', PHP_BINARY);
|
||||
$artisan = base_path('artisan');
|
||||
|
||||
$command = [
|
||||
$phpBinary,
|
||||
$artisan,
|
||||
'core:execute-upgrade-steps',
|
||||
'--from='.$fromVersion,
|
||||
'--to='.$toVersion,
|
||||
];
|
||||
if ($force) {
|
||||
$command[] = '--force';
|
||||
}
|
||||
|
||||
$commandLine = implode(' ', array_map('escapeshellarg', $command)).' 2>&1';
|
||||
|
||||
$descriptors = [
|
||||
0 => ['pipe', 'r'],
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
];
|
||||
|
||||
// spawn 자식에 전달할 env 구성.
|
||||
//
|
||||
// 주의: `$_ENV` 는 `variables_order` php.ini 설정(기본 CLI 가 "EGPCS" 지만 호스팅에 따라
|
||||
// "GPCS" 만 포함해 E 없을 수 있음) 에 따라 비어있을 수 있다. 이 경우 `$_ENV` 에 값을
|
||||
// 할당해도 실제 프로세스 환경변수 테이블에는 반영 안 되며, `array_merge($_ENV, ...)` 결과도
|
||||
// 불완전해져 spawn 자식이 G7_UPDATE_IN_PROGRESS 등 핵심 플래그를 받지 못한다.
|
||||
//
|
||||
// getenv() (인자 없이 호출, PHP 7.1+) 은 프로세스 환경변수 테이블을 직접 반환해
|
||||
// putenv 로 설정한 값까지 확실히 포함한다. getenv() 를 기반으로 삼고 $_ENV 로 보완하여
|
||||
// 양쪽 채널의 합집합을 자식에 전달한다.
|
||||
//
|
||||
// APP_VERSION: Step 11 updateVersionInEnv 는 아직 실행 전이라 .env 는 fromVersion.
|
||||
// G7_UPDATE_IN_PROGRESS: CoreServiceProvider::validateAndDeactivate 가드가 참조.
|
||||
$env = array_merge(getenv(), $_ENV, [
|
||||
'APP_VERSION' => $toVersion,
|
||||
'G7_UPDATE_IN_PROGRESS' => '1',
|
||||
]);
|
||||
|
||||
$process = proc_open($commandLine, $descriptors, $pipes, base_path(), $env);
|
||||
if (! is_resource($process)) {
|
||||
$log('spawn 실패 — proc_open 자원 생성 실패');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
fclose($pipes[0]);
|
||||
|
||||
// stdout 실시간 전달 — 상위 콘솔에서 진행 상황 확인 가능
|
||||
while (! feof($pipes[1])) {
|
||||
$line = fgets($pipes[1]);
|
||||
if ($line !== false) {
|
||||
$trimmed = rtrim($line);
|
||||
if ($trimmed !== '') {
|
||||
$this->line($trimmed);
|
||||
$log('[spawn] '.$trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
$exitCode = proc_close($process);
|
||||
|
||||
if ($exitCode === 0) {
|
||||
$log('spawn 완료 (exit=0)');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
$log("spawn 비정상 종료 (exit={$exitCode}) — fallback 진행");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 업데이트 로그를 파일로 저장합니다.
|
||||
*
|
||||
@@ -411,4 +658,51 @@ class CoreUpdateCommand extends Command
|
||||
|
||||
Log::info("코어 업데이트 로그 저장: {$logPath}");
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 실행 사용자와 코어 파일 소유자가 다른 경우 경고를 표시합니다.
|
||||
*
|
||||
* 공유 호스팅에서 FTP 사용자(파일 소유자)와 SSH 사용자, 또는 SSH 사용자와
|
||||
* www-data 가 다른 경우 권한 거부가 발생할 수 있습니다. 차단하지는 않고
|
||||
* 사용자가 직접 판단하도록 경고만 출력합니다.
|
||||
*/
|
||||
private function warnIfFileOwnerMismatch(\Closure $log): void
|
||||
{
|
||||
if (! function_exists('posix_geteuid') || ! function_exists('posix_getpwuid')) {
|
||||
return; // Windows 또는 posix 확장 미설치
|
||||
}
|
||||
|
||||
$coreFile = base_path('composer.json');
|
||||
if (! file_exists($coreFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$currentUid = posix_geteuid();
|
||||
$ownerUid = fileowner($coreFile);
|
||||
|
||||
if ($currentUid === $ownerUid) {
|
||||
return;
|
||||
}
|
||||
|
||||
$currentUser = posix_getpwuid($currentUid)['name'] ?? (string) $currentUid;
|
||||
$ownerUser = posix_getpwuid($ownerUid)['name'] ?? (string) $ownerUid;
|
||||
|
||||
$this->newLine();
|
||||
$this->warn('⚠ 실행 사용자와 코어 파일 소유자가 다릅니다.');
|
||||
$this->warn(" 실행 사용자: {$currentUser} (uid={$currentUid})");
|
||||
$this->warn(" 코어 소유자: {$ownerUser} (uid={$ownerUid})");
|
||||
$this->warn(' → 권한 거부가 발생할 수 있습니다. 일반적으로 코어 파일 소유자로');
|
||||
$this->warn(' SSH 로그인 후 실행해야 합니다. vendor/ 가 다른 사용자 소유인 경우');
|
||||
$this->warn(" 'chown -R \$(whoami) vendor/' 후 재시도하세요.");
|
||||
$this->newLine();
|
||||
|
||||
$log("권한 경고: 실행자={$currentUser}({$currentUid}) vs 소유자={$ownerUser}({$ownerUid})");
|
||||
|
||||
Log::warning('core:update 실행 사용자가 코어 파일 소유자와 다름', [
|
||||
'current_uid' => $currentUid,
|
||||
'current_user' => $currentUser,
|
||||
'owner_uid' => $ownerUid,
|
||||
'owner_user' => $ownerUser,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Core;
|
||||
|
||||
use App\Console\Commands\Vendor\Concerns\RunsVendorBundleAction;
|
||||
use App\Extension\Vendor\VendorBundler;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 코어 vendor/ 디렉토리를 vendor-bundle.zip 으로 빌드합니다.
|
||||
*
|
||||
* 기존 *:build 패턴과 일관된 코어 단독 vendor 번들 빌드 명령어.
|
||||
*/
|
||||
class CoreVendorBundleCommand extends Command
|
||||
{
|
||||
use RunsVendorBundleAction;
|
||||
|
||||
protected $signature = 'core:vendor-bundle
|
||||
{--force : 해시 체크 무시, 강제 재빌드}
|
||||
{--check : 실제 빌드 없이 stale 여부만 확인}';
|
||||
|
||||
protected $description = '코어 vendor/ 디렉토리를 vendor-bundle.zip 으로 빌드합니다 (공유 호스팅용)';
|
||||
|
||||
public function handle(VendorBundler $bundler): int
|
||||
{
|
||||
$targets = [['type' => 'core', 'identifier' => null]];
|
||||
|
||||
return $this->runBuildAction(
|
||||
$bundler,
|
||||
$targets,
|
||||
(bool) $this->option('force'),
|
||||
(bool) $this->option('check'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Core;
|
||||
|
||||
use App\Console\Commands\Vendor\Concerns\RunsVendorBundleAction;
|
||||
use App\Extension\Vendor\VendorIntegrityChecker;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 코어 vendor-bundle.zip 무결성을 검증합니다.
|
||||
*/
|
||||
class CoreVendorVerifyCommand extends Command
|
||||
{
|
||||
use RunsVendorBundleAction;
|
||||
|
||||
protected $signature = 'core:vendor-verify';
|
||||
|
||||
protected $description = '코어 vendor-bundle.zip 의 SHA256 무결성을 검증합니다';
|
||||
|
||||
public function handle(VendorIntegrityChecker $checker): int
|
||||
{
|
||||
$targets = [['type' => 'core', 'identifier' => null]];
|
||||
|
||||
return $this->runVerifyAction($checker, $targets);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Core;
|
||||
|
||||
use App\Services\CoreUpdateService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 코어 업그레이드 스텝을 별도 프로세스에서 실행합니다.
|
||||
*
|
||||
* CoreUpdateCommand 의 Step 10 에서 proc_open 으로 호출하여 최신 버전 클래스·
|
||||
* config 를 새 PHP 프로세스에서 로드하게 하는 진입점. 새 프로세스에서 실행되므로
|
||||
* upgrade step 이 신규 Service/Repository/Controller 등을 자유롭게 호출할 수 있다.
|
||||
*
|
||||
* 단, 이는 beta.3 이후 업그레이드 경로(경로 B)에만 적용된다. beta.1 → beta.2
|
||||
* 업그레이드는 beta.1 의 CoreUpdateCommand 가 본 커맨드를 알지 못하므로 spawn
|
||||
* 효과를 받지 못한다(경로 A) — 이 경우 upgrade step 파일 내부 로컬 로직으로
|
||||
* 후처리를 수행해야 한다. 상세는 docs/extension/upgrade-step-guide.md 참조.
|
||||
*/
|
||||
class ExecuteUpgradeStepsCommand extends Command
|
||||
{
|
||||
protected $signature = 'core:execute-upgrade-steps
|
||||
{--from= : 시작 버전}
|
||||
{--to= : 대상 버전}
|
||||
{--force : 동일 버전 강제 실행}';
|
||||
|
||||
protected $description = '코어 업그레이드 스텝을 별도 프로세스에서 실행합니다 (CoreUpdateCommand 내부용)';
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @param CoreUpdateService $service 코어 업데이트 서비스
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
public function handle(CoreUpdateService $service): int
|
||||
{
|
||||
$from = (string) $this->option('from');
|
||||
$to = (string) $this->option('to');
|
||||
$force = (bool) $this->option('force');
|
||||
|
||||
if ($from === '' || $to === '') {
|
||||
$this->error('--from 과 --to 는 필수 옵션입니다.');
|
||||
|
||||
return self::INVALID;
|
||||
}
|
||||
|
||||
try {
|
||||
$service->runUpgradeSteps(
|
||||
$from,
|
||||
$to,
|
||||
fn (string $version) => $this->info("upgrade step 실행: {$version}"),
|
||||
$force,
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('core:execute-upgrade-steps 실패', [
|
||||
'from' => $from,
|
||||
'to' => $to,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$this->error($e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\GeoIp;
|
||||
|
||||
use App\Services\GeoIpDatabaseService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* MaxMind GeoLite2 DB 다운로드/갱신 커맨드.
|
||||
*
|
||||
* 모든 비즈니스 로직은 GeoIpDatabaseService에 위임합니다.
|
||||
* 이 커맨드는 CLI 인터페이스(옵션 파싱 + 결과 출력)만 담당합니다.
|
||||
*/
|
||||
class UpdateGeoLiteDatabaseCommand extends Command
|
||||
{
|
||||
protected $signature = 'geoip:update
|
||||
{--force : 파일이 이미 존재해도 재다운로드}
|
||||
{--dry-run : 실제 다운로드 없이 키/URL만 검증}';
|
||||
|
||||
protected $description = 'MaxMind GeoLite2-City DB를 다운로드하여 storage/app/geoip/에 배치합니다.';
|
||||
|
||||
/**
|
||||
* 커맨드를 실행합니다.
|
||||
*
|
||||
* @param GeoIpDatabaseService $service GeoIP DB 관리 서비스
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
public function handle(GeoIpDatabaseService $service): int
|
||||
{
|
||||
if ($this->option('dry-run')) {
|
||||
return $this->handleDryRun($service);
|
||||
}
|
||||
|
||||
$result = $service->updateDatabase((bool) $this->option('force'));
|
||||
|
||||
return $this->reportResult($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* dry-run 모드: 라이선스 키 마스킹 후 URL만 출력합니다.
|
||||
*
|
||||
* @param GeoIpDatabaseService $service GeoIP DB 관리 서비스
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
private function handleDryRun(GeoIpDatabaseService $service): int
|
||||
{
|
||||
if (! $service->isLicenseKeyConfigured()) {
|
||||
$this->error('MaxMind 라이선스 키가 설정되지 않았습니다.');
|
||||
$this->line('관리자 > 환경설정 > 고급 탭에서 라이선스 키를 입력하세요.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$maskedUrl = $service->buildDownloadUrlForDisplay(true);
|
||||
$this->info('[dry-run] 다운로드 URL 검증 완료');
|
||||
$this->line(" URL: {$maskedUrl}");
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service 결과를 CLI 출력으로 변환하고 exit code를 반환합니다.
|
||||
*
|
||||
* @param array<string, mixed> $result Service::updateDatabase() 반환값
|
||||
* @return int 종료 코드
|
||||
*/
|
||||
private function reportResult(array $result): int
|
||||
{
|
||||
$status = (string) ($result['status'] ?? '');
|
||||
$message = (string) ($result['message'] ?? '');
|
||||
|
||||
if ($status === 'missing_license_key') {
|
||||
$this->error($message);
|
||||
$this->line('관리자 > 환경설정 > 고급 탭에서 라이선스 키를 입력하세요.');
|
||||
$this->line('무료 발급: https://www.maxmind.com/en/geolite2/signup');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if ($status === 'skipped') {
|
||||
$this->info($message);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
if ($status === 'updated') {
|
||||
$data = $result['data'] ?? [];
|
||||
$this->newLine();
|
||||
$this->info('✅ '.$message);
|
||||
$this->line(sprintf(' 파일: %s', $data['database_path'] ?? '-'));
|
||||
$this->line(sprintf(' 크기: %.2f MB', (int) ($data['file_size_bytes'] ?? 0) / 1048576));
|
||||
$this->line(sprintf(' 소요 시간: %s초', $data['elapsed_seconds'] ?? '-'));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// 그 외 모든 실패 상태
|
||||
$this->error($message !== '' ? $message : 'GeoIP DB 업데이트에 실패했습니다.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
namespace App\Console\Commands\Module;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
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
|
||||
@@ -26,7 +26,8 @@ class ClearModuleCacheCommand extends Command
|
||||
*/
|
||||
public function __construct(
|
||||
private ModuleManager $moduleManager,
|
||||
private ModuleRepositoryInterface $moduleRepository
|
||||
private ModuleRepositoryInterface $moduleRepository,
|
||||
private CacheInterface $cache
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -74,7 +75,7 @@ class ClearModuleCacheCommand extends Command
|
||||
];
|
||||
|
||||
foreach ($cacheKeys as $key) {
|
||||
if (Cache::forget($key)) {
|
||||
if ($this->cache->forget($key)) {
|
||||
$clearedCount++;
|
||||
}
|
||||
}
|
||||
@@ -122,7 +123,7 @@ class ClearModuleCacheCommand extends Command
|
||||
];
|
||||
|
||||
foreach ($cacheKeys as $key) {
|
||||
if (Cache::forget($key)) {
|
||||
if ($this->cache->forget($key)) {
|
||||
$clearedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Console\Commands\Traits\HasProgressBar;
|
||||
use App\Contracts\Repositories\ModuleRepositoryInterface;
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\Vendor\VendorMode;
|
||||
use App\Models\Menu;
|
||||
use App\Models\Permission;
|
||||
use App\Rules\ValidExtensionIdentifier;
|
||||
@@ -20,7 +21,10 @@ class InstallModuleCommand extends Command
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'module:install {identifier : 설치할 모듈 식별자}';
|
||||
protected $signature = 'module:install
|
||||
{identifier : 설치할 모듈 식별자}
|
||||
{--vendor-mode=auto : Vendor 설치 모드 (auto|composer|bundled)}
|
||||
{--force : 이미 설치된 경우에도 _bundled/_pending 원본으로 활성 디렉토리를 덮어쓰고 재설치 (불완전 설치 복구)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
@@ -56,22 +60,29 @@ class InstallModuleCommand extends Command
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$force = (bool) $this->option('force');
|
||||
|
||||
try {
|
||||
// 모듈 디렉토리 스캔 및 로드
|
||||
$this->moduleManager->loadModules();
|
||||
|
||||
// 이미 설치된 모듈인지 확인
|
||||
// 이미 설치된 모듈인지 확인 (force 시 스킵하여 재설치/복구 허용)
|
||||
$existingModule = $this->moduleRepository->findByIdentifier($identifier);
|
||||
if ($existingModule) {
|
||||
if ($existingModule && ! $force) {
|
||||
$this->warn('⚠️ '.__('modules.commands.install.already_installed', ['module' => $identifier]));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if ($existingModule && $force) {
|
||||
$this->warn('⚠️ '.__('modules.commands.install.force_reinstall', ['module' => $identifier]));
|
||||
}
|
||||
|
||||
// 모듈 설치
|
||||
$vendorMode = VendorMode::fromStringOrAuto((string) $this->option('vendor-mode'));
|
||||
$onProgress = $this->createProgressCallback(ModuleManager::INSTALL_STEPS);
|
||||
try {
|
||||
$result = $this->moduleManager->installModule($identifier, $onProgress);
|
||||
$result = $this->moduleManager->installModule($identifier, $onProgress, $vendorMode, $force);
|
||||
$this->finishProgress();
|
||||
} catch (\Exception $e) {
|
||||
$this->finishProgress();
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Module;
|
||||
|
||||
use App\Console\Commands\Vendor\Concerns\RunsVendorBundleAction;
|
||||
use App\Extension\Vendor\VendorBundler;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 모듈 vendor/ 디렉토리를 vendor-bundle.zip 으로 빌드합니다.
|
||||
*
|
||||
* 기존 module:build 패턴과 동일하게 positional identifier + --all 옵션 지원.
|
||||
*/
|
||||
class ModuleVendorBundleCommand extends Command
|
||||
{
|
||||
use RunsVendorBundleAction;
|
||||
|
||||
protected $signature = 'module:vendor-bundle
|
||||
{identifier? : 빌드할 모듈 식별자 (생략 시 --all 필요)}
|
||||
{--all : 모든 _bundled 모듈 빌드}
|
||||
{--force : 해시 체크 무시, 강제 재빌드}
|
||||
{--check : 실제 빌드 없이 stale 여부만 확인}';
|
||||
|
||||
protected $description = '모듈 vendor/ 디렉토리를 vendor-bundle.zip 으로 빌드합니다 (공유 호스팅용)';
|
||||
|
||||
public function handle(VendorBundler $bundler): int
|
||||
{
|
||||
$identifier = $this->argument('identifier');
|
||||
$all = (bool) $this->option('all');
|
||||
|
||||
if (! $identifier && ! $all) {
|
||||
$this->error('식별자 또는 --all 옵션을 지정해야 합니다.');
|
||||
$this->line('사용법:');
|
||||
$this->line(' php artisan module:vendor-bundle sirsoft-ecommerce');
|
||||
$this->line(' php artisan module:vendor-bundle --all');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$targets = [];
|
||||
if ($all) {
|
||||
foreach ($this->discoverBundledIdentifiers('module') as $id) {
|
||||
$targets[] = ['type' => 'module', 'identifier' => $id];
|
||||
}
|
||||
} else {
|
||||
$targets[] = ['type' => 'module', 'identifier' => $identifier];
|
||||
}
|
||||
|
||||
return $this->runBuildAction(
|
||||
$bundler,
|
||||
$targets,
|
||||
(bool) $this->option('force'),
|
||||
(bool) $this->option('check'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Module;
|
||||
|
||||
use App\Console\Commands\Vendor\Concerns\RunsVendorBundleAction;
|
||||
use App\Extension\Vendor\VendorIntegrityChecker;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 모듈 vendor-bundle.zip 무결성을 검증합니다.
|
||||
*/
|
||||
class ModuleVendorVerifyCommand extends Command
|
||||
{
|
||||
use RunsVendorBundleAction;
|
||||
|
||||
protected $signature = 'module:vendor-verify
|
||||
{identifier? : 검증할 모듈 식별자 (생략 시 --all 필요)}
|
||||
{--all : 모든 _bundled 모듈 검증}';
|
||||
|
||||
protected $description = '모듈 vendor-bundle.zip 의 SHA256 무결성을 검증합니다';
|
||||
|
||||
public function handle(VendorIntegrityChecker $checker): int
|
||||
{
|
||||
$identifier = $this->argument('identifier');
|
||||
$all = (bool) $this->option('all');
|
||||
|
||||
if (! $identifier && ! $all) {
|
||||
$this->error('식별자 또는 --all 옵션을 지정해야 합니다.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$targets = [];
|
||||
if ($all) {
|
||||
foreach ($this->discoverBundledIdentifiers('module') as $id) {
|
||||
$targets[] = ['type' => 'module', 'identifier' => $id];
|
||||
}
|
||||
} else {
|
||||
$targets[] = ['type' => 'module', 'identifier' => $identifier];
|
||||
}
|
||||
|
||||
return $this->runVerifyAction($checker, $targets);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Console\Commands\Module;
|
||||
use App\Console\Commands\Traits\HasProgressBar;
|
||||
use App\Contracts\Repositories\ModuleRepositoryInterface;
|
||||
use App\Extension\ModuleManager;
|
||||
use App\Extension\Vendor\VendorMode;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
@@ -17,7 +18,11 @@ class UpdateModuleCommand extends Command
|
||||
*/
|
||||
protected $signature = 'module:update
|
||||
{identifier : 업데이트할 모듈 식별자}
|
||||
{--force : 버전 비교 없이 강제 업데이트}';
|
||||
{--force : 버전 비교 없이 강제 업데이트}
|
||||
{--vendor-mode=auto : Vendor 설치 모드 (auto|composer|bundled)}
|
||||
{--layout-strategy=overwrite : 레이아웃 전략 (overwrite|keep)}
|
||||
{--source=auto : 업데이트 소스 (auto|bundled|github) — bundled 는 _bundled 만 사용(GitHub 우회)}
|
||||
{--zip= : 외부 ZIP 파일 경로 (지정 시 GitHub/번들 우회 + 버전은 module.json 기준)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
@@ -41,6 +46,40 @@ class UpdateModuleCommand extends Command
|
||||
{
|
||||
$identifier = $this->argument('identifier');
|
||||
$force = $this->option('force');
|
||||
$layoutStrategy = (string) $this->option('layout-strategy');
|
||||
$sourceOption = (string) $this->option('source');
|
||||
$zipPath = $this->option('zip');
|
||||
|
||||
if (! in_array($layoutStrategy, ['overwrite', 'keep'], true)) {
|
||||
$this->error('❌ --layout-strategy 는 overwrite 또는 keep 이어야 합니다.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (! in_array($sourceOption, ['auto', 'bundled', 'github'], true)) {
|
||||
$this->error('❌ --source 는 auto, bundled, github 중 하나여야 합니다.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// --zip 과 --source 동시 지정 금지 (zip 이 우선 대상이므로)
|
||||
if ($zipPath !== null && $sourceOption !== 'auto') {
|
||||
$this->error('❌ --zip 은 --source 와 동시에 지정할 수 없습니다.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if ($zipPath !== null) {
|
||||
$resolvedZip = realpath($zipPath);
|
||||
if (! $resolvedZip || ! is_file($resolvedZip)) {
|
||||
$this->error('❌ 지정된 ZIP 파일이 존재하지 않습니다: '.$zipPath);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$zipPath = $resolvedZip;
|
||||
}
|
||||
|
||||
$sourceOverride = $sourceOption === 'auto' ? null : $sourceOption;
|
||||
|
||||
try {
|
||||
$this->moduleManager->loadModules();
|
||||
@@ -54,23 +93,29 @@ class UpdateModuleCommand extends Command
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// 업데이트 확인
|
||||
$checkResult = $this->moduleManager->checkModuleUpdate($identifier);
|
||||
// 업데이트 확인 (--zip 모드는 GitHub/번들 비교를 우회하므로 스킵)
|
||||
if ($zipPath === null) {
|
||||
$checkResult = $this->moduleManager->checkModuleUpdate($identifier);
|
||||
|
||||
if (! $checkResult['update_available'] && ! $force) {
|
||||
$this->info('✅ '.__('modules.commands.update.no_update', ['module' => $identifier]));
|
||||
if (! $checkResult['update_available'] && ! $force) {
|
||||
$this->info('✅ '.__('modules.commands.update.no_update', ['module' => $identifier]));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// 업데이트 정보 표시
|
||||
$this->info(__('modules.commands.update.current_version', ['version' => $checkResult['current_version']]));
|
||||
// 업데이트 정보 표시
|
||||
$this->info(__('modules.commands.update.current_version', ['version' => $checkResult['current_version']]));
|
||||
|
||||
if ($force && ! $checkResult['update_available']) {
|
||||
$this->warn('⚠️ '.__('modules.commands.update.force_mode'));
|
||||
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']]));
|
||||
}
|
||||
} else {
|
||||
$this->info(__('modules.commands.update.latest_version', ['version' => $checkResult['latest_version']]));
|
||||
$this->info(__('modules.commands.update.update_source', ['source' => $checkResult['update_source']]));
|
||||
$this->info(__('modules.commands.update.current_version', ['version' => $module->version]));
|
||||
$this->info('업데이트 소스: ZIP ('.$zipPath.')');
|
||||
$this->info('업데이트 버전: (module.json 추출 후 판별)');
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
@@ -83,9 +128,31 @@ class UpdateModuleCommand extends Command
|
||||
}
|
||||
|
||||
// 업데이트 실행
|
||||
$vendorMode = VendorMode::fromStringOrAuto((string) $this->option('vendor-mode'));
|
||||
$onProgress = $this->createProgressCallback(ModuleManager::UPDATE_STEPS);
|
||||
|
||||
// upgrade step 콘솔 출력 콜백: progress bar 를 잠시 지우고 별도 줄에 출력
|
||||
$onUpgradeStep = function (string $version) use ($identifier): void {
|
||||
if ($this->progressBar) {
|
||||
$this->progressBar->clear();
|
||||
}
|
||||
$this->line(" • [{$identifier}] upgrade step 실행: {$version}");
|
||||
if ($this->progressBar) {
|
||||
$this->progressBar->display();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
$updateResult = $this->moduleManager->updateModule($identifier, $force, $onProgress);
|
||||
$updateResult = $this->moduleManager->updateModule(
|
||||
$identifier,
|
||||
$force,
|
||||
$onProgress,
|
||||
$vendorMode,
|
||||
$layoutStrategy,
|
||||
$onUpgradeStep,
|
||||
$sourceOverride,
|
||||
$zipPath,
|
||||
);
|
||||
$this->finishProgress();
|
||||
} catch (\Exception $e) {
|
||||
$this->finishProgress();
|
||||
@@ -104,6 +171,7 @@ class UpdateModuleCommand extends Command
|
||||
'module' => $identifier,
|
||||
'from' => $updateResult['from_version'],
|
||||
'to' => $updateResult['to_version'],
|
||||
'layout_strategy' => $layoutStrategy,
|
||||
]);
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\NotificationService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 오래된 사이트내 알림을 정리하는 커맨드
|
||||
*/
|
||||
class NotificationCleanupCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'notification:cleanup';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*/
|
||||
protected $description = '보관 기간이 지난 사이트내 알림을 삭제합니다';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*
|
||||
* @param NotificationService $service 알림 서비스
|
||||
* @return int 명령 실행 결과 코드
|
||||
*/
|
||||
public function handle(NotificationService $service): int
|
||||
{
|
||||
$this->info('알림 정리 시작...');
|
||||
|
||||
$config = config('notification.database_channel', []);
|
||||
$this->info(" 읽음 보관: {$config['read_retention_days']}일, 미읽음 보관: {$config['unread_retention_days']}일");
|
||||
|
||||
$result = $service->cleanup();
|
||||
|
||||
$this->info(" 읽음 알림 삭제: {$result['deleted_read']}건");
|
||||
$this->info(" 미읽음 알림 삭제: {$result['deleted_unread']}건");
|
||||
$this->info('알림 정리 완료');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
namespace App\Console\Commands\Plugin;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
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
|
||||
@@ -26,7 +26,8 @@ class ClearPluginCacheCommand extends Command
|
||||
*/
|
||||
public function __construct(
|
||||
private PluginManager $pluginManager,
|
||||
private PluginRepositoryInterface $pluginRepository
|
||||
private PluginRepositoryInterface $pluginRepository,
|
||||
private CacheInterface $cache
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -74,7 +75,7 @@ class ClearPluginCacheCommand extends Command
|
||||
];
|
||||
|
||||
foreach ($cacheKeys as $key) {
|
||||
if (Cache::forget($key)) {
|
||||
if ($this->cache->forget($key)) {
|
||||
$clearedCount++;
|
||||
}
|
||||
}
|
||||
@@ -121,7 +122,7 @@ class ClearPluginCacheCommand extends Command
|
||||
];
|
||||
|
||||
foreach ($cacheKeys as $key) {
|
||||
if (Cache::forget($key)) {
|
||||
if ($this->cache->forget($key)) {
|
||||
$clearedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Console\Commands\Traits\HasProgressBar;
|
||||
use App\Contracts\Repositories\PluginRepositoryInterface;
|
||||
use App\Enums\ExtensionOwnerType;
|
||||
use App\Extension\PluginManager;
|
||||
use App\Extension\Vendor\VendorMode;
|
||||
use App\Models\Permission;
|
||||
use App\Rules\ValidExtensionIdentifier;
|
||||
use Illuminate\Console\Command;
|
||||
@@ -19,7 +20,10 @@ class InstallPluginCommand extends Command
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'plugin:install {identifier : 설치할 플러그인 식별자}';
|
||||
protected $signature = 'plugin:install
|
||||
{identifier : 설치할 플러그인 식별자}
|
||||
{--vendor-mode=auto : Vendor 설치 모드 (auto|composer|bundled)}
|
||||
{--force : 이미 설치된 경우에도 _bundled/_pending 원본으로 활성 디렉토리를 덮어쓰고 재설치 (불완전 설치 복구)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
@@ -55,22 +59,29 @@ class InstallPluginCommand extends Command
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$force = (bool) $this->option('force');
|
||||
|
||||
try {
|
||||
// 플러그인 디렉토리 스캔 및 로드
|
||||
$this->pluginManager->loadPlugins();
|
||||
|
||||
// 이미 설치된 플러그인인지 확인
|
||||
// 이미 설치된 플러그인인지 확인 (force 시 스킵하여 재설치/복구 허용)
|
||||
$existingPlugin = $this->pluginRepository->findByIdentifier($identifier);
|
||||
if ($existingPlugin) {
|
||||
if ($existingPlugin && ! $force) {
|
||||
$this->warn('⚠️ '.__('plugins.commands.install.already_installed', ['plugin' => $identifier]));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if ($existingPlugin && $force) {
|
||||
$this->warn('⚠️ '.__('plugins.commands.install.force_reinstall', ['plugin' => $identifier]));
|
||||
}
|
||||
|
||||
// 플러그인 설치
|
||||
$vendorMode = VendorMode::fromStringOrAuto((string) $this->option('vendor-mode'));
|
||||
$onProgress = $this->createProgressCallback(PluginManager::INSTALL_STEPS);
|
||||
try {
|
||||
$result = $this->pluginManager->installPlugin($identifier, $onProgress);
|
||||
$result = $this->pluginManager->installPlugin($identifier, $onProgress, $vendorMode, $force);
|
||||
$this->finishProgress();
|
||||
} catch (\Exception $e) {
|
||||
$this->finishProgress();
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Plugin;
|
||||
|
||||
use App\Console\Commands\Vendor\Concerns\RunsVendorBundleAction;
|
||||
use App\Extension\Vendor\VendorBundler;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 플러그인 vendor/ 디렉토리를 vendor-bundle.zip 으로 빌드합니다.
|
||||
*
|
||||
* 기존 plugin:build 패턴과 동일하게 positional identifier + --all 옵션 지원.
|
||||
*/
|
||||
class PluginVendorBundleCommand extends Command
|
||||
{
|
||||
use RunsVendorBundleAction;
|
||||
|
||||
protected $signature = 'plugin:vendor-bundle
|
||||
{identifier? : 빌드할 플러그인 식별자 (생략 시 --all 필요)}
|
||||
{--all : 모든 _bundled 플러그인 빌드}
|
||||
{--force : 해시 체크 무시, 강제 재빌드}
|
||||
{--check : 실제 빌드 없이 stale 여부만 확인}';
|
||||
|
||||
protected $description = '플러그인 vendor/ 디렉토리를 vendor-bundle.zip 으로 빌드합니다 (공유 호스팅용)';
|
||||
|
||||
public function handle(VendorBundler $bundler): int
|
||||
{
|
||||
$identifier = $this->argument('identifier');
|
||||
$all = (bool) $this->option('all');
|
||||
|
||||
if (! $identifier && ! $all) {
|
||||
$this->error('식별자 또는 --all 옵션을 지정해야 합니다.');
|
||||
$this->line('사용법:');
|
||||
$this->line(' php artisan plugin:vendor-bundle sirsoft-payment');
|
||||
$this->line(' php artisan plugin:vendor-bundle --all');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$targets = [];
|
||||
if ($all) {
|
||||
foreach ($this->discoverBundledIdentifiers('plugin') as $id) {
|
||||
$targets[] = ['type' => 'plugin', 'identifier' => $id];
|
||||
}
|
||||
} else {
|
||||
$targets[] = ['type' => 'plugin', 'identifier' => $identifier];
|
||||
}
|
||||
|
||||
return $this->runBuildAction(
|
||||
$bundler,
|
||||
$targets,
|
||||
(bool) $this->option('force'),
|
||||
(bool) $this->option('check'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Plugin;
|
||||
|
||||
use App\Console\Commands\Vendor\Concerns\RunsVendorBundleAction;
|
||||
use App\Extension\Vendor\VendorIntegrityChecker;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 플러그인 vendor-bundle.zip 무결성을 검증합니다.
|
||||
*/
|
||||
class PluginVendorVerifyCommand extends Command
|
||||
{
|
||||
use RunsVendorBundleAction;
|
||||
|
||||
protected $signature = 'plugin:vendor-verify
|
||||
{identifier? : 검증할 플러그인 식별자 (생략 시 --all 필요)}
|
||||
{--all : 모든 _bundled 플러그인 검증}';
|
||||
|
||||
protected $description = '플러그인 vendor-bundle.zip 의 SHA256 무결성을 검증합니다';
|
||||
|
||||
public function handle(VendorIntegrityChecker $checker): int
|
||||
{
|
||||
$identifier = $this->argument('identifier');
|
||||
$all = (bool) $this->option('all');
|
||||
|
||||
if (! $identifier && ! $all) {
|
||||
$this->error('식별자 또는 --all 옵션을 지정해야 합니다.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$targets = [];
|
||||
if ($all) {
|
||||
foreach ($this->discoverBundledIdentifiers('plugin') as $id) {
|
||||
$targets[] = ['type' => 'plugin', 'identifier' => $id];
|
||||
}
|
||||
} else {
|
||||
$targets[] = ['type' => 'plugin', 'identifier' => $identifier];
|
||||
}
|
||||
|
||||
return $this->runVerifyAction($checker, $targets);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Console\Commands\Plugin;
|
||||
use App\Console\Commands\Traits\HasProgressBar;
|
||||
use App\Contracts\Repositories\PluginRepositoryInterface;
|
||||
use App\Extension\PluginManager;
|
||||
use App\Extension\Vendor\VendorMode;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
@@ -17,7 +18,11 @@ class UpdatePluginCommand extends Command
|
||||
*/
|
||||
protected $signature = 'plugin:update
|
||||
{identifier : 업데이트할 플러그인 식별자}
|
||||
{--force : 버전 비교 없이 강제 업데이트}';
|
||||
{--force : 버전 비교 없이 강제 업데이트}
|
||||
{--vendor-mode=auto : Vendor 설치 모드 (auto|composer|bundled)}
|
||||
{--layout-strategy=overwrite : 레이아웃 전략 (overwrite|keep)}
|
||||
{--source=auto : 업데이트 소스 (auto|bundled|github) — bundled 는 _bundled 만 사용(GitHub 우회)}
|
||||
{--zip= : 외부 ZIP 파일 경로 (지정 시 GitHub/번들 우회 + 버전은 plugin.json 기준)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
@@ -41,6 +46,39 @@ class UpdatePluginCommand extends Command
|
||||
{
|
||||
$identifier = $this->argument('identifier');
|
||||
$force = $this->option('force');
|
||||
$layoutStrategy = (string) $this->option('layout-strategy');
|
||||
$sourceOption = (string) $this->option('source');
|
||||
$zipPath = $this->option('zip');
|
||||
|
||||
if (! in_array($layoutStrategy, ['overwrite', 'keep'], true)) {
|
||||
$this->error('❌ --layout-strategy 는 overwrite 또는 keep 이어야 합니다.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (! in_array($sourceOption, ['auto', 'bundled', 'github'], true)) {
|
||||
$this->error('❌ --source 는 auto, bundled, github 중 하나여야 합니다.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if ($zipPath !== null && $sourceOption !== 'auto') {
|
||||
$this->error('❌ --zip 은 --source 와 동시에 지정할 수 없습니다.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if ($zipPath !== null) {
|
||||
$resolvedZip = realpath($zipPath);
|
||||
if (! $resolvedZip || ! is_file($resolvedZip)) {
|
||||
$this->error('❌ 지정된 ZIP 파일이 존재하지 않습니다: '.$zipPath);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$zipPath = $resolvedZip;
|
||||
}
|
||||
|
||||
$sourceOverride = $sourceOption === 'auto' ? null : $sourceOption;
|
||||
|
||||
try {
|
||||
$this->pluginManager->loadPlugins();
|
||||
@@ -54,23 +92,29 @@ class UpdatePluginCommand extends Command
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// 업데이트 확인
|
||||
$checkResult = $this->pluginManager->checkPluginUpdate($identifier);
|
||||
// 업데이트 확인 (--zip 모드는 GitHub/번들 비교를 우회하므로 스킵)
|
||||
if ($zipPath === null) {
|
||||
$checkResult = $this->pluginManager->checkPluginUpdate($identifier);
|
||||
|
||||
if (! $checkResult['update_available'] && ! $force) {
|
||||
$this->info('✅ '.__('plugins.commands.update.no_update', ['plugin' => $identifier]));
|
||||
if (! $checkResult['update_available'] && ! $force) {
|
||||
$this->info('✅ '.__('plugins.commands.update.no_update', ['plugin' => $identifier]));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// 업데이트 정보 표시
|
||||
$this->info(__('plugins.commands.update.current_version', ['version' => $checkResult['current_version']]));
|
||||
// 업데이트 정보 표시
|
||||
$this->info(__('plugins.commands.update.current_version', ['version' => $checkResult['current_version']]));
|
||||
|
||||
if ($force && ! $checkResult['update_available']) {
|
||||
$this->warn('⚠️ '.__('plugins.commands.update.force_mode'));
|
||||
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']]));
|
||||
}
|
||||
} else {
|
||||
$this->info(__('plugins.commands.update.latest_version', ['version' => $checkResult['latest_version']]));
|
||||
$this->info(__('plugins.commands.update.update_source', ['source' => $checkResult['update_source']]));
|
||||
$this->info(__('plugins.commands.update.current_version', ['version' => $plugin->version]));
|
||||
$this->info('업데이트 소스: ZIP ('.$zipPath.')');
|
||||
$this->info('업데이트 버전: (plugin.json 추출 후 판별)');
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
@@ -83,9 +127,31 @@ class UpdatePluginCommand extends Command
|
||||
}
|
||||
|
||||
// 업데이트 실행
|
||||
$vendorMode = VendorMode::fromStringOrAuto((string) $this->option('vendor-mode'));
|
||||
$onProgress = $this->createProgressCallback(PluginManager::UPDATE_STEPS);
|
||||
|
||||
// upgrade step 콘솔 출력 콜백: progress bar 를 잠시 지우고 별도 줄에 출력
|
||||
$onUpgradeStep = function (string $version) use ($identifier): void {
|
||||
if ($this->progressBar) {
|
||||
$this->progressBar->clear();
|
||||
}
|
||||
$this->line(" • [{$identifier}] upgrade step 실행: {$version}");
|
||||
if ($this->progressBar) {
|
||||
$this->progressBar->display();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
$updateResult = $this->pluginManager->updatePlugin($identifier, $force, $onProgress);
|
||||
$updateResult = $this->pluginManager->updatePlugin(
|
||||
$identifier,
|
||||
$force,
|
||||
$onProgress,
|
||||
$vendorMode,
|
||||
$layoutStrategy,
|
||||
$onUpgradeStep,
|
||||
$sourceOverride,
|
||||
$zipPath,
|
||||
);
|
||||
$this->finishProgress();
|
||||
} catch (\Exception $e) {
|
||||
$this->finishProgress();
|
||||
@@ -104,6 +170,7 @@ class UpdatePluginCommand extends Command
|
||||
'plugin' => $identifier,
|
||||
'from' => $updateResult['from_version'],
|
||||
'to' => $updateResult['to_version'],
|
||||
'layout_strategy' => $layoutStrategy,
|
||||
]);
|
||||
|
||||
return Command::SUCCESS;
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
namespace App\Console\Commands\Template;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
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
|
||||
@@ -26,7 +26,8 @@ class ClearTemplateCacheCommand extends Command
|
||||
* 템플릿 관리자
|
||||
*/
|
||||
public function __construct(
|
||||
private TemplateManager $templateManager
|
||||
private TemplateManager $templateManager,
|
||||
private CacheInterface $cache
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
@@ -82,25 +83,25 @@ class ClearTemplateCacheCommand extends Command
|
||||
if ($templateRecord) {
|
||||
$layouts = TemplateLayout::where('template_id', $templateRecord->id)->get();
|
||||
foreach ($layouts as $layout) {
|
||||
Cache::forget("layout.{$identifier}.{$layout->name}");
|
||||
$this->cache->forget("layout.{$identifier}.{$layout->name}");
|
||||
$clearedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Routes 캐시 삭제
|
||||
Cache::forget("template.routes.{$identifier}");
|
||||
$this->cache->forget("template.routes.{$identifier}");
|
||||
$clearedCount++;
|
||||
|
||||
// 3. 다국어 파일 캐시 삭제
|
||||
$supportedLocales = config('app.supported_locales', ['ko', 'en']);
|
||||
foreach ($supportedLocales as $locale) {
|
||||
Cache::forget("template.language.{$identifier}.{$locale}");
|
||||
$this->cache->forget("template.language.{$identifier}.{$locale}");
|
||||
$clearedCount++;
|
||||
}
|
||||
|
||||
// 4. 활성 템플릿 타입 캐시 삭제
|
||||
if ($templateRecord) {
|
||||
Cache::forget("templates.active.{$templateRecord->type}");
|
||||
$this->cache->forget("templates.active.{$templateRecord->type}");
|
||||
$clearedCount++;
|
||||
}
|
||||
|
||||
@@ -132,24 +133,24 @@ class ClearTemplateCacheCommand extends Command
|
||||
// 1. 레이아웃 캐시 삭제
|
||||
$layouts = TemplateLayout::where('template_id', $templateRecord->id)->get();
|
||||
foreach ($layouts as $layout) {
|
||||
Cache::forget("layout.{$templateRecord->identifier}.{$layout->name}");
|
||||
$this->cache->forget("layout.{$templateRecord->identifier}.{$layout->name}");
|
||||
$clearedCount++;
|
||||
}
|
||||
|
||||
// 2. Routes 캐시 삭제
|
||||
Cache::forget("template.routes.{$templateRecord->identifier}");
|
||||
$this->cache->forget("template.routes.{$templateRecord->identifier}");
|
||||
$clearedCount++;
|
||||
|
||||
// 3. 다국어 파일 캐시 삭제
|
||||
foreach ($supportedLocales as $locale) {
|
||||
Cache::forget("template.language.{$templateRecord->identifier}.{$locale}");
|
||||
$this->cache->forget("template.language.{$templateRecord->identifier}.{$locale}");
|
||||
$clearedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 활성 템플릿 타입 캐시 삭제
|
||||
Cache::forget('templates.active.admin');
|
||||
Cache::forget('templates.active.user');
|
||||
$this->cache->forget('templates.active.admin');
|
||||
$this->cache->forget('templates.active.user');
|
||||
$clearedCount += 2;
|
||||
|
||||
$this->info('✅ '.__('templates.commands.cache_clear.success_all', [
|
||||
|
||||
@@ -17,7 +17,9 @@ class InstallTemplateCommand extends Command
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*/
|
||||
protected $signature = 'template:install {identifier : 템플릿 식별자 (디렉토리명)}';
|
||||
protected $signature = 'template:install
|
||||
{identifier : 템플릿 식별자 (디렉토리명)}
|
||||
{--force : 이미 설치된 경우에도 _bundled/_pending 원본으로 활성 디렉토리를 덮어쓰고 재설치 (불완전 설치 복구)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
@@ -53,14 +55,22 @@ class InstallTemplateCommand extends Command
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$force = (bool) $this->option('force');
|
||||
|
||||
try {
|
||||
// 템플릿 디렉토리 스캔 및 로드
|
||||
$this->templateManager->loadTemplates();
|
||||
|
||||
// 이미 설치된 템플릿인지 확인 (force 시 경고 후 재설치/복구 허용)
|
||||
$existingTemplate = $this->templateRepository->findByIdentifier($identifier);
|
||||
if ($existingTemplate && $force) {
|
||||
$this->warn('⚠️ '.__('templates.commands.install.force_reinstall', ['template' => $identifier]));
|
||||
}
|
||||
|
||||
// 템플릿 설치
|
||||
$onProgress = $this->createProgressCallback(TemplateManager::INSTALL_STEPS);
|
||||
try {
|
||||
$result = $this->templateManager->installTemplate($identifier, $onProgress);
|
||||
$result = $this->templateManager->installTemplate($identifier, $onProgress, $force);
|
||||
$this->finishProgress();
|
||||
} catch (\Exception $e) {
|
||||
$this->finishProgress();
|
||||
|
||||
@@ -18,7 +18,9 @@ class UpdateTemplateCommand extends Command
|
||||
protected $signature = 'template:update
|
||||
{identifier : 업데이트할 템플릿 식별자}
|
||||
{--layout-strategy=overwrite : 레이아웃 전략 (overwrite|keep)}
|
||||
{--force : 버전 비교 없이 강제 업데이트}';
|
||||
{--force : 버전 비교 없이 강제 업데이트}
|
||||
{--source=auto : 업데이트 소스 (auto|bundled|github) — bundled 는 _bundled 만 사용(GitHub 우회)}
|
||||
{--zip= : 외부 ZIP 파일 경로 (지정 시 GitHub/번들 우회 + 버전은 template.json 기준)}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
@@ -43,6 +45,8 @@ class UpdateTemplateCommand extends Command
|
||||
$identifier = $this->argument('identifier');
|
||||
$layoutStrategy = $this->option('layout-strategy');
|
||||
$force = $this->option('force');
|
||||
$sourceOption = (string) $this->option('source');
|
||||
$zipPath = $this->option('zip');
|
||||
|
||||
// 레이아웃 전략 검증
|
||||
if (! in_array($layoutStrategy, ['overwrite', 'keep'])) {
|
||||
@@ -51,6 +55,30 @@ class UpdateTemplateCommand extends Command
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if (! in_array($sourceOption, ['auto', 'bundled', 'github'], true)) {
|
||||
$this->error('❌ --source 는 auto, bundled, github 중 하나여야 합니다.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if ($zipPath !== null && $sourceOption !== 'auto') {
|
||||
$this->error('❌ --zip 은 --source 와 동시에 지정할 수 없습니다.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
if ($zipPath !== null) {
|
||||
$resolvedZip = realpath($zipPath);
|
||||
if (! $resolvedZip || ! is_file($resolvedZip)) {
|
||||
$this->error('❌ 지정된 ZIP 파일이 존재하지 않습니다: '.$zipPath);
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
$zipPath = $resolvedZip;
|
||||
}
|
||||
|
||||
$sourceOverride = $sourceOption === 'auto' ? null : $sourceOption;
|
||||
|
||||
try {
|
||||
$this->templateManager->loadTemplates();
|
||||
|
||||
@@ -63,23 +91,29 @@ class UpdateTemplateCommand extends Command
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// 업데이트 확인
|
||||
$checkResult = $this->templateManager->checkTemplateUpdate($identifier);
|
||||
// 업데이트 확인 (--zip 모드는 GitHub/번들 비교를 우회하므로 스킵)
|
||||
if ($zipPath === null) {
|
||||
$checkResult = $this->templateManager->checkTemplateUpdate($identifier);
|
||||
|
||||
if (! $checkResult['update_available'] && ! $force) {
|
||||
$this->info('✅ '.__('templates.commands.update.no_update', ['template' => $identifier]));
|
||||
if (! $checkResult['update_available'] && ! $force) {
|
||||
$this->info('✅ '.__('templates.commands.update.no_update', ['template' => $identifier]));
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
// 업데이트 정보 표시
|
||||
$this->info(__('templates.commands.update.current_version', ['version' => $checkResult['current_version']]));
|
||||
// 업데이트 정보 표시
|
||||
$this->info(__('templates.commands.update.current_version', ['version' => $checkResult['current_version']]));
|
||||
|
||||
if ($force && ! $checkResult['update_available']) {
|
||||
$this->warn('⚠️ '.__('templates.commands.update.force_mode'));
|
||||
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']]));
|
||||
}
|
||||
} 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.current_version', ['version' => $template->version]));
|
||||
$this->info('업데이트 소스: ZIP ('.$zipPath.')');
|
||||
$this->info('업데이트 버전: (template.json 추출 후 판별)');
|
||||
}
|
||||
|
||||
$this->info(__('templates.commands.update.layout_strategy', ['strategy' => $layoutStrategy]));
|
||||
@@ -115,7 +149,7 @@ class UpdateTemplateCommand extends Command
|
||||
// 업데이트 실행
|
||||
$onProgress = $this->createProgressCallback(TemplateManager::UPDATE_STEPS);
|
||||
try {
|
||||
$updateResult = $this->templateManager->updateTemplate($identifier, $layoutStrategy, $force, $onProgress);
|
||||
$updateResult = $this->templateManager->updateTemplate($identifier, $force, $onProgress, $layoutStrategy, $sourceOverride, $zipPath);
|
||||
$this->finishProgress();
|
||||
} catch (\Exception $e) {
|
||||
$this->finishProgress();
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Vendor;
|
||||
|
||||
use App\Console\Commands\Vendor\Concerns\RunsVendorBundleAction;
|
||||
use App\Extension\Vendor\VendorBundler;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 코어 + 모든 _bundled 모듈/플러그인 vendor 번들 일괄 빌드.
|
||||
*
|
||||
* 개별 빌드는 다음 명령어를 사용:
|
||||
* - core:vendor-bundle
|
||||
* - module:vendor-bundle [identifier|--all]
|
||||
* - plugin:vendor-bundle [identifier|--all]
|
||||
*
|
||||
* 본 명령어는 운영/CI 시나리오에서 한 번에 전체 번들을 갱신하기 위한 일괄 전용 명령어다.
|
||||
*/
|
||||
class BuildVendorBundleCommand extends Command
|
||||
{
|
||||
use RunsVendorBundleAction;
|
||||
|
||||
protected $signature = 'vendor-bundle:build-all
|
||||
{--force : 해시 체크 무시, 강제 재빌드}
|
||||
{--check : 실제 빌드 없이 stale 여부만 확인}';
|
||||
|
||||
protected $description = '코어 + 모든 _bundled 모듈/플러그인의 vendor 번들을 일괄 빌드합니다';
|
||||
|
||||
public function handle(VendorBundler $bundler): int
|
||||
{
|
||||
$targets = [['type' => 'core', 'identifier' => null]];
|
||||
|
||||
foreach ($this->discoverBundledIdentifiers('module') as $id) {
|
||||
$targets[] = ['type' => 'module', 'identifier' => $id];
|
||||
}
|
||||
|
||||
foreach ($this->discoverBundledIdentifiers('plugin') as $id) {
|
||||
$targets[] = ['type' => 'plugin', 'identifier' => $id];
|
||||
}
|
||||
|
||||
return $this->runBuildAction(
|
||||
$bundler,
|
||||
$targets,
|
||||
(bool) $this->option('force'),
|
||||
(bool) $this->option('check'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Vendor\Concerns;
|
||||
|
||||
use App\Extension\Vendor\Exceptions\VendorInstallException;
|
||||
use App\Extension\Vendor\VendorBundleResult;
|
||||
use App\Extension\Vendor\VendorBundler;
|
||||
use App\Extension\Vendor\VendorIntegrityChecker;
|
||||
use Illuminate\Support\Facades\File;
|
||||
|
||||
/**
|
||||
* Vendor 번들 빌드/검증 액션 공통 로직.
|
||||
*
|
||||
* core:vendor-bundle / module:vendor-bundle / plugin:vendor-bundle 및
|
||||
* 일괄 빌드 vendor-bundle:build-all 명령어들이 본 trait 을 공유한다.
|
||||
*/
|
||||
trait RunsVendorBundleAction
|
||||
{
|
||||
/**
|
||||
* 빌드 또는 stale 체크 수행.
|
||||
*
|
||||
* @param array<int, array{type: string, identifier: ?string}> $targets
|
||||
* @param bool $force 해시 체크 무시하고 강제 재빌드
|
||||
* @param bool $check 실제 빌드 없이 stale 여부만 확인 (true 면 stale 시 종료코드 1)
|
||||
*/
|
||||
protected function runBuildAction(VendorBundler $bundler, array $targets, bool $force, bool $check): int
|
||||
{
|
||||
if (empty($targets)) {
|
||||
$this->error('빌드 대상이 없습니다.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->line($check ? 'Vendor Bundle Status Check' : 'Vendor Bundle Build');
|
||||
$this->line(str_repeat('─', 66));
|
||||
$this->newLine();
|
||||
|
||||
$hasStale = false;
|
||||
$results = [];
|
||||
|
||||
foreach ($targets as $target) {
|
||||
try {
|
||||
if ($check) {
|
||||
if ($this->performCheck($bundler, $target)) {
|
||||
$hasStale = true;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$results[] = $this->performBuild($bundler, $target, $force);
|
||||
} catch (VendorInstallException $e) {
|
||||
$this->error(sprintf('✗ %s: %s', $this->formatTargetLabel($target), $e->getMessage()));
|
||||
} catch (\Throwable $e) {
|
||||
$this->error(sprintf('✗ %s: %s', $this->formatTargetLabel($target), $e->getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->line(str_repeat('─', 66));
|
||||
|
||||
if ($check) {
|
||||
return $hasStale ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->printBuildSummary($results);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 무결성 검증 수행.
|
||||
*
|
||||
* @param array<int, array{type: string, identifier: ?string}> $targets
|
||||
*/
|
||||
protected function runVerifyAction(VendorIntegrityChecker $checker, array $targets): int
|
||||
{
|
||||
if (empty($targets)) {
|
||||
$this->error('검증 대상이 없습니다.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->line('Vendor Bundle Integrity Verification');
|
||||
$this->line(str_repeat('─', 66));
|
||||
$this->newLine();
|
||||
|
||||
$failures = 0;
|
||||
foreach ($targets as $target) {
|
||||
$label = $this->formatTargetLabel($target);
|
||||
$outputPath = $this->resolveOutputPath($target);
|
||||
|
||||
if (! is_dir($outputPath)) {
|
||||
$this->warn("- $label: 출력 경로 없음");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$zipPath = $outputPath.DIRECTORY_SEPARATOR.VendorIntegrityChecker::ZIP_FILENAME;
|
||||
if (! file_exists($zipPath)) {
|
||||
$this->line("- $label: 번들 없음 (스킵)");
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$result = $checker->verify($outputPath);
|
||||
|
||||
if ($result->valid) {
|
||||
$packageCount = $result->meta['package_count'] ?? 0;
|
||||
$zipSize = @filesize($zipPath) ?: 0;
|
||||
$this->line(sprintf(
|
||||
'✓ %s: OK (%.1f MB, %d packages)',
|
||||
$label,
|
||||
$zipSize / 1024 / 1024,
|
||||
$packageCount,
|
||||
));
|
||||
} else {
|
||||
$failures++;
|
||||
$this->error(sprintf('✗ %s: FAILED', $label));
|
||||
foreach ($result->errorMessages() as $message) {
|
||||
$this->line(' - '.$message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->line(str_repeat('─', 66));
|
||||
|
||||
if ($failures > 0) {
|
||||
$this->error("검증 실패: $failures 개");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('모든 번들이 무결성 검증을 통과했습니다.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* `_bundled` 디렉토리에서 모든 모듈/플러그인 식별자 목록 반환.
|
||||
*
|
||||
* @param string $type 'module' | 'plugin'
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function discoverBundledIdentifiers(string $type): array
|
||||
{
|
||||
$base = base_path($type === 'module' ? 'modules/_bundled' : 'plugins/_bundled');
|
||||
if (! is_dir($base)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$identifiers = [];
|
||||
foreach (File::directories($base) as $dir) {
|
||||
if (is_file($dir.DIRECTORY_SEPARATOR.'composer.json')) {
|
||||
$identifiers[] = basename($dir);
|
||||
}
|
||||
}
|
||||
|
||||
return $identifiers;
|
||||
}
|
||||
|
||||
private function performBuild(VendorBundler $bundler, array $target, bool $force): VendorBundleResult
|
||||
{
|
||||
$label = $this->formatTargetLabel($target);
|
||||
$this->line("▶ $label 빌드 중...");
|
||||
|
||||
$result = $target['type'] === 'core'
|
||||
? $bundler->buildForCore($force)
|
||||
: $bundler->buildForExtension($target['type'], $target['identifier'], $force);
|
||||
|
||||
if ($result->skipped && $result->reason === 'no-external-dependencies') {
|
||||
$this->line(sprintf(
|
||||
' ○ %s: 스킵 (외부 composer 의존성 없음)',
|
||||
$label,
|
||||
));
|
||||
} elseif ($result->skipped && $result->reason === 'extension-not-installed') {
|
||||
$this->line(sprintf(
|
||||
' ○ %s: 스킵 (확장이 설치되지 않음 — 먼저 module:install / plugin:install 필요)',
|
||||
$label,
|
||||
));
|
||||
} elseif ($result->skipped) {
|
||||
$this->line(sprintf(
|
||||
' ○ %s: 스킵 (%s, %s, %d packages)',
|
||||
$label,
|
||||
$result->reason,
|
||||
$result->zipSizeHuman(),
|
||||
$result->packageCount,
|
||||
));
|
||||
} else {
|
||||
$this->line(sprintf(
|
||||
' ✓ %s: %s, %d packages',
|
||||
$label,
|
||||
$result->zipSizeHuman(),
|
||||
$result->packageCount,
|
||||
));
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* stale 여부 확인만 수행.
|
||||
*
|
||||
* @return bool stale 이면 true
|
||||
*/
|
||||
private function performCheck(VendorBundler $bundler, array $target): bool
|
||||
{
|
||||
$label = $this->formatTargetLabel($target);
|
||||
$sourcePath = $this->resolveSourcePath($target);
|
||||
$outputPath = $this->resolveOutputPath($target);
|
||||
|
||||
if (! is_dir($sourcePath)) {
|
||||
$this->warn("- $label: 소스 경로 없음 ($sourcePath)");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$composerJsonPath = $sourcePath.DIRECTORY_SEPARATOR.'composer.json';
|
||||
if (! is_file($composerJsonPath)) {
|
||||
$this->line("- $label: SKIPPED (composer.json 없음)");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 외부 composer 의존성이 없는 확장은 번들링 대상이 아님
|
||||
if (! $this->composerJsonHasExternalDependencies($composerJsonPath)) {
|
||||
$this->line("- $label: SKIPPED (외부 composer 의존성 없음)");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$stale = $bundler->isStale($sourcePath, $outputPath);
|
||||
if ($stale) {
|
||||
$this->error("✗ $label: STALE — 재빌드 필요");
|
||||
} else {
|
||||
$this->line("✓ $label: up-to-date");
|
||||
}
|
||||
|
||||
return $stale;
|
||||
}
|
||||
|
||||
/**
|
||||
* composer.json 에 외부 패키지 의존성이 있는지 확인합니다.
|
||||
*
|
||||
* VendorBundler::hasExternalDependencies() 와 동일 로직 — trait 에서 중복 구현을
|
||||
* 피하지 않고 독립적으로 구현하여 VendorBundler 인스턴스 없이도 사용 가능하게 함.
|
||||
*/
|
||||
private function composerJsonHasExternalDependencies(string $composerJsonPath): bool
|
||||
{
|
||||
$json = @file_get_contents($composerJsonPath);
|
||||
if ($json === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$require = $data['require'] ?? [];
|
||||
if (! is_array($require) || empty($require)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (array_keys($require) as $package) {
|
||||
if ($package === 'php' || str_starts_with($package, 'ext-')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{type: string, identifier: ?string} $target
|
||||
*/
|
||||
private function formatTargetLabel(array $target): string
|
||||
{
|
||||
return $target['type'] === 'core'
|
||||
? 'core'
|
||||
: sprintf('%ss/%s', $target['type'], $target['identifier']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 소스 경로 (composer.json, composer.lock, vendor/ 를 읽는 경로).
|
||||
*
|
||||
* 코어: base_path() 자체가 소스이자 출력
|
||||
* 모듈/플러그인: 활성 디렉토리 (modules/{id}, plugins/{id}) — 설치 시 composer install 이 실행된 곳
|
||||
*
|
||||
* @param array{type: string, identifier: ?string} $target
|
||||
*/
|
||||
private function resolveSourcePath(array $target): string
|
||||
{
|
||||
return match ($target['type']) {
|
||||
'core' => base_path(),
|
||||
'module' => base_path('modules/'.$target['identifier']),
|
||||
'plugin' => base_path('plugins/'.$target['identifier']),
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 출력 경로 (vendor-bundle.zip 과 vendor-bundle.json 을 쓰는 경로).
|
||||
*
|
||||
* 코어: base_path() (소스와 동일)
|
||||
* 모듈/플러그인: _bundled 디렉토리 — Git 추적되는 배포 아티팩트 저장 위치
|
||||
*
|
||||
* @param array{type: string, identifier: ?string} $target
|
||||
*/
|
||||
private function resolveOutputPath(array $target): string
|
||||
{
|
||||
return match ($target['type']) {
|
||||
'core' => base_path(),
|
||||
'module' => base_path('modules/_bundled/'.$target['identifier']),
|
||||
'plugin' => base_path('plugins/_bundled/'.$target['identifier']),
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, VendorBundleResult> $results
|
||||
*/
|
||||
private function printBuildSummary(array $results): void
|
||||
{
|
||||
$built = 0;
|
||||
$skipped = 0;
|
||||
$totalSize = 0;
|
||||
|
||||
foreach ($results as $result) {
|
||||
if ($result->skipped) {
|
||||
$skipped++;
|
||||
} else {
|
||||
$built++;
|
||||
}
|
||||
$totalSize += $result->zipSize;
|
||||
}
|
||||
|
||||
$this->line(sprintf(
|
||||
'Summary: %d built, %d skipped, total %.1f MB',
|
||||
$built,
|
||||
$skipped,
|
||||
$totalSize / 1024 / 1024,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands\Vendor;
|
||||
|
||||
use App\Console\Commands\Vendor\Concerns\RunsVendorBundleAction;
|
||||
use App\Extension\Vendor\VendorIntegrityChecker;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* 코어 + 모든 _bundled 모듈/플러그인 vendor 번들 일괄 검증.
|
||||
*
|
||||
* 개별 검증은 다음 명령어를 사용:
|
||||
* - core:vendor-verify
|
||||
* - module:vendor-verify [identifier|--all]
|
||||
* - plugin:vendor-verify [identifier|--all]
|
||||
*
|
||||
* 본 명령어는 운영/CI 시나리오에서 한 번에 전체 번들의 무결성을 확인하기 위한 일괄 전용 명령어다.
|
||||
*/
|
||||
class VerifyVendorBundleCommand extends Command
|
||||
{
|
||||
use RunsVendorBundleAction;
|
||||
|
||||
protected $signature = 'vendor-bundle:verify-all';
|
||||
|
||||
protected $description = '코어 + 모든 _bundled 모듈/플러그인의 vendor 번들 무결성을 일괄 검증합니다';
|
||||
|
||||
public function handle(VendorIntegrityChecker $checker): int
|
||||
{
|
||||
$targets = [['type' => 'core', 'identifier' => null]];
|
||||
|
||||
foreach ($this->discoverBundledIdentifiers('module') as $id) {
|
||||
$targets[] = ['type' => 'module', 'identifier' => $id];
|
||||
}
|
||||
|
||||
foreach ($this->discoverBundledIdentifiers('plugin') as $id) {
|
||||
$targets[] = ['type' => 'plugin', 'identifier' => $id];
|
||||
}
|
||||
|
||||
return $this->runVerifyAction($checker, $targets);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
namespace App\Contracts\Extension;
|
||||
|
||||
/**
|
||||
* 확장(코어/모듈/플러그인) 캐시 인터페이스
|
||||
*
|
||||
* 코어, 모듈, 플러그인에서 캐시를 관리하기 위한 표준화된 인터페이스입니다.
|
||||
* 접두사 기반으로 캐시 키를 격리하며, 태그 기반 선택적 무효화를 지원합니다.
|
||||
*
|
||||
* @since engine-v1.18.0
|
||||
*/
|
||||
interface CacheInterface
|
||||
{
|
||||
// === 기본 CRUD ===
|
||||
|
||||
/**
|
||||
* 캐시에서 값을 조회합니다.
|
||||
*
|
||||
* @param string $key 캐시 키
|
||||
* @param mixed $default 기본값
|
||||
* @return mixed 캐시된 값 또는 기본값
|
||||
*/
|
||||
public function get(string $key, mixed $default = null): mixed;
|
||||
|
||||
/**
|
||||
* 캐시에 값을 저장합니다.
|
||||
*
|
||||
* @param string $key 캐시 키
|
||||
* @param mixed $value 저장할 값
|
||||
* @param int|null $ttl TTL (초), null이면 기본값 사용
|
||||
* @return bool 저장 성공 여부
|
||||
*/
|
||||
public function put(string $key, mixed $value, ?int $ttl = null): bool;
|
||||
|
||||
/**
|
||||
* 캐시 키가 존재하는지 확인합니다.
|
||||
*
|
||||
* @param string $key 캐시 키
|
||||
* @return bool 존재 여부
|
||||
*/
|
||||
public function has(string $key): bool;
|
||||
|
||||
/**
|
||||
* 캐시 키를 삭제합니다.
|
||||
*
|
||||
* @param string $key 캐시 키
|
||||
* @return bool 삭제 성공 여부
|
||||
*/
|
||||
public function forget(string $key): bool;
|
||||
|
||||
// === Remember 패턴 ===
|
||||
|
||||
/**
|
||||
* 캐시에서 값을 조회하거나 콜백 결과를 캐시합니다.
|
||||
*
|
||||
* @param string $key 캐시 키
|
||||
* @param callable $callback 캐시 미스 시 실행할 콜백
|
||||
* @param int|null $ttl TTL (초), null이면 기본값 사용
|
||||
* @param array $tags 캐시 태그 (무효화용)
|
||||
* @return mixed 캐시된 값 또는 콜백 결과
|
||||
*/
|
||||
public function remember(string $key, callable $callback, ?int $ttl = null, array $tags = []): mixed;
|
||||
|
||||
/**
|
||||
* 쿼리 해시 기반 캐싱을 수행합니다.
|
||||
*
|
||||
* 내부적으로 'query:' 접두사를 부여하여 일반 캐시 키와 네임스페이스를 분리합니다.
|
||||
*
|
||||
* @param string $queryHash 쿼리 해시 (예: md5(json_encode($filters)))
|
||||
* @param callable $callback 캐시 미스 시 실행할 콜백
|
||||
* @param int|null $ttl TTL (초), null이면 기본값 사용
|
||||
* @param array $tags 캐시 태그 (무효화용)
|
||||
* @return mixed 캐시된 값 또는 콜백 결과
|
||||
*/
|
||||
public function rememberQuery(string $queryHash, callable $callback, ?int $ttl = null, array $tags = []): mixed;
|
||||
|
||||
// === 벌크 연산 ===
|
||||
|
||||
/**
|
||||
* 여러 캐시 키를 한 번에 조회합니다.
|
||||
*
|
||||
* @param array $keys 캐시 키 배열
|
||||
* @return array 키 => 값 배열
|
||||
*/
|
||||
public function many(array $keys): array;
|
||||
|
||||
/**
|
||||
* 여러 캐시 키-값을 한 번에 저장합니다.
|
||||
*
|
||||
* @param array $values 키 => 값 배열
|
||||
* @param int|null $ttl TTL (초), null이면 기본값 사용
|
||||
* @return bool 저장 성공 여부
|
||||
*/
|
||||
public function putMany(array $values, ?int $ttl = null): bool;
|
||||
|
||||
// === 무효화 ===
|
||||
|
||||
/**
|
||||
* 이 드라이버 소속 전체 캐시를 삭제합니다.
|
||||
*
|
||||
* @return bool 삭제 성공 여부
|
||||
*/
|
||||
public function flush(): bool;
|
||||
|
||||
/**
|
||||
* 특정 태그의 캐시를 삭제합니다.
|
||||
*
|
||||
* @param array $tags 삭제할 태그 배열
|
||||
* @return bool 삭제 성공 여부
|
||||
*/
|
||||
public function flushTags(array $tags): bool;
|
||||
|
||||
// === 리프레시 ===
|
||||
|
||||
/**
|
||||
* 캐시를 무효화하고 새로고침합니다.
|
||||
*
|
||||
* forget + remember를 원자적으로 실행합니다.
|
||||
*
|
||||
* @param string $key 캐시 키
|
||||
* @param callable $callback 새 값을 생성할 콜백
|
||||
* @param int|null $ttl TTL (초), null이면 기본값 사용
|
||||
* @param array $tags 캐시 태그 (무효화용)
|
||||
* @return mixed 새로 캐시된 값
|
||||
*/
|
||||
public function refresh(string $key, callable $callback, ?int $ttl = null, array $tags = []): mixed;
|
||||
|
||||
// === 메타 ===
|
||||
|
||||
/**
|
||||
* 현재 캐시 스토어가 태그를 지원하는지 확인합니다.
|
||||
*
|
||||
* @return bool 태그 지원 여부
|
||||
*/
|
||||
public function supportsTags(): bool;
|
||||
|
||||
/**
|
||||
* 현재 사용 중인 캐시 스토어 이름을 반환합니다.
|
||||
*
|
||||
* @return string 스토어 이름
|
||||
*/
|
||||
public function getStore(): string;
|
||||
|
||||
/**
|
||||
* 사용할 캐시 스토어를 변경한 새 인스턴스를 반환합니다.
|
||||
*
|
||||
* 기존 인스턴스는 변경하지 않고, 새 스토어를 사용하는 복제된 인스턴스를 반환합니다.
|
||||
*
|
||||
* @param string $store 캐시 스토어 이름
|
||||
* @return static 새 스토어를 사용하는 인스턴스
|
||||
*/
|
||||
public function withStore(string $store): static;
|
||||
|
||||
/**
|
||||
* 접두사가 적용된 전체 캐시 키를 반환합니다.
|
||||
*
|
||||
* 디버깅 및 로그 확인용으로 사용합니다.
|
||||
*
|
||||
* @param string $key 캐시 키
|
||||
* @return string 접두사가 포함된 전체 키
|
||||
*/
|
||||
public function resolveKey(string $key): string;
|
||||
}
|
||||
@@ -7,10 +7,21 @@ interface HookListenerInterface
|
||||
/**
|
||||
* 구독할 훅과 메서드 매핑을 반환합니다.
|
||||
*
|
||||
* @return array [
|
||||
* Action 훅은 기본적으로 환경설정의 큐 드라이버에 따라 자동으로 큐/동기 실행됩니다.
|
||||
* 반드시 동기 실행이 필요한 경우에만 'sync' => true를 선언하세요.
|
||||
* Filter 훅은 반환값 체인이므로 항상 동기 실행됩니다.
|
||||
*
|
||||
* @return array<string, array{
|
||||
* method?: string,
|
||||
* priority?: int,
|
||||
* type?: 'action'|'filter',
|
||||
* sync?: bool,
|
||||
* }> [
|
||||
* 'hook.name' => [
|
||||
* 'method' => 'methodName',
|
||||
* 'priority' => 10
|
||||
* 'method' => 'methodName', // 실행할 메서드 (기본: 'handle')
|
||||
* 'priority' => 10, // 실행 우선순위 (기본: 10, 낮을수록 먼저)
|
||||
* 'type' => 'action', // 'action' 또는 'filter' (기본: 'action')
|
||||
* 'sync' => false, // true: 큐 드라이버 무관하게 동기 실행 (기본: false)
|
||||
* ]
|
||||
* ]
|
||||
*/
|
||||
|
||||
@@ -97,11 +97,12 @@ interface ModuleManagerInterface
|
||||
* 모듈의 레이아웃을 파일에서 다시 읽어 DB에 갱신합니다.
|
||||
*
|
||||
* @param string $moduleName 모듈명
|
||||
* @param bool $preserveModified true 시 사용자가 UI에서 수정한 레이아웃은 덮어쓰지 않음
|
||||
* @return array{success: bool, layouts_refreshed: int} 갱신 결과 및 갱신된 레이아웃 개수
|
||||
*
|
||||
* @throws \Exception 모듈을 찾을 수 없거나 레이아웃 갱신 실패 시
|
||||
*/
|
||||
public function refreshModuleLayouts(string $moduleName): array;
|
||||
public function refreshModuleLayouts(string $moduleName, bool $preserveModified = false): array;
|
||||
|
||||
/**
|
||||
* 모듈 삭제 시 삭제될 데이터 정보를 반환합니다.
|
||||
|
||||
@@ -97,11 +97,12 @@ interface PluginManagerInterface
|
||||
* 플러그인의 레이아웃을 파일에서 다시 읽어 DB에 갱신합니다.
|
||||
*
|
||||
* @param string $pluginName 플러그인명
|
||||
* @param bool $preserveModified true 시 사용자가 UI에서 수정한 레이아웃은 덮어쓰지 않음
|
||||
* @return array{success: bool, layouts_refreshed: int} 갱신 결과 및 갱신된 레이아웃 개수
|
||||
*
|
||||
* @throws \Exception 플러그인을 찾을 수 없거나 레이아웃 갱신 실패 시
|
||||
*/
|
||||
public function refreshPluginLayouts(string $pluginName): array;
|
||||
public function refreshPluginLayouts(string $pluginName, bool $preserveModified = false): array;
|
||||
|
||||
/**
|
||||
* 플러그인 삭제 시 삭제될 데이터 정보를 반환합니다.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Contracts\Notifications;
|
||||
|
||||
/**
|
||||
* 알림 채널 발송 준비 상태 검증 인터페이스
|
||||
*
|
||||
* 각 채널의 설정 완료 여부를 사전 검증하여,
|
||||
* 미설정 채널은 발송 시도 자체를 건너뛰도록 합니다.
|
||||
* 플러그인은 core.notification.channel_readiness 필터 훅으로 체커를 확장할 수 있습니다.
|
||||
*/
|
||||
interface ChannelReadinessCheckerInterface
|
||||
{
|
||||
/**
|
||||
* 채널이 발송 준비 상태인지 확인합니다.
|
||||
*
|
||||
* @param string $channelId 채널 식별자 (mail, database 등)
|
||||
* @return bool
|
||||
*/
|
||||
public function isReady(string $channelId): bool;
|
||||
|
||||
/**
|
||||
* 채널별 준비 상태와 사유를 반환합니다.
|
||||
*
|
||||
* @param string $channelId 채널 식별자
|
||||
* @return array{ready: bool, reason: string|null}
|
||||
*/
|
||||
public function check(string $channelId): array;
|
||||
|
||||
/**
|
||||
* 여러 채널의 준비 상태를 일괄 반환합니다 (관리자 UI용).
|
||||
*
|
||||
* @param array<string> $channelIds 채널 ID 배열
|
||||
* @return array<string, array{ready: bool, reason: string|null}>
|
||||
*/
|
||||
public function checkAll(array $channelIds): array;
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
<?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,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Contracts\Repositories;
|
||||
|
||||
use App\Models\NotificationDefinition;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
interface NotificationDefinitionRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* ID로 알림 정의 조회.
|
||||
*
|
||||
* @param int $id
|
||||
* @return NotificationDefinition|null
|
||||
*/
|
||||
public function findById(int $id): ?NotificationDefinition;
|
||||
|
||||
/**
|
||||
* 타입으로 알림 정의 조회.
|
||||
*
|
||||
* @param string $type
|
||||
* @return NotificationDefinition|null
|
||||
*/
|
||||
public function findByType(string $type): ?NotificationDefinition;
|
||||
|
||||
/**
|
||||
* 활성 상태인 특정 타입 알림 정의 조회.
|
||||
*
|
||||
* @param string $type
|
||||
* @return NotificationDefinition|null
|
||||
*/
|
||||
public function getActiveByType(string $type): ?NotificationDefinition;
|
||||
|
||||
/**
|
||||
* 모든 활성 알림 정의 조회.
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function getAllActive(): Collection;
|
||||
|
||||
/**
|
||||
* 활성 알림 정의의 로케일별 라벨 맵을 반환합니다.
|
||||
*
|
||||
* 키: type 식별자, 값: 해당 로케일의 다국어 라벨 (fallback: ko → en → 식별자)
|
||||
* 알림 목록 응답에서 N+1 회피 목적으로 사용됩니다.
|
||||
*
|
||||
* @param string|null $locale 사용자 로케일 (null이면 app locale)
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getLabelMap(?string $locale = null): array;
|
||||
|
||||
/**
|
||||
* 전체 알림 정의 목록 조회.
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function getAll(): Collection;
|
||||
|
||||
/**
|
||||
* 특정 확장의 알림 정의 목록 조회.
|
||||
*
|
||||
* @param string $extensionType
|
||||
* @param string $extensionIdentifier
|
||||
* @return Collection
|
||||
*/
|
||||
public function getByExtension(string $extensionType, string $extensionIdentifier): Collection;
|
||||
|
||||
/**
|
||||
* 알림 정의 수정.
|
||||
*
|
||||
* @param NotificationDefinition $definition
|
||||
* @param array $data
|
||||
* @return NotificationDefinition
|
||||
*/
|
||||
public function update(NotificationDefinition $definition, array $data): NotificationDefinition;
|
||||
|
||||
/**
|
||||
* 페이지네이션 목록 조회.
|
||||
*
|
||||
* @param array $filters
|
||||
* @param int $perPage
|
||||
* @return LengthAwarePaginator
|
||||
*/
|
||||
public function getPaginated(array $filters = [], int $perPage = 20): LengthAwarePaginator;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Contracts\Repositories;
|
||||
|
||||
use App\Models\NotificationLog;
|
||||
use App\Models\User;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
interface NotificationLogRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* ID로 알림 로그 조회.
|
||||
*/
|
||||
public function findById(int $id): ?NotificationLog;
|
||||
|
||||
/**
|
||||
* 알림 로그 생성.
|
||||
*/
|
||||
public function create(array $data): NotificationLog;
|
||||
|
||||
/**
|
||||
* 알림 로그 삭제.
|
||||
*/
|
||||
public function delete(NotificationLog $log): bool;
|
||||
|
||||
/**
|
||||
* 다건 삭제.
|
||||
*
|
||||
* @return int 삭제된 건수
|
||||
*/
|
||||
public function bulkDelete(array $ids): int;
|
||||
|
||||
/**
|
||||
* 페이지네이션 목록 조회.
|
||||
*
|
||||
* @param User|null $scopeUser 스코프 적용 대상 사용자 (null이면 스코프 미적용)
|
||||
*/
|
||||
public function getPaginated(array $filters = [], int $perPage = 20, ?User $scopeUser = null): LengthAwarePaginator;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Contracts\Repositories;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Notifications\DatabaseNotification;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
interface NotificationRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* 사용자의 알림 목록을 페이지네이션으로 조회합니다.
|
||||
*/
|
||||
public function getByUser(User $user, array $filters = [], int $perPage = 20): LengthAwarePaginator;
|
||||
|
||||
/**
|
||||
* 사용자의 미읽음 알림 수를 반환합니다.
|
||||
*/
|
||||
public function getUnreadCount(User $user): int;
|
||||
|
||||
/**
|
||||
* 특정 알림을 읽음 처리합니다.
|
||||
*/
|
||||
public function markAsRead(User $user, string $notificationId): ?DatabaseNotification;
|
||||
|
||||
/**
|
||||
* 지정된 알림들을 일괄 읽음 처리합니다.
|
||||
*
|
||||
* @return int 읽음 처리된 건수
|
||||
*/
|
||||
public function markBatchAsRead(User $user, array $ids): int;
|
||||
|
||||
/**
|
||||
* 사용자의 모든 미읽음 알림을 읽음 처리합니다.
|
||||
*
|
||||
* @return int 읽음 처리된 건수
|
||||
*/
|
||||
public function markAllAsRead(User $user): int;
|
||||
|
||||
/**
|
||||
* 알림을 삭제합니다.
|
||||
*/
|
||||
public function delete(User $user, string $notificationId): bool;
|
||||
|
||||
/**
|
||||
* 사용자의 모든 알림을 삭제합니다.
|
||||
*
|
||||
* @return int 삭제된 알림 수
|
||||
*/
|
||||
public function deleteAll(User $user): int;
|
||||
|
||||
/**
|
||||
* 오래된 알림을 정리합니다.
|
||||
*
|
||||
* @return array{deleted_read: int, deleted_unread: int}
|
||||
*/
|
||||
public function cleanup(int $readRetentionDays, int $unreadRetentionDays): array;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Contracts\Repositories;
|
||||
|
||||
use App\Models\NotificationTemplate;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
interface NotificationTemplateRepositoryInterface
|
||||
{
|
||||
/**
|
||||
* ID로 알림 템플릿 조회.
|
||||
*
|
||||
* @param int $id
|
||||
* @return NotificationTemplate|null
|
||||
*/
|
||||
public function findById(int $id): ?NotificationTemplate;
|
||||
|
||||
/**
|
||||
* 알림 정의 ID + 채널로 템플릿 조회.
|
||||
*
|
||||
* @param int $definitionId
|
||||
* @param string $channel
|
||||
* @return NotificationTemplate|null
|
||||
*/
|
||||
public function findByDefinitionAndChannel(int $definitionId, string $channel): ?NotificationTemplate;
|
||||
|
||||
/**
|
||||
* 알림 타입 + 채널로 활성 템플릿 조회.
|
||||
*
|
||||
* @param string $type
|
||||
* @param string $channel
|
||||
* @return NotificationTemplate|null
|
||||
*/
|
||||
public function getActiveByTypeAndChannel(string $type, string $channel): ?NotificationTemplate;
|
||||
|
||||
/**
|
||||
* 특정 알림 정의의 모든 템플릿 조회.
|
||||
*
|
||||
* @param int $definitionId
|
||||
* @return Collection
|
||||
*/
|
||||
public function getByDefinitionId(int $definitionId): Collection;
|
||||
|
||||
/**
|
||||
* 템플릿 수정.
|
||||
*
|
||||
* @param NotificationTemplate $template
|
||||
* @param array $data
|
||||
* @return NotificationTemplate
|
||||
*/
|
||||
public function update(NotificationTemplate $template, array $data): NotificationTemplate;
|
||||
|
||||
/**
|
||||
* 템플릿 생성 또는 수정.
|
||||
*
|
||||
* @param array $attributes
|
||||
* @param array $values
|
||||
* @return NotificationTemplate
|
||||
*/
|
||||
public function updateOrCreate(array $attributes, array $values): NotificationTemplate;
|
||||
}
|
||||
@@ -83,6 +83,17 @@ interface RoleRepositoryInterface
|
||||
*/
|
||||
public function findExtensionRoleByIdentifier(string $identifier, ExtensionOwnerType $extensionType, string $extensionIdentifier): ?Role;
|
||||
|
||||
/**
|
||||
* 확장이 소유한 모든 역할을 조회합니다.
|
||||
*
|
||||
* stale cleanup 진입점에서 현재 DB 에 존재하는 확장 역할 전체를 얻기 위해 사용.
|
||||
*
|
||||
* @param ExtensionOwnerType $extensionType 확장 타입
|
||||
* @param string $extensionIdentifier 확장 식별자
|
||||
* @return Collection 해당 확장 소유 역할 컬렉션
|
||||
*/
|
||||
public function getByExtension(ExtensionOwnerType $extensionType, string $extensionIdentifier): Collection;
|
||||
|
||||
/**
|
||||
* 역할에 권한을 할당합니다.
|
||||
*
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* 메일 발송 상태 Enum
|
||||
*/
|
||||
enum MailSendStatus: string
|
||||
{
|
||||
case Sent = 'sent';
|
||||
case Failed = 'failed';
|
||||
case Skipped = 'skipped';
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* 알림 발송 상태 Enum
|
||||
*/
|
||||
enum NotificationLogStatus: string
|
||||
{
|
||||
/** 발송 성공 */
|
||||
case Sent = 'sent';
|
||||
|
||||
/** 발송 실패 */
|
||||
case Failed = 'failed';
|
||||
|
||||
/** 발송 건너뜀 (템플릿 비활성 등) */
|
||||
case Skipped = 'skipped';
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events\Dashboard;
|
||||
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* 대시보드 업데이트 브로드캐스트 이벤트
|
||||
*
|
||||
* 대시보드의 특정 섹션이 업데이트될 때 클라이언트에 실시간으로 전달합니다.
|
||||
*/
|
||||
class DashboardUpdated implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* 이벤트 인스턴스를 생성합니다.
|
||||
*
|
||||
* @param string $type 업데이트 타입 ('stats', 'resources', 'activities', 'modules', 'alerts')
|
||||
* @param array $data 업데이트된 데이터
|
||||
*/
|
||||
public function __construct(
|
||||
public string $type,
|
||||
public array $data
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 브로드캐스트할 채널을 반환합니다.
|
||||
*
|
||||
* @return array<int, \Illuminate\Broadcasting\Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel('admin.dashboard')];
|
||||
}
|
||||
|
||||
/**
|
||||
* 브로드캐스트 이벤트명을 반환합니다.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return "dashboard.{$this->type}.updated";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Broadcasting\Channel;
|
||||
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||
use Illuminate\Broadcasting\PrivateChannel;
|
||||
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* G7 범용 브로드캐스트 이벤트 (HookManager 내부 전용)
|
||||
*
|
||||
* 외부에서 직접 사용하지 않습니다.
|
||||
* 반드시 HookManager::broadcast()를 통해 호출하세요.
|
||||
*/
|
||||
class GenericBroadcastEvent implements ShouldBroadcast
|
||||
{
|
||||
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param string $channel 브로드캐스트 채널명
|
||||
* @param string $eventName 클라이언트 수신 이벤트명
|
||||
* @param array $payload 브로드캐스트 데이터
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $channel,
|
||||
public readonly string $eventName,
|
||||
public readonly array $payload = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 브로드캐스트할 채널을 반환합니다.
|
||||
*
|
||||
* @return array<int, Channel>
|
||||
*/
|
||||
public function broadcastOn(): array
|
||||
{
|
||||
return [new PrivateChannel($this->channel)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 브로드캐스트 이벤트명을 반환합니다.
|
||||
*/
|
||||
public function broadcastAs(): string
|
||||
{
|
||||
return $this->eventName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 브로드캐스트 데이터를 반환합니다.
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
return $this->payload;
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
namespace App\Extension;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Contracts\Extension\ModuleInterface;
|
||||
use App\Contracts\Extension\StorageInterface;
|
||||
use App\Contracts\Extension\UpgradeStepInterface;
|
||||
use App\Extension\Cache\ModuleCacheDriver;
|
||||
use App\Extension\Storage\ModuleStorageDriver;
|
||||
use Illuminate\Database\Seeder;
|
||||
use ReflectionClass;
|
||||
|
||||
/**
|
||||
@@ -32,6 +35,11 @@ abstract class AbstractModule implements ModuleInterface
|
||||
*/
|
||||
private ?StorageInterface $storage = null;
|
||||
|
||||
/**
|
||||
* 캐시 드라이버 인스턴스 (캐시)
|
||||
*/
|
||||
private ?CacheInterface $cache = null;
|
||||
|
||||
/**
|
||||
* manifest JSON 캐시
|
||||
*/
|
||||
@@ -121,14 +129,22 @@ abstract class AbstractModule implements ModuleInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* 벤더명 반환 (디렉토리명에서 자동 추론)
|
||||
* 벤더명 반환
|
||||
*
|
||||
* 디렉토리명이 'sirsoft-sample'이면 벤더명은 'sirsoft'
|
||||
* module.json 의 vendor 필드를 우선 사용합니다.
|
||||
* 값이 없으면 디렉토리명의 첫 단어(예: 'sirsoft-sample' → 'sirsoft')로 폴백합니다.
|
||||
*
|
||||
* @return string 사람이 읽는 벤더/개발자명 또는 폴백으로 얻은 식별자 prefix
|
||||
*/
|
||||
final public function getVendor(): string
|
||||
{
|
||||
$identifier = $this->getIdentifier();
|
||||
$parts = explode('-', $identifier);
|
||||
$manifestVendor = $this->loadManifest()['vendor'] ?? null;
|
||||
|
||||
if (is_string($manifestVendor) && $manifestVendor !== '') {
|
||||
return $manifestVendor;
|
||||
}
|
||||
|
||||
$parts = explode('-', $this->getIdentifier());
|
||||
|
||||
return $parts[0];
|
||||
}
|
||||
@@ -361,6 +377,52 @@ abstract class AbstractModule implements ModuleInterface
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 런타임에 동적으로 생성되는 권한 식별자 목록을 반환합니다.
|
||||
*
|
||||
* `getPermissions()` 는 모듈 정의 시점의 **정적** 권한 구조를 반환하지만,
|
||||
* 일부 모듈(예: sirsoft-board — 게시판 slug 당 권한 세트)은 런타임에 권한을
|
||||
* 동적으로 생성합니다. 이런 권한은 저장 시 `extension_type=module` +
|
||||
* `extension_identifier={module}` 로 기록되므로, `ModuleManager::cleanupStaleModuleEntries()`
|
||||
* 가 **정적 정의에 없다는 이유로 전부 stale 로 오판해 삭제** 하는 회귀가 일어납니다.
|
||||
*
|
||||
* 동적 권한을 보유한 모듈은 본 메서드를 override 해 현재 DB/설정에 존재해야 하는
|
||||
* 동적 권한 식별자(카테고리 + 액션 전체)를 flat 배열로 반환하세요. 반환값은
|
||||
* cleanup 대상에서 자동 제외됩니다.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getDynamicPermissionIdentifiers(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 런타임에 동적으로 생성되는 역할 식별자 목록을 반환합니다.
|
||||
*
|
||||
* `getRoles()` 정적 정의 외에 런타임에 추가되는 역할(예: 게시판 별 manager/step)이
|
||||
* 있을 때 override 하세요. 반환된 식별자는 stale cleanup 대상에서 제외됩니다.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getDynamicRoleIdentifiers(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 런타임에 동적으로 생성되는 메뉴 slug 목록을 반환합니다.
|
||||
*
|
||||
* `getAdminMenus()` 정적 정의 외에 런타임에 추가되는 메뉴(예: 게시판 별 메뉴)가
|
||||
* 있을 때 override 하세요. 반환된 slug 는 stale cleanup 대상에서 제외됩니다.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getDynamicMenuSlugs(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈 설정 정보 반환
|
||||
*
|
||||
@@ -419,6 +481,27 @@ abstract class AbstractModule implements ModuleInterface
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 브로드캐스트 채널 정의를 반환합니다.
|
||||
*
|
||||
* 모듈에서 WebSocket 실시간 채널이 필요한 경우 오버라이드합니다.
|
||||
* 반환된 채널은 ModuleManager가 자동으로 Broadcast::channel()에 등록합니다.
|
||||
*
|
||||
* 네이밍 규칙: module.{identifier}.{resource}.{param}
|
||||
*
|
||||
* @return array<string, array{permission?: string, type?: string}>
|
||||
* [
|
||||
* 'module.vendor-module.orders.{id}' => [
|
||||
* 'permission' => 'vendor-module.orders.read', // 권한 체크 (선택)
|
||||
* 'type' => 'private', // 채널 타입 (기본: private)
|
||||
* ],
|
||||
* ]
|
||||
*/
|
||||
public function getChannels(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄 작업 목록 반환
|
||||
*
|
||||
@@ -453,7 +536,7 @@ abstract class AbstractModule implements ModuleInterface
|
||||
* 빈 배열 반환 시 database/seeders/ 디렉토리의 모든 시더를 자동 검색합니다. (역호환)
|
||||
* 오버라이드하여 실행할 시더와 순서를 명시적으로 정의하세요.
|
||||
*
|
||||
* @return array<class-string<\Illuminate\Database\Seeder>> 시더 클래스명 배열 (FQCN)
|
||||
* @return array<class-string<Seeder>> 시더 클래스명 배열 (FQCN)
|
||||
*/
|
||||
public function getSeeders(): array
|
||||
{
|
||||
@@ -463,12 +546,19 @@ abstract class AbstractModule implements ModuleInterface
|
||||
/**
|
||||
* 모듈 의존성 반환
|
||||
*
|
||||
* 기본적으로 빈 배열 반환
|
||||
* 모듈 개발자가 의존성이 필요한 경우 오버라이드
|
||||
* module.json 의 dependencies 필드를 반환합니다.
|
||||
* 중첩 구조 형식: ['modules' => [identifier => version, ...], 'plugins' => [...]]
|
||||
*
|
||||
* 기본 구현은 manifest JSON 파싱 결과를 그대로 반환하므로 모듈 개발자는
|
||||
* module.json 에 의존성을 정의하면 되고 PHP 오버라이드는 권장하지 않습니다.
|
||||
*
|
||||
* @return array 중첩 구조 의존성 배열
|
||||
*/
|
||||
public function getDependencies(): array
|
||||
{
|
||||
return [];
|
||||
$dependencies = $this->loadManifest()['dependencies'] ?? [];
|
||||
|
||||
return is_array($dependencies) ? $dependencies : [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -624,6 +714,43 @@ abstract class AbstractModule implements ModuleInterface
|
||||
return is_array($config) ? $config : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* SEO 변수 메타데이터를 반환합니다.
|
||||
*
|
||||
* 모듈이 SEO 렌더링에 제공하는 변수를 page_type별로 선언합니다.
|
||||
* SeoRenderer가 이 메서드를 호출하여 변수를 수집하고 자동 해석합니다.
|
||||
*
|
||||
* 각 변수는 source 타입에 따라 해석 방식이 결정됩니다:
|
||||
* - setting: 모듈 환경설정 값 (엔진 자동 해석)
|
||||
* - core_setting: 코어 설정 값 (엔진 자동 해석)
|
||||
* - query: URL 쿼리 파라미터 (엔진 자동 해석)
|
||||
* - route: 라우트 파라미터 (엔진 자동 해석)
|
||||
* - data: 데이터소스 응답 필드 (템플릿 개발자가 vars에서 매핑)
|
||||
*
|
||||
* 기본적으로 빈 배열 반환.
|
||||
* 모듈 개발자가 SEO 변수가 필요한 경우 오버라이드하세요.
|
||||
*
|
||||
* @return array page_type별 변수 정의 배열
|
||||
* [
|
||||
* 'product' => [
|
||||
* 'product_name' => [
|
||||
* 'description' => '상품명',
|
||||
* 'source' => 'data',
|
||||
* 'required' => true,
|
||||
* ],
|
||||
* 'commerce_name' => [
|
||||
* 'description' => '쇼핑몰명',
|
||||
* 'source' => 'setting',
|
||||
* 'key' => 'basic_info.shop_name',
|
||||
* ],
|
||||
* ],
|
||||
* ]
|
||||
*/
|
||||
public function seoVariables(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 그누보드7 코어 요구 버전 제약 반환
|
||||
*
|
||||
@@ -782,6 +909,38 @@ abstract class AbstractModule implements ModuleInterface
|
||||
return 'modules';
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈 캐시 드라이버 인스턴스 반환
|
||||
*
|
||||
* 모듈별로 격리된 캐시를 제공합니다.
|
||||
* 접두사 패턴: g7:module.{identifier}:{key}
|
||||
*
|
||||
* @return CacheInterface 캐시 드라이버 인스턴스
|
||||
*/
|
||||
public function getCache(): CacheInterface
|
||||
{
|
||||
if ($this->cache === null) {
|
||||
$this->cache = new ModuleCacheDriver(
|
||||
$this->getIdentifier(),
|
||||
$this->getCacheStore()
|
||||
);
|
||||
}
|
||||
|
||||
return $this->cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈에서 사용할 캐시 스토어 이름 반환
|
||||
*
|
||||
* 기본값은 환경설정 캐시 드라이버이며, 모듈 개발자가 다른 스토어를 사용하려면 오버라이드합니다.
|
||||
*
|
||||
* @return string 캐시 스토어 이름
|
||||
*/
|
||||
public function getCacheStore(): string
|
||||
{
|
||||
return config('cache.default');
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 스토리지 기본 경로 반환
|
||||
*
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
namespace App\Extension;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Contracts\Extension\PluginInterface;
|
||||
use App\Contracts\Extension\StorageInterface;
|
||||
use App\Contracts\Extension\UpgradeStepInterface;
|
||||
use App\Extension\Cache\PluginCacheDriver;
|
||||
use App\Extension\Storage\PluginStorageDriver;
|
||||
use Illuminate\Database\Seeder;
|
||||
use ReflectionClass;
|
||||
|
||||
/**
|
||||
@@ -34,6 +37,11 @@ abstract class AbstractPlugin implements PluginInterface
|
||||
*/
|
||||
private ?StorageInterface $storage = null;
|
||||
|
||||
/**
|
||||
* 캐시 드라이버 인스턴스 (캐시)
|
||||
*/
|
||||
private ?CacheInterface $cache = null;
|
||||
|
||||
/**
|
||||
* manifest JSON 캐시
|
||||
*/
|
||||
@@ -123,14 +131,22 @@ abstract class AbstractPlugin implements PluginInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* 벤더명 반환 (디렉토리명에서 자동 추론)
|
||||
* 벤더명 반환
|
||||
*
|
||||
* 디렉토리명이 'sirsoft-sample'이면 벤더명은 'sirsoft'
|
||||
* plugin.json 의 vendor 필드를 우선 사용합니다.
|
||||
* 값이 없으면 디렉토리명의 첫 단어(예: 'sirsoft-sample' → 'sirsoft')로 폴백합니다.
|
||||
*
|
||||
* @return string 사람이 읽는 벤더/개발자명 또는 폴백으로 얻은 식별자 prefix
|
||||
*/
|
||||
final public function getVendor(): string
|
||||
{
|
||||
$identifier = $this->getIdentifier();
|
||||
$parts = explode('-', $identifier);
|
||||
$manifestVendor = $this->loadManifest()['vendor'] ?? null;
|
||||
|
||||
if (is_string($manifestVendor) && $manifestVendor !== '') {
|
||||
return $manifestVendor;
|
||||
}
|
||||
|
||||
$parts = explode('-', $this->getIdentifier());
|
||||
|
||||
return $parts[0];
|
||||
}
|
||||
@@ -353,6 +369,29 @@ abstract class AbstractPlugin implements PluginInterface
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 런타임에 동적으로 생성되는 권한 식별자 목록을 반환합니다.
|
||||
*
|
||||
* 동적 권한(예: 사용자 입력에 따라 생성되는 권한)을 보유한 플러그인이 override.
|
||||
* `PluginManager::cleanupStalePluginEntries()` 가 정의 기반 stale 판정에서 이들을 제외합니다.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getDynamicPermissionIdentifiers(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 런타임에 동적으로 생성되는 역할 식별자 목록을 반환합니다.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getDynamicRoleIdentifiers(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 설정 파일 경로 반환
|
||||
*
|
||||
@@ -401,6 +440,27 @@ abstract class AbstractPlugin implements PluginInterface
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 브로드캐스트 채널 정의를 반환합니다.
|
||||
*
|
||||
* 플러그인에서 WebSocket 실시간 채널이 필요한 경우 오버라이드합니다.
|
||||
* 반환된 채널은 PluginManager가 자동으로 Broadcast::channel()에 등록합니다.
|
||||
*
|
||||
* 네이밍 규칙: plugin.{identifier}.{resource}.{param}
|
||||
*
|
||||
* @return array<string, array{permission?: string, type?: string}>
|
||||
* [
|
||||
* 'plugin.vendor-plugin.status.{id}' => [
|
||||
* 'permission' => 'vendor-plugin.status.read',
|
||||
* 'type' => 'private',
|
||||
* ],
|
||||
* ]
|
||||
*/
|
||||
public function getChannels(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄 작업 목록 반환
|
||||
*
|
||||
@@ -435,7 +495,7 @@ abstract class AbstractPlugin implements PluginInterface
|
||||
* 빈 배열 반환 시 database/seeders/ 디렉토리의 모든 시더를 자동 검색합니다. (역호환)
|
||||
* 오버라이드하여 실행할 시더와 순서를 명시적으로 정의하세요.
|
||||
*
|
||||
* @return array<class-string<\Illuminate\Database\Seeder>> 시더 클래스명 배열 (FQCN)
|
||||
* @return array<class-string<Seeder>> 시더 클래스명 배열 (FQCN)
|
||||
*/
|
||||
public function getSeeders(): array
|
||||
{
|
||||
@@ -445,12 +505,19 @@ abstract class AbstractPlugin implements PluginInterface
|
||||
/**
|
||||
* 플러그인 의존성 반환
|
||||
*
|
||||
* 기본적으로 빈 배열 반환
|
||||
* 플러그인 개발자가 의존성이 필요한 경우 오버라이드
|
||||
* plugin.json 의 dependencies 필드를 반환합니다.
|
||||
* 중첩 구조 형식: ['modules' => [identifier => version, ...], 'plugins' => [...]]
|
||||
*
|
||||
* 기본 구현은 manifest JSON 파싱 결과를 그대로 반환하므로 플러그인 개발자는
|
||||
* plugin.json 에 의존성을 정의하면 되고 PHP 오버라이드는 권장하지 않습니다.
|
||||
*
|
||||
* @return array 중첩 구조 의존성 배열
|
||||
*/
|
||||
public function getDependencies(): array
|
||||
{
|
||||
return [];
|
||||
$dependencies = $this->loadManifest()['dependencies'] ?? [];
|
||||
|
||||
return is_array($dependencies) ? $dependencies : [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -542,6 +609,43 @@ abstract class AbstractPlugin implements PluginInterface
|
||||
return is_array($config) ? $config : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* SEO 변수 메타데이터를 반환합니다.
|
||||
*
|
||||
* 플러그인이 SEO 렌더링에 제공하는 변수를 page_type별로 선언합니다.
|
||||
* SeoRenderer가 이 메서드를 호출하여 변수를 수집하고 자동 해석합니다.
|
||||
*
|
||||
* 각 변수는 source 타입에 따라 해석 방식이 결정됩니다:
|
||||
* - setting: 플러그인 환경설정 값 (엔진 자동 해석)
|
||||
* - core_setting: 코어 설정 값 (엔진 자동 해석)
|
||||
* - query: URL 쿼리 파라미터 (엔진 자동 해석)
|
||||
* - route: 라우트 파라미터 (엔진 자동 해석)
|
||||
* - data: 데이터소스 응답 필드 (템플릿 개발자가 vars에서 매핑)
|
||||
*
|
||||
* 기본적으로 빈 배열 반환.
|
||||
* 플러그인 개발자가 SEO 변수가 필요한 경우 오버라이드하세요.
|
||||
*
|
||||
* @return array page_type별 변수 정의 배열
|
||||
* [
|
||||
* 'product' => [
|
||||
* 'product_name' => [
|
||||
* 'description' => '상품명',
|
||||
* 'source' => 'data',
|
||||
* 'required' => true,
|
||||
* ],
|
||||
* 'commerce_name' => [
|
||||
* 'description' => '쇼핑몰명',
|
||||
* 'source' => 'setting',
|
||||
* 'key' => 'basic_info.shop_name',
|
||||
* ],
|
||||
* ],
|
||||
* ]
|
||||
*/
|
||||
public function seoVariables(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 그누보드7 코어 요구 버전 제약 반환
|
||||
*
|
||||
@@ -773,6 +877,38 @@ abstract class AbstractPlugin implements PluginInterface
|
||||
return 'plugins';
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 캐시 드라이버 인스턴스 반환
|
||||
*
|
||||
* 플러그인별로 격리된 캐시를 제공합니다.
|
||||
* 접두사 패턴: g7:plugin.{identifier}:{key}
|
||||
*
|
||||
* @return CacheInterface 캐시 드라이버 인스턴스
|
||||
*/
|
||||
public function getCache(): CacheInterface
|
||||
{
|
||||
if ($this->cache === null) {
|
||||
$this->cache = new PluginCacheDriver(
|
||||
$this->getIdentifier(),
|
||||
$this->getCacheStore()
|
||||
);
|
||||
}
|
||||
|
||||
return $this->cache;
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인에서 사용할 캐시 스토어 이름 반환
|
||||
*
|
||||
* 기본값은 환경설정 캐시 드라이버이며, 플러그인 개발자가 다른 스토어를 사용하려면 오버라이드합니다.
|
||||
*
|
||||
* @return string 캐시 스토어 이름
|
||||
*/
|
||||
public function getCacheStore(): string
|
||||
{
|
||||
return config('cache.default');
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 스토리지 기본 경로 반환
|
||||
*
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Extension;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Contracts\Extension\StorageInterface;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use ReflectionClass;
|
||||
@@ -30,6 +31,15 @@ abstract class BaseModuleServiceProvider extends ServiceProvider
|
||||
*/
|
||||
protected array $storageServices = [];
|
||||
|
||||
/**
|
||||
* CacheInterface가 필요한 서비스 클래스 목록
|
||||
*
|
||||
* 이 배열에 정의된 서비스들은 자동으로 CacheInterface가 주입됩니다.
|
||||
*
|
||||
* @var array<int, class-string>
|
||||
*/
|
||||
protected array $cacheServices = [];
|
||||
|
||||
/**
|
||||
* Repository 인터페이스와 구현체 매핑
|
||||
*
|
||||
@@ -54,6 +64,9 @@ abstract class BaseModuleServiceProvider extends ServiceProvider
|
||||
|
||||
// StorageInterface 바인딩
|
||||
$this->registerStorageBindings();
|
||||
|
||||
// CacheInterface 바인딩
|
||||
$this->registerCacheBindings();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,6 +112,27 @@ abstract class BaseModuleServiceProvider extends ServiceProvider
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* CacheInterface를 필요로 하는 서비스에 자동 바인딩합니다.
|
||||
*
|
||||
* 각 서비스의 생성자에서 CacheInterface를 주입받으면,
|
||||
* 해당 모듈의 Cache 인스턴스가 자동으로 주입됩니다.
|
||||
*/
|
||||
protected function registerCacheBindings(): void
|
||||
{
|
||||
if (empty($this->cacheServices)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->app->when($this->cacheServices)
|
||||
->needs(CacheInterface::class)
|
||||
->give(function () {
|
||||
return $this->app->make(ModuleManager::class)
|
||||
->getModule($this->moduleIdentifier)
|
||||
->getCache();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceProvider 파일의 디렉토리 경로 반환
|
||||
*
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Cache;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 캐시 드라이버 추상 클래스
|
||||
*
|
||||
* CoreCacheDriver, ModuleCacheDriver, PluginCacheDriver의 공통 구현을 제공합니다.
|
||||
* 접두사 기반 키 분리, 태그 지원/미지원 드라이버 호환, 키 인덱스 기반 flush를 포함합니다.
|
||||
*
|
||||
* @since engine-v1.18.0
|
||||
*/
|
||||
abstract class AbstractCacheDriver implements CacheInterface
|
||||
{
|
||||
/**
|
||||
* 사용할 캐시 스토어 이름
|
||||
*/
|
||||
protected string $store;
|
||||
|
||||
/**
|
||||
* 서브클래스에서 접두사를 반환합니다.
|
||||
*
|
||||
* @return string 접두사 (예: 'g7:core', 'g7:module.sirsoft-ecommerce')
|
||||
*/
|
||||
abstract protected function getPrefix(): string;
|
||||
|
||||
/**
|
||||
* 기본 TTL을 반환합니다.
|
||||
*
|
||||
* g7_core_settings 중앙 관리를 통해 기본값을 결정합니다.
|
||||
*
|
||||
* @return int 기본 TTL (초)
|
||||
*/
|
||||
protected function getDefaultTtl(): int
|
||||
{
|
||||
return (int) g7_core_settings('cache.default_ttl', 86400);
|
||||
}
|
||||
|
||||
/**
|
||||
* 접두사가 적용된 전체 캐시 키를 반환합니다.
|
||||
*
|
||||
* @param string $key 캐시 키
|
||||
* @return string 접두사가 포함된 전체 키
|
||||
*/
|
||||
public function resolveKey(string $key): string
|
||||
{
|
||||
return $this->getPrefix() . ':' . $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Laravel Cache 스토어 인스턴스를 반환합니다.
|
||||
*
|
||||
* @return \Illuminate\Cache\Repository 캐시 스토어 인스턴스
|
||||
*/
|
||||
protected function store(): \Illuminate\Cache\Repository
|
||||
{
|
||||
return Cache::store($this->store);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 캐시 스토어가 태그를 지원하는지 확인합니다.
|
||||
*
|
||||
* @return bool 태그 지원 여부
|
||||
*/
|
||||
public function supportsTags(): bool
|
||||
{
|
||||
return method_exists($this->store()->getStore(), 'tags');
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 사용 중인 캐시 스토어 이름을 반환합니다.
|
||||
*
|
||||
* @return string 스토어 이름
|
||||
*/
|
||||
public function getStore(): string
|
||||
{
|
||||
return $this->store;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용할 캐시 스토어를 변경한 새 인스턴스를 반환합니다.
|
||||
*
|
||||
* @param string $store 캐시 스토어 이름
|
||||
* @return static 새 스토어를 사용하는 인스턴스
|
||||
*/
|
||||
public function withStore(string $store): static
|
||||
{
|
||||
$clone = clone $this;
|
||||
$clone->store = $store;
|
||||
|
||||
return $clone;
|
||||
}
|
||||
|
||||
// === 기본 CRUD ===
|
||||
|
||||
/**
|
||||
* 캐시에서 값을 조회합니다.
|
||||
*
|
||||
* @param string $key 캐시 키
|
||||
* @param mixed $default 기본값
|
||||
* @return mixed 캐시된 값 또는 기본값
|
||||
*/
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return $this->store()->get($this->resolveKey($key), $default);
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시에 값을 저장합니다.
|
||||
*
|
||||
* @param string $key 캐시 키
|
||||
* @param mixed $value 저장할 값
|
||||
* @param int|null $ttl TTL (초), null이면 기본값 사용
|
||||
* @return bool 저장 성공 여부
|
||||
*/
|
||||
public function put(string $key, mixed $value, ?int $ttl = null): bool
|
||||
{
|
||||
$resolvedKey = $this->resolveKey($key);
|
||||
$ttl = $ttl ?? $this->getDefaultTtl();
|
||||
|
||||
return $this->store()->put($resolvedKey, $value, $ttl);
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 키가 존재하는지 확인합니다.
|
||||
*
|
||||
* @param string $key 캐시 키
|
||||
* @return bool 존재 여부
|
||||
*/
|
||||
public function has(string $key): bool
|
||||
{
|
||||
return $this->store()->has($this->resolveKey($key));
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 키를 삭제합니다.
|
||||
*
|
||||
* @param string $key 캐시 키
|
||||
* @return bool 삭제 성공 여부
|
||||
*/
|
||||
public function forget(string $key): bool
|
||||
{
|
||||
return $this->store()->forget($this->resolveKey($key));
|
||||
}
|
||||
|
||||
// === Remember 패턴 ===
|
||||
|
||||
/**
|
||||
* 캐시에서 값을 조회하거나 콜백 결과를 캐시합니다.
|
||||
*
|
||||
* 소속 태그(확장 접두사)를 항상 포함하여 flush() 시 일괄 삭제가 가능합니다.
|
||||
*
|
||||
* @param string $key 캐시 키
|
||||
* @param callable $callback 캐시 미스 시 실행할 콜백
|
||||
* @param int|null $ttl TTL (초), null이면 기본값 사용
|
||||
* @param array $tags 캐시 태그 (무효화용)
|
||||
* @return mixed 캐시된 값 또는 콜백 결과
|
||||
*/
|
||||
public function remember(string $key, callable $callback, ?int $ttl = null, array $tags = []): mixed
|
||||
{
|
||||
$resolvedKey = $this->resolveKey($key);
|
||||
$ttl = $ttl ?? $this->getDefaultTtl();
|
||||
|
||||
// 소속 태그(확장 접두사)를 항상 포함 → flush() 시 일괄 삭제 가능
|
||||
$allTags = array_values(array_unique(array_merge([$this->getPrefix()], $tags)));
|
||||
|
||||
// 항상 일반 remember + 키 인덱스에 태그 매핑 기록
|
||||
// Laravel 네이티브 태그 저장은 사용하지 않음 (get/has와의 일관성 보장)
|
||||
$result = $this->store()->remember($resolvedKey, $ttl, $callback);
|
||||
$this->recordKeyTags($resolvedKey, $allTags);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿼리 해시 기반 캐싱을 수행합니다.
|
||||
*
|
||||
* @param string $queryHash 쿼리 해시
|
||||
* @param callable $callback 캐시 미스 시 실행할 콜백
|
||||
* @param int|null $ttl TTL (초), null이면 기본값 사용
|
||||
* @param array $tags 캐시 태그 (무효화용)
|
||||
* @return mixed 캐시된 값 또는 콜백 결과
|
||||
*/
|
||||
public function rememberQuery(string $queryHash, callable $callback, ?int $ttl = null, array $tags = []): mixed
|
||||
{
|
||||
return $this->remember('query:' . $queryHash, $callback, $ttl, $tags);
|
||||
}
|
||||
|
||||
// === 벌크 연산 ===
|
||||
|
||||
/**
|
||||
* 여러 캐시 키를 한 번에 조회합니다.
|
||||
*
|
||||
* @param array $keys 캐시 키 배열
|
||||
* @return array 키 => 값 배열
|
||||
*/
|
||||
public function many(array $keys): array
|
||||
{
|
||||
$resolvedKeys = [];
|
||||
$keyMap = [];
|
||||
|
||||
foreach ($keys as $key) {
|
||||
$resolved = $this->resolveKey($key);
|
||||
$resolvedKeys[] = $resolved;
|
||||
$keyMap[$resolved] = $key;
|
||||
}
|
||||
|
||||
$results = $this->store()->many($resolvedKeys);
|
||||
|
||||
// 원래 키로 매핑하여 반환
|
||||
$mapped = [];
|
||||
foreach ($results as $resolvedKey => $value) {
|
||||
$originalKey = $keyMap[$resolvedKey] ?? $resolvedKey;
|
||||
$mapped[$originalKey] = $value;
|
||||
}
|
||||
|
||||
return $mapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 캐시 키-값을 한 번에 저장합니다.
|
||||
*
|
||||
* @param array $values 키 => 값 배열
|
||||
* @param int|null $ttl TTL (초), null이면 기본값 사용
|
||||
* @return bool 저장 성공 여부
|
||||
*/
|
||||
public function putMany(array $values, ?int $ttl = null): bool
|
||||
{
|
||||
$ttl = $ttl ?? $this->getDefaultTtl();
|
||||
$resolved = [];
|
||||
|
||||
foreach ($values as $key => $value) {
|
||||
$resolved[$this->resolveKey($key)] = $value;
|
||||
}
|
||||
|
||||
return $this->store()->putMany($resolved, $ttl);
|
||||
}
|
||||
|
||||
// === 무효화 ===
|
||||
|
||||
/**
|
||||
* 이 드라이버 소속 전체 캐시를 삭제합니다.
|
||||
*
|
||||
* 태그 지원 드라이버에서는 소속 태그로 삭제하고,
|
||||
* 미지원 드라이버에서는 키 인덱스를 기반으로 삭제합니다.
|
||||
*
|
||||
* @return bool 삭제 성공 여부
|
||||
*/
|
||||
public function flush(): bool
|
||||
{
|
||||
if ($this->supportsTags()) {
|
||||
return $this->flushTags([$this->getPrefix()]);
|
||||
}
|
||||
|
||||
return $this->flushByIndex();
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 태그의 캐시를 삭제합니다.
|
||||
*
|
||||
* @param array $tags 삭제할 태그 배열
|
||||
* @return bool 삭제 성공 여부
|
||||
*/
|
||||
public function flushTags(array $tags): bool
|
||||
{
|
||||
// 항상 키 인덱스 기반으로 태그 삭제 (get/has와의 일관성 보장)
|
||||
return $this->flushTagsByIndex($tags);
|
||||
}
|
||||
|
||||
// === 리프레시 ===
|
||||
|
||||
/**
|
||||
* 캐시를 무효화하고 새로고침합니다.
|
||||
*
|
||||
* @param string $key 캐시 키
|
||||
* @param callable $callback 새 값을 생성할 콜백
|
||||
* @param int|null $ttl TTL (초), null이면 기본값 사용
|
||||
* @param array $tags 캐시 태그 (무효화용)
|
||||
* @return mixed 새로 캐시된 값
|
||||
*/
|
||||
public function refresh(string $key, callable $callback, ?int $ttl = null, array $tags = []): mixed
|
||||
{
|
||||
$this->forget($key);
|
||||
|
||||
return $this->remember($key, $callback, $ttl, $tags);
|
||||
}
|
||||
|
||||
// === 키 인덱스 (태그 미지원 드라이버용) ===
|
||||
|
||||
/**
|
||||
* 키-태그 매핑을 인덱스에 기록합니다.
|
||||
*
|
||||
* file/database 드라이버처럼 태그를 지원하지 않는 경우,
|
||||
* flush()와 flushTags()를 위해 키-태그 매핑을 별도 캐시 키에 기록합니다.
|
||||
*
|
||||
* @param string $resolvedKey 접두사 적용된 전체 키
|
||||
* @param array $tags 태그 배열
|
||||
* @return void
|
||||
*/
|
||||
private function recordKeyTags(string $resolvedKey, array $tags): void
|
||||
{
|
||||
$indexKey = $this->getIndexKey();
|
||||
$index = $this->store()->get($indexKey, []);
|
||||
$index[$resolvedKey] = array_values(array_unique(
|
||||
array_merge($index[$resolvedKey] ?? [], $tags)
|
||||
));
|
||||
$this->store()->put($indexKey, $index, 86400 * 30); // 30일
|
||||
}
|
||||
|
||||
/**
|
||||
* 키 인덱스 기반으로 전체 캐시를 삭제합니다.
|
||||
*
|
||||
* @return bool 삭제 성공 여부
|
||||
*/
|
||||
private function flushByIndex(): bool
|
||||
{
|
||||
$indexKey = $this->getIndexKey();
|
||||
$index = $this->store()->get($indexKey, []);
|
||||
|
||||
foreach (array_keys($index) as $key) {
|
||||
$this->store()->forget($key);
|
||||
}
|
||||
|
||||
$this->store()->forget($indexKey);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 키 인덱스 기반으로 특정 태그의 캐시를 삭제합니다.
|
||||
*
|
||||
* @param array $tags 삭제할 태그 배열
|
||||
* @return bool 삭제 성공 여부
|
||||
*/
|
||||
private function flushTagsByIndex(array $tags): bool
|
||||
{
|
||||
$indexKey = $this->getIndexKey();
|
||||
$index = $this->store()->get($indexKey, []);
|
||||
$tagsSet = array_flip($tags);
|
||||
|
||||
foreach ($index as $key => $keyTags) {
|
||||
if (array_intersect_key(array_flip($keyTags), $tagsSet)) {
|
||||
$this->store()->forget($key);
|
||||
unset($index[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->store()->put($indexKey, $index, 86400 * 30);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 키 인덱스의 캐시 키를 반환합니다.
|
||||
*
|
||||
* @return string 인덱스 캐시 키
|
||||
*/
|
||||
private function getIndexKey(): string
|
||||
{
|
||||
return 'g7:_idx:' . $this->getPrefix();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Cache;
|
||||
|
||||
/**
|
||||
* 코어 캐시 드라이버
|
||||
*
|
||||
* 코어 서비스(LayoutService, SeoCacheManager, SettingsService 등)에서 사용하는
|
||||
* 캐시를 관리합니다. 접두사 패턴: g7:core:{key}
|
||||
*
|
||||
* @since engine-v1.18.0
|
||||
*/
|
||||
class CoreCacheDriver extends AbstractCacheDriver
|
||||
{
|
||||
/**
|
||||
* CoreCacheDriver 생성자
|
||||
*
|
||||
* @param string $store 캐시 스토어 이름 (빈 문자열이면 기본 스토어)
|
||||
*/
|
||||
public function __construct(string $store = '')
|
||||
{
|
||||
$this->store = $store ?: config('cache.default');
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 캐시 접두사를 반환합니다.
|
||||
*
|
||||
* @return string 접두사
|
||||
*/
|
||||
protected function getPrefix(): string
|
||||
{
|
||||
return 'g7:core';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Cache;
|
||||
|
||||
/**
|
||||
* 모듈 캐시 드라이버
|
||||
*
|
||||
* 각 모듈에서 사용하는 캐시를 관리합니다.
|
||||
* 접두사 패턴: g7:module.{identifier}:{key}
|
||||
*
|
||||
* @since engine-v1.18.0
|
||||
*/
|
||||
class ModuleCacheDriver extends AbstractCacheDriver
|
||||
{
|
||||
/**
|
||||
* 모듈 식별자
|
||||
*/
|
||||
private string $identifier;
|
||||
|
||||
/**
|
||||
* ModuleCacheDriver 생성자
|
||||
*
|
||||
* @param string $identifier 모듈 식별자 (예: sirsoft-ecommerce)
|
||||
* @param string $store 캐시 스토어 이름 (빈 문자열이면 기본 스토어)
|
||||
*/
|
||||
public function __construct(string $identifier, string $store = '')
|
||||
{
|
||||
$this->identifier = $identifier;
|
||||
$this->store = $store ?: config('cache.default');
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈 캐시 접두사를 반환합니다.
|
||||
*
|
||||
* @return string 접두사
|
||||
*/
|
||||
protected function getPrefix(): string
|
||||
{
|
||||
return 'g7:module.' . $this->identifier;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Cache;
|
||||
|
||||
/**
|
||||
* 플러그인 캐시 드라이버
|
||||
*
|
||||
* 각 플러그인에서 사용하는 캐시를 관리합니다.
|
||||
* 접두사 패턴: g7:plugin.{identifier}:{key}
|
||||
*
|
||||
* @since engine-v1.18.0
|
||||
*/
|
||||
class PluginCacheDriver extends AbstractCacheDriver
|
||||
{
|
||||
/**
|
||||
* 플러그인 식별자
|
||||
*/
|
||||
private string $identifier;
|
||||
|
||||
/**
|
||||
* PluginCacheDriver 생성자
|
||||
*
|
||||
* @param string $identifier 플러그인 식별자 (예: sirsoft-payment)
|
||||
* @param string $store 캐시 스토어 이름 (빈 문자열이면 기본 스토어)
|
||||
*/
|
||||
public function __construct(string $identifier, string $store = '')
|
||||
{
|
||||
$this->identifier = $identifier;
|
||||
$this->store = $store ?: config('cache.default');
|
||||
}
|
||||
|
||||
/**
|
||||
* 플러그인 캐시 접두사를 반환합니다.
|
||||
*
|
||||
* @return string 접두사
|
||||
*/
|
||||
protected function getPrefix(): string
|
||||
{
|
||||
return 'g7:plugin.' . $this->identifier;
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
namespace App\Extension;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Extension\Cache\CoreCacheDriver;
|
||||
use Composer\Semver\Semver;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
@@ -16,9 +17,9 @@ use Illuminate\Support\Facades\Log;
|
||||
class CoreVersionChecker
|
||||
{
|
||||
/**
|
||||
* 캐시 키 접두사
|
||||
* 캐시 키 접두사 (드라이버 접두사 `g7:core:` 다음에 붙음)
|
||||
*/
|
||||
private const CACHE_PREFIX = 'core_version_check.';
|
||||
private const CACHE_PREFIX = 'ext.version_check.';
|
||||
|
||||
/**
|
||||
* 캐시 유효 시간 (초)
|
||||
@@ -28,11 +29,35 @@ class CoreVersionChecker
|
||||
/**
|
||||
* 현재 설치된 그누보드7 코어 버전 반환
|
||||
*
|
||||
* @return string 코어 버전
|
||||
* 반환 우선순위:
|
||||
* 1. 환경변수 `APP_VERSION` (getenv / $_ENV / $_SERVER)
|
||||
* 2. `config('app.version')`
|
||||
*
|
||||
* 왜 env 를 우선 읽는가:
|
||||
* 코어 업그레이드 중 `core:update` 는 `core:execute-upgrade-steps` / 각 업그레이드 스텝의
|
||||
* inline 스크립트를 `proc_open` 으로 spawn 한다. 디스크 `.env` 의 `APP_VERSION` 은
|
||||
* `updateVersionInEnv()` 가 최종 단계(Step 11)에서 기록하므로, spawn 이 부팅되는
|
||||
* Step 10 시점에는 여전히 이전 버전이 남아있다. 이를 회피하기 위해 spawn 호출부는
|
||||
* `APP_VERSION={toVersion}` 를 `proc_open` 의 `$env` 로 주입하지만, `bootstrap/cache/config.php`
|
||||
* 가 생성되어 있으면 `LoadConfiguration` 부트스트랩이 **캐시된 리터럴** 을 사용해
|
||||
* env 오버라이드가 반영되지 않는 회귀가 있었다 (확장이 `>= 신버전` 요구 시
|
||||
* `validateAndDeactivateIncompatibleExtensions` 가 전 확장을 자동 비활성화).
|
||||
*
|
||||
* env 를 우선 읽는 본 구현은 config cache 유무와 무관하게 spawn 이 전달한 버전을 그대로
|
||||
* 신뢰한다. 일반 요청 경로에서는 `APP_VERSION` 이 `.env` 에 기록된 값 그대로이므로
|
||||
* 동작에 차이가 없다.
|
||||
*
|
||||
* 규정 예외: "env() 는 config 파일에서만 사용" 규칙의 본문 예외. 정당성은 버전 판정이
|
||||
* config cache 우회를 요구하기 때문이다.
|
||||
*/
|
||||
public static function getCoreVersion(): string
|
||||
{
|
||||
return config('app.version', '7.0.0-alpha.1');
|
||||
$envVersion = $_ENV['APP_VERSION'] ?? $_SERVER['APP_VERSION'] ?? getenv('APP_VERSION');
|
||||
if (is_string($envVersion) && $envVersion !== '') {
|
||||
return $envVersion;
|
||||
}
|
||||
|
||||
return config('app.version');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,9 +142,27 @@ class CoreVersionChecker
|
||||
*/
|
||||
public static function clearCache(): void
|
||||
{
|
||||
Cache::forget(self::getCacheKey('modules'));
|
||||
Cache::forget(self::getCacheKey('plugins'));
|
||||
Cache::forget(self::getCacheKey('templates'));
|
||||
$cache = self::resolveCache();
|
||||
$cache->forget(self::getCacheKey('modules'));
|
||||
$cache->forget(self::getCacheKey('plugins'));
|
||||
$cache->forget(self::getCacheKey('templates'));
|
||||
}
|
||||
|
||||
/**
|
||||
* CacheInterface 인스턴스를 컨테이너에서 lazy 조회합니다.
|
||||
*
|
||||
* 컨테이너 미구성 환경(예: 일부 단위 테스트)에서도 동작하도록
|
||||
* fallback 으로 직접 CoreCacheDriver 를 생성합니다.
|
||||
*
|
||||
* @return CacheInterface
|
||||
*/
|
||||
private static function resolveCache(): CacheInterface
|
||||
{
|
||||
try {
|
||||
return app(CacheInterface::class);
|
||||
} catch (\Throwable $e) {
|
||||
return new CoreCacheDriver(config('cache.default', 'array'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,6 +172,6 @@ class CoreVersionChecker
|
||||
*/
|
||||
public static function getCacheTtl(): int
|
||||
{
|
||||
return self::CACHE_TTL;
|
||||
return (int) g7_core_settings('cache.version_check_ttl', self::CACHE_TTL);
|
||||
}
|
||||
}
|
||||
|
||||
+207
-110
@@ -4,6 +4,7 @@ namespace App\Extension;
|
||||
|
||||
use App\Contracts\Repositories\ModuleRepositoryInterface;
|
||||
use App\Contracts\Repositories\PluginRepositoryInterface;
|
||||
use App\Extension\Helpers\GithubHelper;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -53,6 +54,47 @@ class ExtensionManager
|
||||
}
|
||||
|
||||
$this->generateAutoloadFile();
|
||||
|
||||
// 현재 프로세스의 Composer ClassLoader 에도 갱신된 PSR-4 를 즉시 반영.
|
||||
// 파일 쓰기만으로는 다음 요청 부트스트랩 시점부터 적용되므로, 업데이트 실행
|
||||
// 흐름(copyToActive → updateComposerAutoload → runUpgradeSteps) 내에서
|
||||
// 신규 네임스페이스(beta 업그레이드로 추가된 Seeder/Model 등) 의 autoload 가
|
||||
// 실패하지 않도록 런타임 재등록을 수행한다.
|
||||
$this->reregisterRuntimeAutoload();
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 프로세스의 Composer ClassLoader 에 갱신된 PSR-4 매핑을 재등록합니다.
|
||||
*
|
||||
* `generateAutoloadFile()` 은 autoload-extensions.php 를 디스크에 다시 쓰지만,
|
||||
* 이 파일은 CoreServiceProvider::register() / public/index.php 진입점에서만
|
||||
* 로드되므로, 동일 프로세스 내부에서 PSR-4 네임스페이스가 추가·변경된 경우
|
||||
* 다음 부트스트랩 이전까지 신규 매핑이 반영되지 않는다.
|
||||
*
|
||||
* 본 메서드는 `Composer\Autoload\ClassLoader::getRegisteredLoaders()` 로 현재
|
||||
* 프로세스에 등록된 ClassLoader 를 조회하여 `addPsr4()` 를 다시 호출해 신규
|
||||
* 매핑을 즉시 유효화한다. 기존 매핑에 경로가 추가되거나 새 네임스페이스가
|
||||
* 등록되며, 동일 매핑은 중복 없이 merge 된다.
|
||||
*/
|
||||
protected function reregisterRuntimeAutoload(): void
|
||||
{
|
||||
if (! class_exists(\Composer\Autoload\ClassLoader::class, false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! file_exists($this->autoloadFilePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$loaders = \Composer\Autoload\ClassLoader::getRegisteredLoaders();
|
||||
if (empty($loaders)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($loaders as $loader) {
|
||||
self::registerExtensionAutoload($loader);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -925,86 +967,12 @@ PHP;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// GitHub 다운로드 유틸리티 (코어와 동일한 패턴)
|
||||
// GitHub 다운로드 유틸리티 (GithubHelper로 위임)
|
||||
//
|
||||
// `allow_url_fopen=Off` 공유 호스팅 대응을 위해 실제 HTTP 호출은
|
||||
// `GithubHelper`의 Http 파사드 기반 구현을 사용합니다.
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* GitHub API용 인증 헤더를 생성합니다.
|
||||
*
|
||||
* @param string $token GitHub Personal Access Token (선택)
|
||||
* @return array HTTP 헤더 배열
|
||||
*/
|
||||
public function buildGithubHeaders(string $token = ''): array
|
||||
{
|
||||
$headers = [
|
||||
'User-Agent: G7',
|
||||
'Accept: application/vnd.github.v3+json',
|
||||
];
|
||||
|
||||
if (! empty($token)) {
|
||||
$headers[] = 'Authorization: Bearer '.$token;
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub 아카이브 URL을 해석합니다.
|
||||
*
|
||||
* v접두사 유무 모두 시도하여 유효한 아카이브 URL을 반환합니다.
|
||||
*
|
||||
* @param string $owner GitHub 저장소 소유자
|
||||
* @param string $repo GitHub 저장소 이름
|
||||
* @param string $version 버전 태그
|
||||
* @param string $archiveType 아카이브 타입 (zipball/tarball)
|
||||
* @param array $authHeaders 인증 헤더
|
||||
* @return string|null 유효한 URL 또는 null
|
||||
*/
|
||||
public function resolveGithubArchiveUrl(string $owner, string $repo, string $version, string $archiveType, array $authHeaders): ?string
|
||||
{
|
||||
$tagVariants = ["v{$version}", $version];
|
||||
|
||||
foreach ($tagVariants as $tag) {
|
||||
$testUrl = "https://api.github.com/repos/{$owner}/{$repo}/{$archiveType}/{$tag}";
|
||||
$testContext = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'HEAD',
|
||||
'header' => $authHeaders,
|
||||
'timeout' => 10,
|
||||
'ignore_errors' => true,
|
||||
],
|
||||
]);
|
||||
@file_get_contents($testUrl, false, $testContext);
|
||||
$statusCode = $this->extractHttpStatusCode($http_response_header ?? []);
|
||||
if ($statusCode === 200 || $statusCode === 302) {
|
||||
return $testUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 응답 헤더에서 상태 코드를 추출합니다.
|
||||
*
|
||||
* 리다이렉트 시 마지막 상태 코드를 반환합니다.
|
||||
*
|
||||
* @param array $responseHeaders $http_response_header 배열
|
||||
* @return int HTTP 상태 코드
|
||||
*/
|
||||
public function extractHttpStatusCode(array $responseHeaders): int
|
||||
{
|
||||
$statusCode = 0;
|
||||
|
||||
foreach ($responseHeaders as $header) {
|
||||
if (preg_match('#^HTTP/\S+\s+(\d{3})#', $header, $matches)) {
|
||||
$statusCode = (int) $matches[1];
|
||||
}
|
||||
}
|
||||
|
||||
return $statusCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용 가능한 아카이브 추출 전략을 구성합니다.
|
||||
*
|
||||
@@ -1091,40 +1059,15 @@ PHP;
|
||||
return $exitCode === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub 아카이브를 다운로드합니다.
|
||||
*
|
||||
* @param string $url 다운로드 URL
|
||||
* @param array $authHeaders 인증 헤더
|
||||
* @return string 다운로드된 콘텐츠
|
||||
*
|
||||
* @throws \RuntimeException 다운로드 실패 시
|
||||
*/
|
||||
public function downloadArchive(string $url, array $authHeaders): string
|
||||
{
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'header' => $authHeaders,
|
||||
'follow_location' => true,
|
||||
'timeout' => 120,
|
||||
],
|
||||
]);
|
||||
|
||||
$content = @file_get_contents($url, false, $context);
|
||||
if ($content === false) {
|
||||
throw new \RuntimeException(__('settings.core_update.download_failed', ['version' => basename($url)]));
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub에서 확장을 다운로드하고 추출합니다.
|
||||
*
|
||||
* 코어 업데이트의 downloadUpdate()와 동일한 폴백 체인을 사용합니다.
|
||||
* (zipball/ZipArchive → zipball/unzip)
|
||||
*
|
||||
* 모든 HTTP 호출은 `GithubHelper` (Http 파사드 기반)로 위임되어
|
||||
* `allow_url_fopen=Off` 환경에서도 정상 동작합니다.
|
||||
*
|
||||
* @param string $owner GitHub 저장소 소유자
|
||||
* @param string $repo GitHub 저장소 이름
|
||||
* @param string $version 버전 태그
|
||||
@@ -1136,7 +1079,6 @@ PHP;
|
||||
*/
|
||||
public function downloadAndExtractFromGitHub(string $owner, string $repo, string $version, string $destDir, string $token = ''): string
|
||||
{
|
||||
$authHeaders = $this->buildGithubHeaders($token);
|
||||
$extractDir = $destDir.DIRECTORY_SEPARATOR.'extracted';
|
||||
|
||||
$strategies = $this->buildExtractionStrategies();
|
||||
@@ -1151,7 +1093,7 @@ PHP;
|
||||
$extractMethod = $strategy['method'];
|
||||
$label = $strategy['label'];
|
||||
|
||||
$archiveUrl = $this->resolveGithubArchiveUrl($owner, $repo, $version, $archiveType, $authHeaders);
|
||||
$archiveUrl = GithubHelper::resolveArchiveUrl($owner, $repo, $version, $archiveType, $token);
|
||||
if (! $archiveUrl) {
|
||||
continue;
|
||||
}
|
||||
@@ -1160,8 +1102,7 @@ PHP;
|
||||
$archivePath = $destDir.DIRECTORY_SEPARATOR.'download'.$extension;
|
||||
|
||||
try {
|
||||
$content = $this->downloadArchive($archiveUrl, $authHeaders);
|
||||
File::put($archivePath, $content);
|
||||
GithubHelper::downloadArchive($archiveUrl, $archivePath, $token);
|
||||
|
||||
if (File::isDirectory($extractDir)) {
|
||||
File::deleteDirectory($extractDir);
|
||||
@@ -1206,4 +1147,160 @@ PHP;
|
||||
$lastError
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 ZIP 파일을 추출하여 소스 디렉토리 경로를 반환합니다.
|
||||
*
|
||||
* GitHub zipball 처럼 owner-repo-hash/ 래퍼 디렉토리로 감싼 경우와
|
||||
* ZIP 루트가 곧바로 확장 소스인 경우를 모두 지원합니다.
|
||||
* 추출 후 extractDir 내용이 단일 디렉토리뿐이면 그 디렉토리를 반환하고,
|
||||
* 그 외에는 extractDir 자체를 반환합니다.
|
||||
*
|
||||
* downloadAndExtractFromGitHub 와 동일한 폴백 체인(ZipArchive → unzip)을
|
||||
* 사용하며, GitHub 호출은 수행하지 않습니다.
|
||||
*
|
||||
* @param string $zipPath 외부 ZIP 파일 경로
|
||||
* @param string $destDir 추출 작업 디렉토리 (함수가 'extracted' 하위에 추출)
|
||||
* @return string 확장 소스 디렉토리 경로 (래퍼 감지 후)
|
||||
*
|
||||
* @throws \RuntimeException 추출 실패 또는 지원 추출 수단 부재 시
|
||||
*/
|
||||
public function extractFromZip(string $zipPath, string $destDir): string
|
||||
{
|
||||
if (! File::exists($zipPath)) {
|
||||
throw new \RuntimeException(__('settings.core_update.zip_file_not_found', ['path' => $zipPath]));
|
||||
}
|
||||
|
||||
$strategies = $this->buildExtractionStrategies();
|
||||
if (empty($strategies)) {
|
||||
throw new \RuntimeException(__('settings.core_update.no_extract_method_available'));
|
||||
}
|
||||
|
||||
$extractDir = $destDir.DIRECTORY_SEPARATOR.'extracted';
|
||||
if (File::isDirectory($extractDir)) {
|
||||
File::deleteDirectory($extractDir);
|
||||
}
|
||||
File::ensureDirectoryExists($extractDir);
|
||||
|
||||
$lastError = null;
|
||||
foreach ($strategies as $strategy) {
|
||||
$method = $strategy['method'];
|
||||
$label = $strategy['label'];
|
||||
|
||||
try {
|
||||
$this->$method($zipPath, $extractDir);
|
||||
|
||||
return $this->resolveExtractedRoot($extractDir);
|
||||
} catch (\Throwable $e) {
|
||||
$lastError = $e;
|
||||
|
||||
// 다음 전략 시도 전 extractDir 초기화
|
||||
if (File::isDirectory($extractDir)) {
|
||||
File::deleteDirectory($extractDir);
|
||||
}
|
||||
File::ensureDirectoryExists($extractDir);
|
||||
|
||||
Log::warning("ZIP 추출 폴백: {$label} 실패", [
|
||||
'zip' => $zipPath,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
throw new \RuntimeException(
|
||||
__('settings.core_update.all_extract_methods_failed'),
|
||||
0,
|
||||
$lastError
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 추출 디렉토리에서 확장 소스 루트를 판별합니다.
|
||||
*
|
||||
* - 하위에 파일이 있고 디렉토리가 0개 또는 2개 이상 → extractDir 자체가 소스
|
||||
* - 하위에 디렉토리 1개만 있고 파일 없음 → 그 디렉토리가 래퍼 → 하위 반환
|
||||
* - 혼합(파일 + 단일 디렉토리)인 경우 → extractDir 자체 반환 (manifest 루트에 있다고 가정)
|
||||
*
|
||||
* @param string $extractDir 추출 대상 디렉토리
|
||||
* @return string 확장 소스 디렉토리 경로
|
||||
*/
|
||||
protected function resolveExtractedRoot(string $extractDir): string
|
||||
{
|
||||
$dirs = File::directories($extractDir);
|
||||
$files = File::files($extractDir);
|
||||
|
||||
if (count($dirs) === 1 && count($files) === 0) {
|
||||
return $dirs[0];
|
||||
}
|
||||
|
||||
return $extractDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 ZIP 소스를 스테이징 전단계까지 준비합니다.
|
||||
*
|
||||
* 각 확장 Manager(Module/Plugin/Template)의 --zip 업데이트 경로에서
|
||||
* 공용으로 사용하는 헬퍼입니다. ZIP 을 임시 디렉토리에 추출하고 manifest 를
|
||||
* 검증(파일 존재, identifier 일치, version 존재)한 뒤 결과를 반환합니다.
|
||||
*
|
||||
* 호출자는 반환된 temp_dir 을 반드시 정리해야 합니다 (try-finally 로 감싸는 것을 권장).
|
||||
*
|
||||
* @param string $zipPath 외부 ZIP 파일 경로
|
||||
* @param string $identifier 기대하는 확장 식별자 (manifest 와 일치해야 함)
|
||||
* @param string $manifestName manifest 파일명 ('module.json' | 'plugin.json' | 'template.json')
|
||||
* @return array{temp_dir: string, extracted_dir: string, to_version: string, manifest: array}
|
||||
*
|
||||
* @throws \RuntimeException ZIP 추출 실패 / manifest 누락 / identifier 불일치 / version 누락 시
|
||||
*/
|
||||
public function prepareZipSource(string $zipPath, string $identifier, string $manifestName): array
|
||||
{
|
||||
$tempDir = storage_path('app/temp/ext_zip_'.uniqid());
|
||||
File::ensureDirectoryExists($tempDir);
|
||||
|
||||
try {
|
||||
$extractedDir = $this->extractFromZip($zipPath, $tempDir);
|
||||
|
||||
$manifestPath = $extractedDir.DIRECTORY_SEPARATOR.$manifestName;
|
||||
if (! File::exists($manifestPath)) {
|
||||
throw new \RuntimeException(__('extensions.errors.zip_missing_manifest', [
|
||||
'file' => $manifestName,
|
||||
'zip' => $zipPath,
|
||||
]));
|
||||
}
|
||||
|
||||
$manifest = json_decode(File::get($manifestPath), true);
|
||||
if (! is_array($manifest)) {
|
||||
throw new \RuntimeException(__('extensions.errors.zip_invalid_manifest', [
|
||||
'file' => $manifestName,
|
||||
]));
|
||||
}
|
||||
|
||||
$manifestId = $manifest['identifier'] ?? null;
|
||||
if ($manifestId !== $identifier) {
|
||||
throw new \RuntimeException(__('extensions.errors.zip_identifier_mismatch', [
|
||||
'expected' => $identifier,
|
||||
'actual' => $manifestId ?? '(missing)',
|
||||
]));
|
||||
}
|
||||
|
||||
$version = $manifest['version'] ?? null;
|
||||
if (! is_string($version) || $version === '') {
|
||||
throw new \RuntimeException(__('extensions.errors.zip_missing_version', [
|
||||
'file' => $manifestName,
|
||||
]));
|
||||
}
|
||||
|
||||
return [
|
||||
'temp_dir' => $tempDir,
|
||||
'extracted_dir' => $extractedDir,
|
||||
'to_version' => $version,
|
||||
'manifest' => $manifest,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
if (File::isDirectory($tempDir)) {
|
||||
File::deleteDirectory($tempDir);
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Helpers;
|
||||
|
||||
use App\Contracts\Repositories\ModuleRepositoryInterface;
|
||||
use App\Contracts\Repositories\PluginRepositoryInterface;
|
||||
use Composer\Semver\Semver;
|
||||
use Illuminate\Support\Facades\App;
|
||||
|
||||
/**
|
||||
* 확장 의존성 정보 보강 헬퍼
|
||||
*
|
||||
* manifest 의 dependencies 필드(`{modules|plugins}.{id}: versionConstraint`) 를 받아
|
||||
* 각 의존 확장의 설치 버전, 활성 여부, 버전 제약 충족 여부를 포함한 상세 배열로 변환합니다.
|
||||
* ModuleManager / PluginManager / TemplateResource 가 공용으로 사용합니다.
|
||||
*/
|
||||
class DependencyEnricher
|
||||
{
|
||||
/**
|
||||
* 의존성 배열을 상세 정보로 보강합니다.
|
||||
*
|
||||
* @param array $dependencies manifest 의 dependencies 배열 (`['modules' => [...], 'plugins' => [...]]`)
|
||||
* @return array<int, array<string, mixed>> 보강된 의존성 목록 (type 필드로 module/plugin 구분)
|
||||
*/
|
||||
public static function enrich(array $dependencies): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
foreach (self::enrichByType($dependencies, 'modules') as $item) {
|
||||
$result[] = $item;
|
||||
}
|
||||
|
||||
foreach (self::enrichByType($dependencies, 'plugins') as $item) {
|
||||
$result[] = $item;
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 타입(modules/plugins)의 의존성만 보강합니다.
|
||||
*
|
||||
* @param array $dependencies manifest 의 dependencies 배열
|
||||
* @param string $type 'modules' 또는 'plugins'
|
||||
* @return array<int, array<string, mixed>> 보강된 의존성 목록
|
||||
*/
|
||||
protected static function enrichByType(array $dependencies, string $type): array
|
||||
{
|
||||
if (! isset($dependencies[$type]) || ! is_array($dependencies[$type])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$singular = $type === 'modules' ? 'module' : 'plugin';
|
||||
$repository = $type === 'modules'
|
||||
? app(ModuleRepositoryInterface::class)
|
||||
: app(PluginRepositoryInterface::class);
|
||||
|
||||
$result = [];
|
||||
|
||||
foreach ($dependencies[$type] as $identifier => $versionConstraint) {
|
||||
$record = $repository->findByIdentifier($identifier);
|
||||
$activeRecord = $repository->findActiveByIdentifier($identifier);
|
||||
|
||||
$installedVersion = $record?->version;
|
||||
$isActive = (bool) $activeRecord;
|
||||
$isMet = $isActive
|
||||
&& $installedVersion
|
||||
&& self::checkVersionConstraint($installedVersion, (string) $versionConstraint);
|
||||
|
||||
$result[] = [
|
||||
'identifier' => $identifier,
|
||||
'name' => self::resolveName($record, $identifier),
|
||||
'type' => $singular,
|
||||
'required_version' => $versionConstraint,
|
||||
'installed_version' => $installedVersion,
|
||||
'is_active' => $isActive,
|
||||
'is_met' => $isMet,
|
||||
];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Semver 제약 조건을 검증합니다.
|
||||
*
|
||||
* @param string $installedVersion 설치된 버전
|
||||
* @param string $versionConstraint 요구 버전 제약
|
||||
* @return bool 제약 충족 여부
|
||||
*/
|
||||
public static function checkVersionConstraint(string $installedVersion, string $versionConstraint): bool
|
||||
{
|
||||
try {
|
||||
return Semver::satisfies($installedVersion, $versionConstraint);
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 모델에서 현재 로케일의 이름을 추출합니다.
|
||||
*
|
||||
* @param object|null $record 확장 모델 (Module/Plugin)
|
||||
* @param string $fallback 모델이 없을 때 반환할 식별자
|
||||
* @return string 이름
|
||||
*/
|
||||
protected static function resolveName(?object $record, string $fallback): string
|
||||
{
|
||||
if (! $record) {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
$name = $record->name ?? null;
|
||||
|
||||
if (is_array($name)) {
|
||||
$locale = App::getLocale();
|
||||
$fallbackLocale = config('app.fallback_locale', 'ko');
|
||||
|
||||
return (string) ($name[$locale] ?? $name[$fallbackLocale] ?? reset($name) ?: $fallback);
|
||||
}
|
||||
|
||||
if (is_string($name) && str_starts_with($name, '{')) {
|
||||
$decoded = json_decode($name, true);
|
||||
if (is_array($decoded)) {
|
||||
$locale = App::getLocale();
|
||||
$fallbackLocale = config('app.fallback_locale', 'ko');
|
||||
|
||||
return (string) ($decoded[$locale] ?? $decoded[$fallbackLocale] ?? reset($decoded) ?: $fallback);
|
||||
}
|
||||
}
|
||||
|
||||
return (string) ($name ?? $fallback);
|
||||
}
|
||||
}
|
||||
@@ -205,9 +205,17 @@ class ExtensionMenuSyncHelper
|
||||
/**
|
||||
* 현재 확장에 속하지 않는 stale 메뉴를 정리합니다.
|
||||
*
|
||||
* ⚠️ 주의: 이 메서드는 정적 정의(getAdminMenus()) 기반으로만 판단하므로,
|
||||
* 확장이 런타임에 동적으로 생성한 메뉴도 삭제됩니다.
|
||||
* 자동 호출은 폐기되었으며, 필요 시 UpgradeStep에서 명시적으로 호출하세요.
|
||||
* 정책:
|
||||
* - config/확장 정의에 없는 메뉴는 **user_overrides 유무 무관 삭제**
|
||||
* - 필드 단위 보존은 **upsert 시점(`syncMenu()`)** 에서만 작동 (유지 row 에 한해 override 필드만 보존)
|
||||
* - row 자체의 존재 여부는 config 기준으로만 결정 (사용자 수정 이력이 있어도 정의에서 제거되면 삭제)
|
||||
*
|
||||
* 자식 메뉴는 부모 삭제 시 함께 정리 (orphan 회피).
|
||||
*
|
||||
* 자동 호출 경로:
|
||||
* - `CoreUpdateService::syncCoreMenus()` 말미 (완전 동기화 원칙)
|
||||
* - `ModuleManager::updateModule()` 말미 (확장 동기화)
|
||||
* - UpgradeStep 에서 명시 호출
|
||||
*
|
||||
* @param ExtensionOwnerType $extensionType 확장 타입
|
||||
* @param string $extensionIdentifier 확장 식별자
|
||||
@@ -225,20 +233,22 @@ class ExtensionMenuSyncHelper
|
||||
|
||||
$deleted = 0;
|
||||
foreach ($existingMenus as $menu) {
|
||||
if (! in_array($menu->slug, $currentSlugs, true)) {
|
||||
// role_menus 피벗 정리
|
||||
$menu->roles()->detach();
|
||||
if (in_array($menu->slug, $currentSlugs, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 자식 메뉴 먼저 삭제
|
||||
foreach ($menu->children as $child) {
|
||||
$child->roles()->detach();
|
||||
$this->menuRepository->delete($child);
|
||||
$deleted++;
|
||||
}
|
||||
// role_menus 피벗 정리
|
||||
$menu->roles()->detach();
|
||||
|
||||
$this->menuRepository->delete($menu);
|
||||
// 자식 메뉴 먼저 삭제 (orphan 회피)
|
||||
foreach ($menu->children as $child) {
|
||||
$child->roles()->detach();
|
||||
$this->menuRepository->delete($child);
|
||||
$deleted++;
|
||||
}
|
||||
|
||||
$this->menuRepository->delete($menu);
|
||||
$deleted++;
|
||||
}
|
||||
|
||||
if ($deleted > 0) {
|
||||
|
||||
@@ -57,6 +57,17 @@ class ExtensionPendingHelper
|
||||
$dirName = basename($dir);
|
||||
$identifier = $data['identifier'] ?? $dirName;
|
||||
|
||||
// 디렉토리명과 identifier 가 일치하는 경우만 등록.
|
||||
// _pending / _bundled 에는 업데이트·백업 과정의 임시 디렉토리
|
||||
// (예: sirsoft-admin_basic_20260402_081819, sirsoft-admin_basic_updating_<uniq>,
|
||||
// sirsoft-admin_basic_old_<uniq>) 가 남을 수 있고, 그 내부에 원본 manifest 가 그대로
|
||||
// 있어 identifier 가 원본과 동일해진다. 이 경우 표준 경로({basePath}/{subDir}/{identifier})
|
||||
// 와 실제 디렉토리 경로가 어긋나 install 이 실패하므로, 엄격히 일치하는 경로만
|
||||
// 정식 확장 소스로 인정한다.
|
||||
if ($dirName !== $identifier) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$result[$identifier] = array_merge($data, [
|
||||
'identifier' => $identifier,
|
||||
'directory' => $dirName,
|
||||
@@ -178,6 +189,19 @@ class ExtensionPendingHelper
|
||||
|
||||
// 교체 완료 후 _old 삭제 (실패해도 무해)
|
||||
File::deleteDirectory($oldPath);
|
||||
|
||||
// 원자적 rename 은 inode 단위로 교체되므로 PHP realpath/stat 캐시가
|
||||
// 이전 디렉토리의 파일 존재 여부를 기준으로 판단할 수 있다. 직후 Composer
|
||||
// PSR-4 autoload 가 신규 파일(beta.1 에 없던 Seeder/Model)을 file_exists 로
|
||||
// 탐색할 때 false 반환 → "Class not found" fatal 로 업그레이드 스텝이 실패.
|
||||
// clearstatcache(true) 로 전체 stat 캐시를 비워 신규 파일이 즉시 보이도록 한다.
|
||||
clearstatcache(true);
|
||||
|
||||
// opcache 가 활성화된 프로덕션에서는 활성 디렉토리 하위의 이전 컴파일 바이트코드가
|
||||
// 남아있을 수 있어 신규 PHP 파일을 즉시 invalidate (재컴파일 유도).
|
||||
if (function_exists('opcache_reset')) {
|
||||
@opcache_reset();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -127,9 +127,14 @@ class ExtensionRoleSyncHelper
|
||||
/**
|
||||
* 현재 확장에 속하지 않는 stale 권한을 정리합니다.
|
||||
*
|
||||
* ⚠️ 주의: 이 메서드는 정적 정의(getPermissions()) 기반으로만 판단하므로,
|
||||
* 확장이 런타임에 동적으로 생성한 권한도 삭제됩니다.
|
||||
* 자동 호출은 폐기되었으며, 필요 시 UpgradeStep에서 명시적으로 호출하세요.
|
||||
* Permission 은 설계상 **관리자가 직접 수정할 수 없는 테이블** 이므로 `user_overrides`
|
||||
* 컬럼 자체가 없습니다 (Menu/Role 과 달리). 따라서 단순 diff 로 삭제합니다.
|
||||
* 자식 권한도 동일하게 순수 orphan 삭제.
|
||||
*
|
||||
* 자동 호출 경로:
|
||||
* - `CoreUpdateService::syncCoreRolesAndPermissions()` 말미 (완전 동기화 원칙)
|
||||
* - `ModuleManager::updateModule()` 말미
|
||||
* - UpgradeStep 에서 명시 호출
|
||||
*
|
||||
* @param ExtensionOwnerType $extensionType 확장 타입
|
||||
* @param string $extensionIdentifier 확장 식별자
|
||||
@@ -147,18 +152,22 @@ class ExtensionRoleSyncHelper
|
||||
|
||||
$deleted = 0;
|
||||
foreach ($existingPermissions as $permission) {
|
||||
if (! in_array($permission->identifier, $currentIdentifiers, true)) {
|
||||
// 역할 연결 해제
|
||||
$permission->roles()->detach();
|
||||
// 자식 권한도 정리
|
||||
foreach ($permission->children as $child) {
|
||||
$child->roles()->detach();
|
||||
$this->permissionRepository->delete($child);
|
||||
$deleted++;
|
||||
}
|
||||
$this->permissionRepository->delete($permission);
|
||||
if (in_array($permission->identifier, $currentIdentifiers, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 역할 연결 해제
|
||||
$permission->roles()->detach();
|
||||
|
||||
// 자식 권한도 정리 (orphan 회피)
|
||||
foreach ($permission->children as $child) {
|
||||
$child->roles()->detach();
|
||||
$this->permissionRepository->delete($child);
|
||||
$deleted++;
|
||||
}
|
||||
|
||||
$this->permissionRepository->delete($permission);
|
||||
$deleted++;
|
||||
}
|
||||
|
||||
if ($deleted > 0) {
|
||||
@@ -172,6 +181,70 @@ class ExtensionRoleSyncHelper
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 확장에 속하지 않는 stale 역할을 정리합니다.
|
||||
*
|
||||
* 정책:
|
||||
* - config/확장 정의에 없는 역할은 **user_overrides 유무 무관 삭제**
|
||||
* - 필드 단위 보존은 upsert 시점(`syncRole()`) 에서만 작동
|
||||
* - 예외: `user_roles` 피벗에 참조 사용자가 있으면 **삭제 차단** + 경고 로그
|
||||
* (사용자를 다른 역할로 수동 재배정 후 재실행)
|
||||
*
|
||||
* @param ExtensionOwnerType $extensionType 확장 타입
|
||||
* @param string $extensionIdentifier 확장 식별자
|
||||
* @param array $currentIdentifiers 현재 유효한 역할 식별자 목록
|
||||
* @return int 삭제된 역할 수
|
||||
*/
|
||||
public function cleanupStaleRoles(
|
||||
ExtensionOwnerType $extensionType,
|
||||
string $extensionIdentifier,
|
||||
array $currentIdentifiers,
|
||||
): int {
|
||||
$existingRoles = $this->roleRepository->getByExtension($extensionType, $extensionIdentifier);
|
||||
|
||||
$deleted = 0;
|
||||
$blocked = 0;
|
||||
|
||||
foreach ($existingRoles as $role) {
|
||||
if (in_array($role->identifier, $currentIdentifiers, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// user_roles 피벗 참조 검사 — 하나라도 있으면 삭제 차단 (안전 가드)
|
||||
$userCount = $role->users()->count();
|
||||
if ($userCount > 0) {
|
||||
Log::warning('stale 역할 삭제 차단 — 참조 사용자 존재', [
|
||||
'identifier' => $role->identifier,
|
||||
'id' => $role->id,
|
||||
'user_count' => $userCount,
|
||||
'remediation' => '사용자를 다른 역할로 재배정 후 재실행 필요',
|
||||
]);
|
||||
$blocked++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 조건 통과 → 삭제 (role_permissions, role_menus 는 cascade 또는 모델 이벤트로 정리)
|
||||
$this->roleRepository->delete($role);
|
||||
Log::info('stale 역할 삭제', [
|
||||
'identifier' => $role->identifier,
|
||||
'id' => $role->id,
|
||||
]);
|
||||
$deleted++;
|
||||
}
|
||||
|
||||
if ($deleted > 0 || $blocked > 0) {
|
||||
Log::info('stale 역할 정리 결과', [
|
||||
'extension_type' => $extensionType->value,
|
||||
'extension_identifier' => $extensionIdentifier,
|
||||
'deleted' => $deleted,
|
||||
'blocked' => $blocked,
|
||||
]);
|
||||
}
|
||||
|
||||
return $deleted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장의 전체 역할-권한 할당을 일괄 동기화합니다.
|
||||
*
|
||||
|
||||
@@ -60,6 +60,24 @@ class FileHandleHelper
|
||||
return [];
|
||||
}
|
||||
|
||||
// 존재하지 않는 디렉토리는 잠금이 있을 수 없음 → WMI 전체 스캔 회피
|
||||
// (Get-Process 의 Modules 열거는 수백 프로세스에 접근 핸들을 열어 20초+ 소요 가능)
|
||||
if (! is_dir($directoryPath)) {
|
||||
$output('[lock-detect] 디렉토리가 존재하지 않으므로 잠금 감지를 건너뜁니다: '.$directoryPath);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// 파일이 전혀 없는 디렉토리도 파일 수준 잠금이 있을 수 없음
|
||||
// 디렉토리 핸들 자체를 잠그는 드문 케이스는 Restart Manager 로는 감지 불가하고
|
||||
// WMI 모듈 검색의 비용이 압도적으로 크므로 빠른 경로로 처리한다.
|
||||
$files = self::sampleFiles($directoryPath, $maxFiles);
|
||||
if (empty($files)) {
|
||||
$output('[lock-detect] 디렉토리가 비어 있으므로 잠금 감지를 건너뜁니다: '.$directoryPath);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$allProcesses = [];
|
||||
|
||||
// 1순위: WMI CommandLine + 프로세스 모듈 검색
|
||||
@@ -70,27 +88,18 @@ class FileHandleHelper
|
||||
}
|
||||
|
||||
// 2순위: Restart Manager API (파일 수준 잠금)
|
||||
$files = self::sampleFiles($directoryPath, $maxFiles);
|
||||
if (! empty($files)) {
|
||||
$output('[lock-detect] 2단계: Restart Manager API ('.count($files).'개 파일)...');
|
||||
$rmResults = self::detectViaRestartManager($files, $onOutput);
|
||||
foreach ($rmResults as $proc) {
|
||||
if (! isset($allProcesses[$proc['pid']])) {
|
||||
$allProcesses[$proc['pid']] = $proc;
|
||||
}
|
||||
$output('[lock-detect] 2단계: Restart Manager API ('.count($files).'개 파일)...');
|
||||
$rmResults = self::detectViaRestartManager($files, $onOutput);
|
||||
foreach ($rmResults as $proc) {
|
||||
if (! isset($allProcesses[$proc['pid']])) {
|
||||
$allProcesses[$proc['pid']] = $proc;
|
||||
}
|
||||
}
|
||||
|
||||
// 3순위: handle.exe 폴백
|
||||
if (empty($allProcesses)) {
|
||||
$output('[lock-detect] 3단계: handle.exe 폴백...');
|
||||
$handleResults = self::detectViaHandle($directoryPath, $onOutput);
|
||||
foreach ($handleResults as $proc) {
|
||||
if (! isset($allProcesses[$proc['pid']])) {
|
||||
$allProcesses[$proc['pid']] = $proc;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 빈 결과 = "잠금 없음" 이라는 정상 응답이므로 폴백을 호출하지 않는다.
|
||||
// (handle.exe 는 Sysinternals 외부 도구이고, Windows Store App Execution Alias 와
|
||||
// 충돌하는 경우 무한 대기하므로 명시적으로 사용 가능한 경우에만 호출.)
|
||||
|
||||
// 현재 PHP 프로세스 제외
|
||||
$currentPid = self::currentPid();
|
||||
@@ -165,12 +174,8 @@ class FileHandleHelper
|
||||
$processes = self::findLockingProcesses($directoryPath, 30, $onOutput);
|
||||
|
||||
if (empty($processes)) {
|
||||
$output('');
|
||||
$output('ℹ️ 잠금 프로세스가 감지되지 않았습니다.');
|
||||
$output(' 수동 해결: 해당 디렉토리를 참조하는 프로세스를 닫은 후 재시도하세요.');
|
||||
$output('');
|
||||
|
||||
return false;
|
||||
// 해제할 잠금이 없으므로 호출자는 안전하게 진행 가능
|
||||
return true;
|
||||
}
|
||||
|
||||
$output('');
|
||||
@@ -224,9 +229,14 @@ class FileHandleHelper
|
||||
/**
|
||||
* WMI로 해당 디렉토리 경로를 참조하는 프로세스를 탐지합니다.
|
||||
*
|
||||
* Get-CimInstance Win32_Process의 CommandLine에 디렉토리 경로가 포함된 프로세스와,
|
||||
* Get-Process의 Modules에서 해당 디렉토리 내 DLL을 로드한 프로세스를 찾습니다.
|
||||
* Node.js 워처, IDE, 파일 탐색기 등 프로세스 종류 무관하게 감지합니다.
|
||||
* Get-CimInstance Win32_Process의 CommandLine에 디렉토리 경로가 포함된 프로세스를 찾습니다.
|
||||
*
|
||||
* 과거 버전은 Get-Process 의 Modules 컬렉션(로드된 DLL) 까지 스캔했지만,
|
||||
* 이는 모든 프로세스에 접근 핸들을 열어 DLL 테이블을 열거하므로
|
||||
* 활성 프로세스가 수백 개인 Windows 환경에서 20초+ 블로킹을 유발합니다.
|
||||
* Modules 스캔은 Node.js native addon 등 드문 케이스만 감지하므로
|
||||
* 비용 대비 효과가 낮아 CommandLine 검색만 유지합니다.
|
||||
* (Node 워처, IDE, 파일 탐색기 등 실사용 케이스는 CommandLine 으로 충분히 감지됩니다.)
|
||||
*
|
||||
* @param string $directoryPath 디렉토리 경로
|
||||
* @param \Closure|null $onOutput 진단 출력 콜백
|
||||
@@ -247,25 +257,21 @@ class FileHandleHelper
|
||||
$escapedPath = str_replace("'", "''", $normalizedPath);
|
||||
|
||||
// 간결한 인라인 PowerShell — 임시 파일 불필요
|
||||
// CommandLine 검색만 수행 (Modules 스캔은 수백 프로세스 핸들 오픈으로 인해 제거됨)
|
||||
$psCommand = implode('; ', [
|
||||
'$ErrorActionPreference = "SilentlyContinue"',
|
||||
'$p = "'.addcslashes($escapedPath, '"').'"',
|
||||
'$pl = $p.ToLower()',
|
||||
'$r = @{}',
|
||||
// Strategy 1: CommandLine search
|
||||
'Get-CimInstance Win32_Process | ForEach-Object { if ($_.CommandLine) { $cl = $_.CommandLine.ToLower().Replace("/","\\"); if ($cl.Contains($pl)) { $r[$_.ProcessId] = "$($_.ProcessId)|$($_.Name)|$($_.CommandLine.Substring(0,[Math]::Min(120,$_.CommandLine.Length)))" } } }',
|
||||
// Strategy 2: Modules search
|
||||
'Get-Process | ForEach-Object { if (-not $r.ContainsKey($_.Id)) { try { foreach ($m in $_.Modules) { if ($m.FileName -and $m.FileName.ToLower().StartsWith($pl)) { $r[$_.Id] = "$($_.Id)|$($_.ProcessName)|module:$($m.FileName)"; break } } } catch {} } }',
|
||||
// Output
|
||||
'foreach ($v in $r.Values) { Write-Output $v }',
|
||||
]);
|
||||
|
||||
$cmdOutput = [];
|
||||
$exitCode = 0;
|
||||
exec(
|
||||
'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command '.escapeshellarg($psCommand).' 2>NUL',
|
||||
$cmdOutput,
|
||||
$exitCode
|
||||
// exec() 대신 proc_open 사용 — PHPUnit 환경에서 상속된 부모 파이프로 인한
|
||||
// PowerShell 하위 프로세스 블로킹을 회피한다.
|
||||
[$cmdOutput, $exitCode] = self::runCommandWithTimeout(
|
||||
'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command '.escapeshellarg($psCommand),
|
||||
30
|
||||
);
|
||||
|
||||
$output("[WMI] PowerShell exit code: {$exitCode}, 출력: ".count($cmdOutput).'줄');
|
||||
@@ -320,14 +326,16 @@ class FileHandleHelper
|
||||
$stderrPath = $scriptPath.'.err';
|
||||
|
||||
try {
|
||||
$cmdOutput = [];
|
||||
$exitCode = 0;
|
||||
exec(
|
||||
'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File '.escapeshellarg($scriptPath).' 2>'.escapeshellarg($stderrPath),
|
||||
$cmdOutput,
|
||||
$exitCode
|
||||
[$cmdOutput, $exitCode, $stderrCaptured] = self::runCommandWithTimeout(
|
||||
'powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File '.escapeshellarg($scriptPath),
|
||||
30,
|
||||
true
|
||||
);
|
||||
|
||||
if ($stderrCaptured !== '') {
|
||||
@file_put_contents($stderrPath, $stderrCaptured);
|
||||
}
|
||||
|
||||
$output("[RestartManager] exit code: {$exitCode}, 출력: ".count($cmdOutput).'줄');
|
||||
|
||||
if (file_exists($stderrPath)) {
|
||||
@@ -480,14 +488,25 @@ PSFOOTER;
|
||||
return [];
|
||||
}
|
||||
|
||||
$cmdOutput = [];
|
||||
$exitCode = 0;
|
||||
// PATH 에 handle.exe 가 있는지 사전 검사 (없는 경우 즉시 skip)
|
||||
// - handle.exe 미설치 시 cmd.exe 가 명령을 찾는 데 수십 초가 걸리거나
|
||||
// EULA 다이얼로그로 인해 무한 대기에 빠질 수 있음.
|
||||
// - 결과는 정적 캐시하여 같은 프로세스에서 반복 검사 회피.
|
||||
static $handleAvailable = null;
|
||||
if ($handleAvailable === null) {
|
||||
$handleAvailable = self::isCommandAvailable('handle.exe');
|
||||
}
|
||||
|
||||
if (! $handleAvailable) {
|
||||
$logOutput('[handle.exe] PATH 에서 찾을 수 없어 건너뜁니다.');
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$dirPath = str_replace('/', '\\', $directoryPath);
|
||||
exec(
|
||||
'handle.exe -nobanner -accepteula '.escapeshellarg($dirPath).' 2>NUL',
|
||||
$cmdOutput,
|
||||
$exitCode
|
||||
[$cmdOutput, $exitCode] = self::runCommandWithTimeout(
|
||||
'handle.exe -nobanner -accepteula '.escapeshellarg($dirPath),
|
||||
5
|
||||
);
|
||||
|
||||
if ($exitCode !== 0 || empty($cmdOutput)) {
|
||||
@@ -501,6 +520,43 @@ PSFOOTER;
|
||||
return self::parseHandleOutput($cmdOutput);
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows PATH 에서 명령어 실행 파일이 발견되는지 빠르게 확인합니다.
|
||||
*
|
||||
* Windows Store App Execution Alias(`%LOCALAPPDATA%\Microsoft\WindowsApps\*.exe`)는
|
||||
* 실제 실행 파일이 아닌 0바이트 stub 으로, 호출 시 Microsoft Store 앱을 실행하려
|
||||
* 시도하면서 무한 대기를 유발합니다. PATH 검색 결과에서 이런 경로를 제외합니다.
|
||||
*
|
||||
* @param string $command 명령어 파일명 (예: 'handle.exe')
|
||||
*/
|
||||
private static function isCommandAvailable(string $command): bool
|
||||
{
|
||||
if (! self::isWindows()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// `where` 는 PATH 검색이 빠르며, 미발견 시 1초 이내에 exit code 1 반환
|
||||
[$out, $code] = self::runCommandWithTimeout('where '.escapeshellarg($command), 3);
|
||||
|
||||
if ($code !== 0 || empty($out)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Windows Store App Execution Alias 경로 제외
|
||||
foreach ($out as $path) {
|
||||
$path = trim($path);
|
||||
if ($path === '' || stripos($path, '\\Microsoft\\WindowsApps\\') !== false) {
|
||||
continue;
|
||||
}
|
||||
// 0 바이트 파일은 stub 으로 간주
|
||||
if (@filesize($path) > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 파서/유틸리티
|
||||
// ========================================================================
|
||||
@@ -606,6 +662,116 @@ PSFOOTER;
|
||||
return $processes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 명령을 타임아웃과 함께 실행합니다 (부모 파이프 비상속).
|
||||
*
|
||||
* exec()/shell_exec() 는 내부적으로 `sh -c` 또는 `cmd.exe /c` 를 통해 자식
|
||||
* 프로세스를 스폰하며, 부모 PHP 프로세스의 stdin/stdout/stderr 핸들을 그대로
|
||||
* 상속시킵니다. PHPUnit 과 같이 stdout 을 자체 파이프로 모니터링하는 환경에서는
|
||||
* 자식 프로세스가 상속된 파이프에 블로킹 쓰기를 수행할 때 무한 대기가 발생합니다.
|
||||
*
|
||||
* proc_open 으로 자체 파이프를 명시적으로 열면 상속을 차단하여 이 문제를 회피합니다.
|
||||
* 또한 poll 루프에서 deadline 을 초과하면 강제 종료(TerminateProcess)합니다.
|
||||
*
|
||||
* @param string $command 실행할 명령 문자열
|
||||
* @param int $timeoutSeconds 최대 실행 시간(초)
|
||||
* @param bool $returnStderr true 면 세 번째 요소로 stderr 문자열을 함께 반환
|
||||
* @return array{0: string[], 1: int, 2?: string} [stdout 라인 배열, exit code, (stderr 문자열)]
|
||||
*/
|
||||
private static function runCommandWithTimeout(string $command, int $timeoutSeconds, bool $returnStderr = false): array
|
||||
{
|
||||
$descriptors = [
|
||||
0 => ['pipe', 'r'],
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
];
|
||||
|
||||
$pipes = [];
|
||||
$process = @proc_open($command, $descriptors, $pipes, null, null, [
|
||||
'bypass_shell' => false,
|
||||
'create_new_console' => false,
|
||||
]);
|
||||
|
||||
if (! is_resource($process)) {
|
||||
return $returnStderr ? [[], -1, ''] : [[], -1];
|
||||
}
|
||||
|
||||
// stdin 즉시 닫기 (하위 프로세스가 입력 대기 방지)
|
||||
fclose($pipes[0]);
|
||||
|
||||
stream_set_blocking($pipes[1], false);
|
||||
stream_set_blocking($pipes[2], false);
|
||||
|
||||
$stdout = '';
|
||||
$stderr = '';
|
||||
$deadline = microtime(true) + $timeoutSeconds;
|
||||
$timedOut = false;
|
||||
|
||||
while (true) {
|
||||
$status = proc_get_status($process);
|
||||
if (! $status['running']) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (microtime(true) >= $deadline) {
|
||||
$timedOut = true;
|
||||
break;
|
||||
}
|
||||
|
||||
$read = [$pipes[1], $pipes[2]];
|
||||
$write = null;
|
||||
$except = null;
|
||||
// 100ms 씩 poll (Windows 는 select() 정확도 한계)
|
||||
$ready = @stream_select($read, $write, $except, 0, 100_000);
|
||||
|
||||
if ($ready > 0) {
|
||||
foreach ($read as $stream) {
|
||||
$chunk = fread($stream, 8192);
|
||||
if ($chunk !== false && $chunk !== '') {
|
||||
if ($stream === $pipes[1]) {
|
||||
$stdout .= $chunk;
|
||||
} else {
|
||||
$stderr .= $chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 남은 출력 흡수
|
||||
$stdout .= stream_get_contents($pipes[1]) ?: '';
|
||||
$stderr .= stream_get_contents($pipes[2]) ?: '';
|
||||
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
|
||||
if ($timedOut) {
|
||||
// Windows: taskkill /F /T 로 자식 트리까지 종료
|
||||
$status = proc_get_status($process);
|
||||
if (! empty($status['pid'])) {
|
||||
if (PHP_OS_FAMILY === 'Windows') {
|
||||
@exec('taskkill /F /T /PID '.$status['pid'].' 2>NUL');
|
||||
} else {
|
||||
@posix_kill($status['pid'], 9);
|
||||
}
|
||||
}
|
||||
proc_terminate($process, 9);
|
||||
}
|
||||
|
||||
$exitCode = proc_close($process);
|
||||
|
||||
$lines = $stdout === '' ? [] : preg_split("/\r\n|\n|\r/", rtrim($stdout, "\r\n"));
|
||||
if ($lines === false) {
|
||||
$lines = [];
|
||||
}
|
||||
|
||||
if ($returnStderr) {
|
||||
return [$lines, $exitCode, $stderr];
|
||||
}
|
||||
|
||||
return [$lines, $exitCode];
|
||||
}
|
||||
|
||||
/**
|
||||
* 시스템 프로세스 여부를 확인합니다.
|
||||
*
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Extension\Helpers;
|
||||
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class FilePermissionHelper
|
||||
{
|
||||
@@ -10,12 +11,16 @@ class FilePermissionHelper
|
||||
* 디렉토리를 재귀적으로 복사하면서 기존 파일/디렉토리의 퍼미션을 보존합니다.
|
||||
*
|
||||
* - 기존 디렉토리: 퍼미션/소유자/그룹 유지
|
||||
* - 신규 디렉토리: 부모 디렉토리의 퍼미션 상속
|
||||
* - 신규 디렉토리: 부모 디렉토리의 퍼미션/소유자/그룹 상속
|
||||
* - 기존 파일: 퍼미션/소유자/그룹 유지한 채 내용만 교체
|
||||
* - 신규 파일: PHP 기본 퍼미션 적용 (umask 기반)
|
||||
* - 신규 파일: 부모 디렉토리의 소유자/그룹 상속 (퍼미션은 PHP 기본 umask)
|
||||
* - removeOrphans=false: 소스에 없고 대상에만 있는 파일 유지 (사용자 추가 파일 보호)
|
||||
* - removeOrphans=true: 소스에 없고 대상에만 있는 파일/디렉토리 삭제 (excludes 제외)
|
||||
*
|
||||
* 신규 항목의 소유권 상속은 sudo 로 실행된 업데이트 프로세스가 root 소유로 파일을
|
||||
* 생성하는 것을 방지한다. vendor/ 처럼 cleanDirectory 후 재생성되는 디렉토리 구조
|
||||
* 전체가 기존 부모(= vendor/) 의 소유권을 승계하도록 보장한다.
|
||||
*
|
||||
* @param string $source 소스 디렉토리 경로
|
||||
* @param string $destination 대상 디렉토리 경로
|
||||
* @param \Closure|null $onProgress 진행 콜백
|
||||
@@ -27,10 +32,8 @@ class FilePermissionHelper
|
||||
public static function copyDirectory(string $source, string $destination, ?\Closure $onProgress = null, array $excludes = [], string $relativePath = '', bool $removeOrphans = false): void
|
||||
{
|
||||
if (! File::isDirectory($destination)) {
|
||||
// 신규 디렉토리: 부모 디렉토리의 퍼미션 상속
|
||||
$parentDir = dirname($destination);
|
||||
$parentPerms = File::isDirectory($parentDir) ? (fileperms($parentDir) & 0777) : 0755;
|
||||
File::ensureDirectoryExists($destination, $parentPerms, true);
|
||||
// 신규 디렉토리: 부모 디렉토리의 퍼미션/소유권 상속
|
||||
static::createDirectoryInheritingParent($destination);
|
||||
}
|
||||
// 기존 디렉토리: 퍼미션 건드리지 않음 (그대로 유지)
|
||||
|
||||
@@ -130,7 +133,11 @@ class FilePermissionHelper
|
||||
* 퍼미션과 소유권을 보존하면서 파일을 복사합니다.
|
||||
*
|
||||
* - 기존 파일: 복사 후 원래 퍼미션/소유자/그룹 복원
|
||||
* - 신규 파일: PHP 기본 퍼미션 적용 (umask 기반)
|
||||
* - 신규 파일: 부모 디렉토리의 소유자/그룹 상속 (퍼미션은 PHP 기본 umask)
|
||||
*
|
||||
* 신규 파일에 부모 소유권을 상속시키는 이유는 sudo 로 실행된 업데이트가 root 소유로
|
||||
* 파일을 생성하는 문제를 방지하기 위함이다. vendor/ 내부처럼 cleanDirectory 후
|
||||
* 전량 재생성되는 경로에서 필요하다.
|
||||
*
|
||||
* @param string $source 소스 파일
|
||||
* @param string $destination 대상 파일
|
||||
@@ -138,11 +145,12 @@ class FilePermissionHelper
|
||||
*/
|
||||
public static function copyFile(string $source, string $destination): void
|
||||
{
|
||||
$isExisting = File::exists($destination);
|
||||
$existingPerms = null;
|
||||
$existingOwner = null;
|
||||
$existingGroup = null;
|
||||
|
||||
if (File::exists($destination)) {
|
||||
if ($isExisting) {
|
||||
$existingPerms = fileperms($destination);
|
||||
$existingOwner = fileowner($destination);
|
||||
$existingGroup = filegroup($destination);
|
||||
@@ -151,14 +159,192 @@ class FilePermissionHelper
|
||||
File::ensureDirectoryExists(dirname($destination));
|
||||
File::copy($source, $destination);
|
||||
|
||||
if ($existingPerms !== null) {
|
||||
@chmod($destination, $existingPerms);
|
||||
if ($isExisting) {
|
||||
// 기존 파일: 원래 퍼미션/소유권 복원
|
||||
if ($existingPerms !== null) {
|
||||
@chmod($destination, $existingPerms);
|
||||
}
|
||||
if ($existingOwner !== null && function_exists('chown')) {
|
||||
@chown($destination, $existingOwner);
|
||||
}
|
||||
if ($existingGroup !== null && function_exists('chgrp')) {
|
||||
@chgrp($destination, $existingGroup);
|
||||
}
|
||||
} else {
|
||||
// 신규 파일: 부모 디렉토리의 소유자/그룹 상속
|
||||
static::inheritOwnershipFromParent($destination);
|
||||
}
|
||||
if ($existingOwner !== null && function_exists('chown')) {
|
||||
@chown($destination, $existingOwner);
|
||||
}
|
||||
|
||||
/**
|
||||
* 부모 디렉토리의 퍼미션·소유자·그룹을 상속하여 신규 디렉토리를 생성합니다.
|
||||
*
|
||||
* @param string $path 생성할 디렉토리 경로
|
||||
* @return void
|
||||
*/
|
||||
protected static function createDirectoryInheritingParent(string $path): void
|
||||
{
|
||||
$parentDir = dirname($path);
|
||||
$parentExists = File::isDirectory($parentDir);
|
||||
$parentPerms = $parentExists ? (fileperms($parentDir) & 0777) : 0755;
|
||||
|
||||
File::ensureDirectoryExists($path, $parentPerms, true);
|
||||
|
||||
if ($parentExists) {
|
||||
static::applyOwnership($path, fileowner($parentDir), filegroup($parentDir));
|
||||
}
|
||||
if ($existingGroup !== null && function_exists('chgrp')) {
|
||||
@chgrp($destination, $existingGroup);
|
||||
}
|
||||
|
||||
/**
|
||||
* 부모 디렉토리의 소유자·그룹을 대상 경로에 상속합니다.
|
||||
*
|
||||
* @param string $path 소유권을 상속받을 파일 또는 디렉토리
|
||||
* @return void
|
||||
*/
|
||||
protected static function inheritOwnershipFromParent(string $path): void
|
||||
{
|
||||
$parentDir = dirname($path);
|
||||
if (! File::isDirectory($parentDir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
static::applyOwnership($path, fileowner($parentDir), filegroup($parentDir));
|
||||
}
|
||||
|
||||
/**
|
||||
* 소유자·그룹을 적용합니다. sudo 없이 실행 시 silent fail 로 현행 동작 유지.
|
||||
*
|
||||
* @param string $path 대상 경로
|
||||
* @param int|false $owner fileowner() 반환값 (false 허용)
|
||||
* @param int|false $group filegroup() 반환값 (false 허용)
|
||||
* @return void
|
||||
*/
|
||||
protected static function applyOwnership(string $path, int|false $owner, int|false $group): void
|
||||
{
|
||||
if ($owner !== false && function_exists('chown')) {
|
||||
@chown($path, $owner);
|
||||
}
|
||||
if ($group !== false && function_exists('chgrp')) {
|
||||
@chgrp($path, $group);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 웹서버(www-data 등) 계정의 소유자를 추정합니다.
|
||||
*
|
||||
* Laravel 표준상 웹서버가 쓰기 접근해야 하는 디렉토리(`storage/*`, `bootstrap/cache`)
|
||||
* 를 순회하여 base_path() 소유자와 **다른** 첫 소유자를 "웹서버 계정" 으로 판정한다.
|
||||
* 모든 후보가 base_path() 와 동일하면 대칭 구성으로 보고 base_path() 소유자를 반환.
|
||||
*
|
||||
* 사용 예:
|
||||
* - sudo 실행된 업데이트가 원본 스냅샷을 수집하지 못한 경우의 fallback
|
||||
* - 외부 프로세스(composer 등) 가 root 로 오염시킨 경로의 원본 추정
|
||||
*
|
||||
* @return array{0: int|false, 1: int|false, 2: string} [owner, group, source]
|
||||
*/
|
||||
public static function inferWebServerOwnership(): array
|
||||
{
|
||||
$baseOwner = @fileowner(base_path());
|
||||
$baseGroup = @filegroup(base_path());
|
||||
|
||||
if ($baseOwner === false) {
|
||||
return [false, false, 'none'];
|
||||
}
|
||||
|
||||
$candidates = [
|
||||
'storage/logs',
|
||||
'storage/framework/views',
|
||||
'storage/framework/cache',
|
||||
'storage/app',
|
||||
'storage',
|
||||
'bootstrap/cache',
|
||||
];
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$path = base_path($candidate);
|
||||
if (! File::isDirectory($path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$owner = @fileowner($path);
|
||||
if ($owner !== false && $owner !== $baseOwner) {
|
||||
return [$owner, @filegroup($path), $candidate];
|
||||
}
|
||||
}
|
||||
|
||||
return [$baseOwner, $baseGroup, 'base_path (대칭 구성)'];
|
||||
}
|
||||
|
||||
/**
|
||||
* 경로와 그 하위 항목의 소유자·그룹을 재귀적으로 복원합니다.
|
||||
*
|
||||
* 현재 소유자가 기준과 이미 일치하면 해당 항목은 스킵. symbolic link 는 링크 자체만
|
||||
* 처리하고 대상은 따라가지 않는다. @chown/@chgrp suppress 로 권한 부족 / chown 미지원
|
||||
* 환경에서도 silent fail.
|
||||
*
|
||||
* @param string $path 대상 경로 (파일 또는 디렉토리)
|
||||
* @param int $owner 기준 소유자 UID
|
||||
* @param int|false $group 기준 그룹 GID (false = 그룹 유지)
|
||||
* @return int 실제 소유권을 변경한 항목 수
|
||||
*/
|
||||
public static function chownRecursive(string $path, int $owner, int|false $group): int
|
||||
{
|
||||
if (! function_exists('chown')) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 재귀 전체 기간 동안 실패/성공을 집계하고 종료 시 요약 로그를 남긴다.
|
||||
// 경로당 개별 로그는 재귀가 깊어지면 로그 폭주 유발 → 최초 실패 1건만 즉시 로깅.
|
||||
$report = ['changed' => 0, 'failed' => 0, 'first_failure' => null];
|
||||
self::chownRecursiveInternal($path, $owner, $group, $report);
|
||||
|
||||
if ($report['failed'] > 0) {
|
||||
Log::warning('chownRecursive: 부분 실패', [
|
||||
'root' => $path,
|
||||
'owner' => $owner,
|
||||
'group' => $group,
|
||||
'changed' => $report['changed'],
|
||||
'failed' => $report['failed'],
|
||||
'first_failure' => $report['first_failure'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $report['changed'];
|
||||
}
|
||||
|
||||
/**
|
||||
* chownRecursive 의 내부 재귀 구현. 실패 카운터를 참조 전달로 집계한다.
|
||||
*
|
||||
* @param string $path 대상 경로
|
||||
* @param int $owner 기준 소유자 UID
|
||||
* @param int|false $group 기준 그룹 GID
|
||||
* @param array{changed:int, failed:int, first_failure:string|null} $report 집계 구조 (참조)
|
||||
*/
|
||||
private static function chownRecursiveInternal(string $path, int $owner, int|false $group, array &$report): void
|
||||
{
|
||||
$currentOwner = @fileowner($path);
|
||||
if ($currentOwner !== false && $currentOwner !== $owner) {
|
||||
if (@chown($path, $owner)) {
|
||||
$report['changed']++;
|
||||
} else {
|
||||
if ($report['first_failure'] === null) {
|
||||
$report['first_failure'] = $path;
|
||||
Log::warning('chown 최초 실패', ['path' => $path, 'owner' => $owner]);
|
||||
}
|
||||
$report['failed']++;
|
||||
}
|
||||
if ($group !== false && function_exists('chgrp')) {
|
||||
@chgrp($path, $group);
|
||||
}
|
||||
}
|
||||
|
||||
if (! is_dir($path) || is_link($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$items = new \FilesystemIterator($path, \FilesystemIterator::SKIP_DOTS);
|
||||
foreach ($items as $item) {
|
||||
self::chownRecursiveInternal($item->getPathname(), $owner, $group, $report);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Helpers;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 범용 엔티티 동기화 헬퍼.
|
||||
*
|
||||
* 단일 스코프·단일 키 구조의 모듈 내부 도메인 엔티티를 대상으로
|
||||
* upsert + stale cleanup 패턴을 제공합니다.
|
||||
*
|
||||
* 전용 helper (ExtensionMenuSyncHelper, ExtensionRoleSyncHelper,
|
||||
* NotificationSyncHelper) 와의 역할 구분:
|
||||
* - 전용 helper: 다중 모델 관계·피벗 참조 차단 등 도메인 로직 포함
|
||||
* - 본 helper: 단일 테이블 · 단일 unique 키 · 선택적 scope 필터 범용 처리
|
||||
*
|
||||
* 사용 조건: 대상 모델은 `HasUserOverrides` trait 적용 필수.
|
||||
*/
|
||||
class GenericEntitySyncHelper
|
||||
{
|
||||
/**
|
||||
* 엔티티를 동기화합니다 (user_overrides 보존 upsert).
|
||||
*
|
||||
* 내부적으로 `HasUserOverrides::syncOrCreateFromUpgrade` 를 위임 호출합니다.
|
||||
*
|
||||
* @template T of Model
|
||||
* @param class-string<T> $modelClass HasUserOverrides 적용 모델 클래스
|
||||
* @param array<string, mixed> $finder updateOrCreate 의 unique 키 조건
|
||||
* @param array<string, mixed> $attributes 갱신할 속성
|
||||
* @return T 동기화된 모델 인스턴스
|
||||
*/
|
||||
public function sync(string $modelClass, array $finder, array $attributes): Model
|
||||
{
|
||||
return $modelClass::syncOrCreateFromUpgrade($finder, $attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 주어진 scope 내에서 currentKeys 에 없는 row 를 삭제합니다 (완전 동기화).
|
||||
*
|
||||
* 정책: `user_overrides` 무관 — seeder/config 에 없는 row 는 삭제.
|
||||
*
|
||||
* @param class-string<Model> $modelClass 대상 모델 클래스
|
||||
* @param array<string, mixed> $scopeFilter 삭제 대상 한정 조건 (예: ['extension_identifier' => 'x', 'type' => 'refund']). 빈 배열이면 전체 테이블 대상.
|
||||
* @param string $keyField 비교 기준 컬럼명 (예: 'code', 'slug', 'type')
|
||||
* @param array<int, string|int> $currentKeys 현재 유효한 키 목록
|
||||
* @return int 삭제된 행 수
|
||||
*/
|
||||
public function cleanupStale(
|
||||
string $modelClass,
|
||||
array $scopeFilter,
|
||||
string $keyField,
|
||||
array $currentKeys,
|
||||
): int {
|
||||
$query = $modelClass::query();
|
||||
foreach ($scopeFilter as $column => $value) {
|
||||
$query->where($column, $value);
|
||||
}
|
||||
$query->whereNotIn($keyField, $currentKeys);
|
||||
|
||||
$targets = $query->get(['id', $keyField]);
|
||||
foreach ($targets as $row) {
|
||||
$row->delete();
|
||||
}
|
||||
|
||||
$count = $targets->count();
|
||||
if ($count > 0) {
|
||||
Log::info('stale 엔티티 정리 완료', [
|
||||
'model' => $modelClass,
|
||||
'scope' => $scopeFilter,
|
||||
'key_field' => $keyField,
|
||||
'deleted' => $count,
|
||||
'keys' => $targets->pluck($keyField)->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
namespace App\Extension\Helpers;
|
||||
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
@@ -9,6 +14,9 @@ use Illuminate\Support\Facades\Log;
|
||||
*
|
||||
* 코어 업데이트(CoreUpdateService)와 확장 수동 설치(ModuleService, PluginService, TemplateService)
|
||||
* 모두에서 사용할 수 있는 GitHub API 관련 공통 메서드를 제공합니다.
|
||||
*
|
||||
* 모든 원격 HTTP 호출은 Laravel Http 파사드를 사용합니다.
|
||||
* `allow_url_fopen=Off` 환경(공유 호스팅)에서도 정상 동작합니다.
|
||||
*/
|
||||
class GithubHelper
|
||||
{
|
||||
@@ -43,26 +51,20 @@ class GithubHelper
|
||||
*/
|
||||
public static function checkRepoExists(string $owner, string $repo, string $token = ''): bool
|
||||
{
|
||||
$apiUrl = "https://api.github.com/repos/{$owner}/{$repo}";
|
||||
try {
|
||||
$response = static::request($token, 5)
|
||||
->get("https://api.github.com/repos/{$owner}/{$repo}");
|
||||
} catch (ConnectionException $e) {
|
||||
Log::warning('GitHub 저장소 확인 연결 실패', [
|
||||
'owner' => $owner,
|
||||
'repo' => $repo,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'header' => static::buildHeaders($token),
|
||||
'timeout' => 5,
|
||||
'ignore_errors' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$response = @file_get_contents($apiUrl, false, $context);
|
||||
|
||||
if ($response === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$statusCode = static::extractStatusCode($http_response_header ?? []);
|
||||
|
||||
return $statusCode === 200;
|
||||
return $response->successful();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,30 +79,21 @@ class GithubHelper
|
||||
{
|
||||
$apiUrl = "https://api.github.com/repos/{$owner}/{$repo}/releases/latest";
|
||||
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'header' => static::buildHeaders($token),
|
||||
'timeout' => 10,
|
||||
'ignore_errors' => true,
|
||||
],
|
||||
]);
|
||||
try {
|
||||
$response = static::request($token, 10)->get($apiUrl);
|
||||
} catch (ConnectionException $e) {
|
||||
Log::warning('GitHub API 연결 실패', ['url' => $apiUrl, 'error' => $e->getMessage()]);
|
||||
|
||||
$response = @file_get_contents($apiUrl, false, $context);
|
||||
|
||||
if ($response === false) {
|
||||
return ['version' => null, 'zipball_url' => null, 'error' => __('common.errors.github_api_failed')];
|
||||
}
|
||||
|
||||
$statusCode = static::extractStatusCode($http_response_header ?? []);
|
||||
|
||||
if ($statusCode !== 200) {
|
||||
if (! $response->successful()) {
|
||||
return ['version' => null, 'zipball_url' => null, 'error' => null];
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
$data = $response->json();
|
||||
|
||||
if (! isset($data['tag_name'])) {
|
||||
if (! is_array($data) || ! isset($data['tag_name'])) {
|
||||
return ['version' => null, 'zipball_url' => null, 'error' => null];
|
||||
}
|
||||
|
||||
@@ -166,7 +159,9 @@ class GithubHelper
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 URL에서 아카이브를 다운로드합니다.
|
||||
* 특정 URL에서 아카이브를 다운로드하여 파일로 저장합니다.
|
||||
*
|
||||
* Http 파사드의 `sink()`를 사용하여 메모리 사용을 최소화합니다.
|
||||
*
|
||||
* @param string $url 다운로드 URL
|
||||
* @param string $savePath 저장할 파일 경로
|
||||
@@ -176,22 +171,100 @@ class GithubHelper
|
||||
*/
|
||||
public static function downloadArchive(string $url, string $savePath, string $token = ''): void
|
||||
{
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'header' => static::buildHeaders($token),
|
||||
'follow_location' => true,
|
||||
'timeout' => 120,
|
||||
],
|
||||
]);
|
||||
try {
|
||||
$response = static::request($token, 120)
|
||||
->sink($savePath)
|
||||
->get($url);
|
||||
} catch (ConnectionException $e) {
|
||||
// 연결 실패 시에도 sink가 생성했을 수 있는 빈/부분 파일 정리
|
||||
if (File::exists($savePath)) {
|
||||
File::delete($savePath);
|
||||
}
|
||||
|
||||
$content = @file_get_contents($url, false, $context);
|
||||
Log::warning('GitHub 아카이브 다운로드 연결 실패', ['url' => $url, 'error' => $e->getMessage()]);
|
||||
|
||||
if ($content === false) {
|
||||
throw new \RuntimeException(__('common.errors.github_archive_download_failed', ['url' => $url]));
|
||||
}
|
||||
|
||||
\Illuminate\Support\Facades\File::put($savePath, $content);
|
||||
if (! $response->successful()) {
|
||||
// sink로 저장된 미완성 파일 정리
|
||||
if (File::exists($savePath)) {
|
||||
File::delete($savePath);
|
||||
}
|
||||
|
||||
throw new \RuntimeException(__('common.errors.github_archive_download_failed', ['url' => $url]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 URL에서 아카이브를 다운로드하여 바이너리 내용을 반환합니다.
|
||||
*
|
||||
* `downloadArchive()`와 달리 파일로 저장하지 않고 메모리로 내용을 반환합니다.
|
||||
* 호출부가 바이너리 내용을 직접 처리해야 하는 경우에만 사용하세요.
|
||||
*
|
||||
* @param string $url 다운로드 URL
|
||||
* @param string $token GitHub Personal Access Token
|
||||
* @return string 다운로드된 바이너리 내용
|
||||
*
|
||||
* @throws \RuntimeException 다운로드 실패 시
|
||||
*/
|
||||
public static function downloadArchiveContent(string $url, string $token = ''): string
|
||||
{
|
||||
try {
|
||||
$response = static::request($token, 120)->get($url);
|
||||
} catch (ConnectionException $e) {
|
||||
Log::warning('GitHub 아카이브 다운로드 연결 실패', ['url' => $url, 'error' => $e->getMessage()]);
|
||||
|
||||
throw new \RuntimeException(__('settings.core_update.download_failed', ['version' => basename($url)]));
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new \RuntimeException(__('settings.core_update.download_failed', ['version' => basename($url)]));
|
||||
}
|
||||
|
||||
return $response->body();
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub 아카이브 URL을 해석합니다 (v 접두사 유/무 모두 시도).
|
||||
*
|
||||
* HEAD 요청으로 200/302 응답이 오는 첫 번째 URL을 반환합니다.
|
||||
*
|
||||
* @param string $owner 저장소 소유자
|
||||
* @param string $repo 저장소 이름
|
||||
* @param string $version 버전 태그 (v 접두사 유/무 무관)
|
||||
* @param string $archiveType 아카이브 타입 (zipball/tarball)
|
||||
* @param string $token GitHub Personal Access Token
|
||||
* @return string|null 유효한 아카이브 URL 또는 null
|
||||
*/
|
||||
public static function resolveArchiveUrl(string $owner, string $repo, string $version, string $archiveType = 'zipball', string $token = ''): ?string
|
||||
{
|
||||
$tagVariants = ["v{$version}", $version];
|
||||
|
||||
foreach ($tagVariants as $tag) {
|
||||
// GitHub API 의 `zipball/{tag}` 엔드포인트는 태그가 존재하지 않아도 무조건 302 로
|
||||
// codeload 에 리다이렉트하며, 실제 다운로드 시점에 codeload 에서 404 가 반환되어
|
||||
// 다운로드 파일이 "404: Not Found" 14 byte 텍스트로 저장되는 불가시 실패가 발생한다.
|
||||
// 따라서 아카이브 URL 반환 전에 `git/refs/tags/{tag}` 로 태그 자체의 존재 여부를
|
||||
// 먼저 검증한다 (GitHub API: 존재=200, 부재=404).
|
||||
$tagCheckUrl = "https://api.github.com/repos/{$owner}/{$repo}/git/refs/tags/{$tag}";
|
||||
|
||||
try {
|
||||
$tagResponse = static::request($token, 10)->get($tagCheckUrl);
|
||||
} catch (ConnectionException $e) {
|
||||
Log::info('GitHub 태그 존재 검증 실패', ['url' => $tagCheckUrl, 'error' => $e->getMessage()]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($tagResponse->status() !== 200) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return "https://api.github.com/repos/{$owner}/{$repo}/{$archiveType}/{$tag}";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -215,25 +288,14 @@ class GithubHelper
|
||||
foreach ($refs as $tryRef) {
|
||||
$url = "https://raw.githubusercontent.com/{$owner}/{$repo}/{$tryRef}/{$filePath}";
|
||||
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'header' => static::buildHeaders($token),
|
||||
'timeout' => 10,
|
||||
'ignore_errors' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
$content = @file_get_contents($url, false, $context);
|
||||
|
||||
if ($content === false) {
|
||||
try {
|
||||
$response = static::request($token, 10)->get($url);
|
||||
} catch (ConnectionException $e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$statusCode = static::extractStatusCode($http_response_header ?? []);
|
||||
|
||||
if ($statusCode === 200) {
|
||||
return $content;
|
||||
if ($response->successful()) {
|
||||
return $response->body();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,7 +303,10 @@ class GithubHelper
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub API 요청용 HTTP 헤더를 생성합니다.
|
||||
* GitHub API 요청용 HTTP 헤더를 생성합니다 (file_get_contents 스트림 컨텍스트 호환).
|
||||
*
|
||||
* 레거시 호출부 호환을 위해 배열 형태의 헤더를 반환합니다.
|
||||
* 신규 코드는 `request()`를 사용하세요.
|
||||
*
|
||||
* @param string $token GitHub Personal Access Token (빈 문자열이면 인증 없음)
|
||||
* @return array HTTP 헤더 배열
|
||||
@@ -261,22 +326,27 @@ class GithubHelper
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP 응답 헤더에서 상태 코드를 추출합니다.
|
||||
* GitHub API 호출을 위한 표준화된 Http 파사드 PendingRequest를 생성합니다.
|
||||
*
|
||||
* @param array $responseHeaders $http_response_header 배열
|
||||
* @return int HTTP 상태 코드
|
||||
* 모든 원격 HTTP 호출의 단일 진입점입니다. User-Agent, Accept, 토큰 인증,
|
||||
* 타임아웃을 일괄 설정합니다.
|
||||
*
|
||||
* @param string $token GitHub Personal Access Token (빈 문자열이면 인증 없음)
|
||||
* @param int $timeout 읽기 타임아웃(초)
|
||||
*/
|
||||
public static function extractStatusCode(array $responseHeaders): int
|
||||
protected static function request(string $token = '', int $timeout = 10): PendingRequest
|
||||
{
|
||||
$statusCode = 0;
|
||||
$request = Http::withHeaders([
|
||||
'User-Agent' => 'G7',
|
||||
'Accept' => 'application/vnd.github.v3+json',
|
||||
])
|
||||
->timeout($timeout)
|
||||
->connectTimeout(5);
|
||||
|
||||
foreach ($responseHeaders as $header) {
|
||||
if (preg_match('#^HTTP/\S+\s+(\d{3})#', $header, $matches)) {
|
||||
// 리다이렉트 시 마지막 상태 코드를 사용하기 위해 계속 진행
|
||||
$statusCode = (int) $matches[1];
|
||||
}
|
||||
if ($token !== '') {
|
||||
$request = $request->withToken($token);
|
||||
}
|
||||
|
||||
return $statusCode;
|
||||
return $request;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Helpers;
|
||||
|
||||
use App\Models\NotificationDefinition;
|
||||
use App\Models\NotificationTemplate;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 알림 정의/템플릿 동기화 헬퍼.
|
||||
*
|
||||
* 코어/확장 업그레이드 경로에서 알림 정의·템플릿을 동기화합니다.
|
||||
* ExtensionMenuSyncHelper, ExtensionRoleSyncHelper 와 동일한 Helper 패턴을
|
||||
* 따르며, 내부 upsert 는 `HasUserOverrides::syncOrCreateFromUpgrade` 에 위임하여
|
||||
* trait 공통 API 를 재활용합니다.
|
||||
*
|
||||
* Seeder 는 본 helper 를 호출하는 얇은 진입점이며, 모든 데이터 정합성 로직은
|
||||
* helper 에 집중됩니다.
|
||||
*/
|
||||
class NotificationSyncHelper
|
||||
{
|
||||
/**
|
||||
* 알림 정의를 동기화합니다 (user_overrides 보존 upsert).
|
||||
*
|
||||
* 신규: 생성
|
||||
* 기존: user_overrides 에 없는 필드만 업데이트
|
||||
*
|
||||
* @param array<string, mixed> $data definition 데이터 (type, hook_prefix, extension_type,
|
||||
* extension_identifier, name, description, variables,
|
||||
* channels, hooks, is_active, is_default, templates 포함 가능)
|
||||
* @return NotificationDefinition 동기화된 definition
|
||||
*/
|
||||
public function syncDefinition(array $data): NotificationDefinition
|
||||
{
|
||||
return NotificationDefinition::syncOrCreateFromUpgrade(
|
||||
['type' => $data['type']],
|
||||
[
|
||||
'hook_prefix' => $data['hook_prefix'],
|
||||
'extension_type' => $data['extension_type'],
|
||||
'extension_identifier' => $data['extension_identifier'],
|
||||
'name' => $data['name'],
|
||||
'description' => $data['description'] ?? null,
|
||||
'variables' => $data['variables'] ?? [],
|
||||
'channels' => $data['channels'] ?? ['mail'],
|
||||
'hooks' => $data['hooks'] ?? [],
|
||||
'is_active' => $data['is_active'] ?? true,
|
||||
'is_default' => $data['is_default'] ?? true,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 템플릿을 동기화합니다 (user_overrides 보존 upsert).
|
||||
*
|
||||
* @param int $definitionId 연결된 definition id
|
||||
* @param array<string, mixed> $data template 데이터 (channel, subject, body,
|
||||
* click_url, recipients, is_active, is_default)
|
||||
* @return NotificationTemplate 동기화된 template
|
||||
*/
|
||||
public function syncTemplate(int $definitionId, array $data): NotificationTemplate
|
||||
{
|
||||
return NotificationTemplate::syncOrCreateFromUpgrade(
|
||||
['definition_id' => $definitionId, 'channel' => $data['channel']],
|
||||
[
|
||||
'subject' => $data['subject'] ?? null,
|
||||
'body' => $data['body'],
|
||||
'click_url' => $data['click_url'] ?? null,
|
||||
'recipients' => $data['recipients'] ?? null,
|
||||
'is_active' => $data['is_active'] ?? true,
|
||||
'is_default' => $data['is_default'] ?? true,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* seeder/정의에 없는 stale 알림 정의를 삭제합니다 (완전 동기화 원칙).
|
||||
*
|
||||
* 정책: `user_overrides` 무관 — config/seeder 에 없는 정의는 삭제.
|
||||
* FK cascade 로 연관 `notification_templates` 도 자동 정리됩니다.
|
||||
*
|
||||
* @param string $extensionType 확장 타입: 'core', 'module', 'plugin'
|
||||
* @param string $extensionIdentifier 확장 식별자
|
||||
* @param array<int, string> $currentTypes 현재 유효한 definition type 목록
|
||||
* @return int 삭제된 definition 수
|
||||
*/
|
||||
public function cleanupStaleDefinitions(
|
||||
string $extensionType,
|
||||
string $extensionIdentifier,
|
||||
array $currentTypes,
|
||||
): int {
|
||||
$query = NotificationDefinition::query()
|
||||
->where('extension_type', $extensionType)
|
||||
->where('extension_identifier', $extensionIdentifier)
|
||||
->whereNotIn('type', $currentTypes);
|
||||
|
||||
$targets = $query->get(['id', 'type']);
|
||||
foreach ($targets as $def) {
|
||||
$def->delete();
|
||||
}
|
||||
|
||||
$count = $targets->count();
|
||||
if ($count > 0) {
|
||||
Log::info('stale 알림 정의 정리 완료', [
|
||||
'extension_type' => $extensionType,
|
||||
'extension_identifier' => $extensionIdentifier,
|
||||
'deleted' => $count,
|
||||
'types' => $targets->pluck('type')->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 주어진 definition 의 channel 목록 기준으로 stale template 을 삭제합니다.
|
||||
*
|
||||
* 정책: `user_overrides` 무관 — seeder 에 없는 (definition_id, channel)
|
||||
* 조합은 삭제. definition 삭제 시 FK cascade 로 자동 정리되는 경로와 별개로,
|
||||
* definition 은 유지되지만 channel 이 재구성된 경우에 사용됩니다.
|
||||
*
|
||||
* @param int $definitionId
|
||||
* @param array<int, string> $currentChannels 현재 유효한 channel 목록
|
||||
* @return int 삭제된 template 수
|
||||
*/
|
||||
public function cleanupStaleTemplates(int $definitionId, array $currentChannels): int
|
||||
{
|
||||
$query = NotificationTemplate::query()
|
||||
->where('definition_id', $definitionId)
|
||||
->whereNotIn('channel', $currentChannels);
|
||||
|
||||
$targets = $query->get(['id', 'channel']);
|
||||
foreach ($targets as $template) {
|
||||
$template->delete();
|
||||
}
|
||||
|
||||
$count = $targets->count();
|
||||
if ($count > 0) {
|
||||
Log::info('stale 알림 템플릿 정리 완료', [
|
||||
'definition_id' => $definitionId,
|
||||
'deleted' => $count,
|
||||
'channels' => $targets->pluck('channel')->all(),
|
||||
]);
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 훅 리스너 인자를 큐 직렬화에 안전한 형태로 변환합니다.
|
||||
*
|
||||
* Eloquent Model은 클래스명 + PK로 변환하고,
|
||||
* 큐 워커에서 실행 시 다시 DB에서 조회하여 복원합니다.
|
||||
*/
|
||||
class HookArgumentSerializer
|
||||
{
|
||||
/**
|
||||
* Model 직렬화 마커 키
|
||||
*/
|
||||
private const MODEL_MARKER = '__hook_model__';
|
||||
|
||||
/**
|
||||
* Collection 직렬화 마커 키
|
||||
*/
|
||||
private const COLLECTION_MARKER = '__hook_collection__';
|
||||
|
||||
/**
|
||||
* 인자 배열을 직렬화 안전한 형태로 변환합니다.
|
||||
*
|
||||
* @param array $args 원본 인자 배열
|
||||
* @return array 직렬화된 인자 배열
|
||||
*/
|
||||
public static function serialize(array $args): array
|
||||
{
|
||||
return array_map([self::class, 'serializeValue'], $args);
|
||||
}
|
||||
|
||||
/**
|
||||
* 직렬화된 인자 배열을 원본으로 복원합니다.
|
||||
*
|
||||
* @param array $args 직렬화된 인자 배열
|
||||
* @return array 복원된 인자 배열
|
||||
*/
|
||||
public static function deserialize(array $args): array
|
||||
{
|
||||
return array_map([self::class, 'deserializeValue'], $args);
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 값을 직렬화합니다.
|
||||
*
|
||||
* @param mixed $value 원본 값
|
||||
* @return mixed 직렬화된 값
|
||||
*/
|
||||
private static function serializeValue(mixed $value): mixed
|
||||
{
|
||||
// Eloquent Model → 클래스명 + PK
|
||||
if ($value instanceof Model) {
|
||||
return [
|
||||
self::MODEL_MARKER => true,
|
||||
'class' => get_class($value),
|
||||
'id' => $value->getKey(),
|
||||
];
|
||||
}
|
||||
|
||||
// Collection → 배열로 변환 (내부 요소도 재귀 직렬화)
|
||||
if ($value instanceof Collection) {
|
||||
return [
|
||||
self::COLLECTION_MARKER => true,
|
||||
'items' => self::serialize($value->all()),
|
||||
];
|
||||
}
|
||||
|
||||
// 배열 → 재귀 직렬화
|
||||
if (is_array($value)) {
|
||||
return array_map([self::class, 'serializeValue'], $value);
|
||||
}
|
||||
|
||||
// 스칼라 값 (string, int, float, bool, null)
|
||||
if (is_scalar($value) || is_null($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// Closure 등 직렬화 불가능한 객체 → null로 대체 + 경고
|
||||
Log::warning('훅 인자 직렬화 불가: 해당 인자를 null로 대체합니다.', [
|
||||
'type' => get_debug_type($value),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 값을 역직렬화합니다.
|
||||
*
|
||||
* @param mixed $value 직렬화된 값
|
||||
* @return mixed 복원된 값
|
||||
*/
|
||||
private static function deserializeValue(mixed $value): mixed
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
// Model 복원
|
||||
if (! empty($value[self::MODEL_MARKER])) {
|
||||
$class = $value['class'];
|
||||
$id = $value['id'];
|
||||
|
||||
if (class_exists($class) && is_subclass_of($class, Model::class)) {
|
||||
return $class::find($id);
|
||||
}
|
||||
|
||||
Log::warning('훅 인자 역직렬화 실패: 모델 클래스를 찾을 수 없습니다.', [
|
||||
'class' => $class,
|
||||
'id' => $id,
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Collection 복원
|
||||
if (! empty($value[self::COLLECTION_MARKER])) {
|
||||
return collect(self::deserialize($value['items']));
|
||||
}
|
||||
|
||||
// 일반 배열 → 재귀 역직렬화
|
||||
return array_map([self::class, 'deserializeValue'], $value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
/**
|
||||
* 훅 리스너 큐 디스패치 시 요청 컨텍스트 캡처/복원 헬퍼.
|
||||
*
|
||||
* 큐 워커는 별도 프로세스이므로 Auth, Request, App 로케일이 모두 리셋된다.
|
||||
* 디스패치 시점(원래 요청 컨텍스트)에 capture()로 스냅샷을 만들고,
|
||||
* Job::handle() 진입 시 restore()로 복원하여 리스너가 평소처럼 동작하도록 한다.
|
||||
*
|
||||
* 코어 기본 항목: user_id, ip_address, user_agent, locale, path
|
||||
* 확장점:
|
||||
* - capture() 마지막에 'hook.context.capture' 필터 발화 (플러그인 키 추가 가능)
|
||||
* - restore() 진입 직후 'hook.context.restore' 액션 발화 (플러그인 복원 로직)
|
||||
*/
|
||||
class HookContextCapture
|
||||
{
|
||||
/**
|
||||
* 현재 요청 컨텍스트를 스냅샷으로 캡처합니다.
|
||||
*
|
||||
* dispatch 클로저에서 호출되므로 Auth/Request가 살아있는 상태입니다.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function capture(): array
|
||||
{
|
||||
$request = request();
|
||||
|
||||
$context = [
|
||||
'user_id' => Auth::id(),
|
||||
'ip_address' => $request?->ip(),
|
||||
'user_agent' => $request?->userAgent(),
|
||||
'locale' => App::getLocale(),
|
||||
'path' => $request?->path(),
|
||||
];
|
||||
|
||||
return HookManager::applyFilters('hook.context.capture', $context);
|
||||
}
|
||||
|
||||
/**
|
||||
* 캡처된 컨텍스트를 큐 워커 환경에 복원합니다.
|
||||
*
|
||||
* Job::handle() 진입 직후 호출됩니다. Job 종료 시 컨테이너가 폐기되므로
|
||||
* 별도 원복(finally) 처리는 불필요합니다.
|
||||
*
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public static function restore(array $context): void
|
||||
{
|
||||
// 확장 복원 로직 (코어보다 먼저 실행 — 플러그인이 코어 동작에 영향 주는 컨텍스트 설정 가능)
|
||||
HookManager::doAction('hook.context.restore', $context);
|
||||
|
||||
// 로케일
|
||||
if (! empty($context['locale'])) {
|
||||
App::setLocale($context['locale']);
|
||||
}
|
||||
|
||||
// 인증 사용자
|
||||
// 주의: Sanctum의 RequestGuard는 onceUsingId()를 지원하지 않으므로
|
||||
// User를 직접 조회하여 setUser()로 주입합니다. (Job 종료 시 컨테이너 폐기되어 자동 해제)
|
||||
if (! empty($context['user_id'])) {
|
||||
$user = User::find($context['user_id']);
|
||||
if ($user !== null) {
|
||||
Auth::setUser($user);
|
||||
}
|
||||
}
|
||||
|
||||
// Request 재구성 (IP, User-Agent, Path)
|
||||
// ResolvesActivityLogType::resolveLogType() 등이 request()->path() / is('api/admin/*') 를
|
||||
// 큐 워커 컨텍스트에서 호출하기 때문에 path 복원이 필수입니다.
|
||||
$server = [];
|
||||
if (! empty($context['ip_address'])) {
|
||||
$server['REMOTE_ADDR'] = $context['ip_address'];
|
||||
}
|
||||
if (! empty($context['user_agent'])) {
|
||||
$server['HTTP_USER_AGENT'] = $context['user_agent'];
|
||||
}
|
||||
if (! empty($context['path'])) {
|
||||
$server['REQUEST_URI'] = '/'.ltrim($context['path'], '/');
|
||||
}
|
||||
|
||||
if ($server !== []) {
|
||||
$request = Request::create(
|
||||
$server['REQUEST_URI'] ?? '/',
|
||||
'GET',
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
$server,
|
||||
);
|
||||
app()->instance('request', $request);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension;
|
||||
|
||||
use App\Jobs\DispatchHookListenerJob;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 훅 리스너를 HookManager에 등록하는 공통 유틸리티
|
||||
*
|
||||
* CoreServiceProvider, ModuleManager, PluginManager 3곳의
|
||||
* 중복된 등록 로직을 통합합니다.
|
||||
*
|
||||
* 기본 동작:
|
||||
* - Action 훅: 큐 드라이버에 따라 자동으로 큐/동기 실행
|
||||
* - Filter 훅: 항상 동기 실행 (반환값 체인)
|
||||
* - `'sync' => true` 선언 시: 큐 드라이버 무관하게 동기 실행
|
||||
*/
|
||||
class HookListenerRegistrar
|
||||
{
|
||||
/**
|
||||
* 리스너 클래스를 HookManager에 등록합니다.
|
||||
*
|
||||
* @param string $listenerClass HookListenerInterface 구현 클래스의 FQCN
|
||||
* @param string|null $source 등록 출처 (로그용: 'core', 모듈/플러그인 식별자)
|
||||
* @return void
|
||||
*/
|
||||
public static function register(string $listenerClass, ?string $source = null): void
|
||||
{
|
||||
try {
|
||||
$subscribedHooks = $listenerClass::getSubscribedHooks();
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('훅 리스너 등록 실패: getSubscribedHooks() 오류', [
|
||||
'listener' => $listenerClass,
|
||||
'source' => $source,
|
||||
'exception' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($subscribedHooks as $hookName => $config) {
|
||||
$method = $config['method'] ?? 'handle';
|
||||
$priority = $config['priority'] ?? 10;
|
||||
$type = $config['type'] ?? 'action';
|
||||
$forceSync = ! empty($config['sync']);
|
||||
|
||||
if ($type === 'filter') {
|
||||
// Filter: 항상 동기 실행 (반환값 체인이므로 큐 불가)
|
||||
HookManager::addFilter($hookName, function ($value, ...$args) use ($listenerClass, $method) {
|
||||
return app($listenerClass)->{$method}($value, ...$args);
|
||||
}, $priority);
|
||||
} elseif ($forceSync) {
|
||||
// Action + sync: true → 동기 실행 (개발자가 명시적으로 opt-out)
|
||||
HookManager::addAction($hookName, function (...$args) use ($listenerClass, $method) {
|
||||
app($listenerClass)->{$method}(...$args);
|
||||
}, $priority);
|
||||
} else {
|
||||
// Action 기본: 큐 디스패치
|
||||
// 큐 드라이버가 sync이면 Laravel이 즉시 실행 → 하위호환 보장
|
||||
// HookContextCapture::capture()로 Auth/Request/Locale 스냅샷을 함께 전달하여
|
||||
// 큐 워커에서 리스너가 평소처럼 사용자 컨텍스트를 사용할 수 있도록 한다.
|
||||
HookManager::addAction($hookName, function (...$args) use ($listenerClass, $method) {
|
||||
dispatch(new DispatchHookListenerJob(
|
||||
$listenerClass,
|
||||
$method,
|
||||
HookArgumentSerializer::serialize($args),
|
||||
HookContextCapture::capture(),
|
||||
));
|
||||
}, $priority);
|
||||
}
|
||||
|
||||
Log::info('훅 리스너 등록 완료', [
|
||||
'hook' => $hookName,
|
||||
'listener' => $listenerClass,
|
||||
'method' => $method,
|
||||
'priority' => $priority,
|
||||
'type' => $type,
|
||||
'sync' => $forceSync,
|
||||
'source' => $source,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,24 +3,27 @@
|
||||
namespace App\Extension;
|
||||
|
||||
use App\Contracts\Extension\HookManagerInterface;
|
||||
use App\Events\GenericBroadcastEvent;
|
||||
use App\Models\PermissionHook;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class HookManager implements HookManagerInterface
|
||||
{
|
||||
private static array $hooks = [];
|
||||
|
||||
private static array $filters = [];
|
||||
|
||||
private static array $dispatching = [];
|
||||
|
||||
/**
|
||||
* Hook 이벤트를 발생시켜 등록된 콜백들을 실행합니다.
|
||||
*
|
||||
* @param string $hookName Hook 이름
|
||||
* @param mixed ...$args Hook에 전달할 인수들
|
||||
* @return void
|
||||
* @param string $hookName Hook 이름
|
||||
* @param mixed ...$args Hook에 전달할 인수들
|
||||
*/
|
||||
public static function doAction(string $hookName, ...$args): void
|
||||
{
|
||||
@@ -50,9 +53,9 @@ class HookManager implements HookManagerInterface
|
||||
/**
|
||||
* Filter를 적용하여 데이터를 변환합니다.
|
||||
*
|
||||
* @param string $filterName Filter 이름
|
||||
* @param mixed $value 변환할 원본 값
|
||||
* @param mixed ...$args Filter에 전달할 추가 인수들
|
||||
* @param string $filterName Filter 이름
|
||||
* @param mixed $value 변환할 원본 값
|
||||
* @param mixed ...$args Filter에 전달할 추가 인수들
|
||||
* @return mixed 변환된 값
|
||||
*/
|
||||
public static function applyFilters(string $filterName, $value, ...$args)
|
||||
@@ -60,7 +63,7 @@ class HookManager implements HookManagerInterface
|
||||
$eventName = "filter.{$filterName}";
|
||||
|
||||
// 등록된 필터가 있는지 확인
|
||||
if (!isset(self::$filters[$filterName])) {
|
||||
if (! isset(self::$filters[$filterName])) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
@@ -81,18 +84,17 @@ class HookManager implements HookManagerInterface
|
||||
/**
|
||||
* Hook 이벤트에 콜백 함수를 등록합니다.
|
||||
*
|
||||
* @param string $hookName Hook 이름
|
||||
* @param callable $callback 실행할 콜백 함수
|
||||
* @param int $priority 실행 우선순위 (기본값: 10)
|
||||
* @return void
|
||||
* @param string $hookName Hook 이름
|
||||
* @param callable $callback 실행할 콜백 함수
|
||||
* @param int $priority 실행 우선순위 (기본값: 10)
|
||||
*/
|
||||
public static function addAction(string $hookName, callable $callback, int $priority = 10): void
|
||||
{
|
||||
if (!isset(self::$hooks[$hookName])) {
|
||||
if (! isset(self::$hooks[$hookName])) {
|
||||
self::$hooks[$hookName] = [];
|
||||
}
|
||||
|
||||
if (!isset(self::$hooks[$hookName][$priority])) {
|
||||
if (! isset(self::$hooks[$hookName][$priority])) {
|
||||
self::$hooks[$hookName][$priority] = [];
|
||||
}
|
||||
|
||||
@@ -100,7 +102,7 @@ class HookManager implements HookManagerInterface
|
||||
|
||||
// Laravel 이벤트 시스템에도 등록 (가드 래핑으로 doAction 실행 중 중복 방지)
|
||||
Event::listen("hook.{$hookName}", function (...$args) use ($hookName, $callback) {
|
||||
if (!empty(self::$dispatching[$hookName])) {
|
||||
if (! empty(self::$dispatching[$hookName])) {
|
||||
return; // doAction() 내부 배열에서 이미 실행됨 → 스킵
|
||||
}
|
||||
call_user_func_array($callback, $args);
|
||||
@@ -110,30 +112,63 @@ class HookManager implements HookManagerInterface
|
||||
/**
|
||||
* Filter에 콜백 함수를 등록합니다.
|
||||
*
|
||||
* @param string $filterName Filter 이름
|
||||
* @param callable $callback 실행할 콜백 함수
|
||||
* @param int $priority 실행 우선순위 (기본값: 10)
|
||||
* @return void
|
||||
* @param string $filterName Filter 이름
|
||||
* @param callable $callback 실행할 콜백 함수
|
||||
* @param int $priority 실행 우선순위 (기본값: 10)
|
||||
*/
|
||||
public static function addFilter(string $filterName, callable $callback, int $priority = 10): void
|
||||
{
|
||||
if (!isset(self::$filters[$filterName])) {
|
||||
if (! isset(self::$filters[$filterName])) {
|
||||
self::$filters[$filterName] = [];
|
||||
}
|
||||
|
||||
if (!isset(self::$filters[$filterName][$priority])) {
|
||||
if (! isset(self::$filters[$filterName][$priority])) {
|
||||
self::$filters[$filterName][$priority] = [];
|
||||
}
|
||||
|
||||
self::$filters[$filterName][$priority][] = $callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket 브로드캐스트를 실행합니다.
|
||||
*
|
||||
* broadcast(new Event(...)) 직접 호출 대신 이 메서드를 사용합니다.
|
||||
* 내부적으로 GenericBroadcastEvent를 생성하여 Laravel broadcast()를 실행합니다.
|
||||
*
|
||||
* @param string $channel 채널명 (예: 'admin.dashboard', 'user.notifications.123')
|
||||
* @param string $eventName 이벤트명 (예: 'dashboard.stats.updated', 'notification.received')
|
||||
* @param array $payload 브로드캐스트 데이터
|
||||
*/
|
||||
public static function broadcast(string $channel, string $eventName, array $payload = []): void
|
||||
{
|
||||
$driver = config('broadcasting.default');
|
||||
|
||||
if (in_array($driver, ['null', 'log', null], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reverb/Pusher 드라이버: 호스트 미설정 시 연결 시도 없이 건너뜀
|
||||
$connection = config("broadcasting.connections.{$driver}");
|
||||
if (empty($connection['options']['host'] ?? null)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
broadcast(new GenericBroadcastEvent($channel, $eventName, $payload));
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('브로드캐스트 실패 (Reverb 미실행 가능)', [
|
||||
'channel' => $channel,
|
||||
'event' => $eventName,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook 이벤트에서 콜백 함수를 제거합니다.
|
||||
*
|
||||
* @param string $hookName Hook 이름
|
||||
* @param callable $callback 제거할 콜백 함수
|
||||
* @return void
|
||||
* @param string $hookName Hook 이름
|
||||
* @param callable $callback 제거할 콜백 함수
|
||||
*/
|
||||
public static function removeAction(string $hookName, callable $callback): void
|
||||
{
|
||||
@@ -153,9 +188,8 @@ class HookManager implements HookManagerInterface
|
||||
/**
|
||||
* Filter에서 콜백 함수를 제거합니다.
|
||||
*
|
||||
* @param string $filterName Filter 이름
|
||||
* @param callable $callback 제거할 콜백 함수
|
||||
* @return void
|
||||
* @param string $filterName Filter 이름
|
||||
* @param callable $callback 제거할 콜백 함수
|
||||
*/
|
||||
public static function removeFilter(string $filterName, callable $callback): void
|
||||
{
|
||||
@@ -192,8 +226,7 @@ class HookManager implements HookManagerInterface
|
||||
/**
|
||||
* 특정 Hook에 등록된 모든 콜백을 제거합니다.
|
||||
*
|
||||
* @param string $hookName Hook 이름
|
||||
* @return void
|
||||
* @param string $hookName Hook 이름
|
||||
*/
|
||||
public static function clearAction(string $hookName): void
|
||||
{
|
||||
@@ -208,8 +241,7 @@ class HookManager implements HookManagerInterface
|
||||
/**
|
||||
* 특정 Filter에 등록된 모든 콜백을 제거합니다.
|
||||
*
|
||||
* @param string $filterName Filter 이름
|
||||
* @return void
|
||||
* @param string $filterName Filter 이름
|
||||
*/
|
||||
public static function clearFilter(string $filterName): void
|
||||
{
|
||||
@@ -222,8 +254,6 @@ class HookManager implements HookManagerInterface
|
||||
* 테스트 환경에서 등록된 모든 Hook과 Filter를 초기화합니다.
|
||||
*
|
||||
* 주의: 이 메서드는 테스트 환경에서만 사용해야 합니다.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function resetAll(): void
|
||||
{
|
||||
@@ -238,15 +268,15 @@ class HookManager implements HookManagerInterface
|
||||
* permission_hooks 테이블에 해당 훅이 매핑되어 있으면
|
||||
* 현재 사용자가 해당 권한을 보유하고 있는지 확인합니다.
|
||||
*
|
||||
* @param string $hookName 훅 이름
|
||||
* @param User|null $user 사용자 (null이면 현재 인증된 사용자)
|
||||
* @return void
|
||||
* @param string $hookName 훅 이름
|
||||
* @param User|null $user 사용자 (null이면 현재 인증된 사용자)
|
||||
*
|
||||
* @throws AuthorizationException 권한이 없는 경우
|
||||
*/
|
||||
public static function checkHookPermission(string $hookName, ?User $user = null): void
|
||||
{
|
||||
// 훅에 권한 매핑이 없으면 모든 사용자 허용
|
||||
if (!PermissionHook::hasPermissionMapping($hookName)) {
|
||||
if (! PermissionHook::hasPermissionMapping($hookName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -254,7 +284,7 @@ class HookManager implements HookManagerInterface
|
||||
$user = $user ?? Auth::user();
|
||||
|
||||
// 비인증 사용자는 권한 매핑된 훅 접근 불가
|
||||
if (!$user) {
|
||||
if (! $user) {
|
||||
throw new AuthorizationException(__('auth.unauthorized'));
|
||||
}
|
||||
|
||||
@@ -278,10 +308,10 @@ class HookManager implements HookManagerInterface
|
||||
/**
|
||||
* 훅 실행 전 권한 체크 후 Action 실행
|
||||
*
|
||||
* @param string $hookName 훅 이름
|
||||
* @param User|null $user 권한 체크 대상 사용자
|
||||
* @param mixed ...$args 훅에 전달할 인자
|
||||
* @return void
|
||||
* @param string $hookName 훅 이름
|
||||
* @param User|null $user 권한 체크 대상 사용자
|
||||
* @param mixed ...$args 훅에 전달할 인자
|
||||
*
|
||||
* @throws AuthorizationException 권한이 없는 경우
|
||||
*/
|
||||
public static function doActionWithPermission(string $hookName, ?User $user = null, ...$args): void
|
||||
@@ -293,11 +323,12 @@ class HookManager implements HookManagerInterface
|
||||
/**
|
||||
* 훅 실행 전 권한 체크 후 Filter 실행
|
||||
*
|
||||
* @param string $hookName 훅 이름
|
||||
* @param User|null $user 권한 체크 대상 사용자
|
||||
* @param mixed $value 필터링할 값
|
||||
* @param mixed ...$args 필터에 전달할 추가 인자
|
||||
* @param string $hookName 훅 이름
|
||||
* @param User|null $user 권한 체크 대상 사용자
|
||||
* @param mixed $value 필터링할 값
|
||||
* @param mixed ...$args 필터에 전달할 추가 인자
|
||||
* @return mixed 필터링된 값
|
||||
*
|
||||
* @throws AuthorizationException 권한이 없는 경우
|
||||
*/
|
||||
public static function applyFiltersWithPermission(string $hookName, ?User $user = null, mixed $value = null, ...$args): mixed
|
||||
|
||||
+634
-189
File diff suppressed because it is too large
Load Diff
+650
-195
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,8 @@ use App\Services\LayoutService;
|
||||
use App\Services\TemplateService;
|
||||
use Composer\Semver\Semver;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Extension\Cache\CoreCacheDriver;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -32,6 +33,7 @@ class TemplateManager implements TemplateManagerInterface
|
||||
{
|
||||
use Traits\CachesTemplateStatus;
|
||||
use Traits\ClearsTemplateCaches;
|
||||
use Traits\ComputesLayoutContentHash;
|
||||
use Traits\InspectsUninstallData;
|
||||
use Traits\InvalidatesLayoutCache;
|
||||
use Traits\ValidatesLayoutFiles;
|
||||
@@ -80,6 +82,18 @@ class TemplateManager implements TemplateManagerInterface
|
||||
$this->bundledTemplatesPath = $this->templatesPath.DIRECTORY_SEPARATOR.'_bundled';
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어 캐시 드라이버를 lazy 조회합니다.
|
||||
*/
|
||||
private function cache(): CacheInterface
|
||||
{
|
||||
try {
|
||||
return app(CacheInterface::class);
|
||||
} catch (\Throwable $e) {
|
||||
return new CoreCacheDriver(config('cache.default', 'array'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 템플릿을 로드하고 초기화합니다.
|
||||
*/
|
||||
@@ -111,6 +125,15 @@ class TemplateManager implements TemplateManagerInterface
|
||||
continue;
|
||||
}
|
||||
|
||||
// 무결성 검사: 활성 디렉토리는 있으나 template.json 누락 감지
|
||||
if (! File::exists($templateFile)) {
|
||||
Log::warning('템플릿 활성 디렉토리가 불완전합니다 (template.json 누락)', [
|
||||
'template' => $templateName,
|
||||
'directory' => $directory,
|
||||
'hint' => "복구: php artisan template:install {$templateName} --force",
|
||||
]);
|
||||
}
|
||||
|
||||
if (File::exists($templateFile)) {
|
||||
try {
|
||||
$jsonContent = File::get($templateFile);
|
||||
@@ -355,7 +378,7 @@ class TemplateManager implements TemplateManagerInterface
|
||||
*
|
||||
* @throws \Exception 템플릿을 찾을 수 없거나 의존성 문제 시
|
||||
*/
|
||||
public function installTemplate(string $templateName, ?\Closure $onProgress = null): bool
|
||||
public function installTemplate(string $templateName, ?\Closure $onProgress = null, bool $force = false): bool
|
||||
{
|
||||
// identifier 형식 검증 (내부 호출 방어)
|
||||
ExtensionManager::validateIdentifierFormat($templateName);
|
||||
@@ -370,9 +393,10 @@ class TemplateManager implements TemplateManagerInterface
|
||||
}
|
||||
|
||||
// 1. _pending/_bundled에서 활성 디렉토리로 복사 (활성 디렉토리에 없는 경우)
|
||||
// force=true 시 활성 디렉토리가 있어도 원본으로 덮어씀 (불완전 설치 복구)
|
||||
$onProgress?->__invoke('copy', '파일 복사 중...');
|
||||
if (! isset($this->templates[$templateName])) {
|
||||
$this->copyToActiveFromSource($templateName, $onProgress);
|
||||
if ($force || ! isset($this->templates[$templateName])) {
|
||||
$this->copyToActiveFromSource($templateName, $onProgress, $force);
|
||||
}
|
||||
|
||||
// 2. 검증
|
||||
@@ -900,6 +924,7 @@ class TemplateManager implements TemplateManagerInterface
|
||||
'description' => $this->getLocalizedValue($template['description'] ?? '', $locale),
|
||||
'github_url' => $template['github_url'] ?? null,
|
||||
'github_changelog_url' => $template['github_changelog_url'] ?? null,
|
||||
'requires_core' => $template['g7_version'] ?? null,
|
||||
'dependencies' => $template['dependencies'] ?? [],
|
||||
'locales' => $template['locales'] ?? [],
|
||||
'layouts_count' => $layoutsCount,
|
||||
@@ -946,6 +971,7 @@ class TemplateManager implements TemplateManagerInterface
|
||||
'description' => $this->getLocalizedValue($description, $locale),
|
||||
'github_url' => $metadata['github_url'] ?? null,
|
||||
'github_changelog_url' => $metadata['github_changelog_url'] ?? null,
|
||||
'requires_core' => $metadata['g7_version'] ?? null,
|
||||
'dependencies' => $metadata['dependencies'] ?? [],
|
||||
'locales' => $metadata['locales'] ?? [],
|
||||
'layouts_count' => 0,
|
||||
@@ -1579,11 +1605,11 @@ class TemplateManager implements TemplateManagerInterface
|
||||
if (! in_array($override->name, $registeredLayoutNames)) {
|
||||
// 캐시 삭제 (레코드 삭제 전에 수행)
|
||||
$cacheKey = "template.{$templateId}.layout.{$override->name}";
|
||||
Cache::forget($cacheKey);
|
||||
$this->cache()->forget($cacheKey);
|
||||
|
||||
$sourceHash = md5($override->source_type?->value.$override->source_identifier);
|
||||
$cacheKeyWithHash = "template.{$templateId}.layout.{$override->name}.{$sourceHash}";
|
||||
Cache::forget($cacheKeyWithHash);
|
||||
$this->cache()->forget($cacheKeyWithHash);
|
||||
|
||||
$override->forceDelete();
|
||||
$deletedCount++;
|
||||
@@ -1632,12 +1658,12 @@ class TemplateManager implements TemplateManagerInterface
|
||||
foreach ($overrideLayouts as $layout) {
|
||||
// 기본 캐시 키 패턴으로 삭제
|
||||
$cacheKey = "template.{$templateId}.layout.{$layout->name}";
|
||||
Cache::forget($cacheKey);
|
||||
$this->cache()->forget($cacheKey);
|
||||
|
||||
// sourceHash를 포함한 캐시 키도 삭제 (LayoutService의 캐시 키 패턴)
|
||||
$sourceHash = md5($layout->source_type?->value.$layout->source_identifier);
|
||||
$cacheKeyWithHash = "template.{$templateId}.layout.{$layout->name}.{$sourceHash}";
|
||||
Cache::forget($cacheKeyWithHash);
|
||||
$this->cache()->forget($cacheKeyWithHash);
|
||||
}
|
||||
|
||||
Log::info(__('templates.info.override_layouts_cache_invalidated'), [
|
||||
@@ -1754,9 +1780,9 @@ class TemplateManager implements TemplateManagerInterface
|
||||
// PublicLayoutController::serve()와 동일한 캐시 키 패턴 사용
|
||||
$cacheKey = "layout.{$templateIdentifier}.{$layoutName}.v{$cacheVersion}";
|
||||
|
||||
Cache::remember($cacheKey, $cacheTtl, function () use ($templateIdentifier, $layoutName, $layoutService) {
|
||||
$this->cache()->remember($cacheKey, function () use ($templateIdentifier, $layoutName, $layoutService) {
|
||||
return $layoutService->getLayout($templateIdentifier, $layoutName);
|
||||
});
|
||||
}, $cacheTtl);
|
||||
|
||||
Log::debug('레이아웃 캐시 워밍 완료', [
|
||||
'template' => $templateIdentifier,
|
||||
@@ -1780,7 +1806,7 @@ class TemplateManager implements TemplateManagerInterface
|
||||
|
||||
if ($result['success']) {
|
||||
$cacheKey = "template.routes.{$templateIdentifier}.v{$cacheVersion}";
|
||||
Cache::put($cacheKey, ['success' => true, 'data' => $result['data']], $cacheTtl);
|
||||
$this->cache()->put($cacheKey, ['success' => true, 'data' => $result['data']], $cacheTtl);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::debug('Routes 캐시 워밍 실패', [
|
||||
@@ -1797,7 +1823,7 @@ class TemplateManager implements TemplateManagerInterface
|
||||
$langFilePath = base_path("templates/{$templateIdentifier}/lang/{$locale}.json");
|
||||
if (file_exists($langFilePath)) {
|
||||
$cacheKey = "template.language.{$templateIdentifier}.{$locale}.v{$cacheVersion}";
|
||||
Cache::remember($cacheKey, $cacheTtl, function () use ($templateIdentifier, $locale, $templateService) {
|
||||
$this->cache()->remember($cacheKey, function () use ($templateIdentifier, $locale, $templateService) {
|
||||
// TemplateService를 통해 $partial 해석 + 모듈/플러그인 다국어 병합
|
||||
$result = $templateService->getLanguageDataWithModules($templateIdentifier, $locale);
|
||||
|
||||
@@ -1806,7 +1832,7 @@ class TemplateManager implements TemplateManagerInterface
|
||||
}
|
||||
|
||||
return ['success' => true, 'data' => $result['data']];
|
||||
});
|
||||
}, $cacheTtl);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::debug('다국어 파일 캐시 워밍 실패', [
|
||||
@@ -2346,39 +2372,16 @@ class TemplateManager implements TemplateManagerInterface
|
||||
}
|
||||
|
||||
try {
|
||||
if (! preg_match('#github\.com[/:]([^/]+)/([^/\.]+)#', $githubUrl, $matches)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$owner = $matches[1];
|
||||
$repo = $matches[2];
|
||||
|
||||
$apiUrl = "https://api.github.com/repos/{$owner}/{$repo}/releases/latest";
|
||||
|
||||
$context = stream_context_create([
|
||||
'http' => [
|
||||
'method' => 'GET',
|
||||
'header' => GithubHelper::buildHeaders(config('app.update.github_token') ?? ''),
|
||||
'timeout' => 5,
|
||||
],
|
||||
]);
|
||||
|
||||
$response = @file_get_contents($apiUrl, false, $context);
|
||||
|
||||
if ($response === false) {
|
||||
Log::warning("GitHub API 호출 실패: {$apiUrl}");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($response, true);
|
||||
|
||||
if (isset($data['tag_name'])) {
|
||||
return ltrim($data['tag_name'], 'v');
|
||||
}
|
||||
|
||||
[$owner, $repo] = GithubHelper::parseUrl($githubUrl);
|
||||
} catch (\RuntimeException $e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$token = (string) (config('app.update.github_token') ?? '');
|
||||
$result = GithubHelper::fetchLatestRelease($owner, $repo, $token);
|
||||
|
||||
return $result['version'];
|
||||
} catch (\Exception $e) {
|
||||
Log::error('최신 버전 확인 중 오류 발생', [
|
||||
'github_url' => $githubUrl,
|
||||
@@ -2415,7 +2418,7 @@ class TemplateManager implements TemplateManagerInterface
|
||||
*
|
||||
* @throws \RuntimeException 소스를 찾을 수 없을 때
|
||||
*/
|
||||
protected function copyToActiveFromSource(string $templateName, ?\Closure $onProgress = null): void
|
||||
protected function copyToActiveFromSource(string $templateName, ?\Closure $onProgress = null, bool $force = false): void
|
||||
{
|
||||
$targetPath = $this->templatesPath.DIRECTORY_SEPARATOR.$templateName;
|
||||
|
||||
@@ -2423,6 +2426,7 @@ class TemplateManager implements TemplateManagerInterface
|
||||
if (isset($this->pendingTemplates[$templateName]) || ExtensionPendingHelper::isPending($this->templatesPath, $templateName)) {
|
||||
$sourcePath = ExtensionPendingHelper::getPendingPath($this->templatesPath, $templateName);
|
||||
ExtensionPendingHelper::copyToActive($sourcePath, $targetPath, $onProgress);
|
||||
Log::info('템플릿을 _pending에서 활성 디렉토리로 복사', ['template' => $templateName, 'force' => $force]);
|
||||
|
||||
// 메모리 재로드
|
||||
$this->reloadTemplate($templateName);
|
||||
@@ -2434,6 +2438,7 @@ class TemplateManager implements TemplateManagerInterface
|
||||
if (isset($this->bundledTemplates[$templateName]) || ExtensionPendingHelper::isBundled($this->templatesPath, $templateName)) {
|
||||
$sourcePath = ExtensionPendingHelper::getBundledPath($this->templatesPath, $templateName);
|
||||
ExtensionPendingHelper::copyToActive($sourcePath, $targetPath, $onProgress);
|
||||
Log::info('템플릿을 _bundled에서 활성 디렉토리로 복사', ['template' => $templateName, 'force' => $force]);
|
||||
|
||||
// 메모리 재로드
|
||||
$this->reloadTemplate($templateName);
|
||||
@@ -2503,7 +2508,15 @@ class TemplateManager implements TemplateManagerInterface
|
||||
/**
|
||||
* 단일 템플릿의 업데이트 가능 여부를 확인합니다.
|
||||
*
|
||||
* 우선순위: GitHub → _pending → _bundled
|
||||
* 일반 업데이트 우선순위 (GitHub 엄격 우선):
|
||||
* 1. GitHub URL 존재 + API 조회 성공 → GitHub 결과만 신뢰
|
||||
* a. GitHub 버전 > 현재 → 'github' 소스 반환
|
||||
* b. GitHub 버전 ≤ 현재 → "업데이트 없음" 즉시 반환 (bundled 폴백 없음)
|
||||
* 2. GitHub URL 없음 OR API 조회 실패 → _bundled 폴백 (안전망)
|
||||
*
|
||||
* --force 업데이트 우선순위는 resolveForceUpdateSource() 참조 (번들 우선).
|
||||
*
|
||||
* 참고: _pending 디렉토리는 install 경로에서만 사용되며 update 에서는 참조하지 않음.
|
||||
*
|
||||
* @param string $identifier 템플릿 식별자
|
||||
* @return array{update_available: bool, update_source: string|null, latest_version: string|null, current_version: string|null}
|
||||
@@ -2523,21 +2536,46 @@ class TemplateManager implements TemplateManagerInterface
|
||||
$currentVersion = $record->version;
|
||||
$template = $this->getTemplate($identifier);
|
||||
|
||||
// 1. GitHub URL이 있으면 GitHub에서 최신 버전 확인
|
||||
// 1. GitHub URL이 있으면 GitHub에서 최신 버전 확인 (조회 성공 시 GitHub만 신뢰)
|
||||
$githubUrl = $template['github_url'] ?? ($record->github_url ?? null);
|
||||
if ($githubUrl) {
|
||||
$latestVersion = $this->fetchLatestVersion($githubUrl);
|
||||
if ($latestVersion && version_compare($latestVersion, $currentVersion, '>')) {
|
||||
try {
|
||||
$latestVersion = $this->fetchLatestVersion($githubUrl);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('템플릿 GitHub 버전 조회 실패', [
|
||||
'template' => $identifier,
|
||||
'url' => $githubUrl,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$latestVersion = null;
|
||||
}
|
||||
|
||||
if ($latestVersion !== null) {
|
||||
// GitHub 조회 성공 → GitHub 결과만 신뢰 (bundled 폴백 없음)
|
||||
if (version_compare($latestVersion, $currentVersion, '>')) {
|
||||
return [
|
||||
'update_available' => true,
|
||||
'update_source' => 'github',
|
||||
'latest_version' => $latestVersion,
|
||||
'current_version' => $currentVersion,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'update_available' => true,
|
||||
'update_source' => 'github',
|
||||
'latest_version' => $latestVersion,
|
||||
'update_available' => false,
|
||||
'update_source' => null,
|
||||
'latest_version' => $currentVersion,
|
||||
'current_version' => $currentVersion,
|
||||
];
|
||||
}
|
||||
|
||||
// GitHub 조회 실패 → _bundled 폴백 안내
|
||||
Log::info('템플릿 업데이트 확인: GitHub 조회 실패로 bundled 폴백', [
|
||||
'template' => $identifier,
|
||||
]);
|
||||
}
|
||||
|
||||
// 2. _bundled에서 업데이트 확인
|
||||
// 2. _bundled에서 업데이트 확인 (GitHub URL 없음 OR GitHub 조회 실패)
|
||||
if (isset($this->bundledTemplates[$identifier])) {
|
||||
$bundledVersion = $this->bundledTemplates[$identifier]['version'] ?? null;
|
||||
if ($bundledVersion && version_compare($bundledVersion, $currentVersion, '>')) {
|
||||
@@ -2682,43 +2720,6 @@ class TemplateManager implements TemplateManagerInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 콘텐츠의 정규화된 SHA-256 해시를 계산합니다.
|
||||
*
|
||||
* 배열/JSON 문자열 모두 동일한 결과를 반환합니다.
|
||||
*
|
||||
* @param array|string $content 레이아웃 콘텐츠 (배열 또는 JSON 문자열)
|
||||
* @return string SHA-256 해시
|
||||
*/
|
||||
private function computeContentHash(array|string $content): string
|
||||
{
|
||||
return hash('sha256', $this->normalizeContent($content));
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 콘텐츠의 정규화된 바이트 크기를 계산합니다.
|
||||
*
|
||||
* @param array|string $content 레이아웃 콘텐츠 (배열 또는 JSON 문자열)
|
||||
* @return int 바이트 크기
|
||||
*/
|
||||
private function computeContentSize(array|string $content): int
|
||||
{
|
||||
return strlen($this->normalizeContent($content));
|
||||
}
|
||||
|
||||
/**
|
||||
* 콘텐츠를 정규화된 JSON 문자열로 변환합니다.
|
||||
*
|
||||
* @param array|string $content 레이아웃 콘텐츠 (배열 또는 JSON 문자열)
|
||||
* @return string 정규화된 JSON 문자열
|
||||
*/
|
||||
private function normalizeContent(array|string $content): string
|
||||
{
|
||||
return is_string($content)
|
||||
? json_encode(json_decode($content, true), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
: json_encode($content, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자가 수정한 레이아웃이 있는지 확인합니다.
|
||||
*
|
||||
@@ -2779,15 +2780,19 @@ class TemplateManager implements TemplateManagerInterface
|
||||
* 프로세스: 백업 → updating 상태 → 파일 교체 → DB 갱신 →
|
||||
* 레이아웃 갱신 → 상태 복원 → 백업 삭제
|
||||
*
|
||||
* 파라미터 순서는 updateModule / updatePlugin 과 일치시켜 공통 prefix
|
||||
* (id, force, onProgress, ...) 를 공유한다. 템플릿은 upgrade step 이 없어
|
||||
* vendorMode / onUpgradeStep 은 없음.
|
||||
*
|
||||
* @param string $identifier 템플릿 식별자
|
||||
* @param string $layoutStrategy 레이아웃 전략 ('overwrite' 또는 'keep')
|
||||
* @param bool $force 버전 비교 없이 강제 업데이트
|
||||
* @param \Closure|null $onProgress 진행 콜백 (?string $step, string $message)
|
||||
* @param string $layoutStrategy 레이아웃 전략 ('overwrite' 또는 'keep')
|
||||
* @return array{success: bool, from_version: string|null, to_version: string|null, message: string}
|
||||
*
|
||||
* @throws \RuntimeException 업데이트 실패 시
|
||||
*/
|
||||
public function updateTemplate(string $identifier, string $layoutStrategy = 'overwrite', bool $force = false, ?\Closure $onProgress = null): array
|
||||
public function updateTemplate(string $identifier, bool $force = false, ?\Closure $onProgress = null, string $layoutStrategy = 'overwrite', ?string $sourceOverride = null, ?string $zipPath = null): array
|
||||
{
|
||||
$record = $this->templateRepository->findByIdentifier($identifier);
|
||||
if (! $record) {
|
||||
@@ -2804,19 +2809,62 @@ class TemplateManager implements TemplateManagerInterface
|
||||
$fromVersion = $record->version;
|
||||
$updateInfo = $this->checkTemplateUpdate($identifier);
|
||||
|
||||
if (! $updateInfo['update_available'] && ! $force) {
|
||||
// ZIP 강제 경로: 외부 ZIP 파일을 직접 추출하여 사용. checkTemplateUpdate 결과는 무시.
|
||||
// zipTempDir / zipExtractedDir 는 staging 단계에서 사용 후 finally 에서 정리.
|
||||
$zipTempDir = null;
|
||||
$zipExtractedDir = null;
|
||||
if ($zipPath !== null) {
|
||||
$prepared = $this->extensionManager->prepareZipSource($zipPath, $identifier, 'template.json');
|
||||
$zipTempDir = $prepared['temp_dir'];
|
||||
$zipExtractedDir = $prepared['extracted_dir'];
|
||||
$updateSource = 'zip';
|
||||
$toVersion = $prepared['to_version'];
|
||||
}
|
||||
// 번들 강제 경로: 코어 업그레이드 / 일괄 업데이트 컨텍스트에서 GitHub 상태와 무관하게
|
||||
// _bundled manifest 버전을 강제 사용.
|
||||
elseif ($sourceOverride === 'bundled') {
|
||||
$bundled = $this->getBundledVersion($identifier);
|
||||
if ($bundled === null) {
|
||||
throw new \RuntimeException(
|
||||
__('templates.errors.force_update_no_source', ['template' => $identifier])
|
||||
);
|
||||
}
|
||||
$updateSource = 'bundled';
|
||||
$toVersion = $bundled;
|
||||
} elseif ($sourceOverride === 'github') {
|
||||
// GitHub 강제 경로: _bundled 폴백 없이 GitHub 만 시도.
|
||||
$template = $this->getTemplate($identifier);
|
||||
$githubUrl = $template['github_url'] ?? ($record->github_url ?? null);
|
||||
if (empty($githubUrl)) {
|
||||
throw new \RuntimeException(
|
||||
__('templates.errors.force_update_no_source', ['template' => $identifier])
|
||||
);
|
||||
}
|
||||
$updateSource = 'github';
|
||||
$toVersion = ($updateInfo['update_source'] === 'github' ? $updateInfo['latest_version'] : null)
|
||||
?? $updateInfo['current_version'];
|
||||
} elseif (! $updateInfo['update_available'] && ! $force) {
|
||||
return [
|
||||
'success' => false,
|
||||
'from_version' => $fromVersion,
|
||||
'to_version' => $fromVersion,
|
||||
'message' => __('templates.no_update_available'),
|
||||
];
|
||||
}
|
||||
|
||||
// force 시 버전/소스 결정
|
||||
if ($force && ! $updateInfo['update_available']) {
|
||||
$toVersion = $updateInfo['current_version'];
|
||||
} elseif ($force && ! $updateInfo['update_available']) {
|
||||
$updateSource = $this->resolveForceUpdateSource($identifier);
|
||||
|
||||
if ($updateSource === null) {
|
||||
throw new \RuntimeException(
|
||||
__('templates.errors.force_update_no_source', ['template' => $identifier])
|
||||
);
|
||||
}
|
||||
|
||||
// 번들 재설치는 번들 manifest 버전 기준, github 재설치는 현재 버전 기준
|
||||
if ($updateSource === 'bundled') {
|
||||
$toVersion = $this->getBundledVersion($identifier) ?? $updateInfo['current_version'];
|
||||
} else {
|
||||
$toVersion = $updateInfo['current_version'];
|
||||
}
|
||||
} else {
|
||||
$toVersion = $updateInfo['latest_version'];
|
||||
$updateSource = $updateInfo['update_source'];
|
||||
@@ -2848,6 +2896,9 @@ class TemplateManager implements TemplateManagerInterface
|
||||
$sourcePath = ExtensionPendingHelper::getBundledPath($this->templatesPath, $identifier);
|
||||
$stagingPath = ExtensionPendingHelper::createUpdateStagingPath($this->templatesPath, $identifier);
|
||||
ExtensionPendingHelper::stageForUpdate($sourcePath, $stagingPath, $onProgress);
|
||||
} elseif ($updateSource === 'zip') {
|
||||
$stagingPath = ExtensionPendingHelper::createUpdateStagingPath($this->templatesPath, $identifier);
|
||||
ExtensionPendingHelper::stageForUpdate($zipExtractedDir, $stagingPath, $onProgress);
|
||||
}
|
||||
|
||||
// 4. 원자적 적용 (스테이징 → 활성 디렉토리)
|
||||
@@ -2861,6 +2912,10 @@ class TemplateManager implements TemplateManagerInterface
|
||||
if ($stagingPath) {
|
||||
ExtensionPendingHelper::cleanupStaging($stagingPath);
|
||||
}
|
||||
// ZIP 임시 추출 디렉토리 정리
|
||||
if ($zipTempDir && File::isDirectory($zipTempDir)) {
|
||||
File::deleteDirectory($zipTempDir);
|
||||
}
|
||||
}
|
||||
|
||||
// 템플릿 재로드 (새 파일로)
|
||||
@@ -2987,8 +3042,17 @@ class TemplateManager implements TemplateManagerInterface
|
||||
/**
|
||||
* --force 시 업데이트 소스를 결정합니다.
|
||||
*
|
||||
* PO 정책: --force 시에는 번들이 우선, 번들이 없는 경우에만 GitHub 사용.
|
||||
* (일반 업데이트의 GitHub 우선과 반대 — 개발자가 로컬 번들로 되돌리려는 의도 존중)
|
||||
*
|
||||
* 우선순위:
|
||||
* 1. _bundled (메모리 캐시) → 'bundled'
|
||||
* 2. _bundled (디스크 재조회) → 'bundled'
|
||||
* 3. GitHub URL 존재 → 'github'
|
||||
* 4. 둘 다 없음 → null (업데이트 불가)
|
||||
*
|
||||
* @param string $identifier 템플릿 식별자
|
||||
* @return string|null 업데이트 소스 ('bundled' 또는 null)
|
||||
* @return string|null 'bundled' | 'github' | null
|
||||
*/
|
||||
private function resolveForceUpdateSource(string $identifier): ?string
|
||||
{
|
||||
@@ -3001,6 +3065,31 @@ class TemplateManager implements TemplateManagerInterface
|
||||
return 'bundled';
|
||||
}
|
||||
|
||||
// 번들 없음 → GitHub URL 확인
|
||||
$template = $this->getTemplate($identifier);
|
||||
$record = $this->templateRepository->findByIdentifier($identifier);
|
||||
$githubUrl = ($template['github_url'] ?? null) ?: ($record->github_url ?? null);
|
||||
if ($githubUrl) {
|
||||
return 'github';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* _bundled 에 등록된 템플릿의 버전을 반환합니다 (force 업데이트용).
|
||||
*
|
||||
* @param string $identifier 템플릿 식별자
|
||||
* @return string|null 버전 문자열 또는 null
|
||||
*/
|
||||
private function getBundledVersion(string $identifier): ?string
|
||||
{
|
||||
if (isset($this->bundledTemplates[$identifier]['version'])) {
|
||||
return $this->bundledTemplates[$identifier]['version'];
|
||||
}
|
||||
|
||||
$meta = ExtensionPendingHelper::loadBundledExtensions($this->templatesPath, 'template.json');
|
||||
|
||||
return $meta[$identifier]['version'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Traits;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 캐시 자동 무효화 트레이트
|
||||
*
|
||||
* 모델에 적용하면 saved/deleted 이벤트 시 관련 태그 캐시를 자동 무효화합니다.
|
||||
*
|
||||
* 사용 예:
|
||||
* ```php
|
||||
* class Product extends Model
|
||||
* {
|
||||
* use CacheInvalidatable;
|
||||
*
|
||||
* protected function getCacheInvalidationTags(): array
|
||||
* {
|
||||
* return ['products', 'product:' . $this->id];
|
||||
* }
|
||||
*
|
||||
* protected function getCacheDriver(): ?CacheInterface
|
||||
* {
|
||||
* return app(ModuleManager::class)
|
||||
* ->getModule('sirsoft-ecommerce')?->getCache();
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @since engine-v1.18.0
|
||||
*/
|
||||
trait CacheInvalidatable
|
||||
{
|
||||
/**
|
||||
* 트레이트 부트 메서드
|
||||
*
|
||||
* saved/deleted 이벤트에 캐시 무효화 로직을 등록합니다.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function bootCacheInvalidatable(): void
|
||||
{
|
||||
static::saved(fn ($model) => $model->invalidateRelatedCache());
|
||||
static::deleted(fn ($model) => $model->invalidateRelatedCache());
|
||||
}
|
||||
|
||||
/**
|
||||
* 무효화할 캐시 태그를 반환합니다.
|
||||
*
|
||||
* 모델에서 구현해야 합니다.
|
||||
*
|
||||
* @return array 태그 배열
|
||||
*/
|
||||
abstract protected function getCacheInvalidationTags(): array;
|
||||
|
||||
/**
|
||||
* 사용할 CacheInterface를 반환합니다.
|
||||
*
|
||||
* 모델에서 구현해야 합니다. null을 반환하면 무효화를 건너뜁니다.
|
||||
*
|
||||
* @return CacheInterface|null 캐시 드라이버 인스턴스
|
||||
*/
|
||||
abstract protected function getCacheDriver(): ?CacheInterface;
|
||||
|
||||
/**
|
||||
* 관련 캐시를 무효화합니다.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function invalidateRelatedCache(): void
|
||||
{
|
||||
$cache = $this->getCacheDriver();
|
||||
if ($cache === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tags = $this->getCacheInvalidationTags();
|
||||
if (empty($tags)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$cache->flushTags($tags);
|
||||
} catch (\Exception $e) {
|
||||
Log::warning('캐시 자동 무효화 실패', [
|
||||
'model' => static::class,
|
||||
'tags' => $tags,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
namespace App\Extension\Traits;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Enums\ExtensionStatus;
|
||||
use App\Extension\Cache\CoreCacheDriver;
|
||||
use App\Models\Module;
|
||||
use App\Services\CacheService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 모듈 상태 캐시를 관리하는 Trait
|
||||
@@ -14,11 +17,6 @@ use App\Services\CacheService;
|
||||
*/
|
||||
trait CachesModuleStatus
|
||||
{
|
||||
/**
|
||||
* 캐시 그룹
|
||||
*/
|
||||
private static string $cacheGroup = 'modules';
|
||||
|
||||
/**
|
||||
* 활성화된 모듈 identifier 목록을 조회합니다.
|
||||
*
|
||||
@@ -26,14 +24,17 @@ trait CachesModuleStatus
|
||||
*/
|
||||
public static function getActiveModuleIdentifiers(): array
|
||||
{
|
||||
return CacheService::remember(
|
||||
self::$cacheGroup,
|
||||
'active_identifiers',
|
||||
if (! self::isExtensionTableReady('modules')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return self::resolveStatusCache()->remember(
|
||||
'ext.modules.active_identifiers',
|
||||
fn () => Module::where('status', ExtensionStatus::Active->value)
|
||||
->pluck('identifier')
|
||||
->toArray(),
|
||||
null,
|
||||
'modules'
|
||||
(int) g7_core_settings('cache.extension_status_ttl', 86400),
|
||||
['ext.status', 'ext.modules']
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,15 +45,18 @@ trait CachesModuleStatus
|
||||
*/
|
||||
public static function getInstalledModuleIdentifiers(): array
|
||||
{
|
||||
return CacheService::remember(
|
||||
self::$cacheGroup,
|
||||
'installed_identifiers',
|
||||
if (! self::isExtensionTableReady('modules')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return self::resolveStatusCache()->remember(
|
||||
'ext.modules.installed_identifiers',
|
||||
fn () => Module::whereIn('status', [
|
||||
ExtensionStatus::Active->value,
|
||||
ExtensionStatus::Inactive->value,
|
||||
])->pluck('identifier')->toArray(),
|
||||
null,
|
||||
'modules'
|
||||
(int) g7_core_settings('cache.extension_status_ttl', 86400),
|
||||
['ext.status', 'ext.modules']
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,9 +68,42 @@ trait CachesModuleStatus
|
||||
*/
|
||||
public static function invalidateModuleStatusCache(): void
|
||||
{
|
||||
CacheService::forgetMany(self::$cacheGroup, [
|
||||
'active_identifiers',
|
||||
'installed_identifiers',
|
||||
]);
|
||||
$cache = self::resolveStatusCache();
|
||||
$cache->forget('ext.modules.active_identifiers');
|
||||
$cache->forget('ext.modules.installed_identifiers');
|
||||
}
|
||||
|
||||
/**
|
||||
* DB 연결 + 테이블 존재 여부를 확인합니다 (인스톨러 안전성).
|
||||
*
|
||||
* 설치 완료 상태(`config('app.installer_completed')`)일 때는 테이블 존재를
|
||||
* 전제로 하여 `Schema::hasTable()` 호출을 건너뜁니다. 인스톨러 이전 환경이나
|
||||
* 테스트에서는 기존 체크 경로로 폴백합니다.
|
||||
*/
|
||||
private static function isExtensionTableReady(string $table): bool
|
||||
{
|
||||
if (config('app.installer_completed')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::connection()->getPdo();
|
||||
|
||||
return Schema::hasTable($table);
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CacheInterface 인스턴스를 lazy 조회합니다 (register 시점 안전).
|
||||
*/
|
||||
private static function resolveStatusCache(): CacheInterface
|
||||
{
|
||||
try {
|
||||
return app(CacheInterface::class);
|
||||
} catch (\Throwable $e) {
|
||||
return new CoreCacheDriver(config('cache.default', 'array'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
namespace App\Extension\Traits;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Enums\ExtensionStatus;
|
||||
use App\Extension\Cache\CoreCacheDriver;
|
||||
use App\Models\Plugin;
|
||||
use App\Services\CacheService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 플러그인 상태 캐시를 관리하는 Trait
|
||||
@@ -14,11 +17,6 @@ use App\Services\CacheService;
|
||||
*/
|
||||
trait CachesPluginStatus
|
||||
{
|
||||
/**
|
||||
* 캐시 그룹
|
||||
*/
|
||||
private static string $pluginCacheGroup = 'plugins';
|
||||
|
||||
/**
|
||||
* 활성화된 플러그인 identifier 목록을 조회합니다.
|
||||
*
|
||||
@@ -26,14 +24,17 @@ trait CachesPluginStatus
|
||||
*/
|
||||
public static function getActivePluginIdentifiers(): array
|
||||
{
|
||||
return CacheService::remember(
|
||||
self::$pluginCacheGroup,
|
||||
'active_identifiers',
|
||||
if (! self::isPluginTableReady()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return self::resolvePluginStatusCache()->remember(
|
||||
'ext.plugins.active_identifiers',
|
||||
fn () => Plugin::where('status', ExtensionStatus::Active->value)
|
||||
->pluck('identifier')
|
||||
->toArray(),
|
||||
null,
|
||||
'plugins'
|
||||
(int) g7_core_settings('cache.extension_status_ttl', 86400),
|
||||
['ext.status', 'ext.plugins']
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,15 +45,18 @@ trait CachesPluginStatus
|
||||
*/
|
||||
public static function getInstalledPluginIdentifiers(): array
|
||||
{
|
||||
return CacheService::remember(
|
||||
self::$pluginCacheGroup,
|
||||
'installed_identifiers',
|
||||
if (! self::isPluginTableReady()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return self::resolvePluginStatusCache()->remember(
|
||||
'ext.plugins.installed_identifiers',
|
||||
fn () => Plugin::whereIn('status', [
|
||||
ExtensionStatus::Active->value,
|
||||
ExtensionStatus::Inactive->value,
|
||||
])->pluck('identifier')->toArray(),
|
||||
null,
|
||||
'plugins'
|
||||
(int) g7_core_settings('cache.extension_status_ttl', 86400),
|
||||
['ext.status', 'ext.plugins']
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,9 +68,36 @@ trait CachesPluginStatus
|
||||
*/
|
||||
public static function invalidatePluginStatusCache(): void
|
||||
{
|
||||
CacheService::forgetMany(self::$pluginCacheGroup, [
|
||||
'active_identifiers',
|
||||
'installed_identifiers',
|
||||
]);
|
||||
$cache = self::resolvePluginStatusCache();
|
||||
$cache->forget('ext.plugins.active_identifiers');
|
||||
$cache->forget('ext.plugins.installed_identifiers');
|
||||
}
|
||||
|
||||
/**
|
||||
* 설치 완료 상태에서는 `Schema::hasTable()` 호출을 건너뜁니다.
|
||||
* 인스톨러 이전 환경이나 테스트에서는 기존 체크 경로로 폴백합니다.
|
||||
*/
|
||||
private static function isPluginTableReady(): bool
|
||||
{
|
||||
if (config('app.installer_completed')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::connection()->getPdo();
|
||||
|
||||
return Schema::hasTable('plugins');
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function resolvePluginStatusCache(): CacheInterface
|
||||
{
|
||||
try {
|
||||
return app(CacheInterface::class);
|
||||
} catch (\Throwable $e) {
|
||||
return new CoreCacheDriver(config('cache.default', 'array'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
namespace App\Extension\Traits;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Enums\ExtensionStatus;
|
||||
use App\Extension\Cache\CoreCacheDriver;
|
||||
use App\Models\Template;
|
||||
use App\Services\CacheService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* 템플릿 상태 캐시를 관리하는 Trait
|
||||
@@ -14,11 +17,6 @@ use App\Services\CacheService;
|
||||
*/
|
||||
trait CachesTemplateStatus
|
||||
{
|
||||
/**
|
||||
* 캐시 그룹
|
||||
*/
|
||||
private static string $templateCacheGroup = 'templates';
|
||||
|
||||
/**
|
||||
* 활성화된 템플릿 identifier 목록을 조회합니다.
|
||||
*
|
||||
@@ -26,14 +24,17 @@ trait CachesTemplateStatus
|
||||
*/
|
||||
public static function getActiveTemplateIdentifiers(): array
|
||||
{
|
||||
return CacheService::remember(
|
||||
self::$templateCacheGroup,
|
||||
'active_identifiers',
|
||||
if (! self::isTemplateTableReady()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return self::resolveTemplateStatusCache()->remember(
|
||||
'ext.templates.active_identifiers',
|
||||
fn () => Template::where('status', ExtensionStatus::Active->value)
|
||||
->pluck('identifier')
|
||||
->toArray(),
|
||||
null,
|
||||
'templates'
|
||||
(int) g7_core_settings('cache.extension_status_ttl', 86400),
|
||||
['ext.status', 'ext.templates']
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,15 +46,18 @@ trait CachesTemplateStatus
|
||||
*/
|
||||
public static function getActiveTemplateIdentifiersByType(string $type): array
|
||||
{
|
||||
return CacheService::remember(
|
||||
self::$templateCacheGroup,
|
||||
"active_identifiers_{$type}",
|
||||
if (! self::isTemplateTableReady()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return self::resolveTemplateStatusCache()->remember(
|
||||
"ext.templates.active_identifiers_{$type}",
|
||||
fn () => Template::where('status', ExtensionStatus::Active->value)
|
||||
->where('type', $type)
|
||||
->pluck('identifier')
|
||||
->toArray(),
|
||||
null,
|
||||
'templates'
|
||||
(int) g7_core_settings('cache.extension_status_ttl', 86400),
|
||||
['ext.status', 'ext.templates']
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,15 +68,18 @@ trait CachesTemplateStatus
|
||||
*/
|
||||
public static function getInstalledTemplateIdentifiers(): array
|
||||
{
|
||||
return CacheService::remember(
|
||||
self::$templateCacheGroup,
|
||||
'installed_identifiers',
|
||||
if (! self::isTemplateTableReady()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return self::resolveTemplateStatusCache()->remember(
|
||||
'ext.templates.installed_identifiers',
|
||||
fn () => Template::whereIn('status', [
|
||||
ExtensionStatus::Active->value,
|
||||
ExtensionStatus::Inactive->value,
|
||||
])->pluck('identifier')->toArray(),
|
||||
null,
|
||||
'templates'
|
||||
(int) g7_core_settings('cache.extension_status_ttl', 86400),
|
||||
['ext.status', 'ext.templates']
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,11 +91,38 @@ trait CachesTemplateStatus
|
||||
*/
|
||||
public static function invalidateTemplateStatusCache(): void
|
||||
{
|
||||
CacheService::forgetMany(self::$templateCacheGroup, [
|
||||
'active_identifiers',
|
||||
'active_identifiers_admin',
|
||||
'active_identifiers_user',
|
||||
'installed_identifiers',
|
||||
]);
|
||||
$cache = self::resolveTemplateStatusCache();
|
||||
$cache->forget('ext.templates.active_identifiers');
|
||||
$cache->forget('ext.templates.active_identifiers_admin');
|
||||
$cache->forget('ext.templates.active_identifiers_user');
|
||||
$cache->forget('ext.templates.installed_identifiers');
|
||||
}
|
||||
|
||||
/**
|
||||
* 설치 완료 상태에서는 `Schema::hasTable()` 호출을 건너뜁니다.
|
||||
* 인스톨러 이전 환경이나 테스트에서는 기존 체크 경로로 폴백합니다.
|
||||
*/
|
||||
private static function isTemplateTableReady(): bool
|
||||
{
|
||||
if (config('app.installer_completed')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
DB::connection()->getPdo();
|
||||
|
||||
return Schema::hasTable('templates');
|
||||
} catch (\Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static function resolveTemplateStatusCache(): CacheInterface
|
||||
{
|
||||
try {
|
||||
return app(CacheInterface::class);
|
||||
} catch (\Throwable $e) {
|
||||
return new CoreCacheDriver(config('cache.default', 'array'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
namespace App\Extension\Traits;
|
||||
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Extension\Cache\CoreCacheDriver;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
@@ -14,6 +15,11 @@ use Illuminate\Support\Facades\Log;
|
||||
*/
|
||||
trait ClearsTemplateCaches
|
||||
{
|
||||
/**
|
||||
* 캐시 버전 키 (드라이버 접두사 `g7:core:` 다음에 붙음).
|
||||
*/
|
||||
private static string $extensionCacheVersionKey = 'ext.cache_version';
|
||||
|
||||
/**
|
||||
* 확장 기능 캐시 버전을 증가시킵니다.
|
||||
*
|
||||
@@ -25,7 +31,7 @@ trait ClearsTemplateCaches
|
||||
{
|
||||
try {
|
||||
$newVersion = time();
|
||||
Cache::put('extension_cache_version', $newVersion);
|
||||
self::resolveExtensionCache()->put(self::$extensionCacheVersionKey, $newVersion);
|
||||
|
||||
Log::info('확장 기능 캐시 버전 증가', [
|
||||
'new_version' => $newVersion,
|
||||
@@ -44,7 +50,7 @@ trait ClearsTemplateCaches
|
||||
*/
|
||||
public static function getExtensionCacheVersion(): int
|
||||
{
|
||||
return (int) Cache::get('extension_cache_version', 0);
|
||||
return (int) self::resolveExtensionCache()->get(self::$extensionCacheVersionKey, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,4 +88,19 @@ trait ClearsTemplateCaches
|
||||
{
|
||||
Log::info('템플릿 레이아웃 캐시 무효화 (캐시 버전 증가로 처리)');
|
||||
}
|
||||
|
||||
/**
|
||||
* CacheInterface 인스턴스를 컨테이너에서 lazy 조회합니다.
|
||||
*
|
||||
* 컨테이너 미구성 환경(예: 일부 단위 테스트)에서도 동작하도록
|
||||
* fallback 으로 직접 CoreCacheDriver 를 생성합니다.
|
||||
*/
|
||||
private static function resolveExtensionCache(): CacheInterface
|
||||
{
|
||||
try {
|
||||
return app(CacheInterface::class);
|
||||
} catch (\Throwable $e) {
|
||||
return new CoreCacheDriver(config('cache.default', 'array'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Traits;
|
||||
|
||||
/**
|
||||
* 레이아웃 콘텐츠의 정규화된 해시/크기를 계산하는 공통 로직.
|
||||
*
|
||||
* template_layouts 테이블의 original_content_hash / original_content_size
|
||||
* 컬럼에 기록되는 값이며, 확장 업데이트 시 사용자 수정 여부 판단에 사용됩니다.
|
||||
*
|
||||
* Module/Plugin/Template 매니저에서 공용으로 사용합니다.
|
||||
*/
|
||||
trait ComputesLayoutContentHash
|
||||
{
|
||||
/**
|
||||
* 레이아웃 콘텐츠의 정규화된 SHA-256 해시를 계산합니다.
|
||||
*
|
||||
* @param array|string $content 배열 또는 JSON 문자열
|
||||
*/
|
||||
protected function computeContentHash(array|string $content): string
|
||||
{
|
||||
return hash('sha256', $this->normalizeContent($content));
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 콘텐츠의 정규화된 바이트 크기를 계산합니다.
|
||||
*
|
||||
* @param array|string $content 배열 또는 JSON 문자열
|
||||
*/
|
||||
protected function computeContentSize(array|string $content): int
|
||||
{
|
||||
return strlen($this->normalizeContent($content));
|
||||
}
|
||||
|
||||
/**
|
||||
* 콘텐츠를 정규화된 JSON 문자열로 변환합니다.
|
||||
*
|
||||
* @param array|string $content 배열 또는 JSON 문자열
|
||||
*/
|
||||
protected function normalizeContent(array|string $content): string
|
||||
{
|
||||
return is_string($content)
|
||||
? json_encode(json_decode($content, true), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
|
||||
: json_encode($content, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
namespace App\Extension\Traits;
|
||||
|
||||
use App\Contracts\Extension\CacheInterface;
|
||||
use App\Contracts\Repositories\LayoutRepositoryInterface;
|
||||
use App\Enums\LayoutSourceType;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use App\Extension\Cache\CoreCacheDriver;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
@@ -93,20 +94,34 @@ trait InvalidatesLayoutCache
|
||||
*/
|
||||
protected function forgetLayoutCacheKeys(object $layout, string $templateIdentifier = ''): void
|
||||
{
|
||||
$cache = $this->resolveLayoutCache();
|
||||
|
||||
// 1. LayoutService 내부 캐시 (버전 없음 → 능동 삭제)
|
||||
Cache::forget("template.{$layout->template_id}.layout.{$layout->name}");
|
||||
$cache->forget("template.{$layout->template_id}.layout.{$layout->name}");
|
||||
|
||||
// 2. 소스 해시 포함 키 (버전 없음 → 능동 삭제)
|
||||
if ($layout->source_type && $layout->source_identifier) {
|
||||
$sourceHash = md5($layout->source_type->value.$layout->source_identifier);
|
||||
Cache::forget("template.{$layout->template_id}.layout.{$layout->name}.{$sourceHash}");
|
||||
$cache->forget("template.{$layout->template_id}.layout.{$layout->name}.{$sourceHash}");
|
||||
}
|
||||
|
||||
// 3. PublicLayoutController 캐시 (버전 포함)
|
||||
// 레이아웃 내용 편집 시 현재 버전 키 삭제 (버전 변경 없이 내용만 바뀜)
|
||||
if ($templateIdentifier) {
|
||||
$cacheVersion = (int) Cache::get('extension_cache_version', 0);
|
||||
Cache::forget("layout.{$templateIdentifier}.{$layout->name}.v{$cacheVersion}");
|
||||
$cacheVersion = (int) $cache->get('ext.cache_version', 0);
|
||||
$cache->forget("layout.{$templateIdentifier}.{$layout->name}.v{$cacheVersion}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CacheInterface 인스턴스를 lazy 조회합니다.
|
||||
*/
|
||||
private function resolveLayoutCache(): CacheInterface
|
||||
{
|
||||
try {
|
||||
return app(CacheInterface::class);
|
||||
} catch (\Throwable $e) {
|
||||
return new CoreCacheDriver(config('cache.default', 'array'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Traits;
|
||||
|
||||
use App\Contracts\Extension\ModuleManagerInterface;
|
||||
use App\Contracts\Extension\PluginManagerInterface;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* SEO 변수 중복 검증 기능을 제공하는 트레이트.
|
||||
*
|
||||
* 모듈 및 플러그인 설치 시 seoVariables()에서 반환하는
|
||||
* 변수명이 기존 활성 확장과 충돌하지 않는지 검증합니다.
|
||||
*
|
||||
* 검증 대상:
|
||||
* - _common 변수: 모든 활성 확장의 _common 및 page_type별 변수와 중복 확인
|
||||
* - page_type별 변수: 동일 page_type 내 기존 변수 + _common 변수와 중복 확인
|
||||
*/
|
||||
trait ValidatesSeoVariables
|
||||
{
|
||||
/**
|
||||
* 확장의 SEO 변수명이 기존 활성 확장과 중복되지 않는지 검증합니다.
|
||||
*
|
||||
* @param object $extension 검증할 모듈 또는 플러그인 인스턴스
|
||||
* @param string $extensionType 확장 타입 ('module' 또는 'plugin')
|
||||
*
|
||||
* @throws \Exception SEO 변수명이 중복될 때
|
||||
*/
|
||||
protected function validateSeoVariables(object $extension, string $extensionType = 'module'): void
|
||||
{
|
||||
$newVars = $extension->seoVariables();
|
||||
$identifier = $extension->getIdentifier();
|
||||
|
||||
// SEO 변수가 없으면 검증 통과
|
||||
if (empty($newVars)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. 자체 _common과 page_type별 변수 간 중복 검증
|
||||
$selfErrors = $this->validateSelfSeoVariableConflicts($newVars);
|
||||
|
||||
// 2. 기존 활성 확장들의 SEO 변수 수집
|
||||
$existingVars = $this->collectExistingSeoVariables($identifier);
|
||||
|
||||
// 3. 기존 변수와 충돌 검증
|
||||
$crossErrors = $this->validateCrossSeoVariableConflicts($newVars, $existingVars);
|
||||
|
||||
$errors = array_merge($selfErrors, $crossErrors);
|
||||
if (! empty($errors)) {
|
||||
throw new \Exception(
|
||||
$this->formatSeoValidationError($extensionType, $identifier, implode("\n- ", $errors))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SEO 변수 검증 에러 메시지를 포맷합니다.
|
||||
*
|
||||
* @param string $extensionType 확장 타입 ('module' 또는 'plugin')
|
||||
* @param string $identifier 확장 식별자
|
||||
* @param string $reason 에러 사유
|
||||
* @return string 포맷된 에러 메시지
|
||||
*/
|
||||
private function formatSeoValidationError(string $extensionType, string $identifier, string $reason): string
|
||||
{
|
||||
$translationKey = $extensionType === 'module'
|
||||
? 'modules.errors.seo_variable_conflict'
|
||||
: 'plugins.errors.seo_variable_conflict';
|
||||
|
||||
return __($translationKey, [
|
||||
'identifier' => $identifier,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장 자체 내에서 _common과 page_type별 변수명 충돌을 검증합니다.
|
||||
*
|
||||
* @param array $vars SEO 변수 정의
|
||||
* @return array 에러 메시지 배열
|
||||
*/
|
||||
private function validateSelfSeoVariableConflicts(array $vars): array
|
||||
{
|
||||
$errors = [];
|
||||
$commonVarNames = array_keys($vars['_common'] ?? []);
|
||||
|
||||
foreach ($vars as $pageType => $pageVars) {
|
||||
if ($pageType === '_common' || ! is_array($pageVars)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pageVarNames = array_keys($pageVars);
|
||||
$conflicts = array_intersect($commonVarNames, $pageVarNames);
|
||||
|
||||
if (! empty($conflicts)) {
|
||||
$errors[] = "SEO variable name conflict: _common variables [".implode(', ', $conflicts)."] duplicated in page_type '{$pageType}'";
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 활성 확장들의 SEO 변수를 수집합니다.
|
||||
*
|
||||
* @param string $excludeIdentifier 제외할 확장 식별자 (현재 설치 중인 확장)
|
||||
* @return array ['_common' => [변수명 => 소유자], 'page_type' => [변수명 => 소유자]]
|
||||
*/
|
||||
private function collectExistingSeoVariables(string $excludeIdentifier): array
|
||||
{
|
||||
$collected = ['_common' => []];
|
||||
|
||||
// 활성 모듈들의 SEO 변수 수집
|
||||
try {
|
||||
$moduleManager = app(ModuleManagerInterface::class);
|
||||
foreach ($moduleManager->getActiveModules() as $module) {
|
||||
if ($module->getIdentifier() === $excludeIdentifier) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->mergeExtensionSeoVars($collected, $module->seoVariables(), $module->getIdentifier());
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::debug('[SEO] Failed to collect module SEO variables', ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
// 활성 플러그인들의 SEO 변수 수집
|
||||
try {
|
||||
$pluginManager = app(PluginManagerInterface::class);
|
||||
foreach ($pluginManager->getActivePlugins() as $plugin) {
|
||||
if ($plugin->getIdentifier() === $excludeIdentifier) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->mergeExtensionSeoVars($collected, $plugin->seoVariables(), $plugin->getIdentifier());
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::debug('[SEO] Failed to collect plugin SEO variables', ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return $collected;
|
||||
}
|
||||
|
||||
/**
|
||||
* 확장의 SEO 변수를 수집 결과에 병합합니다.
|
||||
*
|
||||
* @param array &$collected 수집 결과 (참조)
|
||||
* @param array $vars 확장의 seoVariables() 반환값
|
||||
* @param string $owner 소유자 식별자
|
||||
*/
|
||||
private function mergeExtensionSeoVars(array &$collected, array $vars, string $owner): void
|
||||
{
|
||||
foreach ($vars as $pageType => $pageVars) {
|
||||
if (! is_array($pageVars)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! isset($collected[$pageType])) {
|
||||
$collected[$pageType] = [];
|
||||
}
|
||||
|
||||
foreach (array_keys($pageVars) as $varName) {
|
||||
$collected[$pageType][$varName] = $owner;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 확장의 변수가 기존 확장과 충돌하는지 검증합니다.
|
||||
*
|
||||
* @param array $newVars 새 확장의 SEO 변수
|
||||
* @param array $existingVars 기존 활성 확장들의 변수 맵
|
||||
* @return array 에러 메시지 배열
|
||||
*/
|
||||
private function validateCrossSeoVariableConflicts(array $newVars, array $existingVars): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
foreach ($newVars as $pageType => $pageVars) {
|
||||
if (! is_array($pageVars)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (array_keys($pageVars) as $varName) {
|
||||
// 동일 pageType 내 중복 확인
|
||||
if (isset($existingVars[$pageType][$varName])) {
|
||||
$owner = $existingVars[$pageType][$varName];
|
||||
$errors[] = "Variable '{$varName}' in '{$pageType}' conflicts with {$owner}";
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// 새 _common 변수가 기존 page_type별 변수와 충돌 확인
|
||||
if ($pageType === '_common') {
|
||||
foreach ($existingVars as $existPageType => $existPageVars) {
|
||||
if ($existPageType === '_common') {
|
||||
continue;
|
||||
}
|
||||
if (isset($existPageVars[$varName])) {
|
||||
$owner = $existPageVars[$varName];
|
||||
$errors[] = "Common variable '{$varName}' conflicts with '{$existPageType}' variable of {$owner}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 새 page_type별 변수가 기존 _common 변수와 충돌 확인
|
||||
if ($pageType !== '_common' && isset($existingVars['_common'][$varName])) {
|
||||
$owner = $existingVars['_common'][$varName];
|
||||
$errors[] = "Variable '{$varName}' in '{$pageType}' conflicts with _common variable of {$owner}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
}
|
||||
@@ -80,4 +80,32 @@ class UpgradeContext
|
||||
currentStep: $stepVersion,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 업그레이드 스텝의 spawn 프로세스(proc_open)에 전달할 환경변수 기본 세트를 반환합니다.
|
||||
*
|
||||
* 코어 업그레이드의 `updateVersionInEnv()` 는 Step 11 (마지막) 에서 실행되므로,
|
||||
* Step 10 의 upgrade step / spawn 은 디스크 `.env` 의 `APP_VERSION` 이 여전히
|
||||
* `fromVersion` 상태에서 부팅된다. 이 때 `CoreServiceProvider::boot()` 의
|
||||
* `validateAndDeactivateIncompatibleExtensions` 가 확장 manifest 의 요구 버전과
|
||||
* 구 APP_VERSION 을 비교하여 전 확장을 자동 비활성화하는 회귀가 발생한다.
|
||||
*
|
||||
* 본 헬퍼는 `APP_VERSION` 을 `toVersion` 으로 명시 전달해 spawn 자식이 새 버전 기준으로
|
||||
* 호환성 판정을 수행하도록 보장한다. 반환값을 `proc_open` 의 `$env` 인자로 그대로 넘기거나
|
||||
* `array_merge` 로 확장 전용 env 와 합쳐 사용한다.
|
||||
*
|
||||
* 추가 키를 병합할 때 예시:
|
||||
* $env = $context->spawnEnv(['MY_FLAG' => '1']);
|
||||
*
|
||||
* @param array<string, string> $additional 병합할 추가 env 쌍 (APP_VERSION 보다 나중에 적용되어 덮어쓸 수 있음)
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function spawnEnv(array $additional = []): array
|
||||
{
|
||||
return array_merge(
|
||||
$_ENV,
|
||||
['APP_VERSION' => $this->toVersion],
|
||||
$additional,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Vendor;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 런타임 환경에서 composer 실행 가능 여부 및 ZipArchive 사용 가능 여부를 감지합니다.
|
||||
*
|
||||
* 공유 호스팅 환경(proc_open 차단, composer 미설치)에서 vendor 번들 모드로
|
||||
* 자동 폴백하기 위한 판단 근거를 제공합니다.
|
||||
*/
|
||||
class EnvironmentDetector
|
||||
{
|
||||
/**
|
||||
* 캐시된 composer 실행 가능 여부.
|
||||
*/
|
||||
private ?bool $cachedComposerExecutable = null;
|
||||
|
||||
/**
|
||||
* 캐시된 composer 바이너리 경로.
|
||||
*/
|
||||
private ?string $cachedComposerBinary = null;
|
||||
|
||||
/**
|
||||
* proc_open() 함수 사용 가능 여부.
|
||||
*/
|
||||
public function hasProcOpen(): bool
|
||||
{
|
||||
if (! function_exists('proc_open')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$disabled = array_map('trim', explode(',', (string) ini_get('disable_functions')));
|
||||
|
||||
return ! in_array('proc_open', $disabled, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* shell_exec() 함수 사용 가능 여부.
|
||||
*/
|
||||
public function hasShellExec(): bool
|
||||
{
|
||||
if (! function_exists('shell_exec')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$disabled = array_map('trim', explode(',', (string) ini_get('disable_functions')));
|
||||
|
||||
return ! in_array('shell_exec', $disabled, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* ZipArchive 클래스 사용 가능 여부.
|
||||
*/
|
||||
public function hasZipArchive(): bool
|
||||
{
|
||||
return class_exists(\ZipArchive::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* composer 바이너리 경로 찾기.
|
||||
*
|
||||
* 우선순위: hint → config(process.composer_binary) → $_ENV['COMPOSER_BINARY']
|
||||
* → PATH 검색 → composer.phar
|
||||
*/
|
||||
public function findComposerBinary(?string $hint = null): ?string
|
||||
{
|
||||
if ($this->cachedComposerBinary !== null) {
|
||||
return $this->cachedComposerBinary ?: null;
|
||||
}
|
||||
|
||||
$candidates = array_filter([
|
||||
$hint,
|
||||
config('process.composer_binary'),
|
||||
$_ENV['COMPOSER_BINARY'] ?? null,
|
||||
getenv('COMPOSER_BINARY') ?: null,
|
||||
]);
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if ($this->isExecutableCandidate($candidate)) {
|
||||
return $this->cachedComposerBinary = $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
$found = $this->searchComposerInPath();
|
||||
if ($found !== null) {
|
||||
return $this->cachedComposerBinary = $found;
|
||||
}
|
||||
|
||||
// composer.phar 폴백
|
||||
$pharCandidates = [
|
||||
base_path('composer.phar'),
|
||||
getcwd().DIRECTORY_SEPARATOR.'composer.phar',
|
||||
];
|
||||
foreach ($pharCandidates as $phar) {
|
||||
if (is_file($phar)) {
|
||||
return $this->cachedComposerBinary = $phar;
|
||||
}
|
||||
}
|
||||
|
||||
$this->cachedComposerBinary = '';
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* composer 실행 가능 여부 종합 판단.
|
||||
*
|
||||
* proc_open 사용 가능 + composer 바이너리 발견 + `composer --version` 종료 코드 0.
|
||||
*/
|
||||
public function canExecuteComposer(?string $hint = null): bool
|
||||
{
|
||||
if ($this->cachedComposerExecutable !== null && $hint === null) {
|
||||
return $this->cachedComposerExecutable;
|
||||
}
|
||||
|
||||
if (! $this->hasProcOpen()) {
|
||||
return $this->cachedComposerExecutable = false;
|
||||
}
|
||||
|
||||
$binary = $this->findComposerBinary($hint);
|
||||
if ($binary === null) {
|
||||
return $this->cachedComposerExecutable = false;
|
||||
}
|
||||
|
||||
try {
|
||||
$command = $this->buildComposerCommand($binary, ['--version', '--no-interaction']);
|
||||
$process = @proc_open(
|
||||
$command,
|
||||
[
|
||||
0 => ['pipe', 'r'],
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
],
|
||||
$pipes,
|
||||
null,
|
||||
null,
|
||||
['bypass_shell' => true]
|
||||
);
|
||||
|
||||
if (! is_resource($process)) {
|
||||
return $this->cachedComposerExecutable = false;
|
||||
}
|
||||
|
||||
foreach ($pipes as $pipe) {
|
||||
if (is_resource($pipe)) {
|
||||
@fclose($pipe);
|
||||
}
|
||||
}
|
||||
$exit = proc_close($process);
|
||||
|
||||
return $this->cachedComposerExecutable = ($exit === 0);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Composer 실행 가능 여부 확인 실패', [
|
||||
'error' => $e->getMessage(),
|
||||
'binary' => $binary,
|
||||
]);
|
||||
|
||||
return $this->cachedComposerExecutable = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 지정 경로에 쓰기 가능한지 확인.
|
||||
*/
|
||||
public function canWriteVendor(string $targetPath): bool
|
||||
{
|
||||
$parent = dirname($targetPath);
|
||||
|
||||
return is_dir($parent) && is_writable($parent);
|
||||
}
|
||||
|
||||
/**
|
||||
* 인스톨러 Step 2 요구사항 체크용 종합 리포트.
|
||||
*
|
||||
* @return array{
|
||||
* proc_open: bool,
|
||||
* shell_exec: bool,
|
||||
* zip_archive: bool,
|
||||
* composer_binary: string|null,
|
||||
* composer_executable: bool,
|
||||
* can_use_composer: bool,
|
||||
* can_use_bundle: bool,
|
||||
* }
|
||||
*/
|
||||
public function summarize(?string $hint = null): array
|
||||
{
|
||||
$composerBinary = $this->findComposerBinary($hint);
|
||||
$composerExecutable = $this->canExecuteComposer($hint);
|
||||
$zipAvailable = $this->hasZipArchive();
|
||||
|
||||
return [
|
||||
'proc_open' => $this->hasProcOpen(),
|
||||
'shell_exec' => $this->hasShellExec(),
|
||||
'zip_archive' => $zipAvailable,
|
||||
'composer_binary' => $composerBinary,
|
||||
'composer_executable' => $composerExecutable,
|
||||
'can_use_composer' => $composerExecutable,
|
||||
'can_use_bundle' => $zipAvailable,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 캐시 초기화 (테스트용).
|
||||
*/
|
||||
public function resetCache(): void
|
||||
{
|
||||
$this->cachedComposerExecutable = null;
|
||||
$this->cachedComposerBinary = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* composer 실행 명령 문자열 구성.
|
||||
*
|
||||
* @param array<int, string> $args
|
||||
*/
|
||||
private function buildComposerCommand(string $binary, array $args): string
|
||||
{
|
||||
$escaped = array_map('escapeshellarg', $args);
|
||||
|
||||
if (str_contains($binary, ' ')) {
|
||||
// 전체 명령어로 취급
|
||||
return $binary.' '.implode(' ', $escaped);
|
||||
}
|
||||
|
||||
if (str_ends_with(strtolower($binary), '.phar')) {
|
||||
$phpBinary = config('process.php_binary', 'php');
|
||||
|
||||
return escapeshellarg($phpBinary).' '.escapeshellarg($binary).' '.implode(' ', $escaped);
|
||||
}
|
||||
|
||||
return escapeshellarg($binary).' '.implode(' ', $escaped);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATH 환경변수에서 composer 검색.
|
||||
*/
|
||||
private function searchComposerInPath(): ?string
|
||||
{
|
||||
$pathEnv = getenv('PATH') ?: ($_SERVER['PATH'] ?? '');
|
||||
if (empty($pathEnv)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$separator = DIRECTORY_SEPARATOR === '\\' ? ';' : ':';
|
||||
$paths = explode($separator, $pathEnv);
|
||||
$names = DIRECTORY_SEPARATOR === '\\'
|
||||
? ['composer.bat', 'composer.exe', 'composer.phar', 'composer']
|
||||
: ['composer', 'composer.phar'];
|
||||
|
||||
foreach ($paths as $dir) {
|
||||
foreach ($names as $name) {
|
||||
$candidate = rtrim($dir, '/\\').DIRECTORY_SEPARATOR.$name;
|
||||
if (is_file($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 후보 경로가 실행 가능한 파일인지 확인.
|
||||
*/
|
||||
private function isExecutableCandidate(?string $candidate): bool
|
||||
{
|
||||
if ($candidate === null || $candidate === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 공백 포함 시 전체 커맨드로 간주 — 파일 존재 검사 스킵
|
||||
if (str_contains($candidate, ' ')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return is_file($candidate);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Vendor\Exceptions;
|
||||
|
||||
/**
|
||||
* Vendor 설치 실패 예외.
|
||||
*
|
||||
* 에러 키는 lang/{locale}/exceptions.php 의 vendor.* 하위에 정의되어 있음.
|
||||
* 예: exceptions.vendor.composer_not_available
|
||||
*/
|
||||
class VendorInstallException extends \RuntimeException
|
||||
{
|
||||
/**
|
||||
* @param string $errorKey 'exceptions.vendor.*' 하위 키 (예: 'composer_not_available')
|
||||
* @param array<string, mixed> $context 다국어 파라미터 치환 컨텍스트
|
||||
* @param \Throwable|null $previous 이전 예외
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $errorKey,
|
||||
public readonly array $context = [],
|
||||
?\Throwable $previous = null,
|
||||
) {
|
||||
$message = __('exceptions.vendor.'.$errorKey, $context);
|
||||
if (is_array($message)) {
|
||||
$message = 'exceptions.vendor.'.$errorKey;
|
||||
}
|
||||
|
||||
// 권한 거부 등 진단 힌트가 있으면 메시지에 덧붙여 사용자에게 즉시 노출
|
||||
if (isset($context['hint'])) {
|
||||
$message = (string) $message.' — '.$context['hint'];
|
||||
}
|
||||
|
||||
parent::__construct((string) $message, 0, $previous);
|
||||
}
|
||||
|
||||
public function getErrorKey(): string
|
||||
{
|
||||
return $this->errorKey;
|
||||
}
|
||||
|
||||
public function getContext(): array
|
||||
{
|
||||
return $this->context;
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Vendor;
|
||||
|
||||
/**
|
||||
* Vendor 번들 무결성 검증 결과.
|
||||
*/
|
||||
class IntegrityResult
|
||||
{
|
||||
/**
|
||||
* @param bool $valid 모든 검증 통과 여부
|
||||
* @param array<string> $errors 검증 실패 키 목록 (다국어 키)
|
||||
* @param array<string> $warnings 경고 목록 (valid=true여도 존재 가능)
|
||||
* @param array<string, mixed> $meta manifest에서 읽은 메타정보
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly bool $valid,
|
||||
public readonly array $errors = [],
|
||||
public readonly array $warnings = [],
|
||||
public readonly array $meta = [],
|
||||
) {}
|
||||
|
||||
public static function valid(array $meta = [], array $warnings = []): self
|
||||
{
|
||||
return new self(valid: true, errors: [], warnings: $warnings, meta: $meta);
|
||||
}
|
||||
|
||||
public static function invalid(array $errors, array $meta = []): self
|
||||
{
|
||||
return new self(valid: false, errors: $errors, warnings: [], meta: $meta);
|
||||
}
|
||||
|
||||
public function errorMessages(): array
|
||||
{
|
||||
return array_map(
|
||||
fn (string $key) => __('exceptions.vendor.'.$key),
|
||||
$this->errors
|
||||
);
|
||||
}
|
||||
}
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Vendor;
|
||||
|
||||
use App\Extension\Vendor\Exceptions\VendorInstallException;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* vendor-bundle.zip 을 대상 디렉토리로 추출하여 vendor/를 구성합니다.
|
||||
*
|
||||
* 공유 호스팅 친화적 설계:
|
||||
* - targetDir 자체(프로젝트 루트)의 쓰기 권한은 요구하지 않음
|
||||
* - vendor/ 디렉토리 자체는 유지하고 **내부 항목만 조작**
|
||||
* - 기존 내용은 vendor/.bundle_backup_{ts}/ 로 이동 (vendor/ 쓰기 권한만으로 가능)
|
||||
* - 추출 실패 시 백업에서 복원, 성공 시 백업 삭제
|
||||
*
|
||||
* 이 접근은 웹 서버 사용자가 프로젝트 루트에는 쓰기 권한이 없지만
|
||||
* vendor/ 디렉토리에만 권한이 부여된 전형적 공유 호스팅 환경을 지원합니다.
|
||||
*/
|
||||
class VendorBundleInstaller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly VendorIntegrityChecker $integrityChecker,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 번들 zip을 대상 디렉토리에 추출합니다.
|
||||
*
|
||||
* @param string $sourceDir vendor-bundle.zip 이 위치한 디렉토리
|
||||
* @param string $targetDir vendor/가 배치될 디렉토리 (일반적으로 sourceDir과 동일)
|
||||
*
|
||||
* @throws VendorInstallException
|
||||
*/
|
||||
public function install(string $sourceDir, string $targetDir): VendorInstallResult
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
|
||||
// 1. ZipArchive 확장 확인
|
||||
if (! class_exists(\ZipArchive::class)) {
|
||||
throw new VendorInstallException('zip_archive_not_available');
|
||||
}
|
||||
|
||||
// 2. 무결성 검증
|
||||
$integrity = $this->integrityChecker->verify($sourceDir);
|
||||
if (! $integrity->valid) {
|
||||
throw new VendorInstallException(
|
||||
errorKey: 'bundle_integrity_failed',
|
||||
context: ['details' => implode(', ', $integrity->errorMessages())],
|
||||
);
|
||||
}
|
||||
|
||||
$zipPath = $sourceDir.DIRECTORY_SEPARATOR.VendorIntegrityChecker::ZIP_FILENAME;
|
||||
$vendorDir = $targetDir.DIRECTORY_SEPARATOR.'vendor';
|
||||
$backupDirName = '.bundle_backup_'.date('Ymd_His');
|
||||
$backupDir = $vendorDir.DIRECTORY_SEPARATOR.$backupDirName;
|
||||
|
||||
// 3. 쓰기 권한 유연 검사 — vendor/ 가 있으면 vendor/ 권한만, 없으면 targetDir 권한 필요
|
||||
$this->validateTargetWritable($targetDir, $vendorDir);
|
||||
|
||||
// 4. zip slip 방지 사전 검증
|
||||
$this->validateZipContents($zipPath);
|
||||
|
||||
// 5. 기존 vendor/ 내부 항목을 vendor/.bundle_backup_{ts}/ 로 이동
|
||||
// (vendor/ 디렉토리 자체는 유지 — targetDir 쓰기 권한 불필요)
|
||||
$hasBackup = false;
|
||||
if (is_dir($vendorDir)) {
|
||||
$hasBackup = $this->moveVendorContentsToBackup($vendorDir, $backupDir, $backupDirName);
|
||||
} else {
|
||||
// vendor/ 없음 — 신규 생성 (targetDir 쓰기 권한 필요, 위에서 이미 검증)
|
||||
if (! @mkdir($vendorDir, 0755, true) && ! is_dir($vendorDir)) {
|
||||
throw new VendorInstallException(
|
||||
errorKey: 'target_not_writable',
|
||||
context: ['path' => $vendorDir],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 추출
|
||||
try {
|
||||
$zip = new \ZipArchive;
|
||||
$openResult = $zip->open($zipPath);
|
||||
if ($openResult !== true) {
|
||||
throw new VendorInstallException(
|
||||
errorKey: 'extraction_failed',
|
||||
context: ['message' => 'ZipArchive::open failed (code: '.$openResult.')'],
|
||||
);
|
||||
}
|
||||
|
||||
$extracted = $zip->extractTo($targetDir);
|
||||
$zip->close();
|
||||
|
||||
if (! $extracted) {
|
||||
throw new VendorInstallException(
|
||||
errorKey: 'extraction_failed',
|
||||
context: ['message' => 'ZipArchive::extractTo returned false'],
|
||||
);
|
||||
}
|
||||
|
||||
// 7. 성공: 백업 디렉토리 삭제 (vendor/.bundle_backup_{ts}/)
|
||||
if ($hasBackup && is_dir($backupDir)) {
|
||||
File::deleteDirectory($backupDir);
|
||||
}
|
||||
|
||||
$duration = microtime(true) - $startTime;
|
||||
$packageCount = (int) ($integrity->meta['package_count'] ?? 0);
|
||||
$zipSize = @filesize($zipPath) ?: 0;
|
||||
|
||||
Log::info('Vendor 번들 추출 성공', [
|
||||
'source_dir' => $sourceDir,
|
||||
'target_dir' => $targetDir,
|
||||
'package_count' => $packageCount,
|
||||
'zip_size' => $zipSize,
|
||||
'duration_seconds' => round($duration, 2),
|
||||
]);
|
||||
|
||||
return new VendorInstallResult(
|
||||
mode: VendorMode::Bundled,
|
||||
strategy: 'bundled',
|
||||
packageCount: $packageCount,
|
||||
durationSeconds: $duration,
|
||||
details: [
|
||||
'zip_size' => $zipSize,
|
||||
'source_dir' => $sourceDir,
|
||||
],
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
// 실패: 백업에서 복원
|
||||
if ($hasBackup && is_dir($backupDir)) {
|
||||
$this->restoreVendorFromBackup($vendorDir, $backupDir, $backupDirName);
|
||||
}
|
||||
|
||||
if ($e instanceof VendorInstallException) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
throw new VendorInstallException(
|
||||
errorKey: 'extraction_failed',
|
||||
context: ['message' => $e->getMessage()],
|
||||
previous: $e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 쓰기 권한 검사 — vendor/ 가 존재하면 vendor/ 권한만, 없으면 targetDir 권한 필요.
|
||||
*
|
||||
* 공유 호스팅 환경에서 targetDir (프로젝트 루트) 에는 쓰기 권한이 없고
|
||||
* vendor/ 에만 권한이 부여된 케이스를 지원한다.
|
||||
*
|
||||
* @throws VendorInstallException
|
||||
*/
|
||||
private function validateTargetWritable(string $targetDir, string $vendorDir): void
|
||||
{
|
||||
if (is_dir($vendorDir)) {
|
||||
if (! is_writable($vendorDir)) {
|
||||
throw new VendorInstallException(
|
||||
errorKey: 'target_not_writable',
|
||||
context: $this->buildPermissionContext($vendorDir),
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// vendor/ 없음 — 생성을 위해 targetDir 에 쓰기 권한 필요
|
||||
if (! is_dir($targetDir) || ! is_writable($targetDir)) {
|
||||
throw new VendorInstallException(
|
||||
errorKey: 'target_not_writable',
|
||||
context: $this->buildPermissionContext($targetDir),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 권한 거부 컨텍스트에 소유권 정보를 포함합니다.
|
||||
*
|
||||
* 공유 호스팅에서 FTP 사용자와 PHP 실행 사용자가 다른 경우 진단을 돕기 위해
|
||||
* 현재 실행 사용자, 디렉토리 소유자, 권장 조치를 함께 반환합니다.
|
||||
*/
|
||||
private function buildPermissionContext(string $path): array
|
||||
{
|
||||
$context = ['path' => $path];
|
||||
|
||||
if (! function_exists('posix_geteuid') || ! function_exists('posix_getpwuid')) {
|
||||
return $context;
|
||||
}
|
||||
|
||||
$currentUid = posix_geteuid();
|
||||
$currentUser = posix_getpwuid($currentUid)['name'] ?? (string) $currentUid;
|
||||
$context['current_user'] = "{$currentUser}({$currentUid})";
|
||||
|
||||
if (file_exists($path)) {
|
||||
$ownerUid = fileowner($path);
|
||||
$ownerUser = posix_getpwuid($ownerUid)['name'] ?? (string) $ownerUid;
|
||||
$context['owner'] = "{$ownerUser}({$ownerUid})";
|
||||
|
||||
if ($currentUid !== $ownerUid) {
|
||||
$context['hint'] = __('exceptions.vendor.target_not_writable_owner_hint', [
|
||||
'path' => $path,
|
||||
'current_user' => $context['current_user'],
|
||||
'current_user_name' => $currentUser,
|
||||
'owner' => $context['owner'],
|
||||
'owner_name' => $ownerUser,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 vendor/ 내부 항목을 vendor/.bundle_backup_{ts}/ 로 이동합니다.
|
||||
*
|
||||
* vendor/ 디렉토리 자체는 유지하므로 targetDir 쓰기 권한이 필요하지 않습니다.
|
||||
*
|
||||
* @return bool 백업 성공 여부 (false 시 in-place overwrite 로 추출 진행)
|
||||
*/
|
||||
private function moveVendorContentsToBackup(string $vendorDir, string $backupDir, string $backupDirName): bool
|
||||
{
|
||||
if (! @mkdir($backupDir) && ! is_dir($backupDir)) {
|
||||
Log::warning('Vendor 번들 백업 디렉토리 생성 실패 — in-place overwrite 로 진행', [
|
||||
'vendor_dir' => $vendorDir,
|
||||
'backup_dir' => $backupDir,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$items = @scandir($vendorDir) ?: [];
|
||||
foreach ($items as $item) {
|
||||
if ($item === '.' || $item === '..' || $item === $backupDirName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$src = $vendorDir.DIRECTORY_SEPARATOR.$item;
|
||||
$dst = $backupDir.DIRECTORY_SEPARATOR.$item;
|
||||
@rename($src, $dst);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 백업 디렉토리에서 vendor/ 로 내용을 복원합니다.
|
||||
*
|
||||
* 추출 중 실패한 경우 기존 상태로 되돌리기 위해 호출됩니다.
|
||||
*/
|
||||
private function restoreVendorFromBackup(string $vendorDir, string $backupDir, string $backupDirName): void
|
||||
{
|
||||
// 1. 추출로 이미 생성된 파일/디렉토리 정리 (백업 폴더 제외)
|
||||
$items = @scandir($vendorDir) ?: [];
|
||||
foreach ($items as $item) {
|
||||
if ($item === '.' || $item === '..' || $item === $backupDirName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = $vendorDir.DIRECTORY_SEPARATOR.$item;
|
||||
if (is_dir($path) && ! is_link($path)) {
|
||||
File::deleteDirectory($path);
|
||||
} elseif (file_exists($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 백업 항목을 원위치로 이동
|
||||
$backupItems = @scandir($backupDir) ?: [];
|
||||
foreach ($backupItems as $item) {
|
||||
if ($item === '.' || $item === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$src = $backupDir.DIRECTORY_SEPARATOR.$item;
|
||||
$dst = $vendorDir.DIRECTORY_SEPARATOR.$item;
|
||||
@rename($src, $dst);
|
||||
}
|
||||
|
||||
// 3. 빈 백업 디렉토리 제거
|
||||
@rmdir($backupDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* zip 내부 파일 경로의 안전성을 검증합니다 (zip slip 방지).
|
||||
*
|
||||
* @throws VendorInstallException
|
||||
*/
|
||||
private function validateZipContents(string $zipPath): void
|
||||
{
|
||||
$zip = new \ZipArchive;
|
||||
if ($zip->open($zipPath) !== true) {
|
||||
throw new VendorInstallException(
|
||||
errorKey: 'extraction_failed',
|
||||
context: ['message' => 'cannot open zip for validation'],
|
||||
);
|
||||
}
|
||||
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$name = $zip->getNameIndex($i);
|
||||
if ($name === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 경로 탈출 방지
|
||||
$normalized = str_replace('\\', '/', $name);
|
||||
if (
|
||||
str_contains($normalized, '../')
|
||||
|| str_starts_with($normalized, '/')
|
||||
|| preg_match('#^[A-Za-z]:/#', $normalized)
|
||||
) {
|
||||
$zip->close();
|
||||
throw new VendorInstallException(
|
||||
errorKey: 'bundle_contains_unsafe_path',
|
||||
context: ['path' => $name],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Vendor;
|
||||
|
||||
/**
|
||||
* Vendor 번들 빌드 결과 DTO.
|
||||
*/
|
||||
class VendorBundleResult
|
||||
{
|
||||
/**
|
||||
* @param string $target 'core' | 'module:identifier' | 'plugin:identifier'
|
||||
* @param string $zipPath 생성된 zip 파일 절대 경로
|
||||
* @param string $manifestPath 생성된 vendor-bundle.json 절대 경로
|
||||
* @param int $zipSize zip 파일 크기 (bytes)
|
||||
* @param int $packageCount 번들에 포함된 composer 패키지 수
|
||||
* @param bool $skipped 이미 최신 상태여서 빌드 스킵 여부
|
||||
* @param string $reason 스킵 사유 또는 빌드 메시지
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $target,
|
||||
public readonly string $zipPath,
|
||||
public readonly string $manifestPath,
|
||||
public readonly int $zipSize,
|
||||
public readonly int $packageCount,
|
||||
public readonly bool $skipped = false,
|
||||
public readonly string $reason = '',
|
||||
) {}
|
||||
|
||||
public function zipSizeHuman(): string
|
||||
{
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
$size = $this->zipSize;
|
||||
$unit = 0;
|
||||
while ($size >= 1024 && $unit < count($units) - 1) {
|
||||
$size /= 1024;
|
||||
$unit++;
|
||||
}
|
||||
|
||||
return sprintf('%.1f %s', $size, $units[$unit]);
|
||||
}
|
||||
}
|
||||
+592
@@ -0,0 +1,592 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Vendor;
|
||||
|
||||
use App\Extension\Vendor\Exceptions\VendorInstallException;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 개발 환경에서 vendor/ 디렉토리를 zip으로 압축하여 번들 파일을 생성합니다.
|
||||
*
|
||||
* 빌드 타임에만 실행되며, 생성된 번들은 런타임에 VendorBundleInstaller가 소비합니다.
|
||||
*
|
||||
* 빌드 절차 (staging 기반):
|
||||
* 1. storage/app/vendor-bundle-staging/{uniqid}/ 임시 디렉토리 생성
|
||||
* 2. 소스의 composer.json + composer.lock 을 임시 디렉토리로 복사
|
||||
* 3. 임시 디렉토리에서 `composer install --no-dev --no-scripts --prefer-dist` 실행
|
||||
* 4. 생성된 vendor/ 를 (EXCLUDE_PATTERNS 적용 후) zip 으로 기록
|
||||
* 5. manifest.json 기록
|
||||
* 6. 임시 디렉토리 정리
|
||||
*
|
||||
* 이 방식은 개발 vendor/ 가 dev 의존성을 포함하고 있어도 번들에 dev 의존성이
|
||||
* 섞여 들어가지 않도록 보장하며, composer 가 생성한 autoload 파일과 vendor/
|
||||
* 내용이 완벽히 일치함을 보장합니다 (autoload_files.php 가 존재하지 않는
|
||||
* dev 패키지를 require 하는 버그 방지).
|
||||
*/
|
||||
class VendorBundler
|
||||
{
|
||||
public const SCHEMA_VERSION = '1.0';
|
||||
|
||||
/**
|
||||
* 압축 제외 경로 패턴 (패키지 디렉토리 내부 파일).
|
||||
*
|
||||
* @var array<int, string>
|
||||
*/
|
||||
private const EXCLUDE_PATTERNS = [
|
||||
'.git',
|
||||
'.github',
|
||||
'tests',
|
||||
'Tests',
|
||||
'docs',
|
||||
'.gitignore',
|
||||
'.gitattributes',
|
||||
'.gitkeep',
|
||||
'phpunit.xml',
|
||||
'phpunit.xml.dist',
|
||||
'psalm.xml',
|
||||
'psalm.xml.dist',
|
||||
'.phpcs.xml',
|
||||
'.phpcs.xml.dist',
|
||||
];
|
||||
|
||||
/**
|
||||
* 테스트에서 composer 실행을 대체하기 위한 주입 가능한 러너.
|
||||
*
|
||||
* null 이면 실제 composer 바이너리를 proc_open 으로 실행합니다.
|
||||
* 테스트에서는 setComposerInstallRunner() 로 주입하여 가짜 vendor/ 구조를 생성합니다.
|
||||
*/
|
||||
private ?\Closure $composerInstallRunner = null;
|
||||
|
||||
public function __construct(
|
||||
private readonly VendorIntegrityChecker $integrityChecker,
|
||||
private readonly ?EnvironmentDetector $environmentDetector = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 테스트용 — composer 실행을 대체할 러너를 주입합니다.
|
||||
*
|
||||
* 러너는 `fn (string $stagingDir): void` 형식이며, 주어진 스테이징 디렉토리에
|
||||
* vendor/ 구조를 직접 생성해야 합니다.
|
||||
*/
|
||||
public function setComposerInstallRunner(?\Closure $runner): void
|
||||
{
|
||||
$this->composerInstallRunner = $runner;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코어(루트) vendor/를 번들링합니다.
|
||||
*
|
||||
* 코어는 소스 = 출력 경로가 base_path() 로 동일합니다.
|
||||
*/
|
||||
public function buildForCore(bool $force = false): VendorBundleResult
|
||||
{
|
||||
return $this->build(
|
||||
sourcePath: base_path(),
|
||||
outputPath: base_path(),
|
||||
target: 'core',
|
||||
force: $force,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈/플러그인의 vendor/를 번들링합니다.
|
||||
*
|
||||
* 소스는 **활성 디렉토리** (예: modules/sirsoft-ecommerce/vendor/) 에서 읽고,
|
||||
* 출력은 **_bundled 디렉토리** (예: modules/_bundled/sirsoft-ecommerce/vendor-bundle.zip) 에 저장합니다.
|
||||
*
|
||||
* 이유: _bundled 는 Git 추적 소스 디렉토리로 vendor/ 를 두지 않는 것이 원칙.
|
||||
* 활성 디렉토리는 설치 시 composer install 이 실행되어 실제 패키지가 설치된 상태.
|
||||
*
|
||||
* @param string $type 'module' | 'plugin'
|
||||
*/
|
||||
public function buildForExtension(string $type, string $identifier, bool $force = false): VendorBundleResult
|
||||
{
|
||||
$activePath = match ($type) {
|
||||
'module' => base_path('modules/'.$identifier),
|
||||
'plugin' => base_path('plugins/'.$identifier),
|
||||
default => throw new \InvalidArgumentException("Unsupported extension type: $type"),
|
||||
};
|
||||
|
||||
$outputPath = match ($type) {
|
||||
'module' => base_path('modules/_bundled/'.$identifier),
|
||||
'plugin' => base_path('plugins/_bundled/'.$identifier),
|
||||
};
|
||||
|
||||
if (! is_dir($outputPath)) {
|
||||
throw new VendorInstallException(
|
||||
errorKey: 'source_dir_not_found',
|
||||
context: ['path' => $outputPath],
|
||||
);
|
||||
}
|
||||
|
||||
// 활성 디렉토리가 없으면 미설치 확장 — skip 처리 (예외 대신)
|
||||
//
|
||||
// 미설치 확장의 경우 composer.json 은 _bundled 에만 존재하므로
|
||||
// 외부 의존성 여부를 _bundled 의 composer.json 에서 확인한다.
|
||||
if (! is_dir($activePath)) {
|
||||
$bundledComposerJson = $outputPath.DIRECTORY_SEPARATOR.'composer.json';
|
||||
$reason = file_exists($bundledComposerJson) && $this->hasExternalDependencies($bundledComposerJson)
|
||||
? 'extension-not-installed'
|
||||
: 'no-external-dependencies';
|
||||
|
||||
return new VendorBundleResult(
|
||||
target: $type.':'.$identifier,
|
||||
zipPath: $outputPath.DIRECTORY_SEPARATOR.VendorIntegrityChecker::ZIP_FILENAME,
|
||||
manifestPath: $outputPath.DIRECTORY_SEPARATOR.VendorIntegrityChecker::MANIFEST_FILENAME,
|
||||
zipSize: 0,
|
||||
packageCount: 0,
|
||||
skipped: true,
|
||||
reason: $reason,
|
||||
);
|
||||
}
|
||||
|
||||
return $this->build(
|
||||
sourcePath: $activePath,
|
||||
outputPath: $outputPath,
|
||||
target: $type.':'.$identifier,
|
||||
force: $force,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* stale 여부 확인 — composer.json/lock 해시가 기존 manifest와 일치하는지 검사.
|
||||
*
|
||||
* 외부 패키지 의존성이 없는 확장은 번들링 대상이 아니므로 항상 false 를 반환합니다.
|
||||
*
|
||||
* @param string $sourcePath composer.json/lock 을 읽을 경로 (활성 디렉토리)
|
||||
* @param string|null $outputPath 번들 출력 경로 (null 이면 sourcePath 와 동일 — 코어 케이스)
|
||||
*/
|
||||
public function isStale(string $sourcePath, ?string $outputPath = null): bool
|
||||
{
|
||||
$outputPath ??= $sourcePath;
|
||||
|
||||
$composerJsonPath = $sourcePath.DIRECTORY_SEPARATOR.'composer.json';
|
||||
if (file_exists($composerJsonPath) && ! $this->hasExternalDependencies($composerJsonPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$manifestPath = $outputPath.DIRECTORY_SEPARATOR.VendorIntegrityChecker::MANIFEST_FILENAME;
|
||||
$zipPath = $outputPath.DIRECTORY_SEPARATOR.VendorIntegrityChecker::ZIP_FILENAME;
|
||||
|
||||
if (! file_exists($manifestPath) || ! file_exists($zipPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$manifest = $this->integrityChecker->readManifest($outputPath);
|
||||
if ($manifest === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (file_exists($composerJsonPath)) {
|
||||
$currentHash = $this->integrityChecker->computeFileHash($composerJsonPath);
|
||||
if (($manifest['composer_json_sha256'] ?? null) !== $currentHash) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
$composerLockPath = $sourcePath.DIRECTORY_SEPARATOR.'composer.lock';
|
||||
if (file_exists($composerLockPath)) {
|
||||
$currentHash = $this->integrityChecker->computeFileHash($composerLockPath);
|
||||
if (($manifest['composer_lock_sha256'] ?? null) !== $currentHash) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 빌드 실행.
|
||||
*
|
||||
* @param string $sourcePath composer.json, composer.lock, vendor/ 를 읽을 경로 (활성 디렉토리)
|
||||
* @param string $outputPath vendor-bundle.zip 과 vendor-bundle.json 을 쓸 경로 (_bundled 또는 코어는 sourcePath 와 동일)
|
||||
* @param string $target 라벨용 대상 식별자 (예: 'core', 'module:sirsoft-ecommerce')
|
||||
* @param bool $force 해시 체크 무시 강제 재빌드
|
||||
*/
|
||||
private function build(string $sourcePath, string $outputPath, string $target, bool $force): VendorBundleResult
|
||||
{
|
||||
// 전제 조건 검증
|
||||
$composerJsonPath = $sourcePath.DIRECTORY_SEPARATOR.'composer.json';
|
||||
if (! file_exists($composerJsonPath)) {
|
||||
throw new VendorInstallException(
|
||||
errorKey: 'composer_json_not_found',
|
||||
context: ['path' => $composerJsonPath],
|
||||
);
|
||||
}
|
||||
|
||||
$zipPath = $outputPath.DIRECTORY_SEPARATOR.VendorIntegrityChecker::ZIP_FILENAME;
|
||||
$manifestPath = $outputPath.DIRECTORY_SEPARATOR.VendorIntegrityChecker::MANIFEST_FILENAME;
|
||||
|
||||
// 실제 외부 패키지 의존성이 없는 확장은 번들링 대상이 아님 — skip 처리
|
||||
// (composer.json 에 `php: ^8.2` 런타임 제약만 있고 패키지 require 가 없는 경우)
|
||||
if (! $this->hasExternalDependencies($composerJsonPath)) {
|
||||
return new VendorBundleResult(
|
||||
target: $target,
|
||||
zipPath: $zipPath,
|
||||
manifestPath: $manifestPath,
|
||||
zipSize: 0,
|
||||
packageCount: 0,
|
||||
skipped: true,
|
||||
reason: 'no-external-dependencies',
|
||||
);
|
||||
}
|
||||
|
||||
// stale 체크
|
||||
if (! $force && ! $this->isStale($sourcePath, $outputPath)) {
|
||||
$existingSize = file_exists($zipPath) ? (int) filesize($zipPath) : 0;
|
||||
$existingManifest = $this->integrityChecker->readManifest($outputPath) ?? [];
|
||||
$existingCount = (int) ($existingManifest['package_count'] ?? 0);
|
||||
|
||||
return new VendorBundleResult(
|
||||
target: $target,
|
||||
zipPath: $zipPath,
|
||||
manifestPath: $manifestPath,
|
||||
zipSize: $existingSize,
|
||||
packageCount: $existingCount,
|
||||
skipped: true,
|
||||
reason: 'up-to-date',
|
||||
);
|
||||
}
|
||||
|
||||
// 기존 파일 제거
|
||||
if (file_exists($zipPath)) {
|
||||
@unlink($zipPath);
|
||||
}
|
||||
if (file_exists($manifestPath)) {
|
||||
@unlink($manifestPath);
|
||||
}
|
||||
|
||||
// === 스테이징 빌드 ===
|
||||
// 개발 머신의 vendor/ 를 직접 사용하지 않고, composer install --no-dev 를
|
||||
// 스테이징 디렉토리에서 새로 실행하여 dev 의존성과 완전히 분리된 번들을 생성한다.
|
||||
$stagingDir = storage_path('app/vendor-bundle-staging/'.uniqid('build-', true));
|
||||
File::ensureDirectoryExists($stagingDir, 0755);
|
||||
|
||||
try {
|
||||
// 1. composer.json + composer.lock 을 스테이징으로 복사
|
||||
File::copy($composerJsonPath, $stagingDir.DIRECTORY_SEPARATOR.'composer.json');
|
||||
$sourceLockPath = $sourcePath.DIRECTORY_SEPARATOR.'composer.lock';
|
||||
if (file_exists($sourceLockPath)) {
|
||||
File::copy($sourceLockPath, $stagingDir.DIRECTORY_SEPARATOR.'composer.lock');
|
||||
}
|
||||
|
||||
// 2. 스테이징에서 composer install --no-dev 실행
|
||||
$this->runComposerInstall($stagingDir, $target);
|
||||
|
||||
// 3. 생성된 vendor/ 검증
|
||||
$stagedVendor = $stagingDir.DIRECTORY_SEPARATOR.'vendor';
|
||||
if (! is_dir($stagedVendor)) {
|
||||
throw new VendorInstallException(
|
||||
errorKey: 'vendor_dir_not_found',
|
||||
context: ['path' => $stagedVendor.' (composer install 후에도 vendor/ 가 생성되지 않음)'],
|
||||
);
|
||||
}
|
||||
|
||||
// 4. zip 생성 (EXCLUDE_PATTERNS 만 적용 — 화이트리스트 불필요)
|
||||
[$zipSize, $fileCount] = $this->writeZip($stagedVendor, $zipPath);
|
||||
|
||||
// 5. manifest 작성 — 스테이징의 composer.lock 을 기준으로 패키지 목록 추출
|
||||
$stagedLockPath = $stagingDir.DIRECTORY_SEPARATOR.'composer.lock';
|
||||
$packages = file_exists($stagedLockPath)
|
||||
? $this->collectPackages($stagedLockPath)
|
||||
: [];
|
||||
|
||||
$manifest = [
|
||||
'schema_version' => self::SCHEMA_VERSION,
|
||||
'generated_at' => date('c'),
|
||||
'generator' => 'g7 vendor-bundle:build',
|
||||
'target' => $target,
|
||||
// manifest 의 해시는 **소스** 의 composer.json/lock 기준 — 런타임에서
|
||||
// 소스 디렉토리(또는 번들과 함께 배포된) composer.json 과 비교하여
|
||||
// stale 여부를 감지한다.
|
||||
'composer_json_sha256' => $this->integrityChecker->computeFileHash($composerJsonPath),
|
||||
'composer_lock_sha256' => file_exists($sourceLockPath)
|
||||
? $this->integrityChecker->computeFileHash($sourceLockPath)
|
||||
: null,
|
||||
'zip_sha256' => $this->integrityChecker->computeFileHash($zipPath),
|
||||
'zip_size' => $zipSize,
|
||||
'package_count' => count($packages),
|
||||
'php_requirement' => $this->extractPhpRequirement($composerJsonPath),
|
||||
'g7_version' => config('app.version'),
|
||||
'packages' => $packages,
|
||||
];
|
||||
|
||||
File::put($manifestPath, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
Log::info('Vendor 번들 빌드 완료', [
|
||||
'target' => $target,
|
||||
'zip_size' => $zipSize,
|
||||
'package_count' => count($packages),
|
||||
'file_count' => $fileCount,
|
||||
]);
|
||||
|
||||
return new VendorBundleResult(
|
||||
target: $target,
|
||||
zipPath: $zipPath,
|
||||
manifestPath: $manifestPath,
|
||||
zipSize: $zipSize,
|
||||
packageCount: count($packages),
|
||||
skipped: false,
|
||||
reason: 'built',
|
||||
);
|
||||
} finally {
|
||||
// 스테이징 정리 (실패/성공 무관)
|
||||
if (File::isDirectory($stagingDir)) {
|
||||
File::deleteDirectory($stagingDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 스테이징 디렉토리에서 `composer install --no-dev` 를 실행합니다.
|
||||
*
|
||||
* 테스트에서는 setComposerInstallRunner() 로 주입된 closure 를 대신 호출합니다.
|
||||
*
|
||||
* @throws VendorInstallException composer 미설치, 실행 실패 시
|
||||
*/
|
||||
private function runComposerInstall(string $stagingDir, string $target): void
|
||||
{
|
||||
// 테스트용 러너 우선
|
||||
if ($this->composerInstallRunner !== null) {
|
||||
($this->composerInstallRunner)($stagingDir);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$detector = $this->environmentDetector ?? app(EnvironmentDetector::class);
|
||||
if (! $detector->canExecuteComposer()) {
|
||||
throw new VendorInstallException(
|
||||
errorKey: 'composer_not_available_for_build',
|
||||
);
|
||||
}
|
||||
|
||||
$binary = $detector->findComposerBinary();
|
||||
if ($binary === null) {
|
||||
throw new VendorInstallException(
|
||||
errorKey: 'composer_not_available_for_build',
|
||||
);
|
||||
}
|
||||
|
||||
$phpBinary = config('process.php_binary', 'php');
|
||||
|
||||
if (str_contains($binary, ' ')) {
|
||||
$composerCmd = $binary;
|
||||
} elseif (str_ends_with(strtolower($binary), '.phar')) {
|
||||
$composerCmd = escapeshellarg($phpBinary).' '.escapeshellarg($binary);
|
||||
} else {
|
||||
$composerCmd = escapeshellarg($binary);
|
||||
}
|
||||
|
||||
$command = $composerCmd.' install --no-dev --no-scripts --prefer-dist --no-interaction --no-progress 2>&1';
|
||||
|
||||
Log::info('vendor-bundle 빌드: composer install 시작', [
|
||||
'target' => $target,
|
||||
'staging' => $stagingDir,
|
||||
]);
|
||||
|
||||
$descriptors = [
|
||||
0 => ['pipe', 'r'],
|
||||
1 => ['pipe', 'w'],
|
||||
2 => ['pipe', 'w'],
|
||||
];
|
||||
|
||||
$process = @proc_open($command, $descriptors, $pipes, $stagingDir);
|
||||
|
||||
if (! is_resource($process)) {
|
||||
throw new VendorInstallException(
|
||||
errorKey: 'bundle_build_composer_failed',
|
||||
context: ['exit' => -1, 'message' => "proc_open 실패: {$stagingDir}"],
|
||||
);
|
||||
}
|
||||
|
||||
fclose($pipes[0]);
|
||||
$output = stream_get_contents($pipes[1]);
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
$exitCode = proc_close($process);
|
||||
|
||||
if ($exitCode !== 0) {
|
||||
throw new VendorInstallException(
|
||||
errorKey: 'bundle_build_composer_failed',
|
||||
context: ['exit' => $exitCode, 'message' => (string) $output],
|
||||
);
|
||||
}
|
||||
|
||||
Log::info('vendor-bundle 빌드: composer install 완료', [
|
||||
'target' => $target,
|
||||
'staging' => $stagingDir,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* vendor/ 디렉토리를 재귀적으로 zip에 쓰기.
|
||||
*
|
||||
* 스테이징에서 composer install --no-dev 로 새로 생성된 vendor/ 를 대상으로 하므로
|
||||
* 화이트리스트 필터링은 불필요하며, EXCLUDE_PATTERNS (tests/, docs/ 등) 만 적용한다.
|
||||
*
|
||||
* @return array{0: int, 1: int} [zipSize, fileCount]
|
||||
*/
|
||||
private function writeZip(string $vendorPath, string $zipPath): array
|
||||
{
|
||||
if (! class_exists(\ZipArchive::class)) {
|
||||
throw new VendorInstallException('zip_archive_not_available');
|
||||
}
|
||||
|
||||
$zip = new \ZipArchive;
|
||||
if ($zip->open($zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
|
||||
throw new VendorInstallException(
|
||||
errorKey: 'extraction_failed',
|
||||
context: ['message' => 'cannot create zip for writing: '.$zipPath],
|
||||
);
|
||||
}
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($vendorPath, \RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::SELF_FIRST
|
||||
);
|
||||
|
||||
// 경로 정규화 — Windows 의 경로 구분자 차이를 제거하여 Linux 와 동일하게 처리
|
||||
$normalizedVendorPath = rtrim(str_replace('\\', '/', $vendorPath), '/');
|
||||
$vendorPathLen = strlen($normalizedVendorPath);
|
||||
$fileCount = 0;
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
$realPath = $file->getRealPath();
|
||||
if ($realPath === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// vendor 루트로부터의 상대 경로 계산 (OS 일관)
|
||||
$normalizedReal = str_replace('\\', '/', $realPath);
|
||||
if (! str_starts_with($normalizedReal, $normalizedVendorPath.'/')) {
|
||||
continue;
|
||||
}
|
||||
$subPath = substr($normalizedReal, $vendorPathLen + 1);
|
||||
if ($subPath === '' || $subPath === false) {
|
||||
continue;
|
||||
}
|
||||
$relativePath = 'vendor/'.$subPath;
|
||||
|
||||
if ($this->shouldExclude($relativePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($file->isDir()) {
|
||||
$zip->addEmptyDir($relativePath);
|
||||
} else {
|
||||
$zip->addFile($realPath, $relativePath);
|
||||
$fileCount++;
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
$size = @filesize($zipPath) ?: 0;
|
||||
|
||||
return [$size, $fileCount];
|
||||
}
|
||||
|
||||
/**
|
||||
* 제외 패턴 검사.
|
||||
*/
|
||||
private function shouldExclude(string $relativePath): bool
|
||||
{
|
||||
$segments = explode('/', $relativePath);
|
||||
foreach ($segments as $segment) {
|
||||
if (in_array($segment, self::EXCLUDE_PATTERNS, true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* composer.lock 에서 설치된 패키지 목록 추출.
|
||||
*
|
||||
* @return array<int, array{name: string, version: string, type: string}>
|
||||
*/
|
||||
private function collectPackages(string $composerLockPath): array
|
||||
{
|
||||
$json = @file_get_contents($composerLockPath);
|
||||
if ($json === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $e) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$packages = [];
|
||||
foreach (($data['packages'] ?? []) as $package) {
|
||||
if (! is_array($package)) {
|
||||
continue;
|
||||
}
|
||||
$packages[] = [
|
||||
'name' => (string) ($package['name'] ?? ''),
|
||||
'version' => (string) ($package['version'] ?? ''),
|
||||
'type' => (string) ($package['type'] ?? 'library'),
|
||||
];
|
||||
}
|
||||
|
||||
return $packages;
|
||||
}
|
||||
|
||||
/**
|
||||
* composer.json 에서 php 요구 버전 추출.
|
||||
*/
|
||||
private function extractPhpRequirement(string $composerJsonPath): ?string
|
||||
{
|
||||
$json = @file_get_contents($composerJsonPath);
|
||||
if ($json === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $data['require']['php'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* composer.json 에 외부 패키지 의존성이 선언되어 있는지 확인합니다.
|
||||
*
|
||||
* 런타임 제약(php, ext-*)만 있는 경우 false 를 반환합니다.
|
||||
* 이런 확장은 vendor 번들링 대상이 아니므로 skip 처리됩니다.
|
||||
*/
|
||||
private function hasExternalDependencies(string $composerJsonPath): bool
|
||||
{
|
||||
$json = @file_get_contents($composerJsonPath);
|
||||
if ($json === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$require = $data['require'] ?? [];
|
||||
if (! is_array($require) || empty($require)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (array_keys($require) as $package) {
|
||||
// php 버전 제약과 ext-* PHP 확장 제약은 외부 의존성이 아님
|
||||
if ($package === 'php' || str_starts_with($package, 'ext-')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Vendor;
|
||||
|
||||
/**
|
||||
* Vendor 설치 요청 컨텍스트.
|
||||
*
|
||||
* CLI/웹/API 모든 진입점에서 공통으로 사용되는 설치 파라미터 DTO.
|
||||
*/
|
||||
class VendorInstallContext
|
||||
{
|
||||
/**
|
||||
* @param string $target 'core' | 'module' | 'plugin'
|
||||
* @param string|null $identifier 코어는 null, 확장은 식별자
|
||||
* @param string $sourceDir vendor 설치 소스 경로 (vendor-bundle.zip이 이 위치에 있어야 함)
|
||||
* @param string $targetDir vendor/가 배치될 경로 (보통 sourceDir과 동일)
|
||||
* @param VendorMode $requestedMode 사용자 요청 모드
|
||||
* @param VendorMode|null $previousMode 업데이트 시 이전 설치 모드 (DB에서 조회)
|
||||
* @param string|null $composerBinaryHint composer 바이너리 경로 힌트
|
||||
* @param bool $noDev composer --no-dev 플래그
|
||||
* @param string $operation 'install' | 'update'
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $target,
|
||||
public readonly ?string $identifier,
|
||||
public readonly string $sourceDir,
|
||||
public readonly string $targetDir,
|
||||
public readonly VendorMode $requestedMode = VendorMode::Auto,
|
||||
public readonly ?VendorMode $previousMode = null,
|
||||
public readonly ?string $composerBinaryHint = null,
|
||||
public readonly bool $noDev = true,
|
||||
public readonly string $operation = 'install',
|
||||
) {}
|
||||
|
||||
public function isCore(): bool
|
||||
{
|
||||
return $this->target === 'core';
|
||||
}
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return $this->isCore()
|
||||
? 'core'
|
||||
: sprintf('%s:%s', $this->target, $this->identifier);
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Vendor;
|
||||
|
||||
/**
|
||||
* Vendor 설치 결과 DTO.
|
||||
*/
|
||||
class VendorInstallResult
|
||||
{
|
||||
/**
|
||||
* @param VendorMode $mode 실제로 사용된 모드 (auto가 resolver에 의해 해석된 결과)
|
||||
* @param string $strategy 'composer' | 'bundled' | 'skipped'
|
||||
* @param int $packageCount 설치된 패키지 수 (bundled일 때 manifest 기준)
|
||||
* @param float $durationSeconds 설치 소요 시간
|
||||
* @param array<string, mixed> $details 부가 정보 (composer 출력, bundle 크기 등)
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly VendorMode $mode,
|
||||
public readonly string $strategy,
|
||||
public readonly int $packageCount = 0,
|
||||
public readonly float $durationSeconds = 0.0,
|
||||
public readonly array $details = [],
|
||||
) {}
|
||||
|
||||
public static function skipped(VendorMode $mode, string $reason): self
|
||||
{
|
||||
return new self(
|
||||
mode: $mode,
|
||||
strategy: 'skipped',
|
||||
details: ['skip_reason' => $reason],
|
||||
);
|
||||
}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Vendor;
|
||||
|
||||
/**
|
||||
* vendor-bundle.json 기반 번들 파일의 무결성을 검증합니다.
|
||||
*
|
||||
* 검증 항목:
|
||||
* - vendor-bundle.zip 존재 + SHA256 일치
|
||||
* - vendor-bundle.json 존재 + 파싱 가능 + 스키마 버전 지원
|
||||
* - composer.json/composer.lock SHA256 일치 (소스 디렉토리에 존재 시)
|
||||
*/
|
||||
class VendorIntegrityChecker
|
||||
{
|
||||
/**
|
||||
* 지원하는 manifest 스키마 버전 목록.
|
||||
*/
|
||||
public const SUPPORTED_SCHEMA_VERSIONS = ['1.0'];
|
||||
|
||||
public const MANIFEST_FILENAME = 'vendor-bundle.json';
|
||||
|
||||
public const ZIP_FILENAME = 'vendor-bundle.zip';
|
||||
|
||||
/**
|
||||
* 주어진 소스 디렉토리의 번들 무결성을 검증합니다.
|
||||
*
|
||||
* @param string $sourceDir vendor-bundle.zip 이 위치한 디렉토리
|
||||
*/
|
||||
public function verify(string $sourceDir): IntegrityResult
|
||||
{
|
||||
$zipPath = $sourceDir.DIRECTORY_SEPARATOR.self::ZIP_FILENAME;
|
||||
$manifestPath = $sourceDir.DIRECTORY_SEPARATOR.self::MANIFEST_FILENAME;
|
||||
|
||||
// 1. 파일 존재 확인
|
||||
if (! file_exists($zipPath)) {
|
||||
return IntegrityResult::invalid(['bundle_zip_missing']);
|
||||
}
|
||||
if (! file_exists($manifestPath)) {
|
||||
return IntegrityResult::invalid(['bundle_manifest_missing']);
|
||||
}
|
||||
|
||||
// 2. manifest 파싱
|
||||
$manifestJson = @file_get_contents($manifestPath);
|
||||
if ($manifestJson === false) {
|
||||
return IntegrityResult::invalid(['bundle_manifest_invalid']);
|
||||
}
|
||||
|
||||
try {
|
||||
$manifest = json_decode($manifestJson, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $e) {
|
||||
return IntegrityResult::invalid(['bundle_manifest_invalid']);
|
||||
}
|
||||
|
||||
if (! is_array($manifest)) {
|
||||
return IntegrityResult::invalid(['bundle_manifest_invalid']);
|
||||
}
|
||||
|
||||
// 3. 스키마 버전 확인
|
||||
$schemaVersion = $manifest['schema_version'] ?? null;
|
||||
if (! in_array($schemaVersion, self::SUPPORTED_SCHEMA_VERSIONS, true)) {
|
||||
return IntegrityResult::invalid(['bundle_schema_unsupported'], $manifest);
|
||||
}
|
||||
|
||||
$errors = [];
|
||||
$warnings = [];
|
||||
|
||||
// 4. zip SHA256 검증
|
||||
$expectedZipHash = $manifest['zip_sha256'] ?? null;
|
||||
if ($expectedZipHash === null) {
|
||||
$errors[] = 'bundle_manifest_invalid';
|
||||
} else {
|
||||
$actualZipHash = $this->computeFileHash($zipPath);
|
||||
if (! hash_equals((string) $expectedZipHash, $actualZipHash)) {
|
||||
$errors[] = 'zip_hash_mismatch';
|
||||
}
|
||||
}
|
||||
|
||||
// 5. composer.json/lock 해시 비교 (소스에 존재할 때만)
|
||||
$composerJsonPath = $sourceDir.DIRECTORY_SEPARATOR.'composer.json';
|
||||
if (file_exists($composerJsonPath)) {
|
||||
$expected = $manifest['composer_json_sha256'] ?? null;
|
||||
if ($expected !== null) {
|
||||
$actual = $this->computeFileHash($composerJsonPath);
|
||||
if (! hash_equals((string) $expected, $actual)) {
|
||||
$errors[] = 'composer_json_sha_mismatch';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$composerLockPath = $sourceDir.DIRECTORY_SEPARATOR.'composer.lock';
|
||||
if (file_exists($composerLockPath)) {
|
||||
$expected = $manifest['composer_lock_sha256'] ?? null;
|
||||
if ($expected !== null) {
|
||||
$actual = $this->computeFileHash($composerLockPath);
|
||||
if (! hash_equals((string) $expected, $actual)) {
|
||||
$errors[] = 'composer_lock_sha_mismatch';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($errors)) {
|
||||
return IntegrityResult::invalid($errors, $manifest);
|
||||
}
|
||||
|
||||
return IntegrityResult::valid($manifest, $warnings);
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일의 SHA256 해시를 계산합니다.
|
||||
*/
|
||||
public function computeFileHash(string $path): string
|
||||
{
|
||||
return hash_file('sha256', $path) ?: '';
|
||||
}
|
||||
|
||||
/**
|
||||
* manifest만 파싱하여 반환 (검증 없이 메타 조회용).
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function readManifest(string $sourceDir): ?array
|
||||
{
|
||||
$path = $sourceDir.DIRECTORY_SEPARATOR.self::MANIFEST_FILENAME;
|
||||
if (! file_exists($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$json = @file_get_contents($path);
|
||||
if ($json === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$data = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (\JsonException $e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
}
|
||||
Vendored
+41
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Vendor;
|
||||
|
||||
/**
|
||||
* Vendor 설치 모드.
|
||||
*
|
||||
* - Auto: composer 사용 가능 시 composer, 불가 시 bundled
|
||||
* - Composer: 강제 composer 실행 (불가 시 예외)
|
||||
* - Bundled: 강제 vendor-bundle.zip 추출 (zip 없으면 예외)
|
||||
*/
|
||||
enum VendorMode: string
|
||||
{
|
||||
case Auto = 'auto';
|
||||
case Composer = 'composer';
|
||||
case Bundled = 'bundled';
|
||||
|
||||
/**
|
||||
* 다국어 라벨.
|
||||
*/
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Auto => __('vendor.mode.auto'),
|
||||
self::Composer => __('vendor.mode.composer'),
|
||||
self::Bundled => __('vendor.mode.bundled'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 문자열에서 안전하게 변환 (null/auto 폴백).
|
||||
*/
|
||||
public static function fromStringOrAuto(?string $value): self
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return self::Auto;
|
||||
}
|
||||
|
||||
return self::tryFrom($value) ?? self::Auto;
|
||||
}
|
||||
}
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
namespace App\Extension\Vendor;
|
||||
|
||||
use App\Extension\Vendor\Exceptions\VendorInstallException;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Vendor 설치 진입점.
|
||||
*
|
||||
* CLI / 웹 인스톨러 / Admin API 모든 경로가 최종적으로 이 클래스를 경유합니다.
|
||||
* 모드를 결정(resolveMode)하고 적절한 전략으로 위임합니다.
|
||||
*
|
||||
* Composer 전략은 외부 콜백(executeComposer)에 위임 — 코어(CoreUpdateService)와
|
||||
* 확장(ExtensionManager)이 각자 기존 composer 실행 로직을 전달하는 형태.
|
||||
*/
|
||||
class VendorResolver
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EnvironmentDetector $environmentDetector,
|
||||
private readonly VendorIntegrityChecker $integrityChecker,
|
||||
private readonly VendorBundleInstaller $bundleInstaller,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 사용할 Vendor 모드를 결정합니다.
|
||||
*
|
||||
* 우선순위:
|
||||
* 1. 명시적 요청 모드 (composer/bundled) → 그대로 사용
|
||||
* 2. 업데이트 시 이전 설치 모드 상속 (auto가 아닐 때)
|
||||
* 3. 전역 기본값 config('app.install.default_vendor_mode')
|
||||
* 4. 환경 감지: composer 가능하면 composer, 불가 시 번들 존재 여부 확인
|
||||
*
|
||||
* @throws VendorInstallException
|
||||
*/
|
||||
public function resolveMode(VendorInstallContext $ctx): VendorMode
|
||||
{
|
||||
// 1. 명시적 지정
|
||||
if ($ctx->requestedMode !== VendorMode::Auto) {
|
||||
return $ctx->requestedMode;
|
||||
}
|
||||
|
||||
// 2. 업데이트 시 이전 모드 상속
|
||||
if ($ctx->operation === 'update'
|
||||
&& $ctx->previousMode !== null
|
||||
&& $ctx->previousMode !== VendorMode::Auto
|
||||
) {
|
||||
return $ctx->previousMode;
|
||||
}
|
||||
|
||||
// 3. 전역 기본값
|
||||
$configured = config('app.install.default_vendor_mode', 'auto');
|
||||
if ($configured !== 'auto') {
|
||||
$parsed = VendorMode::tryFrom((string) $configured);
|
||||
if ($parsed !== null && $parsed !== VendorMode::Auto) {
|
||||
return $parsed;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. auto → 환경 감지
|
||||
if ($this->environmentDetector->canExecuteComposer($ctx->composerBinaryHint)) {
|
||||
return VendorMode::Composer;
|
||||
}
|
||||
|
||||
// 5. composer 불가 → 번들 존재 확인
|
||||
$zipPath = $ctx->sourceDir.DIRECTORY_SEPARATOR.VendorIntegrityChecker::ZIP_FILENAME;
|
||||
if (file_exists($zipPath)) {
|
||||
return VendorMode::Bundled;
|
||||
}
|
||||
|
||||
throw new VendorInstallException('no_vendor_strategy_available');
|
||||
}
|
||||
|
||||
/**
|
||||
* Vendor 설치를 수행합니다.
|
||||
*
|
||||
* @param VendorInstallContext $ctx 설치 컨텍스트
|
||||
* @param callable|null $composerExecutor Composer 모드일 때 실행될 콜백
|
||||
* 시그니처: fn(VendorInstallContext $ctx): VendorInstallResult
|
||||
*
|
||||
* @throws VendorInstallException
|
||||
*/
|
||||
public function install(VendorInstallContext $ctx, ?callable $composerExecutor = null): VendorInstallResult
|
||||
{
|
||||
$mode = $this->resolveMode($ctx);
|
||||
|
||||
Log::info('Vendor 설치 모드 결정됨', [
|
||||
'target' => $ctx->label(),
|
||||
'requested_mode' => $ctx->requestedMode->value,
|
||||
'resolved_mode' => $mode->value,
|
||||
'operation' => $ctx->operation,
|
||||
]);
|
||||
|
||||
$this->validateModeFeasibility($mode, $ctx, $composerExecutor);
|
||||
|
||||
return match ($mode) {
|
||||
VendorMode::Composer => $this->executeComposer($ctx, $composerExecutor),
|
||||
VendorMode::Bundled => $this->executeBundle($ctx),
|
||||
VendorMode::Auto => throw new VendorInstallException('no_vendor_strategy_available'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택된 모드가 실제로 가능한지 사전 검증합니다.
|
||||
*
|
||||
* @throws VendorInstallException
|
||||
*/
|
||||
private function validateModeFeasibility(
|
||||
VendorMode $mode,
|
||||
VendorInstallContext $ctx,
|
||||
?callable $composerExecutor,
|
||||
): void {
|
||||
if ($mode === VendorMode::Composer) {
|
||||
if (! $this->environmentDetector->canExecuteComposer($ctx->composerBinaryHint)) {
|
||||
throw new VendorInstallException('composer_not_available');
|
||||
}
|
||||
if ($composerExecutor === null) {
|
||||
throw new VendorInstallException(
|
||||
errorKey: 'composer_execution_failed',
|
||||
context: ['message' => 'composer executor callback not provided'],
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($mode === VendorMode::Bundled) {
|
||||
$zipPath = $ctx->sourceDir.DIRECTORY_SEPARATOR.VendorIntegrityChecker::ZIP_FILENAME;
|
||||
if (! file_exists($zipPath)) {
|
||||
throw new VendorInstallException(
|
||||
errorKey: 'bundle_zip_missing',
|
||||
context: ['path' => $zipPath],
|
||||
);
|
||||
}
|
||||
|
||||
$integrity = $this->integrityChecker->verify($ctx->sourceDir);
|
||||
if (! $integrity->valid) {
|
||||
throw new VendorInstallException(
|
||||
errorKey: 'bundle_integrity_failed',
|
||||
context: ['details' => implode(', ', $integrity->errorMessages())],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function executeComposer(VendorInstallContext $ctx, callable $composerExecutor): VendorInstallResult
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
$result = $composerExecutor($ctx);
|
||||
$duration = microtime(true) - $startTime;
|
||||
|
||||
if ($result instanceof VendorInstallResult) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// 콜백이 단순 true/void를 반환한 경우 — 기본 결과 생성
|
||||
return new VendorInstallResult(
|
||||
mode: VendorMode::Composer,
|
||||
strategy: 'composer',
|
||||
packageCount: 0,
|
||||
durationSeconds: $duration,
|
||||
details: ['raw_result' => $result],
|
||||
);
|
||||
}
|
||||
|
||||
private function executeBundle(VendorInstallContext $ctx): VendorInstallResult
|
||||
{
|
||||
return $this->bundleInstaller->install($ctx->sourceDir, $ctx->targetDir);
|
||||
}
|
||||
}
|
||||
@@ -53,11 +53,23 @@ class ResponseHelper
|
||||
): JsonResponse {
|
||||
$response = [
|
||||
'success' => false,
|
||||
'message' => self::trans($messageKey, $messageParams, $domain)
|
||||
'message' => self::trans($messageKey, $messageParams, $domain),
|
||||
];
|
||||
|
||||
if ($errors !== null) {
|
||||
$response['errors'] = $errors;
|
||||
if ($errors instanceof \Throwable) {
|
||||
if (config('app.debug')) {
|
||||
// messageParams에 이미 에러 메시지가 포함된 경우 중복 방지
|
||||
if (empty($messageParams)) {
|
||||
$response['message'] .= ': ' . $errors->getMessage();
|
||||
}
|
||||
$response['debug'] = self::formatException($errors);
|
||||
}
|
||||
} elseif ($errors !== null) {
|
||||
if ($statusCode >= 500 && is_string($errors) && !config('app.debug')) {
|
||||
// 프로덕션 500+ 에러의 string errors 차단 (내부 예외 메시지 노출 방지)
|
||||
} else {
|
||||
$response['errors'] = $errors;
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json($response, $statusCode);
|
||||
@@ -159,10 +171,15 @@ class ResponseHelper
|
||||
): JsonResponse {
|
||||
$response = [
|
||||
'success' => false,
|
||||
'message' => self::trans($messageKey, $messageParams, $domain)
|
||||
'message' => self::trans($messageKey, $messageParams, $domain),
|
||||
];
|
||||
|
||||
if ($error !== null && config('app.debug')) {
|
||||
if ($error instanceof \Throwable) {
|
||||
if (config('app.debug')) {
|
||||
$response['message'] .= ': ' . $error->getMessage();
|
||||
$response['debug'] = self::formatException($error);
|
||||
}
|
||||
} elseif ($error !== null && config('app.debug')) {
|
||||
$response['error'] = $error;
|
||||
}
|
||||
|
||||
@@ -225,15 +242,36 @@ class ResponseHelper
|
||||
{
|
||||
$languages = explode(',', $acceptLanguage);
|
||||
$firstLanguage = trim($languages[0]);
|
||||
|
||||
|
||||
// 언어-지역 형태에서 언어만 추출 (예: ko-KR -> ko)
|
||||
if (strpos($firstLanguage, '-') !== false) {
|
||||
return explode('-', $firstLanguage)[0];
|
||||
}
|
||||
|
||||
|
||||
return $firstLanguage;
|
||||
}
|
||||
|
||||
/**
|
||||
* 예외 정보를 디버그용 배열로 변환합니다.
|
||||
*
|
||||
* @param \Throwable $e 예외 인스턴스
|
||||
* @return array 디버그 정보 배열
|
||||
*/
|
||||
private static function formatException(\Throwable $e): array
|
||||
{
|
||||
return [
|
||||
'exception' => get_class($e),
|
||||
'message' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
'trace' => collect($e->getTrace())->take(10)->map(fn ($frame) => [
|
||||
'file' => $frame['file'] ?? null,
|
||||
'line' => $frame['line'] ?? null,
|
||||
'function' => ($frame['class'] ?? '') . ($frame['type'] ?? '') . ($frame['function'] ?? ''),
|
||||
])->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 모듈별 성공 응답을 생성합니다.
|
||||
*
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Helpers\PermissionHelper;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use App\Http\Requests\ActivityLog\ActivityLogBulkDeleteRequest;
|
||||
use App\Http\Requests\ActivityLog\ActivityLogDeleteRequest;
|
||||
@@ -42,14 +41,7 @@ class ActivityLogController extends AdminBaseController
|
||||
|
||||
$collection = new ActivityLogCollection($logs);
|
||||
|
||||
$responseData = $collection->toArray($request);
|
||||
|
||||
// 컬렉션 레벨 abilities (페이지 레벨 버튼 제어용)
|
||||
$responseData['abilities'] = [
|
||||
'can_delete' => PermissionHelper::check('core.activities.delete', $request->user()),
|
||||
];
|
||||
|
||||
return $this->success('activity_log.fetch_success', $responseData);
|
||||
return $this->success('activity_log.fetch_success', $collection->toArray($request));
|
||||
} catch (Exception $e) {
|
||||
return $this->error('activity_log.fetch_failed', 500, $e->getMessage());
|
||||
}
|
||||
|
||||
@@ -2,14 +2,12 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Helpers\ResponseHelper;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use App\Http\Resources\UserResource;
|
||||
use App\Services\AuthService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AuthController extends AdminBaseController
|
||||
@@ -38,7 +36,7 @@ class AuthController extends AdminBaseController
|
||||
|
||||
// 관리자 권한 확인
|
||||
if (!$user->isAdmin()) {
|
||||
return ResponseHelper::forbidden('auth.admin_required');
|
||||
return $this->forbidden('auth.admin_required');
|
||||
}
|
||||
|
||||
// 사용자 정보는 Resource로, 토큰은 그대로
|
||||
@@ -46,7 +44,7 @@ class AuthController extends AdminBaseController
|
||||
|
||||
return $this->success('auth.admin_login_success', $data);
|
||||
} catch (ValidationException $e) {
|
||||
return ResponseHelper::unauthorized('auth.login_failed', [], 'core');
|
||||
return $this->unauthorized('auth.login_failed');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,7 +97,7 @@ class AuthController extends AdminBaseController
|
||||
}
|
||||
return $this->success('common.success', $data);
|
||||
} catch (ValidationException $e) {
|
||||
return ResponseHelper::unauthorized('auth.unauthenticated', [], 'core');
|
||||
return $this->unauthorized('auth.unauthenticated');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use App\Services\GeoIpDatabaseService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
/**
|
||||
* GeoIP DB 관리 컨트롤러.
|
||||
*
|
||||
* 수동 DB 업데이트 트리거를 제공합니다. 정기 갱신은
|
||||
* routes/console.php의 스케줄(geoip:update)이 담당합니다.
|
||||
* 비즈니스 로직은 모두 GeoIpDatabaseService에 위임합니다.
|
||||
*/
|
||||
class GeoIpController extends AdminBaseController
|
||||
{
|
||||
public function __construct(
|
||||
private GeoIpDatabaseService $geoIpDatabaseService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* MaxMind GeoLite2-City DB를 즉시 재다운로드합니다.
|
||||
*
|
||||
* 내부적으로 GeoIpDatabaseService::updateDatabase(true)를 호출합니다.
|
||||
* 다운로드는 동기 실행이므로 PHP-FPM/웹서버 타임아웃(90초 이상) 필요.
|
||||
*
|
||||
* @return JsonResponse 업데이트 결과 JSON 응답
|
||||
*/
|
||||
public function update(): JsonResponse
|
||||
{
|
||||
$result = $this->geoIpDatabaseService->updateDatabase(true);
|
||||
|
||||
if ($result['success']) {
|
||||
return $this->success(
|
||||
'settings.geoip.update_success',
|
||||
$result['data'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
return $this->error(
|
||||
$this->mapStatusToMessageKey((string) $result['status']),
|
||||
$this->mapStatusToHttpCode((string) $result['status']),
|
||||
$result['message'] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Service 상태 코드를 i18n 메시지 키로 매핑합니다.
|
||||
*
|
||||
* @param string $status Service 상태 코드
|
||||
* @return string i18n 키
|
||||
*/
|
||||
private function mapStatusToMessageKey(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
'missing_license_key' => 'settings.geoip.license_key_missing',
|
||||
'unauthorized' => 'settings.geoip.license_key_invalid',
|
||||
'connection_failed' => 'settings.geoip.connection_failed',
|
||||
default => 'settings.geoip.update_failed',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Service 상태 코드를 HTTP 상태 코드로 매핑합니다.
|
||||
*
|
||||
* @param string $status Service 상태 코드
|
||||
* @return int HTTP 상태 코드
|
||||
*/
|
||||
private function mapStatusToHttpCode(string $status): int
|
||||
{
|
||||
return match ($status) {
|
||||
'missing_license_key' => 400,
|
||||
'unauthorized' => 401,
|
||||
default => 500,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Helpers\PermissionHelper;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use App\Http\Requests\MailSendLog\MailSendLogBulkDeleteRequest;
|
||||
use App\Http\Requests\MailSendLog\MailSendLogDeleteRequest;
|
||||
use App\Http\Requests\MailSendLog\MailSendLogIndexRequest;
|
||||
use App\Http\Resources\MailSendLogCollection;
|
||||
use App\Models\MailSendLog;
|
||||
use App\Services\MailSendLogService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 메일 발송 이력 관리 컨트롤러
|
||||
*/
|
||||
class MailSendLogController extends AdminBaseController
|
||||
{
|
||||
/**
|
||||
* MailSendLogController 생성자.
|
||||
*
|
||||
* @param MailSendLogService $mailSendLogService 메일 발송 이력 서비스
|
||||
*/
|
||||
public function __construct(
|
||||
private MailSendLogService $mailSendLogService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 발송 이력 목록을 조회합니다.
|
||||
*
|
||||
* @param MailSendLogIndexRequest $request 검증된 조회 요청
|
||||
* @return JsonResponse 발송 이력 목록
|
||||
*/
|
||||
public function index(MailSendLogIndexRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$filters = array_filter([
|
||||
'extension_type' => $request->validated('extension_type'),
|
||||
'extension_identifier' => $request->validated('extension_identifier'),
|
||||
'template_type' => $request->validated('template_type'),
|
||||
'status' => $request->validated('status'),
|
||||
'search' => $request->validated('search'),
|
||||
'search_type' => $request->validated('search_type'),
|
||||
'date_from' => $request->validated('date_from'),
|
||||
'date_to' => $request->validated('date_to'),
|
||||
'sort_by' => $request->validated('sort_by'),
|
||||
'sort_order' => $request->validated('sort_order'),
|
||||
], fn ($value) => $value !== null);
|
||||
|
||||
$perPage = (int) ($request->validated('per_page') ?? 20);
|
||||
$logs = $this->mailSendLogService->getLogs($filters, $perPage);
|
||||
|
||||
$collection = new MailSendLogCollection($logs);
|
||||
|
||||
$responseData = $collection->toArray($request);
|
||||
|
||||
// 컬렉션 레벨 abilities (페이지 레벨 버튼 제어용)
|
||||
$responseData['abilities'] = [
|
||||
'can_delete' => PermissionHelper::check('core.mail-send-logs.delete', $request->user()),
|
||||
];
|
||||
|
||||
return $this->success('mail_send_log.fetch_success', $responseData);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('메일 발송 이력 조회 실패', ['error' => $e->getMessage()]);
|
||||
|
||||
return $this->error('mail_send_log.fetch_failed', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 발송 이력을 삭제합니다.
|
||||
*
|
||||
* @param MailSendLogDeleteRequest $request 검증된 삭제 요청
|
||||
* @param MailSendLog $mailSendLog 삭제할 발송 이력 모델
|
||||
* @return JsonResponse 삭제 결과
|
||||
*/
|
||||
public function destroy(MailSendLogDeleteRequest $request, MailSendLog $mailSendLog): JsonResponse
|
||||
{
|
||||
try {
|
||||
$this->mailSendLogService->delete($mailSendLog->id);
|
||||
|
||||
return $this->success('mail_send_log.delete_success');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('메일 발송 이력 삭제 실패', ['id' => $mailSendLog->id, 'error' => $e->getMessage()]);
|
||||
|
||||
return $this->error('mail_send_log.delete_failed', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 발송 이력을 일괄 삭제합니다.
|
||||
*
|
||||
* @param MailSendLogBulkDeleteRequest $request 검증된 일괄 삭제 요청
|
||||
* @return JsonResponse 삭제 결과
|
||||
*/
|
||||
public function bulkDestroy(MailSendLogBulkDeleteRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$count = $this->mailSendLogService->deleteMany($request->validated('ids'));
|
||||
|
||||
return $this->success('mail_send_log.bulk_delete_success', ['deleted_count' => $count]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('메일 발송 이력 일괄 삭제 실패', ['error' => $e->getMessage()]);
|
||||
|
||||
return $this->error('mail_send_log.delete_failed', 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use App\Http\Requests\MailTemplate\MailTemplateIndexRequest;
|
||||
use App\Http\Requests\MailTemplate\MailTemplatePreviewRequest;
|
||||
use App\Http\Requests\MailTemplate\UpdateMailTemplateRequest;
|
||||
use App\Http\Resources\MailTemplateCollection;
|
||||
use App\Http\Resources\MailTemplateResource;
|
||||
use App\Models\MailTemplate;
|
||||
use App\Services\MailTemplateService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 메일 템플릿 관리 컨트롤러
|
||||
*
|
||||
* 메일 템플릿의 조회, 수정, 미리보기, 초기화 기능을 제공합니다.
|
||||
*/
|
||||
class MailTemplateController extends AdminBaseController
|
||||
{
|
||||
/**
|
||||
* MailTemplateController 생성자.
|
||||
*
|
||||
* @param MailTemplateService $mailTemplateService 메일 템플릿 서비스
|
||||
*/
|
||||
public function __construct(
|
||||
private MailTemplateService $mailTemplateService
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 템플릿 목록을 페이지네이션하여 조회합니다.
|
||||
*
|
||||
* @param MailTemplateIndexRequest $request 검증된 목록 조회 요청
|
||||
* @return JsonResponse 페이지네이션된 템플릿 목록
|
||||
*/
|
||||
public function index(MailTemplateIndexRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$filters = array_filter($request->validated(), fn ($value) => $value !== null);
|
||||
$perPage = (int) ($request->validated('per_page') ?? 20);
|
||||
$templates = $this->mailTemplateService->getTemplates($filters, $perPage);
|
||||
|
||||
$collection = new MailTemplateCollection($templates);
|
||||
|
||||
return $this->success('mail_template.fetch_success', $collection->toArray($request));
|
||||
} catch (\Exception $e) {
|
||||
Log::error('메일 템플릿 목록 조회 실패', ['error' => $e->getMessage()]);
|
||||
|
||||
return $this->error('mail_template.fetch_failed', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 템플릿을 수정합니다.
|
||||
*
|
||||
* @param UpdateMailTemplateRequest $request 수정 요청
|
||||
* @param MailTemplate $mailTemplate 수정 대상
|
||||
* @return JsonResponse 수정 결과
|
||||
*/
|
||||
public function update(UpdateMailTemplateRequest $request, MailTemplate $mailTemplate): JsonResponse
|
||||
{
|
||||
try {
|
||||
$userId = $request->user()?->id;
|
||||
$template = $this->mailTemplateService->updateTemplate(
|
||||
$mailTemplate,
|
||||
$request->validated(),
|
||||
$userId
|
||||
);
|
||||
|
||||
return $this->success(
|
||||
'mail_template.save_success',
|
||||
new MailTemplateResource($template)
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('메일 템플릿 수정 실패', ['error' => $e->getMessage()]);
|
||||
|
||||
return $this->error('mail_template.save_error', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 템플릿의 활성 상태를 토글합니다.
|
||||
*
|
||||
* @param MailTemplate $mailTemplate 토글 대상
|
||||
* @return JsonResponse 토글 결과
|
||||
*/
|
||||
public function toggleActive(MailTemplate $mailTemplate): JsonResponse
|
||||
{
|
||||
try {
|
||||
$template = $this->mailTemplateService->toggleActive($mailTemplate);
|
||||
|
||||
return $this->success(
|
||||
'mail_template.toggle_success',
|
||||
new MailTemplateResource($template)
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('메일 템플릿 활성 상태 토글 실패', ['error' => $e->getMessage()]);
|
||||
|
||||
return $this->error('mail_template.toggle_failed', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 템플릿 미리보기를 반환합니다.
|
||||
*
|
||||
* @param MailTemplatePreviewRequest $request 검증된 미리보기 요청
|
||||
* @return JsonResponse 미리보기 결과
|
||||
*/
|
||||
public function preview(MailTemplatePreviewRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$result = $this->mailTemplateService->getPreview($request->validated());
|
||||
|
||||
return $this->success('mail_template.preview_success', $result);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('메일 템플릿 미리보기 실패', ['error' => $e->getMessage()]);
|
||||
|
||||
return $this->error('mail_template.preview_failed', 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 템플릿을 시더 기본값으로 복원합니다.
|
||||
*
|
||||
* @param MailTemplate $mailTemplate 복원 대상
|
||||
* @return JsonResponse 복원 결과
|
||||
*/
|
||||
public function reset(MailTemplate $mailTemplate): JsonResponse
|
||||
{
|
||||
try {
|
||||
$defaultData = $this->mailTemplateService->getDefaultTemplateData($mailTemplate->type);
|
||||
|
||||
if (! $defaultData) {
|
||||
return $this->error('mail_template.reset_no_default', 404);
|
||||
}
|
||||
|
||||
$template = $this->mailTemplateService->resetToDefault($mailTemplate, $defaultData);
|
||||
|
||||
return $this->success(
|
||||
'mail_template.reset_success',
|
||||
new MailTemplateResource($template)
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('메일 템플릿 기본값 복원 실패', ['error' => $e->getMessage()]);
|
||||
|
||||
return $this->error('mail_template.reset_failed', 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
namespace App\Http\Controllers\Api\Admin;
|
||||
|
||||
use App\Helpers\PermissionHelper;
|
||||
use App\Helpers\ResponseHelper;
|
||||
use App\Http\Controllers\Api\Base\AdminBaseController;
|
||||
use App\Http\Requests\Module\ActivateModuleRequest;
|
||||
use App\Http\Requests\Module\DeactivateModuleRequest;
|
||||
@@ -11,6 +9,7 @@ use App\Http\Requests\Module\IndexModuleRequest;
|
||||
use App\Http\Requests\Module\InstallModuleFromFileRequest;
|
||||
use App\Http\Requests\Module\InstallModuleFromGithubRequest;
|
||||
use App\Http\Requests\Module\InstallModuleRequest;
|
||||
use App\Http\Requests\Module\PerformModuleUpdateRequest;
|
||||
use App\Http\Requests\Module\RefreshModuleLayoutsRequest;
|
||||
use App\Http\Requests\Module\UninstallModuleRequest;
|
||||
use App\Http\Requests\Extension\ChangelogRequest;
|
||||
@@ -62,15 +61,9 @@ class ModuleController extends AdminBaseController
|
||||
$collection = new ModuleCollection(collect($responseData['data']));
|
||||
$responseData['data'] = $collection->toArray($request)['data'];
|
||||
$responseData['meta'] = $collection->with($request)['meta'];
|
||||
$responseData['abilities'] = $collection->resolveCollectionAbilities($request);
|
||||
}
|
||||
|
||||
// 컬렉션 레벨 abilities (페이지 레벨 버튼 제어용)
|
||||
$responseData['abilities'] = [
|
||||
'can_install' => PermissionHelper::check('core.modules.install', $request->user()),
|
||||
'can_activate' => PermissionHelper::check('core.modules.activate', $request->user()),
|
||||
'can_uninstall' => PermissionHelper::check('core.modules.uninstall', $request->user()),
|
||||
];
|
||||
|
||||
return $this->success('module.fetch_success', $responseData);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error('module.fetch_failed', 500, $e->getMessage());
|
||||
@@ -154,8 +147,12 @@ class ModuleController extends AdminBaseController
|
||||
public function install(InstallModuleRequest $request): JsonResponse
|
||||
{
|
||||
try {
|
||||
$moduleName = $request->validated()['module_name'];
|
||||
$module = $this->moduleService->installModule($moduleName);
|
||||
$validated = $request->validated();
|
||||
$moduleName = $validated['module_name'];
|
||||
$vendorMode = \App\Extension\Vendor\VendorMode::fromStringOrAuto(
|
||||
$validated['vendor_mode'] ?? null
|
||||
);
|
||||
$module = $this->moduleService->installModule($moduleName, $vendorMode);
|
||||
|
||||
if ($module) {
|
||||
return $this->successWithResource(
|
||||
@@ -172,7 +169,7 @@ class ModuleController extends AdminBaseController
|
||||
$firstError = collect($e->errors())->flatten()->first()
|
||||
?? __('module.install_failed');
|
||||
|
||||
return ResponseHelper::error($firstError, 422, $e->errors());
|
||||
return $this->validationError($e->errors(), $firstError);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error('modules.installation_failed', 500, $e->getMessage(), [
|
||||
'error' => $e->getMessage(),
|
||||
@@ -411,16 +408,48 @@ class ModuleController extends AdminBaseController
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 모듈의 수정된 레이아웃을 확인합니다.
|
||||
*
|
||||
* 업데이트 전 사용자가 수정한 레이아웃이 있는지 확인하여
|
||||
* 레이아웃 전략(overwrite/keep) 선택에 참고할 수 있도록 합니다.
|
||||
*
|
||||
* @param string $moduleName 모듈 식별자
|
||||
* @return JsonResponse 수정된 레이아웃 정보를 포함한 JSON 응답
|
||||
*/
|
||||
public function checkModifiedLayouts(string $moduleName): JsonResponse
|
||||
{
|
||||
try {
|
||||
$result = $this->moduleService->checkModifiedLayouts($moduleName);
|
||||
|
||||
return $this->success('modules.check_modified_layouts_success', $result);
|
||||
} catch (ValidationException $e) {
|
||||
return $this->error('modules.check_modified_layouts_failed', 422, $e->errors());
|
||||
} catch (\Exception $e) {
|
||||
return $this->error('modules.check_modified_layouts_failed', 500, $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 모듈을 업데이트합니다.
|
||||
*
|
||||
* layout_strategy 파라미터로 레이아웃 처리 방식을 결정합니다:
|
||||
* - overwrite: 모든 레이아웃을 새 버전으로 교체
|
||||
* - keep: 사용자가 수정한 레이아웃을 유지
|
||||
*
|
||||
* @param PerformModuleUpdateRequest $request 업데이트 요청 데이터
|
||||
* @param string $moduleName 업데이트할 모듈 identifier
|
||||
* @return JsonResponse 업데이트 결과 JSON 응답
|
||||
*/
|
||||
public function performUpdate(string $moduleName): JsonResponse
|
||||
public function performUpdate(PerformModuleUpdateRequest $request, string $moduleName): JsonResponse
|
||||
{
|
||||
try {
|
||||
$result = $this->moduleService->updateModule($moduleName);
|
||||
$validated = $request->validated();
|
||||
$vendorMode = \App\Extension\Vendor\VendorMode::fromStringOrAuto(
|
||||
$validated['vendor_mode'] ?? null
|
||||
);
|
||||
$layoutStrategy = $validated['layout_strategy'] ?? 'overwrite';
|
||||
$result = $this->moduleService->updateModule($moduleName, $vendorMode, $layoutStrategy);
|
||||
|
||||
$moduleInfo = $result['module_info'] ?? null;
|
||||
|
||||
@@ -438,7 +467,7 @@ class ModuleController extends AdminBaseController
|
||||
$firstError = collect($e->errors())->flatten()->first()
|
||||
?? __('modules.errors.update_failed', ['module' => $moduleName, 'error' => '']);
|
||||
|
||||
return ResponseHelper::error($firstError, 422, $e->errors());
|
||||
return $this->validationError($e->errors(), $firstError);
|
||||
} catch (\Exception $e) {
|
||||
return $this->error('modules.errors.update_failed', 500, $e->getMessage(), [
|
||||
'module' => $moduleName,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user