v7.0.0-beta.4 release

This commit is contained in:
HeuJung
2026-05-11 11:29:41 +09:00
parent 05db17887d
commit 1db039ff34
1545 changed files with 136783 additions and 11634 deletions
+1 -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.3
APP_VERSION=7.0.0-beta.4
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
+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.3
APP_VERSION=7.0.0-beta.4
APP_LOCALE=ko
APP_FALLBACK_LOCALE=ko
+16
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/
@@ -106,3 +120,5 @@ id_ed25519
*.sql
*.sqlite
*.db
.claude/tmp/
+51 -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,7 +97,7 @@
| [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/) (29개)
| 문서 | 설명 | TL;DR 핵심 |
|------|------|-----------|
@@ -95,28 +106,32 @@
| [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... |
| [upgrade-step-guide.md](docs/extension/upgrade-step-guide.md) | 업그레이드 스텝 작성 가이드 (Upgrade Step Guide) | upgrade step 이 실행되는 환경은 경로에 따라 다르다 — 섹션 9 "업그레이드 경로" 먼저 읽기 |
| [vendor-bundle.md](docs/extension/vendor-bundle.md) | Vendor 번들 시스템 (Vendor Bundle System) | - |
### 공통 (5개)
### 공통 (4개)
| 문서 | 설명 | TL;DR 핵심 |
|------|------|-----------|
@@ -124,7 +139,6 @@
| [database-guide.md](docs/database-guide.md) | 그누보드7 데이터베이스 개발 가이드 | 마이그레이션: 한국어 comment 필수, down() 구현 필수 |
| [requirements.md](docs/requirements.md) | 그누보드7 시스템 요구사항 (System Requirements) | PHP 8.2+ 필수 |
| [testing-guide.md](docs/testing-guide.md) | 그누보드7 테스트 가이드 | 테스트 통과 = 작업 완료 (작성만으로 불충분!) |
| [auto-document.md](.claude/docs/auto-document.md) | 자동 문서화 (auto-document) | - |
<!-- AUTO-GENERATED-END: docs-quick-reference -->
@@ -176,6 +190,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 +248,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)
@@ -395,17 +437,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 레이아웃 렌더링 테스트
@@ -824,6 +867,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) |
+224
View File
@@ -4,6 +4,230 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [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" 만 입력 가능
#### 웹 인스톨러 — 번들 언어팩 동반 선택 · 설치
- 인스톨러 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 수준에서 구분 — 자동 비활성화된 확장만 재호환 감지/원클릭 복구 대상이 되도록 분리
- 코어 업그레이드 후 자동 비활성화 확장이 다시 호환되면 관리자 대시보드에 "다시 활성화" 알림 표시 + 원클릭 복구 버튼 제공 (자동 재활성화는 하지 않음 — 운영자가 명시적으로 복구)
- 모듈/플러그인/템플릿 목록 화면 상단에 자동 비활성화 확장 안내 배너 추가 (코어 업그레이드 가이드 링크 동반)
- 업데이트 모달에 코어 버전 호환성 안내 + "위험을 이해하고 강제로 진행" 체크박스 추가 — 운영자가 위험을 인지하면 비호환 확장도 강제 설치 가능
- 관리자 대시보드 "시스템 알림" 카드 노출 — 코어 호환성 자동 비활성화/재호환 알림이 분기 렌더되며 개별 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 프롬프트에 동일 규칙 적용
- 비밀번호 재설정 정책 기본값을 비활성으로 변경 — 본인인증 인프라가 미구성된 사이트에서도 기본 동작이 영향받지 않도록 운영자 opt-in 으로 전환
- 본인인증 정책의 인증 조건(`conditions`) 운영자 편집 허용 — 회원가입 단계 등 정책 조건을 코드 수정 없이 관리자 화면에서 조정 가능. 모듈 업데이트 시 운영자 수정값 보존
- 토큰 만료 등으로 권한 없는 레이아웃 진입 시 "페이지 로딩 실패" 에러 화면 대신 로그인 페이지로 자동 이동 — 로그인 화면에서 "세션이 만료되었습니다. 다시 로그인해 주세요." 토스트로 사용자에게 안내. 템플릿이 자체 로그인 경로를 사용하는 경우 부트스트랩에서 인증 설정을 커스터마이즈할 수 있는 공개 API 도 함께 제공
- 템플릿 다국어 데이터 로딩 시 활성 언어팩의 다국어가 가장 높은 우선순위로 병합되도록 변경
- 권한·역할·메뉴·알림 등 코어 기본 데이터와 배송유형·클레임 사유·게시판 유형 등 모듈 기본 데이터에 활성 언어팩의 다국어가 자동 반영되도록 개선
- 사용자 수정 보존(user_overrides) 정책을 다국어 JSON 컬럼은 sub-key 단위(`name.ko` 등) 로 기록하도록 개선 — 운영자가 한 언어 라벨만 수정해도 그 언어만 보존되며, 신규 활성 언어팩(예: 일본어 추가) 의 라벨은 자동 동기화됨. 기존 컬럼 단위(`name`) 기록은 업그레이드 시 활성 locale dot-path 로 자동 변환
- 언어팩 활성/비활성 시점에 영향받는 모듈/플러그인의 entity 시더가 자동 재실행되어 신규 언어 라벨이 즉시 DB 에 반영되도록 라이프사이클 통합 — scope 별 라우팅 (코어 언어팩 → 모든 활성 확장, 모듈 언어팩 → 해당 모듈만, 플러그인 언어팩 → 해당 플러그인만)
- 환경설정 → SEO → "추가 봇 패턴" 필드의 역할 변경 — 기존에는 유일한 봇 매칭 소스(기본 5종)였으나, 이제는 라이브러리가 놓치는 조직별 커스텀 봇만 추가하는 보강 레이어로 동작. 기존 설치의 운영자 커스텀 값은 모두 보존되며, 신규 설치 기본값은 jaybizzle 미커버 3종으로 변경
### Security
- 회원가입·비밀번호 재설정 라우트에 본인인증 정책 강제 미들웨어 부착 — 정책이 활성화된 경우 미인증 요청을 라우트 단계에서 차단
- 플러그인이 정책 해석 필터 훅에서 잘못된 타입을 반환해도 원본 정책이 유지되도록 우회 차단 강화
- 웹 인스톨러의 Composer/PHP 바이너리 경로 검증에서 사용자 입력이 그대로 shell 명령으로 실행될 수 있던 문제 수정 — 입력은 실행 가능한 단일 파일 경로로만 허용하고 모든 분기에서 인자 escape 강제. 설치 워커가 동일한 입력을 사용하던 내부 헬퍼도 같은 정책으로 정렬
- 설치 단계 4 의 확장 기능 선택 API 가 사용자가 보낸 모듈/플러그인/템플릿/언어팩 식별자에 셸 메타문자 검증을 적용하도록 강화 — 부적절한 식별자는 400 응답으로 거부되어 이후 설치 명령에 도달하지 않음
- 인스톨러의 코어 업데이트 _pending 경로 검증이 `..` 등 부모 디렉토리 우회 시도를 거부하고 응답 메시지를 단일화하여 임의 디렉토리 enumeration 신호 차단
- 설치 시 `.env` 작성 헬퍼가 입력값에 포함된 개행 문자를 제거하도록 변경 — DB 비밀번호 등 사용자 입력으로 새로운 환경 변수 라인이 주입되는 시나리오 차단
- 데이터베이스 연결 정보의 host/port/database 값에 DSN 키-밸류 구분자(`;`, `=`) 또는 NUL/CRLF 가 포함되면 연결을 거부하도록 추가 검증
- 설치가 완료된 시스템에서 `public/install/` 하위 모든 엔드포인트가 비즈니스 로직 진입 전 HTTP 410 으로 차단되도록 공통 가드 도입 — 운영 환경에서 인스톨러 노출형 결함의 공격 표면 제거. 운영자가 인스톨러를 다시 사용해야 하는 경우 설치 완료 마커(`storage/app/g7_installed`) 와 `.env` 의 `INSTALLER_COMPLETED` 를 모두 제거
### 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)
- 코어 업그레이드 후 새 버전에서 추가된 권한·메뉴·알림 정의가 등록되지 않아 관리자 화면에서 "해당 권한이 없습니다" 가 반복 표시되거나 신규 메일 템플릿이 비어있던 문제 수정 — 업그레이드 시 새 버전 설정 파일을 정확히 인식하도록 보정. 본 릴리즈로 업그레이드하면 누락분이 자동 등록됨
## [7.0.0-beta.3] - 2026-04-23
### Fixed
+9 -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.3 g7
# (필요 시) mv g7-7.0.0-beta.4 g7
# ZIP 파일 정리 (선택)
rm g7-release.zip
+85 -7
View File
@@ -8,7 +8,7 @@
</p>
<p align="center">
<a href="#"><img src="https://img.shields.io/badge/version-7.0.0--beta.3-blue" alt="Version"></a>
<a href="#"><img src="https://img.shields.io/badge/version-7.0.0--beta.4-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>
@@ -43,16 +43,19 @@ Laravel과 React를 기반으로, 보안부터 아키텍처까지 처음부터
| 영역 | 설명 |
|------|------|
| **모듈 아키텍처** | 모듈 + 플러그인 + 템플릿 3중 확장 구조. 코어 수정 없이 독립적 모듈(게시판, 커머스 등) 개발이 가능합니다. Hook 기반 기능 주입으로 Service-Repository 패턴의 명확한 계층 분리를 유지합니다 |
| **현지화** | 백엔드부터 프론트엔드까지 일관된 다국어 개발 환경을 제공합니다. 로케일 기반 UI, 확장 가능한 언어 팩을 지원합니다 |
| **언어팩 시스템** | 새 언어를 코어 수정 없이 ZIP 또는 GitHub URL 로 설치할 수 있습니다. 일본어 등 공식 번들 언어팩을 즉시 사용할 수 있고, 운영자가 직접 수정한 라벨은 언어팩이 덮어쓰지 않도록 sub-key 단위로 보존합니다. 모듈/플러그인/템플릿 단위로 별도 적용 가능 |
| **현지화** | 백엔드부터 프론트엔드까지 일관된 다국어 개발 환경을 제공합니다. 활성 언어팩이 알림 채널 라벨, Provider/Registry 페이로드, 환경설정 카탈로그(결제수단·통화·배송 가능 국가)까지 자동 보강되며, 모듈/플러그인이 자기 도메인 라벨을 자기 영역에서 자기설명하도록 활동 로그·메시지 영역도 분리되어 있습니다 |
| **해외 결제** | 로컬 비즈니스를 넘어 글로벌 커머스로 도약하기 위한 기반을 제공합니다 `정식버전에서 지원예정` |
| **권한 제어** | 역할별 메뉴와 기능, 데이터 범위까지 제어할 수 있습니다. 역할(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
@@ -294,6 +301,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)
---
## 빠른 시작
@@ -349,6 +398,35 @@ 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-marketing-ja** | 마케팅 플러그인 일본어 |
| **g7-plugin-sirsoft-tosspayments-ja** | 토스페이먼츠 플러그인 일본어 |
| **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 리스트 연동 |
---
## 비즈니스 모델
@@ -433,7 +511,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";
}
}
@@ -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}");
@@ -4,6 +4,7 @@ namespace App\Console\Commands\Core;
use App\Console\Commands\Core\Concerns\BundledExtensionUpdatePrompt;
use App\Exceptions\UpgradeHandoffException;
use App\Console\Commands\Traits\HasUnifiedConfirm;
use App\Extension\CoreVersionChecker;
use App\Extension\Helpers\CoreBackupHelper;
use App\Extension\ModuleManager;
@@ -18,6 +19,7 @@ use Illuminate\Support\Facades\Log;
class CoreUpdateCommand extends Command
{
use BundledExtensionUpdatePrompt;
use HasUnifiedConfirm;
protected $signature = 'core:update
{--force : 버전 비교 없이 강제 업데이트}
@@ -180,7 +182,7 @@ class CoreUpdateCommand extends Command
}
$this->newLine();
if (! $this->confirm('코어를 업데이트하시겠습니까?')) {
if (! $this->unifiedConfirm('코어를 업데이트하시겠습니까?', false)) {
return Command::SUCCESS;
}
@@ -285,6 +287,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
@@ -337,13 +355,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 ──
@@ -395,8 +420,11 @@ class CoreUpdateCommand extends Command
$service->clearAllCaches();
// 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);
@@ -438,10 +466,13 @@ 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('일괄 확장 업데이트 후 소유권 재복원 완료');
}
@@ -479,7 +510,9 @@ class CoreUpdateCommand extends Command
try {
$service->updateVersionInEnv($toVersion);
$service->clearAllCaches();
$service->restoreOwnership($ownershipSnapshot, $onProgress);
// Stage 4 — handoff cleanup 도 detailed snapshot 으로 정확 복원
$service->restoreOwnership($ownershipSnapshot, $onProgress, $detailedOwnershipSnapshot);
$this->surfacePermissionWarnings($service, $log);
$log('핸드오프 cleanup 완료 (버전 toVersion 고정 + 캐시 clear + 소유권 복원)');
if (! empty($pendingPath)) {
@@ -822,4 +855,44 @@ class CoreUpdateCommand extends Command
'owner_user' => $ownerUser,
]);
}
/**
* `restoreOwnership()` 직후 누적된 권한 정상화 실패 경고를 콘솔/로그에 즉시 노출합니다.
*
* 운영자가 sudo 환경 결함(파일시스템 ACL, immutable 비트, NFS 권한 거부 등) 으로
* 일부 경로 chown / chmod 실패 시 그 경로와 운영자 수동 복구 명령을 즉시 보여준다.
* 본 메서드 호출 후 service 의 `lastPermissionWarnings` 가 다음 호출 시 초기화되므로
* 매 `restoreOwnership` 직후 1회 호출 패턴이 정합.
*
* @param CoreUpdateService $service
* @param callable $log 내부 로그 누적 콜백 (`saveUpdateLog` 입력용)
* @return void
*/
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,177 @@
<?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;
}
}
@@ -46,6 +46,51 @@ class ExecuteUpgradeStepsCommand extends Command
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 동작 회피)
try {
app(\App\Extension\ExtensionManager::class)->updateComposerAutoload();
} catch (\Throwable $e) {
\Illuminate\Support\Facades\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/* 보정)
try {
$writablePaths = (array) config('app.update.restore_ownership_group_writable', []);
if (! empty($writablePaths)) {
$service->ensureWritableDirectories(
$writablePaths,
function (string $level, string $msg): void {
// 콘솔 + upgrade 로그 채널 동시 출력 — PO 가 단일 파일(upgrade.log)에서
// spawn 자식의 권한 정상화 진행을 추적할 수 있도록 양쪽 모두 누적.
$this->{$level === 'warning' ? 'warn' : 'info'}($msg);
\Illuminate\Support\Facades\Log::channel('upgrade')->$level('[spawn] '.$msg);
},
);
}
} catch (\Throwable $e) {
\Illuminate\Support\Facades\Log::warning('upgrade step spawn 자식: ensureWritableDirectories 호출 실패', [
'error' => $e->getMessage(),
]);
}
try {
$service->runUpgradeSteps(
$from,
@@ -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
{
@@ -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,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\Extension\ModuleManager;
use App\Extension\Vendor\VendorMode;
@@ -12,6 +13,7 @@ use Illuminate\Support\Facades\Log;
class UpdateModuleCommand extends Command
{
use HasProgressBar;
use HasUnifiedConfirm;
/**
* The name and signature of the console command.
@@ -121,7 +123,7 @@ class UpdateModuleCommand extends Command
$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;
@@ -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,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\Extension\PluginManager;
use App\Extension\Vendor\VendorMode;
@@ -12,6 +13,7 @@ use Illuminate\Support\Facades\Log;
class UpdatePluginCommand extends Command
{
use HasProgressBar;
use HasUnifiedConfirm;
/**
* The name and signature of the console command.
@@ -120,7 +122,7 @@ class UpdatePluginCommand extends Command
$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;
@@ -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,93 @@
<?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;
/**
* 프론트가 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;
}
@@ -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;
/**
* 지정된 모듈을 시스템에서 제거합니다.
@@ -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;
/**
* 템플릿의 의존성을 검증합니다.
@@ -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,129 @@
<?php
namespace App\Contracts\Repositories;
use App\Models\IdentityPolicy;
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)
* @return IdentityPolicy|null
*/
public function findByKey(string $key): ?IdentityPolicy;
/**
* id 로 조회합니다.
*
* @param int $id 정책 PK
* @return IdentityPolicy|null
*/
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;
/**
* 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(확장) 가 등록한 정책 개수를 반환합니다.
*
* 모듈/플러그인 uninstall 모달의 "삭제될 데이터" 표시에 사용.
*
* @param string $sourceType 'core' | 'module' | 'plugin' | 'admin'
* @param string $sourceIdentifier 확장 식별자
* @return int
*/
public function countBySource(string $sourceType, string $sourceIdentifier): int;
/**
* 목록 조회 (관리자 S1d DataGrid).
*
* @param array<string, mixed> $filters 필터 조건
* @param int $perPage 페이지 크기
* @return \Illuminate\Contracts\Pagination\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;
}
@@ -193,4 +193,13 @@ interface ModuleRepositoryInterface
* @return Collection 해당 플러그인에 의존하는 활성 모듈 컬렉션
*/
public function findActiveByPluginDependency(string $pluginIdentifier): Collection;
/**
* 코어 버전 비호환으로 자동 비활성화된 모듈을 조회합니다.
*
* `deactivated_reason = 'incompatible_core'` 인 레코드만 반환합니다.
*
* @return Collection 자동 비활성화된 모듈 컬렉션
*/
public function findAutoDeactivated(): Collection;
}
@@ -157,4 +157,14 @@ interface PluginRepositoryInterface
* @return array 활성화된 플러그인 identifier 배열
*/
public function getActivePluginIdentifiers(): array;
/**
* 코어 버전 비호환으로 자동 비활성화된 플러그인을 조회합니다.
*
* `deactivated_reason = 'incompatible_core'` 인 레코드만 반환합니다.
* 알림 영속화 + 재호환 판정 + 상단 배너 데이터 소스로 사용됩니다.
*
* @return Collection 자동 비활성화된 플러그인 컬렉션
*/
public function findAutoDeactivated(): 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;
}
@@ -126,4 +126,13 @@ interface TemplateRepositoryInterface
* @return Collection 해당 플러그인에 의존하는 활성 템플릿 컬렉션
*/
public function findActiveByPluginDependency(string $pluginIdentifier): Collection;
/**
* 코어 버전 비호환으로 자동 비활성화된 템플릿을 조회합니다.
*
* `deactivated_reason = 'incompatible_core'` 인 레코드만 반환합니다.
*
* @return Collection 자동 비활성화된 템플릿 컬렉션
*/
public function findAutoDeactivated(): Collection;
}
@@ -93,4 +93,57 @@ 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;
/**
* 사용자의 연속 로그인 실패 카운터를 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 \Illuminate\Support\Carbon 잠금 해제 시각
*/
public function lockAccount(User $user, int $minutes): \Illuminate\Support\Carbon;
/**
* 사용자의 모든 로그인 시도 추적 컬럼을 초기화합니다.
*
* 정상 로그인 성공 시 호출됩니다 (`failed_login_attempts=0`,
* `locked_until=null`, `last_failed_login_at=null`).
*
* @param User $user 대상 사용자
* @return void
*/
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,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,
]));
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Exceptions;
use RuntimeException;
use Throwable;
/**
* 모듈 설치/활성화/업데이트 흐름에서 발생하는 운영 오류 예외.
*
* 다국어 키 + 파라미터를 보존하여 컨트롤러/리스너가 원본 키를 활용 가능합니다.
*/
class ModuleOperationException extends RuntimeException
{
/**
* @param string $errorKey 다국어 키 (예: 'modules.errors.install_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,27 @@
<?php
namespace App\Exceptions;
use RuntimeException;
use Throwable;
/**
* 플러그인 설치/활성화/업데이트 흐름에서 발생하는 운영 오류 예외.
*
* 다국어 키 + 파라미터를 보존하여 컨트롤러/리스너가 원본 키를 활용 가능합니다.
*/
class PluginOperationException extends RuntimeException
{
/**
* @param string $errorKey 다국어 키 (예: 'plugins.errors.install_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,29 @@
<?php
namespace App\Exceptions;
use RuntimeException;
use Throwable;
/**
* 템플릿 설치/업데이트 흐름에서 발생하는 운영 오류 예외.
*
* 다국어 키 + 파라미터를 보존하여 컨트롤러/리스너가 원본 키를 활용 가능합니다.
* `TemplateNotFoundException` / `TemplateFileCopyException` 등 의미가 분리된 예외는
* 별도 클래스로 유지하고, 본 클래스는 일반 운영 흐름의 단일 메시지 throw 캡슐화에 사용합니다.
*/
class TemplateOperationException extends RuntimeException
{
/**
* @param string $errorKey 다국어 키 (예: 'templates.errors.install_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);
}
}
+250
View File
@@ -153,6 +153,8 @@ abstract class AbstractModule implements ModuleInterface
* 모듈 설치
*
* 모듈 개발자가 설치 시 추가 작업이 필요한 경우 오버라이드
*
* @return bool 성공 여부
*/
public function install(): bool
{
@@ -163,6 +165,8 @@ abstract class AbstractModule implements ModuleInterface
* 모듈 제거
*
* 모듈 개발자가 제거 시 추가 작업이 필요한 경우 오버라이드
*
* @return bool 성공 여부
*/
public function uninstall(): bool
{
@@ -188,6 +192,8 @@ abstract class AbstractModule implements ModuleInterface
* 모듈 활성화
*
* 모듈 개발자가 활성화 시 추가 작업이 필요한 경우 오버라이드
*
* @return bool 성공 여부
*/
public function activate(): bool
{
@@ -198,6 +204,8 @@ abstract class AbstractModule implements ModuleInterface
* 모듈 비활성화
*
* 모듈 개발자가 비활성화 시 추가 작업이 필요한 경우 오버라이드
*
* @return bool 성공 여부
*/
public function deactivate(): bool
{
@@ -275,6 +283,8 @@ abstract class AbstractModule implements ModuleInterface
*
* 기본적으로 src/routes/api.php, src/routes/web.php를 반환
* 파일이 존재하는 경우에만 포함
*
* @return array<string, string> 라우트 키 => 파일 경로 매핑
*/
public function getRoutes(): array
{
@@ -300,6 +310,8 @@ abstract class AbstractModule implements ModuleInterface
*
* 기본적으로 database/migrations 디렉토리를 반환
* 디렉토리가 존재하는 경우에만 포함
*
* @return array<string> 마이그레이션 디렉토리 경로 배열
*/
public function getMigrations(): array
{
@@ -317,6 +329,8 @@ abstract class AbstractModule implements ModuleInterface
*
* 기본적으로 빈 배열 반환
* 모듈 개발자가 뷰가 필요한 경우 오버라이드
*
* @return array<string> 뷰 디렉토리 경로 배열
*/
public function getViews(): array
{
@@ -428,6 +442,8 @@ abstract class AbstractModule implements ModuleInterface
*
* 기본적으로 빈 배열 반환
* 모듈 개발자가 설정이 필요한 경우 오버라이드
*
* @return array 모듈 설정 배열
*/
public function getConfig(): array
{
@@ -439,6 +455,8 @@ abstract class AbstractModule implements ModuleInterface
*
* 기본적으로 빈 배열 반환
* 모듈 개발자가 관리자 메뉴가 필요한 경우 오버라이드
*
* @return array 관리자 메뉴 정의 배열
*/
public function getAdminMenus(): array
{
@@ -475,6 +493,8 @@ abstract class AbstractModule implements ModuleInterface
*
* 기본적으로 빈 배열 반환
* 모듈 개발자가 훅 리스너가 필요한 경우 오버라이드
*
* @return array 훅 리스너 정의 배열
*/
public function getHookListeners(): array
{
@@ -530,6 +550,173 @@ abstract class AbstractModule implements ModuleInterface
return [];
}
/**
* Declarative i18n getter family — 모듈이 선언하는 다국어/SSoT 데이터 4종.
*
* 모두 default `[]` 반환 (override 미선택 시 무영향). 각 메서드 결과는 `ModuleManager` 가
* activate/update 시 자동 동기화하며, lang pack 활성 시 다국어 키 보강 필터를 통해 ja/en 등 추가
* 로케일이 자동 주입됩니다 (audit 룰 `seeder-translation-filter` 가 hook 호출 발화 보장).
*
* | 메서드 | 도메인 | 동기화 위치 | lang pack 필터 |
* |--------------------------------|---------------|---------------------------------|--------------------------------------------------|
* | `getNotificationDefinitions()` | 알림 정의 | `notification_definitions` | `seed.{id}.notifications.translations` |
* | `getIdentityMessages()` | IDV 메일 | `identity_message_definitions` | `seed.{id}.identity_messages.translations` |
* | `getIdentityPolicies()` | IDV 정책 | `identity_policies` | (lang pack seed 대상 외 — 다국어 필드 부재) |
* | `getIdentityPurposes()` | IDV 목적 | 메모리 레지스트리 (DB 없음) | (lang pack seed 대상 외 — `label_key` 참조) |
*
* 신규 i18n SSoT 도메인 추가 시 동일 패턴(meta 4-요소: declarative getter + Manager sync +
* applyFilters + Injector 메서드) 을 따라야 하며, audit 룰 `core-config-lang-pack-seed-coverage`
* 와 `module-getter-lang-pack-coverage` 가 정합성을 자동 검증합니다.
*
* @see getIdentityPolicies()
* @see getIdentityPurposes()
* @see getIdentityMessages()
* @see getNotificationDefinitions()
*/
/**
* 이 모듈이 등록할 IDV(본인인증) 정책 선언을 반환합니다.
*
* 반환된 정책은 `ModuleManager` 가 activate/update 시
* `IdentityPolicySyncHelper::syncPolicy()` 로 DB(identity_policies) 에 동기화하며,
* deactivate/uninstall 시 `cleanupStalePolicies()` 로 정리합니다.
*
* `source_type` / `source_identifier` 는 Manager 가 자동 주입하므로 반환 배열에 포함하지 않습니다.
* 운영자가 관리자 UI 에서 수정한 필드(`enabled` / `grace_minutes` / `provider_id` / `fail_mode`)
* 는 `user_overrides` JSON 으로 보존됩니다.
*
* @return array<int, array{
* key: string,
* scope: string,
* target: string,
* purpose: string,
* provider_id?: string|null,
* grace_minutes?: int,
* enabled?: bool,
* priority?: int,
* applies_to?: string,
* fail_mode?: string,
* conditions?: array<string, mixed>
* }>
*/
public function getIdentityPolicies(): array
{
return [];
}
/**
* 이 모듈이 등록할 IDV(본인인증) 목적(purpose) 선언을 반환합니다.
*
* DB 에 저장되지 않는 **코드 계약** 입니다. 활성화된 모듈의 getter 결과를
* `IdentityVerificationManager` 가 부팅 시 런타임 레지스트리에 병합하며,
* `core.identity.purposes` filter 훅으로도 서드파티 동적 등록을 수용합니다.
*
* 새 purpose 는 이를 지원하는 Provider 와 challenge 로직이 함께 제공되어야 동작합니다.
* Provider 없이 purpose 만 선언하면 관리자 UI 에는 노출되나 실제 challenge 는 실패합니다.
*
* @return array<string, array{
* label: string|array,
* description?: string|array,
* default_provider?: string|null,
* allowed_channels?: string[]
* }>
*/
public function getIdentityPurposes(): array
{
return [];
}
/**
* 이 모듈이 등록할 IDV(본인인증) 메시지 정의/템플릿 선언을 반환합니다.
*
* `getIdentityPolicies()` / `getIdentityPurposes()` 와 동일한 패턴으로
* `ModuleManager` 가 activate/update 시 `IdentityMessageSyncHelper` 를 통해
* `identity_message_definitions` / `identity_message_templates` 테이블에 동기화하며,
* uninstall(deleteData=true) 시 자동 정리됩니다.
*
* 운영자가 관리자 UI 에서 수정한 필드(name/description/subject/body 등) 는
* `user_overrides` JSON 으로 보존됩니다.
*
* `extension_type='module'`, `extension_identifier=$this->getIdentifier()` 는
* Manager 가 자동 주입하므로 반환 배열에 포함하지 않습니다.
*
* 반환 형식 예:
* ```php
* return [
* [
* 'provider_id' => 'g7:core.mail',
* 'scope_type' => IdentityMessageDefinition::SCOPE_PURPOSE,
* 'scope_value' => 'checkout_verification',
* 'name' => ['ko' => '결제 시 본인 확인', 'en' => 'Checkout Verification'],
* 'description' => ['ko' => '...', 'en' => '...'],
* 'channels' => ['mail'],
* 'variables' => [['key' => 'code', 'description' => '인증 코드']],
* 'templates' => [
* [
* 'channel' => 'mail',
* 'subject' => ['ko' => '...', 'en' => '...'],
* 'body' => ['ko' => '...', 'en' => '...'],
* ],
* ],
* ],
* ];
* ```
*
* scope_type 권장:
* - `IdentityMessageDefinition::SCOPE_PURPOSE` — purpose 단위 메시지 (해당 purpose 트리거 시 우선)
* - `IdentityMessageDefinition::SCOPE_POLICY` — 특정 policy_key 전용 메시지 (가장 구체적, purpose 보다 우선)
*
* @return array<int, array<string, mixed>>
*/
public function getIdentityMessages(): array
{
return [];
}
/**
* 이 모듈이 등록할 알림 정의/템플릿 선언을 반환합니다.
*
* `getIdentityMessages()` 와 동일한 패턴으로 `ModuleManager` 가 activate/update 시
* `NotificationSyncHelper::syncDefinition()` + `syncTemplate()` 으로 upsert 하고,
* 현재 선언에 없는 기존 정의는 `cleanupStaleDefinitions()` 로 정리합니다.
* uninstall(deleteData=true) 시에도 자동 정리됩니다.
*
* 운영자가 관리자 UI 에서 수정한 필드(name/description/subject/body/recipients 등) 는
* `user_overrides` JSON 으로 보존됩니다.
*
* `extension_type='module'`, `extension_identifier=$this->getIdentifier()` 는
* Manager 가 자동 주입하므로 반환 배열에 포함하지 않습니다 (포함되어 있으면 덮어씀).
*
* 반환 형식 예:
* ```php
* return [
* [
* 'type' => 'order_confirmed',
* 'hook_prefix' => 'sirsoft-ecommerce',
* 'name' => ['ko' => '주문 확인', 'en' => 'Order Confirmed'],
* 'description' => ['ko' => '...', 'en' => '...'],
* 'channels' => ['mail', 'database'],
* 'hooks' => ['sirsoft-ecommerce.order.after_confirm'],
* 'variables' => [['key' => 'order_number', 'description' => '주문번호']],
* 'templates' => [
* [
* 'channel' => 'mail',
* 'recipients' => [['type' => 'trigger_user']],
* 'subject' => ['ko' => '...', 'en' => '...'],
* 'body' => ['ko' => '...', 'en' => '...'],
* ],
* ],
* ],
* ];
* ```
*
* @return array<int, array<string, mixed>>
*/
public function getNotificationDefinitions(): array
{
return [];
}
/**
* 모듈 설치 시 실행할 시더 클래스 목록 반환
*
@@ -649,11 +836,27 @@ abstract class AbstractModule implements ModuleInterface
return $this->loadManifest()['license'] ?? null;
}
/**
* 관리자 UI 에서 숨김 여부 반환
*
* module.json 의 hidden 필드가 true 면 관리자 모듈 목록(/api/admin/modules) 에서 기본 제외됩니다.
* artisan CLI, 설치/제거, 업데이트 감지는 영향을 받지 않습니다.
* 학습용 샘플 모듈, 내부 운영용 모듈 등에 사용합니다.
*
* @return bool 숨김 여부 (기본값: false)
*/
public function isHidden(): bool
{
return (bool) ($this->loadManifest()['hidden'] ?? false);
}
/**
* 모듈 메타데이터 반환
*
* 기본적으로 빈 배열 반환
* 모듈 개발자가 메타데이터가 필요한 경우 오버라이드
*
* @return array 메타데이터 배열
*/
public function getMetadata(): array
{
@@ -751,6 +954,53 @@ abstract class AbstractModule implements ModuleInterface
return [];
}
/**
* 페이지 타입별 OG 메타태그 기본값 선언
*
* 모듈이 자기 도메인 데이터로부터 og:image, og:image:width/height,
* og:type, og:product:price 같은 도메인별 OG 태그를 직접 만들어 제공합니다.
* 레이아웃 meta.seo.og 가 같은 키를 선언하면 그쪽이 우선 (override).
*
* @param string $pageType 레이아웃 meta.seo.page_type (예: 'product', 'category', 'post')
* @param array $context DataSourceResolver 결과 + _seo 주입된 컨텍스트
* @param array $routeParams URL 라우트 파라미터
* @return array OG 데이터 (type, image, image_width, image_height, image_secure_url,
* image_type, image_alt, site_name, locale, extra)
*/
public function seoOgDefaults(string $pageType, array $context, array $routeParams = []): array
{
return [];
}
/**
* 페이지 타입별 Twitter 카드 기본값 선언
*
* @param string $pageType 페이지 타입
* @param array $context 컨텍스트
* @param array $routeParams 라우트 파라미터
* @return array Twitter 카드 데이터 (card, site, creator, title, description, image, image_alt, extra)
*/
public function seoTwitterDefaults(string $pageType, array $context, array $routeParams = []): array
{
return [];
}
/**
* 페이지 타입별 JSON-LD 구조화 데이터 선언
*
* 모듈이 자기 도메인 스키마(Product/Article/Event 등 Schema.org 타입)를
* 직접 owned. 레이아웃 meta.seo.structured_data 가 비어있을 때 적용.
*
* @param string $pageType 페이지 타입
* @param array $context 컨텍스트
* @param array $routeParams 라우트 파라미터
* @return array Schema.org 형식 (@type 필수). 빈 배열 반환 시 미적용.
*/
public function seoStructuredData(string $pageType, array $context, array $routeParams = []): array
{
return [];
}
/**
* 그누보드7 코어 요구 버전 제약 반환
*
+199
View File
@@ -155,6 +155,8 @@ abstract class AbstractPlugin implements PluginInterface
* 플러그인 설치
*
* 플러그인 개발자가 설치 시 추가 작업이 필요한 경우 오버라이드
*
* @return bool 성공 여부
*/
public function install(): bool
{
@@ -165,6 +167,8 @@ abstract class AbstractPlugin implements PluginInterface
* 플러그인 제거
*
* 플러그인 개발자가 제거 시 추가 작업이 필요한 경우 오버라이드
*
* @return bool 성공 여부
*/
public function uninstall(): bool
{
@@ -190,6 +194,8 @@ abstract class AbstractPlugin implements PluginInterface
* 플러그인 활성화
*
* 플러그인 개발자가 활성화 시 추가 작업이 필요한 경우 오버라이드
*
* @return bool 성공 여부
*/
public function activate(): bool
{
@@ -200,6 +206,8 @@ abstract class AbstractPlugin implements PluginInterface
* 플러그인 비활성화
*
* 플러그인 개발자가 비활성화 시 추가 작업이 필요한 경우 오버라이드
*
* @return bool 성공 여부
*/
public function deactivate(): bool
{
@@ -277,6 +285,8 @@ abstract class AbstractPlugin implements PluginInterface
*
* 기본적으로 src/routes/api.php, src/routes/web.php를 반환
* 파일이 존재하는 경우에만 포함
*
* @return array<string, string> 라우트 키 => 파일 경로 매핑
*/
public function getRoutes(): array
{
@@ -302,6 +312,8 @@ abstract class AbstractPlugin implements PluginInterface
*
* 기본적으로 database/migrations 디렉토리를 반환
* 디렉토리가 존재하는 경우에만 포함
*
* @return array<string> 마이그레이션 디렉토리 경로 배열
*/
public function getMigrations(): array
{
@@ -319,6 +331,8 @@ abstract class AbstractPlugin implements PluginInterface
*
* 기본적으로 빈 배열 반환
* 플러그인 개발자가 뷰가 필요한 경우 오버라이드
*
* @return array<string> 뷰 디렉토리 경로 배열
*/
public function getViews(): array
{
@@ -423,6 +437,8 @@ abstract class AbstractPlugin implements PluginInterface
*
* 기본적으로 빈 배열 반환
* 플러그인 개발자가 훅이 필요한 경우 오버라이드
*
* @return array 훅 정의 배열
*/
public function getHooks(): array
{
@@ -434,6 +450,8 @@ abstract class AbstractPlugin implements PluginInterface
*
* 기본적으로 빈 배열 반환
* 플러그인 개발자가 훅 리스너가 필요한 경우 오버라이드
*
* @return array 훅 리스너 정의 배열
*/
public function getHookListeners(): array
{
@@ -489,6 +507,125 @@ abstract class AbstractPlugin implements PluginInterface
return [];
}
/**
* 이 플러그인이 등록할 IDV(본인인증) 정책 선언을 반환합니다.
*
* 반환된 정책은 `PluginManager` 가 activate/update 시
* `IdentityPolicySyncHelper::syncPolicy()` 로 DB(identity_policies) 에 동기화하며,
* deactivate/uninstall 시 `cleanupStalePolicies()` 로 정리합니다.
*
* `source_type` / `source_identifier` 는 Manager 가 자동 주입하므로 반환 배열에 포함하지 않습니다.
* 운영자가 관리자 UI 에서 수정한 필드(`enabled` / `grace_minutes` / `provider_id` / `fail_mode`)
* 는 `user_overrides` JSON 으로 보존됩니다.
*
* @return array<int, array{
* key: string,
* scope: string,
* target: string,
* purpose: string,
* provider_id?: string|null,
* grace_minutes?: int,
* enabled?: bool,
* priority?: int,
* applies_to?: string,
* fail_mode?: string,
* conditions?: array<string, mixed>
* }>
*/
public function getIdentityPolicies(): array
{
return [];
}
/**
* 이 플러그인이 등록할 IDV(본인인증) 목적(purpose) 선언을 반환합니다.
*
* DB 에 저장되지 않는 **코드 계약** 입니다. 활성화된 플러그인의 getter 결과를
* `IdentityVerificationManager` 가 부팅 시 런타임 레지스트리에 병합하며,
* `core.identity.purposes` filter 훅으로도 서드파티 동적 등록을 수용합니다.
*
* 새 purpose 는 이를 지원하는 Provider 와 challenge 로직이 함께 제공되어야 동작합니다.
* Provider 없이 purpose 만 선언하면 관리자 UI 에는 노출되나 실제 challenge 는 실패합니다.
*
* @return array<string, array{
* label: string|array,
* description?: string|array,
* default_provider?: string|null,
* allowed_channels?: string[]
* }>
*/
public function getIdentityPurposes(): array
{
return [];
}
/**
* 이 플러그인이 등록할 IDV(본인인증) 메시지 정의/템플릿 선언을 반환합니다.
*
* `getIdentityPolicies()` / `getIdentityPurposes()` 와 동일한 패턴으로
* `PluginManager` 가 activate/update 시 `IdentityMessageSyncHelper` 를 통해
* `identity_message_definitions` / `identity_message_templates` 테이블에 동기화하며,
* uninstall(deleteData=true) 시 자동 정리됩니다.
*
* 운영자가 관리자 UI 에서 수정한 필드(name/description/subject/body 등) 는
* `user_overrides` JSON 으로 보존됩니다.
*
* `extension_type='plugin'`, `extension_identifier=$this->getIdentifier()` 는
* Manager 가 자동 주입하므로 반환 배열에 포함하지 않습니다.
*
* 반환 형식: AbstractModule::getIdentityMessages() 와 동일.
*
* @return array<int, array<string, mixed>>
*/
public function getIdentityMessages(): array
{
return [];
}
/**
* 이 플러그인이 등록할 알림 정의/템플릿 선언을 반환합니다.
*
* `getIdentityMessages()` 와 동일한 패턴으로 `PluginManager` 가 activate/update 시
* `NotificationSyncHelper::syncDefinition()` + `syncTemplate()` 으로 upsert 하고,
* 현재 선언에 없는 기존 정의는 `cleanupStaleDefinitions()` 로 정리합니다.
* uninstall(deleteData=true) 시에도 자동 정리됩니다.
*
* 운영자가 관리자 UI 에서 수정한 필드(name/description/subject/body/recipients 등) 는
* `user_overrides` JSON 으로 보존됩니다.
*
* `extension_type='plugin'`, `extension_identifier=$this->getIdentifier()` 는
* Manager 가 자동 주입하므로 반환 배열에 포함하지 않습니다 (포함되어 있으면 덮어씀).
*
* 반환 형식 예:
* ```php
* return [
* [
* 'type' => 'plugin_event',
* 'hook_prefix' => 'sirsoft-payment',
* 'name' => ['ko' => '결제 알림', 'en' => 'Payment'],
* 'description' => ['ko' => '...', 'en' => '...'],
* 'channels' => ['mail', 'database'],
* 'hooks' => ['sirsoft-payment.after_charge'],
* 'variables' => [['key' => 'amount', 'description' => '결제 금액']],
* 'templates' => [
* [
* 'channel' => 'mail',
* 'recipients' => [['type' => 'trigger_user']],
* 'subject' => ['ko' => '...', 'en' => '...'],
* 'body' => ['ko' => '...', 'en' => '...'],
* ],
* ],
* ],
* ];
* ```
*
* @return array<int, array<string, mixed>>
*/
public function getNotificationDefinitions(): array
{
return [];
}
/**
* 플러그인 설치 시 실행할 시더 클래스 목록 반환
*
@@ -544,11 +681,27 @@ abstract class AbstractPlugin implements PluginInterface
return $this->loadManifest()['license'] ?? null;
}
/**
* 관리자 UI 에서 숨김 여부 반환
*
* plugin.json 의 hidden 필드가 true 면 관리자 플러그인 목록(/api/admin/plugins) 에서 기본 제외됩니다.
* artisan CLI, 설치/제거, 업데이트 감지는 영향을 받지 않습니다.
* 학습용 샘플 플러그인, 내부 운영용 플러그인 등에 사용합니다.
*
* @return bool 숨김 여부 (기본값: false)
*/
public function isHidden(): bool
{
return (bool) ($this->loadManifest()['hidden'] ?? false);
}
/**
* 플러그인 메타데이터 반환
*
* 기본적으로 빈 배열 반환
* 플러그인 개발자가 메타데이터가 필요한 경우 오버라이드
*
* @return array 메타데이터 배열
*/
public function getMetadata(): array
{
@@ -646,6 +799,52 @@ abstract class AbstractPlugin implements PluginInterface
return [];
}
/**
* 페이지 타입별 OG 메타태그 기본값 선언
*
* 플러그인이 자기 도메인 데이터로부터 og:image, og:type 등 도메인별 OG 태그를
* 직접 만들어 제공합니다. 레이아웃 meta.seo.og 가 같은 키를 선언하면 그쪽이 우선.
*
* @param string $pageType 레이아웃 meta.seo.page_type
* @param array $context 컨텍스트 (DataSourceResolver 결과 + _seo)
* @param array $routeParams 라우트 파라미터
* @return array OG 데이터 (type, image, image_width, image_height, image_secure_url,
* image_type, image_alt, site_name, locale, extra)
*/
public function seoOgDefaults(string $pageType, array $context, array $routeParams = []): array
{
return [];
}
/**
* 페이지 타입별 Twitter 카드 기본값 선언
*
* @param string $pageType 페이지 타입
* @param array $context 컨텍스트
* @param array $routeParams 라우트 파라미터
* @return array Twitter 카드 데이터 (card, site, creator, title, description, image, image_alt, extra)
*/
public function seoTwitterDefaults(string $pageType, array $context, array $routeParams = []): array
{
return [];
}
/**
* 페이지 타입별 JSON-LD 구조화 데이터 선언
*
* 플러그인이 자기 도메인 스키마(Schema.org @type) 를 직접 owned.
* 레이아웃 meta.seo.structured_data 가 비어있을 때 적용.
*
* @param string $pageType 페이지 타입
* @param array $context 컨텍스트
* @param array $routeParams 라우트 파라미터
* @return array Schema.org 형식 (@type 필수). 빈 배열 반환 시 미적용.
*/
public function seoStructuredData(string $pageType, array $context, array $routeParams = []): array
{
return [];
}
/**
* 그누보드7 코어 요구 버전 제약 반환
*
@@ -0,0 +1,105 @@
<?php
namespace App\Extension\Concerns;
use App\Contracts\Repositories\IdentityMessageDefinitionRepositoryInterface;
use App\Contracts\Repositories\IdentityPolicyRepositoryInterface;
use App\Contracts\Repositories\MenuRepositoryInterface;
use App\Contracts\Repositories\NotificationDefinitionRepositoryInterface;
use App\Contracts\Repositories\PermissionRepositoryInterface;
use App\Enums\ExtensionOwnerType;
use Illuminate\Support\Facades\Log;
/**
* 코어 공유 테이블에 적재된 확장(모듈/플러그인) 영역의 데이터 레코드 조회 trait.
*
* `ModuleManager::getModuleUninstallInfo()` / `PluginManager::getPluginUninstallInfo()` 가
* 공통으로 사용. uninstall(deleteData=true) 시 cleanup 되는 영역만 표시 — 사용자가
* 모달에서 "삭제될 데이터" 를 정확히 파악하도록 함.
*
* 감사 성격 데이터(`identity_verification_logs`, `notification_logs` 등) 는 보존되므로
* 본 목록에 포함하지 않음 — uninstall cleanup 동작과 정합.
*
* 새 공유 테이블이 추가되면 sharedRecordResolvers() 메서드에 한 항목만 추가.
*
* 모든 데이터 접근은 RepositoryInterface 를 경유 (Service-Repository 패턴 준수).
*
* @since 7.0.0-beta.4
*/
trait ResolvesExtensionSharedRecords
{
/**
* 코어 공유 테이블 레지스트리.
*
* 각 entry 는 `[label_key, resolver]` 2-튜플 — resolver 는 (extensionType, identifier) 받아
* 해당 확장이 차지한 row 개수를 반환하는 callable. label_key 는 모달 lang 키 매칭용.
*
* 신규 공유 테이블 추가 시 본 메서드에만 새 항목 추가.
*
* @return array<int, array{0: string, 1: callable(string, string): int}>
*/
protected function sharedRecordResolvers(): array
{
return [
['permissions', fn (string $type, string $id): int => app(PermissionRepositoryInterface::class)
->getByExtension(ExtensionOwnerType::from($type), $id)
->count(),
],
['menus', fn (string $type, string $id): int => app(MenuRepositoryInterface::class)
->getMenusByExtension(ExtensionOwnerType::from($type), $id)
->count(),
],
['notification_definitions', fn (string $type, string $id): int => app(NotificationDefinitionRepositoryInterface::class)
->getByExtension($type, $id)
->count(),
],
['identity_policies', fn (string $type, string $id): int => app(IdentityPolicyRepositoryInterface::class)
->countBySource($type, $id),
],
['identity_message_definitions', fn (string $type, string $id): int => app(IdentityMessageDefinitionRepositoryInterface::class)
->getByExtension($type, $id)
->count(),
],
];
}
/**
* 코어 공유 테이블에 적재된 확장 영역 레코드 정보를 조회합니다.
*
* 0건인 항목은 결과에서 제외 (모달에서 빈 항목 노이즈 회피).
* Repository/Schema 예외는 경고 로그만 남기고 skip — uninstall 모달이 부분적으로라도
* 동작하도록 보장 (예: 마이그레이션 미실행 환경).
*
* @param string $extensionType 'module' 또는 'plugin'
* @param string $extensionIdentifier 확장 식별자
* @return array<int, array{table: string, label_key: string, count: int}>
*/
protected function resolveExtensionSharedRecords(string $extensionType, string $extensionIdentifier): array
{
$records = [];
foreach ($this->sharedRecordResolvers() as [$labelKey, $resolver]) {
try {
$count = (int) $resolver($extensionType, $extensionIdentifier);
} catch (\Throwable $e) {
Log::warning('확장 공유 레코드 조회 실패', [
'label' => $labelKey,
'extension' => "{$extensionType}/{$extensionIdentifier}",
'error' => $e->getMessage(),
]);
continue;
}
if ($count === 0) {
continue;
}
$records[] = [
'table' => $labelKey,
'label_key' => $labelKey,
'count' => $count,
];
}
return $records;
}
}
+9 -6
View File
@@ -3,6 +3,7 @@
namespace App\Extension;
use App\Contracts\Extension\CacheInterface;
use App\Exceptions\CoreVersionMismatchException;
use App\Extension\Cache\CoreCacheDriver;
use Composer\Semver\Semver;
use Exception;
@@ -49,6 +50,8 @@ class CoreVersionChecker
*
* 규정 예외: "env() 는 config 파일에서만 사용" 규칙의 본문 예외. 정당성은 버전 판정이
* config cache 우회를 요구하기 때문이다.
*
* @return string 코어 버전 문자열 (예: "7.0.0-beta.4")
*/
public static function getCoreVersion(): string
{
@@ -100,12 +103,12 @@ class CoreVersionChecker
}
if (! self::satisfies($requiredVersion)) {
throw new Exception(__('extensions.errors.core_version_mismatch', [
'extension' => $identifier,
'type' => __('extensions.types.'.$type),
'required' => $requiredVersion,
'installed' => self::getCoreVersion(),
]));
throw new CoreVersionMismatchException(
$type,
$identifier,
$requiredVersion,
self::getCoreVersion(),
);
}
return true;
+86 -3
View File
@@ -540,6 +540,91 @@ PHP;
return implode('\\', $namespace);
}
/**
* FQCN 으로부터 등록된 확장(모듈/플러그인) 식별자를 추론합니다.
*
* `directoryToNamespace()` 의 역변환. PSR-4 prefix `Modules\` / `Plugins\` 의
* Vendor\Name 두 세그먼트를 kebab-case 식별자로 환원합니다.
*
* 예시:
* - 'Modules\Sirsoft\Ecommerce\Models\Order' → 'sirsoft-ecommerce'
* - 'Plugins\Sirsoft\Payment\Services\PaymentService' → 'sirsoft-payment'
* - 'Modules\Sirsoft\DaumPostcode\Models\Address' → 'sirsoft-daum_postcode'
* - 'App\Models\User' → null (코어)
*
* 등록 여부는 검증하지 않습니다 — 호출 측이 lang 파일 존재 여부로 fallback 처리합니다.
*
* @param string $fqcn 클래스 FQCN
* @return string|null 모듈/플러그인 identifier, 코어/미해석 시 null
*/
public static function resolveExtensionByFqcn(string $fqcn): ?string
{
static $cache = [];
$key = ltrim($fqcn, '\\');
if (array_key_exists($key, $cache)) {
return $cache[$key];
}
$cache[$key] = self::doResolveExtensionByFqcn($key);
return $cache[$key];
}
/**
* resolveExtensionByFqcn 의 캐시되지 않은 본 구현.
*
* @param string $fqcn ltrim 된 FQCN
* @return string|null 식별자 또는 null
*/
protected static function doResolveExtensionByFqcn(string $fqcn): ?string
{
if ($fqcn === '') {
return null;
}
if (str_starts_with($fqcn, 'Modules\\')) {
return self::namespaceTailToIdentifier(substr($fqcn, strlen('Modules\\')));
}
if (str_starts_with($fqcn, 'Plugins\\')) {
return self::namespaceTailToIdentifier(substr($fqcn, strlen('Plugins\\')));
}
return null;
}
/**
* `Vendor\Name\...` 꼬리에서 `vendor-name` 식별자를 추출합니다.
*
* @param string $tail 접두 (Modules\ / Plugins\) 제거 후의 FQCN 꼬리
* @return string|null 식별자 또는 null
*/
protected static function namespaceTailToIdentifier(string $tail): ?string
{
$parts = explode('\\', $tail);
if (count($parts) < 2 || $parts[0] === '' || $parts[1] === '') {
return null;
}
return self::pascalToKebabSegment($parts[0]).'-'.self::pascalToKebabSegment($parts[1]);
}
/**
* 단일 PascalCase 세그먼트를 snake_case (단어 경계 `_` 사용) 로 변환합니다.
*
* `directoryToNamespace()` 의 역연산:
* - 'Ecommerce' → 'ecommerce'
* - 'DaumPostcode' → 'daum_postcode'
*
* @param string $pascal PascalCase 단일 세그먼트
* @return string snake_case 단일 세그먼트
*/
protected static function pascalToKebabSegment(string $pascal): string
{
return strtolower((string) preg_replace('/(?<!^)([A-Z])/', '_$1', $pascal));
}
/**
* 확장 식별자의 형식을 검증합니다.
*
@@ -920,9 +1005,7 @@ PHP;
return $pharPath;
}
throw new \RuntimeException(
'Composer 바이너리를 찾을 수 없습니다. COMPOSER_BINARY 환경변수를 설정하거나 composer를 PATH에 추가하세요.'
);
throw new \RuntimeException(__('exceptions.extension.composer_binary_not_found'));
}
/**
@@ -18,7 +18,8 @@ use Illuminate\Support\Facades\Log;
*
* user_overrides 컬럼에서 유저가 수정한 필드명 목록을 읽어,
* 해당 필드는 건너뛰고 나머지만 갱신합니다.
* parent_id와 is_active는 항상 확장 정의값으로 업데이트됩니다.
* parent_id 는 항상 확장 정의값으로 업데이트됩니다.
* is_active 는 확장 정의의 `is_active` 값을 따르며 (기본 true), user_overrides 에 등록되었으면 보존됩니다.
*/
class ExtensionMenuSyncHelper
{
@@ -54,7 +55,7 @@ class ExtensionMenuSyncHelper
$existing = $this->menuRepository->findBySlugAndExtension($slug, $extensionType, $extensionIdentifier);
if (! $existing) {
// 신규 생성
// 신규 생성 — 정의의 is_active 값을 그대로 채택 (기본 true)
$menu = $this->menuRepository->updateOrCreate(
[
'slug' => $slug,
@@ -67,7 +68,7 @@ class ExtensionMenuSyncHelper
'icon' => $newAttributes['icon'] ?? null,
'order' => $newAttributes['order'] ?? 0,
'parent_id' => $parentId,
'is_active' => true,
'is_active' => (bool) ($newAttributes['is_active'] ?? true),
]
);
@@ -77,27 +78,51 @@ class ExtensionMenuSyncHelper
return $menu;
}
// 기존 메뉴 업데이트: user_overrides에 없는 필드만 갱신
// 기존 메뉴 업데이트: user_overrides 에 없는 필드만 갱신.
//
// user_overrides 형식 호환: 컬럼명 단위(`'name'`, legacy) + dot-path sub-key 단위
// (`'name.ko'`, `'name.en'`, beta.4 도입). 다국어 컬럼은 어느 형태로든 마킹되어
// 있으면 컬럼 전체를 보존 대상으로 간주한다 (간소화 — sub-key 단위 부분 보존은
// 별도 도메인 helper 에서 처리).
$userOverrides = $existing->user_overrides ?? [];
$isFieldOverridden = static function (string $column) use ($userOverrides): bool {
if (in_array($column, $userOverrides, true)) {
return true;
}
// dot-path 마킹 (`name.ko`, `name.en` 등) 도 컬럼 전체 보존으로 인정
$prefix = $column.'.';
foreach ($userOverrides as $entry) {
if (is_string($entry) && str_starts_with($entry, $prefix)) {
return true;
}
}
return false;
};
$updateData = [
'parent_id' => $parentId,
'is_active' => true,
];
if (! in_array('name', $userOverrides, true)) {
// is_active 는 운영자가 user_overrides 로 마킹한 경우 보존, 아니면 정의값으로 갱신
if (! $isFieldOverridden('is_active')) {
$updateData['is_active'] = (bool) ($newAttributes['is_active'] ?? true);
}
if (! $isFieldOverridden('name')) {
$updateData['name'] = $newAttributes['name'] ?? [];
}
if (! in_array('icon', $userOverrides, true)) {
if (! $isFieldOverridden('icon')) {
$updateData['icon'] = $newAttributes['icon'] ?? null;
}
if (! in_array('order', $userOverrides, true)) {
if (! $isFieldOverridden('order')) {
$updateData['order'] = $newAttributes['order'] ?? 0;
}
if (! in_array('url', $userOverrides, true)) {
if (! $isFieldOverridden('url')) {
$updateData['url'] = $newAttributes['url'] ?? null;
}
@@ -147,6 +172,8 @@ class ExtensionMenuSyncHelper
'icon' => $menuData['icon'] ?? null,
'order' => $menuData['order'] ?? 0,
'url' => $menuData['url'] ?? null,
// 정의의 is_active 값을 명시 전달 (기본 true). 미전달 시 syncMenu 가 true 로 폴백.
'is_active' => $menuData['is_active'] ?? true,
],
parentId: $parentId,
);
+123 -18
View File
@@ -198,10 +198,13 @@ class FilePermissionHelper
/**
* 부모 디렉토리의 소유자·그룹을 대상 경로에 상속합니다.
*
* @param string $path 소유권을 상속받을 파일 또는 디렉토리
* sudo 컨텍스트에서 root 가 만든 파일을 부모(보통 PHP-FPM owner) 로 정합화하기 위해
* 외부 호출처(예: `SettingsMigrator::writeJsonFile`) 가 직접 호출 가능하도록 public.
*
* @param string $path 소유권을 상속받을 파일 또는 디렉토리
* @return void
*/
protected static function inheritOwnershipFromParent(string $path): void
public static function inheritOwnershipFromParent(string $path): void
{
$parentDir = dirname($path);
if (! File::isDirectory($parentDir)) {
@@ -288,15 +291,36 @@ class FilePermissionHelper
* @return int 실제 소유권을 변경한 항목 수
*/
public static function chownRecursive(string $path, int $owner, int|false $group): int
{
return self::chownRecursiveDetailed($path, $owner, $group)['changed'];
}
/**
* `chownRecursive` 의 상세 결과 변형. 실패 경로를 누적하여 반환한다.
*
* 코어/확장 업데이트 흐름이 운영자에게 권한 정상화 실패 경로를 노출할 수 있도록
* 누적 결과를 구조화 반환한다. 실패 경로 수가 많을 때 로그 폭주를 막기 위해
* `failed_paths` 는 최대 50개로 잘라낸다 (전체 카운트는 `failed` 에 보존).
*
* `$respectPreservationMarker = true` 일 때 (트랙 2-A): 트리 순회 중 디렉토리에
* `.preserve-ownership` 파일이 발견되면 해당 서브트리 전체를 chown 비대상으로 skip.
* `ModuleStorageDriver` / `PluginStorageDriver` 가 자동 작성하는 마커로 사용자 데이터
* (storage/app/{modules,plugins}/{id}/) 의 시드 시점 owner/perms 영구 보존.
*
* @param string $path 대상 경로
* @param int $owner 기준 소유자 UID
* @param int|false $group 기준 그룹 GID (false = 그룹 유지)
* @param bool $respectPreservationMarker `.preserve-ownership` 마커가 있는 서브트리 skip 여부
* @return array{changed:int, failed:int, failed_paths:array<int,string>, supported:bool, skipped_subtrees:int}
*/
public static function chownRecursiveDetailed(string $path, int $owner, int|false $group, bool $respectPreservationMarker = false): array
{
if (! function_exists('chown')) {
return 0;
return ['changed' => 0, 'failed' => 0, 'failed_paths' => [], 'supported' => false, 'skipped_subtrees' => 0];
}
// 재귀 전체 기간 동안 실패/성공을 집계하고 종료 시 요약 로그를 남긴다.
// 경로당 개별 로그는 재귀가 깊어지면 로그 폭주 유발 → 최초 실패 1건만 즉시 로깅.
$report = ['changed' => 0, 'failed' => 0, 'first_failure' => null];
self::chownRecursiveInternal($path, $owner, $group, $report);
$report = ['changed' => 0, 'failed' => 0, 'failed_paths' => [], 'first_failure' => null, 'skipped_subtrees' => 0];
self::chownRecursiveInternal($path, $owner, $group, $report, $respectPreservationMarker);
if ($report['failed'] > 0) {
Log::warning('chownRecursive: 부분 실패', [
@@ -309,7 +333,15 @@ class FilePermissionHelper
]);
}
return $report['changed'];
// 마커 skip 카운트는 호출자(restoreOwnership 등) 의 종합 로그에 포함되므로 별도 info 미출력.
return [
'changed' => $report['changed'],
'failed' => $report['failed'],
'failed_paths' => array_slice($report['failed_paths'], 0, 50),
'supported' => true,
'skipped_subtrees' => $report['skipped_subtrees'],
];
}
/**
@@ -332,22 +364,47 @@ class FilePermissionHelper
* @return int 실제 chmod 한 항목 수
*/
public static function syncGroupWritability(string $root): int
{
return self::syncGroupWritabilityDetailed($root)['changed'];
}
/**
* `syncGroupWritability` 의 상세 결과 변형. 실패 경로를 누적하여 반환한다.
*
* `skipped` 는 루트가 g-w 정책 보존으로 no-op 되었거나 chmod 미지원 환경에서 true.
* 코어/확장 업데이트가 운영자에게 권한 정상화 실패 경로를 즉시 노출할 때 사용.
*
* `$force=true` 시 루트가 g-w 라도 강제로 g+w 부여 후 하위 정상화. sudo root 가 0755 로
* 신규 디렉토리를 생성한 케이스(권한 정상화가 가장 필요한 시나리오) 에서 운영자 정책 보존
* 분기로 silent no-op 되던 결함을 차단할 때 사용. 일반 호출은 force=false (기존 동작 유지).
*
* @param string $root 대상 루트
* @param bool $force 루트 g-w 정책 강제 우회
* @return array{changed:int, failed:int, failed_paths:array<int,string>, supported:bool, skipped:bool}
*/
public static function syncGroupWritabilityDetailed(string $root, bool $force = false): array
{
if (! function_exists('chmod') || ! is_dir($root)) {
return 0;
return ['changed' => 0, 'failed' => 0, 'failed_paths' => [], 'supported' => function_exists('chmod'), 'skipped' => true];
}
$rootPerms = @fileperms($root);
if ($rootPerms === false) {
return 0;
return ['changed' => 0, 'failed' => 0, 'failed_paths' => [], 'supported' => true, 'skipped' => true];
}
// 루트가 g+w 가 아니면 정책 보존 (no-op)
if (($rootPerms & 0020) === 0) {
return 0;
if (! $force) {
return ['changed' => 0, 'failed' => 0, 'failed_paths' => [], 'supported' => true, 'skipped' => true];
}
// force 모드: 루트에 g+w 강제 부여 → 이후 하위 정상화로 진행. sudo root 가 0755 로
// 신규 디렉토리를 생성한 시나리오에서 운영자 정책 보존 분기로 silent no-op 되던 결함 차단.
if (! @chmod($root, $rootPerms | 0020)) {
return ['changed' => 0, 'failed' => 1, 'failed_paths' => [$root], 'supported' => true, 'skipped' => false];
}
}
$report = ['changed' => 0];
$report = ['changed' => 0, 'failed' => 0, 'failed_paths' => []];
self::syncGroupWritabilityInternal($root, $report, true);
if ($report['changed'] > 0) {
@@ -356,8 +413,22 @@ class FilePermissionHelper
'changed' => $report['changed'],
]);
}
if ($report['failed'] > 0) {
Log::warning('syncGroupWritability: 부분 실패', [
'root' => $root,
'changed' => $report['changed'],
'failed' => $report['failed'],
'first_failure' => $report['failed_paths'][0] ?? null,
]);
}
return $report['changed'];
return [
'changed' => $report['changed'],
'failed' => $report['failed'],
'failed_paths' => array_slice($report['failed_paths'], 0, 50),
'supported' => true,
'skipped' => false,
];
}
/**
@@ -380,6 +451,15 @@ class FilePermissionHelper
// g+w 만 추가, 다른 비트 무변경
if (@chmod($path, $perms | 0020)) {
$report['changed']++;
} else {
if (! isset($report['failed'])) {
$report['failed'] = 0;
$report['failed_paths'] = [];
}
$report['failed']++;
if (count($report['failed_paths']) < 50) {
$report['failed_paths'][] = $path;
}
}
}
}
@@ -400,10 +480,22 @@ class FilePermissionHelper
* @param string $path 대상 경로
* @param int $owner 기준 소유자 UID
* @param int|false $group 기준 그룹 GID
* @param array{changed:int, failed:int, first_failure:string|null} $report 집계 구조 (참조)
* @param array{changed:int, failed:int, first_failure:string|null, skipped_subtrees:int} $report 집계 구조 (참조)
* @param bool $respectPreservationMarker `.preserve-ownership` 마커가 있는 디렉토리 서브트리 skip 여부
*/
private static function chownRecursiveInternal(string $path, int $owner, int|false $group, array &$report): void
private static function chownRecursiveInternal(string $path, int $owner, int|false $group, array &$report, bool $respectPreservationMarker = false): void
{
// 트랙 2-A — 디렉토리에 .preserve-ownership 마커가 있으면 서브트리 전체 skip (자기 자신 + 하위)
// ModuleStorageDriver / PluginStorageDriver 가 자동 작성하는 마커로 사용자 데이터 영구 보존.
if ($respectPreservationMarker && is_dir($path) && ! is_link($path)) {
$markerPath = $path.DIRECTORY_SEPARATOR.'.preserve-ownership';
if (@file_exists($markerPath)) {
$report['skipped_subtrees']++;
return; // 자기 자신 + 하위 모두 chown 비대상
}
}
$currentOwner = @fileowner($path);
if ($currentOwner !== false && $currentOwner !== $owner) {
if (@chown($path, $owner)) {
@@ -414,8 +506,21 @@ class FilePermissionHelper
Log::warning('chown 최초 실패', ['path' => $path, 'owner' => $owner]);
}
$report['failed']++;
if (! isset($report['failed_paths'])) {
$report['failed_paths'] = [];
}
if (count($report['failed_paths']) < 50) {
$report['failed_paths'][] = $path;
}
}
if ($group !== false && function_exists('chgrp')) {
}
// chgrp 는 owner 일치 여부와 무관하게 별도 판정 (이전: chown 분기 안에 있어 owner 일치 시 chgrp 도 스킵되던 결함).
// 운영자 환경에서 lang-packs 가 base_path owner 와 동일하지만 그룹은 root 등 다른 그룹으로 잔존하는 케이스에서
// 그룹 변경이 영구히 누락되던 silent fail 차단.
if ($group !== false && function_exists('chgrp')) {
$currentGroup = @filegroup($path);
if ($currentGroup !== false && $currentGroup !== $group) {
@chgrp($path, $group);
}
}
@@ -426,7 +531,7 @@ class FilePermissionHelper
$items = new \FilesystemIterator($path, \FilesystemIterator::SKIP_DOTS);
foreach ($items as $item) {
self::chownRecursiveInternal($item->getPathname(), $owner, $group, $report);
self::chownRecursiveInternal($item->getPathname(), $owner, $group, $report, $respectPreservationMarker);
}
}
}
@@ -0,0 +1,151 @@
<?php
namespace App\Extension\Helpers;
use App\Models\IdentityMessageDefinition;
use App\Models\IdentityMessageTemplate;
use Illuminate\Support\Facades\Log;
/**
* IDV 메시지 정의/템플릿 동기화 헬퍼.
*
* 알림 시스템(NotificationSyncHelper)과 분리된 IDV 전용 헬퍼.
* (provider_id, scope_type, scope_value) 매트릭스를 키로 사용합니다.
*
* Seeder 는 본 helper 를 호출하는 얇은 진입점이며, 모든 데이터 정합성 로직은
* helper 에 집중됩니다. 내부 upsert 는 `HasUserOverrides::syncOrCreateFromUpgrade`
* 에 위임하여 trait 공통 API 를 재활용합니다.
*/
class IdentityMessageSyncHelper
{
/**
* 메시지 정의를 동기화합니다 (user_overrides 보존 upsert).
*
* @param array<string, mixed> $data definition 데이터 (provider_id, scope_type, scope_value,
* extension_type, extension_identifier, name, description,
* channels, variables, is_active, is_default, templates 포함 가능)
* @return IdentityMessageDefinition
*/
public function syncDefinition(array $data): IdentityMessageDefinition
{
$scopeValue = (string) ($data['scope_value'] ?? '');
return IdentityMessageDefinition::syncOrCreateFromUpgrade(
[
'provider_id' => $data['provider_id'],
'scope_type' => $data['scope_type'],
'scope_value' => $scopeValue,
],
[
'extension_type' => $data['extension_type'],
'extension_identifier' => $data['extension_identifier'],
'name' => $data['name'],
'description' => $data['description'] ?? null,
'channels' => $data['channels'] ?? ['mail'],
'variables' => $data['variables'] ?? [],
'is_active' => $data['is_active'] ?? true,
'is_default' => $data['is_default'] ?? true,
]
);
}
/**
* 메시지 템플릿을 동기화합니다 (user_overrides 보존 upsert).
*
* @param int $definitionId
* @param array<string, mixed> $data template 데이터 (channel, subject, body, is_active, is_default)
* @return IdentityMessageTemplate
*/
public function syncTemplate(int $definitionId, array $data): IdentityMessageTemplate
{
return IdentityMessageTemplate::syncOrCreateFromUpgrade(
['definition_id' => $definitionId, 'channel' => $data['channel']],
[
'subject' => $data['subject'] ?? null,
'body' => $data['body'],
'is_active' => $data['is_active'] ?? true,
'is_default' => $data['is_default'] ?? true,
]
);
}
/**
* stale 메시지 정의를 삭제합니다 (완전 동기화 원칙).
*
* 정책: `user_overrides` 무관 — config/seeder 에 없는 정의는 삭제.
* FK cascade 로 연관 templates 도 자동 정리됩니다.
*
* @param string $extensionType
* @param string $extensionIdentifier
* @param array<int, array{provider_id: string, scope_type: string, scope_value: string}> $currentScopes
* @return int 삭제된 definition 수
*/
public function cleanupStaleDefinitions(
string $extensionType,
string $extensionIdentifier,
array $currentScopes,
): int {
$currentKeys = array_map(
fn (array $s) => $s['provider_id'].'|'.$s['scope_type'].'|'.((string) ($s['scope_value'] ?? '')),
$currentScopes
);
$query = IdentityMessageDefinition::query()
->where('extension_type', $extensionType)
->where('extension_identifier', $extensionIdentifier);
$targets = $query->get(['id', 'provider_id', 'scope_type', 'scope_value']);
$stale = $targets->filter(function (IdentityMessageDefinition $def) use ($currentKeys) {
$key = $def->provider_id.'|'.$def->scope_type->value.'|'.((string) $def->scope_value);
return ! in_array($key, $currentKeys, true);
});
foreach ($stale as $def) {
$def->delete();
}
$count = $stale->count();
if ($count > 0) {
Log::info('stale IDV 메시지 정의 정리 완료', [
'extension_type' => $extensionType,
'extension_identifier' => $extensionIdentifier,
'deleted' => $count,
'scopes' => $stale->map(fn ($d) => $d->provider_id.'|'.$d->scope_type->value.'|'.$d->scope_value)->all(),
]);
}
return $count;
}
/**
* 주어진 definition 의 channel 목록 기준으로 stale template 을 삭제합니다.
*
* @param int $definitionId
* @param array<int, string> $currentChannels
* @return int 삭제된 template 수
*/
public function cleanupStaleTemplates(int $definitionId, array $currentChannels): int
{
$query = IdentityMessageTemplate::query()
->where('definition_id', $definitionId)
->whereNotIn('channel', $currentChannels);
$targets = $query->get(['id', 'channel']);
foreach ($targets as $template) {
$template->delete();
}
$count = $targets->count();
if ($count > 0) {
Log::info('stale IDV 메시지 템플릿 정리 완료', [
'definition_id' => $definitionId,
'deleted' => $count,
'channels' => $targets->pluck('channel')->all(),
]);
}
return $count;
}
}
@@ -0,0 +1,91 @@
<?php
namespace App\Extension\Helpers;
use App\Models\IdentityPolicy;
use Illuminate\Support\Facades\Log;
/**
* 본인인증 정책 동기화 Helper.
*
* 선언형 정의 시스템 — 알림의 NotificationSyncHelper 동형 패턴.
* config/core.php.identity_policies 블록 + {벤더}IdentityPolicySeeder 가 이 Helper 를 호출한다.
*
* 운영자가 S1d UI 에서 수정한 필드는 user_overrides JSON 에 기록되며,
* 재동기화 시 해당 필드는 갱신 대상에서 제외된다 (HasUserOverrides 공통 API).
*
* @since 7.0.0-beta.4
*/
class IdentityPolicySyncHelper
{
/**
* 정책을 동기화합니다 (user_overrides 보존 upsert).
*
* 신규: 생성
* 기존: user_overrides 에 없는 필드만 업데이트
*
* @param array<string, mixed> $data 정책 데이터 (key/scope/target/purpose 등)
* @return IdentityPolicy 동기화된 정책
*/
public function syncPolicy(array $data): IdentityPolicy
{
return IdentityPolicy::syncOrCreateFromUpgrade(
['key' => $data['key']],
[
'scope' => $data['scope'] ?? 'route',
'target' => $data['target'] ?? $data['key'],
'purpose' => $data['purpose'] ?? 'sensitive_action',
'provider_id' => $data['provider_id'] ?? null,
'grace_minutes' => (int) ($data['grace_minutes'] ?? 0),
'enabled' => (bool) ($data['enabled'] ?? true),
'priority' => (int) ($data['priority'] ?? 100),
'conditions' => $data['conditions'] ?? null,
'source_type' => $data['source_type'] ?? 'core',
'source_identifier' => $data['source_identifier'] ?? 'core',
'applies_to' => $data['applies_to'] ?? 'both',
'fail_mode' => $data['fail_mode'] ?? 'block',
]
);
}
/**
* seed/정의에 없는 stale 정책을 삭제합니다 (완전 동기화 원칙).
*
* 운영자가 S1d 에서 직접 생성한 정책(source_type='admin')은 영향받지 않습니다.
*
* @param string $sourceType 확장 타입 (core|module|plugin)
* @param string $sourceIdentifier 확장 식별자
* @param array<int, string> $currentKeys 현재 유효한 정책 key 목록
* @return int 삭제된 정책 수
*/
public function cleanupStalePolicies(
string $sourceType,
string $sourceIdentifier,
array $currentKeys,
): int {
$query = IdentityPolicy::query()
->where('source_type', $sourceType)
->where('source_identifier', $sourceIdentifier);
if (! empty($currentKeys)) {
$query->whereNotIn('key', $currentKeys);
}
$targets = $query->get(['id', 'key']);
foreach ($targets as $policy) {
$policy->delete();
}
$count = $targets->count();
if ($count > 0) {
Log::info('IdentityPolicySyncHelper: stale 정책 정리', [
'source_type' => $sourceType,
'source_identifier' => $sourceIdentifier,
'deleted_count' => $count,
'deleted_keys' => $targets->pluck('key')->all(),
]);
}
return $count;
}
}
+13 -2
View File
@@ -342,7 +342,7 @@ class SettingsMigrator
private function executeAddCategory(array $args): bool
{
if ($this->type === 'plugin') {
throw new \LogicException('addCategory는 모듈에서만 사용할 수 있습니다.');
throw new \LogicException(__('exceptions.settings.add_category_module_only'));
}
$category = $args['category'];
@@ -427,7 +427,10 @@ class SettingsMigrator
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \RuntimeException(
"JSON 파싱 실패: {$filePath} - ".json_last_error_msg()
__('exceptions.settings.json_parse_failed', [
'path' => $filePath,
'error' => json_last_error_msg(),
])
);
}
@@ -437,6 +440,11 @@ class SettingsMigrator
/**
* 배열 데이터를 JSON 파일로 저장합니다.
*
* sudo update 흐름에서 모듈/플러그인 upgrade step 이 root 로 실행될 때 root 소유로
* 파일이 만들어지지 않도록, 작성 직후 부모 디렉토리(예: storage/app/modules/{id}/settings/)
* 의 owner/group 을 상속한다. 부모 owner 가 PHP-FPM 이라면 후속 PHP-FPM 의 update 시도
* 가 쓰기 실패하지 않는다.
*
* @param string $filePath 파일 절대 경로
* @param array $data 저장할 데이터
* @return void
@@ -445,6 +453,9 @@ class SettingsMigrator
{
$content = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
File::put($filePath, $content);
// sudo 컨텍스트 root → 부모 owner 정합화. 자기 자신 owner 면 멱등 (no-op).
FilePermissionHelper::inheritOwnershipFromParent($filePath);
}
/**
+73 -3
View File
@@ -3,6 +3,7 @@
namespace App\Extension;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
@@ -24,6 +25,11 @@ class HookArgumentSerializer
*/
private const COLLECTION_MARKER = '__hook_collection__';
/**
* Enum 직렬화 마커 키
*/
private const ENUM_MARKER = '__hook_enum__';
/**
* 인자 배열을 직렬화 안전한 형태로 변환합니다.
*
@@ -54,12 +60,33 @@ class HookArgumentSerializer
*/
private static function serializeValue(mixed $value): mixed
{
// Eloquent Model → 클래스명 + PK
// Eloquent Model → 클래스명 + PK + attributes 스냅샷
// attributes 는 hard-delete 모델의 after_delete 훅 페이로드 복원용 fallback.
// 큐 워커가 find($id) 로 조회 못 하더라도 attributes 로 in-memory 모델 재생성 가능.
if ($value instanceof Model) {
return [
self::MODEL_MARKER => true,
'class' => get_class($value),
'id' => $value->getKey(),
'attributes' => $value->getAttributes(),
];
}
// Backed Enum → 클래스명 + value (역직렬화 시 from() 사용)
if ($value instanceof \BackedEnum) {
return [
self::ENUM_MARKER => true,
'class' => get_class($value),
'value' => $value->value,
];
}
// Pure (Unit) Enum → 클래스명 + name (역직렬화 시 constant() 사용)
if ($value instanceof \UnitEnum) {
return [
self::ENUM_MARKER => true,
'class' => get_class($value),
'name' => $value->name,
];
}
@@ -107,10 +134,28 @@ class HookArgumentSerializer
$id = $value['id'];
if (class_exists($class) && is_subclass_of($class, Model::class)) {
return $class::find($id);
// 1차: DB 조회로 복원 (살아있는 레코드 또는 SoftDeletes 의 trashed)
$found = in_array(SoftDeletes::class, class_uses_recursive($class), true)
? $class::withTrashed()->find($id)
: $class::find($id);
if ($found !== null) {
return $found;
}
// 2차: hard-delete 된 모델은 직렬화 시점의 attributes 스냅샷으로 in-memory 재생성
// (after_delete 훅의 큐 워커가 listener 호출 시 null 대신 살아있을 때 상태 전달)
if (isset($value['attributes']) && is_array($value['attributes'])) {
/** @var Model $instance */
$instance = new $class();
$instance->setRawAttributes($value['attributes']);
$instance->exists = false; // in-memory only — save() 가 update 시도 못 하도록
return $instance;
}
}
Log::warning('훅 인자 역직렬화 실패: 모델 클래스를 찾을 수 없습니다.', [
Log::warning('훅 인자 역직렬화 실패: 모델 클래스를 찾을 수 없거나 attributes 부재.', [
'class' => $class,
'id' => $id,
]);
@@ -123,6 +168,31 @@ class HookArgumentSerializer
return collect(self::deserialize($value['items']));
}
// Enum 복원
if (! empty($value[self::ENUM_MARKER])) {
$class = $value['class'];
if (! enum_exists($class)) {
Log::warning('훅 인자 역직렬화 실패: enum 클래스를 찾을 수 없습니다.', [
'class' => $class,
]);
return null;
}
// BackedEnum: value 키로 from()
if (array_key_exists('value', $value)) {
return $class::from($value['value']);
}
// UnitEnum: name 키로 constant()
if (array_key_exists('name', $value)) {
return constant($class.'::'.$value['name']);
}
return null;
}
// 일반 배열 → 재귀 역직렬화
return array_map([self::class, 'deserializeValue'], $value);
}
+37
View File
@@ -18,18 +18,42 @@ use Illuminate\Support\Facades\Log;
*/
class HookListenerRegistrar
{
/**
* 등록 이력 캐시 (process-wide idempotency).
*
* Laravel ServiceProvider boot 가 PHPUnit 테스트 환경에서 매 setUp 마다 다시
* 호출되며 listener 가 누적 등록되어 hook 카운트 폭증으로 hang 을 유발하던
* 문제 차단. production 환경은 boot 가 1회만 호출되므로 무영향.
*
* 모듈 install/uninstall 시나리오에서 재등록이 필요하면 clear() 사용.
*
* @var array<string, true> key: "{source}::{listenerClass}"
*/
private static array $registered = [];
/**
* 리스너 클래스를 HookManager에 등록합니다.
*
* 동일 source + listenerClass 조합이 이미 등록된 경우 skip (idempotent).
*
* @param string $listenerClass HookListenerInterface 구현 클래스의 FQCN
* @param string|null $source 등록 출처 (로그용: 'core', 모듈/플러그인 식별자)
* @return void
*/
public static function register(string $listenerClass, ?string $source = null): void
{
$key = ($source ?? 'unknown').'::'.$listenerClass;
if (isset(self::$registered[$key])) {
return; // 동일 PHP process 내 중복 등록 방지
}
self::$registered[$key] = true;
try {
$subscribedHooks = $listenerClass::getSubscribedHooks();
} catch (\Throwable $e) {
// 실패 시 캐시 롤백하여 재시도 가능 상태 유지
unset(self::$registered[$key]);
Log::error('훅 리스너 등록 실패: getSubscribedHooks() 오류', [
'listener' => $listenerClass,
'source' => $source,
@@ -81,4 +105,17 @@ class HookListenerRegistrar
]);
}
}
/**
* 등록 이력 캐시를 비웁니다.
*
* 모듈 install/uninstall 시나리오 또는 테스트 격리가 필요할 때 호출.
* 캐시 비운 후 register() 호출하면 listener 가 다시 HookManager 에 추가됨.
*
* @return void
*/
public static function clear(): void
{
self::$registered = [];
}
}
+37 -12
View File
@@ -19,6 +19,14 @@ class HookManager implements HookManagerInterface
private static array $dispatching = [];
/**
* 현재 실행 중인 훅 이름 스택 — 정책 Listener 등 "어느 훅에서 호출되었는지" 알아야 하는
* 단일 핸들러 패턴에 사용됩니다. (내부 전용)
*
* @var array<int, string>
*/
private static array $runningHookStack = [];
/**
* Hook 이벤트를 발생시켜 등록된 콜백들을 실행합니다.
*
@@ -29,25 +37,42 @@ class HookManager implements HookManagerInterface
{
// 가드 플래그 설정 (addAction으로 등록된 콜백의 Event::listen 중복 실행 방지)
self::$dispatching[$hookName] = true;
self::$runningHookStack[] = $hookName;
// 등록된 Hook이 있는지 확인
if (isset(self::$hooks[$hookName])) {
// Hook들을 우선순위에 따라 정렬
$hooks = self::$hooks[$hookName];
ksort($hooks);
try {
// 등록된 Hook이 있는지 확인
if (isset(self::$hooks[$hookName])) {
// Hook들을 우선순위에 따라 정렬
$hooks = self::$hooks[$hookName];
ksort($hooks);
// 각 Hook을 순차적으로 실행
foreach ($hooks as $priority => $callbacks) {
foreach ($callbacks as $callback) {
call_user_func_array($callback, $args);
// 각 Hook을 순차적으로 실행
foreach ($hooks as $priority => $callbacks) {
foreach ($callbacks as $callback) {
call_user_func_array($callback, $args);
}
}
}
// Laravel 이벤트 시스템에도 전달 (직접 Event::listen으로 등록한 외부 리스너용)
Event::dispatch("hook.{$hookName}", $args);
} finally {
array_pop(self::$runningHookStack);
unset(self::$dispatching[$hookName]);
}
}
// Laravel 이벤트 시스템에도 전달 (직접 Event::listen으로 등록한 외부 리스너용)
Event::dispatch("hook.{$hookName}", $args);
/**
* 현재 실행 중인 (가장 내부의) 훅 이름을 반환합니다.
* 단일 handler 메서드가 여러 훅에 구독되어 "어느 훅에서 호출되었는지" 구분이 필요할 때 사용.
*
* @return string|null 실행 중 훅 이름 또는 null (최상위 컨텍스트)
*/
public static function getRunningHook(): ?string
{
$count = count(self::$runningHookStack);
unset(self::$dispatching[$hookName]);
return $count > 0 ? self::$runningHookStack[$count - 1] : null;
}
/**
@@ -0,0 +1,58 @@
<?php
namespace App\Extension\IdentityVerification\DTO;
use Carbon\CarbonInterface;
/**
* 본인인증 Challenge DTO.
*
* 프로바이더가 {@see \App\Contracts\Extension\IdentityVerificationInterface::requestChallenge()}
* 호출 시 반환하는 불변 객체로, 프론트에 노출되는 정보와 서버 내부 참조용 식별자를 함께 담습니다.
*/
final class VerificationChallenge
{
/**
* @param string $id challenge UUID (identity_verification_logs.id 와 동일)
* @param string $providerId 프로바이더 식별자
* @param string $purpose signup|password_reset|self_update|sensitive_action|...
* @param string $channel email|sms|ipin|...
* @param string $targetHash SHA256(email|phone) — PII 원본 저장 회피
* @param CarbonInterface $expiresAt 만료 시각
* @param string $renderHint text_code|link|external_redirect
* @param string|null $redirectUrl external_redirect 일 때 이동할 외부 URL
* @param array $publicPayload 프론트에 내려줄 공개 페이로드 (민감정보 제외)
* @param array $metadata 서버 내부 참조용 데이터
*/
public function __construct(
public readonly string $id,
public readonly string $providerId,
public readonly string $purpose,
public readonly string $channel,
public readonly string $targetHash,
public readonly CarbonInterface $expiresAt,
public readonly string $renderHint,
public readonly ?string $redirectUrl = null,
public readonly array $publicPayload = [],
public readonly array $metadata = [],
) {}
/**
* 프론트/ResponseHelper 에 그대로 넘길 수 있는 직렬화 배열.
*
* @return array
*/
public function toArray(): array
{
return [
'id' => $this->id,
'provider_id' => $this->providerId,
'purpose' => $this->purpose,
'channel' => $this->channel,
'render_hint' => $this->renderHint,
'redirect_url' => $this->redirectUrl,
'expires_at' => $this->expiresAt->toIso8601String(),
'public_payload' => $this->publicPayload,
];
}
}
@@ -0,0 +1,85 @@
<?php
namespace App\Extension\IdentityVerification\DTO;
use Carbon\CarbonInterface;
/**
* 본인인증 검증 결과 DTO.
*
* {@see \App\Contracts\Extension\IdentityVerificationInterface::verify()} 의 반환값.
*/
final class VerificationResult
{
/**
* @param bool $success 검증 성공 여부
* @param string $challengeId challenge UUID
* @param string $providerId 프로바이더 식별자
* @param CarbonInterface|null $verifiedAt 검증 완료 시각 (success=true 일 때)
* @param string|null $identityHash 프로바이더 교체 시 동일인 매칭용 정규화 식별자 (SHA256(name|birth|gender) 등)
* @param array $claims 프로바이더가 반환한 PII 클레임 (각 플러그인이 자기 테이블에 저장)
* @param string|null $failureCode 실패 코드 (예: INVALID_CODE|EXPIRED|MAX_ATTEMPTS)
* @param string|null $failureReason 실패 이유 (i18n key 또는 raw 메시지)
*/
public function __construct(
public readonly bool $success,
public readonly string $challengeId,
public readonly string $providerId,
public readonly ?CarbonInterface $verifiedAt = null,
public readonly ?string $identityHash = null,
public readonly array $claims = [],
public readonly ?string $failureCode = null,
public readonly ?string $failureReason = null,
) {}
/**
* 검증 성공 결과 인스턴스를 생성합니다.
*
* @param string $challengeId challenge UUID
* @param string $providerId 프로바이더 식별자
* @param CarbonInterface $verifiedAt 검증 완료 시각
* @param string|null $identityHash 정규화 식별자
* @param array $claims 프로바이더 클레임
* @return self 성공 결과 DTO
*/
public static function success(
string $challengeId,
string $providerId,
CarbonInterface $verifiedAt,
?string $identityHash = null,
array $claims = [],
): self {
return new self(
success: true,
challengeId: $challengeId,
providerId: $providerId,
verifiedAt: $verifiedAt,
identityHash: $identityHash,
claims: $claims,
);
}
/**
* 실패 결과 생성.
*
* @param string $challengeId
* @param string $providerId
* @param string $failureCode
* @param string|null $failureReason
* @return self
*/
public static function failure(
string $challengeId,
string $providerId,
string $failureCode,
?string $failureReason = null,
): self {
return new self(
success: false,
challengeId: $challengeId,
providerId: $providerId,
failureCode: $failureCode,
failureReason: $failureReason,
);
}
}
@@ -0,0 +1,323 @@
<?php
namespace App\Extension\IdentityVerification;
use App\Contracts\Extension\IdentityVerificationInterface;
use App\Enums\IdentityPolicySourceType;
use App\Enums\IdentityVerificationChannel;
use App\Enums\IdentityVerificationPurpose;
use App\Extension\HookManager;
use InvalidArgumentException;
/**
* 본인인증 프로바이더 레지스트리 / 매니저.
*
* 기존 드라이버 레시피(Interface + CoreServiceProvider 바인딩 + 필터 훅 등록)를 그대로 따릅니다.
* 플러그인은 `core.identity.registered_providers` 필터 훅으로 프로바이더를 등록합니다.
*
* @since 7.0.0-beta.4
*/
class IdentityVerificationManager
{
/**
* @var array<string, IdentityVerificationInterface>
*/
protected array $providers = [];
/**
* 확장(모듈/플러그인) 이 선언한 purpose 레지스트리.
*
* `AbstractModule::getIdentityPurposes()` / `AbstractPlugin::getIdentityPurposes()`
* 반환값을 `ModuleManager` / `PluginManager` 가 부팅 시 여기에 병합합니다.
* DB 에 저장되지 않는 **코드 계약** 입니다.
*
* @var array<string, array<string, mixed>>
*/
protected array $declaredPurposes = [];
/**
* 코어 기본 purpose 목록.
*
* `signup` / `password_reset` / `self_update` / `sensitive_action` — 이 4종은
* 코어가 계약으로 보장하며 `MailIdentityProvider` 가 모두 지원합니다.
*
* @var array<string, array<string, mixed>>
*/
protected array $corePurposes = [
IdentityVerificationPurpose::Signup->value => [
'label' => 'identity.purposes.signup.label',
'description' => 'identity.purposes.signup.description',
'default_provider' => null,
'allowed_channels' => [IdentityVerificationChannel::Email->value],
'source_type' => IdentityPolicySourceType::Core->value,
'source_identifier' => 'core',
],
IdentityVerificationPurpose::PasswordReset->value => [
'label' => 'identity.purposes.password_reset.label',
'description' => 'identity.purposes.password_reset.description',
'default_provider' => null,
'allowed_channels' => [IdentityVerificationChannel::Email->value],
'source_type' => IdentityPolicySourceType::Core->value,
'source_identifier' => 'core',
],
IdentityVerificationPurpose::SelfUpdate->value => [
'label' => 'identity.purposes.self_update.label',
'description' => 'identity.purposes.self_update.description',
'default_provider' => null,
'allowed_channels' => [IdentityVerificationChannel::Email->value],
'source_type' => IdentityPolicySourceType::Core->value,
'source_identifier' => 'core',
],
IdentityVerificationPurpose::SensitiveAction->value => [
'label' => 'identity.purposes.sensitive_action.label',
'description' => 'identity.purposes.sensitive_action.description',
'default_provider' => null,
'allowed_channels' => [IdentityVerificationChannel::Email->value],
'source_type' => IdentityPolicySourceType::Core->value,
'source_identifier' => 'core',
],
];
/**
* 기본 프로바이더 id (설정에 의해 덮어쓰기 가능).
*/
protected string $defaultId = 'g7:core.mail';
/**
* 프로바이더를 등록합니다.
*
* @param IdentityVerificationInterface $provider IDV 프로바이더 인스턴스
* @return void
*/
public function register(IdentityVerificationInterface $provider): void
{
$this->providers[$provider->getId()] = $provider;
}
/**
* 프로바이더 등록을 해제합니다.
*
* @param string $id 프로바이더 식별자 (예: g7:core.mail)
* @return void
*/
public function unregister(string $id): void
{
unset($this->providers[$id]);
}
/**
* 특정 id 의 프로바이더가 등록되어 있는지 확인합니다.
*
* @param string $id 프로바이더 식별자
* @return bool 등록 여부
*/
public function has(string $id): bool
{
return isset($this->all()[$id]);
}
/**
* 특정 id 의 프로바이더를 반환합니다.
*
* @param string $id 프로바이더 식별자
* @return IdentityVerificationInterface 등록된 provider
*
* @throws InvalidArgumentException 프로바이더 미등록 시
*/
public function get(string $id): IdentityVerificationInterface
{
$providers = $this->all();
if (! isset($providers[$id])) {
throw new InvalidArgumentException("Identity verification provider not found: {$id}");
}
return $providers[$id];
}
/**
* 등록된 전체 프로바이더 목록 (필터 훅 통과 후).
*
* @return array<string, IdentityVerificationInterface>
*/
public function all(): array
{
$merged = HookManager::applyFilters('core.identity.registered_providers', $this->providers);
if (! is_array($merged)) {
return $this->providers;
}
$valid = [];
foreach ($merged as $key => $provider) {
if ($provider instanceof IdentityVerificationInterface) {
$valid[$provider->getId()] = $provider;
}
}
return $valid;
}
/**
* 기본 프로바이더를 반환합니다.
*
* 우선순위: settings.identity.default_provider → 코어 기본 (g7:core.mail) → 등록된 첫 provider.
*
* @return IdentityVerificationInterface 기본 provider
*
* @throws InvalidArgumentException 등록된 provider 가 하나도 없을 때
*/
public function default(): IdentityVerificationInterface
{
$configured = (string) config('settings.identity.default_provider', $this->defaultId);
$providers = $this->all();
if (isset($providers[$configured])) {
return $providers[$configured];
}
if (isset($providers[$this->defaultId])) {
return $providers[$this->defaultId];
}
if (empty($providers)) {
throw new InvalidArgumentException('No identity verification provider is registered.');
}
return $providers[array_key_first($providers)];
}
/**
* 특정 purpose 에 사용할 프로바이더를 해석합니다.
*
* 해석 순서:
* 1. `settings.identity.purpose_providers.{purpose}` 에 명시적 지정 + 해당 프로바이더가 purpose 지원 → 사용
* 2. 기본 프로바이더가 purpose 지원 → 사용
* 3. 등록된 프로바이더 중 purpose 지원하는 첫 번째 → 사용
* 4. 없으면 mail (코어 기본) 반환 — mail 은 모든 purpose 지원 계약
*
* 정책의 provider_id 가 우선되므로 (IdentityPolicyService::resolveRenderHint 참조), 본 메서드는
* 정책에 provider_id 가 명시되지 않은 경우의 fallback 으로 사용됩니다.
*
* @param string $purpose IDV 목적 (signup, password_reset, sensitive_action 등)
* @return IdentityVerificationInterface 해석된 provider
*/
public function resolveForPurpose(string $purpose): IdentityVerificationInterface
{
$providers = $this->all();
$explicitId = (string) config("settings.identity.purpose_providers.{$purpose}", '');
if ($explicitId !== '' && isset($providers[$explicitId]) && $providers[$explicitId]->supportsPurpose($purpose)) {
return $providers[$explicitId];
}
$default = $this->default();
if ($default->supportsPurpose($purpose)) {
return $default;
}
foreach ($providers as $provider) {
if ($provider->supportsPurpose($purpose)) {
return $provider;
}
}
if (isset($providers[$this->defaultId])) {
return $providers[$this->defaultId];
}
throw new InvalidArgumentException("No identity verification provider supports purpose: {$purpose}");
}
/**
* 확장(모듈/플러그인) 이 선언한 purpose 들을 레지스트리에 등록합니다.
*
* `ModuleManager::bootModules()` / `PluginManager::bootPlugins()` 가
* 활성화된 확장의 `getIdentityPurposes()` 결과를 순회하며 호출합니다.
*
* 같은 key 로 중복 등록되면 나중 호출이 이전 값을 덮어씁니다
* (확장 로드 순서 결정성 보장은 ExtensionManager 책임).
*
* @param array<string, array<string, mixed>> $purposes key => metadata 매핑
* @param string|null $sourceType 'module' | 'plugin' | 'admin' (미명시 시 'admin' 으로 마킹)
* @param string|null $sourceIdentifier source 식별자 (module/plugin id; 미명시 시 'admin')
* @return void
*/
public function registerDeclaredPurposes(array $purposes, ?string $sourceType = null, ?string $sourceIdentifier = null): void
{
$resolvedType = $sourceType ?? 'admin';
$resolvedIdentifier = $sourceIdentifier ?? 'admin';
foreach ($purposes as $key => $meta) {
if (! is_string($key) || $key === '' || ! is_array($meta)) {
continue;
}
// legacy `label_key` / `description_key` 명명을 표준 `label` / `description` 으로 정규화.
// controller 의 resolvePurposeText 는 `label` / `description` 만 인식하므로, 미정규화
// meta 가 등록되면 응답에서 라벨이 raw 키로 노출되는 회귀 발생.
if (! isset($meta['label']) && isset($meta['label_key'])) {
$meta['label'] = $meta['label_key'];
}
if (! isset($meta['description']) && isset($meta['description_key'])) {
$meta['description'] = $meta['description_key'];
}
$meta['source_type'] = $resolvedType;
$meta['source_identifier'] = $resolvedIdentifier;
$this->declaredPurposes[$key] = $meta;
}
}
/**
* 확장이 선언한 purpose 를 한 개 등록합니다.
*
* @param string $key purpose 식별자
* @param array<string, mixed> $meta label/description/allowed_channels 등 메타데이터
* @param string|null $sourceType 'module' | 'plugin' | 'admin' (미명시 시 'admin')
* @param string|null $sourceIdentifier source 식별자 (미명시 시 'admin')
* @return void
*/
public function registerPurpose(string $key, array $meta, ?string $sourceType = null, ?string $sourceIdentifier = null): void
{
$meta['source_type'] = $sourceType ?? 'admin';
$meta['source_identifier'] = $sourceIdentifier ?? 'admin';
$this->declaredPurposes[$key] = $meta;
}
/**
* 등록된 전체 purpose 목록을 반환합니다.
*
* 병합 순서: 코어 기본 4종 → 확장 getter 선언분 → `core.identity.purposes` filter 훅.
* 같은 key 가 충돌하면 나중 소스가 이전을 덮어씁니다 (filter 훅이 최종 결정권).
*
* @return array<string, array<string, mixed>>
*/
public function getAllPurposes(): array
{
$merged = $this->corePurposes;
foreach ($this->declaredPurposes as $key => $meta) {
$merged[$key] = $meta;
}
$filtered = HookManager::applyFilters('core.identity.purposes', $merged);
if (! is_array($filtered)) {
return $merged;
}
return $filtered;
}
/**
* 특정 purpose 가 등록되어 있는지 확인합니다 (코어·확장·filter 훅 모두 포함).
*
* @param string $key purpose 식별자
* @return bool 존재 여부
*/
public function hasPurpose(string $key): bool
{
$all = $this->getAllPurposes();
return isset($all[$key]);
}
}
@@ -0,0 +1,447 @@
<?php
namespace App\Extension\IdentityVerification\Providers;
use App\Contracts\Extension\IdentityVerificationInterface;
use App\Contracts\Repositories\IdentityVerificationLogRepositoryInterface;
use App\Enums\IdentityVerificationStatus;
use App\Extension\IdentityVerification\DTO\VerificationChallenge;
use App\Extension\IdentityVerification\DTO\VerificationResult;
use App\Models\User;
use App\Services\IdentityMessageDispatcher;
use Carbon\CarbonInterval;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
/**
* 코어 기본 본인인증 프로바이더 — 메일 채널.
*
* 두 가지 렌더링 힌트를 purpose 별로 분기합니다.
* - purpose=password_reset → render_hint=link, 기존 `password_reset_tokens` 흐름 재사용 가능
* - 그 외 purpose → render_hint=text_code, 6자리 코드를 발송
*
* 모든 challenge 는 identity_verification_logs 테이블에 기록됩니다.
*
* @since 7.0.0-beta.4
*/
class MailIdentityProvider implements IdentityVerificationInterface
{
public const ID = 'g7:core.mail';
/**
* @param array $config withConfig() 로 전달되는 런타임 설정 (code_length, code_ttl_minutes, link_ttl_minutes 등)
*/
public function __construct(
protected IdentityVerificationLogRepositoryInterface $logRepository,
protected array $config = [],
) {}
/**
* 프로바이더 식별자를 반환합니다.
*
* @return string 프로바이더 ID
*/
public function getId(): string
{
return self::ID;
}
/**
* 프로바이더 표시 라벨을 반환합니다.
*
* @return string 다국어 라벨
*/
public function getLabel(): string
{
return __('identity.providers.mail.label');
}
/**
* 지원 채널 목록을 반환합니다.
*
* @return array<string> 채널 키 배열
*/
public function getChannels(): array
{
return ['email'];
}
/**
* 기본 렌더 힌트를 반환합니다.
*
* @return string 렌더 힌트 (text_code, link 등)
*/
public function getRenderHint(): string
{
// 기본값 — purpose 별 분기는 requestChallenge 내부에서 수행
return 'text_code';
}
/**
* 지정된 purpose 를 지원하는지 반환합니다.
*
* @param string $purpose 본인인증 목적
* @return bool 지원 여부
*/
public function supportsPurpose(string $purpose): bool
{
// 메일 프로바이더는 모든 코어·플러그인 purpose 를 범용 지원
return true;
}
/**
* 프로바이더 사용 가능 여부를 반환합니다.
*
* @return bool 메일러 설정 존재 시 true
*/
public function isAvailable(): bool
{
$mailer = (string) config('mail.default', '');
return $mailer !== '';
}
/**
* 인증 챌린지를 발급하고 메일을 발송합니다.
*
* @param User|array $target 대상 사용자 또는 식별 정보
* @param array $context 요청 컨텍스트 (purpose, ip_address 등)
* @return VerificationChallenge 발급된 챌린지
*
* @throws \InvalidArgumentException 이메일이 비어있는 경우
*/
public function requestChallenge(User|array $target, array $context = []): VerificationChallenge
{
$email = $target instanceof User
? $target->email
: (string) ($target['email'] ?? '');
if ($email === '') {
throw new \InvalidArgumentException('MailIdentityProvider requires an email target.');
}
$purpose = (string) ($context['purpose'] ?? 'sensitive_action');
$renderHint = $this->resolveRenderHint($purpose);
$ttlMinutes = (int) config('settings.identity.challenge_ttl_minutes', 15);
$maxAttempts = (int) config('settings.identity.max_attempts', 5);
$targetHash = hash('sha256', mb_strtolower($email));
$metadata = [];
$publicPayload = [];
$code = null;
$linkToken = null;
if ($renderHint === 'text_code') {
$code = $this->generateNumericCode((int) ($this->config['code_length'] ?? 6));
$metadata['code_hash'] = Hash::make($code);
$publicPayload['code_length'] = strlen($code);
} else {
// link 흐름 — 수신자에게는 서명 링크 전달, 서버는 해시만 저장
$linkToken = Str::random(64);
$metadata['link_token_hash'] = Hash::make($linkToken);
$publicPayload['link_hint'] = 'email_link';
}
$expiresAt = Carbon::now()->add(CarbonInterval::minutes($ttlMinutes));
$log = $this->logRepository->create([
'provider_id' => self::ID,
'purpose' => $purpose,
'channel' => 'email',
'user_id' => $target instanceof User ? $target->id : null,
'target_hash' => $targetHash,
'status' => IdentityVerificationStatus::Requested->value,
'render_hint' => $renderHint,
'attempts' => 0,
'max_attempts' => $maxAttempts,
'ip_address' => $context['ip_address'] ?? null,
'user_agent' => $context['user_agent'] ?? null,
'origin_type' => $context['origin_type'] ?? null,
'origin_identifier' => $context['origin_identifier'] ?? null,
'origin_policy_key' => $context['origin_policy_key'] ?? null,
'properties' => $context['properties'] ?? null,
'metadata' => $metadata,
'expires_at' => $expiresAt,
]);
$sent = $this->dispatchMessage(
email: $email,
purpose: $purpose,
renderHint: $renderHint,
challengeId: $log->id,
policyKey: $context['origin_policy_key'] ?? null,
code: $code,
linkToken: $linkToken,
ttlMinutes: $ttlMinutes,
expiresAt: $expiresAt,
);
$this->logRepository->updateById($log->id, [
'status' => $sent ? IdentityVerificationStatus::Sent->value : IdentityVerificationStatus::Failed->value,
]);
return new VerificationChallenge(
id: $log->id,
providerId: self::ID,
purpose: $purpose,
channel: 'email',
targetHash: $targetHash,
expiresAt: $expiresAt,
renderHint: $renderHint,
publicPayload: $publicPayload,
metadata: [],
);
}
/**
* 사용자가 제출한 코드/토큰을 검증합니다.
*
* @param string $challengeId 챌린지 ID
* @param array $input 사용자 입력 (code 또는 token)
* @param array $context 검증 컨텍스트
* @return VerificationResult 검증 결과
*/
public function verify(string $challengeId, array $input, array $context = []): VerificationResult
{
$log = $this->logRepository->findById($challengeId);
if (! $log) {
return VerificationResult::failure($challengeId, self::ID, 'NOT_FOUND', 'identity.errors.challenge_not_found');
}
if ($log->provider_id !== self::ID) {
return VerificationResult::failure($challengeId, self::ID, 'WRONG_PROVIDER', 'identity.errors.wrong_provider');
}
if (in_array($log->status, [
IdentityVerificationStatus::Verified->value,
IdentityVerificationStatus::Expired->value,
IdentityVerificationStatus::Cancelled->value,
], true)) {
return VerificationResult::failure($challengeId, self::ID, 'INVALID_STATE', 'identity.errors.invalid_state');
}
if ($log->isExpired()) {
$this->logRepository->updateById($log->id, [
'status' => IdentityVerificationStatus::Expired->value,
]);
return VerificationResult::failure($challengeId, self::ID, 'EXPIRED', 'identity.errors.expired');
}
if ($log->attempts >= $log->max_attempts) {
return VerificationResult::failure($challengeId, self::ID, 'MAX_ATTEMPTS', 'identity.errors.max_attempts');
}
$storedHash = $log->metadata['code_hash'] ?? $log->metadata['link_token_hash'] ?? null;
$provided = (string) ($input['code'] ?? $input['token'] ?? '');
$this->logRepository->updateById($log->id, [
'attempts' => $log->attempts + 1,
]);
if ($storedHash === null || ! Hash::check($provided, $storedHash)) {
if (($log->attempts + 1) >= $log->max_attempts) {
$this->logRepository->updateById($log->id, [
'status' => IdentityVerificationStatus::Failed->value,
]);
}
return VerificationResult::failure($challengeId, self::ID, 'INVALID_CODE', 'identity.errors.invalid_code');
}
$verifiedAt = Carbon::now();
$verificationToken = $this->generateVerificationToken($log->purpose, $log->target_hash);
$this->logRepository->updateById($log->id, [
'status' => IdentityVerificationStatus::Verified->value,
'verified_at' => $verifiedAt,
'verification_token' => $verificationToken,
]);
return VerificationResult::success(
challengeId: $challengeId,
providerId: self::ID,
verifiedAt: $verifiedAt,
identityHash: null, // 메일 프로바이더는 PII 정규화 식별자 없음 (KCP/이니시스에서만 반환)
claims: ['verification_token' => $verificationToken],
);
}
/**
* 챌린지를 취소 상태로 전환합니다.
*
* @param string $challengeId 챌린지 ID
* @return bool 성공 여부
*/
public function cancel(string $challengeId): bool
{
return $this->logRepository->updateById($challengeId, [
'status' => IdentityVerificationStatus::Cancelled->value,
]);
}
/**
* 프로바이더 설정 스키마를 반환합니다.
*
* @return array 설정 필드 정의 배열
*/
public function getSettingsSchema(): array
{
return [
'code_length' => [
'label' => __('identity.providers.mail.settings.code_length'),
'type' => 'integer',
'default' => 6,
'help' => __('identity.providers.mail.settings.code_length_help'),
],
'from_address' => [
'label' => __('identity.providers.mail.settings.from_address'),
'type' => 'string',
'default' => null,
'help' => __('identity.providers.mail.settings.from_address_help'),
],
];
}
/**
* 런타임 설정을 병합한 프로바이더 인스턴스를 반환합니다.
*
* @param array $config 병합할 설정 배열
* @return static 설정이 병합된 새 인스턴스
*/
public function withConfig(array $config): static
{
$clone = clone $this;
$clone->config = array_merge($this->config, $config);
return $clone;
}
protected function resolveRenderHint(string $purpose): string
{
return $purpose === 'password_reset' ? 'link' : 'text_code';
}
protected function generateNumericCode(int $length): string
{
$length = max(4, min(10, $length));
$code = '';
for ($i = 0; $i < $length; $i++) {
$code .= (string) random_int(0, 9);
}
return $code;
}
protected function generateVerificationToken(string $purpose, string $targetHash): string
{
return hash_hmac(
'sha256',
$purpose.'|'.$targetHash.'|'.Str::uuid()->toString(),
(string) config('app.key', 'fallback-secret')
);
}
/**
* IDV 전용 메시지 디스패처를 통해 메일을 발송합니다.
*
* @param string $email
* @param string $purpose
* @param string $renderHint text_code | link
* @param string $challengeId
* @param string|null $policyKey
* @param string|null $code text_code 흐름 시 평문 인증 코드
* @param string|null $linkToken link 흐름 시 서명 링크용 raw 토큰
* @param int $ttlMinutes
* @param Carbon $expiresAt
* @return bool 발송 성공 여부
*/
protected function dispatchMessage(
string $email,
string $purpose,
string $renderHint,
string $challengeId,
?string $policyKey,
?string $code,
?string $linkToken,
int $ttlMinutes,
Carbon $expiresAt,
): bool {
try {
$actionUrl = $linkToken !== null
? $this->buildSignedLink($challengeId, $linkToken, $expiresAt)
: null;
return app(IdentityMessageDispatcher::class)->dispatch(
providerId: self::ID,
purpose: $purpose,
policyKey: $policyKey,
renderHint: $renderHint,
channel: 'mail',
target: $email,
data: [
'code' => $code,
'action_url' => $actionUrl,
'expire_minutes' => $ttlMinutes,
'purpose_label' => $this->resolvePurposeLabel($purpose),
'app_name' => (string) config('app.name'),
'site_url' => (string) config('app.url'),
'recipient_email' => $email,
],
context: [
'challenge_id' => $challengeId,
'render_hint' => $renderHint,
],
);
} catch (\Throwable $e) {
Log::warning('[IDV] Mail dispatch failed', [
'email' => $email,
'purpose' => $purpose,
'policy_key' => $policyKey,
'message' => $e->getMessage(),
]);
return false;
}
}
/**
* link 흐름용 서명 링크를 생성합니다.
*
* @param string $challengeId
* @param string $linkToken
* @param Carbon $expiresAt
* @return string
*/
protected function buildSignedLink(string $challengeId, string $linkToken, Carbon $expiresAt): string
{
try {
return URL::temporarySignedRoute(
'api.identity.challenges.verify',
$expiresAt,
['challenge_id' => $challengeId, 'token' => $linkToken],
);
} catch (\Throwable) {
// 라우트 미존재 환경(테스트 등)에서는 경로 + query 로 fallback
return rtrim((string) config('app.url'), '/').'/identity/verify?challenge='.$challengeId.'&token='.$linkToken;
}
}
/**
* purpose 라벨(다국어)을 현재 로케일 문자열로 해석합니다.
*
* @param string $purpose
* @return string
*/
protected function resolvePurposeLabel(string $purpose): string
{
$key = 'identity.purposes.'.$purpose.'.label';
$translated = __($key);
return is_string($translated) && $translated !== $key ? $translated : $purpose;
}
}

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