26 Commits
Author SHA1 Message Date
HeuJung 7c18adc09d Merge branch 'develop' 2026-07-03 19:16:37 +09:00
HeuJung a10200164c fix(core): 코어 업데이트 스텝 재실행 안내에 실행 권한별 4분기 지침 추가
sudo(root) 로 core:update 를 실행하면 업그레이드 스텝 spawn 자식이 실패할 때
운영자에게 core:execute-upgrade-steps 재실행을 안내하는데, 이 명령을 root 로
그대로 재실행하면 스텝이 만드는 파일이 root 소유가 되어 이후 웹서버(www-data)
요청이 그 경로에 쓰기 실패한다.

재실행 안내를 실행 환경 4가지로 분기한다:
- non_root: 일반 SSH 사용자 / 공유 호스팅(웹서버=PHP=실행 유저) / posix 미지원 → 명령만
- root_web_known: 웹서버 계정 식별 → sudo -u {계정} + 계정명 경고
- root_web_symmetric: root 서비스 구성 → 명령만
- root_web_unknown: 계정 추정 실패 → placeholder + 확인 방법 안내

웹서버 계정은 FilePermissionHelper::inferWebServerOwnership 로 추정한다.
출력 전담 renderResumeGuidance 를 분리해 4분기 출력 계약을 회귀 테스트로 고정.
2026-07-03 19:16:11 +09:00
HeuJung a681908d54 Merge branch 'develop' 2026-07-03 18:45:59 +09:00
HeuJung 710f8e9135 fix(extension): 확장 토글 시 config 캐시 재생성을 동기 실행 (큐 지연 제거)
ExtensionConfigCacheListener 가 config 캐시 재생성을 큐로 디스패치해,
워커 미가동 환경·CLI 커맨드(plugin:deactivate 등)에서 재생성이 지연/누락되던
결함을 수정한다. 훅 Action 리스너의 기본 등록 정책이 큐 디스패치이므로,
getSubscribedHooks 각 훅에 'sync' => true 를 추가해 동기 실행한다.

- 회귀 테스트 test_all_hooks_are_synchronous 추가
- docs/backend/service-provider.md 에 sync 요구사항 명문화
- 2브라우저(Chrome MCP + Playwright MCP) 회귀 검증 중 발견
2026-07-03 18:40:19 +09:00
HeuJung c08dae7cb0 perf(core): 서빙 API 부팅 비용 최적화 (information_schema 가드 + 훅/오토로드/config 캐시)
매 요청 균일하게 발생하던 부팅 오버헤드를 4개 축에서 제거한다.

- 설치 완료 상태에서 매 요청 반복되던 information_schema 조회(알림·본인인증
 테이블 hasTable)를 installer_completed 가드로 스킵. 미설치 환경은 기존
 hasTable 폴백 유지.
- 코어+모듈+플러그인 정적 훅 매핑을 bootstrap/cache/hooks.php 에 사전 계산해
 매 요청 디렉토리 스캔·리플렉션·클래스 로딩을 제거(route:cache 동형).
 등록↔발화 계약·매핑 바이트 동일. 확장/코어 변경 시 자동 재생성, 캐시
 부재·손상은 스캔 폴백.
- 확장 소스(Modules\*/Plugins\*)를 autoload-extensions.php 의 classmap 에
 편입해 findFile 파일시스템 스캔을 제거(느린 FS·cold OPcache 환경 직격).
 클래스 로딩은 여전히 lazy, PSR-4 폴백 유지.
- config 캐시를 변경 지점(설정 저장/코어·확장 업데이트/APP_KEY 재생성/설치
 완료)에서 clear 후 즉시 재생성하도록 ConfigCacheHelper 로 일원화. clear 만
 하고 방치돼 캐시가 영구 비활성으로 남던 성능 손실 제거. HookCacheManager::read
 요청당 1회 로드(memo)로 중복 파싱 제거.

부팅 매핑 fingerprint 스캔↔캐시 완전 동일(229 action + 35 filter). 신규/수정
8개 스위트 54 pass. 순수 내부 부팅 인프라 — 확장 공개 표면·훅 계약 불변.
2026-07-03 17:47:23 +09:00
HeuJung deacbc7b66 Merge branch 'develop' 2026-07-03 10:03:19 +09:00
HeuJung 239bdffa10 docs(release): 7.0.1 출시일 7/3 조정 + 엔진 CHANGELOG 버전 병합·정합 정리
7.0.1 릴리즈 산출물 날짜를 2026-07-03 으로 조정하고, 엔진
CHANGELOG 의 engine-v1.52.0(DevTools 번들 분리) 과 engine-v1.51.0
(레이아웃 편집기 번들 분리) 을 단일 engine-v1.51.0 섹션으로 병합.

병합에 따라 DevTools 관련 코드 @since 를 v1.51.0 으로 정정하고,
그 과정에서 발견한 CHANGELOG 부재 유령 버전(v1.52.0/v1.52.2/
v1.52.4) 을 CHANGELOG 정본(engine-v1.50.0) 에 맞춰 정정.

주석/CHANGELOG 문자열 정정만으로 런타임 로직 변경 없음.
2026-07-03 10:02:38 +09:00
HeuJung 4371f0c90e Merge branch 'develop' 2026-07-02 18:03:37 +09:00
HeuJung 52ee8c855a chore(release): 공개 소스 본문 내부 단서(· 호칭) 정리
공개 release 발행 시 파일 본문 누출 검사(check-public-release-leakage)에
걸리던 내부 단서를 정리해 발행을 차단하지 않도록 함.

- 코드/테스트 주석·문서 본문의 "" 규정 인용을 "코어 규정/규칙" 등
 중립 표현으로 치환 (코어·이커머스·board·kginicis·gdpr·pay·템플릿 전 영역)
- 내부 역할 호칭 ""/""를 문맥에 맞는 중립 표현으로 일반화
- config/app.php 의 보호경로 목록(G7_UPDATE_PROTECTED_PATHS) 내 파일명은
 기능 코드이므로 삭제 대신 검사기 화이트리스트에 등록

주석/설명 문자열/문서 본문만 변경 — 로직·데이터·동작 무변경.
검증: 누출 재검사 error 0 / warn 0, 영향 테스트 TS 140/140·PHP 113/113 통과.
2026-07-02 17:58:33 +09:00
HeuJung 61608ca341 Merge branch 'develop' 2026-07-02 17:32:32 +09:00
HeuJung eb2b35ef36 chore(release): 7.0.1 출시일 오늘로 조정 + 공개 소스 내부 단서 정리
- 출시일: CHANGELOG [7.0.1] 및 엔진 CHANGELOG(v1.52.0/v1.51.0) 를 2026-07-02(오늘)로 조정
- 공개 release 포함 소스의 내부 이슈번호 정리:
 - 공개 이슈는 gnuboard/g7 명시 형식으로 정규화
 - 내부 이슈번호는 서술문으로 일반화
- 코드 주석의 내부 역할 호칭("")을 중립 표현으로 일반화
2026-07-02 17:19:23 +09:00
정정홍andGitHub 56d5200fcc Merge pull request from gnuboard:HeuJung/issue435
HeuJung/issue435
2026-07-02 15:14:53 +09:00
HeuJung ce8055e65c fix(core): 시스템 정보 조회가 일부 호스팅에서 전체 실패하던 문제 수정
disable_functions/open_basedir 제약 호스팅에서 CPU·메모리·디스크 등
개별 probe 실패가 system-info API 전체를 500 으로 떨구던 결함 수정.
buildSystemInfo 각 항목을 safeSystemProbe 로 격리해 실패 항목만 unknown
폴백으로 채우고 나머지는 정상 반환. 개별 실패는 Log::warning, 전체 실패는
컨트롤러 Log::error 로 기록. ( 대시보드 격리가 settings 에 미적용되어
재발한 공개 대응)

동반: SettingsController::restore 를 RestoreSettingsRequest 로 이전
(base Request + 컨트롤러 내 검증 제거), 미정의였던 backup_path_required·
restore_* lang 키 정의(ko/en/ja).
2026-07-02 15:11:59 +09:00
정정홍andGitHub 802e99bbcc Merge pull request from gnuboard:HeuJung/issue432
fix(core): 코어 업데이트가 특정 상황에서 중단되던 두 결함 수정
2026-07-02 12:43:17 +09:00
HeuJung 27901dd254 fix(core): 코어 업데이트가 특정 상황에서 중단되던 두 결함 수정
Windows JUNCTION 복사 실패:
- public/storage 는 storage:link 가 Windows 에서 생성하는 JUNCTION 으로,
 PHP is_link/is_dir 이 모두 false 를 반환해 copyDirectory 가 파일로
 오판 → copy(디렉토리) 예외로 코어 업데이트가 중단됐다.
- isReparsePoint 로 junction 을 정확 감지(link/file/dir 모두 false +
 readlink 성공)하고, copyLink 가 symlink 실패 시 mklink /J 로 폴백 복원.
 removeOrphanItems 도 junction orphan 을 rmdir 로 링크만 제거해 target
 재귀 삭제 사고를 차단.

실행할 스텝이 없는 업데이트를 실패로 오판:
- spawn 자식이 exit=0 + step 0건으로 종료하면 부모 가드가 from<to 만으로
 fail-fast 를 발동해, 스텝이 필요 없는 패치 릴리즈(예: 7.0.0→7.0.1)를
 실패로 처리하고 확장 업데이트 단계까지 도달하지 못했다.
- [STEPS_EXECUTED] 신호에 discovered(범위 내 발견된 스텝 파일 수)를 추가하고,
 handleSpawnExit 가 executed=0 && discovered=0 은 정상 통과, discovered>0
 일 때만 fail-fast 하도록 구분. 구버전 자식(discovered 부재)은 레거시 판정 유지.
 정상 통과 시 확장 일괄 업데이트 단계까지 진행된다.

테스트: FilePermissionHelperSymlinkTest / CoreUpdateCommandSpawnFailureTest /
CoreUpdateCommandHandoffTest / MultiVersionUpgradePathTest 전부 green.
2026-07-02 12:30:23 +09:00
정정홍andGitHub 68d2a68062 Merge pull request from gnuboard:HeuJung/issue429
perf(core): 코어 번들 3분할로 초기 로딩 최적화
2026-07-02 09:21:12 +09:00
HeuJung 32251b2a1d perf(core): 코어 번들 3분할로 초기 로딩 최적화
레이아웃 편집기·개발자 진단도구를 코어 번들에서 분리해 각각
편집 화면 진입 시·디버그 모드 시에만 지연 로드하도록 변경.
초기 접속 gzip payload 458KB → 221KB (52% 감소).

lazy 번들은 window.G7Core.__runtime 으로 코어 런타임을 공유해
React/컨텍스트/싱글톤 단일 인스턴스를 보장(재번들 0중복).
코어 7.0.1 bump.
2026-07-01 17:05:15 +09:00
HeuJung 917bfb603a v7.0.0 release 2026-07-01 11:32:54 +09:00
HeuJung c4ea9a6cd1 v7.0.0 release 2026-07-01 10:30:32 +09:00
HeuJung 0e7fa04b03 v7.0.0-beta.7 release 2026-05-15 19:24:41 +09:00
HeuJung c186d9a9c2 v7.0.0-beta.6 release 2026-05-14 12:24:08 +09:00
HeuJung 61f5874fd2 v7.0.0-beta.6 release 2026-05-14 11:13:08 +09:00
HeuJung cc45c0b84c v7.0.0-beta.5 release 2026-05-12 18:35:03 +09:00
HeuJung eb467aca79 v7.0.0-beta.5 release 2026-05-12 17:58:34 +09:00
HeuJung 1db039ff34 v7.0.0-beta.4 release 2026-05-11 11:29:41 +09:00
HeuJung 05db17887d v7.0.0-beta.3 release 2026-04-23 17:36:31 +09:00
4610 changed files with 774201 additions and 161293 deletions
+6 -1
View File
@@ -3,7 +3,7 @@ APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.0-beta.2
APP_VERSION=7.0.1
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
@@ -103,3 +103,8 @@ VITE_APP_NAME="${APP_NAME}"
G7_UPDATE_GITHUB_URL=https://github.com/gnuboard/g7
G7_UPDATE_GITHUB_TOKEN=
G7_UPDATE_PENDING_PATH=
# spawn 자식 프로세스 실패 시 동작 모드 (기본: abort)
# - abort : 즉시 abort + 운영자에게 수동 명령 안내 (안전 우선, stale 메모리 fatal 차단)
# - fallback : in-process fallback 진행 (proc_open 미지원 공유 호스팅 호환, stale 메모리 위험 잔존)
# G7_UPDATE_SPAWN_FAILURE_MODE=abort
+1 -1
View File
@@ -3,7 +3,7 @@ APP_ENV=testing
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
APP_VERSION=7.0.0-beta.2
APP_VERSION=7.0.1
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
+27
View File
@@ -75,12 +75,26 @@ templates/_bundled/*/node_modules/
!plugins/_pending/
!templates/_pending/
# ===== 언어팩 시스템 =====
# 활성 언어팩 디렉토리 (설치된 복사본 — Git 제외)
lang-packs/*/
!lang-packs/_bundled/
# _pending 디렉토리 (외부 다운로드 임시 영역)
lang-packs/_bundled/_pending/
!lang-packs/_pending/
# DevTools debug dump
storage/debug-dump/
# MaxMind GeoLite2 DB (재배포 금지 — 각 환경에서 직접 다운로드)
storage/app/geoip/
# 인스톨러 런타임 산출물 — 각 환경에서 자동 생성/소비
storage/installer-state.json
storage/installer/
# 개발 전용 파일
.api-test/
.serena/
@@ -90,6 +104,7 @@ boost.json
cypress/
cypress.config.js
.env.testing
.claude/settings.local.json
# ===== 보안: 민감 파일 방어 패턴 =====
*.pem
@@ -106,3 +121,15 @@ id_ed25519
*.sql
*.sqlite
*.db
# NHN KCP 결제플러그인 PG 공개키는 예외 처리
!plugins/_bundled/*/bin/pub.key
.claude/tmp/
# ===== Playwright E2E =====
tests/Playwright/.auth/
playwright-report/
test-results/
# Playwright MCP 세션 아티팩트(스냅샷/콘솔 로그) — 커밋 대상 아님
.playwright-mcp/
+93 -7
View File
@@ -6,22 +6,29 @@
<!-- AUTO-GENERATED-START: docs-quick-reference -->
### 백엔드 [backend/](docs/backend/) (22개)
### 백엔드 [backend/](docs/backend/) (31개)
| 문서 | 설명 | 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... |
| [admin-settings-access.md](docs/backend/admin-settings-access.md) | Admin 환경설정 값 접근 (`g7_core_settings` vs `config()`) | 동기화 SSoT: storage/app/settings/*.json → SettingsServicePr... |
| [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) |
| [console-confirm.md](docs/backend/console-confirm.md) | 콘솔 yes/no 프롬프트 (ConsoleConfirm) | 콘솔 커맨드의 yes/no 프롬프트는 $this->unifiedConfirm() 사용 — Laravel... |
| [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 조작... |
| [dto.md](docs/backend/dto.md) | DTO (Data Transfer Object) 사용 규칙 | DTO 두 패턴 — Value Object(불변 1회 전달) vs Data Carrier(다단계 변형/... |
| [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 미들웨어... |
| [identity-messages.md](docs/backend/identity-messages.md) | 본인인증 메시지 템플릿 시스템 (Identity Messages) | 알림 시스템(notification_*)과 완전 분리된 IDV 전용 템플릿 인프라 |
| [identity-policies.md](docs/backend/identity-policies.md) | 본인인증 정책 시스템 (Identity Policies) | - |
| [identity-providers.md](docs/backend/identity-providers.md) | IDV Provider 작성 가이드 (Identity Verification Providers) | VerificationProviderInterface 구현 + IdentityProviderManage... |
| [language-pack-service.md](docs/backend/language-pack-service.md) | LanguagePackService (백엔드 Service 레이어) | LanguagePackService 가 install/activate/deactivate/uninsta... |
| [middleware.md](docs/backend/middleware.md) | 미들웨어 등록 규칙 | 인증 필요 미들웨어 → 전역 등록 금지! |
| [notification-system.md](docs/backend/notification-system.md) | 알림 시스템 (Notification System) | GenericNotification 범용 클래스 1개로 모든 알림 처리 (개별 클래스 불필요) |
| [response-helper.md](docs/backend/response-helper.md) | API 응답 규칙 (ResponseHelper) | 모든 API 응답은 ResponseHelper 사용 |
@@ -30,10 +37,12 @@
| [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 주입 필수 (구체 클래스 직접 주입 금지) |
| [settings-multilingual-enrichment.md](docs/backend/settings-multilingual-enrichment.md) | Settings 카탈로그 다국어 자동 보강 | settings JSON 의 다국어 카탈로그 라벨(_cached_name 등)은 카탈로그 빌드 시점에 보강 |
| [translatable-seeders.md](docs/backend/translatable-seeders.md) | 다국어 시더 인터페이스 (Translatable Seeders) | 다국어 JSON 컬럼(name 등)을 시드하는 확장 entity 시더는 TranslatableSeede... |
| [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개)
### 프론트엔드 [frontend/](docs/frontend/) (50개)
| 문서 | 설명 | TL;DR 핵심 |
|------|------|-----------|
@@ -59,6 +68,8 @@
| [g7core-api-advanced.md](docs/frontend/g7core-api-advanced.md) | G7Core 전역 API 레퍼런스 - 고급 | - |
| [g7core-api.md](docs/frontend/g7core-api.md) | G7Core 전역 API 레퍼런스 | G7Core.state: get/set/subscribe 전역 상태 관리 |
| [g7core-helpers.md](docs/frontend/g7core-helpers.md) | G7Core 헬퍼 API | - |
| [identity-guard-interceptor.md](docs/frontend/identity-guard-interceptor.md) | IdentityGuardInterceptor — 코어 본인인증 인터셉터 레퍼런스 | ActionDispatcher.handleApiCall 응답 후처리에서 isIdentityRequire... |
| [identity-verification-ui.md](docs/frontend/identity-verification-ui.md) | 본인인증(IDV) 공통 UI 가이드 | 모든 IDV 강제 지점은 동일한 428 응답 형식을 공유 (코어 9 + 게시판 4 + 이커머스 4 + N) |
| [layout-json-components-loading.md](docs/frontend/layout-json-components-loading.md) | 레이아웃 JSON - 데이터 로딩 및 생명주기 | - |
| [layout-json-components-rendering.md](docs/frontend/layout-json-components-rendering.md) | 레이아웃 JSON - 조건부/반복 렌더링 | - |
| [layout-json-components-slots.md](docs/frontend/layout-json-components-slots.md) | 레이아웃 JSON - 슬롯 시스템 | - |
@@ -86,30 +97,35 @@
| [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/) (25개)
### 확장 시스템 [extension/](docs/extension/) (30개)
| 문서 | 설명 | 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에 변경사항 기록 필수 (미기록 시 버전 업 불가) |
| [editor-spec.md](docs/extension/editor-spec.md) | 편집기 스펙 (editor-spec.json) | editor-spec.json = 편집기 팔레트/스타일 컨트롤/중첩 규칙/샘플 데이터/레시피의 선언 (... |
| [extension-manager.md](docs/extension/extension-manager.md) | ExtensionManager (확장 관리자) | composer.json 수정 없음 - 런타임 오토로드 방식 사용 |
| [extension-update-system.md](docs/extension/extension-update-system.md) | 확장 업데이트 시스템 (Extension Update System) | 업데이트 감지 우선순위: GitHub > _bundled (2단계, _pending 미참여) |
| [hooks.md](docs/extension/hooks.md) | 훅 시스템 (Hook System) | Action 훅: doAction() - 부가 작업 (로그, 알림, 캐시) |
| [language-packs.md](docs/extension/language-packs.md) | 언어팩 시스템 (Language Packs) | 코어/번들 확장의 lang/{ko,en}/ 는 가상 보호 행으로 자동 노출 (DB 없이 항상 activ... |
| [layout-extensions.md](docs/extension/layout-extensions.md) | 레이아웃 확장 시스템 (Layout Extensions) | - |
| [menus.md](docs/extension/menus.md) | 메뉴 시스템 | 구조: User → Role → role_menus 피벗 → Menu |
| [module-assets.md](docs/extension/module-assets.md) | 모듈 프론트엔드 에셋 시스템 | module.json에 에셋 매니페스트 정의 (js, css, loading strategy) |
| [module-basics.md](docs/extension/module-basics.md) | 모듈 개발 기초 | 디렉토리: vendor-module (예: sirsoft-ecommerce) |
| [module-commands.md](docs/extension/module-commands.md) | 모듈 Artisan 커맨드 | 목록: php artisan module:list |
| [module-i18n.md](docs/extension/module-i18n.md) | 모듈 다국어 시스템 | 백엔드: /lang/{locale}/*.php → __('vendor-module::key') |
| [module-identity-settings.md](docs/extension/module-identity-settings.md) | 모듈/플러그인 본인인증(IDV) 설정 통합 가이드 | 정책/목적/메시지: module.php::getIdentity{Policies,Purposes,Mess... |
| [module-layouts.md](docs/extension/module-layouts.md) | 모듈 레이아웃 시스템 | 위치: modules/_bundled/vendor-module/resources/layouts/admi... |
| [module-routing.md](docs/extension/module-routing.md) | 모듈 라우트 규칙 | URL prefix 자동: /api/admin/[vendor-module]/... |
| [module-settings.md](docs/extension/module-settings.md) | 모듈 환경설정 시스템 개발 가이드 | - |
| [permissions.md](docs/extension/permissions.md) | 권한 시스템 | 구조: User → Role → Permission (기능 레벨) |
| [plugin-development.md](docs/extension/plugin-development.md) | 플러그인 개발 가이드 | 디렉토리: plugins/vendor-plugin (예: sirsoft-payment) |
| [sample-extensions.md](docs/extension/sample-extensions.md) | 학습용 샘플 확장 (Sample Extensions) | 샘플 확장 4종: gnuboard7-hello_module / _plugin / _admin_templ... |
| [storage-driver.md](docs/extension/storage-driver.md) | 스토리지 드라이버 시스템 (StorageInterface) | 모든 파일 저장은 StorageInterface 사용 (Storage::disk() 직접 호출 금지) |
| [template-basics.md](docs/extension/template-basics.md) | 템플릿 시스템 기초 | 타입: Admin (관리자용), User (일반사용자용) |
| [template-caching.md](docs/extension/template-caching.md) | 템플릿 캐싱 전략 | - |
| [template-commands.md](docs/extension/template-commands.md) | 템플릿 Artisan 커맨드 | 목록: php artisan template:list |
| [template-idv-bootstrap.md](docs/extension/template-idv-bootstrap.md) | 템플릿 IDV launcher 등록 가이드 | 템플릿 부트스트랩(initTemplate)에서 window.G7Core.identity.setLaunc... |
| [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... |
@@ -124,7 +140,7 @@
| [database-guide.md](docs/database-guide.md) | 그누보드7 데이터베이스 개발 가이드 | 마이그레이션: 한국어 comment 필수, down() 구현 필수 |
| [requirements.md](docs/requirements.md) | 그누보드7 시스템 요구사항 (System Requirements) | PHP 8.2+ 필수 |
| [testing-guide.md](docs/testing-guide.md) | 그누보드7 테스트 가이드 | 테스트 통과 = 작업 완료 (작성만으로 불충분!) |
| [auto-document.md](.claude/docs/auto-document.md) | 자동 문서화 (auto-document) | - |
| [e2e-testing.md](docs/testing/e2e-testing.md) | 그누보드7 Playwright E2E 테스트 가이드 | - |
<!-- AUTO-GENERATED-END: docs-quick-reference -->
@@ -176,6 +192,11 @@
| `handler: "nav"` | `handler: "navigate"` |
| `handler: "setLocalState"` | `handler: "setState"` + `target: "local"` |
| `navigate` + `replace: true` (URL만 변경 시) | `handler: "replaceUrl"` |
| apiCall `params.target` (params 내부) | `target` 은 액션 top-level. params 내부 위치 시 URL 미해석 |
| apiCall `params.onSuccess` / `params.onError` (params 내부) | 액션 top-level. params 내부면 무시됨 |
| `refetchDataSource` `params.id` | `params.dataSourceId` 사용 |
| `handler: "showToast"` | `handler: "toast"` |
| 모달 안에서 부모 `_local.*` 참조 | 데이터소스 응답 필드 또는 `_global` 사용 (모달은 별도 컨텍스트) |
### 데이터 바인딩
@@ -229,6 +250,29 @@
| 모든 API에 개별 headers 설정 | globalHeaders로 공통 헤더 정의 |
| pattern 없이 헤더 정의 | pattern 필수 (`*`, `/api/shop/*` 등) |
### 인증/리다이렉트 규칙 (engine-v1.47.0+)
| 금지 | 올바른 사용 |
|------|------------|
| 모듈/플러그인에서 `AuthManager.updateConfig()` 호출 | 템플릿 부트스트랩(`initTemplate`)에서만 호출 |
| `AuthManager.updateConfig({ loginPath: 'https://...' })` (외부 origin) | `loginPath` 는 `/` 로 시작하는 동일 origin path-only |
| `AuthManager.updateConfig({ loginPath: '//evil.com/...' })` (protocol-relative) | `//` 시작 금지 (open redirect 방지) |
| 401 에러 페이지(`errors/401.json`)에서 직접 로그인 리다이렉트 구현 | 코어 `TemplateApp.showRouteError` 가드에 위임 (자동 처리) |
### Listener 데이터 접근
| 금지 | 올바른 사용 |
|------|------------|
| Listener 에서 `Model::query/find/where/create` 직접 호출 | Repository 인터페이스 주입 후 위임 |
| Listener 에서 `DB::table()->update(...)` | Repository 의 도메인 의도 메서드 (recalculate*/anonymize* 등) |
| Listener 에서 `$row->save()` / `saveQuietly()` / `delete()` | Repository 의 update/save/delete 호출 |
| Listener 생성자에 구체 Repository 직접 주입 | Repository Interface 주입 |
| Listener 에서 `request()` / `$_POST` 직접 접근 | Service 가 검증 후 도메인 객체로 전달 받기 |
| Filter 훅에 `'type' => 'filter'` 누락 | type 명시 필수 (반환값 무시 회귀 차단) |
| Listener 가 `HookListenerInterface` 미구현 (auto-discovery 대상) | implements + `getSubscribedHooks()` 정적 메서드 |
> 상세: [hooks.md "Listener 데이터 접근 규정"](docs/extension/hooks.md), [service-repository.md](docs/backend/service-repository.md)
---
## 템플릿 엔진 내부 버전 (engine-v1.x.x)
@@ -338,8 +382,19 @@ Keep a Changelog 표준:
| API 변경 (엔드포인트, 파라미터) | 테스트 건수/파일명 |
| 기존 기능의 버그 수정 | 리팩토링 세부사항 |
| 성능 개선 (체감 가능한 것) | 내부 규정/문서 변경 |
| Breaking Change | 이슈 번호 |
| Breaking Change | 내부 작업 이슈 번호 단독 (예: `refs #347`) |
| 엔진 버전 참조 (engine-v1.X.Y) | 코드 패턴 설명 |
| **공개 제보자 attribution** (`(#N @login 님께서 제보해주셨습니다.)`) | — |
| **KISA 등 공식 보안 채널** (`(KISA 측에서 제보해주셨습니다 — KVE-XXXX-XXXXX)`) | — |
### 공개 제보자 attribution
공개 저장소(GitHub) 이슈로 제보·건의된 항목이 출시 CHANGELOG 에 반영되면, 항목 끝에 공개 이슈 번호와 제보자 GitHub 핸들 멘션을 부착합니다.
- 형식: `- (본문) (#N @login 님께서 제보해주셨습니다.)` 또는 `... 건의해주셨습니다.`
- 톤: 버그 리포트는 "제보", 제안형 개선 요청은 "건의"
- 다중 매칭: `(#A @x, #B @y 님께서 제보해주셨습니다.)` / 혼재: `(#A @x 님께서 제보해주시고, #B @y 님께서 건의해주셨습니다.)`
- KISA 등 공식 보안 채널: GitHub 멘션 없이 텍스트 "KISA 측에서" + 공개 가능한 식별자만
### 신규 기능의 버그 수정 제외 규칙
@@ -395,17 +450,18 @@ Added/Changed/Fixed 내 항목이 10개를 초과하면 `####` 서브 헤딩으
```text
기능 구현 = 테스트 코드 작성 필수
신규 기능 / 도메인 표면 변경 = 시나리오 매니페스트(tests/scenarios/<feature>.yaml) 작성 의무 — 입력 axis cross product + 후속 효과 체인 전수 커버
테스트 통과 = 작업 완료 (작성만으로 불충분!)
기존 테스트 있음 → 변경사항 반영하여 수정 후 실행
기능 구현 시 관련된 모든 계층(백엔드+프론트엔드+레이아웃 렌더링) 테스트 필수
주의: 모듈/플러그인 프론트엔드 테스트는 독립 vitest.config.ts 사용 (루트 config 포함 금지)
필수: 도메인 매트릭스로 테스트 유형 분류 (Pure Logic/CRUD/Hook/Migration)
필수: 도메인 매트릭스 = 테스트 위치/형식 가이드. 입력 조합 망라 의무는 시나리오 매니페스트가 SSoT
필수: 버그 수정은 먼저 실패하는 회귀 테스트 → fail 확인 → 수정 → green 4단계
필수: 테스트 중 발견한 무관 에러도 같은 세션에서 처리 (stale test 또는 로직 수정)
필수: 릴리스 전 composer test-smoke 통과
```
> 상세: [docs/testing-guide.md](docs/testing-guide.md) — 도메인 매트릭스, Pre-release Smoke Suite, 회귀 테스트 4단계, 무관 에러 처리
> 상세: [docs/testing-guide.md](docs/testing-guide.md) — 기능 단위 시나리오 매트릭스, 도메인 매트릭스, Pre-release Smoke Suite, 회귀 테스트 4단계, 무관 에러 처리
### 그누보드7 레이아웃 렌더링 테스트
@@ -512,6 +568,20 @@ powershell -Command "npm run test:run"
---
## npm install 규칙
기본 `npm install`은 `package-lock.json`을 자동 수정할 수 있으므로, lock 파일 변경 의도가 없는 의존성 복구나 작업 환경 재구성에는 `npm install --package-lock=false`를 사용합니다.
| 상황 | 권장 명령어 | 비고 |
| ---- | ----------- | ---- |
| 누락 의존성 복구 / 작업 환경 재구성 | `npm install --package-lock=false` | lock 파일 변경 없이 설치 |
| clean install | `npm ci` | `package.json`과 `package-lock.json`이 동기화된 경우 |
| 의존성 신규 추가/업데이트 | `npm install <pkg>` | lock 변경이 작업 범위에 포함된 경우만 |
lock 파일 변경 의도가 없는 상황에서 `npm install` 단독 실행을 피합니다. `module.json`, `plugin.json`, `template.json`의 `version`을 바꾸면 해당 확장의 `package.json`, `package-lock.json`, `composer.json` 버전도 함께 동기화합니다. 의존성 재설치 없이 lock 파일의 version 필드만 갱신할 때는 `npm install --package-lock-only`를 사용합니다.
---
## 핵심 원칙
### 1. 동적 로딩
@@ -706,6 +776,18 @@ php artisan module:update sirsoft-ecommerce --force
php artisan plugin:update sirsoft-payment --force
```
### 코어 3-번들 구조 + 공유 런타임 (engine-v1.51.0+)
`core:build` 는 코어 프론트엔드를 3개 IIFE 번들로 빌드한다:
| 번들 | 로드 시점 | vite config |
|------|----------|-------------|
| `template-engine.min.js` | 모든 페이지 (동기 `<script>`) | `vite.config.core.js` |
| `layout-editor.min.js` | `/admin/layout-editor/*` 진입 시 런타임 주입 | `vite.config.editor.js` |
| `devtools.min.js` | 디버그 모드에서만 런타임 주입 | `vite.config.devtools.js` |
lazy 번들(편집기/devtools)이 코어 런타임(DynamicRenderer·엔진 싱글톤·React Context·DevTools 코어)을 재사용할 때는 재번들하지 않고 `window.G7Core.__runtime` 을 빌려 쓴다 — React/컨텍스트/싱글톤 인스턴스 동일성이 강제되기 때문(사본이 둘이면 "Invalid hook call"·컨텍스트 미매칭). 메인 번들이 `G7CoreGlobals` 에서 공유 대상을 `G7Core.__runtime` 에 노출하고, lazy 번들 vite config 는 React 4종(`react`/`react-dom`/`react-dom/client`/`react/jsx-runtime`)을 external→window 로, 코어 런타임 모듈을 `resolveId` 플러그인으로 `__runtime-shims/` 로 치환한다.
### 빌드 명령어 (Artisan)
```bash
@@ -744,6 +826,7 @@ php artisan plugin:build sirsoft-payment --active # 활성 디렉토리에
# 코어 업데이트
php artisan core:check-updates # 코어 업데이트 확인
php artisan core:update [--force] [--no-backup] [--no-maintenance] # 코어 업데이트 실행
php artisan core:execute-upgrade-steps --from=X.Y.Z --to=A.B.C [--force] # 업그레이드 스텝 단독 실행 (HANDOFF 안내/수동 복구용 — 사전·사후 단계 자동 수행)
# 모듈
php artisan module:list
@@ -824,6 +907,7 @@ php artisan migrate:rollback
| `app/Http/Requests/**` | [validation.md](docs/backend/validation.md) |
| `app/Repositories/**` | [service-repository.md](docs/backend/service-repository.md) |
| `app/Http/Resources/**` | [api-resources.md](docs/backend/api-resources.md) |
| `app/**/DTO/**`, `modules/**/src/DTO/**`, `plugins/**/src/DTO/**` | [dto.md](docs/backend/dto.md) |
| `database/migrations/**` | [database-guide.md](docs/database-guide.md) |
| `database/seeders/**` | [database-guide.md](docs/database-guide.md) |
| `resources/layouts/**/*.json` | [layout-json.md](docs/frontend/layout-json.md) |
@@ -831,6 +915,8 @@ php artisan migrate:rollback
| `templates/**/src/components/**/*.tsx` | [components.md](docs/frontend/components.md) |
| `modules/**/Listeners/**` | [hooks.md](docs/extension/hooks.md) |
| `plugins/**/Listeners/**` | [hooks.md](docs/extension/hooks.md) |
| `lang/{ko,en}/**/*.php` | [database-guide.md](docs/database-guide.md) (다국어 섹션) — 코어 백엔드 다국어 |
| `lang/{ko,en}.json`, `lang/partial/{ko,en}/**` | [data-binding-i18n.md](docs/frontend/data-binding-i18n.md) — 코어 프론트엔드 다국어 (`$t:core.*`) |
| `lang/**` | [database-guide.md](docs/database-guide.md) (다국어 섹션) |
| `routes/**` | [routing.md](docs/backend/routing.md) |
| `app/Seo/**` | [seo-system.md](docs/backend/seo-system.md) |
+500 -5
View File
@@ -4,6 +4,500 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [7.0.1] - 2026-07-03
### Changed
- 첫 화면 접속 시 내려받는 코어 스크립트 용량을 절반 이하로 줄여 초기 로딩을 개선했습니다. 레이아웃 편집기와 개발자 진단도구를 코어에서 분리해, 편집기는 편집 화면에 들어갈 때만, 진단도구는 디버그 모드를 켰을 때만 내려받도록 했습니다. 일반 사용자는 평소 쓰지 않는 편집기·진단도구 코드를 더 이상 받지 않습니다.
- 설치가 끝난 사이트에서 매 요청마다 반복되던 불필요한 데이터베이스 조회를 줄여 페이지 응답 속도를 개선했습니다. 알림·본인인증 설정을 준비하는 과정에서 이미 존재가 보장된 테이블을 매번 다시 확인하던 동작을 설치 완료 상태에서는 건너뛰도록 했습니다.
- 화면을 그릴 때마다 서버가 매번 확장 기능의 연결 정보를 처음부터 다시 계산하던 부담을 줄여 페이지 응답 속도를 개선했습니다. 이 연결 정보는 확장을 설치·수정하거나 코어를 업데이트할 때만 바뀌므로 그 시점에 한 번 저장해 두고, 이후 요청에서는 저장된 값을 재사용하도록 했습니다. 확장이나 코어를 변경하면 저장 값이 자동으로 갱신됩니다.
- 설치된 모듈·플러그인의 코드를 찾을 때마다 서버가 파일을 하나하나 탐색하던 부담을 줄여, 특히 공유 호스팅 등 파일 접근이 느린 환경에서 페이지 응답 속도를 개선했습니다. 확장 코드의 위치를 설치·수정 시점에 미리 정리해 두고 이후 요청에서는 그 목록을 바로 참조하도록 했습니다. 확장을 설치·수정·삭제하거나 코어를 업데이트하면 목록이 자동으로 갱신됩니다.
- 코어 업데이트 중 데이터 변환 단계가 현재 환경에서 자동으로 끝까지 실행되지 못하고 중단될 때, 남은 단계를 이어서 실행하는 안내를 실행 권한에 맞게 개선했습니다. 특히 관리자 권한(sudo)으로 업데이트한 경우, 남은 단계를 관리자 권한 그대로 실행하면 그 과정에서 만들어지는 파일이 관리자 소유로 남아 이후 웹사이트가 그 파일에 쓰지 못하는 문제가 생길 수 있습니다. 이제 이런 경우 웹 서버 실행 계정(예: www-data)으로 실행하도록 정확한 명령을 함께 안내하며, 웹 서버 계정을 자동으로 확인하지 못하면 어떤 계정으로 실행해야 하는지 확인 방법을 안내합니다.
### Fixed
- Windows 환경에서 코어 업데이트가 `public/storage` 링크를 복사하는 단계에서 "디렉토리는 복사할 수 없습니다" 오류로 중단되던 문제를 수정했습니다. Windows의 저장소 링크(정션)가 이제 링크 그대로 안전하게 보존되어 업데이트가 정상 완료됩니다.
- 별도 데이터 변환 작업이 필요 없는 버전으로 코어를 업데이트할 때, "실행할 업그레이드 단계가 없다"는 정상 상황을 실패로 표시하고 중단하던 문제를 수정했습니다. 이제 변환 작업이 없는 업데이트는 매끄럽게 완료되며, 이어서 설치된 확장의 최신 번들 반영 단계까지 정상적으로 진행됩니다.
- 일부 호스팅 환경에서 관리자 환경설정의 보안 화면에 들어갈 때 시스템 정보 조회가 전체 실패(오류 응답)하던 문제를 수정했습니다. 이제 CPU·메모리·디스크 등 특정 항목을 서버 정책상 읽을 수 없더라도 그 항목만 "알 수 없음"으로 표시하고 나머지 시스템 정보는 정상적으로 보여줍니다. 읽지 못한 항목은 로그로 원인을 확인할 수 있습니다. (#59 @glitter-gim 님께서 제보해주셨습니다.)
## [7.0.0] - 2026-07-01
### Added
- 모듈·플러그인이 기존 화면의 초기화 동작에 자신의 초기화 단계를 덧붙일 수 있도록 레이아웃 확장 기능을 확대했습니다. 화면이 특정 모듈을 직접 알지 않아도, 모듈이 설치되어 있을 때만 그 모듈의 초기화(예: 표시 통화 복원)가 함께 실행됩니다.
- 관리자 대시보드 통계에 설치된 템플릿 수(활성/전체)와 활성 언어팩 수(활성/전체)를 추가했습니다.
- 관리자 대시보드에 최근 발송된 알림 이력을 보여주는 "최근 알림" 위젯용 조회 기능을 추가했습니다. 발송 채널·수신자·제목·상태(성공/실패/건너뜀)·발송 시각을 최신순으로 확인할 수 있습니다.
- 본인인증 challenge 취소(cancel) 와 verification 토큰 소비(consume_token) 시점에 외부 본인인증 플러그인이 자기 데이터 정리 로직을 등록할 수 있는 훅 4건 추가.
- 플러그인 개발용 표준 베이스 클래스를 추가하여, 모듈 개발과 동일한 방식으로 Repository · 캐시 · 스토리지 의존성을 선언만으로 자동 주입받을 수 있도록 개선. 플러그인이 코어와 같은 표준 위에서 작성되어 일관된 캐시·파일 격리 동작을 제공합니다.
- 모듈·플러그인이 페이지에 주입하는 레이아웃 확장을 관리자 화면에서 직접 편집할 수 있도록 개선. 편집 내역은 버전으로 기록되어 이전 버전으로 복구할 수 있으며, 저장 전 미리보기를 제공합니다.
#### 위지윅 레이아웃 편집기 (신규 도입)
화면을 직접 보며 편집하는 위지윅 방식의 레이아웃 편집기를 이번 버전에서 새로 도입했습니다. 아래는 도입된 기능 영역별 상세입니다.
- 좌측 트리 — 편집 화면 좌측 목록을 트리 구조로 개편하여 일반 레이아웃과 확장 주입 레이아웃을 구분 표시. 확장 주입 레이아웃은 해당 템플릿에 실제 적용되는 항목만 표시됩니다.
- 편집 진입과 캔버스 — 템플릿 관리 화면에 코드 편집과 레이아웃 편집 진입을 분리하고, 운영 화면 좌측 하단에 편집 권한 보유자 전용 진입 버튼을 추가했습니다. 편집 캔버스는 실제 페이지와 동일한 렌더링 결과를 보여주며, 데스크탑·태블릿·모바일 폭을 즉시 전환해 반응형 결과를 확인할 수 있습니다. 데이터 소스가 정의된 화면은 샘플 데이터로 자동 채워져 실제 API 없이도 화면을 검토할 수 있고, 본문 영역에 사용된 위지윅 에디터 등 동적 자원도 캔버스 안에서 그대로 동작합니다. 좌측 트리에서 화면을 선택하면 주소창이 즉시 반영되어 새로고침·공유·뒤로가기 동작이 자연스럽고, 같은 주소로 직접 진입하면 해당 화면이 자동 선택됩니다. 편집 중 로그인이 풀리면 안내 후 자동으로 로그인 화면으로 이동하며 재로그인 시 편집 화면으로 복귀합니다. 권한 부족·서버 오류·네트워크 끊김 등은 상황별 안내 카드(아이콘·설명·다음 행동 버튼)로 표시합니다.
- 요소 추가·재배치 — 컴포넌트를 클릭해 선택하면 외곽에 + 버튼이 나타나고, + 버튼을 누르면 화면 중앙에 모달 팔레트가 열려 좌측 카테고리 사이드바와 우측 그리드 카드(아이콘 + 라벨 + 유형 뱃지)에서 추가할 컴포넌트를 고를 수 있습니다. 부모 컨테이너에 들어갈 수 있는 컴포넌트만 자동 필터링되며, 검색으로 컴포넌트를 빠르게 찾을 수 있습니다. 추가한 컴포넌트는 템플릿이 정의한 기본 모양·기본 텍스트로 캔버스에 바로 보이며, 추가 직후 해당 위치로 화면이 부드럽게 스크롤됩니다. 컴포넌트 우상단 ⓘ 아이콘으로 속성 설정·복사·삭제 메뉴를 열고, 호버/선택 시 외곽선·8방향 리사이즈 핸들 자리·잠금/네비게이션 어포던스가 표시됩니다. 변경 사항은 Ctrl+Z / Ctrl+Shift+Z 또는 툴바 ↶/↷ 로 되돌리거나 다시 실행할 수 있습니다. 편집 모드에서 컴포넌트를 클릭해도 페이지 이동이 일어나지 않고 선택만 되며, 내부 라우트로 가리키는 요소는 "→ 이 화면 편집" 어포던스로 목적지 화면 편집 모드로 즉시 전환됩니다.
- 저장·충돌 방지 — 낙관적 잠금으로 저장 결과는 성공·검증 실패·동시 저장 충돌·네트워크 오류 등 상황별 안내(성공 토스트 / 오류 배너 / 충돌 모달)로 즉시 표시됩니다. 같은 레이아웃을 다른 관리자가 먼저 저장했으면 충돌 안내 모달에서 최신 버전 불러오기를 선택할 수 있고, 신규 추가한 컴포넌트가 그 사이 비활성화된 모듈·플러그인 소속이면 저장이 자동 차단됩니다. 저장 직후에는 되돌리기 이력이 정리되어 저장 시점이 새 기준이 되며, 변경 사항이 있는 상태에서 다른 화면으로 이동하려 하면 저장하지 않은 내용이 있다는 안내가 먼저 표시됩니다. 로그인·회원가입 등 비로그인 전용 화면을 편집할 때는 로그인 상태 미리보기가 적용되지 않아 안내 토스트가 잘못 뜨지 않습니다.
- 속성 편집 — 요소를 선택하면 정렬·글자 크기·굵기·색상·여백·크기 등을 친화적인 컨트롤(분절 버튼·슬라이더·색상 선택·태그 입력)로 조정할 수 있고, 요소 모서리·변을 직접 드래그해 가로·세로 크기를 바꿀 수 있습니다(드래그 크기 조정도 되돌리기/다시 실행에 반영). 반복 영역(목록처럼 같은 틀이 여러 번 펼쳐지는 자리)의 속성을 편집하면 모든 항목에 함께 적용됩니다. 요소를 편집하는 동안에는 선택한 요소만 밝게 유지되고 나머지 화면은 어둡게 잠겨, 다른 요소를 실수로 건드리지 않습니다. 속성 편집 팝업은 헤더를 잡아 옮길 수 있으며 선택한 요소를 가리지 않도록 빈 공간으로 자동 배치되고, 큰 요소에서도 요소 추가·메뉴 버튼이 크기 조절 핸들과 겹치지 않도록 여백을 둡니다. 고급 설정에서 요소를 볼 수 있는 권한을 권한 목록에서 골라 지정할 수 있습니다. 목록 있음·없음, 회원·비회원, 검증 실패·저장 중 같은 화면 상태를 미리보기로 전환해 데이터 의존 영역을 실제와 가깝게 검토할 수 있고, 같은 데이터 이름을 여러 확장이 쓰더라도 각 화면이 자기 출처의 샘플로 올바르게 표시됩니다. 라우트를 전환해도 저장하지 않은 편집 내용이 유지되며, 미저장 상태로 화면을 떠나거나 새로고침하면 경고가 표시됩니다. 마지막 저장 상태로 한 번에 되돌리는 초기화 버튼도 제공합니다.
- 이미지 관리 — 배경 이미지 등 레이아웃 첨부 이미지를 업로드·관리할 수 있으며, 상단 툴바와 배경 이미지 컨트롤에서 썸네일 갤러리를 열어 업로드·삭제·배경 적용을 할 수 있으며, 지정한 배경 이미지가 편집 캔버스에 즉시 반영되고(채움·맞춤·타일 표시 모드 포함) 모달을 다시 열어도 현재 설정이 그대로 복원됩니다. 발행된 배경 이미지는 일반 방문자 화면에서도 정상적으로 표시됩니다.
- 동작·표시조건·정렬 편집 — 요소를 선택하고 속성 편집 팝업에서 (1) 클릭·마우스 오버 시 동작을 "다른 페이지로 이동"·"안내 메시지 보여주기"·"화면 상태 바꾸기"·"목록 새로고침"·"서버에 보내고 결과 처리"·"결제 진입" 같은 친화 명칭으로 조립하고(서버 호출은 성공·실패 후속 동작까지 단계로 구성), (2) 로그인 회원·관리자·데이터 있음/없음·수정/새 화면·입력 오류 등 친화 조건을 "그리고/또는"으로 결합해 요소를 보여줄 조건을 정하며, (3) 가로·세로 컨테이너의 자식 배치(방향·정렬·줄바꿈·간격)와 정렬 박스 안 요소의 늘어남·정렬·순서를 시각 컨트롤로 편집할 수 있습니다. 핸들러·코드 용어 없이 친화 입력만 노출되며, 이동할 페이지·데이터 이름은 목록에서 선택합니다. "결제 진입" 동작은 특정 결제사에 고정하지 않고, 서버 응답이 알려주는 결제 수단(결제 핸들러·결제 데이터)을 데이터 검색에서 골라 연결합니다 — 결제 수단을 바꾸거나 추가해도 화면 편집 없이 동작합니다.
- 색 모드·디바이스별 스타일 — 속성 편집에 라이트/다크 색 모드와 디바이스별 편집을 더해, 스타일·표시조건 탭 상단의 서브탭에서 라이트/다크 색 모드와 기본값·PC·태블릿·모바일(+ 직접 지정하는 커스텀 화면 폭)을 선택해, 각 범위에 스타일·정렬·표시 조건을 따로 지정할 수 있습니다. 글자색·배경색은 패널의 프리셋 색을 고르면 라이트와 다크 모두에 적용되어 다크 화면용 색을 비개발자도 지정할 수 있고(컬러 피커로 직접 고른 자유 색은 라이트 화면 전용), 어느 범위에 기본값과 다른 설정이 있으면 탭에 표시점이 뜨며 "기본값으로 초기화"로 그 범위만 되돌릴 수 있습니다. 편집기 상단 라이트/다크 토글로 미리보기 색상 테마를 전환하면, 관리자 화면이 다크 테마여도 미리보기가 라이트/다크를 정확히 구분해 보여줍니다.
- 화면 문구 다국어 편집 — 상단 툴바의 콘텐츠 언어 선택으로 미리보기에 표시되는 화면 문구의 언어를 전환할 수 있고, 캔버스의 텍스트를 더블클릭하면 그 자리에서 바로 고칠 수 있습니다(Enter 로 확정, Esc 로 취소). 문구를 고치면 그 문구가 다국어 키로 자동 등록되어, 입력한 내용이 즉시 화면에 반영됩니다. 편집 중 떠 있는 서식 막대로 굵게·기울임·밑줄을 켜고 끄거나, 글자 크기·정렬·글자색을 목록에서 골라 적용할 수 있으며(색은 색상 미리보기와 함께 선택), 바뀐 서식은 편집 중에도 즉시 보입니다. 편집 안내 배지를 누르면 속성 편집의 번역 탭이 열려 모든 언어의 값을 한 번에 입력할 수 있어, 한 언어만 고치고 다른 언어 번역을 놓치는 일을 줄입니다. (데이터에 연결된 문구·반복 영역 문구는 직접 편집 대상에서 제외됩니다.)
- 모든 컴포넌트로 속성 편집 확대 — 어떤 요소를 선택해도 속성 탭에 "요소 ID" 칸이 항상 제공되어 화면 내 요소에 고유 식별자를 부여할 수 있으며(화면 안전 문자만 입력되고 한글·공백 등은 자동으로 걸러집니다), 컴포넌트별 편집 항목(링크 주소·이미지 주소·입력칸 종류·선택지·정렬 옵션 등)도 친화 컨트롤로 편집할 수 있습니다. 아이콘은 자유 입력 대신 검색 가능한 그리드에서 골라 지정할 수 있어(템플릿이 제공하는 아이콘 목록을 검색으로 빠르게 찾기), 아이콘 이름을 외우지 않아도 됩니다. 입력칸의 선택지처럼 여러 항목으로 된 값은 항목 추가·삭제·순서 변경으로 편집하며, 이미 데이터에 연결된 값은 실수로 덮어쓰지 않도록 보호됩니다.
- 모달 시각 편집 — 좌측 트리에 각 화면이 사용하는 모달 목록이 "이 화면의 모달" 그룹으로 표시되고(공통 레이아웃의 모달, 모듈·플러그인 화면의 모달, 별도 파일로 분리된 모달 포함), 각 모달은 "주문 취소"·"비회원 조회 비밀번호 재설정"처럼 친화 제목으로 표시됩니다. 모달을 선택하면 실제 화면 위에 모달이 열린 상태로 문구 수정·요소 추가·저장을 할 수 있습니다. 모달 바깥 영역은 어둡게 잠겨 클릭·드래그·편집이 차단되어 실수로 호스트 화면을 건드리지 않습니다.
- 버전 배지·복원 — 좌측 트리에 각 화면과 확장 주입 조각의 현재 저장 버전 배지가 표시되고, 버전 기록 모달에서 이전 버전과의 비교(diff)와 복원을 확장 주입 조각에서도 동일하게 사용할 수 있으며, 저장·복원 시 배지가 새로고침 없이 즉시 갱신되고 복원 결과가 캔버스에 바로 반영됩니다.
- 공통 레이아웃 편집 — 각 화면의 콘텐츠가 채워질 슬롯 자리가 점선 박스와 라벨로 표시되고(슬롯 자체는 편집 대상이 아니라 잠금), 헤더·푸터를 클릭해 바로 선택·편집할 수 있습니다. 모달 편집 중에도 편집 캔버스의 스크롤이 그대로 유지됩니다.
- 화면 상태 미리보기 — 모달·공통 레이아웃까지 확대해, 배송지 직접 입력, 모바일 메뉴 펼침, 쿠키 배너 표시, 본인인증 진행 모달처럼 특정 조작에서만 나타나는 영역을 상태 전환만으로 미리보며 편집할 수 있고, 모달 안에 확장이 주입한 영역도 시각 편집할 수 있습니다.
- 일부 템플릿(사용자 템플릿 등)의 화면을 미리볼 때 헤더가 "데이터를 표시할 수 없습니다"로 표시되지 않고, 미리보기용 샘플 데이터가 모든 템플릿에서 안전하게 렌더됩니다.
- 회원이 아닌 사용자(비회원)에게도 이메일 알림을 보낼 수 있도록 알림 시스템을 확장했습니다. 회원에게 알림을 보내던 것과 동일한 방식으로, 입력한 이메일을 수신자로 알림이 발송되며 발송 내역에도 함께 기록됩니다. 알림 종류별로 비회원 발송을 허용할 수단을 선택할 수 있어, 이메일은 비회원에게 보내되 사이트 안 알림처럼 로그인이 필요한 수단은 보내지 않도록 구분됩니다(새 알림 수단이 추가될 때 비회원 허용 여부를 별도로 정하지 않으면 기본적으로 보내지 않습니다). 비회원에게 가는 알림은 그 사용자가 보던 화면 언어로 발송됩니다.
### Changed
- 모든 번들 플러그인이 위 표준 베이스 클래스로 이관되어 캐시·스토리지 도메인이 코어 및 다른 확장과 명확히 격리되도록 개선. 한 플러그인의 캐시 키가 코어 캐시 영역을 침범하던 잠재 결함을 차단하며, 관리자 화면의 변경사항이 의도대로 즉시 반영됩니다.
- 템플릿이 정의하는 에러 페이지(404 / 403 / 500 / 503 / 401 / 점검) 의 식별자를 다른 모든 페이지와 동일한 디렉토리 접두사 포함 형식(`errors/{코드}`) 으로 통일. 템플릿 설치 시 에러 페이지가 일관된 식별자로 시드되며, 일부 환경에서 에러 페이지가 "찾을 수 없음" 응답으로 떨어지던 회귀를 차단합니다. 번들 템플릿(`sirsoft-basic`, `sirsoft-admin_basic`) 의 에러 페이지 설정도 함께 정합화됩니다.
- 템플릿 외부 리소스 위임 — Font Awesome·웹폰트 등 외부 CDN 리소스를 페이지 코드에서 하드코딩하지 않고 템플릿의 `externals` 선언으로 위임. 외부 스타일시트·웹폰트·스크립트와 preconnect·preload 등 리소스 힌트를 한 곳에서 관리하며, 관리자 템플릿과 사용자 템플릿에 동일하게 적용
- 템플릿 컴포넌트 에셋 캐시 버스팅 — `?v=time()` → `?v={{ $extensionCacheVersion }}`로 변경하여 매 요청 무효화 → 확장 변경 시점에만 무효화
- 모듈·플러그인 업데이트 시 관리자가 편집한 확장 레이아웃을 보존하거나 덮어쓰는 전략을 선택할 수 있도록 개선.
- 코어 업그레이드 스텝 명령에 보조 단계(권한 정상화·마이그레이션·캐시 정리 등)를 건너뛰고 스텝만 실행하는 옵션 추가.
- 코어 영역의 프론트엔드 다국어 자원(에러 메시지·시스템 토스트 등) 위치를 정리하여 어떤 템플릿이 부팅되어도 자동 노출되도록 개선. 이전에 코어 에러 화면에서 `core.errors.*` 같은 키가 raw 로 노출되던 결함이 해소됩니다.
### Fixed
#### 본인인증(IDV)
- 정책 관리에서 코어·모듈·플러그인이 제공한 정책의 "인증 목적"·"적용 대상"·"우선순위"를 바꿔 저장해도 반영되지 않던 문제를 수정했습니다 — 이제 정책 키·강제 시점·강제 위치(정책이 걸리는 지점)만 고정되고, 그 외 모든 항목을 운영자가 자유롭게 변경할 수 있으며, 변경한 값은 확장 업데이트 후에도 유지됩니다.
- 한 화면에 본인인증 정책이 여러 개 걸리고 우선순위가 같을 때, 어느 정책이 먼저 적용될지 정해지지 않아 의도한 인증(예: 성인 인증)이 무시되고 다른 인증이 적용될 수 있던 결함을 수정했습니다. 이제 우선순위가 같아도 항상 동일한 정책이 적용되도록 적용 순서를 고정하고, 정책 추가·수정 화면에 우선순위 입력칸을 제공하며 같은 위치에 같은 우선순위의 활성 정책을 저장하려 하면 저장을 막고 안내합니다. 정책 목록에도 우선순위 열을 추가해 한눈에 확인할 수 있습니다.
- 성인 인증 같은 추가 조건을 함께 요구할 때, 그 조건을 충족하지 못해 인증이 실패하면 실패 사유 안내와 "본인 확인이 필요합니다" 안내가 동시에 뜨던 문제를 수정했습니다. 이제 추가 조건 미충족으로 실패하면 그 사유 안내 하나만 표시되며, 일반 본인인증 실패의 안내는 종전대로 표시됩니다.
- 환경설정의 기본 프로바이더 / 목적별 프로바이더 / 코드 유효시간 / 최대 시도 횟수 4개 항목이 admin UI 저장값을 무시하던 결함 수정. 변경한 환경설정값이 회원가입·비밀번호 재설정 등 모든 본인인증 흐름에 즉시 반영됩니다.
- 환경설정 > 본인인증 > 목적별 프로바이더에서 일부 본인인증 목적(예: 성인인증)의 프로바이더를 지정해 저장하면 "설정 저장에 실패했습니다" 오류로 전혀 저장되지 않던 결함 수정. 이제 모든 본인인증 목적의 프로바이더 매핑이 정상 저장됩니다. 아울러 저장 검증에 실패하면 문제가 된 목적별 프로바이더 입력칸이 빨갛게 강조되고 그 아래에 사유가 표시되어, 어느 항목을 고쳐야 하는지 바로 확인할 수 있습니다.
- 정책에 프로바이더가 지정되지 않은 경우 환경설정의 기본 프로바이더가 자동 적용되도록 보강. 운영자가 정책마다 프로바이더를 일일이 지정하지 않아도 환경설정 기본값으로 일관되게 동작합니다.
- 코드 입력 모달의 "남은 시도 횟수" 표시가 환경설정값과 무관하게 항상 5회로 굳어 있던 결함 수정. 운영자가 환경설정에서 지정한 한도가 사용자 화면에 정확히 표시됩니다.
- 인증 코드를 최대 횟수까지 잘못 입력해 시도가 소진되었을 때, 화면에 "최대 시도 초과 — 다시 요청해주세요" 안내 대신 일반 오류 문구("인증 코드가 올바르지 않습니다")가 표시되던 결함 수정. 시도가 소진되면 재요청을 안내하는 메시지가 정확히 표시됩니다.
- 본인인증을 사용하는 모듈·플러그인을 비활성화해도 해당 확장이 설정한 본인인증 요구가 계속 적용되던 결함 수정. 이제 확장을 비활성화하면 그 확장이 선언한 본인인증 정책도 함께 적용 해제되고, 다시 활성화하면 운영자가 조정한 설정을 유지한 채 정상 복구됩니다.
#### 확장 동기화·캐시·레이아웃 주입
- 확장 메뉴 동기화 시점에 운영자가 수정하지 않은 필드 (아이콘·이름·정렬·URL) 가 잘못 "사용자 수정" 으로 표시되어 이후 모듈/플러그인 정의값으로의 자동 동기화가 영구히 차단되던 결함 수정. 확장 재설치·업데이트 시 메뉴 정의 변경이 정상 반영됩니다.
- 확장 역할 동기화 시점에 운영자가 수정하지 않은 필드 (역할 이름·설명) 가 잘못 "사용자 수정" 으로 표시되어 이후 모듈/플러그인 정의값으로의 자동 동기화가 영구히 차단되던 결함 수정. 메뉴 동기화와 동일 맥락으로, 확장 재설치·업데이트 시 역할 정의 변경이 정상 반영됩니다.
- 모듈·플러그인의 레이아웃 확장이 대상이 아닌 템플릿(관리자 확장 ↔ 사용자 템플릿)에도 등록되던 결함 수정. 업그레이드 시 잘못 등록된 항목을 자동 정리하며, 이후로는 대상 레이아웃이 존재하는 템플릿에만 등록됩니다.
- 모듈·플러그인이 공통 레이아웃(헤더·푸터 등 모든 화면이 공유하는 틀)에 주입한 UI·초기화 동작이 실제 화면에는 전혀 나타나지 않던 결함 수정. 개별 화면이 공통 레이아웃을 물려받을 때 그 "물려받은 출처" 정보가 사라져, 공통 레이아웃을 대상으로 한 확장 주입이 어느 화면에도 적용되지 않았습니다. 이제 화면이 물려받은 공통 레이아웃을 대상으로 한 확장도 정상 적용되어, 모듈이 설치되어 있으면 해당 UI(예: 헤더 통화 선택기)와 초기화 동작이 모든 관련 화면에 표시됩니다.
- 확장(모듈·플러그인) 변경 후 라우트 트리·메뉴·다국어 응답이 옛 값으로 남아 있던 결함 수정. 이전에는 클라이언트가 캐시 버전 신호를 동반하지 않아 백엔드 캐시가 갱신 시점을 놓쳤습니다. 확장 설치/활성화/업데이트가 일어나면 다음 페이지 로드부터 새 값이 즉시 보입니다.
- 확장을 업데이트하거나 레이아웃을 저장해도 변경 내용이 화면에 반영되지 않고 옛 내용이 계속 보이던 결함 수정. 캐시 갱신 신호가 기록되는 저장소와 읽히는 저장소가 환경설정에 따라 어긋날 수 있어, 올린 갱신 신호가 화면 쪽에서 보이지 않던 문제였습니다. 이제 갱신 신호가 항상 같은 저장소로 일관되게 기록·조회되어 확장 업데이트·레이아웃 변경이 다음 화면 로드부터 즉시 반영됩니다.
- 코어 업데이트 시 사용자가 모듈·플러그인·템플릿·언어팩의 `_bundled` 폴더 아래에 직접 만들어 둔 커스텀 폴더·파일이 함께 삭제되던 결함 수정. 이제 코어 업데이트는 코어가 제공하는 번들 확장만 갱신하고, 사용자가 추가한 항목은 그대로 보존합니다. (#43 @bigmsg 님께서 제보해주셨습니다.)
- 관리자 권한(`sudo`)으로 코어를 업데이트한 뒤, 모듈·플러그인·템플릿·언어팩을 업데이트하려 하면 "백업 생성 중" 단계에서 `mkdir(): Permission denied` 오류로 실패하던 결함 수정. 관리자 권한 업데이트가 백업 폴더와 업그레이드 기록 파일을 관리자 소유로 남겨, 이후 웹서버 계정이 같은 위치에 쓰지 못하던 문제였습니다. 이제 이들 폴더·파일의 소유권과 그룹 쓰기 권한이 정상화되고, 코어와 확장의 업그레이드 기록을 별도 파일로 분리해 두 작업이 서로의 기록 파일을 두고 충돌하지 않습니다.
#### 알림
- 회원에게 보내는 알림(이메일·사이트 알림)이 보내는 사람(관리자 등)의 화면 언어가 아니라 받는 회원 본인의 언어로 발송되도록 수정. 관리자가 한국어 화면에서 다른 언어를 쓰는 회원의 주문 상태를 바꿔도, 알림은 그 회원의 언어로 전달됩니다.
- 신규 주문 접수 시 관리자에게 발송되는 알림 메일의 결제금액이 0원으로 표시되던 결함 수정. 결제 예정 금액이 정확히 표시됩니다.
- 알림 템플릿을 기본값으로 복원할 때 제목·본문만 복원되고 수신자·클릭 주소는 관리자가 수정한 값이 그대로 남던 결함 수정. 이제 복원 시 수신자·클릭 주소까지 기본값으로 정확히 되돌아갑니다.
#### 설치·인스톨러
- 설치 마법사에서 데이터베이스 테이블 접두사를 지나치게 길게 입력하면 일부 확장 설치 시 테이블 인덱스 이름이 데이터베이스 한도를 넘겨 설치가 실패할 수 있던 문제를 예방합니다 — 접두사 입력 길이를 6자로 제한하고, 한도를 넘기면 설치 전 단계에서 안내합니다.
- 인스톨러에서 "기존 테이블 삭제 후 설치"를 선택할 때, 입력한 테이블 접두사와 무관한 다른 테이블까지 모두 삭제되던 결함 수정. 이제 입력한 접두사로 시작하는 테이블만 삭제되어, 같은 데이터베이스를 다른 프로그램과 함께 쓰거나 다른 접두사로 재설치할 때 무관한 테이블이 보존됩니다.
#### 관리자 화면·검색·시스템·보안
- 관리자 대시보드의 최근 활동·모듈/플러그인/템플릿 상태·게시판/이커머스 위젯 등 같은 틀이 여러 번 펼쳐지는 목록에서, 항목마다 고유 식별자를 붙이도록 작성된 화면이 모든 행에 같은 식별자를 그대로 내보내 일부 보조 기능·접근성 도구가 항목을 구분하지 못하고 일부 항목이 화면에서 누락되어 보이던 결함을 수정했습니다 — 이제 반복 목록의 각 행이 고유하게 식별되어 모든 항목이 정상 표시됩니다.
- 한 화면 안에서 탭이나 필터를 클릭으로 전환할 때, 화면에 따라 표시 조건에 묶인 데이터 목록이 비어 보이던 결함 수정. 새로고침해야 보이던 목록이 이제 탭·필터 전환 즉시 정상 표시됩니다. (표시 조건에 따라 로딩되는 데이터 목록이 전환 시점에 다시 평가되지 않던 문제였습니다.)
- 환경설정 화면 등에서 별도 표시 텍스트 없이 켜고 끄는 토글·체크박스를 켜서 저장하면 "참/거짓 값이어야 합니다" 오류로 저장이 거부되던 결함 수정. 토글의 켜짐/꺼짐 상태가 문자열이 아닌 참/거짓 값으로 정확히 전달되어 정상적으로 저장됩니다.
- 관리자 목록 화면에서 필터(발급상태·사용여부 등)를 선택하고 검색하면 일부 필터가 검색 직후 "전체"로 풀려 보이던 결함 수정. 새로고침해야 선택한 필터가 다시 표시되던 불편을 없애고, 검색 직후에도 선택한 필터가 그대로 유지됩니다.
- 검색창에 `<`, `>`, `(`, `+` 등 특수문자를 입력하면 오류가 발생하던 결함 수정. 특수문자는 일반 검색어로 처리되어, 어떤 문자를 입력해도 오류 없이 검색 결과(또는 빈 결과)가 표시됩니다.
- IME(한글·일본어·중국어) 입력 도중 Enter 로 글자를 조합 확정할 때 마지막 글자가 누락되거나 검색·제출이 두 번 실행되던 결함 수정. 이제 조합이 끝난 뒤의 Enter 만 동작합니다. (#54 @devrhee16 님께서 제보해주셨습니다.)
- 관리자 대시보드의 시스템 리소스(CPU·메모리·디스크) 수집이 일부 호스팅 환경(open_basedir 제한, 일부 시스템 함수 비활성)에서 실패할 때 대시보드 리소스 조회 전체가 오류로 응답하던 결함 수정. 이제 수집할 수 없는 항목만 "알 수 없음"으로 표시되고 나머지 화면은 정상 동작합니다. (#40 @glitter-gim 님께서 제보해주셨습니다.)
- 시스템 정보 화면의 디스크 사용량 조회도 동일한 환경에서 안전하게 "알 수 없음"으로 표시되도록 보강했습니다.
- 웹소켓 사용을 끈 상태에서도 실시간 연결이 일부 동작할 수 있던 문제 수정. 이제 웹소켓 사용을 끄면 실시간 연결·채널 인증이 완전히 비활성화됩니다. (#50 @bigmsg 님께서 제보해주셨습니다.)
- 사이트 이름을 언어별로 다르게 저장한 환경에서 브라우저 탭 제목과 검색 메타데이터(JSON-LD)가 올바르게 표시되지 않거나 페이지가 깨질 수 있던 문제 수정. 이제 모든 메타 경로가 현재 언어의 사이트 이름을 일관되게 사용합니다. (#49 @glitter-gim 님께서 제보해주셨습니다.)
- 존재하지 않는 페이지에 접근할 때 정상 페이지처럼 200 응답을 반환하던 문제 수정. 이제 미등록 경로는 올바른 404 응답을 반환해 검색엔진이 없는 페이지를 색인하지 않습니다. (#47 @glitter-gim 님께서 제보해주셨습니다.)
- 인증이 필요한 관리자 API에 로그인 없이 접근할 때, 브라우저 주소창 직접 입력 등 일부 요청에서 서버 오류로 응답하던 결함 수정. 이제 어떤 방식으로 접근해도 일관되게 "인증 필요" 응답이 반환됩니다. (#39 @glitter-gim 님께서 제보해주셨습니다.)
### Security
- 사용되지 않는 파일 서빙 엔드포인트(`/storage` 직접 업로드·다운로드 경로) 노출을 제거했습니다. 실제 파일 업로드·다운로드는 별도의 인증된 경로로 처리되며 영향이 없습니다. (#52 @glitter-gim 님께서 제보해주셨습니다.)
- 설치가 완료된 시스템에서 설치 상태 관리 엔드포인트(`/install/api/state-management.php`)가 다른 설치 API 와 동일하게 차단되지 않아, 설치 완료 후에도 설치 상태 조회·초기화·중단 요청이 가능하던 문제를 수정했습니다. 설치가 완전히 끝난 환경에서는 해당 요청이 모두 HTTP 410 으로 차단됩니다. 설치 진행 중 완료 화면으로 정상 전환되는 동작은 영향받지 않습니다. (KISA 측에서 제보해주셨습니다 — KVE-2026-1056)
### Removed
- 기본 제공 결제 수단을 KG이니시스로 단일화했습니다. 그 외 결제 연동(나이스페이먼츠·NHN KCP·토스페이먼츠)은 기본 번들에서 제외되며, 필요한 경우 별도 플러그인으로 설치해 사용할 수 있습니다. 이미 해당 결제 연동을 설치해 사용 중인 사이트는 영향받지 않습니다.
## [7.0.0-beta.7] - 2026-05-15
### Fixed
- 인스톨러 설치 완료 후 `.env` 에 DB 자격증명과 `APP_KEY` 가 기록되지 않고 `storage/installer/runtime.php` 에 평문으로 잔존하던 결함을 수정. 운영 동작은 정상이었으나 자격증명이 정상적인 위치로 옮겨지지 않고 임시 파일에 보존되는 상태였습니다. (#37 @glitter-gim 님께서 제보해주셨습니다.)
- beta.4~beta.6 출시본에서 위 결함으로 인해 자격증명이 잔존한 환경을 beta.7 업그레이드 시 자동으로 감지하여 `.env` 로 머지하고 임시 파일을 정리하도록 업그레이드 스텝을 추가했습니다. `.env` 의 `INSTALLER_COMPLETED` 마커가 이미 설정된 정합 이상 케이스는 자동 정리 대상에서 제외하고 운영자 수동 검토를 안내합니다.
- 위 업그레이드 스텝이 `.env` 를 머지할 때 권한·소유자·그룹을 웹 서버 기준으로 정렬하도록 보강. 기존 `.env` 가 있던 환경에서는 운영자가 설정한 권한을 그대로 보존하고, 새로 생성되는 환경에서는 웹 서버가 읽을 수 있는 권한과 그룹을 자동으로 적용합니다. 명령줄 업그레이드 실행 사용자와 웹 서버 사용자가 다른 자체 구축 서버 환경에서 머지 직후 웹 서버가 `.env` 를 읽지 못해 사이트가 다운되는 회귀를 차단합니다.
- 인스톨러 설치 완료 화면의 `.env` 권한 강화 안내(`chmod`/`chgrp` 명령 예시) 가 Windows 환경에서 비노출되도록 OS 가드 추가. POSIX 권한 모델이 없는 Windows 환경에서 무의미한 Linux/macOS 전용 명령이 노출되어 운영자 혼란을 유발하던 문제를 수정합니다.
- beta.4~6 출시본의 인스톨러 결함으로 자격증명이 `storage/installer/runtime.php` 에 잔존한 환경에서 beta.7 업그레이드 시 번들 확장 일괄 업데이트 단계가 `Access denied for user 'root'@'localhost'` 로 실패하던 회귀 수정. 업그레이드 스텝이 `.env` 머지 직후 부모 프로세스의 환경 변수도 함께 갱신하여 후속 자식 프로세스가 정상 자격증명을 상속받도록 보강합니다. 정상 설치 환경 (beta.3 등) 에서는 트리거되지 않습니다.
- 위 동일 회귀의 추가 차단 — 코어 업데이트가 별도 프로세스로 업그레이드 스텝을 실행하는 경로에서는 자식 종료 후 부모의 환경 변수가 갱신되지 않아 동일 증상이 재발하던 결함 수정. 자식 프로세스가 부팅 시점에 디스크의 `.env` 와 자신의 환경 변수를 대조하여 손상이 감지되면 디스크 값으로 보정하도록 보강하여, 다음 단계의 확장 일괄 업데이트가 정상 자격증명으로 진행되도록 합니다. 정상 설치 환경에서는 자동 비활성화됩니다.
### Changed
- `INSTALL.md` 에 "업그레이드" 섹션을 신설하여 자동 업데이트의 표준 절차, `sudo` 권장 사유 (`.env` · `bootstrap/cache` 의 권한 모델), 사용자군별 단계적 업그레이드 분기, 권한·캐시 트러블슈팅을 한곳에서 안내하도록 보완. 관리자 환경설정 > 정보 > 업데이트 확인 모달이 본 섹션으로 외부 링크를 노출합니다. (#30 @yks118 님께서 건의해주셨습니다.)
- 관리자 환경설정 > 정보 > 업데이트 확인 모달의 자동 업데이트 명령어 안내를 `sudo php artisan core:update` 권장 형태로 변경. 권한이 일치하지 않는 환경(`.env` 가 웹 서버 사용자 소유) 에서의 실행 실패를 사용자가 사전에 회피할 수 있도록 모달 하단에 "전체 업그레이드 가이드 보기" 외부 링크를 추가해 INSTALL.md 의 업그레이드 섹션으로 연결합니다. (#30 @yks118 님께서 제보해주셨습니다.)
### Notes — `.env` 권한 권장안
위 업그레이드 스텝은 `.env` 가 이미 존재하는 환경에서는 권한을 임의로 변경하지 않고 기존 상태를 그대로 보존합니다. 보안 위생 강화를 원하시면 다음 권장안을 참고하세요.
- 가장 안전한 권한은 `0600` (소유자만 읽기·쓰기) 입니다. `.env` 는 DB 비밀번호와 `APP_KEY` 등 평문 자격증명을 포함하므로 외부 접근을 최소화하는 것이 권장됩니다.
- 단, `0600` 적용 전에 **`.env` 의 소유자가 웹 서버 실행 사용자와 동일해야** 합니다 (예: 단일 사용자 환경의 `chown www-data:www-data .env && chmod 600 .env`). 소유자가 웹 서버 사용자와 다른데 `0600` 으로 변경하면 웹 서버가 `.env` 를 읽지 못해 사이트가 응답하지 않습니다.
- 명령줄 사용자와 웹 서버 사용자가 다른 환경 (자체 구축 서버 등) 에서는 `0640` 권한 + 그룹을 웹 서버 그룹으로 설정 (`chgrp www-data .env && chmod 640 .env`) 하는 것이 안전한 절충안입니다. 웹 서버는 그룹 멤버로 읽고, 명령줄 사용자는 직접 수정할 수 있습니다.
## [7.0.0-beta.6] - 2026-05-14
### Fixed
- 코어 업데이트 자동 롤백 시 신 버전이 추가한 신규 파일이 활성 디렉토리에 잔존하여 `BindingResolutionException` 등 부정합 부팅 실패가 발생하던 결함 수정. 백업 디렉토리에 신규 파일 manifest 를 기록하고 롤백 시 정확히 그 목록만 정리합니다. 사용자가 코어 영역에 직접 추가한 파일과 모든 symlink (`public/storage` 등) 는 보존됩니다. (#34 @bigmsg 님께서 제보해주셨습니다.)
- 자동 롤백 후 `bootstrap/cache` 잔존 PHP 캐시로 인한 추가 부팅 실패 차단 — 롤백 직후 캐시를 자동으로 정리합니다.
- beta.5 이전 자동 롤백으로 인해 활성 디렉토리에 잔존했을 수 있는 ServiceProvider 후보를 beta.6 업그레이드 스텝이 진단 로그로 식별하여 운영자 수동 검토를 안내합니다. 자동 삭제하지 않으므로 사용자가 직접 추가한 ServiceProvider 는 보존됩니다.
- 업그레이드 스텝 단독 실행 명령(`core:execute-upgrade-steps`) 이 마이그레이션·코어 재동기화·버전 갱신·캐시 정리·번들 확장 일괄 업데이트를 자동으로 함께 수행하도록 보강. 자동 업데이트 중단 후 안내된 수동 명령을 그대로 실행하면 별도 보조 작업 없이 업그레이드가 완료됩니다.
- 인스톨러 Step 3 (PHP CLI / Composer / 자동 감지 / 코어 _pending 경로 검증) 의 프론트엔드 catch 분기에서 사용하는 다국어 키가 언어 파일에 정의되어 있지 않아, 서버 응답 실패 시 화면에 다국어 키 문자열이 그대로 노출되던 결함 수정.
- 인스톨러 다국어 파일에 중복 정의된 키 4건 정리. POST 전용 API 의 메서드 거부 메시지가 일반 문구로 덮여 표시되던 문제, 데이터베이스 연결 실패 로그에서 placeholder 가 치환되지 않던 문제, HTTPS 카드 라벨이 안내 문장으로 표시되던 문제가 함께 해결됩니다.
- 인스톨러 Step 3 의 PHP CLI 및 Composer 절대경로 검증이 공유 호스팅 환경(시놀로지 DSM 등 `open_basedir` 가 시스템 binary 영역을 차단하는 환경)과 Windows 환경에서 정상 경로임에도 거부되던 결함 수정. 셸 인자 escape 와 메타문자 차단은 유지하고, 파일 시스템 권한에 의존하던 사전 검사를 실제 실행 결과로 판정하도록 변경합니다. Windows 의 백슬래시 경로 구분자도 정상 입력으로 인식됩니다 (단, `C:\Program Files\...` 같은 공백 포함 디렉토리는 공백 없는 경로 또는 8.3 short path 사용 권장). (#33 @glitter-gim 님께서 제보해주셨습니다.)
- 코어 운영 단계의 composer 바이너리 자동 인식이 같은 `open_basedir` 환경에서 실패하여 모듈/플러그인 설치 시 vendor-bundle 모드만 사용 가능하던 결함 수정. composer 가 정상 설치되어 PATH 에 존재하는 환경에서도 인식되도록 검출 로직을 개선했습니다.
- 멀티 PHP 버전 환경(시놀로지 DSM Web Station, cPanel/Plesk multi-PHP 등) 에서 Composer 를 특정 PHP 인터프리터로 실행하려는 "PHP 절대경로 + Composer 절대경로" 공백 분리 입력 형식이 거부되던 결함 수정. 입력을 두 토큰으로 분리한 뒤 각 토큰을 개별 escape 처리하여 안전을 유지하면서 멀티 PHP 운영 시나리오를 지원합니다.
- 인스톨러 세션 쿠키가 일부 환경(비표준 포트 + dynamic DNS 도메인 + 브라우저 추적 보호 등) 에서 브라우저에 의해 차단되어 첫 화면 "설치하기" 클릭이 동작하지 않고 새로고침처럼 보이던 결함을 보완. 세션 쿠키 SameSite 정책을 Strict → Lax 로 완화하여 차단 케이스 자체를 줄이고, Step 0 진입 시 쿠키 round-trip 사전 진단을 수행하여 차단이 감지되면 사용자에게 회피 방법(다른 브라우저 / 쿠키 차단 해제 / IP·localhost 접속) 을 명시 안내합니다.
### Added
- `hotfix:rollback-stale-files` Artisan 커맨드 추가 — 자동 롤백 후 활성 디렉토리에 잔존할 수 있는 신 파일을 운영자가 명시 실행하여 진단/정리할 수 있는 단발성 회복 도구. 기본은 진단 모드 (실제 삭제 없음), `--prune` 옵션 시 확인 프롬프트 후 정리. symlink 와 보호 경로(`storage/`, `vendor/`, `.env*` 등) 는 자동 제외.
- 본인인증 메시지 정의 목록 API 응답에 `can_create` 권한 키 추가 — 운영자의 수정 권한 보유 여부에 따라 관리자 화면이 "정의 추가" 버튼을 정확히 활성화/비활성화할 수 있도록 보강.
## [7.0.0-beta.5] - 2026-05-12
### Upgrade Notice
beta.4 → beta.5 업그레이드 시 beta.4 의 결함으로 인해 활성 확장 디렉토리 일부가 손실됩니다. (#34 @bigmsg 님께서 제보해주셨습니다.)
- 손실 항목 1: 운영자가 활성 디렉토리(`modules/{id}`, `plugins/{id}`, `templates/{id}`, `lang-packs/{id}`)에서 직접 수정한 코드
- 손실 항목 2: 외부 install 한 확장 (GitHub 등 `_bundled` 에 포함되지 않은 모듈/플러그인/템플릿/언어팩)
beta.5 의 자동 복구 스텝이 `_bundled` 기반으로 번들 확장은 복원하지만, 위 두 항목은 `_bundled` 에 없어 자동 복구가 불가능합니다.
업그레이드 전에 다음 디렉토리 전체를 별도 위치(외장 디스크, 별도 git 브랜치, tar.gz 파일)로 백업해두실 것을 권고합니다.
- `modules/`
- `plugins/`
- `templates/`
- `lang-packs/`
업그레이드 후 자동 복구된 활성 디렉토리에 본인의 수정 사항/외부 확장이 누락되어 있다면, 사전 백업에서 복원하거나 외부 확장은 원본 소스(GitHub 등)에서 재설치하세요. 실패한 외부 확장 식별자는 업그레이드 로그(`storage/logs/core_update_success_*.log`)에 명시됩니다.
#### 3가지 사용자군별 업그레이드 절차
- **사용자군 B — 정상 beta.4 사용자 (대다수)**: `php artisan core:update` 표준 절차. beta.5 의 자동 복구 스텝이 활성 확장 디렉토리 + lang-packs 정합성을 보장합니다.
- **사용자군 A — beta.3 → beta.4 업그레이드 도중 `Call to undefined method` 류 fatal 후 stuck 한 사용자**: `.env` `APP_VERSION` 을 변경하지 마시고 그대로 `php artisan core:update --force` 를 재실행하면 됩니다. 디스크는 이미 beta.4 이므로 새 자식 프로세스가 beta.5 메모리로 부팅되어 남은 업그레이드 스텝이 멱등 적용됩니다. 단, 활성 디렉토리에서 직접 수정한 코드 또는 `_bundled` 에 없는 외부 확장은 자동 복구 대상이 아니므로 업그레이드 전 위 디렉토리 백업이 동일하게 권장됩니다.
- **사용자군 C — beta.1 또는 beta.2 사용자**: `php artisan core:update` 1회 호출로 beta.5 까지 직접 업그레이드는 권장하지 않습니다. 중간 버전의 업그레이드 스텝이 이전 버전 메모리에 없는 신규 코드에 의존하여 fatal 위험이 있습니다. 다음 단계적 업그레이드를 권장합니다:
```text
beta.1 사용자: beta.1 → beta.2 → beta.3 → beta.4 → beta.5 (4 회)
beta.2 사용자: beta.2 → beta.3 → beta.4 → beta.5 (3 회)
```
각 단계마다 `.env` `APP_VERSION` 이 자동 갱신되므로 다음 호출은 그대로 `php artisan core:update` 실행하면 됩니다. 가까운 릴리즈 zip 으로 `--source=` 또는 `--zip=` 옵션을 사용해 단계별 진행 가능합니다.
#### 자동 롤백 후 `public/storage` 회복
업그레이드 도중 자동 롤백 후 화면에서 업로드 파일이 보이지 않거나 미디어 경로가 깨졌다면 `public/storage` symlink 가 일반 디렉토리로 변질된 상태일 수 있습니다.
**우선 시도 — beta.5 의 자동 복구**: beta.4 → beta.5 업그레이드 자체는 부모 beta.4 메모리 결함으로 symlink 가 깨질 수 있으나, beta.5 의 업그레이드 스텝이 다음 패턴을 자동 감지하여 복구합니다:
- 감지 조건: `public/storage` 가 일반 디렉토리 + `storage/app/public` 이 존재 (Laravel 표준 구성)
- 복구 동작: 손상된 디렉토리를 `public/storage.broken.YYYYMMDDHHMMSS` 로 보존 후 symlink 재생성
- 운영자 조치: 자동 복구 후 `.broken` 디렉토리의 콘텐츠를 검증한 뒤 수동 삭제 (false positive 방어를 위해 자동 삭제하지 않음)
**자동 복구가 작동하지 않은 경우 매뉴얼 회복**:
```bash
rm -rf public/storage
php artisan storage:link
```
beta.5 부터 백업/복원 과정에서도 symlink 가 보존되므로 beta.5 → beta.6 이후로는 본 결함이 자동 차단됩니다. Windows 환경에서는 PHP `symlink()` 가 `SeCreateSymbolicLink` 권한을 요구하므로 자동 복구가 silent skip 될 수 있어 매뉴얼 회복 절차가 여전히 필요합니다.
#### spawn 자식 프로세스 실패 시 동작 모드
beta.5 부터 코어 업데이트의 자식 프로세스 spawn 이 실패하면 즉시 abort 가 기본 동작입니다 (`G7_UPDATE_SPAWN_FAILURE_MODE=abort`). 다음 4가지 상황에서 부모 프로세스 메모리의 stale 클래스로 인한 fatal 위험을 차단합니다:
1. `proc_open` 비활성 (일부 공유 호스팅)
2. `proc_open` 자원 생성 실패
3. 자식 비정상 종료 (uncaught exception)
4. 자식 exit=0 이지만 업그레이드 스텝 0건 실행 (silent skip)
abort 발생 시 운영자에게 수동 재개 명령 (`php artisan core:execute-upgrade-steps --from=... --to=...`) 을 안내합니다. `proc_open` 비활성 환경 등 호환성이 필요한 경우 `.env` 에 `G7_UPDATE_SPAWN_FAILURE_MODE=fallback` 을 설정하여 in-process fallback 으로 진행할 수 있으나, 부모 메모리 stale 로 인한 fatal 위험이 잔존합니다.
### Added
- 본인인증 관리자 권한을 조회/수정으로 분리 — "프로바이더 설정 조회" / "정책 조회" 권한 신설로 단순 조회 권한만 부여받은 운영자도 환경설정 본인인증 화면에 접근 가능 (기존에는 수정 권한이 없으면 조회 화면도 403)
- 환경설정의 본인인증 정책/메시지 탭에 권한 기반 버튼 비활성화 적용 — 수정 권한이 없는 운영자에게는 "정책 추가" / "수정" / "삭제" / "활성 토글" 버튼이 자동으로 비활성화 (이커머스/게시판 본인인증 탭 동일)
- 코어 업데이트의 spawn 자식 프로세스 실패 시 abort/fallback 동작 모드 선택 (`G7_UPDATE_SPAWN_FAILURE_MODE`, 기본 `abort`) — 부모 메모리 stale 로 인한 upgrade step fatal 위험을 fail-fast 로 차단 (#28 @bigmsg 님께서 제보해주셨습니다.)
- 업그레이드 스텝에 "버전별 데이터 스냅샷" 규약 도입 — 멀티 버전 점프 시에도 사용자가 단계별로 업그레이드한 것과 동등한 결과를 보장하도록 각 스텝의 시드 카탈로그·적용 로직·단발성 핫픽스를 해당 버전 디렉토리로 격리. 외부 확장 작성자가 beta.5+ 신규 업그레이드 스텝 작성 시 적용 (상세: 업그레이드 스텝 가이드) (#29 @bigmsg 님께서 건의해주셨습니다.)
- 언어팩 일괄 활성화 검증 규칙에 동적 확장 훅 추가 (`core.language_packs.bulk_activate_validation_rules`) — 모듈/플러그인이 호스트 확장 재활성화 cascade 흐름에서 추가 검증 필드를 동적으로 등록 가능
### Changed
#### Breaking
- 본인인증 관리자 권한 키 정리 — 기존 `core.admin.identity.manage` / `core.admin.identity.policies.manage` 가 각각 `core.admin.identity.providers.update` / `core.admin.identity.policies.update` 로 변경. beta.4 환경에서 기존 권한을 부여받은 운영자 역할은 업그레이드 시 자동으로 새 권한 키로 마이그레이션 (부여 시점/부여자/스코프 메타데이터 모두 보존). 외부 확장 또는 운영 도구가 옛 키 문자열을 하드코딩한 경우 새 키로 갱신 필요
### Fixed
- 코어 업데이트 시 활성 모듈/플러그인/템플릿/언어팩 디렉토리가 삭제되던 문제 수정 — beta.4 → beta.5 업그레이드 시 번들 원본에서 활성 디렉토리를 자동 복원하고, 외부 설치 확장은 업데이트 로그로 재설치 안내
- 코어 업데이트의 단계 전환 신호를 비정상 입력으로부터 차단하도록 검증 강화
- 본인인증 정책 응답의 생성/수정 일시가 사용자 타임존이 아닌 UTC ISO 문자열로 노출되던 문제 수정
- 백업/롤백 시 `public/storage` 등 symlink 가 target 디렉토리 내용으로 추적 복사되어 symlink 가 일반 디렉토리로 변질되던 문제 수정 (Linux 환경 한정 — Windows 는 권한 한계로 일반 디렉토리 폴백 + 수동 회복 안내 유지)
- root/super user 환경에서 Composer 검증·설치가 비대화형 컨텍스트에서 실패하던 문제 수정 (코어 업데이트, 확장 의존성 설치, 인스톨러 포함) (#31 @glitter-gim 님께서 제보해주셨습니다.)
- 본인인증 활동 로그의 "액션" 열에 `identity.verify` / `identity.verify_failed` 가 번역되지 않은 키 그대로 노출되던 문제 수정 — 코어 액션 라벨 매핑이 누락되어 있던 부분을 추가
- 본인인증 단계에서 운영자가 지정한 본인인증 수단이 무시되고 항상 기본 수단(메일)으로 발행되던 문제 수정 — 가입 정책에 설정한 본인인증 수단과 API 요청에서 명시한 수단이 실제 인증 발행에 반영되도록 변경
## [7.0.0-beta.4] - 2026-05-11
### Changed
#### Breaking
- 레이아웃 `navigate` 액션의 기본 스크롤 동작이 페이지 이동 후 최상단 이동으로 변경됨 — 일반 하이퍼링크 이동 UX 와 일치. 이전처럼 스크롤 위치를 유지하려면 `scroll: "preserve"` 명시 필요. 검색 필터/페이지네이션 등 위치 유지가 필요한 화면에서 회귀가 의심되면 해당 옵션 추가 (engine-v1.45.0)
#### 언어팩 매니페스트 정합화
- 언어팩 매니페스트(`language-pack.json`) 를 모듈/플러그인/템플릿 매니페스트와 동일한 필드 구조로 정렬 — 외부 작성자가 다른 확장과 동일한 표준으로 언어팩을 만들 수 있도록 개선
- 언어팩 매니페스트에 GitHub 저장소 필드 추가로 GitHub 기반 업데이트 경로 지원 (모듈/플러그인 매니페스트와 동일한 동작)
- 관리자 환경설정의 언어팩 카드와 상세 모달이 모듈/플러그인/템플릿 카드와 동일한 형식으로 다국어 이름 · 설명 · GitHub 링크를 노출하도록 변경
### Added
#### Generator 메타 태그
- 관리자 환경설정 → SEO 탭에 Generator 메타 태그 카드 추가 — 토글로 노출 여부를 제어하고 내용 입력으로 W3Techs 등 CMS 시장 점유율 측정 도구가 인식하는 `<meta name="generator">` 태그를 SEO 봇 페이지·SPA·관리자 셸 모두에 출력. 내용 미입력 시 "GnuBoard7 {버전}" 자동 적용, 운영자가 버전 노출을 원치 않으면 "GnuBoard7" 만 입력 가능 (#26 @Lastorder-DC 님께서 건의해주셨습니다.)
#### 웹 인스톨러 — 번들 언어팩 동반 선택 · 설치
- 인스톨러 4단계 확장 선택 화면에 "언어팩" 카드 신설 — 번들 언어팩을 locale 별 서브헤딩으로 노출하고, 사용자가 모듈/플러그인/템플릿을 선택하면 그 확장에 종속된 번들 언어팩 카드가 즉시 활성화. 종속 확장을 해제하면 그 언어팩 카드는 자동으로 비활성화 + 선택 해제
- 5단계 설치 진행 시 모든 확장의 install/activate 가 끝난 뒤 선택된 번들 언어팩을 일괄 설치 — 1건 실패는 best-effort 처리로 전체 설치를 중단하지 않음
- 코어/확장당 다수 locale(일본어 + 중국어 등)이 동시에 번들된 시나리오 대응 — 각 locale 은 독립된 서브헤딩 + 카드로 노출
#### 인스톨러 SSE 호환성 자동 감지
- 인스톨러 시작 시 SSE 호환성을 사전 점검하여 환경에 맞는 모드(SSE 또는 폴링)로 단방향 진입 — 워커 동시 실행 race(테이블 / unique 키 중복 에러) 차단
- SSE 비호환 환경 사용자에게 폴링 모드 진행 여부를 명시적 다이얼로그로 확인 후 시작
#### ActivityLog 다국어 영역 분리
- 활동 로그의 액션 라벨이 모듈/플러그인 자체 다국어 파일에서 우선 해석되도록 변경 — 그동안 코어에 일괄 등록되어야 했던 모듈 origin 라벨(이커머스 주문·상품·마일리지, 게시판 게시물·댓글·신고, 페이지 등) 이 각 영역으로 이전되어 모듈/플러그인이 자기 도메인 라벨을 자기 영역에서 자기설명. 미정의 시 코어 라벨로 자동 fallback 하여 회귀 없음
- 모듈/플러그인이 발화하는 활동 로그가 호출자/대상 모델의 영역을 자동으로 인식하여 자기 다국어 파일로 라우팅 — 새 활동 로그를 추가할 때 별도 분기 코드 없이 자기 lang 만 채우면 정합
#### 다국어 시더 인프라
- 확장 entity 시더가 활성 언어팩의 다국어 데이터를 자동 머지하도록 다국어 시더 인터페이스 도입 — 신규 시더 작성 시 회귀를 자동 차단
- 번들 일본어 언어팩 빌드 스크립트가 시더 메타데이터를 일관된 경로로 조회하도록 단순화
#### 다국어 라벨 helper + Provider/Registry 페이로드 정합화
- 다국어 라벨 표시 시 활성 언어팩의 lang key fallback 을 자동으로 처리하는 다국어 라벨 보강 helper 추가 — Provider/Registry 등록 페이로드(알림 채널·결제 PG 등) 와 settings JSON 다국어 데이터를 단일 시그니처로 처리
- 알림 채널(`config/notification.php` 의 `default_channels`) 의 라벨이 활성 언어팩(일본어 등) 으로 자동 보강되도록 변경 — 일본어 활성 시 한국어 fallback 으로 노출되던 회귀 차단
- 토스페이먼츠 PG 프로바이더 등록 페이로드가 lang key 기반으로 라벨을 선언하도록 변경 — 활성 언어팩으로 자동 보강
- Provider/Registry 등록 페이로드가 다국어 JSON(`['ko' => ..., 'en' => ...]`) 을 직접 보유하지 않도록 audit 룰 신설 (회귀 자동 차단)
- settings JSON 다국어 entry 의 식별 키(code/id/key) 보유 검증 audit 룰 신설 (lang pack fallback 키 조립 가능성 보장)
- 프론트엔드 `$localized()` 표현식이 두 번째 인수로 lang key fallback 을 받도록 확장 — 활성 로케일 라벨 부재 시 모듈/플러그인 언어팩의 키로 자동 보강
- 이커머스 환경설정 카탈로그(배송 가능 국가 / 통화 / 결제수단) 표시 시 활성 언어팩의 라벨이 자동 적용되도록 변경 — 일본어 활성 시 카탈로그 라벨이 한국어로 노출되던 회귀 차단 (다음 일본어 언어팩 빌드 시 자동 적용)
- audit 룰이 `getDefault*Channels/Providers/Methods` 등 registry 기본값 반환 메서드의 다국어 JSON 직접 보유도 감지하도록 강화
#### Settings 카탈로그 다국어 자동 보강
- 환경설정 카탈로그(결제수단·통화·배송 가능 국가 등) 의 다국어 라벨이 카탈로그 빌드 시점에 활성 언어팩으로 자동 보강되도록 변경 — 사용자 체크아웃 + 관리자 환경설정 양쪽 모두 일본어 등 활성 시 한국어 fallback 으로 노출되던 회귀 차단
- 모듈/플러그인 개발자가 카탈로그를 추가할 때 누락하지 않도록 audit 룰이 helper 호출 누락 검출
- [docs/backend/settings-multilingual-enrichment.md](docs/backend/settings-multilingual-enrichment.md) 에 단순 helper 사용 패턴 문서화
#### 확장 시스템
- manifest `hidden: true` 플래그 추가 — 관리자 UI 목록에서 학습용/내부용 확장을 숨김 (CLI 는 정상 노출). 관리자 UI 에 슈퍼관리자 전용 "숨김 포함" 토글, artisan `module:list` / `plugin:list` / `template:list` 에 `--hidden` 플래그, 관리자 API `/api/admin/{modules,plugins,templates}` 에 `include_hidden` 쿼리 파라미터 지원
- 학습용 최소 샘플 확장 4종 번들 추가
- 모듈: `gnuboard7-hello_module` (Memo CRUD + 훅 발행 시연)
- 플러그인: `gnuboard7-hello_plugin` (Action/Filter 훅 구독 시연)
- Admin 템플릿: `gnuboard7-hello_admin_template` (Basic 컴포넌트 최소 셋)
- User 템플릿: `gnuboard7-hello_user_template` (홈 + Memo 리스트 연동)
- 모듈/플러그인/템플릿 정보 모달에 "지원 언어" 섹션 추가 — 코어/번들/사용자설치 출처 배지로 한눈에 확인
- 모듈/플러그인/템플릿 인스톨러에 의존 확장 + 동반 번들 언어팩 동반선택 UI 추가 — 미선택 의존성에 종속된 언어팩은 자동 비활성화
- 인스톨러 요구사항 검증 단계에 언어팩 디렉토리 쓰기 권한 점검 + 권한 부여 안내 추가
- 사용자 수동 비활성화와 코어 버전 호환성으로 인한 자동 비활성화를 DB 수준에서 구분 — 자동 비활성화된 확장만 재호환 감지/원클릭 복구 대상이 되도록 분리 (#18 @laelbe 님께서 제보해주셨습니다.)
- 코어 업그레이드 후 자동 비활성화 확장이 다시 호환되면 관리자 대시보드에 "다시 활성화" 알림 표시 + 원클릭 복구 버튼 제공 (자동 재활성화는 하지 않음 — 운영자가 명시적으로 복구)
- 모듈/플러그인/템플릿 목록 화면 상단에 자동 비활성화 확장 안내 배너 추가 (코어 업그레이드 가이드 링크 동반)
- 업데이트 모달에 코어 버전 호환성 안내 + "위험을 이해하고 강제로 진행" 체크박스 추가 — 운영자가 위험을 인지하면 비호환 확장도 강제 설치 가능
- 관리자 대시보드 "시스템 알림" 카드 노출 — 코어 호환성 자동 비활성화/재호환 알림이 분기 렌더되며 개별 dismiss 지원
#### 코어 업데이트 가시성
- 코어 업데이트 마무리 단계에서 sudo 환경 결함(파일시스템 ACL · immutable 비트 · NFS 권한 거부 등) 으로 일부 경로의 소유권/그룹 쓰기 권한을 정상화하지 못한 경우 즉시 콘솔에 실패 경로 + 운영자 수동 복구 명령(`sudo chown -R …` / `sudo chmod -R g+w …`) 안내 — 이전에는 silent fail 로 묻혀 운영자가 후속 권한 거부 발생 후에야 인지하던 문제 해소
#### 알림 시스템
- 권한 기반 수신자 타입 추가 — `permission` 타입으로 특정 권한을 가진 모든 사용자에게 알림 발송 가능 (예: 게시판 신고 알림은 신고 관리 권한자에게 자동 발송)
- 모듈 환경설정에서 알림이 비활성화된 경우 발송 자체를 사전 차단하는 정책 게이트 도입 — 이전에는 수신자 해석 단계가 정책을 우회하여 발송되던 문제 해소
- 게시판 환경설정 → 신고 정책 탭에 신고 알림 채널 선택 UI 추가 (이메일 / 사이트 알림 다중 선택)
- 모듈/플러그인이 자기가 발송하는 알림 정의를 manifest 에서 직접 선언하도록 통일 — 활성화/업데이트 시 운영자 편집값을 보존하면서 자동 등록되고, 제거 시 함께 정리됨. 권한·메뉴·본인인증과 동일한 declarative getter 패턴
- 코어 기본 알림(회원가입 환영·비밀번호 재설정·비밀번호 변경) 의 다국어 제목/본문/변수/채널 정의를 `config/core.php` 로 통합 — 운영자가 코드 수정 없이도 향후 표면 변경을 추적할 수 있는 단일 소스 확보
#### 본인인증
- 본인인증(IdentityVerification) 인프라 도입 — 코어에 범용 IDV 프로바이더 계약을 마련하고, 기본 메일 프로바이더를 내장. 플러그인이 KCP·이니시스·SMS 등 다른 경로를 동일 계약으로 붙일 수 있도록 확장점 제공
- 외부 본인인증 provider (KCP·PortOne·토스인증·Stripe Identity 등) 가 G7 표준 Extension Point 패턴으로 자기 SDK UI 를 주입할 수 있는 슬롯 도입
- 비동기 검증 흐름을 위한 백엔드 폴링/콜백 엔드포인트 추가 — `GET /api/identity/challenges/{id}` (상태 폴링), `POST /api/identity/callback/{providerId}` (외부 redirect 콜백 수신)
- 본인인증 정책 시스템 신설 — 회원가입·비밀번호 재설정·민감 작업에 적용되는 모든 본인인증 시점을 라우트/훅 단위로 선언형 정책으로 통합 관리. 관리자 화면에서 정책 활성/유예 시간/프로바이더/실패 모드/단계(가입 제출 전 vs 가입 후 활성화 전)·적용 대상(self/admin/both)을 조정할 수 있으며, 운영자 수정값은 업데이트 재시딩 시 보존됨
- 본인인증 정책의 enable 토글이 라우트 코드 수정 없이 즉시 적용 — 모든 API 라우트가 정책 DB 와 자동 매칭되어 운영자가 admin UI 에서 정책을 켜는 즉시 본인인증이 강제됨. 코어/모듈/플러그인 어떤 라우트의 응답 처리 패턴에서도 본인인증 흐름이 일관되게 모달까지 도달하도록 처리
- 회원가입 단계 정책 2종 시드 — 가입 제출 전 동기 검증, 가입 후 활성화 전 비동기 challenge (기본값 비활성, 운영자 opt-in)
- 본인인증 정책이 활성화된 모든 강제 지점(회원가입·비밀번호 재설정·민감 작업·게시판/이커머스 정책 등)에서 사용자/관리자 화면에 동일한 모달 UX 가 자동으로 표시되도록 코어 인터셉터와 공통 모달 인프라 도입
- 전역 프론트엔드 인터셉트 — 서버가 HTTP 428 본인인증 요구 응답을 반환하면 자동으로 인증 모달을 띄우고 사용자가 인증에 성공하면 원 요청을 자동 재실행
- 본인인증 가드가 모듈/플러그인이 선언한 훅에도 자동 적용되도록 동적 구독 도입 — 결제 직전·민감 액션 직전 등 도메인 특화 가드를 운영자 토글 한 번으로 활성화
- 관리자 화면에 본인인증 정책 관리 페이지 추가 (정책 목록 DataGrid + 편집)
- 관리자 화면에 본인인증 이력 페이지 추가 — 알림 발송 이력과 동일한 수준의 UI 제공: 인증 수단(Provider) 탭(활성화된 프로바이더에 따라 자동 갱신), 통합 검색(자동 감지/사용자 ID/대상 식별자/IP/정책 키), 상태·인증 목적·채널·발생 유형 다중선택 OR 필터, 날짜 범위 + 고급 검색(발생 유형·출처·정책 키) 토글 영역, 정렬·페이지 사이즈 선택, 행 펼침으로 인라인 상세 표시, 모바일 반응형, 사용자 타임존 기준 시각, 보관주기(180일) 일괄 파기, 인증 수단/발생 위치 다국어 라벨 표시
- 본인인증 목적(purpose) 의 출처(source) 추적 — 코어/모듈/플러그인이 선언한 목적을 구분해 환경설정 화면에서 분리 표시하며, 코어 정책 화면 상단에 "코어가 제공하는 본인인증 목적" 칩 섹션(목적 코드/라벨 + 설명·허용 채널 툴팁) 추가
- 모듈 환경설정의 본인인증 정책 탭을 코어 정책 관리 화면과 동일한 UI/UX 로 통일 — 검색·필터(강제 시점/출처)·페이지네이션·정책 추가/편집 모달 직접 호출(코어 화면으로 이동 X)·이 모듈이 등록한 본인인증 목적 칩 섹션(없으면 안내 메시지). 정책 목록의 "인증 목적" 컬럼이 코드명 대신 사람이 읽을 수 있는 라벨로 표시
- 모듈/플러그인이 자기 컨텍스트의 본인인증 정책·목적·메시지 정의를 manifest 에서 직접 선언하도록 통일 — `module.php::getIdentityPolicies()` / `getIdentityPurposes()` / `getIdentityMessages()` 선언만으로 코어 정책 관리에 자동 연동되며, 활성화/업데이트 시 운영자 편집값을 보존하면서 자동 등록·정리됨. 권한·메뉴·알림과 동일한 declarative getter 패턴
- 본인인증 정책/이력 목록 API 가 source 컨텍스트별 필터를 받아 모듈 환경설정 탭이 자기 정책/이력만 조회 가능하며, 운영자 정의 정책을 특정 모듈/플러그인 컨텍스트에 귀속시켜 추가할 수 있도록 source_identifier 입력 허용 (`admin` / `module:{id}` / `plugin:{id}`)
- 본인인증 메일 메시지 템플릿 시스템 도입 — 프로바이더와 (목적/정책)별로 다국어 제목/본문을 개별 정의 가능하며, 메시지 발송 시 정책 → 목적 → 프로바이더 기본값 순서로 fallback 해석. 회원가입/계정 변경/중요 작업/비밀번호 재설정 + 프로바이더 기본값 등 5종 메일 템플릿이 한국어/영어로 시드되어 즉시 발송 가능
- 본인인증 challenge 발급/검증/취소 라우트에 권한 미들웨어 + scope=self 가드 적용 — 게스트는 `core.identity.{request,verify,cancel}` 권한으로 비로그인 가입(Mode B) 흐름 진입, 로그인 사용자는 본인 challenge 만 다룰 수 있으며 관리자는 임의 challenge 도 처리 가능. 모달 취소 시 서버 cancel API 를 호출해 challenge 가 audit log 에 cancelled 상태로 즉시 기록되도록 정합화
- 환경설정 → 본인인증 탭에 "메시지 템플릿" 서브탭 신설 — 알림 템플릿 관리와 동일한 UX (채널 서브탭·페이지당 항목 수 셀렉터·카드 펼침 본문 미리보기·활성/기본 배지·페이지네이션) 로 정의 목록·활성 토글·다국어 편집(변수 가이드 + 기본값 복원) 제공. 운영자가 추가한 정책에 전용 메일 메시지 정의를 화면에서 직접 등록·삭제 가능 (시드 기본값 보호 + 정책 키 매칭 검증). 외부 본인인증 프로바이더 플러그인이 자기 메시지 기본값을 코어 복원 로직에 기여할 수 있는 필터 훅 노출
- 알림 발송 이력 / 본인인증 이력 샘플 시더를 코어/모듈별로 분리 — 코어 시더는 코어 정의·정책만, 각 모듈 시더는 자기 영역만 채우도록 영역 격리. 모듈은 `module:seed {id} --sample` 으로 자기 영역 이력만 독립 생성 가능. 샘플 데이터는 실제 등록된 사용자·정의·정책·프로바이더 기반으로 생성되어 운영 데이터와 동일한 스키마/분포 유지
- 모듈/플러그인 제거 시 코어 공유 테이블에 적재된 해당 확장의 데이터(권한·관리자 메뉴·알림 정의·본인인증 정책·본인인증 메시지 정의·본인인증 목적) 가 함께 정리되며, 제거 모달의 "삭제될 데이터" 에도 항목별 건수가 표시되도록 개선
- IDV 도메인 분류 데이터(목적·채널·트리거 출처·정책 범위·정책 실패 모드·정책 적용 대상·정책 출처·메시지 스코프) 8종을 PHP Backed Enum 으로 정의하여 정책 등록/수정 시 일관된 검증 적용
#### 인스톨러
- 인스톨러 설치 완료 화면에 설치된 코어 버전 표시 — 사용자가 어떤 버전이 설치되었는지 즉시 확인할 수 있도록 개선
#### 언어팩 시스템
- 새 언어(일본어/중국어 등)를 코어 수정 없이 추가할 수 있는 언어팩(Language Pack) 시스템 도입
- ZIP 업로드 또는 GitHub URL로 언어팩 설치/제거/활성화 지원
- 동일 언어 슬롯에 여러 벤더의 언어팩 공존 가능 — 라디오 전환으로 즉시 활성 변경
- 코어/모듈/플러그인/템플릿 별도 적용 — 모듈 언어팩은 해당 코어 언어팩이 활성일 때만 활성화 가능
- 관리자 메뉴: 환경설정 > 언어팩 관리(통합) + 모듈/플러그인/템플릿별 진입점 추가
- 사용자가 직접 수정한 다국어 키는 언어팩이 덮어쓰지 않도록 보존 정책 적용
- 보안: 언어 번역 외의 PHP 실행 코드 포함 시 설치 차단
- 언어팩 관리 화면을 모듈 관리와 동일 수준의 운영 도구로 보강 — 검색(식별자/벤더/언어), 업데이트 확인, 캐시 갱신, 다중 선택 일괄 제거 지원
- 활성/비활성 상태를 토글 스위치로 일원화 (보호된 팩은 비활성화된 토글로 표시)
- 업데이트 가능 항목에 "업데이트 가능" 배지와 행 단위 업데이트 실행 버튼 노출
- 정보 모달을 모듈 정보 모달과 동일한 4섹션 구조(기본정보 / 호환성 / 소스 정보 / 변경로그)로 제공하며, CHANGELOG.md 를 자동 파싱하여 버전별 카테고리로 표시
- 설치 모달에서 ZIP 업로드 전에 manifest 와 검증 결과를 사전 확인할 수 있는 미리보기 제공
- 모듈/플러그인/템플릿별 언어팩 페이지 진입 시 대상 확장 안내 배너와 환경설정 탭으로 회귀하는 링크 노출
- 언어팩 업데이트 권한을 별도 권한 키로 분리하여 설치/관리 권한과 독립적으로 부여 가능
- 언어팩 운영 풀 패리티 — 모듈/플러그인/템플릿 관리와 동일한 안전 장치/확장성 적용
- 공식 일본어(ja) 번들 언어팩 12종 추가 — 코어, 주요 모듈(전자상거래/게시판/페이지), 주요 플러그인(CKEditor5/마케팅/토스페이먼츠), 기본 템플릿(admin/user)을 일본어로 즉시 사용 가능
- 언어팩 관리 화면에서 번들 언어팩을 모듈/플러그인 관리와 동일하게 "미설치" 상태로 노출 — 행별 "설치" 버튼으로 즉시 설치 가능
- 언어팩 목록 필터에 "미설치 (번들)" 상태 옵션 추가
- 본인인증 메일 메시지 정의의 다국어 키를 언어팩으로 주입할 수 있도록 확장 — 코어 수정 없이 언어팩 ZIP 만으로 IDV 메일 본문/제목을 다국어화 가능
- 모듈/플러그인이 선언한 알림/본인인증 메시지 정의의 다국어 키도 언어팩으로 주입되도록 정합 — 이전에는 hook 발화 누락으로 코어 정의에만 적용되던 동작 정상화
- 업데이트 시 자동 백업 + 실패 시 직전 버전으로 자동 복구
- 동시성 가드 — 진행 중인 작업 동안 활성/비활성/제거/재업데이트 진입 차단
- 번들 디렉토리(`lang-packs/_bundled/{identifier}`) 에서 외부 다운로드 없이 (재)설치하는 경로 추가 — 코어 언어팩 복구/재배포 단순화
- 라이프사이클 훅 명명을 모듈/플러그인/템플릿과 통일 — `core.language_packs.{installed|updated|uninstalled|activated|deactivated}` 발행 (확장 가능성 확대)
- Artisan 커맨드 신규 — `language-pack:list`, `language-pack:install`, `language-pack:update`, `language-pack:uninstall`
- 코어와 번들 확장(모듈/플러그인/템플릿)에 내장된 한국어/영어를 가상 보호 언어팩으로 자동 노출 — 별도 설치 없이 언어팩 관리자에서 항상 활성/보호 상태로 확인 가능, 수정/제거 차단
- 호스트 확장 비활성화 시 그에 종속된 언어팩이 함께 비활성화되며, 재활성화 시 "다음 언어팩도 활성화하시겠습니까" 모달로 사용자 의사 확인 후 일괄 활성화
- 여러 언어팩을 한 번에 활성화하는 `POST /api/admin/language-packs/bulk-activate` API 추가 (의존성/버전 호환성 자동 검사)
- 언어팩 활성화 시 의존성 + 호스트 확장 버전 호환성 검사 자동 수행 — 호스트 확장이 비활성/미설치이거나 버전 미달이면 활성화 차단
- 언어팩 업데이트 우선순위를 모듈/플러그인 패턴으로 정합화 — GitHub 1순위 + bundled 폴백, 강제 업데이트 시 bundled 우선
- 언어팩 상세 모달에서 코어/번들 확장 내장 항목 클릭 시 "별도 언어팩이 아닌 내장 번역" 안내 배너 노출
- 모듈/플러그인/템플릿 상세 모달의 닫기 버튼이 콘텐츠 길이와 무관하게 모달 하단에 항상 보이도록 sticky 처리
#### SEO
- 관리자 환경설정 > SEO 탭의 Sitemap 카드에서 sitemap 을 즉시 재생성하고 마지막 생성 시각을 확인할 수 있는 "지금 생성" 버튼 추가 — 큐 드라이버와 무관하게 동기 실행되며 결과를 즉시 표시
#### SEO 봇 감지
- 봇 감지 엔진을 `jaybizzle/crawler-detect` 라이브러리로 교체. 기본 약 1,000종의 봇(검색엔진·링크 미리보기·AI 검색 등)이 자동 감지됨 — 링크를 슬랙·페이스북·LinkedIn·트위터·디스코드·텔레그램 등에 붙여넣으면 제목·설명·이미지 미리보기가 즉시 동작
- 라이브러리가 놓치는 봇 3종(`kakaotalk-scrap`·`Meta-ExternalAgent`·`ChatGPT-User`) 을 G7 보강 패턴으로 기본 포함
- 봇 감지 확장 훅 `core.seo.resolve_is_bot` 신설 — 플러그인이 IP 범위 검증·역방향 DNS·Cloudflare 봇 점수 등을 주입할 수 있는 슬롯
- "봇 라이브러리 사용" 관리자 토글 추가 (기본 on, 비활성 시 운영자 커스텀 목록만 사용하는 레거시 모드)
#### SEO OG / Twitter 카드 / 도메인 ownership
- 슬랙·페이스북 링크 미리보기에 이미지·카드가 표시되도록 OG 보강 태그를 자동 출력하도록 개선 — og:site_name, og:image:width, og:image:height, og:image:secure_url, og:image:type, og:image:alt, og:locale 추가
- Twitter 카드 메타태그(twitter:card / twitter:site / twitter:title / twitter:image 등) 출력 신설 — 슬랙 unfurl 폴백 경로 정상화
- 운영자 환경설정 SEO 탭에 "OG / Twitter 카드 기본값" 카드 추가 — 사이트 이름·이미지 기본 가로/세로·Twitter 카드 타입·Twitter 사이트 핸들 5개 입력
- 모듈·플러그인이 자기 도메인의 OG/Twitter/JSON-LD 를 직접 선언하도록 SEO declaration API 신설 — 이커머스 상품(Product/Offer/AggregateRating)·게시판 게시글(Article) 등 도메인 스키마가 레이아웃에서 확장 코드로 owned 되어 데이터에 따라 정확한 부속 태그 생성
- SEO 메타 확장 훅을 분기별·통합 모두 제공 — OG·Twitter·구조화 데이터 각각의 hook 슬롯과 통합 hook 모두 지원하여 확장이 원하는 단계에서 선택 변경 가능
- 모듈 설정 타이틀 템플릿에서 변수가 비어있을 때 인접 구분자(- – · |) 가 자동 정리되도록 개선 — 옵셔널 그룹 `[ ... ]` 표기도 지원하여 페이지별 구성 명시 가능
### Changed
- 콘솔 confirm 입력 처리 통일 — yes/y, no/n 외 입력 시 안내 메시지 출력 후 재질문, empty 입력 시 default 사용. 코어 업데이트·매니저 커맨드(module/plugin/template install·update·uninstall)·설정 마이그레이션의 모든 yes/no 프롬프트에 동일 규칙 적용 (#15 @laelbe 님께서 건의해주셨습니다.)
- 비밀번호 재설정 정책 기본값을 비활성으로 변경 — 본인인증 인프라가 미구성된 사이트에서도 기본 동작이 영향받지 않도록 운영자 opt-in 으로 전환
- 본인인증 정책의 인증 조건(`conditions`) 운영자 편집 허용 — 회원가입 단계 등 정책 조건을 코드 수정 없이 관리자 화면에서 조정 가능. 모듈 업데이트 시 운영자 수정값 보존
- 토큰 만료 등으로 권한 없는 레이아웃 진입 시 "페이지 로딩 실패" 에러 화면 대신 로그인 페이지로 자동 이동 — 로그인 화면에서 "세션이 만료되었습니다. 다시 로그인해 주세요." 토스트로 사용자에게 안내. 템플릿이 자체 로그인 경로를 사용하는 경우 부트스트랩에서 인증 설정을 커스터마이즈할 수 있는 공개 API 도 함께 제공 (#19 @abc101 님께서 건의해주셨습니다.)
- 템플릿 다국어 데이터 로딩 시 활성 언어팩의 다국어가 가장 높은 우선순위로 병합되도록 변경
- 권한·역할·메뉴·알림 등 코어 기본 데이터와 배송유형·클레임 사유·게시판 유형 등 모듈 기본 데이터에 활성 언어팩의 다국어가 자동 반영되도록 개선
- 사용자 수정 보존(user_overrides) 정책을 다국어 JSON 컬럼은 sub-key 단위(`name.ko` 등) 로 기록하도록 개선 — 운영자가 한 언어 라벨만 수정해도 그 언어만 보존되며, 신규 활성 언어팩(예: 일본어 추가) 의 라벨은 자동 동기화됨. 기존 컬럼 단위(`name`) 기록은 업그레이드 시 활성 locale dot-path 로 자동 변환
- 언어팩 활성/비활성 시점에 영향받는 모듈/플러그인의 entity 시더가 자동 재실행되어 신규 언어 라벨이 즉시 DB 에 반영되도록 라이프사이클 통합 — scope 별 라우팅 (코어 언어팩 → 모든 활성 확장, 모듈 언어팩 → 해당 모듈만, 플러그인 언어팩 → 해당 플러그인만)
- 환경설정 → SEO → "추가 봇 패턴" 필드의 역할 변경 — 기존에는 유일한 봇 매칭 소스(기본 5종)였으나, 이제는 라이브러리가 놓치는 조직별 커스텀 봇만 추가하는 보강 레이어로 동작. 기존 설치의 운영자 커스텀 값은 모두 보존되며, 신규 설치 기본값은 jaybizzle 미커버 3종으로 변경
### Security
- 회원가입·비밀번호 재설정 라우트에 본인인증 정책 강제 미들웨어 부착 — 정책이 활성화된 경우 미인증 요청을 라우트 단계에서 차단 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
- 플러그인이 정책 해석 필터 훅에서 잘못된 타입을 반환해도 원본 정책이 유지되도록 우회 차단 강화 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
- 웹 인스톨러의 Composer/PHP 바이너리 경로 검증에서 사용자 입력이 그대로 shell 명령으로 실행될 수 있던 문제 수정 — 입력은 실행 가능한 단일 파일 경로로만 허용하고 모든 분기에서 인자 escape 강제. 설치 워커가 동일한 입력을 사용하던 내부 헬퍼도 같은 정책으로 정렬 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
- 설치 단계 4 의 확장 기능 선택 API 가 사용자가 보낸 모듈/플러그인/템플릿/언어팩 식별자에 셸 메타문자 검증을 적용하도록 강화 — 부적절한 식별자는 400 응답으로 거부되어 이후 설치 명령에 도달하지 않음 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
- 인스톨러의 코어 업데이트 _pending 경로 검증이 `..` 등 부모 디렉토리 우회 시도를 거부하고 응답 메시지를 단일화하여 임의 디렉토리 enumeration 신호 차단 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
- 설치 시 `.env` 작성 헬퍼가 입력값에 포함된 개행 문자를 제거하도록 변경 — DB 비밀번호 등 사용자 입력으로 새로운 환경 변수 라인이 주입되는 시나리오 차단 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
- 데이터베이스 연결 정보의 host/port/database 값에 DSN 키-밸류 구분자(`;`, `=`) 또는 NUL/CRLF 가 포함되면 연결을 거부하도록 추가 검증 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
- 설치가 완료된 시스템에서 `public/install/` 하위 모든 엔드포인트가 비즈니스 로직 진입 전 HTTP 410 으로 차단되도록 공통 가드 도입 — 운영 환경에서 인스톨러 노출형 결함의 공격 표면 제거. 운영자가 인스톨러를 다시 사용해야 하는 경우 설치 완료 마커(`storage/app/g7_installed`) 와 `.env` 의 `INSTALLER_COMPLETED` 를 모두 제거 (KISA 측에서 제보해주셨습니다 — KVE-2026-0851)
### Fixed
- 코어 업데이트 후 일부 환경에서 모듈/플러그인이 저장한 사용자 데이터(상품 이미지·첨부파일 등)에 PHP 가 접근하지 못해 "찾을 수 없음" 오류가 발생하던 문제 수정 — 업데이트 종료 시점 권한 복원 범위를 PHP 쓰기 영역(storage/logs·storage/framework·bootstrap/cache·storage/app/core_pending)으로 한정하고 항목별 정확 복원으로 정합화. 이전 버전에서 본 릴리즈로 업그레이드한 환경에서는 업그레이드 시점에 storage/app 디렉토리/파일 권한을 PHP 가 접근 가능한 형태로 자동 정상화함. 향후 업데이트에서는 사용자 데이터 디렉토리에 자동 생성되는 보존 마커로 권한이 영구 보호됨
- PHP 8.5 환경에서 모든 페이지 응답이 손상되어 Firefox 에서는 "Content Encoding Error", Edge/Chromium 에서는 빈 화면으로 표시되던 문제 수정
- `php artisan serve` 환경에서 인스톨러 진행 중 .env 파일이 변경되면 개발 서버가 워커를 재시작하면서 설치 단계가 중도에 끊기던 문제 수정 — 설치 진행 중에는 .env 를 건드리지 않고 완료 화면 노출 후 한 번에 반영하도록 변경
- 관리자 환경설정 "시스템 사양" 카드의 메모리 항목이 서버 물리 메모리가 아닌 현재 PHP 프로세스가 사용 중인 메모리(수 MB 단위)로 표시되던 문제 수정 — 디스크 사용량과 동일한 형식(사용량/전체/백분율)으로 실제 서버 RAM 을 표시하도록 개선
- 관리자 환경설정 "시스템 사양" 카드의 CPU 항목이 Windows 11 / Windows Server 2025 에서 "operable program or batch file." 로 표시되던 문제 수정 — 해당 OS 에서 제거된 wmic 의존을 걷어내고 PowerShell 기반 조회로 전환, 구형 Windows 환경에서는 기존 방식으로 자동 폴백
- 관리자 환경설정 "정보" 탭을 한 번 조회한 뒤 다른 탭으로 전환할 때마다 수 초 지연이 반복되던 문제 수정 — 시스템 정보 조회 결과를 1시간 캐싱하여 탭 전환 시 대기 시간 제거 (시스템 캐시 초기화 시 함께 무효화)
- 큐 워커가 훅 페이로드의 enum 값(주문 상태 등)을 복원하지 못해 활동 로그·알림 등 일부 후속 처리가 실패하던 문제 수정
- 항목 삭제 후 큐 워커가 처리하는 후속 훅에서 대상 데이터를 복원하지 못해 처리가 중단되던 문제 수정 — 소프트 삭제 페이지·첨부파일, 하드 삭제 주문 옵션 등 포함
- 페이지 키워드 검색 결과의 전체 건수가 발행된 모든 페이지 수로 부풀려지던 문제 수정
- 사용자가 댓글을 단 게시글 활동 조회 시 500 에러가 발생하던 문제 수정
- 사용자 활동 통계에 삭제된 게시글의 댓글 수가 포함되던 문제 수정
- 일부 주문에서 옵션 정보 직렬화 시 500 에러가 발생하던 문제 수정
- 상품 옵션 삭제 검증 시 런타임 에러가 발생하던 문제 수정
- 설치 직후 또는 활성 모듈 디렉토리 부재 시 이커머스 환경설정 기본값이 비어있던 문제 수정
- 검색 가능 드롭다운(SearchableDropdown)에서 빠른 모달 전환 시 race condition 가능성 차단
- 반응형 설정과 반복 렌더링이 결합된 레이아웃에서 무한 재귀가 발생할 수 있던 엔진 결함 수정 (engine-v1.43.1)
- 슬롯 치환 시 텍스트 노드를 컴포넌트로 가정하여 발생할 수 있던 레이아웃 렌더링 오류 차단
- 인스톨러 실행 시 보안 키 생성 단계에서 개발용 패키지 ServiceProvider 를 찾지 못해 설치가 중단되던 문제 수정 — 이전 환경에서 남은 컴파일 캐시를 vendor 교체 직후와 보안 키 생성 직전에 자동 정리하도록 개선
- 폴링 모드 인스톨러가 큰 확장(레이아웃·테스트 수천 파일) 설치 도중 멈추던 문제 수정 — 명령 출력을 파일 기반으로 처리하여 OS 파이프 버퍼 한도와 무관하게 동작
- PHP 8.5 + Apache + mod_fcgid 환경에서 폴링 모드 진행 상황이 실시간 반영되지 않고 설치 완료 시점에 일괄 표시되던 문제 수정 — 어느 모드로도 진행 상황이 즉시 표시되도록 보정. Apache 환경별 권장 설정은 INSTALL.md 와 시스템 요구사항 문서에 명시
- 설치 완료 후 임시 파일이 자동 정리되지 않던 문제 수정
- 알림 템플릿 편집 모달의 입력 필드에서 글자를 입력할 때마다 화면이 심하게 버벅이던 문제 수정
- 슈퍼관리자가 다른 관리자 계정을 삭제할 수 없던 문제 수정 — 관리자 계정 삭제 가능 여부를 역할/권한/스코프 설정에 따라 판단하도록 개선 (슈퍼관리자 본인 보호는 유지)
- `seo:generate-sitemap` 커맨드가 큐 드라이버 설정과 무관하게 항상 "큐에 디스패치" 안내를 출력하던 문제 수정 — 동기 드라이버에서는 즉시 생성으로 동작하고 그에 맞는 안내를 표시하도록 변경
- 관리자 템플릿을 활성화해도 즉시 반영되지 않아 사용자가 직접 새로고침해야 하던 문제 수정 — 관리자 템플릿 활성화 시 자동으로 페이지를 갱신하여 새 템플릿이 즉시 적용되도록 개선
- 보안 환경설정의 "최대 로그인 시도 횟수 / 차단 시간" 설정이 실제로 적용되지 않아 무제한 로그인 시도가 가능하던 문제 수정 — 임계 도달 시 계정 잠금(HTTP 423), 잠금 해제 시각 안내 토스트, per-IP 백업 throttle, 활동 로그 기록까지 통합 구현
- 게시판 글쓰기 화면을 URL 로 직접 진입하거나 강제 새로고침했을 때 업로드한 첨부파일이 게시글에 연결되지 않던 문제 수정 (engine-v1.49.2) (#24 @minyho 님께서 제보해주셨습니다.)
- 코어 업그레이드 후 새 버전에서 추가된 권한·메뉴·알림 정의가 등록되지 않아 관리자 화면에서 "해당 권한이 없습니다" 가 반복 표시되거나 신규 메일 템플릿이 비어있던 문제 수정 — 업그레이드 시 새 버전 설정 파일을 정확히 인식하도록 보정. 본 릴리즈로 업그레이드하면 누락분이 자동 등록됨
- PHP 8.5 환경에서 관리자 로그인 시 `PDO::MYSQL_ATTR_SSL_CA` 등 PDO 드라이버 상수가 deprecation 경고를 발생시키던 문제 수정 — `Pdo\Mysql::ATTR_SSL_CA` 형태의 신규 상수로 전환하고, PHP 8.5 미만 환경에서는 기존 상수를 그대로 사용하도록 분기 처리 (#23 @yks118 님께서 제보해주셨습니다.)
- 회원가입 시 일부 환경에서 동의 항목 메타데이터가 누락되어 활동 로그 처리에서 예외가 발생, 후속 훅 체인이 중단되며 신규 회원에게 `user` 역할이 자동 부여되지 않던 문제 수정 — 동의 메타데이터를 nullable 로 받아 누락 케이스에서도 후속 권한 부여가 정상 동작하도록 개선 (#25 @comtylove-netizen 님께서 제보해주셨습니다.)
## [7.0.0-beta.3] - 2026-04-23
### Fixed
- CKEditor5 플러그인 활성 상태에서 게시판 글쓰기 저장 시 "제목은 필수입니다" 422 오류가 발생하던 호환성 문제 수정 — 제목·내용 입력 순서와 무관하게 정상 저장되도록 개선 (#17 @laelbe 님께서 제보해주셨습니다.)
- 코어 업데이트가 sudo 로 실행된 환경에서 캐시·세션·확장 디렉토리의 그룹 쓰기 권한이 일부 손실되어 업데이트 직후 "Permission denied" 또는 플러그인 제거 검증 실패가 발생하던 문제 수정 — 업데이트 종료 시점에 그룹 쓰기 권한을 자동 정상화하며 기존 손실 분은 1회성 복구 스텝으로 회수
- 업데이트 완료 후 Laravel 런타임이 새로 만드는 캐시·세션 하위 디렉토리가 기본 umask(022) 때문에 다시 그룹 쓰기 권한을 잃어 재차 "Permission denied" 가 발생하던 문제 수정 — 업그레이드 스텝이 업데이트 진행 프로세스의 umask 를 그룹 쓰기 친화적으로 전환하고, 이후 부팅 시점에도 `storage/` 의 현재 그룹 쓰기 설정을 감지해 프로세스 umask 를 자동 동조 (운영자가 그룹 공유를 비활성화한 환경은 그대로 보존)
- 코어 업데이트 마지막 단계의 진행 표시줄이 끝난 뒤 줄바꿈 없이 다음 셸 프롬프트가 같은 줄에 붙어 표시되던 출력 문제 수정
### Notes
- 7.0.0-beta.1 에서 7.0.0-beta.3 로 직접 업그레이드하는 경우, 환경(opcache CLI 활성 등)에 따라 권한 복구 스텝이 자동 실행되지 않고 건너뛰어질 수 있습니다. 업데이트 후 `storage/framework/cache` 등에서 Permission denied 가 발생하면 아래 명령을 수동 실행해 주세요 — 7.0.0-beta.2 에서 올라오는 경로에서는 해당 없음
- `php artisan core:execute-upgrade-steps --from=7.0.0-beta.2 --to=7.0.0-beta.3 --force`
- 시스템 레벨에서도 일관된 그룹 공유 권한을 원하면 php-fpm pool 설정에 `umask = 002` (또는 systemd unit 의 `UMask=0002`) 추가를 권장합니다. 코드 레벨 동조와 병행하면 외부 프로세스(cron, composer 등) 도 동일 권한으로 파일을 생성합니다
## [7.0.0-beta.2] - 2026-04-20
### Added
@@ -33,17 +527,17 @@
#### GeoIP 및 타임존
- MaxMind GeoLite2 자동 다운로드 스케줄러 + 관리자 환경설정 UI 추가
- IANA 타임존 전체(약 425개) 지원 — 기존 7개 하드코딩 화이트리스트 폐기
- IANA 타임존 전체(약 425개) 지원 — 기존 7개 하드코딩 화이트리스트 폐기 (#8 @abc101 님께서 건의해주셨습니다.)
- Select 컴포넌트에 `searchable` prop 추가 — 타임존 등 대량 옵션에서 검색 가능
#### 인스톨러 개선
- SSE/폴링 듀얼 모드 지원 — Nginx 프록시 + Apache 환경에서 SSE 문제 시 폴링 모드로 전환 가능
- 확장 의존성 자동 해결 — 템플릿 선택 시 필요한 모듈/플러그인을 즉시 자동 선택, 전이적 의존성까지 해결
- 확장 의존성 자동 해결 — 템플릿 선택 시 필요한 모듈/플러그인을 즉시 자동 선택, 전이적 의존성까지 해결 (#10 @glitter-gim 님께서 건의해주셨습니다.)
- 자동 선택된 항목을 시각적으로 구분하고 요구한 확장 이름을 함께 표시
- 다른 확장이 의존하는 항목은 선택 해제 차단 (의존 관계 안내 메시지 포함)
- 의존성 버전 제약 사전 검증 — 버전 불일치 시 설치 진행 전 경고 (semver 비교: `>=`, `^`, `~` 등 지원)
- 기존 DB 테이블 감지 및 안전한 재설치 지원 — 백업 안내 + 명시적 동의 후 진행
- 의존성 버전 제약 사전 검증 — 버전 불일치 시 설치 진행 전 경고 (semver 비교: `>=`, `^`, `~` 등 지원) (#3 @laelbe 님께서 제보해주셨습니다.)
- 기존 DB 테이블 감지 및 안전한 재설치 지원 — 백업 안내 + 명시적 동의 후 진행 (#5 @laelbe 님께서 건의해주셨습니다.)
- 권한 안내 단순화 — `chmod -R 755` 단일 명령어로 통합 (업계 표준 정렬)
- 소유자 불일치(`ownership_mismatch`) 감지 — 전통적 Apache 환경에서 3가지 해결 옵션 제시
- Step 5에 "설치 시작" 버튼 도입 — 모드 선택 후 사용자 클릭으로 설치 시작
@@ -56,7 +550,7 @@
#### 어드민 UI
- 페이지 진입/탭 전환 시 로딩 spinner 표시 — 데이터 fetch 완료까지 유지
- 페이지 진입/탭 전환 시 로딩 spinner 표시 — 데이터 fetch 완료까지 유지 (#6 @laelbe 님께서 제보해주셨습니다.)
- 목록 페이지네이션 시 DataGrid body 영역 한정 spinner (pagination 버튼 가림 방지)
- DataGrid 컴포넌트에 `id` prop 추가
@@ -152,6 +646,7 @@
- 코어·확장 설정에서 제거된 메뉴·권한·역할·알림 정의·알림 템플릿·게시판 유형·클레임 사유가 업데이트 후에도 DB에 잔존하던 문제 수정 — 고아 레코드 자동 정리
- 관리자 UI에서 메뉴·역할·알림·게시판 유형·클레임 사유·배송 유형 등을 수정해도 다음 업데이트 시 기본값으로 덮어써지던 문제 수정 — 사용자가 수정한 필드는 모든 저장 경로에서 자동 추적되어 업데이트 후에도 보존
- 알림 정의·템플릿도 업데이트 기준으로 동기화 — 새 버전에서 제거된 알림은 DB에서도 삭제
- 관리자 모듈/플러그인 목록의 "작성자" 표기가 manifest 의 `vendor` 필드 대신 식별자 prefix 를 사용하던 문제 수정 — `vendor` 가 정의된 확장은 정확한 작성자명으로 표시 (#9 @glitter-gim 님께서 제보해주셨습니다.)
### Removed
+184 -1
View File
@@ -47,6 +47,14 @@ git clone https://github.com/gnuboard/g7.git
</VirtualHost>
```
**Apache + mod_fcgid 환경 추가 설정** (PHP 8.5 NTS Windows 등 mod_php 미제공 빌드):
`fcgid.conf` 에 다음 1줄을 추가 후 Apache 재시작. 미설정 시 mod_fcgid 의 default 64KB 출력 버퍼가 인스톨러 SSE 스트림과 폴링 응답을 스크립트 종료 시점까지 보관하여 설치 진행 상황이 화면에 실시간 반영되지 않는다.
```apache
FcgidOutputBufferSize 0
```
**Nginx 예시**:
```nginx
@@ -238,7 +246,7 @@ unzip g7-release.zip
# 압축 해제 결과 확인 — 루트 디렉토리가 g7이 아니면 이름 변경
ls -la
# (필요 시) mv g7-7.0.0-beta.2 g7
# (필요 시) mv g7-7.0.1 g7
# ZIP 파일 정리 (선택)
rm g7-release.zip
@@ -330,6 +338,157 @@ http://도메인/install
---
## 업그레이드
이미 운영 중인 그누보드7을 새 버전으로 업그레이드할 때의 절차입니다. 신버전 ZIP 또는 git pull 로 파일만 덮어쓰면 **데이터베이스 마이그레이션, 데이터 시드, 확장(모듈/플러그인/템플릿) 재동기화가 함께 수행되지 않아** 게시판/회원/상품 데이터 누락이나 부정합 부팅 실패가 발생합니다. 반드시 아래 절차를 따라주세요.
### 개요
그누보드7 업그레이드의 SSoT(Single Source of Truth)는 Artisan 커맨드입니다.
```bash
sudo php artisan core:update
```
이 커맨드 1회 실행으로 다음 작업이 자동 수행됩니다.
- 신버전 다운로드 및 압축 해제 (GitHub Release 또는 지정 ZIP)
- 자동 백업 생성 (실패 시 자동 롤백)
- 유지보수 모드 자동 진입
- 데이터베이스 마이그레이션 실행
- 업그레이드 스텝 실행 (버전별 데이터 보정)
- 번들 확장 자동 재동기화
- 캐시 정리 및 유지보수 모드 해제
### 업그레이드 사전 준비
업그레이드 전 다음을 확인하세요.
1. **데이터베이스 백업** (필수)
```bash
mysqldump -u DB사용자 -p DB이름 > backup-$(date +%Y%m%d).sql
```
2. **활성 디렉토리 백업 권장** — 직접 수정한 코드나 외부 확장(`_bundled` 에 포함되지 않은 GitHub install 확장) 이 있다면 다음 4개 디렉토리를 별도 위치로 백업하세요.
```bash
tar -czf extensions-backup-$(date +%Y%m%d).tar.gz modules plugins templates lang-packs
```
3. **SSH/CLI 접근 확보** — 코어 업그레이드는 보안상 웹 UI 에서 실행할 수 없으며, 반드시 서버 터미널(SSH) 에서 직접 실행해야 합니다.
### 방법 1: 관리자 UI 에서 업데이트 확인 (권장 동선 시작점)
운영자가 새 버전 출시 여부를 가장 빠르게 알 수 있는 동선입니다.
1. 관리자 페이지 로그인 → **환경설정 > 정보** 탭 이동
2. "**업데이트 확인**" 버튼 클릭 → 최신 버전과 변경사항 모달 표시
3. 신버전이 감지되면 "**변경사항 보기**" 로 changelog 확인 → "**업데이트 방법**" 버튼 클릭
4. 모달이 권장 명령어와 본 가이드 링크를 안내하므로 그 안내에 따라 **서버 터미널에서 직접 실행**
> 모달은 안내 전용입니다. 보안상 UI 에서 코어 업그레이드를 직접 실행하지 않으며, 운영자가 권한 일치를 확인한 후 CLI 에서 실행해야 합니다.
### 방법 2: CLI 자동 업그레이드 (표준 절차)
대다수 환경에서 권장되는 절차입니다.
```bash
sudo php artisan core:update
```
#### `sudo` 가 권장되는 이유
업그레이드는 다음 파일들을 수정·생성하므로, 운영 환경에서 일반 사용자 권한으로 모두 접근 가능한 경우가 드뭅니다.
- `.env` — `APP_VERSION` 갱신 (보안상 `0600` 또는 `0640` 권한, 소유자는 보통 웹 서버 사용자 `www-data` · `nginx` · `apache` 등)
- `bootstrap/cache/` — 설정·라우트·서비스 캐시 갱신
- `storage/framework/`, `storage/logs/` — 캐시·로그 파일 생성
- `modules/`, `plugins/`, `templates/`, `lang-packs/` — 확장 재동기화
`sudo` 로 root 권한을 위임받으면 위 파일들의 소유자가 누구든(웹 서버 사용자 또는 별도 배포 사용자) 권한 충돌 없이 한 번에 처리할 수 있습니다. 업그레이드 도중 `.env` 머지 단계는 기존 소유자·그룹을 그대로 보존하므로, root 로 실행해도 파일 권한이 임의로 변경되지 않습니다.
#### `sudo` 없이 실행해도 되는 환경
- 소유자가 명령줄 사용자와 일치하는 **단일 사용자 호스팅** (Cafe24 등 공유 호스팅, 개인 VPS 의 사용자 계정 운영) — 그냥 `php artisan core:update` 실행
- **Windows (XAMPP/Laragon 등)** — 관리자 권한 PowerShell 에서 `php artisan core:update` 실행
> 자신의 환경이 어느 쪽인지 확인하려면 `ls -l .env` 의 소유자(세 번째 컬럼) 가 명령줄 로그인 사용자와 일치하는지 보세요. 일치하면 `sudo` 없이 실행 가능합니다.
### 방법 3: 수동 ZIP 업그레이드 (PHP zip 확장 미설치 환경)
PHP `zip` 확장 또는 시스템 `unzip` 명령이 없는 서버에서는 GitHub Release ZIP 을 직접 다운로드한 뒤 압축 해제 경로를 지정해 업그레이드합니다.
```bash
# 1. GitHub 릴리스 페이지에서 ZIP 다운로드 후 서버에 압축 해제
wget https://github.com/gnuboard/g7/releases/download/<버전>/<파일명>.zip
unzip <파일명>.zip -d /tmp/g7-new
# 2. 압축 해제된 경로를 지정하여 업데이트 실행
sudo php artisan core:update --source=/tmp/g7-new
```
`--source=` 옵션은 다운로드 단계를 건너뛰고 지정 경로의 파일을 신버전으로 간주합니다.
### 사용자군별 단계적 업그레이드
여러 베타 버전을 건너뛰고 한 번에 최신 버전으로 올리는 것은 권장하지 않습니다. 중간 버전의 업그레이드 스텝이 이전 버전 메모리에 없는 신규 코드에 의존하여 부팅 실패 위험이 있습니다.
| 현재 버전 | 권장 경로 |
|----------|----------|
| **beta.5 이상** | `sudo php artisan core:update` 1회로 최신 버전까지 자동 처리 |
| **beta.3 / beta.4** | beta.5 → 최신 순서로 2회 분할 실행. 각 단계 후 `.env` `APP_VERSION` 이 자동 갱신되므로 같은 명령을 반복 실행 |
| **beta.1 / beta.2** | beta.2 → beta.3 → beta.4 → beta.5 → 최신 순으로 단계별 진행 |
| **beta.4 도중 fatal 후 중단** | `.env` `APP_VERSION` 을 변경하지 말고 `php artisan core:update --force` 재실행. 새 자식 프로세스가 신버전 메모리로 부팅되어 남은 스텝이 멱등 적용됨 |
특정 베타 버전으로의 단계 업그레이드는 `--source=` 또는 `--zip=` 옵션으로 해당 버전 릴리스 ZIP 을 지정하여 진행합니다.
> 사용자군별 상세 분기는 [CHANGELOG.md](CHANGELOG.md) 의 해당 버전 "Upgrade Notice" 섹션을 참고하세요.
### 권한/캐시 트러블슈팅
업그레이드 도중 또는 직후 사이트가 응답하지 않는 경우, 다음을 확인합니다. 본 절의 `chmod`/`chown`/`chgrp` 명령은 POSIX 권한 모델 (Linux/macOS/BSD) 환경 전용이며, Windows 환경에서는 해당하지 않습니다.
#### `.env` 읽기 실패 (`Permission denied`)
```bash
ls -l .env
# -rw------- 1 www-data www-data ... .env ← 세 번째 컬럼이 소유자
```
대부분의 경우 `sudo php artisan core:update` 로 재실행하면 root 권한으로 소유자에 관계없이 접근됩니다. 권한 보안을 강화하려면 본 문서 [`.env` 권한 강화 (선택)](#env-권한-강화-선택) 섹션의 `0600` / `0640` 가이드를 참고하세요. 명령줄 사용자와 웹 서버 사용자가 다른 환경에서는 `0640` + 웹 서버 그룹 부여가 안전한 절충안입니다.
#### `bootstrap/cache` 쓰기 실패
```bash
sudo chown -R www-data:www-data bootstrap/cache storage
```
소유자를 웹 서버 사용자로 일괄 정렬한 뒤 업그레이드를 재실행합니다.
#### 업그레이드 도중 자동 롤백 후 부팅 실패
beta.6 이상에서는 자동 롤백 후 잔존 파일을 추가 진단하는 `hotfix:rollback-stale-files` 커맨드가 제공됩니다.
```bash
# 진단 모드 (실제 삭제 없음)
sudo php artisan hotfix:rollback-stale-files
# 운영자 확인 후 실제 정리
sudo php artisan hotfix:rollback-stale-files --prune
```
### 업그레이드 후 확인
업그레이드가 끝나면 다음을 점검합니다.
1. 관리자 페이지 > **환경설정 > 정보** 에서 G7 버전이 갱신되었는지 확인
2. **확장 > 모듈/플러그인/템플릿** 목록이 모두 정상 표시되는지 확인
3. 사용자 페이지 진입하여 기존 콘텐츠(게시판/상품/페이지) 가 정상 노출되는지 확인
4. `storage/logs/laravel.log` 에 신규 에러가 누적되지 않는지 확인
---
## 프로덕션 환경 추가 설정
프로덕션 환경에서는 아래 항목을 추가로 설정하는 것을 권장합니다.
@@ -338,6 +497,30 @@ http://도메인/install
프로덕션 환경에서는 HTTPS를 사용해야 합니다. `.env` 파일에서 `APP_URL`을 `https://`로 설정하세요.
### `.env` 권한 강화 (선택)
`.env` 는 DB 비밀번호와 `APP_KEY` 등 평문 자격증명을 포함합니다. 인스톨러는 설치 직후 `.env` 의 권한을 임의로 변경하지 않으므로, 운영자가 환경에 맞춰 직접 강화 권한을 적용할 수 있습니다.
- 가장 안전한 권한은 `0600` (소유자만 읽기·쓰기) 입니다. 다만 `0600` 적용 전에 **`.env` 의 소유자가 웹 서버 실행 사용자와 동일해야** 합니다. 소유자가 다른데 `0600` 으로 변경하면 웹 서버가 `.env` 를 읽지 못해 사이트가 응답하지 않습니다.
- **단일 사용자 환경** (예: 카페24 등 명령줄 사용자와 웹 서버 사용자가 같은 경우):
```bash
sudo chown www-data:www-data .env
sudo chmod 600 .env
```
- **자체 구축 서버** (명령줄 사용자 `jjh` 등과 웹 서버 사용자 `www-data` 가 다른 경우):
```bash
sudo chgrp www-data .env
sudo chmod 640 .env
```
웹 서버는 그룹 멤버로 읽기만 허용되고, 명령줄 사용자는 직접 수정할 수 있는 절충안입니다.
인스톨러 Step 5 완료 화면은 현재 `.env` 권한이 `0600` 이 아닐 경우 위 두 명령 예시를 조건부로 표시합니다.
### 데몬 프로세스
상시 실행이 필요한 프로세스입니다. Supervisor 등을 사용하여 관리합니다.
+100 -20
View File
@@ -8,12 +8,12 @@
</p>
<p align="center">
<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/version-7.0.1-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/PHP-8.2%2B-777BB4?logo=php&logoColor=white" alt="PHP"></a>
<a href="#"><img src="https://img.shields.io/badge/Laravel-12.x-FF2D20?logo=laravel&logoColor=white" alt="Laravel"></a>
<a href="#"><img src="https://img.shields.io/badge/React-19-61DAFB?logo=react&logoColor=black" alt="React"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="License"></a>
<a href="#"><img src="https://img.shields.io/badge/status-Open%20Beta-orange" alt="Status"></a>
<a href="#"><img src="https://img.shields.io/badge/status-Stable-brightgreen" alt="Status"></a>
</p>
---
@@ -43,16 +43,19 @@ Laravel과 React를 기반으로, 보안부터 아키텍처까지 처음부터
| 영역 | 설명 |
|------|------|
| **모듈 아키텍처** | 모듈 + 플러그인 + 템플릿 3중 확장 구조. 코어 수정 없이 독립적 모듈(게시판, 커머스 등) 개발이 가능합니다. Hook 기반 기능 주입으로 Service-Repository 패턴의 명확한 계층 분리를 유지합니다 |
| **현지화** | 백엔드부터 프론트엔드까지 일관된 다국어 개발 환경을 제공합니다. 로케일 기반 UI, 확장 가능한 언어 팩을 지원합니다 |
| **해외 결제** | 로컬 비즈니스를 넘어 글로벌 커머스로 도약하기 위한 기반을 제공합니다 `정식버전에서 지원예정` |
| **언어팩 시스템** | 새 언어를 코어 수정 없이 ZIP 또는 GitHub URL 로 설치할 수 있습니다. 일본어 등 공식 번들 언어팩을 즉시 사용할 수 있고, 운영자가 직접 수정한 라벨은 언어팩이 덮어쓰지 않도록 sub-key 단위로 보존합니다. 모듈/플러그인/템플릿 단위로 별도 적용 가능 |
| **현지화** | 백엔드부터 프론트엔드까지 일관된 다국어 개발 환경을 제공합니다. 활성 언어팩이 알림 채널 라벨, Provider/Registry 페이로드, 환경설정 카탈로그(결제수단·통화·배송 가능 국가)까지 자동 보강되며, 모듈/플러그인이 자기 도메인 라벨을 자기 영역에서 자기설명하도록 활동 로그·메시지 영역도 분리되어 있습니다 |
| **해외 결제** | 로컬 비즈니스를 넘어 글로벌 커머스로 도약하기 위한 기반을 제공합니다. 결제 연동은 동일한 Extension Point 패턴으로 붙일 수 있으며, 해외 결제 수단은 별도 플러그인으로 제공됩니다 |
| **권한 제어** | 역할별 메뉴와 기능, 데이터 범위까지 제어할 수 있습니다. 역할(Role) + 권한(Permission) + 스코프(Scope) 3단계 접근 제어로 조직 구조에 맞는 유연한 접근 관리를 제공합니다 |
| **보안** | 입력값 자동 검증과 토큰 기반 인증을 제공합니다. 설계부터 보안을 고려한 다층 방어 구조(CSRF/XSS/SQL Injection)를 구현합니다 |
| **본인인증 (IDV)** | 회원가입·비밀번호 재설정·민감 작업 등 모든 본인인증 시점을 라우트/훅 단위 선언형 정책으로 통합 관리합니다. 코어가 메일 프로바이더를 기본 내장하고, 외부 KCP·이니시스·SMS·PortOne·Stripe Identity 등은 동일한 Provider 계약으로 붙일 수 있는 확장점을 제공합니다. 서버가 HTTP 428 응답을 반환하면 프론트엔드 인터셉터가 자동으로 인증 모달을 띄우고 인증 성공 시 원 요청을 재실행합니다 |
| **보안** | 입력값 자동 검증과 토큰 기반 인증을 제공합니다. 설계부터 보안을 고려한 다층 방어 구조(CSRF/XSS/SQL Injection), 로그인 시도 제한·계정 잠금(HTTP 423) 실제 구현, 설치 완료 후 인스톨러 엔드포인트 자동 차단(HTTP 410) 까지 다층 방어를 구성합니다 |
| **유연한 화면 구성** | 화면 구조를 정의하면 즉시 반영할 수 있습니다. 프론트엔드 인프라 없이 JSON 선언만으로 웹앱 수준의 동적 화면 구현이 가능합니다 |
| **레이아웃 편집기** | 위지윅 기반 레이아웃 편집 기능으로 화면 블록을 직접 배치하고 수정 결과를 바로 확인할 수 있습니다 `정식버전에서 지원예정` |
| **레이아웃 편집기** | 위지윅 기반 레이아웃 편집 기능으로 화면 블록을 직접 배치하고 수정 결과를 바로 확인할 수 있습니다 |
| **검증된 기반** | Laravel + React 기반을 제공합니다. 글로벌 기업이 채택한 기술 스택으로 높은 확장성과 유연한 UI 구현이 가능합니다 |
| **공통 캐시 시스템** | `CacheInterface` 와 코어/모듈/플러그인 3종 드라이버로 키 접두사(`g7:core:`, `g7:module.{id}:`, `g7:plugin.{id}:`) 를 자동 격리합니다. 태그 기반 자동 무효화와 `g7_core_settings('cache.*_ttl')` 중앙 관리로 하드코딩 없이 운영할 수 있습니다 |
| **알림 시스템** | 알림 정의(Definition) × 템플릿(Template) × 수신자(Recipients) 3계층 구조로 메일/DB/실시간 브로드캐스트(Reverb) 다채널 독립 발송을 지원합니다. 작성자·역할·특정 사용자 단위 타겟팅과 훅 기반 발송으로 모듈이 자체 알림을 자유롭게 등록할 수 있습니다 |
| **활동 로그** | 관리자·사용자 활동 이력을 자동으로 기록하고 조회할 수 있습니다. Monolog 기반 구조로 확장이 용이합니다 |
| **알림 시스템** | 알림 정의(Definition) × 템플릿(Template) × 수신자(Recipients) 3계층 구조로 메일/DB/실시간 브로드캐스트(Reverb) 다채널 독립 발송을 지원합니다. 작성자·역할·특정 사용자·권한 보유자 단위 타겟팅과 훅 기반 발송으로 모듈이 자체 알림을 자유롭게 등록할 수 있습니다 |
| **SEO** | `jaybizzle/crawler-detect` 기반으로 약 1,000종 봇(검색엔진·SNS unfurl·AI 검색)을 자동 감지하여 봇 요청에는 정적 HTML 을, 일반 사용자에게는 SPA 를 응답합니다. OG/Twitter 카드 메타와 모듈이 선언한 도메인 스키마(Article/Product/Offer/AggregateRating), Sitemap 자동·수동 생성, Generator 메타 태그까지 표준 SEO 표면을 코어에서 제공합니다 |
| **활동 로그** | 관리자·사용자 활동 이력을 자동으로 기록하고 조회할 수 있습니다. Monolog 기반 구조로 확장이 용이하며, 액션 라벨이 모듈/플러그인 자체 다국어 파일에서 우선 해석되어 도메인별 자기설명이 가능합니다 |
| **검색** | Laravel Scout 기반 전문 검색을 지원합니다. 상품, 게시글 등 주요 콘텐츠를 대상으로 검색 기능을 제공합니다 |
---
@@ -77,12 +80,16 @@ Gnuboard7
│ ├── Controller → FormRequest → Service → Repository → Model
│ ├── Hook System (Action / Filter)
│ ├── Permission (Role → Permission → Scope)
│ ├── Identity Verification (Policy × Purpose × Provider × Message)
│ ├── Language Pack (가상 보호 행 + ZIP/GitHub 설치 + sub-key 보존)
│ ├── Notification (Definition × Template × Recipients)
│ └── SEO (Bot Detection → Static HTML → Cache → Sitemap)
│
├── Extensions
│ ├── Modules — 게시판, 쇼핑몰, 페이지 ...
│ ├── Plugins — 결제, 인증, 마케팅 ...
│ └── Templates — 관리자 UI, 사용자 UI
│ ├── Templates — 관리자 UI, 사용자 UI
│ └── LanguagePacks — 일본어 등 공식/외부 언어팩
│
└── Template Engine
├── JSON Layout → React Components
@@ -93,15 +100,12 @@ Gnuboard7
그누보드7의 템플릿 엔진은 **JSON으로 UI 구조를 선언**하면, 엔진이 이를 해석하여 React 컴포넌트로 렌더링합니다.
#### 현재 지원
#### 제공 기능
- JSON 선언만으로 React 기반 UI 구성 — React 전문 지식 없이도 화면 개발 가능
- 모듈/플러그인이 프론트엔드 빌드 없이 JSON만으로 UI를 동적으로 주입/확장
- 고도화된 UI가 필요한 경우 커스텀 React 컴포넌트를 개발하여 등록 가능
#### 지원 예정
- UI가 코드가 아닌 데이터(JSON)로 정의되는 구조를 활용하여, **드래그 앤 드롭 방식의 비주얼 에디터**를 통해 비개발자도 화면을 직접 구성할 수 있도록 지원할 계획입니다
- UI가 코드가 아닌 데이터(JSON)로 정의되는 구조를 활용한 **위지윅 레이아웃 편집기** — 비개발자도 화면 블록을 직접 배치·편집하고 결과를 바로 확인할 수 있습니다
```mermaid
flowchart TB
@@ -294,6 +298,48 @@ HookManager::doAction('sirsoft-ecommerce.order.after_confirm', $order);
- 실시간 브로드캐스트는 Laravel Reverb (WebSocket) 기반. Reverb 미구성 환경에서는 graceful skip 으로 오류 없이 동작
- `GenericNotification` 단일 클래스가 모든 알림을 처리 — 신규 알림 타입 추가 시 개별 Notification 클래스 작성 불필요
#### 5. 언어팩 시스템
새 언어를 코어 수정 없이 추가할 수 있는 운영 도구로, 모듈/플러그인/템플릿 관리와 동일한 라이프사이클(설치 → 활성화 → 업데이트 → 제거 + 자동 백업/롤백) 을 제공합니다.
| 영역 | 동작 |
| --- | --- |
| 설치 경로 | ZIP 업로드 / GitHub URL / `lang-packs/_bundled` 번들 디렉토리 (코어 업데이트 시 일괄 동기화) |
| 적용 범위 | 코어, 모듈, 플러그인, 템플릿 별도 적용 — 모듈 언어팩은 해당 코어 언어팩이 활성일 때만 활성화 |
| 사용자 수정 보존 | 다국어 JSON 컬럼은 sub-key 단위 (`name.ko` / `name.ja`) 로 user override 기록 — 한 언어 라벨만 수정해도 그 언어만 보존, 신규 언어는 자동 동기화 |
| 활성화 시점 | 활성/비활성 시 영향받는 모듈/플러그인의 entity 시더가 자동 재실행 → 메뉴·권한·역할·매니페스트·알림 라벨 즉시 DB 반영 |
| 가상 보호 행 | 코어/번들 확장에 내장된 한국어/영어는 별도 설치 없이 항상 활성/보호 상태로 노출 (수정/제거 차단) |
| 보안 | 언어 번역 외의 PHP 실행 코드 포함 시 설치 차단 |
공식 일본어(ja) 번들 12종(코어 + 주요 모듈/플러그인/템플릿) 이 즉시 사용 가능하며, 인스톨러 4단계에서 모듈/플러그인/템플릿 선택과 종속된 언어팩 카드가 자동 연동되어 함께 설치할 수 있습니다.
> 상세: [docs/extension/language-packs.md](docs/extension/language-packs.md)
#### 6. 본인인증 (Identity Verification)
회원가입·비밀번호 재설정·민감 작업·결제 직전 등 모든 본인인증 시점을 라우트/훅 단위 선언형 정책으로 통합 관리합니다.
```text
┌────────────────────┐ ┌─────────────────────┐ ┌──────────────────────┐
│ Policy │ │ Purpose │ │ Provider │
│ (강제 시점·실패 모드│ │ (인증 목적·허용 채널│ │ (메일·KCP·이니시스 │
│ ·단계·conditions) │ ◀▶ │ ·source 추적) │ ◀▶ │ ·SMS·외부 IDV ...) │
└────────────────────┘ └─────────────────────┘ └──────────────────────┘
│ │
└──────────▶ Message Template (정책×목적 매핑) ◀───┘
│
GenericNotification
```
- **정책 SSoT** — 정책 enable 토글이 라우트 코드 수정 없이 즉시 적용. 모든 API 라우트가 정책 DB 와 자동 매칭
- **428 인터셉터** — 서버가 HTTP 428 응답을 반환하면 프론트엔드가 자동으로 인증 모달을 띄우고 인증 성공 시 원 요청을 자동 재실행
- **선언형 등록** — 모듈/플러그인은 `module.php::getIdentityPolicies()` / `getIdentityPurposes()` / `getIdentityMessages()` 만 선언하면 활성화/업데이트 시 자동 등록되며 운영자 편집값 보존
- **메시지 템플릿** — 프로바이더와 (목적/정책)별로 다국어 제목/본문을 개별 정의. 정책 → 목적 → 프로바이더 기본값 순서로 fallback
- **외부 Provider 슬롯** — 플러그인이 KCP·PortOne·토스인증·Stripe Identity 등을 G7 표준 Extension Point 패턴으로 자기 SDK UI 를 주입 가능
- **이력 관리** — 관리자 화면에서 인증 수단 탭, 통합 검색, 상태/목적/채널/IP 멀티 필터, 보관주기(180일) 일괄 파기 제공
> 상세: [docs/backend/identity-policies.md](docs/backend/identity-policies.md), [docs/backend/identity-providers.md](docs/backend/identity-providers.md), [docs/backend/identity-messages.md](docs/backend/identity-messages.md)
---
## 빠른 시작
@@ -336,11 +382,12 @@ cp .env.example .env
| 플러그인 | 설명 |
|---------|------|
| **sirsoft-tosspayments** | 토스페이먼츠 결제 연동 |
| **sirsoft-verification** | 본인인증 |
| **sirsoft-pay_kginicis** | KG이니시스 결제 연동 |
| **sirsoft-verification_kginicis** | KG이니시스 본인인증 |
| **sirsoft-daum_postcode** | 다음 우편번호 검색 |
| **sirsoft-marketing** | 마케팅 도구 |
| **sirsoft-ckeditor5** | CKEditor 5 에디터 |
| **sirsoft-gdpr** | 개인정보 보호(GDPR) |
### 템플릿
@@ -349,6 +396,38 @@ cp .env.example .env
| **sirsoft-admin_basic** | 관리자 기본 템플릿 |
| **sirsoft-basic** | 사용자 기본 템플릿 |
### 번들 언어팩
설치 시 함께 동반 설치할 수 있는 공식 언어팩입니다. 코어 + 주요 모듈/플러그인/템플릿이 일관된 번역으로 즉시 사용 가능합니다.
| 식별자 | 설명 |
| ------ | ---- |
| **g7-core-ja** | 코어 일본어 |
| **g7-module-sirsoft-board-ja** | 게시판 모듈 일본어 |
| **g7-module-sirsoft-ecommerce-ja** | 이커머스 모듈 일본어 |
| **g7-module-sirsoft-page-ja** | 페이지 모듈 일본어 |
| **g7-plugin-sirsoft-ckeditor5-ja** | CKEditor5 플러그인 일본어 |
| **g7-plugin-sirsoft-daum_postcode-ja** | 다음 우편번호 플러그인 일본어 |
| **g7-plugin-sirsoft-gdpr-ja** | 개인정보 보호(GDPR) 플러그인 일본어 |
| **g7-plugin-sirsoft-marketing-ja** | 마케팅 플러그인 일본어 |
| **g7-plugin-sirsoft-pay_kginicis-ja** | KG이니시스 결제 플러그인 일본어 |
| **g7-plugin-sirsoft-verification_kginicis-ja** | KG이니시스 본인인증 플러그인 일본어 |
| **g7-template-sirsoft-admin_basic-ja** | 관리자 기본 템플릿 일본어 |
| **g7-template-sirsoft-basic-ja** | 사용자 기본 템플릿 일본어 |
> 한국어/영어는 코어/번들 확장에 내장되어 있으며 설치 없이 항상 활성 상태로 동작합니다. 새 언어는 ZIP 또는 GitHub URL 로 자유롭게 추가할 수 있습니다.
### 학습용 샘플 확장
확장 시스템 학습을 위한 최소 구현 샘플입니다. 관리자 UI 에서 "숨김 포함" 토글로 노출되며 CLI 에서는 항상 보입니다.
| 식별자 | 종류 | 설명 |
| ------ | ---- | ---- |
| **gnuboard7-hello_module** | 모듈 | Memo CRUD + 훅 발행 시연 |
| **gnuboard7-hello_plugin** | 플러그인 | Action/Filter 훅 구독 시연 |
| **gnuboard7-hello_admin_template** | Admin 템플릿 | Basic 컴포넌트 최소 셋 |
| **gnuboard7-hello_user_template** | User 템플릿 | 홈 + Memo 리스트 연동 |
---
## 비즈니스 모델
@@ -357,8 +436,8 @@ cp .env.example .env
| 모델 | 설명 | 상태 |
|------|------|------|
| **커뮤니티** | 게시판, 댓글, 회원 관리 | Beta |
| **커머스** | 상품 등록, 주문, 결제, 배송 관리 | Beta |
| **커뮤니티** | 게시판, 댓글, 회원 관리 | 정식 |
| **커머스** | 상품 등록, 주문, 결제, 배송 관리 | 정식 |
---
@@ -406,7 +485,8 @@ cp .env.example .env
<p>
<a href="https://github.com/HeuJung"><img src="https://github.com/HeuJung.png" width="60" alt="HeuJung"></a>&nbsp;&nbsp;
<a href="https://github.com/chym1217"><img src="https://github.com/chym1217.png" width="60" alt="chym1217"></a>
<a href="https://github.com/chym1217"><img src="https://github.com/chym1217.png" width="60" alt="chym1217"></a>&nbsp;&nbsp;
<a href="https://github.com/thisgun"><img src="https://github.com/thisgun.png" width="60" alt="thisgun"></a>
</p>
### Contributors
@@ -433,7 +513,7 @@ cp .env.example .env
## 보안 취약점
보안 취약점을 발견하셨다면 [GitHub Issues](https://github.com/gnuboard/g7/issues)에 보고해 주세요.
보안 취약점을 발견하셨다면 [SIR 문의게시판](https://sir.kr/boards/co_qa)에 비밀글로 제보해 주세요.
---
@@ -3,6 +3,7 @@
namespace App\ActivityLog\Traits;
use App\Enums\ActivityLogType;
use App\Extension\ExtensionManager;
use Illuminate\Support\Facades\Log;
/**
@@ -39,6 +40,9 @@ trait ResolvesActivityLogType
* 활동 로그를 기록합니다.
*
* context에 log_type이 명시되지 않으면 resolveLogType()으로 자동 결정합니다.
* 호출 클래스 FQCN 으로부터 모듈/플러그인 origin 을 추론하여
* properties.extension_origin 에 자동 주입합니다 (loggable 미지정 케이스의
* action 라벨 namespace fallback 용).
*
* @param string $action 액션명 (예: 'user.create')
* @param array $context Monolog context 배열
@@ -47,6 +51,13 @@ trait ResolvesActivityLogType
{
$context['log_type'] ??= $this->resolveLogType();
$origin = ExtensionManager::resolveExtensionByFqcn(static::class);
if ($origin !== null) {
$properties = $context['properties'] ?? [];
$properties['extension_origin'] ??= $origin;
$context['properties'] = $properties;
}
try {
Log::channel('activity')->info($action, $context);
} catch (\Exception $e) {
@@ -0,0 +1,42 @@
<?php
namespace App\Concerns\Seeder;
use App\Extension\HookManager;
/**
* TranslatableSeederInterface 구현체용 헬퍼 트레이트.
*
* 시더의 run() 안에서 $this->resolveTranslatedDefaults() 호출 시
* 활성 언어팩의 ja/en 등 locale 키가 자동 머지된 entry 배열을 반환.
*
* @since 7.0.0-beta.5
*/
trait HasTranslatableSeeder
{
/**
* 활성 언어팩의 다국어 데이터로 머지된 시드 entry 를 반환합니다.
*
* @return array<int, array<string, mixed>>
*/
protected function resolveTranslatedDefaults(): array
{
return HookManager::applyFilters(
$this->resolveTranslationFilterName(),
$this->getDefaults(),
);
}
/**
* `seed.{ext}.{entity}.translations` 필터 키를 조립합니다.
*/
protected function resolveTranslationFilterName(): string
{
$extension = $this->getExtensionIdentifier();
$entity = $this->getTranslatableEntity();
return $extension !== ''
? "seed.{$extension}.{$entity}.translations"
: "seed.{$entity}.translations";
}
}
+99 -23
View File
@@ -2,12 +2,15 @@
namespace App\Console\Commands\Core;
use App\Extension\Traits\ClearsTemplateCaches;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Process\Process;
class BuildCoreCommand extends Command
{
use ClearsTemplateCaches;
/**
* The name and signature of the console command.
*/
@@ -82,26 +85,95 @@ class BuildCoreCommand extends Command
*/
private function buildEngineOnly(string $projectPath, bool $watchMode, bool $productionMode): int
{
$buildCommand = ['npm', 'run'];
// 감시 모드: 엔진 번들 + 편집기 번들(layout-editor.min.js)을 각각 vite --watch 로
// 병렬 감시한다. (기존 dev 서버는 코어 lib 를 빌드하지 않으므로 사용 불가)
if ($watchMode) {
// build:core는 watch 모드가 없으므로 dev 사용
$buildCommand[] = 'dev';
$this->info('👀 파일 감시 모드로 코어 빌드 시작 (템플릿 엔진)');
$this->info('👀 파일 감시 모드로 코어 빌드 시작 (템플릿 엔진 + 레이아웃 편집기)');
$this->line(' Ctrl+C로 종료할 수 있습니다.');
} else {
$buildCommand[] = 'build:core';
$this->info('🔨 코어 빌드 시작 (템플릿 엔진)'.($productionMode ? ' (프로덕션)' : ''));
return $this->runWatchBundles($projectPath);
}
$result = $this->runNpmCommand($buildCommand, $projectPath, ! $watchMode);
// ── 1) 템플릿 엔진 번들 (편집기 코드 제외) ──────────────────────────────
$this->info('🔨 코어 빌드 시작 (템플릿 엔진)'.($productionMode ? ' (프로덕션)' : ''));
$engineResult = $this->runNpmCommand(['npm', 'run', 'build:core'], $projectPath, true);
if ($result === Command::SUCCESS && ! $watchMode) {
$this->info('✅ 코어 빌드 완료 (템플릿 엔진)');
$this->showEngineBuildResults($projectPath);
if ($engineResult !== Command::SUCCESS) {
return $engineResult;
}
return $result;
// ── 2) 레이아웃 편집기 번들 (lazy, /admin/layout-editor/* 진입 시 로드) ──
$this->info('🔨 코어 빌드 시작 (레이아웃 편집기)'.($productionMode ? ' (프로덕션)' : ''));
$editorResult = $this->runNpmCommand(['npm', 'run', 'build:core-editor'], $projectPath, true);
if ($editorResult !== Command::SUCCESS) {
$this->error('❌ 레이아웃 편집기 번들 빌드 실패');
return $editorResult;
}
// ── 3) DevTools 번들 (lazy, 디버그 모드에서만 로드) ──
$this->info('🔨 코어 빌드 시작 (DevTools)'.($productionMode ? ' (프로덕션)' : ''));
$devtoolsResult = $this->runNpmCommand(['npm', 'run', 'build:core-devtools'], $projectPath, true);
if ($devtoolsResult !== Command::SUCCESS) {
$this->error('❌ DevTools 번들 빌드 실패');
return $devtoolsResult;
}
$this->info('✅ 코어 빌드 완료 (템플릿 엔진 + 레이아웃 편집기 + DevTools)');
$this->showEngineBuildResults($projectPath);
$this->incrementExtensionCacheVersion();
return Command::SUCCESS;
}
/**
* 감시 모드에서 엔진 번들 + 편집기 번들을 병렬로 vite --watch 실행합니다.
*
* @param string $projectPath 프로젝트 경로
* @return int 명령 실행 결과 코드
*/
private function runWatchBundles(string $projectPath): int
{
// 엔진 + 편집기 + DevTools 번들을 각각 vite --watch 로 병렬 감시
$bundles = [
'engine' => ['npm', 'run', 'build:core-watch'],
'editor' => ['npm', 'run', 'build:core-editor-watch'],
'devtools' => ['npm', 'run', 'build:core-devtools-watch'],
];
/** @var array<string, Process> $processes */
$processes = [];
foreach ($bundles as $label => $command) {
if (PHP_OS_FAMILY === 'Windows') {
$command = array_merge(['cmd', '/c'], $command);
}
$process = new Process($command);
$process->setWorkingDirectory($projectPath);
$process->setTimeout(null);
$process->start(function ($type, $buffer) use ($label) {
$this->output->write("[{$label}] ".$buffer);
});
$processes[$label] = $process;
}
// 하나라도 실행 중이면 계속 대기 (Ctrl+C 로 종료)
do {
$anyRunning = false;
foreach ($processes as $process) {
if ($process->isRunning()) {
$anyRunning = true;
}
}
usleep(100000); // 100ms
} while ($anyRunning);
return Command::SUCCESS;
}
/**
@@ -130,6 +202,7 @@ class BuildCoreCommand extends Command
if ($result === Command::SUCCESS && ! $watchMode) {
$this->info('✅ 코어 빌드 완료 (전체)');
$this->showFullBuildResults($projectPath);
$this->incrementExtensionCacheVersion();
}
return $result;
@@ -150,22 +223,25 @@ class BuildCoreCommand extends Command
$this->line(' 빌드 결과:');
// template-engine.min.js 확인
// template-engine.min.js 확인 (일반 페이지 초기 로드 = 이 번들만)
$engineFile = $corePath.'/template-engine.min.js';
if (file_exists($engineFile)) {
$fileSize = number_format(filesize($engineFile) / 1024, 2);
$this->line(" - template-engine.min.js ({$fileSize} KB)");
}
// lang 파일 확인
$langPath = $corePath.'/lang';
if (is_dir($langPath)) {
$langFiles = glob($langPath.'/*.json');
foreach ($langFiles as $langFile) {
$fileName = 'lang/'.basename($langFile);
$fileSize = number_format(filesize($langFile) / 1024, 2);
$this->line(" - {$fileName} ({$fileSize} KB)");
}
// layout-editor.min.js 확인 (편집기 lazy 번들, /admin/layout-editor/* 진입 시 로드)
$editorFile = $corePath.'/layout-editor.min.js';
if (file_exists($editorFile)) {
$fileSize = number_format(filesize($editorFile) / 1024, 2);
$this->line(" - layout-editor.min.js ({$fileSize} KB, lazy)");
}
// devtools.min.js 확인 (DevTools lazy 번들, 디버그 모드에서만 로드)
$devtoolsFile = $corePath.'/devtools.min.js';
if (file_exists($devtoolsFile)) {
$fileSize = number_format(filesize($devtoolsFile) / 1024, 2);
$this->line(" - devtools.min.js ({$fileSize} KB, lazy/debug)");
}
}
@@ -2,10 +2,14 @@
namespace App\Console\Commands\Core\Concerns;
use App\Console\Commands\Core\ExecuteBundledUpdatesCommand;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Extension\Vendor\VendorMode;
use App\Services\CoreUpdateService;
use App\Services\LanguagePackService;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
/**
@@ -21,13 +25,16 @@ use Illuminate\Support\Facades\Log;
* 6) 결과 요약 출력
*
* --force 옵션이 지정된 경우: 프롬프트 스킵 + 전역 전략 'overwrite' 로 즉시 실행.
*
* 본 트레이트를 사용하는 Command 는 HasUnifiedConfirm 트레이트도 함께 사용해야 한다
* (yes/no 입력 정규화 및 재질문 루프 제공).
*/
trait BundledExtensionUpdatePrompt
{
/**
* 번들 업데이트 목록 수집.
*
* @return array{modules: array, plugins: array, templates: array}
* @return array{modules: array, plugins: array, templates: array, lang_packs: array}
*/
protected function collectBundledUpdates(
ModuleManager $moduleManager,
@@ -37,7 +44,12 @@ trait BundledExtensionUpdatePrompt
// CoreUpdateService::collectBundledExtensionUpdates() 를 통해 _bundled manifest 버전을
// DB 현재 버전과 직접 비교한다. Manager::checkXxxUpdate() 의 "GitHub 엄격 우선" 정책을
// 우회하여 GitHub 미릴리스 상태에서도 _bundled 신버전을 정확히 감지.
return app(\App\Services\CoreUpdateService::class)->collectBundledExtensionUpdates();
$extUpdates = app(CoreUpdateService::class)->collectBundledExtensionUpdates();
// 언어팩도 동일 패턴으로 _bundled vs DB 직접 비교 (LanguagePackService::collectBundledLangPackUpdates).
$extUpdates['lang_packs'] = app(LanguagePackService::class)->collectBundledLangPackUpdates();
return $extUpdates;
}
/**
@@ -52,7 +64,8 @@ trait BundledExtensionUpdatePrompt
bool $force,
): array {
$updates = $this->collectBundledUpdates($moduleManager, $pluginManager, $templateManager);
$total = count($updates['modules']) + count($updates['plugins']) + count($updates['templates']);
$updates['lang_packs'] = $updates['lang_packs'] ?? [];
$total = count($updates['modules']) + count($updates['plugins']) + count($updates['templates']) + count($updates['lang_packs']);
if ($total === 0) {
$this->info('활성 확장이 최신 번들과 일치합니다.');
@@ -71,9 +84,12 @@ trait BundledExtensionUpdatePrompt
foreach ($updates['templates'] as $t) {
$this->line(" [템플릿] {$t['identifier']} {$t['current_version']} → {$t['latest_version']}");
}
foreach ($updates['lang_packs'] as $lp) {
$this->line(" [언어팩] {$lp['identifier']} {$lp['current_version']} → {$lp['latest_version']}");
}
$this->newLine();
if (! $force && ! $this->confirm('일괄 업데이트를 진행하시겠습니까?', true)) {
if (! $force && ! $this->unifiedConfirm('일괄 업데이트를 진행하시겠습니까?', true)) {
$this->info('일괄 업데이트를 건너뜁니다.');
return ['success' => 0, 'failed' => 0, 'skipped' => $total, 'has_updates' => true];
@@ -107,10 +123,12 @@ trait BundledExtensionUpdatePrompt
* 확장별 전략 오버라이드 수집.
*
* @param array $updates collectBundledUpdates() 반환값
* @return array<string, string> key: "{type}:{identifier}", value: strategy
* @return array<string, string> key: "{type}:{identifier}", value: strategy
*/
private function collectPerExtensionStrategies(array $updates, string $globalStrategy, bool $force): array
{
// lang_packs 는 layout 이 없어 overwrite/keep strategy 가 의미 없으므로 의도적으로 제외.
// 매니페스트에는 strategy 없이 그대로 전달되어 ExecuteBundledUpdatesCommand 가 일괄 처리.
$strategies = [];
foreach (['modules', 'plugins', 'templates'] as $type) {
foreach ($updates[$type] as $ext) {
@@ -122,7 +140,7 @@ trait BundledExtensionUpdatePrompt
return $strategies;
}
if (! $this->confirm('전역 전략과 다르게 적용할 확장이 있습니까?', false)) {
if (! $this->unifiedConfirm('전역 전략과 다르게 적용할 확장이 있습니까?', false)) {
return $strategies;
}
@@ -167,6 +185,13 @@ trait BundledExtensionUpdatePrompt
/**
* 실제 일괄 업데이트 실행.
*
* 구조 fix (beta.4 도입): 부모(`core:update`) 프로세스의 stale memory 가 신버전 sync
* 메서드를 호출하지 못하던 결함의 영구 차단. 사용자 선택을 매니페스트로 직렬화한 후
* `core:execute-bundled-updates` 를 별도 PHP 프로세스에서 spawn 하여 실행한다 (자식은
* 디스크의 fresh 코어 코드 로드).
*
* proc_open 미지원 / 실패 환경에서는 in-process fallback 으로 안전하게 전환 (기존 흐름).
*
* @return array{success: int, failed: int, skipped: int, has_updates: bool}
*/
private function executeBulkUpdate(
@@ -176,12 +201,194 @@ trait BundledExtensionUpdatePrompt
array $updates,
array $strategies,
): array {
$success = 0;
$failed = 0;
$manifest = $this->buildBundledUpdateManifest($updates, $strategies);
if ($this->bundledManifestIsEmpty($manifest)) {
return ['success' => 0, 'failed' => 0, 'skipped' => 0, 'has_updates' => false];
}
$this->newLine();
$this->info('── 일괄 업데이트 실행 ──');
$spawnResult = $this->spawnBundledUpdates($manifest);
if ($spawnResult !== null) {
return [
'success' => $spawnResult['success'],
'failed' => $spawnResult['failed'],
'skipped' => 0,
'has_updates' => true,
];
}
// proc_open 미지원 / spawn 실패 — in-process fallback
$this->warn('별도 프로세스 spawn 실패 — in-process fallback 으로 전환합니다.');
return $this->executeBulkUpdateInProcess(
$moduleManager,
$pluginManager,
$templateManager,
$updates,
$strategies,
);
}
/**
* 사용자 선택을 spawn 자식에 전달할 매니페스트 형식으로 직렬화.
*
* @return array{modules: array, plugins: array, templates: array, lang_packs: array}
*/
private function buildBundledUpdateManifest(array $updates, array $strategies): array
{
$manifest = [
'modules' => [],
'plugins' => [],
'templates' => [],
'lang_packs' => [],
];
foreach ($updates['modules'] ?? [] as $m) {
$id = $m['identifier'];
$manifest['modules'][] = [
'identifier' => $id,
'strategy' => $strategies["modules:{$id}"] ?? 'overwrite',
];
}
foreach ($updates['plugins'] ?? [] as $p) {
$id = $p['identifier'];
$manifest['plugins'][] = [
'identifier' => $id,
'strategy' => $strategies["plugins:{$id}"] ?? 'overwrite',
];
}
foreach ($updates['templates'] ?? [] as $t) {
$id = $t['identifier'];
$manifest['templates'][] = [
'identifier' => $id,
'strategy' => $strategies["templates:{$id}"] ?? 'overwrite',
];
}
foreach ($updates['lang_packs'] ?? [] as $lp) {
$manifest['lang_packs'][] = ['identifier' => $lp['identifier']];
}
return $manifest;
}
private function bundledManifestIsEmpty(array $manifest): bool
{
return empty($manifest['modules'])
&& empty($manifest['plugins'])
&& empty($manifest['templates'])
&& empty($manifest['lang_packs']);
}
/**
* `core:execute-bundled-updates` 를 별도 PHP 프로세스에서 실행.
*
* 자식 stdout 을 부모 콘솔로 실시간 전달 (단 `[BUNDLED-RESULT]` prefix 라인은
* 결과 페이로드로 보관 후 부모 콘솔로 노출하지 않음). 종료 후 페이로드 파싱.
*
* @return array{success: int, failed: int}|null spawn 성공 시 결과, 실패/미지원 시 null
*/
private function spawnBundledUpdates(array $manifest): ?array
{
if (! function_exists('proc_open')) {
return null;
}
$manifestPath = storage_path('app/core_pending'.DIRECTORY_SEPARATOR.'bundled-updates-manifest_'.uniqid().'.json');
File::ensureDirectoryExists(dirname($manifestPath));
File::put($manifestPath, json_encode($manifest, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
try {
$phpBinary = config('process.php_binary', PHP_BINARY);
$artisan = base_path('artisan');
$command = [
$phpBinary,
$artisan,
'core:execute-bundled-updates',
'--manifest='.$manifestPath,
];
$commandLine = implode(' ', array_map('escapeshellarg', $command)).' 2>&1';
$descriptors = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
// ENV 합집합 (G7_UPDATE_IN_PROGRESS 등 핵심 플래그 자식에 전달)
$env = array_merge(getenv(), $_ENV);
$process = proc_open($commandLine, $descriptors, $pipes, base_path(), $env);
if (! is_resource($process)) {
return null;
}
fclose($pipes[0]);
$resultPayload = null;
$resultPrefix = ExecuteBundledUpdatesCommand::RESULT_PREFIX;
while (! feof($pipes[1])) {
$line = fgets($pipes[1]);
if ($line === false) {
continue;
}
$trimmed = rtrim($line);
if (str_starts_with($trimmed, $resultPrefix)) {
$json = substr($trimmed, strlen($resultPrefix));
$decoded = json_decode($json, true);
if (is_array($decoded)) {
$resultPayload = $decoded;
}
continue; // 부모 콘솔에 노출 안 함
}
$this->line($trimmed);
}
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
if ($resultPayload === null) {
Log::warning('번들 spawn 자식이 결과 페이로드를 출력하지 않음 — 카운트 0 으로 처리');
return ['success' => 0, 'failed' => 0];
}
return [
'success' => (int) ($resultPayload['success'] ?? 0),
'failed' => (int) ($resultPayload['failed'] ?? 0),
];
} finally {
if (File::exists($manifestPath)) {
File::delete($manifestPath);
}
}
}
/**
* in-process fallback — proc_open 미지원 환경 전용.
*
* 기존 (beta.3) 흐름의 직접 호출 패턴 보존. 단 부모 메모리의 stale 코드로 인해
* sync 메서드 누락 결함이 재현될 수 있으므로 spawn 가능 환경에서는 사용되지 않는다.
*
* @return array{success: int, failed: int, skipped: int, has_updates: bool}
*/
private function executeBulkUpdateInProcess(
ModuleManager $moduleManager,
PluginManager $pluginManager,
TemplateManager $templateManager,
array $updates,
array $strategies,
): array {
$success = 0;
$failed = 0;
foreach ($updates['modules'] as $m) {
$id = $m['identifier'];
$strategy = $strategies["modules:{$id}"] ?? 'overwrite';
@@ -224,6 +431,24 @@ trait BundledExtensionUpdatePrompt
}
}
$langPackService = app(LanguagePackService::class);
foreach ($updates['lang_packs'] ?? [] as $lp) {
$id = $lp['identifier'];
$this->line("→ [언어팩] {$id}");
try {
$pack = $langPackService->findByIdentifier($id);
if (! $pack) {
throw new \RuntimeException(__('language_packs.errors.identifier_not_found', ['identifier' => $id]));
}
$langPackService->performUpdate($pack, true);
$success++;
} catch (\Throwable $e) {
$failed++;
$this->warn(" 실패: {$e->getMessage()}");
Log::error('번들 일괄 업데이트 실패', ['type' => 'lang_pack', 'id' => $id, 'error' => $e->getMessage()]);
}
}
$this->newLine();
$this->info("업데이트 완료: 성공 {$success}, 실패 {$failed}");
@@ -234,4 +459,5 @@ trait BundledExtensionUpdatePrompt
'has_updates' => true,
];
}
}
+649 -18
View File
@@ -3,20 +3,27 @@
namespace App\Console\Commands\Core;
use App\Console\Commands\Core\Concerns\BundledExtensionUpdatePrompt;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Exceptions\UpgradeHandoffException;
use App\Extension\CoreVersionChecker;
use App\Extension\Helpers\CoreBackupHelper;
use App\Extension\Helpers\FilePermissionHelper;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Extension\Vendor\Exceptions\VendorInstallException;
use App\Extension\Vendor\VendorMode;
use App\Services\CoreUpdateService;
use App\Support\ConfigCacheHelper;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class CoreUpdateCommand extends Command
{
use BundledExtensionUpdatePrompt;
use ClearsTemplateCaches;
use HasUnifiedConfirm;
protected $signature = 'core:update
{--force : 버전 비교 없이 강제 업데이트}
@@ -179,7 +186,7 @@ class CoreUpdateCommand extends Command
}
$this->newLine();
if (! $this->confirm('코어를 업데이트하시겠습니까?')) {
if (! $this->unifiedConfirm('코어를 업데이트하시겠습니까?', false)) {
return Command::SUCCESS;
}
@@ -284,6 +291,22 @@ class CoreUpdateCommand extends Command
$log('원본 소유권 스냅샷 수집: '.implode(',', array_keys($ownershipSnapshot)));
}
// PHP-FPM 쓰기 영역의 항목별 정확 스냅샷 (owner/group/perms). Stage 4.
// sudo update 가 root 로 만들 수 있는 좁은 영역(storage/logs, storage/framework,
// storage/app/core_pending, bootstrap/cache) 의 모든 하위 항목을 재귀 stat 하여
// Step 11/12 의 restoreOwnership 이 항목별 정확 복원하도록 전달한다.
// 사용자 데이터 영역(storage/app/{modules,plugins,attachments,public,settings})
// 은 본 스냅샷 대상이 아니며 chown 자체가 빠지므로 시드/업로드 owner 가 보존된다.
$detailedOwnershipSnapshot = $service->snapshotOwnershipDetailed([
'storage/logs',
'storage/framework',
'storage/app/core_pending',
'bootstrap/cache',
]);
if (! empty($detailedOwnershipSnapshot)) {
$log('항목별 정확 스냅샷 수집: '.count($detailedOwnershipSnapshot).'개 항목 (PHP-FPM 쓰기 영역)');
}
// ── Step 6: _pending에서 Vendor 설치 (composer 또는 bundled) ──
$vendorMode = VendorMode::fromStringOrAuto((string) $this->option('vendor-mode'));
$composerSkipped = $vendorMode !== VendorMode::Bundled
@@ -312,6 +335,44 @@ class CoreUpdateCommand extends Command
}
}
// ── Step 6.5: 신 버전 신규 파일 manifest 생성 ──
//
// applyUpdate 직전 시점에 백업 디렉토리(= 활성 디렉토리의 사전 스냅샷) 와
// _pending(= 신 버전 소스) 을 비교해 신 버전이 추가하는 파일/디렉토리 목록을
// `_new_files_manifest.json` 으로 백업 디렉토리에 기록한다. 자동 롤백 시
// `restoreFromBackup()` 이 본 manifest 를 참조해 활성 디렉토리에서 정확히 그
// 항목만 prune. 사용자가 활성 디렉토리에 직접 추가한 파일은 백업에 포함되어
// 있으므로 manifest 에서 제외되어 보존된다.
//
// `--no-backup` 모드면 backupPath 가 null 이므로 manifest 생성을 스킵 — 롤백
// 자체가 불가능한 모드이므로 기존 동작 유지.
if ($backupPath !== null) {
$bar->setMessage('신규 파일 manifest 생성 중...');
$log('신규 파일 manifest 생성 시작');
try {
$manifestStats = CoreBackupHelper::writeNewFilesManifest(
$backupPath,
$pendingPath,
(array) config('app.update.targets', []),
(array) config('app.update.protected_paths', []),
(array) config('app.update.excludes', []),
$fromVersion,
$toVersion,
);
$log(sprintf(
'신규 파일 manifest 작성 완료 (files=%d, dirs=%d)',
$manifestStats['new_files_count'],
$manifestStats['new_dirs_count'],
));
} catch (\Throwable $manifestError) {
// manifest 작성 실패는 fatal 이 아님 — 롤백 시 기존 overlay 만 수행 (기존 동작)
$log("신규 파일 manifest 작성 실패 (계속 진행): {$manifestError->getMessage()}");
Log::warning('코어 업데이트: manifest 작성 실패', [
'error' => $manifestError->getMessage(),
]);
}
}
// ── Step 7: 파일 적용 ──
$bar->setMessage(__('settings.core_update.step_apply'));
$bar->advance();
@@ -336,13 +397,20 @@ class CoreUpdateCommand extends Command
}
// ── Step 9: Migration + 역할/메뉴 동기화 ──
//
// 동기화는 반드시 reloadCoreConfigAndResync() 로 호출한다. 본 메서드는 부모
// 프로세스가 부팅 시점에 캐시한 stale config 를 우회해 디스크의 fresh
// config/core.php 를 require → Config Repository 에 재주입한 뒤 syncCore* 를
// 호출한다. 디스크는 Step 7(applyUpdate) 에서 이미 신버전으로 교체되어 있다.
//
// syncCoreRolesAndPermissions / syncCoreMenus 직접 호출 금지 — 부모 메모리의
// 구버전 config 로 sync 가 돌면 신규 권한/메뉴가 누락된다 (#326 회귀).
$bar->setMessage(__('settings.core_update.step_migration'));
$bar->advance();
$log('마이그레이션, 역할/메뉴 동기화 실행');
$service->runMigrations();
$service->syncCoreRolesAndPermissions();
$service->syncCoreMenus();
$service->reloadCoreConfigAndResync();
$log('마이그레이션, 역할/메뉴 동기화 완료');
// ── Step 10: Upgrade Steps ──
@@ -393,9 +461,17 @@ class CoreUpdateCommand extends Command
$service->updateVersionInEnv($toVersion);
$service->clearAllCaches();
// 코어 업데이트 후 프론트엔드가 새 lang/routes/layout 자원으로 fetch 하도록
// `ext.cache_version` bump. 코어 lang JSON 변경이 프론트엔드 캐시에
// 반영되지 않는 회귀를 차단한다 (core-frontend-i18n-infrastructure 계획서).
$this->incrementExtensionCacheVersion();
// sudo 실행 시 composer 등 외부 프로세스가 root 로 생성한 파일의 소유권을
// 백업 직후 수집한 원본 스냅샷 기준으로 복원 (각 경로 고유 소유자 유지)
$service->restoreOwnership($ownershipSnapshot, $onProgress);
// 백업 직후 수집한 원본 스냅샷 기준으로 복원 (각 경로 고유 소유자 유지).
// detailedSnapshot 동시 전달 — PHP-FPM 쓰기 영역의 owner/group/perms 를 항목별
// 정확 복원하여 #282 (sudo update 후 traversal 비트 손실) 회귀 차단.
$service->restoreOwnership($ownershipSnapshot, $onProgress, $detailedOwnershipSnapshot);
$this->surfacePermissionWarnings($service, $log);
$log('업데이트 경로 소유권 복원 완료');
$service->cleanupPending($pendingPath);
@@ -409,6 +485,13 @@ class CoreUpdateCommand extends Command
$maintenanceEnabled = false;
}
// 모든 파일(코어 config/lang/vendor)이 안착한 뒤 config 캐시를 재생성한다.
// clearAllCaches() 는 흐름 중간에서 stale 캐시를 비우기만 하므로, 여기서
// 재생성하지 않으면 업데이트 후 config:cache 가 비활성 상태로 남아 이후 모든
// 요청이 config 파일을 재파싱한다(성능 손실). ConfigCacheHelper 는 설치 미완료
// 상태를 가드하고 실패를 안전하게 흡수한다.
ConfigCacheHelper::rebuild();
$log('정리 완료');
$bar->finish();
@@ -437,10 +520,99 @@ class CoreUpdateCommand extends Command
if (($promptResult['success'] ?? 0) > 0) {
$this->newLine();
$this->info('일괄 업데이트로 생성된 파일의 소유권을 복원하는 중...');
$service->restoreOwnership($ownershipSnapshot, $onProgress);
// 일괄 확장 update 가 sudo 컨텍스트에서 root 로 만들 수 있는 PHP-FPM 쓰기
// 영역의 owner/group/perms 를 항목별 정확 복원 (Stage 4).
$service->restoreOwnership($ownershipSnapshot, $onProgress, $detailedOwnershipSnapshot);
// restoreOwnership 의 진행 표시($onProgress → $bar->display())가
// 개행 없이 끝나므로 다음 셸 프롬프트가 같은 줄에 붙는 것을 방지.
$this->newLine(2);
$this->surfacePermissionWarnings($service, $log);
$log('일괄 확장 업데이트 후 소유권 재복원 완료');
}
// fallback(spawn 실패)로 부모가 upgrade step 을 직접 실행한 경우, 부모가 만든
// upgrade 로그가 root 로 남는다 — 모든 로그 쓰기가 끝난 이 시점에 정합한다.
$this->restoreUpgradeLogOwnership();
return Command::SUCCESS;
} catch (UpgradeHandoffException $e) {
// 업그레이드 스텝이 새 PHP 프로세스 재진입을 요청한 경우.
//
// 롤백하지 않음 — 파일 교체 / migration / composer 결과까지는 이미 `toVersion` 상태로 유효.
// Step 11 cleanup 을 축소 실행하여 중간 상태를 확정하고, 사용자에게 스텝 전용 재실행 안내.
//
// - updateVersionInEnv(toVersion): 디스크가 이미 toVersion 이므로 .env 도 toVersion 으로
// 일치시킨다. afterVersion 이 아님 — afterVersion 으로 두면 사용자가 다시 core:update
// 를 돌릴 때 GitHub 재다운로드부터 전체가 반복된다. toVersion 으로 고정 + 사용자에게는
// execute-upgrade-steps 만 실행하도록 안내하여 재다운로드를 회피한다.
// - clearAllCaches: 신규 코드·config 반영
// - restoreOwnership: sudo 생성 파일 소유권 복원
// - cleanupPending: _pending 정리
// - 백업은 유지 (재실행 중 실패해도 복구 가능)
// - maintenance 해제: 서비스 재개
//
// resumeCommand 는 예외에 명시되지 않았으면 `execute-upgrade-steps --from=<afterVersion>
// --to=<toVersion> --force` 로 자동 생성. step 만 단독 실행되어 재다운로드 없음.
$bar->finish();
$this->newLine(2);
$log("업그레이드 핸드오프 수신: {$e->afterVersion} 까지 완료 — {$e->reason}");
$resumeCommand = $e->resumeCommand ?? sprintf(
'php artisan core:execute-upgrade-steps --from=%s --to=%s --force',
$e->afterVersion,
$toVersion,
);
try {
$service->updateVersionInEnv($toVersion);
$service->clearAllCaches();
// 핸드오프 cleanup 에서도 프론트엔드 캐시 버전 bump — 사용자가 resume 명령
// (execute-upgrade-steps) 을 실행하기 전이라도 이미 toVersion 으로 반영된
// 코어 lang/routes/layout 자원이 프론트엔드 캐시 stale 로 가려지지 않도록.
$this->incrementExtensionCacheVersion();
// Stage 4 — handoff cleanup 도 detailed snapshot 으로 정확 복원
$service->restoreOwnership($ownershipSnapshot, $onProgress, $detailedOwnershipSnapshot);
$this->surfacePermissionWarnings($service, $log);
$log('핸드오프 cleanup 완료 (버전 toVersion 고정 + 캐시 clear + 소유권 복원)');
if (! empty($pendingPath)) {
$service->cleanupPending($pendingPath);
}
if ($maintenanceEnabled) {
$service->disableMaintenanceMode();
$maintenanceEnabled = false;
$log('유지보수 모드 해제');
}
} catch (\Throwable $cleanupError) {
$log("핸드오프 cleanup 중 오류: {$cleanupError->getMessage()}");
$this->warn("핸드오프 cleanup 중 오류 발생 — 수동 점검 필요: {$cleanupError->getMessage()}");
}
$this->saveUpdateLog($logEntries, $fromVersion, $toVersion, true);
$this->newLine();
$this->warn('업그레이드 파일 반영은 완료되었으나, 일부 업그레이드 스텝이 현재 프로세스에서 실행될 수 없어 중단되었습니다.');
$this->newLine();
$this->line(" 완료 스텝 지점: {$e->afterVersion}");
$this->line(" 파일·설정 버전: {$toVersion} (이미 반영됨)");
$this->line(" 사유: {$e->reason}");
$this->newLine();
$this->info('나머지 스텝을 적용하려면 아래 명령을 실행하세요 (재다운로드 없이 스텝만 실행):');
$this->surfaceResumeCommandWithPermissionGuidance($resumeCommand);
$this->newLine();
$this->warn('⚠ 위 명령을 실행하지 않으면 버전 표시는 최신이지만 일부 스텝이 미실행 상태로 남습니다.');
$this->newLine();
if ($backupPath) {
$this->line("백업이 유지되었습니다: {$backupPath}");
$this->newLine();
}
$this->restoreUpgradeLogOwnership();
return Command::SUCCESS;
} catch (\Throwable $e) {
@@ -473,6 +645,17 @@ class CoreUpdateCommand extends Command
$log("백업 복원 실패: {$restoreError->getMessage()}");
$this->error("백업 복원 실패: {$restoreError->getMessage()}");
}
// 롤백 직후 캐시 자동 정리 — 신 코드가 `bootstrap/cache/*.php` 에 cache 된 채로
// 남아 부팅 실패하는 회귀 차단. 운영자의 수동 `php artisan optimize:clear` 단계 제거.
// 캐시 정리 실패는 fatal 아님 — warning 후 진행.
try {
$service->clearAllCaches();
$log('롤백 후 캐시 정리 완료');
} catch (\Throwable $cacheError) {
$log("롤백 후 캐시 정리 실패 (계속 진행): {$cacheError->getMessage()}");
$this->warn("캐시 정리 실패 — 부팅 후 'php artisan optimize:clear' 수동 실행 필요: {$cacheError->getMessage()}");
}
}
// _pending 정리 (실패 시에도)
@@ -523,6 +706,8 @@ class CoreUpdateCommand extends Command
}
}
$this->restoreUpgradeLogOwnership();
return Command::FAILURE;
}
}
@@ -538,18 +723,27 @@ class CoreUpdateCommand extends Command
* 일 때만 성공으로 판정한다. proc_open 미지원 환경·커맨드 미존재·비정상 종료는
* false 반환하여 호출자가 in-process fallback 으로 전환할 수 있게 한다.
*
* 핸드오프 신호: 자식이 exit=UpgradeHandoffException::EXIT_CODE 로 종료하면서
* stdout 에 `[HANDOFF] <json>` 라인을 남기면, 본 메서드는 UpgradeHandoffException 을
* 재구성하여 상위로 던진다 (CoreUpdateCommand::handle 의 catch 블록이 처리).
*
* @param string $fromVersion 시작 버전
* @param string $toVersion 대상 버전
* @param bool $force 동일 버전 강제 실행 여부
* @param \Closure $log 로그 엔트리 수집 콜백
* @return bool spawn 성공 여부 (false 면 fallback 실행 필요)
* @return bool spawn 성공 여부 (false 면 fallback 실행 필요)
*
* @throws UpgradeHandoffException 자식이 핸드오프 신호를 보낸 경우
*/
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;
return $this->failSpawnWithMode(
'proc_open 비활성',
$log,
$fromVersion,
$toVersion,
);
}
$phpBinary = config('process.php_binary', PHP_BINARY);
@@ -565,6 +759,17 @@ class CoreUpdateCommand extends Command
if ($force) {
$command[] = '--force';
}
// 부모는 이미 Step 9 (runMigrations + reloadCoreConfigAndResync, 라인 408-409),
// Step 11 (updateVersionInEnv + clearAllCaches, 라인 457-458), 번들 확장 일괄 업데이트
// prompt (라인 497-515) 를 자식 종료 후 실행하므로 자식 내부 중복 회피. 단독 실행 시
// (운영자 직접 호출) 옵션이 전달되지 않아 자식 기본값(5단계 모두 포함) 발동 →
// gnuboard/g7#34 의 운영자 수동 절차(migrate / resync / .env sed / cache:clear / module:update --source=bundled)
// 가 단일 명령으로 통합되어 단독 안전성 보장.
$command[] = '--skip-migrations';
$command[] = '--skip-resync';
$command[] = '--skip-version-env';
$command[] = '--skip-cache-clear';
$command[] = '--skip-bundled-updates';
$commandLine = implode(' ', array_map('escapeshellarg', $command)).' 2>&1';
@@ -594,22 +799,67 @@ class CoreUpdateCommand extends Command
$process = proc_open($commandLine, $descriptors, $pipes, base_path(), $env);
if (! is_resource($process)) {
$log('spawn 실패 — proc_open 자원 생성 실패');
return false;
return $this->failSpawnWithMode(
'proc_open 자원 생성 실패',
$log,
$fromVersion,
$toVersion,
);
}
fclose($pipes[0]);
// stdout 실시간 전달 — 상위 콘솔에서 진행 상황 확인 가능
// 단, [HANDOFF] / [STEPS_EXECUTED] 접두사 라인은 상위 콘솔로 노출하지 않고
// 페이로드만 보관한다 (각각 UpgradeHandoffException 재구성 / silent skip 가드용).
$handoffPayload = null;
$stepsExecuted = null;
$stepsDiscovered = null;
while (! feof($pipes[1])) {
$line = fgets($pipes[1]);
if ($line !== false) {
$trimmed = rtrim($line);
if ($trimmed !== '') {
$this->line($trimmed);
$log('[spawn] '.$trimmed);
if ($trimmed === '') {
continue;
}
if (str_starts_with($trimmed, '[HANDOFF] ')) {
$json = substr($trimmed, strlen('[HANDOFF] '));
$decoded = json_decode($json, true);
// 페이로드 값 검증 — array_key_exists 만으로는 비정상 입력(빈 문자열·
// shell metacharacter·과도 길이) 이 통과한다. resumeCommand 는 null
// 허용 (자식이 null 로 전달한 경우 부모가 from/to 기반 자동 생성).
if ($this->isValidHandoffPayload($decoded)) {
$handoffPayload = $decoded;
$log('[spawn] 핸드오프 신호 수신: after='.$decoded['afterVersion']);
continue;
}
// 구조/값이 깨진 핸드오프 라인 — 정상 출력으로 간주해 그대로 전달
if (is_array($decoded)) {
$log('[spawn] 핸드오프 페이로드 검증 실패 — 정상 출력으로 처리: '.json_encode($decoded, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
}
if (str_starts_with($trimmed, '[STEPS_EXECUTED] ')) {
$json = substr($trimmed, strlen('[STEPS_EXECUTED] '));
$decoded = json_decode($json, true);
if (is_array($decoded) && isset($decoded['count']) && is_int($decoded['count']) && $decoded['count'] >= 0) {
$stepsExecuted = $decoded['count'];
// discovered 는 신버전 자식만 발행하는 필드. 구버전 자식(필드 부재)은
// null 로 남아 handleSpawnExit 가 기존(레거시) 판정 경로를 탄다.
if (isset($decoded['discovered']) && is_int($decoded['discovered']) && $decoded['discovered'] >= 0) {
$stepsDiscovered = $decoded['discovered'];
}
$log('[spawn] 실행된 step 수: '.$stepsExecuted
.($stepsDiscovered !== null ? " (발견 {$stepsDiscovered})" : ''));
continue;
}
}
$this->line($trimmed);
$log('[spawn] '.$trimmed);
}
}
@@ -617,17 +867,207 @@ class CoreUpdateCommand extends Command
fclose($pipes[2]);
$exitCode = proc_close($process);
return $this->handleSpawnExit(
$exitCode,
$handoffPayload,
$stepsExecuted,
$stepsDiscovered,
$fromVersion,
$toVersion,
$log,
);
}
/**
* spawn 자식 종료 후 exit code · 신호를 해석해 성공/실패/핸드오프를 판정합니다.
*
* 판정 규칙 (exit=0 경우):
* - `[STEPS_EXECUTED]` 신호 미수신 (`$stepsExecuted === null`) → fail-fast.
* 이전 버전 자식 (신호 미발행) 또는 silent skip 의심.
* - executed=0 인데 **discovered>0** → fail-fast. 범위 내에 스텝 파일이 실제로
* 존재하는데 자식이 하나도 실행하지 못함 (gnuboard/g7#28 silent skip — 케이스 A).
* - executed=0 이고 **discovered=0** → 정상 통과. 해당 from~to 범위에 스텝 파일이
* 애초에 없는 릴리즈 (예: 데이터/설정 변경 없는 패치). from<to 여도 실패 아님 (케이스 B).
* - discovered 신호 부재 (`null`, 구버전 자식) + executed=0 + from<to → 레거시 판정 유지
* (fail-fast). 신버전 자식은 항상 discovered 를 발행하므로 이 경로는 구버전 자식 한정.
*
* @param int $exitCode 자식 프로세스 종료 코드
* @param array|null $handoffPayload 파싱된 [HANDOFF] 페이로드 (없으면 null)
* @param int|null $stepsExecuted 실행된 스텝 수 ([STEPS_EXECUTED] 미수신 시 null)
* @param int|null $stepsDiscovered 범위 내 발견된 스텝 파일 수 (구버전 자식은 null)
* @param string $fromVersion 업그레이드 시작 버전
* @param string $toVersion 업그레이드 대상 버전
* @param \Closure $log 로그 수집 콜백
* @return bool spawn 성공 여부 (mode=fallback 실패 시 false — 호출자 in-process fallback)
*
* @throws UpgradeHandoffException 핸드오프 수신 또는 mode=abort 실패 시
*/
private function handleSpawnExit(
int $exitCode,
?array $handoffPayload,
?int $stepsExecuted,
?int $stepsDiscovered,
string $fromVersion,
string $toVersion,
\Closure $log,
): bool {
if ($exitCode === UpgradeHandoffException::EXIT_CODE && $handoffPayload !== null) {
$log("spawn 핸드오프 종료 (exit={$exitCode})");
// resumeCommand 가 null 이면 null 그대로 전달 — 상위 catch 에서 자동 생성.
$resume = $handoffPayload['resumeCommand'];
throw new UpgradeHandoffException(
afterVersion: (string) $handoffPayload['afterVersion'],
reason: (string) $handoffPayload['reason'],
resumeCommand: is_string($resume) ? $resume : null,
);
}
if ($exitCode === 0) {
$log('spawn 완료 (exit=0)');
// 신호 미수신 — 이전 버전 자식 (신호 미발행) 또는 silent skip 의심. fail-fast.
if ($stepsExecuted === null) {
return $this->failSpawnWithMode(
'spawn 자식이 [STEPS_EXECUTED] 신호 미발행 — 이전 버전 자식 또는 silent skip 의심',
$log,
$fromVersion,
$toVersion,
);
}
// executed=0 + discovered=0 → 범위 내 스텝 파일 부재. 정상 통과 (케이스 B).
// 스텝이 필요 없는 릴리즈(데이터/설정 변경 없음)를 실패로 오판하지 않는다.
if ($stepsExecuted === 0 && $stepsDiscovered === 0) {
$log("spawn 완료 (exit=0, 실행할 스텝 없음 — 범위 내 스텝 파일 부재, from={$fromVersion} to={$toVersion})");
return true;
}
// executed=0 + (discovered>0 또는 discovered 신호 부재) + from<to → fail-fast.
// discovered>0: 스텝 파일이 있는데 자식이 실행 못함 (gnuboard/g7#28 케이스 A).
// discovered=null: 구버전 자식 (discovered 미발행) — 레거시 판정 유지.
if ($stepsExecuted === 0
&& $stepsDiscovered !== 0
&& version_compare($fromVersion, $toVersion, '<')) {
return $this->failSpawnWithMode(
sprintf(
'spawn 자식 exit=0 이지만 step 0건 실행 — 의도된 동작이 아님 (from=%s to=%s%s)',
$fromVersion,
$toVersion,
$stepsDiscovered !== null ? ", 발견 {$stepsDiscovered}건" : '',
),
$log,
$fromVersion,
$toVersion,
);
}
$log("spawn 완료 (exit=0, steps={$stepsExecuted})");
return true;
}
$log("spawn 비정상 종료 (exit={$exitCode}) — fallback 진행");
return $this->failSpawnWithMode(
"spawn 비정상 종료 (exit={$exitCode})",
$log,
$fromVersion,
$toVersion,
);
}
/**
* spawn 자식 프로세스 실패 시 `app.update.spawn_failure_mode` 에 따라 분기합니다.
*
* - 'abort' (기본): `UpgradeHandoffException` 을 throw 하여 부모의 in-process
* fallback 진입을 차단. 부모 catch 블록이 cleanup 후 운영자에게
* `core:execute-upgrade-steps` 수동 명령을 안내한다.
* - 'fallback' (호환): 기존 동작 — log 한 줄 남기고 false 반환. 호출자가
* in-process fallback 으로 진행. 부모 메모리 stale 시 fatal 위험.
*
* @param string $reason 실패 사유 (로그/안내 메시지에 포함)
* @param \Closure $log 로그 엔트리 수집 콜백
* @param string $fromVersion 업그레이드 시작 버전 (handoff afterVersion / resumeCommand 구성)
* @param string $toVersion 업그레이드 대상 버전 (resumeCommand 구성)
* @return false fallback 모드일 때만 반환. abort 모드는 throw 후 미반환.
*
* @throws UpgradeHandoffException mode=abort 일 때
*/
private function failSpawnWithMode(string $reason, \Closure $log, string $fromVersion, string $toVersion): bool
{
$mode = config('app.update.spawn_failure_mode', 'fallback');
if ($mode === 'abort') {
$resumeCommand = sprintf(
'php artisan core:execute-upgrade-steps --from=%s --to=%s --force',
$fromVersion,
$toVersion,
);
$log("spawn 자식 실패 — {$reason}. fail-fast 모드 abort.");
throw new UpgradeHandoffException(
afterVersion: $fromVersion,
reason: "spawn 자식 실패 — {$reason}. fail-fast 모드. 수동 재개로 진행하세요.",
resumeCommand: $resumeCommand,
);
}
$log("{$reason} — in-process fallback 진행 (stale 메모리 위험)");
return false;
}
/**
* [HANDOFF] 페이로드의 값을 검증합니다.
*
* spawn 자식의 stdout 을 부모가 그대로 신뢰하면 (a) 빈 afterVersion 으로 Step 11
* fromVersion 해석 혼란, (b) shell injection 문자열이 운영자 안내에 노출, (c) 과도한
* reason 길이로 로그 폭증 위험. 본 메서드는 다음 4가지 키/값을 동시에 검증한다:
*
* - `afterVersion`: 정규식 `/^\d+\.\d+\.\d+/` 매치 + 비어있지 않음
* - `reason`: string + 길이 < 500
* - `resumeCommand`: null 허용. string 일 때 길이 < 1000 + shell metacharacter(`;&|`$()<>`) 부재
*
* @param mixed $payload json_decode 결과
*/
private function isValidHandoffPayload($payload): bool
{
if (! is_array($payload)) {
return false;
}
foreach (['afterVersion', 'reason', 'resumeCommand'] as $key) {
if (! array_key_exists($key, $payload)) {
return false;
}
}
$afterVersion = $payload['afterVersion'];
if (! is_string($afterVersion) || $afterVersion === '' || ! preg_match('/^\d+\.\d+\.\d+/', $afterVersion)) {
return false;
}
$reason = $payload['reason'];
if (! is_string($reason) || strlen($reason) >= 500) {
return false;
}
$resumeCommand = $payload['resumeCommand'];
if ($resumeCommand !== null) {
if (! is_string($resumeCommand)) {
return false;
}
if (strlen($resumeCommand) >= 1000) {
return false;
}
if (preg_match('/[;&|`$()<>]/', $resumeCommand)) {
return false;
}
}
return true;
}
/**
* 업데이트 로그를 파일로 저장합니다.
*
@@ -655,10 +1095,44 @@ class CoreUpdateCommand extends Command
$content = $header."\n".implode("\n", $entries)."\n";
file_put_contents($logPath, $content);
// sudo 업데이트가 로그 파일을 root 소유로 만들면 이후 www-data 의 tinker/로그 쓰기가
// 거부된다. 부모(storage/logs) 소유권을 상속한다(멱등, sudo 없으면 silent no-op).
FilePermissionHelper::inheritOwnershipFromParent($logPath);
Log::info("코어 업데이트 로그 저장: {$logPath}");
}
/**
* 코어 업데이트(부모 프로세스)가 생성한 upgrade 로그 파일의 소유권을 부모(storage/logs)
* 로 정합합니다 — **spawn 실패로 부모가 upgrade step 을 in-process 로 직접 실행한 경우** 대비.
*
* upgrade 로그(`upgrade-YYYY-MM-DD.log`)를 만드는 주체는 두 갈래다:
* - spawn 성공: 자식(ExecuteUpgradeStepsCommand)이 로그 생성 → 자식이 자기 종료 직전 정합
* - spawn 실패(fallback): 부모가 runUpgradeSteps + reloadCoreConfigAndResync 를 직접 실행
* 하여 부모 프로세스가 upgrade 로그를 root 로 생성 → **부모가** 정합해야 한다.
*
* sudo 업데이트는 root 로 실행되어 로그를 root 소유로 만들고, 이후 www-data(php-fpm) 의
* module:update upgrade step 이 같은 날짜 로그에 append 하지 못해 Permission denied 로
* 실패한다. 본 메서드를 코어 업데이트의 모든 로그 쓰기가 끝난 종료 시점(각 return 직전)에
* 호출한다. **본 메서드 자체는 로그를 쓰지 않는다**(쓰면 다시 root 가 됨). silent no-op 멱등.
*/
private function restoreUpgradeLogOwnership(): void
{
$logsDir = storage_path('logs');
if (! is_dir($logsDir)) {
return;
}
// 코어('upgrade-*.log') + 확장('extension-upgrade-*.log') 두 채널의 daily 로그를 모두
// 정합한다. glob 'upgrade-*.log' 는 'extension-' 접두사 파일을 매칭하지 못하므로
// 두 패턴을 각각 순회한다.
foreach (['upgrade-*.log', 'extension-upgrade-*.log'] as $pattern) {
foreach (glob($logsDir.DIRECTORY_SEPARATOR.$pattern) ?: [] as $logFile) {
FilePermissionHelper::inheritOwnershipFromParent($logFile);
}
}
}
/**
* 현재 실행 사용자와 코어 파일 소유자가 다른 경우 경고를 표시합니다.
*
@@ -705,4 +1179,161 @@ class CoreUpdateCommand extends Command
'owner_user' => $ownerUser,
]);
}
/**
* 업그레이드 스텝 재실행 명령을 실행 사용자 권한에 맞는 안내와 함께 출력합니다.
*
* 배경: sudo(root) 로 `core:update` 를 실행하면 업그레이드 스텝 spawn 자식이 root 로
* 돌아가고, 그 자식이 실패(proc_open 미지원 · 비정상 종료 · silent skip)하면 스텝이
* 미실행 상태로 남아 운영자에게 재실행을 안내한다. 이때 운영자가 안내받은 명령을 그대로
* root 로 재실행하면 스텝이 만드는 파일·캐시가 root 소유로 생성되어, 이후 웹서버
* (php-fpm www-data 등) 요청이 그 경로에 쓰기 실패한다.
*
* 실행 환경을 4가지로 분기해 안내한다:
* 1. non-root (일반 SSH 사용자 = 파일 소유자) 또는 posix 미지원(Windows 등)
* → 명령만 그대로 안내 (권한 분기 불필요, 공유 호스팅 = 웹서버·PHP·실행 유저 동일 포함).
* 2. root 실행 + 웹서버 계정 식별 가능 + 실행 사용자와 다름
* → `sudo -u {webUser}` 로 재실행 안내 + root 그대로 실행 시 위험 경고.
* 3. root 실행 + 웹서버 계정을 root 로 추정 (root 로 서비스하는 비표준/컨테이너 구성)
* → 권한 문제 없으므로 명령만 그대로 안내.
* 4. root 실행 + 웹서버 계정 식별 불가 (스냅샷/추정 실패)
* → 명령 그대로 안내하되 "웹서버 계정으로 실행" 일반 경고 (계정명 미상).
*
* @param string $resumeCommand 재실행할 `core:execute-upgrade-steps` 명령
*/
private function surfaceResumeCommandWithPermissionGuidance(string $resumeCommand): void
{
[$mode, $webUser] = $this->classifyResumeExecutionContext();
$this->renderResumeGuidance($resumeCommand, $mode, $webUser);
}
/**
* 실행 환경 모드에 따라 재실행 명령과 권한 안내를 콘솔에 출력합니다.
*
* `classifyResumeExecutionContext()` 판정 결과를 입력으로 받아 출력만 담당한다 —
* posix/파일시스템 상태에 의존하지 않으므로 4가지 모드 전부 결정적으로 테스트 가능하다.
*
* @param string $resumeCommand 재실행할 `core:execute-upgrade-steps` 명령
* @param string $mode `classifyResumeExecutionContext()` 가 반환한 실행 환경 모드
* @param string|null $webUser 웹서버 계정명 (`root_web_known` 일 때만 유효)
*/
private function renderResumeGuidance(string $resumeCommand, string $mode, ?string $webUser): void
{
// 케이스 1·3: 권한 분기 불필요 — 명령만 그대로 안내.
// - non_root: 일반 SSH 사용자 / 공유 호스팅(웹서버=PHP=실행 유저 동일) / posix 미지원
// - root_web_symmetric: root 로 서비스하는 구성 → 재실행도 root 로 무해
if ($mode === 'non_root' || $mode === 'root_web_symmetric') {
$this->line(" {$resumeCommand}");
return;
}
// 케이스 2: root 실행 + 웹서버 계정 식별 → 웹서버 권한 재실행 안내.
if ($mode === 'root_web_known' && $webUser !== null) {
$this->line(" sudo -u {$webUser} {$resumeCommand}");
$this->newLine();
$this->warn('⚠ 현재 sudo(root) 로 실행 중입니다. 위 명령을 root 로 그대로 실행하면 업그레이드');
$this->warn(" 스텝이 생성하는 파일이 root 소유가 되어 이후 웹서버({$webUser}) 요청이 쓰기 실패할 수");
$this->warn(" 있습니다. 반드시 웹서버 계정({$webUser})으로 재실행하세요.");
return;
}
// 케이스 4: root 실행 + 웹서버 계정 식별 불가 — 계정명 미상 상태의 일반 경고.
$this->line(" sudo -u <웹서버계정> {$resumeCommand}");
$this->newLine();
$this->warn('⚠ 현재 sudo(root) 로 실행 중입니다. 웹서버 계정을 자동으로 식별하지 못했습니다.');
$this->warn(' 위 명령을 root 로 그대로 실행하면 업그레이드 스텝이 생성하는 파일이 root 소유가 되어');
$this->warn(' 이후 웹서버(php-fpm) 요청이 쓰기 실패할 수 있습니다. 웹서버 계정(예: www-data,');
$this->warn(' nginx, apache)을 확인해 그 계정으로 재실행하세요.');
}
/**
* 재실행 안내의 실행 환경을 분류합니다.
*
* 반환하는 모드:
* - `non_root` : posix 미지원 또는 현재 유효 사용자가 root 가 아님
* (일반 SSH 사용자 = 파일 소유자, 공유 호스팅 대칭 구성 포함).
* - `root_web_known` : root 실행 + 웹서버 계정 식별 성공 + 실행 사용자(root)와 다름.
* - `root_web_symmetric` : root 실행 + 웹서버 계정이 root 로 추정됨 (root 서비스 구성).
* - `root_web_unknown` : root 실행 + 웹서버 계정 추정 실패 (스냅샷/추정 불가).
*
* 웹서버 계정은 `FilePermissionHelper::inferWebServerOwnership()` 이 storage/bootstrap
* 쓰기 영역 소유자로 추정한다.
*
* @return array{0: string, 1: string|null} [모드, 웹서버 계정명 또는 null]
*/
private function classifyResumeExecutionContext(): array
{
if (! function_exists('posix_geteuid') || ! function_exists('posix_getpwuid')) {
return ['non_root', null];
}
// root(sudo) 실행이 아니면 일반 SSH 사용자 = 파일 소유자 → 권한 분기 안내 불필요.
// (공유 호스팅에서 웹서버·PHP·실행 유저가 같은 경우도 여기서 non_root 로 처리됨.)
if (posix_geteuid() !== 0) {
return ['non_root', null];
}
[$owner] = FilePermissionHelper::inferWebServerOwnership();
// 추정 실패 (스냅샷 불가) — 계정명 미상 경고 경로.
if ($owner === false) {
return ['root_web_unknown', null];
}
// 웹서버 계정이 root 로 추정됨 — root 로 서비스하는 구성이라 재실행도 root 로 무해.
if ($owner === 0) {
return ['root_web_symmetric', null];
}
$entry = posix_getpwuid($owner);
$name = $entry['name'] ?? null;
// uid 는 나왔지만 이름 해석 실패 — 미상 경로로 처리 (uid 노출은 오히려 혼란).
if ($name === null) {
return ['root_web_unknown', null];
}
return ['root_web_known', $name];
}
/**
* `restoreOwnership()` 직후 누적된 권한 정상화 실패 경고를 콘솔/로그에 즉시 노출합니다.
*
* 운영자가 sudo 환경 결함(파일시스템 ACL, immutable 비트, NFS 권한 거부 등) 으로
* 일부 경로 chown / chmod 실패 시 그 경로와 운영자 수동 복구 명령을 즉시 보여준다.
* 본 메서드 호출 후 service 의 `lastPermissionWarnings` 가 다음 호출 시 초기화되므로
* 매 `restoreOwnership` 직후 1회 호출 패턴이 정합.
*
* @param callable $log 내부 로그 누적 콜백 (`saveUpdateLog` 입력용)
*/
private function surfacePermissionWarnings(CoreUpdateService $service, callable $log): void
{
$warnings = $service->getLastPermissionWarnings();
if (empty($warnings)) {
return;
}
$this->newLine();
$this->warn('⚠ 권한 정상화 실패 — 일부 경로의 소유권/그룹 쓰기 권한을 복원하지 못했습니다.');
foreach ($warnings as $w) {
$kind = $w['kind'] === 'chown' ? '소유권' : '그룹 쓰기';
$this->warn(sprintf(' - %s [%s]: %d 건 실패', $w['target'], $kind, $w['failed']));
foreach (array_slice($w['failed_paths'], 0, 5) as $p) {
$this->line(" · {$p}");
}
if (count($w['failed_paths']) > 5) {
$this->line(sprintf(' · … (총 %d건, 상위 5건만 표시)', $w['failed']));
}
}
$this->newLine();
$this->line(' 복구 예시:');
$this->line(' sudo chown -R <owner>:<group> <path>');
$this->line(' sudo chmod -R g+w <path>');
$this->newLine();
$log(sprintf('권한 정상화 실패 %d 건 — 운영자 수동 복구 필요', count($warnings)));
}
}
@@ -0,0 +1,178 @@
<?php
namespace App\Console\Commands\Core;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Extension\Vendor\VendorMode;
use App\Services\LanguagePackService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
/**
* 번들 확장(모듈/플러그인/템플릿/언어팩) 일괄 업데이트를 별도 PHP 프로세스에서 실행합니다.
*
* `core:update` 의 `BundledExtensionUpdatePrompt::executeBulkUpdate` 가 사용자 선택을
* 매니페스트로 직렬화한 후 본 커맨드를 `proc_open` 으로 spawn 한다. 자식은 fresh PHP
* 프로세스이므로 디스크에 막 적용된 신버전 코어 코드(특히 `ModuleManager` /
* `PluginManager` / `TemplateManager` / `LanguagePackService`) 를 메모리에 로드한 상태
* 에서 update 메서드를 호출한다.
*
* 부모 프로세스의 stale memory 가 신규 sync 메서드(`syncDeclarativeArtifacts` 등) 를
* 호출하지 못하던 결함의 영구 차단 (beta.4 의 `Upgrade_7_0_0_beta_4` 사후 보정과는 별개의
* 구조적 fix — 향후 모든 코어 업그레이드에서 동일 패턴 회귀를 차단).
*
* 매니페스트 형식:
* ```json
* {
* "modules": [{"identifier": "...", "strategy": "overwrite|keep"}, ...],
* "plugins": [{"identifier": "...", "strategy": "overwrite|keep"}, ...],
* "templates": [{"identifier": "...", "strategy": "overwrite|keep"}, ...],
* "lang_packs": [{"identifier": "..."}, ...]
* }
* ```
*
* 출력:
* - stdout 에 진행 라인을 그대로 출력 (부모 콘솔이 forwarding)
* - 종료 직전 `[BUNDLED-RESULT] {json}` 표식으로 최종 카운트 페이로드를 1회 출력 →
* 부모가 stdout 파싱해 결과 복원.
*/
class ExecuteBundledUpdatesCommand extends Command
{
public const RESULT_PREFIX = '[BUNDLED-RESULT] ';
protected $signature = 'core:execute-bundled-updates
{--manifest= : 업데이트 매니페스트 JSON 파일 경로 (필수)}
{--force : 강제 업데이트 플래그 (확장 매니저 update 호출에 전달)}';
protected $description = '번들 확장 일괄 업데이트를 실행합니다 (CoreUpdateCommand 내부용 — fresh PHP 프로세스에서 호출)';
public function handle(): int
{
$manifestPath = (string) $this->option('manifest');
if ($manifestPath === '' || ! File::isFile($manifestPath)) {
$this->error('--manifest 옵션이 필수이며 존재하는 파일이어야 합니다.');
return self::INVALID;
}
// spawn 자식 진입 시 활성 모듈/플러그인의 PSR-4 매핑을 fresh 등록 — 자세한 배경은
// ExecuteUpgradeStepsCommand::handle 의 동일 호출 주석 참조.
// (Artisan::call 대신 직접 메서드 호출 — nested Artisan::call 이 outer 명령의
// output buffer 를 덮어쓰는 Laravel 동작 회피)
try {
app(\App\Extension\ExtensionManager::class)->updateComposerAutoload();
} catch (\Throwable $e) {
Log::warning('bundled update spawn 자식: updateComposerAutoload 호출 실패', [
'error' => $e->getMessage(),
]);
}
$manifest = json_decode(File::get($manifestPath), true);
if (! is_array($manifest)) {
$this->error('매니페스트 JSON 파싱 실패: '.$manifestPath);
return self::FAILURE;
}
$modules = $manifest['modules'] ?? [];
$plugins = $manifest['plugins'] ?? [];
$templates = $manifest['templates'] ?? [];
$langPacks = $manifest['lang_packs'] ?? [];
$moduleManager = app(ModuleManager::class);
$pluginManager = app(PluginManager::class);
$templateManager = app(TemplateManager::class);
$langPackService = app(LanguagePackService::class);
$success = 0;
$failed = 0;
$this->info('── 번들 일괄 업데이트 실행 (spawn child) ──');
foreach ($modules as $entry) {
$id = (string) ($entry['identifier'] ?? '');
$strategy = (string) ($entry['strategy'] ?? 'overwrite');
if ($id === '') {
continue;
}
$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('번들 일괄 업데이트 실패 (spawn)', ['type' => 'module', 'id' => $id, 'error' => $e->getMessage()]);
}
}
foreach ($plugins as $entry) {
$id = (string) ($entry['identifier'] ?? '');
$strategy = (string) ($entry['strategy'] ?? 'overwrite');
if ($id === '') {
continue;
}
$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('번들 일괄 업데이트 실패 (spawn)', ['type' => 'plugin', 'id' => $id, 'error' => $e->getMessage()]);
}
}
foreach ($templates as $entry) {
$id = (string) ($entry['identifier'] ?? '');
$strategy = (string) ($entry['strategy'] ?? 'overwrite');
if ($id === '') {
continue;
}
$this->line("→ [템플릿] {$id} ({$strategy})");
try {
$templateManager->updateTemplate($id, true, null, $strategy, 'bundled');
$success++;
} catch (\Throwable $e) {
$failed++;
$this->warn(" 실패: {$e->getMessage()}");
Log::error('번들 일괄 업데이트 실패 (spawn)', ['type' => 'template', 'id' => $id, 'error' => $e->getMessage()]);
}
}
foreach ($langPacks as $entry) {
$id = (string) ($entry['identifier'] ?? '');
if ($id === '') {
continue;
}
$this->line("→ [언어팩] {$id}");
try {
$pack = $langPackService->findByIdentifier($id);
if (! $pack) {
throw new \RuntimeException(__('language_packs.errors.identifier_not_found', ['identifier' => $id]));
}
$langPackService->performUpdate($pack, true);
$success++;
} catch (\Throwable $e) {
$failed++;
$this->warn(" 실패: {$e->getMessage()}");
Log::error('번들 일괄 업데이트 실패 (spawn)', ['type' => 'lang_pack', 'id' => $id, 'error' => $e->getMessage()]);
}
}
$this->newLine();
$this->info("업데이트 완료: 성공 {$success}, 실패 {$failed}");
// 부모 프로세스가 결과를 복원할 수 있도록 표식 라인으로 페이로드 출력
$this->line(self::RESULT_PREFIX.json_encode([
'success' => $success,
'failed' => $failed,
], JSON_UNESCAPED_UNICODE));
return $failed > 0 ? self::FAILURE : self::SUCCESS;
}
}
@@ -2,6 +2,15 @@
namespace App\Console\Commands\Core;
use App\Console\Commands\Core\Concerns\BundledExtensionUpdatePrompt;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Exceptions\UpgradeHandoffException;
use App\Extension\ExtensionManager;
use App\Extension\Helpers\FilePermissionHelper;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use App\Extension\TemplateManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Services\CoreUpdateService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
@@ -17,15 +26,35 @@ use Illuminate\Support\Facades\Log;
* 업그레이드는 beta.1 의 CoreUpdateCommand 가 본 커맨드를 알지 못하므로 spawn
* 효과를 받지 못한다(경로 A) — 이 경우 upgrade step 파일 내부 로컬 로직으로
* 후처리를 수행해야 한다. 상세는 docs/extension/upgrade-step-guide.md 참조.
*
* 단독 실행 안전성: 운영자가 HANDOFF 안내문 또는 수동 복구 목적으로 직접 호출하면
* 기본값으로 부모 CoreUpdateCommand 가 처리하던 사전(Migration + Resync) 및
* 사후(.env 버전 + 캐시 정리 + 번들 확장 일괄 업데이트) 단계를 자동 수행한다.
* CoreUpdateCommand spawn 호출 시엔 `--skip-*` 옵션 5개로 중복 회피.
*
* `--steps-only`: 업그레이드 스텝만 실행하고 모든 보조 단계(권한 정상화 ·
* 오토로드 재생성 · 마이그레이션 · resync · 버전 갱신 · 캐시 정리 · 번들 업데이트)를
* 생략한다. 특정 스텝의 데이터 보정만 단발성으로 재실행할 때 사용한다. 보조 단계가
* 이미 완료된 환경에서 무거운 권한 재귀 순회 등을 건너뛴다.
*/
class ExecuteUpgradeStepsCommand extends Command
{
use BundledExtensionUpdatePrompt;
use ClearsTemplateCaches;
use HasUnifiedConfirm;
protected $signature = 'core:execute-upgrade-steps
{--from= : 시작 버전}
{--to= : 대상 버전}
{--force : 동일 버전 강제 실행}';
{--force : 동일 버전 강제 실행 + 번들 확장 일괄 업데이트 prompt 스킵}
{--steps-only : 업그레이드 스텝만 실행 — 권한 정상화·오토로드 재생성·마이그레이션·resync·버전 갱신·캐시 정리·번들 업데이트 등 모든 사전/사후 보조 단계 생략}
{--skip-migrations : 마이그레이션 실행 생략 (CoreUpdateCommand spawn 시 부모 Step 9 가 이미 실행)}
{--skip-resync : 코어 config 재로드 및 권한/메뉴/시더 동기화 생략 (동일 사유)}
{--skip-version-env : .env APP_VERSION 갱신 생략 (부모 Step 11 가 처리)}
{--skip-cache-clear : 캐시 정리 생략 (부모 Step 11 가 처리)}
{--skip-bundled-updates : 번들 확장 일괄 업데이트 생략 (부모가 prompt 로 처리)}';
protected $description = '코어 업그레이드 스텝을 별도 프로세스에서 실행합니다 (CoreUpdateCommand 내부용)';
protected $description = '코어 업그레이드 스텝을 별도 프로세스에서 실행합니다. 단독 실행 시 사전/사후 단계를 자동 수행하며, --steps-only 로 스텝만 실행할 수 있습니다.';
/**
* 커맨드를 실행합니다.
@@ -39,19 +68,144 @@ class ExecuteUpgradeStepsCommand extends Command
$to = (string) $this->option('to');
$force = (bool) $this->option('force');
// --steps-only: 업그레이드 스텝만 실행하고 모든 보조 단계(권한 정상화 · 오토로드
// 재생성 · 마이그레이션 · resync · 버전 갱신 · 캐시 정리 · 번들 업데이트)를 생략한다.
// 운영자가 특정 스텝의 데이터 보정만 단발성으로 재실행할 때 사용한다.
$stepsOnly = (bool) $this->option('steps-only');
if ($from === '' || $to === '') {
$this->error('--from 과 --to 는 필수 옵션입니다.');
return self::INVALID;
}
// spawn 자식 진입 시 활성 모듈/플러그인의 PSR-4 매핑을 fresh 등록.
// 부모 프로세스의 autoload-extensions.php 가 stale 한 경우 upgrade step 안에서
// ModuleManager / PluginManager 호출 → declaration 메서드가 Models/Services 등
// 다른 클래스 lazy load 시 "Class not found" 발생. 진입 직후 1회 호출로 모든 후속
// upgrade step (현재 + 미래) 이 fresh autoload 환경에서 실행됨을 보장.
//
// 본 커맨드 자체가 spawn 자식 (proc_open 으로 fork 된 별개 PHP 프로세스) 의 진입점이라
// 디스크의 fresh ExtensionManager(beta.X+1) 클래스를 메모리에 로드한 상태. 따라서
// `app(ExtensionManager::class)->updateComposerAutoload()` 직접 호출은 stale 가능성
// 없음. (Artisan::call 대신 직접 호출 — nested Artisan::call 이 outer 명령의
// output buffer 를 덮어쓰는 Laravel 동작 회피)
if (! $stepsOnly) {
try {
app(ExtensionManager::class)->updateComposerAutoload();
} catch (\Throwable $e) {
Log::warning('upgrade step spawn 자식: updateComposerAutoload 호출 실패', [
'error' => $e->getMessage(),
]);
}
}
// spawn 자식 진입 시 활성 디렉토리의 쓰기 권한 디렉토리를 멱등적으로 보장.
// fresh 디스크 config (`app.update.restore_ownership_group_writable`) 를 읽어 처리하므로
// 미래 release 가 새 쓰기 권한 디렉토리를 도입할 때 본 호출은 자동으로 신규 항목을 처리한다.
// upgrade step 에 mkdir/chown 코드를 매번 하드코딩할 필요 없음.
//
// 한계: 부모(이전 버전) 만 알고 있던 신규 디렉토리는 처리 불가 — 그 일회성 케이스는
// 해당 release 의 upgrade step 단발 처리. (예: beta.3→beta.4 의 lang-packs/* 보정)
if (! $stepsOnly) {
try {
$writablePaths = (array) config('app.update.restore_ownership_group_writable', []);
if (! empty($writablePaths)) {
$service->ensureWritableDirectories(
$writablePaths,
function (string $level, string $msg): void {
// 콘솔 + upgrade 로그 채널 동시 출력 — 운영자가 단일 파일(upgrade.log)에서
// spawn 자식의 권한 정상화 진행을 추적할 수 있도록 양쪽 모두 누적.
$this->{$level === 'warning' ? 'warn' : 'info'}($msg);
Log::channel('upgrade')->$level('[spawn] '.$msg);
},
);
}
} catch (\Throwable $e) {
Log::warning('upgrade step spawn 자식: ensureWritableDirectories 호출 실패', [
'error' => $e->getMessage(),
]);
}
}
// 코어 업데이트 spawn 자식 감지.
//
// CoreUpdateCommand::spawnUpgradeStepsProcess 가 자식에 `G7_UPDATE_IN_PROGRESS=1` env 를
// 전달한다 (beta.3 부터 존재). 이 시그널은 자식이 부모의 `core:update` 컨텍스트에서
// 실행 중임을 의미한다. 부모는 본인이 사전(Step 9 migration + resync) + 사후
// (Step 11 version-env + cache-clear + bundled prompt) 단계를 모두 책임지므로 자식은
// 5단계 모두 스킵해야 한다.
//
// 구버전 부모(예: beta.5)는 신버전 자식이 도입한 `--skip-*` 옵션을 모른다. 옵션 부재
// 시 자식이 사후 단계를 실행하면 (특히 bundled prompt) non-interactive 환경에서
// `Aborted.` exit=1 회귀 발생 (882deb9b0 사각지대). env 시그널이 옵션 부재를 보완.
//
// 단독 호출 (운영자 수동) 시엔 env 미설정 → 모든 단계 정상 진행.
$isSpawnChild = getenv('G7_UPDATE_IN_PROGRESS') === '1';
// 사전 단계 (단독 실행 안전성 보장).
//
// CoreUpdateCommand 가 spawn 호출 시엔 부모 Step 9 (CoreUpdateCommand.php:408-409) 에서
// 이미 동일 단계를 실행했으므로 --skip-migrations / --skip-resync 로 중복 회피.
// 운영자 단독 호출 시 옵션 미전달 → 기본값으로 두 단계 자동 수행 →
// migration / permission / menu / seeder 누락 차단.
if (! $this->option('skip-migrations') && ! $isSpawnChild && ! $stepsOnly) {
$this->info('마이그레이션 실행');
$service->runMigrations();
} else {
$this->info($stepsOnly ? '[steps-only] 마이그레이션 스킵' : '[spawn] 마이그레이션 스킵 — 부모가 이미 실행');
}
if (! $this->option('skip-resync') && ! $isSpawnChild && ! $stepsOnly) {
$this->info('코어 config 재로드 및 권한/메뉴/시더 동기화');
$service->reloadCoreConfigAndResync();
} else {
$this->info($stepsOnly ? '[steps-only] resync 스킵' : '[spawn] resync 스킵 — 부모가 이미 실행');
}
$stepsExecuted = 0;
$stepsDiscovered = 0;
try {
$service->runUpgradeSteps(
$from,
$to,
fn (string $version) => $this->info("upgrade step 실행: {$version}"),
function (string $version) use (&$stepsExecuted): void {
$this->info("upgrade step 실행: {$version}");
$stepsExecuted++;
},
$force,
function (int $discovered) use (&$stepsDiscovered): void {
$stepsDiscovered = $discovered;
},
);
} catch (UpgradeHandoffException $e) {
// 업그레이드 스텝이 새 PHP 프로세스 재진입을 요청했다.
// 부모 프로세스(spawnUpgradeStepsProcess)가 [HANDOFF] 라인을 stdout 에서
// 읽어 페이로드를 복원하고 UpgradeHandoffException 재구성 후 상위로 던지도록,
// JSON 페이로드를 표식과 함께 출력한다. 표식 문자열은 구분자 역할이므로 일반
// step 출력과 충돌하지 않게 고정된 접두사를 사용한다.
//
// resumeCommand 는 step 작성자가 null 로 두는 것을 권장(CoreUpdateCommand 가
// from/to 버전을 사용해 자동 생성). null 도 JSON 으로 그대로 전달.
$payload = json_encode([
'afterVersion' => $e->afterVersion,
'reason' => $e->reason,
'resumeCommand' => $e->resumeCommand,
], JSON_UNESCAPED_UNICODE);
$this->line('[HANDOFF] '.$payload);
Log::info('core:execute-upgrade-steps 핸드오프', [
'from' => $from,
'to' => $to,
'after' => $e->afterVersion,
'reason' => $e->reason,
]);
$this->restoreUpgradeLogOwnership();
return UpgradeHandoffException::EXIT_CODE;
} catch (\Throwable $e) {
Log::error('core:execute-upgrade-steps 실패', [
'from' => $from,
@@ -60,9 +214,97 @@ class ExecuteUpgradeStepsCommand extends Command
]);
$this->error($e->getMessage());
$this->restoreUpgradeLogOwnership();
return self::FAILURE;
}
// 부모 (CoreUpdateCommand::spawnUpgradeStepsProcess) 가 silent skip 가드용으로 파싱하는
// 명시 종료 신호. [HANDOFF] 와 동일 구조의 라인 프로토콜. 정상 종료 (handoff 미발생) 경로
// 에서만 발행되며, 부모는 exit=0 인데 본 라인이 부재하면 mode=abort 시 throw,
// mode=fallback 시 in-process 진행 (`spawn_failure_mode`).
$this->line('[STEPS_EXECUTED] '.json_encode([
'count' => $stepsExecuted,
'discovered' => $stepsDiscovered,
'fromVersion' => $from,
'toVersion' => $to,
], JSON_UNESCAPED_UNICODE));
// 사후 단계 (단독 실행 안전성 보장).
//
// CoreUpdateCommand spawn 시엔 부모 Step 11 (CoreUpdateCommand.php:457-458) 및
// 번들 확장 일괄 업데이트 prompt (라인 497-515) 가 자식 종료 후 실행하므로
// --skip-version-env / --skip-cache-clear / --skip-bundled-updates 로 중복 회피.
// 단독 실행 시엔 옵션 미전달 → 기본값으로 3단계 자동 수행 → gnuboard/g7#34 의 운영자 수동 절차
// (sed APP_VERSION + cache:clear + module/plugin/template:update --force --source=bundled) 통합.
if (! $this->option('skip-version-env') && ! $isSpawnChild && ! $stepsOnly) {
$this->info(".env APP_VERSION={$to} 갱신");
$service->updateVersionInEnv($to);
} else {
$this->info($stepsOnly ? '[steps-only] .env APP_VERSION 갱신 스킵' : '[spawn] .env APP_VERSION 갱신 스킵 — 부모가 처리');
}
if (! $this->option('skip-cache-clear') && ! $isSpawnChild && ! $stepsOnly) {
$this->info('캐시 정리 (config/route/view/services/packages)');
$service->clearAllCaches();
// clearAllCaches() 는 config:clear 만 하므로, 단독 실행 흐름에서는 config 캐시가
// 비활성 상태로 남는다. 모든 upgrade step + 번들 확장 업데이트가 config 소스를
// 변경했을 수 있으니, 캐시 정리 세트의 마지막에 config 캐시를 재생성한다.
// spawn 자식·steps-only 는 이 블록을 스킵하고 부모(CoreUpdateCommand)가 재생성한다.
\App\Support\ConfigCacheHelper::rebuild();
// 업그레이드 스텝 단독 실행 시에도 코어 lang/routes/layout 변경이
// 프론트엔드 캐시 stale 로 가려지지 않도록 `ext.cache_version` bump.
// spawn 자식·steps-only 모드에서는 부모가 처리하므로 스킵.
$this->incrementExtensionCacheVersion();
} else {
$this->info($stepsOnly ? '[steps-only] 캐시 정리 스킵' : '[spawn] 캐시 정리 스킵 — 부모가 처리');
}
if (! $this->option('skip-bundled-updates') && ! $isSpawnChild && ! $stepsOnly) {
$this->info('번들 확장 일괄 업데이트 (모듈/플러그인/템플릿/언어팩)');
$this->runBundledExtensionUpdatePrompt(
app(ModuleManager::class),
app(PluginManager::class),
app(TemplateManager::class),
$force,
);
} else {
$this->info($stepsOnly ? '[steps-only] 번들 확장 일괄 업데이트 스킵' : '[spawn] 번들 확장 일괄 업데이트 스킵 — 부모가 처리');
}
$this->restoreUpgradeLogOwnership();
return self::SUCCESS;
}
/**
* spawn 자식이 root 로 만든 upgrade 로그 파일의 소유권을 부모(storage/logs) 로 정합합니다.
*
* upgrade step 은 sudo 코어 업데이트에서 별도 PHP 프로세스로 spawn 되며(이 커맨드),
* root 로 실행되어 `upgrade-YYYY-MM-DD.log`(Log::channel('upgrade')) 를 root 소유로
* 만든다. 부모(CoreUpdateCommand) 프로세스는 업데이트 시작 시점의 *이전 버전* 클래스를
* 메모리에 들고 있어, 부모 측 로그 정상화 로직이 추가돼 있어도 그 버전엔 없을 수 있다
* (클래스 캐싱). 반면 본 spawn 자식은 항상 *신버전* 코드로 실행되므로, upgrade 로그를
* 만든 주체인 자식이 자기 종료 직전에 직접 소유권을 정합하면 부모 버전과 무관하게 동작한다.
*
* 그 결과 이후 www-data(php-fpm) 의 module:update upgrade step 이 같은 날짜 upgrade
* 로그에 append 할 수 있다. 본 메서드는 어떤 로그도 쓰지 않는다(쓰면 다시 root 가 됨).
* sudo 아닌 환경에서는 silent no-op(멱등).
*/
private function restoreUpgradeLogOwnership(): void
{
$logsDir = storage_path('logs');
if (! is_dir($logsDir)) {
return;
}
// 코어('upgrade-*.log') + 확장('extension-upgrade-*.log') 두 채널의 daily 로그를 모두
// 정합한다. glob 'upgrade-*.log' 는 'extension-' 접두사 파일을 매칭하지 못하므로
// 두 패턴을 각각 순회한다.
foreach (['upgrade-*.log', 'extension-upgrade-*.log'] as $pattern) {
foreach (glob($logsDir.DIRECTORY_SEPARATOR.$pattern) ?: [] as $logFile) {
FilePermissionHelper::inheritOwnershipFromParent($logFile);
}
}
}
}
@@ -3,6 +3,9 @@
namespace App\Console\Commands\Extension;
use App\Extension\ExtensionManager;
use App\Extension\HookCacheManager;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use Illuminate\Console\Command;
class UpdateAutoloadCommand extends Command
@@ -24,8 +27,12 @@ class UpdateAutoloadCommand extends Command
/**
* Execute the console command.
*/
public function handle(ExtensionManager $extensionManager): int
{
public function handle(
ExtensionManager $extensionManager,
HookCacheManager $hookCacheManager,
ModuleManager $moduleManager,
PluginManager $pluginManager,
): int {
$this->info('확장 오토로드 파일을 생성합니다...');
try {
@@ -34,6 +41,16 @@ class UpdateAutoloadCommand extends Command
$this->info('오토로드 파일이 성공적으로 생성되었습니다.');
$this->line(' → bootstrap/cache/autoload-extensions.php');
// 오토로드 캐시와 동일 생명주기의 정적 훅 매핑 캐시도 함께 재생성.
// 코어 업데이트(clearAllCaches → 이 커맨드) 시 코어 리스너 추가/변경/삭제가
// 캐시에 반영되도록 보장한다 (미갱신 시 stale 매핑으로 훅 발화 누락/과잉 위험).
$moduleManager->loadModules();
$pluginManager->loadPlugins();
$hookCacheManager->generate($moduleManager, $pluginManager);
$this->info('정적 훅 매핑 캐시가 재생성되었습니다.');
$this->line(' → bootstrap/cache/hooks.php');
return Command::SUCCESS;
} catch (\Exception $e) {
$this->error('오토로드 파일 생성 중 오류가 발생했습니다: '.$e->getMessage());
@@ -0,0 +1,57 @@
<?php
namespace App\Console\Commands;
use App\Extension\HookCacheManager;
use App\Extension\ModuleManager;
use App\Extension\PluginManager;
use Illuminate\Console\Command;
/**
* 정적 훅 매핑 캐시 생성 Artisan 커맨드.
*
* 코어(app/Listeners) + 활성 모듈/플러그인의 정적 훅 리스너를 사전 계산하여
* bootstrap/cache/hooks.php 에 저장한다. route:cache 동형 — 코어 리스너를
* 추가/변경하는 코드 배포 시 배포 파이프라인에서 실행한다.
* (확장 install/update 시에는 ExtensionManager 가 자동 재생성하므로 수동 실행 불필요.)
*/
class HooksCacheCommand extends Command
{
/**
* @var string 커맨드 시그니처
*/
protected $signature = 'hooks:cache';
/**
* @var string 커맨드 설명
*/
protected $description = '정적 훅 매핑 캐시 생성 (bootstrap/cache/hooks.php)';
/**
* 커맨드를 실행합니다.
*
* @param HookCacheManager $hookCacheManager 훅 캐시 매니저
* @param ModuleManager $moduleManager 모듈 매니저
* @param PluginManager $pluginManager 플러그인 매니저
* @return int 종료 코드
*/
public function handle(
HookCacheManager $hookCacheManager,
ModuleManager $moduleManager,
PluginManager $pluginManager,
): int {
$moduleManager->loadModules();
$pluginManager->loadPlugins();
$counts = $hookCacheManager->generate($moduleManager, $pluginManager);
$this->info(__('hooks.cache_generated', [
'core' => $counts['core'],
'modules' => $counts['modules'],
'plugins' => $counts['plugins'],
'path' => $hookCacheManager->getCacheFilePath(),
]));
return Command::SUCCESS;
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Console\Commands;
use App\Extension\HookCacheManager;
use Illuminate\Console\Command;
/**
* 정적 훅 매핑 캐시 삭제 Artisan 커맨드.
*
* bootstrap/cache/hooks.php 를 삭제한다. 캐시 삭제 후에는 부팅 시 스캔 폴백으로
* 동작하므로 항상 안전하다 (route:clear 동형).
*/
class HooksClearCommand extends Command
{
/**
* @var string 커맨드 시그니처
*/
protected $signature = 'hooks:clear';
/**
* @var string 커맨드 설명
*/
protected $description = '정적 훅 매핑 캐시 삭제 (bootstrap/cache/hooks.php)';
/**
* 커맨드를 실행합니다.
*
* @param HookCacheManager $hookCacheManager 훅 캐시 매니저
* @return int 종료 코드
*/
public function handle(HookCacheManager $hookCacheManager): int
{
if (! $hookCacheManager->isCached()) {
$this->info(__('hooks.cache_absent'));
return Command::SUCCESS;
}
$hookCacheManager->clear();
$this->info(__('hooks.cache_cleared'));
return Command::SUCCESS;
}
}
@@ -0,0 +1,246 @@
<?php
namespace App\Console\Commands\Hotfix;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Extension\Helpers\CoreBackupHelper;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
/**
* 자동 롤백 후 활성 디렉토리에 잔존할 수 있는 신 버전 신규 파일을 진단/정리하는 단발성
* 결함 보정 도구.
*
* 결함 컨텍스트: 7.0.0-beta.5 이전의 코어 자동 롤백은 backup → 활성 overlay 복사 방식
* 으로만 동작하여, 신 버전이 활성 디렉토리에 새로 추가한 파일은 백업에 없으므로 삭제되지
* 않고 잔존했다. 결과적으로 `bootstrap/providers.php` 는 백업본(구버전) 으로 되돌아가지만
* 디스크에는 신 ServiceProvider 파일이 잔존하여 `BindingResolutionException` 등 부팅
* 부정합 발생.
*
* 도입 버전: 7.0.0-beta.6 신설.
*
* 동작:
* - `--backup` 미지정 시 `storage/app/core_backups/` 의 가장 최근 백업 자동 선택
* - 백업 디렉토리에 `_new_files_manifest.json` 이 있으면 그 기반으로 잔존 파일 후보 식별 (정확)
* - manifest 부재 시 보수적 진단 모드 (실제 정리는 운영자 수동)
* - 기본은 진단 모드 (실제 삭제 없이 후보 목록만 출력)
* - `--prune` 옵션 시 확인 프롬프트 후 정리. symlink/protected_paths 가드 적용
* - 정리 결과는 `storage/logs/hotfix_rollback_stale_files_<timestamp>.log` 기록
*
* @permanent — 7.0.0-beta.5 이전 사용자의 잔존 결함 회복을 위해 영구 보존. 향후 운영자가
* 과거 롤백 흔적을 점검할 수 있도록 유지하며, deprecation 예정 없음.
*/
class RollbackStaleFilesCommand extends Command
{
use HasUnifiedConfirm;
protected $signature = 'hotfix:rollback-stale-files
{--backup= : 특정 백업 디렉토리 경로 지정 (미지정 시 가장 최근 백업 자동 선택)}
{--prune : 진단만이 아닌 실제 정리 수행 (확인 프롬프트 동반)}';
protected $description = '[hotfix/beta.6 신설] 코어 자동 롤백 후 활성 디렉토리에 잔존할 수 있는 신 버전 신규 파일을 진단/정리합니다.';
public function handle(): int
{
$backupPath = $this->resolveBackupPath();
if ($backupPath === null) {
$this->info('사용 가능한 백업이 없습니다 (`storage/app/core_backups/` 비어 있음).');
return Command::SUCCESS;
}
$this->info("백업 디렉토리: {$backupPath}");
$manifestPath = $backupPath.DIRECTORY_SEPARATOR.CoreBackupHelper::NEW_FILES_MANIFEST;
$manifestAvailable = File::exists($manifestPath);
if (! $manifestAvailable) {
$this->warn('⚠ _new_files_manifest.json 부재 — 보수적 진단 모드로 진행합니다.');
$this->line(' manifest 가 없으면 신 파일 vs 사용자 추가 파일을 정확히 구분할 수 없습니다.');
$this->line(' beta.5 이전 사용자: beta.6 업그레이드 스텝이 manifest 를 사후 작성하므로');
$this->line(' 먼저 `php artisan core:execute-upgrade-steps --from=<버전> --to=7.0.0-beta.6 --force`');
$this->line(' 를 실행한 뒤 본 커맨드를 재실행하는 것을 권장합니다.');
$this->newLine();
}
$candidates = $this->collectCandidates($backupPath, $manifestAvailable);
if ($candidates['files'] === [] && $candidates['dirs'] === []) {
$this->info('잔존 후보 없음 — 활성 디렉토리가 정상 상태입니다.');
return Command::SUCCESS;
}
$this->newLine();
$this->info(sprintf(
'잔존 후보: 파일 %d개, 디렉토리 %d개',
count($candidates['files']),
count($candidates['dirs']),
));
foreach (array_slice($candidates['files'], 0, 20) as $f) {
$this->line(" · 파일: {$f}");
}
if (count($candidates['files']) > 20) {
$this->line(sprintf(' ... (총 %d건, 상위 20건만 표시)', count($candidates['files'])));
}
foreach (array_slice($candidates['dirs'], 0, 10) as $d) {
$this->line(" · 디렉토리: {$d}");
}
if (count($candidates['dirs']) > 10) {
$this->line(sprintf(' ... (총 %d건, 상위 10건만 표시)', count($candidates['dirs'])));
}
if (! $this->option('prune')) {
$this->newLine();
$this->info('진단 모드: 실제 삭제하지 않았습니다. 정리하려면 `--prune` 옵션을 추가하세요.');
return Command::SUCCESS;
}
if (! $manifestAvailable) {
$this->newLine();
$this->error('manifest 부재 상태에서는 자동 prune 을 수행하지 않습니다 (사용자 추가 파일 손실 위험).');
$this->line('beta.6 업그레이드 스텝을 먼저 실행하여 manifest 를 사후 작성한 뒤 재시도하세요.');
return Command::FAILURE;
}
$this->newLine();
if (! $this->unifiedConfirm('위 후보를 활성 디렉토리에서 정리하시겠습니까?', false)) {
$this->info('취소되었습니다.');
return Command::SUCCESS;
}
$protectedPaths = (array) config('app.update.protected_paths', []);
$result = CoreBackupHelper::pruneNewFiles($backupPath, base_path(), $protectedPaths);
$this->newLine();
$this->info(sprintf(
'정리 완료: 파일 %d개, 디렉토리 %d개 제거. 보호 %d건, symlink skip %d건, 실패 %d건.',
$result['removed_files'],
$result['removed_dirs'],
$result['protected_count'],
$result['symlink_skipped'],
$result['failed_count'],
));
$logPath = storage_path(sprintf(
'logs/hotfix_rollback_stale_files_%s.log',
date('Ymd_His'),
));
@file_put_contents($logPath, $this->buildLogEntry($backupPath, $result, $candidates));
$this->line("로그: {$logPath}");
Log::info('hotfix:rollback-stale-files 정리 완료', [
'backup_path' => $backupPath,
'result' => $result,
]);
return Command::SUCCESS;
}
/**
* `--backup` 옵션 또는 가장 최근 백업 디렉토리 경로를 반환.
*/
private function resolveBackupPath(): ?string
{
$explicit = $this->option('backup');
if ($explicit !== null && $explicit !== '') {
$real = realpath($explicit);
if ($real === false || ! is_dir($real)) {
$this->error("지정된 백업 디렉토리가 존재하지 않습니다: {$explicit}");
return null;
}
return $real;
}
$backups = CoreBackupHelper::listBackups();
if ($backups === []) {
return null;
}
// listBackups 는 정렬 보장이 약하므로 mtime 기준으로 다시 정렬
usort($backups, fn ($a, $b) => filemtime($b['path']) <=> filemtime($a['path']));
return $backups[0]['path'] ?? null;
}
/**
* 잔존 후보 식별 — manifest 가 있으면 그 기반, 부재 시 빈 배열 반환 (운영자 수동 검토 안내).
*
* @return array{files:array<int,string>, dirs:array<int,string>}
*/
private function collectCandidates(string $backupPath, bool $manifestAvailable): array
{
if (! $manifestAvailable) {
return ['files' => [], 'dirs' => []];
}
$manifestPath = $backupPath.DIRECTORY_SEPARATOR.CoreBackupHelper::NEW_FILES_MANIFEST;
$raw = @file_get_contents($manifestPath);
if ($raw === false) {
return ['files' => [], 'dirs' => []];
}
$manifest = json_decode($raw, true);
if (! is_array($manifest)) {
return ['files' => [], 'dirs' => []];
}
$files = [];
foreach ((array) ($manifest['new_files'] ?? []) as $rel) {
if (! is_string($rel) || $rel === '') {
continue;
}
$absolute = base_path($rel);
if (file_exists($absolute) || is_link($absolute)) {
$files[] = $rel;
}
}
$dirs = [];
foreach ((array) ($manifest['new_dirs'] ?? []) as $rel) {
if (! is_string($rel) || $rel === '') {
continue;
}
$absolute = base_path($rel);
if (is_dir($absolute) && ! is_link($absolute)) {
$dirs[] = $rel;
}
}
return ['files' => $files, 'dirs' => $dirs];
}
/**
* 진단/정리 결과를 로그 텍스트로 빌드.
*/
private function buildLogEntry(string $backupPath, array $result, array $candidates): string
{
$lines = [
'=== hotfix:rollback-stale-files 실행 로그 ===',
'날짜: '.date('Y-m-d H:i:s'),
"백업 디렉토리: {$backupPath}",
'',
'결과:',
sprintf(' 파일 제거: %d', $result['removed_files']),
sprintf(' 디렉토리 제거: %d', $result['removed_dirs']),
sprintf(' 보호 경로 skip: %d', $result['protected_count']),
sprintf(' symlink skip: %d', $result['symlink_skipped']),
sprintf(' 실패: %d', $result['failed_count']),
'',
'후보 목록 (실행 직전 스냅샷):',
];
foreach ($candidates['files'] as $f) {
$lines[] = " · 파일: {$f}";
}
foreach ($candidates['dirs'] as $d) {
$lines[] = " · 디렉토리: {$d}";
}
return implode("\n", $lines)."\n";
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\Console\Commands\LanguagePack;
use App\Services\LanguagePackService;
use Illuminate\Console\Command;
use Throwable;
/**
* 언어팩을 활성화합니다.
*
* 모듈/플러그인/템플릿의 activate 커맨드와 동일한 운영 도구.
* 동일 슬롯의 다른 활성 팩은 자동으로 inactive 로 전환됩니다.
*/
class ActivateLanguagePackCommand extends Command
{
/**
* @var string
*/
protected $signature = 'language-pack:activate
{identifier : 언어팩 식별자}
{--force : 의존성/검증 경고를 무시하고 강제 활성화}';
/**
* @var string
*/
protected $description = '언어팩을 활성화합니다.';
/**
* @param LanguagePackService $service 언어팩 Service
*/
public function __construct(
private readonly LanguagePackService $service,
) {
parent::__construct();
}
/**
* 커맨드를 실행합니다.
*
* @return int 종료 코드
*/
public function handle(): int
{
$identifier = (string) $this->argument('identifier');
$force = (bool) $this->option('force');
$pack = $this->service->findByIdentifier($identifier);
if (! $pack) {
$this->error("언어팩을 찾을 수 없습니다: {$identifier}");
return self::FAILURE;
}
try {
$this->service->activate($pack, $force);
$this->info("언어팩 활성화 완료: {$identifier}");
return self::SUCCESS;
} catch (Throwable $e) {
$this->error('언어팩 활성화 실패: '.$e->getMessage());
return self::FAILURE;
}
}
}
@@ -0,0 +1,57 @@
<?php
namespace App\Console\Commands\LanguagePack;
use App\Services\LanguagePackService;
use Illuminate\Console\Command;
use Throwable;
/**
* 언어팩 관련 캐시(레지스트리/Translator/템플릿/버전 스탬프)를 정리합니다.
*
* 모듈/플러그인/템플릿의 cache-clear 커맨드와 동일한 운영 도구.
* _bundled 의 다국어 파일을 손으로 수정한 뒤 즉시 반영하려는 운영 시나리오에 사용합니다.
*/
class CacheClearLanguagePackCommand extends Command
{
/**
* @var string
*/
protected $signature = 'language-pack:cache-clear';
/**
* @var string
*/
protected $description = '언어팩 캐시(registry/translator/template/version)를 정리합니다.';
/**
* @param LanguagePackService $service 언어팩 Service
*/
public function __construct(
private readonly LanguagePackService $service,
) {
parent::__construct();
}
/**
* 커맨드를 실행합니다.
*
* @return int 종료 코드
*/
public function handle(): int
{
try {
$result = $this->service->refreshCache();
$this->info('언어팩 캐시 정리 완료:');
foreach ($result as $key => $ok) {
$this->line(' - '.$key.': '.($ok ? 'ok' : 'failed'));
}
return collect($result)->every(fn ($v) => $v === true) ? self::SUCCESS : self::FAILURE;
} catch (Throwable $e) {
$this->error('언어팩 캐시 정리 실패: '.$e->getMessage());
return self::FAILURE;
}
}
}
@@ -0,0 +1,86 @@
<?php
namespace App\Console\Commands\LanguagePack;
use App\Services\LanguagePackService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* GitHub 으로 설치된 언어팩의 최신 릴리스 버전을 확인하여 `latest_version` 컬럼을 갱신합니다.
*
* 관리 UI 에서 "업데이트 가능" 배지 노출에 사용되며, 스케줄러에서 주기적으로 실행됩니다.
*/
class CheckLanguagePackUpdatesCommand extends Command
{
/**
* @var string
*/
protected $signature = 'language-pack:check-updates {--identifier= : 특정 언어팩만 확인 (선택)}';
/**
* @var string
*/
protected $description = '설치된 언어팩의 GitHub 업데이트를 확인합니다.';
/**
* @param LanguagePackService $service 언어팩 Service (실제 로직 SSoT)
*/
public function __construct(
private readonly LanguagePackService $service,
) {
parent::__construct();
}
/**
* 커맨드를 실행합니다.
*
* 실제 갱신 로직은 LanguagePackService::checkUpdates() 에 위임합니다.
*
* @return int 종료 코드 (0=성공, 1=실패)
*/
public function handle(): int
{
$identifier = $this->option('identifier');
try {
$result = $this->service->checkUpdates($identifier);
if ($result['checked'] === 0) {
$this->info('확인할 GitHub 기반 언어팩이 없습니다.');
return Command::SUCCESS;
}
foreach ($result['details'] as $entry) {
if ($entry['error']) {
$this->warn(sprintf(' [%s] 조회 실패: %s', $entry['identifier'], $entry['error']));
continue;
}
$line = sprintf(
' [%s] %s → %s%s',
$entry['identifier'],
$entry['current'],
$entry['latest'] ?? '?',
$entry['has_update'] ? ' (업데이트 가능)' : '',
);
$this->line($line);
}
$this->info(sprintf('확인 완료: %d 건 검사, %d 건 업데이트 가능.', $result['checked'], $result['updates']));
return Command::SUCCESS;
} catch (Throwable $e) {
$this->error('업데이트 확인 실패: '.$e->getMessage());
Log::error('language-pack:check-updates 실패', [
'identifier' => $identifier,
'error' => $e->getMessage(),
]);
return Command::FAILURE;
}
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Console\Commands\LanguagePack;
use App\Services\LanguagePackService;
use Illuminate\Console\Command;
use Throwable;
/**
* 언어팩을 비활성화합니다.
*
* 모듈/플러그인/템플릿의 deactivate 커맨드와 동일한 운영 도구.
* 보호된 팩(is_protected=true)은 거부되며, 호스트 cascade 컨텍스트에서만 우회됩니다.
*/
class DeactivateLanguagePackCommand extends Command
{
/**
* @var string
*/
protected $signature = 'language-pack:deactivate
{identifier : 언어팩 식별자}';
/**
* @var string
*/
protected $description = '언어팩을 비활성화합니다.';
/**
* @param LanguagePackService $service 언어팩 Service
*/
public function __construct(
private readonly LanguagePackService $service,
) {
parent::__construct();
}
/**
* 커맨드를 실행합니다.
*
* @return int 종료 코드
*/
public function handle(): int
{
$identifier = (string) $this->argument('identifier');
$pack = $this->service->findByIdentifier($identifier);
if (! $pack) {
$this->error("언어팩을 찾을 수 없습니다: {$identifier}");
return self::FAILURE;
}
try {
$this->service->deactivate($pack);
$this->info("언어팩 비활성화 완료: {$identifier}");
return self::SUCCESS;
} catch (Throwable $e) {
$this->error('언어팩 비활성화 실패: '.$e->getMessage());
return self::FAILURE;
}
}
}
@@ -0,0 +1,75 @@
<?php
namespace App\Console\Commands\LanguagePack;
use App\Services\LanguagePackService;
use Illuminate\Console\Command;
use Throwable;
/**
* 번들/GitHub/URL 소스에서 언어팩을 설치합니다.
*
* 모듈/플러그인/템플릿의 install 커맨드와 동일한 운영 도구.
* `--source=bundled` 시 `lang-packs/_bundled/{identifier}` 디렉토리에서 설치하며,
* 외부 다운로드 없이 코어 언어팩의 (재)설치/복구가 가능합니다.
*/
class InstallLanguagePackCommand extends Command
{
/**
* @var string
*/
protected $signature = 'language-pack:install
{identifier : 번들 식별자 또는 소스에 따른 식별자/URL}
{--source=bundled : 설치 소스 (bundled|github|url)}
{--no-activate : 설치 후 자동 활성화하지 않음}';
/**
* @var string
*/
protected $description = '번들/GitHub/URL 소스에서 언어팩을 설치합니다.';
/**
* @param LanguagePackService $service 언어팩 Service
*/
public function __construct(
private readonly LanguagePackService $service,
) {
parent::__construct();
}
/**
* 커맨드를 실행합니다.
*
* @return int 종료 코드
*/
public function handle(): int
{
$identifier = (string) $this->argument('identifier');
$source = (string) $this->option('source');
$autoActivate = ! (bool) $this->option('no-activate');
try {
$pack = match ($source) {
'bundled' => $this->service->installFromBundled($identifier, $autoActivate),
'github' => $this->service->installFromGithub($identifier, $autoActivate),
'url' => $this->service->installFromUrl($identifier, null, $autoActivate),
default => throw new \InvalidArgumentException(
__('language_packs.errors.unsupported_source', ['source' => $source])
),
};
$this->info(sprintf(
'언어팩 설치 완료: %s v%s (status=%s)',
$pack->identifier,
$pack->version,
$pack->status,
));
return self::SUCCESS;
} catch (Throwable $e) {
$this->error('언어팩 설치 실패: '.$e->getMessage());
return self::FAILURE;
}
}
}
@@ -0,0 +1,75 @@
<?php
namespace App\Console\Commands\LanguagePack;
use App\Contracts\Repositories\LanguagePackRepositoryInterface;
use Illuminate\Console\Command;
/**
* 설치된 언어팩 목록을 출력합니다.
*
* 모듈/플러그인/템플릿의 list 커맨드와 동일한 운영 도구.
*/
class ListLanguagePackCommand extends Command
{
/**
* @var string
*/
protected $signature = 'language-pack:list {--scope= : 특정 스코프만 (core/module/plugin/template)}';
/**
* @var string
*/
protected $description = '설치된 언어팩 목록을 출력합니다.';
/**
* @param LanguagePackRepositoryInterface $repository Repository
*/
public function __construct(
private readonly LanguagePackRepositoryInterface $repository,
) {
parent::__construct();
}
/**
* 커맨드를 실행합니다.
*
* @return int 종료 코드
*/
public function handle(): int
{
$filters = [];
if ($scope = $this->option('scope')) {
$filters['scope'] = $scope;
}
$paginator = $this->repository->paginate($filters, 100);
$packs = $paginator->items();
if (empty($packs)) {
$this->info('설치된 언어팩이 없습니다.');
return self::SUCCESS;
}
$rows = [];
foreach ($packs as $pack) {
$rows[] = [
$pack->identifier,
$pack->scope,
$pack->target_identifier ?? '-',
$pack->locale,
$pack->vendor,
$pack->version,
$pack->status,
];
}
$this->table(
['Identifier', 'Scope', 'Target', 'Locale', 'Vendor', 'Version', 'Status'],
$rows
);
return self::SUCCESS;
}
}
@@ -0,0 +1,77 @@
<?php
namespace App\Console\Commands\LanguagePack;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Services\LanguagePackService;
use Illuminate\Console\Command;
use Throwable;
/**
* 언어팩을 제거합니다.
*
* 모듈/플러그인/템플릿의 uninstall 커맨드와 동일한 운영 도구.
* 보호된 팩(`is_protected=true`)은 차단되며, `--force` 없이는 yes/no 확인을 요구합니다.
*/
class UninstallLanguagePackCommand extends Command
{
use HasUnifiedConfirm;
/**
* @var string
*/
protected $signature = 'language-pack:uninstall
{identifier : 언어팩 식별자}
{--cascade : 코어 팩 제거 시 동일 locale 의 module/plugin/template 팩도 inactive 로 강등}
{--force : 확인 없이 즉시 제거}';
/**
* @var string
*/
protected $description = '언어팩을 제거합니다.';
/**
* @param LanguagePackService $service 언어팩 Service
*/
public function __construct(
private readonly LanguagePackService $service,
) {
parent::__construct();
}
/**
* 커맨드를 실행합니다.
*
* @return int 종료 코드
*/
public function handle(): int
{
$identifier = (string) $this->argument('identifier');
$cascade = (bool) $this->option('cascade');
$force = (bool) $this->option('force');
$pack = $this->service->findByIdentifier($identifier);
if (! $pack) {
$this->error("언어팩을 찾을 수 없습니다: {$identifier}");
return self::FAILURE;
}
if (! $force && ! $this->unifiedConfirm("언어팩 '{$identifier}' 을(를) 제거하시겠습니까?", false)) {
$this->info('취소되었습니다.');
return self::SUCCESS;
}
try {
$this->service->uninstall($pack, $cascade);
$this->info("언어팩 제거 완료: {$identifier}");
return self::SUCCESS;
} catch (Throwable $e) {
$this->error('언어팩 제거 실패: '.$e->getMessage());
return self::FAILURE;
}
}
}
@@ -0,0 +1,87 @@
<?php
namespace App\Console\Commands\LanguagePack;
use App\Services\LanguagePackService;
use Illuminate\Console\Command;
use Throwable;
/**
* 언어팩을 업데이트합니다.
*
* 모듈/플러그인/템플릿의 update 커맨드와 동일한 운영 도구.
* 동시성 가드 + 백업 + 롤백은 Service 의 performUpdate 가 담당합니다.
*
* 옵션:
* --force 버전이 동일해도 강제 재적용 (확장 update --force 와 동일 의미)
* --source=auto|bundled|github 업데이트 소스 우선순위. auto(기본): force 시 bundled, 외 GitHub
*/
class UpdateLanguagePackCommand extends Command
{
/**
* @var string
*/
protected $signature = 'language-pack:update
{identifier : 언어팩 식별자}
{--force : 버전 변경이 없어도 강제 재적용 (_bundled 우선)}
{--source=auto : 업데이트 소스 (auto|bundled|github)}';
/**
* @var string
*/
protected $description = '언어팩을 GitHub 또는 _bundled 신버전으로 업데이트합니다.';
/**
* @param LanguagePackService $service 언어팩 Service
*/
public function __construct(
private readonly LanguagePackService $service,
) {
parent::__construct();
}
/**
* 커맨드를 실행합니다.
*
* @return int 종료 코드
*/
public function handle(): int
{
$identifier = (string) $this->argument('identifier');
$force = (bool) $this->option('force');
$source = (string) $this->option('source');
if (! in_array($source, ['auto', 'bundled', 'github'], true)) {
$this->error("알 수 없는 --source 값: {$source} (auto|bundled|github 중 선택)");
return self::FAILURE;
}
$pack = $this->service->findByIdentifier($identifier);
if (! $pack) {
$this->error("언어팩을 찾을 수 없습니다: {$identifier}");
return self::FAILURE;
}
try {
$fromVersion = $pack->version;
// source=bundled 는 force 와 동등 (bundled 1순위 강제). source=github 는 force 무시.
$effectiveForce = $force || $source === 'bundled';
$updated = $this->service->performUpdate($pack, $effectiveForce);
$this->info(sprintf(
'언어팩 업데이트 완료: %s (%s → %s)',
$updated->identifier,
$fromVersion,
$updated->version,
));
return self::SUCCESS;
} catch (Throwable $e) {
$this->error('언어팩 업데이트 실패: '.$e->getMessage());
return self::FAILURE;
}
}
}
@@ -2,6 +2,7 @@
namespace App\Console\Commands;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Models\SystemConfig;
use App\Repositories\JsonConfigRepository;
use Illuminate\Console\Command;
@@ -12,6 +13,8 @@ use Illuminate\Support\Facades\Schema;
*/
class MigrateSettingsToJsonCommand extends Command
{
use HasUnifiedConfirm;
/**
* 커맨드 시그니처
*
@@ -122,8 +125,6 @@ class MigrateSettingsToJsonCommand extends Command
* 커맨드를 실행합니다.
*
* DI 컨테이너 바인딩 시점 문제로 직접 인스턴스화합니다.
*
* @return int
*/
public function handle(): int
{
@@ -133,7 +134,7 @@ class MigrateSettingsToJsonCommand extends Command
if (! Schema::hasTable('system_configs')) {
$this->warn('system_configs 테이블이 존재하지 않습니다. 기본값으로 초기화합니다.');
$configRepository = new JsonConfigRepository();
$configRepository = new JsonConfigRepository;
$configRepository->initialize();
$this->info('기본 설정 파일이 생성되었습니다.');
@@ -142,11 +143,11 @@ class MigrateSettingsToJsonCommand extends Command
}
// JsonConfigRepository 직접 인스턴스화 (DI 컨테이너 바인딩 전 실행 가능)
$configRepository = new JsonConfigRepository();
$configRepository = new JsonConfigRepository;
// 기존 JSON 파일 존재 확인
if (file_exists(storage_path('app/settings/general.json')) && ! $this->option('force')) {
if (! $this->confirm('기존 JSON 설정 파일이 존재합니다. 덮어쓰시겠습니까?')) {
if (! $this->unifiedConfirm('기존 JSON 설정 파일이 존재합니다. 덮어쓰시겠습니까?', false)) {
$this->info('마이그레이션이 취소되었습니다.');
return Command::SUCCESS;
@@ -196,10 +197,6 @@ class MigrateSettingsToJsonCommand extends Command
/**
* 문자열 값을 원래 타입으로 파싱합니다.
*
* @param string $value
* @param string $type
* @return mixed
*/
private function parseValue(string $value, string $type): mixed
{
@@ -4,6 +4,7 @@ namespace App\Console\Commands\Module;
use App\Extension\ModuleManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Extension\Traits\GeneratesComponentManifest;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Process\Process;
@@ -11,6 +12,7 @@ use Symfony\Component\Process\Process;
class BuildModuleCommand extends Command
{
use ClearsTemplateCaches;
use GeneratesComponentManifest;
/**
* The name and signature of the console command.
@@ -196,6 +198,14 @@ class BuildModuleCommand extends Command
// 빌드 결과 파일 확인
$this->displayBuildResults($buildPath, $identifier);
// 편집기 컴포넌트 매니페스트(components.json) 생성
$manifestResult = $this->generateComponentManifest($buildPath, $identifier);
if ($manifestResult['written']) {
$this->line(" - components.json 생성됨 (컴포넌트 {$manifestResult['count']}개)");
} else {
$this->warn(' - components.json 생성 실패 (편집 모드 컨트롤 노출 제한)');
}
// 캐시 버전 증가 (브라우저 캐시 무효화)
$this->incrementExtensionCacheVersion();
$this->line(' - 캐시 버전 갱신됨');
@@ -13,7 +13,8 @@ class ListModuleCommand extends Command
* The name and signature of the console command.
*/
protected $signature = 'module:list
{--status= : 상태로 필터 (installed, uninstalled, active, inactive)}';
{--status= : 상태로 필터 (installed, uninstalled, active, inactive)}
{--hidden : 숨김(hidden=true) 모듈도 함께 출력}';
/**
* The console command description.
@@ -39,6 +40,7 @@ class ListModuleCommand extends Command
$this->moduleManager->loadModules();
$statusFilter = $this->option('status');
$includeHidden = (bool) $this->option('hidden');
// 상태 필터 검증
if ($statusFilter && ! in_array($statusFilter, ['installed', 'uninstalled', 'active', 'inactive'])) {
@@ -58,6 +60,11 @@ class ListModuleCommand extends Command
// 설치된 모듈 추가
foreach ($installedModules as $identifier => $module) {
// 숨김 필터: --hidden 미지정 시 hidden=true 모듈 제외
if (! $includeHidden && ! empty($module['hidden'])) {
continue;
}
// 상태 필터
if ($statusFilter) {
if ($statusFilter === 'uninstalled') {
@@ -86,6 +93,11 @@ class ListModuleCommand extends Command
// 미설치 모듈 추가
if (! $statusFilter || $statusFilter === 'uninstalled') {
foreach ($uninstalledModules as $identifier => $module) {
// 숨김 필터: --hidden 미지정 시 hidden=true 모듈 제외
if (! $includeHidden && ! empty($module['hidden'])) {
continue;
}
// 상태 필터 (uninstalled 또는 필터 없음)
if ($statusFilter && $statusFilter !== 'uninstalled') {
continue;
@@ -3,6 +3,7 @@
namespace App\Console\Commands\Module;
use App\Console\Commands\Traits\HasProgressBar;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Enums\ExtensionOwnerType;
use App\Extension\ModuleManager;
@@ -14,6 +15,7 @@ use Illuminate\Support\Facades\Log;
class UninstallModuleCommand extends Command
{
use HasProgressBar;
use HasUnifiedConfirm;
/**
* The name and signature of the console command.
@@ -82,7 +84,7 @@ class UninstallModuleCommand extends Command
}
$this->newLine();
if (! $this->confirm(__('modules.commands.uninstall.confirm_question'), false)) {
if (! $this->unifiedConfirm(__('modules.commands.uninstall.confirm_question'), false)) {
$this->info(__('modules.commands.uninstall.aborted'));
return Command::SUCCESS;
@@ -3,15 +3,19 @@
namespace App\Console\Commands\Module;
use App\Console\Commands\Traits\HasProgressBar;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Contracts\Repositories\ModuleRepositoryInterface;
use App\Enums\LayoutSourceType;
use App\Extension\ModuleManager;
use App\Extension\Vendor\VendorMode;
use App\Services\LayoutExtensionService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class UpdateModuleCommand extends Command
{
use HasProgressBar;
use HasUnifiedConfirm;
/**
* The name and signature of the console command.
@@ -34,7 +38,8 @@ class UpdateModuleCommand extends Command
*/
public function __construct(
private ModuleManager $moduleManager,
private ModuleRepositoryInterface $moduleRepository
private ModuleRepositoryInterface $moduleRepository,
private LayoutExtensionService $layoutExtensionService
) {
parent::__construct();
}
@@ -118,10 +123,34 @@ class UpdateModuleCommand extends Command
$this->info('업데이트 버전: (module.json 추출 후 판별)');
}
$this->info(__('modules.commands.update.layout_strategy', ['strategy' => $layoutStrategy]));
// overwrite 전략일 때 관리자가 편집한 레이아웃 확장 경고
if ($layoutStrategy === 'overwrite') {
$modifiedExtensions = $this->layoutExtensionService->getModifiedExtensionsBySource(
LayoutSourceType::Module,
$identifier
);
if (! empty($modifiedExtensions)) {
$this->newLine();
$this->warn('⚠️ '.__('modules.commands.update.modified_extensions_warning', [
'count' => count($modifiedExtensions),
]));
foreach ($modifiedExtensions as $extension) {
$this->warn(__('modules.commands.update.modified_extension_item', [
'target' => $extension['target_name'],
'source' => $extension['source_identifier'],
]));
}
}
}
$this->newLine();
// 확인 프롬프트 (--force 시 건너뜀)
if (! $force && ! $this->confirm(__('modules.commands.update.confirm_question'), false)) {
if (! $force && ! $this->unifiedConfirm(__('modules.commands.update.confirm_question'), false)) {
$this->info(__('modules.commands.update.aborted'));
return Command::SUCCESS;
@@ -0,0 +1,130 @@
<?php
namespace App\Console\Commands;
use App\Enums\ExtensionOwnerType;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Config;
/**
* Playwright E2E 용 Sanctum 토큰 발급 커맨드.
*
* 임의 권한 식별자(코어/모듈/플러그인) 를 받아 권한 보유 관리자 유저를 즉시 생성하고
* Sanctum 개인 액세스 토큰을 발급한다. 발급된 토큰은 stdout 마지막 줄에 출력되어
* Playwright fixture 가 stdin 캡처로 사용한다.
*
* 보안 가드 (3중):
* ① CLI 한정 — `php_sapi_name() === 'cli'` 확인. production 웹 요청에서 절대 도달 불가
* ② 명시 옵트인 — `G7_PLAYWRIGHT_BYPASS=1` 환경변수 부여 필수.
* `.env` 영구 수정 없이 인라인 환경변수로만 활성화 가능 → 무심코 production 으로 새지 않음
* ③ APP_DEBUG 강제 — bypass flag 인지 후 `config('app.debug')` 를 true 로 inline override.
* SettingsServiceProvider 의 testing/bypass 분기가 이미 settings JSON 덮어쓰기를 건너뛰므로
* production + debug=false 환경에서도 토큰 발급이 가능하다
*
* 환경 매트릭스:
* - local + bypass=1 : 로컬 개발자 PC 에서 직접 spec 작성/실행
* - testing + bypass=1 : CI / PHPUnit 환경에서 .env.testing 로 동작하는 E2E 통합 (testing 환경은 이미 testing 가드로 통과)
* - production + bypass=1 : production DB 가 활성 호스트를 가리키는 환경(예: g7.dev) 에서 E2E
*
* 호출 예시 (PowerShell):
* $env:G7_PLAYWRIGHT_BYPASS='1'; php artisan playwright:issue-token --permissions=core.templates.layouts.edit
*
* 로직 출처: tests/Feature/Api/Admin/LayoutAccessCheckEndpointTest::makeAdminUser (44~87행)
*/
class PlaywrightIssueToken extends Command
{
protected $signature = 'playwright:issue-token
{--permissions=* : 부여할 권한 식별자 (예: core.templates.layouts.edit). 다중 지정 가능}';
protected $description = 'Playwright E2E 용 Sanctum 토큰 발급 (CLI + G7_PLAYWRIGHT_BYPASS 3중 가드)';
public function handle(): int
{
// ① CLI 한정 — production 웹 요청에서 절대 도달 불가
if (php_sapi_name() !== 'cli') {
$this->error('CLI 전용 커맨드입니다. (현재 SAPI: '.php_sapi_name().')');
return self::FAILURE;
}
// ② 명시 옵트인 — 환경변수 없이는 production 호출 실수 차단
if (env('G7_PLAYWRIGHT_BYPASS') !== '1') {
$this->error('G7_PLAYWRIGHT_BYPASS=1 환경변수가 필요합니다. (예: PowerShell — $env:G7_PLAYWRIGHT_BYPASS=\'1\')');
return self::FAILURE;
}
// ③ APP_DEBUG 강제 — production + debug=false 환경에서도 sanctum 토큰 발급 + 디버그 정보 누락 방지.
// SettingsServiceProvider::applyDebugConfig 는 bypass flag 가 있으면 settings JSON 덮어쓰기를 이미 건너뛴 상태.
Config::set('app.debug', true);
$permissions = $this->option('permissions') ?: [];
$user = $this->makeAdminUser($permissions);
$token = $user->createToken('playwright-'.uniqid())->plainTextToken;
$this->line($token);
return self::SUCCESS;
}
/**
* 권한 식별자 배열로 관리자 유저를 생성하고 권한을 부여한다.
*
* 절차:
* 1. User factory 로 신규 유저 생성
* 2. 권한 식별자별로 Permission 행 보장 (firstOrCreate)
* 3. uniqid 접미사로 격리된 test role 생성 + 권한 sync
* 4. admin role 보장 (firstOrCreate) + 유저-역할 부여
*/
private function makeAdminUser(array $permissions): User
{
$user = User::factory()->create();
$permissionIds = [];
foreach ($permissions as $identifier) {
$permission = Permission::firstOrCreate(
['identifier' => $identifier],
[
'name' => json_encode(['ko' => $identifier, 'en' => $identifier]),
'description' => json_encode(['ko' => $identifier, 'en' => $identifier]),
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => 'admin',
]
);
$permissionIds[] = $permission->id;
}
$testRole = Role::create([
'identifier' => 'playwright_test_'.uniqid(),
'name' => json_encode(['ko' => 'Playwright 테스트 관리자', 'en' => 'Playwright Test Admin']),
'description' => json_encode(['ko' => 'E2E 자동화 전용', 'en' => 'E2E automation only']),
'is_active' => true,
]);
$adminRole = Role::firstOrCreate(
['identifier' => 'admin'],
[
'name' => json_encode(['ko' => '관리자', 'en' => 'Admin']),
'description' => json_encode(['ko' => '시스템 관리자', 'en' => 'System Admin']),
'extension_type' => ExtensionOwnerType::Core,
'extension_identifier' => 'core',
'type' => 'admin',
'is_active' => true,
]
);
if (! empty($permissionIds)) {
$testRole->permissions()->sync($permissionIds);
}
$user->roles()->attach($adminRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
$user->roles()->attach($testRole->id, ['assigned_at' => now(), 'assigned_by' => null]);
return $user->fresh();
}
}
@@ -4,6 +4,7 @@ namespace App\Console\Commands\Plugin;
use App\Extension\PluginManager;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Extension\Traits\GeneratesComponentManifest;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Symfony\Component\Process\Process;
@@ -11,6 +12,7 @@ use Symfony\Component\Process\Process;
class BuildPluginCommand extends Command
{
use ClearsTemplateCaches;
use GeneratesComponentManifest;
/**
* The name and signature of the console command.
@@ -196,6 +198,14 @@ class BuildPluginCommand extends Command
// 빌드 결과 파일 확인
$this->displayBuildResults($buildPath, $identifier);
// 편집기 컴포넌트 매니페스트(components.json) 생성
$manifestResult = $this->generateComponentManifest($buildPath, $identifier);
if ($manifestResult['written']) {
$this->line(" - components.json 생성됨 (컴포넌트 {$manifestResult['count']}개)");
} else {
$this->warn(' - components.json 생성 실패 (편집 모드 컨트롤 노출 제한)');
}
// 캐시 버전 증가 (브라우저 캐시 무효화)
$this->incrementExtensionCacheVersion();
$this->line(' - 캐시 버전 갱신됨');
@@ -13,7 +13,8 @@ class ListPluginCommand extends Command
* The name and signature of the console command.
*/
protected $signature = 'plugin:list
{--status= : 상태로 필터 (installed, uninstalled, active, inactive)}';
{--status= : 상태로 필터 (installed, uninstalled, active, inactive)}
{--hidden : 숨김(hidden=true) 플러그인도 함께 출력}';
/**
* The console command description.
@@ -39,6 +40,7 @@ class ListPluginCommand extends Command
$this->pluginManager->loadPlugins();
$statusFilter = $this->option('status');
$includeHidden = (bool) $this->option('hidden');
// 상태 필터 검증
if ($statusFilter && ! in_array($statusFilter, ['installed', 'uninstalled', 'active', 'inactive'])) {
@@ -58,6 +60,11 @@ class ListPluginCommand extends Command
// 설치된 플러그인 추가
foreach ($installedPlugins as $identifier => $plugin) {
// 숨김 필터: --hidden 미지정 시 hidden=true 플러그인 제외
if (! $includeHidden && ! empty($plugin['hidden'])) {
continue;
}
// 상태 필터
if ($statusFilter) {
if ($statusFilter === 'uninstalled') {
@@ -86,6 +93,11 @@ class ListPluginCommand extends Command
// 미설치 플러그인 추가
if (! $statusFilter || $statusFilter === 'uninstalled') {
foreach ($uninstalledPlugins as $identifier => $plugin) {
// 숨김 필터: --hidden 미지정 시 hidden=true 플러그인 제외
if (! $includeHidden && ! empty($plugin['hidden'])) {
continue;
}
// 상태 필터 (uninstalled 또는 필터 없음)
if ($statusFilter && $statusFilter !== 'uninstalled') {
continue;
@@ -3,6 +3,7 @@
namespace App\Console\Commands\Plugin;
use App\Console\Commands\Traits\HasProgressBar;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Enums\ExtensionOwnerType;
use App\Extension\PluginManager;
@@ -13,6 +14,7 @@ use Illuminate\Support\Facades\Log;
class UninstallPluginCommand extends Command
{
use HasProgressBar;
use HasUnifiedConfirm;
/**
* The name and signature of the console command.
@@ -79,7 +81,7 @@ class UninstallPluginCommand extends Command
}
$this->newLine();
if (! $this->confirm(__('plugins.commands.uninstall.confirm_question'), false)) {
if (! $this->unifiedConfirm(__('plugins.commands.uninstall.confirm_question'), false)) {
$this->info(__('plugins.commands.uninstall.aborted'));
return Command::SUCCESS;
@@ -3,15 +3,19 @@
namespace App\Console\Commands\Plugin;
use App\Console\Commands\Traits\HasProgressBar;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Contracts\Repositories\PluginRepositoryInterface;
use App\Enums\LayoutSourceType;
use App\Extension\PluginManager;
use App\Extension\Vendor\VendorMode;
use App\Services\LayoutExtensionService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class UpdatePluginCommand extends Command
{
use HasProgressBar;
use HasUnifiedConfirm;
/**
* The name and signature of the console command.
@@ -34,7 +38,8 @@ class UpdatePluginCommand extends Command
*/
public function __construct(
private PluginManager $pluginManager,
private PluginRepositoryInterface $pluginRepository
private PluginRepositoryInterface $pluginRepository,
private LayoutExtensionService $layoutExtensionService
) {
parent::__construct();
}
@@ -117,10 +122,34 @@ class UpdatePluginCommand extends Command
$this->info('업데이트 버전: (plugin.json 추출 후 판별)');
}
$this->info(__('plugins.commands.update.layout_strategy', ['strategy' => $layoutStrategy]));
// overwrite 전략일 때 관리자가 편집한 레이아웃 확장 경고
if ($layoutStrategy === 'overwrite') {
$modifiedExtensions = $this->layoutExtensionService->getModifiedExtensionsBySource(
LayoutSourceType::Plugin,
$identifier
);
if (! empty($modifiedExtensions)) {
$this->newLine();
$this->warn('⚠️ '.__('plugins.commands.update.modified_extensions_warning', [
'count' => count($modifiedExtensions),
]));
foreach ($modifiedExtensions as $extension) {
$this->warn(__('plugins.commands.update.modified_extension_item', [
'target' => $extension['target_name'],
'source' => $extension['source_identifier'],
]));
}
}
}
$this->newLine();
// 확인 프롬프트 (--force 시 건너뜀)
if (! $force && ! $this->confirm(__('plugins.commands.update.confirm_question'), false)) {
if (! $force && ! $this->unifiedConfirm(__('plugins.commands.update.confirm_question'), false)) {
$this->info(__('plugins.commands.update.aborted'));
return Command::SUCCESS;
@@ -0,0 +1,43 @@
<?php
namespace App\Console\Commands;
use App\Contracts\Repositories\LayoutVersionRepositoryInterface;
use Illuminate\Console\Command;
/**
* 레이아웃 버전의 변경 요약(changes_summary)을 현재 알고리즘으로 재계산하는 커맨드
*
* 버전 변경량 측정이 키 경로 단위에서 라인 단위(버전 비교 diff 뷰와 동일 SSoT)로
* 바뀌기 전에 저장된 버전들은 옛 기준의 changes_summary 를 담고 있어, 버전 목록의
* 변경량이 상세 diff 와 불일치한다. 본 커맨드로 기존 버전을 일괄 재계산해 정합시킨다.
*/
class RecalculateLayoutVersionDiffsCommand extends Command
{
/**
* The name and signature of the console command.
*/
protected $signature = 'layout-versions:recalculate-diffs';
/**
* The console command description.
*/
protected $description = '레이아웃 버전의 변경 요약을 현재 라인 단위 알고리즘으로 재계산합니다';
/**
* Execute the console command.
*
* @param LayoutVersionRepositoryInterface $repository 버전 저장소
* @return int 명령 실행 결과 코드
*/
public function handle(LayoutVersionRepositoryInterface $repository): int
{
$this->info('레이아웃 버전 변경 요약 재계산을 시작합니다...');
$updated = $repository->recalculateAllChangeSummaries();
$this->info("버전 {$updated}건의 변경 요약을 재계산했습니다.");
return self::SUCCESS;
}
}
@@ -4,18 +4,20 @@ namespace App\Console\Commands;
use App\Jobs\GenerateSitemapJob;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Config;
/**
* Sitemap XML 생성 Artisan 커맨드
*
* 큐를 통한 비동기 실행 또는 --sync 옵션으로 동기 실행을 지원합니다.
* 큐 드라이버 설정에 따라 비동기/동기 실행을 자동 선택합니다.
* --sync 옵션을 명시하면 큐 드라이버와 무관하게 동기 실행합니다.
*/
class SeoGenerateSitemapCommand extends Command
{
/**
* @var string 커맨드 시그니처
*/
protected $signature = 'seo:generate-sitemap {--sync : 동기 실행}';
protected $signature = 'seo:generate-sitemap {--sync : 큐 드라이버를 무시하고 동기 실행}';
/**
* @var string 커맨드 설명
@@ -29,7 +31,13 @@ class SeoGenerateSitemapCommand extends Command
*/
public function handle(): int
{
if ($this->option('sync')) {
$forceSync = (bool) $this->option('sync');
// queue.default 는 SettingsServiceProvider 가 drivers.queue_driver 와 동기화하되,
// testing 환경에서는 phpunit.xml 값을 보존하므로 격리가 유지된다.
$connection = (string) Config::get('queue.default', 'sync');
$isSyncDriver = $connection === 'sync';
if ($forceSync || $isSyncDriver) {
GenerateSitemapJob::dispatchSync();
$this->info('Sitemap이 생성되었습니다.');
} else {
@@ -13,7 +13,8 @@ class ListTemplateCommand extends Command
*/
protected $signature = 'template:list
{--type= : 템플릿 타입으로 필터 (admin, user)}
{--status= : 상태로 필터 (installed, uninstalled, active, inactive)}';
{--status= : 상태로 필터 (installed, uninstalled, active, inactive)}
{--hidden : 숨김(hidden=true) 템플릿도 함께 출력}';
/**
* The console command description.
@@ -40,6 +41,7 @@ class ListTemplateCommand extends Command
$typeFilter = $this->option('type');
$statusFilter = $this->option('status');
$includeHidden = (bool) $this->option('hidden');
// 타입 필터 검증
if ($typeFilter && ! in_array($typeFilter, ['admin', 'user'])) {
@@ -66,6 +68,11 @@ class ListTemplateCommand extends Command
// 설치된 템플릿 추가
foreach ($installedTemplates as $identifier => $template) {
// 숨김 필터: --hidden 미지정 시 hidden=true 템플릿 제외
if (! $includeHidden && ! empty($template['hidden'])) {
continue;
}
// 타입 필터
if ($typeFilter && $template['type'] !== $typeFilter) {
continue;
@@ -99,6 +106,11 @@ class ListTemplateCommand extends Command
// 미설치 템플릿 추가
if (! $statusFilter || $statusFilter === 'uninstalled') {
foreach ($uninstalledTemplates as $identifier => $template) {
// 숨김 필터: --hidden 미지정 시 hidden=true 템플릿 제외
if (! $includeHidden && ! empty($template['hidden'])) {
continue;
}
// 타입 필터
if ($typeFilter && $template['type'] !== $typeFilter) {
continue;
@@ -3,6 +3,7 @@
namespace App\Console\Commands\Template;
use App\Console\Commands\Traits\HasProgressBar;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Contracts\Repositories\TemplateRepositoryInterface;
use App\Extension\TemplateManager;
use Illuminate\Console\Command;
@@ -11,6 +12,7 @@ use Illuminate\Support\Facades\Log;
class UninstallTemplateCommand extends Command
{
use HasProgressBar;
use HasUnifiedConfirm;
/**
* The name and signature of the console command.
@@ -57,7 +59,7 @@ class UninstallTemplateCommand extends Command
$this->warn(__('templates.commands.uninstall.confirm_details.layouts', ['count' => $template->layouts()->count()]));
$this->warn(__('templates.commands.uninstall.confirm_details.versions'));
if (! $this->confirm(__('templates.commands.uninstall.confirm_question'), false)) {
if (! $this->unifiedConfirm(__('templates.commands.uninstall.confirm_question'), false)) {
$this->info(__('templates.commands.uninstall.aborted'));
return Command::SUCCESS;
@@ -3,6 +3,7 @@
namespace App\Console\Commands\Template;
use App\Console\Commands\Traits\HasProgressBar;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Contracts\Repositories\TemplateRepositoryInterface;
use App\Extension\TemplateManager;
use Illuminate\Console\Command;
@@ -11,6 +12,7 @@ use Illuminate\Support\Facades\Log;
class UpdateTemplateCommand extends Command
{
use HasProgressBar;
use HasUnifiedConfirm;
/**
* The name and signature of the console command.
@@ -140,7 +142,7 @@ class UpdateTemplateCommand extends Command
$this->newLine();
// 확인 프롬프트 (--force 시 건너뜀)
if (! $force && ! $this->confirm(__('templates.commands.update.confirm_question'), false)) {
if (! $force && ! $this->unifiedConfirm(__('templates.commands.update.confirm_question'), false)) {
$this->info(__('templates.commands.update.aborted'));
return Command::SUCCESS;
@@ -0,0 +1,50 @@
<?php
namespace App\Console\Commands\Traits;
use App\Console\Helpers\ConsoleConfirm;
/**
* Laravel Command 컨텍스트에서 표준화된 yes/no 프롬프트를 제공하는 트레이트.
*
* Symfony QuestionHelper 를 거쳐 입력을 받기 때문에 Laravel 테스트의
* expectsQuestion() / expectsConfirmation() 헬퍼와 호환된다. 입력 정규화 및
* 재질문 루프는 ConsoleConfirm::parse() 와 공유한다.
*
* - `--no-interaction` 시: $default 즉시 반환
* - empty 입력 시: $default 반환
* - yes/y → true, no/n → false (대소문자 무시)
* - 그 외 입력: "yes, y, no, n 중 하나로 입력해 주세요." 출력 후 재질문
*/
trait HasUnifiedConfirm
{
/**
* 표준 yes/no 프롬프트.
*
* @param string $question 질문 메시지
* @param bool $default 기본값 (true=[yes], false=[no])
*/
protected function unifiedConfirm(string $question, bool $default = false): bool
{
if (! $this->input->isInteractive()) {
return $default;
}
$hint = $default ? '[yes]' : '[no]';
$prompt = "{$question} (yes/no) {$hint}";
while (true) {
// Symfony QuestionHelper 의 default 표시(`[default]`)를 회피하기 위해 default 를
// null 로 넘긴다. empty 입력 시 Symfony 가 null 반환 → ConsoleConfirm::parse 가
// empty 로 처리하여 자체 default 적용.
$raw = (string) $this->ask($prompt);
$parsed = ConsoleConfirm::parse($raw, $default);
if ($parsed !== null) {
return $parsed;
}
$this->warn(' yes, y, no, n 중 하나로 입력해 주세요.');
}
}
}
+106
View File
@@ -0,0 +1,106 @@
<?php
namespace App\Console\Helpers;
/**
* 콘솔 yes/no 프롬프트 표준 헬퍼.
*
* Symfony Console 비의존(fgets 기반) — Laravel Command 외부, upgrade step,
* 단순 PHP 스크립트 어디서나 호출 가능하다. 입력 처리 규칙:
*
* 1. 입력을 trim 후 소문자 정규화한다
* 2. empty 입력은 default 값으로 처리한다
* 3. yes / y → true, no / n → false
* 4. 위 외 입력은 안내 메시지 출력 후 다시 질문한다 (재질문 루프)
* 5. STDIN 미연결(非TTY) 또는 EOF 시 default 를 즉시 반환한다 (CI/spawn 안전망)
*/
final class ConsoleConfirm
{
/**
* 표준 yes/no 프롬프트.
*
* @param string $question 질문 메시지 (말미 안내/콜론은 자동 부여)
* @param bool $default 기본값 (true=[yes], false=[no])
* @param resource|null $stdin 테스트용 STDIN 주입. null 이면 STDIN 사용
* @param callable|null $writer 테스트용 출력 콜백. null 이면 echo 사용
*/
public static function ask(
string $question,
bool $default = false,
$stdin = null,
?callable $writer = null,
): bool {
$writer ??= static fn (string $text) => print $text;
$useStdin = $stdin !== null;
if (! $useStdin && ! self::isTty()) {
return $default;
}
$hint = $default ? '[yes]' : '[no]';
$stream = $useStdin ? $stdin : (defined('STDIN') ? STDIN : null);
if ($stream === null) {
return $default;
}
while (true) {
$writer("{$question} (yes/no) {$hint}: ");
$raw = fgets($stream);
if ($raw === false) {
return $default;
}
$parsed = self::parse($raw, $default);
if ($parsed !== null) {
return $parsed;
}
$writer(" yes, y, no, n 중 하나로 입력해 주세요.\n");
}
}
/**
* 입력 문자열을 yes/no/null 로 정규화한다.
*
* @param string $raw 사용자 입력 원문 (개행 포함 가능)
* @param bool $default empty 입력 시 반환할 값
* @return bool|null true=yes, false=no, null=재질문 필요
*/
public static function parse(string $raw, bool $default): ?bool
{
$answer = strtolower(trim($raw));
if ($answer === '') {
return $default;
}
if ($answer === 'yes' || $answer === 'y') {
return true;
}
if ($answer === 'no' || $answer === 'n') {
return false;
}
return null;
}
/**
* STDIN 이 TTY 인지 검사한다.
*/
private static function isTty(): bool
{
if (! defined('STDIN')) {
return false;
}
if (function_exists('stream_isatty')) {
return @stream_isatty(STDIN);
}
if (function_exists('posix_isatty')) {
return @posix_isatty(STDIN);
}
return false;
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Contracts\Extension;
/**
* 캐시/스토리지 도메인 분리를 제공하는 확장의 좁은 contract.
*
* AbstractModule / AbstractPlugin 이 공통으로 노출하는 캐시·스토리지
* 접근자를 한 인터페이스로 묶어, AbstractExtensionServiceProvider 가
* 모듈·플러그인 양쪽을 균등하게 다룰 수 있도록 합니다.
*/
interface CacheableExtensionInterface
{
/**
* 확장 식별자를 반환합니다 (vendor-extension 형식).
*
* @return string 확장 식별자
*/
public function getIdentifier(): string;
/**
* 확장 도메인에 격리된 스토리지 드라이버를 반환합니다.
*
* @return StorageInterface 확장 전용 스토리지 인스턴스
*/
public function getStorage(): StorageInterface;
/**
* 확장 도메인에 격리된 캐시 드라이버를 반환합니다.
*
* @return CacheInterface 확장 전용 캐시 인스턴스
*/
public function getCache(): CacheInterface;
}
@@ -0,0 +1,106 @@
<?php
namespace App\Contracts\Extension;
use App\Extension\IdentityVerification\DTO\VerificationChallenge;
use App\Extension\IdentityVerification\DTO\VerificationResult;
use App\Models\User;
/**
* 본인인증 프로바이더 인터페이스 (IdentityVerification / IDV)
*
* 이 계약을 구현하면 메일, KCP, 이니시스, SMS 등 어떤 본인인증 경로도
* 동일한 방식으로 코어 Manager 에 붙일 수 있습니다. IDV 가 적용되는 목적은
* purpose (signup / password_reset / self_update / sensitive_action / 플러그인 등록값) 로 구분합니다.
*/
interface IdentityVerificationInterface
{
/**
* 프로바이더 식별자.
*
* 코어 기본 프로바이더는 `g7:core.*` 접두사를 사용합니다 (예: `g7:core.mail`).
* 플러그인 프로바이더는 자신의 벤더-slug 식별자를 사용합니다 (예: `kcp`, `inicis`).
*/
public function getId(): string;
/**
* 관리자 UI 에 표시되는 라벨.
*/
public function getLabel(): string;
/**
* 지원 채널 목록 (예: ['email', 'sms', 'ipin']).
*
* @return array<int, string>
*/
public function getChannels(): array;
/**
* 채널 키 → 다국어 표시 라벨 맵.
*
* 각 프로바이더는 자신이 지원하는 모든 채널의 표시 라벨을 제공해야 합니다.
* 관리자 이력 화면 등에서 채널 식별자(`email`, `ipin`)를 사람이 읽는
* 이름(`이메일`, `아이핀`)으로 표시하는 데 사용됩니다.
* 라벨은 `getLabel()` 과 동일하게 `__()` 로 다국어 처리하여 언어팩 활성화 시
* 번역이 적용되도록 합니다.
*
* @return array<string, string> 채널 키 → 라벨 맵 (예: ['email' => '이메일'])
*/
public function getChannelLabels(): array;
/**
* 프론트가 challenge 를 렌더하는 방법 힌트.
*
* - text_code : 숫자 코드 입력 UI
* - link : 메일/SMS 링크 클릭 유도
* - external_redirect: 외부 인증 페이지 이동
*/
public function getRenderHint(): string;
/**
* 주어진 목적을 지원하는지 여부.
*
* purpose 는 최소 signup / password_reset / self_update / sensitive_action 을
* 포함하며, 플러그인은 `core.identity.purposes` 필터 훅으로 커스텀 purpose 를 추가할 수 있습니다.
*/
public function supportsPurpose(string $purpose): bool;
/**
* 런타임 설정(API 키/시크릿 등) 기준으로 실제 사용 가능한지 여부.
*/
public function isAvailable(): bool;
/**
* Challenge 를 발행합니다.
*
* @param User|array $target 대상 사용자(로그인 상태) 또는 이메일·전화 배열(가입 전)
* @param array $context origin_type / origin_identifier / origin_policy_key / purpose / ip / user_agent 등
*/
public function requestChallenge(User|array $target, array $context = []): VerificationChallenge;
/**
* Challenge 를 검증합니다.
*
* @param string $challengeId requestChallenge 가 반환한 id
* @param array $input 프로바이더별 입력 (코드, 토큰, 외부 CB 페이로드 등)
* @param array $context origin 정보, 요청 ip/ua 등
*/
public function verify(string $challengeId, array $input, array $context = []): VerificationResult;
/**
* Challenge 를 취소합니다.
*/
public function cancel(string $challengeId): bool;
/**
* 관리자 환경설정 UI 가 반복 렌더하기 위한 설정 스키마.
*
* @return array<string, array{label: string, type: string, default?: mixed, options?: array<mixed>, help?: string}>
*/
public function getSettingsSchema(): array;
/**
* 설정값을 주입한 새 인스턴스를 반환합니다. (withStore/withDisk 불변 복제 패턴 준용)
*/
public function withConfig(array $config): static;
}
+11 -1
View File
@@ -2,7 +2,7 @@
namespace App\Contracts\Extension;
interface ModuleInterface
interface ModuleInterface extends CacheableExtensionInterface
{
/**
* 모듈명 반환 (표시용)
@@ -204,6 +204,16 @@ interface ModuleInterface
*/
public function getLicense(): ?string;
/**
* 관리자 UI 에서 숨김 여부를 반환합니다.
*
* true 반환 시 관리자 모듈 목록 응답에서 기본 제외됩니다.
* CLI 명령, 설치/제거, 업데이트 감지는 영향 받지 않습니다.
*
* @return bool 숨김 여부
*/
public function isHidden(): bool;
/**
* 모듈의 메타데이터를 반환합니다.
*
@@ -2,6 +2,8 @@
namespace App\Contracts\Extension;
use App\Enums\DeactivationReason;
interface ModuleManagerInterface
{
/**
@@ -40,9 +42,16 @@ interface ModuleManagerInterface
*
* @param string $moduleName 비활성화할 모듈명
* @param bool $force 의존 템플릿이 있어도 강제 비활성화 여부
* @param string $reason 비활성화 사유 (DeactivationReason enum value: manual|incompatible_core)
* @param string|null $incompatibleRequiredVersion incompatible_core 사유 시 요구된 코어 버전 제약
* @return array{success: bool, layouts_deleted: int, warning?: bool, dependent_templates?: array, message?: string} 비활성화 결과 및 삭제된 레이아웃 개수
*/
public function deactivateModule(string $moduleName, bool $force = false): array;
public function deactivateModule(
string $moduleName,
bool $force = false,
string $reason = DeactivationReason::Manual->value,
?string $incompatibleRequiredVersion = null,
): array;
/**
* 지정된 모듈을 시스템에서 제거합니다.
+11 -1
View File
@@ -2,7 +2,7 @@
namespace App\Contracts\Extension;
interface PluginInterface
interface PluginInterface extends CacheableExtensionInterface
{
/**
* 플러그인의 고유 식별자를 반환합니다 (vendor-plugin 형식).
@@ -47,6 +47,16 @@ interface PluginInterface
*/
public function getLicense(): ?string;
/**
* 관리자 UI 에서 숨김 여부를 반환합니다.
*
* true 반환 시 관리자 플러그인 목록 응답에서 기본 제외됩니다.
* CLI 명령, 설치/제거, 업데이트 감지는 영향 받지 않습니다.
*
* @return bool 숨김 여부
*/
public function isHidden(): bool;
/**
* 플러그인의 추가 메타데이터를 반환합니다.
*
@@ -2,6 +2,8 @@
namespace App\Contracts\Extension;
use App\Enums\DeactivationReason;
interface PluginManagerInterface
{
/**
@@ -40,9 +42,16 @@ interface PluginManagerInterface
*
* @param string $pluginName 비활성화할 플러그인명
* @param bool $force 의존 템플릿이 있어도 강제 비활성화 여부
* @param string $reason 비활성화 사유 (DeactivationReason enum value: manual|incompatible_core)
* @param string|null $incompatibleRequiredVersion incompatible_core 사유 시 요구된 코어 버전 제약
* @return array{success: bool, layouts_deleted: int, warning?: bool, dependent_templates?: array, message?: string} 비활성화 결과
*/
public function deactivatePlugin(string $pluginName, bool $force = false): array;
public function deactivatePlugin(
string $pluginName,
bool $force = false,
string $reason = DeactivationReason::Manual->value,
?string $incompatibleRequiredVersion = null,
): array;
/**
* 지정된 플러그인을 시스템에서 제거합니다.
@@ -2,6 +2,8 @@
namespace App\Contracts\Extension;
use App\Enums\DeactivationReason;
interface TemplateManagerInterface
{
/**
@@ -74,9 +76,15 @@ interface TemplateManagerInterface
* 지정된 템플릿을 비활성화합니다.
*
* @param string $identifier 비활성화할 템플릿 식별자
* @param string $reason 비활성화 사유 (DeactivationReason enum value: manual|incompatible_core)
* @param string|null $incompatibleRequiredVersion incompatible_core 사유 시 요구된 코어 버전 제약
* @return bool 비활성화 성공 여부
*/
public function deactivateTemplate(string $identifier): bool;
public function deactivateTemplate(
string $identifier,
string $reason = DeactivationReason::Manual->value,
?string $incompatibleRequiredVersion = null,
): bool;
/**
* 템플릿의 의존성을 검증합니다.
@@ -0,0 +1,23 @@
<?php
namespace App\Contracts\Notifications;
/**
* 비회원(게스트) 알림 수신자 계약
*
* user_id 없이 이메일/이름/로케일만 가진 익명 수신자를 1급 수신자로 표현합니다.
* 알림 채널 게이트(GenericNotification::via)는 구체 타입(User 등) 검사 대신
* 이 계약으로 게스트 여부를 판별하여, 채널별 게스트 발송 허용 정책을 적용합니다.
*
* 구현체는 Laravel Notifiable 트레잇을 함께 사용해 회원과 동일한
* `$notifiable->notify()` 발송 경로를 공유합니다.
*/
interface GuestRecipientInterface
{
/**
* 게스트(비회원) 수신자 여부를 반환합니다.
*
* @return bool true = 비회원 수신자
*/
public function isGuest(): bool;
}
@@ -52,4 +52,14 @@ interface ActivityLogRepositoryInterface
* @return Collection 활동 로그 컬렉션
*/
public function getRecent(string $permission, int $limit = 5): Collection;
/**
* 사용자 삭제 시 해당 사용자의 모든 activity_logs.user_id 컬럼을 NULL 로 익명화합니다.
*
* 외래키가 제거된 파티셔닝 호환 스키마에서 직접 익명화 책임을 갖습니다.
*
* @param int $userId 익명화 대상 사용자 ID
* @return int 익명화된 row 수
*/
public function anonymizeUserId(int $userId): int;
}
@@ -0,0 +1,97 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\IdentityMessageDefinition;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
interface IdentityMessageDefinitionRepositoryInterface
{
/**
* ID로 메시지 정의 조회.
*
* @param int $id
* @return IdentityMessageDefinition|null
*/
public function findById(int $id): ?IdentityMessageDefinition;
/**
* (provider, scope_type, scope_value) 조합으로 메시지 정의 조회.
*
* @param string $providerId
* @param string $scopeType
* @param string|null $scopeValue
* @return IdentityMessageDefinition|null
*/
public function findByScope(string $providerId, string $scopeType, ?string $scopeValue = null): ?IdentityMessageDefinition;
/**
* 활성 상태인 (provider, scope_type, scope_value) 메시지 정의 조회.
*
* @param string $providerId
* @param string $scopeType
* @param string|null $scopeValue
* @return IdentityMessageDefinition|null
*/
public function getActiveByScope(string $providerId, string $scopeType, ?string $scopeValue = null): ?IdentityMessageDefinition;
/**
* 모든 활성 메시지 정의 조회.
*
* @return Collection
*/
public function getAllActive(): Collection;
/**
* 활성 메시지 정의의 로케일별 라벨 맵 (N+1 회피용).
*
* 키: "{provider_id}|{scope_type}|{scope_value}", 값: 다국어 라벨
*
* @param string|null $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 array $data
* @return IdentityMessageDefinition
*/
public function store(array $data): IdentityMessageDefinition;
/**
* 메시지 정의 수정.
*
* @param IdentityMessageDefinition $definition
* @param array $data
* @return IdentityMessageDefinition
*/
public function update(IdentityMessageDefinition $definition, array $data): IdentityMessageDefinition;
/**
* 페이지네이션 목록 조회.
*
* @param array $filters
* @param int $perPage
* @return LengthAwarePaginator
*/
public function getPaginated(array $filters = [], int $perPage = 20): LengthAwarePaginator;
}
@@ -0,0 +1,69 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\IdentityMessageTemplate;
use Illuminate\Database\Eloquent\Collection;
interface IdentityMessageTemplateRepositoryInterface
{
/**
* ID로 메시지 템플릿 조회.
*
* @param int $id
* @return IdentityMessageTemplate|null
*/
public function findById(int $id): ?IdentityMessageTemplate;
/**
* 정의 ID + 채널로 템플릿 조회.
*
* @param int $definitionId
* @param string $channel
* @return IdentityMessageTemplate|null
*/
public function findByDefinitionAndChannel(int $definitionId, string $channel): ?IdentityMessageTemplate;
/**
* 활성 (정의 ID, 채널) 템플릿 조회.
*
* @param int $definitionId
* @param string $channel
* @return IdentityMessageTemplate|null
*/
public function getActiveByDefinitionAndChannel(int $definitionId, string $channel): ?IdentityMessageTemplate;
/**
* 특정 정의의 전체 템플릿 조회.
*
* @param int $definitionId
* @return Collection
*/
public function getByDefinitionId(int $definitionId): Collection;
/**
* 템플릿 수정.
*
* @param IdentityMessageTemplate $template
* @param array $data
* @return IdentityMessageTemplate
*/
public function update(IdentityMessageTemplate $template, array $data): IdentityMessageTemplate;
/**
* 템플릿 생성 또는 수정 (idempotent upsert).
*
* @param array $attributes
* @param array $values
* @return IdentityMessageTemplate
*/
public function updateOrCreate(array $attributes, array $values): IdentityMessageTemplate;
/**
* 템플릿 신규 생성.
*
* @param array $data
* @return IdentityMessageTemplate
*/
public function create(array $data): IdentityMessageTemplate;
}
@@ -0,0 +1,161 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\IdentityPolicy;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
/**
* identity_policies 테이블 Repository 계약.
*
* 정책은 선언형 Seeder (config/core.php.identity_policies + {벤더}IdentityPolicySeeder) 로 동기화됩니다.
* Repository 는 런타임 조회 + 운영자 UI 편집 경로 두 방향 모두 지원합니다.
*
* @since 7.0.0-beta.4
*/
interface IdentityPolicyRepositoryInterface
{
/**
* key 로 조회합니다.
*
* @param string $key 정책 키 (예: core.auth.signup_before_submit)
*/
public function findByKey(string $key): ?IdentityPolicy;
/**
* key + source_type 조합으로 정책 존재 여부를 확인합니다.
*
* 운영자 UI 의 scope_value 매핑 검증 등 "특정 출처 정책" 만 허용하는 검증 경로에서 사용.
*
* @param string $key 정책 키
* @param string $sourceType 'core' | 'module' | 'plugin' | 'admin'
*/
public function existsByKeyAndSourceType(string $key, string $sourceType): bool;
/**
* id 로 조회합니다.
*
* @param int $id 정책 PK
*/
public function findById(int $id): ?IdentityPolicy;
/**
* scope + target 조합으로 매칭되는 활성 정책들을 반환합니다 (priority DESC 정렬).
*
* @param string $scope 'route' | 'hook'
* @param string $target scope 별 식별자 (route name 또는 hook name)
* @return Collection<int, IdentityPolicy>
*/
public function resolveByScopeTarget(string $scope, string $target): Collection;
/**
* key 존재 시 업데이트, 없으면 생성합니다. Seeder/SyncHelper 가 사용하는 upsert 경로.
*
* @param array<string, mixed> $attributes 정책 속성
* @return IdentityPolicy upsert 된 정책
*/
public function upsertByKey(array $attributes): IdentityPolicy;
/**
* key 기준 업데이트 (운영자 UI 편집 경로).
*
* @param string $key 정책 키
* @param array<string, mixed> $attributes 변경할 속성
* @param array<int, string> $overridesFields user_overrides 에 append 할 필드명들
* @return bool 업데이트 성공 여부
*/
public function updateByKey(string $key, array $attributes, array $overridesFields = []): bool;
/**
* key 기준 삭제 (source_type=admin 인 정책만 허용).
*
* @param string $key 정책 키
* @return bool 삭제 성공 여부
*/
public function deleteByKey(string $key): bool;
/**
* 정책 모델을 영속화합니다 (Eloquent save 위임).
*
* upsert/updateByKey 가 다루지 않는 부분 갱신 경로 (예: user_overrides 부분 복원) 에서 사용합니다.
* Service 가 모델 객체에 직접 변경을 적용한 뒤 본 메서드로 저장 — Service-Repository 경유 패턴 유지.
*
* @param IdentityPolicy $policy 영속화할 모델
* @return bool 저장 성공 여부
*/
public function save(IdentityPolicy $policy): bool;
/**
* source_type+source_identifier 에 속하지 않은 stale 정책을 제거합니다.
*
* @param string $sourceType 'core' | 'module' | 'plugin' | 'admin'
* @param string $sourceIdentifier vendor 식별자 (예: sirsoft-ecommerce, core)
* @param array<int, string> $currentKeys 현재 선언된 key 목록
* @return int 삭제된 행 수
*/
public function cleanupStale(string $sourceType, string $sourceIdentifier, array $currentKeys): int;
/**
* source_type+source_identifier 에 속하면서 currentKeys 에 없는 stale 정책을 조회합니다.
*
* bulk delete(cleanupStale) 와 달리 모델 인스턴스를 반환하므로, 호출 측이 per-model
* delete()(deleted 이벤트 발화 — 라우트 스코프 캐시 flush)와 로깅을 수행할 수 있다.
*
* @param string $sourceType 'core' | 'module' | 'plugin' | 'admin'
* @param string $sourceIdentifier vendor 식별자
* @param array<int, string> $currentKeys 현재 선언된 key 목록
* @return Collection<int, IdentityPolicy> stale 정책 목록
*/
public function findStale(string $sourceType, string $sourceIdentifier, array $currentKeys): Collection;
/**
* 특정 source(확장) 가 등록한 정책 개수를 반환합니다.
*
* 모듈/플러그인 uninstall 모달의 "삭제될 데이터" 표시에 사용.
*
* @param string $sourceType 'core' | 'module' | 'plugin' | 'admin'
* @param string $sourceIdentifier 확장 식별자
*/
public function countBySource(string $sourceType, string $sourceIdentifier): int;
/**
* 목록 조회 (관리자 S1d DataGrid).
*
* @param array<string, mixed> $filters 필터 조건
* @param int $perPage 페이지 크기
* @return LengthAwarePaginator
*/
public function search(array $filters, int $perPage = 20);
/**
* 전체 활성 정책을 반환합니다.
*
* @return Collection<int, IdentityPolicy>
*/
public function allEnabled(): Collection;
/**
* scope='route' 활성 정책을 [target => Collection<IdentityPolicy>] 맵으로 반환합니다.
*
* EnforceIdentityPolicy 미들웨어의 자동 매핑 lookup 진입점으로 사용. 부팅 시 1회 캐싱되며
* IdentityPolicy 모델 saved/deleted 이벤트가 캐시를 즉시 invalidate 합니다.
*
* brace expansion 지원: target 'api.admin.{modules,plugins}.uninstall' 같은 표현은
* 두 개의 라우트명으로 펼쳐 동일 정책 인스턴스를 양쪽에 매핑합니다.
*
* @return array<string, Collection<int, IdentityPolicy>> route name → 매칭 정책 컬렉션
*/
public function getRouteScopeIndex(): array;
/**
* scope='hook' 활성 정책의 target 목록(중복 제거)을 반환합니다.
*
* EnforceIdentityPolicyListener::loadDynamicHookTargets() 가 부팅 시 동적 훅 구독을 위해
* 호출하는 단일 진입점입니다. identity_policies 테이블이 존재하지 않거나 DB 미연결 환경
* (마이그레이션 전 부팅) 에서는 빈 배열을 반환해 부팅을 보호합니다.
*
* @return list<string> 동적 hook target 목록
*/
public function listHookTargets(): array;
}
@@ -0,0 +1,101 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\IdentityVerificationLog;
/**
* identity_verification_logs 테이블 Repository 계약.
*
* Challenge 생명주기(requested/sent/verified/failed/expired/cancelled/policy_violation_logged)의
* 적재·조회·보관주기 파기 책임을 갖습니다.
*
* @since 7.0.0-beta.4
*/
interface IdentityVerificationLogRepositoryInterface
{
/**
* 로그를 생성합니다.
*
* @param array<string, mixed> $attributes 로그 속성
* @return IdentityVerificationLog 생성된 로그
*/
public function create(array $attributes): IdentityVerificationLog;
/**
* id(UUID) 로 조회합니다.
*
* @param string $id 로그 UUID
* @return IdentityVerificationLog|null
*/
public function findById(string $id): ?IdentityVerificationLog;
/**
* id 기준 업데이트.
*
* @param string $id 로그 UUID
* @param array<string, mixed> $attributes 변경할 속성
* @return bool 1건 이상 업데이트되었는지 여부
*/
public function updateById(string $id, array $attributes): bool;
/**
* target_hash + purpose 조합으로 최근 성공한 challenge 를 조회합니다.
* grace_minutes 내 재사용 가능 여부 판정에 사용합니다.
*
* @param string $purpose IDV 목적
* @param int|null $userId 로그인 상태에서는 user_id 우선 매칭
* @param string|null $targetHash user_id 가 null 일 때 대신 매칭하는 sha256 해시
* @param int $withinMinutes grace_minutes (이 분 내 verified 만 매칭)
* @return IdentityVerificationLog|null 매칭 로그 또는 null
*/
public function findRecentVerified(
string $purpose,
?int $userId,
?string $targetHash,
int $withinMinutes,
): ?IdentityVerificationLog;
/**
* 특정 토큰(verification_token) 으로 verified 상태의 challenge 를 찾습니다.
* IdvTokenRule 이 register 검증 시 사용합니다.
*
* @param string $token verification_token
* @param string $purpose IDV 목적 (예: signup)
* @return IdentityVerificationLog|null 매칭 로그 또는 null
*/
public function findVerifiedForToken(string $token, string $purpose): ?IdentityVerificationLog;
/**
* 만료 경과 challenge 를 일괄 expire 처리합니다.
*
* @return int 처리된 행 수
*/
public function expirePastDue(): int;
/**
* 보관주기 경과 로그를 일괄 삭제합니다.
*
* @param int $days 보관 일수 (이보다 오래된 로그가 삭제됨)
* @return int 삭제된 행 수
*/
public function purgeOlderThan(int $days): int;
/**
* 목록 조회 (관리자 인증 이력 화면).
*
* @param array<string, mixed> $filters provider_id/purpose/status/user_id/date_from/date_to 등
* @param int $perPage 페이지 크기
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
*/
public function search(array $filters, int $perPage = 20);
/**
* user_id 백필 (signup 흐름에서 pre-signup challenge → 가입 성공 후 user_id 채움).
*
* @param string $id 로그 UUID
* @param int $userId 채울 user_id
* @return bool 백필 성공 여부 (이미 user_id 가 있으면 false)
*/
public function backfillUserId(string $id, int $userId): bool;
}
@@ -0,0 +1,182 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\LanguagePack;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
/**
* 언어팩 Repository 인터페이스.
*/
interface LanguagePackRepositoryInterface
{
/**
* 식별자로 언어팩을 조회합니다.
*
* @param string $identifier 언어팩 식별자
* @return LanguagePack|null 언어팩 또는 null
*/
public function findByIdentifier(string $identifier): ?LanguagePack;
/**
* ID 로 언어팩을 조회합니다.
*
* @param int $id 언어팩 ID
* @return LanguagePack|null 언어팩 또는 null
*/
public function findById(int $id): ?LanguagePack;
/**
* 모든 활성 언어팩을 조회합니다.
*
* @return Collection<int, LanguagePack> 활성 언어팩 컬렉션
*/
public function getActivePacks(): Collection;
/**
* 특정 슬롯의 활성 언어팩을 조회합니다.
*
* @param string $scope 스코프
* @param string|null $targetIdentifier 대상 확장 식별자
* @param string $locale 로케일
* @param int|null $excludeId 결과에서 제외할 언어팩 id (재설치 시 자기 자신 제외용)
* @return LanguagePack|null 활성 언어팩 또는 null
*/
public function findActiveForSlot(
string $scope,
?string $targetIdentifier,
string $locale,
?int $excludeId = null
): ?LanguagePack;
/**
* 특정 슬롯의 모든 후보 언어팩을 조회합니다 (벤더별).
*
* @param string $scope 스코프
* @param string|null $targetIdentifier 대상 확장 식별자
* @param string $locale 로케일
* @return Collection<int, LanguagePack> 후보 언어팩 컬렉션
*/
public function getPacksForSlot(
string $scope,
?string $targetIdentifier,
string $locale
): Collection;
/**
* 활성 코어 언어팩이 있는 모든 로케일을 반환합니다.
*
* @return array<int, string> 로케일 문자열 배열
*/
public function getActiveCoreLocales(): array;
/**
* 페이지네이션 + 필터링된 언어팩 목록을 조회합니다.
*
* @param array<string, mixed> $filters 필터 (scope, target_identifier, locale, status, vendor)
* @param int $perPage 페이지당 건수
* @return LengthAwarePaginator 페이지네이션 결과
*/
public function paginate(array $filters = [], int $perPage = 20): LengthAwarePaginator;
/**
* 필터링된 언어팩 컬렉션을 페이지네이션 없이 조회합니다.
*
* 미설치 번들 가상 레코드(`lang-packs/_bundled/{identifier}`)와 병합한 뒤
* Service 계층에서 수동 페이지네이션을 수행하기 위해 사용됩니다.
*
* @param array<string, mixed> $filters 필터 (scope, target_identifier, locale, status, vendor, search)
* @return Collection<int, LanguagePack> 필터링된 언어팩 컬렉션
*/
public function getFilteredCollection(array $filters = []): Collection;
/**
* 언어팩을 생성합니다.
*
* @param array<string, mixed> $data 생성 데이터
* @return LanguagePack 생성된 언어팩
*/
public function create(array $data): LanguagePack;
/**
* 언어팩을 갱신합니다.
*
* @param LanguagePack $pack 대상 언어팩
* @param array<string, mixed> $data 갱신 데이터
* @return LanguagePack 갱신된 언어팩
*/
public function update(LanguagePack $pack, array $data): LanguagePack;
/**
* 언어팩을 삭제합니다.
*
* @param LanguagePack $pack 대상 언어팩
* @return bool 삭제 성공 여부
*/
public function delete(LanguagePack $pack): bool;
/**
* 특정 확장(scope, target_identifier)에 연결된 언어팩 전체를 조회합니다.
*
* @param string $scope 스코프
* @param string $targetIdentifier 대상 확장 식별자
* @return Collection<int, LanguagePack> 언어팩 컬렉션
*/
public function getPacksForTarget(string $scope, string $targetIdentifier): Collection;
/**
* 특정 로케일에 속하는 모든 언어팩을 조회합니다 (cascade 삭제 시 사용).
*
* @param string $locale 로케일
* @return Collection<int, LanguagePack> 언어팩 컬렉션
*/
public function getPacksForLocale(string $locale): Collection;
/**
* 번들 manifest 로부터 가상 LanguagePack 인스턴스를 합성합니다 (DB 미저장).
*
* 미설치(uninstalled) 번들의 가상 행 합성 책임을 Repository 로 일원화 — Service 가
* Model 을 직접 인스턴스화하지 않도록 합니다. exists=false 로 표시되며, `bundled_identifier`
* 가상 속성을 함께 채워 Resource 가 행 액션에 노출할 수 있게 합니다.
*
* @param array<string, mixed> $manifest 번들 manifest 데이터
* @param string $bundledIdentifier `lang-packs/_bundled/{이 값}` 디렉토리명
* @return LanguagePack 가상 LanguagePack 인스턴스 (DB 미저장)
*/
public function buildVirtualFromManifest(array $manifest, string $bundledIdentifier): LanguagePack;
/**
* 코어/번들 확장의 lang/{ko,en}/ 디렉토리로부터 가상 보호 LanguagePack 인스턴스를 합성합니다.
*
* 항상 active+protected 로 표시되며 사용자가 install/uninstall/activate/deactivate 할 수 없습니다.
*
* @param string $scope 스코프 (core/module/plugin/template)
* @param string|null $targetIdentifier 대상 확장 식별자 (core 일 때 null)
* @param string $locale 로케일 (예: 'ko', 'en')
* @param string $vendor 벤더 (확장 manifest.vendor 또는 'g7')
* @param string $version 버전
* @param string $langPathRelative lang 디렉토리 상대 경로
* @return LanguagePack 가상 LanguagePack 인스턴스
*/
public function buildVirtualBuiltInPack(
string $scope,
?string $targetIdentifier,
string $locale,
string $vendor,
string $version,
string $langPathRelative,
): LanguagePack;
/**
* 호스트 확장(modules/plugins/templates)의 status + version 행을 조회합니다.
*
* `language_packs.requires.target_version` 검사 등 cross-domain 조회를 Repository
* 경계 안으로 캡슐화하기 위한 메서드입니다. Service 가 DB facade 를 직접 호출하지 않도록 합니다.
*
* @param string $scope 스코프 (module/plugin/template). core 또는 알 수 없는 값은 null 반환.
* @param string $identifier 호스트 확장 식별자
* @return object|null `{status, version}` 객체 또는 행 부재/스코프 미지원 시 null
*/
public function findHostExtensionRow(string $scope, string $identifier): ?object;
}
@@ -0,0 +1,41 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\LanguagePack;
/**
* 언어팩 활성/비활성 시 DB JSON 다국어 컬럼을 일괄 동기화하는 집계 Repository 의 계약.
*
* Permission/Role/Menu/Module/Plugin/Template/NotificationDefinition/NotificationTemplate/
* IdentityMessageDefinition/IdentityMessageTemplate 10 모델의 JSON 컬럼에 대한 locale 키
* 병합/제거를 단일 진입점으로 캡슐화합니다 (Service-Repository 패턴: Listener/Service 가 직접
* 모델 정적 호출을 하지 못하도록 영속 책임을 일원화).
*
* 보존 정책 (활성/비활성 공통):
* - 모델의 `user_overrides` JSON 컬럼에 등록된 (column, locale) 키 또는 column 전체는 sync
* 대상에서 제외 — 사용자가 의도적으로 수정한 값을 보존.
* - 활성 시: locale 키 부재 → 추가, locale 키 존재 + override 미등록 → 덮어쓰기, override 등록 → skip
* - 비활성 시: override 미등록 → 제거, override 등록 → 보존
*
* @since 7.0.0-beta.4
*/
interface LanguagePackTranslationRepositoryInterface
{
/**
* 언어팩의 seed/*.json 을 DB JSON 컬럼에 병합합니다.
*
* @param LanguagePack $pack 활성화된 언어팩
* @param array<string, array<string, mixed>> $seedBundle 엔티티별 seed 데이터 (loadSeed 결과 묶음)
* @return array<int, array<string, mixed>> 감사 로그 항목 (skipped/applied 결정)
*/
public function applySeedFromPack(LanguagePack $pack, array $seedBundle): array;
/**
* 언어팩의 locale 키를 DB JSON 컬럼에서 제거합니다 (user_overrides 컬럼은 보존).
*
* @param LanguagePack $pack 비활성화된 언어팩
* @return array<int, array<string, mixed>> 감사 로그 항목 (preserved/stripped 결정)
*/
public function stripLocaleFromPack(LanguagePack $pack): array;
}
@@ -14,8 +14,8 @@ interface LayoutExtensionRepositoryInterface
/**
* 특정 확장점에 등록된 확장 목록 조회
*
* @param int $templateId 템플릿 ID
* @param string $extensionPointName 확장점 이름
* @param int $templateId 템플릿 ID
* @param string $extensionPointName 확장점 이름
* @return Collection<int, LayoutExtension>
*/
public function getByExtensionPoint(int $templateId, string $extensionPointName): Collection;
@@ -23,8 +23,8 @@ interface LayoutExtensionRepositoryInterface
/**
* 특정 레이아웃을 타겟으로 하는 오버레이 목록 조회
*
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름
* @return Collection<int, LayoutExtension>
*/
public function getOverlaysByLayout(int $templateId, string $layoutName): Collection;
@@ -32,8 +32,7 @@ interface LayoutExtensionRepositoryInterface
/**
* 확장 등록
*
* @param array $data 확장 데이터
* @return LayoutExtension
* @param array $data 확장 데이터
*/
public function create(array $data): LayoutExtension;
@@ -42,17 +41,16 @@ interface LayoutExtensionRepositoryInterface
*
* 동일한 조건의 확장이 존재하면 업데이트하고, 없으면 생성합니다.
*
* @param array $attributes 조회 조건 (template_id, extension_type, target_name, source_type, source_identifier)
* @param array $values 생성/업데이트할 값
* @return LayoutExtension
* @param array $attributes 조회 조건 (template_id, extension_type, target_name, source_type, source_identifier)
* @param array $values 생성/업데이트할 값
*/
public function updateOrCreate(array $attributes, array $values): LayoutExtension;
/**
* 출처별 확장 삭제 (soft delete)
*
* @param LayoutSourceType $sourceType 출처 타입
* @param string $identifier 출처 식별자
* @param LayoutSourceType $sourceType 출처 타입
* @param string $identifier 출처 식별자
* @return int 삭제된 레코드 수
*/
public function softDeleteBySource(LayoutSourceType $sourceType, string $identifier): int;
@@ -60,8 +58,8 @@ interface LayoutExtensionRepositoryInterface
/**
* 출처별 확장 복원
*
* @param LayoutSourceType $sourceType 출처 타입
* @param string $identifier 출처 식별자
* @param LayoutSourceType $sourceType 출처 타입
* @param string $identifier 출처 식별자
* @return int 복원된 레코드 수
*/
public function restoreBySource(LayoutSourceType $sourceType, string $identifier): int;
@@ -69,8 +67,8 @@ interface LayoutExtensionRepositoryInterface
/**
* 출처별 확장 영구 삭제
*
* @param LayoutSourceType $sourceType 출처 타입
* @param string $identifier 출처 식별자
* @param LayoutSourceType $sourceType 출처 타입
* @param string $identifier 출처 식별자
* @return int 삭제된 레코드 수
*/
public function forceDeleteBySource(LayoutSourceType $sourceType, string $identifier): int;
@@ -80,9 +78,9 @@ interface LayoutExtensionRepositoryInterface
*
* 특정 extension_point에 대해 템플릿이 오버라이드를 정의했는지 확인합니다.
*
* @param int $templateId 템플릿 ID
* @param string $extensionPointName 확장점 이름
* @param string $moduleIdentifier 모듈/플러그인 식별자
* @param int $templateId 템플릿 ID
* @param string $extensionPointName 확장점 이름
* @param string $moduleIdentifier 모듈/플러그인 식별자
* @return LayoutExtension|null 템플릿 오버라이드 또는 null
*/
public function findTemplateOverrideForExtensionPoint(
@@ -96,9 +94,9 @@ interface LayoutExtensionRepositoryInterface
*
* 특정 target_layout에 대해 템플릿이 오버라이드를 정의했는지 확인합니다.
*
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름
* @param string $moduleIdentifier 모듈/플러그인 식별자
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름
* @param string $moduleIdentifier 모듈/플러그인 식별자
* @return LayoutExtension|null 템플릿 오버라이드 또는 null
*/
public function findTemplateOverrideForOverlay(
@@ -113,8 +111,8 @@ interface LayoutExtensionRepositoryInterface
* 템플릿 오버라이드가 있는 모듈 확장은 제외하고,
* 오버라이드된 버전과 원본 모듈 확장을 함께 반환합니다.
*
* @param int $templateId 템플릿 ID
* @param string $extensionPointName 확장점 이름
* @param int $templateId 템플릿 ID
* @param string $extensionPointName 확장점 이름
* @return Collection<int, LayoutExtension>
*/
public function getResolvedExtensionPoints(int $templateId, string $extensionPointName): Collection;
@@ -125,8 +123,8 @@ interface LayoutExtensionRepositoryInterface
* 템플릿 오버라이드가 있는 모듈 확장은 제외하고,
* 오버라이드된 버전과 원본 모듈 확장을 함께 반환합니다.
*
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름
* @return Collection<int, LayoutExtension>
*/
public function getResolvedOverlays(int $templateId, string $layoutName): Collection;
@@ -134,15 +132,92 @@ interface LayoutExtensionRepositoryInterface
/**
* 특정 템플릿의 모든 확장 조회
*
* @param int $templateId 템플릿 ID
* @param int $templateId 템플릿 ID
* @return Collection<int, LayoutExtension>
*/
public function getByTemplateId(int $templateId): Collection;
/**
* 특정 템플릿의 확장을 오버라이드 해석을 적용해 조회
*
* 템플릿 오버라이드에 가려져 화면에 적용되지 않는 모듈/플러그인 확장을
* 제외합니다. 레이아웃 편집 화면 트리가 실제 화면에 반영되는 확장만
* 보여주도록 사용합니다.
*
* @param int $templateId 템플릿 ID
* @return Collection<int, LayoutExtension>
*/
public function getResolvedByTemplateId(int $templateId): Collection;
/**
* 템플릿 오버라이드 해석을 적용해 가려진 확장을 제거
*
* 동일한 (extension_type, target_name) 범위의 확장만 전달해야 합니다.
*
* @param Collection<int, LayoutExtension> $extensions 동일 범위 확장 컬렉션
* @return Collection<int, LayoutExtension>
*/
public function resolveOverrides(Collection $extensions): Collection;
/**
* ID로 단일 확장 조회
*
* @param int $extensionId 확장 ID
* @return LayoutExtension|null 확장 모델 또는 null
*/
public function findById(int $extensionId): ?LayoutExtension;
/**
* 특정 출처(모듈/플러그인)의 모든 확장 조회
*
* @param LayoutSourceType $sourceType 출처 타입
* @param string $identifier 출처 식별자
* @return Collection<int, LayoutExtension>
*/
public function getBySource(LayoutSourceType $sourceType, string $identifier): Collection;
/**
* 조회 조건에 일치하는 확장을 조회
*
* @param array $attributes 조회 조건
* @return LayoutExtension|null 일치하는 확장 또는 null
*/
public function findByAttributes(array $attributes): ?LayoutExtension;
/**
* 조회 조건에 일치하는 모든 LIVE 확장을 템플릿 구분 없이 조회 (cross-template 판정용)
*
* @param array $attributes 조회 조건 (template_id 제외)
* @return \Illuminate\Database\Eloquent\Collection<int, LayoutExtension>
*/
public function getAllByAttributesAcrossTemplates(array $attributes): \Illuminate\Database\Eloquent\Collection;
/**
* 확장 업데이트
*
* @param int $extensionId 확장 ID
* @param array $data 업데이트할 데이터
* @return LayoutExtension 업데이트된 확장 모델
*/
public function update(int $extensionId, array $data): LayoutExtension;
/**
* 확장 content + lock_version 동시 갱신 (낙관적 잠금)
*
* Service 가 호출 직전에 expected_lock_version 검증을 마친 상태로,
* 본 메서드는 update 데이터에 lock_version 증가를 함께 반영한다.
*
* @param int $extensionId 확장 ID
* @param array $data 업데이트 데이터 (content, priority 등)
* @param int $newLockVersion 새 lock_version 값 (currentVersion + 1)
* @return LayoutExtension 업데이트된 확장 모델
*/
public function updateWithLock(int $extensionId, array $data, int $newLockVersion): LayoutExtension;
/**
* 특정 템플릿의 모든 확장 삭제
*
* @param int $templateId 템플릿 ID
* @param int $templateId 템플릿 ID
* @return int 삭제된 레코드 수
*/
public function deleteByTemplateId(int $templateId): int;
@@ -0,0 +1,75 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\TemplateLayoutExtensionVersion;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
interface LayoutExtensionVersionRepositoryInterface
{
/**
* 버전 저장 (자동 증가)
*
* @param int $extensionId 레이아웃 확장 ID
* @param array $oldContent 이전 콘텐츠
* @param array|null $newContent 새 콘텐츠 (null이면 현재 확장 content 사용)
* @return TemplateLayoutExtensionVersion 생성된 버전 모델
*/
public function saveVersion(int $extensionId, array $oldContent, ?array $newContent = null): TemplateLayoutExtensionVersion;
/**
* 특정 확장의 모든 버전 조회 (최신순)
*
* @param int $extensionId 레이아웃 확장 ID
* @return Collection 버전 컬렉션
*/
public function getVersions(int $extensionId): Collection;
/**
* 확장 ID 목록의 현재(최신) 버전 번호 맵 조회
*
* 레이아웃 편집기 라우트 트리의 확장 노드 버전 배지 데이터
* 소스로, 버전 이력이 1건 이상인 확장만 포함된다(미저장 확장 = 원본 → 맵 제외).
*
* @param array<int> $extensionIds 확장 ID 목록
* @return array<int, int> 확장 ID → 최신 버전 번호
*/
public function getCurrentVersionsByExtensionIds(array $extensionIds): array;
/**
* 특정 버전 조회
*
* @param int $versionId 버전 ID
* @return TemplateLayoutExtensionVersion|null 찾은 버전 모델 또는 null
*/
public function getVersion(int $versionId): ?TemplateLayoutExtensionVersion;
/**
* 다음 버전 번호 계산
*
* @param int $extensionId 레이아웃 확장 ID
* @return int 다음 버전 번호
*/
public function getNextVersion(int $extensionId): int;
/**
* JSON content 변경사항 카운트 계산 (라인 단위)
*
* @param array $oldContent 이전 콘텐츠
* @param array $newContent 새 콘텐츠
* @return array{added: int, removed: int, char_diff: int} 추가/삭제 라인 수 + 문자 수 변화
*/
public function calculateChanges(array $oldContent, array $newContent): array;
/**
* 버전 복원
*
* @param int $extensionId 레이아웃 확장 ID
* @param int $versionId 복원할 버전 ID
* @return TemplateLayoutExtensionVersion 복원 후 생성된 새 버전 모델
*
* @throws ModelNotFoundException 버전을 찾을 수 없는 경우
*/
public function restoreVersion(int $extensionId, int $versionId): TemplateLayoutExtensionVersion;
}
@@ -43,6 +43,44 @@ interface LayoutRepositoryInterface
*/
public function exists(int $templateId, string $name): bool;
/**
* 특정 템플릿의 레이아웃 중 지정한 확장점(extension_point)을 정의한 것이 있는지 확인
*
* 레이아웃 content JSON 트리를 재귀 순회하여 `type: extension_point` 노드의
* `name` 이 일치하는지 검사합니다.
*
* @param int $templateId 템플릿 ID
* @param string $extensionPointName 확장점 이름
* @return bool 정의 존재 여부
*/
public function hasExtensionPoint(int $templateId, string $extensionPointName): bool;
/**
* 지정 extension_point 를 포함하는 레이아웃 이름 목록을 반환합니다.
*
* 확장 편집 모드에서 extension_point 확장의 대표 호스트 레이아웃 선택(picker)용. 여러
* 레이아웃에 같은 확장점이 있으면 모두 반환한다.
*
* @param int $templateId 템플릿 ID
* @param string $extensionPointName 확장점 이름
* @return array<int, string> 호스트 레이아웃 이름 목록
*/
public function findLayoutNamesWithExtensionPoint(int $templateId, string $extensionPointName): array;
/**
* 레이아웃 content 트리에 지정 노드 id 가 존재하는지 확인합니다.
*
* overlay 확장의 호스트 유효성 판정용 — overlay 는 `injections[].target_id` 위치에 주입되므로,
* 호스트 레이아웃에 그 id 노드가 없으면 실제로 주입되지 않는다(`applyExtensions` no-op).
* 노드 식별자는 `id` 또는 `props.id` 두 형태를 모두 검사한다.
*
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름
* @param string $nodeId 찾을 노드 id
* @return bool 노드 id 존재 여부
*/
public function layoutContainsNodeId(int $templateId, string $layoutName, string $nodeId): bool;
/**
* extends를 가진 자식 레이아웃 조회
*
@@ -61,6 +99,19 @@ interface LayoutRepositoryInterface
*/
public function update(int $id, array $data): TemplateLayout;
/**
* 레이아웃 content + lock_version 동시 갱신 (낙관적 잠금)
*
* Service 가 호출 직전에 expected_lock_version 검증을 마친 상태로,
* 본 메서드는 content 교체와 lock_version 증가를 한 번의 UPDATE 로 수행한다.
*
* @param int $id 레이아웃 ID
* @param array $content 전체 레이아웃 JSON content
* @param int $newLockVersion 새 lock_version 값 (currentVersion + 1)
* @return TemplateLayout 업데이트된 레이아웃 모델
*/
public function updateContent(int $id, array $content, int $newLockVersion): TemplateLayout;
/**
* 특정 레이아웃의 모든 버전 조회
*
@@ -4,6 +4,7 @@ namespace App\Contracts\Repositories;
use App\Models\TemplateLayoutVersion;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\ModelNotFoundException;
interface LayoutVersionRepositoryInterface
{
@@ -42,11 +43,11 @@ interface LayoutVersionRepositoryInterface
public function getNextVersion(int $layoutId): int;
/**
* JSON content 변경사항 계산
* JSON content 변경사항 카운트 계산 (라인 단위)
*
* @param array $oldContent 이전 콘텐츠
* @param array $newContent 새 콘텐츠
* @return array 변경사항 (added, removed, modified)
* @return array{added: int, removed: int, char_diff: int} 추가/삭제 라인 수 + 문자 수 변화
*/
public function calculateChanges(array $oldContent, array $newContent): array;
@@ -57,7 +58,30 @@ interface LayoutVersionRepositoryInterface
* @param int $versionId 복원할 버전 ID
* @return TemplateLayoutVersion 복원 후 생성된 새 버전 모델
*
* @throws \Illuminate\Database\Eloquent\ModelNotFoundException 버전을 찾을 수 없는 경우
* @throws ModelNotFoundException 버전을 찾을 수 없는 경우
*/
public function restoreVersion(int $layoutId, int $versionId): TemplateLayoutVersion;
}
/**
* 템플릿의 레이아웃별 현재(최신) 버전 번호 맵 조회
*
* 레이아웃 편집기 좌측 라우트 트리의 버전 배지 데이터 소스로,
* 해당 템플릿에 속한 레이아웃 중 버전 이력이 1건 이상인 레이아웃만 포함된다
* (한 번도 저장된 적 없는 레이아웃 = 원본 상태 → 맵에 없음 → 배지 미표시).
*
* @param int $templateId 대상 템플릿 ID
* @return array<string, int> 레이아웃 이름 → 최신 버전 번호
*/
public function getCurrentVersionsByTemplateId(int $templateId): array;
/**
* 모든 버전의 changes_summary 를 현재 알고리즘으로 재계산하여 갱신합니다.
*
* 측정 알고리즘 변경(키 경로 단위 → 라인 단위) 이전에 저장된 버전들은 옛 기준의
* changes_summary 를 담고 있다. 각 레이아웃의 버전을 버전 순으로 정렬해 인접 쌍으로
* 재계산하여 갱신한다(첫 버전은 baseline 으로 빈 요약).
*
* @return int 갱신된 버전 수
*/
public function recalculateAllChangeSummaries(): int;
}
@@ -193,4 +193,13 @@ interface ModuleRepositoryInterface
* @return Collection 해당 플러그인에 의존하는 활성 모듈 컬렉션
*/
public function findActiveByPluginDependency(string $pluginIdentifier): Collection;
/**
* 코어 버전 비호환으로 자동 비활성화된 모듈을 조회합니다.
*
* `deactivated_reason = 'incompatible_core'` 인 레코드만 반환합니다.
*
* @return Collection 자동 비활성화된 모듈 컬렉션
*/
public function findAutoDeactivated(): Collection;
}
@@ -4,10 +4,19 @@ namespace App\Contracts\Repositories;
use App\Models\NotificationLog;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Pagination\LengthAwarePaginator;
interface NotificationLogRepositoryInterface
{
/**
* 최근 발송된 알림 로그를 발송 시각 최신순으로 조회합니다 (대시보드 최근 알림).
*
* @param int $limit 조회 건수
* @return Collection<int, NotificationLog> 최근 알림 로그 컬렉션
*/
public function getRecent(int $limit): Collection;
/**
* ID로 알림 로그 조회.
*/
@@ -157,4 +157,14 @@ interface PluginRepositoryInterface
* @return array 활성화된 플러그인 identifier 배열
*/
public function getActivePluginIdentifiers(): array;
/**
* 코어 버전 비호환으로 자동 비활성화된 플러그인을 조회합니다.
*
* `deactivated_reason = 'incompatible_core'` 인 레코드만 반환합니다.
* 알림 영속화 + 재호환 판정 + 상단 배너 데이터 소스로 사용됩니다.
*
* @return Collection 자동 비활성화된 플러그인 컬렉션
*/
public function findAutoDeactivated(): Collection;
}
@@ -39,6 +39,14 @@ interface RoleRepositoryInterface
*/
public function findByIdentifier(string $identifier): ?Role;
/**
* 식별자로 역할에 소속된 사용자들을 조회합니다.
*
* @param string $identifier 역할 식별자
* @return Collection 역할 소속 사용자 컬렉션
*/
public function getUsersByIdentifier(string $identifier): Collection;
/**
* 새로운 역할을 생성합니다.
*
@@ -101,4 +101,14 @@ interface ScheduleRepositoryInterface
* @return Schedule 복제된 스케줄
*/
public function duplicate(Schedule $schedule): Schedule;
/**
* ID 목록으로 스케줄들을 조회하고 ID 키 맵으로 반환합니다.
*
* Bulk activity log 처리 시 N+1 회피용 단일 쿼리 진입점.
*
* @param array<int, int> $ids 스케줄 ID 목록
* @return Collection<int, Schedule> id => Schedule 매핑
*/
public function findManyByIdsKeyed(array $ids): Collection;
}
@@ -0,0 +1,104 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\TemplateCustomTranslation;
use Illuminate\Database\Eloquent\Collection;
interface TemplateCustomTranslationRepositoryInterface
{
/**
* 특정 템플릿의 커스텀 다국어 키 목록 조회
*
* @param int $templateId 템플릿 ID
* @param string|null $layoutName 레이아웃 이름 필터 (null 이면 전체)
* @param string|null $status 상태 필터 (null 이면 전체)
* @return Collection<int, TemplateCustomTranslation> 커스텀 키 컬렉션
*/
public function getByTemplateId(int $templateId, ?string $layoutName = null, ?string $status = null): Collection;
/**
* 특정 템플릿의 활성 커스텀 다국어 키 조회 (런타임 병합용)
*
* @param int $templateId 템플릿 ID
* @return Collection<int, TemplateCustomTranslation> 활성 커스텀 키 컬렉션
*/
public function getActiveByTemplateId(int $templateId): Collection;
/**
* 특정 템플릿 + 레이아웃의 커스텀 키 조회 (좀비 감지용)
*
* `layout_name` 으로 격리해, 해당 레이아웃 저장 시 그 레이아웃 출처 키만
* 좀비 후보로 검사합니다.
*
* @param int $templateId 템플릿 ID
* @param string $layoutName 레이아웃 이름 (원본, 예: board/list)
* @param string|null $status 상태 필터 (null 이면 전체)
* @return Collection<int, TemplateCustomTranslation> 커스텀 키 컬렉션
*/
public function getByTemplateIdAndLayout(int $templateId, string $layoutName, ?string $status = null): Collection;
/**
* 주어진 ID 목록의 상태를 일괄 변경합니다 (active ↔ orphaned 전이).
*
* @param array<int, int> $ids 대상 커스텀 키 ID 목록
* @param string $status 새 상태 (active|orphaned)
* @return int 변경된 행 수
*/
public function updateStatus(array $ids, string $status): int;
/**
* ID로 커스텀 다국어 키 조회
*
* @param int $id 커스텀 키 ID
* @return TemplateCustomTranslation|null 찾은 모델 또는 null
*/
public function findById(int $id): ?TemplateCustomTranslation;
/**
* 템플릿 + 키로 조회
*
* @param int $templateId 템플릿 ID
* @param string $translationKey 다국어 키
* @return TemplateCustomTranslation|null 찾은 모델 또는 null
*/
public function findByKey(int $templateId, string $translationKey): ?TemplateCustomTranslation;
/**
* 특정 템플릿 + 레이아웃의 최대 seq 조회
*
* `custom.{layout}.{seq}` 형식 키에서 seq 의 현재 최대값을 반환합니다.
* 신규 키 생성 시 다음 seq 계산에 사용됩니다.
*
* @param int $templateId 템플릿 ID
* @param string $layoutKey 키 네임스페이스 (레이아웃명 정규화)
* @return int 현재 최대 seq (없으면 0)
*/
public function getMaxSeq(int $templateId, string $layoutKey): int;
/**
* 커스텀 다국어 키 생성
*
* @param array<string, mixed> $data 생성 데이터
* @return TemplateCustomTranslation 생성된 모델
*/
public function create(array $data): TemplateCustomTranslation;
/**
* 커스텀 다국어 키 값 수정 (lock_version 갱신 포함)
*
* @param int $id 커스텀 키 ID
* @param array<string, mixed> $data 수정 데이터
* @param int $newLockVersion 새 잠금 버전
* @return TemplateCustomTranslation 수정된 모델
*/
public function update(int $id, array $data, int $newLockVersion): TemplateCustomTranslation;
/**
* 커스텀 다국어 키 삭제
*
* @param int $id 커스텀 키 ID
* @return bool 삭제 성공 여부
*/
public function delete(int $id): bool;
}
@@ -0,0 +1,45 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\TemplateLayoutAttachment;
use Illuminate\Database\Eloquent\Collection;
/**
* 템플릿 레이아웃 첨부 파일 Repository 인터페이스
*/
interface TemplateLayoutAttachmentRepositoryInterface
{
/**
* ID로 첨부 파일 조회
*
* @param int $id 첨부 파일 ID
* @return TemplateLayoutAttachment|null 첨부 파일 또는 null
*/
public function findById(int $id): ?TemplateLayoutAttachment;
/**
* 템플릿(+선택적 레이아웃)별 첨부 파일 목록 조회 (최신순)
*
* @param int $templateId 템플릿 ID
* @param string|null $layoutName 레이아웃 이름 (null 이면 템플릿 전체)
* @return Collection<int, TemplateLayoutAttachment>
*/
public function listForTemplate(int $templateId, ?string $layoutName = null): Collection;
/**
* 첨부 파일 생성
*
* @param array<string, mixed> $data 생성 데이터
* @return TemplateLayoutAttachment 생성된 첨부 파일
*/
public function create(array $data): TemplateLayoutAttachment;
/**
* 첨부 파일 삭제 (DB 행만 — 스토리지 파일 삭제는 Service 책임)
*
* @param TemplateLayoutAttachment $attachment 삭제할 첨부 파일
* @return bool 삭제 성공 여부
*/
public function delete(TemplateLayoutAttachment $attachment): bool;
}
@@ -126,4 +126,13 @@ interface TemplateRepositoryInterface
* @return Collection 해당 플러그인에 의존하는 활성 템플릿 컬렉션
*/
public function findActiveByPluginDependency(string $pluginIdentifier): Collection;
/**
* 코어 버전 비호환으로 자동 비활성화된 템플릿을 조회합니다.
*
* `deactivated_reason = 'incompatible_core'` 인 레코드만 반환합니다.
*
* @return Collection 자동 비활성화된 템플릿 컬렉션
*/
public function findAutoDeactivated(): Collection;
}
@@ -5,6 +5,7 @@ namespace App\Contracts\Repositories;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
interface UserRepositoryInterface
{
@@ -93,4 +94,79 @@ interface UserRepositoryInterface
* @return array 언어별 사용자 수 배열
*/
public function getUsersByLanguage(): array;
}
/**
* UUID 목록으로 사용자들을 조회하고 UUID 키 맵으로 반환합니다.
*
* Bulk activity log 처리 시 N+1 회피용 단일 쿼리 진입점.
*
* @param array<int, string> $uuids 사용자 UUID 목록
* @return Collection<string, User> uuid => User 매핑
*/
public function findManyByUuidsKeyed(array $uuids): Collection;
/**
* UUID 목록으로 사용자들을 조회합니다.
*
* @param array<int, string> $uuids 사용자 UUID 목록
* @return Collection<int, User> 조회된 사용자 컬렉션
*/
public function findManyByUuids(array $uuids): Collection;
/**
* 슈퍼관리자 1명을 조회합니다.
*
* @return User|null 슈퍼관리자 또는 없으면 null
*/
public function findSuperAdmin(): ?User;
/**
* 특정 권한 identifier 를 가진 역할에 소속된 모든 사용자를 조회합니다.
*
* @param string $permissionIdentifier 권한 identifier
* @return Collection<int, User> 권한 보유 사용자 컬렉션
*/
public function findManyByPermissionIdentifier(string $permissionIdentifier): Collection;
/**
* 사용자의 연속 로그인 실패 카운터를 1 증가시킵니다.
*
* `last_failed_login_at` 도 현재 시각으로 갱신하며 새 카운트를 반환합니다.
*
* @param User $user 대상 사용자
* @return int 증가 후 카운트
*/
public function incrementFailedAttempts(User $user): int;
/**
* 사용자의 계정을 지정된 분만큼 잠급니다.
*
* `locked_until` 을 현재 시각 + $minutes 로 설정하고 `failed_login_attempts` 를
* 0 으로 리셋합니다 (다음 잠금 윈도우 시작점). 잠금 해제 시각을 반환합니다.
*
* @param User $user 잠글 사용자
* @param int $minutes 잠금 유지 시간(분)
* @return Carbon 잠금 해제 시각
*/
public function lockAccount(User $user, int $minutes): Carbon;
/**
* 사용자의 모든 로그인 시도 추적 컬럼을 초기화합니다.
*
* 정상 로그인 성공 시 호출됩니다 (`failed_login_attempts=0`,
* `locked_until=null`, `last_failed_login_at=null`).
*
* @param User $user 대상 사용자
*/
public function resetLoginAttempts(User $user): void;
/**
* 사용자의 계정이 현재 시점에 잠금 상태인지 판정합니다.
*
* `locked_until` 이 NULL 이거나 현재 시각보다 과거이면 false 를 반환합니다.
*
* @param User $user 대상 사용자
* @return bool 잠금 여부
*/
public function isLocked(User $user): bool;
}
@@ -0,0 +1,45 @@
<?php
namespace App\Contracts\Seeder;
/**
* 다국어 데이터를 시드하는 시더가 구현해야 할 인터페이스.
*
* 활성 언어팩의 seed/{entity}.json 자동 머지 인프라와 결선되어,
* `seed.{vendor-extension}.{entity}.translations` 필터를 자동 호출.
*
* @since 7.0.0-beta.5
*/
interface TranslatableSeederInterface
{
/**
* 확장 식별자 — `seed.{vendor-extension}.{entity}.translations` 필터 키 구성.
*
* 예: 'sirsoft-board', 'sirsoft-ecommerce'.
* 코어 시더는 빈 문자열 반환 (필터 키: `seed.{entity}.translations`).
*/
public function getExtensionIdentifier(): string;
/**
* Entity 이름 — `seed/{entity}.json` 파일명 (확장자 제외).
*
* 예: 'board_types', 'shipping_carriers'.
*/
public function getTranslatableEntity(): string;
/**
* 시드 entry 매칭 키 컬럼명.
*
* 우선순위: code > slug > key > identifier > id.
*/
public function getMatchKey(): string;
/**
* 기본 데이터 (활성 언어팩 머지 전 원본).
*
* `applyFilters` 호출 후 ja 등 활성 locale 키가 자동 보강된 결과를 시더가 사용.
*
* @return array<int, array<string, mixed>>
*/
public function getDefaults(): array;
}
@@ -0,0 +1,295 @@
<?php
namespace App\Database\Sample;
use App\Enums\IdentityOriginType;
use App\Enums\IdentityVerificationStatus;
use App\Extension\IdentityVerification\IdentityVerificationManager;
use App\Models\IdentityPolicy;
use App\Models\IdentityVerificationLog;
use App\Models\User;
use App\Traits\HasSeederCounts;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
/**
* 본인인증 이력 샘플 시더 추상 베이스.
*
* 코어/모듈/플러그인이 각자 영역의 IDV 이력을 채울 수 있도록 공통 골격을 제공한다.
* - 등록된 IdentityPolicy 중 자기 영역 정책만 추려서 사용
* - user_id = 실제 등록된 G7 사용자
* - provider_id = IdentityVerificationManager 에 등록된 실제 프로바이더
* - 상태 분포 = 운영 트래픽 비율 (verified 55, expired 15, failed 12, sent 7,
* cancelled 5, requested 3, policy_violation_logged 3)
* - attempts/expires_at/verified_at/consumed_at = 상태별 라이프사이클 일관성 보장
*
* 서브클래스는 영역 필터(applyPolicyScope) + 카운트 키/기본값 + 라벨을 정의한다.
*/
abstract class AbstractIdentityVerificationLogSampleSeeder extends Seeder
{
use HasSeederCounts;
/**
* 상태별 가중치 (총합 100).
*
* @var array<int, array{0: IdentityVerificationStatus, 1: int}>
*/
protected array $statusBuckets;
/**
* 한국/해외 IP 풀.
*
* @var array<int, string>
*/
protected array $ips = [
'121.78.45.12', '211.234.111.5', '125.142.88.91', '210.94.0.74',
'203.241.185.20', '180.182.50.7', '175.223.18.143', '218.236.42.61',
'14.45.110.222', '112.184.99.180', '61.43.232.18', '59.16.7.205',
'110.45.234.12', '106.247.83.190', '220.86.55.121',
'203.0.113.42', '198.51.100.7', '172.217.27.142',
'8.8.8.8', '1.1.1.1',
];
/**
* 데스크톱/모바일 UA 풀.
*
* @var array<int, string>
*/
protected array $userAgents = [
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15',
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (Linux; Android 14; SM-S921N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Mobile Safari/537.36',
'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:130.0) Gecko/20100101 Firefox/130.0',
];
public function __construct()
{
$this->statusBuckets = [
[IdentityVerificationStatus::Verified, 55],
[IdentityVerificationStatus::Expired, 15],
[IdentityVerificationStatus::Failed, 12],
[IdentityVerificationStatus::Sent, 7],
[IdentityVerificationStatus::Cancelled, 5],
[IdentityVerificationStatus::Requested, 3],
[IdentityVerificationStatus::PolicyViolationLogged, 3],
];
}
/**
* IdentityPolicy 쿼리에 영역 필터를 적용한다.
*
* @param Builder $query IdentityPolicy 쿼리
* @return Builder 영역 필터가 적용된 쿼리
*/
abstract protected function applyPolicyScope(Builder $query): Builder;
/**
* 카운트 옵션 키.
*
* @return string 카운트 옵션 키
*/
abstract protected function countKey(): string;
/**
* 기본 생성 건수.
*
* @return int 기본 건수
*/
abstract protected function defaultCount(): int;
/**
* 콘솔 메시지에 사용할 영역 라벨.
*
* @return string 영역 라벨
*/
abstract protected function scopeLabel(): string;
/**
* 시더 실행.
*/
public function run(): void
{
$count = $this->getSeederCount($this->countKey(), $this->defaultCount());
$label = $this->scopeLabel();
$users = User::query()->get(['id', 'name', 'email']);
if ($users->isEmpty()) {
$this->command->warn("사용자 데이터가 없어 {$label} 본인인증 이력 시더를 건너뜁니다.");
return;
}
$policies = $this->applyPolicyScope(IdentityPolicy::query())
->get(['key', 'purpose', 'source_type', 'source_identifier', 'provider_id']);
if ($policies->isEmpty()) {
$this->command->warn("{$label} 영역 IdentityPolicy 가 없어 시더를 건너뜁니다.");
return;
}
$manager = app(IdentityVerificationManager::class);
$providerIds = array_keys($manager->all());
if (empty($providerIds)) {
$this->command->warn("등록된 본인인증 프로바이더가 없어 {$label} 시더를 건너뜁니다.");
return;
}
$this->command->info("{$label} 본인인증 이력 시딩 시작... ({$count}건)");
$ttlMinutes = (int) config('settings.identity.challenge_ttl_minutes', 15);
$maxAttempts = (int) config('settings.identity.max_attempts', 5);
$now = Carbon::now();
$batch = [];
for ($i = 0; $i < $count; $i++) {
$user = $users->random();
$policy = $policies->random();
$providerId = $policy->provider_id ?: $providerIds[array_rand($providerIds)];
$status = $this->pickStatus();
$renderHint = mt_rand(1, 100) <= 70 ? 'text_code' : 'email_link';
$createdAt = $this->randomCreatedAt($now);
[$expiresAt, $verifiedAt, $consumedAt, $attempts] = $this->buildLifecycle(
$status,
$createdAt,
$ttlMinutes,
$maxAttempts,
);
$properties = $renderHint === 'text_code'
? ['code_length' => 6]
: ['link_hint' => 'email_link'];
$metadata = $status === IdentityVerificationStatus::PolicyViolationLogged
? ['violation_reason' => 'fail_mode_log_only']
: ['hint_used' => $renderHint];
$batch[] = [
'id' => (string) Str::uuid(),
'provider_id' => $providerId,
'purpose' => $policy->purpose,
'channel' => 'email',
'user_id' => $user->id,
'target_hash' => hash('sha256', mb_strtolower($user->email)),
'status' => $status->value,
'render_hint' => $renderHint,
'attempts' => $attempts,
'max_attempts' => $maxAttempts,
'ip_address' => $this->ips[array_rand($this->ips)],
'user_agent' => $this->userAgents[array_rand($this->userAgents)],
// 본 시더의 모든 challenge 는 IdentityPolicy enforce 경로를 통한 것이므로
// origin_type 은 'policy' 로 분류한다 (이전 버전에서는 source_type 을 잘못 매핑).
'origin_type' => IdentityOriginType::Policy->value,
'origin_identifier' => $policy->source_identifier,
'origin_policy_key' => $policy->key,
'properties' => json_encode($properties, JSON_UNESCAPED_UNICODE),
'metadata' => json_encode($metadata, JSON_UNESCAPED_UNICODE),
'verification_token' => $status === IdentityVerificationStatus::Verified
? bin2hex(random_bytes(32))
: null,
'expires_at' => $expiresAt,
'verified_at' => $verifiedAt,
'consumed_at' => $consumedAt,
'created_at' => $createdAt,
'updated_at' => $verifiedAt ?? $createdAt,
];
}
foreach (array_chunk($batch, 100) as $chunk) {
IdentityVerificationLog::insert($chunk);
}
$this->command->info("{$label} 본인인증 이력 시딩 완료 ({$count}건)");
}
/**
* 가중치 기반 상태 선택.
*
* @return IdentityVerificationStatus 선택된 상태
*/
protected function pickStatus(): IdentityVerificationStatus
{
$r = mt_rand(1, 100);
$acc = 0;
foreach ($this->statusBuckets as [$status, $weight]) {
$acc += $weight;
if ($r <= $acc) {
return $status;
}
}
return IdentityVerificationStatus::Verified;
}
/**
* 상태별 라이프사이클 일관성 있게 구성.
*
* @param IdentityVerificationStatus $status Challenge 상태
* @param Carbon $createdAt 생성 시각
* @param int $ttlMinutes TTL (분)
* @param int $maxAttempts 최대 시도 횟수
* @return array{0: Carbon|null, 1: Carbon|null, 2: Carbon|null, 3: int} [expires_at, verified_at, consumed_at, attempts]
*/
protected function buildLifecycle(
IdentityVerificationStatus $status,
Carbon $createdAt,
int $ttlMinutes,
int $maxAttempts,
): array {
$expiresAt = (clone $createdAt)->addMinutes($ttlMinutes);
$verifiedAt = null;
$consumedAt = null;
$attempts = 0;
switch ($status) {
case IdentityVerificationStatus::Verified:
$attempts = mt_rand(1, 3);
$verifiedAt = (clone $createdAt)->addSeconds(mt_rand(20, 600));
if (mt_rand(0, 1)) {
$consumedAt = (clone $verifiedAt)->addSeconds(mt_rand(1, 30));
}
break;
case IdentityVerificationStatus::Expired:
$attempts = mt_rand(0, 2);
break;
case IdentityVerificationStatus::Failed:
$attempts = $maxAttempts;
break;
case IdentityVerificationStatus::Cancelled:
$attempts = mt_rand(0, 2);
break;
case IdentityVerificationStatus::Sent:
case IdentityVerificationStatus::Requested:
$attempts = 0;
break;
case IdentityVerificationStatus::PolicyViolationLogged:
$expiresAt = null;
$attempts = 0;
break;
}
return [$expiresAt, $verifiedAt, $consumedAt, $attempts];
}
/**
* 최근 60일 내 임의 생성 시각.
*
* @param Carbon $now 기준 시각
* @return Carbon Challenge 생성 시각
*/
protected function randomCreatedAt(Carbon $now): Carbon
{
return (clone $now)
->subDays(mt_rand(0, 60))
->subHours(mt_rand(0, 23))
->subMinutes(mt_rand(0, 59))
->subSeconds(mt_rand(0, 59));
}
}
@@ -0,0 +1,273 @@
<?php
namespace App\Database\Sample;
use App\Enums\NotificationLogStatus;
use App\Models\NotificationDefinition;
use App\Models\NotificationLog;
use App\Models\User;
use App\Traits\HasSeederCounts;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon;
/**
* 알림 발송 이력 샘플 시더 추상 베이스.
*
* 코어/모듈/플러그인이 각자 영역의 발송 이력을 채울 수 있도록 공통 골격을 제공한다.
* - 등록된 NotificationDefinition 중 자기 영역 정의만 추려서 사용
* - 수신자/발송자 = 실제 등록된 G7 사용자
* - 상태 분포 = 운영 트래픽 비율 (sent 80%, failed 13%, skipped 7%)
* - sent_at = 최근 60일 분포
*
* 서브클래스는 영역 필터(applyDefinitionScope) + 카운트 키/기본값 + 라벨 + 본문/제목 맵을 정의한다.
*/
abstract class AbstractNotificationLogSampleSeeder extends Seeder
{
use HasSeederCounts;
/**
* 발송 실패 시 사용할 실제 SMTP/메일 게이트웨이 에러 메시지.
*
* @var array<int, string>
*/
protected array $errorMessages = [
'SMTP connection refused: smtp.gmail.com:587',
'Connection timed out after 10s',
'Mailbox unavailable: 550 5.1.1 user unknown',
'TLS handshake failed',
'Rate limit exceeded (provider quota)',
'Recipient address rejected: domain not found',
'Authentication failed: invalid credentials',
'Greylisted, retry later (450 4.2.0)',
];
/**
* 발송 건너뜀 사유.
*
* @var array<int, string>
*/
protected array $skipReasons = [
'Template inactive',
'User opted out',
'Channel disabled by user preference',
'Quiet hours policy applied',
'Duplicate suppression window',
];
/**
* 알림 정의 쿼리에 영역 필터를 적용한다 (예: extension_type='core' 또는 extension_identifier='sirsoft-board').
*
* @param Builder $query NotificationDefinition 쿼리
* @return Builder 영역 필터가 적용된 쿼리
*/
abstract protected function applyDefinitionScope(Builder $query): Builder;
/**
* 카운트 옵션 키 (예: 'core_notification_logs', 'ecommerce_notification_logs').
*
* @return string 카운트 옵션 키
*/
abstract protected function countKey(): string;
/**
* 기본 생성 건수.
*
* @return int 기본 건수
*/
abstract protected function defaultCount(): int;
/**
* 콘솔 메시지에 사용할 영역 라벨 (예: '코어', '이커머스 모듈').
*
* @return string 영역 라벨
*/
abstract protected function scopeLabel(): string;
/**
* 알림 타입별 한국어 제목 맵.
*
* @return array<string, string> [type => subject]
*/
abstract protected function subjectMap(): array;
/**
* 알림 타입별 한국어 본문 빌더 맵.
*
* @return array<string, callable(User, Carbon): string> [type => fn($recipient, $sentAt) => string]
*/
abstract protected function bodyMap(): array;
/**
* 시더 실행.
*/
public function run(): void
{
$count = $this->getSeederCount($this->countKey(), $this->defaultCount());
$label = $this->scopeLabel();
$users = User::query()->get(['id', 'name', 'email']);
if ($users->isEmpty()) {
$this->command->warn("사용자 데이터가 없어 {$label} 알림 발송 이력 시더를 건너뜁니다.");
return;
}
$definitions = $this->applyDefinitionScope(
NotificationDefinition::query()->where('is_active', true)
)->get(['type', 'extension_type', 'extension_identifier', 'channels']);
if ($definitions->isEmpty()) {
$this->command->warn("{$label} 영역 활성 알림 정의가 없어 시더를 건너뜁니다.");
return;
}
$admin = User::query()
->whereHas('roles', fn ($q) => $q->where('identifier', 'admin'))
->first();
$adminId = $admin?->id;
$this->command->info("{$label} 알림 발송 이력 시딩 시작... ({$count}건)");
$now = Carbon::now();
$batch = [];
for ($i = 0; $i < $count; $i++) {
$definition = $definitions->random();
$channel = $this->pickChannel($definition->channels ?? ['mail']);
$recipient = $users->random();
[$status, $error] = $this->randomStatusAndError();
$sentAt = $this->randomSentAt($now);
$batch[] = [
'channel' => $channel,
'notification_type' => $definition->type,
'extension_type' => $definition->extension_type,
'extension_identifier' => $definition->extension_identifier,
'recipient_user_id' => $recipient->id,
'recipient_identifier' => $channel === 'mail'
? $recipient->email
: (string) $recipient->id,
'recipient_name' => $recipient->name,
'sender_user_id' => $this->isAdminBoundType($definition->type) ? null : $adminId,
'subject' => $this->renderSubject($definition->type),
'body' => $this->renderBody($definition->type, $recipient, $sentAt),
'status' => $status->value,
'error_message' => $error,
'source' => 'notification',
'sent_at' => $sentAt,
'created_at' => $sentAt,
'updated_at' => $sentAt,
];
}
foreach (array_chunk($batch, 100) as $chunk) {
NotificationLog::insert($chunk);
}
$this->command->info("{$label} 알림 발송 이력 시딩 완료 ({$count}건)");
}
/**
* 알림 정의에 등록된 채널 중 하나를 선택한다.
*
* @param array<int, string> $channels 활성 채널 배열
* @return string 선택된 채널
*/
protected function pickChannel(array $channels): string
{
if (empty($channels)) {
return 'mail';
}
if (in_array('mail', $channels, true) && in_array('database', $channels, true)) {
return mt_rand(1, 100) <= 60 ? 'mail' : 'database';
}
return $channels[array_rand($channels)];
}
/**
* 가중치 기반 상태/에러 메시지 페어를 반환한다.
*
* @return array{0: NotificationLogStatus, 1: string|null}
*/
protected function randomStatusAndError(): array
{
$rand = mt_rand(1, 100);
if ($rand <= 80) {
return [NotificationLogStatus::Sent, null];
}
if ($rand <= 93) {
return [
NotificationLogStatus::Failed,
$this->errorMessages[array_rand($this->errorMessages)],
];
}
return [
NotificationLogStatus::Skipped,
$this->skipReasons[array_rand($this->skipReasons)],
];
}
/**
* 최근 60일 내 임의 발송 시각을 반환한다.
*
* @param Carbon $now 기준 시각
* @return Carbon 발송 시각
*/
protected function randomSentAt(Carbon $now): Carbon
{
return (clone $now)
->subDays(mt_rand(0, 60))
->subHours(mt_rand(0, 23))
->subMinutes(mt_rand(0, 59))
->subSeconds(mt_rand(0, 59));
}
/**
* 알림 타입이 관리자 수신용인지 (sender_user_id null 처리).
*
* @param string $type 알림 타입
* @return bool 관리자 수신용이면 true
*/
protected function isAdminBoundType(string $type): bool
{
return str_ends_with($type, '_admin');
}
/**
* 알림 타입별 제목 (서브클래스 subjectMap 우선, fallback 은 generic).
*
* @param string $type 알림 타입
* @return string 제목
*/
protected function renderSubject(string $type): string
{
return $this->subjectMap()[$type] ?? "[G7] {$type} 알림";
}
/**
* 알림 타입별 본문 (서브클래스 bodyMap 우선, fallback 은 generic).
*
* @param string $type 알림 타입
* @param User $recipient 수신자
* @param Carbon $sentAt 발송 시각
* @return string 본문
*/
protected function renderBody(string $type, User $recipient, Carbon $sentAt): string
{
$builder = $this->bodyMap()[$type] ?? null;
if ($builder === null) {
return "안녕하세요 {$recipient->name}님,\n\n{$type} 알림이 도착했습니다.";
}
return $builder($recipient, $sentAt);
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace App\Enums;
/**
* 확장(모듈, 플러그인, 템플릿) 비활성화 사유 Enum
*
* `plugins/modules/templates.deactivated_reason` 컬럼의 값 도메인.
* 사용자 수동 비활성화와 시스템 자동 비활성화를 DB 레벨에서 구분하여
* UI 라벨링·알림 영속화·재호환 시 원클릭 복구 판정에 사용합니다.
*/
enum DeactivationReason: string
{
/**
* 관리자가 직접 비활성화
*/
case Manual = 'manual';
/**
* 코어 버전 호환성 검사 실패로 시스템이 자동 비활성화
*/
case IncompatibleCore = 'incompatible_core';
/**
* 사람이 읽을 수 있는 라벨 (i18n key 가 아닌 fallback 한국어)
*/
public function label(): string
{
return match ($this) {
self::Manual => '사용자 수동 비활성화',
self::IncompatibleCore => '코어 버전 호환성',
};
}
/**
* 시스템(자동) 트리거 여부
*
* true 인 경우 알림 영속화 + 원클릭 복구 UX 대상이 됩니다.
*/
public function isSystemTriggered(): bool
{
return match ($this) {
self::Manual => false,
self::IncompatibleCore => true,
};
}
/**
* 모든 값 배열
*
* @return array<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 유효한 값인지 확인
*/
public static function isValid(string $value): bool
{
return in_array($value, self::values(), true);
}
}
+45
View File
@@ -0,0 +1,45 @@
<?php
namespace App\Enums;
/**
* 본인인증 메시지 정의 스코프 Enum.
*
* `identity_message_definitions.scope_type` 에 저장되어 메시지 정의가
* provider 기본 / purpose 별 / policy 별 중 어느 계층에 속하는지 분류합니다.
*
* 기존 `IdentityMessageDefinition::SCOPE_*` 상수를 대체합니다.
*
* @since 7.0.0-beta.5
*/
enum IdentityMessageScopeType: string
{
/** Provider 기본 메시지 */
case ProviderDefault = 'provider_default';
/** Purpose 별 메시지 */
case Purpose = 'purpose';
/** Policy 별 메시지 */
case Policy = 'policy';
/**
* 모든 scope_type 값 배열.
*
* @return array<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 다국어 라벨을 반환합니다.
*
* @return string 번역된 scope_type 라벨
*/
public function label(): string
{
return __('identity.message.scope_type.'.$this->value);
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
namespace App\Enums;
/**
* 본인인증 트리거 출처 유형 Enum.
*
* `identity_verification_logs.origin_type` 에 저장되는 인증 호출 경로 분류.
* 이슈 #297 요구사항 8.3 에 따라 인증이 어디서 시작되었는지 추적합니다.
*
* @since 7.0.0-beta.5
*/
enum IdentityOriginType: string
{
/** 라우트 미들웨어 (가장 흔한 경로) */
case Route = 'route';
/** Service 훅 (예: core.user.before_update) */
case Hook = 'hook';
/** identity_policies 정책 강제 */
case Policy = 'policy';
/** 사용자 정의 미들웨어 */
case Middleware = 'middleware';
/** 클라이언트가 직접 호출한 API */
case Api = 'api';
/** 모듈/플러그인 커스텀 호출 */
case Custom = 'custom';
/** 시스템 자동 (cron 등) */
case System = 'system';
/**
* 모든 origin_type 값 배열.
*
* @return array<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 다국어 라벨을 반환합니다.
*
* @return string 번역된 origin_type 라벨
*/
public function label(): string
{
return __('identity.origin_types.'.$this->value);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Enums;
/**
* 본인인증 정책 적용 대상 사용자 Enum.
*
* `identity_policies.applies_to` 에 저장되어 일반 사용자/관리자 중 어느 쪽에
* 정책을 강제할지 결정합니다.
*
* @since 7.0.0-beta.5
*/
enum IdentityPolicyAppliesTo: string
{
/** 일반 사용자 본인 */
case Self_ = 'self';
/** 관리자 */
case Admin = 'admin';
/** 모두 */
case Both = 'both';
/**
* 모든 applies_to 값 배열.
*
* @return array<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 다국어 라벨을 반환합니다.
*
* @return string 번역된 applies_to 라벨
*/
public function label(): string
{
return __('identity.policy.applies_to.'.$this->value);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
namespace App\Enums;
/**
* 본인인증 정책 실패 시 동작 Enum.
*
* `identity_policies.fail_mode` 에 저장되어 정책 위반 시 요청을 차단할지
* 감사 로그만 남길지 결정합니다.
*
* @since 7.0.0-beta.5
*/
enum IdentityPolicyFailMode: string
{
/** HTTP 428 차단 (정책 강제) */
case Block = 'block';
/** 감사 로그만 기록하고 요청 통과 */
case LogOnly = 'log_only';
/**
* 모든 fail_mode 값 배열.
*
* @return array<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 다국어 라벨을 반환합니다.
*
* @return string 번역된 fail_mode 라벨
*/
public function label(): string
{
return __('identity.policy.fail_mode.'.$this->value);
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
namespace App\Enums;
/**
* 본인인증 정책 적용 범위 Enum.
*
* `identity_policies.scope` 에 저장되어 정책이 어떤 단위에 적용되는지 결정합니다.
* 이슈 #297 에서 scope=route 분기가 강화되었으므로 enum 화로 회귀 방지.
*
* @since 7.0.0-beta.5
*/
enum IdentityPolicyScope: string
{
/** 라우트 패턴 매칭 */
case Route = 'route';
/** Service 훅 매칭 */
case Hook = 'hook';
/** 모듈/플러그인 커스텀 키 */
case Custom = 'custom';
/**
* 모든 scope 값 배열.
*
* @return array<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 다국어 라벨을 반환합니다.
*
* @return string 번역된 scope 라벨
*/
public function label(): string
{
return __('identity.policy.scope.'.$this->value);
}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Enums;
/**
* 본인인증 정책 출처 Enum.
*
* `identity_policies.source_type` 에 저장되어 정책이 코어/모듈/플러그인/관리자
* 중 어디서 등록되었는지 분류합니다. AdminIdentityLogIndexRequest 의 source_type
* 필터에도 동일 분류를 사용합니다.
*
* @since 7.0.0-beta.5
*/
enum IdentityPolicySourceType: string
{
/** 코어 */
case Core = 'core';
/** 모듈 */
case Module = 'module';
/** 플러그인 */
case Plugin = 'plugin';
/** 관리자 직접 등록 */
case Admin = 'admin';
/**
* 모든 source_type 값 배열.
*
* @return array<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 다국어 라벨을 반환합니다.
*
* @return string 번역된 source_type 라벨
*/
public function label(): string
{
return __('identity.policy.source_type.'.$this->value);
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\Enums;
/**
* 본인인증 전송 채널 Enum.
*
* `identity_verification_logs.channel` 에 저장되는 채널 분류.
* 현재 코어는 email 만 제공하며 — sms / ipin / kakao 등은 모듈/플러그인 provider 가
* 자체 식별자로 추가합니다. 모듈 채널은 enum 외부의 string 으로 유지되며,
* 본 enum 은 코어 분류만 보장합니다.
*
* 참고: `identity_message_templates.channel` 컬럼은 메시지 템플릿 도메인 분류로
* (`mail` 등) 별개 의미를 가지므로 본 enum 과 매핑되지 않습니다.
*
* @since 7.0.0-beta.5
*/
enum IdentityVerificationChannel: string
{
/** 이메일 */
case Email = 'email';
/**
* 코어 채널 값 배열.
*
* @return array<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 코어 채널 여부.
*
* @param string $value 검증할 채널 값
* @return bool 코어 채널 여부
*/
public static function isCore(string $value): bool
{
return in_array($value, self::values(), true);
}
/**
* 다국어 라벨을 반환합니다.
*
* @return string 번역된 채널 라벨
*/
public function label(): string
{
return __('identity.channels.'.$this->value);
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace App\Enums;
/**
* 본인인증 코어 purpose Enum.
*
* 코어가 계약으로 보장하는 4종 — `MailIdentityProvider` 가 모두 지원합니다.
* 모듈/플러그인은 `AbstractModule::getIdentityPurposes()` / `AbstractPlugin::getIdentityPurposes()`
* 로 추가 purpose 를 선언할 수 있으며, 그 값은 `IdentityVerificationManager::declaredPurposes`
* 레지스트리에 string 으로 머지됩니다 — 본 enum 에는 코어 4종만 정의합니다.
*
* @since 7.0.0-beta.5
*/
enum IdentityVerificationPurpose: string
{
/** 회원가입 */
case Signup = 'signup';
/** 비밀번호 재설정 */
case PasswordReset = 'password_reset';
/** 본인 정보 변경 */
case SelfUpdate = 'self_update';
/** 민감 작업 (결제 등) */
case SensitiveAction = 'sensitive_action';
/**
* 코어 purpose 값 배열.
*
* @return array<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 코어 정의 purpose 인지 확인합니다 (모듈 declared 는 false).
*
* @param string $value 검증할 purpose 값
* @return bool 코어 purpose 여부
*/
public static function isCore(string $value): bool
{
return in_array($value, self::values(), true);
}
/**
* 다국어 라벨을 반환합니다.
*
* @return string 번역된 purpose 라벨
*/
public function label(): string
{
return __('identity.purposes.'.$this->value.'.label');
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Enums;
/**
* 본인인증 Challenge 생명주기 상태.
*
* 코어 고정 8값 — 로그 분석·모니터링 호환성을 위해 플러그인 확장 불가.
*
* @since 7.0.0-beta.4 (engine-v1.46.0 에서 Processing 추가)
*/
enum IdentityVerificationStatus: string
{
/** Challenge 생성 */
case Requested = 'requested';
/** 발송 완료 (메일·SMS 등) */
case Sent = 'sent';
/**
* 외부 비동기 검증 진행 중.
*
* Stripe Identity / 토스인증 push / 외부 SDK redirect 콜백 등 클라이언트가 즉시 verify 응답을 받지 못하고
* webhook/callback 으로 결과를 기다리는 흐름. 클라이언트는 GET /api/identity/challenges/{id} 폴링으로 상태 추적.
*/
case Processing = 'processing';
/** 검증 성공 */
case Verified = 'verified';
/** 검증 실패 */
case Failed = 'failed';
/** 만료 */
case Expired = 'expired';
/** 사용자/관리자가 취소 */
case Cancelled = 'cancelled';
/** fail_mode=log_only 인 정책이 위반되었으나 요청은 통과한 로그 */
case PolicyViolationLogged = 'policy_violation_logged';
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace App\Enums;
/**
* 언어팩 행 단위 액션 가능 여부 키 Enum.
*
* `LanguagePackResource::abilityMap()` / `LanguagePackCollection::abilityMap()` 가 응답에
* 노출하는 abilities 객체의 키. UI 의 토글/제거/업데이트 버튼 disabled 분기에 사용됩니다.
*/
enum LanguagePackAbility: string
{
/**
* 설치 가능 여부 (미설치 가상 행에서 노출).
*/
case CanInstall = 'can_install';
/**
* 활성화 가능 여부 (status=installed/inactive 에서 가능).
*/
case CanActivate = 'can_activate';
/**
* 비활성화 가능 여부 (status=active 이고 보호 대상 아님).
*/
case CanDeactivate = 'can_deactivate';
/**
* 제거(uninstall) 가능 여부 (DB 행 존재 + 보호 대상 아님).
*/
case CanUninstall = 'can_uninstall';
/**
* 업데이트 가능 여부 (latest_version > version).
*/
case CanUpdate = 'can_update';
/**
* 다국어 라벨을 반환합니다.
*
* @return string 라벨 (lang/{locale}/language_packs.php 의 ability 키)
*/
public function label(): string
{
return __('language_packs.ability.'.$this->value);
}
/**
* 모든 ability 키를 문자열 배열로 반환합니다.
*
* @return array<int, string> ability 키 배열
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 주어진 문자열이 유효한 ability 키인지 확인합니다.
*
* @param string $value 검사할 ability 키
* @return bool 유효 여부
*/
public static function isValid(string $value): bool
{
return in_array($value, self::values(), true);
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
namespace App\Enums;
/**
* 언어팩 의존성/호환성 검사 실패 사유 코드 Enum.
*
* `LanguagePackService::assertDependencies()` / `assertTargetExtensionExists()` 등
* 도메인 검증 함수가 차단 사유를 반환할 때 사용합니다. 컨트롤러는 이 코드를
* 다국어 메시지 키로 변환하여 422 응답에 포함합니다.
*/
enum LanguagePackErrorCode: string
{
/**
* 동일 locale 의 코어 언어팩이 active 상태가 아니어서 모듈/플러그인/템플릿 팩을 활성화할 수 없음.
*/
case CoreLocaleMissing = 'core_locale_missing';
/**
* 대상 확장(모듈/플러그인/템플릿)이 설치되어 있지 않음.
*/
case TargetNotInstalled = 'target_not_installed';
/**
* 대상 확장이 설치되어 있지만 비활성 상태임.
*/
case TargetInactive = 'target_inactive';
/**
* 대상 확장 버전이 manifest 의 `requires.target_version` 제약 미만.
*/
case TargetVersionTooOld = 'target_version_too_old';
/**
* 대상 확장 버전이 manifest 의 `requires.target_version` 제약과 불일치.
*/
case TargetVersionMismatch = 'target_version_mismatch';
/**
* 일반 의존성 누락 (manifest 의 `requires.dependencies` 등).
*/
case DependencyMissing = 'dependency_missing';
/**
* 언어팩 설치 디렉토리(`lang-packs/`, `lang-packs/_pending/`)에 쓰기 권한이 없음.
*
* 웹 서버 사용자(www-data 등)가 디렉토리를 생성/이동할 수 없을 때 발생.
* 사용자에게 chmod 안내 메시지를 함께 표시한다 (모듈/플러그인/템플릿 install 과 동일 수준).
*/
case DirectoryNotWritable = 'directory_not_writable';
/**
* 다국어 라벨/메시지를 반환합니다.
*
* @return string 라벨 (lang/{locale}/language_packs.php 의 error_code 키)
*/
public function label(): string
{
return __('language_packs.error_code.'.$this->value);
}
/**
* 모든 에러 코드 값을 문자열 배열로 반환합니다.
*
* @return array<int, string> 에러 코드 문자열 배열
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 주어진 문자열이 유효한 에러 코드 값인지 확인합니다.
*
* @param string $value 검사할 에러 코드 문자열
* @return bool 유효 여부
*/
public static function isValid(string $value): bool
{
return in_array($value, self::values(), true);
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
namespace App\Enums;
/**
* 언어팩 출처(origin) 분류 Enum.
*
* `LanguagePackSourceType` 가 7가지 세부 소스 (bundled / bundled_with_extension /
* built_in / github / url / upload / zip) 를 갖는 반면, UI 배지·필터 등에서는 사용자
* 관점의 3분류(빌트인 / 번들 / 사용자 설치) 가 더 직관적이다. 본 Enum 은 그 메타
* 그룹핑을 표현하며 `LanguagePackSourceType::fromSourceType()` 1곳에서 매핑한다.
*
* - `built_in` : 코어/번들 확장의 lang/{locale}/ 가상 보호 행 + bundled_with_extension
* - `bundled` : lang-packs/_bundled/{identifier} 독립 패키지
* - `user_installed` : github / url / upload / zip 출처 외부 설치
*/
enum LanguagePackOrigin: string
{
/**
* 코어/번들 확장의 lang/{locale}/ 자원에서 합성된 가상 보호 행 또는 확장과
* 한 몸으로 등록된 bundled_with_extension 행.
*/
case BuiltIn = 'built_in';
/**
* lang-packs/_bundled/{identifier} 의 독립 번들 패키지 (install/uninstall 자유).
*/
case Bundled = 'bundled';
/**
* 사용자가 GitHub URL / 임의 URL / ZIP 업로드로 설치한 외부 언어팩.
*/
case UserInstalled = 'user_installed';
/**
* `LanguagePackSourceType` 으로부터 origin 을 결정합니다.
*
* @param LanguagePackSourceType $source 세부 소스 타입
* @return self 매칭된 origin
*/
public static function fromSourceType(LanguagePackSourceType $source): self
{
return match ($source) {
LanguagePackSourceType::BuiltIn,
LanguagePackSourceType::BundledWithExtension => self::BuiltIn,
LanguagePackSourceType::Bundled => self::Bundled,
LanguagePackSourceType::Github,
LanguagePackSourceType::Url,
LanguagePackSourceType::Upload,
LanguagePackSourceType::Zip => self::UserInstalled,
};
}
/**
* 문자열 source_type 값으로부터 origin 을 결정합니다 (직렬화 단계 편의 메서드).
*
* @param string|null $sourceType source_type 컬럼 값
* @return self|null 매칭된 origin (유효하지 않은 값은 null)
*/
public static function fromSourceTypeValue(?string $sourceType): ?self
{
if ($sourceType === null || ! LanguagePackSourceType::isValid($sourceType)) {
return null;
}
return self::fromSourceType(LanguagePackSourceType::from($sourceType));
}
/**
* 모든 origin 값을 문자열 배열로 반환합니다.
*
* @return array<int, string> origin 문자열 배열
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 다국어 라벨을 반환합니다.
*
* @return string 라벨 (lang/{locale}/language_packs.php 의 origin 키)
*/
public function label(): string
{
return __('language_packs.origin.'.$this->value);
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace App\Enums;
/**
* 언어팩 적용 대상 분류 Enum.
*
* core 는 G7 코어 자체에 적용되며 target_identifier 가 null 이고,
* module/plugin/template 은 대상 확장 식별자(target_identifier)가 필수입니다.
*/
enum LanguagePackScope: string
{
/**
* 코어 (G7 본체) 적용.
*/
case Core = 'core';
/**
* 모듈 적용 (target_identifier = 모듈 식별자).
*/
case Module = 'module';
/**
* 플러그인 적용 (target_identifier = 플러그인 식별자).
*/
case Plugin = 'plugin';
/**
* 템플릿 적용 (target_identifier = 템플릿 식별자).
*/
case Template = 'template';
/**
* 모든 스코프 값을 문자열 배열로 반환합니다.
*
* @return array<int, string> 스코프 문자열 배열
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 주어진 문자열이 유효한 스코프 값인지 확인합니다.
*
* @param string $value 검사할 스코프 문자열
* @return bool 유효 여부
*/
public static function isValid(string $value): bool
{
return in_array($value, self::values(), true);
}
/**
* 현재 스코프가 코어인지 확인합니다.
*
* @return bool 코어이면 true
*/
public function isCore(): bool
{
return $this === self::Core;
}
/**
* 현재 스코프가 target_identifier 를 필요로 하는지 확인합니다.
*
* 코어 외 스코프(module/plugin/template)는 대상 확장 식별자가 필수입니다.
*
* @return bool target_identifier 필요 여부
*/
public function requiresTarget(): bool
{
return ! $this->isCore();
}
}
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace App\Enums;
/**
* 언어팩 소스 타입 Enum.
*
* 언어팩이 어디로부터 등록되었는지를 분류합니다. 보호 정책 결정과 업데이트 우선순위
* (GitHub 1순위 + bundled 폴백, force 시 bundled 1순위) 결정에 사용됩니다.
*/
enum LanguagePackSourceType: string
{
/**
* `lang-packs/_bundled/` 의 독립 패키지 (사용자가 install/uninstall/activate/deactivate 자유).
*/
case Bundled = 'bundled';
/**
* 모듈/플러그인/템플릿 디렉토리의 `lang/{locale}/` 자원 (확장과 한 몸, 부모 lifecycle 종속).
*/
case BundledWithExtension = 'bundled_with_extension';
/**
* 코어/번들 확장의 `lang/{ko,en}/` 가상 보호 행 (DB 등록 없이 디렉토리 스캔으로 합성).
*/
case BuiltIn = 'built_in';
/**
* GitHub 저장소 URL 로부터 설치된 외부 언어팩.
*/
case Github = 'github';
/**
* 임의 URL 로부터 다운로드된 외부 언어팩.
*/
case Url = 'url';
/**
* 사용자가 ZIP 파일 업로드로 설치한 외부 언어팩.
*/
case Upload = 'upload';
/**
* ZIP 파일 (Upload 와 동일한 의미로 사용되는 별칭).
*/
case Zip = 'zip';
/**
* 모든 소스 타입 값을 문자열 배열로 반환합니다.
*
* @return array<int, string> 소스 타입 문자열 배열
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 주어진 문자열이 유효한 소스 타입 값인지 확인합니다.
*
* @param string $value 검사할 소스 타입 문자열
* @return bool 유효 여부
*/
public static function isValid(string $value): bool
{
return in_array($value, self::values(), true);
}
/**
* 다국어 라벨을 반환합니다.
*
* @return string 라벨 (lang/{locale}/language_packs.php 의 source_type 키)
*/
public function label(): string
{
return __('language_packs.source_type.'.$this->value);
}
/**
* 본 소스 타입이 사용자 직접 설치 출처인지 (ZIP/GitHub/URL/Upload) 판정합니다.
*
* @return bool 사용자 외부 설치 출처면 true
*/
public function isExternal(): bool
{
return match ($this) {
self::Github, self::Url, self::Upload, self::Zip => true,
default => false,
};
}
/**
* 본 소스 타입이 보호 대상 (수정/제거 차단) 인지 판정합니다.
*
* `BundledWithExtension` 과 `BuiltIn` 은 확장 본체에 종속되므로 보호.
* `Bundled` 은 사용자가 install/uninstall 자유.
*
* @return bool 보호 대상이면 true
*/
public function isProtectedByDefault(): bool
{
return match ($this) {
self::BundledWithExtension, self::BuiltIn => true,
default => false,
};
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Enums;
/**
* 언어팩 상태 Enum.
*
* 슬롯(scope, target_identifier, locale) 당 active 상태는 1개만 허용됩니다.
* 동일 슬롯에 다른 벤더 언어팩이 존재할 때 active 외의 후보는 inactive/installed 상태로 보관됩니다.
*/
enum LanguagePackStatus: string
{
/**
* 설치 완료 상태 (슬롯의 다른 후보가 이미 active 이거나 활성화 대기 중).
*/
case Installed = 'installed';
/**
* 활성 상태 (슬롯당 1개만 허용).
*/
case Active = 'active';
/**
* 비활성 상태 (파일 보존, 번역 미적용).
*/
case Inactive = 'inactive';
/**
* 업데이트 진행 중 상태.
*/
case Updating = 'updating';
/**
* 오류 상태 (manifest 검증 실패, 대상 확장 미존재 등).
*/
case Error = 'error';
/**
* 미설치 상태 (`lang-packs/_bundled/{identifier}` 에만 존재하고 DB 레코드 없음).
*
* 모듈/플러그인 관리의 `not_installed` 와 동일한 개념으로, 목록 행에서 "설치"
* 버튼을 노출하기 위한 가상 상태. DB 컬럼 enum 후보가 아니라 응답 전용 값입니다.
*/
case Uninstalled = 'uninstalled';
/**
* 모든 상태 값을 문자열 배열로 반환합니다.
*
* @return array<int, string> 상태 문자열 배열
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 주어진 문자열이 유효한 상태 값인지 확인합니다.
*
* @param string $value 검사할 상태 문자열
* @return bool 유효 여부
*/
public static function isValid(string $value): bool
{
return in_array($value, self::values(), true);
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
namespace App\Enums;
/**
* 텍스트 방향 Enum.
*
* 언어팩 manifest 의 `text_direction` 필드와 DB `language_packs.text_direction` 컬럼이
* 사용합니다. RTL 언어(아랍어, 히브리어 등) 지원을 위한 값.
*/
enum TextDirection: string
{
/**
* 좌→우 (대부분의 언어).
*/
case Ltr = 'ltr';
/**
* 우→좌 (아랍어, 히브리어 등).
*/
case Rtl = 'rtl';
/**
* 다국어 라벨을 반환합니다.
*
* @return string 라벨 (lang/{locale}/language_packs.php 의 text_direction 키)
*/
public function label(): string
{
return __('language_packs.text_direction.'.$this->value);
}
/**
* 모든 방향 값을 문자열 배열로 반환합니다.
*
* @return array<int, string> 방향 문자열 배열
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
/**
* 주어진 문자열이 유효한 방향 값인지 확인합니다.
*
* @param string $value 검사할 방향 문자열
* @return bool 유효 여부
*/
public static function isValid(string $value): bool
{
return in_array($value, self::values(), true);
}
}
+6
View File
@@ -29,6 +29,11 @@ enum UserStatus: string
*/
case Withdrawn = 'withdrawn';
/**
* 본인인증 대기 (Mode C — 가입 직후 비활성, IDV 통과 후 활성화)
*/
case PendingVerification = 'pending_verification';
/**
* 모든 상태 값을 문자열 배열로 반환합니다.
*
@@ -82,6 +87,7 @@ enum UserStatus: string
self::Inactive => 'secondary',
self::Blocked => 'danger',
self::Withdrawn => 'warning',
self::PendingVerification => 'info',
};
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Exceptions\Auth;
use Illuminate\Support\Carbon;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* 계정 잠금 예외 — 보안 환경설정의 `max_login_attempts` 도달 후
* `login_lockout_time` 분 동안 로그인 시도를 차단할 때 발생합니다.
*
* HTTP 423 Locked 응답으로 매핑되며, 프론트엔드 토스트는 다국어 키
* `auth.account_locked` 로 잔여 분(`minutes` 플레이스홀더)을 노출합니다.
*
* 컨트롤러는 본 예외를 별도로 catch 하여 ResponseHelper 응답을 만들거나,
* 글로벌 예외 핸들러가 자동으로 423 JSON 응답으로 변환합니다.
*
* @since 7.0.0
*/
class AccountLockedException extends HttpException
{
public function __construct(
public readonly Carbon $lockedUntil,
public readonly int $remainingMinutes,
?string $message = null,
) {
parent::__construct(
423,
$message ?? 'auth.account_locked',
null,
['Retry-After' => max(1, $remainingMinutes * 60)]
);
}
}
@@ -1,22 +0,0 @@
<?php
namespace App\Exceptions;
use Exception;
/**
* 관리자 계정 삭제 시도 시 발생하는 예외
*
* 관리자 역할을 가진 계정은 시스템에서 삭제할 수 없으며,
* 이 예외는 삭제 시도를 방지합니다.
*/
class CannotDeleteAdminException extends Exception
{
/**
* 관리자 계정 삭제 시도 시 예외를 생성합니다.
*/
public function __construct()
{
parent::__construct(__('user.delete_admin_forbidden'));
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Exceptions;
use RuntimeException;
/**
* 낙관적 잠금 충돌 예외 — 동일 행에 대해 두 클라이언트가 동시에 저장을 시도해
* `lock_version` 이 기대값과 일치하지 않을 때 발생합니다. 컨트롤러는 이 예외를
* 잡아 409 Conflict 응답으로 변환하고, 프론트엔드는 "다른 사용자가 먼저
* 저장했습니다" 안내 모달을 표시합니다.
*/
class ConcurrentModificationException extends RuntimeException
{
public function __construct(
public readonly int $currentVersion,
public readonly int $expectedVersion,
public readonly string $resource,
) {
parent::__construct(__('exceptions.concurrent_modification', [
'resource' => $resource,
'current' => $currentVersion,
'expected' => $expectedVersion,
]));
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Exceptions;
use RuntimeException;
use Throwable;
/**
* 코어 업데이트 흐름에서 발생하는 운영 오류 예외.
*
* 다국어 키 + 파라미터를 보존하여 컨트롤러/리스너가 원본 키를 활용 가능. 코어
* 업데이트의 다운로드/검증/추출/composer 실행/소유권 복원 등 단계에서 회복 불가
* 한 실패가 발생할 때 사용한다.
*/
class CoreUpdateOperationException extends RuntimeException
{
/**
* @param string $errorKey 다국어 키 (예: 'settings.core_update.zip_file_not_found')
* @param array<string, mixed> $params 메시지 파라미터
* @param Throwable|null $previous 원인 예외
*/
public function __construct(
public readonly string $errorKey,
public readonly array $params = [],
?Throwable $previous = null,
) {
parent::__construct(__($errorKey, $params), 0, $previous);
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Exceptions;
/**
* 확장 코어 버전 호환성 검사 실패.
*
* `update*`/`activate*`/원클릭 복구 등 확장 매니저가 코어 버전 호환성 사전 검증에서
* 실패할 때 던집니다. 글로벌 Handler 가 HTTP 422 + `error_code: 'core_version_mismatch'`
* 응답으로 매핑하여 프론트가 일관된 toast/배너/모달 안내를 표시할 수 있게 합니다.
*
* **부모 클래스 선택**: 의도적으로 `\Error` 를 상속한다 (IDV 예외와 동일 패턴).
*
* 이유: 코어/모듈/플러그인 컨트롤러 다수가 `try { ... } catch (\Exception $e) { ... }`
* catch-all 로 자체 응답 변환을 수행한다. 본 예외가 `\Exception` 자식이면 그 catch-all
* 에 포획되어 generic 422 로 강등 → `error_code` 와 구조화 payload 가 모두 사라져
* 프론트가 일관된 안내(toast/배너/모달)를 표시할 수 없다. `\Error` 는 PHP 의 `\Exception`
* 과 별도 계층이므로 catch-all 을 통과하여 글로벌 render() 콜백으로 도달한다.
*
* 명시 catch 가 필요한 호출자는 `catch (CoreVersionMismatchException $e)` 또는
* `catch (\Throwable $e)` 로 잡을 수 있다.
*
* @since 7.0.0-beta.4
*/
class CoreVersionMismatchException extends \Error
{
/**
* @param string $extensionType 확장 타입 (module|plugin|template)
* @param string $identifier 확장 식별자
* @param string $requiredCoreVersion 요구된 코어 버전 제약
* @param string $currentCoreVersion 현재 설치된 코어 버전
* @param string $message 사람이 읽을 수 있는 메시지 (이미 번역된 문자열)
*/
public function __construct(
public readonly string $extensionType,
public readonly string $identifier,
public readonly string $requiredCoreVersion,
public readonly string $currentCoreVersion,
string $message = '',
) {
parent::__construct($message ?: __('extensions.errors.core_version_mismatch', [
'extension' => $identifier,
'type' => __('extensions.types.'.$extensionType),
'required' => $requiredCoreVersion,
'installed' => $currentCoreVersion,
]));
}
/**
* 응답 payload 빌더.
*
* @return array{extension_type: string, identifier: string, required_core_version: string, current_core_version: string, guide_url: string}
*/
public function getPayload(): array
{
return [
'extension_type' => $this->extensionType,
'identifier' => $this->identifier,
'required_core_version' => $this->requiredCoreVersion,
'current_core_version' => $this->currentCoreVersion,
'guide_url' => '/admin/core/update',
];
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Exceptions;
/**
* 정책 위반 — 본인인증 필요.
*
* 미들웨어/Listener 가 정책 매칭 후 던지며, 글로벌 Handler 가 HTTP 428 (Precondition Required) 응답으로 매핑합니다.
* 프론트 `ErrorHandlingResolver` 가 이 상태코드 + error_code 를 감지해 자동으로 Challenge 모달을 열고
* return_request 를 재실행합니다.
*
* **부모 클래스 선택 (CRITICAL)**: 의도적으로 `\Error` 를 상속한다.
*
* 이유: 코어/모듈/플러그인 컨트롤러 23+ 곳이 `try { ... } catch (\Exception $e) { ... }` 패턴으로
* 자체 응답 변환을 하는데, IDV 예외가 `\Exception` 자식이면 그 catch-all 에 포획되어 422 일반
* 에러로 강등 → 프론트 IdentityGuardInterceptor 가 모달을 띄우지 못한다.
* `\Error` 는 PHP 의 `\Exception` 과 별도 계층이므로 `catch (\Exception)` 으로 잡히지 않으며,
* Laravel 글로벌 핸들러의 `render(Throwable)` 콜백은 `\Throwable` 으로 받아 정상 428 매핑한다.
*
* 즉 어떤 라우트 (코어/모듈/플러그인) 에서 어느 catch-all 패턴이 있어도 IDV 흐름은 항상 글로벌
* 핸들러까지 도달한다 — 라우트별 안전망 코드 작성 불필요.
*
* 예외적으로 명시 catch 가 필요한 호출자는 `catch (IdentityVerificationRequiredException $e)`
* 또는 `catch (\Throwable $e)` 로 잡을 수 있다 (테스트의 expectException 도 정상 작동).
*
* @since 7.0.0-beta.4
*/
class IdentityVerificationRequiredException extends \Error
{
/**
* @param string $policyKey 매칭된 정책 식별자 (identity_policies.key)
* @param string $purpose 요구되는 IDV purpose
* @param string|null $providerId 특정 provider 강제 시 id, null 이면 기본
* @param string|null $renderHint 프론트 렌더 힌트
* @param array|null $returnRequest 재실행할 원 요청 정보 (method/url/headers_echo)
*/
public function __construct(
public readonly string $policyKey,
public readonly string $purpose,
public readonly ?string $providerId = null,
public readonly ?string $renderHint = null,
public readonly ?array $returnRequest = null,
string $message = 'identity.errors.verification_required',
) {
parent::__construct($message);
}
public function getPayload(): array
{
return [
'policy_key' => $this->policyKey,
'purpose' => $this->purpose,
'provider_id' => $this->providerId,
'render_hint' => $this->renderHint,
'challenge_start_url' => '/api/identity/challenges',
'return_request' => $this->returnRequest,
];
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Exceptions;
use RuntimeException;
use Throwable;
/**
* 언어팩 설치/활성화/업데이트/검증 흐름에서 발생하는 운영 오류 예외.
*
* 다국어 키 + 파라미터를 보존하여 컨트롤러/리스너가 원본 키를 활용 가능합니다.
* `LanguagePackSlotConflictException` 와 같이 의미가 분리된 예외는 별도 클래스로 유지하고,
* 본 클래스는 일반 운영 흐름의 단일 메시지 throw 캡슐화에 사용합니다.
*/
class LanguagePackOperationException extends RuntimeException
{
/**
* @param string $errorKey 다국어 키 (예: 'language_packs.errors.download_failed')
* @param array<string, mixed> $params 메시지 파라미터
* @param Throwable|null $previous 원인 예외
*/
public function __construct(
public readonly string $errorKey,
public readonly array $params = [],
?Throwable $previous = null,
) {
parent::__construct(__($errorKey, $params), 0, $previous);
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Exceptions;
use App\Models\LanguagePack;
use RuntimeException;
/**
* 같은 슬롯(scope + target_identifier + locale)에 이미 활성 언어팩이 있을 때
* `force=false` 로 활성화를 시도하면 발생합니다. 컨트롤러는 이 예외를 잡아
* 409 응답으로 변환하고, 프론트엔드는 사용자에게 교체 확인 모달을 띄웁니다.
*/
class LanguagePackSlotConflictException extends RuntimeException
{
public function __construct(
public readonly LanguagePack $current,
public readonly LanguagePack $target,
) {
parent::__construct(__('language_packs.errors.slot_conflict', [
'current' => $current->identifier,
'target' => $target->identifier,
]));
}
}

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